Code Walkthrough · Validation · Wave 2

Walk-Forward Split in Python: Rolling and Anchored IS/OOS Windows

A backtest that looks good on your full dataset tells you very little if you chose the parameters after seeing the data. Walk-forward analysis (WFA) forces parameter selection to happen on one slice of data (in-sample) and measures performance on a fresh, non-overlapping slice (out-of-sample). This walkthrough implements both rolling and anchored WFA in ~40 lines of pandas and vectorbt on the SPY SMA strategy.

Versions: vectorbt 1.0.0 · yfinance 1.4.1 · pandas 2.3.3 · run 2026-06-06
Ticker: SPY · Range: 2015-01-01 to 2024-12-31 (fixed)

← Code Walkthroughs · First Bot · SMA Backtest · Reading Stats · Paper Order

walk_forward_split.py
"""AlgoDrill — Walk-forward split in pandas + vectorbt.

Rolling in-sample (IS) / out-of-sample (OOS) windows for the
SPY SMA crossover strategy. Demonstrates rolling vs anchored WFA
and why IS-optimized parameters often degrade on fresh OOS data.

~40 lines of core logic; one full WFA pass: 3 rolling windows.
Each window: grid-search fast/slow on IS, apply best to OOS.

Versions: vectorbt 1.0.0 · yfinance 1.4.1 · pandas 2.3.3 (run 2026-06-06)
Install:  pip install vectorbt yfinance pandas
Ref:      Pardo, "Design, Testing, and Optimization of Trading Systems" (1992)
"""
import vectorbt as vbt

TICKER = "SPY"
START  = "2015-01-01"
END    = "2024-12-31"
FEES   = 0.0005           # 5 bps per side
PARAMS = [(5, 20), (10, 50), (20, 100)]   # (fast, slow) grid


def backtest(price, fast, slow):
    """Return (total_return_pct, sharpe, max_drawdown_pct) for one run."""
    fa = vbt.MA.run(price, fast)
    sl = vbt.MA.run(price, slow)
    pf = vbt.Portfolio.from_signals(
        price,
        entries=fa.ma_crossed_above(sl),
        exits=fa.ma_crossed_below(sl),
        init_cash=10_000, fees=FEES, freq="d",
    )
    return (
        round(float(pf.total_return()) * 100, 1),
        round(float(pf.sharpe_ratio()), 2),
        round(float(pf.max_drawdown()) * 100, 1),
    )


def best_params(price):
    """Grid-search; return (fast, slow) with highest IS Sharpe."""
    results = [(backtest(price, f, s)[1], f, s) for f, s in PARAMS]
    return max(results)[1:]   # fast, slow


# ── Fetch once, slice into windows ───────────────────────────────────────────
data  = vbt.YFData.download(TICKER, start=START, end=END)
close = data.get("Close")

# ── Rolling WFA: 4-year IS, 2-year OOS ───────────────────────────────────────
roll_windows = [
    ("2015-2018", "2019-2020", "2015-01-01", "2018-12-31", "2019-01-01", "2020-12-31"),
    ("2017-2020", "2021-2022", "2017-01-01", "2020-12-31", "2021-01-01", "2022-12-31"),
    ("2019-2022", "2023-2024", "2019-01-01", "2022-12-31", "2023-01-01", "2024-12-31"),
]

print("=== Rolling WFA: SPY SMA — 4yr IS / 2yr OOS / 5 bps fees ===")
print(f"{'IS':^11}  {'OOS':^11}  {'Params':^10}  {'IS Sharpe':>9}  {'OOS Ret':>8}  {'OOS Sharpe':>10}  {'OOS MaxDD':>9}")
print("-" * 80)

for is_l, oos_l, is0, is1, oos0, oos1 in roll_windows:
    isp  = close.loc[is0:is1]
    oosp = close.loc[oos0:oos1]
    f, s = best_params(isp)
    is_ret, is_sr, _         = backtest(isp, f, s)
    oos_ret, oos_sr, oos_mdd = backtest(oosp, f, s)
    print(f"  {is_l:<9}  {oos_l:<9}   f={f:>2}/s={s:>3}    {is_sr:>9.2f}  {oos_ret:>8.1f}%"
          f"  {oos_sr:>10.2f}  {oos_mdd:>9.1f}%")

print()

