test(ml-backtesting): multi-horizon mask averaging invariant test
Boundary test that fails on bit-pick-first or bit-pick-max regressions of the open_horizon_masks averaging in stop_check_isv. Per-horizon pnl_ema_loss values (2, 4, 3, 3, 3) yield mean=3.0 across the 0x1F mask; snapshots placed relative to vwap_entry for ask-spread safety. Tests Δ_entry=2.5 (no-fire) and Δ_entry=4.5 (fire). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -219,3 +219,102 @@ fn trail_arms_then_fires() -> Result<()> {
|
|||||||
assert_eq!(size, 0);
|
assert_eq!(size, 0);
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user