#!/usr/bin/env python """r0727_lumpsum_split.py — il capitale che e' GIA' fermo, e cosa fa alla decisione di venue. LA DOMANDA. Sul conto che gira ci sono **$600**. Fermi altrove ce ne sono ~**€10.000** (Revolut: €6.043 in XEON, che rende ~0% reale netto dopo inflazione e tasse, piu' VWCE/SPYM/PPFB) e €329 su Degiro. Tutte le traiettorie pubblicate (25/07 e 26/07) partono da `START = 600.0` **cablato**: il progetto ha misurato con cura il CALENDARIO dei versamenti (fattore 6) e non ha mai misurato un **versamento iniziale**. PERCHE' LE DUE DOMANDE SI MISURANO INSIEME. Un lump-sum e' il front-loading estremo, e il 26/07 aveva gia' misurato che il front-loading *mette piu' capitale sull'exchange prima* — cioe' proprio dove vive il rischio di venue. E la decisione del 26/07 ("100% Deribit fino a $20k") e' stata presa con **$600 sul conto, dove lo split e' impossibile per costruzione**. CLAUDE.md registra l'impegno esplicito: *"Se cambia il piano (orizzonte, importo dei versamenti) si riapre PRIMA."* Un lump-sum da €5-10k e' esattamente quel cambiamento. Misurare il lump-sum senza il rischio di venue risponderebbe alla meta' comoda della domanda. COSA NON DECIDE QUESTO SCRIPT. Quanto di quei €6.043 sia un vero fondo d'emergenza e quanto capitale in attesa: e' una decisione dell'operatore, e un fondo d'emergenza NON e' capitale disponibile. Qui si misura **cosa compra ogni euro che entra**, per ogni taglia, cosi' che la decisione si prenda sul prezzo giusto. ASSUNZIONI DICHIARATE * Il costo-opportunita' del lump-sum e' ~0% reale (XEON: ~2% lordo, ~0 netto dopo inflazione e 26%). Tutti i numeri sono NOMINALI in USD, come ovunque nel progetto: il confronto e' quindi fra "fermo a ~0 reale" e "dentro il book", non fra due investimenti rischiosi. * Book = quello LIVE (TP01+SKH01 su Deribit), de-luckato ×0.89 (fattore MISURATO, r0726_deluck_factor). E' lo stesso book di ogni traiettoria pubblicata -> i numeri sono confrontabili con la tabella dei versamenti del 26/07. * Bersaglio = $272.061, il muro del 26/07 (rendita perpetua a €50/g netti). Identico per CONC e SPLIT: e' CONSERVATIVO contro lo split, che avrebbe un muro leggermente diverso. * `p` (probabilita' annua di fallimento di un venue) NON e' stimata: e' una sensibilita'. La sceglie l'operatore. uv run python scripts/research/r0727_lumpsum_split.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")) import r0725_capcurve as CC # noqa: E402 import r0726_capwall_refresh as WR # noqa: E402 import r0726_venue_risk as VR # noqa: E402 from src.portfolio.gtaa import GTAA_MIN_CAPITAL # noqa: E402 DELUCK = 0.89 START = 600.0 # il conto Deribit di oggi YEARS = 20 N_PATHS = 4000 BLOCK = 20 SEED = 726 # lo stesso di r0726_venue_risk: consente la replica esatta TARGET = VR.TARGET # $272.061 LUMPS_EUR = (0.0, 2_000.0, 5_000.0, 10_000.0) P_GRID = (0.0, 0.005, 0.01, 0.02, 0.05) # Split: la quota su IB parte dal 25% raccomandato da r0725_ib10k (oltre, la rendita cala: # 0% -> €2.16/g, 50% -> €1.54/g su $11.5k). Ma la quota MINIMA e' imposta dall'eseguibilita' # della gamba equity: sotto GTAA_MIN_CAPITAL non c'e' uno sleeve, c'e' cash su un secondo conto. # Si accetta di salire fino a IB_SHARE_MAX pur di rendere lo split possibile; oltre, no. CONC = {"Deribit": 1.0} IB_SHARE = 0.25 IB_SHARE_MAX = 0.35 # Haircut sulla gamba IB nella variante realistica: costi a taglia piccola (CAGR 3.8% -> 3.0% a # $3k, misurato il 25/07) + drag del veicolo UCITS (+0.10%/anno, misurato il 26/07). IB_HAIRCUT_YR = 0.008 + 0.001 def simulate(cfg: dict, series: dict, p_annual: float, lump_usd: float = 0.0, dep_eur: float = 250.0, n_paths: int = N_PATHS, years: int = YEARS, seed: int = SEED) -> dict: """Accumulo con capitale iniziale `START + lump_usd` e jump di venue. Generalizza `r0726_venue_risk.simulate` sul solo capitale iniziale e sul deposito: la struttura (ordine di consumo dell'RNG compreso) e' identica, cosi' che con lump=0 e dep=250 i numeri debbano coincidere ESATTAMENTE con quelli pubblicati il 26/07. """ 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) idx = CC._boot_paths(np.arange(len(R), dtype=float), n_paths, n_days, BLOCK, 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, START + lump_usd), w0) 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 and dep > 0: wa = alive * w0 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_all_dead=float((~alive.any(axis=1)).mean()), median_end=float(np.median(cap.sum(axis=1))), paid=START + lump_usd + dep * (n_days // 30)) def haircut(s: pd.Series, per_year: float) -> pd.Series: """Abbassa il drift di una serie giornaliera di `per_year` all'anno (costi non modellati).""" return s - per_year / 365.0 def split_config(total_usd: float) -> dict | None: """Configurazione di split possibile a quel capitale, o None. La quota IB non e' libera: dal basso la vincola l'ESEGUIBILITA' (sotto GTAA_MIN_CAPITAL la gamba equity non esiste, e lo 'split' sarebbe cash fermo su un secondo conto), dall'alto la vincola la RENDITA (r0725_ib10k: oltre il 25% si paga, oltre il 50% si dimezza). Fra i due vincoli si prende la quota minima che rende la gamba eseguibile — mai meno del 25% raccomandato, mai piu' di IB_SHARE_MAX. """ need = GTAA_MIN_CAPITAL / total_usd if total_usd > 0 else float("inf") share = max(IB_SHARE, need) if share > IB_SHARE_MAX: return None return {"Deribit": 1.0 - share, "IB": share} def main() -> None: print("=" * 104) print(" r0727 — IL CAPITALE GIA' FERMO: cosa compra un versamento iniziale, e cosa fa al venue") print("=" * 104) series = VR.venue_series() r_deribit = series["Deribit"].values.astype(float) gross = CC.TARGET_EUR_DAY * 365 * CC.EURUSD / (1 - CC.TAX_RATE) _, perp, wall = WR.perp_and_wall(r_deribit, 1.0, gross) print(f" book live de-luckato ×{DELUCK} | rendita perpetua {perp:.2%} | bersaglio ${TARGET:,.0f}") print(f" {N_PATHS} path, blocchi {BLOCK}g, orizzonte {YEARS}a, conto di partenza ${START:.0f}, " f"EURUSD {CC.EURUSD}") # ---------------------------------------------------------------- validazione print("\n" + "-" * 104) print(" VALIDAZIONE — con lump 0 e €250/mese si devono riprodurre i numeri del 26/07") print("-" * 104) for p in (0.0, 0.01): mine = simulate(CONC, series, p, lump_usd=0.0, dep_eur=250.0) theirs = VR.simulate({"Deribit": 1.0}, series, p, n_paths=N_PATHS, years=YEARS, seed=SEED) ok = (abs(mine["p_reach"] - theirs["p_reach"]) < 1e-12 and abs(mine["p_all_dead"] - theirs["p_all_dead"]) < 1e-12) print(f" p={p:.1%} P(arrivare) {mine['p_reach']:.4f} vs {theirs['p_reach']:.4f} · " f"P(perso tutto) {mine['p_all_dead']:.4f} vs {theirs['p_all_dead']:.4f} " f"-> {'IDENTICO' if ok else 'DIVERGE (la generalizzazione ha cambiato qualcosa)'}") # ---------------------------------------------------------------- (1) cosa compra il lump print("\n" + "=" * 104) print(" (1) COSA COMPRA UN VERSAMENTO INIZIALE — senza rischio di venue (p=0), 100% Deribit") print("=" * 104) print(f"\n {'lump':>8}{'dep/mese':>10}{'tot versato':>14}{'anni al muro':>14}" f"{'P(entro 20a)':>14}{'cap. mediano':>15}{'rendita a 20a':>16}") grid = {} for dep in (0.0, 250.0): for L in LUMPS_EUR: r = simulate(CONC, series, 0.0, lump_usd=L * CC.EURUSD, dep_eur=dep) grid[(L, dep)] = r rent = r["median_end"] * perp * (1 - CC.TAX_RATE) / 365.0 / CC.EURUSD yrs = f"{r['median_yrs']:.1f}a" if not np.isnan(r["median_yrs"]) else "mai" print(f" €{L:>7,.0f}{dep:>10,.0f}{r['paid']:>13,.0f}${yrs:>14}" f"{r['p_reach']:>13.1%}{r['median_end']:>14,.0f}${rent:>13.2f} €/g") # equivalenza: a quale versamento mensile equivale il lump, a parita' di traguardo mediano. # NB: NON si confronta il lump col totale dei versamenti risparmiati — sarebbe la domanda # sbagliata (il valore e' arrivare prima, non versare meno), e darebbe un rapporto <1 che # invita alla conclusione opposta. print("\n EQUIVALENZA — a quanti €/mese IN PIU', per sempre, equivale il lump-sum?") base = grid[(0.0, 250.0)]["median_yrs"] for L in LUMPS_EUR[1:]: got = grid[(L, 250.0)]["median_yrs"] lo, hi = 250.0, 3_000.0 for _ in range(12): # bisezione sul deposito a pari traguardo mid = 0.5 * (lo + hi) y = simulate(CONC, series, 0.0, lump_usd=0.0, dep_eur=mid)["median_yrs"] if np.isnan(y) or y > got: lo = mid else: hi = mid eq = 0.5 * (lo + hi) print(f" €{L:>6,.0f} oggi -> traguardo {got:.1f}a (invece di {base:.1f}a) " f"= come versare €{eq:,.0f}/mese invece di €250, cioe' €{eq - 250:,.0f}/mese in piu' " f"per {got:.0f} anni (€{(eq - 250) * 12 * got:,.0f})") # ---------------------------------------------------------------- (2) venue print("\n" + "=" * 104) print(" (2) LO STESSO LUMP, COL RISCHIO DI VENUE DENTRO — concentrato vs split") print(f" SPLIT = Deribit / IB, quota IB scelta dal CAPITALE (minimo eseguibile " f"${GTAA_MIN_CAPITAL:,.0f}, tetto {IB_SHARE_MAX:.0%})") print("=" * 104) ib_real = {**series, "IB": haircut(series["IB"], IB_HAIRCUT_YR)} for L in LUMPS_EUR: tot = START + L * CC.EURUSD cfg = split_config(tot) if cfg: why = (f"POSSIBILE a quota IB {cfg['IB']:.0%} (${tot * cfg['IB']:,.0f} sulla gamba equity; " f"il minimo eseguibile e' ${GTAA_MIN_CAPITAL:,.0f})") else: why = (f"IMPOSSIBILE: servirebbe una quota IB del {GTAA_MIN_CAPITAL / tot:.0%} " f"per fare ${GTAA_MIN_CAPITAL:,.0f}, oltre il tetto {IB_SHARE_MAX:.0%}") print(f"\n lump €{L:,.0f} -> capitale iniziale ${tot:,.0f} · split {why}") print(f" {'p annua':>9}{'P(arrivare) CONC':>19}{'SPLIT':>9}{'SPLIT+costi':>13}" f"{'P(perso tutto) CONC':>22}{'SPLIT':>9}") for p in P_GRID: c = simulate(CONC, series, p, lump_usd=L * CC.EURUSD) if cfg: s = simulate(cfg, series, p, lump_usd=L * CC.EURUSD) sr = simulate(cfg, ib_real, p, lump_usd=L * CC.EURUSD) print(f" {p:>8.1%}{c['p_reach']:>18.1%}{s['p_reach']:>9.1%}{sr['p_reach']:>13.1%}" f"{c['p_all_dead']:>21.1%}{s['p_all_dead']:>9.1%}") else: print(f" {p:>8.1%}{c['p_reach']:>18.1%}{'-':>9}{'-':>13}" f"{c['p_all_dead']:>21.1%}{'-':>9}") # --- lo split che si puo' fare OGGI: la gamba equity e' bloccata dal PRIIPs ------------- print("\n" + "-" * 104) print(" (2-bis) E SE LA SECONDA GAMBA FOSSE SOLO LIQUIDITA'?") print(" GTAA01 oggi NON e' deployabile (blocco PRIIPs sugli ETF USA; la via UCITS su Degiro") print(" e' verificata ma in preparazione). Lo split disponibile SUBITO e' Deribit + un conto") print(" fermo (XEON o simile, ~0% reale). Quanta protezione viene dalla DIVERSIFICAZIONE DI") print(" VENUE e quanta dai ritorni della gamba equity?") print("-" * 104) L = 10_000.0 tot = START + L * CC.EURUSD cfg = split_config(tot) zero = {**series, "IB": series["IB"] * 0.0} # stessa griglia temporale, drift nullo print(f"\n lump €{L:,.0f} (${tot:,.0f}), quota seconda gamba {cfg['IB']:.0%}") print(f" {'p annua':>9}{'P(arrivare) CONC':>19}{'SPLIT-GTAA':>13}{'SPLIT-CASSA':>13}" f"{'P(perso tutto) CONC':>22}{'SPLIT':>9}") for p in P_GRID: c = simulate(CONC, series, p, lump_usd=L * CC.EURUSD) g = simulate(cfg, series, p, lump_usd=L * CC.EURUSD) z = simulate(cfg, zero, p, lump_usd=L * CC.EURUSD) print(f" {p:>8.1%}{c['p_reach']:>18.1%}{g['p_reach']:>13.1%}{z['p_reach']:>13.1%}" f"{c['p_all_dead']:>21.1%}{g['p_all_dead']:>9.1%}") print("\n (P(perso tutto) e' identica fra SPLIT-GTAA e SPLIT-CASSA: dipende da QUANTI conti") print(" falliscono, non da cosa ci sta sopra. La differenza fra le due colonne di") print(" P(arrivare) e' il prezzo di tenere ferma la seconda gamba invece di investirla.)") print("\n ⚠️ La colonna P(perso tutto) sotto CONC non dipende dal lump: con UN conto solo,") print(" 'almeno un fallimento' COINCIDE con 'perso tutto', e quella probabilita' e' una") print(" proprieta' del tempo di esposizione, non del capitale. Il lump non la peggiora —") print(" ma moltiplica cio' che quella probabilita' porta via.") # ---------------------------------------------------------------- (3) la soglia print("\n" + "=" * 104) print(" (3) DA QUALE CAPITALE LO SPLIT E' POSSIBILE SENZA DISTORCERE I PESI") print("=" * 104) ideale = GTAA_MIN_CAPITAL / IB_SHARE minimo = GTAA_MIN_CAPITAL / IB_SHARE_MAX tot10 = START + 10_000 * CC.EURUSD print(f"\n soglia di eseguibilita' della gamba equity (GTAA_MIN_CAPITAL): ${GTAA_MIN_CAPITAL:,.0f}") print(f" quota IB raccomandata da r0725_ib10k (oltre, cala la rendita): {IB_SHARE:.0%}") print(f" -> split a quota RACCOMANDATA possibile da : ${ideale:,.0f}") print(f" -> split possibile forzando la quota fino al {IB_SHARE_MAX:.0%} da : ${minimo:,.0f}") print(f"\n Con un lump da €10.000 il conto sarebbe ${tot10:,.0f}: " f"{'SOPRA' if tot10 >= ideale else 'SOTTO'} la soglia a quota") print(f" raccomandata (${ideale:,.0f}) e {'sopra' if tot10 >= minimo else 'sotto'} " f"quella a quota forzata (${minimo:,.0f}) -> lo split e' possibile, ma a") print(f" quota IB {max(IB_SHARE, GTAA_MIN_CAPITAL / tot10):.0%}, non {IB_SHARE:.0%}: " "e' il capitale a scegliere la quota, non la preferenza.") print(f"\n La riapertura della decisione di venue e' fissata a ${20_000:,.0f}. Un lump da") print(f" €10k porta il conto a ${tot10:,.0f} — sotto quella data ma sopra la fattibilita'") print(" tecnica, ed e' un cambiamento del piano: CLAUDE.md dice di riaprire PRIMA in") print(" questo caso, e questa tabella e' il materiale per farlo.") if __name__ == "__main__": main()