Skip to content
SIAT PAPER 2026Open the research page
Cryptoverso

Lab from the book · L07

L07 — Rolling-window correlations and how far they rise in crashes

Lab 7 — When diversification vanishes

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 "When diversification vanishes". Three things. First: the intuitive way of measuring correlation during crashes gives the wrong answer, and here you watch it happen. Second: correlation doesn't sit still, and it rises exactly when you'd need it not to. Third: what having N assets is really worth, with the calculation almost nobody does.

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.

Output

3 serie allineate, 2128 giorni comuni, dal 2020-09-02 al 2026-06-30
Show the script for this step
lab_07_correlazioni.py
python
import datetime as dt

import matplotlib.pyplot as plt
import numpy as np
import polars as pl

from cvbook.dati import carica
from cvbook.metriche import drawdown, rendimenti

NOMI = ["btcusdt", "ethusdt", "solusdt"]  # ← PROVA / TRY: aggiungi "ftsemib" o "eni"
                                          # (aggiungili anche a avvio.prepara([...]))
DA = dt.date(2020, 9, 1)  # PROVA / TRY: sposta la data d'inizio

serie = [
    carica(n).filter(pl.col("data") >= DA).select(["data", "chiusura"]).rename({"chiusura": n})
    for n in NOMI
]
tabella = serie[0]
for s in serie[1:]:
    tabella = tabella.join(s, on="data")
tabella = tabella.sort("data")

date = tabella["data"].to_list()[1:]
M = np.column_stack([rendimenti(tabella[n].to_numpy()) for n in NOMI])
print(f"{len(NOMI)} serie allineate, {len(M)} giorni comuni, dal {date[0]} al {date[-1]}")

1. The intuitive method, and why it's wrong

The natural idea: take the worst days and compute correlation there. It seems obvious, and it's an artifact — selecting on an extreme value of one variable restricts its variability, and the correlation ends up biased downward.

Output

correlazione media su tutto il periodo:        0.675
correlazione media sul 5% dei giorni peggiori: 0.582

Sembra che nei crolli la correlazione SCENDA. E' falso, ed e' il risultato del modo in cui abbiamo selezionato i giorni.
Show the script for this step
lab_07_correlazioni.py
python
coppie = np.triu_indices(len(NOMI), 1)


def correlazione_media(blocco: np.ndarray) -> float:
    return float(np.corrcoef(blocco.T)[coppie].mean())


