Skip to main content

fin_primitives/backtest/
walk_forward.rs

1//! # Walk-Forward Optimizer
2//!
3//! ## Responsibility
4//! Provides rigorous out-of-sample validation by splitting a bar series into
5//! rolling train/test windows, optimizing strategy parameters on the training
6//! slice (in-sample), and evaluating the result on the held-out test slice
7//! (out-of-sample).
8//!
9//! ## Algorithm
10//!
11//! ```text
12//! |─── train ───|─ test ─|
13//!     step ──►
14//!              |─── train ───|─ test ─|
15//!                  step ──►
16//!                           |─── train ───|─ test ─|
17//! ```
18//!
19//! Grid search is performed over every combination of parameter values defined
20//! in [`ParamRange`]. The combination with the highest in-sample Sharpe ratio
21//! is selected; then the same strategy is evaluated on the out-of-sample test
22//! slice, and the result is recorded in [`WfPeriod`].
23//!
24//! ## Stability Score
25//!
26//! The `stability_score` in [`WalkForwardResult`] measures how consistently
27//! out-of-sample Sharpe ratios are positive across periods:
28//!
29//! ```text
30//! stability_score = (# periods with OOS Sharpe > 0) / (total periods)
31//! ```
32//!
33//! A score of 1.0 means every OOS window was profitable; 0.0 means none were.
34//!
35//! ## Example
36//!
37//! ```rust
38//! use fin_primitives::backtest::walk_forward::{
39//!     WalkForwardOptimizer, WalkForwardConfig, ParamRange,
40//! };
41//! use fin_primitives::backtest::{BacktestConfig, Signal, SignalDirection, Strategy};
42//! use fin_primitives::ohlcv::OhlcvBar;
43//! use std::collections::HashMap;
44//! use rust_decimal_macros::dec;
45//!
46//! # fn make_bar(c: f64, ts: i64) -> OhlcvBar {
47//! #   use fin_primitives::types::{NanoTimestamp, Price, Quantity, Symbol};
48//! #   let sym = Symbol::new("T").unwrap();
49//! #   let p = Price::new(rust_decimal::Decimal::try_from(c).unwrap()).unwrap();
50//! #   OhlcvBar { symbol: sym, open: p, high: p, low: p, close: p,
51//! #     volume: Quantity::new(dec!(100)).unwrap(),
52//! #     ts_open: NanoTimestamp::new(ts), ts_close: NanoTimestamp::new(ts+1), tick_count: 1 }
53//! # }
54//! let bars: Vec<OhlcvBar> = (0..200).map(|i| make_bar(100.0 + i as f64 * 0.1, i)).collect();
55//!
56//! let config = WalkForwardConfig {
57//!     train_window: 60,
58//!     test_window: 20,
59//!     step: 20,
60//!     param_space: vec![
61//!         ParamRange { name: "sma_period".to_owned(), min: 5.0, max: 20.0, step: 5.0 },
62//!     ],
63//! };
64//!
65//! let bt_config = BacktestConfig::new(dec!(10_000), dec!(0.001)).unwrap();
66//! let opt = WalkForwardOptimizer::new(config, bt_config).unwrap();
67//!
68//! let result = opt.run(&bars, |train, params| {
69//!     let _period = params.get("sma_period").copied().unwrap_or(10.0) as usize;
70//!     Box::new(HoldStrategy)
71//! }).unwrap();
72//!
73//! println!("Aggregate Sharpe: {:.2}", result.aggregate_sharpe);
74//! println!("Stability score:  {:.2}", result.stability_score);
75//!
76//! # struct HoldStrategy;
77//! # impl Strategy for HoldStrategy {
78//! #   fn on_bar(&mut self, _b: &OhlcvBar) -> Option<Signal> { None }
79//! # }
80//! ```
81
82use crate::backtest::{BacktestConfig, Backtester, BacktestResult, Strategy};
83use crate::error::FinError;
84use crate::ohlcv::OhlcvBar;
85use std::collections::HashMap;
86
87// ─── ParamRange ───────────────────────────────────────────────────────────────
88
89/// A single named parameter range for grid search.
90///
91/// The grid is `[min, min+step, min+2*step, ..., max]` (inclusive).
92/// At least one value is always produced (when `min == max`).
93#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
94pub struct ParamRange {
95    /// Name of the parameter (e.g. `"rsi_period"`, `"sma_period"`).
96    pub name: String,
97    /// Minimum value (inclusive).
98    pub min: f64,
99    /// Maximum value (inclusive, up to floating-point precision).
100    pub max: f64,
101    /// Grid step size. Must be positive.
102    pub step: f64,
103}
104
105impl ParamRange {
106    /// Enumerates all grid values for this range.
107    ///
108    /// Returns `[min, min+step, min+2*step, ..., max]`.
109    /// If `step <= 0` or `min > max`, returns `[min]` as a degenerate case.
110    pub fn values(&self) -> Vec<f64> {
111        if self.step <= 0.0 || self.min > self.max {
112            return vec![self.min];
113        }
114        let mut vals = Vec::new();
115        let mut v = self.min;
116        while v <= self.max + f64::EPSILON {
117            vals.push(v);
118            v += self.step;
119        }
120        vals
121    }
122}
123
124// ─── WalkForwardConfig ────────────────────────────────────────────────────────
125
126/// Configuration for a walk-forward optimization run.
127#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
128pub struct WalkForwardConfig {
129    /// Number of bars in each training (in-sample) window.
130    pub train_window: usize,
131    /// Number of bars in each test (out-of-sample) window.
132    pub test_window: usize,
133    /// Number of bars to advance the window on each step.
134    /// Typically set to `test_window` for non-overlapping OOS periods.
135    pub step: usize,
136    /// Parameter space to search over. Each element defines one axis.
137    pub param_space: Vec<ParamRange>,
138}
139
140impl WalkForwardConfig {
141    /// Validates the configuration.
142    ///
143    /// # Errors
144    /// Returns [`FinError::InvalidInput`] if `train_window`, `test_window`,
145    /// or `step` are zero.
146    pub fn validate(&self) -> Result<(), FinError> {
147        if self.train_window == 0 {
148            return Err(FinError::InvalidInput(
149                "train_window must be > 0".to_owned(),
150            ));
151        }
152        if self.test_window == 0 {
153            return Err(FinError::InvalidInput(
154                "test_window must be > 0".to_owned(),
155            ));
156        }
157        if self.step == 0 {
158            return Err(FinError::InvalidInput(
159                "step must be > 0".to_owned(),
160            ));
161        }
162        Ok(())
163    }
164}
165
166// ─── WfPeriod ─────────────────────────────────────────────────────────────────
167
168/// Results for a single walk-forward period.
169///
170/// Each period corresponds to one train/test split. The `best_params`
171/// are the parameter combination that maximized in-sample Sharpe ratio.
172#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
173pub struct WfPeriod {
174    /// Bar index of the first bar in the training window.
175    pub train_start: usize,
176    /// Bar index one past the last bar in the training window (exclusive).
177    pub train_end: usize,
178    /// Bar index of the first bar in the test window.
179    pub test_start: usize,
180    /// Bar index one past the last bar in the test window (exclusive).
181    pub test_end: usize,
182    /// Parameter values that achieved the best in-sample Sharpe.
183    pub best_params: HashMap<String, f64>,
184    /// Sharpe ratio achieved on the training (in-sample) window.
185    pub in_sample_sharpe: f64,
186    /// Sharpe ratio achieved on the test (out-of-sample) window.
187    pub out_of_sample_sharpe: f64,
188    /// Full backtest result on the out-of-sample window.
189    pub oos_result: BacktestResult,
190}
191
192// ─── WalkForwardResult ────────────────────────────────────────────────────────
193
194/// Aggregated output of a walk-forward optimization run.
195#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
196pub struct WalkForwardResult {
197    /// Per-period details.
198    pub periods: Vec<WfPeriod>,
199    /// Mean out-of-sample Sharpe ratio across all periods.
200    pub aggregate_sharpe: f64,
201    /// Fraction of periods in which the OOS Sharpe was positive.
202    ///
203    /// Range: `[0.0, 1.0]`. 1.0 = every OOS window was profitable.
204    pub stability_score: f64,
205    /// Mean out-of-sample total return across all periods.
206    pub mean_oos_return: f64,
207    /// Worst (maximum) out-of-sample drawdown across all periods.
208    pub worst_oos_drawdown: f64,
209}
210
211impl WalkForwardResult {
212    /// Returns `true` if the strategy passed a basic robustness check:
213    /// aggregate Sharpe > 0 and stability score >= `min_stability`.
214    pub fn is_robust(&self, min_stability: f64) -> bool {
215        self.aggregate_sharpe > 0.0 && self.stability_score >= min_stability
216    }
217
218    /// Returns the period with the highest out-of-sample Sharpe ratio.
219    pub fn best_period(&self) -> Option<&WfPeriod> {
220        self.periods
221            .iter()
222            .max_by(|a, b| a.out_of_sample_sharpe.partial_cmp(&b.out_of_sample_sharpe).unwrap_or(std::cmp::Ordering::Equal))
223    }
224
225    /// Returns the period with the lowest (worst) out-of-sample Sharpe ratio.
226    pub fn worst_period(&self) -> Option<&WfPeriod> {
227        self.periods
228            .iter()
229            .min_by(|a, b| a.out_of_sample_sharpe.partial_cmp(&b.out_of_sample_sharpe).unwrap_or(std::cmp::Ordering::Equal))
230    }
231}
232
233// ─── WalkForwardOptimizer ─────────────────────────────────────────────────────
234
235/// Walk-forward optimizer: splits bars into rolling train/test windows,
236/// runs a grid search on training data, and evaluates the winning
237/// parameters on held-out test data.
238///
239/// The strategy factory closure receives the training bar slice and the
240/// candidate parameter `HashMap<String, f64>`. It should return a
241/// `Box<dyn Strategy>` initialized with those parameters.
242///
243/// # Example
244/// See the [module-level documentation](self) for a complete example.
245pub struct WalkForwardOptimizer {
246    config: WalkForwardConfig,
247    bt_config: BacktestConfig,
248}
249
250impl WalkForwardOptimizer {
251    /// Constructs a new optimizer.
252    ///
253    /// # Errors
254    /// Returns [`FinError::InvalidInput`] if the config is invalid.
255    pub fn new(config: WalkForwardConfig, bt_config: BacktestConfig) -> Result<Self, FinError> {
256        config.validate()?;
257        Ok(Self { config, bt_config })
258    }
259
260    /// Runs the walk-forward optimization.
261    ///
262    /// `make_strategy` is called once per (parameter combination × window).
263    /// It receives:
264    /// - `train_bars`: the training slice for fitting
265    /// - `params`: a `HashMap<String, f64>` with the current grid point
266    ///
267    /// It must return a `Box<dyn Strategy>` configured with `params`.
268    ///
269    /// # Errors
270    /// - [`FinError::InvalidInput`] if `bars` is too short for one window.
271    /// - Propagates any [`FinError`] from individual backtest runs.
272    pub fn run<F>(
273        &self,
274        bars: &[OhlcvBar],
275        mut make_strategy: F,
276    ) -> Result<WalkForwardResult, FinError>
277    where
278        F: FnMut(&[OhlcvBar], &HashMap<String, f64>) -> Box<dyn Strategy>,
279    {
280        let window = self.config.train_window + self.config.test_window;
281        if bars.len() < window {
282            return Err(FinError::InvalidInput(format!(
283                "need at least {} bars for one walk-forward window, got {}",
284                window,
285                bars.len()
286            )));
287        }
288
289        let backtester = Backtester::new(self.bt_config.clone());
290        let grid = build_grid(&self.config.param_space);
291        let mut periods: Vec<WfPeriod> = Vec::new();
292        let mut offset = 0usize;
293
294        while offset + window <= bars.len() {
295            let train_start = offset;
296            let train_end = offset + self.config.train_window;
297            let test_start = train_end;
298            let test_end = train_end + self.config.test_window;
299
300            let train_bars = &bars[train_start..train_end];
301            let test_bars = &bars[test_start..test_end];
302
303            // ── Grid search on training window ────────────────────────────────
304            let mut best_is_sharpe = f64::NEG_INFINITY;
305            let mut best_params: HashMap<String, f64> = HashMap::new();
306
307            let search_grid: &[HashMap<String, f64>] = &grid;
308
309            for param_set in search_grid {
310                // In-sample evaluation
311                let mut is_strategy = make_strategy(train_bars, param_set);
312                let is_result = match backtester.run(train_bars, is_strategy.as_mut()) {
313                    Ok(r) => r,
314                    Err(_) => continue,
315                };
316                let is_sharpe = is_result
317                    .sharpe_ratio
318                    .to_string()
319                    .parse::<f64>()
320                    .unwrap_or(f64::NEG_INFINITY);
321
322                if is_sharpe > best_is_sharpe {
323                    best_is_sharpe = is_sharpe;
324                    best_params = param_set.clone();
325                }
326            }
327
328            // ── Out-of-sample evaluation with best params ─────────────────────
329            let mut oos_strategy = make_strategy(test_bars, &best_params);
330            let oos_result = backtester.run(test_bars, oos_strategy.as_mut())?;
331
332            let oos_sharpe = oos_result
333                .sharpe_ratio
334                .to_string()
335                .parse::<f64>()
336                .unwrap_or(0.0);
337
338            periods.push(WfPeriod {
339                train_start,
340                train_end,
341                test_start,
342                test_end,
343                best_params,
344                in_sample_sharpe: best_is_sharpe.max(0.0), // clamp negative for display
345                out_of_sample_sharpe: oos_sharpe,
346                oos_result,
347            });
348
349            offset += self.config.step;
350        }
351
352        if periods.is_empty() {
353            return Err(FinError::InvalidInput(
354                "no walk-forward periods could be constructed".to_owned(),
355            ));
356        }
357
358        // ── Aggregate metrics ─────────────────────────────────────────────────
359        let n = periods.len() as f64;
360        let aggregate_sharpe = periods.iter().map(|p| p.out_of_sample_sharpe).sum::<f64>() / n;
361        let positive_count = periods.iter().filter(|p| p.out_of_sample_sharpe > 0.0).count();
362        let stability_score = positive_count as f64 / n;
363
364        let mean_oos_return = periods
365            .iter()
366            .map(|p| {
367                p.oos_result
368                    .total_return
369                    .to_string()
370                    .parse::<f64>()
371                    .unwrap_or(0.0)
372            })
373            .sum::<f64>()
374            / n;
375
376        let worst_oos_drawdown = periods
377            .iter()
378            .map(|p| {
379                p.oos_result
380                    .max_drawdown
381                    .to_string()
382                    .parse::<f64>()
383                    .unwrap_or(0.0)
384            })
385            .fold(0.0_f64, f64::max);
386
387        Ok(WalkForwardResult {
388            periods,
389            aggregate_sharpe,
390            stability_score,
391            mean_oos_return,
392            worst_oos_drawdown,
393        })
394    }
395
396    /// Returns the configuration.
397    pub fn config(&self) -> &WalkForwardConfig {
398        &self.config
399    }
400
401    /// Returns the backtest configuration.
402    pub fn bt_config(&self) -> &BacktestConfig {
403        &self.bt_config
404    }
405}
406
407// ─── Grid builder ─────────────────────────────────────────────────────────────
408
409/// Builds the full Cartesian product of all parameter ranges.
410///
411/// Each element of the returned Vec is a `HashMap<String, f64>` mapping
412/// parameter names to their values for one grid point.
413fn build_grid(param_space: &[ParamRange]) -> Vec<HashMap<String, f64>> {
414    if param_space.is_empty() {
415        return vec![HashMap::new()];
416    }
417
418    let mut grid: Vec<HashMap<String, f64>> = vec![HashMap::new()];
419
420    for param in param_space {
421        let vals = param.values();
422        let mut new_grid = Vec::with_capacity(grid.len() * vals.len());
423        for existing in &grid {
424            for &v in &vals {
425                let mut m = existing.clone();
426                m.insert(param.name.clone(), v);
427                new_grid.push(m);
428            }
429        }
430        grid = new_grid;
431    }
432
433    grid
434}
435
436// ─── Tests ────────────────────────────────────────────────────────────────────
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::backtest::{Signal, SignalDirection};
442    use crate::types::{NanoTimestamp, Price, Quantity, Symbol};
443    use rust_decimal::Decimal;
444    use rust_decimal_macros::dec;
445
446    fn make_bar(close: f64, ts: i64) -> OhlcvBar {
447        let sym = Symbol::new("TEST").unwrap();
448        let p = Price::new(Decimal::try_from(close).unwrap()).unwrap();
449        OhlcvBar {
450            symbol: sym,
451            open: p,
452            high: p,
453            low: p,
454            close: p,
455            volume: Quantity::new(dec!(1000)).unwrap(),
456            ts_open: NanoTimestamp::new(ts),
457            ts_close: NanoTimestamp::new(ts + 1),
458            tick_count: 1,
459        }
460    }
461
462    /// Strategy that always holds.
463    struct HoldAll;
464    impl Strategy for HoldAll {
465        fn on_bar(&mut self, _: &OhlcvBar) -> Option<Signal> {
466            None
467        }
468    }
469
470    /// Strategy that buys on bar 0 then holds, configurable by params.
471    struct BuyOnce {
472        bought: bool,
473        qty: f64,
474    }
475    impl BuyOnce {
476        fn from_params(params: &HashMap<String, f64>) -> Self {
477            Self {
478                bought: false,
479                qty: params.get("qty").copied().unwrap_or(1.0),
480            }
481        }
482    }
483    impl Strategy for BuyOnce {
484        fn on_bar(&mut self, _: &OhlcvBar) -> Option<Signal> {
485            if !self.bought {
486                self.bought = true;
487                let qty = Decimal::try_from(self.qty).unwrap_or(dec!(1));
488                return Some(Signal::new(SignalDirection::Buy, qty));
489            }
490            Some(Signal::hold())
491        }
492    }
493
494    // ── ParamRange ────────────────────────────────────────────────────────────
495
496    #[test]
497    fn test_param_range_values_basic() {
498        let r = ParamRange { name: "x".to_owned(), min: 1.0, max: 3.0, step: 1.0 };
499        let vals = r.values();
500        assert_eq!(vals.len(), 3);
501        assert!((vals[0] - 1.0).abs() < 1e-10);
502        assert!((vals[2] - 3.0).abs() < 1e-10);
503    }
504
505    #[test]
506    fn test_param_range_single_value_when_min_equals_max() {
507        let r = ParamRange { name: "x".to_owned(), min: 5.0, max: 5.0, step: 1.0 };
508        let vals = r.values();
509        assert_eq!(vals.len(), 1);
510        assert!((vals[0] - 5.0).abs() < 1e-10);
511    }
512
513    #[test]
514    fn test_param_range_degenerate_step() {
515        let r = ParamRange { name: "x".to_owned(), min: 1.0, max: 5.0, step: 0.0 };
516        let vals = r.values();
517        assert_eq!(vals.len(), 1); // degenerate fallback
518    }
519
520    // ── build_grid ────────────────────────────────────────────────────────────
521
522    #[test]
523    fn test_build_grid_empty_space() {
524        let grid = build_grid(&[]);
525        assert_eq!(grid.len(), 1);
526        assert!(grid[0].is_empty());
527    }
528
529    #[test]
530    fn test_build_grid_single_param() {
531        let params = vec![ParamRange { name: "p".to_owned(), min: 5.0, max: 15.0, step: 5.0 }];
532        let grid = build_grid(&params);
533        assert_eq!(grid.len(), 3); // 5, 10, 15
534        for m in &grid {
535            assert!(m.contains_key("p"));
536        }
537    }
538
539    #[test]
540    fn test_build_grid_two_params_cartesian() {
541        let params = vec![
542            ParamRange { name: "a".to_owned(), min: 1.0, max: 2.0, step: 1.0 },
543            ParamRange { name: "b".to_owned(), min: 10.0, max: 20.0, step: 10.0 },
544        ];
545        let grid = build_grid(&params);
546        assert_eq!(grid.len(), 4); // 2 × 2
547    }
548
549    // ── WalkForwardConfig ─────────────────────────────────────────────────────
550
551    #[test]
552    fn test_config_validation_zero_train() {
553        let cfg = WalkForwardConfig {
554            train_window: 0,
555            test_window: 20,
556            step: 20,
557            param_space: vec![],
558        };
559        assert!(cfg.validate().is_err());
560    }
561
562    #[test]
563    fn test_config_validation_zero_test() {
564        let cfg = WalkForwardConfig {
565            train_window: 60,
566            test_window: 0,
567            step: 20,
568            param_space: vec![],
569        };
570        assert!(cfg.validate().is_err());
571    }
572
573    #[test]
574    fn test_config_validation_zero_step() {
575        let cfg = WalkForwardConfig {
576            train_window: 60,
577            test_window: 20,
578            step: 0,
579            param_space: vec![],
580        };
581        assert!(cfg.validate().is_err());
582    }
583
584    // ── WalkForwardOptimizer ──────────────────────────────────────────────────
585
586    #[test]
587    fn test_optimizer_too_few_bars() {
588        let bars: Vec<OhlcvBar> = (0..5).map(|i| make_bar(100.0, i)).collect();
589        let cfg = WalkForwardConfig {
590            train_window: 60,
591            test_window: 20,
592            step: 20,
593            param_space: vec![],
594        };
595        let bt_cfg = BacktestConfig::new(dec!(10_000), dec!(0)).unwrap();
596        let opt = WalkForwardOptimizer::new(cfg, bt_cfg).unwrap();
597        let result = opt.run(&bars, |_, _| Box::new(HoldAll));
598        assert!(result.is_err());
599    }
600
601    #[test]
602    fn test_optimizer_hold_strategy_returns_result() {
603        let bars: Vec<OhlcvBar> = (0..100).map(|i| make_bar(100.0 + i as f64 * 0.1, i)).collect();
604        let cfg = WalkForwardConfig {
605            train_window: 40,
606            test_window: 20,
607            step: 20,
608            param_space: vec![],
609        };
610        let bt_cfg = BacktestConfig::new(dec!(10_000), dec!(0)).unwrap();
611        let opt = WalkForwardOptimizer::new(cfg, bt_cfg).unwrap();
612        let result = opt.run(&bars, |_, _| Box::new(HoldAll)).unwrap();
613        assert!(!result.periods.is_empty());
614        // Hold strategy → Sharpe = 0
615        assert_eq!(result.aggregate_sharpe, 0.0);
616        assert_eq!(result.stability_score, 0.0);
617    }
618
619    #[test]
620    fn test_optimizer_with_param_grid() {
621        let bars: Vec<OhlcvBar> = (0..120).map(|i| make_bar(100.0 + i as f64 * 0.5, i)).collect();
622        let cfg = WalkForwardConfig {
623            train_window: 50,
624            test_window: 20,
625            step: 20,
626            param_space: vec![
627                ParamRange { name: "qty".to_owned(), min: 1.0, max: 3.0, step: 1.0 },
628            ],
629        };
630        let bt_cfg = BacktestConfig::new(dec!(10_000), dec!(0)).unwrap();
631        let opt = WalkForwardOptimizer::new(cfg, bt_cfg).unwrap();
632        let result = opt.run(&bars, |_, params| Box::new(BuyOnce::from_params(params))).unwrap();
633        assert!(!result.periods.is_empty());
634        for p in &result.periods {
635            assert!(!p.best_params.is_empty());
636            assert!(p.best_params.contains_key("qty"));
637        }
638    }
639
640    #[test]
641    fn test_optimizer_stability_score_bounds() {
642        let bars: Vec<OhlcvBar> = (0..100).map(|i| make_bar(100.0, i)).collect();
643        let cfg = WalkForwardConfig {
644            train_window: 40,
645            test_window: 20,
646            step: 20,
647            param_space: vec![],
648        };
649        let bt_cfg = BacktestConfig::new(dec!(10_000), dec!(0)).unwrap();
650        let opt = WalkForwardOptimizer::new(cfg, bt_cfg).unwrap();
651        let result = opt.run(&bars, |_, _| Box::new(HoldAll)).unwrap();
652        assert!((0.0..=1.0).contains(&result.stability_score));
653    }
654
655    #[test]
656    fn test_wf_result_robustness_check() {
657        let result = WalkForwardResult {
658            periods: vec![],
659            aggregate_sharpe: 1.5,
660            stability_score: 0.8,
661            mean_oos_return: 0.05,
662            worst_oos_drawdown: 0.1,
663        };
664        assert!(result.is_robust(0.7));
665        assert!(!result.is_robust(0.9));
666    }
667
668    #[test]
669    fn test_wf_result_best_worst_period() {
670        let make_period = |oos_sharpe: f64| WfPeriod {
671            train_start: 0,
672            train_end: 50,
673            test_start: 50,
674            test_end: 70,
675            best_params: HashMap::new(),
676            in_sample_sharpe: 1.0,
677            out_of_sample_sharpe: oos_sharpe,
678            oos_result: crate::backtest::BacktestResult {
679                total_return: Decimal::ZERO,
680                sharpe_ratio: Decimal::ZERO,
681                max_drawdown: Decimal::ZERO,
682                win_rate: Decimal::ZERO,
683                trade_count: 0,
684                final_equity: dec!(10_000),
685                equity_curve: vec![],
686            },
687        };
688        let result = WalkForwardResult {
689            periods: vec![make_period(0.5), make_period(2.0), make_period(-0.3)],
690            aggregate_sharpe: 0.73,
691            stability_score: 0.67,
692            mean_oos_return: 0.0,
693            worst_oos_drawdown: 0.0,
694        };
695        assert!((result.best_period().unwrap().out_of_sample_sharpe - 2.0).abs() < 1e-10);
696        assert!((result.worst_period().unwrap().out_of_sample_sharpe + 0.3).abs() < 1e-10);
697    }
698
699    #[test]
700    fn test_optimizer_step_advances_window() {
701        // With step < test_window, windows overlap
702        let bars: Vec<OhlcvBar> = (0..150).map(|i| make_bar(100.0 + i as f64 * 0.1, i)).collect();
703        let cfg = WalkForwardConfig {
704            train_window: 50,
705            test_window: 30,
706            step: 10, // smaller than test_window → overlapping OOS
707            param_space: vec![],
708        };
709        let bt_cfg = BacktestConfig::new(dec!(10_000), dec!(0)).unwrap();
710        let opt = WalkForwardOptimizer::new(cfg, bt_cfg).unwrap();
711        let result = opt.run(&bars, |_, _| Box::new(HoldAll)).unwrap();
712        // Should produce multiple periods due to small step
713        assert!(result.periods.len() > 2);
714    }
715}