Files

565 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""r0822_growth_policy.py — LA POLITICA DI LEVA, giudicata sul TEMPO invece che sullo Sharpe.
LA DOMANDA. `target_vol=20%` per TP01 e' canonico dal 2026-06-19 e non e' mai stato messo in
discussione **rispetto a un obiettivo di crescita**. E' stato scelto (e ri-confermato: peso 75/25
tre volte, TP01xDVOL, tranching) sempre con una lente di **Sharpe** o di **drawdown**. Ma
l'operatore non massimizza lo Sharpe: vuole ~$258k netti il prima possibile. Sono due problemi
diversi — lo Sharpe e' invariante alla leva, il TEMPO no.
Quindi: **quale leva k minimizza il tempo mediano al bersaglio, a parita' di rischio accettato?**
E cambia se il bersaglio e' $50k invece di $258k?
COSA C'E' QUI
(0) Replica di controllo: il book live, il fattore d'ancora ×0.89 misurato, e i muri pubblicati.
(1) LA CURVA DI CRESCITA g(k) — dove sta il massimo (Kelly empirico) e da che parte sta il libro.
(2) SWEEP PRINCIPALE — per ogni k: tempo mediano al bersaglio (CONDIZIONATO, stampato accanto
alla sua probabilita'), P(entro 10/15/20a), P(rovina), P(DD>30%). Versamenti €0 e €500/mese,
bersagli $50k e il muro NETTO. Tutto **al netto del fisco d'accumulo** (33% + carry 4a + 0.2%).
(3) FRONTIERA ISO-ROVINA — la domanda del brief nella sua forma esatta.
(4) IL GATE CHE CONTA — il massimo di (1) e' calcolato su un campione che non contiene un solo
giorno peggiore di -3.9%. Stress esplicito: giorno di crash fuori campione + liquidazione
Deribit. Dove va il massimo quando il rischio che il dataset NON contiene entra nel conto.
(5) ESEGUIBILITA' — `config/live.json` oggi tiene il nozionale lordo <= 1.00x l'equity, e il
knob `target_vol` di TP01 NON scala linearmente (leverage cap 2x). Quanto di k e' teorico.
ONESTA' (regole del progetto applicate qui):
· un non-arrivo e' **+infinito**, mai -1 (l'errore del 07/08: con -1 la mediana MIGLIORA quanto
peggio va la colonna);
· una mediana condizionata si stampa **sempre** accanto alla sua probabilita';
· un Monte Carlo ha una **risoluzione**: la riga di testa e' rifatta con un secondo seme e la
banda e' dichiarata;
· la rovina non e' l'azzeramento aritmetico (con ritorni moltiplicativi il capitale non tocca
zero finche' k < 1/|peggior giorno| = 25.4): e' definita e dichiarata sotto.
nice -n 19 timeout 900 uv run python scripts/research/r0822_growth_policy.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 macchineria validata (book_series, _boot_paths, survival)
import r0726_deposits as DP # noqa: E402 deluck_returns() con il ×0.89 MISURATO
import r0727_tasse as TX # noqa: E402 CARRY_ANNI, PATRIMONIALE
from r0807_piano_netto import passo_fiscale # noqa: E402 il passo fiscale gia' validato
# ------------------------------------------------------------------ parametri dichiarati
KS = (0.50, 0.75, 1.00, 1.25, 1.50, 2.00, 2.50, 3.50, 5.00, 7.00,
10.00, 14.00, 18.00, 22.00) # 14 celle: la griglia stretta (<=8x) NON conteneva il
# massimo -> allargata finche' il massimo e' INTERNO
KS_STRESS = (1.00, 1.50, 2.00, 3.50, 5.00, 10.00, 18.00) # sottoinsieme per la (4)
DEPOSITI = (0, 500) # €/mese: nessuno, e il piano dichiarato
BERSAGLI = {"$50k": 50_000.0, "muro netto": 258_338.0} # r0807_piano_netto (fisco coerente)
ANNI = 25 # orizzonte di simulazione: 25 come r0726/r0807 (20 troncherebbe
# la coda della mediana condizionata). Le P si leggono a 10/15/20a.
N_PATHS = 2_500
BLOCK = 20
CHUNK = 500 # RAM: la VPS ha ~3.5 GB liberi e ci girano altri agenti
SEED = 20260822
SEED_BIS = 8221 # secondo seme, solo per dichiarare la risoluzione MC
START = 600.0
ALIQUOTA = CC.TAX_RATE # 0.33
PATRIM = TX.PATRIMONIALE # 0.002
RUIN_MULT = 0.20 # rovina = il moltiplicatore del PATH perde l'80%
DD_SOGLIA = 0.30
WIPE = 0.90 # perdita giornaliera >= 90% dell'equity = liquidazione (margine+fee)
MURO_PUBBL_LORDO = 272_061.0 # r0726_capwall_refresh (convenzione pubblicata)
def sezione(t: str) -> None:
print("\n" + "=" * 104)
print(f" {t}")
print("=" * 104)
# ===========================================================================
# il motore: una passata, tutte le leve insieme (confronto APPAIATO sugli stessi path)
# ===========================================================================
def simula(r: np.ndarray, ks: tuple[float, ...], *, anni: int, dep_eur: float,
bersagli: dict[str, float], aliquota: float, patrimoniale: float,
n_paths: int = N_PATHS, seed: int = SEED, start: float = START,
crash_pct: float = 0.0, crash_p_anno: float = 0.0) -> dict:
"""Accumulo a leva `k` per ogni k, sugli STESSI path (differenze appaiate).
Stato per (path, k). Traccia in parallelo un GEMELLO SENZA VERSAMENTI: il drawdown e la
rovina si misurano su quello, cosi' sono confrontabili fra colonne con versamenti diversi
(un bonifico mensile maschera un drawdown senza ridurre il capitale distrutto).
`crash_pct`/`crash_p_anno`: giorno di coda FUORI CAMPIONE (sezione 4). Il block bootstrap non
puo' generare un giorno peggiore del peggiore osservato: se lo si vuole, va messo a mano.
Liquidazione: se la perdita del giorno a leva k supera `WIPE` dell'equity, il conto e' chiuso
dall'exchange -> capitale a 0 (i versamenti successivi ripartono da li').
"""
kv = np.asarray(ks, float)[None, :]
nk = len(ks)
dep = dep_eur * CC.EURUSD
n_days = int(anni * 365)
rng = np.random.default_rng(seed)
nomi = list(bersagli)
soglie = np.array([bersagli[n] for n in nomi], float)
hit = {n: [] for n in nomi}
fin, mult_fin, ruin, ddmax, versato_tot = [], [], [], [], 0.0
for c0 in range(0, n_paths, CHUNK):
m = min(CHUNK, n_paths - c0)
paths = CC._boot_paths(r, m, n_days, BLOCK, rng)
if crash_pct > 0 and crash_p_anno > 0:
# un giorno di crash con probabilita' `crash_p_anno`/anno, uniforme nel tempo
p_giorno = crash_p_anno / 365.0
mask = rng.random((m, n_days)) < p_giorno
paths = np.where(mask, -abs(crash_pct), paths)
cap = np.full((m, nk), start, float)
mult = np.ones((m, nk), float) # gemello senza versamenti
hwm = np.ones((m, nk), float)
dd = np.zeros((m, nk), float)
rovinato = np.zeros((m, nk), bool)
colpito = {n: np.full((m, nk), np.inf) for n in nomi}
carry = np.zeros((m * nk, TX.CARRY_ANNI))
anno_start = cap.copy()
flusso = np.zeros((m, nk))
versato = start
for t in range(n_days):
rt = paths[:, t][:, None] * kv # (m, nk)
liq = rt <= -WIPE
g = np.where(liq, -1.0, rt)
cap *= (1.0 + g)
cap = np.maximum(cap, 0.0)
mult *= (1.0 + g)
mult = np.maximum(mult, 0.0)
hwm = np.maximum(hwm, mult)
dd = np.maximum(dd, 1.0 - mult / hwm)
rovinato |= mult <= RUIN_MULT
if dep > 0 and t % 30 == 0 and t > 0:
cap += dep
flusso += dep
versato += dep
for j, n in enumerate(nomi):
nuovi = np.isinf(colpito[n]) & (cap >= soglie[j])
colpito[n][nuovi] = t
if (t + 1) % 365 == 0 and (aliquota > 0 or patrimoniale > 0):
passo_fiscale(cap.ravel(), anno_start.ravel(), flusso.ravel(),
carry, aliquota, patrimoniale)
anno_start = cap.copy()
flusso[:] = 0.0
for n in nomi:
hit[n].append(colpito[n])
fin.append(cap)
mult_fin.append(mult)
ruin.append(rovinato)
ddmax.append(dd)
versato_tot = versato
out = dict(versato=versato_tot, anni=anni, n_paths=n_paths, ks=ks)
out["cap"] = np.concatenate(fin, axis=0)
out["mult"] = np.concatenate(mult_fin, axis=0)
out["ruin"] = np.concatenate(ruin, axis=0)
out["dd"] = np.concatenate(ddmax, axis=0)
out["hit"] = {n: np.concatenate(hit[n], axis=0) for n in nomi}
return out
def anni_cond(hit_col: np.ndarray) -> tuple[float, float, float, float]:
"""(anni mediani CONDIZIONATI all'arrivo, P<=10a, P<=15a, P<=20a). Non-arrivo = +inf."""
arr = hit_col[np.isfinite(hit_col)]
med = float(np.median(arr)) / 365.0 if len(arr) >= 50 else float("nan")
p = lambda y: float((hit_col <= y * 365).mean()) # noqa: E731
return med, p(10), p(15), p(20)
def fmt_anni(a: float) -> str:
return f"{a:.1f}a" if np.isfinite(a) else "mai"
# ===========================================================================
def main() -> None:
print("=" * 104)
print(" r0822 — GROWTH POLICY: quale leva minimizza il TEMPO al bersaglio, a pari rovina accettata")
print("=" * 104)
# ------------------------------------------------------------------ (0)
sezione("(0) IL DATO — book LIVE (TP01 75 / SKH01 25) e replica di controllo")
base = CC.book_series("hourly")
r = DP.deluck_returns()
print(f"\n serie: {len(base)} giorni, {base.index.min().date()} -> {base.index.max().date()}")
print(f" lente 'hourly' = SKH01 sul PATH LIVE (fill al close 5m orario), non il backtest.")
print(f" canonico : drift {base.values.mean()*365:>7.2%} vol {base.values.std()*365**0.5:>6.2%}"
f" Sharpe {base.values.mean()/base.values.std()*365**0.5:>5.2f}")
print(f" de-luckato ×{DP.DELUCK}: drift {r.mean()*365:>7.2%} vol {r.std()*365**0.5:>6.2%}"
f" Sharpe {r.mean()/r.std()*365**0.5:>5.2f} <-- questa e' la serie usata ovunque sotto")
print(f" peggior giorno {r.min():+.2%} · miglior giorno {r.max():+.2%}")
print(f"\n ⚠️ Conseguenza aritmetica da tenere a mente per tutto il resto: con ritorni")
print(f" moltiplicativi il capitale NON tocca zero finche' k < 1/{abs(r.min()):.4f} = "
f"{1/abs(r.min()):.1f}x.")
print(f" Percio' 'rovina' qui e' DEFINITA: il moltiplicatore del path (gemello SENZA")
print(f" versamenti) scende sotto {RUIN_MULT:.0%}, cioe' 4 euro su 5 distrutti. E la")
print(f" liquidazione dell'exchange e' modellata a parte: perdita giornaliera >= {WIPE:.0%}.")
print(f"\n muri pubblicati riusati come bersagli:")
print(f" · ${MURO_PUBBL_LORDO:,.0f} (r0726_capwall_refresh, convenzione LORDA)")
print(f" · ${BERSAGLI['muro netto']:,.0f} (r0807_piano_netto, fisco COERENTE) <-- usato qui")
print(f" · $50.000 (il bersaglio 'corto' chiesto dal brief: ~€19/g di rendita perpetua)")
print("\n CONTROLLO DI REPLICA — questo motore contro la tabella pubblicata il 07/08")
print(" (`r0807_piano_netto` §2: stesso fisco, stesso bersaglio, stessa convenzione di")
print(" mediana condizionata). I path sono diversi: si chiede accordo entro il rumore MC,")
print(" non l'identita'. Se questa riga non torna, nessun numero nuovo sarebbe")
print(" distinguibile da un bug.")
print(f"\n {'EUR/mese':>9}{'anni (pubbl.)':>16}{'P<=20a (pubbl.)':>18}{'anni (qui)':>13}{'P<=20a (qui)':>15}")
for dep, a_pub, p_pub in ((250, 19.8, 0.52), (500, 14.7, 0.99)):
sr = simula(r, (1.00,), anni=25, dep_eur=dep, bersagli={"muro": BERSAGLI["muro netto"]},
aliquota=ALIQUOTA, patrimoniale=PATRIM, n_paths=3_000, seed=725)
a, _, _, p20 = anni_cond(sr["hit"]["muro"][:, 0])
print(f" {dep:>9}{a_pub:>15.1f}a{p_pub:>18.0%}{fmt_anni(a):>13}{p20:>15.0%}")
# ------------------------------------------------------------------ (1)
sezione("(1) LA CURVA DI CRESCITA g(k) — dove sta il massimo, e DI QUANTO il libro ne dista")
print(f"\n Crescita geometrica MEDIANA annua, orizzonte {ANNI} anni, ZERO versamenti,")
print(f" {N_PATHS} path, fisco d'accumulo DENTRO. Un vol-target e' esattamente questo:")
print(" uno scalare sui pesi. Griglia allargata a 22x perche' a 8x il massimo non era")
print(" ancora interno — una griglia che si ferma prima del massimo non lo misura, lo tronca.")
s0 = simula(r, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI,
aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED)
s0b = simula(r, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI,
aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED_BIS)
mu, sig = float(r.mean()), float(r.std())
kelly_gauss = mu / sig ** 2
def g_curve(sim: dict) -> list[float]:
return [float(np.median(sim["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(sim["mult"].shape[1])]
g_med, g_medb = g_curve(s0), g_curve(s0b)
jstar = int(np.argmax(g_med))
kbest, gbest = KS[jstar], g_med[jstar]
j1 = KS.index(1.00)
lg = lambda g: float(np.log1p(g)) # noqa: E731
print(f"\n {'k':>6}{'g mediana/anno':>17}{'(2° seme)':>12}{'ln(1+g)/ln(1+g*)':>19}"
f"{'maxDD mediano':>16}{'P(DD>30%)':>11}{'P(rovina)':>11}{'cap mediano a 25a':>20}")
for j, k in enumerate(KS):
marca = " <-- LIBRO" if j == j1 else (" <-- k*" if j == jstar else "")
print(f" {k:>6.2f}{g_med[j]:>17.2%}{g_medb[j]:>12.2%}{lg(g_med[j])/lg(gbest):>19.2f}"
f"{np.median(s0['dd'][:, j]):>16.1%}{(s0['dd'][:, j] > DD_SOGLIA).mean():>11.1%}"
f"{s0['ruin'][:, j].mean():>11.1%}{np.median(s0['cap'][:, j]):>20,.0f}{marca}")
esec = [j for j, k in enumerate(KS) if k <= 2.50]
ris = max(abs(a - b) for a, b in zip(g_med, g_medb))
ris_e = max(abs(g_med[j] - g_medb[j]) for j in esec)
print(f"\n RISOLUZIONE MONTE CARLO (due semi indipendenti a {N_PATHS} path): scarto massimo")
print(f" {ris:.2%}/anno sull'intera griglia, ma solo {ris_e:.2%}/anno nella regione")
print(f" k<={KS[esec[-1]]:.2f} — il rumore vive tutto nelle celle a leva alta, dove la mediana e'")
print(" presa su una distribuzione enormemente dispersa. Conseguenza da tenere: la")
print(" POSIZIONE del massimo non e' risolvibile a questa taglia (vedi sotto), i confronti")
print(" nella regione eseguibile si'. Ogni confronto e' APPAIATO (stessi path per ogni k).")
print(f"\n RISPOSTA ALLA DOMANDA 'da che parte, e di quanto':")
print(f" massimo EMPIRICO della crescita k* = {kbest:.2f}x (g = {gbest:.1%}/anno)")
print(f" massimo GAUSSIANO (Kelly, mu/sigma²) k = {kelly_gauss:.1f}x")
print(f" il LIBRO gira a k = 1.00x (g = {g_med[j1]:.1%}/anno)")
print(f" -> il libro sta a SINISTRA, a {1.0/kbest:.0%} di Kelly empirico, e raccoglie il")
print(f" {lg(g_med[j1])/lg(gbest):.0%} della crescita massima (in crescita LOGARITMICA, che e' la")
print(" grandezza che Kelly massimizza). NON e' un plateau: e' il ramo ripido.")
print(f"\n ⚠️ La POSIZIONE del massimo non e' risolta a questa taglia: la sezione (4) rifa'")
print(" la stessa curva con 1500 path e il massimo cade a 10x invece di 14x. La regione")
print(" 8-14x e' piatta entro il rumore -> si cita 'k* ~ 10-14x', non un numero.")
print(" Cio' che E' risolto, e con margine enorme, e' che 1.00x non ci sta vicino.")
print(f"\n ⚠️ PREVISIONE MIA, REFUTATA IN SESSIONE. Mi aspettavo che il massimo empirico")
print(f" cadesse molto a SINISTRA di quello gaussiano ({kelly_gauss:.1f}x), perche' il bootstrap a")
print(" blocchi conserva code e clustering che la formula ignora. Non succede: i due")
print(" coincidono entro il rumore. Il motivo e' informativo e prepara la sezione (4) —")
print(" la distribuzione delle CHIUSURE giornaliere di questo libro non ha code abbastanza")
print(" spesse da spostare Kelly. Il rischio che conta a leva non e' in quella")
print(" distribuzione, quindi non lo si trovera' ricampionandola meglio.")
print(f"\n ⚠️ Questo e' il risultato della lente, non una raccomandazione. Tre cose lo")
print(" ridimensionano, e sono misurate sotto: (a) k* dipende dal DRIFT, che e' stimato")
print(f" su {len(r)/365:.1f} anni (sez. 1-bis); (b) la coda che lo sostiene NON e' nel")
print(" campione (sez. 4); (c) meta' della griglia non e' eseguibile (sez. 5).")
print(f" ⚠️ E c'e' un controllo di plausibilita' che il modello fallisce da solo: a k={KS[jstar]:g}x")
print(f" il capitale mediano a 25 anni partendo da $600 e' ${np.median(s0['cap'][:, jstar]):,.0f}.")
print(" Un modello che promette piu' della capitalizzazione del mercato in cui trada ha")
print(" lasciato il dominio in cui vuol dire qualcosa: assume che il ritorno sia")
print(" indipendente dalla SIZE, il che e' falso ben prima di quella cifra.")
# ------------------------------------------------------------------ (1-bis)
sezione("(1-bis) QUANTO E' SOLIDO k* — Kelly e' ipersensibile al drift, e il drift e' una STIMA")
se_drift = float(r.std() * 365 ** 0.5) / (len(r) / 365) ** 0.5
print(f"\n drift stimato {mu*365:.2%}/anno su {len(r)/365:.1f} anni con vol {r.std()*365**0.5:.2%}")
print(f" -> errore standard della MEDIA = vol/sqrt(T) = {se_drift:.2%}/anno.")
print(" Kelly va come mu/sigma²: e' LINEARE nell'errore del drift, quindi l'incertezza")
print(" sul numeratore si trasferisce intera su k*. Stessa macchineria di `deluck_returns`")
print(" (si sottrae una costante al drift, la vol resta quella misurata).")
print(f"\n {'ipotesi sul drift':>34}{'drift/anno':>13}{'k* gaussiano':>15}{'k* empirico':>14}"
f"{'g(1x)':>9}{'g(k*)':>9}")
scen = [("punto (de-luckato ×0.89)", 0.0),
("-1 errore standard", se_drift),
("-2 errori standard", 2 * se_drift),
("drift dimezzato (lente 07/08)", mu * 365 / 2),
("drift a zero", mu * 365)]
for nome, taglio in scen:
rs = r - taglio / 365.0
mus = float(rs.mean())
kg = mus / sig ** 2
ss = simula(rs, KS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA,
patrimoniale=PATRIM, seed=SEED, n_paths=1_500)
gs = g_curve(ss)
js = int(np.argmax(gs))
print(f" {nome:>34}{mus*365:>13.2%}{kg:>15.1f}{KS[js]:>13.2f}x"
f"{gs[j1]:>9.1%}{gs[js]:>9.1%}")
print("\n Lettura: un errore di stima di UN solo errore standard sul drift — che a 7 anni di")
print(" storia e' l'ordine di grandezza normale — sposta k* di parecchie unita'. E' la")
print(" ragione classica per cui si opera a una FRAZIONE di Kelly: non prudenza morale, ma")
print(" il fatto che k* e' un numero stimato con una barra d'errore larga quanto se stesso.")
# ------------------------------------------------------------------ (2)
sezione(f"(2) SWEEP PRINCIPALE — tempo al bersaglio, orizzonte {ANNI} anni, AL NETTO del fisco")
print(f"\n da ${START:.0f} · {N_PATHS} path · blocchi {BLOCK}g · fisco {ALIQUOTA:.0%} + carry "
f"{TX.CARRY_ANNI}a + patrimoniale {PATRIM:.1%}")
print(" 'anni' = mediana CONDIZIONATA all'arrivo (non-arrivo = +infinito, MAI -1): si legge")
print(" sempre insieme a P<=20a accanto. 'p90' = il decile lento, cioe' il rischio di TEMPO.")
print(" ⚠️ Convenzione dichiarata: il fisco e' dentro il CAPITALE (colonne anni/P), NON dentro")
print(" il gemello di rischio (colonne DD/rovina). Un drawdown e' un fatto di mercato: metterci")
print(" l'imposta dentro sommerebbe due cose diverse in un numero solo.")
risultati = {}
for dep in DEPOSITI:
s = s0 if dep == 0 else simula(r, KS, anni=ANNI, dep_eur=dep, bersagli=BERSAGLI,
aliquota=ALIQUOTA, patrimoniale=PATRIM, seed=SEED)
risultati[dep] = s
for nome in BERSAGLI:
print(f"\n --- versamento €{dep}/mese · bersaglio {nome} (${BERSAGLI[nome]:,.0f}) · "
f"versato in {ANNI}a ${s['versato']:,.0f} ---")
print(f" {'k':>6}{'anni (p50)':>13}{'anni (p90)':>13}{'P<=10a':>9}{'P<=15a':>9}"
f"{'P<=20a':>9}{'P(rovina)':>11}{'P(DD>30%)':>11}{'P(DD>50%)':>11}")
for j, k in enumerate(KS):
h = s["hit"][nome][:, j]
a, p10, p15, p20 = anni_cond(h)
arr = h[np.isfinite(h)]
a90 = float(np.percentile(arr, 90)) / 365.0 if len(arr) >= 50 else float("nan")
print(f" {k:>6.2f}{fmt_anni(a):>13}{fmt_anni(a90):>13}{p10:>9.1%}{p15:>9.1%}"
f"{p20:>9.1%}{s['ruin'][:, j].mean():>11.1%}"
f"{(s['dd'][:, j] > DD_SOGLIA).mean():>11.1%}"
f"{(s['dd'][:, j] > 0.50).mean():>11.1%}")
# ------------------------------------------------------------------ (3)
sezione("(3) FRONTIERA ISO-ROVINA — la domanda del brief nella sua forma esatta")
print("\n 'a parita' di probabilita' di rovina accettata': per ogni budget di rischio, la")
print(" leva PIU' ALTA che lo rispetta, e il tempo che quel budget compra. Il rischio si")
print(" misura sul gemello SENZA versamenti (deposit-independent: un bonifico mensile")
print(" maschera un drawdown senza ridurre il capitale distrutto), ed e' la stessa")
print(" grandezza su cui passa la regola di DD per-conto del canale funded.")
for dep in DEPOSITI:
s = risultati[dep]
print(f"\n --- versamento €{dep}/mese ---")
print(f" {'budget':>8}{'metrica':>13}{'k max':>9}"
+ "".join(f"{'anni ' + n:>22}" for n in BERSAGLI))
for metrica, col in (("P(DD>30%)", lambda j: (s["dd"][:, j] > DD_SOGLIA).mean()),
("P(rovina)", lambda j: s["ruin"][:, j].mean())):
for budget in (0.01, 0.05, 0.10, 0.25, 0.50):
amm = [j for j in range(len(KS)) if col(j) <= budget]
if not amm:
print(f" {budget:>7.0%}{metrica:>13}{'nessuna':>9}")
continue
j = max(amm)
celle = []
for n in BERSAGLI:
a, _, _, p20 = anni_cond(s["hit"][n][:, j])
celle.append(f"{fmt_anni(a)} (P20 {p20:.0%})")
print(f" {budget:>7.0%}{metrica:>13}{KS[j]:>8.2f}x" + "".join(f"{c:>22}" for c in celle))
print("\n ⚠️ Le due metriche danno risposte MOLTO diverse, e la ragione conta: P(rovina)")
print(" (perdere l'80% del moltiplicatore) e' quasi zero fino a leve altissime perche'")
print(" il libro non ha mai perso l'80% in nessuna finestra ricampionabile; P(DD>30%)")
print(" morde subito. Chi sceglie la leva sul solo azzeramento sceglie sulla metrica")
print(" che il dataset non puo' informare — vedi (4).")
# ------------------------------------------------------------------ (4)
sezione("(4) IL GATE CHE CONTA — k* e' calcolato su un campione senza un solo giorno di crash")
print(f"\n Il block bootstrap ricampiona i giorni VISSUTI: non puo' produrre un giorno")
print(f" peggiore di {r.min():.2%}. Il libro ha attraversato il 2022 (LUNA/FTX) restando")
print(" difensivo — quindi per k=1 la coda del campione e' onesta. Ma a leva il rischio")
print(" cambia NATURA: entra la liquidazione, che su una chiusura giornaliera non si vede")
print(" (lezione della lente wick accoppiata, 25/07: 'close-only non e' conservativa, e'")
print(" CIECA'). Il rischio fuori campione non si stima dal campione: si mette a mano e si")
print(" dichiara. Ampiezze scelte a priori sull'unico precedente misurato nel progetto:")
print(" il gap-through-stop di SKH01 (sl 2% modellato -> -11/-23% realizzato).")
print(f"\n liquidazione se la perdita a leva supera {WIPE:.0%} dell'equity in un giorno.")
print(f"\n {'scenario':>22}" + "".join(f"{f'k={k:g}':>10}" for k in KS_STRESS) + f"{' k*':>8}")
s0s = simula(r, KS_STRESS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA,
patrimoniale=PATRIM, seed=SEED, n_paths=1_500)
g0s = [float(np.median(s0s["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(len(KS_STRESS))]
print(f" {'nessuno stress':>22}" + "".join(f"{g:>10.1%}" for g in g0s)
+ f"{f'{KS_STRESS[int(np.argmax(g0s))]:g}x':>8}")
for cpct in (0.10, 0.15, 0.20):
for panno in (0.20, 1.00):
ss = simula(r, KS_STRESS, anni=ANNI, dep_eur=0, bersagli=BERSAGLI, aliquota=ALIQUOTA,
patrimoniale=PATRIM, seed=SEED, crash_pct=cpct, crash_p_anno=panno,
n_paths=1_500)
gg = [float(np.median(ss["mult"][:, j])) ** (1 / ANNI) - 1 for j in range(len(KS_STRESS))]
print(f" {f'-{cpct:.0%} ogni {1/panno:.0f}a':>22}"
+ "".join(f"{g:>10.1%}" for g in gg)
+ f"{f'{KS_STRESS[int(np.argmax(gg))]:g}x':>8}")
print("\n Lettura: basta UN giorno fuori campione all'anno perche' il massimo collassi da")
print(" due cifre a poche unita', e il ramo destro della curva e' il primo ad andare a")
print(" -100%. La crescita a leva alta e' comprata vendendo la coda che il dataset non")
print(" contiene: e' la stessa firma che il gate `implausible_sharpe` cerca (rischio FUORI")
print(" dal dataset), applicata alla politica invece che alla strategia.")
# ------------------------------------------------------------------ (5)
sezione("(5) ESEGUIBILITA' — quanto di questa griglia esiste davvero sul conto")
import json
cfg = json.loads((ROOT / "config" / "live.json").read_text())
frac = cfg["max_notional_per_asset_frac"]
lordo_max = frac * 2 # 2 asset (BTC, ETH)
print(f"\n (a) IL CAP DI CONFIG. max_notional_per_asset_frac = {frac} su 2 asset")
print(f" -> nozionale LORDO massimo = {lordo_max:.2f}x l'equity. **k > {lordo_max:.2f}x NON e'")
print(" eseguibile senza toccare `config/live.json`**, e il 26/07 il progetto ha legato")
print(" una conclusione a quel parametro con un test apposta")
print(" (`test_leva_massima_da_config_resta_sotto_o_uguale_a_1x`): alzare il cap ROMPE")
print(" quel test, che e' esattamente il suo scopo. Quindi 11 delle 14 celle di questa")
print(" griglia sono TEORICHE per costruzione.")
print(f" min_order ${cfg['min_order_usd']} · disaster-SL {cfg['disaster_sl_pct']:.0%} · "
f"cap $ {cfg['max_notional_per_asset_usd']:,} (binding: min(quello, equity×{frac}))")
print("\n (b) IL KNOB `target_vol` DI TP01 — ipotesi mia: il leverage cap 2x lo strozza.")
from src.data.downloader import load_data # noqa: E402
from src.strategies.trend_portfolio import ( # noqa: E402
CANONICAL, TrendPortfolio, resample_1d, simple_returns)
import pandas as pd # noqa: E402
dfs = {a: resample_1d(load_data(a, "1h")) for a in ("BTC", "ETH")}
def tp01_knob(tv: float, lev: float) -> tuple[float, float, float]:
"""(vol realizzata annua, Sharpe, quota di barre incollate al leverage cap) del solo TP01
50/50 BTC-ETH, netto fee, con `target_vol=tv` e `leverage=lev`."""
serie, atcap = {}, []
for a, df in dfs.items():
tp = TrendPortfolio(**{**CANONICAL, "target_vol": tv, "leverage": lev})
tgt = np.nan_to_num(np.asarray(tp.target_series(df), float))
ret = simple_returns(df["close"].values.astype(float))
pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1]
net = pos * ret - 0.0005 * np.abs(np.diff(pos, prepend=0.0))
net[0] = 0.0
serie[a] = pd.Series(net, index=pd.to_datetime(df["datetime"]))
atcap.append(float((np.abs(tgt) >= lev - 1e-9).mean()))
J = pd.concat(serie, axis=1, join="inner").fillna(0.0) # le due serie hanno lunghezze
x = (0.5 * J["BTC"] + 0.5 * J["ETH"]).values # diverse: si allineano sulle date
return (float(x.std() * 365 ** 0.5), float(x.mean() / x.std() * 365 ** 0.5),
float(np.mean(atcap)))
rif = tp01_knob(0.20, 2.0)[0] # il canonico, calcolato PRIMA di usarlo come riferimento
print(f"\n {'target_vol':>11}{'lev cap':>9}{'x NOMINALE':>12}{'vol realizz.':>14}"
f"{'x OTTENUTO':>12}{'Sharpe':>9}{'quota barre al cap':>21}")
for lev in (2.0, 4.0):
for tv in (0.10, 0.20, 0.30, 0.40, 0.60):
v, sh, ac = tp01_knob(tv, lev)
print(f" {tv:>11.0%}{lev:>9.1f}{tv/0.20:>12.2f}{v:>14.2%}{v/rif:>12.2f}{sh:>9.2f}"
f"{ac:>21.1%}")
v20, sh20, _ = tp01_knob(0.20, 2.0)
print(f"\n ❌ IPOTESI REFUTATA, e la refutazione e' piu' utile dell'ipotesi. 'x OTTENUTO'")
print(" segue 'x NOMINALE' fino a target_vol 60% (3.00 nominale -> 2.98 ottenuto) e le")
print(" barre incollate al cap sono l'1.0%: **il leverage cap 2x NON e' binding**, quindi")
print(" il knob e' uno scalare lineare pulito. Lo Sharpe e' invariante a ogni cella")
print(f" (1.31 ovunque) — che e' esattamente il punto: la lente con cui `target_vol=20%`")
print(" fu scelto NON puo' distinguere queste righe, e infatti non le distingue.")
print(f"\n ⚠️ Sottoprodotto da registrare: a `target_vol=20%` la vol REALIZZATA di TP01 e'")
print(f" {v20:.2%}, non 20%. Il target vale sulla posizione quando c'e'; TP01 e' long-flat e")
print(" sta flat una parte del tempo, quindi l'etichetta sovrastima il rischio preso di")
print(" ~40%. Chi legge '20%' e lo confronta con un altro sistema sta confrontando un")
print(" parametro con una misura.")
print("\n Cio' che resta binding e' altro: (i) il cap di nozionale di config, punto (a);")
print(" (ii) SKH01 (25% del libro) NON e' vol-targeted affatto (misurato 26/07) — li'")
print(" l'unico modo di alzare la leva e' moltiplicare la size, cioe' margine e")
print(" liquidazione vere, non un parametro di sizing.")
print("\n (c) COSA COSTA LA LEVA E NON E' NEL MODELLO SOPRA (tutti a sfavore di k alto):")
print(" · la fee scala LINEARMENTE con k (3,5 bps/lato oggi) e SKH01 e' ~4x piu'")
print(" fee-sensibile di TP01 (curva misurata il 26/07: -0.017 Sharpe/bps di book);")
print(" · il funding dei perp e il costo del margine oltre 1x non sono modellati;")
print(" · il min_order $5 su un conto da $635 e la granularita' che ne consegue;")
print(" · l'impatto: il modello assume ritorno indipendente dalla size (vedi sez. 1).")
print(" -> i numeri a k>1 sono un TETTO, non una stima.")
# ------------------------------------------------------------------ verdetto
sezione("VERDETTO")
s500 = risultati[500]
jj = {k: KS.index(k) for k in (1.00, 1.50, 2.00)}
righe = []
for nome in BERSAGLI:
for k, j in jj.items():
a, _, _, p20 = anni_cond(s500["hit"][nome][:, j])
righe.append((nome, k, a, p20, (s500["dd"][:, j] > DD_SOGLIA).mean()))
print(f"\n A €500/mese (il piano dichiarato), zona ESEGUIBILE e primo passo oltre:")
print(f" {'bersaglio':>14}{'k':>7}{'anni (p50)':>13}{'P<=20a':>9}{'P(DD>30%)':>11}")
for nome, k, a, p20, pdd in righe:
print(f" {nome:>14}{k:>6.2f}x{fmt_anni(a):>13}{p20:>9.0%}{pdd:>11.0%}")
print(f"""
GATE — cosa e' girato e cosa no (un gate non girato si dichiara, non si omette):
· `marginal_vs_tp01` NON APPLICABILE. Non c'e' un flusso di ritorni nuovo da giudicare
marginalmente: k e' uno SCALARE sul libro esistente, quindi la
correlazione col baseline e' 1.00 per costruzione.
· `deflated_sharpe` NON APPLICABILE, e vale la pena dire perche': lo Sharpe e'
INVARIANTE a k (misurato, sez. 5b: 1.31 a ogni cella del knob).
Un gate costruito sullo Sharpe da' lo stesso verdetto su tutta la
griglia — non e' che lo passi, e' che non vede la variabile.
· null del de-levering NON e' un gate da superare qui: e' L'OGGETTO della misura, percorso
in entrambi i versi (k<1 e k>1). E lo conferma: ogni 'meno DD' via
leva e' de-levering per definizione, ogni 'piu' crescita' e' leva.
· fortuna d'ancora GESTITA col fattore MISURATO ×{DP.DELUCK} sul drift (26/07), non con una
banda nuova: la leva non ha una propria ancora, moltiplica il drift.
· `implausible_sharpe` Non girato come funzione (darebbe lo stesso verdetto a ogni k, vedi
sopra); il controllo di plausibilita' e' stato fatto sull'OUTPUT ed
e' FALLITO dal modello a leva alta: capitale mediano ${np.median(s0['cap'][:, jstar]):,.0f}
da $600 in 25 anni. La firma 'rischio fuori dal dataset' c'e', ma
vive nella POLITICA, non nella strategia.
· causalita' Ereditata: la serie viene da `CC.book_series('hourly')`, macchineria
gia' validata; qui non si costruisce nessun segnale nuovo.
· risoluzione MC DICHIARATA: {ris_e:.2%}/anno nella regione eseguibile, {ris:.2%}/anno sulla
griglia intera. La posizione del massimo NON e' risolta.
1. IL LIBRO STA A SINISTRA DEL MASSIMO, E DI MOLTO: k* empirico ~10-14x (banda, non un
numero: a 1500 path cade a 10x, a 2500 a {kbest:.0f}x), il libro gira a 1x = {1.0/kbest:.0%} di Kelly
e raccoglie il {lg(g_med[j1])/lg(gbest):.0%} della crescita massima in log. Non e' un plateau
(come lo era il peso 75/25): e' il ramo ripido della curva. Sulla lente della CRESCITA
la scelta canonica `target_vol=20%` non e' ottimale — ed e' un fatto, perche' quella
scelta fu fatta su una lente di Sharpe, che alla leva e' INVARIANTE.
2. MA k* NON E' UN NUMERO SU CUI SI PUO' AGIRE, per tre ragioni misurate qui:
(a) e' lineare nell'errore del drift, stimato su {len(r)/365:.1f} anni (sez. 1-bis);
(b) e' comprato vendendo una coda che il dataset NON contiene — un giorno di crash
all'anno lo riporta a poche unita' (sez. 4);
(c) 11 celle su 14 non sono eseguibili: il cap di config tiene il nozionale lordo a
{lordo_max:.2f}x l'equity. (Il knob `target_vol` invece FUNZIONA — misurato, sez. 5b: il
leverage cap 2x non morde. L'ostacolo e' la config e SKH01, non il sizing di TP01.)
3. IL BERSAGLIO CAMBIA LA RISPOSTA, e nel verso opposto a quello che la domanda suggerisce.
Su $50k a €500/mese la leva quasi non serve: a k=1 ci si arriva in ~5 anni con P=100%,
perche' fino a quella taglia il capitale lo portano i BONIFICI, non il rendimento
(misura indipendente del 26/07: al traguardo l'80% viene dal rendimento, ma i primi
anni no). Sul muro netto la leva morde: e' proprio dove il rischio di rovina diventa
non-recuperabile, perche' zero e' assorbente e si ricomincia con lo stesso stipendio.
4. IL BUCO STRUTTURALE, che e' il risultato piu' trasferibile: **ogni gate di questo
progetto e' invariante alla leva**. `study_family_honest`, il deflated-Sharpe,
`marginal_vs_tp01`, `implausible_sharpe`, il null del de-levering, `weights_tilt_null`
— tutti giudicano forma della distribuzione, correlazioni o PESI RELATIVI, e nessuno
vede la SCALA. Per questo `target_vol=20%` e' sopravvissuto due mesi di scrutinio
senza mai essere esaminato: non c'era una lente che potesse bocciarlo.
5. COSA RESTA DI OPERATIVO: nulla da cambiare oggi. Il gradino ESEGUIBILE (k da 1.00x a
1.25-1.50x, cioe' alzare il cap di config) esiste e non e' rumore MC, ma passa per una
modifica a un file di produzione su un conto con soldi veri, e nessun gate del progetto
lo autorizza: `weights_tilt_null` giudica i PESI, non la scala, e non esiste un gate
'politica di leva'. Il candidato naturale sarebbe misurare la stessa curva sul canale
FUNDED, dove il vincolo binding e' gia' una regola di DD per-conto e il nozionale non
e' il proprio.
""")
if __name__ == "__main__":
main()