Files
foxhunt/crates/ml-backtesting/tests/lob_sim_fuzz.rs
jgrusewski 771faac723 test(ml-backtesting): Ring 2 invariant fuzz at N ∈ {1, 16, 256} (C10)
Property-based fuzz tests with random-walk MBP-10 sequences applied to
the LOB simulator at three backtest counts; assert per-snapshot
invariants that must hold regardless of input or block scheduling:

  fuzz_n1_book_only (200 events, no orders)
    Pure book-update kernel — verifies mid-drift random walks preserve
    book monotonicity and never produce a crossed book.

  fuzz_n16_with_orders (200 events, market orders every 8th event)
    16 backtests in parallel, each submitting random buy/sell market
    orders 1-3 lots at every 8th event. Asserts book invariants on
    each backtest's state PLUS:
      - position_lots stays within ±30 (plausible given fixture book depth)
      - realized_pnl + vwap_entry finite (no NaN/Inf leaks)

  fuzz_n256_with_orders (100 events, market orders)
    Production-scale parallelism. Same invariants as N=16. Each block
    has its own per-backtest Pos + OpenTradeState + TradeLog, exercising
    the per-block isolation discipline established in C5-C7.

All 3 pass on RTX 3050. Spec §8 Ring 2 confidence gate hit.

Adds rand + rand_chacha to ml-backtesting dev-dependencies.

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

149 lines
5.2 KiB
Rust

