//! 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(()) }