#!/usr/bin/env python """r0726_decadimento.py — "dai per scontato che non perdiamo mai con i trade?" L'obiezione dell'operatore, presa sul serio. Si divide in due parti che hanno risposte OPPOSTE. PARTE 1 — LE PERDITE SONO NEL MODELLO. Il block bootstrap ricampiona i ritorni REALI del book (blocchi da 20 giorni, quindi conserva l'autocorrelazione e la forma dei drawdown): giorni negativi, mesi negativi e drawdown ci sono tutti. Qui si mostrano espliciti, invece di lasciarlo assumere. PARTE 2 — MA C'E' UN'ASSUNZIONE OTTIMISTA, ED E' UN'ALTRA. Il bootstrap assume che **il futuro sia il passato rimescolato**: che l'edge continui a esistere, alla stessa taglia, per vent'anni. Questo NON e' misurato da nessuna parte del progetto, ed e' l'ipotesi piu' forte di tutto il piano. Tre cose che il modello NON contiene: (a) DECADIMENTO dell'alpha — il TSMOM e' pubblicato dagli anni '80 e in molti mercati si e' eroso; il modello lo tiene costante; (b) ROTTURA DI REGIME — 7 anni di storia crypto contengono DUE tori. Un decennio piatto non c'e' mai stato nel campione, quindi il bootstrap non lo puo' estrarre; (c) MORTE della strategia — il caso in cui semplicemente smette di funzionare. Qui si misura il costo di ognuna. uv run python scripts/research/r0726_decadimento.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 import r0726_capwall_refresh as WR # noqa: E402 import r0726_deposits as DP # noqa: E402 START = 596.92 + 5_000 * CC.EURUSD # il piano dichiarato: conto + €5.000 DEP = 500.0 * CC.EURUSD N_PATHS = 5000 BLOCK = 20 YEARS = 20 SEED = 20260726 def maxdd(equity: np.ndarray) -> np.ndarray: """Max drawdown per path su una matrice (path x tempo) di equity.""" peak = np.maximum.accumulate(equity, axis=1) return (1.0 - equity / peak).max(axis=1) def simulate(paths: np.ndarray, wall: float, decay_years: float | None = None, dead_from: float | None = None, drift_mult: float = 1.0) -> dict: """Traiettoria con versamenti, opzionalmente con alpha che decade o che muore. decay_years: il DRIFT decade linearmente a zero in tanti anni (la vol resta). dead_from: dall'anno indicato il book rende zero (resta solo il versamento). drift_mult: moltiplicatore costante sul drift (per lo scenario 'meta' edge'). """ n_paths, n_days = paths.shape mu = float(paths.mean()) cap = np.full(n_paths, START) hit = np.full(n_paths, -1, int) eq = np.empty((n_paths, n_days)) for t in range(n_days): yr = t / 365.0 r = paths[:, t] if drift_mult != 1.0: r = r - (1.0 - drift_mult) * mu if decay_years is not None: k = max(0.0, 1.0 - yr / decay_years) # 1 -> 0 in decay_years r = r - (1.0 - k) * mu if dead_from is not None and yr >= dead_from: r = np.zeros_like(r) cap = cap * (1.0 + r) if t % 30 == 0 and t > 0: cap += DEP eq[:, t] = cap newly = (hit < 0) & (cap >= wall) hit[newly] = t yrs = np.where(hit >= 0, hit / 365.0, np.nan) return dict(cap=cap, yrs=yrs, eq=eq) def main() -> None: print("=" * 100) print(' "dai per scontato che non perdiamo mai con i trade?"') print("=" * 100) r = DP.deluck_returns() gross = CC.TARGET_EUR_DAY * 365 * CC.EURUSD / (1 - CC.TAX_RATE) _, perp, wall = WR.perp_and_wall(r, 1.0, gross) # ---------------------------------------------------------------- parte 1 print("\n" + "=" * 100) print(" PARTE 1 — QUANTO SI PERDE, NEL MODELLO CHE HO GIA' USATO") print("=" * 100) # ⚠️ Le statistiche DISTRIBUTIVE vanno lette sulla serie GREZZA. Il de-luck sottrae una # costante a OGNI giorno (e' cosi' che riduce il drift lasciando la vol invariata, che e' # esattamente cio' che la misura d'ancora del 26/07 dice di fare): questo trasforma i giorni # FLAT in piccoli negativi, e "% di giorni in perdita" sulla serie de-luckata sale dal 36% al # 64% senza che sia successo niente. Giusto per il drift, fuorviante per la forma. raw = CC.book_series("hourly").values.astype(float) neg_d, zero_d = float((raw < 0).mean()), float((raw == 0).mean()) print(f"\n Sulla serie REALE del book, {len(raw):,} giorni (statistiche di FORMA -> serie") print(f" grezza, non de-luckata: vedi nota nel codice):") print(f" giorni in perdita : {neg_d:.1%}") print(f" giorni flat (book fuori) : {zero_d:.1%}") print(f" giorni in guadagno : {float((raw > 0).mean()):.1%}") print(f" giorno peggiore : {raw.min()*100:+.2f}%") print(f" peggior mese (30g rolling) : " f"{min(np.prod(1+raw[i:i+30])-1 for i in range(len(raw)-30))*100:+.2f}%") rng = np.random.default_rng(SEED) n_days = 365 * YEARS paths = CC._boot_paths(r, N_PATHS, n_days, BLOCK, rng) base = simulate(paths, wall) dd = maxdd(base["eq"]) print(f"\n Sui {N_PATHS:,} percorsi simulati (20 anni ciascuno):") print(f" drawdown massimo mediano {np.median(dd):.1%} p90 {np.percentile(dd,90):.1%}" f" PEGGIORE {dd.max():.1%}") # anni in perdita: quota di anni-calendario chiusi sotto l'anno precedente ann = base["eq"][:, ::365] yr_ret = ann[:, 1:] / ann[:, :-1] - 1.0 print(f" anni-calendario in perdita : {float((yr_ret < 0).mean()):.1%} " f"(su {yr_ret.size:,} anni simulati)") # il peggior percorso worst = int(np.argmin(base["cap"])) print(f" percorso PEGGIORE dei {N_PATHS:,}: capitale finale ${base['cap'][worst]:,.0f} " f"contro ${DP.START + 0:,.0f}+versamenti = ${START + DEP*12*YEARS:,.0f} messi dentro") print(f"\n -> No, il modello non assume che non si perda: perde nel {neg_d:.0%} dei giorni,") print(f" ha drawdown mediani del {np.median(dd):.0%} (peggiore {dd.max():.0%}) e anni") print(f" negativi nel {float((yr_ret < 0).mean()):.0%} dei casi.") print(f" Cio' che il modello NON contiene e' un'altra cosa -> parte 2.") # ---------------------------------------------------------------- parte 2 print("\n" + "=" * 100) print(" PARTE 2 — L'ASSUNZIONE OTTIMISTA C'E', MA E' UN'ALTRA") print("=" * 100) print("\n Il bootstrap ricampiona il PASSATO: assume che l'edge esista ancora, alla stessa") print(" taglia, per vent'anni. Questo non e' misurato da nessuna parte. Quanto costa se e'") print(" falso:") scen = [ ("edge intatto (cio' che ho mostrato finora)", dict()), ("edge DIMEZZATO da subito", dict(drift_mult=0.5)), ("edge che decade a zero in 20 anni", dict(decay_years=20.0)), ("edge che decade a zero in 10 anni", dict(decay_years=10.0)), ("edge MORTO dall'anno 10 (rende 0)", dict(dead_from=10.0)), ("edge MORTO dall'anno 5", dict(dead_from=5.0)), ] print(f"\n {'scenario':<42}{'mediana traguardo':>19}{'P(entro 20a)':>14}{'cap. mediano 20a':>19}") for lab, kw in scen: s = simulate(paths, wall, **kw) f = s["yrs"][~np.isnan(s["yrs"])] med = f"{np.percentile(f,50):.1f}a" if len(f) > N_PATHS * 0.5 else "oltre 20a" print(f" {lab:<42}{med:>19}{float((s['yrs'] >= 0).mean() if False else (~np.isnan(s['yrs'])).mean()):>13.1%}" f"${np.median(s['cap']):>18,.0f}") print("\n Lettura: il piano NON e' fragile a un dimezzamento dell'edge (traguardo da ~11.6 a") print(" ~15 anni), ma lo e' alla MORTE dell'edge. E la differenza fra i due casi non e'") print(" misurabile in anticipo: e' la ragione per cui il progetto ha gate pre-registrati") print(" (kill di DVOLSPREAD, decisione XSR01) invece di aspettarsi che le cose funzionino.") # ---------------------------------------------------------------- il peggior decennio print("\n" + "=" * 100) print(" E SE I PROSSIMI 10 ANNI FOSSERO IL PEGGIOR TRATTO DELLA STORIA?") print("=" * 100) # peggior finestra contigua di 2 anni nella serie reale, ricampionata a blocchi W = 365 * 2 if len(r) > W: cums = np.array([np.prod(1 + r[i:i + W]) - 1 for i in range(0, len(r) - W, 30)]) i_bad = int(np.argmin(cums)) * 30 bad = r[i_bad:i_bad + W] print(f"\n Peggior biennio del campione: rendimento {cums.min()*100:+.1f}% " f"({len(bad)} giorni)") rng2 = np.random.default_rng(SEED + 5) bad_paths = CC._boot_paths(bad, N_PATHS, n_days, BLOCK, rng2) sb = simulate(bad_paths, wall) f = sb["yrs"][~np.isnan(sb["yrs"])] print(f" Se TUTTI i 20 anni fossero fatti cosi':") print(f" P(traguardo entro 20a) {(~np.isnan(sb['yrs'])).mean():>6.1%} " f"capitale mediano ${np.median(sb['cap']):>12,.0f} " f"(versati ${START + DEP*12*YEARS:,.0f})") print(f"\n ⚠️ Questo NON e' il caso peggiore possibile: e' il peggiore CHE SIA GIA'") print(f" SUCCESSO. Un regime peggiore di qualunque cosa nel campione non e'") print(f" simulabile ricampionando il campione — e' il limite strutturale del") print(f" metodo, non un parametro da alzare.") if __name__ == "__main__": main()