Load trader V4 artifacts from manifests

This commit is contained in:
Codex
2026-06-26 22:07:43 +08:00
parent 6bbedda97d
commit 4e5f49d6fe
11 changed files with 598 additions and 151 deletions
+49 -42
View File
@@ -1,65 +1,72 @@
# quant-trader-service # quant-trader-service
Clean P0 rebuild of the Trader-style strategy service. Trader V4 P0 decision service.
## Scope ## Scope
This implementation follows the desktop design documents in The service follows the V4 Trader documents under `/Users/zach/Desktop/app/trader`.
`/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` ## Implemented Surface
- `03-Trader服务详细设计说明书-20260623.md`
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 ## Local Database
- 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
## Implemented P0 Surface Default configuration:
- Spring Boot P0 service under `com.quantai.trader` ```text
- Playbook YAML loading, validation, normalized JSON, SHA-256 definition hash jdbc:mysql://127.0.0.1:3306/quant_trader
- MySQL 8 Flyway DDL for the P0 trader tables username: quant_trader
- Core domain records for cycles, actions, entry plans, risk, evidence, feedback, samples, and reports password: quant_trader
- 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 Override with `TRADER_DB_URL`, `TRADER_DB_USERNAME`, and `TRADER_DB_PASSWORD`.
- Evidence appender and proxy-only training sample exporter
- Async replay-run registry and report contract ## Artifact Root
- MyBatis-Plus repositories aligned with `quant-app-server`
- Deterministic JSONL replay fixtures for accepted, rejected, blocked, and hard-fail paths Default artifact root:
- Feedback endpoint that returns `TRADER_FEEDBACK_DISABLED` unless explicitly enabled
- Focused unit and MVC tests ```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 ## Commands
```bash ```bash
mvn test mvn clean test
mvn spring-boot:run 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 ## HTTP
```text ```text
GET /api/trader/health GET /api/trader/health
GET /api/trader/playbooks POST /api/trader/replay/cycles
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/feedback POST /api/trader/feedback
``` ```
Remark: `/api/trader/feedback` is intentionally disabled in P0 unless `/api/trader/feedback` is disabled by default in P0 and still rejects
`trader.integration.http-feedback-enabled=true`. `PAPER_APP`, `REAL_APP`, and any real fill when explicitly enabled for tests.
@@ -10,12 +10,14 @@ public record TraderArtifactBundle(
String pmConfigVersion, String pmConfigVersion,
String bundleHashSha256, String bundleHashSha256,
Set<String> providedModels, Set<String> providedModels,
TraderPmConfig pmConfig TraderPmConfig pmConfig,
TraderArtifactModelPolicy modelPolicy
) { ) {
public TraderArtifactBundle { public TraderArtifactBundle {
if (providedModels == null || !providedModels.containsAll(Set.of("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"))) { if (providedModels == null || !providedModels.containsAll(Set.of("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"))) {
throw new IllegalArgumentException("artifact bundle must provide all five V4 models"); throw new IllegalArgumentException("artifact bundle must provide all five V4 models");
} }
pmConfig = java.util.Objects.requireNonNull(pmConfig, "pmConfig is required"); pmConfig = java.util.Objects.requireNonNull(pmConfig, "pmConfig is required");
modelPolicy = java.util.Objects.requireNonNull(modelPolicy, "modelPolicy is required");
} }
} }
@@ -1,71 +1,186 @@
package com.quantai.trader.artifact; 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.config.TraderProperties;
import com.quantai.trader.domain.TraderException; import com.quantai.trader.domain.TraderException;
import com.quantai.trader.domain.TraderPmConfig; import com.quantai.trader.domain.TraderPmConfig;
import com.quantai.trader.enums.TraderRunMode;
import com.quantai.trader.enums.TraderErrorCode; import com.quantai.trader.enums.TraderErrorCode;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component; 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.Set;
import java.util.stream.Collectors;
import java.util.stream.StreamSupport;
@Component @Component
public class TraderArtifactLoader { public class TraderArtifactLoader {
private static final Logger log = LoggerFactory.getLogger(TraderArtifactLoader.class); private static final Logger log = LoggerFactory.getLogger(TraderArtifactLoader.class);
private static final Set<String> REQUIRED_MODELS = Set.of("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK");
private final TraderProperties properties; private final TraderProperties properties;
private final ObjectMapper objectMapper;
public TraderArtifactLoader(TraderProperties properties) { public TraderArtifactLoader(TraderProperties properties, ObjectMapper objectMapper) {
this.properties = properties; this.properties = properties;
this.objectMapper = objectMapper;
} }
public TraderArtifactBundle loadActiveBundle() { public TraderArtifactBundle loadActiveBundle() {
TraderProperties.Artifact artifact = properties.artifact(); TraderProperties.Artifact artifact = properties.artifact();
if (artifact.modelBundleVersion().isBlank() Path root = Path.of(artifact.artifactRoot());
|| artifact.calibrationBundleVersion().isBlank() TraderModelBundleManifest modelManifest = readModelBundleManifest(root.resolve("manifests/model_bundle_manifest.json"));
|| artifact.pmConfigVersion().isBlank()) { TraderPmConfigManifest pmManifest = readPmConfigManifest(root.resolve("manifests/position_manager_manifest.json"));
throw new TraderException(TraderErrorCode.TRADER_MODEL_ARTIFACT_MISSING, TraderArtifactModelPolicy modelPolicy = readJson(root.resolve("model_output_policy.json"), TraderArtifactModelPolicy.class);
"model/calibration/pm version is required"); validateVersions(artifact, modelManifest, pmManifest);
} validateModelManifest(modelManifest);
TraderArtifactBundle bundle = deterministicP0Bundle(artifact); 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={}", log.info("event=trader.artifact.loaded modelBundleVersion={} calibrationBundleVersion={} pmConfigVersion={} providedModels={}",
bundle.modelBundleVersion(), bundle.calibrationBundleVersion(), bundle.pmConfigVersion(), bundle.providedModels()); bundle.modelBundleVersion(), bundle.calibrationBundleVersion(), bundle.pmConfigVersion(), bundle.providedModels());
return bundle; return bundle;
} }
private TraderArtifactBundle deterministicP0Bundle(TraderProperties.Artifact artifact) { private TraderModelBundleManifest readModelBundleManifest(Path path) {
TraderPmConfig pmConfig = new TraderPmConfig( JsonNode root = readJsonNode(path);
artifact.pmConfigVersion(), return new TraderModelBundleManifest(
new TraderPmConfig.OpenRuleConfig( requiredText(root, "model_bundle_version", path),
new BigDecimal("0.58"), new BigDecimal("0.58"), requiredText(root, "calibration_bundle_version", path),
new BigDecimal("0.55"), new BigDecimal("0.55"), requiredText(root, "feature_version", path),
new BigDecimal("0.45"), new BigDecimal("1.0"), requiredText(root, "label_version", path),
new BigDecimal("0.03"), new BigDecimal("0.10"), new BigDecimal("0.80")), requiredText(root, "split_version", path),
new TraderPmConfig.AddRuleConfig( textSet(root, "required_models_json", path),
new BigDecimal("0.60"), new BigDecimal("0.60"), textSet(root, "provided_models_json", path),
new BigDecimal("0.58"), new BigDecimal("0.55"), new BigDecimal("0.45"), textSet(root, "missing_models_json", path),
new BigDecimal("0.45"), new BigDecimal("0.50"), requiredText(root, "bundle_hash_sha256", path),
new BigDecimal("1.0"), BigDecimal.ZERO, new BigDecimal("0.10"), root.path("complete").asBoolean(false),
new BigDecimal("500"), 3, 5), requiredText(root, "status", path));
new TraderPmConfig.ExitRuleConfig( }
new BigDecimal("0.70"), new BigDecimal("0.70"), new BigDecimal("0.70"),
new BigDecimal("0.25"), new BigDecimal("0.62"), private TraderPmConfigManifest readPmConfigManifest(Path path) {
new BigDecimal("0.35"), new BigDecimal("0.70"), JsonNode root = readJsonNode(path);
new BigDecimal("5.0"), new BigDecimal("80")), return new TraderPmConfigManifest(
new TraderPmConfig.SizingConfig( requiredText(root, "pm_config_version", path),
new BigDecimal("0.80"), new BigDecimal("0.05"), BigDecimal.ONE, requiredText(root, "model_bundle_version", path),
new BigDecimal("0.02"), new BigDecimal("0.25"), BigDecimal.ONE, requiredText(root, "calibration_bundle_version", path),
new BigDecimal("1.0"), new BigDecimal("80"), enumSet(root, "allowed_run_modes_json", TraderRunMode.class, path),
new BigDecimal("0.20"), new BigDecimal("0.50"), new BigDecimal("500")) convert(root.path("config_json"), TraderPmConfig.class, path),
); requiredText(root, "config_hash_sha256", path),
return new TraderArtifactBundle( requiredText(root, "status", path));
artifact.modelBundleVersion(), }
artifact.calibrationBundleVersion(),
artifact.pmConfigVersion(), private void validateVersions(TraderProperties.Artifact expected, TraderModelBundleManifest model, TraderPmConfigManifest pm) {
"deterministic-p0-fixture", if (!expected.modelBundleVersion().equals(model.modelBundleVersion())
Set.of("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"), || !expected.calibrationBundleVersion().equals(model.calibrationBundleVersion())
pmConfig); || !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> T readJson(Path path, Class<T> 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> T convert(JsonNode node, Class<T> 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<String> 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 <E extends Enum<E>> Set<E> enumSet(JsonNode node, String field, Class<E> type, Path path) {
return textSet(node, field, path).stream()
.map(value -> Enum.valueOf(type, value))
.collect(Collectors.toUnmodifiableSet());
} }
} }
@@ -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
) {
}
}
@@ -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<String> requiredModels,
Set<String> providedModels,
Set<String> missingModels,
String bundleHashSha256,
boolean complete,
String status
) {
}
@@ -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<TraderRunMode> allowedRunModes,
TraderPmConfig config,
String configHashSha256,
String status
) {
}
@@ -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"));
}
}
@@ -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"));
}
}
@@ -1,5 +1,7 @@
package com.quantai.trader; 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.config.TraderProperties;
import com.quantai.trader.domain.*; import com.quantai.trader.domain.*;
import com.quantai.trader.enums.PositionSide; import com.quantai.trader.enums.PositionSide;
@@ -9,6 +11,9 @@ import com.quantai.trader.enums.TraderRunMode;
import com.quantai.trader.risk.RiskLimits; import com.quantai.trader.risk.RiskLimits;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -27,6 +32,26 @@ public final class TestFixtures {
return properties(TraderRunMode.SHADOW, TraderExecutionMode.SHADOW, false, false); 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, public static TraderProperties properties(TraderRunMode runMode, TraderExecutionMode executionMode,
boolean tradingEnabled, boolean feedbackHttpEnabled) { boolean tradingEnabled, boolean feedbackHttpEnabled) {
return new TraderProperties( return new TraderProperties(
@@ -190,4 +215,145 @@ public final class TestFixtures {
public static RiskLimits riskLimits() { public static RiskLimits riskLimits() {
return new RiskLimits(bd("200"), BigDecimal.ONE, bd("500"), 3, 1500, false, false); 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
}
""");
}
} }
@@ -2,20 +2,29 @@ package com.quantai.trader.artifact;
import com.quantai.trader.config.TraderProperties; import com.quantai.trader.config.TraderProperties;
import org.junit.jupiter.api.Test; 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 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
class TraderArtifactLoaderTest { class TraderArtifactLoaderTest {
@TempDir
Path artifactRoot;
@Test @Test
void deterministicP0BundleProvidesAllFiveModelFamilies() { void activeArtifactBundleProvidesAllFiveModelFamilies() throws IOException {
TraderArtifactBundle bundle = new TraderArtifactLoader(properties()).loadActiveBundle(); writeArtifactBundle(artifactRoot);
TraderArtifactBundle bundle = new TraderArtifactLoader(propertiesWithArtifactRoot(artifactRoot), objectMapper()).loadActiveBundle();
assertThat(bundle.providedModels()).containsExactlyInAnyOrder("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK"); assertThat(bundle.providedModels()).containsExactlyInAnyOrder("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK");
assertThat(bundle.pmConfig().pmConfigVersion()).isEqualTo("pm-v4-btc-p0"); assertThat(bundle.pmConfig().pmConfigVersion()).isEqualTo("pm-v4-btc-p0");
assertThat(bundle.modelPolicy().entry().pricePlanConfigHash()).isEqualTo("p0-price-plan-hash");
} }
@Test @Test
@@ -26,13 +35,21 @@ class TraderArtifactLoaderTest {
} }
@Test @Test
void bundleContractRejectsMissingModelFamily() { void bundleContractRejectsMissingModelFamily() throws IOException {
TraderArtifactBundle bundle = new TraderArtifactLoader(properties()).loadActiveBundle(); writeArtifactBundle(artifactRoot);
TraderArtifactBundle bundle = new TraderArtifactLoader(propertiesWithArtifactRoot(artifactRoot), objectMapper()).loadActiveBundle();
assertThatThrownBy(() -> new TraderArtifactBundle( assertThatThrownBy(() -> new TraderArtifactBundle(
bundle.modelBundleVersion(), bundle.calibrationBundleVersion(), bundle.pmConfigVersion(), 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) .isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("all five"); .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");
}
} }
@@ -5,33 +5,39 @@ import com.quantai.trader.domain.*;
import com.quantai.trader.enums.TraderActionType; import com.quantai.trader.enums.TraderActionType;
import com.quantai.trader.evidence.EvidenceAppender; import com.quantai.trader.evidence.EvidenceAppender;
import com.quantai.trader.evidence.TraderEvidenceRepository; 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.TraderOutboxEvent;
import com.quantai.trader.outbox.TraderOutboxRepository; import com.quantai.trader.outbox.TraderOutboxRepository;
import com.quantai.trader.persistence.TraderDecisionTraceWriter; import com.quantai.trader.persistence.TraderDecisionTraceWriter;
import com.quantai.trader.position.TraderPositionManager; import com.quantai.trader.position.TraderPositionManager;
import com.quantai.trader.risk.TraderRiskGate; import com.quantai.trader.risk.TraderRiskGate;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Path;
import static com.quantai.trader.TestFixtures.T0; import static com.quantai.trader.TestFixtures.*;
import static com.quantai.trader.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class TraderP0CycleRunnerTest { class TraderP0CycleRunnerTest {
@TempDir
Path artifactRoot;
@Test @Test
void runsReplayShadowCycleThroughModelPmRiskActionOutboxAndEvidence() { void runsReplayShadowCycleThroughModelPmRiskActionOutboxAndEvidence() throws IOException {
writeArtifactBundle(artifactRoot);
RecordingEvidenceRepository evidenceRepository = new RecordingEvidenceRepository(); RecordingEvidenceRepository evidenceRepository = new RecordingEvidenceRepository();
EvidenceAppender evidenceAppender = new EvidenceAppender(evidenceRepository); EvidenceAppender evidenceAppender = new EvidenceAppender(evidenceRepository);
RecordingTraceWriter traceWriter = new RecordingTraceWriter(); RecordingTraceWriter traceWriter = new RecordingTraceWriter();
RecordingOutboxRepository outboxRepository = new RecordingOutboxRepository(); RecordingOutboxRepository outboxRepository = new RecordingOutboxRepository();
TraderP0CycleRunner runner = new TraderP0CycleRunner( TraderP0CycleRunner runner = new TraderP0CycleRunner(
properties(), propertiesWithArtifactRoot(artifactRoot),
new TraderArtifactLoader(properties()), new TraderArtifactLoader(propertiesWithArtifactRoot(artifactRoot), objectMapper()),
new DeterministicTraderModelService(), new ArtifactTraderModelService(),
new TraderPositionManager(), new TraderPositionManager(),
new TraderRiskGate(), new TraderRiskGate(),
new TraderActionFactory(), new TraderActionFactory(),
@@ -53,15 +59,16 @@ class TraderP0CycleRunnerTest {
} }
@Test @Test
void recordsWaitActionWhenReplaySnapshotHasNoLiquidity() { void recordsWaitActionWhenReplaySnapshotHasNoLiquidity() throws IOException {
writeArtifactBundle(artifactRoot);
RecordingEvidenceRepository evidenceRepository = new RecordingEvidenceRepository(); RecordingEvidenceRepository evidenceRepository = new RecordingEvidenceRepository();
EvidenceAppender evidenceAppender = new EvidenceAppender(evidenceRepository); EvidenceAppender evidenceAppender = new EvidenceAppender(evidenceRepository);
RecordingTraceWriter traceWriter = new RecordingTraceWriter(); RecordingTraceWriter traceWriter = new RecordingTraceWriter();
RecordingOutboxRepository outboxRepository = new RecordingOutboxRepository(); RecordingOutboxRepository outboxRepository = new RecordingOutboxRepository();
TraderP0CycleRunner runner = new TraderP0CycleRunner( TraderP0CycleRunner runner = new TraderP0CycleRunner(
properties(), propertiesWithArtifactRoot(artifactRoot),
new TraderArtifactLoader(properties()), new TraderArtifactLoader(propertiesWithArtifactRoot(artifactRoot), objectMapper()),
new DeterministicTraderModelService(), new ArtifactTraderModelService(),
new TraderPositionManager(), new TraderPositionManager(),
new TraderRiskGate(), new TraderRiskGate(),
new TraderActionFactory(), new TraderActionFactory(),