The seven deadly biases
Worked examples
Read the claim, then run it and check the machine agrees. One at a time — nothing here is taken on trust.
import numpy as np
import pandas as pd
import qadata
prices = qadata.trending_prices()
rets = prices.pct_change().fillna(0.0)
rng = np.random.default_rng(42)
totals = []
for _ in range(50): # 50 strategies with NO edge at all
coin = pd.Series(rng.integers(0, 2, len(prices)).astype(float),
index=prices.index)
strat = coin.shift(1).fillna(0.0) * rets
totals.append((1.0 + strat).cumprod().iloc[-1] - 1.0)
totals = pd.Series(totals)
best = totals.idxmax()
print(f"best coin : #{best}, total {totals[best]:+.2%}")
print(f"median coin: {totals.median():+.2%}")
print(f"worst coin : {totals.min():+.2%}")
print(f"coins beating the honest 20d filter (+27.26%): {(totals > 0.2726).sum()} of 50")
Fifty position series drawn from a literal coin — causal, shifted, cost-free, and utterly without information. The best of them made +58.10% (Sharpe 1.38, better than the real trend filter), the median +12.00%, and ten of fifty beat +27.26%. If you had "tried a few ideas" and published the winner, this is exactly the number you'd be publishing. The fix isn't running fewer backtests — it's accounting for every one you ran, which is m05-2's whole subject.
import qadata
panel = qadata.universe() # 20 assets, 5 years, planted drifts
totals = panel.iloc[-1] / panel.iloc[0] - 1.0
survivors = totals[totals > 0] # what a survivor-only database keeps
print(f"all 20 assets, mean total return : {totals.mean():+.2%}")
print(f"the {len(survivors)} 'survivors' only : {survivors.mean():+.2%}")
print(f"the {(totals <= 0).sum()} assets a survivor database forgets:")
print(totals[totals <= 0].round(3).to_string())
The honest universe averages +71.54%; the 12 survivors average +136.56%. No signal, no strategy — just a filter on who's allowed into the average, applied by the database before you ever typed a line. Any backtest of "stocks in today's index" or "funds in today's database" inherits this boost invisibly. The eight forgotten assets are the whole story: they were real, tradeable, and would have been in your portfolio.
import numpy as np
import qadata
prices = qadata.trending_prices()
rets = prices.pct_change().fillna(0.0)
def zscore_full(p): # z-score with FULL-SAMPLE mean/std
z = (p - p.mean()) / p.std()
return (z > 0).astype(float)
def zscore_rolling(p): # same idea, trailing 20d window
z = (p - p.rolling(20).mean()) / p.rolling(20).std()
return (z > 0).astype(float)
for name, rule in [("full-sample z", zscore_full), ("rolling z", zscore_rolling)]:
strat = rule(prices).shift(1).fillna(0.0) * rets # shift discipline: intact!
eq = (1.0 + strat).cumprod()
print(f"{name:14}: total {eq.iloc[-1] - 1:+7.2%}, "
f"Sharpe {strat.mean() / strat.std() * np.sqrt(252):.2f}")
try:
qadata.assert_causal(zscore_full, prices)
except AssertionError as e:
print(f"caught: {e}")
qadata.assert_causal(zscore_rolling, prices)
print("rolling z: causal")
Both rules apply shift(1) faithfully, yet the full-sample version is look-ahead: its mean and std already contain every future close, so truncating history changes positions it had "already decided" — assert_causal names the date. Two lessons here. First, the disguise: no deleted shift, just an innocent-looking normalization. Second, the direction: the peeking rule made +8.07% against the causal rule's +27.26% — look-ahead HURT this time. A biased backtest isn't "my number, but optimistic"; it's no number at all. (Notice rolling z > 0 is exactly m02-1's filter: price above its 20d mean.)
Check the concept
One question at a time. Unsure? Revisit the lecture, then answer.
The challenge
Pass the quiz to unlock the challenge — your code will still be waiting here.