The C1.3 commit 7c850a6c0 missed three test files that construct
UniformSimParams literals. cargo check reported E0063 missing-field
errors. Added delta_floor: 0.0 (band disabled, preserves pre-C1.3
behavior in tests) to:
- parallel_sim_correctness.rs
- decision_floor_coldstart.rs
- lob_sim_integrated_fuzz.rs
Per feedback_no_partial_refactor: the atomic refactor must include
every consumer in one logical change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
7.2 KiB
Rust
169 lines
7.2 KiB
Rust
//! Regression tests for the cold-start trade-must-fire invariant.
|
|
//!
|
|
//! v2 path (deleted): kernel observed sentinel `isv_kelly_d` (all zeros
|
|
//! from `alloc_zeros`) and skipped every horizon → `market_target =
|
|
//! (noop, 0)` forever. Fixed in v2 with kelly_frac_floor +
|
|
//! sharpe_weight_floor max-blend.
|
|
//!
|
|
//! v3 path (current — CRT.1 C1.2): the §4.4 multi-horizon ISV-weighted
|
|
//! conviction formula bootstraps via `eps_edge = cost · 0.01` floor on
|
|
//! `net_edge_h`. With cost > 0 the weight is bounded below by
|
|
//! `0.01·cost / (var + cost²) > 0`, so cold-start state still produces
|
|
//! non-zero conviction and a non-zero target under strong directional
|
|
//! alpha. The kelly_frac_floor / sharpe_weight_floor params no longer
|
|
//! gate the default kernel — the eps_edge floor in §4.4 does.
|
|
//!
|
|
//! Asserts: with sentinel isv_kelly_d, strong directional alpha
|
|
//! (p_h=0.8 ⇒ long) + cost > 0 ⇒ market_target side=0 (buy),
|
|
//! size >= 1 lot.
|
|
|
|
use anyhow::Result;
|
|
use ml_backtesting::policy::{
|
|
EnsembleAggregator, IsvKellyStateHost, SizingPolicyId, Strategy, StrategyConfig,
|
|
N_HORIZONS,
|
|
};
|
|
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 + sharpe_weight_floor are bytecode-VM-only inputs
|
|
// under v3 (the default kernel uses the §4.4 multi-horizon formula
|
|
// and does not read these). They remain in the cfg because the
|
|
// bytecode VM still consumes them in OP_EMIT_PER_HORIZON_SIZE /
|
|
// OP_AGG_WEIGHTED_SHARPE.
|
|
kelly_frac_floor: kelly,
|
|
sharpe_weight_floor: sharpe,
|
|
threshold: 0.0,
|
|
// cost > 0 is required for the §4.4 formula's eps_edge floor
|
|
// (eps_edge = cost · 0.01) and for the cost²-bootstrap of the
|
|
// weight denominator. 1.0 is a typical futures round-trip cost.
|
|
cost_per_lot_per_side: 1.0,
|
|
max_hold_ns: 0,
|
|
delta_floor: 0.0,
|
|
min_reasonable_px: 0.0,
|
|
max_reasonable_px: f32::INFINITY,
|
|
})
|
|
}
|
|
|
|
#[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(())
|
|
}
|
|
|
|
/// Q1 cluster smoke local repro: same cold-start scenario as
|
|
/// `cold_start_sentinel_state_still_fires_a_trade` (sentinel isv, p=0.8
|
|
/// long, floors=0.20/0.10) but routes through the bytecode VM via
|
|
/// `upload_program(MaxConfidence ensemble)`. The cluster smoke
|
|
/// `lob-backtest-sweep-qf9db` produced n_trades=0 with this
|
|
/// configuration despite p70/p80/p90 convictions of 0.66/0.77/0.87.
|
|
/// This test isolates the bytecode-VM path arithmetic from the rest of
|
|
/// the harness (data loader, predecoded inference, trade lifecycle).
|
|
///
|
|
/// Root cause was `StopRules::default()` (deleted in S1.10). ISV stop
|
|
/// controller (Tasks 2-9) replaces that data path.
|
|
///
|
|
/// Pass criterion identical to legacy-path test: side=0, size>=1.
|
|
#[test]
|
|
#[ignore = "requires CUDA"]
|
|
fn cold_start_stopgap_bytecode_vm_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)?;
|
|
|
|
let max_conf = 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,
|
|
};
|
|
let prog = max_conf.flatten();
|
|
sim.upload_program(0, &prog)?;
|
|
|
|
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)?;
|
|
eprintln!("stopgap cold-start single-step: side={side} size={size}");
|
|
assert_eq!(
|
|
side, 0,
|
|
"stopgap bytecode VM cold-start with p=0.8 must produce long; got side={side}"
|
|
);
|
|
assert!(
|
|
size >= 1,
|
|
"stopgap bytecode size {size} < 1 — bytecode VM cold-start path is broken (Q1 falsified)"
|
|
);
|
|
Ok(())
|
|
}
|
|
|