Files
foxhunt/bin/fxt-backtest/src/main.rs
jgrusewski ff9207e7bb feat(per-horizon-cfc): plumb kernel-step-trace into fxt-backtest inference path
Mirrors the alpha_train CLI flag (--kernel-step-trace <path>) into
fxt-backtest. The PerceptionTrainerConfig already accepts the path;
this commit just exposes the CLI surface and propagates through:

  fxt-backtest run --kernel-step-trace <path>
  -> BacktestHarnessConfig.kernel_step_trace_path
  -> PerceptionTrainerConfig.kernel_step_trace_path

Sweep YAML gains a `kernel_step_trace: Option<PathBuf>` base field.
Argo lob-backtest-sweep-template gains a `kernel-step-trace` workflow
parameter (default disabled; when set, --features kernel-step-trace
is passed to cargo build AND --kernel-step-trace to fxt-backtest).

Gated behind the `kernel-step-trace` Cargo feature (matching alpha_train).
When disabled (default), zero overhead.

Enables per-step JSONL diagnostic emission from inference kernels --
needed for Smoke 2 deep-dive after sampling histograms (in parallel
investigation) localize the per-horizon dynamics bottleneck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:33:44 +02:00

632 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! `fxt-backtest` — Real-LOB GPU backtest CLI.
//!
//! Two subcommands:
//!
//! fxt-backtest run --data <mbp10-dir> [--policy-grid <yaml>] ...
//! Runs the LOB backtest harness on a directory of MBP-10 dbn files.
//! Writes per-cell artifacts under --out/cell_<NNNN>/.
//!
//! fxt-backtest aggregate <sweep-dir>
//! Walks every cell directory under <sweep-dir>, 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 `<out>/<cell_name>/`, 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<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// Output directory; per-cell artifacts written to <out>/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/<sha>/step_trace.jsonl`
#[arg(long)]
kernel_step_trace: Option<PathBuf>,
/// 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<u32>,
}
#[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_<NNNN>_cost<C>_lat<L>_th<T>_<W>`), 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<SweepCell>,
}
#[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<PathBuf>,
/// 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<String>,
#[serde(default)]
predecoded_dir: Option<PathBuf>,
#[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<PathBuf>,
#[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<SimVariant>,
/// 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<f32>,
/// None = no upper bound (f32::INFINITY passed to kernel).
#[serde(default)] max_reasonable_px: Option<f32>,
/// 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<PathBuf>,
/// 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<u32>,
}
/// 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<u32>,
#[serde(default)] target_annual_vol_units: Option<f32>,
#[serde(default)] annualisation_factor: Option<f32>,
#[serde(default)] max_lots: Option<u16>,
#[serde(default)] kelly_frac_floor: Option<f32>,
#[serde(default)] sharpe_weight_floor: Option<f32>,
/// 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<f32>,
}
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<String>,
#[serde(default)] latency_ns: Option<u32>,
#[serde(default)] target_annual_vol_units: Option<f32>,
#[serde(default)] annualisation_factor: Option<f32>,
#[serde(default)] max_lots: Option<u16>,
#[serde(default)] max_events: Option<u64>,
#[serde(default)] seed: Option<u64>,
#[serde(default)] checkpoint: Option<PathBuf>,
#[serde(default)] kelly_frac_floor: Option<f32>,
#[serde(default)] sharpe_weight_floor: Option<f32>,
/// 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<u32>,
}
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_<name>/ 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<ResolvedSimVariant> suitable for
/// BatchedSimConfig::from_grid.
fn resolve_sim_variants(base: &SweepBase) -> Vec<ml_backtesting::sim::ResolvedSimVariant> {
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_<name>`
/// 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<String> = 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<Strategy> = 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<Strategy> =
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(())
}