Vai al contenuto
PAPER SIAT 2026Apri la pagina della ricerca
Cryptoverso

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
lab_13_bias_dati.py
python
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 m

1. 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.

La stessa regola scritta due volte: una guarda il giorno in corso, l'altra no.Fonte: Binance Data Vision · Periodo: 2017-08-17 … 2026-06-30 · Metodo: Stessa serie e stessa regola, calcolate una volta con la posizione decisa sulla chiusura dello stesso giorno e una volta sulla chiusura del giorno precedente.

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
lab_13_bias_dati.py
python
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
lab_13_bias_dati.py
python
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,988
Espandi lo script di questo passo
lab_13_bias_dati.py
python
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

  1. 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.
  2. Incolla nella seconda cella una tua funzione che calcoli una posizione e passala a test_invarianza. È il controllo che consiglio di automatizzare.
  3. 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

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

Torna all’indice dei lab