//! 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>, } /// 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 = 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::() / 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::() / 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 = 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 } }