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>
1280 lines
42 KiB
Rust
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());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|