Files
foxhunt/crates/ml-backtesting/tests/stop_controller.rs
jgrusewski 1d889d2de9 arch(crt-a): Wiener-α conviction-EMA smoothing in decision_policy
After A1 (commit 045850e8f) the controller fires every event. Without
smoothing, target lots would oscillate as alpha probabilities jitter
event-to-event, generating hyperactive spread-bleed from back-and-forth
trades.

Ships per spec §4.2 and §3.7: Wiener-α adaptive EMA on max-conviction-
across-horizons. Formula:
  α_raw    = diff_var / (diff_var + sample_var + ε)
  α_active = max(α_raw, 0.4)        per pearl_wiener_alpha_floor_for_nonstationary
  ema      = α_active × new + (1 − α_active) × prev
First-observation bootstrap: prev_ema == 0 → replace directly per
pearl_first_observation_bootstrap.

Three new device slots (per-backtest):
  - conviction_ema_d:             smoothed conviction (used for final-aggregate rescale)
  - conviction_diff_var_ema_d:    second-order EMA, drives adaptive α
  - conviction_sample_var_ema_d:  second-order EMA, drives adaptive α

Architectural choice: rescale at the final aggregate (final_size *= conv_ema /
raw_max_conv), not at per-horizon sig_mag. Per-horizon sizing keeps raw
|alpha-0.5|*2 so horizons with no signal (alpha=0.5) contribute zero —
the spec §4.2 "smoothed conviction" is a unified gain over the aggregate,
not a substitute for per-horizon signal strength. At bootstrap the scale
is exactly 1.0 (raw_max_conv == conv_ema) so the very first decision is
bit-identical to the pre-A2 kernel. Direction recovery (sign of
alpha-0.5) remains per-horizon — genuine reversals respond at event rate.

Threaded through both decision_policy_default and decision_policy_program
kernels' signatures and launches in sim/mod.rs.

Full multi-horizon conviction *aggregation* (spec §4.4) remains Phase B;
A2 ships ONLY the smoothing operator on the existing scalar conviction.

Pure on-device: no memcpy_htod / dtoh / dtov / synchronize in hot path.
Single test-only DtoH accessor (read_conviction_ema) for unit-test
inspection of the smoothed value.

Tests:
- conviction_ema_smooths_micro_oscillations — sentinel→bootstrap→bounded
  EMA across 10 alternating high/low all-bullish alpha drives; direction
  stable.
- conviction_ema_does_not_lag_reversals — sign flip in alpha → target
  side flips next event.
- All 22 stop_controller + 5 decision_floor_coldstart + 3 threshold_and_cost
  + parallel_sim + ring3_replay + trainer_parity + 6 fuzz tests pass.
- 2 lob_sim_fixtures tests (fix_decision_alpha_buy_close, fix_decision_program_h4_only)
  were already failing on baseline 045850e8f pre-A2; not a regression from
  this commit.

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

