Files
foxhunt/crates/ml-backtesting/src/artifacts.rs
jgrusewski 0ddc861708 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).
2026-05-19 08:44:46 +02:00

318 lines
11 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.
//! Per-cell backtest output writers + summary statistics.
//!
//! See docs/superpowers/specs/2026-05-18-real-lob-integration-design.md §7
//! "Output artifacts".
//!
//! summary.json top-level stats (Sharpe, drawdown, profit factor, etc.)
//! trades.csv per-trade audit log
//! pnl_curve.bin binary float32 cumulative P&L at each event timestamp
//!
//! Annualisation uses the non-overlapping convention per
//! `pearl_phase1d4_backtest_cost_edge_frontier`: σ_ann = σ_trade × √825
//! (K=6000 holding period, 250 trading days = ~825 non-overlapping trades/yr).
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::Path;
use crate::order::TradeRecord;
/// Top-level per-cell summary. Serialised to summary.json.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Summary {
pub total_pnl_usd: f32,
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,
pub avg_win_usd: f32,
pub avg_loss_usd: f32,
pub profit_factor: f32,
pub total_fees_usd: f32,
/// Fraction of decision points where any backtest held a non-flat
/// position. Populated by the harness if it tracks bars-in-position;
/// 0.0 placeholder when unmeasured.
pub exposure_pct: f32,
/// Downsampled per-horizon Kelly cap trace (entries × N_HORIZONS).
/// Empty if the harness doesn't record per-decision Kelly caps;
/// when populated, used by aggregate/diagnostics.
#[serde(default)]
pub kelly_cap_history_sample: Vec<Vec<f32>>,
}
/// Non-overlapping annualisation factor (√825) per
/// `pearl_phase1d4_backtest_cost_edge_frontier.md`. Pinned here so
/// `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 {
fp as f32 / 100.0
}
/// Compute summary statistics from the per-cell trade log + cumulative
/// P&L curve (USD). The curve is sampled at every event; the harness
/// passes the same vector it serialises to pnl_curve.bin.
pub fn compute_summary(records: &[TradeRecord], pnl_curve_usd: &[f32]) -> Summary {
if records.is_empty() {
return Summary {
total_pnl_usd: 0.0,
n_trades: 0,
..Default::default()
};
}
let n_trades = records.len() as u64;
let mut wins = 0u64;
let mut losses = 0u64;
let mut win_sum = 0.0f32;
let mut loss_sum = 0.0f32;
let mut total_fees = 0.0f32;
let mut per_trade_returns: Vec<f32> = Vec::with_capacity(records.len());
for r in records {
let p = fp_to_usd(r.realised_pnl_usd_fp);
total_fees += fp_to_usd(r.fees_usd_fp);
per_trade_returns.push(p);
if p > 0.0 {
wins += 1;
win_sum += p;
} else if p < 0.0 {
losses += 1;
loss_sum += -p;
}
}
let total_pnl_usd: f32 = per_trade_returns.iter().sum();
let win_rate = wins as f32 / n_trades as f32;
let avg_win = if wins > 0 { win_sum / wins as f32 } else { 0.0 };
let avg_loss = if losses > 0 { loss_sum / losses as f32 } else { 0.0 };
let profit_factor = if loss_sum > 0.0 {
win_sum / loss_sum
} else if win_sum > 0.0 {
f32::INFINITY
} else {
0.0
};
// Per-trade Sharpe + Sortino.
let mean = total_pnl_usd / n_trades as f32;
let var: f32 = per_trade_returns
.iter()
.map(|p| (p - mean).powi(2))
.sum::<f32>()
/ n_trades as f32;
let std_dev = var.sqrt().max(1e-9);
let sharpe_per_trade = mean / std_dev;
let sharpe_ann = sharpe_per_trade * ANNUALISATION_SQRT_FACTOR;
let downside_var: f32 = per_trade_returns
.iter()
.filter_map(|p| if *p < mean { Some((p - mean).powi(2)) } else { None })
.sum::<f32>()
/ n_trades as f32;
let downside_std = downside_var.sqrt().max(1e-9);
let sortino_per_trade = mean / downside_std;
let sortino_ann = sortino_per_trade * ANNUALISATION_SQRT_FACTOR;
// Max drawdown from the cumulative P&L curve.
let (max_drawdown_usd, _peak_at_dd) = if pnl_curve_usd.is_empty() {
(0.0, 0.0)
} else {
let mut peak = pnl_curve_usd[0];
let mut dd = 0.0f32;
let mut peak_at_dd = peak;
for &v in pnl_curve_usd {
if v > peak {
peak = v;
}
let cur_dd = peak - v;
if cur_dd > dd {
dd = cur_dd;
peak_at_dd = peak;
}
}
(dd, peak_at_dd)
};
let calmar = if max_drawdown_usd > 0.0 {
total_pnl_usd / max_drawdown_usd
} else if total_pnl_usd > 0.0 {
f32::INFINITY
} else {
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,
avg_win_usd: avg_win,
avg_loss_usd: avg_loss,
profit_factor,
total_fees_usd: total_fees,
exposure_pct: 0.0, // populated by harness if it tracks this
kelly_cap_history_sample: Vec::new(),
}
}
/// Write `summary.json` to the given path.
pub fn write_summary(path: &Path, s: &Summary) -> Result<()> {
let f = std::fs::File::create(path)
.with_context(|| format!("create {}", path.display()))?;
serde_json::to_writer_pretty(f, s).context("write summary.json")?;
Ok(())
}
/// Write `trades.csv` to the given path. Side is rendered "buy"/"sell"
/// from the sign of `size_lots`; price columns are converted from
/// fixed-point ×100 ticks back to float-tick values for human readability.
pub fn write_trades_csv(path: &Path, records: &[TradeRecord]) -> Result<()> {
let mut f = std::fs::File::create(path)
.with_context(|| format!("create {}", path.display()))?;
writeln!(
f,
"entry_ts_ns,exit_ts_ns,side,size_lots,entry_px_ticks,exit_px_ticks,fees_usd,realised_pnl_usd,strategy_id,horizon_idx"
)?;
for r in records {
let side = if r.size_lots > 0 { "buy" } else { "sell" };
let entry_px = r.entry_px_ticks as f32 / 100.0;
let exit_px = r.exit_px_ticks as f32 / 100.0;
writeln!(
f,
"{},{},{},{},{:.2},{:.2},{:.2},{:.2},{},{}",
r.entry_ts_ns,
r.exit_ts_ns,
side,
r.size_lots.abs(),
entry_px,
exit_px,
fp_to_usd(r.fees_usd_fp),
fp_to_usd(r.realised_pnl_usd_fp),
r.strategy_id,
r.horizon_idx,
)?;
}
Ok(())
}
/// Write the cumulative P&L curve as a packed binary float32 array.
pub fn write_pnl_curve_bin(path: &Path, curve_usd: &[f32]) -> Result<()> {
let mut f = std::fs::File::create(path)
.with_context(|| format!("create {}", path.display()))?;
let bytes: &[u8] = bytemuck::cast_slice(curve_usd);
f.write_all(bytes).context("write pnl_curve.bin")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn trade(entry_ts: u64, exit_ts: u64, size: i32, pnl_fp: i32) -> TradeRecord {
TradeRecord {
entry_ts_ns: entry_ts,
exit_ts_ns: exit_ts,
entry_px_ticks: 550_000,
exit_px_ticks: 550_500,
size_lots: size,
fees_usd_fp: 0,
realised_pnl_usd_fp: pnl_fp,
horizon_idx: 4,
strategy_id: 0,
_pad: [0; 2],
}
}
#[test]
fn empty_records_summary_is_zero() {
let s = compute_summary(&[], &[]);
assert_eq!(s.n_trades, 0);
assert_eq!(s.total_pnl_usd, 0.0);
assert_eq!(s.profit_factor, 0.0);
}
#[test]
fn three_wins_one_loss_summary() {
let recs = vec![
trade(1_000_000_000, 2_000_000_000, 1, 5000), // +$50
trade(3_000_000_000, 4_000_000_000, 1, 7500), // +$75
trade(5_000_000_000, 6_000_000_000, -1, -2500), // -$25
trade(7_000_000_000, 8_000_000_000, 1, 10000), // +$100
];
// Cumulative pnl curve sampled at trade-close events.
let curve = vec![0.0, 50.0, 125.0, 100.0, 200.0];
let s = compute_summary(&recs, &curve);
assert_eq!(s.n_trades, 4);
assert!((s.total_pnl_usd - 200.0).abs() < 0.01);
assert!((s.win_rate - 0.75).abs() < 0.01);
assert!((s.avg_win_usd - 75.0).abs() < 0.01);
assert!((s.avg_loss_usd - 25.0).abs() < 0.01);
assert!((s.profit_factor - (225.0 / 25.0)).abs() < 0.01);
// Drawdown of $25 from peak $125 to trough $100.
assert!((s.max_drawdown_usd - 25.0).abs() < 0.01);
}
#[test]
fn write_then_read_pnl_curve_bin_roundtrip() {
let dir = tempfile::tempdir().expect("tmpdir");
let path = dir.path().join("pnl_curve.bin");
let curve = vec![0.0f32, 10.0, 25.5, -3.25, 100.125];
write_pnl_curve_bin(&path, &curve).unwrap();
let raw = std::fs::read(&path).unwrap();
let read_back: Vec<f32> = bytemuck::cast_slice(&raw).to_vec();
assert_eq!(read_back, curve);
}
#[test]
fn write_summary_then_parse_back() {
let dir = tempfile::tempdir().expect("tmpdir");
let path = dir.path().join("summary.json");
let s = Summary {
total_pnl_usd: 123.45,
sharpe_ann: 1.5,
n_trades: 7,
win_rate: 0.6,
..Default::default()
};
write_summary(&path, &s).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let back: Summary = serde_json::from_str(&raw).unwrap();
assert!((back.total_pnl_usd - 123.45).abs() < 0.01);
assert_eq!(back.n_trades, 7);
}
#[test]
fn write_trades_csv_header_and_one_row() {
let dir = tempfile::tempdir().expect("tmpdir");
let path = dir.path().join("trades.csv");
let recs = vec![trade(1_000_000_000, 2_000_000_000, 3, 12345)];
write_trades_csv(&path, &recs).unwrap();
let s = std::fs::read_to_string(&path).unwrap();
assert!(s.contains("entry_ts_ns,exit_ts_ns,side"));
assert!(s.contains("1000000000,2000000000,buy,3"));
assert!(s.contains("123.45")); // $123.45 from fp 12345
}
}