Files
foxhunt/ml/tests/walk_forward_validation_tests.rs
jgrusewski 2df1ea92e1 feat(ml): WAVE 29 DQN Codebase Cleanup & Refactoring Campaign
BREAKING CHANGES:
- Removed orphaned dqn.rs monolithic trainer (4,975 lines)
- Removed orphaned dqn_ensemble.rs module (816 lines)
- Removed orphaned tft.rs and tft_complete_int8_integration_test.rs
- TFT trainer split into modular directory structure

DQN Module Refactoring:
- Split trainers/dqn.rs into modular structure (config.rs, statistics.rs, trainer.rs)
- Fixed hyperopt 39D search space (continuous params only)
- Boolean flags (use_dueling, use_double_dqn, use_per, use_noisy_nets) are now FIXED architectural decisions
- use_distributional defaults to false (Candle BUG #36 - scatter_add gradient issues)

Clean Module Structure:
- ml/src/trainers/dqn/ directory with proper mod.rs exports
- ml/src/trainers/tft/ directory with config.rs, types.rs, model.rs, trainer.rs, tests.rs
- All P0 features validated: TD-error clamping, batch diversity, LR scheduler, priority staleness

Documentation:
- Added comprehensive docs in docs/codebase-cleanup/
- ADR-001 for DQN refactoring decisions
- Rainbow DQN component matrix and quick reference guides

Build Status: Compiles with zero errors

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 23:46:13 +01:00

491 lines
17 KiB
Rust

#[cfg(test)]
mod walk_forward_tests {
use chrono::{DateTime, Duration, Utc};
/// Test data structure representing temporal market data
#[derive(Debug, Clone)]
struct TemporalDataPoint {
timestamp: DateTime<Utc>,
features: Vec<f32>,
target: f32,
}
/// Walk-forward validation configuration
#[derive(Debug, Clone)]
struct WalkForwardConfig {
train_window_days: i64,
validation_window_days: i64,
embargo_days: i64,
step_days: i64,
}
/// Result of a single fold in walk-forward validation
#[derive(Debug)]
struct FoldResult {
train_start: DateTime<Utc>,
train_end: DateTime<Utc>,
embargo_start: DateTime<Utc>,
embargo_end: DateTime<Utc>,
val_start: DateTime<Utc>,
val_end: DateTime<Utc>,
train_indices: Vec<usize>,
val_indices: Vec<usize>,
}
// Helper function to generate mock temporal data
fn generate_mock_data(start_date: DateTime<Utc>, num_days: usize) -> Vec<TemporalDataPoint> {
(0..num_days)
.map(|i| TemporalDataPoint {
timestamp: start_date + Duration::days(i as i64),
features: vec![i as f32; 10],
target: (i as f32) * 0.01,
})
.collect()
}
// Mock walk-forward split function (to be implemented)
fn walk_forward_split(
data: &[TemporalDataPoint],
config: &WalkForwardConfig,
) -> Vec<FoldResult> {
// This will be implemented in the actual codebase
// For now, return empty vec to make tests compilable but failing
vec![]
}
#[test]
fn test_temporal_split_maintains_order() {
// GIVEN: A dataset with known temporal ordering
let start_date = Utc::now();
let data = generate_mock_data(start_date, 365);
let config = WalkForwardConfig {
train_window_days: 90,
validation_window_days: 30,
embargo_days: 5,
step_days: 30,
};
// WHEN: We perform walk-forward validation splits
let folds = walk_forward_split(&data, &config);
// THEN: Every fold must maintain temporal order
for fold in &folds {
// Training data must come before embargo period
assert!(
fold.train_end <= fold.embargo_start,
"Training period must end before embargo period starts. \
Train end: {:?}, Embargo start: {:?}",
fold.train_end,
fold.embargo_start
);
// Embargo period must come before validation period
assert!(
fold.embargo_end <= fold.val_start,
"Embargo period must end before validation period starts. \
Embargo end: {:?}, Val start: {:?}",
fold.embargo_end,
fold.val_start
);
// Overall: train_start < train_end < embargo_start < embargo_end < val_start < val_end
assert!(fold.train_start < fold.train_end);
assert!(fold.embargo_start < fold.embargo_end);
assert!(fold.val_start < fold.val_end);
// Verify indices maintain temporal order
for window in fold.train_indices.windows(2) {
let earlier_timestamp = data[window[0]].timestamp;
let later_timestamp = data[window[1]].timestamp;
assert!(
earlier_timestamp <= later_timestamp,
"Training indices must be in temporal order"
);
}
for window in fold.val_indices.windows(2) {
let earlier_timestamp = data[window[0]].timestamp;
let later_timestamp = data[window[1]].timestamp;
assert!(
earlier_timestamp <= later_timestamp,
"Validation indices must be in temporal order"
);
}
}
}
#[test]
fn test_embargo_period_prevents_leakage() {
// GIVEN: A dataset with daily data points
let start_date = Utc::now();
let data = generate_mock_data(start_date, 365);
let config = WalkForwardConfig {
train_window_days: 90,
validation_window_days: 30,
embargo_days: 5, // Critical gap to prevent leakage
step_days: 30,
};
// WHEN: We perform walk-forward validation splits
let folds = walk_forward_split(&data, &config);
// THEN: There must be exactly embargo_days gap between train and validation
for fold in &folds {
// Calculate actual gap duration
let gap_duration = fold.val_start.signed_duration_since(fold.train_end);
let expected_gap = Duration::days(config.embargo_days);
assert_eq!(
gap_duration,
expected_gap,
"Embargo period must be exactly {} days. Found: {} days",
config.embargo_days,
gap_duration.num_days()
);
// Verify no data points exist in embargo period
for &train_idx in &fold.train_indices {
let train_timestamp = data[train_idx].timestamp;
assert!(
train_timestamp < fold.embargo_start,
"Training data timestamp ({:?}) must not overlap with embargo period ({:?} to {:?})",
train_timestamp,
fold.embargo_start,
fold.embargo_end
);
}
for &val_idx in &fold.val_indices {
let val_timestamp = data[val_idx].timestamp;
assert!(
val_timestamp >= fold.embargo_end,
"Validation data timestamp ({:?}) must not overlap with embargo period ({:?} to {:?})",
val_timestamp,
fold.embargo_start,
fold.embargo_end
);
}
// Verify embargo period is strictly empty
let embargo_data_count = data
.iter()
.filter(|d| d.timestamp >= fold.embargo_start && d.timestamp < fold.embargo_end)
.count();
assert!(
embargo_data_count > 0,
"Embargo period should contain data points that are excluded from both train and val"
);
}
}
#[test]
fn test_multiple_folds_cover_data() {
// GIVEN: A dataset spanning 365 days
let start_date = Utc::now();
let data = generate_mock_data(start_date, 365);
let config = WalkForwardConfig {
train_window_days: 90,
validation_window_days: 30,
embargo_days: 5,
step_days: 30, // Move forward 30 days each fold
};
// WHEN: We perform walk-forward validation splits
let folds = walk_forward_split(&data, &config);
// THEN: All data should be used across validation folds
// (training data can overlap, but validation should be disjoint)
// Verify we have multiple folds
assert!(
folds.len() >= 3,
"Should have at least 3 folds with these parameters. Found: {}",
folds.len()
);
// Collect all validation indices across folds
let mut all_val_indices = std::collections::HashSet::new();
for fold in &folds {
for &idx in &fold.val_indices {
// Validation periods should be disjoint (no overlap)
assert!(
all_val_indices.insert(idx),
"Validation index {} appears in multiple folds - validation periods must be disjoint",
idx
);
}
}
// Calculate expected coverage
// Total data points that can be in validation (excluding early train-only period)
let min_train_embargo_days = config.train_window_days + config.embargo_days;
let validation_eligible_start = data
.iter()
.position(|d| {
d.timestamp >= start_date + Duration::days(min_train_embargo_days)
})
.unwrap_or(data.len());
let validation_eligible_count = data.len() - validation_eligible_start;
// We should validate on a significant portion of eligible data
let coverage_ratio = all_val_indices.len() as f64 / validation_eligible_count as f64;
assert!(
coverage_ratio >= 0.7,
"Should validate on at least 70% of eligible data. Coverage: {:.1}%",
coverage_ratio * 100.0
);
// Verify folds are temporally ordered and non-overlapping
for i in 1..folds.len() {
let prev_fold = &folds[i - 1];
let curr_fold = &folds[i];
// Current fold should start after previous fold's validation
assert!(
curr_fold.val_start >= prev_fold.val_end,
"Fold {} validation period must start after fold {} validation ends",
i,
i - 1
);
}
}
#[test]
fn test_no_future_data_in_training() {
// CRITICAL TEST: Verify no look-ahead bias
// GIVEN: A dataset with known temporal ordering
let start_date = Utc::now();
let data = generate_mock_data(start_date, 365);
let config = WalkForwardConfig {
train_window_days: 90,
validation_window_days: 30,
embargo_days: 5,
step_days: 30,
};
// WHEN: We perform walk-forward validation splits
let folds = walk_forward_split(&data, &config);
// THEN: No training data can have timestamps >= validation start (including embargo)
for (fold_idx, fold) in folds.iter().enumerate() {
for &train_idx in &fold.train_indices {
let train_timestamp = data[train_idx].timestamp;
// Training data must be strictly before validation period
assert!(
train_timestamp < fold.val_start,
"LOOK-AHEAD BIAS DETECTED in fold {}: Training data timestamp ({:?}) \
is not before validation start ({:?})",
fold_idx,
train_timestamp,
fold.val_start
);
// Training data must also be before embargo period
assert!(
train_timestamp < fold.embargo_start,
"LOOK-AHEAD BIAS DETECTED in fold {}: Training data timestamp ({:?}) \
overlaps with embargo period (starts {:?})",
fold_idx,
train_timestamp,
fold.embargo_start
);
// Verify no training index >= any validation index
for &val_idx in &fold.val_indices {
assert!(
train_idx < val_idx,
"CRITICAL: Training index ({}) must be < validation index ({}) in fold {}",
train_idx,
val_idx,
fold_idx
);
}
}
// Verify validation data is strictly after all training data
if let (Some(&last_train_idx), Some(&first_val_idx)) = (
fold.train_indices.last(),
fold.val_indices.first()
) {
let last_train_timestamp = data[last_train_idx].timestamp;
let first_val_timestamp = data[first_val_idx].timestamp;
let gap = first_val_timestamp.signed_duration_since(last_train_timestamp);
assert!(
gap >= Duration::days(config.embargo_days),
"Gap between last training point and first validation point must be >= embargo period. \
Found: {} days, Expected: >= {} days",
gap.num_days(),
config.embargo_days
);
}
}
}
#[test]
fn test_sliding_window_progression() {
// GIVEN: A dataset with daily data
let start_date = Utc::now();
let data = generate_mock_data(start_date, 365);
let config = WalkForwardConfig {
train_window_days: 90,
validation_window_days: 30,
embargo_days: 5,
step_days: 30, // Slide forward by 30 days each fold
};
// WHEN: We perform walk-forward validation splits
let folds = walk_forward_split(&data, &config);
// THEN: Each fold should progress by step_days
for i in 1..folds.len() {
let prev_fold = &folds[i - 1];
let curr_fold = &folds[i];
// Validation periods should progress by step_days
let val_progression = curr_fold
.val_start
.signed_duration_since(prev_fold.val_start);
assert_eq!(
val_progression,
Duration::days(config.step_days),
"Validation period should advance by {} days between folds. \
Fold {} to {}: {} days",
config.step_days,
i - 1,
i,
val_progression.num_days()
);
}
}
#[test]
fn test_consistent_window_sizes() {
// GIVEN: A dataset with sufficient data
let start_date = Utc::now();
let data = generate_mock_data(start_date, 365);
let config = WalkForwardConfig {
train_window_days: 90,
validation_window_days: 30,
embargo_days: 5,
step_days: 30,
};
// WHEN: We perform walk-forward validation splits
let folds = walk_forward_split(&data, &config);
// THEN: Each fold should have consistent window sizes
for (fold_idx, fold) in folds.iter().enumerate() {
// Training window duration
let train_duration = fold.train_end.signed_duration_since(fold.train_start);
assert_eq!(
train_duration,
Duration::days(config.train_window_days),
"Fold {} training window should be {} days, found {} days",
fold_idx,
config.train_window_days,
train_duration.num_days()
);
// Validation window duration
let val_duration = fold.val_end.signed_duration_since(fold.val_start);
assert_eq!(
val_duration,
Duration::days(config.validation_window_days),
"Fold {} validation window should be {} days, found {} days",
fold_idx,
config.validation_window_days,
val_duration.num_days()
);
// Embargo period duration
let embargo_duration = fold.embargo_end.signed_duration_since(fold.embargo_start);
assert_eq!(
embargo_duration,
Duration::days(config.embargo_days),
"Fold {} embargo period should be {} days, found {} days",
fold_idx,
config.embargo_days,
embargo_duration.num_days()
);
}
}
#[test]
fn test_data_point_assignment_is_exhaustive() {
// GIVEN: A dataset
let start_date = Utc::now();
let data = generate_mock_data(start_date, 200);
let config = WalkForwardConfig {
train_window_days: 60,
validation_window_days: 20,
embargo_days: 3,
step_days: 20,
};
// WHEN: We perform walk-forward validation splits
let folds = walk_forward_split(&data, &config);
// THEN: Every fold should have data points assigned
for (fold_idx, fold) in folds.iter().enumerate() {
assert!(
!fold.train_indices.is_empty(),
"Fold {} must have training data",
fold_idx
);
assert!(
!fold.val_indices.is_empty(),
"Fold {} must have validation data",
fold_idx
);
// Verify indices are within bounds
for &idx in &fold.train_indices {
assert!(idx < data.len(), "Training index out of bounds");
}
for &idx in &fold.val_indices {
assert!(idx < data.len(), "Validation index out of bounds");
}
}
}
#[test]
fn test_edge_case_insufficient_data() {
// GIVEN: A dataset too small for the configuration
let start_date = Utc::now();
let data = generate_mock_data(start_date, 30); // Only 30 days
let config = WalkForwardConfig {
train_window_days: 90,
validation_window_days: 30,
embargo_days: 5,
step_days: 30,
};
// WHEN: We attempt walk-forward validation
let folds = walk_forward_split(&data, &config);
// THEN: Should return empty or handle gracefully
// (Implementation should validate data sufficiency)
assert!(
folds.is_empty(),
"Should return no folds when data is insufficient for window sizes"
);
}
}