Files
foxhunt/crates/ml-backtesting/tests/stop_controller.rs
jgrusewski dcffb11f5d test(ml-backtesting): multi-horizon mask averaging invariant test
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) <noreply@anthropic.com>
2026-05-20 00:15:26 +02:00

321 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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(())
}