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
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
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.
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
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.22Show the script for this step
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
- 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.
- Change
FINESTRAfrom 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? - 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
lab_07_correlazioni.ipynb11.6 KB
sha256 23abfc4d315b8d34888ba085321b92b4959243c11ba7d4c2c99eeb12af4f961d
lab_07_correlazioni.py8.9 KB
sha256 a57c7a11076a5bb23f77e555fc3407044a3edd3ae2732805344beb26e2ff2d48
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