Time Series Cross-Validation: Purged K-Fold and Embargo
Financial data has serial correlation that destroys standard K-fold cross-validation. Purged K-fold CV with embargo gaps fixes this — here's how it works.
Standard cross-validation assumes your samples are independent. Financial data laughs at this assumption — prices today correlate with prices tomorrow, and a backtest that trains on future data to predict the past will look incredible until it meets real money.
Purged K-fold CV fixes this. It introduces two mechanisms: purging removes training samples near test boundaries to prevent information leakage, and embargo adds a gap after each test set to account for the persistence of alphas.
The Core Problem
Say you have 1,000 days of returns data and you want to validate a trading strategy. If you split into 5 folds naively — Fold 1 trains on days 1-800, tests on 801-850; Fold 2 trains on days 1-850, tests on 851-900 — you’ve got two problems:
- Look-ahead bias: Training data at day 850 might include information (earnings, macro data) that wasn’t publicly available until day 851.
- Train-test overlap: The model trained on days 1-850 has seen day 800, which is adjacent to the test set at 801. If your features encode any momentum or mean-reversion, the adjacency leaks information.
Standard K-fold doesn’t account for the autocorrelation structure in financial time series. The result is CV estimates that are optimistically biased — your strategy looks better in backtest than it will live.
Purged K-Fold: The Mechanism
Purging removes a buffer zone of samples immediately before and after each test set. If your test set is days 801-850, you purge days n_train before and after — where n_train is the number of samples it takes for information to fully absorb into prices.
Training set: days 1-800
Purged gap: days 801-(800+n_purge) ← removed from training
Test set: days (801+n_purge)-(850+n_purge)
Embargo gap: days (851+n_purge)-(850+n_embargo) ← removed from both
Next fold: starts after embargo
The math for choosing purge and embargo sizes comes from the feature half-life. If your alpha signal has a decay time constant τ, you want n_purge ≥ τ and n_embargo ≥ 2τ for conservative estimates.
Marcos López de Prado’s Advances in Financial Machine Learning (Ch. 7) derives the formal conditions: purged gaps should satisfy |t_i - s_j| > h where h is the memory parameter of the stochastic process.
Embargo: Why Just Purging Isn’t Enough
Purging handles the boundary. But financial data has serial correlation that extends beyond the immediate boundary — if Apple crashes 20% on day T because of an earnings surprise, the market is still digesting that information on days T+1, T+2, T+3.
Embargo adds a gap after each test set. The model does NOT train on samples from the embargo period. This is especially important for:
- High-frequency strategies (tick data has autocorrelation at millisecond scales)
- Event-driven strategies (earnings, macro releases)
- Strategies with slow-moving averages (MA crossover will show correlation across days)
A reasonable default: embargo = 2× the test set length. If your test set is 50 days, embargo 100 days after it.
The Formulas
Number of test samples per fold:
Where T is total samples, k is number of folds, n_embargo is embargo size.
Effective training size for fold i:
CV estimate of out-of-sample performance:
Where is the mean return in fold i’s test set.
Python Implementation
import numpy as np
import pandas as pd
from sklearn.model_selection import KFold
def purged_kfold(X, y, k=5, pct_embargo=0.01, n_purge=None):
"""
Purged K-fold cross-validation for financial time series.
Parameters
----------
X : array-like, shape (n_samples, n_features)
Feature matrix
y : array-like, shape (n_samples,)
Labels or returns
k : int
Number of folds
pct_embargo : float
Percentage of samples to embargo after each test set
n_purge : int, optional
Number of samples to purge before/after test set.
If None, defaults to sqrt(n_samples)
Returns
-------
train_indices, test_indices : list of tuples
"""
n_samples = len(X)
n_embargo = int(n_samples * pct_embargo)
if n_purge is None:
n_purge = int(np.sqrt(n_samples))
n_per_fold = (n_samples - n_embargo) // k
fold_indices = []
for i in range(k):
test_start = i * n_per_fold + n_purge
test_end = test_start + n_per_fold
train_end = test_start - n_purge
train_start = 0
embargo_end = min(test_end + n_embargo, n_samples)
train_idx = list(range(train_start, train_end))
test_idx = list(range(test_start, test_end))
fold_indices.append((train_idx, test_idx))
return fold_indices
def apply_purged_cv(model, X, y, k=5, pct_embargo=0.01):
"""
Apply purged K-fold and return OOS predictions.
"""
folds = purged_kfold(X, y, k=k, pct_embargo=pct_embargo)
oos_preds = np.zeros(len(y))
oos_mask = np.zeros(len(y), dtype=bool)
for train_idx, test_idx in folds:
X_train, X_test = X[train_idx], X[test_idx]
y_train = y[train_idx]
model_clone = clone(model)
model_clone.fit(X_train, y_train)
oos_preds[test_idx] = model_clone.predict(X_test)
oos_mask[test_idx] = True
return oos_preds[oos_mask], oos_mask[oos_mask]
How to Read Results
A purged K-fold CV gives you an unbiased estimate — but only if you interpret it correctly:
Mean OOS return tells you the strategy’s average performance across folds. If it’s negative, stop. If it’s positive but volatile, the strategy is likely regime-dependent.
Sharpe ratio from OOS returns (not OOS mean return) is the right metric. A mean return of 0.1% daily with std of 0.5% gives a Sharpe of ~0.14 annualized — mediocre, not impressive.
Fold-by-fold breakdown matters more than the average. If Fold 1 returns 2.0% and Fold 2 returns -1.8%, your strategy has regime risk that the average hides. Report the min and max fold returns alongside the mean.
Common Pitfalls
Using pct_embargo=0: The most common mistake. If you’re not embargoing, you’re not doing time-series CV. Even 1% is better than 0%.
Purge size too small: n_purge = sqrt(n) is a rule of thumb. If your signal has slow decay (e.g., MA(200)), use a larger purge. The cost is efficiency — you lose training samples — but it’s better than biased estimates.
Reporting CV Sharpe but trading something else: If your live strategy rebalances daily but your CV trains on monthly data, the CV is measuring the wrong thing. CV methodology must match trading methodology.
Ignoring the embargo gap in backtest: If you embargo 100 days after each test fold, your walk-forward backtest should also have gaps between the in-sample optimization window and out-of-sample test. Most practitioners skip this and get optimistic bias.
Testing on too few folds: k=5 is standard for 1,000+ samples. If you use k=10 on 500 samples, each test set has only 50 samples — too noisy to be meaningful.
Where It Fits
Purged K-fold is the foundation for everything else in this section. Combinatorial Purged CV extends it to combinatorial enumeration of train-test splits. The Deflated Sharpe Ratio uses purged K-fold OOS returns as input. PBO (Probability of Backtest Overfitting) uses CPCV (combinatorial purged CV) as its core methodology.
If you’re validating ANY strategy that trains on historical data to predict future returns, you need purged K-fold or its variants. There’s no shortcut — standard K-fold will lie to you.
A production validation gate like ours runs purged K-fold as a baseline check before accepting any strategy into a portfolio. The purge and embargo parameters are calibrated to the strategy’s rebalancing frequency.
References
- López de Prado, Advances in Financial Machine Learning, Ch. 7. Wiley, 2018. ISBN: 978-1-119-48208-6.
- Hastie, Tibshirani, Friedman, The Elements of Statistical Learning, Ch. 7. Springer, 2009. ISBN: 978-0-387-84857-0.
- Bailey, Borwein, López de Prado, “Stock Return Data: A Non-Factorial Analysis,” Journal of Computational Finance, 2017.