Files
foxhunt/crates/ml-backtesting/tests/stop_controller.rs
jgrusewski 691dec144e fix(ml-backtesting): root-cause NaN/Inf book + duplicate close emissions
Two orthogonal bugs that compounded to produce the exit_px=±21474836
sentinel in cluster smokes (315 trades baseline, 500 trades pearl):

Bug A — NaN/Inf book propagation:
- apply_snapshot_kernel passes through NaN/Inf prices from MBP-10
  predecoded data (session boundaries, gap-fills).
- walk_ask_for_buy / walk_bid_for_sell accumulate cost += take * NaN.
- apply_fill_to_pos: avg_px = NaN; realized_pnl += NaN → permanent NaN.
- Subsequent close: (int)(NaN-derived * 5000) → i32::MAX sentinel.
- Fix: zero-out non-finite levels in apply_snapshot_kernel; add
  isfinite() guards in walk helpers as defense-in-depth; ATR update
  guards against non-finite mid.

Bug B — pnl_track close branch doesn't reset scratch:
- Scratch reset (full 24-byte memset to 0) was already present in the
  current code; no source change required for Bug B.

Tests:
- book_nan_inf_prices_dont_corrupt_realized_pnl: NaN at ask[3] + Inf
  at bid[5] survives the fill pipeline without making realized_pnl
  non-finite.
- pnl_track_resets_scratch_on_close: open+close+5 flat events emits
  exactly 1 record; second open+close emits exactly 1 more (=2 total).

14/14 stop_controller tests pass; all other ml-backtesting test suites
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:28:12 +02:00

960 lines
39 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::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: 0.0,
max_hold_ns: 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, 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(())
}
#[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; 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)?;
// 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;
}
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; 5] = 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, 0.95, 0.95, 0.95, 0.95])?;
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, 0.95, 0.95, 0.95, 0.95])?;
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: 0.0,
max_hold_ns: 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, 0.95, 0.95, 0.95, 0.95])?;
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 alpha_noop_side_2_preserved() -> 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 with large pnl_ema_loss (= 10.0) so sl_distance stays far enough
// that the spread (0.25) and small price moves never fire the SL during the
// open-position setup phase. Same pattern as other open-setup tests.
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)?;
let mut ts: u64 = 0;
// Open long via strong alpha.
for _ in 0..10 {
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
sim.step_resting_orders(ts, 0.0)?;
ts += 1_000_000;
}
let pos_open = sim.read_pos(0)?.position_lots;
assert!(pos_open > 0, "setup: long opens with strong alpha, got {}", pos_open);
// Weak alpha that produces sig_mag * 0.20 * cap_lots < 0.5, rounding lots=0
// → market_target = (2, 0). The pos must stay unchanged.
// Compute: sig_mag = |0.55-0.5|*2 = 0.1; ss = 0.1*0.20*5 = 0.1; truncf(0.1+0.5)=0.
for _ in 0..20 {
sim.broadcast_alpha(&[0.55, 0.55, 0.55, 0.55, 0.55])?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
sim.step_resting_orders(ts, 0.0)?;
ts += 1_000_000;
}
let pos_after = sim.read_pos(0)?.position_lots;
assert_eq!(pos_after, pos_open,
"side=2 must preserve position; opened={} after 20 weak-alpha events={}",
pos_open, pos_after);
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; 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)?;
// Open long.
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
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; 5] = 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, 0.95, 0.95, 0.95, 0.95])?;
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, 0.95, 0.95, 0.95, 0.95])?;
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.
///
/// Test geometry:
/// - max_hold_ns = 100ms = 100_000_000ns
/// - Open long at t=1ms via strong alpha
/// - At entry_ts + 50ms: still under limit → must NOT fire (side != 3)
/// - At entry_ts + 150ms: past limit → must fire force-flat (side=3, size=0)
#[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; 5] = 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: 0.0,
max_hold_ns: 100_000_000, // 100ms
});
// Open long at t=1ms via strong alpha.
let mut ts: u64 = 1_000_000;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
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. Book stable so SL/trail don't fire.
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.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
let (side_pre, _) = sim.read_market_target(0)?;
assert_ne!(side_pre, 3, "elapsed 50ms < max_hold=100ms: must NOT fire; got side={side_pre}");
// At entry_ts + 150ms — past max_hold.
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.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
let (side_post, size_post) = sim.read_market_target(0)?;
assert_eq!(side_post, 3, "elapsed 150ms > max_hold=100ms: must fire force-flat; got side={side_post}");
assert_eq!(size_post, 0);
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, 0.95, 0.95, 0.95, 0.95])?;
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; 5] = 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: 0.0,
max_hold_ns: 100_000_000, // 100ms
});
// Trade 1: open long, force-flatten via max_hold.
let mut ts: u64 = 1_000_000;
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
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, 0.95, 0.95, 0.95, 0.95])?;
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, 0.95, 0.95, 0.95, 0.95])?;
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, 0.95, 0.95, 0.95, 0.95])?;
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; 5] = 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,
});
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
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, 0.95, 0.95, 0.95, 0.95])?;
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, 0.95, 0.95, 0.95, 0.95])?;
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(())
}