Lab from the book · L14
L14 — Twenty strategies with no edge and the multiple-testing correction
Lab 14 — How a backtest lies: the method
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 method". The previous chapter's errors live in the code and can be found. These don't: they live in how you worked, leave no trace, and fail no automated check. Here we make them visible with simulations, and at the end there's the multiple-testing corrector: tell it how many attempts you made and it gives back the threshold you should have used.
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
from math import erf, sqrt
import matplotlib.pyplot as plt
import numpy as np
from cvbook import seed_for
from cvbook.dati import carica
from cvbook.regole import compra_e_tieni, esegui, rottura, sopra_media
ALFA = 0.05
def quantile_normale(p: float) -> float:
basso, alto = -10.0, 10.0
for _ in range(200):
mezzo = (basso + alto) / 2
if 0.5 * (1 + erf(mezzo / sqrt(2))) < p:
basso = mezzo
else:
alto = mezzo
return (basso + alto) / 21. Twenty ideas that don't work, and the one that passes
Twenty bars, one per idea tried, with the t statistic up the side from minus 2 to 1.5 and a dashed line on the standard test threshold, 1.64. None of the twenty ideas has an edge inside it — it was set to zero — and yet one bar clears the threshold.
Output
1 idee su 20 hanno superato il test. Nessuna di esse aveva un vantaggio: era zero, messo li' da noi.
Show the script for this step
IDEE = 20 # PROVA / TRY: 500 (vedi esercizio 2)
OSSERVAZIONI = 400
rng = np.random.default_rng(seed_for("lab-metodo-venti"))
soglia = quantile_normale(1 - ALFA)
statistiche = []
for _ in range(IDEE):
campione = rng.normal(0.0, 0.03, OSSERVAZIONI) # vantaggio ESATTAMENTE zero
statistiche.append(campione.mean() / (campione.std(ddof=1) / np.sqrt(OSSERVAZIONI)))
statistiche = np.array(statistiche)
passate = statistiche > soglia
with avvio.figura("schermo"):
fig, ax = plt.subplots(figsize=(9, 4))
ax.bar(np.arange(1, IDEE + 1), statistiche)
ax.axhline(soglia, linestyle="--", linewidth=1.5,
label=f"soglia del test standard ({soglia:.2f})")
ax.set_xlabel("Idea provata")
ax.set_ylabel("Statistica t")
ax.legend()
plt.show()
print(f"{int(passate.sum())} idee su {IDEE} hanno superato il test.")
print("Nessuna di esse aveva un vantaggio: era zero, messo li' da noi.")2. The table that changes the meaning of every result
Output
idee provate prob. che almeno una passi
1 5.0%
5 22.6%
10 40.1%
20 64.2%
50 92.3%
100 99.4%
Chi prova cento configurazioni TROVERA' qualcosa che supera il test. E non sapra' di aver trovato niente, perche' il risultato finale ha esattamente lo stesso aspetto di una scoperta vera.Show the script for this step
print(f"{'idee provate':>13s} {'prob. che almeno una passi':>28s}")
for n in (1, 5, 10, 20, 50, 100):
print(f"{n:13d} {1 - (1 - ALFA) ** n:27.1%}")
print("\nChi prova cento configurazioni TROVERA' qualcosa che supera il test. "
"E non sapra' di aver trovato niente, perche' il risultato finale ha "
"esattamente lo stesso aspetto di una scoperta vera.")3. The attempts you don't count
The chapter lists the choices that show up in no tally: which asset, which period, which rule, when to stop. Here we actually count them, on real data.
Output
combinazioni provate: 45 (3 serie × 5 regole × 3 date d'inizio) la migliore: rottura a 20 su ethusdt partendo dal giorno 0 → 48.88x, cioe' 9.40 volte il compra-e-tieni quante battono il compra-e-tieni: 38 su 45 (84%) mediana del rapporto: 1.94 Se pubblicassi solo la prima riga, non avrei mentito su nessun numero. Avrei omesso il denominatore.
Show the script for this step
SERIE = ["btcusdt", "ethusdt", "solusdt"]
REGOLE = { # PROVA / TRY: aggiungi una regola (vedi esercizio 1)
"sopra la media 50": lambda p: sopra_media(p, 50),
"sopra la media 100": lambda p: sopra_media(p, 100),
"sopra la media 200": lambda p: sopra_media(p, 200),
"rottura a 20": lambda p: rottura(p, 20),
"rottura a 55": lambda p: rottura(p, 55),
}
PARTENZE = [0, 365, 730] # PROVA / TRY: aggiungi una data d'inizio (esercizio 1)
risultati = []
for nome_serie in SERIE:
p_intero = carica(nome_serie).sort("data")["chiusura"].to_numpy()
for nome_regola, regola in REGOLE.items():
for partenza in PARTENZE:
p = p_intero[partenza:]
if len(p) < 400:
continue
finale = esegui(p, regola(p), costo=0.0012)["finale"]
# Il denominatore passa dallo stesso motore delle regole, con gli
# stessi costi: `p[-1] / p[0]` faceva entrare il compra-e-tieni
# senza pagare il proprio ingresso mentre ogni regola pagava.
riferimento = esegui(p, compra_e_tieni(p), costo=0.0012)["finale"]
risultati.append((nome_serie, nome_regola, partenza,
finale, finale / riferimento))
print(f"combinazioni provate: {len(risultati)} "
f"({len(SERIE)} serie × {len(REGOLE)} regole × {len(PARTENZE)} date d'inizio)\n")
rapporti = np.array([r[4] for r in risultati])
migliore = risultati[int(np.argmax(rapporti))]
print(f"la migliore: {migliore[1]} su {migliore[0]} partendo dal giorno {migliore[2]}")
print(f" → {migliore[3]:.2f}x, cioe' {migliore[4]:.2f} volte il compra-e-tieni\n")
print(f"quante battono il compra-e-tieni: {int((rapporti > 1).sum())} su {len(rapporti)} "
f"({(rapporti > 1).mean():.0%})")
print(f"mediana del rapporto: {np.median(rapporti):.2f}")
print("\nSe pubblicassi solo la prima riga, non avrei mentito su nessun numero. "
"Avrei omesso il denominatore.")4. The multiple-testing corrector
Apply it to your past results. With some emotional caution.
Output
tentativi dichiarati: 45 soglia non corretta: 1.64 soglia corretta: 3.06 il tuo 2.40 NON supera la soglia corretta.
Show the script for this step
def soglia_corretta(tentativi: int, alfa: float = ALFA) -> float:
"""Correzione conservativa: il rischio accettato si divide per i tentativi."""
return quantile_normale(1 - alfa / tentativi)
TUOI_TENTATIVI = len(risultati) # ← PROVA / TRY: metti il numero dal TUO registro delle ipotesi
TUA_STATISTICA = 2.4 # ← PROVA / TRY: la statistica t del TUO risultato migliore
print(f"tentativi dichiarati: {TUOI_TENTATIVI}")
print(f"soglia non corretta: {quantile_normale(1 - ALFA):.2f}")
print(f"soglia corretta: {soglia_corretta(TUOI_TENTATIVI):.2f}")
print(f"\nil tuo {TUA_STATISTICA:.2f} " +
("SUPERA" if TUA_STATISTICA > soglia_corretta(TUOI_TENTATIVI) else "NON supera") +
" la soglia corretta.")5. The hypothesis log
All of this chapter's defences boil down to a single practice, which costs ten minutes and is worth more than any sophisticated technique. Here's the skeleton: copy it into a text file and keep it in chronological order.
Output
data: 2026-08-16 ipotesi: la rottura del massimo a N giorni produce un vantaggio su BTC successo se: supera il 95esimo percentile del metro del caso, con costi 0,25% fallimento se: resta sotto, oppure il risultato dipende da N in modo instabile varianti previste: 24 valori di N, un solo mercato --- esito --- risultato: tentativi effettivi: note: Dopo sei mesi quel file ti dira' una cosa che nessun backtest puo' dirti: QUANTE idee hai provato in tutto. E quel numero e' il moltiplicatore da applicare a ogni tuo risultato positivo.
Show the script for this step
MODELLO = """\
data: 2026-08-16
ipotesi: la rottura del massimo a N giorni produce un vantaggio su BTC
successo se: supera il 95esimo percentile del metro del caso, con costi 0,25%
fallimento se: resta sotto, oppure il risultato dipende da N in modo instabile
varianti previste: 24 valori di N, un solo mercato
--- esito ---
risultato:
tentativi effettivi:
note:
"""
print(MODELLO)
print("Dopo sei mesi quel file ti dira' una cosa che nessun backtest puo' dirti: "
"QUANTE idee hai provato in tutto. E quel numero e' il moltiplicatore da "
"applicare a ogni tuo risultato positivo.")Exercises
- In the third cell add a rule and a start date. The number of combinations grows as a product, not a sum — and that's the number that matters.
- In the first cell raise
IDEEto 500 and count how many pass. About 5%, as predicted. Each one, shown on its own, would look like a discovery. - Take one of your own past results, estimate how many attempts were behind it (counting informal ones too), and run it through the corrector. It's the most uncomfortable exercise in the book.
Reproducibility & downloads
Run on 2026-08-27 from the repository notebook
The notebook
lab_14_bias_metodo.ipynb13.3 KB
sha256 1f7f24eec11a879da3e44a00cd8bc5a9157015a8bab673c4f23fa8d3f5f4f197
lab_14_bias_metodo.py9.8 KB
sha256 8a162337cee3fd77ee9a569a0d82204e96d9c9cf8ea1e844681bdd2aac9f6b05
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
ethusdt.parquet87.0 KB
sha256 c2bd0259da905e0fec87235d7a62295532433fb89657726dd2d19558db7c072a
Source: Binance Data Vision · Period: 2017-08-17 → 2026-06-30 · 3,240 rows · extracted 2026-08-16
solusdt.parquet57.5 KB
sha256 c7ba2368a3e419b898fb31ec6d5345b7212b74784b69079d3d43571c2ac63657
Source: Binance Data Vision · Period: 2020-08-11 → 2026-06-30 · 2,150 rows · extracted 2026-08-16