# ── Anchored WFA: IS always starts from 2015-01-01 ───────────────────────────
anch_windows = [
    ("2015-2018", "2019-2020", "2015-01-01", "2018-12-31", "2019-01-01", "2020-12-31"),
    ("2015-2020", "2021-2022", "2015-01-01", "2020-12-31", "2021-01-01", "2022-12-31"),
    ("2015-2022", "2023-2024", "2015-01-01", "2022-12-31", "2023-01-01", "2024-12-31"),
]

print("=== Anchored WFA: IS always starts 2015 (growing window) ===")
print(f"{'IS':^11}  {'OOS':^11}  {'Params':^10}  {'IS Sharpe':>9}  {'OOS Ret':>8}  {'OOS Sharpe':>10}  {'OOS MaxDD':>9}")
print("-" * 80)

for is_l, oos_l, is0, is1, oos0, oos1 in anch_windows:
    isp  = close.loc[is0:is1]
    oosp = close.loc[oos0:oos1]
    f, s = best_params(isp)
    is_ret, is_sr, _         = backtest(isp, f, s)
    oos_ret, oos_sr, oos_mdd = backtest(oosp, f, s)
    print(f"  {is_l:<9}  {oos_l:<9}   f={f:>2}/s={s:>3}    {is_sr:>9.2f}  {oos_ret:>8.1f}%"
          f"  {oos_sr:>10.2f}  {oos_mdd:>9.1f}%")

Script Output

=== Rolling WFA: SPY SMA — 4yr IS / 2yr OOS / 5 bps fees ===
    IS           OOS        Params    IS Sharpe   OOS Ret  OOS Sharpe  OOS MaxDD
--------------------------------------------------------------------------------
  2015-2018  2019-2020   f=10/s= 50         0.59      11.5%        0.58      -15.1%
  2017-2020  2021-2022   f= 5/s= 20         1.39      11.2%        0.61      -16.1%
  2019-2022  2023-2024   f= 5/s= 20         1.33      29.1%        1.77       -7.5%

=== Anchored WFA: IS always starts 2015 (growing window) ===
    IS           OOS        Params    IS Sharpe   OOS Ret  OOS Sharpe  OOS MaxDD
--------------------------------------------------------------------------------
  2015-2018  2019-2020   f=10/s= 50         0.59      11.5%        0.58      -15.1%
  2015-2020  2021-2022   f= 5/s= 20         0.96      11.2%        0.61      -16.1%
  2015-2022  2023-2024   f= 5/s= 20         0.86      29.1%        1.77       -7.5%

Line-by-Line Walkthrough

Lines 1–18: Setup and the backtest helper

The script defines two functions. backtest(price, fast, slow) runs a single SMA crossover on a price slice and returns total return, Sharpe, and max drawdown — the same helper pattern from the hero walkthrough. best_params(price) runs a 3-point grid search over (5,20), (10,50), and (20,100) parameter pairs, selects the pair with the highest IS Sharpe, and returns that pair. This is the IS optimization step.

The grid is intentionally small — 3 combinations, not 300. A larger grid would find better IS fits, but would also increase the chance that the chosen parameters are overfitted to IS noise. The relationship between grid size and overfitting risk is the fundamental tension in parameter optimization; the Backtesting Pitfalls guide covers this in depth, including the Deflated Sharpe Ratio adjustment that accounts for multiple-testing inflation.

Lines 44–60: Rolling WFA windows

The three rolling windows are defined as tuples: IS label, OOS label, IS start date, IS end date, OOS start date, OOS end date. For each window, the script slices close to the IS period, finds the best parameters, then runs the same parameters on the OOS slice. The IS slice and OOS slice never overlap — this is the structural guarantee that prevents look-ahead bias.

Reading the rolling results:

Lines 62–79: Anchored WFA

The anchored variant always starts IS from 2015-01-01. The IS window grows forward: window 1 uses 4 years of IS data, window 2 uses 6 years, window 3 uses 8 years. The OOS windows are identical to the rolling version, so you can compare directly.

Rolling vs Anchored difference: Window 2 shows the contrast clearly. Rolling WFA chose 5/20 parameters from a 2017–2020 IS window with IS Sharpe 1.39. Anchored WFA also chose 5/20 parameters from a 2015–2020 IS window, but the IS Sharpe was only 0.96 — because the longer IS window (2015–2020) averaged out the 2017–2020 burst. The OOS result was identical (11.2% return, 0.61 Sharpe) since both chose the same best parameters. The IS Sharpe difference (1.39 vs 0.96) matters when you use IS Sharpe as a deployment threshold: a rolling WFA might have a deceptively high IS hurdle that inflates confidence.

