Files
foxhunt/crates/ml-backtesting/tests/lob_sim_integrated_fuzz.rs
jgrusewski b8966fb1a6 feat(ml-backtesting): per-backtest sim parameter arrays (P1)
Migrates target_annual_vol_units, annualisation_factor, max_lots,
latency_ns, kelly_frac_floor, sharpe_weight_floor from scalar-broadcast
kernel args to per-backtest device arrays via BatchedSimConfig. Atomic
contract change per feedback_no_partial_refactor — kernel + sim + harness
+ all 3 existing test files migrate in this commit.

- crates/ml-backtesting/src/sim.rs → sim/mod.rs (directory module)
- crates/ml-backtesting/src/sim/batched_config.rs (NEW): BatchedSimConfig
  + UniformSimParams + validate(). from_uniform rebuilds the legacy
  uniform-broadcast behaviour at n=1 (smoke/fixtures). from_grid lands
  in P6 for the 140-variant sweep packing.
- LobSimCuda gains 6 per-backtest device buffers (target_annual_vol_units_d,
  annualisation_factor_d, max_lots_d, latency_ns_d, kelly_frac_floor_d,
  sharpe_weight_floor_d). step_decision_with_latency uploads from
  BatchedSimConfig each call; both decision kernel launches now pass
  per-backtest array pointers.
- decision_policy_default + decision_policy_program: scalar args become
  const float* / const int* per_b arrays; first lines of each kernel
  index by `b` into the arrays. Behaviour preserved at n=1 uniform.
- dispatch_latent_market_orders: reads latency per-backtest from
  &BatchedSimConfig (host loop stays for P1; P2 replaces with kernel).
- step_decision_with_latency now ALWAYS dispatches through the latency
  path; when cfg.latency_ns[b]=0 the in-flight slot's arrival_ts equals
  current_ts and gets promoted immediately on next snapshot. Eliminates
  the if/else branch and consolidates the launch path.
- harness.rs: BacktestHarness gains a sim_config field, built via
  BatchedSimConfig::from_uniform at new() from the harness cfg's scalar
  fields. The run loop passes &self.sim_config to step_decision_with_latency.

Regression coverage:
- parallel_sim_correctness::parallel_sim_equivalence_with_uniform_config
  — n=8 with uniform config produces 8 bit-identical market_targets
  (proves per-backtest indexing reduces correctly).
- Existing decision_floor_coldstart tests (3) all pass through the new
  ABI — proves cold-start floor + variance-cap gate behaviour preserved.
- parallel_sim_independence_per_backtest deferred to P2 (needs
  read_first_inflight_arrival_ts helper that depends on LimitSlot layout).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:58:10 +02:00

189 lines
7.4 KiB
Rust

