diff --git a/docs/diary/2026-07-26-versamenti.md b/docs/diary/2026-07-26-versamenti.md index 15e8041..2af8712 100644 --- a/docs/diary/2026-07-26-versamenti.md +++ b/docs/diary/2026-07-26-versamenti.md @@ -293,3 +293,73 @@ configurazione.** Qui lo stesso numero era sbagliato in un verso prima del depos dopo: la soluzione non era scegliere il valore giusto, era **legarlo alla grandezza che lo determina**. Il segnale che serviva questa correzione era gia' visibile: l'azione manuale era stata pre-registrata 24 giorni prima e non era mai stata eseguita. + +--- + +# Addendum 2 — "dai per scontato che non perdiamo mai con i trade?" + +Obiezione dell'operatore. Si divide in due parti che hanno risposte **opposte**: +la prima e' infondata, la seconda coglie l'ipotesi piu' fragile di tutto il piano. +Script `r0726_decadimento.py`. + +## Parte 1 — no: le perdite sono nel modello + +Il block bootstrap ricampiona i ritorni **reali** del book a blocchi di 20 giorni, quindi conserva +autocorrelazione e forma dei drawdown. + +``` +serie reale (2.692 giorni): in perdita 35.6% · flat 27.5% · in guadagno 36.9% + giorno peggiore -3.94% · peggior mese -5.76% +5.000 percorsi simulati (20a): maxDD mediano 14.4% · p90 19.8% · PEGGIORE 44.2% + anni-calendario in perdita 5.8% (su 95.000 anni) +``` + +⚠️ **Errore mio catturato scrivendo questo**: la prima stesura riportava **63.9%** di giorni in +perdita. Artefatto: il de-luck sottrae una costante a *ogni* giorno (e' cosi' che riduce il drift +lasciando la vol invariata, come prescrive la misura d'ancora), quindi **trasforma il 27.5% di +giorni flat in piccoli negativi**. Corretto per il drift, fuorviante per la forma. +**REGOLA: una correzione uniforme sul drift e' giusta per le domande sul drift e sbagliata per le +domande sulla distribuzione** — la stessa serie dice 36% o 64% di giorni in perdita a seconda di +quale delle due si guarda, senza che sia cambiato niente. + +## Parte 2 — si', ma l'assunzione e' un'altra: che l'edge continui a esistere + +Il bootstrap assume che **il futuro sia il passato rimescolato**. Nessuna parte del progetto misura +il decadimento dell'alpha. Quanto costa se e' falso (piano €5.000 + €500/mese): + +``` +scenario mediana traguardo P(entro 20a) cap. mediano 20a +edge intatto (cio' che ho mostrato finora) 11.6a 99.9% $1,115,643 +edge DIMEZZATO da subito 15.8a 76.6% $342,224 +edge che decade a zero in 20 anni 14.3a 65.5% $269,970 +edge che decade a zero in 10 anni oltre 20a 18.4% $157,856 +edge MORTO dall'anno 10 (rende 0) oltre 20a 42.0% $259,553 +edge MORTO dall'anno 5 oltre 20a 0.0% $163,377 +``` + +**Il piano NON e' fragile a un dimezzamento dell'edge** (11.6 → 15.8 anni, P ancora 77%). **Lo e' +alla morte dell'edge.** E la differenza fra i due casi non e' distinguibile in anticipo — e' la +ragione per cui il progetto ha gate pre-registrati con date e soglie invece di aspettarsi che le +cose continuino a funzionare. + +## Il limite strutturale del metodo + +``` +Peggior BIENNIO dell'intero campione: +3.0% +Se tutti i 20 anni fossero fatti cosi': P(traguardo) 2.9%, capitale mediano $149.014 + (a fronte di $136.847 versati) +``` + +**Il peggior biennio della storia del book e' POSITIVO.** Sette anni di storia crypto contengono +due mercati toro: un decennio davvero brutto **non e' mai successo**, quindi il bootstrap non lo +puo' estrarre. Non e' un parametro da alzare, e' il limite del ricampionamento: **non si puo' +simulare un regime peggiore di qualunque cosa ci sia nel campione.** + +Se i prossimi 20 anni somigliassero al peggior biennio gia' visto, il capitale finirebbe **appena +sopra i soldi versati**: il piano non fallirebbe, semplicemente non renderebbe. + +## Cosa se ne fa + +Nessun cambio a book, pesi o piano. Cambia **cosa si sorveglia**: la domanda "l'edge c'e' ancora?" +non ha una risposta nel backtest, ce l'ha solo nel forward — che e' esattamente cio' che i monitor +paper e i gate pre-registrati esistono per misurare. diff --git a/scripts/research/r0726_decadimento.py b/scripts/research/r0726_decadimento.py new file mode 100644 index 0000000..38c8e73 --- /dev/null +++ b/scripts/research/r0726_decadimento.py @@ -0,0 +1,192 @@ +#!/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()