//! `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>,
    /// Decide every Sth event (matches training stride; default 4).
    #[arg(long, default_value_t = 4)]
    decision_stride: usize,
    /// 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,
}

#[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_decision_stride")] decision_stride: 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>,
}

/// 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>,
}

fn default_n_parallel() -> usize { 1 }
fn default_decision_stride() -> usize { 4 }
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 }

#[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)] decision_stride: Option<usize>,
    #[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>,
}

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.
            decision_stride: cell.decision_stride.unwrap_or(grid.base.decision_stride),
            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(),
        };
        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,
    }).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, ..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 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,
        decision_stride: cell.decision_stride.unwrap_or(base.decision_stride),
        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
    };

    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,
        ..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")?
    };

    let cfg = BacktestHarnessConfig {
        data_root: args.data.clone(),
        predecoded_dir: args.predecoded_dir.clone().unwrap_or(args.data),
        n_parallel: args.n_parallel,
        decision_stride: args.decision_stride,
        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,
    };

    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(())
}
