Initial quant trader service baseline

This commit is contained in:
Codex
2026-06-23 22:09:06 +08:00
commit 7ff786f658
137 changed files with 6664 additions and 0 deletions
@@ -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() {
}
}
@@ -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"
);
}
}
@@ -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");
}
}
@@ -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"));
}
}
@@ -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()
);
}
}
@@ -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"
);
}
}
@@ -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<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();
}
}
}
@@ -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();
}
}
@@ -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()
))
);
}
}
@@ -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);
}
}
@@ -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<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);
}
}
@@ -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()
);
}
}
@@ -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<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("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
) {
}
}
@@ -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);
}
}
@@ -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<TraderRiskDecision> decisions = new ArrayList<>();
@Override
public void insert(TraderRiskDecision decision) {
decisions.add(decision);
}
@Override
public List<TraderRiskDecision> findByCycleId(String runId, String cycleId) {
return decisions.stream()
.filter(item -> item.runId().equals(runId) && item.cycleId().equals(cycleId))
.toList();
}
}
}
@@ -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<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();
}
}
}
@@ -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");
}
}