Lab from the book · L03
L03 — Token mortality and survivorship bias
Lab 3 — The graveyard of tokens
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 graveyard of tokens". This notebook contains the real prices of the two dead assets the chapter talks about — one that went to zero in nine days, one whose market was shut down. Keeping that data is harder than it sounds: most convenient sources expose only what's still active. The notebook's value isn't the timeline of the crash. It's the final exercise: rebuilding the same basket looking only at the survivors, and seeing what reassuring answer you get.
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 datetime as dt
import matplotlib.pyplot as plt
import numpy as np
import polars as pl
from cvbook.dati import DISCONTINUITA, carica, carica_strumento
from cvbook.metriche import recupero_necessario1. Nine days
Logarithmic scale: each tick is a factor of ten. On a linear scale this chart would be a vertical line followed by a flat line — and that's exactly how crashes look to whoever watches them happen. The series stops on 13 May 2022: from the 31st the same ticker quotes a different token. Drawing the whole series shows a resurrection that nobody lived.
The closing price of lunausdt in USDT on a logarithmic scale, from 1 April to 13 May 2022, with the vertical axis spanning six orders of magnitude from ten to the minus four up to ten squared. The line starts a little above 100, drifts down towards 80 through April, then falls almost vertically over nine days to five hundred-thousandths and ends there.
Show the script for this step
luna = carica_strumento("lunausdt").sort("data").filter(pl.col("data") >= dt.date(2022, 4, 1))
with avvio.figura("schermo"):
fig, ax = plt.subplots()
ax.semilogy(luna["data"].to_list(), luna["chiusura"].to_numpy(), linewidth=1.6)
ax.set_ylabel("Prezzo di chiusura (USDT, scala log)")
fig.autofmt_xdate()
plt.show()Try it: the chart that says the opposite
Run the cell below. Same series, read raw — reused ticker included — through 30 June.
The same series read raw: the closing price of lunausdt in USDT on a logarithmic scale, from 1 April to 30 June 2022, drawn as a dashed line on the same vertical axis from ten to the minus four up to ten squared. A dotted vertical line marks 13 May. Left of that line is the collapse down to five hundred-thousandths; right of it the line climbs back roughly a hundred thousand times and drifts between 2 and 9 until the end of June.
Output
2022-05-05 82.35000 USDT perdita 0.000% servirebbe +0% 2022-05-06 77.30000 USDT perdita 6.132% servirebbe +7% 2022-05-07 68.13000 USDT perdita 17.268% servirebbe +21% 2022-05-08 64.26000 USDT perdita 21.967% servirebbe +28% 2022-05-09 30.29000 USDT perdita 63.218% servirebbe +172% 2022-05-10 17.46000 USDT perdita 78.798% servirebbe +372% 2022-05-11 1.07690 USDT perdita 98.692% servirebbe +7,547% 2022-05-12 0.00032 USDT perdita 100.000% servirebbe +25,734,275% 2022-05-13 0.00005 USDT perdita 100.000% servirebbe +99,999,900%
Show the script for this step
grezza = carica("lunausdt").sort("data").filter(
pl.col("data").is_between(dt.date(2022, 4, 1), dt.date(2022, 6, 30))
)
with avvio.figura("schermo"):
fig, ax = plt.subplots()
ax.semilogy(grezza["data"].to_list(), grezza["chiusura"].to_numpy(),
linewidth=1.6, linestyle="--")
ax.axvline(DISCONTINUITA["lunausdt"], linewidth=1.0, linestyle=":")
ax.set_ylabel("Prezzo di chiusura (USDT, scala log)")
ax.set_title("La stessa serie letta grezza: da qui in poi è un altro token")
fig.autofmt_xdate()
plt.show()
righe = luna.filter(pl.col("data").is_between(dt.date(2022, 5, 5), dt.date(2022, 5, 13)))
partenza = float(righe["chiusura"][0])
for d, p in zip(righe["data"].to_list(), righe["chiusura"].to_numpy()):
perdita = 1 - p / partenza
recupero = recupero_necessario(min(perdita, 0.999999))
print(f"{d} {p:12.5f} USDT perdita {perdita:7.3%} servirebbe +{recupero:,.0%}")Look at the second-to-last row: the loss was already 98.7%. The next day the price divided again by three thousand. Even after losing 98%, there was still everything left to lose.
2. When the price stops existing
The second way of disappearing is worse, because it doesn't even leave a price. Note where the line ends: it doesn't reach zero, it breaks off.
The closing price of fttusdt in USDT from 1 October to 15 November 2022, with the vertical axis running from 5 to 25. The line, dotted with one marker per day, hovers around 25 until early November, then drops and stops: the last marker is 15 November at 1.43, and after that date there is no price at all.
Output
ultimo giorno con un prezzo: 2022-11-15 (1.43 USDT) dopo quella data non esiste piu' un mercato: non c'e' un prezzo, non c'e' una quotazione, non c'e' modo di vendere.
Show the script for this step
ftt = carica("fttusdt").sort("data").filter(pl.col("data") >= dt.date(2022, 10, 1))
with avvio.figura("schermo"):
fig, ax = plt.subplots()
ax.plot(ftt["data"].to_list(), ftt["chiusura"].to_numpy(), linewidth=1.6, marker="o",
markersize=2.5)
ax.set_ylabel("Prezzo di chiusura (USDT)")
fig.autofmt_xdate()
plt.show()
print(f"ultimo giorno con un prezzo: {ftt['data'][-1]} ({float(ftt['chiusura'][-1]):.2f} USDT)")
print("dopo quella data non esiste piu' un mercato: non c'e' un prezzo, non c'e' "
"una quotazione, non c'e' modo di vendere.")3. The basket, with and without the dead
An equal-weight basket built on April 1, 2021. We measure it two ways: looking only at what still exists today, and looking at everything that was actually bought.
Two capital curves rebased to 100 from 1 April 2021 to 31 December 2022, with the vertical axis graduated up to 500 and a dotted horizontal line at 100. Both rise to a peak on 6 November 2021 — 564 for the survivors-only curve, 424 for the dashed one that keeps the dead tokens inside — and then fall through the whole of 2022, below where they started: the first ends at 47.1, the second at 29.0, 38.5% lower.
Output
paniere dei soli sopravvissuti: 47.1 paniere reale, morti compresi: 29.0 differenza: -38.5%
Show the script for this step
INIZIO = dt.date(2021, 4, 1) # ← PROVA / TRY: sposta a gennaio 2022 (vedi esercizio 2)
FINE = dt.date(2022, 12, 31) # ← PROVA / TRY: porta a oggi e guarda cosa cambia
VIVI = ["btcusdt", "ethusdt", "solusdt"] # ← PROVA / TRY: togli "solusdt" (esercizio 1)
MORTI = ["lunausdt", "fttusdt"]
# NON TOCCARE / DO NOT CHANGE: MORTI deve restare com'è — sono gli unici due
# token defunti congelati nel registro dati; la dimostrazione è proprio che
# le fonti comode non li avrebbero mai lasciati scegliere (vedi esercizio 3)
# MORTI must stay as it is — these are the only two dead tokens frozen in the
# data registry; the whole demonstration is that convenient sources would
# never have let you choose them (see exercise 3)
def curva_paniere(nomi: list[str]) -> tuple[list, np.ndarray]:
"""Paniere a peso uguale, ribilanciato una sola volta all'inizio.
Due scelte, e vanno dette perché sono le stesse della figura stampata nel
libro — altrimenti questo quaderno risponderebbe a una domanda diversa da
quella del capitolo, che è esattamente ciò che non deve succedere.
**Un asset delistato non sparisce: resta all'ultimo valore noto.** È quanto
vale davvero per chi ce l'ha in portafoglio, e non è zero: FTT il 15 novembre
2022 valeva ancora 1,43 dollari, semplicemente non si poteva più vendere.
**Le serie si leggono con `carica_strumento`**, che si ferma dove
l'identificativo cambia strumento. Con la serie grezza LUNA risaliva al 6,8%
del valore iniziale e il paniere dei morti chiudeva a 30,3 invece che a 29,0:
questo quaderno avrebbe sottostimato del 7% proprio l'errore che esiste per
misurare.
"""
calendario = [
d for d in carica("btcusdt").sort("data")["data"].to_list()
if INIZIO <= d <= FINE
]
quote = []
for n in nomi:
s = carica_strumento(n).sort("data").filter(
pl.col("data").is_between(INIZIO, FINE)
)
mappa = dict(zip(s["data"].to_list(), s["chiusura"].to_numpy()))
base = mappa[min(mappa)]
ultimo, riempita = base, []
for d in calendario:
ultimo = mappa.get(d, ultimo)
riempita.append(ultimo / base)
quote.append(riempita)
return calendario, np.mean(np.array(quote), axis=0)
date_v, solo_vivi = curva_paniere(VIVI)
date_t, tutti = curva_paniere(VIVI + MORTI)
with avvio.figura("schermo"):
fig, ax = plt.subplots()
ax.plot(date_v, solo_vivi * 100, linewidth=1.8, label="solo i sopravvissuti")
ax.plot(date_t, tutti * 100, linewidth=1.8, linestyle="--",
label="tutti, morti compresi")
ax.axhline(100, linestyle=":", linewidth=0.9)
ax.set_ylabel("Capitale (base 100)")
ax.legend()
fig.autofmt_xdate()
plt.show()
print(f"paniere dei soli sopravvissuti: {solo_vivi[-1] * 100:.1f}")
print(f"paniere reale, morti compresi: {tutti[-1] * 100:.1f}")
print(f"differenza: {tutti[-1] / solo_vivi[-1] - 1:+.1%}")Neither number is wrong: they are answers to two different questions. The first is "how did these three assets do". The second is "how would it have gone for me". Almost everyone asks the first and believes they've answered the second.
Exercises.
- Remove
"solusdt"fromVIVIand rerun: the distance between the two curves changes quite a bit. How much does the conclusion depend on which names you included? - Move
INIZIOto January 2022. The real basket does much worse than the survivors-only one: the distortion grows when the period contains more deaths. - The exercise worth the whole chapter: try rebuilding this same figure using any free source of historical data. You'll find the two dead assets aren't there, and you'll get the reassuring curve without even noticing you made a mistake.
Reproducibility & downloads
Run on 2026-08-27 from the repository notebook
The notebook
lab_03_cimitero_token.ipynb15.5 KB
sha256 9f2859d2011e405f7bb96bc91d15f5420d55d4bbe861ccd27718c6ae411b1156
lab_03_cimitero_token.py12.0 KB
sha256 95fe5ef3c088d79b5d1380b6ed67e3aeabb43f52c91c286c41df17d5fc4b816c
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
lunausdt.parquet27.6 KB
sha256 10fe10357f76eb408550f4809ce2a87cb1129164f6f6d074ae7eac730ccb7f15
Source: Binance Data Vision · Period: 2020-08-21 → 2022-12-31 · 846 rows · extracted 2026-08-16
fttusdt.parquet27.5 KB
sha256 7b235709ebb5ae31df0a9c315bd134b95e5249196a6c84b54763d9e63795fc06
Source: Binance Data Vision · Period: 2019-12-20 → 2022-11-15 · 1,062 rows · extracted 2026-08-16