From 1f6f2d7622f011aba91e16d3f2bc4af859a58b95 Mon Sep 17 00:00:00 2001 From: HJ <16863475+hjcud@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:26:04 +0900 Subject: [PATCH] feat: select official backtest inputs automatically --- .../CompetitionRoomConfiguration.java | 3 + .../strategy/StrategyReleaseController.java | 21 --- .../StrategyReleaseControllerTest.java | 21 --- ...tegyReleaseInputCatalogControllerTest.java | 7 +- .../ImmutableStrategyReleaseCommand.java | 16 -- ...mmutableStrategyReleaseCommandService.java | 24 ++- .../OfficialBacktestInputSelector.java | 151 ++++++++++++++++++ .../strategy/OfficialBacktestRequest.java | 31 +++- .../strategy/StrategyReleaseInputCatalog.java | 9 +- .../StrategyBotIndependentE2ETest.java | 19 ++- ...ableStrategyReleaseCommandServiceTest.java | 24 ++- .../OfficialBacktestInputSelectorTest.java | 88 ++++++++++ .../v1/StrategyBotContractFixtures.java | 14 ++ .../v1/official-backtest-request.valid.json | 7 + ...ableStrategyReleaseJooqCommandAdapter.java | 74 ++++++--- ...gyReleaseInputCatalogJooqQueryAdapter.java | 28 +++- ...tegyReleasePersistenceIntegrationTest.java | 23 ++- ...fficialBacktestRequestHashFixtureTest.java | 10 +- 18 files changed, 451 insertions(+), 119 deletions(-) create mode 100644 modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/OfficialBacktestInputSelector.java create mode 100644 modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/OfficialBacktestInputSelectorTest.java 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 7cd2a38b..a7645a50 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 @@ -49,6 +49,7 @@ import com.idea2strategy.backend.persistence.strategy.ImmutableStrategyReleaseJooqCommandAdapter; import com.idea2strategy.backend.persistence.backtest.FeatureMaterializationPinResolver; import com.idea2strategy.backend.persistence.strategy.StrategyDocumentJooqQueryAdapter; +import com.idea2strategy.backend.persistence.strategy.StrategyReleaseInputCatalogJooqQueryAdapter; import com.idea2strategy.backend.persistence.strategy.StrategyJooqQueryAdapter; import com.idea2strategy.backend.persistence.strategy.StrategyValidationRunJooqQueryAdapter; import java.time.Clock; @@ -233,6 +234,7 @@ ImmutableStrategyReleaseCommandService roomImmutableStrategyReleaseCommandServic StrategyValidationRunJooqQueryAdapter validationAdapter, StrategyJooqQueryAdapter strategyAdapter, StrategyDocumentJooqQueryAdapter documentAdapter, + StrategyReleaseInputCatalogJooqQueryAdapter releaseInputs, CurrentPrincipal principal) { return new ImmutableStrategyReleaseCommandService( releaseAdapter, @@ -240,6 +242,7 @@ ImmutableStrategyReleaseCommandService roomImmutableStrategyReleaseCommandServic validationAdapter, strategyAdapter, documentAdapter, + releaseInputs, principal, Clock.systemUTC()); } diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/StrategyReleaseController.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/StrategyReleaseController.java index 746e9cc8..4a2cf185 100644 --- a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/StrategyReleaseController.java +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/strategy/StrategyReleaseController.java @@ -55,23 +55,9 @@ public record ReleaseRequest( UUID validationRunId, BigDecimal initialCashAmount, int budgetCapBps, - String brokerRulesVersion, - String accountingRulesVersion, - String precisionRulesVersion, - UUID feePolicyId, - UUID buyingPowerBufferPolicyId, - UUID datasetManifestId, - String executionPolicyVersion, Map candidateConflictPolicy) { ImmutableStrategyReleaseCommand toCommand(UUID releaseId) { require(initialCashAmount, "initialCashAmount"); - require(brokerRulesVersion, "brokerRulesVersion"); - require(accountingRulesVersion, "accountingRulesVersion"); - require(precisionRulesVersion, "precisionRulesVersion"); - require(feePolicyId, "feePolicyId"); - require(buyingPowerBufferPolicyId, "buyingPowerBufferPolicyId"); - require(datasetManifestId, "datasetManifestId"); - require(executionPolicyVersion, "executionPolicyVersion"); require(candidateConflictPolicy, "candidateConflictPolicy"); if (budgetCapBps <= 0 || budgetCapBps > 10_000) { throw new IllegalArgumentException("budgetCapBps must be in 1..10000"); @@ -80,13 +66,6 @@ ImmutableStrategyReleaseCommand toCommand(UUID releaseId) { releaseId, initialCashAmount, budgetCapBps, - brokerRulesVersion, - accountingRulesVersion, - precisionRulesVersion, - feePolicyId, - buyingPowerBufferPolicyId, - datasetManifestId, - executionPolicyVersion, json(candidateConflictPolicy)); } diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/StrategyReleaseControllerTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/StrategyReleaseControllerTest.java index 1a50ea07..cb94b7a3 100644 --- a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/StrategyReleaseControllerTest.java +++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/StrategyReleaseControllerTest.java @@ -27,10 +27,6 @@ class StrategyReleaseControllerTest { private static final UUID STRATEGY_ID = UUID.fromString("20000000-0000-4000-8000-000000000001"); private static final UUID VALIDATION_ID = UUID.fromString("30000000-0000-4000-8000-000000000001"); private static final UUID BOT_ID = UUID.fromString("50000000-0000-4000-8000-000000000002"); - private static final UUID FEE_ID = UUID.fromString("80000000-0000-4000-8000-000000000001"); - private static final UUID BUFFER_ID = UUID.fromString("90000000-0000-4000-8000-000000000001"); - private static final UUID DATASET_ID = UUID.fromString("a0000000-0000-4000-8000-000000000001"); - private ImmutableStrategyReleaseCommandService releaseService; private BasicStrategyCatalogQueryService catalogService; private MockMvc mvc; @@ -57,13 +53,6 @@ void releasesTheValidatedStrategyAndStartsItsOfficialBasicBacktest() throws Exce "validationRunId":"30000000-0000-4000-8000-000000000001", "initialCashAmount":100000.00, "budgetCapBps":10000, - "brokerRulesVersion":"broker/v1", - "accountingRulesVersion":"accounting/v1", - "precisionRulesVersion":"precision/v1", - "feePolicyId":"80000000-0000-4000-8000-000000000001", - "buyingPowerBufferPolicyId":"90000000-0000-4000-8000-000000000001", - "datasetManifestId":"a0000000-0000-4000-8000-000000000001", - "executionPolicyVersion":"backtest-policy-v1", "candidateConflictPolicy":{"policy":"FIRST_WINS"} } """)) @@ -77,9 +66,6 @@ void releasesTheValidatedStrategyAndStartsItsOfficialBasicBacktest() throws Exce assertThat(command.getValue().releaseId()) .isEqualTo(StrategyReleaseController.releaseId(VALIDATION_ID)); assertThat(command.getValue().initialCashAmount()).isEqualByComparingTo("100000.00"); - assertThat(command.getValue().feePolicyId()).isEqualTo(FEE_ID); - assertThat(command.getValue().buyingPowerBufferPolicyId()).isEqualTo(BUFFER_ID); - assertThat(command.getValue().datasetManifestId()).isEqualTo(DATASET_ID); assertThat(command.getValue().candidateConflictPolicy()).isEqualTo("{\"policy\":\"FIRST_WINS\"}"); } @@ -126,13 +112,6 @@ private static String validRequest() { "validationRunId":"30000000-0000-4000-8000-000000000001", "initialCashAmount":100000.00, "budgetCapBps":10000, - "brokerRulesVersion":"broker/v1", - "accountingRulesVersion":"accounting/v1", - "precisionRulesVersion":"precision/v1", - "feePolicyId":"80000000-0000-4000-8000-000000000001", - "buyingPowerBufferPolicyId":"90000000-0000-4000-8000-000000000001", - "datasetManifestId":"a0000000-0000-4000-8000-000000000001", - "executionPolicyVersion":"backtest-policy-v1", "candidateConflictPolicy":{"policy":"FIRST_WINS"} } """; diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/StrategyReleaseInputCatalogControllerTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/StrategyReleaseInputCatalogControllerTest.java index 089d41d3..55856e6e 100644 --- a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/StrategyReleaseInputCatalogControllerTest.java +++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/strategy/StrategyReleaseInputCatalogControllerTest.java @@ -27,10 +27,13 @@ void exposesOnlyServerObservedImmutableReleaseInputs() throws Exception { when(service.getSelectable()).thenReturn(new StrategyReleaseInputCatalog( List.of(new ExecutionPolicy( "policy-v1", "market-v1", "accounting-v1", "precision-v1", - feeId, 20, bufferId, 1)), + feeId, 20, bufferId, 1, + LocalDate.parse("2025-01-01"), LocalDate.parse("2026-01-01"), + "market-bars-v2", Instant.parse("2026-08-07T11:00:00Z"))), List.of(new Dataset( datasetId, "alpaca-sip", "ADJUSTED", "1m", - LocalDate.parse("2025-01-01"), LocalDate.parse("2026-01-01"), "market-bars-v2")), + LocalDate.parse("2025-01-01"), LocalDate.parse("2026-01-01"), "market-bars-v2", + Instant.parse("2026-08-07T11:30:00Z"))), Instant.parse("2026-08-07T12:00:00Z"))); MockMvcBuilders.standaloneSetup(new StrategyReleaseInputCatalogController(service)) diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommand.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommand.java index 828376d9..78d8fa35 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommand.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommand.java @@ -8,26 +8,10 @@ public record ImmutableStrategyReleaseCommand( UUID releaseId, BigDecimal initialCashAmount, int budgetCapBps, - String brokerRulesVersion, - String accountingRulesVersion, - String precisionRulesVersion, - UUID feePolicyId, - UUID buyingPowerBufferPolicyId, - UUID datasetManifestId, - String executionPolicyVersion, String candidateConflictPolicy) { public ImmutableStrategyReleaseCommand { Objects.requireNonNull(releaseId, "releaseId"); Objects.requireNonNull(initialCashAmount, "initialCashAmount"); - Objects.requireNonNull(brokerRulesVersion, "brokerRulesVersion"); - Objects.requireNonNull(accountingRulesVersion, "accountingRulesVersion"); - Objects.requireNonNull(precisionRulesVersion, "precisionRulesVersion"); - Objects.requireNonNull(feePolicyId, "feePolicyId"); - Objects.requireNonNull(buyingPowerBufferPolicyId, "buyingPowerBufferPolicyId"); - Objects.requireNonNull(datasetManifestId, "datasetManifestId"); - if (executionPolicyVersion == null || executionPolicyVersion.isBlank()) { - throw new IllegalArgumentException("executionPolicyVersion must not be blank"); - } candidateConflictPolicy = StrategyDocumentJson.canonicalize(candidateConflictPolicy); } } diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommandService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommandService.java index ea510747..f414ac79 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommandService.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommandService.java @@ -33,6 +33,7 @@ public final class ImmutableStrategyReleaseCommandService { private final StrategyValidationRunQueryPort validationPort; private final StrategyQueryPort strategyPort; private final StrategyDocumentQueryPort documentPort; + private final StrategyReleaseInputCatalogQueryPort releaseInputs; private final CurrentPrincipal principal; private final Clock clock; private final ObjectMapper objectMapper = new ObjectMapper() @@ -45,6 +46,7 @@ public ImmutableStrategyReleaseCommandService( StrategyValidationRunQueryPort validationPort, StrategyQueryPort strategyPort, StrategyDocumentQueryPort documentPort, + StrategyReleaseInputCatalogQueryPort releaseInputs, CurrentPrincipal principal, Clock clock) { this.releasePort = Objects.requireNonNull(releasePort, "releasePort"); @@ -52,6 +54,7 @@ public ImmutableStrategyReleaseCommandService( this.validationPort = Objects.requireNonNull(validationPort, "validationPort"); this.strategyPort = Objects.requireNonNull(strategyPort, "strategyPort"); this.documentPort = Objects.requireNonNull(documentPort, "documentPort"); + this.releaseInputs = Objects.requireNonNull(releaseInputs, "releaseInputs"); this.principal = Objects.requireNonNull(principal, "principal"); this.clock = Objects.requireNonNull(clock, "clock"); } @@ -61,24 +64,31 @@ public ImmutableStrategyRelease release( BasicStrategyCatalog catalog, ImmutableStrategyReleaseCommand command) { Objects.requireNonNull(command, "command"); + var releasedAt = clock.instant(); + var inputCatalog = releaseInputs.findSelectableAt(releasedAt); + var policy = OfficialBacktestInputSelector.selectPolicy(inputCatalog); var preparation = new ImmutableStrategyReleasePreparationCommand( command.releaseId(), command.initialCashAmount(), command.budgetCapBps(), - command.brokerRulesVersion(), - command.accountingRulesVersion(), - command.precisionRulesVersion(), - command.feePolicyId(), - command.buyingPowerBufferPolicyId(), + policy.brokerRulesVersion(), + policy.accountingRulesVersion(), + policy.precisionRulesVersion(), + policy.feePolicyId(), + policy.buyingPowerBufferPolicyId(), command.candidateConflictPolicy()); - var release = prepare(validationRunId, catalog, preparation, clock.instant()); + var release = prepare(validationRunId, catalog, preparation, releasedAt); + var selectedInputs = OfficialBacktestInputSelector.select( + release.contractPlan().planDocument(), inputCatalog); var validation = validationPort.findOwnedById(validationRunId, principal.accountId()) .orElseThrow(() -> new NoSuchElementException("Strategy validation not found")); // prepare() already compiled the plan and assembled the contract the release publishes, and the // request now takes its checksum from that contract. Compiling a second time here produced a // digest of a different artifact and was the cause of root #439. var backtestRequest = OfficialBacktestRequest.forRelease( - release, command.datasetManifestId(), command.executionPolicyVersion()); + release, + selectedInputs.datasets().stream().map(StrategyReleaseInputCatalog.Dataset::id).toList(), + selectedInputs.policy().version()); return releasePort.saveOnce( release, backtestRequest, validationRunId, validation.requestedEditSequence(), validation.semanticHash()); diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/OfficialBacktestInputSelector.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/OfficialBacktestInputSelector.java new file mode 100644 index 00000000..05f31daa --- /dev/null +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/OfficialBacktestInputSelector.java @@ -0,0 +1,151 @@ +package com.idea2strategy.backend.application.strategy; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.idea2strategy.backend.application.strategy.StrategyReleaseInputCatalog.Dataset; +import com.idea2strategy.backend.application.strategy.StrategyReleaseInputCatalog.ExecutionPolicy; +import java.time.Duration; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Selects the complete immutable official input set; callers never choose catalog identifiers. */ +public final class OfficialBacktestInputSelector { + private static final ObjectMapper JSON = new ObjectMapper(); + + private OfficialBacktestInputSelector() {} + + public static Selection select(String compiledPlanDocument, StrategyReleaseInputCatalog catalog) { + ExecutionPolicy policy = selectPolicy(catalog); + Set requiredResolutions = requiredResolutions(compiledPlanDocument); + + return selectDatasets(policy, requiredResolutions, catalog); + } + + public static ExecutionPolicy selectPolicy(StrategyReleaseInputCatalog catalog) { + if (catalog.executionPolicies().isEmpty()) { + throw new ImmutableStrategyReleaseRejectedException( + "No locked official backtest execution policy is available"); + } + return catalog.executionPolicies().stream() + .sorted(Comparator.comparing(ExecutionPolicy::lockedAt).reversed() + .thenComparing(ExecutionPolicy::version)) + .findFirst() + .orElseThrow(); + } + + private static Selection selectDatasets( + ExecutionPolicy policy, + Set requiredResolutions, + StrategyReleaseInputCatalog catalog) { + Map> byPeriod = new HashMap<>(); + catalog.datasets().stream() + .filter(dataset -> "ADJUSTED".equals(dataset.dataLayer())) + .filter(dataset -> policy.marketDataSchemaVersion().equals(dataset.schemaVersion())) + .filter(dataset -> !dataset.periodStart().isBefore(policy.periodStart())) + .filter(dataset -> !dataset.periodEnd().isAfter(policy.periodEnd())) + .filter(dataset -> !dataset.availableAt().isAfter(catalog.observedAt())) + .filter(dataset -> requiredResolutions.contains(normalizeResolution(dataset.resolution()))) + .forEach(dataset -> byPeriod + .computeIfAbsent(new Period(dataset.periodStart(), dataset.periodEnd()), ignored -> new ArrayList<>()) + .add(dataset)); + + Period selectedPeriod = byPeriod.entrySet().stream() + .filter(entry -> entry.getValue().stream() + .map(dataset -> normalizeResolution(dataset.resolution())) + .collect(java.util.stream.Collectors.toSet()) + .containsAll(requiredResolutions)) + .map(Map.Entry::getKey) + .sorted(Comparator + .comparingInt((Period period) -> period.start().equals(policy.periodStart()) + && period.end().equals(policy.periodEnd()) ? 0 : 1) + .thenComparing(Period::end, Comparator.reverseOrder()) + .thenComparing(Period::start)) + .findFirst() + .orElseThrow(() -> new ImmutableStrategyReleaseRejectedException( + "No coherent official backtest dataset set covers required resolutions " + + String.join(", ", requiredResolutions.stream() + .sorted(Comparator.comparing(OfficialBacktestInputSelector::duration)) + .toList()))); + + List periodDatasets = byPeriod.get(selectedPeriod); + List selected = requiredResolutions.stream() + .sorted(Comparator.comparing(OfficialBacktestInputSelector::duration)) + .map(resolution -> periodDatasets.stream() + .filter(dataset -> resolution.equals(normalizeResolution(dataset.resolution()))) + .max(Comparator.comparing(Dataset::availableAt) + .thenComparing(dataset -> dataset.id().toString())) + .orElseThrow()) + .toList(); + return new Selection(policy, selected); + } + + private static Set requiredResolutions(String compiledPlanDocument) { + final JsonNode plan; + try { + plan = JSON.readTree(compiledPlanDocument); + } catch (JsonProcessingException exception) { + throw new ImmutableStrategyReleaseRejectedException( + "Compiled plan is not readable while selecting official backtest inputs"); + } + Set resolutions = new LinkedHashSet<>(); + plan.path("requiredFeatures").forEach(feature -> addResolution(resolutions, feature.path("resolution"))); + plan.path("executionSnapshot").path("partitions").forEach(partition -> + partition.path("flows").forEach(flow -> + flow.path("steps").forEach(step -> + addResolution(resolutions, step.path("arguments").path("resolution"))))); + if (resolutions.isEmpty()) { + throw new ImmutableStrategyReleaseRejectedException( + "Compiled plan declares no market-data resolution"); + } + return java.util.Collections.unmodifiableSet(resolutions); + } + + private static void addResolution(Set target, JsonNode value) { + if (value.isTextual() && !value.textValue().isBlank()) { + target.add(normalizeResolution(value.textValue())); + } + } + + private static String normalizeResolution(String value) { + String normalized = value.trim().toLowerCase(java.util.Locale.ROOT); + if (Set.of("30m", "1h", "4h", "1d").contains(normalized)) { + return normalized; + } + try { + Duration parsed = Duration.parse(value.toUpperCase(java.util.Locale.ROOT)); + if (parsed.equals(Duration.ofMinutes(30))) return "30m"; + if (parsed.equals(Duration.ofHours(1))) return "1h"; + if (parsed.equals(Duration.ofHours(4))) return "4h"; + if (parsed.equals(Duration.ofDays(1))) return "1d"; + } catch (RuntimeException ignored) { + // The stable failure below names the unsupported contract value. + } + throw new ImmutableStrategyReleaseRejectedException( + "Unsupported official backtest resolution " + value); + } + + private static Duration duration(String resolution) { + return switch (resolution) { + case "30m" -> Duration.ofMinutes(30); + case "1h" -> Duration.ofHours(1); + case "4h" -> Duration.ofHours(4); + case "1d" -> Duration.ofDays(1); + default -> throw new IllegalArgumentException("Unsupported resolution " + resolution); + }; + } + + public record Selection(ExecutionPolicy policy, List datasets) { + public Selection { + datasets = List.copyOf(datasets); + } + } + + private record Period(LocalDate start, LocalDate end) {} +} diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/OfficialBacktestRequest.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/OfficialBacktestRequest.java index 026d05f1..96301e11 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/OfficialBacktestRequest.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/OfficialBacktestRequest.java @@ -3,6 +3,8 @@ import com.idea2strategy.backend.domain.strategy.ImmutableStrategyRelease; import java.nio.charset.StandardCharsets; import java.time.Instant; +import java.util.HashSet; +import java.util.List; import java.util.Objects; import java.util.UUID; @@ -18,7 +20,7 @@ public record OfficialBacktestRequest( UUID botId, String expectedSnapshotHash, String compiledPlanChecksum, - UUID datasetManifestId, + List datasetManifestIds, String assumptionsVersion, String executionPolicyVersion, String requestReason) { @@ -32,7 +34,10 @@ public record OfficialBacktestRequest( Objects.requireNonNull(botId, "botId"); requireSha256(expectedSnapshotHash, "expectedSnapshotHash"); requireSha256(compiledPlanChecksum, "compiledPlanChecksum"); - Objects.requireNonNull(datasetManifestId, "datasetManifestId"); + datasetManifestIds = List.copyOf(Objects.requireNonNull(datasetManifestIds, "datasetManifestIds")); + if (datasetManifestIds.isEmpty() || new HashSet<>(datasetManifestIds).size() != datasetManifestIds.size()) { + throw new IllegalArgumentException("datasetManifestIds must contain unique official datasets"); + } requireText(assumptionsVersion, "assumptionsVersion"); requireText(executionPolicyVersion, "executionPolicyVersion"); if (!REQUEST_REASON.equals(requestReason)) { @@ -57,12 +62,23 @@ public static OfficialBacktestRequest forRelease( ImmutableStrategyRelease release, UUID datasetManifestId, String executionPolicyVersion) { + return forRelease(release, List.of(datasetManifestId), executionPolicyVersion); + } + + public static OfficialBacktestRequest forRelease( + ImmutableStrategyRelease release, + List datasetManifestIds, + String executionPolicyVersion) { Objects.requireNonNull(release, "release"); - Objects.requireNonNull(datasetManifestId, "datasetManifestId"); + datasetManifestIds = List.copyOf(Objects.requireNonNull(datasetManifestIds, "datasetManifestIds")); + if (datasetManifestIds.isEmpty()) { + throw new IllegalArgumentException("datasetManifestIds must not be empty"); + } requireText(executionPolicyVersion, "executionPolicyVersion"); String snapshotHash = prefixed(release.snapshotHash()); String planChecksum = release.contractPlan().planChecksum(); - String operationKey = "OFFICIAL_BACKTEST|" + datasetManifestId + "|" + String operationKey = "OFFICIAL_BACKTEST|" + String.join(",", datasetManifestIds.stream() + .map(UUID::toString).toList()) + "|" + release.launchConfiguration().accountingRulesVersion(); String material = String.join("\n", "contractVersion=" + CONTRACT_VERSION, @@ -76,10 +92,15 @@ public static OfficialBacktestRequest forRelease( var metadata = new MessageMetadata( CONTRACT_VERSION, MESSAGE_TYPE, messageId, release.releasedAt(), release.botId(), idempotencyKey); return new OfficialBacktestRequest( - metadata, runId, release.botId(), snapshotHash, planChecksum, datasetManifestId, + metadata, runId, release.botId(), snapshotHash, planChecksum, datasetManifestIds, release.launchConfiguration().accountingRulesVersion(), executionPolicyVersion, REQUEST_REASON); } + /** The finest selected dataset remains the v1 representative field in the transport envelope. */ + public UUID datasetManifestId() { + return datasetManifestIds.getFirst(); + } + private static UUID derivedId(UUID botId, String component) { return UUID.nameUUIDFromBytes((botId + ":" + component).getBytes(StandardCharsets.UTF_8)); } diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/StrategyReleaseInputCatalog.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/StrategyReleaseInputCatalog.java index 6106a811..9b093b36 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/StrategyReleaseInputCatalog.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/StrategyReleaseInputCatalog.java @@ -24,7 +24,11 @@ public record ExecutionPolicy( UUID feePolicyId, int feeRateBps, UUID buyingPowerBufferPolicyId, - int buyingPowerBufferBps) {} + int buyingPowerBufferBps, + LocalDate periodStart, + LocalDate periodEnd, + String marketDataSchemaVersion, + Instant lockedAt) {} public record Dataset( UUID id, @@ -33,5 +37,6 @@ public record Dataset( String resolution, LocalDate periodStart, LocalDate periodEnd, - String schemaVersion) {} + String schemaVersion, + Instant availableAt) {} } diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/journey/StrategyBotIndependentE2ETest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/journey/StrategyBotIndependentE2ETest.java index a0d6ba04..88a0fa25 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/journey/StrategyBotIndependentE2ETest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/journey/StrategyBotIndependentE2ETest.java @@ -20,6 +20,7 @@ import com.idea2strategy.backend.application.strategy.StrategyDraftReplaceResult; import com.idea2strategy.backend.application.strategy.StrategyEditLeaseTokens; import com.idea2strategy.backend.application.strategy.StrategyQueryPort; +import com.idea2strategy.backend.application.strategy.StrategyReleaseInputCatalog; import com.idea2strategy.backend.application.strategy.StrategyValidationRunCommandPort; import com.idea2strategy.backend.application.strategy.StrategyValidationRunQueryPort; import com.idea2strategy.backend.application.testing.FakeBacktestAdapter; @@ -116,6 +117,17 @@ void createsValidatesReleasesRunsAndPermanentlyStopsABasicStrategyBot() { repository.validationQuery(), repository, repository, + observedAt -> new StrategyReleaseInputCatalog( + List.of(new StrategyReleaseInputCatalog.ExecutionPolicy( + "backtest-policy-v1", "broker/v1", "accounting/v1", "precision/v1", + FEE_POLICY_ID, 20, BUFFER_POLICY_ID, 1, + java.time.LocalDate.parse("2025-01-01"), java.time.LocalDate.parse("2025-12-31"), + "market-bars/1", NOW.minusSeconds(60))), + List.of(new StrategyReleaseInputCatalog.Dataset( + DATASET_ID, "ALPACA_SIP_ALL_30M", "ADJUSTED", "30m", + java.time.LocalDate.parse("2025-01-01"), java.time.LocalDate.parse("2025-12-31"), + "market-bars/1", NOW.minusSeconds(30))), + observedAt), principal, clock); var release = releases.release(VALIDATION_ID, catalog(), releaseCommand()); @@ -147,13 +159,6 @@ private static ImmutableStrategyReleaseCommand releaseCommand() { BOT_ID, new BigDecimal("100000.00"), 10_000, - "broker/v1", - "accounting/v1", - "precision/v1", - FEE_POLICY_ID, - BUFFER_POLICY_ID, - DATASET_ID, - "backtest-policy-v1", "{\"policy\":\"FIRST_WINS\"}"); } diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommandServiceTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommandServiceTest.java index 27d35d39..407aa01a 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommandServiceTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/ImmutableStrategyReleaseCommandServiceTest.java @@ -54,7 +54,7 @@ planPort, validations, strategies, documents, new TestPrincipal(OWNER_ID), () -> PLAN_ID, Clock.fixed(NOW, ZoneOffset.UTC)); var releases = new CapturingReleasePort(); var service = new ImmutableStrategyReleaseCommandService( - releases, planService, validations, strategies, documents, new TestPrincipal(OWNER_ID), + releases, planService, validations, strategies, documents, inputCatalogPort(), new TestPrincipal(OWNER_ID), Clock.fixed(NOW, ZoneOffset.UTC)); ImmutableStrategyRelease release = service.release(RUN_ID, catalog(), command()); @@ -170,7 +170,7 @@ void releaseEndpointBindsTheOwnedValidationToTheRequestedStrategyAndItsExactCata var releasePort = new CapturingReleasePort(); var service = new ImmutableStrategyReleaseCommandService( releasePort, planService, validations, strategies, documents, - new TestPrincipal(OWNER_ID), Clock.fixed(NOW, ZoneOffset.UTC)); + inputCatalogPort(), new TestPrincipal(OWNER_ID), Clock.fixed(NOW, ZoneOffset.UTC)); var catalogPort = new ExactCatalogPort(catalog()); var catalogService = new BasicStrategyCatalogQueryService( catalogPort, Clock.fixed(NOW, ZoneOffset.UTC), ZoneOffset.UTC); @@ -210,14 +210,26 @@ private ImmutableStrategyReleaseCommandService releaseService() { () -> PLAN_ID, Clock.fixed(NOW, ZoneOffset.UTC)); return new ImmutableStrategyReleaseCommandService( new CapturingReleasePort(), planService, validations, strategies, documents, - new TestPrincipal(OWNER_ID), Clock.fixed(NOW, ZoneOffset.UTC)); + inputCatalogPort(), new TestPrincipal(OWNER_ID), Clock.fixed(NOW, ZoneOffset.UTC)); } private static ImmutableStrategyReleaseCommand command() { return new ImmutableStrategyReleaseCommand( - RELEASE_ID, new BigDecimal("100000.00"), 10_000, "broker/v1", "accounting/v1", - "precision/v1", FEE_ID, BUFFER_ID, DATASET_ID, "backtest-policy-v1", - "{\"policy\":\"FIRST_WINS\"}"); + RELEASE_ID, new BigDecimal("100000.00"), 10_000, "{\"policy\":\"FIRST_WINS\"}"); + } + + private static StrategyReleaseInputCatalogQueryPort inputCatalogPort() { + return observedAt -> new StrategyReleaseInputCatalog( + List.of(new StrategyReleaseInputCatalog.ExecutionPolicy( + "backtest-policy-v1", "broker/v1", "accounting/v1", "precision/v1", + FEE_ID, 20, BUFFER_ID, 1, + java.time.LocalDate.parse("2025-01-01"), java.time.LocalDate.parse("2025-12-31"), + "market-bars/1", NOW.minusSeconds(60))), + List.of(new StrategyReleaseInputCatalog.Dataset( + DATASET_ID, "ALPACA_SIP_ALL_30M", "ADJUSTED", "30m", + java.time.LocalDate.parse("2025-01-01"), java.time.LocalDate.parse("2025-12-31"), + "market-bars/1", NOW.minusSeconds(30))), + observedAt); } private static StrategyDocument document() { diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/OfficialBacktestInputSelectorTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/OfficialBacktestInputSelectorTest.java new file mode 100644 index 00000000..9d160fe3 --- /dev/null +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/OfficialBacktestInputSelectorTest.java @@ -0,0 +1,88 @@ +package com.idea2strategy.backend.application.strategy; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.idea2strategy.backend.application.strategy.StrategyReleaseInputCatalog.Dataset; +import com.idea2strategy.backend.application.strategy.StrategyReleaseInputCatalog.ExecutionPolicy; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class OfficialBacktestInputSelectorTest { + private static final Instant NOW = Instant.parse("2026-08-10T00:00:00Z"); + private static final UUID FEE_ID = UUID.fromString("10000000-0000-4000-8000-000000000001"); + private static final UUID BUFFER_ID = UUID.fromString("10000000-0000-4000-8000-000000000002"); + private static final UUID BARS_30M_OLD = UUID.fromString("20000000-0000-4000-8000-000000000001"); + private static final UUID BARS_30M = UUID.fromString("20000000-0000-4000-8000-000000000002"); + private static final UUID BARS_1H = UUID.fromString("20000000-0000-4000-8000-000000000003"); + private static final UUID RAW_30M = UUID.fromString("20000000-0000-4000-8000-000000000004"); + + @Test + void selectsTheNewestOfficialPolicyAndEveryDatasetResolutionRequiredByThePlan() { + var selected = OfficialBacktestInputSelector.select(plan("30m", "PT1H"), new StrategyReleaseInputCatalog( + List.of(policy("official-v1", NOW.minusSeconds(60))), + List.of( + dataset(BARS_30M_OLD, "ADJUSTED", "30m", NOW.minusSeconds(120)), + dataset(BARS_30M, "ADJUSTED", "30m", NOW.minusSeconds(30)), + dataset(BARS_1H, "ADJUSTED", "1h", NOW.minusSeconds(20)), + dataset(RAW_30M, "RAW", "30m", NOW.minusSeconds(10))), + NOW)); + + assertThat(selected.policy().version()).isEqualTo("official-v1"); + assertThat(selected.datasets()).extracting(Dataset::id).containsExactly(BARS_30M, BARS_1H); + } + + @Test + void failsClosedWhenOneRequiredResolutionHasNoCoherentOfficialDatasetSet() { + var catalog = new StrategyReleaseInputCatalog( + List.of(policy("official-v1", NOW.minusSeconds(60))), + List.of(dataset(BARS_30M, "ADJUSTED", "30m", NOW.minusSeconds(30))), + NOW); + + assertThatThrownBy(() -> OfficialBacktestInputSelector.select(plan("30m", "PT1H"), catalog)) + .isInstanceOf(ImmutableStrategyReleaseRejectedException.class) + .hasMessageContaining("30m, 1h"); + } + + private static ExecutionPolicy policy(String version, Instant lockedAt) { + return new ExecutionPolicy( + version, + "market:1.0.0", + "accounting:1.0.0", + "precision:1.0.0", + FEE_ID, + 20, + BUFFER_ID, + 1, + LocalDate.parse("2024-01-01"), + LocalDate.parse("2024-02-01"), + "market-bars/1", + lockedAt); + } + + private static Dataset dataset(UUID id, String layer, String resolution, Instant availableAt) { + return new Dataset( + id, + "ALPACA_SIP_ALL_" + resolution.toUpperCase(), + layer, + resolution, + LocalDate.parse("2024-01-01"), + LocalDate.parse("2024-02-01"), + "market-bars/1", + availableAt); + } + + private static String plan(String liveResolution, String featureResolution) { + return """ + { + "requiredFeatures":[{"resolution":"%s"}], + "executionSnapshot":{"partitions":[{"flows":[{"steps":[ + {"arguments":{"resolution":"%s"}} + ]}]}]} + } + """.formatted(featureResolution, liveResolution); + } +} diff --git a/modules/backend-messaging/src/main/java/com/idea2strategy/backend/messaging/strategybot/v1/StrategyBotContractFixtures.java b/modules/backend-messaging/src/main/java/com/idea2strategy/backend/messaging/strategybot/v1/StrategyBotContractFixtures.java index 32eda77b..184eeb97 100644 --- a/modules/backend-messaging/src/main/java/com/idea2strategy/backend/messaging/strategybot/v1/StrategyBotContractFixtures.java +++ b/modules/backend-messaging/src/main/java/com/idea2strategy/backend/messaging/strategybot/v1/StrategyBotContractFixtures.java @@ -138,6 +138,7 @@ public static FixtureSet standard() { compiledPlan.planChecksum(), DATASET_MANIFEST_ID, SNAPSHOT_HASH, + List.of(new PinnedDataset(DATASET_MANIFEST_ID, "MARKET_BARS", SNAPSHOT_HASH)), "2025-01-01", "2025-12-31", "accounting:1.0.0", @@ -566,6 +567,7 @@ public record OfficialBacktestRequest( String compiledPlanChecksum, String datasetManifestId, String expectedDatasetHash, + List datasets, String periodStart, String periodEnd, String assumptionsVersion, @@ -584,6 +586,10 @@ public record OfficialBacktestRequest( requireSha256(compiledPlanChecksum, "compiledPlanChecksum"); requireText(datasetManifestId, "datasetManifestId"); requireSha256(expectedDatasetHash, "expectedDatasetHash"); + datasets = List.copyOf(Objects.requireNonNull(datasets, "datasets")); + if (datasets.isEmpty()) { + throw new IllegalArgumentException("Official release backtests pin required datasets"); + } requireText(periodStart, "periodStart"); requireText(periodEnd, "periodEnd"); requireText(assumptionsVersion, "assumptionsVersion"); @@ -599,6 +605,14 @@ public record OfficialBacktestRequest( } } + public record PinnedDataset(String datasetManifestId, String purposeCode, String expectedDatasetHash) { + public PinnedDataset { + requireText(datasetManifestId, "datasetManifestId"); + requireText(purposeCode, "purposeCode"); + requireSha256(expectedDatasetHash, "expectedDatasetHash"); + } + } + public record PinnedFeatureMaterialization(String featureMaterializationId, String lockedResultHash) { public PinnedFeatureMaterialization { requireText(featureMaterializationId, "featureMaterializationId"); diff --git a/modules/backend-messaging/src/main/resources/contracts/strategy-bot/v1/official-backtest-request.valid.json b/modules/backend-messaging/src/main/resources/contracts/strategy-bot/v1/official-backtest-request.valid.json index 1b754da1..a1ce602d 100644 --- a/modules/backend-messaging/src/main/resources/contracts/strategy-bot/v1/official-backtest-request.valid.json +++ b/modules/backend-messaging/src/main/resources/contracts/strategy-bot/v1/official-backtest-request.valid.json @@ -15,6 +15,13 @@ "compiledPlanChecksum": "sha256:88d61198d46dce161c2a929702a7fd1cee5c9b044c470d2590b96f3825fcacb3", "datasetManifestId": "00000000-0000-4000-8000-000000000203", "expectedDatasetHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "datasets": [ + { + "datasetManifestId": "00000000-0000-4000-8000-000000000203", + "purposeCode": "MARKET_BARS", + "expectedDatasetHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + ], "periodStart": "2025-01-01", "periodEnd": "2025-12-31", "assumptionsVersion": "accounting:1.0.0", diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/ImmutableStrategyReleaseJooqCommandAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/ImmutableStrategyReleaseJooqCommandAdapter.java index 8ce62bd1..6c19933d 100644 --- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/ImmutableStrategyReleaseJooqCommandAdapter.java +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/ImmutableStrategyReleaseJooqCommandAdapter.java @@ -220,17 +220,20 @@ private void saveOfficialBacktestOnce( String idempotencyKey = request.metadata().idempotencyKey(); - var dataset = dsl.fetchOne( - "select dataset_hash, period_start::date as period_start, period_end::date as period_end, " - + "schema_version, data_layer::text as data_layer " - + "from market_data.dataset_manifests " - + "where id = ? and status = 'AVAILABLE' and available_at is not null " - + "and available_at <= ?::timestamptz", - request.datasetManifestId(), release.releasedAt().atOffset(ZoneOffset.UTC)); - if (dataset == null) { - throw new ImmutableStrategyReleaseRejectedException( - "Official backtest dataset must be available at the release instant"); - } + var datasets = request.datasetManifestIds().stream().map(datasetManifestId -> { + var dataset = dsl.fetchOne( + "select id, dataset_hash, period_start::date as period_start, period_end::date as period_end, " + + "schema_version, data_layer::text as data_layer, resolution " + + "from market_data.dataset_manifests " + + "where id = ? and status = 'AVAILABLE' and available_at is not null " + + "and available_at <= ?::timestamptz", + datasetManifestId, release.releasedAt().atOffset(ZoneOffset.UTC)); + if (dataset == null) { + throw new ImmutableStrategyReleaseRejectedException( + "Every official backtest dataset must be available at the release instant"); + } + return dataset; + }).toList(); var policy = dsl.fetchOne( "select policy_document from backtest.execution_policy_versions " + "where version = ? and locked_at <= ?::timestamptz " @@ -242,11 +245,22 @@ private void saveOfficialBacktestOnce( throw new ImmutableStrategyReleaseRejectedException( "Official backtest execution policy must be locked and not retired at the release instant"); } - requireCompatibleOfficialInput(policy.get("policy_document", String.class), dataset); + datasets.forEach(dataset -> requireCompatibleOfficialInput( + policy.get("policy_document", String.class), dataset)); var queuedAt = release.releasedAt().atOffset(ZoneOffset.UTC); - String expectedDatasetHash = prefixed(dataset.get("dataset_hash", String.class)); - java.time.LocalDate periodStart = dataset.get("period_start", java.time.LocalDate.class); - java.time.LocalDate periodEnd = dataset.get("period_end", java.time.LocalDate.class); + var primaryDataset = datasets.getFirst(); + java.time.LocalDate periodStart = primaryDataset.get("period_start", java.time.LocalDate.class); + java.time.LocalDate periodEnd = primaryDataset.get("period_end", java.time.LocalDate.class); + if (datasets.stream().anyMatch(dataset -> + !periodStart.equals(dataset.get("period_start", java.time.LocalDate.class)) + || !periodEnd.equals(dataset.get("period_end", java.time.LocalDate.class)))) { + throw new ImmutableStrategyReleaseRejectedException( + "Official backtest datasets must share one evaluation period"); + } + List datasetPins = datasets.stream().map(dataset -> new DatasetPin( + dataset.get("id", UUID.class), + "MARKET_BARS", + prefixed(dataset.get("dataset_hash", String.class)))).toList(); final List resolvedFeatures; try { resolvedFeatures = featurePins.resolve( @@ -256,7 +270,7 @@ private void saveOfficialBacktestOnce( "Official backtest feature pins are not publishable: " + exception.getMessage()); } BasicPayload basicPayload = payloadDocument( - request, expectedDatasetHash, periodStart, periodEnd, resolvedFeatures); + request, datasetPins, periodStart, periodEnd, resolvedFeatures); String payload = basicPayload.document(); var configuration = release.launchConfiguration(); @@ -282,7 +296,7 @@ private void saveOfficialBacktestOnce( request.runId(), basicPayload.requestHash(), request.metadata().contractVersion(), request.compiledPlanChecksum(), request.expectedSnapshotHash(), request.executionPolicyVersion(), queuedAt, - List.of(new DatasetPin(request.datasetManifestId(), "MARKET_BARS", expectedDatasetHash)), + datasetPins, resolvedFeatures)); var existingOutbox = dsl.fetchOne( @@ -319,10 +333,11 @@ private void saveOfficialBacktestOnce( private BasicPayload payloadDocument( OfficialBacktestRequest request, - String expectedDatasetHash, + List datasetPins, java.time.LocalDate periodStart, java.time.LocalDate periodEnd, List resolvedFeatures) { + DatasetPin primaryDataset = datasetPins.getFirst(); ObjectNode root = objectMapper.createObjectNode(); ObjectNode metadata = root.putObject("metadata"); metadata.put("contractVersion", request.metadata().contractVersion()); @@ -337,8 +352,15 @@ private BasicPayload payloadDocument( root.put("aggregateSequence", 1); root.put("expectedSnapshotHash", request.expectedSnapshotHash()); root.put("compiledPlanChecksum", request.compiledPlanChecksum()); - root.put("datasetManifestId", request.datasetManifestId().toString()); - root.put("expectedDatasetHash", expectedDatasetHash); + root.put("datasetManifestId", primaryDataset.datasetManifestId().toString()); + root.put("expectedDatasetHash", primaryDataset.lockedDatasetHash()); + var datasets = root.putArray("datasets"); + datasetPins.forEach(dataset -> { + var node = datasets.addObject(); + node.put("datasetManifestId", dataset.datasetManifestId().toString()); + node.put("purposeCode", dataset.purposeCode()); + node.put("expectedDatasetHash", dataset.lockedDatasetHash()); + }); root.put("periodStart", periodStart.toString()); root.put("periodEnd", periodEnd.toString()); root.put("assumptionsVersion", request.assumptionsVersion()); @@ -354,7 +376,7 @@ private BasicPayload payloadDocument( }); try { String requestHash = basicRequestHash( - request, expectedDatasetHash, periodStart, periodEnd, resolvedFeatures); + request, datasetPins, periodStart, periodEnd, resolvedFeatures); root.put("requestHash", requestHash); return new BasicPayload( requestHash, @@ -366,7 +388,7 @@ private BasicPayload payloadDocument( static String basicRequestHash( OfficialBacktestRequest request, - String expectedDatasetHash, + List datasetPins, java.time.LocalDate periodStart, java.time.LocalDate periodEnd, List resolvedFeatures) { @@ -377,13 +399,17 @@ static String basicRequestHash( request.botId().toString(), request.expectedSnapshotHash(), request.compiledPlanChecksum(), - request.datasetManifestId().toString(), - expectedDatasetHash, periodStart.toString(), periodEnd.toString(), request.assumptionsVersion(), request.executionPolicyVersion(), request.requestReason())); + datasetPins.stream() + .sorted(java.util.Comparator.comparing(DatasetPin::purposeCode) + .thenComparing(dataset -> dataset.datasetManifestId().toString())) + .forEach(dataset -> material.append('\n').append(dataset.datasetManifestId()) + .append('\n').append(dataset.purposeCode()) + .append('\n').append(dataset.lockedDatasetHash())); resolvedFeatures.stream() .sorted(java.util.Comparator.comparing(feature -> feature.featureMaterializationId().toString())) .forEach(feature -> material.append('\n').append(feature.featureMaterializationId()) diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/StrategyReleaseInputCatalogJooqQueryAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/StrategyReleaseInputCatalogJooqQueryAdapter.java index 460d116a..bc8ad839 100644 --- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/StrategyReleaseInputCatalogJooqQueryAdapter.java +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/strategy/StrategyReleaseInputCatalogJooqQueryAdapter.java @@ -7,6 +7,7 @@ import java.time.Instant; import java.time.LocalDate; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.time.ZoneOffset; import java.util.UUID; import org.jooq.DSLContext; @@ -28,6 +29,11 @@ public StrategyReleaseInputCatalog findSelectableAt(Instant observedAt) { p.policy_document ->> 'marketRulesVersion' as broker_rules_version, p.policy_document ->> 'accountingRulesVersion' as accounting_rules_version, p.policy_document ->> 'precisionRulesVersion' as precision_rules_version, + p.policy_document ->> 'periodStart' as period_start, + p.policy_document ->> 'periodEnd' as period_end, + p.policy_document ->> 'marketDataSchemaVersion' as market_data_schema_version, + p.policy_document ->> 'timezone' as timezone, + p.locked_at, f.id as fee_policy_id, f.fee_rate_bps, b.id as buffer_policy_id, b.buffer_bps from backtest.execution_policy_versions p @@ -41,6 +47,13 @@ public StrategyReleaseInputCatalog findSelectableAt(Instant observedAt) { and (f.effective_to is null or f.effective_to > ?::timestamptz) and b.effective_from <= ?::timestamptz and (b.effective_to is null or b.effective_to > ?::timestamptz) + and p.policy_document ->> 'marketRulesVersion' is not null + and p.policy_document ->> 'accountingRulesVersion' is not null + and p.policy_document ->> 'precisionRulesVersion' is not null + and p.policy_document ->> 'periodStart' is not null + and p.policy_document ->> 'periodEnd' is not null + and p.policy_document ->> 'marketDataSchemaVersion' is not null + and p.policy_document ->> 'timezone' is not null order by p.locked_at desc, p.version """, at, at, at, at, at, at).map(row -> new ExecutionPolicy( row.get("version", String.class), @@ -50,12 +63,16 @@ public StrategyReleaseInputCatalog findSelectableAt(Instant observedAt) { row.get("fee_policy_id", UUID.class), row.get("fee_rate_bps", Integer.class), row.get("buffer_policy_id", UUID.class), - row.get("buffer_bps", Integer.class))); + row.get("buffer_bps", Integer.class), + localDate(row.get("period_start", String.class), row.get("timezone", String.class)), + localDate(row.get("period_end", String.class), row.get("timezone", String.class)), + row.get("market_data_schema_version", String.class), + row.get("locked_at", OffsetDateTime.class).toInstant())); var datasets = dsl.fetch(""" select d.id, f.code as feed_code, d.data_layer, d.resolution, d.period_start::date as period_start, - d.period_end::date as period_end, d.schema_version + d.period_end::date as period_end, d.schema_version, d.available_at from market_data.dataset_manifests d join market_data.feeds f on f.id = d.feed_id where d.status = 'AVAILABLE' @@ -70,7 +87,12 @@ and btrim(d.dataset_hash) <> '' row.get("resolution", String.class), row.get("period_start", LocalDate.class), row.get("period_end", LocalDate.class), - row.get("schema_version", String.class))); + row.get("schema_version", String.class), + row.get("available_at", OffsetDateTime.class).toInstant())); return new StrategyReleaseInputCatalog(policies, datasets, observedAt); } + + private static LocalDate localDate(String instant, String timezone) { + return OffsetDateTime.parse(instant).atZoneSameInstant(ZoneId.of(timezone)).toLocalDate(); + } } diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/ImmutableStrategyReleasePersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/ImmutableStrategyReleasePersistenceIntegrationTest.java index 2ecc2abe..4600796e 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/ImmutableStrategyReleasePersistenceIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/ImmutableStrategyReleasePersistenceIntegrationTest.java @@ -48,7 +48,9 @@ class ImmutableStrategyReleasePersistenceIntegrationTest { private static final UUID FEE_ID = UUID.fromString("b0000000-0000-4000-8000-000000000011"); private static final UUID BUFFER_ID = UUID.fromString("c0000000-0000-4000-8000-000000000011"); private static final UUID DATASET_ID = UUID.fromString("d0000000-0000-4000-8000-000000000011"); + private static final UUID SECOND_DATASET_ID = UUID.fromString("d0000000-0000-4000-8000-000000000012"); private static final UUID FEED_ID = UUID.fromString("e0000000-0000-4000-8000-000000000011"); + private static final UUID SECOND_FEED_ID = UUID.fromString("e0000000-0000-4000-8000-000000000012"); private static final UUID FEATURE_FEED_ID = UUID.fromString("39e0e076-89e0-5159-b113-a8f6778b7c9e"); private static final UUID FEATURE_PIPELINE_ID = UUID.fromString("e1000000-0000-4000-8000-000000000011"); private static final UUID FEATURE_MANIFEST_ID = UUID.fromString("e2000000-0000-4000-8000-000000000011"); @@ -128,6 +130,11 @@ void prepareValidatedStrategyAndPinnedPolicies() { + "(id, provider_id, code, data_kind, resolution, timezone_name, feed_version, created_at) " + "values (?, ?, 'OFFICIAL', 'BAR', '1d', 'UTC', 'v1', ?)", FEED_ID, UUID.fromString("f0000000-0000-4000-8000-000000000011"), at); + jdbc.update( + "insert into market_data.feeds " + + "(id, provider_id, code, data_kind, resolution, timezone_name, feed_version, created_at) " + + "values (?, ?, 'OFFICIAL_4H', 'BAR', '4h', 'UTC', 'v1', ?)", + SECOND_FEED_ID, UUID.fromString("f0000000-0000-4000-8000-000000000011"), at); jdbc.update( "insert into market_data.feeds " + "(id, provider_id, code, data_kind, resolution, timezone_name, feed_version, created_at) " @@ -141,6 +148,13 @@ void prepareValidatedStrategyAndPinnedPolicies() { + "values (?, ?, 'ADJUSTED', '1d', 1, 'AVAILABLE', '2025-01-01T00:00:00Z', " + "'2025-12-31T00:00:00Z', 'v1', ?, ?, ?)", DATASET_ID, FEED_ID, HASH_D, at, at); + 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', '4h', 1, 'AVAILABLE', '2025-01-01T00:00:00Z', " + + "'2025-12-31T00:00:00Z', 'v1', ?, ?, ?)", + SECOND_DATASET_ID, SECOND_FEED_ID, HASH_C, at, at); jdbc.update( "insert into market_data.instruments " + "(id, asset_type, primary_exchange_mic, currency_code) values (?, 'STOCK', 'XNAS', 'USD')", @@ -217,7 +231,7 @@ void prepareValidatedStrategyAndPinnedPolicies() { void atomicallyCreatesOneImmutableAggregateAndMakesTheReleaseIdIdempotent() throws Exception { ImmutableStrategyRelease release = release(BOT_ID, HASH_D); OfficialBacktestRequest request = OfficialBacktestRequest.forRelease( - release, DATASET_ID, "backtest-policy-v1"); + release, List.of(DATASET_ID, SECOND_DATASET_ID), "backtest-policy-v1"); jdbc.update("update strategy.element_catalog_versions set retired_at = ? where id = ?", NOW.atOffset(ZoneOffset.UTC), CATALOG_ID); @@ -261,7 +275,7 @@ void atomicallyCreatesOneImmutableAggregateAndMakesTheReleaseIdIdempotent() thro assertThat(count("backtest.runs")).isEqualTo(1); assertThat(count("backtest.run_input_pins")).isEqualTo(1); assertThat(count("backtest.input_bundles")).isEqualTo(1); - assertThat(count("backtest.input_datasets")).isEqualTo(1); + assertThat(count("backtest.input_datasets")).isEqualTo(2); assertThat(count("backtest.input_feature_materializations")).isEqualTo(1); assertThat(count("operations.outbox_messages")).isEqualTo(1); // The transport aggregate for a BASIC official backtest is the bot, not the run. The BASIC @@ -278,6 +292,11 @@ void atomicallyCreatesOneImmutableAggregateAndMakesTheReleaseIdIdempotent() thro "select payload_document ->> 'datasetManifestId' from operations.outbox_messages " + "where aggregate_id = ?", String.class, BOT_ID)) .isEqualTo(DATASET_ID.toString()); + assertThat(jdbc.queryForObject( + "select jsonb_array_length(payload_document -> 'datasets') " + + "from operations.outbox_messages where aggregate_id = ?", + Integer.class, BOT_ID)) + .isEqualTo(2); var transported = OBJECT_MAPPER.readValue( jdbc.queryForObject( "select payload_document::text from operations.outbox_messages where aggregate_id = ?", diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/OfficialBacktestRequestHashFixtureTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/OfficialBacktestRequestHashFixtureTest.java index 6056c6c7..e07dd3f5 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/OfficialBacktestRequestHashFixtureTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/OfficialBacktestRequestHashFixtureTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.idea2strategy.backend.application.strategy.OfficialBacktestRequest; +import com.idea2strategy.backend.persistence.backtest.BacktestRunInputPinWriter.DatasetPin; import com.idea2strategy.backend.persistence.backtest.BacktestRunInputPinWriter.FeaturePin; import java.time.Instant; import java.time.LocalDate; @@ -28,19 +29,22 @@ void matchesTheLanguageNeutralBasicRequestHashFixture() { botId, "sha256:" + "1".repeat(64), "sha256:" + "2".repeat(64), - UUID.fromString("40000000-0000-4000-8000-000000000001"), + List.of(UUID.fromString("40000000-0000-4000-8000-000000000001")), "accounting-v1", "backtest-policy-v1", OfficialBacktestRequest.REQUEST_REASON); assertThat(ImmutableStrategyReleaseJooqCommandAdapter.basicRequestHash( request, - "sha256:" + "3".repeat(64), + List.of(new DatasetPin( + UUID.fromString("40000000-0000-4000-8000-000000000001"), + "MARKET_BARS", + "sha256:" + "3".repeat(64))), LocalDate.parse("2025-01-01"), LocalDate.parse("2025-12-31"), List.of(new FeaturePin( UUID.fromString("50000000-0000-4000-8000-000000000001"), "sha256:" + "5".repeat(64))))) - .isEqualTo("sha256:bdf3474e890de34991f6caf4c6e9c0a2807f5a6728701eb03000f05797deebfb"); + .isEqualTo("sha256:07b563b854efa511a10d2661d1da454fb84a73b5a31c8afecafa144d209812cc"); } }