Skip to main content

wickra_backtest_core/
metrics.rs

1//! Performance metrics computed from the equity curve and the trade log.
2
3use serde::Serialize;
4
5use crate::portfolio::Trade;
6
7/// Summary performance metrics.
8#[derive(Debug, Clone, Default, Serialize, PartialEq)]
9pub struct Metrics {
10    /// Absolute `PnL` (final equity − initial equity).
11    pub pnl: f64,
12    /// Total return in percent.
13    pub return_pct: f64,
14    /// Per-bar Sharpe ratio (mean / std of per-bar equity returns).
15    pub sharpe: f64,
16    /// Per-bar Sortino ratio (mean / downside deviation of per-bar returns).
17    pub sortino: f64,
18    /// Calmar ratio (total return divided by maximum drawdown).
19    pub calmar: f64,
20    /// Maximum drawdown in percent (peak-to-trough).
21    pub max_drawdown: f64,
22    /// Fraction of trades that were profitable, in percent.
23    pub win_rate: f64,
24    /// Gross profit divided by gross loss.
25    pub profit_factor: f64,
26    /// Number of completed trades.
27    pub num_trades: usize,
28}
29
30/// Compute metrics from the starting equity, the per-bar equity series and trades.
31pub 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    // Downside deviation: root-mean-square of the returns below the zero target.
122    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); // 100 -> 110
164                                                     // peak 120 -> trough 90 = 25% drawdown
165        assert!((m.max_drawdown - 25.0).abs() < 1e-9);
166        // Calmar = total return / max drawdown.
167        assert!((m.calmar - 10.0 / 25.0).abs() < 1e-9);
168    }
169
170    #[test]
171    fn sortino_only_penalises_downside() {
172        // A rising curve with varying (but never negative) returns has no
173        // downside deviation, so Sortino is zero by convention while the
174        // varying returns still give a positive Sharpe.
175        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        // With a drawdown the downside deviation is positive and finite.
180        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}