Skip to main content

quantwave_backtest/
walk_forward.rs

1//! Walk-forward out-of-sample validation (quantwave-cr6v.14 / quantwave-xibc).
2//!
3//! Clean-room rolling OOS folds on pre-computed signals (RaptorBT / Zorro WFO pattern).
4//! v1: no in-fold parameter optimization — each fold backtests the OOS window only.
5
6use crate::{
7    BacktestConfig, BacktestEngine, BacktestError, PerformanceMetrics, internal_invariant,
8};
9use polars::prelude::*;
10use std::collections::HashMap;
11
12/// Rolling walk-forward configuration (bar counts on the unique timestamp index).
13#[derive(Debug, Clone, PartialEq)]
14pub struct WalkForwardConfig {
15    /// In-sample warmup bars (skipped for OOS metrics; advances the window).
16    pub train_bars: usize,
17    /// Out-of-sample bars backtested per fold.
18    pub test_bars: usize,
19    /// Step between folds (defaults to `test_bars`).
20    pub step_bars: Option<usize>,
21    pub overfit_threshold: f64,
22}
23
24impl WalkForwardConfig {
25    pub fn new(train_bars: usize, test_bars: usize) -> Self {
26        Self {
27            train_bars,
28            test_bars,
29            step_bars: None,
30            overfit_threshold: 1.0,
31        }
32    }
33
34    fn step(&self) -> usize {
35        self.step_bars.unwrap_or(self.test_bars).max(1)
36    }
37}
38
39/// Run walk-forward OOS backtests; returns fold × metrics DataFrame.
40pub fn run_walk_forward(
41    lf: LazyFrame,
42    base_config: &BacktestConfig,
43    wf: &WalkForwardConfig,
44) -> Result<DataFrame, BacktestError> {
45    if wf.train_bars == 0 || wf.test_bars == 0 {
46        return Err(BacktestError::InvalidInput(
47            "train_bars and test_bars must be > 0".into(),
48        ));
49    }
50
51    let df = lf.collect()?;
52    if df.height() == 0 {
53        return Err(BacktestError::InvalidInput("empty dataframe".into()));
54    }
55
56    let ts_col = &base_config.timestamp_col;
57    let timestamps = unique_sorted_timestamps(&df, ts_col)?;
58    let step = wf.step();
59    let mut fold_id = 0usize;
60    let mut fold_ids = Vec::new();
61    let mut oos_start = Vec::new();
62    let mut oos_end = Vec::new();
63    let mut train_lens = Vec::new();
64    let mut test_lens = Vec::new();
65    let mut metric_cols: HashMap<&'static str, Vec<f64>> = PerformanceMetrics::column_names()
66        .iter()
67        .map(|&n| (n, Vec::new()))
68        .collect();
69
70    let mut start = 0usize;
71    while start + wf.train_bars + wf.test_bars <= timestamps.len() {
72        let test_start_idx = start + wf.train_bars;
73        let test_end_idx = test_start_idx + wf.test_bars;
74        let ts_min = timestamps[test_start_idx];
75        let ts_max = timestamps[test_end_idx - 1];
76
77        let oos_lf = df.clone().lazy().filter(
78            col(ts_col)
79                .gt_eq(lit(ts_min))
80                .and(col(ts_col).lt_eq(lit(ts_max))),
81        );
82
83        let report = BacktestEngine::new(base_config.clone()).backtest_with_report(oos_lf)?;
84
85        fold_ids.push(fold_id as f64);
86        oos_start.push(ts_min as f64);
87        oos_end.push(ts_max as f64);
88        train_lens.push(wf.train_bars as f64);
89        test_lens.push(wf.test_bars as f64);
90        for (name, value) in report.metrics.row_iter() {
91            metric_cols
92                .get_mut(name)
93                .ok_or_else(|| {
94                    internal_invariant(format!(
95                        "metric column '{name}' missing from walk-forward accumulator"
96                    ))
97                })?
98                .push(value);
99        }
100
101        fold_id += 1;
102        start += step;
103    }
104
105    if fold_ids.is_empty() {
106        return Err(BacktestError::InvalidInput(format!(
107            "insufficient bars for walk-forward: need >= {} unique timestamps, got {}",
108            wf.train_bars + wf.test_bars,
109            timestamps.len()
110        )));
111    }
112
113    let mut columns = vec![
114        Column::new("fold_id".into(), fold_ids),
115        Column::new("oos_start_ts".into(), oos_start),
116        Column::new("oos_end_ts".into(), oos_end),
117        Column::new("train_bars".into(), train_lens),
118        Column::new("test_bars".into(), test_lens),
119    ];
120    for name in PerformanceMetrics::column_names() {
121        columns.push(Column::new(
122            PlSmallStr::from_str(name),
123            metric_cols.remove(name).ok_or_else(|| {
124                internal_invariant(format!(
125                    "metric column '{name}' missing when building walk-forward df"
126                ))
127            })?,
128        ));
129    }
130
131    DataFrame::new(columns).map_err(BacktestError::from)
132}
133
134fn unique_sorted_timestamps(df: &DataFrame, ts_col: &str) -> Result<Vec<i64>, BacktestError> {
135    let ts = df
136        .column(ts_col)
137        .map_err(|_| BacktestError::MissingColumn {
138            name: ts_col.to_string(),
139        })?;
140    let mut values: Vec<i64> = match ts.dtype() {
141        DataType::Int64 => ts
142            .i64()
143            .map_err(|_| BacktestError::InvalidDtype {
144                col: ts_col.to_string(),
145                expected: "Int64".into(),
146                got: format!("{:?}", ts.dtype()),
147            })?
148            .into_iter()
149            .flatten()
150            .collect(),
151        DataType::Int32 => ts
152            .i32()
153            .map_err(|_| BacktestError::InvalidDtype {
154                col: ts_col.to_string(),
155                expected: "Int32".into(),
156                got: format!("{:?}", ts.dtype()),
157            })?
158            .into_iter()
159            .flatten()
160            .map(|v| v as i64)
161            .collect(),
162        other => {
163            return Err(BacktestError::InvalidDtype {
164                col: ts_col.to_string(),
165                expected: "Int64 or Int32".into(),
166                got: format!("{other:?}"),
167            });
168        }
169    };
170    values.sort_unstable();
171    values.dedup();
172    Ok(values)
173}
174
175/// Run walk-forward optimization: sweep on train fold, pick best by objective, backtest OOS.
176pub fn run_walk_forward_optimize(
177    lf: LazyFrame,
178    base_config: &BacktestConfig,
179    wf: &WalkForwardConfig,
180    variants: &[crate::SweepVariant],
181    objective_metric: &str,
182) -> Result<DataFrame, BacktestError> {
183    if wf.train_bars == 0 || wf.test_bars == 0 {
184        return Err(BacktestError::InvalidInput(
185            "train/test_bars must be > 0".into(),
186        ));
187    }
188    if variants.is_empty() {
189        return Err(BacktestError::InvalidInput(
190            "at least one variant required".into(),
191        ));
192    }
193
194    let df = lf.collect()?;
195    if df.height() == 0 {
196        return Err(BacktestError::InvalidInput("empty dataframe".into()));
197    }
198
199    let ts_col = &base_config.timestamp_col;
200    let timestamps = unique_sorted_timestamps(&df, ts_col)?;
201    let step = wf.step();
202    let param_keys = crate::sweep::sorted_param_keys(variants);
203
204    let mut fold_ids = Vec::new();
205    let mut oos_starts = Vec::new();
206    let mut oos_ends = Vec::new();
207    let mut train_metrics = Vec::new();
208    let mut oos_metrics = Vec::new();
209    let mut overfit_flags = Vec::new();
210    let mut best_params: HashMap<String, Vec<f64>> =
211        param_keys.iter().map(|k| (k.clone(), Vec::new())).collect();
212
213    let mut metric_cols: HashMap<&'static str, Vec<f64>> = PerformanceMetrics::column_names()
214        .iter()
215        .map(|&n| (n, Vec::new()))
216        .collect();
217
218    let mut start = 0usize;
219    let mut fold_id = 0usize;
220    while start + wf.train_bars + wf.test_bars <= timestamps.len() {
221        let test_start_idx = start + wf.train_bars;
222        let test_end_idx = test_start_idx + wf.test_bars;
223        let ts_train_start = timestamps[start];
224        let ts_train_end = timestamps[test_start_idx - 1];
225        let ts_oos_start = timestamps[test_start_idx];
226        let ts_oos_end = timestamps[test_end_idx - 1];
227
228        // 1. Train Sweep
229        let train_lf = df.clone().lazy().filter(
230            col(ts_col)
231                .gt_eq(lit(ts_train_start))
232                .and(col(ts_col).lt_eq(lit(ts_train_end))),
233        );
234        let sweep_df = crate::sweep::run_param_sweep(train_lf, variants, base_config)?;
235
236        // Pick best variant
237        let obj_col = sweep_df
238            .column(objective_metric)
239            .map_err(|e| BacktestError::InvalidInput(format!("objective_metric not found: {e}")))?;
240        let obj_series = obj_col
241            .f64()
242            .map_err(|e| BacktestError::InvalidInput(e.to_string()))?;
243
244        let mut best_idx = 0;
245        let mut best_val = f64::NEG_INFINITY;
246        for (i, val) in obj_series.into_iter().enumerate() {
247            if let Some(v) = val
248                && (v > best_val || (best_val == f64::NEG_INFINITY && v.is_finite()))
249            {
250                best_val = v;
251                best_idx = i;
252            }
253        }
254
255        let winning_variant = &variants[best_idx];
256        for k in &param_keys {
257            best_params
258                .get_mut(k)
259                .ok_or_else(|| {
260                    internal_invariant(format!(
261                        "best param column '{k}' missing from walk-forward optimize accumulator"
262                    ))
263                })?
264                .push(winning_variant.params[k]);
265        }
266        train_metrics.push(best_val);
267
268        // 2. OOS Backtest
269        let oos_lf = df.clone().lazy().filter(
270            col(ts_col)
271                .gt_eq(lit(ts_oos_start))
272                .and(col(ts_col).lt_eq(lit(ts_oos_end))),
273        );
274
275        let mut oos_config = base_config.clone();
276        oos_config.signal_col = winning_variant.signal_col.clone();
277        let report = BacktestEngine::new(oos_config).backtest_with_report(oos_lf)?;
278
279        let oos_val = report
280            .metrics
281            .row_iter()
282            .find(|(n, _)| *n == objective_metric)
283            .map(|(_, v)| v)
284            .ok_or_else(|| {
285                BacktestError::InvalidInput(format!(
286                    "objective_metric '{objective_metric}' not found in OOS metrics"
287                ))
288            })?;
289        oos_metrics.push(oos_val);
290        overfit_flags.push(best_val - oos_val > wf.overfit_threshold);
291
292        for (name, value) in report.metrics.row_iter() {
293            metric_cols
294                .get_mut(name)
295                .ok_or_else(|| {
296                    internal_invariant(format!(
297                        "metric column '{name}' missing from walk-forward optimize accumulator"
298                    ))
299                })?
300                .push(value);
301        }
302
303        fold_ids.push(fold_id as f64);
304        oos_starts.push(ts_oos_start as f64);
305        oos_ends.push(ts_oos_end as f64);
306
307        fold_id += 1;
308        start += step;
309    }
310
311    if fold_ids.is_empty() {
312        return Err(BacktestError::InvalidInput(
313            "insufficient bars for wfo".into(),
314        ));
315    }
316
317    let mut columns = vec![
318        Column::new("fold_id".into(), fold_ids),
319        Column::new("oos_start_ts".into(), oos_starts),
320        Column::new("oos_end_ts".into(), oos_ends),
321        Column::new("train_metric".into(), train_metrics),
322        Column::new("oos_metric".into(), oos_metrics),
323        Column::new("overfit_flag".into(), overfit_flags),
324    ];
325    for k in &param_keys {
326        columns.push(Column::new(
327            format!("best_{k}").into(),
328            best_params.remove(k).ok_or_else(|| {
329                internal_invariant(format!(
330                    "best param column '{k}' missing when building walk-forward optimize df"
331                ))
332            })?,
333        ));
334    }
335    for name in PerformanceMetrics::column_names() {
336        columns.push(Column::new(
337            PlSmallStr::from_str(name),
338            metric_cols.remove(name).ok_or_else(|| {
339                internal_invariant(format!(
340                    "metric column '{name}' missing when building walk-forward optimize df"
341                ))
342            })?,
343        ));
344    }
345
346    DataFrame::new(columns).map_err(BacktestError::from)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use approx::assert_relative_eq;
353
354    fn wf_base_df(n: usize) -> DataFrame {
355        DataFrame::new(vec![
356            Column::new(
357                "timestamp".into(),
358                (0..n as i64)
359                    .map(|i| 1_700_000_000 + i * 3600)
360                    .collect::<Vec<_>>(),
361            ),
362            Column::new(
363                "close".into(),
364                (0..n).map(|i| 100.0 + i as f64 * 0.1).collect::<Vec<_>>(),
365            ),
366            Column::new(
367                "signal".into(),
368                (0..n)
369                    .map(|i| if (i / 20) % 2 == 0 { 1.0 } else { 0.0 })
370                    .collect::<Vec<_>>(),
371            ),
372        ])
373        .unwrap()
374    }
375
376    fn zero_cost_config() -> BacktestConfig {
377        BacktestConfig {
378            cost_model: crate::CostModel {
379                commission_bps: 0.0,
380                slippage_bps: 0.0,
381                initial_cash: 100_000.0,
382            },
383            ..Default::default()
384        }
385    }
386
387    #[test]
388    fn test_walk_forward_produces_two_folds() {
389        let wf = WalkForwardConfig::new(30, 20);
390        let df = run_walk_forward(wf_base_df(100).lazy(), &zero_cost_config(), &wf).unwrap();
391
392        // 100 unique bars, train=30, test=20, step=20 → folds at 0, 20, 40
393        assert_eq!(df.height(), 3);
394        assert!(df.column("fold_id").is_ok());
395        assert!(df.column("num_trades").is_ok());
396        assert_relative_eq!(
397            df.column("fold_id").unwrap().f64().unwrap().get(2).unwrap(),
398            2.0,
399            epsilon = 1e-9
400        );
401    }
402
403    #[test]
404    fn test_walk_forward_insufficient_bars_errors() {
405        let wf = WalkForwardConfig::new(50, 50);
406        let err = run_walk_forward(wf_base_df(60).lazy(), &zero_cost_config(), &wf)
407            .unwrap_err()
408            .to_string();
409        assert!(err.contains("insufficient bars"));
410    }
411
412    #[test]
413    fn test_walk_forward_oos_windows_do_not_overlap_when_step_equals_test() {
414        let wf = WalkForwardConfig::new(20, 15);
415        let df = run_walk_forward(wf_base_df(80).lazy(), &zero_cost_config(), &wf).unwrap();
416        let starts = df.column("oos_start_ts").unwrap().f64().unwrap();
417        let ends = df.column("oos_end_ts").unwrap().f64().unwrap();
418        for i in 0..df.height() - 1 {
419            assert!(ends.get(i).unwrap() < starts.get(i + 1).unwrap());
420        }
421    }
422
423    fn wfo_base_df(n: usize) -> DataFrame {
424        // Create an explicit pattern: signal_A is good in first half (train), bad in second (OOS).
425        // signal_B is bad in first half, good in second half.
426        let mut close = vec![100.0; n];
427        let mut signal_a = vec![0.0; n];
428        let mut signal_b = vec![0.0; n];
429
430        for i in 1..n {
431            if i < n / 2 {
432                // First half: A makes money, B loses
433                signal_a[i] = 1.0;
434                signal_b[i] = -1.0;
435                close[i] = close[i - 1] + 1.0;
436            } else {
437                // Second half: A loses, B makes money
438                signal_a[i] = 1.0;
439                signal_b[i] = -1.0;
440                close[i] = close[i - 1] - 1.0;
441            }
442        }
443
444        DataFrame::new(vec![
445            Column::new("timestamp".into(), (0..n as i64).collect::<Vec<_>>()),
446            Column::new("close".into(), close),
447            Column::new("signal_A".into(), signal_a),
448            Column::new("signal_B".into(), signal_b),
449        ])
450        .unwrap()
451    }
452
453    #[test]
454    fn test_wfo_opt_picks_higher_sharpe_param_on_train() {
455        let wf = WalkForwardConfig::new(20, 20); // 20 train, 20 oos (total 40 bars)
456        let df = wfo_base_df(40);
457        let variants = vec![
458            crate::SweepVariant {
459                params: std::collections::HashMap::from([("param".into(), 1.0)]),
460                signal_col: "signal_A".into(),
461            },
462            crate::SweepVariant {
463                params: std::collections::HashMap::from([("param".into(), 2.0)]),
464                signal_col: "signal_B".into(),
465            },
466        ];
467
468        let out = run_walk_forward_optimize(
469            df.lazy(),
470            &zero_cost_config(),
471            &wf,
472            &variants,
473            "total_return",
474        )
475        .unwrap();
476
477        assert_eq!(out.height(), 1);
478        let best_param = out
479            .column("best_param")
480            .unwrap()
481            .f64()
482            .unwrap()
483            .get(0)
484            .unwrap();
485        // In train (0..20), A is profitable, so param 1.0 should be chosen
486        assert_eq!(best_param, 1.0);
487    }
488
489    #[test]
490    fn test_wfo_opt_oos_uses_locked_param_not_reoptimized() {
491        let wf = WalkForwardConfig::new(20, 20);
492        let df = wfo_base_df(40);
493        let variants = vec![
494            crate::SweepVariant {
495                params: std::collections::HashMap::from([("param".into(), 1.0)]),
496                signal_col: "signal_A".into(),
497            },
498            crate::SweepVariant {
499                params: std::collections::HashMap::from([("param".into(), 2.0)]),
500                signal_col: "signal_B".into(),
501            },
502        ];
503        let out = run_walk_forward_optimize(
504            df.lazy(),
505            &zero_cost_config(),
506            &wf,
507            &variants,
508            "total_return",
509        )
510        .unwrap();
511
512        let oos_metric = out
513            .column("oos_metric")
514            .unwrap()
515            .f64()
516            .unwrap()
517            .get(0)
518            .unwrap();
519        // In OOS (20..40), A loses money, so total_return should be negative
520        assert!(oos_metric < 0.0);
521    }
522
523    #[test]
524    fn test_wfo_opt_overfit_flag_when_train_oos_diverge() {
525        let mut wf = WalkForwardConfig::new(20, 20);
526        wf.overfit_threshold = 0.0; // PnL is very small due to 1 unit position
527        let df = wfo_base_df(40);
528        let variants = vec![crate::SweepVariant {
529            params: std::collections::HashMap::from([("p".into(), 1.0)]),
530            signal_col: "signal_A".into(),
531        }];
532        let out = run_walk_forward_optimize(
533            df.lazy(),
534            &zero_cost_config(),
535            &wf,
536            &variants,
537            "total_return",
538        )
539        .unwrap();
540
541        let overfit = out
542            .column("overfit_flag")
543            .unwrap()
544            .bool()
545            .unwrap()
546            .get(0)
547            .unwrap();
548        // Train return > 0, OOS return < 0, difference is large
549        assert!(overfit);
550    }
551
552    #[test]
553    fn test_wfo_opt_fold_count_matches_walk_forward() {
554        let wf = WalkForwardConfig::new(20, 10);
555        let df = wfo_base_df(60);
556        let variants = vec![crate::SweepVariant {
557            params: std::collections::HashMap::from([("p".into(), 1.0)]),
558            signal_col: "signal_A".into(),
559        }];
560        let mut cfg = zero_cost_config();
561        cfg.signal_col = "signal_A".into();
562        let out1 = run_walk_forward(df.clone().lazy(), &cfg, &wf).unwrap();
563        let out2 = run_walk_forward_optimize(
564            df.lazy(),
565            &zero_cost_config(),
566            &wf,
567            &variants,
568            "total_return",
569        )
570        .unwrap();
571
572        assert_eq!(out1.height(), out2.height());
573    }
574}