A strategy that scores a 3.1 Sharpe ratio in cross-validation and then delivers 0.4 in live trading is rarely a bad alpha. More often it is a leaking validation scheme. The model was never wrong — the test was.
This article shows exactly where sklearn.model_selection.KFold leaks future information in financial time series, and gives you a compact, production-ready Python implementation of purged K-fold cross-validation with embargo that plugs directly into GridSearchCV.
scikit-learn's KFold is built on one assumption: samples are independent and identically distributed. Market labels violate both halves of that assumption.
With a 10-day label horizon and 5-fold CV, a naive split leaks roughly one full fold of future prices into the training set. The model memorises the answer key, cross-validation reports a beautiful equity curve, and live trading hands you the tuition bill.
t is still correlated with test features starting at t+1.Marcos López de Prado's Advances in Financial Machine Learning formalises two boundaries that any honest financial validation must enforce.
Drop every training observation whose label interval overlaps the test interval. If label i spans [t_i0, t_i1] and the test set spans [T0, T1], the sample is purged whenever t_i1 >= T0 and t_i0 <= T1.
Purging removes overlap in the past direction. Embargo removes residual serial correlation in the future direction by discarding a buffer of training observations after the test window. A common default is 1% of the sample, floored at your label horizon.
| Boundary | What it removes | Typical size |
|---|---|---|
| Purge | Training labels resolving inside the test window | Label horizon (e.g. 10 bars) |
| Embargo | Training samples after the test window that inherit autocorrelation | 1%–2% of sample |
The class below is deliberately dependency-light: NumPy plus scikit-learn's BaseCrossValidator. It supports an explicit per-sample label horizon, so you can pass the real resolution time of each label instead of assuming a constant holding period.
import numpy as np
from sklearn.model_selection import BaseCrossValidator
class PurgedKFold(BaseCrossValidator):
"""
K-Fold with purging and embargo for overlapping financial labels.
label_end : array-like of int, optional
Bar index at which each sample's label resolves.
If None, horizon is assumed constant (in bars).
horizon : int
Constant label horizon in bars (used when label_end is None).
embargo_pct : float
Fraction of the sample discarded after each test fold.
"""
def __init__(self, n_splits=5, horizon=10, embargo_pct=0.01, label_end=None):
if n_splits < 2:
raise ValueError("n_splits must be >= 2")
self.n_splits = n_splits
self.horizon = horizon
self.embargo_pct = embargo_pct
self.label_end = None if label_end is None else np.asarray(label_end)
def split(self, X, y=None, groups=None):
n = len(X)
idx = np.arange(n)
fold = n // self.n_splits
embargo = int(n * self.embargo_pct)
ends = self.label_end if self.label_end is not None else idx + self.horizon
for k in range(self.n_splits):
t0 = k * fold
t1 = n if k == self.n_splits - 1 else (k + 1) * fold
test = idx[t0:t1]
# purge: drop training labels that resolve at or after test start
purged = idx[ends <= t0] if k > 0 else idx[:0]
# embargo: drop a buffer immediately after the test window
after = idx[min(n, t1 + embargo):]
train = np.concatenate([purged, after])
if len(train) == 0:
continue
yield train, test
def get_n_splits(self, X=None, y=None, groups=None):
return self.n_splits
Two details matter in production. First, purged is computed from label_end, not from the row index — that is what makes it correct for variable-horizon labels such as triple-barrier events. Second, the embargo is applied to the tail of the sample, so the last fold does not silently shrink.
Because the class implements get_n_splits and split, scikit-learn accepts it anywhere a cross-validator is expected:
from sklearn.model_selection import GridSearchCV
from sklearn.ensemble import RandomForestClassifier
cv = PurgedKFold(n_splits=5, horizon=10, embargo_pct=0.01)
search = GridSearchCV(
estimator=RandomForestClassifier(n_estimators=400, max_depth=4),
param_grid={"max_depth": [2, 3, 4, 6]},
cv=cv,
scoring="roc_auc",
n_jobs=-1,
)
search.fit(X, y)
print(search.best_score_, search.best_params_)
That single substitution is usually enough to remove the most damaging class of optimism. Expect your cross-validated score to fall. That fall is not a regression — it is the first honest number you have seen.
Never trust a validator you have not tried to break. Two cheap diagnostics:
Shuffle y while keeping row order fixed, then re-run the same CV. A leak-free pipeline scores near 0.5 AUC on shuffled labels. A pipeline that still scores 0.7 is reading the future through the split boundary.
Replace label_end with random values inside the sample. If the score improves, your purge logic is using the wrong index source.
horizon to the actual median label resolution time, not the intended holding period.embargo_pct to at least 1%, and never below your feature lookback divided by sample length.No. Walk-forward trains only on the past and tests forward, which is realistic but wasteful of data. Purged K-fold uses both sides of the test window while removing overlapping labels, which recovers sample efficiency but demands careful purge and embargo boundaries.
Yes, and you should once the basic version is stable. CPCV builds multiple backtest paths from the same folds, which gives a distribution of Sharpe ratios instead of a single point estimate. The purge and embargo logic is identical; only the fold generation changes.
It applies even more strongly. Intraday labels resolve in minutes, so the overlap fraction per fold is larger and the leak is proportionally more severe.
Then purging costs nothing and can be skipped, but embargo should still be applied because feature autocorrelation does not disappear just because labels are non-overlapping.
Skip the edge cases. The full framework ships the purged splitter, a label-overlap auditor that reports leakage percentage per fold, and a Scikit-Learn compatible interface tested against GridSearchCV and cross_val_score.