Walk-forward optimization in pure Python
Full-sample parameter search tells you which settings fit this history best. Walk-forward asks a harder question: would the settings you would have chosen at each point in time have worked on the next slice you had not seen yet? Here is how to build that loop with pandas and numpy — and the ways people quietly break it.
The backtest that lied
Pick a tunable rule — say a fast/slow SMA crossover — download a long price series, sweep a small grid, keep the Sharpe champion, and report that Sharpe as “the strategy.” That last step is the lie. You did not measure a strategy; you measured the maximum of many correlated trials on one sample. On unseen data the same parameters almost always shrink.
The rule that ends the lie is simple to state and easy to violate: never score a strategy on the same bars you used to choose its parameters. Walk-forward is the workmanlike way to enforce that rule across a whole history instead of a single holdout you will reuse until it is in-sample.
For the broader diagnosis of why flattering backtests are the default, see the companion guide on backtest overfitting. This page is the hands-on loop.
Walk-forward in plain English
Slice the timeline into a marching sequence of (train, test) pairs:
|= train 1 =|= test 1 =|
|= train 2 =|= test 2 =|
|= train 3 =|= test 3 =|
...
On each train window you optimize (grid, Bayesian search, whatever you committed to beforehand). Freeze the winner. Apply those frozen parameters to the next test window. Only the concatenated test returns form the equity curve you are allowed to believe.
Two common flavors:
- Rolling. Train length is fixed; the window slides. Older data is forgotten — closer to how a discretionary trader adapts when regimes change.
- Anchored (expanding). Train start is fixed; the window grows. More statistically efficient if you believe the edge is stable across decades.
Neither is “more correct” in the abstract. What is incorrect is choosing rolling vs anchored — or the train/test lengths — by peeking at which setting prints the prettier final curve. Decide a priori.
A pure-Python sketch
You do not need a backtest framework for the idea. The pattern below uses only pandas and numpy: a returns function, a score, and a loop that never lets test bars enter the parameter search.
Strategy as a pure function — yesterday’s signal times today’s return (no look-ahead):
def sma_crossover_returns(close, log_ret, fast, slow):
sma_f = close.rolling(fast).mean()
sma_s = close.rolling(slow).mean()
signal = (sma_f > sma_s).astype(int)
return signal.shift(1) * log_ret # position from prior close
Score — annualized Sharpe of daily log-returns, with a floor so empty or flat windows do not win by accident:
def sharpe(r):
r = r.dropna()
if len(r) < 20 or r.std() == 0:
return float("-inf")
return (252 ** 0.5) * r.mean() / r.std()
The walk-forward core (rolling windows; knobs fixed before you look at results):
def walk_forward(close, log_ret, grid, train_years=5, test_months=6):
train_n = train_years * 252
test_n = test_months * 21
oos = pd.Series(index=log_ret.index, dtype="float64")
log_rows = []
start = train_n
lookback = max(slow for _, slow in grid)
while start + test_n <= len(log_ret):
train = slice(start - train_n, start)
test = slice(start, start + test_n)
best, best_s = None, float("-inf")
for params in grid:
s = sharpe(sma_crossover_returns(
close.iloc[train], log_ret.iloc[train], *params))
if s > best_s:
best, best_s = params, s
# warm SMAs from train history; score only the test slice
warm = slice(start - lookback, start + test_n)
r = sma_crossover_returns(
close.iloc[warm], log_ret.iloc[warm], *best).iloc[lookback:]
oos.iloc[test] = r.values
log_rows.append({"params": best, "is_sharpe": best_s,
"test_end": log_ret.index[start + test_n - 1]})
start += test_n
return oos.dropna(), pd.DataFrame(log_rows)
Keep the grid modest and identical to whatever you would have used in a naive full-sample sweep — otherwise you are not comparing honesty to the trap; you are comparing two different searches.
Two details that decide whether the loop is honest
Warm-up lookback
A slow SMA of length 200 needs 200 prior closes before it exists. If you compute indicators only inside the test window, you throw away the first ~200 bars of every OOS segment — or worse, you silently warm them with test-period prices in a way that is easy to get wrong. The fix is to include a lookback into the already-known train prices when building signals for the test window. Prediction at day t still uses only information available at t; you are not leaking future returns into the score.
Shift by one
Today’s position must be decided from yesterday’s close (or earlier). Forget the lag once and your “walk-forward” is an elaborate look-ahead bias with extra steps. Non-negotiable.
Reading the overfitting tax
Stitch the OOS returns into an equity curve. Overlay the curve you would have gotten by applying the full-sample champion parameters to the same OOS dates. The gap between those two curves is the overfitting tax — what you were silently paying every time you trusted an in-sample sweep.
SMA crossover grid on SPY
On a 16-cell fast/slow grid, that article reports a full-sample (in-sample) Sharpe somewhere around 0.6–0.8, and for their SPY run with 5-year train / 6-month test windows, an in-sample figure near 0.7 against a walk-forward result that usually lands between 0.2 and 0.4. Those are the article’s illustrative figures — not a Quant for Free backtest, and not a claim about live trading.
LessonA halved Sharpe after walk-forward is not a bug in the method. It is the method working. If the honest curve still looks usable, then — and only then — you graduate to costs, trial-count deflation, and harder validators.
Parameter stability — the diagnostic that matters most
A middling walk-forward Sharpe with stable parameter picks across adjacent windows is more believable than a high Sharpe whose optimizer jumps to a new pair every re-optimization. White-noise parameter paths mean the grid is hunting noise for a test length that is too short or a search that is too aggressive.
Plot the chosen fast and slow (or whatever knobs you have) against each test-window end date. What you want: long flat stretches with occasional changes. What you do not want: a different winner every window. If it looks like white noise, lengthen train, shrink the grid, or accept that this rule has no robust edge on this series.
Pitfalls that quietly ruin walk-forwards
- Window-length p-hacking. Tuning
train_yearsandtest_monthsby the final equity curve is overfitting one level up. Lock the knobs before you run. - No transaction costs. Crossovers flip often. The same Python And Trading article notes that even a 5 bps round-trip can knock on the order of 30% off an apparent Sharpe in that SMA setting — subtract cost × |Δsignal| and re-run before you celebrate.
- Survivorship and look-ahead in the tape. Less acute for a single liquid ETF; decisive for stock universes. Use only information that existed as of each rebalance.
- Multiple testing across strategies. Walk-forward protects parameter overfit inside one idea. If you try ten ideas and keep the best WFO Sharpe, you are again selecting the maximum of N. Log every trial and deflate.
- Thin test windows. A six-month daily window is only on the order of ~126 returns (as that article notes). Sharpe on one segment is noisy. Stack many windows — and never trust a single segment in isolation.
What walk-forward does not fix
Honest OOS stitching is necessary and not sufficient. After you have a walk-forward curve:
- Count every variant you tried (including abandoned ones) and run it through the Deflated Sharpe Ratio calculator.
- Put the idea through the broader checklist in the validation gauntlet (costs, multiple testing, purged splits).
- When labels overlap in time, prefer purged/embargoed splits and combinatorial purged cross-validation (CPCV) so you get a distribution of OOS outcomes, not one number.
- For a research workflow that wires these pieces together, see methodology.
A pre-trust checklist
Before believing a walk-forward result — mine or anyone else’s — I want all of these to be true:
- Train/test lengths and rolling vs anchored were fixed before seeing the equity curve.
- Parameters are chosen only on train; only test returns enter the reported curve.
- Indicator warm-up uses past (train) prices; signals are lagged so there is no look-ahead.
- Costs are modelled; the result still survives net of friction.
- Parameter paths across windows are inspected for stability, not only the average Sharpe.
- The trial count across strategies goes into DSR / PBO — WFO alone is not a free pass.