Part I: Foundations of Temporal AI
Chapter 2: Temporal Data Engineering

Evaluation Protocols, Backtesting, and Data Leakage

"My equity curve climbed so smoothly that I started shopping for a yacht. Then someone asked which data my scaler had seen, and the yacht sailed away without me."

A Backtest That Looked Far Too Good to Be True
Big Picture

An evaluation protocol is a promise: the number it reports is what you will get in production. For temporal data that promise is shockingly easy to break, because the standard machine-learning toolkit was built for exchangeable rows and silently shuffles the future into the past. This section makes the promise enforceable. We replace i.i.d. k-fold with time-aware validation, add purging and embargo so overlapping labels cannot leak across the train/test wall, choose forecast metrics that do not lie near zero or flatter slow series, and finish with the realism checks (costs, non-stationarity, multiple testing, naive baselines) that separate a backtest you can trust from one you cannot. Section 1.6 introduced look-ahead bias as the cardinal sin in one page; this is the full treatment it pointed forward to.

The previous section, Section 2.5, built feature pipelines that respect the arrow of time. Now we ask the harder question: once a model is trained, how do you measure it without fooling yourself? The order matters, because every leakage path from Section 1.6 reappears here at evaluation time, and a flawless training pipeline can still be graded by a dishonest protocol. The thread of this section is a single rule with many consequences: you may only score a model on data that lies strictly in the future of everything it learned from, and "everything it learned from" includes every scaler, statistic, and overlapping label, not just the model weights.

1. Why i.i.d. Cross-Validation Is Wrong for Time Series Beginner

Ordinary k-fold cross-validation rests on one assumption: the samples are independent and identically distributed, so any partition into folds is as good as any other and shuffling is harmless. Time series violate this at the root. Observations are autocorrelated, the distribution drifts (the non-stationarity of Section 1.6), and there is a privileged direction: the past explains the future, never the reverse. When shuffled k-fold scatters samples randomly into folds, it routinely places a sample from time $t+5$ into the training fold used to predict a sample at time $t$. The model is then graded on its ability to interpolate between observations it has effectively already seen, which is a far easier task than the forecasting it will actually face. The score is optimistic, and the optimism grows with autocorrelation and drift.

The fix is conceptual before it is mechanical. Evaluation must mirror deployment, and deployment has a temporal boundary: at decision time $\tau$, the model knows the past $\{t \le \tau\}$ and must predict the future $\{t > \tau\}$. Every honest protocol in this section is just a disciplined way of sweeping that boundary $\tau$ forward through the data while never letting any computation on the right of the wall influence anything on the left.

Warning: Shuffled k-Fold and Preprocessing Leakage Are Both Invalid Here

Two defaults from tabular machine learning are quietly wrong on time series. The first is KFold(shuffle=True) or any cross-validator that does not respect order: it trains on the future to predict the past and inflates every metric. The second is fitting a preprocessing step (a StandardScaler, a SimpleImputer with the column mean, a target encoder, a feature selector) on the whole dataset before splitting, so the training rows encode statistics of the test horizon. Either mistake alone can turn a worthless model into a publishable-looking one. The only safe construction fits every transform inside the temporal training block and a time-aware splitter decides what counts as future. Anything else is a backtest that lies.

2. Time-Aware Validation: Hold-Out, Walk-Forward, and Rolling Windows Beginner

The simplest honest protocol is a single temporal hold-out: train on the first portion of the series, test on the tail, never the reverse. It is correct but wasteful, since it grades the model on one window and one regime. Backtesting generalizes it by sweeping the split forward, refitting at each step, and averaging the out-of-sample errors, so the score reflects many regimes rather than one. Two sweep shapes dominate. The expanding window (also called walk-forward) keeps the training set anchored at the start and lets it grow, so the model always uses all available history, which suits stable processes and small data. The rolling window keeps the training set a fixed length and slides both ends forward, so the model always forgets the distant past, which suits non-stationary processes where old data is actively misleading. Figure 2.6.1 contrasts the two.

Expanding window (walk-forward) fold 1 fold 2 fold 3 fold 4 Rolling window (fixed length) fold 1 fold 2 fold 3 fold 4 time → train test dropped
Figure 2.6.1: Two backtesting schemes. In the expanding window (top), training always starts at the origin and grows, so each fold uses all history before its test block. In the rolling window (bottom), the training length is fixed and the oldest data is dropped as the window slides, which lets the model track a drifting process. In both, every test block lies strictly to the right of its training data, so no future ever trains a past prediction.