//! Extended Ring 2 fuzz that exercises the FULL integrated pipeline
//! (book_update + resting_orders + decision_policy + pnl_track +
//! isv_kelly_update + step_resting_orders) under random adversarial
//! conditions. Existing lob_sim_fuzz only covers book + market orders.
//!
//! Goals:
//! * No NaN / Inf leaks in Pos.realized_pnl, IsvKellyState fields,
//! or per-event book state after thousands of random alpha decisions.
//! * submission_overflow counter stays plausible (i.e. doesn't run
//! away even when alpha churns orders every event).
//! * Trade-log + audit-ring never overflow their caps.
//! * Book invariants (monotonicity, no-crossed) hold under sustained
//! resting-order pressure + trade-flow simulation.
use anyhow::Result;
use ml_backtesting::policy::IsvKellyStateHost;
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] = 20.0 + 10.0 * k as f32;
ask_sz[k] = 20.0 + 10.0 * k as f32;
}
(bid_px, bid_sz, ask_px, ask_sz)
}
fn random_warm_start_isv(rng: &mut ChaCha8Rng) -> [IsvKellyStateHost; 5] {
let mut out: [IsvKellyStateHost; 5] = Default::default();
for h in 0..5 {
// Make at least one horizon credibly profitable so the decision
// kernel actually opens positions; others get random warm-starts.
let positive_h = h == 4;
let win_rate: f32 = if positive_h { 0.7 } else { rng.gen_range(0.3..0.6) };
let pnl_win: f32 = if positive_h { 2.0 } else { rng.gen_range(0.5..1.5) };
let pnl_loss: f32 = rng.gen_range(0.3..1.0);
let var: f32 = rng.gen_range(0.1..0.5);
let recent_sharpe: f32 = if positive_h { 1.0 } else { rng.gen_range(-0.5..0.5) };
out[h] = IsvKellyStateHost {
pnl_ema_win: pnl_win,
pnl_ema_loss: pnl_loss,
win_rate_ema: win_rate,
n_trades_seen: rng.gen_range(10..100),
realised_return_var: var,
recent_sharpe,
};
}
out
}
fn assert_finite_pos(b_idx: usize, pos: &ml_backtesting::lob::PosFlat) {
assert!(pos.realized_pnl.is_finite(), "backtest {b_idx}: realized_pnl NaN/Inf");
assert!(pos.vwap_entry.is_finite(), "backtest {b_idx}: vwap_entry NaN/Inf");
assert!(
pos.position_lots.abs() <= 100,
"backtest {b_idx}: position_lots out of plausible range: {}",
pos.position_lots
);
}
fn assert_finite_isv(b_idx: usize, h: usize, s: &IsvKellyStateHost) {
assert!(s.pnl_ema_win.is_finite(), "backtest {b_idx} h{h}: pnl_ema_win NaN/Inf");
assert!(s.pnl_ema_loss.is_finite(), "backtest {b_idx} h{h}: pnl_ema_loss NaN/Inf");
assert!(s.win_rate_ema.is_finite(), "backtest {b_idx} h{h}: win_rate_ema NaN/Inf");
assert!(s.realised_return_var.is_finite(), "backtest {b_idx} h{h}: realised_return_var NaN/Inf");
assert!(s.recent_sharpe.is_finite(), "backtest {b_idx} h{h}: recent_sharpe NaN/Inf");
}
fn run_integrated_fuzz(n_backtests: usize, n_events: usize, 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);
// Warm-start every backtest with random-but-plausible Kelly state.
for b in 0..n_backtests {
let states = random_warm_start_isv(&mut rng);
sim.write_isv_kelly(b, &states)?;
}
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;
let mut decision_idx: u32 = 0;
for event_idx in 0..n_events {
// Random walk the book.
let drift_choices = [-TICK, 0.0, 0.0, 0.0, TICK];
let drift = drift_choices[rng.gen_range(0..drift_choices.len())];
for k in 0..BOOK_LEVELS {
bid_px[k] += drift;
ask_px[k] += drift;
let dbz: f32 = rng.gen_range(-5.0..5.0);
let daz: f32 = rng.gen_range(-5.0..5.0);
bid_sz[k] = (bid_sz[k] + dbz).max(1.0);
ask_sz[k] = (ask_sz[k] + daz).max(1.0);
}
sim.apply_snapshot(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
// Inferred-like trade flow (random sign, small magnitude).
let signed_vol: f32 = rng.gen_range(-3.0..3.0);
sim.step_resting_orders(ts_ns, signed_vol)?;
// Decide every 4th event.
if event_idx % 4 == 0 {
let mut probs = [0.5_f32; 5];
for h in 0..5 {
probs[h] = rng.gen_range(0.1..0.9);
}
sim.broadcast_alpha(&probs)?;
// Mix latency vs immediate paths to cover both.
let latency = if decision_idx % 2 == 0 { 0 } else { 50_000_000 };
let sim_cfg = ml_backtesting::sim::BatchedSimConfig::from_uniform(
sim.n_backtests(),
&ml_backtesting::sim::UniformSimParams {
target_annual_vol_units: rng.gen_range(20.0..60.0),
annualisation_factor: rng.gen_range(500.0..1200.0),
max_lots: rng.gen_range(2..6),
latency_ns: latency,
kelly_frac_floor: 0.10,
sharpe_weight_floor: 0.10,
},
);
sim.step_decision_with_latency(ts_ns, &sim_cfg)?;
decision_idx += 1;
}
// Spot-check invariants every 50 events to bound test time.
if event_idx % 50 == 0 {
let books = sim.read_books()?;
for (b, book) in books.iter().enumerate() {
for k in 1..BOOK_LEVELS {
assert!(
book.bid_px[k] <= book.bid_px[k - 1] + 1e-4,
"event {event_idx} backtest {b}: bid monotonicity violated"
);
assert!(
book.ask_px[k] >= book.ask_px[k - 1] - 1e-4,
"event {event_idx} backtest {b}: ask monotonicity violated"
);
}
assert!(
book.ask_px[0] > book.bid_px[0],
"event {event_idx} backtest {b}: crossed book"
);
}
for b in 0..n_backtests {
let pos = sim.read_pos(b)?;
assert_finite_pos(b, &pos);
let isv = sim.read_isv_kelly(b)?;
for h in 0..5 {
assert_finite_isv(b, h, &isv[h]);
}
}
}
ts_ns += 1_000_000;
}
Ok(())
}
#[test]
#[ignore = "requires CUDA"]
fn integrated_fuzz_n1_short() -> Result<()> {
run_integrated_fuzz(1, 200, 0xCAFE_F00D)
}
#[test]
#[ignore = "requires CUDA"]
fn integrated_fuzz_n8_medium() -> Result<()> {
run_integrated_fuzz(8, 500, 0xDEAD_BEEF)
}
#[test]
#[ignore = "requires CUDA"]
fn integrated_fuzz_n64_long() -> Result<()> {
run_integrated_fuzz(64, 1000, 0x1234_5678)
}