Calculator from the book · C05
C05 — Probability of a custody event and cost of concentration
Calculator 5 — The risk of keeping it all in one place
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 "Where your money really lives". Three numbers: the probability of suffering at least one custody event within your horizon, how much you'd need to earn to break even, and how the distribution of final capital changes as the share held at the fullest venue varies. The annual probability you plug in here is your own assumption, not a measurement — there is no reliable statistic on platform failures, for the same reason there is no complete list of dead tokens: whatever disappears also stops showing up in the counts. This notebook is for understanding the shape of the problem, not for estimating its level.
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 import seed_for
from cvbook.dati import carica
from cvbook.lingua import t
from cvbook.metriche import recupero_necessario, rendimenti
from cvbook.simulazioni import bootstrap_traiettorie1. Your three numbers
Output
ipotesi di rischio annuo: 2.0% orizzonte: 10 anni quota nel posto piu' pieno: 60% probabilita' di almeno un evento in 10 anni: 18.3% se accade, ti resta: 40% del capitale per tornare in pari devi guadagnare: +150%
Show the script for this step
RISCHIO_ANNUO = 0.02 # ← la TUA ipotesi: probabilita' che la sede sparisca in un anno
# PROVA / TRY: 0,005 · 0,01 · 0,02 · 0,05 — è un'ipotesi
# tua, non una misura (vedi la nota qui sopra)
ORIZZONTE = 10 # ← per quanti anni ci tieni i soldi
# PROVA / TRY: 5 · 10 · 20 (anni)
QUOTA = 0.60 # ← quanta parte del capitale sta nel posto piu' pieno
# PROVA / TRY: 0,20 · 0,60 · 1,00
almeno_uno = 1 - (1 - RISCHIO_ANNUO) ** ORIZZONTE
print(t(f"ipotesi di rischio annuo: {RISCHIO_ANNUO:.1%}", f"assumed annual risk: {RISCHIO_ANNUO:.1%}"))
print(t(f"orizzonte: {ORIZZONTE} anni", f"horizon: {ORIZZONTE} years"))
print(t(f"quota nel posto piu' pieno: {QUOTA:.0%}\n", f"share at the fullest venue: {QUOTA:.0%}\n"))
print(t(f"probabilita' di almeno un evento in {ORIZZONTE} anni: {almeno_uno:.1%}",
f"probability of at least one event in {ORIZZONTE} years: {almeno_uno:.1%}"))
print(t(f"se accade, ti resta: {1 - QUOTA:.0%} del capitale",
f"if it happens, you're left with: {1 - QUOTA:.0%} of capital"))
print(t(f"per tornare in pari devi guadagnare: +{recupero_necessario(QUOTA):.0%}",
f"to break even you need to gain: +{recupero_necessario(QUOTA):.0%}"))2. A small probability, repeated, does not stay small
Two side-by-side panels. On the left four rising curves give the probability of at least one event within 25 years held, one per annual risk: at 0.5% a year it reaches about 12%, at 5% a year it passes 70%, and a dotted line marks the horizon chosen in the notebook. On the right the gain needed to break even as the share of capital held at a single venue grows, with three marked points: 25% asks for 33%, 50% asks for 100%, 75% asks for 300%.
Output
rischio annuo 5 anni 10 anni 20 anni
0.5% 2.5% 4.9% 9.5%
1.0% 4.9% 9.6% 18.2%
2.0% 9.6% 18.3% 33.2%
5.0% 22.6% 40.1% 64.2%Show the script for this step
anni = np.arange(0, 26)
with avvio.figura("schermo"):
fig, (sx, dx) = plt.subplots(1, 2, figsize=(12, 4.5))
for p in (0.005, 0.01, 0.02, 0.05):
sx.plot(anni, (1 - (1 - p) ** anni) * 100, linewidth=2,
label=t(f"{p:.1%} l'anno", f"{p:.1%} a year"))
sx.axvline(ORIZZONTE, linestyle=":", linewidth=1.2)
sx.set_xlabel(t("Anni di permanenza", "Years held"))
sx.set_ylabel(t("Probabilita' di almeno un evento (%)", "Probability of at least one event (%)"))
sx.legend()
quote = np.linspace(0.02, 0.95, 300)
dx.plot(quote * 100, [recupero_necessario(q) * 100 for q in quote], linewidth=2)
for q in (0.25, 0.50, 0.75):
dx.plot([q * 100], [recupero_necessario(q) * 100], marker="o")
dx.annotate(f"{q:.0%} → +{recupero_necessario(q):.0%}",
xy=(q * 100, recupero_necessario(q) * 100),
xytext=(-6, 8), textcoords="offset points", ha="right")
dx.set_ylim(0, 500)
dx.set_xlabel(t("Quota del capitale nella sede (%)", "Share of capital at the venue (%)"))
dx.set_ylabel(t("Guadagno necessario per tornare in pari (%)", "Gain needed to break even (%)"))
plt.show()
print(f"{t('rischio annuo', 'annual risk'):>14s} "
+ "".join(f"{a:>10d}" + t(" anni", " years") for a in (5, 10, 20)))
for p in (0.005, 0.01, 0.02, 0.05):
print(f"{p:14.1%} " + "".join(f"{1 - (1 - p) ** a:14.1%}" for a in (5, 10, 20)))3. Nine years of real market, with and without custody risk
The returns are the ones that actually happened, block-resampled. The only thing added is the rare event. The three curves differ only in how much capital sits at a single venue. The horizon here isn't yours to pick: the data set it. The resampled paths are as long as the returns they draw from — 3,239 of them, barely nine years — so drawing the custody event over ten would load a year of risk onto paths that don't exist.
Three cumulative curves over capital after ten years, on a logarithmic axis from ten to the minus four up to ten to the fourth times the starting capital, against the share of paths with an equal or worse outcome, from 0 to 100%. The curve with all the capital at one venue has a tail reaching ten to the minus four; the ones with a half and with a fifth at a single venue sit progressively further right in the lower part of the chart.
Output
orizzonte dei percorsi, dettato dai dati: 9 anni (3239 rendimenti)
probabilita' di almeno un evento nei 9 anni: 16.2%
quota in una sede mediana 5% peggiore sotto il capitale
100% 8.99x 0.00x 24.8%
50% 13.09x 0.42x 11.0%
20% 14.32x 0.48x 10.4%
La mediana si sposta poco: nel caso tipico non succede niente, ed e' per questo che il problema non si vede. Cambia la coda sinistra, cioe' esattamente la parte che decide se sei ancora nel gioco.Show the script for this step
PERCORSI = 5000 # PROVA / TRY: 500 (veloce) · 5000 · 20000 (code più nette)
QUOTE = [1.00, 0.50, 0.20] # PROVA / TRY: le tue quote di concentrazione
r = rendimenti(carica("btcusdt").sort("data")["chiusura"].to_numpy())
# PROVA / TRY: per un'altra serie aggiungila a avvio.prepara([...]) qui sopra
# — le 11 disponibili sono in codice/dati/registro.json
rng = np.random.default_rng(seed_for("custodia"))
# NON TOCCARE / DO NOT CHANGE: è lo stesso seme della figura del capitolo. Con
# questo, gli stessi 5.000 percorsi e gli stessi anni, la tabella qui sotto è
# quella stampata nel libro — che è la promessa del libro, non un dettaglio.
# Cambiarlo dopo aver visto il risultato è il p-hacking che il libro smonta.
# This is the same seed as the chapter's figure. With it — same 5,000 paths,
# same number of years — the table below is the one printed in the book.
anni_percorso = max(1, round(len(r) / 365))
mercato = bootstrap_traiettorie(r, n_traiettorie=PERCORSI, rng=rng,
a_blocchi=20)[:, -1]
colpito = (rng.random((PERCORSI, anni_percorso)) < RISCHIO_ANNUO).any(axis=1)
with avvio.figura("schermo"):
fig, ax = plt.subplots()
for quota in QUOTE:
finali = np.maximum(np.where(colpito, mercato * (1 - quota), mercato), 1e-4)
ordinati = np.sort(finali)
probabilita = np.arange(1, len(ordinati) + 1) / len(ordinati) * 100
ax.plot(ordinati, probabilita, linewidth=2,
label=t(f"{quota:.0%} in una sede", f"{quota:.0%} at one venue"))
ax.axvline(1.0, linewidth=1.2, linestyle=":")
ax.set_xscale("log")
ax.set_xlabel(t(f"Capitale dopo {anni_percorso} anni (volte quello iniziale, scala log)",
f"Capital after {anni_percorso} years (× starting, log scale)"))
ax.set_ylabel(t("Percorsi con esito peggiore o uguale (%)", "Paths with equal or worse outcome (%)"))
ax.legend()
plt.show()
print(t(f"orizzonte dei percorsi, dettato dai dati: {anni_percorso} anni "
f"({len(r)} rendimenti)",
f"path horizon, set by the data: {anni_percorso} years ({len(r)} returns)"))
print(t(f"probabilita' di almeno un evento nei {anni_percorso} anni: {colpito.mean():.1%}\n",
f"probability of at least one event in {anni_percorso} years: {colpito.mean():.1%}\n"))
print(f"{t('quota in una sede', 'share at one venue'):>18s} {t('mediana', 'median'):>10s} "
f"{t('5% peggiore', 'worst 5%'):>13s} {t('sotto il capitale', 'below capital'):>19s}")
for quota in QUOTE:
finali = np.where(colpito, mercato * (1 - quota), mercato)
print(f"{quota:18.0%} {np.median(finali):9.2f}x {np.percentile(finali, 5):12.2f}x "
f"{float((finali < 1).mean()):19.1%}")
print(t("\nLa mediana si sposta poco: nel caso tipico non succede niente, ed e' per "
"questo che il problema non si vede. Cambia la coda sinistra, cioe' "
"esattamente la parte che decide se sei ancora nel gioco.",
"\nThe median barely moves: in the typical case nothing happens, and "
"that's exactly why the problem is invisible. What changes is the left "
"tail — exactly the part that decides whether you're still in the "
"game."))4. The exercise I recommend
Do it twice: first with your current situation, as it is; then with the share you would have decided to hold, had you thought about it. The gap between the two numbers is the cost of never having made that decision.
Output
cosi' com'e' oggi (quota 85%): 5% peggiore 0.32x percorsi sotto il capitale 14.0%
con il limite (quota 20%): 5% peggiore 0.48x percorsi sotto il capitale 10.4%
Distribuire su piu' sedi NON riduce a zero il rischio: lo trasforma da un interruttore in una perdita parziale, e in cambio aggiunge piu' cose da gestire, piu' credenziali, piu' punti in cui sbagliare. E' un compromesso, non una soluzione.Show the script for this step
QUOTA_ATTUALE = 0.85 # ← quanto hai davvero nel posto piu' pieno, oggi
# PROVA / TRY: la tua quota vera
QUOTA_DECISA = 0.20 # ← il limite che vorresti darti
# PROVA / TRY: il limite che decideresti tu
def coda(quota: float) -> tuple[float, float]:
finali = np.where(colpito, mercato * (1 - quota), mercato)
return float(np.percentile(finali, 5)), float((finali < 1).mean())
for nome, quota in (
(t("cosi' com'e' oggi", "as it is today"), QUOTA_ATTUALE),
(t("con il limite", "with the limit"), QUOTA_DECISA),
):
peggiore, sotto = coda(quota)
print(t(f"{nome:>20s} (quota {quota:.0%}): 5% peggiore {peggiore:6.2f}x "
f"percorsi sotto il capitale {sotto:.1%}",
f"{nome:>20s} (share {quota:.0%}): worst 5% {peggiore:6.2f}x "
f"paths below capital {sotto:.1%}"))
print(t("\nDistribuire su piu' sedi NON riduce a zero il rischio: lo trasforma da un "
"interruttore in una perdita parziale, e in cambio aggiunge piu' cose da "
"gestire, piu' credenziali, piu' punti in cui sbagliare. E' un compromesso, "
"non una soluzione.",
"\nSpreading across several venues does NOT reduce risk to zero: it turns "
"it from an on/off switch into a partial loss, and in exchange adds more "
"things to manage, more credentials, more points of failure. It's a "
"trade-off, not a solution."))The four questions no calculation replaces
- Whose key is it? If the credentials that move the funds are only yours, the risk is yours and reducible with procedures under your control. If they belong to someone else, you're running that someone else's risk.
- What happens if that venue shuts down tomorrow morning? Not what it says would happen — what actually, materially happens.
- How much do I have there, as a percentage? As a percentage, not in absolute value: absolute value grows on its own with the market, and that's how almost everyone ends up concentrated without having decided to.
- Who else can move these assets? Authorizations granted years ago and never revoked, decommissioned devices, copies of credentials left in places that seemed convenient at the time.
Reproducibility & downloads
Run on 2026-08-27 from the repository notebook
The notebook
calc_05_custodia.ipynb17.6 KB
sha256 8da65f7f76d16ecf892f33093e6abfbd36e94215add6ac04fc7a6c306d94ee7a
calc_05_custodia.py14.0 KB
sha256 f2b2c3916dfd87b19fe0ad37f15f95e3f6a412121f946961df193c3c6cf90fdd
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