Skip to main content

finance_query/backtesting/result/
periods.rs

1use std::collections::HashMap;
2
3use chrono::{Datelike, Weekday};
4
5use super::stats::{datetime_from_timestamp, infer_bars_per_year, partial_period_adjust};
6use super::{BacktestResult, EquityPoint, PerformanceMetrics};
7use crate::backtesting::position::Trade;
8
9impl BacktestResult {
10    /// Performance metrics broken down by calendar year.
11    ///
12    /// Each trade is attributed to the year in which it **closed**
13    /// (`exit_timestamp`).  The equity curve is sliced to the bars that fall
14    /// within that calendar year, and the equity at the first bar of the year
15    /// serves as `initial_capital` for the period metrics.
16    ///
17    /// Years with no closed trades are omitted from the result.
18    ///
19    /// # Caveats
20    ///
21    /// - **Open positions**: a position that is open throughout the year
22    ///   contributes to the equity-curve drawdown and Sharpe of that year but
23    ///   does **not** appear in `total_trades` or `win_rate`, because those
24    ///   are derived from closed trades only.  Strategies with long holding
25    ///   periods will show systematically low trade counts per year.
26    /// - **Partial years**: the first and last year of a backtest typically
27    ///   cover fewer than 12 months.  `annualized_return_pct`, `calmar_ratio`,
28    ///   and `serenity_ratio` are set to `0.0` for slices shorter than half a
29    ///   year (`< bars_per_year / 2` bars) to prevent geometric-compounding
30    ///   distortion.
31    /// - **`total_signals` / `executed_signals`**: these fields are `0` in
32    ///   period breakdowns because signal records are not partitioned per
33    ///   period.  Use [`BacktestResult::signals`] directly if needed.
34    pub fn by_year(&self) -> HashMap<i32, PerformanceMetrics> {
35        self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| dt.year()))
36    }
37
38    /// Performance metrics broken down by calendar month.
39    ///
40    /// Each trade is attributed to the `(year, month)` in which it **closed**.
41    /// Uses the same equity-slicing approach as [`by_year`](Self::by_year);
42    /// the same caveats about open positions, partial periods, and signal
43    /// counts apply here as well.
44    pub fn by_month(&self) -> HashMap<(i32, u32), PerformanceMetrics> {
45        self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| (dt.year(), dt.month())))
46    }
47
48    /// Performance metrics broken down by day of week.
49    ///
50    /// Each trade is attributed to the weekday on which it **closed**
51    /// (`exit_timestamp`).  Only weekdays present in the trade log appear in
52    /// the result.  Trades and equity-curve points with timestamps that cannot
53    /// be converted to a valid date are silently skipped.
54    ///
55    /// # Sharpe / Sortino annualisation
56    ///
57    /// The equity curve is filtered to bars that fall on each specific
58    /// weekday, so consecutive equity points in each slice are roughly one
59    /// *week* apart (for a daily-bar backtest).  `bars_per_year` is inferred
60    /// from the calendar span of each slice so that annualisation matches the
61    /// actual sampling frequency — **you do not need to adjust the config**.
62    /// The inferred value is approximately `52` for daily bars, `12` for
63    /// weekly bars, and so on.
64    ///
65    /// # Other caveats
66    ///
67    /// The same open-position and signal-count caveats from
68    /// [`by_year`](Self::by_year) apply here.
69    pub fn by_day_of_week(&self) -> HashMap<Weekday, PerformanceMetrics> {
70        // Pre-group trades by weekday — O(T)
71        let mut trade_groups: HashMap<Weekday, Vec<&Trade>> = HashMap::new();
72        for trade in &self.trades {
73            if let Some(day) = datetime_from_timestamp(trade.exit_timestamp).map(|dt| dt.weekday())
74            {
75                trade_groups.entry(day).or_default().push(trade);
76            }
77        }
78
79        // Pre-group equity curve by weekday — O(N), avoids O(N × K) rescanning
80        let mut equity_groups: HashMap<Weekday, Vec<EquityPoint>> = HashMap::new();
81        for p in &self.equity_curve {
82            if let Some(day) = datetime_from_timestamp(p.timestamp).map(|dt| dt.weekday()) {
83                equity_groups.entry(day).or_default().push(p.clone());
84            }
85        }
86
87        trade_groups
88            .into_iter()
89            .map(|(day, group_trades)| {
90                let equity_slice = equity_groups.remove(&day).unwrap_or_default();
91                let initial_capital = equity_slice
92                    .first()
93                    .map(|p| p.equity)
94                    .unwrap_or(self.initial_capital);
95                let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
96                // Infer the effective bars_per_year from the slice's calendar
97                // span: same-weekday bars are ~5 trading days apart for a
98                // daily-bar backtest, so the correct annualisation factor is
99                // ≈52, not the configured 252.
100                let bpy = infer_bars_per_year(&equity_slice, self.config.bars_per_year);
101                let metrics = PerformanceMetrics::calculate(
102                    &trades_vec,
103                    &equity_slice,
104                    initial_capital,
105                    0,
106                    0,
107                    self.config.risk_free_rate,
108                    bpy,
109                );
110                let slice_len = equity_slice.len();
111                (day, partial_period_adjust(metrics, slice_len, bpy))
112            })
113            .collect()
114    }
115
116    /// Groups trades and equity-curve points by an arbitrary calendar key,
117    /// then computes [`PerformanceMetrics`] for each group.
118    ///
119    /// `key_fn` maps a Unix-second timestamp to `Some(K)`, or `None` for
120    /// timestamps that cannot be parsed (those entries are silently skipped).
121    ///
122    /// Both trades and equity-curve points are pre-grouped in **O(N + T)**
123    /// passes before metrics are computed per period, avoiding the O(N × K)
124    /// inner-loop cost of the naïve approach.
125    fn temporal_metrics<K>(
126        &self,
127        key_fn: impl Fn(i64) -> Option<K>,
128    ) -> HashMap<K, PerformanceMetrics>
129    where
130        K: std::hash::Hash + Eq + Copy,
131    {
132        // Pre-group trades by period key — O(T)
133        let mut trade_groups: HashMap<K, Vec<&Trade>> = HashMap::new();
134        for trade in &self.trades {
135            if let Some(key) = key_fn(trade.exit_timestamp) {
136                trade_groups.entry(key).or_default().push(trade);
137            }
138        }
139
140        // Pre-group equity curve by period key — O(N)
141        let mut equity_groups: HashMap<K, Vec<EquityPoint>> = HashMap::new();
142        for p in &self.equity_curve {
143            if let Some(key) = key_fn(p.timestamp) {
144                equity_groups.entry(key).or_default().push(p.clone());
145            }
146        }
147
148        trade_groups
149            .into_iter()
150            .map(|(key, group_trades)| {
151                let equity_slice = equity_groups.remove(&key).unwrap_or_default();
152                let initial_capital = equity_slice
153                    .first()
154                    .map(|p| p.equity)
155                    .unwrap_or(self.initial_capital);
156                let trades_vec: Vec<Trade> = group_trades.into_iter().cloned().collect();
157                let metrics = PerformanceMetrics::calculate(
158                    &trades_vec,
159                    &equity_slice,
160                    initial_capital,
161                    // H-3: both zero — signal records are not partitioned
162                    // per period; callers should filter BacktestResult::signals
163                    // directly if per-period signal counts are needed.
164                    0,
165                    0,
166                    self.config.risk_free_rate,
167                    self.config.bars_per_year,
168                );
169                let slice_len = equity_slice.len();
170                // C-2: suppress annualised metrics for sub-half-year slices.
171                (
172                    key,
173                    partial_period_adjust(metrics, slice_len, self.config.bars_per_year),
174                )
175            })
176            .collect()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::super::fixtures::{equity_point, make_result, make_trade_timed, ts};
183    use super::*;
184
185    // ── by_year ───────────────────────────────────────────────────────────────
186
187    #[test]
188    fn by_year_no_trades_empty() {
189        let result = make_result(vec![], vec![equity_point(ts("2023-06-01"), 10000.0, 0.0)]);
190        assert!(result.by_year().is_empty());
191    }
192
193    #[test]
194    fn by_year_splits_across_years() {
195        let eq = vec![
196            equity_point(ts("2022-06-15"), 10000.0, 0.0),
197            equity_point(ts("2022-06-16"), 10100.0, 0.0),
198            equity_point(ts("2023-06-15"), 10200.0, 0.0),
199            equity_point(ts("2023-06-16"), 10300.0, 0.0),
200        ];
201        let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-15"), ts("2022-06-16"));
202        let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-15"), ts("2023-06-16"));
203        let result = make_result(vec![t1, t2], eq);
204        let by_year = result.by_year();
205        assert_eq!(by_year.len(), 2);
206        assert!(by_year.contains_key(&2022));
207        assert!(by_year.contains_key(&2023));
208        assert_eq!(by_year[&2022].total_trades, 1);
209        assert_eq!(by_year[&2023].total_trades, 1);
210    }
211
212    #[test]
213    fn by_year_all_same_year() {
214        let eq = vec![
215            equity_point(ts("2023-03-01"), 10000.0, 0.0),
216            equity_point(ts("2023-06-01"), 10200.0, 0.0),
217            equity_point(ts("2023-09-01"), 10500.0, 0.0),
218        ];
219        let t1 = make_trade_timed(200.0, 2.0, ts("2023-03-01"), ts("2023-06-01"));
220        let t2 = make_trade_timed(300.0, 3.0, ts("2023-06-01"), ts("2023-09-01"));
221        let result = make_result(vec![t1, t2], eq);
222        let by_year = result.by_year();
223        assert_eq!(by_year.len(), 1);
224        assert!(by_year.contains_key(&2023));
225        assert_eq!(by_year[&2023].total_trades, 2);
226    }
227
228    // ── by_month ──────────────────────────────────────────────────────────────
229
230    #[test]
231    fn by_month_splits_across_months() {
232        let eq = vec![
233            equity_point(ts("2023-03-15"), 10000.0, 0.0),
234            equity_point(ts("2023-03-16"), 10100.0, 0.0),
235            equity_point(ts("2023-07-15"), 10200.0, 0.0),
236            equity_point(ts("2023-07-16"), 10300.0, 0.0),
237        ];
238        let t1 = make_trade_timed(100.0, 1.0, ts("2023-03-15"), ts("2023-03-16"));
239        let t2 = make_trade_timed(100.0, 1.0, ts("2023-07-15"), ts("2023-07-16"));
240        let result = make_result(vec![t1, t2], eq);
241        let by_month = result.by_month();
242        assert_eq!(by_month.len(), 2);
243        assert!(by_month.contains_key(&(2023, 3)));
244        assert!(by_month.contains_key(&(2023, 7)));
245    }
246
247    #[test]
248    fn by_month_same_month_different_years_are_separate_keys() {
249        let eq = vec![
250            equity_point(ts("2022-06-15"), 10000.0, 0.0),
251            equity_point(ts("2023-06-15"), 10200.0, 0.0),
252        ];
253        let t1 = make_trade_timed(100.0, 1.0, ts("2022-06-14"), ts("2022-06-15"));
254        let t2 = make_trade_timed(100.0, 1.0, ts("2023-06-14"), ts("2023-06-15"));
255        let result = make_result(vec![t1, t2], eq);
256        let by_month = result.by_month();
257        assert_eq!(by_month.len(), 2);
258        assert!(by_month.contains_key(&(2022, 6)));
259        assert!(by_month.contains_key(&(2023, 6)));
260    }
261
262    // ── by_day_of_week ────────────────────────────────────────────────────────
263
264    #[test]
265    fn by_day_of_week_single_day() {
266        // 2023-01-02 is a Monday
267        let monday = ts("2023-01-02");
268        let t1 = make_trade_timed(100.0, 1.0, monday - 86400, monday);
269        let t2 = make_trade_timed(50.0, 0.5, monday - 86400 * 2, monday);
270        let eq = vec![equity_point(monday, 10000.0, 0.0)];
271        let result = make_result(vec![t1, t2], eq);
272        let by_dow = result.by_day_of_week();
273        assert_eq!(by_dow.len(), 1);
274        assert!(by_dow.contains_key(&Weekday::Mon));
275        assert_eq!(by_dow[&Weekday::Mon].total_trades, 2);
276    }
277
278    #[test]
279    fn by_day_of_week_multiple_days() {
280        // 2023-01-02 = Monday, 2023-01-03 = Tuesday
281        let monday = ts("2023-01-02");
282        let tuesday = ts("2023-01-03");
283        let t_mon = make_trade_timed(100.0, 1.0, monday - 86400, monday);
284        let t_tue = make_trade_timed(-50.0, -0.5, tuesday - 86400, tuesday);
285        let eq = vec![
286            equity_point(monday, 10000.0, 0.0),
287            equity_point(tuesday, 10100.0, 0.0),
288        ];
289        let result = make_result(vec![t_mon, t_tue], eq);
290        let by_dow = result.by_day_of_week();
291        assert_eq!(by_dow.len(), 2);
292        assert!(by_dow.contains_key(&Weekday::Mon));
293        assert!(by_dow.contains_key(&Weekday::Tue));
294        assert_eq!(by_dow[&Weekday::Mon].total_trades, 1);
295        assert_eq!(by_dow[&Weekday::Tue].total_trades, 1);
296        assert_eq!(by_dow[&Weekday::Mon].winning_trades, 1);
297        assert_eq!(by_dow[&Weekday::Tue].losing_trades, 1);
298    }
299
300    #[test]
301    fn by_day_of_week_no_trades_empty() {
302        let result = make_result(vec![], vec![equity_point(ts("2023-01-02"), 10000.0, 0.0)]);
303        assert!(result.by_day_of_week().is_empty());
304    }
305
306    #[test]
307    fn by_day_of_week_infers_weekly_bpy_for_daily_bars() {
308        // C-3: for a daily-bar backtest filtered to Mondays, the inferred
309        // bars_per_year should be ≈52 (one per week), not the configured 252.
310        // We verify this indirectly: Sharpe from by_day_of_week should differ
311        // from a Sharpe computed with bpy=252 on the same Monday returns,
312        // confirming that infer_bars_per_year adjusted the annualisation.
313        //
314        // Build 2 years of weekly Monday equity points (≈104 points).
315        let base = ts("2023-01-02"); // Monday
316        let week_secs = 7 * 86400i64;
317        let n_weeks = 104usize;
318        let equity_pts: Vec<EquityPoint> = (0..n_weeks)
319            .map(|i| {
320                equity_point(
321                    base + (i as i64) * week_secs,
322                    10000.0 + i as f64 * 10.0,
323                    0.0,
324                )
325            })
326            .collect();
327
328        let trade = make_trade_timed(
329            100.0,
330            1.0,
331            base,
332            base + week_secs, // exit on the second Monday
333        );
334        let result = make_result(vec![trade], equity_pts.clone());
335        let by_dow = result.by_day_of_week();
336
337        // The inferred bpy from 103 weekly returns over ~2 years ≈ 52.
338        // With bpy=252, Sharpe would be sqrt(252/52) ≈ 2.2× larger.
339        // We only assert the result is finite and present — correctness of
340        // the specific ratio is covered by infer_bars_per_year unit behaviour.
341        assert!(by_dow.contains_key(&Weekday::Mon));
342        let s = by_dow[&Weekday::Mon].sharpe_ratio;
343        assert!(
344            s.is_finite() || s == f64::MAX,
345            "Sharpe should be finite, got {s}"
346        );
347    }
348}