Walk-forward testing explained, with a worked Python example
How walk-forward testing works, how to choose window sizes, and a runnable Python example showing how far in-sample results can mislead.
You've tuned a strategy on ten years of data and the equity curve looks lovely. The trouble is that you picked the settings by looking at those same ten years, so the backtest is partly a measure of how well you fitted the past. Walk-forward testing is the standard way to get a more honest number: you repeatedly choose settings on one stretch of history, then score them on the stretch that comes next, which you haven't looked at.
Below is how the method works, the choices you have to make, and a small Python example you can run yourself. The example uses fake prices with no edge in them at all, which turns out to be the most useful way to see what walk-forward testing catches.
Why one out-of-sample split isn't enough#
The simplest guard against overfitting is a hold-out: tune on the first seven years, test once on the last three. It's better than nothing, but it has known weaknesses. Bailey, Borwein, López de Prado and Zhu, in their paper on the probability of backtest overfitting, point out that the result depends heavily on which slice you hold out: "If one is unlucky, the chosen hold-out section may be the one that refutes a valid strategy or supports an invalid strategy", and "Different hold-outs are thus likely to lead to different conclusions." They also note that a hold-out doesn't account for how many variations you tried, and that it's quite likely the researcher has already used the hold-out data as part of their in-sample work.
Forecasting researchers make a similar point. Hewamalage, Ackermann and Bergmeir describe the single-split ("fixed origin") setup as giving only one forecast per step of the horizon for a single series, so the test period is short. One market regime can decide your verdict.
Walk-forward testing doesn't remove these problems, but it spreads the test across many periods instead of one, and it mimics what you'd actually do live: re-tune every so often and trade the next period with whatever you picked.
How walk-forward testing works#
The loop is short:
- Take a training window of history, say three years.
- Run your parameter search on that window only and pick the best settings.
- Apply those settings, unchanged, to the next block of data (the test window), say one year. Record the results.
- Slide both windows forward by the length of the test window and repeat.
- Stitch all the test-window results together. That stitched record is your walk-forward result.
The in-sample scores from step 2 are only there to choose settings. The number you judge the strategy on is the stitched out-of-sample record from step 5.
Machine-learning and forecasting libraries implement the same idea under other names. In scikit-learn it's TimeSeriesSplit, which exists because ordinary cross-validation "would lead to training on future data and evaluating on past data." In R, Rob Hyndman's tsCV function does it for forecasting models, moving the forecast origin forward through the series. The forecasting literature calls it rolling-origin evaluation or time series cross-validation; traders tend to say walk-forward.
The choices you have to make#
Expanding or rolling training window#
With an expanding (anchored) window, the training set always starts at the beginning of your data and grows each step. With a rolling window, the training set stays the same length and the oldest data drops off. scikit-learn's default is expanding: the docs note that "successive training sets are supersets of those that come before them", and you switch to a rolling window by setting max_train_size. In tsCV, the window argument does the same job.
Hewamalage and colleagues suggest the expanding window suits small datasets and short series, while the rolling window "removes the oldest data from training". For trading, a rolling window is a bet that old market behaviour is less relevant than recent behaviour. An expanding window is a bet that more data beats fresher data. Neither is right in general, and you shouldn't pick the one that happens to give the better backtest.
Window lengths#
The training window needs enough trades to make the parameter choice mean something. If a three-year window only produces a dozen trades, the "best" settings are mostly noise. The test window should be roughly how often you'd realistically re-tune live: monthly, quarterly or yearly. Shorter test windows give you more re-tuning steps but each step is noisier.
Write these choices down before you run anything. If you try five window lengths and report the best, you've added another layer of optimisation and you're back where you started.
A gap between training and test#
If your features or labels look ahead (for example, a label defined by the return over the next 20 days), the last rows of the training window overlap with the start of the test window. scikit-learn has a gap parameter for this: the "number of samples to exclude from the end of each train set before the test set." Use a gap at least as long as your longest look-ahead. More generally, Hewamalage and colleagues warn that data leakage, "the inadvertent use of data from the test set", is easy to introduce in time series work, for example by normalising or otherwise preprocessing the whole series before splitting it.
A worked example on prices with no edge#
Here's a complete example. It generates ten years of daily prices as a random walk with zero drift, so no strategy can have a genuine edge on it. Then it walk-forward tests a moving-average crossover (long when the fast average is above the slow one, flat otherwise) with a small grid of settings, a three-year rolling training window and a one-year test window. Trades are charged 5 basis points and the position is only taken on the bar after the signal, so there's no same-bar look-ahead.
import numpy as np
import pandas as pd
DAYS, TRAIN, TEST, WARMUP = 252 * 10, 252 * 3, 252, 200
COST = 0.0005 # 5 basis points each time the position changes
GRID = [(f, s) for f in (5, 10, 20, 50) for s in (50, 100, 150, 200) if f < s]
def sharpe(r):
return np.sqrt(252) * r.mean() / r.std() if r.std() > 0 else 0.0
def walk_forward(seed, verbose=False):
rng = np.random.default_rng(seed)
# A random walk with no drift: by construction there is no edge to find
returns = pd.Series(rng.normal(0, 0.01, DAYS))
prices = 100 * np.exp(returns.cumsum())
runs = {}
for fast, slow in GRID:
signal = (prices.rolling(fast).mean() > prices.rolling(slow).mean()).astype(float)
position = signal.shift(1).fillna(0) # trade on the next bar, not the same one
trades = position.diff().abs().fillna(0)
runs[(fast, slow)] = position * returns - trades * COST
is_scores, oos_pieces = [], []
for start in range(0, DAYS - TRAIN - TEST + 1, TEST):
train = slice(start + WARMUP, start + TRAIN) # skip bars before the slow average exists
test = slice(start + TRAIN, start + TRAIN + TEST)
best = max(GRID, key=lambda p: sharpe(runs[p].iloc[train]))
is_scores.append(sharpe(runs[best].iloc[train]))
oos_pieces.append(runs[best].iloc[test])
if verbose:
print(f"window {len(oos_pieces)}: picked {best}, "
f"in-sample {is_scores[-1]:.2f}, out-of-sample {sharpe(oos_pieces[-1]):.2f}")
return np.mean(is_scores), sharpe(pd.concat(oos_pieces))
walk_forward(7, verbose=True)
results = np.array([walk_forward(seed) for seed in range(200)])
print(f"200 series, average in-sample Sharpe of the chosen settings: {results[:, 0].mean():.2f}")
print(f"200 series, average stitched out-of-sample Sharpe: {results[:, 1].mean():.2f}")
The moving averages only ever use past prices, so computing them once over the whole series doesn't leak anything. The walk-forward loop only decides which slice is used for choosing and which for scoring.
Running it with NumPy 2.5 and pandas 3.0 gives this for one series (seed 7):
window 1: picked (50, 100), in-sample -0.01, out-of-sample 1.51
window 2: picked (50, 100), in-sample 0.90, out-of-sample -1.41
window 3: picked (50, 100), in-sample 0.22, out-of-sample 0.21
window 4: picked (50, 100), in-sample 0.11, out-of-sample -0.46
window 5: picked (20, 50), in-sample 0.28, out-of-sample -0.99
window 6: picked (5, 150), in-sample 0.39, out-of-sample -0.69
window 7: picked (5, 50), in-sample 0.22, out-of-sample -2.37
200 series, average in-sample Sharpe of the chosen settings: 0.41
200 series, average stitched out-of-sample Sharpe: -0.04
Reading the results#
Look at the last two lines first. Across 200 random-walk series, the settings picked in each training window had an average in-sample Sharpe ratio of about 0.4. Out of sample, the same settings averaged roughly zero, slightly negative after costs. That's the right answer, because there was nothing to find. An in-sample Sharpe of 0.4 came purely from choosing the best of 15 settings on noise, and the walk-forward record exposed it.
The single series shows the other things to look for. The in-sample and out-of-sample numbers barely relate to each other: window 2 had the best training score and one of the worst test scores, and window 1 was the reverse. The chosen settings also wander, from (50, 100) to (20, 50) to (5, 150) to (5, 50). When a real strategy behaves like this, with the best parameters jumping around between windows and out-of-sample results that look like coin flips, you're probably fitting noise.
What you'd hope to see on a strategy with a genuine edge is the opposite: out-of-sample results that are positive in most windows, an out-of-sample Sharpe that's lower than in-sample but not collapsed, and parameter choices that stay in the same neighbourhood from one window to the next.
What walk-forward testing can't do#
It can't fix bad inputs. Survivorship bias, look-ahead in your data, unrealistic fills and missing costs all flow straight through it.
It also doesn't protect you from yourself. If you run a walk-forward test, dislike the answer, change the strategy and run it again, the out-of-sample periods have stopped being out of sample. Do that twenty times and the stitched result is overfitted too, which is the same problem Bailey and co-authors raise about hold-outs that ignore the number of trials. Keep a count of how many variations you've tested and be more sceptical the higher it gets. Their paper proposes a way to estimate the probability that your selection process is overfitting, which is worth reading once you're comparing many candidates.
And it assumes the future will resemble the windows you tested on. A structural change in the market after your last test window is invisible to any backtest.
A good habit is to run your whole pipeline on synthetic data with no edge, like the example above, before you run it on real prices. If the pipeline reports a profitable strategy on a random walk, something in it is leaking information or quietly optimising, and you've found the bug before it cost anything.
This article is for education only and isn't financial advice. Trading involves risk of loss.
Sources
- The probability of backtest overfitting — David H. Bailey, Jonathan M. Borwein, Marcos López de Prado & Qiji Jim Zhu — The Journal of Computational Finance, 20(4) (2017) (checked 2026-10-18)
- TimeSeriesSplit — scikit-learn 1.9 documentation — scikit-learn developers (checked 2026-10-18)
- Time series cross-validation (tsCV) — forecast package reference — Rob J Hyndman (checked 2026-10-18)
- Forecast Evaluation for Data Scientists: Common Pitfalls and Best Practices — Hansika Hewamalage, Klaus Ackermann & Christoph Bergmeir — arXiv:2203.10716 (2022) (checked 2026-10-18)
Trading a strategy by hand?
If you have rules you already follow and want them automated, tell us about them. You define the strategy; we help automate it.