Skip to content
SIAT PAPER 2026Open the research page
Cryptoverso

Lab from the book · L08

L08 — Principal components and the effective number of bets

Lab 8 — How many things you're really buying

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 "How many things you're really buying". Principal component analysis answers a question no other measure asks: how many independent directions are needed to describe the movement of a portfolio. The exercise that makes the notebook worthwhile is the second one: adding assets one at a time and watching the number not move. It's counterintuitive until you see it.

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_08_acp.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 rendimenti


def allinea(nomi: list[str], da: dt.date = dt.date(2020, 9, 1)) -> np.ndarray:
    """Rendimenti giornalieri delle serie, sui soli giorni presenti in tutte."""
    pezzi = [
        carica(n).filter(pl.col("data") >= da).select(["data", "chiusura"]).rename({"chiusura": n})
        for n in nomi
    ]
    base = pezzi[0]
    for p in pezzi[1:]:
        base = base.join(p, on="data")
    base = base.sort("data")
    return np.column_stack([rendimenti(base[n].to_numpy()) for n in nomi])

1. The components, on the chapter's three assets

Start from the correlation matrix and take its eigenvalues: they're the shares of movement explained by each independent direction. Three lines of code, no specialized library needed.

Two side-by-side panels over the three principal components of btcusdt, ethusdt and solusdt. On the left three bars with the share of movement each one explains, labelled 78%, 15% and 6%. On the right the same share added up step by step, from 78% with a single component to 100% with all three, with a dotted line at 90%: two components are enough to pass it.

How many independent directions it takes to describe three assets that look like three separate bets.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: Eigenvalues of the correlation matrix of the three series' daily changes, expressed as a share of total variance and as a running sum.

Output

componente 1:  78.5%   cumulata  78.5%
componente 2:  15.3%   cumulata  93.8%
componente 3:   6.2%   cumulata 100.0%

componenti necessarie per arrivare al 90%: 2
giorni usati: 2128 — il calendario delle sole cripto, che non chiudono mai
Il capitolo stampa 78,7% perche' misura queste stesse tre serie sui soli giorni in cui e' aperta anche la borsa di Milano: e' l'unico modo di confrontarle con il paniere esteso. Due decimi di punto di differenza, e il numero effettivo di scommesse — 1,55 — e' identico.
Show the script for this step
lab_08_acp.py
python
NOMI = ["btcusdt", "ethusdt", "solusdt"]
# PROVA / TRY: togli "solusdt" (esercizio 1) · aggiungi "ftsemib"/"eni"
# (aggiungili anche a avvio.prepara([...]))

M = allinea(NOMI)
C = np.corrcoef(M.T)
autovalori = np.linalg.eigvalsh(C)[::-1]
quote = autovalori / autovalori.sum()
cumulata = np.cumsum(quote)

with avvio.figura("schermo"):
    fig, (sx, dx) = plt.subplots(1, 2, figsize=(11, 4))
    x = np.arange(1, len(quote) + 1)
    sx.bar(x, quote * 100)
    for k, q in enumerate(quote):
        sx.annotate(f"{q:.0%}", xy=(k + 1, q * 100), xytext=(0, 4),
                    textcoords="offset points", ha="center")
    sx.set_xticks(x)
    sx.set_xlabel("Componente")
    sx.set_ylabel("Varianza spiegata (%)")

    dx.plot(x, cumulata * 100, marker="o")
    dx.axhline(90, linestyle=":", linewidth=1.2)
    dx.set_xticks(x)
    dx.set_xlabel("Componenti usate")
    dx.set_ylabel("Varianza cumulata (%)")
    plt.show()

for k, (q, c) in enumerate(zip(quote, cumulata), start=1):
    print(f"componente {k}: {q:6.1%}   cumulata {c:6.1%}")
print(f"\ncomponenti necessarie per arrivare al 90%: {int(np.searchsorted(cumulata, 0.90)) + 1}")
print(f"giorni usati: {len(M)} — il calendario delle sole cripto, che non chiudono mai")
print("Il capitolo stampa 78,7% perche' misura queste stesse tre serie sui soli "
      "giorni in cui e' aperta anche la borsa di Milano: e' l'unico modo di "
      "confrontarle con il paniere esteso. Due decimi di punto di differenza, "
      "e il numero effettivo di scommesse — 1,55 — e' identico.")

The first component — a single direction, essentially "today the sector goes up or down" — explains most of everything that happens. The differences between the three assets live in what's left.

2. The effective number of bets

A single number in place of the chart: sum the squares of the shares and take the inverse. If all components weighed the same it would give the number of assets; if one alone weighed everything it would give one.

Output

numero effettivo di scommesse (componenti):        1.55
numero effettivo di scommesse (correlazione media): 1.28

