The shift(-1) Bug: How a One‑Character Mistake Turned a Random Model into a 77,000% Backtest
A reproducible case study on why naive machine learning almost always fails on financial data, and what a proper walk-forward validation…
The shift(-1) Bug: How a One‑Character Mistake Turned a Random Model into a 77,000% Backtest
A reproducible case study on why naive machine learning almost always fails on financial data, and what a proper walk-forward validation looks like.
Why financial ML is harder than Kaggle
Standard machine learning tutorials work beautifully on stationary, IID data: think images, text, or even cross‑sectional credit‑card transactions. Financial time series break all of those assumptions:
- Extremely low signal‑to‑noise ratio - asset returns are almost indistinguishable from noise, so any edge is tiny.
- Non‑stationarity - relationships that worked in 2016 vanish in 2020, and you won’t know until your live P&L tells you.
- Ubiquitous paths to leakage - because time moves in one direction, it is trivially easy to accidentally let information from the future slip into your features (and into your test set).
Here we will deliberately build a “brilliant” model that predicts the S&P 500 with 62% accuracy and a +77,000% cumulative return. Then find the hidden leakage bug and watch that performance evaporate. All code and data are real; no simulation, no cherry‑picking.
The setup: predict 5‑day S&P 500 direction
- Instrument: S&P 500 index, daily data from 2000‑01‑03 to 2018‑12‑21 (4,754 usable rows after feature warm‑up).
- Target: will the index be up or down 5 trading days from now? (binary classification)
- Features: 8 simple technical indicators - momentum, volatility, RSI, relative volume, etc. Every feature is computed as of day t using only trailing information. … except one, which we will bug intentionally.
The bug is extremely realistic: a single flipped sign in a shift() operation.
The author meant to use shift(1) (yesterday’s return, known at close of day t),
but wrote shift(-1) instead i.e reading tomorrow’s return, which is inside the prediction window.
#Correct (trailing – known at day t):
f["mom_fixed"] = c.pct_change(1).shift(1)
#Bug (lead - the first day of the target horizon):
f["mom_bugged"] = c.pct_change(1).shift(-1)
Note : We sometimes use shift(-1) as Target Labels to create the "answer key" for training machine learning models. This shifts future price outcomes back to today's row so the model can learn what a winning setup looks like.
However, you must never include this column as an input feature during training. Doing so creates look-ahead bias, allowing the model to cheat by reading the future. This results in perfect backtest accuracy but instant failure in live trading.
Pipeline A: “Leaky” Exactly what 90% of first-draft notebooks do
We scale features with StandardScaler fit on the entire dataset (future data influences the scaling),
and we evaluate using a shuffled 5‑fold cross‑validation (completely ignoring the time order).
# Leaky scaling + Shuffled CV
Xs = StandardScaler().fit_transform(X) # sees all rows
kf = KFold(n_splits=5, shuffle=True, random_state=42)
The model is a simple RandomForestClassifier with 300 trees & no hyper‑parameter tuning, no feature selection.
The “too good to be true” results


Figure 1 : What the Shuffled CV backtest shows: a perfect curve of wealth, with barely a drawdown. If these numbers were real, you’d be the richest person on Earth by lunchtime.
Any backtest that looks this good is either a Nobel‑prize‑worthy discovery or a data leak. In practice it’s almost always the latter.
Pipeline B: “Honest” with purged walk-forward validation
We fix exactly two things:
- The
shift()bug → usemom_fixedinstead ofmom_bugged. - Walk‑forward validation with:
- Expanding window (train only on past data)
- Purging (drop rows immediately before the test window to avoid overlapping information)
- Embargo (skip the full horizon so that no training label sees the test window)
- Scaler fitted only on the training fold, not on the entire history
# Train‑only scaling, embargoed walk‑forward
test_start = oos_start + b * block_size
train_end = test_start - PURGE_EMBARGO # purging + embargo
scaler = StandardScaler().fit(X[ : train_end])
Xtr, Xte = scaler.transform(X[:train_end]),scaler.transform(X[test_start:test_end])
The model, the random seed, and the feature set are otherwise identical.
Honest Performance


