Lab from the book · L09
L09 — Rolling volatility, regime persistence and the length of turbulent periods
Lab 9 — The market isn't always the same market
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 "The market isn't always the same market". Volatility isn't a constant of the asset: it's a time series. Here you compute it, look at its shape, and verify that turbulent periods persist instead of flickering. The two final exercises are worth more than the figure: one shows that a long window hides regimes instead of measuring them, the other makes them disappear by shuffling the data — and watching a structure vanish when you destroy it on purpose is the most direct way to convince yourself it was there.
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.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. The number that describes a non-existent market
The 30-day annualised volatility of btcusdt from 2018 to 2026, with the vertical axis running from 25 to beyond 175. The line swings between a low of 17% and a high of 184%, eleven times as much; a dashed line marks the average for the period, 62%, and the shaded bands mark the most turbulent quarter of the time. Only 39% of days sit within a fifth of the average.
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».
Show the script for this step
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. Memory: regimes persist
We call a day "turbulent" when volatility sits in the top quarter. By construction that happens 25% of the time. Now we condition on today. Note the precaution that makes the number credible: the two windows — the one measuring today and the one measuring a month from now — do not overlap.
Three bars compared on the probability that the market is turbulent 30 days from now: starting from a calm day it is 16%, with no memory it would be 25% by construction, starting from a turbulent day it is 49%. The first and the third are 3.1 times apart.
Output
probabilita' di base: 25.0% partendo da un giorno agitato: 49.1% partendo da un giorno calmo: 15.9% rapporto: 3.1 volte
Show the script for this step
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. How long turbulent periods last
The comparison with a world where days are independent — same overall percentage of turbulent days, but scattered at random.
Two overlaid histograms of the length of turbulent stretches, with a logarithmic horizontal axis from 1 to beyond 100 days. The filled one, from the real market, counts 32 episodes with a median length of 18 days and a longest of 187; the dashed one, from a world where days are independent, counts 597 episodes, almost all of them a single day and never longer than 7.
Output
episodi mediana il piu lungo
mercato vero 32 18 187
giorni indipendenti 597 1 7Show the script for this step
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. Exercise: the long window hides regimes
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.Show the script for this step
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. Exercise: destroy the structure and watch it disappear
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.
Show the script for this step
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.")Watch out for what this does NOT say
Persistence is about how much the market will move, not in which direction. Knowing that next month will be turbulent doesn't tell you whether it will rise or fall, and anyone who presents the first piece of information as if it were the second is selling you something.
Reproducibility & downloads
Run on 2026-08-27 from the repository notebook
The notebook
lab_09_regimi.ipynb13.4 KB
sha256 ed220b15eadc7c3f1e9517a0e29380c45e2a9aecefa4ddb2fa88c3f441b0c7c5
lab_09_regimi.py9.9 KB
sha256 ca7fce65a4dbcd1848cf41043968bf5efb794d73d537ea5a243b3e187496a003
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