95f7fb30a3
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>
198 lines
9.8 KiB
Python
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()
|