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
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.
@@ -10,12 +10,14 @@ public record TraderArtifactBundle(
String pmConfigVersion,
String bundleHashSha256,
Set<String> 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");
}
}
@@ -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<String> 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> 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;
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
}
""");
}
}
@@ -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");
}
}
@@ -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(),