Files
foxhunt/ml/src/data_validation/cpcv.rs
jgrusewski bcf9ecd07c feat(ml): complete all algorithm gaps — TFT, Mamba2, PPO, CPCV, FDR
TFT training completeness:
- Fix mod.rs::train() stub backward → real AdamW optimizer + gradient flow
- Fix TFTTrainer optimizer init, backward pass, LR scheduling, checkpointing
- Fix temporal_attention weight tracking (RwLock, stores per-head means)

Mamba2 discretization:
- Replace raw continuous-time state transition with proper discretization
- SSD layer now uses softplus(delta) step size with 2nd-order Taylor
  approximation of matrix exponential: A_bar ≈ I + A*dt + (A*dt)²/2
- Correct dtype handling (F64 SSM matrices, F32 output)

PPO entropy fix:
- Fix LSTM training path: was using constant entropy (coeff * 0.5),
  now computes real entropy from log-probabilities

Circuit breaker consolidation:
- Move canonical implementation to ml/src/common/circuit_breaker.rs
- DQN and PPO circuit_breaker.rs now re-export from common

Validation stack additions:
- Add CPCV (Combinatorial Purged Cross-Validation) with purging/embargo
- Add FDR correction (Benjamini-Hochberg + Benjamini-Yekutieli)

1922 lib tests pass, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 19:19:16 +01:00

1280 lines
42 KiB
Rust

