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 and (when a
18    /// [`FundingModel`](crate::FundingModel) is configured) funding
19    /// cashflows — the per-trade nets plus funding settled on positions
20    /// still open at the end of the run.
21    pub net_pnl: f64,
22    /// Sum of fees charged across every fill.
23    pub total_fees: f64,
24    /// Total perp funding *collected* across the run, in quote currency
25    /// (`>= 0`). Includes settlements on positions still open at the end.
26    /// Always `0.0` with [`FundingModel::None`](crate::FundingModel::None)
27    /// (the default). `#[serde(default)]` so previously serialized
28    /// results still deserialize.
29    #[serde(default)]
30    pub funding_received: f64,
31    /// Total perp funding *paid* across the run, in quote currency,
32    /// stored as a positive number. Always `0.0` with funding off.
33    #[serde(default)]
34    pub funding_paid: f64,
35    /// Number of candles fed to the brain.
36    pub candles_processed: usize,
37    /// Number of non-`Hold` decisions emitted by the brain.
38    pub signals_emitted: usize,
39    /// Number of orders the engine placed (may be `< signals_emitted`
40    /// if the sizer returned 0 for some signals).
41    pub orders_filled: usize,
42    /// Number of non-`Hold` decisions blocked by a risk gate (the
43    /// session-PnL halt or the circuit breaker — see
44    /// [`crate::BacktestConfig::session_pnl`] /
45    /// [`crate::BacktestConfig::circuit_breaker`]). Always `0` when no
46    /// gate is configured.
47    #[serde(default)]
48    pub orders_blocked: usize,
49    /// Per-trade outcomes, in chronological order.
50    pub trades: Vec<TradeOutcome>,
51    /// Maximum peak-to-trough drawdown of equity (cash) over the run,
52    /// in quote currency. Always `<= 0`.
53    pub max_drawdown: f64,
54    /// Portfolio equity at each sample point. The first element is
55    /// [`Self::initial_cash`]; one additional sample is appended per
56    /// candle in the merged event stream.
57    pub equity_curve: Vec<f64>,
58    /// Candle timestamp (epoch ms — the same unit as
59    /// [`Candle::time`](rustrade_core::Candle)) of each
60    /// [`Self::equity_curve`] sample. The seed sample borrows the first
61    /// candle's timestamp. Empty for empty runs and for results
62    /// serialized before this field shipped (`#[serde(default)]`) — see
63    /// [`Self::equity_points`].
64    #[serde(default)]
65    pub equity_times: Vec<i64>,
66    /// Per-period simple returns derived from [`Self::equity_curve`].
67    /// Length is `equity_curve.len() - 1` for any non-empty run.
68    pub period_returns: Vec<f64>,
69    /// Per-period risk-free rate used by [`Self::sharpe_ratio`] and
70    /// [`Self::sortino_ratio`]. See [`crate::BacktestConfig::risk_free_rate`].
71    pub risk_free_rate: f64,
72    /// Annualisation factor for the Sharpe and Sortino ratios. See
73    /// [`crate::BacktestConfig::periods_per_year`].
74    pub periods_per_year: u32,
75}
76
77/// One timestamped sample of the portfolio equity curve — the export
78/// shape for plotting / persisting a run (see
79/// [`BacktestResult::equity_points`]).
80#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
81pub struct EquityPoint {
82    /// Candle timestamp of the sample, epoch milliseconds (the same unit
83    /// as [`Candle::time`](rustrade_core::Candle)).
84    pub time: i64,
85    /// Portfolio equity (cash + unrealised PnL) at the sample, in quote
86    /// currency.
87    pub equity: f64,
88}
89
90impl BacktestResult {
91    /// Total return as a percentage of initial cash.
92    pub fn total_return_pct(&self) -> f64 {
93        if self.initial_cash == 0.0 {
94            0.0
95        } else {
96            (self.net_pnl / self.initial_cash) * 100.0
97        }
98    }
99
100    /// Net funding cashflow over the run:
101    /// [`Self::funding_received`] − [`Self::funding_paid`]. Positive =
102    /// the run collected more funding than it paid. `0.0` with funding
103    /// modelling off.
104    pub fn net_funding(&self) -> f64 {
105        self.funding_received - self.funding_paid
106    }
107
108    /// The equity curve as timestamped points, zipping
109    /// [`Self::equity_times`] with [`Self::equity_curve`]. Empty for
110    /// empty runs and for results deserialized from versions that
111    /// predate [`Self::equity_times`] (the timestamps are unknowable
112    /// there — fall back to [`Self::equity_curve`] indices).
113    pub fn equity_points(&self) -> Vec<EquityPoint> {
114        self.equity_times
115            .iter()
116            .zip(self.equity_curve.iter())
117            .map(|(&time, &equity)| EquityPoint { time, equity })
118            .collect()
119    }
120
121    /// Count of trades with net PnL > 0.
122    pub fn wins(&self) -> usize {
123        self.trades
124            .iter()
125            .filter(|t| t.outcome() == Outcome::Win)
126            .count()
127    }
128
129    /// Count of trades with net PnL < 0.
130    pub fn losses(&self) -> usize {
131        self.trades
132            .iter()
133            .filter(|t| t.outcome() == Outcome::Loss)
134            .count()
135    }
136
137    /// Count of trades with net PnL == 0.
138    pub fn breakevens(&self) -> usize {
139        self.trades
140            .iter()
141            .filter(|t| t.outcome() == Outcome::Breakeven)
142            .count()
143    }
144
145    /// Win rate over decided trades (excludes breakevens), in `[0, 1]`.
146    pub fn win_rate(&self) -> f64 {
147        let decided = self.wins() + self.losses();
148        if decided == 0 {
149            0.0
150        } else {
151            self.wins() as f64 / decided as f64
152        }
153    }
154
155    /// Mean net PnL per trade across **all** trades (wins, losses and
156    /// breakevens) — the classic per-trade expectancy. `None` when the
157    /// run closed no trades.
158    pub fn expectancy(&self) -> Option<f64> {
159        if self.trades.is_empty() {
160            return None;
161        }
162        let total: f64 = self.trades.iter().map(TradeOutcome::net_pnl).sum();
163        Some(total / self.trades.len() as f64)
164    }
165
166    /// Mean net PnL of winning trades (`> 0`). `None` when there are no
167    /// winning trades.
168    pub fn avg_win(&self) -> Option<f64> {
169        self.mean_net_where(Outcome::Win)
170    }
171
172    /// Mean net PnL of losing trades — a **negative** number (the sign
173    /// is kept so `expectancy ≈ win_rate·avg_win + loss_rate·avg_loss`
174    /// holds without juggling signs). `None` when there are no losing
175    /// trades.
176    pub fn avg_loss(&self) -> Option<f64> {
177        self.mean_net_where(Outcome::Loss)
178    }
179
180    fn mean_net_where(&self, outcome: Outcome) -> Option<f64> {
181        let mut total = 0.0;
182        let mut n = 0usize;
183        for t in self.trades.iter().filter(|t| t.outcome() == outcome) {
184            total += t.net_pnl();
185            n += 1;
186        }
187        if n == 0 { None } else { Some(total / n as f64) }
188    }
189
190    /// Sum of winning trades' net PnL / sum of losing trades' net PnL
191    /// (positive). `None` if there are no losing trades.
192    pub fn profit_factor(&self) -> Option<f64> {
193        let wins: f64 = self
194            .trades
195            .iter()
196            .filter(|t| t.outcome() == Outcome::Win)
197            .map(|t| t.net_pnl())
198            .sum();
199        let losses: f64 = self
200            .trades
201            .iter()
202            .filter(|t| t.outcome() == Outcome::Loss)
203            .map(|t| t.net_pnl().abs())
204            .sum();
205        if losses == 0.0 {
206            None
207        } else {
208            Some(wins / losses)
209        }
210    }
211
212    /// Annualised Sharpe ratio of the per-period returns.
213    ///
214    /// Computed as `√P · (mean(rᵢ - rf) / stddev(rᵢ))` where `rᵢ` is
215    /// each entry in [`Self::period_returns`], `rf` is
216    /// [`Self::risk_free_rate`], `stddev` is the sample standard
217    /// deviation (`N - 1` denominator), and `P` is
218    /// [`Self::periods_per_year`].
219    ///
220    /// Returns `None` when there are fewer than two return samples, or
221    /// when the sample stddev is zero (a perfectly flat equity curve —
222    /// Sharpe is undefined).
223    pub fn sharpe_ratio(&self) -> Option<f64> {
224        let r = &self.period_returns;
225        if r.len() < 2 {
226            return None;
227        }
228        let rf = self.risk_free_rate;
229        let excess: Vec<f64> = r.iter().map(|x| x - rf).collect();
230        let n = excess.len() as f64;
231        let mean = excess.iter().sum::<f64>() / n;
232        let variance = excess.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
233        let stddev = variance.sqrt();
234        if stddev == 0.0 || !stddev.is_finite() {
235            return None;
236        }
237        let scale = (self.periods_per_year as f64).sqrt();
238        Some(scale * mean / stddev)
239    }
240
241    /// Annualised Sortino ratio of the per-period returns.
242    ///
243    /// Same shape as Sharpe but only penalises downside deviation —
244    /// returns below `rf` contribute to the denominator, returns above
245    /// `rf` don't. Specifically `√P · mean(rᵢ - rf) / downside_dev`
246    /// where `downside_dev = √(Σ min(rᵢ - rf, 0)² / N)`. Returns
247    /// `None` if no returns are below `rf` (no downside to measure) or
248    /// fewer than two samples exist.
249    pub fn sortino_ratio(&self) -> Option<f64> {
250        let r = &self.period_returns;
251        if r.len() < 2 {
252            return None;
253        }
254        let rf = self.risk_free_rate;
255        let excess: Vec<f64> = r.iter().map(|x| x - rf).collect();
256        let n = excess.len() as f64;
257        let mean = excess.iter().sum::<f64>() / n;
258        let downside_var = excess
259            .iter()
260            .map(|x| if *x < 0.0 { x.powi(2) } else { 0.0 })
261            .sum::<f64>()
262            / n;
263        let downside_dev = downside_var.sqrt();
264        if downside_dev == 0.0 || !downside_dev.is_finite() {
265            return None;
266        }
267        let scale = (self.periods_per_year as f64).sqrt();
268        Some(scale * mean / downside_dev)
269    }
270
271    /// Pretty-printed multi-line summary suitable for logging.
272    pub fn summary(&self) -> String {
273        let pf = self
274            .profit_factor()
275            .map(|p| format!("{p:.3}"))
276            .unwrap_or_else(|| "∞ (no losses)".into());
277        let sharpe = self
278            .sharpe_ratio()
279            .map(|s| format!("{s:.3}"))
280            .unwrap_or_else(|| "n/a".into());
281        let sortino = self
282            .sortino_ratio()
283            .map(|s| format!("{s:.3}"))
284            .unwrap_or_else(|| "n/a".into());
285        let fmt_opt = |v: Option<f64>| v.map(|x| format!("{x:.4}")).unwrap_or_else(|| "n/a".into());
286        let expectancy = fmt_opt(self.expectancy());
287        let avg_win = fmt_opt(self.avg_win());
288        let avg_loss = fmt_opt(self.avg_loss());
289        format!(
290            "Backtest [{}]\n\
291             ├ candles_processed: {}\n\
292             ├ signals / orders : {} / {} ({} risk-blocked)\n\
293             ├ trades           : {} (W {} / L {} / BE {})\n\
294             ├ win_rate         : {:.2}%\n\
295             ├ expectancy       : {expectancy}\n\
296             ├ avg win / loss   : {avg_win} / {avg_loss}\n\
297             ├ profit_factor    : {pf}\n\
298             ├ sharpe / sortino : {sharpe} / {sortino}\n\
299             ├ total_return     : {:.4}%\n\
300             ├ net_pnl          : {:.4}\n\
301             ├ total_fees       : {:.4}\n\
302             ├ funding recv/paid: {:.4} / {:.4} (net {:.4})\n\
303             ├ max_drawdown     : {:.4}\n\
304             └ final_cash       : {:.4}",
305            self.symbol,
306            self.candles_processed,
307            self.signals_emitted,
308            self.orders_filled,
309            self.orders_blocked,
310            self.trades.len(),
311            self.wins(),
312            self.losses(),
313            self.breakevens(),
314            self.win_rate() * 100.0,
315            self.total_return_pct(),
316            self.net_pnl,
317            self.total_fees,
318            self.funding_received,
319            self.funding_paid,
320            self.net_funding(),
321            self.max_drawdown,
322            self.final_cash,
323        )
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    fn baseline_result(period_returns: Vec<f64>) -> BacktestResult {
332        let mut equity = vec![10_000.0];
333        let mut prev = 10_000.0;
334        for r in &period_returns {
335            prev *= 1.0 + r;
336            equity.push(prev);
337        }
338        BacktestResult {
339            symbol: "X".into(),
340            initial_cash: 10_000.0,
341            final_cash: prev,
342            net_pnl: prev - 10_000.0,
343            total_fees: 0.0,
344            funding_received: 0.0,
345            funding_paid: 0.0,
346            candles_processed: period_returns.len(),
347            signals_emitted: 0,
348            orders_filled: 0,
349            orders_blocked: 0,
350            trades: Vec::new(),
351            max_drawdown: 0.0,
352            equity_curve: equity,
353            equity_times: Vec::new(),
354            period_returns,
355            risk_free_rate: 0.0,
356            periods_per_year: 252,
357        }
358    }
359
360    #[test]
361    fn sharpe_none_with_one_sample() {
362        let r = baseline_result(vec![0.01]);
363        assert!(r.sharpe_ratio().is_none());
364    }
365
366    #[test]
367    fn sharpe_none_with_zero_variance() {
368        // All returns identically 0.0 → exact zero stddev (no FP noise).
369        let r = baseline_result(vec![0.0; 20]);
370        assert!(r.sharpe_ratio().is_none());
371    }
372
373    #[test]
374    fn sharpe_positive_on_uptrend_with_some_noise() {
375        // Mostly-positive returns with a couple negative blips.
376        let r = baseline_result(vec![
377            0.01, -0.002, 0.012, -0.001, 0.015, 0.008, -0.003, 0.011,
378        ]);
379        let s = r.sharpe_ratio().unwrap();
380        assert!(s > 0.0, "expected positive sharpe, got {s}");
381        // Annualised by sqrt(252) — so the scale factor is sensible.
382        assert!(s.is_finite());
383    }
384
385    #[test]
386    fn sortino_only_penalises_downside() {
387        // Same returns as sharpe test — sortino should be at least as
388        // high as sharpe because it ignores upside variance.
389        let r = baseline_result(vec![
390            0.01, -0.002, 0.012, -0.001, 0.015, 0.008, -0.003, 0.011,
391        ]);
392        let sharpe = r.sharpe_ratio().unwrap();
393        let sortino = r.sortino_ratio().unwrap();
394        assert!(
395            sortino >= sharpe - 1e-9,
396            "sortino={sortino} sharpe={sharpe}"
397        );
398    }
399
400    #[test]
401    fn sortino_none_when_no_downside() {
402        let r = baseline_result(vec![0.01, 0.005, 0.02, 0.001, 0.015]);
403        assert!(r.sortino_ratio().is_none());
404    }
405
406    // ── Trade-stat math (expectancy / avg win / avg loss) ──────────────
407
408    /// A trade whose net PnL is exactly `net` (gross = net, no fee, no
409    /// funding).
410    fn trade(net: f64) -> TradeOutcome {
411        TradeOutcome {
412            symbol: "X".into(),
413            close_side: rustrade_core::Side::Sell,
414            qty: 1.0,
415            entry_price: 100.0,
416            exit_price: 100.0 + net,
417            gross_pnl: net,
418            fee: 0.0,
419            funding: 0.0,
420            closed_at: chrono::Utc::now(),
421        }
422    }
423
424    #[test]
425    fn trade_stats_on_known_trade_set() {
426        let mut r = baseline_result(vec![0.0; 4]);
427        r.trades = vec![trade(10.0), trade(20.0), trade(-5.0), trade(0.0)];
428
429        assert_eq!(r.wins(), 2);
430        assert_eq!(r.losses(), 1);
431        assert_eq!(r.breakevens(), 1);
432        assert!((r.avg_win().unwrap() - 15.0).abs() < 1e-12);
433        assert!((r.avg_loss().unwrap() - (-5.0)).abs() < 1e-12);
434        // Expectancy over ALL 4 trades: (10 + 20 - 5 + 0) / 4 = 6.25.
435        assert!((r.expectancy().unwrap() - 6.25).abs() < 1e-12);
436        // Decomposition identity: N·E = W·avg_win + L·avg_loss.
437        let n = r.trades.len() as f64;
438        let lhs = n * r.expectancy().unwrap();
439        let rhs =
440            r.wins() as f64 * r.avg_win().unwrap() + r.losses() as f64 * r.avg_loss().unwrap();
441        assert!((lhs - rhs).abs() < 1e-12);
442    }
443
444    #[test]
445    fn trade_stats_none_without_matching_trades() {
446        let r = baseline_result(vec![0.0; 2]);
447        assert!(r.expectancy().is_none());
448        assert!(r.avg_win().is_none());
449        assert!(r.avg_loss().is_none());
450
451        let mut winners_only = baseline_result(vec![0.0; 2]);
452        winners_only.trades = vec![trade(3.0)];
453        assert!(winners_only.avg_win().is_some());
454        assert!(winners_only.avg_loss().is_none());
455        assert!((winners_only.expectancy().unwrap() - 3.0).abs() < 1e-12);
456    }
457
458    #[test]
459    fn trade_net_includes_funding() {
460        // gross 5, fee 1, funding +2 → net 6; funding flips a loser into
461        // a winner when it covers the deficit.
462        let mut t = trade(5.0);
463        t.fee = 1.0;
464        t.funding = 2.0;
465        assert!((t.net_pnl() - 6.0).abs() < 1e-12);
466
467        let mut r = baseline_result(vec![0.0; 1]);
468        r.trades = vec![t];
469        assert_eq!(r.wins(), 1);
470        assert!((r.expectancy().unwrap() - 6.0).abs() < 1e-12);
471    }
472
473    // ── Equity-curve export ─────────────────────────────────────────────
474
475    #[test]
476    fn equity_points_zip_times_with_curve() {
477        let mut r = baseline_result(vec![0.01, -0.01]);
478        // baseline_result builds a 3-sample curve; timestamp them.
479        r.equity_times = vec![1_000, 1_000, 2_000];
480        let points = r.equity_points();
481        assert_eq!(points.len(), 3);
482        assert_eq!(points[0].time, 1_000);
483        assert_eq!(points[2].time, 2_000);
484        assert_eq!(points[1].equity, r.equity_curve[1]);
485    }
486
487    #[test]
488    fn equity_points_empty_without_timestamps() {
489        // Results deserialized from pre-`equity_times` versions have an
490        // empty times vec — the export degrades to empty, not garbage.
491        let r = baseline_result(vec![0.01, -0.01]);
492        assert!(r.equity_times.is_empty());
493        assert!(r.equity_points().is_empty());
494    }
495
496    #[test]
497    fn net_funding_is_received_minus_paid() {
498        let mut r = baseline_result(vec![0.0]);
499        r.funding_received = 3.5;
500        r.funding_paid = 1.25;
501        assert!((r.net_funding() - 2.25).abs() < 1e-12);
502    }
503}