//! Ring 2 — property-based fuzz at N ∈ {1, 16, 256}.
//!
//! Random-walk MBP-10 sequences applied to the LOB simulator; assert
//! invariants that must hold regardless of input or scheduling:
//!
//! * bid_px monotonically non-increasing across levels
//! * ask_px monotonically non-decreasing across levels
//! * ask_px[0] > bid_px[0] (no crossed book)
//! * Position lots stays within plausible bounds even under
//! random market orders
//! * Realised P&L finite (no NaN/Inf leaks)
//!
//! See docs/superpowers/specs/2026-05-18-real-lob-integration-design.md §8 Ring 2.
use anyhow::Result;
use ml_backtesting::sim::LobSimCuda;
use ml_core::device::MlDevice;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
const BOOK_LEVELS: usize = 10;
const TICK: f32 = 0.25;
fn make_initial_book() -> ([f32; BOOK_LEVELS], [f32; BOOK_LEVELS], [f32; BOOK_LEVELS], [f32; BOOK_LEVELS]) {
let mut bid_px = [0.0; BOOK_LEVELS];
let mut bid_sz = [0.0; BOOK_LEVELS];
let mut ask_px = [0.0; BOOK_LEVELS];
let mut ask_sz = [0.0; BOOK_LEVELS];
let mid = 5500.00_f32;
for k in 0..BOOK_LEVELS {
bid_px[k] = mid - TICK * (k as f32 + 1.0);
ask_px[k] = mid + TICK * (k as f32 + 1.0);
bid_sz[k] = 5.0 + 5.0 * k as f32;
ask_sz[k] = 5.0 + 5.0 * k as f32;
}
(bid_px, bid_sz, ask_px, ask_sz)
}
fn step_random_walk(
rng: &mut ChaCha8Rng,
bid_px: &mut [f32; BOOK_LEVELS],
bid_sz: &mut [f32; BOOK_LEVELS],
ask_px: &mut [f32; BOOK_LEVELS],
ask_sz: &mut [f32; BOOK_LEVELS],
) {
// Random mid drift in {-TICK, 0, +TICK} with mild bias.
let drift_choices = [-TICK, 0.0, 0.0, 0.0, TICK];
let drift = drift_choices[rng.gen_range(0..drift_choices.len())];
// Shift the whole book by drift, preserving level structure.
for k in 0..BOOK_LEVELS {
bid_px[k] += drift;
ask_px[k] += drift;
// Jitter sizes; clamp positive.
let dbz: f32 = rng.gen_range(-3.0..3.0);
let daz: f32 = rng.gen_range(-3.0..3.0);
bid_sz[k] = (bid_sz[k] + dbz).max(1.0);
ask_sz[k] = (ask_sz[k] + daz).max(1.0);
}
}
fn assert_book_invariants(b_idx: usize, bid_px: &[f32], bid_sz: &[f32], ask_px: &[f32], ask_sz: &[f32]) {
for k in 1..BOOK_LEVELS {
assert!(
bid_px[k] <= bid_px[k - 1] + 1e-4,
"backtest {b_idx}: bid_px non-monotone at level {k}: {} > {}",
bid_px[k], bid_px[k - 1]
);
assert!(
ask_px[k] >= ask_px[k - 1] - 1e-4,
"backtest {b_idx}: ask_px non-monotone at level {k}: {} < {}",
ask_px[k], ask_px[k - 1]
);
}
assert!(
ask_px[0] > bid_px[0],
"backtest {b_idx}: crossed book — ask[0]={}, bid[0]={}",
ask_px[0], bid_px[0]
);
for k in 0..BOOK_LEVELS {
assert!(bid_sz[k] > 0.0, "backtest {b_idx}: bid_sz[{k}] non-positive");
assert!(ask_sz[k] > 0.0, "backtest {b_idx}: ask_sz[{k}] non-positive");
assert!(bid_px[k].is_finite() && ask_px[k].is_finite());
}
}
fn run_fuzz_n(n_backtests: usize, n_events: usize, with_orders: bool, seed: u64) -> Result<()> {
let dev = MlDevice::cuda(0).map_err(|e| anyhow::anyhow!("cuda dev: {e}"))?;
let mut sim = LobSimCuda::new(n_backtests, &dev)?;
let mut rng = ChaCha8Rng::seed_from_u64(seed);
let (mut bid_px, mut bid_sz, mut ask_px, mut ask_sz) = make_initial_book();
let mut ts_ns: u64 = 1_000_000_000;
for event_idx in 0..n_events {
step_random_walk(&mut rng, &mut bid_px, &mut bid_sz, &mut ask_px, &mut ask_sz);
sim.apply_snapshot(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
// 1 in 8 events: each backtest submits a random market order.
if with_orders && event_idx % 8 == 0 {
for b in 0..n_backtests {
let side: u8 = if rng.gen_bool(0.5) { 0 } else { 1 };
let size: u16 = rng.gen_range(1..=3);
sim.submit_market(b, side, size, ts_ns)?;
}
}
// Invariants on book state.
let books = sim.read_books()?;
for (b, book) in books.iter().enumerate() {
assert_book_invariants(b, &book.bid_px, &book.bid_sz, &book.ask_px, &book.ask_sz);
}
if with_orders {
// Position + P&L sanity per backtest.
for b in 0..n_backtests {
let pos = sim.read_pos(b)?;
assert!(
pos.position_lots.abs() <= 30,
"backtest {b}: position_lots out of plausible range: {}",
pos.position_lots
);
assert!(pos.realized_pnl.is_finite(), "backtest {b}: realized_pnl NaN/Inf");
assert!(pos.vwap_entry.is_finite(), "backtest {b}: vwap_entry NaN/Inf");
}
}
ts_ns += 1_000_000;
}
Ok(())
}
#[test]
#[ignore = "requires CUDA"]
fn fuzz_n1_book_only() -> Result<()> {
run_fuzz_n(1, 200, /*with_orders=*/ false, 0xCAFE_F00D)
}
#[test]
#[ignore = "requires CUDA"]
fn fuzz_n16_with_orders() -> Result<()> {
run_fuzz_n(16, 200, /*with_orders=*/ true, 0xDEAD_BEEF)
}
#[test]
#[ignore = "requires CUDA"]
fn fuzz_n256_with_orders() -> Result<()> {
run_fuzz_n(256, 100, /*with_orders=*/ true, 0x1234_5678)
}