Lab from the book · L13
L13 — Lookahead, invariance tests and the five checks on the data
Lab 13 — How a backtest lies: the data
Code language
The code, its comments and its outputs are in Italian: they are the book’s code, kept identical to what the reader runs.
Notebook for the chapter "How a backtest lies — the data". The same backtest written in two versions, one causal and one with a one-line lookahead. Then the invariance test, which catches that error mechanically: you can paste your own code into it. And finally the five checks to run on the data before any calculation. Ten minutes, and they have the best ratio of time spent to errors found.
The lines marked TRY are the ones to change: edit them and rerun to see the effect. Everything else — including lines marked DO NOT CHANGE — exists to keep the result comparable with the one printed in the book.
Show the script for this step
import matplotlib.pyplot as plt
import numpy as np
from cvbook.dati import carica
from cvbook.regole import esegui
df = carica("btcusdt").sort("data")
prezzi = df["chiusura"].to_numpy()
FINESTRA = 20 # PROVA / TRY: qualunque valore (vedi esercizio 1) — il rapporto resta enorme
COSTO = 0.0012 # PROVA / TRY: 0,0006 · 0,0012 · 0,0025
def media_mobile(p: np.ndarray, finestra: int) -> np.ndarray:
cumulata = np.concatenate([[0.0], np.cumsum(p)])
m = np.full(len(p), np.nan)
m[finestra - 1:] = (cumulata[finestra:] - cumulata[:-finestra]) / finestra
return m1. One line of difference
The two versions of the same rule. Look only at the last line of each.
Two capital curves on a logarithmic scale over 3,240 days, with the vertical axis spanning seven orders of magnitude from ten to the zero to ten to the seventh. The lookahead version reaches 6,209,535 times the starting capital; the causal one, dashed, stops at 16.3. The two are 381,178 times apart, and the difference in the code is a single line.
Output
versione causale: 16.29x versione con lookahead:6,209,535.06x rapporto: 381,178 volte Una riga. Il risultato non e' un po' piu' ottimista: e' impossibile, ottenuto da una macchina che sapeva in anticipo come sarebbe finita la giornata.
Show the script for this step
media = media_mobile(prezzi, FINESTRA)
segnale = np.nan_to_num(np.where(prezzi > media, 1.0, 0.0))
# Versione CAUSALE: la posizione di oggi usa il segnale di ieri.
causale = np.zeros(len(prezzi))
causale[1:] = segnale[:-1]
# Versione CON LOOKAHEAD: la posizione di oggi usa il segnale di oggi, cioe'
# un'informazione che al momento di decidere non esisteva ancora.
# NON TOCCARE / DO NOT CHANGE: è sbagliata apposta, per il confronto — non è
# un bug da sistemare.
# It's wrong on purpose, for the comparison — not a bug to fix.
con_lookahead = segnale.copy()
a = esegui(prezzi, causale, costo=COSTO)
b = esegui(prezzi, con_lookahead, costo=COSTO)
with avvio.figura("schermo"):
fig, ax = plt.subplots()
ax.semilogy(b["curva"], linewidth=2, label=f"con lookahead — {b['finale']:,.0f}x")
ax.semilogy(a["curva"], linewidth=2, linestyle="--",
label=f"causale — {a['finale']:,.1f}x")
ax.set_ylabel("Capitale (scala log)")
ax.set_xlabel("Giorni")
ax.legend()
plt.show()
print(f"versione causale: {a['finale']:12,.2f}x")
print(f"versione con lookahead:{b['finale']:12,.2f}x")
print(f"rapporto: {b['finale'] / a['finale']:12,.0f} volte")
print("\nUna riga. Il risultato non e' un po' piu' ottimista: e' impossibile, "
"ottenuto da una macchina che sapeva in anticipo come sarebbe finita la "
"giornata.")2. The invariance test
Take the calculation, run it on the whole series, then on a truncated one, and compare the common part. They must be identical. If they change, the calculation is using future data. You can paste your own code into it: if the test fails, you've found your lookahead before it cost you money.
Output
regola causale: troncando a 400: identico troncando a 1200: identico troncando a 2400: identico esito: PASSA regola con lookahead: troncando a 400: identico troncando a 1200: identico troncando a 2400: identico esito: PASSA regola normalizzata sull'intero periodo: troncando a 400: DIVERSO, prima differenza al giorno 95 troncando a 1200: DIVERSO, prima differenza al giorno 95 troncando a 2400: DIVERSO, prima differenza al giorno 1218 esito: FALLISCE
Show the script for this step
def test_invarianza(funzione, p: np.ndarray, tagli=(400, 1200, 2400)) -> bool:
"""True se `funzione(p)` non cambia il passato quando arrivano dati nuovi."""
completa = funzione(p)
tutto_bene = True
for taglio in tagli:
parziale = funzione(p[:taglio])
uguali = np.allclose(parziale, completa[:taglio], equal_nan=True)
if not uguali:
primo = int(np.argmax(~np.isclose(parziale, completa[:taglio],
equal_nan=True)))
print(f" troncando a {taglio}: DIVERSO, prima differenza al giorno {primo}")
tutto_bene = False
else:
print(f" troncando a {taglio}: identico")
return tutto_bene
def regola_causale(p: np.ndarray) -> np.ndarray:
s = np.nan_to_num(np.where(p > media_mobile(p, FINESTRA), 1.0, 0.0))
pos = np.zeros(len(p))
pos[1:] = s[:-1]
return pos
def regola_con_lookahead(p: np.ndarray) -> np.ndarray:
return np.nan_to_num(np.where(p > media_mobile(p, FINESTRA), 1.0, 0.0))
def regola_normalizzata_male(p: np.ndarray) -> np.ndarray:
"""Errore diffusissimo: normalizzare usando media e deviazione di TUTTO."""
z = (p - p.mean()) / p.std()
return (z > 0).astype(float)
print("regola causale:")
print(" esito:", "PASSA" if test_invarianza(regola_causale, prezzi) else "FALLISCE")
print("\nregola con lookahead:")
print(" esito:", "PASSA" if test_invarianza(regola_con_lookahead, prezzi) else "FALLISCE")
print("\nregola normalizzata sull'intero periodo:")
print(" esito:", "PASSA" if test_invarianza(regola_normalizzata_male, prezzi) else "FALLISCE")Note the third case: there's no wrong lag, the code looks innocent. But normalizing with the mean of the entire period sneaks information about the last day into the first day of the test.
3. The five checks on the data
Before any calculation. They cost ten minutes; skipping them costs days of work built on a foundation that doesn't hold.
Output
=== btcusdt ===
1. righe: 3240 su 3240 giorni di calendario (100.0% di copertura)
2. i cinque movimenti piu' grandi:
2020-03-12 -39.5%
2017-12-07 +22.5%
2021-02-08 +19.5%
2018-01-16 -19.5%
2017-09-14 -19.2%
3. giorni a variazione esattamente zero: 0 (ok)
4. barre incoerenti (max sotto la chiusura, ecc.): 0
5. volume mediano dei primi 30 giorni: 744
volume mediano degli ultimi 30: 19,344
→ l'inizio della serie e' molto sottile: quei prezzi esistono, ma non erano ottenibili in quantita'.
=== lunausdt ===
1. righe: 846 su 863 giorni di calendario (98.0% di copertura)
2. i cinque movimenti piu' grandi:
2022-05-31 +17739900.0%
2022-09-09 +167.6%
2022-05-12 -100.0%
2022-05-11 -93.8%
2021-02-08 +87.4%
3. giorni a variazione esattamente zero: 0 (ok)
4. barre incoerenti (max sotto la chiusura, ecc.): 0
5. volume mediano dei primi 30 giorni: 3,230,763
volume mediano degli ultimi 30: 2,814,988Show the script for this step
def controlla(nome: str) -> None:
d = carica(nome).sort("data")
date = d["data"].to_list()
chiusura = d["chiusura"].to_numpy()
r = chiusura[1:] / chiusura[:-1] - 1.0
print(f"\n=== {nome} ===")
# 1. righe contro calendario
attesi = (date[-1] - date[0]).days + 1
print(f"1. righe: {len(date)} su {attesi} giorni di calendario "
f"({len(date) / attesi:.1%} di copertura)")
# 2. i venti movimenti piu' grandi
estremi = np.argsort(np.abs(r))[-5:][::-1]
print("2. i cinque movimenti piu' grandi:")
for i in estremi:
print(f" {date[i + 1]} {r[i]:+7.1%}")
# 3. giorni a variazione esattamente zero
zeri = int(np.sum(r == 0.0))
print(f"3. giorni a variazione esattamente zero: {zeri} "
f"({'sospetti: probabile riempimento' if zeri > 3 else 'ok'})")
# 4. coerenza fra massimo, minimo, apertura e chiusura
m, mi = d["massimo"].to_numpy(), d["minimo"].to_numpy()
ap, ch = d["apertura"].to_numpy(), d["chiusura"].to_numpy()
incoerenti = int(np.sum((m < ch) | (m < ap) | (mi > ch) | (mi > ap) | (m < mi)))
print(f"4. barre incoerenti (max sotto la chiusura, ecc.): {incoerenti}")
# 5. volume all'inizio e alla fine
v = d["volume"].to_numpy()
print(f"5. volume mediano dei primi 30 giorni: {np.median(v[:30]):,.0f}")
print(f" volume mediano degli ultimi 30: {np.median(v[-30:]):,.0f}")
if np.median(v[:30]) < np.median(v[-30:]) / 10:
print(" → l'inizio della serie e' molto sottile: quei prezzi esistono, "
"ma non erano ottenibili in quantita'.")
controlla("btcusdt")
controlla("lunausdt")Look at the second check on the dead token: a movement of over seventeen million percentage points shows up. It's not a file error: it's what happens when a price falls to five hundred-thousandths and then moves by a few decimal digits. In percentage terms these are absurd numbers; in money they're crumbs. That's why check number two should be done by looking, not by automating a threshold: any filter that discarded that day would discard real data, and any calculation that treats it as a normal return produces meaningless statistics. On series that get close to zero, percentage returns stop being the right representation.
Exercises
- In the first cell change
FINESTRA. The ratio between the two curves stays huge for any value: the lookahead isn't a calibration error, it's a type error. - Paste into the second cell your own function that computes a position and pass it to
test_invarianza. It's the check I recommend automating. - Write a fourth, wrong rule: use the maximum of the entire series as a threshold. Then pass it to the test and watch it fail.
Reproducibility & downloads
Run on 2026-08-27 from the repository notebook
The notebook
lab_13_bias_dati.ipynb14.9 KB
sha256 ac531ca9f98665dae8ae0b3fc5c0a5de1af4dd76e0085bb3cc74f648714f6217
lab_13_bias_dati.py11.4 KB
sha256 3dc8a1a32c009ac9f85e647b0e2af86c3bff8b177b0d29374261bfac0a03974b
The data
btcusdt.parquet93.2 KB
sha256 ea75ad84e6e981507054df5c622c6b0ec3c8849c1f4dd007721878d4e4c8a329
Source: Binance Data Vision · Period: 2017-08-17 → 2026-06-30 · 3,240 rows · extracted 2026-08-16
lunausdt.parquet27.6 KB
sha256 10fe10357f76eb408550f4809ce2a87cb1129164f6f6d074ae7eac730ccb7f15
Source: Binance Data Vision · Period: 2020-08-21 → 2022-12-31 · 846 rows · extracted 2026-08-16