Scikit-learn's TimeSeriesSplit implements the expanding-window scheme directly, and with its max_train_size argument it becomes a rolling window. Code 2.6.1 shows both and confirms that every test index is larger than every training index, the mechanical signature of a valid temporal split.

import numpy as np
from sklearn.model_selection import TimeSeriesSplit

T = 16
X = np.arange(T).reshape(-1, 1)        # stand-in for an ordered time series

# Expanding window: training set grows, test block is the next chunk.
expanding = TimeSeriesSplit(n_splits=4, test_size=2)
print("EXPANDING")
for k, (tr, te) in enumerate(expanding.split(X), 1):
    assert te.min() > tr.max()          # every test index is in the future of train
    print(f"  fold {k}: train={list(tr)}  test={list(te)}")

# Rolling window: cap the training length so old data is forgotten.
rolling = TimeSeriesSplit(n_splits=4, test_size=2, max_train_size=6)
print("ROLLING (max_train_size=6)")
for k, (tr, te) in enumerate(rolling.split(X), 1):
    print(f"  fold {k}: train={list(tr)}  test={list(te)}")
Code 2.6.1: Generating expanding-window and rolling-window backtest folds with TimeSeriesSplit. The assertion te.min() > tr.max() is the contract every temporal split must satisfy; max_train_size converts the expanding scheme into the rolling one of Figure 2.6.1.
EXPANDING
  fold 1: train=[0, 1, 2, 3, 4, 5, 6, 7]  test=[8, 9]
  fold 2: train=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]  test=[10, 11]
  fold 3: train=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]  test=[12, 13]
  fold 4: train=[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]  test=[14, 15]
ROLLING (max_train_size=6)
  fold 1: train=[2, 3, 4, 5, 6, 7]  test=[8, 9]
  fold 2: train=[4, 5, 6, 7, 8, 9]  test=[10, 11]
  fold 3: train=[6, 7, 8, 9, 10, 11]  test=[12, 13]
  fold 4: train=[8, 9, 10, 11, 12, 13]  test=[14, 15]
Output 2.6.1: The expanding folds accumulate history while the rolling folds keep exactly six training points and discard older ones. In both, every test index exceeds every training index, so the split never lets the future grade the past.
Key Insight: Backtesting Is Cross-Validation With the Arrow of Time Glued On

Every valid temporal protocol is the same loop: pick a split point, fit on its past, score on its near future, advance, repeat. Expanding versus rolling is only a choice about how much past to keep, and that choice is really a hypothesis about stationarity. If old data still describes the present, expand; if the process drifts, roll and forget. The one thing you are never allowed to change is the direction of the arrow.

3. Purging and Embargo for Overlapping Horizons Intermediate