The same model idea, tested properly: the blue line drifts downward with costs, while buy‑and‑hold (grey) captures the broad market. The model has no real edge.
The “edge” was entirely an artefact of the data pipeline, not any predictive power.
Diagnosing the leak without even re-testing
The fastest way to spot a leakage bug before you waste time on backtests is to look at feature importance.

Left: the buggy feature dominates the model (21% importance, 2× any legitimate feature). Right: with the fix, all features share importance roughly equally. If one feature is suspiciously strong, start investigating.
A lopsided feature importance is one of the cheapest early‑warning signals you can implement.
Accuracy per fold: the leak inflates every single split

Shuffled CV (navy bars) shows consistently high accuracy far above the “always up” baseline (dotted gold). Proper walk‑forward (red bars) hovers around 50%, indistinguishable from a coin flip.
A common mistake is to think “it’s okay to shuffle because the features are stationary.” But when rolling windows or overlapping labels are present, shuffling mixes future information into the training folds. The result is that every fold looks good, not just the average and you walk away convinced the model works.
The exact mechanism of the shift(-1) leak

The green arrow shows the correct lag: yesterday’s return (t-1) becomes a feature for day t. The red arrow shows the bug: tomorrow’s return (t+1) leaks into day t. Notice that t+1 is inside the 5-day target window so the model gets a partial, noisy preview of the thing it’s supposed to predict.
This is insidious because the leak doesn’t reveal the exact target so it just gives a hint. That’s why the inflated accuracy is “only” ~62% instead of 100%. We often rationalise this as “the model learned something real”. It didn’t. It cheated.
The checklist: 6 habits that will save your backtest
- Never shuffle time series in CV : use walk‑forward validation (expanding or sliding window).
- Purge and embargo : Drop the
Hrows before each test window where labels overlap. Apurge=Handembargo=H(withHequal to your forecasting horizon) is a safe default. - Fit every transformer on the training fold only : scalers, imputers, PCA, everything.
Then
transformthe test fold. No exceptions. - Feature importance sanity check : If one feature has 3-4 × the importance of the next, suspect leakage or a feature that is a direct proxy for the target.
- Equity curve sanity check : If a simple directional model on a major index grows 100× over a decade, there’s a bug. Realistic edge is a few percentage points of excess return above buy‑and‑hold, after realistic costs.
- Re‑run the experiment with a shifted target : If accuracy doesn’t degrade when you move the target forward or backward by a few days, your features are leaking.
References
- de Prado, M. L. (2018). “Advances in Financial Machine Learning” The definitive source on purged walk‑forward cross‑validation, combinatorial embargoing, and fractional differencing. Chapter 7 (“Cross‑Validation in Finance”) directly motivates the honest pipeline built in this article.
- Bergmeir, C., & Benítez, J. M. (2012). “On the use of cross‑validation for time series predictor evaluation.” Information Sciences, pg 191, 192‑213.
Thanks for reading :) For reviews and comments email me : ayushshankaram@gmail.com
“Before you check an equity curve tomorrow, print the feature importances first. If one feature is 3× stronger than the rest, stop. You might have just found your
shift(-1).”
메타데이터
- post_id
- 604402064861
- slug
- the-shift-1-bug-how-a-one-character-mistake-turned-a-random-model-into-a-77-000-backtest-604402064861
- url
- https://medium.com/@ayushshankaram/the-shift-1-bug-how-a-one-character-mistake-turned-a-random-model-into-a-77-000-backtest-604402064861
- canonical_url
- https://medium.com/@ayushshankaram/the-shift-1-bug-how-a-one-character-mistake-turned-a-random-model-into-a-77-000-backtest-604402064861
- author_url
- https://medium.com/@ayushshankaram
- status
- ok
- fetched_at
- 2026-07-26 07:48:27