feat(ml-backtesting): max_drawdown_pct + deployability verdict emitter (X16+X17)

X16: Adds Summary.max_drawdown_pct field as |max_drawdown_usd| /
STARTING_CAPITAL_USD (pinned at $35k per project_ml_alpha_starting_capital
memory — realistic ES single-contract small-account anchor). Used by
the verdict emitter as the capital-deployability hard gate (median
across windows < 20%).

X17: Adds VerdictTier enum (Pass-robust / Pass-nominal / Fail-inconclusive
/ Fail / Fail-degenerate), AnchorSpec / AnchorReport / DeployabilityVerdict
types, classify_verdict (tiered logic per spec §3.5), and
emit_deployability_verdict that reads per-cell summary.json files at
both realistic (1.0 tick, 200 ms) and stress (1.5 tick, 400 ms) anchors,
computes median Sharpe / Sortino / max_dd_pct / profit_factor across
walk-forward windows, and applies the gates.

Per spec §3.2 (X16), §3.5 (X17).
This commit is contained in:
jgrusewski
2026-05-19 08:44:46 +02:00
parent 7545651bce
commit 0ddc861708
2 changed files with 201 additions and 0 deletions

View File

@@ -16,6 +16,193 @@ use std::path::Path;
use std::sync::Arc;
use crate::artifacts::Summary;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// X17: Tiered verdict for the deployability validation.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum VerdictTier {
PassRobust,
PassNominal,
FailInconclusive,
Fail,
FailDegenerate,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AnchorSpec {
pub name: String,
pub cost_tick: f32,
pub latency_ms: u32,
}
impl AnchorSpec {
pub fn realistic() -> Self {
Self { name: "realistic".to_string(), cost_tick: 1.0, latency_ms: 200 }
}
pub fn stress() -> Self {
Self { name: "stress".to_string(), cost_tick: 1.5, latency_ms: 400 }
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct AnchorReport {
pub anchor: AnchorSpec,
pub sharpe_per_window: Vec<f32>,
pub sortino_per_window: Vec<f32>,
pub max_dd_pct_per_window: Vec<f32>,
pub profit_factor_per_window: Vec<f32>,
pub median_sharpe: f32,
pub median_sortino: f32,
pub median_max_dd_pct: f32,
pub median_profit_factor: f32,
pub gate_sharpe: bool,
pub gate_max_dd: bool,
pub pass: bool,
pub status: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DeployabilityVerdict {
pub verdict: VerdictTier,
pub realistic: AnchorReport,
pub stress: AnchorReport,
pub threshold: f32,
pub windows: Vec<String>,
pub training_sha: String,
pub spec_sha: String,
pub timestamp_utc: String,
}
/// X17: tiered verdict classifier. Spec §3.5.
pub fn classify_verdict(realistic: &AnchorReport, stress: &AnchorReport) -> VerdictTier {
if realistic.status != "ok" {
return VerdictTier::FailDegenerate;
}
if realistic.pass && stress.status == "ok" && stress.pass {
return VerdictTier::PassRobust;
}
if realistic.pass {
return VerdictTier::PassNominal;
}
let sharpe_grey = realistic.median_sharpe >= 0.8 && realistic.median_sharpe < 1.0;
let max_dd_grey = realistic.median_max_dd_pct >= 0.20 && realistic.median_max_dd_pct < 0.25;
if sharpe_grey || max_dd_grey {
return VerdictTier::FailInconclusive;
}
VerdictTier::Fail
}
fn median(xs: &[f32]) -> f32 {
let mut v = xs.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = v.len();
if n == 0 { return f32::NAN; }
if n % 2 == 1 { v[n / 2] } else { 0.5 * (v[n / 2 - 1] + v[n / 2]) }
}
/// Parse a cell directory name like
/// `cell_0042_cost1.00_lat200_th0.75_W1` into its (cost_tick, latency_ms,
/// threshold, window) tuple. Returns None on non-matching names so
/// callers can skip them.
fn parse_cell_name(name: &str) -> Option<(f32, u32, f32, String)> {
let parts: Vec<&str> = name.split('_').collect();
if parts.len() < 5 || parts[0] != "cell" { return None; }
let cost: f32 = parts.iter().find(|p| p.starts_with("cost"))?[4..].parse().ok()?;
let lat: u32 = parts.iter().find(|p| p.starts_with("lat"))?[3..].parse().ok()?;
let th: f32 = parts.iter().find(|p| p.starts_with("th"))?[2..].parse().ok()?;
let win = parts.iter().rev().find(|p| p.starts_with('W'))?.to_string();
Some((cost, lat, th, win))
}
fn build_anchor_report(
sweep_dir: &Path,
anchor: AnchorSpec,
threshold: f32,
windows: &[&str],
) -> Result<AnchorReport> {
let mut by_window: BTreeMap<String, Summary> = BTreeMap::new();
for entry in std::fs::read_dir(sweep_dir)
.with_context(|| format!("read_dir {}", sweep_dir.display()))?
{
let path = entry?.path();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string();
let (cell_cost, cell_lat, cell_th, cell_win) = match parse_cell_name(&name) {
Some(t) => t,
None => continue,
};
if (cell_cost - anchor.cost_tick).abs() > 1e-6 { continue; }
if cell_lat != anchor.latency_ms { continue; }
if (cell_th - threshold).abs() > 1e-6 { continue; }
if !windows.iter().any(|w| **w == cell_win) { continue; }
let summary_path = path.join("summary.json");
let s: Summary = serde_json::from_reader(
std::fs::File::open(&summary_path)
.with_context(|| format!("open {}", summary_path.display()))?,
)?;
by_window.insert(cell_win.clone(), s);
}
let mut sharpe = Vec::new();
let mut sortino = Vec::new();
let mut max_dd = Vec::new();
let mut pf = Vec::new();
let mut status = String::from("ok");
for w in windows {
let s = by_window.get(*w).ok_or_else(||
anyhow::anyhow!("missing cell for anchor={} window={}", anchor.name, w)
)?;
if s.n_trades == 0 || !s.sharpe_ann.is_finite() || !s.sortino_ann.is_finite() {
status = format!("fail-degenerate: {w} trades={} sharpe={}", s.n_trades, s.sharpe_ann);
}
sharpe.push(s.sharpe_ann);
sortino.push(s.sortino_ann);
max_dd.push(s.max_drawdown_pct);
pf.push(s.profit_factor);
}
let median_sharpe = median(&sharpe);
let median_sortino = median(&sortino);
let median_max_dd_pct = median(&max_dd);
let median_profit_factor = median(&pf);
let gate_sharpe = median_sharpe > 1.0;
let gate_max_dd = median_max_dd_pct < 0.20;
let pass = gate_sharpe && gate_max_dd && status == "ok";
Ok(AnchorReport {
anchor,
sharpe_per_window: sharpe,
sortino_per_window: sortino,
max_dd_pct_per_window: max_dd,
profit_factor_per_window: pf,
median_sharpe, median_sortino, median_max_dd_pct, median_profit_factor,
gate_sharpe, gate_max_dd, pass,
status,
})
}
/// X17: emit the full deployability verdict by aggregating per-cell
/// summary.json files at both anchors against the supplied threshold +
/// walk-forward window IDs. Spec §3.5.
pub fn emit_deployability_verdict(
sweep_dir: &Path,
threshold: f32,
windows: &[&str],
training_sha: &str,
spec_sha: &str,
) -> Result<DeployabilityVerdict> {
let realistic = build_anchor_report(sweep_dir, AnchorSpec::realistic(), threshold, windows)?;
let stress = build_anchor_report(sweep_dir, AnchorSpec::stress(), threshold, windows)?;
let verdict = classify_verdict(&realistic, &stress);
Ok(DeployabilityVerdict {
verdict,
realistic,
stress,
threshold,
windows: windows.iter().map(|s| s.to_string()).collect(),
training_sha: training_sha.to_string(),
spec_sha: spec_sha.to_string(),
timestamp_utc: chrono::Utc::now().to_rfc3339(),
})
}
/// One row of the aggregate parquet: cell name + a flattened subset of
/// `Summary` fields the user is most likely to filter / sort by.

View File

@@ -25,6 +25,11 @@ pub struct Summary {
pub sharpe_ann: f32,
pub sortino_ann: f32,
pub max_drawdown_usd: f32,
/// X16: max drawdown as a fraction of starting capital. Computed
/// in compute_summary using STARTING_CAPITAL_USD ($35k per project
/// convention; see memory `project_ml_alpha_starting_capital`).
/// Range [0.0, 1.0]; 0.20 = 20% drawdown.
pub max_drawdown_pct: f32,
pub calmar: f32,
pub n_trades: u64,
pub win_rate: f32,
@@ -48,6 +53,13 @@ pub struct Summary {
/// `aggregate` reads from the same constant if it ever recomputes.
pub const ANNUALISATION_SQRT_FACTOR: f32 = 28.722_815;
/// X16: starting capital in USD used as the base for max_drawdown_pct.
/// $35k is the project's chosen base for ES single-contract analysis
/// — realistic for a small-account ES trader (margin ~$15k initial,
/// ~$20k headroom for adverse moves before a margin call). See memory
/// `project_ml_alpha_starting_capital`.
pub const STARTING_CAPITAL_USD: f32 = 35_000.0;
/// Convert TradeRecord fixed-point USD ×100 to plain USD float.
#[inline]
fn fp_to_usd(fp: i32) -> f32 {
@@ -146,11 +158,13 @@ pub fn compute_summary(records: &[TradeRecord], pnl_curve_usd: &[f32]) -> Summar
0.0
};
let max_drawdown_pct = max_drawdown_usd.abs() / STARTING_CAPITAL_USD;
Summary {
total_pnl_usd,
sharpe_ann,
sortino_ann,
max_drawdown_usd,
max_drawdown_pct,
calmar,
n_trades,
win_rate,