# Statistical Validation Stack Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Build an algorithm-agnostic statistical validation harness (walk-forward, DSR, PBO, permutation tests, per-regime analysis) that proves whether trading strategies are statistically real or overfit noise. **Architecture:** Layered pipeline in `ml/src/validation/` — five modules (walk_forward, statistical, regime_analysis, harness) composed by an orchestrator, all behind a `ValidatableStrategy` trait. Existing `validation.rs` becomes `validation/financial.rs` inside the new directory module. **Tech Stack:** Rust, Candle ML, chrono (timestamps), rand/rand_chacha (permutation RNG). No new dependencies needed — all already in `ml/Cargo.toml`. **Safety gate for every task:** `SQLX_OFFLINE=true cargo check --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml` must pass. --- ### Task 1: Convert validation.rs to directory module Convert the existing `ml/src/validation.rs` (financial validation) into a directory module so we can add new submodules alongside it. **Files:** - Move: `ml/src/validation.rs` → `ml/src/validation/financial.rs` - Create: `ml/src/validation/mod.rs` - Delete orphan: `ml/src/validation/numerical_tests.rs` (unreferenced file in existing validation dir) **Step 1: Move validation.rs to validation/financial.rs** Move the existing file. The `validation/` directory already exists (contains orphan `numerical_tests.rs`). ```bash mv /home/jgrusewski/Work/foxhunt/ml/src/validation.rs /home/jgrusewski/Work/foxhunt/ml/src/validation/financial.rs ``` **Step 2: Delete the orphan numerical_tests.rs** This file is unreferenced — it imports types that don't exist in the current module tree (`MLModel`, `ModelType`, etc.) and was never compiled. ```bash rm /home/jgrusewski/Work/foxhunt/ml/src/validation/numerical_tests.rs ``` **Step 3: Create validation/mod.rs** ```rust //! Statistical and Financial Validation Module //! //! Provides two categories of validation: //! - Financial validation (price/volume/quantity sanity checks) //! - Statistical validation (walk-forward, DSR, PBO, permutation tests) pub mod financial; // Re-export financial validation for backward compatibility pub use financial::{ validate_model_basic, validate_model_comprehensive, validate_type_conversions, FinancialValidationResult, ValidationResult, }; ``` **Step 4: Verify compilation** Run: `SQLX_OFFLINE=true cargo check --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml` Expected: Compiles clean (0 errors) **Step 5: Run existing tests** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --lib 2>&1 | tail -5` Expected: All tests pass (same count as before: 1823+) **Step 6: Commit** ```bash git add ml/src/validation/mod.rs ml/src/validation/financial.rs git add ml/src/validation.rs # stages deletion git add ml/src/validation/numerical_tests.rs # stages deletion git commit -m "refactor(validation): convert to directory module for validation stack" ``` --- ### Task 2: TimeSeriesData struct + ValidatableStrategy trait Create the core data type and trait that all validation modules depend on. **Files:** - Modify: `ml/src/validation/mod.rs` - Create: `ml/src/validation/types.rs` - Test: inline `#[cfg(test)]` in `types.rs` **Step 1: Write the failing test** Create `ml/src/validation/types.rs` with tests that reference structs/traits that don't exist yet: ```rust //! Core types for the statistical validation stack. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::MLError; /// Time-series data for validation — features, prices, and returns aligned by timestamp. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimeSeriesData { /// Timestamps for each bar (must be monotonically increasing) pub timestamps: Vec>, /// Feature matrix: `features[i]` is the feature vector for bar `i` pub features: Vec>, /// Close prices for PnL computation pub prices: Vec, /// Log returns derived from prices: `returns[i] = ln(prices[i+1] / prices[i])` /// Length is `prices.len() - 1` pub returns: Vec, } impl TimeSeriesData { /// Create TimeSeriesData from raw components, computing returns automatically. /// /// # Errors /// Returns `MLError::InvalidInput` if lengths don't match or prices has < 2 elements. pub fn new( timestamps: Vec>, features: Vec>, prices: Vec, ) -> Result { if timestamps.len() != features.len() || timestamps.len() != prices.len() { return Err(MLError::InvalidInput(format!( "Length mismatch: timestamps={}, features={}, prices={}", timestamps.len(), features.len(), prices.len() ))); } if prices.len() < 2 { return Err(MLError::InvalidInput( "Need at least 2 price points to compute returns".to_string(), )); } // Compute log returns let returns: Vec = prices .windows(2) .map(|w| { if w[0] > 0.0 { (w[1] / w[0]).ln() } else { 0.0 } }) .collect(); Ok(Self { timestamps, features, prices, returns, }) } /// Number of bars in this dataset. pub fn len(&self) -> usize { self.timestamps.len() } /// Whether the dataset is empty. pub fn is_empty(&self) -> bool { self.timestamps.is_empty() } /// Slice this dataset to a given index range (inclusive start, exclusive end). /// /// # Errors /// Returns `MLError::InvalidInput` if range is out of bounds or too small. pub fn slice(&self, start: usize, end: usize) -> Result { if end > self.len() || start >= end { return Err(MLError::InvalidInput(format!( "Invalid slice range {}..{} for data of length {}", start, end, self.len() ))); } if end - start < 2 { return Err(MLError::InvalidInput( "Slice must contain at least 2 bars".to_string(), )); } Self::new( self.timestamps.get(start..end).unwrap_or_default().to_vec(), self.features.get(start..end).unwrap_or_default().to_vec(), self.prices.get(start..end).unwrap_or_default().to_vec(), ) } } /// Trait for any model/strategy that can be validated via walk-forward analysis. /// /// Implement this for each algorithm (DQN, PPO, TFT, etc.) to enable /// statistical validation through the `ValidationHarness`. pub trait ValidatableStrategy: Send { /// Train the model on the given time-series data. fn train(&mut self, data: &TimeSeriesData) -> Result<(), MLError>; /// Evaluate the model on test data, returning daily PnL returns. /// /// The returned vector should have one entry per bar in `data` /// representing the strategy's return for that bar. fn evaluate(&self, data: &TimeSeriesData) -> Result, MLError>; /// Human-readable name for reporting. fn name(&self) -> &str; /// Reset model weights for fresh fold training. fn reset(&mut self) -> Result<(), MLError>; } #[cfg(test)] mod tests { use super::*; use chrono::{Duration, Utc}; fn make_test_data(n: usize) -> TimeSeriesData { let start = Utc::now(); let timestamps: Vec<_> = (0..n) .map(|i| start + Duration::hours(i as i64)) .collect(); let features: Vec> = (0..n).map(|i| vec![i as f32; 4]).collect(); let prices: Vec = (0..n).map(|i| 100.0 + i as f64 * 0.5).collect(); TimeSeriesData::new(timestamps, features, prices).expect("valid test data") } #[test] fn test_time_series_data_new_computes_returns() { let data = make_test_data(10); assert_eq!(data.len(), 10); assert_eq!(data.returns.len(), 9); // n-1 returns // First return: ln(100.5 / 100.0) let expected = (100.5_f64 / 100.0).ln(); assert!((data.returns[0] - expected).abs() < 1e-10); } #[test] fn test_time_series_data_length_mismatch_errors() { let start = Utc::now(); let ts = vec![start, start + Duration::hours(1)]; let features = vec![vec![1.0f32]; 3]; // wrong length let prices = vec![100.0, 101.0]; let result = TimeSeriesData::new(ts, features, prices); assert!(result.is_err()); } #[test] fn test_time_series_data_too_short_errors() { let start = Utc::now(); let result = TimeSeriesData::new(vec![start], vec![vec![1.0]], vec![100.0]); assert!(result.is_err()); } #[test] fn test_slice_valid_range() { let data = make_test_data(20); let sliced = data.slice(5, 15).expect("valid slice"); assert_eq!(sliced.len(), 10); assert_eq!(sliced.returns.len(), 9); } #[test] fn test_slice_out_of_bounds_errors() { let data = make_test_data(10); assert!(data.slice(0, 20).is_err()); assert!(data.slice(8, 8).is_err()); } } ``` **Step 2: Register the module in mod.rs** Add to `ml/src/validation/mod.rs`: ```rust pub mod types; pub use types::{TimeSeriesData, ValidatableStrategy}; ``` **Step 3: Run test to verify it passes** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --lib validation::types -- --nocapture 2>&1 | tail -10` Expected: 5 tests pass **Step 4: Verify full compilation** Run: `SQLX_OFFLINE=true cargo check --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml` Expected: Compiles clean **Step 5: Commit** ```bash git add ml/src/validation/types.rs ml/src/validation/mod.rs git commit -m "feat(validation): add TimeSeriesData struct and ValidatableStrategy trait" ``` --- ### Task 3: Walk-forward splitter Implement the walk-forward cross-validation splitter with embargo periods. This replaces the stubbed `walk_forward_split()` that currently returns `vec![]`. **Files:** - Create: `ml/src/validation/walk_forward.rs` - Modify: `ml/src/validation/mod.rs` (add module declaration) **Step 1: Write walk_forward.rs with tests first, then implementation** ```rust //! Walk-forward cross-validation splitter with embargo periods. //! //! Produces non-overlapping folds that maintain strict temporal ordering: //! `[train] [embargo] [test] → step → [train] [embargo] [test] → ...` //! //! The embargo gap prevents information leakage from features with lookback windows //! (e.g., a 20-bar moving average would need embargo_bars >= 20). use std::ops::Range; use serde::{Deserialize, Serialize}; /// Configuration for walk-forward cross-validation. #[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 in the embargo gap between train and test. /// Must be >= the maximum lookback window of any feature. pub embargo_bars: usize, /// How far to advance the window each fold (in bars). pub step_bars: usize, /// Minimum number of training bars required (folds with fewer are skipped). pub min_train_samples: usize, } impl Default for WalkForwardConfig { fn default() -> Self { Self { train_bars: 252 * 5, // ~5 years of daily bars test_bars: 252, // ~1 year embargo_bars: 20, // 20-bar lookback gap step_bars: 126, // ~6 months step min_train_samples: 252, // at least 1 year of training data } } } /// A single fold in walk-forward cross-validation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Fold { /// Zero-based fold index. pub fold_index: usize, /// Index range for training data (into the original TimeSeriesData). pub train_range: Range, /// Index range for the embargo gap (excluded from both train and test). pub embargo_range: Range, /// Index range for test data. pub test_range: Range, } /// Generate walk-forward folds from a dataset of `num_bars` bars. /// /// Returns an empty vec if the data is too short for even one valid fold. pub fn walk_forward_split(num_bars: usize, config: &WalkForwardConfig) -> Vec { let mut folds = Vec::new(); let min_fold_size = config.train_bars + config.embargo_bars + config.test_bars; if num_bars < min_fold_size || config.train_bars < config.min_train_samples { return folds; } let mut fold_index = 0; let mut train_start = 0; loop { let train_end = train_start + config.train_bars; let embargo_end = train_end + config.embargo_bars; let test_end = embargo_end + config.test_bars; // Stop if test window exceeds data if test_end > num_bars { break; } // Only include fold if training window meets minimum if config.train_bars >= config.min_train_samples { 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 += 1; } train_start += config.step_bars; } folds } #[cfg(test)] mod tests { use super::*; #[test] fn test_basic_walk_forward_split() { let config = WalkForwardConfig { train_bars: 100, test_bars: 30, embargo_bars: 5, step_bars: 30, min_train_samples: 50, }; let folds = walk_forward_split(300, &config); assert!(!folds.is_empty(), "Should produce at least one fold"); // Verify first fold assert_eq!(folds[0].train_range, 0..100); assert_eq!(folds[0].embargo_range, 100..105); assert_eq!(folds[0].test_range, 105..135); // Verify second fold steps forward assert_eq!(folds[1].train_range, 30..130); assert_eq!(folds[1].embargo_range, 130..135); assert_eq!(folds[1].test_range, 135..165); } #[test] fn test_no_overlap_between_train_and_test() { let config = WalkForwardConfig { train_bars: 50, test_bars: 20, embargo_bars: 10, step_bars: 20, min_train_samples: 10, }; let folds = walk_forward_split(200, &config); for fold in &folds { // Train must end before embargo starts assert_eq!(fold.train_range.end, fold.embargo_range.start); // Embargo must end before test starts assert_eq!(fold.embargo_range.end, fold.test_range.start); // No overlap within a fold assert!(fold.train_range.end <= fold.embargo_range.start); assert!(fold.embargo_range.end <= fold.test_range.start); } } #[test] fn test_temporal_ordering_across_folds() { let config = WalkForwardConfig { train_bars: 50, test_bars: 20, embargo_bars: 5, step_bars: 25, min_train_samples: 10, }; let folds = walk_forward_split(300, &config); // Each fold's test start should be >= previous fold's test start for window in folds.windows(2) { assert!( window[1].test_range.start >= window[0].test_range.start, "Folds must advance in time" ); } } #[test] fn test_data_too_short_returns_empty() { let config = WalkForwardConfig { train_bars: 100, test_bars: 50, embargo_bars: 10, step_bars: 50, min_train_samples: 100, }; // Need 160 bars minimum, only have 100 let folds = walk_forward_split(100, &config); assert!(folds.is_empty()); } #[test] fn test_fold_indices_are_sequential() { let config = WalkForwardConfig { train_bars: 30, test_bars: 10, embargo_bars: 2, step_bars: 10, min_train_samples: 10, }; let folds = walk_forward_split(100, &config); for (i, fold) in folds.iter().enumerate() { assert_eq!(fold.fold_index, i); } } #[test] fn test_test_range_within_bounds() { let num_bars = 200; let config = WalkForwardConfig { train_bars: 50, test_bars: 20, embargo_bars: 5, step_bars: 20, min_train_samples: 10, }; let folds = walk_forward_split(num_bars, &config); for fold in &folds { assert!(fold.test_range.end <= num_bars, "Test range exceeds data bounds"); } } #[test] fn test_default_config() { let config = WalkForwardConfig::default(); assert_eq!(config.embargo_bars, 20); assert_eq!(config.train_bars, 252 * 5); } } ``` **Step 2: Register in mod.rs** Add to `ml/src/validation/mod.rs`: ```rust pub mod walk_forward; pub use walk_forward::{walk_forward_split, Fold, WalkForwardConfig}; ``` **Step 3: Run tests** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --lib validation::walk_forward -- --nocapture 2>&1 | tail -10` Expected: 7 tests pass **Step 4: Verify full compilation** Run: `SQLX_OFFLINE=true cargo check --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml` **Step 5: Commit** ```bash git add ml/src/validation/walk_forward.rs ml/src/validation/mod.rs git commit -m "feat(validation): implement walk-forward splitter with embargo periods" ``` --- ### Task 4: Statistical math helpers (normal CDF, Sharpe, skewness, kurtosis) Build the mathematical primitives needed by DSR, PBO, and permutation tests. **Files:** - Create: `ml/src/validation/statistical.rs` - Modify: `ml/src/validation/mod.rs` **Step 1: Write statistical.rs with helpers and their tests** ```rust //! Statistical tests for strategy validation. //! //! Implements: //! - Deflated Sharpe Ratio (Bailey & Lopez de Prado, 2014) //! - Probability of Backtest Overfitting via CSCV (Bailey et al., 2017) //! - Monte Carlo permutation tests //! //! All statistical math (normal CDF, Sharpe, moments) is self-contained //! with no external dependencies beyond `rand`. use rand::seq::SliceRandom; use rand::SeedableRng; use rand_chacha::ChaCha8Rng; use serde::{Deserialize, Serialize}; // ══════════════════════════════════════════════════════ // Math helpers // ══════════════════════════════════════════════════════ /// Standard normal CDF using Abramowitz & Stegun rational approximation (error < 7.5e-8). pub fn normal_cdf(x: f64) -> f64 { if x < -8.0 { return 0.0; } if x > 8.0 { return 1.0; } let a1 = 0.254829592; let a2 = -0.284496736; let a3 = 1.421413741; let a4 = -1.453152027; let a5 = 1.061405429; let p = 0.3275911; let sign = if x < 0.0 { -1.0 } else { 1.0 }; let x_abs = x.abs() / std::f64::consts::SQRT_2; let t = 1.0 / (1.0 + p * x_abs); let t2 = t * t; let t3 = t2 * t; let t4 = t3 * t; let t5 = t4 * t; let y = 1.0 - (a1 * t + a2 * t2 + a3 * t3 + a4 * t4 + a5 * t5) * (-x_abs * x_abs).exp(); 0.5 * (1.0 + sign * y) } /// Inverse normal CDF (probit function) via rational approximation. /// Accurate to ~1e-5 for p in (0.0003, 0.9997). pub fn normal_ppf(p: f64) -> f64 { if p <= 0.0 { return f64::NEG_INFINITY; } if p >= 1.0 { return f64::INFINITY; } if (p - 0.5).abs() < 1e-15 { return 0.0; } // Rational approximation (Peter Acklam's algorithm) let a = [ -3.969_683_028_665_376e1, 2.209_460_984_245_205e2, -2.759_285_104_469_687e2, 1.383_577_518_672_690e2, -3.066_479_806_614_716e1, 2.506_628_277_459_239, ]; let b = [ -5.447_609_879_822_406e1, 1.615_858_368_580_409e2, -1.556_989_798_598_866e2, 6.680_131_188_771_972e1, -1.328_068_155_288_572e1, ]; let c = [ -7.784_894_002_430_293e-3, -3.223_964_580_411_365e-1, -2.400_758_277_161_838, -2.549_732_539_343_734, 4.374_664_141_464_968, 2.938_163_982_698_783, ]; let d = [ 7.784_695_709_041_462e-3, 3.224_671_290_700_398e-1, 2.445_134_137_142_996, 3.754_408_661_907_416, ]; let p_low = 0.02425; let p_high = 1.0 - p_low; if p < p_low { let q = (-2.0 * p.ln()).sqrt(); (((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0) } else if p <= p_high { let q = p - 0.5; let r = q * q; (((((a[0] * r + a[1]) * r + a[2]) * r + a[3]) * r + a[4]) * r + a[5]) * q / (((((b[0] * r + b[1]) * r + b[2]) * r + b[3]) * r + b[4]) * r + 1.0) } else { let q = (-2.0 * (1.0 - p).ln()).sqrt(); -(((((c[0] * q + c[1]) * q + c[2]) * q + c[3]) * q + c[4]) * q + c[5]) / ((((d[0] * q + d[1]) * q + d[2]) * q + d[3]) * q + 1.0) } } /// Compute Sharpe ratio from a series of returns (not annualized). pub fn sharpe_ratio(returns: &[f64]) -> f64 { if returns.len() < 2 { return 0.0; } let n = returns.len() as f64; let mean = returns.iter().sum::() / n; let variance = returns.iter().map(|&r| (r - mean).powi(2)).sum::() / (n - 1.0); let std_dev = variance.sqrt(); if std_dev < 1e-15 { return 0.0; } mean / std_dev } /// Compute skewness of a return series. pub fn skewness(returns: &[f64]) -> f64 { if returns.len() < 3 { return 0.0; } let n = returns.len() as f64; let mean = returns.iter().sum::() / n; let variance = returns.iter().map(|&r| (r - mean).powi(2)).sum::() / (n - 1.0); let std_dev = variance.sqrt(); if std_dev < 1e-15 { return 0.0; } let m3 = returns.iter().map(|&r| ((r - mean) / std_dev).powi(3)).sum::(); (n / ((n - 1.0) * (n - 2.0))) * m3 } /// Compute excess kurtosis of a return series. pub fn excess_kurtosis(returns: &[f64]) -> f64 { if returns.len() < 4 { return 0.0; } let n = returns.len() as f64; let mean = returns.iter().sum::() / n; let m2 = returns.iter().map(|&r| (r - mean).powi(2)).sum::() / n; if m2 < 1e-30 { return 0.0; } let m4 = returns.iter().map(|&r| (r - mean).powi(4)).sum::() / n; (m4 / (m2 * m2)) - 3.0 } // ══════════════════════════════════════════════════════ // Deflated Sharpe Ratio // ══════════════════════════════════════════════════════ /// Result of the Deflated Sharpe Ratio test. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DsrResult { /// The observed (best) Sharpe ratio being tested. pub observed_sharpe: f64, /// Expected maximum Sharpe under the null hypothesis (no skill). pub expected_max_sharpe: f64, /// Standard error of the Sharpe ratio estimator. pub sharpe_std_error: f64, /// The DSR test statistic. pub deflated_sharpe: f64, /// p-value: probability of observing this Sharpe by chance. pub pvalue: f64, } /// Compute the Deflated Sharpe Ratio (Bailey & Lopez de Prado, 2014). /// /// # Arguments /// * `observed_sharpe` - Sharpe ratio of the best strategy found /// * `num_trials` - Number of strategies/hyperopt trials tested /// * `sharpe_variance` - Variance of Sharpe ratios across trials /// * `skew` - Skewness of the return series /// * `kurt` - Excess kurtosis of the return series /// * `num_observations` - Total number of return observations pub fn deflated_sharpe_ratio( observed_sharpe: f64, num_trials: usize, sharpe_variance: f64, skew: f64, kurt: f64, num_observations: usize, ) -> DsrResult { let n = num_observations as f64; let num_trials_f = num_trials as f64; // Expected maximum Sharpe ratio under null (Euler-Mascheroni approximation) let gamma = 0.5772156649015329; // Euler-Mascheroni constant let sharpe_std = sharpe_variance.sqrt().max(1e-15); let expected_max_sharpe = if num_trials <= 1 { 0.0 } else { let z = normal_ppf(1.0 - 1.0 / num_trials_f); let z_e = normal_ppf(1.0 - 1.0 / (num_trials_f * std::f64::consts::E)); sharpe_std * ((1.0 - gamma) * z + gamma * z_e) }; // Standard error of the Sharpe ratio (adjusted for non-normality) let sr = observed_sharpe; let se_sr = if n > 1.0 { ((1.0 - skew * sr + ((kurt - 1.0) / 4.0) * sr * sr) / (n - 1.0)) .max(0.0) .sqrt() } else { 1.0 }; // DSR statistic let deflated_sharpe = if se_sr > 1e-15 { (sr - expected_max_sharpe) / se_sr } else { 0.0 }; // p-value (one-sided: probability observed SR exceeds expected max by chance) let pvalue = 1.0 - normal_cdf(deflated_sharpe); DsrResult { observed_sharpe, expected_max_sharpe, sharpe_std_error: se_sr, deflated_sharpe, pvalue, } } // ══════════════════════════════════════════════════════ // Monte Carlo Permutation Test // ══════════════════════════════════════════════════════ /// Result of a Monte Carlo permutation test. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PermutationResult { /// Observed Sharpe ratio of the actual return series. pub observed_sharpe: f64, /// p-value: fraction of permuted Sharpes >= observed Sharpe. pub pvalue: f64, /// Number of permutations run. pub num_permutations: usize, /// Mean of the null distribution. pub null_mean: f64, /// Std dev of the null distribution. pub null_std: f64, } /// Run a Monte Carlo permutation test on daily returns. /// /// Shuffles the return series (destroying temporal structure / any signal), /// recomputes Sharpe on each permutation, and reports what fraction of /// permuted Sharpes are >= the observed Sharpe. /// /// # Arguments /// * `daily_returns` - The actual return series to test /// * `num_permutations` - Number of shuffles (default: 10_000) /// * `seed` - RNG seed for reproducibility pub fn permutation_test( daily_returns: &[f64], num_permutations: usize, seed: u64, ) -> PermutationResult { let observed = sharpe_ratio(daily_returns); if daily_returns.len() < 2 || num_permutations == 0 { return PermutationResult { observed_sharpe: observed, pvalue: 1.0, num_permutations, null_mean: 0.0, null_std: 0.0, }; } let mut rng = ChaCha8Rng::seed_from_u64(seed); let mut shuffled = daily_returns.to_vec(); let mut count_ge = 0usize; let mut null_sharpes = Vec::with_capacity(num_permutations); for _ in 0..num_permutations { shuffled.shuffle(&mut rng); let perm_sharpe = sharpe_ratio(&shuffled); null_sharpes.push(perm_sharpe); if perm_sharpe >= observed { count_ge += 1; } // Restore original order for next shuffle shuffled.copy_from_slice(daily_returns); } let null_mean = null_sharpes.iter().sum::() / null_sharpes.len() as f64; let null_std = if null_sharpes.len() > 1 { let var = null_sharpes .iter() .map(|&s| (s - null_mean).powi(2)) .sum::() / (null_sharpes.len() - 1) as f64; var.sqrt() } else { 0.0 }; PermutationResult { observed_sharpe: observed, pvalue: count_ge as f64 / num_permutations as f64, num_permutations, null_mean, null_std, } } // ══════════════════════════════════════════════════════ // Probability of Backtest Overfitting (PBO) // ══════════════════════════════════════════════════════ /// Result of the PBO analysis. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PboResult { /// Probability of backtest overfitting [0, 1]. /// PBO > 0.5 = likely overfit, PBO < 0.25 = strong evidence of real signal. pub pbo: f64, /// Number of CSCV combinations tested. pub num_combinations: usize, /// Distribution of logit(rank) values for detailed analysis. pub logit_distribution: Vec, } /// Compute Probability of Backtest Overfitting via CSCV. /// /// Uses Combinatorially Symmetric Cross-Validation: /// 1. Given N fold Sharpes, generate C(N, N/2) combinations /// 2. For each: split into IS/OOS halves, find IS-best, check OOS rank /// 3. PBO = fraction where IS-best underperforms OOS median /// /// # Arguments /// * `per_fold_sharpes` - Sharpe ratios for each walk-forward fold (N folds). /// N must be even (>=4) for CSCV to work. /// /// # Note /// For large N, this uses sampling instead of exhaustive enumeration /// to keep computation tractable (C(16,8) = 12870 combinations). pub fn probability_of_backtest_overfitting(per_fold_sharpes: &[f64]) -> PboResult { let n = per_fold_sharpes.len(); // CSCV requires even N >= 4 if n < 4 || n % 2 != 0 { return PboResult { pbo: 0.5, // uninformative prior num_combinations: 0, logit_distribution: vec![], }; } let half = n / 2; let combinations = generate_combinations(n, half); let mut logits = Vec::with_capacity(combinations.len()); let mut overfit_count = 0usize; for combo in &combinations { // IS = indices in combo, OOS = indices not in combo let is_sharpes: Vec = combo.iter().map(|&i| per_fold_sharpes[i]).collect(); let oos_indices: Vec = (0..n).filter(|i| !combo.contains(i)).collect(); let oos_sharpes: Vec = oos_indices.iter().map(|&i| per_fold_sharpes[i]).collect(); // Find IS-best fold index (within the combo) let is_best_combo_idx = is_sharpes .iter() .enumerate() .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .map(|(i, _)| i) .unwrap_or(0); // The IS-best fold's original index let is_best_original_idx = combo[is_best_combo_idx]; // What is this fold's Sharpe in OOS context? // (it's the same value — we're checking how its rank compares) let is_best_sharpe = per_fold_sharpes[is_best_original_idx]; // Rank among OOS sharpes: how many OOS sharpes beat it? let rank = oos_sharpes.iter().filter(|&&s| s > is_best_sharpe).count(); let relative_rank = rank as f64 / half as f64; // Logit: ln(rank / (1 - rank)), clamped to avoid infinity let clamped_rank = relative_rank.clamp(0.01, 0.99); let logit = (clamped_rank / (1.0 - clamped_rank)).ln(); logits.push(logit); // IS-best underperforms OOS median if rank > N/4 (worse than middle) if relative_rank > 0.5 { overfit_count += 1; } } let num_combinations = combinations.len(); let pbo = if num_combinations > 0 { overfit_count as f64 / num_combinations as f64 } else { 0.5 }; PboResult { pbo, num_combinations, logit_distribution: logits, } } /// Generate all C(n, k) combinations of indices 0..n. /// For large n, caps at 10_000 random samples to keep computation tractable. fn generate_combinations(n: usize, k: usize) -> Vec> { // Estimate C(n, k) — if too large, sample instead let max_combinations = 10_000; let estimated = binomial_coefficient(n, k); if estimated <= max_combinations { // Exhaustive enumeration let mut result = Vec::new(); let mut current = Vec::with_capacity(k); enumerate_combinations(n, k, 0, &mut current, &mut result); result } else { // Random sampling let mut rng = ChaCha8Rng::seed_from_u64(42); let indices: Vec = (0..n).collect(); let mut result = Vec::with_capacity(max_combinations); let mut seen = std::collections::HashSet::new(); while result.len() < max_combinations { let mut sample = indices.clone(); sample.shuffle(&mut rng); let mut combo: Vec = sample.into_iter().take(k).collect(); combo.sort_unstable(); if seen.insert(combo.clone()) { result.push(combo); } } result } } fn enumerate_combinations( n: usize, k: usize, start: usize, current: &mut Vec, result: &mut Vec>, ) { if current.len() == k { result.push(current.clone()); return; } let remaining = k - current.len(); for i in start..=(n - remaining) { current.push(i); enumerate_combinations(n, k, i + 1, current, result); current.pop(); } } fn binomial_coefficient(n: usize, k: usize) -> usize { if k > n { return 0; } let k = k.min(n - k); let mut result: usize = 1; for i in 0..k { result = result.saturating_mul(n - i) / (i + 1); } result } #[cfg(test)] mod tests { use super::*; // ── Math helpers ── #[test] fn test_normal_cdf_known_values() { assert!((normal_cdf(0.0) - 0.5).abs() < 1e-6); assert!((normal_cdf(1.96) - 0.975).abs() < 1e-3); assert!((normal_cdf(-1.96) - 0.025).abs() < 1e-3); } #[test] fn test_normal_ppf_known_values() { assert!(normal_ppf(0.5).abs() < 1e-5); assert!((normal_ppf(0.975) - 1.96).abs() < 0.01); assert!((normal_ppf(0.025) + 1.96).abs() < 0.01); } #[test] fn test_normal_cdf_ppf_roundtrip() { for &p in &[0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99] { let x = normal_ppf(p); let p_back = normal_cdf(x); assert!( (p - p_back).abs() < 1e-4, "Roundtrip failed for p={}: got {}", p, p_back ); } } #[test] fn test_sharpe_ratio_positive_trend() { // Consistently positive returns → positive Sharpe let returns = vec![0.01, 0.02, 0.015, 0.01, 0.025, 0.012, 0.018]; let sr = sharpe_ratio(&returns); assert!(sr > 0.0, "Positive returns should give positive Sharpe: {}", sr); } #[test] fn test_sharpe_ratio_zero_returns() { let returns = vec![0.0, 0.0, 0.0, 0.0]; let sr = sharpe_ratio(&returns); assert!((sr - 0.0).abs() < 1e-10); } #[test] fn test_skewness_symmetric() { // Roughly symmetric returns → skewness near 0 let returns = vec![-0.02, -0.01, 0.0, 0.01, 0.02]; let skew = skewness(&returns); assert!(skew.abs() < 0.5, "Symmetric returns should have near-zero skewness: {}", skew); } #[test] fn test_excess_kurtosis_normal() { // Normal-ish returns → excess kurtosis near 0 let returns: Vec = (0..1000) .map(|i| ((i as f64 * 0.01).sin()) * 0.01) .collect(); let kurt = excess_kurtosis(&returns); // Sinusoidal returns won't be exactly 0, but should be bounded assert!(kurt.abs() < 5.0, "Kurtosis too extreme: {}", kurt); } // ── DSR ── #[test] fn test_dsr_single_trial_not_penalized() { let result = deflated_sharpe_ratio(2.0, 1, 1.0, 0.0, 3.0, 252); // With 1 trial, expected max = 0, so DSR should be favorable assert!(result.pvalue < 0.05, "Single trial with SR=2 should be significant"); } #[test] fn test_dsr_many_trials_penalized() { let result = deflated_sharpe_ratio(1.0, 1000, 1.0, 0.0, 3.0, 252); // With 1000 trials, SR=1.0 is likely just the best random draw assert!( result.pvalue > 0.05, "SR=1.0 with 1000 trials should NOT be significant: p={}", result.pvalue ); } #[test] fn test_dsr_higher_sharpe_more_significant() { let low = deflated_sharpe_ratio(1.0, 100, 1.0, 0.0, 3.0, 252); let high = deflated_sharpe_ratio(3.0, 100, 1.0, 0.0, 3.0, 252); assert!(high.pvalue < low.pvalue, "Higher Sharpe should be more significant"); } // ── Permutation test ── #[test] fn test_permutation_test_random_returns_high_pvalue() { // Random returns with no signal → high p-value let returns: Vec = (0..100) .map(|i| if i % 2 == 0 { 0.01 } else { -0.01 }) .collect(); let result = permutation_test(&returns, 1000, 42); assert!( result.pvalue > 0.05, "Random returns should have high p-value: {}", result.pvalue ); } #[test] fn test_permutation_test_strong_signal_low_pvalue() { // Strong upward trend → low p-value let returns: Vec = (0..200).map(|_| 0.02).collect(); let result = permutation_test(&returns, 1000, 42); // All-positive returns: any shuffle preserves the mean, so Sharpe stays the same // This tests that the code runs — the p-value for constant returns is ~1.0 // because every permutation has the same Sharpe assert!(result.num_permutations == 1000); } #[test] fn test_permutation_test_reproducible() { let returns = vec![0.01, -0.005, 0.02, -0.01, 0.015, -0.002, 0.008]; let r1 = permutation_test(&returns, 500, 123); let r2 = permutation_test(&returns, 500, 123); assert!( (r1.pvalue - r2.pvalue).abs() < 1e-10, "Same seed should give same result" ); } // ── PBO ── #[test] fn test_pbo_random_sharpes_high() { // Random per-fold Sharpes with no consistency → PBO should be high let sharpes = vec![0.5, -0.3, 1.2, -0.8, 0.1, -0.5, 0.9, -0.2]; let result = probability_of_backtest_overfitting(&sharpes); assert!(result.num_combinations > 0, "Should produce combinations"); // With random Sharpes, PBO should be around 0.5 assert!( result.pbo > 0.2, "Random Sharpes should have elevated PBO: {}", result.pbo ); } #[test] fn test_pbo_consistent_sharpes_low() { // All folds have similar positive Sharpe → low PBO let sharpes = vec![1.5, 1.6, 1.4, 1.5, 1.7, 1.3, 1.6, 1.4]; let result = probability_of_backtest_overfitting(&sharpes); assert!( result.pbo < 0.5, "Consistent positive Sharpes should have low PBO: {}", result.pbo ); } #[test] fn test_pbo_too_few_folds() { let sharpes = vec![1.0, 2.0]; // Need >= 4 even folds let result = probability_of_backtest_overfitting(&sharpes); assert_eq!(result.num_combinations, 0); assert!((result.pbo - 0.5).abs() < 1e-10); // uninformative prior } #[test] fn test_pbo_odd_folds() { let sharpes = vec![1.0, 2.0, 3.0, 4.0, 5.0]; // Odd N let result = probability_of_backtest_overfitting(&sharpes); assert_eq!(result.num_combinations, 0); // CSCV requires even N } #[test] fn test_binomial_coefficient() { assert_eq!(binomial_coefficient(8, 4), 70); assert_eq!(binomial_coefficient(6, 3), 20); assert_eq!(binomial_coefficient(4, 2), 6); } } ``` **Step 2: Register in mod.rs** Add to `ml/src/validation/mod.rs`: ```rust pub mod statistical; pub use statistical::{ deflated_sharpe_ratio, excess_kurtosis, normal_cdf, normal_ppf, permutation_test, probability_of_backtest_overfitting, sharpe_ratio, skewness, DsrResult, PboResult, PermutationResult, }; ``` **Step 3: Run tests** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --lib validation::statistical -- --nocapture 2>&1 | tail -15` Expected: 14 tests pass **Step 4: Verify full compilation** Run: `SQLX_OFFLINE=true cargo check --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml` **Step 5: Commit** ```bash git add ml/src/validation/statistical.rs ml/src/validation/mod.rs git commit -m "feat(validation): add DSR, PBO, permutation test, and math helpers" ``` --- ### Task 5: Per-regime analysis Implements per-regime Sharpe breakdown using the existing `RegimeType` enum from `dqn::regime_conditional`. **Files:** - Create: `ml/src/validation/regime_analysis.rs` - Modify: `ml/src/validation/mod.rs` **Step 1: Write regime_analysis.rs** ```rust //! Per-regime performance analysis. //! //! Labels each bar with a market regime (Trending, Ranging, Volatile) using //! the existing regime detection infrastructure, then computes per-regime //! Sharpe ratio, win rate, and average return. use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::dqn::RegimeType; use super::statistical::sharpe_ratio; /// Performance metrics for a single market regime. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RegimeMetrics { /// Which regime these metrics describe. pub regime: RegimeType, /// Sharpe ratio for bars in this regime. pub sharpe: f64, /// Number of bars classified into this regime. pub num_bars: usize, /// Fraction of bars with positive returns. pub win_rate: f64, /// Mean return across all bars in this regime. pub avg_return: f64, } /// Compute per-regime performance breakdown. /// /// # Arguments /// * `daily_returns` - Daily returns (length N) /// * `features` - Feature vectors for each bar (length N, used for regime classification) /// /// Regime classification uses `RegimeType::classify_from_features()` which looks at /// ADX and entropy values within the feature vector. pub fn per_regime_breakdown( daily_returns: &[f64], features: &[Vec], ) -> HashMap { let mut regime_returns: HashMap> = HashMap::new(); let n = daily_returns.len().min(features.len()); for i in 0..n { let regime = RegimeType::classify_from_features(&features[i]); regime_returns .entry(regime) .or_default() .push(daily_returns[i]); } let mut result = HashMap::new(); for (regime, returns) in ®ime_returns { let num_bars = returns.len(); let avg_return = if num_bars > 0 { returns.iter().sum::() / num_bars as f64 } else { 0.0 }; let win_rate = if num_bars > 0 { returns.iter().filter(|&&r| r > 0.0).count() as f64 / num_bars as f64 } else { 0.0 }; result.insert( *regime, RegimeMetrics { regime: *regime, sharpe: sharpe_ratio(returns), num_bars, win_rate, avg_return, }, ); } result } #[cfg(test)] mod tests { use super::*; #[test] fn test_per_regime_breakdown_groups_correctly() { // With insufficient features (< 211), classify_from_features returns Ranging (safe default) let returns = vec![0.01, -0.005, 0.02, 0.015, -0.01]; let features: Vec> = (0..5).map(|i| vec![i as f32; 10]).collect(); let breakdown = per_regime_breakdown(&returns, &features); // All should be classified as Ranging (features too short for real classification) assert!(breakdown.contains_key(&RegimeType::Ranging)); let ranging = &breakdown[&RegimeType::Ranging]; assert_eq!(ranging.num_bars, 5); } #[test] fn test_per_regime_metrics_computation() { let returns = vec![0.01, 0.02, 0.03]; let features: Vec> = (0..3).map(|_| vec![0.0; 10]).collect(); let breakdown = per_regime_breakdown(&returns, &features); let metrics = breakdown.values().next().expect("should have one regime"); assert_eq!(metrics.num_bars, 3); assert!((metrics.win_rate - 1.0).abs() < 1e-10); // all positive assert!((metrics.avg_return - 0.02).abs() < 1e-10); } #[test] fn test_empty_returns() { let breakdown = per_regime_breakdown(&[], &[]); assert!(breakdown.is_empty()); } } ``` **Step 2: Register in mod.rs** Add to `ml/src/validation/mod.rs`: ```rust pub mod regime_analysis; pub use regime_analysis::{per_regime_breakdown, RegimeMetrics}; ``` **Step 3: Run tests** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --lib validation::regime_analysis -- --nocapture 2>&1 | tail -10` Expected: 3 tests pass **Step 4: Verify full compilation** Run: `SQLX_OFFLINE=true cargo check --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml` **Step 5: Commit** ```bash git add ml/src/validation/regime_analysis.rs ml/src/validation/mod.rs git commit -m "feat(validation): add per-regime performance analysis" ``` --- ### Task 6: Validation harness orchestrator Ties everything together: runs a strategy through walk-forward folds, computes DSR, PBO, permutation test, and regime breakdown, then produces a `ValidationReport`. **Files:** - Create: `ml/src/validation/harness.rs` - Modify: `ml/src/validation/mod.rs` **Step 1: Write harness.rs** ```rust //! Validation harness orchestrator. //! //! Runs a `ValidatableStrategy` through the complete validation pipeline: //! 1. Walk-forward cross-validation with embargo //! 2. Per-fold Sharpe computation //! 3. Deflated Sharpe Ratio (adjusts for multiple testing) //! 4. Probability of Backtest Overfitting (CSCV) //! 5. Monte Carlo permutation test //! 6. Per-regime performance breakdown //! 7. Overall verdict (Pass / Marginal / Fail) use std::collections::HashMap; use serde::{Deserialize, Serialize}; use crate::dqn::RegimeType; use crate::MLError; use super::regime_analysis::{per_regime_breakdown, RegimeMetrics}; use super::statistical::{ deflated_sharpe_ratio, excess_kurtosis, permutation_test, probability_of_backtest_overfitting, sharpe_ratio, skewness, DsrResult, PboResult, PermutationResult, }; use super::types::{TimeSeriesData, ValidatableStrategy}; use super::walk_forward::{walk_forward_split, WalkForwardConfig}; /// Overall validation verdict. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ValidationVerdict { /// All tests pass: DSR p < 0.05, PBO < 0.25, MC p < 0.05 Pass, /// At least one test passes at relaxed threshold Marginal, /// No statistical evidence of a real signal Fail, } impl std::fmt::Display for ValidationVerdict { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ValidationVerdict::Pass => write!(f, "PASS"), ValidationVerdict::Marginal => write!(f, "MARGINAL"), ValidationVerdict::Fail => write!(f, "FAIL"), } } } /// Complete validation report from the harness. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ValidationReport { pub strategy_name: String, // Walk-forward results pub per_fold_sharpes: Vec, pub aggregate_sharpe: f64, pub num_folds: usize, // DSR pub dsr: DsrResult, // PBO pub pbo: PboResult, // Monte Carlo pub permutation: PermutationResult, // Regime breakdown pub per_regime_metrics: HashMap, // Overall pub verdict: ValidationVerdict, } /// Configuration for the validation harness. #[derive(Debug, Clone)] pub struct ValidationHarnessConfig { /// Walk-forward configuration. pub wf_config: WalkForwardConfig, /// Number of Monte Carlo permutations. pub num_permutations: usize, /// Number of hyperopt trials tested (for DSR). pub num_trials: usize, /// RNG seed for reproducibility. pub seed: u64, } impl Default for ValidationHarnessConfig { fn default() -> Self { Self { wf_config: WalkForwardConfig::default(), num_permutations: 10_000, num_trials: 1, seed: 42, } } } /// The validation harness: runs a strategy through the complete pipeline. pub struct ValidationHarness { config: ValidationHarnessConfig, } impl ValidationHarness { pub fn new(config: ValidationHarnessConfig) -> Self { Self { config } } /// Run full validation on a strategy. /// /// # Arguments /// * `strategy` - A model implementing `ValidatableStrategy` /// * `data` - The full time-series dataset to validate against /// /// # Returns /// A `ValidationReport` with all statistical test results and a verdict. pub fn validate( &self, strategy: &mut S, data: &TimeSeriesData, ) -> Result { // 1. Walk-forward split let folds = walk_forward_split(data.len(), &self.config.wf_config); if folds.is_empty() { return Err(MLError::ValidationError { message: format!( "Not enough data for walk-forward validation: {} bars, need at least {}", data.len(), self.config.wf_config.train_bars + self.config.wf_config.embargo_bars + self.config.wf_config.test_bars ), }); } // 2. Run strategy through each fold let mut per_fold_sharpes = Vec::with_capacity(folds.len()); let mut all_test_returns = Vec::new(); let mut all_test_features = Vec::new(); for fold in &folds { // Reset strategy for fresh training strategy.reset()?; // Train on training data let train_data = data.slice(fold.train_range.start, fold.train_range.end)?; strategy.train(&train_data)?; // Evaluate on test data let test_data = data.slice(fold.test_range.start, fold.test_range.end)?; let fold_returns = strategy.evaluate(&test_data)?; // Collect per-fold Sharpe let fold_sharpe = sharpe_ratio(&fold_returns); per_fold_sharpes.push(fold_sharpe); // Accumulate returns and features for aggregate stats all_test_returns.extend_from_slice(&fold_returns); all_test_features.extend(test_data.features.iter().take(fold_returns.len()).cloned()); } // 3. Aggregate Sharpe let aggregate_sharpe = sharpe_ratio(&all_test_returns); // 4. DSR let sharpe_variance = if per_fold_sharpes.len() > 1 { let mean = per_fold_sharpes.iter().sum::() / per_fold_sharpes.len() as f64; per_fold_sharpes .iter() .map(|&s| (s - mean).powi(2)) .sum::() / (per_fold_sharpes.len() - 1) as f64 } else { 1.0 }; let skew = skewness(&all_test_returns); let kurt = excess_kurtosis(&all_test_returns); let dsr = deflated_sharpe_ratio( aggregate_sharpe, self.config.num_trials, sharpe_variance, skew, kurt, all_test_returns.len(), ); // 5. PBO (requires even number of folds) let pbo_sharpes = if per_fold_sharpes.len() % 2 != 0 && per_fold_sharpes.len() > 1 { // Drop last fold to make even per_fold_sharpes[..per_fold_sharpes.len() - 1].to_vec() } else { per_fold_sharpes.clone() }; let pbo = probability_of_backtest_overfitting(&pbo_sharpes); // 6. Monte Carlo permutation test let permutation = permutation_test( &all_test_returns, self.config.num_permutations, self.config.seed, ); // 7. Per-regime breakdown let per_regime_metrics = per_regime_breakdown(&all_test_returns, &all_test_features); // 8. Verdict let verdict = determine_verdict(&dsr, &pbo, &permutation); Ok(ValidationReport { strategy_name: strategy.name().to_string(), per_fold_sharpes, aggregate_sharpe, num_folds: folds.len(), dsr, pbo, permutation, per_regime_metrics, verdict, }) } } fn determine_verdict( dsr: &DsrResult, pbo: &PboResult, permutation: &PermutationResult, ) -> ValidationVerdict { let dsr_pass = dsr.pvalue < 0.05; let pbo_pass = pbo.pbo < 0.25; let mc_pass = permutation.pvalue < 0.05; if dsr_pass && pbo_pass && mc_pass { ValidationVerdict::Pass } else if dsr.pvalue < 0.10 || pbo.pbo < 0.50 || permutation.pvalue < 0.10 { ValidationVerdict::Marginal } else { ValidationVerdict::Fail } } #[cfg(test)] mod tests { use super::*; use chrono::{Duration, Utc}; /// A mock strategy that always returns fixed returns (for testing the harness). struct MockStrategy { name: String, returns_per_bar: f64, } impl MockStrategy { fn new(name: &str, returns_per_bar: f64) -> Self { Self { name: name.to_string(), returns_per_bar, } } } impl ValidatableStrategy for MockStrategy { fn train(&mut self, _data: &TimeSeriesData) -> Result<(), MLError> { Ok(()) } fn evaluate(&self, data: &TimeSeriesData) -> Result, MLError> { Ok(vec![self.returns_per_bar; data.len()]) } fn name(&self) -> &str { &self.name } fn reset(&mut self) -> Result<(), MLError> { Ok(()) } } fn make_data(n: usize) -> TimeSeriesData { let start = Utc::now(); let timestamps: Vec<_> = (0..n) .map(|i| start + Duration::hours(i as i64)) .collect(); let features: Vec> = (0..n).map(|i| vec![i as f32; 10]).collect(); let prices: Vec = (0..n).map(|i| 100.0 + i as f64 * 0.1).collect(); TimeSeriesData::new(timestamps, features, prices).expect("valid data") } #[test] fn test_harness_runs_end_to_end() { let data = make_data(500); let config = ValidationHarnessConfig { wf_config: WalkForwardConfig { train_bars: 100, test_bars: 50, embargo_bars: 5, step_bars: 50, min_train_samples: 50, }, num_permutations: 100, // small for test speed num_trials: 1, seed: 42, }; let harness = ValidationHarness::new(config); let mut strategy = MockStrategy::new("test-mock", 0.01); let report = harness.validate(&mut strategy, &data); assert!(report.is_ok(), "Harness should complete: {:?}", report.err()); let report = report.expect("valid report"); assert_eq!(report.strategy_name, "test-mock"); assert!(!report.per_fold_sharpes.is_empty()); assert!(report.num_folds > 0); } #[test] fn test_harness_insufficient_data_errors() { let data = make_data(20); // way too short let config = ValidationHarnessConfig { wf_config: WalkForwardConfig { train_bars: 100, test_bars: 50, embargo_bars: 5, step_bars: 50, min_train_samples: 50, }, ..Default::default() }; let harness = ValidationHarness::new(config); let mut strategy = MockStrategy::new("too-short", 0.01); let report = harness.validate(&mut strategy, &data); assert!(report.is_err()); } #[test] fn test_verdict_all_pass() { let dsr = DsrResult { observed_sharpe: 3.0, expected_max_sharpe: 1.0, sharpe_std_error: 0.5, deflated_sharpe: 4.0, pvalue: 0.001, }; let pbo = PboResult { pbo: 0.1, num_combinations: 70, logit_distribution: vec![], }; let perm = PermutationResult { observed_sharpe: 3.0, pvalue: 0.001, num_permutations: 1000, null_mean: 0.0, null_std: 0.5, }; assert_eq!(determine_verdict(&dsr, &pbo, &perm), ValidationVerdict::Pass); } #[test] fn test_verdict_fail() { let dsr = DsrResult { observed_sharpe: 0.5, expected_max_sharpe: 2.0, sharpe_std_error: 0.5, deflated_sharpe: -3.0, pvalue: 0.99, }; let pbo = PboResult { pbo: 0.8, num_combinations: 70, logit_distribution: vec![], }; let perm = PermutationResult { observed_sharpe: 0.5, pvalue: 0.6, num_permutations: 1000, null_mean: 0.0, null_std: 0.5, }; assert_eq!(determine_verdict(&dsr, &pbo, &perm), ValidationVerdict::Fail); } } ``` **Step 2: Register in mod.rs** Add to `ml/src/validation/mod.rs`: ```rust pub mod harness; pub use harness::{ValidationHarness, ValidationHarnessConfig, ValidationReport, ValidationVerdict}; ``` **Step 3: Run tests** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --lib validation::harness -- --nocapture 2>&1 | tail -10` Expected: 4 tests pass **Step 4: Verify full test suite** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --lib validation -- --nocapture 2>&1 | tail -10` Expected: All validation tests pass (5 + 7 + 14 + 3 + 4 = 33 tests) **Step 5: Commit** ```bash git add ml/src/validation/harness.rs ml/src/validation/mod.rs git commit -m "feat(validation): add validation harness orchestrator with verdict system" ``` --- ### Task 7: DQN strategy adapter Implement `ValidatableStrategy` for DQN so it can be validated through the harness. **Files:** - Create: `ml/src/validation/adapters.rs` - Modify: `ml/src/validation/mod.rs` **Step 1: Write adapters.rs** This is a thin wrapper that translates between DQN's native API and the `ValidatableStrategy` trait. ```rust //! Strategy adapters for the validation harness. //! //! Each adapter wraps a model's native API to implement `ValidatableStrategy`, //! enabling walk-forward validation, DSR, PBO, and permutation tests. use crate::dqn::{DQNConfig, Experience, DQN}; use crate::MLError; use super::types::{TimeSeriesData, ValidatableStrategy}; /// Adapter that makes a DQN model validatable through the harness. pub struct DqnStrategy { config: DQNConfig, dqn: DQN, } impl DqnStrategy { /// Create a new DQN strategy adapter. pub fn new(config: DQNConfig) -> Result { let dqn = DQN::new(config.clone())?; Ok(Self { config, dqn }) } } impl ValidatableStrategy for DqnStrategy { fn train(&mut self, data: &TimeSeriesData) -> Result<(), MLError> { // Feed experiences into the replay buffer let num_bars = data.len(); if num_bars < 2 { return Ok(()); } for i in 0..num_bars.saturating_sub(1) { let state = data.features.get(i).cloned().unwrap_or_default(); let next_state = data.features.get(i + 1).cloned().unwrap_or_default(); // Select action using current policy let action = self.dqn.select_action(&state)?; // Reward = next bar's return (simple reward shaping) let reward = data.returns.get(i).copied().unwrap_or(0.0) as f32; let done = i == num_bars - 2; let exp = Experience::new( state, action.to_index() as u8, reward, next_state, done, ); self.dqn.store_experience(exp)?; } // Train for multiple steps if enough data in replay buffer let train_steps = (num_bars / self.config.batch_size).min(100); for _ in 0..train_steps { match self.dqn.train_step(None) { Ok(_) => {} Err(MLError::TrainingError(msg)) if msg.contains("Not enough") => break, Err(e) => return Err(e), } } Ok(()) } fn evaluate(&self, data: &TimeSeriesData) -> Result, MLError> { let mut returns = Vec::with_capacity(data.len()); for i in 0..data.len() { let state = data.features.get(i).cloned().unwrap_or_default(); let action = self.dqn.select_action(&state)?; // Simple PnL model: Buy=+return, Hold=0, Sell=-return let bar_return = data.returns.get(i).copied().unwrap_or(0.0); let pnl = match action.to_index() { 0 => bar_return, // Buy → long exposure 2 => -bar_return, // Sell → short exposure _ => 0.0, // Hold → no exposure }; returns.push(pnl); } Ok(returns) } fn name(&self) -> &str { "DQN" } fn reset(&mut self) -> Result<(), MLError> { self.dqn = DQN::new(self.config.clone())?; Ok(()) } } #[cfg(test)] mod tests { use super::*; use chrono::{Duration, Utc}; fn make_test_config() -> DQNConfig { let mut config = DQNConfig::default(); config.state_dim = 10; config.num_actions = 3; config.hidden_dims = vec![16, 8]; config.batch_size = 4; config.min_replay_size = 4; config.warmup_steps = 0; config.use_noisy_nets = false; config.use_iqn = false; config.use_distributional = false; config.use_dueling = false; config.use_per = false; config.epsilon_start = 0.5; config } fn make_test_data(n: usize, feature_dim: usize) -> TimeSeriesData { let start = Utc::now(); let timestamps: Vec<_> = (0..n) .map(|i| start + Duration::hours(i as i64)) .collect(); let features: Vec> = (0..n) .map(|i| vec![0.1 * i as f32; feature_dim]) .collect(); let prices: Vec = (0..n).map(|i| 100.0 + i as f64 * 0.5).collect(); TimeSeriesData::new(timestamps, features, prices).expect("valid data") } #[test] fn test_dqn_strategy_creation() { let config = make_test_config(); let strategy = DqnStrategy::new(config); assert!(strategy.is_ok()); } #[test] fn test_dqn_strategy_train_and_evaluate() { let config = make_test_config(); let mut strategy = DqnStrategy::new(config).expect("valid strategy"); let data = make_test_data(30, 10); let train_result = strategy.train(&data); assert!(train_result.is_ok(), "Training failed: {:?}", train_result.err()); let eval_result = strategy.evaluate(&data); assert!(eval_result.is_ok(), "Evaluation failed: {:?}", eval_result.err()); let returns = eval_result.expect("valid returns"); assert_eq!(returns.len(), 30); } #[test] fn test_dqn_strategy_reset() { let config = make_test_config(); let mut strategy = DqnStrategy::new(config).expect("valid strategy"); let data = make_test_data(20, 10); strategy.train(&data).expect("training ok"); strategy.reset().expect("reset ok"); // After reset, should be able to train again cleanly strategy.train(&data).expect("retrain ok"); } #[test] fn test_dqn_strategy_name() { let config = make_test_config(); let strategy = DqnStrategy::new(config).expect("valid strategy"); assert_eq!(strategy.name(), "DQN"); } } ``` **Step 2: Register in mod.rs** Add to `ml/src/validation/mod.rs`: ```rust pub mod adapters; pub use adapters::DqnStrategy; ``` **Step 3: Run tests** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --lib validation::adapters -- --nocapture 2>&1 | tail -10` Expected: 4 tests pass **Step 4: Verify full compilation** Run: `SQLX_OFFLINE=true cargo check --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml` **Step 5: Commit** ```bash git add ml/src/validation/adapters.rs ml/src/validation/mod.rs git commit -m "feat(validation): add DQN strategy adapter for ValidatableStrategy trait" ``` --- ### Task 8: Integration test — DQN through full validation pipeline End-to-end test that runs a DQN model through the entire validation harness. **Files:** - Create: `ml/tests/validation_harness_integration_test.rs` **Step 1: Write the integration test** ```rust //! Integration test: Full validation pipeline with DQN model. //! //! Runs DQN through walk-forward validation, DSR, PBO, permutation test, //! and per-regime analysis to verify the complete pipeline works end-to-end. use chrono::{Duration, Utc}; use ml::dqn::DQNConfig; use ml::validation::{ DqnStrategy, TimeSeriesData, ValidationHarness, ValidationHarnessConfig, WalkForwardConfig, ValidatableStrategy, }; fn make_synthetic_data(num_bars: usize, feature_dim: usize) -> TimeSeriesData { let start = Utc::now(); let timestamps: Vec<_> = (0..num_bars) .map(|i| start + Duration::hours(i as i64)) .collect(); let features: Vec> = (0..num_bars) .map(|i| { let base = (i as f32) * 0.01; vec![base; feature_dim] }) .collect(); let prices: Vec = (0..num_bars) .map(|i| 100.0 + (i as f64 * 0.1).sin() * 5.0 + i as f64 * 0.01) .collect(); TimeSeriesData::new(timestamps, features, prices).expect("valid synthetic data") } #[test] fn test_full_validation_pipeline_with_dqn() { let mut config = DQNConfig::default(); config.state_dim = 8; config.num_actions = 3; config.hidden_dims = vec![16, 8]; config.batch_size = 4; config.min_replay_size = 4; config.warmup_steps = 0; config.use_noisy_nets = false; config.use_iqn = false; config.use_distributional = false; config.use_dueling = false; config.use_per = false; config.epsilon_start = 0.3; let mut strategy = DqnStrategy::new(config).expect("DQN creation"); let data = make_synthetic_data(300, 8); let harness_config = ValidationHarnessConfig { wf_config: WalkForwardConfig { train_bars: 50, test_bars: 30, embargo_bars: 5, step_bars: 30, min_train_samples: 20, }, num_permutations: 100, // small for test speed num_trials: 1, seed: 42, }; let harness = ValidationHarness::new(harness_config); let report = harness.validate(&mut strategy, &data); assert!(report.is_ok(), "Validation pipeline failed: {:?}", report.err()); let report = report.expect("valid report"); assert_eq!(report.strategy_name, "DQN"); assert!(report.num_folds >= 2, "Should have multiple folds: {}", report.num_folds); assert!(report.aggregate_sharpe.is_finite(), "Sharpe must be finite"); assert!(report.dsr.pvalue >= 0.0 && report.dsr.pvalue <= 1.0, "DSR p-value out of range"); assert!(report.pbo.pbo >= 0.0 && report.pbo.pbo <= 1.0, "PBO out of range"); assert!(report.permutation.pvalue >= 0.0 && report.permutation.pvalue <= 1.0, "MC p-value out of range"); // Regime breakdown should have at least one regime assert!(!report.per_regime_metrics.is_empty(), "Should have at least one regime"); println!("=== Validation Report ==="); println!("Strategy: {}", report.strategy_name); println!("Folds: {}", report.num_folds); println!("Aggregate Sharpe: {:.4}", report.aggregate_sharpe); println!("DSR p-value: {:.4}", report.dsr.pvalue); println!("PBO: {:.4}", report.pbo.pbo); println!("MC p-value: {:.4}", report.permutation.pvalue); println!("Verdict: {}", report.verdict); for (regime, metrics) in &report.per_regime_metrics { println!(" {:?}: Sharpe={:.3}, WinRate={:.1}%, Bars={}", regime, metrics.sharpe, metrics.win_rate * 100.0, metrics.num_bars); } } #[test] fn test_walk_forward_split_standalone() { use ml::validation::{walk_forward_split, WalkForwardConfig}; let config = WalkForwardConfig { train_bars: 50, test_bars: 20, embargo_bars: 5, step_bars: 20, min_train_samples: 20, }; let folds = walk_forward_split(200, &config); assert!(!folds.is_empty()); // Verify no test range exceeds data bounds for fold in &folds { assert!(fold.test_range.end <= 200); assert!(fold.train_range.end <= fold.embargo_range.start); assert!(fold.embargo_range.end <= fold.test_range.start); } } #[test] fn test_dsr_and_pbo_standalone() { use ml::validation::{deflated_sharpe_ratio, probability_of_backtest_overfitting}; // DSR: high Sharpe with few trials should be significant let dsr = deflated_sharpe_ratio(2.5, 5, 0.5, 0.1, 3.5, 500); assert!(dsr.pvalue < 0.1, "Strong Sharpe with few trials should be significant"); // PBO: consistent fold Sharpes should show low overfitting probability let sharpes = vec![1.0, 1.2, 0.8, 1.1, 0.9, 1.3, 1.0, 0.95]; let pbo = probability_of_backtest_overfitting(&sharpes); assert!(pbo.num_combinations > 0); } ``` **Step 2: Run the integration test** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --test validation_harness_integration_test -- --nocapture 2>&1 | tail -20` Expected: 3 tests pass **Step 3: Verify all existing tests still pass** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --lib 2>&1 | tail -5` Expected: 1823+ tests pass (same as before plus ~37 new validation tests) **Step 4: Commit** ```bash git add ml/tests/validation_harness_integration_test.rs git commit -m "test(validation): add end-to-end integration test for full validation pipeline" ``` --- ### Task 9: Final verification Full test suite run and compilation check to ensure nothing is broken. **Step 1: Full workspace compilation** Run: `SQLX_OFFLINE=true cargo check --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml` Expected: Compiles clean **Step 2: All lib tests** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --lib 2>&1 | tail -5` Expected: All tests pass (1823 existing + ~37 new validation = ~1860) **Step 3: All integration tests** Run: `SQLX_OFFLINE=true cargo test --manifest-path /home/jgrusewski/Work/foxhunt/ml/Cargo.toml --tests 2>&1 | tail -10` Expected: All integration tests pass **Step 4: Summary of new files** ``` ml/src/validation/ ├── mod.rs # Re-exports + module declarations ├── financial.rs # Existing financial validation (moved from validation.rs) ├── types.rs # TimeSeriesData + ValidatableStrategy trait ├── walk_forward.rs # Walk-forward splitter with embargo ├── statistical.rs # DSR + PBO + Monte Carlo + math helpers ├── regime_analysis.rs # Per-regime Sharpe breakdown ├── harness.rs # Orchestrator + ValidationReport + Verdict └── adapters.rs # DqnStrategy (ValidatableStrategy for DQN) ml/tests/ └── validation_harness_integration_test.rs # End-to-end pipeline test ``` **Step 5: Verify git status is clean** Run: `git status` Expected: All changes committed, working tree clean.