Three orthogonal fixes for residual issues in smoke stx9p (97 zero sentinels, 85 i32::MAX sentinels, 840/1024 over-60s holds): Fix 1 — zero-sentinel residue (px > 0): - Per-level sanitization in apply_snapshot_kernel only checked sz > 0. - A book level with px=0, sz>0 passed → walk_* computed total_cost=0 → avg_px=0 → vwap_entry=0. Trade record reported entry_px=0. - Add px > 0.0f to bid_ok/ask_ok conditions. Fix 2 — i32::MAX upper bound (px < 1e8): - Post-Bug-D (1e9 nanoprice scaling) some boundary events carried prices larger than any plausible instrument. Saturating cast to i32 produced the 21474836 sentinel even when isfinite() passed. - Add px < 1.0e8f upper bound to per-level AND top-of-book validation. Fix 3 — session-gap force-close: - max_hold check fires at decision_stride frequency, but during weekend halts no events advance current_ts → max_hold never fires until next session. Result: 49h holds in stx9p (840/1024 over 60s threshold). - Detect ts gap > 1 hour in resting_orders_step. If position is open, zero position_lots directly. pnl_track_step's existing close branch emits the TradeRecord on the next call. No synthetic P&L added — records show realised_pnl from whatever was accumulated before the gap (honest: cannot fill across a halt). - New per-backtest last_event_ts_d slot tracks the previous event ts. - Test session_gap_force_closes_open_positions: 2-hour ts jump after open verifies force-flat fires and exactly 1 TradeRecord is emitted. All 16 stop_controller tests pass. All 5 decision_floor_coldstart pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1102 lines
45 KiB
Rust
1102 lines
45 KiB
Rust
//! Stop-controller spec §9.1 unit tests.
|
||
//! See docs/superpowers/specs/2026-05-19-isv-driven-stop-controller-design.md.
|
||
|
||
use anyhow::Result;
|
||
use ml_backtesting::policy::{
|
||
EnsembleAggregator, Strategy, StrategyConfig, SizingPolicyId, N_HORIZONS,
|
||
};
|
||
use ml_backtesting::sim::{BatchedSimConfig, LobSimCuda, UniformSimParams};
|
||
use ml_core::device::MlDevice;
|
||
|
||
fn level_book(mid: f32, tick: f32) -> ([f32; 10], [f32; 10], [f32; 10], [f32; 10]) {
|
||
let mut bid_px = [0.0; 10];
|
||
let mut bid_sz = [0.0; 10];
|
||
let mut ask_px = [0.0; 10];
|
||
let mut ask_sz = [0.0; 10];
|
||
for k in 0..10 {
|
||
bid_px[k] = mid - tick * (k as f32 + 1.0);
|
||
ask_px[k] = mid + tick * (k as f32 + 1.0);
|
||
bid_sz[k] = 20.0;
|
||
ask_sz[k] = 20.0;
|
||
}
|
||
(bid_px, bid_sz, ask_px, ask_sz)
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn atr_ema_bootstrap_first_observation() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Event 0: snapshot at mid=5500. After: prev_mid=5500, atr_mid_ema=0.
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
assert_eq!(sim.read_atr_mid_ema(0)?, 0.0, "ATR is 0 after first snapshot");
|
||
let prev0 = sim.read_prev_mid(0)?;
|
||
assert!((prev0 - 5500.0).abs() < 1e-4, "prev_mid bootstrapped to 5500, got {prev0}");
|
||
|
||
// Event 1: snapshot at mid=5500.5. Δ=0.5. After: atr_mid_ema=0.5 (replace, not blend).
|
||
let (bp, bs, ap, az) = level_book(5500.5, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let atr1 = sim.read_atr_mid_ema(0)?;
|
||
assert!((atr1 - 0.5).abs() < 1e-4, "ATR after 2nd snapshot equals |Δmid|=0.5, got {atr1}");
|
||
|
||
// Events 2..200: jitter book. ATR stays positive and finite, bounded by max Δ.
|
||
let mut mid = 5500.5_f32;
|
||
let mut max_delta: f32 = 0.5;
|
||
for i in 0..198 {
|
||
let drift = if i % 3 == 0 { 0.25 } else if i % 3 == 1 { -0.25 } else { 0.0 };
|
||
mid += drift;
|
||
max_delta = max_delta.max(drift.abs());
|
||
let (bp, bs, ap, az) = level_book(mid, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
}
|
||
let atr_final = sim.read_atr_mid_ema(0)?;
|
||
assert!(atr_final > 0.0 && atr_final.is_finite(),
|
||
"ATR positive + finite after 200 snapshots, got {atr_final}");
|
||
assert!(atr_final <= max_delta + 1e-3,
|
||
"ATR bounded by max observed Δ={max_delta}, got {atr_final}");
|
||
Ok(())
|
||
}
|
||
|
||
fn cfg_default(n: usize) -> BatchedSimConfig {
|
||
BatchedSimConfig::from_uniform(n, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 5,
|
||
latency_ns: 0,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 0.0,
|
||
max_hold_ns: 0,
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn stop_check_skipped_when_flat() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
|
||
// Position is flat (just allocated, zeros). Stop check must not fire;
|
||
// alpha decides the action.
|
||
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
|
||
sim.step_decision_with_latency(0, &cfg_default(1))?;
|
||
|
||
let (side, size) = sim.read_market_target(0)?;
|
||
// Strong bullish alpha + flat position → entry side=0 size>=1.
|
||
// Not (3, 0) (force-flat) and not unchanged from the initial alloc-zero state.
|
||
assert_eq!(side, 0, "with flat position, stop must not write side=3; got side={side}");
|
||
assert!(size >= 1, "entry should fire under p=0.8 + flat (size={size})");
|
||
Ok(())
|
||
}
|
||
|
||
use ml_backtesting::policy::IsvKellyStateHost;
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn sl_fires_when_unrealized_breaks_distance() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Seed isv: 5 horizons with pnl_ema_loss=2.0 → sl_distance=2.0.
|
||
// pnl_ema_win=0.0 forces cold-start branch in decision kernel (kelly_frac →
|
||
// floor=0.20, cap_lots → max_lots=5). With alpha=0.95 this gives
|
||
// lots=round(0.9*0.20*5)=round(0.9)=1 — position opens.
|
||
let seeded: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 2.0,
|
||
win_rate_ema: 0.5,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.5,
|
||
});
|
||
sim.write_isv_kelly(0, &seeded)?;
|
||
|
||
// Two snapshots so atr_mid_ema becomes nonzero (small).
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open a long position via strong-alpha + step_decision + manual fill.
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(0, &cfg_default(1))?;
|
||
let mut ts: u64 = 1_000_000;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos_open = sim.read_pos(0)?;
|
||
assert!(pos_open.position_lots > 0,
|
||
"test setup: long position should open, got {}", pos_open.position_lots);
|
||
|
||
// Drive mid down by 3.0 (>= sl_distance=2.0). Stop must fire force-flat.
|
||
let (bp3, bs3, ap3, az3) = level_book(5497.0, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
|
||
let (side, size) = sim.read_market_target(0)?;
|
||
assert_eq!(side, 3, "SL must fire with force-flat (3, 0); got side={side}");
|
||
assert_eq!(size, 0, "force-flat encoding has abs_sz=0; got size={size}");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn trail_arms_then_fires() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Cold-start ISV (forces kelly_frac_floor + max_lots cap) so position opens.
|
||
// pnl_ema_loss large enough that SL won't fire during the +5pt walk.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 10.0, // big SL so trail fires first
|
||
win_rate_ema: 0.0,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open long.
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(0, &cfg_default(1))?;
|
||
let mut ts: u64 = 1_000_000;
|
||
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; }
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opened");
|
||
|
||
// After opening, switch ISV to one with pnl_ema_win=1.5 (trail_distance target).
|
||
// The trail check reads this fresh state on subsequent decision steps.
|
||
let trail_state: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 1.5,
|
||
pnl_ema_loss: 10.0,
|
||
win_rate_ema: 0.5,
|
||
n_trades_seen: 20,
|
||
realised_return_var: 0.5,
|
||
recent_sharpe: 0.5,
|
||
});
|
||
sim.write_isv_kelly(0, &trail_state)?;
|
||
|
||
// Walk mid up to ratchet trail HWM past trail_distance=1.5.
|
||
for &m in &[5501.0_f32, 5502.0, 5503.0, 5504.0, 5505.0] {
|
||
let (b, bs, a, asz) = level_book(m, 0.25);
|
||
sim.apply_snapshot(&b, &bs, &a, &asz)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
ts += 1_000_000;
|
||
}
|
||
let hwm_armed = sim.read_trail_hwm(0)?;
|
||
assert!(hwm_armed > 1.5, "HWM should ratchet past trail_distance=1.5, got {hwm_armed}");
|
||
|
||
// Drop mid by trail_distance+ε from peak to fire trail.
|
||
let (b, bs, a, asz) = level_book(5502.0, 0.25); // drop from peak 5505 to 5502 = 3.0
|
||
sim.apply_snapshot(&b, &bs, &a, &asz)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
|
||
let (side, size) = sim.read_market_target(0)?;
|
||
assert_eq!(side, 3, "trail must fire force-flat; got side={side}");
|
||
assert_eq!(size, 0);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn multi_horizon_mask_averages_emas() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Cold-start to open. After opening, overwrite ISV so averaging arithmetic
|
||
// yields mean_sl = 3.0 across all open horizons in the mask:
|
||
// h0.pnl_ema_loss=2.0, h1=4.0, h2=h3=h4=3.0 → mean = (2+4+3*3)/5 = 3.0
|
||
// attribution_mask = 0x1F (all 5 bits) because sharpe_weight_floor=0.10
|
||
// makes every horizon weight > 1e-9 in decision_policy_default.
|
||
// Snapshots placed relative to vwap_entry to avoid ask-spread sensitivity.
|
||
// Bit-pick-first (h0=2.0) would fire at Δ_entry=2.5 → test catches it.
|
||
// Bit-pick-max (h1=4.0) would not fire at Δ_entry=4.5 → test catches it.
|
||
// Correct mean (3.0) → no fire at Δ_entry=2.5, fire at Δ_entry=4.5.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 10.0,
|
||
win_rate_ema: 0.0,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(0, &cfg_default(1))?;
|
||
let mut ts: u64 = 1_000_000;
|
||
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; }
|
||
let pos_open = sim.read_pos(0)?;
|
||
assert!(pos_open.position_lots > 0, "setup: long opens, got {}", pos_open.position_lots);
|
||
|
||
// Read the actual VWAP entry price so snapshot mids are placed relative to it,
|
||
// making the test robust to ask-spread and tick size.
|
||
let entry_px = pos_open.vwap_entry;
|
||
assert!(entry_px > 0.0, "vwap_entry must be set after fill, got {entry_px}");
|
||
|
||
// Seed multi-horizon ISV state. pnl_ema_win=100.0 keeps trail_distance huge
|
||
// so the trail never fires during the test.
|
||
// See setup comment above: mean=3.0, regression discriminators at Δ=2.5/4.5.
|
||
let mut seeded: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 100.0,
|
||
pnl_ema_loss: 3.0,
|
||
win_rate_ema: 0.5,
|
||
n_trades_seen: 20,
|
||
realised_return_var: 0.5,
|
||
recent_sharpe: 0.5,
|
||
});
|
||
seeded[0].pnl_ema_loss = 2.0;
|
||
seeded[1].pnl_ema_loss = 4.0;
|
||
// h2, h3, h4 remain 3.0. Mean = (2+4+3+3+3)/5 = 3.0.
|
||
sim.write_isv_kelly(0, &seeded)?;
|
||
|
||
// Snapshots placed so bid[0] = entry_px - delta_from_entry (for a long):
|
||
// unrealized_pl_per_lot = bid[0] - entry_px = -delta_from_entry
|
||
// sl_distance = fmaxf(mean_ema_loss=3.0, atr). ATR after these moderate
|
||
// price moves stays < 3.0 (the move is ~2.5 from the open level), so
|
||
// sl_distance == 3.0. (If ATR somehow > 3.0, sl_distance > 3.0 and the
|
||
// no-fire leg is still safe; only the fire leg could be affected but
|
||
// we use Δ=4.5 which gives 1.5× headroom above the 3.0 floor.)
|
||
//
|
||
// No-fire: Δ_from_entry = 2.5 — bid[0] = entry_px - 2.5 → mid = entry_px - 2.25
|
||
let mid_no_fire = entry_px - 2.25;
|
||
let (bp3, bs3, ap3, az3) = level_book(mid_no_fire, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
let (side_at_25, _) = sim.read_market_target(0)?;
|
||
assert_ne!(side_at_25, 3,
|
||
"Δ=2.5 < mean_sl=3.0 must not fire under correct averaging; got side={side_at_25}");
|
||
ts += 1_000_000;
|
||
|
||
let atr_at_check = sim.read_atr_mid_ema(0)?;
|
||
assert!(
|
||
atr_at_check < 3.0,
|
||
"test precondition: ATR must stay below mean_sl=3.0 to make sl_distance = max(ema_loss=3.0, atr) = 3.0; got atr={atr_at_check}"
|
||
);
|
||
|
||
// Fire: Δ_from_entry = 4.5 (> mean_sl=3.0 and > any reasonable ATR floor)
|
||
// bid[0] = entry_px - 4.5 → mid = entry_px - 4.25
|
||
let mid_fire = entry_px - 4.25;
|
||
let (bp4, bs4, ap4, az4) = level_book(mid_fire, 0.25);
|
||
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
let (side_at_45, size_at_45) = sim.read_market_target(0)?;
|
||
assert_eq!(side_at_45, 3, "Δ=4.5 > mean_sl=3.0 must fire force-flat; got side={side_at_45}");
|
||
assert_eq!(size_at_45, 0);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn bytecode_and_default_kernel_agree_on_stops() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
|
||
// Helper: drive one scenario using either the default kernel (use_program=false)
|
||
// or the bytecode VM kernel (use_program=true). Returns the market_target after
|
||
// a stop condition is triggered.
|
||
fn drive_scenario(use_program: bool, dev: &MlDevice) -> Result<(i32, i32)> {
|
||
let mut sim = LobSimCuda::new(1, dev)?;
|
||
|
||
if use_program {
|
||
// Upload an Ensemble(MaxConfidence) program so program_lens[0] != 0
|
||
// and the program kernel handles this backtest.
|
||
let prog = Strategy::Ensemble {
|
||
children: (0..N_HORIZONS as u8)
|
||
.map(|h| Strategy::Leaf(StrategyConfig {
|
||
horizon_idx: h,
|
||
sizing_policy: SizingPolicyId::IsvKelly,
|
||
max_concurrent_lots: 5,
|
||
}))
|
||
.collect(),
|
||
aggregator: EnsembleAggregator::MaxConfidence,
|
||
};
|
||
sim.upload_program(0, &prog.flatten())?;
|
||
}
|
||
|
||
// Cold-start ISV: pnl_ema_win=0.0, n_trades_seen=0, realised_return_var=0.0
|
||
// forces kelly_frac_floor + max_lots path so position opens.
|
||
// pnl_ema_loss=10.0 keeps SL far enough that it won't fire during open setup.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 10.0,
|
||
win_rate_ema: 0.0,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open long.
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(0, &cfg_default(1))?;
|
||
let mut ts: u64 = 1_000_000;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos_open = sim.read_pos(0)?;
|
||
assert!(
|
||
pos_open.position_lots > 0,
|
||
"setup ({} kernel): long must open, got {}",
|
||
if use_program { "program" } else { "default" },
|
||
pos_open.position_lots
|
||
);
|
||
|
||
// Set SL trigger setup: pnl_ema_loss=2.0 → sl_distance=2.0 (assuming ATR < 2.0).
|
||
// pnl_ema_win=10.0 keeps trail_distance=10.0 so trail won't fire before SL.
|
||
let triggered: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 10.0,
|
||
pnl_ema_loss: 2.0,
|
||
win_rate_ema: 0.5,
|
||
n_trades_seen: 20,
|
||
realised_return_var: 0.5,
|
||
recent_sharpe: 0.5,
|
||
});
|
||
sim.write_isv_kelly(0, &triggered)?;
|
||
|
||
// Drive mid down well past sl_distance=2.0 relative to vwap_entry.
|
||
// trigger_mid = entry - 4.0 → unrealized = bid[0] - entry = (mid - tick) - entry
|
||
// = (entry - 4.0 - 0.25) - entry = -4.25 <= -2.0 → SL fires.
|
||
let trigger_mid = pos_open.vwap_entry - 4.0;
|
||
let (bp3, bs3, ap3, az3) = level_book(trigger_mid, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
sim.read_market_target(0)
|
||
}
|
||
|
||
let mt_default = drive_scenario(false, &dev)?;
|
||
let mt_program = drive_scenario(true, &dev)?;
|
||
|
||
assert_eq!(
|
||
mt_default, mt_program,
|
||
"default kernel and program kernel disagree on stop output: default={mt_default:?} program={mt_program:?}"
|
||
);
|
||
assert_eq!(
|
||
mt_default.0, 3,
|
||
"both kernels must fire stop force-flat (side=3); got side={}",
|
||
mt_default.0
|
||
);
|
||
assert_eq!(mt_default.1, 0, "force-flat encoding has abs_sz=0; got size={}", mt_default.1);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn position_target_not_additive() -> Result<()> {
|
||
// latency=0: target (0, n) repeated must NOT accumulate the position
|
||
// past n. Cap is max_lots=5.
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
|
||
let cfg = cfg_default(1);
|
||
let mut ts: u64 = 0;
|
||
for _ in 0..10 {
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg)?;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos = sim.read_pos(0)?;
|
||
assert!(pos.position_lots.abs() <= 5,
|
||
"|position_lots| must respect max_lots=5 with target semantics; got {}",
|
||
pos.position_lots);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn position_target_not_additive_with_latency() -> Result<()> {
|
||
// 200ms latency: in-flight orders must count toward effective_position
|
||
// so re-submission of the same target produces delta=0 and no new order.
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
|
||
let cfg_lat = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 5,
|
||
latency_ns: 200_000_000,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 0.0,
|
||
max_hold_ns: 0,
|
||
});
|
||
|
||
let mut ts: u64 = 0;
|
||
// 500 events of persistent target — most should produce delta=0.
|
||
for _ in 0..500 {
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_lat)?;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos = sim.read_pos(0)?;
|
||
assert!(pos.position_lots.abs() <= 5,
|
||
"|position_lots| must stay <= max_lots=5 under 200ms latency with in-flight summation; got {}",
|
||
pos.position_lots);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn alpha_noop_side_2_preserved() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Seed ISV with large pnl_ema_loss (= 10.0) so sl_distance stays far enough
|
||
// that the spread (0.25) and small price moves never fire the SL during the
|
||
// open-position setup phase. Same pattern as other open-setup tests.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 10.0,
|
||
win_rate_ema: 0.0,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
let mut ts: u64 = 0;
|
||
|
||
// Open long via strong alpha.
|
||
for _ in 0..10 {
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos_open = sim.read_pos(0)?.position_lots;
|
||
assert!(pos_open > 0, "setup: long opens with strong alpha, got {}", pos_open);
|
||
|
||
// Weak alpha that produces sig_mag * 0.20 * cap_lots < 0.5, rounding lots=0
|
||
// → market_target = (2, 0). The pos must stay unchanged.
|
||
// Compute: sig_mag = |0.55-0.5|*2 = 0.1; ss = 0.1*0.20*5 = 0.1; truncf(0.1+0.5)=0.
|
||
for _ in 0..20 {
|
||
sim.broadcast_alpha(&[0.55, 0.55, 0.55, 0.55, 0.55])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos_after = sim.read_pos(0)?.position_lots;
|
||
assert_eq!(pos_after, pos_open,
|
||
"side=2 must preserve position; opened={} after 20 weak-alpha events={}",
|
||
pos_open, pos_after);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn trail_hwm_reset_on_close() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0, pnl_ema_loss: 10.0,
|
||
win_rate_ema: 0.0, n_trades_seen: 0,
|
||
realised_return_var: 0.0, recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open long.
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; }
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens");
|
||
|
||
// Switch ISV to trail-test state (pnl_ema_win=1.5 → trail_distance=1.5).
|
||
let trail_state: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 1.5, pnl_ema_loss: 100.0, // big SL → only trail can fire
|
||
win_rate_ema: 0.5, n_trades_seen: 20,
|
||
realised_return_var: 0.5, recent_sharpe: 0.5,
|
||
});
|
||
sim.write_isv_kelly(0, &trail_state)?;
|
||
|
||
// Ratchet HWM up.
|
||
for &m in &[5501.0_f32, 5502.0, 5503.0, 5504.0, 5505.0] {
|
||
let (b, bs, a, asz) = level_book(m, 0.25);
|
||
sim.apply_snapshot(&b, &bs, &a, &asz)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let hwm_armed = sim.read_trail_hwm(0)?;
|
||
assert!(hwm_armed > 1.5, "HWM should ratchet past trail_distance=1.5, got {hwm_armed}");
|
||
|
||
// Fire trail — drop mid past trail_distance from peak.
|
||
let (b, bs, a, asz) = level_book(5502.0, 0.25);
|
||
sim.apply_snapshot(&b, &bs, &a, &asz)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
let (s_fire, _) = sim.read_market_target(0)?;
|
||
assert_eq!(s_fire, 3, "trail must fire force-flat");
|
||
|
||
// Drain so position flattens AND pnl_track runs.
|
||
for _ in 0..10 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos_closed = sim.read_pos(0)?;
|
||
assert_eq!(pos_closed.position_lots, 0,
|
||
"position must flatten after trail fire; got {}", pos_closed.position_lots);
|
||
let hwm_after_close = sim.read_trail_hwm(0)?;
|
||
assert_eq!(hwm_after_close, 0.0,
|
||
"pnl_track_step must reset trail_hwm to 0 on close; got {hwm_after_close}");
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that max_hold_ns fires a force-flat when the elapsed hold
|
||
/// time exceeds the configured limit, and does NOT fire before that limit.
|
||
///
|
||
/// Test geometry:
|
||
/// - max_hold_ns = 100ms = 100_000_000ns
|
||
/// - Open long at t=1ms via strong alpha
|
||
/// - At entry_ts + 50ms: still under limit → must NOT fire (side != 3)
|
||
/// - At entry_ts + 150ms: past limit → must fire force-flat (side=3, size=0)
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn max_hold_forces_close() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Cold-start ISV: large pnl_ema_loss so SL never fires during the test.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 100.0,
|
||
win_rate_ema: 0.0,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Config with max_hold_ns = 100ms.
|
||
let cfg_max_hold = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 5,
|
||
latency_ns: 0,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 0.0,
|
||
max_hold_ns: 100_000_000, // 100ms
|
||
});
|
||
|
||
// Open long at t=1ms via strong alpha.
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; }
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens");
|
||
let entry_ts = ts;
|
||
|
||
// At entry_ts + 50ms — still under 100ms hold. Book stable so SL/trail don't fire.
|
||
ts = entry_ts + 50_000_000;
|
||
let (bp3, bs3, ap3, az3) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
let (side_pre, _) = sim.read_market_target(0)?;
|
||
assert_ne!(side_pre, 3, "elapsed 50ms < max_hold=100ms: must NOT fire; got side={side_pre}");
|
||
|
||
// At entry_ts + 150ms — past max_hold.
|
||
ts = entry_ts + 150_000_000;
|
||
let (bp4, bs4, ap4, az4) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
let (side_post, size_post) = sim.read_market_target(0)?;
|
||
assert_eq!(side_post, 3, "elapsed 150ms > max_hold=100ms: must fire force-flat; got side={side_post}");
|
||
assert_eq!(size_post, 0);
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that NaN or Inf prices in an MBP-10 snapshot do not propagate
|
||
/// into realized_pnl via the book walk → fill pipeline.
|
||
///
|
||
/// Real MBP-10 data at session boundaries or gap-fills can contain NaN or Inf
|
||
/// in level prices/sizes. apply_snapshot_kernel must sanitize them so the walk
|
||
/// helpers never accumulate NaN/Inf cost, which would corrupt avg_px and
|
||
/// subsequently realized_pnl, emitting the ±21,474,836 sentinel.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn book_nan_inf_prices_dont_corrupt_realized_pnl() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Build a book with NaN at one level and Inf at another (real MBP-10
|
||
// data at session boundaries can contain these).
|
||
let mut bid_px = [0.0_f32; 10];
|
||
let mut bid_sz = [0.0_f32; 10];
|
||
let mut ask_px = [0.0_f32; 10];
|
||
let mut ask_sz = [0.0_f32; 10];
|
||
for k in 0..10 {
|
||
bid_px[k] = 5500.0 - 0.25 * (k as f32 + 1.0);
|
||
ask_px[k] = 5500.0 + 0.25 * (k as f32 + 1.0);
|
||
bid_sz[k] = 20.0;
|
||
ask_sz[k] = 20.0;
|
||
}
|
||
// Inject corruption at level 3 (NaN price) and level 5 (Inf size).
|
||
ask_px[3] = f32::NAN;
|
||
bid_sz[5] = f32::INFINITY;
|
||
|
||
// First snapshot bootstraps ATR + book.
|
||
sim.apply_snapshot(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
|
||
// Second snapshot: same book; ATR converges; sanitization tested again.
|
||
sim.apply_snapshot(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
|
||
|
||
// Open long via strong alpha. The fill must NOT propagate NaN/Inf into
|
||
// pos.realized_pnl, even though the book has NaN at ask[3] and Inf at bid[5].
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
for _ in 0..5 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
|
||
let pos = sim.read_pos(0)?;
|
||
assert!(
|
||
pos.realized_pnl.is_finite(),
|
||
"realized_pnl must stay finite even with NaN/Inf in book; got {}",
|
||
pos.realized_pnl
|
||
);
|
||
// Position may or may not have opened (depending on which levels were used).
|
||
// The invariant is finiteness, not a specific position state.
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that pnl_track resets scratch on close so that:
|
||
/// (a) flat events after a close do NOT emit duplicate close records, and
|
||
/// (b) the next open transition is captured correctly (exactly 1 record per trade).
|
||
///
|
||
/// Without the scratch reset: prev_size stays = prior entry_size, so every flat
|
||
/// event fires the close branch again (duplicates). The next open (0→1) sees
|
||
/// prev=1, now=1 → neither branch fires → open record never captured → second
|
||
/// close uses stale realized_at_open from trade 1.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn pnl_track_resets_scratch_on_close() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0, pnl_ema_loss: 100.0, // huge SL so it doesn't fire
|
||
win_rate_ema: 0.0, n_trades_seen: 0,
|
||
realised_return_var: 0.0, recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Config with max_hold_ns = 100ms to force-close without needing SL/trail.
|
||
let cfg_max_hold = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 5,
|
||
latency_ns: 0,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 0.0,
|
||
max_hold_ns: 100_000_000, // 100ms
|
||
});
|
||
|
||
// Trade 1: open long, force-flatten via max_hold.
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "trade 1: long opens");
|
||
|
||
// Force close trade 1 via max_hold: jump ts by 200ms.
|
||
ts += 200_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
for _ in 0..5 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
assert_eq!(sim.read_pos(0)?.position_lots, 0, "trade 1: closed via max_hold");
|
||
let trades_after_t1 = sim.read_total_trade_count()?;
|
||
assert_eq!(trades_after_t1, 1,
|
||
"trade 1: exactly ONE record emitted (no duplicates); got {trades_after_t1}");
|
||
|
||
// Several flat events — pnl_track must NOT emit duplicate close records.
|
||
for _ in 0..5 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let trades_after_flat = sim.read_total_trade_count()?;
|
||
assert_eq!(trades_after_flat, 1,
|
||
"flat events: no duplicate close records; got {trades_after_flat}");
|
||
|
||
// Trade 2: re-open + close via max_hold.
|
||
ts += 10_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "trade 2: long opens");
|
||
|
||
// Force close trade 2.
|
||
ts += 200_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
for _ in 0..5 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
assert_eq!(sim.read_pos(0)?.position_lots, 0, "trade 2: closed via max_hold");
|
||
let trades_after_t2 = sim.read_total_trade_count()?;
|
||
assert_eq!(trades_after_t2, 2,
|
||
"trade 2: exactly TWO records total (1 per trade, no missed open); got {trades_after_t2}");
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that the trade-vol floor prevents sub-cost stop fires.
|
||
///
|
||
/// Per pearl_trade_level_vol_for_stop_distance.md: at cold-start (var=0),
|
||
/// trade_vol = sqrt(max(0, cost²)) = cost = 0.125. With ema_loss=0 and
|
||
/// ATR decayed to ≈ 0.003 before entry, sl_distance = max(0, atr, 0.125) = 0.125.
|
||
///
|
||
/// Test geometry (long position, tick=0.25, cost=0.125):
|
||
/// entry fills at ask[0] = mid_fill + tick
|
||
/// unrealized = bid[0] - entry = (mid_new - tick) - entry
|
||
///
|
||
/// Boundary straddling trade_vol = 0.125:
|
||
/// no-fire (u = -0.08): mid_new = entry + tick - 0.08 = entry + 0.17
|
||
/// |u| = 0.08 < trade_vol = 0.125 → NOT fire ✓
|
||
/// fire (u = -0.20): mid_new = entry + tick - 0.20 = entry + 0.05
|
||
/// |u| = 0.20 > trade_vol = 0.125 → fires ✓
|
||
///
|
||
/// ATR convergence: 25 identical snapshots (delta=0) decay ATR to ≈ 0.003
|
||
/// before opening. Both discriminating snaps produce ATR well below 0.125,
|
||
/// so trade_vol is always the binding constraint in this cold-start scenario.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn trade_vol_floor_prevents_sub_cost_stops() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Zero ema_loss: only ATR and cost floor govern sl_distance.
|
||
// Large pnl_ema_win to keep trail_distance enormous, preventing trail fires.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 100.0, pnl_ema_loss: 0.0,
|
||
win_rate_ema: 0.0, n_trades_seen: 0,
|
||
realised_return_var: 0.0, recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
// Bootstrap ATR: two different mids to seed first-observation atr_mid_ema.
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Converge ATR to ≈ 0 via 25 identical snapshots at 5500.1 (delta=0).
|
||
// After 25 steps: atr ≈ 0.6^25 * 0.1 ≈ 0.003 — tiny baseline.
|
||
for _ in 0..25 {
|
||
let (bp, bs, ap, az) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
}
|
||
|
||
// Open long with cost=0.125 (trade_vol floor at cold-start = cost = 0.125).
|
||
let cfg_cost = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 5,
|
||
latency_ns: 0,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 0.125,
|
||
max_hold_ns: 0,
|
||
});
|
||
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(0, &cfg_cost)?;
|
||
let mut ts: u64 = 1_000_000;
|
||
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; }
|
||
let pos_open = sim.read_pos(0)?;
|
||
assert!(pos_open.position_lots > 0, "setup: long opens (got {})", pos_open.position_lots);
|
||
let entry = pos_open.vwap_entry;
|
||
// entry ≈ 5500.1 + tick(0.25) = 5500.35
|
||
|
||
// No-fire leg: unrealized = -0.08 (< trade_vol = cost = 0.125).
|
||
// trade_vol = sqrt(max(var=0, cost²=0.015625)) = 0.125
|
||
// sl_distance = max(0, ATR≈0.003, 0.125) = 0.125; 0.08 < 0.125 → NOT fire ✓
|
||
// bid[0] = entry - 0.08 → mid = entry - 0.08 + tick(0.25) = entry + 0.17
|
||
let mid_no_fire = entry + 0.17;
|
||
let (bp3, bs3, ap3, az3) = level_book(mid_no_fire, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
// ATR update: delta ≈ |(entry+0.17) - 5500.1| — pumped slightly but still << 0.125 ✓
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_cost)?;
|
||
let (side_no_fire, _) = sim.read_market_target(0)?;
|
||
assert_ne!(side_no_fire, 3,
|
||
"unrealized=-0.08 < trade_vol=0.125: SL must NOT fire; got side={side_no_fire}");
|
||
ts += 1_000_000;
|
||
|
||
// Fire leg: unrealized = -0.20 (> trade_vol = 0.125 → fires).
|
||
// bid[0] = entry - 0.20 → mid = entry - 0.20 + tick(0.25) = entry + 0.05
|
||
let mid_fire = entry + 0.05;
|
||
let (bp4, bs4, ap4, az4) = level_book(mid_fire, 0.25);
|
||
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_cost)?;
|
||
let (side_fire, size_fire) = sim.read_market_target(0)?;
|
||
assert_eq!(side_fire, 3,
|
||
"unrealized=-0.20 > trade_vol=0.125: SL must fire force-flat; got side={side_fire}");
|
||
assert_eq!(size_fire, 0);
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that a timestamp gap > 1 hour (session boundary / weekend halt)
|
||
/// force-closes any open position before the next event is processed.
|
||
///
|
||
/// max_hold_ns can't fire during a halt because no events advance current_ts.
|
||
/// The session-gap check in resting_orders_step detects the jump and zeroes
|
||
/// position_lots directly; pnl_track_step's existing close branch then emits
|
||
/// exactly one TradeRecord.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn session_gap_force_closes_open_positions() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0, pnl_ema_loss: 100.0, // huge SL — won't fire
|
||
win_rate_ema: 0.0, n_trades_seen: 0,
|
||
realised_return_var: 0.0, recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open long at t=1ms.
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens");
|
||
let trades_before_gap = sim.read_total_trade_count()?;
|
||
|
||
// Advance ts by 2 hours (> 1 hour SESSION_GAP_NS). Call step_resting_orders
|
||
// with the new ts — session-gap force-close fires, then pnl_track detects
|
||
// the prev!=0 && now==0 close transition and emits a TradeRecord.
|
||
ts += 2 * 3_600_000_000_000u64;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
|
||
assert_eq!(
|
||
sim.read_pos(0)?.position_lots, 0,
|
||
"session gap > 1h must force-close the position"
|
||
);
|
||
let trades_after_gap = sim.read_total_trade_count()?;
|
||
assert_eq!(
|
||
trades_after_gap, trades_before_gap + 1,
|
||
"session-gap close emits exactly one TradeRecord; got {} -> {}",
|
||
trades_before_gap, trades_after_gap
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that a corrupt top-of-book snapshot is skipped entirely and the
|
||
/// per-backtest `snapshots_skipped` counter is incremented.
|
||
///
|
||
/// Bug C-b (smoke v74v4 2026-05-20): per-level sanitization (Task 15) zeros each
|
||
/// unhealthy level individually. When ALL 10 levels are invalid (session-boundary
|
||
/// event), the book becomes uniformly zero. apply_fill_to_pos then reads
|
||
/// bid_px[0]=0/ask_px[0]=0 → vwap_entry=0 → trade record entry_px=0 (zero sentinel).
|
||
///
|
||
/// Fix: pre-validate top-of-book in apply_snapshot_kernel. If bid_px[0]/ask_px[0]
|
||
/// are non-finite or zero/negative, skip the entire snapshot — book, prev_mid,
|
||
/// and atr_mid_ema remain unchanged.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn corrupt_top_of_book_skips_snapshot_and_increments_counter() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Apply a valid book first to seed prev_mid and atr_mid_ema.
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
// Apply a second valid snapshot to advance ATR from 0.
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.5, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
let atr_before = sim.read_atr_mid_ema(0)?;
|
||
let prev_mid_before = sim.read_prev_mid(0)?;
|
||
// Sanity: atr_before must be nonzero after two valid snapshots with Δ=0.5.
|
||
assert!(atr_before > 0.0, "ATR should be nonzero after two valid snapshots, got {atr_before}");
|
||
|
||
// Case 1: top-of-book bid_px[0] = NaN. Whole snapshot must be skipped.
|
||
let mut bp_bad = bp2;
|
||
bp_bad[0] = f32::NAN;
|
||
sim.apply_snapshot(&bp_bad, &bs2, &ap2, &az2)?;
|
||
|
||
let skipped = sim.read_snapshots_skipped()?;
|
||
assert_eq!(skipped[0], 1,
|
||
"corrupt top-of-book (NaN bid_px[0]) should increment skip counter to 1; got {}", skipped[0]);
|
||
let atr_after_nan = sim.read_atr_mid_ema(0)?;
|
||
let prev_mid_after_nan = sim.read_prev_mid(0)?;
|
||
assert_eq!(atr_after_nan, atr_before,
|
||
"ATR must not change on corrupt snapshot; before={atr_before} after={atr_after_nan}");
|
||
assert_eq!(prev_mid_after_nan, prev_mid_before,
|
||
"prev_mid must not change on corrupt snapshot; before={prev_mid_before} after={prev_mid_after_nan}");
|
||
|
||
// Case 2: top-of-book bid_sz[0] = 0. Must also skip.
|
||
let mut bs_bad = bs2;
|
||
bs_bad[0] = 0.0;
|
||
sim.apply_snapshot(&bp2, &bs_bad, &ap2, &az2)?;
|
||
|
||
let skipped_2 = sim.read_snapshots_skipped()?;
|
||
assert_eq!(skipped_2[0], 2,
|
||
"zero top-of-book bid_sz[0] should increment skip counter to 2; got {}", skipped_2[0]);
|
||
let atr_after_zero_sz = sim.read_atr_mid_ema(0)?;
|
||
assert_eq!(atr_after_zero_sz, atr_before,
|
||
"ATR must not change on zero-size corrupt snapshot; got {atr_after_zero_sz}");
|
||
|
||
// Case 3: valid snapshot after two corrupt skips must update ATR normally.
|
||
let (bp3, bs3, ap3, az3) = level_book(5500.5, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
let skipped_3 = sim.read_snapshots_skipped()?;
|
||
assert_eq!(skipped_3[0], 2,
|
||
"valid snapshot must not increment skip counter; got {}", skipped_3[0]);
|
||
let atr_after_valid = sim.read_atr_mid_ema(0)?;
|
||
// mid didn't move (same 5500.5 as prev), so delta=0, atr stays at atr_before.
|
||
// The assertion here is that it is still finite and the state is reachable.
|
||
assert!(atr_after_valid.is_finite(),
|
||
"ATR must remain finite after valid snapshot following skips; got {atr_after_valid}");
|
||
|
||
// Case 4: valid snapshot with a different mid forces an ATR update, proving
|
||
// the pipeline is unblocked after the skips.
|
||
let (bp4, bs4, ap4, az4) = level_book(5501.5, 0.25);
|
||
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
|
||
let atr_after_move = sim.read_atr_mid_ema(0)?;
|
||
assert_ne!(atr_after_move, atr_before,
|
||
"valid snapshot with Δmid=1.0 must update ATR; before={atr_before} after={atr_after_move}");
|
||
|
||
Ok(())
|
||
}
|