//! `fxt-backtest` — Real-LOB GPU backtest CLI. //! //! Two subcommands: //! //! fxt-backtest run --data [--policy-grid ] ... //! Runs the LOB backtest harness on a directory of MBP-10 dbn files. //! Writes per-cell artifacts under --out/cell_/. //! //! fxt-backtest aggregate //! Walks every cell directory under , parses summary.json, //! emits aggregate.parquet + pareto_frontier.json at the sweep-dir root. //! //! See docs/superpowers/specs/2026-05-18-real-lob-integration-design.md §7. use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use ml_alpha::cfc::trunk::CfcConfig; use ml_alpha::heads::N_HORIZONS; use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig}; use ml_backtesting::aggregate::{aggregate_sweep_dir, emit_deployability_verdict}; use ml_backtesting::harness::{BacktestHarness, BacktestHarnessConfig}; use ml_backtesting::policy::Strategy; use ml_core::device::MlDevice; use std::path::PathBuf; #[derive(Parser, Debug)] #[command(name = "fxt-backtest", version, about = "Real-LOB GPU backtest CLI", long_about = None)] struct Cli { #[command(subcommand)] cmd: Cmd, } #[derive(Subcommand, Debug)] enum Cmd { /// Run a single backtest over an MBP-10 dataset. Run(RunArgs), /// Aggregate per-cell summary.json files in a sweep directory into /// aggregate.parquet + pareto_frontier.json. Aggregate(AggregateArgs), /// Run a parameter sweep: iterate over a grid of `Run` configs, /// writing each cell's artifacts to `//`, then /// automatically invoke `aggregate` on the resulting directory. /// All cells run sequentially on the same GPU (single-machine); /// for cluster fan-out use `argo-alpha-sweep.sh` (see infra/argo). Sweep(SweepArgs), /// Emit a deployability verdict from a completed sweep. Reads the /// per-cell summary.json files at the realistic (1.0 tick, 200 ms) /// and stress (1.5 tick, 400 ms) anchors against the supplied /// pre-registered threshold + walk-forward window IDs, classifies /// into Pass-robust / Pass-nominal / Fail-inconclusive / Fail / /// Fail-degenerate per spec §3.5, and writes the audit JSON. Verdict(VerdictArgs), } #[derive(Parser, Debug)] struct RunArgs { /// Root directory containing MBP-10 .dbn.zst files (and predecoded sidecars). #[arg(long)] data: PathBuf, /// Directory containing predecoded sidecars; defaults to --data. #[arg(long)] predecoded_dir: Option, /// Number of parallel backtest cells (one CUDA block per cell). #[arg(long, default_value_t = 1)] n_parallel: usize, /// Optional YAML file listing per-cell Strategy compositions. /// If omitted, every cell gets Strategy::default_for(--max-lots). #[arg(long)] policy_grid: Option, /// Total submission→fill latency in nanoseconds (IBKR + Scaleway /// baseline = 100_000_000 = 100ms). Propagated to the resting-order /// engine so in-flight orders wait for arrival_ts_ns before crossing. /// 0 = immediate-match path (legacy). #[arg(long, default_value_t = 100_000_000)] latency_ns: u32, /// Target annual volatility in price-unit lot-equivalents /// (see spec §5 IsvKellyState.target_annual_vol). #[arg(long, default_value_t = 50.0)] target_annual_vol_units: f32, /// Trade-rate annualisation factor — non-overlapping convention, /// 825 (K=6000 holding × 250 trading days) per pearl_phase1d4 default. #[arg(long, default_value_t = 825.0)] annualisation_factor: f32, /// Per-leaf max concurrent lots (Strategy::default_for argument). #[arg(long, default_value_t = 5)] max_lots: u16, /// Cap on events processed; 0 = exhaust the input stream. #[arg(long, default_value_t = 0)] max_events: u64, /// Cold-start Kelly fraction floor (see BacktestHarnessConfig + /// pearl_blend_formulas_must_have_permanent_floor). When isv_kelly /// has no closed-trade history, kelly_frac defaults to this floor so /// the first trade can fire and bootstrap state. #[arg(long, default_value_t = 0.20)] kelly_frac_floor: f32, /// Cold-start Sharpe weight floor — applied to recent_sharpe in the /// cross-horizon aggregation. Lets the aggregate produce a non-zero /// signed size before recent_sharpe is populated. #[arg(long, default_value_t = 0.10)] sharpe_weight_floor: f32, /// Random seed for trunk initialisation when --checkpoint is not given. #[arg(long, default_value_t = 0xC0FFEE)] seed: u64, /// Path to a CfcTrunk bincode checkpoint (see ml-alpha /// CfcTrunk::save_checkpoint). When set, overrides --seed and the /// trunk runs with trained weights. Without it the trunk is /// random-initialised — useful for plumbing tests, not real backtests. #[arg(long)] checkpoint: Option, /// Output directory; per-cell artifacts written to /cell_NNNN/. #[arg(long)] out: PathBuf, /// Enable per-step kernel-state JSONL trace to the given file. /// /// Mirrors `alpha_train --kernel-step-trace`. When set, the /// `PerceptionTrainer` constructed by the binary allocates the GPU /// log ring + spawns a drain task that writes one JSONL record per /// kernel emission to this path. Requires the `kernel-step-trace` /// Cargo feature at compile time (proxied through to `ml-alpha`). /// When unset (default), no ring allocated, zero overhead. /// /// Example: `--kernel-step-trace /feature-cache/runs//step_trace.jsonl` #[arg(long)] kernel_step_trace: Option, /// Per-horizon alpha_probs sampling stride for ALPHA Smoke 2 /// diagnostic. When set to N, every Nth inference call DtoHs the 5 /// alpha_probs and accumulates per-horizon mean/stddev/min/max + 10-bin /// histogram (emitted at end of CRT.diag block). #[arg(long)] per_horizon_logit_sampling_stride: Option, } #[derive(Parser, Debug)] struct AggregateArgs { /// Sweep directory containing one subdirectory per cell, each with /// a summary.json file. sweep_dir: PathBuf, } #[derive(Parser, Debug)] struct VerdictArgs { /// Sweep directory containing one subdirectory per cell (named /// `cell__cost_lat_th_`), each with a summary.json. sweep_dir: PathBuf, /// Pre-registered threshold (from the threshold-tuning sweep on W0). /// Read from `config/ml/v2_prod_thresholds.json` in production usage. #[arg(long)] threshold: f32, /// Walk-forward window IDs, comma-separated. Typical: /// "W1,W2,W3,W4" matching the 4 held-out quarters in /// `config/ml/sweep_deployability.yaml`. #[arg(long, default_value = "W1,W2,W3,W4")] windows: String, /// Training-run commit SHA (recorded in the audit JSON). #[arg(long)] training_sha: String, /// Spec-revision commit SHA (recorded in the audit JSON). #[arg(long)] spec_sha: String, /// Output JSON path. Convention: `deployability_verdict.json` at /// repo root, committed as audit record. #[arg(long)] out: PathBuf, } #[derive(Parser, Debug)] struct SweepArgs { /// Sweep grid YAML file. See SweepGrid struct in main.rs for the /// schema; each cell may override a subset of Run args. #[arg(long)] grid: PathBuf, /// Root output directory for the sweep. One subdirectory created /// per cell, plus aggregate.parquet + pareto_frontier.json at root. #[arg(long)] out: PathBuf, /// Stop the sweep after this many cells (0 = run all). Useful for /// smoke-testing a large grid. #[arg(long, default_value_t = 0)] max_cells: usize, } /// Sweep grid YAML schema. The `base` field provides defaults that /// each `cells` entry may override on a per-field basis. #[derive(Debug, serde::Deserialize)] struct SweepGrid { base: SweepBase, cells: Vec, } #[derive(Debug, serde::Deserialize, Clone)] struct SweepBase { /// Single data file path. Used by the legacy fan-out flow (one Argo /// task per cell). For the P6 batched flow that varies window per /// cell, prefer `data_template` with `{window}` interpolation. #[serde(default)] data: Option, /// P6: data path template with `{window}` placeholder. When set, /// each cell's `window` field interpolates into this template to /// produce the per-cell data path. /// Example: /mnt/training-data/futures-baseline-mbp10/ES.FUT/ES.FUT_{window}.dbn.zst #[serde(default)] data_template: Option, #[serde(default)] predecoded_dir: Option, #[serde(default = "default_n_parallel")] n_parallel: usize, #[serde(default = "default_latency_ns")] latency_ns: u32, #[serde(default = "default_target_vol")] target_annual_vol_units: f32, #[serde(default = "default_ann_factor")] annualisation_factor: f32, #[serde(default = "default_max_lots")] max_lots: u16, #[serde(default)] max_events: u64, #[serde(default = "default_seed")] seed: u64, #[serde(default)] checkpoint: Option, #[serde(default = "default_kelly_frac_floor")] kelly_frac_floor: f32, #[serde(default = "default_sharpe_weight_floor")] sharpe_weight_floor: f32, /// P6: list of sim variants. When non-empty, each cell runs ONE /// harness at n_parallel=variants.len() instead of fanning out one /// harness per cell. Cells become a window axis; variants become the /// (cost, latency, threshold) axis. #[serde(default)] sim_variants: Vec, /// S1.19: instrument-aware price-range sanitization. Snapshots whose /// top-of-book bid or ask falls outside [min, max] are skipped. /// None = no lower bound (0.0 passed to kernel, which disables the check). #[serde(default)] min_reasonable_px: Option, /// None = no upper bound (f32::INFINITY passed to kernel). #[serde(default)] max_reasonable_px: Option, /// CRT.1 C1.3: no-trade band in lots. seed_inflight_limits_batched skips /// seeding when |target_signed − effective| < delta_floor. Default 1.0 = /// 1 lot (ES single-contract sizing). #[serde(default = "default_delta_floor")] delta_floor: f32, /// Optional per-step kernel-state JSONL trace path. Propagated to /// every cell's `PerceptionTrainerConfig.kernel_step_trace_path` + /// `BacktestHarnessConfig.kernel_step_trace_path`. Requires the /// `kernel-step-trace` Cargo feature on the binary at compile time. /// Sweep cells share the same trace file path — useful for single-cell /// diagnostic sweeps; with N>1 cells the trace will interleave records /// from every harness/trainer instance (per-cell isolation must be /// handled by the operator setting distinct paths via a richer schema). #[serde(default)] kernel_step_trace: Option, /// Per-horizon alpha_probs sampling stride (ALPHA Smoke 2 diagnostic). /// `None` = disabled. When set, every Nth inference call DtoHs the 5 /// alpha_probs into a mapped-pinned buffer for per-horizon stats. #[serde(default)] per_horizon_logit_sampling_stride: Option, } /// P6: per-sim-variant overrides for the batched-cell sweep flow. /// `threshold` and `cost_per_lot_per_side` are required (no defaults — /// these are the spec's primary sweep axes); other fields are optional /// overrides on top of the SweepBase scalars. #[derive(Debug, serde::Deserialize, Clone)] struct SimVariant { name: String, threshold: f32, cost_per_lot_per_side: f32, #[serde(default)] latency_ns: Option, #[serde(default)] target_annual_vol_units: Option, #[serde(default)] annualisation_factor: Option, #[serde(default)] max_lots: Option, #[serde(default)] kelly_frac_floor: Option, #[serde(default)] sharpe_weight_floor: Option, /// Follow-up gp74n: max-hold force-close. 0 = disabled (default). /// Existing YAMLs omit this field; serde(default) gives 0. #[serde(default)] max_hold_ns: u64, /// CRT.1 C1.3: per-variant override for the no-trade band. None = use /// SweepBase.delta_floor. #[serde(default)] delta_floor: Option, } fn default_n_parallel() -> usize { 1 } fn default_latency_ns() -> u32 { 100_000_000 } fn default_target_vol() -> f32 { 50.0 } fn default_ann_factor() -> f32 { 825.0 } fn default_max_lots() -> u16 { 5 } fn default_seed() -> u64 { 0xC0FFEE } // Sized so a strong-conviction signal (|p-0.5| ≥ 0.2 ⇒ sig_mag ≥ 0.4) // produces ≥1 lot at cold-start for max_lots=5: // ss = sig_mag * floor * cap_lots → 0.4 * 0.20 * 5 = 0.4 → rounds to 0 // ss = sig_mag * floor * cap_lots → 0.5 * 0.20 * 5 = 0.5 → rounds to 1 // Weaker signals stay at 0 lots cold-start. Once kelly state bootstraps // (first close), `max(floor, computed_kelly)` keeps the floor as the // minimum trading intensity. fn default_kelly_frac_floor() -> f32 { 0.20 } fn default_sharpe_weight_floor() -> f32 { 0.10 } // CRT.1 C1.3: 1.0 lot — skip seeding when |delta| < 1 lot. Appropriate for // ES single-contract sizing. Per-variant YAML can override. fn default_delta_floor() -> f32 { 1.0 } #[derive(Debug, serde::Deserialize, Clone)] struct SweepCell { name: String, /// P6: window identifier (e.g., "2025-Q2") substituted into /// SweepBase.data_template. When set, the cell's data path is /// data_template.replace("{window}", window). #[serde(default)] window: Option, #[serde(default)] latency_ns: Option, #[serde(default)] target_annual_vol_units: Option, #[serde(default)] annualisation_factor: Option, #[serde(default)] max_lots: Option, #[serde(default)] max_events: Option, #[serde(default)] seed: Option, #[serde(default)] checkpoint: Option, #[serde(default)] kelly_frac_floor: Option, #[serde(default)] sharpe_weight_floor: Option, /// Per-cell override for the diagnostic alpha_probs sampler. When /// unset, falls back to `SweepBase::per_horizon_logit_sampling_stride`. #[serde(default)] per_horizon_logit_sampling_stride: Option, } fn main() -> Result<()> { tracing_subscriber::fmt::init(); let cli = Cli::parse(); match cli.cmd { Cmd::Run(args) => run(args), Cmd::Aggregate(args) => { let out = aggregate_sweep_dir(&args.sweep_dir)?; println!( "aggregated {} cells; {} on Pareto frontier; parquet={}, frontier={}", out.n_cells, out.n_pareto, out.parquet_path.display(), out.frontier_path.display() ); Ok(()) } Cmd::Sweep(args) => sweep(args), Cmd::Verdict(args) => { let windows: Vec<&str> = args.windows.split(',').map(|s| s.trim()).collect(); let verdict = emit_deployability_verdict( &args.sweep_dir, args.threshold, &windows, &args.training_sha, &args.spec_sha, ) .context("emit_deployability_verdict")?; let json = serde_json::to_string_pretty(&verdict) .context("serialize deployability_verdict")?; std::fs::write(&args.out, &json) .with_context(|| format!("write {}", args.out.display()))?; println!( "verdict: {:?} (realistic median sharpe = {:.3}, max_dd_pct = {:.3}; stress median sharpe = {:.3}, max_dd_pct = {:.3}) -> {}", verdict.verdict, verdict.realistic.median_sharpe, verdict.realistic.median_max_dd_pct, verdict.stress.median_sharpe, verdict.stress.median_max_dd_pct, args.out.display(), ); Ok(()) } } } fn sweep(args: SweepArgs) -> Result<()> { let grid_yaml = std::fs::read_to_string(&args.grid) .with_context(|| format!("read grid yaml {}", args.grid.display()))?; let grid: SweepGrid = serde_yaml::from_str(&grid_yaml).context("parse grid yaml")?; let n_cells = if args.max_cells > 0 { args.max_cells.min(grid.cells.len()) } else { grid.cells.len() }; anyhow::ensure!(n_cells > 0, "grid has no cells"); std::fs::create_dir_all(&args.out) .with_context(|| format!("create sweep out {}", args.out.display()))?; // P6: resolve sim variants once. When non-empty, each cell runs ONE // harness at n_parallel=variants.len() with BatchedSimConfig::from_grid. let resolved_variants = if grid.base.sim_variants.is_empty() { Vec::new() } else { resolve_sim_variants(&grid.base) }; let batched_mode = !resolved_variants.is_empty(); for (i, cell) in grid.cells.iter().take(n_cells).enumerate() { let cell_out = args.out.join(&cell.name); tracing::info!( cell = cell.name.as_str(), progress = format!("{}/{}", i + 1, n_cells), "running sweep cell" ); // Resolve per-cell data path. data_template + cell.window interpolation // takes precedence over scalar `data` when both are present. let cell_data = match (&grid.base.data_template, &cell.window) { (Some(tpl), Some(window)) => PathBuf::from(tpl.replace("{window}", window)), _ => grid.base.data.clone() .ok_or_else(|| anyhow::anyhow!( "sweep base must set either `data` (legacy) or `data_template + cell.window`" ))?, }; if batched_mode { // P6 batched flow: build BatchedSimConfig from grid, run ONE // harness at n_parallel=variants.len() with variant_names plumbed // through for sim_/ output dirs. run_batched_cell( &resolved_variants, cell, &grid.base, &cell_data, &cell_out, )?; tracing::info!(cell = cell.name.as_str(), "batched cell complete"); continue; } let run_args = RunArgs { data: cell_data, predecoded_dir: grid.base.predecoded_dir.clone(), n_parallel: grid.base.n_parallel, policy_grid: None, // sweep cells use the default policy; nested // grid-of-grids is a separate v2 concern. latency_ns: cell.latency_ns.unwrap_or(grid.base.latency_ns), target_annual_vol_units: cell.target_annual_vol_units.unwrap_or(grid.base.target_annual_vol_units), annualisation_factor: cell.annualisation_factor.unwrap_or(grid.base.annualisation_factor), max_lots: cell.max_lots.unwrap_or(grid.base.max_lots), max_events: cell.max_events.unwrap_or(grid.base.max_events), seed: cell.seed.unwrap_or(grid.base.seed), checkpoint: cell.checkpoint.clone().or_else(|| grid.base.checkpoint.clone()), kelly_frac_floor: cell.kelly_frac_floor.unwrap_or(grid.base.kelly_frac_floor), sharpe_weight_floor: cell.sharpe_weight_floor.unwrap_or(grid.base.sharpe_weight_floor), out: cell_out.clone(), kernel_step_trace: grid.base.kernel_step_trace.clone(), per_horizon_logit_sampling_stride: grid.base.per_horizon_logit_sampling_stride, }; run(run_args).with_context(|| format!("sweep cell {}", cell.name))?; tracing::info!(cell = cell.name.as_str(), "cell complete"); } // Auto-aggregate at the end. let out = aggregate_sweep_dir(&args.out)?; println!( "sweep complete: {} cells; {} on Pareto frontier; parquet={}, frontier={}", out.n_cells, out.n_pareto, out.parquet_path.display(), out.frontier_path.display() ); Ok(()) } /// P6: resolve sim_variants from SweepBase, layering per-variant overrides /// over base scalars. Produces a flat Vec suitable for /// BatchedSimConfig::from_grid. fn resolve_sim_variants(base: &SweepBase) -> Vec { base.sim_variants.iter().map(|v| ml_backtesting::sim::ResolvedSimVariant { name: v.name.clone(), target_annual_vol_units: v.target_annual_vol_units.unwrap_or(base.target_annual_vol_units), annualisation_factor: v.annualisation_factor.unwrap_or(base.annualisation_factor), max_lots: v.max_lots.unwrap_or(base.max_lots), latency_ns: v.latency_ns.unwrap_or(base.latency_ns), kelly_frac_floor: v.kelly_frac_floor.unwrap_or(base.kelly_frac_floor), sharpe_weight_floor: v.sharpe_weight_floor.unwrap_or(base.sharpe_weight_floor), threshold: v.threshold, cost_per_lot_per_side: v.cost_per_lot_per_side, max_hold_ns: v.max_hold_ns, min_reasonable_px: base.min_reasonable_px.unwrap_or(0.0), max_reasonable_px: base.max_reasonable_px.unwrap_or(f32::INFINITY), delta_floor: v.delta_floor.unwrap_or(base.delta_floor), }).collect() } /// P6: run ONE cell with n_parallel=variants.len() backtests sharing the /// forward pass. Each backtest gets distinct (cost, latency, threshold, /// ...) from its ResolvedSimVariant. Output dirs are named `sim_` /// per spec §3.3. fn run_batched_cell( variants: &[ml_backtesting::sim::ResolvedSimVariant], cell: &SweepCell, base: &SweepBase, cell_data: &std::path::Path, cell_out: &std::path::Path, ) -> Result<()> { use ml_alpha::trainer::perception::{PerceptionTrainer, PerceptionTrainerConfig}; use ml_core::device::MlDevice; let n = variants.len(); let names: Vec = variants.iter().map(|v| v.name.clone()).collect(); let batched = ml_backtesting::sim::BatchedSimConfig::from_grid(variants); let dev = MlDevice::cuda(0).context("CUDA device unavailable")?; let seed = cell.seed.unwrap_or(base.seed); let trainer_cfg = PerceptionTrainerConfig { seed, n_batch: 1, kernel_step_trace_path: base.kernel_step_trace.clone(), ..Default::default() }; let checkpoint = cell.checkpoint.clone().or_else(|| base.checkpoint.clone()); let trainer = if let Some(ckpt_path) = &checkpoint { PerceptionTrainer::from_checkpoint(&dev, &trainer_cfg, ckpt_path) .with_context(|| format!("load checkpoint {}", ckpt_path.display()))? } else { let mut cfg2 = trainer_cfg.clone(); cfg2.seed = seed; PerceptionTrainer::new(&dev, &cfg2).context("PerceptionTrainer::new (random init)")? }; let logit_stride = cell.per_horizon_logit_sampling_stride .or(base.per_horizon_logit_sampling_stride); if let Some(s) = logit_stride { anyhow::ensure!(s > 0, "per_horizon_logit_sampling_stride must be > 0 (got 0)"); } let cfg = BacktestHarnessConfig { data_root: cell_data.to_path_buf(), predecoded_dir: base.predecoded_dir.clone().unwrap_or_else(|| cell_data.to_path_buf()), n_parallel: n, target_annual_vol_units: base.target_annual_vol_units, // overridden by sim_config annualisation_factor: base.annualisation_factor, max_lots: base.max_lots, max_events: cell.max_events.unwrap_or(base.max_events), latency_ns: base.latency_ns, kelly_frac_floor: base.kelly_frac_floor, sharpe_weight_floor: base.sharpe_weight_floor, threshold: 0.0, // overridden by sim_config cost_per_lot_per_side: 0.0, // overridden by sim_config variant_names: Some(names), sim_config_override: Some(batched), strategies: Vec::new(), // batched flow uses default policy per backtest kernel_step_trace_path: base.kernel_step_trace.clone(), per_horizon_logit_sampling_stride: base.per_horizon_logit_sampling_stride, }; std::fs::create_dir_all(cell_out) .with_context(|| format!("create cell out {}", cell_out.display()))?; let mut harness = BacktestHarness::new(cfg, &dev, trainer).context("BacktestHarness::new (batched)")?; let stats = harness.run().context("BacktestHarness::run (batched)")?; harness.write_artifacts(cell_out).context("write_artifacts (batched)")?; println!( "batched cell {} done: {} events, {} decisions, {} variants → {}", cell.name, stats.events_processed, stats.decisions_taken, n, cell_out.display(), ); Ok(()) } fn run(args: RunArgs) -> Result<()> { // Resolve strategies — either from YAML or default_for. let strategies: Vec = if let Some(grid_path) = &args.policy_grid { let yaml = std::fs::read_to_string(grid_path) .with_context(|| format!("read policy grid {}", grid_path.display()))?; let parsed: Vec = serde_yaml::from_str(&yaml).context("parse policy grid yaml")?; anyhow::ensure!( parsed.len() == args.n_parallel, "policy grid has {} cells, --n-parallel = {}", parsed.len(), args.n_parallel ); parsed } else { (0..args.n_parallel) .map(|_| Strategy::default_for(args.max_lots)) .collect() }; // If the user provided a policy grid YAML, strategies will be flattened // and uploaded to the per-backtest bytecode-program slots in the sim; // otherwise each cell uses the hardcoded default policy // (WeightedByRealizedSharpe across all 5 horizons). let strategy_grid = if args.policy_grid.is_some() { strategies } else { Vec::new() }; let dev = MlDevice::cuda(0).map_err(|e| anyhow::anyhow!("MlDevice::cuda(0): {e}"))?; // X11 inference path: backtester drives forward via PerceptionTrainer // (in inference role). The trainer wraps a CfcTrunk loaded from a // Checkpoint file; forward kernels live on the trainer and read // weights from `self.trunk` (the post-X1-X9 source of truth). let trainer_cfg = PerceptionTrainerConfig { n_batch: 1, seq_len: 32, kernel_step_trace_path: args.kernel_step_trace.clone(), ..Default::default() }; let trainer = if let Some(ckpt_path) = &args.checkpoint { tracing::info!(checkpoint = %ckpt_path.display(), "loading PerceptionTrainer from checkpoint"); PerceptionTrainer::from_checkpoint(&dev, &trainer_cfg, ckpt_path) .with_context(|| format!("load checkpoint {}", ckpt_path.display()))? } else { tracing::warn!( seed = args.seed, "no --checkpoint provided; using random-init PerceptionTrainer (backtest results are NOISE)" ); let _ = CfcConfig::default; // CfcConfig used transitively via trunk_cfg let _ = N_HORIZONS; let mut cfg2 = trainer_cfg.clone(); cfg2.seed = args.seed; PerceptionTrainer::new(&dev, &cfg2) .context("PerceptionTrainer::new")? }; if let Some(s) = args.per_horizon_logit_sampling_stride { anyhow::ensure!(s > 0, "--per-horizon-logit-sampling-stride must be > 0 (got 0)"); } let cfg = BacktestHarnessConfig { data_root: args.data.clone(), predecoded_dir: args.predecoded_dir.clone().unwrap_or(args.data), n_parallel: args.n_parallel, target_annual_vol_units: args.target_annual_vol_units, annualisation_factor: args.annualisation_factor, max_lots: args.max_lots, max_events: args.max_events, latency_ns: args.latency_ns, kelly_frac_floor: args.kelly_frac_floor, sharpe_weight_floor: args.sharpe_weight_floor, threshold: 0.0, // P4: default = gate disabled cost_per_lot_per_side: 0.0, // P4: default = frictionless variant_names: None, // P6: legacy single-cell flow uses cell_NNNN naming sim_config_override: None, // P6: legacy single-cell flow uses from_uniform strategies: strategy_grid, kernel_step_trace_path: args.kernel_step_trace.clone(), per_horizon_logit_sampling_stride: args.per_horizon_logit_sampling_stride, }; std::fs::create_dir_all(&args.out) .with_context(|| format!("create out dir {}", args.out.display()))?; let mut harness = BacktestHarness::new(cfg, &dev, trainer).context("BacktestHarness::new")?; let stats = harness.run().context("BacktestHarness::run")?; harness.write_artifacts(&args.out).context("write_artifacts")?; println!( "done: {} events processed, {} decisions taken; artifacts under {}", stats.events_processed, stats.decisions_taken, args.out.display() ); Ok(()) }