//! # Combinatorial Purged Cross-Validation (CPCV)
//!
//! A validation method specifically designed for financial time series that avoids
//! data leakage through purging and embargo mechanisms. Based on the methodology
//! described by Marcos Lopez de Prado in *Advances in Financial Machine Learning*.
//!
//! ## Overview
//!
//! CPCV generates all combinatorial splits of N groups into training and test sets,
//! applies purging around train/test boundaries to prevent information leakage, and
//! computes performance across all combinations for unbiased strategy estimation.
//!
//! ## Key Features
//!
//! - **Combinatorial splits**: Generates all C(n_groups, n_test_groups) possible splits
//! - **Purging**: Removes data around train/test boundaries to avoid lookahead bias
//! - **Embargo**: Applies an embargo period after each test set to prevent leakage
//! - **Unbiased estimation**: Computes performance across all combinations
//!
//! ## Usage
//!
//! ```rust,no_run
//! use ml::data_validation::cpcv::{CPCVConfig, CPCVValidator};
//!
//! let config = CPCVConfig::default();
//! let validator = CPCVValidator::new(config)?;
//!
//! let data: Vec<f64> = vec![/* time series returns */];
//! let result = validator.validate(&data, |train, test| {
//! // Compute strategy returns given train/test indices
//! Ok(0.01) // placeholder
//! })?;
//!
//! println!("Mean return: {:.4}", result.mean_return);
//! println!("P(loss): {:.2}%", result.probability_of_loss * 100.0);
//! # Ok::<(), ml::MLError>(())
//! ```
use crate::MLError;
use serde::{Deserialize, Serialize};
/// Configuration for Combinatorial Purged Cross-Validation.
///
/// Controls the number of groups, test groups, purge length, and embargo length
/// used when generating cross-validation splits.
///
/// # Defaults
///
/// - `n_groups`: 10
/// - `n_test_groups`: 2
/// - `purge_length`: 5
/// - `embargo_length`: 3
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CPCVConfig {
/// Total number of groups to partition the data into.
pub n_groups: usize,
/// Number of groups to hold out as test sets in each split.
pub n_test_groups: usize,
/// Number of samples to purge around each train/test boundary to prevent leakage.
pub purge_length: usize,
/// Number of samples to embargo after each test group to prevent leakage.
pub embargo_length: usize,
}
impl Default for CPCVConfig {
fn default() -> Self {
Self {
n_groups: 10,
n_test_groups: 2,
purge_length: 5,
embargo_length: 3,
}
}
}
/// A single train/test split produced by the CPCV generator.
///
/// Contains the indices into the original data array that belong to the
/// training set and test set respectively, after purging and embargo
/// have been applied.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CPCVSplit {
/// Indices belonging to the training set (after purge and embargo removal).
pub train_indices: Vec<usize>,
/// Indices belonging to the test set.
pub test_indices: Vec<usize>,
}
/// Aggregated results from running CPCV across all combinatorial splits.
///
/// Contains summary statistics and per-split returns that allow assessment
/// of strategy robustness and probability of backtest overfitting.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CPCVResult {
/// Mean return across all combinatorial splits.
pub mean_return: f64,
/// Standard deviation of returns across all splits.
pub std_return: f64,
/// Return value from each individual split.
pub per_split_returns: Vec<f64>,
/// Fraction of splits with negative returns (estimate of probability of loss).
pub probability_of_loss: f64,
/// Total number of combinatorial splits evaluated.
pub num_splits: usize,
}
/// Combinatorial Purged Cross-Validation validator.
///
/// Implements the full CPCV workflow: partitioning data into groups, generating
/// all combinatorial train/test splits, applying purging and embargo, and
/// aggregating strategy performance across all splits.
#[derive(Debug, Clone)]
pub struct CPCVValidator {
/// Configuration parameters.
config: CPCVConfig,
}
impl CPCVValidator {
/// Create a new CPCV validator with the given configuration.
///
/// # Arguments
///
/// * `config` - CPCV configuration specifying groups, purge, and embargo parameters.
///
/// # Returns
///
/// Returns `Ok(CPCVValidator)` if the configuration is valid, or an error if:
/// - `n_groups` is zero
/// - `n_test_groups` is zero or greater than or equal to `n_groups`
///
/// # Errors
///
/// Returns `MLError::ValidationError` when configuration parameters are invalid.
pub fn new(config: CPCVConfig) -> Result<Self, MLError> {
if config.n_groups == 0 {
return Err(MLError::ValidationError {
message: "n_groups must be greater than 0".to_string(),
});
}
if config.n_test_groups == 0 {
return Err(MLError::ValidationError {
message: "n_test_groups must be greater than 0".to_string(),
});
}
if config.n_test_groups >= config.n_groups {
return Err(MLError::ValidationError {
message: format!(
"n_test_groups ({}) must be less than n_groups ({})",
config.n_test_groups, config.n_groups
),
});
}
Ok(Self { config })
}
/// Return the number of combinatorial splits: C(n_groups, n_test_groups).
///
/// This is the total number of distinct ways to choose `n_test_groups` groups
/// from `n_groups` groups.
pub fn num_combinations(&self) -> usize {
binomial(self.config.n_groups, self.config.n_test_groups)
}
/// Generate all combinatorial purged cross-validation splits.
///
/// Partitions `n_samples` data points into `n_groups` contiguous groups,
/// then generates all C(n_groups, n_test_groups) possible train/test splits.
/// For each split, purging and embargo are applied to remove data points
/// near train/test boundaries.
///
/// # Arguments
///
/// * `n_samples` - Total number of data points in the time series.
///
/// # Returns
///
/// A vector of `CPCVSplit` structs, one for each combinatorial split.
///
/// # Errors
///
/// Returns `MLError::ValidationError` if `n_samples` is less than `n_groups`.
pub fn generate_splits(&self, n_samples: usize) -> Result<Vec<CPCVSplit>, MLError> {
if n_samples < self.config.n_groups {
return Err(MLError::ValidationError {
message: format!(
"n_samples ({}) must be >= n_groups ({})",
n_samples, self.config.n_groups
),
});
}
// Partition indices into contiguous groups
let group_boundaries = compute_group_boundaries(n_samples, self.config.n_groups);
// Generate all combinations of test group indices
let test_group_combos = combinations(self.config.n_groups, self.config.n_test_groups);
let mut splits = Vec::with_capacity(test_group_combos.len());
for test_groups in &test_group_combos {
// Collect test indices
let mut test_indices = Vec::new();
for &tg in test_groups {
let (start, end) = get_group_range(&group_boundaries, tg)?;
for idx in start..end {
test_indices.push(idx);
}
}
// Build a set of indices to exclude from training (test + purge + embargo)
let mut excluded = vec![false; n_samples];
// Mark test indices as excluded
for &idx in &test_indices {
if idx < n_samples {
// Safety: idx < n_samples guaranteed by group boundary construction
if let Some(slot) = excluded.get_mut(idx) {
*slot = true;
}
}
}
// Apply purging and embargo around each test group
for &tg in test_groups {
let (test_start, test_end) = get_group_range(&group_boundaries, tg)?;
// Purge before test set: remove `purge_length` samples before test_start
let purge_start = test_start.saturating_sub(self.config.purge_length);
for idx in purge_start..test_start {
if let Some(slot) = excluded.get_mut(idx) {
*slot = true;
}
}
// Purge after test set: remove `purge_length` samples after test_end
let purge_after_end = (test_end + self.config.purge_length).min(n_samples);
for idx in test_end..purge_after_end {
if let Some(slot) = excluded.get_mut(idx) {
*slot = true;
}
}
// Embargo after test set: remove `embargo_length` samples after purge zone
let embargo_end =
(purge_after_end + self.config.embargo_length).min(n_samples);
for idx in purge_after_end..embargo_end {
if let Some(slot) = excluded.get_mut(idx) {
*slot = true;
}
}
}
// Training indices = all non-excluded indices
let train_indices: Vec<usize> = (0..n_samples)
.filter(|&i| {
excluded.get(i).copied().unwrap_or(true) == false
})
.collect();
splits.push(CPCVSplit {
train_indices,
test_indices,
});
}
Ok(splits)
}
/// Run full CPCV validation on a data series using a user-supplied strategy function.
///
/// For each combinatorial split, the strategy function is called with training and
/// test data slices, and should return the strategy's return metric for that split.
/// Results are aggregated across all splits.
///
/// # Arguments
///
/// * `data` - The full time series of returns or prices.
/// * `strategy_fn` - A function that receives train data and test data slices and
/// returns a single f64 performance metric (e.g., mean return, Sharpe ratio).
///
/// # Returns
///
/// A `CPCVResult` with aggregated statistics across all splits.
///
/// # Errors
///
/// Returns an error if split generation fails or if the strategy function returns
/// an error for any split.
pub fn validate<F>(
&self,
data: &[f64],
strategy_fn: F,
) -> Result<CPCVResult, MLError>
where
F: Fn(&[f64], &[f64]) -> Result<f64, MLError>,
{
let splits = self.generate_splits(data.len())?;
if splits.is_empty() {
return Err(MLError::ValidationError {
message: "No splits generated".to_string(),
});
}
let mut per_split_returns = Vec::with_capacity(splits.len());
for split in &splits {
// Gather train and test data from indices
let train_data: Vec<f64> = split
.train_indices
.iter()
.filter_map(|&i| data.get(i).copied())
.collect();
let test_data: Vec<f64> = split
.test_indices
.iter()
.filter_map(|&i| data.get(i).copied())
.collect();
let ret = strategy_fn(&train_data, &test_data)?;
per_split_returns.push(ret);
}
let num_splits = per_split_returns.len();
let mean_return = if num_splits > 0 {
per_split_returns.iter().sum::<f64>() / num_splits as f64
} else {
0.0
};
let std_return = if num_splits > 1 {
let variance = per_split_returns
.iter()
.map(|&r| {
let diff = r - mean_return;
diff * diff
})
.sum::<f64>()
/ (num_splits - 1) as f64;
variance.sqrt()
} else {
0.0
};
let loss_count = per_split_returns.iter().filter(|&&r| r < 0.0).count();
let probability_of_loss = if num_splits > 0 {
loss_count as f64 / num_splits as f64
} else {
0.0
};
Ok(CPCVResult {
mean_return,
std_return,
per_split_returns,
probability_of_loss,
num_splits,
})
}
/// Get a reference to the current configuration.
pub fn config(&self) -> &CPCVConfig {
&self.config
}
}
// =============================================================================
// Internal helper functions
// =============================================================================
/// Compute the binomial coefficient C(n, k) using an iterative algorithm
/// that avoids overflow for reasonable inputs.
fn binomial(n: usize, k: usize) -> usize {
if k > n {
return 0;
}
if k == 0 || k == n {
return 1;
}
// Use the smaller of k and n-k for efficiency
let k = k.min(n - k);
let mut result: usize = 1;
for i in 0..k {
// Multiply first then divide to maintain integer precision
result = result * (n - i) / (i + 1);
}
result
}
/// Compute contiguous group boundaries for partitioning `n_samples` into `n_groups`.
///
/// Returns a vector of length `n_groups + 1` where group `i` spans
/// `[boundaries[i], boundaries[i+1])`.
fn compute_group_boundaries(n_samples: usize, n_groups: usize) -> Vec<usize> {
let mut boundaries = Vec::with_capacity(n_groups + 1);
let base_size = n_samples / n_groups;
let remainder = n_samples % n_groups;
let mut current = 0;
for i in 0..n_groups {
boundaries.push(current);
// Distribute remainder samples across the first `remainder` groups
let group_size = base_size + if i < remainder { 1 } else { 0 };
current += group_size;
}
boundaries.push(n_samples);
boundaries
}
/// Get the [start, end) range for a given group index from the boundaries vector.
///
/// # Errors
///
/// Returns `MLError::ValidationError` if the group index is out of bounds.
fn get_group_range(
boundaries: &[usize],
group_idx: usize,
) -> Result<(usize, usize), MLError> {
let start = boundaries.get(group_idx).copied().ok_or_else(|| {
MLError::ValidationError {
message: format!(
"Group index {} out of range (boundaries len: {})",
group_idx,
boundaries.len()
),
}
})?;
let end = boundaries.get(group_idx + 1).copied().ok_or_else(|| {
MLError::ValidationError {
message: format!(
"Group index {} + 1 out of range (boundaries len: {})",
group_idx,
boundaries.len()
),
}
})?;
Ok((start, end))
}
/// Generate all combinations of `k` items from `0..n`.
///
/// Returns a vector of vectors, where each inner vector contains `k` indices
/// in ascending order.
fn combinations(n: usize, k: usize) -> Vec<Vec<usize>> {
let mut result = Vec::new();
let mut combo = Vec::with_capacity(k);
generate_combinations(n, k, 0, &mut combo, &mut result);
result
}
/// Recursive helper for combination generation.
fn generate_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();
// Prune: ensure enough elements remain to complete the combination
if start + remaining > n {
return;
}
for i in start..n {
current.push(i);
generate_combinations(n, k, i + 1, current, result);
current.pop();
}
}
#[cfg(test)]
mod tests {
use super::*;
// =========================================================================
// Helper utilities
// =========================================================================
/// Create a simple ascending data series for testing.
fn make_data(n: usize) -> Vec<f64> {
(0..n).map(|i| i as f64 * 0.001).collect()
}
/// A trivial strategy that returns the mean of test data.
fn mean_strategy(
_train: &[f64],
test: &[f64],
) -> Result<f64, MLError> {
if test.is_empty() {
return Ok(0.0);
}
let sum: f64 = test.iter().sum();
Ok(sum / test.len() as f64)
}
// =========================================================================
// Configuration tests
// =========================================================================
#[test]
fn test_default_config() {
let config = CPCVConfig::default();
assert_eq!(config.n_groups, 10);
assert_eq!(config.n_test_groups, 2);
assert_eq!(config.purge_length, 5);
assert_eq!(config.embargo_length, 3);
}
#[test]
fn test_config_serde_roundtrip() {
let config = CPCVConfig {
n_groups: 8,
n_test_groups: 3,
purge_length: 10,
embargo_length: 5,
};
let json = serde_json::to_string(&config).ok();
assert!(json.is_some());
let json_str = json.unwrap_or_default();
let parsed: Result<CPCVConfig, _> = serde_json::from_str(&json_str);
assert!(parsed.is_ok());
if let Ok(c) = parsed {
assert_eq!(c.n_groups, 8);
assert_eq!(c.n_test_groups, 3);
assert_eq!(c.purge_length, 10);
assert_eq!(c.embargo_length, 5);
}
}
// =========================================================================
// Validator construction tests
// =========================================================================
#[test]
fn test_validator_creation_default() {
let validator = CPCVValidator::new(CPCVConfig::default());
assert!(validator.is_ok());
}
#[test]
fn test_validator_rejects_zero_groups() {
let config = CPCVConfig {
n_groups: 0,
..CPCVConfig::default()
};
let result = CPCVValidator::new(config);
assert!(result.is_err());
}
#[test]
fn test_validator_rejects_zero_test_groups() {
let config = CPCVConfig {
n_test_groups: 0,
..CPCVConfig::default()
};
let result = CPCVValidator::new(config);
assert!(result.is_err());
}
#[test]
fn test_validator_rejects_test_groups_gte_groups() {
let config = CPCVConfig {
n_groups: 5,
n_test_groups: 5,
..CPCVConfig::default()
};
let result = CPCVValidator::new(config);
assert!(result.is_err());
let config2 = CPCVConfig {
n_groups: 5,
n_test_groups: 6,
..CPCVConfig::default()
};
let result2 = CPCVValidator::new(config2);
assert!(result2.is_err());
}
// =========================================================================
// Binomial coefficient tests
// =========================================================================
#[test]
fn test_binomial_basic() {
assert_eq!(binomial(10, 2), 45);
assert_eq!(binomial(5, 0), 1);
assert_eq!(binomial(5, 5), 1);
assert_eq!(binomial(6, 3), 20);
assert_eq!(binomial(10, 3), 120);
}
#[test]
fn test_binomial_k_greater_than_n() {
assert_eq!(binomial(3, 5), 0);
}
#[test]
fn test_binomial_edge_cases() {
assert_eq!(binomial(0, 0), 1);
assert_eq!(binomial(1, 0), 1);
assert_eq!(binomial(1, 1), 1);
}
// =========================================================================
// Combination generation tests
// =========================================================================
#[test]
fn test_combinations_count() {
let combos = combinations(5, 2);
assert_eq!(combos.len(), 10); // C(5,2) = 10
let combos = combinations(10, 2);
assert_eq!(combos.len(), 45); // C(10,2) = 45
}
#[test]
fn test_combinations_content() {
let combos = combinations(4, 2);
assert_eq!(combos.len(), 6);
// All combinations of 2 from {0,1,2,3}
let expected = vec![
vec![0, 1],
vec![0, 2],
vec![0, 3],
vec![1, 2],
vec![1, 3],
vec![2, 3],
];
assert_eq!(combos, expected);
}
#[test]
fn test_combinations_single() {
let combos = combinations(4, 1);
assert_eq!(combos.len(), 4);
assert_eq!(combos, vec![vec![0], vec![1], vec![2], vec![3]]);
}
// =========================================================================
// Group boundary tests
// =========================================================================
#[test]
fn test_group_boundaries_even_split() {
let boundaries = compute_group_boundaries(100, 10);
assert_eq!(boundaries.len(), 11);
assert_eq!(*boundaries.first().unwrap_or(&usize::MAX), 0);
assert_eq!(*boundaries.last().unwrap_or(&0), 100);
// Each group should have exactly 10 elements
for i in 0..10 {
let start = boundaries.get(i).copied().unwrap_or(0);
let end = boundaries.get(i + 1).copied().unwrap_or(0);
assert_eq!(end - start, 10);
}
}
#[test]
fn test_group_boundaries_uneven_split() {
let boundaries = compute_group_boundaries(103, 10);
assert_eq!(boundaries.len(), 11);
assert_eq!(*boundaries.first().unwrap_or(&usize::MAX), 0);
assert_eq!(*boundaries.last().unwrap_or(&0), 103);
// First 3 groups should have 11 elements, rest should have 10
for i in 0..10 {
let start = boundaries.get(i).copied().unwrap_or(0);
let end = boundaries.get(i + 1).copied().unwrap_or(0);
let size = end - start;
if i < 3 {
assert_eq!(size, 11);
} else {
assert_eq!(size, 10);
}
}
}
// =========================================================================
// Split generation tests
// =========================================================================
#[test]
fn test_num_combinations() {
let config = CPCVConfig {
n_groups: 10,
n_test_groups: 2,
purge_length: 0,
embargo_length: 0,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
assert_eq!(v.num_combinations(), 45);
}
}
#[test]
fn test_generate_splits_count() {
let config = CPCVConfig {
n_groups: 5,
n_test_groups: 2,
purge_length: 0,
embargo_length: 0,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let splits = v.generate_splits(100);
assert!(splits.is_ok());
if let Ok(s) = splits {
assert_eq!(s.len(), 10); // C(5,2) = 10
}
}
}
#[test]
fn test_generate_splits_no_overlap() {
let config = CPCVConfig {
n_groups: 5,
n_test_groups: 2,
purge_length: 0,
embargo_length: 0,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let splits = v.generate_splits(100);
assert!(splits.is_ok());
if let Ok(s) = splits {
for split in &s {
// Train and test should not overlap
for train_idx in &split.train_indices {
assert!(
!split.test_indices.contains(train_idx),
"Train index {} found in test set",
train_idx
);
}
}
}
}
}
#[test]
fn test_purging_removes_boundary_samples() {
let config = CPCVConfig {
n_groups: 5,
n_test_groups: 1,
purge_length: 3,
embargo_length: 0,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let n_samples = 50;
let splits = v.generate_splits(n_samples);
assert!(splits.is_ok());
if let Ok(s) = splits {
// Check a middle group (e.g., group 2 = indices 20..30 for 50/5=10 per group)
// Find the split where group 2 is the test group
for split in &s {
// Check that purged indices (3 before test start, 3 after test end)
// are not in the training set
if !split.test_indices.is_empty() {
let test_min = split
.test_indices
.iter()
.copied()
.min()
.unwrap_or(0);
let test_max = split
.test_indices
.iter()
.copied()
.max()
.unwrap_or(0);
// Indices just before test should be purged
for purge_idx in test_min.saturating_sub(3)..test_min {
assert!(
!split.train_indices.contains(&purge_idx),
"Purge index {} before test should not be in training",
purge_idx
);
}
// Indices just after test should be purged
let purge_end = (test_max + 1 + 3).min(n_samples);
for purge_idx in (test_max + 1)..purge_end {
assert!(
!split.train_indices.contains(&purge_idx),
"Purge index {} after test should not be in training",
purge_idx
);
}
}
}
}
}
}
#[test]
fn test_embargo_removes_samples_after_test() {
let config = CPCVConfig {
n_groups: 5,
n_test_groups: 1,
purge_length: 0,
embargo_length: 4,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let n_samples = 50;
let splits = v.generate_splits(n_samples);
assert!(splits.is_ok());
if let Ok(s) = splits {
for split in &s {
if !split.test_indices.is_empty() {
let test_max = split
.test_indices
.iter()
.copied()
.max()
.unwrap_or(0);
// Embargo indices after test should not be in training
let embargo_end = (test_max + 1 + 4).min(n_samples);
for embargo_idx in (test_max + 1)..embargo_end {
assert!(
!split.train_indices.contains(&embargo_idx),
"Embargo index {} should not be in training",
embargo_idx
);
}
}
}
}
}
}
#[test]
fn test_generate_splits_insufficient_samples() {
let config = CPCVConfig {
n_groups: 10,
n_test_groups: 2,
purge_length: 0,
embargo_length: 0,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let result = v.generate_splits(5); // 5 < 10
assert!(result.is_err());
}
}
#[test]
fn test_all_samples_covered_no_purge() {
let config = CPCVConfig {
n_groups: 4,
n_test_groups: 1,
purge_length: 0,
embargo_length: 0,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let n_samples = 40;
let splits = v.generate_splits(n_samples);
assert!(splits.is_ok());
if let Ok(s) = splits {
for split in &s {
// Without purge/embargo, train + test = all samples
let mut all_indices: Vec<usize> = split.train_indices.clone();
all_indices.extend_from_slice(&split.test_indices);
all_indices.sort_unstable();
all_indices.dedup();
assert_eq!(
all_indices.len(),
n_samples,
"Train + test should cover all samples when purge=0, embargo=0"
);
}
}
}
}
// =========================================================================
// Validation tests
// =========================================================================
#[test]
fn test_validate_basic() {
let config = CPCVConfig {
n_groups: 5,
n_test_groups: 2,
purge_length: 0,
embargo_length: 0,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let data = make_data(100);
let result = v.validate(&data, mean_strategy);
assert!(result.is_ok());
if let Ok(r) = result {
assert_eq!(r.num_splits, 10); // C(5,2) = 10
assert_eq!(r.per_split_returns.len(), 10);
// Mean of ascending data should be positive
assert!(r.mean_return > 0.0);
assert!(r.std_return >= 0.0);
// All returns from ascending data are positive
assert!((r.probability_of_loss - 0.0).abs() < f64::EPSILON);
}
}
}
#[test]
fn test_validate_with_purge_and_embargo() {
let config = CPCVConfig {
n_groups: 5,
n_test_groups: 2,
purge_length: 2,
embargo_length: 2,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let data = make_data(200);
let result = v.validate(&data, mean_strategy);
assert!(result.is_ok());
if let Ok(r) = result {
assert_eq!(r.num_splits, 10);
// With purge/embargo, training sets are smaller, but mean should still be positive
assert!(r.mean_return > 0.0);
}
}
}
#[test]
fn test_validate_probability_of_loss() {
let config = CPCVConfig {
n_groups: 4,
n_test_groups: 1,
purge_length: 0,
embargo_length: 0,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
// Data that goes negative in later segments
let mut data = Vec::with_capacity(40);
for i in 0..20 {
data.push(i as f64 * 0.01); // positive
}
for i in 0..20 {
data.push(-0.01 * (i as f64 + 1.0)); // negative
}
let result = v.validate(&data, mean_strategy);
assert!(result.is_ok());
if let Ok(r) = result {
// Some splits should have negative returns (groups in the negative region)
assert!(r.probability_of_loss > 0.0);
assert!(r.probability_of_loss <= 1.0);
}
}
}
#[test]
fn test_validate_strategy_error_propagation() {
let config = CPCVConfig {
n_groups: 3,
n_test_groups: 1,
purge_length: 0,
embargo_length: 0,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let data = make_data(30);
let result = v.validate(&data, |_train, _test| {
Err(MLError::ValidationError {
message: "strategy failure".to_string(),
})
});
assert!(result.is_err());
}
}
// =========================================================================
// Result serde tests
// =========================================================================
#[test]
fn test_result_serde_roundtrip() {
let result = CPCVResult {
mean_return: 0.05,
std_return: 0.02,
per_split_returns: vec![0.03, 0.07, 0.05],
probability_of_loss: 0.0,
num_splits: 3,
};
let json = serde_json::to_string(&result).ok();
assert!(json.is_some());
let json_str = json.unwrap_or_default();
let parsed: Result<CPCVResult, _> = serde_json::from_str(&json_str);
assert!(parsed.is_ok());
if let Ok(r) = parsed {
assert!((r.mean_return - 0.05).abs() < f64::EPSILON);
assert_eq!(r.num_splits, 3);
assert_eq!(r.per_split_returns.len(), 3);
}
}
// =========================================================================
// Edge case tests
// =========================================================================
#[test]
fn test_minimal_valid_config() {
let config = CPCVConfig {
n_groups: 2,
n_test_groups: 1,
purge_length: 0,
embargo_length: 0,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
assert_eq!(v.num_combinations(), 2); // C(2,1) = 2
let splits = v.generate_splits(10);
assert!(splits.is_ok());
if let Ok(s) = splits {
assert_eq!(s.len(), 2);
}
}
}
#[test]
fn test_large_purge_does_not_panic() {
// Purge larger than group size should still produce valid (possibly empty) training sets
let config = CPCVConfig {
n_groups: 3,
n_test_groups: 1,
purge_length: 100,
embargo_length: 100,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let splits = v.generate_splits(30);
assert!(splits.is_ok());
// Splits should still be generated even if training sets are small
if let Ok(s) = splits {
assert_eq!(s.len(), 3); // C(3,1) = 3
}
}
}
#[test]
fn test_purge_and_embargo_combined() {
let config = CPCVConfig {
n_groups: 5,
n_test_groups: 1,
purge_length: 2,
embargo_length: 3,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let n_samples = 50;
let splits = v.generate_splits(n_samples);
assert!(splits.is_ok());
if let Ok(s) = splits {
for split in &s {
if !split.test_indices.is_empty() {
let test_min = split
.test_indices
.iter()
.copied()
.min()
.unwrap_or(0);
let test_max = split
.test_indices
.iter()
.copied()
.max()
.unwrap_or(0);
// Check purge zone before test
for idx in test_min.saturating_sub(2)..test_min {
assert!(
!split.train_indices.contains(&idx),
"Purge-before index {} should be excluded",
idx
);
}
// Check purge zone after test
let purge_after_end = (test_max + 1 + 2).min(n_samples);
for idx in (test_max + 1)..purge_after_end {
assert!(
!split.train_indices.contains(&idx),
"Purge-after index {} should be excluded",
idx
);
}
// Check embargo zone (after purge zone)
let embargo_end = (purge_after_end + 3).min(n_samples);
for idx in purge_after_end..embargo_end {
assert!(
!split.train_indices.contains(&idx),
"Embargo index {} should be excluded",
idx
);
}
}
}
}
}
}
#[test]
fn test_train_set_smaller_with_purge() {
let config_no_purge = CPCVConfig {
n_groups: 5,
n_test_groups: 1,
purge_length: 0,
embargo_length: 0,
};
let config_with_purge = CPCVConfig {
n_groups: 5,
n_test_groups: 1,
purge_length: 3,
embargo_length: 2,
};
let v_no_purge = CPCVValidator::new(config_no_purge);
let v_with_purge = CPCVValidator::new(config_with_purge);
assert!(v_no_purge.is_ok());
assert!(v_with_purge.is_ok());
if let (Ok(v_np), Ok(v_wp)) = (v_no_purge, v_with_purge) {
let splits_np = v_np.generate_splits(100);
let splits_wp = v_wp.generate_splits(100);
assert!(splits_np.is_ok());
assert!(splits_wp.is_ok());
if let (Ok(s_np), Ok(s_wp)) = (splits_np, splits_wp) {
// Training sets with purge/embargo should be smaller
for i in 0..s_np.len().min(s_wp.len()) {
if let (Some(np), Some(wp)) = (s_np.get(i), s_wp.get(i)) {
assert!(
wp.train_indices.len() <= np.train_indices.len(),
"Purged training set should be <= non-purged. Got {} vs {}",
wp.train_indices.len(),
np.train_indices.len()
);
}
}
}
}
}
#[test]
fn test_config_accessor() {
let config = CPCVConfig {
n_groups: 7,
n_test_groups: 3,
purge_length: 4,
embargo_length: 2,
};
let validator = CPCVValidator::new(config.clone());
assert!(validator.is_ok());
if let Ok(v) = validator {
assert_eq!(v.config().n_groups, 7);
assert_eq!(v.config().n_test_groups, 3);
assert_eq!(v.config().purge_length, 4);
assert_eq!(v.config().embargo_length, 2);
}
}
#[test]
fn test_default_config_validation() {
let config = CPCVConfig::default();
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let data = make_data(200);
let result = v.validate(&data, mean_strategy);
assert!(result.is_ok());
if let Ok(r) = result {
assert_eq!(r.num_splits, 45); // C(10, 2) = 45
}
}
}
#[test]
fn test_split_indices_are_sorted_and_unique() {
let config = CPCVConfig {
n_groups: 4,
n_test_groups: 2,
purge_length: 1,
embargo_length: 1,
};
let validator = CPCVValidator::new(config);
assert!(validator.is_ok());
if let Ok(v) = validator {
let splits = v.generate_splits(40);
assert!(splits.is_ok());
if let Ok(s) = splits {
for split in &s {
// Test indices should be sorted (because groups are contiguous)
let mut sorted_test = split.test_indices.clone();
sorted_test.sort_unstable();
assert_eq!(split.test_indices, sorted_test);
// Train indices should be sorted
let mut sorted_train = split.train_indices.clone();
sorted_train.sort_unstable();
assert_eq!(split.train_indices, sorted_train);
// No duplicates in train
let mut deduped = split.train_indices.clone();
deduped.dedup();
assert_eq!(split.train_indices.len(), deduped.len());
// No duplicates in test
let mut deduped_test = split.test_indices.clone();
deduped_test.dedup();
assert_eq!(split.test_indices.len(), deduped_test.len());
}
}
}
}
}