1use std::collections::HashMap;
4
5use chrono::{DateTime, Datelike, NaiveDateTime, Utc, Weekday};
6use serde::{Deserialize, Serialize};
7
8use super::config::BacktestConfig;
9use super::position::{Position, Trade};
10use super::signal::SignalDirection;
11
12#[non_exhaustive]
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct EquityPoint {
16 pub timestamp: i64,
18 pub equity: f64,
20 pub drawdown_pct: f64,
25}
26
27#[non_exhaustive]
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SignalRecord {
31 pub timestamp: i64,
33 pub price: f64,
35 pub direction: SignalDirection,
37 pub strength: f64,
39 pub reason: Option<String>,
41 pub executed: bool,
43 #[serde(default)]
48 pub tags: Vec<String>,
49}
50
51#[non_exhaustive]
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct PerformanceMetrics {
55 pub total_return_pct: f64,
57
58 pub annualized_return_pct: f64,
60
61 pub sharpe_ratio: f64,
63
64 pub sortino_ratio: f64,
66
67 pub max_drawdown_pct: f64,
74
75 pub max_drawdown_duration: i64,
79
80 pub win_rate: f64,
87
88 pub profit_factor: f64,
94
95 pub avg_trade_return_pct: f64,
97
98 pub avg_win_pct: f64,
100
101 pub avg_loss_pct: f64,
103
104 pub avg_trade_duration: f64,
106
107 pub total_trades: usize,
109
110 pub winning_trades: usize,
115
116 pub losing_trades: usize,
121
122 pub largest_win: f64,
124
125 pub largest_loss: f64,
127
128 pub max_consecutive_wins: usize,
130
131 pub max_consecutive_losses: usize,
133
134 pub calmar_ratio: f64,
139
140 pub total_commission: f64,
142
143 pub long_trades: usize,
145
146 pub short_trades: usize,
148
149 pub total_signals: usize,
151
152 pub executed_signals: usize,
154
155 pub avg_win_duration: f64,
157
158 pub avg_loss_duration: f64,
160
161 pub time_in_market_pct: f64,
163
164 pub max_idle_period: i64,
166
167 pub total_dividend_income: f64,
169
170 pub kelly_criterion: f64,
178
179 pub sqn: f64,
194
195 pub expectancy: f64,
205
206 pub omega_ratio: f64,
214
215 pub tail_ratio: f64,
227
228 pub recovery_factor: f64,
234
235 pub ulcer_index: f64,
244
245 pub serenity_ratio: f64,
254}
255
256impl PerformanceMetrics {
257 pub fn max_drawdown_percentage(&self) -> f64 {
263 self.max_drawdown_pct * 100.0
264 }
265
266 fn empty(
269 initial_capital: f64,
270 equity_curve: &[EquityPoint],
271 total_signals: usize,
272 executed_signals: usize,
273 ) -> Self {
274 let final_equity = equity_curve
275 .last()
276 .map(|e| e.equity)
277 .unwrap_or(initial_capital);
278 let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
279 Self {
280 total_return_pct,
281 annualized_return_pct: 0.0,
282 sharpe_ratio: 0.0,
283 sortino_ratio: 0.0,
284 max_drawdown_pct: 0.0,
285 max_drawdown_duration: 0,
286 win_rate: 0.0,
287 profit_factor: 0.0,
288 avg_trade_return_pct: 0.0,
289 avg_win_pct: 0.0,
290 avg_loss_pct: 0.0,
291 avg_trade_duration: 0.0,
292 total_trades: 0,
293 winning_trades: 0,
294 losing_trades: 0,
295 largest_win: 0.0,
296 largest_loss: 0.0,
297 max_consecutive_wins: 0,
298 max_consecutive_losses: 0,
299 calmar_ratio: 0.0,
300 total_commission: 0.0,
301 long_trades: 0,
302 short_trades: 0,
303 total_signals,
304 executed_signals,
305 avg_win_duration: 0.0,
306 avg_loss_duration: 0.0,
307 time_in_market_pct: 0.0,
308 max_idle_period: 0,
309 total_dividend_income: 0.0,
310 kelly_criterion: 0.0,
311 sqn: 0.0,
312 expectancy: 0.0,
313 omega_ratio: 0.0,
314 tail_ratio: 0.0,
315 recovery_factor: 0.0,
316 ulcer_index: 0.0,
317 serenity_ratio: 0.0,
318 }
319 }
320
321 pub fn calculate(
330 trades: &[Trade],
331 equity_curve: &[EquityPoint],
332 initial_capital: f64,
333 total_signals: usize,
334 executed_signals: usize,
335 risk_free_rate: f64,
336 bars_per_year: f64,
337 ) -> Self {
338 if trades.is_empty() {
339 return Self::empty(
340 initial_capital,
341 equity_curve,
342 total_signals,
343 executed_signals,
344 );
345 }
346
347 let total_trades = trades.len();
348 let stats = analyze_trades(trades);
349
350 let win_rate = stats.winning_trades as f64 / total_trades as f64;
351
352 let profit_factor = if stats.gross_loss > 0.0 {
353 stats.gross_profit / stats.gross_loss
354 } else if stats.gross_profit > 0.0 {
355 f64::MAX
356 } else {
357 0.0
358 };
359
360 let avg_trade_return_pct = stats.total_return_sum / total_trades as f64;
361
362 let avg_win_pct = if !stats.winning_returns.is_empty() {
363 stats.winning_returns.iter().sum::<f64>() / stats.winning_returns.len() as f64
364 } else {
365 0.0
366 };
367
368 let avg_loss_pct = if !stats.losing_returns.is_empty() {
369 stats.losing_returns.iter().sum::<f64>() / stats.losing_returns.len() as f64
370 } else {
371 0.0
372 };
373
374 let avg_trade_duration = stats.total_duration as f64 / total_trades as f64;
375
376 let (max_consecutive_wins, max_consecutive_losses) = calculate_consecutive(trades);
378
379 let max_drawdown_pct = equity_curve
381 .iter()
382 .map(|e| e.drawdown_pct)
383 .fold(0.0, f64::max);
384
385 let max_drawdown_duration = calculate_max_drawdown_duration(equity_curve);
386
387 let final_equity = equity_curve
389 .last()
390 .map(|e| e.equity)
391 .unwrap_or(initial_capital);
392 let total_return_pct = ((final_equity / initial_capital) - 1.0) * 100.0;
393
394 let num_periods = equity_curve.len().saturating_sub(1);
398 let years = num_periods as f64 / bars_per_year;
399 let growth = final_equity / initial_capital;
400 let annualized_return_pct = if years > 0.0 {
401 if growth <= 0.0 {
402 -100.0
403 } else {
404 (growth.powf(1.0 / years) - 1.0) * 100.0
405 }
406 } else {
407 0.0
408 };
409
410 let returns: Vec<f64> = calculate_periodic_returns(equity_curve);
412 let (sharpe_ratio, sortino_ratio) =
413 calculate_risk_ratios(&returns, risk_free_rate, bars_per_year);
414
415 let calmar_ratio = if max_drawdown_pct > 0.0 {
419 annualized_return_pct / (max_drawdown_pct * 100.0)
420 } else if annualized_return_pct > 0.0 {
421 f64::MAX
422 } else {
423 0.0
424 };
425
426 let (avg_win_duration, avg_loss_duration) = calculate_win_loss_durations(trades);
428 let time_in_market_pct = calculate_time_in_market(trades, equity_curve);
429 let max_idle_period = calculate_max_idle_period(trades);
430
431 let kelly_criterion = calculate_kelly(win_rate, avg_win_pct, avg_loss_pct);
433 let sqn = calculate_sqn(&stats.all_returns);
434 let loss_rate = stats.losing_trades as f64 / total_trades as f64;
439 let avg_win_dollar = if stats.winning_trades > 0 {
440 stats.gross_profit / stats.winning_trades as f64
441 } else {
442 0.0
443 };
444 let avg_loss_dollar = if stats.losing_trades > 0 {
445 -(stats.gross_loss / stats.losing_trades as f64)
446 } else {
447 0.0
448 };
449 let expectancy = win_rate * avg_win_dollar + loss_rate * avg_loss_dollar;
450 let omega_ratio = calculate_omega_ratio(&returns);
455 let tail_ratio = calculate_tail_ratio(&stats.all_returns);
456 let recovery_factor = if max_drawdown_pct > 0.0 {
457 total_return_pct / (max_drawdown_pct * 100.0)
458 } else if total_return_pct > 0.0 {
459 f64::MAX
460 } else {
461 0.0
462 };
463 let ulcer_index = calculate_ulcer_index(equity_curve);
465 let rf_pct = risk_free_rate * 100.0;
466 let serenity_ratio = if ulcer_index > 0.0 {
467 (annualized_return_pct - rf_pct) / ulcer_index
468 } else if annualized_return_pct > rf_pct {
469 f64::MAX
470 } else {
471 0.0
472 };
473
474 Self {
475 total_return_pct,
476 annualized_return_pct,
477 sharpe_ratio,
478 sortino_ratio,
479 max_drawdown_pct,
480 max_drawdown_duration,
481 win_rate,
482 profit_factor,
483 avg_trade_return_pct,
484 avg_win_pct,
485 avg_loss_pct,
486 avg_trade_duration,
487 total_trades,
488 winning_trades: stats.winning_trades,
489 losing_trades: stats.losing_trades,
490 largest_win: stats.largest_win,
491 largest_loss: stats.largest_loss,
492 max_consecutive_wins,
493 max_consecutive_losses,
494 calmar_ratio,
495 total_commission: stats.total_commission,
496 long_trades: stats.long_trades,
497 short_trades: stats.short_trades,
498 total_signals,
499 executed_signals,
500 avg_win_duration,
501 avg_loss_duration,
502 time_in_market_pct,
503 max_idle_period,
504 total_dividend_income: stats.total_dividend_income,
505 kelly_criterion,
506 sqn,
507 expectancy,
508 omega_ratio,
509 tail_ratio,
510 recovery_factor,
511 ulcer_index,
512 serenity_ratio,
513 }
514 }
515}
516
517struct TradeStats {
519 winning_trades: usize,
520 losing_trades: usize,
521 long_trades: usize,
522 short_trades: usize,
523 gross_profit: f64,
524 gross_loss: f64,
525 total_return_sum: f64,
526 total_duration: i64,
527 largest_win: f64,
528 largest_loss: f64,
529 total_commission: f64,
530 total_dividend_income: f64,
531 winning_returns: Vec<f64>,
532 losing_returns: Vec<f64>,
533 all_returns: Vec<f64>,
535}
536
537fn analyze_trades(trades: &[Trade]) -> TradeStats {
539 let mut stats = TradeStats {
540 winning_trades: 0,
541 losing_trades: 0,
542 long_trades: 0,
543 short_trades: 0,
544 gross_profit: 0.0,
545 gross_loss: 0.0,
546 total_return_sum: 0.0,
547 total_duration: 0,
548 largest_win: 0.0,
549 largest_loss: 0.0,
550 total_commission: 0.0,
551 total_dividend_income: 0.0,
552 winning_returns: Vec::new(),
553 losing_returns: Vec::new(),
554 all_returns: Vec::new(),
555 };
556
557 for t in trades {
558 if t.is_profitable() {
559 stats.winning_trades += 1;
560 stats.gross_profit += t.pnl;
561 stats.winning_returns.push(t.return_pct);
562 stats.largest_win = stats.largest_win.max(t.pnl);
563 } else if t.is_loss() {
564 stats.losing_trades += 1;
565 stats.gross_loss += t.pnl.abs();
566 stats.losing_returns.push(t.return_pct);
567 stats.largest_loss = stats.largest_loss.min(t.pnl);
568 }
569 if t.is_long() {
570 stats.long_trades += 1;
571 } else {
572 stats.short_trades += 1;
573 }
574 stats.total_return_sum += t.return_pct;
575 stats.total_duration += t.duration_secs();
576 stats.total_commission += t.commission;
577 stats.total_dividend_income += t.dividend_income;
578 stats.all_returns.push(t.return_pct);
579 }
580
581 stats
582}
583
584fn calculate_kelly(win_rate: f64, avg_win_pct: f64, avg_loss_pct: f64) -> f64 {
589 let abs_loss = avg_loss_pct.abs();
590 if abs_loss == 0.0 {
591 return if avg_win_pct > 0.0 { f64::MAX } else { 0.0 };
594 }
595 if avg_win_pct == 0.0 {
596 return 0.0;
597 }
598 let r = avg_win_pct / abs_loss;
599 win_rate - (1.0 - win_rate) / r
600}
601
602fn calculate_sqn(returns: &[f64]) -> f64 {
607 let n = returns.len();
608 if n < 2 {
609 return 0.0;
610 }
611 let mean = returns.iter().sum::<f64>() / n as f64;
612 let variance = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
613 let std_dev = variance.sqrt();
614 if std_dev == 0.0 {
615 return 0.0;
616 }
617 (mean / std_dev) * (n as f64).sqrt()
618}
619
620fn calculate_omega_ratio(returns: &[f64]) -> f64 {
625 let gains: f64 = returns.iter().map(|&r| r.max(0.0)).sum();
626 let losses: f64 = returns.iter().map(|&r| (-r).max(0.0)).sum();
627 if losses == 0.0 {
628 if gains > 0.0 { f64::MAX } else { 0.0 }
629 } else {
630 gains / losses
631 }
632}
633
634fn calculate_tail_ratio(returns: &[f64]) -> f64 {
638 let n = returns.len();
639 if n < 2 {
640 return 0.0;
641 }
642 let mut sorted = returns.to_vec();
643 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
644
645 let p5_idx = ((0.05 * n as f64).floor() as usize).min(n - 1);
646 let p95_idx = ((0.95 * n as f64).floor() as usize).min(n - 1);
647
648 let p5 = sorted[p5_idx].abs();
649 let p95 = sorted[p95_idx].abs();
650
651 if p5 == 0.0 {
652 if p95 > 0.0 { f64::MAX } else { 0.0 }
653 } else {
654 p95 / p5
655 }
656}
657
658fn calculate_ulcer_index(equity_curve: &[EquityPoint]) -> f64 {
661 if equity_curve.is_empty() {
662 return 0.0;
663 }
664 let sum_sq: f64 = equity_curve
668 .iter()
669 .map(|p| (p.drawdown_pct * 100.0).powi(2))
670 .sum();
671 (sum_sq / equity_curve.len() as f64).sqrt()
672}
673
674fn calculate_consecutive(trades: &[Trade]) -> (usize, usize) {
676 let mut max_wins = 0;
677 let mut max_losses = 0;
678 let mut current_wins = 0;
679 let mut current_losses = 0;
680
681 for trade in trades {
682 if trade.is_profitable() {
683 current_wins += 1;
684 current_losses = 0;
685 max_wins = max_wins.max(current_wins);
686 } else if trade.is_loss() {
687 current_losses += 1;
688 current_wins = 0;
689 max_losses = max_losses.max(current_losses);
690 } else {
691 current_wins = 0;
693 current_losses = 0;
694 }
695 }
696
697 (max_wins, max_losses)
698}
699
700fn calculate_max_drawdown_duration(equity_curve: &[EquityPoint]) -> i64 {
702 if equity_curve.is_empty() {
703 return 0;
704 }
705
706 let mut max_duration = 0;
707 let mut current_duration = 0;
708 let mut peak = equity_curve[0].equity;
709
710 for point in equity_curve {
711 if point.equity >= peak {
712 peak = point.equity;
713 max_duration = max_duration.max(current_duration);
714 current_duration = 0;
715 } else {
716 current_duration += 1;
717 }
718 }
719
720 max_duration.max(current_duration)
721}
722
723fn calculate_periodic_returns(equity_curve: &[EquityPoint]) -> Vec<f64> {
725 if equity_curve.len() < 2 {
726 return vec![];
727 }
728
729 equity_curve
730 .windows(2)
731 .map(|w| {
732 let prev = w[0].equity;
733 let curr = w[1].equity;
734 if prev > 0.0 {
735 (curr - prev) / prev
736 } else {
737 0.0
738 }
739 })
740 .collect()
741}
742
743fn annual_to_periodic_rf(annual_rate: f64, bars_per_year: f64) -> f64 {
749 (1.0 + annual_rate).powf(1.0 / bars_per_year) - 1.0
750}
751
752fn calculate_risk_ratios(
759 returns: &[f64],
760 annual_risk_free_rate: f64,
761 bars_per_year: f64,
762) -> (f64, f64) {
763 if returns.len() < 2 {
764 return (0.0, 0.0);
765 }
766
767 let periodic_rf = annual_to_periodic_rf(annual_risk_free_rate, bars_per_year);
768 let n = returns.len() as f64;
769
770 let mean = returns.iter().map(|r| r - periodic_rf).sum::<f64>() / n;
772
773 let (var_sum, downside_sq_sum) = returns.iter().fold((0.0_f64, 0.0_f64), |(v, d), &r| {
775 let e = r - periodic_rf;
776 let delta = e - mean;
777 (v + delta * delta, if e < 0.0 { d + e * e } else { d })
778 });
779
780 let std_dev = (var_sum / (n - 1.0)).sqrt();
782 let sharpe = if std_dev > 0.0 {
783 (mean / std_dev) * bars_per_year.sqrt()
784 } else if mean > 0.0 {
785 f64::MAX
786 } else {
787 0.0
788 };
789
790 let downside_dev = (downside_sq_sum / (n - 1.0)).sqrt();
793 let sortino = if downside_dev > 0.0 {
794 (mean / downside_dev) * bars_per_year.sqrt()
795 } else if mean > 0.0 {
796 f64::MAX
797 } else {
798 0.0
799 };
800
801 (sharpe, sortino)
802}
803
804fn calculate_win_loss_durations(trades: &[Trade]) -> (f64, f64) {
806 let (win_sum, win_count, loss_sum, loss_count) =
807 trades
808 .iter()
809 .fold((0i64, 0usize, 0i64, 0usize), |(ws, wc, ls, lc), t| {
810 if t.is_profitable() {
811 (ws + t.duration_secs(), wc + 1, ls, lc)
812 } else if t.is_loss() {
813 (ws, wc, ls + t.duration_secs(), lc + 1)
814 } else {
815 (ws, wc, ls, lc)
816 }
817 });
818
819 let avg_win = if win_count == 0 {
820 0.0
821 } else {
822 win_sum as f64 / win_count as f64
823 };
824 let avg_loss = if loss_count == 0 {
825 0.0
826 } else {
827 loss_sum as f64 / loss_count as f64
828 };
829
830 (avg_win, avg_loss)
831}
832
833fn calculate_time_in_market(trades: &[Trade], equity_curve: &[EquityPoint]) -> f64 {
838 let total_duration_secs: i64 = trades.iter().map(|t| t.duration_secs()).sum();
839
840 let backtest_secs = match (equity_curve.first(), equity_curve.last()) {
841 (Some(first), Some(last)) if last.timestamp > first.timestamp => {
842 last.timestamp - first.timestamp
843 }
844 _ => return 0.0,
845 };
846
847 (total_duration_secs as f64 / backtest_secs as f64).min(1.0)
848}
849
850fn calculate_max_idle_period(trades: &[Trade]) -> i64 {
854 if trades.len() < 2 {
855 return 0;
856 }
857
858 trades
861 .windows(2)
862 .map(|w| (w[1].entry_timestamp - w[0].exit_timestamp).max(0))
863 .max()
864 .unwrap_or(0)
865}
866
867fn infer_bars_per_year(equity_slice: &[EquityPoint], fallback_bpy: f64) -> f64 {
878 if equity_slice.len() < 2 {
879 return fallback_bpy;
880 }
881 let first_ts = equity_slice.first().unwrap().timestamp as f64;
882 let last_ts = equity_slice.last().unwrap().timestamp as f64;
883 let seconds_per_year = 365.25 * 24.0 * 3600.0;
884 let years = (last_ts - first_ts) / seconds_per_year;
885 if years <= 0.0 {
886 return fallback_bpy;
887 }
888 ((equity_slice.len() - 1) as f64 / years).max(1.0)
891}
892
893fn partial_period_adjust(
903 mut metrics: PerformanceMetrics,
904 slice_len: usize,
905 bpy: f64,
906) -> PerformanceMetrics {
907 let periods = slice_len.saturating_sub(1) as f64;
908 if periods / bpy < 0.5 {
909 metrics.annualized_return_pct = 0.0;
910 metrics.calmar_ratio = 0.0;
911 metrics.serenity_ratio = 0.0;
912 }
913 metrics
914}
915
916fn datetime_from_timestamp(ts: i64) -> Option<NaiveDateTime> {
924 DateTime::<Utc>::from_timestamp(ts, 0).map(|dt| dt.naive_utc())
925}
926
927#[non_exhaustive]
931#[derive(Debug, Clone, Serialize, Deserialize)]
932pub struct BenchmarkMetrics {
933 pub symbol: String,
935
936 pub benchmark_return_pct: f64,
938
939 pub buy_and_hold_return_pct: f64,
941
942 pub alpha: f64,
960
961 pub beta: f64,
963
964 pub information_ratio: f64,
966}
967
968#[non_exhaustive]
970#[derive(Debug, Clone, Serialize, Deserialize)]
971pub struct BacktestResult {
972 pub symbol: String,
974
975 pub strategy_name: String,
977
978 pub config: BacktestConfig,
980
981 pub start_timestamp: i64,
983
984 pub end_timestamp: i64,
986
987 pub initial_capital: f64,
989
990 pub final_equity: f64,
992
993 pub metrics: PerformanceMetrics,
995
996 pub trades: Vec<Trade>,
998
999 pub equity_curve: Vec<EquityPoint>,
1001
1002 pub signals: Vec<SignalRecord>,
1004
1005 pub open_position: Option<Position>,
1007
1008 pub benchmark: Option<BenchmarkMetrics>,
1010
1011 #[serde(default)]
1016 pub diagnostics: Vec<String>,
1017}
1018
1019impl BacktestResult {
1020 pub fn summary(&self) -> String {
1022 format!(
1023 "Backtest: {} on {}\n\
1024 Period: {} bars\n\
1025 Initial: ${:.2} -> Final: ${:.2}\n\
1026 Return: {:.2}% | Sharpe: {:.2} | Max DD: {:.2}%\n\
1027 Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2}",
1028 self.strategy_name,
1029 self.symbol,
1030 self.equity_curve.len(),
1031 self.initial_capital,
1032 self.final_equity,
1033 self.metrics.total_return_pct,
1034 self.metrics.sharpe_ratio,
1035 self.metrics.max_drawdown_pct * 100.0,
1036 self.metrics.total_trades,
1037 self.metrics.win_rate * 100.0,
1038 self.metrics.profit_factor,
1039 )
1040 }
1041
1042 pub fn is_profitable(&self) -> bool {
1044 self.final_equity > self.initial_capital
1045 }
1046
1047 pub fn total_pnl(&self) -> f64 {
1049 self.final_equity - self.initial_capital
1050 }
1051
1052 pub fn num_bars(&self) -> usize {
1054 self.equity_curve.len()
1055 }
1056
1057 pub fn rolling_sharpe(&self, window: usize) -> Vec<f64> {
1077 if window == 0 {
1078 return vec![];
1079 }
1080 let returns = calculate_periodic_returns(&self.equity_curve);
1081 if returns.len() < window {
1082 return vec![];
1083 }
1084 let rf = self.config.risk_free_rate;
1085 let bpy = self.config.bars_per_year;
1086 returns
1087 .windows(window)
1088 .map(|w| {
1089 let (sharpe, _) = calculate_risk_ratios(w, rf, bpy);
1090 sharpe
1091 })
1092 .collect()
1093 }
1094
1095 pub fn drawdown_series(&self) -> Vec<f64> {
1110 self.equity_curve.iter().map(|p| p.drawdown_pct).collect()
1111 }
1112
1113 pub fn rolling_win_rate(&self, window: usize) -> Vec<f64> {
1127 if window == 0 || self.trades.len() < window {
1128 return vec![];
1129 }
1130 self.trades
1131 .windows(window)
1132 .map(|w| {
1133 let wins = w.iter().filter(|t| t.is_profitable()).count();
1134 wins as f64 / window as f64
1135 })
1136 .collect()
1137 }
1138
1139 pub fn by_year(&self) -> HashMap<i32, PerformanceMetrics> {
1164 self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| dt.year()))
1165 }
1166
1167 pub fn by_month(&self) -> HashMap<(i32, u32), PerformanceMetrics> {
1174 self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| (dt.year(), dt.month())))
1175 }
1176
1177 pub fn by_day_of_week(&self) -> HashMap<Weekday, PerformanceMetrics> {
1199 let mut trade_groups: HashMap<Weekday, Vec<&Trade>> = HashMap::new();
1201 for trade in &self.trades {
1202 if let Some(day) = datetime_from_timestamp(trade.exit_timestamp).map(|dt| dt.weekday())
1203 {
1204 trade_groups.entry(day).or_default().push(trade);
1205 }
1206 }
1207
1208 let mut equity_groups: HashMap<Weekday, Vec<EquityPoint>> = HashMap::new();
1210 for p in &self.equity_curve {
1211 if let Some(day) = datetime_from_timestamp(p.timestamp).map(|dt| dt.weekday()) {
1212 equity_groups.entry(day).or_default().push(p.clone());
1213 }
1214 }
1215
1216 trade_groups
1217 .into_iter()
1218 .map(|(day, group_trades)| {
1219 let equity_slice = equity_groups.remove(&day).unwrap_or_default();
1220 let initial_capital = equity_slice
1221 .first()
1222 .map(|p| p.equity)
1223 .unwrap_or(self.initial_capital);
1224 let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
1225 let bpy = infer_bars_per_year(&equity_slice, self.config.bars_per_year);
1230 let metrics = PerformanceMetrics::calculate(
1231 &trades_vec,
1232 &equity_slice,
1233 initial_capital,
1234 0,
1235 0,
1236 self.config.risk_free_rate,
1237 bpy,
1238 );
1239 let slice_len = equity_slice.len();
1240 (day, partial_period_adjust(metrics, slice_len, bpy))
1241 })
1242 .collect()
1243 }
1244
1245 fn temporal_metrics<K>(
1255 &self,
1256 key_fn: impl Fn(i64) -> Option<K>,
1257 ) -> HashMap<K, PerformanceMetrics>
1258 where
1259 K: std::hash::Hash + Eq + Copy,
1260 {
1261 let mut trade_groups: HashMap<K, Vec<&Trade>> = HashMap::new();
1263 for trade in &self.trades {
1264 if let Some(key) = key_fn(trade.exit_timestamp) {
1265 trade_groups.entry(key).or_default().push(trade);
1266 }
1267 }
1268
1269 let mut equity_groups: HashMap<K, Vec<EquityPoint>> = HashMap::new();
1271 for p in &self.equity_curve {
1272 if let Some(key) = key_fn(p.timestamp) {
1273 equity_groups.entry(key).or_default().push(p.clone());
1274 }
1275 }
1276
1277 trade_groups
1278 .into_iter()
1279 .map(|(key, group_trades)| {
1280 let equity_slice = equity_groups.remove(&key).unwrap_or_default();
1281 let initial_capital = equity_slice
1282 .first()
1283 .map(|p| p.equity)
1284 .unwrap_or(self.initial_capital);
1285 let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
1286 let metrics = PerformanceMetrics::calculate(
1287 &trades_vec,
1288 &equity_slice,
1289 initial_capital,
1290 0,
1294 0,
1295 self.config.risk_free_rate,
1296 self.config.bars_per_year,
1297 );
1298 let slice_len = equity_slice.len();
1299 (
1301 key,
1302 partial_period_adjust(metrics, slice_len, self.config.bars_per_year),
1303 )
1304 })
1305 .collect()
1306 }
1307
1308 pub fn trades_by_tag(&self, tag: &str) -> Vec<&Trade> {
1322 self.trades
1323 .iter()
1324 .filter(|t| t.tags.iter().any(|t2| t2 == tag))
1325 .collect()
1326 }
1327
1328 pub fn metrics_by_tag(&self, tag: &str) -> PerformanceMetrics {
1355 let mut equity = self.initial_capital;
1357 let mut peak = equity;
1358 let mut trades_vec: Vec<Trade> = Vec::new();
1359 let mut equity_curve: Vec<EquityPoint> = Vec::new();
1360
1361 for trade in &self.trades {
1362 if !trade.tags.iter().any(|t| t == tag) {
1363 continue;
1364 }
1365 if equity_curve.is_empty() {
1366 equity_curve.push(EquityPoint {
1367 timestamp: trade.entry_timestamp,
1368 equity,
1369 drawdown_pct: 0.0,
1370 });
1371 }
1372 equity += trade.pnl;
1373 if equity > peak {
1374 peak = equity;
1375 }
1376 let drawdown_pct = if peak > 0.0 {
1377 (peak - equity) / peak
1378 } else {
1379 0.0
1380 };
1381 equity_curve.push(EquityPoint {
1382 timestamp: trade.exit_timestamp,
1383 equity,
1384 drawdown_pct,
1385 });
1386 trades_vec.push(trade.clone());
1387 }
1388
1389 if trades_vec.is_empty() {
1390 return PerformanceMetrics::empty(self.initial_capital, &[], 0, 0);
1391 }
1392
1393 let bpy = infer_bars_per_year(&equity_curve, self.config.bars_per_year);
1396 let metrics = PerformanceMetrics::calculate(
1397 &trades_vec,
1398 &equity_curve,
1399 self.initial_capital,
1400 0,
1401 0,
1402 self.config.risk_free_rate,
1403 bpy,
1404 );
1405 partial_period_adjust(metrics, equity_curve.len(), bpy)
1406 }
1407
1408 pub fn all_tags(&self) -> Vec<&str> {
1413 let mut tags: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1414 for trade in &self.trades {
1415 for tag in &trade.tags {
1416 tags.insert(tag.as_str());
1417 }
1418 }
1419 tags.into_iter().collect()
1420 }
1421}
1422
1423#[cfg(test)]
1424mod tests {
1425 use super::*;
1426 use crate::backtesting::position::PositionSide;
1427 use crate::backtesting::signal::Signal;
1428
1429 fn make_trade(pnl: f64, return_pct: f64, is_long: bool) -> Trade {
1430 Trade {
1431 side: if is_long {
1432 PositionSide::Long
1433 } else {
1434 PositionSide::Short
1435 },
1436 entry_timestamp: 0,
1437 exit_timestamp: 100,
1438 entry_price: 100.0,
1439 exit_price: 100.0 + pnl / 10.0,
1440 quantity: 10.0,
1441 entry_quantity: 10.0,
1442 commission: 0.0,
1443 transaction_tax: 0.0,
1444 pnl,
1445 return_pct,
1446 dividend_income: 0.0,
1447 unreinvested_dividends: 0.0,
1448 tags: Vec::new(),
1449 is_partial: false,
1450 scale_sequence: 0,
1451 entry_signal: Signal::long(0, 100.0),
1452 exit_signal: Signal::exit(100, 110.0),
1453 }
1454 }
1455
1456 #[test]
1457 fn test_metrics_no_trades() {
1458 let equity = vec![
1459 EquityPoint {
1460 timestamp: 0,
1461 equity: 10000.0,
1462 drawdown_pct: 0.0,
1463 },
1464 EquityPoint {
1465 timestamp: 1,
1466 equity: 10100.0,
1467 drawdown_pct: 0.0,
1468 },
1469 ];
1470
1471 let metrics = PerformanceMetrics::calculate(&[], &equity, 10000.0, 0, 0, 0.0, 252.0);
1472
1473 assert_eq!(metrics.total_trades, 0);
1474 assert!((metrics.total_return_pct - 1.0).abs() < 0.01);
1475 }
1476
1477 #[test]
1478 fn test_metrics_with_trades() {
1479 let trades = vec![
1480 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), ];
1485
1486 let equity = vec![
1487 EquityPoint {
1488 timestamp: 0,
1489 equity: 10000.0,
1490 drawdown_pct: 0.0,
1491 },
1492 EquityPoint {
1493 timestamp: 1,
1494 equity: 10100.0,
1495 drawdown_pct: 0.0,
1496 },
1497 EquityPoint {
1498 timestamp: 2,
1499 equity: 10050.0,
1500 drawdown_pct: 0.005,
1501 },
1502 EquityPoint {
1503 timestamp: 3,
1504 equity: 10125.0,
1505 drawdown_pct: 0.0,
1506 },
1507 EquityPoint {
1508 timestamp: 4,
1509 equity: 10150.0,
1510 drawdown_pct: 0.0,
1511 },
1512 ];
1513
1514 let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 10, 4, 0.0, 252.0);
1515
1516 assert_eq!(metrics.total_trades, 4);
1517 assert_eq!(metrics.winning_trades, 3);
1518 assert_eq!(metrics.losing_trades, 1);
1519 assert!((metrics.win_rate - 0.75).abs() < 0.01);
1520 assert_eq!(metrics.long_trades, 3);
1521 assert_eq!(metrics.short_trades, 1);
1522 }
1523
1524 #[test]
1525 fn test_consecutive_wins_losses() {
1526 let trades = vec![
1527 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), ];
1534
1535 let (max_wins, max_losses) = calculate_consecutive(&trades);
1536 assert_eq!(max_wins, 3);
1537 assert_eq!(max_losses, 2);
1538 }
1539
1540 #[test]
1541 fn test_drawdown_duration() {
1542 let equity = vec![
1543 EquityPoint {
1544 timestamp: 0,
1545 equity: 100.0,
1546 drawdown_pct: 0.0,
1547 },
1548 EquityPoint {
1549 timestamp: 1,
1550 equity: 95.0,
1551 drawdown_pct: 0.05,
1552 },
1553 EquityPoint {
1554 timestamp: 2,
1555 equity: 90.0,
1556 drawdown_pct: 0.10,
1557 },
1558 EquityPoint {
1559 timestamp: 3,
1560 equity: 92.0,
1561 drawdown_pct: 0.08,
1562 },
1563 EquityPoint {
1564 timestamp: 4,
1565 equity: 100.0,
1566 drawdown_pct: 0.0,
1567 }, EquityPoint {
1569 timestamp: 5,
1570 equity: 98.0,
1571 drawdown_pct: 0.02,
1572 },
1573 ];
1574
1575 let duration = calculate_max_drawdown_duration(&equity);
1576 assert_eq!(duration, 3); }
1578
1579 #[test]
1580 fn test_sharpe_uses_sample_variance() {
1581 let returns = vec![0.01, -0.01, 0.02, -0.02];
1588 let (sharpe, _) = calculate_risk_ratios(&returns, 0.0, 252.0);
1589 assert!(
1591 (sharpe).abs() < 1e-10,
1592 "Sharpe of zero-mean returns should be 0, got {}",
1593 sharpe
1594 );
1595 }
1596
1597 #[test]
1598 fn test_max_drawdown_percentage_method() {
1599 let trade = make_trade(100.0, 10.0, true);
1603 let equity = vec![
1604 EquityPoint {
1605 timestamp: 0,
1606 equity: 10000.0,
1607 drawdown_pct: 0.0,
1608 },
1609 EquityPoint {
1610 timestamp: 1,
1611 equity: 9000.0,
1612 drawdown_pct: 0.1,
1613 },
1614 EquityPoint {
1615 timestamp: 2,
1616 equity: 10000.0,
1617 drawdown_pct: 0.0,
1618 },
1619 ];
1620 let metrics = PerformanceMetrics::calculate(&[trade], &equity, 10000.0, 1, 1, 0.0, 252.0);
1621 assert!(
1622 (metrics.max_drawdown_pct - 0.1).abs() < 1e-9,
1623 "max_drawdown_pct should be 0.1 (fraction), got {}",
1624 metrics.max_drawdown_pct
1625 );
1626 assert!(
1627 (metrics.max_drawdown_percentage() - 10.0).abs() < 1e-9,
1628 "max_drawdown_percentage() should be 10.0, got {}",
1629 metrics.max_drawdown_percentage()
1630 );
1631 }
1632
1633 #[test]
1634 fn test_kelly_criterion() {
1635 let kelly = calculate_kelly(0.6, 10.0, -5.0);
1637 assert!(
1638 (kelly - 0.4).abs() < 1e-9,
1639 "Kelly should be 0.4, got {kelly}"
1640 );
1641
1642 assert_eq!(calculate_kelly(1.0, 10.0, 0.0), f64::MAX);
1644 assert_eq!(calculate_kelly(0.0, 0.0, 0.0), 0.0);
1646
1647 let kelly_neg = calculate_kelly(0.3, 5.0, -5.0);
1649 assert!(
1650 (kelly_neg - (-0.4)).abs() < 1e-9,
1651 "Kelly should be -0.4, got {kelly_neg}"
1652 );
1653 }
1654
1655 #[test]
1656 fn test_sqn() {
1657 let returns = vec![1.0; 10];
1659 assert_eq!(calculate_sqn(&returns), 0.0);
1660
1661 assert_eq!(calculate_sqn(&[1.0]), 0.0);
1663 assert_eq!(calculate_sqn(&[]), 0.0);
1664
1665 let returns2 = vec![2.0, -1.0, 3.0, -1.0, 2.0];
1669 let sqn = calculate_sqn(&returns2);
1670 assert!(
1671 (sqn - 1.1952).abs() < 0.001,
1672 "SQN should be ~1.195, got {sqn}"
1673 );
1674 }
1675
1676 #[test]
1677 fn test_omega_ratio() {
1678 assert_eq!(calculate_omega_ratio(&[1.0, 2.0, 3.0]), f64::MAX);
1680
1681 assert_eq!(calculate_omega_ratio(&[-1.0, -2.0, -3.0]), 0.0);
1683
1684 let omega = calculate_omega_ratio(&[2.0, -1.0, 3.0, -2.0]);
1686 assert!(
1687 (omega - 5.0 / 3.0).abs() < 1e-9,
1688 "Omega should be 5/3, got {omega}"
1689 );
1690 }
1691
1692 #[test]
1693 fn test_tail_ratio() {
1694 assert_eq!(calculate_tail_ratio(&[1.0]), 0.0);
1696
1697 let mut vals = vec![1.0f64; 16];
1700 vals.extend([-10.0, -5.0, 5.0, 10.0]);
1701 vals.sort_by(|a, b| a.partial_cmp(b).unwrap());
1702 let tr = calculate_tail_ratio(&vals);
1706 assert!(
1707 (tr - 2.0).abs() < 1e-9,
1708 "Tail ratio should be 2.0, got {tr}"
1709 );
1710
1711 let zeros_with_win = vec![
1713 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1714 0.0, 0.0, 5.0,
1715 ];
1716 assert_eq!(calculate_tail_ratio(&zeros_with_win), f64::MAX);
1717 }
1718
1719 #[test]
1720 fn test_ulcer_index() {
1721 let flat = vec![
1723 EquityPoint {
1724 timestamp: 0,
1725 equity: 100.0,
1726 drawdown_pct: 0.0,
1727 },
1728 EquityPoint {
1729 timestamp: 1,
1730 equity: 110.0,
1731 drawdown_pct: 0.0,
1732 },
1733 ];
1734 assert_eq!(calculate_ulcer_index(&flat), 0.0);
1735
1736 let dd = vec![
1739 EquityPoint {
1740 timestamp: 0,
1741 equity: 100.0,
1742 drawdown_pct: 0.1,
1743 },
1744 EquityPoint {
1745 timestamp: 1,
1746 equity: 90.0,
1747 drawdown_pct: 0.2,
1748 },
1749 ];
1750 let ui = calculate_ulcer_index(&dd);
1751 let expected = ((100.0f64 + 400.0) / 2.0).sqrt(); assert!(
1753 (ui - expected).abs() < 1e-9,
1754 "Ulcer index should be {expected}, got {ui}"
1755 );
1756 }
1757
1758 #[test]
1759 fn test_new_metrics_in_calculate() {
1760 let trades = vec![
1762 make_trade(100.0, 10.0, true),
1763 make_trade(200.0, 20.0, true),
1764 make_trade(-50.0, -5.0, true),
1765 ];
1766 let equity = vec![
1767 EquityPoint {
1768 timestamp: 0,
1769 equity: 10000.0,
1770 drawdown_pct: 0.0,
1771 },
1772 EquityPoint {
1773 timestamp: 1,
1774 equity: 10100.0,
1775 drawdown_pct: 0.0,
1776 },
1777 EquityPoint {
1778 timestamp: 2,
1779 equity: 10300.0,
1780 drawdown_pct: 0.0,
1781 },
1782 EquityPoint {
1783 timestamp: 3,
1784 equity: 10250.0,
1785 drawdown_pct: 0.005,
1786 },
1787 ];
1788 let m = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 3, 3, 0.0, 252.0);
1789
1790 assert!(
1793 m.kelly_criterion > 0.0,
1794 "Kelly should be positive for profitable strategy"
1795 );
1796
1797 assert!(m.sqn.is_finite(), "SQN should be finite");
1799
1800 assert!(
1803 m.expectancy > 0.0,
1804 "Expectancy should be positive in dollar terms"
1805 );
1806
1807 assert!(m.omega_ratio > 0.0 && m.omega_ratio.is_finite() || m.omega_ratio == f64::MAX);
1810
1811 assert!(m.ulcer_index >= 0.0);
1813
1814 assert!(m.recovery_factor > 0.0);
1816 }
1817
1818 #[test]
1819 fn test_profit_factor_all_wins_is_f64_max() {
1820 let trades = vec![make_trade(100.0, 10.0, true), make_trade(50.0, 5.0, true)];
1821 let equity = vec![
1822 EquityPoint {
1823 timestamp: 0,
1824 equity: 10000.0,
1825 drawdown_pct: 0.0,
1826 },
1827 EquityPoint {
1828 timestamp: 1,
1829 equity: 10150.0,
1830 drawdown_pct: 0.0,
1831 },
1832 ];
1833
1834 let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 2, 2, 0.0, 252.0);
1835 assert_eq!(metrics.profit_factor, f64::MAX);
1836 }
1837
1838 use super::super::config::BacktestConfig;
1841 use crate::backtesting::position::Position;
1842 use chrono::{NaiveDate, Weekday};
1843
1844 fn make_trade_timed(pnl: f64, return_pct: f64, entry_ts: i64, exit_ts: i64) -> Trade {
1845 Trade {
1846 side: PositionSide::Long,
1847 entry_timestamp: entry_ts,
1848 exit_timestamp: exit_ts,
1849 entry_price: 100.0,
1850 exit_price: 100.0 + pnl / 10.0,
1851 quantity: 10.0,
1852 entry_quantity: 10.0,
1853 commission: 0.0,
1854 transaction_tax: 0.0,
1855 pnl,
1856 return_pct,
1857 dividend_income: 0.0,
1858 unreinvested_dividends: 0.0,
1859 tags: Vec::new(),
1860 is_partial: false,
1861 scale_sequence: 0,
1862 entry_signal: Signal::long(entry_ts, 100.0),
1863 exit_signal: Signal::exit(exit_ts, 100.0 + pnl / 10.0),
1864 }
1865 }
1866
1867 fn make_result(trades: Vec<Trade>, equity_curve: Vec<EquityPoint>) -> BacktestResult {
1870 let metrics = PerformanceMetrics::calculate(
1871 &trades,
1872 &equity_curve,
1873 10000.0,
1874 trades.len(),
1875 trades.len(),
1876 0.0,
1877 252.0,
1878 );
1879 BacktestResult {
1880 symbol: "TEST".to_string(),
1881 strategy_name: "TestStrategy".to_string(),
1882 config: BacktestConfig::default(),
1883 start_timestamp: equity_curve.first().map(|e| e.timestamp).unwrap_or(0),
1884 end_timestamp: equity_curve.last().map(|e| e.timestamp).unwrap_or(0),
1885 initial_capital: 10000.0,
1886 final_equity: equity_curve.last().map(|e| e.equity).unwrap_or(10000.0),
1887 metrics,
1888 trades,
1889 equity_curve,
1890 signals: vec![],
1891 open_position: None::<Position>,
1892 benchmark: None,
1893 diagnostics: vec![],
1894 }
1895 }
1896
1897 fn ts(date: &str) -> i64 {
1898 let d = NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap();
1899 d.and_hms_opt(12, 0, 0).unwrap().and_utc().timestamp()
1900 }
1901
1902 fn equity_point(timestamp: i64, equity: f64, drawdown_pct: f64) -> EquityPoint {
1903 EquityPoint {
1904 timestamp,
1905 equity,
1906 drawdown_pct,
1907 }
1908 }
1909
1910 #[test]
1913 fn rolling_sharpe_window_zero_returns_empty() {
1914 let result = make_result(
1915 vec![],
1916 vec![equity_point(0, 10000.0, 0.0), equity_point(1, 10100.0, 0.0)],
1917 );
1918 assert!(result.rolling_sharpe(0).is_empty());
1919 }
1920
1921 #[test]
1922 fn rolling_sharpe_insufficient_bars_returns_empty() {
1923 let result = make_result(
1925 vec![],
1926 vec![
1927 equity_point(0, 10000.0, 0.0),
1928 equity_point(1, 10100.0, 0.0),
1929 equity_point(2, 10200.0, 0.0),
1930 ],
1931 );
1932 assert!(result.rolling_sharpe(3).is_empty());
1933 }
1934
1935 #[test]
1936 fn rolling_sharpe_correct_length() {
1937 let pts: Vec<EquityPoint> = (0..5)
1939 .map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
1940 .collect();
1941 let result = make_result(vec![], pts);
1942 assert_eq!(result.rolling_sharpe(2).len(), 3);
1943 }
1944
1945 #[test]
1946 fn rolling_sharpe_monotone_increase_positive() {
1947 let pts: Vec<EquityPoint> = (0..10)
1949 .map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
1950 .collect();
1951 let result = make_result(vec![], pts);
1952 let sharpes = result.rolling_sharpe(3);
1953 assert!(!sharpes.is_empty());
1954 for s in &sharpes {
1955 assert!(
1956 *s > 0.0 || *s == f64::MAX,
1957 "expected positive Sharpe, got {s}"
1958 );
1959 }
1960 }
1961
1962 #[test]
1965 fn drawdown_series_mirrors_equity_curve() {
1966 let pts = vec![
1967 equity_point(0, 10000.0, 0.00),
1968 equity_point(1, 9500.0, 0.05),
1969 equity_point(2, 9000.0, 0.10),
1970 equity_point(3, 9200.0, 0.08),
1971 equity_point(4, 10000.0, 0.00),
1972 ];
1973 let result = make_result(vec![], pts.clone());
1974 let dd = result.drawdown_series();
1975 assert_eq!(dd.len(), pts.len());
1976 for (got, ep) in dd.iter().zip(pts.iter()) {
1977 assert!(
1978 (got - ep.drawdown_pct).abs() < f64::EPSILON,
1979 "expected {}, got {}",
1980 ep.drawdown_pct,
1981 got
1982 );
1983 }
1984 }
1985
1986 #[test]
1987 fn drawdown_series_empty_curve() {
1988 let result = make_result(vec![], vec![]);
1989 assert!(result.drawdown_series().is_empty());
1990 }
1991
1992 #[test]
1995 fn rolling_win_rate_window_zero_returns_empty() {
1996 let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
1997 assert!(result.rolling_win_rate(0).is_empty());
1998 }
1999
2000 #[test]
2001 fn rolling_win_rate_window_exceeds_trades_returns_empty() {
2002 let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
2003 assert!(result.rolling_win_rate(2).is_empty());
2004 }
2005
2006 #[test]
2007 fn rolling_win_rate_all_wins() {
2008 let trades = vec![
2009 make_trade(10.0, 1.0, true),
2010 make_trade(20.0, 2.0, true),
2011 make_trade(15.0, 1.5, true),
2012 ];
2013 let result = make_result(trades, vec![]);
2014 let wr = result.rolling_win_rate(2);
2015 assert_eq!(wr, vec![1.0, 1.0]);
2017 }
2018
2019 #[test]
2020 fn rolling_win_rate_alternating() {
2021 let trades = vec![
2023 make_trade(10.0, 1.0, true),
2024 make_trade(-10.0, -1.0, true),
2025 make_trade(10.0, 1.0, true),
2026 make_trade(-10.0, -1.0, true),
2027 ];
2028 let result = make_result(trades, vec![]);
2029 let wr = result.rolling_win_rate(2);
2030 assert_eq!(wr.len(), 3);
2031 for v in &wr {
2032 assert!((v - 0.5).abs() < f64::EPSILON, "expected 0.5, got {v}");
2033 }
2034 }
2035
2036 #[test]
2037 fn rolling_win_rate_correct_length() {
2038 let trades: Vec<Trade> = (0..5)
2039 .map(|i| make_trade(i as f64, i as f64, true))
2040 .collect();
2041 let result = make_result(trades, vec![]);
2042 assert_eq!(result.rolling_win_rate(3).len(), 3);
2044 }
2045
2046 #[test]
2047 fn rolling_win_rate_window_equals_trade_count_returns_one_element() {
2048 let trades = vec![
2050 make_trade(10.0, 1.0, true),
2051 make_trade(-5.0, -0.5, true),
2052 make_trade(8.0, 0.8, true),
2053 ];
2054 let result = make_result(trades, vec![]);
2055 let wr = result.rolling_win_rate(3);
2056 assert_eq!(wr.len(), 1);
2057 assert!((wr[0] - 2.0 / 3.0).abs() < f64::EPSILON);
2059 }
2060
2061 #[test]
2064 fn partial_period_adjust_zeroes_annualised_fields_for_short_slice() {
2065 let dummy_metrics = PerformanceMetrics::calculate(
2067 &[make_trade(100.0, 10.0, true)],
2068 &[equity_point(0, 10000.0, 0.0), equity_point(1, 11000.0, 0.0)],
2069 10000.0,
2070 0,
2071 0,
2072 0.0,
2073 252.0,
2074 );
2075 assert!(dummy_metrics.annualized_return_pct != 0.0);
2076 let adjusted = partial_period_adjust(dummy_metrics, 10, 252.0);
2077 assert_eq!(adjusted.annualized_return_pct, 0.0);
2078 assert_eq!(adjusted.calmar_ratio, 0.0);
2079 assert_eq!(adjusted.serenity_ratio, 0.0);
2080 }
2081
2082 #[test]
2083 fn partial_period_adjust_preserves_full_year_metrics() {
2084 let metrics = PerformanceMetrics::calculate(
2086 &[make_trade(100.0, 10.0, true)],
2087 &[equity_point(0, 10000.0, 0.0), equity_point(1, 11000.0, 0.0)],
2088 10000.0,
2089 0,
2090 0,
2091 0.0,
2092 252.0,
2093 );
2094 let ann_before = metrics.annualized_return_pct;
2095 let adjusted = partial_period_adjust(metrics, 252, 252.0);
2096 assert_eq!(adjusted.annualized_return_pct, ann_before);
2097 }
2098
2099 #[test]
2102 fn by_year_no_trades_empty() {
2103 let result = make_result(vec![], vec![equity_point(ts("2023-06-01"), 10000.0, 0.0)]);
2104 assert!(result.by_year().is_empty());
2105 }
2106
2107 #[test]
2108 fn by_year_splits_across_years() {
2109 let eq = vec![
2110 equity_point(ts("2022-06-15"), 10000.0, 0.0),
2111 equity_point(ts("2022-06-16"), 10100.0, 0.0),
2112 equity_point(ts("2023-06-15"), 10200.0, 0.0),
2113 equity_point(ts("2023-06-16"), 10300.0, 0.0),
2114 ];
2115 let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-15"), ts("2022-06-16"));
2116 let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-15"), ts("2023-06-16"));
2117 let result = make_result(vec![t1, t2], eq);
2118 let by_year = result.by_year();
2119 assert_eq!(by_year.len(), 2);
2120 assert!(by_year.contains_key(&2022));
2121 assert!(by_year.contains_key(&2023));
2122 assert_eq!(by_year[&2022].total_trades, 1);
2123 assert_eq!(by_year[&2023].total_trades, 1);
2124 }
2125
2126 #[test]
2127 fn by_year_all_same_year() {
2128 let eq = vec![
2129 equity_point(ts("2023-03-01"), 10000.0, 0.0),
2130 equity_point(ts("2023-06-01"), 10200.0, 0.0),
2131 equity_point(ts("2023-09-01"), 10500.0, 0.0),
2132 ];
2133 let t1 = make_trade_timed(200.0, 2.0, ts("2023-03-01"), ts("2023-06-01"));
2134 let t2 = make_trade_timed(300.0, 3.0, ts("2023-06-01"), ts("2023-09-01"));
2135 let result = make_result(vec![t1, t2], eq);
2136 let by_year = result.by_year();
2137 assert_eq!(by_year.len(), 1);
2138 assert!(by_year.contains_key(&2023));
2139 assert_eq!(by_year[&2023].total_trades, 2);
2140 }
2141
2142 #[test]
2145 fn by_month_splits_across_months() {
2146 let eq = vec![
2147 equity_point(ts("2023-03-15"), 10000.0, 0.0),
2148 equity_point(ts("2023-03-16"), 10100.0, 0.0),
2149 equity_point(ts("2023-07-15"), 10200.0, 0.0),
2150 equity_point(ts("2023-07-16"), 10300.0, 0.0),
2151 ];
2152 let t1 = make_trade_timed(100.0, 1.0, ts("2023-03-15"), ts("2023-03-16"));
2153 let t2 = make_trade_timed(100.0, 1.0, ts("2023-07-15"), ts("2023-07-16"));
2154 let result = make_result(vec![t1, t2], eq);
2155 let by_month = result.by_month();
2156 assert_eq!(by_month.len(), 2);
2157 assert!(by_month.contains_key(&(2023, 3)));
2158 assert!(by_month.contains_key(&(2023, 7)));
2159 }
2160
2161 #[test]
2162 fn by_month_same_month_different_years_are_separate_keys() {
2163 let eq = vec![
2164 equity_point(ts("2022-06-15"), 10000.0, 0.0),
2165 equity_point(ts("2023-06-15"), 10200.0, 0.0),
2166 ];
2167 let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-14"), ts("2022-06-15"));
2168 let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-14"), ts("2023-06-15"));
2169 let result = make_result(vec![t1, t2], eq);
2170 let by_month = result.by_month();
2171 assert_eq!(by_month.len(), 2);
2172 assert!(by_month.contains_key(&(2022, 6)));
2173 assert!(by_month.contains_key(&(2023, 6)));
2174 }
2175
2176 #[test]
2179 fn by_day_of_week_single_day() {
2180 let monday = ts("2023-01-02");
2182 let t1 = make_trade_timed(100.0, 1.0, monday - 86400, monday);
2183 let t2 = make_trade_timed(50.0, 0.5, monday - 86400 * 2, monday);
2184 let eq = vec![equity_point(monday, 10000.0, 0.0)];
2185 let result = make_result(vec![t1, t2], eq);
2186 let by_dow = result.by_day_of_week();
2187 assert_eq!(by_dow.len(), 1);
2188 assert!(by_dow.contains_key(&Weekday::Mon));
2189 assert_eq!(by_dow[&Weekday::Mon].total_trades, 2);
2190 }
2191
2192 #[test]
2193 fn by_day_of_week_multiple_days() {
2194 let monday = ts("2023-01-02");
2196 let tuesday = ts("2023-01-03");
2197 let t_mon = make_trade_timed(100.0, 1.0, monday - 86400, monday);
2198 let t_tue = make_trade_timed(-50.0, -0.5, tuesday - 86400, tuesday);
2199 let eq = vec![
2200 equity_point(monday, 10000.0, 0.0),
2201 equity_point(tuesday, 10100.0, 0.0),
2202 ];
2203 let result = make_result(vec![t_mon, t_tue], eq);
2204 let by_dow = result.by_day_of_week();
2205 assert_eq!(by_dow.len(), 2);
2206 assert!(by_dow.contains_key(&Weekday::Mon));
2207 assert!(by_dow.contains_key(&Weekday::Tue));
2208 assert_eq!(by_dow[&Weekday::Mon].total_trades, 1);
2209 assert_eq!(by_dow[&Weekday::Tue].total_trades, 1);
2210 assert_eq!(by_dow[&Weekday::Mon].winning_trades, 1);
2211 assert_eq!(by_dow[&Weekday::Tue].losing_trades, 1);
2212 }
2213
2214 #[test]
2215 fn by_day_of_week_no_trades_empty() {
2216 let result = make_result(vec![], vec![equity_point(ts("2023-01-02"), 10000.0, 0.0)]);
2217 assert!(result.by_day_of_week().is_empty());
2218 }
2219
2220 #[test]
2221 fn by_day_of_week_infers_weekly_bpy_for_daily_bars() {
2222 let base = ts("2023-01-02"); let week_secs = 7 * 86400i64;
2231 let n_weeks = 104usize;
2232 let equity_pts: Vec<EquityPoint> = (0..n_weeks)
2233 .map(|i| {
2234 equity_point(
2235 base + (i as i64) * week_secs,
2236 10000.0 + i as f64 * 10.0,
2237 0.0,
2238 )
2239 })
2240 .collect();
2241
2242 let trade = make_trade_timed(
2243 100.0,
2244 1.0,
2245 base,
2246 base + week_secs, );
2248 let result = make_result(vec![trade], equity_pts.clone());
2249 let by_dow = result.by_day_of_week();
2250
2251 assert!(by_dow.contains_key(&Weekday::Mon));
2256 let s = by_dow[&Weekday::Mon].sharpe_ratio;
2257 assert!(
2258 s.is_finite() || s == f64::MAX,
2259 "Sharpe should be finite, got {s}"
2260 );
2261 }
2262
2263 #[test]
2264 fn infer_bars_per_year_approximates_weekly_for_monday_subset() {
2265 let base = ts("2023-01-02");
2268 let week_secs = 7 * 86400i64;
2269 let pts: Vec<EquityPoint> = (0..104)
2270 .map(|i| equity_point(base + i * week_secs, 10000.0, 0.0))
2271 .collect();
2272 let bpy = infer_bars_per_year(&pts, 252.0);
2273 assert!(bpy > 48.0 && bpy < 56.0, "expected ~52, got {bpy}");
2275 }
2276
2277 fn make_tagged_trade(pnl: f64, tags: &[&str]) -> Trade {
2282 let entry_signal = tags
2283 .iter()
2284 .fold(Signal::long(0, 100.0), |sig, &t| sig.tag(t));
2285 let exit_price = 100.0 + pnl / 10.0;
2288 let exit_ts = 86400i64;
2289 let pos = Position::new(PositionSide::Long, 0, 100.0, 10.0, 0.0, entry_signal);
2290 pos.close(exit_ts, exit_price, 0.0, Signal::exit(exit_ts, exit_price))
2291 }
2292
2293 fn make_tagged_short_trade(pnl: f64, tags: &[&str]) -> Trade {
2295 let entry_signal = tags
2296 .iter()
2297 .fold(Signal::short(0, 100.0), |sig, &t| sig.tag(t));
2298 let exit_price = 100.0 - pnl / 10.0;
2301 let exit_ts = 86400i64;
2302 let pos = Position::new(PositionSide::Short, 0, 100.0, 10.0, 0.0, entry_signal);
2303 pos.close(exit_ts, exit_price, 0.0, Signal::exit(exit_ts, exit_price))
2304 }
2305
2306 #[test]
2309 fn signal_tag_builder_appends_tag() {
2310 let sig = Signal::long(0, 100.0).tag("breakout");
2311 assert_eq!(sig.tags, vec!["breakout"]);
2312 }
2313
2314 #[test]
2315 fn signal_tag_builder_chains_multiple_tags() {
2316 let sig = Signal::long(0, 100.0).tag("breakout").tag("high_volume");
2317 assert_eq!(sig.tags, vec!["breakout", "high_volume"]);
2318 }
2319
2320 #[test]
2321 fn signal_tag_builder_preserves_order() {
2322 let sig = Signal::long(0, 100.0).tag("a").tag("b").tag("c");
2323 assert_eq!(sig.tags, vec!["a", "b", "c"]);
2324 }
2325
2326 #[test]
2327 fn signal_constructors_start_with_empty_tags() {
2328 assert!(Signal::long(0, 0.0).tags.is_empty());
2329 assert!(Signal::short(0, 0.0).tags.is_empty());
2330 assert!(Signal::exit(0, 0.0).tags.is_empty());
2331 assert!(Signal::hold().tags.is_empty());
2332 }
2333
2334 #[test]
2337 fn position_close_propagates_entry_signal_tags_to_trade() {
2338 let entry_signal = Signal::long(0, 100.0).tag("breakout").tag("high_volume");
2339 let pos = Position::new(
2340 crate::backtesting::position::PositionSide::Long,
2341 0,
2342 100.0,
2343 10.0,
2344 0.0,
2345 entry_signal,
2346 );
2347 let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2348 assert_eq!(trade.tags, vec!["breakout", "high_volume"]);
2349 }
2350
2351 #[test]
2352 fn position_close_propagates_empty_tags_when_none_set() {
2353 let entry_signal = Signal::long(0, 100.0);
2354 let pos = Position::new(
2355 crate::backtesting::position::PositionSide::Long,
2356 0,
2357 100.0,
2358 10.0,
2359 0.0,
2360 entry_signal,
2361 );
2362 let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2363 assert!(trade.tags.is_empty());
2364 }
2365
2366 #[test]
2369 fn trades_by_tag_returns_matching_trades() {
2370 let result = make_result(
2371 vec![
2372 make_tagged_trade(100.0, &["breakout"]),
2373 make_tagged_trade(-50.0, &["reversal"]),
2374 make_tagged_trade(200.0, &["breakout", "high_volume"]),
2375 ],
2376 vec![equity_point(0, 10000.0, 0.0)],
2377 );
2378 let tagged = result.trades_by_tag("breakout");
2379 assert_eq!(tagged.len(), 2);
2380 assert!((tagged[0].pnl - 100.0).abs() < 1e-9);
2381 assert!((tagged[1].pnl - 200.0).abs() < 1e-9);
2382 }
2383
2384 #[test]
2385 fn trades_by_tag_returns_empty_for_missing_tag() {
2386 let result = make_result(
2387 vec![make_tagged_trade(100.0, &["breakout"])],
2388 vec![equity_point(0, 10000.0, 0.0)],
2389 );
2390 assert!(result.trades_by_tag("nonexistent").is_empty());
2391 }
2392
2393 #[test]
2394 fn trades_by_tag_returns_empty_when_no_trades_tagged() {
2395 let result = make_result(
2396 vec![make_trade(100.0, 10.0, true)],
2397 vec![equity_point(0, 10000.0, 0.0)],
2398 );
2399 assert!(result.trades_by_tag("breakout").is_empty());
2400 }
2401
2402 #[test]
2403 fn trades_by_tag_multi_tag_trade_matches_each_tag() {
2404 let result = make_result(
2405 vec![make_tagged_trade(100.0, &["a", "b", "c"])],
2406 vec![equity_point(0, 10000.0, 0.0)],
2407 );
2408 assert_eq!(result.trades_by_tag("a").len(), 1);
2409 assert_eq!(result.trades_by_tag("b").len(), 1);
2410 assert_eq!(result.trades_by_tag("c").len(), 1);
2411 assert_eq!(result.trades_by_tag("d").len(), 0);
2412 }
2413
2414 #[test]
2417 fn all_tags_returns_sorted_deduped_tags() {
2418 let result = make_result(
2419 vec![
2420 make_tagged_trade(10.0, &["z_tag", "a_tag"]),
2421 make_tagged_trade(10.0, &["m_tag", "a_tag"]),
2422 ],
2423 vec![equity_point(0, 10000.0, 0.0)],
2424 );
2425 let tags = result.all_tags();
2426 assert_eq!(tags, vec!["a_tag", "m_tag", "z_tag"]);
2427 }
2428
2429 #[test]
2430 fn all_tags_returns_empty_when_no_tagged_trades() {
2431 let result = make_result(
2432 vec![make_trade(100.0, 10.0, true)],
2433 vec![equity_point(0, 10000.0, 0.0)],
2434 );
2435 assert!(result.all_tags().is_empty());
2436 }
2437
2438 #[test]
2439 fn all_tags_returns_empty_when_no_trades() {
2440 let result = make_result(vec![], vec![equity_point(0, 10000.0, 0.0)]);
2441 assert!(result.all_tags().is_empty());
2442 }
2443
2444 #[test]
2447 fn metrics_by_tag_returns_empty_metrics_for_missing_tag() {
2448 let result = make_result(
2449 vec![make_tagged_trade(100.0, &["breakout"])],
2450 vec![equity_point(0, 10000.0, 0.0)],
2451 );
2452 let metrics = result.metrics_by_tag("nonexistent");
2453 assert_eq!(metrics.total_trades, 0);
2454 assert_eq!(metrics.win_rate, 0.0);
2455 }
2456
2457 #[test]
2458 fn metrics_by_tag_counts_only_tagged_trades() {
2459 let result = make_result(
2460 vec![
2461 make_tagged_trade(100.0, &["breakout"]),
2462 make_tagged_trade(200.0, &["breakout"]),
2463 make_tagged_trade(-50.0, &["reversal"]),
2464 ],
2465 vec![equity_point(0, 10000.0, 0.0)],
2466 );
2467 let metrics = result.metrics_by_tag("breakout");
2468 assert_eq!(metrics.total_trades, 2);
2469 assert_eq!(metrics.long_trades, 2);
2470 }
2471
2472 #[test]
2473 fn metrics_by_tag_win_rate_all_profitable() {
2474 let result = make_result(
2475 vec![
2476 make_tagged_trade(100.0, &["win"]),
2477 make_tagged_trade(200.0, &["win"]),
2478 ],
2479 vec![equity_point(0, 10000.0, 0.0)],
2480 );
2481 let metrics = result.metrics_by_tag("win");
2482 assert!(
2483 (metrics.win_rate - 1.0).abs() < 1e-9,
2484 "expected 100% win rate"
2485 );
2486 }
2487
2488 #[test]
2489 fn metrics_by_tag_win_rate_half_profitable() {
2490 let result = make_result(
2491 vec![
2492 make_tagged_trade(100.0, &["mixed"]),
2493 make_tagged_trade(-100.0, &["mixed"]),
2494 ],
2495 vec![equity_point(0, 10000.0, 0.0)],
2496 );
2497 let metrics = result.metrics_by_tag("mixed");
2498 assert!(
2499 (metrics.win_rate - 0.5).abs() < 1e-9,
2500 "expected 50% win rate, got {}",
2501 metrics.win_rate
2502 );
2503 }
2504
2505 #[test]
2506 fn metrics_by_tag_total_return_reflects_tagged_pnl() {
2507 let result = make_result(
2509 vec![
2510 make_tagged_trade(100.0, &["breakout"]),
2511 make_tagged_trade(200.0, &["breakout"]),
2512 make_tagged_trade(-500.0, &["other"]),
2513 ],
2514 vec![equity_point(0, 10000.0, 0.0)],
2515 );
2516 let metrics = result.metrics_by_tag("breakout");
2517 assert!(
2520 (metrics.total_return_pct - 3.0).abs() < 0.01,
2521 "expected 3%, got {}",
2522 metrics.total_return_pct
2523 );
2524 }
2525
2526 #[test]
2528 fn metrics_by_tag_mixed_long_short_counts_correctly() {
2529 let long_trade = make_tagged_trade(100.0, &["strategy"]);
2530 let short_trade = make_tagged_short_trade(50.0, &["strategy"]);
2531 assert!(long_trade.is_long());
2532 assert!(short_trade.is_short());
2533
2534 let result = make_result(
2535 vec![long_trade, short_trade],
2536 vec![equity_point(0, 10000.0, 0.0)],
2537 );
2538 let metrics = result.metrics_by_tag("strategy");
2539 assert_eq!(metrics.total_trades, 2);
2540 assert_eq!(metrics.long_trades, 1);
2541 assert_eq!(metrics.short_trades, 1);
2542 assert!(
2543 (metrics.win_rate - 1.0).abs() < 1e-9,
2544 "both trades are profitable"
2545 );
2546 }
2547
2548 #[test]
2550 fn all_tags_deduplicates_within_single_trade() {
2551 let sig = Signal::long(0, 100.0).tag("dup").tag("dup");
2552 let pos = Position::new(PositionSide::Long, 0, 100.0, 10.0, 0.0, sig);
2553 let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2554 assert_eq!(trade.tags, vec!["dup", "dup"]); let result = make_result(vec![trade], vec![equity_point(0, 10000.0, 0.0)]);
2556 assert_eq!(result.all_tags(), vec!["dup"]); }
2558
2559 #[test]
2561 fn trades_by_tag_is_case_sensitive() {
2562 let result = make_result(
2563 vec![make_tagged_trade(100.0, &["Breakout"])],
2564 vec![equity_point(0, 10000.0, 0.0)],
2565 );
2566 assert_eq!(result.trades_by_tag("Breakout").len(), 1);
2567 assert_eq!(result.trades_by_tag("breakout").len(), 0);
2568 assert_eq!(result.trades_by_tag("BREAKOUT").len(), 0);
2569 }
2570}