Purged K-Fold Cross-Validation in Python: Killing Look-Ahead Bias in Financial Machine Learning

Published 2026-10-22 · Author: Liziyong Li · Category: Financial ML Validation · Reading time: 9 min

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.

1. Why standard K-Fold breaks on price data

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.

Three distinct leaks, one symptom

  1. Label overlap — training labels resolve inside the test window.
  2. Feature-window bleed — a training feature vector ending at t is still correlated with test features starting at t+1.
  3. Post-test contamination — the first training samples after the test fold inherit serial correlation from the test period itself.

2. Purge and embargo: the minimised fix

Marcos López de Prado's Advances in Financial Machine Learning formalises two boundaries that any honest financial validation must enforce.

Purging

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.

Embargo

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.

BoundaryWhat it removesTypical size
PurgeTraining labels resolving inside the test windowLabel horizon (e.g. 10 bars)
EmbargoTraining samples after the test window that inherit autocorrelation1%–2% of sample

3. A drop-in Python implementation

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.

4. Wiring it into GridSearchCV

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.

5. Verifying that the leak is actually gone

Never trust a validator you have not tried to break. Two cheap diagnostics:

Label permutation test

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.

Shuffled-horizon test

Replace label_end with random values inside the sample. If the score improves, your purge logic is using the wrong index source.

Practical defaults that survive contact with live trading

6. Frequently asked questions

Is purged K-fold the same as walk-forward?

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.

Can I use combinatorial purged cross-validation (CPCV)?

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.

Does this apply to intraday bars?

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.

What if my labels never overlap?

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.

Get the production-ready validator

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.

Get Purged Validator — $39 → Browse all quant assets

Related reading