Skip to main content

sharpebench_sim/
windows.rs

1//! Window generation: walk-forward OOS splits + coarse regime tagging.
2//!
3//! A single fixed window is the StockBench mistake — one lucky quarter. Real
4//! evaluation rolls forward over many disjoint out-of-sample windows and reports
5//! stability across regimes, so a bull-market fluke can't masquerade as skill.
6
7use crate::data::Dataset;
8use crate::engine::Window;
9
10/// Generate disjoint-start walk-forward test windows of `test` length, stepping
11/// by `step`, after a `warmup` burn-in (so features have history). Each window is
12/// an out-of-sample slice `[start, start + test)`.
13pub fn walk_forward(n_days: usize, warmup: usize, test: usize, step: usize) -> Vec<Window> {
14    let mut windows = Vec::new();
15    if test == 0 || step == 0 {
16        return windows;
17    }
18    let mut start = warmup;
19    while start + test <= n_days {
20        windows.push(Window {
21            start,
22            end: start + test,
23        });
24        start += step;
25    }
26    windows
27}
28
29/// Coarse market regime over a window.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Regime {
32    Bull,
33    Bear,
34    Chop,
35}
36
37/// Tag a window's regime by the equal-weight average total return across symbols
38/// (>+3% = bull, < -3% = bear, else chop).
39pub fn tag_regime(data: &Dataset, window: Window) -> Regime {
40    let end = window.end.min(data.len());
41    if window.start + 1 >= end {
42        return Regime::Chop;
43    }
44    let mut total = 0.0;
45    let mut count = 0.0;
46    for sym in data.symbols() {
47        if let (Some(a), Some(b)) = (
48            data.close_at(&sym, window.start),
49            data.close_at(&sym, end - 1),
50        ) {
51            if a > 0.0 {
52                total += b / a - 1.0;
53                count += 1.0;
54            }
55        }
56    }
57    if count == 0.0 {
58        return Regime::Chop;
59    }
60    let avg = total / count;
61    if avg > 0.03 {
62        Regime::Bull
63    } else if avg < -0.03 {
64        Regime::Bear
65    } else {
66        Regime::Chop
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn walk_forward_disjoint_and_stepped() {
76        let ws = walk_forward(200, 20, 60, 60);
77        assert_eq!(ws.len(), 3);
78        assert_eq!(ws[0].start, 20);
79        assert_eq!(ws[1].start, 80);
80        assert!(ws.iter().all(|w| w.end - w.start == 60));
81    }
82
83    #[test]
84    fn regime_classifies_trends() {
85        use std::collections::BTreeMap;
86        let mk = |series: Vec<f64>| {
87            let mut closes = BTreeMap::new();
88            closes.insert("A".to_string(), series);
89            Dataset {
90                dates: (0..50).map(|i| format!("d{i}")).collect(),
91                closes,
92                dividends: BTreeMap::new(),
93            }
94        };
95        let up: Vec<f64> = (0..50).map(|i| 100.0 * (1.0 + 0.01 * i as f64)).collect();
96        let down: Vec<f64> = (0..50).map(|i| 100.0 * (1.0 - 0.005 * i as f64)).collect();
97        let flat: Vec<f64> = (0..50).map(|i| 100.0 + 0.001 * (i as f64).sin()).collect();
98        let w = Window { start: 0, end: 50 };
99        assert_eq!(tag_regime(&mk(up), w), Regime::Bull);
100        assert_eq!(tag_regime(&mk(down), w), Regime::Bear);
101        assert_eq!(tag_regime(&mk(flat), w), Regime::Chop);
102    }
103}