diff --git a/README.md b/README.md index dba0c9c..c89721e 100644 --- a/README.md +++ b/README.md @@ -1,65 +1,72 @@ # quant-trader-service -Clean P0 rebuild of the Trader-style strategy service. +Trader V4 P0 decision service. ## Scope -This implementation follows the desktop design documents in -`/Users/zach/Desktop/app/trader`: +The service follows the V4 Trader documents under `/Users/zach/Desktop/app/trader`. +P0 only allows `REPLAY_SIM` and `SHADOW`; `PAPER`, `REAL`, real fills, and old +strategy-service contracts are rejected. -- `00-操盘手Trader新策略服务概要设计-20260622.md` -- `03-Trader服务详细设计说明书-20260623.md` +## Implemented Surface -P0 keeps the service in replay/shadow preparation mode: +- Spring Boot service under `com.quantai.trader` +- strict P0 runtime guard for run mode, execution mode, and trading switch +- JSON artifact loader for model bundle manifest, PM config manifest, and model output policy +- five-model output contract: Direction, Entry, Continue, Exit, Risk +- dynamic PM sizing with side-aware stop/target price calculation +- Risk Gate for hard blockers and forced close decisions +- JDBC persistence for run, cycle, model output, PM decision, risk decision, action, evidence, and outbox +- Flyway V1 schema for the V4 P0 tables +- health, replay-cycle, and feedback endpoints +- JaCoCo line coverage gate at 90% -- no real trading -- no old V3 `latest/promoted` path -- no `SCALE_IN` action surface -- no real App feedback by default -- no model training that can control live size +## Local Database -## Implemented P0 Surface +Default configuration: -- Spring Boot P0 service under `com.quantai.trader` -- Playbook YAML loading, validation, normalized JSON, SHA-256 definition hash -- MySQL 8 Flyway DDL for the P0 trader tables -- Core domain records for cycles, actions, entry plans, risk, evidence, feedback, samples, and reports -- State-machine guardrails for initial entry, planned legs, and management actions -- Dynamic position sizing from signal, execution quality, and risk scores -- Risk gate that records every allow/block decision -- Evidence appender and proxy-only training sample exporter -- Async replay-run registry and report contract -- MyBatis-Plus repositories aligned with `quant-app-server` -- Deterministic JSONL replay fixtures for accepted, rejected, blocked, and hard-fail paths -- Feedback endpoint that returns `TRADER_FEEDBACK_DISABLED` unless explicitly enabled -- Focused unit and MVC tests +```text +jdbc:mysql://127.0.0.1:3306/quant_trader +username: quant_trader +password: quant_trader +``` + +Override with `TRADER_DB_URL`, `TRADER_DB_USERNAME`, and `TRADER_DB_PASSWORD`. + +## Artifact Root + +Default artifact root: + +```text +/Users/zach/Desktop/quant-strategy-training-data/trader-v4/artifact_bundle +``` + +Required files: + +```text +manifests/model_bundle_manifest.json +manifests/position_manager_manifest.json +model_output_policy.json +``` + +The loader requires the configured model/calibration/PM version triple to match +the manifests, all five model families to be present, and the model/PM manifests +to be `ACTIVE`. ## Commands ```bash -mvn test -mvn spring-boot:run +mvn clean test +TRADER_DB_USERNAME=quant_trader TRADER_DB_PASSWORD=quant_trader SERVER_PORT=18080 mvn spring-boot:run ``` -The service uses MySQL through MyBatis-Plus. Configure the database with -`TRADER_DB_URL`, `TRADER_DB_USERNAME`, and `TRADER_DB_PASSWORD` when the local -defaults are not available. - -Replay acceptance fixtures live under `src/test/resources/replay-fixtures`. -They are deterministic P0 contract samples, not a profitability backtest corpus. - ## HTTP ```text GET /api/trader/health -GET /api/trader/playbooks -GET /api/trader/playbooks/{playbookId} -POST /api/trader/replay/runs -GET /api/trader/replay/runs/{runId} -POST /api/trader/replay/runs/{runId}/cancel -GET /api/trader/replay/runs/{runId}/report +POST /api/trader/replay/cycles POST /api/trader/feedback ``` -Remark: `/api/trader/feedback` is intentionally disabled in P0 unless -`trader.integration.http-feedback-enabled=true`. +`/api/trader/feedback` is disabled by default in P0 and still rejects +`PAPER_APP`, `REAL_APP`, and any real fill when explicitly enabled for tests. diff --git a/src/main/java/com/quantai/trader/artifact/TraderArtifactBundle.java b/src/main/java/com/quantai/trader/artifact/TraderArtifactBundle.java index 5e15381..190ee58 100644 --- a/src/main/java/com/quantai/trader/artifact/TraderArtifactBundle.java +++ b/src/main/java/com/quantai/trader/artifact/TraderArtifactBundle.java @@ -10,12 +10,14 @@ public record TraderArtifactBundle( String pmConfigVersion, String bundleHashSha256, Set providedModels, - TraderPmConfig pmConfig + TraderPmConfig pmConfig, + TraderArtifactModelPolicy modelPolicy ) { public TraderArtifactBundle { if (providedModels == null || !providedModels.containsAll(Set.of("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"))) { throw new IllegalArgumentException("artifact bundle must provide all five V4 models"); } pmConfig = java.util.Objects.requireNonNull(pmConfig, "pmConfig is required"); + modelPolicy = java.util.Objects.requireNonNull(modelPolicy, "modelPolicy is required"); } } diff --git a/src/main/java/com/quantai/trader/artifact/TraderArtifactLoader.java b/src/main/java/com/quantai/trader/artifact/TraderArtifactLoader.java index 610e983..c302d18 100644 --- a/src/main/java/com/quantai/trader/artifact/TraderArtifactLoader.java +++ b/src/main/java/com/quantai/trader/artifact/TraderArtifactLoader.java @@ -1,71 +1,186 @@ package com.quantai.trader.artifact; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.quantai.trader.config.TraderProperties; import com.quantai.trader.domain.TraderException; import com.quantai.trader.domain.TraderPmConfig; +import com.quantai.trader.enums.TraderRunMode; import com.quantai.trader.enums.TraderErrorCode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; -import java.math.BigDecimal; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; @Component public class TraderArtifactLoader { private static final Logger log = LoggerFactory.getLogger(TraderArtifactLoader.class); + private static final Set REQUIRED_MODELS = Set.of("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"); private final TraderProperties properties; + private final ObjectMapper objectMapper; - public TraderArtifactLoader(TraderProperties properties) { + public TraderArtifactLoader(TraderProperties properties, ObjectMapper objectMapper) { this.properties = properties; + this.objectMapper = objectMapper; } public TraderArtifactBundle loadActiveBundle() { TraderProperties.Artifact artifact = properties.artifact(); - if (artifact.modelBundleVersion().isBlank() - || artifact.calibrationBundleVersion().isBlank() - || artifact.pmConfigVersion().isBlank()) { - throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, - "model/calibration/pm version is required"); - } - TraderArtifactBundle bundle = deterministicP0Bundle(artifact); + Path root = Path.of(artifact.artifactRoot()); + TraderModelBundleManifest modelManifest = readModelBundleManifest(root.resolve("manifests/model_bundle_manifest.json")); + TraderPmConfigManifest pmManifest = readPmConfigManifest(root.resolve("manifests/position_manager_manifest.json")); + TraderArtifactModelPolicy modelPolicy = readJson(root.resolve("model_output_policy.json"), TraderArtifactModelPolicy.class); + validateVersions(artifact, modelManifest, pmManifest); + validateModelManifest(modelManifest); + validatePmManifest(pmManifest, properties.runMode()); + TraderArtifactBundle bundle = new TraderArtifactBundle( + modelManifest.modelBundleVersion(), + modelManifest.calibrationBundleVersion(), + pmManifest.pmConfigVersion(), + modelManifest.bundleHashSha256(), + modelManifest.providedModels(), + pmManifest.config(), + modelPolicy); log.info("event=trader.artifact.loaded modelBundleVersion={} calibrationBundleVersion={} pmConfigVersion={} providedModels={}", bundle.modelBundleVersion(), bundle.calibrationBundleVersion(), bundle.pmConfigVersion(), bundle.providedModels()); return bundle; } - private TraderArtifactBundle deterministicP0Bundle(TraderProperties.Artifact artifact) { - TraderPmConfig pmConfig = new TraderPmConfig( - artifact.pmConfigVersion(), - new TraderPmConfig.OpenRuleConfig( - new BigDecimal("0.58"), new BigDecimal("0.58"), - new BigDecimal("0.55"), new BigDecimal("0.55"), - new BigDecimal("0.45"), new BigDecimal("1.0"), - new BigDecimal("0.03"), new BigDecimal("0.10"), new BigDecimal("0.80")), - new TraderPmConfig.AddRuleConfig( - new BigDecimal("0.60"), new BigDecimal("0.60"), - new BigDecimal("0.58"), new BigDecimal("0.55"), new BigDecimal("0.45"), - new BigDecimal("0.45"), new BigDecimal("0.50"), - new BigDecimal("1.0"), BigDecimal.ZERO, new BigDecimal("0.10"), - new BigDecimal("500"), 3, 5), - new TraderPmConfig.ExitRuleConfig( - new BigDecimal("0.70"), new BigDecimal("0.70"), new BigDecimal("0.70"), - new BigDecimal("0.25"), new BigDecimal("0.62"), - new BigDecimal("0.35"), new BigDecimal("0.70"), - new BigDecimal("5.0"), new BigDecimal("80")), - new TraderPmConfig.SizingConfig( - new BigDecimal("0.80"), new BigDecimal("0.05"), BigDecimal.ONE, - new BigDecimal("0.02"), new BigDecimal("0.25"), BigDecimal.ONE, - new BigDecimal("1.0"), new BigDecimal("80"), - new BigDecimal("0.20"), new BigDecimal("0.50"), new BigDecimal("500")) - ); - return new TraderArtifactBundle( - artifact.modelBundleVersion(), - artifact.calibrationBundleVersion(), - artifact.pmConfigVersion(), - "deterministic-p0-fixture", - Set.of("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"), - pmConfig); + private TraderModelBundleManifest readModelBundleManifest(Path path) { + JsonNode root = readJsonNode(path); + return new TraderModelBundleManifest( + requiredText(root, "model_bundle_version", path), + requiredText(root, "calibration_bundle_version", path), + requiredText(root, "feature_version", path), + requiredText(root, "label_version", path), + requiredText(root, "split_version", path), + textSet(root, "required_models_json", path), + textSet(root, "provided_models_json", path), + textSet(root, "missing_models_json", path), + requiredText(root, "bundle_hash_sha256", path), + root.path("complete").asBoolean(false), + requiredText(root, "status", path)); + } + + private TraderPmConfigManifest readPmConfigManifest(Path path) { + JsonNode root = readJsonNode(path); + return new TraderPmConfigManifest( + requiredText(root, "pm_config_version", path), + requiredText(root, "model_bundle_version", path), + requiredText(root, "calibration_bundle_version", path), + enumSet(root, "allowed_run_modes_json", TraderRunMode.class, path), + convert(root.path("config_json"), TraderPmConfig.class, path), + requiredText(root, "config_hash_sha256", path), + requiredText(root, "status", path)); + } + + private void validateVersions(TraderProperties.Artifact expected, TraderModelBundleManifest model, TraderPmConfigManifest pm) { + if (!expected.modelBundleVersion().equals(model.modelBundleVersion()) + || !expected.calibrationBundleVersion().equals(model.calibrationBundleVersion()) + || !expected.pmConfigVersion().equals(pm.pmConfigVersion())) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "artifact version triple does not match configured model/calibration/pm versions"); + } + if (!model.modelBundleVersion().equals(pm.modelBundleVersion()) + || !model.calibrationBundleVersion().equals(pm.calibrationBundleVersion())) { + throw new TraderException(TraderErrorCode.TRADER_CALIBRATION_MISMATCH, + "model and pm manifests reference different model/calibration versions"); + } + } + + private void validateModelManifest(TraderModelBundleManifest manifest) { + if (!manifest.complete() || !"ACTIVE".equals(manifest.status())) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "model bundle manifest must be complete and ACTIVE"); + } + if (!manifest.requiredModels().containsAll(REQUIRED_MODELS) + || !manifest.providedModels().containsAll(REQUIRED_MODELS) + || !manifest.missingModels().isEmpty()) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "model bundle must provide all five V4 models with no missing model"); + } + } + + private void validatePmManifest(TraderPmConfigManifest manifest, TraderRunMode runMode) { + if (!"ACTIVE".equals(manifest.status())) { + throw new TraderException(TraderErrorCode.TRADER_PM_CONFIG_MISMATCH, + "pm config manifest must be ACTIVE"); + } + if (!manifest.allowedRunModes().contains(runMode)) { + throw new TraderException(TraderErrorCode.TRADER_PM_CONFIG_MISMATCH, + "pm config manifest does not allow current run mode"); + } + } + + private JsonNode readJsonNode(Path path) { + if (!Files.isRegularFile(path)) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "artifact file is missing: " + path); + } + try { + return objectMapper.readTree(path.toFile()); + } catch (IOException exception) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "artifact file cannot be read: " + path); + } + } + + private T readJson(Path path, Class type) { + if (!Files.isRegularFile(path)) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "artifact file is missing: " + path); + } + try { + return objectMapper.readValue(path.toFile(), type); + } catch (IOException exception) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "artifact file cannot be read: " + path); + } + } + + private T convert(JsonNode node, Class type, Path path) { + if (node == null || node.isMissingNode() || node.isNull()) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "artifact field is missing: " + path + "#config_json"); + } + try { + return objectMapper.treeToValue(node, type); + } catch (IOException exception) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "artifact field cannot be parsed: " + path + "#config_json"); + } + } + + private String requiredText(JsonNode node, String field, Path path) { + String value = node.path(field).asText(""); + if (value.isBlank()) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "artifact field is required: " + path + "#" + field); + } + return value; + } + + private Set textSet(JsonNode node, String field, Path path) { + JsonNode array = node.path(field); + if (!array.isArray()) { + throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, + "artifact field must be array: " + path + "#" + field); + } + return StreamSupport.stream(array.spliterator(), false) + .map(JsonNode::asText) + .collect(Collectors.toUnmodifiableSet()); + } + + private > Set enumSet(JsonNode node, String field, Class type, Path path) { + return textSet(node, field, path).stream() + .map(value -> Enum.valueOf(type, value)) + .collect(Collectors.toUnmodifiableSet()); } } diff --git a/src/main/java/com/quantai/trader/artifact/TraderArtifactModelPolicy.java b/src/main/java/com/quantai/trader/artifact/TraderArtifactModelPolicy.java new file mode 100644 index 0000000..9735dc2 --- /dev/null +++ b/src/main/java/com/quantai/trader/artifact/TraderArtifactModelPolicy.java @@ -0,0 +1,76 @@ +package com.quantai.trader.artifact; + +import java.math.BigDecimal; + +public record TraderArtifactModelPolicy( + DirectionPolicy direction, + EntryPolicy entry, + ContinuePolicy continuation, + ExitPolicy exit, + RiskPolicy risk, + BigDecimal uncertainty, + BigDecimal oodScore +) { + public record DirectionPolicy( + BigDecimal longProbWhenMarkGteIndex, + BigDecimal longProbWhenMarkLtIndex, + BigDecimal neutralProb, + BigDecimal expectedReturnBps, + int horizonMinutes, + String modelVersion + ) { + } + + public record EntryPolicy( + BigDecimal longEntryProb, + BigDecimal shortEntryProb, + BigDecimal entryQualityScore, + BigDecimal expectedEdgeBps, + String pricePlanId, + String pricePlanConfigHash, + BigDecimal stopDistanceBps, + BigDecimal targetDistanceBps, + int maxHoldMinutes, + BigDecimal costBps, + String modelVersion + ) { + } + + public record ContinuePolicy( + BigDecimal longContinueProb, + BigDecimal shortContinueProb, + BigDecimal trendPersistenceProb, + BigDecimal holdEdgeBps, + BigDecimal continueVsExitEdgeBps, + String modelVersion + ) { + } + + public record ExitPolicy( + BigDecimal longExitProb, + BigDecimal shortExitProb, + BigDecimal profitGivebackProb, + BigDecimal reversalProb, + BigDecimal stopRiskProb, + BigDecimal stagnationProb, + BigDecimal expectedGivebackBps, + String modelVersion + ) { + } + + public record RiskPolicy( + BigDecimal marketRiskProb, + BigDecimal positionRiskProb, + BigDecimal marketRiskSeverityBps, + BigDecimal positionRiskSeverityBps, + BigDecimal drawdownProb, + BigDecimal expectedShortfallBps, + BigDecimal volatilityExpansionProb, + BigDecimal spikeProb, + BigDecimal liquidityRiskProb, + BigDecimal liquidityCapacityRatioWhenReady, + BigDecimal liquidityCapacityRatioWhenNotReady, + String modelVersion + ) { + } +} diff --git a/src/main/java/com/quantai/trader/artifact/TraderModelBundleManifest.java b/src/main/java/com/quantai/trader/artifact/TraderModelBundleManifest.java new file mode 100644 index 0000000..20645d2 --- /dev/null +++ b/src/main/java/com/quantai/trader/artifact/TraderModelBundleManifest.java @@ -0,0 +1,18 @@ +package com.quantai.trader.artifact; + +import java.util.Set; + +public record TraderModelBundleManifest( + String modelBundleVersion, + String calibrationBundleVersion, + String featureVersion, + String labelVersion, + String splitVersion, + Set requiredModels, + Set providedModels, + Set missingModels, + String bundleHashSha256, + boolean complete, + String status +) { +} diff --git a/src/main/java/com/quantai/trader/artifact/TraderPmConfigManifest.java b/src/main/java/com/quantai/trader/artifact/TraderPmConfigManifest.java new file mode 100644 index 0000000..7d29c68 --- /dev/null +++ b/src/main/java/com/quantai/trader/artifact/TraderPmConfigManifest.java @@ -0,0 +1,17 @@ +package com.quantai.trader.artifact; + +import com.quantai.trader.domain.TraderPmConfig; +import com.quantai.trader.enums.TraderRunMode; + +import java.util.Set; + +public record TraderPmConfigManifest( + String pmConfigVersion, + String modelBundleVersion, + String calibrationBundleVersion, + Set allowedRunModes, + TraderPmConfig config, + String configHashSha256, + String status +) { +} diff --git a/src/main/java/com/quantai/trader/model/ArtifactTraderModelService.java b/src/main/java/com/quantai/trader/model/ArtifactTraderModelService.java new file mode 100644 index 0000000..8f6bf7d --- /dev/null +++ b/src/main/java/com/quantai/trader/model/ArtifactTraderModelService.java @@ -0,0 +1,72 @@ +package com.quantai.trader.model; + +import com.quantai.trader.artifact.TraderArtifactBundle; +import com.quantai.trader.artifact.TraderArtifactModelPolicy; +import com.quantai.trader.domain.*; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.Map; + +@Service +public class ArtifactTraderModelService implements TraderModelService { + @Override + public TraderModelOutput evaluate(TraderMarketSnapshot snapshot, TraderArtifactBundle bundle) { + TraderArtifactModelPolicy policy = bundle.modelPolicy(); + DirectionOutput direction = direction(snapshot, bundle, policy.direction()); + return new TraderModelOutput( + "model_output_" + snapshot.cycleId(), + snapshot.runId(), + snapshot.cycleId(), + bundle.modelBundleVersion(), + bundle.calibrationBundleVersion(), + direction, + entry(bundle, policy.entry()), + continuation(bundle, policy.continuation()), + exit(bundle, policy.exit()), + risk(snapshot, bundle, policy.risk()), + policy.uncertainty(), + policy.oodScore(), + Map.of("artifactPolicy", bundle.bundleHashSha256())); + } + + private DirectionOutput direction(TraderMarketSnapshot snapshot, TraderArtifactBundle bundle, TraderArtifactModelPolicy.DirectionPolicy policy) { + BigDecimal longProb = snapshot.markPrice().compareTo(snapshot.indexPrice()) >= 0 + ? policy.longProbWhenMarkGteIndex() + : policy.longProbWhenMarkLtIndex(); + BigDecimal shortProb = BigDecimal.ONE.subtract(longProb).subtract(policy.neutralProb()).max(BigDecimal.ZERO); + BigDecimal neutralProb = BigDecimal.ONE.subtract(longProb).subtract(shortProb); + return new DirectionOutput(longProb, shortProb, neutralProb, longProb.max(shortProb), + longProb.subtract(shortProb).abs(), policy.expectedReturnBps(), policy.horizonMinutes(), + policy.modelVersion(), bundle.calibrationBundleVersion(), Map.of("source", "artifact_policy")); + } + + private EntryOutput entry(TraderArtifactBundle bundle, TraderArtifactModelPolicy.EntryPolicy policy) { + return new EntryOutput(policy.longEntryProb(), policy.shortEntryProb(), policy.entryQualityScore(), + policy.expectedEdgeBps(), policy.pricePlanId(), policy.pricePlanConfigHash(), + policy.stopDistanceBps(), policy.targetDistanceBps(), policy.maxHoldMinutes(), policy.costBps(), + policy.modelVersion(), bundle.calibrationBundleVersion(), Map.of("source", "artifact_policy")); + } + + private ContinueOutput continuation(TraderArtifactBundle bundle, TraderArtifactModelPolicy.ContinuePolicy policy) { + return new ContinueOutput(policy.longContinueProb(), policy.shortContinueProb(), policy.trendPersistenceProb(), + policy.holdEdgeBps(), policy.continueVsExitEdgeBps(), + policy.modelVersion(), bundle.calibrationBundleVersion(), Map.of("source", "artifact_policy")); + } + + private ExitOutput exit(TraderArtifactBundle bundle, TraderArtifactModelPolicy.ExitPolicy policy) { + return new ExitOutput(policy.longExitProb(), policy.shortExitProb(), policy.profitGivebackProb(), + policy.reversalProb(), policy.stopRiskProb(), policy.stagnationProb(), policy.expectedGivebackBps(), + policy.modelVersion(), bundle.calibrationBundleVersion(), Map.of("source", "artifact_policy")); + } + + private RiskOutput risk(TraderMarketSnapshot snapshot, TraderArtifactBundle bundle, TraderArtifactModelPolicy.RiskPolicy policy) { + BigDecimal liquidityCapacity = snapshot.dataReady() + ? policy.liquidityCapacityRatioWhenReady() + : policy.liquidityCapacityRatioWhenNotReady(); + return new RiskOutput(policy.marketRiskProb(), policy.positionRiskProb(), policy.marketRiskSeverityBps(), + policy.positionRiskSeverityBps(), policy.drawdownProb(), policy.expectedShortfallBps(), + policy.volatilityExpansionProb(), policy.spikeProb(), policy.liquidityRiskProb(), liquidityCapacity, + policy.modelVersion(), bundle.calibrationBundleVersion(), Map.of("source", "artifact_policy")); + } +} diff --git a/src/main/java/com/quantai/trader/model/DeterministicTraderModelService.java b/src/main/java/com/quantai/trader/model/DeterministicTraderModelService.java deleted file mode 100644 index a6f9bef..0000000 --- a/src/main/java/com/quantai/trader/model/DeterministicTraderModelService.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.quantai.trader.model; - -import com.quantai.trader.artifact.TraderArtifactBundle; -import com.quantai.trader.domain.*; -import org.springframework.stereotype.Service; - -import java.math.BigDecimal; -import java.util.Map; - -@Service -public class DeterministicTraderModelService implements TraderModelService { - @Override - public TraderModelOutput evaluate(TraderMarketSnapshot snapshot, TraderArtifactBundle bundle) { - BigDecimal directionalBias = snapshot.markPrice().compareTo(snapshot.indexPrice()) >= 0 - ? new BigDecimal("0.62") - : new BigDecimal("0.32"); - BigDecimal shortBias = BigDecimal.ONE.subtract(directionalBias).subtract(new BigDecimal("0.10")); - if (shortBias.compareTo(BigDecimal.ZERO) < 0) { - shortBias = BigDecimal.ZERO; - } - BigDecimal neutral = BigDecimal.ONE.subtract(directionalBias).subtract(shortBias); - EntryOutput entry = new EntryOutput( - new BigDecimal("0.63"), new BigDecimal("0.42"), new BigDecimal("0.64"), - new BigDecimal("12.0"), "p0-plan-atr-2r", "p0-price-plan-hash", - new BigDecimal("35"), new BigDecimal("70"), 45, new BigDecimal("4.0"), - "entry-p0", bundle.calibrationBundleVersion(), Map.of("source", "deterministic_fixture")); - return new TraderModelOutput( - "model_output_" + snapshot.cycleId(), - snapshot.runId(), - snapshot.cycleId(), - bundle.modelBundleVersion(), - bundle.calibrationBundleVersion(), - new DirectionOutput(directionalBias, shortBias, neutral, directionalBias, directionalBias.subtract(shortBias).abs(), - new BigDecimal("8.0"), 45, "direction-p0", bundle.calibrationBundleVersion(), Map.of()), - entry, - new ContinueOutput(new BigDecimal("0.61"), new BigDecimal("0.39"), new BigDecimal("0.58"), - new BigDecimal("5.0"), new BigDecimal("3.0"), "continue-p0", bundle.calibrationBundleVersion(), Map.of()), - new ExitOutput(new BigDecimal("0.24"), new BigDecimal("0.48"), new BigDecimal("0.20"), - new BigDecimal("0.25"), new BigDecimal("0.22"), new BigDecimal("0.20"), - new BigDecimal("10"), "exit-p0", bundle.calibrationBundleVersion(), Map.of()), - new RiskOutput(new BigDecimal("0.20"), new BigDecimal("0.18"), - new BigDecimal("20"), new BigDecimal("18"), new BigDecimal("0.15"), - new BigDecimal("20"), new BigDecimal("0.20"), new BigDecimal("0.10"), - new BigDecimal("0.12"), snapshot.depthNotional5Bps().compareTo(BigDecimal.ZERO) > 0 ? new BigDecimal("1.0") : BigDecimal.ZERO, - "risk-p0", bundle.calibrationBundleVersion(), Map.of()), - new BigDecimal("0.10"), - new BigDecimal("0.05"), - Map.of("source", "deterministic_p0_fixture")); - } -} diff --git a/src/test/java/com/quantai/trader/TestFixtures.java b/src/test/java/com/quantai/trader/TestFixtures.java index efc02fa..47cf8a7 100644 --- a/src/test/java/com/quantai/trader/TestFixtures.java +++ b/src/test/java/com/quantai/trader/TestFixtures.java @@ -1,5 +1,7 @@ package com.quantai.trader; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.quantai.trader.config.TraderProperties; import com.quantai.trader.domain.*; import com.quantai.trader.enums.PositionSide; @@ -9,6 +11,9 @@ import com.quantai.trader.enums.TraderRunMode; import com.quantai.trader.risk.RiskLimits; import java.math.BigDecimal; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Instant; import java.util.List; import java.util.Map; @@ -27,6 +32,26 @@ public final class TestFixtures { return properties(TraderRunMode.SHADOW, TraderExecutionMode.SHADOW, false, false); } + public static ObjectMapper objectMapper() { + return JsonMapper.builder().findAndAddModules().build(); + } + + public static TraderProperties propertiesWithArtifactRoot(Path artifactRoot) { + TraderProperties base = properties(); + return new TraderProperties( + base.serviceName(), + base.runMode(), + base.symbol(), + new TraderProperties.Artifact("trader-v4-btc-p0", "cal-v4-btc-p0", "pm-v4-btc-p0", artifactRoot.toString()), + base.feedback(), + base.execution(), + base.runtime(), + base.outbox(), + base.release(), + base.risk(), + base.positionManager()); + } + public static TraderProperties properties(TraderRunMode runMode, TraderExecutionMode executionMode, boolean tradingEnabled, boolean feedbackHttpEnabled) { return new TraderProperties( @@ -190,4 +215,145 @@ public final class TestFixtures { public static RiskLimits riskLimits() { return new RiskLimits(bd("200"), BigDecimal.ONE, bd("500"), 3, 1500, false, false); } + + public static void writeArtifactBundle(Path artifactRoot) throws IOException { + Files.createDirectories(artifactRoot.resolve("manifests")); + Files.writeString(artifactRoot.resolve("manifests/model_bundle_manifest.json"), """ + { + "model_bundle_version": "trader-v4-btc-p0", + "calibration_bundle_version": "cal-v4-btc-p0", + "feature_version": "feature-v4-p0", + "label_version": "label-v4-p0", + "split_version": "split-v4-p0", + "required_models_json": ["DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"], + "provided_models_json": ["DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"], + "missing_models_json": [], + "bundle_hash_sha256": "00000000000000000000000000000000000000000000000000000000000000a1", + "complete": true, + "status": "ACTIVE" + } + """); + Files.writeString(artifactRoot.resolve("manifests/position_manager_manifest.json"), """ + { + "pm_config_version": "pm-v4-btc-p0", + "model_bundle_version": "trader-v4-btc-p0", + "calibration_bundle_version": "cal-v4-btc-p0", + "allowed_run_modes_json": ["REPLAY_SIM", "SHADOW"], + "config_hash_sha256": "00000000000000000000000000000000000000000000000000000000000000b1", + "status": "ACTIVE", + "config_json": { + "pmConfigVersion": "pm-v4-btc-p0", + "open": { + "longOpenProb": 0.58, + "shortOpenProb": 0.58, + "minLongEntryProb": 0.55, + "minShortEntryProb": 0.55, + "maxMarketRiskProb": 0.45, + "minExpectedEdgeBps": 1.0, + "minDirectionMargin": 0.03, + "minLiquidityCapacityRatio": 0.10, + "maxOodScore": 0.80 + }, + "add": { + "minLongProb": 0.60, + "minShortProb": 0.60, + "minContinueProb": 0.58, + "minEntryProb": 0.55, + "maxExitProb": 0.45, + "maxMarketRiskProb": 0.45, + "maxPositionRiskProb": 0.50, + "minExpectedEdgeBps": 1.0, + "minContinueVsExitEdgeBps": 0, + "minLiquidityCapacityRatio": 0.10, + "minPostTradeLiquidationBufferBps": 500, + "maxAddCount": 3, + "cooldownMinutes": 5 + }, + "exit": { + "closeExitProb": 0.70, + "closePositionRiskProb": 0.70, + "closeMarketRiskProb": 0.70, + "closeContinueMax": 0.25, + "reduceGivebackProb": 0.62, + "reduceContinueMin": 0.35, + "reduceContinueMax": 0.70, + "minProfitForReduceBps": 5.0, + "maxExpectedShortfallBps": 80 + }, + "sizing": { + "baseRatio": 0.80, + "minInitialRatio": 0.05, + "maxSingleLegRatio": 1.0, + "minAddRatio": 0.02, + "maxAddRatio": 0.25, + "maxTotalPositionRatio": 1.0, + "minEdgeBps": 1.0, + "maxLossPerTradeBps": 80, + "maxLiquidityUsageRatio": 0.20, + "uncertaintyPenaltyMultiplier": 0.50, + "minPostTradeLiquidationBufferBps": 500 + } + } + } + """); + Files.writeString(artifactRoot.resolve("model_output_policy.json"), """ + { + "direction": { + "longProbWhenMarkGteIndex": 0.62, + "longProbWhenMarkLtIndex": 0.32, + "neutralProb": 0.10, + "expectedReturnBps": 8.0, + "horizonMinutes": 45, + "modelVersion": "direction-p0" + }, + "entry": { + "longEntryProb": 0.63, + "shortEntryProb": 0.42, + "entryQualityScore": 0.64, + "expectedEdgeBps": 12.0, + "pricePlanId": "p0-plan-atr-2r", + "pricePlanConfigHash": "p0-price-plan-hash", + "stopDistanceBps": 35, + "targetDistanceBps": 70, + "maxHoldMinutes": 45, + "costBps": 4.0, + "modelVersion": "entry-p0" + }, + "continuation": { + "longContinueProb": 0.61, + "shortContinueProb": 0.39, + "trendPersistenceProb": 0.58, + "holdEdgeBps": 5.0, + "continueVsExitEdgeBps": 3.0, + "modelVersion": "continue-p0" + }, + "exit": { + "longExitProb": 0.24, + "shortExitProb": 0.48, + "profitGivebackProb": 0.20, + "reversalProb": 0.25, + "stopRiskProb": 0.22, + "stagnationProb": 0.20, + "expectedGivebackBps": 10, + "modelVersion": "exit-p0" + }, + "risk": { + "marketRiskProb": 0.20, + "positionRiskProb": 0.18, + "marketRiskSeverityBps": 20, + "positionRiskSeverityBps": 18, + "drawdownProb": 0.15, + "expectedShortfallBps": 20, + "volatilityExpansionProb": 0.20, + "spikeProb": 0.10, + "liquidityRiskProb": 0.12, + "liquidityCapacityRatioWhenReady": 1.0, + "liquidityCapacityRatioWhenNotReady": 0, + "modelVersion": "risk-p0" + }, + "uncertainty": 0.10, + "oodScore": 0.05 + } + """); + } } diff --git a/src/test/java/com/quantai/trader/artifact/TraderArtifactLoaderTest.java b/src/test/java/com/quantai/trader/artifact/TraderArtifactLoaderTest.java index 4c579f1..f4759e7 100644 --- a/src/test/java/com/quantai/trader/artifact/TraderArtifactLoaderTest.java +++ b/src/test/java/com/quantai/trader/artifact/TraderArtifactLoaderTest.java @@ -2,20 +2,29 @@ package com.quantai.trader.artifact; import com.quantai.trader.config.TraderProperties; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.Path; import java.util.Set; -import static com.quantai.trader.TestFixtures.properties; +import static com.quantai.trader.TestFixtures.*; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; class TraderArtifactLoaderTest { + @TempDir + Path artifactRoot; + @Test - void deterministicP0BundleProvidesAllFiveModelFamilies() { - TraderArtifactBundle bundle = new TraderArtifactLoader(properties()).loadActiveBundle(); + void activeArtifactBundleProvidesAllFiveModelFamilies() throws IOException { + writeArtifactBundle(artifactRoot); + + TraderArtifactBundle bundle = new TraderArtifactLoader(propertiesWithArtifactRoot(artifactRoot), objectMapper()).loadActiveBundle(); assertThat(bundle.providedModels()).containsExactlyInAnyOrder("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"); assertThat(bundle.pmConfig().pmConfigVersion()).isEqualTo("pm-v4-btc-p0"); + assertThat(bundle.modelPolicy().entry().pricePlanConfigHash()).isEqualTo("p0-price-plan-hash"); } @Test @@ -26,13 +35,21 @@ class TraderArtifactLoaderTest { } @Test - void bundleContractRejectsMissingModelFamily() { - TraderArtifactBundle bundle = new TraderArtifactLoader(properties()).loadActiveBundle(); + void bundleContractRejectsMissingModelFamily() throws IOException { + writeArtifactBundle(artifactRoot); + TraderArtifactBundle bundle = new TraderArtifactLoader(propertiesWithArtifactRoot(artifactRoot), objectMapper()).loadActiveBundle(); assertThatThrownBy(() -> new TraderArtifactBundle( bundle.modelBundleVersion(), bundle.calibrationBundleVersion(), bundle.pmConfigVersion(), - bundle.bundleHashSha256(), Set.of("DIRECTION", "ENTRY", "CONTINUE", "RISK"), bundle.pmConfig())) + bundle.bundleHashSha256(), Set.of("DIRECTION", "ENTRY", "CONTINUE", "RISK"), bundle.pmConfig(), bundle.modelPolicy())) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("all five"); } + + @Test + void rejectsMissingArtifactFiles() { + assertThatThrownBy(() -> new TraderArtifactLoader(propertiesWithArtifactRoot(artifactRoot), objectMapper()).loadActiveBundle()) + .isInstanceOf(com.quantai.trader.domain.TraderException.class) + .hasMessageContaining("artifact file is missing"); + } } diff --git a/src/test/java/com/quantai/trader/replay/TraderP0CycleRunnerTest.java b/src/test/java/com/quantai/trader/replay/TraderP0CycleRunnerTest.java index 82fd393..6351d82 100644 --- a/src/test/java/com/quantai/trader/replay/TraderP0CycleRunnerTest.java +++ b/src/test/java/com/quantai/trader/replay/TraderP0CycleRunnerTest.java @@ -5,33 +5,39 @@ import com.quantai.trader.domain.*; import com.quantai.trader.enums.TraderActionType; import com.quantai.trader.evidence.EvidenceAppender; import com.quantai.trader.evidence.TraderEvidenceRepository; -import com.quantai.trader.model.DeterministicTraderModelService; +import com.quantai.trader.model.ArtifactTraderModelService; import com.quantai.trader.outbox.TraderOutboxEvent; import com.quantai.trader.outbox.TraderOutboxRepository; import com.quantai.trader.persistence.TraderDecisionTraceWriter; import com.quantai.trader.position.TraderPositionManager; import com.quantai.trader.risk.TraderRiskGate; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.util.ArrayList; import java.util.List; +import java.io.IOException; import java.math.BigDecimal; +import java.nio.file.Path; -import static com.quantai.trader.TestFixtures.T0; -import static com.quantai.trader.TestFixtures.properties; +import static com.quantai.trader.TestFixtures.*; import static org.assertj.core.api.Assertions.assertThat; class TraderP0CycleRunnerTest { + @TempDir + Path artifactRoot; + @Test - void runsReplayShadowCycleThroughModelPmRiskActionOutboxAndEvidence() { + void runsReplayShadowCycleThroughModelPmRiskActionOutboxAndEvidence() throws IOException { + writeArtifactBundle(artifactRoot); RecordingEvidenceRepository evidenceRepository = new RecordingEvidenceRepository(); EvidenceAppender evidenceAppender = new EvidenceAppender(evidenceRepository); RecordingTraceWriter traceWriter = new RecordingTraceWriter(); RecordingOutboxRepository outboxRepository = new RecordingOutboxRepository(); TraderP0CycleRunner runner = new TraderP0CycleRunner( - properties(), - new TraderArtifactLoader(properties()), - new DeterministicTraderModelService(), + propertiesWithArtifactRoot(artifactRoot), + new TraderArtifactLoader(propertiesWithArtifactRoot(artifactRoot), objectMapper()), + new ArtifactTraderModelService(), new TraderPositionManager(), new TraderRiskGate(), new TraderActionFactory(), @@ -53,15 +59,16 @@ class TraderP0CycleRunnerTest { } @Test - void recordsWaitActionWhenReplaySnapshotHasNoLiquidity() { + void recordsWaitActionWhenReplaySnapshotHasNoLiquidity() throws IOException { + writeArtifactBundle(artifactRoot); RecordingEvidenceRepository evidenceRepository = new RecordingEvidenceRepository(); EvidenceAppender evidenceAppender = new EvidenceAppender(evidenceRepository); RecordingTraceWriter traceWriter = new RecordingTraceWriter(); RecordingOutboxRepository outboxRepository = new RecordingOutboxRepository(); TraderP0CycleRunner runner = new TraderP0CycleRunner( - properties(), - new TraderArtifactLoader(properties()), - new DeterministicTraderModelService(), + propertiesWithArtifactRoot(artifactRoot), + new TraderArtifactLoader(propertiesWithArtifactRoot(artifactRoot), objectMapper()), + new ArtifactTraderModelService(), new TraderPositionManager(), new TraderRiskGate(), new TraderActionFactory(),