market_targets[b] now interpreted as target absolute position (side=0
long, side=1 short, side=2 no-op preserved, side=3 force-flat).
seed_inflight_limits_batched computes order_lots = target_signed -
effective_position where effective_position sums pos.position_lots
plus all unfilled signed slot sizes (active in {1, 2}). Fixes both
the additive accumulation bug AND the worse latency-bypass variant
(naive delta would have made things 200x worse under 200ms latency).
3 new tests added (all passing):
- position_target_not_additive: latency=0, 10 repeated target events stay <= max_lots=5
- position_target_not_additive_with_latency: 200ms latency, 500 events, in-flight summation prevents runaway
- alpha_noop_side_2_preserved: side=2 events leave filled position unchanged
All 9 stop_controller tests pass; parallel_sim and fuzz regression clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
550 lines
22 KiB
Rust
550 lines
22 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, StopRules, 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,
|
||
use_cold_start_stopgap: false,
|
||
})
|
||
}
|
||
|
||
#[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,
|
||
sl_tp_rules: StopRules::default(),
|
||
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,
|
||
use_cold_start_stopgap: false,
|
||
});
|
||
|
||
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(())
|
||
}
|