Source migration: - crates/ml-backtesting/src/lob/mod.rs:5 + policy/mod.rs:18: local const N_HORIZONS 5→3 via re-export from ml_alpha::heads::N_HORIZONS (single source of truth across crates) - crates/ml-backtesting/src/sim/mod.rs:1751,1773,1784: [IsvKellyStateHost; 5] array literals → ; N_HORIZONS] - crates/ml-backtesting/cuda/lob_state.cuh:9: #define N_HORIZONS 5→3 (this triggers cubin rebuild of decision_policy + 4 other ml-backtesting kernels that #include this header) Test migration (8 test files + 2 JSON fixtures): - threshold_and_cost.rs, decision_floor_coldstart.rs, parallel_sim_ correctness.rs, stop_controller.rs (37+10+1 broadcast_alpha calls), lob_sim_integrated_fuzz.rs, lob_sim_fixtures.rs: hardcoded [f32; 5] alpha-probs and [IsvKellyStateHost; 5] arrays → N_HORIZONS-sized via std::array::from_fn or [v; N_HORIZONS] literals - trainer_parity.rs:34 + ring3_replay.rs:47: horizons literal [30,100,300,1000,6000] → ml_alpha::heads::HORIZONS - fixtures/decision_alpha_buy_close.json + decision_program_h4_only.json: 5-element warm_start_isv_kelly / alpha_probs / expected_isv_kelly_after trimmed to 3 elements; active horizon relocated to N_HORIZONS-1 Library lib-test rewrite (per pearl_tests_must_prove_not_lock_observations): - crates/ml-backtesting/src/policy/mod.rs:197-233: lib tests default_strategy_has_5_horizon_leaves + ..._flattens_to_5_emits... renamed to N_HORIZONS-parametric form (observed-value 5 and 7 were bug-locks). cargo check -p ml-backtesting --all-targets: clean. cargo test -p ml-backtesting --lib: 33 passed. cargo test -p ml-backtesting --tests (non-CUDA, non-fixture-data): 2 passed, 53 ignored (CUDA-gated or FOXHUNT_TEST_DATA-gated). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
507 lines
17 KiB
Rust
507 lines
17 KiB
Rust
//! Ring 1 fixture tests: N=1 (or small N) bit-exact GPU oracle assertions.
|
|
//! See docs/superpowers/specs/2026-05-18-real-lob-integration-design.md §8.
|
|
//!
|
|
//! Run with: `cargo test -p ml-backtesting --test lob_sim_fixtures -- --ignored --nocapture`
|
|
|
|
use anyhow::{Context, Result};
|
|
use ml_backtesting::lob::BOOK_LEVELS;
|
|
use ml_backtesting::policy::{IsvKellyStateHost, N_HORIZONS};
|
|
use ml_backtesting::sim::LobSimCuda;
|
|
use ml_core::device::MlDevice;
|
|
use serde::Deserialize;
|
|
use std::path::Path;
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case")]
|
|
enum FixtureEvent {
|
|
Snapshot {
|
|
bid_px: [f32; BOOK_LEVELS],
|
|
bid_sz: [f32; BOOK_LEVELS],
|
|
ask_px: [f32; BOOK_LEVELS],
|
|
ask_sz: [f32; BOOK_LEVELS],
|
|
},
|
|
SubmitMarket {
|
|
backtest_idx: usize,
|
|
side: String,
|
|
size: u16,
|
|
#[serde(default = "default_ts_ns")]
|
|
ts_ns: u64,
|
|
},
|
|
Decision {
|
|
alpha_probs: [f32; N_HORIZONS],
|
|
target_annual_vol_units: f32,
|
|
annualisation_factor: f32,
|
|
max_lots: u16,
|
|
#[serde(default = "default_ts_ns")]
|
|
ts_ns: u64,
|
|
},
|
|
SeedLimit {
|
|
backtest_idx: usize,
|
|
slot_idx: usize,
|
|
side: String,
|
|
kind: String,
|
|
active_state: u8,
|
|
oco_pair: u8,
|
|
price: f32,
|
|
size: f32,
|
|
#[serde(default)] queue_position_init: f32,
|
|
#[serde(default)] arrival_ts_ns: u64,
|
|
},
|
|
SeedStop {
|
|
backtest_idx: usize,
|
|
slot_idx: usize,
|
|
side: String,
|
|
kind: String,
|
|
active_state: u8,
|
|
oco_pair: u8,
|
|
trigger_price: f32,
|
|
#[serde(default)] limit_price: f32,
|
|
size: f32,
|
|
#[serde(default)] arrival_ts_ns: u64,
|
|
},
|
|
StepResting {
|
|
ts_ns: u64,
|
|
#[serde(default)] trade_signed_vol: f32,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ExpectedSlotState {
|
|
backtest_idx: usize,
|
|
slot_idx: usize,
|
|
active: u8,
|
|
}
|
|
|
|
fn default_ts_ns() -> u64 { 1 }
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
struct IsvKellyFixture {
|
|
#[serde(default)] pnl_ema_win: f32,
|
|
#[serde(default)] pnl_ema_loss: f32,
|
|
#[serde(default)] win_rate_ema: f32,
|
|
#[serde(default)] n_trades_seen: u32,
|
|
#[serde(default)] realised_return_var: f32,
|
|
#[serde(default)] recent_sharpe: f32,
|
|
}
|
|
|
|
impl IsvKellyFixture {
|
|
fn to_host(&self) -> IsvKellyStateHost {
|
|
IsvKellyStateHost {
|
|
pnl_ema_win: self.pnl_ema_win,
|
|
pnl_ema_loss: self.pnl_ema_loss,
|
|
win_rate_ema: self.win_rate_ema,
|
|
n_trades_seen: self.n_trades_seen,
|
|
realised_return_var: self.realised_return_var,
|
|
recent_sharpe: self.recent_sharpe,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Deserialize, Default)]
|
|
struct ExpectedIsvKelly {
|
|
#[serde(default)] n_trades_seen_min: u32,
|
|
#[serde(default = "u32_max")] n_trades_seen_max: u32,
|
|
}
|
|
fn u32_max() -> u32 { u32::MAX }
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ExpectedPos {
|
|
position_lots: i32,
|
|
#[serde(default)]
|
|
vwap_entry: f32,
|
|
#[serde(default = "default_tol")]
|
|
vwap_tol: f32,
|
|
#[serde(default)]
|
|
realized_pnl: f32,
|
|
#[serde(default = "default_tol")]
|
|
realized_pnl_tol: f32,
|
|
}
|
|
|
|
fn default_tol() -> f32 { 1e-3 }
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct FixtureBook {
|
|
bid_px: [f32; BOOK_LEVELS],
|
|
bid_sz: [f32; BOOK_LEVELS],
|
|
ask_px: [f32; BOOK_LEVELS],
|
|
ask_sz: [f32; BOOK_LEVELS],
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ExpectedTradeRecord {
|
|
entry_ts_ns: u64,
|
|
exit_ts_ns: u64,
|
|
size_lots: i32,
|
|
realised_pnl_usd_fp: i32,
|
|
#[serde(default = "default_pnl_fp_tol")]
|
|
realised_pnl_tol_usd_fp: i32,
|
|
}
|
|
fn default_pnl_fp_tol() -> i32 { 100 }
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Fixture {
|
|
name: String,
|
|
n_backtests: usize,
|
|
events: Vec<FixtureEvent>,
|
|
#[serde(default)]
|
|
expected_books: Vec<FixtureBook>,
|
|
#[serde(default)]
|
|
expected_books_apply_to_all: bool,
|
|
#[serde(default)]
|
|
expected_pos: Vec<ExpectedPos>,
|
|
#[serde(default)]
|
|
expected_trade_records: Vec<ExpectedTradeRecord>,
|
|
#[serde(default)]
|
|
warm_start_isv_kelly: Vec<[IsvKellyFixture; N_HORIZONS]>,
|
|
#[serde(default)]
|
|
expected_min_trade_records: usize,
|
|
#[serde(default)]
|
|
expected_isv_kelly_after: Vec<[ExpectedIsvKelly; N_HORIZONS]>,
|
|
#[serde(default)]
|
|
expected_limit_slot_states: Vec<ExpectedSlotState>,
|
|
#[serde(default)]
|
|
expected_stop_slot_states: Vec<ExpectedSlotState>,
|
|
#[serde(default)]
|
|
fill_all_limit_slots: bool,
|
|
#[serde(default)]
|
|
expect_33rd_overflow: bool,
|
|
#[serde(default)]
|
|
upload_h4_leaf_program: bool,
|
|
}
|
|
|
|
fn parse_kind_limit(s: &str) -> u8 {
|
|
match s {
|
|
"Limit" => 1,
|
|
"Ioc" => 2,
|
|
"Fok" => 3,
|
|
other => panic!("unknown limit kind: {other}"),
|
|
}
|
|
}
|
|
fn parse_kind_stop(s: &str) -> u8 {
|
|
match s {
|
|
"StopMarket" => 4,
|
|
"StopLimit" => 5,
|
|
other => panic!("unknown stop kind: {other}"),
|
|
}
|
|
}
|
|
fn parse_side(s: &str) -> u8 {
|
|
match s {
|
|
"buy" => 0,
|
|
"sell" => 1,
|
|
other => panic!("unknown side: {other}"),
|
|
}
|
|
}
|
|
|
|
fn run_book_fixture(path: &Path) -> Result<()> {
|
|
let s = std::fs::read_to_string(path)
|
|
.with_context(|| format!("read {}", path.display()))?;
|
|
let fx: Fixture = serde_json::from_str(&s).context("parse fixture")?;
|
|
eprintln!("running fixture: {}", fx.name);
|
|
|
|
let dev = MlDevice::cuda(0)
|
|
.map_err(|e| anyhow::anyhow!("cuda device: {e}"))?;
|
|
let mut sim = LobSimCuda::new(fx.n_backtests, &dev)?;
|
|
|
|
// Upload a single-leaf longest-horizon Strategy program for backtest 0
|
|
// if requested. Horizon idx = N_HORIZONS-1 (longest available horizon).
|
|
if fx.upload_h4_leaf_program {
|
|
use ml_backtesting::policy::{SizingPolicyId, Strategy, StrategyConfig};
|
|
let leaf = Strategy::Leaf(StrategyConfig {
|
|
horizon_idx: (N_HORIZONS - 1) as u8,
|
|
sizing_policy: SizingPolicyId::IsvKelly,
|
|
max_concurrent_lots: 5,
|
|
});
|
|
let prog = leaf.flatten();
|
|
sim.upload_program(0, &prog)?;
|
|
}
|
|
|
|
// Apply ISV-Kelly warm-start if provided (one row per backtest).
|
|
for (b, states) in fx.warm_start_isv_kelly.iter().enumerate() {
|
|
let host_states: [IsvKellyStateHost; N_HORIZONS] =
|
|
std::array::from_fn(|h| states[h].to_host());
|
|
sim.write_isv_kelly(b, &host_states)?;
|
|
}
|
|
|
|
for ev in &fx.events {
|
|
match ev {
|
|
FixtureEvent::Snapshot { bid_px, bid_sz, ask_px, ask_sz } => {
|
|
sim.apply_snapshot(bid_px, bid_sz, ask_px, ask_sz)?;
|
|
}
|
|
FixtureEvent::SubmitMarket { backtest_idx, side, size, ts_ns } => {
|
|
let side_u8 = match side.as_str() {
|
|
"buy" => 0u8,
|
|
"sell" => 1u8,
|
|
other => anyhow::bail!("submit_market: unknown side {other}"),
|
|
};
|
|
sim.submit_market(*backtest_idx, side_u8, *size, *ts_ns)?;
|
|
}
|
|
FixtureEvent::Decision {
|
|
alpha_probs,
|
|
target_annual_vol_units,
|
|
annualisation_factor,
|
|
max_lots,
|
|
ts_ns,
|
|
} => {
|
|
sim.broadcast_alpha(alpha_probs)?;
|
|
let sim_cfg = ml_backtesting::sim::BatchedSimConfig::from_uniform(
|
|
sim.n_backtests(),
|
|
&ml_backtesting::sim::UniformSimParams {
|
|
target_annual_vol_units: *target_annual_vol_units,
|
|
annualisation_factor: *annualisation_factor,
|
|
max_lots: *max_lots,
|
|
latency_ns: 0,
|
|
kelly_frac_floor: 0.10,
|
|
sharpe_weight_floor: 0.10,
|
|
threshold: 0.0,
|
|
// CRT.1 C1.2: cost > 0 is required for the §4.4
|
|
// conviction formula's eps_edge floor.
|
|
cost_per_lot_per_side: 1.0,
|
|
max_hold_ns: 0,
|
|
min_reasonable_px: 0.0,
|
|
max_reasonable_px: f32::INFINITY,
|
|
delta_floor: 0.0,
|
|
},
|
|
);
|
|
sim.step_decision(*ts_ns, &sim_cfg)?;
|
|
}
|
|
FixtureEvent::SeedLimit {
|
|
backtest_idx, slot_idx, side, kind, active_state,
|
|
oco_pair, price, size, queue_position_init, arrival_ts_ns,
|
|
} => {
|
|
sim.seed_limit_order(
|
|
*backtest_idx, *slot_idx,
|
|
parse_side(side), parse_kind_limit(kind), *active_state,
|
|
*oco_pair, *price, *size, *queue_position_init, *arrival_ts_ns,
|
|
)?;
|
|
}
|
|
FixtureEvent::SeedStop {
|
|
backtest_idx, slot_idx, side, kind, active_state,
|
|
oco_pair, trigger_price, limit_price, size, arrival_ts_ns,
|
|
} => {
|
|
sim.seed_stop_order(
|
|
*backtest_idx, *slot_idx,
|
|
parse_side(side), parse_kind_stop(kind), *active_state,
|
|
*oco_pair, *trigger_price, *limit_price, *size, *arrival_ts_ns,
|
|
)?;
|
|
}
|
|
FixtureEvent::StepResting { ts_ns, trade_signed_vol } => {
|
|
sim.step_resting_orders(*ts_ns, *trade_signed_vol)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
if fx.expected_min_trade_records > 0 {
|
|
let records = sim.read_trade_records(0)?;
|
|
assert!(
|
|
records.len() >= fx.expected_min_trade_records,
|
|
"expected >= {} trade records, got {}",
|
|
fx.expected_min_trade_records,
|
|
records.len()
|
|
);
|
|
}
|
|
|
|
if fx.fill_all_limit_slots {
|
|
// Seed all 32 limit slots successfully; expect each call to return Ok.
|
|
for slot in 0..ml_backtesting::lob::MAX_LIMITS {
|
|
sim.seed_limit_order(
|
|
0, slot, 0 /*buy*/, 1 /*Limit*/, 1 /*resting*/, 0xFF,
|
|
5400.00 /* deep below the book — never marketable */,
|
|
1.0, 0.0, 0,
|
|
)?;
|
|
}
|
|
// The 33rd seed (re-seeding slot 0 which is in use) must fail.
|
|
if fx.expect_33rd_overflow {
|
|
let res = sim.seed_limit_order(
|
|
0, 0, 0, 1, 1, 0xFF, 5400.00, 1.0, 0.0, 0,
|
|
);
|
|
assert!(
|
|
res.is_err(),
|
|
"expected slot-already-in-use error, got Ok"
|
|
);
|
|
}
|
|
}
|
|
|
|
for want in &fx.expected_limit_slot_states {
|
|
let got = sim.read_limit_slot(want.backtest_idx, want.slot_idx)?;
|
|
assert_eq!(
|
|
got.active, want.active,
|
|
"limit_slot[b={},s={}].active got {} want {}",
|
|
want.backtest_idx, want.slot_idx, got.active, want.active
|
|
);
|
|
}
|
|
for want in &fx.expected_stop_slot_states {
|
|
let got = sim.read_stop_slot(want.backtest_idx, want.slot_idx)?;
|
|
assert_eq!(
|
|
got.active, want.active,
|
|
"stop_slot[b={},s={}].active got {} want {}",
|
|
want.backtest_idx, want.slot_idx, got.active, want.active
|
|
);
|
|
}
|
|
|
|
if !fx.expected_isv_kelly_after.is_empty() {
|
|
for (b, expected) in fx.expected_isv_kelly_after.iter().enumerate() {
|
|
let got = sim.read_isv_kelly(b)?;
|
|
for h in 0..N_HORIZONS {
|
|
let n = got[h].n_trades_seen;
|
|
assert!(
|
|
n >= expected[h].n_trades_seen_min && n <= expected[h].n_trades_seen_max,
|
|
"backtest {b} horizon {h}: n_trades_seen got {n}, want in [{}..{}]",
|
|
expected[h].n_trades_seen_min,
|
|
expected[h].n_trades_seen_max
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if !fx.expected_books.is_empty() {
|
|
let books = sim.read_books()?;
|
|
let expected: Vec<&FixtureBook> = if fx.expected_books_apply_to_all {
|
|
(0..fx.n_backtests).map(|_| &fx.expected_books[0]).collect()
|
|
} else {
|
|
anyhow::ensure!(
|
|
fx.expected_books.len() == fx.n_backtests,
|
|
"expected_books len {} ≠ n_backtests {}",
|
|
fx.expected_books.len(),
|
|
fx.n_backtests
|
|
);
|
|
fx.expected_books.iter().collect()
|
|
};
|
|
|
|
for (b, (got, want)) in books.iter().zip(expected.iter()).enumerate() {
|
|
for k in 0..BOOK_LEVELS {
|
|
assert_eq!(got.bid_px[k], want.bid_px[k], "backtest {b} bid_px[{k}]");
|
|
assert_eq!(got.bid_sz[k], want.bid_sz[k], "backtest {b} bid_sz[{k}]");
|
|
assert_eq!(got.ask_px[k], want.ask_px[k], "backtest {b} ask_px[{k}]");
|
|
assert_eq!(got.ask_sz[k], want.ask_sz[k], "backtest {b} ask_sz[{k}]");
|
|
}
|
|
}
|
|
}
|
|
|
|
if !fx.expected_trade_records.is_empty() {
|
|
let records = sim.read_trade_records(0)?;
|
|
assert_eq!(
|
|
records.len(),
|
|
fx.expected_trade_records.len(),
|
|
"trade record count mismatch: got {}, want {}",
|
|
records.len(),
|
|
fx.expected_trade_records.len()
|
|
);
|
|
for (i, want) in fx.expected_trade_records.iter().enumerate() {
|
|
let got = &records[i];
|
|
assert_eq!(got.entry_ts_ns, want.entry_ts_ns, "trade[{i}].entry_ts_ns");
|
|
assert_eq!(got.exit_ts_ns, want.exit_ts_ns, "trade[{i}].exit_ts_ns");
|
|
assert_eq!(got.size_lots, want.size_lots, "trade[{i}].size_lots");
|
|
let diff = (got.realised_pnl_usd_fp - want.realised_pnl_usd_fp).abs();
|
|
assert!(
|
|
diff <= want.realised_pnl_tol_usd_fp,
|
|
"trade[{i}].realised_pnl_usd_fp got {} want {} (tol {})",
|
|
got.realised_pnl_usd_fp, want.realised_pnl_usd_fp, want.realised_pnl_tol_usd_fp
|
|
);
|
|
}
|
|
}
|
|
|
|
if !fx.expected_pos.is_empty() {
|
|
anyhow::ensure!(
|
|
fx.expected_pos.len() == fx.n_backtests,
|
|
"expected_pos len {} ≠ n_backtests {}",
|
|
fx.expected_pos.len(),
|
|
fx.n_backtests
|
|
);
|
|
for (b, want) in fx.expected_pos.iter().enumerate() {
|
|
let got = sim.read_pos(b)?;
|
|
assert_eq!(
|
|
got.position_lots, want.position_lots,
|
|
"backtest {b} position_lots"
|
|
);
|
|
if want.position_lots != 0 {
|
|
let diff = (got.vwap_entry - want.vwap_entry).abs();
|
|
assert!(
|
|
diff <= want.vwap_tol,
|
|
"backtest {b} vwap_entry got {} want {} (tol {})",
|
|
got.vwap_entry, want.vwap_entry, want.vwap_tol
|
|
);
|
|
}
|
|
let pnl_diff = (got.realized_pnl - want.realized_pnl).abs();
|
|
assert!(
|
|
pnl_diff <= want.realized_pnl_tol,
|
|
"backtest {b} realized_pnl got {} want {} (tol {})",
|
|
got.realized_pnl, want.realized_pnl, want.realized_pnl_tol
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_book_update_replace() {
|
|
run_book_fixture(Path::new("tests/fixtures/book_update_replace.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_book_update_two_step() {
|
|
run_book_fixture(Path::new("tests/fixtures/book_update_two_step.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_book_update_n_backtests() {
|
|
run_book_fixture(Path::new("tests/fixtures/book_update_n_backtests.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_market_order_consumes_top() {
|
|
run_book_fixture(Path::new("tests/fixtures/market_order_consumes_top.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_pnl_accounting_buy_close() {
|
|
run_book_fixture(Path::new("tests/fixtures/pnl_accounting_buy_close.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_decision_alpha_buy_close() {
|
|
run_book_fixture(Path::new("tests/fixtures/decision_alpha_buy_close.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_limit_rest_marketable_fill() {
|
|
run_book_fixture(Path::new("tests/fixtures/limit_rest_marketable_fill.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_stop_trigger() {
|
|
run_book_fixture(Path::new("tests/fixtures/stop_trigger.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_oco_one_cancels_other() {
|
|
run_book_fixture(Path::new("tests/fixtures/oco_one_cancels_other.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_submission_overflow() {
|
|
run_book_fixture(Path::new("tests/fixtures/submission_overflow.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_latency_in_flight_miss() {
|
|
run_book_fixture(Path::new("tests/fixtures/latency_in_flight_miss.json")).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn fix_decision_program_h4_only() {
|
|
run_book_fixture(Path::new("tests/fixtures/decision_program_h4_only.json")).unwrap();
|
|
}
|