wickra_backtest_core/
metrics.rs1use serde::Serialize;
4
5use crate::portfolio::Trade;
6
7#[derive(Debug, Clone, Default, Serialize, PartialEq)]
9pub struct Metrics {
10 pub pnl: f64,
12 pub return_pct: f64,
14 pub sharpe: f64,
16 pub sortino: f64,
18 pub calmar: f64,
20 pub max_drawdown: f64,
22 pub win_rate: f64,
24 pub profit_factor: f64,
26 pub num_trades: usize,
28}
29
30pub fn compute(initial: f64, equity: &[f64], trades: &[Trade]) -> Metrics {
32 let final_equity = equity.last().copied().unwrap_or(initial);
33 let pnl = final_equity - initial;
34 let return_pct = if initial.abs() < f64::EPSILON {
35 0.0
36 } else {
37 pnl / initial * 100.0
38 };
39
40 let sharpe = sharpe_ratio(equity);
41 let sortino = sortino_ratio(equity);
42 let max_drawdown = max_drawdown_pct(equity);
43 let calmar = if max_drawdown.abs() < f64::EPSILON {
44 if return_pct > 0.0 {
45 f64::INFINITY
46 } else {
47 0.0
48 }
49 } else {
50 return_pct / max_drawdown
51 };
52
53 let wins = trades.iter().filter(|t| t.pnl > 0.0).count();
54 let win_rate = if trades.is_empty() {
55 0.0
56 } else {
57 wins as f64 / trades.len() as f64 * 100.0
58 };
59
60 let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum();
61 let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| -t.pnl).sum();
62 let profit_factor = if gross_loss.abs() < f64::EPSILON {
63 if gross_profit > 0.0 {
64 f64::INFINITY
65 } else {
66 0.0
67 }
68 } else {
69 gross_profit / gross_loss
70 };
71
72 Metrics {
73 pnl,
74 return_pct,
75 sharpe,
76 sortino,
77 calmar,
78 max_drawdown,
79 win_rate,
80 profit_factor,
81 num_trades: trades.len(),
82 }
83}
84
85fn bar_returns(equity: &[f64]) -> Vec<f64> {
86 equity
87 .windows(2)
88 .map(|w| {
89 if w[0].abs() < f64::EPSILON {
90 0.0
91 } else {
92 w[1] / w[0] - 1.0
93 }
94 })
95 .collect()
96}
97
98fn sharpe_ratio(equity: &[f64]) -> f64 {
99 if equity.len() < 2 {
100 return 0.0;
101 }
102 let returns = bar_returns(equity);
103 let n = returns.len() as f64;
104 let mean = returns.iter().sum::<f64>() / n;
105 let var = returns.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / n;
106 let std = var.sqrt();
107 if std.abs() < f64::EPSILON {
108 0.0
109 } else {
110 mean / std
111 }
112}
113
114fn sortino_ratio(equity: &[f64]) -> f64 {
115 if equity.len() < 2 {
116 return 0.0;
117 }
118 let returns = bar_returns(equity);
119 let n = returns.len() as f64;
120 let mean = returns.iter().sum::<f64>() / n;
121 let downside = returns.iter().map(|r| r.min(0.0).powi(2)).sum::<f64>() / n;
123 let dd = downside.sqrt();
124 if dd.abs() < f64::EPSILON {
125 0.0
126 } else {
127 mean / dd
128 }
129}
130
131fn max_drawdown_pct(equity: &[f64]) -> f64 {
132 let mut peak = f64::NEG_INFINITY;
133 let mut max_dd = 0.0;
134 for &e in equity {
135 if e > peak {
136 peak = e;
137 }
138 if peak > 0.0 {
139 let dd = (peak - e) / peak * 100.0;
140 if dd > max_dd {
141 max_dd = dd;
142 }
143 }
144 }
145 max_dd
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151
152 #[test]
153 fn flat_equity_is_zero_metrics() {
154 let m = compute(1000.0, &[1000.0, 1000.0, 1000.0], &[]);
155 assert!((m.pnl).abs() < 1e-9);
156 assert!((m.sharpe).abs() < 1e-9);
157 assert!((m.max_drawdown).abs() < 1e-9);
158 }
159
160 #[test]
161 fn drawdown_and_return() {
162 let m = compute(100.0, &[100.0, 120.0, 90.0, 110.0], &[]);
163 assert!((m.return_pct - 10.0).abs() < 1e-9); assert!((m.max_drawdown - 25.0).abs() < 1e-9);
166 assert!((m.calmar - 10.0 / 25.0).abs() < 1e-9);
168 }
169
170 #[test]
171 fn sortino_only_penalises_downside() {
172 let up = compute(100.0, &[100.0, 110.0, 115.0], &[]);
176 assert!(up.sortino.abs() < 1e-9);
177 assert!(up.sharpe > 0.0);
178
179 let mixed = compute(100.0, &[100.0, 110.0, 99.0, 105.0], &[]);
181 assert!(mixed.sortino.is_finite());
182 }
183
184 #[test]
185 fn calmar_is_infinite_without_drawdown() {
186 let m = compute(100.0, &[100.0, 110.0, 120.0], &[]);
187 assert!(m.max_drawdown.abs() < 1e-9);
188 assert!(m.calmar.is_infinite() && m.calmar > 0.0);
189 }
190}