primo = M[:, 0]
peggiori = np.argsort(primo)[: len(primo) // 20]  # il 5% dei giorni peggiori
                                                   # PROVA / TRY: // 5 per il 20% (vedi esercizio 3)

print(f"correlazione media su tutto il periodo:        {correlazione_media(M):.3f}")
print(f"correlazione media sul 5% dei giorni peggiori: {correlazione_media(M[peggiori]):.3f}")
print("\nSembra che nei crolli la correlazione SCENDA. E' falso, ed e' il "
      "risultato del modo in cui abbiamo selezionato i giorni.")

2. The correct method: time windows

Correlation is measured on rolling windows, then you look at in which periods it's higher. No selection based on the value of the variables.

The average correlation between three series measured over rolling 60-day windows, from 2021 to 2026, with the vertical axis running from 0 to 1 and a dotted line on the average for the period. The shaded bands mark the stretches where the market sits more than 30% below its peak: the correlation swings between 0.17 and 0.94, averaging 0.79 inside those bands and 0.68 outside them.

The correlation between three series, and how it rises exactly in the hard stretches.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Average pairwise correlation of the daily changes over rolling 60-day windows; the bands are the days on which the reference series sits more than 30% below its own peak.

Output

minimo 0.17   massimo 0.94   media 0.73
media nei periodi difficili: 0.79
media nel resto del tempo:   0.68
Show the script for this step
lab_07_correlazioni.py
python
FINESTRA = 60  # PROVA / TRY: 20 · 60 · 200 (vedi esercizio 2)

corr = np.array([correlazione_media(M[i - FINESTRA:i]) for i in range(FINESTRA, len(M))])
date_c = date[FINESTRA:]
dd = drawdown(np.concatenate([[1.0], np.cumprod(1 + primo)]))[FINESTRA + 1:]
brutti = dd < -0.30

with avvio.figura("schermo"):
    fig, ax = plt.subplots(figsize=(10, 4))
    ax.fill_between(date_c, 0, 1, where=brutti, step="mid", alpha=0.25,
                    label="mercato oltre il 30% sotto il massimo")
    ax.plot(date_c, corr, linewidth=1.3, label="correlazione media a 60 giorni")
    ax.axhline(corr.mean(), linestyle=":", linewidth=1.2)
    ax.set_ylim(0, 1)
    ax.set_ylabel("Correlazione media")
    ax.legend(loc="lower left")
    fig.autofmt_xdate()
    plt.show()

print(f"minimo {corr.min():.2f}   massimo {corr.max():.2f}   media {corr.mean():.2f}")
print(f"media nei periodi difficili: {corr[brutti].mean():.2f}")
print(f"media nel resto del tempo:   {corr[~brutti].mean():.2f}")

3. What having N assets is really worth

The share of swing that remains, compared to holding just one: one divided by the number of assets, plus correlation times the rest. Then the square root.

Output

correlazione media delle finestre mobili (quella del capitolo): 0.73
correlazione statica sull'intero periodo:                       0.67

 asset   indipendenti    a corr. 0,3   a corr, 0,73    a corr. 0,9
     2          70.7%         80.6%         93.1%         97.5%
     3          57.7%         73.0%         90.7%         96.6%
     4          50.0%         68.9%         89.5%         96.2%
     5          44.7%         66.3%         88.7%         95.9%
    10          31.6%         60.8%         87.2%         95.4%
    15          25.8%         58.9%         86.7%         95.2%
    30          18.3%         56.9%         86.2%         95.0%

numero di asset DAVVERO indipendenti equivalenti ai tuoi 3: 1.22
Show the script for this step
lab_07_correlazioni.py
python
def oscillazione_residua(n: int, rho: float) -> float:
    return float(np.sqrt(1 / n + (1 - 1 / n) * rho))


# Due stimatori della stessa parola. `correlazione_media(M)` è la correlazione
# **statica** su tutto il periodo: 0,675. La media delle finestre mobili della
# cella precedente è 0,73, ed è quella che il capitolo usa in ogni suo conto.
# Non sono la stessa cosa e non vanno confuse — è l'errore che il capitolo
# stesso ha dovuto correggere. Qui si usa quella del capitolo.
#
# Two estimators of the same word: the static correlation over the whole period
# (0.675) and the average of the rolling windows (0.73). The chapter's numbers
# all use the second one, so this table uses it too.
rho_misurata = float(corr.mean())
rho_statica = correlazione_media(M)
print(f"correlazione media delle finestre mobili (quella del capitolo): {rho_misurata:.2f}")
print(f"correlazione statica sull'intero periodo:                       {rho_statica:.2f}\n")
etichetta_misurata = f"a corr. {rho_misurata:.2f}".replace(".", ",")
print(f"{'asset':>6s} {'indipendenti':>14s} {'a corr. 0,3':>14s} "
      f"{etichetta_misurata:>14s} {'a corr. 0,9':>14s}")
for n in (2, 3, 4, 5, 10, 15, 30):
    valori = "".join(f"{oscillazione_residua(n, rho) * 100:13.1f}%"
                     for rho in (0.0, 0.3, rho_misurata, 0.9))
    print(f"{n:6d} {valori}")

# `len(NOMI)` e non un 3 scritto a mano: la cella sopra invita ad aggiungere
# serie, e con un 3 fisso il numero effettivo restava quello di tre asset
# qualunque cosa il lettore ci mettesse dentro.
quanti = len(NOMI)
n_eff = 1 / (1 / quanti + (1 - 1 / quanti) * rho_misurata)
print(f"\nnumero di asset DAVVERO indipendenti equivalenti ai tuoi {quanti}: {n_eff:.2f}")

Exercises

  1. In the third cell look at the measured-correlation column: between 4 and 15 assets the reduction changes very little. From the fifth one on you pay cost and complexity without buying protection.
  2. Change FINESTRA from 60 to 20 and then to 200. With short windows correlation swings much more: how much of its instability is the market's, and how much is the measurement's?
  3. Rerun the first cell taking the worst 20% of days instead of 5%. The artifact fades. It's proof that it was an effect of the selection, not a fact about the 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