Skip to content
SIAT PAPER 2026Open the research page
Cryptoverso

Lab from the book · L12

L12 — An honest backtest line by line, and the generator of the yardstick

Lab 12 — An honest backtest, line by line

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 "What a backtest really is". Two things. The first is a backtest written to be read: every step commented, costs included, causal computation. You can follow it even without knowing how to program. The second is the yardstick generator: given any set of rules, it produces a thousand comparable random strategies and tells you which percentile you're in. It's the piece of code I recommend reusing more than any other.

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_12_backtest_base.py
python
import matplotlib.pyplot as plt
import numpy as np

from cvbook import seed_for
from cvbook.dati import carica
from cvbook.metriche import drawdown_massimo
from cvbook.regole import compra_e_tieni, esegui, sopra_media

SERIE = "btcusdt"  # ← PROVA / TRY: "ethusdt" · "solusdt" (le tre preparate nel setup)
FINESTRA = 50      # PROVA / TRY: 20 · 100 · 200 (vedi esercizio 1)
COSTO = 0.0012     # PROVA / TRY: raddoppialo (vedi esercizio 2)

prezzi = carica(SERIE).sort("data")["chiusura"].to_numpy()

1. The backtest, one step at a time

The rule: stay in the market when price closes above its fifty-day average, stay out when below.

Output

btcusdt, media a 50 giorni, costi 0.12% per operazione

capitale finale:         64.40x
al lordo dei costi:      78.98x
operazioni:                170
tempo dentro:            51.5%
calo massimo:           -64.0%

compra e tieni:          13.66x  (calo massimo -83.2%)
Show the script for this step
lab_12_backtest_base.py
python
# Passo 1 — la media mobile. Alla riga t contiene la media dei prezzi FINO a t.
finestra = FINESTRA
cumulata = np.concatenate([[0.0], np.cumsum(prezzi)])
media = np.full(len(prezzi), np.nan)
media[finestra - 1:] = (cumulata[finestra:] - cumulata[:-finestra]) / finestra

# Passo 2 — il segnale. Confronta la chiusura di oggi con la sua media di oggi.
segnale = np.nan_to_num(np.where(prezzi > media, 1.0, 0.0))

# Passo 3 — LA RIGA CHE CONTA. La posizione di oggi usa il segnale di IERI.
# Senza questo sfasamento staremmo decidendo con un'informazione che, al momento
# della decisione, non esisteva ancora.
# NON TOCCARE / DO NOT CHANGE: questo sfasamento è la causalità del backtest.
# L'esercizio 3 ti invita a romperlo apposta per vedere il risultato
# spettacolare e falso che ne esce — non per lasciarlo rotto dopo.
# This lag is the backtest's causality. Exercise 3 invites you to break it on
# purpose to see the spectacular, fake result that comes out — not to leave
# it broken afterwards.
posizione = np.zeros(len(prezzi))
posizione[1:] = segnale[:-1]

# Passo 4 — il risultato, con i costi applicati a ogni cambio di posizione.
risultato = esegui(prezzi, posizione, costo=COSTO)

print(f"{SERIE}, media a {FINESTRA} giorni, costi {COSTO:.2%} per operazione\n")
print(f"capitale finale:      {risultato['finale']:8.2f}x")
print(f"al lordo dei costi:   {risultato['finale_lordo']:8.2f}x")
print(f"operazioni:           {risultato['operazioni']:8.0f}")
print(f"tempo dentro:         {risultato['esposizione']:8.1%}")
print(f"calo massimo:         {drawdown_massimo(risultato['curva']):8.1%}")

riferimento = esegui(prezzi, compra_e_tieni(prezzi), costo=COSTO)
print(f"\ncompra e tieni:       {riferimento['finale']:8.2f}x  "
      f"(calo massimo {drawdown_massimo(riferimento['curva']):.1%})")

2. The causality check, done in three lines

The mechanical and definitive test: compute the position over the whole series, then over one truncated in half, and compare the common part. They must be identical. If they change, the calculation is using future data.

Output

troncando a   500 giorni: posizioni identiche? SI
troncando a  1500 giorni: posizioni identiche? SI
troncando a  2500 giorni: posizioni identiche? SI
Show the script for this step
lab_12_backtest_base.py
python
completa = sopra_media(prezzi, FINESTRA)
for taglio in (500, 1500, 2500):  # PROVA / TRY: aggiungi un altro punto di taglio
    parziale = sopra_media(prezzi[:taglio], FINESTRA)
    uguali = np.allclose(parziale, completa[:taglio])
    esito_test = "SI" if uguali else "NO, c'e' un lookahead"
    print(f"troncando a {taglio:5d} giorni: posizioni identiche? {esito_test}")

3. The yardstick: compared to what?

Not zero, not buy-and-hold. A thousand strategies that stay in the market the same number of days, make the same number of trades and pay the same costs, but choose their moments at random. It's the comparison that answers the right question: did it really take a method?

Histogram of the final capital of a thousand strategies that pick their moments at random, with a logarithmic horizontal axis from ten to the zero to ten squared times the starting capital and the count reaching 250. The mass is packed around 3 times, which is the median; a black vertical line marks the notebook's rule at 64 times, out in the right tail, and the title says it sits at the hundredth percentile.

The right yardstick: a thousand strategies that do the same things but pick their moments at random.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: A thousand strategies that stay in the market for the same number of days as the rule, make the same number of trades and pay the same costs, but choose their days at random.

