diff --git a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/OwnedRoomManagementController.java b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/OwnedRoomManagementController.java index cc7c35e1..269e4cb7 100644 --- a/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/OwnedRoomManagementController.java +++ b/apps/backend-api/src/main/java/com/idea2strategy/backend/api/competition/OwnedRoomManagementController.java @@ -3,8 +3,11 @@ import com.idea2strategy.backend.application.competition.OwnedRoomManagementQueryService; import com.idea2strategy.backend.application.competition.OwnedRoomManagementView; import java.util.List; +import java.util.UUID; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -24,5 +27,10 @@ public Response list(@RequestParam(defaultValue = "50") int limit) { return new Response(service.list(limit)); } + @GetMapping("/{roomId}") + public ResponseEntity get(@PathVariable UUID roomId) { + return ResponseEntity.of(service.get(roomId)); + } + public record Response(List items) {} } diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/competition/OwnedRoomManagementControllerTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/competition/OwnedRoomManagementControllerTest.java index fbe52d17..3748a55c 100644 --- a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/competition/OwnedRoomManagementControllerTest.java +++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/competition/OwnedRoomManagementControllerTest.java @@ -5,11 +5,13 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.idea2strategy.backend.application.competition.OwnedRoomManagementQueryService; +import com.idea2strategy.backend.application.competition.OwnedRoomManagementQueryPort; import com.idea2strategy.backend.application.competition.OwnedRoomManagementView; import java.math.BigDecimal; import java.time.Instant; 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.test.web.servlet.setup.MockMvcBuilders; @@ -28,9 +30,14 @@ void returnsOwnerConfigurationInvitationAndParticipationEvidence() throws Except NOW.plusSeconds(1200), NOW.plusSeconds(1800), "Asia/Seoul", List.of(new OwnedRoomManagementView.Invitation(id(6), "LINK", NOW, NOW.plusSeconds(600), null, null)), List.of(new OwnedRoomManagementView.Participation(id(7), id(8), "Bot A", "ACTIVE", NOW))); - var service = new OwnedRoomManagementQueryService((owner, limit) -> { - if (!owner.equals(ACCOUNT_ID) || limit != 25) throw new AssertionError("principal or limit lost"); - return List.of(view); + var service = new OwnedRoomManagementQueryService(new OwnedRoomManagementQueryPort() { + @Override public List findOwnedBy(UUID owner, int limit) { + if (!owner.equals(ACCOUNT_ID) || limit != 25) throw new AssertionError("principal or limit lost"); + return List.of(view); + } + @Override public Optional findOwnedById(UUID owner, UUID roomId) { + return owner.equals(ACCOUNT_ID) && roomId.equals(ROOM_ID) ? Optional.of(view) : Optional.empty(); + } }, () -> ACCOUNT_ID); var mvc = MockMvcBuilders.standaloneSetup(new OwnedRoomManagementController(service)).build(); @@ -40,6 +47,11 @@ void returnsOwnerConfigurationInvitationAndParticipationEvidence() throws Except .andExpect(jsonPath("$.items[0].accessType").value("SECRET")) .andExpect(jsonPath("$.items[0].invitations[0].credentialType").value("LINK")) .andExpect(jsonPath("$.items[0].participations[0].anonymousAlias").value("Bot A")); + mvc.perform(get("/api/v1/competition/rooms/mine/{roomId}", ROOM_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.roomId").value(ROOM_ID.toString())); + mvc.perform(get("/api/v1/competition/rooms/mine/{roomId}", id(99))) + .andExpect(status().isNotFound()); } private static UUID id(int suffix) { diff --git a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java index 06a98bdb..19c7db23 100644 --- a/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java +++ b/apps/backend-api/src/test/java/com/idea2strategy/backend/api/journey/ExternalToolDelegatedEditJourneyIntegrationTest.java @@ -1,6 +1,7 @@ package com.idea2strategy.backend.api.journey; import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -8,10 +9,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.nio.charset.StandardCharsets; import java.util.Base64; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.web.servlet.MockMvc; @@ -30,17 +33,18 @@ * runs the line end to end over HTTP: sign up, log in, create a strategy, delegate editing of it, * reach the edit service under that delegation, and lose access the moment it is revoked. * - *

It stops short of applying blocks. A new strategy has no groups and the delegated operations - * cannot create one, so a real apply needs a valid Basic skeleton with a catalog and instruments; - * that belongs in a strategy-authoring fixture rather than here. What this test does establish is - * the part that was actually broken — that the routes exist and that a granted delegation carries - * a request through authorization, which no stub could show. + *

It stops short of applying blocks. The delegated operation creates a group, but a real apply + * also needs a complete valid chain; that belongs in a strategy-authoring fixture rather than here. + * What this test establishes is the part that was actually broken — that the routes exist and that + * a granted delegation carries a request through authorization, which no stub could show. */ @Testcontainers(disabledWithoutDocker = true) @SpringBootTest class ExternalToolDelegatedEditJourneyIntegrationTest { private static final String EMAIL = "delegated-edit@example.com"; private static final String PASSWORD = "CorrectHorse!2026"; + private static final UUID INSTRUMENT_ID = UUID.fromString("11111111-1111-4111-8111-111111111111"); + private static final UUID SYMBOL_ID = UUID.fromString("22222222-2222-4222-8222-222222222222"); @Container static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer("postgres:16-alpine"); @@ -65,10 +69,22 @@ static void properties(DynamicPropertyRegistry registry) { @Autowired WebApplicationContext context; @Autowired ObjectMapper json; + @Autowired JdbcTemplate jdbc; @Test void anExternalToolDelegatesThenPreviewsAndAppliesABasicEdit() throws Exception { MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build(); + jdbc.update(""" + insert into market_data.instruments + (id, asset_type, primary_exchange_mic, currency_code, provider_reference, listed_at, created_at) + values (?::uuid, 'STOCK'::market_data.asset_type, 'XNAS', 'USD', 'delegated-edit-e2e', + date '2000-01-01', now()) + """, INSTRUMENT_ID.toString()); + jdbc.update(""" + insert into market_data.instrument_symbols + (id, instrument_id, exchange_mic, symbol, effective_from) + values (?::uuid, ?::uuid, 'XNAS', 'AAPL', timestamp with time zone '2000-01-01 00:00:00+00') + """, SYMBOL_ID.toString(), INSTRUMENT_ID.toString()); JsonNode signup = json.readTree(mvc.perform(post("/api/v1/auth/signup") .contentType(MediaType.APPLICATION_JSON) @@ -108,13 +124,20 @@ void anExternalToolDelegatesThenPreviewsAndAppliesABasicEdit() throws Exception // Returned exactly once. Nothing later in the journey can recover it. assertThat(grant.path("credential").asText()).isNotBlank(); + JsonNode instruments = json.readTree(mvc.perform(get("/api/v1/strategy-catalogs/basic/instruments") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString()); + String instrumentId = instruments.path("instruments").path(0).path("id").asText(); + assertThat(instrumentId).isNotBlank(); + String editBody = """ {"authorizationId":"%s","credentialId":"%s","operations":[ {"action":"ADD_GROUP","arguments":{"groupId":"buy","container":"BUY", "evaluationMode":"INDEPENDENT","allocationMode":"EQUAL", - "instrumentIds":["11111111-1111-4111-8111-111111111111"]}}]} + "instrumentIds":["%s"]}}]} """.formatted( - grant.path("authorizationId").asText(), grant.path("credentialId").asText()); + grant.path("authorizationId").asText(), grant.path("credentialId").asText(), instrumentId); // The strategy is untouched — {"groups":[],"mode":"BASIC"} — and the tool builds its // container anyway. Before this work the same call answered 404 because the route did not diff --git a/apps/idea2strategy-cli/README.md b/apps/idea2strategy-cli/README.md index 40958842..41dc7679 100644 --- a/apps/idea2strategy-cli/README.md +++ b/apps/idea2strategy-cli/README.md @@ -40,8 +40,10 @@ delegation create --name NAME --scopes STRATEGY_EDIT,STRATEGY_VALIDATE --strateg [--expires-at ISO_8601_INSTANT] delegation revoke --authorization-id ID strategy list [--limit 1..100] [--cursor CURSOR] +strategy get --strategy-id ID strategy create --name NAME [--description TEXT] strategy copy --strategy-id ID --name NAME +strategy delete --strategy-id ID --yes strategy edit preview --strategy-id ID --authorization-id ID --credential-id ID --operations-file FILE strategy edit apply --strategy-id ID --authorization-id ID --credential-id ID --operations-file FILE --preview-hash HASH --expected-edit-sequence SEQUENCE @@ -50,12 +52,33 @@ strategy release --strategy-id ID --validation-run-id ID --initial-cash-amount A --broker-rules-version VERSION --accounting-rules-version VERSION --precision-rules-version VERSION --fee-policy-id ID --buying-power-buffer-policy-id ID --dataset-manifest-id ID --execution-policy-version VERSION --candidate-conflict-policy JSON_OBJECT +bot list +bot get --bot-id ID +bot stop --bot-id ID [--reason-code USER_REQUEST] --yes +backtest create --bot-id ID --period-start YYYY-MM-DD --period-end YYYY-MM-DD +backtest list [--limit 1..200] [--offset 0..] +backtest get --run-id ID +backtest cancel --run-id ID [--reason-code USER_CANCELLED] --yes +backtest delete --run-id ID --yes +competition create --input-file FILE +competition list [--scope mine|public] [--limit 1..100] +competition get --room-id ID +competition delete --room-id ID [--reason-code USER_CANCELLED] --yes operator bootstrap --manifest REVIEWED.json --expected-sha256 LOWERCASE_SHA256 ``` A delegated tool can build a strategy from nothing: `ADD_GROUP` creates a trade container, naming -its side, how its blocks combine, how capital is split, and which instruments it trades. A strategy -holds one container per side, so a second container on a side already in use is refused. +its side, how its blocks combine, how capital is split, and which instruments it trades. Multiple +independent containers may use the same side, up to the Basic composition limit; each must have a +unique group id. +`SET_GROUP_INSTRUMENTS` replaces one container's complete official-instrument set, which keeps CLI, +backend validation, and the visual editor on the same persisted document format. + +Bots are immutable after creation. The CLI intentionally exposes only read and safe stop operations; +there is no bot update command. Backtest deletion is evidence-preserving soft deletion: queued work is +cancelled, running work is asked to stop cooperatively, and retained execution evidence is hidden from +the owner's normal reads only after it is terminal. Competition rooms expose create/read/cancel only; +`competition delete` maps to the domain cancellation workflow and never physically erases audit history. A delegation must name the strategies it may edit; one that names none would be granted and then authorize nothing. `--expires-at` is optional and defaults to 24 hours from the grant. The raw diff --git a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/ApiClient.java b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/ApiClient.java index 69584a59..e4685fcf 100644 --- a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/ApiClient.java +++ b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/ApiClient.java @@ -8,6 +8,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Duration; +import java.util.Map; final class ApiClient { private static final ObjectMapper JSON = new ObjectMapper(); @@ -32,14 +33,22 @@ JsonNode get(String path, String token) { } JsonNode post(String path, JsonNode body, String token) { - return send("POST", path, body, token); + return send("POST", path, body, token, Map.of()); + } + + JsonNode post(String path, JsonNode body, String token, Map headers) { + return send("POST", path, body, token, headers); } JsonNode delete(String path, String token) { - return send("DELETE", path, null, token); + return send("DELETE", path, null, token, Map.of()); } private JsonNode send(String method, String path, JsonNode body, String token) { + return send(method, path, body, token, Map.of()); + } + + private JsonNode send(String method, String path, JsonNode body, String token, Map headers) { HttpRequest.Builder request = HttpRequest.newBuilder(baseUri.resolve(path)) .timeout(Duration.ofSeconds(30)) .header("Accept", "application/json") @@ -50,6 +59,7 @@ private JsonNode send(String method, String path, JsonNode body, String token) { if (body != null) { request.header("Content-Type", "application/json"); } + headers.forEach(request::header); String encoded = body == null ? "" : body.toString(); switch (method) { case "GET" -> request.GET(); @@ -99,7 +109,12 @@ private static CliFailure httpFailure(int status, JsonNode body) { default -> status >= 500 ? "SERVICE_ERROR" : "REQUEST_REJECTED"; }; String code = body.path("code").asText(fallbackCode); - String message = body.path("message").asText("Idea2Strategy API rejected the request"); + String message = body.path("message").asText(); + if (message.isBlank()) { + message = body.path("detail").isTextual() + ? body.path("detail").asText() + : body.path("title").asText("Idea2Strategy API rejected the request"); + } return new CliFailure(exitCode, code, message, status); } } diff --git a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Arguments.java b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Arguments.java index 2aceb277..fabee12f 100644 --- a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Arguments.java +++ b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Arguments.java @@ -13,7 +13,7 @@ final class Arguments { * turn a forgotten value — `--email` with nothing after it — into the string "true" and let a * typo log in as nobody. */ - private static final List VALUELESS = List.of("--browser", "--no-open"); + private static final List VALUELESS = List.of("--browser", "--no-open", "--yes"); private final List positionals; private final Map options; diff --git a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java index 4cc795f6..65358737 100644 --- a/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java +++ b/apps/idea2strategy-cli/src/main/java/com/idea2strategy/cli/Idea2StrategyCli.java @@ -13,6 +13,7 @@ import java.math.BigDecimal; import java.net.URLEncoder; import java.time.Instant; +import java.time.LocalDate; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -25,19 +26,34 @@ public final class Idea2StrategyCli { private static final ObjectMapper JSON = new ObjectMapper(); private static final Set ALLOWED_EDIT_OPERATIONS = - Set.of("ADD_GROUP", "ADD_BLOCK", "REMOVE_BLOCK", "CONNECT_BLOCKS", "SET_VALUE"); + Set.of("ADD_GROUP", "ADD_BLOCK", "REMOVE_BLOCK", "CONNECT_BLOCKS", "SET_VALUE", + "SET_GROUP_INSTRUMENTS"); private static final Set AUTHENTICATED_COMMANDS = Set.of( "catalog.elements", "catalog.instruments", "delegation.create", "delegation.revoke", "strategy.list", + "strategy.get", "strategy.create", "strategy.copy", + "strategy.delete", "strategy.edit.preview", "strategy.edit.apply", "strategy.validate", - "strategy.release"); + "strategy.release", + "bot.list", + "bot.get", + "bot.stop", + "backtest.create", + "backtest.list", + "backtest.get", + "backtest.cancel", + "backtest.delete", + "competition.create", + "competition.list", + "competition.get", + "competition.delete"); private Idea2StrategyCli() {} @@ -80,6 +96,7 @@ private static JsonNode execute(Invocation invocation, InputStream stdin, Map command = arguments.positionals(); if (command.equals(List.of("tool-contract"))) { @@ -102,12 +119,26 @@ private static JsonNode execute(Invocation invocation, InputStream stdin, Map delegationCreate(arguments, api, token); case "delegation.revoke" -> delegationRevoke(arguments, api, token); case "strategy.list" -> strategyList(arguments, api, token); + case "strategy.get" -> strategyGet(arguments, api, token); case "strategy.create" -> strategyCreate(arguments, api, token); case "strategy.copy" -> strategyCopy(arguments, api, token); + case "strategy.delete" -> strategyDelete(arguments, api, token); case "strategy.edit.preview" -> basicEdit(arguments, api, token, false); case "strategy.edit.apply" -> basicEdit(arguments, api, token, true); case "strategy.validate" -> strategyValidate(arguments, api, token); case "strategy.release" -> strategyRelease(arguments, api, token); + case "bot.list" -> botList(arguments, api, token); + case "bot.get" -> botGet(arguments, api, token); + case "bot.stop" -> botStop(arguments, api, token); + case "backtest.create" -> backtestCreate(arguments, api, token); + case "backtest.list" -> backtestList(arguments, backtestApi, token); + case "backtest.get" -> backtestGet(arguments, backtestApi, token); + case "backtest.cancel" -> backtestCancel(arguments, backtestApi, token); + case "backtest.delete" -> backtestDelete(arguments, backtestApi, token); + case "competition.create" -> competitionCreate(arguments, api, token); + case "competition.list" -> competitionList(arguments, api, token); + case "competition.get" -> competitionGet(arguments, api, token); + case "competition.delete" -> competitionDelete(arguments, api, token); default -> throw new IllegalStateException("Unmapped authenticated command"); }; } @@ -329,6 +360,17 @@ private static JsonNode strategyCreate(Arguments args, ApiClient api, String tok return api.post("/api/v1/strategies", body, token); } + private static JsonNode strategyGet(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--strategy-id"); + return api.get("/api/v1/strategies/" + segment(args.required("--strategy-id")) + "/document", token); + } + + private static JsonNode strategyDelete(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--strategy-id", "--yes"); + requireConfirmation(args, "Strategy deletion"); + return api.delete("/api/v1/strategies/" + segment(args.required("--strategy-id")), token); + } + private static JsonNode strategyCopy(Arguments args, ApiClient api, String token) { args.rejectUnknown("--strategy-id", "--name"); ObjectNode body = JSON.createObjectNode().put("name", args.required("--name")); @@ -423,6 +465,149 @@ private static JsonNode strategyRelease(Arguments args, ApiClient api, String to + "/releases", body, token); } + private static JsonNode botList(Arguments args, ApiClient api, String token) { + args.rejectUnknown(); + return api.get("/api/v1/bots/operations", token); + } + + private static JsonNode botGet(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--bot-id"); + String botId = args.required("--bot-id"); + JsonNode bots = api.get("/api/v1/bots/operations", token); + if (!bots.isArray()) { + throw new CliFailure(6, "INVALID_SERVER_RESPONSE", "Bot operations response was not a list"); + } + for (JsonNode bot : bots) { + if (botId.equals(bot.path("botId").asText())) { + return bot; + } + } + throw new CliFailure(5, "BOT_NOT_FOUND", "Owned bot was not found"); + } + + private static JsonNode botStop(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--bot-id", "--reason-code", "--yes"); + requireConfirmation(args, "Bot stop"); + ObjectNode body = JSON.createObjectNode().put("reasonCode", args.optional("--reason-code", "USER_REQUEST")); + return api.post("/api/v1/bots/" + segment(args.required("--bot-id")) + "/stop", body, token); + } + + private static JsonNode backtestCreate(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--bot-id", "--period-start", "--period-end", "--idempotency-key"); + String start = validDate(args.required("--period-start"), "--period-start"); + String end = validDate(args.required("--period-end"), "--period-end"); + if (LocalDate.parse(start).isAfter(LocalDate.parse(end))) { + throw Arguments.usage("--period-start must not be after --period-end"); + } + ObjectNode body = JSON.createObjectNode().put("periodStart", start).put("periodEnd", end); + return api.post( + "/api/v1/bots/" + segment(args.required("--bot-id")) + "/backtests", + body, + token, + Map.of("Idempotency-Key", args.required("--idempotency-key"))); + } + + private static JsonNode backtestList(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--limit", "--offset"); + int limit = boundedInteger(args.optional("--limit", "50"), "--limit", 1, 200); + int offset = boundedInteger(args.optional("--offset", "0"), "--offset", 0, Integer.MAX_VALUE); + return api.get("/api/v1/backtests?limit=" + limit + "&offset=" + offset, token); + } + + private static JsonNode backtestGet(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--run-id"); + return api.get("/api/v1/backtests/" + segment(args.required("--run-id")), token); + } + + private static JsonNode backtestCancel(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--run-id", "--reason-code", "--yes"); + requireConfirmation(args, "Backtest cancellation"); + ObjectNode body = JSON.createObjectNode().put("reasonCode", args.optional("--reason-code", "USER_CANCELLED")); + return api.post("/api/v1/backtests/" + segment(args.required("--run-id")) + "/cancellation", body, token); + } + + private static JsonNode backtestDelete(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--run-id", "--yes"); + requireConfirmation(args, "Backtest deletion"); + return api.delete("/api/v1/backtests/" + segment(args.required("--run-id")), token); + } + + private static JsonNode competitionCreate(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--input-file"); + JsonNode body = readJsonObject(args.required("--input-file"), "competition input"); + return api.post("/api/v1/competition/rooms", body, token); + } + + private static JsonNode competitionList(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--scope", "--limit", "--query"); + int limit = boundedInteger(args.optional("--limit", "50"), "--limit", 1, 100); + String scope = args.optional("--scope", "mine"); + if ("mine".equals(scope)) { + return api.get("/api/v1/competition/rooms/mine?limit=" + limit, token); + } + if ("public".equals(scope)) { + String path = "/api/v1/competition/rooms/public?limit=" + limit; + if (args.optional("--query") != null) { + path += "&q=" + segment(args.optional("--query")); + } + return api.get(path, token); + } + throw Arguments.usage("--scope must be mine or public"); + } + + private static JsonNode competitionGet(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--room-id"); + return api.get("/api/v1/competition/rooms/mine/" + segment(args.required("--room-id")), token); + } + + private static JsonNode competitionDelete(Arguments args, ApiClient api, String token) { + args.rejectUnknown("--room-id", "--reason-code", "--yes"); + requireConfirmation(args, "Competition cancellation"); + ObjectNode body = JSON.createObjectNode().put("reasonCode", args.optional("--reason-code", "CREATOR_REQUEST")); + return api.post("/api/v1/competition/rooms/" + segment(args.required("--room-id")) + + "/cancellation", body, token); + } + + private static void requireConfirmation(Arguments args, String operation) { + if (!args.flag("--yes")) { + throw Arguments.usage(operation + " requires --yes"); + } + } + + private static int boundedInteger(String value, String option, int minimum, int maximum) { + try { + int parsed = Integer.parseInt(value); + if (parsed < minimum || parsed > maximum) { + throw Arguments.usage(option + " must be between " + minimum + " and " + maximum); + } + return parsed; + } catch (NumberFormatException exception) { + throw Arguments.usage(option + " must be an integer"); + } + } + + private static String validDate(String value, String option) { + try { + return LocalDate.parse(value).toString(); + } catch (java.time.format.DateTimeParseException exception) { + throw Arguments.usage(option + " must use YYYY-MM-DD"); + } + } + + private static JsonNode readJsonObject(String fileName, String label) { + try { + JsonNode value = JSON.readTree(Files.readString(Path.of(fileName))); + if (!value.isObject()) { + throw new CliFailure(5, "INVALID_INPUT", label + " file must contain a JSON object"); + } + return value; + } catch (CliFailure failure) { + throw failure; + } catch (Exception exception) { + throw new CliFailure(5, "INVALID_INPUT", label + " file is not valid JSON"); + } + } + private static ArrayNode readOperations(String fileName) { try { JsonNode value = JSON.readTree(Files.readString(Path.of(fileName))); @@ -469,6 +654,7 @@ private static void putOptional(ObjectNode target, String name, String value) { private record Invocation( String baseUrl, + String backtestBaseUrl, Path configDirectory, String environmentToken, List commandArguments, @@ -478,6 +664,8 @@ static Invocation parse(String[] raw, Map environment) { List values = new ArrayList<>(Arrays.asList(raw)); String baseUrl = takeGlobal(values, "--base-url", environment.getOrDefault( "I2S_BASE_URL", "http://localhost:8080")); + String backtestBaseUrl = takeGlobal(values, "--backtest-base-url", environment.getOrDefault( + "I2S_BACKTEST_BASE_URL", baseUrl)); String defaultConfig = environment.get("I2S_CONFIG_DIR"); if (defaultConfig == null || defaultConfig.isBlank()) { defaultConfig = Path.of(System.getProperty("user.home"), ".idea2strategy").toString(); @@ -492,6 +680,7 @@ static Invocation parse(String[] raw, Map environment) { case "catalog" -> 2; case "delegation" -> 2; case "strategy" -> words.size() >= 2 && "edit".equals(words.get(1)) ? 3 : 2; + case "bot", "backtest", "competition" -> 2; case "operator" -> 2; default -> 1; }; @@ -499,7 +688,7 @@ static Invocation parse(String[] raw, Map environment) { throw Arguments.usage("Incomplete command"); } String commandName = String.join(".", words.subList(0, commandWordCount)); - return new Invocation(baseUrl, Path.of(config), blankToNull(environment.get("I2S_TOKEN")), + return new Invocation(baseUrl, backtestBaseUrl, Path.of(config), blankToNull(environment.get("I2S_TOKEN")), List.copyOf(values), commandName); } diff --git a/apps/idea2strategy-cli/src/main/resources/idea2strategy-ai-tool-contract.json b/apps/idea2strategy-cli/src/main/resources/idea2strategy-ai-tool-contract.json index ee4fd1de..20081057 100644 --- a/apps/idea2strategy-cli/src/main/resources/idea2strategy-ai-tool-contract.json +++ b/apps/idea2strategy-cli/src/main/resources/idea2strategy-ai-tool-contract.json @@ -15,6 +15,12 @@ "purpose": "Read the official element codes and their declared parameters. Never guess a code; an unpublished code is refused.", "command": ["catalog", "elements"], "requiredOutputFields": ["data.elements"] + }, + { + "name": "read-strategy", + "purpose": "Read the complete persisted Basic strategy document before planning an edit.", + "command": ["strategy", "get", "--strategy-id", ""], + "requiredOutputFields": ["data"] } ], "workflow": [ @@ -70,8 +76,15 @@ "ADD_BLOCK", "REMOVE_BLOCK", "CONNECT_BLOCKS", - "SET_VALUE" + "SET_VALUE", + "SET_GROUP_INSTRUMENTS" ], + "resourceLifecycle": { + "strategies": ["create", "read", "update-through-reviewed-edit", "delete"], + "bots": ["read", "stop"], + "backtests": ["create", "read", "cancel", "evidence-preserving-delete"], + "competitions": ["create", "read", "cancel-as-delete"] + }, "allowedDelegationScopes": [ "STRATEGY_EDIT", "STRATEGY_VALIDATE" @@ -79,7 +92,9 @@ "forbiddenCapabilities": [ "ARBITRARY_CODE", "DIRECT_ORDER", - "EXTERNAL_DATA" + "EXTERNAL_DATA", + "BOT_UPDATE", + "COMPETITION_UPDATE" ], "exitCodes": { "0": "success", diff --git a/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/ExternalAiToolE2eTest.java b/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/ExternalAiToolE2eTest.java index 5d458f05..5127bbac 100644 --- a/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/ExternalAiToolE2eTest.java +++ b/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/ExternalAiToolE2eTest.java @@ -58,9 +58,11 @@ void publishesMachineReadableToolContractWithoutCredentials() throws Exception { assertThat(contract.path("reviewGate").path("applyRequiresPreviewHash").asBoolean()).isTrue(); assertThat(contract.path("allowedEditOperations")).extracting(JsonNode::asText) .containsExactlyInAnyOrder( - "ADD_GROUP", "ADD_BLOCK", "REMOVE_BLOCK", "CONNECT_BLOCKS", "SET_VALUE"); + "ADD_GROUP", "ADD_BLOCK", "REMOVE_BLOCK", "CONNECT_BLOCKS", "SET_VALUE", + "SET_GROUP_INSTRUMENTS"); assertThat(contract.path("forbiddenCapabilities")).extracting(JsonNode::asText) - .containsExactlyInAnyOrder("ARBITRARY_CODE", "DIRECT_ORDER", "EXTERNAL_DATA"); + .containsExactlyInAnyOrder( + "ARBITRARY_CODE", "DIRECT_ORDER", "EXTERNAL_DATA", "BOT_UPDATE", "COMPETITION_UPDATE"); assertThat(requestCount).hasValue(0); } diff --git a/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/Idea2StrategyCliTest.java b/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/Idea2StrategyCliTest.java index 73986891..40441e31 100644 --- a/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/Idea2StrategyCliTest.java +++ b/apps/idea2strategy-cli/src/test/java/com/idea2strategy/cli/Idea2StrategyCliTest.java @@ -31,6 +31,7 @@ class Idea2StrategyCliTest { private final AtomicReference requestPath = new AtomicReference<>(); private final AtomicReference requestBody = new AtomicReference<>(); private final AtomicReference authorization = new AtomicReference<>(); + private final AtomicReference idempotencyKey = new AtomicReference<>(); @BeforeEach void startServer() throws IOException { @@ -259,6 +260,92 @@ void strategyReleaseSendsEveryLockedLaunchAndBacktestInput() throws Exception { assertThat(body.path("candidateConflictPolicy").path("policy").asText()).isEqualTo("FIRST_WINS"); } + @Test + void strategyAndBotLifecycleCommandsUseOnlyTheSupportedServerActions() throws Exception { + Files.writeString(tempDir.resolve("credentials.json"), "{\"sessionToken\":\"stored-token\"}"); + + assertRoute("strategy", "get", "--strategy-id", "strategy 1", "GET", + "/api/v1/strategies/strategy%201/document"); + assertRoute("strategy", "delete", "--strategy-id", "s1", "--yes", "DELETE", + "/api/v1/strategies/s1"); + assertRoute("bot", "list", "GET", "/api/v1/bots/operations"); + assertRoute("bot", "stop", "--bot-id", "b1", "--reason-code", "USER_REQUEST", "--yes", + "POST", "/api/v1/bots/b1/stop"); + assertThat(requestBody.get()).isEqualTo("{\"reasonCode\":\"USER_REQUEST\"}"); + } + + @Test + void destructiveCommandsRequireExplicitConfirmationBeforeNetworkAccess() throws Exception { + Files.writeString(tempDir.resolve("credentials.json"), "{\"sessionToken\":\"stored-token\"}"); + + Result result = run("", "--base-url", baseUrl, "--config-dir", tempDir.toString(), + "bot", "stop", "--bot-id", "b1", "--reason-code", "USER_REQUEST"); + + assertThat(result.exitCode()).isEqualTo(2); + assertThat(requestPath.get()).isNull(); + assertThat(result.stderr()).contains("--yes"); + } + + @Test + void backtestCommandsUseBackendForCreationAndTheBacktestOriginForRunLifecycle() throws Exception { + Files.writeString(tempDir.resolve("credentials.json"), "{\"sessionToken\":\"stored-token\"}"); + HttpServer backtestServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + AtomicReference backtestPath = new AtomicReference<>(); + AtomicReference backtestMethod = new AtomicReference<>(); + backtestServer.createContext("/", exchange -> { + backtestPath.set(exchange.getRequestURI().toString()); + backtestMethod.set(exchange.getRequestMethod()); + respondWithoutRecording(exchange, 200, "{\"items\":[],\"limit\":25,\"offset\":0}"); + }); + backtestServer.start(); + String backtestUrl = "http://127.0.0.1:" + backtestServer.getAddress().getPort(); + try { + Result created = run("", "--base-url", baseUrl, "--backtest-base-url", backtestUrl, + "--config-dir", tempDir.toString(), "backtest", "create", "--bot-id", "b1", + "--period-start", "2024-01-01", "--period-end", "2024-03-31", + "--idempotency-key", "cli-backtest-1"); + assertThat(created.exitCode()).isZero(); + assertThat(requestPath.get()).isEqualTo("/api/v1/bots/b1/backtests"); + assertThat(idempotencyKey.get()).isEqualTo("cli-backtest-1"); + + Result listed = run("", "--base-url", baseUrl, "--backtest-base-url", backtestUrl, + "--config-dir", tempDir.toString(), "backtest", "list", "--limit", "25", "--offset", "0"); + assertThat(listed.exitCode()).isZero(); + assertThat(backtestMethod.get()).isEqualTo("GET"); + assertThat(backtestPath.get()).isEqualTo("/api/v1/backtests?limit=25&offset=0"); + + Result deleted = run("", "--base-url", baseUrl, "--backtest-base-url", backtestUrl, + "--config-dir", tempDir.toString(), "backtest", "delete", "--run-id", "r1", "--yes"); + assertThat(deleted.exitCode()).isZero(); + assertThat(backtestMethod.get()).isEqualTo("DELETE"); + assertThat(backtestPath.get()).isEqualTo("/api/v1/backtests/r1"); + } finally { + backtestServer.stop(0); + } + } + + @Test + void competitionCommandsCreateReadAndCancelWithoutExposingAnUpdateCommand() throws Exception { + Files.writeString(tempDir.resolve("credentials.json"), "{\"sessionToken\":\"stored-token\"}"); + Path input = tempDir.resolve("room.json"); + Files.writeString(input, "{\"name\":\"CLI Room\",\"accessType\":\"PUBLIC\"}"); + + assertRoute("competition", "create", "--input-file", input.toString(), "POST", + "/api/v1/competition/rooms"); + assertThat(requestBody.get()).contains("CLI Room"); + assertRoute("competition", "list", "--scope", "mine", "--limit", "20", "GET", + "/api/v1/competition/rooms/mine?limit=20"); + assertRoute("competition", "get", "--room-id", "room-1", "GET", + "/api/v1/competition/rooms/mine/room-1"); + assertRoute("competition", "delete", "--room-id", "room-1", "--reason-code", "CREATOR_REQUEST", + "--yes", "POST", "/api/v1/competition/rooms/room-1/cancellation"); + + Result update = run("", "--base-url", baseUrl, "--config-dir", tempDir.toString(), + "competition", "update", "--room-id", "room-1"); + assertThat(update.exitCode()).isEqualTo(2); + assertThat(JSON.readTree(update.stderr()).path("error").path("code").asText()).isEqualTo("USAGE_ERROR"); + } + private void assertRoute(String... commandAndExpectation) throws Exception { int length = commandAndExpectation.length; String expectedMethod = commandAndExpectation[length - 2]; @@ -292,6 +379,15 @@ private void respond(HttpExchange exchange, int status, String body) throws IOEx requestPath.set(exchange.getRequestURI().toString()); requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); authorization.set(exchange.getRequestHeaders().getFirst("Authorization")); + idempotencyKey.set(exchange.getRequestHeaders().getFirst("Idempotency-Key")); + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + exchange.getResponseBody().write(bytes); + exchange.close(); + } + + private void respondWithoutRecording(HttpExchange exchange, int status, String body) throws IOException { byte[] bytes = body.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.sendResponseHeaders(status, bytes.length); diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryPort.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryPort.java index 0eb04b3a..fa0bd54e 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryPort.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryPort.java @@ -1,8 +1,10 @@ package com.idea2strategy.backend.application.competition; import java.util.List; +import java.util.Optional; import java.util.UUID; public interface OwnedRoomManagementQueryPort { List findOwnedBy(UUID ownerAccountId, int limit); + Optional findOwnedById(UUID ownerAccountId, UUID roomId); } diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryService.java index b41e0fae..63df5e31 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryService.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryService.java @@ -3,6 +3,8 @@ import com.idea2strategy.backend.application.common.CurrentPrincipal; import java.util.List; import java.util.Objects; +import java.util.Optional; +import java.util.UUID; public final class OwnedRoomManagementQueryService { private final OwnedRoomManagementQueryPort port; @@ -19,4 +21,8 @@ public List list(int limit) { } return port.findOwnedBy(principal.accountId(), limit); } + + public Optional get(UUID roomId) { + return port.findOwnedById(principal.accountId(), Objects.requireNonNull(roomId, "roomId")); + } } diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBacktestCapabilityValidator.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBacktestCapabilityValidator.java index 1d156e85..0cd93e6e 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBacktestCapabilityValidator.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBacktestCapabilityValidator.java @@ -62,11 +62,11 @@ public BasicBacktestCapabilityResult validate( var requiredFeeds = new TreeSet<>(FEED_ORDER); var requiredFeatures = new TreeSet(); - var resolvedResolutions = new TreeSet(); var issues = new ArrayList(); for (int groupIndex = 0; groupIndex < assembly.groups().size(); groupIndex++) { var group = assembly.groups().get(groupIndex); + var flowResolutions = new TreeSet(); for (int blockIndex = 0; blockIndex < group.blocks().size(); blockIndex++) { var block = group.blocks().get(blockIndex); String location = "groups[" + groupIndex + "].blocks[" + blockIndex + "].elementCode"; @@ -77,15 +77,14 @@ public BasicBacktestCapabilityResult validate( catalogFeatures, requiredFeeds, requiredFeatures, - resolvedResolutions, + flowResolutions, issues); } - } - - if (resolvedResolutions.size() > 1) { - add(issues, "BACKTEST_MULTIPLE_RESOLUTIONS", "groups", - "A production backtest strategy must use one resolution across all blocks", - resolvedResolutions.stream().map(value -> "resolution:" + value).toList()); + if (flowResolutions.size() > 1) { + add(issues, "BACKTEST_MULTIPLE_RESOLUTIONS", "groups[" + groupIndex + "]", + "Each backtest flow must use one resolution, but independent flows may use different resolutions", + flowResolutions.stream().map(value -> "resolution:" + value).toList()); + } } return new BasicBacktestCapabilityResult( diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidator.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidator.java index ae74f3b9..655288aa 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidator.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidator.java @@ -21,7 +21,8 @@ public final class BasicBlockAssemblyValidator { private static final int MAX_SECTIONS = 4; - private static final int MAX_CONTAINERS_PER_SIDE = MAX_SECTIONS; + private static final int MAX_CONTAINERS_PER_SIDE_PER_SECTION = 4; + private static final int MAX_CONTAINERS_PER_SIDE = MAX_SECTIONS * MAX_CONTAINERS_PER_SIDE_PER_SECTION; private static final int MAX_INSTRUMENTS_PER_GROUP = 5; private static final int MAX_CONDITIONS_PER_GROUP = 5; private final ObjectMapper objectMapper; @@ -53,14 +54,14 @@ public BasicBlockAssemblyValidationResult validate(BasicBlockAssembly assembly, .map(BasicBlockGroup::allocationGroupId).distinct().count() > MAX_CONTAINERS_PER_SIDE) { add(issues, "TOO_MANY_BUY_CONTAINERS", "groups", - "A Basic strategy may contain at most four buy containers"); + "A Basic strategy may contain at most sixteen buy containers"); } if (assembly.groups().stream() .filter(group -> group.container() == BasicBlockAssembly.TradeContainer.SELL) .map(BasicBlockGroup::allocationGroupId).distinct().count() > MAX_CONTAINERS_PER_SIDE) { add(issues, "TOO_MANY_SELL_CONTAINERS", "groups", - "A Basic strategy may contain at most four sell containers"); + "A Basic strategy may contain at most sixteen sell containers"); } for (int groupIndex = 0; groupIndex < assembly.groups().size(); groupIndex++) { diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicStrategyDraftCommandService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicStrategyDraftCommandService.java index 4973dcc9..d6d83130 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicStrategyDraftCommandService.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/BasicStrategyDraftCommandService.java @@ -17,7 +17,7 @@ public final class BasicStrategyDraftCommandService { public static final String SEMANTIC_SCHEMA_VERSION = "basic-semantic/v1"; public static final String PRESENTATION_SCHEMA_VERSION = "basic-presentation/v1"; private static final String EMPTY_SEMANTIC_DOCUMENT = "{\"groups\":[],\"mode\":\"BASIC\"}"; - private static final String EMPTY_PRESENTATION_DOCUMENT = + static final String EMPTY_PRESENTATION_DOCUMENT = "{\"positions\":{},\"viewport\":{\"x\":0,\"y\":0,\"zoom\":1}}"; private final BasicStrategyDraftCommandPort commandPort; diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java index ea4abc23..f4605d60 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditService.java @@ -10,8 +10,10 @@ import com.idea2strategy.backend.domain.strategy.StrategyDocument; import com.idea2strategy.backend.domain.strategy.StrategyElementDefinition; import com.idea2strategy.backend.domain.strategy.StrategyMode; +import com.idea2strategy.backend.domain.strategy.SupportedInstrument; import java.time.Clock; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.NoSuchElementException; @@ -26,6 +28,7 @@ public final class DelegatedBasicStrategyEditService { private static final String REMOVE_BLOCK = "REMOVE_BLOCK"; private static final String CONNECT_BLOCKS = "CONNECT_BLOCKS"; private static final String SET_VALUE = "SET_VALUE"; + private static final String SET_GROUP_INSTRUMENTS = "SET_GROUP_INSTRUMENTS"; private final StrategyQueryPort strategyPort; private final StrategyDocumentQueryPort documentPort; @@ -87,13 +90,15 @@ public StrategyDocument apply( "Only a valid official Basic strategy preview can be applied"); } StrategyDocument current = requireDocument(editor, strategyId); + String resetPresentation = StrategyDocumentJson.canonicalize( + BasicStrategyDraftCommandService.EMPTY_PRESENTATION_DOCUMENT); var replacement = current.replace( preview.proposedSemanticDocument(), - current.presentationDocument(), + resetPresentation, BasicStrategyDraftCommandService.SEMANTIC_SCHEMA_VERSION, - current.presentationSchemaVersion(), + BasicStrategyDraftCommandService.PRESENTATION_SCHEMA_VERSION, preview.previewHash(), - current.presentationHash(), + StrategyDocumentJson.sha256(resetPresentation), clock.instant()); return switch (commandPort.replace(replacement, expectedEditSequence, editor, clock.instant())) { case UPDATED -> replacement; @@ -132,9 +137,11 @@ private DelegatedBasicEditPreview prepare( } Map definitions = catalog.elements().stream() .collect(Collectors.toMap(StrategyElementDefinition::elementCode, Function.identity())); + Map instruments = catalog.instruments().stream() + .collect(Collectors.toMap(SupportedInstrument::id, Function.identity())); var changes = new ArrayList(); for (DelegatedBasicEditOperation operation : operations) { - applyOperation(root, definitions, operation, changes); + applyOperation(root, definitions, instruments, operation, changes); } String proposed = canonical(root); BasicBlockAssembly assembly = parseAssembly(proposed); @@ -163,14 +170,16 @@ private StrategyDocument requireDocument(DelegatedStrategyEditor editor, UUID st private void applyOperation( ObjectNode root, Map definitions, + Map instruments, DelegatedBasicEditOperation operation, List changes) { switch (operation.action()) { - case ADD_GROUP -> addGroup(root, operation.arguments(), changes); + case ADD_GROUP -> addGroup(root, instruments, operation.arguments(), changes); case ADD_BLOCK -> addBlock(root, definitions, operation.arguments(), changes); case REMOVE_BLOCK -> removeBlock(root, operation.arguments(), changes); case CONNECT_BLOCKS -> connectBlocks(root, operation.arguments(), changes); case SET_VALUE -> setValue(root, definitions, operation.arguments(), changes); + case SET_GROUP_INSTRUMENTS -> setGroupInstruments(root, instruments, operation.arguments(), changes); default -> throw new DelegatedBasicEditRejectedException( "Delegated operation is not allowed: " + operation.action()); } @@ -181,24 +190,21 @@ private void applyOperation( * *

A container is more than a bag of blocks: it carries the side, how its blocks combine, how * capital is split, and which instruments it trades. Those are the parts of a strategy a - * customer is most likely to have an opinion about, so the arguments are all explicit and the - * one-container-per-side rule is enforced here rather than left to validation — a second BUY - * container has no defined meaning, and refusing it at the operation says so where the tool can - * still react. + * customer is most likely to have an opinion about, so the arguments are all explicit. Multiple + * containers on the same side are independent strategies; their ids remain unique and the + * complete document is still checked against the Basic composition limits before it is applied. */ - private void addGroup(ObjectNode root, Map arguments, List changes) { + private void addGroup( + ObjectNode root, + Map instruments, + Map arguments, + List changes) { String groupId = text(arguments, "groupId"); ArrayNode groups = array(root, "groups"); if (find(groups, "id", groupId) != null) { throw new DelegatedBasicEditRejectedException("Block group id already exists: " + groupId); } String container = enumeration(arguments, "container", BasicBlockAssembly.TradeContainer.class); - for (JsonNode existing : groups) { - if (container.equals(existing.path("container").asText())) { - throw new DelegatedBasicEditRejectedException( - "A strategy holds one container per side; " + container + " already exists"); - } - } ObjectNode group = objectMapper.createObjectNode(); group.put("id", groupId); @@ -209,26 +215,51 @@ private void addGroup(ObjectNode root, Map arguments, List arguments) { + private void setGroupInstruments( + ObjectNode root, + Map instruments, + Map arguments, + List changes) { + String groupId = text(arguments, "groupId"); + ArrayNode replacement = instrumentIds(arguments, instruments); + group(root, groupId).set("instrumentIds", replacement); + var names = new ArrayList(); + for (JsonNode value : replacement) { + names.add(instruments.get(UUID.fromString(value.asText())).symbol()); + } + changes.add("SET_GROUP_INSTRUMENTS " + groupId + " " + String.join(",", names)); + } + + private ArrayNode instrumentIds( + Map arguments, Map allowedInstruments) { Object value = arguments.get("instrumentIds"); if (!(value instanceof List values) || values.isEmpty()) { throw new DelegatedBasicEditRejectedException( "A container must name the instruments it trades: instrumentIds"); } ArrayNode instruments = objectMapper.createArrayNode(); + var seen = new LinkedHashSet(); for (Object instrument : values) { if (!(instrument instanceof String text)) { throw new DelegatedBasicEditRejectedException("instrumentIds must be identifiers"); } try { - instruments.add(UUID.fromString(text).toString()); + UUID id = UUID.fromString(text); + if (!seen.add(id)) { + throw new DelegatedBasicEditRejectedException("instrumentIds must not contain duplicate ids"); + } + if (!allowedInstruments.containsKey(id)) { + throw new DelegatedBasicEditRejectedException( + "Instrument is not present in the official catalog: " + id); + } + instruments.add(id.toString()); } catch (IllegalArgumentException exception) { throw new DelegatedBasicEditRejectedException("Instrument id is not a UUID: " + text); } 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 index 1773261f..1fa1fb5f 100644 --- 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 @@ -6,6 +6,7 @@ import com.idea2strategy.backend.application.strategy.StrategyReleaseInputCatalog.Dataset; import com.idea2strategy.backend.application.strategy.StrategyReleaseInputCatalog.ExecutionPolicy; import java.time.Duration; +import java.time.Instant; import java.time.LocalDate; import java.util.ArrayList; import java.util.Comparator; @@ -14,6 +15,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; /** Selects the complete immutable official input set; callers never choose catalog identifiers. */ public final class OfficialBacktestInputSelector { @@ -23,9 +25,10 @@ private OfficialBacktestInputSelector() {} public static Selection select(String compiledPlanDocument, StrategyReleaseInputCatalog catalog) { ExecutionPolicy policy = selectPolicy(catalog); - Set requiredResolutions = requiredResolutions(compiledPlanDocument); + Requirements requirements = requirements(compiledPlanDocument); - return selectDatasets(policy, requiredResolutions, null, null, catalog); + return selectDatasets( + policy, requirements, policy.periodStart(), policy.periodEnd().minusDays(1), catalog); } public static Selection select( @@ -37,9 +40,9 @@ public static Selection select( throw new IllegalArgumentException("A valid requested backtest period is required"); } ExecutionPolicy policy = selectPolicy(catalog); - Set requiredResolutions = requiredResolutions(compiledPlanDocument); + Requirements requirements = requirements(compiledPlanDocument); - return selectDatasets(policy, requiredResolutions, requestedStart, requestedEnd, catalog); + return selectDatasets(policy, requirements, requestedStart, requestedEnd, catalog); } public static ExecutionPolicy selectPolicy(StrategyReleaseInputCatalog catalog) { @@ -56,79 +59,116 @@ public static ExecutionPolicy selectPolicy(StrategyReleaseInputCatalog catalog) private static Selection selectDatasets( ExecutionPolicy policy, - Set requiredResolutions, + Requirements requirements, LocalDate requestedStart, LocalDate requestedEnd, StrategyReleaseInputCatalog catalog) { - Map> byPeriod = new HashMap<>(); - catalog.datasets().stream() + if (requestedStart.isBefore(policy.periodStart()) + || requestedEnd.isAfter(policy.periodEnd().minusDays(1))) { + throw new ImmutableStrategyReleaseRejectedException( + "Requested official backtest period is outside the locked execution policy"); + } + List candidates = 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 -> requestedStart == null || !dataset.periodStart().isAfter(requestedStart)) - .filter(dataset -> requestedEnd == null || !dataset.periodEnd().isBefore(requestedEnd)) + .filter(dataset -> !dataset.periodEnd().isAfter(policy.periodEnd().plusDays(1))) .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)); - - Candidate selectedCandidate = byPeriod.entrySet().stream() - .filter(entry -> entry.getValue().stream() - .map(dataset -> normalizeResolution(dataset.resolution())) - .collect(java.util.stream.Collectors.toSet()) - .containsAll(requiredResolutions)) - .map(entry -> candidate(entry.getKey(), entry.getValue(), requiredResolutions)) - .sorted((left, right) -> { - int revisionComparison = compareRevisionVectors(right.revisionVector(), left.revisionVector()); - if (revisionComparison != 0) return revisionComparison; - int exactComparison = Integer.compare(periodRank(left.period(), policy), periodRank(right.period(), policy)); - if (exactComparison != 0) return exactComparison; - int endComparison = right.period().end().compareTo(left.period().end()); - if (endComparison != 0) return endComparison; - return left.period().start().compareTo(right.period().start()); - }) - .findFirst() - .orElseThrow(() -> new ImmutableStrategyReleaseRejectedException( - "No coherent official backtest dataset set covers the requested period and required resolutions " - + String.join(", ", requiredResolutions.stream() - .sorted(Comparator.comparing(OfficialBacktestInputSelector::duration)) - .toList()))); + .filter(dataset -> requirements.resolutions().contains(normalizeResolution(dataset.resolution()))) + .toList(); - return new Selection(policy, selectedCandidate.datasets()); + Set selected = new LinkedHashSet<>(); + for (String resolution : requirements.resolutions().stream() + .sorted(Comparator.comparing(OfficialBacktestInputSelector::duration)) + .toList()) { + long warmupDays = requirements.warmupDays().getOrDefault(resolution, 1L); + LocalDate coverageStart = requestedStart.minusDays(warmupDays); + if (coverageStart.isBefore(policy.periodStart())) coverageStart = policy.periodStart(); + LocalDate coverageEnd = requestedEnd.plusDays(1); + Set instruments = requirements.instruments().getOrDefault(resolution, Set.of()); + List scopes = instruments.isEmpty() ? java.util.Arrays.asList((UUID) null) : instruments.stream().sorted().toList(); + for (UUID instrument : scopes) { + UUID requiredInstrument = instrument; + selected.addAll(minimumCover( + resolution, + coverageStart, + coverageEnd, + candidates.stream() + .filter(dataset -> resolution.equals(normalizeResolution(dataset.resolution()))) + .filter(dataset -> dataset.instrumentId() == null + || dataset.instrumentId().equals(requiredInstrument)) + .toList())); + } + } + return new Selection(policy, List.copyOf(selected)); } - private static Candidate candidate(Period period, List periodDatasets, Set requiredResolutions) { - List selected = requiredResolutions.stream() - .sorted(Comparator.comparing(OfficialBacktestInputSelector::duration)) - .map(resolution -> periodDatasets.stream() - .filter(dataset -> resolution.equals(normalizeResolution(dataset.resolution()))) - .max(Comparator.comparingInt(Dataset::revisionNumber) - .thenComparing(Dataset::availableAt) - .thenComparing(dataset -> dataset.id().toString())) - .orElseThrow()) - .toList(); - List revisionVector = selected.stream() - .map(Dataset::revisionNumber) - .sorted() - .toList(); - return new Candidate(period, selected, revisionVector); + private static List minimumCover( + String resolution, LocalDate requestedStart, LocalDate requestedEnd, List candidates) { + Cover best = coverFrom(requestedStart, requestedStart, requestedEnd, candidates); + if (best == null) { + LocalDate firstGap = firstGap(requestedStart, requestedEnd, candidates); + throw new ImmutableStrategyReleaseRejectedException( + "No official " + resolution + " dataset manifest covers " + firstGap + + " within requested period " + requestedStart + ".." + requestedEnd); + } + return best.datasets(); } - private static int compareRevisionVectors(List left, List right) { - for (int index = 0; index < Math.min(left.size(), right.size()); index++) { - int comparison = Integer.compare(left.get(index), right.get(index)); - if (comparison != 0) return comparison; + private static Cover coverFrom( + LocalDate coverageStart, + LocalDate cursor, + LocalDate requestedEnd, + List candidates) { + Cover best = null; + for (Dataset candidate : candidates.stream() + .filter(dataset -> cursor.equals(coverageStart) + ? !dataset.periodStart().isAfter(cursor) + : dataset.periodStart().equals(cursor)) + .filter(dataset -> dataset.periodEnd().isAfter(cursor)) + .sorted(Comparator.comparing(Dataset::periodEnd).reversed() + .thenComparing(Comparator.comparingInt(Dataset::revisionNumber).reversed()) + .thenComparing(Dataset::availableAt, Comparator.reverseOrder()) + .thenComparing(dataset -> dataset.id().toString())) + .toList()) { + Cover path; + if (!candidate.periodEnd().isBefore(requestedEnd)) { + path = Cover.of(coverageStart, requestedEnd, candidate); + } else { + Cover suffix = coverFrom(coverageStart, candidate.periodEnd(), requestedEnd, candidates); + if (suffix == null || suffix.datasets().contains(candidate)) { + continue; + } + List datasets = new ArrayList<>(); + datasets.add(candidate); + datasets.addAll(suffix.datasets()); + path = Cover.of(coverageStart, requestedEnd, datasets); + } + if (best == null || path.compareTo(best) < 0) { + best = path; + } } - return Integer.compare(left.size(), right.size()); + return best; } - private static int periodRank(Period period, ExecutionPolicy policy) { - return period.start().equals(policy.periodStart()) && period.end().equals(policy.periodEnd()) ? 0 : 1; + private static LocalDate firstGap( + LocalDate requestedStart, LocalDate requestedEnd, List candidates) { + LocalDate cursor = requestedStart; + while (cursor.isBefore(requestedEnd)) { + LocalDate current = cursor; + LocalDate next = candidates.stream() + .filter(dataset -> !dataset.periodStart().isAfter(current)) + .filter(dataset -> dataset.periodEnd().isAfter(current)) + .map(Dataset::periodEnd) + .max(LocalDate::compareTo) + .orElse(current); + if (next.equals(cursor)) return cursor; + cursor = next; + } + return cursor; } - private static Set requiredResolutions(String compiledPlanDocument) { + private static Requirements requirements(String compiledPlanDocument) { final JsonNode plan; try { plan = JSON.readTree(compiledPlanDocument); @@ -137,16 +177,59 @@ private static Set requiredResolutions(String compiledPlanDocument) { "Compiled plan is not readable while selecting official backtest inputs"); } Set resolutions = new LinkedHashSet<>(); - plan.path("requiredFeatures").forEach(feature -> addResolution(resolutions, feature.path("resolution"))); + Map> instruments = new HashMap<>(); + Map warmupDays = new HashMap<>(); + plan.path("requiredFeatures").forEach(feature -> { + String resolution = resolution(feature.path("resolution")); + if (resolution == null) return; + resolutions.add(resolution); + feature.path("instruments").forEach(value -> addInstrument(instruments, resolution, value)); + long observations = Math.max(1, feature.path("requiredObservations").asLong( + feature.path("requiredHistoryPoints").asLong(1))); + warmupDays.merge(resolution, warmupDays(resolution, observations), Math::max); + }); plan.path("executionSnapshot").path("partitions").forEach(partition -> - partition.path("flows").forEach(flow -> - flow.path("steps").forEach(step -> - addResolution(resolutions, step.path("arguments").path("resolution"))))); + partition.path("flows").forEach(flow -> addFlowRequirements(flow, resolutions, instruments, warmupDays))); + plan.path("flows").forEach(flow -> addFlowRequirements(flow, resolutions, instruments, warmupDays)); if (resolutions.isEmpty()) { throw new ImmutableStrategyReleaseRejectedException( "Compiled plan declares no market-data resolution"); } - return java.util.Collections.unmodifiableSet(resolutions); + return new Requirements(Set.copyOf(resolutions), Map.copyOf(instruments), Map.copyOf(warmupDays)); + } + + private static void addFlowRequirements( + JsonNode flow, Set resolutions, Map> instruments, Map warmupDays) { + Set flowInstruments = new LinkedHashSet<>(); + JsonNode instrumentValues = flow.has("officialInstrumentIds") + ? flow.path("officialInstrumentIds") : flow.path("instrumentIds"); + instrumentValues.forEach(value -> { + if (value.isTextual()) flowInstruments.add(UUID.fromString(value.textValue())); + }); + flow.path("steps").forEach(step -> { + JsonNode value = step.path("arguments").path("resolution"); + if (!value.isTextual()) value = step.path("parameters").path("resolution"); + String resolution = resolution(value); + if (resolution == null) return; + resolutions.add(resolution); + instruments.computeIfAbsent(resolution, ignored -> new LinkedHashSet<>()).addAll(flowInstruments); + warmupDays.merge(resolution, warmupDays(resolution, 1), Math::max); + }); + } + + private static void addInstrument(Map> target, String resolution, JsonNode value) { + if (value.isTextual()) { + target.computeIfAbsent(resolution, ignored -> new LinkedHashSet<>()).add(UUID.fromString(value.textValue())); + } + } + + private static String resolution(JsonNode value) { + return value.isTextual() && !value.textValue().isBlank() ? normalizeResolution(value.textValue()) : null; + } + + private static long warmupDays(String resolution, long observations) { + long seconds = Math.multiplyExact(duration(resolution).toSeconds(), observations); + return Math.max(1, (seconds + Duration.ofDays(1).toSeconds() - 1) / Duration.ofDays(1).toSeconds()); } private static void addResolution(Set target, JsonNode value) { @@ -189,7 +272,65 @@ public record Selection(ExecutionPolicy policy, List datasets) { } } - private record Period(LocalDate start, LocalDate end) {} + private record Cover( + List datasets, + List revisionVector, + List availabilityVector, + long outsideDays) + implements Comparable { + private Cover { + datasets = List.copyOf(datasets); + revisionVector = List.copyOf(revisionVector); + availabilityVector = List.copyOf(availabilityVector); + } + + static Cover of(LocalDate requestedStart, LocalDate requestedEnd, Dataset dataset) { + return of(requestedStart, requestedEnd, List.of(dataset)); + } + + static Cover of(LocalDate requestedStart, LocalDate requestedEnd, List datasets) { + List ordered = datasets.stream() + .sorted(Comparator.comparing(Dataset::periodStart) + .thenComparing(Dataset::periodEnd) + .thenComparing(dataset -> dataset.id().toString())) + .toList(); + List revisions = ordered.stream() + .map(Dataset::revisionNumber) + .sorted(Comparator.reverseOrder()) + .toList(); + List availability = ordered.stream() + .map(Dataset::availableAt) + .sorted(Comparator.reverseOrder()) + .toList(); + long outside = java.time.temporal.ChronoUnit.DAYS.between( + ordered.getFirst().periodStart(), requestedStart) + + java.time.temporal.ChronoUnit.DAYS.between( + requestedEnd, ordered.getLast().periodEnd()); + return new Cover(ordered, revisions, availability, outside); + } + + @Override + public int compareTo(Cover other) { + int count = Integer.compare(datasets.size(), other.datasets.size()); + if (count != 0) return count; + for (int index = 0; index < revisionVector.size(); index++) { + int revision = Integer.compare(other.revisionVector.get(index), revisionVector.get(index)); + if (revision != 0) return revision; + } + for (int index = 0; index < availabilityVector.size(); index++) { + int availability = other.availabilityVector.get(index).compareTo(availabilityVector.get(index)); + if (availability != 0) return availability; + } + int outside = Long.compare(outsideDays, other.outsideDays); + if (outside != 0) return outside; + String ids = datasets.stream().map(dataset -> dataset.id().toString()) + .collect(java.util.stream.Collectors.joining("|")); + String otherIds = other.datasets.stream().map(dataset -> dataset.id().toString()) + .collect(java.util.stream.Collectors.joining("|")); + return ids.compareTo(otherIds); + } + } - private record Candidate(Period period, List datasets, List revisionVector) {} + private record Requirements( + Set resolutions, Map> instruments, Map warmupDays) {} } diff --git a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/StrategyBotCompiledPlanAssembler.java b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/StrategyBotCompiledPlanAssembler.java index fc787d6f..e47e5569 100644 --- a/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/StrategyBotCompiledPlanAssembler.java +++ b/modules/backend-application/src/main/java/com/idea2strategy/backend/application/strategy/StrategyBotCompiledPlanAssembler.java @@ -57,8 +57,8 @@ * delay every bot's first decision by a bar and would disagree with the pinned contract sample. * * - *

One container per trade side. A Basic strategy is a buy container and a sell - * container, and the blocks inside a container are an AND chain, so each flow carries its own + *

Independent containers per trade side. A Basic strategy may have several buy + * and sell containers, and the blocks inside each container are an AND chain, so every flow carries its own * {@code side}, {@code allocation} and {@code steps}. Version 1 of the contract put them on the plan * and could therefore describe only one container, which is why that ordinary strategy used to be * refused at release (root #202). @@ -69,8 +69,8 @@ public final class StrategyBotCompiledPlanAssembler { /** * The shape this assembler publishes. * - *

Version 2, always. A Basic strategy is one container per trade side and each container is - * its own AND chain of blocks, so {@code side}, {@code allocation} and {@code steps} belong to + *

Version 2, always. Every Basic trade container is its own AND chain of blocks, so + * {@code side}, {@code allocation} and {@code steps} belong to * the flow. Version 1 put them on the plan and could therefore describe only one container, * which is why a strategy with a buy rule and a sell rule was refused at release (root #202). * 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 e6bbbe00..6389d45d 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 @@ -32,6 +32,7 @@ public record ExecutionPolicy( public record Dataset( UUID id, + UUID instrumentId, String feedCode, String dataLayer, String resolution, @@ -39,5 +40,18 @@ public record Dataset( LocalDate periodStart, LocalDate periodEnd, String schemaVersion, - Instant availableAt) {} + Instant availableAt) { + public Dataset( + UUID id, + String feedCode, + String dataLayer, + String resolution, + int revisionNumber, + LocalDate periodStart, + LocalDate periodEnd, + String schemaVersion, + Instant availableAt) { + this(id, null, feedCode, dataLayer, resolution, revisionNumber, periodStart, periodEnd, schemaVersion, availableAt); + } + } } diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryServiceTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryServiceTest.java index 8bcda011..65a4a297 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryServiceTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/competition/OwnedRoomManagementQueryServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.List; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -15,10 +16,16 @@ class OwnedRoomManagementQueryServiceTest { void scopesTheManagementListToTheCurrentAccountAndBoundsTheLimit() { var observedOwner = new AtomicReference(); var observedLimit = new AtomicReference(); - OwnedRoomManagementQueryPort port = (owner, limit) -> { - observedOwner.set(owner); - observedLimit.set(limit); - return List.of(); + OwnedRoomManagementQueryPort port = new OwnedRoomManagementQueryPort() { + @Override public List findOwnedBy(UUID owner, int limit) { + observedOwner.set(owner); + observedLimit.set(limit); + return List.of(); + } + @Override public Optional findOwnedById(UUID owner, UUID roomId) { + observedOwner.set(owner); + return Optional.empty(); + } }; var service = new OwnedRoomManagementQueryService(port, () -> ACCOUNT_ID); @@ -27,5 +34,7 @@ void scopesTheManagementListToTheCurrentAccountAndBoundsTheLimit() { assertThat(observedLimit.get()).isEqualTo(50); assertThatThrownBy(() -> service.list(0)).isInstanceOf(IllegalArgumentException.class); assertThatThrownBy(() -> service.list(101)).isInstanceOf(IllegalArgumentException.class); + assertThat(service.get(UUID.randomUUID())).isEmpty(); + assertThat(observedOwner.get()).isEqualTo(ACCOUNT_ID); } } diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBacktestCapabilityValidatorTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBacktestCapabilityValidatorTest.java index 013c7c9e..70aa0061 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBacktestCapabilityValidatorTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBacktestCapabilityValidatorTest.java @@ -102,25 +102,47 @@ void resolvesTheProductionFeedFromEachBlocksResolutionParameter() { } @Test - void rejectsUnsupportedOrMixedProductionResolutions() { - var mixed = validator.validate( + void rejectsUnsupportedOrMixedProductionResolutionsInsideOneFlow() { + var mixedFlow = validator.validate( assembly("30m", "1h"), catalog(featureOnlyContract(), featureFreeContract())); var legacy = validator.validate( assembly("1m", "1m"), catalog(featureOnlyContract(), featureFreeContract())); - assertThat(mixed.issues()).extracting(BasicBacktestCapabilityIssue::code) + assertThat(mixedFlow.issues()).extracting(BasicBacktestCapabilityIssue::code) .contains("BACKTEST_MULTIPLE_RESOLUTIONS"); assertThat(legacy.issues()).extracting(BasicBacktestCapabilityIssue::code) .contains("BASIC_INVALID_RESOLUTION"); } + @Test + void acceptsIndependentFlowsAtThirtyMinutesFourHoursAndOneDay() { + var result = validator.validate( + multiResolutionAssembly(), catalog(featureOnlyContract(), featureFreeContract())); + + assertThat(result.backtestable()).isTrue(); + assertThat(result.issues()).isEmpty(); + assertThat(result.requiredFeatures()).containsExactly("RSI_14"); + } + private static BasicBlockAssembly assembly() { return assembly("30m", "30m"); } private static BasicBlockAssembly assembly(String triggerResolution, String conditionResolution) { - return new BasicBlockAssembly(CATALOG_ID, List.of(new BasicBlockGroup( - "buy", + return new BasicBlockAssembly(CATALOG_ID, List.of(group("buy", triggerResolution, conditionResolution))); + } + + private static BasicBlockAssembly multiResolutionAssembly() { + return new BasicBlockAssembly(CATALOG_ID, List.of( + group("thirty-minute", "30m", "30m"), + group("four-hour", "4h", "4h"), + group("daily", "1d", "1d"))); + } + + private static BasicBlockGroup group( + String id, String triggerResolution, String conditionResolution) { + return new BasicBlockGroup( + id, TradeContainer.BUY, EvaluationMode.INDEPENDENT, AllocationMode.EQUAL, @@ -131,7 +153,7 @@ private static BasicBlockAssembly assembly(String triggerResolution, String cond new BasicBlock("order", "BUY_ORDER", Map.of())), List.of( new BasicBlockConnection("trigger", "signal", "condition", "input"), - new BasicBlockConnection("condition", "result", "order", "input"))))); + new BasicBlockConnection("condition", "result", "order", "input"))); } private static Map parameters(String resolution) { diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidatorTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidatorTest.java index 0d93f64d..8d7c245e 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidatorTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/BasicBlockAssemblyValidatorTest.java @@ -201,7 +201,7 @@ void enforcesPublishedBasicCompositionLimits() { sixInstruments, blocks, connections); List groups = new java.util.ArrayList<>(); groups.add(oversized); - for (int index = 0; index < 4; index++) { + for (int index = 0; index < 16; index++) { groups.add(group("extra-buy-" + index, TradeContainer.BUY, "BUY_ORDER")); } @@ -220,18 +220,25 @@ void countsExpandedInstrumentFlowsByAllocationGroupInsteadOfRawGroupCount() { List fiveInstrumentsInOnePartition = java.util.stream.IntStream.range(0, 5) .mapToObj(index -> group("buy:" + index, "partition-buy", TradeContainer.BUY, "BUY_ORDER")) .toList(); - List fivePartitions = java.util.stream.IntStream.range(0, 5) + List sixteenStrategies = java.util.stream.IntStream.range(0, 16) + .mapToObj(index -> group("buy:" + index, "partition-" + index, TradeContainer.BUY, "BUY_ORDER")) + .toList(); + List seventeenStrategies = java.util.stream.IntStream.range(0, 17) .mapToObj(index -> group("buy:" + index, "partition-" + index, TradeContainer.BUY, "BUY_ORDER")) .toList(); var onePartition = new BasicBlockAssemblyValidator().validate( new BasicBlockAssembly(CATALOG_ID, fiveInstrumentsInOnePartition), catalog()); - var tooManyPartitions = new BasicBlockAssemblyValidator().validate( - new BasicBlockAssembly(CATALOG_ID, fivePartitions), catalog()); + var maximumStrategies = new BasicBlockAssemblyValidator().validate( + new BasicBlockAssembly(CATALOG_ID, sixteenStrategies), catalog()); + var tooManyStrategies = new BasicBlockAssemblyValidator().validate( + new BasicBlockAssembly(CATALOG_ID, seventeenStrategies), catalog()); assertThat(onePartition.issues()).extracting(BasicBlockAssemblyIssue::code) .doesNotContain("TOO_MANY_BUY_CONTAINERS"); - assertThat(tooManyPartitions.issues()).extracting(BasicBlockAssemblyIssue::code) + assertThat(maximumStrategies.issues()).extracting(BasicBlockAssemblyIssue::code) + .doesNotContain("TOO_MANY_BUY_CONTAINERS"); + assertThat(tooManyStrategies.issues()).extracting(BasicBlockAssemblyIssue::code) .contains("TOO_MANY_BUY_CONTAINERS"); } diff --git a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditServiceTest.java b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditServiceTest.java index c7534a49..856c338e 100644 --- a/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditServiceTest.java +++ b/modules/backend-application/src/test/java/com/idea2strategy/backend/application/strategy/DelegatedBasicStrategyEditServiceTest.java @@ -24,6 +24,7 @@ class DelegatedBasicStrategyEditServiceTest { private static final UUID STRATEGY_ID = UUID.fromString("40000000-0000-4000-8000-000000000001"); private static final UUID CATALOG_ID = UUID.fromString("50000000-0000-4000-8000-000000000001"); private static final UUID INSTRUMENT_ID = UUID.fromString("60000000-0000-4000-8000-000000000001"); + private static final UUID META_INSTRUMENT_ID = UUID.fromString("60000000-0000-4000-8000-000000000002"); private static final Instant NOW = Instant.parse("2026-08-01T12:00:00Z"); @Test @@ -48,6 +49,11 @@ void previewsAndAppliesOnlyTheExactReviewedOfficialBasicEdit() { assertThat(applied.editSequence()).isEqualTo(8); assertThat(applied.semanticHash()).isEqualTo(preview.previewHash()); + String emptyPresentation = "{\"positions\":{},\"viewport\":{\"x\":0,\"y\":0,\"zoom\":1}}"; + assertThat(applied.presentationDocument()).isEqualTo(StrategyDocumentJson.canonicalize(emptyPresentation)); + assertThat(applied.presentationSchemaVersion()) + .isEqualTo(BasicStrategyDraftCommandService.PRESENTATION_SCHEMA_VERSION); + assertThat(applied.presentationHash()).isEqualTo(StrategyDocumentJson.sha256(emptyPresentation)); assertThat(commandPort.saved).isEqualTo(applied); assertThat(commandPort.editor).isEqualTo(editor); } @@ -180,27 +186,74 @@ void createsATradeContainerSoADelegatedToolCanStartFromNothing() { assertThat(preview.proposedSemanticDocument()).contains("\"container\":\"SELL\""); } - /** - * Rule 9.9: a strategy holds one container per side. A second BUY container has no defined - * meaning, so it is refused at the operation where the tool can still react, rather than - * surviving into a document that only fails later. - */ @Test - void refusesASecondContainerOnASideThatAlreadyHasOne() { - var commandPort = new RecordingCommandPort(); - var service = service(new RecordingAuthorizer(), commandPort); + void replacesAContainersCompleteInstrumentSetWithReviewedCatalogInstruments() { + var service = service(new RecordingAuthorizer(), new RecordingCommandPort()); var operations = List.of(new DelegatedBasicEditOperation( - "ADD_GROUP", + "SET_GROUP_INSTRUMENTS", Map.of( - "groupId", "buy-2", - "container", "BUY", - "evaluationMode", "INDEPENDENT", - "allocationMode", "EQUAL", - "instrumentIds", List.of(INSTRUMENT_ID.toString())))); + "groupId", "buy", + "instrumentIds", List.of(INSTRUMENT_ID.toString(), META_INSTRUMENT_ID.toString())))); - assertThatThrownBy(() -> service.preview(editor(), STRATEGY_ID, 7, catalog(), operations)) + var preview = service.preview(editor(), STRATEGY_ID, 7, catalog(), operations); + + assertThat(preview.valid()).isTrue(); + assertThat(preview.changes()).containsExactly("SET_GROUP_INSTRUMENTS buy AAPL,META"); + assertThat(preview.proposedSemanticDocument()) + .contains("\"instrumentIds\":[\"" + INSTRUMENT_ID + "\",\"" + META_INSTRUMENT_ID + "\"]"); + } + + @Test + void refusesEmptyDuplicateOrUnpublishedContainerInstrumentSets() { + var service = service(new RecordingAuthorizer(), new RecordingCommandPort()); + + assertThatThrownBy(() -> service.preview(editor(), STRATEGY_ID, 7, catalog(), List.of( + new DelegatedBasicEditOperation("SET_GROUP_INSTRUMENTS", Map.of( + "groupId", "buy", "instrumentIds", List.of()))))) .isInstanceOf(DelegatedBasicEditRejectedException.class) - .hasMessageContaining("one container per side"); + .hasMessageContaining("instrumentIds"); + assertThatThrownBy(() -> service.preview(editor(), STRATEGY_ID, 7, catalog(), List.of( + new DelegatedBasicEditOperation("SET_GROUP_INSTRUMENTS", Map.of( + "groupId", "buy", "instrumentIds", + List.of(INSTRUMENT_ID.toString(), INSTRUMENT_ID.toString())))))) + .isInstanceOf(DelegatedBasicEditRejectedException.class) + .hasMessageContaining("duplicate"); + assertThatThrownBy(() -> service.preview(editor(), STRATEGY_ID, 7, catalog(), List.of( + new DelegatedBasicEditOperation("SET_GROUP_INSTRUMENTS", Map.of( + "groupId", "buy", "instrumentIds", + List.of("60000000-0000-4000-8000-000000000099")))))) + .isInstanceOf(DelegatedBasicEditRejectedException.class) + .hasMessageContaining("official catalog"); + } + + @Test + void allowsMultipleIndependentContainersOnTheSameSideWhenTheirIdsDiffer() { + var commandPort = new RecordingCommandPort(); + var service = service(new RecordingAuthorizer(), commandPort); + var operations = List.of( + new DelegatedBasicEditOperation("ADD_GROUP", Map.of( + "groupId", "buy-2", "container", "BUY", + "evaluationMode", "INDEPENDENT", "allocationMode", "EQUAL", + "instrumentIds", List.of(META_INSTRUMENT_ID.toString()))), + new DelegatedBasicEditOperation("ADD_BLOCK", Map.of( + "groupId", "buy-2", "blockId", "trigger-2", "elementCode", "MARKET_OPEN")), + new DelegatedBasicEditOperation("ADD_BLOCK", Map.of( + "groupId", "buy-2", "blockId", "condition-2", "elementCode", "RSI", + "parameters", Map.of("period", 21))), + new DelegatedBasicEditOperation("ADD_BLOCK", Map.of( + "groupId", "buy-2", "blockId", "order-2", "elementCode", "BUY_ORDER")), + new DelegatedBasicEditOperation("CONNECT_BLOCKS", Map.of( + "groupId", "buy-2", "fromBlockId", "trigger-2", "outputPort", "signal", + "toBlockId", "condition-2", "inputPort", "input")), + new DelegatedBasicEditOperation("CONNECT_BLOCKS", Map.of( + "groupId", "buy-2", "fromBlockId", "condition-2", "outputPort", "result", + "toBlockId", "order-2", "inputPort", "input"))); + + var preview = service.preview(editor(), STRATEGY_ID, 7, catalog(), operations); + + assertThat(preview.valid()).isTrue(); + assertThat(preview.proposedSemanticDocument()).contains( + "\"id\":\"buy\"", "\"id\":\"buy-2\"", "\"container\":\"BUY\""); assertThat(commandPort.saved).isNull(); } @@ -300,7 +353,9 @@ private static BasicStrategyCatalog catalog() { "{\"input\":{\"type\":\"BOOLEAN\"}}", "{\"result\":{\"type\":\"BOOLEAN\"}}"), element("BUY_ORDER", "ORDER", "{}", "{\"input\":{\"type\":\"BOOLEAN\"}}", "{}")), List.of(), - List.of(new SupportedInstrument(INSTRUMENT_ID, "STOCK", "XNAS", "USD", "AAPL"))); + List.of( + new SupportedInstrument(INSTRUMENT_ID, "STOCK", "XNAS", "USD", "AAPL"), + new SupportedInstrument(META_INSTRUMENT_ID, "STOCK", "XNAS", "USD", "META"))); } private static StrategyElementDefinition element( 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 index e21f46ae..a47ab09f 100644 --- 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 @@ -44,7 +44,7 @@ void failsClosedWhenOneRequiredResolutionHasNoCoherentOfficialDatasetSet() { assertThatThrownBy(() -> OfficialBacktestInputSelector.select(plan("30m", "PT1H"), catalog)) .isInstanceOf(ImmutableStrategyReleaseRejectedException.class) - .hasMessageContaining("30m, 1h"); + .hasMessageContaining("1h"); } @Test @@ -93,7 +93,150 @@ void prefersTheNewestCoherentRevisionEvenWhenItsPeriodEndDiffers() { assertThat(selected.datasets()).extracting(Dataset::id).containsExactly(revised30m, revised1h); } + @Test + void treatsThePolicyEndAsExclusiveForCustomBacktests() { + var catalog = new StrategyReleaseInputCatalog( + List.of(policy("official-v1", "2024-01-01", "2024-02-01", NOW.minusSeconds(60))), + List.of(dataset(BARS_30M, "ADJUSTED", "30m", "2024-01-01", "2024-02-02", NOW.minusSeconds(30))), + NOW); + + var selected = OfficialBacktestInputSelector.select( + plan("30m", "30m"), LocalDate.parse("2024-01-01"), LocalDate.parse("2024-01-31"), catalog); + + assertThat(selected.datasets()).extracting(Dataset::id).containsExactly(BARS_30M); + assertThatThrownBy(() -> OfficialBacktestInputSelector.select( + plan("30m", "30m"), LocalDate.parse("2024-01-01"), LocalDate.parse("2024-02-01"), catalog)) + .isInstanceOf(ImmutableStrategyReleaseRejectedException.class) + .hasMessageContaining("outside the locked execution policy"); + } + + @Test + void selectsTheMinimumOrderedManifestCoverForEveryRequiredResolution() { + UUID bars30mFull = UUID.fromString("50000000-0000-4000-8000-000000000001"); + UUID bars30m2024 = UUID.fromString("50000000-0000-4000-8000-000000000002"); + UUID bars30m2025 = UUID.fromString("50000000-0000-4000-8000-000000000003"); + UUID bars1h2024 = UUID.fromString("50000000-0000-4000-8000-000000000004"); + UUID bars1h2025 = UUID.fromString("50000000-0000-4000-8000-000000000005"); + var catalog = new StrategyReleaseInputCatalog( + List.of(policy("official-v1", "2024-01-01", "2026-01-01", NOW.minusSeconds(60))), + List.of( + dataset(bars30mFull, "ADJUSTED", "30m", "2024-01-01", "2026-01-01", NOW.minusSeconds(10)), + dataset(bars30m2024, "ADJUSTED", "30m", "2024-01-01", "2025-01-01", NOW.minusSeconds(20)), + dataset(bars30m2025, "ADJUSTED", "30m", "2025-01-01", "2026-01-01", NOW.minusSeconds(20)), + dataset(bars1h2024, "ADJUSTED", "1h", "2024-01-01", "2025-01-01", NOW.minusSeconds(20)), + dataset(bars1h2025, "ADJUSTED", "1h", "2025-01-01", "2026-01-01", NOW.minusSeconds(20))), + NOW); + + var selected = OfficialBacktestInputSelector.select( + plan("30m", "1h"), LocalDate.parse("2024-02-01"), LocalDate.parse("2025-12-01"), catalog); + + assertThat(selected.datasets()).extracting(Dataset::id) + .containsExactly(bars30mFull, bars1h2024, bars1h2025); + } + + @Test + void failsClosedAndNamesTheResolutionWhenSegmentedCoverageHasAGap() { + UUID first = UUID.fromString("60000000-0000-4000-8000-000000000001"); + UUID second = UUID.fromString("60000000-0000-4000-8000-000000000002"); + var catalog = new StrategyReleaseInputCatalog( + List.of(policy("official-v1", "2024-01-01", "2026-01-01", NOW.minusSeconds(60))), + List.of( + dataset(BARS_30M, "ADJUSTED", "30m", "2024-01-01", "2026-01-01", NOW.minusSeconds(20)), + dataset(first, "ADJUSTED", "1h", "2024-01-01", "2025-01-01", NOW.minusSeconds(20)), + dataset(second, "ADJUSTED", "1h", "2025-02-01", "2026-01-01", NOW.minusSeconds(20))), + NOW); + + assertThatThrownBy(() -> OfficialBacktestInputSelector.select( + plan("30m", "1h"), LocalDate.parse("2024-02-01"), LocalDate.parse("2025-12-01"), catalog)) + .isInstanceOf(ImmutableStrategyReleaseRejectedException.class) + .hasMessageContaining("1h") + .hasMessageContaining("2025-01-01"); + } + + @Test + void neverUsesAnInstrumentScopedManifestForAnotherInstrument() { + UUID aapl = UUID.fromString("70000000-0000-4000-8000-000000000001"); + UUID msft = UUID.fromString("70000000-0000-4000-8000-000000000002"); + UUID aaplBars = UUID.fromString("70000000-0000-4000-8000-000000000003"); + UUID msftBars = UUID.fromString("70000000-0000-4000-8000-000000000004"); + var catalog = new StrategyReleaseInputCatalog( + List.of(policy("official-v1", NOW.minusSeconds(60))), + List.of( + new Dataset(aaplBars, aapl, "AAPL", "ADJUSTED", "30m", 1, + LocalDate.parse("2024-01-01"), LocalDate.parse("2024-02-01"), + "market-bars/1", NOW.minusSeconds(20)), + new Dataset(msftBars, msft, "MSFT", "ADJUSTED", "30m", 2, + LocalDate.parse("2024-01-01"), LocalDate.parse("2024-02-01"), + "market-bars/1", NOW.minusSeconds(10))), + NOW); + String plan = """ + {"executionSnapshot":{"partitions":[{"flows":[{ + "officialInstrumentIds":["%s"], + "steps":[{"arguments":{"resolution":"30m"}}] + }]}]}} + """.formatted(aapl); + + var selected = OfficialBacktestInputSelector.select( + plan, LocalDate.parse("2024-01-05"), LocalDate.parse("2024-01-25"), catalog); + + assertThat(selected.datasets()).extracting(Dataset::id).containsExactly(aaplBars); + } + + @Test + void selectsOnlyTheManifestsRequiredByIndependentThirtyMinuteFourHourAndDailyFlows() { + UUID aapl = UUID.fromString("72000000-0000-4000-8000-000000000001"); + UUID msft = UUID.fromString("72000000-0000-4000-8000-000000000002"); + UUID meta = UUID.fromString("72000000-0000-4000-8000-000000000003"); + UUID bars30m = UUID.fromString("72000000-0000-4000-8000-000000000004"); + UUID bars4h = UUID.fromString("72000000-0000-4000-8000-000000000005"); + UUID bars1d = UUID.fromString("72000000-0000-4000-8000-000000000006"); + UUID unused1h = UUID.fromString("72000000-0000-4000-8000-000000000007"); + var catalog = new StrategyReleaseInputCatalog( + List.of(policy("official-v1", NOW.minusSeconds(60))), + List.of( + dataset(bars30m, "ADJUSTED", "30m", NOW.minusSeconds(20)), + dataset(unused1h, "ADJUSTED", "1h", NOW.minusSeconds(20)), + dataset(bars4h, "ADJUSTED", "4h", NOW.minusSeconds(20)), + dataset(bars1d, "ADJUSTED", "1d", NOW.minusSeconds(20))), + NOW); + String plan = """ + {"executionSnapshot":{"partitions":[{"flows":[ + {"officialInstrumentIds":["%s"],"steps":[{"arguments":{"resolution":"30m"}}]}, + {"officialInstrumentIds":["%s"],"steps":[{"arguments":{"resolution":"4h"}}]}, + {"officialInstrumentIds":["%s"],"steps":[{"arguments":{"resolution":"1d"}}]} + ]}]}} + """.formatted(aapl, msft, meta); + + var selected = OfficialBacktestInputSelector.select( + plan, LocalDate.parse("2024-01-05"), LocalDate.parse("2024-01-25"), catalog); + + assertThat(selected.datasets()).extracting(Dataset::id) + .containsExactly(bars30m, bars4h, bars1d); + } + + @Test + void rejectsOverlappingSegmentsThatTheWorkerCannotBind() { + UUID first = UUID.fromString("71000000-0000-4000-8000-000000000001"); + UUID overlap = UUID.fromString("71000000-0000-4000-8000-000000000002"); + var catalog = new StrategyReleaseInputCatalog( + List.of(policy("official-v1", "2024-01-01", "2024-04-01", NOW.minusSeconds(60))), + List.of( + dataset(first, "ADJUSTED", "30m", "2024-01-01", "2024-03-01", NOW.minusSeconds(20)), + dataset(overlap, "ADJUSTED", "30m", "2024-02-01", "2024-04-01", NOW.minusSeconds(10))), + NOW); + + assertThatThrownBy(() -> OfficialBacktestInputSelector.select( + plan("30m", "30m"), LocalDate.parse("2024-01-05"), LocalDate.parse("2024-03-15"), catalog)) + .isInstanceOf(ImmutableStrategyReleaseRejectedException.class) + .hasMessageContaining("30m"); + } + private static ExecutionPolicy policy(String version, Instant lockedAt) { + return policy(version, "2024-01-01", "2024-02-01", lockedAt); + } + + private static ExecutionPolicy policy( + String version, String periodStart, String periodEnd, Instant lockedAt) { return new ExecutionPolicy( version, "market:1.0.0", @@ -103,8 +246,8 @@ private static ExecutionPolicy policy(String version, Instant lockedAt) { 20, BUFFER_ID, 1, - LocalDate.parse("2024-01-01"), - LocalDate.parse("2024-02-01"), + LocalDate.parse(periodStart), + LocalDate.parse(periodEnd), "market-bars/1", lockedAt); } diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolver.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolver.java index d2dcf930..816356b0 100644 --- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolver.java +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolver.java @@ -78,8 +78,7 @@ private FeaturePin resolveOne( LocalDate evaluationEnd, OffsetDateTime asOf) { Duration resolution = requirement.resolution(); - OffsetDateTime requiredStart = evaluationStart.atStartOfDay().atOffset(ZoneOffset.UTC) - .minus(resolution.multipliedBy(requirement.requiredObservations())); + OffsetDateTime requiredStart = evaluationStart.atStartOfDay().atOffset(ZoneOffset.UTC); OffsetDateTime requiredEnd = evaluationEnd.plusDays(1).atStartOfDay().atOffset(ZoneOffset.UTC); var candidates = dsl.fetch( "select fm.id, fm.input_dataset_set_hash, fm.result_hash, fm.period_start, fm.period_end, " @@ -173,8 +172,8 @@ private FeaturePin resolveOne( if (definitionHash == null || !SHA_256.matcher(definitionHash).matches()) { throw mismatch("feature definition hash"); } - UUID expectedFeedId = deterministicUuid( - "feature-output-feed", definitionHash, calculatorVersion, definitionResolution, OUTPUT_SCHEMA); + UUID expectedFeedId = expectedFeatureOutputFeedId( + requirement.featureId(), definitionHash, calculatorVersion, definitionResolution); if (!INTERNAL_PROVIDER_CODE.equals(candidate.get("provider_code", String.class)) || !INTERNAL_PROVIDER_RIGHTS.equals(candidate.get("provider_rights_version", String.class)) || !"ACTIVE".equals(candidate.get("provider_status", String.class))) { @@ -232,6 +231,37 @@ private FeaturePin resolveOne( return new FeaturePin(candidate.get("id", UUID.class), prefixed(resultHash)); } + static UUID expectedFeatureOutputFeedId( + UUID featureDefinitionId, + String definitionHash, + String calculatorVersion, + String definitionResolution) { + if ("rsi:1.0.0".equals(calculatorVersion)) { + if (featureDefinitionId.equals(UUID.fromString("ec37984b-6605-5560-8ea0-774c5b8e9626")) + && definitionHash.equals("sha256:250df12e46d233e7b8ece86c64df7a3941f0d70436aebe522b1387f15fb346dc") + && definitionResolution.equals("30m")) { + return UUID.fromString("57794d8c-2254-53e4-966e-44f97edd9e6a"); + } + if (featureDefinitionId.equals(UUID.fromString("85f4f80f-be4e-d9dc-bd52-d4781ba5f30f")) + && definitionHash.equals("sha256:7e8c5600ff2bf07a043f797a50d6467f86fbdb56ee532c87929df97f246af2de") + && definitionResolution.equals("1h")) { + return UUID.fromString("28012549-4f45-56d3-8bb6-329e4c7a9d77"); + } + if (featureDefinitionId.equals(UUID.fromString("65a5aaf5-f536-820f-119a-239b0aec0de7")) + && definitionHash.equals("sha256:42e28b02a1552eb2aa42e0d89b1ea3dd909ee8d34c3bc290c4ce0234c6d705da") + && definitionResolution.equals("4h")) { + return UUID.fromString("e1d7d508-aaf1-5ae9-8098-c4af870f6fa4"); + } + if (featureDefinitionId.equals(UUID.fromString("647a5fd6-98ed-0617-d4b2-844748d54fac")) + && definitionHash.equals("sha256:64dbbcda7352d0add9a4a6a6ed94a780603880891684dc32cf39e0a3d1167422") + && definitionResolution.equals("1d")) { + return UUID.fromString("6d2647f8-5caf-55ee-8821-869dc693f68a"); + } + } + return deterministicUuid( + "feature-output-feed", definitionHash, calculatorVersion, definitionResolution, OUTPUT_SCHEMA); + } + private void requireObjectCoverage(UUID manifestId, OffsetDateTime requiredStart, OffsetDateTime requiredEnd) { var receipts = dsl.fetch( "select dox.period_start as membership_period_start, dox.period_end as membership_period_end, " diff --git a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/OwnedRoomManagementJooqAdapter.java b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/OwnedRoomManagementJooqAdapter.java index b6c914fa..715c9d5a 100644 --- a/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/OwnedRoomManagementJooqAdapter.java +++ b/modules/backend-persistence/src/main/java/com/idea2strategy/backend/persistence/competition/OwnedRoomManagementJooqAdapter.java @@ -14,7 +14,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; +import org.jooq.Condition; import org.jooq.DSLContext; import org.jooq.JSONB; import org.springframework.stereotype.Repository; @@ -32,6 +34,15 @@ public OwnedRoomManagementJooqAdapter(DSLContext dsl, ObjectMapper objectMapper) @Override public List findOwnedBy(UUID ownerAccountId, int limit) { + return findOwned(ownerAccountId, null, limit); + } + + @Override + public Optional findOwnedById(UUID ownerAccountId, UUID roomId) { + return findOwned(ownerAccountId, roomId, 1).stream().findFirst(); + } + + private List findOwned(UUID ownerAccountId, UUID roomId, int limit) { var rooms = table(name("competition", "rooms")).as("r"); var rules = table(name("competition", "room_rules")).as("rr"); var schedules = table(name("competition", "room_schedules")).as("rs"); @@ -39,6 +50,8 @@ public List findOwnedBy(UUID ownerAccountId, int limit) var id = field(name("r", "id"), UUID.class); var createdAt = field(name("r", "created_at"), OffsetDateTime.class); + Condition ownership = field(name("r", "creator_account_id"), UUID.class).eq(ownerAccountId); + if (roomId != null) ownership = ownership.and(id.eq(roomId)); List baseRooms = dsl.select( id, field(name("r", "name"), String.class), @@ -66,7 +79,7 @@ public List findOwnedBy(UUID ownerAccountId, int limit) .join(rules).on(id.eq(field(name("rr", "room_id"), UUID.class))) .join(schedules).on(id.eq(field(name("rs", "room_id"), UUID.class))) .join(live).on(id.eq(field(name("lr", "room_id"), UUID.class))) - .where(field(name("r", "creator_account_id"), UUID.class).eq(ownerAccountId)) + .where(ownership) .orderBy(createdAt.desc(), id.desc()) .limit(limit) .fetch(record -> new BaseRoom( 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 6c19933d..75b02c46 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 @@ -245,18 +245,14 @@ private void saveOfficialBacktestOnce( throw new ImmutableStrategyReleaseRejectedException( "Official backtest execution policy must be locked and not retired at the release instant"); } - datasets.forEach(dataset -> requireCompatibleOfficialInput( - policy.get("policy_document", String.class), dataset)); + OfficialPolicy officialPolicy = officialPolicy(policy.get("policy_document", String.class)); + datasets.forEach(dataset -> requireCompatibleOfficialInput(officialPolicy, dataset)); var queuedAt = release.releasedAt().atOffset(ZoneOffset.UTC); - 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"); - } + java.time.LocalDate periodStart = officialPolicy.periodStart(); + // The policy end is an exclusive local-day boundary, while a backtest request's + // periodEnd is inclusive. Sending the boundary itself asks the worker to evaluate + // one day beyond the selected manifest cover. + java.time.LocalDate periodEnd = officialPolicy.periodEnd().minusDays(1); List datasetPins = datasets.stream().map(dataset -> new DatasetPin( dataset.get("id", UUID.class), "MARKET_BARS", @@ -433,7 +429,7 @@ static String basicRequestHash( * consumer resolves it the same way (backtest-engine #87). Both ends are inclusive of the * manifest and must lie inside the policy window. */ - private void requireCompatibleOfficialInput(String policyDocument, org.jooq.Record dataset) { + private OfficialPolicy officialPolicy(String policyDocument) { final com.fasterxml.jackson.databind.JsonNode document; try { document = objectMapper.readTree(policyDocument); @@ -450,11 +446,29 @@ private void requireCompatibleOfficialInput(String policyDocument, org.jooq.Reco "Official backtest execution policy does not state its period and market data schema"); } + try { + java.time.ZoneId zone = java.time.ZoneId.of(policyTimezone); + java.time.LocalDate policyFirstDay = + OffsetDateTime.parse(policyStart).atZoneSameInstant(zone).toLocalDate(); + java.time.LocalDate policyLastDay = + OffsetDateTime.parse(policyEnd).atZoneSameInstant(zone).toLocalDate(); + if (policyLastDay.isBefore(policyFirstDay)) { + throw new ImmutableStrategyReleaseRejectedException( + "Official backtest execution policy period is reversed"); + } + return new OfficialPolicy(policySchema, policyFirstDay, policyLastDay); + } catch (java.time.DateTimeException exception) { + throw new ImmutableStrategyReleaseRejectedException( + "Official backtest execution policy period or timezone is invalid"); + } + } + + private void requireCompatibleOfficialInput(OfficialPolicy policy, org.jooq.Record dataset) { String manifestSchema = dataset.get("schema_version", String.class); - if (!policySchema.equals(manifestSchema)) { + if (!policy.marketDataSchemaVersion().equals(manifestSchema)) { throw new ImmutableStrategyReleaseRejectedException( "Official backtest dataset schema " + manifestSchema - + " does not match the execution policy schema " + policySchema); + + " does not match the execution policy schema " + policy.marketDataSchemaVersion()); } // Official Basic runs replay adjusted prices. A RAW manifest would silently measure a strategy @@ -465,27 +479,22 @@ private void requireCompatibleOfficialInput(String policyDocument, org.jooq.Reco "Official backtest dataset must be ADJUSTED, not " + dataLayer); } - java.time.ZoneId zone; - try { - zone = java.time.ZoneId.of(policyTimezone); - } catch (java.time.DateTimeException exception) { - throw new ImmutableStrategyReleaseRejectedException( - "Official backtest execution policy timezone is invalid: " + policyTimezone); - } - java.time.LocalDate policyFirstDay = - OffsetDateTime.parse(policyStart).atZoneSameInstant(zone).toLocalDate(); - java.time.LocalDate policyLastDay = - OffsetDateTime.parse(policyEnd).atZoneSameInstant(zone).toLocalDate(); java.time.LocalDate manifestFirstDay = dataset.get("period_start", java.time.LocalDate.class); java.time.LocalDate manifestLastDay = dataset.get("period_end", java.time.LocalDate.class); - if (manifestFirstDay.isBefore(policyFirstDay) || manifestLastDay.isAfter(policyLastDay)) { + if (manifestFirstDay.isBefore(policy.periodStart()) + || manifestLastDay.isAfter(policy.periodEnd().plusDays(1))) { throw new ImmutableStrategyReleaseRejectedException( "Official backtest dataset period " + manifestFirstDay + ".." + manifestLastDay + " is not inside the execution policy period " - + policyFirstDay + ".." + policyLastDay); + + policy.periodStart() + ".." + policy.periodEnd()); } } + private record OfficialPolicy( + String marketDataSchemaVersion, + java.time.LocalDate periodStart, + java.time.LocalDate periodEnd) {} + private static String prefixed(String value) { return value.startsWith("sha256:") ? value : "sha256:" + value; } 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 4d0703d5..4a82d88a 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 @@ -66,10 +66,10 @@ and exists ( and candidate.schema_version = p.policy_document ->> 'marketDataSchemaVersion' and candidate.object_count > 0 and btrim(candidate.dataset_hash) <> '' - and (candidate.period_start at time zone candidate_feed.timezone_name)::date >= + and (candidate.period_start at time zone 'UTC')::date >= ((p.policy_document ->> 'periodStart')::timestamptz at time zone (p.policy_document ->> 'timezone'))::date - and (candidate.period_end at time zone candidate_feed.timezone_name)::date <= + and (candidate.period_end at time zone 'UTC')::date <= ((p.policy_document ->> 'periodEnd')::timestamptz at time zone (p.policy_document ->> 'timezone'))::date and exists ( @@ -113,9 +113,9 @@ and not exists ( 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.revision_number, - (d.period_start at time zone f.timezone_name)::date as period_start, - (d.period_end at time zone f.timezone_name)::date as period_end, d.schema_version, d.available_at + select d.id, d.instrument_id, f.code as feed_code, d.data_layer, d.resolution, d.revision_number, + (d.period_start at time zone 'UTC')::date as period_start, + (d.period_end at time zone 'UTC')::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' @@ -151,6 +151,7 @@ and not exists ( order by d.period_end desc, d.period_start, d.id """, at, at, at).map(row -> new Dataset( row.get("id", UUID.class), + row.get("instrument_id", UUID.class), row.get("feed_code", String.class), row.get("data_layer", String.class), row.get("resolution", String.class), diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/CustomBacktestJooqAdapterIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/CustomBacktestJooqAdapterIntegrationTest.java index b07c3b03..41ccdb3f 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/CustomBacktestJooqAdapterIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/CustomBacktestJooqAdapterIntegrationTest.java @@ -387,7 +387,7 @@ private static String planDocument() { private static String policyDocument() { return "{\"marketRulesVersion\":\"v1\",\"accountingRulesVersion\":\"v1\"," + "\"precisionRulesVersion\":\"v1\",\"periodStart\":\"2024-01-01T05:00:00Z\"," - + "\"periodEnd\":\"2025-01-01T04:59:59Z\",\"marketDataSchemaVersion\":\"v1\"," + + "\"periodEnd\":\"2025-01-01T05:00:00Z\",\"marketDataSchemaVersion\":\"v1\"," + "\"timezone\":\"America/New_York\",\"feePolicyId\":\"" + FEE + "\"," + "\"buyingPowerBufferPolicyId\":\"" + BUFFER + "\"}"; } diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolverIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolverIntegrationTest.java index 3f37067a..6afea5c3 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolverIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/backtest/FeatureMaterializationPinResolverIntegrationTest.java @@ -98,6 +98,16 @@ void resolvesExactlyOneCompleteVersionedOutputForEveryPlanTuple() { MATERIALIZATION, "sha256:" + HASH)); } + @Test + void currentBasicRsiDefinitionReusesTheAuthoritySeededSemanticFeed() { + assertThat(FeatureMaterializationPinResolver.expectedFeatureOutputFeedId( + UUID.fromString("647a5fd6-98ed-0617-d4b2-844748d54fac"), + "sha256:64dbbcda7352d0add9a4a6a6ed94a780603880891684dc32cf39e0a3d1167422", + "rsi:1.0.0", + "1d")) + .isEqualTo(UUID.fromString("6d2647f8-5caf-55ee-8821-869dc693f68a")); + } + /* basic-compiled-plan-v2 declares requiredFeatures with minItems 0, and thirteen of the fourteen published Basic elements need no official feature, so a plan without any is the ordinary case rather than a malformed one. */ @@ -227,6 +237,18 @@ void acceptsSparseFeatureRowsThatBeginAfterTheRequestedWarmupWindow() { .containsExactly(new BacktestRunInputPinWriter.FeaturePin(MATERIALIZATION, "sha256:" + HASH)); } + @Test + void acceptsAFeatureMaterializationThatWarmsUpInsideTheEvaluationPeriod() { + jdbc.update("update market_data.feature_materializations set period_start = '2024-01-01T00:00:00Z' where id = ?", + MATERIALIZATION); + jdbc.update("update market_data.dataset_manifests set period_start = '2024-01-01T00:00:00Z' where id = ?", + MANIFEST); + + assertThat(resolver.resolve( + plan(), LocalDate.parse("2024-01-01"), LocalDate.parse("2024-12-31"), AS_OF)) + .containsExactly(new BacktestRunInputPinWriter.FeaturePin(MATERIALIZATION, "sha256:" + HASH)); + } + private void assertRejected(String message) { assertRejected(plan(), message); } diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/CompetitionRoomCqrsPersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/CompetitionRoomCqrsPersistenceIntegrationTest.java index 1ebd7b05..49166cbc 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/CompetitionRoomCqrsPersistenceIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/competition/CompetitionRoomCqrsPersistenceIntegrationTest.java @@ -334,6 +334,9 @@ void ownerManagementQueryRestoresConfigurationAndInvitationMetadata() { }); assertThat(view.participations()).isEmpty(); }); + assertThat(ownedRoomManagementAdapter.findOwnedById(OWNER_ID, ROOM_ID)) + .get().satisfies(view -> assertThat(view.name()).isEqualTo("Managed secret room")); + assertThat(ownedRoomManagementAdapter.findOwnedById(UUID.randomUUID(), ROOM_ID)).isEmpty(); assertThat(ownedRoomManagementAdapter.findOwnedBy(UUID.randomUUID(), 50)).isEmpty(); } 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 0ee3d6dd..f1ab20e4 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 @@ -49,6 +49,7 @@ class ImmutableStrategyReleasePersistenceIntegrationTest { 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 THIRD_DATASET_ID = UUID.fromString("d0000000-0000-4000-8000-000000000013"); 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"); @@ -107,7 +108,7 @@ void prepareValidatedStrategyAndPinnedPolicies() { + "values ('backtest-policy-v1', ?, ?::jsonb, ?)", HASH_A, "{\"version\":\"backtest-policy-v1\",\"periodStart\":\"2025-01-01T05:00:00Z\"," - + "\"periodEnd\":\"2026-01-01T05:00:00Z\",\"marketDataSchemaVersion\":\"v1\"," + + "\"periodEnd\":\"2025-12-31T05:00:00Z\",\"marketDataSchemaVersion\":\"v1\"," + "\"timezone\":\"America/New_York\"}", at); jdbc.update( @@ -151,8 +152,15 @@ void prepareValidatedStrategyAndPinnedPolicies() { + "(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', ?, ?, ?)", + + "'2025-06-30T00:00:00Z', 'v1', ?, ?, ?)", SECOND_DATASET_ID, SECOND_FEED_ID, HASH_C, 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-07-01T00:00:00Z', " + + "'2025-12-31T00:00:00Z', 'v1', ?, ?, ?)", + THIRD_DATASET_ID, SECOND_FEED_ID, HASH_B, at, at); jdbc.update( "insert into market_data.instruments " + "(id, asset_type, primary_exchange_mic, currency_code) values (?, 'STOCK', 'XNAS', 'USD')", @@ -229,7 +237,7 @@ void prepareValidatedStrategyAndPinnedPolicies() { void atomicallyCreatesOneImmutableAggregateAndMakesTheReleaseIdIdempotent() throws Exception { ImmutableStrategyRelease release = release(BOT_ID, HASH_D); OfficialBacktestRequest request = OfficialBacktestRequest.forRelease( - release, List.of(DATASET_ID, SECOND_DATASET_ID), "backtest-policy-v1"); + release, List.of(DATASET_ID, SECOND_DATASET_ID, THIRD_DATASET_ID), "backtest-policy-v1"); jdbc.update("update strategy.element_catalog_versions set retired_at = ? where id = ?", NOW.atOffset(ZoneOffset.UTC), CATALOG_ID); @@ -273,7 +281,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(2); + assertThat(count("backtest.input_datasets")).isEqualTo(3); 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 @@ -294,7 +302,7 @@ void atomicallyCreatesOneImmutableAggregateAndMakesTheReleaseIdIdempotent() thro "select jsonb_array_length(payload_document -> 'datasets') " + "from operations.outbox_messages where aggregate_id = ?", Integer.class, BOT_ID)) - .isEqualTo(2); + .isEqualTo(3); var transported = OBJECT_MAPPER.readValue( jdbc.queryForObject( "select payload_document::text from operations.outbox_messages where aggregate_id = ?", @@ -323,7 +331,12 @@ void atomicallyCreatesOneImmutableAggregateAndMakesTheReleaseIdIdempotent() thro new StrategyBotContractFixtures.PinnedFeatureMaterialization( FEATURE_MATERIALIZATION_ID.toString(), "sha256:" + HASH_B)); assertThat(transported.periodStart()).isEqualTo("2025-01-01"); - assertThat(transported.periodEnd()).isEqualTo("2025-12-31"); + assertThat(transported.periodEnd()).isEqualTo("2025-12-30"); + assertThat(jdbc.queryForObject( + "select evaluation_end from backtest.runs where id = ?", + java.time.LocalDate.class, + request.runId())) + .isEqualTo(java.time.LocalDate.parse("2025-12-30")); assertThat(transported.requestHash()).matches("sha256:[0-9a-f]{64}"); assertThat(jdbc.queryForMap( "select p.input_bundle_fingerprint, p.input_contract_version, " @@ -426,7 +439,7 @@ private void assertIncompatiblePolicyAndManifestAreRefused( assertNothingDurable(); // 3. A RAW manifest would measure the strategy against unadjusted splits and dividends. - setPolicyDocument("2025-01-01T05:00:00Z", "2026-01-01T05:00:00Z", "v1"); + setPolicyDocument("2025-01-01T05:00:00Z", "2025-12-31T05:00:00Z", "v1"); jdbc.update("update market_data.dataset_manifests set data_layer = 'RAW' where id = ?", DATASET_ID); assertThatThrownBy(() -> adapter.saveOnce(release, request, RUN_ID, 7, HASH_A)) .isInstanceOf(ImmutableStrategyReleaseRejectedException.class) @@ -453,7 +466,7 @@ private void assertIncompatiblePolicyAndManifestAreRefused( assertNothingDurable(); // Restore the compatible pair so the caller can go on to prove the success path. - setPolicyDocument("2025-01-01T05:00:00Z", "2026-01-01T05:00:00Z", "v1"); + setPolicyDocument("2025-01-01T05:00:00Z", "2025-12-31T05:00:00Z", "v1"); } private void setPolicyDocument(String periodStart, String periodEnd, String schemaVersion) { diff --git a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/StrategyReleaseInputCatalogPersistenceIntegrationTest.java b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/StrategyReleaseInputCatalogPersistenceIntegrationTest.java index 56e7bfae..f8cdc1ab 100644 --- a/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/StrategyReleaseInputCatalogPersistenceIntegrationTest.java +++ b/modules/backend-persistence/src/test/java/com/idea2strategy/backend/persistence/strategy/StrategyReleaseInputCatalogPersistenceIntegrationTest.java @@ -59,7 +59,7 @@ void seedDatasets() { PROVIDER, NOW.atOffset(ZoneOffset.UTC)); jdbc.update("insert into market_data.feeds " + "(id,provider_id,code,data_kind,resolution,timezone_name,feed_version,created_at) " - + "values (?, ?, 'LOCAL_MARKET_30M', 'BARS', '30m', 'UTC', 'v1', ?)", + + "values (?, ?, 'LOCAL_MARKET_30M', 'BARS', '30m', 'America/New_York', 'v1', ?)", MARKET_FEED, PROVIDER, NOW.atOffset(ZoneOffset.UTC)); insertDataset(MARKET_DATASET, MARKET_FEED, "ADJUSTED", "a".repeat(64)); insertDataset(FEATURE_DATASET, FEATURE_FEED, "DERIVED", "b".repeat(64));