Files
foxhunt/crates/ml-backtesting/src/artifacts.rs
jgrusewski 63ed6d0217 feat(ml-backtesting): artifacts + sweep aggregate + fxt-backtest CLI (C9)
artifacts.rs:
  - Summary struct (total_pnl_usd, sharpe_ann, sortino_ann,
    max_drawdown_usd, calmar, n_trades, win_rate, avg_win/avg_loss,
    profit_factor, total_fees_usd, exposure_pct,
    kelly_cap_history_sample).
  - compute_summary(records, pnl_curve_usd) — non-overlapping
    annualisation × √825 per pearl_phase1d4_backtest_cost_edge_frontier
    (K=6000 holding × 250 trading days ≈ 825 trades/year).
    Sharpe + Sortino + max drawdown + Calmar.
  - write_summary (JSON pretty-printed), write_trades_csv (with USD
    conversion from fp ×100), write_pnl_curve_bin (bytemuck-cast f32
    slice). 5 unit tests with tempdir.

aggregate.rs:
  - aggregate_sweep_dir walks <root>/<cell>/summary.json, builds an
    arrow RecordBatch (cell name + 9 stats columns), writes
    SNAPPY-compressed aggregate.parquet.
  - pareto_frontier: cells are kept unless another cell weakly
    dominates on all three of (sharpe_ann maxed, max_drawdown_usd
    minimised, total_fees_usd minimised) AND strictly improves on one.
    Written to pareto_frontier.json (Vec<cell-name>).
  - 2 unit tests (3-cell mutual-non-dominance; B-dominates-A).

harness.rs:
  - run() now samples Pos.realized_pnl × $50/index-pt per event into
    self.pnl_curves[b], so the per-cell P&L curve is ready for
    write_artifacts() without an extra sim pass.
  - write_artifacts(out_dir) — per-cell <out>/cell_NNNN/{summary.json,
    trades.csv, pnl_curve.bin}.

bin/fxt-backtest:
  - clap-derive CLI with two subcommands:
      run --data <dir> [--predecoded-dir <dir>] [--policy-grid <yaml>]
          [--n-parallel N] [--decision-stride S] [--latency-ns N]
          [--target-annual-vol-units F] [--annualisation-factor F]
          [--max-lots N] [--max-events N] [--seed N] --out <dir>
      aggregate <sweep_dir>
  - Constructs MlDevice::cuda(0) + CfcTrunk::new_random for the trunk
    (v1 — ml-alpha has no checkpoint format yet; --seed gates init).
  - Parses --policy-grid YAML if given but doesn't yet plumb to the
    LobSimCuda decision kernel (the v1 kernel hardcodes the
    Strategy::default_for path; bytecode VM is C7's deferred follow-up).
    Parse step kept end-to-end so the YAML format is validated now.
  - --latency-ns parsed but not consumed — reserved for follow-up
    resting-order in-flight promotion (deferred from C5).

Adds parquet + arrow + arrow-array + arrow-schema + serde_yaml to
ml-backtesting deps; bin/fxt-backtest added to workspace members.

All 33 lib tests + 6 GPU fixture tests green. CLI --help renders both
subcommands correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 08:59:41 +02:00

304 lines
10 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,
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;
/// 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
};
Summary {
total_pnl_usd,
sharpe_ann,
sortino_ann,
max_drawdown_usd,
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
}
}