The split-point rule is necessary but not sufficient when features or labels span time. Suppose a label at time $t$ is the return realized over the next $h$ steps, or a feature is a rolling statistic over a trailing window. Then a training sample whose label window overlaps the test period shares information with the test set even though its timestamp sits before the split. Marcos Lopez de Prado named the two corrections. Purging removes from the training set any sample whose label interval overlaps the test interval, and embargo additionally drops a short gap of training samples immediately after the test block, because serial correlation lets information leak backward across the boundary just past it. Let sample $i$ carry an information interval $[t_i^{\text{start}}, t_i^{\text{end}}]$ (for a horizon-$h$ label, $t_i^{\text{end}} = t_i + h$). With test interval $[\tau, \tau']$, a training sample is purged when its interval overlaps the test interval,

$$\big[t_i^{\text{start}},\, t_i^{\text{end}}\big] \cap \big[\tau,\, \tau'\big] \neq \varnothing,$$

and the embargo extends the forbidden zone past the test block by an embargo length $e$, removing any training sample with $t_i^{\text{start}} \in (\tau', \, \tau' + e]$. Without purging, the model is graded on labels it partly memorized; without embargo, the autocorrelation just after the test window does the leaking instead. Both are invisible to the split-index check of Code 2.6.1, which is exactly why they are dangerous.

Fun Fact: The Leak That Hides in the Label, Not the Features

Most people hunt leakage in the feature columns and never think to look at the label. But a five-day-ahead return label at Monday's close literally contains Friday's price, which also seeds the test fold that starts mid-week. The features can be spotless and the backtest still cheats, purely because two labels overlap in calendar time. Purging is the rare bug fix that touches no feature and no model, only which rows you are allowed to keep.

4. Forecast Metrics and Their Pitfalls Intermediate

An honest protocol still needs an honest score. The scale-dependent errors are the familiar pair. Mean absolute error, $\mathrm{MAE} = \frac{1}{n}\sum_i |y_i - \hat y_i|$, is robust and interpretable in the unit of the series. Root mean squared error,

$$\mathrm{RMSE} = \sqrt{\tfrac{1}{n}\textstyle\sum_{i=1}^{n}\,(y_i - \hat y_i)^2},$$

penalizes large misses harder and shares the series unit, but it is sensitive to outliers and not comparable across series of different scale. The percentage errors try to fix comparability and introduce new traps. Mean absolute percentage error, $\mathrm{MAPE} = \frac{100}{n}\sum_i |y_i - \hat y_i| / |y_i|$, divides by the truth and therefore explodes when $y_i$ is near zero and is undefined at exactly zero, so it is meaningless for intermittent demand or any series that visits the origin. The symmetric variant sMAPE divides by $(|y_i| + |\hat y_i|)/2$, which bounds it but still misbehaves when both terms are tiny. The clean answer is the mean absolute scaled error, which divides the model's MAE by the in-sample MAE of a naive baseline,

$$\mathrm{MASE} = \frac{\frac{1}{n}\sum_{i=1}^{n} |y_i - \hat y_i|}{\frac{1}{T-m}\sum_{t=m+1}^{T} |y_t - y_{t-m}|},$$

where the denominator is the seasonal-naive error at season length $m$ (use $m=1$ for the random-walk baseline). MASE is scale-free, defined whenever the series is not constant, and carries an instant interpretation: below one means you beat the naive forecaster, above one means you lost to it. For probabilistic forecasts, the pinball (quantile) loss scores a predicted quantile $\hat q_\alpha$ at level $\alpha$ against the realized $y$,

$$L_\alpha(y, \hat q_\alpha) = \begin{cases} \alpha\,(y - \hat q_\alpha), & y \ge \hat q_\alpha,\\[2pt] (1-\alpha)\,(\hat q_\alpha - y), & y < \hat q_\alpha,\end{cases}$$

which is minimized in expectation exactly at the true $\alpha$-quantile, so averaging it across levels grades a whole predictive distribution. The full machinery of probabilistic scoring rules, calibration, and conformal intervals is developed in Chapter 19; here the pinball loss is the bridge metric. Table 2.6.1 summarizes when to reach for each, and Code 2.6.2 implements MASE and the pinball loss from scratch.

MetricWhat it measuresWhen to usePitfall to avoid
MAEAverage absolute error, series unitRobust point error on one seriesNot comparable across scales
RMSERoot mean squared error, series unitWhen large misses matter mostOutlier-sensitive; scale-bound
MAPEAverage percent errorStrictly positive, away-from-zero seriesExplodes or undefined near zero
sMAPESymmetric percent errorWhen a bounded percentage is wantedUnstable when values are tiny
MASEError relative to a naive baselineCross-series and cross-scale comparisonNeeds a sensible season length $m$
PinballQuantile / interval accuracyProbabilistic and interval forecastsPer-level; average across $\alpha$
Table 2.6.1: Forecast metrics, their meaning, the situation each one fits, and the trap each one hides. MASE is the default for comparing across series because it is scale-free and baseline-relative; the pinball loss is the default once forecasts become distributions rather than points.
Fun Fact: The Metric That Divides by Almost Nothing

MAPE has a spectacular failure mode hiding in plain sight: a single near-zero actual value sends one term toward infinity, so an otherwise excellent forecaster can post a MAPE of thousands of percent because of one quiet day. This is exactly why intermittent-demand forecasting competitions banned it outright; when a series legitimately visits zero, the metric stops measuring the model and starts measuring the divisor.

import numpy as np

def mase(y_true, y_pred, y_train, m=1):
    """Mean Absolute Scaled Error. m is the season length (1 = random walk)."""
    naive = np.abs(np.diff(y_train, n=1) if m == 1
                   else y_train[m:] - y_train[:-m]).mean()   # in-sample naive MAE
    return np.abs(y_true - y_pred).mean() / naive

def pinball_loss(y_true, q_pred, alpha):
    """Quantile (pinball) loss for a predicted alpha-quantile."""
    diff = y_true - q_pred
    return np.mean(np.maximum(alpha * diff, (alpha - 1.0) * diff))

y_train = np.array([10., 12., 11., 13., 12., 14., 13., 15.])
y_true  = np.array([16., 14., 17., 15.])
y_pred  = np.array([15.2, 14.4, 16.1, 15.3])      # a decent point forecast

print(f"MASE (vs random-walk naive): {mase(y_true, y_pred, y_train):.3f}")
# Probabilistic: a low and a high quantile around the same point forecast.
print(f"pinball @0.1: {pinball_loss(y_true, y_pred - 1.5, 0.1):.3f}")
print(f"pinball @0.9: {pinball_loss(y_true, y_pred + 1.5, 0.9):.3f}")
Code 2.6.2: From-scratch MASE and pinball loss. MASE normalizes by the in-sample naive error so a value below one certifies the model beats the random walk; the pinball loss grades each predicted quantile and is minimized at the true quantile.
MASE (vs random-walk naive): 0.382
pinball @0.1: 0.175
pinball @0.9: 0.125
Output 2.6.2: A MASE of 0.382 means the model's absolute error is about two fifths of the naive baseline's, a clear improvement. The two pinball values grade the lower and upper quantiles separately; averaging such scores across many levels yields a single number for the whole predictive distribution.
Library Shortcut: Metrics and Walk-Forward Backtesting Off the Shelf

Every metric and split here exists, tested, in production libraries. Nixtla's utilsforecast.losses ships mase, smape, and a quantile loss, and its statsforecast and mlforecast expose a single cross_validation call that runs a full leakage-safe walk-forward and returns per-window scores. Darts and sktime provide rolling-origin backtesters that refit on each window, and scikit-learn covers mean_pinball_loss and TimeSeriesSplit directly. Choosing one replaces a dozen leak-prone lines with one call and removes the most common way temporal experiments fool themselves; the from-scratch versions above exist so you know what the call is doing.

5. Backtest Realism: Costs, Drift, Multiple Testing, and Baselines Advanced

A protocol can be leakage-free and still flatter a strategy if it models a frictionless, stationary, single-experiment world that does not exist. Four realism gaps matter most. First, costs and latency: in finance every trade pays a spread, a commission, and slippage, and the signal must be computable and actionable before the bar it trades on closes, so a backtest that fills at the same price it observed is fiction. Second, non-stationarity makes the past an imperfect guide; a strategy validated on one regime can fail the moment the regime turns, which is why the rolling window of Figure 2.6.1 and the drift handling of Chapter 20 exist. Third, multiple testing and backtest overfitting: if you try hundreds of strategy variants and keep the best by its backtest, the winner's superiority is largely the luck of selection, and its out-of-sample edge collapses. Lopez de Prado's deflated Sharpe ratio and the probability of backtest overfitting were designed to quantify exactly this. Fourth, and simplest, always report against a naive baseline. A "good" RMSE is meaningless in isolation; what matters is whether the model beats seasonal-naive or the random walk, which is precisely what MASE encodes. If your elaborate forecaster cannot beat yesterday-repeated, you have learned that, not failed to. Figure 2.6.2 captures the warning sign: a curve too perfect to be honest.

A smug robot lounges beside a flawless upward equity curve admiring itself, unaware that a sneaky hand is slipping it a glowing card drawn from the future, so its perfect track record is quietly the product of cheating.
Figure 2.6.2: A backtest that looks far too good to be true usually is; somewhere a hand is slipping the model tomorrow's answer, and the flawless curve is the receipt for a leak rather than proof of skill.
Practical Example: The Strategy That Backtested Beautifully and Lost Live

Who: A two-person systematic trading team at a small fund, building a daily mean-reversion signal on a basket of equities.

Situation: Their walk-forward backtest over ten years showed a Sharpe ratio (risk-adjusted return, mean return divided by its standard deviation) of 2.8, a smooth equity curve, and modest drawdowns, comfortably past the fund's allocation threshold.

Problem: Within a month of going live the strategy was flat-to-negative, with realized returns that bore no relationship to the backtest's promise.

Dilemma: Three explanations competed. A regime change at deployment (bad luck), underestimated costs (a modeling gap), or contamination of the backtest itself (a bug). The team had also run roughly four hundred parameter combinations and kept the best, so selection luck was a fourth suspect.

Decision: Rather than re-trade, they re-audited. They reran the single best configuration with realistic costs, then estimated the probability of backtest overfitting across all four hundred trials.

How: Two faults surfaced together. A volatility feature had been standardized with full-sample statistics computed once before the walk-forward loop, leaking the distribution of later years into earlier folds, exactly the Section 1.6 scaler leak. And the four-hundred-trial search meant the winning Sharpe was inflated by selection; the deflated Sharpe ratio brought it near zero. Adding a per-trade cost of a few basis points erased what little remained.

Result: The leakage-free, cost-aware, selection-corrected backtest showed essentially no edge, which matched the live result. The strategy was retired before larger losses, and the team made three checks mandatory: refit every transform inside the window, charge realistic costs, and deflate any Sharpe chosen out of many.

Lesson: A spectacular backtest is a hypothesis about a bug or about selection luck until proven otherwise. Realism, costs, the arrow of time, and a naive baseline, is not bureaucracy; it is the only thing standing between a paper fortune and a real loss.

Research Frontier: Overfitting Detection and Foundation-Model Evaluation (2024 to 2026)

Three threads are active right now. Backtest-overfitting detection has moved from a finance niche toward general practice: the probability of backtest overfitting and the deflated Sharpe ratio are increasingly applied to any model selected from a large search, and combinatorial purged cross-validation generalizes the purge-and-embargo idea of subsection three to many train/test recombinations. Evaluation of temporal foundation models is the second thread: as Chronos, TimesFM, Moirai, Lag-Llama, and MOMENT claim strong zero-shot forecasts, the community has grown wary of train/test contamination, because a model pretrained on enormous web-scraped corpora may already have seen the very benchmark series, so 2024 to 2026 work emphasizes strict leakage audits and held-out, post-cutoff evaluation windows. The third thread is automated leakage auditing: tools that scan pipelines for the global-fit-before-split pattern and for overlapping labels, turning the manual line-by-line audit of the case study into a reproducible gate. The common message is that as models get more powerful, the evaluation has to get more paranoid.

Exercise 2.6.1: Expanding or Rolling? Conceptual

For each setting, decide whether an expanding or a rolling window is the more honest backtest and justify it in two sentences. (a) Twenty years of stable, mean-reverting electricity demand with a fixed daily and weekly seasonality. (b) A consumer-behavior series that underwent a permanent structural break two years ago when a competitor entered the market. (c) A short sensor series of only three hundred observations. Tie each answer to the stationarity assumption the window choice encodes, per the Key Insight in subsection two.

Exercise 2.6.2: Spot and Fix the Leaky Evaluation Coding

The snippet below reports a wonderful score on a horizon-5 return label and is wrong in at least three distinct ways. Name each leak, say what future information it exposes, and rewrite the evaluation so that preprocessing is fit only on each training block, the split respects time, and overlapping labels near the boundary are purged with a short embargo.

import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import Ridge
from sklearn.model_selection import cross_val_score

y = prices[5:] / prices[:-5] - 1.0                  # (1) horizon-5 forward return
X = StandardScaler().fit_transform(features)        # (2) scale whole dataset
scores = cross_val_score(Ridge(), X[:-5], y,        # (3) default shuffled KFold
                         cv=5, scoring="r2")
print("mean R2:", scores.mean())

Hint: one leak is the cross-validator, one is the scaler, and one is the overlap of the five-step label across whatever boundary the folds draw.

Exercise 2.6.3: Does the Model Beat the Naive Baseline? Analysis

Take any forecaster you have built and, using a proper TimeSeriesSplit, compute both its RMSE and its MASE against the seasonal-naive baseline at the natural season length of your series. If MASE is above one, explain in a short paragraph why the RMSE alone would have hidden this failure, and then argue when a model that loses to seasonal-naive might still be worth deploying (consider probabilistic coverage and decision cost, forwarding to Chapter 19).