Files
foxhunt/crates/ml-backtesting/src/harness.rs
jgrusewski fc41440dc9 diag(ml-backtesting): finer NaN instrumentation — per-vwap-write-site + last-bad-vwap capture
v1 instrumentation (S1.20) eliminated 3 of 6 hypotheses: apply_fill_to_pos
arithmetic is fully clean (avg_px=0 realised=0 realized_pnl=0). But
pnl_track open branch still saw vwap_entry=0 (194 times) or > 21M (216
times) at the open transition.

v2 adds per-vwap-write-site counters so we can pinpoint which apply_fill
branch produced the bad vwap, plus captures the actual last-bad-vwap
value and the path id (1..6 = open-flat, scale-in zero/huge, flip-beyond
zero/huge, session-gap-saw-stale).

The 7th counter (vwap_session_gap_was_bad) fires when the session-gap
force-close path sees pos.vwap_entry already in a corrupt state pre-gap.

Logged at end of smoke as `nan_counters_v2: zero_flat=N huge_flat=N
zero_scale=N huge_scale=N zero_flip=N huge_flip=N session_gap_was_bad=N
| b0_last_bad_vwap=X b0_last_bad_path=N`.

19/19 stop_controller CUDA tests pass; 5/5 decision_floor_coldstart pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 13:27:03 +02:00

426 lines
20 KiB
Rust

