Files
foxhunt/crates/ml-backtesting/tests/stop_controller.rs
jgrusewski a85f38e97a fix(ml-backtesting): three residual sentinel + session-gap fixes
Three orthogonal fixes for residual issues in smoke stx9p (97 zero
sentinels, 85 i32::MAX sentinels, 840/1024 over-60s holds):

Fix 1 — zero-sentinel residue (px > 0):
- Per-level sanitization in apply_snapshot_kernel only checked sz > 0.
- A book level with px=0, sz>0 passed → walk_* computed total_cost=0
  → avg_px=0 → vwap_entry=0. Trade record reported entry_px=0.
- Add px > 0.0f to bid_ok/ask_ok conditions.

Fix 2 — i32::MAX upper bound (px < 1e8):
- Post-Bug-D (1e9 nanoprice scaling) some boundary events carried prices
  larger than any plausible instrument. Saturating cast to i32 produced
  the 21474836 sentinel even when isfinite() passed.
- Add px < 1.0e8f upper bound to per-level AND top-of-book validation.

Fix 3 — session-gap force-close:
- max_hold check fires at decision_stride frequency, but during weekend
  halts no events advance current_ts → max_hold never fires until next
  session. Result: 49h holds in stx9p (840/1024 over 60s threshold).
- Detect ts gap > 1 hour in resting_orders_step. If position is open,
  zero position_lots directly. pnl_track_step's existing close branch
  emits the TradeRecord on the next call. No synthetic P&L added —
  records show realised_pnl from whatever was accumulated before the gap
  (honest: cannot fill across a halt).
- New per-backtest last_event_ts_d slot tracks the previous event ts.
- Test session_gap_force_closes_open_positions: 2-hour ts jump after
  open verifies force-flat fires and exactly 1 TradeRecord is emitted.

All 16 stop_controller tests pass. All 5 decision_floor_coldstart pass.

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

1102 lines
45 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(())
}
/// Validates that a timestamp gap > 1 hour (session boundary / weekend halt)
/// force-closes any open position before the next event is processed.
///
/// max_hold_ns can't fire during a halt because no events advance current_ts.
/// The session-gap check in resting_orders_step detects the jump and zeroes
/// position_lots directly; pnl_track_step's existing close branch then emits
/// exactly one TradeRecord.
#[test]
#[ignore = "requires CUDA"]
fn session_gap_force_closes_open_positions() -> 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 — won'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)?;
// Open long at t=1ms.
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_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");
let trades_before_gap = sim.read_total_trade_count()?;
// Advance ts by 2 hours (> 1 hour SESSION_GAP_NS). Call step_resting_orders
// with the new ts — session-gap force-close fires, then pnl_track detects
// the prev!=0 && now==0 close transition and emits a TradeRecord.
ts += 2 * 3_600_000_000_000u64;
sim.step_resting_orders(ts, 0.0)?;
sim.step_pnl_track(ts)?;
assert_eq!(
sim.read_pos(0)?.position_lots, 0,
"session gap > 1h must force-close the position"
);
let trades_after_gap = sim.read_total_trade_count()?;
assert_eq!(
trades_after_gap, trades_before_gap + 1,
"session-gap close emits exactly one TradeRecord; got {} -> {}",
trades_before_gap, trades_after_gap
);
Ok(())
}
/// Validates that a corrupt top-of-book snapshot is skipped entirely and the
/// per-backtest `snapshots_skipped` counter is incremented.
///
/// Bug C-b (smoke v74v4 2026-05-20): per-level sanitization (Task 15) zeros each
/// unhealthy level individually. When ALL 10 levels are invalid (session-boundary
/// event), the book becomes uniformly zero. apply_fill_to_pos then reads
/// bid_px[0]=0/ask_px[0]=0 → vwap_entry=0 → trade record entry_px=0 (zero sentinel).
///
/// Fix: pre-validate top-of-book in apply_snapshot_kernel. If bid_px[0]/ask_px[0]
/// are non-finite or zero/negative, skip the entire snapshot — book, prev_mid,
/// and atr_mid_ema remain unchanged.
#[test]
#[ignore = "requires CUDA"]
fn corrupt_top_of_book_skips_snapshot_and_increments_counter() -> 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)?;
// Apply a valid book first to seed prev_mid and atr_mid_ema.
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
// Apply a second valid snapshot to advance ATR from 0.
let (bp2, bs2, ap2, az2) = level_book(5500.5, 0.25);
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
let atr_before = sim.read_atr_mid_ema(0)?;
let prev_mid_before = sim.read_prev_mid(0)?;
// Sanity: atr_before must be nonzero after two valid snapshots with Δ=0.5.
assert!(atr_before > 0.0, "ATR should be nonzero after two valid snapshots, got {atr_before}");
// Case 1: top-of-book bid_px[0] = NaN. Whole snapshot must be skipped.
let mut bp_bad = bp2;
bp_bad[0] = f32::NAN;
sim.apply_snapshot(&bp_bad, &bs2, &ap2, &az2)?;
let skipped = sim.read_snapshots_skipped()?;
assert_eq!(skipped[0], 1,
"corrupt top-of-book (NaN bid_px[0]) should increment skip counter to 1; got {}", skipped[0]);
let atr_after_nan = sim.read_atr_mid_ema(0)?;
let prev_mid_after_nan = sim.read_prev_mid(0)?;
assert_eq!(atr_after_nan, atr_before,
"ATR must not change on corrupt snapshot; before={atr_before} after={atr_after_nan}");
assert_eq!(prev_mid_after_nan, prev_mid_before,
"prev_mid must not change on corrupt snapshot; before={prev_mid_before} after={prev_mid_after_nan}");
// Case 2: top-of-book bid_sz[0] = 0. Must also skip.
let mut bs_bad = bs2;
bs_bad[0] = 0.0;
sim.apply_snapshot(&bp2, &bs_bad, &ap2, &az2)?;
let skipped_2 = sim.read_snapshots_skipped()?;
assert_eq!(skipped_2[0], 2,
"zero top-of-book bid_sz[0] should increment skip counter to 2; got {}", skipped_2[0]);
let atr_after_zero_sz = sim.read_atr_mid_ema(0)?;
assert_eq!(atr_after_zero_sz, atr_before,
"ATR must not change on zero-size corrupt snapshot; got {atr_after_zero_sz}");
// Case 3: valid snapshot after two corrupt skips must update ATR normally.
let (bp3, bs3, ap3, az3) = level_book(5500.5, 0.25);
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
let skipped_3 = sim.read_snapshots_skipped()?;
assert_eq!(skipped_3[0], 2,
"valid snapshot must not increment skip counter; got {}", skipped_3[0]);
let atr_after_valid = sim.read_atr_mid_ema(0)?;
// mid didn't move (same 5500.5 as prev), so delta=0, atr stays at atr_before.
// The assertion here is that it is still finite and the state is reachable.
assert!(atr_after_valid.is_finite(),
"ATR must remain finite after valid snapshot following skips; got {atr_after_valid}");
// Case 4: valid snapshot with a different mid forces an ATR update, proving
// the pipeline is unblocked after the skips.
let (bp4, bs4, ap4, az4) = level_book(5501.5, 0.25);
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
let atr_after_move = sim.read_atr_mid_ema(0)?;
assert_ne!(atr_after_move, atr_before,
"valid snapshot with Δmid=1.0 must update ATR; before={atr_before} after={atr_after_move}");
Ok(())
}