Initial quant trader service baseline
This commit is contained in:
@@ -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()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
+97
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:mysql://127.0.0.1:3306/quant_app_test_codex?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&allowPublicKeyRetrieval=true&useSSL=false
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: quant_app
|
||||
password: quant_app
|
||||
flyway:
|
||||
enabled: true
|
||||
@@ -0,0 +1,22 @@
|
||||
P0 replay fixtures
|
||||
==================
|
||||
|
||||
These JSONL files are deterministic acceptance fixtures for the P0 replay loop.
|
||||
They are not a profitability backtest corpus. Each line is one replay clock tick
|
||||
with the schema consumed by JsonlReplayMarketEventReader:
|
||||
|
||||
- eventTime
|
||||
- contextFeatures
|
||||
- setupFeatures
|
||||
- triggerFeatures
|
||||
- executionFeatures
|
||||
- dataQuality
|
||||
|
||||
Scenario notes:
|
||||
|
||||
- trend-up-breakout-happy.jsonl: accepted continuation setup; should produce one action and one sample.
|
||||
- trend-down-no-setup.jsonl: bearish regime without a valid long continuation setup; should complete with no action.
|
||||
- sideways-range-no-setup.jsonl: range regime without a setup; should complete with no action.
|
||||
- false-breakout-trigger-wait.jsonl: setup exists but trigger score is too weak; should complete with no action.
|
||||
- missing-features-data-quality.jsonl: data-quality blocker; should complete with a blocked sample.
|
||||
- incomplete-entry-plan-hard-fail.jsonl: setupPass is true but required prices are missing; should fail the replay run.
|
||||
@@ -0,0 +1 @@
|
||||
{"eventTime":"2026-01-01T03:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"UP","marketStructure":"weak_breakout_retest","timeframeAlignment":"1h_up_4h_flat"},"setupFeatures":{"setupPass":true,"setupName":"breakout_retest_continuation","entryPrice":"65120","invalidPrice":"64980","stopPrice":"65040","targetPrice":"65450","executionQualityScore":"0.76","volumeImpulse":"0.88","retestHold":true},"triggerFeatures":{"triggerScore":"0.32","triggerName":"false_breakout_probe","breakoutFollowThrough":false},"executionFeatures":{"lastPrice":"65105","spreadBps":"1.4","bookImbalance":"0.49"},"dataQuality":{"missing_features":[]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"eventTime":"2026-01-01T05:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"UP","marketStructure":"breakout_retest"},"setupFeatures":{"setupPass":true,"setupName":"breakout_retest_continuation","executionQualityScore":"0.90"},"triggerFeatures":{"triggerScore":"0.95","triggerName":"micro_continuation"},"executionFeatures":{"lastPrice":"65300"},"dataQuality":{"missing_features":[]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"eventTime":"2026-01-01T04:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"UP","marketStructure":"breakout_retest"},"setupFeatures":{"setupPass":true,"setupName":"breakout_retest_continuation","entryPrice":"65200","invalidPrice":"65020","stopPrice":"65100","targetPrice":"65580","executionQualityScore":"0.84"},"triggerFeatures":{"triggerScore":"0.91","triggerName":"micro_continuation"},"executionFeatures":{"lastPrice":"65210"},"dataQuality":{"missing_features":["level_1.best_bid","level_1.best_ask"]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"eventTime":"2026-01-01T02:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"RANGE","marketStructure":"mid_range_chop","timeframeAlignment":"mixed"},"setupFeatures":{"setupPass":false,"setupName":"range_filter_rejected","rejectReason":"no_clean_platform_breakout"},"triggerFeatures":{"triggerScore":"0.35","triggerName":"range_noise"},"executionFeatures":{"lastPrice":"64680","spreadBps":"1.1"},"dataQuality":{"missing_features":[]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"eventTime":"2026-01-01T01:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"DOWN","marketStructure":"lower_high_break","timeframeAlignment":"1h_4h_down"},"setupFeatures":{"setupPass":false,"setupName":"long_continuation_rejected","rejectReason":"trend_direction_mismatch"},"triggerFeatures":{"triggerScore":"0.20","triggerName":"none"},"executionFeatures":{"lastPrice":"64240","spreadBps":"0.9"},"dataQuality":{"missing_features":[]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"eventTime":"2026-01-01T00:00:00Z","contextFeatures":{"contextPass":true,"trendRegime":"UP","marketStructure":"breakout_retest","timeframeAlignment":"1h_4h_up"},"setupFeatures":{"setupPass":true,"setupName":"breakout_retest_continuation","entryPrice":"65000","invalidPrice":"64800","stopPrice":"64920","targetPrice":"65350","executionQualityScore":"0.92","volumeImpulse":"1.48","retestHold":true},"triggerFeatures":{"triggerScore":"0.95","triggerName":"micro_continuation","breakoutFollowThrough":true},"executionFeatures":{"lastPrice":"65010","spreadBps":"0.8","bookImbalance":"0.57"},"dataQuality":{"missing_features":[]}}
|
||||
Reference in New Issue
Block a user