Skip to content
SIAT PAPER 2026Open the research page
Cryptoverso

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
lab_14_bias_metodo.py
python
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) / 2

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

Twenty ideas with no edge, and the one that passes the test anyway.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Twenty sequences of 400 observations with zero edge; for each one the t statistic of the mean, compared with the one-tailed test 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
lab_14_bias_metodo.py
python
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
lab_14_bias_metodo.py
python
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
lab_14_bias_metodo.py
python
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
lab_14_bias_metodo.py
python
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
lab_14_bias_metodo.py
python
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

  1. 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.
  2. In the first cell raise IDEE to 500 and count how many pass. About 5%, as predicted. Each one, shown on its own, would look like a discovery.
  3. 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

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

Back to the lab index