From 58b5ebbd3819ce7fcb70442f08413a5704bd97c3 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Tue, 19 May 2026 09:24:01 +0200 Subject: [PATCH] feat(fxt-backtest): verdict subcommand wraps emit_deployability_verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `fxt-backtest verdict --threshold X --windows W1,W2,W3,W4 --training-sha SHA --spec-sha SHA --out deployability_verdict.json` that reads per-cell summary.json files at the realistic (1 tick, 200ms) + stress (1.5 tick, 400ms) anchors against the pre-registered threshold, computes median Sharpe/max_dd/Sortino/profit_factor across windows, classifies into Pass-robust / Pass-nominal / Fail-inconclusive / Fail / Fail-degenerate per spec §3.5, and writes the audit JSON. Adds serde_json workspace dep to fxt-backtest's Cargo.toml (was already a transitive dep but not declared at this layer). End-to-end CLI for Phase 2 runtime is now: argo-train.sh → argo-lob-sweep.sh (smoke / threshold-tuning / deployability) → fxt-backtest aggregate → fxt-backtest verdict → commit deployability_verdict.json. --- bin/fxt-backtest/Cargo.toml | 1 + bin/fxt-backtest/src/main.rs | 60 +++++++++++++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/bin/fxt-backtest/Cargo.toml b/bin/fxt-backtest/Cargo.toml index bb414fe0f..956a4431f 100644 --- a/bin/fxt-backtest/Cargo.toml +++ b/bin/fxt-backtest/Cargo.toml @@ -20,6 +20,7 @@ ml-core.workspace = true anyhow.workspace = true clap = { workspace = true, features = ["derive", "env"] } serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true serde_yaml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true diff --git a/bin/fxt-backtest/src/main.rs b/bin/fxt-backtest/src/main.rs index ad57c1920..f147ce222 100644 --- a/bin/fxt-backtest/src/main.rs +++ b/bin/fxt-backtest/src/main.rs @@ -17,7 +17,7 @@ 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; +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; @@ -43,6 +43,13 @@ enum Cmd { /// 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)] @@ -104,6 +111,32 @@ struct AggregateArgs { 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 @@ -182,6 +215,31 @@ fn main() -> Result<()> { 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(()) + } } }