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).
318 lines
11 KiB
Rust
318 lines
11 KiB
Rust
//! 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
|
||
}
|
||
}
|