diff --git a/ml/src/validation/mod.rs b/ml/src/validation/mod.rs index 0040fb6e2..3a199f7ad 100644 --- a/ml/src/validation/mod.rs +++ b/ml/src/validation/mod.rs @@ -6,6 +6,7 @@ pub mod financial; pub mod types; +pub mod walk_forward; // Re-export financial validation for backward compatibility pub use financial::{ @@ -15,3 +16,6 @@ pub use financial::{ // Re-export statistical validation types pub use types::{TimeSeriesData, ValidatableStrategy}; + +// Re-export walk-forward cross-validation splitter +pub use walk_forward::{walk_forward_split, Fold, WalkForwardConfig}; diff --git a/ml/src/validation/walk_forward.rs b/ml/src/validation/walk_forward.rs new file mode 100644 index 000000000..733b263d6 --- /dev/null +++ b/ml/src/validation/walk_forward.rs @@ -0,0 +1,302 @@ +//! Walk-forward cross-validation splitter with embargo periods. +//! +//! Provides a time-series-aware splitting strategy that slides a +//! `[train | embargo | test]` window across bar indices. The embargo gap +//! between train and test prevents information leakage from lookback +//! features (e.g., moving averages, rolling volatility). +//! +//! # Example +//! +//! ``` +//! use ml::validation::walk_forward::{WalkForwardConfig, walk_forward_split}; +//! +//! let cfg = WalkForwardConfig { +//! train_bars: 100, +//! test_bars: 20, +//! embargo_bars: 5, +//! step_bars: 20, +//! min_train_samples: 50, +//! }; +//! let folds = walk_forward_split(200, &cfg); +//! assert!(!folds.is_empty()); +//! ``` + +use serde::{Deserialize, Serialize}; +use std::ops::Range; + +/// Configuration for walk-forward cross-validation. +/// +/// Controls the sizes of the training window, embargo gap, test window, +/// and the step size between successive folds. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalkForwardConfig { + /// Number of bars in each training window. + pub train_bars: usize, + /// Number of bars in each test window. + pub test_bars: usize, + /// Number of bars between train end and test start (prevents leakage). + pub embargo_bars: usize, + /// How far to advance the window origin each fold. + pub step_bars: usize, + /// Minimum number of training bars required to produce a fold. + pub min_train_samples: usize, +} + +impl Default for WalkForwardConfig { + fn default() -> Self { + Self { + train_bars: 1260, // ~5 years of daily bars + test_bars: 252, // ~1 year of daily bars + embargo_bars: 20, // ~1 month gap + step_bars: 126, // ~6 months advance per fold + min_train_samples: 252, // ~1 year minimum training data + } + } +} + +/// A single fold produced by the walk-forward splitter. +/// +/// Each fold defines three contiguous, non-overlapping ranges: +/// `[train_range | embargo_range | test_range]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fold { + /// Zero-based index of this fold in the sequence. + pub fold_index: usize, + /// Bar range used for training (exclusive end). + pub train_range: Range, + /// Bar range of the embargo gap (exclusive end). + pub embargo_range: Range, + /// Bar range used for testing (exclusive end). + pub test_range: Range, +} + +/// Generate walk-forward cross-validation folds with embargo. +/// +/// Slides a `[train | embargo | test]` window across `num_bars` bar indices, +/// advancing by `config.step_bars` each iteration. A fold is only emitted when: +/// - The training window has at least `config.min_train_samples` bars +/// - The test window fits entirely within `[0, num_bars)` +/// +/// Returns an empty `Vec` if the data is too short for even one fold. +pub fn walk_forward_split(num_bars: usize, config: &WalkForwardConfig) -> Vec { + let mut folds = Vec::new(); + + // Total bars consumed by one complete window + let window_size = config + .train_bars + .saturating_add(config.embargo_bars) + .saturating_add(config.test_bars); + + // Early exit: not enough data for even one fold + if num_bars < window_size { + return folds; + } + + // Also bail if train window is smaller than the minimum + if config.train_bars < config.min_train_samples { + return folds; + } + + let mut train_start: usize = 0; + let mut fold_index: usize = 0; + + loop { + let train_end = train_start.saturating_add(config.train_bars); + let embargo_end = train_end.saturating_add(config.embargo_bars); + let test_end = embargo_end.saturating_add(config.test_bars); + + // Stop if test window exceeds available data + if test_end > num_bars { + break; + } + + folds.push(Fold { + fold_index, + train_range: train_start..train_end, + embargo_range: train_end..embargo_end, + test_range: embargo_end..test_end, + }); + + fold_index = fold_index.saturating_add(1); + + // Advance origin by step_bars; use saturating_add to avoid overflow + train_start = train_start.saturating_add(config.step_bars); + } + + folds +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_walk_forward_split() { + let cfg = WalkForwardConfig { + train_bars: 100, + test_bars: 20, + embargo_bars: 5, + step_bars: 20, + min_train_samples: 50, + }; + let folds = walk_forward_split(300, &cfg); + + // Should produce at least 2 folds + assert!( + folds.len() >= 2, + "Expected at least 2 folds, got {}", + folds.len() + ); + + // First fold: train [0..100), embargo [100..105), test [105..125) + let f0 = folds.first().expect("first fold must exist"); + assert_eq!(f0.train_range, 0..100); + assert_eq!(f0.embargo_range, 100..105); + assert_eq!(f0.test_range, 105..125); + + // Second fold: train [20..120), embargo [120..125), test [125..145) + let f1 = folds.get(1).expect("second fold must exist"); + assert_eq!(f1.train_range, 20..120); + assert_eq!(f1.embargo_range, 120..125); + assert_eq!(f1.test_range, 125..145); + } + + #[test] + fn test_no_overlap_between_train_and_test() { + let cfg = WalkForwardConfig { + train_bars: 80, + test_bars: 30, + embargo_bars: 10, + step_bars: 25, + min_train_samples: 40, + }; + let folds = walk_forward_split(500, &cfg); + assert!(!folds.is_empty(), "Expected at least one fold"); + + for fold in &folds { + // Train end == embargo start + assert_eq!( + fold.train_range.end, fold.embargo_range.start, + "Fold {}: train.end ({}) != embargo.start ({})", + fold.fold_index, fold.train_range.end, fold.embargo_range.start + ); + // Embargo end == test start + assert_eq!( + fold.embargo_range.end, fold.test_range.start, + "Fold {}: embargo.end ({}) != test.start ({})", + fold.fold_index, fold.embargo_range.end, fold.test_range.start + ); + // No overlap: train end <= test start (with embargo in between) + assert!( + fold.train_range.end <= fold.test_range.start, + "Fold {}: train overlaps test ({} > {})", + fold.fold_index, + fold.train_range.end, + fold.test_range.start, + ); + } + } + + #[test] + fn test_temporal_ordering_across_folds() { + let cfg = WalkForwardConfig { + train_bars: 60, + test_bars: 15, + embargo_bars: 5, + step_bars: 15, + min_train_samples: 30, + }; + let folds = walk_forward_split(300, &cfg); + assert!( + folds.len() >= 2, + "Need at least 2 folds for ordering test, got {}", + folds.len() + ); + + for pair in folds.windows(2) { + if let [prev, curr] = pair { + assert!( + curr.test_range.start >= prev.test_range.start, + "Fold {} test start ({}) < fold {} test start ({})", + curr.fold_index, + curr.test_range.start, + prev.fold_index, + prev.test_range.start, + ); + } + } + } + + #[test] + fn test_data_too_short_returns_empty() { + let cfg = WalkForwardConfig { + train_bars: 100, + test_bars: 20, + embargo_bars: 5, + step_bars: 20, + min_train_samples: 50, + }; + // Total window = 100 + 5 + 20 = 125, but only 100 bars available + let folds = walk_forward_split(100, &cfg); + assert!( + folds.is_empty(), + "Expected empty folds for insufficient data, got {} folds", + folds.len() + ); + } + + #[test] + fn test_fold_indices_are_sequential() { + let cfg = WalkForwardConfig { + train_bars: 50, + test_bars: 10, + embargo_bars: 3, + step_bars: 10, + min_train_samples: 20, + }; + let folds = walk_forward_split(200, &cfg); + assert!(!folds.is_empty(), "Expected at least one fold"); + + for (i, fold) in folds.iter().enumerate() { + assert_eq!( + fold.fold_index, i, + "Fold index mismatch: expected {}, got {}", + i, fold.fold_index + ); + } + } + + #[test] + fn test_test_range_within_bounds() { + let num_bars = 250; + let cfg = WalkForwardConfig { + train_bars: 80, + test_bars: 20, + embargo_bars: 5, + step_bars: 15, + min_train_samples: 40, + }; + let folds = walk_forward_split(num_bars, &cfg); + assert!(!folds.is_empty(), "Expected at least one fold"); + + for fold in &folds { + assert!( + fold.test_range.end <= num_bars, + "Fold {} test_range.end ({}) exceeds num_bars ({})", + fold.fold_index, + fold.test_range.end, + num_bars, + ); + } + } + + #[test] + fn test_default_config() { + let cfg = WalkForwardConfig::default(); + assert_eq!(cfg.train_bars, 1260, "Default train_bars should be ~5yr daily"); + assert_eq!(cfg.test_bars, 252, "Default test_bars should be ~1yr daily"); + assert_eq!(cfg.embargo_bars, 20, "Default embargo_bars should be ~1mo"); + assert_eq!(cfg.step_bars, 126, "Default step_bars should be ~6mo"); + assert_eq!(cfg.min_train_samples, 252, "Default min_train_samples should be ~1yr"); + } +}