Skip to content
SIAT PAPER 2026Open the research page
Cryptoverso

Lab from the book · L18

L18 — A thousand paths instead of one, and the three numbers that decide

Lab 18 — A thousand futures instead of one

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 "A thousand futures instead of one". Every capital curve you've ever seen is one realization. Here you generate the other possible histories, made of the exact same raw material, and look at where the one that actually happened falls. The most useful output is a single sentence: "in the worst 5% of cases you would have closed at X and gone through a drawdown of Y%." That sentence, read before opening a position, changes sizing more than any reasoning.

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_18_montecarlo.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, rendimenti
from cvbook.simulazioni import bootstrap_traiettorie, distribuzione_esiti

SERIE = "btcusdt"  # ← PROVA / TRY: "ethusdt" · "solusdt" (vedi esercizio 3)
PERCORSI = 5000     # ← mille bastano per la mediana, per le CODE servono di piu'
                    # PROVA / TRY: 1000 · 5000 · 20000 (vedi sezione 4 qui sotto)
BLOCCHI = 20        # ← lunghezza dei blocchi ricampionati
                    # PROVA / TRY: 5 · 20 · 60 (vedi esercizio 2)

r = rendimenti(carica(SERIE).sort("data")["chiusura"].to_numpy())
reale = np.cumprod(1 + r)

1. The other possible histories

Returns are neither modified nor modeled: they are exactly those, fat tails and all. They get block-shuffled, not day by day — shuffling individual days would destroy the volatility clustering Lab 9 measured, producing paths that are too tame.

Two side-by-side panels over 3,239 days. On the left 120 of the 5,000 resampled paths, pale and overlaid, on a logarithmic scale from ten to the minus two to ten cubed, with the history that actually happened in black, ending at 13.7 times the starting capital. On the right a histogram of each path's maximum drawdown, with the axis running from minus 100 to minus 50: the mass sits between 70 and 90, and a black line marks the drawdown that actually happened, 83.

The other possible histories next to the one that happened, and the drawdowns each of them went through.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Five thousand paths obtained by shuffling the changes that actually happened in blocks of 20 days, without modifying or modelling them; the seed is fixed by the notebook.

Output

btcusdt: 3239 giorni ricampionati a blocchi di 20

la storia capitata:         13.68x   calo massimo  -83.2%
mediana dei percorsi:       15.12x   calo mediano  -79.9%
il 5% peggiore chiude a:     0.47x   con cali fino a  -95.3%
il 5% migliore chiude a:   446.78x
percorsi che finiscono sotto il capitale iniziale: 10.3%
Show the script for this step
lab_18_montecarlo.py
python
rng = np.random.default_rng(seed_for("lab-montecarlo"))
percorsi = bootstrap_traiettorie(r, n_traiettorie=PERCORSI, rng=rng, a_blocchi=BLOCCHI)

with avvio.figura("schermo"):
    fig, (sx, dx) = plt.subplots(1, 2, figsize=(12, 4.5))
    for k in range(120):
        # `rasterized=True` sul solo strato denso: 120 traiettorie da 3 240
        # punti sono ~389 000 vertici, e in un SVG ogni vertice diventa testo.
        # Rasterizzando queste strisce — e solo queste — assi, griglia, legenda
        # e numeri restano vettoriali e leggibili, la figura non cambia di una
        # traiettoria, e il file passa da 2 132 KB a 148 KB.
        sx.semilogy(percorsi[k], linewidth=0.5, alpha=0.4, color="#7A8CC7", rasterized=True)
    sx.semilogy(reale, linewidth=2.5, color="black", label="la storia capitata")
    sx.set_ylabel("Capitale (scala log)")
    sx.set_xlabel("Giorni")
    sx.legend()

    cali = np.array([drawdown_massimo(p) for p in percorsi])
    dx.hist(cali * 100, bins=60)
    dx.axvline(drawdown_massimo(reale) * 100, linewidth=2.5, color="black")
    dx.set_xlabel("Calo massimo del percorso (%)")
    dx.set_ylabel(f"Su {PERCORSI} percorsi")
    plt.show()

