Validation¶
The validation module provides cross-validation strategies designed specifically for financial time series. Unlike standard k-fold CV which randomly shuffles data, these methods respect the temporal ordering of observations to prevent look-ahead bias — a critical requirement when backtesting portfolio strategies.
Overview¶
Standard cross-validation assumes observations are i.i.d., which is violated by financial returns that exhibit autocorrelation, volatility clustering, and regime changes. The three validation strategies in this module address this by enforcing temporal ordering:
- Walk-Forward — rolling or expanding window that mimics real-time portfolio management
- Combinatorial Purged CV (CPCV) — generates a population of backtest paths with purging and embargoing
- Multiple Randomized CV — dual randomization across time and assets for robustness testing
All validators follow the frozen-config + factory pattern: a @dataclass(frozen=True) config holds serializable parameters, and a factory function builds the skfolio cross-validator.
Walk-Forward¶
Walk-Forward validation partitions the time series into successive train/test windows that move forward in time. This is the most common and intuitive method — it directly simulates how a portfolio manager would use the model in practice.
|-------- train --------|-- test --|
|-------- train --------|-- test --|
|-------- train --------|-- test --|
Configuration¶
from optimizer.validation import WalkForwardConfig
config = WalkForwardConfig(
test_size=63, # ~1 quarter of trading days
train_size=252, # ~1 year of trading days
purged_size=0, # observations purged between train/test
expend_train=False, # False = rolling, True = expanding
reduce_test=False, # allow shorter final test window
)
| Field | Type | Default | Description |
|---|---|---|---|
test_size |
int |
63 | Trading days per test window |
train_size |
int |
252 | Trading days per training window (initial size when expanding) |
purged_size |
int |
0 | Observations excised between train and test |
expend_train |
bool |
False |
True = expanding window, False = rolling window |
reduce_test |
bool |
False |
Allow shorter final test window to avoid data waste |
Presets¶
| Preset | test_size | train_size | Window Type |
|---|---|---|---|
for_monthly_rolling() |
21 | 252 | Rolling |
for_quarterly_rolling() |
63 | 252 | Rolling |
for_quarterly_expanding() |
63 | 252 | Expanding |
Rolling vs Expanding¶
- Rolling window (
expend_train=False): Training window has fixed size and slides forward. Better when the market regime changes over time — older data may not be representative. - Expanding window (
expend_train=True): Training window grows as data accumulates. Better when more data always improves estimation — the estimator benefits from a longer history.
Combinatorial Purged Cross-Validation (CPCV)¶
CPCV generates a combinatorial population of backtest paths from all possible selections of test folds, with purging and embargoing to prevent information leakage. Developed by Marcos Lopez de Prado, it provides a distribution of backtest performance rather than a single path.
from optimizer.validation import CPCVConfig
config = CPCVConfig(
n_folds=10, # non-overlapping temporal blocks
n_test_folds=8, # blocks per test set in each combination
purged_size=0, # observations purged at train/test boundary
embargo_size=0, # observations embargoed after each test block
)
| Field | Type | Default | Description |
|---|---|---|---|
n_folds |
int |
10 | Number of non-overlapping temporal blocks |
n_test_folds |
int |
8 | Blocks assigned to test set per combination |
purged_size |
int |
0 | Observations purged at each train-test boundary |
embargo_size |
int |
0 | Observations embargoed after each test block |
Presets¶
| Preset | n_folds | n_test_folds | Paths | Use Case |
|---|---|---|---|---|
for_statistical_testing() |
12 | 2 | C(12,2) = 66 | Significance testing with high statistical power |
for_small_sample() |
6 | 2 | C(6,2) = 15 | Shorter time series |
Purging and Embargoing¶
- Purging: Removes observations immediately adjacent to the train-test boundary to prevent information leakage from autocorrelated returns.
- Embargoing: Removes observations immediately following each test block to prevent the model from learning patterns that persist into the test period.
Multiple Randomized CV¶
Dual randomization across both temporal windows and asset subsets to test strategy robustness along both dimensions. Each trial randomly selects a time window and a subset of assets, then runs walk-forward validation within that subsample.
from optimizer.validation import MultipleRandomizedCVConfig
config = MultipleRandomizedCVConfig(
walk_forward_config=WalkForwardConfig(),
n_subsamples=10, # number of random trials
asset_subset_size=10, # assets drawn per trial
window_size=None, # None = full sample
random_state=42,
)
| Field | Type | Default | Description |
|---|---|---|---|
walk_forward_config |
WalkForwardConfig |
default | Inner walk-forward configuration |
n_subsamples |
int |
10 | Number of random trials |
asset_subset_size |
int |
10 | Assets drawn per trial |
window_size |
int or None |
None |
Temporal window length; None = full sample |
random_state |
int or None |
None |
Seed for reproducibility |
Preset¶
| Preset | n_subsamples | asset_subset_size | Use Case |
|---|---|---|---|
for_robustness_check(20, 10) |
20 | 10 | Standard robustness testing |
Running Cross-Validation¶
The run_cross_val function is the main entry point for executing cross-validation:
from optimizer.validation import WalkForwardConfig, run_cross_val
cv_config = WalkForwardConfig.for_quarterly_rolling()
cv_result = run_cross_val(pipeline, X, cv=cv_config, y=None, n_jobs=None)
When no cv argument is provided, run_cross_val defaults to quarterly rolling walk-forward validation.
Computing Optimal Folds¶
from optimizer.validation import compute_optimal_folds
n_folds = compute_optimal_folds(n_observations=1260, min_train=252, min_test=63)
Code Examples¶
Walk-forward backtest¶
from optimizer.optimization import MeanRiskConfig, build_mean_risk
from optimizer.pipeline import run_full_pipeline
from optimizer.validation import WalkForwardConfig
optimizer = build_mean_risk(MeanRiskConfig.for_max_sharpe())
result = run_full_pipeline(
prices=prices,
optimizer=optimizer,
cv_config=WalkForwardConfig.for_quarterly_rolling(),
)
# Out-of-sample performance
print(f"OOS Sharpe: {result.backtest.sharpe_ratio:.3f}")
print(f"OOS Max DD: {result.backtest.max_drawdown:.3f}")
CPCV for backtest overfitting detection¶
from optimizer.validation import CPCVConfig, build_cpcv, run_cross_val
cpcv = build_cpcv(CPCVConfig.for_statistical_testing())
population = run_cross_val(pipeline, X, cv=cpcv)
# population is a Population object — analyze distribution of paths
for path in population:
print(f"Path Sharpe: {path.sharpe_ratio:.3f}")
Robustness testing with randomized CV¶
from optimizer.validation import MultipleRandomizedCVConfig, build_multiple_randomized_cv
config = MultipleRandomizedCVConfig.for_robustness_check(
n_subsamples=20,
asset_subset_size=15,
)
cv = build_multiple_randomized_cv(config)
population = run_cross_val(pipeline, X, cv=cv)
Gotchas and Tips¶
Never use standard k-fold CV
Standard KFold or StratifiedKFold will randomly assign future data to the training set, creating look-ahead bias. Always use temporal validation methods for financial time series.
Default is quarterly rolling
run_cross_val() defaults to quarterly rolling walk-forward (test_size=63, train_size=252) when no cv is passed. This is a sensible default for daily equity returns.
CPCV returns Population, not MultiPeriodPortfolio
Walk-forward returns a MultiPeriodPortfolio (single path). CPCV returns a Population (collection of paths). Handle them differently when extracting metrics.
Purging is important for high-frequency data
For daily equity data, purged_size=0 is usually fine. For intraday data or when using features with look-ahead windows (e.g., rolling averages), increase purged_size to cover the window length.
Quick Reference¶
| Task | Code |
|---|---|
| Quarterly rolling backtest | WalkForwardConfig.for_quarterly_rolling() |
| Monthly rolling backtest | WalkForwardConfig.for_monthly_rolling() |
| Expanding window | WalkForwardConfig.for_quarterly_expanding() |
| CPCV statistical test | CPCVConfig.for_statistical_testing() |
| Robustness check | MultipleRandomizedCVConfig.for_robustness_check() |
| Run CV | run_cross_val(pipeline, X, cv=config) |
| Default CV | run_cross_val(pipeline, X) → quarterly rolling |