1use crate::{
7 BacktestConfig, BacktestEngine, BacktestError, PerformanceMetrics, internal_invariant,
8};
9use polars::prelude::*;
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, PartialEq)]
14pub struct WalkForwardConfig {
15 pub train_bars: usize,
17 pub test_bars: usize,
19 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
39pub 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
175pub 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 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 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 ¶m_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 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 ¶m_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 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 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 signal_a[i] = 1.0;
434 signal_b[i] = -1.0;
435 close[i] = close[i - 1] + 1.0;
436 } else {
437 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); 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 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 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; 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 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}