Ignore ML checkpoints, trained model safetensors, stray ml/ml/ dir, and .claude/worktrees/. Clean up duplicate hive-mind-prompt entries. Add 17 design/implementation plan docs from 2026-02-20 to 2026-02-22. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
72 KiB
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).
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.
rm /home/jgrusewski/Work/foxhunt/ml/src/validation/numerical_tests.rs
Step 3: Create validation/mod.rs
//! 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
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)]intypes.rs
Step 1: Write the failing test
Create ml/src/validation/types.rs with tests that reference structs/traits that don't exist yet:
//! 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<DateTime<Utc>>,
/// Feature matrix: `features[i]` is the feature vector for bar `i`
pub features: Vec<Vec<f32>>,
/// Close prices for PnL computation
pub prices: Vec<f64>,
/// Log returns derived from prices: `returns[i] = ln(prices[i+1] / prices[i])`
/// Length is `prices.len() - 1`
pub returns: Vec<f64>,
}
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<DateTime<Utc>>,
features: Vec<Vec<f32>>,
prices: Vec<f64>,
) -> Result<Self, MLError> {
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<f64> = 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<Self, MLError> {
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<Vec<f64>, 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<Vec<f32>> = (0..n).map(|i| vec![i as f32; 4]).collect();
let prices: Vec<f64> = (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:
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
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
//! 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<usize>,
/// Index range for the embargo gap (excluded from both train and test).
pub embargo_range: Range<usize>,
/// Index range for test data.
pub test_range: Range<usize>,
}
/// 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<Fold> {
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:
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
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
//! 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::<f64>() / n;
let variance = returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / (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::<f64>() / n;
let variance = returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / (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::<f64>();
(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::<f64>() / n;
let m2 = returns.iter().map(|&r| (r - mean).powi(2)).sum::<f64>() / n;
if m2 < 1e-30 {
return 0.0;
}
let m4 = returns.iter().map(|&r| (r - mean).powi(4)).sum::<f64>() / 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::<f64>() / 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::<f64>()
/ (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<f64>,
}
/// 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<f64> = combo.iter().map(|&i| per_fold_sharpes[i]).collect();
let oos_indices: Vec<usize> = (0..n).filter(|i| !combo.contains(i)).collect();
let oos_sharpes: Vec<f64> = 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<Vec<usize>> {
// 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<usize> = (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<usize> = 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<usize>,
result: &mut Vec<Vec<usize>>,
) {
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<f64> = (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<f64> = (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<f64> = (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:
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
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
//! 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<f32>],
) -> HashMap<RegimeType, RegimeMetrics> {
let mut regime_returns: HashMap<RegimeType, Vec<f64>> = 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::<f64>() / 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<Vec<f32>> = (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<Vec<f32>> = (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:
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
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
//! 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<f64>,
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<RegimeType, RegimeMetrics>,
// 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<S: ValidatableStrategy>(
&self,
strategy: &mut S,
data: &TimeSeriesData,
) -> Result<ValidationReport, MLError> {
// 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::<f64>() / per_fold_sharpes.len() as f64;
per_fold_sharpes
.iter()
.map(|&s| (s - mean).powi(2))
.sum::<f64>()
/ (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<Vec<f64>, 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<Vec<f32>> = (0..n).map(|i| vec![i as f32; 10]).collect();
let prices: Vec<f64> = (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:
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
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.
//! 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<Self, MLError> {
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<Vec<f64>, 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<Vec<f32>> = (0..n)
.map(|i| vec![0.1 * i as f32; feature_dim])
.collect();
let prices: Vec<f64> = (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:
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
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
//! 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<Vec<f32>> = (0..num_bars)
.map(|i| {
let base = (i as f32) * 0.01;
vec![base; feature_dim]
})
.collect();
let prices: Vec<f64> = (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
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.