//! BacktestHarness — orchestrator wiring ml-alpha's MultiHorizonLoader
//! and PerceptionTrainer (in inference role) into the LobSimCuda.
//!
//! Design: the harness reuses the trainer's loader + forward chain
//! (PerceptionTrainer::evaluate_batched, accessed via `forward_only`)
//! so train-vs-deploy skew is structurally impossible. The input struct
//! (`Mbp10RawInput`), feature-assembly cubin, and full perception
//! forward are the same code paths used in training. The harness's
//! only job is to walk the chronological snapshot stream, maintain a
//! sliding K-window for the recurrent Mamba2 context, drive the sim's
//! decision loop with the trainer's per-horizon probability output.
use anyhow::{Context, Result};
use ml_alpha::cfc::snap_features::Mbp10RawInput;
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
};
use ml_alpha::heads::N_HORIZONS;
use ml_alpha::trainer::perception::PerceptionTrainer;
use ml_core::device::MlDevice;
use std::collections::VecDeque;
use std::path::PathBuf;
use crate::sim::LobSimCuda;
#[derive(Clone, Debug)]
pub struct BacktestHarnessConfig {
pub data_root: PathBuf,
pub predecoded_dir: PathBuf,
pub n_parallel: usize,
pub decision_stride: usize,
pub target_annual_vol_units: f32,
pub annualisation_factor: f32,
pub max_lots: u16,
/// Max number of inference inputs to consume. 0 = exhaust loader.
pub max_events: u64,
/// Per-backtest Strategy compositions. Empty = every cell uses the
/// hardcoded default policy (WeightedByRealizedSharpe across all 5
/// horizons). Non-empty: len MUST equal n_parallel; each entry is
/// flattened to bytecode and uploaded to its backtest slot.
pub strategies: Vec<crate::policy::Strategy>,
/// Total submission→fill latency in nanoseconds. 0 = immediate
/// (legacy path). 100_000_000 = 100ms IBKR + Scaleway baseline.
/// When non-zero, the orchestrator submits aggressive IOC limits
/// with `active=2`/`arrival_ts_ns = current + latency_ns` so the
/// book can move under them during the in-flight window.
pub latency_ns: u32,
/// Cold-start Kelly fraction floor — used when isv_kelly is at
/// sentinel (no closed trades yet). Per
/// pearl_blend_formulas_must_have_permanent_floor: max(floor, real),
/// not blend. Default 0.10 = trade conservatively until Kelly state
/// bootstraps.
pub kelly_frac_floor: f32,
/// Cold-start Sharpe weight floor — applied to recent_sharpe when
/// aggregating signed-sizes across horizons. Lets the cross-horizon
/// sum produce a non-zero size before recent_sharpe is populated.
/// Default 0.10 = uniform 1/5 weight per horizon at cold-start.
pub sharpe_weight_floor: f32,
/// P4: threshold gate — absolute conviction cutoff. When
/// `max_h |alpha[h] - 0.5| * 2 < threshold`, the decision kernels
/// emit noop. Default 0.0 = gate disabled (every signal passes).
pub threshold: f32,
/// P4: per-fill cost (price-units / lot / side). Deducted from
/// pos.realized_pnl and accumulated into total_fees_per_b_d at
/// every fill. Default 0.0 = no cost (frictionless).
pub cost_per_lot_per_side: f32,
/// P6: optional per-backtest variant names. When Some, write_artifacts
/// writes subdirs as `sim_<name>` instead of `cell_NNNN`. Length MUST
/// equal n_parallel when set. None preserves the legacy cell_NNNN
/// naming for non-grid-pack callers (smoke, fixtures).
pub variant_names: Option<Vec<String>>,
/// P6: optional pre-built BatchedSimConfig (one entry per backtest =
/// per variant). When Some, the harness uses this directly instead
/// of building from_uniform off the scalar fields above. Length MUST
/// equal n_parallel when set.
pub sim_config_override: Option<crate::sim::BatchedSimConfig>,
}
pub struct BacktestHarness {
cfg: BacktestHarnessConfig,
/// Per-backtest sim parameter arrays. P1 onwards is the source of
/// truth for sim-side scalar params; cfg's scalar fields are still
/// used to construct this via BatchedSimConfig::from_uniform at
/// harness new(), but the run-loop reads sim_config, not cfg.
sim_config: crate::sim::BatchedSimConfig,
loader: MultiHorizonLoader,
/// PerceptionTrainer in inference role — owns the trunk (loaded from
/// Checkpoint) and the kernel-launch scratches. Forward driven via
/// `forward_only` at decision-stride boundaries.
trainer: PerceptionTrainer,
/// Sliding K-window of recent snapshots for the recurrent forward.
/// At every decision-stride boundary, when the window has reached
/// `seq_len` entries, we call `trainer.forward_only(&window)` and
/// take the last K position's probs.
snapshot_window: VecDeque<Mbp10RawInput>,
/// Window capacity = trainer's seq_len, captured at construction.
seq_len: usize,
sim: LobSimCuda,
decision_count: u64,
event_count: u64,
/// Per-cell cumulative P&L curve in USD, sampled once per event.
/// `pnl_curves[b][i]` = realized_pnl USD for backtest b after event i.
pnl_curves: Vec<Vec<f32>>,
/// Side-channel log of max_conviction per decision (one value per
/// stride boundary, NOT per event). Shared across all backtests in
/// a batched cell because broadcast_alpha gives every backtest the
/// same probs. Used by the threshold-tuning step to compute p60-p95
/// absolute values: percentiles of this Vec → calibrated thresholds.
conviction_log: Vec<f32>,
}
impl BacktestHarness {
/// Construct with an externally-built MlDevice + PerceptionTrainer.
/// Caller loads the trainer from a Checkpoint via
/// `PerceptionTrainer::from_checkpoint`. The harness reads
/// `trainer.config().seq_len` to size its sliding snapshot window.
pub fn new(
cfg: BacktestHarnessConfig,
dev: &MlDevice,
trainer: PerceptionTrainer,
) -> Result<Self> {
if !cfg.strategies.is_empty() {
anyhow::ensure!(
cfg.strategies.len() == cfg.n_parallel,
"strategies len {} ≠ n_parallel {}",
cfg.strategies.len(),
cfg.n_parallel
);
}
let files = discover_mbp10_files_sorted(&cfg.data_root)?;
let loader_cfg = MultiHorizonLoaderConfig {
files,
predecoded_dir: cfg.predecoded_dir.clone(),
seq_len: 1,
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 0,
seed: 0,
decision_stride: cfg.decision_stride,
inference_only: true,
};
let loader = MultiHorizonLoader::new(&loader_cfg)?;
let seq_len = trainer.config().seq_len;
anyhow::ensure!(
trainer.config().n_batch == 1,
"BacktestHarness requires PerceptionTrainer with n_batch=1 (got {})",
trainer.config().n_batch
);
let mut sim = LobSimCuda::new(cfg.n_parallel, dev)?;
// P6: if sim_config_override is provided (sweep runner grid-pack
// flow), use it directly; otherwise build a uniform broadcast from
// scalar cfg fields (smoke + fixture flows).
let sim_config = match cfg.sim_config_override.clone() {
Some(bc) => {
anyhow::ensure!(
bc.n_backtests() == cfg.n_parallel,
"sim_config_override has {} variants but n_parallel = {}",
bc.n_backtests(), cfg.n_parallel,
);
bc
}
None => crate::sim::BatchedSimConfig::from_uniform(
cfg.n_parallel,
&crate::sim::UniformSimParams {
target_annual_vol_units: cfg.target_annual_vol_units,
annualisation_factor: cfg.annualisation_factor,
max_lots: cfg.max_lots,
latency_ns: cfg.latency_ns,
kelly_frac_floor: cfg.kelly_frac_floor,
sharpe_weight_floor: cfg.sharpe_weight_floor,
threshold: cfg.threshold,
cost_per_lot_per_side: cfg.cost_per_lot_per_side,
max_hold_ns: 0, // default disabled; set via sim_config_override
min_reasonable_px: 0.0,
max_reasonable_px: f32::INFINITY,
},
),
};
// Upload caller-provided strategies (one program per backtest).
for (b, strat) in cfg.strategies.iter().enumerate() {
let prog = strat.flatten();
sim.upload_program(b, &prog)?;
}
// S1.19: upload per-backtest price-range bounds from sim_config.
sim.upload_price_range(&sim_config.min_reasonable_px, &sim_config.max_reasonable_px)?;
let pnl_curves = (0..cfg.n_parallel).map(|_| Vec::with_capacity(1024)).collect();
Ok(Self {
cfg,
sim_config,
loader,
trainer,
snapshot_window: VecDeque::with_capacity(seq_len),
seq_len,
sim,
decision_count: 0,
event_count: 0,
pnl_curves,
// Pre-size for ~2.5M decisions (one full quarter at stride=4).
// Auto-grows past this; pre-allocation just avoids re-allocs.
conviction_log: Vec::with_capacity(3_000_000),
})
}
/// Drive the chronological event stream → trunk inference → sim
/// decision loop until either the stream is exhausted or
/// cfg.max_events is reached. See spec §7 orchestrator code.
pub fn run(&mut self) -> Result<RunStats> {
let stride = self.cfg.decision_stride.max(1) as u64;
let mut total_decisions = 0u64;
// Periodic progress line — without this, multi-million-event
// runs look identical to a deadlock in pod logs.
const PROGRESS_EVERY: u64 = 1_000_000;
let mut next_progress_event = PROGRESS_EVERY;
let started = std::time::Instant::now();
while let Some(raw) = self.loader.next_inference_input()? {
// Apply snapshot to every backtest's book.
self.sim.apply_snapshot(&raw.bid_px, &raw.bid_sz, &raw.ask_px, &raw.ask_sz)?;
// Process resting orders (in-flight promotion, queue decay,
// marketability check, stop triggers, OCO). Trade-flow signal
// comes from the loader's L1-delta tick-rule inference (see
// ml_alpha::data::loader::infer_signed_trade_flow). Positive =
// buyer-initiated, negative = seller-initiated.
self.sim.step_resting_orders(raw.ts_ns, raw.trade_signed_vol)?;
// Maintain the sliding K-window for the recurrent forward.
// Push the current snapshot, evict the oldest if at capacity.
if self.snapshot_window.len() == self.seq_len {
self.snapshot_window.pop_front();
}
self.snapshot_window.push_back(raw.clone());
// At decision-stride boundaries: run forward inference + sim
// decision. Skip until the window is full (insufficient
// context for Mamba2's recurrent state).
if self.event_count % stride == 0 && self.snapshot_window.len() == self.seq_len {
let window: Vec<Mbp10RawInput> = self.snapshot_window.iter().cloned().collect();
let probs_all = self.trainer.forward_only(&window)
.context("trainer.forward_only")?;
// probs_all is [K * B * N_HORIZONS] with B=1; take the
// LAST K position's probs as the decision signal.
let last_probs_start = (self.seq_len - 1) * N_HORIZONS;
let last_probs: [f32; N_HORIZONS] = probs_all[last_probs_start..]
.try_into()
.context("slice last K probs")?;
// Side-channel: record this decision's max_conviction for
// the threshold-tuning percentile computation. Doing it
// BEFORE broadcast/step so the log captures every decision
// attempt, including those the threshold gate would skip.
let max_conv = last_probs.iter()
.map(|p| ((p - 0.5).abs() * 2.0).min(1.0).max(0.0))
.fold(0.0_f32, f32::max);
self.conviction_log.push(max_conv);
self.sim.broadcast_alpha(&last_probs)?;
self.sim.step_decision_with_latency(raw.ts_ns, &self.sim_config)?;
self.decision_count += 1;
total_decisions += 1;
}
// Sample per-cell realised P&L (price-units → USD via $50/index-pt).
for b in 0..self.cfg.n_parallel {
let pos = self.sim.read_pos(b)?;
self.pnl_curves[b].push(pos.realized_pnl * 50.0);
}
self.event_count += 1;
if self.event_count >= next_progress_event {
let elapsed = started.elapsed().as_secs_f32();
let rate = self.event_count as f32 / elapsed.max(1e-3);
let trades = self.sim.read_total_trade_count().unwrap_or(0);
eprintln!(
"progress: events={} decisions={} trades={} elapsed={:.0}s rate={:.0}ev/s",
self.event_count, total_decisions, trades, elapsed, rate,
);
next_progress_event += PROGRESS_EVERY;
}
if self.cfg.max_events > 0 && self.event_count >= self.cfg.max_events {
break;
}
}
// S1.20: NaN instrumentation summary. Print ONCE per smoke so the
// cluster log shows which kernel-side arithmetic site produced the
// residual 97 zero + 85 i32::MAX sentinel trade records.
match self.sim.read_nan_counters() {
Ok(counters) => {
let sum_u32 = |v: &[u32]| -> u64 { v.iter().map(|&x| x as u64).sum() };
eprintln!(
"nan_counters: avg_px={} realised={} realized_pnl={} zero_vwap_open={} saturated_vwap_open={} defensive_clamp={}",
sum_u32(&counters.nan_avg_px),
sum_u32(&counters.nan_realised),
sum_u32(&counters.nan_realized_pnl),
sum_u32(&counters.zero_vwap_at_open),
sum_u32(&counters.saturated_vwap_at_open),
sum_u32(&counters.defensive_exit_clamp),
);
}
Err(e) => eprintln!("nan_counters read failed: {e}"),
}
// S1.21: per-vwap-write-site counters. Printed after v1 line so both
// appear together in the smoke log.
match self.sim.read_nan_counters_v2() {
Ok(c) => {
let sum_u32 = |v: &[u32]| -> u64 { v.iter().map(|&x| x as u64).sum() };
let last_bad_vwap_b0 = c.last_bad_vwap.first().copied().unwrap_or(0.0);
let last_bad_path_b0 = c.last_bad_path.first().copied().unwrap_or(0);
eprintln!(
"nan_counters_v2: zero_flat={} huge_flat={} zero_scale={} huge_scale={} zero_flip={} huge_flip={} session_gap_was_bad={} | b0_last_bad_vwap={} b0_last_bad_path={}",
sum_u32(&c.vwap_zero_open_flat),
sum_u32(&c.vwap_huge_open_flat),
sum_u32(&c.vwap_zero_scale_in),
sum_u32(&c.vwap_huge_scale_in),
sum_u32(&c.vwap_zero_flip),
sum_u32(&c.vwap_huge_flip),
sum_u32(&c.vwap_session_gap_was_bad),
last_bad_vwap_b0,
last_bad_path_b0,
);
}
Err(e) => eprintln!("nan_counters_v2 read failed: {e}"),
}
Ok(RunStats {
events_processed: self.event_count,
decisions_taken: total_decisions,
})
}
/// After `run()` completes, write per-cell artifacts to
/// `<out_dir>/cell_<b>/{summary.json,trades.csv,pnl_curve.bin}`.
pub fn write_artifacts(&self, out_dir: &std::path::Path) -> Result<()> {
std::fs::create_dir_all(out_dir)
.with_context(|| format!("create out dir {}", out_dir.display()))?;
// Write the threshold-tuning side-channel ONCE per cell (shared
// across all backtests in a batched cell because broadcast_alpha
// gives every backtest the same probs). Raw convictions.bin
// (little-endian f32) + conviction_percentiles.json with the
// pre-computed p60/p70/p80/p90/p95 values.
if !self.conviction_log.is_empty() {
let convictions_path = out_dir.join("convictions.bin");
let mut bytes = Vec::with_capacity(self.conviction_log.len() * 4);
for v in &self.conviction_log {
bytes.extend_from_slice(&v.to_le_bytes());
}
std::fs::write(&convictions_path, &bytes)
.with_context(|| format!("write {}", convictions_path.display()))?;
let mut sorted = self.conviction_log.clone();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let pct = |q: f32| -> f32 {
let idx = ((sorted.len() - 1) as f32 * q).round() as usize;
sorted[idx]
};
let pcts = serde_json::json!({
"n_decisions": sorted.len(),
"min": sorted.first().copied().unwrap_or(0.0),
"max": sorted.last().copied().unwrap_or(0.0),
"mean": sorted.iter().sum::<f32>() / sorted.len() as f32,
"p10": pct(0.10),
"p25": pct(0.25),
"p50": pct(0.50),
"p60": pct(0.60),
"p70": pct(0.70),
"p80": pct(0.80),
"p90": pct(0.90),
"p95": pct(0.95),
"p99": pct(0.99),
});
let pcts_path = out_dir.join("conviction_percentiles.json");
std::fs::write(&pcts_path, serde_json::to_string_pretty(&pcts)?)
.with_context(|| format!("write {}", pcts_path.display()))?;
eprintln!(
"convictions: n={} min={:.4} p60={:.4} p70={:.4} p80={:.4} p90={:.4} p95={:.4} max={:.4}",
sorted.len(),
sorted.first().copied().unwrap_or(0.0),
pct(0.60), pct(0.70), pct(0.80), pct(0.90), pct(0.95),
sorted.last().copied().unwrap_or(0.0),
);
}
if let Some(names) = self.cfg.variant_names.as_ref() {
anyhow::ensure!(
names.len() == self.cfg.n_parallel,
"variant_names len {} != n_parallel {}",
names.len(), self.cfg.n_parallel,
);
}
for b in 0..self.cfg.n_parallel {
// P6: when sweep runner provides variant names, dir = sim_<name>
// per spec §3.3. Otherwise fall back to legacy cell_NNNN naming.
let subdir_name = match self.cfg.variant_names.as_ref() {
Some(names) => format!("sim_{}", names[b]),
None => format!("cell_{b:04}"),
};
let cell_dir = out_dir.join(subdir_name);
std::fs::create_dir_all(&cell_dir)
.with_context(|| format!("create cell dir {}", cell_dir.display()))?;
let records = self.sim.read_trade_records(b)?;
let curve = &self.pnl_curves[b];
let summary = crate::artifacts::compute_summary(&records, curve);
crate::artifacts::write_summary(&cell_dir.join("summary.json"), &summary)?;
crate::artifacts::write_trades_csv(&cell_dir.join("trades.csv"), &records)?;
crate::artifacts::write_pnl_curve_bin(&cell_dir.join("pnl_curve.bin"), curve)?;
}
Ok(())
}
pub fn sim(&self) -> &LobSimCuda { &self.sim }
pub fn event_count(&self) -> u64 { self.event_count }
pub fn decision_count(&self) -> u64 { self.decision_count }
}
#[derive(Clone, Copy, Debug)]
pub struct RunStats {
pub events_processed: u64,
pub decisions_taken: u64,
}