trail_distance = max(pnl_ema_win, atr_mid_ema). HWM ratchets up via fmaxf each event while position open; trail fires when HWM clears the arming threshold AND unrealized drops by trail_distance from HWM. Same force-flat (3, 0) write as SL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
222 lines
8.6 KiB
Rust
222 lines
8.6 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::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(())
|
|
}
|