Skip to content
Course contents
Putting it together

Testing on history, honestly

A backtest runs the signal over past data to see how it would have done, and it is the most seductive and most dangerous tool in trading. You compute the strategy's historical return, then spend the chapter on the traps, lookahead, costs, and overfitting, that make backtests lie.

9 min readChapter 27 of 30
What you will learn
  • Compute the historical return of a signal-based strategy
  • Name and avoid the core backtest traps (lookahead bias, ignoring costs, overfitting)
  • Include trading costs, connecting to the cost stack from Taxation

A backtest runs your rule over past data to see how it would have performed, and it is the most seductive tool in all of trading and the most dangerous. Seductive because a good-looking backtest feels like proof you have found money. Dangerous because it is extraordinarily easy to produce a beautiful backtest that would never have worked in reality. This chapter tests the crossover from the last one honestly, and the honest result is the whole point of the course.

The honest backtest

A backtest runs the signal over past data against buy-and-hold, and is the most seductive and dangerous tool in trading, because lookahead, costs, and overfitting make backtests lie.
A backtest runs the signal over past data against buy-and-hold, and is the most seductive and dangerous tool in trading, because lookahead, costs, and overfitting make backtests lie.

A correct backtest has two non-negotiable features, both in this code: it acts only on information available at the time, and it charges for trading.

ExampleAn honest backtest: no lookahead, with costs, versus buy-and-holdch27/backtest.py
# Backtest the crossover, honestly: no lookahead, and with costs.
import pandas as pd

df = pd.read_csv("sample_prices.csv", parse_dates=["Date"], index_col="Date")
df["fast"] = df["Close"].rolling(20).mean()
df["slow"] = df["Close"].rolling(50).mean()
df["signal"] = (df["fast"] > df["slow"]).astype(int)

# CRUCIAL: act on YESTERDAY's signal, not today's, or you are peeking at the future.
df["position"] = df["signal"].shift(1).fillna(0)

df["market_return"] = df["Close"].pct_change().fillna(0)
df["strategy_return"] = df["position"] * df["market_return"]

# A cost every time the position changes (a trade).
cost_per_trade = 0.001                                   # 0.1%, illustrative
df["trade"] = df["position"].diff().abs().fillna(0)
df["strategy_return"] = df["strategy_return"] - df["trade"] * cost_per_trade

buy_hold = (1 + df["market_return"]).prod() - 1
strategy = (1 + df["strategy_return"]).prod() - 1
print(f"Buy-and-hold return:          {buy_hold * 100:.2f}%")
print(f"Strategy return (with costs): {strategy * 100:.2f}%")
print(f"Number of trades: {int(df['trade'].sum())}")
Output
Buy-and-hold return:          0.62%
Strategy return (with costs): -8.11%
Number of trades: 5

The critical line is df["position"] = df["signal"].shift(1). It shifts the signal forward one day, so the strategy acts on yesterday's signal, never today's, which is how you avoid the lookahead cheating from the last chapter. The strategy's return each day is then the position times that day's market return, and a cost of 0.1% is subtracted every time the position changes, the cost stack from the Taxation course, made real. Finally it compares the strategy to simply buying and holding.

Now read the result, and do not look away from it. Buy-and-hold returned 0.62% over the period. The crossover strategy, tested honestly with costs, returned minus 8.11%, across 5 trades. The famous, sensible-looking strategy did not just underperform holding the stock; it lost money outright. This is not a bug in the code. It is the ordinary truth about simple strategies, and the reason the whole trading catalogue keeps insisting that most approaches lose.

The traps that make backtests lie

Three traps turn honest results like that one into dishonest, encouraging ones, and avoiding them is most of what separates a real test from a fantasy.

The first and deadliest is lookahead bias: using information in the test that you could not have had at the time. Acting on a signal computed from today's close, as if you had traded at today's open, is the classic version, and it can turn a losing rule into a spectacular one on paper. The one-day shift in the code is the fix; without it, this backtest would be a lie.

The second is ignoring costs. Every trade pays brokerage, the securities transaction tax, and the rest, as the Taxation course detailed, plus slippage from imperfect fills. A strategy that looks profitable before costs can be a loser after them, especially one that trades often, and leaving costs out is the most common way beginners flatter a strategy. Here, including a modest cost pushed an already-poor result further into the red.

The third is overfitting, tuning the rule to fit the past so well that it captures noise rather than a real pattern, which the next chapter but one demonstrates directly. A backtest polished until it looks wonderful on history has usually just been fitted to that particular history, and it falls apart on new data.

The honest attitude, then, is to treat every backtest as a hypothesis to be doubted, not a promise to be believed. A good-looking backtest is where scrutiny begins, not ends.

What to carry forward

An honest backtest acts only on information available at the time (shift the signal by a day to avoid lookahead) and charges for every trade (costs, from the Taxation course). Tested this way, the famous crossover lost 8.11% against buy-and-hold's 0.62%, which is the ordinary fate of simple rules, not a fluke. The traps that make backtests lie are lookahead, ignoring costs, and overfitting, so treat every backtest as a hypothesis to doubt.

A single return figure, even an honest one, is not enough to judge a strategy. The next chapter measures it the way the Risk and Psychology course taught, by its drawdown and its risk, not just its return.