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