commit 7ff786f658cc0b6fc51faebc5d4f2d7cbf280fc0 Author: Codex Date: Tue Jun 23 22:09:06 2026 +0800 Initial quant trader service baseline diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd6e6c3 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..dba0c9c --- /dev/null +++ b/README.md @@ -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`. diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..814705a --- /dev/null +++ b/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.0.5 + + + + com.quantai + quant-trader-service + 0.0.1-SNAPSHOT + quant-trader-service + Clean P0 rebuild of the Trader-style strategy service. + + + 21 + 3.5.16 + + + + + org.springframework.boot + spring-boot-starter-actuator + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-jdbc + + + com.baomidou + mybatis-plus-spring-boot4-starter + ${mybatis-plus.version} + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.fasterxml.jackson.datatype + jackson-datatype-jsr310 + + + org.springframework.boot + spring-boot-starter-flyway + + + org.flywaydb + flyway-mysql + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-configuration-processor + true + + + org.projectlombok + lombok + true + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/src/main/java/com/quantai/trader/QuantTraderServiceApplication.java b/src/main/java/com/quantai/trader/QuantTraderServiceApplication.java new file mode 100644 index 0000000..437ef3e --- /dev/null +++ b/src/main/java/com/quantai/trader/QuantTraderServiceApplication.java @@ -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); + } +} diff --git a/src/main/java/com/quantai/trader/brain/LifecycleActionValueService.java b/src/main/java/com/quantai/trader/brain/LifecycleActionValueService.java new file mode 100644 index 0000000..3df168d --- /dev/null +++ b/src/main/java/com/quantai/trader/brain/LifecycleActionValueService.java @@ -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)); + } +} diff --git a/src/main/java/com/quantai/trader/brain/PlaybookCandidateEngine.java b/src/main/java/com/quantai/trader/brain/PlaybookCandidateEngine.java new file mode 100644 index 0000000..e07b995 --- /dev/null +++ b/src/main/java/com/quantai/trader/brain/PlaybookCandidateEngine.java @@ -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 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 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 + ); + } +} diff --git a/src/main/java/com/quantai/trader/brain/TraderContextGate.java b/src/main/java/com/quantai/trader/brain/TraderContextGate.java new file mode 100644 index 0000000..c8d4a83 --- /dev/null +++ b/src/main/java/com/quantai/trader/brain/TraderContextGate.java @@ -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"); + } +} diff --git a/src/main/java/com/quantai/trader/brain/TraderCycleResult.java b/src/main/java/com/quantai/trader/brain/TraderCycleResult.java new file mode 100644 index 0000000..3d274a3 --- /dev/null +++ b/src/main/java/com/quantai/trader/brain/TraderCycleResult.java @@ -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 +) { +} diff --git a/src/main/java/com/quantai/trader/brain/TraderDecisionCycleRunner.java b/src/main/java/com/quantai/trader/brain/TraderDecisionCycleRunner.java new file mode 100644 index 0000000..b300595 --- /dev/null +++ b/src/main/java/com/quantai/trader/brain/TraderDecisionCycleRunner.java @@ -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 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 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); + } +} diff --git a/src/main/java/com/quantai/trader/brain/TraderLifecycleResult.java b/src/main/java/com/quantai/trader/brain/TraderLifecycleResult.java new file mode 100644 index 0000000..6fca9c3 --- /dev/null +++ b/src/main/java/com/quantai/trader/brain/TraderLifecycleResult.java @@ -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 +) { +} diff --git a/src/main/java/com/quantai/trader/brain/TriggerMarkoutService.java b/src/main/java/com/quantai/trader/brain/TriggerMarkoutService.java new file mode 100644 index 0000000..a4d45a6 --- /dev/null +++ b/src/main/java/com/quantai/trader/brain/TriggerMarkoutService.java @@ -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; + } +} diff --git a/src/main/java/com/quantai/trader/config/TraderProperties.java b/src/main/java/com/quantai/trader/config/TraderProperties.java new file mode 100644 index 0000000..52e60d6 --- /dev/null +++ b/src/main/java/com/quantai/trader/config/TraderProperties.java @@ -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; + } + } +} diff --git a/src/main/java/com/quantai/trader/controller/TraderApiError.java b/src/main/java/com/quantai/trader/controller/TraderApiError.java new file mode 100644 index 0000000..b778893 --- /dev/null +++ b/src/main/java/com/quantai/trader/controller/TraderApiError.java @@ -0,0 +1,7 @@ +package com.quantai.trader.controller; + +public record TraderApiError( + String code, + String message +) { +} diff --git a/src/main/java/com/quantai/trader/controller/TraderApiExceptionHandler.java b/src/main/java/com/quantai/trader/controller/TraderApiExceptionHandler.java new file mode 100644 index 0000000..26425f8 --- /dev/null +++ b/src/main/java/com/quantai/trader/controller/TraderApiExceptionHandler.java @@ -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 handleTraderException(TraderException ex) { + return ResponseEntity.badRequest().body(new TraderApiError(ex.errorCode().name(), ex.getMessage())); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new TraderApiError("TRADER_NOT_FOUND", ex.getMessage())); + } +} diff --git a/src/main/java/com/quantai/trader/controller/TraderFeedbackController.java b/src/main/java/com/quantai/trader/controller/TraderFeedbackController.java new file mode 100644 index 0000000..a85163d --- /dev/null +++ b/src/main/java/com/quantai/trader/controller/TraderFeedbackController.java @@ -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() + )); + } +} diff --git a/src/main/java/com/quantai/trader/controller/TraderFeedbackRequest.java b/src/main/java/com/quantai/trader/controller/TraderFeedbackRequest.java new file mode 100644 index 0000000..d8ad943 --- /dev/null +++ b/src/main/java/com/quantai/trader/controller/TraderFeedbackRequest.java @@ -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 rawFeedback +) { +} diff --git a/src/main/java/com/quantai/trader/controller/TraderHealthController.java b/src/main/java/com/quantai/trader/controller/TraderHealthController.java new file mode 100644 index 0000000..a5afde1 --- /dev/null +++ b/src/main/java/com/quantai/trader/controller/TraderHealthController.java @@ -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 health() { + return Map.of( + "service", properties.getServiceName(), + "runMode", properties.getRunMode(), + "symbol", properties.getSymbol(), + "playbookCount", catalog.list().size(), + "httpFeedbackEnabled", properties.getIntegration().isHttpFeedbackEnabled() + ); + } +} diff --git a/src/main/java/com/quantai/trader/controller/TraderPlaybookController.java b/src/main/java/com/quantai/trader/controller/TraderPlaybookController.java new file mode 100644 index 0000000..12477e5 --- /dev/null +++ b/src/main/java/com/quantai/trader/controller/TraderPlaybookController.java @@ -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 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 outputActions + ) { + + static PlaybookResponse from(TraderPlaybookDefinitionSnapshot snapshot) { + return new PlaybookResponse( + snapshot.playbookId(), + snapshot.playbookVersion(), + snapshot.family(), + snapshot.variant(), + snapshot.definitionHashSha256(), + snapshot.definition().outputActions() + ); + } + } +} diff --git a/src/main/java/com/quantai/trader/controller/TraderReplayController.java b/src/main/java/com/quantai/trader/controller/TraderReplayController.java new file mode 100644 index 0000000..76653ce --- /dev/null +++ b/src/main/java/com/quantai/trader/controller/TraderReplayController.java @@ -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)); + } +} diff --git a/src/main/java/com/quantai/trader/domain/ExecutionDecision.java b/src/main/java/com/quantai/trader/domain/ExecutionDecision.java new file mode 100644 index 0000000..976a876 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/ExecutionDecision.java @@ -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 details +) { + + public ExecutionDecision { + details = Maps.immutable(details); + } + + public boolean blocked() { + return !pass; + } +} diff --git a/src/main/java/com/quantai/trader/domain/ManagementDecision.java b/src/main/java/com/quantai/trader/domain/ManagementDecision.java new file mode 100644 index 0000000..3799530 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/ManagementDecision.java @@ -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 details +) { + + public ManagementDecision { + details = Maps.immutable(details); + } +} diff --git a/src/main/java/com/quantai/trader/domain/Maps.java b/src/main/java/com/quantai/trader/domain/Maps.java new file mode 100644 index 0000000..d1a79ab --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/Maps.java @@ -0,0 +1,17 @@ +package com.quantai.trader.domain; + +import java.util.LinkedHashMap; +import java.util.Map; + +final class Maps { + + private Maps() { + } + + static Map immutable(Map value) { + if (value == null || value.isEmpty()) { + return Map.of(); + } + return Map.copyOf(new LinkedHashMap<>(value)); + } +} diff --git a/src/main/java/com/quantai/trader/domain/PlaybookCandidate.java b/src/main/java/com/quantai/trader/domain/PlaybookCandidate.java new file mode 100644 index 0000000..7e2f9a5 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/PlaybookCandidate.java @@ -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 setupEvidence +) { + + public PlaybookCandidate { + setupEvidence = Maps.immutable(setupEvidence); + } +} diff --git a/src/main/java/com/quantai/trader/domain/PositionSizingPlan.java b/src/main/java/com/quantai/trader/domain/PositionSizingPlan.java new file mode 100644 index 0000000..22e9e5f --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/PositionSizingPlan.java @@ -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 +) { +} diff --git a/src/main/java/com/quantai/trader/domain/RiskDecision.java b/src/main/java/com/quantai/trader/domain/RiskDecision.java new file mode 100644 index 0000000..5cc9746 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/RiskDecision.java @@ -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 details +) { + + public RiskDecision { + details = Maps.immutable(details); + } + + public boolean blocked() { + return !allowAction; + } +} diff --git a/src/main/java/com/quantai/trader/domain/StageDecision.java b/src/main/java/com/quantai/trader/domain/StageDecision.java new file mode 100644 index 0000000..cfc6bc7 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/StageDecision.java @@ -0,0 +1,27 @@ +package com.quantai.trader.domain; + +import java.util.Map; + +public record StageDecision( + boolean pass, + String reason, + String blocker, + Map 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()); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderAction.java b/src/main/java/com/quantai/trader/domain/TraderAction.java new file mode 100644 index 0000000..82d7a2b --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderAction.java @@ -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 actionContext, + String sendStatus +) { + + public TraderAction { + actionContext = Maps.immutable(actionContext); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderAppFeedback.java b/src/main/java/com/quantai/trader/domain/TraderAppFeedback.java new file mode 100644 index 0000000..cda5a4a --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderAppFeedback.java @@ -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 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"); + } + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderDataSourceManifest.java b/src/main/java/com/quantai/trader/domain/TraderDataSourceManifest.java new file mode 100644 index 0000000..653114b --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderDataSourceManifest.java @@ -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 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" + ); + } + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderDecisionCycle.java b/src/main/java/com/quantai/trader/domain/TraderDecisionCycle.java new file mode 100644 index 0000000..c7d808f --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderDecisionCycle.java @@ -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 + ); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderEntryPlan.java b/src/main/java/com/quantai/trader/domain/TraderEntryPlan.java new file mode 100644 index 0000000..aa5f11e --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderEntryPlan.java @@ -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; + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderEvidence.java b/src/main/java/com/quantai/trader/domain/TraderEvidence.java new file mode 100644 index 0000000..cf5bd2f --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderEvidence.java @@ -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 details +) { + + public TraderEvidence { + details = Maps.immutable(details); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderException.java b/src/main/java/com/quantai/trader/domain/TraderException.java new file mode 100644 index 0000000..2a97bf2 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderException.java @@ -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; + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderManagementAction.java b/src/main/java/com/quantai/trader/domain/TraderManagementAction.java new file mode 100644 index 0000000..2e94ce6 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderManagementAction.java @@ -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 details +) { + + public TraderManagementAction { + details = Maps.immutable(details); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderMarketEvent.java b/src/main/java/com/quantai/trader/domain/TraderMarketEvent.java new file mode 100644 index 0000000..7f19e2b --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderMarketEvent.java @@ -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 payload +) { + + public TraderMarketEvent { + payload = Maps.immutable(payload); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderMarketSnapshot.java b/src/main/java/com/quantai/trader/domain/TraderMarketSnapshot.java new file mode 100644 index 0000000..2ab4c81 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderMarketSnapshot.java @@ -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 contextFeatures, + Map setupFeatures, + Map triggerFeatures, + Map executionFeatures, + Map dataQuality +) { + + public TraderMarketSnapshot { + contextFeatures = Maps.immutable(contextFeatures); + setupFeatures = Maps.immutable(setupFeatures); + triggerFeatures = Maps.immutable(triggerFeatures); + executionFeatures = Maps.immutable(executionFeatures); + dataQuality = Maps.immutable(dataQuality); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderModelManifest.java b/src/main/java/com/quantai/trader/domain/TraderModelManifest.java new file mode 100644 index 0000000..2a464b2 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderModelManifest.java @@ -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 metrics, + String status +) { + + public TraderModelManifest { + metrics = Maps.immutable(metrics); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderModelOutput.java b/src/main/java/com/quantai/trader/domain/TraderModelOutput.java new file mode 100644 index 0000000..00eeabb --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderModelOutput.java @@ -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 details +) { + + public TraderModelOutput { + details = Maps.immutable(details); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderPositionLeg.java b/src/main/java/com/quantai/trader/domain/TraderPositionLeg.java new file mode 100644 index 0000000..cde2c9f --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderPositionLeg.java @@ -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 +) { +} diff --git a/src/main/java/com/quantai/trader/domain/TraderPositionPath.java b/src/main/java/com/quantai/trader/domain/TraderPositionPath.java new file mode 100644 index 0000000..afbf053 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderPositionPath.java @@ -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 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; + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderPricePlan.java b/src/main/java/com/quantai/trader/domain/TraderPricePlan.java new file mode 100644 index 0000000..1210cf0 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderPricePlan.java @@ -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; + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderReplayReport.java b/src/main/java/com/quantai/trader/domain/TraderReplayReport.java new file mode 100644 index 0000000..120bca8 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderReplayReport.java @@ -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 strictVsLoose, + List failureRisks, + String conclusion, + String reportPath, + Instant createdAt +) { + + public TraderReplayReport { + strictVsLoose = Maps.immutable(strictVsLoose); + failureRisks = failureRisks == null ? List.of() : List.copyOf(failureRisks); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderRiskDecision.java b/src/main/java/com/quantai/trader/domain/TraderRiskDecision.java new file mode 100644 index 0000000..503fdb2 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderRiskDecision.java @@ -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 decision, + Instant createdAt +) { + + public TraderRiskDecision { + decision = Maps.immutable(decision); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TraderTrainingSample.java b/src/main/java/com/quantai/trader/domain/TraderTrainingSample.java new file mode 100644 index 0000000..fe5ea90 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TraderTrainingSample.java @@ -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 features, + Map labels, + BigDecimal netReturnBps1x, + BigDecimal netReturnBps10x, + boolean proxyOnly +) { + + public TraderTrainingSample { + features = Maps.immutable(features); + labels = Maps.immutable(labels); + } +} diff --git a/src/main/java/com/quantai/trader/domain/TriggerDecision.java b/src/main/java/com/quantai/trader/domain/TriggerDecision.java new file mode 100644 index 0000000..ee12444 --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TriggerDecision.java @@ -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 details +) { + + public TriggerDecision { + details = Maps.immutable(details); + } + + public boolean blocked() { + return !pass; + } +} diff --git a/src/main/java/com/quantai/trader/domain/TriggerEvent.java b/src/main/java/com/quantai/trader/domain/TriggerEvent.java new file mode 100644 index 0000000..f9a546e --- /dev/null +++ b/src/main/java/com/quantai/trader/domain/TriggerEvent.java @@ -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 triggerEvidence, + Map markout +) { + + public TriggerEvent { + triggerEvidence = Maps.immutable(triggerEvidence); + markout = Maps.immutable(markout); + } +} diff --git a/src/main/java/com/quantai/trader/enums/ReplayRunStatus.java b/src/main/java/com/quantai/trader/enums/ReplayRunStatus.java new file mode 100644 index 0000000..9eef8b5 --- /dev/null +++ b/src/main/java/com/quantai/trader/enums/ReplayRunStatus.java @@ -0,0 +1,10 @@ +package com.quantai.trader.enums; + +public enum ReplayRunStatus { + CREATED, + RUNNING, + CANCEL_REQUESTED, + CANCELLED, + COMPLETED, + FAILED +} diff --git a/src/main/java/com/quantai/trader/enums/TraderActionType.java b/src/main/java/com/quantai/trader/enums/TraderActionType.java new file mode 100644 index 0000000..fa18293 --- /dev/null +++ b/src/main/java/com/quantai/trader/enums/TraderActionType.java @@ -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 +} diff --git a/src/main/java/com/quantai/trader/enums/TraderErrorCode.java b/src/main/java/com/quantai/trader/enums/TraderErrorCode.java new file mode 100644 index 0000000..14709ac --- /dev/null +++ b/src/main/java/com/quantai/trader/enums/TraderErrorCode.java @@ -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 +} diff --git a/src/main/java/com/quantai/trader/enums/TraderFeedbackSource.java b/src/main/java/com/quantai/trader/enums/TraderFeedbackSource.java new file mode 100644 index 0000000..b1a2c2f --- /dev/null +++ b/src/main/java/com/quantai/trader/enums/TraderFeedbackSource.java @@ -0,0 +1,8 @@ +package com.quantai.trader.enums; + +public enum TraderFeedbackSource { + MARKET_PROXY, + SHADOW_APP, + PAPER_APP, + REAL_APP +} diff --git a/src/main/java/com/quantai/trader/enums/TraderFeedbackType.java b/src/main/java/com/quantai/trader/enums/TraderFeedbackType.java new file mode 100644 index 0000000..bf9edbe --- /dev/null +++ b/src/main/java/com/quantai/trader/enums/TraderFeedbackType.java @@ -0,0 +1,8 @@ +package com.quantai.trader.enums; + +public enum TraderFeedbackType { + FILL_EVENT, + CANCEL_EVENT, + CLOSE_EVENT, + REJECT_EVENT +} diff --git a/src/main/java/com/quantai/trader/enums/TraderPlaybookId.java b/src/main/java/com/quantai/trader/enums/TraderPlaybookId.java new file mode 100644 index 0000000..23d127c --- /dev/null +++ b/src/main/java/com/quantai/trader/enums/TraderPlaybookId.java @@ -0,0 +1,7 @@ +package com.quantai.trader.enums; + +public enum TraderPlaybookId { + BREAKOUT_RETEST_CONTINUATION, + SUPPORT_PULLBACK_CONTINUATION, + FALSE_BREAK_RECLAIM +} diff --git a/src/main/java/com/quantai/trader/enums/TraderRunMode.java b/src/main/java/com/quantai/trader/enums/TraderRunMode.java new file mode 100644 index 0000000..31226c8 --- /dev/null +++ b/src/main/java/com/quantai/trader/enums/TraderRunMode.java @@ -0,0 +1,7 @@ +package com.quantai.trader.enums; + +public enum TraderRunMode { + REPLAY, + SHADOW, + PAPER +} diff --git a/src/main/java/com/quantai/trader/enums/TraderSide.java b/src/main/java/com/quantai/trader/enums/TraderSide.java new file mode 100644 index 0000000..c7caa6a --- /dev/null +++ b/src/main/java/com/quantai/trader/enums/TraderSide.java @@ -0,0 +1,6 @@ +package com.quantai.trader.enums; + +public enum TraderSide { + LONG, + SHORT +} diff --git a/src/main/java/com/quantai/trader/enums/TraderState.java b/src/main/java/com/quantai/trader/enums/TraderState.java new file mode 100644 index 0000000..c8d75dc --- /dev/null +++ b/src/main/java/com/quantai/trader/enums/TraderState.java @@ -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 +} diff --git a/src/main/java/com/quantai/trader/evidence/EvidenceAppender.java b/src/main/java/com/quantai/trader/evidence/EvidenceAppender.java new file mode 100644 index 0000000..15daca4 --- /dev/null +++ b/src/main/java/com/quantai/trader/evidence/EvidenceAppender.java @@ -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; + } +} diff --git a/src/main/java/com/quantai/trader/execution/ExecutionQualityGate.java b/src/main/java/com/quantai/trader/execution/ExecutionQualityGate.java new file mode 100644 index 0000000..df316bb --- /dev/null +++ b/src/main/java/com/quantai/trader/execution/ExecutionQualityGate.java @@ -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 + )); + } +} diff --git a/src/main/java/com/quantai/trader/execution/TraderEntryPlanner.java b/src/main/java/com/quantai/trader/execution/TraderEntryPlanner.java new file mode 100644 index 0000000..f3df074 --- /dev/null +++ b/src/main/java/com/quantai/trader/execution/TraderEntryPlanner.java @@ -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 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" + ); + } + } +} diff --git a/src/main/java/com/quantai/trader/infrastructure/entity/TraderEvidenceEntity.java b/src/main/java/com/quantai/trader/infrastructure/entity/TraderEvidenceEntity.java new file mode 100644 index 0000000..c6502ec --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/entity/TraderEvidenceEntity.java @@ -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; +} diff --git a/src/main/java/com/quantai/trader/infrastructure/entity/TraderPlaybookDefinitionEntity.java b/src/main/java/com/quantai/trader/infrastructure/entity/TraderPlaybookDefinitionEntity.java new file mode 100644 index 0000000..8ab81df --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/entity/TraderPlaybookDefinitionEntity.java @@ -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; +} diff --git a/src/main/java/com/quantai/trader/infrastructure/entity/TraderReplayReportEntity.java b/src/main/java/com/quantai/trader/infrastructure/entity/TraderReplayReportEntity.java new file mode 100644 index 0000000..08c78c5 --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/entity/TraderReplayReportEntity.java @@ -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; +} diff --git a/src/main/java/com/quantai/trader/infrastructure/entity/TraderReplayRunEntity.java b/src/main/java/com/quantai/trader/infrastructure/entity/TraderReplayRunEntity.java new file mode 100644 index 0000000..c785450 --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/entity/TraderReplayRunEntity.java @@ -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; +} diff --git a/src/main/java/com/quantai/trader/infrastructure/entity/TraderRiskDecisionEntity.java b/src/main/java/com/quantai/trader/infrastructure/entity/TraderRiskDecisionEntity.java new file mode 100644 index 0000000..ab05247 --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/entity/TraderRiskDecisionEntity.java @@ -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; +} diff --git a/src/main/java/com/quantai/trader/infrastructure/entity/TraderTrainingSampleEntity.java b/src/main/java/com/quantai/trader/infrastructure/entity/TraderTrainingSampleEntity.java new file mode 100644 index 0000000..3f22c96 --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/entity/TraderTrainingSampleEntity.java @@ -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; +} diff --git a/src/main/java/com/quantai/trader/infrastructure/mapper/TraderEvidenceMapper.java b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderEvidenceMapper.java new file mode 100644 index 0000000..6f7958b --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderEvidenceMapper.java @@ -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 { +} diff --git a/src/main/java/com/quantai/trader/infrastructure/mapper/TraderPlaybookDefinitionMapper.java b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderPlaybookDefinitionMapper.java new file mode 100644 index 0000000..f5d2b14 --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderPlaybookDefinitionMapper.java @@ -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 { +} diff --git a/src/main/java/com/quantai/trader/infrastructure/mapper/TraderReplayReportMapper.java b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderReplayReportMapper.java new file mode 100644 index 0000000..17c259d --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderReplayReportMapper.java @@ -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 { +} diff --git a/src/main/java/com/quantai/trader/infrastructure/mapper/TraderReplayRunMapper.java b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderReplayRunMapper.java new file mode 100644 index 0000000..dc97339 --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderReplayRunMapper.java @@ -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 { +} diff --git a/src/main/java/com/quantai/trader/infrastructure/mapper/TraderRiskDecisionMapper.java b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderRiskDecisionMapper.java new file mode 100644 index 0000000..a725ace --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderRiskDecisionMapper.java @@ -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 { +} diff --git a/src/main/java/com/quantai/trader/infrastructure/mapper/TraderTrainingSampleMapper.java b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderTrainingSampleMapper.java new file mode 100644 index 0000000..5f57a7b --- /dev/null +++ b/src/main/java/com/quantai/trader/infrastructure/mapper/TraderTrainingSampleMapper.java @@ -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 { +} diff --git a/src/main/java/com/quantai/trader/market/SnapshotBuilder.java b/src/main/java/com/quantai/trader/market/SnapshotBuilder.java new file mode 100644 index 0000000..ca3d0ff --- /dev/null +++ b/src/main/java/com/quantai/trader/market/SnapshotBuilder.java @@ -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); + } +} diff --git a/src/main/java/com/quantai/trader/model/TraderModel.java b/src/main/java/com/quantai/trader/model/TraderModel.java new file mode 100644 index 0000000..adb9942 --- /dev/null +++ b/src/main/java/com/quantai/trader/model/TraderModel.java @@ -0,0 +1,9 @@ +package com.quantai.trader.model; + +import com.quantai.trader.domain.TraderModelManifest; +import com.quantai.trader.domain.TraderModelOutput; + +public interface TraderModel { + + TraderModelOutput predict(TInput input, TraderModelManifest manifest); +} diff --git a/src/main/java/com/quantai/trader/persistence/MybatisReplayReportRepository.java b/src/main/java/com/quantai/trader/persistence/MybatisReplayReportRepository.java new file mode 100644 index 0000000..0f48489 --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/MybatisReplayReportRepository.java @@ -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 findByRunId(String runId) { + TraderReplayReportEntity entity = mapper.selectOne( + Wrappers.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()) + ); + } +} diff --git a/src/main/java/com/quantai/trader/persistence/MybatisReplayRunRepository.java b/src/main/java/com/quantai/trader/persistence/MybatisReplayRunRepository.java new file mode 100644 index 0000000..7927033 --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/MybatisReplayRunRepository.java @@ -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.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 findByRunId(String runId) { + TraderReplayRunEntity entity = mapper.selectOne( + Wrappers.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() + ); + } +} diff --git a/src/main/java/com/quantai/trader/persistence/MybatisTraderEvidenceRepository.java b/src/main/java/com/quantai/trader/persistence/MybatisTraderEvidenceRepository.java new file mode 100644 index 0000000..fd99334 --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/MybatisTraderEvidenceRepository.java @@ -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 findByCycleId(String runId, String cycleId) { + return mapper.selectList( + Wrappers.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()) + ); + } +} diff --git a/src/main/java/com/quantai/trader/persistence/MybatisTraderPlaybookDefinitionRepository.java b/src/main/java/com/quantai/trader/persistence/MybatisTraderPlaybookDefinitionRepository.java new file mode 100644 index 0000000..4632704 --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/MybatisTraderPlaybookDefinitionRepository.java @@ -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 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 findPlaybookDefinition(String playbookId, String playbookVersion) { + TraderPlaybookDefinitionEntity entity = mapper.selectOne( + Wrappers.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) + ); + } +} diff --git a/src/main/java/com/quantai/trader/persistence/MybatisTraderRiskDecisionRepository.java b/src/main/java/com/quantai/trader/persistence/MybatisTraderRiskDecisionRepository.java new file mode 100644 index 0000000..b8be1f2 --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/MybatisTraderRiskDecisionRepository.java @@ -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 findByCycleId(String runId, String cycleId) { + return mapper.selectList( + Wrappers.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()) + ); + } +} diff --git a/src/main/java/com/quantai/trader/persistence/MybatisTraderSampleRepository.java b/src/main/java/com/quantai/trader/persistence/MybatisTraderSampleRepository.java new file mode 100644 index 0000000..458e913 --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/MybatisTraderSampleRepository.java @@ -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 findByRunId(String runId) { + return mapper.selectList( + Wrappers.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()) + ); + } +} diff --git a/src/main/java/com/quantai/trader/persistence/ReplayReportRepository.java b/src/main/java/com/quantai/trader/persistence/ReplayReportRepository.java new file mode 100644 index 0000000..c8788e1 --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/ReplayReportRepository.java @@ -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 findByRunId(String runId); +} diff --git a/src/main/java/com/quantai/trader/persistence/ReplayRunRepository.java b/src/main/java/com/quantai/trader/persistence/ReplayRunRepository.java new file mode 100644 index 0000000..261a22a --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/ReplayRunRepository.java @@ -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 findByRunId(String runId); +} diff --git a/src/main/java/com/quantai/trader/persistence/TraderEvidenceRepository.java b/src/main/java/com/quantai/trader/persistence/TraderEvidenceRepository.java new file mode 100644 index 0000000..f092047 --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/TraderEvidenceRepository.java @@ -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 findByCycleId(String runId, String cycleId); +} diff --git a/src/main/java/com/quantai/trader/persistence/TraderPersistenceCodec.java b/src/main/java/com/quantai/trader/persistence/TraderPersistenceCodec.java new file mode 100644 index 0000000..d6154ff --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/TraderPersistenceCodec.java @@ -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_TYPE = new TypeReference<>() { + }; + private static final TypeReference> 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 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 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; + } +} diff --git a/src/main/java/com/quantai/trader/persistence/TraderPlaybookDefinitionRepository.java b/src/main/java/com/quantai/trader/persistence/TraderPlaybookDefinitionRepository.java new file mode 100644 index 0000000..385594d --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/TraderPlaybookDefinitionRepository.java @@ -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 findPlaybookDefinition(String playbookId, String playbookVersion); +} diff --git a/src/main/java/com/quantai/trader/persistence/TraderRiskDecisionRepository.java b/src/main/java/com/quantai/trader/persistence/TraderRiskDecisionRepository.java new file mode 100644 index 0000000..d43edb7 --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/TraderRiskDecisionRepository.java @@ -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 findByCycleId(String runId, String cycleId); +} diff --git a/src/main/java/com/quantai/trader/persistence/TraderSampleRepository.java b/src/main/java/com/quantai/trader/persistence/TraderSampleRepository.java new file mode 100644 index 0000000..77c8684 --- /dev/null +++ b/src/main/java/com/quantai/trader/persistence/TraderSampleRepository.java @@ -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 findByRunId(String runId); +} diff --git a/src/main/java/com/quantai/trader/playbook/RuleDefinition.java b/src/main/java/com/quantai/trader/playbook/RuleDefinition.java new file mode 100644 index 0000000..5415af4 --- /dev/null +++ b/src/main/java/com/quantai/trader/playbook/RuleDefinition.java @@ -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 +) { +} diff --git a/src/main/java/com/quantai/trader/playbook/TraderPlaybookCatalog.java b/src/main/java/com/quantai/trader/playbook/TraderPlaybookCatalog.java new file mode 100644 index 0000000..1932d57 --- /dev/null +++ b/src/main/java/com/quantai/trader/playbook/TraderPlaybookCatalog.java @@ -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 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 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 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); + } + } +} diff --git a/src/main/java/com/quantai/trader/playbook/TraderPlaybookDefinition.java b/src/main/java/com/quantai/trader/playbook/TraderPlaybookDefinition.java new file mode 100644 index 0000000..741a176 --- /dev/null +++ b/src/main/java/com/quantai/trader/playbook/TraderPlaybookDefinition.java @@ -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 contextTimeframes, + @JsonProperty("setup_timeframes") + List setupTimeframes, + @JsonProperty("trigger_timeframes") + List triggerTimeframes, + @JsonProperty("execution_window") + String executionWindow, + @JsonProperty("management_windows") + List 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 riskConstraints, + @JsonProperty("required_features") + List requiredFeatures, + @JsonProperty("output_actions") + List outputActions +) { + + public List outputActions() { + return outputActions == null ? List.of() : List.copyOf(outputActions); + } +} diff --git a/src/main/java/com/quantai/trader/playbook/TraderPlaybookDefinitionSnapshot.java b/src/main/java/com/quantai/trader/playbook/TraderPlaybookDefinitionSnapshot.java new file mode 100644 index 0000000..1a75efc --- /dev/null +++ b/src/main/java/com/quantai/trader/playbook/TraderPlaybookDefinitionSnapshot.java @@ -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 +) { +} diff --git a/src/main/java/com/quantai/trader/playbook/TraderPlaybookValidationException.java b/src/main/java/com/quantai/trader/playbook/TraderPlaybookValidationException.java new file mode 100644 index 0000000..77e4dc4 --- /dev/null +++ b/src/main/java/com/quantai/trader/playbook/TraderPlaybookValidationException.java @@ -0,0 +1,8 @@ +package com.quantai.trader.playbook; + +public class TraderPlaybookValidationException extends RuntimeException { + + public TraderPlaybookValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/quantai/trader/playbook/TraderPlaybookValidator.java b/src/main/java/com/quantai/trader/playbook/TraderPlaybookValidator.java new file mode 100644 index 0000000..e9ddebf --- /dev/null +++ b/src/main/java/com/quantai/trader/playbook/TraderPlaybookValidator.java @@ -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 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 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 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); + } + } +} diff --git a/src/main/java/com/quantai/trader/position/TraderPositionManager.java b/src/main/java/com/quantai/trader/position/TraderPositionManager.java new file mode 100644 index 0000000..8ff3f5b --- /dev/null +++ b/src/main/java/com/quantai/trader/position/TraderPositionManager.java @@ -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 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); + } +} diff --git a/src/main/java/com/quantai/trader/replay/DataSourceSpec.java b/src/main/java/com/quantai/trader/replay/DataSourceSpec.java new file mode 100644 index 0000000..8d064cf --- /dev/null +++ b/src/main/java/com/quantai/trader/replay/DataSourceSpec.java @@ -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 missingSummary +) { +} diff --git a/src/main/java/com/quantai/trader/replay/JsonlReplayMarketEventReader.java b/src/main/java/com/quantai/trader/replay/JsonlReplayMarketEventReader.java new file mode 100644 index 0000000..a09a348 --- /dev/null +++ b/src/main/java/com/quantai/trader/replay/JsonlReplayMarketEventReader.java @@ -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 readTicks(ReplayRunConfig config) { + DataSourceSpec source = selectReplaySource(config); + Path path = Path.of(source.path()); + validateReadable(config); + try (var lines = Files.lines(path)) { + List 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 contextFeatures, + Map setupFeatures, + Map triggerFeatures, + Map executionFeatures, + Map dataQuality + ) { + } +} diff --git a/src/main/java/com/quantai/trader/replay/ReplayClockTick.java b/src/main/java/com/quantai/trader/replay/ReplayClockTick.java new file mode 100644 index 0000000..7bcd2c9 --- /dev/null +++ b/src/main/java/com/quantai/trader/replay/ReplayClockTick.java @@ -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 contextFeatures, + Map setupFeatures, + Map triggerFeatures, + Map executionFeatures, + Map dataQuality +) { +} diff --git a/src/main/java/com/quantai/trader/replay/ReplayMarketEventReader.java b/src/main/java/com/quantai/trader/replay/ReplayMarketEventReader.java new file mode 100644 index 0000000..8c317a9 --- /dev/null +++ b/src/main/java/com/quantai/trader/replay/ReplayMarketEventReader.java @@ -0,0 +1,10 @@ +package com.quantai.trader.replay; + +import java.util.List; + +public interface ReplayMarketEventReader { + + void validateReadable(ReplayRunConfig config); + + List readTicks(ReplayRunConfig config); +} diff --git a/src/main/java/com/quantai/trader/replay/ReplayRun.java b/src/main/java/com/quantai/trader/replay/ReplayRun.java new file mode 100644 index 0000000..cb6130c --- /dev/null +++ b/src/main/java/com/quantai/trader/replay/ReplayRun.java @@ -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 + ); + } +} diff --git a/src/main/java/com/quantai/trader/replay/ReplayRunConfig.java b/src/main/java/com/quantai/trader/replay/ReplayRunConfig.java new file mode 100644 index 0000000..39268ed --- /dev/null +++ b/src/main/java/com/quantai/trader/replay/ReplayRunConfig.java @@ -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 dataSources +) { + + public ReplayRunConfig withRunId(String nextRunId) { + return new ReplayRunConfig( + nextRunId, + symbol, + playbookId, + playbookVersion, + from, + to, + featureVersion, + labelVersion, + Map.copyOf(dataSources) + ); + } +} diff --git a/src/main/java/com/quantai/trader/replay/ReplayRunResponse.java b/src/main/java/com/quantai/trader/replay/ReplayRunResponse.java new file mode 100644 index 0000000..b267253 --- /dev/null +++ b/src/main/java/com/quantai/trader/replay/ReplayRunResponse.java @@ -0,0 +1,9 @@ +package com.quantai.trader.replay; + +import com.quantai.trader.enums.ReplayRunStatus; + +public record ReplayRunResponse( + String runId, + ReplayRunStatus status +) { +} diff --git a/src/main/java/com/quantai/trader/replay/ReplayRunService.java b/src/main/java/com/quantai/trader/replay/ReplayRunService.java new file mode 100644 index 0000000..ffc2c33 --- /dev/null +++ b/src/main/java/com/quantai/trader/replay/ReplayRunService.java @@ -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 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 ticks = eventReader.readTicks(run.config()); + List 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" + ); + } +} diff --git a/src/main/java/com/quantai/trader/report/ReplayReportWriter.java b/src/main/java/com/quantai/trader/report/ReplayReportWriter.java new file mode 100644 index 0000000..8f552d8 --- /dev/null +++ b/src/main/java/com/quantai/trader/report/ReplayReportWriter.java @@ -0,0 +1,65 @@ +package com.quantai.trader.report; + +import com.quantai.trader.domain.TraderReplayReport; +import com.quantai.trader.brain.TraderCycleResult; +import com.quantai.trader.persistence.ReplayReportRepository; +import com.quantai.trader.playbook.TraderPlaybookDefinitionSnapshot; +import com.quantai.trader.replay.ReplayRunConfig; +import com.quantai.trader.util.Ids; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; + +@Component +public class ReplayReportWriter { + + private final ReplayReportRepository repository; + + public ReplayReportWriter(ReplayReportRepository repository) { + this.repository = repository; + } + + public TraderReplayReport writeReport( + ReplayRunConfig config, + TraderPlaybookDefinitionSnapshot playbook, + List results + ) { + int actionCount = (int) results.stream().filter(result -> result.action() != null).count(); + int sampleCount = (int) results.stream().filter(result -> result.sample() != null).count(); + int monthsCovered = Math.max(1, (int) ChronoUnit.MONTHS.between( + config.from().atZone(java.time.ZoneOffset.UTC).withDayOfMonth(1), + config.to().atZone(java.time.ZoneOffset.UTC).withDayOfMonth(1) + )); + List failureRisks = actionCount == 0 + ? List.of("no_action_generated", "proxy_only_execution") + : List.of("proxy_only_execution"); + TraderReplayReport report = new TraderReplayReport( + config.runId(), + Ids.reportId(config.runId()), + config.symbol(), + playbook.playbookId(), + playbook.playbookVersion(), + actionCount, + monthsCovered, + BigDecimal.ZERO, + BigDecimal.ZERO, + BigDecimal.ZERO, + Map.of( + "p0ReplayEngine", "jsonl_fixture", + "tickCount", results.size(), + "sampleCount", sampleCount, + "actionCount", actionCount + ), + failureRisks, + "P0_OBSERVE_ONLY", + null, + Instant.now() + ); + repository.insert(report); + return report; + } +} diff --git a/src/main/java/com/quantai/trader/risk/TraderPositionSizer.java b/src/main/java/com/quantai/trader/risk/TraderPositionSizer.java new file mode 100644 index 0000000..08ede02 --- /dev/null +++ b/src/main/java/com/quantai/trader/risk/TraderPositionSizer.java @@ -0,0 +1,136 @@ +package com.quantai.trader.risk; + +import com.quantai.trader.config.TraderProperties; +import com.quantai.trader.domain.PlaybookCandidate; +import com.quantai.trader.domain.PositionSizingPlan; +import com.quantai.trader.domain.TraderDecisionCycle; +import com.quantai.trader.domain.TraderPositionPath; +import com.quantai.trader.domain.TraderPricePlan; +import com.quantai.trader.domain.TriggerDecision; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; + +@Component +public class TraderPositionSizer { + + private static final MathContext MC = new MathContext(10, RoundingMode.HALF_UP); + private final TraderProperties properties; + + public TraderPositionSizer(TraderProperties properties) { + this.properties = properties; + } + + public PositionSizingPlan sizeInitialPlan( + TraderDecisionCycle cycle, + PlaybookCandidate candidate, + TriggerDecision trigger, + TraderPricePlan pricePlan + ) { + BigDecimal signalStrength = clamp(trigger.signalStrengthScore()); + BigDecimal executionQuality = readScore(candidate, "executionQualityScore"); + BigDecimal riskScore = structuralRiskScore(pricePlan); + BigDecimal composite = signalStrength.add(executionQuality, MC).add(riskScore, MC) + .divide(BigDecimal.valueOf(3), MC); + + BigDecimal initialRatio = initialRatio(composite); + int plannedLegCount = plannedLegCount(initialRatio, candidate); + + return new PositionSizingPlan( + plannedLegCount, + initialRatio, + BigDecimal.ZERO, + properties.getSizing().getMethod(), + signalStrength, + executionQuality, + riskScore + ); + } + + public PositionSizingPlan sizeNextPlannedLeg( + TraderDecisionCycle cycle, + PlaybookCandidate candidate, + TraderPositionPath path, + TraderPricePlan pricePlan, + int nextIndex + ) { + int plannedLegCount = Math.min(candidate.maxPlannedEntryLegs(), properties.getSizing().getMaxPlannedEntryLegs()); + BigDecimal current = path.totalPositionRatio() == null ? BigDecimal.ZERO : path.totalPositionRatio(); + BigDecimal remaining = BigDecimal.ONE.subtract(current, MC).max(BigDecimal.ZERO); + int remainingLegs = Math.max(1, plannedLegCount - nextIndex); + BigDecimal nextRatio = capSingle(remaining.divide(BigDecimal.valueOf(remainingLegs), MC)); + BigDecimal riskScore = structuralRiskScore(pricePlan); + return new PositionSizingPlan( + plannedLegCount, + BigDecimal.ZERO, + nextRatio, + properties.getSizing().getMethod(), + BigDecimal.ZERO, + BigDecimal.ZERO, + riskScore + ); + } + + private BigDecimal initialRatio(BigDecimal composite) { + BigDecimal ratio; + if (properties.getSizing().isAllowFullInitialEntry() && composite.compareTo(new BigDecimal("0.85")) >= 0) { + ratio = BigDecimal.ONE; + } else if (composite.compareTo(new BigDecimal("0.55")) >= 0) { + ratio = new BigDecimal("0.60"); + } else { + ratio = new BigDecimal("0.30"); + } + return capSingle(ratio); + } + + private int plannedLegCount(BigDecimal initialRatio, PlaybookCandidate candidate) { + if (initialRatio.compareTo(BigDecimal.ONE) == 0) { + return 1; + } + return Math.min(candidate.maxPlannedEntryLegs(), properties.getSizing().getMaxPlannedEntryLegs()); + } + + private BigDecimal capSingle(BigDecimal ratio) { + BigDecimal maxSingle = properties.getSizing().getMaxSingleLegRatio(); + BigDecimal maxTotal = properties.getSizing().getMaxTotalPositionRatio(); + return ratio.min(maxSingle).min(maxTotal).setScale(8, RoundingMode.HALF_UP); + } + + private BigDecimal structuralRiskScore(TraderPricePlan pricePlan) { + if (pricePlan == null || pricePlan.entryPrice() == null || pricePlan.stopPrice() == null) { + return BigDecimal.ZERO; + } + BigDecimal distance = pricePlan.entryPrice().subtract(pricePlan.stopPrice(), MC).abs(); + if (pricePlan.entryPrice().compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + BigDecimal distanceBps = distance.divide(pricePlan.entryPrice(), MC).multiply(BigDecimal.valueOf(10_000), MC); + if (distanceBps.compareTo(BigDecimal.valueOf(50)) <= 0) { + return new BigDecimal("0.90"); + } + if (distanceBps.compareTo(BigDecimal.valueOf(120)) <= 0) { + return new BigDecimal("0.65"); + } + return new BigDecimal("0.35"); + } + + private BigDecimal readScore(PlaybookCandidate candidate, String key) { + Object value = candidate.setupEvidence().get(key); + if (value instanceof Number number) { + return clamp(BigDecimal.valueOf(number.doubleValue())); + } + if (value instanceof String text && !text.isBlank()) { + return clamp(new BigDecimal(text)); + } + return BigDecimal.ZERO.setScale(8, RoundingMode.HALF_UP); + } + + private BigDecimal clamp(BigDecimal value) { + if (value == null) { + return BigDecimal.ZERO; + } + return value.max(BigDecimal.ZERO).min(BigDecimal.ONE).setScale(8, RoundingMode.HALF_UP); + } +} diff --git a/src/main/java/com/quantai/trader/risk/TraderRiskGate.java b/src/main/java/com/quantai/trader/risk/TraderRiskGate.java new file mode 100644 index 0000000..8bae855 --- /dev/null +++ b/src/main/java/com/quantai/trader/risk/TraderRiskGate.java @@ -0,0 +1,163 @@ +package com.quantai.trader.risk; + +import com.quantai.trader.config.TraderProperties; +import com.quantai.trader.domain.ExecutionDecision; +import com.quantai.trader.domain.ManagementDecision; +import com.quantai.trader.domain.RiskDecision; +import com.quantai.trader.domain.TraderAction; +import com.quantai.trader.domain.TraderDecisionCycle; +import com.quantai.trader.domain.TraderEntryPlan; +import com.quantai.trader.domain.TraderPositionPath; +import com.quantai.trader.domain.TraderRiskDecision; +import com.quantai.trader.enums.TraderErrorCode; +import com.quantai.trader.persistence.TraderRiskDecisionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.Instant; +import java.util.Map; + +@Component +public class TraderRiskGate { + + private static final Logger log = LoggerFactory.getLogger(TraderRiskGate.class); + private static final MathContext MC = new MathContext(10, RoundingMode.HALF_UP); + private final TraderProperties properties; + private final TraderRiskDecisionRepository repository; + + public TraderRiskGate(TraderProperties properties, TraderRiskDecisionRepository repository) { + this.properties = properties; + this.repository = repository; + } + + public RiskDecision evaluate(TraderDecisionCycle cycle, TraderEntryPlan entryPlan, ExecutionDecision execution) { + RiskDecision result = decide(entryPlan, execution); + TraderRiskDecision persisted = new TraderRiskDecision( + cycle.runId(), + cycle.cycleId(), + entryPlan.actionId(), + null, + entryPlan.entryAction(), + properties.getRisk().getLeverageScreen(), + entryPlan.plannedLegRatio(), + maxLossBps(entryPlan), + null, + null, + null, + null, + null, + result.allowAction(), + result.blocker(), + result.details(), + Instant.now() + ); + repository.insert(persisted); + log.info( + "event=trader.risk.decision runId={} cycleId={} symbol={} playbookId={} playbookVersion={} state={} actionId={} actionType={} allowAction={} blocker={}", + cycle.runId(), + cycle.cycleId(), + cycle.symbol(), + cycle.playbookId(), + cycle.playbookVersion(), + cycle.state(), + entryPlan.actionId(), + entryPlan.entryAction(), + result.allowAction(), + result.blocker() + ); + return result; + } + + public RiskDecision evaluateManagement( + TraderDecisionCycle cycle, + TraderAction action, + TraderPositionPath path, + ManagementDecision management + ) { + if (path == null || path.totalPositionRatio() == null) { + throw new IllegalArgumentException("management risk evaluation requires an opened position path"); + } + BigDecimal ratio = path.totalPositionRatio(); + RiskDecision result = new RiskDecision(true, null, BigDecimal.ONE, Map.of( + "positionId", path.positionId(), + "managementReason", management.reason(), + "plannedTotalPositionRatio", ratio + )); + TraderRiskDecision persisted = new TraderRiskDecision( + cycle.runId(), + cycle.cycleId(), + action.actionId(), + null, + action.actionType(), + properties.getRisk().getLeverageScreen(), + ratio, + BigDecimal.ZERO, + null, + null, + null, + null, + null, + true, + null, + result.details(), + Instant.now() + ); + repository.insert(persisted); + log.info( + "event=trader.risk.decision runId={} cycleId={} symbol={} playbookId={} playbookVersion={} state={} actionId={} actionType={} allowAction={} blocker={}", + cycle.runId(), + cycle.cycleId(), + cycle.symbol(), + cycle.playbookId(), + cycle.playbookVersion(), + cycle.state(), + action.actionId(), + action.actionType(), + true, + null + ); + return result; + } + + private RiskDecision decide(TraderEntryPlan entryPlan, ExecutionDecision execution) { + if (!entryPlan.completeForEntry()) { + return block(TraderErrorCode.TRADER_ENTRY_PLAN_INCOMPLETE.name(), Map.of("reason", "entry plan incomplete")); + } + if (execution.blocked()) { + return block(execution.blocker(), Map.of("reason", execution.reason())); + } + if (entryPlan.plannedLegRatio().compareTo(BigDecimal.ZERO) <= 0 + || entryPlan.plannedLegRatio().compareTo(properties.getSizing().getMaxSingleLegRatio()) > 0 + || entryPlan.plannedLegRatio().compareTo(properties.getSizing().getMaxTotalPositionRatio()) > 0) { + return block(TraderErrorCode.TRADER_RISK_BLOCKED.name(), Map.of( + "reason", "planned position ratio exceeds P0 risk constraints", + "plannedLegRatio", entryPlan.plannedLegRatio() + )); + } + return new RiskDecision(true, null, entryPlan.riskGateScore(), Map.of( + "plannedLegRatio", entryPlan.plannedLegRatio(), + "leverageScreen", properties.getRisk().getLeverageScreen(), + "requireOneXNotNegative", properties.getRisk().isRequireOneXNotNegative() + )); + } + + private RiskDecision block(String blocker, Map details) { + return new RiskDecision(false, blocker, BigDecimal.ZERO, details); + } + + private BigDecimal maxLossBps(TraderEntryPlan entryPlan) { + if (entryPlan.entryPrice() == null || entryPlan.stopPrice() == null || entryPlan.entryPrice().compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + return entryPlan.entryPrice() + .subtract(entryPlan.stopPrice(), MC) + .abs() + .divide(entryPlan.entryPrice(), MC) + .multiply(BigDecimal.valueOf(10_000), MC) + .setScale(8, RoundingMode.HALF_UP); + } +} diff --git a/src/main/java/com/quantai/trader/sample/TrainingSampleExporter.java b/src/main/java/com/quantai/trader/sample/TrainingSampleExporter.java new file mode 100644 index 0000000..016f8cc --- /dev/null +++ b/src/main/java/com/quantai/trader/sample/TrainingSampleExporter.java @@ -0,0 +1,91 @@ +package com.quantai.trader.sample; + +import com.quantai.trader.config.TraderProperties; +import com.quantai.trader.domain.PlaybookCandidate; +import com.quantai.trader.domain.TraderAction; +import com.quantai.trader.domain.TraderDecisionCycle; +import com.quantai.trader.domain.TraderPositionPath; +import com.quantai.trader.domain.TraderTrainingSample; +import com.quantai.trader.persistence.TraderSampleRepository; +import com.quantai.trader.util.Ids; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Map; + +@Component +public class TrainingSampleExporter { + + private static final Logger log = LoggerFactory.getLogger(TrainingSampleExporter.class); + private final TraderProperties properties; + private final TraderSampleRepository repository; + + public TrainingSampleExporter(TraderProperties properties, TraderSampleRepository repository) { + this.properties = properties; + this.repository = repository; + } + + public TraderTrainingSample export( + TraderDecisionCycle cycle, + PlaybookCandidate candidate, + TraderAction action, + TraderPositionPath path + ) { + Map features = Map.of( + "playbookId", candidate == null ? cycle.playbookId() : candidate.playbookId(), + "playbookVersion", candidate == null ? cycle.playbookVersion() : candidate.playbookVersion(), + "state", cycle.state().name(), + "actionType", action == null ? "NONE" : action.actionType().name(), + "proxyOnly", true + ); + Map labels = Map.of( + "trigger_acceptance", action != null, + "target_before_stop", path != null && path.targetBeforeStop(), + "stagnation_timeout_hit", path != null && path.stagnationTimeoutHit(), + "best_counterfactual_action", action == null ? "WAIT" : action.actionType().name() + ); + TraderTrainingSample sample = new TraderTrainingSample( + cycle.runId(), + cycle.cycleId(), + Ids.sampleId(cycle, properties.getLabelVersion()), + action == null ? null : action.actionId(), + path == null ? null : path.positionId(), + properties.getFeatureVersion(), + properties.getLabelVersion(), + cycle.cycleTime(), + features, + labels, + BigDecimal.ZERO, + BigDecimal.ZERO, + true + ); + log.info( + "event=trader.sample.export_start runId={} cycleId={} symbol={} playbookId={} playbookVersion={} state={} actionId={} positionId={} sampleId={} proxyOnly=true", + cycle.runId(), + cycle.cycleId(), + cycle.symbol(), + cycle.playbookId(), + cycle.playbookVersion(), + cycle.state(), + sample.actionId(), + sample.positionId(), + sample.sampleId() + ); + repository.insert(sample); + log.info( + "event=trader.sample.exported runId={} cycleId={} symbol={} playbookId={} playbookVersion={} state={} actionId={} positionId={} sampleId={} proxyOnly=true", + cycle.runId(), + cycle.cycleId(), + cycle.symbol(), + cycle.playbookId(), + cycle.playbookVersion(), + cycle.state(), + sample.actionId(), + sample.positionId(), + sample.sampleId() + ); + return sample; + } +} diff --git a/src/main/java/com/quantai/trader/state/TraderDecisionCycleFactory.java b/src/main/java/com/quantai/trader/state/TraderDecisionCycleFactory.java new file mode 100644 index 0000000..f985899 --- /dev/null +++ b/src/main/java/com/quantai/trader/state/TraderDecisionCycleFactory.java @@ -0,0 +1,31 @@ +package com.quantai.trader.state; + +import com.quantai.trader.domain.TraderDecisionCycle; +import com.quantai.trader.domain.TraderMarketSnapshot; +import com.quantai.trader.enums.TraderState; + +public final class TraderDecisionCycleFactory { + + private TraderDecisionCycleFactory() { + } + + public static TraderDecisionCycle create(TraderMarketSnapshot snapshot, TraderRuntimeState runtimeState) { + return new TraderDecisionCycle( + runtimeState.runId(), + snapshot.cycleId(), + snapshot.snapshotId(), + snapshot.symbol(), + runtimeState.playbookId(), + runtimeState.playbookVersion(), + TraderState.CONTEXT_CHECK, + snapshot.snapshotTime(), + runtimeState.runMode(), + "CREATED", + null + ); + } + + public static TraderDecisionCycle next(TraderDecisionCycle cycle, TraderState state) { + return cycle.withState(state, "RUNNING", null); + } +} diff --git a/src/main/java/com/quantai/trader/state/TraderRuntimeState.java b/src/main/java/com/quantai/trader/state/TraderRuntimeState.java new file mode 100644 index 0000000..90f626b --- /dev/null +++ b/src/main/java/com/quantai/trader/state/TraderRuntimeState.java @@ -0,0 +1,11 @@ +package com.quantai.trader.state; + +import com.quantai.trader.enums.TraderRunMode; + +public record TraderRuntimeState( + String runId, + TraderRunMode runMode, + String playbookId, + String playbookVersion +) { +} diff --git a/src/main/java/com/quantai/trader/state/TraderStateMachine.java b/src/main/java/com/quantai/trader/state/TraderStateMachine.java new file mode 100644 index 0000000..d9f7c34 --- /dev/null +++ b/src/main/java/com/quantai/trader/state/TraderStateMachine.java @@ -0,0 +1,182 @@ +package com.quantai.trader.state; + +import com.quantai.trader.domain.PlaybookCandidate; +import com.quantai.trader.domain.TraderAction; +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.enums.TraderActionType; +import com.quantai.trader.enums.TraderErrorCode; +import com.quantai.trader.enums.TraderState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Set; + +@Component +public class TraderStateMachine { + + private static final Logger log = LoggerFactory.getLogger(TraderStateMachine.class); + private static final Set MANAGEMENT_ACTIONS = Set.of( + TraderActionType.HOLD, + TraderActionType.REDUCE, + TraderActionType.MOVE_STOP, + TraderActionType.CLOSE, + TraderActionType.CANCEL, + TraderActionType.REQUOTE + ); + + public TraderAction toInitialEntryAction( + TraderDecisionCycle cycle, + PlaybookCandidate candidate, + TraderEntryPlan entryPlan + ) { + requireState(cycle.state(), TraderState.ENTRY_PLANNED); + requireAction(entryPlan.entryAction(), TraderActionType.OPEN_INITIAL); + requireEntryPlanComplete(entryPlan); + TraderAction action = new TraderAction( + cycle.runId(), + cycle.cycleId(), + entryPlan.actionId(), + TraderActionType.OPEN_INITIAL, + candidate.playbookId(), + candidate.playbookVersion(), + cycle.symbol(), + candidate.side(), + entryPlan.entryPrice(), + null, + cycle.cycleTime(), + entryPlan.reason(), + Map.of( + "entryLegId", entryPlan.entryLegId(), + "plannedLegRatio", entryPlan.plannedLegRatio(), + "plannedLegCount", entryPlan.plannedLegCount(), + "proxyOnly", true + ), + "SHADOW_CREATED" + ); + logActionCreated(cycle, action); + return action; + } + + public TraderAction toPlannedLegAction( + TraderDecisionCycle cycle, + TraderEntryPlan plannedLegPlan, + TraderPositionPath currentPath + ) { + requireState(cycle.state(), TraderState.PLANNED_LEG_WAIT); + requireAction(plannedLegPlan.entryAction(), TraderActionType.OPEN_PLANNED_LEG); + requireEntryPlanComplete(plannedLegPlan); + if (currentPath == null || !currentPath.opened()) { + throw illegal("planned leg requires an opened proxy position"); + } + if (currentPath.reduceSeen()) { + throw new TraderException( + TraderErrorCode.TRADER_PLANNED_LEG_AFTER_REDUCE, + "planned leg cannot be sent after reduce" + ); + } + TraderAction action = new TraderAction( + cycle.runId(), + cycle.cycleId(), + plannedLegPlan.actionId(), + TraderActionType.OPEN_PLANNED_LEG, + cycle.playbookId(), + cycle.playbookVersion(), + cycle.symbol(), + currentPath.side(), + plannedLegPlan.entryPrice(), + null, + cycle.cycleTime(), + plannedLegPlan.reason(), + Map.of( + "entryLegId", plannedLegPlan.entryLegId(), + "positionId", currentPath.positionId(), + "plannedLegRatio", plannedLegPlan.plannedLegRatio(), + "proxyOnly", true + ), + "SHADOW_CREATED" + ); + logActionCreated(cycle, action); + return action; + } + + public TraderAction toManagementAction( + TraderDecisionCycle cycle, + TraderPositionPath path, + TraderActionType actionType + ) { + requireState(cycle.state(), TraderState.MANAGING); + if (!MANAGEMENT_ACTIONS.contains(actionType)) { + throw illegal("illegal management action: " + actionType); + } + if (path == null || !path.opened()) { + throw illegal("management action requires an opened proxy position"); + } + TraderAction action = new TraderAction( + cycle.runId(), + cycle.cycleId(), + cycle.cycleId() + "_" + actionType, + actionType, + cycle.playbookId(), + cycle.playbookVersion(), + cycle.symbol(), + path.side(), + path.currentPrice(), + null, + cycle.cycleTime(), + "MANAGEMENT_" + actionType, + Map.of( + "positionId", path.positionId(), + "proxyOnly", true + ), + "SHADOW_CREATED" + ); + logActionCreated(cycle, action); + return action; + } + + private void requireEntryPlanComplete(TraderEntryPlan entryPlan) { + if (!entryPlan.completeForEntry()) { + throw new TraderException( + TraderErrorCode.TRADER_ENTRY_PLAN_INCOMPLETE, + "entry plan must contain entry/invalid/stop/target/maxHold" + ); + } + } + + private void requireState(TraderState actual, TraderState expected) { + if (actual != expected) { + throw illegal("state " + actual + " cannot output an action that requires " + expected); + } + } + + private void requireAction(TraderActionType actual, TraderActionType expected) { + if (actual != expected) { + throw illegal("entry plan action " + actual + " must be " + expected); + } + } + + private TraderException illegal(String message) { + return new TraderException(TraderErrorCode.TRADER_ILLEGAL_ACTION_TRANSITION, message); + } + + private void logActionCreated(TraderDecisionCycle cycle, TraderAction action) { + log.info( + "event=trader.action.created runId={} cycleId={} symbol={} playbookId={} playbookVersion={} state={} actionId={} actionType={} positionId={} reason={}", + cycle.runId(), + cycle.cycleId(), + cycle.symbol(), + cycle.playbookId(), + cycle.playbookVersion(), + cycle.state(), + action.actionId(), + action.actionType(), + action.actionContext().get("positionId"), + action.reason() + ); + } +} diff --git a/src/main/java/com/quantai/trader/util/Ids.java b/src/main/java/com/quantai/trader/util/Ids.java new file mode 100644 index 0000000..cbb588d --- /dev/null +++ b/src/main/java/com/quantai/trader/util/Ids.java @@ -0,0 +1,66 @@ +package com.quantai.trader.util; + +import com.quantai.trader.domain.TraderDecisionCycle; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.UUID; + +public final class Ids { + + private static final DateTimeFormatter RUN_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss").withZone(ZoneOffset.UTC); + + private Ids() { + } + + public static String runId(Instant now) { + return "trader_run_" + RUN_FORMATTER.format(now) + "_" + suffix(); + } + + public static String cycleId(String runId, String symbol, Instant eventTime) { + return "trader_cycle_" + runId + "_" + symbol + "_" + eventTime.toEpochMilli(); + } + + public static String snapshotId(String cycleId) { + return "trader_snapshot_" + cycleId; + } + + public static String candidateId(TraderDecisionCycle cycle, String playbookId) { + return "trader_candidate_" + cycle.cycleId() + "_" + playbookId; + } + + public static String actionId(TraderDecisionCycle cycle, int sequence) { + return "trader_action_" + cycle.cycleId() + "_" + sequence; + } + + public static String entryLegId(TraderDecisionCycle cycle, int index) { + return "trader_leg_" + actionId(cycle, index + 1) + "_" + index; + } + + public static String positionId(String runId, String symbol, String side, int sequence) { + return "trader_position_" + runId + "_" + symbol + "_" + side + "_" + sequence; + } + + public static String evidenceId(TraderDecisionCycle cycle, String stage) { + return "trader_evidence_" + cycle.cycleId() + "_" + slug(stage) + "_" + suffix(); + } + + public static String sampleId(TraderDecisionCycle cycle, String labelVersion) { + return "trader_sample_" + cycle.cycleId() + "_" + slug(labelVersion); + } + + public static String reportId(String runId) { + return "trader_report_" + runId; + } + + private static String slug(String value) { + return value.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "_"); + } + + private static String suffix() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 8); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..e039716 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,66 @@ +spring: + application: + name: quant-trader-service + datasource: + url: ${TRADER_DB_URL:jdbc:mysql://127.0.0.1:3306/quant_trader?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&allowPublicKeyRetrieval=true&useSSL=false} + username: ${TRADER_DB_USERNAME:quant_trader} + password: ${TRADER_DB_PASSWORD:quant_trader} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: ${TRADER_DB_HIKARI_MAX_POOL_SIZE:10} + minimum-idle: ${TRADER_DB_HIKARI_MIN_IDLE:2} + connection-timeout: ${TRADER_DB_HIKARI_CONNECTION_TIMEOUT_MS:30000} + validation-timeout: ${TRADER_DB_HIKARI_VALIDATION_TIMEOUT_MS:5000} + leak-detection-threshold: ${TRADER_DB_HIKARI_LEAK_DETECTION_MS:60000} + jdbc: + template: + query-timeout: ${TRADER_JDBC_QUERY_TIMEOUT_SECONDS:20} + flyway: + enabled: ${TRADER_FLYWAY_ENABLED:true} + locations: classpath:db/migration + validate-on-migrate: true + clean-disabled: true + baseline-on-migrate: false + +management: + endpoints: + web: + exposure: + include: health,info + +mybatis-plus: + mapper-locations: classpath*:/mapper/**/*.xml + global-config: + banner: false + +trader: + service-name: quant-trader-service + run-mode: REPLAY + symbol: BTCUSDT + feature-version: trader_feature_v0 + label-version: trader_label_v0 + playbook: + location-pattern: classpath:/playbooks/*.yml + replay: + output-dir: /Users/zach/Desktop/app/trader/replay-output + fail-on-data-missing: true + integration: + app-action-channel: JAR_FUTURE + http-feedback-enabled: false + risk: + leverage-screen: 10 + require-one-x-not-negative: true + max-planned-entry-legs: 3 + allow-free-scale-in: false + allow-reduce-then-add: false + require-stop: true + require-target: true + require-invalid: true + sizing: + method: SIGNAL_EXECUTION_RISK_DYNAMIC + allow-full-initial-entry: true + max-planned-entry-legs: 3 + max-total-position-ratio: 1.0 + max-single-leg-ratio: 1.0 + data-source: + hash-mode: FULL_HASH_OR_SCHEMA_ROW_TIME_MISSING_SUMMARY diff --git a/src/main/resources/db/migration/V1__trader_p0_schema.sql b/src/main/resources/db/migration/V1__trader_p0_schema.sql new file mode 100644 index 0000000..1a2910c --- /dev/null +++ b/src/main/resources/db/migration/V1__trader_p0_schema.sql @@ -0,0 +1,441 @@ +create table trader_playbook_definition ( + id bigint primary key auto_increment, + playbook_id varchar(64) not null, + playbook_version varchar(64) not null, + family varchar(64) not null, + variant varchar(64) not null, + side_mode varchar(16) not null, + source_path varchar(512) not null, + definition_hash_sha256 varchar(64) not null, + definition_json json not null, + loaded_at datetime(3) not null, + status varchar(32) not null, + created_at datetime(3) not null default current_timestamp(3), + updated_at datetime(3) not null default current_timestamp(3) on update current_timestamp(3), + unique key uk_trader_playbook_version (playbook_id, playbook_version), + unique key uk_trader_playbook_hash (playbook_id, definition_hash_sha256), + key idx_trader_playbook_status (status, loaded_at) +); + +create table trader_data_source_manifest ( + id bigint primary key auto_increment, + source_id varchar(96) not null, + symbol varchar(32) not null, + source_type varchar(64) not null, + exchange varchar(64) not null, + granularity varchar(32) not null, + source_path varchar(512) not null, + content_hash_sha256 varchar(64) null, + schema_hash_sha256 varchar(64) null, + data_from datetime(3) not null, + data_to datetime(3) not null, + min_event_time datetime(3) null, + max_event_time datetime(3) null, + timezone varchar(64) not null, + row_count bigint null, + missing_summary_json json not null, + quality_status varchar(32) not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_data_source (source_id), + key idx_trader_data_source_symbol_type (symbol, source_type, data_from, data_to), + key idx_trader_data_source_quality (quality_status, created_at) +); + +create table trader_replay_run ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + run_mode varchar(16) not null, + symbol varchar(32) not null, + playbook_id varchar(64) not null, + playbook_version varchar(64) not null, + playbook_definition_hash varchar(64) not null, + data_from datetime(3) not null, + data_to datetime(3) not null, + feature_version varchar(64) not null, + label_version varchar(64) not null, + data_source_manifest_json json not null, + status varchar(32) not null, + config_json json not null, + started_at datetime(3) null, + finished_at datetime(3) null, + created_at datetime(3) not null default current_timestamp(3), + updated_at datetime(3) not null default current_timestamp(3) on update current_timestamp(3), + unique key uk_trader_replay_run_id (run_id), + key idx_trader_replay_run_status (status, started_at), + key idx_trader_replay_run_symbol_time (symbol, data_from, data_to) +); + +create table trader_market_event ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + event_id varchar(96) not null, + symbol varchar(32) not null, + event_time datetime(3) not null, + source varchar(64) not null, + source_path varchar(512) null, + payload_json json not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_market_event (run_id, event_id), + key idx_trader_market_event_symbol_time (run_id, symbol, event_time), + key idx_trader_market_event_source (run_id, source, event_time) +); + +create table trader_market_snapshot ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + snapshot_id varchar(96) not null, + symbol varchar(32) not null, + snapshot_time datetime(3) not null, + feature_version varchar(64) not null, + context_features_json json not null, + setup_features_json json not null, + trigger_features_json json not null, + execution_features_json json not null, + data_quality_json json not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_snapshot (run_id, snapshot_id), + key idx_trader_snapshot_cycle (run_id, cycle_id), + key idx_trader_snapshot_symbol_time (run_id, symbol, snapshot_time) +); + +create table trader_decision_cycle ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + snapshot_id varchar(96) not null, + symbol varchar(32) not null, + playbook_id varchar(64) not null, + playbook_version varchar(64) not null, + state varchar(48) not null, + cycle_time datetime(3) not null, + run_mode varchar(16) not null, + decision_status varchar(32) not null, + blocker varchar(128) null, + created_at datetime(3) not null default current_timestamp(3), + updated_at datetime(3) not null default current_timestamp(3) on update current_timestamp(3), + unique key uk_trader_cycle (run_id, cycle_id), + key idx_trader_cycle_symbol_time (run_id, symbol, cycle_time), + key idx_trader_cycle_state (run_id, state, cycle_time) +); + +create table trader_playbook_candidate ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + candidate_id varchar(96) not null, + playbook_id varchar(64) not null, + playbook_version varchar(64) not null, + side varchar(16) not null, + variant varchar(64) not null, + candidate_time datetime(3) not null, + entry_price decimal(28,10) null, + invalid_price decimal(28,10) null, + stop_price decimal(28,10) null, + target_price decimal(28,10) null, + partial_take_profit_price decimal(28,10) null, + max_entry_wait_ms bigint null, + max_hold_ms bigint null, + setup_evidence_json json not null, + status varchar(32) not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_candidate (run_id, candidate_id), + key idx_trader_candidate_cycle (run_id, cycle_id), + key idx_trader_candidate_playbook_time (run_id, playbook_id, candidate_time) +); + +create table trader_trigger_event ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + candidate_id varchar(96) not null, + trigger_id varchar(96) not null, + trigger_time datetime(3) not null, + trigger_timeframe varchar(16) not null, + feature_version varchar(64) not null, + trigger_evidence_json json not null, + markout_json json null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_trigger (run_id, trigger_id), + key idx_trader_trigger_candidate (run_id, candidate_id), + key idx_trader_trigger_time (run_id, trigger_time) +); + +create table trader_entry_plan ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + action_id varchar(96) not null, + entry_leg_id varchar(96) not null, + candidate_id varchar(96) not null, + entry_action varchar(32) not null, + planned_leg_index int not null, + planned_leg_count int not null, + planned_leg_ratio decimal(18,8) not null, + sizing_method varchar(64) not null, + signal_strength_score decimal(18,8) null, + execution_quality_score decimal(18,8) null, + risk_gate_score decimal(18,8) null, + entry_price decimal(28,10) not null, + invalid_price decimal(28,10) not null, + stop_price decimal(28,10) not null, + target_price decimal(28,10) not null, + partial_take_profit_price decimal(28,10) null, + max_entry_wait_ms bigint not null, + max_hold_ms bigint not null, + reason varchar(256) not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_entry_leg (run_id, entry_leg_id), + key idx_trader_entry_action (run_id, action_id), + key idx_trader_entry_candidate (run_id, candidate_id), + constraint chk_trader_entry_action check (entry_action in ('OPEN_INITIAL', 'OPEN_PLANNED_LEG')), + constraint chk_trader_entry_leg_index check (planned_leg_index >= 0 and planned_leg_count between 1 and 3 and planned_leg_index < planned_leg_count), + constraint chk_trader_entry_action_leg check ( + (entry_action = 'OPEN_INITIAL' and planned_leg_index = 0) + or (entry_action = 'OPEN_PLANNED_LEG' and planned_leg_index > 0) + ), + constraint chk_trader_entry_leg_ratio check (planned_leg_ratio > 0 and planned_leg_ratio <= 1) +); + +create table trader_action ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + action_id varchar(96) not null, + action_type varchar(32) not null, + playbook_id varchar(64) not null, + playbook_version varchar(64) not null, + symbol varchar(32) not null, + side varchar(16) not null, + price decimal(28,10) null, + quantity decimal(28,10) null, + action_time datetime(3) not null, + reason varchar(256) not null, + action_context_json json not null, + send_status varchar(32) not null, + created_at datetime(3) not null default current_timestamp(3), + updated_at datetime(3) not null default current_timestamp(3) on update current_timestamp(3), + unique key uk_trader_action (run_id, action_id), + key idx_trader_action_cycle (run_id, cycle_id), + key idx_trader_action_symbol_time (run_id, symbol, action_time), + constraint chk_trader_action_type check (action_type in ('WAIT','OPEN_INITIAL','OPEN_PLANNED_LEG','HOLD','REDUCE','MOVE_STOP','CLOSE','CANCEL','REQUOTE')) +); + +create table trader_app_feedback ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + action_id varchar(96) not null, + feedback_type varchar(32) not null, + feedback_source varchar(32) not null, + proxy_method varchar(64) null, + simulator_version varchar(64) null, + is_real_fill boolean not null, + order_id varchar(96) null, + position_id varchar(96) null, + order_status varchar(64) null, + app_received_time datetime(3) null, + exchange_ack_time datetime(3) null, + filled_time datetime(3) null, + filled_price decimal(28,10) null, + filled_quantity decimal(28,10) null, + fee decimal(28,10) null, + slippage_bps decimal(18,8) null, + close_reason varchar(128) null, + close_signal_source varchar(128) null, + exchange_error_code varchar(64) null, + platform_error_code varchar(64) null, + raw_feedback_json json not null, + created_at datetime(3) not null default current_timestamp(3), + key idx_trader_feedback_action (run_id, action_id), + key idx_trader_feedback_position (run_id, position_id), + key idx_trader_feedback_type_time (run_id, feedback_type, app_received_time), + key idx_trader_feedback_source (run_id, feedback_source, is_real_fill), + constraint chk_trader_feedback_source check (feedback_source in ('MARKET_PROXY','SHADOW_APP','PAPER_APP','REAL_APP')), + constraint chk_trader_real_fill_source check ( + (is_real_fill = true and feedback_source in ('PAPER_APP','REAL_APP')) + or (is_real_fill = false and feedback_source in ('MARKET_PROXY','SHADOW_APP')) + ) +); + +create table trader_account_state ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + account_state_id varchar(96) not null, + snapshot_time datetime(3) not null, + equity_usdt decimal(28,10) null, + available_margin_usdt decimal(28,10) null, + used_margin_usdt decimal(28,10) null, + open_position_count int not null default 0, + leverage_limit decimal(18,8) null, + daily_realized_pnl_bps decimal(18,8) null, + max_drawdown_bps decimal(18,8) null, + consecutive_loss_count int not null default 0, + raw_account_json json not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_account_state (run_id, account_state_id), + key idx_trader_account_state_time (run_id, snapshot_time) +); + +create table trader_risk_decision ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + action_id varchar(96) null, + account_state_id varchar(96) null, + action_type varchar(32) not null, + leverage_screen decimal(18,8) not null, + planned_total_position_ratio decimal(18,8) not null, + max_loss_bps decimal(18,8) not null, + liquidation_buffer_bps decimal(18,8) null, + expected_value_bps_1x decimal(18,8) null, + expected_value_bps_10x decimal(18,8) null, + uncertainty decimal(18,8) null, + ood_score decimal(18,8) null, + allow_action boolean not null, + blocker varchar(128) null, + decision_json json not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_risk_decision (run_id, cycle_id, action_type), + key idx_trader_risk_action (run_id, action_id), + key idx_trader_risk_blocker (run_id, blocker, created_at), + constraint chk_trader_risk_position_ratio check (planned_total_position_ratio >= 0 and planned_total_position_ratio <= 1), + constraint chk_trader_risk_action check (action_type in ('OPEN_INITIAL','OPEN_PLANNED_LEG','HOLD','REDUCE','MOVE_STOP','CLOSE','CANCEL','REQUOTE')) +); + +create table trader_position_leg ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + position_id varchar(96) not null, + leg_id varchar(96) not null, + action_type varchar(32) not null, + quantity decimal(28,10) not null, + price decimal(28,10) not null, + leg_ratio decimal(18,8) not null, + risk_delta_bps decimal(18,8) not null, + action_time datetime(3) not null, + reason varchar(256) not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_position_leg (run_id, leg_id), + key idx_trader_position_leg_position (run_id, position_id, action_time), + constraint chk_trader_position_leg_action check (action_type in ('OPEN_INITIAL','OPEN_PLANNED_LEG','REDUCE','CLOSE')) +); + +create table trader_position_path ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + action_id varchar(96) not null, + position_id varchar(96) not null, + entry_time datetime(3) not null, + last_event_time datetime(3) not null, + entry_price decimal(28,10) not null, + current_price decimal(28,10) not null, + mfe_bps decimal(18,8) null, + mae_bps decimal(18,8) null, + time_to_target_ms bigint null, + time_to_invalid_ms bigint null, + target_before_stop boolean not null, + stagnation_timeout_hit boolean not null, + path_summary_json json not null, + created_at datetime(3) not null default current_timestamp(3), + updated_at datetime(3) not null default current_timestamp(3) on update current_timestamp(3), + unique key uk_trader_position_path (run_id, position_id), + key idx_trader_position_path_cycle (run_id, cycle_id), + key idx_trader_position_path_time (run_id, entry_time, last_event_time) +); + +create table trader_management_action ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + action_id varchar(96) not null, + management_action_id varchar(96) not null, + position_id varchar(96) not null, + management_action_type varchar(32) not null, + action_time datetime(3) not null, + before_risk_bps decimal(18,8) null, + after_risk_bps decimal(18,8) null, + reason varchar(256) not null, + details_json json not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_management_action (run_id, management_action_id), + key idx_trader_management_position (run_id, position_id, action_time), + constraint chk_trader_management_action check (management_action_type in ('HOLD','REDUCE','MOVE_STOP','CLOSE','CANCEL','REQUOTE')) +); + +create table trader_evidence ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + evidence_id varchar(96) not null, + stage varchar(64) not null, + pass boolean not null, + reason varchar(256) not null, + blocker varchar(128) null, + evidence_time datetime(3) not null, + details_json json not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_evidence (run_id, evidence_id), + key idx_trader_evidence_cycle_stage (run_id, cycle_id, stage), + key idx_trader_evidence_blocker (run_id, blocker, evidence_time) +); + +create table trader_training_sample ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + cycle_id varchar(96) not null, + sample_id varchar(96) not null, + action_id varchar(96) null, + position_id varchar(96) null, + feature_version varchar(64) not null, + label_version varchar(64) not null, + sample_time datetime(3) not null, + features_json json not null, + labels_json json not null, + net_return_bps_1x decimal(18,8) null, + net_return_bps_10x decimal(18,8) null, + proxy_only boolean not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_training_sample (run_id, sample_id), + key idx_trader_training_cycle (run_id, cycle_id), + key idx_trader_training_versions (feature_version, label_version, proxy_only), + key idx_trader_training_time (run_id, sample_time) +); + +create table trader_model_manifest ( + id bigint primary key auto_increment, + model_name varchar(96) not null, + model_version varchar(96) not null, + feature_version varchar(64) not null, + label_version varchar(64) not null, + artifact_path varchar(512) not null, + trained_at datetime(3) not null, + metrics_json json not null, + status varchar(32) not null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_model_manifest (model_name, model_version), + key idx_trader_model_status (model_name, status, trained_at) +); + +create table trader_replay_report ( + id bigint primary key auto_increment, + run_id varchar(64) not null, + report_id varchar(96) not null, + symbol varchar(32) not null, + playbook_id varchar(64) not null, + playbook_version varchar(64) not null, + candidate_events int not null, + months_covered int not null, + base_net_return_bps_1x decimal(18,8) null, + leveraged_net_return_bps_10x decimal(18,8) null, + holdout_return_bps_10x decimal(18,8) null, + strict_vs_loose_json json not null, + failure_risks_json json not null, + conclusion varchar(64) not null, + report_path varchar(512) null, + created_at datetime(3) not null default current_timestamp(3), + unique key uk_trader_replay_report (run_id, report_id), + key idx_trader_replay_report_conclusion (conclusion, created_at) +); diff --git a/src/main/resources/db/migration/V2__trader_persistence_id_lengths.sql b/src/main/resources/db/migration/V2__trader_persistence_id_lengths.sql new file mode 100644 index 0000000..19f4123 --- /dev/null +++ b/src/main/resources/db/migration/V2__trader_persistence_id_lengths.sql @@ -0,0 +1,8 @@ +alter table trader_evidence + modify column evidence_id varchar(160) not null; + +alter table trader_training_sample + modify column sample_id varchar(160) not null; + +alter table trader_replay_run + add column failure_reason text null after finished_at; diff --git a/src/main/resources/playbooks/breakout-retest-intraday-5m-60m.yml b/src/main/resources/playbooks/breakout-retest-intraday-5m-60m.yml new file mode 100644 index 0000000..85ac765 --- /dev/null +++ b/src/main/resources/playbooks/breakout-retest-intraday-5m-60m.yml @@ -0,0 +1,61 @@ +playbook_id: BREAKOUT_RETEST_CONTINUATION +playbook_version: 2026-06-22.p0 +family: TREND_CONTINUATION +variant: INTRADAY_5M_60M +side_mode: BOTH +context_timeframes: [1h, 4h] +setup_timeframes: [15m, 30m, 60m] +trigger_timeframes: [1m, 3m, 5m] +execution_window: 30s-300s +management_windows: [15m, 30m, 60m] +entry_rule: + name: breakout_retest_entry + description: platform breakout, retest hold, and trigger continuation +planned_entry_leg_rule: + name: preplanned_continuation_entry_leg + description: second or third entry leg must be declared before initial entry + max_planned_entry_legs: 3 + ratio_mode: SIGNAL_EXECUTION_RISK_DYNAMIC + allow_full_initial_entry: true + ratio_template_fixed: false + description_ext: playbook declares leg boundaries, while actual ratios are calculated by SignalStrength, ExecutionQuality, and RiskGate +invalid_rule: + name: retest_platform_invalid + description: price falls back through the retest platform +stop_rule: + name: structural_stop + description: stop beyond invalid zone with leverage-aware risk cap +target_rule: + name: prior_high_or_measured_move + description: target near prior high or measured continuation level +partial_take_profit_rule: + name: action_decision_zone + description: prior high is an action decision zone, not a fixed partial close +max_hold_rule: + name: intraday_timeout + description: exit or downgrade if no progress within 60m-120m +failure_exit_rule: + name: failed_continuation_exit + description: close when continuation fails and price returns into platform +risk_constraints: + max_leverage: "10x_screen_only" + require_1x_not_negative: "true" + require_stop: "true" + require_target: "true" + max_total_position_ratio: "1.0" + max_single_leg_ratio: "1.0" +required_features: + - candles + - trades + - level_1 + - book_proxy_optional +output_actions: + - WAIT + - OPEN_INITIAL + - OPEN_PLANNED_LEG + - HOLD + - REDUCE + - MOVE_STOP + - CLOSE + - CANCEL + - REQUOTE diff --git a/src/test/java/com/quantai/trader/QuantTraderServiceApplicationTest.java b/src/test/java/com/quantai/trader/QuantTraderServiceApplicationTest.java new file mode 100644 index 0000000..2415636 --- /dev/null +++ b/src/test/java/com/quantai/trader/QuantTraderServiceApplicationTest.java @@ -0,0 +1,12 @@ +package com.quantai.trader; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class QuantTraderServiceApplicationTest { + + @Test + void contextLoads() { + } +} diff --git a/src/test/java/com/quantai/trader/TestFixtures.java b/src/test/java/com/quantai/trader/TestFixtures.java new file mode 100644 index 0000000..643c452 --- /dev/null +++ b/src/test/java/com/quantai/trader/TestFixtures.java @@ -0,0 +1,148 @@ +package com.quantai.trader; + +import com.quantai.trader.config.TraderProperties; +import com.quantai.trader.domain.PlaybookCandidate; +import com.quantai.trader.domain.TraderAction; +import com.quantai.trader.domain.TraderDecisionCycle; +import com.quantai.trader.domain.TraderEntryPlan; +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.TraderRunMode; +import com.quantai.trader.enums.TraderSide; +import com.quantai.trader.enums.TraderState; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Map; + +public final class TestFixtures { + + public static final Instant NOW = Instant.parse("2026-06-23T12:00:00Z"); + + private TestFixtures() { + } + + public static TraderProperties properties() { + return new TraderProperties(); + } + + public static TraderDecisionCycle cycle(TraderState state) { + return new TraderDecisionCycle( + "trader_run_test", + "trader_cycle_test", + "trader_snapshot_test", + "BTCUSDT", + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0", + state, + NOW, + TraderRunMode.REPLAY, + "CREATED", + null + ); + } + + public static TraderPricePlan pricePlan() { + return new TraderPricePlan( + new BigDecimal("65000"), + new BigDecimal("64800"), + new BigDecimal("64920"), + new BigDecimal("65350"), + null, + 300_000, + 7_200_000 + ); + } + + public static PlaybookCandidate candidate() { + return new PlaybookCandidate( + "trader_run_test", + "trader_cycle_test", + "trader_candidate_test", + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0", + TraderSide.LONG, + "INTRADAY_5M_60M", + NOW, + pricePlan(), + 3, + Map.of("executionQualityScore", "0.90") + ); + } + + public static TriggerDecision strongTrigger() { + return new TriggerDecision(true, new BigDecimal("0.95"), "TRIGGER_ACCEPTED", null, Map.of()); + } + + public static TraderEntryPlan fullInitialPlan() { + return new TraderEntryPlan( + "trader_run_test", + "trader_cycle_test", + "trader_action_test_1", + "trader_leg_test_0", + "trader_candidate_test", + TraderActionType.OPEN_INITIAL, + 0, + 1, + BigDecimal.ONE, + "SIGNAL_EXECUTION_RISK_DYNAMIC", + new BigDecimal("0.95"), + new BigDecimal("0.90"), + new BigDecimal("0.90"), + new BigDecimal("65000"), + new BigDecimal("64800"), + new BigDecimal("64920"), + new BigDecimal("65350"), + null, + 300_000, + 7_200_000, + "TEST_INITIAL_ENTRY" + ); + } + + public static TraderPositionPath openedPath(boolean reduceSeen) { + return new TraderPositionPath( + "trader_run_test", + "trader_cycle_test", + "trader_action_test_1", + "trader_position_test", + TraderSide.LONG, + NOW, + NOW, + new BigDecimal("65000"), + new BigDecimal("65010"), + BigDecimal.ZERO, + BigDecimal.ZERO, + null, + null, + false, + false, + true, + reduceSeen, + 1, + new BigDecimal("0.60"), + Map.of("proxyFill", true) + ); + } + + public static TraderAction action() { + return new TraderAction( + "trader_run_test", + "trader_cycle_test", + "trader_action_test_1", + TraderActionType.OPEN_INITIAL, + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0", + "BTCUSDT", + TraderSide.LONG, + new BigDecimal("65000"), + null, + NOW, + "TEST", + Map.of("plannedLegRatio", BigDecimal.ONE), + "SHADOW_CREATED" + ); + } +} diff --git a/src/test/java/com/quantai/trader/brain/TraderDecisionCycleRunnerTest.java b/src/test/java/com/quantai/trader/brain/TraderDecisionCycleRunnerTest.java new file mode 100644 index 0000000..f9c25c4 --- /dev/null +++ b/src/test/java/com/quantai/trader/brain/TraderDecisionCycleRunnerTest.java @@ -0,0 +1,85 @@ +package com.quantai.trader.brain; + +import com.quantai.trader.enums.TraderRunMode; +import com.quantai.trader.enums.TraderState; +import com.quantai.trader.domain.TraderException; +import com.quantai.trader.replay.ReplayClockTick; +import com.quantai.trader.state.TraderRuntimeState; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +class TraderDecisionCycleRunnerTest { + + @Autowired + private TraderDecisionCycleRunner runner; + + @Test + void exportsSampleForHappyPathReplayTick() { + ReplayClockTick tick = new ReplayClockTick( + "trader_run_runner", + "BTCUSDT", + Instant.parse("2026-06-23T12:00:00Z"), + Map.of("contextPass", true), + Map.of( + "setupPass", true, + "entryPrice", new BigDecimal("65000"), + "invalidPrice", new BigDecimal("64800"), + "stopPrice", new BigDecimal("64920"), + "targetPrice", new BigDecimal("65350"), + "executionQualityScore", "0.90" + ), + Map.of("triggerScore", "0.95"), + Map.of("lastPrice", "65010"), + Map.of() + ); + + TraderCycleResult result = runner.runReplayTick( + tick, + new TraderRuntimeState( + "trader_run_runner", + TraderRunMode.REPLAY, + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0" + ) + ); + + assertThat(result.cycle().state()).isEqualTo(TraderState.SAMPLE_EXPORTED); + assertThat(result.action()).isNotNull(); + assertThat(result.sample().proxyOnly()).isTrue(); + } + + @Test + void setupPassWithoutPricePlanFailsInsteadOfUsingFallbackPrices() { + ReplayClockTick tick = new ReplayClockTick( + "trader_run_runner", + "BTCUSDT", + Instant.parse("2026-06-23T12:00:00Z"), + Map.of("contextPass", true), + Map.of("setupPass", true), + Map.of("triggerScore", "0.95"), + Map.of("lastPrice", "65010"), + Map.of() + ); + + assertThatThrownBy(() -> runner.runReplayTick( + tick, + new TraderRuntimeState( + "trader_run_runner", + TraderRunMode.REPLAY, + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0" + ) + )) + .isInstanceOf(TraderException.class) + .hasMessageContaining("entryPrice"); + } +} diff --git a/src/test/java/com/quantai/trader/controller/TraderControllerTest.java b/src/test/java/com/quantai/trader/controller/TraderControllerTest.java new file mode 100644 index 0000000..d57363b --- /dev/null +++ b/src/test/java/com/quantai/trader/controller/TraderControllerTest.java @@ -0,0 +1,135 @@ +package com.quantai.trader.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.nio.file.Path; + +import static org.hamcrest.Matchers.hasItem; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class TraderControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void listsLoadedPlaybook() throws Exception { + mockMvc.perform(get("/api/trader/playbooks")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].playbookId").value("BREAKOUT_RETEST_CONTINUATION")) + .andExpect(jsonPath("$[0].outputActions", hasItem("OPEN_INITIAL"))); + } + + @Test + void feedbackIsDisabledByDefault() throws Exception { + mockMvc.perform(post("/api/trader/feedback") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "runId": "run", + "cycleId": "cycle", + "actionId": "action", + "feedbackType": "FILL_EVENT", + "feedbackSource": "PAPER_APP", + "realFill": true, + "rawFeedback": {} + } + """)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value("TRADER_FEEDBACK_DISABLED")); + } + + @Test + void createsReplayRunAsynchronously() throws Exception { + Path fixture = Path.of(new ClassPathResource("replay-fixtures/trend-up-breakout-happy.jsonl").getFile().toURI()); + + MvcResult result = mockMvc.perform(post("/api/trader/replay/runs") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "symbol": "BTCUSDT", + "playbookId": "BREAKOUT_RETEST_CONTINUATION", + "playbookVersion": "2026-06-22.p0", + "from": "2026-01-01T00:00:00Z", + "to": "2026-01-02T00:00:00Z", + "featureVersion": "trader_feature_v0", + "labelVersion": "trader_label_v0", + "dataSources": { + "ticks": { + "sourceId": "btc_ticks_test", + "path": "%s", + "hashSha256": "abc", + "timezone": "UTC", + "missingSummary": {} + } + } + } + """.formatted(fixture.toString()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("CREATED")) + .andExpect(jsonPath("$.runId").exists()) + .andReturn(); + + String runId = result.getResponse().getContentAsString() + .replaceAll(".*\\\"runId\\\":\\\"([^\\\"]+)\\\".*", "$1"); + waitForCompletedRun(runId); + mockMvc.perform(get("/api/trader/replay/runs/{runId}/report", runId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.candidateEvents").value(1)) + .andExpect(jsonPath("$.strictVsLoose.p0ReplayEngine").value("jsonl_fixture")); + } + + @Test + void rejectsReplayRunWithoutReadableFixture() throws Exception { + mockMvc.perform(post("/api/trader/replay/runs") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "symbol": "BTCUSDT", + "playbookId": "BREAKOUT_RETEST_CONTINUATION", + "playbookVersion": "2026-06-22.p0", + "from": "2026-01-01T00:00:00Z", + "to": "2026-01-02T00:00:00Z", + "featureVersion": "trader_feature_v0", + "labelVersion": "trader_label_v0", + "dataSources": { + "ticks": { + "sourceId": "btc_ticks_missing", + "path": "/tmp/not-a-real-trader-fixture.jsonl", + "hashSha256": "abc", + "timezone": "UTC", + "missingSummary": {} + } + } + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("TRADER_DATA_SOURCE_MISSING")); + } + + private void waitForCompletedRun(String runId) throws Exception { + for (int i = 0; i < 20; i++) { + MvcResult statusResult = mockMvc.perform(get("/api/trader/replay/runs/{runId}", runId)) + .andExpect(status().isOk()) + .andReturn(); + if (statusResult.getResponse().getContentAsString().contains("\"status\":\"COMPLETED\"")) { + return; + } + Thread.sleep(50); + } + mockMvc.perform(get("/api/trader/replay/runs/{runId}", runId)) + .andExpect(jsonPath("$.status").value("COMPLETED")); + } +} diff --git a/src/test/java/com/quantai/trader/domain/TraderAppFeedbackTest.java b/src/test/java/com/quantai/trader/domain/TraderAppFeedbackTest.java new file mode 100644 index 0000000..ccf49a6 --- /dev/null +++ b/src/test/java/com/quantai/trader/domain/TraderAppFeedbackTest.java @@ -0,0 +1,53 @@ +package com.quantai.trader.domain; + +import com.quantai.trader.enums.TraderFeedbackSource; +import com.quantai.trader.enums.TraderFeedbackType; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TraderAppFeedbackTest { + + @Test + void rejectsProxySourceMarkedAsRealFill() { + assertThatThrownBy(() -> feedback(TraderFeedbackSource.MARKET_PROXY, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("inconsistent"); + } + + @Test + void acceptsPaperSourceMarkedAsRealFill() { + assertThatCode(() -> feedback(TraderFeedbackSource.PAPER_APP, true)).doesNotThrowAnyException(); + } + + private TraderAppFeedback feedback(TraderFeedbackSource source, boolean realFill) { + return new TraderAppFeedback( + "run", + "cycle", + "action", + TraderFeedbackType.FILL_EVENT, + source, + null, + null, + realFill, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + Map.of() + ); + } +} diff --git a/src/test/java/com/quantai/trader/domain/TraderDataSourceManifestTest.java b/src/test/java/com/quantai/trader/domain/TraderDataSourceManifestTest.java new file mode 100644 index 0000000..f503a84 --- /dev/null +++ b/src/test/java/com/quantai/trader/domain/TraderDataSourceManifestTest.java @@ -0,0 +1,51 @@ +package com.quantai.trader.domain; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TraderDataSourceManifestTest { + + @Test + void requiresFullHashOrSchemaRowTimeLineage() { + assertThatThrownBy(() -> manifest(null, null, null, null, null)) + .isInstanceOf(TraderException.class) + .hasMessageContaining("content hash"); + } + + @Test + void acceptsFullHash() { + assertThatCode(() -> manifest("abc", null, null, null, null)).doesNotThrowAnyException(); + } + + @Test + void acceptsSchemaLineageForLargeFiles() { + assertThatCode(() -> manifest(null, "schema", 100L, Instant.parse("2026-01-01T00:00:00Z"), Instant.parse("2026-01-02T00:00:00Z"))) + .doesNotThrowAnyException(); + } + + private TraderDataSourceManifest manifest(String hash, String schema, Long rows, Instant min, Instant max) { + return new TraderDataSourceManifest( + "source-1", + "BTCUSDT", + "candles", + "BINANCE", + "1m", + "/tmp/candles.parquet", + hash, + schema, + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-02T00:00:00Z"), + min, + max, + "UTC", + rows, + Map.of(), + "P0_ACCEPTED" + ); + } +} diff --git a/src/test/java/com/quantai/trader/evidence/EvidenceAppenderTest.java b/src/test/java/com/quantai/trader/evidence/EvidenceAppenderTest.java new file mode 100644 index 0000000..4666600 --- /dev/null +++ b/src/test/java/com/quantai/trader/evidence/EvidenceAppenderTest.java @@ -0,0 +1,50 @@ +package com.quantai.trader.evidence; + +import com.quantai.trader.TestFixtures; +import com.quantai.trader.domain.StageDecision; +import com.quantai.trader.domain.TraderEvidence; +import com.quantai.trader.persistence.TraderEvidenceRepository; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class EvidenceAppenderTest { + + @Test + void appendsBlockingEvidenceToRepository() { + CapturingEvidenceRepository repository = new CapturingEvidenceRepository(); + EvidenceAppender appender = new EvidenceAppender(repository); + + appender.append( + TestFixtures.cycle(com.quantai.trader.enums.TraderState.CONTEXT_CHECK), + "CONTEXT_GATE", + StageDecision.block("DATA_MISSING", "TRADER_DATA_QUALITY_FAILED") + ); + + assertThat(repository.findByCycleId("trader_run_test", "trader_cycle_test")) + .singleElement() + .satisfies(evidence -> { + assertThat(evidence.pass()).isFalse(); + assertThat(evidence.blocker()).isEqualTo("TRADER_DATA_QUALITY_FAILED"); + }); + } + + private static class CapturingEvidenceRepository implements TraderEvidenceRepository { + private final List evidence = new ArrayList<>(); + + @Override + public void insert(TraderEvidence evidence) { + this.evidence.add(evidence); + } + + @Override + public List findByCycleId(String runId, String cycleId) { + return evidence.stream() + .filter(item -> item.runId().equals(runId) && item.cycleId().equals(cycleId)) + .toList(); + } + } +} diff --git a/src/test/java/com/quantai/trader/execution/TraderEntryPlannerTest.java b/src/test/java/com/quantai/trader/execution/TraderEntryPlannerTest.java new file mode 100644 index 0000000..87c5361 --- /dev/null +++ b/src/test/java/com/quantai/trader/execution/TraderEntryPlannerTest.java @@ -0,0 +1,67 @@ +package com.quantai.trader.execution; + +import com.quantai.trader.TestFixtures; +import com.quantai.trader.domain.PlaybookCandidate; +import com.quantai.trader.domain.TraderEntryPlan; +import com.quantai.trader.domain.TraderException; +import com.quantai.trader.domain.TraderPricePlan; +import com.quantai.trader.enums.TraderActionType; +import com.quantai.trader.enums.TraderSide; +import com.quantai.trader.enums.TraderState; +import com.quantai.trader.risk.TraderPositionSizer; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TraderEntryPlannerTest { + + private final TraderEntryPlanner planner = new TraderEntryPlanner(new TraderPositionSizer(TestFixtures.properties())); + + @Test + void createsCompleteInitialEntryPlan() { + TraderEntryPlan plan = planner.planInitialEntry( + TestFixtures.cycle(TraderState.ENTRY_PLANNED), + TestFixtures.candidate(), + TestFixtures.strongTrigger() + ); + + assertThat(plan.entryAction()).isEqualTo(TraderActionType.OPEN_INITIAL); + assertThat(plan.completeForEntry()).isTrue(); + assertThat(plan.plannedLegIndex()).isZero(); + } + + @Test + void rejectsCandidateWithoutStopTargetInvalidOrMaxHold() { + TraderPricePlan incomplete = new TraderPricePlan(new BigDecimal("1"), null, null, null, null, 0, 0); + PlaybookCandidate candidate = new PlaybookCandidate( + "run", + "cycle", + "candidate", + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0", + TraderSide.LONG, + "INTRADAY_5M_60M", + TestFixtures.NOW, + incomplete, + 3, + Map.of() + ); + + assertThatThrownBy(() -> planner.planInitialEntry(TestFixtures.cycle(TraderState.ENTRY_PLANNED), candidate, TestFixtures.strongTrigger())) + .isInstanceOf(TraderException.class) + .hasMessageContaining("entry/invalid/stop/target"); + } + + @Test + void skipsPlannedLegAfterReduce() { + assertThat(planner.planNextDeclaredLeg( + TestFixtures.cycle(TraderState.PLANNED_LEG_WAIT), + TestFixtures.candidate(), + TestFixtures.openedPath(true) + )).isEmpty(); + } +} diff --git a/src/test/java/com/quantai/trader/persistence/MybatisReplayPersistenceTest.java b/src/test/java/com/quantai/trader/persistence/MybatisReplayPersistenceTest.java new file mode 100644 index 0000000..320dbe2 --- /dev/null +++ b/src/test/java/com/quantai/trader/persistence/MybatisReplayPersistenceTest.java @@ -0,0 +1,102 @@ +package com.quantai.trader.persistence; + +import com.quantai.trader.enums.ReplayRunStatus; +import com.quantai.trader.playbook.TraderPlaybookDefinitionSnapshot; +import com.quantai.trader.replay.DataSourceSpec; +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.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class MybatisReplayPersistenceTest { + + @Autowired + private ReplayRunService replayRunService; + + @Autowired + private ReplayReportRepository reportRepository; + + @Autowired + private TraderSampleRepository sampleRepository; + + @Autowired + private TraderEvidenceRepository evidenceRepository; + + @Autowired + private TraderRiskDecisionRepository riskDecisionRepository; + + @Autowired + private TraderPlaybookDefinitionRepository playbookDefinitionRepository; + + @Test + void persistsReplayArtifactsThroughMybatisPlusRepositories() throws Exception { + ReplayRunResponse response = replayRunService.createRun(configFor("trend-up-breakout-happy.jsonl")); + ReplayRun run = waitForTerminalRun(response.runId()); + + assertThat(run.status()).isEqualTo(ReplayRunStatus.COMPLETED); + assertThat(reportRepository.findByRunId(run.runId())).isPresent() + .get() + .extracting(report -> report.strictVsLoose().get("p0ReplayEngine")) + .isEqualTo("jsonl_fixture"); + + var samples = sampleRepository.findByRunId(run.runId()); + assertThat(samples).hasSize(1); + String cycleId = samples.getFirst().cycleId(); + assertThat(evidenceRepository.findByCycleId(run.runId(), cycleId)).isNotEmpty(); + assertThat(riskDecisionRepository.findByCycleId(run.runId(), cycleId)).isNotEmpty(); + + TraderPlaybookDefinitionSnapshot playbook = playbookDefinitionRepository + .findPlaybookDefinition("BREAKOUT_RETEST_CONTINUATION", "2026-06-22.p0") + .orElseThrow(); + assertThat(playbook.definitionHashSha256()).isNotBlank(); + } + + private ReplayRun waitForTerminalRun(String runId) throws InterruptedException { + for (int i = 0; i < 200; i++) { + ReplayRun run = replayRunService.find(runId).orElseThrow(); + if (run.status() == ReplayRunStatus.COMPLETED + || run.status() == ReplayRunStatus.FAILED + || run.status() == ReplayRunStatus.CANCELLED) { + return run; + } + Thread.sleep(50); + } + return replayRunService.find(runId).orElseThrow(); + } + + private ReplayRunConfig configFor(String fixtureName) throws Exception { + Path path = Path.of(new ClassPathResource("replay-fixtures/" + fixtureName).getFile().toURI()); + return new ReplayRunConfig( + null, + "BTCUSDT", + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0", + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-02T00:00:00Z"), + "trader_feature_v0", + "trader_label_v0", + Map.of("ticks", new DataSourceSpec( + fixtureName.replace(".jsonl", ""), + path.toString(), + "fixture-hash-not-used-in-p0", + null, + 1L, + null, + null, + "UTC", + Map.of() + )) + ); + } +} diff --git a/src/test/java/com/quantai/trader/persistence/TraderPlaybookDefinitionRepositoryTest.java b/src/test/java/com/quantai/trader/persistence/TraderPlaybookDefinitionRepositoryTest.java new file mode 100644 index 0000000..67e1752 --- /dev/null +++ b/src/test/java/com/quantai/trader/persistence/TraderPlaybookDefinitionRepositoryTest.java @@ -0,0 +1,97 @@ +package com.quantai.trader.persistence; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.quantai.trader.domain.TraderException; +import com.quantai.trader.playbook.RuleDefinition; +import com.quantai.trader.playbook.TraderPlaybookDefinition; +import com.quantai.trader.playbook.TraderPlaybookDefinitionSnapshot; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@SpringBootTest +class TraderPlaybookDefinitionRepositoryTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().findAndRegisterModules(); + private static final String HASH_A = "a".repeat(64); + private static final String HASH_B = "b".repeat(64); + + @Autowired + private TraderPlaybookDefinitionRepository repository; + + @Test + void rejectsSameVersionWithDifferentHash() throws Exception { + String playbookId = nextPlaybookId(); + String version = "2026-06-23.test-conflict"; + repository.insertPlaybookDefinitionIfAbsent(snapshot(playbookId, version, HASH_A)); + + assertThatThrownBy(() -> repository.insertPlaybookDefinitionIfAbsent(snapshot(playbookId, version, HASH_B))) + .isInstanceOf(TraderException.class) + .hasMessageContaining("another definition hash"); + } + + @Test + void allowsSameVersionWithSameHash() throws Exception { + String playbookId = nextPlaybookId(); + String version = "2026-06-23.test-same"; + repository.insertPlaybookDefinitionIfAbsent(snapshot(playbookId, version, HASH_A)); + repository.insertPlaybookDefinitionIfAbsent(snapshot(playbookId, version, HASH_A)); + + assertThat(repository.findPlaybookDefinition(playbookId, version)).isPresent(); + } + + private TraderPlaybookDefinitionSnapshot snapshot(String playbookId, String version, String hash) throws Exception { + TraderPlaybookDefinition definition = definition(playbookId, version); + return new TraderPlaybookDefinitionSnapshot( + playbookId, + version, + "TREND_CONTINUATION", + "INTRADAY_5M_60M", + "test.yml", + hash, + OBJECT_MAPPER.writeValueAsString(definition), + Instant.now(), + "ACTIVE", + definition + ); + } + + private TraderPlaybookDefinition definition(String playbookId, String version) { + RuleDefinition rule = new RuleDefinition("test_rule", "test rule", 3, "FIXED", true, true, "test"); + return new TraderPlaybookDefinition( + playbookId, + version, + "TREND_CONTINUATION", + "INTRADAY_5M_60M", + "BOTH", + List.of("1h"), + List.of("15m"), + List.of("1m"), + "30s-300s", + List.of("15m"), + rule, + rule, + rule, + rule, + rule, + rule, + rule, + rule, + Map.of("max_leverage", "10x_screen_only"), + List.of("candles", "trades", "level_1"), + List.of("WAIT", "OPEN_INITIAL", "OPEN_PLANNED_LEG", "HOLD", "REDUCE", "MOVE_STOP", "CLOSE", "CANCEL", "REQUOTE") + ); + } + + private String nextPlaybookId() { + return "TEST_PLAYBOOK_" + UUID.randomUUID().toString().substring(0, 8); + } +} diff --git a/src/test/java/com/quantai/trader/playbook/TraderPlaybookValidatorTest.java b/src/test/java/com/quantai/trader/playbook/TraderPlaybookValidatorTest.java new file mode 100644 index 0000000..6199907 --- /dev/null +++ b/src/test/java/com/quantai/trader/playbook/TraderPlaybookValidatorTest.java @@ -0,0 +1,93 @@ +package com.quantai.trader.playbook; + +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TraderPlaybookValidatorTest { + + private final TraderPlaybookValidator validator = new TraderPlaybookValidator(); + + @Test + void rejectsMissingRequiredRule() { + TraderPlaybookDefinition definition = validDefinition(null, List.of("WAIT")); + + assertThatThrownBy(() -> validator.validate(definition)) + .isInstanceOf(TraderPlaybookValidationException.class) + .hasMessageContaining("entry_rule"); + } + + @Test + void rejectsScaleInAction() { + TraderPlaybookDefinition definition = validDefinition(rule("entry"), List.of("WAIT", "SCALE_IN")); + + assertThatThrownBy(() -> validator.validate(definition)) + .isInstanceOf(TraderPlaybookValidationException.class) + .hasMessageContaining("SCALE_IN"); + } + + @Test + void rejectsFixedLegRatioTemplate() { + RuleDefinition fixed = new RuleDefinition("legs", "fixed", 3, "FIXED", true, true, null); + TraderPlaybookDefinition definition = new TraderPlaybookDefinition( + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0", + "TREND_CONTINUATION", + "INTRADAY_5M_60M", + "BOTH", + List.of("1h"), + List.of("15m"), + List.of("5m"), + "30s-300s", + List.of("60m"), + rule("entry"), + fixed, + rule("invalid"), + rule("stop"), + rule("target"), + null, + rule("max_hold"), + null, + Map.of(), + List.of("candles", "trades", "level_1"), + List.of("WAIT", "OPEN_PLANNED_LEG") + ); + + assertThatThrownBy(() -> validator.validate(definition)) + .isInstanceOf(TraderPlaybookValidationException.class) + .hasMessageContaining("fixed"); + } + + private TraderPlaybookDefinition validDefinition(RuleDefinition entryRule, List outputActions) { + return new TraderPlaybookDefinition( + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0", + "TREND_CONTINUATION", + "INTRADAY_5M_60M", + "BOTH", + List.of("1h"), + List.of("15m"), + List.of("5m"), + "30s-300s", + List.of("60m"), + entryRule, + new RuleDefinition("legs", "dynamic", 3, "SIGNAL_EXECUTION_RISK_DYNAMIC", true, false, null), + rule("invalid"), + rule("stop"), + rule("target"), + null, + rule("max_hold"), + null, + Map.of(), + List.of("candles", "trades", "level_1"), + outputActions + ); + } + + private RuleDefinition rule(String name) { + return new RuleDefinition(name, name, null, null, null, null, null); + } +} diff --git a/src/test/java/com/quantai/trader/position/TraderPositionManagerTest.java b/src/test/java/com/quantai/trader/position/TraderPositionManagerTest.java new file mode 100644 index 0000000..3e08e47 --- /dev/null +++ b/src/test/java/com/quantai/trader/position/TraderPositionManagerTest.java @@ -0,0 +1,62 @@ +package com.quantai.trader.position; + +import com.quantai.trader.TestFixtures; +import com.quantai.trader.domain.TraderAction; +import com.quantai.trader.domain.TraderException; +import com.quantai.trader.domain.TraderMarketSnapshot; +import com.quantai.trader.enums.TraderActionType; +import com.quantai.trader.enums.TraderSide; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TraderPositionManagerTest { + + @Test + void plannedLegWithoutExistingPathFailsInsteadOfNoOp() { + TraderPositionManager manager = new TraderPositionManager(); + TraderAction action = new TraderAction( + "trader_run_test", + "trader_cycle_test", + "trader_action_test_2", + TraderActionType.OPEN_PLANNED_LEG, + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0", + "BTCUSDT", + TraderSide.LONG, + new BigDecimal("65000"), + null, + TestFixtures.NOW, + "TEST_PLANNED_LEG", + Map.of("positionId", "missing_position", "plannedLegRatio", new BigDecimal("0.20")), + "SHADOW_CREATED" + ); + + assertThatThrownBy(() -> manager.simulateOrUpdate( + TestFixtures.cycle(com.quantai.trader.enums.TraderState.PLANNED_LEG_WAIT), + action, + snapshot() + )) + .isInstanceOf(TraderException.class) + .hasMessageContaining("not found"); + } + + private TraderMarketSnapshot snapshot() { + return new TraderMarketSnapshot( + "trader_run_test", + "trader_cycle_test", + "trader_snapshot_test", + "BTCUSDT", + TestFixtures.NOW, + "trader_feature_v0", + Map.of(), + Map.of(), + Map.of(), + Map.of("lastPrice", "65010"), + Map.of() + ); + } +} diff --git a/src/test/java/com/quantai/trader/replay/TraderReplayFixtureAcceptanceTest.java b/src/test/java/com/quantai/trader/replay/TraderReplayFixtureAcceptanceTest.java new file mode 100644 index 0000000..49227b7 --- /dev/null +++ b/src/test/java/com/quantai/trader/replay/TraderReplayFixtureAcceptanceTest.java @@ -0,0 +1,118 @@ +package com.quantai.trader.replay; + +import com.quantai.trader.domain.TraderReplayReport; +import com.quantai.trader.enums.ReplayRunStatus; +import com.quantai.trader.persistence.ReplayReportRepository; +import com.quantai.trader.persistence.TraderSampleRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.core.io.ClassPathResource; + +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class TraderReplayFixtureAcceptanceTest { + + @Autowired + private ReplayRunService replayRunService; + + @Autowired + private ReplayReportRepository reportRepository; + + @Autowired + private TraderSampleRepository sampleRepository; + + @Test + void completesReplayForRepresentativeMarketFixtures() throws Exception { + List fixtures = List.of( + new ExpectedCompletedFixture("trend-up-breakout-happy.jsonl", 1, "CLOSE"), + new ExpectedCompletedFixture("trend-down-no-setup.jsonl", 0, "NONE"), + new ExpectedCompletedFixture("sideways-range-no-setup.jsonl", 0, "NONE"), + new ExpectedCompletedFixture("false-breakout-trigger-wait.jsonl", 0, "NONE"), + new ExpectedCompletedFixture("missing-features-data-quality.jsonl", 0, "NONE") + ); + + for (ExpectedCompletedFixture fixture : fixtures) { + ReplayRun run = runFixtureToTerminalStatus(fixture.fileName()); + + assertThat(run.status()).as(fixture.fileName()).isEqualTo(ReplayRunStatus.COMPLETED); + TraderReplayReport report = reportRepository.findByRunId(run.runId()).orElseThrow(); + assertThat(report.strictVsLoose()) + .containsEntry("p0ReplayEngine", "jsonl_fixture") + .containsEntry("tickCount", 1) + .containsEntry("sampleCount", 1) + .containsEntry("actionCount", fixture.expectedActionCount()); + assertThat(report.candidateEvents()).isEqualTo(fixture.expectedActionCount()); + + var samples = sampleRepository.findByRunId(run.runId()); + assertThat(samples).hasSize(1); + assertThat(samples.getFirst().features()).containsEntry("actionType", fixture.expectedSampleActionType()); + } + } + + @Test + void failsReplayWhenSetupPassLacksRequiredEntryPlanPrices() throws Exception { + ReplayRun run = runFixtureToTerminalStatus("incomplete-entry-plan-hard-fail.jsonl"); + + assertThat(run.status()).isEqualTo(ReplayRunStatus.FAILED); + assertThat(run.failureReason()).contains("entryPrice"); + assertThat(reportRepository.findByRunId(run.runId())).isEmpty(); + assertThat(sampleRepository.findByRunId(run.runId())).isEmpty(); + } + + private ReplayRun runFixtureToTerminalStatus(String fixtureName) throws Exception { + ReplayRunResponse response = replayRunService.createRun(configFor(fixtureName)); + return waitForTerminalRun(response.runId()); + } + + private ReplayRun waitForTerminalRun(String runId) throws InterruptedException { + for (int i = 0; i < 100; i++) { + ReplayRun run = replayRunService.find(runId).orElseThrow(); + if (run.status() == ReplayRunStatus.COMPLETED + || run.status() == ReplayRunStatus.FAILED + || run.status() == ReplayRunStatus.CANCELLED) { + return run; + } + Thread.sleep(25); + } + return replayRunService.find(runId).orElseThrow(); + } + + private ReplayRunConfig configFor(String fixtureName) throws Exception { + Path path = Path.of(new ClassPathResource("replay-fixtures/" + fixtureName).getFile().toURI()); + return new ReplayRunConfig( + null, + "BTCUSDT", + "BREAKOUT_RETEST_CONTINUATION", + "2026-06-22.p0", + Instant.parse("2026-01-01T00:00:00Z"), + Instant.parse("2026-01-02T00:00:00Z"), + "trader_feature_v0", + "trader_label_v0", + Map.of("ticks", new DataSourceSpec( + fixtureName.replace(".jsonl", ""), + path.toString(), + "fixture-hash-not-used-in-p0", + null, + 1L, + null, + null, + "UTC", + Map.of() + )) + ); + } + + private record ExpectedCompletedFixture( + String fileName, + int expectedActionCount, + String expectedSampleActionType + ) { + } +} diff --git a/src/test/java/com/quantai/trader/risk/TraderPositionSizerTest.java b/src/test/java/com/quantai/trader/risk/TraderPositionSizerTest.java new file mode 100644 index 0000000..32fa6b4 --- /dev/null +++ b/src/test/java/com/quantai/trader/risk/TraderPositionSizerTest.java @@ -0,0 +1,50 @@ +package com.quantai.trader.risk; + +import com.quantai.trader.TestFixtures; +import com.quantai.trader.domain.PositionSizingPlan; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class TraderPositionSizerTest { + + @Test + void allowsFullInitialEntryForStrongSignal() { + TraderPositionSizer sizer = new TraderPositionSizer(TestFixtures.properties()); + + PositionSizingPlan plan = sizer.sizeInitialPlan( + TestFixtures.cycle(com.quantai.trader.enums.TraderState.CONTEXT_CHECK), + TestFixtures.candidate(), + TestFixtures.strongTrigger(), + TestFixtures.pricePlan() + ); + + assertThat(plan.initialLegRatio()).isEqualByComparingTo(BigDecimal.ONE); + assertThat(plan.plannedLegCount()).isEqualTo(1); + assertThat(plan.sizingMethod()).isEqualTo("SIGNAL_EXECUTION_RISK_DYNAMIC"); + } + + @Test + void weakSignalUsesDynamicPartialSizeInsteadOfFixedTemplate() { + TraderPositionSizer sizer = new TraderPositionSizer(TestFixtures.properties()); + var weakTrigger = new com.quantai.trader.domain.TriggerDecision( + true, + new BigDecimal("0.25"), + "TRIGGER_ACCEPTED", + null, + java.util.Map.of() + ); + + PositionSizingPlan plan = sizer.sizeInitialPlan( + TestFixtures.cycle(com.quantai.trader.enums.TraderState.CONTEXT_CHECK), + TestFixtures.candidate(), + weakTrigger, + TestFixtures.pricePlan() + ); + + assertThat(plan.initialLegRatio()).isLessThan(BigDecimal.ONE); + assertThat(plan.plannedLegCount()).isEqualTo(3); + } +} diff --git a/src/test/java/com/quantai/trader/risk/TraderRiskGateTest.java b/src/test/java/com/quantai/trader/risk/TraderRiskGateTest.java new file mode 100644 index 0000000..d47df8c --- /dev/null +++ b/src/test/java/com/quantai/trader/risk/TraderRiskGateTest.java @@ -0,0 +1,63 @@ +package com.quantai.trader.risk; + +import com.quantai.trader.TestFixtures; +import com.quantai.trader.domain.ExecutionDecision; +import com.quantai.trader.domain.TraderRiskDecision; +import com.quantai.trader.persistence.TraderRiskDecisionRepository; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class TraderRiskGateTest { + + @Test + void recordsRiskDecisionForAllowedAction() { + CapturingRiskDecisionRepository repository = new CapturingRiskDecisionRepository(); + TraderRiskGate gate = new TraderRiskGate(TestFixtures.properties(), repository); + + var decision = gate.evaluate( + TestFixtures.cycle(com.quantai.trader.enums.TraderState.ENTRY_PLANNED), + TestFixtures.fullInitialPlan(), + new ExecutionDecision(true, BigDecimal.ONE, "PASS", null, Map.of()) + ); + + assertThat(decision.allowAction()).isTrue(); + assertThat(repository.findByCycleId("trader_run_test", "trader_cycle_test")).hasSize(1); + } + + @Test + void blocksWhenExecutionIsBlockedAndStillRecordsDecision() { + CapturingRiskDecisionRepository repository = new CapturingRiskDecisionRepository(); + TraderRiskGate gate = new TraderRiskGate(TestFixtures.properties(), repository); + + var decision = gate.evaluate( + TestFixtures.cycle(com.quantai.trader.enums.TraderState.ENTRY_PLANNED), + TestFixtures.fullInitialPlan(), + new ExecutionDecision(false, BigDecimal.ZERO, "BAD_EXECUTION", "TRADER_RISK_BLOCKED", Map.of()) + ); + + assertThat(decision.blocked()).isTrue(); + assertThat(repository.findByCycleId("trader_run_test", "trader_cycle_test").getFirst().allowAction()).isFalse(); + } + + private static class CapturingRiskDecisionRepository implements TraderRiskDecisionRepository { + private final List decisions = new ArrayList<>(); + + @Override + public void insert(TraderRiskDecision decision) { + decisions.add(decision); + } + + @Override + public List findByCycleId(String runId, String cycleId) { + return decisions.stream() + .filter(item -> item.runId().equals(runId) && item.cycleId().equals(cycleId)) + .toList(); + } + } +} diff --git a/src/test/java/com/quantai/trader/sample/TrainingSampleExporterTest.java b/src/test/java/com/quantai/trader/sample/TrainingSampleExporterTest.java new file mode 100644 index 0000000..1c317ae --- /dev/null +++ b/src/test/java/com/quantai/trader/sample/TrainingSampleExporterTest.java @@ -0,0 +1,48 @@ +package com.quantai.trader.sample; + +import com.quantai.trader.TestFixtures; +import com.quantai.trader.domain.TraderTrainingSample; +import com.quantai.trader.persistence.TraderSampleRepository; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TrainingSampleExporterTest { + + @Test + void exportsProxyOnlySampleWithFeatureAndLabelVersions() { + CapturingSampleRepository repository = new CapturingSampleRepository(); + TrainingSampleExporter exporter = new TrainingSampleExporter(TestFixtures.properties(), repository); + + var sample = exporter.export( + TestFixtures.cycle(com.quantai.trader.enums.TraderState.SAMPLE_EXPORTED), + TestFixtures.candidate(), + TestFixtures.action(), + TestFixtures.openedPath(false) + ); + + assertThat(sample.proxyOnly()).isTrue(); + assertThat(sample.featureVersion()).isEqualTo("trader_feature_v0"); + assertThat(sample.labelVersion()).isEqualTo("trader_label_v0"); + assertThat(repository.findByRunId("trader_run_test")).hasSize(1); + } + + private static class CapturingSampleRepository implements TraderSampleRepository { + private final List samples = new ArrayList<>(); + + @Override + public void insert(TraderTrainingSample sample) { + samples.add(sample); + } + + @Override + public List findByRunId(String runId) { + return samples.stream() + .filter(item -> item.runId().equals(runId)) + .toList(); + } + } +} diff --git a/src/test/java/com/quantai/trader/state/TraderStateMachineTest.java b/src/test/java/com/quantai/trader/state/TraderStateMachineTest.java new file mode 100644 index 0000000..db5ebb0 --- /dev/null +++ b/src/test/java/com/quantai/trader/state/TraderStateMachineTest.java @@ -0,0 +1,70 @@ +package com.quantai.trader.state; + +import com.quantai.trader.TestFixtures; +import com.quantai.trader.domain.TraderException; +import com.quantai.trader.enums.TraderActionType; +import com.quantai.trader.enums.TraderState; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class TraderStateMachineTest { + + private final TraderStateMachine stateMachine = new TraderStateMachine(); + + @Test + void initialEntryOnlyFromEntryPlanned() { + var action = stateMachine.toInitialEntryAction( + TestFixtures.cycle(TraderState.ENTRY_PLANNED), + TestFixtures.candidate(), + TestFixtures.fullInitialPlan() + ); + + assertThat(action.actionType()).isEqualTo(TraderActionType.OPEN_INITIAL); + } + + @Test + void blocksInitialEntryFromWrongState() { + assertThatThrownBy(() -> stateMachine.toInitialEntryAction( + TestFixtures.cycle(TraderState.CONTEXT_CHECK), + TestFixtures.candidate(), + TestFixtures.fullInitialPlan() + )).isInstanceOf(TraderException.class) + .hasMessageContaining("requires ENTRY_PLANNED"); + } + + @Test + void blocksPlannedLegAfterReduce() { + var plan = new com.quantai.trader.domain.TraderEntryPlan( + "trader_run_test", + "trader_cycle_test", + "trader_action_test_2", + "trader_leg_test_1", + "trader_candidate_test", + TraderActionType.OPEN_PLANNED_LEG, + 1, + 3, + new java.math.BigDecimal("0.20"), + "SIGNAL_EXECUTION_RISK_DYNAMIC", + java.math.BigDecimal.ZERO, + java.math.BigDecimal.ZERO, + java.math.BigDecimal.ONE, + new java.math.BigDecimal("65000"), + new java.math.BigDecimal("64800"), + new java.math.BigDecimal("64920"), + new java.math.BigDecimal("65350"), + null, + 300_000, + 7_200_000, + "TEST_PLANNED_LEG" + ); + + assertThatThrownBy(() -> stateMachine.toPlannedLegAction( + TestFixtures.cycle(TraderState.PLANNED_LEG_WAIT), + plan, + TestFixtures.openedPath(true) + )).isInstanceOf(TraderException.class) + .hasMessageContaining("after reduce"); + } +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 0000000..066b244 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,8 @@ +spring: + datasource: + url: jdbc:mysql://127.0.0.1:3306/quant_app_test_codex?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&allowPublicKeyRetrieval=true&useSSL=false + driver-class-name: com.mysql.cj.jdbc.Driver + username: quant_app + password: quant_app + flyway: + enabled: true diff --git a/src/test/resources/replay-fixtures/README.md b/src/test/resources/replay-fixtures/README.md new file mode 100644 index 0000000..0c9e240 --- /dev/null +++ b/src/test/resources/replay-fixtures/README.md @@ -0,0 +1,22 @@ +P0 replay fixtures +================== + +These JSONL files are deterministic acceptance fixtures for the P0 replay loop. +They are not a profitability backtest corpus. Each line is one replay clock tick +with the schema consumed by JsonlReplayMarketEventReader: + +- eventTime +- contextFeatures +- setupFeatures +- triggerFeatures +- executionFeatures +- dataQuality + +Scenario notes: + +- trend-up-breakout-happy.jsonl: accepted continuation setup; should produce one action and one sample. +- trend-down-no-setup.jsonl: bearish regime without a valid long continuation setup; should complete with no action. +- sideways-range-no-setup.jsonl: range regime without a setup; should complete with no action. +- false-breakout-trigger-wait.jsonl: setup exists but trigger score is too weak; should complete with no action. +- missing-features-data-quality.jsonl: data-quality blocker; should complete with a blocked sample. +- incomplete-entry-plan-hard-fail.jsonl: setupPass is true but required prices are missing; should fail the replay run. diff --git a/src/test/resources/replay-fixtures/false-breakout-trigger-wait.jsonl b/src/test/resources/replay-fixtures/false-breakout-trigger-wait.jsonl new file mode 100644 index 0000000..7121d40 --- /dev/null +++ b/src/test/resources/replay-fixtures/false-breakout-trigger-wait.jsonl @@ -0,0 +1 @@ +{"eventTime":"2026-01-01T03:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"UP","marketStructure":"weak_breakout_retest","timeframeAlignment":"1h_up_4h_flat"},"setupFeatures":{"setupPass":true,"setupName":"breakout_retest_continuation","entryPrice":"65120","invalidPrice":"64980","stopPrice":"65040","targetPrice":"65450","executionQualityScore":"0.76","volumeImpulse":"0.88","retestHold":true},"triggerFeatures":{"triggerScore":"0.32","triggerName":"false_breakout_probe","breakoutFollowThrough":false},"executionFeatures":{"lastPrice":"65105","spreadBps":"1.4","bookImbalance":"0.49"},"dataQuality":{"missing_features":[]}} diff --git a/src/test/resources/replay-fixtures/incomplete-entry-plan-hard-fail.jsonl b/src/test/resources/replay-fixtures/incomplete-entry-plan-hard-fail.jsonl new file mode 100644 index 0000000..ef14beb --- /dev/null +++ b/src/test/resources/replay-fixtures/incomplete-entry-plan-hard-fail.jsonl @@ -0,0 +1 @@ +{"eventTime":"2026-01-01T05:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"UP","marketStructure":"breakout_retest"},"setupFeatures":{"setupPass":true,"setupName":"breakout_retest_continuation","executionQualityScore":"0.90"},"triggerFeatures":{"triggerScore":"0.95","triggerName":"micro_continuation"},"executionFeatures":{"lastPrice":"65300"},"dataQuality":{"missing_features":[]}} diff --git a/src/test/resources/replay-fixtures/missing-features-data-quality.jsonl b/src/test/resources/replay-fixtures/missing-features-data-quality.jsonl new file mode 100644 index 0000000..6b02b7d --- /dev/null +++ b/src/test/resources/replay-fixtures/missing-features-data-quality.jsonl @@ -0,0 +1 @@ +{"eventTime":"2026-01-01T04:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"UP","marketStructure":"breakout_retest"},"setupFeatures":{"setupPass":true,"setupName":"breakout_retest_continuation","entryPrice":"65200","invalidPrice":"65020","stopPrice":"65100","targetPrice":"65580","executionQualityScore":"0.84"},"triggerFeatures":{"triggerScore":"0.91","triggerName":"micro_continuation"},"executionFeatures":{"lastPrice":"65210"},"dataQuality":{"missing_features":["level_1.best_bid","level_1.best_ask"]}} diff --git a/src/test/resources/replay-fixtures/sideways-range-no-setup.jsonl b/src/test/resources/replay-fixtures/sideways-range-no-setup.jsonl new file mode 100644 index 0000000..40cf49c --- /dev/null +++ b/src/test/resources/replay-fixtures/sideways-range-no-setup.jsonl @@ -0,0 +1 @@ +{"eventTime":"2026-01-01T02:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"RANGE","marketStructure":"mid_range_chop","timeframeAlignment":"mixed"},"setupFeatures":{"setupPass":false,"setupName":"range_filter_rejected","rejectReason":"no_clean_platform_breakout"},"triggerFeatures":{"triggerScore":"0.35","triggerName":"range_noise"},"executionFeatures":{"lastPrice":"64680","spreadBps":"1.1"},"dataQuality":{"missing_features":[]}} diff --git a/src/test/resources/replay-fixtures/trend-down-no-setup.jsonl b/src/test/resources/replay-fixtures/trend-down-no-setup.jsonl new file mode 100644 index 0000000..d7a9b84 --- /dev/null +++ b/src/test/resources/replay-fixtures/trend-down-no-setup.jsonl @@ -0,0 +1 @@ +{"eventTime":"2026-01-01T01:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"DOWN","marketStructure":"lower_high_break","timeframeAlignment":"1h_4h_down"},"setupFeatures":{"setupPass":false,"setupName":"long_continuation_rejected","rejectReason":"trend_direction_mismatch"},"triggerFeatures":{"triggerScore":"0.20","triggerName":"none"},"executionFeatures":{"lastPrice":"64240","spreadBps":"0.9"},"dataQuality":{"missing_features":[]}} diff --git a/src/test/resources/replay-fixtures/trend-up-breakout-happy.jsonl b/src/test/resources/replay-fixtures/trend-up-breakout-happy.jsonl new file mode 100644 index 0000000..91e4369 --- /dev/null +++ b/src/test/resources/replay-fixtures/trend-up-breakout-happy.jsonl @@ -0,0 +1 @@ +{"eventTime":"2026-01-01T00:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"UP","marketStructure":"breakout_retest","timeframeAlignment":"1h_4h_up"},"setupFeatures":{"setupPass":true,"setupName":"breakout_retest_continuation","entryPrice":"65000","invalidPrice":"64800","stopPrice":"64920","targetPrice":"65350","executionQualityScore":"0.92","volumeImpulse":"1.48","retestHold":true},"triggerFeatures":{"triggerScore":"0.95","triggerName":"micro_continuation","breakoutFollowThrough":true},"executionFeatures":{"lastPrice":"65010","spreadBps":"0.8","bookImbalance":"0.57"},"dataQuality":{"missing_features":[]}}