//! 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, /// 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_` 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>, /// 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, } 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, /// 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>, /// 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, } 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 { 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 { 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 = 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}"), } // S2.1/S2.2: generic stop-controller diagnostic counters. The 6 // max_hold-specific decision-rate counters were removed when max_hold // enforcement moved to resting_orders_step (event-rate). Two remain: // kernel_calls — stop_check_isv entries with open position (SL/trail active) // seed_saw_force_flat — force-flat signals seen by seed_inflight (SL/trail) match self.sim.read_max_hold_counters() { Ok(c) => { let sum_u32 = |v: &[u32]| -> u64 { v.iter().map(|&x| x as u64).sum() }; eprintln!( "stop_ctrl_counters: kernel_calls={} seed_saw_force_flat={}", sum_u32(&c.mh_kernel_calls), sum_u32(&c.mh_force_flat_seen_by_seed), ); } Err(e) => eprintln!("stop_ctrl_counters read failed: {e}"), } Ok(RunStats { events_processed: self.event_count, decisions_taken: total_decisions, }) } /// After `run()` completes, write per-cell artifacts to /// `/cell_/{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::() / 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_ // 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, }