The IS-to-OOS Gap

Across all windows, IS Sharpe averaged 1.10. OOS Sharpe averaged 0.99. The average degradation was about 10% — moderate for a 3-combination grid on a simple SMA strategy. Real parameter sweeps over 50–500 combinations on the same data will show larger IS-to-OOS gaps because more combinations give the optimizer more chances to find parameters that fit IS noise.

Robert Pardo’s heuristic (from Design, Testing, and Optimization of Trading Systems, 1992) suggests OOS efficiency ≥ 60–80% of IS performance as a minimum for live deployment consideration. Only a systematic, multi-window WFA across varied market conditions can measure that ratio. A single holdout test cannot, because the OOS regime might be favorable regardless of the strategy’s true edge.

Rolling vs Anchored: Which to Use

Aspect Rolling WFA Anchored WFA
IS data used Fixed-length window moves forward Growing window from fixed origin
Old data Discarded (assumes recent is more relevant) Retained (all observations equally weighted)
IS sample size Constant — consistent parameter stability Grows — more data per step, changing stability
Best for Regime-sensitive strategies (trend-following) Stationary strategies (mean reversion, pairs)
Risk Recent regime dominates; structural shifts missed Old regimes dilute recent signal; slow to adapt

Stack Setup

python -m venv .venv
source .venv/bin/activate
pip install vectorbt yfinance
python walk_forward_split.py

The script fetches SPY once at launch and slices from the cached DataFrame for each IS/OOS window, so it runs quickly after the initial download.

Frequently Asked Questions

What is the difference between rolling and anchored walk-forward analysis?
In rolling WFA, the in-sample window moves forward in time at each step: the IS window for step 2 drops the oldest data from step 1's IS window and gains new data. In anchored WFA (also called expanding-window WFA), the IS window always starts from the same origin date and grows forward. Rolling WFA assumes that recent data is more relevant than older data, so old data is discarded. Anchored WFA uses all available history, assuming every observation is equally relevant. For trend-following strategies where market regimes shift, rolling WFA is often preferred. For mean-reversion strategies with stable statistical relationships, anchored WFA can produce more stable parameter estimates because it uses a larger sample.
Why do IS-optimized parameters sometimes underperform out-of-sample?
IS optimization finds parameters that maximize performance on the specific IS sample. If the IS sample contains a regime (e.g., a strong trend from 2019-2022) that does not persist in the OOS period, the optimized parameters are tuned to a regime that no longer exists. This is data snooping: the optimizer found parameters that happen to work on the noise in the IS sample, not the underlying signal. The more parameters you optimize, the more likely this is. Pardo's rule of thumb (from 'Design, Testing, and Optimization of Trading Systems') is that OOS performance should be at least 60-80% of IS performance for the strategy to be considered robust. The walkthrough shows windows where OOS Sharpe was 0.58 vs IS Sharpe of 0.59 (close) and others where IS Sharpe was 1.39 but OOS Sharpe was 0.61 (severe degradation).
How many walk-forward windows do I need for a reliable result?
There is no universal minimum, but the general guidance is: enough OOS windows to cover multiple market regimes (bull, bear, sideways, high-volatility, low-volatility). For daily-bar equity strategies, at least 3-5 OOS windows of 1-2 years each provides coverage across different conditions. The walkthrough uses 3 windows (6 total OOS years on 2019-2024), which is a reasonable starting point for illustration but would not be considered rigorous for live deployment on its own. For a more statistically grounded approach, Lopez de Prado's Combinatorial Purged Cross-Validation (CPCV) generates many more OOS paths from the same data, reducing dependence on the specific window boundaries chosen.
What is the difference between WFA and simple holdout testing?
Simple holdout testing uses a single OOS period (e.g., the last 20% of data). WFA uses multiple OOS periods by rolling the IS/OOS boundary forward through time, producing multiple OOS observations rather than one. The advantage of WFA is that it provides a better estimate of how the strategy would have performed across different market regimes: a single 2-year OOS period might fall entirely in a bull market, making results look good regardless of whether the strategy has real edge. WFA covering a bear market (2020 crash, 2022 drawdown) and a recovery tests the strategy's regime-independence. The cost of WFA is complexity: each step requires re-optimization, which multiplies computation time.

The Walk-Forward Analysis guide explains the theory, anchored vs rolling mechanics, and the IS-to-OOS efficiency ratio in depth.

Walk-Forward Analysis Guide →   All Code Walkthroughs →