1395 lines
59 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,
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
})
}
#[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,
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
});
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.
///
/// 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: 0.0,
max_hold_ns: 100_000_000, // 100ms
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
});
// 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: 0.0,
max_hold_ns: 100_000_000, // 100ms
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
});
// 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,
});
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-A2: Wiener-α adaptive EMA on max-conviction-across-horizons.
///
/// After A1 the controller fires every event. Without smoothing, sizing
/// magnitude would oscillate with the per-event alpha jitter (spread-bleed).
/// A2 adds a per-backtest Wiener-α EMA with floor 0.4 (non-stationary
/// control loop per pearl_wiener_alpha_floor_for_nonstationary) on the
/// max |alpha-0.5|*2 across horizons. Direction continues to come from
/// the raw alpha sign — only magnitude is smoothed.
///
/// This test drives alternating high/low all-bullish alpha probs and
/// asserts:
/// (a) the on-device EMA is in fact populated (sentinel 0 → nonzero
/// after first observation);
/// (b) the EMA value sits strictly between the high and low input
/// convictions (smoothing produces a bounded mix, never extrapolates);
/// (c) under high enough kelly-floor to clear the integer-rounding bar,
/// direction (side) stays stable across alternation — no sign flips
/// on co-directional inputs.
fn cfg_high_kelly_floor(n: usize) -> BatchedSimConfig {
// kelly_frac_floor=1.0 keeps |signed_size| ≥ ~sig_mag * max_lots = 0.1*5 = 0.5
// for the low-conv alpha (sig_mag ~ EMA), rounding to ±1 lots — so the
// *direction* of the target is stable across alternation regardless of
// sizing oscillation.
BatchedSimConfig::from_uniform(n, &UniformSimParams {
target_annual_vol_units: 50.0,
annualisation_factor: 825.0,
max_lots: 5,
latency_ns: 0,
kelly_frac_floor: 1.0,
sharpe_weight_floor: 0.10,
threshold: 0.0,
cost_per_lot_per_side: 0.0,
max_hold_ns: 0,
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
})
}
#[test]
#[ignore = "requires CUDA"]
fn conviction_ema_smooths_micro_oscillations() -> 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)?;
let alphas_high: [f32; N_HORIZONS] = [0.8; N_HORIZONS]; // strong bullish, conv=0.6
let alphas_low: [f32; N_HORIZONS] = [0.55; N_HORIZONS]; // weak bullish, conv=0.1
let conv_high = 0.6_f32;
let conv_low = 0.1_f32;
let cfg = cfg_high_kelly_floor(1);
// Pre-condition: EMA slot starts at sentinel 0 (no observation yet).
assert_eq!(sim.read_conviction_ema(0)?, 0.0,
"conviction_ema must initialize to 0 (first-observation sentinel)");
// First decision: alphas_high. Bootstraps the EMA directly per
// pearl_first_observation_bootstrap (prev_ema == 0 → replace).
sim.broadcast_alpha(&alphas_high)?;
sim.step_decision_with_latency(1_000_000_000u64, &cfg)?;
let ema_after_first = sim.read_conviction_ema(0)?;
assert!((ema_after_first - conv_high).abs() < 1e-5,
"first observation must replace directly (bootstrap); got EMA={ema_after_first}, expected {conv_high}");
// Drive 10 alternations. Capture each EMA + target sign.
let mut prev_target_signed: Option<i32> = None;
let mut sign_flips = 0;
let mut ema_min = ema_after_first;
let mut ema_max = ema_after_first;
for i in 0..10 {
let probs = if i % 2 == 0 { alphas_low } else { alphas_high };
sim.broadcast_alpha(&probs)?;
let ts = 1_000_000_000u64 * ((i as u64) + 2);
sim.step_decision_with_latency(ts, &cfg)?;
let (side, size) = sim.read_market_target(0)?;
// side ∈ {0=buy, 1=sell, 2=noop, 3=force-flat}. For all-bullish
// alphas the raw direction is +; smoothed magnitude shouldn't flip
// it under the high-kelly-floor config.
let target_signed: i32 = match side {
0 => size,
1 => -size,
_ => 0,
};
if let Some(p) = prev_target_signed {
if target_signed != 0 && p != 0 && (target_signed > 0) != (p > 0) {
sign_flips += 1;
}
}
prev_target_signed = Some(target_signed);
let ema = sim.read_conviction_ema(0)?;
if ema < ema_min { ema_min = ema; }
if ema > ema_max { ema_max = ema; }
}
// (b): EMA stays bounded between the two input convictions (post-bootstrap,
// the blend formula α·new + (1-α)·old is a strict convex combination of
// observed values, so the running EMA is bounded by the observed range).
assert!(ema_min > conv_low - 1e-4 && ema_max < conv_high + 1e-4,
"EMA must stay between conv_low={conv_low} and conv_high={conv_high}; got min={ema_min}, max={ema_max}");
// The EMA should also have moved away from the bootstrap value (proves
// the second-and-onward update path executed, not just bootstrap).
assert!(ema_min < conv_high - 1e-4,
"EMA must drop below initial bootstrap conv_high={conv_high} after seeing alphas_low; got min={ema_min}");
// (c): direction stable under co-directional alternation. The raw alpha
// sign is positive in both cases (>0.5); smoothed magnitude shouldn't
// produce a sign flip.
assert_eq!(sign_flips, 0,
"direction must stay stable under EMA smoothing of co-directional alphas; flips={sign_flips}");
Ok(())
}
/// CRT-A2 corollary: when a true reversal arrives (alpha sign flips from
/// >0.5 to <0.5), the target direction must respond at the next event —
/// the smoothing is on magnitude only, not on sign. This is the property
/// that distinguishes Wiener-α smoothing on max-conviction from smoothing
/// the alpha probabilities themselves (which would lag reversals).
#[test]
#[ignore = "requires CUDA"]
fn conviction_ema_does_not_lag_reversals() -> 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)?;
let cfg = cfg_high_kelly_floor(1);
// Bootstrap with strong bullish.
sim.broadcast_alpha(&[0.8; N_HORIZONS])?;
sim.step_decision_with_latency(1_000_000_000, &cfg)?;
let (side_buy, size_buy) = sim.read_market_target(0)?;
assert_eq!(side_buy, 0, "bootstrap bullish → buy; got side={side_buy} size={size_buy}");
assert!(size_buy >= 1, "bootstrap bullish → size>=1; got {size_buy}");
// Hard reversal: strong bearish. Direction must flip on next event.
sim.broadcast_alpha(&[0.2; N_HORIZONS])?;
sim.step_decision_with_latency(2_000_000_000, &cfg)?;
let (side_sell, size_sell) = sim.read_market_target(0)?;
// The position may now be long (from the prior buy fill), but the
// *target* side written by the decision kernel reflects raw alpha sign.
// Stop checks only fire on open positions with SL/trail breach; with
// identical book + zero ATR they won't fire here, so the alpha-driven
// target dictates.
assert_eq!(side_sell, 1,
"post-reversal target must flip to sell; got side={side_sell} size={size_sell}");
Ok(())
}