What is walk-forward optimisation?
Walk-forward simulates production: fit on history available at time t, trade forward unseen, roll t forward, repeat. The stitched out-of-sample segments — never the in-sample fits — form the performance record. Anything else is a backtest of the researcher's memory, not of the strategy.
Two window designs. Rolling: fixed-length training window slides forward (adapts to regime, forgets old data). Anchored: training start fixed, window grows (more data, slower adaptation). Rolling suits non-stationary markets; anchored suits short histories. Document the choice — it is a trial.
Re-fit cadence trades realism against cost: monthly re-fit of a daily strategy is standard; weekly for fast signals, quarterly for slow factors. Between re-fits the config is frozen — no peeking at the OOS segment to 'adjust' parameters.
How do you run an honest walk-forward?
Five rules. One: the training window ends before the OOS step begins, with an embargo gap (label horizon + buffer) so no training label sees OOS prices. Two: the parameter grid is fixed before the walk starts — expanding the grid mid-walk is adding trials. Three: exactly one config per window is selected by the in-sample criterion (no averaging top-3). Four: transaction costs apply in both train and test selection, or the selected config is the cheapest illusion. Five: the stitched curve compounds OOS segments in calendar order with no re-scaling.
Report per-window diagnostics, not just the final Sharpe: selected config per window (stability = same config re-selected), in-sample vs OOS per window (decay trajectory), and turnover per window (cost sensitivity). A strategy whose selected lookback flips every window is a different strategy each quarter — price that instability.
Minimum viable scale: at least 5–10 OOS steps covering more than one regime (e.g., 5 years of 6-month steps). Fewer steps make the stitched Sharpe a small-sample anecdote.
What is the reproducible code?
Generic walk-forward loop: user supplies a signal function over a parameter grid; the loop handles windowing, embargo, frozen-config trading, and stitching. Prints per-window picks and the stitched OOS Sharpe.
python# walk_forward.py # Walk-forward optimisation loop (Pardo 2008). # Run: python walk_forward.py import numpy as np import pandas as pd def sharpe(r: np.ndarray, annual_factor: float = 252.0) -> float: if len(r) < 2 or np.std(r) == 0: return 0.0 return float(np.sqrt(annual_factor) * np.mean(r) / np.std(r)) def momentum_signal(prices: pd.Series, lookback: int) -> pd.Series: """Long when price above trailing mean (skip most recent day).""" ma = prices.shift(1).rolling(lookback).mean() return (prices.shift(1) > ma).astype(float).fillna(0.0) def walk_forward( prices: pd.Series, grid: list[int], train_years: float = 3.0, oos_months: int = 6, embargo_days: int = 5, cost_bps: float = 2.0, ) -> pd.DataFrame: bars_per_year, bars_per_month = 252, 21 train_len = int(train_years * bars_per_year) oos_len = oos_months * bars_per_month rets = prices.pct_change().fillna(0.0) rows = [] start = train_len while start + oos_len <= len(prices): train = prices.iloc[start - train_len : start - embargo_days] oos = prices.iloc[start : start + oos_len] # In-sample grid search (costs included in selection). best_lb, best_sr, best_tr = grid[0], -np.inf, None for lb in grid: sig = momentum_signal(train, lb) tr = sig.shift(1).fillna(0.0) * train.pct_change().fillna(0.0) turnover = sig.diff().abs().fillna(0.0) tr -= turnover * cost_bps / 1e4 s = sharpe(tr.values) if s > best_sr: best_sr, best_lb, best_tr = s, lb, tr # Frozen config trades OOS unseen. sig_oos = momentum_signal( pd.concat([train.tail(max(grid) + 2), oos]), best_lb ).iloc[-len(oos) :] tr_oos = sig_oos.shift(1).fillna(0.0) * oos.pct_change().fillna(0.0) tr_oos -= sig_oos.diff().abs().fillna(0.0) * cost_bps / 1e4 rows.append( {"oos_start": oos.index[0], "lookback": best_lb, "is_sharpe": round(best_sr, 2), "oos_sharpe": round(sharpe(tr_oos.values), 2)} ) start += oos_len return pd.DataFrame(rows) if __name__ == "__main__": rng = np.random.default_rng(5) n = 252 * 8 dates = pd.date_range("2016-01-01", periods=n, freq="B") # Trending + noise regime (momentum should survive modestly). prices = pd.Series( 100 * np.exp(np.cumsum(0.0004 + rng.normal(0, 0.01, n))), index=dates ) report = walk_forward(prices, grid=[10, 20, 60, 120, 200]) print(report.to_string(index=False)) print(f"mean IS best: {report.is_sharpe.mean():.2f} " f"mean OOS: {report.oos_sharpe.mean():.2f}")
What mistakes does this prevent?
Selecting the grid by full-sample performance then 'validating' with walk-forward on the same history: the grid itself was fit to the OOS data. Fix: freeze the grid before the walk, or nest grid design inside an outer walk.
Ignoring costs in selection but deducting them in reporting: the walk then selects high-turnover configs that look brilliant gross and bleed net. Costs belong in the selection criterion.
Re-scaling stitched segments to equal volatility ex post. Real production compounds what happened; volatility targeting must be part of the frozen rule, applied causally, not a cosmetic overlay.
What are the honest limits?
Walk-forward is still history: it cannot simulate unprecedented regimes, capacity constraints at scale, or counterparty failure. It is the best pre-production estimate, not a guarantee — hence the paper-trading requirement after it.
Few OOS steps mean wide confidence bands. Five 6-month steps give a Sharpe estimate with ±0.5+ standard error; do not over-interpret small differences between configs on short walks.
Parameter stability is evidence, not proof: a config re-selected in 8 of 10 windows may still be the beneficiary of a persistent regime that ends tomorrow. Pair the walk with CPCV (procedure audit) and the DSR (significance audit).