1296fa2d96
Obiezione dell'operatore, presa sul serio. Due parti con risposte opposte. PARTE 1 (infondata): le perdite SONO nel modello. Il block bootstrap ricampiona i ritorni reali a blocchi di 20 giorni e conserva la forma dei drawdown. Serie reale: 35.6% giorni in perdita, 27.5% flat, 36.9% in guadagno; giorno peggiore -3.94%, peggior mese -5.76%. Sui 5.000 percorsi: maxDD mediano 14.4%, p90 19.8%, PEGGIORE 44.2%; anni-calendario in perdita 5.8% su 95.000 anni simulati. ⚠️ ERRORE MIO CATTURATO PRIMA DI PUBBLICARE: la prima stesura diceva 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. REGOLA: una correzione uniforme sul drift e' giusta per le domande sul drift e sbagliata per quelle sulla distribuzione — la stessa serie dice 36% o 64% a seconda di quale si guarda, senza che sia cambiato niente. PARTE 2 (coglie il punto): l'assunzione ottimista c'e' ed e' che l'EDGE CONTINUI A ESISTERE per vent'anni. Il bootstrap assume che il futuro sia il passato rimescolato; nessuna parte del progetto misura il decadimento dell'alpha. Misurato ora: edge intatto -> 11.6a, P(20a) 99.9% edge dimezzato -> 15.8a, P 76.6% decade a zero in 20a -> 14.3a, P 65.5% decade a zero in 10a -> oltre 20a, P 18.4% morto dall'anno 10 -> oltre 20a, P 42.0% morto dall'anno 5 -> oltre 20a, P 0.0% Il piano NON e' fragile a un dimezzamento dell'edge, lo e' alla sua morte. E i due casi non sono distinguibili in anticipo: e' la ragione per cui esistono i gate pre-registrati. IL LIMITE STRUTTURALE: il peggior BIENNIO dell'intero campione e' +3.0%, cioe' POSITIVO. Sette anni di storia crypto contengono due tori: un decennio davvero brutto non e' mai successo, quindi il bootstrap non lo puo' estrarre. Se i 20 anni fossero tutti come quel biennio, P(traguardo) 2.9% e capitale finale $149.014 contro $136.847 versati — il piano non fallirebbe, semplicemente non renderebbe. Non e' un parametro da alzare: non si puo' simulare un regime peggiore di qualunque cosa ci sia nel campione. Book, pesi, piano INVARIATI. Cambia cosa si sorveglia. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
9.3 KiB
Python
193 lines
9.3 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):")
|
|
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()
|