Files
Adriano Dal Pastro 95f7fb30a3 fix: il massimo di una simulazione non e' una statistica della strategia
L'operatore ha contestato un numero che avevo citato io ("anno migliore +184%"). Aveva
ragione, e l'errore era di METODO, non di calcolo.

IL NUMERO E' VERO. Tracciato l'anno estremo: 18 blocchi da 20 giorni, nessuno
significativamente negativo (+20% +15% +12% +10% +8% ... -1%). La popolazione lo consente:
blocchi 20g reali con mediana -0.1%, p95 +8.5%, max +22.4%, skew +2.15 — firma classica di
un trend-follower. E la materia prima esiste: la miglior finestra 365g REALE e' +89.8%.

MA CITARLO ERA SBAGLIATO, perche' il massimo di N estrazioni cresce con N:
  su   1.000 anni simulati -> max  +96.0%
  su   5.000               -> max +135.2%
  su 114.000               -> max +184.3%
Il numero che avevo dato parlava del mio N_PATHS, non del book. Con 1.000 percorsi avrei
scritto +96% per la stessa identica strategia.

I NUMERI CORRETTI SONO I PERCENTILI: p1 -11.9% · p5 -5.5% · MEDIANA +16.0% · p95 +51.2% ·
p99 +71.8%. Vale simmetricamente per il "peggiore -27.4%", anch'esso minimo campionario (a
1.000 percorsi era -19.1%): la coda sinistra onesta e' p1 = -11.9%.

CABLATO: r0726_decadimento.py aveva lo stesso difetto ("drawdown PEGGIORE 44.2%") -> ora
stampa p99 = 26.0% e dichiara che il 44.2% e' un massimo campionario da non citare come
"il caso peggiore".

REGOLA: il massimo (o il minimo) di una simulazione e' una statistica del NUMERO DI
SIMULAZIONI, non della strategia. Si citano i percentili. Stessa famiglia dell'errore gia'
codificato oggi ("un percentile stampato a 0 decimali mente esattamente agli estremi"):
gli estremi sono dove i numeri sembrano piu' informativi e lo sono meno.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 21:49:45 +00:00

198 lines
9.8 KiB
Python

#!/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):")
# ⚠️ Il MASSIMO di N estrazioni cresce con N: descrive la taglia della simulazione, non la
# strategia. Si citano i PERCENTILI, che non dipendono dal numero di percorsi. (Lezione del
# 26/07: avevo citato "anno migliore +184%" che a 1.000 percorsi sarebbe stato +96%.)
print(f" drawdown massimo mediano {np.median(dd):.1%} p90 {np.percentile(dd,90):.1%}"
f" p99 {np.percentile(dd,99):.1%}")
print(f" (il PEGGIORE dei {N_PATHS:,} percorsi e' {dd.max():.1%}, ma e' un massimo campionario:")
print(f" cresce col numero di simulazioni e non va citato come 'il caso peggiore')")
# 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%} (p99 {np.percentile(dd,99):.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()