1use serde::{Deserialize, Serialize};
4
5use super::config::BacktestConfig;
6use super::position::{Position, Trade};
7use super::signal::SignalDirection;
8
9#[non_exhaustive]
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct EquityPoint {
13 pub timestamp: i64,
15 pub equity: f64,
17 pub drawdown_pct: f64,
19}
20
21#[non_exhaustive]
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct SignalRecord {
25 pub timestamp: i64,
27 pub price: f64,
29 pub direction: SignalDirection,
31 pub strength: f64,
33 pub reason: Option<String>,
35 pub executed: bool,
37}
38
39#[non_exhaustive]
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct PerformanceMetrics {
43 pub total_return_pct: f64,
45
46 pub annualized_return_pct: f64,
48
49 pub sharpe_ratio: f64,
51
52 pub sortino_ratio: f64,
54
55 pub max_drawdown_pct: f64,
57
58 pub max_drawdown_duration: i64,
60
61 pub win_rate: f64,
63
64 pub profit_factor: f64,
66
67 pub avg_trade_return_pct: f64,
69
70 pub avg_win_pct: f64,
72
73 pub avg_loss_pct: f64,
75
76 pub avg_trade_duration: f64,
78
79 pub total_trades: usize,
81
82 pub winning_trades: usize,
84
85 pub losing_trades: usize,
87
88 pub largest_win: f64,
90
91 pub largest_loss: f64,
93
94 pub max_consecutive_wins: usize,
96
97 pub max_consecutive_losses: usize,
99
100 pub calmar_ratio: f64,
102
103 pub total_commission: f64,
105
106 pub long_trades: usize,
108
109 pub short_trades: usize,
111
112 pub total_signals: usize,
114
115 pub executed_signals: usize,
117}
118
119impl PerformanceMetrics {
120 pub fn calculate(
122 trades: &[Trade],
123 equity_curve: &[EquityPoint],
124 initial_capital: f64,
125 total_signals: usize,
126 executed_signals: usize,
127 ) -> Self {
128 let total_trades = trades.len();
129
130 if total_trades == 0 {
131 let final_equity = equity_curve
132 .last()
133 .map(|e| e.equity)
134 .unwrap_or(initial_capital);
135 let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
136
137 return Self {
138 total_return_pct,
139 annualized_return_pct: 0.0,
140 sharpe_ratio: 0.0,
141 sortino_ratio: 0.0,
142 max_drawdown_pct: 0.0,
143 max_drawdown_duration: 0,
144 win_rate: 0.0,
145 profit_factor: 0.0,
146 avg_trade_return_pct: 0.0,
147 avg_win_pct: 0.0,
148 avg_loss_pct: 0.0,
149 avg_trade_duration: 0.0,
150 total_trades: 0,
151 winning_trades: 0,
152 losing_trades: 0,
153 largest_win: 0.0,
154 largest_loss: 0.0,
155 max_consecutive_wins: 0,
156 max_consecutive_losses: 0,
157 calmar_ratio: 0.0,
158 total_commission: 0.0,
159 long_trades: 0,
160 short_trades: 0,
161 total_signals,
162 executed_signals,
163 };
164 }
165
166 let winning_trades = trades.iter().filter(|t| t.is_profitable()).count();
168 let losing_trades = trades.iter().filter(|t| t.is_loss()).count();
169 let long_trades = trades.iter().filter(|t| t.is_long()).count();
170 let short_trades = trades.iter().filter(|t| t.is_short()).count();
171
172 let win_rate = winning_trades as f64 / total_trades as f64;
173
174 let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
176 let gross_loss: f64 = trades
177 .iter()
178 .filter(|t| t.pnl < 0.0)
179 .map(|t| t.pnl.abs())
180 .sum();
181
182 let profit_factor = if gross_loss > 0.0 {
183 gross_profit / gross_loss
184 } else if gross_profit > 0.0 {
185 f64::INFINITY
186 } else {
187 0.0
188 };
189
190 let avg_trade_return_pct =
192 trades.iter().map(|t| t.return_pct).sum::<f64>() / total_trades as f64;
193
194 let winning_returns: Vec<f64> = trades
195 .iter()
196 .filter(|t| t.is_profitable())
197 .map(|t| t.return_pct)
198 .collect();
199 let losing_returns: Vec<f64> = trades
200 .iter()
201 .filter(|t| t.is_loss())
202 .map(|t| t.return_pct)
203 .collect();
204
205 let avg_win_pct = if !winning_returns.is_empty() {
206 winning_returns.iter().sum::<f64>() / winning_returns.len() as f64
207 } else {
208 0.0
209 };
210
211 let avg_loss_pct = if !losing_returns.is_empty() {
212 losing_returns.iter().sum::<f64>() / losing_returns.len() as f64
213 } else {
214 0.0
215 };
216
217 let total_duration: i64 = trades.iter().map(|t| t.duration_secs()).sum();
219 let avg_trade_duration = total_duration as f64 / total_trades as f64;
220
221 let largest_win = trades.iter().map(|t| t.pnl).fold(0.0, f64::max);
223 let largest_loss = trades.iter().map(|t| t.pnl).fold(0.0, f64::min);
224
225 let (max_consecutive_wins, max_consecutive_losses) = calculate_consecutive(trades);
227
228 let total_commission: f64 = trades.iter().map(|t| t.commission).sum();
230
231 let max_drawdown_pct = equity_curve
233 .iter()
234 .map(|e| e.drawdown_pct)
235 .fold(0.0, f64::max);
236
237 let max_drawdown_duration = calculate_max_drawdown_duration(equity_curve);
238
239 let final_equity = equity_curve
241 .last()
242 .map(|e| e.equity)
243 .unwrap_or(initial_capital);
244 let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
245
246 let num_bars = equity_curve.len();
248 let years = num_bars as f64 / 252.0;
249 let annualized_return_pct = if years > 0.0 {
250 ((final_equity / initial_capital).powf(1.0 / years) - 1.0) * 100.0
251 } else {
252 0.0
253 };
254
255 let returns: Vec<f64> = calculate_periodic_returns(equity_curve);
257 let sharpe_ratio = calculate_sharpe_ratio(&returns);
258 let sortino_ratio = calculate_sortino_ratio(&returns);
259
260 let calmar_ratio = if max_drawdown_pct > 0.0 {
262 annualized_return_pct / (max_drawdown_pct * 100.0)
263 } else if annualized_return_pct > 0.0 {
264 f64::INFINITY
265 } else {
266 0.0
267 };
268
269 Self {
270 total_return_pct,
271 annualized_return_pct,
272 sharpe_ratio,
273 sortino_ratio,
274 max_drawdown_pct,
275 max_drawdown_duration,
276 win_rate,
277 profit_factor,
278 avg_trade_return_pct,
279 avg_win_pct,
280 avg_loss_pct,
281 avg_trade_duration,
282 total_trades,
283 winning_trades,
284 losing_trades,
285 largest_win,
286 largest_loss,
287 max_consecutive_wins,
288 max_consecutive_losses,
289 calmar_ratio,
290 total_commission,
291 long_trades,
292 short_trades,
293 total_signals,
294 executed_signals,
295 }
296 }
297}
298
299fn calculate_consecutive(trades: &[Trade]) -> (usize, usize) {
301 let mut max_wins = 0;
302 let mut max_losses = 0;
303 let mut current_wins = 0;
304 let mut current_losses = 0;
305
306 for trade in trades {
307 if trade.is_profitable() {
308 current_wins += 1;
309 current_losses = 0;
310 max_wins = max_wins.max(current_wins);
311 } else if trade.is_loss() {
312 current_losses += 1;
313 current_wins = 0;
314 max_losses = max_losses.max(current_losses);
315 } else {
316 current_wins = 0;
318 current_losses = 0;
319 }
320 }
321
322 (max_wins, max_losses)
323}
324
325fn calculate_max_drawdown_duration(equity_curve: &[EquityPoint]) -> i64 {
327 if equity_curve.is_empty() {
328 return 0;
329 }
330
331 let mut max_duration = 0;
332 let mut current_duration = 0;
333 let mut peak = equity_curve[0].equity;
334
335 for point in equity_curve {
336 if point.equity >= peak {
337 peak = point.equity;
338 max_duration = max_duration.max(current_duration);
339 current_duration = 0;
340 } else {
341 current_duration += 1;
342 }
343 }
344
345 max_duration.max(current_duration)
346}
347
348fn calculate_periodic_returns(equity_curve: &[EquityPoint]) -> Vec<f64> {
350 if equity_curve.len() < 2 {
351 return vec![];
352 }
353
354 equity_curve
355 .windows(2)
356 .map(|w| {
357 let prev = w[0].equity;
358 let curr = w[1].equity;
359 if prev > 0.0 {
360 (curr - prev) / prev
361 } else {
362 0.0
363 }
364 })
365 .collect()
366}
367
368fn calculate_sharpe_ratio(returns: &[f64]) -> f64 {
370 if returns.is_empty() {
371 return 0.0;
372 }
373
374 let mean = returns.iter().sum::<f64>() / returns.len() as f64;
375 let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / returns.len() as f64;
376 let std_dev = variance.sqrt();
377
378 if std_dev > 0.0 {
379 (mean / std_dev) * (252.0_f64).sqrt()
381 } else if mean > 0.0 {
382 f64::INFINITY
383 } else {
384 0.0
385 }
386}
387
388fn calculate_sortino_ratio(returns: &[f64]) -> f64 {
390 if returns.is_empty() {
391 return 0.0;
392 }
393
394 let mean = returns.iter().sum::<f64>() / returns.len() as f64;
395
396 let downside_returns: Vec<f64> = returns.iter().filter(|&&r| r < 0.0).copied().collect();
398
399 if downside_returns.is_empty() {
400 return if mean > 0.0 { f64::INFINITY } else { 0.0 };
401 }
402
403 let downside_variance =
404 downside_returns.iter().map(|r| r.powi(2)).sum::<f64>() / returns.len() as f64;
405 let downside_dev = downside_variance.sqrt();
406
407 if downside_dev > 0.0 {
408 (mean / downside_dev) * (252.0_f64).sqrt()
409 } else if mean > 0.0 {
410 f64::INFINITY
411 } else {
412 0.0
413 }
414}
415
416#[non_exhaustive]
418#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct BacktestResult {
420 pub symbol: String,
422
423 pub strategy_name: String,
425
426 pub config: BacktestConfig,
428
429 pub start_timestamp: i64,
431
432 pub end_timestamp: i64,
434
435 pub initial_capital: f64,
437
438 pub final_equity: f64,
440
441 pub metrics: PerformanceMetrics,
443
444 pub trades: Vec<Trade>,
446
447 pub equity_curve: Vec<EquityPoint>,
449
450 pub signals: Vec<SignalRecord>,
452
453 pub open_position: Option<Position>,
455}
456
457impl BacktestResult {
458 pub fn summary(&self) -> String {
460 format!(
461 "Backtest: {} on {}\n\
462 Period: {} bars\n\
463 Initial: ${:.2} -> Final: ${:.2}\n\
464 Return: {:.2}% | Sharpe: {:.2} | Max DD: {:.2}%\n\
465 Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2}",
466 self.strategy_name,
467 self.symbol,
468 self.equity_curve.len(),
469 self.initial_capital,
470 self.final_equity,
471 self.metrics.total_return_pct,
472 self.metrics.sharpe_ratio,
473 self.metrics.max_drawdown_pct * 100.0,
474 self.metrics.total_trades,
475 self.metrics.win_rate * 100.0,
476 self.metrics.profit_factor,
477 )
478 }
479
480 pub fn is_profitable(&self) -> bool {
482 self.final_equity > self.initial_capital
483 }
484
485 pub fn total_pnl(&self) -> f64 {
487 self.final_equity - self.initial_capital
488 }
489
490 pub fn num_bars(&self) -> usize {
492 self.equity_curve.len()
493 }
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use crate::backtesting::position::PositionSide;
500 use crate::backtesting::signal::Signal;
501
502 fn make_trade(pnl: f64, return_pct: f64, is_long: bool) -> Trade {
503 Trade {
504 side: if is_long {
505 PositionSide::Long
506 } else {
507 PositionSide::Short
508 },
509 entry_timestamp: 0,
510 exit_timestamp: 100,
511 entry_price: 100.0,
512 exit_price: 100.0 + pnl / 10.0,
513 quantity: 10.0,
514 commission: 0.0,
515 pnl,
516 return_pct,
517 entry_signal: Signal::long(0, 100.0),
518 exit_signal: Signal::exit(100, 110.0),
519 }
520 }
521
522 #[test]
523 fn test_metrics_no_trades() {
524 let equity = vec![
525 EquityPoint {
526 timestamp: 0,
527 equity: 10000.0,
528 drawdown_pct: 0.0,
529 },
530 EquityPoint {
531 timestamp: 1,
532 equity: 10100.0,
533 drawdown_pct: 0.0,
534 },
535 ];
536
537 let metrics = PerformanceMetrics::calculate(&[], &equity, 10000.0, 0, 0);
538
539 assert_eq!(metrics.total_trades, 0);
540 assert!((metrics.total_return_pct - 1.0).abs() < 0.01);
541 }
542
543 #[test]
544 fn test_metrics_with_trades() {
545 let trades = vec![
546 make_trade(100.0, 10.0, true), make_trade(-50.0, -5.0, true), make_trade(75.0, 7.5, false), make_trade(25.0, 2.5, true), ];
551
552 let equity = vec![
553 EquityPoint {
554 timestamp: 0,
555 equity: 10000.0,
556 drawdown_pct: 0.0,
557 },
558 EquityPoint {
559 timestamp: 1,
560 equity: 10100.0,
561 drawdown_pct: 0.0,
562 },
563 EquityPoint {
564 timestamp: 2,
565 equity: 10050.0,
566 drawdown_pct: 0.005,
567 },
568 EquityPoint {
569 timestamp: 3,
570 equity: 10125.0,
571 drawdown_pct: 0.0,
572 },
573 EquityPoint {
574 timestamp: 4,
575 equity: 10150.0,
576 drawdown_pct: 0.0,
577 },
578 ];
579
580 let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 10, 4);
581
582 assert_eq!(metrics.total_trades, 4);
583 assert_eq!(metrics.winning_trades, 3);
584 assert_eq!(metrics.losing_trades, 1);
585 assert!((metrics.win_rate - 0.75).abs() < 0.01);
586 assert_eq!(metrics.long_trades, 3);
587 assert_eq!(metrics.short_trades, 1);
588 }
589
590 #[test]
591 fn test_consecutive_wins_losses() {
592 let trades = vec![
593 make_trade(100.0, 10.0, true), make_trade(50.0, 5.0, true), make_trade(25.0, 2.5, true), make_trade(-50.0, -5.0, true), make_trade(-25.0, -2.5, true), make_trade(100.0, 10.0, true), ];
600
601 let (max_wins, max_losses) = calculate_consecutive(&trades);
602 assert_eq!(max_wins, 3);
603 assert_eq!(max_losses, 2);
604 }
605
606 #[test]
607 fn test_drawdown_duration() {
608 let equity = vec![
609 EquityPoint {
610 timestamp: 0,
611 equity: 100.0,
612 drawdown_pct: 0.0,
613 },
614 EquityPoint {
615 timestamp: 1,
616 equity: 95.0,
617 drawdown_pct: 0.05,
618 },
619 EquityPoint {
620 timestamp: 2,
621 equity: 90.0,
622 drawdown_pct: 0.10,
623 },
624 EquityPoint {
625 timestamp: 3,
626 equity: 92.0,
627 drawdown_pct: 0.08,
628 },
629 EquityPoint {
630 timestamp: 4,
631 equity: 100.0,
632 drawdown_pct: 0.0,
633 }, EquityPoint {
635 timestamp: 5,
636 equity: 98.0,
637 drawdown_pct: 0.02,
638 },
639 ];
640
641 let duration = calculate_max_drawdown_duration(&equity);
642 assert_eq!(duration, 3); }
644}