Lab del libro · L13
L13 — Lookahead, test di invarianza e i cinque controlli sui dati
Lab 13 — Come mente un backtest: i dati
Quaderno del capitolo «Come mente un backtest — i dati» di La matematica di chi perde.
Lo stesso backtest scritto in due versioni, una causale e una con un lookahead di una riga. Poi il test di invarianza, che scova quell'errore in modo meccanico: puoi incollarci dentro il tuo codice.
E infine i cinque controlli da fare sui dati prima di qualunque calcolo. Dieci minuti, e sono quelli con il miglior rapporto fra tempo speso ed errori trovati.
Le righe marcate PROVA sono quelle da cambiare: cambiale e riesegui per vedere l'effetto. Il resto — comprese le righe marcate NON TOCCARE — serve a mantenere il risultato confrontabile con quello stampato nel libro.
Espandi lo script di questo passo
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. Una riga di differenza
Le due versioni della stessa regola. Guarda solo l'ultima riga di ciascuna.
Due curve di capitale su scala logaritmica lungo 3.240 giorni, con l'asse verticale che copre sette ordini di grandezza da 10 alla 0 a 10 alla 7. La versione con lookahead arriva a 6.209.535 volte il capitale iniziale; quella causale, tratteggiata, si ferma a 16,3. Fra le due corrono 381.178 volte, e la differenza nel codice e' una riga.
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.
Espandi lo script di questo passo
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. Il test di invarianza
Prendi il calcolo, eseguilo su tutta la serie, poi su una serie troncata, e confronta la parte comune. Devono essere identici. Se cambiano, il calcolo sta usando dati successivi.
Puoi incollarci dentro il tuo codice: se il test fallisce, hai trovato il tuo lookahead prima che ti costasse dei soldi.
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
Espandi lo script di questo passo
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")Nota il terzo caso: non c'è nessuno sfasamento sbagliato, il codice sembra innocuo. Ma normalizzare con la media dell'intero periodo infila nel primo giorno del test un'informazione sull'ultimo.
3. I cinque controlli sui dati
Prima di qualunque calcolo. Costano dieci minuti; saltarli costa giorni di lavoro costruito su una base che non regge.
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,988Espandi lo script di questo passo
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")Guarda il secondo controllo sul token morto: compare un movimento di oltre diciassette milioni di punti percentuali. Non è un errore del file: è quello che succede quando un prezzo scende a cinque centomillesimi e poi si muove di qualche cifra decimale. In percentuale sono numeri assurdi; in denaro sono briciole.
È il motivo per cui il controllo numero due va fatto guardando, non automatizzando una soglia: qualunque filtro che scartasse quel giorno scarterebbe un dato vero, e qualunque calcolo che lo tratti come un rendimento normale produce statistiche prive di senso. Su serie che arrivano vicino allo zero, i rendimenti percentuali smettono di essere la rappresentazione giusta.
Esercizi
- Nella prima cella cambia
FINESTRA. Il rapporto fra le due curve resta enorme per qualunque valore: il lookahead non è un errore di taratura, è un errore di tipo. - Incolla nella seconda cella una tua funzione che calcoli una posizione e passala a
test_invarianza. È il controllo che consiglio di automatizzare. - Scrivi una quarta regola sbagliata: usa il massimo dell'intera serie come soglia. Poi passala al test e guardalo fallire.
Riproducibilità e download
Eseguito il 2026-08-27 dal quaderno del repository
Il quaderno
lab_13_bias_dati.ipynb14,9 KB
sha256 ac531ca9f98665dae8ae0b3fc5c0a5de1af4dd76e0085bb3cc74f648714f6217
lab_13_bias_dati.py11,4 KB
sha256 3dc8a1a32c009ac9f85e647b0e2af86c3bff8b177b0d29374261bfac0a03974b
I dati
btcusdt.parquet93,2 KB
sha256 ea75ad84e6e981507054df5c622c6b0ec3c8849c1f4dd007721878d4e4c8a329
Fonte: Binance Data Vision · Periodo: 2017-08-17 → 2026-06-30 · 3240 righe · estratto 2026-08-16
lunausdt.parquet27,6 KB
sha256 10fe10357f76eb408550f4809ce2a87cb1129164f6f6d074ae7eac730ccb7f15
Fonte: Binance Data Vision · Periodo: 2020-08-21 → 2022-12-31 · 846 righe · estratto 2026-08-16