Files
foxhunt/crates/ml-backtesting/tests/stop_controller.rs
jgrusewski 1e656948b3 arch(crt-1): composite exit_signal safety circuit-breaker (C1.4)
Per spec §4.3 + plan C1.4. Safety backup for the primary exit path
(target → 0 from collapsed multi-horizon conviction, C1.2). Catches
the edge case where conviction stays bounded above zero but the trade
is deep in unrealized loss, or short/long horizons disagree for many
events while threshold-gate logic still permits sizing.

Three terms, max-of-three composite:
  degradation_term  = (1 − conv_ema_now / conviction_at_entry) / 2.0
  disagreement_term = disagree_consecutive_events / 32.0
  pnl_decay_term    = max(−pnl_adjusted_conv_ema, 0) / 1.0
  exit_signal       = max(deg, dis, pnl_decay)

Fires force-flat (market_targets = (3, 0)) when exit_signal >= 1.0.

State-update flow:
  - pnl_track open branch: write conviction_at_entry (offset 20),
    conviction_ema (offset 24, initialised to entry value),
    pnl_adjusted_conviction_ema (offset 44, = 0),
    peak_unrealized_pnl (offset 48, = 0). Takes new conviction_ema_per_b
    read-only arg to snapshot at open.
  - decision kernel intra-event (when pos open): update offsets
    24/44/48/56 (disagreement counter) via update_open_trade_trajectory.
  - composite_exit_check device helper: reads offsets 20/24/44/56,
    writes (3, 0) on fire. Sibling of stop_check_isv with same return
    semantics; called after trajectory update so all four reads see
    the freshly-written values.

Fields not read in CRT.1 (offsets 28-43 per-horizon EMA, 52 degradation
counter, 60 horizon_idx_dominant) are NOT written by the open branch —
alloc_zeros covers init. Per feedback_no_hiding: don't populate unused
fields. Also fixes a latent C1.1 bug: pnl_track close branch read
horizon_idx from offset 20 (now conviction_at_entry, f32); moved the
read to offset 60 per the documented layout.

Threshold-bootstrap defaults (will be ISV-derived in CRT.2 alongside
the rest of the controller anchors per pearl_controller_anchors_isv_driven):
  degradation_factor  = 2.0  (50% conviction decay vs entry → ≤0.5 term)
  disagreement_factor = 32.0 (32 events of short/long disagreement)
  pnl_decay_factor    = 1.0  (pnl_adj_conv structurally ∈ [-1, +1])

Under these defaults disagreement_term is the only term that can fire
on its own — degradation_term caps at 0.5 (conv_ema/conviction_at_entry
≥ 0) and pnl_decay_term caps at 1.0 in the limit. The three terms still
add evidence in superposition; CRT.2 will retune factors against ISV
percentiles. Unit-test coverage validates the disagreement path
(composite_exit_signal_fires_on_disagreement).

Per pearl_no_host_branches_in_captured_graph — all composite logic
lives inside the decision kernel; no host roundtrip in the per-event
hot path. Per feedback_no_htod_htoh_only_mapped_pinned — no new
memcpy or stream.synchronize() in step_pnl_track or
dispatch_latent_market_orders.

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

