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>
491 lines
17 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|