Skip to main content

finance_query/backtesting/
walk_forward.rs

1//! Walk-forward parameter optimisation for backtesting strategies.
2//!
3//! Walk-forward testing prevents overfitting by splitting historical data into
4//! rolling in-sample (training) and out-of-sample (test) windows. For each
5//! window, the best parameters are discovered on the in-sample slice via grid
6//! search, then validated on the subsequent out-of-sample slice.
7//!
8//! # How it works
9//!
10//! ```text
11//! |--- in-sample (IS) ---|--- out-of-sample (OOS) ---|
12//!            |-- step --|--- IS ---|--- OOS ---|
13//!                                  |-- step --|--- IS ---|--- OOS ---|
14//! ```
15//!
16//! Aggregate metrics from all OOS windows provide an unbiased estimate of
17//! real-world strategy performance.
18//!
19//! # Example
20//!
21//! ```ignore
22//! use finance_query::backtesting::{
23//!     BacktestConfig, SmaCrossover,
24//!     optimizer::{GridSearch, OptimizeMetric, ParamRange},
25//!     walk_forward::WalkForwardConfig,
26//! };
27//!
28//! # fn example(candles: &[finance_query::models::chart::Candle]) {
29//! let grid = GridSearch::new()
30//!     .param("fast", ParamRange::int_range(5, 30, 5))
31//!     .param("slow", ParamRange::int_range(20, 100, 10))
32//!     .optimize_for(OptimizeMetric::SharpeRatio);
33//!
34//! let wf = WalkForwardConfig::new(grid, BacktestConfig::default())
35//!     .in_sample_bars(252)
36//!     .out_of_sample_bars(63);
37//!
38//! let report = wf
39//!     .run("AAPL", candles, |params| SmaCrossover::new(
40//!         params["fast"].as_int() as usize,
41//!         params["slow"].as_int() as usize,
42//!     ))
43//!     .unwrap();
44//!
45//! println!("OOS consistency: {:.1}%", report.consistency_ratio * 100.0);
46//! # }
47//! ```
48
49use std::collections::HashMap;
50
51use rayon::prelude::*;
52use serde::{Deserialize, Serialize};
53
54use crate::models::chart::Candle;
55
56use super::config::BacktestConfig;
57use super::error::{BacktestError, Result};
58use super::optimizer::{GridSearch, OptimizationReport, ParamValue};
59use super::result::{BacktestResult, PerformanceMetrics};
60use super::strategy::Strategy;
61
62// ── Result types ─────────────────────────────────────────────────────────────
63
64/// Backtest results for a single walk-forward window pair.
65#[non_exhaustive]
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct WindowResult {
68    /// Zero-based window index
69    pub window: usize,
70    /// Parameter values selected as best on the in-sample data
71    pub optimized_params: HashMap<String, ParamValue>,
72    /// In-sample backtest result (using the best parameters)
73    pub in_sample: BacktestResult,
74    /// Out-of-sample backtest result (using the same best parameters)
75    pub out_of_sample: BacktestResult,
76}
77
78/// Aggregate walk-forward report across all windows.
79#[non_exhaustive]
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct WalkForwardReport {
82    /// Strategy name
83    pub strategy_name: String,
84    /// Per-window results
85    pub windows: Vec<WindowResult>,
86    /// Aggregate performance metrics computed from the concatenated OOS equity curves
87    pub aggregate_metrics: PerformanceMetrics,
88    /// Fraction of OOS windows that were profitable (0.0 – 1.0)
89    pub consistency_ratio: f64,
90    /// Full grid-search optimisation reports, one per window
91    pub optimization_reports: Vec<OptimizationReport>,
92}
93
94// ── WalkForwardConfig ─────────────────────────────────────────────────────────
95
96/// Configuration for a walk-forward parameter optimisation test.
97///
98/// Build with [`WalkForwardConfig::new`], configure window sizes with the
99/// builder methods, then call [`WalkForwardConfig::run`].
100#[non_exhaustive]
101#[derive(Debug, Clone)]
102pub struct WalkForwardConfig {
103    /// Grid search to use for optimising in-sample windows
104    pub grid: GridSearch,
105    /// Base backtest configuration (capital, commission, slippage, …)
106    pub config: BacktestConfig,
107    /// Number of bars in each in-sample (training) window
108    pub in_sample_bars: usize,
109    /// Number of bars in each out-of-sample (test) window
110    pub out_of_sample_bars: usize,
111    /// Number of bars to advance the window each step.
112    ///
113    /// Defaults to `out_of_sample_bars` (non-overlapping OOS windows).
114    pub step_bars: Option<usize>,
115}
116
117impl WalkForwardConfig {
118    /// Create a new walk-forward config.
119    ///
120    /// Defaults: `in_sample_bars = 252`, `out_of_sample_bars = 63`, `step_bars = None`.
121    pub fn new(grid: GridSearch, config: BacktestConfig) -> Self {
122        Self {
123            grid,
124            config,
125            in_sample_bars: 252,
126            out_of_sample_bars: 63,
127            step_bars: None,
128        }
129    }
130
131    /// Set the number of bars for each in-sample (training) window.
132    pub fn in_sample_bars(mut self, bars: usize) -> Self {
133        self.in_sample_bars = bars;
134        self
135    }
136
137    /// Set the number of bars for each out-of-sample (test) window.
138    pub fn out_of_sample_bars(mut self, bars: usize) -> Self {
139        self.out_of_sample_bars = bars;
140        self
141    }
142
143    /// Set the step size (bars to advance between windows).
144    ///
145    /// Defaults to `out_of_sample_bars` for non-overlapping OOS windows.
146    pub fn step_bars(mut self, bars: usize) -> Self {
147        self.step_bars = Some(bars);
148        self
149    }
150
151    /// Run the walk-forward test.
152    ///
153    /// `symbol` is used only for labelling. `factory` receives the parameter
154    /// map selected by each in-sample optimisation and must return a fresh
155    /// strategy instance.
156    ///
157    /// Returns an error if there is not enough data for at least one complete
158    /// window pair, or if the grid search or the out-of-sample simulation
159    /// fails on any window (fail-fast — a partial result is never returned).
160    pub fn run<S, F>(
161        &self,
162        symbol: &str,
163        candles: &[Candle],
164        factory: F,
165    ) -> Result<WalkForwardReport>
166    where
167        S: Strategy + Clone + Send,
168        F: Fn(&HashMap<String, ParamValue>) -> S,
169        F: Send + Sync,
170    {
171        self.validate(candles.len())?;
172        // Checked once for the whole series; every window is a slice of it.
173        crate::backtesting::engine::validate_series_order(candles, &[])?;
174
175        let step = self.step_bars.unwrap_or(self.out_of_sample_bars);
176        let total_bars = self.in_sample_bars + self.out_of_sample_bars;
177
178        // Slide the window through the candle series
179        let starts: Vec<usize> = {
180            let mut v = Vec::new();
181            let mut start = 0usize;
182            while start + total_bars <= candles.len() {
183                v.push(start);
184                start += step;
185            }
186            v
187        };
188
189        let mut windows: Vec<WindowResult> = Vec::with_capacity(starts.len());
190        let mut opt_reports: Vec<OptimizationReport> = Vec::with_capacity(starts.len());
191
192        // Collect every result rather than short-circuiting: rayon does not
193        // define which error wins a fallible collect, and callers rely on the
194        // lowest-index window's failure being the one reported.
195        let results: Vec<Result<(WindowResult, OptimizationReport)>> = starts
196            .par_iter()
197            .enumerate()
198            .map(|(idx, &start)| self.run_one_window(idx, start, symbol, candles, &factory))
199            .collect();
200
201        for r in results {
202            let (w, o) = r?;
203            windows.push(w);
204            opt_reports.push(o);
205        }
206
207        let strategy_name = windows[0].in_sample.strategy_name.clone();
208        let consistency_ratio = calculate_consistency_ratio(&windows);
209        let aggregate_metrics = aggregate_oos_metrics(
210            &windows,
211            self.config.risk_free_rate,
212            self.config.bars_per_year,
213        );
214
215        Ok(WalkForwardReport {
216            strategy_name,
217            windows,
218            aggregate_metrics,
219            consistency_ratio,
220            optimization_reports: opt_reports,
221        })
222    }
223
224    /// Run the optimisation and out-of-sample test for a single window.
225    fn run_one_window<S, F>(
226        &self,
227        window_idx: usize,
228        start: usize,
229        symbol: &str,
230        candles: &[Candle],
231        factory: &F,
232    ) -> Result<(WindowResult, OptimizationReport)>
233    where
234        S: Strategy + Clone + Send,
235        F: Fn(&HashMap<String, ParamValue>) -> S,
236        F: Send + Sync,
237    {
238        let is_end = start + self.in_sample_bars;
239        let oos_end = is_end + self.out_of_sample_bars;
240
241        let is_candles = &candles[start..is_end];
242        let oos_candles = &candles[is_end..oos_end];
243
244        // Optimise on the in-sample slice
245        let opt_report = self
246            .grid
247            .run(symbol, is_candles, &self.config, factory)
248            .map_err(|e| {
249                BacktestError::invalid_param(
250                    "walk_forward",
251                    format!("window {window_idx} optimisation failed: {e}"),
252                )
253            })?;
254
255        let best_params = opt_report.best.params.clone();
256        let is_result = opt_report.best.result.clone();
257
258        // Test on the out-of-sample slice using the best parameters
259        let oos_strategy = factory(&best_params);
260        let oos_result = crate::backtesting::BacktestEngine::new(self.config.clone())
261            .simulate(symbol, oos_candles, oos_strategy, &[])
262            .map_err(|e| {
263                BacktestError::invalid_param(
264                    "walk_forward",
265                    format!("window {window_idx} OOS run failed: {e}"),
266                )
267            })?;
268
269        Ok((
270            WindowResult {
271                window: window_idx,
272                optimized_params: best_params,
273                in_sample: is_result,
274                out_of_sample: oos_result,
275            },
276            opt_report,
277        ))
278    }
279
280    /// Validate the configuration before running.
281    fn validate(&self, num_candles: usize) -> Result<()> {
282        if self.in_sample_bars == 0 {
283            return Err(BacktestError::invalid_param(
284                "in_sample_bars",
285                "must be greater than zero",
286            ));
287        }
288        if self.out_of_sample_bars == 0 {
289            return Err(BacktestError::invalid_param(
290                "out_of_sample_bars",
291                "must be greater than zero",
292            ));
293        }
294        if self.step_bars == Some(0) {
295            return Err(BacktestError::invalid_param(
296                "step_bars",
297                "must be greater than zero",
298            ));
299        }
300        let total_bars = self.in_sample_bars + self.out_of_sample_bars;
301        if num_candles < total_bars {
302            return Err(BacktestError::insufficient_data(total_bars, num_candles));
303        }
304        Ok(())
305    }
306}
307
308// ── Internal helpers ──────────────────────────────────────────────────────────
309
310/// Fraction of OOS windows that had a positive total P&L.
311fn calculate_consistency_ratio(windows: &[WindowResult]) -> f64 {
312    if windows.is_empty() {
313        return 0.0;
314    }
315    let profitable = windows
316        .iter()
317        .filter(|w| w.out_of_sample.is_profitable())
318        .count();
319    profitable as f64 / windows.len() as f64
320}
321
322/// Compute aggregate `PerformanceMetrics` over all OOS trade lists and equity curves.
323///
324/// Concatenates trades and stitches OOS equity curves so each window starts
325/// from the previous window's ending equity.
326fn aggregate_oos_metrics(
327    windows: &[WindowResult],
328    risk_free_rate: f64,
329    bars_per_year: f64,
330) -> PerformanceMetrics {
331    use crate::backtesting::result::EquityPoint;
332
333    let all_trades: Vec<_> = windows
334        .iter()
335        .flat_map(|w| w.out_of_sample.trades.iter().cloned())
336        .collect();
337
338    // Stitch per-window equity into one continuous compounded series.
339    // Each OOS window internally resets to its own initial capital; to avoid
340    // synthetic drawdowns between windows, scale each window by the running
341    // equity level from the previous window.
342    let mut combined_equity: Vec<EquityPoint> = Vec::new();
343    // `windows` is guaranteed non-empty by the validation above; index directly.
344    let mut running_equity = windows[0].out_of_sample.initial_capital;
345
346    for (window_idx, window) in windows.iter().enumerate() {
347        let window_initial = window.out_of_sample.initial_capital;
348        if window_initial <= 0.0 {
349            continue;
350        }
351
352        for (point_idx, point) in window.out_of_sample.equity_curve.iter().enumerate() {
353            if window_idx > 0 && point_idx == 0 {
354                continue;
355            }
356
357            let scaled_equity = running_equity * (point.equity / window_initial);
358            combined_equity.push(EquityPoint {
359                timestamp: point.timestamp,
360                equity: scaled_equity,
361                drawdown_pct: 0.0,
362            });
363        }
364
365        if let Some(last) = combined_equity.last() {
366            running_equity = last.equity;
367        }
368    }
369
370    // Recompute drawdowns on the stitched curve.
371    let mut peak = f64::NEG_INFINITY;
372    for point in &mut combined_equity {
373        peak = peak.max(point.equity);
374        point.drawdown_pct = if peak > 0.0 {
375            (peak - point.equity) / peak
376        } else {
377            0.0
378        };
379    }
380
381    // Aggregate metrics use the initial capital of the first OOS window.
382    let initial_capital = windows
383        .first()
384        .map(|w| w.out_of_sample.initial_capital)
385        .unwrap_or(10_000.0);
386
387    let total_signals: usize = windows.iter().map(|w| w.out_of_sample.signals.len()).sum();
388    let executed_signals: usize = windows
389        .iter()
390        .map(|w| {
391            w.out_of_sample
392                .signals
393                .iter()
394                .filter(|s| s.executed)
395                .count()
396        })
397        .sum();
398
399    PerformanceMetrics::calculate(
400        &all_trades,
401        &combined_equity,
402        initial_capital,
403        total_signals,
404        executed_signals,
405        risk_free_rate,
406        bars_per_year,
407    )
408}
409
410// ── Tests ─────────────────────────────────────────────────────────────────────
411
412#[cfg(test)]
413#[path = "walk_forward_tests.rs"]
414mod tests;