Migrates target_annual_vol_units, annualisation_factor, max_lots, latency_ns, kelly_frac_floor, sharpe_weight_floor from scalar-broadcast kernel args to per-backtest device arrays via BatchedSimConfig. Atomic contract change per feedback_no_partial_refactor — kernel + sim + harness + all 3 existing test files migrate in this commit. - crates/ml-backtesting/src/sim.rs → sim/mod.rs (directory module) - crates/ml-backtesting/src/sim/batched_config.rs (NEW): BatchedSimConfig + UniformSimParams + validate(). from_uniform rebuilds the legacy uniform-broadcast behaviour at n=1 (smoke/fixtures). from_grid lands in P6 for the 140-variant sweep packing. - LobSimCuda gains 6 per-backtest device buffers (target_annual_vol_units_d, annualisation_factor_d, max_lots_d, latency_ns_d, kelly_frac_floor_d, sharpe_weight_floor_d). step_decision_with_latency uploads from BatchedSimConfig each call; both decision kernel launches now pass per-backtest array pointers. - decision_policy_default + decision_policy_program: scalar args become const float* / const int* per_b arrays; first lines of each kernel index by `b` into the arrays. Behaviour preserved at n=1 uniform. - dispatch_latent_market_orders: reads latency per-backtest from &BatchedSimConfig (host loop stays for P1; P2 replaces with kernel). - step_decision_with_latency now ALWAYS dispatches through the latency path; when cfg.latency_ns[b]=0 the in-flight slot's arrival_ts equals current_ts and gets promoted immediately on next snapshot. Eliminates the if/else branch and consolidates the launch path. - harness.rs: BacktestHarness gains a sim_config field, built via BatchedSimConfig::from_uniform at new() from the harness cfg's scalar fields. The run loop passes &self.sim_config to step_decision_with_latency. Regression coverage: - parallel_sim_correctness::parallel_sim_equivalence_with_uniform_config — n=8 with uniform config produces 8 bit-identical market_targets (proves per-backtest indexing reduces correctly). - Existing decision_floor_coldstart tests (3) all pass through the new ABI — proves cold-start floor + variance-cap gate behaviour preserved. - parallel_sim_independence_per_backtest deferred to P2 (needs read_first_inflight_arrival_ts helper that depends on LimitSlot layout). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
5.1 KiB
Rust
117 lines
5.1 KiB
Rust
//! Regression test for the cold-start sentinel-skip bug surfaced during
|
|
//! the trunk-grows smoke (2026-05-19). Before the kernel floor was
|
|
//! added, `step_decision*` would observe sentinel `isv_kelly_d` (all
|
|
//! zeros from `alloc_zeros`) and skip every horizon → `market_target =
|
|
//! (noop, 0)` forever, which prevented any trade from ever firing.
|
|
//!
|
|
//! Per `pearl_blend_formulas_must_have_permanent_floor.md` and
|
|
//! `pearl_kelly_cap_signal_driven_floors.md`: the decision policy uses
|
|
//! `max(floor, computed)` on Kelly fraction AND on the recent-Sharpe
|
|
//! aggregation weight so cold-start produces a non-zero target.
|
|
//!
|
|
//! Asserts: with sentinel isv_kelly_d, strong directional alpha
|
|
//! (p_h=0.8 ⇒ long) + floors > 0 ⇒ market_target side=0 (buy),
|
|
//! size >= 1 lot.
|
|
|
|
use anyhow::Result;
|
|
use ml_backtesting::policy::IsvKellyStateHost;
|
|
use ml_backtesting::sim::{BatchedSimConfig, LobSimCuda, UniformSimParams};
|
|
use ml_core::device::MlDevice;
|
|
|
|
fn cfg_uniform(n: usize, kelly: f32, sharpe: f32) -> 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: kelly,
|
|
sharpe_weight_floor: sharpe,
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn cold_start_sentinel_state_still_fires_a_trade() -> Result<()> {
|
|
let dev = match MlDevice::cuda(0) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
eprintln!("skipping: cuda device unavailable ({e})");
|
|
return Ok(());
|
|
}
|
|
};
|
|
let mut sim = LobSimCuda::new(1, &dev)?;
|
|
// Do NOT seed isv_kelly — leave at zeros (alloc_zeros' sentinel).
|
|
// Strong directional alpha across all horizons → conviction-driven
|
|
// sig_mag = 0.6 for every horizon, dir = +1.
|
|
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
|
|
sim.step_decision_with_latency(0, &cfg_uniform(1, 0.20, 0.10))?;
|
|
let (side, size) = sim.read_market_target(0)?;
|
|
assert_eq!(side, 0, "cold-start with p_h=0.8 must produce a long; got side={side}");
|
|
assert!(size >= 1, "cold-start size {size} < 1 — the kernel floor isn't firing");
|
|
Ok(())
|
|
}
|
|
|
|
/// After the first trade closes as a loss, the original kernel set
|
|
/// `realised_return_var = ret²` which collapses `cap_units` to ~0 and
|
|
/// permanently locks the policy out of further trading despite strong
|
|
/// alpha signal. The fix gates the variance-derived cap behind a
|
|
/// sample-size threshold (`n_trades_seen >= MIN_TRADES_FOR_VAR_CAP`)
|
|
/// so cap_lots falls back to `max_lots` while statistics are unreliable.
|
|
///
|
|
/// Test: write an IsvKellyState with n_trades_seen=1 and a large
|
|
/// realised_return_var (mimicking the post-loss state from the smoke),
|
|
/// then prove that the decision kernel still produces a non-zero trade.
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn post_first_loss_state_does_not_lock_out_further_trades() -> Result<()> {
|
|
let dev = match MlDevice::cuda(0) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
eprintln!("skipping: cuda device unavailable ({e})");
|
|
return Ok(());
|
|
}
|
|
};
|
|
let mut sim = LobSimCuda::new(1, &dev)?;
|
|
// Seed isv_kelly_d with the exact state pattern the smoke produced:
|
|
// one closed-loss trade, large realised_return_var. Pre-fix, the
|
|
// variance-derived cap collapses to ~0.
|
|
let post_loss: [IsvKellyStateHost; 5] = std::array::from_fn(|_| IsvKellyStateHost {
|
|
pnl_ema_win: 0.0,
|
|
pnl_ema_loss: 10.18, // magnitude of the lone loss return
|
|
win_rate_ema: 0.0,
|
|
n_trades_seen: 1, // exactly 1 closed trade — under MIN_TRADES_FOR_VAR_CAP
|
|
realised_return_var: 103.6, // ret² from the smoke (10.18²)
|
|
recent_sharpe: -1.0, // very negative — would have starved the weight side too
|
|
});
|
|
sim.write_isv_kelly(0, &post_loss)?;
|
|
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
|
|
sim.step_decision_with_latency(0, &cfg_uniform(1, 0.20, 0.10))?;
|
|
let (side, size) = sim.read_market_target(0)?;
|
|
assert_eq!(side, 0, "post-loss state must still fire a long with strong alpha (got side={side})");
|
|
assert!(size >= 1, "post-loss size {size} < 1 — n_trades_seen gate isn't bypassing the variance cap");
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn cold_start_with_zero_floor_reproduces_old_bug() -> Result<()> {
|
|
// Mirror of the test above but with floors = 0 — the kernel must
|
|
// then behave like the old sentinel-skip pre-fix code: no trade ever
|
|
// fires. Lets us prove the fix actually changes behaviour (and not
|
|
// some other unrelated code path).
|
|
let dev = match MlDevice::cuda(0) {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
eprintln!("skipping: cuda device unavailable ({e})");
|
|
return Ok(());
|
|
}
|
|
};
|
|
let mut sim = LobSimCuda::new(1, &dev)?;
|
|
sim.broadcast_alpha(&[0.8, 0.8, 0.8, 0.8, 0.8])?;
|
|
sim.step_decision_with_latency(0, &cfg_uniform(1, 0.0, 0.0))?;
|
|
let (side, size) = sim.read_market_target(0)?;
|
|
assert_eq!(side, 2, "with zero floors, sentinel state must still skip (side=noop)");
|
|
assert_eq!(size, 0);
|
|
Ok(())
|
|
}
|