Due metodi con assunzioni diverse. Il secondo assume che tutte le coppie abbiano la stessa correlazione; il primo vede la struttura reale. Quando concordano sull'ordine di grandezza, la conclusione e' molto piu' solida.
Show the script for this step
lab_08_acp.py
python
def numero_effettivo(nomi: list[str]) -> tuple[float, float]:
    m = allinea(nomi)
    c = np.corrcoef(m.T)
    v = np.linalg.eigvalsh(c)[::-1]
    q = v / v.sum()
    rho = float(c[np.triu_indices(len(nomi), 1)].mean())
    da_correlazione = 1.0 / (1 / len(nomi) + (1 - 1 / len(nomi)) * rho)
    return float(1.0 / np.sum(q**2)), da_correlazione


da_componenti, da_correlazione = numero_effettivo(NOMI)
print(f"numero effettivo di scommesse (componenti):        {da_componenti:.2f}")
print(f"numero effettivo di scommesse (correlazione media): {da_correlazione:.2f}")
print("\nDue metodi con assunzioni diverse. Il secondo assume che tutte le coppie "
      "abbiano la stessa correlazione; il primo vede la struttura reale. Quando "
      "concordano sull'ordine di grandezza, la conclusione e' molto piu' solida.")

3. The exercise: add assets and watch the number not move

Output

                       portafoglio  asset  scommesse effettive
                           btcusdt      1                 1.00
                 btcusdt + ethusdt      2                 1.21
       btcusdt + ethusdt + solusdt      3                 1.55

Aggiungere asset dello stesso tipo non aggiunge dimensioni: aggiunge costi e cose da seguire.
Show the script for this step
lab_08_acp.py
python
print(f"{'portafoglio':>34s} {'asset':>6s} {'scommesse effettive':>20s}")
for k in range(1, len(NOMI) + 1):
    sottoinsieme = NOMI[:k]
    if k == 1:
        effettivo = 1.0
    else:
        effettivo, _ = numero_effettivo(sottoinsieme)
    print(f"{' + '.join(sottoinsieme):>34s} {k:6d} {effettivo:20.2f}")

print("\nAggiungere asset dello stesso tipo non aggiunge dimensioni: aggiunge "
      "costi e cose da seguire.")

4. Stability over time

Limits should be looked at, not just named. Components computed over a calm period can differ from those computed over a stressed one: good practice is to compute them over several windows and see how stable they are.

A dotted line following the variance explained by the first component across about seventy rolling 250-day windows, with the vertical axis running from just under 70 to 100. The value does not sit still: it swings between a low of 62% and a high of 93%, around an average of 82%.

The same measurement repeated over different windows: how stable it actually is.Source: Binance Data Vision · Period: 2017-08-17 … 2026-06-30 · Method: First principal component recomputed on every rolling 250-day window, and its share of explained variance window by window.

Output

prima componente: minimo 62.1%, massimo 93.2%, media 82.5%
Show the script for this step
lab_08_acp.py
python
FINESTRA = 250  # PROVA / TRY: 60 (vedi esercizio 2) · 250
prime = []
for i in range(FINESTRA, len(M), 25):
    blocco = M[i - FINESTRA:i]
    v = np.linalg.eigvalsh(np.corrcoef(blocco.T))[::-1]
    prime.append(v[0] / v.sum())

prime = np.array(prime)
with avvio.figura("schermo"):
    fig, ax = plt.subplots(figsize=(9, 3.5))
    ax.plot(prime * 100, marker="o", markersize=3)
    ax.set_xlabel(f"Finestre mobili di {FINESTRA} giorni")
    ax.set_ylabel("Varianza spiegata dalla prima componente (%)")
    ax.set_ylim(0, 100)
    plt.show()

print(f"prima componente: minimo {prime.min():.1%}, massimo {prime.max():.1%}, "
      f"media {prime.mean():.1%}")

Exercises

  1. Remove "solusdt" from NOMI and rerun: with only two assets the first component explains even more. It's not an improvement of the measure — with fewer series there's less structure to find.
  2. In the fourth cell, reduce FINESTRA to 60. The first component becomes much more unstable: how much of that instability is the market's, and how much is having used less data?
  3. Apply the same code to your own indicators instead of assets. If two or three components explain almost everything, your eight indicators are measuring the same thing in slightly different ways.

Reproducibility & downloads

Run on 2026-08-27 from the repository notebook

The notebook

  • lab_08_acp.ipynb12.9 KB

    sha256 cf4cc6adca958b2808a04cfc54b7fb88a4037aa6c5447c9cb32a781ecd5ae412

  • lab_08_acp.py9.6 KB

    sha256 a133b07afe789c2afca16b695cb449e25c85ccc56483454c8adcc797397ac835

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