test(ml): add walk-forward validation assertion to smoke test (7/7)

Assertion 7 runs DqnStrategy through ValidationHarness with real
6E.FUT data to verify the full train->validate pipeline works
end-to-end. Produces 15 folds with finite Sharpe ratio.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-20 20:22:15 +01:00
parent 4cff7a56a7
commit fbc7c6d6bb

View File

@@ -1,7 +1,7 @@
//! DQN Training Smoke Test
//!
//! Verifies the complete train -> checkpoint -> validate pipeline
//! works on real 6E.FUT data with 6 assertions:
//! works on real 6E.FUT data with 7 assertions:
//!
//! 1. All 20 epochs complete (no premature early stopping)
//! 2. Loss decreases >5% (gradient flow works)
@@ -9,6 +9,7 @@
//! 4. Q-value divergence (model develops action preferences)
//! 5. Checkpoint round-trip (save/load weight integrity)
//! 6. Epsilon decayed below 0.5 (exploration schedule ran)
//! 7. Walk-forward validation produces finite Sharpe (pipeline works end-to-end)
#![allow(unused_crate_dependencies)]
@@ -165,8 +166,119 @@ async fn test_dqn_training_smoke() -> Result<()> {
" Training time: {:.1}s",
metrics.training_time_seconds
);
// === ASSERT 7: Walk-forward Sharpe validates training produces value ===
// Uses DqnStrategy (15-dim, 3-action) with the validation harness to prove
// the walk-forward pipeline works. Since DQNTrainer uses 51-dim/45-action
// architecture, we test the validation pipeline independently with a simpler
// DQN that trains inside the harness vs. random baseline.
use ml::dqn::DQNConfig;
use ml::real_data_loader::RealDataLoader;
use ml::validation::{
DqnStrategy, TimeSeriesData, ValidationHarness, ValidationHarnessConfig,
WalkForwardConfig,
};
let mut loader = RealDataLoader::new_from_workspace()?;
let bars = loader.load_symbol_data("6E.FUT").await?;
// Build 15-dim features
let feat_matrix = loader.extract_features(&bars)?;
let indicators = loader.calculate_indicators(&bars)?;
let n = bars.len();
let mut features = Vec::with_capacity(n);
for i in 0..n {
let mut row = Vec::with_capacity(15);
if let Some(price_row) = feat_matrix.prices.get(i) {
row.extend_from_slice(price_row);
} else {
row.extend_from_slice(&[0.0_f32; 5]);
}
let close = bars.get(i).map(|b| b.close as f32).unwrap_or(1.0);
let denom = if close.abs() > 1e-10 { close } else { 1.0 };
row.push(indicators.rsi.get(i).copied().unwrap_or(50.0) / 100.0);
row.push(indicators.ema_fast.get(i).copied().unwrap_or(0.0) / denom);
row.push(indicators.ema_slow.get(i).copied().unwrap_or(0.0) / denom);
let macd_line = indicators.macd.get(i).copied().unwrap_or(0.0);
let macd_signal = indicators.macd_signal.get(i).copied().unwrap_or(0.0);
row.push(macd_line);
row.push(macd_signal);
row.push(macd_line - macd_signal);
row.push(indicators.bb_upper.get(i).copied().unwrap_or(0.0) / denom);
row.push(indicators.bb_middle.get(i).copied().unwrap_or(0.0) / denom);
row.push(indicators.bb_lower.get(i).copied().unwrap_or(0.0) / denom);
row.push(indicators.atr.get(i).copied().unwrap_or(0.0) / denom);
features.push(row);
}
let timestamps: Vec<chrono::DateTime<chrono::Utc>> =
bars.iter().map(|b| b.timestamp).collect();
let prices: Vec<f64> = bars.iter().map(|b| b.close).collect();
let ts_data = TimeSeriesData::new(timestamps, features, prices)?;
// Configure walk-forward harness
let num_bars = ts_data.len();
let train_bars = (num_bars / 5).max(200);
let test_bars = (num_bars / 20).max(50);
let harness_config = ValidationHarnessConfig {
wf_config: WalkForwardConfig {
train_bars,
test_bars,
embargo_bars: 20,
step_bars: test_bars,
min_train_samples: 100,
},
num_permutations: 100,
num_trials: 1,
seed: 42,
};
let harness = ValidationHarness::new(harness_config);
let mut dqn_config = DQNConfig::default();
dqn_config.state_dim = 15;
dqn_config.num_actions = 3;
dqn_config.hidden_dims = vec![64, 32];
dqn_config.batch_size = 16;
dqn_config.min_replay_size = 16;
dqn_config.warmup_steps = 0;
dqn_config.use_noisy_nets = false;
dqn_config.use_iqn = false;
dqn_config.use_distributional = false;
dqn_config.use_dueling = true;
dqn_config.use_per = false;
dqn_config.epsilon_start = 0.3;
dqn_config.epsilon_end = 0.01;
let mut strategy = DqnStrategy::new(dqn_config)?;
let report = harness.validate(&mut strategy, &ts_data)?;
// The validation harness trains the DQN during walk-forward folds.
// A Sharpe ratio that is finite and produces at least 2 folds proves
// the entire train->validate pipeline works end-to-end.
assert!(
report.aggregate_sharpe.is_finite(),
"ASSERT 7 FAILED: Aggregate Sharpe is not finite: {}",
report.aggregate_sharpe
);
assert!(
report.num_folds >= 2,
"ASSERT 7 FAILED: Walk-forward produced fewer than 2 folds (got {})",
report.num_folds
);
let trained_sharpe = report.aggregate_sharpe;
println!("{}", "=".repeat(70));
println!(" ASSERTIONS 1-6: ALL PASSED");
println!("{}", "-".repeat(70));
println!(" ASSERT 7: Walk-Forward Validation");
println!(" Folds: {}", report.num_folds);
println!(" Aggregate Sharpe: {:.4}", trained_sharpe);
println!(" DSR p-value: {:.4}", report.dsr.pvalue);
println!(" PBO: {:.4}", report.pbo.pbo);
println!(" Verdict: {}", report.verdict);
println!("{}", "=".repeat(70));
println!(" ALL 7 ASSERTIONS PASSED");
println!("{}", "=".repeat(70));
Ok(())