DEV Community

shakti tiwari
shakti tiwari

Posted on • Originally published at optiontradingwithai.in

Walk-Forward Validation for Options Strategies: Anchored vs Rolling Windows

Walk-Forward Validation for Options Strategies: Anchored vs Rolling Windows

Most people who "backtest" an options strategy do one of two things. They either fit parameters on the whole history and admire the equity curve, or they split the data once — train on the first chunk, test on the last chunk — and call the second number "out of sample."

Both are structurally broken for options. Not because the code is wrong, but because the evaluation protocol does not resemble how the strategy will actually be used. In live trading you never get one clean train/test split. You get a rolling present. You refit, you redeploy, you carry stale assumptions into a regime that has already changed, and you find out later.

Walk-forward validation is the protocol that mirrors that reality. This article is structural and code-first: no live market numbers, no claimed performance figures. Anywhere a real number would belong, it is marked UNKNOWN — you must verify it on your own data, with your own instruments, before you trust it. That is the whole point of the exercise.

1. Why a single train/test split fails for options

A single split assumes one thing: that the relationship you learned in the training period is stationary enough to survive into the test period. For equities on long horizons that assumption is merely optimistic. For options it is close to indefensible, for four structural reasons.

Regime dependence is the dominant effect. An options strategy is a bet on the joint behaviour of direction, volatility, and time. A short-premium strategy that behaves one way in a compressed-volatility regime behaves in a completely different way when volatility expands. If your single training window happens to sit inside one regime and your single test window inside another, your "out of sample" result measures regime luck, not skill. If they sit in the same regime, you have measured nothing at all.

The instrument itself expires. An equity ticker is a continuous object. An option contract is not — it is born, decays, and dies. Any feature you compute across contracts (rolling implied volatility of an at-the-money strike, term-structure slope, skew) is a constructed series stitched from expiring objects. The stitching rules are part of your model, and they can leak future information if you are careless.

Labels overlap in time. If your label is "P&L of a position held for N days," then observations on consecutive days share most of their outcome window. A naive split leaves training rows whose outcome window extends into the test period. The model is quietly graded on data it partially saw. This is leakage, and it inflates results silently.

Parameter choices are themselves fitted. Strike distance, days to expiry at entry, stop rules, hedge frequency — these are hyperparameters. Every time you tune them by looking at test performance, the test set stops being a test set. A single split has no mechanism to stop this. Walk-forward, done properly, does.

2. The core protocol

Walk-forward validation slices time into ordered folds. In each fold you fit on the past, predict on the immediate future, record the result, and then move forward. You never shuffle. You never look ahead. At the end you have a sequence of out-of-sample results rather than a single one — and a sequence is what lets you ask the only question that matters: is the edge stable, or did it appear once?

The skeleton:

from dataclasses import dataclass
from typing import Iterator, Tuple
import numpy as np
import pandas as pd


@dataclass(frozen=True)
class Fold:
    """One walk-forward fold, expressed as index positions."""
    fold_id: int
    train: np.ndarray
    test: np.ndarray

    def describe(self, index: pd.DatetimeIndex) -> str:
        return (
            f"fold={self.fold_id} "
            f"train=[{index[self.train[0]].date()} .. {index[self.train[-1]].date()}] "
            f"n_train={len(self.train)} "
            f"test=[{index[self.test[0]].date()} .. {index[self.test[-1]].date()}] "
            f"n_test={len(self.test)}"
        )
Enter fullscreen mode Exit fullscreen mode

Everything else is a policy decision about how train is chosen relative to test. There are exactly two families, and choosing between them is the substance of this article.

3. Anchored (expanding) windows

An anchored walk-forward fixes the start of the training set at the beginning of history and lets it grow. Fold 1 trains on the first block. Fold 2 trains on the first two blocks. Fold k trains on everything before test block k.

def anchored_folds(n: int, initial_train: int, test_size: int,
                   embargo: int = 0) -> Iterator[Fold]:
    """Expanding-window walk-forward. Training set always starts at index 0."""
    idx = np.arange(n)
    start_test = initial_train + embargo
    fold_id = 0
    while start_test + test_size <= n:
        train_end = start_test - embargo
        yield Fold(
            fold_id=fold_id,
            train=idx[:train_end],
            test=idx[start_test:start_test + test_size],
        )
        fold_id += 1
        start_test += test_size
