Quant for Free
Home / Guides / Walk-Forward Python
Out-of-sample validation

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:

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.

Pedagogy note: the sketch above is rewritten for this guide. A clear public walkthrough of the same SMA-on-SPY pattern (including full data plumbing and plots) is Walk-Forward Optimization in Python on Python And Trading (May 2026). Numbers quoted below from that article are labeled as such.

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.

Attributed example · Python And Trading (May 2026)

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

What walk-forward does not fix

Honest OOS stitching is necessary and not sufficient. After you have a walk-forward curve:

Have a walk-forward Sharpe and a trial count? See whether it clears the luck line.
Open the DSR calculator →

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.

Questions

What is walk-forward optimization?
It is a sequence of train/test windows that march forward in time. You pick parameters on each train window, freeze them, apply them to the next test window, and stitch only those test returns into the equity curve you report.
Does walk-forward prevent all overfitting?
No. It blocks parameter overfit for one strategy and one committed search. It does not block strategy shopping, post-hoc window tuning, omitted costs, or contaminated data. Pair it with trial logging, Deflated Sharpe, and stronger validators such as CPCV.
Rolling or anchored?
Rolling forgets old data on a fixed train length — better when you expect regime change. Anchored expands the train sample from a fixed start — more efficient if the edge is stable. Choose before you look at results.
Is this investment advice?
No. This is an educational guide on validation practice. It does not recommend any security, strategy, or trade.
A
ridingyo
Systematic-trading developer. Builds and validates MT4/MT5 expert advisors using López de Prado's validation methodology — DSR, PBO, purged walk-forward and CPCV. Writes the tools and guides at Quant for Free.
Read the methodology →
Educational content, not investment advice. The methods and attributed examples here describe research practice and statistical evaluation. They do not predict performance and are not a recommendation to buy, sell, or hold any instrument. Simulated results do not guarantee future outcomes. Source figures labeled as from Python And Trading are that article’s illustrations, not Quant for Free live results.