Skip to main content

quantwave_backtest/
metrics.rs

1//! Performance analytics computed from [`BacktestResult`] (quantwave-cr6v.1).
2//!
3//! Clean-room implementation — concepts aligned with industry practice, no
4//! copied third-party code.
5//!
6//! ## Formula sources (v1)
7//! - **Max drawdown %**: peak-to-trough decline on the equity curve
8//!   ([StockCharts — Drawdown](https://chartschool.stockcharts.com/table-of-contents/overview)).
9//! - **Sharpe ratio**: per-bar returns, annualized with √252 trading days
10//!   ([QuantConnect — Sharpe Ratio](https://www.quantconnect.com/docs/v2/writing-algorithms/indicators/supported-indicators/sharpe-ratio)).
11//! - **Sortino ratio**: same return series; downside deviation uses only
12//!   negative returns (clean-room; analogous to QuantConnect Sortino semantics).
13//! - **CAGR**: `(final/initial)^(252/n_bars) - 1` on daily-bar synthetic tests;
14//!   bar count from equity curve length (clean-room annualization).
15//! - **Win rate / profit factor / avg trade PnL**: aggregated from trade blotter
16//!   `pnl_net` column (clean-room).
17
18use crate::BacktestResult;
19
20/// Bundle of raw backtest output plus computed analytics.
21#[derive(Debug)]
22pub struct BacktestReport {
23    pub result: BacktestResult,
24    pub metrics: PerformanceMetrics,
25}
26
27/// Summary performance statistics for a completed backtest run.
28#[derive(Debug, Clone, PartialEq)]
29pub struct PerformanceMetrics {
30    pub num_trades: f64,
31    pub win_rate: f64,
32    pub profit_factor: f64,
33    pub max_drawdown_pct: f64,
34    pub cagr: f64,
35    pub sharpe_ratio: f64,
36    pub sortino_ratio: f64,
37    pub total_return: f64,
38    pub final_equity: f64,
39    pub avg_trade_pnl: f64,
40}
41
42impl PerformanceMetrics {
43    /// Column names for sweep / tabular export (stable order).
44    pub const fn column_names() -> &'static [&'static str] {
45        &[
46            "num_trades",
47            "win_rate",
48            "profit_factor",
49            "max_drawdown_pct",
50            "cagr",
51            "sharpe_ratio",
52            "sortino_ratio",
53            "total_return",
54            "final_equity",
55            "avg_trade_pnl",
56        ]
57    }
58
59    /// Metric values in [`Self::column_names`] order.
60    pub fn values(&self) -> [f64; 10] {
61        [
62            self.num_trades,
63            self.win_rate,
64            self.profit_factor,
65            self.max_drawdown_pct,
66            self.cagr,
67            self.sharpe_ratio,
68            self.sortino_ratio,
69            self.total_return,
70            self.final_equity,
71            self.avg_trade_pnl,
72        ]
73    }
74
75    /// Iterate (column name, value) pairs for sweep row assembly.
76    pub fn row_iter(&self) -> impl Iterator<Item = (&'static str, f64)> {
77        Self::column_names()
78            .iter()
79            .copied()
80            .zip(self.values())
81    }
82
83    /// Compute metrics from a [`BacktestResult`].
84    ///
85    /// Uses `stats` for initial/final equity when present; falls back to the
86    /// equity curve endpoints.
87    pub fn from_result(result: &BacktestResult) -> Self {
88        let initial_cash = result
89            .stats
90            .get("initial_cash")
91            .copied()
92            .or_else(|| equity_first(result))
93            .unwrap_or(0.0);
94
95        let final_equity = result
96            .stats
97            .get("final_equity")
98            .copied()
99            .or_else(|| equity_last(result))
100            .unwrap_or(initial_cash);
101
102        let total_return = if initial_cash.abs() > f64::EPSILON {
103            (final_equity - initial_cash) / initial_cash
104        } else {
105            0.0
106        };
107
108        let trade_pnls = extract_trade_pnls(result);
109        let num_trades = trade_pnls.len() as f64;
110        let max_drawdown_pct = compute_max_drawdown_pct(result);
111
112        if num_trades == 0.0 && total_return.abs() < 1e-12 {
113            return Self::zero_trades_flat(final_equity, max_drawdown_pct);
114        }
115
116        let (win_rate, profit_factor, avg_trade_pnl) = aggregate_trade_stats(&trade_pnls);
117        let n_bars = equity_len(result);
118        let cagr = compute_cagr(initial_cash, final_equity, n_bars);
119        let returns = per_bar_returns(result);
120        let sharpe_ratio = compute_sharpe(&returns);
121        let sortino_ratio = compute_sortino(&returns);
122
123        Self {
124            num_trades,
125            win_rate,
126            profit_factor,
127            max_drawdown_pct,
128            cagr,
129            sharpe_ratio,
130            sortino_ratio,
131            total_return,
132            final_equity,
133            avg_trade_pnl,
134        }
135    }
136
137    pub fn from_raw(trades: &[crate::Trade], equity: &[crate::EquityPoint], initial_cash: f64) -> Self {
138        let final_equity = equity.last().map(|e| e.equity).unwrap_or(initial_cash);
139        let total_return = if initial_cash.abs() > f64::EPSILON {
140            (final_equity - initial_cash) / initial_cash
141        } else {
142            0.0
143        };
144
145        let mut peak = 0.0;
146        let mut max_drawdown_pct = 0.0;
147        let mut seen = false;
148        for e in equity {
149            let eq = e.equity;
150            if !seen {
151                peak = eq;
152                seen = true;
153            } else if eq > peak {
154                peak = eq;
155            }
156            if peak > f64::EPSILON {
157                let dd = (peak - eq) / peak;
158                if dd > max_drawdown_pct {
159                    max_drawdown_pct = dd;
160                }
161            }
162        }
163
164        let num_trades = trades.len() as f64;
165        if num_trades == 0.0 && total_return.abs() < 1e-12 {
166            return Self::zero_trades_flat(final_equity, max_drawdown_pct);
167        }
168
169        let mut wins = 0.0;
170        let mut gross_profit = 0.0;
171        let mut gross_loss = 0.0;
172        let mut sum_pnl = 0.0;
173        for t in trades {
174            let pnl = t.pnl_net;
175            sum_pnl += pnl;
176            if pnl > 0.0 {
177                wins += 1.0;
178                gross_profit += pnl;
179            } else {
180                gross_loss += pnl.abs();
181            }
182        }
183
184        let win_rate = wins / num_trades;
185        let profit_factor = if gross_loss > f64::EPSILON {
186            gross_profit / gross_loss
187        } else if gross_profit > f64::EPSILON {
188            f64::INFINITY
189        } else {
190            0.0
191        };
192        let avg_trade_pnl = sum_pnl / num_trades;
193
194        let n_bars = equity.len();
195        let cagr = compute_cagr(initial_cash, final_equity, n_bars);
196
197        let returns: Vec<f64> = equity.windows(2).filter_map(|w| {
198            if w[0].equity.abs() > f64::EPSILON {
199                Some((w[1].equity - w[0].equity) / w[0].equity)
200            } else {
201                None
202            }
203        }).collect();
204
205        let sharpe_ratio = compute_sharpe(&returns);
206        let sortino_ratio = compute_sortino(&returns);
207
208        Self {
209            num_trades,
210            win_rate,
211            profit_factor,
212            max_drawdown_pct,
213            cagr,
214            sharpe_ratio,
215            sortino_ratio,
216            total_return,
217            final_equity,
218            avg_trade_pnl,
219        }
220    }
221
222    fn zero_trades_flat(final_equity: f64, max_drawdown_pct: f64) -> Self {
223        Self {
224            num_trades: 0.0,
225            win_rate: 0.0,
226            profit_factor: 0.0,
227            max_drawdown_pct,
228            cagr: 0.0,
229            sharpe_ratio: 0.0,
230            sortino_ratio: 0.0,
231            total_return: 0.0,
232            final_equity,
233            avg_trade_pnl: 0.0,
234        }
235    }
236}
237
238fn extract_trade_pnls(result: &BacktestResult) -> Vec<f64> {
239    let Ok(col) = result.trades.column("pnl_net") else {
240        return Vec::new();
241    };
242    let Ok(ca) = col.f64() else {
243        return Vec::new();
244    };
245    ca.into_iter().map(|v| v.unwrap_or(0.0)).collect()
246}
247
248/// Win rate, profit factor, and average trade PnL from closed-trade `pnl_net` values.
249fn aggregate_trade_stats(pnls: &[f64]) -> (f64, f64, f64) {
250    let n = pnls.len() as f64;
251    if n == 0.0 {
252        return (0.0, 0.0, 0.0);
253    }
254
255    let wins = pnls.iter().filter(|&&p| p > 0.0).count() as f64;
256    let win_rate = wins / n;
257
258    let gross_profit: f64 = pnls.iter().filter(|&&p| p > 0.0).copied().sum();
259    let gross_loss: f64 = pnls
260        .iter()
261        .filter(|&&p| p < 0.0)
262        .map(|p| p.abs())
263        .sum();
264
265    let profit_factor = if gross_loss > f64::EPSILON {
266        gross_profit / gross_loss
267    } else if gross_profit > f64::EPSILON {
268        f64::INFINITY
269    } else {
270        0.0
271    };
272
273    let avg_trade_pnl = pnls.iter().sum::<f64>() / n;
274
275    (win_rate, profit_factor, avg_trade_pnl)
276}
277
278/// Peak-to-trough drawdown on the equity curve as a fraction (0.10 = 10%).
279fn compute_max_drawdown_pct(result: &BacktestResult) -> f64 {
280    let equity = portfolio_equity_values(result);
281    if equity.is_empty() {
282        return 0.0;
283    }
284
285    let mut peak = 0.0;
286    let mut max_dd = 0.0;
287    let mut seen = false;
288
289    for eq in equity {
290        if !seen {
291            peak = eq;
292            seen = true;
293        } else if eq > peak {
294            peak = eq;
295        }
296        if peak > f64::EPSILON {
297            let dd = (peak - eq) / peak;
298            if dd > max_dd {
299                max_dd = dd;
300            }
301        }
302    }
303
304    max_dd
305}
306
307fn equity_len(result: &BacktestResult) -> usize {
308    portfolio_equity_values(result).len()
309}
310
311/// CAGR annualized with 252 trading days per year (clean-room).
312fn compute_cagr(initial: f64, final_equity: f64, n_bars: usize) -> f64 {
313    if initial <= f64::EPSILON || n_bars == 0 {
314        return 0.0;
315    }
316    let ratio = final_equity / initial;
317    if ratio <= 0.0 {
318        return 0.0;
319    }
320    ratio.powf(252.0 / n_bars as f64) - 1.0
321}
322
323fn per_bar_returns(result: &BacktestResult) -> Vec<f64> {
324    let equity = portfolio_equity_values(result);
325    equity
326        .windows(2)
327        .filter_map(|w| {
328            if w[0].abs() > f64::EPSILON {
329                Some((w[1] - w[0]) / w[0])
330            } else {
331                None
332            }
333        })
334        .collect()
335}
336
337const TRADING_DAYS_PER_YEAR: f64 = 252.0;
338
339/// Sharpe ratio: √252 × mean(returns) / std(returns), risk-free = 0.
340fn compute_sharpe(returns: &[f64]) -> f64 {
341    if returns.len() < 2 {
342        return 0.0;
343    }
344    let mean = returns.iter().sum::<f64>() / returns.len() as f64;
345    let variance = returns
346        .iter()
347        .map(|r| {
348            let d = r - mean;
349            d * d
350        })
351        .sum::<f64>()
352        / (returns.len() - 1) as f64;
353    let std = variance.sqrt();
354    if std <= f64::EPSILON {
355        return 0.0;
356    }
357    (mean / std) * TRADING_DAYS_PER_YEAR.sqrt()
358}
359
360/// Sortino ratio: √252 × mean(returns) / downside deviation (negative returns only).
361fn compute_sortino(returns: &[f64]) -> f64 {
362    if returns.is_empty() {
363        return 0.0;
364    }
365    let mean = returns.iter().sum::<f64>() / returns.len() as f64;
366    let downside: Vec<f64> = returns.iter().copied().filter(|&r| r < 0.0).collect();
367    if downside.is_empty() {
368        return f64::INFINITY;
369    }
370    let downside_var = downside.iter().map(|r| r * r).sum::<f64>() / downside.len() as f64;
371    let downside_std = downside_var.sqrt();
372    if downside_std <= f64::EPSILON {
373        return f64::INFINITY;
374    }
375    (mean / downside_std) * TRADING_DAYS_PER_YEAR.sqrt()
376}
377
378/// Equity series for analytics. When a `symbol` column exists, use portfolio rows
379/// (`symbol` null) to avoid double-counting per-symbol curves.
380fn portfolio_equity_values(result: &BacktestResult) -> Vec<f64> {
381    let Ok(eq_col) = result.equity_curve.column("equity") else {
382        return Vec::new();
383    };
384    let Ok(eq_ca) = eq_col.f64() else {
385        return Vec::new();
386    };
387
388    if let Ok(sym_col) = result.equity_curve.column("symbol") {
389        if let Ok(sym_ca) = sym_col.str() {
390            return eq_ca
391                .into_iter()
392                .zip(sym_ca.into_iter())
393                .filter_map(|(eq, sym)| {
394                    if sym.is_none() {
395                        eq
396                    } else {
397                        None
398                    }
399                })
400                .collect();
401        }
402    }
403
404    eq_ca.into_iter().flatten().collect()
405}
406
407fn equity_first(result: &BacktestResult) -> Option<f64> {
408    portfolio_equity_values(result).first().copied()
409}
410
411fn equity_last(result: &BacktestResult) -> Option<f64> {
412    portfolio_equity_values(result).last().copied()
413}