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 excess: Vec<f64> = returns.iter().map(|r| r - periodic_rf).collect();
769 let n = excess.len() as f64;
770 let mean = excess.iter().sum::<f64>() / n;
771
772 let variance = excess.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / (n - 1.0);
774 let std_dev = variance.sqrt();
775 let sharpe = if std_dev > 0.0 {
776 (mean / std_dev) * bars_per_year.sqrt()
777 } else if mean > 0.0 {
778 f64::MAX
779 } else {
780 0.0
781 };
782
783 let downside_sq_sum: f64 = excess.iter().filter(|&&r| r < 0.0).map(|r| r.powi(2)).sum();
786 let downside_dev = (downside_sq_sum / (n - 1.0)).sqrt();
787 let sortino = if downside_dev > 0.0 {
788 (mean / downside_dev) * bars_per_year.sqrt()
789 } else if mean > 0.0 {
790 f64::MAX
791 } else {
792 0.0
793 };
794
795 (sharpe, sortino)
796}
797
798fn calculate_win_loss_durations(trades: &[Trade]) -> (f64, f64) {
800 let win_durations: Vec<i64> = trades
801 .iter()
802 .filter(|t| t.is_profitable())
803 .map(|t| t.duration_secs())
804 .collect();
805 let loss_durations: Vec<i64> = trades
806 .iter()
807 .filter(|t| t.is_loss())
808 .map(|t| t.duration_secs())
809 .collect();
810
811 let avg_win = if win_durations.is_empty() {
812 0.0
813 } else {
814 win_durations.iter().sum::<i64>() as f64 / win_durations.len() as f64
815 };
816
817 let avg_loss = if loss_durations.is_empty() {
818 0.0
819 } else {
820 loss_durations.iter().sum::<i64>() as f64 / loss_durations.len() as f64
821 };
822
823 (avg_win, avg_loss)
824}
825
826fn calculate_time_in_market(trades: &[Trade], equity_curve: &[EquityPoint]) -> f64 {
831 let total_duration_secs: i64 = trades.iter().map(|t| t.duration_secs()).sum();
832
833 let backtest_secs = match (equity_curve.first(), equity_curve.last()) {
834 (Some(first), Some(last)) if last.timestamp > first.timestamp => {
835 last.timestamp - first.timestamp
836 }
837 _ => return 0.0,
838 };
839
840 (total_duration_secs as f64 / backtest_secs as f64).min(1.0)
841}
842
843fn calculate_max_idle_period(trades: &[Trade]) -> i64 {
847 if trades.len() < 2 {
848 return 0;
849 }
850
851 trades
854 .windows(2)
855 .map(|w| (w[1].entry_timestamp - w[0].exit_timestamp).max(0))
856 .max()
857 .unwrap_or(0)
858}
859
860fn infer_bars_per_year(equity_slice: &[EquityPoint], fallback_bpy: f64) -> f64 {
871 if equity_slice.len() < 2 {
872 return fallback_bpy;
873 }
874 let first_ts = equity_slice.first().unwrap().timestamp as f64;
875 let last_ts = equity_slice.last().unwrap().timestamp as f64;
876 let seconds_per_year = 365.25 * 24.0 * 3600.0;
877 let years = (last_ts - first_ts) / seconds_per_year;
878 if years <= 0.0 {
879 return fallback_bpy;
880 }
881 ((equity_slice.len() - 1) as f64 / years).max(1.0)
884}
885
886fn partial_period_adjust(
896 mut metrics: PerformanceMetrics,
897 slice_len: usize,
898 bpy: f64,
899) -> PerformanceMetrics {
900 let periods = slice_len.saturating_sub(1) as f64;
901 if periods / bpy < 0.5 {
902 metrics.annualized_return_pct = 0.0;
903 metrics.calmar_ratio = 0.0;
904 metrics.serenity_ratio = 0.0;
905 }
906 metrics
907}
908
909fn datetime_from_timestamp(ts: i64) -> Option<NaiveDateTime> {
917 DateTime::<Utc>::from_timestamp(ts, 0).map(|dt| dt.naive_utc())
918}
919
920#[non_exhaustive]
924#[derive(Debug, Clone, Serialize, Deserialize)]
925pub struct BenchmarkMetrics {
926 pub symbol: String,
928
929 pub benchmark_return_pct: f64,
931
932 pub buy_and_hold_return_pct: f64,
934
935 pub alpha: f64,
953
954 pub beta: f64,
956
957 pub information_ratio: f64,
959}
960
961#[non_exhaustive]
963#[derive(Debug, Clone, Serialize, Deserialize)]
964pub struct BacktestResult {
965 pub symbol: String,
967
968 pub strategy_name: String,
970
971 pub config: BacktestConfig,
973
974 pub start_timestamp: i64,
976
977 pub end_timestamp: i64,
979
980 pub initial_capital: f64,
982
983 pub final_equity: f64,
985
986 pub metrics: PerformanceMetrics,
988
989 pub trades: Vec<Trade>,
991
992 pub equity_curve: Vec<EquityPoint>,
994
995 pub signals: Vec<SignalRecord>,
997
998 pub open_position: Option<Position>,
1000
1001 pub benchmark: Option<BenchmarkMetrics>,
1003
1004 #[serde(default)]
1009 pub diagnostics: Vec<String>,
1010}
1011
1012impl BacktestResult {
1013 pub fn summary(&self) -> String {
1015 format!(
1016 "Backtest: {} on {}\n\
1017 Period: {} bars\n\
1018 Initial: ${:.2} -> Final: ${:.2}\n\
1019 Return: {:.2}% | Sharpe: {:.2} | Max DD: {:.2}%\n\
1020 Trades: {} | Win Rate: {:.1}% | Profit Factor: {:.2}",
1021 self.strategy_name,
1022 self.symbol,
1023 self.equity_curve.len(),
1024 self.initial_capital,
1025 self.final_equity,
1026 self.metrics.total_return_pct,
1027 self.metrics.sharpe_ratio,
1028 self.metrics.max_drawdown_pct * 100.0,
1029 self.metrics.total_trades,
1030 self.metrics.win_rate * 100.0,
1031 self.metrics.profit_factor,
1032 )
1033 }
1034
1035 pub fn is_profitable(&self) -> bool {
1037 self.final_equity > self.initial_capital
1038 }
1039
1040 pub fn total_pnl(&self) -> f64 {
1042 self.final_equity - self.initial_capital
1043 }
1044
1045 pub fn num_bars(&self) -> usize {
1047 self.equity_curve.len()
1048 }
1049
1050 pub fn rolling_sharpe(&self, window: usize) -> Vec<f64> {
1070 if window == 0 {
1071 return vec![];
1072 }
1073 let returns = calculate_periodic_returns(&self.equity_curve);
1074 if returns.len() < window {
1075 return vec![];
1076 }
1077 let rf = self.config.risk_free_rate;
1078 let bpy = self.config.bars_per_year;
1079 returns
1080 .windows(window)
1081 .map(|w| {
1082 let (sharpe, _) = calculate_risk_ratios(w, rf, bpy);
1083 sharpe
1084 })
1085 .collect()
1086 }
1087
1088 pub fn drawdown_series(&self) -> Vec<f64> {
1103 self.equity_curve.iter().map(|p| p.drawdown_pct).collect()
1104 }
1105
1106 pub fn rolling_win_rate(&self, window: usize) -> Vec<f64> {
1120 if window == 0 || self.trades.len() < window {
1121 return vec![];
1122 }
1123 self.trades
1124 .windows(window)
1125 .map(|w| {
1126 let wins = w.iter().filter(|t| t.is_profitable()).count();
1127 wins as f64 / window as f64
1128 })
1129 .collect()
1130 }
1131
1132 pub fn by_year(&self) -> HashMap<i32, PerformanceMetrics> {
1157 self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| dt.year()))
1158 }
1159
1160 pub fn by_month(&self) -> HashMap<(i32, u32), PerformanceMetrics> {
1167 self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| (dt.year(), dt.month())))
1168 }
1169
1170 pub fn by_day_of_week(&self) -> HashMap<Weekday, PerformanceMetrics> {
1192 let mut trade_groups: HashMap<Weekday, Vec<&Trade>> = HashMap::new();
1194 for trade in &self.trades {
1195 if let Some(day) = datetime_from_timestamp(trade.exit_timestamp).map(|dt| dt.weekday())
1196 {
1197 trade_groups.entry(day).or_default().push(trade);
1198 }
1199 }
1200
1201 let mut equity_groups: HashMap<Weekday, Vec<EquityPoint>> = HashMap::new();
1203 for p in &self.equity_curve {
1204 if let Some(day) = datetime_from_timestamp(p.timestamp).map(|dt| dt.weekday()) {
1205 equity_groups.entry(day).or_default().push(p.clone());
1206 }
1207 }
1208
1209 trade_groups
1210 .into_iter()
1211 .map(|(day, group_trades)| {
1212 let equity_slice = equity_groups.remove(&day).unwrap_or_default();
1213 let initial_capital = equity_slice
1214 .first()
1215 .map(|p| p.equity)
1216 .unwrap_or(self.initial_capital);
1217 let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
1218 let bpy = infer_bars_per_year(&equity_slice, self.config.bars_per_year);
1223 let metrics = PerformanceMetrics::calculate(
1224 &trades_vec,
1225 &equity_slice,
1226 initial_capital,
1227 0,
1228 0,
1229 self.config.risk_free_rate,
1230 bpy,
1231 );
1232 let slice_len = equity_slice.len();
1233 (day, partial_period_adjust(metrics, slice_len, bpy))
1234 })
1235 .collect()
1236 }
1237
1238 fn temporal_metrics<K>(
1248 &self,
1249 key_fn: impl Fn(i64) -> Option<K>,
1250 ) -> HashMap<K, PerformanceMetrics>
1251 where
1252 K: std::hash::Hash + Eq + Copy,
1253 {
1254 let mut trade_groups: HashMap<K, Vec<&Trade>> = HashMap::new();
1256 for trade in &self.trades {
1257 if let Some(key) = key_fn(trade.exit_timestamp) {
1258 trade_groups.entry(key).or_default().push(trade);
1259 }
1260 }
1261
1262 let mut equity_groups: HashMap<K, Vec<EquityPoint>> = HashMap::new();
1264 for p in &self.equity_curve {
1265 if let Some(key) = key_fn(p.timestamp) {
1266 equity_groups.entry(key).or_default().push(p.clone());
1267 }
1268 }
1269
1270 trade_groups
1271 .into_iter()
1272 .map(|(key, group_trades)| {
1273 let equity_slice = equity_groups.remove(&key).unwrap_or_default();
1274 let initial_capital = equity_slice
1275 .first()
1276 .map(|p| p.equity)
1277 .unwrap_or(self.initial_capital);
1278 let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
1279 let metrics = PerformanceMetrics::calculate(
1280 &trades_vec,
1281 &equity_slice,
1282 initial_capital,
1283 0,
1287 0,
1288 self.config.risk_free_rate,
1289 self.config.bars_per_year,
1290 );
1291 let slice_len = equity_slice.len();
1292 (
1294 key,
1295 partial_period_adjust(metrics, slice_len, self.config.bars_per_year),
1296 )
1297 })
1298 .collect()
1299 }
1300
1301 pub fn trades_by_tag(&self, tag: &str) -> Vec<&Trade> {
1315 self.trades
1316 .iter()
1317 .filter(|t| t.tags.iter().any(|t2| t2 == tag))
1318 .collect()
1319 }
1320
1321 pub fn metrics_by_tag(&self, tag: &str) -> PerformanceMetrics {
1348 let mut equity = self.initial_capital;
1350 let mut peak = equity;
1351 let mut trades_vec: Vec<Trade> = Vec::new();
1352 let mut equity_curve: Vec<EquityPoint> = Vec::new();
1353
1354 for trade in &self.trades {
1355 if !trade.tags.iter().any(|t| t == tag) {
1356 continue;
1357 }
1358 if equity_curve.is_empty() {
1359 equity_curve.push(EquityPoint {
1360 timestamp: trade.entry_timestamp,
1361 equity,
1362 drawdown_pct: 0.0,
1363 });
1364 }
1365 equity += trade.pnl;
1366 if equity > peak {
1367 peak = equity;
1368 }
1369 let drawdown_pct = if peak > 0.0 {
1370 (peak - equity) / peak
1371 } else {
1372 0.0
1373 };
1374 equity_curve.push(EquityPoint {
1375 timestamp: trade.exit_timestamp,
1376 equity,
1377 drawdown_pct,
1378 });
1379 trades_vec.push(trade.clone());
1380 }
1381
1382 if trades_vec.is_empty() {
1383 return PerformanceMetrics::empty(self.initial_capital, &[], 0, 0);
1384 }
1385
1386 let bpy = infer_bars_per_year(&equity_curve, self.config.bars_per_year);
1389 let metrics = PerformanceMetrics::calculate(
1390 &trades_vec,
1391 &equity_curve,
1392 self.initial_capital,
1393 0,
1394 0,
1395 self.config.risk_free_rate,
1396 bpy,
1397 );
1398 partial_period_adjust(metrics, equity_curve.len(), bpy)
1399 }
1400
1401 pub fn all_tags(&self) -> Vec<&str> {
1406 let mut tags: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1407 for trade in &self.trades {
1408 for tag in &trade.tags {
1409 tags.insert(tag.as_str());
1410 }
1411 }
1412 tags.into_iter().collect()
1413 }
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418 use super::*;
1419 use crate::backtesting::position::PositionSide;
1420 use crate::backtesting::signal::Signal;
1421
1422 fn make_trade(pnl: f64, return_pct: f64, is_long: bool) -> Trade {
1423 Trade {
1424 side: if is_long {
1425 PositionSide::Long
1426 } else {
1427 PositionSide::Short
1428 },
1429 entry_timestamp: 0,
1430 exit_timestamp: 100,
1431 entry_price: 100.0,
1432 exit_price: 100.0 + pnl / 10.0,
1433 quantity: 10.0,
1434 entry_quantity: 10.0,
1435 commission: 0.0,
1436 transaction_tax: 0.0,
1437 pnl,
1438 return_pct,
1439 dividend_income: 0.0,
1440 unreinvested_dividends: 0.0,
1441 tags: Vec::new(),
1442 is_partial: false,
1443 scale_sequence: 0,
1444 entry_signal: Signal::long(0, 100.0),
1445 exit_signal: Signal::exit(100, 110.0),
1446 }
1447 }
1448
1449 #[test]
1450 fn test_metrics_no_trades() {
1451 let equity = vec![
1452 EquityPoint {
1453 timestamp: 0,
1454 equity: 10000.0,
1455 drawdown_pct: 0.0,
1456 },
1457 EquityPoint {
1458 timestamp: 1,
1459 equity: 10100.0,
1460 drawdown_pct: 0.0,
1461 },
1462 ];
1463
1464 let metrics = PerformanceMetrics::calculate(&[], &equity, 10000.0, 0, 0, 0.0, 252.0);
1465
1466 assert_eq!(metrics.total_trades, 0);
1467 assert!((metrics.total_return_pct - 1.0).abs() < 0.01);
1468 }
1469
1470 #[test]
1471 fn test_metrics_with_trades() {
1472 let trades = vec![
1473 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), ];
1478
1479 let equity = vec![
1480 EquityPoint {
1481 timestamp: 0,
1482 equity: 10000.0,
1483 drawdown_pct: 0.0,
1484 },
1485 EquityPoint {
1486 timestamp: 1,
1487 equity: 10100.0,
1488 drawdown_pct: 0.0,
1489 },
1490 EquityPoint {
1491 timestamp: 2,
1492 equity: 10050.0,
1493 drawdown_pct: 0.005,
1494 },
1495 EquityPoint {
1496 timestamp: 3,
1497 equity: 10125.0,
1498 drawdown_pct: 0.0,
1499 },
1500 EquityPoint {
1501 timestamp: 4,
1502 equity: 10150.0,
1503 drawdown_pct: 0.0,
1504 },
1505 ];
1506
1507 let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 10, 4, 0.0, 252.0);
1508
1509 assert_eq!(metrics.total_trades, 4);
1510 assert_eq!(metrics.winning_trades, 3);
1511 assert_eq!(metrics.losing_trades, 1);
1512 assert!((metrics.win_rate - 0.75).abs() < 0.01);
1513 assert_eq!(metrics.long_trades, 3);
1514 assert_eq!(metrics.short_trades, 1);
1515 }
1516
1517 #[test]
1518 fn test_consecutive_wins_losses() {
1519 let trades = vec![
1520 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), ];
1527
1528 let (max_wins, max_losses) = calculate_consecutive(&trades);
1529 assert_eq!(max_wins, 3);
1530 assert_eq!(max_losses, 2);
1531 }
1532
1533 #[test]
1534 fn test_drawdown_duration() {
1535 let equity = vec![
1536 EquityPoint {
1537 timestamp: 0,
1538 equity: 100.0,
1539 drawdown_pct: 0.0,
1540 },
1541 EquityPoint {
1542 timestamp: 1,
1543 equity: 95.0,
1544 drawdown_pct: 0.05,
1545 },
1546 EquityPoint {
1547 timestamp: 2,
1548 equity: 90.0,
1549 drawdown_pct: 0.10,
1550 },
1551 EquityPoint {
1552 timestamp: 3,
1553 equity: 92.0,
1554 drawdown_pct: 0.08,
1555 },
1556 EquityPoint {
1557 timestamp: 4,
1558 equity: 100.0,
1559 drawdown_pct: 0.0,
1560 }, EquityPoint {
1562 timestamp: 5,
1563 equity: 98.0,
1564 drawdown_pct: 0.02,
1565 },
1566 ];
1567
1568 let duration = calculate_max_drawdown_duration(&equity);
1569 assert_eq!(duration, 3); }
1571
1572 #[test]
1573 fn test_sharpe_uses_sample_variance() {
1574 let returns = vec![0.01, -0.01, 0.02, -0.02];
1581 let (sharpe, _) = calculate_risk_ratios(&returns, 0.0, 252.0);
1582 assert!(
1584 (sharpe).abs() < 1e-10,
1585 "Sharpe of zero-mean returns should be 0, got {}",
1586 sharpe
1587 );
1588 }
1589
1590 #[test]
1591 fn test_max_drawdown_percentage_method() {
1592 let trade = make_trade(100.0, 10.0, true);
1596 let equity = vec![
1597 EquityPoint {
1598 timestamp: 0,
1599 equity: 10000.0,
1600 drawdown_pct: 0.0,
1601 },
1602 EquityPoint {
1603 timestamp: 1,
1604 equity: 9000.0,
1605 drawdown_pct: 0.1,
1606 },
1607 EquityPoint {
1608 timestamp: 2,
1609 equity: 10000.0,
1610 drawdown_pct: 0.0,
1611 },
1612 ];
1613 let metrics = PerformanceMetrics::calculate(&[trade], &equity, 10000.0, 1, 1, 0.0, 252.0);
1614 assert!(
1615 (metrics.max_drawdown_pct - 0.1).abs() < 1e-9,
1616 "max_drawdown_pct should be 0.1 (fraction), got {}",
1617 metrics.max_drawdown_pct
1618 );
1619 assert!(
1620 (metrics.max_drawdown_percentage() - 10.0).abs() < 1e-9,
1621 "max_drawdown_percentage() should be 10.0, got {}",
1622 metrics.max_drawdown_percentage()
1623 );
1624 }
1625
1626 #[test]
1627 fn test_kelly_criterion() {
1628 let kelly = calculate_kelly(0.6, 10.0, -5.0);
1630 assert!(
1631 (kelly - 0.4).abs() < 1e-9,
1632 "Kelly should be 0.4, got {kelly}"
1633 );
1634
1635 assert_eq!(calculate_kelly(1.0, 10.0, 0.0), f64::MAX);
1637 assert_eq!(calculate_kelly(0.0, 0.0, 0.0), 0.0);
1639
1640 let kelly_neg = calculate_kelly(0.3, 5.0, -5.0);
1642 assert!(
1643 (kelly_neg - (-0.4)).abs() < 1e-9,
1644 "Kelly should be -0.4, got {kelly_neg}"
1645 );
1646 }
1647
1648 #[test]
1649 fn test_sqn() {
1650 let returns = vec![1.0; 10];
1652 assert_eq!(calculate_sqn(&returns), 0.0);
1653
1654 assert_eq!(calculate_sqn(&[1.0]), 0.0);
1656 assert_eq!(calculate_sqn(&[]), 0.0);
1657
1658 let returns2 = vec![2.0, -1.0, 3.0, -1.0, 2.0];
1662 let sqn = calculate_sqn(&returns2);
1663 assert!(
1664 (sqn - 1.1952).abs() < 0.001,
1665 "SQN should be ~1.195, got {sqn}"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_omega_ratio() {
1671 assert_eq!(calculate_omega_ratio(&[1.0, 2.0, 3.0]), f64::MAX);
1673
1674 assert_eq!(calculate_omega_ratio(&[-1.0, -2.0, -3.0]), 0.0);
1676
1677 let omega = calculate_omega_ratio(&[2.0, -1.0, 3.0, -2.0]);
1679 assert!(
1680 (omega - 5.0 / 3.0).abs() < 1e-9,
1681 "Omega should be 5/3, got {omega}"
1682 );
1683 }
1684
1685 #[test]
1686 fn test_tail_ratio() {
1687 assert_eq!(calculate_tail_ratio(&[1.0]), 0.0);
1689
1690 let mut vals = vec![1.0f64; 16];
1693 vals.extend([-10.0, -5.0, 5.0, 10.0]);
1694 vals.sort_by(|a, b| a.partial_cmp(b).unwrap());
1695 let tr = calculate_tail_ratio(&vals);
1699 assert!(
1700 (tr - 2.0).abs() < 1e-9,
1701 "Tail ratio should be 2.0, got {tr}"
1702 );
1703
1704 let zeros_with_win = vec![
1706 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,
1707 0.0, 0.0, 5.0,
1708 ];
1709 assert_eq!(calculate_tail_ratio(&zeros_with_win), f64::MAX);
1710 }
1711
1712 #[test]
1713 fn test_ulcer_index() {
1714 let flat = vec![
1716 EquityPoint {
1717 timestamp: 0,
1718 equity: 100.0,
1719 drawdown_pct: 0.0,
1720 },
1721 EquityPoint {
1722 timestamp: 1,
1723 equity: 110.0,
1724 drawdown_pct: 0.0,
1725 },
1726 ];
1727 assert_eq!(calculate_ulcer_index(&flat), 0.0);
1728
1729 let dd = vec![
1732 EquityPoint {
1733 timestamp: 0,
1734 equity: 100.0,
1735 drawdown_pct: 0.1,
1736 },
1737 EquityPoint {
1738 timestamp: 1,
1739 equity: 90.0,
1740 drawdown_pct: 0.2,
1741 },
1742 ];
1743 let ui = calculate_ulcer_index(&dd);
1744 let expected = ((100.0f64 + 400.0) / 2.0).sqrt(); assert!(
1746 (ui - expected).abs() < 1e-9,
1747 "Ulcer index should be {expected}, got {ui}"
1748 );
1749 }
1750
1751 #[test]
1752 fn test_new_metrics_in_calculate() {
1753 let trades = vec![
1755 make_trade(100.0, 10.0, true),
1756 make_trade(200.0, 20.0, true),
1757 make_trade(-50.0, -5.0, true),
1758 ];
1759 let equity = vec![
1760 EquityPoint {
1761 timestamp: 0,
1762 equity: 10000.0,
1763 drawdown_pct: 0.0,
1764 },
1765 EquityPoint {
1766 timestamp: 1,
1767 equity: 10100.0,
1768 drawdown_pct: 0.0,
1769 },
1770 EquityPoint {
1771 timestamp: 2,
1772 equity: 10300.0,
1773 drawdown_pct: 0.0,
1774 },
1775 EquityPoint {
1776 timestamp: 3,
1777 equity: 10250.0,
1778 drawdown_pct: 0.005,
1779 },
1780 ];
1781 let m = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 3, 3, 0.0, 252.0);
1782
1783 assert!(
1786 m.kelly_criterion > 0.0,
1787 "Kelly should be positive for profitable strategy"
1788 );
1789
1790 assert!(m.sqn.is_finite(), "SQN should be finite");
1792
1793 assert!(
1796 m.expectancy > 0.0,
1797 "Expectancy should be positive in dollar terms"
1798 );
1799
1800 assert!(m.omega_ratio > 0.0 && m.omega_ratio.is_finite() || m.omega_ratio == f64::MAX);
1803
1804 assert!(m.ulcer_index >= 0.0);
1806
1807 assert!(m.recovery_factor > 0.0);
1809 }
1810
1811 #[test]
1812 fn test_profit_factor_all_wins_is_f64_max() {
1813 let trades = vec![make_trade(100.0, 10.0, true), make_trade(50.0, 5.0, true)];
1814 let equity = vec![
1815 EquityPoint {
1816 timestamp: 0,
1817 equity: 10000.0,
1818 drawdown_pct: 0.0,
1819 },
1820 EquityPoint {
1821 timestamp: 1,
1822 equity: 10150.0,
1823 drawdown_pct: 0.0,
1824 },
1825 ];
1826
1827 let metrics = PerformanceMetrics::calculate(&trades, &equity, 10000.0, 2, 2, 0.0, 252.0);
1828 assert_eq!(metrics.profit_factor, f64::MAX);
1829 }
1830
1831 use super::super::config::BacktestConfig;
1834 use crate::backtesting::position::Position;
1835 use chrono::{NaiveDate, Weekday};
1836
1837 fn make_trade_timed(pnl: f64, return_pct: f64, entry_ts: i64, exit_ts: i64) -> Trade {
1838 Trade {
1839 side: PositionSide::Long,
1840 entry_timestamp: entry_ts,
1841 exit_timestamp: exit_ts,
1842 entry_price: 100.0,
1843 exit_price: 100.0 + pnl / 10.0,
1844 quantity: 10.0,
1845 entry_quantity: 10.0,
1846 commission: 0.0,
1847 transaction_tax: 0.0,
1848 pnl,
1849 return_pct,
1850 dividend_income: 0.0,
1851 unreinvested_dividends: 0.0,
1852 tags: Vec::new(),
1853 is_partial: false,
1854 scale_sequence: 0,
1855 entry_signal: Signal::long(entry_ts, 100.0),
1856 exit_signal: Signal::exit(exit_ts, 100.0 + pnl / 10.0),
1857 }
1858 }
1859
1860 fn make_result(trades: Vec<Trade>, equity_curve: Vec<EquityPoint>) -> BacktestResult {
1863 let metrics = PerformanceMetrics::calculate(
1864 &trades,
1865 &equity_curve,
1866 10000.0,
1867 trades.len(),
1868 trades.len(),
1869 0.0,
1870 252.0,
1871 );
1872 BacktestResult {
1873 symbol: "TEST".to_string(),
1874 strategy_name: "TestStrategy".to_string(),
1875 config: BacktestConfig::default(),
1876 start_timestamp: equity_curve.first().map(|e| e.timestamp).unwrap_or(0),
1877 end_timestamp: equity_curve.last().map(|e| e.timestamp).unwrap_or(0),
1878 initial_capital: 10000.0,
1879 final_equity: equity_curve.last().map(|e| e.equity).unwrap_or(10000.0),
1880 metrics,
1881 trades,
1882 equity_curve,
1883 signals: vec![],
1884 open_position: None::<Position>,
1885 benchmark: None,
1886 diagnostics: vec![],
1887 }
1888 }
1889
1890 fn ts(date: &str) -> i64 {
1891 let d = NaiveDate::parse_from_str(date, "%Y-%m-%d").unwrap();
1892 d.and_hms_opt(12, 0, 0).unwrap().and_utc().timestamp()
1893 }
1894
1895 fn equity_point(timestamp: i64, equity: f64, drawdown_pct: f64) -> EquityPoint {
1896 EquityPoint {
1897 timestamp,
1898 equity,
1899 drawdown_pct,
1900 }
1901 }
1902
1903 #[test]
1906 fn rolling_sharpe_window_zero_returns_empty() {
1907 let result = make_result(
1908 vec![],
1909 vec![equity_point(0, 10000.0, 0.0), equity_point(1, 10100.0, 0.0)],
1910 );
1911 assert!(result.rolling_sharpe(0).is_empty());
1912 }
1913
1914 #[test]
1915 fn rolling_sharpe_insufficient_bars_returns_empty() {
1916 let result = make_result(
1918 vec![],
1919 vec![
1920 equity_point(0, 10000.0, 0.0),
1921 equity_point(1, 10100.0, 0.0),
1922 equity_point(2, 10200.0, 0.0),
1923 ],
1924 );
1925 assert!(result.rolling_sharpe(3).is_empty());
1926 }
1927
1928 #[test]
1929 fn rolling_sharpe_correct_length() {
1930 let pts: Vec<EquityPoint> = (0..5)
1932 .map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
1933 .collect();
1934 let result = make_result(vec![], pts);
1935 assert_eq!(result.rolling_sharpe(2).len(), 3);
1936 }
1937
1938 #[test]
1939 fn rolling_sharpe_monotone_increase_positive() {
1940 let pts: Vec<EquityPoint> = (0..10)
1942 .map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
1943 .collect();
1944 let result = make_result(vec![], pts);
1945 let sharpes = result.rolling_sharpe(3);
1946 assert!(!sharpes.is_empty());
1947 for s in &sharpes {
1948 assert!(
1949 *s > 0.0 || *s == f64::MAX,
1950 "expected positive Sharpe, got {s}"
1951 );
1952 }
1953 }
1954
1955 #[test]
1958 fn drawdown_series_mirrors_equity_curve() {
1959 let pts = vec![
1960 equity_point(0, 10000.0, 0.00),
1961 equity_point(1, 9500.0, 0.05),
1962 equity_point(2, 9000.0, 0.10),
1963 equity_point(3, 9200.0, 0.08),
1964 equity_point(4, 10000.0, 0.00),
1965 ];
1966 let result = make_result(vec![], pts.clone());
1967 let dd = result.drawdown_series();
1968 assert_eq!(dd.len(), pts.len());
1969 for (got, ep) in dd.iter().zip(pts.iter()) {
1970 assert!(
1971 (got - ep.drawdown_pct).abs() < f64::EPSILON,
1972 "expected {}, got {}",
1973 ep.drawdown_pct,
1974 got
1975 );
1976 }
1977 }
1978
1979 #[test]
1980 fn drawdown_series_empty_curve() {
1981 let result = make_result(vec![], vec![]);
1982 assert!(result.drawdown_series().is_empty());
1983 }
1984
1985 #[test]
1988 fn rolling_win_rate_window_zero_returns_empty() {
1989 let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
1990 assert!(result.rolling_win_rate(0).is_empty());
1991 }
1992
1993 #[test]
1994 fn rolling_win_rate_window_exceeds_trades_returns_empty() {
1995 let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
1996 assert!(result.rolling_win_rate(2).is_empty());
1997 }
1998
1999 #[test]
2000 fn rolling_win_rate_all_wins() {
2001 let trades = vec![
2002 make_trade(10.0, 1.0, true),
2003 make_trade(20.0, 2.0, true),
2004 make_trade(15.0, 1.5, true),
2005 ];
2006 let result = make_result(trades, vec![]);
2007 let wr = result.rolling_win_rate(2);
2008 assert_eq!(wr, vec![1.0, 1.0]);
2010 }
2011
2012 #[test]
2013 fn rolling_win_rate_alternating() {
2014 let trades = vec![
2016 make_trade(10.0, 1.0, true),
2017 make_trade(-10.0, -1.0, true),
2018 make_trade(10.0, 1.0, true),
2019 make_trade(-10.0, -1.0, true),
2020 ];
2021 let result = make_result(trades, vec![]);
2022 let wr = result.rolling_win_rate(2);
2023 assert_eq!(wr.len(), 3);
2024 for v in &wr {
2025 assert!((v - 0.5).abs() < f64::EPSILON, "expected 0.5, got {v}");
2026 }
2027 }
2028
2029 #[test]
2030 fn rolling_win_rate_correct_length() {
2031 let trades: Vec<Trade> = (0..5)
2032 .map(|i| make_trade(i as f64, i as f64, true))
2033 .collect();
2034 let result = make_result(trades, vec![]);
2035 assert_eq!(result.rolling_win_rate(3).len(), 3);
2037 }
2038
2039 #[test]
2040 fn rolling_win_rate_window_equals_trade_count_returns_one_element() {
2041 let trades = vec![
2043 make_trade(10.0, 1.0, true),
2044 make_trade(-5.0, -0.5, true),
2045 make_trade(8.0, 0.8, true),
2046 ];
2047 let result = make_result(trades, vec![]);
2048 let wr = result.rolling_win_rate(3);
2049 assert_eq!(wr.len(), 1);
2050 assert!((wr[0] - 2.0 / 3.0).abs() < f64::EPSILON);
2052 }
2053
2054 #[test]
2057 fn partial_period_adjust_zeroes_annualised_fields_for_short_slice() {
2058 let dummy_metrics = PerformanceMetrics::calculate(
2060 &[make_trade(100.0, 10.0, true)],
2061 &[equity_point(0, 10000.0, 0.0), equity_point(1, 11000.0, 0.0)],
2062 10000.0,
2063 0,
2064 0,
2065 0.0,
2066 252.0,
2067 );
2068 assert!(dummy_metrics.annualized_return_pct != 0.0);
2069 let adjusted = partial_period_adjust(dummy_metrics, 10, 252.0);
2070 assert_eq!(adjusted.annualized_return_pct, 0.0);
2071 assert_eq!(adjusted.calmar_ratio, 0.0);
2072 assert_eq!(adjusted.serenity_ratio, 0.0);
2073 }
2074
2075 #[test]
2076 fn partial_period_adjust_preserves_full_year_metrics() {
2077 let metrics = PerformanceMetrics::calculate(
2079 &[make_trade(100.0, 10.0, true)],
2080 &[equity_point(0, 10000.0, 0.0), equity_point(1, 11000.0, 0.0)],
2081 10000.0,
2082 0,
2083 0,
2084 0.0,
2085 252.0,
2086 );
2087 let ann_before = metrics.annualized_return_pct;
2088 let adjusted = partial_period_adjust(metrics, 252, 252.0);
2089 assert_eq!(adjusted.annualized_return_pct, ann_before);
2090 }
2091
2092 #[test]
2095 fn by_year_no_trades_empty() {
2096 let result = make_result(vec![], vec![equity_point(ts("2023-06-01"), 10000.0, 0.0)]);
2097 assert!(result.by_year().is_empty());
2098 }
2099
2100 #[test]
2101 fn by_year_splits_across_years() {
2102 let eq = vec![
2103 equity_point(ts("2022-06-15"), 10000.0, 0.0),
2104 equity_point(ts("2022-06-16"), 10100.0, 0.0),
2105 equity_point(ts("2023-06-15"), 10200.0, 0.0),
2106 equity_point(ts("2023-06-16"), 10300.0, 0.0),
2107 ];
2108 let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-15"), ts("2022-06-16"));
2109 let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-15"), ts("2023-06-16"));
2110 let result = make_result(vec![t1, t2], eq);
2111 let by_year = result.by_year();
2112 assert_eq!(by_year.len(), 2);
2113 assert!(by_year.contains_key(&2022));
2114 assert!(by_year.contains_key(&2023));
2115 assert_eq!(by_year[&2022].total_trades, 1);
2116 assert_eq!(by_year[&2023].total_trades, 1);
2117 }
2118
2119 #[test]
2120 fn by_year_all_same_year() {
2121 let eq = vec![
2122 equity_point(ts("2023-03-01"), 10000.0, 0.0),
2123 equity_point(ts("2023-06-01"), 10200.0, 0.0),
2124 equity_point(ts("2023-09-01"), 10500.0, 0.0),
2125 ];
2126 let t1 = make_trade_timed(200.0, 2.0, ts("2023-03-01"), ts("2023-06-01"));
2127 let t2 = make_trade_timed(300.0, 3.0, ts("2023-06-01"), ts("2023-09-01"));
2128 let result = make_result(vec![t1, t2], eq);
2129 let by_year = result.by_year();
2130 assert_eq!(by_year.len(), 1);
2131 assert!(by_year.contains_key(&2023));
2132 assert_eq!(by_year[&2023].total_trades, 2);
2133 }
2134
2135 #[test]
2138 fn by_month_splits_across_months() {
2139 let eq = vec![
2140 equity_point(ts("2023-03-15"), 10000.0, 0.0),
2141 equity_point(ts("2023-03-16"), 10100.0, 0.0),
2142 equity_point(ts("2023-07-15"), 10200.0, 0.0),
2143 equity_point(ts("2023-07-16"), 10300.0, 0.0),
2144 ];
2145 let t1 = make_trade_timed(100.0, 1.0, ts("2023-03-15"), ts("2023-03-16"));
2146 let t2 = make_trade_timed(100.0, 1.0, ts("2023-07-15"), ts("2023-07-16"));
2147 let result = make_result(vec![t1, t2], eq);
2148 let by_month = result.by_month();
2149 assert_eq!(by_month.len(), 2);
2150 assert!(by_month.contains_key(&(2023, 3)));
2151 assert!(by_month.contains_key(&(2023, 7)));
2152 }
2153
2154 #[test]
2155 fn by_month_same_month_different_years_are_separate_keys() {
2156 let eq = vec![
2157 equity_point(ts("2022-06-15"), 10000.0, 0.0),
2158 equity_point(ts("2023-06-15"), 10200.0, 0.0),
2159 ];
2160 let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-14"), ts("2022-06-15"));
2161 let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-14"), ts("2023-06-15"));
2162 let result = make_result(vec![t1, t2], eq);
2163 let by_month = result.by_month();
2164 assert_eq!(by_month.len(), 2);
2165 assert!(by_month.contains_key(&(2022, 6)));
2166 assert!(by_month.contains_key(&(2023, 6)));
2167 }
2168
2169 #[test]
2172 fn by_day_of_week_single_day() {
2173 let monday = ts("2023-01-02");
2175 let t1 = make_trade_timed(100.0, 1.0, monday - 86400, monday);
2176 let t2 = make_trade_timed(50.0, 0.5, monday - 86400 * 2, monday);
2177 let eq = vec![equity_point(monday, 10000.0, 0.0)];
2178 let result = make_result(vec![t1, t2], eq);
2179 let by_dow = result.by_day_of_week();
2180 assert_eq!(by_dow.len(), 1);
2181 assert!(by_dow.contains_key(&Weekday::Mon));
2182 assert_eq!(by_dow[&Weekday::Mon].total_trades, 2);
2183 }
2184
2185 #[test]
2186 fn by_day_of_week_multiple_days() {
2187 let monday = ts("2023-01-02");
2189 let tuesday = ts("2023-01-03");
2190 let t_mon = make_trade_timed(100.0, 1.0, monday - 86400, monday);
2191 let t_tue = make_trade_timed(-50.0, -0.5, tuesday - 86400, tuesday);
2192 let eq = vec![
2193 equity_point(monday, 10000.0, 0.0),
2194 equity_point(tuesday, 10100.0, 0.0),
2195 ];
2196 let result = make_result(vec![t_mon, t_tue], eq);
2197 let by_dow = result.by_day_of_week();
2198 assert_eq!(by_dow.len(), 2);
2199 assert!(by_dow.contains_key(&Weekday::Mon));
2200 assert!(by_dow.contains_key(&Weekday::Tue));
2201 assert_eq!(by_dow[&Weekday::Mon].total_trades, 1);
2202 assert_eq!(by_dow[&Weekday::Tue].total_trades, 1);
2203 assert_eq!(by_dow[&Weekday::Mon].winning_trades, 1);
2204 assert_eq!(by_dow[&Weekday::Tue].losing_trades, 1);
2205 }
2206
2207 #[test]
2208 fn by_day_of_week_no_trades_empty() {
2209 let result = make_result(vec![], vec![equity_point(ts("2023-01-02"), 10000.0, 0.0)]);
2210 assert!(result.by_day_of_week().is_empty());
2211 }
2212
2213 #[test]
2214 fn by_day_of_week_infers_weekly_bpy_for_daily_bars() {
2215 let base = ts("2023-01-02"); let week_secs = 7 * 86400i64;
2224 let n_weeks = 104usize;
2225 let equity_pts: Vec<EquityPoint> = (0..n_weeks)
2226 .map(|i| {
2227 equity_point(
2228 base + (i as i64) * week_secs,
2229 10000.0 + i as f64 * 10.0,
2230 0.0,
2231 )
2232 })
2233 .collect();
2234
2235 let trade = make_trade_timed(
2236 100.0,
2237 1.0,
2238 base,
2239 base + week_secs, );
2241 let result = make_result(vec![trade], equity_pts.clone());
2242 let by_dow = result.by_day_of_week();
2243
2244 assert!(by_dow.contains_key(&Weekday::Mon));
2249 let s = by_dow[&Weekday::Mon].sharpe_ratio;
2250 assert!(
2251 s.is_finite() || s == f64::MAX,
2252 "Sharpe should be finite, got {s}"
2253 );
2254 }
2255
2256 #[test]
2257 fn infer_bars_per_year_approximates_weekly_for_monday_subset() {
2258 let base = ts("2023-01-02");
2261 let week_secs = 7 * 86400i64;
2262 let pts: Vec<EquityPoint> = (0..104)
2263 .map(|i| equity_point(base + i * week_secs, 10000.0, 0.0))
2264 .collect();
2265 let bpy = infer_bars_per_year(&pts, 252.0);
2266 assert!(bpy > 48.0 && bpy < 56.0, "expected ~52, got {bpy}");
2268 }
2269
2270 fn make_tagged_trade(pnl: f64, tags: &[&str]) -> Trade {
2275 let entry_signal = tags
2276 .iter()
2277 .fold(Signal::long(0, 100.0), |sig, &t| sig.tag(t));
2278 let exit_price = 100.0 + pnl / 10.0;
2281 let exit_ts = 86400i64;
2282 let pos = Position::new(PositionSide::Long, 0, 100.0, 10.0, 0.0, entry_signal);
2283 pos.close(exit_ts, exit_price, 0.0, Signal::exit(exit_ts, exit_price))
2284 }
2285
2286 fn make_tagged_short_trade(pnl: f64, tags: &[&str]) -> Trade {
2288 let entry_signal = tags
2289 .iter()
2290 .fold(Signal::short(0, 100.0), |sig, &t| sig.tag(t));
2291 let exit_price = 100.0 - pnl / 10.0;
2294 let exit_ts = 86400i64;
2295 let pos = Position::new(PositionSide::Short, 0, 100.0, 10.0, 0.0, entry_signal);
2296 pos.close(exit_ts, exit_price, 0.0, Signal::exit(exit_ts, exit_price))
2297 }
2298
2299 #[test]
2302 fn signal_tag_builder_appends_tag() {
2303 let sig = Signal::long(0, 100.0).tag("breakout");
2304 assert_eq!(sig.tags, vec!["breakout"]);
2305 }
2306
2307 #[test]
2308 fn signal_tag_builder_chains_multiple_tags() {
2309 let sig = Signal::long(0, 100.0).tag("breakout").tag("high_volume");
2310 assert_eq!(sig.tags, vec!["breakout", "high_volume"]);
2311 }
2312
2313 #[test]
2314 fn signal_tag_builder_preserves_order() {
2315 let sig = Signal::long(0, 100.0).tag("a").tag("b").tag("c");
2316 assert_eq!(sig.tags, vec!["a", "b", "c"]);
2317 }
2318
2319 #[test]
2320 fn signal_constructors_start_with_empty_tags() {
2321 assert!(Signal::long(0, 0.0).tags.is_empty());
2322 assert!(Signal::short(0, 0.0).tags.is_empty());
2323 assert!(Signal::exit(0, 0.0).tags.is_empty());
2324 assert!(Signal::hold().tags.is_empty());
2325 }
2326
2327 #[test]
2330 fn position_close_propagates_entry_signal_tags_to_trade() {
2331 let entry_signal = Signal::long(0, 100.0).tag("breakout").tag("high_volume");
2332 let pos = Position::new(
2333 crate::backtesting::position::PositionSide::Long,
2334 0,
2335 100.0,
2336 10.0,
2337 0.0,
2338 entry_signal,
2339 );
2340 let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2341 assert_eq!(trade.tags, vec!["breakout", "high_volume"]);
2342 }
2343
2344 #[test]
2345 fn position_close_propagates_empty_tags_when_none_set() {
2346 let entry_signal = Signal::long(0, 100.0);
2347 let pos = Position::new(
2348 crate::backtesting::position::PositionSide::Long,
2349 0,
2350 100.0,
2351 10.0,
2352 0.0,
2353 entry_signal,
2354 );
2355 let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2356 assert!(trade.tags.is_empty());
2357 }
2358
2359 #[test]
2362 fn trades_by_tag_returns_matching_trades() {
2363 let result = make_result(
2364 vec![
2365 make_tagged_trade(100.0, &["breakout"]),
2366 make_tagged_trade(-50.0, &["reversal"]),
2367 make_tagged_trade(200.0, &["breakout", "high_volume"]),
2368 ],
2369 vec![equity_point(0, 10000.0, 0.0)],
2370 );
2371 let tagged = result.trades_by_tag("breakout");
2372 assert_eq!(tagged.len(), 2);
2373 assert!((tagged[0].pnl - 100.0).abs() < 1e-9);
2374 assert!((tagged[1].pnl - 200.0).abs() < 1e-9);
2375 }
2376
2377 #[test]
2378 fn trades_by_tag_returns_empty_for_missing_tag() {
2379 let result = make_result(
2380 vec![make_tagged_trade(100.0, &["breakout"])],
2381 vec![equity_point(0, 10000.0, 0.0)],
2382 );
2383 assert!(result.trades_by_tag("nonexistent").is_empty());
2384 }
2385
2386 #[test]
2387 fn trades_by_tag_returns_empty_when_no_trades_tagged() {
2388 let result = make_result(
2389 vec![make_trade(100.0, 10.0, true)],
2390 vec![equity_point(0, 10000.0, 0.0)],
2391 );
2392 assert!(result.trades_by_tag("breakout").is_empty());
2393 }
2394
2395 #[test]
2396 fn trades_by_tag_multi_tag_trade_matches_each_tag() {
2397 let result = make_result(
2398 vec![make_tagged_trade(100.0, &["a", "b", "c"])],
2399 vec![equity_point(0, 10000.0, 0.0)],
2400 );
2401 assert_eq!(result.trades_by_tag("a").len(), 1);
2402 assert_eq!(result.trades_by_tag("b").len(), 1);
2403 assert_eq!(result.trades_by_tag("c").len(), 1);
2404 assert_eq!(result.trades_by_tag("d").len(), 0);
2405 }
2406
2407 #[test]
2410 fn all_tags_returns_sorted_deduped_tags() {
2411 let result = make_result(
2412 vec![
2413 make_tagged_trade(10.0, &["z_tag", "a_tag"]),
2414 make_tagged_trade(10.0, &["m_tag", "a_tag"]),
2415 ],
2416 vec![equity_point(0, 10000.0, 0.0)],
2417 );
2418 let tags = result.all_tags();
2419 assert_eq!(tags, vec!["a_tag", "m_tag", "z_tag"]);
2420 }
2421
2422 #[test]
2423 fn all_tags_returns_empty_when_no_tagged_trades() {
2424 let result = make_result(
2425 vec![make_trade(100.0, 10.0, true)],
2426 vec![equity_point(0, 10000.0, 0.0)],
2427 );
2428 assert!(result.all_tags().is_empty());
2429 }
2430
2431 #[test]
2432 fn all_tags_returns_empty_when_no_trades() {
2433 let result = make_result(vec![], vec![equity_point(0, 10000.0, 0.0)]);
2434 assert!(result.all_tags().is_empty());
2435 }
2436
2437 #[test]
2440 fn metrics_by_tag_returns_empty_metrics_for_missing_tag() {
2441 let result = make_result(
2442 vec![make_tagged_trade(100.0, &["breakout"])],
2443 vec![equity_point(0, 10000.0, 0.0)],
2444 );
2445 let metrics = result.metrics_by_tag("nonexistent");
2446 assert_eq!(metrics.total_trades, 0);
2447 assert_eq!(metrics.win_rate, 0.0);
2448 }
2449
2450 #[test]
2451 fn metrics_by_tag_counts_only_tagged_trades() {
2452 let result = make_result(
2453 vec![
2454 make_tagged_trade(100.0, &["breakout"]),
2455 make_tagged_trade(200.0, &["breakout"]),
2456 make_tagged_trade(-50.0, &["reversal"]),
2457 ],
2458 vec![equity_point(0, 10000.0, 0.0)],
2459 );
2460 let metrics = result.metrics_by_tag("breakout");
2461 assert_eq!(metrics.total_trades, 2);
2462 assert_eq!(metrics.long_trades, 2);
2463 }
2464
2465 #[test]
2466 fn metrics_by_tag_win_rate_all_profitable() {
2467 let result = make_result(
2468 vec![
2469 make_tagged_trade(100.0, &["win"]),
2470 make_tagged_trade(200.0, &["win"]),
2471 ],
2472 vec![equity_point(0, 10000.0, 0.0)],
2473 );
2474 let metrics = result.metrics_by_tag("win");
2475 assert!(
2476 (metrics.win_rate - 1.0).abs() < 1e-9,
2477 "expected 100% win rate"
2478 );
2479 }
2480
2481 #[test]
2482 fn metrics_by_tag_win_rate_half_profitable() {
2483 let result = make_result(
2484 vec![
2485 make_tagged_trade(100.0, &["mixed"]),
2486 make_tagged_trade(-100.0, &["mixed"]),
2487 ],
2488 vec![equity_point(0, 10000.0, 0.0)],
2489 );
2490 let metrics = result.metrics_by_tag("mixed");
2491 assert!(
2492 (metrics.win_rate - 0.5).abs() < 1e-9,
2493 "expected 50% win rate, got {}",
2494 metrics.win_rate
2495 );
2496 }
2497
2498 #[test]
2499 fn metrics_by_tag_total_return_reflects_tagged_pnl() {
2500 let result = make_result(
2502 vec![
2503 make_tagged_trade(100.0, &["breakout"]),
2504 make_tagged_trade(200.0, &["breakout"]),
2505 make_tagged_trade(-500.0, &["other"]),
2506 ],
2507 vec![equity_point(0, 10000.0, 0.0)],
2508 );
2509 let metrics = result.metrics_by_tag("breakout");
2510 assert!(
2513 (metrics.total_return_pct - 3.0).abs() < 0.01,
2514 "expected 3%, got {}",
2515 metrics.total_return_pct
2516 );
2517 }
2518
2519 #[test]
2521 fn metrics_by_tag_mixed_long_short_counts_correctly() {
2522 let long_trade = make_tagged_trade(100.0, &["strategy"]);
2523 let short_trade = make_tagged_short_trade(50.0, &["strategy"]);
2524 assert!(long_trade.is_long());
2525 assert!(short_trade.is_short());
2526
2527 let result = make_result(
2528 vec![long_trade, short_trade],
2529 vec![equity_point(0, 10000.0, 0.0)],
2530 );
2531 let metrics = result.metrics_by_tag("strategy");
2532 assert_eq!(metrics.total_trades, 2);
2533 assert_eq!(metrics.long_trades, 1);
2534 assert_eq!(metrics.short_trades, 1);
2535 assert!(
2536 (metrics.win_rate - 1.0).abs() < 1e-9,
2537 "both trades are profitable"
2538 );
2539 }
2540
2541 #[test]
2543 fn all_tags_deduplicates_within_single_trade() {
2544 let sig = Signal::long(0, 100.0).tag("dup").tag("dup");
2545 let pos = Position::new(PositionSide::Long, 0, 100.0, 10.0, 0.0, sig);
2546 let trade = pos.close(86400, 110.0, 0.0, Signal::exit(86400, 110.0));
2547 assert_eq!(trade.tags, vec!["dup", "dup"]); let result = make_result(vec![trade], vec![equity_point(0, 10000.0, 0.0)]);
2549 assert_eq!(result.all_tags(), vec!["dup"]); }
2551
2552 #[test]
2554 fn trades_by_tag_is_case_sensitive() {
2555 let result = make_result(
2556 vec![make_tagged_trade(100.0, &["Breakout"])],
2557 vec![equity_point(0, 10000.0, 0.0)],
2558 );
2559 assert_eq!(result.trades_by_tag("Breakout").len(), 1);
2560 assert_eq!(result.trades_by_tag("breakout").len(), 0);
2561 assert_eq!(result.trades_by_tag("BREAKOUT").len(), 0);
2562 }
2563}