Skip to content
SIAT PAPER 2026Open the research page
Cryptoverso

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
lab_09_regimi.py
python
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.

Volatility is not a number: it is a series that spends little time near its own average.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Standard deviation of daily changes over rolling 30-day windows, annualised; the bands are the days in the top quarter of the distribution.

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
lab_09_regimi.py
python
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.

The same probability seen from three different starting conditions.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Share of turbulent days 30 days ahead, conditioned on today's state, with the two measurement windows kept from overlapping.

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
lab_09_regimi.py
python
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.

How long turbulent stretches last, and how long they would last if days were independent.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Length of every consecutive run of turbulent days, compared with the one obtained by shuffling the same days into a random order.

Output

                         episodi   mediana   il piu lungo
          mercato vero        32        18            187
   giorni indipendenti       597         1              7
Show the script for this step
lab_09_regimi.py
python
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
lab_09_regimi.py
python
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
lab_09_regimi.py
python
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

Back to the lab index