//! Stop-controller spec §9.1 unit tests. //! See docs/superpowers/specs/2026-05-19-isv-driven-stop-controller-design.md. use anyhow::Result; use ml_backtesting::policy::{ EnsembleAggregator, Strategy, StrategyConfig, SizingPolicyId, N_HORIZONS, }; use ml_backtesting::sim::{BatchedSimConfig, LobSimCuda, UniformSimParams}; use ml_core::device::MlDevice; fn level_book(mid: f32, tick: f32) -> ([f32; 10], [f32; 10], [f32; 10], [f32; 10]) { let mut bid_px = [0.0; 10]; let mut bid_sz = [0.0; 10]; let mut ask_px = [0.0; 10]; let mut ask_sz = [0.0; 10]; for k in 0..10 { 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; ask_sz[k] = 20.0; } (bid_px, bid_sz, ask_px, ask_sz) } #[test] #[ignore = "requires CUDA"] fn atr_ema_bootstrap_first_observation() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; // Event 0: snapshot at mid=5500. After: prev_mid=5500, atr_mid_ema=0. let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; assert_eq!(sim.read_atr_mid_ema(0)?, 0.0, "ATR is 0 after first snapshot"); let prev0 = sim.read_prev_mid(0)?; assert!((prev0 - 5500.0).abs() < 1e-4, "prev_mid bootstrapped to 5500, got {prev0}"); // Event 1: snapshot at mid=5500.5. Δ=0.5. After: atr_mid_ema=0.5 (replace, not blend). let (bp, bs, ap, az) = level_book(5500.5, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let atr1 = sim.read_atr_mid_ema(0)?; assert!((atr1 - 0.5).abs() < 1e-4, "ATR after 2nd snapshot equals |Δmid|=0.5, got {atr1}"); // Events 2..200: jitter book. ATR stays positive and finite, bounded by max Δ. let mut mid = 5500.5_f32; let mut max_delta: f32 = 0.5; for i in 0..198 { let drift = if i % 3 == 0 { 0.25 } else if i % 3 == 1 { -0.25 } else { 0.0 }; mid += drift; max_delta = max_delta.max(drift.abs()); let (bp, bs, ap, az) = level_book(mid, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; } let atr_final = sim.read_atr_mid_ema(0)?; assert!(atr_final > 0.0 && atr_final.is_finite(), "ATR positive + finite after 200 snapshots, got {atr_final}"); assert!(atr_final <= max_delta + 1e-3, "ATR bounded by max observed Δ={max_delta}, got {atr_final}"); Ok(()) } fn cfg_default(n: usize) -> BatchedSimConfig { BatchedSimConfig::from_uniform(n, &UniformSimParams { target_annual_vol_units: 50.0, annualisation_factor: 825.0, max_lots: 5, latency_ns: 0, kelly_frac_floor: 0.20, sharpe_weight_floor: 0.10, threshold: 0.0, 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, }) } #[test] #[ignore = "requires CUDA"] fn stop_check_skipped_when_flat() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; // Position is flat (just allocated, zeros). Stop check must not fire; // alpha decides the action. sim.broadcast_alpha(&[0.8; N_HORIZONS])?; sim.step_decision_with_latency(0, &cfg_default(1))?; let (side, size) = sim.read_market_target(0)?; // Strong bullish alpha + flat position → entry side=0 size>=1. // Not (3, 0) (force-flat) and not unchanged from the initial alloc-zero state. assert_eq!(side, 0, "with flat position, stop must not write side=3; got side={side}"); assert!(size >= 1, "entry should fire under p=0.8 + flat (size={size})"); Ok(()) } use ml_backtesting::policy::IsvKellyStateHost; #[test] #[ignore = "requires CUDA"] fn sl_fires_when_unrealized_breaks_distance() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; // Seed isv: 5 horizons with pnl_ema_loss=2.0 → sl_distance=2.0. // pnl_ema_win=0.0 forces cold-start branch in decision kernel (kelly_frac → // floor=0.20, cap_lots → max_lots=5). With alpha=0.95 this gives // lots=round(0.9*0.20*5)=round(0.9)=1 — position opens. let seeded: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 0.0, pnl_ema_loss: 2.0, win_rate_ema: 0.5, n_trades_seen: 0, realised_return_var: 0.0, recent_sharpe: 0.5, }); sim.write_isv_kelly(0, &seeded)?; // Two snapshots so atr_mid_ema becomes nonzero (small). let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; // Open a long position via strong-alpha + step_decision + manual fill. sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(0, &cfg_default(1))?; let mut ts: u64 = 1_000_000; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; } let pos_open = sim.read_pos(0)?; assert!(pos_open.position_lots > 0, "test setup: long position should open, got {}", pos_open.position_lots); // Drive mid down by 3.0 (>= sl_distance=2.0). Stop must fire force-flat. let (bp3, bs3, ap3, az3) = level_book(5497.0, 0.25); sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_default(1))?; let (side, size) = sim.read_market_target(0)?; assert_eq!(side, 3, "SL must fire with force-flat (3, 0); got side={side}"); assert_eq!(size, 0, "force-flat encoding has abs_sz=0; got size={size}"); Ok(()) } #[test] #[ignore = "requires CUDA"] fn trail_arms_then_fires() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; // Cold-start ISV (forces kelly_frac_floor + max_lots cap) so position opens. // pnl_ema_loss large enough that SL won't fire during the +5pt walk. let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 0.0, pnl_ema_loss: 10.0, // big SL so trail fires first win_rate_ema: 0.0, n_trades_seen: 0, realised_return_var: 0.0, recent_sharpe: 0.0, }); sim.write_isv_kelly(0, &cold_start)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; // Open long. sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(0, &cfg_default(1))?; let mut ts: u64 = 1_000_000; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; } assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opened"); // After opening, switch ISV to one with pnl_ema_win=1.5 (trail_distance target). // The trail check reads this fresh state on subsequent decision steps. let trail_state: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 1.5, pnl_ema_loss: 10.0, win_rate_ema: 0.5, n_trades_seen: 20, realised_return_var: 0.5, recent_sharpe: 0.5, }); sim.write_isv_kelly(0, &trail_state)?; // Walk mid up to ratchet trail HWM past trail_distance=1.5. for &m in &[5501.0_f32, 5502.0, 5503.0, 5504.0, 5505.0] { let (b, bs, a, asz) = level_book(m, 0.25); sim.apply_snapshot(&b, &bs, &a, &asz)?; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_default(1))?; ts += 1_000_000; } let hwm_armed = sim.read_trail_hwm(0)?; assert!(hwm_armed > 1.5, "HWM should ratchet past trail_distance=1.5, got {hwm_armed}"); // Drop mid by trail_distance+ε from peak to fire trail. let (b, bs, a, asz) = level_book(5502.0, 0.25); // drop from peak 5505 to 5502 = 3.0 sim.apply_snapshot(&b, &bs, &a, &asz)?; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_default(1))?; let (side, size) = sim.read_market_target(0)?; assert_eq!(side, 3, "trail must fire force-flat; got side={side}"); assert_eq!(size, 0); Ok(()) } #[test] #[ignore = "requires CUDA"] fn multi_horizon_mask_averages_emas() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; // Cold-start to open. After opening, overwrite ISV so averaging arithmetic // yields mean_sl = 3.0 across all open horizons in the mask: // h0.pnl_ema_loss=2.0, h1=4.0, h2=h3=h4=3.0 → mean = (2+4+3*3)/5 = 3.0 // attribution_mask = 0x1F (all 5 bits) because sharpe_weight_floor=0.10 // makes every horizon weight > 1e-9 in decision_policy_default. // Snapshots placed relative to vwap_entry to avoid ask-spread sensitivity. // Bit-pick-first (h0=2.0) would fire at Δ_entry=2.5 → test catches it. // Bit-pick-max (h1=4.0) would not fire at Δ_entry=4.5 → test catches it. // Correct mean (3.0) → no fire at Δ_entry=2.5, fire at Δ_entry=4.5. let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 0.0, pnl_ema_loss: 10.0, win_rate_ema: 0.0, n_trades_seen: 0, realised_return_var: 0.0, recent_sharpe: 0.0, }); sim.write_isv_kelly(0, &cold_start)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(0, &cfg_default(1))?; let mut ts: u64 = 1_000_000; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; } let pos_open = sim.read_pos(0)?; assert!(pos_open.position_lots > 0, "setup: long opens, got {}", pos_open.position_lots); // Read the actual VWAP entry price so snapshot mids are placed relative to it, // making the test robust to ask-spread and tick size. let entry_px = pos_open.vwap_entry; assert!(entry_px > 0.0, "vwap_entry must be set after fill, got {entry_px}"); // Seed multi-horizon ISV state. pnl_ema_win=100.0 keeps trail_distance huge // so the trail never fires during the test. // See setup comment above: mean=3.0, regression discriminators at Δ=2.5/4.5. let mut seeded: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 100.0, pnl_ema_loss: 3.0, win_rate_ema: 0.5, n_trades_seen: 20, realised_return_var: 0.5, recent_sharpe: 0.5, }); seeded[0].pnl_ema_loss = 2.0; seeded[1].pnl_ema_loss = 4.0; // h2, h3, h4 remain 3.0. Mean = (2+4+3+3+3)/5 = 3.0. sim.write_isv_kelly(0, &seeded)?; // Snapshots placed so bid[0] = entry_px - delta_from_entry (for a long): // unrealized_pl_per_lot = bid[0] - entry_px = -delta_from_entry // sl_distance = fmaxf(mean_ema_loss=3.0, atr). ATR after these moderate // price moves stays < 3.0 (the move is ~2.5 from the open level), so // sl_distance == 3.0. (If ATR somehow > 3.0, sl_distance > 3.0 and the // no-fire leg is still safe; only the fire leg could be affected but // we use Δ=4.5 which gives 1.5× headroom above the 3.0 floor.) // // No-fire: Δ_from_entry = 2.5 — bid[0] = entry_px - 2.5 → mid = entry_px - 2.25 let mid_no_fire = entry_px - 2.25; let (bp3, bs3, ap3, az3) = level_book(mid_no_fire, 0.25); sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_default(1))?; let (side_at_25, _) = sim.read_market_target(0)?; assert_ne!(side_at_25, 3, "Δ=2.5 < mean_sl=3.0 must not fire under correct averaging; got side={side_at_25}"); ts += 1_000_000; let atr_at_check = sim.read_atr_mid_ema(0)?; assert!( atr_at_check < 3.0, "test precondition: ATR must stay below mean_sl=3.0 to make sl_distance = max(ema_loss=3.0, atr) = 3.0; got atr={atr_at_check}" ); // Fire: Δ_from_entry = 4.5 (> mean_sl=3.0 and > any reasonable ATR floor) // bid[0] = entry_px - 4.5 → mid = entry_px - 4.25 let mid_fire = entry_px - 4.25; let (bp4, bs4, ap4, az4) = level_book(mid_fire, 0.25); sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_default(1))?; let (side_at_45, size_at_45) = sim.read_market_target(0)?; assert_eq!(side_at_45, 3, "Δ=4.5 > mean_sl=3.0 must fire force-flat; got side={side_at_45}"); assert_eq!(size_at_45, 0); Ok(()) } #[test] #[ignore = "requires CUDA"] fn bytecode_and_default_kernel_agree_on_stops() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; // Helper: drive one scenario using either the default kernel (use_program=false) // or the bytecode VM kernel (use_program=true). Returns the market_target after // a stop condition is triggered. fn drive_scenario(use_program: bool, dev: &MlDevice) -> Result<(i32, i32)> { let mut sim = LobSimCuda::new(1, dev)?; if use_program { // Upload an Ensemble(MaxConfidence) program so program_lens[0] != 0 // and the program kernel handles this backtest. let prog = Strategy::Ensemble { children: (0..N_HORIZONS as u8) .map(|h| Strategy::Leaf(StrategyConfig { horizon_idx: h, sizing_policy: SizingPolicyId::IsvKelly, max_concurrent_lots: 5, })) .collect(), aggregator: EnsembleAggregator::MaxConfidence, }; sim.upload_program(0, &prog.flatten())?; } // Cold-start ISV: pnl_ema_win=0.0, n_trades_seen=0, realised_return_var=0.0 // forces kelly_frac_floor + max_lots path so position opens. // pnl_ema_loss=10.0 keeps SL far enough that it won't fire during open setup. let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 0.0, pnl_ema_loss: 10.0, win_rate_ema: 0.0, n_trades_seen: 0, realised_return_var: 0.0, recent_sharpe: 0.0, }); sim.write_isv_kelly(0, &cold_start)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; // Open long. sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(0, &cfg_default(1))?; let mut ts: u64 = 1_000_000; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; } let pos_open = sim.read_pos(0)?; assert!( pos_open.position_lots > 0, "setup ({} kernel): long must open, got {}", if use_program { "program" } else { "default" }, pos_open.position_lots ); // Set SL trigger setup: pnl_ema_loss=2.0 → sl_distance=2.0 (assuming ATR < 2.0). // pnl_ema_win=10.0 keeps trail_distance=10.0 so trail won't fire before SL. let triggered: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 10.0, pnl_ema_loss: 2.0, win_rate_ema: 0.5, n_trades_seen: 20, realised_return_var: 0.5, recent_sharpe: 0.5, }); sim.write_isv_kelly(0, &triggered)?; // Drive mid down well past sl_distance=2.0 relative to vwap_entry. // trigger_mid = entry - 4.0 → unrealized = bid[0] - entry = (mid - tick) - entry // = (entry - 4.0 - 0.25) - entry = -4.25 <= -2.0 → SL fires. let trigger_mid = pos_open.vwap_entry - 4.0; let (bp3, bs3, ap3, az3) = level_book(trigger_mid, 0.25); sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_default(1))?; sim.read_market_target(0) } let mt_default = drive_scenario(false, &dev)?; let mt_program = drive_scenario(true, &dev)?; assert_eq!( mt_default, mt_program, "default kernel and program kernel disagree on stop output: default={mt_default:?} program={mt_program:?}" ); assert_eq!( mt_default.0, 3, "both kernels must fire stop force-flat (side=3); got side={}", mt_default.0 ); assert_eq!(mt_default.1, 0, "force-flat encoding has abs_sz=0; got size={}", mt_default.1); Ok(()) } #[test] #[ignore = "requires CUDA"] fn position_target_not_additive() -> Result<()> { // latency=0: target (0, n) repeated must NOT accumulate the position // past n. Cap is max_lots=5. let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let cfg = cfg_default(1); let mut ts: u64 = 0; for _ in 0..10 { sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg)?; sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; } let pos = sim.read_pos(0)?; assert!(pos.position_lots.abs() <= 5, "|position_lots| must respect max_lots=5 with target semantics; got {}", pos.position_lots); Ok(()) } #[test] #[ignore = "requires CUDA"] fn position_target_not_additive_with_latency() -> Result<()> { // 200ms latency: in-flight orders must count toward effective_position // so re-submission of the same target produces delta=0 and no new order. let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let cfg_lat = BatchedSimConfig::from_uniform(1, &UniformSimParams { target_annual_vol_units: 50.0, annualisation_factor: 825.0, max_lots: 5, latency_ns: 200_000_000, kelly_frac_floor: 0.20, sharpe_weight_floor: 0.10, threshold: 0.0, 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, }); let mut ts: u64 = 0; // 500 events of persistent target — most should produce delta=0. for _ in 0..500 { sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_lat)?; sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; } let pos = sim.read_pos(0)?; assert!(pos.position_lots.abs() <= 5, "|position_lots| must stay <= max_lots=5 under 200ms latency with in-flight summation; got {}", pos.position_lots); Ok(()) } #[test] #[ignore = "requires CUDA"] fn trail_hwm_reset_on_close() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 0.0, pnl_ema_loss: 10.0, win_rate_ema: 0.0, n_trades_seen: 0, realised_return_var: 0.0, recent_sharpe: 0.0, }); sim.write_isv_kelly(0, &cold_start)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; // Open long. sim.broadcast_alpha(&[0.95; N_HORIZONS])?; let mut ts: u64 = 1_000_000; sim.step_decision_with_latency(ts, &cfg_default(1))?; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens"); // Switch ISV to trail-test state (pnl_ema_win=1.5 → trail_distance=1.5). let trail_state: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 1.5, pnl_ema_loss: 100.0, // big SL → only trail can fire win_rate_ema: 0.5, n_trades_seen: 20, realised_return_var: 0.5, recent_sharpe: 0.5, }); sim.write_isv_kelly(0, &trail_state)?; // Ratchet HWM up. for &m in &[5501.0_f32, 5502.0, 5503.0, 5504.0, 5505.0] { let (b, bs, a, asz) = level_book(m, 0.25); sim.apply_snapshot(&b, &bs, &a, &asz)?; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_default(1))?; sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } let hwm_armed = sim.read_trail_hwm(0)?; assert!(hwm_armed > 1.5, "HWM should ratchet past trail_distance=1.5, got {hwm_armed}"); // Fire trail — drop mid past trail_distance from peak. let (b, bs, a, asz) = level_book(5502.0, 0.25); sim.apply_snapshot(&b, &bs, &a, &asz)?; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_default(1))?; let (s_fire, _) = sim.read_market_target(0)?; assert_eq!(s_fire, 3, "trail must fire force-flat"); // Drain so position flattens AND pnl_track runs. for _ in 0..10 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } let pos_closed = sim.read_pos(0)?; assert_eq!(pos_closed.position_lots, 0, "position must flatten after trail fire; got {}", pos_closed.position_lots); let hwm_after_close = sim.read_trail_hwm(0)?; assert_eq!(hwm_after_close, 0.0, "pnl_track_step must reset trail_hwm to 0 on close; got {hwm_after_close}"); Ok(()) } /// Validates that max_hold_ns fires a force-flat when the elapsed hold /// time exceeds the configured limit, and does NOT fire before that limit. /// /// S2.2: max_hold enforcement moved to resting_orders_step (event-rate). /// The test now exercises the event-rate path via step_resting_orders /// rather than checking the market_target written by stop_check_isv. /// /// Test geometry: /// - max_hold_ns = 100ms = 100_000_000ns /// - Open long at t=1ms via strong alpha /// - At entry_ts + 50ms: step_resting_orders at 50ms must NOT close (elapsed < cap) /// - At entry_ts + 150ms: step_resting_orders at 150ms must close (elapsed >= cap) #[test] #[ignore = "requires CUDA"] fn max_hold_forces_close() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; // Cold-start ISV: large pnl_ema_loss so SL never fires during the test. let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 0.0, pnl_ema_loss: 100.0, win_rate_ema: 0.0, n_trades_seen: 0, realised_return_var: 0.0, recent_sharpe: 0.0, }); sim.write_isv_kelly(0, &cold_start)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; // Config with max_hold_ns = 100ms. let cfg_max_hold = BatchedSimConfig::from_uniform(1, &UniformSimParams { target_annual_vol_units: 50.0, annualisation_factor: 825.0, max_lots: 5, latency_ns: 0, kelly_frac_floor: 0.20, sharpe_weight_floor: 0.10, threshold: 0.0, cost_per_lot_per_side: 1.0, max_hold_ns: 100_000_000, // 100ms min_reasonable_px: 0.0, max_reasonable_px: f32::INFINITY, delta_floor: 0.0, }); // Open long at t=1ms via strong alpha. let mut ts: u64 = 1_000_000; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_max_hold)?; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens"); let entry_ts = ts; // At entry_ts + 50ms — still under 100ms hold. step_resting_orders must NOT close. ts = entry_ts + 50_000_000; let (bp3, bs3, ap3, az3) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?; sim.step_resting_orders(ts, 0.0)?; let pos_pre = sim.read_pos(0)?; assert_ne!(pos_pre.position_lots, 0, "elapsed 50ms < max_hold=100ms: resting_orders must NOT close; position_lots={}", pos_pre.position_lots); // At entry_ts + 150ms — past max_hold. step_resting_orders must force-close. ts = entry_ts + 150_000_000; let (bp4, bs4, ap4, az4) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?; sim.step_resting_orders(ts, 0.0)?; let pos_post = sim.read_pos(0)?; assert_eq!(pos_post.position_lots, 0, "elapsed 150ms > max_hold=100ms: resting_orders must force-close; position_lots={}", pos_post.position_lots); Ok(()) } /// Validates that NaN or Inf prices in an MBP-10 snapshot do not propagate /// into realized_pnl via the book walk → fill pipeline. /// /// Real MBP-10 data at session boundaries or gap-fills can contain NaN or Inf /// in level prices/sizes. apply_snapshot_kernel must sanitize them so the walk /// helpers never accumulate NaN/Inf cost, which would corrupt avg_px and /// subsequently realized_pnl, emitting the ±21,474,836 sentinel. #[test] #[ignore = "requires CUDA"] fn book_nan_inf_prices_dont_corrupt_realized_pnl() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; // Build a book with NaN at one level and Inf at another (real MBP-10 // data at session boundaries can contain these). let mut bid_px = [0.0_f32; 10]; let mut bid_sz = [0.0_f32; 10]; let mut ask_px = [0.0_f32; 10]; let mut ask_sz = [0.0_f32; 10]; for k in 0..10 { bid_px[k] = 5500.0 - 0.25 * (k as f32 + 1.0); ask_px[k] = 5500.0 + 0.25 * (k as f32 + 1.0); bid_sz[k] = 20.0; ask_sz[k] = 20.0; } // Inject corruption at level 3 (NaN price) and level 5 (Inf size). ask_px[3] = f32::NAN; bid_sz[5] = f32::INFINITY; // First snapshot bootstraps ATR + book. sim.apply_snapshot(&bid_px, &bid_sz, &ask_px, &ask_sz)?; // Second snapshot: same book; ATR converges; sanitization tested again. sim.apply_snapshot(&bid_px, &bid_sz, &ask_px, &ask_sz)?; // Open long via strong alpha. The fill must NOT propagate NaN/Inf into // pos.realized_pnl, even though the book has NaN at ask[3] and Inf at bid[5]. sim.broadcast_alpha(&[0.95; N_HORIZONS])?; let mut ts: u64 = 1_000_000; sim.step_decision_with_latency(ts, &cfg_default(1))?; for _ in 0..5 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } let pos = sim.read_pos(0)?; assert!( pos.realized_pnl.is_finite(), "realized_pnl must stay finite even with NaN/Inf in book; got {}", pos.realized_pnl ); // Position may or may not have opened (depending on which levels were used). // The invariant is finiteness, not a specific position state. Ok(()) } /// Validates that pnl_track resets scratch on close so that: /// (a) flat events after a close do NOT emit duplicate close records, and /// (b) the next open transition is captured correctly (exactly 1 record per trade). /// /// Without the scratch reset: prev_size stays = prior entry_size, so every flat /// event fires the close branch again (duplicates). The next open (0→1) sees /// prev=1, now=1 → neither branch fires → open record never captured → second /// close uses stale realized_at_open from trade 1. #[test] #[ignore = "requires CUDA"] fn pnl_track_resets_scratch_on_close() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 0.0, pnl_ema_loss: 100.0, // huge SL so it doesn't fire win_rate_ema: 0.0, n_trades_seen: 0, realised_return_var: 0.0, recent_sharpe: 0.0, }); sim.write_isv_kelly(0, &cold_start)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; // Config with max_hold_ns = 100ms to force-close without needing SL/trail. let cfg_max_hold = BatchedSimConfig::from_uniform(1, &UniformSimParams { target_annual_vol_units: 50.0, annualisation_factor: 825.0, max_lots: 5, latency_ns: 0, kelly_frac_floor: 0.20, sharpe_weight_floor: 0.10, threshold: 0.0, cost_per_lot_per_side: 1.0, max_hold_ns: 100_000_000, // 100ms min_reasonable_px: 0.0, max_reasonable_px: f32::INFINITY, delta_floor: 0.0, }); // Trade 1: open long, force-flatten via max_hold. let mut ts: u64 = 1_000_000; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_max_hold)?; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } assert!(sim.read_pos(0)?.position_lots > 0, "trade 1: long opens"); // Force close trade 1 via max_hold: jump ts by 200ms. ts += 200_000_000; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_max_hold)?; for _ in 0..5 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } assert_eq!(sim.read_pos(0)?.position_lots, 0, "trade 1: closed via max_hold"); let trades_after_t1 = sim.read_total_trade_count()?; assert_eq!(trades_after_t1, 1, "trade 1: exactly ONE record emitted (no duplicates); got {trades_after_t1}"); // Several flat events — pnl_track must NOT emit duplicate close records. for _ in 0..5 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } let trades_after_flat = sim.read_total_trade_count()?; assert_eq!(trades_after_flat, 1, "flat events: no duplicate close records; got {trades_after_flat}"); // Trade 2: re-open + close via max_hold. ts += 10_000_000; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_max_hold)?; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } assert!(sim.read_pos(0)?.position_lots > 0, "trade 2: long opens"); // Force close trade 2. ts += 200_000_000; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_max_hold)?; for _ in 0..5 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } assert_eq!(sim.read_pos(0)?.position_lots, 0, "trade 2: closed via max_hold"); let trades_after_t2 = sim.read_total_trade_count()?; assert_eq!(trades_after_t2, 2, "trade 2: exactly TWO records total (1 per trade, no missed open); got {trades_after_t2}"); Ok(()) } /// Validates that the trade-vol floor prevents sub-cost stop fires. /// /// Per pearl_trade_level_vol_for_stop_distance.md: at cold-start (var=0), /// trade_vol = sqrt(max(0, cost²)) = cost = 0.125. With ema_loss=0 and /// ATR decayed to ≈ 0.003 before entry, sl_distance = max(0, atr, 0.125) = 0.125. /// /// Test geometry (long position, tick=0.25, cost=0.125): /// entry fills at ask[0] = mid_fill + tick /// unrealized = bid[0] - entry = (mid_new - tick) - entry /// /// Boundary straddling trade_vol = 0.125: /// no-fire (u = -0.08): mid_new = entry + tick - 0.08 = entry + 0.17 /// |u| = 0.08 < trade_vol = 0.125 → NOT fire ✓ /// fire (u = -0.20): mid_new = entry + tick - 0.20 = entry + 0.05 /// |u| = 0.20 > trade_vol = 0.125 → fires ✓ /// /// ATR convergence: 25 identical snapshots (delta=0) decay ATR to ≈ 0.003 /// before opening. Both discriminating snaps produce ATR well below 0.125, /// so trade_vol is always the binding constraint in this cold-start scenario. #[test] #[ignore = "requires CUDA"] fn trade_vol_floor_prevents_sub_cost_stops() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; // Zero ema_loss: only ATR and cost floor govern sl_distance. // Large pnl_ema_win to keep trail_distance enormous, preventing trail fires. let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 100.0, pnl_ema_loss: 0.0, win_rate_ema: 0.0, n_trades_seen: 0, realised_return_var: 0.0, recent_sharpe: 0.0, }); sim.write_isv_kelly(0, &cold_start)?; // Bootstrap ATR: two different mids to seed first-observation atr_mid_ema. let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; // Converge ATR to ≈ 0 via 25 identical snapshots at 5500.1 (delta=0). // After 25 steps: atr ≈ 0.6^25 * 0.1 ≈ 0.003 — tiny baseline. for _ in 0..25 { let (bp, bs, ap, az) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; } // Open long with cost=0.125 (trade_vol floor at cold-start = cost = 0.125). let cfg_cost = BatchedSimConfig::from_uniform(1, &UniformSimParams { target_annual_vol_units: 50.0, annualisation_factor: 825.0, max_lots: 5, latency_ns: 0, kelly_frac_floor: 0.20, sharpe_weight_floor: 0.10, threshold: 0.0, cost_per_lot_per_side: 0.125, max_hold_ns: 0, min_reasonable_px: 0.0, max_reasonable_px: f32::INFINITY, delta_floor: 0.0, }); sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(0, &cfg_cost)?; let mut ts: u64 = 1_000_000; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; } let pos_open = sim.read_pos(0)?; assert!(pos_open.position_lots > 0, "setup: long opens (got {})", pos_open.position_lots); let entry = pos_open.vwap_entry; // entry ≈ 5500.1 + tick(0.25) = 5500.35 // No-fire leg: unrealized = -0.08 (< trade_vol = cost = 0.125). // trade_vol = sqrt(max(var=0, cost²=0.015625)) = 0.125 // sl_distance = max(0, ATR≈0.003, 0.125) = 0.125; 0.08 < 0.125 → NOT fire ✓ // bid[0] = entry - 0.08 → mid = entry - 0.08 + tick(0.25) = entry + 0.17 let mid_no_fire = entry + 0.17; let (bp3, bs3, ap3, az3) = level_book(mid_no_fire, 0.25); sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?; // ATR update: delta ≈ |(entry+0.17) - 5500.1| — pumped slightly but still << 0.125 ✓ sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_cost)?; let (side_no_fire, _) = sim.read_market_target(0)?; assert_ne!(side_no_fire, 3, "unrealized=-0.08 < trade_vol=0.125: SL must NOT fire; got side={side_no_fire}"); ts += 1_000_000; // Fire leg: unrealized = -0.20 (> trade_vol = 0.125 → fires). // bid[0] = entry - 0.20 → mid = entry - 0.20 + tick(0.25) = entry + 0.05 let mid_fire = entry + 0.05; let (bp4, bs4, ap4, az4) = level_book(mid_fire, 0.25); sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_cost)?; let (side_fire, size_fire) = sim.read_market_target(0)?; assert_eq!(side_fire, 3, "unrealized=-0.20 > trade_vol=0.125: SL must fire force-flat; got side={side_fire}"); assert_eq!(size_fire, 0); Ok(()) } /// Validates that a timestamp gap > 1 hour (session boundary / weekend halt) /// force-closes any open position before the next event is processed. /// /// max_hold_ns can't fire during a halt because no events advance current_ts. /// The session-gap check in resting_orders_step detects the jump and zeroes /// position_lots directly; pnl_track_step's existing close branch then emits /// exactly one TradeRecord. #[test] #[ignore = "requires CUDA"] fn session_gap_force_closes_open_positions() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 0.0, pnl_ema_loss: 100.0, // huge SL — won't fire win_rate_ema: 0.0, n_trades_seen: 0, realised_return_var: 0.0, recent_sharpe: 0.0, }); sim.write_isv_kelly(0, &cold_start)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; // Open long at t=1ms. let mut ts: u64 = 1_000_000; sim.broadcast_alpha(&[0.95; N_HORIZONS])?; sim.step_decision_with_latency(ts, &cfg_default(1))?; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens"); let trades_before_gap = sim.read_total_trade_count()?; // Advance ts by 2 hours (> 1 hour SESSION_GAP_NS). Call step_resting_orders // with the new ts — session-gap force-close fires, then pnl_track detects // the prev!=0 && now==0 close transition and emits a TradeRecord. ts += 2 * 3_600_000_000_000u64; sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; assert_eq!( sim.read_pos(0)?.position_lots, 0, "session gap > 1h must force-close the position" ); let trades_after_gap = sim.read_total_trade_count()?; assert_eq!( trades_after_gap, trades_before_gap + 1, "session-gap close emits exactly one TradeRecord; got {} -> {}", trades_before_gap, trades_after_gap ); Ok(()) } /// Validates that a corrupt top-of-book snapshot is skipped entirely and the /// per-backtest `snapshots_skipped` counter is incremented. /// /// Bug C-b (smoke v74v4 2026-05-20): per-level sanitization (Task 15) zeros each /// unhealthy level individually. When ALL 10 levels are invalid (session-boundary /// event), the book becomes uniformly zero. apply_fill_to_pos then reads /// bid_px[0]=0/ask_px[0]=0 → vwap_entry=0 → trade record entry_px=0 (zero sentinel). /// /// Fix: pre-validate top-of-book in apply_snapshot_kernel. If bid_px[0]/ask_px[0] /// are non-finite or zero/negative, skip the entire snapshot — book, prev_mid, /// and atr_mid_ema remain unchanged. #[test] #[ignore = "requires CUDA"] fn corrupt_top_of_book_skips_snapshot_and_increments_counter() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; // Apply a valid book first to seed prev_mid and atr_mid_ema. let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; // Apply a second valid snapshot to advance ATR from 0. let (bp2, bs2, ap2, az2) = level_book(5500.5, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; let atr_before = sim.read_atr_mid_ema(0)?; let prev_mid_before = sim.read_prev_mid(0)?; // Sanity: atr_before must be nonzero after two valid snapshots with Δ=0.5. assert!(atr_before > 0.0, "ATR should be nonzero after two valid snapshots, got {atr_before}"); // Case 1: top-of-book bid_px[0] = NaN. Whole snapshot must be skipped. let mut bp_bad = bp2; bp_bad[0] = f32::NAN; sim.apply_snapshot(&bp_bad, &bs2, &ap2, &az2)?; let skipped = sim.read_snapshots_skipped()?; assert_eq!(skipped[0], 1, "corrupt top-of-book (NaN bid_px[0]) should increment skip counter to 1; got {}", skipped[0]); let atr_after_nan = sim.read_atr_mid_ema(0)?; let prev_mid_after_nan = sim.read_prev_mid(0)?; assert_eq!(atr_after_nan, atr_before, "ATR must not change on corrupt snapshot; before={atr_before} after={atr_after_nan}"); assert_eq!(prev_mid_after_nan, prev_mid_before, "prev_mid must not change on corrupt snapshot; before={prev_mid_before} after={prev_mid_after_nan}"); // Case 2: top-of-book bid_sz[0] = 0. Must also skip. let mut bs_bad = bs2; bs_bad[0] = 0.0; sim.apply_snapshot(&bp2, &bs_bad, &ap2, &az2)?; let skipped_2 = sim.read_snapshots_skipped()?; assert_eq!(skipped_2[0], 2, "zero top-of-book bid_sz[0] should increment skip counter to 2; got {}", skipped_2[0]); let atr_after_zero_sz = sim.read_atr_mid_ema(0)?; assert_eq!(atr_after_zero_sz, atr_before, "ATR must not change on zero-size corrupt snapshot; got {atr_after_zero_sz}"); // Case 3: valid snapshot after two corrupt skips must update ATR normally. let (bp3, bs3, ap3, az3) = level_book(5500.5, 0.25); sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?; let skipped_3 = sim.read_snapshots_skipped()?; assert_eq!(skipped_3[0], 2, "valid snapshot must not increment skip counter; got {}", skipped_3[0]); let atr_after_valid = sim.read_atr_mid_ema(0)?; // mid didn't move (same 5500.5 as prev), so delta=0, atr stays at atr_before. // The assertion here is that it is still finite and the state is reachable. assert!(atr_after_valid.is_finite(), "ATR must remain finite after valid snapshot following skips; got {atr_after_valid}"); // Case 4: valid snapshot with a different mid forces an ATR update, proving // the pipeline is unblocked after the skips. let (bp4, bs4, ap4, az4) = level_book(5501.5, 0.25); sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?; let atr_after_move = sim.read_atr_mid_ema(0)?; assert_ne!(atr_after_move, atr_before, "valid snapshot with Δmid=1.0 must update ATR; before={atr_before} after={atr_after_move}"); Ok(()) } /// Validates that all 6 NaN instrumentation counters (S1.20) are zero after /// LobSimCuda::new() with no kernel launches. This confirms the device buffers /// are correctly zero-initialized and the accessor compiles + works. #[test] #[ignore = "requires CUDA"] fn nan_counters_initialize_to_zero() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let sim = LobSimCuda::new(1, &dev)?; let counters = sim.read_nan_counters()?; assert_eq!(counters.nan_avg_px[0], 0, "nan_avg_px must be zero at init"); assert_eq!(counters.nan_realised[0], 0, "nan_realised must be zero at init"); assert_eq!(counters.nan_realized_pnl[0], 0, "nan_realized_pnl must be zero at init"); assert_eq!(counters.zero_vwap_at_open[0], 0, "zero_vwap_at_open must be zero at init"); assert_eq!(counters.saturated_vwap_at_open[0], 0, "saturated_vwap_at_open must be zero at init"); assert_eq!(counters.defensive_exit_clamp[0], 0, "defensive_exit_clamp must be zero at init"); Ok(()) } /// Validates that the parameterized price-range filter rejects real-data outliers. /// /// Audit (fxt-data-audit on ES.FUT_2024-Q1) found: /// bid_min = -$4.85 (negative bid) /// bid_p1 = $64.15 (sub-$100, far below ES range) /// ask_max = $53,012 (10x above any plausible ES price) /// /// The old hardcoded `< 1e8` threshold ($100M) never triggered on any of these. /// This test pins the corrected behavior: upload min=1000/max=20000, then verify /// that sub-range, super-range, and negative-bid snapshots all increment /// snapshots_skipped and leave prev_mid unchanged; valid ES snapshots pass through. #[test] #[ignore = "requires CUDA"] fn price_range_rejection_skips_snapshot() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; // Configure a tight ES-like range. sim.upload_price_range(&[1000.0], &[20000.0])?; // Valid snapshot at ES range — should pass and be applied. let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let skipped_after_valid = sim.read_snapshots_skipped()?; assert_eq!(skipped_after_valid[0], 0, "valid snapshot must not skip"); let prev_mid_after_valid = sim.read_prev_mid(0)?; assert!( (prev_mid_after_valid - 5500.0).abs() < 1e-4, "valid snapshot must set prev_mid to mid; got {prev_mid_after_valid}" ); // Sub-range top-of-book (e.g. $50) — should be skipped. let (bp_low, bs_low, ap_low, az_low) = level_book(50.0, 0.25); sim.apply_snapshot(&bp_low, &bs_low, &ap_low, &az_low)?; let skipped_after_low = sim.read_snapshots_skipped()?; assert_eq!(skipped_after_low[0], 1, "sub-range snapshot must skip"); let prev_mid_after_low = sim.read_prev_mid(0)?; assert!( (prev_mid_after_low - prev_mid_after_valid).abs() < 1e-4, "skipped snapshot must not mutate prev_mid; got {prev_mid_after_low}" ); // Super-range top-of-book ($50,000) — should be skipped. let (bp_high, bs_high, ap_high, az_high) = level_book(50000.0, 0.25); sim.apply_snapshot(&bp_high, &bs_high, &ap_high, &az_high)?; let skipped_after_high = sim.read_snapshots_skipped()?; assert_eq!(skipped_after_high[0], 2, "super-range snapshot must skip"); // Negative bid (bid[0] = -5.0) — should be skipped. let (mut bp_neg, bs_neg, ap_neg, az_neg) = level_book(5500.0, 0.25); bp_neg[0] = -5.0; sim.apply_snapshot(&bp_neg, &bs_neg, &ap_neg, &az_neg)?; let skipped_after_neg = sim.read_snapshots_skipped()?; assert_eq!(skipped_after_neg[0], 3, "negative bid must skip"); Ok(()) } /// S1.21: All per-vwap-write-site v2 counters initialize to zero at /// LobSimCuda construction; last_bad_path initializes to zero (no bad /// write observed yet). #[test] #[ignore = "requires CUDA"] fn nan_counters_v2_initialize_to_zero() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let sim = LobSimCuda::new(1, &dev)?; let c = sim.read_nan_counters_v2()?; assert_eq!(c.vwap_zero_open_flat[0], 0, "vwap_zero_open_flat must initialize to 0"); assert_eq!(c.vwap_huge_open_flat[0], 0, "vwap_huge_open_flat must initialize to 0"); assert_eq!(c.vwap_zero_scale_in[0], 0, "vwap_zero_scale_in must initialize to 0"); assert_eq!(c.vwap_huge_scale_in[0], 0, "vwap_huge_scale_in must initialize to 0"); assert_eq!(c.vwap_zero_flip[0], 0, "vwap_zero_flip must initialize to 0"); assert_eq!(c.vwap_huge_flip[0], 0, "vwap_huge_flip must initialize to 0"); assert_eq!(c.vwap_session_gap_was_bad[0], 0, "vwap_session_gap_was_bad must initialize to 0"); assert_eq!(c.last_bad_path[0], 0, "last_bad_path must initialize to 0 (no bad write)"); Ok(()) } #[test] #[ignore = "requires CUDA"] fn max_hold_counters_initialize_to_zero() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let sim = LobSimCuda::new(1, &dev)?; let c = sim.read_max_hold_counters()?; // S2.2: 6 decision-rate max_hold counters removed; 2 generic ones remain. assert_eq!(c.mh_kernel_calls[0], 0, "mh_kernel_calls must initialize to 0"); assert_eq!(c.mh_force_flat_seen_by_seed[0], 0, "mh_force_flat_seen_by_seed must initialize to 0"); Ok(()) } /// CRT.1 C1.2: multi-horizon ISV-weighted conviction (spec §4.4). /// /// The v2 A2 scalar approach used `max(|p−0.5|)` across horizons, which /// smooths magnitude only and cannot smooth direction. With horizons that /// disagree on direction, the scalar approach picks one horizon's sign /// and the EMA stabilizes on that — even though the multi-horizon vote is /// near-zero. v2 smoke vjmwc + lkrdf falsified this on the cluster. /// /// The v3 formula sums signed contributions `magnitude_h × weight_h × /// direction_h` across horizons. Disagreeing horizons cancel in the sum, /// so |conviction_signed| collapses toward zero when the multi-horizon /// vote is split. With four equally-weighted horizons (cold-start ISV), /// two bullish (+) and two bearish (−) at equal magnitudes, conviction /// MUST be zero and the kernel MUST write force-flat (side=3) or no-op /// (side=2) targets — never an actual buy/sell. #[test] #[ignore = "requires CUDA"] fn multi_horizon_conviction_cancels_on_disagreement() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; sim.upload_price_range(&[1000.0], &[20000.0])?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; // cost > 0 so eps_edge and weight_h are well-defined at cold start // (eps_edge = cost · 0.01; weight_h ≈ 0.01 / cost when ISV is at zero). // With all weights equal, disagreeing horizons cancel in the signed sum. let cfg = BatchedSimConfig::from_uniform(1, &UniformSimParams { target_annual_vol_units: 50.0, annualisation_factor: 825.0, max_lots: 5, latency_ns: 0, kelly_frac_floor: 0.20, sharpe_weight_floor: 0.10, threshold: 0.0, 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, }); // One horizon bullish (0.7), one bearish (0.3), one neutral (0.5). // Magnitudes: [0.4, 0.4, 0.0]. Directions: [+1, -1, +1]. // At cold start (ISV all zero, cost=1.0), every weight_h is // eps_edge / (0 + cost²) = 0.01 — equal. Weighted signed sum: // 0.4*0.01*(+1) + 0.4*0.01*(-1) + 0 = 0 // conviction_signed = 0 / total_abs_weight = 0 // → conv_ema = 0 → target_lots = 0 → side ∈ {2, 3}. let mixed: [f32; N_HORIZONS] = [0.7, 0.3, 0.5]; sim.broadcast_alpha(&mixed)?; sim.step_decision_with_latency(1_000_000_000u64, &cfg)?; let (side, size) = sim.read_market_target(0)?; // side=2 means no-op (target=0 or below threshold). // side=3 means force-flat (target=0 with open position semantics). // Either is acceptable: both encode "no new lots ordered." assert!( (side == 2 || side == 3) && size == 0, "disagreeing horizons must cancel to no-trade; got side={side} size={size}" ); // Sanity: the on-device signed EMA should also be (near) zero — // disagreement cancels in BOTH magnitude (|EMA|→0) and signed value // (signed→0) under the post-2026-05-22 anti-calibration fix, so the // strict |ema| < ε check still holds (and is in fact more correct, // since the test previously asserted on the magnitude-EMA buffer). let ema = sim.read_conv_signed_ema(0)?; assert!(ema.abs() < 1e-5, "conv_signed_ema must collapse to ~0 on cancellation; got {ema}"); Ok(()) } /// Anti-calibration fix (2026-05-22): event-to-event sign flips on the /// multi-horizon conviction signal must produce a SIGNED EMA whose /// magnitude is structurally smaller than the per-event |conviction|, /// AND `target_lots` derived from the signed EMA must be strictly /// smaller (in magnitude) than the pre-fix size that the bug would have /// produced. /// /// Bug history: the prior magnitude-only EMA combined `sign(instantaneous) /// × EMA(|instantaneous|)`. When per-horizon directions disagreed /// event-to-event, the magnitude EMA stayed high (|x| EMA cannot cancel) /// while the sign flipped on noise → big trades in random direction. /// Empirically (smoke ckpts across architecture variants): higher reported /// conviction → MORE negative PnL (-15.6 → -145.2). The fix replaces the /// magnitude-only EMA with a SIGNED EMA so directions that disagree /// event-to-event cancel in the EMA — sign AND magnitude both derive from /// the same smoothed scalar. /// /// Test setup: alternate alpha_probs across calls — all horizons bullish /// (0.85) then all bearish (0.15) — producing a `conviction_signed` that /// flips between ~+0.7 and ~−0.7 each event. With the Wiener-α floor of /// 0.4, perfect ±M sign-flip alternation steady-state oscillates with /// bounded amplitude `M · α / (2 - α)` ≈ 0.175 around zero (NOT exactly /// to zero — the Wiener α can rise above 0.4 under diff-variance growth, /// pushing amplitude somewhat higher). What matters structurally: /// /// 1. |signed_ema_steady_state| ≪ |conviction_signed_instantaneous| /// (the EMA cancels what the magnitude EMA cannot) /// 2. |target_lots_post_fix| < |target_lots_pre_fix| /// = |sign(instantaneous) × EMA(|x|) × max_lots| /// = 1 × ~0.7 × 10 = ~7 /// /// Both invariants pin the new behavior and would fail under the prior /// magnitude-only EMA. #[test] #[ignore = "requires CUDA"] fn signed_conviction_ema_collapses_on_sign_flips() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; sim.upload_price_range(&[1000.0], &[20000.0])?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; // threshold = 0.0 so sizing isn't gated off; max_lots = 10 so the // bug's predicted |target_lots| under steady magnitude EMA = 7 is // well separated from the post-fix expected magnitude (< 4 at the // oscillation crest under α floor 0.4). let max_lots: u16 = 10; let cfg = BatchedSimConfig::from_uniform(1, &UniformSimParams { target_annual_vol_units: 50.0, annualisation_factor: 825.0, max_lots, latency_ns: 0, kelly_frac_floor: 0.20, sharpe_weight_floor: 0.10, threshold: 0.0, 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, }); // All horizons strongly bullish: magnitude ≈ 2·|0.85−0.5| = 0.7, // direction = +1 across the board → conviction_signed ≈ +0.7. let bullish: [f32; N_HORIZONS] = [0.85; N_HORIZONS]; // All horizons strongly bearish: magnitude ≈ 0.7, direction = −1 → // conviction_signed ≈ −0.7. Per-event sign flip with constant // magnitude — the exact scenario the bug-trace investigation // identified (magnitude EMA stays at 0.7, sign flips event-to-event). let bearish: [f32; N_HORIZONS] = [0.15; N_HORIZONS]; // Pre-fix predicted |target_lots| in the TAIL (event >= 1) under the // magnitude-only EMA: // round(sign(instantaneous) × EMA(|x|) × max_lots) // = round(±1 × ~0.7 × 10) = ±7 every tail event. // Post-fix, the EMA tracks the SIGNED signal. The bootstrap event // (E0) lands at ±7 in BOTH implementations (first-observation // replace). What pins the fix is the TAIL behavior: after Wiener // stabilization, |signed_ema| converges to its steady-state // oscillation amplitude (~0.467 at α≈0.8, perfectly symmetric ±0.7 // alternation) so |target_lots| caps at ~5, strictly below the // bug's tail value of 7. We assert |target| <= 5 across the tail — // strict enough that the bug (constant 7) trips immediately. const PRE_FIX_TAIL_ABS: i32 = 7; const POST_FIX_TAIL_CAP: i32 = 5; // < 7 strictly; bug would always be 7 // Drive 16 alternating events. Track per-event signed EMA and // |target_lots|. Bootstrap event (k=0) is exempt from the magnitude // comparison (both implementations land at ±7 there). Tail = k >= 4 // to ensure Wiener α has stabilized past the bootstrap transient. let mut events: Vec<(f32, i32)> = Vec::with_capacity(16); for k in 0_u32..16 { let probs = if k.is_multiple_of(2) { &bullish } else { &bearish }; sim.broadcast_alpha(probs)?; sim.step_decision_with_latency(((k as u64) + 1) * 1_000_000_000u64, &cfg)?; let ema = sim.read_conv_signed_ema(0)?; let (_side, size) = sim.read_market_target(0)?; events.push((ema, size)); } // Print per-event diagnostics — helps localize regressions if a // future implementation drift trips the cap. for (k, (ema, size)) in events.iter().enumerate() { eprintln!("event {k:2}: signed_ema = {ema:+.4} |target_lots| = {size}"); } let tail: &[(f32, i32)] = &events[4..]; // Invariant 1: tail |target_lots| must be strictly smaller than the // pre-fix value of 7. This is the load-bearing check — under the bug, // every tail event would produce |target| = 7. for (i, (ema, size)) in tail.iter().enumerate() { assert!( size.abs() <= POST_FIX_TAIL_CAP, "tail event {i}: |target_lots| = {size} must be <= {POST_FIX_TAIL_CAP} \ (pre-fix bug produces {PRE_FIX_TAIL_ABS} every tail event); \ signed_ema = {ema}" ); } // Invariant 2: tail signed EMA magnitudes are structurally smaller // than the per-event |conviction_signed| ≈ 0.7. Allowing a generous // 0.6 ceiling — the theoretical steady-state amplitude is ~0.467 at // α≈0.8 under perfect ±M alternation. for (i, (ema, _)) in tail.iter().enumerate() { assert!( ema.abs() < 0.6, "tail event {i}: |signed EMA| = {ema} must be < 0.6 \ (per-event |conviction_signed| ≈ 0.7); the EMA is not \ cancelling sign flips" ); } // Invariant 3: under perfect ±M alternation the signed EMA itself // must FLIP SIGN across the tail samples (the EMA tracks the // alternating mean). The pre-fix magnitude EMA was locked in [0, 1] // — sign flipping is uniquely the signed-EMA's behavior. let any_pos = tail.iter().any(|(e, _)| *e > 0.0); let any_neg = tail.iter().any(|(e, _)| *e < 0.0); assert!( any_pos && any_neg, "tail signed EMA must oscillate across signs under ±M alternation; \ tail emas = {:?}", tail.iter().map(|(e, _)| *e).collect::>() ); Ok(()) } /// CRT.1 C1.3: no-trade band — delta_floor=1.0 blocks spurious re-seeding. /// /// When the same conviction alpha is submitted twice in a row, the second /// decision produces target=N with effective=N (already in position), so /// delta=0. The kernel must NOT seed a second order. This test also covers /// the abs_delta_f < delta_floor path: the delta_floor=0.0 path is band-disabled /// so a second call with the same target (delta=0 exactly) still passes through /// to the existing `if (delta == 0) return;` guard — but the comparison here /// validates the accessor and the field plumbing, not the strict floor gate. /// /// The strict floor gate (abs_delta_f < delta_floor) fires for fractional /// targets where delta rounds to 1 but float value is 0.9. In this scenario /// (integer delta=0 path) the existing delta==0 guard already fires, so /// inflight count is stable either way. #[test] #[ignore = "requires CUDA"] fn no_trade_band_blocks_micro_delta() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; sim.upload_price_range(&[1000.0], &[20000.0])?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; // Band enabled: delta_floor=1.0 (1 lot). With max_lots=1, the only // possible deltas are -1, 0, +1. delta=1 at the floor boundary fires; // delta=0 does not. We use max_lots=1 so the first strong-bullish // decision seeds exactly 1 lot and any subsequent same-target call // has delta=0 and must NOT re-seed. let cfg_band = BatchedSimConfig::from_uniform(1, &UniformSimParams { target_annual_vol_units: 50.0, annualisation_factor: 825.0, max_lots: 1, latency_ns: 0, kelly_frac_floor: 0.20, sharpe_weight_floor: 0.10, threshold: 0.0, cost_per_lot_per_side: 1.0, max_hold_ns: 0, min_reasonable_px: 0.0, max_reasonable_px: f32::INFINITY, delta_floor: 1.0, }); // First decision: strong bullish alpha. With max_lots=1 and delta_floor=1.0, // |delta|=1 >= delta_floor=1.0 so the kernel seeds one in-flight lot. let probs: [f32; N_HORIZONS] = [0.95; N_HORIZONS]; sim.broadcast_alpha(&probs)?; sim.step_decision_with_latency(1_000_000_000u64, &cfg_band)?; let inflight_after_first = sim.read_inflight_count(0)?; assert!(inflight_after_first >= 1, "first decision must seed at least one order; got {inflight_after_first}"); // Second decision: same alpha (same target), position unchanged (in-flight, // not yet filled). effective = 0 + in-flight = 1. target = 1. delta = 0. // The kernel must NOT seed again. sim.broadcast_alpha(&probs)?; sim.step_decision_with_latency(2_000_000_000u64, &cfg_band)?; let inflight_after_second = sim.read_inflight_count(0)?; assert_eq!(inflight_after_second, inflight_after_first, "same target should not re-seed; inflight went {} → {}", inflight_after_first, inflight_after_second); Ok(()) } #[test] fn open_trade_state_64_byte_layout() { // CRT.1 C1.1: spec §7 — open_trade_state expanded 24 → 64 bytes // (single cache line). All readers and writers migrate atomically. assert_eq!(ml_backtesting::lob::OPEN_TRADE_STATE_BYTES, 64, "spec §7: open_trade_state must be 64 bytes for CRT.1 trajectory tracking"); } /// CRT.1 C1.4: composite exit_signal — disagreement_term fires force-flat. /// /// With the spec-default `disagreement_factor = 32.0`, the composite /// disagreement_term reaches 1.0 after 32 consecutive events of short /// (h=0) vs long (h=N-1) horizon directional sign disagreement. Once /// `exit_signal >= 1.0`, the composite check writes the (3, 0) force-flat /// encoding. This is the only one of the three composite terms reachable /// with bounded conv_ema ∈ [0, 1] under spec-default factors — both /// degradation_term and pnl_decay_term cap at 0.5 / 1.0 by construction, /// so the disagreement counter is the canonical edge-case trigger. /// /// Test geometry: /// 1. Cold-start ISV + strong-bullish alpha → open a long position. The /// pnl_track open-branch snapshots conviction_at_entry into /// open_trade_state[20], seeds open_trade_state[24] = entry. /// 2. Switch to a SPLIT alpha (h0 bullish, h4 bearish, middle bullish so /// a position-holding majority keeps conviction_signed positive and /// SL doesn't widen). Every event with h0/h4 sign disagreement /// increments the disagreement counter at open_trade_state[56]. /// 3. After 32 such events, composite_exit_check sees /// disagreement_term = 32/32 = 1.0 → writes force-flat (side=3). /// /// The SL/trail check runs first; it must not fire over the test horizon, /// so pnl_ema_loss is set large (1000.0) and the book stays at the entry /// price level (no SL pressure). #[test] #[ignore = "requires CUDA"] fn composite_exit_signal_fires_on_disagreement() -> Result<()> { let dev = match MlDevice::cuda(0) { Ok(d) => d, Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } }; let mut sim = LobSimCuda::new(1, &dev)?; sim.upload_price_range(&[1000.0], &[20000.0])?; // Cold-start ISV with large pnl_ema_loss so SL/trail can't fire during // the 32-event disagreement window. let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost { pnl_ema_win: 0.0, pnl_ema_loss: 1000.0, win_rate_ema: 0.0, n_trades_seen: 0, realised_return_var: 0.0, recent_sharpe: 0.0, }); sim.write_isv_kelly(0, &cold_start)?; let (bp, bs, ap, az) = level_book(5500.0, 0.25); sim.apply_snapshot(&bp, &bs, &ap, &az)?; let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25); sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?; // Open long with strong unanimous bullish alpha. let bullish: [f32; N_HORIZONS] = [0.95; N_HORIZONS]; sim.broadcast_alpha(&bullish)?; let mut ts: u64 = 1_000_000; sim.step_decision_with_latency(ts, &cfg_default(1))?; for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; } let pos_open = sim.read_pos(0)?; assert!(pos_open.position_lots > 0, "setup: long position must open before disagreement test; got {}", pos_open.position_lots); // Switch to split alpha: h=0 bullish (0.6), h=N-1 bearish (0.4), middle // horizons bullish so the signed conviction stays positive and the // threshold gate doesn't shut down sizing each event. The trajectory // update bumps disagreement_consecutive_events because the h=0 vs // h=N-1 directional sign disagrees. let mut split: [f32; N_HORIZONS] = [0.6; N_HORIZONS]; split[N_HORIZONS - 1] = 0.4; // 32 events at spec-default factor produces exactly 1.0 — to be safe // against any single-event slack, run 33 events. for _ in 0..33 { sim.broadcast_alpha(&split)?; sim.step_decision_with_latency(ts, &cfg_default(1))?; ts += 1_000_000; } let (side, size) = sim.read_market_target(0)?; assert_eq!(side, 3, "composite exit_signal must fire force-flat after 32+ disagreement events; got side={side}"); assert_eq!(size, 0, "force-flat encoding must have abs_sz=0; got size={size}"); Ok(()) }