esiti = distribuzione_esiti(percorsi)
print(f"{SERIE}: {len(r)} giorni ricampionati a blocchi di {BLOCCHI}\n")
print(f"la storia capitata:      {reale[-1]:8.2f}x   calo massimo {drawdown_massimo(reale):7.1%}")
print(f"mediana dei percorsi:    {esiti['mediana_finale']:8.2f}x   calo mediano "
      f"{esiti['drawdown_mediano']:7.1%}")
print(f"il 5% peggiore chiude a: {esiti['peggiore_5pct']:8.2f}x   con cali fino a "
      f"{esiti['drawdown_peggiore_5pct']:7.1%}")
print(f"il 5% migliore chiude a: {esiti['migliore_5pct']:8.2f}x")
print(f"percorsi che finiscono sotto il capitale iniziale: {esiti['prob_perdita']:.1%}")

Reread the last line. With the exact same returns, a non-negligible share of paths ends at a loss. Not from a bad decision: from the combination in which things arrived. And note the maximum drawdown: the one already seen is not the worst possible. It's only the worst of one realization, i.e. of a sample of size one.

2. From the chart to the decision: three numbers and a threshold

Enter your threshold and see whether the position is too big.

Output

calo al 5esimo percentile dei percorsi possibili: -95.3%
mettendoci il 50% del capitale, sul totale fa: 47.7%
la tua soglia:                                     30.0%

→ la posizione e' TROPPO GRANDE. Compatibile con la tua soglia: 31.5% del capitale, cioe' 6,294 euro.

Regola: riduci la posizione finche' il calo al quinto percentile non sta sotto la tua soglia. Non serve altro, e questo unico passaggio fa piu' lavoro di qualunque affinamento della strategia.
Show the script for this step
lab_18_montecarlo.py
python
CAPITALE = 20_000.0     # PROVA / TRY: il tuo capitale reale
SOGLIA_PERDITA = 0.30   # ← oltre questo calo cambieresti comportamento
                        # PROVA / TRY: la tua soglia vera
QUOTA = 0.50            # ← quanta parte del capitale metti in questa posizione
                        # PROVA / TRY: la quota che stai davvero valutando

calo_5pct = float(np.percentile([drawdown_massimo(p) for p in percorsi], 5))
calo_atteso_sul_totale = abs(calo_5pct) * QUOTA

print(f"calo al 5esimo percentile dei percorsi possibili: {calo_5pct:.1%}")
print(f"mettendoci il {QUOTA:.0%} del capitale, sul totale fa: {calo_atteso_sul_totale:.1%}")
print(f"la tua soglia:                                     {SOGLIA_PERDITA:.1%}\n")
if calo_atteso_sul_totale > SOGLIA_PERDITA:
    quota_compatibile = SOGLIA_PERDITA / abs(calo_5pct)
    print(f"→ la posizione e' TROPPO GRANDE. Compatibile con la tua soglia: "
          f"{quota_compatibile:.1%} del capitale, cioe' "
          f"{quota_compatibile * CAPITALE:,.0f} euro.")
else:
    print("→ la posizione e' compatibile con la soglia che hai dichiarato.")

print("\nRegola: riduci la posizione finche' il calo al quinto percentile non sta "
      "sotto la tua soglia. Non serve altro, e questo unico passaggio fa piu' "
      "lavoro di qualunque affinamento della strategia.")

3. Why in blocks and not day by day

The comparison that justifies the technical caveat.

Output

                         memoria della vol.   calo mediano   5% peggiore
     giorno per giorno               -0.002         -77.2%        -94.5%
       a blocchi di 20                0.164         -79.6%        -94.8%
        la storia vera                0.175         -83.2%             —

La prima colonna e' quella che decide. Rimescolare giorno per giorno azzera la memoria della volatilita': si ottengono percorsi in cui i giorni agitati sono sparsi, che non e' come si comporta nessun mercato. I blocchi la conservano quasi tutta.

