research(wave-0822): ledger degli esiti — ADAPTIVE-HORIZON scartato

This commit is contained in:
Adriano Dal Pastro
2026-08-22 17:03:07 +00:00
parent fa86a7b089
commit ff4aa94528
13 changed files with 8127 additions and 0 deletions
+712
View File
@@ -0,0 +1,712 @@
"""r0822_prop_alloc — PROGETTARE l'allocazione per la BARRIERA, non per lo Sharpe (2026-08-22).
IL BUCO CHE CHIUDE
------------------
Il canale prop/funded e' l'unico misurato che moltiplica il nozionale senza possedere capitale.
Il progetto lo ha gia' VALUTATO (24-25/07: `r0725_prop_ladder.py`, `r0725_prop_coupled.py`,
`r0725_hyro.py`) ma non lo ha mai **PROGETTATO**: ha sempre preso il libro ottimizzato per lo
Sharpe (config A = live 75/25, config B = 55/20/25 scelta a mano) e poi MISURATO P(pass) del
risultato. Due punti nello spazio dei pesi, tre leve, nessuna ottimizzazione, nessun null.
Qui la funzione obiettivo cambia: sotto una **barriera di maxDD STATICA per-conto** conta la coda
sinistra e la DURATA, non il rapporto rendimento/vol. Si ottimizza
J(w, k) = P(passare l'eval) x P(essere vivo dopo 12 mesi da funded)
su pesi w e leva k, con la lente WICK ACCOPPIATA (la `close-only` e' CIECA: 0% di breach da
daily-loss su ogni configurazione, la regola non scatta mai sulle chiusure).
LA PREVISIONE, REGISTRATA PRIMA DI MISURARE
-------------------------------------------
Nel limite gaussiano con leva LIBERA l'obiettivo si riduce allo Sharpe, e si vede in due righe.
Per un moto browniano con drift mu e vol sigma a leva k, la probabilita' di toccare +T prima di
-D vale P = (1-e^{-theta*D})/(1-e^{-theta*(T+D)}) con **theta = 2*mu/(k*sigma^2)**: la leva entra
SOLO dividendo theta. Alla leva minima che porta a +T dentro l'orizzonte (k ~ T/mu) si ottiene
theta ~ 2*mu^2/(T*sigma^2) = 2*S^2/T, con S = Sharpe: **la vol sparisce e resta lo Sharpe al
quadrato**. Quindi mi aspetto PRIMA di misurare:
(a) l'argmax di J vicino all'argmax di Sharpe -> la premessa "obiettivo diverso" e' in gran
parte FALSA sui PESI;
(b) il contenuto vero nella **LEVA**, che lo Sharpe non determina affatto, e nella deviazione
dal gaussiano (skew, wick, daily-loss: una soglia ASSOLUTA non e' scale-invariante);
(c) leva ottima per J **piu' bassa** di quella ottima per il payout (0.75x, 25/07): J e' una
probabilita' di sopravvivenza, il payout e' un flusso.
Se (a) risulta falsa, e' un risultato; se risulta vera, il filone "riallocare per la barriera"
si chiude e si smette di pagarlo.
CONVENZIONI DICHIARATE
----------------------
* Macchineria del wick ACCOPPIATO **riusata** da `r0725_prop_coupled.py` (import, non riscritta):
`_hourly_legs` (recon MTM orario TP01/SKH01), `crypto_daily_tuples` (minimo ESATTO sul path
orario condiviso), `xsec_daily_tuples` (XS01 dagli OHLC 1d, ordine condiviso avverso).
* VRP01 e GTAA01 sono AGGIUNTI qui (non c'erano): GTAA01 col wick dagli OHLC dei 6 ETF (long-only
co-moventi -> ogni gamba al proprio minimo = convenzione severa dichiarata); VRP01 con
**gap = 0**, cioe' CIECO, perche' il suo mark infra-settimana non e' nel dataset dello sleeve
(il rendimento settimanale e' lumpato sul giorno di scadenza). E' una lente GENEROSA per VRP01:
la misura del 30/07 sulle quote reali dice mark peggiore infra-settimana mediano -6.9% e minimo
**-81.7%** del capitale su un trade finito in UTILE. Se VRP01 perde sotto una lente generosa,
perde.
* de-luck sul DRIFT **x0.89** (fattore MISURATO il 26/07 sul libro live, `r0726_deluck_factor.py`),
non x0.6 come nel 25/07 (li' era stimato a occhio e ri-misurato troppo severo del 31-34%).
Sensibilita' a 1.00 e 0.60 girata: cio' che conta e' se l'ORDINE fra le configurazioni tiene.
* bootstrap a blocchi (20g) con **indici CONDIVISI fra tutte le configurazioni** -> le differenze
fra configurazioni sono APPAIATE e il loro errore MC e' molto minore di quello dei livelli.
* finestra comune **2024+** (XS01 nasce li'): ~2.6 anni, ed e' la finestra in cui XS01 e' stato
scoperto -> le configurazioni che lo contengono sono FAVORITE per costruzione. Dichiarato.
* fisco 33% sui payout, split 80%, regole vere delle due firm (max-loss STATICO dal saldo
iniziale, base ripristinata dopo il prelievo — bug del 25/07 gia' corretto a monte).
Uso: `nice -n 19 timeout 900 uv run python scripts/research/r0822_prop_alloc.py`
"""
from __future__ import annotations
import sys
import time
from functools import lru_cache
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
from src.data.eq_splits import repair_splits # noqa: E402
from src.portfolio.gtaa import ( # noqa: E402
ANN, EQ_UNIVERSE, GTAA_DEFAULT_CAPITAL, HORIZONS, IB_MIN_TRADE_USD, REBAL_BAND_USD,
REBAL_EVERY, TARGET_VOL, ib_commission,
)
from src.portfolio.portfolio import metrics, weights_tilt_null # noqa: E402
from src.portfolio.sleeves import ( # noqa: E402
GTAA_BOOK_ACTIVATION, _gtaa_daily_returns, _skyhook_returns, _tp01_returns,
_vrp_combo_returns, _xsec_returns,
)
import r0725_prop_coupled as pc # noqa: E402
import r0725_prop_ladder as pl # noqa: E402
from r0725_capcurve import EURUSD, TAX_RATE # noqa: E402
RAW = ROOT / "data" / "raw"
# ------------------------------------------------------------------ costanti dichiarate
DELUCK = 0.89 # fattore d'ancora MISURATO sul drift (26/07), non stimato a occhio
START_WIN = "2024-01-01" # finestra comune ai 5 sleeve (XS01 nasce qui)
BLOCK = 20 # blocco del bootstrap, in giorni
N_PATHS = 4000
SPLIT = pl.SPLIT # 80% al trader
TARGET_EUR_DAY = 50.0
FIRMS = {f["name"]: f for f in pl.FIRMS}
SLEEVES = ("TP01", "SKH01", "XS01", "VRP01", "GTAA01")
CRYPTO_PROP = ("TP01", "SKH01", "XS01") # cio' che una firm CRYPTO puo' davvero far eseguire
# ================================================================== §0. PANNELLO (R, wick)
@lru_cache(maxsize=8)
def _eq_ohlc(sym: str) -> pd.DataFrame:
p = RAW / f"eq_{sym.lower()}_1d.parquet"
d = pd.read_parquet(p)
d.index = pd.to_datetime(d["timestamp"], unit="ms", utc=True)
d = d.sort_index()
d, _ = repair_splits(d) # split IWM/EFA non aggiustati (25/07)
return d[["open", "high", "low", "close"]].astype(float)
def _gtaa_exposure(close: pd.Series) -> np.ndarray:
"""Copia FEDELE di gtaa._exposure (che prende una Series di soli close)."""
px = close.values
n = len(px)
tgt = np.zeros(n)
mh = max(HORIZONS)
for i in range(mh, n):
tgt[i] = np.mean([1.0 if px[i] > px[i - H] else 0.0 for H in HORIZONS])
s = pd.Series(tgt, index=close.index)
rv = close.pct_change().rolling(63, min_periods=20).std().shift(1) * ANN
scale = np.clip(np.nan_to_num(TARGET_VOL / rv.replace(0, np.nan).values, nan=0.0), 0, 10.0)
return (s * scale).clip(0, 1.0).values
@lru_cache(maxsize=2)
def gtaa_daily_tuples() -> pd.DataFrame:
"""(R, m) giornalieri di GTAA01: chiusura replicata da gtaa.gtaa_returns e minimo intraday
dagli OHLC delle 6 gambe. Long-only e co-moventi -> convenzione **ogni gamba al proprio
minimo insieme** (severa, la stessa famiglia del bound 'perleg' di XS01).
Weekend/festivi: R = 0 e m = 0 (capitale IB fermo — convenzione del book)."""
cap_leg = GTAA_DEFAULT_CAPITAL / len(EQ_UNIVERSE)
legs_r, legs_m = {}, {}
for sym in EQ_UNIVERSE:
d = _eq_ohlc(sym)
close = d["close"]
ex = np.nan_to_num(_gtaa_exposure(close))
px = close.values
prev = np.concatenate(([np.nan], px[:-1]))
ret = np.zeros(len(px))
ret[1:] = px[1:] / px[:-1] - 1.0
ret_low = d["low"].values / prev - 1.0 # long-only: il peggio e' il minimo
held = np.empty(len(ex))
comm = np.zeros(len(ex))
cur = 0.0
for i in range(len(ex)): # identico a gtaa._gated_returns
if i % REBAL_EVERY == 0:
notional = abs(ex[i] - cur) * cap_leg
if notional >= max(REBAL_BAND_USD, IB_MIN_TRADE_USD):
comm[i] = ib_commission(notional, px[i]) / cap_leg
cur = ex[i]
held[i] = cur
pos = np.zeros(len(held))
pos[1:] = held[:-1]
legs_r[sym] = pd.Series(pos * ret - comm, index=close.index)
legs_m[sym] = pd.Series(pos * np.nan_to_num(ret_low) - comm, index=close.index)
R = pd.concat(legs_r, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
M = pd.concat(legs_m, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
days = pd.date_range(R.index.min().normalize(), R.index.max().normalize(), freq="1D", tz="UTC")
R = R.reindex(days).fillna(0.0)
M = M.reindex(days).fillna(0.0)
out = pd.DataFrame({"R": R, "m": np.minimum(M, R)})
return out[out.index >= GTAA_BOOK_ACTIVATION]
@lru_cache(maxsize=2)
def vrp_daily_tuples() -> pd.DataFrame:
"""(R, m) di VRP01 con **m = R**: il mark infra-settimana NON e' nel dataset dello sleeve
(rendimento settimanale lumpato sul giorno di scadenza). Lente CIECA e GENEROSA — dichiarata."""
r = _vrp_combo_returns().dropna().sort_index()
if r.index.tz is None:
r.index = r.index.tz_localize("UTC")
return pd.DataFrame({"R": r.values, "m": r.values}, index=r.index)
def _d(s: pd.Series) -> pd.Series:
s = s.dropna().sort_index()
if s.index.tz is None:
s.index = s.index.tz_localize("UTC")
return s
_CRY_CACHE: dict = {}
def _crypto(w_tp: float, w_skh: float) -> pd.DataFrame:
key = (round(w_tp, 6), round(w_skh, 6))
if key not in _CRY_CACHE:
_CRY_CACHE[key] = pc.crypto_daily_tuples(w_tp, w_skh)
return _CRY_CACHE[key]
@lru_cache(maxsize=4)
def _panel_index(start: str) -> pd.DatetimeIndex:
"""Griglia comune ai 5 sleeve (intersezione), dal `start` in poi."""
idx = _crypto(1.0, 0.0).index
for f in (pc.xsec_daily_tuples(), vrp_daily_tuples(), gtaa_daily_tuples()):
idx = idx.intersection(f.index)
return idx[idx >= pd.Timestamp(start, tz="UTC")]
_RG_CACHE: dict = {}
def book_RG(w: dict, start: str = START_WIN) -> tuple[np.ndarray, np.ndarray]:
"""(R, gap) giornalieri di un libro a pesi arbitrari sui 5 sleeve.
Crypto TP01+SKH01: minimo ESATTO sul path orario condiviso (r0725_prop_coupled).
XS01/VRP01/GTAA01: il loro minimo si SOMMA (worst simultaneo — convenzione del 25/07)."""
key = (start,) + tuple(round(float(w.get(nm, 0.0)), 6) for nm in SLEEVES)
if key in _RG_CACHE:
return _RG_CACHE[key]
idx = _panel_index(start)
w_tp, w_skh = w.get("TP01", 0.0), w.get("SKH01", 0.0)
R = np.zeros(len(idx))
M = np.zeros(len(idx))
if w_tp > 0 or w_skh > 0:
c = _crypto(w_tp, w_skh).reindex(idx)
R += c["R"].values
M += c["m"].values
for nm, fr in (("XS01", pc.xsec_daily_tuples()), ("VRP01", vrp_daily_tuples()),
("GTAA01", gtaa_daily_tuples())):
wt = w.get(nm, 0.0)
if wt > 0:
f = fr.reindex(idx)
R += wt * f["R"].values
M += wt * f["m"].values
R = np.nan_to_num(R)
M = np.nan_to_num(M)
_RG_CACHE[key] = (R, np.minimum(M, R) - R)
return _RG_CACHE[key]
def deluck(R: np.ndarray, factor: float = DELUCK) -> np.ndarray:
return R - (1.0 - factor) * R.mean()
# ================================================================== §1. SIMULATORE
def boot_idx(n_hist: int, n_days: int, n_paths: int, seed: int, block: int = BLOCK) -> np.ndarray:
rng = np.random.default_rng(seed)
n_blocks = int(np.ceil(n_days / block))
st = rng.integers(0, n_hist - block, size=(n_paths, n_blocks))
return (st[:, :, None] + np.arange(block)[None, None, :]).reshape(n_paths, -1)[:, :n_days]
def eval_sim(R: np.ndarray, G: np.ndarray, idx: np.ndarray, lev: float, firm: str,
lens: str = "coupled") -> dict:
"""P(passare l'eval) entro l'orizzonte del bootstrap + giorni al pass."""
tgt, dd, dl = FIRMS[firm]["ev"]
r = R[idx] * lev
g = (G[idx] * lev) if lens == "coupled" else np.zeros_like(r)
P, D = r.shape
eq = np.ones(P)
alive = np.ones(P, bool)
passed = np.zeros(P, bool)
tpass = np.full(P, D, int)
for t in range(D):
act = alive & ~passed
if not act.any():
break
rt, gt = r[:, t], g[:, t]
eqp = eq
eq = np.where(act, eq * (1.0 + rt), eq)
eq_low = np.where(act, eqp * (1.0 + rt + gt), eq)
bust = act & ((eq_low < 1.0 - dd) | (rt + gt < -dl))
alive &= ~bust
ok = act & ~bust & (eq >= 1.0 + tgt)
tpass[ok] = t
passed |= ok
return dict(p_pass=float(passed.mean()), passed=passed,
t_pass=float(np.median(tpass[passed])) if passed.any() else np.nan)
def funded_sim(R: np.ndarray, G: np.ndarray, idx: np.ndarray, lev: float, firm: str,
notional: float, lens: str = "coupled") -> dict:
"""P(vivo a fine orizzonte) + payout netto cumulato (prelievo mensile, base ripristinata)."""
ml, dl = FIRMS[firm]["fu"]
r = R[idx] * lev
g = (G[idx] * lev) if lens == "coupled" else np.zeros_like(r)
P, D = r.shape
eq = np.ones(P)
alive = np.ones(P, bool)
payout = np.zeros(P)
alive_at = {}
for t in range(D):
rt, gt = r[:, t], g[:, t]
eqp = eq
eq = np.where(alive, eq * (1.0 + rt), eq)
eq_low = np.where(alive, eqp * (1.0 + rt + gt), eq)
bust = alive & ((eq_low < 1.0 - ml) | (rt + gt < -dl))
alive &= ~bust
if (t + 1) % 30 == 0:
take = alive & (eq > 1.0)
payout += np.where(take, (eq - 1.0) * notional, 0.0) * SPLIT * (1.0 - TAX_RATE)
eq = np.where(take, 1.0, eq)
if (t + 1) % 365 == 0:
alive_at[(t + 1) // 365] = float(alive.mean())
return dict(p_alive=float(alive.mean()), alive_at=alive_at,
e_payout=float(payout.mean()), payout=payout)
def objective(w: dict, lev: float, firm: str, ev_idx: np.ndarray, fu_idx: np.ndarray,
lens: str = "coupled", factor: float = DELUCK,
notional: float = 100_000.0) -> dict:
R, G = book_RG(w)
R = deluck(R, factor)
e = eval_sim(R, G, ev_idx, lev, firm, lens)
f = funded_sim(R, G, fu_idx, lev, firm, notional, lens)
ann = float(R.mean() * 365.0)
vol = float(R.std() * np.sqrt(365.0))
return dict(J=e["p_pass"] * f["p_alive"], p_pass=e["p_pass"], p_alive=f["p_alive"],
e_payout=f["e_payout"], sharpe=(ann / vol if vol > 0 else 0.0),
vol=vol, drift=ann, t_pass=e["t_pass"])
# ================================================================== §2. IL CONTO ANALITICO
def p_hit_target_first(mu: float, sig: float, lev: float, T: float, D: float) -> float:
"""Gambler's ruin per moto browniano con drift: P(toccare +T prima di -D), SENZA limite di
tempo. theta = 2*mu/(k*sigma^2): la leva entra SOLO dividendo theta -> con leva -> inf la
probabilita' tende a D/(T+D) (37.5% con 10%/6%), qualunque sia lo Sharpe."""
if sig <= 0:
return float(mu > 0)
theta = 2.0 * mu / (lev * sig * sig)
if abs(theta) < 1e-12:
return D / (T + D)
return float((1.0 - np.exp(-theta * D)) / (1.0 - np.exp(-theta * (T + D))))
# ================================================================== §3. GRIGLIA / NULL
def simplex_grid(names: tuple, step_den: int) -> list[dict]:
"""Tutti i vettori di peso a passo 1/step_den sul simplesso di `names` (somma 1)."""
out = []
n = len(names)
def rec(prefix, rem, k):
if k == n - 1:
out.append(dict(zip(names, [p / step_den for p in prefix] + [rem / step_den])))
return
for v in range(rem + 1):
rec(prefix + [v], rem - v, k + 1)
rec([], step_den, 0)
return out
def wkey(w: dict) -> str:
return "/".join(f"{int(round(100 * w.get(nm, 0.0))):d}" for nm in CRYPTO_PROP)
def grid_scan(grid: list[dict], levs: tuple, firm: str, ev_idx: np.ndarray, fu_idx: np.ndarray,
lens: str = "coupled", factor: float = DELUCK) -> pd.DataFrame:
rows = []
for w in grid:
for lev in levs:
o = objective(w, lev, firm, ev_idx, fu_idx, lens, factor)
rows.append(dict(w=wkey(w), lev=lev, **{k: v for k, v in o.items()}))
return pd.DataFrame(rows)
def random_weights(n: int, names: tuple, seed: int, floor: float = 0.0,
caps: dict | None = None) -> list[dict]:
"""Stesso campionatore di `portfolio.weights_tilt_null` (dirichlet uniforme sul simplesso +
rejection su floor/caps), ma qui serve a costruire il NULL della funzione obiettivo J."""
rng = np.random.default_rng(seed)
caps_v = np.array([(caps or {}).get(nm, 1.0) for nm in names], float)
out = []
while len(out) < n:
b = rng.dirichlet(np.ones(len(names)), size=4 * n + 64)
ok = (b >= floor).all(axis=1) & (b <= caps_v).all(axis=1)
out.extend(b[ok])
return [dict(zip(names, v)) for v in out[:n]]
# ================================================================== main
def hr(t: str = "") -> None:
print("\n" + "-" * 104)
if t:
print(f" {t}")
print("-" * 104)
def main() -> None:
t0 = time.time()
print("=" * 104)
print(" PROP-ALLOC — allocare per la BARRIERA (P(pass) x P(vivo 12m)) invece che per lo Sharpe")
print("=" * 104)
# ---------------------------------------------------------------- A. pannello + sanity
idx = _panel_index(START_WIN)
print(f"\n finestra comune ai 5 sleeve: {len(idx)} giorni {idx[0].date()} -> {idx[-1].date()}"
f" (XS01 nasce nel 2024: e' anche la sua finestra di SCOPERTA -> lo favorisce)")
print(f" de-luck sul drift x{DELUCK} (misurato 26/07) | bootstrap a blocchi di {BLOCK}g, "
f"indici CONDIVISI fra configurazioni | {N_PATHS} percorsi")
off = {"TP01": _d(_tp01_returns()), "SKH01": _d(_skyhook_returns()), "XS01": _d(_xsec_returns()),
"VRP01": _d(_vrp_combo_returns()), "GTAA01": _d(_gtaa_daily_returns())}
rec = {"TP01": _crypto(1.0, 0.0)["R"], "SKH01": _crypto(0.0, 1.0)["R"],
"XS01": pc.xsec_daily_tuples()["R"], "VRP01": vrp_daily_tuples()["R"],
"GTAA01": gtaa_daily_tuples()["R"]}
print("\n sanity — la ricostruzione (R) deve coincidere con lo sleeve ufficiale sulla chiusura:")
for nm in SLEEVES:
com = off[nm].index.intersection(rec[nm].index)
dmax = float(np.abs(off[nm].reindex(com).values - rec[nm].reindex(com).values).max())
note = "" if nm not in ("TP01", "SKH01") else " (recon MTM orario: lente diversa per costruzione)"
print(f" {nm:>7}: max|delta| = {dmax:.2e} {'OK' if dmax < 1e-9 else 'DIVERGE'}{note}")
hr("A. PROFILO DEGLI SLEEVE SOTTO BARRIERA — cio' che conta non e' lo Sharpe, e si vede qui")
print(f" {'sleeve':>8} {'Sharpe':>7} {'vol a':>7} {'drift a':>8} {'skew':>7} "
f"{'giorno peggiore':>16} {'gap p90':>9} {'gap peggiore':>13} {'g/vol':>7}")
prof = {}
for nm in SLEEVES:
R, G = book_RG({nm: 1.0})
Rd = deluck(R)
vol = Rd.std() * np.sqrt(365.0)
dr = Rd.mean() * 365.0
sk = float(pd.Series(Rd).skew())
prof[nm] = dict(sharpe=dr / vol if vol > 0 else 0.0, vol=vol, drift=dr, skew=sk,
worst=Rd.min(), gap90=np.percentile(-G, 90), gapw=G.min())
print(f" {nm:>8} {prof[nm]['sharpe']:>7.2f} {vol:>6.1%} {dr:>8.1%} {sk:>7.2f} "
f"{Rd.min():>15.2%} {-np.percentile(-G, 90)*100:>8.2f}pp {G.min()*100:>12.2f}pp "
f"{(-G.min()/vol) if vol > 0 else 0:>7.2f}")
print("\n lettura: `gap peggiore` e' l'escursione intraday che la lente close-only NON vede;")
print(" `g/vol` la misura in unita' di vol annua. VRP01 ha gap 0 **per cecita' del dato**,")
print(" non per assenza di rischio (mark infra-settimana misurato il 30/07: mediana -6.9%,")
print(" minimo -81.7% del capitale su un trade finito in UTILE).")
hr("B. IL CONTO ANALITICO — perche' con leva LIBERA la barriera premia lo SHARPE, non la vol")
tgt, dd, _dl = FIRMS["HYRO"]["ev"]
print(f" P(toccare +{tgt:.0%} prima di -{dd:.0%}), gambler's ruin senza limite di tempo, "
f"theta = 2*mu/(k*sigma^2):")
print(f" {'sleeve':>8} {'Sharpe':>7} | " + " ".join(f"{f'k={k:g}':>8}" for k in (0.25, 0.5, 1.0, 2.0, 4.0)))
for nm in SLEEVES:
p = prof[nm]
cells = " ".join(f"{p_hit_target_first(p['drift'], p['vol'], k, tgt, dd):>8.1%}"
for k in (0.25, 0.5, 1.0, 2.0, 4.0))
print(f" {nm:>8} {p['sharpe']:>7.2f} | {cells}")
print(f" {'limite k->inf':>16} | " + " ".join([f"{dd/(tgt+dd):>8.1%}"] * 5)
+ " <- D/(T+D): la leva alta CANCELLA l'edge")
print("\n Alla leva minima che porta a +T dentro l'orizzonte (k ~ T/mu) resta theta ~ 2*S^2/T:")
print(" la VOL sparisce e conta lo Sharpe AL QUADRATO. Percio' la previsione registrata prima")
print(" di misurare e': l'argmax di J sui PESI ~ l'argmax di Sharpe, e il contenuto vero e'")
print(" nella LEVA (che lo Sharpe non determina) e nella deviazione dal gaussiano.")
# ---------------------------------------------------------------- C. griglia
n_hist = len(idx)
EV_H, FU_H = 365, 365
ev_idx = boot_idx(n_hist, EV_H, N_PATHS, seed=8220)
fu_idx = boot_idx(n_hist, FU_H, N_PATHS, seed=8221)
LEVS = (0.25, 0.375, 0.50, 0.625, 0.75, 1.00, 1.25, 1.50)
STEP = 8
grid = simplex_grid(CRYPTO_PROP, STEP)
hr(f"C. GRIGLIA — {len(grid)} vettori di peso (passo 1/{STEP}) x {len(LEVS)} leve "
f"= {len(grid)*len(LEVS)} celle, firm HYRO (eval +10%/-6%/-4%, funded -6%/-4%)")
print(f" orizzonte eval {EV_H}g, funded {FU_H}g. Lente ACCOPPIATA. "
f"J = P(pass) x P(vivo 12m).")
t = time.time()
G3 = grid_scan(grid, LEVS, "HYRO", ev_idx, fu_idx)
print(f" ({time.time()-t:.0f}s)")
best = G3.loc[G3["J"].idxmax()]
# argmax di SHARPE: lo Sharpe non dipende dalla leva -> si sceglie il peso, poi la leva
# migliore PER QUEL PESO secondo J (altrimenti non e' un confronto onesto).
w_sh = G3.loc[G3["sharpe"].idxmax(), "w"]
sub_sh = G3[G3["w"] == w_sh]
best_sh = sub_sh.loc[sub_sh["J"].idxmax()]
live = G3[(G3["w"] == "75/25/0")]
best_live = live.loc[live["J"].idxmax()]
print(f"\n {'':>22} {'pesi TP/SKH/XS':>16} {'leva':>6} {'J':>7} {'P(pass)':>8} "
f"{'P(vivo12m)':>11} {'Sharpe':>7} {'vol':>6} {'E[pay/a]':>10} {'g.al pass':>10}")
for lab, row in (("argmax J", best), ("argmax Sharpe", best_sh),
("libro LIVE 75/25", best_live)):
print(f" {lab:>22} {row['w']:>16} {row['lev']:>6.3f} {row['J']:>7.3f} "
f"{row['p_pass']:>8.1%} {row['p_alive']:>11.1%} {row['sharpe']:>7.2f} "
f"{row['vol']:>6.1%} {row['e_payout']:>9,.0f}$ {row['t_pass']:>10.0f}")
print("\n Le prime 8 celle per J:")
print(f" {'#':>3} {'pesi':>16} {'leva':>6} {'J':>7} {'P(pass)':>8} {'P(vivo)':>8} "
f"{'Sharpe':>7} {'E[pay/a]':>10}")
for i, (_, r) in enumerate(G3.sort_values("J", ascending=False).head(8).iterrows(), 1):
print(f" {i:>3} {r['w']:>16} {r['lev']:>6.3f} {r['J']:>7.3f} {r['p_pass']:>8.1%} "
f"{r['p_alive']:>8.1%} {r['sharpe']:>7.2f} {r['e_payout']:>9,.0f}$")
print("\n E per E[payout] (obiettivo DIVERSO: un flusso, non una sopravvivenza):")
print(f" {'#':>3} {'pesi':>16} {'leva':>6} {'E[pay/a]':>10} {'J':>7} {'P(pass)':>8} {'P(vivo)':>8}")
for i, (_, r) in enumerate(G3.sort_values("e_payout", ascending=False).head(4).iterrows(), 1):
print(f" {i:>3} {r['w']:>16} {r['lev']:>6.3f} {r['e_payout']:>9,.0f}$ {r['J']:>7.3f} "
f"{r['p_pass']:>8.1%} {r['p_alive']:>8.1%}")
hr("C-bis. LA LEVA — la variabile che lo Sharpe non determina e che decide tutto")
print(f" pesi = argmax J ({best['w']}):")
print(f" {'leva':>7} {'J':>7} {'P(pass) 365g':>13} {'P(pass) 1095g':>14} {'P(vivo 12m)':>12} "
f"{'E[pay/a]':>10}")
ev_long = boot_idx(n_hist, 1095, N_PATHS, seed=8222)
w_best = dict(zip(CRYPTO_PROP, [int(x) / 100 for x in best["w"].split("/")]))
Rb, Gb = book_RG(w_best)
Rb = deluck(Rb)
for lev in LEVS:
o = objective(w_best, lev, "HYRO", ev_idx, fu_idx)
pl_long = eval_sim(Rb, Gb, ev_long, lev, "HYRO")["p_pass"]
print(f" {lev:>7.3f} {o['J']:>7.3f} {o['p_pass']:>12.1%} {pl_long:>13.1%} "
f"{o['p_alive']:>11.1%} {o['e_payout']:>9,.0f}$")
print("\n A 365 giorni P(pass) e' a CAMPANA nella leva (a leva bassa il drift non arriva a +10%")
print(" in tempo); senza limite di tempo — che e' la regola vera di HyroTrader — e' MONOTONA")
print(" DECRESCENTE, come dice il conto analitico. Le due colonne dicono cose diverse e vanno")
print(" lette insieme: la leva alta non aumenta la probabilita' di farcela, aumenta la FRETTA.")
# ---------------------------------------------------------------- D. i null
hr("D. NULL 1 — l'argmax e' speciale, o e' solo il MASSIMO DI 45 ESTRAZIONI?")
rw = random_weights(300, CRYPTO_PROP, seed=8223)
lev_star = float(best["lev"])
Jr = np.array([objective(w, lev_star, "HYRO", ev_idx, fu_idx)["J"] for w in rw])
pctl = float((Jr < best["J"]).mean() * 100.0)
bestofk = 100.0 * len(grid) / (len(grid) + 1)
print(f" J dell'argmax = {best['J']:.3f} alla leva {lev_star:g}")
print(f" null: 300 vettori di peso casuali uniformi sul simplesso (stesso campionatore di")
print(f" `portfolio.weights_tilt_null`), valutati alla STESSA leva")
print(f" mediana {np.median(Jr):.3f} | p90 {np.percentile(Jr, 90):.3f} | "
f"max {Jr.max():.3f} | frazione sopra il LIVE 75/25 = {(Jr > best_live['J']).mean():.0%}")
print(f" percentile dell'argmax fra i casuali = {pctl:.1f}° contro la firma best-of-"
f"{len(grid)} = {bestofk:.1f}°")
print(f" -> {'DENTRO' if pctl <= bestofk else 'SOPRA'} la firma del massimo-di-k: "
f"{'nessuna evidenza che quel vettore sia speciale' if pctl <= bestofk else 'vettore particolare'}")
print(f" (la meta' bassa del null e' pero' informativa: il LIVE 75/25 e' battuto dal "
f"{(Jr > best_live['J']).mean():.0%} dei pesi CASUALI)")
hr("D-bis. NULL 2 — selezione IN-SAMPLE-ONLY (la scelta guardando J e' selezione come ogni altra)")
half = n_hist // 2
R_all = {}
for w in grid:
R_, G_ = book_RG(w)
R_all[wkey(w)] = (deluck(R_), G_)
ev_h = boot_idx(half, EV_H, N_PATHS, seed=8224)
fu_h = boot_idx(half, FU_H, N_PATHS, seed=8225)
def _J(key, lev, sl):
R_, G_ = R_all[key]
e = eval_sim(R_[sl], G_[sl], ev_h, lev, "HYRO")["p_pass"]
f = funded_sim(R_[sl], G_[sl], fu_h, lev, "HYRO", 100_000.0)["p_alive"]
return e * f
IS, OOS = slice(0, half), slice(half, n_hist)
rows = []
for w in grid:
k = wkey(w)
for lev in LEVS:
rows.append((k, lev, _J(k, lev, IS), _J(k, lev, OOS)))
S = pd.DataFrame(rows, columns=["w", "lev", "J_is", "J_oos"])
pick = S.loc[S["J_is"].idxmax()]
oracle = S.loc[S["J_oos"].idxmax()]
live_oos = S[(S["w"] == "75/25/0")].sort_values("J_oos", ascending=False).iloc[0]
print(f" prima meta' {idx[0].date()}->{idx[half-1].date()} | seconda {idx[half].date()}->{idx[-1].date()}")
print(f" {'':>28} {'pesi':>16} {'leva':>6} {'J in-sample':>12} {'J out-of-sample':>16}")
print(f" {'scelto SUL SOLO in-sample':>28} {pick['w']:>16} {pick['lev']:>6.3f} "
f"{pick['J_is']:>12.3f} {S[(S['w']==pick['w'])&(S['lev']==pick['lev'])]['J_oos'].iloc[0]:>16.3f}")
print(f" {'oracolo (max sull OOS)':>28} {oracle['w']:>16} {oracle['lev']:>6.3f} "
f"{oracle['J_is']:>12.3f} {oracle['J_oos']:>16.3f}")
print(f" {'libro LIVE 75/25 (baseline)':>28} {live_oos['w']:>16} {live_oos['lev']:>6.3f} "
f"{live_oos['J_is']:>12.3f} {live_oos['J_oos']:>16.3f}")
rho = float(S["J_is"].corr(S["J_oos"], method="spearman"))
print(f" Spearman(J in-sample, J out-of-sample) su {len(S)} celle = {rho:+.2f}")
print(" -> se rho e' alto la mappa e' stabile e la scelta si trasferisce; se e' ~0 l'argmax")
print(" e' rumore e la decisione va presa sul MECCANISMO (la leva), non sulla cella.")
hr("D-ter. NULL DEL DE-LEVERING — la leva e' GIA' dentro l'ottimizzazione, ma va detto")
print(" Il null classico ('esiste k<1 che da' lo stesso DD con Sharpe migliore?') qui e'")
print(" strutturale: ogni configurazione e' valutata a OTTO leve, quindi nessuna puo' vincere")
print(" solo perche' e' meno esposta. La domanda che resta e': la MIGLIOR leva del libro LIVE")
print(" raggiunge il J dell'argmax?")
print(f" argmax J : {best['w']:>10} @ {best['lev']:g}x -> J = {best['J']:.3f}")
print(f" LIVE 75/25 al suo : {best_live['w']:>10} @ {best_live['lev']:g}x -> J = {best_live['J']:.3f}")
dJ = best["J"] - best_live["J"]
se = np.sqrt(0.25 / N_PATHS) * 2
print(f" delta = {dJ:+.3f} (errore MC su livelli ~+-{se:.3f}; le due celle condividono")
print(f" gli indici di bootstrap -> il delta e' APPAIATO e il suo errore e' piu' piccolo)")
# ---------------------------------------------------------------- E. banda d'ancora
hr("E. BANDA D'ANCORA — non ri-misurata sotto questa lente (costo); si usa il FATTORE misurato")
print(" Ri-fare i 24x23x10 offset col recon MTM orario e' fuori budget. Si applica il fattore")
print(" di drift MISURATO il 26/07 (x0.89 sul libro live) e si controlla cio' che conta: se")
print(" l'ORDINE fra le configurazioni sopravvive alla banda 0.60 (stima 25/07, ri-misurata")
print(" troppo severa) - 1.00 (nessuna correzione).")
cands = [best["w"], best_sh["w"], best_live["w"]]
print(f"\n {'fattore':>9} " + " ".join(f"{('J ' + c):>14}" for c in dict.fromkeys(cands))
+ f" {'argmax pesi':>14} {'argmax leva':>11}")
for fct in (1.00, 0.89, 0.60):
cells = []
for c in dict.fromkeys(cands):
wc = dict(zip(CRYPTO_PROP, [int(x) / 100 for x in c.split("/")]))
sub = [objective(wc, lv, "HYRO", ev_idx, fu_idx, factor=fct)["J"] for lv in LEVS]
cells.append(max(sub))
g = grid_scan(grid, LEVS, "HYRO", ev_idx, fu_idx, factor=fct)
b = g.loc[g["J"].idxmax()]
print(f" {fct:>9.2f} " + " ".join(f"{v:>14.3f}" for v in cells)
+ f" {b['w']:>14} {b['lev']:>11.3f}")
# ---------------------------------------------------------------- F. VRP01 / GTAA01
hr("F. E GLI SLEEVE 'INUTILI A $600'? — l'asimmetria del 25/07 §4 vale per XS01, non per tutti")
print(" Su un conto funded da $100k gli sleeve fuori portata a $600 diventano ESEGUIBILI per")
print(" TAGLIA. Ma la taglia non e' l'unico vincolo: una firm CRYPTO non lista opzioni Deribit")
print(" (VRP01) ne' ETF azionari USA (GTAA01, che al retail UE e' pure bloccato dal PRIIPs).")
print(" Qui si misura comunque il CONTROFATTUALE: se fossero disponibili, aiuterebbero?")
print(f"\n {'aggiunta al ' + best['w']:>34} {'leva':>6} {'J':>7} {'P(pass)':>8} "
f"{'P(vivo)':>8} {'Sharpe':>7} {'E[pay/a]':>10}")
for extra, wx in [(None, 0.0), ("VRP01", 0.10), ("VRP01", 0.20),
("GTAA01", 0.10), ("GTAA01", 0.20)]:
w2 = {k: v * (1.0 - wx) for k, v in w_best.items()}
if extra:
w2[extra] = wx
rows = [(lv, objective(w2, lv, "HYRO", ev_idx, fu_idx)) for lv in LEVS]
lv, o = max(rows, key=lambda x: x[1]["J"])
lab = "nessuna (base)" if extra is None else f"{extra} @ {wx:.0%}"
print(f" {lab:>34} {lv:>6.3f} {o['J']:>7.3f} {o['p_pass']:>8.1%} {o['p_alive']:>8.1%} "
f"{o['sharpe']:>7.2f} {o['e_payout']:>9,.0f}$")
print("\n ATTENZIONE: la riga VRP01 e' calcolata con **gap = 0** (il mark infra-settimana non")
print(" esiste nel dataset dello sleeve): e' un TETTO, non una stima. Con il mark misurato il")
print(" 30/07 (peggiore infra-settimana mediano -6.9%, minimo -81.7%) una barriera STATICA al")
print(" 6% verrebbe sfondata dal mark, non dal risultato.")
# ---------------------------------------------------------------- G. FTMO
hr("G. LA BARRIERA CAMBIA LA RISPOSTA? — HYRO (-6% statico) vs FTMO (-10% statico)")
print(f" {'firm':>6} {'argmax pesi':>16} {'leva':>6} {'J':>7} {'P(pass)':>8} {'P(vivo12m)':>11} "
f"{'E[pay/a]':>10} | {'LIVE 75/25: leva':>17} {'J':>7}")
for fm in ("HYRO", "FTMO"):
g = grid_scan(grid, LEVS, fm, ev_idx, fu_idx)
b = g.loc[g["J"].idxmax()]
lv2 = g[g["w"] == "75/25/0"]
b2 = lv2.loc[lv2["J"].idxmax()]
print(f" {fm:>6} {b['w']:>16} {b['lev']:>6.3f} {b['J']:>7.3f} {b['p_pass']:>8.1%} "
f"{b['p_alive']:>11.1%} {b['e_payout']:>9,.0f}$ | {b2['lev']:>17.3f} {b2['J']:>7.3f}")
print("\n La barriera piu' larga di FTMO sposta l'ottimo di leva verso l'ALTO: il vincolo e' la")
print(" barriera, non il libro. E' la stessa lettura del 25/07 §4 ('sul canale funded il")
print(" vincolo binding e' la regola di DD, non il capitale'), qui misurata sull'ottimo.")
# ---------------------------------------------------------------- H. gate ufficiale sui pesi
hr("H. GATE UFFICIALE SUI PESI (`portfolio.weights_tilt_null`) — sullo SHARPE, come previsto")
cols = {nm: off[nm] for nm in CRYPTO_PROP}
try:
res = weights_tilt_null(cols, {"TP01": 0.75, "SKH01": 0.25, "XS01": 0.0001},
{nm: max(w_best.get(nm, 0.0), 1e-4) for nm in CRYPTO_PROP},
floor=0.0, n=300, k_seen=len(grid) * len(LEVS))
print(f" proposta {best['w']} vs LIVE 75/25 : delta_insample {res['delta_insample']:+.4f} | "
f"delta_hold {res['delta_hold']:+.4f} | pctl_hold {res['pctl_hold']:.1f}° "
f"(best-of-k {res['bestofk_pctl']:.1f}°) | gate_pass = {res['gate_pass']}")
except Exception as exc: # pragma: no cover
print(f" non girato: {exc}")
print(" NB: questo gate giudica lo SHARPE del book di ricerca, non J. Un cambio dei pesi del")
print(" LIBRO LIVE resta soggetto a lui; qui si progetta un conto SEPARATO (funded), che non")
print(" e' il libro live e non passa da questo gate.")
# ---------------------------------------------------------------- I. tavola operativa
hr("I. TAVOLA OPERATIVA — le 3 configurazioni migliori, e quanti conti servono per 50 EUR/g")
top = G3.sort_values("J", ascending=False).drop_duplicates("w").head(3)
rows_ops = []
fu3 = boot_idx(n_hist, 1095, N_PATHS, seed=8226)
print(f" {'pesi TP/SKH/XS':>16} {'leva':>6} {'P(pass)':>8} {'P(vivo12m)':>11} "
f"{'E[payout 3a]':>13} {'EUR/g per conto':>16} {'conti per 50/g':>15}")
for _, r in top.iterrows():
w2 = dict(zip(CRYPTO_PROP, [int(x) / 100 for x in r["w"].split("/")]))
R_, G_ = book_RG(w2)
R_ = deluck(R_)
f3 = funded_sim(R_, G_, fu3, float(r["lev"]), "HYRO", 100_000.0)
eur_day = f3["e_payout"] / EURUSD / 3.0 / 365.0
n_acct = (TARGET_EUR_DAY / eur_day) if eur_day > 0 else np.inf
rows_ops.append((r["w"], float(r["lev"]), f3["e_payout"], eur_day, n_acct))
print(f" {r['w']:>16} {r['lev']:>6.3f} {r['p_pass']:>8.1%} {r['p_alive']:>11.1%} "
f"{f3['e_payout']:>12,.0f}$ {eur_day:>15.2f} {n_acct:>15.1f}")
print("\n E[payout 3a] e' su UN conto funded da $100k, gia' netto split 80% e fisco 33%, e")
print(" MEDIA su tutti i percorsi (i morti contano zero). 'conti per 50/g' e' quindi un numero")
print(" ATTESO, non una garanzia: la distribuzione e' bimodale (vivo / bustato).")
cap_tot = sum(f["cap"] for f in pl.FIRMS)
print(f" ⚠ TETTO STRUTTURALE: cap per trader ${FIRMS['HYRO']['cap']:,.0f} (HYRO) + "
f"${FIRMS['FTMO']['cap']:,.0f} (FTMO) = ${cap_tot:,.0f} di nozionale su due firm")
print(f" -> al massimo {cap_tot/100_000:.0f} conti da $100k. Se ne servono di piu', servono "
f"ALTRE FIRM (e ognuna aggiunge rischio di controparte).")
hr("I-bis. LA SCALA DI CONTI — macchineria del 25/07 (`r0725_prop_coupled.simulate`), "
"de-luck portato a x0.89")
print(f" 36 mesi, EUR {pl.START_EUR:.0f} di cassa iniziale, max {pl.MAX_CONCURRENT} conti, "
f"morte-firm {pl.FIRM_DEATH_PER_YEAR:.0%}/anno, lente ACCOPPIATA")
pc.DELUCK = DELUCK # il 25/07 girava a 0.6 (poi ri-misurato troppo severo)
cfg_best = tuple(w_best.get(nm, 0.0) for nm in CRYPTO_PROP)
cfg_live = (0.75, 0.25, 0.0)
cfg_B = (0.55, 0.20, 0.25)
pols = {
f"argmax-J {best['w']} su tutti i conti": ([cfg_best], [0] * pl.MAX_CONCURRENT),
"LIVE 75/25 su tutti i conti (baseline)": ([cfg_live], [0] * pl.MAX_CONCURRENT),
"config B 55/20/25 (scelta a mano 25/07)": ([cfg_B], [0] * pl.MAX_CONCURRENT),
}
print(f"\n {'politica':>44} {'leva':>6} {'EUR/g med':>10} {'p90':>8} {'P(>=10/g)':>10} "
f"{'P(>=50/g)':>10} {'P(zero)':>8}")
for pname, (cfgs, slots) in pols.items():
for lev in (float(best["lev"]), 0.75):
r = pc.simulate(cfgs, slots, n_paths=2000, lev=lev, seed=20260822,
lens="coupled", start=START_WIN)
print(f" {pname:>44} {lev:>6.3f} {r['med']:>10.2f} {r['p90']:>8.2f} "
f"{r['p_ge10']:>10.1%} {r['p_target']:>10.1%} {r['p_zero']:>8.1%}")
print("\n" + "=" * 104)
print(f" fatto in {time.time()-t0:.0f}s")
print("=" * 104)
if __name__ == "__main__":
main()