finance_query/backtesting/
walk_forward.rs1use 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#[non_exhaustive]
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct WindowResult {
68 pub window: usize,
70 pub optimized_params: HashMap<String, ParamValue>,
72 pub in_sample: BacktestResult,
74 pub out_of_sample: BacktestResult,
76}
77
78#[non_exhaustive]
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct WalkForwardReport {
82 pub strategy_name: String,
84 pub windows: Vec<WindowResult>,
86 pub aggregate_metrics: PerformanceMetrics,
88 pub consistency_ratio: f64,
90 pub optimization_reports: Vec<OptimizationReport>,
92}
93
94#[non_exhaustive]
101#[derive(Debug, Clone)]
102pub struct WalkForwardConfig {
103 pub grid: GridSearch,
105 pub config: BacktestConfig,
107 pub in_sample_bars: usize,
109 pub out_of_sample_bars: usize,
111 pub step_bars: Option<usize>,
115}
116
117impl WalkForwardConfig {
118 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 pub fn in_sample_bars(mut self, bars: usize) -> Self {
133 self.in_sample_bars = bars;
134 self
135 }
136
137 pub fn out_of_sample_bars(mut self, bars: usize) -> Self {
139 self.out_of_sample_bars = bars;
140 self
141 }
142
143 pub fn step_bars(mut self, bars: usize) -> Self {
147 self.step_bars = Some(bars);
148 self
149 }
150
151 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 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 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 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 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 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 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 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
308fn 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
322fn 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 let mut combined_equity: Vec<EquityPoint> = Vec::new();
343 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 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 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#[cfg(test)]
413#[path = "walk_forward_tests.rs"]
414mod tests;