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
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.
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
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
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
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
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
- 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. - Change
BLOCCHIfrom 5 to 60. The median drawdown grows with block length: the choice is a parameter, not a truth, and must be stated. - 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
lab_18_montecarlo.ipynb15.5 KB
sha256 ccc0148896e31223fba548dd4978d81a5a72493deb9f93ae15c5ed5f725116e0
lab_18_montecarlo.py12.1 KB
sha256 f1c9771b4454d10dcddedd1649a0ec17fd5e689d3e76a13939ae2e7de175e810
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