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 pub fn by_year(&self) -> HashMap<i32, PerformanceMetrics> {
35 self.temporal_metrics(|ts| datetime_from_timestamp(ts).map(|dt| dt.year()))
36 }
37
38 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 pub fn by_day_of_week(&self) -> HashMap<Weekday, PerformanceMetrics> {
70 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 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 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 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 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 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 0,
165 0,
166 self.config.risk_free_rate,
167 self.config.bars_per_year,
168 );
169 let slice_len = equity_slice.len();
170 (
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 #[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 #[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 #[test]
265 fn by_day_of_week_single_day() {
266 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 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 let base = ts("2023-01-02"); 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, );
334 let result = make_result(vec![trade], equity_pts.clone());
335 let by_dow = result.by_day_of_week();
336
337 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}