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().iter().copied().zip(self.values())
78    }
79
80    /// Compute metrics from a [`BacktestResult`].
81    ///
82    /// Uses `stats` for initial/final equity when present; falls back to the
83    /// equity curve endpoints.
84    pub fn from_result(result: &BacktestResult) -> Self {
85        let initial_cash = result
86            .stats
87            .get("initial_cash")
88            .copied()
89            .or_else(|| equity_first(result))
90            .unwrap_or(0.0);
91
92        let final_equity = result
93            .stats
94            .get("final_equity")
95            .copied()
96            .or_else(|| equity_last(result))
97            .unwrap_or(initial_cash);
98
99        let total_return = if initial_cash.abs() > f64::EPSILON {
100            (final_equity - initial_cash) / initial_cash
101        } else {
102            0.0
103        };
104
105        let trade_pnls = extract_trade_pnls(result);
106        let num_trades = trade_pnls.len() as f64;
107        let max_drawdown_pct = compute_max_drawdown_pct(result);
108
109        if num_trades == 0.0 && total_return.abs() < 1e-12 {
110            return Self::zero_trades_flat(final_equity, max_drawdown_pct);
111        }
112
113        let (win_rate, profit_factor, avg_trade_pnl) = aggregate_trade_stats(&trade_pnls);
114        let n_bars = equity_len(result);
115        let cagr = compute_cagr(initial_cash, final_equity, n_bars);
116        let returns = per_bar_returns(result);
117        let sharpe_ratio = compute_sharpe(&returns);
118        let sortino_ratio = compute_sortino(&returns);
119
120        Self {
121            num_trades,
122            win_rate,
123            profit_factor,
124            max_drawdown_pct,
125            cagr,
126            sharpe_ratio,
127            sortino_ratio,
128            total_return,
129            final_equity,
130            avg_trade_pnl,
131        }
132    }
133
134    pub fn from_raw(
135        trades: &[crate::Trade],
136        equity: &[crate::EquityPoint],
137        initial_cash: f64,
138    ) -> Self {
139        let final_equity = equity.last().map(|e| e.equity).unwrap_or(initial_cash);
140        let total_return = if initial_cash.abs() > f64::EPSILON {
141            (final_equity - initial_cash) / initial_cash
142        } else {
143            0.0
144        };
145
146        let mut peak = 0.0;
147        let mut max_drawdown_pct = 0.0;
148        let mut seen = false;
149        for e in equity {
150            let eq = e.equity;
151            if !seen {
152                peak = eq;
153                seen = true;
154            } else if eq > peak {
155                peak = eq;
156            }
157            if peak > f64::EPSILON {
158                let dd = (peak - eq) / peak;
159                if dd > max_drawdown_pct {
160                    max_drawdown_pct = dd;
161                }
162            }
163        }
164
165        let num_trades = trades.len() as f64;
166        if num_trades == 0.0 && total_return.abs() < 1e-12 {
167            return Self::zero_trades_flat(final_equity, max_drawdown_pct);
168        }
169
170        let mut wins = 0.0;
171        let mut gross_profit = 0.0;
172        let mut gross_loss = 0.0;
173        let mut sum_pnl = 0.0;
174        for t in trades {
175            let pnl = t.pnl_net;
176            sum_pnl += pnl;
177            if pnl > 0.0 {
178                wins += 1.0;
179                gross_profit += pnl;
180            } else {
181                gross_loss += pnl.abs();
182            }
183        }
184
185        let win_rate = wins / num_trades;
186        let profit_factor = if gross_loss > f64::EPSILON {
187            gross_profit / gross_loss
188        } else if gross_profit > f64::EPSILON {
189            f64::INFINITY
190        } else {
191            0.0
192        };
193        let avg_trade_pnl = sum_pnl / num_trades;
194
195        let n_bars = equity.len();
196        let cagr = compute_cagr(initial_cash, final_equity, n_bars);
197
198        let returns: Vec<f64> = equity
199            .windows(2)
200            .filter_map(|w| {
201                if w[0].equity.abs() > f64::EPSILON {
202                    Some((w[1].equity - w[0].equity) / w[0].equity)
203                } else {
204                    None
205                }
206            })
207            .collect();
208
209        let sharpe_ratio = compute_sharpe(&returns);
210        let sortino_ratio = compute_sortino(&returns);
211
212        Self {
213            num_trades,
214            win_rate,
215            profit_factor,
216            max_drawdown_pct,
217            cagr,
218            sharpe_ratio,
219            sortino_ratio,
220            total_return,
221            final_equity,
222            avg_trade_pnl,
223        }
224    }
225
226    fn zero_trades_flat(final_equity: f64, max_drawdown_pct: f64) -> Self {
227        Self {
228            num_trades: 0.0,
229            win_rate: 0.0,
230            profit_factor: 0.0,
231            max_drawdown_pct,
232            cagr: 0.0,
233            sharpe_ratio: 0.0,
234            sortino_ratio: 0.0,
235            total_return: 0.0,
236            final_equity,
237            avg_trade_pnl: 0.0,
238        }
239    }
240}
241
242fn extract_trade_pnls(result: &BacktestResult) -> Vec<f64> {
243    let Ok(col) = result.trades.column("pnl_net") else {
244        return Vec::new();
245    };
246    let Ok(ca) = col.f64() else {
247        return Vec::new();
248    };
249    ca.into_iter().map(|v| v.unwrap_or(0.0)).collect()
250}
251
252/// Win rate, profit factor, and average trade PnL from closed-trade `pnl_net` values.
253fn aggregate_trade_stats(pnls: &[f64]) -> (f64, f64, f64) {
254    let n = pnls.len() as f64;
255    if n == 0.0 {
256        return (0.0, 0.0, 0.0);
257    }
258
259    let wins = pnls.iter().filter(|&&p| p > 0.0).count() as f64;
260    let win_rate = wins / n;
261
262    let gross_profit: f64 = pnls.iter().filter(|&&p| p > 0.0).copied().sum();
263    let gross_loss: f64 = pnls.iter().filter(|&&p| p < 0.0).map(|p| p.abs()).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        && let Ok(sym_ca) = sym_col.str()
390    {
391        return eq_ca
392            .into_iter()
393            .zip(sym_ca)
394            .filter_map(|(eq, sym)| if sym.is_none() { eq } else { None })
395            .collect();
396    }
397
398    eq_ca.into_iter().flatten().collect()
399}
400
401fn equity_first(result: &BacktestResult) -> Option<f64> {
402    portfolio_equity_values(result).first().copied()
403}
404
405fn equity_last(result: &BacktestResult) -> Option<f64> {
406    portfolio_equity_values(result).last().copied()
407}