"""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 = 2500 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: try: sys.stdout.reconfigure(line_buffering=True) except Exception: # pragma: no cover pass t0 = time.time() def tick(lab: str) -> None: print(f" [{time.time()-t0:6.0f}s] {lab}") 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") tick("pannello costruito") 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).") tick("A. profilo sleeve") 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).") tick("B. conto analitico") 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 tick("C. griglia + leva") hr("D. NULL 1 — l'argmax e' speciale, o e' solo il MASSIMO DI 45 ESTRAZIONI?") rw = random_weights(200, 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: {len(rw)} 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)") tick("D. null casuale") 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.") tick("D-bis. selezione in-sample") 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). L'argmax e' ri-cercato sulle 12 celle-peso") print(" migliori (non su tutte le 45: costo), il che e' conservativo per la STABILITA'.") cands = [best["w"], best_sh["w"], best_live["w"]] top_keys = list(G3.sort_values("J", ascending=False).drop_duplicates("w")["w"].head(12)) top_w = [dict(zip(CRYPTO_PROP, [int(x) / 100 for x in k.split("/")])) for k in top_keys] 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(top_w, 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 tick("E. banda ancora") 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 tick("F. VRP/GTAA") 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 = G3 if fm == "HYRO" else 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 tick("G. FTMO") 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=200, 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 tick("H. gate pesi") 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).") tick("I. tavola operativa") 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=1500, 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()