Skip to main content

rustrade_backtest/
result.rs

1//! Backtest result — aggregated metrics + the full trade ledger.
2
3use serde::{Deserialize, Serialize};
4
5use crate::metrics::{Outcome, TradeOutcome};
6
7/// Final outcome of a [`crate::Backtest::run`] call.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct BacktestResult {
10    /// Symbol the backtest was configured for. For multi-symbol
11    /// backtests this is a comma-separated list in config order.
12    pub symbol: String,
13    /// Initial cash balance.
14    pub initial_cash: f64,
15    /// Final cash balance (= initial + net realised PnL).
16    pub final_cash: f64,
17    /// Total realised PnL net of fees.
18    pub net_pnl: f64,
19    /// Sum of fees charged across every fill.
20    pub total_fees: f64,
21    /// Number of candles fed to the brain.
22    pub candles_processed: usize,
23    /// Number of non-`Hold` decisions emitted by the brain.
24    pub signals_emitted: usize,
25    /// Number of orders the engine placed (may be `< signals_emitted`
26    /// if the sizer returned 0 for some signals).
27    pub orders_filled: usize,
28    /// Per-trade outcomes, in chronological order.
29    pub trades: Vec<TradeOutcome>,
30    /// Maximum peak-to-trough drawdown of equity (cash) over the run,
31    /// in quote currency. Always `<= 0`.
32    pub max_drawdown: f64,
33    /// Portfolio equity at each sample point. The first element is
34    /// [`Self::initial_cash`]; one additional sample is appended per
35    /// candle in the merged event stream.
36    pub equity_curve: Vec<f64>,
37    /// Per-period simple returns derived from [`Self::equity_curve`].
38    /// Length is `equity_curve.len() - 1` for any non-empty run.
39    pub period_returns: Vec<f64>,
40    /// Per-period risk-free rate used by [`Self::sharpe_ratio`] and
41    /// [`Self::sortino_ratio`]. See [`crate::BacktestConfig::risk_free_rate`].
42    pub risk_free_rate: f64,
43    /// Annualisation factor for the Sharpe and Sortino ratios. See
44    /// [`crate::BacktestConfig::periods_per_year`].
45    pub periods_per_year: u32,
46}
47
48impl BacktestResult {
49    /// Total return as a percentage of initial cash.
50    pub fn total_return_pct(&self) -> f64 {
51        if self.initial_cash == 0.0 {
52            0.0
53        } else {
54            (self.net_pnl / self.initial_cash) * 100.0
55        }
56    }
57
58    /// Count of trades with net PnL > 0.
59    pub fn wins(&self) -> usize {
60        self.trades
61            .iter()
62            .filter(|t| t.outcome() == Outcome::Win)
63            .count()
64    }
65
66    /// Count of trades with net PnL < 0.
67    pub fn losses(&self) -> usize {
68        self.trades
69            .iter()
70            .filter(|t| t.outcome() == Outcome::Loss)
71            .count()
72    }
73
74    /// Count of trades with net PnL == 0.
75    pub fn breakevens(&self) -> usize {
76        self.trades
77            .iter()
78            .filter(|t| t.outcome() == Outcome::Breakeven)
79            .count()
80    }
81
82    /// Win rate over decided trades (excludes breakevens), in `[0, 1]`.
83    pub fn win_rate(&self) -> f64 {
84        let decided = self.wins() + self.losses();
85        if decided == 0 {
86            0.0
87        } else {
88            self.wins() as f64 / decided as f64
89        }
90    }
91
92    /// Sum of winning trades' net PnL / sum of losing trades' net PnL
93    /// (positive). `None` if there are no losing trades.
94    pub fn profit_factor(&self) -> Option<f64> {
95        let wins: f64 = self
96            .trades
97            .iter()
98            .filter(|t| t.outcome() == Outcome::Win)
99            .map(|t| t.net_pnl())
100            .sum();
101        let losses: f64 = self
102            .trades
103            .iter()
104            .filter(|t| t.outcome() == Outcome::Loss)
105            .map(|t| t.net_pnl().abs())
106            .sum();
107        if losses == 0.0 {
108            None
109        } else {
110            Some(wins / losses)
111        }
112    }
113
114    /// Annualised Sharpe ratio of the per-period returns.
115    ///
116    /// Computed as `√P · (mean(rᵢ - rf) / stddev(rᵢ))` where `rᵢ` is
117    /// each entry in [`Self::period_returns`], `rf` is
118    /// [`Self::risk_free_rate`], `stddev` is the sample standard
119    /// deviation (`N - 1` denominator), and `P` is
120    /// [`Self::periods_per_year`].
121    ///
122    /// Returns `None` when there are fewer than two return samples, or
123    /// when the sample stddev is zero (a perfectly flat equity curve —
124    /// Sharpe is undefined).
125    pub fn sharpe_ratio(&self) -> Option<f64> {
126        let r = &self.period_returns;
127        if r.len() < 2 {
128            return None;
129        }
130        let rf = self.risk_free_rate;
131        let excess: Vec<f64> = r.iter().map(|x| x - rf).collect();
132        let n = excess.len() as f64;
133        let mean = excess.iter().sum::<f64>() / n;
134        let variance = excess.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
135        let stddev = variance.sqrt();
136        if stddev == 0.0 || !stddev.is_finite() {
137            return None;
138        }
139        let scale = (self.periods_per_year as f64).sqrt();
140        Some(scale * mean / stddev)
141    }
142
143    /// Annualised Sortino ratio of the per-period returns.
144    ///
145    /// Same shape as Sharpe but only penalises downside deviation —
146    /// returns below `rf` contribute to the denominator, returns above
147    /// `rf` don't. Specifically `√P · mean(rᵢ - rf) / downside_dev`
148    /// where `downside_dev = √(Σ min(rᵢ - rf, 0)² / N)`. Returns
149    /// `None` if no returns are below `rf` (no downside to measure) or
150    /// fewer than two samples exist.
151    pub fn sortino_ratio(&self) -> Option<f64> {
152        let r = &self.period_returns;
153        if r.len() < 2 {
154            return None;
155        }
156        let rf = self.risk_free_rate;
157        let excess: Vec<f64> = r.iter().map(|x| x - rf).collect();
158        let n = excess.len() as f64;
159        let mean = excess.iter().sum::<f64>() / n;
160        let downside_var = excess
161            .iter()
162            .map(|x| if *x < 0.0 { x.powi(2) } else { 0.0 })
163            .sum::<f64>()
164            / n;
165        let downside_dev = downside_var.sqrt();
166        if downside_dev == 0.0 || !downside_dev.is_finite() {
167            return None;
168        }
169        let scale = (self.periods_per_year as f64).sqrt();
170        Some(scale * mean / downside_dev)
171    }
172
173    /// Pretty-printed multi-line summary suitable for logging.
174    pub fn summary(&self) -> String {
175        let pf = self
176            .profit_factor()
177            .map(|p| format!("{p:.3}"))
178            .unwrap_or_else(|| "∞ (no losses)".into());
179        let sharpe = self
180            .sharpe_ratio()
181            .map(|s| format!("{s:.3}"))
182            .unwrap_or_else(|| "n/a".into());
183        let sortino = self
184            .sortino_ratio()
185            .map(|s| format!("{s:.3}"))
186            .unwrap_or_else(|| "n/a".into());
187        format!(
188            "Backtest [{}]\n\
189             ├ candles_processed: {}\n\
190             ├ signals / orders : {} / {}\n\
191             ├ trades           : {} (W {} / L {} / BE {})\n\
192             ├ win_rate         : {:.2}%\n\
193             ├ profit_factor    : {pf}\n\
194             ├ sharpe / sortino : {sharpe} / {sortino}\n\
195             ├ total_return     : {:.4}%\n\
196             ├ net_pnl          : {:.4}\n\
197             ├ total_fees       : {:.4}\n\
198             ├ max_drawdown     : {:.4}\n\
199             └ final_cash       : {:.4}",
200            self.symbol,
201            self.candles_processed,
202            self.signals_emitted,
203            self.orders_filled,
204            self.trades.len(),
205            self.wins(),
206            self.losses(),
207            self.breakevens(),
208            self.win_rate() * 100.0,
209            self.total_return_pct(),
210            self.net_pnl,
211            self.total_fees,
212            self.max_drawdown,
213            self.final_cash,
214        )
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn baseline_result(period_returns: Vec<f64>) -> BacktestResult {
223        let mut equity = vec![10_000.0];
224        let mut prev = 10_000.0;
225        for r in &period_returns {
226            prev *= 1.0 + r;
227            equity.push(prev);
228        }
229        BacktestResult {
230            symbol: "X".into(),
231            initial_cash: 10_000.0,
232            final_cash: prev,
233            net_pnl: prev - 10_000.0,
234            total_fees: 0.0,
235            candles_processed: period_returns.len(),
236            signals_emitted: 0,
237            orders_filled: 0,
238            trades: Vec::new(),
239            max_drawdown: 0.0,
240            equity_curve: equity,
241            period_returns,
242            risk_free_rate: 0.0,
243            periods_per_year: 252,
244        }
245    }
246
247    #[test]
248    fn sharpe_none_with_one_sample() {
249        let r = baseline_result(vec![0.01]);
250        assert!(r.sharpe_ratio().is_none());
251    }
252
253    #[test]
254    fn sharpe_none_with_zero_variance() {
255        // All returns identically 0.0 → exact zero stddev (no FP noise).
256        let r = baseline_result(vec![0.0; 20]);
257        assert!(r.sharpe_ratio().is_none());
258    }
259
260    #[test]
261    fn sharpe_positive_on_uptrend_with_some_noise() {
262        // Mostly-positive returns with a couple negative blips.
263        let r = baseline_result(vec![
264            0.01, -0.002, 0.012, -0.001, 0.015, 0.008, -0.003, 0.011,
265        ]);
266        let s = r.sharpe_ratio().unwrap();
267        assert!(s > 0.0, "expected positive sharpe, got {s}");
268        // Annualised by sqrt(252) — so the scale factor is sensible.
269        assert!(s.is_finite());
270    }
271
272    #[test]
273    fn sortino_only_penalises_downside() {
274        // Same returns as sharpe test — sortino should be at least as
275        // high as sharpe because it ignores upside variance.
276        let r = baseline_result(vec![
277            0.01, -0.002, 0.012, -0.001, 0.015, 0.008, -0.003, 0.011,
278        ]);
279        let sharpe = r.sharpe_ratio().unwrap();
280        let sortino = r.sortino_ratio().unwrap();
281        assert!(
282            sortino >= sharpe - 1e-9,
283            "sortino={sortino} sharpe={sharpe}"
284        );
285    }
286
287    #[test]
288    fn sortino_none_when_no_downside() {
289        let r = baseline_result(vec![0.01, 0.005, 0.02, 0.001, 0.015]);
290        assert!(r.sortino_ratio().is_none());
291    }
292}