1use 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().iter().copied().zip(self.values())
78 }
79
80 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
252fn 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
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 && 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}