#!/usr/bin/env python """r0822_growth_policy.py — LA POLITICA DI LEVA, giudicata sul TEMPO invece che sullo Sharpe. LA DOMANDA. `target_vol=20%` per TP01 e' canonico dal 2026-06-19 e non e' mai stato messo in discussione **rispetto a un obiettivo di crescita**. E' stato scelto (e ri-confermato: peso 75/25 tre volte, TP01xDVOL, tranching) sempre con una lente di **Sharpe** o di **drawdown**. Ma l'operatore non massimizza lo Sharpe: vuole ~$258k netti il prima possibile. Sono due problemi diversi — lo Sharpe e' invariante alla leva, il TEMPO no. Quindi: **quale leva k minimizza il tempo mediano al bersaglio, a parita' di rischio accettato?** E cambia se il bersaglio e' $50k invece di $258k? COSA C'E' QUI (0) Replica di controllo: il book live, il fattore d'ancora ×0.89 misurato, e i muri pubblicati. (1) LA CURVA DI CRESCITA g(k) — dove sta il massimo (Kelly empirico) e da che parte sta il libro. (2) SWEEP PRINCIPALE — per ogni k: tempo mediano al bersaglio (CONDIZIONATO, stampato accanto alla sua probabilita'), P(entro 10/15/20a), P(rovina), P(DD>30%). Versamenti €0 e €500/mese, bersagli $50k e il muro NETTO. Tutto **al netto del fisco d'accumulo** (33% + carry 4a + 0.2%). (3) FRONTIERA ISO-ROVINA — la domanda del brief nella sua forma esatta. (4) IL GATE CHE CONTA — il massimo di (1) e' calcolato su un campione che non contiene un solo giorno peggiore di -3.9%. Stress esplicito: giorno di crash fuori campione + liquidazione Deribit. Dove va il massimo quando il rischio che il dataset NON contiene entra nel conto. (5) ESEGUIBILITA' — `config/live.json` oggi tiene il nozionale lordo <= 1.00x l'equity, e il knob `target_vol` di TP01 NON scala linearmente (leverage cap 2x). Quanto di k e' teorico. ONESTA' (regole del progetto applicate qui): · un non-arrivo e' **+infinito**, mai -1 (l'errore del 07/08: con -1 la mediana MIGLIORA quanto peggio va la colonna); · una mediana condizionata si stampa **sempre** accanto alla sua probabilita'; · un Monte Carlo ha una **risoluzione**: la riga di testa e' rifatta con un secondo seme e la banda e' dichiarata; · la rovina non e' l'azzeramento aritmetico (con ritorni moltiplicativi il capitale non tocca zero finche' k < 1/|peggior giorno| = 25.4): e' definita e dichiarata sotto. nice -n 19 timeout 900 uv run python scripts/research/r0822_growth_policy.py """ from __future__ import annotations import sys from pathlib import Path import numpy as np 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 macchineria validata (book_series, _boot_paths, survival) import r0726_deposits as DP # noqa: E402 deluck_returns() con il ×0.89 MISURATO import r0727_tasse as TX # noqa: E402 CARRY_ANNI, PATRIMONIALE from r0807_piano_netto import passo_fiscale # noqa: E402 il passo fiscale gia' validato # ------------------------------------------------------------------ parametri dichiarati KS = (0.50, 0.75, 1.00, 1.25, 1.50, 2.00, 2.50, 3.50, 5.00, 7.00, 10.00, 14.00, 18.00, 22.00) # 14 celle: la griglia stretta (<=8x) NON conteneva il # massimo -> allargata finche' il massimo e' INTERNO KS_STRESS = (1.00, 1.50, 2.00, 3.50, 5.00, 10.00, 18.00) # sottoinsieme per la (4) DEPOSITI = (0, 500) # €/mese: nessuno, e il piano dichiarato BERSAGLI = {"$50k": 50_000.0, "muro netto": 258_338.0} # r0807_piano_netto (fisco coerente) ANNI = 25 # orizzonte di simulazione: 25 come r0726/r0807 (20 troncherebbe # la coda della mediana condizionata). Le P si leggono a 10/15/20a. N_PATHS = 2_500 BLOCK = 20 CHUNK = 500 # RAM: la VPS ha ~3.5 GB liberi e ci girano altri agenti SEED = 20260822 SEED_BIS = 8221 # secondo seme, solo per dichiarare la risoluzione MC START = 600.0 ALIQUOTA = CC.TAX_RATE # 0.33 PATRIM = TX.PATRIMONIALE # 0.002 RUIN_MULT = 0.20 # rovina = il moltiplicatore del PATH perde l'80% DD_SOGLIA = 0.30 WIPE = 0.90 # perdita giornaliera >= 90% dell'equity = liquidazione (margine+fee) MURO_PUBBL_LORDO = 272_061.0 # r0726_capwall_refresh (convenzione pubblicata) def sezione(t: str) -> None: print("\n" + "=" * 104) print(f" {t}") print("=" * 104) # =========================================================================== # il motore: una passata, tutte le leve insieme (confronto APPAIATO sugli stessi path) # =========================================================================== def simula(r: np.ndarray, ks: tuple[float, ...], *, anni: int, dep_eur: float, bersagli: dict[str, float], aliquota: float, patrimoniale: float, n_paths: int = N_PATHS, seed: int = SEED, start: float = START, crash_pct: float = 0.0, crash_p_anno: float = 0.0) -> dict: """Accumulo a leva `k` per ogni k, sugli STESSI path (differenze appaiate). Stato per (path, k). Traccia in parallelo un GEMELLO SENZA VERSAMENTI: il drawdown e la rovina si misurano su quello, cosi' sono confrontabili fra colonne con versamenti diversi (un bonifico mensile maschera un drawdown senza ridurre il capitale distrutto). `crash_pct`/`crash_p_anno`: giorno di coda FUORI CAMPIONE (sezione 4). Il block bootstrap non puo' generare un giorno peggiore del peggiore osservato: se lo si vuole, va messo a mano. Liquidazione: se la perdita del giorno a leva k supera `WIPE` dell'equity, il conto e' chiuso dall'exchange -> capitale a 0 (i versamenti successivi ripartono da li'). """ kv = np.asarray(ks, float)[None, :] nk = len(ks) dep = dep_eur * CC.EURUSD n_days = int(anni * 365) rng = np.random.default_rng(seed) nomi = list(bersagli) soglie = np.array([bersagli[n] for n in nomi], float) hit = {n: [] for n in nomi} fin, mult_fin, ruin, ddmax, versato_tot = [], [], [], [], 0.0 for c0 in range(0, n_paths, CHUNK): m = min(CHUNK, n_paths - c0) paths = CC._boot_paths(r, m, n_days, BLOCK, rng) if crash_pct > 0 and crash_p_anno > 0: # un giorno di crash con probabilita' `crash_p_anno`/anno, uniforme nel tempo p_giorno = crash_p_anno / 365.0 mask = rng.random((m, n_days)) < p_giorno paths = np.where(mask, -abs(crash_pct), paths) cap = np.full((m, nk), start, float) mult = np.ones((m, nk), float) # gemello senza versamenti hwm = np.ones((m, nk), float) dd = np.zeros((m, nk), float) rovinato = np.zeros((m, nk), bool) colpito = {n: np.full((m, nk), np.inf) for n in nomi} carry = np.zeros((m * nk, TX.CARRY_ANNI)) anno_start = cap.copy() flusso = np.zeros((m, nk)) versato = start for t in range(n_days): rt = paths[:, t][:, None] * kv # (m, nk) liq = rt <= -WIPE g = np.where(liq, -1.0, rt) cap *= (1.0 + g) cap = np.maximum(cap, 0.0) mult *= (1.0 + g) mult = np.maximum(mult, 0.0) hwm = np.maximum(hwm, mult) dd = np.maximum(dd, 1.0 - mult / hwm) rovinato |= mult <= RUIN_MULT if dep > 0 and t % 30 == 0 and t > 0: cap += dep flusso += dep versato += dep for j, n in enumerate(nomi): nuovi = np.isinf(colpito[n]) & (cap >= soglie[j]) colpito[n][nuovi] = t if (t + 1) % 365 == 0 and (aliquota > 0 or patrimoniale > 0): passo_fiscale(cap.ravel(), anno_start.ravel(), flusso.ravel(), carry, aliquota, patrimoniale) anno_start = cap.copy() flusso[:] = 0.0 for n in nomi: hit[n].append(colpito[n]) fin.append(cap) mult_fin.append(mult) ruin.append(rovinato) ddmax.append(dd) versato_tot = versato out = dict(versato=versato_tot, anni=anni, n_paths=n_paths, ks=ks) out["cap"] = np.concatenate(fin, axis=0) out["mult"] = np.concatenate(mult_fin, axis=0) out["ruin"] = np.concatenate(ruin, axis=0) out["dd"] = np.concatenate(ddmax, axis=0) out["hit"] = {n: np.concatenate(hit[n], axis=0) for n in nomi} return out def anni_cond(hit_col: np.ndarray) -> tuple[float, float, float, float]: """(anni mediani CONDIZIONATI all'arrivo, P<=10a, P<=15a, P<=20a). Non-arrivo = +inf.""" arr = hit_col[np.isfinite(hit_col)] med = float(np.median(arr)) / 365.0 if len(arr) >= 50 else float("nan") p = lambda y: float((hit_col <= y * 365).mean()) # noqa: E731 return med, p(10), p(15), p(20) def fmt_anni(a: float) -> str: return f"{a:.1f}a" if np.isfinite(a) else "mai" # =========================================================================== def main() -> None: print("=" * 104) print(" r0822 — GROWTH POLICY: quale leva minimizza il TEMPO al bersaglio, a pari rovina accettata") print("=" * 104) # ------------------------------------------------------------------ (0) sezione("(0) IL DATO — book LIVE (TP01 75 / SKH01 25) e replica di controllo") base = CC.book_series("hourly") r = DP.deluck_returns() print(f"\n serie: {len(base)} giorni, {base.index.min().date()} -> {base.index.max().date()}") print(f" lente 'hourly' = SKH01 sul PATH LIVE (fill al close 5m orario), non il backtest.") print(f" canonico : drift {base.values.mean()*365:>7.2%} vol {base.values.std()*365**0.5:>6.2%}" f" Sharpe {base.values.mean()/base.values.std()*365**0.5:>5.2f}") print(f" de-luckato ×{DP.DELUCK}: drift {r.mean()*365:>7.2%} vol {r.std()*365**0.5:>6.2%}" f" Sharpe {r.mean()/r.std()*365**0.5:>5.2f} <-- questa e' la serie usata ovunque sotto") print(f" peggior giorno {r.min():+.2%} · miglior giorno {r.max():+.2%}") print(f"\n ⚠️ Conseguenza aritmetica da tenere a mente per tutto il resto: con ritorni") print(f" moltiplicativi il capitale NON tocca zero finche' k < 1/{abs(r.min()):.4f} = " f"{1/abs(r.min()):.1f}x.") print(f" Percio' 'rovina' qui e' DEFINITA: il moltiplicatore del path (gemello SENZA") print(f" versamenti) scende sotto {RUIN_MULT:.0%}, cioe' 4 euro su 5 distrutti. E la") print(f" liquidazione dell'exchange e' modellata a parte: perdita giornaliera >= {WIPE:.0%}.") print(f"\n muri pubblicati riusati come bersagli:") print(f" · ${MURO_PUBBL_LORDO:,.0f} (r0726_capwall_refresh, convenzione LORDA)") print(f" · ${BERSAGLI['muro netto']:,.0f} (r0807_piano_netto, fisco COERENTE) <-- usato qui") print(f" · $50.000 (il bersaglio 'corto' chiesto dal brief: ~€19/g di rendita perpetua)") print("\n CONTROLLO DI REPLICA — questo motore contro la tabella pubblicata il 07/08") print(" (`r0807_piano_netto` §2: stesso fisco, stesso bersaglio, stessa convenzione di") print(" mediana condizionata). I path sono diversi: si chiede accordo entro il rumore MC,") print(" non l'identita'. Se questa riga non torna, nessun numero nuovo sarebbe") print(" distinguibile da un bug.") print(f"\n {'EUR/mese':>9}{'anni (pubbl.)':>16}{'P<=20a (pubbl.)':>18}{'anni (qui)':>13}{'P<=20a (qui)':>15}") for dep, a_pub, p_pub in ((250, 19.8, 0.52), (500, 14.7, 0.99)): sr = simula(r, (1.00,), anni=25, dep_eur=dep, bersagli={"muro": BERSAGLI["muro netto"]}, aliquota=ALIQUOTA, patrimoniale=PATRIM, n_paths=3_000, seed=725) a, _, _, p20 = anni_cond(sr["hit"]["muro"][:, 0]) print(f" {dep:>9}{a_pub:>15.1f}a{p_pub:>18.0%}{fmt_anni(a):>13}{p20:>15.0%}") # ------------------------------------------------------------------ (1) sezione("(1) LA CURVA DI CRESCITA g(k) — dove sta il massimo, e DI QUANTO il libro ne dista") print(f"\n Crescita geometrica MEDIANA annua, orizzonte {ANNI} anni, ZERO versamenti,") print(f" {N_PATHS} path, fisco d'accumulo DENTRO. Un vol-target e' esattamente questo:") print(" uno scalare sui pesi. Griglia allargata a 22x perche' a 8x il massimo non era") print(" ancora interno — una griglia che si ferma prima del massimo non lo misura, lo tronca.") s0 = simula(r, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED) s0b = simula(r, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED_BIS) mu, sig = float(r.mean()), float(r.std()) kelly_gauss = mu / sig ** 2 def g_curve(sim: dict) -> list[float]: return [float(np.median(sim["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(sim["mult"].shape[1])] g_med, g_medb = g_curve(s0), g_curve(s0b) jstar = int(np.argmax(g_med)) kbest, gbest = KS[jstar], g_med[jstar] j1 = KS.index(1.00) lg = lambda g: float(np.log1p(g)) # noqa: E731 print(f"\n {'k':>6}{'g mediana/anno':>17}{'(2° seme)':>12}{'ln(1+g)/ln(1+g*)':>19}" f"{'maxDD mediano':>16}{'P(DD>30%)':>11}{'P(rovina)':>11}{'cap mediano a 25a':>20}") for j, k in enumerate(KS): marca = " <-- LIBRO" if j == j1 else (" <-- k*" if j == jstar else "") print(f" {k:>6.2f}{g_med[j]:>17.2%}{g_medb[j]:>12.2%}{lg(g_med[j])/lg(gbest):>19.2f}" f"{np.median(s0['dd'][:, j]):>16.1%}{(s0['dd'][:, j] > DD_SOGLIA).mean():>11.1%}" f"{s0['ruin'][:, j].mean():>11.1%}{np.median(s0['cap'][:, j]):>20,.0f}{marca}") esec = [j for j, k in enumerate(KS) if k <= 2.50] ris = max(abs(a - b) for a, b in zip(g_med, g_medb)) ris_e = max(abs(g_med[j] - g_medb[j]) for j in esec) print(f"\n RISOLUZIONE MONTE CARLO (due semi indipendenti a {N_PATHS} path): scarto massimo") print(f" {ris:.2%}/anno sull'intera griglia, ma solo {ris_e:.2%}/anno nella regione") print(f" k<={KS[esec[-1]]:.2f} — il rumore vive tutto nelle celle a leva alta, dove la mediana e'") print(" presa su una distribuzione enormemente dispersa. Conseguenza da tenere: la") print(" POSIZIONE del massimo non e' risolvibile a questa taglia (vedi sotto), i confronti") print(" nella regione eseguibile si'. Ogni confronto e' APPAIATO (stessi path per ogni k).") print(f"\n RISPOSTA ALLA DOMANDA 'da che parte, e di quanto':") print(f" massimo EMPIRICO della crescita k* = {kbest:.2f}x (g = {gbest:.1%}/anno)") print(f" massimo GAUSSIANO (Kelly, mu/sigma²) k = {kelly_gauss:.1f}x") print(f" il LIBRO gira a k = 1.00x (g = {g_med[j1]:.1%}/anno)") print(f" -> il libro sta a SINISTRA, a {1.0/kbest:.0%} di Kelly empirico, e raccoglie il") print(f" {lg(g_med[j1])/lg(gbest):.0%} della crescita massima (in crescita LOGARITMICA, che e' la") print(" grandezza che Kelly massimizza). NON e' un plateau: e' il ramo ripido.") print(f"\n ⚠️ La POSIZIONE del massimo non e' risolta a questa taglia: la sezione (4) rifa'") print(" la stessa curva con 1500 path e il massimo cade a 10x invece di 14x. La regione") print(" 8-14x e' piatta entro il rumore -> si cita 'k* ~ 10-14x', non un numero.") print(" Cio' che E' risolto, e con margine enorme, e' che 1.00x non ci sta vicino.") print(f"\n ⚠️ PREVISIONE MIA, REFUTATA IN SESSIONE. Mi aspettavo che il massimo empirico") print(f" cadesse molto a SINISTRA di quello gaussiano ({kelly_gauss:.1f}x), perche' il bootstrap a") print(" blocchi conserva code e clustering che la formula ignora. Non succede: i due") print(" coincidono entro il rumore. Il motivo e' informativo e prepara la sezione (4) —") print(" la distribuzione delle CHIUSURE giornaliere di questo libro non ha code abbastanza") print(" spesse da spostare Kelly. Il rischio che conta a leva non e' in quella") print(" distribuzione, quindi non lo si trovera' ricampionandola meglio.") print(f"\n ⚠️ Questo e' il risultato della lente, non una raccomandazione. Tre cose lo") print(" ridimensionano, e sono misurate sotto: (a) k* dipende dal DRIFT, che e' stimato") print(f" su {len(r)/365:.1f} anni (sez. 1-bis); (b) la coda che lo sostiene NON e' nel") print(" campione (sez. 4); (c) meta' della griglia non e' eseguibile (sez. 5).") print(f" ⚠️ E c'e' un controllo di plausibilita' che il modello fallisce da solo: a k={KS[jstar]:g}x") print(f" il capitale mediano a 25 anni partendo da $600 e' ${np.median(s0['cap'][:, jstar]):,.0f}.") print(" Un modello che promette piu' della capitalizzazione del mercato in cui trada ha") print(" lasciato il dominio in cui vuol dire qualcosa: assume che il ritorno sia") print(" indipendente dalla SIZE, il che e' falso ben prima di quella cifra.") # ------------------------------------------------------------------ (1-bis) sezione("(1-bis) QUANTO E' SOLIDO k* — Kelly e' ipersensibile al drift, e il drift e' una STIMA") se_drift = float(r.std() * 365 ** 0.5) / (len(r) / 365) ** 0.5 print(f"\n drift stimato {mu*365:.2%}/anno su {len(r)/365:.1f} anni con vol {r.std()*365**0.5:.2%}") print(f" -> errore standard della MEDIA = vol/sqrt(T) = {se_drift:.2%}/anno.") print(" Kelly va come mu/sigma²: e' LINEARE nell'errore del drift, quindi l'incertezza") print(" sul numeratore si trasferisce intera su k*. Stessa macchineria di `deluck_returns`") print(" (si sottrae una costante al drift, la vol resta quella misurata).") print(f"\n {'ipotesi sul drift':>34}{'drift/anno':>13}{'k* gaussiano':>15}{'k* empirico':>14}" f"{'g(1x)':>9}{'g(k*)':>9}") scen = [("punto (de-luckato ×0.89)", 0.0), ("-1 errore standard", se_drift), ("-2 errori standard", 2 * se_drift), ("drift dimezzato (lente 07/08)", mu * 365 / 2), ("drift a zero", mu * 365)] for nome, taglio in scen: rs = r - taglio / 365.0 mus = float(rs.mean()) kg = mus / sig ** 2 ss = simula(rs, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED, n_paths=1_500) gs = g_curve(ss) js = int(np.argmax(gs)) print(f" {nome:>34}{mus*365:>13.2%}{kg:>15.1f}{KS[js]:>13.2f}x" f"{gs[j1]:>9.1%}{gs[js]:>9.1%}") print("\n Lettura: un errore di stima di UN solo errore standard sul drift — che a 7 anni di") print(" storia e' l'ordine di grandezza normale — sposta k* di parecchie unita'. E' la") print(" ragione classica per cui si opera a una FRAZIONE di Kelly: non prudenza morale, ma") print(" il fatto che k* e' un numero stimato con una barra d'errore larga quanto se stesso.") # ------------------------------------------------------------------ (2) sezione(f"(2) SWEEP PRINCIPALE — tempo al bersaglio, orizzonte {ANNI} anni, AL NETTO del fisco") print(f"\n da ${START:.0f} · {N_PATHS} path · blocchi {BLOCK}g · fisco {ALIQUOTA:.0%} + carry " f"{TX.CARRY_ANNI}a + patrimoniale {PATRIM:.1%}") print(" 'anni' = mediana CONDIZIONATA all'arrivo (non-arrivo = +infinito, MAI -1): si legge") print(" sempre insieme a P<=20a accanto. 'p90' = il decile lento, cioe' il rischio di TEMPO.") print(" ⚠️ Convenzione dichiarata: il fisco e' dentro il CAPITALE (colonne anni/P), NON dentro") print(" il gemello di rischio (colonne DD/rovina). Un drawdown e' un fatto di mercato: metterci") print(" l'imposta dentro sommerebbe due cose diverse in un numero solo.") risultati = {} for dep in DEPOSITI: s = s0 if dep == 0 else simula(r, KS, anni=ANNI, dep_eur=dep, bersagli=BERSAGLI, aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED) risultati[dep] = s for nome in BERSAGLI: print(f"\n --- versamento €{dep}/mese · bersaglio {nome} (${BERSAGLI[nome]:,.0f}) · " f"versato in {ANNI}a ${s['versato']:,.0f} ---") print(f" {'k':>6}{'anni (p50)':>13}{'anni (p90)':>13}{'P<=10a':>9}{'P<=15a':>9}" f"{'P<=20a':>9}{'P(rovina)':>11}{'P(DD>30%)':>11}{'P(DD>50%)':>11}") for j, k in enumerate(KS): h = s["hit"][nome][:, j] a, p10, p15, p20 = anni_cond(h) arr = h[np.isfinite(h)] a90 = float(np.percentile(arr, 90)) / 365.0 if len(arr) >= 50 else float("nan") print(f" {k:>6.2f}{fmt_anni(a):>13}{fmt_anni(a90):>13}{p10:>9.1%}{p15:>9.1%}" f"{p20:>9.1%}{s['ruin'][:, j].mean():>11.1%}" f"{(s['dd'][:, j] > DD_SOGLIA).mean():>11.1%}" f"{(s['dd'][:, j] > 0.50).mean():>11.1%}") # ------------------------------------------------------------------ (3) sezione("(3) FRONTIERA ISO-ROVINA — la domanda del brief nella sua forma esatta") print("\n 'a parita' di probabilita' di rovina accettata': per ogni budget di rischio, la") print(" leva PIU' ALTA che lo rispetta, e il tempo che quel budget compra. Il rischio si") print(" misura sul gemello SENZA versamenti (deposit-independent: un bonifico mensile") print(" maschera un drawdown senza ridurre il capitale distrutto), ed e' la stessa") print(" grandezza su cui passa la regola di DD per-conto del canale funded.") for dep in DEPOSITI: s = risultati[dep] print(f"\n --- versamento €{dep}/mese ---") print(f" {'budget':>8}{'metrica':>13}{'k max':>9}" + "".join(f"{'anni ' + n:>22}" for n in BERSAGLI)) for metrica, col in (("P(DD>30%)", lambda j: (s["dd"][:, j] > DD_SOGLIA).mean()), ("P(rovina)", lambda j: s["ruin"][:, j].mean())): for budget in (0.01, 0.05, 0.10, 0.25, 0.50): amm = [j for j in range(len(KS)) if col(j) <= budget] if not amm: print(f" {budget:>7.0%}{metrica:>13}{'nessuna':>9}") continue j = max(amm) celle = [] for n in BERSAGLI: a, _, _, p20 = anni_cond(s["hit"][n][:, j]) celle.append(f"{fmt_anni(a)} (P20 {p20:.0%})") print(f" {budget:>7.0%}{metrica:>13}{KS[j]:>8.2f}x" + "".join(f"{c:>22}" for c in celle)) print("\n ⚠️ Le due metriche danno risposte MOLTO diverse, e la ragione conta: P(rovina)") print(" (perdere l'80% del moltiplicatore) e' quasi zero fino a leve altissime perche'") print(" il libro non ha mai perso l'80% in nessuna finestra ricampionabile; P(DD>30%)") print(" morde subito. Chi sceglie la leva sul solo azzeramento sceglie sulla metrica") print(" che il dataset non puo' informare — vedi (4).") # ------------------------------------------------------------------ (4) sezione("(4) IL GATE CHE CONTA — k* e' calcolato su un campione senza un solo giorno di crash") print(f"\n Il block bootstrap ricampiona i giorni VISSUTI: non puo' produrre un giorno") print(f" peggiore di {r.min():.2%}. Il libro ha attraversato il 2022 (LUNA/FTX) restando") print(" difensivo — quindi per k=1 la coda del campione e' onesta. Ma a leva il rischio") print(" cambia NATURA: entra la liquidazione, che su una chiusura giornaliera non si vede") print(" (lezione della lente wick accoppiata, 25/07: 'close-only non e' conservativa, e'") print(" CIECA'). Il rischio fuori campione non si stima dal campione: si mette a mano e si") print(" dichiara. Ampiezze scelte a priori sull'unico precedente misurato nel progetto:") print(" il gap-through-stop di SKH01 (sl 2% modellato -> -11/-23% realizzato).") print(f"\n liquidazione se la perdita a leva supera {WIPE:.0%} dell'equity in un giorno.") print(f"\n {'scenario':>22}" + "".join(f"{f'k={k:g}':>10}" for k in KS_STRESS) + f"{' k*':>8}") s0s = simula(r, KS_STRESS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED, n_paths=1_500) g0s = [float(np.median(s0s["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(len(KS_STRESS))] print(f" {'nessuno stress':>22}" + "".join(f"{g:>10.1%}" for g in g0s) + f"{f'{KS_STRESS[int(np.argmax(g0s))]:g}x':>8}") for cpct in (0.10, 0.15, 0.20): for panno in (0.20, 1.00): ss = simula(r, KS_STRESS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED, crash_pct=cpct, crash_p_anno=panno, n_paths=1_500) gg = [float(np.median(ss["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(len(KS_STRESS))] print(f" {f'-{cpct:.0%} ogni {1/panno:.0f}a':>22}" + "".join(f"{g:>10.1%}" for g in gg) + f"{f'{KS_STRESS[int(np.argmax(gg))]:g}x':>8}") print("\n Lettura: basta UN giorno fuori campione all'anno perche' il massimo collassi da") print(" due cifre a poche unita', e il ramo destro della curva e' il primo ad andare a") print(" -100%. La crescita a leva alta e' comprata vendendo la coda che il dataset non") print(" contiene: e' la stessa firma che il gate `implausible_sharpe` cerca (rischio FUORI") print(" dal dataset), applicata alla politica invece che alla strategia.") # ------------------------------------------------------------------ (5) sezione("(5) ESEGUIBILITA' — quanto di questa griglia esiste davvero sul conto") import json cfg = json.loads((ROOT / "config" / "live.json").read_text()) frac = cfg["max_notional_per_asset_frac"] lordo_max = frac * 2 # 2 asset (BTC, ETH) print(f"\n (a) IL CAP DI CONFIG. max_notional_per_asset_frac = {frac} su 2 asset") print(f" -> nozionale LORDO massimo = {lordo_max:.2f}x l'equity. **k > {lordo_max:.2f}x NON e'") print(" eseguibile senza toccare `config/live.json`**, e il 26/07 il progetto ha legato") print(" una conclusione a quel parametro con un test apposta") print(" (`test_leva_massima_da_config_resta_sotto_o_uguale_a_1x`): alzare il cap ROMPE") print(" quel test, che e' esattamente il suo scopo. Quindi 11 delle 14 celle di questa") print(" griglia sono TEORICHE per costruzione.") print(f" min_order ${cfg['min_order_usd']} · disaster-SL {cfg['disaster_sl_pct']:.0%} · " f"cap $ {cfg['max_notional_per_asset_usd']:,} (binding: min(quello, equity×{frac}))") print("\n (b) IL KNOB `target_vol` DI TP01 — ipotesi mia: il leverage cap 2x lo strozza.") from src.data.downloader import load_data # noqa: E402 from src.strategies.trend_portfolio import ( # noqa: E402 CANONICAL, TrendPortfolio, resample_1d, simple_returns) import pandas as pd # noqa: E402 dfs = {a: resample_1d(load_data(a, "1h")) for a in ("BTC", "ETH")} def tp01_knob(tv: float, lev: float) -> tuple[float, float, float]: """(vol realizzata annua, Sharpe, quota di barre incollate al leverage cap) del solo TP01 50/50 BTC-ETH, netto fee, con `target_vol=tv` e `leverage=lev`.""" serie, atcap = {}, [] for a, df in dfs.items(): tp = TrendPortfolio(**{**CANONICAL, "target_vol": tv, "leverage": lev}) tgt = np.nan_to_num(np.asarray(tp.target_series(df), float)) ret = simple_returns(df["close"].values.astype(float)) pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1] net = pos * ret - 0.0005 * np.abs(np.diff(pos, prepend=0.0)) net[0] = 0.0 serie[a] = pd.Series(net, index=pd.to_datetime(df["datetime"])) atcap.append(float((np.abs(tgt) >= lev - 1e-9).mean())) J = pd.concat(serie, axis=1, join="inner").fillna(0.0) # le due serie hanno lunghezze x = (0.5 * J["BTC"] + 0.5 * J["ETH"]).values # diverse: si allineano sulle date return (float(x.std() * 365 ** 0.5), float(x.mean() / x.std() * 365 ** 0.5), float(np.mean(atcap))) rif = tp01_knob(0.20, 2.0)[0] # il canonico, calcolato PRIMA di usarlo come riferimento print(f"\n {'target_vol':>11}{'lev cap':>9}{'x NOMINALE':>12}{'vol realizz.':>14}" f"{'x OTTENUTO':>12}{'Sharpe':>9}{'quota barre al cap':>21}") for lev in (2.0, 4.0): for tv in (0.10, 0.20, 0.30, 0.40, 0.60): v, sh, ac = tp01_knob(tv, lev) print(f" {tv:>11.0%}{lev:>9.1f}{tv/0.20:>12.2f}{v:>14.2%}{v/rif:>12.2f}{sh:>9.2f}" f"{ac:>21.1%}") v20, sh20, _ = tp01_knob(0.20, 2.0) print(f"\n ❌ IPOTESI REFUTATA, e la refutazione e' piu' utile dell'ipotesi. 'x OTTENUTO'") print(" segue 'x NOMINALE' fino a target_vol 60% (3.00 nominale -> 2.98 ottenuto) e le") print(" barre incollate al cap sono l'1.0%: **il leverage cap 2x NON e' binding**, quindi") print(" il knob e' uno scalare lineare pulito. Lo Sharpe e' invariante a ogni cella") print(f" (1.31 ovunque) — che e' esattamente il punto: la lente con cui `target_vol=20%`") print(" fu scelto NON puo' distinguere queste righe, e infatti non le distingue.") print(f"\n ⚠️ Sottoprodotto da registrare: a `target_vol=20%` la vol REALIZZATA di TP01 e'") print(f" {v20:.2%}, non 20%. Il target vale sulla posizione quando c'e'; TP01 e' long-flat e") print(" sta flat una parte del tempo, quindi l'etichetta sovrastima il rischio preso di") print(" ~40%. Chi legge '20%' e lo confronta con un altro sistema sta confrontando un") print(" parametro con una misura.") print("\n Cio' che resta binding e' altro: (i) il cap di nozionale di config, punto (a);") print(" (ii) SKH01 (25% del libro) NON e' vol-targeted affatto (misurato 26/07) — li'") print(" l'unico modo di alzare la leva e' moltiplicare la size, cioe' margine e") print(" liquidazione vere, non un parametro di sizing.") print("\n (c) COSA COSTA LA LEVA E NON E' NEL MODELLO SOPRA (tutti a sfavore di k alto):") print(" · la fee scala LINEARMENTE con k (3,5 bps/lato oggi) e SKH01 e' ~4x piu'") print(" fee-sensibile di TP01 (curva misurata il 26/07: -0.017 Sharpe/bps di book);") print(" · il funding dei perp e il costo del margine oltre 1x non sono modellati;") print(" · il min_order $5 su un conto da $635 e la granularita' che ne consegue;") print(" · l'impatto: il modello assume ritorno indipendente dalla size (vedi sez. 1).") print(" -> i numeri a k>1 sono un TETTO, non una stima.") # ------------------------------------------------------------------ verdetto sezione("VERDETTO") s500 = risultati[500] jj = {k: KS.index(k) for k in (1.00, 1.50, 2.00)} righe = [] for nome in BERSAGLI: for k, j in jj.items(): a, _, _, p20 = anni_cond(s500["hit"][nome][:, j]) righe.append((nome, k, a, p20, (s500["dd"][:, j] > DD_SOGLIA).mean())) print(f"\n A €500/mese (il piano dichiarato), zona ESEGUIBILE e primo passo oltre:") print(f" {'bersaglio':>14}{'k':>7}{'anni (p50)':>13}{'P<=20a':>9}{'P(DD>30%)':>11}") for nome, k, a, p20, pdd in righe: print(f" {nome:>14}{k:>6.2f}x{fmt_anni(a):>13}{p20:>9.0%}{pdd:>11.0%}") print(f""" GATE — cosa e' girato e cosa no (un gate non girato si dichiara, non si omette): · `marginal_vs_tp01` NON APPLICABILE. Non c'e' un flusso di ritorni nuovo da giudicare marginalmente: k e' uno SCALARE sul libro esistente, quindi la correlazione col baseline e' 1.00 per costruzione. · `deflated_sharpe` NON APPLICABILE, e vale la pena dire perche': lo Sharpe e' INVARIANTE a k (misurato, sez. 5b: 1.31 a ogni cella del knob). Un gate costruito sullo Sharpe da' lo stesso verdetto su tutta la griglia — non e' che lo passi, e' che non vede la variabile. · null del de-levering NON e' un gate da superare qui: e' L'OGGETTO della misura, percorso in entrambi i versi (k<1 e k>1). E lo conferma: ogni 'meno DD' via leva e' de-levering per definizione, ogni 'piu' crescita' e' leva. · fortuna d'ancora GESTITA col fattore MISURATO ×{DP.DELUCK} sul drift (26/07), non con una banda nuova: la leva non ha una propria ancora, moltiplica il drift. · `implausible_sharpe` Non girato come funzione (darebbe lo stesso verdetto a ogni k, vedi sopra); il controllo di plausibilita' e' stato fatto sull'OUTPUT ed e' FALLITO dal modello a leva alta: capitale mediano ${np.median(s0['cap'][:, jstar]):,.0f} da $600 in 25 anni. La firma 'rischio fuori dal dataset' c'e', ma vive nella POLITICA, non nella strategia. · causalita' Ereditata: la serie viene da `CC.book_series('hourly')`, macchineria gia' validata; qui non si costruisce nessun segnale nuovo. · risoluzione MC DICHIARATA: {ris_e:.2%}/anno nella regione eseguibile, {ris:.2%}/anno sulla griglia intera. La posizione del massimo NON e' risolta. 1. IL LIBRO STA A SINISTRA DEL MASSIMO, E DI MOLTO: k* empirico ~10-14x (banda, non un numero: a 1500 path cade a 10x, a 2500 a {kbest:.0f}x), il libro gira a 1x = {1.0/kbest:.0%} di Kelly e raccoglie il {lg(g_med[j1])/lg(gbest):.0%} della crescita massima in log. Non e' un plateau (come lo era il peso 75/25): e' il ramo ripido della curva. Sulla lente della CRESCITA la scelta canonica `target_vol=20%` non e' ottimale — ed e' un fatto, perche' quella scelta fu fatta su una lente di Sharpe, che alla leva e' INVARIANTE. 2. MA k* NON E' UN NUMERO SU CUI SI PUO' AGIRE, per tre ragioni misurate qui: (a) e' lineare nell'errore del drift, stimato su {len(r)/365:.1f} anni (sez. 1-bis); (b) e' comprato vendendo una coda che il dataset NON contiene — un giorno di crash all'anno lo riporta a poche unita' (sez. 4); (c) 11 celle su 14 non sono eseguibili: il cap di config tiene il nozionale lordo a {lordo_max:.2f}x l'equity. (Il knob `target_vol` invece FUNZIONA — misurato, sez. 5b: il leverage cap 2x non morde. L'ostacolo e' la config e SKH01, non il sizing di TP01.) 3. IL BERSAGLIO CAMBIA LA RISPOSTA, e nel verso opposto a quello che la domanda suggerisce. Su $50k a €500/mese la leva quasi non serve: a k=1 ci si arriva in ~5 anni con P=100%, perche' fino a quella taglia il capitale lo portano i BONIFICI, non il rendimento (misura indipendente del 26/07: al traguardo l'80% viene dal rendimento, ma i primi anni no). Sul muro netto la leva morde: e' proprio dove il rischio di rovina diventa non-recuperabile, perche' zero e' assorbente e si ricomincia con lo stesso stipendio. 4. IL BUCO STRUTTURALE, che e' il risultato piu' trasferibile: **ogni gate di questo progetto e' invariante alla leva**. `study_family_honest`, il deflated-Sharpe, `marginal_vs_tp01`, `implausible_sharpe`, il null del de-levering, `weights_tilt_null` — tutti giudicano forma della distribuzione, correlazioni o PESI RELATIVI, e nessuno vede la SCALA. Per questo `target_vol=20%` e' sopravvissuto due mesi di scrutinio senza mai essere esaminato: non c'era una lente che potesse bocciarlo. 5. COSA RESTA DI OPERATIVO: nulla da cambiare oggi. Il gradino ESEGUIBILE (k da 1.00x a 1.25-1.50x, cioe' alzare il cap di config) esiste e non e' rumore MC, ma passa per una modifica a un file di produzione su un conto con soldi veri, e nessun gate del progetto lo autorizza: `weights_tilt_null` giudica i PESI, non la scala, e non esiste un gate 'politica di leva'. Il candidato naturale sarebbe misurare la stessa curva sul canale FUNDED, dove il vincolo binding e' gia' una regola di DD per-conto e il nozionale non e' il proprio. """) if __name__ == "__main__": main()