Lab del libro · L09
L09 — Volatilità mobile, persistenza dei regimi e durata dei periodi agitati
Lab 9 — Il mercato non è sempre lo stesso mercato
Quaderno del capitolo «Il mercato non è sempre lo stesso mercato» di La matematica di chi perde.
La volatilità non è una costante dell'asset: è una serie storica. Qui la calcoli, ne guardi la forma, e verifichi che i periodi agitati durano invece di lampeggiare.
I due esercizi finali valgono più della figura: uno mostra che una finestra lunga nasconde i regimi invece di misurarli, l'altro li fa sparire rimescolando i dati — e vedere sparire una struttura quando la si distrugge di proposito è il modo più diretto di convincersi che c'era.
Le righe marcate PROVA sono quelle da cambiare: cambiale e riesegui per vedere l'effetto. Il resto — comprese le righe marcate NON TOCCARE — serve a mantenere il risultato confrontabile con quello stampato nel libro.
Espandi lo script di questo passo
import matplotlib.pyplot as plt
import numpy as np
from cvbook.dati import carica
from cvbook.metriche import GIORNI_ANNO, rendimenti
SERIE = "btcusdt" # ← PROVA / TRY: "ethusdt" · "solusdt" (le tre preparate nel setup)
FINESTRA = 30 # ← giorni della finestra mobile
# PROVA / TRY: 10 · 30 · 250 (vedi esercizio 4 qui sotto)
df = carica(SERIE).sort("data")
r = rendimenti(df["chiusura"].to_numpy())
date = df["data"].to_list()[1:]
def volatilita_mobile(rend: np.ndarray, finestra: int) -> np.ndarray:
"""Deviazione standard annualizzata, causale: usa solo il passato."""
return np.array([
np.std(rend[i - finestra:i], ddof=1) * np.sqrt(GIORNI_ANNO)
for i in range(finestra, len(rend) + 1)
])
vol = volatilita_mobile(r, FINESTRA)
date_v = date[FINESTRA - 1:]1. Il numero che descrive un mercato inesistente
La volatilita' annualizzata a 30 giorni di btcusdt dal 2018 al 2026, con l'asse verticale da 25 a oltre 175. La linea oscilla fra un minimo di 17% e un massimo di 184%, undici volte tanto; una riga tratteggiata segna la media di periodo, 62%, e le bande ombreggiate marcano il quarto di tempo piu' agitato. Solo il 39% dei giorni sta entro un quinto dalla media.
Output
minimo 16.7% massimo 183.6% → rapporto massimo/minimo: 11.0 volte media 61.6% quota di tempo entro il ±20% dalla media: 38.6% Il mercato passa poco tempo vicino al numero che tutti chiamano «la volatilita' storica».
Espandi lo script di questo passo
alta = float(np.percentile(vol, 75))
media = float(vol.mean())
with avvio.figura("schermo"):
fig, ax = plt.subplots(figsize=(10, 4))
ax.fill_between(date_v, 0, 1, where=vol > alta, transform=ax.get_xaxis_transform(),
step="mid", alpha=0.25, label="quarto piu' agitato")
ax.plot(date_v, vol * 100, linewidth=1.0, label=f"volatilita' a {FINESTRA} giorni")
ax.axhline(media * 100, linestyle="--", linewidth=1.2,
label=f"media di periodo ({media:.0%})")
ax.set_ylabel("Volatilita' annualizzata (%)")
ax.legend(loc="upper right")
fig.autofmt_xdate()
plt.show()
vicino = float(np.mean((vol > media * 0.8) & (vol < media * 1.2)))
print(f"minimo {vol.min():6.1%}")
print(f"massimo {vol.max():6.1%} → rapporto massimo/minimo: {vol.max() / vol.min():.1f} volte")
print(f"media {media:6.1%}")
print(f"quota di tempo entro il ±20% dalla media: {vicino:.1%}")
print(f"\nIl mercato passa poco tempo vicino al numero che tutti chiamano "
f"«la volatilita' storica».")2. La memoria: i regimi persistono
Chiamiamo «agitato» un giorno in cui la volatilità sta nel quarto più alto. Per costruzione capita il 25% delle volte. Ora condizioniamo su com'è oggi.
Nota la precauzione che rende credibile il numero: le due finestre — quella che misura oggi e quella che misura fra un mese — non si sovrappongono.
Tre barre a confronto sulla probabilita' che il mercato sia agitato fra 30 giorni: partendo da un giorno calmo vale 16%, senza memoria varrebbe 25% per costruzione, partendo da un giorno agitato vale 49%. Fra la prima e la terza corrono 3,1 volte.
Output
probabilita' di base: 25.0% partendo da un giorno agitato: 49.1% partendo da un giorno calmo: 15.9% rapporto: 3.1 volte
Espandi lo script di questo passo
ORIZZONTE = FINESTRA # non sovrapposte: nessun dato in comune fra le due misure
alto = vol > np.percentile(vol, 75)
oggi, dopo = alto[:-ORIZZONTE], alto[ORIZZONTE:]
base = float(alto.mean())
da_alto = float(dopo[oggi].mean())
da_calmo = float(dopo[~oggi].mean())
with avvio.figura("schermo"):
fig, ax = plt.subplots(figsize=(6, 4))
valori = [da_calmo * 100, base * 100, da_alto * 100]
ax.bar(["oggi calmo", "senza memoria", "oggi agitato"], valori)
for k, v in enumerate(valori):
ax.annotate(f"{v:.0f}%", xy=(k, v), xytext=(0, 4), textcoords="offset points",
ha="center")
ax.set_ylabel(f"Agitato fra {ORIZZONTE} giorni (%)")
plt.show()
print(f"probabilita' di base: {base:.1%}")
print(f"partendo da un giorno agitato: {da_alto:.1%}")
print(f"partendo da un giorno calmo: {da_calmo:.1%}")
print(f"rapporto: {da_alto / da_calmo:.1f} volte")3. Quanto durano i periodi agitati
Il confronto con un mondo in cui i giorni sono indipendenti — stessa percentuale complessiva di giorni agitati, ma sparsi a caso.
Due istogrammi sovrapposti della durata dei periodi agitati, con l'asse orizzontale logaritmico da 1 a oltre 100 giorni. Quello pieno del mercato vero conta 32 episodi, con durata mediana 18 giorni e il piu' lungo di 187; quello tratteggiato di un mondo in cui i giorni sono indipendenti conta 597 episodi, quasi tutti di un giorno solo e mai oltre 7.
Output
episodi mediana il piu lungo
mercato vero 32 18 187
giorni indipendenti 597 1 7Espandi lo script di questo passo
def sequenze(maschera: np.ndarray) -> np.ndarray:
lunghezze, corrente = [], 0
for x in maschera:
if x:
corrente += 1
elif corrente:
lunghezze.append(corrente)
corrente = 0
if corrente:
lunghezze.append(corrente)
return np.array(lunghezze)
rng = np.random.default_rng(20260816)
# NON TOCCARE / DO NOT CHANGE: il seme fissa i numeri di episodi/mediana/il
# più lungo citati nel testo qui sotto e riusati nella cella 5.
# The seed fixes the episode/median/longest numbers quoted in the text below
# and reused in cell 5.
vere = sequenze(alto)
finte = sequenze(rng.random(len(alto)) < base)
with avvio.figura("schermo"):
fig, ax = plt.subplots(figsize=(8, 4))
bordi = np.logspace(0, np.log10(max(vere.max(), finte.max()) + 1), 16)
ax.hist(vere, bins=bordi, label="mercato vero")
ax.hist(finte, bins=bordi, histtype="step", linewidth=1.8, linestyle="--",
label="giorni indipendenti")
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xlabel("Durata del periodo agitato (giorni)")
ax.set_ylabel("Quante volte")
ax.legend()
plt.show()
print(f"{'':>22s} {'episodi':>9s} {'mediana':>9s} {'il piu lungo':>14s}")
print(f"{'mercato vero':>22s} {len(vere):9d} {np.median(vere):9.0f} {vere.max():14d}")
print(f"{'giorni indipendenti':>22s} {len(finte):9d} {np.median(finte):9.0f} {finte.max():14d}")4. Esercizio: la finestra lunga nasconde i regimi
Output
finestra minimo massimo rapporto entro ±20%
10 8.6% 295.7% 34.5x 29.0%
30 16.7% 183.6% 11.0x 38.6%
60 26.3% 147.9% 5.6x 46.2%
120 29.0% 131.9% 4.5x 51.0%
250 34.8% 113.0% 3.2x 48.3%
Con finestre lunghe l'escursione si comprime e sembra che il mercato sia piu' stabile. Non lo e' diventato: lo stiamo guardando con meno risoluzione.Espandi lo script di questo passo
print(f"{'finestra':>10s} {'minimo':>9s} {'massimo':>9s} {'rapporto':>10s} {'entro ±20%':>12s}")
for f in (10, 30, 60, 120, 250):
v = volatilita_mobile(r, f)
dentro = float(np.mean((v > v.mean() * 0.8) & (v < v.mean() * 1.2)))
print(f"{f:10d} {v.min():9.1%} {v.max():9.1%} {v.max() / v.min():9.1f}x {dentro:12.1%}")
print("\nCon finestre lunghe l'escursione si comprime e sembra che il mercato sia "
"piu' stabile. Non lo e' diventato: lo stiamo guardando con meno risoluzione.")5. Esercizio: distruggi la struttura e guardala sparire
Output
stessi identici rendimenti, in ordine casuale: persistenza a 30 giorni: 31.4% contro 23.2% (nel mercato vero: 49.1% contro 15.9%) episodio agitato piu' lungo: 53 giorni (nel mercato vero: 187) La struttura non era nei rendimenti presi uno per uno: era nel loro ORDINE. Rimescolarli la distrugge, ed e' la prova che c'era.
Espandi lo script di questo passo
rimescolati = rng.permutation(r)
vol_finta = volatilita_mobile(rimescolati, FINESTRA)
alto_finto = vol_finta > np.percentile(vol_finta, 75)
seq_finta = sequenze(alto_finto)
oggi_f, dopo_f = alto_finto[:-ORIZZONTE], alto_finto[ORIZZONTE:]
print("stessi identici rendimenti, in ordine casuale:\n")
print(f" persistenza a {ORIZZONTE} giorni: {float(dopo_f[oggi_f].mean()):.1%} "
f"contro {float(dopo_f[~oggi_f].mean()):.1%} (nel mercato vero: "
f"{da_alto:.1%} contro {da_calmo:.1%})")
print(f" episodio agitato piu' lungo: {seq_finta.max()} giorni "
f"(nel mercato vero: {vere.max()})")
print("\nLa struttura non era nei rendimenti presi uno per uno: era nel loro "
"ORDINE. Rimescolarli la distrugge, ed e' la prova che c'era.")Attenzione a cosa questo NON dice
La persistenza riguarda quanto il mercato si muoverà, non in che direzione. Sapere che il prossimo mese sarà agitato non ti dice se salirà o scenderà, e chiunque ti presenti la prima informazione facendola passare per la seconda ti sta vendendo qualcosa.
Riproducibilità e download
Eseguito il 2026-08-27 dal quaderno del repository
Il quaderno
lab_09_regimi.ipynb13,4 KB
sha256 ed220b15eadc7c3f1e9517a0e29380c45e2a9aecefa4ddb2fa88c3f441b0c7c5
lab_09_regimi.py9,9 KB
sha256 ca7fce65a4dbcd1848cf41043968bf5efb794d73d537ea5a243b3e187496a003
I dati
btcusdt.parquet93,2 KB
sha256 ea75ad84e6e981507054df5c622c6b0ec3c8849c1f4dd007721878d4e4c8a329
Fonte: Binance Data Vision · Periodo: 2017-08-17 → 2026-06-30 · 3240 righe · estratto 2026-08-16
ethusdt.parquet87,0 KB
sha256 c2bd0259da905e0fec87235d7a62295532433fb89657726dd2d19558db7c072a
Fonte: Binance Data Vision · Periodo: 2017-08-17 → 2026-06-30 · 3240 righe · estratto 2026-08-16
solusdt.parquet57,5 KB
sha256 c7ba2368a3e419b898fb31ec6d5345b7212b74784b69079d3d43571c2ac63657
Fonte: Binance Data Vision · Periodo: 2020-08-11 → 2026-06-30 · 2150 righe · estratto 2026-08-16