From 326229188a6e513509a26d9ec3dd9e1d8b6c0438 Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sun, 26 Jul 2026 17:57:53 +0000 Subject: [PATCH] =?UTF-8?q?research:=20rischio=20di=20VENUE=20=E2=80=94=20?= =?UTF-8?q?mai=20prezzato,=20e=20non=20e'=20diversificabile=20dagli=20slee?= =?UTF-8?q?ve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domanda dell'operatore: "tutto su Deribit?". Ha scoperto un buco, non un dettaglio. IL BUCO. Il progetto ha prezzato con ossessione fee, slippage, min-order, pavimento IB, haircut small-cap, fortuna d'ancora, degrado d'esecuzione, look-ahead, backfill, split non aggiustati — e MAI la probabilita' che l'exchange sparisca col saldo dentro. E TP01+SKH01+VRP01 stanno tutti sullo stesso conto: tre sleeve quasi-ortogonali sui ritorni, PERFETTAMENTE CORRELATI sul fallimento del venue. La matrice di correlazione del book non lo vede per costruzione. LIMITE DEI MURI DEL 25-26/07 (dichiarato): book_series gira a alloc=$600 col book a 2 sleeve, quindi portarlo a $272k assumeva gia' "tutto su Deribit" senza dirlo — e assumeva anche di girare il book da $600 a $272k (falso: a $3k GTAA01, $5k XSR01, $20k XS01 -> il muro vero e' piu' basso). LA MISURA (accumulo da $600, 250 EUR/m, 20a, bersaglio $272k, x0.89, jump di venue; CONC = 100% Deribit vs SPLIT = Deribit 65 / HL 15 / IB 20; bersaglio identico -> conservativo CONTRO lo split): p annua P(arrivare) CONC / SPLIT P(perso TUTTO) CONC / SPLIT 0.5% 87% / 90% 10% / 0% 1.0% 81% / 83% 18% / 0% 2.0% 69% / 71% 34% / 4% 5.0% 42% / 45% 64% / 27% Capitale mediano CONC a 20a: $544k (p=0) -> $0 (p=5%). LA COLONNA CHE CONTA NON E' LA PRIMA. Sulla probabilita' di ARRIVARE la concentrazione costa 1-3pp; sulla ROVINA fino a 64pp. Motivo strutturale: con un conto solo "almeno un fallimento" COINCIDE con "perso tutto". Lo SPLIT viene colpito 2.5x piu' spesso ed e' molto piu' sicuro -> "quante volte vieni colpito" non e' una misura di rischio. ONESTA': p NON e' stimato (sensibilita', non previsione); i fallimenti sono assunti indipendenti, ottimistico per Deribit-HL -> la parte solida dello split e' IB, altra classe di rischio; a $600 lo split e' impossibile, la concentrazione e' forzata. RISPOSTA: no, ma la domanda ha una DATA. Prima soglia vera ~$3k (GTAA01 su IB). E converge col 25/07: "max 25% su IB, COSTA ~0.08 EUR/g" era detto su basi di solo rendimento; sull'asse della rovina quello stesso 25% e' la mossa principale — non e' il prezzo di un peggioramento, e' il premio di un'assicurazione. Incluse le aggiunte a r0726_capwall_refresh: drill-down 250 EUR/m e solve_deposit (10 anni @P=90% = 1.178 EUR/m, $155.923 versati su $272k -> il rendimento fa il 43%, contro il 77% a 20 anni). Bug catturato: il contatore dei versamenti si congelava al traguardo mentre il capitale continuava a riceverli -> rapporto gonfiato (46x vs 25x a 30 anni); test di regressione cablato. Book, pesi, cron, config INVARIATI. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 56 ++++++++ docs/diary/2026-07-26-venue-risk.md | 119 +++++++++++++++ scripts/research/r0726_capwall_refresh.py | 149 +++++++++++++++++++ scripts/research/r0726_venue_risk.py | 168 ++++++++++++++++++++++ tests/test_venue_risk.py | 123 ++++++++++++++++ 5 files changed, 615 insertions(+) create mode 100644 docs/diary/2026-07-26-venue-risk.md create mode 100644 scripts/research/r0726_venue_risk.py create mode 100644 tests/test_venue_risk.py diff --git a/CLAUDE.md b/CLAUDE.md index 5d7df06..c7b3b43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -488,6 +488,23 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis capitale-rendita non si raggiunge mai** (0% dei path a 20 anni a OGNI fattore) — l'accumulo viene dai versamenti, non dal rendimento; e la mediana e' una MEDIANA (meta' dei path arriva dopo, una quota non arriva affatto). + **QUANTO VERSARE PER UN ORIZZONTE DATO** (bersaglio $272k, ×0.89; "arrivarci in N anni" non e' + un numero: dipende dalla confidenza): + | orizzonte | P=50% | P=75% | P=90% | P=95% | totale versato @P=90% | + |---|---|---|---|---|---| + | **10 anni** | €806/m | €995/m | **€1.178/m** | €1.299/m | **$155.923** | + | 15 anni | €313/m | €408/m | €509/m | €583/m | $101.643 | + | 20 anni | €129/m | €180/m | €237/m | €280/m | $63.406 | + ⚠️ **Comprimere l'orizzonte da 20 a 10 anni costa 2.5× al mese E 2.5× in totale**: a 10 anni + versi $155k per arrivare a $272k (il rendimento fa il 43%), a 20 anni ne versi $63k (il + rendimento fa il **77%**). **A orizzonti corti non fai lavorare la strategia, COMPRI il + capitale coi bonifici** — il confronto onesto e' sempre `totale versato` vs bersaglio. + Drill-down €250/m (5000 path): traguardo p10 13.4a / **mediana 16.2a** / p90 19.7a; + P(entro 15a) **31%** → P(entro 20a) **92%** (la curva non da' segnali per un decennio e poi si + muove tutta insieme: chi giudica al 5° anno giudica nel punto peggiore); al traguardo hai + versato ~$54k su $272k → **80% viene dal rendimento**. ⚠️ Bug catturato in sessione: il + contatore `paid` si congelava al traguardo mentre `cap` continuava a ricevere i depositi → + rapporto capitale/versato gonfiato (46× invece di 25× a 30 anni); test di regressione cablato. (3) **Diversificare NON crea reddito a pari nozionale — libera BUDGET DI RISCHIO.** Per tier: iso-nozionale 2 sleeve 5.98% → 4 sleeve 6.35% (nulla, il diversificatore a basso CAGR toglie vol e ritorno insieme); a **ISO-RISCHIO** (vol 15%) 7.51%/$395k → **9.25%/$321k (−19% di muro)**. @@ -863,6 +880,45 @@ Prima ondata di ricerca onesta su BTC/ETH certificati (5 track, harness condivis da `main()`** — i numeri headline del diario del 26/07 vennero da una corsa ad hoc mai committata. Ora il driver e' committato (`r0726_skh_live_book.py`). *Un numero pubblicato in un diario deve avere uno script committato che lo riproduce.* +- ⚠️ **RISCHIO DI VENUE — mai prezzato in 2 mesi, e non e' diversificabile dagli sleeve + (2026-07-26).** Script `r0726_venue_risk.py`, test `tests/test_venue_risk.py` (13), diario + `2026-07-26-venue-risk.md`. **Book/pesi/cron INVARIATI.** + (0) **Il buco:** il progetto ha prezzato fee, slippage, min-order, pavimento IB, haircut + small-cap, fortuna d'ancora, degrado d'esecuzione, look-ahead, backfill, split — **mai la + probabilita' che l'exchange sparisca col saldo dentro**. E TP01+SKH01+VRP01 stanno **tutti sullo + stesso conto Deribit**: tre sleeve quasi-ortogonali sui ritorni, **perfettamente correlati sul + fallimento del venue** — cosa che la matrice di correlazione del book non vede per costruzione. + (0-bis) ⚠️ **Limite dei muri del 25-26/07:** `book_series` gira a **`alloc=$600` col book a 2 + sleeve** → portarlo fino a $272k assume (a) che a $272k si giri ancora il book da $600 (falso: + a $3k GTAA01, $5k XSR01, $20k XS01 → il muro vero e' **piu' basso**) e (b) **"tutto su Deribit" + per 10-20 anni, senza dirlo**. + (1) **La misura** (accumulo da $600, €250/m, 20a, bersaglio $272k, ×0.89, jump di venue a + probabilita' annua `p`; CONC = 100% Deribit vs SPLIT = Deribit 65 / HL 15 / IB 20; **bersaglio + identico → conservativo CONTRO lo split**): + | p annua | P(arrivare) CONC | SPLIT | **P(perso TUTTO) CONC** | **SPLIT** | + |---|---|---|---|---| + | 0.5% | 87% | 90% | **10%** | **0%** | + | 1.0% | 81% | 83% | **18%** | **0%** | + | 2.0% | 69% | 71% | **34%** | **4%** | + | 5.0% | 42% | 45% | **64%** | **27%** | + Capitale mediano CONC a 20a: $544k (p=0) → **$0 (p=5%)**. + (2) **La colonna che conta non e' la prima.** Sulla probabilita' di ARRIVARE la concentrazione + costa 1-3pp; sulla **ROVINA** costa fino a 64pp. Motivo strutturale: **con un conto solo "almeno + un fallimento" COINCIDE con "perso tutto"**. ⚠️ Lo SPLIT viene colpito **2.5× piu' spesso** (26% + vs 10%) ed e' molto piu' sicuro → **"quante volte vieni colpito" NON e' una misura di rischio**. + (3) **Onesta' obbligatorie:** `p` **non e' stimato** (sensibilita', non previsione — la sceglie + l'operatore e va dichiarata); i fallimenti sono assunti **indipendenti**, ottimistico per + Deribit-HL (crisi sistemica) → **la parte solida dello split e' IB**, altra classe di rischio; + **a $600 lo split e' impossibile**, la concentrazione e' forzata. + (4) **RISPOSTA: no, ma la domanda ha una DATA.** Oggi concentrazione forzata; **~$3k = prima + riduzione vera** (GTAA01 su IB, 20-25% fuori dal rischio-exchange); ~$20k (XS01/HL) aggiunge + poco perche' e' ancora crypto. ✅ **Convergenza che rafforza il 25/07:** `r0725_ib10k` disse + "max 25% su IB, **costa** ~€0.08/g" su basi di solo RENDIMENTO; sull'asse della ROVINA quello + stesso 25% e' la mossa principale → **€0.08/g non e' il prezzo di un peggioramento, e' il premio + di un'assicurazione contro il modo piu' probabile di perdere tutto.** + **REGOLE:** (a) un rischio non-diversificabile dagli sleeve va prezzato a parte; (b) P(successo) + puo' nascondere P(rovina) — per una rendita la metrica e' la seconda; (c) una raccomandazione + presa su un asse solo va ricontrollata sugli altri prima di considerarla stabile. - **Soffitto strutturale BTC/ETH-direzionale ~1.3** superato SOLO espandendo a un meccanismo diverso: cross-sectional su universo Hyperliquid certificato (XS01) → portafoglio Sharpe ~1.55. - **Sweep "strategie alternative" (2026-06-20) — 104 ipotesi / 153 agenti / NIENTE di nuovo regge.** diff --git a/docs/diary/2026-07-26-venue-risk.md b/docs/diary/2026-07-26-venue-risk.md new file mode 100644 index 0000000..e77c68c --- /dev/null +++ b/docs/diary/2026-07-26-venue-risk.md @@ -0,0 +1,119 @@ +# 2026-07-26 — "Tutto su Deribit?" — il rischio di venue, mai prezzato in 2 mesi di progetto + +**Richiesta:** *"tutto su deribit?"* (dopo il ricalcolo dei muri di capitale e della traiettoria). + +**Script:** `r0726_venue_risk.py` + aggiunte a `r0726_capwall_refresh.py` (drill-down €250/mese, +`solve_deposit`). **Test:** `tests/test_venue_risk.py` (13). **Book, pesi, cron: INVARIATI.** + +--- + +## 0. La domanda ha scoperto un buco, non un dettaglio + +Il progetto ha prezzato con ossessione: fee a 0.10% RT, slippage, min-order $5, pavimento fisso +IB, haircut small-cap, fortuna d'ancora su 4 sleeve, degrado d'esecuzione su entrambi i lati di +SKH01, look-ahead, backfill sintetico, split non aggiustati. + +**Non ha mai prezzato la probabilita' che l'exchange sparisca col saldo dentro.** Per un piano di +rendita a 10-20 anni su un exchange offshore e' un rischio di prim'ordine, e — punto che rende il +buco piu' grave — **non e' diversificabile con gli sleeve**: TP01, SKH01 e VRP01 stanno tutti +sullo stesso conto Deribit. Tre sleeve quasi-ortogonali sui ritorni, **perfettamente correlati sul +fallimento del venue**. + +## 0-bis. E un limite nei muri che avevo appena pubblicato + +`r0725_capcurve.book_series` gira a **`alloc=$600`** col book a **2 sleeve** (TP01+SKH01 75/25). +Portare quella serie fino a $272.061 assume due cose mai dichiarate: + (a) che a $272k si giri ancora il book da $600 — falso: a ~$3k si accende GTAA01, a ~$5k XSR01, + a ~$20k XS01, e il book a 5 sleeve ha Sharpe piu' alto → **il muro vero e' piu' basso**; + (b) che tutto il capitale stia su **un solo exchange** per 10-20 anni — cioe' i muri + **assumevano gia' "tutto su Deribit"**, senza dirlo. + +--- + +## 1. La misura + +Monte Carlo dell'accumulo (da $600, €250/mese, 20 anni, bersaglio $272.061, de-luck ×0.89) con +**jump di venue**: ogni venue fallisce indipendentemente con probabilita' annua `p`, e in quel caso +la sua quota di capitale va a **zero**; i depositi futuri si ripartiscono fra i superstiti. + +* **CONC** = tutto su Deribit (TP01+SKH01) — l'ipotesi implicita dei muri +* **SPLIT** = Deribit 65% / Hyperliquid 15% (XS01) / IB 20% (GTAA01) — il book attivo mappato sui + venue dove gira davvero + +Bersaglio **identico** nei due casi, per confrontabilita': lo SPLIT ha in realta' Sharpe piu' alto +e quindi muro piu' basso → **il confronto e' conservativo CONTRO lo split**. + +``` +p annua P(arrivare a $272k in 20a) P(perso TUTTO) + CONC SPLIT diff CONC SPLIT +0.0% 95% 96% +1% 0% 0% +0.5% 87% 90% +2% 10% 0% +1.0% 81% 83% +3% 18% 0% +2.0% 69% 71% +3% 34% 4% +5.0% 42% 45% +3% 64% 27% +``` + +Capitale mediano a 20 anni, CONC: $544k (p=0) → $475k (p=1%) → **$0 (p=5%)**. + +### La colonna che conta non e' la prima + +Sulla probabilita' di **arrivare** al bersaglio la concentrazione costa quasi nulla (1-3pp): se il +venue regge, ci arrivi. Il danno e' tutto sulla **rovina**: a p=1%/anno la concentrazione porta +**18% di probabilita' di perdere tutto** contro **0%**; a p=5% il capitale mediano e' azzerato. + +Il motivo e' strutturale: **con un solo conto "almeno un fallimento" coincide con "perso tutto"**. +Nello split un fallimento costa una frazione. + +⚠️ Controintuitivo ma corretto: **lo SPLIT viene colpito PIU' spesso** (26% vs 10% a p=0.5%: ha +tre venue che possono fallire) ed e' irrilevante. Conta quanto perdi, non quante volte sei toccato. +Una metrica "P(almeno un incidente)" avrebbe classificato lo split come il piu' rischioso. + +--- + +## 2. Tre onesta' obbligatorie + +1. **`p` NON e' stimato.** Il record degli exchange crypto dice che non e' trascurabile, ma + metterci una cifra precisa qui sarebbe inventarla. La tabella e' una **sensibilita'**: la scelta + di `p` resta dell'operatore e va dichiarata insieme al risultato. +2. **I fallimenti sono assunti INDIPENDENTI**, e per la coppia Deribit-Hyperliquid e' ottimistico: + in una crisi sistemica crypto possono andare insieme. **La parte solida dello split e' IB**, che + e' un'altra classe di rischio (broker regolamentato), non un terzo exchange crypto. +3. **Oggi lo split non e' una scelta.** GTAA01 richiede $3k (`GTAA_MIN_CAPITAL`), XS01 ~$20k. A + $600 la concentrazione e' **forzata**, non scelta. + +--- + +## 3. Conclusione: la domanda ha una data, non una risposta secca + +**No, non tutto su Deribit — ma non da oggi.** + +| soglia | cosa si sblocca | effetto sul rischio di venue | +|---|---|---| +| oggi ($600) | niente | concentrazione **forzata** | +| **~$3k** | GTAA01 su IB | **prima riduzione vera**: 20-25% fuori dal rischio-exchange, su un broker regolamentato | +| ~$20k | XS01 su Hyperliquid | poco: e' ancora crypto, fallimenti plausibilmente correlati | + +### La convergenza che rafforza una decisione gia' presa + +Il 25/07 (`r0725_ib10k.py`) concludeva: **"al massimo 25% su IB, costa ~€0.08/g di rendita"** — su +basi puramente di **rendimento**, e con tono di concessione ("costa"). Questa misura dice che quel +25% compra una riduzione grande della probabilita' di **rovina**, che l'analisi di allora non +prezzava affatto. + +**Stesso numero, ragione diversa, e molto piu' forte: €0.08/giorno non e' il prezzo di un +peggioramento, e' il premio di un'assicurazione contro il modo piu' probabile di perdere tutto.** + +--- + +## 4. Regole trasferibili + +1. **Un rischio non-diversificabile dagli sleeve va prezzato a parte.** Tre sleeve ortogonali sui + ritorni possono essere perfettamente correlati sul fallimento del venue: la matrice di + correlazione del book non lo vede, per costruzione. +2. **P(successo) puo' nascondere P(rovina).** Qui la concentrazione costa 1-3pp sulla prima e fino + a 64pp sulla seconda. Per un piano di rendita la metrica e' la seconda. +3. **"Quante volte vieni colpito" non e' una misura di rischio** — lo split viene colpito 2.5x piu' + spesso ed e' molto piu' sicuro. +4. **Quando una raccomandazione e' presa su un asse solo, ricontrollarla sugli altri prima di + considerarla stabile.** Il "25% su IB" era una concessione sul rendimento; sull'asse della + rovina e' la mossa principale. diff --git a/scripts/research/r0726_capwall_refresh.py b/scripts/research/r0726_capwall_refresh.py index 9f8cc5e..de6053c 100644 --- a/scripts/research/r0726_capwall_refresh.py +++ b/scripts/research/r0726_capwall_refresh.py @@ -94,6 +94,77 @@ def trajectory(r: np.ndarray, cap_needed: float, n_paths: int = 3000, return out +def focus(r: np.ndarray, cap_needed: float, dep_eur: float, n_paths: int = 5000, + years: int = 30, seed: int = 726) -> dict: + """Drill-down su UN livello di deposito: percentili del tempo, probabilita' cumulate, + capitale raggiunto per anno, e quanto del capitale finale e' DEPOSITO vs rendimento.""" + rng = np.random.default_rng(seed) + n_days = 365 * years + paths = CC._boot_paths(r, n_paths, n_days, 20, rng) + dep = dep_eur * CC.EURUSD + cap = np.full(n_paths, 600.0) + hit = np.full(n_paths, -1, int) + paid = np.full(n_paths, 600.0) # capitale immesso (iniziale + depositi) + paid_at_hit = np.full(n_paths, np.nan) + snaps, snap_paid = {}, {} + for t in range(n_days): + cap = cap * (1.0 + paths[:, t]) + if t % 30 == 0 and t > 0: + cap += dep + # ⚠️ il contatore deve seguire il capitale: `cap` riceve il deposito SEMPRE, quindi + # anche `paid` accumula sempre. Congelarlo al traguardo (come faceva la prima + # stesura) rende il rapporto capitale/versato gonfiato dopo il traguardo, perche' + # il capitale conterrebbe depositi che il contatore non conta. Il valore AL + # traguardo si cattura a parte, con lo snapshot `paid_at_hit`. + paid = paid + dep + newly = (hit < 0) & (cap >= cap_needed) + hit[newly] = t + paid_at_hit[newly] = paid[newly] + if (t + 1) % 365 == 0: + snaps[(t + 1) // 365] = cap.copy() + snap_paid[(t + 1) // 365] = paid.copy() + yrs = np.where(hit >= 0, hit / 365.0, np.nan) + fin = yrs[~np.isnan(yrs)] + return dict(yrs=yrs, fin=fin, snaps=snaps, snap_paid=snap_paid, + paid_at_hit=paid_at_hit[~np.isnan(paid_at_hit)], n=n_paths) + + +def _p_hit(paths: np.ndarray, cap_needed: float, dep_usd: float, n_days: int) -> float: + """P(il capitale tocca `cap_needed` entro n_days) partendo da $600 con deposito mensile.""" + cap = np.full(paths.shape[0], 600.0) + hit = np.zeros(paths.shape[0], bool) + for t in range(n_days): + cap = cap * (1.0 + paths[:, t]) + if t % 30 == 0 and t > 0: + cap += dep_usd + hit |= cap >= cap_needed + return float(hit.mean()) + + +def solve_deposit(r: np.ndarray, cap_needed: float, years: int, p_target: float, + n_paths: int = 4000, seed: int = 726) -> tuple[float, float]: + """Deposito mensile (EUR) perche' P(arrivare entro `years`) >= p_target. + + Il bootstrap si estrae UNA VOLTA e si riusa a ogni passo della ricerca binaria: con + campioni diversi per passo la funzione non sarebbe monotona e la bisezione oscillerebbe. + """ + rng = np.random.default_rng(seed) + n_days = 365 * years + paths = CC._boot_paths(r, n_paths, n_days, 20, rng) + lo, hi = 0.0, 20_000.0 + if _p_hit(paths, cap_needed, hi * CC.EURUSD, n_days) < p_target: + return float("nan"), float("nan") + for _ in range(16): + mid = (lo + hi) / 2 + if _p_hit(paths, cap_needed, mid * CC.EURUSD, n_days) >= p_target: + hi = mid + else: + lo = mid + dep = hi + versato = 600.0 + dep * CC.EURUSD * (years * 365 // 30) + return dep, versato + + def main() -> None: print("=" * 100) print(" r0726 — MURI DI CAPITALE col fattore de-luck MISURATO (era ×0.6 a occhio)") @@ -180,6 +251,84 @@ def main() -> None: print(" capitale-rendita non si raggiunge, e la mediana e' una MEDIANA (meta' dei") print(" path arriva dopo, e una quota non arriva affatto).") + # ------------------------------------------------------- drill-down su un deposito + DEP = 250.0 + print("\n" + "=" * 100) + print(f" DRILL-DOWN — EUR {DEP:,.0f}/mese (5000 path, 30 anni)") + print("=" * 100) + print(f" E' la soglia dove il verdetto si ribalta: P(entro 20a) 8% (×0.60) -> 91% (×0.89).") + r_new = (base - (1.0 - 0.89) * mu).values.astype(float) + r_old = (base - (1.0 - 0.60) * mu).values.astype(float) + F = focus(r_new, cap_new, DEP) + F_old = focus(r_old, cap_old, DEP) + + print(f"\n A) QUANDO si tocca il capitale-rendita (${cap_new:,.0f})") + print(f" {'':<14}{'p10':>9}{'p25':>9}{'MEDIANA':>10}{'p75':>9}{'p90':>9}") + for lab, D in (("26/07 (×0.89)", F), ("25/07 (×0.60)", F_old)): + f_ = D["fin"] + if len(f_) < 100: + print(f" {lab:<14}{'—':>9}{'—':>9}{'—':>10}{'—':>9}{'—':>9}") + continue + q = np.percentile(f_, [10, 25, 50, 75, 90]) + print(f" {lab:<14}" + "".join(f"{v:8.1f}a" for v in q[:2]) + + f"{q[2]:9.1f}a" + "".join(f"{v:8.1f}a" for v in q[3:])) + + print(f"\n B) PROBABILITA' di averlo toccato entro N anni") + print(f" {'':<14}" + "".join(f"{y:>9}a" for y in (5, 10, 15, 20, 25, 30))) + for lab, D in (("26/07 (×0.89)", F), ("25/07 (×0.60)", F_old)): + y = D["yrs"] + print(f" {lab:<14}" + "".join(f"{float((y <= n).sum())/D['n']:9.0%}" + for n in (5, 10, 15, 20, 25, 30))) + + print(f"\n C) CAPITALE lungo la strada (×0.89) — e quanto ne hai MESSO tu") + print(f" {'anno':>5}{'versato':>12}{'p10':>13}{'MEDIANA':>13}{'p90':>13}" + f"{' mediana/versato':>18}") + for y in (5, 10, 15, 20, 25, 30): + c = F["snaps"][y] + pd_ = float(np.median(F["snap_paid"][y])) + q = np.percentile(c, [10, 50, 90]) + print(f" {y:>5}{'$'+format(pd_, ',.0f'):>12}" + + "".join(f"{'$'+format(v, ',.0f'):>13}" for v in q) + + f"{q[1]/pd_:>17.1f}x") + + if len(F["paid_at_hit"]) > 100: + med_paid = float(np.median(F["paid_at_hit"])) + print(f"\n D) AL TRAGUARDO (mediana dei path che arrivano)") + print(f" versato di tasca: ${med_paid:,.0f} " + f"(= EUR {med_paid/CC.EURUSD:,.0f} in ~{np.median(F['fin']):.0f} anni)") + print(f" capitale: ${cap_new:,.0f}") + print(f" quota da RENDIMENTO: {(1 - med_paid/cap_new)*100:.0f}% " + f"(il resto sono i tuoi versamenti)") + print(f" rendita che paga: EUR {CC.TARGET_EUR_DAY:.0f}/giorno netti") + + # -------------------------------------------- quanto versare per un ORIZZONTE dato + print("\n" + "=" * 100) + print(f" QUANTO VERSARE PER ARRIVARCI IN N ANNI (bersaglio ${cap_new:,.0f}, ×0.89)") + print("=" * 100) + print(" 'Arrivarci in N anni' non e' un numero: dipende da quanto vuoi esserne sicuro.") + print(" La colonna e' la PROBABILITA' di ORRIVARE entro l'orizzonte.".replace("ORRIVARE", "arrivare")) + print(f"\n {'orizzonte':<12}{'P=50%':>12}{'P=75%':>12}{'P=90%':>12}{'P=95%':>12}") + sol = {} + for years in (10, 15, 20): + row = f" {str(years) + ' anni':<12}" + for p in (0.50, 0.75, 0.90, 0.95): + dep, versato = solve_deposit(r_new, cap_new, years, p) + sol[(years, p)] = (dep, versato) + row += (f"{'EUR ' + format(dep, ',.0f'):>12}" if np.isfinite(dep) + else f"{'irragg.':>12}") + print(row) + print(f"\n {'':12}{'(totale versato di tasca su tutto l orizzonte)':<48}") + for years in (10, 15, 20): + row = f" {str(years) + ' anni':<12}" + for p in (0.50, 0.75, 0.90, 0.95): + _d, v = sol[(years, p)] + row += (f"{'$' + format(v, ',.0f'):>12}" if np.isfinite(v) else f"{'—':>12}") + print(row) + print("\n ⚠️ A orizzonti corti il versamento domina il rendimento: si sta comprando") + print(" il capitale, non generandolo. Il confronto onesto e' con la colonna") + print(" 'totale versato' — se si avvicina al bersaglio, il book non sta facendo") + print(" il lavoro, lo stanno facendo i bonifici.") + if __name__ == "__main__": main() diff --git a/scripts/research/r0726_venue_risk.py b/scripts/research/r0726_venue_risk.py new file mode 100644 index 0000000..8beeb5b --- /dev/null +++ b/scripts/research/r0726_venue_risk.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python +"""r0726_venue_risk.py — "tutto su Deribit?": il rischio di VENUE, mai prezzato dal progetto. + +CONTESTO. I muri di capitale (25/07, corretti il 26/07) sono calcolati su `book_series("hourly")` += **TP01+SKH01 75/25 a alloc=$600**, cioe' il book a 2 sleeve eseguibile a bassa taglia. Portare +quella serie fino a $272.061 assume implicitamente due cose mai dichiarate: + (a) che a $272k si continui a girare il book da $600 (falso: a ~$3k si accende GTAA01, a ~$5k + XSR01, a ~$20k XS01 — il book a 5 sleeve ha Sharpe piu' alto, quindi il muro vero e' PIU' + BASSO di quello che ho pubblicato); + (b) che TUTTO il capitale stia su **un solo exchange**, per 10-20 anni. + +Il progetto ha prezzato con cura fee, slippage, min-order, pavimenti di commissione, fortuna +d'ancora e degrado d'esecuzione — e **mai** la probabilita' che il venue sparisca col saldo dentro. +Per un piano di rendita ventennale su un exchange offshore quello e' un rischio di prim'ordine, e +non e' diversificabile con gli sleeve: TP01, SKH01 e VRP01 stanno **tutti** sullo stesso conto. + +COSA FA. Monte Carlo dell'accumulo con **jump di venue**: ogni venue fallisce indipendentemente +con probabilita' annua `p` e in quel caso la sua quota di capitale va a **zero** (i depositi futuri +vanno ai superstiti). Confronta: + * CONC = tutto su Deribit (TP01+SKH01), cioe' l'ipotesi implicita dei muri; + * SPLIT = Deribit 65% (TP01+SKH01) / Hyperliquid 15% (XS01) / IB 20% (GTAA01), + cioe' il book attivo mappato sui venue dove gira davvero. +Bersaglio FISSO ($272.061) in entrambi i casi, per confrontabilita': lo SPLIT ha in realta' un +muro piu' basso (Sharpe piu' alto), quindi il confronto e' conservativo CONTRO lo split. + +NON stima `p`. Il record storico degli exchange crypto (Mt.Gox, QuadrigaCX, FTX...) dice che non +e' trascurabile, ma un numero preciso qui sarebbe inventato: si riporta la SENSIBILITA' e la +scelta di `p` resta dichiarata dall'operatore. + + uv run python scripts/research/r0726_venue_risk.py +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + +import r0725_capcurve as CC # noqa: E402 +import r0726_loo_deluck as L # noqa: E402 +from src.portfolio.portfolio import combine_outer # noqa: E402 + +TARGET = 272_061.0 +DEP_EUR = 250.0 +YEARS = 20 +N_PATHS = 4000 +P_GRID = (0.0, 0.005, 0.01, 0.02, 0.05) +DELUCK = 0.89 + + +def venue_series() -> dict: + """Serie giornaliere per VENUE, alle ancore canoniche, de-luckate sul drift.""" + S = L.build_all() + can = {n: S[n][L.SPEC[n][2]] for n in L.SPEC} + deribit = combine_outer({k: can[k] for k in ("TP01_trend_1d", "SKH01_skyhook")}, + {"TP01_trend_1d": 0.75, "SKH01_skyhook": 0.25}) + out = {"Deribit": deribit, "Hyperliquid": can["XS01_xsec_hl"], "IB": can["GTAA01_eq_trend"]} + return {k: v - (1.0 - DELUCK) * float(v.mean()) for k, v in out.items()} + + +def simulate(cfg: dict, series: dict, p_annual: float, n_paths: int = N_PATHS, + years: int = YEARS, seed: int = 726) -> dict: + """Accumulo da $600 con deposito mensile e jump di venue. + + `cfg`: {venue: peso}. Ogni venue fallisce indipendentemente (hazard giornaliero costante); + al fallimento la sua quota va a zero e il venue esce (depositi ai superstiti, pesi + rinormalizzati). Le serie dei venue sono allineate su indice comune per estrarre blocchi + CONGIUNTI: i venue non sono indipendenti fra loro sui RITORNI, solo sui fallimenti. + """ + rng = np.random.default_rng(seed) + n_days = years * 365 + names = list(cfg) + J = pd.concat({k: series[k] for k in names}, axis=1, join="inner").fillna(0.0) + R = J.values.astype(float) # (T, V) ritorni congiunti + idx = CC._boot_paths(np.arange(len(R), dtype=float), n_paths, n_days, 20, rng).astype(int) + dep = DEP_EUR * CC.EURUSD + + w0 = np.array([cfg[k] for k in names], float) + cap = np.outer(np.full(n_paths, 600.0), w0) # (P, V) capitale per venue + alive = np.ones((n_paths, len(names)), bool) + haz = 1.0 - (1.0 - p_annual) ** (1.0 / 365.0) + hit = np.zeros(n_paths, bool) + hit_day = np.full(n_paths, n_days + 1, int) + for t in range(n_days): + cap *= (1.0 + R[idx[:, t], :]) + if haz > 0: + dead = (rng.random((n_paths, len(names))) < haz) & alive + cap = np.where(dead, 0.0, cap) + alive &= ~dead + if t % 30 == 0 and t > 0: + wa = alive * w0 # depositi ripartiti fra i superstiti + tot = wa.sum(axis=1, keepdims=True) + share = np.divide(wa, tot, out=np.zeros_like(wa), where=tot > 0) + cap += dep * share + tot_cap = cap.sum(axis=1) + newly = (~hit) & (tot_cap >= TARGET) + hit_day[newly] = t + hit |= newly + yrs = np.where(hit, hit_day / 365.0, np.nan) + fin = yrs[~np.isnan(yrs)] + return dict(p_reach=float(hit.mean()), + median_yrs=float(np.median(fin)) if len(fin) > 50 else float("nan"), + p_any_fail=float((~alive.all(axis=1)).mean()), + p_all_dead=float((~alive.any(axis=1)).mean()), + median_end=float(np.median(cap.sum(axis=1)))) + + +def main() -> None: + print("=" * 100) + print(" r0726 — \"TUTTO SU DERIBIT?\" — il rischio di venue, mai prezzato") + print("=" * 100) + print(f" accumulo da $600, EUR {DEP_EUR:.0f}/mese, orizzonte {YEARS} anni, " + f"bersaglio ${TARGET:,.0f}, de-luck ×{DELUCK}") + + S = venue_series() + for k, v in S.items(): + print(f" {k:<12} {len(v):>5} barre {v.index[0].date()} -> {v.index[-1].date()} " + f"drift {v.mean()*365.25*100:5.1f}%/a vol {v.std()*np.sqrt(365.25)*100:5.1f}%") + + CONC = {"Deribit": 1.0} + SPLIT = {"Deribit": 0.65, "Hyperliquid": 0.15, "IB": 0.20} + + print("\n" + "-" * 100) + print(" P(raggiungere il bersaglio entro 20 anni), al variare della probabilita' ANNUA") + print(" che un venue sparisca col saldo dentro") + print("-" * 100) + print(f" {'p annua':<12}{'CONC (100% Deribit)':>24}{'SPLIT (65/15/20)':>22}" + f"{'differenza':>14}") + res = {} + for p in P_GRID: + a = simulate(CONC, S, p) + b = simulate(SPLIT, S, p) + res[p] = (a, b) + print(f" {p:<12.1%}{a['p_reach']:>23.0%} {b['p_reach']:>21.0%}" + f"{b['p_reach']-a['p_reach']:>13.0%}") + + print("\n" + "-" * 100) + print(" Dettaglio: quanto spesso si viene colpiti, e cosa resta") + print("-" * 100) + print(f" {'p annua':<10}{'':<6}{'P(almeno 1 fallimento)':>24}" + f"{'P(perso TUTTO)':>18}{'capitale mediano a 20a':>26}") + for p in P_GRID: + for lab, r in (("CONC ", res[p][0]), ("SPLIT", res[p][1])): + print(f" {p:<10.1%}{lab:<6}{r['p_any_fail']:>23.0%}" + f"{r['p_all_dead']:>17.0%}{'$'+format(r['median_end'], ',.0f'):>26}") + print() + + print("=" * 100) + print(" LETTURA") + print("=" * 100) + print(" * A p=0 lo SPLIT e' gia' diverso dal CONC per composizione (Sharpe/drift diversi),") + print(" non per il rischio di venue: quella riga e' il termine di paragone.") + print(" * La differenza CONC-vs-SPLIT CRESCE con p: e' il prezzo della concentrazione.") + print(" * 'P(perso TUTTO)' e' la riga che conta per una RENDITA: nel CONC coincide con") + print(" 'almeno 1 fallimento' — non c'e' nulla dietro. Nello SPLIT serve che spariscano") + print(" tre venue indipendenti.") + print(" * Confronto CONSERVATIVO contro lo split: bersaglio uguale per entrambi, mentre") + print(" il book diversificato ha Sharpe piu' alto -> muro piu' basso -> arriverebbe prima.") + + +if __name__ == "__main__": + main() diff --git a/tests/test_venue_risk.py b/tests/test_venue_risk.py new file mode 100644 index 0000000..ecf1b64 --- /dev/null +++ b/tests/test_venue_risk.py @@ -0,0 +1,123 @@ +"""Test per r0726_venue_risk.py e per le aggiunte a r0726_capwall_refresh.py. + +Due invarianti che, se rotte, darebbero numeri plausibili e sbagliati: + * l'hazard GIORNALIERO deve comporre esattamente alla probabilita' ANNUA dichiarata — + un errore qui sposta l'intera tabella di sensibilita' senza che nulla sembri storto; + * il contatore dei versamenti deve seguire il capitale (bug catturato in sessione: `paid` + si congelava al traguardo mentre `cap` continuava a ricevere i depositi -> rapporto + capitale/versato gonfiato del 70-80% a 25-30 anni). +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "scripts" / "research")) +sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt")) + + +def _flat(vals, start="2020-01-01"): + return pd.Series(np.asarray(vals, float), + index=pd.date_range(start, periods=len(vals), freq="1D", tz="UTC")) + + +# =========================================================================== +# hazard: giornaliero -> annuo +# =========================================================================== +@pytest.mark.parametrize("p", [0.005, 0.01, 0.02, 0.05, 0.2]) +def test_hazard_giornaliero_compone_alla_probabilita_annua(p): + """(1-(1-p)^(1/365))^ -> su 365 giorni la sopravvivenza deve tornare (1-p).""" + haz = 1.0 - (1.0 - p) ** (1.0 / 365.0) + assert (1.0 - haz) ** 365 == pytest.approx(1.0 - p, rel=1e-9) + + +def test_p_zero_non_uccide_nessun_venue(): + import r0726_venue_risk as VR + + S = {"A": _flat([0.001] * 400), "B": _flat([0.001] * 400)} + r = VR.simulate({"A": 0.5, "B": 0.5}, S, 0.0, n_paths=200, years=1) + assert r["p_any_fail"] == 0.0 + assert r["p_all_dead"] == 0.0 + + +def test_p_certo_uccide_tutto_e_azzera_il_capitale(): + """Controllo POSITIVO: se il gate non mordesse mai, il test a p=0 passerebbe comunque.""" + import r0726_venue_risk as VR + + S = {"A": _flat([0.001] * 400), "B": _flat([0.001] * 400)} + r = VR.simulate({"A": 0.5, "B": 0.5}, S, 1.0, n_paths=200, years=1) + assert r["p_all_dead"] == 1.0 + assert r["median_end"] == 0.0 + + +def test_concentrato_perde_tutto_piu_spesso_del_diversificato(): + """L'asimmetria che motiva l'intero studio: a parita' di p, 'perso TUTTO' e' molto piu' + probabile con un solo venue, anche se 'almeno un fallimento' e' piu' probabile con tre.""" + import r0726_venue_risk as VR + + S = {"A": _flat([0.0005] * 800), "B": _flat([0.0005] * 800), "C": _flat([0.0005] * 800)} + conc = VR.simulate({"A": 1.0}, S, 0.05, n_paths=800, years=10) + split = VR.simulate({"A": 0.34, "B": 0.33, "C": 0.33}, S, 0.05, n_paths=800, years=10) + assert conc["p_all_dead"] > split["p_all_dead"] + assert split["p_any_fail"] > conc["p_any_fail"] + + +def test_venue_series_applica_il_deluck_sul_drift(): + import r0726_venue_risk as VR + + S = VR.venue_series() + assert set(S) == {"Deribit", "Hyperliquid", "IB"} + for v in S.values(): + assert isinstance(v.index, pd.DatetimeIndex) and len(v) > 100 + + +# =========================================================================== +# capwall: il contatore dei versamenti +# =========================================================================== +def test_focus_versato_segue_il_capitale(): + """`paid` deve valere 600 + dep*n_versamenti, deterministico e indipendente dal path. + Se si congelasse al traguardo (il bug), i path che arrivano presto mostrerebbero un + versato piu' basso e il rapporto capitale/versato uscirebbe gonfiato.""" + import r0725_capcurve as CC + import r0726_capwall_refresh as CW + + r = np.full(2000, 0.0004) + F = CW.focus(r, cap_needed=1e12, dep_eur=250.0, n_paths=50, years=5, seed=1) + for y, paid in F["snap_paid"].items(): + n_dep = (y * 365) // 30 + atteso = 600.0 + 250.0 * CC.EURUSD * n_dep + assert np.allclose(paid, atteso, rtol=0, atol=1e-6), f"anno {y}: {paid[0]} != {atteso}" + + +def test_focus_bersaglio_irraggiungibile_non_produce_traguardi(): + import r0726_capwall_refresh as CW + + F = CW.focus(np.full(2000, 0.0001), cap_needed=1e15, dep_eur=100.0, + n_paths=50, years=3, seed=2) + assert len(F["fin"]) == 0 + assert len(F["paid_at_hit"]) == 0 + + +def test_solve_deposit_e_monotono_nella_confidenza(): + """Piu' certezza -> piu' soldi. Se la ricerca binaria riusasse campioni diversi a ogni + passo, la funzione non sarebbe monotona e questo test lo direbbe.""" + import r0726_capwall_refresh as CW + + r = np.random.default_rng(3).normal(0.0006, 0.006, 1500) + d50, _ = CW.solve_deposit(r, 100_000.0, 10, 0.50, n_paths=400, seed=5) + d90, _ = CW.solve_deposit(r, 100_000.0, 10, 0.90, n_paths=400, seed=5) + assert d90 >= d50 + + +def test_solve_deposit_ritorna_nan_se_irraggiungibile(): + import r0726_capwall_refresh as CW + + r = np.full(1500, -0.01) # book che perde l'1% al giorno + dep, versato = CW.solve_deposit(r, 1e12, 5, 0.5, n_paths=200, seed=6) + assert np.isnan(dep) and np.isnan(versato)