Output

la regola: 64.40x con 170 operazioni
mediana delle casuali: 3.45x
percentile della regola: 99.5
Show the script for this step
lab_12_backtest_base.py
python
N_CASUALI = 1000


def _ripartisci(totale: int, parti: int, rng) -> np.ndarray:
    """Divide `totale` in `parti` addendi casuali, ciascuno almeno 1."""
    if parti == 1:
        return np.array([totale])
    libero = totale - parti
    tagli = np.sort(rng.integers(0, libero + 1, size=parti - 1))
    return np.diff(np.concatenate([[0], tagli, [libero]])) + 1


def posizione_casuale(n: int, giorni_dentro: int, n_operazioni: int, rng) -> np.ndarray:
    """Entra ed esce a caso, con gli STESSI giorni dentro e le stesse operazioni.

    Allineare solo le operazioni non basta: due strategie con lo stesso numero
    di entrate possono stare dentro al mercato per meta' del tempo o per il
    doppio, e su un asset che sale il tempo di esposizione vale piu' di
    qualunque bravura. Il metro deve tenere fissi tutti e due i numeri.
    """
    blocchi = max(n_operazioni // 2, 1)
    dentro = _ripartisci(giorni_dentro, blocchi, rng)
    fuori = _ripartisci(n - giorni_dentro, blocchi + 1, rng)
    pos = np.zeros(n)
    i = 0
    for b in range(blocchi):
        i += int(fuori[b])
        pos[i:i + int(dentro[b])] = 1.0
        i += int(dentro[b])
    return pos


def metro_del_caso(prezzi: np.ndarray, posizione: np.ndarray, *,
                   n_casuali: int = N_CASUALI, costo: float = COSTO,
                   seme: str = "metro-del-caso") -> dict:
    # NON TOCCARE / DO NOT CHANGE: «metro-del-caso» e' il seme della figura
    # stampata nel capitolo. Con quello — stesse mille strategie, stessi giorni
    # dentro, stesse operazioni — questa cella ridisegna l'istogramma del libro
    # e stampa il suo percentile. Con un seme qualunque il quaderno resta
    # corretto ma risponde con un numero diverso da quello in pagina, ed e'
    # esattamente la promessa che il libro fa in copertina.
    # This is the seed of the figure printed in the chapter: with it the cell
    # redraws the book's histogram and prints its percentile.
    """Quale percentile occupa questa posizione, fra tante casuali equivalenti?"""
    vera = esegui(prezzi, posizione, costo=costo)
    n_op = int(vera["operazioni"])
    giorni_dentro = int(np.asarray(posizione).sum())
    rng = np.random.default_rng(seed_for(seme))
    casuali = np.array([
        esegui(prezzi, posizione_casuale(len(prezzi), giorni_dentro, n_op, rng),
               costo=costo)["finale"]
        for _ in range(n_casuali)
    ])
    return {
        "risultato": vera["finale"],
        "operazioni": n_op,
        "giorni_dentro": giorni_dentro,
        "casuali": casuali,
        "percentile": float((casuali < vera["finale"]).mean() * 100),
    }


esito = metro_del_caso(prezzi, posizione)

with avvio.figura("schermo"):
    fig, ax = plt.subplots()
    ax.hist(esito["casuali"], bins=60)
    ax.axvline(esito["risultato"], linewidth=2.5, color="black")
    ax.set_xscale("log")
    ax.set_xlabel("Capitale finale (volte, scala log)")
    ax.set_ylabel(f"Su {N_CASUALI} strategie casuali")
    ax.set_title(f"La regola sta al {esito['percentile']:.0f}esimo percentile")
    plt.show()

print(f"la regola: {esito['risultato']:.2f}x con {esito['operazioni']} operazioni")
print(f"mediana delle casuali: {np.median(esito['casuali']):.2f}x")
print(f"percentile della regola: {esito['percentile']:.1f}")

4. Reuse the yardstick on your own rule

Replace posizione with your own and rerun. It's the part of the notebook worth copying elsewhere.

Output

ethusdt:   42.64x  →  percentile  98.3
solusdt:   99.73x  →  percentile  99.0
Show the script for this step
lab_12_backtest_base.py
python
for altra_serie in ("ethusdt", "solusdt"):
    p = carica(altra_serie).sort("data")["chiusura"].to_numpy()
    e = metro_del_caso(p, sopra_media(p, FINESTRA), n_casuali=300,
                       seme=f"metro-{altra_serie}")
    print(f"{altra_serie}: {e['risultato']:7.2f}x  →  percentile {e['percentile']:5.1f}")

An important warning

This notebook's result is strong, and worthless. The fifty-day window wasn't chosen beforehand: it was chosen looking at the data. The chapter "Optimizing is fooling yourself" takes this very result apart, and Lab 15 makes you take it apart yourself. This is exactly how a tainted result presents itself: not with fake data, but with real data and a choice made while looking at it.

Exercises.

  1. Change FINESTRA from 50 to 20, 100, 200. The percentile swings a lot. Which of those values would you have chosen before seeing the results?
  2. Double COSTO. The percentile drops: an edge that only survives at optimistic costs belongs to whoever has low costs, not to you.
  3. In cell 2, try removing the step-3 lag (set posizione = segnale) and rerun everything. The result becomes spectacular and the causality check fails. It's one line.

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