Walk-forward analysis
An edge that only exists on the data you fitted it to is not an edge — it is a memory. Walk-forward analysis is the discipline of always testing on data the strategy has never seen, in the order it actually arrived. This chapter covers in-sample vs out-of-sample, the danger of tuning on everything, rolling re-optimisation, and why purging and embargoing are non-negotiable when labels overlap.
The intuition is the one every teacher knows: if you let students see the exam before it counts, everyone scores 100 and the grade means nothing. A strategy tuned on the same data you then report is a student who already saw the exam. Walk-forward is the discipline of keeping a sealed exam — data the strategy has never touched — and grading only on that, in the order time actually delivered it. The number that survives is the one you can expect to repeat live; the tuned-on-everything number is the leaked exam.
In-sample vs out-of-sample#
Split your history at a date. Everything before is in-sample (IS) — the data you are allowed to look at, tune on, stare at. Everything after is out-of-sample (OOS) — data you touch exactly once, to measure. The moment you use the OOS block to make a decision, it becomes in-sample, and you have no clean test left.
edgekit gives you two cuts. oos_split makes a hard calendar cut into date-indexed IS/OOS slices; is_oos_splitmakes a positional chronological cut at a fraction — no shuffle, preserving time's arrow.
import edgekit as ek
import numpy as np
# calendar cut — measure IS -> OOS Sharpe degradation
in_sample, out_of_sample = ek.validation.oos_split(df, cut="2023-01-01")
# positional cut at a fraction (chronological, no shuffle)
a, b = ek.validation.is_oos_split(np.arange(100), frac=0.55) # len 55 / 45The danger of tuning on all the data#
Suppose you sweep a parameter over the whole history and pick the value with the best Sharpe. That Sharpe is now an in-sample number for the entire dataset — you have no data left that the choice did not see. This is the most seductive form of overfitting because it does not feel like cheating: you ran one honest backtest per value. But the selection used all the data, so the winner is partly fitted to noise, and its forward performance will disappoint by the amount of that noise.
The only cure is to make the selection itself out-of-sample: choose parameters on the past and measure on a future the choice could not have seen. That is walk-forward.
Walk-forward optimisation#
Walk-forward slides a train/test window through time. On each fold, fit or select the configuration on the training window, then apply it — unchanged — to the immediately following test window. Concatenate the test blocks and you have a fully out-of-sample track record built the way you would actually have traded it: decide on the past, live with it on the future, roll forward.

edgekit's walk_forward has two modes, selected by refit:
refit=False(the honest default) — split one strategy's returns intokconsecutive blocks and check each is positive. An edge should be spread across time, not concentrated in one lucky block.refit=True— expanding-window re-optimisation across a config matrix: pick the in-sample-best config up to each fold, apply it to the next. If the picked config drifts fold to fold, that instability is an overfit smell.
import edgekit as ek
# sequential blocks: is the edge positive in each period, not just overall?
out = ek.validation.walk_forward(daily_r, k=6, refit=False)
print(out["n_positive"], "of", out["k"], "blocks positive")
for b in out["blocks"]:
print(b["sharpe"], b["total"], b["positive"])
# expanding re-optimisation: does the picked config stay stable OOS?
wf = ek.validation.walk_forward(M, cfgs=list("abcdefgh"), k=6, refit=True)
print(wf["oos_sharpe"], wf["picks"], wf["stable"])Scenario. You run refit=True across six folds on a US100 ORB, letting each fold pick the best opening-range length from {15, 30, 45, 60}. Case A: the picks come back [30, 30, 45, 30, 30, 45]— the system keeps choosing ~30–45 minutes, so "a 30-minute range means something" is a real statement and the concatenated OOS Sharpe is trustworthy. Case B: the picks are [15, 60, 30, 60, 15, 45]— every fold crowns a different winner because it is chasing whatever fit that window's noise. Case B can still show a decent average OOS number, but the drifting picks tell you there is no stable parameter underneath — you are watching an overfit happen in slow motion.
picks. If every fold selects roughly the same configuration, the edge is stable and the parameter means something. If the choice jumps around, you are chasing noise — the walk-forward just made a slow-motion overfit visible.Purged and embargoed cross-validation#
A subtler leak appears when your labels overlap in time— which they always do in ML meta-labelling, where each trade's outcome resolves over a horizon of future bars. If a training label's outcome window overlaps the test window, information from the test period leaks backward into training. Standard k-fold cross-validation, which ignores time, leaks badly here.
Purging#
Purgefrom the training set any label whose outcome window overlaps the test set's time span. A label opened just before the test block but resolving inside it knows the test period's outcome — drop it.
Embargo#
Even after purging, serial correlation can leak across the train/test boundary. An embargois a gap — at least as long as the label horizon — inserted after the training window so no train label's outcome can resolve inside the test window:
This is the firewall. edgekit's ML stack builds it in: walk_forward_windows keeps a train label only if its exit_time falls at or before the embargoed train-window end, and PurgedKFold purges train labels whose horizon overlaps the validation fold plus an embargo tail.
import edgekit as ek
# rolling OOS windows with purge + embargo (embargo_hours >= label horizon)
wf = ek.ml.WalkForwardConfig(train_days=7, test_hours=8, embargo_hours=6)
for w in ek.ml.walk_forward_windows(labels, wf):
tr, te = labels.iloc[w["train_pos"]], labels.iloc[w["test_pos"]]
# fit on tr, predict te — no train outcome resolves inside the embargo
# purged k-fold for hyperparameter selection inside a training window
for train, val in ek.ml.PurgedKFold(n_splits=4, embargo_frac=0.02).split(labels):
...embargo_hours≥ your label horizon and never shorten it to "get more training data".What a passing walk-forward looks like#
| Signal | Healthy | Overfit |
|---|---|---|
| OOS blocks positive | most of k blocks positive | one block carries everything |
| IS -> OOS Sharpe | modest degradation | collapse (e.g. 1.5 -> 0.1) |
| Refit config picks | stable across folds | jumps fold to fold |
| Bad-regime block | small profit or small loss | large loss it never saw in-sample |
Some IS→OOS degradation is normal and expected — that is the edge-decay the honest forward projection accounts for. A collapse, or an edge that lives in a single block, is the tell that you fitted noise.
Next: Overfitting detection — parameter plateaus, PBO via CSCV, and the deflated Sharpe that corrects for how many times you tried.
