Files
foxhunt/crates/ml-backtesting/tests/lob_sim_fixtures.rs
jgrusewski 0c8b4b5608 refactor(per-horizon): N_HORIZONS 5→3 — ml-backtesting full propagation
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>
2026-05-22 01:37:23 +02:00

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();
}