Walk-forward analysis
Rolling optimise-then-test windows that mimic how you would actually run a strategy over time — the gold standard for honest parameter selection.
A single train/test split has two weaknesses. It uses only one arbitrary cut point, and it tunes the parameters *once* and assumes they stay optimal forever. But markets drift — the right lookback for 2019 may be wrong for 2023. Walk-forward analysis fixes both by doing what you would actually do in real life: periodically re-tune on recent history, then trade the next stretch, then re-tune, and so on.
The rolling window idea
You slide a pair of windows across history. An in-sample (optimise) window where you pick the best parameters, immediately followed by an out-of-sample (test) window where you trade those parameters *without changing them*. Then you roll both windows forward and repeat. Crucially, you only ever keep the results from the out-of-sample windows — stitched together, they form one continuous equity curve made entirely of decisions taken with no knowledge of their own future.
The out-of-sample slices — TEST 1, TEST 2, TEST 3, TEST 4 — are joined end to end into a single walk-forward equity curve. This is the closest thing to a time machine you get: it simulates having actually run and periodically re-tuned the strategy through history, and every trade in it was taken blind to what came next.
Anchored vs rolling
- Rolling (sliding) window: the optimise window is a fixed length (say 2 years) that slides forward, always forgetting the oldest data. Best when you believe recent behaviour matters most and the market regime changes.
- Anchored (expanding) window: the optimise window always starts at the beginning and grows, so it never forgets. Best when you believe more data is simply better and the underlying edge is stable.
Walk-forward in code
import numpy as np, pandas as pd
df = load_prices() class="c"># long, time-ordered price history
opt_len = class="n">500 class="c"># in-sample bars to optimise on
test_len = class="n">125 class="c"># out-of-sample bars to then trade
param_grid = range(class="n">10, class="n">200) class="c"># candidate lookbacks
def sharpe(data, lookback):
ma = data[class="s">'close'].rolling(lookback).mean()
sig = (data[class="s">'close'] > ma).astype(int).shift(class="n">1) class="c"># lagged — no peeking
strat = sig * data[class="s">'close'].pct_change()
return strat.mean() / (strat.std() + 1e-class="n">9) * np.sqrt(class="n">252), strat
oos_returns = [] class="c"># we ONLY keep out-of-sample returns
chosen = []
start = class="n">0
while start + opt_len + test_len <= len(df):
in_sample = df.iloc[start : start + opt_len]
out_sample = df.iloc[start + opt_len : start + opt_len + test_len]
class="c"># class="n">1. optimise on the in-sample window
best_lb = max(param_grid, key=lambda lb: sharpe(in_sample, lb)[class="n">0])
chosen.append(best_lb)
class="c"># class="n">2. trade that FROZEN parameter on the next, unseen window
_, oos = sharpe(out_sample, best_lb)
oos_returns.append(oos)
start += test_len class="c"># roll forward by one test window
class="c"># Stitch the out-of-sample pieces into one honest equity curve.
wf = pd.concat(oos_returns).fillna(class="n">0)
equity = (class="n">1 + wf).cumprod()
wf_sharpe = wf.mean() / (wf.std() + 1e-class="n">9) * np.sqrt(class="n">252)
print(fclass="s">"Walk-forward OOS sharpe: {wf_sharpe:.2f}")
print(fclass="s">"Parameter chosen each window: {chosen}")Two things to study in that output. The wf_sharpe is your *real* expectation — it is built only from out-of-sample trades, so it is not inflated by fitting. And chosen — the parameter picked in each window — is quietly one of the most useful diagnostics you have.
Walk-forward tells you whether your parameters generalise through time. The final lesson tackles the other question: given that any single backtest is just one roll of the dice, how good or bad could the outcome realistically have been — and is your performance a stable plateau or a fragile spike?