1424 lines
60 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: 1.0,
max_hold_ns: 0,
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
delta_floor: 0.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: 1.0,
max_hold_ns: 0,
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
delta_floor: 0.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 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.
///
/// S2.2: max_hold enforcement moved to resting_orders_step (event-rate).
/// The test now exercises the event-rate path via step_resting_orders
/// rather than checking the market_target written by stop_check_isv.
///
/// Test geometry:
/// - max_hold_ns = 100ms = 100_000_000ns
/// - Open long at t=1ms via strong alpha
/// - At entry_ts + 50ms: step_resting_orders at 50ms must NOT close (elapsed < cap)
/// - At entry_ts + 150ms: step_resting_orders at 150ms must close (elapsed >= cap)
#[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: 1.0,
max_hold_ns: 100_000_000, // 100ms
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
delta_floor: 0.0,
});
// 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. step_resting_orders must NOT close.
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.step_resting_orders(ts, 0.0)?;
let pos_pre = sim.read_pos(0)?;
assert_ne!(pos_pre.position_lots, 0,
"elapsed 50ms < max_hold=100ms: resting_orders must NOT close; position_lots={}",
pos_pre.position_lots);
// At entry_ts + 150ms — past max_hold. step_resting_orders must force-close.
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.step_resting_orders(ts, 0.0)?;
let pos_post = sim.read_pos(0)?;
assert_eq!(pos_post.position_lots, 0,
"elapsed 150ms > max_hold=100ms: resting_orders must force-close; position_lots={}",
pos_post.position_lots);
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: 1.0,
max_hold_ns: 100_000_000, // 100ms
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
delta_floor: 0.0,
});
// 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,
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
delta_floor: 0.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(())
}
/// Validates that all 6 NaN instrumentation counters (S1.20) are zero after
/// LobSimCuda::new() with no kernel launches. This confirms the device buffers
/// are correctly zero-initialized and the accessor compiles + works.
#[test]
#[ignore = "requires CUDA"]
fn nan_counters_initialize_to_zero() -> Result<()> {
let dev = match MlDevice::cuda(0) {
Ok(d) => d,
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
};
let sim = LobSimCuda::new(1, &dev)?;
let counters = sim.read_nan_counters()?;
assert_eq!(counters.nan_avg_px[0], 0, "nan_avg_px must be zero at init");
assert_eq!(counters.nan_realised[0], 0, "nan_realised must be zero at init");
assert_eq!(counters.nan_realized_pnl[0], 0, "nan_realized_pnl must be zero at init");
assert_eq!(counters.zero_vwap_at_open[0], 0, "zero_vwap_at_open must be zero at init");
assert_eq!(counters.saturated_vwap_at_open[0], 0, "saturated_vwap_at_open must be zero at init");
assert_eq!(counters.defensive_exit_clamp[0], 0, "defensive_exit_clamp must be zero at init");
Ok(())
}
/// Validates that the parameterized price-range filter rejects real-data outliers.
///
/// Audit (fxt-data-audit on ES.FUT_2024-Q1) found:
/// bid_min = -$4.85 (negative bid)
/// bid_p1 = $64.15 (sub-$100, far below ES range)
/// ask_max = $53,012 (10x above any plausible ES price)
///
/// The old hardcoded `< 1e8` threshold ($100M) never triggered on any of these.
/// This test pins the corrected behavior: upload min=1000/max=20000, then verify
/// that sub-range, super-range, and negative-bid snapshots all increment
/// snapshots_skipped and leave prev_mid unchanged; valid ES snapshots pass through.
#[test]
#[ignore = "requires CUDA"]
fn price_range_rejection_skips_snapshot() -> 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)?;
// Configure a tight ES-like range.
sim.upload_price_range(&[1000.0], &[20000.0])?;
// Valid snapshot at ES range — should pass and be applied.
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
let skipped_after_valid = sim.read_snapshots_skipped()?;
assert_eq!(skipped_after_valid[0], 0, "valid snapshot must not skip");
let prev_mid_after_valid = sim.read_prev_mid(0)?;
assert!(
(prev_mid_after_valid - 5500.0).abs() < 1e-4,
"valid snapshot must set prev_mid to mid; got {prev_mid_after_valid}"
);
// Sub-range top-of-book (e.g. $50) — should be skipped.
let (bp_low, bs_low, ap_low, az_low) = level_book(50.0, 0.25);
sim.apply_snapshot(&bp_low, &bs_low, &ap_low, &az_low)?;
let skipped_after_low = sim.read_snapshots_skipped()?;
assert_eq!(skipped_after_low[0], 1, "sub-range snapshot must skip");
let prev_mid_after_low = sim.read_prev_mid(0)?;
assert!(
(prev_mid_after_low - prev_mid_after_valid).abs() < 1e-4,
"skipped snapshot must not mutate prev_mid; got {prev_mid_after_low}"
);
// Super-range top-of-book ($50,000) — should be skipped.
let (bp_high, bs_high, ap_high, az_high) = level_book(50000.0, 0.25);
sim.apply_snapshot(&bp_high, &bs_high, &ap_high, &az_high)?;
let skipped_after_high = sim.read_snapshots_skipped()?;
assert_eq!(skipped_after_high[0], 2, "super-range snapshot must skip");
// Negative bid (bid[0] = -5.0) — should be skipped.
let (mut bp_neg, bs_neg, ap_neg, az_neg) = level_book(5500.0, 0.25);
bp_neg[0] = -5.0;
sim.apply_snapshot(&bp_neg, &bs_neg, &ap_neg, &az_neg)?;
let skipped_after_neg = sim.read_snapshots_skipped()?;
assert_eq!(skipped_after_neg[0], 3, "negative bid must skip");
Ok(())
}
/// S1.21: All per-vwap-write-site v2 counters initialize to zero at
/// LobSimCuda construction; last_bad_path initializes to zero (no bad
/// write observed yet).
#[test]
#[ignore = "requires CUDA"]
fn nan_counters_v2_initialize_to_zero() -> Result<()> {
let dev = match MlDevice::cuda(0) {
Ok(d) => d,
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
};
let sim = LobSimCuda::new(1, &dev)?;
let c = sim.read_nan_counters_v2()?;
assert_eq!(c.vwap_zero_open_flat[0], 0, "vwap_zero_open_flat must initialize to 0");
assert_eq!(c.vwap_huge_open_flat[0], 0, "vwap_huge_open_flat must initialize to 0");
assert_eq!(c.vwap_zero_scale_in[0], 0, "vwap_zero_scale_in must initialize to 0");
assert_eq!(c.vwap_huge_scale_in[0], 0, "vwap_huge_scale_in must initialize to 0");
assert_eq!(c.vwap_zero_flip[0], 0, "vwap_zero_flip must initialize to 0");
assert_eq!(c.vwap_huge_flip[0], 0, "vwap_huge_flip must initialize to 0");
assert_eq!(c.vwap_session_gap_was_bad[0], 0, "vwap_session_gap_was_bad must initialize to 0");
assert_eq!(c.last_bad_path[0], 0, "last_bad_path must initialize to 0 (no bad write)");
Ok(())
}
#[test]
#[ignore = "requires CUDA"]
fn max_hold_counters_initialize_to_zero() -> Result<()> {
let dev = match MlDevice::cuda(0) {
Ok(d) => d,
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
};
let sim = LobSimCuda::new(1, &dev)?;
let c = sim.read_max_hold_counters()?;
// S2.2: 6 decision-rate max_hold counters removed; 2 generic ones remain.
assert_eq!(c.mh_kernel_calls[0], 0, "mh_kernel_calls must initialize to 0");
assert_eq!(c.mh_force_flat_seen_by_seed[0], 0, "mh_force_flat_seen_by_seed must initialize to 0");
Ok(())
}
/// CRT.1 C1.2: multi-horizon ISV-weighted conviction (spec §4.4).
///
/// The v2 A2 scalar approach used `max(|p0.5|)` across horizons, which
/// smooths magnitude only and cannot smooth direction. With horizons that
/// disagree on direction, the scalar approach picks one horizon's sign
/// and the EMA stabilizes on that — even though the multi-horizon vote is
/// near-zero. v2 smoke vjmwc + lkrdf falsified this on the cluster.
///
/// The v3 formula sums signed contributions `magnitude_h × weight_h ×
/// direction_h` across horizons. Disagreeing horizons cancel in the sum,
/// so |conviction_signed| collapses toward zero when the multi-horizon
/// vote is split. With four equally-weighted horizons (cold-start ISV),
/// two bullish (+) and two bearish () at equal magnitudes, conviction
/// MUST be zero and the kernel MUST write force-flat (side=3) or no-op
/// (side=2) targets — never an actual buy/sell.
#[test]
#[ignore = "requires CUDA"]
fn multi_horizon_conviction_cancels_on_disagreement() -> 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)?;
sim.upload_price_range(&[1000.0], &[20000.0])?;
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
// cost > 0 so eps_edge and weight_h are well-defined at cold start
// (eps_edge = cost · 0.01; weight_h ≈ 0.01 / cost when ISV is at zero).
// With all weights equal, disagreeing horizons cancel in the signed sum.
let cfg = 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: 1.0,
max_hold_ns: 0,
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
delta_floor: 0.0,
});
// Two horizons bullish (0.7), two bearish (0.3), one neutral (0.5).
// Magnitudes: [0.4, 0.4, 0.4, 0.4, 0.0]. Directions: [+1, -1, +1, -1, +1].
// At cold start (ISV all zero, cost=1.0), every weight_h is
// eps_edge / (0 + cost²) = 0.01 — equal. Weighted signed sum:
// 0.4*0.01*(+1) + 0.4*0.01*(-1) + 0.4*0.01*(+1) + 0.4*0.01*(-1) + 0
// = 0
// conviction_signed = 0 / total_abs_weight = 0
// → conv_ema = 0 → target_lots = 0 → side ∈ {2, 3}.
let mixed: [f32; N_HORIZONS] = [0.7, 0.3, 0.7, 0.3, 0.5];
sim.broadcast_alpha(&mixed)?;
sim.step_decision_with_latency(1_000_000_000u64, &cfg)?;
let (side, size) = sim.read_market_target(0)?;
// side=2 means no-op (target=0 or below threshold).
// side=3 means force-flat (target=0 with open position semantics).
// Either is acceptable: both encode "no new lots ordered."
assert!(
(side == 2 || side == 3) && size == 0,
"disagreeing horizons must cancel to no-trade; got side={side} size={size}"
);
// Sanity: the on-device EMA should also be (near) zero.
let ema = sim.read_conviction_ema(0)?;
assert!(ema.abs() < 1e-5, "conviction_ema must collapse to ~0 on cancellation; got {ema}");
Ok(())
}
/// CRT.1 C1.3: no-trade band — delta_floor=1.0 blocks spurious re-seeding.
///
/// When the same conviction alpha is submitted twice in a row, the second
/// decision produces target=N with effective=N (already in position), so
/// delta=0. The kernel must NOT seed a second order. This test also covers
/// the abs_delta_f < delta_floor path: the delta_floor=0.0 path is band-disabled
/// so a second call with the same target (delta=0 exactly) still passes through
/// to the existing `if (delta == 0) return;` guard — but the comparison here
/// validates the accessor and the field plumbing, not the strict floor gate.
///
/// The strict floor gate (abs_delta_f < delta_floor) fires for fractional
/// targets where delta rounds to 1 but float value is 0.9. In this scenario
/// (integer delta=0 path) the existing delta==0 guard already fires, so
/// inflight count is stable either way.
#[test]
#[ignore = "requires CUDA"]
fn no_trade_band_blocks_micro_delta() -> 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)?;
sim.upload_price_range(&[1000.0], &[20000.0])?;
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
// Band enabled: delta_floor=1.0 (1 lot). With max_lots=1, the only
// possible deltas are -1, 0, +1. delta=1 at the floor boundary fires;
// delta=0 does not. We use max_lots=1 so the first strong-bullish
// decision seeds exactly 1 lot and any subsequent same-target call
// has delta=0 and must NOT re-seed.
let cfg_band = BatchedSimConfig::from_uniform(1, &UniformSimParams {
target_annual_vol_units: 50.0,
annualisation_factor: 825.0,
max_lots: 1,
latency_ns: 0,
kelly_frac_floor: 0.20,
sharpe_weight_floor: 0.10,
threshold: 0.0,
cost_per_lot_per_side: 1.0,
max_hold_ns: 0,
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
delta_floor: 1.0,
});
// First decision: strong bullish alpha. With max_lots=1 and delta_floor=1.0,
// |delta|=1 >= delta_floor=1.0 so the kernel seeds one in-flight lot.
let probs: [f32; N_HORIZONS] = [0.95; N_HORIZONS];
sim.broadcast_alpha(&probs)?;
sim.step_decision_with_latency(1_000_000_000u64, &cfg_band)?;
let inflight_after_first = sim.read_inflight_count(0)?;
assert!(inflight_after_first >= 1,
"first decision must seed at least one order; got {inflight_after_first}");
// Second decision: same alpha (same target), position unchanged (in-flight,
// not yet filled). effective = 0 + in-flight = 1. target = 1. delta = 0.
// The kernel must NOT seed again.
sim.broadcast_alpha(&probs)?;
sim.step_decision_with_latency(2_000_000_000u64, &cfg_band)?;
let inflight_after_second = sim.read_inflight_count(0)?;
assert_eq!(inflight_after_second, inflight_after_first,
"same target should not re-seed; inflight went {} → {}",
inflight_after_first, inflight_after_second);
Ok(())
}
#[test]
fn open_trade_state_64_byte_layout() {
// CRT.1 C1.1: spec §7 — open_trade_state expanded 24 → 64 bytes
// (single cache line). All readers and writers migrate atomically.
assert_eq!(ml_backtesting::lob::OPEN_TRADE_STATE_BYTES, 64,
"spec §7: open_trade_state must be 64 bytes for CRT.1 trajectory tracking");
}
/// CRT.1 C1.4: composite exit_signal — disagreement_term fires force-flat.
///
/// With the spec-default `disagreement_factor = 32.0`, the composite
/// disagreement_term reaches 1.0 after 32 consecutive events of short
/// (h=0) vs long (h=N-1) horizon directional sign disagreement. Once
/// `exit_signal >= 1.0`, the composite check writes the (3, 0) force-flat
/// encoding. This is the only one of the three composite terms reachable
/// with bounded conv_ema ∈ [0, 1] under spec-default factors — both
/// degradation_term and pnl_decay_term cap at 0.5 / 1.0 by construction,
/// so the disagreement counter is the canonical edge-case trigger.
///
/// Test geometry:
/// 1. Cold-start ISV + strong-bullish alpha → open a long position. The
/// pnl_track open-branch snapshots conviction_at_entry into
/// open_trade_state[20], seeds open_trade_state[24] = entry.
/// 2. Switch to a SPLIT alpha (h0 bullish, h4 bearish, middle bullish so
/// a position-holding majority keeps conviction_signed positive and
/// SL doesn't widen). Every event with h0/h4 sign disagreement
/// increments the disagreement counter at open_trade_state[56].
/// 3. After 32 such events, composite_exit_check sees
/// disagreement_term = 32/32 = 1.0 → writes force-flat (side=3).
///
/// The SL/trail check runs first; it must not fire over the test horizon,
/// so pnl_ema_loss is set large (1000.0) and the book stays at the entry
/// price level (no SL pressure).
#[test]
#[ignore = "requires CUDA"]
fn composite_exit_signal_fires_on_disagreement() -> 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)?;
sim.upload_price_range(&[1000.0], &[20000.0])?;
// Cold-start ISV with large pnl_ema_loss so SL/trail can't fire during
// the 32-event disagreement window.
let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
pnl_ema_win: 0.0,
pnl_ema_loss: 1000.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 with strong unanimous bullish alpha.
let bullish: [f32; N_HORIZONS] = [0.95; N_HORIZONS];
sim.broadcast_alpha(&bullish)?;
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;
}
let pos_open = sim.read_pos(0)?;
assert!(pos_open.position_lots > 0,
"setup: long position must open before disagreement test; got {}",
pos_open.position_lots);
// Switch to split alpha: h=0 bullish (0.6), h=N-1 bearish (0.4), middle
// horizons bullish so the signed conviction stays positive and the
// threshold gate doesn't shut down sizing each event. The trajectory
// update bumps disagreement_consecutive_events because the h=0 vs
// h=N-1 directional sign disagrees.
let mut split: [f32; N_HORIZONS] = [0.6; N_HORIZONS];
split[N_HORIZONS - 1] = 0.4;
// 32 events at spec-default factor produces exactly 1.0 — to be safe
// against any single-event slack, run 33 events.
for _ in 0..33 {
sim.broadcast_alpha(&split)?;
sim.step_decision_with_latency(ts, &cfg_default(1))?;
ts += 1_000_000;
}
let (side, size) = sim.read_market_target(0)?;
assert_eq!(side, 3,
"composite exit_signal must fire force-flat after 32+ disagreement events; got side={side}");
assert_eq!(size, 0, "force-flat encoding must have abs_sz=0; got size={size}");
Ok(())
}