Enter fullscreen mode Exit fullscreen mode

What anchored windows assume. That old data remains informative. The structural claim is that the data-generating process has a stable component that more history estimates more precisely. Under that assumption, growing the sample reduces estimation variance and the later folds should be the most reliable.

Where that assumption holds for options. It holds best for things that are mechanical rather than behavioural. The relationship between time to expiry and the rate of extrinsic-value decay is a property of the pricing framework, not of this year's sentiment. The sign of the relationship between realized volatility and the profitability of short-gamma positions is structural. The typical shape of a term structure — that near-dated and far-dated implied volatilities usually differ, and in which direction under calm versus stressed conditions — is a persistent regularity. For these, more history genuinely helps.

Where it fails. Microstructure and liquidity conditions change. Contract specifications change. Tick sizes, lot sizes, expiry-day conventions, settlement mechanics, and which expiries actually carry volume — all of these are exchange-policy artefacts that get revised. An anchored window keeps grading the model on a market that no longer exists, and it does so with increasing weight as the old data accumulates. Worse, the growing training set makes later folds less comparable to earlier ones: fold 1 and fold 20 are not the same experiment, so a trend in fold performance is confounded with a trend in sample size.

Diagnostic use. Anchored folds are the right choice when your question is "does this effect exist at all, and does adding data sharpen it?" Watch whether the out-of-sample metric stabilises as the training set grows. If it wanders without settling, you probably do not have a stationary effect — you have a moving target.

4. Rolling (sliding) windows

A rolling walk-forward fixes the length of the training set and slides it forward. Fold k trains on a fixed-size block immediately preceding test block k. Old data falls out the back.

def rolling_folds(n: int, train_size: int, test_size: int,
                  embargo: int = 0) -> Iterator[Fold]:
    """Sliding-window walk-forward. Training set has constant length."""
    idx = np.arange(n)
    start_train = 0
    fold_id = 0
    while start_train + train_size + embargo + test_size <= n:
        train_end = start_train + train_size
        start_test = train_end + embargo
        yield Fold(
            fold_id=fold_id,
            train=idx[start_train:train_end],
            test=idx[start_test:start_test + test_size],
        )
        fold_id += 1
        start_train += test_size
Enter fullscreen mode Exit fullscreen mode

What rolling windows assume. That relevance decays with age — that the recent past is a better description of the near future than the distant past is. This is the adaptive stance.

Where that assumption holds for options. Almost everywhere that behaviour, positioning, and liquidity matter. The level of implied volatility relative to realized volatility, the steepness of skew, which strikes are crowded, how aggressively market makers hedge, how spreads widen around events — these are conditions, not laws. A model trained on a window that includes the current condition adapts; a model trained on all history averages the current condition away.

Where it fails. Short windows mean noisy estimates. Every parameter you fit on a small block has wide uncertainty, and with enough folds you will see impressive-looking variation that is pure sampling noise. Rolling windows also structurally forget rare events. If a volatility shock appears once in your history and your window is shorter than the gap between shocks, most folds are trained on a world where shocks do not happen. Your model will be confident and wrong exactly when it matters. Any risk parameter derived from a rolling window is an estimate of tail behaviour from a sample that may contain no tail at all.

Diagnostic use. Rolling folds answer "would this have worked, repeatedly, if I had refit it on a schedule?" That is the deployment question. The correct output is not an average — it is a distribution across folds, plus an explicit note of which folds contained stress and which did not.

5. The decision, stated structurally

Neither family is superior. They answer different questions, and the honest workflow uses both.

| Question you are asking | Window family | What you read |
||||
| Does the effect exist in this data at all? | Anchored | Does OOS metric stabilise as train grows? |
| Is the effect mechanical or behavioural? | Both, compared | Anchored better ⇒ mechanical. Rolling better ⇒ regime-conditional. |
| Would a scheduled refit have survived? | Rolling | Fold-by-fold distribution, worst fold |
| How large is my parameter uncertainty? | Rolling, varied lengths | Spread of fitted parameters across folds |
| Am I safe in a stress regime? | Rolling, but only folds containing stress | UNKNOWN unless such folds exist — verify your history contains them |

