//! 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::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: 0.0, use_cold_start_stopgap: false, }) } #[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, 0.8, 0.8, 0.8, 0.8])?; 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; 5] = 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, 0.95, 0.95, 0.95, 0.95])?; 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, 0.95, 0.95, 0.95, 0.95])?; 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; 5] = 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, 0.95, 0.95, 0.95, 0.95])?; 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; 5] = 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, 0.95, 0.95, 0.95, 0.95])?; 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, 0.95, 0.95, 0.95, 0.95])?; 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; 5] = 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, 0.95, 0.95, 0.95, 0.95])?; 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; 5] = 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, 0.95, 0.95, 0.95, 0.95])?; 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, 0.95, 0.95, 0.95, 0.95])?; 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(()) }