Extract 9 new sub-crates from the ml monolith to enable parallel compilation across the workspace: New crates (this commit): - ml-features (282 tests): feature engineering, 21 modules - ml-labeling (45 tests): triple barrier, meta-labeling, fractional diff - ml-ensemble (116 tests): ensemble coordination, voting, confidence - ml-hyperopt (47 tests): core PSO/TPE optimizer, parameter space - ml-checkpoint (41 tests): checkpoint persistence, compression, signing - ml-regime (68 tests): CUSUM, Bayesian changepoint, regime classification - ml-data-validation (67 tests): FDR correction, CPCV, data quality - ml-risk (33 tests): neural VaR, Kelly criterion, circuit breakers - ml-validation (43 tests): statistical validation, walk-forward, DSR Extended existing crates: - ml-dqn: added evaluation/ (backtesting engine, metrics, reports) and checkpoint implementation - ml-supervised: added checkpoint implementations - ml-core: added shared types needed by new sub-crates Pattern: each module in ml/ becomes a thin facade (pub use subcrate::*) with bridge modules staying in ml for cross-model adapter code. Dead code deleted (~7K lines): - 13 undeclared files in microstructure/ (never compiled) - 7 undeclared files + tests/ in risk/ (never compiled) - parquet_io, cache_service, cache_storage, minio_integration (unused) - extraction_wave_d_impl.rs (bare fn outside impl block) All 2,746 sub-crate tests + 951 ml tests pass. Full workspace builds clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
310 lines
10 KiB
Rust
310 lines
10 KiB
Rust
//! 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<usize>,
|
|
/// Bar range of the embargo gap (exclusive end).
|
|
pub embargo_range: Range<usize>,
|
|
/// Bar range used for testing (exclusive end).
|
|
pub test_range: Range<usize>,
|
|
}
|
|
|
|
/// Generate walk-forward cross-validation folds with embargo.
|
|
///
|
|
/// Uses a **rolling window** design: each fold has a fixed-size training window
|
|
/// that slides forward. This is the standard approach for evaluating strategies
|
|
/// on recent data (avoids stale early data diluting training signal).
|
|
///
|
|
/// An expanding window variant (where training grows from an anchor point) may
|
|
/// be added later if needed for strategies that benefit from larger datasets.
|
|
///
|
|
/// 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<Fold> {
|
|
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");
|
|
}
|
|
}
|