Files
foxhunt/crates/ml-backtesting/tests/lob_sim_fixtures.rs
jgrusewski 344d7a67d7 feat(ml-backtesting): wire --latency-ns end-to-end + fixture (C12)
The in-flight machinery from C11 is now reachable from the harness +
CLI. New step_decision_with_latency on LobSimCuda:

  latency_ns == 0  → existing immediate-match path (submit_market_immediate
                      kernel fills against current book).
  latency_ns > 0   → host reads market_targets, converts each non-noop
                      into seed_limit_order(active=2, price=very-aggressive,
                      arrival_ts_ns = current + latency_ns). The
                      resting_orders kernel promotes + fills at arrival
                      time, so the order sees whatever book exists then —
                      not the book at decision time.

Aggressive-price heuristic: buy at 1e9 / sell at 0 — the marketability
check (price ≥ best ask for buy, ≤ best bid for sell) unconditionally
crosses at arrival; the kernel then walks book levels for the actual
fill price. This models "market order with latency" correctly because
slippage emerges from the book-walking at arrival, not from a limit
price boundary.

BacktestHarness gains a latency_ns field; harness::run() always calls
step_decision_with_latency now. Also calls sim.step_resting_orders
per event (with trade_signed_vol=0.0 — the Mbp10RawInput trade-flow
hookup is deferred to a trades-feed integration commit) so in-flight
orders get a chance to promote on every snapshot.

bin/fxt-backtest no longer ignores --latency-ns; the flag value
propagates through BacktestHarnessConfig.latency_ns into the kernel
path. Default stays at 100_000_000 (IBKR + Scaleway baseline per
spec §4). Setting --latency-ns 0 selects the legacy immediate path.

Adds latency_in_flight_miss GPU fixture: limit at 5510 submitted
active=2 at T=1s with arrival_ts=T+100ms. step_resting at T+50ms
sees no promotion (still in-flight). Book moves +5 against buyer
during the 100ms window. step_resting at T+150ms promotes the
limit and fills it at the WORSE post-move book (vwap=5505 vs the
original 5500 it would have hit without latency). Slot ends active=0.

11/11 GPU fixtures green on RTX 3050. 33 lib tests still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:20:03 +02:00

472 lines
15 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;
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; 5],
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; 5]>,
#[serde(default)]
expected_min_trade_records: usize,
#[serde(default)]
expected_isv_kelly_after: Vec<[ExpectedIsvKelly; 5]>,
#[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,
}
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)?;
// 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; 5] = [
states[0].to_host(),
states[1].to_host(),
states[2].to_host(),
states[3].to_host(),
states[4].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)?;
sim.step_decision(*ts_ns, *target_annual_vol_units, *annualisation_factor, *max_lots)?;
}
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..5 {
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();
}