565 lines
36 KiB
Python
565 lines
36 KiB
Python
#!/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()
|