Skip to main content

finance_query/backtesting/result/
rolling.rs

1use super::BacktestResult;
2use super::stats::{calculate_periodic_returns, calculate_risk_ratios};
3
4impl BacktestResult {
5    // ─── Rolling & temporal analysis ─────────────────────────────────────────
6
7    /// Rolling Sharpe ratio over a sliding window of equity-curve bars.
8    ///
9    /// For each window of `window` consecutive bar-to-bar returns, computes
10    /// the Sharpe ratio using the same `risk_free_rate` and `bars_per_year`
11    /// as the overall backtest.  The first element corresponds to bars
12    /// `0..window` of the equity curve.
13    ///
14    /// Returns an empty vector when `window == 0` or when the equity curve
15    /// contains fewer than `window + 1` bars (i.e. fewer than `window`
16    /// return periods).
17    ///
18    /// # Statistical reliability
19    ///
20    /// Sharpe and Sortino are computed from `window` return observations using
21    /// sample variance (`n − 1` degrees of freedom).  Very small windows
22    /// produce extreme and unreliable values — at least **30 bars** is a
23    /// practical lower bound; **60–252** is typical for daily backtests.
24    pub fn rolling_sharpe(&self, window: usize) -> Vec<f64> {
25        if window == 0 {
26            return vec![];
27        }
28        let returns = calculate_periodic_returns(&self.equity_curve);
29        if returns.len() < window {
30            return vec![];
31        }
32        let rf = self.config.risk_free_rate;
33        let bpy = self.config.bars_per_year;
34        returns
35            .windows(window)
36            .map(|w| {
37                let (sharpe, _) = calculate_risk_ratios(w, rf, bpy);
38                sharpe
39            })
40            .collect()
41    }
42
43    /// Running drawdown fraction at each bar of the equity curve (0.0–1.0).
44    ///
45    /// Each value is the fractional decline from the running all-time-high
46    /// equity up to that bar: `0.0` means the equity is at a new peak; `0.2`
47    /// means it is 20% below the highest value seen so far.
48    ///
49    /// **This is not a sliding-window computation.** Values are read directly
50    /// from the precomputed [`EquityPoint::drawdown_pct`] field, which tracks
51    /// the running-peak drawdown since the backtest began.  To compute the
52    /// *maximum* drawdown within a rolling N-bar window (regime-change
53    /// detection), iterate over [`BacktestResult::equity_curve`] manually.
54    ///
55    /// The returned vector has the same length as
56    /// [`BacktestResult::equity_curve`].
57    ///
58    /// [`EquityPoint::drawdown_pct`]: super::EquityPoint::drawdown_pct
59    pub fn drawdown_series(&self) -> Vec<f64> {
60        self.equity_curve.iter().map(|p| p.drawdown_pct).collect()
61    }
62
63    /// Rolling win rate over a sliding window of consecutive closed trades.
64    ///
65    /// For each window of `window` trades (ordered by exit timestamp as stored
66    /// in the trade log), returns the fraction of winning trades in that
67    /// window.  The first element corresponds to trades `0..window`.
68    ///
69    /// This is a **trade-count window**, not a time window.  To compute win
70    /// rate over a fixed calendar period, use [`by_year`](Self::by_year),
71    /// [`by_month`](Self::by_month), or filter [`BacktestResult::trades`]
72    /// directly by timestamp.
73    ///
74    /// Returns an empty vector when `window == 0` or when fewer than `window`
75    /// trades were closed.
76    pub fn rolling_win_rate(&self, window: usize) -> Vec<f64> {
77        if window == 0 || self.trades.len() < window {
78            return vec![];
79        }
80        self.trades
81            .windows(window)
82            .map(|w| {
83                let wins = w.iter().filter(|t| t.is_profitable()).count();
84                wins as f64 / window as f64
85            })
86            .collect()
87    }
88}
89
90#[cfg(test)]
91mod tests {
92    use super::super::EquityPoint;
93    use super::super::fixtures::{equity_point, make_result, make_trade};
94    use crate::backtesting::position::Trade;
95
96    // ─── Rolling & temporal analysis ─────────────────────────────────────────
97
98    // ── rolling_sharpe ────────────────────────────────────────────────────────
99
100    #[test]
101    fn rolling_sharpe_window_zero_returns_empty() {
102        let result = make_result(
103            vec![],
104            vec![equity_point(0, 10000.0, 0.0), equity_point(1, 10100.0, 0.0)],
105        );
106        assert!(result.rolling_sharpe(0).is_empty());
107    }
108
109    #[test]
110    fn rolling_sharpe_insufficient_bars_returns_empty() {
111        // 3 equity points → 2 returns; window=3 needs 3 returns → empty
112        let result = make_result(
113            vec![],
114            vec![
115                equity_point(0, 10000.0, 0.0),
116                equity_point(1, 10100.0, 0.0),
117                equity_point(2, 10200.0, 0.0),
118            ],
119        );
120        assert!(result.rolling_sharpe(3).is_empty());
121    }
122
123    #[test]
124    fn rolling_sharpe_correct_length() {
125        // 5 equity points → 4 returns; window=2 → 3 values
126        let pts: Vec<EquityPoint> = (0..5)
127            .map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
128            .collect();
129        let result = make_result(vec![], pts);
130        assert_eq!(result.rolling_sharpe(2).len(), 3);
131    }
132
133    #[test]
134    fn rolling_sharpe_monotone_increase_positive() {
135        // Strictly increasing equity → all positive Sharpe values
136        let pts: Vec<EquityPoint> = (0..10)
137            .map(|i| equity_point(i, 10000.0 + i as f64 * 100.0, 0.0))
138            .collect();
139        let result = make_result(vec![], pts);
140        let sharpes = result.rolling_sharpe(3);
141        assert!(!sharpes.is_empty());
142        for s in &sharpes {
143            assert!(
144                *s > 0.0 || *s == f64::MAX,
145                "expected positive Sharpe, got {s}"
146            );
147        }
148    }
149
150    // ── drawdown_series ───────────────────────────────────────────────────────
151
152    #[test]
153    fn drawdown_series_mirrors_equity_curve() {
154        let pts = vec![
155            equity_point(0, 10000.0, 0.00),
156            equity_point(1, 9500.0, 0.05),
157            equity_point(2, 9000.0, 0.10),
158            equity_point(3, 9200.0, 0.08),
159            equity_point(4, 10000.0, 0.00),
160        ];
161        let result = make_result(vec![], pts.clone());
162        let dd = result.drawdown_series();
163        assert_eq!(dd.len(), pts.len());
164        for (got, ep) in dd.iter().zip(pts.iter()) {
165            assert!(
166                (got - ep.drawdown_pct).abs() < f64::EPSILON,
167                "expected {}, got {}",
168                ep.drawdown_pct,
169                got
170            );
171        }
172    }
173
174    #[test]
175    fn drawdown_series_empty_curve() {
176        let result = make_result(vec![], vec![]);
177        assert!(result.drawdown_series().is_empty());
178    }
179
180    // ── rolling_win_rate ──────────────────────────────────────────────────────
181
182    #[test]
183    fn rolling_win_rate_window_zero_returns_empty() {
184        let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
185        assert!(result.rolling_win_rate(0).is_empty());
186    }
187
188    #[test]
189    fn rolling_win_rate_window_exceeds_trades_returns_empty() {
190        let result = make_result(vec![make_trade(50.0, 5.0, true)], vec![]);
191        assert!(result.rolling_win_rate(2).is_empty());
192    }
193
194    #[test]
195    fn rolling_win_rate_all_wins() {
196        let trades = vec![
197            make_trade(10.0, 1.0, true),
198            make_trade(20.0, 2.0, true),
199            make_trade(15.0, 1.5, true),
200        ];
201        let result = make_result(trades, vec![]);
202        let wr = result.rolling_win_rate(2);
203        // 3 trades, window=2 → 2 values, each 1.0
204        assert_eq!(wr, vec![1.0, 1.0]);
205    }
206
207    #[test]
208    fn rolling_win_rate_alternating() {
209        // win, loss, win, loss → window=2 → [0.5, 0.5, 0.5]
210        let trades = vec![
211            make_trade(10.0, 1.0, true),
212            make_trade(-10.0, -1.0, true),
213            make_trade(10.0, 1.0, true),
214            make_trade(-10.0, -1.0, true),
215        ];
216        let result = make_result(trades, vec![]);
217        let wr = result.rolling_win_rate(2);
218        assert_eq!(wr.len(), 3);
219        for v in &wr {
220            assert!((v - 0.5).abs() < f64::EPSILON, "expected 0.5, got {v}");
221        }
222    }
223
224    #[test]
225    fn rolling_win_rate_correct_length() {
226        let trades: Vec<Trade> = (0..5)
227            .map(|i| make_trade(i as f64, i as f64, true))
228            .collect();
229        let result = make_result(trades, vec![]);
230        // 5 trades, window=3 → 3 values
231        assert_eq!(result.rolling_win_rate(3).len(), 3);
232    }
233
234    #[test]
235    fn rolling_win_rate_window_equals_trade_count_returns_one_element() {
236        // L-2: boundary — window == trades.len() → exactly 1 element
237        let trades = vec![
238            make_trade(10.0, 1.0, true),
239            make_trade(-5.0, -0.5, true),
240            make_trade(8.0, 0.8, true),
241        ];
242        let result = make_result(trades, vec![]);
243        let wr = result.rolling_win_rate(3);
244        assert_eq!(wr.len(), 1);
245        // 2 wins out of 3
246        assert!((wr[0] - 2.0 / 3.0).abs() < f64::EPSILON);
247    }
248}