diff --git a/scripts/research/r0823_xs_ampiezza.py b/scripts/research/r0823_xs_ampiezza.py new file mode 100644 index 0000000..8a496fb --- /dev/null +++ b/scripts/research/r0823_xs_ampiezza.py @@ -0,0 +1,1109 @@ +"""XS-AMPIEZZA (§56) — su un universo POVERO, una costruzione cross-sectional puo' recuperare +AMPIEZZA EFFETTIVA senza aggiungere gambe? E quanto del divario 1,265 -> 0,116 recupera? + +DA DOVE VIENE LA DOMANDA +------------------------ +XS01 e' l'unico meccanismo che abbia mai sfondato il soffitto direzionale ~1,3 di questo progetto, +e sta fermo per VENUE: gira su Hyperliquid, che la decisione del 26/07 tiene fuori fino a $20k. +Poche ore fa §50 (`r0823_book_3rd.py`) ha misurato la versione eseguibile su Deribit — le 11 gambe +quotate da >=1 anno — e ha trovato che collassa (Sharpe 1,265 -> 0,116), separando due cause con un +null dei sottoinsiemi: il sottoinsieme MEDIANO da 11 fa gia' 0,548 (=> "il 62% della caduta e' +AMPIEZZA") e quello di Deribit sta al 9° percentile (=> "sfortuna di listino"). + +E c'e' un precedente che punta nella stessa direzione: il 25/07 XSR01 nacque dalla DIAGNOSI di un +fallimento — l'ampiezza effettiva di un paniere di 50 coppie era 4,5 perche' condividevano la gamba +BTC; il demeaning cross-sezionale la porto' a 37,4 e lo Sharpe da 0,82 a 1,82. L'ampiezza effettiva +non e' il numero di gambe: e' il numero di scommesse indipendenti, e si puo' guadagnare senza +aggiungere strumenti. + +ATTESA A PRIORI — DICHIARATA PRIMA DI MISURARE (coordinatore + mia) +------------------------------------------------------------------ + (A1) Il 62% della caduta e' ampiezza, e l'ampiezza NON si compra con la costruzione: il demeaning + funziono' su 50 gambe perche' c'era un fattore comune grosso da togliere; su 11 c'e' molto + meno da togliere e il recupero sara' parziale e SOTTO l'MDE. + (A2) Il gate e' N_eff: se N_eff delle 11 e' gia' vicino a 11, non c'e' niente da recuperare. + (A3) Mi aspetto che il muro NON sia l'eseguibilita' (nell'ondata del 22/08 non ha bocciato niente + per sei volte di fila) ma la storia corta: 2,6 anni, in-sample = il solo 2024. + (A4) I due affinamenti del 19/06 (blend [30,90] e gate p30) non si replicano fuori campione + (misurato il 22/08): mi aspetto che il meccanismo NUDO non sia peggiore. + ESITO (scritto dopo, per onesta' di lettura): A1 giusta nella CONCLUSIONE e SBAGLIATA nel + MECCANISMO — l'ampiezza non e' quasi per niente il problema; A2 confermata; A3 confermata; + A4 confermata sul FULL. E l'etichetta "ampiezza" di §50 e' cio' che questo filone corregge. + +LA FAMIGLIA, DICHIARATA PRIMA (e contata AL RIALZO nel deflated-Sharpe) +---------------------------------------------------------------------- +Griglia cartesiana su universo 11-Deribit, 80 celle: + kind in {canon, resid (residuo vs BTC prima del ranking), ivol (equal-risk), + white (whitening inv-cov con shrinkage 50%)} 4 + k in {1,2,3,4,5} (k=5 e' il canonico, tarato su A=19) 5 + lookbacks in {(30,90) col gate di dispersione p30, (30,) NUDO senza gate} 2 + H in {10, 20} (holding piu' lungo) 2 +Il DEMEANING cross-sezionale — la costruzione che creo' XSR01 — e' fuori griglia perche' su XS01 +e' un NO-OP ALGEBRICO, e questo si dimostra invece di misurarlo (sezione 3). + +METODO +------ + * ampiezza effettiva: DUE definizioni, perche' quella importata degenera qui (sezione 3); + * de-luck di FASE: XS01 ha un ciclo H=10 e i suoi numeri headline sono sulla fase 0, che + CLAUDE.md registra come fortunata -> ogni confronto e' la MEDIANA DELLE DIFFERENZE APPAIATE + sulle 10 fasi, mai la differenza delle mediane (lezione 26/07); + * null di PERMUTAZIONE cross-sezionale a FEE ZERO (25/07: permutare un segnale ne fa esplodere il + turnover, e un null a fee piena perderebbe per COSTO invece che per assenza d'informazione); + * gate onesto coi TRE componenti reali di `study_family_honest` (selezione in-sample-only -> + `altlib.deflated_sharpe` -> `altlib.marginal_vs_tp01`), importati, non riscritti; + * MDE dichiarato in barre ATTIVE, non in giorni di calendario; + * controlli positivi obbligatori su OGNI rilevatore (un rilevatore che trova ~0 e non e' validato + e' indistinguibile da uno rotto). + +VINCOLI DI ESECUZIONE: sola lettura, nessun ordine, solo GET pubbliche Deribit con pacing e +astensione nei minuti :05-:10 e :24-:30 (finestre del cron di produzione). + + uv run python scripts/research/r0823_xs_ampiezza.py # completo + uv run python scripts/research/r0823_xs_ampiezza.py --no-net # solo cache del venue +""" +from __future__ import annotations + +import argparse +import datetime as dt +import itertools +import json +import math +import sys +import time +import urllib.request +from pathlib import Path + +import numpy as np +import pandas as pd +from scipy.stats import spearmanr + +ROOT = Path("/opt/docker/PythagorasGoal") +for _p in (str(ROOT), str(ROOT / "scripts" / "research"), + str(ROOT / "scripts" / "research" / "alt")): + if _p not in sys.path: + sys.path.insert(0, _p) + +# --- produzione (importata, MAI riscritta) --------------------------------------------------- +from src.portfolio import sleeves as SL # noqa: E402 +from src.portfolio.portfolio import HOLDOUT, metrics, to_daily # noqa: E402 + +# --- macchineria di ricerca gia' validata (importata, non riscritta) ------------------------- +import altlib as al # noqa: E402 +from r0725_statarb_basket_gate import eff_breadth as XSR_EFF_BREADTH # noqa: E402 + +# ============================================================================================= +# COSTANTI DICHIARATE +# ============================================================================================= +SCRATCH = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/" + "b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad/xsamp") +if not SCRATCH.parent.exists(): + SCRATCH = Path("/tmp/pythagoras_xsamp") + +API = "https://www.deribit.com/api/v2/public" +CACHE_FRESH_S = 3 * 3600 +SEED = 20260823 +N_PHASES = 10 # ciclo H=10 di XS01 +N_SUBSETS = 40 # sottoinsiemi casuali da 11 fra le 19 +PH_SUB = (0, 2, 4, 6, 8) # fasi su cui si de-lucka il null dei sottoinsiemi +N_PERM = 200 # permutazioni del null a fee zero +CAPITAL_TODAY = 635.0 +CAP_LADDER = (635.0, 2000.0, 5000.0, 20000.0) +MIN_ORDER = 5.0 # config/live.json -> min_order_usd +W_SLEEVE = 0.15 # il peso a cui §50 ha valutato ogni candidato a 3° sleeve +FEE_SIDE = al.FEE_SIDE # 0.05%/lato, il default del progetto +ANN = math.sqrt(365.25) +T0 = time.time() +_ARGS = None + +XS_UNIVERSE = list(SL.XS_UNIVERSE) # i 19 major, LETTI dalla produzione +CFG = dict(SL.XS_CFG) # config canonica, LETTA dalla produzione + + +def sez(t: str) -> None: + print("\n" + "=" * 104) + print(f" {t}") + print("=" * 104) + + +def sub(t: str) -> None: + print("\n" + "-" * 104) + print(f" {t}") + print("-" * 104) + + +def clock() -> str: + return f"[{time.time() - T0:5.0f}s]" + + +# ============================================================================================= +# RETE — sole GET pubbliche, pacing, astensione nelle finestre del cron +# ============================================================================================= +def _pace() -> None: + while dt.datetime.now(dt.timezone.utc).minute in (5, 6, 7, 8, 9, 10, 24, 25, 26, 27, 28, 29, 30): + time.sleep(15) + time.sleep(0.55) # <= 2 req/s + + +def get_json(path: str, cache_name: str) -> dict | None: + SCRATCH.mkdir(parents=True, exist_ok=True) + fp = SCRATCH / cache_name + if _ARGS and _ARGS.no_net: + return json.loads(fp.read_text()) if fp.exists() else None + if fp.exists() and (time.time() - fp.stat().st_mtime) < CACHE_FRESH_S: + return json.loads(fp.read_text()) + try: + _pace() + with urllib.request.urlopen(f"{API}/{path}", timeout=30) as r: + d = json.loads(r.read()) + fp.write_text(json.dumps(d)) + return d + except Exception as e: # noqa: BLE001 + print(f" [rete KO su {path}: {type(e).__name__}] -> cache") + return json.loads(fp.read_text()) if fp.exists() else None + + +# ============================================================================================= +# IL MOTORE — replica di `sleeves._xsec_returns` generalizzata a (universo, fase, costruzione) +# ============================================================================================= +def prices(universe: list[str]) -> pd.DataFrame: + cols = {} + for sym in universe: + p = SL._HL_DIR / f"hl_{sym.lower()}_1d.parquet" + if not p.exists(): + continue + d = pd.read_parquet(p) + cols[sym] = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + return pd.concat(cols, axis=1, join="inner").sort_index().dropna() + + +def run(universe: list[str], kind: str = "canon", k: int | None = None, H: int | None = None, + lookbacks=None, disp_pct: int | None = None, phase: int = 0, + tv: float | None = None, fee: float = FEE_SIDE, demean: bool = False) -> dict: + """Il meccanismo XS01, un grado di liberta' per volta. Coi default e' BIT-EXACT contro + `sleeves._xsec_returns` (verificato nella sezione 2).""" + k = CFG["k"] if k is None else k + H = CFG["H"] if H is None else H + lookbacks = CFG["lookbacks"] if lookbacks is None else lookbacks + disp_pct = CFG["disp_pct"] if disp_pct is None else disp_pct + tv = CFG["target_vol"] if tv is None else tv + minhist = CFG["disp_minhist"] + + C = prices(universe) + px = C.values + n, A = px.shape + kk = min(k, A // 2) + mlb = max(lookbacks) + dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) + vol = pd.DataFrame(dret, index=C.index).rolling(30, min_periods=15).std().shift(1).values + bi = list(C.columns).index("BTC") if "BTC" in C.columns else 0 + + W = np.zeros((n, A)); w = np.zeros(A); disp_hist: list[float] = [] + for i in range(n): + if i >= mlb and i % H == phase: + rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks] + if kind == "resid": + # beta OLS causale su 90 barre di ritorni; il momentum si ranka sul RESIDUO vs BTC + seg = dret[max(1, i - 90):i + 1] + b = np.array([np.cov(seg[:, j], seg[:, bi])[0, 1] / max(np.var(seg[:, bi]), 1e-12) + for j in range(A)]) + rLs = [rL - b * rL[bi] for rL in rLs] + disp_i = float(np.mean([r.std() for r in rLs])) + thr = (np.percentile(disp_hist, disp_pct) + if (disp_pct > 0 and len(disp_hist) >= minhist) else -np.inf) + if disp_i >= thr: + score = np.zeros(A); cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + score += (rL - rL.mean()) / sd; cnt += 1 + if cnt: + score /= cnt + order = np.argsort(score) + w = np.zeros(A); lo, hi = order[:kk], order[-kk:] + w[hi] = 0.5 / kk; w[lo] = -0.5 / kk + if kind == "ivol": # equal-risk: pesi ~ 1/vol + v = np.nan_to_num(vol[i], nan=0.0) + w = w * np.where(v > 0, 1.0 / v, 0.0) + w = w - w.mean() # 1/vol rompe sum(w)=0: ri-centrare + g = np.abs(w).sum(); w = w / g if g > 0 else w + elif kind == "white": # whitening: w ~ Sigma^-1 z + seg = dret[max(1, i - 90):i + 1] + S = np.cov(seg.T) + S = 0.5 * S + 0.5 * np.diag(np.diag(S)) # shrinkage dichiarata + z = np.zeros(A); z[hi] = 1.0 / kk; z[lo] = -1.0 / kk + try: + w = np.linalg.solve(S + 1e-12 * np.eye(A), z) + except Exception: # noqa: BLE001 + pass + w = w - w.mean() + g = np.abs(w).sum(); w = w / g if g > 0 else w + else: + w = np.zeros(A) + if demean: + w = w - w.mean() + disp_hist.append(disp_i) + W[i] = w + + gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) + turn = np.zeros(n); turn[0] = np.abs(W[0]).sum() + turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) + net = gross - turn * fee + s = pd.Series(net, index=C.index) + rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25) + scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0) + return dict(net=to_daily(pd.Series(s.values * scale, index=C.index)), + W=W, dret=dret, scale=scale, cols=list(C.columns), idx=C.index) + + +def leg_pnl(R: dict, fee: float = FEE_SIDE) -> pd.DataFrame: + """P&L NETTA per gamba: e' l'oggetto su cui si misura l'ampiezza effettiva (le 'scommesse').""" + W, dret, scale = R["W"], R["dret"], R["scale"] + n, A = W.shape + g = np.zeros((n, A)); g[1:] = W[:-1] * dret[1:] + t = np.abs(np.diff(W, axis=0, prepend=0.0)) + return pd.DataFrame((g - t * fee) * scale[:, None], index=R["idx"], columns=R["cols"]) + + +def sh(s: pd.Series) -> float: + return float(metrics(s)["sharpe"]) + + +def sh_hold(s: pd.Series) -> float: + return float(metrics(s[s.index >= HOLDOUT])["sharpe"]) + + +def sh_ins(s: pd.Series) -> float: + return float(metrics(s[s.index < HOLDOUT])["sharpe"]) + + +# ============================================================================================= +# AMPIEZZA EFFETTIVA — due definizioni, perche' la prima degenera +# ============================================================================================= +def neff_rbar(M: pd.DataFrame) -> tuple[float, float]: + """La definizione usata da XSR01 (`r0725_statarb_basket_gate.eff_breadth`): + N / (1 + (N-1)*rbar) con rbar = correlazione media fuori diagonale.""" + Cm = np.nan_to_num(M.corr().values, nan=0.0) + off = Cm[~np.eye(len(Cm), dtype=bool)] + rbar = float(np.nanmean(off)); N = len(Cm) + ne = N / (1.0 + (N - 1) * rbar) if rbar > -1.0 / (N - 1) else float(N) + return ne, rbar + + +def neff_eig(M: pd.DataFrame) -> float: + """Participation ratio degli autovalori: (sum lam)^2 / sum lam^2. Sta SEMPRE in [1, N].""" + Cm = np.nan_to_num(M.corr().values, nan=0.0) + lam = np.clip(np.linalg.eigvalsh(Cm), 0.0, None) + return float(lam.sum() ** 2 / max((lam ** 2).sum(), 1e-12)) + + +# ============================================================================================= +# 1) CENSIMENTO DAL VENUE — quali gambe esistono DAVVERO, e a che lotto +# ============================================================================================= +def venue_census() -> dict: + out: dict = {"perp": {}, "read_ok": True} + ins = {} + for cur in ("BTC", "ETH", "USDC"): + d = get_json(f"get_instruments?currency={cur}&expired=false", f"instr_{cur}.json") + if d is None: + out["read_ok"] = False + continue + ins[cur] = d["result"] + bs = get_json("get_book_summary_by_currency?currency=USDC&kind=future", "bs_usdc_fut.json") + marks = {} + if bs: + for x in bs["result"]: + p = x.get("mark_price") or x.get("last") + if p: + marks[x["instrument_name"]] = (float(p), float(x.get("volume_usd") or 0.0), + x.get("bid_price"), x.get("ask_price")) + for _cur, lst in ins.items(): + for x in lst: + if x["kind"] != "future" or x.get("settlement_period") != "perpetual": + continue + n = x["instrument_name"] + mk, vol, bid, ask = marks.get(n, (None, None, None, None)) + out["perp"][x["base_currency"]] = dict( + instr=n, min_amt=float(x["min_trade_amount"]), + created=dt.datetime.fromtimestamp(x["creation_timestamp"] / 1000, + dt.timezone.utc).date().isoformat(), + mark=mk, min_usd=(mk * float(x["min_trade_amount"])) if mk else None, + vol24=vol, bid=bid, ask=ask) + return out + + +def sezione1(V: dict) -> dict: + sez("1) IL VENUE, LETTO ORA — quali delle 19 gambe di XS01 esistono su Deribit (perp USDC)") + if not V["read_ok"]: + print(" ⚠️ lista strumenti NON letta dal venue in questa corsa: si usa la cache.") + oggi = dt.date.today() + print(f" {'asset':<7}{'strumento':<26}{'quotato dal':<13}{'eta' + chr(39) + ' (g)':>10}" + f"{'lotto min':>11}{'vol 24h $':>15}{'quote':>10}") + ok, stab = [], [] + for s in XS_UNIVERSE: + p = V["perp"].get(s) + if not p: + print(f" {s:<7}{'— non quotato —':<26}{'—':<13}{'—':>10}{'—':>11}{'—':>15}{'—':>10}") + continue + ok.append(s) + eta = (oggi - dt.date.fromisoformat(p["created"])).days + if eta >= 365: + stab.append(s) + q = "bid+ask" if (p["bid"] and p["ask"]) else ("SOLO 1 LATO" if (p["bid"] or p["ask"]) + else "NESSUNA") + mu = f"${p['min_usd']:.2f}" if p["min_usd"] else "n/d" + v = f"{p['vol24']:,.0f}" if p["vol24"] is not None else "n/d" + print(f" {s:<7}{p['instr']:<26}{p['created']:<13}{eta:>10}{mu:>11}{v:>15}{q:>10}") + print(f"\n -> quotate: {len(ok)}/19 ({', '.join(ok)})") + print(f" -> quotate da >= 1 anno: {len(stab)}/19 ({', '.join(stab)})") + print(f" -> MANCANTI: {', '.join([s for s in XS_UNIVERSE if s not in ok])}") + print(" 📌 Il lotto minimo NON e' il vincolo: il piu' caro sta sotto la decina di dollari.") + print(" E' la stessa constatazione dell'ondata 22/08 (l'eseguibilita' a $600 non ha") + print(" bocciato niente per sei volte di fila). Il muro, se c'e', e' l'edge.") + return dict(quotate=ok, stabili=stab) + + +# ============================================================================================= +# 2) REPLICHE E CONTROLLI POSITIVI — prima di ogni delta +# ============================================================================================= +def sezione2(stab: list[str]) -> None: + sez("2) REPLICHE BIT-EXACT E CONTROLLI POSITIVI (un rilevatore non validato e' indistinguibile" + " da uno rotto)") + + sub("2a) il mio motore contro lo sleeve di PRODUZIONE") + mine = run(XS_UNIVERSE)["net"] + ref = to_daily(SL._xsec_returns()) + j = pd.concat({"a": mine, "b": ref}, axis=1, join="outer") + dm = float(np.nanmax(np.abs(j["a"].values - j["b"].values))) + print(f" run(19 gambe, canonico, fase 0) vs sleeves._xsec_returns : max|dif| = {dm:.3e}" + f" {'OK' if dm < 1e-12 else 'FAIL'}") + assert dm < 1e-12, "replica NON bit-exact: tutto il resto misurerebbe un'altra strategia" + print(f" Sharpe FULL 19 gambe {sh(mine):.3f} · HOLD {sh_hold(mine):.3f} " + f"(§50 riporta 1,265 col drag di funding dichiarato: qui il funding NON c'e', perche'") + print(" la domanda e' sulla STRUTTURA del segnale e il drag e' una costante additiva.)") + + sub("2b) CONTROLLO POSITIVO dello stimatore di AMPIEZZA (deve dare 1 e N sui casi noti)") + rng = np.random.default_rng(SEED) + ind = pd.DataFrame(rng.normal(size=(900, 11))) + one = pd.DataFrame(np.tile(rng.normal(size=(900, 1)), (1, 11))) + hlf = ind.copy() + common = rng.normal(size=900) + hlf = pd.DataFrame(0.7 * np.tile(common[:, None], (1, 11)) + 0.7 * ind.values) + for nm, M, atteso in (("11 gambe INDIPENDENTI", ind, "~11"), + ("11 gambe IDENTICHE", one, "1"), + ("11 gambe con fattore comune 50%", hlf, "fra 1 e 11")): + ne_r, rb = neff_rbar(M) + print(f" {nm:<34} rbar {rb:>+7.3f} N_eff(rbar) {ne_r:>8.2f} " + f"N_eff(autovalori) {neff_eig(M):>6.2f} atteso {atteso}") + print(" -> entrambi gli stimatori passano il controllo; la differenza fra i due si vede") + print(" solo quando rbar e' NEGATIVA, ed e' esattamente il caso di XS01 (sezione 3).") + + sub("2c) CONTROLLO POSITIVO dello stimatore di IC (con un segnale che BARA deve dare ~+1)") + def _t(m, se): + return "t inf (SE=0: IC costante)" if se <= 0 else f"t {m/se:+.1f}" + m, se, n = ic_stats(XS_UNIVERSE, cheat=True) + print(f" IC di un punteggio = ritorno FUTURO (look-ahead deliberato): {m:+.4f} " + f"({_t(m, se)}, n={n}) -> lo stimatore ha potenza") + m0, se0, n0 = ic_stats(XS_UNIVERSE, shuffle_seed=SEED) + print(f" IC di un punteggio PERMUTATO a caso : {m0:+.4f} " + f"({_t(m0, se0)}, n={n0}) -> e non inventa segnale dove non c'e'") + + sub("2d) identita' con la funzione importata da XSR01") + R = run(stab, k=2) + L = leg_pnl(R) + P = pd.DataFrame(R["W"], index=R["idx"], columns=R["cols"]) + S = pd.DataFrame(R["dret"], index=R["idx"], columns=R["cols"]) + ne_mine, _ = neff_rbar(L) + print(f" la mia `neff_rbar` e la `eff_breadth` di r0725_statarb_basket_gate implementano la") + print(f" STESSA formula N/(1+(N-1)*rbar); qui do' {ne_mine:.2f} sulla P&L per gamba di XS01.") + print(f" (`eff_breadth` non e' chiamabile direttamente su XS01: prende matrici di posizione e") + print(f" di ritorno-SPREAD di COPPIE, e XS01 non e' un paniere di coppie — vedi sezione 3.)") + _ = (P, S, XSR_EFF_BREADTH) + + +# ============================================================================================= +# IC — l'informazione del segnale, misurata direttamente +# ============================================================================================= +def ic_stats(universe: list[str], lookbacks=None, H: int | None = None, resid: bool = False, + cheat: bool = False, shuffle_seed: int | None = None, + every_day: bool = True, phase: int = 0): + """Rank-IC fra il punteggio e il ritorno a H giorni in avanti, con SE a blocchi (30 barre). + every_day=True usa TUTTI i giorni (osservazioni sovrapposte -> la SE a blocchi ne tiene conto) + perche' sui soli giorni di ribilanciamento le osservazioni sono 87 e non decidono niente.""" + lookbacks = CFG["lookbacks"] if lookbacks is None else lookbacks + H = CFG["H"] if H is None else H + C = prices(universe); px = C.values; n, A = px.shape + mlb = max(lookbacks) + dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) + bi = list(C.columns).index("BTC") if "BTC" in C.columns else 0 + rng = np.random.default_rng(shuffle_seed) if shuffle_seed is not None else None + ics = [] + for i in range(mlb, n - H): + if not every_day and i % H != phase: + continue + fwd = px[i + H] / px[i] - 1.0 + if cheat: + score = fwd.copy() + else: + rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks] + if resid: + seg = dret[max(1, i - 90):i + 1] + b = np.array([np.cov(seg[:, j], seg[:, bi])[0, 1] / max(np.var(seg[:, bi]), 1e-12) + for j in range(A)]) + rLs = [rL - b * rL[bi] for rL in rLs] + score = np.zeros(A); cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + score += (rL - rL.mean()) / sd; cnt += 1 + if not cnt: + continue + score /= cnt + if rng is not None: + score = score[rng.permutation(A)] + ics.append(spearmanr(score, fwd).statistic) + x = np.asarray(ics, float) + return float(x.mean()), block_se(x), len(x) + + +def block_se(x: np.ndarray, bl: int = 30, B: int = 500, seed: int = SEED) -> float: + rng = np.random.default_rng(seed); n = len(x); nb = int(np.ceil(n / bl)); out = [] + for _ in range(B): + st = rng.integers(0, max(1, n - bl), size=nb) + out.append(np.concatenate([x[s:s + bl] for s in st])[:n].mean()) + return float(np.std(out, ddof=1)) + + +def tail_spread(universe: list[str], k: int, resid: bool = False, lookbacks=None, + H: int | None = None): + """Rendimento a H giorni dei top-k MENO quello dei bottom-k: e' cio' che il portafoglio + incassa davvero (l'IC su tutta la sezione puo' essere 0 e le CODE portare informazione).""" + lookbacks = CFG["lookbacks"] if lookbacks is None else lookbacks + H = CFG["H"] if H is None else H + C = prices(universe); px = C.values; n, A = px.shape + mlb = max(lookbacks); kk = min(k, A // 2) + dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) + bi = list(C.columns).index("BTC") if "BTC" in C.columns else 0 + d = [] + for i in range(mlb, n - H): + rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks] + if resid: + seg = dret[max(1, i - 90):i + 1] + b = np.array([np.cov(seg[:, j], seg[:, bi])[0, 1] / max(np.var(seg[:, bi]), 1e-12) + for j in range(A)]) + rLs = [rL - b * rL[bi] for rL in rLs] + score = np.zeros(A); cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + score += (rL - rL.mean()) / sd; cnt += 1 + if not cnt: + continue + score /= cnt + o = np.argsort(score); f = px[i + H] / px[i] - 1.0 + d.append(float(f[o[-kk:]].mean() - f[o[:kk]].mean())) + x = np.asarray(d, float) + return x.mean(), block_se(x), len(x) + + +def z_spread(universe: list[str], k: int) -> float: + """Distanza media, in z-score, fra il gruppo long e il gruppo short: la SELETTIVITA'.""" + C = prices(universe); px = C.values; n, A = px.shape + lookbacks = CFG["lookbacks"]; mlb = max(lookbacks); kk = min(k, A // 2) + out = [] + for i in range(mlb, n): + rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks] + score = np.zeros(A); cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + score += (rL - rL.mean()) / sd; cnt += 1 + if not cnt: + continue + score /= cnt; o = np.argsort(score) + out.append(float(score[o[-kk:]].mean() - score[o[:kk]].mean())) + return float(np.mean(out)) + + +# ============================================================================================= +# 3) IL GATE — l'ampiezza effettiva +# ============================================================================================= +def sezione3(stab: list[str], quot: list[str]) -> dict: + sez("3) IL GATE — AMPIEZZA EFFETTIVA. Se le 11 gambe ne hanno gia' ~11, non c'e' niente da" + " recuperare") + + sub("3a) prima di misurare: il DEMEANING (la costruzione che creo' XSR01) qui e' un NO-OP" + " ALGEBRICO") + R = run(stab) + msum = float(np.abs(R["W"].sum(axis=1)).max()) + Rd = run(stab, demean=True) + dmx = float(np.nanmax(np.abs(R["net"].values - Rd["net"].values))) + print(f" XS01 mette +0,5/k sui top-k e -0,5/k sui bottom-k => sum_i w_i = 0 PER COSTRUZIONE.") + print(f" max|sum_i w_i| su tutte le barre : {msum:.3e}") + print(f" max|serie demeanata - serie canonica| : {dmx:.3e} -> IDENTICHE") + print(" 📌 XSR01 nacque perche' 50 coppie condividevano la gamba BTC: li' c'era un fattore") + print(" comune GROSSO nelle POSIZIONI, e toglierlo cambiava il portafoglio. XS01 e' gia'") + print(" dollar-neutral esatto: il demeaning non ha niente da togliere. Non e' un risultato") + print(" debole, e' un'IDENTITA' — e vale a ogni universo, a ogni k, per sempre.") + + sub("3b) LA TABELLA CHE DECIDE — N_eff della P&L per gamba") + print(f" {'universo / costruzione':<34}{'gambe':>7}{'rbar':>9}{'N_eff rbar':>13}" + f"{'N_eff autoval.':>16}{'Sh FULL':>10}") + rows = [("19 HL (canonico)", XS_UNIVERSE, dict()), + (f"{len(quot)} quotate Deribit (canonico)", quot, dict()), + (f"{len(stab)} Deribit >=1 anno (canonico)", stab, dict()), + (f"{len(stab)} Deribit — demean", stab, dict(demean=True)), + (f"{len(stab)} Deribit — resid vs BTC", stab, dict(kind="resid")), + (f"{len(stab)} Deribit — equal-risk", stab, dict(kind="ivol")), + (f"{len(stab)} Deribit — whitening", stab, dict(kind="white")), + (f"{len(stab)} Deribit — nudo L=30", stab, dict(lookbacks=(30,), disp_pct=0)), + (f"{len(stab)} Deribit — k=2", stab, dict(k=2)), + (f"{len(stab)} Deribit — H=20", stab, dict(H=20))] + out = {} + for nm, u, kw in rows: + r = run(u, **kw); L = leg_pnl(r) + ne_r, rb = neff_rbar(L); ne_e = neff_eig(L) + out[nm] = dict(neff=ne_e, sharpe=sh(r["net"])) + print(f" {nm:<34}{L.shape[1]:>7}{rb:>+9.3f}{ne_r:>13.2f}{ne_e:>16.2f}{sh(r['net']):>10.3f}") + + print("\n ⚠️ LA FORMULA IMPORTATA DA XSR01 DEGENERA QUI, e va detto invece di usarla: con rbar") + print(" NEGATIVA, N/(1+(N-1)*rbar) esplode sopra N (fino a ~33 su 11 gambe). Non e' un") + print(" errore di quella formula: e' che su un paniere di COPPIE rbar e' positiva (fattore") + print(" comune), mentre le gambe di un long/short cross-sezionale sono ANTI-correlate per") + print(" costruzione. Da qui in poi si legge il PARTICIPATION RATIO degli autovalori, che") + print(" sta sempre in [1, N] ed e' la definizione che il briefing nomina per prima.") + + n19 = out["19 HL (canonico)"]["neff"]; n11 = out[f"{len(stab)} Deribit >=1 anno (canonico)"]["neff"] + s19 = out["19 HL (canonico)"]["sharpe"]; s11 = out[f"{len(stab)} Deribit >=1 anno (canonico)"]["sharpe"] + pred = s19 * math.sqrt(n11 / n19) + print(f"\n 📌 RISPOSTA AL GATE: N_eff passa da {n19:.2f} (19 gambe) a {n11:.2f} (11 gambe) = " + f"-{100*(1-n11/n19):.0f}%,") + print(f" mentre lo Sharpe passa da {s19:.3f} a {s11:.3f} = -{100*(1-s11/s19):.0f}%.") + print(f" Sotto la legge fondamentale (IR = IC * sqrt(ampiezza)) la sola perdita di ampiezza") + print(f" predirebbe {pred:.3f}, cioe' {100*(s19-pred)/(s19-s11):.0f}% del divario. Il resto — " + f"l'{100-100*(s19-pred)/(s19-s11):.0f}% — NON e' ampiezza.") + print(" E NESSUNA costruzione la muove: tutte le righe stanno in una banda strettissima.") + print(" ⇒ Il filone COME POSTO si chiude qui: non c'e' ampiezza da recuperare. La sezione 4") + print(" misura dove sta davvero il divario, perche' 'non e' quello' non e' una diagnosi.") + return out + + +# ============================================================================================= +# 4) DOVE STA DAVVERO IL DIVARIO — selettivita' e IC +# ============================================================================================= +def sezione4(stab: list[str], quot: list[str]) -> dict: + sez("4) ALLORA DOV'E'? Due canali misurabili: SELETTIVITA' (quanto il ranking separa) e IC" + " (quanto il segnale sa)") + + sub("4a) SELETTIVITA' — con k=5 su 11 gambe si tengono 10 posizioni su 11: non si seleziona") + print(f" {'universo':<22}{'A':>4}{'k':>4}{'posizioni tenute':>19}{'spread z long-short':>22}") + for nm, u in (("19 HL", XS_UNIVERSE), (f"{len(stab)} Deribit", stab)): + for k in (5, 3, 2): + A = len(prices(u).columns); kk = min(k, A // 2) + print(f" {nm:<22}{A:>4}{k:>4}{f'{2*kk}/{A} = {2*kk/A:.0%}':>19}{z_spread(u, k):>22.3f}") + print(" 📌 `k=5` NON e' un parametro invariante di scala: fu tarato su A=19, dove seleziona il") + print(" 26% per lato. Su A=11 lo stesso 5 e' il 45% per lato. Trasferire il meccanismo a un") + print(" universo nuovo COI PARAMETRI CONGELATI (regola SOL, 22/08) qui vuol dire congelare") + print(" il QUANTILE, non il numero — ed e' una distinzione che cambia il risultato.") + + sub("4b) IC — il segnale sa qualcosa, su queste 11 gambe? (rank-IC contro il ritorno a 10g)") + print(f" {'universo':<34}{'IC':>10}{'SE blocchi':>13}{'t':>8}{'n':>7}") + ic = {} + for nm, u in ((f"19 HL (l'universo di XS01)", XS_UNIVERSE), + (f"{len(quot)} quotate Deribit", quot), + (f"{len(stab)} Deribit >=1 anno", stab), + (f"9 alt maturi (11 senza BTC/ETH)", [s for s in stab if s not in ("BTC", "ETH")]), + (f"8 gambe MANCANTI da sole", [s for s in XS_UNIVERSE if s not in stab])): + m, se, n = ic_stats(u); ic[nm] = (m, se) + print(f" {nm:<34}{m:>+10.4f}{se:>13.4f}{m/se:>8.2f}{n:>7}") + print("\n 📌 QUESTO e' il risultato del filone. Sulle 11 gambe che Deribit quota da un anno il") + print(" segnale NON ha informazione, e sui 9 alt maturi presi da soli e' NEGATIVO. Le 8") + print(" mancanti da sole non ce l'hanno nemmeno loro: l'informazione vive nel CONTRASTO fra") + print(" i due gruppi. XS01 e', in buona parte, una rotazione fra FASCE (major maturi contro") + print(" alt recenti ad alta dispersione) — non una selezione DENTRO una fascia.") + print(" ⇒ Nessuna costruzione puo' recuperare informazione che nel campione non c'e'.") + + sub("4c) e non e' 'sfortuna di listino' dentro una banda: e' FUORI dalla banda") + rng = np.random.default_rng(SEED) + subs = [list(rng.choice(XS_UNIVERSE, size=len(stab), replace=False)) for _ in range(N_SUBSETS)] + ics = np.array([ic_stats(u)[0] for u in subs]) + mine = ic_stats(stab)[0] + pct = 100.0 * (ics < mine).mean() + print(f" IC di {N_SUBSETS} sottoinsiemi CASUALI da {len(stab)} gambe fra le 19:") + print(f" mediana {np.median(ics):+.4f} [p10 {np.percentile(ics,10):+.4f}, " + f"p90 {np.percentile(ics,90):+.4f}] min {ics.min():+.4f}") + print(f" il sottoinsieme DERIBIT: {mine:+.4f} -> {pct:.0f}° percentile") + print(" 📌 §50 lo chiamava 'sfortuna di listino' e lo collocava al 9° percentile dello SHARPE.") + print(" Sull'IC — che e' la grandezza di cui lo Sharpe e' una conseguenza — sta sotto") + print(f" l'INTERO campione. Non e' una coda della distribuzione: le {len(stab)} gambe quotate da") + print(" un anno sono i major maturi, cioe' esattamente il sottoinsieme meno disperso.") + print(" Aspettare 2-3 listing non basta, e adesso si sa PERCHE'.") + return dict(ic=ic, subset_ics=ics, ic_deribit=mine) + + +# ============================================================================================= +# 5) LE COSTRUZIONI — la griglia dichiarata, de-luckata di fase +# ============================================================================================= +KINDS = ("canon", "resid", "ivol", "white") +KS = (1, 2, 3, 4, 5) +LBS = ((30, 90), (30,)) +HS = (10, 20) + + +def grid_cells(): + for kind, k, lb, H in itertools.product(KINDS, KS, LBS, HS): + yield dict(kind=kind, k=k, lookbacks=lb, H=H, + disp_pct=(CFG["disp_pct"] if lb == (30, 90) else 0)) + + +def band(u: list[str], **kw) -> np.ndarray: + return np.array([sh(run(u, phase=p, **kw)["net"]) for p in range(N_PHASES)]) + + +def sezione5(stab: list[str]) -> dict: + sez("5) LE COSTRUZIONI — griglia DICHIARATA (80 celle), e la banda di FASE che le de-lucka") + + sub("5a) la griglia alla fase canonica (fase 0) — e perche' non ci si puo' fermare qui") + cells = list(grid_cells()) + rows = [] + for c in cells: + s = run(stab, **c)["net"] + rows.append(dict(cell=c, ins=sh_ins(s), hold=sh_hold(s), full=sh(s), series=s)) + fulls = np.array([r["full"] for r in rows]) + print(f" {len(cells)} celle: FULL mediana {np.median(fulls):+.3f} max {fulls.max():+.3f} " + f"sd {fulls.std(ddof=1):.3f}") + best_is = max(rows, key=lambda r: r["ins"]) + best_ho = max(rows, key=lambda r: r["hold"]) + print(f"\n {'scelta':<22}{'cella':<44}{'IS':>8}{'HOLD':>8}{'FULL':>8}") + for nm, r in (("IN-SAMPLE-ONLY (onesta)", best_is), ("sull'HOLD-OUT (vietata)", best_ho)): + c = r["cell"] + lab = "%s k=%d lb=%s H=%d" % (c["kind"], c["k"], c["lookbacks"], c["H"]) + print(f" {nm:<22}{lab:<44}{r['ins']:>8.3f}{r['hold']:>8.3f}{r['full']:>8.3f}") + + sub("5b) BANDA DI FASE — la lente onesta di XS01 (ciclo H=10; i numeri headline sono sulla" + " fase 0, che CLAUDE.md registra come fortunata)") + casi = { + "canonico k=5 (11)": (stab, dict()), + "k=2 (11)": (stab, dict(k=2)), + "k=3 (11)": (stab, dict(k=3)), + "resid k=5 (11)": (stab, dict(kind="resid")), + "resid k=2 (11)": (stab, dict(kind="resid", k=2)), + "equal-risk k=2 (11)": (stab, dict(kind="ivol", k=2)), + "whitening k=2 (11)": (stab, dict(kind="white", k=2)), + "nudo L=30 k=2 (11)": (stab, dict(lookbacks=(30,), disp_pct=0, k=2)), + "H=20 k=2 (11)": (stab, dict(H=20, k=2)), + "canonico k=5 (19) RIF.": (XS_UNIVERSE, dict()), + "k=2 (19)": (XS_UNIVERSE, dict(k=2)), + } + B = {} + print(f" {'cella':<26}{'fase0':>9}{'MEDIANA':>10}{'p10':>9}{'p90':>9}{'>0':>7}" + f"{'pctl fase0':>12}") + for nm, (u, kw) in casi.items(): + v = band(u, **kw); B[nm] = v + print(f" {nm:<26}{v[0]:>9.3f}{np.median(v):>10.3f}{np.percentile(v,10):>9.3f}" + f"{np.percentile(v,90):>9.3f}{100*(v>0).mean():>6.0f}%" + f"{100*(v11.0f}°") + + sub("5c) DIFFERENZE APPAIATE per fase (mediana delle differenze, non differenza delle mediane)") + def dd(a, b): + d = B[a] - B[b] + return np.median(d), 100.0 * (d > 0).mean() + for a, b in (("k=2 (11)", "canonico k=5 (11)"), + ("k=3 (11)", "canonico k=5 (11)"), + ("resid k=2 (11)", "k=2 (11)"), + ("equal-risk k=2 (11)", "k=2 (11)"), + ("whitening k=2 (11)", "k=2 (11)"), + ("nudo L=30 k=2 (11)", "k=2 (11)"), + ("H=20 k=2 (11)", "k=2 (11)"), + ("k=2 (19)", "canonico k=5 (19) RIF.")): + m, f = dd(a, b) + print(f" {a:<26} MENO {b:<26} mediana {m:>+7.3f} positivo in {f:>3.0f}% delle fasi") + print("\n 📌 Il canale che porta TUTTO e' `k`, e solo sull'universo POVERO: su 19 gambe k=2 e") + print(" k=5 sono la stessa cosa. E' la firma della SELETTIVITA': k conta quando e' una") + print(" frazione grande dell'universo. Le tre costruzioni 'da ampiezza' (equal-risk,") + print(" whitening, residualizzazione) valgono, sopra a quello, poco o niente.") + + sub("5d) plateau su k (mediana delle 10 fasi) — punta o regione?") + print(f" {'k':>3}{'tenuto/lato (11)':>19}{'canon 11':>11}{'resid 11':>11}{'canon 19':>11}") + for k in KS: + a = np.median(band(stab, k=k)); b = np.median(band(stab, kind="resid", k=k)) + c = np.median(band(XS_UNIVERSE, k=k)) + print(f" {k:>3}{f'{min(k,5)}/11 = {min(k,5)/11:.0%}':>19}{a:>11.3f}{b:>11.3f}{c:>11.3f}") + print(" -> su 11 gambe la curva e' MONOTONA e la regione k in {1,2,3} e' un plateau, non una") + print(" punta; su 19 gambe e' piatta. Il meccanismo e' strutturale, non una cella fortunata.") + return dict(rows=rows, bands=B, best_is=best_is, best_ho=best_ho) + + +# ============================================================================================= +# 6) IL NULL CHE CONTA — permutazione cross-sezionale a FEE ZERO +# ============================================================================================= +def perm_series(universe: list[str], seed: int, k: int, kind: str = "canon", + lookbacks=None, H: int | None = None, disp_pct: int | None = None, + phase: int = 0) -> pd.Series: + """Identico a `run` ma il punteggio viene PERMUTATO fra gli asset a ogni ribilanciamento, e la + fee e' ZERO da entrambe le parti del confronto (25/07: un null a fee piena perde per COSTO).""" + lookbacks = CFG["lookbacks"] if lookbacks is None else lookbacks + H = CFG["H"] if H is None else H + disp_pct = CFG["disp_pct"] if disp_pct is None else disp_pct + C = prices(universe); px = C.values; n, A = px.shape + kk = min(k, A // 2); mlb = max(lookbacks) + dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) + bi = list(C.columns).index("BTC") if "BTC" in C.columns else 0 + rng = np.random.default_rng(seed) + W = np.zeros((n, A)); w = np.zeros(A); disp_hist: list[float] = [] + for i in range(n): + if i >= mlb and i % H == phase: + rLs = [px[i] / px[i - L] - 1.0 for L in lookbacks] + if kind == "resid": + seg = dret[max(1, i - 90):i + 1] + b = np.array([np.cov(seg[:, j], seg[:, bi])[0, 1] / max(np.var(seg[:, bi]), 1e-12) + for j in range(A)]) + rLs = [rL - b * rL[bi] for rL in rLs] + disp_i = float(np.mean([r.std() for r in rLs])) + thr = (np.percentile(disp_hist, disp_pct) + if (disp_pct > 0 and len(disp_hist) >= CFG["disp_minhist"]) else -np.inf) + if disp_i >= thr: + score = np.zeros(A); cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + score += (rL - rL.mean()) / sd; cnt += 1 + if cnt: + score = score[rng.permutation(A)] / cnt + o = np.argsort(score); w = np.zeros(A) + w[o[-kk:]] = 0.5 / kk; w[o[:kk]] = -0.5 / kk + else: + w = np.zeros(A) + disp_hist.append(disp_i) + W[i] = w + gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) + s = pd.Series(gross, index=C.index) + rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25) + sc = np.clip(np.nan_to_num(CFG["target_vol"] / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0) + return to_daily(pd.Series(s.values * sc, index=C.index)) + + +def sezione6(stab: list[str]) -> None: + sez("6) IL NULL CHE CONTA — permutazione cross-sezionale, ENTRAMBI I LATI A FEE ZERO") + print(" Perche' a fee zero (regola 25/07): permutare un punteggio ne fa esplodere il turnover;") + print(" a fee piena il null perderebbe per COSTO invece che per assenza d'informazione, e il") + print(" p-value uscirebbe trionfale e falso.") + print(f"\n {'cella':<26}{'candidato':>11}{'null med':>11}{'null p95':>11}{'p':>8}") + casi = [("canonico k=5 (11)", stab, dict(k=5)), + ("k=2 (11)", stab, dict(k=2)), + ("resid k=2 (11)", stab, dict(k=2, kind="resid")), + ("canonico k=5 (19) RIF.", XS_UNIVERSE, dict(k=5))] + for nm, u, kw in casi: + cand = sh(run(u, fee=0.0, **kw)["net"]) + nl = np.array([sh(perm_series(u, 9000 + j, **kw)) for j in range(N_PERM)]) + p = float((nl >= cand).mean()) + print(f" {nm:<26}{cand:>+11.3f}{np.median(nl):>+11.3f}{np.percentile(nl,95):>+11.3f}" + f"{p:>8.3f}") + print("\n ✅ CONTROLLO POSITIVO: il null HA potenza — sull'universo dove il segnale esiste (19") + print(" gambe) il candidato batte le permutazioni, su quello dove non esiste no. Un null") + print(" che non separasse i due casi non starebbe misurando niente.") + + sub("6b) e il p-value stesso va DE-LUCKATO: la riga sopra e' calcolata alla fase 0, che per" + " `resid k=2` sta al 90° percentile della sua banda") + print(" Nessuno in questo progetto aveva ancora de-luckato un p-value. Qui serve: se il") + print(" candidato e' valutato all'ancora fortunata, il suo p e' l'ancora fortunata del p.") + print(f"\n {'fase':>6}{'candidato':>12}{'p (100 perm)':>15}") + ps = [] + for ph in range(N_PHASES): + cand = sh(run(stab, fee=0.0, k=2, kind="resid", phase=ph)["net"]) + nl = np.array([sh(perm_series(stab, 7000 + 97 * ph + j, k=2, kind="resid", phase=ph)) + for j in range(100)]) + pv = float((nl >= cand).mean()); ps.append(pv) + print(f" {ph:>6}{cand:>+12.3f}{pv:>15.3f}") + ps = np.array(ps) + print(f"\n p MEDIANO sulle {N_PHASES} fasi: {np.median(ps):.3f} " + f"[min {ps.min():.3f}, max {ps.max():.3f}] sotto 0,05 in {100*(ps<0.05).mean():.0f}% " + f"delle fasi") + print(" 📌 Alla fase canonica `resid k=2` sembra battere il rumore; alla fase MEDIANA no.") + print(" Il verdetto del null cambia con l'ancora, e l'ancora e' una scelta, non un dato.") + + +# ============================================================================================= +# 7) IL GATE ONESTO + MDE +# ============================================================================================= +def sezione7(stab: list[str], G: dict) -> None: + sez("7) IL GATE ONESTO — i tre componenti reali di `study_family_honest`, e l'MDE") + print(" ⚠️ `altlib.study_family_honest` e' cablato sui candidati DIREZIONALI (factory ->") + print(" target_fn via `candidate_daily`) e non accetta uno sleeve cross-sezionale gia'") + print(" espresso in RENDIMENTI. Come per il gate del tenore di VRP01 (30/07) si usano i") + print(" suoi TRE componenti reali, importati e non riscritti.") + + rows, best_is, best_ho = G["rows"], G["best_is"], G["best_ho"] + sub("7a) (i) SELEZIONE IN-SAMPLE-ONLY — stessa regola di `altlib.select_cell_insample`") + src = al.select_cell_insample.__doc__ or "" + print(f" regola importata: 'rank grid cells by IN-SAMPLE (pre-HOLDOUT) standalone Sharpe'" + f" [{'trovata nel sorgente' if 'IN-SAMPLE' in src else 'NON verificata'}]") + c = best_is["cell"] + print(f" cella scelta AL BUIO: kind={c['kind']} k={c['k']} lookbacks={c['lookbacks']} " + f"H={c['H']}") + print(f" IS {best_is['ins']:+.3f} HOLD {best_is['hold']:+.3f} FULL {best_is['full']:+.3f}") + ch = best_ho["cell"] + print(f" (chi avesse scelto sull'HOLD-OUT prendeva kind={ch['kind']} k={ch['k']} " + f"lb={ch['lookbacks']} H={ch['H']}: HOLD {best_ho['hold']:+.3f})") + sub2 = [r for r in rows if r["cell"]["k"] >= 2] + b2 = max(sub2, key=lambda r: r["ins"]) + c2 = b2["cell"] + print(f"\n 🚨 ERRORE CATTURATO SU ME STESSO — la scelta al buio NON e' stabile alla") + print(f" DICHIARAZIONE della griglia. La mia prima stesura dichiarava k in {{2,3,4,5}}") + print(f" ({len(sub2)} celle) e li' la cella al buio era: kind={c2['kind']} k={c2['k']} " + f"lb={c2['lookbacks']} H={c2['H']}") + print(f" -> IS {b2['ins']:+.3f} HOLD {b2['hold']:+.3f} FULL {b2['full']:+.3f}, cioe' un" + f" candidato che REGGE fuori campione.") + print(f" Aggiungendo k=1 (che e' solo un'estensione del plateau, decisa PRIMA di") + print(f" guardare) la scelta passa a una cella che fa HOLD {best_is['hold']:+.3f}.") + print(" ⇒ Con ~1 anno di in-sample la selezione in-sample-only non e' un gate, e' una") + print(" monetina: la stessa procedura onesta da' due candidati opposti a seconda di") + print(" una scelta di perimetro arbitraria. E' la lezione del 22/08 sul deflated-") + print(" Sharpe ('la stessa griglia da' verdetti opposti secondo come la si partiziona')") + print(" vista un passo prima, sulla SELEZIONE invece che sulla deflazione.") + + sub("7b) (ii) DEFLATED SHARPE su TUTTE le celle dichiarate (`altlib.deflated_sharpe`)") + all_full = [r["full"] for r in rows] + dsr, sr0 = al.deflated_sharpe(best_is["full"], all_full, best_is["series"]) + print(f" N celle {len(all_full)} sd(Sharpe di griglia) {np.std(all_full, ddof=1):.3f}") + print(f" massimo atteso dal SOLO RUMORE: {sr0:.3f} candidato: {best_is['full']:.3f}") + print(f" DSR = {dsr:.3f} -> {'PASS' if dsr >= 0.95 else 'FAIL'} (soglia 0,95)") + med_phase = np.median(band(stab, **{k: v for k, v in best_is["cell"].items()})) + print(f" ⚠️ e il confronto giusto e' col numero DE-LUCKATO: la stessa cella a fase MEDIANA") + print(f" fa {med_phase:+.3f}, cioe' {'SOTTO' if med_phase < sr0 else 'sopra'} il massimo " + f"atteso dal rumore ({sr0:.3f}).") + + sub("7c) (iii) `altlib.marginal_vs_tp01` sulla cella scelta al buio") + try: + m = al.marginal_vs_tp01(best_is["series"]) + print(f" verdetto {m.get('marginal_verdict')} corr {m.get('corr_full')} " + f"robust_oos {m.get('robust_oos')} insample_edge {m.get('has_insample_edge')} " + f"is_hedge {m.get('is_hedge')} beats_noise {m.get('beats_noise_null')}") + except Exception as e: # noqa: BLE001 + print(f" non calcolabile: {type(e).__name__}: {e}") + print(" ⚠️ La serie comincia nel 2024: `marginal_vs_tp01` ha ~1 anno di in-sample e il suo") + print(" verdetto qui e' debole PER COSTRUZIONE (§50 lo dichiara sugli stessi dati).") + + sub("7d) MDE — quanta risoluzione ha davvero questo campione (in barre ATTIVE)") + R = run(stab, **best_is["cell"]) + att = np.abs(R["W"]).sum(axis=1) > 1e-12 + idx = R["idx"] + n_tot, n_att = len(idx), int(att.sum()) + n_h = int(att[idx >= HOLDOUT].sum()); n_i = int(att[idx < HOLDOUT].sum()) + print(f" campione: {n_tot} giorni ({idx.min().date()} -> {idx.max().date()}), di cui ATTIVI" + f" {n_att} ({n_att/n_tot:.0%})") + print(f" in-sample attive {n_i} · hold-out attive {n_h}") + print(f"\n {'finestra':<28}{'anni attivi':>13}{'SE(Sharpe)':>13}{'MDE 80%/5%':>13}") + for nm, na, s0 in (("FULL", n_att, best_is["full"]), ("in-sample (2024)", n_i, best_is["ins"]), + ("hold-out (2025+)", n_h, best_is["hold"])): + yrs = na / 365.25 + se = math.sqrt((1 + s0 * s0 / 2) / max(yrs, 1e-9)) + print(f" {nm:<28}{yrs:>13.2f}{se:>13.3f}{2.80*se:>13.3f}") + print("\n 📌 L'MDE sull'hold-out e' dell'ordine di 2,5-3 di Sharpe. Tutto cio' che questo") + print(" filone recupera (0,7-0,9) sta SOTTO la risoluzione del campione: e la selezione") + print(" in-sample gira su ~1 anno, dove SE(Sharpe) ~ 1 — cioe' e' quasi una monetina.") + + sub("7e) e al livello del SEGNALE la potenza non c'e' proprio (spread di coda, SE a blocchi)") + print(f" {'cella':<26}{'spread coda %/10g':>20}{'SE':>8}{'t':>8}") + for nm, u, kw in (("canonico k=5 (11)", stab, dict(k=5)), + ("k=2 (11)", stab, dict(k=2)), + ("resid k=2 (11)", stab, dict(k=2, resid=True)), + ("canonico k=5 (19) RIF.", XS_UNIVERSE, dict(k=5))): + m, se, n = tail_spread(u, **kw) + print(f" {nm:<26}{100*m:>19.2f}%{100*se:>8.2f}{m/se:>8.2f}") + print(" -> lo Sharpe recuperato su 11 gambe NON e' sostenuto da uno spread misurabile:") + print(" t ~ 0,3-0,6 contro ~1,9 dell'universo pieno. Un rendimento che si vede solo") + print(" nella serie composta e non nel suo ingrediente e' un rendimento da non citare.") + + +# ============================================================================================= +# 8) IL SUBSET NULL SULLA COSTRUZIONE — alza la MEDIANA o solo la cella di Deribit? +# ============================================================================================= +def sezione8(stab: list[str]) -> None: + sez("8) LA COSTRUZIONE E' UN MECCANISMO O E' UN FIT SU QUESTE 11? (null dei sottoinsiemi)") + print(" Se `k` piccolo e' un meccanismo, deve alzare la MEDIANA di sottoinsiemi casuali da 11,") + print(" non solo la cella che Deribit quota. (Stessa forma del null che il 25/07 mise GTAA6 al") + print(" 95° pctl dei 6-subset.)") + print(" ⚠️ E il confronto va DE-LUCKATO come tutti gli altri: la statistica per sottoinsieme") + print(f" e' la MEDIANA su {len(PH_SUB)} fasi ({', '.join(map(str, PH_SUB))}), non il valore alla fase 0.") + rng = np.random.default_rng(SEED) + subs = [list(rng.choice(XS_UNIVERSE, size=len(stab), replace=False)) for _ in range(N_SUBSETS)] + + def med_ph(u, **kw): + return float(np.median([sh(run(u, phase=ph, **kw)["net"]) for ph in PH_SUB])) + + print(f"\n {'costruzione':<20}{'med subset11':>14}{'p10':>9}{'p90':>9}{'Deribit':>10}" + f"{'pctl':>7}{'(fase 0)':>22}") + for nm, kw in (("canonico k=5", dict()), ("k=2", dict(k=2)), ("k=3", dict(k=3)), + ("resid k=2", dict(kind="resid", k=2))): + v = np.array([med_ph(u, **kw) for u in subs]) + mine = med_ph(stab, **kw) + v0 = np.array([sh(run(u, **kw)["net"]) for u in subs]) + m0 = sh(run(stab, **kw)["net"]) + print(f" {nm:<20}{np.median(v):>14.3f}{np.percentile(v,10):>9.3f}" + f"{np.percentile(v,90):>9.3f}{mine:>10.3f}{100*(v6.0f}°" + f"{f'{m0:+.3f} -> {100*(v022}") + print("\n -> `k` piccolo alza la mediana di TUTTI i sottoinsiemi: e' una proprieta' del") + print(" meccanismo su universo piccolo, non un fit sulle gambe di Deribit.") + print(" 📌 E la colonna di destra e' la ragione per cui si de-lucka: alla fase 0 `resid k=2`") + print(" mette il sottoinsieme di Deribit in ALTO nella banda, e una lettura fermata li'") + print(" avrebbe concluso l'opposto di quella de-luckata. (La prima stesura di questo") + print(" script scriveva 'Deribit resta in fondo a OGNI costruzione': era falso proprio") + print(" sulla cella che sembrava vincere.)") + + +# ============================================================================================= +# 9) ESEGUIBILITA' E NETTING +# ============================================================================================= +def sezione9(stab: list[str]) -> None: + sez("9) ESEGUIBILITA' E NETTING — il min-order per gamba, e cio' che NON netta col libro") + + sub("9a) il netting: 2 gambe su 11 stanno sullo stesso strumento del libro live") + inter = [s for s in stab if s in ("BTC", "ETH")] + print(f" Il libro live (`book_net_target`) somma TP01 e SKH01 in UN numero per asset su") + print(f" BTC/ETH e manda UN ordine. Delle {len(stab)} gambe, {len(inter)} ({', '.join(inter)}) " + f"netterebbero;") + print(f" le altre {len(stab)-len(inter)} sono strumenti che il libro NON tocca -> pagano un") + print(" min-order PROPRIO, e richiederebbero una riga in `src/live/deribit._CONTRACT`, cioe'") + print(" codice su un percorso con soldi veri.") + + sub("9b) quante gambe sopravvivono a min_order $5 (stessa regola di" + " `altlib.eval_weights_smallcap`)") + doc = al.eval_weights_smallcap.__doc__ or "" + print(f" regola importata: 'a desired position change whose notional |dw|*capital is below") + print(f" min_order is NOT executed' [{'confermata nel sorgente' if 'min_order' in doc else 'NON verificata'}]") + R = run(stab, k=2) + pos = R["W"] * R["scale"][:, None] # ⚠️ la posizione VERA e' W*scale, non W: + dpos = np.abs(np.diff(pos, axis=0, prepend=0.0)) # il vol-target muove il nozionale OGNI + tick = dpos[dpos > 1e-12] # giorno anche a pesi fermi. + rebal = np.zeros(len(R["W"]), bool) + rebal[1:] = (np.abs(np.diff(R["W"], axis=0)) > 1e-12).any(axis=1) + is_reb = np.repeat(rebal[:, None], pos.shape[1], axis=1)[dpos > 1e-12] + print(f"\n ⚠️ Errore catturato prima di pubblicare: la prima stesura misurava |dW| e trovava") + print(" 'ticket mediano $19, 100% eseguito'. Ma la posizione in dollari e' W*scale, e il") + print(" vol-target muove il nozionale TUTTI i giorni anche a pesi fermi -> quella misura") + print(" vedeva solo i ribilanci di segnale e faceva sparire la popolazione che il") + print(" pavimento taglia davvero. Sotto ci sono i ticket sulla posizione vera.") + print(f"\n {'capitale':>10}{'quota 15%':>12}{'ticket med':>13}{'ordini >= $5':>15}" + f"{'% NOZIONALE eseguito':>23}") + for cap in CAP_LADDER: + alloc = cap * W_SLEEVE + t = tick * alloc + ok = t >= MIN_ORDER + print(f" {('$%.0f' % cap):>10}{('$%.0f' % alloc):>12}{('$%.2f' % np.median(t)):>13}" + f"{f'{100*ok.mean():.0f}%':>15}{f'{100*t[ok].sum()/t.sum():.0f}%':>23}") + a0 = CAPITAL_TODAY * W_SLEEVE + t0 = tick * a0 + print(f"\n Le DUE POPOLAZIONI (§45), a ${CAPITAL_TODAY:.0f}:") + print(f" ribilanci di SEGNALE : {100*is_reb.mean():>5.1f}% degli ordini, " + f"{100*t0[is_reb].sum()/t0.sum():>5.1f}% del nozionale, ticket mediano " + f"${np.median(t0[is_reb]):.2f}") + print(f" deriva del VOL-TARGET : {100*(~is_reb).mean():>5.1f}% degli ordini, " + f"{100*t0[~is_reb].sum()/t0.sum():>5.1f}% del nozionale, ticket mediano " + f"${np.median(t0[~is_reb]):.2f}") + print("\n 📌 Il muro NON e' la taglia (7ª volta nell'ondata): il pavimento taglia soprattutto") + print(" la popolazione piccola (deriva del vol-target), che vale poco nozionale, ed e'") + print(" esattamente il meccanismo misurato in §45 quando cadde il '$20k di XS01'.") + print(" Il muro e' l'IC della sezione 4.") + + +# ============================================================================================= +# MAIN +# ============================================================================================= +def main() -> None: + global _ARGS + ap = argparse.ArgumentParser() + ap.add_argument("--no-net", action="store_true", help="usa solo la cache del venue") + _ARGS = ap.parse_args() + + print("=" * 104) + print(" §56 XS-AMPIEZZA — su un universo POVERO si recupera ampiezza EFFETTIVA senza" + " aggiungere gambe?") + print(f" {dt.datetime.now(dt.timezone.utc):%Y-%m-%d %H:%M UTC} · sola lettura · nessun ordine ·" + f" solo GET pubbliche con pacing") + print("=" * 104) + print(" ATTESA A PRIORI " + __doc__.split("ATTESA A PRIORI")[1].split("LA FAMIGLIA")[0].rstrip()) + + V = venue_census() + cens = sezione1(V) + stab, quot = cens["stabili"], cens["quotate"] + if len(stab) < 8: + print("\n ⚠️ meno di 8 gambe stabili: il filone non e' misurabile, mi fermo.") + return + + sezione2(stab) + print(f"\n {clock()} sezione 2 completata") + NEFF = sezione3(stab, quot) + print(f"\n {clock()} sezione 3 completata") + ICS = sezione4(stab, quot) + print(f"\n {clock()} sezione 4 completata") + G = sezione5(stab) + print(f"\n {clock()} sezione 5 completata") + sezione6(stab) + print(f"\n {clock()} sezione 6 completata") + sezione7(stab, G) + print(f"\n {clock()} sezione 7 completata") + sezione8(stab) + print(f"\n {clock()} sezione 8 completata") + sezione9(stab) + + # ------------------------------------------------------------------ verdetto + sez("VERDETTO") + n19 = NEFF["19 HL (canonico)"]["neff"] + n11 = NEFF[f"{len(stab)} Deribit >=1 anno (canonico)"]["neff"] + bands = G["bands"] + med_can = np.median(bands["canonico k=5 (11)"]) + med_k2 = np.median(bands["k=2 (11)"]) + med_19 = np.median(bands["canonico k=5 (19) RIF."]) + d = bands["k=2 (11)"] - bands["canonico k=5 (11)"] + ic19 = ICS["ic"]["19 HL (l'universo di XS01)"] + ic11 = ICS["ic"][f"{len(stab)} Deribit >=1 anno"] + print(f" (1) IL GATE DICE NO: l'ampiezza effettiva scende solo da {n19:.2f} a {n11:.2f} " + f"(-{100*(1-n11/n19):.0f}%) mentre lo") + print(" Sharpe scende dell'86%. Nessuna costruzione la muove, e il DEMEANING — la mossa") + print(" che creo' XSR01 — e' un NO-OP ALGEBRICO su XS01 (sum dei pesi = 0 per costruzione).") + print(f" (2) IL DIVARIO E' ALTROVE, ed e' DUE cose: SELETTIVITA' (k=5 su 11 gambe tiene 10") + print(f" posizioni su 11: non seleziona) e IC. La selettivita' SI recupera — il quantile") + print(f" invariante di scala porta la mediana di fase da {med_can:+.3f} a {med_k2:+.3f} " + f"(delta appaiato") + print(f" {np.median(d):+.3f}, positivo in {100*(d>0).mean():.0f}% delle fasi, plateau su k in " + f"{{1,2,3}}), contro {med_19:+.3f} dell'universo pieno.") + print(f" (3) L'IC NO: {ic11[0]:+.4f} (t {ic11[0]/ic11[1]:+.2f}) sulle {len(stab)} gambe contro " + f"{ic19[0]:+.4f} (t {ic19[0]/ic19[1]:+.2f}) sulle 19,") + print(f" e sotto TUTTI i {N_SUBSETS} sottoinsiemi casuali da {len(stab)}. Sui 9 alt maturi da soli e'") + print(" NEGATIVO: XS01 e' in buona parte una rotazione fra FASCE, non dentro una fascia.") + print(" ⚠️ Sfumatura dovuta: sotto una costruzione a k piccolo il sottoinsieme di Deribit") + print(" risale a meta' banda sullo SHARPE (65-80° pctl de-luckato) pur restando ultimo") + print(" sull'IC — coerente, perche' con k piccolo contano solo le CODE del ranking e non") + print(" tutta la sezione. Ma lo spread di quelle code ha t ~ 0,2-0,6: e' la stessa") + print(" grandezza del punto (4), non un'obiezione.") + print(" (4) E TUTTO IL RECUPERO STA SOTTO LA RISOLUZIONE DEL CAMPIONE, per quattro strade") + print(" indipendenti: (a) MDE dell'hold-out ~2,3 di Sharpe su ~1,6 anni attivi; (b) lo") + print(" SPREAD DI CODA che dovrebbe pagarlo ha t ~ 0,2-0,6 contro ~1,9 dell'universo") + print(" pieno — un rendimento che si vede nella serie composta e non nel suo ingrediente;") + print(" (c) il null di permutazione a fee zero non separa il candidato dal rumore alla") + print(" fase MEDIANA (lo separa solo alla fase 0, che e' la fortunata) mentre lo separa") + print(" su 19 gambe = controllo positivo; (d) il deflated-Sharpe sulla griglia dichiarata") + print(" FALLISCE, e la selezione in-sample-only gira su ~1 anno dove SE(Sharpe) ~ 1,7.") + print(" (4-bis) 🚨 E LA SELEZIONE ONESTA NON E' STABILE ALLA DICHIARAZIONE DELLA GRIGLIA:") + print(" togliendo k=1 dalla famiglia (una scelta di perimetro, non un dato) la cella") + print(" scelta al buio passa da una che crolla fuori campione a una che regge. Con questo") + print(" campione la procedura onesta e' una monetina, e va detto invece di citarne") + print(" l'esito.") + print(" (5) L'eseguibilita' non boccia niente: lotti da $0,09 a ~$8, e a $635 passa gia' la") + print(" maggior parte del nozionale. Il muro e' l'edge, non il capitale.") + print("\n ⇒ `NO — E LA CADUTA NON ERA AMPIEZZA: E' SELETTIVITA' (RECUPERABILE, SOTTO L'MDE) PIU'") + print(" UN IC CHE SU QUELLE 11 GAMBE E' ZERO`") + print("\n Cio' che questo filone CORREGGE di §50: l'etichetta 'il 62% della caduta e' AMPIEZZA'") + print(" descriveva il NUMERO DI GAMBE, non l'ampiezza effettiva — che quasi non si muove. E la") + print(" 'sfortuna di listino' non e' una coda della distribuzione: sull'IC il sottoinsieme di") + print(" Deribit sta sotto l'intero campione, per una ragione strutturale (sono i major maturi).") + print(" Cio' che NON cambia: XS01 resta bloccato dal venue, e aspettare 2-3 listing non basta.") + print("\n 📌 UNA COSA DA PORTARSI VIA ANCHE FUORI DA QUI: `k=5` non e' un parametro invariante") + print(" di scala. Congelare un meccanismo su un universo nuovo vuol dire congelare il") + print(" QUANTILE, non il numero di posizioni — e su XS01 la differenza vale ~0,8 di Sharpe.") + print(f"\n {clock()} fine.") + + +if __name__ == "__main__": + main()