Skip to content
SIAT PAPER 2026Open the research page
Cryptoverso

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

The same rule written twice: one looks at the day in progress, the other does not.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Same series and same rule, computed once with the position decided on the same day's close and once on the previous day's close.

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
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. 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
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")

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,988
Show the script for this step
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")

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

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

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

Back to the lab index