The one-sentence version
Backtesting uses historical data to test strategies, and it is extremely easy to produce beautiful but fake results. The seven most common errors are: look-ahead bias, survivorship bias, overfitting, data snooping, ignoring transaction costs, ignoring capacity, and the fallacy of out-of-sample safety. This article uses code to demonstrate two of the most fatal ones: on purely random prices, if you try enough parameter combinations, you can "find" an in-sample strategy that performs well, but it is worthless out-of-sample; if you shift the signal timing by just one day (peeking at the closing price of the same day), a losing strategy can become a winning one.
Disclaimer
This article is for technical educational purposes only and does not constitute any investment advice. The code in this article uses simulated data.
Layer 1: The Intuition
1.1 Hindsight Bias
"If I had bought Bitcoin in 2010..." Anyone can find a perfect path in hindsight. The danger of backtesting lies in the fact that it is a systematic hindsight generator.
A backtesting result is often unreliable not because the code is written incorrectly, but because:
- It uses information that was impossible to know at the time;
- It has been tested too many times on the same historical data;
- It ignores real-world frictions.
1.2 Overview of the Seven Sins
| Sin | What it is | Typical Example |
|---|---|---|
| 1. Look-ahead bias | Using data that was not yet known at the time | Calculating signals using the current day's closing price and executing at that same closing price; using the reporting period instead of the announcement date for financial reports |
| 2. Survivorship bias | Backtesting only on "surviving" assets | Backtesting the last ten years using today's index constituents, causing delisted or removed stocks to disappear |
| 3. Overfitting | Tuning parameters to "just right" for history | Testing every moving average period from 2 to 200 and picking the best one |
| 4. Data snooping | Repeatedly testing different ideas on the same dataset | An entire team conducting research on the same historical period for years (see Multiple Testing and the Factor Zoo) |
| 5. Ignoring costs | Disregarding commissions, spreads, and impact | A high-turnover strategy that turns from profitable to losing after deducting costs (see Modeling Transaction Costs) |
| 6. Ignoring capacity | Not considering the impact of fund size on price | A small-cap strategy backtested with 100 million USD, which would be impossible to execute in reality due to liquidity constraints |
| 7. Fallacy of out-of-sample | Thinking that setting aside out-of-sample data makes you safe | Modifying the strategy after seeing out-of-sample results, then looking at out-of-sample again... the out-of-sample becomes in-sample |
Layer 2: Demo 1: "Good Strategies" on Random Data
2.1 Experimental Design
- Generate 2000 days of pure random walk prices: zero predictability;
- The first 1000 days serve as in-sample, the last 1000 days as out-of-sample;
- Use a moving average crossover strategy (hold when the fast line crosses above the slow line), iterating through all combinations where the fast line period is 2–48 and the slow line period is between fast line + 5 and 195.
import numpy as np, pandas as pd
rng = np.random.default_rng(7)
n = 2000
ret = pd.Series(rng.normal(0, 0.01, n)) # Pure random walk: no predictability
price = 100 * np.exp(ret.cumsum())
ins, oos = slice(0, 1000), slice(1000, n)
def sharpe(x):
return x.mean() / x.std() * np.sqrt(252) if x.std() > 0 else 0.0
def ma_strategy(price, fast, slow):
sig = (price.rolling(fast).mean() > price.rolling(slow).mean()).astype(float)
return sig.shift(1) * price.pct_change() # Trade today using yesterday's signal to avoid look-ahead bias
results = []
for fast in range(2, 50, 2):
for slow in range(fast + 5, 200, 5):
r = ma_strategy(price, fast, slow)
results.append((fast, slow, sharpe(r.iloc[ins].dropna()), sharpe(r.iloc[oos].dropna())))
res = pd.DataFrame(results, columns=["fast", "slow", "is_sharpe", "oos_sharpe"])
best = res.loc[res["is_sharpe"].idxmax()]
print("Number of parameter combinations tried:", len(res))
print("In-sample best:", best.round(2).to_dict())
print("Mean out-of-sample Sharpe across all combinations:", round(res["oos_sharpe"].mean(), 2))
Output:
Number of parameter combinations tried: 826
In-sample best: {'fast': 44.0, 'slow': 84.0, 'is_sharpe': 0.61, 'oos_sharpe': -0.1}
Mean out-of-sample Sharpe for all combinations: -0.13
2.2 Interpretation
- On a sequence that is known to be unpredictable, after testing 826 parameter sets, the best set has an in-sample Sharpe ratio of 0.61;
- In the out-of-sample period, this parameter set has a Sharpe ratio of −0.1, which is no different from the average of all parameters;
- If you only saw the result "Fast 44, Slow 84, Sharpe 0.61," you would likely believe it;
- Real research is even worse: researchers often test not just parameters, but different indicators, different stock pools, and different time periods. The "number of trials" far exceeds a few hundred, and the in-sample results can be even more attractive. To see how to correct for this, see Multiple Testing and the Factor Zoo.
Layer 3: Demo 2: Forward Error Only Needs One Line
3.1 The same strategy, two ways
sig = (price.rolling(5).mean() > price.rolling(20).mean()).astype(float)
cheat = sig * price.pct_change() # Use today's signal to earn today's return
honest = sig.shift(1) * price.pct_change() # Use yesterday's signal to earn today's return
print("Look-ahead version Sharpe:", round(sharpe(cheat.dropna()), 2), " Correct version Sharpe:", round(sharpe(honest.dropna()), 2))
Output:
Look-ahead version Sharpe: 0.57 Correct version Sharpe: -0.07
3.2 Why the difference is so large
Today's closing price simultaneously affects "today's moving average" (the signal) and "today's return." If the price jumps today, the moving average is more likely to cross up, making the signal more likely to be 1, thus "coincidentally" holding the asset on the day it jumps. This correlation arises entirely from the same data point being used twice, which is impossible in reality: you do not know the closing price before the market closes.
On purely random data, look-ahead bias creates a Sharpe ratio of 0.57 out of thin air. On real data, the fake returns it generates may be even larger and harder to detect because they are often hidden within complex data processing workflows:
- Using the mean and standard deviation of the full sample for normalization (see Factor Engineering);
- Using the Volume Weighted Average Price (VWAP) of the day as the execution price while simultaneously using it to calculate the signal;
- Aligning financial data by reporting period rather than announcement date;
- Shuffling time series during cross-validation in machine learning.
3.3 Defense Checklist
| Sin | Defense |
|---|---|
| Look-ahead bias | Explicitly shift all signals; use point-in-time databases; specifically check "could this data have been obtained at that time" during code reviews |
| Survivorship bias | Use databases that include delisted stocks and historical constituents |
| Overfitting | Fewer parameters; parameters should be effective around a reasonable range (parameter plateau) rather than a single spike |
| Data snooping | Record all ideas and the number of attempts; use the Deflated Sharpe Ratio for correction (see Multiple Testing and the Factor Zoo) |
| Ignoring costs | Incorporate commission, spread, and market impact cost models into the backtest (see Modeling Transaction Costs) |
| Ignoring capacity | Limit position sizes based on target fund size and target asset volume |
| Fallacy of out-of-sample | Look at out-of-sample data only once; or use "vault" data that is only opened for final confirmation (see Agent Factor Mining) |
3.4 A Healthy Research Habit
- Write down your hypothesis and rationale first, then look at the data (to avoid looking at results first and then inventing a story);
- Record every attempt, including the failures;
- Run your backtesting framework on simulated or shuffled data: if it can "make money" on random data, the framework definitely has look-ahead bias;
- Use paper trading or small-capital live trading as the final out-of-sample test.
Summary
| Layer | Key points |
|---|---|
| Seven Sins | Look-ahead, survivorship, overfitting, data snooping, ignoring costs, ignoring capacity, fallacy of out-of-sample |
| Demonstration 1 | Testing 826 parameter sets on random data results in an in-sample Sharpe of 0.61 and zero out-of-sample |
| Demonstration 2 | A shift(1) difference: look-ahead bias turns a Sharpe of −0.07 into 0.57 |
| Habits | Write hypotheses first; record all attempts; use random data to test the backtesting framework; verify with small-capital live trading |