//! # 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 = 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, /// Indices belonging to the test set. pub test_indices: Vec, } /// 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, /// 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 { 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, 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 = (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( &self, data: &[f64], strategy_fn: F, ) -> Result where F: Fn(&[f64], &[f64]) -> Result, { 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 = split .train_indices .iter() .filter_map(|&i| data.get(i).copied()) .collect(); let test_data: Vec = 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::() / 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::() / (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 { 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> { 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, result: &mut Vec>, ) { 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 { (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 { 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 = 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 = 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 = 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()); } } } } }