Anti-calibration evidence (smoke ckpts across architecture variants
showed higher reported conviction → MORE negative PnL, -15.6 → -145.2)
traced to a magnitude/direction mismatch in the sizing formula:
conv_ema = EMA(|conviction_signed|) # magnitude smoothing
target_lots = sign(conviction_signed) # INSTANTANEOUS sign
× conv_ema × max_lots # × SMOOTHED magnitude
When per-horizon directions disagree event-to-event, the magnitude EMA
stays high (|x| EMA can't cancel) but the sign flips on noise. Result:
big trades in random direction whenever the 5 horizons disagree.
Replace with a single signed-conviction EMA:
conv_signed_ema = EMA(conviction_signed)
target_lots = sign(conv_signed_ema)
× |conv_signed_ema| × max_lots
Now BOTH sign AND magnitude come from the same smoothed signal. When
directions disagree, signed EMA → 0 collapses size to zero naturally.
Atomic migration across decision_policy.cu (2 kernels), pnl_track.cu
(open-branch reads the SIGNED buffer and takes fabsf for the magnitude-
semantics open_trade_state offsets that composite_exit_check forms ratios
from), and src/sim/mod.rs (renamed device buffers + accessor + 4 launch
sites). No legacy aliases per feedback_no_legacy_aliases; no parallel
buffers per feedback_single_source_of_truth_no_duplicates; α floor 0.4
preserved per pearl_wiener_alpha_floor_for_nonstationary; first-
observation bootstrap preserved per pearl_first_observation_bootstrap.
ml-backtesting lib: 33 passed.
GPU oracle: signed_conviction_ema_collapses_on_sign_flips passes —
observed steady-state shows |signed_ema| ≈ 0.205 (post-fix) producing
|target_lots| = 2 every tail event, vs the pre-fix bug's predicted
|target_lots| = 7 every tail event. Existing
multi_horizon_conviction_cancels_on_disagreement still passes (zero-
cancellation regime is even cleaner under signed EMA).
ml-alpha lib: 33 passed (no regression).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1580 lines
68 KiB
Rust
1580 lines
68 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: 1.0,
|
||
max_hold_ns: 0,
|
||
min_reasonable_px: 0.0,
|
||
max_reasonable_px: f32::INFINITY,
|
||
delta_floor: 0.0,
|
||
})
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn stop_check_skipped_when_flat() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
|
||
// Position is flat (just allocated, zeros). Stop check must not fire;
|
||
// alpha decides the action.
|
||
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
|
||
sim.step_decision_with_latency(0, &cfg_default(1))?;
|
||
|
||
let (side, size) = sim.read_market_target(0)?;
|
||
// Strong bullish alpha + flat position → entry side=0 size>=1.
|
||
// Not (3, 0) (force-flat) and not unchanged from the initial alloc-zero state.
|
||
assert_eq!(side, 0, "with flat position, stop must not write side=3; got side={side}");
|
||
assert!(size >= 1, "entry should fire under p=0.8 + flat (size={size})");
|
||
Ok(())
|
||
}
|
||
|
||
use ml_backtesting::policy::IsvKellyStateHost;
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn sl_fires_when_unrealized_breaks_distance() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Seed isv: 5 horizons with pnl_ema_loss=2.0 → sl_distance=2.0.
|
||
// pnl_ema_win=0.0 forces cold-start branch in decision kernel (kelly_frac →
|
||
// floor=0.20, cap_lots → max_lots=5). With alpha=0.95 this gives
|
||
// lots=round(0.9*0.20*5)=round(0.9)=1 — position opens.
|
||
let seeded: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 2.0,
|
||
win_rate_ema: 0.5,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.5,
|
||
});
|
||
sim.write_isv_kelly(0, &seeded)?;
|
||
|
||
// Two snapshots so atr_mid_ema becomes nonzero (small).
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open a long position via strong-alpha + step_decision + manual fill.
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(0, &cfg_default(1))?;
|
||
let mut ts: u64 = 1_000_000;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos_open = sim.read_pos(0)?;
|
||
assert!(pos_open.position_lots > 0,
|
||
"test setup: long position should open, got {}", pos_open.position_lots);
|
||
|
||
// Drive mid down by 3.0 (>= sl_distance=2.0). Stop must fire force-flat.
|
||
let (bp3, bs3, ap3, az3) = level_book(5497.0, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
|
||
let (side, size) = sim.read_market_target(0)?;
|
||
assert_eq!(side, 3, "SL must fire with force-flat (3, 0); got side={side}");
|
||
assert_eq!(size, 0, "force-flat encoding has abs_sz=0; got size={size}");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn trail_arms_then_fires() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Cold-start ISV (forces kelly_frac_floor + max_lots cap) so position opens.
|
||
// pnl_ema_loss large enough that SL won't fire during the +5pt walk.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 10.0, // big SL so trail fires first
|
||
win_rate_ema: 0.0,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open long.
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(0, &cfg_default(1))?;
|
||
let mut ts: u64 = 1_000_000;
|
||
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; }
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opened");
|
||
|
||
// After opening, switch ISV to one with pnl_ema_win=1.5 (trail_distance target).
|
||
// The trail check reads this fresh state on subsequent decision steps.
|
||
let trail_state: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 1.5,
|
||
pnl_ema_loss: 10.0,
|
||
win_rate_ema: 0.5,
|
||
n_trades_seen: 20,
|
||
realised_return_var: 0.5,
|
||
recent_sharpe: 0.5,
|
||
});
|
||
sim.write_isv_kelly(0, &trail_state)?;
|
||
|
||
// Walk mid up to ratchet trail HWM past trail_distance=1.5.
|
||
for &m in &[5501.0_f32, 5502.0, 5503.0, 5504.0, 5505.0] {
|
||
let (b, bs, a, asz) = level_book(m, 0.25);
|
||
sim.apply_snapshot(&b, &bs, &a, &asz)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
ts += 1_000_000;
|
||
}
|
||
let hwm_armed = sim.read_trail_hwm(0)?;
|
||
assert!(hwm_armed > 1.5, "HWM should ratchet past trail_distance=1.5, got {hwm_armed}");
|
||
|
||
// Drop mid by trail_distance+ε from peak to fire trail.
|
||
let (b, bs, a, asz) = level_book(5502.0, 0.25); // drop from peak 5505 to 5502 = 3.0
|
||
sim.apply_snapshot(&b, &bs, &a, &asz)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
|
||
let (side, size) = sim.read_market_target(0)?;
|
||
assert_eq!(side, 3, "trail must fire force-flat; got side={side}");
|
||
assert_eq!(size, 0);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn multi_horizon_mask_averages_emas() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Cold-start to open. After opening, overwrite ISV so averaging arithmetic
|
||
// yields mean_sl = 3.0 across all open horizons in the mask:
|
||
// h0.pnl_ema_loss=2.0, h1=4.0, h2=h3=h4=3.0 → mean = (2+4+3*3)/5 = 3.0
|
||
// attribution_mask = 0x1F (all 5 bits) because sharpe_weight_floor=0.10
|
||
// makes every horizon weight > 1e-9 in decision_policy_default.
|
||
// Snapshots placed relative to vwap_entry to avoid ask-spread sensitivity.
|
||
// Bit-pick-first (h0=2.0) would fire at Δ_entry=2.5 → test catches it.
|
||
// Bit-pick-max (h1=4.0) would not fire at Δ_entry=4.5 → test catches it.
|
||
// Correct mean (3.0) → no fire at Δ_entry=2.5, fire at Δ_entry=4.5.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 10.0,
|
||
win_rate_ema: 0.0,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(0, &cfg_default(1))?;
|
||
let mut ts: u64 = 1_000_000;
|
||
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; }
|
||
let pos_open = sim.read_pos(0)?;
|
||
assert!(pos_open.position_lots > 0, "setup: long opens, got {}", pos_open.position_lots);
|
||
|
||
// Read the actual VWAP entry price so snapshot mids are placed relative to it,
|
||
// making the test robust to ask-spread and tick size.
|
||
let entry_px = pos_open.vwap_entry;
|
||
assert!(entry_px > 0.0, "vwap_entry must be set after fill, got {entry_px}");
|
||
|
||
// Seed multi-horizon ISV state. pnl_ema_win=100.0 keeps trail_distance huge
|
||
// so the trail never fires during the test.
|
||
// See setup comment above: mean=3.0, regression discriminators at Δ=2.5/4.5.
|
||
let mut seeded: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 100.0,
|
||
pnl_ema_loss: 3.0,
|
||
win_rate_ema: 0.5,
|
||
n_trades_seen: 20,
|
||
realised_return_var: 0.5,
|
||
recent_sharpe: 0.5,
|
||
});
|
||
seeded[0].pnl_ema_loss = 2.0;
|
||
seeded[1].pnl_ema_loss = 4.0;
|
||
// h2, h3, h4 remain 3.0. Mean = (2+4+3+3+3)/5 = 3.0.
|
||
sim.write_isv_kelly(0, &seeded)?;
|
||
|
||
// Snapshots placed so bid[0] = entry_px - delta_from_entry (for a long):
|
||
// unrealized_pl_per_lot = bid[0] - entry_px = -delta_from_entry
|
||
// sl_distance = fmaxf(mean_ema_loss=3.0, atr). ATR after these moderate
|
||
// price moves stays < 3.0 (the move is ~2.5 from the open level), so
|
||
// sl_distance == 3.0. (If ATR somehow > 3.0, sl_distance > 3.0 and the
|
||
// no-fire leg is still safe; only the fire leg could be affected but
|
||
// we use Δ=4.5 which gives 1.5× headroom above the 3.0 floor.)
|
||
//
|
||
// No-fire: Δ_from_entry = 2.5 — bid[0] = entry_px - 2.5 → mid = entry_px - 2.25
|
||
let mid_no_fire = entry_px - 2.25;
|
||
let (bp3, bs3, ap3, az3) = level_book(mid_no_fire, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
let (side_at_25, _) = sim.read_market_target(0)?;
|
||
assert_ne!(side_at_25, 3,
|
||
"Δ=2.5 < mean_sl=3.0 must not fire under correct averaging; got side={side_at_25}");
|
||
ts += 1_000_000;
|
||
|
||
let atr_at_check = sim.read_atr_mid_ema(0)?;
|
||
assert!(
|
||
atr_at_check < 3.0,
|
||
"test precondition: ATR must stay below mean_sl=3.0 to make sl_distance = max(ema_loss=3.0, atr) = 3.0; got atr={atr_at_check}"
|
||
);
|
||
|
||
// Fire: Δ_from_entry = 4.5 (> mean_sl=3.0 and > any reasonable ATR floor)
|
||
// bid[0] = entry_px - 4.5 → mid = entry_px - 4.25
|
||
let mid_fire = entry_px - 4.25;
|
||
let (bp4, bs4, ap4, az4) = level_book(mid_fire, 0.25);
|
||
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
let (side_at_45, size_at_45) = sim.read_market_target(0)?;
|
||
assert_eq!(side_at_45, 3, "Δ=4.5 > mean_sl=3.0 must fire force-flat; got side={side_at_45}");
|
||
assert_eq!(size_at_45, 0);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn bytecode_and_default_kernel_agree_on_stops() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
|
||
// Helper: drive one scenario using either the default kernel (use_program=false)
|
||
// or the bytecode VM kernel (use_program=true). Returns the market_target after
|
||
// a stop condition is triggered.
|
||
fn drive_scenario(use_program: bool, dev: &MlDevice) -> Result<(i32, i32)> {
|
||
let mut sim = LobSimCuda::new(1, dev)?;
|
||
|
||
if use_program {
|
||
// Upload an Ensemble(MaxConfidence) program so program_lens[0] != 0
|
||
// and the program kernel handles this backtest.
|
||
let prog = Strategy::Ensemble {
|
||
children: (0..N_HORIZONS as u8)
|
||
.map(|h| Strategy::Leaf(StrategyConfig {
|
||
horizon_idx: h,
|
||
sizing_policy: SizingPolicyId::IsvKelly,
|
||
max_concurrent_lots: 5,
|
||
}))
|
||
.collect(),
|
||
aggregator: EnsembleAggregator::MaxConfidence,
|
||
};
|
||
sim.upload_program(0, &prog.flatten())?;
|
||
}
|
||
|
||
// Cold-start ISV: pnl_ema_win=0.0, n_trades_seen=0, realised_return_var=0.0
|
||
// forces kelly_frac_floor + max_lots path so position opens.
|
||
// pnl_ema_loss=10.0 keeps SL far enough that it won't fire during open setup.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 10.0,
|
||
win_rate_ema: 0.0,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open long.
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(0, &cfg_default(1))?;
|
||
let mut ts: u64 = 1_000_000;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos_open = sim.read_pos(0)?;
|
||
assert!(
|
||
pos_open.position_lots > 0,
|
||
"setup ({} kernel): long must open, got {}",
|
||
if use_program { "program" } else { "default" },
|
||
pos_open.position_lots
|
||
);
|
||
|
||
// Set SL trigger setup: pnl_ema_loss=2.0 → sl_distance=2.0 (assuming ATR < 2.0).
|
||
// pnl_ema_win=10.0 keeps trail_distance=10.0 so trail won't fire before SL.
|
||
let triggered: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 10.0,
|
||
pnl_ema_loss: 2.0,
|
||
win_rate_ema: 0.5,
|
||
n_trades_seen: 20,
|
||
realised_return_var: 0.5,
|
||
recent_sharpe: 0.5,
|
||
});
|
||
sim.write_isv_kelly(0, &triggered)?;
|
||
|
||
// Drive mid down well past sl_distance=2.0 relative to vwap_entry.
|
||
// trigger_mid = entry - 4.0 → unrealized = bid[0] - entry = (mid - tick) - entry
|
||
// = (entry - 4.0 - 0.25) - entry = -4.25 <= -2.0 → SL fires.
|
||
let trigger_mid = pos_open.vwap_entry - 4.0;
|
||
let (bp3, bs3, ap3, az3) = level_book(trigger_mid, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
sim.read_market_target(0)
|
||
}
|
||
|
||
let mt_default = drive_scenario(false, &dev)?;
|
||
let mt_program = drive_scenario(true, &dev)?;
|
||
|
||
assert_eq!(
|
||
mt_default, mt_program,
|
||
"default kernel and program kernel disagree on stop output: default={mt_default:?} program={mt_program:?}"
|
||
);
|
||
assert_eq!(
|
||
mt_default.0, 3,
|
||
"both kernels must fire stop force-flat (side=3); got side={}",
|
||
mt_default.0
|
||
);
|
||
assert_eq!(mt_default.1, 0, "force-flat encoding has abs_sz=0; got size={}", mt_default.1);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn position_target_not_additive() -> Result<()> {
|
||
// latency=0: target (0, n) repeated must NOT accumulate the position
|
||
// past n. Cap is max_lots=5.
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
|
||
let cfg = cfg_default(1);
|
||
let mut ts: u64 = 0;
|
||
for _ in 0..10 {
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg)?;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos = sim.read_pos(0)?;
|
||
assert!(pos.position_lots.abs() <= 5,
|
||
"|position_lots| must respect max_lots=5 with target semantics; got {}",
|
||
pos.position_lots);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn position_target_not_additive_with_latency() -> Result<()> {
|
||
// 200ms latency: in-flight orders must count toward effective_position
|
||
// so re-submission of the same target produces delta=0 and no new order.
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
|
||
let cfg_lat = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 5,
|
||
latency_ns: 200_000_000,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 1.0,
|
||
max_hold_ns: 0,
|
||
min_reasonable_px: 0.0,
|
||
max_reasonable_px: f32::INFINITY,
|
||
delta_floor: 0.0,
|
||
});
|
||
|
||
let mut ts: u64 = 0;
|
||
// 500 events of persistent target — most should produce delta=0.
|
||
for _ in 0..500 {
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_lat)?;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos = sim.read_pos(0)?;
|
||
assert!(pos.position_lots.abs() <= 5,
|
||
"|position_lots| must stay <= max_lots=5 under 200ms latency with in-flight summation; got {}",
|
||
pos.position_lots);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn trail_hwm_reset_on_close() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0, pnl_ema_loss: 10.0,
|
||
win_rate_ema: 0.0, n_trades_seen: 0,
|
||
realised_return_var: 0.0, recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open long.
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; }
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens");
|
||
|
||
// Switch ISV to trail-test state (pnl_ema_win=1.5 → trail_distance=1.5).
|
||
let trail_state: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 1.5, pnl_ema_loss: 100.0, // big SL → only trail can fire
|
||
win_rate_ema: 0.5, n_trades_seen: 20,
|
||
realised_return_var: 0.5, recent_sharpe: 0.5,
|
||
});
|
||
sim.write_isv_kelly(0, &trail_state)?;
|
||
|
||
// Ratchet HWM up.
|
||
for &m in &[5501.0_f32, 5502.0, 5503.0, 5504.0, 5505.0] {
|
||
let (b, bs, a, asz) = level_book(m, 0.25);
|
||
sim.apply_snapshot(&b, &bs, &a, &asz)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let hwm_armed = sim.read_trail_hwm(0)?;
|
||
assert!(hwm_armed > 1.5, "HWM should ratchet past trail_distance=1.5, got {hwm_armed}");
|
||
|
||
// Fire trail — drop mid past trail_distance from peak.
|
||
let (b, bs, a, asz) = level_book(5502.0, 0.25);
|
||
sim.apply_snapshot(&b, &bs, &a, &asz)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
let (s_fire, _) = sim.read_market_target(0)?;
|
||
assert_eq!(s_fire, 3, "trail must fire force-flat");
|
||
|
||
// Drain so position flattens AND pnl_track runs.
|
||
for _ in 0..10 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos_closed = sim.read_pos(0)?;
|
||
assert_eq!(pos_closed.position_lots, 0,
|
||
"position must flatten after trail fire; got {}", pos_closed.position_lots);
|
||
let hwm_after_close = sim.read_trail_hwm(0)?;
|
||
assert_eq!(hwm_after_close, 0.0,
|
||
"pnl_track_step must reset trail_hwm to 0 on close; got {hwm_after_close}");
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that max_hold_ns fires a force-flat when the elapsed hold
|
||
/// time exceeds the configured limit, and does NOT fire before that limit.
|
||
///
|
||
/// S2.2: max_hold enforcement moved to resting_orders_step (event-rate).
|
||
/// The test now exercises the event-rate path via step_resting_orders
|
||
/// rather than checking the market_target written by stop_check_isv.
|
||
///
|
||
/// Test geometry:
|
||
/// - max_hold_ns = 100ms = 100_000_000ns
|
||
/// - Open long at t=1ms via strong alpha
|
||
/// - At entry_ts + 50ms: step_resting_orders at 50ms must NOT close (elapsed < cap)
|
||
/// - At entry_ts + 150ms: step_resting_orders at 150ms must close (elapsed >= cap)
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn max_hold_forces_close() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Cold-start ISV: large pnl_ema_loss so SL never fires during the test.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 100.0,
|
||
win_rate_ema: 0.0,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Config with max_hold_ns = 100ms.
|
||
let cfg_max_hold = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 5,
|
||
latency_ns: 0,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 1.0,
|
||
max_hold_ns: 100_000_000, // 100ms
|
||
min_reasonable_px: 0.0,
|
||
max_reasonable_px: f32::INFINITY,
|
||
delta_floor: 0.0,
|
||
});
|
||
|
||
// Open long at t=1ms via strong alpha.
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; sim.step_pnl_track(ts)?; ts += 1_000_000; }
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens");
|
||
let entry_ts = ts;
|
||
|
||
// At entry_ts + 50ms — still under 100ms hold. step_resting_orders must NOT close.
|
||
ts = entry_ts + 50_000_000;
|
||
let (bp3, bs3, ap3, az3) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
let pos_pre = sim.read_pos(0)?;
|
||
assert_ne!(pos_pre.position_lots, 0,
|
||
"elapsed 50ms < max_hold=100ms: resting_orders must NOT close; position_lots={}",
|
||
pos_pre.position_lots);
|
||
|
||
// At entry_ts + 150ms — past max_hold. step_resting_orders must force-close.
|
||
ts = entry_ts + 150_000_000;
|
||
let (bp4, bs4, ap4, az4) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
let pos_post = sim.read_pos(0)?;
|
||
assert_eq!(pos_post.position_lots, 0,
|
||
"elapsed 150ms > max_hold=100ms: resting_orders must force-close; position_lots={}",
|
||
pos_post.position_lots);
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that NaN or Inf prices in an MBP-10 snapshot do not propagate
|
||
/// into realized_pnl via the book walk → fill pipeline.
|
||
///
|
||
/// Real MBP-10 data at session boundaries or gap-fills can contain NaN or Inf
|
||
/// in level prices/sizes. apply_snapshot_kernel must sanitize them so the walk
|
||
/// helpers never accumulate NaN/Inf cost, which would corrupt avg_px and
|
||
/// subsequently realized_pnl, emitting the ±21,474,836 sentinel.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn book_nan_inf_prices_dont_corrupt_realized_pnl() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Build a book with NaN at one level and Inf at another (real MBP-10
|
||
// data at session boundaries can contain these).
|
||
let mut bid_px = [0.0_f32; 10];
|
||
let mut bid_sz = [0.0_f32; 10];
|
||
let mut ask_px = [0.0_f32; 10];
|
||
let mut ask_sz = [0.0_f32; 10];
|
||
for k in 0..10 {
|
||
bid_px[k] = 5500.0 - 0.25 * (k as f32 + 1.0);
|
||
ask_px[k] = 5500.0 + 0.25 * (k as f32 + 1.0);
|
||
bid_sz[k] = 20.0;
|
||
ask_sz[k] = 20.0;
|
||
}
|
||
// Inject corruption at level 3 (NaN price) and level 5 (Inf size).
|
||
ask_px[3] = f32::NAN;
|
||
bid_sz[5] = f32::INFINITY;
|
||
|
||
// First snapshot bootstraps ATR + book.
|
||
sim.apply_snapshot(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
|
||
// Second snapshot: same book; ATR converges; sanitization tested again.
|
||
sim.apply_snapshot(&bid_px, &bid_sz, &ask_px, &ask_sz)?;
|
||
|
||
// Open long via strong alpha. The fill must NOT propagate NaN/Inf into
|
||
// pos.realized_pnl, even though the book has NaN at ask[3] and Inf at bid[5].
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
for _ in 0..5 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
|
||
let pos = sim.read_pos(0)?;
|
||
assert!(
|
||
pos.realized_pnl.is_finite(),
|
||
"realized_pnl must stay finite even with NaN/Inf in book; got {}",
|
||
pos.realized_pnl
|
||
);
|
||
// Position may or may not have opened (depending on which levels were used).
|
||
// The invariant is finiteness, not a specific position state.
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that pnl_track resets scratch on close so that:
|
||
/// (a) flat events after a close do NOT emit duplicate close records, and
|
||
/// (b) the next open transition is captured correctly (exactly 1 record per trade).
|
||
///
|
||
/// Without the scratch reset: prev_size stays = prior entry_size, so every flat
|
||
/// event fires the close branch again (duplicates). The next open (0→1) sees
|
||
/// prev=1, now=1 → neither branch fires → open record never captured → second
|
||
/// close uses stale realized_at_open from trade 1.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn pnl_track_resets_scratch_on_close() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0, pnl_ema_loss: 100.0, // huge SL so it doesn't fire
|
||
win_rate_ema: 0.0, n_trades_seen: 0,
|
||
realised_return_var: 0.0, recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Config with max_hold_ns = 100ms to force-close without needing SL/trail.
|
||
let cfg_max_hold = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 5,
|
||
latency_ns: 0,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 1.0,
|
||
max_hold_ns: 100_000_000, // 100ms
|
||
min_reasonable_px: 0.0,
|
||
max_reasonable_px: f32::INFINITY,
|
||
delta_floor: 0.0,
|
||
});
|
||
|
||
// Trade 1: open long, force-flatten via max_hold.
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "trade 1: long opens");
|
||
|
||
// Force close trade 1 via max_hold: jump ts by 200ms.
|
||
ts += 200_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
for _ in 0..5 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
assert_eq!(sim.read_pos(0)?.position_lots, 0, "trade 1: closed via max_hold");
|
||
let trades_after_t1 = sim.read_total_trade_count()?;
|
||
assert_eq!(trades_after_t1, 1,
|
||
"trade 1: exactly ONE record emitted (no duplicates); got {trades_after_t1}");
|
||
|
||
// Several flat events — pnl_track must NOT emit duplicate close records.
|
||
for _ in 0..5 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let trades_after_flat = sim.read_total_trade_count()?;
|
||
assert_eq!(trades_after_flat, 1,
|
||
"flat events: no duplicate close records; got {trades_after_flat}");
|
||
|
||
// Trade 2: re-open + close via max_hold.
|
||
ts += 10_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "trade 2: long opens");
|
||
|
||
// Force close trade 2.
|
||
ts += 200_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_max_hold)?;
|
||
for _ in 0..5 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
assert_eq!(sim.read_pos(0)?.position_lots, 0, "trade 2: closed via max_hold");
|
||
let trades_after_t2 = sim.read_total_trade_count()?;
|
||
assert_eq!(trades_after_t2, 2,
|
||
"trade 2: exactly TWO records total (1 per trade, no missed open); got {trades_after_t2}");
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that the trade-vol floor prevents sub-cost stop fires.
|
||
///
|
||
/// Per pearl_trade_level_vol_for_stop_distance.md: at cold-start (var=0),
|
||
/// trade_vol = sqrt(max(0, cost²)) = cost = 0.125. With ema_loss=0 and
|
||
/// ATR decayed to ≈ 0.003 before entry, sl_distance = max(0, atr, 0.125) = 0.125.
|
||
///
|
||
/// Test geometry (long position, tick=0.25, cost=0.125):
|
||
/// entry fills at ask[0] = mid_fill + tick
|
||
/// unrealized = bid[0] - entry = (mid_new - tick) - entry
|
||
///
|
||
/// Boundary straddling trade_vol = 0.125:
|
||
/// no-fire (u = -0.08): mid_new = entry + tick - 0.08 = entry + 0.17
|
||
/// |u| = 0.08 < trade_vol = 0.125 → NOT fire ✓
|
||
/// fire (u = -0.20): mid_new = entry + tick - 0.20 = entry + 0.05
|
||
/// |u| = 0.20 > trade_vol = 0.125 → fires ✓
|
||
///
|
||
/// ATR convergence: 25 identical snapshots (delta=0) decay ATR to ≈ 0.003
|
||
/// before opening. Both discriminating snaps produce ATR well below 0.125,
|
||
/// so trade_vol is always the binding constraint in this cold-start scenario.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn trade_vol_floor_prevents_sub_cost_stops() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Zero ema_loss: only ATR and cost floor govern sl_distance.
|
||
// Large pnl_ema_win to keep trail_distance enormous, preventing trail fires.
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 100.0, pnl_ema_loss: 0.0,
|
||
win_rate_ema: 0.0, n_trades_seen: 0,
|
||
realised_return_var: 0.0, recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
// Bootstrap ATR: two different mids to seed first-observation atr_mid_ema.
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Converge ATR to ≈ 0 via 25 identical snapshots at 5500.1 (delta=0).
|
||
// After 25 steps: atr ≈ 0.6^25 * 0.1 ≈ 0.003 — tiny baseline.
|
||
for _ in 0..25 {
|
||
let (bp, bs, ap, az) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
}
|
||
|
||
// Open long with cost=0.125 (trade_vol floor at cold-start = cost = 0.125).
|
||
let cfg_cost = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 5,
|
||
latency_ns: 0,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 0.125,
|
||
max_hold_ns: 0,
|
||
min_reasonable_px: 0.0,
|
||
max_reasonable_px: f32::INFINITY,
|
||
delta_floor: 0.0,
|
||
});
|
||
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(0, &cfg_cost)?;
|
||
let mut ts: u64 = 1_000_000;
|
||
for _ in 0..3 { sim.step_resting_orders(ts, 0.0)?; ts += 1_000_000; }
|
||
let pos_open = sim.read_pos(0)?;
|
||
assert!(pos_open.position_lots > 0, "setup: long opens (got {})", pos_open.position_lots);
|
||
let entry = pos_open.vwap_entry;
|
||
// entry ≈ 5500.1 + tick(0.25) = 5500.35
|
||
|
||
// No-fire leg: unrealized = -0.08 (< trade_vol = cost = 0.125).
|
||
// trade_vol = sqrt(max(var=0, cost²=0.015625)) = 0.125
|
||
// sl_distance = max(0, ATR≈0.003, 0.125) = 0.125; 0.08 < 0.125 → NOT fire ✓
|
||
// bid[0] = entry - 0.08 → mid = entry - 0.08 + tick(0.25) = entry + 0.17
|
||
let mid_no_fire = entry + 0.17;
|
||
let (bp3, bs3, ap3, az3) = level_book(mid_no_fire, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
// ATR update: delta ≈ |(entry+0.17) - 5500.1| — pumped slightly but still << 0.125 ✓
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_cost)?;
|
||
let (side_no_fire, _) = sim.read_market_target(0)?;
|
||
assert_ne!(side_no_fire, 3,
|
||
"unrealized=-0.08 < trade_vol=0.125: SL must NOT fire; got side={side_no_fire}");
|
||
ts += 1_000_000;
|
||
|
||
// Fire leg: unrealized = -0.20 (> trade_vol = 0.125 → fires).
|
||
// bid[0] = entry - 0.20 → mid = entry - 0.20 + tick(0.25) = entry + 0.05
|
||
let mid_fire = entry + 0.05;
|
||
let (bp4, bs4, ap4, az4) = level_book(mid_fire, 0.25);
|
||
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_cost)?;
|
||
let (side_fire, size_fire) = sim.read_market_target(0)?;
|
||
assert_eq!(side_fire, 3,
|
||
"unrealized=-0.20 > trade_vol=0.125: SL must fire force-flat; got side={side_fire}");
|
||
assert_eq!(size_fire, 0);
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that a timestamp gap > 1 hour (session boundary / weekend halt)
|
||
/// force-closes any open position before the next event is processed.
|
||
///
|
||
/// max_hold_ns can't fire during a halt because no events advance current_ts.
|
||
/// The session-gap check in resting_orders_step detects the jump and zeroes
|
||
/// position_lots directly; pnl_track_step's existing close branch then emits
|
||
/// exactly one TradeRecord.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn session_gap_force_closes_open_positions() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
let cold_start: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0, pnl_ema_loss: 100.0, // huge SL — won't fire
|
||
win_rate_ema: 0.0, n_trades_seen: 0,
|
||
realised_return_var: 0.0, recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open long at t=1ms.
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.broadcast_alpha(&[0.95, 0.95, 0.95, 0.95, 0.95])?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
assert!(sim.read_pos(0)?.position_lots > 0, "setup: long opens");
|
||
let trades_before_gap = sim.read_total_trade_count()?;
|
||
|
||
// Advance ts by 2 hours (> 1 hour SESSION_GAP_NS). Call step_resting_orders
|
||
// with the new ts — session-gap force-close fires, then pnl_track detects
|
||
// the prev!=0 && now==0 close transition and emits a TradeRecord.
|
||
ts += 2 * 3_600_000_000_000u64;
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
|
||
assert_eq!(
|
||
sim.read_pos(0)?.position_lots, 0,
|
||
"session gap > 1h must force-close the position"
|
||
);
|
||
let trades_after_gap = sim.read_total_trade_count()?;
|
||
assert_eq!(
|
||
trades_after_gap, trades_before_gap + 1,
|
||
"session-gap close emits exactly one TradeRecord; got {} -> {}",
|
||
trades_before_gap, trades_after_gap
|
||
);
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that a corrupt top-of-book snapshot is skipped entirely and the
|
||
/// per-backtest `snapshots_skipped` counter is incremented.
|
||
///
|
||
/// Bug C-b (smoke v74v4 2026-05-20): per-level sanitization (Task 15) zeros each
|
||
/// unhealthy level individually. When ALL 10 levels are invalid (session-boundary
|
||
/// event), the book becomes uniformly zero. apply_fill_to_pos then reads
|
||
/// bid_px[0]=0/ask_px[0]=0 → vwap_entry=0 → trade record entry_px=0 (zero sentinel).
|
||
///
|
||
/// Fix: pre-validate top-of-book in apply_snapshot_kernel. If bid_px[0]/ask_px[0]
|
||
/// are non-finite or zero/negative, skip the entire snapshot — book, prev_mid,
|
||
/// and atr_mid_ema remain unchanged.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn corrupt_top_of_book_skips_snapshot_and_increments_counter() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Apply a valid book first to seed prev_mid and atr_mid_ema.
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
// Apply a second valid snapshot to advance ATR from 0.
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.5, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
let atr_before = sim.read_atr_mid_ema(0)?;
|
||
let prev_mid_before = sim.read_prev_mid(0)?;
|
||
// Sanity: atr_before must be nonzero after two valid snapshots with Δ=0.5.
|
||
assert!(atr_before > 0.0, "ATR should be nonzero after two valid snapshots, got {atr_before}");
|
||
|
||
// Case 1: top-of-book bid_px[0] = NaN. Whole snapshot must be skipped.
|
||
let mut bp_bad = bp2;
|
||
bp_bad[0] = f32::NAN;
|
||
sim.apply_snapshot(&bp_bad, &bs2, &ap2, &az2)?;
|
||
|
||
let skipped = sim.read_snapshots_skipped()?;
|
||
assert_eq!(skipped[0], 1,
|
||
"corrupt top-of-book (NaN bid_px[0]) should increment skip counter to 1; got {}", skipped[0]);
|
||
let atr_after_nan = sim.read_atr_mid_ema(0)?;
|
||
let prev_mid_after_nan = sim.read_prev_mid(0)?;
|
||
assert_eq!(atr_after_nan, atr_before,
|
||
"ATR must not change on corrupt snapshot; before={atr_before} after={atr_after_nan}");
|
||
assert_eq!(prev_mid_after_nan, prev_mid_before,
|
||
"prev_mid must not change on corrupt snapshot; before={prev_mid_before} after={prev_mid_after_nan}");
|
||
|
||
// Case 2: top-of-book bid_sz[0] = 0. Must also skip.
|
||
let mut bs_bad = bs2;
|
||
bs_bad[0] = 0.0;
|
||
sim.apply_snapshot(&bp2, &bs_bad, &ap2, &az2)?;
|
||
|
||
let skipped_2 = sim.read_snapshots_skipped()?;
|
||
assert_eq!(skipped_2[0], 2,
|
||
"zero top-of-book bid_sz[0] should increment skip counter to 2; got {}", skipped_2[0]);
|
||
let atr_after_zero_sz = sim.read_atr_mid_ema(0)?;
|
||
assert_eq!(atr_after_zero_sz, atr_before,
|
||
"ATR must not change on zero-size corrupt snapshot; got {atr_after_zero_sz}");
|
||
|
||
// Case 3: valid snapshot after two corrupt skips must update ATR normally.
|
||
let (bp3, bs3, ap3, az3) = level_book(5500.5, 0.25);
|
||
sim.apply_snapshot(&bp3, &bs3, &ap3, &az3)?;
|
||
let skipped_3 = sim.read_snapshots_skipped()?;
|
||
assert_eq!(skipped_3[0], 2,
|
||
"valid snapshot must not increment skip counter; got {}", skipped_3[0]);
|
||
let atr_after_valid = sim.read_atr_mid_ema(0)?;
|
||
// mid didn't move (same 5500.5 as prev), so delta=0, atr stays at atr_before.
|
||
// The assertion here is that it is still finite and the state is reachable.
|
||
assert!(atr_after_valid.is_finite(),
|
||
"ATR must remain finite after valid snapshot following skips; got {atr_after_valid}");
|
||
|
||
// Case 4: valid snapshot with a different mid forces an ATR update, proving
|
||
// the pipeline is unblocked after the skips.
|
||
let (bp4, bs4, ap4, az4) = level_book(5501.5, 0.25);
|
||
sim.apply_snapshot(&bp4, &bs4, &ap4, &az4)?;
|
||
let atr_after_move = sim.read_atr_mid_ema(0)?;
|
||
assert_ne!(atr_after_move, atr_before,
|
||
"valid snapshot with Δmid=1.0 must update ATR; before={atr_before} after={atr_after_move}");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that all 6 NaN instrumentation counters (S1.20) are zero after
|
||
/// LobSimCuda::new() with no kernel launches. This confirms the device buffers
|
||
/// are correctly zero-initialized and the accessor compiles + works.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn nan_counters_initialize_to_zero() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let sim = LobSimCuda::new(1, &dev)?;
|
||
let counters = sim.read_nan_counters()?;
|
||
assert_eq!(counters.nan_avg_px[0], 0, "nan_avg_px must be zero at init");
|
||
assert_eq!(counters.nan_realised[0], 0, "nan_realised must be zero at init");
|
||
assert_eq!(counters.nan_realized_pnl[0], 0, "nan_realized_pnl must be zero at init");
|
||
assert_eq!(counters.zero_vwap_at_open[0], 0, "zero_vwap_at_open must be zero at init");
|
||
assert_eq!(counters.saturated_vwap_at_open[0], 0, "saturated_vwap_at_open must be zero at init");
|
||
assert_eq!(counters.defensive_exit_clamp[0], 0, "defensive_exit_clamp must be zero at init");
|
||
Ok(())
|
||
}
|
||
|
||
/// Validates that the parameterized price-range filter rejects real-data outliers.
|
||
///
|
||
/// Audit (fxt-data-audit on ES.FUT_2024-Q1) found:
|
||
/// bid_min = -$4.85 (negative bid)
|
||
/// bid_p1 = $64.15 (sub-$100, far below ES range)
|
||
/// ask_max = $53,012 (10x above any plausible ES price)
|
||
///
|
||
/// The old hardcoded `< 1e8` threshold ($100M) never triggered on any of these.
|
||
/// This test pins the corrected behavior: upload min=1000/max=20000, then verify
|
||
/// that sub-range, super-range, and negative-bid snapshots all increment
|
||
/// snapshots_skipped and leave prev_mid unchanged; valid ES snapshots pass through.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn price_range_rejection_skips_snapshot() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
|
||
// Configure a tight ES-like range.
|
||
sim.upload_price_range(&[1000.0], &[20000.0])?;
|
||
|
||
// Valid snapshot at ES range — should pass and be applied.
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let skipped_after_valid = sim.read_snapshots_skipped()?;
|
||
assert_eq!(skipped_after_valid[0], 0, "valid snapshot must not skip");
|
||
let prev_mid_after_valid = sim.read_prev_mid(0)?;
|
||
assert!(
|
||
(prev_mid_after_valid - 5500.0).abs() < 1e-4,
|
||
"valid snapshot must set prev_mid to mid; got {prev_mid_after_valid}"
|
||
);
|
||
|
||
// Sub-range top-of-book (e.g. $50) — should be skipped.
|
||
let (bp_low, bs_low, ap_low, az_low) = level_book(50.0, 0.25);
|
||
sim.apply_snapshot(&bp_low, &bs_low, &ap_low, &az_low)?;
|
||
let skipped_after_low = sim.read_snapshots_skipped()?;
|
||
assert_eq!(skipped_after_low[0], 1, "sub-range snapshot must skip");
|
||
let prev_mid_after_low = sim.read_prev_mid(0)?;
|
||
assert!(
|
||
(prev_mid_after_low - prev_mid_after_valid).abs() < 1e-4,
|
||
"skipped snapshot must not mutate prev_mid; got {prev_mid_after_low}"
|
||
);
|
||
|
||
// Super-range top-of-book ($50,000) — should be skipped.
|
||
let (bp_high, bs_high, ap_high, az_high) = level_book(50000.0, 0.25);
|
||
sim.apply_snapshot(&bp_high, &bs_high, &ap_high, &az_high)?;
|
||
let skipped_after_high = sim.read_snapshots_skipped()?;
|
||
assert_eq!(skipped_after_high[0], 2, "super-range snapshot must skip");
|
||
|
||
// Negative bid (bid[0] = -5.0) — should be skipped.
|
||
let (mut bp_neg, bs_neg, ap_neg, az_neg) = level_book(5500.0, 0.25);
|
||
bp_neg[0] = -5.0;
|
||
sim.apply_snapshot(&bp_neg, &bs_neg, &ap_neg, &az_neg)?;
|
||
let skipped_after_neg = sim.read_snapshots_skipped()?;
|
||
assert_eq!(skipped_after_neg[0], 3, "negative bid must skip");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// S1.21: All per-vwap-write-site v2 counters initialize to zero at
|
||
/// LobSimCuda construction; last_bad_path initializes to zero (no bad
|
||
/// write observed yet).
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn nan_counters_v2_initialize_to_zero() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let sim = LobSimCuda::new(1, &dev)?;
|
||
let c = sim.read_nan_counters_v2()?;
|
||
assert_eq!(c.vwap_zero_open_flat[0], 0, "vwap_zero_open_flat must initialize to 0");
|
||
assert_eq!(c.vwap_huge_open_flat[0], 0, "vwap_huge_open_flat must initialize to 0");
|
||
assert_eq!(c.vwap_zero_scale_in[0], 0, "vwap_zero_scale_in must initialize to 0");
|
||
assert_eq!(c.vwap_huge_scale_in[0], 0, "vwap_huge_scale_in must initialize to 0");
|
||
assert_eq!(c.vwap_zero_flip[0], 0, "vwap_zero_flip must initialize to 0");
|
||
assert_eq!(c.vwap_huge_flip[0], 0, "vwap_huge_flip must initialize to 0");
|
||
assert_eq!(c.vwap_session_gap_was_bad[0], 0, "vwap_session_gap_was_bad must initialize to 0");
|
||
assert_eq!(c.last_bad_path[0], 0, "last_bad_path must initialize to 0 (no bad write)");
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn max_hold_counters_initialize_to_zero() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let sim = LobSimCuda::new(1, &dev)?;
|
||
let c = sim.read_max_hold_counters()?;
|
||
// S2.2: 6 decision-rate max_hold counters removed; 2 generic ones remain.
|
||
assert_eq!(c.mh_kernel_calls[0], 0, "mh_kernel_calls must initialize to 0");
|
||
assert_eq!(c.mh_force_flat_seen_by_seed[0], 0, "mh_force_flat_seen_by_seed must initialize to 0");
|
||
Ok(())
|
||
}
|
||
|
||
/// CRT.1 C1.2: multi-horizon ISV-weighted conviction (spec §4.4).
|
||
///
|
||
/// The v2 A2 scalar approach used `max(|p−0.5|)` across horizons, which
|
||
/// smooths magnitude only and cannot smooth direction. With horizons that
|
||
/// disagree on direction, the scalar approach picks one horizon's sign
|
||
/// and the EMA stabilizes on that — even though the multi-horizon vote is
|
||
/// near-zero. v2 smoke vjmwc + lkrdf falsified this on the cluster.
|
||
///
|
||
/// The v3 formula sums signed contributions `magnitude_h × weight_h ×
|
||
/// direction_h` across horizons. Disagreeing horizons cancel in the sum,
|
||
/// so |conviction_signed| collapses toward zero when the multi-horizon
|
||
/// vote is split. With four equally-weighted horizons (cold-start ISV),
|
||
/// two bullish (+) and two bearish (−) at equal magnitudes, conviction
|
||
/// MUST be zero and the kernel MUST write force-flat (side=3) or no-op
|
||
/// (side=2) targets — never an actual buy/sell.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn multi_horizon_conviction_cancels_on_disagreement() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
sim.upload_price_range(&[1000.0], &[20000.0])?;
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
|
||
// cost > 0 so eps_edge and weight_h are well-defined at cold start
|
||
// (eps_edge = cost · 0.01; weight_h ≈ 0.01 / cost when ISV is at zero).
|
||
// With all weights equal, disagreeing horizons cancel in the signed sum.
|
||
let cfg = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 5,
|
||
latency_ns: 0,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 1.0,
|
||
max_hold_ns: 0,
|
||
min_reasonable_px: 0.0,
|
||
max_reasonable_px: f32::INFINITY,
|
||
delta_floor: 0.0,
|
||
});
|
||
|
||
// Two horizons bullish (0.7), two bearish (0.3), one neutral (0.5).
|
||
// Magnitudes: [0.4, 0.4, 0.4, 0.4, 0.0]. Directions: [+1, -1, +1, -1, +1].
|
||
// At cold start (ISV all zero, cost=1.0), every weight_h is
|
||
// eps_edge / (0 + cost²) = 0.01 — equal. Weighted signed sum:
|
||
// 0.4*0.01*(+1) + 0.4*0.01*(-1) + 0.4*0.01*(+1) + 0.4*0.01*(-1) + 0
|
||
// = 0
|
||
// conviction_signed = 0 / total_abs_weight = 0
|
||
// → conv_ema = 0 → target_lots = 0 → side ∈ {2, 3}.
|
||
let mixed: [f32; N_HORIZONS] = [0.7, 0.3, 0.7, 0.3, 0.5];
|
||
sim.broadcast_alpha(&mixed)?;
|
||
sim.step_decision_with_latency(1_000_000_000u64, &cfg)?;
|
||
let (side, size) = sim.read_market_target(0)?;
|
||
// side=2 means no-op (target=0 or below threshold).
|
||
// side=3 means force-flat (target=0 with open position semantics).
|
||
// Either is acceptable: both encode "no new lots ordered."
|
||
assert!(
|
||
(side == 2 || side == 3) && size == 0,
|
||
"disagreeing horizons must cancel to no-trade; got side={side} size={size}"
|
||
);
|
||
|
||
// Sanity: the on-device signed EMA should also be (near) zero —
|
||
// disagreement cancels in BOTH magnitude (|EMA|→0) and signed value
|
||
// (signed→0) under the post-2026-05-22 anti-calibration fix, so the
|
||
// strict |ema| < ε check still holds (and is in fact more correct,
|
||
// since the test previously asserted on the magnitude-EMA buffer).
|
||
let ema = sim.read_conv_signed_ema(0)?;
|
||
assert!(ema.abs() < 1e-5, "conv_signed_ema must collapse to ~0 on cancellation; got {ema}");
|
||
Ok(())
|
||
}
|
||
|
||
/// Anti-calibration fix (2026-05-22): event-to-event sign flips on the
|
||
/// multi-horizon conviction signal must produce a SIGNED EMA whose
|
||
/// magnitude is structurally smaller than the per-event |conviction|,
|
||
/// AND `target_lots` derived from the signed EMA must be strictly
|
||
/// smaller (in magnitude) than the pre-fix size that the bug would have
|
||
/// produced.
|
||
///
|
||
/// Bug history: the prior magnitude-only EMA combined `sign(instantaneous)
|
||
/// × EMA(|instantaneous|)`. When per-horizon directions disagreed
|
||
/// event-to-event, the magnitude EMA stayed high (|x| EMA cannot cancel)
|
||
/// while the sign flipped on noise → big trades in random direction.
|
||
/// Empirically (smoke ckpts across architecture variants): higher reported
|
||
/// conviction → MORE negative PnL (-15.6 → -145.2). The fix replaces the
|
||
/// magnitude-only EMA with a SIGNED EMA so directions that disagree
|
||
/// event-to-event cancel in the EMA — sign AND magnitude both derive from
|
||
/// the same smoothed scalar.
|
||
///
|
||
/// Test setup: alternate alpha_probs across calls — all horizons bullish
|
||
/// (0.85) then all bearish (0.15) — producing a `conviction_signed` that
|
||
/// flips between ~+0.7 and ~−0.7 each event. With the Wiener-α floor of
|
||
/// 0.4, perfect ±M sign-flip alternation steady-state oscillates with
|
||
/// bounded amplitude `M · α / (2 - α)` ≈ 0.175 around zero (NOT exactly
|
||
/// to zero — the Wiener α can rise above 0.4 under diff-variance growth,
|
||
/// pushing amplitude somewhat higher). What matters structurally:
|
||
///
|
||
/// 1. |signed_ema_steady_state| ≪ |conviction_signed_instantaneous|
|
||
/// (the EMA cancels what the magnitude EMA cannot)
|
||
/// 2. |target_lots_post_fix| < |target_lots_pre_fix|
|
||
/// = |sign(instantaneous) × EMA(|x|) × max_lots|
|
||
/// = 1 × ~0.7 × 10 = ~7
|
||
///
|
||
/// Both invariants pin the new behavior and would fail under the prior
|
||
/// magnitude-only EMA.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn signed_conviction_ema_collapses_on_sign_flips() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
sim.upload_price_range(&[1000.0], &[20000.0])?;
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
|
||
// threshold = 0.0 so sizing isn't gated off; max_lots = 10 so the
|
||
// bug's predicted |target_lots| under steady magnitude EMA = 7 is
|
||
// well separated from the post-fix expected magnitude (< 4 at the
|
||
// oscillation crest under α floor 0.4).
|
||
let max_lots: u16 = 10;
|
||
let cfg = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots,
|
||
latency_ns: 0,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 1.0,
|
||
max_hold_ns: 0,
|
||
min_reasonable_px: 0.0,
|
||
max_reasonable_px: f32::INFINITY,
|
||
delta_floor: 0.0,
|
||
});
|
||
|
||
// All horizons strongly bullish: magnitude ≈ 2·|0.85−0.5| = 0.7,
|
||
// direction = +1 across the board → conviction_signed ≈ +0.7.
|
||
let bullish: [f32; N_HORIZONS] = [0.85; N_HORIZONS];
|
||
// All horizons strongly bearish: magnitude ≈ 0.7, direction = −1 →
|
||
// conviction_signed ≈ −0.7. Per-event sign flip with constant
|
||
// magnitude — the exact scenario the bug-trace investigation
|
||
// identified (magnitude EMA stays at 0.7, sign flips event-to-event).
|
||
let bearish: [f32; N_HORIZONS] = [0.15; N_HORIZONS];
|
||
|
||
// Pre-fix predicted |target_lots| in the TAIL (event >= 1) under the
|
||
// magnitude-only EMA:
|
||
// round(sign(instantaneous) × EMA(|x|) × max_lots)
|
||
// = round(±1 × ~0.7 × 10) = ±7 every tail event.
|
||
// Post-fix, the EMA tracks the SIGNED signal. The bootstrap event
|
||
// (E0) lands at ±7 in BOTH implementations (first-observation
|
||
// replace). What pins the fix is the TAIL behavior: after Wiener
|
||
// stabilization, |signed_ema| converges to its steady-state
|
||
// oscillation amplitude (~0.467 at α≈0.8, perfectly symmetric ±0.7
|
||
// alternation) so |target_lots| caps at ~5, strictly below the
|
||
// bug's tail value of 7. We assert |target| <= 5 across the tail —
|
||
// strict enough that the bug (constant 7) trips immediately.
|
||
const PRE_FIX_TAIL_ABS: i32 = 7;
|
||
const POST_FIX_TAIL_CAP: i32 = 5; // < 7 strictly; bug would always be 7
|
||
|
||
// Drive 16 alternating events. Track per-event signed EMA and
|
||
// |target_lots|. Bootstrap event (k=0) is exempt from the magnitude
|
||
// comparison (both implementations land at ±7 there). Tail = k >= 4
|
||
// to ensure Wiener α has stabilized past the bootstrap transient.
|
||
let mut events: Vec<(f32, i32)> = Vec::with_capacity(16);
|
||
for k in 0_u32..16 {
|
||
let probs = if k.is_multiple_of(2) { &bullish } else { &bearish };
|
||
sim.broadcast_alpha(probs)?;
|
||
sim.step_decision_with_latency(((k as u64) + 1) * 1_000_000_000u64, &cfg)?;
|
||
let ema = sim.read_conv_signed_ema(0)?;
|
||
let (_side, size) = sim.read_market_target(0)?;
|
||
events.push((ema, size));
|
||
}
|
||
|
||
// Print per-event diagnostics — helps localize regressions if a
|
||
// future implementation drift trips the cap.
|
||
for (k, (ema, size)) in events.iter().enumerate() {
|
||
eprintln!("event {k:2}: signed_ema = {ema:+.4} |target_lots| = {size}");
|
||
}
|
||
|
||
let tail: &[(f32, i32)] = &events[4..];
|
||
|
||
// Invariant 1: tail |target_lots| must be strictly smaller than the
|
||
// pre-fix value of 7. This is the load-bearing check — under the bug,
|
||
// every tail event would produce |target| = 7.
|
||
for (i, (ema, size)) in tail.iter().enumerate() {
|
||
assert!(
|
||
size.abs() <= POST_FIX_TAIL_CAP,
|
||
"tail event {i}: |target_lots| = {size} must be <= {POST_FIX_TAIL_CAP} \
|
||
(pre-fix bug produces {PRE_FIX_TAIL_ABS} every tail event); \
|
||
signed_ema = {ema}"
|
||
);
|
||
}
|
||
|
||
// Invariant 2: tail signed EMA magnitudes are structurally smaller
|
||
// than the per-event |conviction_signed| ≈ 0.7. Allowing a generous
|
||
// 0.6 ceiling — the theoretical steady-state amplitude is ~0.467 at
|
||
// α≈0.8 under perfect ±M alternation.
|
||
for (i, (ema, _)) in tail.iter().enumerate() {
|
||
assert!(
|
||
ema.abs() < 0.6,
|
||
"tail event {i}: |signed EMA| = {ema} must be < 0.6 \
|
||
(per-event |conviction_signed| ≈ 0.7); the EMA is not \
|
||
cancelling sign flips"
|
||
);
|
||
}
|
||
|
||
// Invariant 3: under perfect ±M alternation the signed EMA itself
|
||
// must FLIP SIGN across the tail samples (the EMA tracks the
|
||
// alternating mean). The pre-fix magnitude EMA was locked in [0, 1]
|
||
// — sign flipping is uniquely the signed-EMA's behavior.
|
||
let any_pos = tail.iter().any(|(e, _)| *e > 0.0);
|
||
let any_neg = tail.iter().any(|(e, _)| *e < 0.0);
|
||
assert!(
|
||
any_pos && any_neg,
|
||
"tail signed EMA must oscillate across signs under ±M alternation; \
|
||
tail emas = {:?}",
|
||
tail.iter().map(|(e, _)| *e).collect::<Vec<_>>()
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// CRT.1 C1.3: no-trade band — delta_floor=1.0 blocks spurious re-seeding.
|
||
///
|
||
/// When the same conviction alpha is submitted twice in a row, the second
|
||
/// decision produces target=N with effective=N (already in position), so
|
||
/// delta=0. The kernel must NOT seed a second order. This test also covers
|
||
/// the abs_delta_f < delta_floor path: the delta_floor=0.0 path is band-disabled
|
||
/// so a second call with the same target (delta=0 exactly) still passes through
|
||
/// to the existing `if (delta == 0) return;` guard — but the comparison here
|
||
/// validates the accessor and the field plumbing, not the strict floor gate.
|
||
///
|
||
/// The strict floor gate (abs_delta_f < delta_floor) fires for fractional
|
||
/// targets where delta rounds to 1 but float value is 0.9. In this scenario
|
||
/// (integer delta=0 path) the existing delta==0 guard already fires, so
|
||
/// inflight count is stable either way.
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn no_trade_band_blocks_micro_delta() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
sim.upload_price_range(&[1000.0], &[20000.0])?;
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
|
||
// Band enabled: delta_floor=1.0 (1 lot). With max_lots=1, the only
|
||
// possible deltas are -1, 0, +1. delta=1 at the floor boundary fires;
|
||
// delta=0 does not. We use max_lots=1 so the first strong-bullish
|
||
// decision seeds exactly 1 lot and any subsequent same-target call
|
||
// has delta=0 and must NOT re-seed.
|
||
let cfg_band = BatchedSimConfig::from_uniform(1, &UniformSimParams {
|
||
target_annual_vol_units: 50.0,
|
||
annualisation_factor: 825.0,
|
||
max_lots: 1,
|
||
latency_ns: 0,
|
||
kelly_frac_floor: 0.20,
|
||
sharpe_weight_floor: 0.10,
|
||
threshold: 0.0,
|
||
cost_per_lot_per_side: 1.0,
|
||
max_hold_ns: 0,
|
||
min_reasonable_px: 0.0,
|
||
max_reasonable_px: f32::INFINITY,
|
||
delta_floor: 1.0,
|
||
});
|
||
|
||
// First decision: strong bullish alpha. With max_lots=1 and delta_floor=1.0,
|
||
// |delta|=1 >= delta_floor=1.0 so the kernel seeds one in-flight lot.
|
||
let probs: [f32; N_HORIZONS] = [0.95; N_HORIZONS];
|
||
sim.broadcast_alpha(&probs)?;
|
||
sim.step_decision_with_latency(1_000_000_000u64, &cfg_band)?;
|
||
let inflight_after_first = sim.read_inflight_count(0)?;
|
||
assert!(inflight_after_first >= 1,
|
||
"first decision must seed at least one order; got {inflight_after_first}");
|
||
|
||
// Second decision: same alpha (same target), position unchanged (in-flight,
|
||
// not yet filled). effective = 0 + in-flight = 1. target = 1. delta = 0.
|
||
// The kernel must NOT seed again.
|
||
sim.broadcast_alpha(&probs)?;
|
||
sim.step_decision_with_latency(2_000_000_000u64, &cfg_band)?;
|
||
let inflight_after_second = sim.read_inflight_count(0)?;
|
||
assert_eq!(inflight_after_second, inflight_after_first,
|
||
"same target should not re-seed; inflight went {} → {}",
|
||
inflight_after_first, inflight_after_second);
|
||
Ok(())
|
||
}
|
||
|
||
#[test]
|
||
fn open_trade_state_64_byte_layout() {
|
||
// CRT.1 C1.1: spec §7 — open_trade_state expanded 24 → 64 bytes
|
||
// (single cache line). All readers and writers migrate atomically.
|
||
assert_eq!(ml_backtesting::lob::OPEN_TRADE_STATE_BYTES, 64,
|
||
"spec §7: open_trade_state must be 64 bytes for CRT.1 trajectory tracking");
|
||
}
|
||
|
||
/// CRT.1 C1.4: composite exit_signal — disagreement_term fires force-flat.
|
||
///
|
||
/// With the spec-default `disagreement_factor = 32.0`, the composite
|
||
/// disagreement_term reaches 1.0 after 32 consecutive events of short
|
||
/// (h=0) vs long (h=N-1) horizon directional sign disagreement. Once
|
||
/// `exit_signal >= 1.0`, the composite check writes the (3, 0) force-flat
|
||
/// encoding. This is the only one of the three composite terms reachable
|
||
/// with bounded conv_ema ∈ [0, 1] under spec-default factors — both
|
||
/// degradation_term and pnl_decay_term cap at 0.5 / 1.0 by construction,
|
||
/// so the disagreement counter is the canonical edge-case trigger.
|
||
///
|
||
/// Test geometry:
|
||
/// 1. Cold-start ISV + strong-bullish alpha → open a long position. The
|
||
/// pnl_track open-branch snapshots conviction_at_entry into
|
||
/// open_trade_state[20], seeds open_trade_state[24] = entry.
|
||
/// 2. Switch to a SPLIT alpha (h0 bullish, h4 bearish, middle bullish so
|
||
/// a position-holding majority keeps conviction_signed positive and
|
||
/// SL doesn't widen). Every event with h0/h4 sign disagreement
|
||
/// increments the disagreement counter at open_trade_state[56].
|
||
/// 3. After 32 such events, composite_exit_check sees
|
||
/// disagreement_term = 32/32 = 1.0 → writes force-flat (side=3).
|
||
///
|
||
/// The SL/trail check runs first; it must not fire over the test horizon,
|
||
/// so pnl_ema_loss is set large (1000.0) and the book stays at the entry
|
||
/// price level (no SL pressure).
|
||
#[test]
|
||
#[ignore = "requires CUDA"]
|
||
fn composite_exit_signal_fires_on_disagreement() -> Result<()> {
|
||
let dev = match MlDevice::cuda(0) {
|
||
Ok(d) => d,
|
||
Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); }
|
||
};
|
||
let mut sim = LobSimCuda::new(1, &dev)?;
|
||
sim.upload_price_range(&[1000.0], &[20000.0])?;
|
||
|
||
// Cold-start ISV with large pnl_ema_loss so SL/trail can't fire during
|
||
// the 32-event disagreement window.
|
||
let cold_start: [IsvKellyStateHost; N_HORIZONS] = std::array::from_fn(|_| IsvKellyStateHost {
|
||
pnl_ema_win: 0.0,
|
||
pnl_ema_loss: 1000.0,
|
||
win_rate_ema: 0.0,
|
||
n_trades_seen: 0,
|
||
realised_return_var: 0.0,
|
||
recent_sharpe: 0.0,
|
||
});
|
||
sim.write_isv_kelly(0, &cold_start)?;
|
||
|
||
let (bp, bs, ap, az) = level_book(5500.0, 0.25);
|
||
sim.apply_snapshot(&bp, &bs, &ap, &az)?;
|
||
let (bp2, bs2, ap2, az2) = level_book(5500.1, 0.25);
|
||
sim.apply_snapshot(&bp2, &bs2, &ap2, &az2)?;
|
||
|
||
// Open long with strong unanimous bullish alpha.
|
||
let bullish: [f32; N_HORIZONS] = [0.95; N_HORIZONS];
|
||
sim.broadcast_alpha(&bullish)?;
|
||
let mut ts: u64 = 1_000_000;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
for _ in 0..3 {
|
||
sim.step_resting_orders(ts, 0.0)?;
|
||
sim.step_pnl_track(ts)?;
|
||
ts += 1_000_000;
|
||
}
|
||
let pos_open = sim.read_pos(0)?;
|
||
assert!(pos_open.position_lots > 0,
|
||
"setup: long position must open before disagreement test; got {}",
|
||
pos_open.position_lots);
|
||
|
||
// Switch to split alpha: h=0 bullish (0.6), h=N-1 bearish (0.4), middle
|
||
// horizons bullish so the signed conviction stays positive and the
|
||
// threshold gate doesn't shut down sizing each event. The trajectory
|
||
// update bumps disagreement_consecutive_events because the h=0 vs
|
||
// h=N-1 directional sign disagrees.
|
||
let mut split: [f32; N_HORIZONS] = [0.6; N_HORIZONS];
|
||
split[N_HORIZONS - 1] = 0.4;
|
||
|
||
// 32 events at spec-default factor produces exactly 1.0 — to be safe
|
||
// against any single-event slack, run 33 events.
|
||
for _ in 0..33 {
|
||
sim.broadcast_alpha(&split)?;
|
||
sim.step_decision_with_latency(ts, &cfg_default(1))?;
|
||
ts += 1_000_000;
|
||
}
|
||
|
||
let (side, size) = sim.read_market_target(0)?;
|
||
assert_eq!(side, 3,
|
||
"composite exit_signal must fire force-flat after 32+ disagreement events; got side={side}");
|
||
assert_eq!(size, 0, "force-flat encoding must have abs_sz=0; got size={size}");
|
||
Ok(())
|
||
}
|