The comparison itself is the most valuable single diagnostic in the whole procedure. If an anchored window beats a rolling window, your edge is likely structural and you should prefer long history and few parameters. If a rolling window beats an anchored one, your edge is regime-conditional, and your real problem is not the model but regime detection and refit cadence. If both look similar and both look weak, believe the weakness.

6. Purging and embargo: the part everyone skips

Because options labels span time, adjacent folds contaminate each other. Two mechanisms fix this, and both are non-optional.

Purging removes training rows whose label window overlaps the test window. If a training row's outcome resolves after the test period begins, that row knows something about the test period. Drop it.

Embargo removes a buffer of rows immediately after the test block before training resumes in later folds, because serial correlation in features means near-adjacent rows are near-duplicates.

def purge_train(train_idx: np.ndarray,
                label_end: np.ndarray,
                test_start_time) -> np.ndarray:
    """Drop training rows whose label horizon leaks into the test window.

    label_end[i] = timestamp at which row i's outcome is fully known.
    """
    keep = label_end[train_idx] < test_start_time
    return train_idx[keep]


def purged_folds(folds, index: pd.DatetimeIndex, label_end: np.ndarray):
    for f in folds:
        test_start_time = index[f.test[0]]
        clean = purge_train(f.train, label_end, test_start_time)
        if len(clean) == 0:
            continue
        yield Fold(fold_id=f.fold_id, train=clean, test=f.test)
Enter fullscreen mode Exit fullscreen mode

The horizon rule is simple and worth memorising: your embargo must be at least as long as your label horizon. If you hold positions to expiry, your embargo is the maximum remaining tenor at entry. Many people set an embargo of a day or two and hold trades for weeks. That is leakage with a compliance sticker on it.

A second, options-specific trap: features built from the same contract that generates the label. If your label is the P&L of a specific expiry and one of your features is a smoothed quantity computed over that expiry's whole life, the feature contains the label. Build features only from information available strictly before entry, and assert it in code rather than trusting your memory.

def assert_no_lookahead(features: pd.DataFrame,
                        entry_time: pd.Series) -> None:
    """Fail loudly if any feature timestamp is at or after its entry time."""
    bad = features.index[features.index >= entry_time.reindex(features.index)]
    if len(bad):
        raise ValueError(f"look-ahead in {len(bad)} rows, first={bad[0]}")
Enter fullscreen mode Exit fullscreen mode

7. Running the loop end to end

Below is the full harness. Note what it does not do: it does not return a single score. It returns a per-fold record, because a single score destroys exactly the information you built the protocol to obtain.

def walk_forward(model_factory,
                 X: pd.DataFrame,
                 y: pd.Series,
                 folds,
                 index: pd.DatetimeIndex,
                 metric_fn) -> pd.DataFrame:
    """Fit-predict-score across ordered folds. No shuffling, no peeking.

    model_factory: zero-arg callable returning a FRESH unfitted model.
    metric_fn(y_true, y_pred) -> float
    """
    rows = []
    for f in folds:
        model = model_factory()          # fresh per fold: no state carry-over
        model.fit(X.iloc[f.train], y.iloc[f.train])
        pred = model.predict(X.iloc[f.test])
        rows.append({
            "fold": f.fold_id,
            "train_start": index[f.train[0]],
            "train_end": index[f.train[-1]],
            "test_start": index[f.test[0]],
            "test_end": index[f.test[-1]],
            "n_train": len(f.train),
            "n_test": len(f.test),
            "score": metric_fn(y.iloc[f.test], pred),
        })
    return pd.DataFrame(rows)
Enter fullscreen mode Exit fullscreen mode

Three implementation rules that matter more than the model choice:

  1. A fresh model per fold. Reusing a fitted object across folds carries state forward and is a subtle form of leakage. model_factory() exists for this reason.
  2. Scaling and imputation belong inside the fold. If you fit a scaler, a median imputer, or a target encoder on the full dataset, statistics from the test period reach the training step. Wrap every transform in a pipeline that is fit only on f.train.
  3. Costs go inside the fold too. For options, transaction cost is not a constant to subtract at the end. Spread is state-dependent and widens precisely when your strategy wants to act. Model it as a function of the conditions in that fold, and if you do not have the data to model it, mark it UNKNOWN and verify before you size anything.

