Why does random k-fold leak in finance?
Two mechanisms. First, label overlap: a 20-day forward-return label at day t shares 19 days with the label at day t+1. If one lands in train and the other in test, the model trains on the answer. Second, serial dependence: volatility clustering and microstructure noise make adjacent rows correlated, so even non-overlapping neighbours leak regime information.
The symptom is a validation score that will not reproduce: strong in CV, weak in walk-forward and live. The tell-tale diagnostic is the purge ablation — re-run CV with progressively wider purges; if the score decays as purge widens, the original score contained leakage.
Embargo handles the second mechanism. After removing overlapping labels (purge), also remove a buffer after each test block (embargo ≈ 1% of sample) so training rows adjacent to test boundaries — the most correlated ones — are excluded.
How do you size purge and embargo?
Purge width = maximum label horizon. With triple-barrier labels up to 20 days, purge 20 days on each side of every test boundary. With fixed 5-day forward labels, purge 5 days. When horizons vary per observation (barrier labels), purge per-observation using each row's actual label end time — the code below does exactly this.
Embargo ≈ 1% of the sample length is the standard starting point (López de Prado, 2018). Validate it: compute train/test feature correlation at increasing embargo widths; stop when marginal correlation flattens near zero. In highly autocorrelated series (realized vol), 2% may be needed; in daily equity returns, 0.5–1% usually suffices.
Folds should respect time order within the purged design: blocked splits (contiguous test blocks) preserve regime structure better than interleaved folds. Use 5–10 folds; fewer, larger test blocks give more stable per-fold estimates.
What is the reproducible code?
A per-observation purged splitter compatible with scikit-learn's cross_val_score. Provide label end times (when each row's label information ends); the splitter drops any training row whose label window intersects the test block, plus the embargo buffer.
python# purged_kfold.py # Purged k-fold with embargo (Lopez de Prado 2018, Ch.7). # Run: python purged_kfold.py import numpy as np import pandas as pd class PurgedKFold: """Blocked k-fold that purges overlapping labels + embargo. label_ends: Series indexed like X, value = timestamp when the row's label information ends (e.g. barrier touch time or t + horizon). """ def __init__(self, n_splits=5, embargo_pct=0.01): self.n_splits = n_splits self.embargo_pct = embargo_pct def split(self, X, label_ends: pd.Series): n = len(X) embargo = max(1, int(n * self.embargo_pct)) boundaries = np.linspace(0, n, self.n_splits + 1, dtype=int) idx = np.arange(n) for f in range(self.n_splits): test_start, test_end = boundaries[f], boundaries[f + 1] test_idx = idx[test_start:test_end] test_start_t = label_ends.index[test_start] test_end_t = label_ends.index[test_end - 1] # Purge: drop train rows whose label window covers the test block. purge = (label_ends >= test_start_t) & ( label_ends.index <= test_end_t ) train_idx = idx[~purge.values] # Embargo: drop train rows just after the test block. train_idx = train_idx[train_idx >= test_end + embargo] # Also drop train rows just before (symmetric leakage guard). train_idx = train_idx[ (train_idx < test_start - embargo) | (train_idx >= test_end + embargo) ] yield train_idx, test_idx if __name__ == "__main__": # Demo: 1000 days, 5-day labels, synthetic feature with mild signal. rng = np.random.default_rng(3) n = 1000 dates = pd.date_range("2020-01-01", periods=n, freq="B") X = pd.DataFrame({"f1": rng.normal(size=n)}, index=dates) label_ends = pd.Series(dates + pd.Timedelta(days=5), index=dates) for i, (tr, te) in enumerate(PurgedKFold(n_splits=5).split(X, label_ends)): print(f"fold {i}: train={len(tr):>4} test={len(te):>4}")
What mistakes does this prevent?
Feature selection before splitting (the classic): selecting features on the full sample then cross-validating the model leaks the test folds into the selection. Purge does not fix this — selection must happen inside each fold. The splitter above governs rows; the pipeline must still nest selection.
Standardization on full-sample statistics (mean/vol computed over all rows including test) is a subtler leak with the same fix: fit scalers on train folds only.
Hyperparameter search evaluated with random k-fold inherits the leakage bonus, so the search prefers leak-exploiting configs. Search under purged CV and the preference reverses toward genuinely robust configs.
What are the honest limits?
Purging costs data: wide purges on long-horizon labels can discard 10–30% of training rows per fold. That is the price of honesty — a smaller honest sample beats a larger contaminated one, but report the discard rate so readers can judge power.
Purged CV still evaluates fixed-horizon performance, not adaptive production behaviour (re-fitting, regime shifts). It is the model-selection gate; walk-forward is the production-simulation gate. A model should pass both.
Embargo width is a judgment call validated by correlation diagnostics, not a theorem. Document the width and the diagnostic; a purge/embargo choice without a diagnostic is decoration.