From dcffb11f5de271e2996b76fe3eb87bf7d03ee9bf Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Wed, 20 May 2026 00:13:02 +0200 Subject: [PATCH] test(ml-backtesting): multi-horizon mask averaging invariant test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boundary test that fails on bit-pick-first or bit-pick-max regressions of the open_horizon_masks averaging in stop_check_isv. Per-horizon pnl_ema_loss values (2, 4, 3, 3, 3) yield mean=3.0 across the 0x1F mask; snapshots placed relative to vwap_entry for ask-spread safety. Tests Δ_entry=2.5 (no-fire) and Δ_entry=4.5 (fire). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ml-backtesting/tests/stop_controller.rs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/crates/ml-backtesting/tests/stop_controller.rs b/crates/ml-backtesting/tests/stop_controller.rs index d305a9673..ca416c275 100644 --- a/crates/ml-backtesting/tests/stop_controller.rs +++ b/crates/ml-backtesting/tests/stop_controller.rs @@ -219,3 +219,102 @@ fn trail_arms_then_fires() -> Result<()> { 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(()) +}