Initial quant trader service baseline

This commit is contained in:
Codex
2026-06-23 22:09:06 +08:00
commit 7ff786f658
137 changed files with 6664 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
target/
.idea/
*.iml
*.log
.DS_Store
# Runtime and local data stay outside source control.
logs/
run/
tmp/
*.pid
# Training data and model artifacts live under /Users/zach/Desktop/quant-strategy-training-data.
data/
models/
artifacts/
+65
View File
@@ -0,0 +1,65 @@
# quant-trader-service
Clean P0 rebuild of the Trader-style strategy service.
## Scope
This implementation follows the desktop design documents in
`/Users/zach/Desktop/app/trader`:
- `00-操盘手Trader新策略服务概要设计-20260622.md`
- `03-Trader服务详细设计说明书-20260623.md`
P0 keeps the service in replay/shadow preparation mode:
- 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
## Implemented P0 Surface
- 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
## Commands
```bash
mvn test
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/feedback
```
Remark: `/api/trader/feedback` is intentionally disabled in P0 unless
`trader.integration.http-feedback-enabled=true`.
+99
View File
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.0.5</version>
<relativePath/>
</parent>
<groupId>com.quantai</groupId>
<artifactId>quant-trader-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>quant-trader-service</name>
<description>Clean P0 rebuild of the Trader-style strategy service.</description>
<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.16</mybatis-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.datatype</groupId>
<artifactId>jackson-datatype-jsr310</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,16 @@
package com.quantai.trader;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@MapperScan("com.quantai.trader.infrastructure.mapper")
@SpringBootApplication
@ConfigurationPropertiesScan
public class QuantTraderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(QuantTraderServiceApplication.class, args);
}
}
@@ -0,0 +1,27 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.ManagementDecision;
import com.quantai.trader.domain.PlaybookCandidate;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.domain.TraderPositionPath;
import com.quantai.trader.enums.TraderActionType;
import org.springframework.stereotype.Component;
import java.util.Map;
@Component
public class LifecycleActionValueService {
public ManagementDecision decide(
TraderDecisionCycle cycle,
PlaybookCandidate candidate,
TraderPositionPath path,
TraderMarketSnapshot snapshot
) {
if (path != null && path.fullSize()) {
return new ManagementDecision(TraderActionType.CLOSE, "P0_PROXY_CLOSE_AFTER_FULL_PATH", Map.of("proxyOnly", true));
}
return new ManagementDecision(TraderActionType.HOLD, "P0_PROXY_HOLD", Map.of("proxyOnly", true));
}
}
@@ -0,0 +1,71 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.PlaybookCandidate;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.domain.TraderPricePlan;
import com.quantai.trader.enums.TraderErrorCode;
import com.quantai.trader.enums.TraderSide;
import com.quantai.trader.playbook.TraderPlaybookCatalog;
import com.quantai.trader.playbook.TraderPlaybookDefinitionSnapshot;
import com.quantai.trader.util.Ids;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
@Component
public class PlaybookCandidateEngine {
private final TraderPlaybookCatalog catalog;
public PlaybookCandidateEngine(TraderPlaybookCatalog catalog) {
this.catalog = catalog;
}
public List<PlaybookCandidate> generate(TraderMarketSnapshot snapshot, TraderDecisionCycle cycle) {
if (!Boolean.TRUE.equals(snapshot.setupFeatures().get("setupPass"))) {
return List.of();
}
TraderPlaybookDefinitionSnapshot playbook = catalog.require(cycle.playbookId(), cycle.playbookVersion());
BigDecimal entry = requiredDecimal(snapshot.setupFeatures(), "entryPrice");
TraderPricePlan pricePlan = new TraderPricePlan(
entry,
requiredDecimal(snapshot.setupFeatures(), "invalidPrice"),
requiredDecimal(snapshot.setupFeatures(), "stopPrice"),
requiredDecimal(snapshot.setupFeatures(), "targetPrice"),
null,
300_000,
7_200_000
);
return List.of(new PlaybookCandidate(
cycle.runId(),
cycle.cycleId(),
Ids.candidateId(cycle, playbook.playbookId()),
playbook.playbookId(),
playbook.playbookVersion(),
TraderSide.LONG,
playbook.variant(),
snapshot.snapshotTime(),
pricePlan,
playbook.definition().plannedEntryLegRule().maxPlannedEntryLegs(),
snapshot.setupFeatures()
));
}
private BigDecimal requiredDecimal(Map<String, Object> map, String key) {
Object value = map.get(key);
if (value instanceof Number number) {
return BigDecimal.valueOf(number.doubleValue());
}
if (value instanceof String text && !text.isBlank()) {
return new BigDecimal(text);
}
throw new TraderException(
TraderErrorCode.TRADER_ENTRY_PLAN_INCOMPLETE,
"setup feature is required when setupPass=true: " + key
);
}
}
@@ -0,0 +1,26 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.StageDecision;
import com.quantai.trader.domain.TraderMarketSnapshot;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Component
public class TraderContextGate {
public StageDecision evaluate(TraderMarketSnapshot snapshot) {
Object missing = snapshot.dataQuality().get("missing_features");
if (missing instanceof List<?> list && !list.isEmpty()) {
return new StageDecision(false, "DATA_MISSING", "TRADER_DATA_QUALITY_FAILED", Map.of(
"missingFeatures", list
));
}
Object pass = snapshot.contextFeatures().get("contextPass");
if (Boolean.FALSE.equals(pass)) {
return StageDecision.block("CONTEXT_BLOCKED", "TRADER_RISK_BLOCKED");
}
return StageDecision.pass("CONTEXT_PASS");
}
}
@@ -0,0 +1,14 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.TraderAction;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderPositionPath;
import com.quantai.trader.domain.TraderTrainingSample;
public record TraderCycleResult(
TraderDecisionCycle cycle,
TraderAction action,
TraderPositionPath path,
TraderTrainingSample sample
) {
}
@@ -0,0 +1,164 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.ExecutionDecision;
import com.quantai.trader.domain.ManagementDecision;
import com.quantai.trader.domain.PlaybookCandidate;
import com.quantai.trader.domain.RiskDecision;
import com.quantai.trader.domain.StageDecision;
import com.quantai.trader.domain.TraderAction;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderEntryPlan;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.domain.TraderPositionPath;
import com.quantai.trader.domain.TraderTrainingSample;
import com.quantai.trader.evidence.EvidenceAppender;
import com.quantai.trader.enums.TraderState;
import com.quantai.trader.execution.ExecutionQualityGate;
import com.quantai.trader.execution.TraderEntryPlanner;
import com.quantai.trader.market.SnapshotBuilder;
import com.quantai.trader.position.TraderPositionManager;
import com.quantai.trader.replay.ReplayClockTick;
import com.quantai.trader.risk.TraderRiskGate;
import com.quantai.trader.sample.TrainingSampleExporter;
import com.quantai.trader.state.TraderDecisionCycleFactory;
import com.quantai.trader.state.TraderRuntimeState;
import com.quantai.trader.state.TraderStateMachine;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
@Component
public class TraderDecisionCycleRunner {
private final SnapshotBuilder snapshotBuilder;
private final TraderContextGate contextGate;
private final PlaybookCandidateEngine playbookCandidateEngine;
private final TriggerMarkoutService triggerMarkoutService;
private final TraderEntryPlanner entryPlanner;
private final ExecutionQualityGate executionQualityGate;
private final TraderRiskGate riskGate;
private final TraderStateMachine stateMachine;
private final TraderPositionManager positionManager;
private final LifecycleActionValueService actionValueService;
private final EvidenceAppender evidenceAppender;
private final TrainingSampleExporter sampleExporter;
public TraderDecisionCycleRunner(
SnapshotBuilder snapshotBuilder,
TraderContextGate contextGate,
PlaybookCandidateEngine playbookCandidateEngine,
TriggerMarkoutService triggerMarkoutService,
TraderEntryPlanner entryPlanner,
ExecutionQualityGate executionQualityGate,
TraderRiskGate riskGate,
TraderStateMachine stateMachine,
TraderPositionManager positionManager,
LifecycleActionValueService actionValueService,
EvidenceAppender evidenceAppender,
TrainingSampleExporter sampleExporter
) {
this.snapshotBuilder = snapshotBuilder;
this.contextGate = contextGate;
this.playbookCandidateEngine = playbookCandidateEngine;
this.triggerMarkoutService = triggerMarkoutService;
this.entryPlanner = entryPlanner;
this.executionQualityGate = executionQualityGate;
this.riskGate = riskGate;
this.stateMachine = stateMachine;
this.positionManager = positionManager;
this.actionValueService = actionValueService;
this.evidenceAppender = evidenceAppender;
this.sampleExporter = sampleExporter;
}
public TraderCycleResult runReplayTick(ReplayClockTick tick, TraderRuntimeState runtimeState) {
TraderMarketSnapshot snapshot = snapshotBuilder.build(tick, runtimeState);
TraderDecisionCycle cycle = TraderDecisionCycleFactory.create(snapshot, runtimeState);
StageDecision context = contextGate.evaluate(snapshot);
evidenceAppender.append(cycle, "CONTEXT_GATE", context);
if (context.blocked()) {
TraderTrainingSample sample = sampleExporter.export(cycle.withState(TraderState.BLOCKED, "BLOCKED", context.blocker()), null, null, null);
return new TraderCycleResult(cycle, null, null, sample);
}
List<PlaybookCandidate> candidates = playbookCandidateEngine.generate(snapshot, cycle);
if (candidates.isEmpty()) {
evidenceAppender.append(cycle, "PLAYBOOK_CANDIDATE", StageDecision.block("NO_PLAYBOOK_CANDIDATE", "NO_PLAYBOOK_CANDIDATE"));
TraderTrainingSample sample = sampleExporter.export(cycle, null, null, null);
return new TraderCycleResult(cycle, null, null, sample);
}
PlaybookCandidate selected = candidates.getFirst();
var trigger = triggerMarkoutService.evaluate(snapshot, selected);
evidenceAppender.append(cycle, "TRIGGER_MARKOUT", new StageDecision(trigger.pass(), trigger.reason(), trigger.blocker(), trigger.details()));
if (trigger.blocked()) {
TraderTrainingSample sample = sampleExporter.export(cycle.withState(TraderState.TRIGGER_WAIT, "WAIT", trigger.blocker()), selected, null, null);
return new TraderCycleResult(cycle, null, null, sample);
}
TraderDecisionCycle entryCycle = cycle.withState(TraderState.ENTRY_PLANNED, "RUNNING", null);
TraderEntryPlan entryPlan = entryPlanner.planInitialEntry(entryCycle, selected, trigger);
ExecutionDecision execution = executionQualityGate.evaluate(snapshot, entryPlan);
evidenceAppender.append(entryCycle, "EXECUTION_QUALITY", new StageDecision(execution.pass(), execution.reason(), execution.blocker(), execution.details()));
RiskDecision risk = riskGate.evaluate(entryCycle, entryPlan, execution);
evidenceAppender.append(entryCycle, "RISK_GATE", new StageDecision(risk.allowAction(), risk.allowAction() ? "RISK_PASS" : "RISK_BLOCKED", risk.blocker(), risk.details()));
if (execution.blocked() || risk.blocked()) {
TraderTrainingSample sample = sampleExporter.export(entryCycle.withState(TraderState.BLOCKED, "BLOCKED", risk.blocker()), selected, null, null);
return new TraderCycleResult(entryCycle, null, null, sample);
}
TraderAction action = stateMachine.toInitialEntryAction(entryCycle, selected, entryPlan);
TraderPositionPath path = positionManager.simulateOrUpdate(entryCycle, action, snapshot);
evidenceAppender.append(entryCycle, "OPEN_INITIAL", StageDecision.pass(action.reason()));
TraderLifecycleResult lifecycle = runPositionLifecycle(entryCycle, selected, action, path, snapshot);
TraderTrainingSample sample = sampleExporter.export(
lifecycle.finalCycle(),
selected,
lifecycle.lastAction(),
lifecycle.finalPath()
);
return new TraderCycleResult(lifecycle.finalCycle(), lifecycle.lastAction(), lifecycle.finalPath(), sample);
}
private TraderLifecycleResult runPositionLifecycle(
TraderDecisionCycle initialCycle,
PlaybookCandidate candidate,
TraderAction initialAction,
TraderPositionPath initialPath,
TraderMarketSnapshot snapshot
) {
TraderDecisionCycle cycle = initialCycle;
TraderPositionPath path = initialPath;
TraderAction lastAction = initialAction;
if (!path.fullSize()) {
cycle = cycle.withState(TraderState.PLANNED_LEG_WAIT, "RUNNING", null);
Optional<TraderEntryPlan> plannedLeg = entryPlanner.planNextDeclaredLeg(cycle, candidate, path);
if (plannedLeg.isPresent()) {
ExecutionDecision execution = executionQualityGate.evaluate(snapshot, plannedLeg.get());
RiskDecision risk = riskGate.evaluate(cycle, plannedLeg.get(), execution);
evidenceAppender.append(cycle, "PLANNED_LEG_EXECUTION", new StageDecision(execution.pass(), execution.reason(), execution.blocker(), execution.details()));
evidenceAppender.append(cycle, "PLANNED_LEG_RISK", new StageDecision(risk.allowAction(), risk.allowAction() ? "RISK_PASS" : "RISK_BLOCKED", risk.blocker(), risk.details()));
if (execution.pass() && risk.allowAction()) {
TraderAction legAction = stateMachine.toPlannedLegAction(cycle, plannedLeg.get(), path);
path = positionManager.simulateOrUpdate(cycle, legAction, snapshot);
evidenceAppender.append(cycle, "OPEN_PLANNED_LEG", StageDecision.pass(legAction.reason()));
lastAction = legAction;
}
}
}
cycle = cycle.withState(TraderState.MANAGING, "RUNNING", null);
ManagementDecision management = actionValueService.decide(cycle, candidate, path, snapshot);
evidenceAppender.append(cycle, "ACTION_VALUE", new StageDecision(true, management.reason(), null, management.details()));
TraderAction managementAction = stateMachine.toManagementAction(cycle, path, management.actionType());
riskGate.evaluateManagement(cycle, managementAction, path, management);
path = positionManager.simulateOrUpdate(cycle, managementAction, snapshot);
evidenceAppender.append(cycle, "MANAGEMENT_ACTION", StageDecision.pass(managementAction.reason()));
lastAction = managementAction;
return new TraderLifecycleResult(cycle.withState(TraderState.SAMPLE_EXPORTED, "COMPLETED", null), path, lastAction);
}
}
@@ -0,0 +1,12 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.TraderAction;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderPositionPath;
public record TraderLifecycleResult(
TraderDecisionCycle finalCycle,
TraderPositionPath finalPath,
TraderAction lastAction
) {
}
@@ -0,0 +1,39 @@
package com.quantai.trader.brain;
import com.quantai.trader.domain.PlaybookCandidate;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.domain.TriggerDecision;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Map;
@Component
public class TriggerMarkoutService {
public TriggerDecision evaluate(TraderMarketSnapshot snapshot, PlaybookCandidate candidate) {
BigDecimal score = readScore(snapshot.triggerFeatures().get("triggerScore"));
if (score == null) {
return new TriggerDecision(false, BigDecimal.ZERO, "TRIGGER_SCORE_MISSING", "NO_TRIGGER_MARKOUT", Map.of());
}
if (score.compareTo(new BigDecimal("0.50")) < 0) {
return new TriggerDecision(false, score, "TRIGGER_WAIT", "NO_TRIGGER_MARKOUT", Map.of(
"triggerScore", score
));
}
return new TriggerDecision(true, score, "TRIGGER_ACCEPTED", null, Map.of(
"triggerScore", score,
"proxyOnly", true
));
}
private BigDecimal readScore(Object value) {
if (value instanceof Number number) {
return BigDecimal.valueOf(number.doubleValue());
}
if (value instanceof String text && !text.isBlank()) {
return new BigDecimal(text);
}
return null;
}
}
@@ -0,0 +1,299 @@
package com.quantai.trader.config;
import com.quantai.trader.enums.TraderRunMode;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.math.BigDecimal;
@ConfigurationProperties(prefix = "trader")
public class TraderProperties {
private String serviceName = "quant-trader-service";
private TraderRunMode runMode = TraderRunMode.REPLAY;
private String symbol = "BTCUSDT";
private String featureVersion = "trader_feature_v0";
private String labelVersion = "trader_label_v0";
private Playbook playbook = new Playbook();
private Replay replay = new Replay();
private Integration integration = new Integration();
private Risk risk = new Risk();
private Sizing sizing = new Sizing();
private DataSource dataSource = new DataSource();
public String getServiceName() {
return serviceName;
}
public void setServiceName(String serviceName) {
this.serviceName = serviceName;
}
public TraderRunMode getRunMode() {
return runMode;
}
public void setRunMode(TraderRunMode runMode) {
this.runMode = runMode;
}
public String getSymbol() {
return symbol;
}
public void setSymbol(String symbol) {
this.symbol = symbol;
}
public String getFeatureVersion() {
return featureVersion;
}
public void setFeatureVersion(String featureVersion) {
this.featureVersion = featureVersion;
}
public String getLabelVersion() {
return labelVersion;
}
public void setLabelVersion(String labelVersion) {
this.labelVersion = labelVersion;
}
public Playbook getPlaybook() {
return playbook;
}
public void setPlaybook(Playbook playbook) {
this.playbook = playbook;
}
public Replay getReplay() {
return replay;
}
public void setReplay(Replay replay) {
this.replay = replay;
}
public Integration getIntegration() {
return integration;
}
public void setIntegration(Integration integration) {
this.integration = integration;
}
public Risk getRisk() {
return risk;
}
public void setRisk(Risk risk) {
this.risk = risk;
}
public Sizing getSizing() {
return sizing;
}
public void setSizing(Sizing sizing) {
this.sizing = sizing;
}
public DataSource getDataSource() {
return dataSource;
}
public void setDataSource(DataSource dataSource) {
this.dataSource = dataSource;
}
public static class Playbook {
private String locationPattern = "classpath:/playbooks/*.yml";
public String getLocationPattern() {
return locationPattern;
}
public void setLocationPattern(String locationPattern) {
this.locationPattern = locationPattern;
}
}
public static class Replay {
private String outputDir = "/Users/zach/Desktop/app/trader/replay-output";
private boolean failOnDataMissing = true;
public String getOutputDir() {
return outputDir;
}
public void setOutputDir(String outputDir) {
this.outputDir = outputDir;
}
public boolean isFailOnDataMissing() {
return failOnDataMissing;
}
public void setFailOnDataMissing(boolean failOnDataMissing) {
this.failOnDataMissing = failOnDataMissing;
}
}
public static class Integration {
private String appActionChannel = "JAR_FUTURE";
private boolean httpFeedbackEnabled = false;
public String getAppActionChannel() {
return appActionChannel;
}
public void setAppActionChannel(String appActionChannel) {
this.appActionChannel = appActionChannel;
}
public boolean isHttpFeedbackEnabled() {
return httpFeedbackEnabled;
}
public void setHttpFeedbackEnabled(boolean httpFeedbackEnabled) {
this.httpFeedbackEnabled = httpFeedbackEnabled;
}
}
public static class Risk {
private BigDecimal leverageScreen = BigDecimal.TEN;
private boolean requireOneXNotNegative = true;
private int maxPlannedEntryLegs = 3;
private boolean allowFreeScaleIn = false;
private boolean allowReduceThenAdd = false;
private boolean requireStop = true;
private boolean requireTarget = true;
private boolean requireInvalid = true;
public BigDecimal getLeverageScreen() {
return leverageScreen;
}
public void setLeverageScreen(BigDecimal leverageScreen) {
this.leverageScreen = leverageScreen;
}
public boolean isRequireOneXNotNegative() {
return requireOneXNotNegative;
}
public void setRequireOneXNotNegative(boolean requireOneXNotNegative) {
this.requireOneXNotNegative = requireOneXNotNegative;
}
public int getMaxPlannedEntryLegs() {
return maxPlannedEntryLegs;
}
public void setMaxPlannedEntryLegs(int maxPlannedEntryLegs) {
this.maxPlannedEntryLegs = maxPlannedEntryLegs;
}
public boolean isAllowFreeScaleIn() {
return allowFreeScaleIn;
}
public void setAllowFreeScaleIn(boolean allowFreeScaleIn) {
this.allowFreeScaleIn = allowFreeScaleIn;
}
public boolean isAllowReduceThenAdd() {
return allowReduceThenAdd;
}
public void setAllowReduceThenAdd(boolean allowReduceThenAdd) {
this.allowReduceThenAdd = allowReduceThenAdd;
}
public boolean isRequireStop() {
return requireStop;
}
public void setRequireStop(boolean requireStop) {
this.requireStop = requireStop;
}
public boolean isRequireTarget() {
return requireTarget;
}
public void setRequireTarget(boolean requireTarget) {
this.requireTarget = requireTarget;
}
public boolean isRequireInvalid() {
return requireInvalid;
}
public void setRequireInvalid(boolean requireInvalid) {
this.requireInvalid = requireInvalid;
}
}
public static class Sizing {
private String method = "SIGNAL_EXECUTION_RISK_DYNAMIC";
private boolean allowFullInitialEntry = true;
private int maxPlannedEntryLegs = 3;
private BigDecimal maxTotalPositionRatio = BigDecimal.ONE;
private BigDecimal maxSingleLegRatio = BigDecimal.ONE;
public String getMethod() {
return method;
}
public void setMethod(String method) {
this.method = method;
}
public boolean isAllowFullInitialEntry() {
return allowFullInitialEntry;
}
public void setAllowFullInitialEntry(boolean allowFullInitialEntry) {
this.allowFullInitialEntry = allowFullInitialEntry;
}
public int getMaxPlannedEntryLegs() {
return maxPlannedEntryLegs;
}
public void setMaxPlannedEntryLegs(int maxPlannedEntryLegs) {
this.maxPlannedEntryLegs = maxPlannedEntryLegs;
}
public BigDecimal getMaxTotalPositionRatio() {
return maxTotalPositionRatio;
}
public void setMaxTotalPositionRatio(BigDecimal maxTotalPositionRatio) {
this.maxTotalPositionRatio = maxTotalPositionRatio;
}
public BigDecimal getMaxSingleLegRatio() {
return maxSingleLegRatio;
}
public void setMaxSingleLegRatio(BigDecimal maxSingleLegRatio) {
this.maxSingleLegRatio = maxSingleLegRatio;
}
}
public static class DataSource {
private String hashMode = "FULL_HASH_OR_SCHEMA_ROW_TIME_MISSING_SUMMARY";
public String getHashMode() {
return hashMode;
}
public void setHashMode(String hashMode) {
this.hashMode = hashMode;
}
}
}
@@ -0,0 +1,7 @@
package com.quantai.trader.controller;
public record TraderApiError(
String code,
String message
) {
}
@@ -0,0 +1,21 @@
package com.quantai.trader.controller;
import com.quantai.trader.domain.TraderException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class TraderApiExceptionHandler {
@ExceptionHandler(TraderException.class)
public ResponseEntity<TraderApiError> handleTraderException(TraderException ex) {
return ResponseEntity.badRequest().body(new TraderApiError(ex.errorCode().name(), ex.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<TraderApiError> handleIllegalArgument(IllegalArgumentException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new TraderApiError("TRADER_NOT_FOUND", ex.getMessage()));
}
}
@@ -0,0 +1,48 @@
package com.quantai.trader.controller;
import com.quantai.trader.config.TraderProperties;
import com.quantai.trader.enums.TraderErrorCode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/trader/feedback")
public class TraderFeedbackController {
private static final Logger log = LoggerFactory.getLogger(TraderFeedbackController.class);
private final TraderProperties properties;
public TraderFeedbackController(TraderProperties properties) {
this.properties = properties;
}
@PostMapping
public ResponseEntity<?> feedback(@RequestBody TraderFeedbackRequest request) {
if (!properties.getIntegration().isHttpFeedbackEnabled()) {
log.info(
"event=trader.feedback.rejected runId={} cycleId={} actionId={} reason={}",
request.runId(),
request.cycleId(),
request.actionId(),
TraderErrorCode.TRADER_FEEDBACK_DISABLED
);
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(new TraderApiError(
TraderErrorCode.TRADER_FEEDBACK_DISABLED.name(),
"P0 feedback endpoint is disabled; future App main channel is trader-core jar"
));
}
return ResponseEntity.accepted().body(Map.of(
"status", "ACCEPTED_CONTRACT_ONLY",
"runId", request.runId(),
"actionId", request.actionId()
));
}
}
@@ -0,0 +1,26 @@
package com.quantai.trader.controller;
import com.quantai.trader.enums.TraderFeedbackSource;
import com.quantai.trader.enums.TraderFeedbackType;
import java.math.BigDecimal;
import java.util.Map;
public record TraderFeedbackRequest(
String runId,
String cycleId,
String actionId,
TraderFeedbackType feedbackType,
TraderFeedbackSource feedbackSource,
boolean realFill,
String proxyMethod,
String simulatorVersion,
String orderId,
String positionId,
BigDecimal filledPrice,
BigDecimal filledQuantity,
BigDecimal fee,
BigDecimal slippageBps,
Map<String, Object> rawFeedback
) {
}
@@ -0,0 +1,33 @@
package com.quantai.trader.controller;
import com.quantai.trader.config.TraderProperties;
import com.quantai.trader.playbook.TraderPlaybookCatalog;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/trader")
public class TraderHealthController {
private final TraderProperties properties;
private final TraderPlaybookCatalog catalog;
public TraderHealthController(TraderProperties properties, TraderPlaybookCatalog catalog) {
this.properties = properties;
this.catalog = catalog;
}
@GetMapping("/health")
public Map<String, Object> health() {
return Map.of(
"service", properties.getServiceName(),
"runMode", properties.getRunMode(),
"symbol", properties.getSymbol(),
"playbookCount", catalog.list().size(),
"httpFeedbackEnabled", properties.getIntegration().isHttpFeedbackEnabled()
);
}
}
@@ -0,0 +1,52 @@
package com.quantai.trader.controller;
import com.quantai.trader.playbook.TraderPlaybookCatalog;
import com.quantai.trader.playbook.TraderPlaybookDefinitionSnapshot;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/trader/playbooks")
public class TraderPlaybookController {
private final TraderPlaybookCatalog catalog;
public TraderPlaybookController(TraderPlaybookCatalog catalog) {
this.catalog = catalog;
}
@GetMapping
public List<PlaybookResponse> list() {
return catalog.list().stream().map(PlaybookResponse::from).toList();
}
@GetMapping("/{playbookId}")
public PlaybookResponse get(@PathVariable String playbookId) {
return PlaybookResponse.from(catalog.require(playbookId));
}
public record PlaybookResponse(
String playbookId,
String playbookVersion,
String family,
String variant,
String definitionHashSha256,
List<String> outputActions
) {
static PlaybookResponse from(TraderPlaybookDefinitionSnapshot snapshot) {
return new PlaybookResponse(
snapshot.playbookId(),
snapshot.playbookVersion(),
snapshot.family(),
snapshot.variant(),
snapshot.definitionHashSha256(),
snapshot.definition().outputActions()
);
}
}
}
@@ -0,0 +1,52 @@
package com.quantai.trader.controller;
import com.quantai.trader.domain.TraderReplayReport;
import com.quantai.trader.persistence.ReplayReportRepository;
import com.quantai.trader.replay.ReplayRun;
import com.quantai.trader.replay.ReplayRunConfig;
import com.quantai.trader.replay.ReplayRunResponse;
import com.quantai.trader.replay.ReplayRunService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/trader/replay/runs")
public class TraderReplayController {
private final ReplayRunService replayRunService;
private final ReplayReportRepository reportRepository;
public TraderReplayController(ReplayRunService replayRunService, ReplayReportRepository reportRepository) {
this.replayRunService = replayRunService;
this.reportRepository = reportRepository;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ReplayRunResponse create(@RequestBody ReplayRunConfig config) {
return replayRunService.createRun(config);
}
@GetMapping("/{runId}")
public ReplayRun get(@PathVariable String runId) {
return replayRunService.find(runId)
.orElseThrow(() -> new IllegalArgumentException("replay run not found: " + runId));
}
@PostMapping("/{runId}/cancel")
public ReplayRun cancel(@PathVariable String runId) {
return replayRunService.cancel(runId);
}
@GetMapping("/{runId}/report")
public TraderReplayReport report(@PathVariable String runId) {
return reportRepository.findByRunId(runId)
.orElseThrow(() -> new IllegalArgumentException("replay report not found: " + runId));
}
}
@@ -0,0 +1,21 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.util.Map;
public record ExecutionDecision(
boolean pass,
BigDecimal executionQualityScore,
String reason,
String blocker,
Map<String, Object> details
) {
public ExecutionDecision {
details = Maps.immutable(details);
}
public boolean blocked() {
return !pass;
}
}
@@ -0,0 +1,16 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import java.util.Map;
public record ManagementDecision(
TraderActionType actionType,
String reason,
Map<String, Object> details
) {
public ManagementDecision {
details = Maps.immutable(details);
}
}
@@ -0,0 +1,17 @@
package com.quantai.trader.domain;
import java.util.LinkedHashMap;
import java.util.Map;
final class Maps {
private Maps() {
}
static Map<String, Object> immutable(Map<String, Object> value) {
if (value == null || value.isEmpty()) {
return Map.of();
}
return Map.copyOf(new LinkedHashMap<>(value));
}
}
@@ -0,0 +1,25 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderSide;
import java.time.Instant;
import java.util.Map;
public record PlaybookCandidate(
String runId,
String cycleId,
String candidateId,
String playbookId,
String playbookVersion,
TraderSide side,
String variant,
Instant candidateTime,
TraderPricePlan pricePlan,
int maxPlannedEntryLegs,
Map<String, Object> setupEvidence
) {
public PlaybookCandidate {
setupEvidence = Maps.immutable(setupEvidence);
}
}
@@ -0,0 +1,14 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
public record PositionSizingPlan(
int plannedLegCount,
BigDecimal initialLegRatio,
BigDecimal nextLegRatio,
String sizingMethod,
BigDecimal signalStrengthScore,
BigDecimal executionQualityScore,
BigDecimal riskGateScore
) {
}
@@ -0,0 +1,20 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.util.Map;
public record RiskDecision(
boolean allowAction,
String blocker,
BigDecimal riskGateScore,
Map<String, Object> details
) {
public RiskDecision {
details = Maps.immutable(details);
}
public boolean blocked() {
return !allowAction;
}
}
@@ -0,0 +1,27 @@
package com.quantai.trader.domain;
import java.util.Map;
public record StageDecision(
boolean pass,
String reason,
String blocker,
Map<String, Object> details
) {
public StageDecision {
details = Maps.immutable(details);
}
public boolean blocked() {
return !pass;
}
public static StageDecision pass(String reason) {
return new StageDecision(true, reason, null, Map.of());
}
public static StageDecision block(String reason, String blocker) {
return new StageDecision(false, reason, blocker, Map.of());
}
}
@@ -0,0 +1,30 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import com.quantai.trader.enums.TraderSide;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderAction(
String runId,
String cycleId,
String actionId,
TraderActionType actionType,
String playbookId,
String playbookVersion,
String symbol,
TraderSide side,
BigDecimal price,
BigDecimal quantity,
Instant actionTime,
String reason,
Map<String, Object> actionContext,
String sendStatus
) {
public TraderAction {
actionContext = Maps.immutable(actionContext);
}
}
@@ -0,0 +1,44 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderFeedbackSource;
import com.quantai.trader.enums.TraderFeedbackType;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderAppFeedback(
String runId,
String cycleId,
String actionId,
TraderFeedbackType feedbackType,
TraderFeedbackSource feedbackSource,
String proxyMethod,
String simulatorVersion,
boolean realFill,
String orderId,
String positionId,
String orderStatus,
Instant appReceivedTime,
Instant exchangeAckTime,
Instant filledTime,
BigDecimal filledPrice,
BigDecimal filledQuantity,
BigDecimal fee,
BigDecimal slippageBps,
String closeReason,
String closeSignalSource,
String exchangeErrorCode,
String platformErrorCode,
Map<String, Object> rawFeedback
) {
public TraderAppFeedback {
rawFeedback = Maps.immutable(rawFeedback);
boolean sourceCanBeReal = feedbackSource == TraderFeedbackSource.PAPER_APP
|| feedbackSource == TraderFeedbackSource.REAL_APP;
if (realFill != sourceCanBeReal) {
throw new IllegalArgumentException("feedback_source and realFill are inconsistent");
}
}
}
@@ -0,0 +1,48 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderErrorCode;
import java.time.Instant;
import java.util.Map;
public record TraderDataSourceManifest(
String sourceId,
String symbol,
String sourceType,
String exchange,
String granularity,
String sourcePath,
String contentHashSha256,
String schemaHashSha256,
Instant dataFrom,
Instant dataTo,
Instant minEventTime,
Instant maxEventTime,
String timezone,
Long rowCount,
Map<String, Object> missingSummary,
String qualityStatus
) {
public TraderDataSourceManifest {
missingSummary = Maps.immutable(missingSummary);
boolean hasFullHash = contentHashSha256 != null && !contentHashSha256.isBlank();
boolean hasSchemaTrace = schemaHashSha256 != null
&& !schemaHashSha256.isBlank()
&& rowCount != null
&& minEventTime != null
&& maxEventTime != null;
if (sourceId == null || sourceId.isBlank() || sourcePath == null || sourcePath.isBlank()) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "data source id and path are required");
}
if (timezone == null || timezone.isBlank()) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "data source timezone is required");
}
if (!hasFullHash && !hasSchemaTrace) {
throw new TraderException(
TraderErrorCode.TRADER_DATA_SOURCE_MISSING,
"data source requires content hash or schema/row/time lineage"
);
}
}
}
@@ -0,0 +1,37 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderRunMode;
import com.quantai.trader.enums.TraderState;
import java.time.Instant;
public record TraderDecisionCycle(
String runId,
String cycleId,
String snapshotId,
String symbol,
String playbookId,
String playbookVersion,
TraderState state,
Instant cycleTime,
TraderRunMode runMode,
String decisionStatus,
String blocker
) {
public TraderDecisionCycle withState(TraderState nextState, String nextStatus, String nextBlocker) {
return new TraderDecisionCycle(
runId,
cycleId,
snapshotId,
symbol,
playbookId,
playbookVersion,
nextState,
cycleTime,
runMode,
nextStatus,
nextBlocker
);
}
}
@@ -0,0 +1,39 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import java.math.BigDecimal;
public record TraderEntryPlan(
String runId,
String cycleId,
String actionId,
String entryLegId,
String candidateId,
TraderActionType entryAction,
int plannedLegIndex,
int plannedLegCount,
BigDecimal plannedLegRatio,
String sizingMethod,
BigDecimal signalStrengthScore,
BigDecimal executionQualityScore,
BigDecimal riskGateScore,
BigDecimal entryPrice,
BigDecimal invalidPrice,
BigDecimal stopPrice,
BigDecimal targetPrice,
BigDecimal partialTakeProfitPrice,
long maxEntryWaitMs,
long maxHoldMs,
String reason
) {
public boolean completeForEntry() {
return entryPrice != null
&& invalidPrice != null
&& stopPrice != null
&& targetPrice != null
&& maxEntryWaitMs > 0
&& maxHoldMs > 0;
}
}
@@ -0,0 +1,21 @@
package com.quantai.trader.domain;
import java.time.Instant;
import java.util.Map;
public record TraderEvidence(
String runId,
String cycleId,
String evidenceId,
String stage,
boolean pass,
String reason,
String blocker,
Instant evidenceTime,
Map<String, Object> details
) {
public TraderEvidence {
details = Maps.immutable(details);
}
}
@@ -0,0 +1,17 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderErrorCode;
public class TraderException extends RuntimeException {
private final TraderErrorCode errorCode;
public TraderException(TraderErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public TraderErrorCode errorCode() {
return errorCode;
}
}
@@ -0,0 +1,26 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderManagementAction(
String runId,
String cycleId,
String actionId,
String managementActionId,
String positionId,
TraderActionType managementActionType,
Instant actionTime,
BigDecimal beforeRiskBps,
BigDecimal afterRiskBps,
String reason,
Map<String, Object> details
) {
public TraderManagementAction {
details = Maps.immutable(details);
}
}
@@ -0,0 +1,19 @@
package com.quantai.trader.domain;
import java.time.Instant;
import java.util.Map;
public record TraderMarketEvent(
String runId,
String eventId,
String symbol,
Instant eventTime,
String source,
String sourcePath,
Map<String, Object> payload
) {
public TraderMarketEvent {
payload = Maps.immutable(payload);
}
}
@@ -0,0 +1,27 @@
package com.quantai.trader.domain;
import java.time.Instant;
import java.util.Map;
public record TraderMarketSnapshot(
String runId,
String cycleId,
String snapshotId,
String symbol,
Instant snapshotTime,
String featureVersion,
Map<String, Object> contextFeatures,
Map<String, Object> setupFeatures,
Map<String, Object> triggerFeatures,
Map<String, Object> executionFeatures,
Map<String, Object> dataQuality
) {
public TraderMarketSnapshot {
contextFeatures = Maps.immutable(contextFeatures);
setupFeatures = Maps.immutable(setupFeatures);
triggerFeatures = Maps.immutable(triggerFeatures);
executionFeatures = Maps.immutable(executionFeatures);
dataQuality = Maps.immutable(dataQuality);
}
}
@@ -0,0 +1,20 @@
package com.quantai.trader.domain;
import java.time.Instant;
import java.util.Map;
public record TraderModelManifest(
String modelName,
String modelVersion,
String featureVersion,
String labelVersion,
String artifactPath,
Instant trainedAt,
Map<String, Object> metrics,
String status
) {
public TraderModelManifest {
metrics = Maps.immutable(metrics);
}
}
@@ -0,0 +1,20 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderModelOutput(
String modelName,
String modelVersion,
BigDecimal score,
BigDecimal uncertainty,
BigDecimal oodScore,
Instant predictedAt,
Map<String, Object> details
) {
public TraderModelOutput {
details = Maps.immutable(details);
}
}
@@ -0,0 +1,21 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import java.math.BigDecimal;
import java.time.Instant;
public record TraderPositionLeg(
String runId,
String cycleId,
String positionId,
String legId,
TraderActionType actionType,
BigDecimal quantity,
BigDecimal price,
BigDecimal legRatio,
BigDecimal riskDeltaBps,
Instant actionTime,
String reason
) {
}
@@ -0,0 +1,43 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderSide;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderPositionPath(
String runId,
String cycleId,
String actionId,
String positionId,
TraderSide side,
Instant entryTime,
Instant lastEventTime,
BigDecimal entryPrice,
BigDecimal currentPrice,
BigDecimal mfeBps,
BigDecimal maeBps,
Long timeToTargetMs,
Long timeToInvalidMs,
boolean targetBeforeStop,
boolean stagnationTimeoutHit,
boolean proxyOnly,
boolean reduceSeen,
int filledLegCount,
BigDecimal totalPositionRatio,
Map<String, Object> pathSummary
) {
public TraderPositionPath {
pathSummary = Maps.immutable(pathSummary);
}
public boolean opened() {
return positionId != null && filledLegCount > 0;
}
public boolean fullSize() {
return totalPositionRatio != null && totalPositionRatio.compareTo(BigDecimal.ONE) >= 0;
}
}
@@ -0,0 +1,23 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
public record TraderPricePlan(
BigDecimal entryPrice,
BigDecimal invalidPrice,
BigDecimal stopPrice,
BigDecimal targetPrice,
BigDecimal partialTakeProfitPrice,
long maxEntryWaitMs,
long maxHoldMs
) {
public boolean completeForEntry() {
return entryPrice != null
&& invalidPrice != null
&& stopPrice != null
&& targetPrice != null
&& maxEntryWaitMs > 0
&& maxHoldMs > 0;
}
}
@@ -0,0 +1,30 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import java.util.Map;
public record TraderReplayReport(
String runId,
String reportId,
String symbol,
String playbookId,
String playbookVersion,
int candidateEvents,
int monthsCovered,
BigDecimal baseNetReturnBps1x,
BigDecimal leveragedNetReturnBps10x,
BigDecimal holdoutReturnBps10x,
Map<String, Object> strictVsLoose,
List<String> failureRisks,
String conclusion,
String reportPath,
Instant createdAt
) {
public TraderReplayReport {
strictVsLoose = Maps.immutable(strictVsLoose);
failureRisks = failureRisks == null ? List.of() : List.copyOf(failureRisks);
}
}
@@ -0,0 +1,32 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.TraderActionType;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderRiskDecision(
String runId,
String cycleId,
String actionId,
String accountStateId,
TraderActionType actionType,
BigDecimal leverageScreen,
BigDecimal plannedTotalPositionRatio,
BigDecimal maxLossBps,
BigDecimal liquidationBufferBps,
BigDecimal expectedValueBps1x,
BigDecimal expectedValueBps10x,
BigDecimal uncertainty,
BigDecimal oodScore,
boolean allowAction,
String blocker,
Map<String, Object> decision,
Instant createdAt
) {
public TraderRiskDecision {
decision = Maps.immutable(decision);
}
}
@@ -0,0 +1,27 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
public record TraderTrainingSample(
String runId,
String cycleId,
String sampleId,
String actionId,
String positionId,
String featureVersion,
String labelVersion,
Instant sampleTime,
Map<String, Object> features,
Map<String, Object> labels,
BigDecimal netReturnBps1x,
BigDecimal netReturnBps10x,
boolean proxyOnly
) {
public TraderTrainingSample {
features = Maps.immutable(features);
labels = Maps.immutable(labels);
}
}
@@ -0,0 +1,21 @@
package com.quantai.trader.domain;
import java.math.BigDecimal;
import java.util.Map;
public record TriggerDecision(
boolean pass,
BigDecimal signalStrengthScore,
String reason,
String blocker,
Map<String, Object> details
) {
public TriggerDecision {
details = Maps.immutable(details);
}
public boolean blocked() {
return !pass;
}
}
@@ -0,0 +1,22 @@
package com.quantai.trader.domain;
import java.time.Instant;
import java.util.Map;
public record TriggerEvent(
String runId,
String cycleId,
String candidateId,
String triggerId,
Instant triggerTime,
String triggerTimeframe,
String featureVersion,
Map<String, Object> triggerEvidence,
Map<String, Object> markout
) {
public TriggerEvent {
triggerEvidence = Maps.immutable(triggerEvidence);
markout = Maps.immutable(markout);
}
}
@@ -0,0 +1,10 @@
package com.quantai.trader.enums;
public enum ReplayRunStatus {
CREATED,
RUNNING,
CANCEL_REQUESTED,
CANCELLED,
COMPLETED,
FAILED
}
@@ -0,0 +1,13 @@
package com.quantai.trader.enums;
public enum TraderActionType {
WAIT,
OPEN_INITIAL,
OPEN_PLANNED_LEG,
HOLD,
REDUCE,
MOVE_STOP,
CLOSE,
CANCEL,
REQUOTE
}
@@ -0,0 +1,13 @@
package com.quantai.trader.enums;
public enum TraderErrorCode {
TRADER_PLAYBOOK_VERSION_CONFLICT,
TRADER_DATA_SOURCE_MISSING,
TRADER_DATA_QUALITY_FAILED,
TRADER_ENTRY_PLAN_INCOMPLETE,
TRADER_ILLEGAL_ACTION_TRANSITION,
TRADER_PLANNED_LEG_AFTER_REDUCE,
TRADER_RISK_BLOCKED,
TRADER_FEEDBACK_DISABLED,
TRADER_SAMPLE_EXPORT_FAILED
}
@@ -0,0 +1,8 @@
package com.quantai.trader.enums;
public enum TraderFeedbackSource {
MARKET_PROXY,
SHADOW_APP,
PAPER_APP,
REAL_APP
}
@@ -0,0 +1,8 @@
package com.quantai.trader.enums;
public enum TraderFeedbackType {
FILL_EVENT,
CANCEL_EVENT,
CLOSE_EVENT,
REJECT_EVENT
}
@@ -0,0 +1,7 @@
package com.quantai.trader.enums;
public enum TraderPlaybookId {
BREAKOUT_RETEST_CONTINUATION,
SUPPORT_PULLBACK_CONTINUATION,
FALSE_BREAK_RECLAIM
}
@@ -0,0 +1,7 @@
package com.quantai.trader.enums;
public enum TraderRunMode {
REPLAY,
SHADOW,
PAPER
}
@@ -0,0 +1,6 @@
package com.quantai.trader.enums;
public enum TraderSide {
LONG,
SHORT
}
@@ -0,0 +1,18 @@
package com.quantai.trader.enums;
public enum TraderState {
CONTEXT_CHECK,
SETUP_ARMED,
TRIGGER_WAIT,
ENTRY_PLANNED,
ENTRY_SENT_SHADOW,
ENTRY_FILLED_PROXY,
ENTRY_MISSED_PROXY,
PLANNED_LEG_WAIT,
PLANNED_LEG_SENT_SHADOW,
PLANNED_LEG_FILLED_PROXY,
PLANNED_LEG_MISSED_PROXY,
MANAGING,
SAMPLE_EXPORTED,
BLOCKED
}
@@ -0,0 +1,50 @@
package com.quantai.trader.evidence;
import com.quantai.trader.domain.StageDecision;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderEvidence;
import com.quantai.trader.persistence.TraderEvidenceRepository;
import com.quantai.trader.util.Ids;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
public class EvidenceAppender {
private static final Logger log = LoggerFactory.getLogger(EvidenceAppender.class);
private final TraderEvidenceRepository repository;
public EvidenceAppender(TraderEvidenceRepository repository) {
this.repository = repository;
}
public TraderEvidence append(TraderDecisionCycle cycle, String stage, StageDecision decision) {
TraderEvidence evidence = new TraderEvidence(
cycle.runId(),
cycle.cycleId(),
Ids.evidenceId(cycle, stage),
stage,
decision.pass(),
decision.reason(),
decision.blocker(),
cycle.cycleTime(),
decision.details()
);
repository.insert(evidence);
log.info(
"event=trader.evidence runId={} cycleId={} symbol={} playbookId={} playbookVersion={} state={} stage={} pass={} reason={} blocker={}",
cycle.runId(),
cycle.cycleId(),
cycle.symbol(),
cycle.playbookId(),
cycle.playbookVersion(),
cycle.state(),
stage,
decision.pass(),
decision.reason(),
decision.blocker()
);
return evidence;
}
}
@@ -0,0 +1,31 @@
package com.quantai.trader.execution;
import com.quantai.trader.domain.ExecutionDecision;
import com.quantai.trader.domain.TraderEntryPlan;
import com.quantai.trader.domain.TraderMarketSnapshot;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Map;
@Component
public class ExecutionQualityGate {
public ExecutionDecision evaluate(TraderMarketSnapshot snapshot, TraderEntryPlan entryPlan) {
BigDecimal score = entryPlan.executionQualityScore() == null
? new BigDecimal("0.50")
: entryPlan.executionQualityScore();
if (!entryPlan.completeForEntry()) {
return new ExecutionDecision(false, score, "ENTRY_PLAN_INCOMPLETE", "TRADER_ENTRY_PLAN_INCOMPLETE", Map.of());
}
if (score.compareTo(new BigDecimal("0.20")) < 0) {
return new ExecutionDecision(false, score, "EXECUTION_QUALITY_TOO_LOW", "TRADER_RISK_BLOCKED", Map.of(
"executionQualityScore", score
));
}
return new ExecutionDecision(true, score, "EXECUTION_PROXY_PASS", null, Map.of(
"executionQualityScore", score,
"proxyOnly", true
));
}
}
@@ -0,0 +1,113 @@
package com.quantai.trader.execution;
import com.quantai.trader.domain.PlaybookCandidate;
import com.quantai.trader.domain.PositionSizingPlan;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderEntryPlan;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.domain.TraderPositionPath;
import com.quantai.trader.domain.TraderPricePlan;
import com.quantai.trader.domain.TriggerDecision;
import com.quantai.trader.enums.TraderActionType;
import com.quantai.trader.enums.TraderErrorCode;
import com.quantai.trader.risk.TraderPositionSizer;
import com.quantai.trader.util.Ids;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.Optional;
@Component
public class TraderEntryPlanner {
private final TraderPositionSizer positionSizer;
public TraderEntryPlanner(TraderPositionSizer positionSizer) {
this.positionSizer = positionSizer;
}
public TraderEntryPlan planInitialEntry(
TraderDecisionCycle cycle,
PlaybookCandidate candidate,
TriggerDecision trigger
) {
TraderPricePlan pricePlan = candidate.pricePlan();
validatePricePlan(pricePlan);
PositionSizingPlan sizingPlan = positionSizer.sizeInitialPlan(cycle, candidate, trigger, pricePlan);
return new TraderEntryPlan(
cycle.runId(),
cycle.cycleId(),
Ids.actionId(cycle, 1),
Ids.entryLegId(cycle, 0),
candidate.candidateId(),
TraderActionType.OPEN_INITIAL,
0,
sizingPlan.plannedLegCount(),
sizingPlan.initialLegRatio(),
sizingPlan.sizingMethod(),
sizingPlan.signalStrengthScore(),
sizingPlan.executionQualityScore(),
sizingPlan.riskGateScore(),
pricePlan.entryPrice(),
pricePlan.invalidPrice(),
pricePlan.stopPrice(),
pricePlan.targetPrice(),
pricePlan.partialTakeProfitPrice(),
pricePlan.maxEntryWaitMs(),
pricePlan.maxHoldMs(),
"INITIAL_ENTRY_FROM_PLAYBOOK_TRIGGER"
);
}
public Optional<TraderEntryPlan> planNextDeclaredLeg(
TraderDecisionCycle cycle,
PlaybookCandidate candidate,
TraderPositionPath path
) {
if (path == null || !path.opened() || path.reduceSeen() || path.fullSize()) {
return Optional.empty();
}
int nextIndex = path.filledLegCount();
if (nextIndex >= candidate.maxPlannedEntryLegs()) {
return Optional.empty();
}
TraderPricePlan pricePlan = candidate.pricePlan();
validatePricePlan(pricePlan);
PositionSizingPlan sizingPlan = positionSizer.sizeNextPlannedLeg(cycle, candidate, path, pricePlan, nextIndex);
if (sizingPlan.nextLegRatio().compareTo(BigDecimal.ZERO) <= 0) {
return Optional.empty();
}
return Optional.of(new TraderEntryPlan(
cycle.runId(),
cycle.cycleId(),
Ids.actionId(cycle, nextIndex + 1),
Ids.entryLegId(cycle, nextIndex),
candidate.candidateId(),
TraderActionType.OPEN_PLANNED_LEG,
nextIndex,
sizingPlan.plannedLegCount(),
sizingPlan.nextLegRatio(),
sizingPlan.sizingMethod(),
sizingPlan.signalStrengthScore(),
sizingPlan.executionQualityScore(),
sizingPlan.riskGateScore(),
pricePlan.entryPrice(),
pricePlan.invalidPrice(),
pricePlan.stopPrice(),
pricePlan.targetPrice(),
pricePlan.partialTakeProfitPrice(),
pricePlan.maxEntryWaitMs(),
pricePlan.maxHoldMs(),
"DECLARED_PLANNED_ENTRY_LEG"
));
}
private void validatePricePlan(TraderPricePlan pricePlan) {
if (pricePlan == null || !pricePlan.completeForEntry()) {
throw new TraderException(
TraderErrorCode.TRADER_ENTRY_PLAN_INCOMPLETE,
"entry/invalid/stop/target/maxHold are required before an entry action"
);
}
}
}
@@ -0,0 +1,25 @@
package com.quantai.trader.infrastructure.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("trader_evidence")
public class TraderEvidenceEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String runId;
private String cycleId;
private String evidenceId;
private String stage;
private Boolean pass;
private String reason;
private String blocker;
private LocalDateTime evidenceTime;
private String detailsJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,27 @@
package com.quantai.trader.infrastructure.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("trader_playbook_definition")
public class TraderPlaybookDefinitionEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String playbookId;
private String playbookVersion;
private String family;
private String variant;
private String sideMode;
private String sourcePath;
private String definitionHashSha256;
private String definitionJson;
private LocalDateTime loadedAt;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,35 @@
package com.quantai.trader.infrastructure.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("trader_replay_report")
public class TraderReplayReportEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String runId;
private String reportId;
private String symbol;
private String playbookId;
private String playbookVersion;
private Integer candidateEvents;
private Integer monthsCovered;
@TableField("base_net_return_bps_1x")
private BigDecimal baseNetReturnBps1x;
@TableField("leveraged_net_return_bps_10x")
private BigDecimal leveragedNetReturnBps10x;
@TableField("holdout_return_bps_10x")
private BigDecimal holdoutReturnBps10x;
private String strictVsLooseJson;
private String failureRisksJson;
private String conclusion;
private String reportPath;
private LocalDateTime createdAt;
}
@@ -0,0 +1,33 @@
package com.quantai.trader.infrastructure.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("trader_replay_run")
public class TraderReplayRunEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String runId;
private String runMode;
private String symbol;
private String playbookId;
private String playbookVersion;
private String playbookDefinitionHash;
private LocalDateTime dataFrom;
private LocalDateTime dataTo;
private String featureVersion;
private String labelVersion;
private String dataSourceManifestJson;
private String status;
private String configJson;
private LocalDateTime startedAt;
private LocalDateTime finishedAt;
private String failureReason;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,36 @@
package com.quantai.trader.infrastructure.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("trader_risk_decision")
public class TraderRiskDecisionEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String runId;
private String cycleId;
private String actionId;
private String accountStateId;
private String actionType;
private BigDecimal leverageScreen;
private BigDecimal plannedTotalPositionRatio;
private BigDecimal maxLossBps;
private BigDecimal liquidationBufferBps;
@TableField("expected_value_bps_1x")
private BigDecimal expectedValueBps1x;
@TableField("expected_value_bps_10x")
private BigDecimal expectedValueBps10x;
private BigDecimal uncertainty;
private BigDecimal oodScore;
private Boolean allowAction;
private String blocker;
private String decisionJson;
private LocalDateTime createdAt;
}
@@ -0,0 +1,33 @@
package com.quantai.trader.infrastructure.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("trader_training_sample")
public class TraderTrainingSampleEntity {
@TableId(type = IdType.AUTO)
private Long id;
private String runId;
private String cycleId;
private String sampleId;
private String actionId;
private String positionId;
private String featureVersion;
private String labelVersion;
private LocalDateTime sampleTime;
private String featuresJson;
private String labelsJson;
@TableField("net_return_bps_1x")
private BigDecimal netReturnBps1x;
@TableField("net_return_bps_10x")
private BigDecimal netReturnBps10x;
private Boolean proxyOnly;
private LocalDateTime createdAt;
}
@@ -0,0 +1,7 @@
package com.quantai.trader.infrastructure.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.quantai.trader.infrastructure.entity.TraderEvidenceEntity;
public interface TraderEvidenceMapper extends BaseMapper<TraderEvidenceEntity> {
}
@@ -0,0 +1,7 @@
package com.quantai.trader.infrastructure.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.quantai.trader.infrastructure.entity.TraderPlaybookDefinitionEntity;
public interface TraderPlaybookDefinitionMapper extends BaseMapper<TraderPlaybookDefinitionEntity> {
}
@@ -0,0 +1,7 @@
package com.quantai.trader.infrastructure.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.quantai.trader.infrastructure.entity.TraderReplayReportEntity;
public interface TraderReplayReportMapper extends BaseMapper<TraderReplayReportEntity> {
}
@@ -0,0 +1,7 @@
package com.quantai.trader.infrastructure.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.quantai.trader.infrastructure.entity.TraderReplayRunEntity;
public interface TraderReplayRunMapper extends BaseMapper<TraderReplayRunEntity> {
}
@@ -0,0 +1,7 @@
package com.quantai.trader.infrastructure.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.quantai.trader.infrastructure.entity.TraderRiskDecisionEntity;
public interface TraderRiskDecisionMapper extends BaseMapper<TraderRiskDecisionEntity> {
}
@@ -0,0 +1,7 @@
package com.quantai.trader.infrastructure.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.quantai.trader.infrastructure.entity.TraderTrainingSampleEntity;
public interface TraderTrainingSampleMapper extends BaseMapper<TraderTrainingSampleEntity> {
}
@@ -0,0 +1,45 @@
package com.quantai.trader.market;
import com.quantai.trader.config.TraderProperties;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.replay.ReplayClockTick;
import com.quantai.trader.state.TraderRuntimeState;
import com.quantai.trader.util.Ids;
import org.springframework.stereotype.Component;
import java.util.Objects;
@Component
public class SnapshotBuilder {
private final TraderProperties properties;
public SnapshotBuilder(TraderProperties properties) {
this.properties = properties;
}
public TraderMarketSnapshot build(ReplayClockTick tick, TraderRuntimeState runtimeState) {
String cycleId = Ids.cycleId(runtimeState.runId(), tick.symbol(), tick.eventTime());
return new TraderMarketSnapshot(
runtimeState.runId(),
cycleId,
Ids.snapshotId(cycleId),
tick.symbol(),
tick.eventTime(),
properties.getFeatureVersion(),
Objects.requireNonNull(tick.contextFeatures(), "contextFeatures is required"),
Objects.requireNonNull(tick.setupFeatures(), "setupFeatures is required"),
Objects.requireNonNull(tick.triggerFeatures(), "triggerFeatures is required"),
Objects.requireNonNull(tick.executionFeatures(), "executionFeatures is required"),
Objects.requireNonNull(tick.dataQuality(), "dataQuality is required")
);
}
public TraderMarketSnapshot buildNextManagementSnapshot(
TraderMarketSnapshot previous,
ReplayClockTick tick,
TraderRuntimeState runtimeState
) {
return build(tick, runtimeState);
}
}
@@ -0,0 +1,9 @@
package com.quantai.trader.model;
import com.quantai.trader.domain.TraderModelManifest;
import com.quantai.trader.domain.TraderModelOutput;
public interface TraderModel<TInput> {
TraderModelOutput predict(TInput input, TraderModelManifest manifest);
}
@@ -0,0 +1,75 @@
package com.quantai.trader.persistence;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.quantai.trader.domain.TraderReplayReport;
import com.quantai.trader.infrastructure.entity.TraderReplayReportEntity;
import com.quantai.trader.infrastructure.mapper.TraderReplayReportMapper;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class MybatisReplayReportRepository implements ReplayReportRepository {
private final TraderReplayReportMapper mapper;
public MybatisReplayReportRepository(TraderReplayReportMapper mapper) {
this.mapper = mapper;
}
@Override
public void insert(TraderReplayReport report) {
mapper.insert(toEntity(report));
}
@Override
public Optional<TraderReplayReport> findByRunId(String runId) {
TraderReplayReportEntity entity = mapper.selectOne(
Wrappers.<TraderReplayReportEntity>lambdaQuery()
.eq(TraderReplayReportEntity::getRunId, runId)
.orderByDesc(TraderReplayReportEntity::getCreatedAt)
.last("limit 1")
);
return Optional.ofNullable(entity).map(this::toDomain);
}
private TraderReplayReportEntity toEntity(TraderReplayReport report) {
TraderReplayReportEntity entity = new TraderReplayReportEntity();
entity.setRunId(report.runId());
entity.setReportId(report.reportId());
entity.setSymbol(report.symbol());
entity.setPlaybookId(report.playbookId());
entity.setPlaybookVersion(report.playbookVersion());
entity.setCandidateEvents(report.candidateEvents());
entity.setMonthsCovered(report.monthsCovered());
entity.setBaseNetReturnBps1x(report.baseNetReturnBps1x());
entity.setLeveragedNetReturnBps10x(report.leveragedNetReturnBps10x());
entity.setHoldoutReturnBps10x(report.holdoutReturnBps10x());
entity.setStrictVsLooseJson(TraderPersistenceCodec.json(report.strictVsLoose()));
entity.setFailureRisksJson(TraderPersistenceCodec.json(report.failureRisks()));
entity.setConclusion(report.conclusion());
entity.setReportPath(report.reportPath());
entity.setCreatedAt(TraderPersistenceCodec.requiredLocal("report.createdAt", report.createdAt()));
return entity;
}
private TraderReplayReport toDomain(TraderReplayReportEntity entity) {
return new TraderReplayReport(
entity.getRunId(),
entity.getReportId(),
entity.getSymbol(),
entity.getPlaybookId(),
entity.getPlaybookVersion(),
entity.getCandidateEvents(),
entity.getMonthsCovered(),
entity.getBaseNetReturnBps1x(),
entity.getLeveragedNetReturnBps10x(),
entity.getHoldoutReturnBps10x(),
TraderPersistenceCodec.map(entity.getStrictVsLooseJson()),
TraderPersistenceCodec.stringList(entity.getFailureRisksJson()),
entity.getConclusion(),
entity.getReportPath(),
TraderPersistenceCodec.instant(entity.getCreatedAt())
);
}
}
@@ -0,0 +1,88 @@
package com.quantai.trader.persistence;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.quantai.trader.enums.ReplayRunStatus;
import com.quantai.trader.infrastructure.entity.TraderReplayRunEntity;
import com.quantai.trader.infrastructure.mapper.TraderReplayRunMapper;
import com.quantai.trader.replay.ReplayRun;
import com.quantai.trader.replay.ReplayRunConfig;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public class MybatisReplayRunRepository implements ReplayRunRepository {
private final TraderReplayRunMapper mapper;
public MybatisReplayRunRepository(TraderReplayRunMapper mapper) {
this.mapper = mapper;
}
@Override
public void insert(ReplayRun run) {
mapper.insert(toEntity(run));
}
@Override
public void update(ReplayRun run) {
int updated = mapper.update(
null,
Wrappers.<TraderReplayRunEntity>lambdaUpdate()
.set(TraderReplayRunEntity::getStatus, run.status().name())
.set(TraderReplayRunEntity::getConfigJson, TraderPersistenceCodec.json(run.config()))
.set(TraderReplayRunEntity::getStartedAt, TraderPersistenceCodec.local(run.startedAt()))
.set(TraderReplayRunEntity::getFinishedAt, TraderPersistenceCodec.local(run.finishedAt()))
.set(TraderReplayRunEntity::getFailureReason, run.failureReason())
.eq(TraderReplayRunEntity::getRunId, run.runId())
);
if (updated == 0) {
throw new IllegalStateException("replay run not found for update: " + run.runId());
}
}
@Override
public Optional<ReplayRun> findByRunId(String runId) {
TraderReplayRunEntity entity = mapper.selectOne(
Wrappers.<TraderReplayRunEntity>lambdaQuery()
.eq(TraderReplayRunEntity::getRunId, runId)
);
return Optional.ofNullable(entity).map(this::toDomain);
}
private TraderReplayRunEntity toEntity(ReplayRun run) {
ReplayRunConfig config = run.config();
TraderReplayRunEntity entity = new TraderReplayRunEntity();
entity.setRunId(run.runId());
entity.setRunMode("REPLAY");
entity.setSymbol(config.symbol());
entity.setPlaybookId(config.playbookId());
entity.setPlaybookVersion(config.playbookVersion());
entity.setPlaybookDefinitionHash(run.playbookDefinitionHash());
entity.setDataFrom(TraderPersistenceCodec.local(config.from()));
entity.setDataTo(TraderPersistenceCodec.local(config.to()));
entity.setFeatureVersion(config.featureVersion());
entity.setLabelVersion(config.labelVersion());
entity.setDataSourceManifestJson(TraderPersistenceCodec.json(config.dataSources()));
entity.setStatus(run.status().name());
entity.setConfigJson(TraderPersistenceCodec.json(config));
entity.setStartedAt(TraderPersistenceCodec.local(run.startedAt()));
entity.setFinishedAt(TraderPersistenceCodec.local(run.finishedAt()));
entity.setFailureReason(run.failureReason());
entity.setCreatedAt(TraderPersistenceCodec.requiredLocal("run.createdAt", run.createdAt()));
return entity;
}
private ReplayRun toDomain(TraderReplayRunEntity entity) {
return new ReplayRun(
entity.getRunId(),
ReplayRunStatus.valueOf(entity.getStatus()),
TraderPersistenceCodec.replayRunConfig(entity.getConfigJson()),
entity.getPlaybookDefinitionHash(),
TraderPersistenceCodec.instant(entity.getCreatedAt()),
TraderPersistenceCodec.instant(entity.getStartedAt()),
TraderPersistenceCodec.instant(entity.getFinishedAt()),
entity.getFailureReason()
);
}
}
@@ -0,0 +1,61 @@
package com.quantai.trader.persistence;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.quantai.trader.domain.TraderEvidence;
import com.quantai.trader.infrastructure.entity.TraderEvidenceEntity;
import com.quantai.trader.infrastructure.mapper.TraderEvidenceMapper;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
@Repository
public class MybatisTraderEvidenceRepository implements TraderEvidenceRepository {
private final TraderEvidenceMapper mapper;
public MybatisTraderEvidenceRepository(TraderEvidenceMapper mapper) {
this.mapper = mapper;
}
@Override
public void insert(TraderEvidence evidence) {
TraderEvidenceEntity entity = new TraderEvidenceEntity();
entity.setRunId(evidence.runId());
entity.setCycleId(evidence.cycleId());
entity.setEvidenceId(evidence.evidenceId());
entity.setStage(evidence.stage());
entity.setPass(evidence.pass());
entity.setReason(evidence.reason());
entity.setBlocker(evidence.blocker());
entity.setEvidenceTime(TraderPersistenceCodec.local(evidence.evidenceTime()));
entity.setDetailsJson(TraderPersistenceCodec.json(evidence.details()));
entity.setCreatedAt(TraderPersistenceCodec.local(Instant.now()));
mapper.insert(entity);
}
@Override
public List<TraderEvidence> findByCycleId(String runId, String cycleId) {
return mapper.selectList(
Wrappers.<TraderEvidenceEntity>lambdaQuery()
.eq(TraderEvidenceEntity::getRunId, runId)
.eq(TraderEvidenceEntity::getCycleId, cycleId)
.orderByAsc(TraderEvidenceEntity::getEvidenceTime)
.orderByAsc(TraderEvidenceEntity::getId)
).stream().map(this::toDomain).toList();
}
private TraderEvidence toDomain(TraderEvidenceEntity entity) {
return new TraderEvidence(
entity.getRunId(),
entity.getCycleId(),
entity.getEvidenceId(),
entity.getStage(),
Boolean.TRUE.equals(entity.getPass()),
entity.getReason(),
entity.getBlocker(),
TraderPersistenceCodec.instant(entity.getEvidenceTime()),
TraderPersistenceCodec.map(entity.getDetailsJson())
);
}
}
@@ -0,0 +1,83 @@
package com.quantai.trader.persistence;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.enums.TraderErrorCode;
import com.quantai.trader.infrastructure.entity.TraderPlaybookDefinitionEntity;
import com.quantai.trader.infrastructure.mapper.TraderPlaybookDefinitionMapper;
import com.quantai.trader.playbook.TraderPlaybookDefinitionSnapshot;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.Objects;
import java.util.Optional;
@Repository
public class MybatisTraderPlaybookDefinitionRepository implements TraderPlaybookDefinitionRepository {
private final TraderPlaybookDefinitionMapper mapper;
public MybatisTraderPlaybookDefinitionRepository(TraderPlaybookDefinitionMapper mapper) {
this.mapper = mapper;
}
@Override
public void insertPlaybookDefinitionIfAbsent(TraderPlaybookDefinitionSnapshot definition) {
Optional<TraderPlaybookDefinitionSnapshot> existing = findPlaybookDefinition(
definition.playbookId(),
definition.playbookVersion()
);
if (existing.isPresent()) {
if (!existing.get().definitionHashSha256().equals(definition.definitionHashSha256())) {
throw new TraderException(
TraderErrorCode.TRADER_PLAYBOOK_VERSION_CONFLICT,
"playbook version already exists with another definition hash"
);
}
return;
}
mapper.insert(toEntity(definition));
}
@Override
public Optional<TraderPlaybookDefinitionSnapshot> findPlaybookDefinition(String playbookId, String playbookVersion) {
TraderPlaybookDefinitionEntity entity = mapper.selectOne(
Wrappers.<TraderPlaybookDefinitionEntity>lambdaQuery()
.eq(TraderPlaybookDefinitionEntity::getPlaybookId, playbookId)
.eq(TraderPlaybookDefinitionEntity::getPlaybookVersion, playbookVersion)
);
return Optional.ofNullable(entity).map(this::toDomain);
}
private TraderPlaybookDefinitionEntity toEntity(TraderPlaybookDefinitionSnapshot definition) {
TraderPlaybookDefinitionEntity entity = new TraderPlaybookDefinitionEntity();
entity.setPlaybookId(definition.playbookId());
entity.setPlaybookVersion(definition.playbookVersion());
entity.setFamily(definition.family());
entity.setVariant(definition.variant());
entity.setSideMode(Objects.requireNonNull(definition.definition(), "playbook definition is required").sideMode());
entity.setSourcePath(definition.sourcePath());
entity.setDefinitionHashSha256(definition.definitionHashSha256());
entity.setDefinitionJson(definition.definitionJson());
entity.setLoadedAt(TraderPersistenceCodec.local(definition.loadedAt()));
entity.setStatus(definition.status());
entity.setCreatedAt(TraderPersistenceCodec.local(Instant.now()));
return entity;
}
private TraderPlaybookDefinitionSnapshot toDomain(TraderPlaybookDefinitionEntity entity) {
String definitionJson = entity.getDefinitionJson();
return new TraderPlaybookDefinitionSnapshot(
entity.getPlaybookId(),
entity.getPlaybookVersion(),
entity.getFamily(),
entity.getVariant(),
entity.getSourcePath(),
entity.getDefinitionHashSha256(),
definitionJson,
TraderPersistenceCodec.instant(entity.getLoadedAt()),
entity.getStatus(),
TraderPersistenceCodec.playbookDefinition(definitionJson)
);
}
}
@@ -0,0 +1,76 @@
package com.quantai.trader.persistence;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.quantai.trader.domain.TraderRiskDecision;
import com.quantai.trader.enums.TraderActionType;
import com.quantai.trader.infrastructure.entity.TraderRiskDecisionEntity;
import com.quantai.trader.infrastructure.mapper.TraderRiskDecisionMapper;
import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
public class MybatisTraderRiskDecisionRepository implements TraderRiskDecisionRepository {
private final TraderRiskDecisionMapper mapper;
public MybatisTraderRiskDecisionRepository(TraderRiskDecisionMapper mapper) {
this.mapper = mapper;
}
@Override
public void insert(TraderRiskDecision decision) {
TraderRiskDecisionEntity entity = new TraderRiskDecisionEntity();
entity.setRunId(decision.runId());
entity.setCycleId(decision.cycleId());
entity.setActionId(decision.actionId());
entity.setAccountStateId(decision.accountStateId());
entity.setActionType(decision.actionType().name());
entity.setLeverageScreen(decision.leverageScreen());
entity.setPlannedTotalPositionRatio(decision.plannedTotalPositionRatio());
entity.setMaxLossBps(decision.maxLossBps());
entity.setLiquidationBufferBps(decision.liquidationBufferBps());
entity.setExpectedValueBps1x(decision.expectedValueBps1x());
entity.setExpectedValueBps10x(decision.expectedValueBps10x());
entity.setUncertainty(decision.uncertainty());
entity.setOodScore(decision.oodScore());
entity.setAllowAction(decision.allowAction());
entity.setBlocker(decision.blocker());
entity.setDecisionJson(TraderPersistenceCodec.json(decision.decision()));
entity.setCreatedAt(TraderPersistenceCodec.requiredLocal("decision.createdAt", decision.createdAt()));
mapper.insert(entity);
}
@Override
public List<TraderRiskDecision> findByCycleId(String runId, String cycleId) {
return mapper.selectList(
Wrappers.<TraderRiskDecisionEntity>lambdaQuery()
.eq(TraderRiskDecisionEntity::getRunId, runId)
.eq(TraderRiskDecisionEntity::getCycleId, cycleId)
.orderByAsc(TraderRiskDecisionEntity::getCreatedAt)
.orderByAsc(TraderRiskDecisionEntity::getId)
).stream().map(this::toDomain).toList();
}
private TraderRiskDecision toDomain(TraderRiskDecisionEntity entity) {
return new TraderRiskDecision(
entity.getRunId(),
entity.getCycleId(),
entity.getActionId(),
entity.getAccountStateId(),
TraderActionType.valueOf(entity.getActionType()),
entity.getLeverageScreen(),
entity.getPlannedTotalPositionRatio(),
entity.getMaxLossBps(),
entity.getLiquidationBufferBps(),
entity.getExpectedValueBps1x(),
entity.getExpectedValueBps10x(),
entity.getUncertainty(),
entity.getOodScore(),
Boolean.TRUE.equals(entity.getAllowAction()),
entity.getBlocker(),
TraderPersistenceCodec.map(entity.getDecisionJson()),
TraderPersistenceCodec.instant(entity.getCreatedAt())
);
}
}
@@ -0,0 +1,68 @@
package com.quantai.trader.persistence;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.quantai.trader.domain.TraderTrainingSample;
import com.quantai.trader.infrastructure.entity.TraderTrainingSampleEntity;
import com.quantai.trader.infrastructure.mapper.TraderTrainingSampleMapper;
import org.springframework.stereotype.Repository;
import java.time.Instant;
import java.util.List;
@Repository
public class MybatisTraderSampleRepository implements TraderSampleRepository {
private final TraderTrainingSampleMapper mapper;
public MybatisTraderSampleRepository(TraderTrainingSampleMapper mapper) {
this.mapper = mapper;
}
@Override
public void insert(TraderTrainingSample sample) {
TraderTrainingSampleEntity entity = new TraderTrainingSampleEntity();
entity.setRunId(sample.runId());
entity.setCycleId(sample.cycleId());
entity.setSampleId(sample.sampleId());
entity.setActionId(sample.actionId());
entity.setPositionId(sample.positionId());
entity.setFeatureVersion(sample.featureVersion());
entity.setLabelVersion(sample.labelVersion());
entity.setSampleTime(TraderPersistenceCodec.local(sample.sampleTime()));
entity.setFeaturesJson(TraderPersistenceCodec.json(sample.features()));
entity.setLabelsJson(TraderPersistenceCodec.json(sample.labels()));
entity.setNetReturnBps1x(sample.netReturnBps1x());
entity.setNetReturnBps10x(sample.netReturnBps10x());
entity.setProxyOnly(sample.proxyOnly());
entity.setCreatedAt(TraderPersistenceCodec.local(Instant.now()));
mapper.insert(entity);
}
@Override
public List<TraderTrainingSample> findByRunId(String runId) {
return mapper.selectList(
Wrappers.<TraderTrainingSampleEntity>lambdaQuery()
.eq(TraderTrainingSampleEntity::getRunId, runId)
.orderByAsc(TraderTrainingSampleEntity::getSampleTime)
.orderByAsc(TraderTrainingSampleEntity::getId)
).stream().map(this::toDomain).toList();
}
private TraderTrainingSample toDomain(TraderTrainingSampleEntity entity) {
return new TraderTrainingSample(
entity.getRunId(),
entity.getCycleId(),
entity.getSampleId(),
entity.getActionId(),
entity.getPositionId(),
entity.getFeatureVersion(),
entity.getLabelVersion(),
TraderPersistenceCodec.instant(entity.getSampleTime()),
TraderPersistenceCodec.map(entity.getFeaturesJson()),
TraderPersistenceCodec.map(entity.getLabelsJson()),
entity.getNetReturnBps1x(),
entity.getNetReturnBps10x(),
Boolean.TRUE.equals(entity.getProxyOnly())
);
}
}
@@ -0,0 +1,12 @@
package com.quantai.trader.persistence;
import com.quantai.trader.domain.TraderReplayReport;
import java.util.Optional;
public interface ReplayReportRepository {
void insert(TraderReplayReport report);
Optional<TraderReplayReport> findByRunId(String runId);
}
@@ -0,0 +1,14 @@
package com.quantai.trader.persistence;
import com.quantai.trader.replay.ReplayRun;
import java.util.Optional;
public interface ReplayRunRepository {
void insert(ReplayRun run);
void update(ReplayRun run);
Optional<ReplayRun> findByRunId(String runId);
}
@@ -0,0 +1,12 @@
package com.quantai.trader.persistence;
import com.quantai.trader.domain.TraderEvidence;
import java.util.List;
public interface TraderEvidenceRepository {
void insert(TraderEvidence evidence);
List<TraderEvidence> findByCycleId(String runId, String cycleId);
}
@@ -0,0 +1,86 @@
package com.quantai.trader.persistence;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.quantai.trader.playbook.TraderPlaybookDefinition;
import com.quantai.trader.replay.ReplayRunConfig;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.Objects;
final class TraderPersistenceCodec {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().findAndRegisterModules();
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {
};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {
};
private TraderPersistenceCodec() {
}
static String json(Object value) {
try {
return OBJECT_MAPPER.writeValueAsString(Objects.requireNonNull(value, "json value is required"));
} catch (Exception ex) {
throw new IllegalStateException("failed to serialize trader persistence json", ex);
}
}
static Map<String, Object> map(String json) {
try {
return OBJECT_MAPPER.readValue(requiredJson(json), MAP_TYPE);
} catch (Exception ex) {
throw new IllegalStateException("failed to deserialize trader persistence json map", ex);
}
}
static List<String> stringList(String json) {
try {
return OBJECT_MAPPER.readValue(requiredJson(json), STRING_LIST_TYPE);
} catch (Exception ex) {
throw new IllegalStateException("failed to deserialize trader persistence json list", ex);
}
}
static ReplayRunConfig replayRunConfig(String json) {
try {
return OBJECT_MAPPER.readValue(requiredJson(json), ReplayRunConfig.class);
} catch (Exception ex) {
throw new IllegalStateException("failed to deserialize replay run config", ex);
}
}
static TraderPlaybookDefinition playbookDefinition(String json) {
try {
return OBJECT_MAPPER.readValue(requiredJson(json), TraderPlaybookDefinition.class);
} catch (Exception ex) {
throw new IllegalStateException("failed to deserialize playbook definition", ex);
}
}
static LocalDateTime local(Instant value) {
return value == null ? null : LocalDateTime.ofInstant(value, ZoneOffset.UTC);
}
static Instant instant(LocalDateTime value) {
return value == null ? null : value.toInstant(ZoneOffset.UTC);
}
static LocalDateTime requiredLocal(String fieldName, Instant value) {
return LocalDateTime.ofInstant(Objects.requireNonNull(value, fieldName + " is required"), ZoneOffset.UTC);
}
private static String requiredJson(String json) {
Objects.requireNonNull(json, "json text is required");
String trimmed = json.trim();
if (trimmed.isEmpty()) {
throw new IllegalArgumentException("json text is blank");
}
return trimmed;
}
}
@@ -0,0 +1,12 @@
package com.quantai.trader.persistence;
import com.quantai.trader.playbook.TraderPlaybookDefinitionSnapshot;
import java.util.Optional;
public interface TraderPlaybookDefinitionRepository {
void insertPlaybookDefinitionIfAbsent(TraderPlaybookDefinitionSnapshot definition);
Optional<TraderPlaybookDefinitionSnapshot> findPlaybookDefinition(String playbookId, String playbookVersion);
}
@@ -0,0 +1,12 @@
package com.quantai.trader.persistence;
import com.quantai.trader.domain.TraderRiskDecision;
import java.util.List;
public interface TraderRiskDecisionRepository {
void insert(TraderRiskDecision decision);
List<TraderRiskDecision> findByCycleId(String runId, String cycleId);
}
@@ -0,0 +1,12 @@
package com.quantai.trader.persistence;
import com.quantai.trader.domain.TraderTrainingSample;
import java.util.List;
public interface TraderSampleRepository {
void insert(TraderTrainingSample sample);
List<TraderTrainingSample> findByRunId(String runId);
}
@@ -0,0 +1,19 @@
package com.quantai.trader.playbook;
import com.fasterxml.jackson.annotation.JsonProperty;
public record RuleDefinition(
String name,
String description,
@JsonProperty("max_planned_entry_legs")
Integer maxPlannedEntryLegs,
@JsonProperty("ratio_mode")
String ratioMode,
@JsonProperty("allow_full_initial_entry")
Boolean allowFullInitialEntry,
@JsonProperty("ratio_template_fixed")
Boolean ratioTemplateFixed,
@JsonProperty("description_ext")
String descriptionExt
) {
}
@@ -0,0 +1,140 @@
package com.quantai.trader.playbook;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import com.quantai.trader.config.TraderProperties;
import com.quantai.trader.persistence.TraderPlaybookDefinitionRepository;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Component
public class TraderPlaybookCatalog {
private static final Logger log = LoggerFactory.getLogger(TraderPlaybookCatalog.class);
private final TraderProperties properties;
private final TraderPlaybookValidator validator;
private final TraderPlaybookDefinitionRepository repository;
private final ObjectMapper yamlMapper;
private final ObjectMapper jsonMapper;
private volatile Map<String, TraderPlaybookDefinitionSnapshot> definitions = Map.of();
public TraderPlaybookCatalog(
TraderProperties properties,
TraderPlaybookValidator validator,
TraderPlaybookDefinitionRepository repository
) {
this.properties = properties;
this.validator = validator;
this.repository = repository;
this.yamlMapper = new ObjectMapper(new YAMLFactory());
this.jsonMapper = JsonMapper.builder()
.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true)
.build();
}
@PostConstruct
public void load() {
try {
Resource[] resources = new PathMatchingResourcePatternResolver()
.getResources(properties.getPlaybook().getLocationPattern());
Map<String, TraderPlaybookDefinitionSnapshot> loaded = new LinkedHashMap<>();
for (Resource resource : resources) {
TraderPlaybookDefinitionSnapshot snapshot = loadResource(resource);
repository.insertPlaybookDefinitionIfAbsent(snapshot);
loaded.put(snapshot.playbookId(), snapshot);
log.info(
"event=trader.playbook.loaded playbookId={} playbookVersion={} variant={} definitionHashSha256={} sourcePath={}",
snapshot.playbookId(),
snapshot.playbookVersion(),
snapshot.variant(),
snapshot.definitionHashSha256(),
snapshot.sourcePath()
);
}
this.definitions = Map.copyOf(loaded);
} catch (IOException ex) {
throw new IllegalStateException("failed to load trader playbooks", ex);
}
}
public List<TraderPlaybookDefinitionSnapshot> list() {
return definitions.values().stream()
.sorted(Comparator.comparing(TraderPlaybookDefinitionSnapshot::playbookId))
.toList();
}
public TraderPlaybookDefinitionSnapshot require(String playbookId) {
TraderPlaybookDefinitionSnapshot snapshot = definitions.get(playbookId);
if (snapshot == null) {
throw new TraderPlaybookValidationException("playbook not loaded: " + playbookId);
}
return snapshot;
}
public TraderPlaybookDefinitionSnapshot require(String playbookId, String version) {
TraderPlaybookDefinitionSnapshot snapshot = require(playbookId);
if (!snapshot.playbookVersion().equals(version)) {
throw new TraderPlaybookValidationException("playbook version not loaded: " + playbookId + "/" + version);
}
return snapshot;
}
private TraderPlaybookDefinitionSnapshot loadResource(Resource resource) throws IOException {
try (InputStream inputStream = resource.getInputStream()) {
TraderPlaybookDefinition definition = yamlMapper.readValue(inputStream, TraderPlaybookDefinition.class);
validator.validate(definition);
String definitionJson = normalizedJson(definition);
return new TraderPlaybookDefinitionSnapshot(
definition.playbookId(),
definition.playbookVersion(),
definition.family(),
definition.variant(),
resource.getDescription(),
sha256(definitionJson),
definitionJson,
Instant.now(),
"ACTIVE",
definition
);
}
}
private String normalizedJson(TraderPlaybookDefinition definition) throws IOException {
JsonNode node = jsonMapper.valueToTree(definition);
return jsonMapper.writeValueAsString(node);
}
private static String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] bytes = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder builder = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
builder.append(String.format("%02x", b));
}
return builder.toString();
} catch (NoSuchAlgorithmException ex) {
throw new IllegalStateException("SHA-256 digest is unavailable", ex);
}
}
}
@@ -0,0 +1,54 @@
package com.quantai.trader.playbook;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
import java.util.Map;
public record TraderPlaybookDefinition(
@JsonProperty("playbook_id")
String playbookId,
@JsonProperty("playbook_version")
String playbookVersion,
String family,
String variant,
@JsonProperty("side_mode")
String sideMode,
@JsonProperty("context_timeframes")
List<String> contextTimeframes,
@JsonProperty("setup_timeframes")
List<String> setupTimeframes,
@JsonProperty("trigger_timeframes")
List<String> triggerTimeframes,
@JsonProperty("execution_window")
String executionWindow,
@JsonProperty("management_windows")
List<String> managementWindows,
@JsonProperty("entry_rule")
RuleDefinition entryRule,
@JsonProperty("planned_entry_leg_rule")
RuleDefinition plannedEntryLegRule,
@JsonProperty("invalid_rule")
RuleDefinition invalidRule,
@JsonProperty("stop_rule")
RuleDefinition stopRule,
@JsonProperty("target_rule")
RuleDefinition targetRule,
@JsonProperty("partial_take_profit_rule")
RuleDefinition partialTakeProfitRule,
@JsonProperty("max_hold_rule")
RuleDefinition maxHoldRule,
@JsonProperty("failure_exit_rule")
RuleDefinition failureExitRule,
@JsonProperty("risk_constraints")
Map<String, String> riskConstraints,
@JsonProperty("required_features")
List<String> requiredFeatures,
@JsonProperty("output_actions")
List<String> outputActions
) {
public List<String> outputActions() {
return outputActions == null ? List.of() : List.copyOf(outputActions);
}
}
@@ -0,0 +1,17 @@
package com.quantai.trader.playbook;
import java.time.Instant;
public record TraderPlaybookDefinitionSnapshot(
String playbookId,
String playbookVersion,
String family,
String variant,
String sourcePath,
String definitionHashSha256,
String definitionJson,
Instant loadedAt,
String status,
TraderPlaybookDefinition definition
) {
}
@@ -0,0 +1,8 @@
package com.quantai.trader.playbook;
public class TraderPlaybookValidationException extends RuntimeException {
public TraderPlaybookValidationException(String message) {
super(message);
}
}
@@ -0,0 +1,88 @@
package com.quantai.trader.playbook;
import com.quantai.trader.enums.TraderActionType;
import com.quantai.trader.enums.TraderPlaybookId;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Set;
@Component
public class TraderPlaybookValidator {
private static final Set<String> REQUIRED_FEATURES = Set.of("candles", "trades", "level_1");
public void validate(TraderPlaybookDefinition definition) {
requireText(definition.playbookId(), "playbook_id is required");
requireText(definition.playbookVersion(), "playbook_version is required");
requireEnum(definition.playbookId());
requireRule(definition.entryRule(), "entry_rule is required");
requireRule(definition.plannedEntryLegRule(), "planned_entry_leg_rule is required");
requireRule(definition.invalidRule(), "invalid_rule is required");
requireRule(definition.stopRule(), "stop_rule is required");
requireRule(definition.targetRule(), "target_rule is required");
requireRule(definition.maxHoldRule(), "max_hold_rule is required");
requireOutputActions(definition.outputActions());
requirePlannedLegBoundary(definition);
requireRequiredFeatures(definition.requiredFeatures());
}
private void requireEnum(String playbookId) {
try {
TraderPlaybookId.valueOf(playbookId);
} catch (IllegalArgumentException ex) {
throw new TraderPlaybookValidationException("unknown playbook_id: " + playbookId);
}
}
private void requireOutputActions(List<String> outputActions) {
if (outputActions == null || outputActions.isEmpty()) {
throw new TraderPlaybookValidationException("output_actions is required");
}
if (outputActions.contains("SCALE_IN")) {
throw new TraderPlaybookValidationException("SCALE_IN is not a P0 action");
}
for (String action : outputActions) {
try {
TraderActionType.valueOf(action);
} catch (IllegalArgumentException ex) {
throw new TraderPlaybookValidationException("unsupported output action: " + action);
}
}
}
private void requirePlannedLegBoundary(TraderPlaybookDefinition definition) {
if (definition.outputActions().contains(TraderActionType.OPEN_PLANNED_LEG.name())
&& definition.plannedEntryLegRule() == null) {
throw new TraderPlaybookValidationException("OPEN_PLANNED_LEG requires planned_entry_leg_rule");
}
RuleDefinition rule = definition.plannedEntryLegRule();
if (rule != null) {
int maxLegs = rule.maxPlannedEntryLegs() == null ? 0 : rule.maxPlannedEntryLegs();
if (maxLegs < 1 || maxLegs > 3) {
throw new TraderPlaybookValidationException("max_planned_entry_legs must be between 1 and 3");
}
if (Boolean.TRUE.equals(rule.ratioTemplateFixed())) {
throw new TraderPlaybookValidationException("fixed leg ratio templates are not allowed in P0");
}
}
}
private void requireRequiredFeatures(List<String> requiredFeatures) {
if (requiredFeatures == null || !requiredFeatures.containsAll(REQUIRED_FEATURES)) {
throw new TraderPlaybookValidationException("required_features must include candles, trades, and level_1");
}
}
private void requireRule(RuleDefinition rule, String message) {
if (rule == null || rule.name() == null || rule.name().isBlank()) {
throw new TraderPlaybookValidationException(message);
}
}
private void requireText(String value, String message) {
if (value == null || value.isBlank()) {
throw new TraderPlaybookValidationException(message);
}
}
}
@@ -0,0 +1,247 @@
package com.quantai.trader.position;
import com.quantai.trader.domain.TraderAction;
import com.quantai.trader.domain.TraderDecisionCycle;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.domain.TraderMarketSnapshot;
import com.quantai.trader.domain.TraderPositionPath;
import com.quantai.trader.enums.TraderActionType;
import com.quantai.trader.enums.TraderErrorCode;
import com.quantai.trader.util.Ids;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class TraderPositionManager {
private final Map<String, TraderPositionPath> pathsByPosition = new ConcurrentHashMap<>();
public TraderPositionPath simulateOrUpdate(
TraderDecisionCycle cycle,
TraderAction action,
TraderMarketSnapshot snapshot
) {
return switch (action.actionType()) {
case OPEN_INITIAL -> openInitialProxy(cycle, action, snapshot);
case OPEN_PLANNED_LEG -> appendPlannedLegProxy(cycle, action, snapshot);
case HOLD, MOVE_STOP, CANCEL, REQUOTE -> updatePathOnly(cycle, action, snapshot);
case REDUCE -> reducePosition(cycle, action, snapshot);
case CLOSE -> closePosition(cycle, action, snapshot);
case WAIT -> noPositionChange(cycle, action, snapshot);
};
}
private TraderPositionPath openInitialProxy(TraderDecisionCycle cycle, TraderAction action, TraderMarketSnapshot snapshot) {
BigDecimal ratio = contextDecimal(action, "plannedLegRatio");
String positionId = Ids.positionId(cycle.runId(), cycle.symbol(), action.side().name(), 1);
TraderPositionPath path = new TraderPositionPath(
cycle.runId(),
cycle.cycleId(),
action.actionId(),
positionId,
action.side(),
action.actionTime(),
snapshot.snapshotTime(),
action.price(),
currentPrice(action, snapshot),
BigDecimal.ZERO,
BigDecimal.ZERO,
null,
null,
false,
false,
true,
false,
1,
ratio,
Map.of("proxyFill", true, "lastAction", action.actionType().name())
);
pathsByPosition.put(positionId, path);
return path;
}
private TraderPositionPath appendPlannedLegProxy(TraderDecisionCycle cycle, TraderAction action, TraderMarketSnapshot snapshot) {
String positionId = String.valueOf(action.actionContext().get("positionId"));
TraderPositionPath existing = pathsByPosition.get(positionId);
if (existing == null) {
throw new TraderException(TraderErrorCode.TRADER_ILLEGAL_ACTION_TRANSITION, "planned leg position path not found: " + positionId);
}
if (existing.reduceSeen()) {
throw new TraderException(TraderErrorCode.TRADER_PLANNED_LEG_AFTER_REDUCE, "planned leg cannot follow reduce");
}
BigDecimal ratio = contextDecimal(action, "plannedLegRatio");
TraderPositionPath updated = new TraderPositionPath(
existing.runId(),
cycle.cycleId(),
action.actionId(),
existing.positionId(),
existing.side(),
existing.entryTime(),
snapshot.snapshotTime(),
existing.entryPrice(),
currentPrice(action, snapshot),
existing.mfeBps(),
existing.maeBps(),
existing.timeToTargetMs(),
existing.timeToInvalidMs(),
existing.targetBeforeStop(),
existing.stagnationTimeoutHit(),
true,
false,
existing.filledLegCount() + 1,
existing.totalPositionRatio().add(ratio),
Map.of("proxyFill", true, "lastAction", action.actionType().name())
);
pathsByPosition.put(positionId, updated);
return updated;
}
private TraderPositionPath updatePathOnly(TraderDecisionCycle cycle, TraderAction action, TraderMarketSnapshot snapshot) {
String positionId = String.valueOf(action.actionContext().get("positionId"));
TraderPositionPath existing = pathsByPosition.get(positionId);
if (existing == null) {
throw new TraderException(TraderErrorCode.TRADER_ILLEGAL_ACTION_TRANSITION, "position path not found: " + positionId);
}
TraderPositionPath updated = new TraderPositionPath(
existing.runId(),
cycle.cycleId(),
action.actionId(),
existing.positionId(),
existing.side(),
existing.entryTime(),
snapshot.snapshotTime(),
existing.entryPrice(),
currentPrice(action, snapshot),
existing.mfeBps(),
existing.maeBps(),
existing.timeToTargetMs(),
existing.timeToInvalidMs(),
existing.targetBeforeStop(),
existing.stagnationTimeoutHit(),
true,
existing.reduceSeen(),
existing.filledLegCount(),
existing.totalPositionRatio(),
Map.of("proxyFill", true, "lastAction", action.actionType().name())
);
pathsByPosition.put(positionId, updated);
return updated;
}
private TraderPositionPath reducePosition(TraderDecisionCycle cycle, TraderAction action, TraderMarketSnapshot snapshot) {
TraderPositionPath updated = updatePathOnly(cycle, action, snapshot);
if (!updated.opened()) {
return updated;
}
TraderPositionPath reduced = new TraderPositionPath(
updated.runId(),
updated.cycleId(),
updated.actionId(),
updated.positionId(),
updated.side(),
updated.entryTime(),
updated.lastEventTime(),
updated.entryPrice(),
updated.currentPrice(),
updated.mfeBps(),
updated.maeBps(),
updated.timeToTargetMs(),
updated.timeToInvalidMs(),
updated.targetBeforeStop(),
updated.stagnationTimeoutHit(),
true,
true,
updated.filledLegCount(),
updated.totalPositionRatio().divide(BigDecimal.valueOf(2)),
Map.of("proxyFill", true, "lastAction", TraderActionType.REDUCE.name())
);
pathsByPosition.put(reduced.positionId(), reduced);
return reduced;
}
private TraderPositionPath closePosition(TraderDecisionCycle cycle, TraderAction action, TraderMarketSnapshot snapshot) {
TraderPositionPath updated = updatePathOnly(cycle, action, snapshot);
if (!updated.opened()) {
return updated;
}
TraderPositionPath closed = new TraderPositionPath(
updated.runId(),
updated.cycleId(),
updated.actionId(),
updated.positionId(),
updated.side(),
updated.entryTime(),
updated.lastEventTime(),
updated.entryPrice(),
updated.currentPrice(),
updated.mfeBps(),
updated.maeBps(),
updated.timeToTargetMs(),
updated.timeToInvalidMs(),
true,
false,
true,
updated.reduceSeen(),
updated.filledLegCount(),
BigDecimal.ZERO,
Map.of("proxyFill", true, "lastAction", TraderActionType.CLOSE.name())
);
pathsByPosition.put(closed.positionId(), closed);
return closed;
}
private TraderPositionPath noPositionChange(TraderDecisionCycle cycle, TraderAction action, TraderMarketSnapshot snapshot) {
Instant now = snapshot.snapshotTime();
return new TraderPositionPath(
cycle.runId(),
cycle.cycleId(),
action.actionId(),
null,
action.side(),
now,
now,
action.price(),
action.price(),
BigDecimal.ZERO,
BigDecimal.ZERO,
null,
null,
false,
false,
true,
false,
0,
BigDecimal.ZERO,
Map.of("lastAction", action.actionType().name())
);
}
private BigDecimal currentPrice(TraderAction action, TraderMarketSnapshot snapshot) {
Object lastPrice = snapshot.executionFeatures().get("lastPrice");
if (lastPrice instanceof Number number) {
return BigDecimal.valueOf(number.doubleValue());
}
if (lastPrice instanceof String text && !text.isBlank()) {
return new BigDecimal(text);
}
return action.price();
}
private BigDecimal contextDecimal(TraderAction action, String key) {
Object value = action.actionContext().get(key);
if (value instanceof BigDecimal decimal) {
return decimal;
}
if (value instanceof Number number) {
return BigDecimal.valueOf(number.doubleValue());
}
if (value instanceof String text && !text.isBlank()) {
return new BigDecimal(text);
}
throw new TraderException(TraderErrorCode.TRADER_ENTRY_PLAN_INCOMPLETE, "missing action context decimal: " + key);
}
}
@@ -0,0 +1,22 @@
package com.quantai.trader.replay;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.Map;
public record DataSourceSpec(
@JsonProperty("sourceId")
String sourceId,
String path,
@JsonProperty("hashSha256")
String hashSha256,
@JsonProperty("schemaHashSha256")
String schemaHashSha256,
Long rowCount,
Instant minEventTime,
Instant maxEventTime,
String timezone,
Map<String, Object> missingSummary
) {
}
@@ -0,0 +1,106 @@
package com.quantai.trader.replay;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.enums.TraderErrorCode;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
@Component
public class JsonlReplayMarketEventReader implements ReplayMarketEventReader {
private final ObjectMapper objectMapper;
public JsonlReplayMarketEventReader() {
this.objectMapper = new ObjectMapper().findAndRegisterModules();
}
@Override
public void validateReadable(ReplayRunConfig config) {
DataSourceSpec source = selectReplaySource(config);
if (source.path() == null || source.path().isBlank()) {
throw new TraderException(
TraderErrorCode.TRADER_DATA_SOURCE_MISSING,
"replay source path is required"
);
}
Path path = Path.of(source.path());
if (!Files.isRegularFile(path) || !Files.isReadable(path)) {
throw new TraderException(
TraderErrorCode.TRADER_DATA_SOURCE_MISSING,
"replay source is not readable: " + source.path()
);
}
}
@Override
public List<ReplayClockTick> readTicks(ReplayRunConfig config) {
DataSourceSpec source = selectReplaySource(config);
Path path = Path.of(source.path());
validateReadable(config);
try (var lines = Files.lines(path)) {
List<ReplayClockTick> ticks = lines
.map(String::trim)
.filter(line -> !line.isEmpty())
.map(line -> parseLine(config, line))
.sorted(Comparator.comparing(ReplayClockTick::eventTime))
.toList();
if (ticks.isEmpty()) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "replay source produced no ticks");
}
return ticks;
} catch (IOException ex) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "failed to read replay source: " + ex.getMessage());
}
}
private ReplayClockTick parseLine(ReplayRunConfig config, String line) {
try {
ReplayTickFixture fixture = objectMapper.readValue(line, ReplayTickFixture.class);
if (fixture.eventTime() == null) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "replay tick eventTime is required");
}
Instant eventTime = Instant.parse(fixture.eventTime());
return new ReplayClockTick(
config.runId(),
config.symbol(),
eventTime,
fixture.contextFeatures(),
fixture.setupFeatures(),
fixture.triggerFeatures(),
fixture.executionFeatures(),
fixture.dataQuality()
);
} catch (IOException ex) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "invalid replay tick json: " + ex.getMessage());
}
}
private DataSourceSpec selectReplaySource(ReplayRunConfig config) {
DataSourceSpec explicit = config.dataSources().get("ticks");
if (explicit != null) {
return explicit;
}
throw new TraderException(
TraderErrorCode.TRADER_DATA_SOURCE_MISSING,
"P0 replay requires dataSources.ticks"
);
}
public record ReplayTickFixture(
String eventTime,
Map<String, Object> contextFeatures,
Map<String, Object> setupFeatures,
Map<String, Object> triggerFeatures,
Map<String, Object> executionFeatures,
Map<String, Object> dataQuality
) {
}
}
@@ -0,0 +1,16 @@
package com.quantai.trader.replay;
import java.time.Instant;
import java.util.Map;
public record ReplayClockTick(
String runId,
String symbol,
Instant eventTime,
Map<String, Object> contextFeatures,
Map<String, Object> setupFeatures,
Map<String, Object> triggerFeatures,
Map<String, Object> executionFeatures,
Map<String, Object> dataQuality
) {
}
@@ -0,0 +1,10 @@
package com.quantai.trader.replay;
import java.util.List;
public interface ReplayMarketEventReader {
void validateReadable(ReplayRunConfig config);
List<ReplayClockTick> readTicks(ReplayRunConfig config);
}
@@ -0,0 +1,44 @@
package com.quantai.trader.replay;
import com.quantai.trader.enums.ReplayRunStatus;
import java.time.Instant;
public record ReplayRun(
String runId,
ReplayRunStatus status,
ReplayRunConfig config,
String playbookDefinitionHash,
Instant createdAt,
Instant startedAt,
Instant finishedAt,
String failureReason
) {
public ReplayRun withStatus(ReplayRunStatus nextStatus) {
Instant now = Instant.now();
return new ReplayRun(
runId,
nextStatus,
config,
playbookDefinitionHash,
createdAt,
nextStatus == ReplayRunStatus.RUNNING ? now : startedAt,
nextStatus == ReplayRunStatus.COMPLETED || nextStatus == ReplayRunStatus.CANCELLED || nextStatus == ReplayRunStatus.FAILED ? now : finishedAt,
failureReason
);
}
public ReplayRun failed(String reason) {
return new ReplayRun(
runId,
ReplayRunStatus.FAILED,
config,
playbookDefinitionHash,
createdAt,
startedAt,
Instant.now(),
reason
);
}
}
@@ -0,0 +1,34 @@
package com.quantai.trader.replay;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.time.Instant;
import java.util.Map;
public record ReplayRunConfig(
String runId,
String symbol,
String playbookId,
String playbookVersion,
Instant from,
Instant to,
String featureVersion,
String labelVersion,
@JsonProperty("dataSources")
Map<String, DataSourceSpec> dataSources
) {
public ReplayRunConfig withRunId(String nextRunId) {
return new ReplayRunConfig(
nextRunId,
symbol,
playbookId,
playbookVersion,
from,
to,
featureVersion,
labelVersion,
Map.copyOf(dataSources)
);
}
}
@@ -0,0 +1,9 @@
package com.quantai.trader.replay;
import com.quantai.trader.enums.ReplayRunStatus;
public record ReplayRunResponse(
String runId,
ReplayRunStatus status
) {
}
@@ -0,0 +1,215 @@
package com.quantai.trader.replay;
import com.quantai.trader.brain.TraderCycleResult;
import com.quantai.trader.brain.TraderDecisionCycleRunner;
import com.quantai.trader.domain.TraderDataSourceManifest;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.enums.ReplayRunStatus;
import com.quantai.trader.enums.TraderErrorCode;
import com.quantai.trader.enums.TraderRunMode;
import com.quantai.trader.persistence.ReplayRunRepository;
import com.quantai.trader.playbook.TraderPlaybookCatalog;
import com.quantai.trader.playbook.TraderPlaybookDefinitionSnapshot;
import com.quantai.trader.report.ReplayReportWriter;
import com.quantai.trader.state.TraderRuntimeState;
import com.quantai.trader.util.Ids;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Service
public class ReplayRunService {
private static final Logger log = LoggerFactory.getLogger(ReplayRunService.class);
private final TraderPlaybookCatalog catalog;
private final ReplayRunRepository repository;
private final ReplayReportWriter reportWriter;
private final ReplayMarketEventReader eventReader;
private final TraderDecisionCycleRunner cycleRunner;
private final ExecutorService executorService = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "trader-replay-worker");
thread.setDaemon(true);
return thread;
});
public ReplayRunService(
TraderPlaybookCatalog catalog,
ReplayRunRepository repository,
ReplayReportWriter reportWriter,
ReplayMarketEventReader eventReader,
TraderDecisionCycleRunner cycleRunner
) {
this.catalog = catalog;
this.repository = repository;
this.reportWriter = reportWriter;
this.eventReader = eventReader;
this.cycleRunner = cycleRunner;
}
public ReplayRunResponse createRun(ReplayRunConfig request) {
validateRequest(request);
TraderPlaybookDefinitionSnapshot playbook = catalog.require(request.playbookId(), request.playbookVersion());
request.dataSources().forEach((sourceType, spec) -> validateDataSource(request, sourceType, spec));
eventReader.validateReadable(request);
String runId = Ids.runId(Instant.now());
ReplayRunConfig config = request.withRunId(runId);
ReplayRun run = new ReplayRun(
runId,
ReplayRunStatus.CREATED,
config,
playbook.definitionHashSha256(),
Instant.now(),
null,
null,
null
);
repository.insert(run);
log.info(
"event=trader.replay.created runId={} symbol={} playbookId={} playbookVersion={} status={}",
runId,
config.symbol(),
config.playbookId(),
config.playbookVersion(),
ReplayRunStatus.CREATED
);
executorService.submit(() -> execute(run, playbook));
return new ReplayRunResponse(runId, ReplayRunStatus.CREATED);
}
public Optional<ReplayRun> find(String runId) {
return repository.findByRunId(runId);
}
public ReplayRun cancel(String runId) {
ReplayRun run = repository.findByRunId(runId)
.orElseThrow(() -> new IllegalArgumentException("replay run not found: " + runId));
if (run.status() == ReplayRunStatus.COMPLETED
|| run.status() == ReplayRunStatus.CANCELLED
|| run.status() == ReplayRunStatus.FAILED) {
return run;
}
ReplayRun cancelled = run.withStatus(ReplayRunStatus.CANCEL_REQUESTED);
repository.update(cancelled);
log.info("event=trader.replay.cancel_requested runId={} status={}", runId, cancelled.status());
return cancelled;
}
private void execute(ReplayRun run, TraderPlaybookDefinitionSnapshot playbook) {
try {
repository.update(run.withStatus(ReplayRunStatus.RUNNING));
log.info(
"event=trader.replay.start runId={} symbol={} playbookId={} playbookVersion={} status={}",
run.runId(),
run.config().symbol(),
playbook.playbookId(),
playbook.playbookVersion(),
ReplayRunStatus.RUNNING
);
List<ReplayClockTick> ticks = eventReader.readTicks(run.config());
List<TraderCycleResult> results = new ArrayList<>(ticks.size());
TraderRuntimeState runtimeState = new TraderRuntimeState(
run.runId(),
TraderRunMode.REPLAY,
playbook.playbookId(),
playbook.playbookVersion()
);
for (ReplayClockTick tick : ticks) {
ReplayRun current = currentRun(run.runId());
if (current.status() == ReplayRunStatus.CANCEL_REQUESTED) {
repository.update(current.withStatus(ReplayRunStatus.CANCELLED));
log.info("event=trader.replay.cancelled runId={} status={}", run.runId(), ReplayRunStatus.CANCELLED);
return;
}
results.add(cycleRunner.runReplayTick(tick, runtimeState));
}
reportWriter.writeReport(run.config(), playbook, results);
ReplayRun current = currentRun(run.runId());
repository.update(current.withStatus(ReplayRunStatus.COMPLETED));
log.info(
"event=trader.replay.completed runId={} symbol={} playbookId={} playbookVersion={} status={} tickCount={} resultCount={}",
run.runId(),
run.config().symbol(),
playbook.playbookId(),
playbook.playbookVersion(),
ReplayRunStatus.COMPLETED,
ticks.size(),
results.size()
);
} catch (RuntimeException ex) {
log.warn(
"event=trader.replay.failed_detected runId={} symbol={} playbookId={} playbookVersion={} status={} reason={}",
run.runId(),
run.config().symbol(),
playbook.playbookId(),
playbook.playbookVersion(),
ReplayRunStatus.FAILED,
ex.getMessage(),
ex
);
try {
repository.update(run.failed(ex.toString()));
} catch (RuntimeException updateFailure) {
log.error(
"event=trader.replay.failed_status_update_failed runId={} originalReason={} updateReason={}",
run.runId(),
ex.getMessage(),
updateFailure.getMessage(),
updateFailure
);
}
}
}
private void validateRequest(ReplayRunConfig request) {
if (request.symbol() == null || request.symbol().isBlank()
|| request.playbookId() == null || request.playbookId().isBlank()
|| request.playbookVersion() == null || request.playbookVersion().isBlank()
|| request.from() == null
|| request.to() == null
|| request.dataSources() == null
|| request.dataSources().isEmpty()) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "replay request is missing required lineage fields");
}
}
private ReplayRun currentRun(String runId) {
return repository.findByRunId(runId)
.orElseThrow(() -> new IllegalStateException("replay run disappeared: " + runId));
}
private void validateDataSource(ReplayRunConfig request, String sourceType, DataSourceSpec spec) {
if (spec.timezone() == null || spec.timezone().isBlank()) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "data source timezone is required: " + sourceType);
}
if (spec.missingSummary() == null) {
throw new TraderException(TraderErrorCode.TRADER_DATA_SOURCE_MISSING, "data source missingSummary is required: " + sourceType);
}
new TraderDataSourceManifest(
spec.sourceId(),
request.symbol(),
sourceType,
"BINANCE",
sourceType.equals("candles") ? "1m" : "event",
spec.path(),
spec.hashSha256(),
spec.schemaHashSha256(),
request.from(),
request.to(),
spec.minEventTime(),
spec.maxEventTime(),
spec.timezone(),
spec.rowCount(),
spec.missingSummary(),
"P0_ACCEPTED"
);
}
}

Some files were not shown because too many files have changed in this diff Show More