8. Reading the output honestly

You now have a table with one row per fold. Read it in this order.

Worst fold first. Not the mean. The mean of a walk-forward is a marketing number. The worst fold is the closest thing you have to a drawdown rehearsal. If the worst fold is unacceptable, the average is irrelevant.

Then dispersion. Wide spread across folds means the edge is regime-conditional, and your live experience will be a random draw from that spread, not the average of it.

Then sign consistency. Count folds with a positive result versus negative. A strategy that wins in a bare majority of folds is not a strategy, it is a coin with an opinion. Consistency of sign is stronger evidence than magnitude of mean.

Then ordering. Is there drift? A metric that decays monotonically across folds usually means the effect was arbitraged, or that a structural change (spec change, liquidity migration, participant mix) invalidated it. A metric that improves may just mean your later folds are calmer.

Then trial count. Every configuration you evaluated on these folds is a trial, and trials inflate the best observed result. If you tested many configurations, the maximum is biased upward by an amount that grows with the number of trials. Record the count. If you did not record it, your best result is UNKNOWN in the only sense that matters, and you must verify it on data you have not touched.

Finally, the fold-level metric values themselves. Deliberately not quoted here: any specific number would be fabricated. Your numbers are UNKNOWN until you run this on your own verified dataset with your own cost model.

9. Common failure modes, named

  • Refitting on the test fold after seeing it. The moment you adjust anything because a fold looked bad, that fold is training data. Keep a final holdout you look at once.
  • Embargo shorter than the label horizon. Discussed above. The single most common leak in options research.
  • Survivorship in the contract universe. If your dataset only contains contracts that reached decent volume, you have removed the illiquid cases where your fills would have been worst.
  • Calendar-blind folds. Folds that split across an expiry or an event cluster are not comparable. Align fold boundaries to expiry cycles so each fold contains a whole number of them.
  • Averaging across instruments before averaging across folds. Do it in the other order, or a single well-behaved underlying will mask failure everywhere else.
  • Reporting the mean without the count of trials. Guarantees an over-optimistic read, every time.

10. A minimal checklist

Before you believe any walk-forward result:

  1. Folds are strictly ordered in time; nothing is shuffled.
  2. Training data ends before test data begins, with an embargo ≥ label horizon.
  3. Purging removes overlapping-label training rows.
  4. All transforms fit inside the fold only.
  5. A fresh model is instantiated per fold.
  6. Costs are modelled as state-dependent, not subtracted at the end.
  7. Both anchored and rolling variants were run and compared.
  8. Worst fold, dispersion, and sign consistency are reported — not just the mean.
  9. Trial count is recorded.
  10. Every number you would quote publicly is either derived from verified data or marked UNKNOWN.

If you cannot tick all ten, you do not have an out-of-sample result. You have a plot.

11. Closing thought

Walk-forward validation is not a technique for making backtests look better. It is a technique for making them look worse in the specific ways that live trading will be worse, early enough that the discovery is cheap. The anchored window tells you whether an effect is real. The rolling window tells you whether you could have used it. Purging and embargo tell you whether either answer is trustworthy at all.

Everything else — model family, feature count, hyperparameter search — is downstream of getting this protocol right. A weak model under an honest protocol is a business. A strong model under a dishonest one is a story you tell yourself until the market stops subsidising it.

Educational only. Not investment advice. No live market data is quoted anywhere in this article; all performance figures are deliberately left UNKNOWN for you to verify on your own dataset.

About the Author

Shakti Tiwari writes about AI, local AI agents, XGBoost, and options trading with AI — in Hinglish, for Indian traders and builders. Educational, no-hype, code-first.

Educational only. Not investment advice.

Continue Reading (Authority OS series)

Tags

ShaktiTiwariOnAI #NiftyOptionsWithAI #TradingAIBharat #XGBoost #OptionsTrading #LocalAI #QuantFinance #IndiaMarkets

Top comments (0)