8c0b5f97ee
I dodici filoni della sesta ondata erano rimasti `_in corso_`: la sessione si e' chiusa dopo che gli agenti avevano scritto gli script e prima che i verdetti fossero consolidati, e i loro messaggi finali sono persi. I numeri qui NON vengono da quei messaggi: vengono dalla riesecuzione dei dodici script (07:38-08:06 UTC, sequenziale, log in logs/r0823b/ che e' gitignored). E' il motivo per cui l'ondata non e' andata persa: ogni script calcola il proprio verdetto a runtime. Cosa tocca decisioni gia' prese: - §67 il gate pre-registrato XSR01 del 23/10 legge un monitor tarato sul pavimento del venue sbagliato (C* $15-20k, non ~$3k) - §64 la raccomandazione di §51 (raccogliere la catena USDC) non e' giustificata dalla ragione che porta: le due superfici sono la stessa - §60 la politica MISTO scelta il 25/07 non e' piu' l'ottimo (oggi MISTO-A) - §63 domanda fiscale NUOVA, diversa da quella aperta il 07/08 Il risultato piu' grande e' di §58: l'obiettivo del progetto ha DUE definizioni operative in uso che danno 33,9% contro 0,33% sulla stessa domanda, e non era mai stato detto quale si stesse ottimizzando. r0823b_quasi_passati.py (§68) terminava con IndexError: Griglia.combo() indicizzava con self.idx (2720 giorni) un sottoinsieme di 958 righe. Corretto col parametro idx esplicito piu' un controllo di lunghezza; la rinormalizzazione e' riga per riga, quindi i valori sono quelli dell'intento dell'autore. La correzione e' del coordinatore, non dell'autore, ed e' dichiarata nel registro. Libro, pesi, cron, config INVARIATI. Nessun ordine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
970 lines
53 KiB
Python
970 lines
53 KiB
Python
#!/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()
|