Lab from the book · L06
L06 — Fat tails, kurtosis and the twenty days that decide everything
Lab 6 — The lying average
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 lying average". Three things, on real data: how wrong the bell curve is on the tails, how few days decide the result, and why the two opposite slogans drawn from that figure are the same statement — and neither is advice.
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 rendimenti
SERIE = "btcusdt" # ← PROVA / TRY: "ethusdt" · "solusdt" (le tre preparate nel setup)
df = carica(SERIE).sort("data")
prezzi = df["chiusura"].to_numpy()
date = df["data"].to_list()
r = rendimenti(prezzi)
mu, sigma = float(np.mean(r)), float(np.std(r, ddof=1))1. How many extreme days the bell curve predicts, and how many there really are
If returns followed the bell curve, the number of days beyond a certain distance from the mean would be computable. Let's compare it with the real count.
Output
btcusdt: 3239 giorni · media 0.1439% · deviazione standard 3.531%
oltre previsti dalla campana osservati rapporto
2σ 147.375 196 1.3x
3σ 8.745 54 6.2x
4σ 0.205 18 87.7x
5σ 0.002 5 2692.6x
6σ 0.000 2 312934.5x
giorno peggiore: -39.5% → 11.2 deviazioni standard dalla media
Secondo il modello a campana un evento del genere non dovrebbe accadere nemmeno una volta nella storia dell'universo. E' successo, in nove anni.
curtosi: 11.6 (per la curva a campana vale 3)Show the script for this step
from math import erfc, sqrt
n = len(r)
print(f"{SERIE}: {n} giorni · media {mu:.4%} · deviazione standard {sigma:.3%}\n")
print(f"{'oltre':>8s} {'previsti dalla campana':>24s} {'osservati':>12s} {'rapporto':>10s}")
for k in (2, 3, 4, 5, 6):
previsti = n * erfc(k / sqrt(2))
osservati = int(np.sum(np.abs(r - mu) > k * sigma))
rapporto = osservati / previsti if previsti > 0 else float("inf")
print(f"{k:>6d}σ {previsti:24.3f} {osservati:12d} {rapporto:10.1f}x")
peggiore = float(np.min(r))
distanza = abs(peggiore - mu) / sigma
print(f"\ngiorno peggiore: {peggiore:.1%} → {distanza:.1f} deviazioni standard dalla media")
print("Secondo il modello a campana un evento del genere non dovrebbe accadere "
"nemmeno una volta nella storia dell'universo. E' successo, in nove anni.")
curtosi = float(np.mean(((r - mu) / sigma) ** 4))
print(f"\ncurtosi: {curtosi:.1f} (per la curva a campana vale 3)")2. The shape, drawn
The vertical scale is logarithmic: without it, the difference on the tails — the only part that matters — would be invisible.
A histogram of the daily changes of btcusdt over 200 bins, with the horizontal axis running from minus 0.4 to 0.2 and the day count on a logarithmic scale spanning almost thirty orders of magnitude, from ten to the minus twenty-seven up to ten. Above the histogram runs the bell curve with the same mean and the same standard deviation: in the centre the two agree, while at the edges the bell collapses by tens of orders of magnitude exactly where the days that actually happened are.
Show the script for this step
from math import exp, pi
griglia = np.linspace(r.min(), r.max(), 400)
campana = n * np.exp(-((griglia - mu) ** 2) / (2 * sigma**2)) / (sigma * np.sqrt(2 * pi))
larghezza = griglia[1] - griglia[0]
with avvio.figura("schermo"):
fig, ax = plt.subplots()
ax.hist(r, bins=200, label="quello che e' successo")
ax.plot(griglia, campana * larghezza, linewidth=2, label="quello che prevede la campana")
ax.set_yscale("log")
ax.set_xlabel("Variazione giornaliera")
ax.set_ylabel("Numero di giorni (scala log)")
ax.legend()
plt.show()3. Twenty days out of 3,200
We remove the best days from the series, then the worst, and look at what's left.
Output
tutti i 3239 giorni: 13.68x
quanti giorni tolti togliendo i peggiori togliendo i migliori
1 22.62x 11.17x
5 48.43x 5.87x
10 102.96x 2.92x
20 381.21x 0.90x
50 8024.89x 0.04xShow the script for this step
ordine = np.argsort(r)
def senza(indici_da_togliere: np.ndarray) -> float:
maschera = np.ones(len(r), dtype=bool)
maschera[indici_da_togliere] = False
return float(np.prod(1 + r[maschera]))
base = float(np.prod(1 + r))
print(f"tutti i {len(r)} giorni: {base:9.2f}x\n")
print(f"{'quanti giorni tolti':>22s} {'togliendo i peggiori':>22s} {'togliendo i migliori':>22s}")
for quanti in (1, 5, 10, 20, 50): # PROVA / TRY: aggiungi 100 (vedi esercizio 2)
peggiori = senza(ordine[:quanti])
migliori = senza(ordine[-quanti:])
print(f"{quanti:>22d} {peggiori:21.2f}x {migliori:21.2f}x")4. But they touch
The piece almost every popular book leaves out: the best and the worst days touch each other. Look at March 2020 in the table below — the 12th is the worst of the whole series, the 13th is among the best, and so is the 19th: three of the twenty days that decide nine years, in the same week. The last line measures the general case, and says something more sober: the median distance between a best day and the nearest worst one is two weeks, not one.
Output
i dieci giorni peggiori e i dieci migliori, in ordine di calendario: 2017-09-14 -19.2% PEGGIORE 2017-09-15 +16.0% migliore 2017-12-06 +15.8% migliore 2017-12-07 +22.5% migliore 2017-12-22 -14.0% PEGGIORE 2017-12-26 +14.5% migliore 2017-12-30 -13.5% PEGGIORE 2018-01-16 -19.5% PEGGIORE 2018-02-05 -15.2% PEGGIORE 2019-04-02 +17.2% migliore 2019-10-25 +16.8% migliore 2020-03-12 -39.5% PEGGIORE 2020-03-13 +16.2% migliore 2020-03-19 +14.3% migliore 2021-02-08 +19.5% migliore 2021-05-19 -14.4% PEGGIORE 2022-02-28 +14.5% migliore 2022-06-13 -15.4% PEGGIORE 2022-11-09 -14.1% PEGGIORE 2026-02-05 -14.0% PEGGIORE distanza mediana fra un giorno migliore e il peggiore piu' vicino: 15 giorni
Show the script for this step
peggiori_10 = np.sort(ordine[:10])
migliori_10 = np.sort(ordine[-10:])
print("i dieci giorni peggiori e i dieci migliori, in ordine di calendario:\n")
righe = sorted(
[(date[i + 1], r[i], "PEGGIORE") for i in peggiori_10]
+ [(date[i + 1], r[i], "migliore") for i in migliori_10]
)
for giorno, variazione, tipo in righe:
print(f" {giorno} {variazione:+7.1%} {tipo}")
distanze = [
min(abs(int(m) - int(p)) for p in peggiori_10) for m in migliori_10
]
print(f"\ndistanza mediana fra un giorno migliore e il peggiore piu' vicino: "
f"{int(np.median(distanze))} giorni")Exercises
- Change
SERIEto"ethusdt"or"solusdt". Kurtosis stays well above 3 and the concentration of the result in few days remains: it is not a quirk of one asset, it's a property of markets. - In the third cell try removing 100 days. What's left no longer resembles anything real: that's why "avoid the worst days" isn't advice but a description of a world that doesn't exist.
- Look at the last table. Try imagining a rule that exits before every worst day and re-enters before every best day: the median distance just printed tells you how much time you'd have to notice.
Reproducibility & downloads
Run on 2026-08-27 from the repository notebook
The notebook
lab_06_code_grasse.ipynb11.4 KB
sha256 13a98ffd0f823af447c3e01b46d59b8ba29971f3bbeaa57fb433c6e0582fca20
lab_06_code_grasse.py8.5 KB
sha256 6e9e0b62b2aed0ee0bad2664bc8029c48ec82ffe271ca0007d249f3018099296
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