Rewrite trader service for V4 P0

This commit is contained in:
Codex
2026-06-26 21:53:22 +08:00
parent 2fe4077164
commit 5d210053d0
184 changed files with 2780 additions and 6945 deletions
@@ -1,12 +0,0 @@
package com.quantai.trader;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class QuantTraderServiceApplicationTest {
@Test
void contextLoads() {
}
}
+158 -144
View File
@@ -1,18 +1,12 @@
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.TraderMarketSnapshot;
import com.quantai.trader.domain.TraderPositionPath;
import com.quantai.trader.domain.TraderPricePlan;
import com.quantai.trader.domain.TriggerDecision;
import com.quantai.trader.domain.*;
import com.quantai.trader.enums.PositionSide;
import com.quantai.trader.enums.TraderActionType;
import com.quantai.trader.enums.TraderExecutionMode;
import com.quantai.trader.enums.TraderRunMode;
import com.quantai.trader.enums.TraderSide;
import com.quantai.trader.enums.TraderState;
import com.quantai.trader.risk.RiskLimits;
import java.math.BigDecimal;
import java.time.Instant;
@@ -20,160 +14,180 @@ import java.util.List;
import java.util.Map;
public final class TestFixtures {
public static final Instant NOW = Instant.parse("2026-06-23T12:00:00Z");
public static final Instant T0 = Instant.parse("2026-06-26T00:00:00Z");
private TestFixtures() {
}
public static BigDecimal bd(String value) {
return new BigDecimal(value);
}
public static TraderProperties properties() {
return new TraderProperties();
return properties(TraderRunMode.SHADOW, TraderExecutionMode.SHADOW, false, false);
}
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 TraderProperties properties(TraderRunMode runMode, TraderExecutionMode executionMode,
boolean tradingEnabled, boolean feedbackHttpEnabled) {
return new TraderProperties(
"quant-trader-service-test",
runMode,
"BTC-USDT-PERP",
new TraderProperties.Artifact("trader-v4-btc-p0", "cal-v4-btc-p0", "pm-v4-btc-p0", "/tmp/trader-v4-p0"),
new TraderProperties.Feedback(feedbackHttpEnabled),
new TraderProperties.Execution(executionMode, 3, 1500),
new TraderProperties.Runtime("trader:v4:test", true, tradingEnabled),
new TraderProperties.Outbox(true, 5),
new TraderProperties.Release(true, true, true),
new TraderProperties.Risk(bd("200"), BigDecimal.ONE, bd("500")),
new TraderProperties.PositionManager(BigDecimal.ONE, BigDecimal.ONE));
}
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 TraderPmConfig pmConfig() {
return new TraderPmConfig(
"pm-v4-btc-p0",
new TraderPmConfig.OpenRuleConfig(
bd("0.58"), bd("0.58"), bd("0.55"), bd("0.55"),
bd("0.45"), bd("1.0"), bd("0.03"), bd("0.10"), bd("0.80")),
new TraderPmConfig.AddRuleConfig(
bd("0.60"), bd("0.60"), bd("0.58"), bd("0.55"), bd("0.45"),
bd("0.45"), bd("0.50"), bd("1.0"), BigDecimal.ZERO, bd("0.10"),
bd("500"), 3, 5),
new TraderPmConfig.ExitRuleConfig(
bd("0.70"), bd("0.70"), bd("0.70"), bd("0.25"), bd("0.62"),
bd("0.35"), bd("0.70"), bd("5.0"), bd("80")),
new TraderPmConfig.SizingConfig(
bd("0.80"), bd("0.05"), BigDecimal.ONE, bd("0.02"), bd("0.25"),
BigDecimal.ONE, bd("1.0"), bd("80"), bd("0.20"), bd("0.50"), bd("500")));
}
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 TraderDecisionCycle cycle() {
return new TraderDecisionCycle("run-1", "cycle-1", "BTC-USDT-PERP", T0,
TraderRunMode.SHADOW, "trader-v4-btc-p0", "cal-v4-btc-p0", "pm-v4-btc-p0");
}
public static TraderMarketSnapshot labeledSnapshot() {
return new TraderMarketSnapshot(
"trader_run_test",
"trader_cycle_test",
"trader_snapshot_test",
"BTCUSDT",
NOW,
"trader_feature_v0",
Map.of("contextPass", true),
Map.of("setupPass", true, "side", "LONG"),
Map.of("triggerScore", "0.95"),
Map.of("lastPrice", "65010"),
Map.of("missing_features", List.of()),
Map.ofEntries(
Map.entry("labelSource", "CRYPTO_LAKE_1M_REPLAY"),
Map.entry("labelStatus", "REPLAY_MARKOUT_LABELED"),
Map.entry("side", "LONG"),
Map.entry("entryPrice", "65000"),
Map.entry("markoutBps1m", "5"),
Map.entry("markoutBps5m", "12"),
Map.entry("markoutBps15m", "24"),
Map.entry("mfeBps15m", "30"),
Map.entry("maeBps15m", "6"),
Map.entry("targetBeforeStop15m", true),
Map.entry("expectedSlippageBps", "1")
)
);
public static TraderMarketSnapshot snapshot() {
return snapshot(true, "1000");
}
public static TriggerDecision strongTrigger() {
return new TriggerDecision(true, new BigDecimal("0.95"), "TRIGGER_ACCEPTED", null, Map.of());
public static TraderMarketSnapshot snapshot(boolean dataReady, String depthNotional5Bps) {
return new TraderMarketSnapshot("snapshot-1", "run-1", "cycle-1", "BTC-USDT-PERP", T0,
"feature-v4-p0", bd("100"), bd("99.5"), bd("1.2"), BigDecimal.ZERO,
bd(depthNotional5Bps), bd(depthNotional5Bps), bd(depthNotional5Bps), dataReady, Map.of(), 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 TraderModelOutput modelOutput() {
return modelOutput("0.70", "0.20", "0.70", "0.30", "0.66", "0.34",
"0.20", "0.50", "0.18", "0.12", "1.0", "12", "4", "0.10", "0.05", "20");
}
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 TraderModelOutput shortModelOutput() {
return modelOutput("0.20", "0.70", "0.30", "0.70", "0.34", "0.66",
"0.50", "0.20", "0.18", "0.12", "1.0", "12", "4", "0.10", "0.05", "20");
}
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"
);
public static TraderModelOutput modelOutput(String longProb, String shortProb,
String longEntryProb, String shortEntryProb,
String longContinueProb, String shortContinueProb,
String longExitProb, String shortExitProb,
String marketRiskProb, String positionRiskProb,
String liquidityCapacityRatio, String expectedEdgeBps,
String continueVsExitEdgeBps, String uncertainty,
String oodScore, String expectedShortfallBps) {
BigDecimal longP = bd(longProb);
BigDecimal shortP = bd(shortProb);
BigDecimal neutral = BigDecimal.ONE.subtract(longP).subtract(shortP);
return new TraderModelOutput(
"model-output-1",
"run-1",
"cycle-1",
"trader-v4-btc-p0",
"cal-v4-btc-p0",
new DirectionOutput(longP, shortP, neutral, longP.max(shortP), longP.subtract(shortP).abs(),
bd("8.0"), 45, "direction-p0", "cal-v4-btc-p0", Map.of()),
new EntryOutput(bd(longEntryProb), bd(shortEntryProb), bd("0.70"), bd(expectedEdgeBps),
"p0-plan-atr-2r", "p0-price-plan-hash", bd("35"), bd("70"), 45, bd("4.0"),
"entry-p0", "cal-v4-btc-p0", Map.of()),
new ContinueOutput(bd(longContinueProb), bd(shortContinueProb), bd("0.60"), bd("5.0"),
bd(continueVsExitEdgeBps), "continue-p0", "cal-v4-btc-p0", Map.of()),
new ExitOutput(bd(longExitProb), bd(shortExitProb), bd("0.20"), bd("0.25"), bd("0.22"),
bd("0.20"), bd("10"), "exit-p0", "cal-v4-btc-p0", Map.of()),
new RiskOutput(bd(marketRiskProb), bd(positionRiskProb), bd("20"), bd("18"), bd("0.15"),
bd(expectedShortfallBps), bd("0.20"), bd("0.10"), bd("0.12"),
bd(liquidityCapacityRatio), "risk-p0", "cal-v4-btc-p0", Map.of()),
bd(uncertainty),
bd(oodScore),
Map.of("fixture", "p0"));
}
public static PositionManagerInput pmInput(TraderModelOutput modelOutput, TraderPositionState positionState) {
return pmInput(modelOutput, positionState, account(), execution());
}
public static PositionManagerInput pmInput(TraderModelOutput modelOutput, TraderPositionState positionState,
TraderAccountState accountState, TraderExecutionState executionState) {
return new PositionManagerInput(cycle(), snapshot(), modelOutput, positionState, accountState, executionState, pmConfig());
}
public static TraderPositionState flatPosition() {
return new TraderPositionState("position-state-1", "run-1", "cycle-1", "BTC-USDT-PERP",
PositionSide.NONE, BigDecimal.ZERO, null, bd("100"), BigDecimal.ZERO, bd("1000"),
0, BigDecimal.ONE, null);
}
public static TraderPositionState longPosition(String unrealizedPnlBps) {
return position(PositionSide.LONG, unrealizedPnlBps, 0, "0.40");
}
public static TraderPositionState shortPosition(String unrealizedPnlBps) {
return position(PositionSide.SHORT, unrealizedPnlBps, 0, "0.40");
}
public static TraderPositionState position(PositionSide side, String unrealizedPnlBps, int addCount, String remainingAddCapacity) {
return new TraderPositionState("position-state-1", "run-1", "cycle-1", "BTC-USDT-PERP",
side, bd("0.30"), bd("100"), bd("101"), bd(unrealizedPnlBps), bd("1000"),
addCount, bd(remainingAddCapacity), null);
}
public static TraderAccountState account() {
return account("0", "0", "1.0");
}
public static TraderAccountState account(String dailyDrawdownBps, String exposureRatio, String remainingSymbolCapacityRatio) {
return new TraderAccountState("account-state-1", "run-1", "cycle-1",
bd(dailyDrawdownBps), bd(exposureRatio), bd(remainingSymbolCapacityRatio), 0);
}
public static TraderExecutionState execution() {
return execution(List.of(), 10, 0);
}
public static TraderExecutionState executionWithOpenOrder() {
return execution(List.of(new OpenOrderState("order-1", "NEW")), 10, 0);
}
public static TraderExecutionState execution(List<OpenOrderState> openOrders, long latencyMs, int apiErrorCount) {
return new TraderExecutionState("execution-state-1", "run-1", "cycle-1", "BTC-USDT-PERP",
openOrders, bd("1.5"), latencyMs, apiErrorCount, bd("1"), bd("4"), bd("5"),
bd("0.1"), bd("0.001"), bd("0.001"), BigDecimal.ONE);
}
public static TraderPositionManagerDecision pmDecision(TraderActionType action, PositionSide side) {
return new TraderPositionManagerDecision(
"pm-cycle-1", "run-1", "cycle-1", "model-output-1", "position-state-1",
"account-state-1", "execution-state-1", action, side,
action.increasesExposure() ? "p0-plan-atr-2r" : null,
action.increasesExposure() ? "p0-price-plan-hash" : null,
action == TraderActionType.OPEN_LONG || action == TraderActionType.OPEN_SHORT ? bd("0.20") : null,
action == TraderActionType.ADD_LONG || action == TraderActionType.ADD_SHORT ? bd("0.10") : null,
action.reducesExposure() ? bd("0.50") : null,
action.increasesExposure() ? bd("99.65") : null,
action.increasesExposure() ? bd("100.70") : null,
"fixture",
Map.of("fixture", "p0"));
}
public static RiskLimits riskLimits() {
return new RiskLimits(bd("200"), BigDecimal.ONE, bd("500"), 3, 1500, false, false);
}
}
@@ -0,0 +1,38 @@
package com.quantai.trader.artifact;
import com.quantai.trader.config.TraderProperties;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static com.quantai.trader.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class TraderArtifactLoaderTest {
@Test
void deterministicP0BundleProvidesAllFiveModelFamilies() {
TraderArtifactBundle bundle = new TraderArtifactLoader(properties()).loadActiveBundle();
assertThat(bundle.providedModels()).containsExactlyInAnyOrder("DIRECTION", "ENTRY", "CONTINUE", "EXIT", "RISK");
assertThat(bundle.pmConfig().pmConfigVersion()).isEqualTo("pm-v4-btc-p0");
}
@Test
void rejectsArtifactConfigWithBlankVersionInsteadOfReplacingIt() {
assertThatThrownBy(() -> new TraderProperties.Artifact(" ", "cal-v4-btc-p0", "pm-v4-btc-p0", "/tmp/trader"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("artifact.modelBundleVersion");
}
@Test
void bundleContractRejectsMissingModelFamily() {
TraderArtifactBundle bundle = new TraderArtifactLoader(properties()).loadActiveBundle();
assertThatThrownBy(() -> new TraderArtifactBundle(
bundle.modelBundleVersion(), bundle.calibrationBundleVersion(), bundle.pmConfigVersion(),
bundle.bundleHashSha256(), Set.of("DIRECTION", "ENTRY", "CONTINUE", "RISK"), bundle.pmConfig()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("all five");
}
}
@@ -1,90 +0,0 @@
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 java.util.UUID;
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() {
String runId = "trader_run_runner_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
ReplayClockTick tick = new ReplayClockTick(
runId,
"BTCUSDT",
Instant.parse("2026-06-23T12:00:00Z"),
Map.of("contextPass", true),
Map.of(
"setupPass", true,
"side", "LONG",
"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(),
Map.of()
);
TraderCycleResult result = runner.runReplayTick(
tick,
new TraderRuntimeState(
runId,
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, "side", "LONG"),
Map.of("triggerScore", "0.95"),
Map.of("lastPrice", "65010"),
Map.of(),
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");
}
}
@@ -1,135 +1,86 @@
package com.quantai.trader.controller;
import com.quantai.trader.domain.FeedbackValidator;
import com.quantai.trader.domain.TraderAppFeedback;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.enums.FeedbackSource;
import com.quantai.trader.enums.TraderErrorCode;
import com.quantai.trader.enums.TraderExecutionMode;
import com.quantai.trader.enums.TraderRunMode;
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 org.springframework.http.ResponseEntity;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Map;
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;
import static com.quantai.trader.TestFixtures.bd;
import static com.quantai.trader.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@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")));
void feedbackEndpointRejectsWhenHttpFeedbackIsDisabled() {
TraderFeedbackController controller = new TraderFeedbackController(properties(), new FeedbackValidator());
assertThatThrownBy(() -> controller.feedback(feedback(FeedbackSource.SHADOW_APP, false)))
.isInstanceOf(TraderException.class)
.hasMessageContaining("HTTP feedback is disabled");
}
@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"));
void feedbackEndpointRejectsPaperAppSourceInP0EvenWhenHttpIsEnabledForTest() {
TraderFeedbackController controller = new TraderFeedbackController(
properties(TraderRunMode.SHADOW, TraderExecutionMode.SHADOW, false, true), new FeedbackValidator());
assertThatThrownBy(() -> controller.feedback(feedback(FeedbackSource.PAPER_APP, true)))
.isInstanceOf(TraderException.class)
.hasMessageContaining("P0 rejects");
}
@Test
void createsReplayRunAsynchronously() throws Exception {
Path fixture = Path.of(new ClassPathResource("replay-fixtures/trend-up-breakout-happy.jsonl").getFile().toURI());
void feedbackEndpointAcceptsShadowRecorderFeedbackWhenExplicitlyEnabled() {
TraderFeedbackController controller = new TraderFeedbackController(
properties(TraderRunMode.SHADOW, TraderExecutionMode.SHADOW, false, true), new FeedbackValidator());
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();
Map<String, Object> result = controller.feedback(feedback(FeedbackSource.SHADOW_APP, false));
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.replayEngine").value("jsonl_fixture"));
assertThat(result).containsEntry("accepted", true).containsEntry("feedbackId", "feedback-1");
}
@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"));
void healthEndpointReportsP0RuntimeIdentity() {
Map<String, Object> health = new TraderHealthController(properties()).health();
assertThat(health).containsEntry("status", "UP")
.containsEntry("runMode", TraderRunMode.SHADOW)
.containsEntry("executionMode", TraderExecutionMode.SHADOW)
.containsEntry("tradingEnabled", false)
.containsEntry("modelBundleVersion", "trader-v4-btc-p0");
}
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"));
@Test
void exceptionHandlerKeepsTraderErrorCodeVisible() {
TraderApiExceptionHandler handler = new TraderApiExceptionHandler();
ResponseEntity<TraderApiError> response = handler.traderException(
new TraderException(TraderErrorCode.TRADER_P0_MODE_BLOCKED, "P0 blocked"));
assertThat(response.getStatusCode().is4xxClientError()).isTrue();
assertThat(response.getBody()).isEqualTo(new TraderApiError(TraderErrorCode.TRADER_P0_MODE_BLOCKED, "P0 blocked"));
}
private TraderAppFeedback feedback(FeedbackSource source, boolean realFill) {
return new TraderAppFeedback(
"feedback-1", "run-1", "cycle-1", "action-1", source, realFill,
realFill ? "order-1" : null, realFill ? "FILLED" : "RECORDED",
Instant.parse("2026-06-26T00:00:00Z"),
null, realFill ? Instant.parse("2026-06-26T00:00:01Z") : null,
realFill ? bd("100") : null,
realFill ? bd("0.01") : null,
realFill ? bd("0.001") : null,
realFill ? bd("1.0") : null,
null,
Map.of());
}
}
@@ -0,0 +1,48 @@
package com.quantai.trader.core;
import com.quantai.trader.domain.TraderActionFactory;
import com.quantai.trader.domain.TraderRiskDecision;
import com.quantai.trader.enums.FeedbackSource;
import com.quantai.trader.enums.PositionSide;
import com.quantai.trader.enums.TraderActionType;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import static com.quantai.trader.TestFixtures.*;
import static org.assertj.core.api.Assertions.assertThat;
class TraderCoreDtoTest {
@Test
void coreDecisionRequestAndResponseCarryStrictP0DecisionContext() {
TraderCoreDecisionRequest request = new TraderCoreDecisionRequest(
"request-1", "run-1", "cycle-1", "BTC-USDT-PERP", T0,
snapshot(), flatPosition(), account(), execution(),
"trader-v4-btc-p0", "cal-v4-btc-p0", "pm-v4-btc-p0", Map.of("mode", "SHADOW"));
TraderRiskDecision riskDecision = new TraderRiskDecision("risk-1", "run-1", "cycle-1",
"pm-cycle-1", true, TraderActionType.OPEN_LONG, TraderActionType.OPEN_LONG, null, Map.of());
var action = new TraderActionFactory().create(pmDecision(TraderActionType.OPEN_LONG, PositionSide.LONG), riskDecision, "BTC-USDT-PERP");
TraderCoreDecisionResponse response = new TraderCoreDecisionResponse(
"response-1", request.requestId(), request.cycleId(), action.modelOutputId(),
action.pmDecisionId(), action.riskDecisionId(), true, action, null, Map.of("source", "core"));
assertThat(response.actionAllowed()).isTrue();
assertThat(response.action().actionType()).isEqualTo(TraderActionType.OPEN_LONG);
assertThat(request.requestContextJson()).containsEntry("mode", "SHADOW");
}
@Test
void coreFeedbackAndHealthDtosStayModeExplicit() {
TraderCoreFeedbackEvent feedback = new TraderCoreFeedbackEvent(
"feedback-1", "action-1", FeedbackSource.REPLAY_SIMULATOR, false, null, "RECORDED",
null, null, null, null, null, T0, Map.of("source", "replay"));
TraderCoreHealth health = new TraderCoreHealth(true, "SHADOW", "trader-v4-btc-p0",
"cal-v4-btc-p0", "pm-v4-btc-p0", null);
assertThat(feedback.feedbackSource()).isEqualTo(FeedbackSource.REPLAY_SIMULATOR);
assertThat(feedback.realFill()).isFalse();
assertThat(health.ready()).isTrue();
assertThat(health.blocker()).isNull();
}
}
@@ -0,0 +1,69 @@
package com.quantai.trader.domain;
import com.quantai.trader.enums.FeedbackSource;
import com.quantai.trader.enums.PositionSide;
import com.quantai.trader.enums.TraderActionType;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.Map;
import static com.quantai.trader.TestFixtures.bd;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class ModelOutputContractTest {
@Test
void rejectsProbabilityOutsideClosedUnitRange() {
assertThatThrownBy(() -> new DirectionOutput(
bd("1.01"), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ONE, BigDecimal.ONE,
bd("1"), 45, "direction-p0", "cal-v4-btc-p0", Map.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("direction.longProb");
}
@Test
void requiresPricePlanIdentityForExposureIncreasingDecision() {
assertThatThrownBy(() -> new TraderPositionManagerDecision(
"pm-1", "run-1", "cycle-1", "model-output-1", "position-state-1",
"account-state-1", "execution-state-1", TraderActionType.OPEN_LONG, PositionSide.LONG,
"p0-plan-atr-2r", " ", bd("0.20"), null, null, bd("99.65"), bd("100.70"),
"OPEN_LONG_PM_PASS", Map.of()))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("pricePlanConfigHash");
}
@Test
void rejectsImpossibleFillSourceCombinations() {
assertThatThrownBy(() -> feedback(FeedbackSource.SHADOW_APP, true))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("realFill requires");
assertThatThrownBy(() -> feedback(FeedbackSource.PAPER_APP, false))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("must be realFill");
}
@Test
void p0FeedbackValidatorRejectsPaperAndRealFillSources() {
TraderAppFeedback paperFill = feedback(FeedbackSource.PAPER_APP, true);
assertThatThrownBy(() -> new FeedbackValidator().validateP0(paperFill))
.isInstanceOf(TraderException.class)
.hasMessageContaining("P0 rejects");
}
private TraderAppFeedback feedback(FeedbackSource source, boolean realFill) {
return new TraderAppFeedback(
"feedback-1", "run-1", "cycle-1", "action-1", source, realFill,
realFill ? "order-1" : null, realFill ? "FILLED" : "RECORDED",
Instant.parse("2026-06-26T00:00:00Z"),
null, realFill ? Instant.parse("2026-06-26T00:00:01Z") : null,
realFill ? bd("100") : null,
realFill ? bd("0.01") : null,
realFill ? bd("0.001") : null,
realFill ? bd("1.0") : null,
null,
Map.of());
}
}
@@ -1,53 +0,0 @@
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()
);
}
}
@@ -1,51 +0,0 @@
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"
);
}
}
@@ -1,50 +1,23 @@
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 java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class EvidenceAppenderTest {
@Test
void appendsBlockingEvidenceToRepository() {
CapturingEvidenceRepository repository = new CapturingEvidenceRepository();
EvidenceAppender appender = new EvidenceAppender(repository);
void appendsEvidenceWithStageReasonAndDetails() {
EvidenceAppender appender = new EvidenceAppender();
appender.append(
TestFixtures.cycle(com.quantai.trader.enums.TraderState.CONTEXT_CHECK),
"CONTEXT_GATE",
StageDecision.block("DATA_MISSING", "TRADER_DATA_QUALITY_FAILED")
);
TraderEvidence item = appender.append("run-1", "cycle-1", "PM_DECISION", true,
"OPEN_LONG_PM_PASS", null, Map.of("action", "OPEN_LONG"));
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<TraderEvidence> evidence = new ArrayList<>();
@Override
public void insert(TraderEvidence evidence) {
this.evidence.add(evidence);
}
@Override
public List<TraderEvidence> findByCycleId(String runId, String cycleId) {
return evidence.stream()
.filter(item -> item.runId().equals(runId) && item.cycleId().equals(cycleId))
.toList();
}
assertThat(item.evidenceId()).isEqualTo("evidence_cycle-1_0");
assertThat(item.pass()).isTrue();
assertThat(item.detailsJson()).containsEntry("action", "OPEN_LONG");
assertThat(appender.all()).containsExactly(item);
}
}
@@ -1,67 +0,0 @@
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();
}
}
@@ -0,0 +1,40 @@
package com.quantai.trader.outbox;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InMemoryOutboxRepositoryTest {
@Test
void rejectsDuplicateDestinationAndIdempotencyKey() {
InMemoryOutboxRepository repository = new InMemoryOutboxRepository();
TraderOutboxEvent event = event("outbox-1", "SHADOW_RECORDER", "idem-1");
repository.insert(event);
assertThatThrownBy(() -> repository.insert(event("outbox-2", "SHADOW_RECORDER", "idem-1")))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("duplicate outbox idempotency key");
assertThat(repository.all()).containsExactly(event);
}
@Test
void allowsSameIdempotencyKeyForDifferentDestination() {
InMemoryOutboxRepository repository = new InMemoryOutboxRepository();
repository.insert(event("outbox-1", "REPLAY_SIM_RECORDER", "idem-1"));
repository.insert(event("outbox-2", "SHADOW_RECORDER", "idem-1"));
assertThat(repository.all()).hasSize(2);
}
private TraderOutboxEvent event(String id, String destination, String idempotencyKey) {
return new TraderOutboxEvent(id, "run-1", "cycle-1", "TRADER_ACTION", "action-1",
"ACTION_CREATED", destination, Map.of("actionType", "OPEN_LONG"), idempotencyKey,
"PENDING", Instant.parse("2026-06-26T00:00:00Z"));
}
}
@@ -1,102 +0,0 @@
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("replayEngine"))
.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()
))
);
}
}
@@ -1,97 +0,0 @@
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);
}
}
@@ -1,93 +0,0 @@
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<String> 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);
}
}
@@ -1,63 +1,86 @@
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.domain.TraderModelOutput;
import com.quantai.trader.domain.TraderPositionManagerDecision;
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;
import static com.quantai.trader.TestFixtures.*;
import static org.assertj.core.api.Assertions.assertThat;
class TraderPositionManagerTest {
private final TraderPositionManager positionManager = new TraderPositionManager();
@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"
);
void opensLongWithDynamicRatioAndSideAwareBpsPrices() {
TraderPositionManagerDecision decision = positionManager.decide(pmInput(modelOutput(), flatPosition()));
assertThatThrownBy(() -> manager.simulateOrUpdate(
TestFixtures.cycle(com.quantai.trader.enums.TraderState.PLANNED_LEG_WAIT),
action,
snapshot()
))
.isInstanceOf(TraderException.class)
.hasMessageContaining("not found");
assertThat(decision.candidateAction()).isEqualTo(TraderActionType.OPEN_LONG);
assertThat(decision.targetPositionRatio()).isGreaterThan(bd("0.05")).isLessThanOrEqualTo(bd("0.20"));
assertThat(decision.stopPrice()).isEqualByComparingTo("99.65");
assertThat(decision.targetPrice()).isEqualByComparingTo("100.70");
assertThat(decision.pricePlanConfigHash()).isEqualTo("p0-price-plan-hash");
}
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(),
Map.of()
);
@Test
void opensShortWithInvertedStopAndTargetBpsPrices() {
TraderPositionManagerDecision decision = positionManager.decide(pmInput(shortModelOutput(), flatPosition()));
assertThat(decision.candidateAction()).isEqualTo(TraderActionType.OPEN_SHORT);
assertThat(decision.stopPrice()).isEqualByComparingTo("100.35");
assertThat(decision.targetPrice()).isEqualByComparingTo("99.30");
}
@Test
void waitsWhenDataOrLiquidityIsInsufficient() {
TraderModelOutput noLiquidity = modelOutput("0.70", "0.20", "0.70", "0.30", "0.66", "0.34",
"0.20", "0.50", "0.18", "0.12", "0", "12", "4", "0.10", "0.05", "20");
assertThat(positionManager.decide(pmInput(noLiquidity, flatPosition())).candidateAction())
.isEqualTo(TraderActionType.WAIT);
assertThat(positionManager.decide(new com.quantai.trader.domain.PositionManagerInput(
cycle(), snapshot(false, "1000"), modelOutput(), flatPosition(), account(), execution(), pmConfig())).candidateAction())
.isEqualTo(TraderActionType.WAIT);
}
@Test
void addsOnlyWhenExistingPositionIsProfitable() {
TraderPositionManagerDecision add = positionManager.decide(pmInput(modelOutput(), longPosition("10")));
TraderPositionManagerDecision hold = positionManager.decide(pmInput(modelOutput(), longPosition("-1")));
assertThat(add.candidateAction()).isEqualTo(TraderActionType.ADD_LONG);
assertThat(add.addRatio()).isEqualByComparingTo("0.032");
assertThat(hold.candidateAction()).isEqualTo(TraderActionType.HOLD);
}
@Test
void usesPositionSideContinuationProbabilityForShortAdd() {
TraderPositionManagerDecision decision = positionManager.decide(pmInput(shortModelOutput(), shortPosition("10")));
assertThat(decision.candidateAction()).isEqualTo(TraderActionType.ADD_SHORT);
assertThat(decision.addRatio()).isEqualByComparingTo("0.032");
}
@Test
void closesExistingPositionWhenExitOrRiskSignalIsHigh() {
TraderModelOutput exitHigh = modelOutput("0.70", "0.20", "0.70", "0.30", "0.66", "0.34",
"0.80", "0.50", "0.18", "0.12", "1.0", "12", "4", "0.10", "0.05", "20");
TraderPositionManagerDecision decision = positionManager.decide(pmInput(exitHigh, longPosition("10")));
assertThat(decision.candidateAction()).isEqualTo(TraderActionType.CLOSE_LONG);
assertThat(decision.reason()).isEqualTo("EXIT_OR_RISK_HIGH");
}
@Test
void returnsZeroInitialRatioWhenExpectedEdgeIsBelowSizingFloor() {
TraderModelOutput weakEdge = modelOutput("0.70", "0.20", "0.70", "0.30", "0.66", "0.34",
"0.20", "0.50", "0.18", "0.12", "1.0", "0.5", "4", "0.10", "0.05", "20");
BigDecimal ratio = positionManager.calculateInitialRatio(pmInput(weakEdge, flatPosition()), com.quantai.trader.enums.PositionSide.LONG);
assertThat(ratio).isEqualByComparingTo(BigDecimal.ZERO);
}
}
@@ -0,0 +1,48 @@
package com.quantai.trader.quality;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
class NoLegacyTraderTermsTest {
private static final Pattern FORBIDDEN = Pattern.compile(
"Playbook|Trigger|Markout|MARKET_PROXY|fallback|\\u515c\\u5e95|latest-promoted|longEntryScore|marketRiskScore|min_order_size|quantity_step_size");
@Test
void mainSourceAndMigrationsDoNotReintroduceOldTraderContracts() throws IOException {
List<Path> files;
try (Stream<Path> stream = Stream.concat(
Files.walk(Path.of("src/main/java")).filter(Files::isRegularFile),
Stream.concat(
Files.walk(Path.of("src/main/resources")).filter(Files::isRegularFile),
Files.walk(Path.of("src/test/resources")).filter(Files::isRegularFile)))) {
files = stream.toList();
}
List<String> hits = files.stream()
.flatMap(path -> matches(path).stream())
.toList();
assertThat(hits).isEmpty();
}
private List<String> matches(Path path) {
try {
String text = Files.readString(path, StandardCharsets.UTF_8);
if (FORBIDDEN.matcher(text).find()) {
return List.of(path.toString());
}
return List.of();
} catch (IOException exception) {
throw new IllegalStateException("failed to scan " + path, exception);
}
}
}
@@ -1,76 +0,0 @@
package com.quantai.trader.replay;
import org.junit.jupiter.api.Test;
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;
class CryptoLakeReplayCsvMarketEventReaderTest {
private final CryptoLakeReplayCsvMarketEventReader reader = new CryptoLakeReplayCsvMarketEventReader();
@Test
void readsCandidateTicksAndRecomputesReplayMarkoutLabels() throws Exception {
ReplayRunConfig config = config();
var ticks = reader.readTicks(config);
assertThat(ticks).hasSize(2);
assertThat(ticks.getFirst().setupFeatures())
.containsEntry("setupPass", true)
.containsEntry("side", "LONG")
.containsEntry("entryPrice", "100");
assertThat(ticks.getFirst().labelInputs())
.containsEntry("labelStatus", "REPLAY_MARKOUT_LABELED")
.containsEntry("side", "LONG")
.containsEntry("markoutBps15m", "200");
assertThat(ticks.get(1).setupFeatures())
.containsEntry("side", "SHORT");
assertThat(ticks.get(1).labelInputs())
.containsEntry("labelStatus", "REPLAY_MARKOUT_LABELED")
.containsEntry("side", "SHORT");
}
private ReplayRunConfig config() throws Exception {
Path replay = Path.of(new ClassPathResource("replay-fixtures/crypto-lake-replay-mini.csv").getFile().toURI());
Path candidates = Path.of(new ClassPathResource("replay-fixtures/crypto-lake-candidate-events-mini.csv").getFile().toURI());
return new ReplayRunConfig(
"trader_run_csv_test",
"BTCUSDT",
"BREAKOUT_RETEST_CONTINUATION",
"2026-06-22.p0",
Instant.parse("2026-01-01T00:00:00Z"),
Instant.parse("2026-01-01T00:05:00Z"),
"trader_feature_v0",
"trader_label_v0",
Map.of(
"cryptoLakeReplay1m", new DataSourceSpec(
"crypto-lake-mini",
replay.toString(),
"fixture-hash-not-used",
null,
18L,
null,
null,
"UTC",
Map.of()
),
"candidateEvents", new DataSourceSpec(
"candidate-events-mini",
candidates.toString(),
"fixture-hash-not-used",
null,
2L,
null,
null,
"UTC",
Map.of()
)
)
);
}
}
@@ -0,0 +1,68 @@
package com.quantai.trader.replay;
import com.quantai.trader.artifact.TraderArtifactLoader;
import com.quantai.trader.domain.TraderActionFactory;
import com.quantai.trader.enums.TraderActionType;
import com.quantai.trader.evidence.EvidenceAppender;
import com.quantai.trader.model.DeterministicTraderModelService;
import com.quantai.trader.outbox.InMemoryOutboxRepository;
import com.quantai.trader.position.TraderPositionManager;
import com.quantai.trader.risk.TraderRiskGate;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static com.quantai.trader.TestFixtures.T0;
import static com.quantai.trader.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThat;
class TraderP0CycleRunnerTest {
@Test
void runsReplayShadowCycleThroughModelPmRiskActionOutboxAndEvidence() {
EvidenceAppender evidenceAppender = new EvidenceAppender();
InMemoryOutboxRepository outboxRepository = new InMemoryOutboxRepository();
TraderP0CycleRunner runner = new TraderP0CycleRunner(
properties(),
new TraderArtifactLoader(properties()),
new DeterministicTraderModelService(),
new TraderPositionManager(),
new TraderRiskGate(),
new TraderActionFactory(),
evidenceAppender,
outboxRepository);
TraderCycleResult result = runner.runFlatCycle(new ReplayMarketEvent(
"run-1", "BTC-USDT-PERP", T0, new BigDecimal("100"), new BigDecimal("99.5"),
new BigDecimal("1.2"), new BigDecimal("1000")));
assertThat(result.action().actionType()).isEqualTo(TraderActionType.OPEN_LONG);
assertThat(result.action().reduceOnly()).isFalse();
assertThat(outboxRepository.all()).hasSize(1);
assertThat(outboxRepository.all().getFirst().destination()).isEqualTo("SHADOW_RECORDER");
assertThat(evidenceAppender.all()).extracting("stage")
.containsExactly("MARKET_SNAPSHOT", "MODEL_OUTPUT", "PM_DECISION", "RISK_DECISION");
}
@Test
void recordsWaitActionWhenReplaySnapshotHasNoLiquidity() {
EvidenceAppender evidenceAppender = new EvidenceAppender();
InMemoryOutboxRepository outboxRepository = new InMemoryOutboxRepository();
TraderP0CycleRunner runner = new TraderP0CycleRunner(
properties(),
new TraderArtifactLoader(properties()),
new DeterministicTraderModelService(),
new TraderPositionManager(),
new TraderRiskGate(),
new TraderActionFactory(),
evidenceAppender,
outboxRepository);
TraderCycleResult result = runner.runFlatCycle(new ReplayMarketEvent(
"run-1", "BTC-USDT-PERP", T0.plusSeconds(60), new BigDecimal("100"), new BigDecimal("99.5"),
new BigDecimal("1.2"), BigDecimal.ZERO));
assertThat(result.action().actionType()).isEqualTo(TraderActionType.WAIT);
assertThat(result.action().pricePlanId()).isNull();
assertThat(outboxRepository.all()).hasSize(1);
}
}
@@ -1,121 +0,0 @@
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<ExpectedCompletedFixture> 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("replayEngine", "jsonl_fixture")
.containsEntry("tickCount", 1)
.containsEntry("sampleCount", 1)
.containsEntry("actionCount", fixture.expectedActionCount())
.containsEntry("labeledSampleCount", 0)
.containsEntry("proxyOnlySampleCount", 1);
assertThat(report.candidateEvents()).isEqualTo(fixture.expectedActionCount());
var samples = sampleRepository.findByRunId(run.runId());
assertThat(samples).hasSize(1);
assertThat(samples.getFirst().features()).containsEntry("actionType", fixture.expectedSampleActionType());
assertThat(samples.getFirst().labels()).containsEntry("label_status", "PROXY_ONLY_NO_REPLAY_LABEL");
}
}
@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
) {
}
}
@@ -1,50 +0,0 @@
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);
}
}
@@ -1,63 +1,83 @@
package com.quantai.trader.risk;
import com.quantai.trader.TestFixtures;
import com.quantai.trader.domain.ExecutionDecision;
import com.quantai.trader.domain.TraderPositionState;
import com.quantai.trader.domain.TraderRiskDecision;
import com.quantai.trader.persistence.TraderRiskDecisionRepository;
import com.quantai.trader.enums.PositionSide;
import com.quantai.trader.enums.TraderActionType;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static com.quantai.trader.TestFixtures.*;
import static org.assertj.core.api.Assertions.assertThat;
class TraderRiskGateTest {
private final TraderRiskGate riskGate = new TraderRiskGate();
@Test
void recordsRiskDecisionForAllowedAction() {
CapturingRiskDecisionRepository repository = new CapturingRiskDecisionRepository();
TraderRiskGate gate = new TraderRiskGate(TestFixtures.properties(), repository);
void killSwitchBlocksOnlyExposureIncreasingActions() {
RiskLimits limits = new RiskLimits(bd("200"), java.math.BigDecimal.ONE, bd("500"), 3, 1500, true, false);
var decision = gate.evaluate(
TestFixtures.cycle(com.quantai.trader.enums.TraderState.ENTRY_PLANNED),
TestFixtures.fullInitialPlan(),
new ExecutionDecision(true, BigDecimal.ONE, "PASS", null, Map.of())
);
TraderRiskDecision open = riskGate.evaluate(new RiskGateInput(
pmDecision(TraderActionType.OPEN_LONG, PositionSide.LONG), flatPosition(), account(), execution(), snapshot(), limits));
TraderRiskDecision reduce = riskGate.evaluate(new RiskGateInput(
pmDecision(TraderActionType.REDUCE_LONG, PositionSide.LONG), longPosition("10"), account(), execution(), snapshot(), limits));
assertThat(open.allowAction()).isFalse();
assertThat(open.finalAction()).isEqualTo(TraderActionType.WAIT);
assertThat(open.blocker()).isEqualTo("KILL_SWITCH_ACTIVE");
assertThat(reduce.allowAction()).isTrue();
assertThat(reduce.finalAction()).isEqualTo(TraderActionType.REDUCE_LONG);
}
@Test
void lowLiquidationBufferForcesCloseOnExistingPosition() {
TraderPositionState lowBufferPosition = new TraderPositionState(
"position-state-1", "run-1", "cycle-1", "BTC-USDT-PERP",
PositionSide.LONG, bd("0.30"), bd("100"), bd("101"), bd("12"), bd("100"),
0, bd("0.40"), null);
TraderRiskDecision decision = riskGate.evaluate(new RiskGateInput(
pmDecision(TraderActionType.HOLD, PositionSide.LONG), lowBufferPosition, account(), execution(), snapshot(), riskLimits()));
assertThat(decision.allowAction()).isTrue();
assertThat(repository.findByCycleId("trader_run_test", "trader_cycle_test")).hasSize(1);
assertThat(decision.finalAction()).isEqualTo(TraderActionType.CLOSE_LONG);
assertThat(decision.blocker()).isEqualTo("LIQUIDATION_BUFFER_LOW");
}
@Test
void blocksWhenExecutionIsBlockedAndStillRecordsDecision() {
CapturingRiskDecisionRepository repository = new CapturingRiskDecisionRepository();
TraderRiskGate gate = new TraderRiskGate(TestFixtures.properties(), repository);
void exchangeInstabilityBlocksOpenAddActions() {
TraderRiskDecision latency = riskGate.evaluate(new RiskGateInput(
pmDecision(TraderActionType.OPEN_LONG, PositionSide.LONG), flatPosition(), account(),
execution(java.util.List.of(), 1600, 0), snapshot(), riskLimits()));
TraderRiskDecision errors = riskGate.evaluate(new RiskGateInput(
pmDecision(TraderActionType.ADD_LONG, PositionSide.LONG), longPosition("10"), account(),
execution(java.util.List.of(), 10, 3), snapshot(), riskLimits()));
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();
assertThat(latency.allowAction()).isFalse();
assertThat(latency.blocker()).isEqualTo("EXCHANGE_LATENCY_HIGH");
assertThat(errors.allowAction()).isFalse();
assertThat(errors.blocker()).isEqualTo("EXCHANGE_UNSTABLE");
}
private static class CapturingRiskDecisionRepository implements TraderRiskDecisionRepository {
private final List<TraderRiskDecision> decisions = new ArrayList<>();
@Test
void dailyLossAndDataQualityBlockActions() {
TraderRiskDecision dailyLoss = riskGate.evaluate(new RiskGateInput(
pmDecision(TraderActionType.OPEN_LONG, PositionSide.LONG), flatPosition(),
account("200", "0", "1.0"), execution(), snapshot(), riskLimits()));
TraderRiskDecision dataNotReady = riskGate.evaluate(new RiskGateInput(
pmDecision(TraderActionType.OPEN_LONG, PositionSide.LONG), flatPosition(),
account(), execution(), snapshot(false, "1000"), riskLimits()));
@Override
public void insert(TraderRiskDecision decision) {
decisions.add(decision);
}
assertThat(dailyLoss.blocker()).isEqualTo("MAX_DAILY_LOSS");
assertThat(dataNotReady.blocker()).isEqualTo("DATA_NOT_READY");
}
@Override
public List<TraderRiskDecision> findByCycleId(String runId, String cycleId) {
return decisions.stream()
.filter(item -> item.runId().equals(runId) && item.cycleId().equals(cycleId))
.toList();
}
@Test
void allowsCleanP0Decision() {
TraderRiskDecision decision = riskGate.evaluate(new RiskGateInput(
pmDecision(TraderActionType.OPEN_LONG, PositionSide.LONG), flatPosition(), account(), execution(), snapshot(), riskLimits()));
assertThat(decision.allowAction()).isTrue();
assertThat(decision.finalAction()).isEqualTo(TraderActionType.OPEN_LONG);
assertThat(decision.blocker()).isNull();
}
}
@@ -0,0 +1,38 @@
package com.quantai.trader.runtime;
import com.quantai.trader.domain.TraderException;
import com.quantai.trader.enums.TraderExecutionMode;
import com.quantai.trader.enums.TraderRunMode;
import org.junit.jupiter.api.Test;
import static com.quantai.trader.TestFixtures.properties;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class P0RuntimeGuardTest {
@Test
void allowsReplaySimAndShadowWhenTradingIsDisabled() {
assertThatCode(() -> new P0RuntimeGuard(properties(TraderRunMode.REPLAY_SIM, TraderExecutionMode.REPLAY_SIM, false, false)).validateStartup())
.doesNotThrowAnyException();
assertThatCode(() -> new P0RuntimeGuard(properties(TraderRunMode.SHADOW, TraderExecutionMode.SHADOW, false, false)).validateStartup())
.doesNotThrowAnyException();
}
@Test
void rejectsPaperOrRealModesInsteadOfConvertingThem() {
assertThatThrownBy(() -> new P0RuntimeGuard(properties(TraderRunMode.PAPER, TraderExecutionMode.SHADOW, false, false)).validateStartup())
.isInstanceOf(TraderException.class)
.hasMessageContaining("REPLAY_SIM or SHADOW");
assertThatThrownBy(() -> new P0RuntimeGuard(properties(TraderRunMode.SHADOW, TraderExecutionMode.REAL, false, false)).validateStartup())
.isInstanceOf(TraderException.class)
.hasMessageContaining("execution.mode");
}
@Test
void rejectsAnyTradingEnabledP0Runtime() {
assertThatThrownBy(() -> new P0RuntimeGuard(properties(TraderRunMode.REPLAY_SIM, TraderExecutionMode.REPLAY_SIM, true, false)).validateStartup())
.isInstanceOf(TraderException.class)
.hasMessageContaining("trading-enabled=false");
}
}
@@ -1,51 +0,0 @@
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 exportsReplayMarkoutSampleWithFeatureAndLabelVersions() {
CapturingSampleRepository repository = new CapturingSampleRepository();
TrainingSampleExporter exporter = new TrainingSampleExporter(TestFixtures.properties(), repository, new TriggerMarkoutLabeler());
var sample = exporter.export(
TestFixtures.cycle(com.quantai.trader.enums.TraderState.SAMPLE_EXPORTED),
TestFixtures.labeledSnapshot(),
TestFixtures.candidate(),
TestFixtures.action(),
TestFixtures.openedPath(false)
);
assertThat(sample.proxyOnly()).isFalse();
assertThat(sample.featureVersion()).isEqualTo("trader_feature_v0");
assertThat(sample.labelVersion()).isEqualTo("trader_label_v0");
assertThat(sample.netReturnBps1x()).isEqualByComparingTo("14.00000000");
assertThat(sample.labels()).containsEntry("label_status", "REPLAY_MARKOUT_LABELED");
assertThat(repository.findByRunId("trader_run_test")).hasSize(1);
}
private static class CapturingSampleRepository implements TraderSampleRepository {
private final List<TraderTrainingSample> samples = new ArrayList<>();
@Override
public void insert(TraderTrainingSample sample) {
samples.add(sample);
}
@Override
public List<TraderTrainingSample> findByRunId(String runId) {
return samples.stream()
.filter(item -> item.runId().equals(runId))
.toList();
}
}
}
@@ -1,70 +0,0 @@
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");
}
}
+34 -5
View File
@@ -1,8 +1,37 @@
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: false
trader:
service-name: quant-trader-service-test
run-mode: SHADOW
symbol: BTC-USDT-PERP
artifact:
model-bundle-version: trader-v4-btc-p0-test
calibration-bundle-version: cal-v4-btc-p0-test
pm-config-version: pm-v4-btc-p0-test
artifact-root: /tmp/trader-v4-p0-test-artifacts
feedback:
http-enabled: false
execution:
mode: SHADOW
max-api-error-count: 3
max-exchange-latency-ms: 1500
runtime:
redis-key-prefix: trader:v4:test
require-redis-for-open-add: true
trading-enabled: false
outbox:
enabled: true
max-retry-count: 5
release:
require-review-for-paper: true
require-review-for-live-probe: true
active-pointer-check-enabled: true
risk:
max-daily-loss-bps: 200
max-total-exposure-ratio: 1.0
min-liquidation-buffer-bps: 500
position-manager:
max-single-leg-ratio: 1.0
max-total-position-ratio: 1.0
@@ -1,22 +0,0 @@
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.
@@ -1,3 +0,0 @@
event_id,bar_time,signal_type,direction,source_service,symbol,old_fusion_score
mini-long-1,1767225600000,BREAKOUT_RETEST_CONTINUATION,LONG,TEST_CANDIDATE_EVENT,BTCUSDT,0.95
mini-short-1,1767225720000,BREAKOUT_RETEST_CONTINUATION,SHORT,TEST_CANDIDATE_EVENT,BTCUSDT,0.90
1 event_id bar_time signal_type direction source_service symbol old_fusion_score
2 mini-long-1 1767225600000 BREAKOUT_RETEST_CONTINUATION LONG TEST_CANDIDATE_EVENT BTCUSDT 0.95
3 mini-short-1 1767225720000 BREAKOUT_RETEST_CONTINUATION SHORT TEST_CANDIDATE_EVENT BTCUSDT 0.90
@@ -1,19 +0,0 @@
symbol,timeframe,open_time,open,high,low,close,volume,taker_buy_volume,funding_bps,open_interest,best_bid_price,best_ask_price,observed_spread_bps,observed_slippage_bps,expected_slippage_bps,p95_latency_ms,source_coverage
BTCUSDT,1m,2026-01-01T00:00:00Z,100.0,100.4,99.8,100.0,10,5,0.1,1000,99.99,100.01,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:01:00Z,100.0,100.7,99.9,100.4,11,6,0.1,1001,100.39,100.41,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:02:00Z,100.4,100.6,100.0,100.2,12,7,0.1,1002,100.19,100.21,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:03:00Z,100.2,100.8,100.1,100.5,13,8,0.1,1003,100.49,100.51,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:04:00Z,100.5,101.0,100.2,100.8,14,9,0.1,1004,100.79,100.81,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:05:00Z,100.8,101.3,100.5,101.0,15,10,0.1,1005,100.99,101.01,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:06:00Z,101.0,101.6,100.7,101.2,16,11,0.1,1006,101.19,101.21,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:07:00Z,101.2,101.7,100.8,101.3,17,12,0.1,1007,101.29,101.31,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:08:00Z,101.3,101.8,100.9,101.4,18,13,0.1,1008,101.39,101.41,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:09:00Z,101.4,101.9,101.0,101.5,19,14,0.1,1009,101.49,101.51,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:10:00Z,101.5,102.0,101.1,101.6,20,15,0.1,1010,101.59,101.61,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:11:00Z,101.6,102.1,101.2,101.7,21,16,0.1,1011,101.69,101.71,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:12:00Z,101.7,102.2,101.3,101.8,22,17,0.1,1012,101.79,101.81,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:13:00Z,101.8,102.3,101.4,101.9,23,18,0.1,1013,101.89,101.91,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:14:00Z,101.9,102.4,101.5,102.0,24,19,0.1,1014,101.99,102.01,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:15:00Z,102.0,102.5,101.6,102.0,25,20,0.1,1015,101.99,102.01,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:16:00Z,102.0,102.1,99.5,99.8,26,6,0.1,1016,99.79,99.81,2,1,1,30,1
BTCUSDT,1m,2026-01-01T00:17:00Z,99.8,100.0,98.8,99.0,27,5,0.1,1017,98.99,99.01,2,1,1,30,1
1 symbol timeframe open_time open high low close volume taker_buy_volume funding_bps open_interest best_bid_price best_ask_price observed_spread_bps observed_slippage_bps expected_slippage_bps p95_latency_ms source_coverage
2 BTCUSDT 1m 2026-01-01T00:00:00Z 100.0 100.4 99.8 100.0 10 5 0.1 1000 99.99 100.01 2 1 1 30 1
3 BTCUSDT 1m 2026-01-01T00:01:00Z 100.0 100.7 99.9 100.4 11 6 0.1 1001 100.39 100.41 2 1 1 30 1
4 BTCUSDT 1m 2026-01-01T00:02:00Z 100.4 100.6 100.0 100.2 12 7 0.1 1002 100.19 100.21 2 1 1 30 1
5 BTCUSDT 1m 2026-01-01T00:03:00Z 100.2 100.8 100.1 100.5 13 8 0.1 1003 100.49 100.51 2 1 1 30 1
6 BTCUSDT 1m 2026-01-01T00:04:00Z 100.5 101.0 100.2 100.8 14 9 0.1 1004 100.79 100.81 2 1 1 30 1
7 BTCUSDT 1m 2026-01-01T00:05:00Z 100.8 101.3 100.5 101.0 15 10 0.1 1005 100.99 101.01 2 1 1 30 1
8 BTCUSDT 1m 2026-01-01T00:06:00Z 101.0 101.6 100.7 101.2 16 11 0.1 1006 101.19 101.21 2 1 1 30 1
9 BTCUSDT 1m 2026-01-01T00:07:00Z 101.2 101.7 100.8 101.3 17 12 0.1 1007 101.29 101.31 2 1 1 30 1
10 BTCUSDT 1m 2026-01-01T00:08:00Z 101.3 101.8 100.9 101.4 18 13 0.1 1008 101.39 101.41 2 1 1 30 1
11 BTCUSDT 1m 2026-01-01T00:09:00Z 101.4 101.9 101.0 101.5 19 14 0.1 1009 101.49 101.51 2 1 1 30 1
12 BTCUSDT 1m 2026-01-01T00:10:00Z 101.5 102.0 101.1 101.6 20 15 0.1 1010 101.59 101.61 2 1 1 30 1
13 BTCUSDT 1m 2026-01-01T00:11:00Z 101.6 102.1 101.2 101.7 21 16 0.1 1011 101.69 101.71 2 1 1 30 1
14 BTCUSDT 1m 2026-01-01T00:12:00Z 101.7 102.2 101.3 101.8 22 17 0.1 1012 101.79 101.81 2 1 1 30 1
15 BTCUSDT 1m 2026-01-01T00:13:00Z 101.8 102.3 101.4 101.9 23 18 0.1 1013 101.89 101.91 2 1 1 30 1
16 BTCUSDT 1m 2026-01-01T00:14:00Z 101.9 102.4 101.5 102.0 24 19 0.1 1014 101.99 102.01 2 1 1 30 1
17 BTCUSDT 1m 2026-01-01T00:15:00Z 102.0 102.5 101.6 102.0 25 20 0.1 1015 101.99 102.01 2 1 1 30 1
18 BTCUSDT 1m 2026-01-01T00:16:00Z 102.0 102.1 99.5 99.8 26 6 0.1 1016 99.79 99.81 2 1 1 30 1
19 BTCUSDT 1m 2026-01-01T00:17:00Z 99.8 100.0 98.8 99.0 27 5 0.1 1017 98.99 99.01 2 1 1 30 1
@@ -1 +0,0 @@
{"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","side":"LONG","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":[]}}
@@ -1 +0,0 @@
{"eventTime":"2026-01-01T05:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"UP","marketStructure":"breakout_retest"},"setupFeatures":{"setupPass":true,"setupName":"breakout_retest_continuation","side":"LONG","executionQualityScore":"0.90"},"triggerFeatures":{"triggerScore":"0.95","triggerName":"micro_continuation"},"executionFeatures":{"lastPrice":"65300"},"dataQuality":{"missing_features":[]}}
@@ -1 +0,0 @@
{"eventTime":"2026-01-01T04:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"UP","marketStructure":"breakout_retest"},"setupFeatures":{"setupPass":true,"setupName":"breakout_retest_continuation","side":"LONG","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"]}}
@@ -1 +0,0 @@
{"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":[]}}
@@ -1 +0,0 @@
{"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":[]}}
@@ -1 +0,0 @@
{"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","side":"LONG","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":[]}}