Sulle altre due colonne, invece, la differenza qui e' piccola — e va detto invece di nasconderlo. Su un orizzonte di nove anni il calo massimo e' dominato dall'accumulo, non dal raggruppamento. Il metodo a blocchi resta quello giusto, ma su QUESTE due misure non e' li' che si vede.
Show the script for this step
lab_18_montecarlo.py
python
def memoria_della_volatilita(percorso: np.ndarray) -> float:
    """Autocorrelazione a un giorno dell'ampiezza dei movimenti.

    E' la misura diretta del raggruppamento: se i giorni agitati arrivano in
    gruppo, l'ampiezza di oggi somiglia a quella di ieri.
    """
    variazioni = np.abs(percorso[1:] / percorso[:-1] - 1.0)
    return float(np.corrcoef(variazioni[:-1], variazioni[1:])[0, 1])


rng2 = np.random.default_rng(seed_for("lab-montecarlo-confronto"))
puntuale = bootstrap_traiettorie(r, n_traiettorie=400, rng=rng2, a_blocchi=None)
a_blocchi = bootstrap_traiettorie(r, n_traiettorie=400, rng=rng2, a_blocchi=BLOCCHI)

print(f"{'':>22s} {'memoria della vol.':>20s} {'calo mediano':>14s} {'5% peggiore':>13s}")
for nome, insieme in (("giorno per giorno", puntuale), (f"a blocchi di {BLOCCHI}", a_blocchi)):
    memoria = np.median([memoria_della_volatilita(p) for p in insieme])
    cali = np.array([drawdown_massimo(p) for p in insieme])
    print(f"{nome:>22s} {memoria:20.3f} {np.median(cali):14.1%} "
          f"{np.percentile(cali, 5):13.1%}")
print(f"{'la storia vera':>22s} {memoria_della_volatilita(reale):20.3f} "
      f"{drawdown_massimo(reale):14.1%} {'—':>13s}")

print("\nLa prima colonna e' quella che decide. Rimescolare giorno per giorno "
      "azzera la memoria della volatilita': si ottengono percorsi in cui i giorni "
      "agitati sono sparsi, che non e' come si comporta nessun mercato. I blocchi "
      "la conservano quasi tutta.")
print("\nSulle altre due colonne, invece, la differenza qui e' piccola — e va "
      "detto invece di nasconderlo. Su un orizzonte di nove anni il calo massimo "
      "e' dominato dall'accumulo, non dal raggruppamento. Il metodo a blocchi "
      "resta quello giusto, ma su QUESTE due misure non e' li' che si vede.")

4. How many paths you really need

A thousand are enough for the median and are borderline for the fifth percentile. If the number you need is a tail — and in this notebook it always is — ten thousand cost a few extra seconds and give you a value to actually base a decision on.

Output

  percorsi    mediana    5% peggiore
       100     13.83x          0.44x
       500     18.19x          0.54x
      1000     14.71x          0.50x
      5000     15.18x          0.48x
     20000     14.81x          0.47x

La colonna di sinistra si stabilizza subito, quella di destra molto piu' tardi. E' uno dei rari casi, in questo libro, in cui il problema si risolve semplicemente calcolando di piu'.
Show the script for this step
lab_18_montecarlo.py
python
print(f"{'percorsi':>10s} {'mediana':>10s} {'5% peggiore':>14s}")
for n in (100, 500, 1000, 5000, 20000):
    rng3 = np.random.default_rng(seed_for(f"stabilita-{n}"))
    campione = bootstrap_traiettorie(r, n_traiettorie=n, rng=rng3, a_blocchi=BLOCCHI)
    finali = campione[:, -1]
    print(f"{n:10d} {np.median(finali):9.2f}x {np.percentile(finali, 5):13.2f}x")

print("\nLa colonna di sinistra si stabilizza subito, quella di destra molto piu' "
      "tardi. E' uno dei rari casi, in questo libro, in cui il problema si "
      "risolve semplicemente calcolando di piu'.")

Exercises

  1. Paste your own returns in place of r (a list of percentage changes per trade works fine) and read the fifth-percentile sentence. It's the most useful thing this notebook can give you.
  2. Change BLOCCHI from 5 to 60. The median drawdown grows with block length: the choice is a parameter, not a truth, and must be stated.
  3. Change SERIE. On a more volatile asset the gap between the median and the fifth percentile widens: it's the measure of how little the median describes that market.

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