Rewrite trader service for V4 P0
This commit is contained in:
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
-97
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,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 +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":[]}}
|
||||
Reference in New Issue
Block a user