quantwave_backtest/
metrics.rs1use crate::BacktestResult;
19
20#[derive(Debug)]
22pub struct BacktestReport {
23 pub result: BacktestResult,
24 pub metrics: PerformanceMetrics,
25}
26
27#[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 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 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 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 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
248fn 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
278fn 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
311fn 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
339fn 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
360fn 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
378fn 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}