diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/CompetitionRoomConfiguration.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/CompetitionRoomConfiguration.java index a7645a50..89b06fe6 100644 --- a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/CompetitionRoomConfiguration.java +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/CompetitionRoomConfiguration.java @@ -5,6 +5,7 @@ import com.idea2strategy.backend.application.common.CurrentPrincipal; import com.idea2strategy.backend.application.competition.AnonymousLeaderboardQueryService; import com.idea2strategy.backend.application.competition.OfficialCompetitionRoomCreationService; +import com.idea2strategy.backend.application.competition.OfficialBacktestCompetitionRoomCreationService; import com.idea2strategy.backend.application.competition.OperatorRoomManagementService; import com.idea2strategy.backend.application.competition.OwnedBotComparisonQueryService; import com.idea2strategy.backend.application.competition.OwnedRoomManagementQueryService; @@ -34,6 +35,7 @@ import com.idea2strategy.backend.persistence.competition.CompetitionRoomRulesJpaEntity; import com.idea2strategy.backend.persistence.competition.CompetitionRoomScheduleJpaEntity; import com.idea2strategy.backend.persistence.competition.OperatorRoomJooqAdapter; +import com.idea2strategy.backend.persistence.competition.OfficialBacktestRoomJooqCommandAdapter; import com.idea2strategy.backend.persistence.competition.OwnedRoomManagementJooqAdapter; import com.idea2strategy.backend.persistence.competition.PostEvaluationChoiceJooqAdapter; import com.idea2strategy.backend.persistence.competition.RoomConfigurationJooqAdapter; @@ -81,6 +83,7 @@ }) @Import({ CompetitionRoomJpaCommandAdapter.class, + OfficialBacktestRoomJooqCommandAdapter.class, OperatorRoomJooqAdapter.class, OwnedRoomManagementJooqAdapter.class, AnonymousLeaderboardJooqAdapter.class, @@ -329,4 +332,18 @@ OfficialCompetitionRoomCreationService officialCompetitionRoomCreationService( UUID::randomUUID, new ObjectMapper()); } + + @Bean + @ConditionalOnBean(CurrentOperatorPrincipal.class) + OfficialBacktestCompetitionRoomCreationService officialBacktestCompetitionRoomCreationService( + OfficialBacktestRoomJooqCommandAdapter commandAdapter, + ScoringTemplateCatalogService scoringCatalog, + CurrentOperatorPrincipal principal) { + return new OfficialBacktestCompetitionRoomCreationService( + commandAdapter, + scoringCatalog, + principal, + Clock.systemUTC(), + UUID::randomUUID); + } } diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/OfficialBacktestCompetitionRoomController.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/OfficialBacktestCompetitionRoomController.java new file mode 100644 index 00000000..1d373e72 --- /dev/null +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/OfficialBacktestCompetitionRoomController.java @@ -0,0 +1,129 @@ +package com.idea2strategy.backend.api.competition; + +import com.idea2strategy.backend.application.competition.BacktestEvaluationPlanDefinition; +import com.idea2strategy.backend.application.competition.CreateOfficialBacktestRoomCommand; +import com.idea2strategy.backend.application.competition.OfficialBacktestCompetitionRoomCreationService; +import com.idea2strategy.backend.domain.competition.RoomAccessType; +import com.idea2strategy.backend.domain.competition.RoomSchedule; +import com.idea2strategy.backend.domain.competition.RoomStatus; +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +/** Operator-only control plane for hidden, server-locked BACKTEST competitions. */ +@RestController +@RequestMapping("/api/v1/operations/competition/backtest-rooms") +@ConditionalOnBean(OfficialBacktestCompetitionRoomCreationService.class) +public class OfficialBacktestCompetitionRoomController { + private final OfficialBacktestCompetitionRoomCreationService service; + + public OfficialBacktestCompetitionRoomController( + OfficialBacktestCompetitionRoomCreationService service) { + this.service = service; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Response create(@RequestBody Request request) { + var room = service.create(request.toCommand()); + return new Response(room.id(), room.accessType(), room.status(), room.lockedAt()); + } + + public record Request( + String name, + RoomAccessType accessType, + UUID scoringTemplateVersionId, + BigDecimal initialCashAmount, + int botParticipationLimit, + int perAccountBotLimit, + UUID feePolicyId, + UUID buyingPowerBufferPolicyId, + Map eligibilityCriteria, + Map marketScope, + String precisionRulesVersion, + Instant recruitmentOpensAt, + Instant participationOpensAt, + Instant evaluationStartsAt, + Instant participationClosesAt, + Instant evaluationEndsAt, + Instant finalizationDeadlineAt, + String timezoneName, + String planVersion, + String planHash, + String commitmentHash, + String commitmentNonceCiphertext, + int nonceKeyVersion, + List periods) { + CreateOfficialBacktestRoomCommand toCommand() { + return new CreateOfficialBacktestRoomCommand( + name, + accessType, + scoringTemplateVersionId, + initialCashAmount, + botParticipationLimit, + perAccountBotLimit, + feePolicyId, + buyingPowerBufferPolicyId, + eligibilityCriteria, + marketScope, + precisionRulesVersion, + new RoomSchedule( + recruitmentOpensAt, + participationOpensAt, + evaluationStartsAt, + participationClosesAt, + evaluationEndsAt, + finalizationDeadlineAt, + timezoneName), + planVersion, + planHash, + commitmentHash, + commitmentNonceCiphertext, + nonceKeyVersion, + periods.stream().map(Period::toCommand).toList()); + } + } + + public record Period( + LocalDate evaluationStart, + LocalDate evaluationEnd, + BigDecimal importanceWeight, + String inputSetHash, + List datasets, + List featureMaterializations) { + CreateOfficialBacktestRoomCommand.Period toCommand() { + return new CreateOfficialBacktestRoomCommand.Period( + evaluationStart, + evaluationEnd, + importanceWeight, + inputSetHash, + datasets.stream().map(Dataset::toDefinition).toList(), + featureMaterializations.stream().map(FeatureMaterialization::toDefinition).toList()); + } + } + + public record Dataset(UUID manifestId, String purposeCode, String lockedDatasetHash) { + BacktestEvaluationPlanDefinition.Dataset toDefinition() { + return new BacktestEvaluationPlanDefinition.Dataset( + manifestId, purposeCode, lockedDatasetHash); + } + } + + public record FeatureMaterialization(UUID id, String lockedResultHash) { + BacktestEvaluationPlanDefinition.FeatureMaterialization toDefinition() { + return new BacktestEvaluationPlanDefinition.FeatureMaterialization(id, lockedResultHash); + } + } + + public record Response(UUID id, RoomAccessType accessType, RoomStatus status, Instant lockedAt) {} +} diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/competition/OfficialBacktestCompetitionRoomControllerTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/competition/OfficialBacktestCompetitionRoomControllerTest.java new file mode 100644 index 00000000..5b226ecb --- /dev/null +++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/competition/OfficialBacktestCompetitionRoomControllerTest.java @@ -0,0 +1,155 @@ +package com.idea2strategy.backend.api.competition; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.idea2strategy.backend.application.competition.BacktestEvaluationPlanDefinition; +import com.idea2strategy.backend.application.competition.OfficialBacktestCompetitionRoomCreationService; +import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogQueryPort; +import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogRecord; +import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogService; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class OfficialBacktestCompetitionRoomControllerTest { + private static final Instant NOW = Instant.parse("2026-08-10T04:00:00Z"); + private static final UUID ROOM = id(1); + private static final UUID OPERATOR = id(2); + private static final UUID TEMPLATE = id(3); + private static final AtomicReference SAVED_PLAN = new AtomicReference<>(); + + @Test + void acceptsHiddenOfficialInputsOnlyOnTheOperatorControlPlane() throws Exception { + SAVED_PLAN.set(null); + var mvc = MockMvcBuilders.standaloneSetup( + new OfficialBacktestCompetitionRoomController(service())) + .setControllerAdvice(new CompetitionRoomExceptionHandler()) + .build(); + + mvc.perform(post("/api/v1/operations/competition/backtest-rooms") + .contentType(MediaType.APPLICATION_JSON) + .content(request())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(ROOM.toString())) + .andExpect(jsonPath("$.accessType").value("PUBLIC")) + .andExpect(jsonPath("$.status").value("DRAFT")) + .andExpect(jsonPath("$.lockedAt").value(NOW.toString())); + + assertThat(SAVED_PLAN.get()).isNotNull(); + assertThat(SAVED_PLAN.get().periods()).hasSize(2); + assertThat(SAVED_PLAN.get().periods()).allSatisfy(period -> + assertThat(period.datasets()).singleElement()); + } + + private static OfficialBacktestCompetitionRoomCreationService service() { + var catalog = new ScoringTemplateCatalogService( + new Catalog(), Clock.fixed(NOW, ZoneOffset.UTC), new ObjectMapper()); + return new OfficialBacktestCompetitionRoomCreationService( + (room, plan) -> SAVED_PLAN.set(plan), + catalog, + () -> Optional.of(OPERATOR), + Clock.fixed(NOW, ZoneOffset.UTC), + () -> ROOM); + } + + private static String request() { + return """ + { + "name":"INT04-A official backtest room", + "accessType":"PUBLIC", + "scoringTemplateVersionId":"a0420000-0000-4000-8000-000000000003", + "initialCashAmount":100000.00000000, + "botParticipationLimit":10, + "perAccountBotLimit":2, + "feePolicyId":"a0420000-0000-4000-8000-000000000004", + "buyingPowerBufferPolicyId":"a0420000-0000-4000-8000-000000000005", + "eligibilityCriteria":{"minimumAccountState":"ACTIVE"}, + "marketScope":{"market":"US"}, + "precisionRulesVersion":"v1", + "recruitmentOpensAt":"2026-08-10T04:00:10Z", + "participationOpensAt":"2026-08-10T04:00:20Z", + "evaluationStartsAt":"2026-08-10T04:00:30Z", + "participationClosesAt":"2026-08-10T04:02:00Z", + "evaluationEndsAt":"2026-08-10T04:03:00Z", + "finalizationDeadlineAt":"2026-08-10T04:04:00Z", + "timezoneName":"UTC", + "planVersion":"int04-a.v1", + "planHash":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", + "commitmentHash":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "commitmentNonceCiphertext":"kms:ciphertext:int04-a", + "nonceKeyVersion":1, + "periods":[ + { + "evaluationStart":"2024-01-01", + "evaluationEnd":"2024-06-30", + "importanceWeight":0.5, + "inputSetHash":"sha256:1111111111111111111111111111111111111111111111111111111111111111", + "datasets":[{ + "manifestId":"a0420000-0000-4000-8000-000000000006", + "purposeCode":"MARKET_BARS", + "lockedDatasetHash":"sha256:2222222222222222222222222222222222222222222222222222222222222222" + }], + "featureMaterializations":[] + }, + { + "evaluationStart":"2024-07-01", + "evaluationEnd":"2024-12-31", + "importanceWeight":0.5, + "inputSetHash":"sha256:3333333333333333333333333333333333333333333333333333333333333333", + "datasets":[{ + "manifestId":"a0420000-0000-4000-8000-000000000007", + "purposeCode":"MARKET_BARS", + "lockedDatasetHash":"sha256:4444444444444444444444444444444444444444444444444444444444444444" + }], + "featureMaterializations":[] + } + ] + } + """; + } + + private static UUID id(int suffix) { + return UUID.fromString("a0420000-0000-4000-8000-" + String.format("%012d", suffix)); + } + + private static final class Catalog implements ScoringTemplateCatalogQueryPort { + private final ScoringTemplateCatalogRecord record = new ScoringTemplateCatalogRecord( + TEMPLATE, + "SINGLE_TOTAL_RETURN_V1", + "int04-a", + """ + { + "kind":"SINGLE", + "calculationRulesVersion":"official-room-scoring.v1", + "components":[ + {"metric":"TOTAL_RETURN","direction":"HIGHER_IS_BETTER","coefficient":1} + ], + "adjustments":[] + } + """, + "sha256:" + "a".repeat(64), + NOW.minusSeconds(60), + null); + + @Override + public List findSelectableAt(Instant at) { + return List.of(record); + } + + @Override + public Optional findSelectableById(UUID id, Instant at) { + return record.id().equals(id) ? Optional.of(record) : Optional.empty(); + } + } +} diff --git a/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/BacktestCompetitionSettlementBatchConfiguration.java b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/BacktestCompetitionSettlementBatchConfiguration.java new file mode 100644 index 00000000..7bde5c5e --- /dev/null +++ b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/BacktestCompetitionSettlementBatchConfiguration.java @@ -0,0 +1,43 @@ +package com.idea2strategy.backend.batch; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.idea2strategy.backend.application.competition.BacktestCompetitionSettlementService; +import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogService; +import com.idea2strategy.backend.persistence.competition.BacktestCompetitionSettlementJooqAdapter; +import com.idea2strategy.backend.persistence.competition.ScoringTemplateCatalogJooqQueryAdapter; +import java.time.Clock; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration(proxyBeanMethods = false) +@EnableScheduling +@ConditionalOnProperty( + name = "idea2strategy.batch.backtest-competition-settlement.enabled", + havingValue = "true", + matchIfMissing = true) +@Import({ScoringTemplateCatalogJooqQueryAdapter.class, BacktestCompetitionSettlementJooqAdapter.class}) +class BacktestCompetitionSettlementBatchConfiguration { + @Bean + ScoringTemplateCatalogService backtestCompetitionScoringCatalog( + ScoringTemplateCatalogJooqQueryAdapter adapter) { + return new ScoringTemplateCatalogService( + adapter, Clock.systemUTC(), new ObjectMapper().findAndRegisterModules()); + } + + @Bean + BacktestCompetitionSettlementService backtestCompetitionSettlementService( + BacktestCompetitionSettlementJooqAdapter adapter) { + return new BacktestCompetitionSettlementService(adapter, Clock.systemUTC()); + } + + @Bean + BacktestCompetitionSettlementBatchRunner backtestCompetitionSettlementBatchRunner( + BacktestCompetitionSettlementService service, + @Value("${idea2strategy.batch.backtest-competition-settlement.batch-size:100}") int batchSize) { + return new BacktestCompetitionSettlementBatchRunner(service, batchSize); + } +} diff --git a/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/BacktestCompetitionSettlementBatchRunner.java b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/BacktestCompetitionSettlementBatchRunner.java new file mode 100644 index 00000000..540fd105 --- /dev/null +++ b/apps/backend-batch/src/main/java/com/idea2strategy/backend/batch/BacktestCompetitionSettlementBatchRunner.java @@ -0,0 +1,31 @@ +package com.idea2strategy.backend.batch; + +import com.idea2strategy.backend.application.competition.BacktestCompetitionSettlementService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; + +class BacktestCompetitionSettlementBatchRunner { + private static final Logger log = LoggerFactory.getLogger(BacktestCompetitionSettlementBatchRunner.class); + private final BacktestCompetitionSettlementService service; + private final int batchSize; + + BacktestCompetitionSettlementBatchRunner( + BacktestCompetitionSettlementService service, int batchSize) { + this.service = service; + this.batchSize = batchSize; + } + + @Scheduled(fixedDelayString = "${idea2strategy.batch.backtest-competition-settlement.fixed-delay:PT10S}") + void run() { + var report = service.run(batchSize); + log.info( + "Backtest competition settlement batch completed: participantsCompleted={}, " + + "participantsFailed={}, publishedSnapshots={}, finalSnapshots={}, observedAt={}", + report.participantsCompleted(), + report.participantsFailed(), + report.publishedSnapshots(), + report.finalSnapshots(), + report.observedAt()); + } +} diff --git a/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java b/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java index 25e23718..b19450aa 100644 --- a/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java +++ b/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BackendBatchApplicationTest.java @@ -13,6 +13,7 @@ "idea2strategy.batch.pending-registration-cleanup.enabled=false", "idea2strategy.batch.room-schedule-transition.enabled=false", "idea2strategy.batch.room-evaluation-start.enabled=false", + "idea2strategy.batch.backtest-competition-settlement.enabled=false", "idea2strategy.batch.private-continuation-transition.enabled=false", "idea2strategy.batch.post-evaluation-stop-transition.enabled=false" }) diff --git a/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BacktestCompetitionSettlementBatchRunnerTest.java b/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BacktestCompetitionSettlementBatchRunnerTest.java new file mode 100644 index 00000000..5936f894 --- /dev/null +++ b/apps/backend-batch/src/test/java/com/idea2strategy/backend/batch/BacktestCompetitionSettlementBatchRunnerTest.java @@ -0,0 +1,40 @@ +package com.idea2strategy.backend.batch; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.idea2strategy.backend.application.competition.BacktestCompetitionSettlementReport; +import com.idea2strategy.backend.application.competition.BacktestCompetitionSettlementService; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; + +class BacktestCompetitionSettlementBatchRunnerTest { + private static final Instant NOW = Instant.parse("2026-08-10T04:00:00Z"); + + @Test + void productionSchedulerRunsByDefault() { + ConditionalOnProperty gate = BacktestCompetitionSettlementBatchConfiguration.class + .getAnnotation(ConditionalOnProperty.class); + + assertThat(gate.name()) + .containsExactly("idea2strategy.batch.backtest-competition-settlement.enabled"); + assertThat(gate.havingValue()).isEqualTo("true"); + assertThat(gate.matchIfMissing()).isTrue(); + } + + @Test + void forwardsTheBoundedBatchSizeToSettlement() { + var observedLimit = new AtomicInteger(); + var service = new BacktestCompetitionSettlementService((observedAt, limit) -> { + observedLimit.set(limit); + return new BacktestCompetitionSettlementReport(observedAt, 0, 0, 0, 0); + }, Clock.fixed(NOW, ZoneOffset.UTC)); + + new BacktestCompetitionSettlementBatchRunner(service, 37).run(); + + assertThat(observedLimit).hasValue(37); + } +} diff --git a/db-migration/src/main/java/com/idea2strategy/backend/migration/DatabaseAccessPolicy.java b/db-migration/src/main/java/com/idea2strategy/backend/migration/DatabaseAccessPolicy.java index 1dd353d0..e14b4451 100644 --- a/db-migration/src/main/java/com/idea2strategy/backend/migration/DatabaseAccessPolicy.java +++ b/db-migration/src/main/java/com/idea2strategy/backend/migration/DatabaseAccessPolicy.java @@ -68,6 +68,7 @@ public final class DatabaseAccessPolicy { private static final Set BATCH_UPDATED_TABLES = Set.of( new QualifiedTable("competition", "rooms"), new QualifiedTable("competition", "participations"), + new QualifiedTable("competition", "backtest_period_runs"), new QualifiedTable("bot", "bots"), new QualifiedTable("bot", "continuation_deadlines"), new QualifiedTable("identity", "accounts"), @@ -92,6 +93,10 @@ public final class DatabaseAccessPolicy { new QualifiedTable("competition", "room_events"), new QualifiedTable("competition", "participation_events"), new QualifiedTable("competition", "backtest_period_runs"), + new QualifiedTable("competition", "backtest_aggregate_results"), + new QualifiedTable("competition", "leaderboard_snapshots"), + new QualifiedTable("competition", "leaderboard_entries"), + new QualifiedTable("competition", "room_final_access_grants"), new QualifiedTable("competition", "live_evaluation_segments"), new QualifiedTable("bot", "continuation_deadlines"), new QualifiedTable("backtest", "runs"), diff --git a/db-migration/src/test/java/com/idea2strategy/backend/migration/DatabaseAccessPolicyTest.java b/db-migration/src/test/java/com/idea2strategy/backend/migration/DatabaseAccessPolicyTest.java index f8e47907..3925a6e2 100644 --- a/db-migration/src/test/java/com/idea2strategy/backend/migration/DatabaseAccessPolicyTest.java +++ b/db-migration/src/test/java/com/idea2strategy/backend/migration/DatabaseAccessPolicyTest.java @@ -276,6 +276,7 @@ void grantsTheBatchRoleTheWritesItsScheduledJobsPerform() { for (var target : List.of( new DatabaseAccessPolicy.QualifiedTable("competition", "rooms"), new DatabaseAccessPolicy.QualifiedTable("competition", "participations"), + new DatabaseAccessPolicy.QualifiedTable("competition", "backtest_period_runs"), new DatabaseAccessPolicy.QualifiedTable("bot", "bots"), new DatabaseAccessPolicy.QualifiedTable("bot", "continuation_deadlines"))) { assertTrue( @@ -290,6 +291,10 @@ void grantsTheBatchRoleTheWritesItsScheduledJobsPerform() { new DatabaseAccessPolicy.QualifiedTable("competition", "room_events"), new DatabaseAccessPolicy.QualifiedTable("competition", "participation_events"), new DatabaseAccessPolicy.QualifiedTable("competition", "backtest_period_runs"), + new DatabaseAccessPolicy.QualifiedTable("competition", "backtest_aggregate_results"), + new DatabaseAccessPolicy.QualifiedTable("competition", "leaderboard_snapshots"), + new DatabaseAccessPolicy.QualifiedTable("competition", "leaderboard_entries"), + new DatabaseAccessPolicy.QualifiedTable("competition", "room_final_access_grants"), new DatabaseAccessPolicy.QualifiedTable("competition", "live_evaluation_segments"), new DatabaseAccessPolicy.QualifiedTable("bot", "continuation_deadlines"), new DatabaseAccessPolicy.QualifiedTable("backtest", "runs"))) { diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestCompetitionSettlementPort.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestCompetitionSettlementPort.java new file mode 100644 index 00000000..b4914b87 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestCompetitionSettlementPort.java @@ -0,0 +1,7 @@ +package com.idea2strategy.backend.application.competition; + +import java.time.Instant; + +public interface BacktestCompetitionSettlementPort { + BacktestCompetitionSettlementReport settleEligible(Instant observedAt, int limit); +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestCompetitionSettlementReport.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestCompetitionSettlementReport.java new file mode 100644 index 00000000..0f7e7c77 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestCompetitionSettlementReport.java @@ -0,0 +1,19 @@ +package com.idea2strategy.backend.application.competition; + +import java.time.Instant; +import java.util.Objects; + +public record BacktestCompetitionSettlementReport( + Instant observedAt, + int participantsCompleted, + int participantsFailed, + int publishedSnapshots, + int finalSnapshots) { + public BacktestCompetitionSettlementReport { + Objects.requireNonNull(observedAt, "observedAt"); + if (participantsCompleted < 0 || participantsFailed < 0 + || publishedSnapshots < 0 || finalSnapshots < 0) { + throw new IllegalArgumentException("settlement counts must be non-negative"); + } + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestCompetitionSettlementService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestCompetitionSettlementService.java new file mode 100644 index 00000000..da44e8cf --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestCompetitionSettlementService.java @@ -0,0 +1,21 @@ +package com.idea2strategy.backend.application.competition; + +import java.time.Clock; +import java.util.Objects; + +public final class BacktestCompetitionSettlementService { + private final BacktestCompetitionSettlementPort port; + private final Clock clock; + + public BacktestCompetitionSettlementService(BacktestCompetitionSettlementPort port, Clock clock) { + this.port = Objects.requireNonNull(port, "port"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + public BacktestCompetitionSettlementReport run(int limit) { + if (limit <= 0) { + throw new IllegalArgumentException("limit must be positive"); + } + return port.settleEligible(clock.instant(), limit); + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestEvaluationPlanDefinition.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestEvaluationPlanDefinition.java new file mode 100644 index 00000000..699e2637 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/BacktestEvaluationPlanDefinition.java @@ -0,0 +1,107 @@ +package com.idea2strategy.backend.application.competition; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +public record BacktestEvaluationPlanDefinition( + String planVersion, + String planHash, + String commitmentHash, + String commitmentNonceCiphertext, + int nonceKeyVersion, + List periods) { + public BacktestEvaluationPlanDefinition { + planVersion = text(planVersion, "planVersion"); + planHash = digest(planHash, "planHash"); + commitmentHash = digest(commitmentHash, "commitmentHash"); + commitmentNonceCiphertext = text(commitmentNonceCiphertext, "commitmentNonceCiphertext"); + if (nonceKeyVersion <= 0) { + throw new IllegalArgumentException("nonceKeyVersion must be positive"); + } + periods = List.copyOf(Objects.requireNonNull(periods, "periods")); + if (periods.size() < 2) { + throw new IllegalArgumentException("a backtest competition requires at least two hidden periods"); + } + BigDecimal totalWeight = periods.stream() + .map(Period::importanceWeight) + .reduce(BigDecimal.ZERO, BigDecimal::add); + if (totalWeight.compareTo(BigDecimal.ONE) != 0) { + throw new IllegalArgumentException("hidden period importance weights must sum to one"); + } + for (int left = 0; left < periods.size(); left++) { + for (int right = left + 1; right < periods.size(); right++) { + if (periods.get(left).overlaps(periods.get(right))) { + throw new IllegalArgumentException("hidden backtest periods must not overlap"); + } + } + } + } + + public record Period( + UUID id, + int sequence, + LocalDate evaluationStart, + LocalDate evaluationEnd, + BigDecimal importanceWeight, + String inputSetHash, + List datasets, + List featureMaterializations) { + public Period { + Objects.requireNonNull(id, "id"); + if (sequence <= 0) throw new IllegalArgumentException("period sequence must be positive"); + Objects.requireNonNull(evaluationStart, "evaluationStart"); + Objects.requireNonNull(evaluationEnd, "evaluationEnd"); + if (evaluationEnd.isBefore(evaluationStart)) { + throw new IllegalArgumentException("period end must not precede its start"); + } + Objects.requireNonNull(importanceWeight, "importanceWeight"); + if (importanceWeight.signum() <= 0 || importanceWeight.compareTo(BigDecimal.ONE) > 0) { + throw new IllegalArgumentException("period importanceWeight must be in (0, 1]"); + } + inputSetHash = digest(inputSetHash, "inputSetHash"); + datasets = List.copyOf(Objects.requireNonNull(datasets, "datasets")); + featureMaterializations = List.copyOf( + Objects.requireNonNull(featureMaterializations, "featureMaterializations")); + if (datasets.isEmpty()) { + throw new IllegalArgumentException("each hidden period requires official datasets"); + } + } + + boolean overlaps(Period other) { + return !evaluationEnd.isBefore(other.evaluationStart) + && !other.evaluationEnd.isBefore(evaluationStart); + } + } + + public record Dataset(UUID manifestId, String purposeCode, String lockedDatasetHash) { + public Dataset { + Objects.requireNonNull(manifestId, "manifestId"); + purposeCode = text(purposeCode, "purposeCode"); + lockedDatasetHash = digest(lockedDatasetHash, "lockedDatasetHash"); + } + } + + public record FeatureMaterialization(UUID id, String lockedResultHash) { + public FeatureMaterialization { + Objects.requireNonNull(id, "id"); + lockedResultHash = digest(lockedResultHash, "lockedResultHash"); + } + } + + private static String digest(String value, String field) { + value = text(value, field); + if (!value.matches("sha256:[0-9a-f]{64}")) { + throw new IllegalArgumentException(field + " must be a sha256 digest"); + } + return value; + } + + private static String text(String value, String field) { + Objects.requireNonNull(value, field); + if (value.isBlank()) throw new IllegalArgumentException(field + " must not be blank"); + return value; + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/CreateOfficialBacktestRoomCommand.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/CreateOfficialBacktestRoomCommand.java new file mode 100644 index 00000000..30c8a834 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/CreateOfficialBacktestRoomCommand.java @@ -0,0 +1,61 @@ +package com.idea2strategy.backend.application.competition; + +import com.idea2strategy.backend.domain.competition.RoomAccessType; +import com.idea2strategy.backend.domain.competition.RoomSchedule; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +public record CreateOfficialBacktestRoomCommand( + String name, + RoomAccessType accessType, + UUID scoringTemplateVersionId, + BigDecimal initialCashAmount, + int botParticipationLimit, + int perAccountBotLimit, + UUID feePolicyId, + UUID buyingPowerBufferPolicyId, + Map eligibilityCriteria, + Map marketScope, + String precisionRulesVersion, + RoomSchedule schedule, + String planVersion, + String planHash, + String commitmentHash, + String commitmentNonceCiphertext, + int nonceKeyVersion, + List periods) { + public CreateOfficialBacktestRoomCommand { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(accessType, "accessType"); + Objects.requireNonNull(scoringTemplateVersionId, "scoringTemplateVersionId"); + Objects.requireNonNull(initialCashAmount, "initialCashAmount"); + Objects.requireNonNull(feePolicyId, "feePolicyId"); + Objects.requireNonNull(buyingPowerBufferPolicyId, "buyingPowerBufferPolicyId"); + eligibilityCriteria = Map.copyOf(Objects.requireNonNull(eligibilityCriteria, "eligibilityCriteria")); + marketScope = Map.copyOf(Objects.requireNonNull(marketScope, "marketScope")); + Objects.requireNonNull(precisionRulesVersion, "precisionRulesVersion"); + Objects.requireNonNull(schedule, "schedule"); + periods = List.copyOf(Objects.requireNonNull(periods, "periods")); + } + + public record Period( + LocalDate evaluationStart, + LocalDate evaluationEnd, + BigDecimal importanceWeight, + String inputSetHash, + List datasets, + List featureMaterializations) { + public Period { + Objects.requireNonNull(evaluationStart, "evaluationStart"); + Objects.requireNonNull(evaluationEnd, "evaluationEnd"); + Objects.requireNonNull(importanceWeight, "importanceWeight"); + datasets = List.copyOf(Objects.requireNonNull(datasets, "datasets")); + featureMaterializations = List.copyOf( + Objects.requireNonNull(featureMaterializations, "featureMaterializations")); + } + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OfficialBacktestCompetitionRoomCreationService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OfficialBacktestCompetitionRoomCreationService.java new file mode 100644 index 00000000..e2b8020d --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OfficialBacktestCompetitionRoomCreationService.java @@ -0,0 +1,131 @@ +package com.idea2strategy.backend.application.competition; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.idea2strategy.backend.application.common.CurrentOperatorPrincipal; +import com.idea2strategy.backend.domain.competition.CompetitionRoom; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.Map; +import java.util.Objects; +import java.util.TreeMap; +import java.util.UUID; +import java.util.function.Supplier; + +public final class OfficialBacktestCompetitionRoomCreationService { + private final OfficialBacktestRoomCommandPort commandPort; + private final ScoringTemplateCatalogService scoringCatalog; + private final CurrentOperatorPrincipal principal; + private final Clock clock; + private final Supplier roomIdSupplier; + private final ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) + .build(); + + public OfficialBacktestCompetitionRoomCreationService( + OfficialBacktestRoomCommandPort commandPort, + ScoringTemplateCatalogService scoringCatalog, + CurrentOperatorPrincipal principal, + Clock clock, + Supplier roomIdSupplier) { + this.commandPort = Objects.requireNonNull(commandPort, "commandPort"); + this.scoringCatalog = Objects.requireNonNull(scoringCatalog, "scoringCatalog"); + this.principal = Objects.requireNonNull(principal, "principal"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.roomIdSupplier = Objects.requireNonNull(roomIdSupplier, "roomIdSupplier"); + } + + public CompetitionRoom create(CreateOfficialBacktestRoomCommand command) { + Objects.requireNonNull(command, "command"); + UUID operatorId = Objects.requireNonNull(principal.operatorId(), "operatorId") + .orElseThrow(OperatorAuthorizationException::new); + var scoring = scoringCatalog.select(command.scoringTemplateVersionId(), Map.of()); + UUID roomId = roomIdSupplier.get(); + var periods = new ArrayList(); + for (int index = 0; index < command.periods().size(); index++) { + var period = command.periods().get(index); + int sequence = index + 1; + periods.add(new BacktestEvaluationPlanDefinition.Period( + UUID.nameUUIDFromBytes(("backtest-period.v1:" + roomId + ":" + sequence) + .getBytes(StandardCharsets.UTF_8)), + sequence, + period.evaluationStart(), + period.evaluationEnd(), + period.importanceWeight(), + period.inputSetHash(), + period.datasets(), + period.featureMaterializations())); + } + var plan = new BacktestEvaluationPlanDefinition( + command.planVersion(), command.planHash(), command.commitmentHash(), + command.commitmentNonceCiphertext(), command.nonceKeyVersion(), periods); + String eligibility = canonical(command.eligibilityCriteria(), "eligibilityCriteria"); + String marketScope = canonical(command.marketScope(), "marketScope"); + String scoringParameters = json(new TreeMap<>(scoring.adjustments())); + String rulesHash = hash(Map.ofEntries( + Map.entry("scoringTemplateVersionId", scoring.template().id().toString()), + Map.entry("scoringTemplateRulesHash", scoring.template().rulesHash()), + Map.entry("initialCashAmount", command.initialCashAmount().toPlainString()), + Map.entry("botParticipationLimit", command.botParticipationLimit()), + Map.entry("perAccountBotLimit", command.perAccountBotLimit()), + Map.entry("feePolicyId", command.feePolicyId().toString()), + Map.entry("buyingPowerBufferPolicyId", command.buyingPowerBufferPolicyId().toString()), + Map.entry("precisionRulesVersion", command.precisionRulesVersion()), + Map.entry("eligibilityCriteria", eligibility), + Map.entry("marketScope", marketScope), + Map.entry("planHash", plan.planHash()), + Map.entry("schedule", command.schedule().toString()))); + var room = CompetitionRoom.platformBacktest( + roomId, + operatorId, + command.name(), + command.accessType(), + scoring.template().id(), + command.initialCashAmount(), + command.botParticipationLimit(), + command.perAccountBotLimit(), + eligibility, + marketScope, + scoringParameters, + command.feePolicyId(), + command.buyingPowerBufferPolicyId(), + command.precisionRulesVersion(), + rulesHash.substring("sha256:".length()), + command.schedule(), + clock.instant()); + commandPort.save(room, plan); + return room; + } + + private String canonical(Map document, String field) { + if (document.isEmpty()) { + throw new IllegalArgumentException(field + " must be a non-empty JSON object"); + } + return json(new TreeMap<>(document)); + } + + private String hash(Object value) { + try { + return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json(value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private String json(Object value) { + try { + return mapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("official backtest room input is not JSON serializable", exception); + } + } +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OfficialBacktestRoomCommandPort.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OfficialBacktestRoomCommandPort.java new file mode 100644 index 00000000..993f7050 --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OfficialBacktestRoomCommandPort.java @@ -0,0 +1,7 @@ +package com.idea2strategy.backend.application.competition; + +import com.idea2strategy.backend.domain.competition.CompetitionRoom; + +public interface OfficialBacktestRoomCommandPort { + void save(CompetitionRoom room, BacktestEvaluationPlanDefinition plan); +} diff --git a/modules/backend-domain/src/main/java/com/idea2strategy/backend/domain/competition/CompetitionRoom.java b/modules/backend-domain/src/main/java/com/idea2strategy/backend/domain/competition/CompetitionRoom.java index 16f44c9c..6d93dd82 100644 --- a/modules/backend-domain/src/main/java/com/idea2strategy/backend/domain/competition/CompetitionRoom.java +++ b/modules/backend-domain/src/main/java/com/idea2strategy/backend/domain/competition/CompetitionRoom.java @@ -192,6 +192,52 @@ public static CompetitionRoom platformLive( createdAt); } + public static CompetitionRoom platformBacktest( + UUID id, + UUID operatorId, + String name, + RoomAccessType accessType, + UUID scoringTemplateVersionId, + BigDecimal initialCashAmount, + int botParticipationLimit, + int perAccountBotLimit, + String eligibilityDocument, + String marketScopeDocument, + String scoringParameters, + UUID feePolicyId, + UUID buyingPowerBufferPolicyId, + String precisionRulesVersion, + String rulesHash, + RoomSchedule schedule, + Instant createdAt) { + return new CompetitionRoom( + id, + CompetitionType.BACKTEST, + RoomOrganizerType.PLATFORM, + null, + operatorId, + name, + accessType, + RoomStatus.DRAFT, + scoringTemplateVersionId, + initialCashAmount, + "USD", + botParticipationLimit, + perAccountBotLimit, + eligibilityDocument, + marketScopeDocument, + scoringParameters, + feePolicyId, + 5, + buyingPowerBufferPolicyId, + precisionRulesVersion, + rulesHash, + createdAt, + null, + schedule, + createdAt); + } + private static int effectiveScale(BigDecimal value) { return Math.max(0, value.stripTrailingZeros().scale()); } diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/BacktestCompetitionSettlementJooqAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/BacktestCompetitionSettlementJooqAdapter.java new file mode 100644 index 00000000..41114fc3 --- /dev/null +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/BacktestCompetitionSettlementJooqAdapter.java @@ -0,0 +1,469 @@ +package com.idea2strategy.backend.persistence.competition; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.MapperFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.idea2strategy.backend.application.competition.BacktestCompetitionSettlementPort; +import com.idea2strategy.backend.application.competition.BacktestCompetitionSettlementReport; +import com.idea2strategy.backend.application.competition.OfficialScoringCalculator; +import com.idea2strategy.backend.application.competition.OfficialScoringMetrics; +import com.idea2strategy.backend.application.competition.OfficialScoringRank; +import com.idea2strategy.backend.application.competition.OfficialScoringRanker; +import com.idea2strategy.backend.application.competition.OfficialScoringResult; +import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogService; +import com.idea2strategy.backend.domain.competition.ScoringDirection; +import com.idea2strategy.backend.domain.competition.ScoringTemplateKind; +import com.idea2strategy.backend.domain.competition.ScoringTemplateVersion; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.jooq.DSLContext; +import org.jooq.Record; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +/** Turns terminal competition-lane runs into immutable BACKTEST aggregates and leaderboards. */ +@Repository +public class BacktestCompetitionSettlementJooqAdapter implements BacktestCompetitionSettlementPort { + private static final int METRIC_SCALE = 8; + private static final String FAILURE_MISSING_RESULT = "BACKTEST_RESULT_EVIDENCE_MISSING"; + private static final String FAILURE_INVALID_RESULT = "BACKTEST_RESULT_EVIDENCE_INVALID"; + private final DSLContext dsl; + private final ScoringTemplateCatalogService scoringCatalog; + private final OfficialScoringCalculator calculator = new OfficialScoringCalculator(); + private final OfficialScoringRanker ranker = new OfficialScoringRanker(); + private final ObjectMapper mapper = JsonMapper.builder() + .enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY) + .enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS) + .build(); + + public BacktestCompetitionSettlementJooqAdapter( + DSLContext dsl, ScoringTemplateCatalogService scoringCatalog) { + this.dsl = dsl; + this.scoringCatalog = scoringCatalog; + } + + @Override + @Transactional + public BacktestCompetitionSettlementReport settleEligible(Instant observedAt, int limit) { + OffsetDateTime observed = observedAt.atOffset(ZoneOffset.UTC); + var candidates = dsl.fetch( + "select p.id as participation_id, p.room_id, ep.period_count, " + + "rr.scoring_template_version_id " + + "from competition.participations p " + + "join competition.rooms r on r.id = p.room_id " + + "join competition.backtest_evaluation_plans ep on ep.room_id = r.id " + + "join competition.room_rules rr on rr.room_id = r.id " + + "where r.competition_type = 'BACKTEST'::competition.competition_type " + + "and r.status in ('EVALUATING'::competition.room_status, " + + "'ENDED'::competition.room_status) " + + "and p.status = 'EVALUATING'::competition.participation_status " + + "order by p.evaluation_started_at, p.id limit ? for update of p skip locked", + limit); + + int completed = 0; + int failed = 0; + for (Record candidate : candidates) { + Settlement settlement; + try { + settlement = settleParticipation(candidate, observed); + } catch (InvalidBacktestResultEvidenceException exception) { + failParticipation(candidate.get("participation_id", UUID.class), FAILURE_INVALID_RESULT, observed); + settlement = Settlement.FAILED; + } + completed += settlement == Settlement.COMPLETED ? 1 : 0; + failed += settlement == Settlement.FAILED ? 1 : 0; + } + + int published = 0; + int finals = 0; + for (Record room : leaderboardCandidates()) { + String status = room.get("status", String.class); + SnapshotWrite write = writeLeaderboard(room, observed); + if (write == SnapshotWrite.CREATED) { + if ("ENDED".equals(status)) { + finals++; + } else { + published++; + } + } + } + return new BacktestCompetitionSettlementReport( + observedAt, completed, failed, published, finals); + } + + private Settlement settleParticipation(Record candidate, OffsetDateTime observedAt) { + UUID participationId = candidate.get("participation_id", UUID.class); + UUID roomId = candidate.get("room_id", UUID.class); + int expectedPeriods = candidate.get("period_count", Integer.class); + var periods = dsl.fetch( + "select period.id as period_id, period.period_sequence, period.importance_weight, " + + "period_run.run_id, run.status::text as run_status, run.failure_code, " + + "run.result_hash as run_result_hash, summary.result_hash as summary_result_hash, " + + "summary.metrics_document::text as metrics_document, " + + "summary.calculation_rules_version " + + "from competition.backtest_evaluation_periods period " + + "left join competition.backtest_period_runs period_run " + + "on period_run.evaluation_period_id = period.id " + + "and period_run.participation_id = ? " + + "left join backtest.runs run on run.id = period_run.run_id " + + "left join backtest.performance_summaries summary on summary.run_id = run.id " + + "where period.evaluation_plan_room_id = ? " + + "order by period.period_sequence, period.id", + participationId, roomId); + if (periods.size() != expectedPeriods || periods.stream().anyMatch(row -> row.get("run_id") == null)) { + return Settlement.PENDING; + } + for (Record period : periods) { + String status = period.get("run_status", String.class); + if (List.of("FAILED", "UNAVAILABLE", "CANCELLED").contains(status)) { + failParticipation(participationId, failureCode(period), observedAt); + return Settlement.FAILED; + } + if (!"COMPLETED".equals(status)) { + return Settlement.PENDING; + } + String runHash = prefixed(period.get("run_result_hash", String.class)); + String summaryHash = prefixed(period.get("summary_result_hash", String.class)); + if (period.get("metrics_document", String.class) == null + || runHash == null || !runHash.equals(summaryHash)) { + failParticipation(participationId, FAILURE_MISSING_RESULT, observedAt); + return Settlement.FAILED; + } + } + + BigDecimal weightedReturn = BigDecimal.ZERO; + BigDecimal weightedDrawdown = BigDecimal.ZERO; + BigDecimal weightedSharpe = BigDecimal.ZERO; + boolean sharpeAvailable = true; + BigDecimal worstDrawdown = BigDecimal.ZERO; + List> periodEvidence = new ArrayList<>(); + for (Record period : periods) { + JsonNode metrics = json(period.get("metrics_document", String.class)); + BigDecimal weight = period.get("importance_weight", BigDecimal.class); + BigDecimal totalReturn = decimal(metrics, "totalReturnPct"); + BigDecimal drawdown = decimal(metrics, "maxDrawdownPct").abs(); + BigDecimal sharpe = nullableDecimal(metrics, "sharpe"); + weightedReturn = weightedReturn.add(totalReturn.multiply(weight)); + weightedDrawdown = weightedDrawdown.add(drawdown.multiply(weight)); + worstDrawdown = worstDrawdown.max(drawdown); + if (sharpe == null) { + sharpeAvailable = false; + } else { + weightedSharpe = weightedSharpe.add(sharpe.multiply(weight)); + } + String resultHash = prefixed(period.get("run_result_hash", String.class)); + Map evidence = new LinkedHashMap<>(); + evidence.put("periodId", period.get("period_id", UUID.class).toString()); + evidence.put("periodSequence", period.get("period_sequence", Integer.class)); + evidence.put("importanceWeight", weight.toPlainString()); + evidence.put("resultHash", resultHash); + evidence.put("totalReturnPct", normalized(totalReturn).toPlainString()); + evidence.put("maxDrawdownPct", normalized(drawdown).toPlainString()); + evidence.put("sharpeRatio", sharpe == null ? null : normalized(sharpe).toPlainString()); + periodEvidence.add(evidence); + } + OfficialScoringMetrics scoringMetrics = new OfficialScoringMetrics( + normalized(weightedReturn), normalized(weightedDrawdown), + sharpeAvailable ? normalized(weightedSharpe) : null); + ScoringTemplateVersion template = scoringCatalog + .select(candidate.get("scoring_template_version_id", UUID.class), Map.of()) + .template(); + BigDecimal score; + try { + score = calculator.score(template, scoringMetrics); + } catch (IllegalArgumentException exception) { + throw new InvalidBacktestResultEvidenceException("backtest metrics cannot be scored", exception); + } + String periodSetHash = hash(Map.of("periods", periodEvidence)); + Map metricsDocument = new LinkedHashMap<>(); + metricsDocument.put("schemaVersion", "backtest-competition-aggregate.v1"); + metricsDocument.put("weightedReturnPct", scoringMetrics.totalReturnPct().toPlainString()); + metricsDocument.put("weightedSharpeRatio", + scoringMetrics.sharpeRatio() == null ? null : scoringMetrics.sharpeRatio().toPlainString()); + metricsDocument.put("weightedMaxDrawdownPct", scoringMetrics.maxDrawdownPct().toPlainString()); + metricsDocument.put("worstPeriodMaxDrawdownPct", normalized(worstDrawdown).toPlainString()); + metricsDocument.put("periodResultSetHash", periodSetHash); + String aggregateHash = hash(Map.of( + "participationId", participationId.toString(), + "roomId", roomId.toString(), + "scoringTemplateVersionId", template.id().toString(), + "metrics", metricsDocument, + "score", score.toPlainString())); + for (Record period : periods) { + dsl.execute( + "update competition.backtest_period_runs set verified_at = ?::timestamptz, " + + "verification_failure_code = null, locked_result_hash = ? " + + "where participation_id = ? and evaluation_period_id = ?", + observedAt, prefixed(period.get("run_result_hash", String.class)), participationId, + period.get("period_id", UUID.class)); + } + UUID aggregateId = derivedId("backtest-competition-aggregate.v1", participationId + ":" + aggregateHash); + dsl.execute( + "insert into competition.backtest_aggregate_results " + + "(id, participation_id, evaluation_plan_room_id, scoring_template_version_id, " + + "weighted_return_pct, weighted_sharpe_ratio, weighted_max_drawdown_pct, " + + "worst_period_max_drawdown_pct, final_score, metrics_document, period_result_set_hash, " + + "calculation_rules_version, aggregate_hash, calculated_at, verified_at, published_at) " + + "values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?, ?, ?, ?::timestamptz, " + + "?::timestamptz, ?::timestamptz) on conflict (participation_id) do nothing", + aggregateId, participationId, roomId, template.id(), scoringMetrics.totalReturnPct(), + scoringMetrics.sharpeRatio(), scoringMetrics.maxDrawdownPct(), normalized(worstDrawdown), score, + json(metricsDocument), periodSetHash, OfficialScoringCalculator.CALCULATION_RULES_VERSION, + aggregateHash, observedAt, observedAt, observedAt); + Record stored = dsl.fetchOne( + "select aggregate_hash from competition.backtest_aggregate_results where participation_id = ?", + participationId); + if (stored == null || !aggregateHash.equals(stored.get("aggregate_hash", String.class))) { + throw new IllegalStateException("participation already has a different backtest aggregate"); + } + dsl.execute( + "update competition.participations set status = 'COMPLETED'::competition.participation_status, " + + "evaluation_finished_at = ?::timestamptz, evaluation_failure_code = null where id = ?", + observedAt, participationId); + appendEvent(participationId, "EVALUATION_COMPLETED", null, observedAt); + return Settlement.COMPLETED; + } + + private List leaderboardCandidates() { + return dsl.fetch( + "select r.id as room_id, r.status::text as status, r.access_type::text as access_type, " + + "r.creator_account_id, rr.scoring_template_version_id, rs.evaluation_ends_at " + + "from competition.rooms r " + + "join competition.room_rules rr on rr.room_id = r.id " + + "join competition.room_schedules rs on rs.room_id = r.id " + + "where r.competition_type = 'BACKTEST'::competition.competition_type " + + "and r.status in ('EVALUATING'::competition.room_status, 'ENDED'::competition.room_status) " + + "and not exists (select 1 from competition.room_events event " + + "where event.room_id = r.id and event.reason_code = 'INSUFFICIENT_PARTICIPATION') " + + "and ((r.status = 'EVALUATING'::competition.room_status and exists " + + "(select 1 from competition.backtest_aggregate_results aggregate where " + + "aggregate.evaluation_plan_room_id = r.id)) or " + + "(r.status = 'ENDED'::competition.room_status and not exists " + + "(select 1 from competition.participations p where p.room_id = r.id and p.status not in " + + "('COMPLETED'::competition.participation_status, " + + "'EVALUATION_FAILED'::competition.participation_status, " + + "'WITHDRAWN'::competition.participation_status, " + + "'EXPELLED'::competition.participation_status)))) " + + "order by r.id for update of r skip locked"); + } + + private SnapshotWrite writeLeaderboard(Record room, OffsetDateTime observedAt) { + UUID roomId = room.get("room_id", UUID.class); + String roomStatus = room.get("status", String.class); + String leaderboardStatus = "ENDED".equals(roomStatus) ? "FINAL" : "PUBLISHED"; + ScoringTemplateVersion template = scoringCatalog + .select(room.get("scoring_template_version_id", UUID.class), Map.of()) + .template(); + var aggregates = dsl.fetch( + "select aggregate.id as aggregate_id, aggregate.participation_id, " + + "aggregate.weighted_return_pct, aggregate.weighted_sharpe_ratio, " + + "aggregate.weighted_max_drawdown_pct, aggregate.final_score, aggregate.aggregate_hash " + + "from competition.backtest_aggregate_results aggregate " + + "join competition.participations p on p.id = aggregate.participation_id " + + "where aggregate.evaluation_plan_room_id = ? and p.status = " + + "'COMPLETED'::competition.participation_status order by aggregate.participation_id", + roomId); + List scoreable = aggregates.map(row -> new OfficialScoringResult( + row.get("participation_id", UUID.class), row.get("final_score", BigDecimal.class), + direction(template), new OfficialScoringMetrics( + row.get("weighted_return_pct", BigDecimal.class), + row.get("weighted_max_drawdown_pct", BigDecimal.class), + row.get("weighted_sharpe_ratio", BigDecimal.class)))); + List ranked = ranker.rank(scoreable); + Map counts = ranked.stream().collect(java.util.stream.Collectors.groupingBy( + OfficialScoringRank::rank, java.util.stream.Collectors.counting())); + Map byParticipation = new LinkedHashMap<>(); + aggregates.forEach(row -> byParticipation.put(row.get("participation_id", UUID.class), row)); + List> entries = new ArrayList<>(); + for (OfficialScoringRank rank : ranked) { + Record aggregate = byParticipation.get(rank.result().participationId()); + Map entry = new LinkedHashMap<>(); + entry.put("participationId", rank.result().participationId().toString()); + entry.put("aggregateId", aggregate.get("aggregate_id", UUID.class).toString()); + entry.put("aggregateHash", aggregate.get("aggregate_hash", String.class)); + entry.put("rank", rank.rank()); + entry.put("jointRank", counts.get(rank.rank()) > 1); + entry.put("score", rank.result().score().toPlainString()); + entries.add(entry); + } + String resultHash = hash(Map.of( + "schemaVersion", "backtest-competition-leaderboard.v1", + "roomId", roomId.toString(), + "status", leaderboardStatus, + "scoringTemplateVersionId", template.id().toString(), + "entries", entries)); + if (Boolean.TRUE.equals(dsl.fetchValue( + "select exists(select 1 from competition.leaderboard_snapshots " + + "where room_id = ? and status = ?::competition.leaderboard_status and result_hash = ?)", + roomId, leaderboardStatus, resultHash))) { + return SnapshotWrite.UNCHANGED; + } + OffsetDateTime cutoff = "FINAL".equals(leaderboardStatus) + ? room.get("evaluation_ends_at", OffsetDateTime.class) : observedAt; + UUID snapshotId = derivedId( + "backtest-competition-leaderboard.v1", roomId + ":" + leaderboardStatus + ":" + resultHash); + dsl.execute( + "insert into competition.leaderboard_snapshots " + + "(id, room_id, scoring_template_version_id, cutoff_at, status, result_hash, created_at) " + + "values (?, ?, ?, ?::timestamptz, ?::competition.leaderboard_status, ?, ?::timestamptz)", + snapshotId, roomId, template.id(), cutoff, leaderboardStatus, resultHash, observedAt); + for (Map entry : entries) { + UUID participationId = UUID.fromString(entry.get("participationId").toString()); + UUID aggregateId = UUID.fromString(entry.get("aggregateId").toString()); + dsl.execute( + "insert into competition.leaderboard_entries " + + "(snapshot_id, participation_id, backtest_aggregate_result_id, rank, is_joint_rank, " + + "eligibility_status, score, tie_break_document, calculation_document) " + + "values (?, ?, ?, ?, ?, 'ELIGIBLE', ?, ?::jsonb, ?::jsonb)", + snapshotId, participationId, aggregateId, entry.get("rank"), entry.get("jointRank"), + new BigDecimal(entry.get("score").toString()), + json(Map.of("aggregateHash", entry.get("aggregateHash"))), + json(Map.of( + "schemaVersion", "backtest-competition-leaderboard-entry.v1", + "aggregateHash", entry.get("aggregateHash")))); + } + if ("FINAL".equals(leaderboardStatus) && "SECRET".equals(room.get("access_type", String.class))) { + freezeSecretAccess(roomId, snapshotId, observedAt, room.get("creator_account_id", UUID.class)); + } + return SnapshotWrite.CREATED; + } + + private void freezeSecretAccess( + UUID roomId, UUID snapshotId, OffsetDateTime observedAt, UUID creatorAccountId) { + Map grants = new LinkedHashMap<>(); + if (creatorAccountId != null) { + grants.put(creatorAccountId, "CREATOR"); + } + dsl.fetch("select distinct owner_account_id from competition.participations where room_id = ? " + + "and status not in ('WITHDRAWN'::competition.participation_status, " + + "'EXPELLED'::competition.participation_status)", roomId) + .forEach(row -> grants.putIfAbsent(row.get("owner_account_id", UUID.class), "ACTIVE_PARTICIPANT")); + for (var grant : grants.entrySet()) { + dsl.execute( + "insert into competition.room_final_access_grants " + + "(room_id, account_id, snapshot_id, eligibility_basis, granted_at) " + + "values (?, ?, ?, ?, ?::timestamptz) on conflict (room_id, account_id) do nothing", + roomId, grant.getKey(), snapshotId, grant.getValue(), observedAt); + } + } + + private void failParticipation(UUID participationId, String reason, OffsetDateTime observedAt) { + dsl.execute( + "update competition.participations set status = " + + "'EVALUATION_FAILED'::competition.participation_status, " + + "evaluation_finished_at = ?::timestamptz, evaluation_failure_code = ? where id = ?", + observedAt, bounded(reason), participationId); + appendEvent(participationId, "EVALUATION_FAILED", bounded(reason), observedAt); + } + + private void appendEvent( + UUID participationId, String eventType, String reason, OffsetDateTime observedAt) { + int sequence = ((Number) dsl.fetchValue( + "select coalesce(max(event_sequence), 0) + 1 from competition.participation_events " + + "where participation_id = ?", participationId)).intValue(); + String material = participationId + ":" + eventType + ":" + sequence; + dsl.execute( + "insert into competition.participation_events " + + "(id, participation_id, event_sequence, event_type, reason_code, occurred_at, " + + "payload_document) values (?, ?, ?, ?, ?, ?::timestamptz, " + + "jsonb_build_object('reasonCode', ?::text))", + derivedId("backtest-competition-participation-event.v1", material), participationId, + sequence, eventType, reason, observedAt, reason); + } + + private ScoringDirection direction(ScoringTemplateVersion template) { + return template.kind() == ScoringTemplateKind.SINGLE + ? template.components().getFirst().direction() : ScoringDirection.HIGHER_IS_BETTER; + } + + private String failureCode(Record period) { + String code = period.get("failure_code", String.class); + return code == null || code.isBlank() ? "BACKTEST_RUN_" + period.get("run_status", String.class) : code; + } + + private JsonNode json(String value) { + try { + return mapper.readTree(value); + } catch (JsonProcessingException exception) { + throw new InvalidBacktestResultEvidenceException("backtest metrics are not valid JSON", exception); + } + } + + private String json(Object value) { + try { + return mapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("settlement evidence is not JSON serializable", exception); + } + } + + private String hash(Object value) { + try { + return "sha256:" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json(value).getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + private static BigDecimal decimal(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || !value.isNumber()) { + throw new InvalidBacktestResultEvidenceException("backtest metric is missing: " + field); + } + return value.decimalValue(); + } + + private static BigDecimal nullableDecimal(JsonNode node, String field) { + JsonNode value = node.get(field); + return value == null || value.isNull() ? null : decimal(node, field); + } + + private static BigDecimal normalized(BigDecimal value) { + return value.setScale(METRIC_SCALE, RoundingMode.HALF_EVEN); + } + + private static String prefixed(String value) { + if (value == null || value.isBlank()) { + return null; + } + return value.startsWith("sha256:") ? value : "sha256:" + value; + } + + private static String bounded(String value) { + return value.substring(0, Math.min(80, value.length())); + } + + private static UUID derivedId(String kind, String material) { + return UUID.nameUUIDFromBytes((kind + ":" + material).getBytes(StandardCharsets.UTF_8)); + } + + private enum Settlement { PENDING, COMPLETED, FAILED } + private enum SnapshotWrite { UNCHANGED, CREATED } + + private static final class InvalidBacktestResultEvidenceException extends RuntimeException { + private InvalidBacktestResultEvidenceException(String message) { + super(message); + } + + private InvalidBacktestResultEvidenceException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/OfficialBacktestRoomJooqCommandAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/OfficialBacktestRoomJooqCommandAdapter.java new file mode 100644 index 00000000..c36b99f0 --- /dev/null +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/OfficialBacktestRoomJooqCommandAdapter.java @@ -0,0 +1,141 @@ +package com.idea2strategy.backend.persistence.competition; + +import com.idea2strategy.backend.application.competition.BacktestEvaluationPlanDefinition; +import com.idea2strategy.backend.application.competition.OfficialBacktestRoomCommandPort; +import com.idea2strategy.backend.domain.competition.CompetitionRoom; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import org.jooq.DSLContext; +import org.jooq.Record; +import org.springframework.transaction.annotation.Transactional; + +/** Atomically locks an official BACKTEST room and every hidden evaluation input. */ +public class OfficialBacktestRoomJooqCommandAdapter implements OfficialBacktestRoomCommandPort { + private final DSLContext dsl; + + public OfficialBacktestRoomJooqCommandAdapter(DSLContext dsl) { + this.dsl = dsl; + } + + @Override + @Transactional + public void save(CompetitionRoom room, BacktestEvaluationPlanDefinition plan) { + OffsetDateTime createdAt = room.createdAt().atOffset(ZoneOffset.UTC); + int policyCount = dsl.fetch( + "select version from backtest.execution_policy_versions " + + "where policy_document ->> 'competitionPlanHash' = ? " + + "and locked_at <= ?::timestamptz and retired_at is null", + plan.planHash(), createdAt).size(); + if (policyCount != 1) { + throw new IllegalArgumentException( + "planHash must resolve to exactly one locked competition execution policy"); + } + for (var period : plan.periods()) { + validatePeriodInputs(period, createdAt); + } + + dsl.execute( + "insert into competition.rooms " + + "(id, competition_type, organizer_type, created_by_operator_id, name, access_type, " + + "status, created_at) values (?, 'BACKTEST', 'PLATFORM', ?, ?, " + + "?::competition.room_access_type, 'DRAFT', ?::timestamptz)", + room.id(), room.createdByOperatorId(), room.name(), room.accessType().name(), createdAt); + dsl.execute( + "insert into competition.room_rules " + + "(room_id, scoring_template_version_id, initial_cash_amount, currency_code, " + + "bot_participation_limit, per_account_bot_limit, eligibility_document, " + + "market_scope_document, scoring_parameters, fee_policy_id, slippage_rate_bps, " + + "buying_power_buffer_policy_id, precision_rules_version, rules_hash, locked_at) " + + "values (?, ?, ?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?, ?, ?, ?, ?, ?::timestamptz)", + room.id(), room.scoringTemplateVersionId(), room.initialCashAmount(), room.currencyCode(), + room.botParticipationLimit(), room.perAccountBotLimit(), room.eligibilityDocument(), + room.marketScopeDocument(), room.scoringParameters(), room.feePolicyId(), room.slippageRateBps(), + room.buyingPowerBufferPolicyId(), room.precisionRulesVersion(), room.rulesHash(), createdAt); + dsl.execute( + "insert into competition.room_schedules " + + "(room_id, recruitment_opens_at, participation_opens_at, evaluation_starts_at, " + + "participation_closes_at, evaluation_ends_at, finalization_deadline_at, timezone_name) " + + "values (?, ?::timestamptz, ?::timestamptz, ?::timestamptz, ?::timestamptz, " + + "?::timestamptz, ?::timestamptz, ?)", + room.id(), utc(room.schedule().recruitmentOpensAt()), utc(room.schedule().participationOpensAt()), + utc(room.schedule().evaluationStartsAt()), utc(room.schedule().participationClosesAt()), + utc(room.schedule().evaluationEndsAt()), utc(room.schedule().finalizationDeadlineAt()), + room.schedule().timezoneName()); + dsl.execute( + "insert into competition.backtest_evaluation_plans " + + "(room_id, plan_version, period_count, plan_hash, commitment_hash, " + + "commitment_nonce_ciphertext, nonce_key_version, locked_at) " + + "values (?, ?, ?, ?, ?, ?, ?, ?::timestamptz)", + room.id(), plan.planVersion(), plan.periods().size(), plan.planHash(), plan.commitmentHash(), + plan.commitmentNonceCiphertext(), plan.nonceKeyVersion(), createdAt); + for (var period : plan.periods()) { + dsl.execute( + "insert into competition.backtest_evaluation_periods " + + "(id, evaluation_plan_room_id, period_sequence, evaluation_start, evaluation_end, " + + "importance_weight, input_set_hash) values (?, ?, ?, ?, ?, ?, ?)", + period.id(), room.id(), period.sequence(), period.evaluationStart(), period.evaluationEnd(), + period.importanceWeight(), period.inputSetHash()); + for (var dataset : period.datasets()) { + dsl.execute( + "insert into competition.backtest_period_datasets " + + "(evaluation_period_id, dataset_manifest_id, purpose_code, locked_dataset_hash) " + + "values (?, ?, ?, ?)", + period.id(), dataset.manifestId(), dataset.purposeCode(), dataset.lockedDatasetHash()); + } + for (var feature : period.featureMaterializations()) { + dsl.execute( + "insert into competition.backtest_period_feature_materializations " + + "(evaluation_period_id, feature_materialization_id, locked_result_hash) " + + "values (?, ?, ?)", + period.id(), feature.id(), feature.lockedResultHash()); + } + } + } + + private void validatePeriodInputs( + BacktestEvaluationPlanDefinition.Period period, OffsetDateTime createdAt) { + for (var dataset : period.datasets()) { + Record stored = dsl.fetchOne( + "select dataset_hash, status::text as status, available_at, " + + "period_start::date as period_start, period_end::date as period_end " + + "from market_data.dataset_manifests where id = ?", + dataset.manifestId()); + if (stored == null + || !"AVAILABLE".equals(stored.get("status", String.class)) + || stored.get("available_at", OffsetDateTime.class) == null + || stored.get("available_at", OffsetDateTime.class).isAfter(createdAt) + || !dataset.lockedDatasetHash().equals(prefixed(stored.get("dataset_hash", String.class))) + || stored.get("period_start", java.time.LocalDate.class).isAfter(period.evaluationStart()) + || stored.get("period_end", java.time.LocalDate.class).isBefore(period.evaluationEnd())) { + throw new IllegalArgumentException( + "hidden period dataset is unavailable, changed, or does not cover its period"); + } + } + for (var feature : period.featureMaterializations()) { + Record stored = dsl.fetchOne( + "select result_hash, status::text as status, available_at, period_start, period_end " + + "from market_data.feature_materializations where id = ?", + feature.id()); + if (stored == null + || !"SUCCEEDED".equals(stored.get("status", String.class)) + || stored.get("available_at", OffsetDateTime.class) == null + || stored.get("available_at", OffsetDateTime.class).isAfter(createdAt) + || !feature.lockedResultHash().equals(prefixed(stored.get("result_hash", String.class))) + || stored.get("period_start", OffsetDateTime.class).toLocalDate() + .isAfter(period.evaluationStart()) + || stored.get("period_end", OffsetDateTime.class).toLocalDate() + .isBefore(period.evaluationEnd())) { + throw new IllegalArgumentException( + "hidden period feature materialization is unavailable, changed, or incomplete"); + } + } + } + + private static OffsetDateTime utc(java.time.Instant instant) { + return instant.atOffset(ZoneOffset.UTC); + } + + private static String prefixed(String value) { + return value == null || value.startsWith("sha256:") ? value : "sha256:" + value; + } +} diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/RoomScheduleTransitionJooqAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/RoomScheduleTransitionJooqAdapter.java index 75cad2c9..7f5a8ebb 100644 --- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/RoomScheduleTransitionJooqAdapter.java +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/RoomScheduleTransitionJooqAdapter.java @@ -112,15 +112,22 @@ && endIfInsufficient(roomId, "EVALUATING", participationClosesAt, observed)) { private boolean endIfInsufficient( UUID roomId, String expectedStatus, OffsetDateTime scheduledAt, OffsetDateTime observedAt) { + String competitionType = dsl.fetchOne( + "select competition_type::text as competition_type from competition.rooms where id = ?", + roomId) + .get("competition_type", String.class); + boolean backtest = "BACKTEST".equals(competitionType); var participations = dsl.fetch( "select p.id, p.bot_id, p.owner_account_id, p.status::text as status, " + "b.lifecycle_status::text as lifecycle_status " + "from competition.participations p join bot.bots b on b.id = p.bot_id " + "where p.room_id = ? and p.status in " + "('REGISTERED'::competition.participation_status, " - + "'EVALUATING'::competition.participation_status) " + + "'EVALUATING'::competition.participation_status, " + + "case when ? then 'PENDING_LEDGER'::competition.participation_status " + + "else 'REGISTERED'::competition.participation_status end) " + "order by p.id for update of p, b", - roomId); + roomId, backtest); if (participations.size() >= 2) { return false; } @@ -145,7 +152,7 @@ private boolean endIfInsufficient( + "withdrawn_at = ?::timestamptz, withdrawal_reason_code = ? where id = ?", observedAt, INSUFFICIENT_PARTICIPATION, participationId); participationEvent(participationId, roomId, botId, observedAt); - if ("RUNNING".equals(participation.get("lifecycle_status", String.class))) { + if (!backtest && "RUNNING".equals(participation.get("lifecycle_status", String.class))) { continuePrivately(botId, ownerAccountId, participationStatus, observedAt); } } diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/BacktestCompetitionSettlementPersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/BacktestCompetitionSettlementPersistenceIntegrationTest.java new file mode 100644 index 00000000..7c65e739 --- /dev/null +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/BacktestCompetitionSettlementPersistenceIntegrationTest.java @@ -0,0 +1,358 @@ +package com.idea2strategy.backend.persistence.competition; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.idea2strategy.backend.application.competition.AnonymousLeaderboardQueryService; +import com.idea2strategy.backend.application.competition.BacktestCompetitionSettlementService; +import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogService; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@Testcontainers(disabledWithoutDocker = true) +@org.springframework.boot.test.context.SpringBootTest( + classes = BacktestCompetitionSettlementPersistenceIntegrationTest.TestApplication.class) +class BacktestCompetitionSettlementPersistenceIntegrationTest { + private static final Instant NOW = Instant.parse("2026-08-10T03:00:00Z"); + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine"); + + @DynamicPropertySource + static void databaseProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "none"); + registry.add("spring.flyway.enabled", () -> "true"); + } + + @Autowired BacktestCompetitionSettlementJooqAdapter settlement; + @Autowired AnonymousLeaderboardJooqAdapter leaderboards; + @Autowired JdbcTemplate jdbc; + + @Test + void aggregatesEverySuccessfulPeriodFailsOnlyTheBrokenParticipationAndPublishesFinalBacktestRanks() { + seedJourney(); + var service = new BacktestCompetitionSettlementService( + settlement, Clock.fixed(NOW, ZoneOffset.UTC)); + + var first = service.run(100); + + assertThat(first.participantsCompleted()).isOne(); + assertThat(first.participantsFailed()).isEqualTo(2); + assertThat(first.publishedSnapshots()).isOne(); + assertThat(first.finalSnapshots()).isZero(); + assertThat(text("select status::text from competition.participations where id = ?", GOOD_PARTICIPATION)) + .isEqualTo("COMPLETED"); + assertThat(text("select status::text from competition.participations where id = ?", BAD_PARTICIPATION)) + .isEqualTo("EVALUATION_FAILED"); + assertThat(text("select evaluation_failure_code from competition.participations where id = ?", BAD_PARTICIPATION)) + .isEqualTo("PERMANENT_INPUT_FAILURE"); + assertThat(text("select evaluation_failure_code from competition.participations where id = ?", INVALID_PARTICIPATION)) + .isEqualTo("BACKTEST_RESULT_EVIDENCE_INVALID"); + assertThat(count("select count(*) from competition.backtest_period_runs " + + "where participation_id = ? and verified_at is not null", INVALID_PARTICIPATION)).isZero(); + assertThat(count("select count(*) from competition.backtest_aggregate_results")).isOne(); + assertThat(decimal("select weighted_return_pct from competition.backtest_aggregate_results")) + .isEqualByComparingTo("15.00000000"); + assertThat(decimal("select weighted_max_drawdown_pct from competition.backtest_aggregate_results")) + .isEqualByComparingTo("4.00000000"); + assertThat(decimal("select weighted_sharpe_ratio from competition.backtest_aggregate_results")) + .isEqualByComparingTo("1.50000000"); + assertThat(count("select count(*) from competition.backtest_period_runs " + + "where participation_id = ? and verified_at is not null and locked_result_hash is not null", + GOOD_PARTICIPATION)).isEqualTo(2); + + UUID aggregateId = jdbc.queryForObject( + "select id from competition.backtest_aggregate_results where participation_id = ?", + UUID.class, GOOD_PARTICIPATION); + UUID publishedSnapshot = jdbc.queryForObject( + "select id from competition.leaderboard_snapshots where room_id = ? and status = 'PUBLISHED'", + UUID.class, ROOM); + assertThat(jdbc.queryForObject( + "select backtest_aggregate_result_id from competition.leaderboard_entries " + + "where snapshot_id = ? and participation_id = ?", + UUID.class, publishedSnapshot, GOOD_PARTICIPATION)).isEqualTo(aggregateId); + assertThat(count("select count(*) from competition.leaderboard_entries " + + "where snapshot_id = ? and performance_snapshot_id is not null", publishedSnapshot)).isZero(); + + assertThatThrownBy(() -> jdbc.update( + "update competition.leaderboard_entries set backtest_aggregate_result_id = null, " + + "performance_snapshot_id = ? where snapshot_id = ? and participation_id = ?", + LIVE_PERFORMANCE, publishedSnapshot, GOOD_PARTICIPATION)) + .isInstanceOf(DataIntegrityViolationException.class) + .hasMessageContaining("BACKTEST leaderboard requires a backtest aggregate result"); + + var published = new AnonymousLeaderboardQueryService(leaderboards, () -> OUTSIDER) + .query(ROOM, null, 20); + assertThat(published.snapshotStatus()).isEqualTo("PUBLISHED"); + assertThat(published.items()).singleElement().satisfies(item -> { + assertThat(item.rank()).isOne(); + assertThat(item.anonymousAlias()).isEqualTo("int04-a-good"); + assertThat(item.totalReturnPct()).isEqualByComparingTo("15.00000000"); + assertThat(item.viewerEvidence()).isNull(); + }); + + jdbc.update( + "update competition.rooms set status = 'ENDED'::competition.room_status, ended_at = ? " + + "where id = ?", + utc(NOW), ROOM); + var finalized = service.run(100); + assertThat(finalized.finalSnapshots()).isOne(); + assertThat(count("select count(*) from competition.leaderboard_snapshots " + + "where room_id = ? and status = 'FINAL'", ROOM)).isOne(); + var finalPage = new AnonymousLeaderboardQueryService(leaderboards, () -> OUTSIDER) + .query(ROOM, null, 20); + assertThat(finalPage.snapshotStatus()).isEqualTo("FINAL"); + assertThat(finalPage.items()).singleElement(); + + var duplicate = service.run(100); + assertThat(duplicate.participantsCompleted()).isZero(); + assertThat(duplicate.participantsFailed()).isZero(); + assertThat(duplicate.publishedSnapshots()).isZero(); + assertThat(duplicate.finalSnapshots()).isZero(); + assertThat(count("select count(*) from competition.backtest_aggregate_results")).isOne(); + assertThat(count("select count(*) from competition.leaderboard_snapshots where room_id = ?", ROOM)) + .isEqualTo(2); + } + + private void seedJourney() { + jdbc.update( + "insert into identity.accounts (id, lifecycle_status, status_changed_at) values " + + "(?, 'ACTIVE', ?), (?, 'ACTIVE', ?), (?, 'ACTIVE', ?), (?, 'ACTIVE', ?)", + GOOD_OWNER, utc(NOW.minusSeconds(100)), BAD_OWNER, utc(NOW.minusSeconds(100)), + INVALID_OWNER, utc(NOW.minusSeconds(100)), OUTSIDER, utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into operations.operator_accounts " + + "(id, external_identity_key_hmac, external_identity_key_version, status, " + + "mfa_enrolled_at, created_at) values (?, 'int04-a-operator', 1, 'ACTIVE', ?, ?)", + OPERATOR, utc(NOW.minusSeconds(100)), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into competition.scoring_template_versions " + + "(id, template_code, version, rules_document, rules_hash, published_at) values " + + "(?, 'SINGLE_TOTAL_RETURN_V1', 'int04-a', ?::jsonb, ?, ?)", + TEMPLATE, + "{\"kind\":\"SINGLE\",\"calculationRulesVersion\":\"official-room-scoring.v1\"," + + "\"components\":[{\"metric\":\"TOTAL_RETURN\"," + + "\"direction\":\"HIGHER_IS_BETTER\",\"coefficient\":1}],\"adjustments\":[]}", + hash('a'), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into trading.fee_policy_versions " + + "(id, policy_code, version, fee_rate_bps, calculation_rules_version, rules_hash, " + + "effective_from, published_at) values (?, 'INT04_A', '1', 20, 'v1', ?, ?, ?)", + FEE, hash('b'), utc(NOW.minusSeconds(100)), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into trading.buying_power_buffer_policy_versions " + + "(id, policy_code, version, buffer_bps, rounding_rules_version, rules_hash, " + + "effective_from, published_at) values (?, 'INT04_A', '1', 0, 'v1', ?, ?, ?)", + BUFFER, hash('c'), utc(NOW.minusSeconds(100)), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into competition.rooms " + + "(id, competition_type, organizer_type, created_by_operator_id, name, access_type, " + + "status, created_at) values " + + "(?, 'BACKTEST', 'PLATFORM', ?, 'INT04-A official', 'PUBLIC', 'EVALUATING', ?)", + ROOM, OPERATOR, utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into competition.room_rules " + + "(room_id, scoring_template_version_id, initial_cash_amount, bot_participation_limit, " + + "per_account_bot_limit, eligibility_document, market_scope_document, scoring_parameters, " + + "fee_policy_id, slippage_rate_bps, buying_power_buffer_policy_id, precision_rules_version, " + + "rules_hash, locked_at) values (?, ?, 100000, 10, 2, '{}'::jsonb, '{}'::jsonb, " + + "'{}'::jsonb, ?, 5, ?, 'v1', ?, ?)", + ROOM, TEMPLATE, FEE, BUFFER, hash('d'), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into competition.room_schedules " + + "(room_id, recruitment_opens_at, participation_opens_at, evaluation_starts_at, " + + "participation_closes_at, evaluation_ends_at, finalization_deadline_at, timezone_name) " + + "values (?, ?, ?, ?, ?, ?, ?, 'UTC')", + ROOM, utc(NOW.minusSeconds(100)), utc(NOW.minusSeconds(90)), utc(NOW.minusSeconds(80)), + utc(NOW.plusSeconds(60)), utc(NOW.plusSeconds(120)), utc(NOW.plusSeconds(180))); + jdbc.update( + "insert into competition.backtest_evaluation_plans " + + "(room_id, plan_version, period_count, plan_hash, commitment_hash, " + + "commitment_nonce_ciphertext, nonce_key_version, locked_at) " + + "values (?, 'int04-a.v1', 2, ?, ?, 'ciphertext', 1, ?)", + ROOM, hash('e'), hash('f'), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into backtest.execution_policy_versions " + + "(version, policy_artifact_hash, policy_document, locked_at) " + + "values ('competition-v1', ?, jsonb_build_object('competitionPlanHash', ?), ?)", + hash('0'), hash('e'), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into competition.backtest_evaluation_periods " + + "(id, evaluation_plan_room_id, period_sequence, evaluation_start, evaluation_end, " + + "importance_weight, input_set_hash) values " + + "(?, ?, 1, '2024-01-01', '2024-06-30', 0.5, ?), " + + "(?, ?, 2, '2024-07-01', '2024-12-31', 0.5, ?)", + PERIOD_ONE, ROOM, hash('1'), PERIOD_TWO, ROOM, hash('2')); + seedBotAndParticipation(GOOD_BOT, GOOD_OWNER, GOOD_PARTICIPATION, "int04-a-good"); + seedBotAndParticipation(BAD_BOT, BAD_OWNER, BAD_PARTICIPATION, "int04-a-bad"); + seedRun(GOOD_RUN_ONE, GOOD_BOT, GOOD_OWNER, GOOD_PARTICIPATION, PERIOD_ONE, + "COMPLETED", null, hash('3'), "10", "-2", "1"); + seedRun(GOOD_RUN_TWO, GOOD_BOT, GOOD_OWNER, GOOD_PARTICIPATION, PERIOD_TWO, + "COMPLETED", null, hash('4'), "20", "-6", "2"); + seedRun(BAD_RUN_ONE, BAD_BOT, BAD_OWNER, BAD_PARTICIPATION, PERIOD_ONE, + "COMPLETED", null, hash('5'), "30", "-3", "3"); + seedRun(BAD_RUN_TWO, BAD_BOT, BAD_OWNER, BAD_PARTICIPATION, PERIOD_TWO, + "FAILED", "PERMANENT_INPUT_FAILURE", null, null, null, null); + seedBotAndParticipation(INVALID_BOT, INVALID_OWNER, INVALID_PARTICIPATION, "int04-a-invalid"); + seedRun(INVALID_RUN_ONE, INVALID_BOT, INVALID_OWNER, INVALID_PARTICIPATION, PERIOD_ONE, + "COMPLETED", null, hash('c'), "7", "-1", "1"); + seedRun(INVALID_RUN_TWO, INVALID_BOT, INVALID_OWNER, INVALID_PARTICIPATION, PERIOD_TWO, + "COMPLETED", null, hash('d'), "8", "-2", "1"); + jdbc.update( + "update backtest.performance_summaries set metrics_document = " + + "metrics_document - 'totalReturnPct' where run_id = ?", + INVALID_RUN_TWO); + jdbc.update( + "insert into performance.bot_snapshots " + + "(id, bot_id, snapshot_type, source_event_sequence, evaluated_at, equity_amount, " + + "total_return_pct, max_drawdown_pct, sharpe_ratio, metrics_document, input_hash, " + + "calculation_rules_version, snapshot_hash, created_at) values " + + "(?, ?, 'LEADERBOARD_CUTOFF', 1, ?, 100000, 99, 1, 1, '{}'::jsonb, ?, 'v1', ?, ?)", + LIVE_PERFORMANCE, GOOD_BOT, utc(NOW.minusSeconds(1)), hash('6'), hash('7'), + utc(NOW.minusSeconds(1))); + } + + private void seedBotAndParticipation( + UUID botId, UUID ownerId, UUID participationId, String alias) { + jdbc.update( + "insert into bot.bots " + + "(id, owner_account_id, mode, name, lifecycle_status, lifecycle_changed_at, " + + "execution_eligible_from, created_at, edit_sequence, updated_at) " + + "values (?, ?, 'BASIC', ?, 'RUNNING', ?, ?, ?, 0, ?)", + botId, ownerId, alias, utc(NOW.minusSeconds(100)), utc(NOW.minusSeconds(100)), + utc(NOW.minusSeconds(100)), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into competition.participations " + + "(id, room_id, bot_id, owner_account_id, anonymous_alias, status, joined_at, " + + "evaluation_started_at) values (?, ?, ?, ?, ?, 'EVALUATING', ?, ?)", + participationId, ROOM, botId, ownerId, alias, + utc(NOW.minusSeconds(90)), utc(NOW.minusSeconds(80))); + } + + private void seedRun( + UUID runId, + UUID botId, + UUID ownerId, + UUID participationId, + UUID periodId, + String status, + String failureCode, + String resultHash, + String totalReturn, + String maxDrawdown, + String sharpe) { + UUID messageId = UUID.nameUUIDFromBytes(("message:" + runId).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + jdbc.update( + "insert into backtest.runs " + + "(id, lane, message_id, bot_id, owner_account_id, configuration_hash, " + + "canonical_payload_hash, aggregate_sequence, status, evaluation_start, evaluation_end, " + + "initial_cash_amount, market_rules_version, accounting_rules_version, " + + "execution_policy_version, precision_rules_version, fee_policy_id, slippage_rate_bps, " + + "buying_power_buffer_policy_id, idempotency_scope, idempotency_key, queued_at, " + + "completed_at, failure_code, result_hash) values " + + "(?, 'COMPETITION', ?, ?, ?, ?, ?, 1, ?::backtest.run_status, '2024-01-01', " + + "'2024-12-31', 100000, 'v1', 'v1', 'competition-v1', 'v1', ?, 5, ?, ?, ?, ?, ?, ?, ?)", + runId, messageId, botId, ownerId, hash('8'), hash('9'), status, FEE, BUFFER, + participationId.toString(), "int04-a:" + runId, utc(NOW.minusSeconds(70)), + utc(NOW.minusSeconds(10)), failureCode, resultHash); + jdbc.update( + "insert into competition.backtest_period_runs " + + "(participation_id, evaluation_period_id, run_id) values (?, ?, ?)", + participationId, periodId, runId); + if ("COMPLETED".equals(status)) { + jdbc.update( + "insert into backtest.performance_summaries " + + "(run_id, metric_catalog_version, metrics_document, calculation_rules_version, " + + "source_set_hash, input_hash, result_hash, calculated_at) values " + + "(?, 'backtest-metrics.v1', jsonb_build_object('totalReturnPct', ?::numeric, " + + "'maxDrawdownPct', ?::numeric, 'sharpe', ?::numeric), 'backtest.v1', ?, ?, ?, ?)", + runId, new BigDecimal(totalReturn), new BigDecimal(maxDrawdown), new BigDecimal(sharpe), + hash('a'), hash('b'), resultHash, utc(NOW.minusSeconds(10))); + } + } + + private int count(String sql, Object... args) { + return jdbc.queryForObject(sql, Integer.class, args); + } + + private String text(String sql, Object... args) { + return jdbc.queryForObject(sql, String.class, args); + } + + private BigDecimal decimal(String sql, Object... args) { + return jdbc.queryForObject(sql, BigDecimal.class, args); + } + + private static java.time.OffsetDateTime utc(Instant instant) { + return instant.atOffset(ZoneOffset.UTC); + } + + private static String hash(char digit) { + return "sha256:" + Character.toString(digit).repeat(64); + } + + private static UUID id(int suffix) { + return UUID.fromString("a0400000-0000-4000-8000-" + String.format("%012d", suffix)); + } + + private static final UUID GOOD_OWNER = id(1); + private static final UUID BAD_OWNER = id(2); + private static final UUID OUTSIDER = id(3); + private static final UUID OPERATOR = id(4); + private static final UUID TEMPLATE = id(5); + private static final UUID FEE = id(6); + private static final UUID BUFFER = id(7); + private static final UUID ROOM = id(8); + private static final UUID PERIOD_ONE = id(9); + private static final UUID PERIOD_TWO = id(10); + private static final UUID GOOD_BOT = id(11); + private static final UUID BAD_BOT = id(12); + private static final UUID GOOD_PARTICIPATION = id(13); + private static final UUID BAD_PARTICIPATION = id(14); + private static final UUID GOOD_RUN_ONE = id(15); + private static final UUID GOOD_RUN_TWO = id(16); + private static final UUID BAD_RUN_ONE = id(17); + private static final UUID BAD_RUN_TWO = id(18); + private static final UUID LIVE_PERFORMANCE = id(19); + private static final UUID INVALID_OWNER = id(20); + private static final UUID INVALID_BOT = id(21); + private static final UUID INVALID_PARTICIPATION = id(22); + private static final UUID INVALID_RUN_ONE = id(23); + private static final UUID INVALID_RUN_TWO = id(24); + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({ + ScoringTemplateCatalogJooqQueryAdapter.class, + BacktestCompetitionSettlementJooqAdapter.class, + AnonymousLeaderboardJooqAdapter.class + }) + static class TestApplication { + @Bean + ScoringTemplateCatalogService scoringTemplateCatalogService( + ScoringTemplateCatalogJooqQueryAdapter adapter) { + return new ScoringTemplateCatalogService( + adapter, Clock.fixed(NOW, ZoneOffset.UTC), new ObjectMapper()); + } + } +} diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/OfficialBacktestRoomCreationPersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/OfficialBacktestRoomCreationPersistenceIntegrationTest.java new file mode 100644 index 00000000..1ffdffbc --- /dev/null +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/OfficialBacktestRoomCreationPersistenceIntegrationTest.java @@ -0,0 +1,229 @@ +package com.idea2strategy.backend.persistence.competition; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.idea2strategy.backend.application.competition.BacktestEvaluationPlanDefinition; +import com.idea2strategy.backend.application.competition.CreateOfficialBacktestRoomCommand; +import com.idea2strategy.backend.application.competition.OfficialBacktestCompetitionRoomCreationService; +import com.idea2strategy.backend.application.competition.ScoringTemplateCatalogService; +import com.idea2strategy.backend.domain.competition.RoomAccessType; +import com.idea2strategy.backend.domain.competition.RoomSchedule; +import java.math.BigDecimal; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.postgresql.PostgreSQLContainer; + +@Testcontainers(disabledWithoutDocker = true) +@org.springframework.boot.test.context.SpringBootTest( + classes = OfficialBacktestRoomCreationPersistenceIntegrationTest.TestApplication.class) +class OfficialBacktestRoomCreationPersistenceIntegrationTest { + private static final Instant NOW = Instant.parse("2026-08-10T04:00:00Z"); + + @Container + static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine"); + + @DynamicPropertySource + static void databaseProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); + registry.add("spring.datasource.username", POSTGRES::getUsername); + registry.add("spring.datasource.password", POSTGRES::getPassword); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "none"); + registry.add("spring.flyway.enabled", () -> "true"); + } + + @Autowired OfficialBacktestRoomJooqCommandAdapter commandAdapter; + @Autowired ScoringTemplateCatalogService scoringCatalog; + @Autowired JdbcTemplate jdbc; + + @Test + void createsTheRoomAndAllHiddenInputsAtomicallyOnlyAfterProductionPreflight() { + seedCatalogAndInputs(); + var created = service(ROOM).create(command(hash('4'))); + + assertThat(created.competitionType().name()).isEqualTo("BACKTEST"); + assertThat(created.status().name()).isEqualTo("DRAFT"); + assertThat(text("select competition_type::text from competition.rooms where id = ?", ROOM)) + .isEqualTo("BACKTEST"); + assertThat(count("select count(*) from competition.backtest_evaluation_plans where room_id = ?", ROOM)) + .isOne(); + assertThat(count("select count(*) from competition.backtest_evaluation_periods " + + "where evaluation_plan_room_id = ?", ROOM)).isEqualTo(2); + assertThat(count("select count(*) from competition.backtest_period_datasets dataset " + + "join competition.backtest_evaluation_periods period " + + "on period.id = dataset.evaluation_period_id where period.evaluation_plan_room_id = ?", ROOM)) + .isEqualTo(2); + assertThat(count("select count(*) from competition.live_room_rules where room_id = ?", ROOM)).isZero(); + + assertThatThrownBy(() -> service(BAD_ROOM).create(command(hash('9')))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("dataset is unavailable, changed, or does not cover"); + assertThat(count("select count(*) from competition.rooms where id = ?", BAD_ROOM)).isZero(); + assertThat(count("select count(*) from competition.backtest_evaluation_plans where room_id = ?", BAD_ROOM)) + .isZero(); + } + + private OfficialBacktestCompetitionRoomCreationService service(UUID roomId) { + return new OfficialBacktestCompetitionRoomCreationService( + commandAdapter, + scoringCatalog, + () -> Optional.of(OPERATOR), + Clock.fixed(NOW, ZoneOffset.UTC), + () -> roomId); + } + + private CreateOfficialBacktestRoomCommand command(String lockedDatasetHash) { + var dataset = new BacktestEvaluationPlanDefinition.Dataset( + DATASET, "MARKET_BARS", lockedDatasetHash); + return new CreateOfficialBacktestRoomCommand( + "INT04-A locked official room", + RoomAccessType.PUBLIC, + TEMPLATE, + new BigDecimal("100000.00000000"), + 10, + 2, + FEE, + BUFFER, + Map.of("minimumAccountState", "ACTIVE"), + Map.of("market", "US"), + "v1", + new RoomSchedule( + NOW.plusSeconds(10), + NOW.plusSeconds(20), + NOW.plusSeconds(30), + NOW.plusSeconds(120), + NOW.plusSeconds(180), + NOW.plusSeconds(240), + "UTC"), + "int04-a-plan.v1", + PLAN_HASH, + hash('5'), + "kms:ciphertext:int04-a", + 1, + List.of( + new CreateOfficialBacktestRoomCommand.Period( + LocalDate.parse("2024-01-01"), + LocalDate.parse("2024-06-30"), + new BigDecimal("0.5"), + hash('6'), + List.of(dataset), + List.of()), + new CreateOfficialBacktestRoomCommand.Period( + LocalDate.parse("2024-07-01"), + LocalDate.parse("2024-12-31"), + new BigDecimal("0.5"), + hash('7'), + List.of(dataset), + List.of()))); + } + + private void seedCatalogAndInputs() { + jdbc.update( + "insert into operations.operator_accounts " + + "(id, external_identity_key_hmac, external_identity_key_version, status, " + + "mfa_enrolled_at, created_at) values (?, 'int04-a-room-operator', 1, 'ACTIVE', ?, ?)", + OPERATOR, utc(NOW.minusSeconds(100)), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into competition.scoring_template_versions " + + "(id, template_code, version, rules_document, rules_hash, published_at) values " + + "(?, 'SINGLE_TOTAL_RETURN_V1', 'int04-a-room', ?::jsonb, ?, ?)", + TEMPLATE, + "{\"kind\":\"SINGLE\",\"calculationRulesVersion\":\"official-room-scoring.v1\"," + + "\"components\":[{\"metric\":\"TOTAL_RETURN\"," + + "\"direction\":\"HIGHER_IS_BETTER\",\"coefficient\":1}],\"adjustments\":[]}", + hash('1'), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into trading.fee_policy_versions " + + "(id, policy_code, version, fee_rate_bps, calculation_rules_version, rules_hash, " + + "effective_from, published_at) values (?, 'INT04_A_ROOM', '1', 20, 'v1', ?, ?, ?)", + FEE, hash('2'), utc(NOW.minusSeconds(100)), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into trading.buying_power_buffer_policy_versions " + + "(id, policy_code, version, buffer_bps, rounding_rules_version, rules_hash, " + + "effective_from, published_at) values (?, 'INT04_A_ROOM', '1', 0, 'v1', ?, ?, ?)", + BUFFER, hash('3'), utc(NOW.minusSeconds(100)), utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into backtest.execution_policy_versions " + + "(version, policy_artifact_hash, policy_document, locked_at) " + + "values ('int04-a-room-v1', ?, jsonb_build_object('competitionPlanHash', ?), ?)", + hash('8'), PLAN_HASH, utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into market_data.providers " + + "(id, code, display_name, rights_version, status, created_at) " + + "values (?, 'INT04_A_PROVIDER', 'INT04-A official', 'internal-v1', 'ACTIVE', ?)", + PROVIDER, utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into market_data.feeds " + + "(id, provider_id, code, data_kind, resolution, timezone_name, feed_version, created_at) " + + "values (?, ?, 'INT04_A_DAILY', 'BAR', '1d', 'UTC', 'v1', ?)", + FEED, PROVIDER, utc(NOW.minusSeconds(100))); + jdbc.update( + "insert into market_data.dataset_manifests " + + "(id, feed_id, data_layer, resolution, revision_number, status, period_start, period_end, " + + "schema_version, dataset_hash, created_at, available_at) values " + + "(?, ?, 'ADJUSTED', '1d', 1, 'AVAILABLE', '2023-12-01T00:00:00Z', " + + "'2025-01-31T23:59:59Z', 'v1', ?, ?, ?)", + DATASET, FEED, "4".repeat(64), utc(NOW.minusSeconds(100)), utc(NOW.minusSeconds(100))); + } + + private int count(String sql, Object... args) { + return jdbc.queryForObject(sql, Integer.class, args); + } + + private String text(String sql, Object... args) { + return jdbc.queryForObject(sql, String.class, args); + } + + private static java.time.OffsetDateTime utc(Instant instant) { + return instant.atOffset(ZoneOffset.UTC); + } + + private static String hash(char digit) { + return "sha256:" + Character.toString(digit).repeat(64); + } + + private static UUID id(int suffix) { + return UUID.fromString("a0410000-0000-4000-8000-" + String.format("%012d", suffix)); + } + + private static final UUID OPERATOR = id(1); + private static final UUID TEMPLATE = id(2); + private static final UUID FEE = id(3); + private static final UUID BUFFER = id(4); + private static final UUID PROVIDER = id(5); + private static final UUID FEED = id(6); + private static final UUID DATASET = id(7); + private static final UUID ROOM = id(8); + private static final UUID BAD_ROOM = id(9); + private static final String PLAN_HASH = "sha256:" + "e".repeat(64); + + @SpringBootConfiguration + @EnableAutoConfiguration + @Import({ScoringTemplateCatalogJooqQueryAdapter.class, OfficialBacktestRoomJooqCommandAdapter.class}) + static class TestApplication { + @Bean + ScoringTemplateCatalogService scoringTemplateCatalogService( + ScoringTemplateCatalogJooqQueryAdapter adapter) { + return new ScoringTemplateCatalogService( + adapter, Clock.fixed(NOW, ZoneOffset.UTC), new ObjectMapper()); + } + } +} diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/RoomScheduleTransitionPersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/RoomScheduleTransitionPersistenceIntegrationTest.java index 6d1adc91..12462057 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/RoomScheduleTransitionPersistenceIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/RoomScheduleTransitionPersistenceIntegrationTest.java @@ -30,6 +30,7 @@ class RoomScheduleTransitionPersistenceIntegrationTest { private static final UUID OWNER_ID = UUID.fromString("10000000-0000-4000-8000-000000000084"); private static final UUID BOT_OWNER_ID = UUID.fromString("10000000-0000-4000-8000-000000000085"); + private static final UUID OPERATOR_ID = UUID.fromString("10000000-0000-4000-8000-000000000086"); private static final UUID ROOM_ID = UUID.fromString("20000000-0000-4000-8000-000000000084"); private static final Instant RECRUITMENT = Instant.parse("2026-08-02T01:00:00Z"); private static final Instant EVALUATION = Instant.parse("2026-08-02T02:00:00Z"); @@ -64,12 +65,20 @@ void clean() { jdbc.update("delete from competition.room_events"); jdbc.update("delete from competition.room_schedules"); jdbc.update("delete from competition.rooms"); + jdbc.update("delete from operations.operator_accounts where id = ?", OPERATOR_ID); jdbc.update("delete from bot.launch_snapshots"); jdbc.update("delete from bot.bots"); jdbc.update("truncate table identity.account_lifecycle_command_receipts, identity.account_lifecycle_events cascade"); jdbc.update("delete from identity.accounts"); jdbc.update("insert into identity.accounts (id, lifecycle_status) values (?, 'ACTIVE')", OWNER_ID); jdbc.update("insert into identity.accounts (id, lifecycle_status) values (?, 'ACTIVE')", BOT_OWNER_ID); + jdbc.update( + "insert into operations.operator_accounts " + + "(id, external_identity_key_hmac, external_identity_key_version, status, " + + "mfa_enrolled_at, created_at) values (?, 'operator-room-schedule', 1, 'ACTIVE', ?, ?)", + OPERATOR_ID, + RECRUITMENT.minusSeconds(7200).atOffset(ZoneOffset.UTC), + RECRUITMENT.minusSeconds(7200).atOffset(ZoneOffset.UTC)); } @Test @@ -202,14 +211,64 @@ void preservesAStoppingBotWhenAnUnderSubscribedRoomEnds() { .isZero(); } + @Test + void countsPendingBacktestLedgersAndNeverStartsAnUnderSubscribedBacktestBotPrivately() { + seedBacktestRoom("EVALUATING"); + UUID firstBotId = seedActiveParticipation(1, BOT_OWNER_ID, "RUNNING"); + UUID secondBotId = seedActiveParticipation(2, OWNER_ID, "RUNNING"); + jdbc.update( + "update competition.participations set status = 'PENDING_LEDGER' where bot_id in (?, ?)", + firstBotId, + secondBotId); + + assertThat(adapter.advanceDue(EVALUATION.plusSeconds(5), 10).transitionsApplied()).isZero(); + assertThat(jdbc.queryForObject( + "select status::text from competition.rooms where id = ?", String.class, ROOM_ID)) + .isEqualTo("EVALUATING"); + + jdbc.update("delete from competition.participations where bot_id = ?", secondBotId); + assertThat(adapter.advanceDue(EVALUATION.plusSeconds(10), 10).transitionsApplied()).isEqualTo(1); + assertThat(jdbc.queryForObject( + "select status::text from competition.participations where bot_id = ?", + String.class, + firstBotId)) + .isEqualTo("WITHDRAWN"); + assertThat(jdbc.queryForObject( + "select count(*) from bot.continuation_deadlines where bot_id = ?", + Integer.class, + firstBotId)) + .isZero(); + assertThat(jdbc.queryForObject( + "select count(*) from operations.outbox_messages where event_type = 'BOT_RUN_COMMAND'", + Integer.class)) + .isZero(); + } + private void seedRoom(String status) { + seedRoom(status, "LIVE_PAPER", "USER"); + } + + private void seedBacktestRoom(String status) { + seedRoom(status, "BACKTEST", "PLATFORM"); + } + + private void seedRoom(String status, String competitionType, String organizerType) { var createdAt = RECRUITMENT.minusSeconds(3600).atOffset(ZoneOffset.UTC); - jdbc.update( - "insert into competition.rooms " - + "(id, competition_type, organizer_type, creator_account_id, name, access_type, status, created_at) " - + "values (?, 'LIVE_PAPER', 'USER', ?, 'Schedule room', 'PUBLIC', " - + "?::competition.room_status, ?::timestamptz)", - ROOM_ID, OWNER_ID, status, createdAt); + if ("BACKTEST".equals(competitionType)) { + jdbc.update( + "insert into competition.rooms " + + "(id, competition_type, organizer_type, created_by_operator_id, name, access_type, " + + "status, created_at) values (?, 'BACKTEST', ?::competition.organizer_type, ?, " + + "'Schedule room', 'PUBLIC', ?::competition.room_status, ?::timestamptz)", + ROOM_ID, organizerType, OPERATOR_ID, status, createdAt); + } else { + jdbc.update( + "insert into competition.rooms " + + "(id, competition_type, organizer_type, creator_account_id, name, access_type, status, " + + "created_at) values (?, 'LIVE_PAPER', ?::competition.organizer_type, ?, " + + "'Schedule room', 'PUBLIC', ?::competition.room_status, ?::timestamptz)", + ROOM_ID, organizerType, OWNER_ID, status, createdAt); + } jdbc.update( "insert into competition.room_schedules " + "(room_id, recruitment_opens_at, participation_opens_at, evaluation_starts_at, "