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>
472 lines
15 KiB
Rust
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();
|
|
}
|