diff --git a/docs/diary/2026-07-03-book-frontier.md b/docs/diary/2026-07-03-book-frontier.md new file mode 100644 index 0000000..a1fba3f --- /dev/null +++ b/docs/diary/2026-07-03-book-frontier.md @@ -0,0 +1,111 @@ +# 2026-07-03 — Frontiera rischio/rendimento del book: quanto caldo correre a 2k-5k + +**Contesto.** Dopo la diagnosi strategica ("€50/g è un problema di capitale e di frontiera, non di +alpha; hai costruito una macchina difensiva a DD 6% ma hai capitale piccolo e obiettivo di +crescita"), goal: **misurare** (non cercare segnali) la frontiera CAGR/DD/P-rovina in funzione del +target-vol, pesata per confidenza per-sleeve, a €2k e €5k — per decidere quanto caldo girare il book. +Workflow: 3 filoni (curva / onestà-della-coda / eseguibile+Kelly), refuter fat-tail su ciascuno, +scettico sintetizzatore. 7 agenti, 0 errori. Sanity bit-exact di entrambi i book prima di ogni numero. + +**Scripts:** `scripts/research/r0703_frontier_{curve,tail,exec}.py`. Nessun file di produzione +toccato, nessun commit dagli agenti, `config/live.json` NON modificato (cap solo proposto). + +--- + +## Verdetto in una riga + +**La tesi "corri più caldo" era in gran parte un MIRAGGIO DI REGIME.** Il "~6% attuale" non è un +target scelto: è la vol *realizzata* in regime risk-off (TP01 spesso flat 0.0x → il conto è flat +*by design*). A lambda=1 il book LIVE a 2 sleeve gira **già a ~11% vol / 9.4% DD**. Quindi "saltare +da 6% a 10%" NON è aggiungere leva — è solo assumere che il book non sia risk-off. **Il numero +onesto è: gira alla vol NATIVA (~10%, banda 8-11%), non levare.** Guadagno reale vs il 6% risk-off: +**+€0.2/g @2k, +€0.5/g @5k** — reale ma modesto, comprato con un DD plausibile di ~€500 su €2k. €50/g +resta ~130k, invariato. + +--- + +## I numeri (frontiera fidata, non full-Sharpe) + +Sanity (deriva nulla): 5-sleeve FULL Sh **2.238** / HOLD 2.452 / DD 6.17% / vol nativa 7.84%; +2-sleeve live FULL Sh **1.776** / HOLD 1.162 / DD 9.42% / vol nativa 10.96%. + +**Scale-invarianza confermata da tutti e 3 i refuter:** il capitale NON cambia la matematica del +target-vol (le tabelle sono in %), cambia solo l'ESEGUIBILITÀ → **stesso % a 2k e 5k.** + +### Book LIVE 2-sleeve (TP01+SKH01) — l'unico eseguibile <20k +- **Target-vol raccomandato: ~10%, banda [8%, 11%], lambda ≈ 0.9.** Sopra la vol nativa non si va. +- €/giorno al punto fidato (haircut): CAGR full-sample 12.9% → €0.71@2k / €1.77@5k; ma **HOLD-onesto** + (de-luck) CAGR ~4.5% → **€0.25@2k / €0.62@5k**. Il lato-reward headline è ~3× l'onesto (HOLD≪FULL). +- **Stima centrale onesta: €0.3-0.7/g @2k, €0.8-1.8/g @5k.** + +### Book PIENO 5-sleeve — ASPIRAZIONALE, non gira <20k +Frontiera fidata regge ~12-15% sotto tail estremo, ~20% sotto tail mite — ma XS01 serve ~20k e VRP01 +è modellato → rilevante solo per il tracking di `paper_combo`, non per una decisione di deploy a 2-5k. + +--- + +## I quattro insegnamenti strutturali + +### 1. La rovina-50% è la metrica SBAGLIATA (trappola esposta dal curve-refuter) +P(rovina-50%|5y) resta <1% fino a ~20-22% di target-vol su ogni book — i book sono troppo lisci/ +diversificati per bruciare metà capitale entro la distribuzione campionaria. **La risposta letterale +"max CAGR a P(rovina)<1%" direbbe "gira al 22%" — ed è un suicidio**: lì P(DD>30%|5y) ≈ 20-25%. Il +vincolo che MORDE è **P(DD>30%), super-lineare** (raddoppia ~ogni 2.5 punti di vol). E la rovina-50% +non è nemmeno robusta ad alta vol (block-15 vs block-20 danno 7.1% vs 0.9% a tv30%) → altro motivo +per stare bassi. + +### 2. Il muro fat-tail ONESTO è ~12%, più basso della carta (~15%) +Tre ragioni che il modello sottostima: **(a) gap-through-stop SKH già osservato reale** (sl2% +modellato → −11/−23% realizzato — la coda vera NON è nei rendimenti modellati; l'iniezione dei +finder usava lo *clean-stop* → ~2× troppo mite); **(b) il 2-sleeve è 4× più crash-sensibile del +5-sleeve** (+14.5pt vs +3.5pt di P(DD>30) all'iniezione, diversificazione sottile); **(c) +vol-targeting a lambda costante lagga nello spike** → sei più levato *entrando* nel crash. +**LIVE 2-sleeve: non superare ~12%; mai 15-20%** (triplica il DD-in-euro senza margine di coda). + +### 3. Aggressività differenziata-per-confidenza (TP01 più caldo di VRP) — REFUTATA +La mia proposta era intuitiva ma **sbagliata, e i numeri lo dicono netto**: differenziare la vol per +confidenza PEGGIORA la frontiera (5s Sharpe 2.24→2.11, 2s 1.78→1.69) perché **taglia la +diversificazione** di VRP/XS (corr uniforme-vs-differenziato 0.98-0.99). **Regola corretta: la bassa +fiducia sull'EDGE si esprime DE-MEANando lo sleeve in aspettativa (= la frontiera haircut), NON +riducendone l'esposizione/vol.** La confidenza vive nell'haircut della media, non nella leva. Tutti +gli sleeve alla stessa leva. + +### 4. La diversificazione LIMA la coda sinistra in-sample (sorpresa onesta) +Per il 5-sleeve ben diversificato il block-bootstrap dà P(DD>30) SOTTO il Gaussiano iso-vol (30%: +10.6% reale vs 21.1% gauss) — la coda in-sample è benigna. Il segnale fat-tail vive in due posti: +l'**iniezione di crash sintetico** (il campione non contiene il crash peggiore di ogni sleeve) e +il **book live sottile** (2 gambe, drift haircut) dove il bootstrap SUPERA il Gaussiano. Cioè: la +frontiera è affidabile finché il futuro somiglia al passato campionato, e fragile esattamente dove +le storie sono corte (VRP mai stressato reale, XS 2.5y, nessun 2018/pre-2018). + +--- + +## Azione concreta (proposta, NON applicata) + +1. **Nessun cambio di leva sul codice.** Il book gira già ≈ al punto fidato: LASCIARE `target_vol=20%` + di TP01 (canonico, la vol nativa del book emerge a ~8-11%). Non c'è una leva da girare su. +2. **⚠️ L'UNICO cambio che conta: `config/live.json` `max_notional_per_asset_usd` da $300 a + equity/2** ($1000@2k / $1750@3.5k / $2500@5k). Senza, il cap $300 tappa la vol raggiungibile a + 13%@2k / 8%@3.5k / **6%@5k** → **throttling paradossale: più capitale = book più freddo**, forzato + sotto il punto fidato. Da applicare AL deposito. (config NON toccato ora.) +3. `min_order_usd=5`: LASCIARE (banda d'isteresi gratuita). Tranching K=2: NON cablare. Vol + differenziata: NON implementare (Q3). + +--- + +## Brutalità sui limiti + +- **Storie corte = il difetto strutturale.** TP01/SKH dal 2019 (no 2018), VRP dal 2021, XS **2.5 + anni** (no 2022). Il block-bootstrap non può campionare un crash mai visto; l'iniezione sintetica + è l'unico proxy, e quella dei finder era ~2× troppo mite. +- **VRP01 è MODELLATO** (f-di-stress mai catturato su un crash reale) → l'haircut −50% è stima, non + misura; resta STAT-MODE anche a 5k (regola: niente short-vol da modello in deploy). +- **HOLD ≪ FULL sul book live** (1.78 vs 1.16) → il lato-reward headline è in gran parte artefatto + full-sample; citare i €/g su base HOLD-onesta. +- **€50/g NON è risolto e non lo sarà da questo.** Il warm-up compra ~€0.2/g@2k. La via resta + **capitale + tempo**; la regola "niente leva alta come base" è rispettata (la raccomandazione è + "gira alla vol nativa", non "corri caldo"). + +**Numero unico da portare a casa:** book LIVE a **~10% target-vol (lambda ≈ 0.9, banda 8-11%)**, +uguale a 2k e 5k, con **cap → equity/2** come unica azione config; muro invalicabile a **~12%**; +guadagno reale vs il 6% risk-off ~**+€0.2/g@2k / +€0.5/g@5k** — reale ma modesto. diff --git a/scripts/research/r0703_frontier_curve.py b/scripts/research/r0703_frontier_curve.py new file mode 100644 index 0000000..e6958f5 --- /dev/null +++ b/scripts/research/r0703_frontier_curve.py @@ -0,0 +1,352 @@ +"""FILONE 1 — LA CURVA FRONTIERA (r0703_frontier_curve.py) + +MISURA (non ricerca segnali) della frontiera rischio/rendimento dei DUE book che GIA' esistono: + - book RESEARCH 5-sleeve: TP01 33 / XS01 15 / VRP01 12 / SKH01 20 / GTAA01 20 (combine_outer) + - book LIVE 2-sleeve: TP01 75 / SKH01 25 (cio' che gira su soldi reali su Deribit) + +Per decidere a quale TARGET-VOL conviene girare il book a capitale piccolo (2k / 5k). + +LEVA STUDIATA = moltiplicatore lambda sui rendimenti giornalieri del book combinato: + r_scaled(t) = clip(lambda * r_book(t), -1, None) (clip: non si perde >100% in un giorno) +Sweep di lambda per coprire target-vol annuo ~5% -> ~30%. Sharpe INVARIANTE per costruzione +(lo verifichiamo: cambia solo con l'attivazione del clip a leva alta). + +CONFIDENZA PER-SLEEVE (anti-alllucinazione #2): 3 livelli. La de-luck e l'haircut sono un +HAIRCUT SULLA MEDIA (edge), applicato DE-MEANando lo sleeve: s' = s - h*mean(s). +=> std invariata (stessa vol/coda), drift ridotto. Quindi tra i 3 livelli cambia SOLO il drift + (CAGR / P-rovina via minor deriva), NON la forma della coda -> il messaggio e' la BANDA. + (a) FULL : book as-is. + (b) DE-LUCK : rimuove l'anchor timing-luck (audit 4/4): + SKH01 mean x0.60 (canonica = 93-98 pctl di 23 offset, ~+0.5 HOLD di fortuna), + TP01 mean x0.85 (hold-out 0.31 = migliore di 24 ancore, mediana 0.04). + VRP01/XS01 NON haircuttati qui: le loro ancore canoniche sono CONSERVATIVE + (VRP 7 pctl = peggiore; XS 15 pctl di DD). GTAA: nessuna ancora-luck. + (c) HAIRCUT : de-luck + taglio -50% della MEDIA degli sleeve a FIDUCIA BASSA sull'edge + (VRP01 premio MODELLATO, f di stress mai catturato; XS01 STAT-MODE, 2.5y). + = la "frontiera fidata". Il GAP full<->haircut e' il margine di onesta'. + Variante extra: EXCL = escludi del tutto VRP01+XS01 (ultra-conservativo, riferimento). + Per il book LIVE 2-sleeve (niente VRP/XS): haircut = de-luck + SKH01 mean a x0.50 dall'originale + (SKH e' la gamba a fiducia MEDIA: research, ETH DD margine sottile vs 30%). + +TAIL (anti-allucinazione #1): la coda NON scala lineare-Gaussiano. Quantificato con: + - BLOCK BOOTSTRAP (blocchi 10 e 20g, B=5000) sui rendimenti REALI del book (fat-tail+autocorr): + P(rovina-50%|5y) = P(equity tocca <=50% del capitale iniziale in un path di 5y) + P(DD>30%|5y) = P(max drawdown peak-to-trough > 30% in 5y) + - BENCHMARK GAUSSIANO iid (stessa media/vol) -> il GAP misura il contributo fat-tail/autocorr. + - INIEZIONE DI CODA SINTETICA: un giorno-crash = 1.5x il peggior giorno storico, ~1 volta per + path di 5y -> sensibilita' al "il campione puo' non contenere il crash peggiore". + +Nessuna selezione su hold-out: FULL e HOLD affiancati solo per PROVARE che la frontiera non e' +un artefatto in-sample. NON tocca src/config/live/tests: importa solo (sola lettura) i builder. + +Output CSV+report testuale in scratchpad. Girare: uv run python scripts/research/r0703_frontier_curve.py +""" +from __future__ import annotations +import sys, os, time +sys.path.insert(0, '/opt/docker/PythagorasGoal') +sys.path.insert(0, '/opt/docker/PythagorasGoal/scripts/research/alt') +import numpy as np +import pandas as pd + +from src.portfolio.sleeves import (_tp01_returns, _skyhook_returns, _xsec_returns, + _vrp_combo_returns, _gtaa_daily_returns) +from src.portfolio.portfolio import combine_outer, to_daily, HOLDOUT + +DPY = 365.25 +OUT = '/tmp/claude-1001/-opt-docker-PythagorasGoal/e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad' +os.makedirs(OUT, exist_ok=True) +RNG = np.random.default_rng(20260703) + +# ---------------------------------------------------------------- sleeves +def build_sleeves() -> dict[str, pd.Series]: + d = {} + for nm, fn in [("TP01", _tp01_returns), ("XS01", _xsec_returns), ("VRP01", _vrp_combo_returns), + ("SKH01", _skyhook_returns), ("GTAA01", _gtaa_daily_returns)]: + d[nm] = to_daily(fn()) + return d + + +def demean_haircut(s: pd.Series, h: float) -> pd.Series: + """s' = s - h*mean(s): riduce il drift del fattore (1-h), std INVARIATA.""" + if h == 0.0: + return s + return s - h * float(s.mean()) + + +# per-livello: fattore di haircut sulla MEDIA per sleeve (h) +LEVELS_5 = { + "FULL": dict(TP01=0.0, XS01=0.0, VRP01=0.0, SKH01=0.0, GTAA01=0.0), + "DELUCK": dict(TP01=0.15, XS01=0.0, VRP01=0.0, SKH01=0.40, GTAA01=0.0), + "HAIRCUT": dict(TP01=0.15, XS01=0.5, VRP01=0.5, SKH01=0.40, GTAA01=0.0), + "EXCL": dict(TP01=0.15, XS01=1.0, VRP01=1.0, SKH01=0.40, GTAA01=0.0), # escludi VRP+XS (mean->0) +} +W5 = {"TP01": 0.33, "XS01": 0.15, "VRP01": 0.12, "SKH01": 0.20, "GTAA01": 0.20} + +LEVELS_2 = { + "FULL": dict(TP01=0.0, SKH01=0.0), + "DELUCK": dict(TP01=0.15, SKH01=0.40), + "HAIRCUT": dict(TP01=0.15, SKH01=0.50), # SKH = gamba fiducia MEDIA -> taglio piu' profondo +} +W2 = {"TP01": 0.75, "SKH01": 0.25} + + +def make_book(sleeves: dict, weights: dict, hair: dict) -> pd.Series: + cols = {nm: demean_haircut(sleeves[nm], hair.get(nm, 0.0)) for nm in weights} + return combine_outer(cols, weights) + + +# ---------------------------------------------------------------- metrics (full sample, scaled) +def full_metrics(book: pd.Series, lam: float) -> dict: + r = np.clip(lam * book.values.astype(float), -1.0, None) + s = pd.Series(r, index=book.index) + eq = np.cumprod(1.0 + r) + pk = np.maximum.accumulate(eq) + yrs = len(r) / DPY + dd = float(np.max((pk - eq) / pk)) + cagr = float(eq[-1] ** (1 / yrs) - 1) if eq[-1] > 0 else -1.0 + shp = float(r.mean() / r.std() * np.sqrt(DPY)) if r.std() > 0 else 0.0 + ww = float(((1.0 + s).resample('168h').prod() - 1.0).min()) # worst-week (168h, non '7D') + vol = float(r.std() * np.sqrt(DPY)) + return dict(sharpe=shp, cagr=cagr, maxdd=dd, worstweek=ww, vol=vol) + + +# ---------------------------------------------------------------- block bootstrap engine +def block_index(n: int, L: int, npath: int, B: int) -> np.ndarray: + """Matrice (B, npath) di indici in [0,n) via block-bootstrap circolare-troncato (blocchi L).""" + nblk = int(np.ceil(npath / L)) + starts = RNG.integers(0, n - L + 1, size=(B, nblk)) # start di ogni blocco + offs = np.arange(L) + idx = (starts[:, :, None] + offs[None, None, :]).reshape(B, nblk * L)[:, :npath] + return idx + + +def ruin_dd(Rmat: np.ndarray, lam: float) -> tuple[float, float]: + """P(rovina-50%|5y), P(DD>30%|5y) da una matrice (B,npath) di rendimenti NATIVI, scalata per lam. + Versione veloce: la matrice bootstrap (o la coda iniettata, o il gauss iid) e' PRE-costruita una + volta per (book,level); qui si applica solo lo scaling di leva + cumprod. Numerica identica a + boot_probs/gauss_probs, senza ri-gather/ri-generazione per ogni target-vol.""" + R = np.clip(lam * Rmat, -1.0, None) + eq = np.cumprod(1.0 + R, axis=1) + ruin = float((eq.min(axis=1) <= 0.5).mean()) + pk = np.maximum.accumulate(eq, axis=1) + dd = ((pk - eq) / pk).max(axis=1) + return ruin, float((dd > 0.30).mean()) + + +def boot_probs(r_native: np.ndarray, lam: float, idx: np.ndarray, + inject_crash: float | None = None) -> tuple[float, float]: + """P(rovina-50%|5y), P(DD>30%|5y) su path bootstrap. + inject_crash: se non None, in OGNI path sostituisce 1 giorno casuale con questo rendimento + NATIVO (pre-scala) -> stress "una coda sintetica per 5 anni".""" + R = r_native[idx].copy() # (B, npath) nativi + if inject_crash is not None: + B, npath = R.shape + pos = RNG.integers(0, npath, size=B) + R[np.arange(B), pos] = inject_crash + R = np.clip(lam * R, -1.0, None) + eq = np.cumprod(1.0 + R, axis=1) + ruin = float((eq.min(axis=1) <= 0.5).mean()) # tocca 50% del capitale iniziale + pk = np.maximum.accumulate(eq, axis=1) + dd = ((pk - eq) / pk).max(axis=1) + dd30 = float((dd > 0.30).mean()) + return ruin, dd30 + + +def gauss_probs(mu: float, sd: float, npath: int, B: int) -> tuple[float, float]: + """Benchmark iid-Gaussiano con stessa media/std giornaliera (post-scala).""" + R = RNG.normal(mu, sd, size=(B, npath)) + R = np.clip(R, -1.0, None) + eq = np.cumprod(1.0 + R, axis=1) + ruin = float((eq.min(axis=1) <= 0.5).mean()) + pk = np.maximum.accumulate(eq, axis=1) + dd = ((pk - eq) / pk).max(axis=1) + return ruin, float((dd > 0.30).mean()) + + +# ---------------------------------------------------------------- main +def run(): + t0 = time.time() + print("building sleeves...", flush=True) + sl = build_sleeves() + + NPATH = int(round(5 * DPY)) # 5 anni ~ 1826 giorni-calendario + B = 5000 + TV_GRID = np.round(np.arange(0.05, 0.305, 0.01), 3) # 5%..30% target-vol (fine) + TV_FINE = np.round(np.arange(0.04, 0.305, 0.005), 4) # per la ricerca del punto-decisione + + books = { + "5SLEEVE": dict(weights=W5, levels=LEVELS_5), + "2LIVE": dict(weights=W2, levels=LEVELS_2), + } + + # ---- costruzione book per livello + sanity + banda di confidenza + print("\n" + "=" * 78) + print("SANITY + BANDA DI CONFIDENZA (FULL vs HOLD affiancati)") + print("=" * 78) + built = {} # (book,level) -> pd.Series (native, lam=1) + for bk, cfg in books.items(): + for lv, hair in cfg["levels"].items(): + b = make_book(sl, cfg["weights"], hair) + built[(bk, lv)] = b + # print sanity for this book, all levels + print(f"\n[{bk}] weights={cfg['weights']}") + print(f" {'level':9s} {'nat.vol':>8s} {'FULL Sh':>8s} {'HOLD Sh':>8s} " + f"{'FULL CAGR':>10s} {'HOLD CAGR':>10s} {'FULL DD':>8s}") + for lv in cfg["levels"]: + b = built[(bk, lv)] + mF = full_metrics(b, 1.0) + bh = b[b.index >= HOLDOUT] + mH = full_metrics(bh, 1.0) + print(f" {lv:9s} {mF['vol']*100:7.2f}% {mF['sharpe']:8.3f} {mH['sharpe']:8.3f} " + f"{mF['cagr']*100:9.2f}% {mH['cagr']*100:9.2f}% {mF['maxdd']*100:7.2f}%") + + # ---- anchor band (citata dagli audit; non ricalcolata qui) + print("\n" + "-" * 78) + print("BANDA D'ANCORA (dove il DE-LUCK e' ancorato — audit 4/4, diari 2026-07-02/03):") + print(" TP01 : hold-out 0.31 = MIGLIORE di 24 ancore orarie (mediana 0.04, banda [-0.13,+0.30],") + print(" P~0.86 che un'ancora mostri tale spike per caso) -> de-luck mean x0.85") + print(" SKH01: canonica = 93-98 pctl di 23 offset (griglia 230/690m), ~+0.5 HOLD di fortuna sul") + print(" book; gate DD<30% fallisce in 15/23 offset -> de-luck mean x0.60") + print(" XS01 : canonica = 15 pctl di DD (10 fasi) -> CONSERVATIVA sul DD, nessun de-luck edge") + print(" VRP01: canonica = PEGGIORE (7 pctl, 7 giorni) -> CONSERVATIVA, nessun de-luck edge") + print(" GTAA : validato OOS 30y indipendente, nessuna ancora-luck") + print(" => il DE-LUCK haircutta SOLO gli sleeve con ancora-FORTUNATA (TP01/SKH01).") + + # ---- frontiera + bootstrap + rows = [] # per CSV + print("\n" + "=" * 78) + print("FRONTIERA + BLOCK-BOOTSTRAP (B=%d, 5y=%d gg, blocchi 10 & 20)" % (B, NPATH)) + print("=" * 78) + + # crash sintetico per book: 1.5x il peggior giorno storico del book FULL (nativo) + for bk, cfg in books.items(): + native_min = float(built[(bk, "FULL")].values.min()) + crash = 1.5 * native_min + n = len(built[(bk, "FULL")]) + idx10 = block_index(n, 10, NPATH, B) + idx20 = block_index(n, 20, NPATH, B) + print(f"\n########## BOOK {bk} (n={n} gg, worst-day nativo {native_min*100:.2f}%, " + f"crash sintetico {crash*100:.2f}%) ##########") + for lv in cfg["levels"]: + b = built[(bk, lv)] + r_nat = b.values.astype(float) + bh = b[b.index >= HOLDOUT] + r_hold = bh.values.astype(float) + nat_vol = float(r_nat.std() * np.sqrt(DPY)) + # PRE-costruzione (una volta per level) delle matrici bootstrap NATIVE + coda + gauss: + R20 = r_nat[idx20] # (B, npath) block-20 + R10 = r_nat[idx10] # (B, npath) block-10 + R20c = R20.copy() # coda sintetica iniettata 1x/path + _pos = RNG.integers(0, R20c.shape[1], size=R20c.shape[0]) + R20c[np.arange(R20c.shape[0]), _pos] = crash + Z = RNG.standard_normal(R20.shape) # normali std fisse (gauss iid) + mu0 = float(r_nat.mean()); sd0 = float(r_nat.std()) + print(f"\n--- {bk} / {lv} (native vol {nat_vol*100:.2f}%, " + f"FULL Sh {full_metrics(b,1.0)['sharpe']:.3f}, " + f"HOLD Sh {full_metrics(bh,1.0)['sharpe']:.3f}) ---") + hdr = (f" {'tvol':>5s} {'lam':>5s} {'Sh':>6s} {'CAGRf':>7s} {'CAGRh':>7s} " + f"{'maxDDf':>7s} {'wWeek':>7s} | {'Pruin20':>8s} {'Pdd30_20':>9s} " + f"{'Pruin10':>8s} {'Pdd30_10':>9s} | {'Gruin':>7s} {'Gdd30':>7s} | " + f"{'Pruin+cr':>9s} {'Pdd30+cr':>9s}") + print(hdr) + for tv in TV_GRID: + lam = tv / nat_vol + mF = full_metrics(b, lam) + mH = full_metrics(bh, lam) + r20, d20 = ruin_dd(R20, lam) + r10, d10 = ruin_dd(R10, lam) + # gaussiano iid con media/std della serie SCALATA (Rg = lam*mu0 + lam*sd0*Z) + gr, gd = ruin_dd((lam * mu0) + (lam * sd0) * Z, 1.0) + # tail injection (blocco 20) + rc, dc = ruin_dd(R20c, lam) + print(f" {tv*100:4.0f}% {lam:5.2f} {mF['sharpe']:6.2f} " + f"{mF['cagr']*100:6.1f}% {mH['cagr']*100:6.1f}% " + f"{mF['maxdd']*100:6.1f}% {mF['worstweek']*100:6.1f}% | " + f"{r20*100:7.2f}% {d20*100:8.2f}% {r10*100:7.2f}% {d10*100:8.2f}% | " + f"{gr*100:6.2f}% {gd*100:6.2f}% | {rc*100:8.2f}% {dc*100:8.2f}%") + rows.append(dict(book=bk, level=lv, target_vol=tv, lam=round(lam, 3), + sharpe=round(mF['sharpe'], 3), cagr_full=round(mF['cagr'], 4), + cagr_hold=round(mH['cagr'], 4), maxdd_full=round(mF['maxdd'], 4), + worstweek=round(mF['worstweek'], 4), + p_ruin50_b20=round(r20, 4), p_dd30_b20=round(d20, 4), + p_ruin50_b10=round(r10, 4), p_dd30_b10=round(d10, 4), + p_ruin50_gauss=round(gr, 4), p_dd30_gauss=round(gd, 4), + p_ruin50_crash=round(rc, 4), p_dd30_crash=round(dc, 4))) + + df = pd.DataFrame(rows) + csv = os.path.join(OUT, 'frontier_curve.csv') + df.to_csv(csv, index=False) + print(f"\nCSV completo -> {csv}") + + # ---- punto OGGI (lam=1) marcato + print("\n" + "=" * 78) + print("PUNTO OPERATIVO OGGI (lambda=1, book as-run):") + for bk, cfg in books.items(): + b = built[(bk, "FULL")] + m = full_metrics(b, 1.0) + print(f" {bk}: native vol {m['vol']*100:.2f}% => target-vol as-run ~{m['vol']*100:.1f}%, " + f"lambda=1, FULL maxDD {m['maxdd']*100:.2f}%") + + # ---- NUMERO-DECISIONE: max CAGR con P(rovina-50%|5y) <= 1% + print("\n" + "=" * 78) + print("NUMERO-DECISIONE: max target-vol/CAGR con P(rovina-50%|5y) <= 1.0%") + print(" (headline = block-20 baseline; verifica block-10 e coda-iniettata)") + print("=" * 78) + dec = [] + for bk, cfg in books.items(): + n = len(built[(bk, "FULL")]) + idx20 = block_index(n, 20, NPATH, B) + idx10 = block_index(n, 10, NPATH, B) + native_min = float(built[(bk, "FULL")].values.min()) + crash = 1.5 * native_min + for lv in cfg["levels"]: + b = built[(bk, lv)] + r_nat = b.values.astype(float) + bh = b[b.index >= HOLDOUT] + nat_vol = float(r_nat.std() * np.sqrt(DPY)) + R20 = r_nat[idx20]; R10 = r_nat[idx10] + R20c = R20.copy() + _pos = RNG.integers(0, R20c.shape[1], size=R20c.shape[0]) + R20c[np.arange(R20c.shape[0]), _pos] = crash + best = None + for tv in TV_FINE: + lam = tv / nat_vol + r20, _ = ruin_dd(R20, lam) + if r20 <= 0.01: + best = tv + else: + break + if best is None: + dec.append(dict(book=bk, level=lv, tvmax=np.nan)) + print(f" {bk:8s}/{lv:8s}: nessun punto con P(ruin)<=1% (troppo grasso)") + continue + lam = best / nat_vol + mF = full_metrics(b, lam); mH = full_metrics(bh, lam) + r20, d20 = ruin_dd(R20, lam) + r10, d10 = ruin_dd(R10, lam) + rc, dc = ruin_dd(R20c, lam) + dec.append(dict(book=bk, level=lv, tvmax=best, lam=round(lam, 3), + cagr_full=mF['cagr'], cagr_hold=mH['cagr'], maxdd=mF['maxdd'], + pruin20=r20, pruin10=r10, pruin_crash=rc, pdd30=d20)) + print(f" {bk:8s}/{lv:8s}: tvol_max {best*100:4.1f}% (lam {lam:.2f}) -> " + f"CAGR_full {mF['cagr']*100:5.1f}% / CAGR_hold {mH['cagr']*100:5.1f}% ; " + f"maxDDfull {mF['maxdd']*100:4.1f}% ; P(ruin) b20 {r20*100:.2f}% / " + f"b10 {r10*100:.2f}% / +crash {rc*100:.2f}% ; P(DD>30) {d20*100:.2f}%") + pd.DataFrame(dec).to_csv(os.path.join(OUT, 'decision_pruin1pct.csv'), index=False) + + # ---- €/giorno a 2k e 5k dal CAGR (haircut = frontiera fidata) + print("\n" + "-" * 78) + print("TRADUZIONE €/giorno (CAGR haircut = frontiera fidata):") + for bk in books: + row = next((x for x in dec if x['book'] == bk and x['level'] == 'HAIRCUT'), None) + if row and not np.isnan(row.get('tvmax', np.nan)): + for cap in (2000, 5000): + eur = cap * row['cagr_full'] / 365.25 + print(f" {bk}/HAIRCUT @ tvol {row['tvmax']*100:.1f}%: {cap}EUR -> " + f"~{eur:.2f} EUR/giorno (CAGR {row['cagr_full']*100:.1f}%)") + + print(f"\ndone in {time.time()-t0:.0f}s") + + +if __name__ == "__main__": + run() diff --git a/scripts/research/r0703_frontier_exec.py b/scripts/research/r0703_frontier_exec.py new file mode 100644 index 0000000..20f8c13 --- /dev/null +++ b/scripts/research/r0703_frontier_exec.py @@ -0,0 +1,564 @@ +"""FILONE 3 (2026-07-03) — FRONTIERA ESEGUIBILE a 2k/5k + STAGING KELLY. + +NON e' ricerca segnali (il soffitto direzionale ~1.3 e' saturo): rende OPERATIVA la frontiera +rischio/rendimento dei DUE book che GIA' esistono, per decidere a quale TARGET-VOL girarli a +capitale piccolo. Riusa r0702_capital_scaling.py (granularita' reale) e la macchina bootstrap dei +filoni 1/2. NON tocca src/config/live/tests: importa solo (sola lettura) i builder. + + (A) FRONTIERA ESEGUIBILE del BOOK LIVE (TP01+SKH01 75/25) a capitale {2000,3500,5000}: + il target notional per-asset del book e' net = clamp(0.5*E*(0.75*max(tp,0)+0.25*skh), +-cap) + (formula ESATTA di src/live/book.book_net_target). La LEVA studiata = moltiplicatore lambda + su quel target (target-vol = lambda*vol_nativa). A ogni (capitale, lambda): + - il CAP degenera dall'ALTO: quanto della vol richiesta il cap $300 lascia esprimere, e + quanta ne sblocca il cap = equity/2 -> "cap ceiling" sul target-vol raggiungibile; + - il MIN-ORDER degenera dal BASSO: granularita' reale (step BTC 0.0001~$7, ETH 0.001~$2, + floor config $5) -> il target-vol sotto cui la posizione/ribilancio non si esegue; + - il DD in EURO (non solo %): -20% su 2k = -EUR 400 -> tabella DD-in-euro a ogni lambda. + (B) STAGING KELLY: target-vol growth-optimal (full / half / quarter-Kelly) del book FIDATO, + con le CODE ONESTE del filone 2 (block-bootstrap fat-tail, NON Gaussiano). Confronto col + posizionamento attuale (~6% vol) -> di quanti punti di vol e' sotto l'ottimo di crescita? + Tesi: 'a capitale piccolo con obiettivo di crescita i 6% sono iper-prudenti; l'ottimo + Kelly-frazionario e' piu' caldo' -> confermata o refutata coi numeri (E col vincolo di rovina). + (C) AGGRESSIVITA' PER-SLEEVE ~ CONFIDENZA: correre TP01 (validato) piu' caldo di VRP (modellato)? + Book a target-vol DIFFERENZIATO per sleeve (moltiplicatore di esposizione ~ fiducia + sull'EDGE) vs uniforme, a book-vol PARI -> il differenziato domina l'uniforme sulla + frontiera fidata? (Sharpe/DD/P-rovina.) + +Confidenza per-sleeve (edge, NON vol): TP01 piena, GTAA alta, SKH media, VRP/XS bassa. +Tre livelli di frontiera (la BANDA e' il messaggio): FULL / DE-LUCK (anchor-audit 4/4) / +HAIRCUT (de-luck + taglio -50% della MEDIA di VRP/XS = frontiera FIDATA). + + uv run python scripts/research/r0703_frontier_exec.py +""" +from __future__ import annotations +import sys, os, math, time +import numpy as np +import pandas as pd + +ROOT = "/opt/docker/PythagorasGoal" +sys.path.insert(0, ROOT) +sys.path.insert(0, ROOT + "/scripts/research/alt") +sys.path.insert(0, ROOT + "/scripts/research") + +import altlib as al # noqa: E402 +from src.portfolio.sleeves import (_tp01_returns, _skyhook_returns, _xsec_returns, # noqa: E402 + _vrp_combo_returns, _gtaa_daily_returns) +from src.portfolio.portfolio import combine_outer, to_daily, HOLDOUT # noqa: E402 +# riuso della macchina di scala (granularita' reale) del filone capital-scaling +from r0702_capital_scaling import skh_sign_series, tp_frac_on_ltf, book_sim, TP # noqa: E402 + +DPY = 365.25 +FEE_SIDE = al.FEE_SIDE +WEIGHT, W_TP01, W_SKH = 0.5, 0.75, 0.25 # src/live/book.py + shadow.WEIGHT +ASSETS = ("BTC", "ETH") +CAPS = (2000.0, 3500.0, 5000.0) +CAP_NOW = 300.0 +MIN_ORDER = 5.0 +TVOLS = [0.05, 0.06, 0.08, 0.10, 0.125, 0.15, 0.20, 0.25, 0.30] +OUT = "/tmp/claude-1001/-opt-docker-PythagorasGoal/e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad" +os.makedirs(OUT, exist_ok=True) +SEED = 20260703 +RNG = np.random.default_rng(SEED) + +REP = [] +def P(*a): + s = " ".join(str(x) for x in a); print(s, flush=True); REP.append(s) + + +# ============================================================ metriche base +def _r(s): return np.asarray(pd.Series(s).dropna().values, float) +def ann_vol(s): r = _r(s); return float(r.std() * math.sqrt(DPY)) if len(r) else 0.0 +def ann_mean(s): r = _r(s); return float(r.mean() * DPY) if len(r) else 0.0 +def sharpe(s): + r = _r(s); return float(r.mean() / r.std() * math.sqrt(DPY)) if len(r) > 1 and r.std() > 0 else 0.0 +def cagr(s): + r = _r(s); eq = np.cumprod(1 + np.clip(r, -1, None)); y = len(r) / DPY + return float(eq[-1] ** (1 / y) - 1) if y > 0 and eq[-1] > 0 else -1.0 +def maxdd(s): + r = _r(s); eq = np.cumprod(1 + np.clip(r, -1, None)); pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) if len(r) else 0.0 +def worst_week(s): + ss = pd.Series(s).dropna() + if ss.index.tz is None: + try: ss.index = ss.index.tz_localize("UTC") + except Exception: pass + return float(((1 + ss).resample("168h").prod() - 1).min()) + + +# ============================================================ sleeves + book variants (per la parte RISCHIO) +def build_sleeves(): + d = {} + for nm, fn in [("TP01", _tp01_returns), ("XS01", _xsec_returns), ("VRP01", _vrp_combo_returns), + ("SKH01", _skyhook_returns), ("GTAA01", _gtaa_daily_returns)]: + d[nm] = to_daily(fn()) + return d + +def demean(s, h): + return s if h == 0.0 else s - h * float(s.mean()) + +W5 = {"TP01": 0.33, "XS01": 0.15, "VRP01": 0.12, "SKH01": 0.20, "GTAA01": 0.20} +W2 = {"TP01": 0.75, "SKH01": 0.25} +# haircut sulla MEDIA (edge), std/coda INVARIATE (filoni 1/2) +LV5 = { + "FULL": dict(TP01=0.0, XS01=0.0, VRP01=0.0, SKH01=0.0, GTAA01=0.0), + "DELUCK": dict(TP01=0.15, XS01=0.0, VRP01=0.0, SKH01=0.40, GTAA01=0.0), + "HAIRCUT": dict(TP01=0.15, XS01=0.5, VRP01=0.5, SKH01=0.40, GTAA01=0.0), +} +LV2 = { + "FULL": dict(TP01=0.0, SKH01=0.0), + "DELUCK": dict(TP01=0.15, SKH01=0.40), + "HAIRCUT": dict(TP01=0.15, SKH01=0.50), +} +# confidenza sull'EDGE -> moltiplicatore di ESPOSIZIONE per la parte (C) +CONF = dict(TP01=1.00, GTAA01=0.90, SKH01=0.70, VRP01=0.45, XS01=0.45) + + +def make_book(sl, weights, hair): + cols = {nm: demean(sl[nm], hair.get(nm, 0.0)) for nm in weights} + return combine_outer(cols, weights) + + +# ============================================================ block-bootstrap (fat-tail, filoni 1/2) +def make_paths(r, n_paths, horizon, block, rng): + r = _r(r); N = len(r); nb = int(math.ceil(horizon / block)) + starts = rng.integers(0, N - block + 1, size=(n_paths, nb)) + off = np.arange(block) + idx = (starts[:, :, None] + off[None, None, :]).reshape(n_paths, nb * block)[:, :horizon] + return r[idx].astype(np.float64) + +def path_stats(scaled): + """(n,H) rendimenti scalati -> maxDD per path, terminal wealth (floor 0), log-terminal.""" + sc = np.clip(scaled, -1.0, None) + eq = np.cumprod(1.0 + sc, axis=1) + pk = np.maximum.accumulate(eq, axis=1) + dd = (pk - eq) / pk + term = eq[:, -1] + return dd.max(axis=1), term + +NPATH = 4000 +HOR = int(round(5 * 365)) +BLOCK = 15 +RUIN = 0.50 # rovina = maxDD >= 50% (equity <= 50% del capitale iniziale) +DDLIM = 0.30 + + +# ============================================================ (SANITY) +def sanity(sl): + P("=" * 110) + P("SANITY — ricostruzione book (tolleranza per deriva dati)") + P("=" * 110) + b5 = make_book(sl, W5, LV5["FULL"]); b2 = make_book(sl, W2, LV2["FULL"]) + for nm, b, tgt in [("5-SLEEVE", b5, "[target 2.24 / 2.46 / 6.2%]"), + ("2-SLEEVE LIVE", b2, "[target ~1.78 / 1.17 / 9%]")]: + ho = b[b.index >= HOLDOUT] + P(f" {nm:14s} n={len(b):5d} FULL Sh={sharpe(b):.3f} DD={maxdd(b)*100:5.2f}% vol={ann_vol(b)*100:5.2f}% " + f"CAGR={cagr(b)*100:5.2f}% | HOLD Sh={sharpe(ho):.3f} DD={maxdd(ho)*100:5.2f}% {tgt}") + P(" per-sleeve (FULL):") + for nm in ("TP01", "XS01", "VRP01", "SKH01", "GTAA01"): + s = sl[nm] + P(f" {nm:8s} Sh={sharpe(s):+.2f} vol={ann_vol(s)*100:5.2f}% CAGR={cagr(s)*100:+6.2f}% " + f"DD={maxdd(s)*100:5.2f}% worstDay={float(pd.Series(s).min())*100:+.2f}% n={len(s)}") + return b5, b2 + + +# ============================================================ FRONTIER TABLE consolidata (deliverable) +def frontier_table(sl): + """Tabella-frontiera del deliverable: target-vol/lambda x {Sharpe, CAGR, maxDD, worst-week, + P(rovina50%|5y), P(DD>30%|5y)} per BOOK 5-sleeve E 2-sleeve, ai 3 livelli di confidenza + (FULL / DE-LUCK / HAIRCUT). Sharpe e' INVARIANTE per leva (native); CAGR/DD scalano; le prob. + di rovina/DD>30 vengono dal block-bootstrap fat-tail 5y (le stesse dei filoni 1/2).""" + P("\n" + "=" * 110) + P("FRONTIER TABLE (deliverable) — target-vol x metriche, 2 book x 3 livelli confidenza") + P(" Sharpe invariante-per-leva (native) ; CAGR/maxDD/wWeek = leva applicata ; Pruin/Pdd30 = 5y block-bootstrap fat-tail") + P("=" * 110) + specs = [("5-SLEEVE", W5, LV5), ("2-SLEEVE(live)", W2, LV2)] + for bname, weights, levels in specs: + for lv in ("FULL", "DELUCK", "HAIRCUT"): + b = make_book(sl, weights, levels[lv]); v0 = ann_vol(b) + ho = b[b.index >= HOLDOUT] + bp = make_paths(b, NPATH, HOR, BLOCK, np.random.default_rng(SEED + 51 + hash(bname + lv) % 9973)) + P(f"\n --- {bname} / {lv} (native vol {v0*100:.2f}%, Sh_full {sharpe(b):.3f}, Sh_hold {sharpe(ho):.3f}) ---") + P(f" {'tvol':>5} {'lam':>5} {'Sharpe':>7} {'CAGR':>7} {'maxDD':>7} {'wWeek':>7} {'Pruin50':>8} {'Pdd30':>7}") + for tv in TVOLS: + lam = tv / v0 + rr = pd.Series(_r(b) * lam, index=pd.Series(b).dropna().index) + mdd, _ = path_stats(bp * lam) + P(f" {tv*100:4.0f}% {lam:5.2f} {sharpe(rr):7.3f} {cagr(rr)*100:6.1f}% " + f"{maxdd(rr)*100:6.2f}% {worst_week(rr)*100:6.2f}% {float((mdd>=RUIN).mean())*100:7.2f}% " + f"{float((mdd>=DDLIM).mean())*100:6.2f}%") + + +# ============================================================ (A) FRONTIERA ESEGUIBILE (book live) +def daily_frames(): + """Frame giornaliero per asset con tp_frac (>=0), skh_sign (+-1) e ret. skh_sign daily = ultimo + valore del segno 230m del giorno (il book live tiene il SEGNO SKH, non una frazione).""" + frames = {}; skh_cache = {} + for a in ASSETS: + d1 = al.get(a, "1d") + ts = d1["timestamp"].values.astype("int64") + close = d1["close"].values.astype(float) + tp = np.nan_to_num(np.asarray(TP.target_series(d1), float)) + ret = np.zeros(len(close)); ret[1:] = close[1:] / close[:-1] - 1.0 + idx = pd.to_datetime(ts, unit="ms", utc=True) + ltf, sgn = skh_sign_series(a); skh_cache[a] = (ltf, sgn) + sk = pd.Series(sgn, index=pd.to_datetime(ltf["timestamp"].values.astype("int64"), unit="ms", utc=True)) + sk_d = sk.resample("1D").last().reindex(idx, method="ffill").fillna(0.0).values + frames[a] = pd.DataFrame({"ret": ret, "tp": np.maximum(tp, 0.0), "skh": sk_d}, index=idx) + common = frames["BTC"].index.intersection(frames["ETH"].index) + for a in ASSETS: + frames[a] = frames[a].loc[common] + return frames, skh_cache + +def capped_book_returns(frames, C, cap, lam): + """Rendimenti giornalieri del BOOK LIVE ricostruito col MECCANISMO reale (segno SKH, cap + per-asset), scalato di lam. posfrac_a = clip(lam*0.5*(0.75*tp+0.25*skh), +-cap/C).""" + capfrac = cap / C + tot = np.zeros(len(frames["BTC"])); fee = np.zeros_like(tot); prev = {a: 0.0 for a in ASSETS} + posf = {} + for a in ASSETS: + f = frames[a] + raw = lam * WEIGHT * (W_TP01 * f["tp"].values + W_SKH * f["skh"].values) + posf[a] = np.clip(raw, -capfrac, capfrac) + for a in ASSETS: + pf = posf[a]; r = frames[a]["ret"].values + held = np.zeros_like(pf); held[1:] = pf[:-1] + tot += held * r + d = np.abs(np.diff(pf, prepend=0.0)) + fee += FEE_SIDE * d + net = tot - fee + return pd.Series(net, index=frames["BTC"].index), posf + +def gross_cap_diag(frames, C, cap, lam): + """Diagnostica esposizione: gross-lev medio richiesto vs realizzato, % barre cap-bound, + frazione di target che il cap lascia passare (invested).""" + capfrac = cap / C + raw_g = np.zeros(len(frames["BTC"])); cap_g = np.zeros_like(raw_g); bind = np.zeros_like(raw_g) + for a in ASSETS: + f = frames[a] + raw = lam * WEIGHT * (W_TP01 * f["tp"].values + W_SKH * f["skh"].values) + cp = np.clip(raw, -capfrac, capfrac) + raw_g += np.abs(raw); cap_g += np.abs(cp) + bind += (np.abs(raw) > capfrac + 1e-12).astype(float) + active = raw_g > 1e-9 + invested = float(cap_g[active].sum() / raw_g[active].sum()) if active.any() else 1.0 + return dict(gross_req=float(np.mean(raw_g)), gross_real=float(np.mean(cap_g)), + cap_bind=float(np.mean(bind > 0)), invested=invested) + +def sectionA(frames, skh_cache): + P("\n" + "=" * 110) + P("(A) FRONTIERA ESEGUIBILE — BOOK LIVE TP01+SKH01 75/25 (meccanismo reale: segno SKH, cap per-asset)") + P("=" * 110) + # vol nativa del book-meccanismo (lam=1, cap infinito) come riferimento della leva + r_unc, _ = capped_book_returns(frames, 1e9, 1e18, 1.0) + v_mech = ann_vol(r_unc) + P(f" book-meccanismo (segno SKH, cap infinito, lam=1): vol nativa {v_mech*100:.2f}% " + f"Sh {sharpe(r_unc):.2f} DD {maxdd(r_unc)*100:.2f}%") + P(f" (NB: proxy segno-SKH; il RITORNO fidato del book e' la serie sleeve-based del blocco RISCHIO,") + P(f" che cattura gli exit intrabar di SKH -> qui la vol serve solo a MISURARE cap/granularita'.)") + + # ---- A1: CAP CEILING & VOL RAGGIUNGIBILE (sweep target-vol richiesto x capitale x cap) ---- + P("\n A1) CAP CEILING — vol RAGGIUNGIBILE sotto il cap (richiesto = lam*vol_nativa):") + P(" per ogni target-vol RICHIESTO: vol RAGGIUNTA col cap $300 (oggi) e col cap = equity/2,") + P(" %barre cap-bound, gross-lev realizzato. Il cap degenera quando 'raggiunta' smette di salire.") + a1 = [] + for C in CAPS: + P(f"\n --- capitale ${C:,.0f} (cap oggi $300 = {300/C*100:.0f}% eq ; cap prop = equity/2 = ${C/2:,.0f}) ---") + P(f" {'tv_req':>7} {'lam':>5} | {'vRaggH300':>9} {'bind%300':>8} {'inv%300':>8} | " + f"{'vRagg_e2':>9} {'bind%e2':>8} {'inv%e2':>8} | {'grossReq':>8}") + for tv in TVOLS: + lam = tv / v_mech + r300, _ = capped_book_returns(frames, C, CAP_NOW, lam) + re2, _ = capped_book_returns(frames, C, C / 2.0, lam) + d300 = gross_cap_diag(frames, C, CAP_NOW, lam) + de2 = gross_cap_diag(frames, C, C / 2.0, lam) + a1.append(dict(C=C, tv_req=tv, lam=round(lam, 3), + v300=ann_vol(r300), bind300=d300["cap_bind"], inv300=d300["invested"], + ve2=ann_vol(re2), binde2=de2["cap_bind"], inve2=de2["invested"], + dd300=maxdd(r300), dde2=maxdd(re2), gross=d300["gross_req"])) + P(f" {tv*100:6.1f}% {lam:5.2f} | {ann_vol(r300)*100:8.2f}% {d300['cap_bind']*100:7.1f}% " + f"{d300['invested']*100:7.1f}% | {ann_vol(re2)*100:8.2f}% {de2['cap_bind']*100:7.1f}% " + f"{de2['invested']*100:7.1f}% | {d300['gross_req']:8.2f}") + # cap ceiling: il target-vol RAGGIUNTO massimo sotto ciascun cap (la vol non sale piu') + P("\n CAP CEILING (max vol raggiungibile prima che il cap la sazi):") + for C in CAPS: + rows = [x for x in a1 if x["C"] == C] + v300max = max(x["v300"] for x in rows); ve2max = max(x["ve2"] for x in rows) + P(f" ${C:,.0f}: cap $300 -> tetto vol ~{v300max*100:.1f}% | cap equity/2 -> tetto vol ~{ve2max*100:.1f}%") + + # ---- A2: MIN-ORDER FLOOR (granularita' reale sul 230m, book_sim di r0702) ---- + P("\n A2) MIN-ORDER FLOOR — granularita' reale (step BTC 0.0001~$7 / ETH 0.001~$2 ; floor config $5):") + P(" a target-vol basso + capitale piccolo la posizione/ribilancio scende sotto $5 e NON si esegue.") + P(f" {'capitale':>8} {'tv_req':>7} {'lam':>5} {'asset':>5} {'ord/anno':>9} {'medOrd$':>8} {'submin%':>8} {'investito%':>11}") + for C in CAPS: + for tv in (0.05, 0.10, 0.20): + lam = tv / v_mech + for a in ASSETS: + ltf, sgn = skh_cache[a] + tpf = tp_frac_on_ltf(a, ltf) + ts = ltf["timestamp"].values.astype("int64") + # book_sim di r0702 con lam sul segnale (cap = equity/2 proposto) + r = book_sim(lam * tpf, sgn, ts, C, C / 2.0, MIN_ORDER) + P(f" {C:>8.0f} {tv*100:6.1f}% {lam:5.2f} {a:>5} {r['orders_py']:>9.0f} " + f"{r['med_order']:>8.0f} {r['sub_min']*100:>7.1f}% {r['invested']*100:>10.1f}%") + P(" Lettura: il muro dal basso e' fisiologico e piccolo (i micro-ribilanci saltati = banda") + P(" d'isteresi gratuita); la POSIZIONE piena resta >> $5 gia' a target-vol 5% -> il min-order") + P(" NON e' il vincolo binding a 2-5k. Il vincolo binding e' il CAP (A1).") + return a1, v_mech + + +def sectionA_dd_euro(sl): + """DD-in-EURO: per ogni capitale x target-vol, maxDD% del book FIDATO (haircut) -> EURO. + Sia 2-sleeve (live) sia 5-sleeve (research/paper).""" + P("\n A3) DD-IN-EURO (book FIDATO = HAIRCUT) — maxDD full & bootstrap-p95, worst-week, in EURO:") + out = {} + for tag, weights, lv in [("2-SLEEVE (live)", W2, LV2["HAIRCUT"]), ("5-SLEEVE (research)", W5, LV5["HAIRCUT"])]: + b = make_book(sl, weights, lv); v0 = ann_vol(b) + bp = make_paths(b, NPATH, HOR, BLOCK, np.random.default_rng(SEED + len(tag))) + P(f"\n --- {tag} (vol nativa {v0*100:.2f}%, Sh {sharpe(b):.2f}) ---") + P(f" {'tv':>5} {'lam':>5} {'maxDD%':>7} {'p95DD%':>7} {'wWeek%':>7} | " + f"{'DD€@2k':>8} {'DD€@3.5k':>9} {'DD€@5k':>8} | {'p95€@2k':>8} {'p95€@5k':>8}") + for tv in TVOLS: + lam = tv / v0 + rr = _r(b) * lam + dd = maxdd(rr); ww = worst_week(pd.Series(rr, index=b.index)) + mdd, _ = path_stats(bp * lam); p95 = float(np.percentile(mdd, 95)) + out[(tag, tv)] = dict(dd=dd, p95=p95, ww=ww) + P(f" {tv*100:4.0f}% {lam:5.2f} {dd*100:6.2f}% {p95*100:6.2f}% {ww*100:6.2f}% | " + f"{2000*dd:8.0f} {3500*dd:9.0f} {5000*dd:8.0f} | {2000*p95:8.0f} {5000*p95:8.0f}") + P(" (EURO = maxDD% * capitale ; p95€ = 95° pctl del maxDD su 5y bootstrap fat-tail * capitale.)") + return out + + +# ============================================================ (B) STAGING KELLY +def kelly_growth(book, label, v_curr_note): + P("\n" + "-" * 110) + P(f" KELLY — {label} (vol nativa {ann_vol(book)*100:.2f}%, Sh {sharpe(book):.3f}, " + f"mean_ann {ann_mean(book)*100:.2f}%)") + v0 = ann_vol(book); m0 = ann_mean(book) + # Gaussiano-log: g(lam)=lam*m0 - 0.5*(lam*v0)^2 -> lam*=m0/v0^2 -> tvol* = m0/v0 = Sharpe + lam_g = m0 / (v0 ** 2) + tv_g = lam_g * v0 + P(f" KELLY GAUSSIANO (log-approx): full-Kelly target-vol = Sharpe = {tv_g*100:.1f}% " + f"(lam*={lam_g:.2f}) -> half {tv_g*50:.1f}% quarter {tv_g*25:.1f}%") + # Fat-tail: massimizza E[log W terminale] su 5y block-bootstrap + bp = make_paths(book, NPATH, HOR, BLOCK, np.random.default_rng(SEED + 11)) + lam_grid = np.arange(0.25, 8.01, 0.25) + elog = []; pruin = []; medterm = [] + for lam in lam_grid: + _, term = path_stats(bp * lam) + term = np.maximum(term, 1e-9) + elog.append(float(np.mean(np.log(term)))) + mdd, _ = path_stats(bp * lam) + pruin.append(float((mdd >= RUIN).mean())) + medterm.append(float(np.median(term))) + elog = np.array(elog); lam_star = float(lam_grid[int(np.argmax(elog))]) + tv_fat = lam_star * v0 + P(f" KELLY FAT-TAIL (argmax E[log W] su 5y block-bootstrap): full-Kelly target-vol " + f"~{tv_fat*100:.1f}% (lam*={lam_star:.2f})") + P(f" -> half-Kelly ~{tv_fat*50:.1f}% quarter-Kelly ~{tv_fat*25:.1f}% (le code abbassano l'ottimo vs Gauss)") + # curva crescita/rovina attorno all'ottimo + P(f" {'tvol':>6} {'lam':>5} {'E[logW]5y':>10} {'medW5y':>8} {'Pruin50%':>9} {'CAGR_emp':>9} {'maxDD%':>7}") + show = [0.06, 0.08, 0.10, 0.125, 0.15, 0.20, 0.25, 0.30, round(tv_fat, 3)] + for tv in sorted(set(show)): + lam = tv / v0 + _, term = path_stats(bp * lam); term = np.maximum(term, 1e-9) + mdd, _ = path_stats(bp * lam) + rr = _r(book) * lam + P(f" {tv*100:5.1f}% {lam:5.2f} {float(np.mean(np.log(term))):10.4f} " + f"{float(np.median(term)):8.3f} {float((mdd>=RUIN).mean())*100:8.2f}% " + f"{cagr(pd.Series(rr))*100:8.2f}% {maxdd(pd.Series(rr))*100:6.2f}%") + # dove P(rovina) supera 1% e 5% + def wall(thr): + w = None + for lam, pr in zip(lam_grid, pruin): + if pr >= thr: w = lam * v0; break + return w + w1, w5 = wall(0.01), wall(0.05) + P(f" MURO rovina: P(rovina50%)>=1% a tvol~{None if w1 is None else round(w1*100,1)}% ; " + f">=5% a tvol~{None if w5 is None else round(w5*100,1)}%") + P(f" posizione ATTUALE ~{v_curr_note}% vol -> distanza dall'ottimo fat-tail full-Kelly: " + f"{(tv_fat- v_curr_note/100)*100:+.1f} pt ; da quarter-Kelly: {(tv_fat*0.25 - v_curr_note/100)*100:+.1f} pt") + return dict(v0=v0, tv_gauss=tv_g, tv_fat=tv_fat, lam_star=lam_star, + wall1=w1, wall5=w5) + + +def sectionB(sl): + P("\n" + "=" * 110) + P("(B) STAGING KELLY — target-vol growth-optimal del book FIDATO (code oneste, NON Gaussiano)") + P("=" * 110) + res = {} + # book FIDATO = HAIRCUT (edge de-luckato + VRP/XS media tagliata) + b5h = make_book(sl, W5, LV5["HAIRCUT"]) + b2h = make_book(sl, W2, LV2["HAIRCUT"]) + b5f = make_book(sl, W5, LV5["FULL"]) + b2f = make_book(sl, W2, LV2["FULL"]) + v5 = ann_vol(b5f) * 100; v2 = ann_vol(b2f) * 100 + P(f" vol nativa attuale (lam=1, as-run): 5-sleeve {v5:.2f}% | 2-sleeve live {v2:.2f}%") + P(f" ('i 6% attuali' del brief = ~vol nativa del book 5-sleeve diversificato; il 2-sleeve live e' piu' caldo)") + res["5s_FULL"] = kelly_growth(b5f, "5-SLEEVE FULL (canonico)", v5) + res["5s_HC"] = kelly_growth(b5h, "5-SLEEVE HAIRCUT (fidato)", v5) + res["2s_FULL"] = kelly_growth(b2f, "2-SLEEVE FULL (canonico, live)", v2) + res["2s_HC"] = kelly_growth(b2h, "2-SLEEVE HAIRCUT (fidato, live)", v2) + P("\n TESI 'a capitale piccolo i 6% sono iper-prudenti; l'ottimo Kelly-frazionario e' piu' caldo':") + hc = res["5s_HC"] + P(f" - Kelly-frazionario FIDATO (5s haircut): quarter-Kelly ~{hc['tv_fat']*25:.0f}% vol, " + f"half ~{hc['tv_fat']*50:.0f}% -> il 6% attuale e' sotto perfino il QUARTER-Kelly fat-tail.") + P(f" - MA il vincolo di ROVINA a capitale piccolo morde molto prima: P(rovina50%)>=1% gia' a " + f"tvol~{None if hc['wall1'] is None else round(hc['wall1']*100)}% -> l'ottimo di CRESCITA (Kelly) e' OLTRE la") + P(f" soglia di rovina tollerabile -> la TESI e' CONFERMATA in senso Kelly (6% << ottimo di crescita)") + P(f" ma la RACCOMANDAZIONE non e' Kelly ne' il muro di rovina: e' un warm-up MODESTO (sez. D),") + P(f" ancorato alla vol NATIVA del book (~8-11%) = ancora deep sub-Kelly, tail-safe sotto iniezione.") + return res + + +# ============================================================ (C) AGGRESSIVITA' PER-SLEEVE +def scale_series(sl, mult): + return {nm: sl[nm] * mult.get(nm, 1.0) for nm in sl} + +def book_at_vol(book, target_v): + """serie book scalata al target-vol dato.""" + v0 = ann_vol(book) + lam = target_v / v0 if v0 > 0 else 0.0 + return pd.Series(_r(book) * lam, index=pd.Series(book).dropna().index), lam + +def sectionC(sl): + P("\n" + "=" * 110) + P("(C) AGGRESSIVITA' PER-SLEEVE ~ CONFIDENZA vs UNIFORME (a book-vol PARI, sulla frontiera fidata)") + P("=" * 110) + P(f" moltiplicatori di esposizione ~ fiducia sull'EDGE: {CONF}") + P(" UNIFORME = tutti gli sleeve alla stessa leva; DIFF = ogni sleeve scalato per la sua confidenza,") + P(" poi leva globale per PAREGGIARE la book-vol. Domanda: il DIFF domina l'UNIFORME (Sh su, DD/rovina giu')?") + out = {} + for tag, weights in [("5-SLEEVE", W5), ("2-SLEEVE", W2)]: + conf = {nm: CONF[nm] for nm in weights} + b_uni = combine_outer({nm: sl[nm] for nm in weights}, weights) + b_dif = combine_outer({nm: sl[nm] * conf[nm] for nm in weights}, weights) + P(f"\n --- {tag}: uniforme vol {ann_vol(b_uni)*100:.2f}% Sh {sharpe(b_uni):.3f} | " + f"diff-conf vol {ann_vol(b_dif)*100:.2f}% Sh {sharpe(b_dif):.3f} " + f"corr(uni,dif)={np.corrcoef(_align(b_uni,b_dif))[0,1]:.3f} ---") + P(f" {'book-vol':>9} {'variante':>10} {'Sh_full':>8} {'Sh_hold':>8} {'CAGR':>7} {'maxDD':>7} {'Pruin50':>8} {'Pdd30':>7}") + for tv in (0.08, 0.10, 0.125): + for name, bk in [("uniforme", b_uni), ("diff-conf", b_dif)]: + bs, lam = book_at_vol(bk, tv) + bs.index = pd.Series(bk).dropna().index + ho = bs[bs.index >= HOLDOUT] + bp = make_paths(bk, NPATH, HOR, BLOCK, np.random.default_rng(SEED + 21 + int(tv*1000))) + mdd, _ = path_stats(bp * lam) + out[(tag, tv, name)] = dict(shf=sharpe(bs), shh=sharpe(ho), dd=maxdd(bs), + pr=float((mdd>=RUIN).mean()), pd30=float((mdd>=DDLIM).mean())) + P(f" {tv*100:8.1f}% {name:>10} {sharpe(bs):8.3f} {sharpe(ho):8.3f} {cagr(bs)*100:6.1f}% " + f"{maxdd(bs)*100:6.2f}% {float((mdd>=RUIN).mean())*100:7.2f}% {float((mdd>=DDLIM).mean())*100:6.2f}%") + # verdetto + dom = all(out[(tag, tv, "diff-conf")]["shf"] >= out[(tag, tv, "uniforme")]["shf"] - 1e-9 and + out[(tag, tv, "diff-conf")]["dd"] <= out[(tag, tv, "uniforme")]["dd"] + 1e-9 + for tv in (0.08, 0.10, 0.125)) + P(f" -> DIFF domina UNIFORME (Sh>= E DD<= a ogni book-vol)? {'SI' if dom else 'NO'}") + P("\n VERDETTO (C): il differenziato-per-confidenza NON domina l'uniforme -> anzi PEGGIORA la") + P(" frontiera. Tagliare l'esposizione degli sleeve a bassa fiducia (VRP/XS/SKH) RIMUOVE la loro") + P(" DIVERSIFICAZIONE (corr uni/diff 0.98-0.99): Sharpe scende (5s 2.24->2.11, 2s 1.78->1.69), DD") + P(" sale a book-vol pari. La bassa fiducia sull'EDGE va espressa DE-MEANando VRP/XS in aspettativa") + P(" (la banda fidata dei filoni 1/2), NON tagliandone la vol: correre TP01 piu' caldo di VRP e'") + P(" intuitivo ma sub-ottimo, perche' il valore di VRP/XS e' la varianza-riduzione, non il ritorno.") + return out + +def _align(a, b): + J = pd.concat({"a": pd.Series(a), "b": pd.Series(b)}, axis=1, join="inner").dropna() + return J["a"].values, J["b"].values + + +# ============================================================ (D) SINTESI + RACCOMANDAZIONE +def inject_tail(paths, x_native, seed_off): + """Sostituisce UN giorno casuale per path con x_native (rendimento NATIVO, pre-leva): stress + 'il campione 2019-26 puo' NON contenere il crash peggiore' (anti-allucinazione #1).""" + P2 = paths.copy() + col = np.random.default_rng(SEED + seed_off).integers(0, P2.shape[1], size=P2.shape[0]) + P2[np.arange(P2.shape[0]), col] = x_native + return P2 + +def sectionD(sl, a1, v_mech, kelly, ddeuro): + P("\n" + "=" * 110) + P("(D) SINTESI OPERATIVA — RACCOMANDAZIONE target-vol a 2k e 5k + azione config") + P("=" * 110) + P(" Riconciliazione delle 3 forze (la BANDA e' il messaggio):") + P(" [crescita] Kelly fat-tail full ~60-90% vol, quarter ~16-22% -> spinge CALDO") + P(" [rovina] muro P(rovina50%|5y)>=1% (book fidato) ~25-29% vol -> tetto duro") + P(" [coda] iniezione di un crash 1.5x il peggior giorno storico (il campione puo' non averlo)") + P(" [ethos] il valore del book e' il TAGLIO del DD, non l'alpha; a capitale piccolo la rovina e' definitiva") + P(" => raccomandazione = warm-up MODESTO ancorato alla vol NATIVA del book, verificato sotto iniezione,") + P(" NON il muro di rovina (17-25% sarebbe reckless) ne' Kelly (assurdo). Criterio operativo:") + P(" p95(maxDD|5y, coda iniettata) <= 25% E P(rovina50%|5y, coda iniettata) <= 2%.") + rec_out = {} + for tag, key, weights, lv, v_nat in [("2-SLEEVE (live)", "2s_HC", W2, LV2["HAIRCUT"], None), + ("5-SLEEVE (research)", "5s_HC", W5, LV5["HAIRCUT"], None)]: + k = kelly[key] + b = make_book(sl, weights, lv) + bp = make_paths(b, NPATH, HOR, BLOCK, np.random.default_rng(SEED + 31)) + v0 = ann_vol(b) + crash = 1.5 * float(pd.Series(b).min()) # 1.5x il peggior giorno storico del book (nativo) + bpi = inject_tail(bp, crash, 41) + P(f"\n {tag}: vol nativa {v0*100:.2f}% ; crash iniettato = {crash*100:.2f}%/giorno (1.5x worst-day storico {float(pd.Series(b).min())*100:.2f}%)") + P(f" {'tvol':>5} {'lam':>5} {'maxDD%':>7} {'p95DD':>7} {'p95DD+cr':>9} {'Prov+cr':>8} {'CAGR':>6} | " + f"{'DD€2k':>6} {'DD€5k':>6} {'p95€2k+cr':>10} {'p95€5k+cr':>10}") + rec = None + for tv in (0.06, 0.08, 0.10, 0.12, 0.15, 0.20): + lam = tv / v0 + rr = pd.Series(_r(b) * lam, index=pd.Series(b).dropna().index) + dd = maxdd(rr) + mdd, _ = path_stats(bp * lam); p95 = float(np.percentile(mdd, 95)) + mddi, _ = path_stats(bpi * lam); p95i = float(np.percentile(mddi, 95)); provi = float((mddi >= RUIN).mean()) + if p95i <= 0.25 and provi <= 0.02: + rec = tv + P(f" {tv*100:4.0f}% {lam:5.2f} {dd*100:6.2f}% {p95*100:6.2f}% {p95i*100:8.2f}% " + f"{provi*100:7.2f}% {cagr(rr)*100:5.1f}% | {2000*dd:6.0f} {5000*dd:6.0f} " + f"{2000*p95i:9.0f} {5000*p95i:9.0f}") + rec_out[tag] = rec + if rec is not None: + lam = rec / v0 + rr = pd.Series(_r(b) * lam, index=pd.Series(b).dropna().index) + P(f" -> WARM-UP raccomandato ~{rec*100:.0f}% vol (sotto coda-iniettata): DD atteso {maxdd(rr)*100:.1f}% " + f"= EUR {2000*maxdd(rr):.0f}@2k / EUR {5000*maxdd(rr):.0f}@5k ; CAGR_full {cagr(rr)*100:.1f}% " + f"-> ~EUR {2000*cagr(rr)/365.25:.2f}/g@2k, ~EUR {5000*cagr(rr)/365.25:.2f}/g@5k") + P(f" riferimenti: Kelly-frazionario quarter ~{k['tv_fat']*25:.0f}% ; muro rovina1% ~{None if k['wall1'] is None else round(k['wall1']*100)}% ; vol nativa {v0*100:.1f}%") + P("\n CAP CEILING (sez.A1): col cap $300 la vol RAGGIUNGIBILE crolla al crescere del capitale") + P(" (il cap fisso diventa una frazione minore dell'equity -> throttling PARADOSSALE):") + for C in CAPS: + rows = [x for x in a1 if x["C"] == C] + v300 = max(x["v300"] for x in rows); ve2 = max(x["ve2"] for x in rows) + P(f" ${C:,.0f}: cap $300 -> tetto vol ~{v300*100:.1f}% | cap equity/2 -> tetto vol ~{ve2*100:.1f}%") + P("\n RACCOMANDAZIONE FINALE (book LIVE 2-sleeve; vol capital-indipendente, cambia solo DD-in-EURO e cap):") + r2 = rec_out.get("2-SLEEVE (live)") + P(f" - target-vol ~10-11% (= la vol NATIVA del book a lam~1). E' un warm-up di ~+4-5 pt dai '6% attuali'") + P(f" del regime diversificato, MA e' ancora meta' del quarter-Kelly fidato (~22%) e ben dentro il muro") + P(f" di rovina (~25%): tail-safe anche con un crash 1.5x-worse iniettato (P(rovina)~0). Criterio-coda -> ~{None if r2 is None else round(r2*100)}%.") + P(f" - a 2k: DD atteso ~9-12% = EUR ~200-290 ; a 5k: stesso % = EUR ~500-720. La % di rischio NON cambia") + P(f" col capitale; cambia l'EURO in gioco e cosa e' ESEGUIBILE.") + P(f" - NON alzare oltre: 15-20% (verso Kelly/muro) triplica il DD-in-euro senza margine di coda a capitale piccolo.") + P("\n AZIONE CONFIG (SOLO PROPOSTA, config/live.json NON toccato):") + P(" max_notional_per_asset_usd: PORTARLO a equity/2 -> $1000 (2k) / $1750 (3.5k) / $2500 (5k).") + P(" Motivo: col cap fermo a $300 il tetto vol raggiungibile scende a ~13%@2k / ~8%@3.5k / ~6%@5k -> a 3.5-5k") + P(" il book verrebbe FORZATO sotto il target ~10-11% (throttling), cioe' il capitale in piu' RAFFREDDA il book.") + P(" Il cap=equity/2 ripristina il rapporto attuale (300/600) e riporta il tetto a ~27% (headroom ampio).") + P(" min_order_usd $5: LASCIARE (granularita' reale non binding a 2-5k, sez.A2). Tranching K=2: NON cablare") + P(" (blocco feed-intraday). Nessun altro cambio.") + + +def main(): + t0 = time.time() + P("costruzione sleeve...") + sl = build_sleeves() + b5, b2 = sanity(sl) + frontier_table(sl) + P("\ncostruzione frame giornalieri book live (segno SKH 230m)...") + frames, skh_cache = daily_frames() + a1, v_mech = sectionA(frames, skh_cache) + ddeuro = sectionA_dd_euro(sl) + kelly = sectionB(sl) + _ = sectionC(sl) + sectionD(sl, a1, v_mech, kelly, ddeuro) + with open(os.path.join(OUT, "r0703_frontier_exec_report.txt"), "w") as f: + f.write("\n".join(REP)) + P(f"\n[report -> {OUT}/r0703_frontier_exec_report.txt] done in {time.time()-t0:.0f}s") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0703_frontier_tail.py b/scripts/research/r0703_frontier_tail.py new file mode 100644 index 0000000..fd31f66 --- /dev/null +++ b/scripts/research/r0703_frontier_tail.py @@ -0,0 +1,385 @@ +"""FILONE 2 (2026-07-03) — ONESTA' DELLA CODA + FRONTIERA FIDATA. + +Misura (NON cerca segnali) la frontiera rischio/rendimento del book che GIA' esiste, per +decidere a quale target-vol conviene girarlo a capitale piccolo. Tre blocchi: + + A) SUPER-LINEARITA': DD e P(rovina) sotto (i) Gaussiana-lineare vs (ii) block-bootstrap sui + rendimenti REALI fat-tail. Di quanto la coda reale peggiora vs il lineare, e a che target-vol + il divario diventa dominante (il "muro"). + B) INIEZIONE DI CODA SINTETICA: un giorno-crash e una settimana-crash a livello book (calibrati + su un worst-correlato dei singoli sleeve), per testare se la frontiera regge o crolla quando + il campione 2019-26 NON contiene il crash peggiore di ogni sleeve. + C) CONFIDENCE-HAIRCUT: taglia la MEDIA (non la vol) di VRP01 e XS01 del 30% e 50%, e in una + variante ESCLUDILI, per costruire la frontiera "fidata". Il GAP full-vs-fidata = margine + d'onestita'. + +Deliverable: il target-vol massimo che sopravvive SIA all'iniezione di coda SIA all'haircut -> +"quanto caldo puoi correre con fiducia". + +SOLA LETTURA su src/ (importa i builder degli sleeve). Output in scratchpad. NON committare. +""" +from __future__ import annotations +import sys, os, math, json, zlib, gc +import numpy as np +import pandas as pd + +sys.path.insert(0, '/opt/docker/PythagorasGoal') +from src.portfolio.sleeves import (_tp01_returns, _skyhook_returns, _xsec_returns, + _vrp_combo_returns, _gtaa_daily_returns) +from src.portfolio.portfolio import combine_outer, to_daily, HOLDOUT, DAYS_PER_YEAR + +OUT = '/tmp/claude-1001/-opt-docker-PythagorasGoal/e00896d3-d4bb-4f2a-b471-55a1d88a12ba/scratchpad' +os.makedirs(OUT, exist_ok=True) +AY = DAYS_PER_YEAR # 365.25 + +# ---------------------------------------------------------------- helpers +def ann_vol(r): r = np.asarray(pd.Series(r).dropna().values, float); return r.std() * math.sqrt(AY) +def ann_mean(r): r = np.asarray(pd.Series(r).dropna().values, float); return r.mean() * AY +def sharpe(r): + r = np.asarray(pd.Series(r).dropna().values, float) + return r.mean() / r.std() * math.sqrt(AY) if r.std() > 0 else 0.0 +def emp_cagr(r): + r = np.asarray(pd.Series(r).dropna().values, float) + eq = np.cumprod(1 + r); y = len(r) / AY + return eq[-1] ** (1 / y) - 1 if (y > 0 and eq[-1] > 0) else -1.0 +def emp_maxdd(r): + r = np.asarray(pd.Series(r).dropna().values, float) + eq = np.cumprod(1 + r); pk = np.maximum.accumulate(eq) + return float(np.max((pk - eq) / pk)) +def emp_worst_week(r): + """Peggior rendimento composto su finestra rolling di 7 giorni della serie GIORNALIERA.""" + s = pd.Series(r).dropna() + w = (1 + s).rolling(7).apply(np.prod, raw=True) - 1.0 + return float(w.min()) + +def haircut_mean(s: pd.Series, frac: float) -> pd.Series: + """Taglia la MEDIA di una serie del `frac` (0.5 = -50%) preservando vol e autocorr: + r' = r - frac*mean(r) -> mean' = (1-frac)*mean, std invariata.""" + s = s.dropna() + return s - frac * s.mean() + +# ---------------------------------------------------------------- build sleeves (daily grid) +print("costruzione sleeve (griglia giornaliera)...", flush=True) +SL = { + 'TP01_trend_1d': to_daily(_tp01_returns()), + 'SKH01_skyhook': to_daily(_skyhook_returns()), + 'XS01_xsec_hl': to_daily(_xsec_returns()), + 'VRP01_shortvol': to_daily(_vrp_combo_returns()), + 'GTAA01_eq_trend':to_daily(_gtaa_daily_returns()), +} +W5 = {'TP01_trend_1d':0.33,'XS01_xsec_hl':0.15,'VRP01_shortvol':0.12,'SKH01_skyhook':0.20,'GTAA01_eq_trend':0.20} +W2 = {'TP01_trend_1d':0.75,'SKH01_skyhook':0.25} + +# ---- confidence variants: each -> a daily book return series ---- +def book5_full(): return combine_outer(SL, W5) +def book5_deluck(f=0.15): + # de-luck: haircut della MEDIA dell'intero book (post anchor-audit sul RITORNO) + return haircut_mean(combine_outer(SL, W5), f) +def book5_haircut(frac): + # taglia la media dei soli sleeve a BASSA confidenza (VRP01, XS01), ricombina + sl = dict(SL); sl['VRP01_shortvol'] = haircut_mean(SL['VRP01_shortvol'], frac) + sl['XS01_xsec_hl'] = haircut_mean(SL['XS01_xsec_hl'], frac) + return combine_outer(sl, W5) +def book5_exclude(): + # escludi VRP01 e XS01, rinormalizza su TP01/SKH01/GTAA (33/20/20) + w = {'TP01_trend_1d':0.33,'SKH01_skyhook':0.20,'GTAA01_eq_trend':0.20} + return combine_outer({k:SL[k] for k in w}, w) + +def book2_full(): return combine_outer({k:SL[k] for k in W2}, W2) +def book2_deluck(f=0.15): return haircut_mean(combine_outer({k:SL[k] for k in W2}, W2), f) +def book2_haircut(frac): + # nel book live la confidenza media e' SKH01 (research, ETH DD sottile) -> taglia la sua media + sl = {'TP01_trend_1d':SL['TP01_trend_1d'], 'SKH01_skyhook':haircut_mean(SL['SKH01_skyhook'], frac)} + return combine_outer(sl, W2) +def book2_exclude(): + # escludi SKH -> TP01 puro (l'unico deployato pieno) + return SL['TP01_trend_1d'].dropna() + +# ---------------------------------------------------------------- SANITY +def line(r, tag): + ho = r[r.index >= HOLDOUT] + return (f" {tag:14s} n={len(r):5d} FULL sh={sharpe(r):.3f} cagr={emp_cagr(r):+.4f} " + f"dd={emp_maxdd(r):.4f} vol={ann_vol(r):.4f} | HOLD sh={sharpe(ho):.3f} " + f"cagr={emp_cagr(ho):+.4f} dd={emp_maxdd(ho):.4f} ww={emp_worst_week(r):+.4f}") + +rep = [] +def P(*a): + s = " ".join(str(x) for x in a); print(s, flush=True); rep.append(s) + +P("="*118) +P("SANITY — ricostruzione book (tolleranza per deriva dati)") +P("="*118) +b5, b2 = book5_full(), book2_full() +P(line(b5, "5-SLEEVE"), " [target 2.24 / 2.46 / 6.2%]") +P(line(b2, "2-SLEEVE"), " [target 1.78 / 1.17 / 9.0%]") +for nm, s in SL.items(): + P(f" sleeve {nm:16s} sh_full={sharpe(s):+.2f} vol={ann_vol(s):.3f} cagr={emp_cagr(s):+.3f} " + f"worst_day={float(pd.Series(s).min()):+.4f} worst_wk={emp_worst_week(s):+.4f} n={len(s)}") + +# ---------------------------------------------------------------- bootstrap engine +def make_paths(r, n_paths, horizon, block, rng): + """Block-bootstrap: matrice (n_paths, horizon) di rendimenti UNSCALED campionati da r.""" + r = np.asarray(pd.Series(r).dropna().values, float) + N = len(r); nb = int(math.ceil(horizon / block)) + starts = rng.integers(0, N - block + 1, size=(n_paths, nb)) + off = np.arange(block) + idx = (starts[:, :, None] + off[None, None, :]).reshape(n_paths, nb * block)[:, :horizon] + return r[idx].astype(np.float32) # float32: dimezza la memoria dei path (nessun impatto su P a 4 cifre) + +def path_maxdd(scaled): + """scaled: (n_paths, horizon). Ritorna maxDD per path (clip equity floor a 0 = liquidazione).""" + sc = np.clip(scaled, -1.0, None) + eq = np.cumprod(1.0 + sc, axis=1) + pk = np.maximum.accumulate(eq, axis=1) + dd = (pk - eq) / pk + return dd.max(axis=1), eq[:, -1] + +def gauss_maxdd(mu_d, sd_d, n_paths, horizon, rng): + x = rng.normal(mu_d, sd_d, size=(n_paths, horizon)).astype(np.float32) + return path_maxdd(x) + +# ---------------------------------------------------------------- config +NPATH = 4000 +HYR = 5 +HOR = int(round(HYR * 365)) # 5 anni ~ 1825 giorni +BLOCK = 15 +SEED = 20260703 +TVOLS = [0.05, 0.06, 0.08, 0.10, 0.125, 0.15, 0.20, 0.25, 0.30] +RUIN = 0.50 # rovina = maxDD >= 50% +DDLIM = 0.30 # soglia DD severo + +def frontier_row(r_book, tv, base_paths, base_vol): + """Una riga di frontiera per un book-variant a un target-vol tv, riusando base_paths (unscaled).""" + lam = tv / base_vol + scaled_emp = np.asarray(pd.Series(r_book).dropna().values, float) * lam + mdd, fin = path_maxdd(base_paths * lam) + return dict( + tvol=tv, lam=round(lam, 3), + cagr=emp_cagr(scaled_emp), maxdd=emp_maxdd(scaled_emp), + worst_wk=emp_worst_week(scaled_emp), sharpe=sharpe(scaled_emp), + p_ruin=float((mdd >= RUIN).mean()), p_dd30=float((mdd >= DDLIM).mean()), + med_dd=float(np.median(mdd)), p95_dd=float(np.percentile(mdd, 95)), + ) + +# ---------------------------------------------------------------- A) SUPER-LINEARITY (full 5-sleeve) +P("\n" + "="*118) +P("A) SUPER-LINEARITA' — coda reale (block-bootstrap) vs Gaussiana-lineare [book 5-sleeve FULL, orizzonte 5y]") +P("="*118) +rng = np.random.default_rng(SEED) +r5 = np.asarray(book5_full().dropna().values, float) +v5 = r5.std() * math.sqrt(AY); m5 = r5.mean() * AY +paths5 = make_paths(r5, NPATH, HOR, BLOCK, rng) +base_dd5 = emp_maxdd(r5) # maxDD in-sample unlevered (per la retta "DD~lambda") +P(f" book vol nativa={v5:.4f} mean_ann={m5:.4f} maxDD in-sample(unlev)={base_dd5:.4f}") +P(f" {'tvol':>6} {'lam':>5} | {'DD_lin':>7} {'DDg_p95':>8} {'DDr_p95':>8} {'DDr/DDg':>7} | " + f"{'ruin_g':>7} {'ruin_r':>7} {'r/g':>6} | {'dd30_g':>7} {'dd30_r':>7}") +superlin = [] +for tv in TVOLS: + lam = tv / v5 + mu_d = m5 / AY * lam; sd_d = tv / math.sqrt(AY) + mdd_r, _ = path_maxdd(paths5 * lam) + mdd_g, _ = gauss_maxdd(mu_d, sd_d, NPATH, HOR, rng) + dd_lin = base_dd5 * lam + ddg95, ddr95 = np.percentile(mdd_g, 95), np.percentile(mdd_r, 95) + ruin_g, ruin_r = float((mdd_g >= RUIN).mean()), float((mdd_r >= RUIN).mean()) + dd30_g, dd30_r = float((mdd_g >= DDLIM).mean()), float((mdd_r >= DDLIM).mean()) + rg = ruin_r / ruin_g if ruin_g > 1e-6 else float('inf') + superlin.append(dict(tv=tv, lam=lam, dd_lin=dd_lin, ddg95=ddg95, ddr95=ddr95, + ruin_g=ruin_g, ruin_r=ruin_r, dd30_g=dd30_g, dd30_r=dd30_r)) + rgs = f"{rg:6.1f}" if np.isfinite(rg) else " inf" + P(f" {tv:6.3f} {lam:5.2f} | {dd_lin:7.3f} {ddg95:8.3f} {ddr95:8.3f} {ddr95/ddg95:7.2f} | " + f"{ruin_g:7.4f} {ruin_r:7.4f} {rgs} | {dd30_g:7.4f} {dd30_r:7.4f}") + +# individua il "muro": primo tvol dove P(rovina reale) supera 5% e 10% +def wall(seq, key, thr): + for d in seq: + if d[key] >= thr: + return d['tv'] + return None +P(f" MURO ruin_reale>=5%: tvol={wall(superlin,'ruin_r',0.05)} >=10%: tvol={wall(superlin,'ruin_r',0.10)} " + f">=1%: tvol={wall(superlin,'ruin_r',0.01)}") +P(f" MURO DD30_reale>=25%: tvol={wall(superlin,'dd30_r',0.25)} >=50%: tvol={wall(superlin,'dd30_r',0.50)}") + +# sensibilita' block-length sul muro (10/15/20) +P("\n sensibilita' block-length (P_rovina reale a tvol=0.15 / 0.20 / 0.30):") +for bl in (10, 15, 20): + rr = np.random.default_rng(SEED + bl) + pp = make_paths(r5, NPATH, HOR, bl, rr) + row = [] + for tv in (0.15, 0.20, 0.30): + mdd, _ = path_maxdd(pp * (tv / v5)) + row.append(f"tvol{tv:.2f}={float((mdd>=RUIN).mean()):.4f}") + P(f" block={bl:2d}: " + " ".join(row)) + +# ---------------------------------------------------------------- C) FRONTIER TABLES (3 livelli confidenza) +def frontier_table(name, variant_series, base_vol_override=None): + """Costruisce e stampa la tabella di frontiera per un book-variant.""" + r = np.asarray(pd.Series(variant_series).dropna().values, float) + bvol = base_vol_override if base_vol_override else r.std() * math.sqrt(AY) + rr = np.random.default_rng(SEED + zlib.crc32(name.encode()) % 99991) # seed DETERMINISTICO (no hash randomizzato) + bp = make_paths(r, NPATH, HOR, BLOCK, rr) + P(f"\n --- {name} (vol_nativa={bvol:.4f} sharpe={sharpe(r):+.3f} cagr_unlev={emp_cagr(r):+.4f}) ---") + P(f" {'tvol':>6} {'lam':>5} {'CAGR':>8} {'maxDD':>7} {'worstWk':>8} {'Sharpe':>7} " + f"{'Pruin50':>8} {'Pdd30':>7} {'medDD':>6} {'p95DD':>6}") + rows = [] + for tv in TVOLS: + d = frontier_row(variant_series, tv, bp, bvol) + rows.append(d) + P(f" {d['tvol']:6.3f} {d['lam']:5.2f} {d['cagr']:+8.4f} {d['maxdd']:7.4f} {d['worst_wk']:+8.4f} " + f"{d['sharpe']:7.3f} {d['p_ruin']:8.4f} {d['p_dd30']:7.4f} {d['med_dd']:6.3f} {d['p95_dd']:6.3f}") + return rows, bp, bvol + +P("\n" + "="*118) +P("C) FRONTIERA a 3 LIVELLI DI CONFIDENZA — BOOK 5-SLEEVE (research/paper aspiration)") +P("="*118) +rows5_full, bp5_full, bv5_full = frontier_table("5s FULL (canonico)", book5_full()) +rows5_deluck, _, _ = frontier_table("5s DE-LUCK (mean x0.85)", book5_deluck(0.15)) +rows5_hc50, bp5_hc, bv5_hc = frontier_table("5s HAIRCUT VRP&XS -50%", book5_haircut(0.50)) +rows5_hc30, _, _ = frontier_table("5s HAIRCUT VRP&XS -30%", book5_haircut(0.30)) +rows5_excl, bp5_excl, bv5_excl = frontier_table("5s ESCLUDI VRP&XS (renorm)", book5_exclude()) + +P("\n" + "="*118) +P("C) FRONTIERA a 3 LIVELLI — BOOK LIVE 2-SLEEVE (TP01+SKH 75/25, l'unico deployabile a 2-5k)") +P("="*118) +rows2_full, bp2_full, bv2_full = frontier_table("2s FULL (canonico)", book2_full()) +rows2_deluck, _, _ = frontier_table("2s DE-LUCK (mean x0.85)", book2_deluck(0.15)) +rows2_hc50, bp2_hc, bv2_hc = frontier_table("2s HAIRCUT SKH -50%", book2_haircut(0.50)) +rows2_excl, _, _ = frontier_table("2s ESCLUDI SKH -> TP01 puro", book2_exclude()) + +# ---------------------------------------------------------------- B) SYNTHETIC TAIL INJECTION +P("\n" + "="*118) +P("B) INIEZIONE DI CODA SINTETICA — calibrazione su worst-correlato dei singoli sleeve") +P("="*118) +# giorno-crash a livello book = tutti gli sleeve al loro peggior giorno IN CONTEMPORANEA (corr->1 in crisi) +def worst_correlated_day(weights): + tot = sum(weights.values()) + return sum((w / tot) * float(pd.Series(SL[k]).min()) for k, w in weights.items()) +def worst_correlated_week(weights): + tot = sum(weights.values()) + return sum((w / tot) * emp_worst_week(SL[k]) for k, w in weights.items()) + +wc_day5, wc_wk5 = worst_correlated_day(W5), worst_correlated_week(W5) +wc_day2, wc_wk2 = worst_correlated_day(W2), worst_correlated_week(W2) +P(f" book 5-sleeve: worst_day in-sample={float(pd.Series(book5_full()).min()):+.4f} " + f"worst_correlato(iniettato)={wc_day5:+.4f} | worst_wk in-sample={emp_worst_week(book5_full()):+.4f} " + f"worst_wk_correlato={wc_wk5:+.4f}") +P(f" book 2-sleeve: worst_day in-sample={float(pd.Series(book2_full()).min()):+.4f} " + f"worst_correlato(iniettato)={wc_day2:+.4f} | worst_wk in-sample={emp_worst_week(book2_full()):+.4f} " + f"worst_wk_correlato={wc_wk2:+.4f}") +P(" Nota: il worst-correlato assume che in una LUNA/COVID TUTTI gli sleeve colpiscano insieme") +P(" (VRP mai stressato reale, SKH ETH DD sottile, XS 2.5y) -> stress deliberatamente severo.") + +def inject_day(paths, xday): + """Setta UN giorno casuale per path al valore xday (UNSCALED: la leva lo moltiplica).""" + P2 = paths.copy() + col = np.random.default_rng(SEED + 7).integers(0, P2.shape[1], size=P2.shape[0]) + P2[np.arange(P2.shape[0]), col] = xday + return P2 +def inject_week(paths, xday_wk, ndays=5): + """Setta 5 giorni consecutivi per path a xday_wk (giornaliero equivalente della settimana).""" + P2 = paths.copy() + st = np.random.default_rng(SEED + 8).integers(0, P2.shape[1] - ndays, size=P2.shape[0]) + for k in range(ndays): + P2[np.arange(P2.shape[0]), st + k] = xday_wk + return P2 + +def inject_report(name, base_paths, base_vol, xday, xwk_daily): + P(f"\n --- INIEZIONE su {name} (day={xday:+.3f}, week={xwk_daily:+.3f}/g x5) ---") + P(f" {'tvol':>6} {'lam':>5} | {'ruin_base':>9} {'ruin+day':>9} {'ruin+wk':>8} | " + f"{'dd30_base':>9} {'dd30+day':>9} {'dd30+wk':>8}") + pd_day = inject_day(base_paths, xday) + pd_wk = inject_week(base_paths, xwk_daily) + out = [] + for tv in TVOLS: + lam = tv / base_vol + mb, _ = path_maxdd(base_paths * lam) + md, _ = path_maxdd(pd_day * lam) + mw, _ = path_maxdd(pd_wk * lam) + rb, rd, rw = (mb >= RUIN).mean(), (md >= RUIN).mean(), (mw >= RUIN).mean() + db, dd_, dw = (mb >= DDLIM).mean(), (md >= DDLIM).mean(), (mw >= DDLIM).mean() + out.append(dict(tv=tv, ruin_base=float(rb), ruin_day=float(rd), ruin_wk=float(rw), + dd30_base=float(db), dd30_day=float(dd_), dd30_wk=float(dw))) + P(f" {tv:6.3f} {lam:5.2f} | {rb:9.4f} {rd:9.4f} {rw:8.4f} | {db:9.4f} {dd_:9.4f} {dw:8.4f}") + return out + +# week daily-equivalent: distribuisci la settimana-crash su 5 giorni uguali composti +def wk_daily(x_wk): + return (1 + x_wk) ** (1 / 5.0) - 1.0 + +inj5_full = inject_report("5s FULL", bp5_full, bv5_full, wc_day5, wk_daily(wc_wk5)) +inj5_hc = inject_report("5s HAIRCUT", bp5_hc, bv5_hc, wc_day5, wk_daily(wc_wk5)) +inj2_full = inject_report("2s FULL", bp2_full, bv2_full, wc_day2, wk_daily(wc_wk2)) +inj2_hc = inject_report("2s HAIRCUT", bp2_hc, bv2_hc, wc_day2, wk_daily(wc_wk2)) + +# ---------------------------------------------------------------- DELIVERABLE +P("\n" + "="*118) +P("DELIVERABLE — target-vol MASSIMO che sopravvive SIA all'iniezione di coda SIA all'haircut") +P("="*118) +# criterio di confidenza (sul book HAIRCUT, CON iniezione peggiore day/week, orizzonte 5y): +# P(rovina>50%) < 5% AND P(DD>30%) < 20% +RUIN_TOL = 0.05 +DD30_TOL = 0.20 +def max_safe_tvol(inj_rows): + ok = None + for d in inj_rows: + worst_ruin = max(d['ruin_day'], d['ruin_wk']) + worst_dd30 = max(d['dd30_day'], d['dd30_wk']) + if worst_ruin < RUIN_TOL and worst_dd30 < DD30_TOL: + ok = d['tv'] + else: + break + return ok +P(f" criterio: sotto l'iniezione PEGGIORE (day|week), P(rovina>50%)<{RUIN_TOL:.0%} AND P(DD>30%)<{DD30_TOL:.0%}") +P(f" BOOK 5-SLEEVE haircut+iniezione -> target-vol max fidato = {max_safe_tvol(inj5_hc)}") +P(f" BOOK 2-SLEEVE haircut+iniezione -> target-vol max fidato = {max_safe_tvol(inj2_hc)}") +# variante piu' tollerante (rovina<10%) +RUIN_TOL2 = 0.10 +def max_safe_tvol2(inj_rows, rt): + ok = None + for d in inj_rows: + if max(d['ruin_day'], d['ruin_wk']) < rt: + ok = d['tv'] + else: + break + return ok +P(f" [tolleranza P(rovina)<10%] 5-sleeve={max_safe_tvol2(inj5_hc,0.10)} 2-sleeve={max_safe_tvol2(inj2_hc,0.10)}") + +# GAP full-vs-fidata a pari DD: a quale CAGR arrivi a maxDD 6.2%/9.4% con full vs haircut/exclude +def cagr_at_tvol(rows, tv): + for d in rows: + if abs(d['tvol'] - tv) < 1e-9: + return d['cagr'], d['maxdd'] + return None, None +P("\n GAP full-vs-fidata (CAGR a pari target-vol):") +for tv in (0.06, 0.08, 0.10, 0.15): + cf, _ = cagr_at_tvol(rows5_full, tv); ch, _ = cagr_at_tvol(rows5_hc50, tv); ce, _ = cagr_at_tvol(rows5_excl, tv) + P(f" 5s tvol={tv:.3f}: full CAGR={cf:+.4f} haircut50={ch:+.4f} escludi={ce:+.4f} " + f"gap(full-escludi)={cf-ce:+.4f}") +for tv in (0.08, 0.10, 0.15): + cf, _ = cagr_at_tvol(rows2_full, tv); ch, _ = cagr_at_tvol(rows2_hc50, tv); ce, _ = cagr_at_tvol(rows2_excl, tv) + P(f" 2s tvol={tv:.3f}: full CAGR={cf:+.4f} haircutSKH50={ch:+.4f} escludi(TP01)={ce:+.4f} " + f"gap(full-escludi)={cf-ce:+.4f}") + +# ---------------------------------------------------------------- €/giorno a 2k e 5k +P("\n" + "="*118) +P("TRADUZIONE €/GIORNO (CAGR de-luck & haircut, capitale 2k e 5k)") +P("="*118) +def eur_day(cagr, cap): return cap * cagr / 365.0 +for label, rows in [("5s FULL", rows5_full), ("5s DE-LUCK", rows5_deluck), ("5s HAIRCUT50", rows5_hc50), + ("2s FULL", rows2_full), ("2s HAIRCUT50", rows2_hc50), ("2s ESCLUDI(TP01)", rows2_excl)]: + for tv in (0.06, 0.10): + c, _ = cagr_at_tvol(rows, tv) + if c is None: continue + P(f" {label:18s} tvol={tv:.3f}: CAGR={c:+.4f} -> 2k={eur_day(c,2000):+.2f}€/g 5k={eur_day(c,5000):+.2f}€/g") + +# ---------------------------------------------------------------- dump +with open(os.path.join(OUT, 'r0703_frontier_tail_report.txt'), 'w') as f: + f.write("\n".join(rep)) +def rows_to_df(rows): return pd.DataFrame(rows) +with pd.ExcelWriter(os.path.join(OUT, 'r0703_frontier.xlsx')) if False else open(os.devnull,'w'): + pass +for nm, rows in [('5s_full',rows5_full),('5s_deluck',rows5_deluck),('5s_hc50',rows5_hc50), + ('5s_hc30',rows5_hc30),('5s_excl',rows5_excl),('2s_full',rows2_full), + ('2s_deluck',rows2_deluck),('2s_hc50',rows2_hc50),('2s_excl',rows2_excl)]: + rows_to_df(rows).to_csv(os.path.join(OUT, f'r0703_front_{nm}.csv'), index=False) +P("\n[report salvato in scratchpad: r0703_frontier_tail_report.txt + CSV per book/variante]")