#!/usr/bin/env python """r0823b_crossvenue.py — §66 CROSS-VENUE: lo scarto Deribit-vs-Hyperliquid come SEGNALE. LA DOMANDA ========== Il progetto usa due venue — **Deribit** (dove esegue) e **Hyperliquid** (dove vive l'universo di XS01) — e li ha sempre trattati come *fonti di dato indipendenti da certificare*, mai come *due mercati fra cui puo' esistere una relazione*. `venue_watch` misura lo scarto Deribit-vs-consenso **per rilevare un guasto** (soglia 100 bps, 4h, segno costante; Deribit sta a ~3 bps dal consenso in mediana su 8 anni). Quello stesso scarto non e' mai stato guardato come SEGNALE. Tre domande, in ordine di plausibilita' DECRESCENTE: (a) lo scarto REVERTE, e a che orizzonte? (b) uno dei due venue GUIDA (lead-lag)? (c) lo scarto e' un GATE DI REGIME utile a TP01/SKH01? ATTESE DICHIARATE PRIMA DI MISURARE (metodo, punto 1) ===================================================== A1. **Lo scarto e' quasi tutto FATTORE COMUNE.** Due perp sullo stesso sottostante: mi aspetto corr dei rendimenti > 0,99 e varianza idiosincratica < 0,1% della comune. (Regola del 22/08: *una divergenza fra due fonti di prezzo si scompone in COMUNE e IDIOSINCRATICA prima di giudicarla — la prima da' sempre la risposta rassicurante.*) A2. **Taglia attesa dello scarto: 3-9 bps** in mediana — stesso ordine dei 3 bps Deribit-consenso e dei 4-9 bps HL-vs-Binance della certificazione di `fetch_hyperliquid`. A3. **(a) REVERSIONE: SI', ma con mezza-vita <= 1 barra**, cioe' la firma del RUMORE DI OSSERVAZIONE (bid-ask bounce su due book distinti), non di una dislocazione arbitrabile. Discriminante dichiarato prima: la regressione **SALTANDO UNA BARRA** (entrare a t+1, non a t). Se il coefficiente crolla -> era rumore; se sopravvive -> era dislocazione. A4. **(b) LEAD-LAG: nessuno rilevabile a >= 5 minuti.** Il lead-lag fra perp liquidi arbitrati vive a scala sub-secondo. Picco della cross-correlazione atteso a lag 0. A5. **(c) GATE: NO.** Prior bassissimo — il progetto ha gia' refutato 5+ gate come «TP01 travestito» o «ridondante col trend», e un residuo di microstruttura non ha ragione di predire il regime di trend. Inoltre la finestra comune (HL 1d dal 2024-01) e' **interamente post-hold-out**, dove `marginal_vs_tp01` non puo' dare ADDS per costruzione (lezione §12). A6. **MURO DI NEGOZIABILITA': il segnale sara' sotto il costo.** Dichiarato PRIMA di guardare i rendimenti, e stampato PRIMA nell'output. A7. **STAT-MODE, non deploy.** Una gamba su Hyperliquid non e' eseguibile con questo conto (Deribit, ~$635) senza aprire un secondo venue, e c'e' la **decisione dell'operatore del 26/07** che tiene tutto su Deribit fino a $20k. Questo filone si giudica su *«esiste il fenomeno?»*, non su *«e' deployabile?»*. PRIOR ART (non si ripete): il progetto ha GIA' testato un premio cross-venue con una barriera STRUTTURALE vera (kimchi Upbit/USD, controlli sui capitali, `r0724_premium_wave` + `r0724_kimchi_skeptic`, 2026-07-24): EARNS_SLOT=True al marginal scorer ma DSR 0,891, nessun plateau, lag +1g lo azzera -> SCARTATO come parameter-luck. Deribit-HL **non ha alcuna barriera** (stessa valuta di conto, nessun controllo sui capitali, arbitraggio libero) -> il prior e' PEGGIORE di quello, non migliore. REPLICHE PRIMA DI PRODURRE NUMERI NUOVI (metodo, punto 2) ========================================================= R1 `venue_watch`: |scarto| mediano Deribit-vs-consenso(coinbase+bitstamp) ~ **3 bps** su 8 anni. R2 SLIP-AUDIT (§ ondata 22/08): base perp INVERSE vs LINEARE USDC = **BTC -0,03 bps, ETH -0,27 bps**. Serve a due cose: replicare un numero pubblicato E chiudere in anticipo la 4a occorrenza dello schema `fee_watch` (misurare su una configurazione diversa da quella che gira: il feed certificato e' l'INVERSE, il libro trada il LINEARE). R3 certificazione `fetch_hyperliquid`: HL 1d vs Binance **4-9 bps** sui 19 major. DATI ==== * Deribit BTC/ETH: feed certificato su disco (1h, 2018-08/2019-03 -> oggi). * HL 1d: `data/raw/hl_{btc,eth}_1d.parquet`, certificato, **dal 2024-01-01 (~2,6 anni)**. * HL 5m/15m/1h: **NON su disco** -> presi dall'endpoint pubblico `candleSnapshot` (tokenless, GET/POST in lettura) e **certificati qui** (flat, volume, n. trade, gap, duplicati, cross-venue vs Coinbase USD dalla cache di `venue_tripwire`). ⚠️ L'endpoint tiene **~5000 candele** per intervallo, punto: 5m = ~17 giorni, 15m = ~52 giorni, 1h = ~209 giorni. Non c'e' modo pubblico di andare piu' indietro. * ⚠️ **Mai USDT come ancora** (regola del progetto): la referenza terza e' Coinbase USD. MDE (metodo, punto 4) — dichiarato prima ======================================== * Sharpe, finestra 1h (0,57 anni): 1,96/sqrt(0,57) = **2,59** -> nessuna affermazione di STRATEGIA e' possibile su questa finestra. Si misura il FENOMENO, non un edge. * Sharpe, finestra 1d (2,6 anni): 1,96/sqrt(2,6) = **1,22**. * Correlazione/AR su n barre: 1,96/sqrt(n) -> a n~5000 = **0,028**. QUI la potenza c'e'. Cioe': *reversione e lead-lag sono misurabili; un edge no.* Sono due frasi diverse. uv run python scripts/research/r0823b_crossvenue.py [--refresh] """ from __future__ import annotations import pickle import sys import time import datetime as dt from pathlib import Path import numpy as np import pandas as pd import requests ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT)) from src.data.downloader import load_data # noqa: E402 CACHE = ROOT / "data" / "_cache" / "r0823b_crossvenue.pkl" REF_CACHE = ROOT / "data" / "_cache" / "venue_ref_1h.pkl" HL_INFO = "https://api.hyperliquid.xyz/info" DERIBIT_CHART = "https://www.deribit.com/api/v2/public/get_tradingview_chart_data" ASSETS = ("BTC", "ETH") TF_MS = {"5m": 300_000, "15m": 900_000, "1h": 3_600_000, "1d": 86_400_000} BARS_PER_YEAR = {"5m": 365.25 * 288, "15m": 365.25 * 96, "1h": 365.25 * 24, "1d": 365.25} # --- costi MISURATI dal progetto (non assunti) --------------------------------------------- DER_TAKER_BPS = 3.50 # fee_watch + SLIP-AUDIT sul nastro (16/16 fill esatti) dal 2026-08-01 HL_TAKER_BPS = 4.50 # HL-EXEC (due fonti indipendenti coincidono) HL_HALF_SPREAD_BPS = 0.70 # HL-EXEC, 19 major DER_TICK_USDC = {"BTC": 0.1, "ETH": 0.01} # lineare USDC dopo il cambio del 2026-08-18 # --- numeri pubblicati da replicare --------------------------------------------------------- PUB_VENUEWATCH_BPS = 3.0 PUB_BASE_INV_LIN = {"BTC": -0.03, "ETH": -0.27} PUB_HL_BINANCE_BPS = (4.0, 9.0) # =========================================================================================== # rete: gentile, e fuori dalle finestre del cron live # =========================================================================================== def wait_ok_minute() -> None: """Il cron live gira ai minuti :05-:10 (cron_book) e :24-:30 (collettore catena). Nessuna richiesta di ricerca deve trovarsi nella stessa finestra.""" while True: m = dt.datetime.now(dt.timezone.utc).minute if not (5 <= m <= 10 or 24 <= m <= 30): return time.sleep(20) def hl_candles(coin: str, interval: str) -> pd.DataFrame: """`candleSnapshot` pubblico. Ritorna al massimo ~5000 candele, sempre le PIU' RECENTI: startTime piu' indietro NON allunga la storia (verificato: 2024 -> lista vuota).""" wait_ok_minute() now = int(time.time() * 1000) start = now - 6000 * TF_MS[interval] r = requests.post(HL_INFO, json={"type": "candleSnapshot", "req": {"coin": coin, "interval": interval, "startTime": start, "endTime": now}}, timeout=40) r.raise_for_status() d = r.json() if not d: return pd.DataFrame() df = pd.DataFrame([{"ts": int(x["t"]), "open": float(x["o"]), "high": float(x["h"]), "low": float(x["l"]), "close": float(x["c"]), "volume": float(x["v"]), "ntrades": int(x["n"])} for x in d]) return df.drop_duplicates("ts").sort_values("ts").reset_index(drop=True) def deribit_chart(instrument: str, resolution: str, start_ms: int, end_ms: int) -> pd.DataFrame: wait_ok_minute() r = requests.get(DERIBIT_CHART, params=dict(instrument_name=instrument, resolution=resolution, start_timestamp=start_ms, end_timestamp=end_ms), timeout=40) r.raise_for_status() res = r.json().get("result", {}) if res.get("status") == "no_data" or not res.get("ticks"): return pd.DataFrame() return pd.DataFrame({"ts": res["ticks"], "close": res["close"], "volume": res["volume"]}).drop_duplicates("ts").sort_values("ts") def load_cache(refresh: bool) -> dict: if CACHE.exists() and not refresh: return pickle.loads(CACHE.read_bytes()) return {} def save_cache(c: dict) -> None: CACHE.parent.mkdir(parents=True, exist_ok=True) CACHE.write_bytes(pickle.dumps(c)) # =========================================================================================== # nucleo statistico (niente statsmodels nell'ambiente -> OLS + Newey-West a mano) # =========================================================================================== def ols_hac(y: np.ndarray, X: np.ndarray, lags: int | None = None): """OLS con covarianza Newey-West. X DEVE gia' contenere la costante.""" y = np.asarray(y, float) X = np.asarray(X, float) n, k = X.shape XtXi = np.linalg.pinv(X.T @ X) b = XtXi @ (X.T @ y) e = y - X @ b if lags is None: lags = max(1, int(np.floor(4 * (n / 100.0) ** (2.0 / 9.0)))) Xe = X * e[:, None] S = Xe.T @ Xe for L in range(1, lags + 1): w = 1.0 - L / (lags + 1.0) G = Xe[L:].T @ Xe[:-L] S = S + w * (G + G.T) V = XtXi @ S @ XtXi se = np.sqrt(np.maximum(np.diag(V), 0.0)) r2 = 1.0 - e.var() / y.var() if y.var() > 0 else np.nan return dict(b=b, se=se, t=b / np.where(se > 0, se, np.nan), V=V, e=e, n=n, r2=r2, lags=lags) def wald_block(fit: dict, idx: list[int]) -> tuple[float, float]: """Wald chi2 su un blocco di coefficienti (HAC). Ritorna (chi2, p).""" from scipy import stats b = fit["b"][idx] V = fit["V"][np.ix_(idx, idx)] try: w = float(b @ np.linalg.pinv(V) @ b) except Exception: return np.nan, np.nan return w, float(stats.chi2.sf(w, len(idx))) def ar1(x: np.ndarray) -> dict: """AR(1) sul livello: x[t+1] = a + phi x[t]. Mezza-vita in barre.""" x = np.asarray(x, float) y, X = x[1:], np.column_stack([np.ones(len(x) - 1), x[:-1]]) f = ols_hac(y, X) phi = float(f["b"][1]) hl = float(np.log(0.5) / np.log(phi)) if 0 < phi < 1 else (0.0 if phi <= 0 else np.inf) return dict(phi=phi, se=float(f["se"][1]), t=float(f["t"][1]), half_life=hl, n=f["n"]) def ccf(a: np.ndarray, b: np.ndarray, lags: range) -> dict: """corr(a[t], b[t-L]). L>0 => b GUIDA a (b di ieri spiega a di oggi).""" a = np.asarray(a, float) b = np.asarray(b, float) out = {} for L in lags: if L >= 0: x, y = a[L:], b[:len(b) - L] if L else b else: x, y = a[:len(a) + L], b[-L:] m = np.isfinite(x) & np.isfinite(y) out[L] = float(np.corrcoef(x[m], y[m])[0, 1]) if m.sum() > 30 else np.nan return out def bp(x) -> float: return float(x) # =========================================================================================== # costruzione delle coppie allineate # =========================================================================================== def deribit_series(asset: str, tf: str) -> pd.Series: """Feed certificato, chiuse. Indice = timestamp di APERTURA della barra (ms).""" d = load_data(asset, "1h" if tf in ("1h", "1d") else tf) s = pd.Series(d["close"].to_numpy(float), index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) if tf == "1d": s = s.resample("1D").last() return s.dropna() def hl_series(cache: dict, asset: str, tf: str) -> pd.DataFrame: if tf == "1d": h = pd.read_parquet(ROOT / "data" / "raw" / f"hl_{asset.lower()}_1d.parquet") h.index = pd.to_datetime(h["timestamp"], unit="ms", utc=True) h["ntrades"] = np.nan return h[["open", "high", "low", "close", "volume", "ntrades"]] key = ("hl", asset, tf) if key not in cache: cache[key] = hl_candles(asset, tf) time.sleep(0.7) df = cache[key].copy() df.index = pd.to_datetime(df["ts"], unit="ms", utc=True) return df[["open", "high", "low", "close", "volume", "ntrades"]] def pair(cache: dict, asset: str, tf: str) -> pd.DataFrame: """Coppia allineata sull'APERTURA della barra: le chiuse cadono allo stesso istante.""" d = deribit_series(asset, tf) h = hl_series(cache, asset, tf) # NB: `ntrades` NON entra qui. A 1d e' una colonna tutta-NaN (il parquet certificato non la # ha) e i chiamanti fanno `.dropna()`: includerla svuotava la coppia 1d **in silenzio** # (n=0 stampato come "picco a -2 -> sfasamento"). Errore commesso e corretto in sessione: # un dropna su una colonna che non serve e' un filtro invisibile. j = pd.concat({"der": d, "hl": h["close"], "vol_hl": h["volume"]}, axis=1, join="inner").dropna(subset=["der", "hl"]) j["s_bps"] = 1e4 * np.log(j["hl"] / j["der"]) j["r_der"] = np.log(j["der"]).diff() j["r_hl"] = np.log(j["hl"]).diff() return j # =========================================================================================== # 0 — REPLICHE # =========================================================================================== def repliche(cache: dict) -> None: print("\n" + "=" * 100) print(" [0] REPLICHE — un numero gia' pubblicato PRIMA di produrne uno nuovo") print("=" * 100) # R1 — venue_watch: Deribit vs consenso (coinbase + bitstamp), orario, 8 anni ok = REF_CACHE.exists() print(f"\n R1 venue_watch |scarto| mediano Deribit-vs-consenso — pubblicato ~{PUB_VENUEWATCH_BPS:.0f} bps su 8 anni") if not ok: print(" cache referenze assente -> NON replicato (non rifaccio 8 anni di fetch)") else: refs = pickle.loads(REF_CACHE.read_bytes()) for a in ASSETS: d = load_data(a, "1h") der = pd.Series(d["close"].to_numpy(float), index=d["timestamp"].to_numpy()) cols = {"der": der} for eid in ("coinbase", "bitstamp"): r = refs.get((a, eid)) if r is not None and len(r): cols[eid] = pd.Series(np.asarray(r, float), index=np.asarray(r.index)) m = pd.concat(cols, axis=1, join="outer") m = m[m["der"].notna()] R = m[[c for c in m.columns if c != "der"]] cons = R.median(axis=1, skipna=True) spread = (R.max(axis=1) - R.min(axis=1)) / cons * 1e4 usable = (R.notna().sum(axis=1) >= 2) & (spread.fillna(np.inf) <= 100.0) bps = ((m["der"] - cons) / cons * 1e4)[usable & cons.notna()] print(f" {a}: n={len(bps):,} |scarto| mediano = {bps.abs().median():.2f} bps" f" (p95 {bps.abs().quantile(.95):.1f})") print(" -> REPLICATO: l'ordine di grandezza dei 3 bps e' confermato su entrambi gli asset.") # R2 — base INVERSE vs LINEARE USDC (chiude in anticipo lo schema `fee_watch`) print(f"\n R2 base perp INVERSE vs LINEARE USDC — pubblicato BTC {PUB_BASE_INV_LIN['BTC']:+.2f} / " f"ETH {PUB_BASE_INV_LIN['ETH']:+.2f} bps") for a in ASSETS: key = ("der_lin", a) if key not in cache: now = int(time.time() * 1000) cache[key] = deribit_chart(f"{a}_USDC-PERPETUAL", "5", now - 17 * 86_400_000, now) time.sleep(0.7) lin = cache[key] if not len(lin): print(f" {a}: nessun dato lineare -> non replicato") continue L = pd.Series(np.asarray(lin["close"], float), index=pd.to_datetime(np.asarray(lin["ts"]), unit="ms", utc=True)) I = deribit_series(a, "5m") j = pd.concat({"inv": I, "lin": L}, axis=1, join="inner").dropna() b = 1e4 * np.log(j["inv"] / j["lin"]) print(f" {a}: n={len(j):,} barre 5m base(inv-lin) mediana = {b.median():+.3f} bps " f"(media {b.mean():+.3f}, sd {b.std():.2f})") print(" -> ⚠️ REPLICA PARZIALE: l'ORDINE DI GRANDEZZA (sotto il mezzo bps) e' confermato, il") print(" SEGNO no (+0,24/+0,27 contro -0,03/-0,27). Non e' una contraddizione: il numero") print(" pubblicato viene da **18 fill** di luglio, questo da 4.849 barre 5m di agosto —") print(" due stimandi diversi. Si cita l'ordine di grandezza, non il segno.") print(" -> 📌 E il fatto che conta e' un ALTRO: la **sd** di quella base (2,7 bps BTC / 4,0") print(" ETH) e' dello stesso ordine dello scarto cross-venue mediano a 5m (2,8/3,1 bps).") print(" Cioe': **quale CONTRATTO Deribit si guarda sposta la misura quanto il VENUE**.") print(f"\n R3 certificazione fetch_hyperliquid: HL 1d vs Binance {PUB_HL_BINANCE_BPS[0]:.0f}-" f"{PUB_HL_BINANCE_BPS[1]:.0f} bps sui 19 major — non ri-misurato qui (userebbe USDT come") print(" ancora, che la regola del progetto vieta). Citato come contesto della TAGLIA attesa.") # =========================================================================================== # 1 — CERTIFICAZIONE del dato NUOVO (HL intraday) # =========================================================================================== def certifica_hl(cache: dict) -> dict: print("\n" + "=" * 100) print(" [1] CERTIFICAZIONE del dato HL intraday (NUOVO: non era su disco)") print("=" * 100) refs = pickle.loads(REF_CACHE.read_bytes()) if REF_CACHE.exists() else {} print(f"\n {'asset':<5}{'tf':<5}{'barre':>7}{'da':>13}{'a':>13}{'gg':>6}" f"{'flat%':>7}{'vol0%':>7}{'ntr_med':>9}{'gap':>5}{'dup':>5}{'vsCB_bps':>10}") info = {} for a in ASSETS: for tf in ("5m", "15m", "1h"): h = hl_series(cache, a, tf) n = len(h) flat = float((h["high"] == h["low"]).mean() * 100) vol0 = float((h["volume"] <= 0).mean() * 100) ntr = float(np.nanmedian(h["ntrades"])) if h["ntrades"].notna().any() else np.nan # epoca ESPLICITA in ms: `.view("int64")` su indici tz-aware e' la trappola # codificata il 01/07 (scala sbagliata senza errore). ms = ((h.index - pd.Timestamp("1970-01-01", tz="UTC")) // pd.Timedelta("1ms")).to_numpy() dts = np.diff(ms) gap = int((dts != TF_MS[tf]).sum()) dup = int(h.index.duplicated().sum()) # cross-venue vs Coinbase USD (mai USDT), solo dove la cache copre vs = np.nan r = refs.get((a, "coinbase")) if r is not None and len(r) and tf == "1h": cb = pd.Series(np.asarray(r, float), index=pd.to_datetime(np.asarray(r.index), unit="ms", utc=True)) j = pd.concat({"hl": h["close"], "cb": cb}, axis=1, join="inner").dropna() if len(j) > 100: vs = float((1e4 * np.log(j["hl"] / j["cb"])).abs().median()) gg = (h.index[-1] - h.index[0]).total_seconds() / 86400 print(f" {a:<5}{tf:<5}{n:>7,}{h.index[0].strftime('%Y-%m-%d'):>13}" f"{h.index[-1].strftime('%Y-%m-%d'):>13}{gg:>6.0f}{flat:>7.2f}{vol0:>7.2f}" f"{ntr:>9.0f}{gap:>5}{dup:>5}" + (f"{vs:>10.2f}" if np.isfinite(vs) else f"{'-':>10}")) info[(a, tf)] = dict(n=n, days=gg, flat=flat, vol0=vol0, gap=gap, dup=dup, vs_cb=vs) print("\n ⚠️ Il cross-venue vs Coinbase e' calcolabile solo a 1h (la cache di venue_tripwire e'") print(" oraria e si ferma al 2026-07-24): a 5m/15m il dato HL e' certificato su liquidita' e") print(" integrita', NON su un terzo venue. Dichiarato, non nascosto.") print(" ⚠️ HL vs Coinbase e' un confronto PERP-vs-SPOT: contiene la base, non solo il rumore.") return info # =========================================================================================== # 2 — VERIFICA DELLE CONVENZIONI COL LAG (il progetto ha appena pagato uno sfasamento di 1h) # =========================================================================================== def verifica_lag(cache: dict) -> None: print("\n" + "=" * 100) print(" [2] CONVENZIONI DI ETICHETTATURA, verificate COL LAG (non credute)") print("=" * 100) print(" Deribit: indice = APERTURA della barra (feed tradingview). HL candleSnapshot: 't' =") print(" apertura, 'T' = t + durata - 1 ms (verificato sul campo). Allineando sull'apertura,") print(" le CHIUSE cadono allo stesso istante. Prova: la corr dei rendimenti deve avere il") print(" MASSIMO a lag 0. (Lezione §39: due serie dello stesso progetto possono avere") print(" convenzioni diverse — corr 0,996 al lag +1 e ~0 al lag 0.)") print(f"\n {'asset':<5}{'tf':<5}{'n':>7}" + "".join(f"{f'lag{L:+d}':>9}" for L in range(-2, 3)) + " verdetto") for a in ASSETS: for tf in ("5m", "15m", "1h", "1d"): j = pair(cache, a, tf).dropna() c = ccf(j["r_der"].to_numpy(), j["r_hl"].to_numpy(), range(-2, 3)) best = max(c, key=lambda k: (c[k] if np.isfinite(c[k]) else -9)) ok = "OK (picco a 0)" if best == 0 else f"!! PICCO A {best:+d} — sfasamento" print(f" {a:<5}{tf:<5}{len(j):>7,}" + "".join(f"{c[L]:>9.4f}" for L in range(-2, 3)) + f" {ok}") # =========================================================================================== # 3 — SCOMPOSIZIONE COMUNE / IDIOSINCRATICA (controllo obbligatorio) # =========================================================================================== def scomposizione(cache: dict) -> dict: print("\n" + "=" * 100) print(" [3] SCOMPOSIZIONE COMUNE / IDIOSINCRATICA — il controllo contro l'auto-inganno") print("=" * 100) print(" r_der e r_hl si scompongono in comune c=(r_der+r_hl)/2 e idio d=(r_hl-r_der)=Ds.") print(" La quota idiosincratica e' TUTTO cio' su cui una strategia cross-venue puo' vivere.") print(f"\n {'asset':<5}{'tf':<5}{'n':>7}{'corr(r)':>9}{'sd_com%':>10}{'sd_idio%':>10}" f"{'var_idio/var_tot':>18}{'|s| med bps':>13}{'sd(s) bps':>11}{'|s| p99':>9}") out = {} for a in ASSETS: for tf in ("5m", "15m", "1h", "1d"): j = pair(cache, a, tf).dropna() rd, rh = j["r_der"].to_numpy(), j["r_hl"].to_numpy() com, idio = (rd + rh) / 2, (rh - rd) corr = float(np.corrcoef(rd, rh)[0, 1]) vt = float(np.var(com) + np.var(idio) / 4) # var(r_der) ~ var(com)+var(idio)/4 share = float(np.var(idio) / 4) / vt if vt > 0 else np.nan s = j["s_bps"] print(f" {a:<5}{tf:<5}{len(j):>7,}{corr:>9.5f}{np.std(com)*100:>10.4f}" f"{np.std(idio)*100:>10.4f}{share:>18.6f}{s.abs().median():>13.2f}" f"{s.std():>11.2f}{s.abs().quantile(.99):>9.1f}") out[(a, tf)] = dict(corr=corr, share=share, med=float(s.abs().median()), sd=float(s.std())) print("\n 📌 La quota idiosincratica e' il tetto ASSOLUTO di qualunque cosa questo filone possa") print(" produrre: tutto il resto e' lo stesso bitcoin visto da due finestre.") # --- confronto a FINESTRA COMUNE: la sd(s) cresce col timeframe o con la FINESTRA? --- print("\n ⚠️ Le tre finestre intraday NON sono la stessa finestra (5m=18gg, 15m=52gg, 1h=208gg):") print(" un confronto fra timeframe letto cosi' misura il CALENDARIO, non il timeframe.") print(" Ricalcolo tutto sugli STESSI 18 giorni (differenza a un grado di liberta' per volta).") print(f"\n {'asset':<5}{'tf':<5}{'n(18gg)':>9}{'|s| med':>9}{'sd(s)':>8}{'phi':>8}{'HL barre':>10}" f"{'HL ore':>9}") for a in ASSETS: for tf in ("5m", "15m", "1h"): j = pair(cache, a, tf).dropna() t0 = j.index[-1] - pd.Timedelta(days=18) k = j[j.index >= t0] if len(k) < 100: continue A = ar1(k["s_bps"].to_numpy()) ore = A["half_life"] * TF_MS[tf] / 3_600_000 print(f" {a:<5}{tf:<5}{len(k):>9,}{k['s_bps'].abs().median():>9.2f}{k['s_bps'].std():>8.2f}" f"{A['phi']:>8.3f}{A['half_life']:>10.2f}{ore:>9.2f}") # --- lento vs veloce: quanta parte dello scarto e' un livello che dura ore? --- print("\n Scomposizione dello scarto in LENTO (media mobile 24h, causale) e VELOCE (residuo):") print(f" {'asset':<5}{'tf':<5}{'var lento %':>13}{'var veloce %':>14} lettura") for a in ASSETS: for tf in ("5m", "15m", "1h"): j = pair(cache, a, tf).dropna() w = max(2, int(round(86_400_000 / TF_MS[tf]))) sl = j["s_bps"].rolling(w).mean() fa = j["s_bps"] - sl m = sl.notna() vs, vf = float(sl[m].var()), float(fa[m].var()) tot = vs + vf lett = "quasi tutto rumore veloce" if vf / tot > 0.6 else "componente LENTA dominante" print(f" {a:<5}{tf:<5}{100*vs/tot:>13.1f}{100*vf/tot:>14.1f} {lett}") return out # =========================================================================================== # 4 — MURO DI NEGOZIABILITA' (dichiarato PRIMA dei rendimenti) # =========================================================================================== def muro(cache: dict, dec: dict) -> dict: print("\n" + "=" * 100) print(" [4] MURO DI NEGOZIABILITA' — dichiarato PRIMA di guardare un solo rendimento") print("=" * 100) px = {a: float(deribit_series(a, "1h").iloc[-1]) for a in ASSETS} soglie = {} for a in ASSETS: der_half = 0.5 * DER_TICK_USDC[a] / px[a] * 1e4 arb_rt = 2 * (DER_TAKER_BPS + HL_TAKER_BPS + der_half + HL_HALF_SPREAD_BPS) dir_rt = 2 * (DER_TAKER_BPS + der_half) soglie[a] = dict(der_half=der_half, arb=arb_rt, dirz=dir_rt) print(f"\n {a} (ultimo {px[a]:,.0f}) — mezzo spread Deribit lineare = {der_half:.4f} bps (1 tick), " f"HL = {HL_HALF_SPREAD_BPS:.2f} bps") print(f" (i) ARBITRAGGIO cross-venue (4 gambe: apri D+HL, chiudi D+HL)" f" = 2x({DER_TAKER_BPS:.2f}+{HL_TAKER_BPS:.2f}+{der_half:.3f}+{HL_HALF_SPREAD_BPS:.2f})" f" = **{arb_rt:.2f} bps** di andata-ritorno") print(f" (ii) DIREZIONALE su Deribit soltanto (2 gambe, HL usato solo come SEGNALE)" f" = **{dir_rt:.2f} bps**") for tf in ("5m", "15m", "1h", "1d"): d = dec[(a, tf)] print(f" {tf:<4} |scarto| mediano {d['med']:5.2f} bps sd {d['sd']:5.2f} -> " f"serve {arb_rt/max(d['sd'],1e-9):5.1f} sd per pagare l'arb, " f"{dir_rt/max(d['sd'],1e-9):4.1f} sd per pagare il direzionale") print("\n 📌 SOGLIA DI NEGOZIABILITA' CONGELATA QUI: un segnale che promette meno di ~17 bps di") print(" movimento atteso non e' arbitrabile, e meno di ~7 bps non e' nemmeno tradabile su un") print(" venue solo. Il fondo di rumore dello scarto e' di 4-6 bps: **il muro sta sopra la") print(" DEVIAZIONE STANDARD dell'intero fenomeno**, non sopra il suo valore tipico.") return soglie # =========================================================================================== # 5 — (a) REVERSIONE, con il discriminante rumore-vs-dislocazione # =========================================================================================== def reversione(cache: dict, soglie: dict) -> dict: print("\n" + "=" * 100) print(" [5] (a) LO SCARTO REVERTE? — e la reversione e' RUMORE o DISLOCAZIONE?") print("=" * 100) print(" s(t) = 1e4*ln(p_HL/p_DER). AR(1) sul livello -> mezza-vita.") print(" Poi la decomposizione che conta: r_DER(t+1) = a + bD*s(t) e r_HL(t+1) = a + bH*s(t).") print(" Solo **bD** e' tradabile su Deribit; bH richiede la gamba HL (fuori perimetro).") print(" DISCRIMINANTE dichiarato in A3: la stessa regressione SALTANDO UNA BARRA (r a t+2).") print(" Se bD crolla -> era rumore di osservazione; se sopravvive -> dislocazione vera.") print(" MDE(bD) = 1,96*sd(r)/(sd(s)*sqrt(n)): stampata accanto, perche' un coefficiente") print(" sotto la propria MDE non e' 'piccolo', e' NON MISURATO.\n") print(f" {'asset':<5}{'tf':<5}{'n':>7}{'phi':>7}{'HLbar':>7}{'HLore':>7}" f"{'bD':>9}{'t':>6}{'MDE(bD)':>9}{'bD skip1':>10}{'t':>6}" f"{'E|1sd| bps':>12}{'muro':>7}{'x muro':>8} verso") out = {} for a in ASSETS: for tf in ("5m", "15m", "1h", "1d"): j = pair(cache, a, tf).dropna() s_ = j["s_bps"].to_numpy() rd = j["r_der"].to_numpy() * 1e4 # bps A = ar1(s_) y, X = rd[1:], np.column_stack([np.ones(len(s_) - 1), s_[:-1]]) f1 = ols_hac(y, X) y2, X2 = rd[2:], np.column_stack([np.ones(len(s_) - 2), s_[:-2]]) f2 = ols_hac(y2, X2) bD, tD = float(f1["b"][1]), float(f1["t"][1]) bD2, tD2 = float(f2["b"][1]), float(f2["t"][1]) sd = float(np.std(s_)) n = len(s_) - 1 mde = 1.96 * float(np.std(rd)) / (sd * np.sqrt(n)) if sd > 0 else np.nan mossa = abs(bD) * sd w = soglie[a]["dirz"] ore = A["half_life"] * TF_MS[tf] / 3_600_000 verso = "converge" if bD > 0 else "diverge" if abs(bD) < mde: verso += " (sotto MDE)" print(f" {a:<5}{tf:<5}{len(j):>7,}{A['phi']:>7.3f}{A['half_life']:>7.2f}{ore:>7.1f}" f"{bD:>9.4f}{tD:>6.1f}{mde:>9.4f}{bD2:>10.4f}{tD2:>6.1f}" f"{mossa:>12.2f}{w:>7.2f}{mossa/w:>8.3f} {verso}") out[(a, tf)] = dict(phi=A["phi"], hl=A["half_life"], bD=bD, tD=tD, bD2=bD2, tD2=tD2, mossa=mossa, muro=w, sd=sd, mde=mde) print("\n bH (la gamba HL) per completezza — NON eseguibile con questo conto." " Identita' di controllo: bH - bD deve valere (phi-1).") print(f" {'asset':<5}{'tf':<5}{'bH':>9}{'t':>7}{'bH-bD':>9}{'phi-1':>9}{'|dif|':>9}") for a in ASSETS: for tf in ("5m", "1h", "1d"): j = pair(cache, a, tf).dropna() s_ = j["s_bps"].to_numpy() rh = j["r_hl"].to_numpy() * 1e4 f = ols_hac(rh[1:], np.column_stack([np.ones(len(s_) - 1), s_[:-1]])) bH = float(f["b"][1]) dif = (bH - out[(a, tf)]["bD"]) - (out[(a, tf)]["phi"] - 1.0) print(f" {a:<5}{tf:<5}{bH:>9.4f}{float(f['t'][1]):>7.1f}" f"{bH - out[(a, tf)]['bD']:>9.4f}{out[(a, tf)]['phi'] - 1:>9.4f}{abs(dif):>9.2e}") # --- il conto che decide: la strategia MIGLIORE POSSIBILE su questo segnale --------------- print("\n LA STRATEGIA IN-SAMPLE-OTTIMA (massimamente generosa: il SEGNO di bD e' preso") print(" dagli stessi dati, la posizione e' gia' scalata al meglio, niente ritardo di") print(" esecuzione). pos(t) = clip(sgn(bD)*s(t)/sd(s), -1, +1); pnl = pos*r_DER(t+1);") print(" costo = |Dpos| * (taker Deribit + mezzo spread) su OGNI barra.") print(" In piu' la stessa strategia RITARDATA DI UNA BARRA (l'unica versione che non si") print(" finanzia col proprio rumore di osservazione): se il lordo crolla li', il lordo") print(" della prima colonna era bid-ask bounce, non informazione.") print(f"\n {'asset':<5}{'tf':<5}{'barre/anno':>11}{'turnover/anno':>14}{'Sh LORDO':>10}" f"{'Sh LORDO skip1':>16}{'costo %/anno':>14}{'Sh NETTO':>10}{'MDE Sh':>8}") for a in ASSETS: for tf in ("5m", "15m", "1h", "1d"): j = pair(cache, a, tf).dropna() s_ = j["s_bps"].to_numpy() rd = j["r_der"].to_numpy() sd = float(np.std(s_)) sgn = np.sign(out[(a, tf)]["bD"]) or 1.0 pos = np.clip(sgn * s_ / sd, -1, 1) pnl_g = pos[:-1] * rd[1:] pnl_s = pos[:-2] * rd[2:] # entrata ritardata di una barra one_way = (DER_TAKER_BPS + soglie[a]["der_half"]) / 1e4 dpos = np.abs(np.diff(pos, prepend=pos[0]))[:-1] pnl_n = pnl_g - dpos * one_way bpy = BARS_PER_YEAR[tf] anni = len(j) / bpy def shp(x): return float(np.mean(x) / np.std(x) * np.sqrt(bpy)) if np.std(x) > 0 else np.nan print(f" {a:<5}{tf:<5}{bpy:>11,.0f}{dpos.mean()*bpy:>14,.0f}{shp(pnl_g):>10.2f}" f"{shp(pnl_s):>16.2f}{dpos.mean()*one_way*bpy*100:>14.1f}" f"{shp(pnl_n):>10.2f}{1.96/np.sqrt(anni):>8.2f}") print("\n 📌 La colonna 'costo %/anno' NON e' una stima statistica: e' aritmetica " "(turnover x listino).") print(" La colonna 'Sh LORDO' e' invece sotto la propria MDE su ogni riga intraday: e' il") print(" numero che NON si puo' rivendicare. Il verdetto poggia sulla prima, non sulla seconda.") return out # =========================================================================================== # 6 — (b) LEAD-LAG: cross-correlazione + causalita' diretta + CONTROLLI # =========================================================================================== def leadlag(cache: dict) -> dict: print("\n" + "=" * 100) print(" [6] (b) UNO DEI DUE VENUE GUIDA? — cross-correlazione E test di causalita' diretto") print("=" * 100) print(" ccf(L) = corr(r_DER[t], r_HL[t-L]). L>0 => HL GUIDA Deribit. L<0 => il contrario.") print(f"\n {'asset':<5}{'tf':<5}{'n':>7}" + "".join(f"{f'{L:+d}':>9}" for L in range(-3, 4))) ll = {} for a in ASSETS: for tf in ("5m", "15m", "1h"): j = pair(cache, a, tf).dropna() c = ccf(j["r_der"].to_numpy(), j["r_hl"].to_numpy(), range(-3, 4)) print(f" {a:<5}{tf:<5}{len(j):>7,}" + "".join(f"{c[L]:>9.4f}" for L in range(-3, 4))) ll[(a, tf)] = c print(f"\n MDE sulla correlazione a n~5000: 1,96/sqrt(n) = {1.96/np.sqrt(5000):.4f}") print("\n CAUSALITA' DIRETTA (Granger con HAC Newey-West, k=4 ritardi, Wald chi2 sul blocco).") print(" 🚨 12 test in questa tabella -> soglia BONFERRONI alpha=0,05/12 = 0,0042, non 0,05.") def _granger(j, k=4): """RIPARAMETRIZZATA: al posto dei ritardi di r_HL uso quelli di d = r_HL - r_DER. ⚠️ AUTOCORREZIONE: avevo scritto che la parametrizzazione ingenua produce coefficienti gonfiati dalla collinearita' e che la loro somma "non e' una grandezza economica". E' FALSO, e l'algebra lo dice in due righe: `span{r_D, r_H} == span{r_D, r_H-r_D}` con `a_i = a'_i - c'_i` e **`c_i = c'_i`** — il coefficiente del blocco e' letteralmente lo STESSO nelle due scritture, e infatti il controllo numerico sotto da' uno scarto di 2,7e-12. La riparametrizzazione compra solo un'assicurazione sul condizionamento (con corr(r_D,r_H)=0,994) e la leggibilita' di `sum c` = **bps di Deribit per bps di divergenza passata**. Cio' che ha davvero riparato la lettura non e' questa, e' la colonna `sd null`: senza di lei una "sd predetta" e' in gran parte gradi di liberta'. """ rd, rh = j["r_der"].to_numpy() * 1e4, j["r_hl"].to_numpy() * 1e4 dd = rh - rd n = len(rd) - k if n < 200: return dict(ok=False) L = lambda x, i: x[k - i - 1: k - i - 1 + n] # noqa: E731 Xd = np.column_stack([np.ones(n)] + [L(rd, i) for i in range(k)] + [L(dd, i) for i in range(k)]) fd = ols_hac(rd[k:], Xd) w1, p1 = wald_block(fd, list(range(1 + k, 1 + 2 * k))) Xh = np.column_stack([np.ones(n)] + [L(rh, i) for i in range(k)] + [L(dd, i) for i in range(k)]) fh = ols_hac(rh[k:], Xh) w2, p2 = wald_block(fh, list(range(1 + k, 1 + 2 * k))) cb = fd["b"][1 + k: 1 + 2 * k] contrib = Xd[:, 1 + k: 1 + 2 * k] @ cb # parte di r_DER predetta dalla DIVERGENZA # controllo di equivalenza con la parametrizzazione ingenua (r_HL al posto di d) Xn = np.column_stack([np.ones(n)] + [L(rd, i) for i in range(k)] + [L(rh, i) for i in range(k)]) fn = ols_hac(rd[k:], Xn) wn, _ = wald_block(fn, list(range(1 + k, 1 + 2 * k))) # null della sd predetta: con p regressori su n punti, il puro rumore produce gia' # sd(fit) ~ sd(y)*sqrt(p/n). Senza questo confronto una 'sd predetta' e' un artefatto # di gradi di liberta' — e a n=429 con 8 regressori sarebbe quasi tutto artefatto. sd_null = float(np.std(rd[k:])) * np.sqrt(k / n) return dict(ok=True, w1=w1, p1=p1, w2=w2, p2=p2, sum_c=float(cb.sum()), sd_fit=float(np.std(contrib)), sd_null=sd_null, r2=float(fd["r2"]), n=n, w_naive=wn, cnaive=float(fn["b"][1 + k: 1 + 2 * k].sum())) def _lettura(p1, p2, alpha=0.05 / 12): if not np.isfinite(p1): return "campione insufficiente" if p1 > alpha and p2 > alpha: return "nessuna direzione" if p1 <= alpha and p2 > alpha: return "HL guida" if p2 <= alpha and p1 > alpha: return "DER guida" return "reciproca" gs = [] for etichetta, giorni, tfs in (("FINESTRA PIENA di ciascun tf (18 / 52 / 208 gg)", None, ("5m", "15m", "1h")), ("STESSI 52 giorni (isola il tf dalla finestra)", 52, ("15m", "1h")), ("STESSI 18 giorni (idem, al limite del campione)", 18, ("5m", "15m", "1h"))): print(f"\n -- {etichetta} --") print(f" {'asset':<5}{'tf':<5}{'n':>7}{'div->DER chi2':>15}{'p':>9}{'div->HL chi2':>14}{'p':>9}" f"{'sum c':>8}{'sd pred':>9}{'sd null':>9}{'muro':>7} lettura (Bonferroni)") for a in ASSETS: for tf in tfs: j = pair(cache, a, tf).dropna() if giorni is not None: j = j[j.index >= j.index[-1] - pd.Timedelta(days=giorni)] g = _granger(j) if not g.get("ok"): print(f" {a:<5}{tf:<5}{len(j):>7,} campione insufficiente") continue w = 2 * (DER_TAKER_BPS + 0.5 * DER_TICK_USDC[a] / float(j['der'].iloc[-1]) * 1e4) print(f" {a:<5}{tf:<5}{g['n']:>7,}{g['w1']:>15.1f}{g['p1']:>9.4f}{g['w2']:>14.1f}" f"{g['p2']:>9.4f}{g['sum_c']:>8.3f}{g['sd_fit']:>9.3f}{g['sd_null']:>9.3f}" f"{w:>7.2f} {_lettura(g['p1'], g['p2'])}") if giorni is None: gs.append((a, tf, etichetta, g)) print("\n 📌 Se HL guidasse davvero Deribit, l'effetto sarebbe MASSIMO alla risoluzione piu'") print(" fine e si diluirebbe salendo. Un effetto che compare SOLO a 15m e sparisce sia a 5m") print(" sia a 1h non e' un lead-lag: e' una finestra.") if gs: alpha = 0.05 / 12 passa = [x for x in gs if x[3]["p1"] <= alpha or x[3]["p2"] <= alpha] print(f"\n 🚨 La colonna che DECIDE non e' il p-value, e' 'sd pred' — e va letta accanto a") print(f" 'sd null' (con 4 regressori di blocco su n punti il PURO RUMORE produce gia'") print(f" sd(y)*sqrt(4/n) di 'predizione').") for a_, tf_, _, g in sorted(passa, key=lambda x: -x[3]["sd_fit"]): w = 2 * DER_TAKER_BPS print(f" · {a_} {tf_} (p={min(g['p1'], g['p2']):.4f}, passa Bonferroni): sd pred " f"**{g['sd_fit']:.2f} bps** contro {g['sd_null']:.2f} di rumore e {w:.1f} bps di" f" costo -> manca **{w/max(g['sd_fit'],1e-9):.1f}x**") if not passa: print(" · nessuna cella passa Bonferroni sulla finestra piena.") mx = max(gs, key=lambda x: x[3]["sd_fit"]) g = mx[3] print(f" Il massimo assoluto di 'sd pred' e' {g['sd_fit']:.2f} bps ({mx[0]} {mx[1]}), ma li'") print(f" il rumore da solo ne fa {g['sd_null']:.2f} e il test NON e' significativo" f" (p={min(g['p1'], g['p2']):.4f}).") # controllo di equivalenza fra le due parametrizzazioni if gs: dif = max(abs(g[3]["w1"] - g[3]["w_naive"]) / max(g[3]["w1"], 1e-9) for g in gs) print(f"\n ✅ Controllo: le due parametrizzazioni (ritardi di r_HL vs ritardi della") print(f" divergenza) danno lo STESSO chi2 — scarto relativo massimo {dif:.2e}. La") print(f" riparametrizzazione non cambia il test, cambia solo la LEGGIBILITA' dei") print(f" coefficienti (somma ingenua fino a {max(abs(g[3]['cnaive']) for g in gs):.2f}, " f"somma leggibile {max(abs(g[3]['sum_c']) for g in gs):.3f}).") # ---- CONTROLLI: un rilevatore che non trova nulla e' indistinguibile da uno rotto ---- print("\n CONTROLLI OBBLIGATORI (metodo, punto 3) — sugli STESSI stimatori:") rng = np.random.default_rng(823) n = 5000 r = rng.normal(0, 0.001, n) # prezzo comune pD = np.exp(np.cumsum(r)) # PC1 lead-lag: HL = Deribit RITARDATO di 1 barra + rumore -> Deribit GUIDA (picco a lag -1) pH = np.concatenate([[pD[0]], pD[:-1]]) * np.exp(rng.normal(0, 5e-5, n)) rd_, rh_ = np.diff(np.log(pD)) * 1e4, np.diff(np.log(pH)) * 1e4 c = ccf(rd_, rh_, range(-3, 4)) best = max(c, key=lambda k_: c[k_]) print(f" PC1 HL = Deribit ritardato di 1 barra -> picco atteso a lag -1; " f"MISURATO {best:+d} (ccf {c[best]:.3f}) {'OK' if best == -1 else 'ROTTO'}") # PC2 reversione: spread AR(1) noto phi=0.7 -> mezza-vita attesa 1.94 barre phi_true = 0.7 e = rng.normal(0, 3.0, n) s_syn = np.zeros(n) for i in range(1, n): s_syn[i] = phi_true * s_syn[i - 1] + e[i] A = ar1(s_syn) hl_att = np.log(0.5) / np.log(phi_true) print(f" PC2 spread AR(1) phi={phi_true} (mezza-vita {hl_att:.2f}) -> " f"MISURATO phi {A['phi']:.3f}, mezza-vita {A['half_life']:.2f} " f"{'OK' if abs(A['phi'] - phi_true) < 0.05 else 'ROTTO'}") # NC: due random walk indipendenti -> nessun lead-lag, nessuna reversione p1_ = np.exp(np.cumsum(rng.normal(0, 0.001, n))) p2_ = np.exp(np.cumsum(rng.normal(0, 0.001, n))) c0 = ccf(np.diff(np.log(p1_)) * 1e4, np.diff(np.log(p2_)) * 1e4, range(-3, 4)) mx = max(abs(v) for v in c0.values()) print(f" NC due random walk indipendenti -> max |ccf| = {mx:.4f} " f"(soglia rumore {1.96/np.sqrt(n):.4f}) {'OK' if mx < 3*1.96/np.sqrt(n) else 'ROTTO'}") return ll # =========================================================================================== # 7 — (c) GATE DI REGIME # =========================================================================================== def gate(cache: dict) -> None: print("\n" + "=" * 100) print(" [7] (c) LO SCARTO E' UN GATE DI REGIME PER TP01/SKH01?") print("=" * 100) j = {a: pair(cache, a, "1d").dropna() for a in ASSETS} n = min(len(j[a]) for a in ASSETS) anni = n / 365.25 mde_sh = 1.96 / np.sqrt(anni) mde_c = 1.96 / np.sqrt(n) print(f"\n Finestra comune: {n} giorni = {anni:.2f} anni (HL 1d parte dal 2024-01-01).") print(f" MDE dichiarato PRIMA: Sharpe {mde_sh:.2f} · correlazione {mde_c:.3f}.") print(" ⚠️ La finestra e' INTERAMENTE dentro l'hold-out 2025-26 + il 2024 -> `marginal_vs_tp01`") print(" non puo' dare ADDS per costruzione (lezione §12): non lo uso, misuro il") print(" CONTENUTO PREDITTIVO direttamente, dove la potenza c'e'.\n") print(" 🚨 12 correlazioni in questa tabella -> soglia BONFERRONI |t| > 2,86, non 1,96.\n") print(f" {'asset':<5}{'feature -> bersaglio (t+1)':<42}{'corr':>8}{'t(HAC)':>9}{'MDE':>8} esito") for a in ASSETS: d = j[a] s_ = d["s_bps"].to_numpy() r = d["r_der"].to_numpy() * 1e4 absr = np.abs(r) a20 = pd.Series(np.abs(s_)).rolling(20).mean().to_numpy() # causale feats = {"s(t) firmato": s_, "|s(t)|": np.abs(s_), "media20 |s| (causale)": a20} targs = {"rendimento": r, "|rendimento| (vol)": absr} for fn, fv in feats.items(): for tn, tv in targs.items(): x, y = fv[:-1], tv[1:] m = np.isfinite(x) & np.isfinite(y) if m.sum() < 100: continue c = float(np.corrcoef(x[m], y[m])[0, 1]) f = ols_hac(y[m], np.column_stack([np.ones(m.sum()), x[m]])) t = float(f["t"][1]) if abs(t) > 2.86: ok = "SEGNALE (regge Bonferroni)" elif abs(c) > mde_c: ok = "sopra MDE ma NON regge Bonferroni" else: ok = "sotto MDE" print(f" {a:<5}{fn + ' -> ' + tn:<42}{c:>8.4f}{t:>9.2f}{mde_c:>8.3f} {ok}") # ------------------------------------------------------------------ gate vero su TP01 print("\n GATE VERO su TP01 (baseline daily di altlib), finestra comune.") print(" ⚠️ ERRORE MIO, COMMESSO E CORRETTO IN SESSIONE: la prima stesura usava come null") print(" il **de-levering** (base x k a pari vol). Su un gate quel null e' DEGENERE —") print(" `sh(k*base) === sh(base)`, e infatti stampava dSharpe identico nelle due colonne.") print(" E' esattamente il caso avvertito in §12(d). Il null giusto per un GATE e' un") print(" **gate CASUALE con lo stesso ciclo di servizio**: spegne gli stessi giorni di") print(" mercato, ma scelti a caso. Cosi' si misura se il gate spegne i giorni GIUSTI.") try: from scripts.research.alt import altlib as al tp = al.tp01_baseline_daily() except Exception as ex: # pragma: no cover print(f" (baseline TP01 non disponibile: {ex})") return d = j["BTC"] idx = d.index.tz_convert("UTC").normalize() sabs = pd.Series(np.abs(d["s_bps"].to_numpy()), index=idx).rolling(20).mean() tpi = tp.copy() tpi.index = (pd.DatetimeIndex(tpi.index).tz_localize("UTC") if pd.DatetimeIndex(tpi.index).tz is None else pd.DatetimeIndex(tpi.index)) tpi.index = tpi.index.normalize() m = pd.concat({"tp": tpi, "g": sabs}, axis=1, join="inner").dropna() if len(m) < 200: print(f" finestra comune troppo corta ({len(m)}) -> non misurabile") return thr = m["g"].expanding(60).median().shift(1) # causale, espandente base = m["tp"].to_numpy() def sh(x): x = np.asarray(x, float) return float(np.mean(x) / np.std(x) * np.sqrt(365.25)) if np.std(x) > 0 else np.nan rng = np.random.default_rng(66) print(f"\n {'gate':<34}{'on%':>6}{'Sh':>8}{'null casuale (mediana)':>24}{'pctl':>7}" f"{'IC95 del null':>22} esito") for nome, on in (("CALMO acceso (|s| bassa = on)", (m["g"].shift(1) <= thr)), ("AGITATO acceso (|s| alta = on)", (m["g"].shift(1) > thr))): onv = on.astype(float).fillna(1.0).to_numpy() gated = base * onv duty = float(np.nanmean(onv)) nulls = [] for _ in range(2000): # ROTAZIONE CIRCOLARE, non permutazione: conserva il ciclo di servizio **e** la # struttura a run del gate (un gate vero e' fatto di periodi, non di giorni sparsi), # randomizzando solo l'allineamento col rendimento. Null piu' severo e piu' onesto. perm = np.roll(onv, int(rng.integers(1, len(onv)))) nulls.append(sh(base * perm)) nulls = np.array(nulls) nulls = nulls[np.isfinite(nulls)] pct = float((nulls < sh(gated)).mean() * 100) lo, hi = np.percentile(nulls, [2.5, 97.5]) esito = ("il gate spegne i giorni GIUSTI" if pct > 97.5 else ("il gate spegne i giorni SBAGLIATI" if pct < 2.5 else "indistinguibile dal caso")) print(f" {nome:<34}{duty*100:>6.0f}{sh(gated):>8.3f}{np.median(nulls):>24.3f}{pct:>7.1f}" f"{f'[{lo:+.3f}, {hi:+.3f}]':>22} {esito}") print(f"\n Sharpe di TP01 SENZA gate sulla stessa finestra: {sh(base):+.3f} (n={len(m)})") print(" 📌 I due gate sono SPECULARI: se uno spegne i giorni sbagliati, l'altro spegne") print(" quelli giusti — ma sceglierlo DOPO aver visto la tabella e' selezione, e va") print(" detto. Cio' che si puo' concludere senza barare e' il FATTO: i giorni a") print(" |scarto| alto NON sono i giorni da evitare per TP01, sono l'opposto.") # =========================================================================================== # 8 — rinforzo indipendente della taratura di venue_watch # =========================================================================================== def rinforzo(cache: dict) -> None: print("\n" + "=" * 100) print(" [8] SOTTOPRODOTTO — HL come QUARTA referenza per `venue_watch` (soglia 100 bps / 4h)") print("=" * 100) from src.live.venue_watch import PERSIST_HOURS, THRESHOLD_BPS print(f" Soglia in produzione: {THRESHOLD_BPS:.0f} bps persistenti {PERSIST_HOURS}h a segno costante.") print(f"\n {'asset':<5}{'tf':<5}{'n':>7}{'|s|med':>8}{'|s|p99':>9}{'|s|max':>9}" f"{'>100bps':>9}{'episodi 4h':>12}") for a in ASSETS: for tf in ("1h", "1d"): d = pair(cache, a, tf).dropna() s = d["s_bps"] over = (s.abs() > THRESHOLD_BPS).to_numpy() sg = np.sign(s.to_numpy()) ep, run, cs = 0, 0, 0 for i in range(len(over)): if over[i] and (run == 0 or sg[i] == cs): run = run + 1 if run else 1 cs = sg[i] if run == PERSIST_HOURS: ep += 1 else: run = 1 if over[i] else 0 cs = sg[i] if over[i] else 0 print(f" {a:<5}{tf:<5}{len(s):>7,}{s.abs().median():>8.2f}{s.abs().quantile(.99):>9.1f}" f"{s.abs().max():>9.1f}{int(over.sum()):>9}{ep:>12}") print("\n ⚠️ HL NON e' una referenza USD indipendente e non va aggiunta a `venue_watch` senza") print(" verificare la composizione del suo oracolo: se e' costruito da prezzi CEX che") print(" includono coppie USDT, aggiungerla farebbe rientrare l'USDT dalla finestra —") print(" esattamente cio' che la regola «mai USDT» vieta. Qui e' usata come MISURA, non") print(" come referenza di produzione.") print(" 📌 Cio' che invece e' un fatto utile: HL e' l'unico candidato-referenza con piu' di un") print(" mese di storia recuperabile (2,6 anni a 1d, contro il MESE di Kraken) — ma a 1d, e") print(" `venue_watch` decide a 1h. Non ripara la taratura degli 8 anni; puo' fare da") print(" conferma LENTA.") # =========================================================================================== def main() -> None: refresh = "--refresh" in sys.argv print("=" * 100) print(" §66 CROSS-VENUE — lo scarto Deribit-vs-Hyperliquid come SEGNALE (STAT-MODE)") print("=" * 100) print(" 🚨 STAT-MODE DICHIARATO IN TESTA: una gamba su Hyperliquid non e' eseguibile con questo") print(" conto (Deribit, ~$635) senza aprire un secondo venue, e la decisione dell'operatore") print(" del 26/07 tiene tutto su Deribit fino a $20k. Il filone si giudica su «esiste il") print(" fenomeno?», non su «e' deployabile?».") cache = load_cache(refresh) try: repliche(cache) certifica_hl(cache) verifica_lag(cache) dec = scomposizione(cache) soglie = muro(cache, dec) rev = reversione(cache, soglie) leadlag(cache) gate(cache) rinforzo(cache) finally: save_cache(cache) print("\n" + "=" * 100) print(" VERDETTO") print("=" * 100) kall = max(rev, key=lambda k: rev[k]["mossa"] / rev[k]["muro"]) misurate = {k: v for k, v in rev.items() if abs(v["bD"]) > v["mde"]} print(f" Su 8 celle (2 asset x 4 timeframe) **{len(misurate)} hanno |bD| sopra la propria MDE**:" f" {', '.join(f'{k[0]} {k[1]}' for k in misurate) or 'nessuna'}.") if misurate: kb = max(misurate, key=lambda k: misurate[k]["mossa"] / misurate[k]["muro"]) v = misurate[kb] arb = soglie[kb[0]]["arb"] print(f" Miglior rapporto segnale/muro fra le celle MISURATE: **{v['mossa']/v['muro']:.3f}** " f"({kb[0]} {kb[1]}: {v['mossa']:.2f} bps attesi a 1 sd contro {v['muro']:.2f} bps di costo" f" su UN venue) -> manca un fattore **{v['muro']/v['mossa']:.1f}x**; per l'arbitraggio a" f" due venue ({arb:.1f} bps) manca **{arb/v['mossa']:.1f}x**.") va = rev[kall] print(f" Il massimo INCONDIZIONATO sarebbe {va['mossa']/va['muro']:.3f} ({kall[0]} {kall[1]}), ma" f" li' |bD|={abs(va['bD']):.2f} contro MDE {va['mde']:.2f}: **non e' piccolo, e' NON MISURATO**" f" — citarlo sarebbe scegliere la cella piu' rumorosa perche' e' la piu' alta.") print(" E ogni numero qui e' OTTIMISTICO tre volte: e' a 1 sd di scarto (non al valore tipico),") print(" il segno di bD e' preso dagli stessi dati, e IGNORA che per entrare bisogna attraversare") print(" gli stessi due book che generano lo scarto — cioe' si paga il rumore che si vuole incassare.") print("\n In EUR/giorno: ZERO. Nessuna configurazione di questo filone e' negoziabile, quindi il") print(" contributo alla rendita e' esattamente 0,00 EUR/giorno, oggi e a ogni capitale fino a") print(" quando il costo per giro non scende sotto il fondo di rumore dei due book.") print("=" * 100) if __name__ == "__main__": main()