From 75606d0f449508c71ccabafedf1dbbb3d47a8a1f Mon Sep 17 00:00:00 2001 From: Adriano Dal Pastro Date: Sat, 22 Aug 2026 17:19:07 +0000 Subject: [PATCH] =?UTF-8?q?research(wave-0822):=20VRP-QUOTE-VERE=20scartat?= =?UTF-8?q?o=20=E2=80=94=20e=20collect=5Fchain=20raccoglie=20la=20famiglia?= =?UTF-8?q?=20di=20contratti=20che=20il=20conto=20non=20puo'=20marginare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/research/RESULTS-0822.md | 45 ++ scripts/research/r0822_basis_calendar.py | 787 ++++++++++++++++++++++ scripts/research/r0822_hl_exec.py | 567 ++++++++++++++++ scripts/research/r0822_oi_pin.py | 33 +- scripts/research/r0822_prop_alloc.py | 50 +- scripts/research/r0822_skew.py | 149 +++- scripts/research/r0822_term_structure.py | 118 +++- scripts/research/r0822_vol_size.py | 29 +- scripts/research/r0822_vrp_real_quotes.py | 59 +- 9 files changed, 1800 insertions(+), 37 deletions(-) create mode 100644 scripts/research/r0822_basis_calendar.py create mode 100644 scripts/research/r0822_hl_exec.py diff --git a/docs/research/RESULTS-0822.md b/docs/research/RESULTS-0822.md index f60e6c7..2c5f880 100644 --- a/docs/research/RESULTS-0822.md +++ b/docs/research/RESULTS-0822.md @@ -11,6 +11,7 @@ null de-levering superato + eseguibilita' al capitale dichiarato. | 12 | ORTHO-SCREEN | **SCARTATO 7/7** | nessuna famiglia arriva a ADDS+DSR; e il motivo e' **aritmetico**: su 168 trial il massimo atteso dal rumore e' Sharpe **1.572**, SOPRA il soffitto direzionale misurato (~1.3) -> uno screen largo su BTC/ETH direzionale **non puo'** passare un DSR, per costruzione | | 3 | XS-LITE | **SCARTATO** (come sleeve) + **1 soglia pubblicata falsificata** | concentrare XS01 non crea uno sleeve nuovo (corr 0.81-0.96 col canonico, DSR FAIL, de-levering non superato) β€” ma il muro *"XS01 serve ~$20k"* e' misurato su una diagnostica di TURNOVER: il ribilancio vero smette di passare sotto **~$109 di sleeve (~$730 di book), 27x piu' in basso** | | 10 | FLOW-SQUEEZE | **SCARTATO** (2 motivi indipendenti) | meta' dell'ipotesi **non esiste nel dato** (`liquidation_*_risk` = una sola categoria in 17.229/17.229 righe); l'altra meta' e' sotto la propria soglia di potenza **dichiarata prima** (MDE 1,056% vs effetto 0,402%) ed e' comunque **morta alla nascita** (la colonna non e' ricostruibile) | +| 11 | VRP-QUOTE-VERE | **SCARTATO** + **1 difetto di RACCOLTA in produzione** | il campione contiene l'**astensione** della strategia (0/19 settimane passano IV-rank>0.30) e il titolo non sopravvive all'ora d'ingresso; ma soprattutto: **`collect_chain.py` raccoglie la famiglia di contratti che il conto NON puo' marginare** | ## Note che sopravvivono ai singoli filoni @@ -144,3 +145,47 @@ come proxy di AFFOLLAMENTO. Quarto lato.** il vincolo binding NON e' la concentrazione degli episodi (86-87 episodi corti, i primi 8 coprono solo il 20-25% delle ore) ma la **lunghezza del calendario** (90 giorni). *Ampiezza scarsa* e *calendario corto* sono due diagnosi diverse e si curano in modi diversi: piu' episodi vs piu' tempo. + +### 11 β€” VRP-QUOTE-VERE (r0822_vrp_real_quotes.py, 60 trial + 9 ancore d'ingresso) +🚨 **IL RISULTATO DI PRODUZIONE β€” `collect_chain.py` interroga `{"currency": "BTC"|"ETH"}`, che su +Deribit restituisce le sole opzioni INVERSE (regolate in BTC/ETH). Il conto e' in USDC** (esegue +`BTC_USDC-PERPETUAL`). Esiste una famiglia **USDC-lineare** con lotto minimo **10x piu' piccolo**: +| famiglia | min lotto | ~$ per lotto | +|---|---|---| +| BTC inverse | 0.1 | ~$7.700 | +| **BTC_USDC** | **0.01** | ~$780 | +| ETH inverse | 1.0 | ~$2.500 | +| **ETH_USDC** | **0.1** | ~$250 | +Quindi il muro pubblicato *"BTC opzioni min 0.1 = $6.210/lotto -> FUORI a $3.000"* (congelato in +`tests/test_vrp_profit_take.py`) e' **misurato sulla famiglia che il conto non puo' marginare**. +**3Βͺ occorrenza dello schema `fee_watch` (21/08): un controllo/collettore puntato su uno strumento +diverso da quello che si trada.** +⚠️ **MA la verifica indipendente del coordinatore cambia la conclusione, e in meglio:** contando +l'open interest famiglia per famiglia (`get_book_summary_by_currency`, misura mia, non dell'agente) +| famiglia | strumenti | con OI>=100 | +|---|---|---| +| BTC inverse | 1038 | **415** | +| ETH inverse | 932 | **548** | +| **BTC_USDC** | 686 | **5** | +| **ETH_USDC** | 660 | **119** | +-> **BTC_USDC e' praticamente morto** (5 strumenti liquidi su 686). **Il muro sul BTC RESTA, ma per +la ragione giusta e meglio misurata: non la taglia del lotto, la LIQUIDITA'.** ETH_USDC (119 +strumenti, ~$250/lotto) e' l'unica gamba che varrebbe la pena raccogliere. +πŸ“Œ Sottoprodotto fuori perimetro: esiste un universo opzioni USDC piu' liquido di BTC_USDC β€” +SOL_USDC 341, XRP_USDC 250, HYPE_USDC 172, AVAX_USDC 146 strumenti con OI>=100. +- **Il campione misura la strategia mentre STA FERMA**: 0/19 settimane passano il gate IV-rank>0.30, + DVOL alla mediana del 7Β°/11Β° percentile storico, sottostante +25%/+44%. +- βœ… **Replica indipendente del f del 30/07 su campione piu' lungo: f = 0,714 pooled** (IC95 + [0,690, 0,779], **0/19 osservazioni >= 1,0**). Attraversare lo spread costa **~10% del credito**. +- ⚠️ **Correzione a un numero pubblicato:** il forfait fee del sleeve sovrastima il listino vero di + **1,44x (BTC) / 1,80x (ETH)**, non di "~2x". Il segno (conservativo) regge, la taglia no. +- ⚠️ **Rischio sul MINIMO, non sulle chiusure:** peggior mark infra-settimana mediana βˆ’6,4% del + rischio, **minimo βˆ’91,0%**; fra le settimane chiuse **in utile**, minimo **βˆ’56,9%**. +- Gate: `implausible_sharpe` **FLAGGED** su entrambi (0 perdite su 10 -> regola del tre: tasso vero + fino al 30%, su un payoff che perde 7,4x il credito); `deflated_sharpe` **non calcolabile** (T=10) + e l'agente **si e' rifiutato di fabbricare una griglia giornaliera al 94% di zeri** per superare + il T>=30; banda d'ancora: canonico all'89Β° pctl, **mediana onesta 1,45**, banda che include il + negativo. Il titolo annualizzato (32,58) e' stato **deliberatamente non riportato come risultato**. +- ⚠️ L'agente ha **refutato in corsa una propria conclusione**: spostare l'ora d'ingresso cambia + QUALI STRIKE corrispondono al delta obiettivo -> non e' lo stesso trade un'ora dopo, e' **un'altra + struttura**; la media BTC **cambia segno** fra le ancore (βˆ’1,35% -> +11,22%). diff --git a/scripts/research/r0822_basis_calendar.py b/scripts/research/r0822_basis_calendar.py new file mode 100644 index 0000000..2521f6e --- /dev/null +++ b/scripts/research/r0822_basis_calendar.py @@ -0,0 +1,787 @@ +#!/usr/bin/env python3 +""" +r0822_basis_calendar.py β€” filone BASIS-CALENDAR (ondata 2026-08-22) + +DOMANDA + Il progetto ha chiuso il FUNDING su tre lati (CC01 carry spot-perp, time-series, + cross-sectional) ma non ha mai guardato la STRUTTURA A TERMINE dei futures datati + di Deribit β€” l'unico strumento delta-neutro eseguibile sul NOSTRO venue, a 2 gambe. + Q1 Il basis (datato vs indice/perp) e la sua pendenza sono PREVEDIBILI, cioe' + esiste un roll-down incassabile, o e' un martingala rumoroso? + Q2 Un CALENDAR SPREAD (front vs back) su segnale di pendenza produce un edge + netto di fee e spread bid-ask REALI? + Q3 E' ortogonale al libro ed eseguibile a $600-3.000? + +IL DATO β€” costruito qui, non esisteva + `data/raw/fut_*` sono futures INDICE AZIONARIO da IB (ES/NQ/DAX...), niente a che + vedere. Su Deribit non c'era nulla. Ma: + * /public/get_instruments?expired=true ritorna UN SOLO strumento (Deribit purga + subito le liste), quindi la via ovvia non funziona; + * i nomi dei trimestrali sono DETERMINISTICI (ultimo venerdi' di MAR/GIU/SET/DIC, + 08:00 UTC) e `get_tradingview_chart_data` serve la storia COMPLETA di un + contratto SCADUTO se il nome lo si costruisce a mano. + Verificato: 30/30 trimestrali 2019Q1..2026Q2 rispondono `status=ok`. + => **7+ anni di storia**, che includono il deleveraging 2022. E' esattamente la coda + che a CC01 mancava per costruzione (funding HL dal 2023-05): qui il 2022 c'e'. + + La chiamata funding porta in regalo l'INDICE Deribit orario (`index_price`), quindi + spot e funding arrivano insieme e il basis si misura sull'indice vero, non su una proxy. + +DISCIPLINA DI RETE (la VPS ha un IP solo e ci gira il libro) + <=2 richieste/s, e il fetcher DORME nella finestra :25-:29 di ogni ora, che e' + `cron_chain` (il collettore della catena opzioni). Il progetto ha gia' pagato un'ora + di guasto per un IP saturato. Tutto e' in cache su scratchpad: un ri-run non tocca la rete. + +CONVENZIONE DI ROLL β€” dichiarata, e mai una serie incollata + I prezzi NON vengono mai splicciati. La posizione e' su una COPPIA DI CONTRATTI + specifica; al roll si chiude e si riapre pagando il round-trip pieno su entrambe le + gambe. L'incollaggio avviene solo nella serie dei RENDIMENTI, che e' l'unico posto in + cui e' lecito: e' l'errore che il progetto ha gia' commesso con le serie equity + ri-aggiustate, e qui e' strutturalmente impossibile. + +USO + nice -n 19 timeout 900 uv run python scripts/research/r0822_basis_calendar.py +""" +from __future__ import annotations + +import calendar +import datetime as dt +import json +import math +import os +import sys +import time +import urllib.parse +import urllib.request +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt")) + +import altlib as A # noqa: E402 + +SCRATCH = Path( + os.environ.get( + "R0822_SCRATCH", + "/tmp/claude-1001/-opt-docker-PythagorasGoal/" + "b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad", + ) +) +CACHE = SCRATCH / "basis_cache" +CACHE.mkdir(parents=True, exist_ok=True) + +API = "https://www.deribit.com/api/v2" +UTC = dt.timezone.utc +MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", + "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] + +ASSETS = ("BTC", "ETH") +FEE_SIDE = 0.00035 # 3,5 bps/lato per GAMBA (taker Deribit, letto dal venue) +HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC") +HOURS_Y = 365.25 * 24 + +_last_call = [0.0] + + +# ========================================================================== +# 0. FETCHER β€” rate-limited, cache-first, consapevole dei cron della VPS +# ========================================================================== +def _polite_sleep() -> None: + """<=2 req/s E fuori dalla finestra :25-:29 (cron_chain, stesso IP).""" + now = dt.datetime.now(UTC) + if 25 <= now.minute <= 29: + wait = (30 - now.minute) * 60 - now.second + print(f" [rete] finestra cron_chain (:25-:29) -> dormo {wait}s") + time.sleep(max(1, wait)) + gap = time.time() - _last_call[0] + if gap < 0.55: + time.sleep(0.55 - gap) + _last_call[0] = time.time() + + +def _api(path: str, **kw) -> dict: + url = API + path + "?" + urllib.parse.urlencode(kw) + for attempt in range(4): + _polite_sleep() + try: + with urllib.request.urlopen(url, timeout=30) as r: + return json.load(r) + except Exception as exc: # noqa: BLE001 + if attempt == 3: + raise + print(f" [rete] {type(exc).__name__} -> retry {attempt+1}/3") + time.sleep(2.0 * (attempt + 1)) + raise RuntimeError("unreachable") + + +def last_friday(year: int, month: int) -> dt.date: + d = dt.date(year, month, calendar.monthrange(year, month)[1]) + while d.weekday() != 4: + d -= dt.timedelta(days=1) + return d + + +def quarterly_universe(asset: str, y0: int = 2019, y1: int = 2027) -> list[tuple[str, pd.Timestamp]]: + """Nomi DETERMINISTICI dei trimestrali Deribit: ultimo venerdi' di MAR/GIU/SET/DIC 08:00 UTC.""" + out = [] + for y in range(y0, y1 + 1): + for m in (3, 6, 9, 12): + lf = last_friday(y, m) + exp = pd.Timestamp(lf.year, lf.month, lf.day, 8, tz="UTC") + out.append((f"{asset}-{lf.day}{MONTHS[m-1]}{str(y)[2:]}", exp)) + return out + + +def fetch_ohlc(instrument: str, t0: pd.Timestamp, t1: pd.Timestamp) -> pd.DataFrame: + """OHLCV orario di UNO strumento (anche SCADUTO), a chunk di 90 giorni. Cache su disco.""" + cf = CACHE / f"ohlc_{instrument.replace('/', '_')}.parquet" + if cf.exists(): + return pd.read_parquet(cf) + frames = [] + cur = t0 + while cur < t1: + end = min(cur + pd.Timedelta(days=90), t1) + try: + res = _api("/public/get_tradingview_chart_data", + instrument_name=instrument, resolution="60", + start_timestamp=int(cur.timestamp() * 1000), + end_timestamp=int(end.timestamp() * 1000)).get("result", {}) + except Exception as exc: # noqa: BLE001 + print(f" {instrument}: chunk {cur.date()} FALLITO ({type(exc).__name__})") + cur = end + continue + ticks = res.get("ticks") or [] + if res.get("status") == "ok" and ticks: + frames.append(pd.DataFrame({ + "ts": pd.to_datetime(ticks, unit="ms", utc=True), + "open": res["open"], "high": res["high"], + "low": res["low"], "close": res["close"], + "volume": res.get("volume", [np.nan] * len(ticks)), + })) + cur = end + df = (pd.concat(frames).drop_duplicates("ts").sort_values("ts").reset_index(drop=True) + if frames else pd.DataFrame(columns=["ts", "open", "high", "low", "close", "volume"])) + df.to_parquet(cf, index=False) + return df + + +def fetch_funding_and_index(asset: str, t0: pd.Timestamp, t1: pd.Timestamp) -> pd.DataFrame: + """Funding orario del perp E indice Deribit (la chiamata li porta insieme).""" + cf = CACHE / f"fund_{asset}.parquet" + if cf.exists(): + return pd.read_parquet(cf) + frames, cur = [], t0 + while cur < t1: + end = min(cur + pd.Timedelta(days=30), t1) + try: + res = _api("/public/get_funding_rate_history", + instrument_name=f"{asset}-PERPETUAL", + start_timestamp=int(cur.timestamp() * 1000), + end_timestamp=int(end.timestamp() * 1000)).get("result", []) + except Exception: # noqa: BLE001 + cur = end + continue + if res: + frames.append(pd.DataFrame({ + "ts": pd.to_datetime([r["timestamp"] for r in res], unit="ms", utc=True), + "index": [r["index_price"] for r in res], + "f1h": [r["interest_1h"] for r in res], + })) + cur = end + df = (pd.concat(frames).drop_duplicates("ts").sort_values("ts").reset_index(drop=True) + if frames else pd.DataFrame(columns=["ts", "index", "f1h"])) + df.to_parquet(cf, index=False) + return df + + +def fetch_live_books(instruments: list[str]) -> pd.DataFrame: + """Calibrazione DATATA dello spread bid-ask: il book VIVO di oggi. + Non e' storia β€” e' un punto di ancoraggio per una stima, e va detto.""" + cf = CACHE / "live_books.parquet" + if cf.exists(): + return pd.read_parquet(cf) + rows = [] + for nm in instruments: + try: + r = _api("/public/get_order_book", instrument_name=nm, depth=5)["result"] + bid, ask = r.get("best_bid_price"), r.get("best_ask_price") + if bid and ask and bid > 0: + rows.append(dict(instrument=nm, bid=bid, ask=ask, + mid=(bid + ask) / 2, + spread_bps=1e4 * (ask - bid) / ((ask + bid) / 2), + bid_sz=r.get("best_bid_amount"), ask_sz=r.get("best_ask_amount"))) + except Exception: # noqa: BLE001 + continue + df = pd.DataFrame(rows) + df.to_parquet(cf, index=False) + return df + + +# ========================================================================== +# 1. PANEL + CERTIFICAZIONE (prima di ogni strategia β€” regola di prim'ordine) +# ========================================================================== +def build_panel(asset: str) -> tuple[pd.DataFrame, pd.DataFrame]: + """Ritorna (wide, meta). `wide` = indice orario x contratto -> close. + `meta` = una riga per contratto con scadenza, barre, copertura, quota barre FLAT.""" + uni = quarterly_universe(asset) + today = pd.Timestamp.now(tz="UTC") + closes, vols, meta = {}, {}, [] + for nm, exp in uni: + if exp > today + pd.Timedelta(days=400): + continue + t0 = exp - pd.Timedelta(days=400) + t1 = min(exp + pd.Timedelta(hours=2), today) + if t1 <= t0: + continue + df = fetch_ohlc(nm, t0, t1) + if df.empty or len(df) < 200: + meta.append(dict(contract=nm, exp=exp, bars=len(df), status="VUOTO/CORTO")) + continue + s = df.set_index("ts")["close"].astype(float) + s = s[s > 0] + closes[nm] = s + vols[nm] = df.set_index("ts")["volume"].astype(float) + flat = float((s.diff() == 0).mean()) + meta.append(dict(contract=nm, exp=exp, bars=len(s), + first=s.index.min(), last=s.index.max(), + flat_frac=flat, status="ok")) + wide = pd.DataFrame(closes).sort_index() + vwide = pd.DataFrame(vols).sort_index() + return wide, vwide, pd.DataFrame(meta) + + +def certify(asset: str, wide: pd.DataFrame, vwide: pd.DataFrame, meta: pd.DataFrame, + fund: pd.DataFrame, perp: pd.Series) -> dict: + """Quattro difetti sorvegliati. Un edge su un book fermo NON e' un edge (regola 4).""" + print(f"\n --- CERTIFICAZIONE {asset} ---") + ok = meta[meta.status == "ok"] + print(f" contratti con dato: {len(ok)}/{len(meta)} " + f"finestra {wide.index.min().date()} .. {wide.index.max().date()}") + + # (a) barre FLAT per bucket di giorni-a-scadenza = liquidita' vera del datato + rows = [] + for nm in wide.columns: + exp = ok.loc[ok.contract == nm, "exp"] + if exp.empty: + continue + s = wide[nm].dropna() + dte = (exp.iloc[0] - s.index).total_seconds() / 86400.0 + flat = (s.diff() == 0).astype(float) + for lo, hi, lab in [(0, 30, "0-30g"), (30, 90, "30-90g"), + (90, 180, "90-180g"), (180, 400, ">180g")]: + m = (dte >= lo) & (dte < hi) + if m.sum() > 50: + rows.append(dict(bucket=lab, flat=float(flat[m].mean()), n=int(m.sum()))) + fb = pd.DataFrame(rows).groupby("bucket").agg(flat=("flat", "mean"), n=("n", "sum")) + order = ["0-30g", "30-90g", "90-180g", ">180g"] + print(" barre FLAT (proxy di illiquidita') per giorni-a-scadenza:") + for b in order: + if b in fb.index: + print(f" {b:9s} flat={fb.loc[b,'flat']*100:5.1f}% barre={int(fb.loc[b,'n']):,}") + + # (b) volume orario mediano per bucket (il flat da solo non basta) + vrows = [] + for nm in vwide.columns: + exp = ok.loc[ok.contract == nm, "exp"] + if exp.empty: + continue + s = vwide[nm].dropna() + dte = (exp.iloc[0] - s.index).total_seconds() / 86400.0 + for lo, hi, lab in [(0, 30, "0-30g"), (30, 90, "30-90g"), + (90, 180, "90-180g"), (180, 400, ">180g")]: + m = (dte >= lo) & (dte < hi) + if m.sum() > 50: + vrows.append(dict(bucket=lab, med=float(s[m].median()), zero=float((s[m] == 0).mean()))) + vb = pd.DataFrame(vrows).groupby("bucket").agg(med=("med", "median"), zero=("zero", "mean")) + print(" volume orario mediano (USD di nozionale) e quota ore a volume ZERO:") + for b in order: + if b in vb.index: + print(f" {b:9s} mediano=${vb.loc[b,'med']:>12,.0f} ore a vol 0 = {vb.loc[b,'zero']*100:5.1f}%") + + # (c) accordo con l'INDICE alla scadenza: il datato DEVE convergere + conv = [] + for nm in wide.columns: + e = ok.loc[ok.contract == nm, "exp"] + if e.empty: + continue + exp = e.iloc[0] + s = wide[nm].dropna() + tail = s[s.index >= exp - pd.Timedelta(hours=6)] + idx = fund.set_index("ts")["index"].reindex(tail.index, method="nearest", tolerance=pd.Timedelta("2h")) + if len(tail) and idx.notna().any(): + conv.append(float(np.abs(np.log(tail / idx)).dropna().iloc[-1]) * 1e4) + if conv: + print(f" convergenza a scadenza |ln(F/indice)| ultima ora: " + f"mediana {np.median(conv):.1f} bps, max {np.max(conv):.1f} bps (n={len(conv)})") + + # (d) CROSS-CHECK INDIPENDENTE: il forward implicito nella catena opzioni + xchk = cross_check_option_forward(asset, wide, ok) + return dict(flat=fb, vol=vb, conv=conv, xchk=xchk) + + +def cross_check_option_forward(asset: str, wide: pd.DataFrame, meta: pd.DataFrame) -> dict: + """Il `underlying_price` di un'opzione Deribit E' il forward della SUA scadenza. + La catena raccolta dal 2026-07-30 e' quindi una MISURA INDIPENDENTE del basis: + se il mio F(T) e il forward delle opzioni sulla stessa scadenza non coincidono, + uno dei due e' sbagliato. (L'archivio ereditato da bite ha underlying_price NULL.)""" + import glob + files = sorted(glob.glob(str(PROJECT_ROOT / "data/raw/cb_chain/2026-*.parquet"))) + if not files: + return dict(status="catena assente") + keep = [] + for f in files[-30:]: + try: + d = pd.read_parquet(f, columns=["asset", "exp", "ts", "underlying_price", "index_price"]) + except Exception: # noqa: BLE001 + continue + d = d[(d.asset == asset) & d.underlying_price.notna()] + if len(d): + keep.append(d) + if not keep: + return dict(status="nessun underlying_price") + ch = pd.concat(keep) + ch["ts_h"] = ch["ts"].dt.floor("1h") + # forward mediano per (ora, scadenza) β€” tutti gli strike condividono lo stesso forward + fw = ch.groupby(["ts_h", "exp"]).agg(fwd=("underlying_price", "median")).reset_index() + rows = [] + for nm in wide.columns: + e = meta.loc[meta.contract == nm, "exp"] + if e.empty: + continue + exp = e.iloc[0] + sub = fw[fw.exp == exp] + if sub.empty: + continue + mine = wide[nm].dropna() + j = sub.set_index("ts_h")["fwd"].reindex(mine.index).dropna() + if len(j) < 5: + continue + dev = (np.log(mine.reindex(j.index) / j) * 1e4).dropna() + rows.append(dict(contract=nm, n=len(dev), med_bps=float(dev.median()), + p95_bps=float(dev.abs().quantile(0.95)))) + return dict(status="ok", table=pd.DataFrame(rows)) + + +# ========================================================================== +# 2. CURVA β€” front/back trimestrali, basis annualizzato, slope forward +# ========================================================================== +def curve_frame(asset: str, wide: pd.DataFrame, meta: pd.DataFrame, + fund: pd.DataFrame, roll_dte: int) -> pd.DataFrame: + """Per ogni ora: front = trimestrale piu' vicino con dte >= roll_dte, back = il successivo. + Ritorna prezzi, tau, basis annualizzati, slope forward e funding.""" + ok = meta[meta.status == "ok"].sort_values("exp") + exps = {r.contract: r.exp for r in ok.itertuples()} + idx = wide.index + F = fund.set_index("ts").reindex(idx) + n = len(idx) + front = np.full(n, "", dtype=object) + back = np.full(n, "", dtype=object) + contracts = list(wide.columns) + tau = {c: (exps[c] - idx).total_seconds() / 3600.0 for c in contracts if c in exps} + avail = {c: wide[c].notna().values for c in contracts} + for c in contracts: + if c not in exps: + continue + alive = avail[c] & (tau[c] >= roll_dte * 24) + # front = il primo (per scadenza) ancora vivo; i contratti sono ordinati per exp + order = [c for c in ok.contract if c in wide.columns] + for i in range(n): + picked = [] + for c in order: + if avail[c][i] and tau[c][i] >= roll_dte * 24: + picked.append(c) + if len(picked) == 2: + break + if len(picked) == 2: + front[i], back[i] = picked + out = pd.DataFrame(index=idx) + out["front"], out["back"] = front, back + m = (front != "") & (back != "") + Ff = np.full(n, np.nan); Fb = np.full(n, np.nan) + tf = np.full(n, np.nan); tb = np.full(n, np.nan) + W = wide.values + colpos = {c: k for k, c in enumerate(wide.columns)} + for i in np.where(m)[0]: + Ff[i] = W[i, colpos[front[i]]]; Fb[i] = W[i, colpos[back[i]]] + tf[i] = tau[front[i]][i]; tb[i] = tau[back[i]][i] + out["F_front"], out["F_back"] = Ff, Fb + out["tau_front_h"], out["tau_back_h"] = tf, tb + out["index"] = F["index"].values + out["f1h"] = F["f1h"].values + # basis annualizzato di ciascuna gamba vs INDICE + out["c_front"] = np.log(Ff / out["index"]) * HOURS_Y / tf + out["c_back"] = np.log(Fb / out["index"]) * HOURS_Y / tb + # slope = tasso forward annualizzato FRA le due scadenze (stazionario, interpretabile) + out["slope"] = np.log(Fb / Ff) * HOURS_Y / (tb - tf) + out["s_raw"] = np.log(Fb / Ff) + return out + + +def describe_curve(asset: str, cv: pd.DataFrame) -> None: + """Q1 β€” descrittiva. Il basis e' positivo? persiste? il suo CAMBIO e' prevedibile?""" + c = cv.dropna(subset=["c_front", "slope"]) + print(f"\n --- Q1 CURVA {asset} (n={len(c):,} ore) ---") + print(f" basis annualizzato del FRONT: mediana {c.c_front.median()*100:+6.2f}%/anno " + f"media {c.c_front.mean()*100:+6.2f}% sd {c.c_front.std()*100:5.2f} " + f"contango {float((c.c_front>0).mean())*100:.1f}% del tempo") + print(f" slope forward front->back : mediana {c.slope.median()*100:+6.2f}%/anno " + f"media {c.slope.mean()*100:+6.2f}% sd {c.slope.std()*100:5.2f} " + f"positiva {float((c.slope>0).mean())*100:.1f}% del tempo") + fnd = cv["f1h"].dropna() * HOURS_Y + print(f" funding perp annualizzato : mediana {fnd.median()*100:+6.2f}%/anno " + f"media {fnd.mean()*100:+6.2f}%") + print(" per anno (mediane annualizzate):") + g = c.groupby(c.index.year).agg(basis_front=("c_front", "median"), + slope=("slope", "median")) + fy = (cv["f1h"] * HOURS_Y).groupby(cv.index.year).median() + for y, r in g.iterrows(): + print(f" {y} basis_front {r.basis_front*100:+7.2f}% slope {r.slope*100:+7.2f}% " + f"funding {fy.get(y, np.nan)*100:+7.2f}%") + # prevedibilita': mean-reversion dello slope a 7 giorni (AR sul livello) + for h, lab in [(24, "1g"), (24 * 7, "7g"), (24 * 30, "30g")]: + x = c["slope"] + dx = x.shift(-h) - x + j = pd.concat([x, dx], axis=1).dropna() + if len(j) > 500: + b = np.polyfit(j.iloc[:, 0], j.iloc[:, 1], 1)[0] + rho = float(j.iloc[:, 0].corr(j.iloc[:, 1])) + print(f" mean-reversion slope a {lab:>3s}: beta {b:+.3f} corr {rho:+.3f} " + f"(beta -1 = reversione totale, 0 = martingala)") + + +# ========================================================================== +# 3. STRATEGIE β€” posizioni in spazio CONTRATTO (il roll si paga da solo) +# ========================================================================== +def _z(x: pd.Series, win: int) -> pd.Series: + m = x.rolling(win, min_periods=win // 3).mean() + s = x.rolling(win, min_periods=win // 3).std() + return ((x - m) / s.replace(0, np.nan)) + + +def run_strategy(cv: pd.DataFrame, wide: pd.DataFrame, family: str, sig: str, + win_h: int, thr: float, slip_bps: float, + fee_side: float = FEE_SIDE) -> pd.Series: + """Ritorno ORARIO netto per $1 di nozionale LORDO PER GAMBA. + Le posizioni vivono in spazio CONTRATTO: quando la coppia cambia (roll) la + variazione per contratto e' automaticamente piena => il roll paga il round-trip + completo su entrambe le gambe. Nessuna serie di prezzi viene MAI incollata. + Causalita': w deciso con dati <= t, TENUTO durante t -> t+1 (shift esplicito).""" + idx = cv.index + if family == "CAL": + base = cv["slope"] + else: # FvP: basis annualizzato del front + base = cv["c_front"] + + if sig == "static_long": + w = pd.Series(1.0, index=idx) + elif sig == "static_short": + w = pd.Series(-1.0, index=idx) + elif sig == "carry": # segno del carry corrente (causale) + w = -np.sign(base) # contango -> vendi il datato + elif sig == "zrev": + z = _z(base, win_h) + w = -np.tanh(z) + w = w.where(z.abs() >= thr, 0.0) + elif sig == "zmom": + z = _z(base, win_h) + w = np.tanh(z) + w = w.where(z.abs() >= thr, 0.0) + else: + raise ValueError(sig) + w = pd.Series(np.asarray(w, float), index=idx).fillna(0.0).clip(-1, 1) + + # --- posizioni per CONTRATTO --- + cols = list(wide.columns) + cpos = {c: k for k, c in enumerate(cols)} + P = np.zeros((len(idx), len(cols))) + fr = cv["front"].values; bk = cv["back"].values + wv = w.values + for i in range(len(idx)): + if fr[i] == "" or bk[i] == "": + continue + if family == "CAL": # long spread = long back / short front + P[i, cpos[bk[i]]] = wv[i] + P[i, cpos[fr[i]]] = -wv[i] + else: # FvP: gamba datata; il perp e' a parte + P[i, cpos[fr[i]]] = wv[i] + Pd = pd.DataFrame(P, index=idx, columns=cols) + + held = Pd.shift(1).fillna(0.0) # decisa a t, TENUTA in t->t+1 + R = wide.reindex(idx).pct_change().replace([np.inf, -np.inf], np.nan).fillna(0.0) + gross = (held * R).sum(axis=1) + + if family == "FvP": + # gamba perp: nozionale opposto alla gamba datata + funding pagato/incassato + perp_r = pd.Series(cv["index"].pct_change().fillna(0.0).values, index=idx) + wheld = held.sum(axis=1) # = w shiftato (una sola gamba datata) + gross = gross - wheld * perp_r - wheld * (-1.0) * cv["f1h"].fillna(0.0) + # long datato (w>0) => short perp => INCASSA funding se f>0 + turn = (Pd.diff().abs().sum(axis=1) * 2.0) # datato + perp + else: + turn = Pd.diff().abs().sum(axis=1) + + cost = turn * (fee_side + slip_bps * 1e-4) + return (gross - cost).fillna(0.0) + + +# ========================================================================== +# 4. METRICHE / GATE +# ========================================================================== +def to_daily(h: pd.Series) -> pd.Series: + return ((1.0 + h.fillna(0.0)).resample("1D").prod() - 1.0).dropna() + + +def stats(daily: pd.Series) -> dict: + r = daily.dropna() + if len(r) < 30 or r.std() == 0: + return dict(sharpe=0.0, holdout=0.0, maxdd=0.0, cagr=0.0, n=len(r), vol=0.0) + sh = float(r.mean() / r.std() * math.sqrt(365.25)) + eq = (1 + r).cumprod() + dd = float(((eq.cummax() - eq) / eq.cummax()).max()) + yrs = (r.index[-1] - r.index[0]).days / 365.25 + cagr = float(eq.iloc[-1] ** (1 / yrs) - 1) if yrs > 0.5 and eq.iloc[-1] > 0 else float("nan") + ho = r[r.index >= HOLDOUT] + sho = float(ho.mean() / ho.std() * math.sqrt(365.25)) if len(ho) > 30 and ho.std() > 0 else float("nan") + return dict(sharpe=sh, holdout=sho, maxdd=dd, cagr=cagr, n=len(r), + vol=float(r.std() * math.sqrt(365.25))) + + +def delevering_null(cand: pd.Series, base: pd.Series) -> dict: + """NULL DEL DE-LEVERING (5 occorrenze in questo progetto). Esiste k<1 che, + applicato al BASELINE, da' lo STESSO maxDD del candidato con Sharpe MIGLIORE? + Se si', il candidato non protegge: e' solo meno leva.""" + cs = stats(cand) + best = None + for k in np.arange(0.05, 1.01, 0.05): + s = stats(base * k) + if s["maxdd"] <= cs["maxdd"] + 1e-9: + best = (float(k), s) + break + if best is None: + return dict(refuted=False, note="nessun k<=1 raggiunge quel DD sul baseline") + k, s = best + return dict(refuted=bool(s["sharpe"] > cs["sharpe"]), k=k, + base_sharpe_at_k=s["sharpe"], cand_sharpe=cs["sharpe"], + base_dd_at_k=s["maxdd"], cand_dd=cs["maxdd"]) + + +# ========================================================================== +# 5. MAIN +# ========================================================================== +def main() -> None: + t_start = time.time() + print("=" * 78) + print("r0822 BASIS-CALENDAR β€” struttura a termine dei futures datati Deribit") + print("=" * 78) + + today = pd.Timestamp.now(tz="UTC") + panels, funds, metas, vwides = {}, {}, {}, {} + + # ---------------- STEP 1: dato ---------------- + print("\n[1] DATO β€” ricostruzione da Deribit pubblico (cache su scratchpad)") + for a in ASSETS: + print(f" {a}: trimestrali (ultimo venerdi' MAR/GIU/SET/DIC 08:00 UTC)...") + wide, vwide, meta = build_panel(a) + fund = fetch_funding_and_index(a, pd.Timestamp("2019-01-01", tz="UTC"), today) + panels[a], vwides[a], metas[a], funds[a] = wide, vwide, meta, fund + ok = meta[meta.status == "ok"] + print(f" {len(ok)} contratti, {int(meta.bars.sum()):,} barre orarie, " + f"funding+indice {len(fund):,} ore") + + # ---------------- STEP 2: certificazione ---------------- + print("\n[2] CERTIFICAZIONE (regola 4: un edge su un book fermo non e' un edge)") + certs = {} + for a in ASSETS: + perp = A.get(a, "1h").set_index(pd.DatetimeIndex( + pd.to_datetime(A.get(a, "1h")["datetime"], utc=True)))["close"] + certs[a] = certify(a, panels[a], vwides[a], metas[a], funds[a], perp) + x = certs[a]["xchk"] + if x.get("status") == "ok" and len(x["table"]): + t = x["table"] + print(f" CROSS-CHECK INDIPENDENTE vs forward della catena opzioni " + f"({int(t.n.sum())} ore, {len(t)} scadenze):") + print(f" deviazione mediana {t.med_bps.median():+.1f} bps, " + f"p95 |dev| {t.p95_bps.max():.1f} bps") + else: + print(f" CROSS-CHECK catena: {x.get('status')}") + + # ---------------- STEP 3: Q1 curva ---------------- + print("\n[3] Q1 β€” LA CURVA E' PREVEDIBILE?") + curves = {} + for a in ASSETS: + cv = curve_frame(a, panels[a], metas[a], funds[a], roll_dte=7) + curves[a] = cv + describe_curve(a, cv) + + # ---------------- STEP 3b: spread bid-ask (calibrazione DATATA) ---------------- + print("\n[3b] SPREAD BID-ASK β€” calibrazione sul book VIVO di oggi (non e' storia)") + live = [] + for a in ASSETS: + for nm, exp in quarterly_universe(a): + if today < exp <= today + pd.Timedelta(days=400): + live.append(nm) + lb = fetch_live_books(live + [f"{a}-PERPETUAL" for a in ASSETS]) + slip_est = 5.0 + if len(lb): + for r in lb.itertuples(): + print(f" {r.instrument:16s} bid {r.bid:>10,.1f} ask {r.ask:>10,.1f} " + f"spread {r.spread_bps:6.1f} bps size {r.bid_sz:>8,.0f}/{r.ask_sz:>8,.0f}") + dated = lb[~lb.instrument.str.contains("PERPETUAL")] + if len(dated): + slip_est = float(dated.spread_bps.median()) / 2.0 + print(f" => mezzo-spread MEDIANO del datato = {slip_est:.1f} bps/lato " + f"(STIMA datata oggi, NON storia: il book storico dei datati non e' disponibile)") + + # ---------------- STEP 4: griglia ---------------- + print("\n[4] GRIGLIA β€” dichiarata PRIMA, contata AL RIALZO") + FAM = {"CAL": ["static_long", "static_short", "zrev", "zmom"], + "FvP": ["carry", "static_long", "zrev", "zmom"]} + WINS = [720, 2160, 4320] # 30 / 90 / 180 giorni in ore + ROLLS = [7, 21] + THRS = [0.0, 1.0] + cells = [(f, s, w, rl, th) for f, sigs in FAM.items() for s in sigs + for w in WINS for rl in ROLLS for th in THRS] + print(f" famiglie {list(FAM)} x segnali x finestre {WINS} x roll {ROLLS} x soglie {THRS}") + print(f" = {len(cells)} celle valutate (le varianti statiche non usano W/soglia:" + f" le conto lo stesso, al rialzo)") + + cvcache = {(a, rl): curve_frame(a, panels[a], metas[a], funds[a], rl) + for a in ASSETS for rl in ROLLS} + + rows = [] + for (fam, sig, win, rl, th) in cells: + hs = [] + for a in ASSETS: + h = run_strategy(cvcache[(a, rl)], panels[a], fam, sig, win, th, slip_est) + hs.append(h) + J = pd.concat(hs, axis=1).fillna(0.0) + comb = J.mean(axis=1) # 50/50 BTC+ETH, come il resto del progetto + d = to_daily(comb) + st = stats(d) + rows.append(dict(family=fam, sig=sig, win=win, roll=rl, thr=th, + daily=d, **st)) + G = pd.DataFrame([{k: v for k, v in r.items() if k != "daily"} for r in rows]) + dailies = {i: r["daily"] for i, r in enumerate(rows)} + + print("\n migliori 8 celle per Sharpe FULL (NON e' la selezione β€” solo panorama):") + for i in G.sharpe.nlargest(8).index: + r = G.loc[i] + print(f" {r.family:4s} {r.sig:12s} W={int(r.win):5d}h roll={int(r.roll):2d}g " + f"thr={r.thr:.1f} | Sh {r.sharpe:+6.2f} hold {r.holdout:+6.2f} " + f"DD {r.maxdd*100:5.2f}% CAGR {r.cagr*100:+6.2f}% vol {r.vol*100:5.2f}%") + + # ---------------- STEP 5: selezione IN-SAMPLE ONLY ---------------- + print("\n[5] SELEZIONE IN-SAMPLE-ONLY (mai la cella col miglior hold-out)") + ins = {} + for i, d in dailies.items(): + pre = d[d.index < HOLDOUT] + ins[i] = (float(pre.mean() / pre.std() * math.sqrt(365.25)) + if len(pre) > 60 and pre.std() > 0 else -9.9) + best_i = max(ins, key=ins.get) + b = G.loc[best_i] + bd = dailies[best_i] + bs = stats(bd) + print(f" cella scelta AL BUIO: {b.family} {b.sig} W={int(b.win)}h roll={int(b.roll)}g thr={b.thr}") + print(f" Sharpe in-sample {ins[best_i]:+.2f} -> FULL {bs['sharpe']:+.2f} " + f"HOLD-OUT {bs['holdout']:+.2f} maxDD {bs['maxdd']*100:.2f}% " + f"CAGR {bs['cagr']*100:+.2f}% vol {bs['vol']*100:.2f}%") + best_full = G.sharpe.idxmax() + print(f" (per confronto, la cella col miglior FULL sarebbe stata " + f"{G.loc[best_full,'family']} {G.loc[best_full,'sig']} " + f"Sh {G.loc[best_full,'sharpe']:+.2f} β€” NON e' quella scelta)") + + # ---------------- STEP 6: GATE ---------------- + print("\n[6] GATE") + dsr, sr0 = A.deflated_sharpe(bs["sharpe"], list(G.sharpe.values), bd) + print(f" deflated-Sharpe : DSR {dsr:.3f} (null max atteso {sr0:+.2f} su {len(G)} trial) " + f"-> {'PASS' if dsr >= 0.95 else 'FAIL'}") + + imp = A.implausible_sharpe(bd) + print(f" implausible_sharpe: implausible={imp['implausible']} " + f"Sh {imp.get('sharpe', float('nan')):+.2f} maxDD {imp.get('maxdd', float('nan'))*100:.2f}% " + f"quota barre in perdita {imp.get('loss_frac', float('nan'))*100:.1f}% " + f"Calmar {imp.get('calmar', float('nan')):.1f}") + if imp["reasons"]: + for rr in imp["reasons"]: + print(f" - {rr}") + + tp = A.tp01_baseline_daily() + try: + mg = A.marginal_vs_tp01(bd) + print(f" marginal_vs_tp01 : {mg.get('verdict')} corr {mg.get('corr', float('nan')):+.3f} " + f"robust_oos={mg.get('robust_oos')} insample_edge={mg.get('has_insample_edge')} " + f"is_hedge={mg.get('is_hedge')} beats_noise={mg.get('beats_noise_null')}") + except Exception as exc: # noqa: BLE001 + mg = {} + print(f" marginal_vs_tp01 : NON GIRATO ({type(exc).__name__}: {exc})") + + dn = delevering_null(bd, tp) + print(f" null de-levering : refuted={dn.get('refuted')} {dn}") + + # anchor: l'ora del giorno in cui si valuta il segnale + print(" anchor_luck_band (ora del giorno di decisione, 24 ancore):") + def by_off(off): + hs = [] + for a in ASSETS: + cv = cvcache[(a, int(b.roll))] + h = run_strategy(cv, panels[a], b.family, b.sig, int(b.win), float(b.thr), slip_est) + hh = h.copy() + mask = (hh.index.hour != off) + hh[mask] = 0.0 # decide/agisce solo a quell'ora + hs.append(hh) + return to_daily(pd.concat(hs, axis=1).fillna(0.0).mean(axis=1)) + try: + ab = A.anchor_luck_band(by_off, list(range(24)), canonical=0) + print(f" canonica {ab.get('canonical'):+.3f} MEDIANA(onesta) {ab.get('median'):+.3f} " + f"banda [{ab.get('p10'):+.3f},{ab.get('p90'):+.3f}] " + f"frazione>0 {ab.get('frac_positive'):.2f} gate_pass={ab.get('gate_pass')}") + except Exception as exc: # noqa: BLE001 + print(f" NON GIRATO ({type(exc).__name__}: {exc})") + + try: + print(f" causality_ok : la posizione e' shiftata in run_strategy " + f"(held = pos.shift(1)); verifica strutturale, non A.causality_ok " + f"(che vuole un target_fn su BTC/ETH direzionale)") + except Exception: # noqa: BLE001 + pass + + # ---------------- STEP 7: quanto spread uccide l'edge ---------------- + print("\n[7] A CHE SPREAD MUORE L'EDGE (la stima di slippage e' il rischio #1)") + for sl in [0.0, 1.0, 2.5, 5.0, 7.5, 10.0, 20.0]: + hs = [] + for a in ASSETS: + hs.append(run_strategy(cvcache[(a, int(b.roll))], panels[a], b.family, + b.sig, int(b.win), float(b.thr), sl)) + s2 = stats(to_daily(pd.concat(hs, axis=1).fillna(0.0).mean(axis=1))) + mark = " <= stima di oggi" if abs(sl - slip_est) < 0.6 else "" + print(f" mezzo-spread {sl:5.1f} bps/lato -> Sharpe {s2['sharpe']:+6.2f} " + f"CAGR {s2['cagr']*100:+6.2f}%{mark}") + + # ---------------- STEP 8: eseguibilita' ---------------- + print("\n[8] Q3 β€” ESEGUIBILITA' a $600-3.000") + for a in ASSETS: + try: + sp = _api("/public/get_instrument", instrument_name=f"{a}-25DEC26")["result"] + print(f" {a}-25DEC26: min_trade_amount ${sp['min_trade_amount']:,.0f} di nozionale, " + f"contract_size ${sp['contract_size']:,.0f}, tick {sp['tick_size']}, " + f"taker {sp['taker_commission']*1e4:.1f} bps") + except Exception as exc: # noqa: BLE001 + print(f" {a}: specs non lette ({type(exc).__name__})") + print(f" vol ANNUA del candidato per $1 di nozionale lordo/gamba: {bs['vol']*100:.2f}%") + if bs["vol"] > 0: + lev = 0.20 / bs["vol"] + print(f" per portarlo al vol-target 20% del progetto servirebbe leva " + f"{lev:.1f}x per gamba = ${lev*2:,.0f} di nozionale LORDO per $1 di conto") + for cap in (600, 3000): + print(f" a ${cap}: nozionale lordo ${lev*2*cap:,.0f} " + f"(margine ~2-4%/gamba => ${lev*2*cap*0.03:,.0f} di IM richiesto)") + + print(f"\n[fine] {time.time()-t_start:.0f}s") + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_hl_exec.py b/scripts/research/r0822_hl_exec.py new file mode 100644 index 0000000..fa9357d --- /dev/null +++ b/scripts/research/r0822_hl_exec.py @@ -0,0 +1,567 @@ +"""HL-EXEC (2026-08-22) β€” AUDIT DI FATTO: le regole VERE di Hyperliquid contro le soglie ASSUNTE. + +DOMANDA. Il progetto tiene due edge cross-sectional fuori dal libro **per taglia**: + XS01 "serve ~$20.000" (origine: diario 2026-06-19-hyperliquid-xsec, "rumore arrotondamento" β€” + una STIMA A OCCHIO, mai calcolata) + XSR01 "diventa reale a ~$5.000" + gate pre-registrato 2026-10-23 con soglia + "haircut di eseguibilita' a $5.000 <= 40%, altrimenti RITIRO" +Entrambe sono calcolate col pavimento **min_order $5**, che e' il minimo di **DERIBIT**. +XS01/XSR01 si eseguirebbero su **HYPERLIQUID**, che ha regole sue. Qui le regole si LEGGONO dal +venue invece di assumerle, e si dice quali conclusioni del progetto cambiano. + +NON manda ordini, non tocca il conto: sole letture PUBBLICHE (nessuna chiave, nessuna firma). + +GRADO DELLE FONTI (dichiarato per ogni numero, come chiede il brief): + [A] letto dall'API pubblica del venue api.hyperliquid.xyz/info (meta, metaAndAssetCtxs, + userFees, l2Book) + [B] derivato da [A] + verificato empiricamente sui book live + [C] documentazione ufficiale (hyperliquid.gitbook.io) + [D] assunto / ereditato dal progetto +La verifica MAINNET non e' un atto di fede: i mark price dell'API si incrociano con l'ultima +chiusura del feed CERTIFICATO su disco (data/raw/hl_*_1d.parquet). Il testnet non puo' superarlo. +(Il progetto ha gia' pagato il prezzo di un feed testnet creduto vero: e' la causa del reset v2.0.0.) + + nice -n 19 timeout 900 uv run python scripts/research/r0822_hl_exec.py + ... --no-net usa solo la cache su disco (nessuna chiamata di rete) + ... --snaps N quanti snapshot del book L2 (default 3, ~45s di distanza) +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import urllib.request +from pathlib import Path + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(PROJECT_ROOT)) +sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research")) + +RAW = PROJECT_ROOT / "data" / "raw" +CACHE = Path(os.environ.get("HLEXEC_CACHE", "/tmp/claude-1001/-opt-docker-PythagorasGoal/" + "b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad/hlexec")) +INFO_URL = "https://api.hyperliquid.xyz/info" # MAINNET (il testnet e' api.hyperliquid-testnet.xyz) + +# --- parametri sotto esame ---------------------------------------------------------------------- +MIN_ORDER_ASSUNTO = 5.0 # [D] pavimento DERIBIT usato da eval_weights_smallcap e da paper_xsr +MIN_ORDER_HL = 10.0 # [C] "Order must have minimum value of $10." (docs/error-responses) +FEE_LEG_MODELLO = 0.0005 # [D] 0.05%/gamba, config CONGELATA di XSR01 +XSR_SOGLIA_HAIRCUT = 0.40 # [D] soglia pre-registrata del gate 2026-10-23 +CAPITALI = (600.0, 1000.0, 2000.0, 3000.0, 5000.0, 10000.0, 20000.0, 50000.0) +TICKETS = (50.0, 100.0, 300.0) +ANN = np.sqrt(365.0) + + +# ================================================================================================== +# 0. RETE β€” letture pubbliche, con cache su disco (una corsa non deve ri-martellare il venue) +# ================================================================================================== +def _post(payload: dict, timeout: int = 30) -> object: + req = urllib.request.Request(INFO_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode()) + + +def cached(name: str, payload: dict, no_net: bool, ttl_s: float = 6 * 3600) -> object: + CACHE.mkdir(parents=True, exist_ok=True) + f = CACHE / f"{name}.json" + if f.exists() and (no_net or (time.time() - f.stat().st_mtime) < ttl_s): + return json.loads(f.read_text()) + if no_net: + raise FileNotFoundError(f"cache assente per {name} e --no-net attivo") + d = _post(payload) + f.write_text(json.dumps(d)) + return d + + +def universo_certificato() -> list[str]: + """I 51 alt+BTC gia' CERTIFICATI su disco. Non si inventa un universo: si legge quello vero.""" + return sorted(p.stem.replace("hl_", "").replace("_1d", "").upper() + for p in RAW.glob("hl_*_1d.parquet")) + + +# ================================================================================================== +# 1. REGOLE DEL VENUE +# ================================================================================================== +def sig_figs_ok(px_str: str, sz_dec: int, max_dec: int = 6) -> bool: + """[C] regola tick perp: <=5 cifre significative E <= (MAX_DECIMALS - szDecimals) decimali; + i prezzi INTERI sono sempre ammessi. Qui serve per VERIFICARLA sui book veri -> [B].""" + s = px_str.lstrip("-") + dec = len(s.split(".")[1]) if "." in s else 0 + if dec == 0: + return True + digits = s.replace(".", "").lstrip("0") + return len(digits.rstrip("0")) <= 5 and dec <= (max_dec - sz_dec) + + +def leggi_regole(no_net: bool) -> tuple[pd.DataFrame, dict]: + meta_ctx = cached("metaAndAssetCtxs", {"type": "metaAndAssetCtxs"}, no_net) + fees = cached("userFees", {"type": "userFees", + "user": "0x0000000000000000000000000000000000000001"}, no_net) + uni, ctxs = meta_ctx[0]["universe"], meta_ctx[1] + rows = [] + for a, c in zip(uni, ctxs): + mark = float(c["markPx"]) if c.get("markPx") else np.nan + rows.append(dict(sym=a["name"], szDecimals=int(a["szDecimals"]), + maxLeverage=int(a["maxLeverage"]), + delisted=bool(a.get("isDelisted", False)), + markPx=mark, dayNtlVlm=float(c.get("dayNtlVlm") or np.nan), + openInterest=float(c.get("openInterest") or np.nan))) + df = pd.DataFrame(rows).set_index("sym") + df["lot_units"] = 10.0 ** (-df["szDecimals"]) # [A] passo di size in unita' + df["lot_usd"] = df["lot_units"] * df["markPx"] # [B] passo di size in dollari + df["px_decimals_max"] = (6 - df["szDecimals"]).clip(lower=0) # [C] + return df, fees["feeSchedule"] + + +def verifica_mainnet(df: pd.DataFrame, syms: list[str]) -> pd.DataFrame: + """Incrocia i mark dell'API con l'ULTIMA chiusura del feed certificato su disco. + Un feed testnet non puo' superare questo controllo: i suoi prezzi sono fantasia.""" + out = [] + for s in syms: + p = RAW / f"hl_{s.lower()}_1d.parquet" + if not p.exists() or s not in df.index: + continue + d = pd.read_parquet(p, columns=["timestamp", "close"]) + last_close = float(d["close"].iloc[-1]) + last_ts = pd.Timestamp(int(d["timestamp"].iloc[-1]), unit="ms", tz="UTC") + mk = float(df.loc[s, "markPx"]) + out.append(dict(sym=s, disco=last_close, api=mk, + dev_pct=100.0 * (mk - last_close) / last_close, + eta_h=(pd.Timestamp.now("UTC") - last_ts) / pd.Timedelta("1h"))) + return pd.DataFrame(out).set_index("sym") + + +# ================================================================================================== +# 2. BOOK L2 -> spread, profondita', costo di un ticket +# ================================================================================================== +def scarica_book(syms: list[str], snaps: int, no_net: bool, pause: float = 0.25, + gap_s: float = 45.0) -> dict[str, list[dict]]: + books: dict[str, list[dict]] = {s: [] for s in syms} + for k in range(snaps): + if k and not no_net: + time.sleep(gap_s) + for s in syms: + try: + b = cached(f"l2_{s}_{k}", {"type": "l2Book", "coin": s}, no_net) + except Exception as e: # un book mancante e' un dato, non un crash + print(f" [book] {s} snap{k}: {type(e).__name__}") + continue + books[s].append(b) + if not no_net: + time.sleep(pause) # pacing: il rate limit e' PER-IP e condiviso + return books + + +def costo_ticket(levels: list[dict], mid: float, notional: float, side: str) -> tuple[float, bool]: + """VWAP di un ordine marketable da `notional` dollari contro il book, in bps DA MID. + Ritorna (bps, book_esaurito). Per ticket piccoli converge al MEZZO SPREAD: e' giusto cosi', + a $14 non si 'cammina' il book, si paga il livello top.""" + resid, cost, filled = notional, 0.0, 0.0 + for lv in levels: + px, sz = float(lv["px"]), float(lv["sz"]) + cap = px * sz + take = min(resid, cap) + cost += take + filled += take / px + resid -= take + if resid <= 1e-9: + break + if filled <= 0: + return float("nan"), True + vwap = cost / filled + bps = (vwap - mid) / mid * 1e4 * (1.0 if side == "buy" else -1.0) + return bps, resid > 1e-6 + + +def misura_liquidita(books: dict[str, list[dict]], meta: pd.DataFrame) -> pd.DataFrame: + rows = [] + for s, snaps in books.items(): + if not snaps: + continue + rec: dict[str, list[float]] = {"spread": [], "d10": [], "viol": []} + for t in TICKETS: + rec[f"t{int(t)}"] = [] + for b in snaps: + bids, asks = b["levels"][0], b["levels"][1] + if not bids or not asks: + continue + bb, ba = float(bids[0]["px"]), float(asks[0]["px"]) + mid = 0.5 * (bb + ba) + rec["spread"].append((ba - bb) / mid * 1e4) + near = sum(float(l["px"]) * float(l["sz"]) for l in asks + if (float(l["px"]) - mid) / mid <= 0.0010) + near += sum(float(l["px"]) * float(l["sz"]) for l in bids + if (mid - float(l["px"])) / mid <= 0.0010) + rec["d10"].append(near) + sd = int(meta.loc[s, "szDecimals"]) if s in meta.index else 0 + rec["viol"].append(sum(0 if sig_figs_ok(l["px"], sd) else 1 for l in bids + asks)) + for t in TICKETS: + bb_, _ = costo_ticket(asks, mid, t, "buy") + sb_, _ = costo_ticket(bids, mid, t, "sell") + if np.isfinite(bb_) and np.isfinite(sb_): + rec[f"t{int(t)}"].append(0.5 * (bb_ + sb_)) + if not rec["spread"]: + continue + row = dict(sym=s, n_snap=len(rec["spread"]), + spread_bps=float(np.median(rec["spread"])), + half_spread_bps=float(np.median(rec["spread"])) / 2.0, + depth10bps_usd=float(np.median(rec["d10"])), + tick_viol=int(sum(rec["viol"]))) + for t in TICKETS: + v = rec[f"t{int(t)}"] + row[f"slip{int(t)}_bps"] = float(np.median(v)) if v else np.nan + rows.append(row) + return pd.DataFrame(rows).set_index("sym").sort_values("spread_bps") + + +def min_notional_osservato(books: dict[str, list[dict]]) -> dict: + """Controllo EMPIRICO del pavimento $10: i livelli con n==1 sono UN ordine solo. + Un ordine singolo sotto $10 non falsifica la regola (i fill parziali erodono un resto), + ma il PAVIMENTO della distribuzione dice dove il venue taglia.""" + vals = [] + for snaps in books.values(): + for b in snaps: + for side in b["levels"]: + for l in side: + if int(l.get("n", 0)) == 1: + vals.append(float(l["px"]) * float(l["sz"])) + v = np.array(vals, float) + if not len(v): + return {} + return dict(n=len(v), minimo=float(v.min()), p01=float(np.percentile(v, 1)), + p05=float(np.percentile(v, 5)), mediana=float(np.median(v)), + sotto10_pct=100.0 * float((v < 10.0).mean()), + sotto5_pct=100.0 * float((v < 5.0).mean())) + + +# ================================================================================================== +# 3. SIMULATORE DI LIBRO A N GAMBE (la contabilita' e' quella di paper_xsr._step, generalizzata) +# ================================================================================================== +def sim_libro(Weff: np.ndarray, R: np.ndarray, r_hedge: np.ndarray | None, cap0: float, + min_order: float | None, lot_usd: np.ndarray | None, px: np.ndarray | None, + fee_leg: float | np.ndarray) -> dict: + """Un passo per barra. Una gamba il cui |dw|*capitale sta sotto min_order NON si muove + (stessa convenzione di altlib.eval_weights_smallcap e di scripts/live/paper_xsr._step). + In piu': la size si arrotonda al LOTTO del venue -> se arrotonda a zero, non si esegue. + `fee_leg` puo' essere uno scalare o un vettore per-gamba (costo asset-specifico).""" + n, k = Weff.shape + w = np.zeros(k) + cap = cap0 + eq = np.empty(n) + nets = np.zeros(n) + n_fill = n_skip = n_lot0 = 0 + fee = np.broadcast_to(np.asarray(fee_leg, float).ravel(), (k,)) if np.ndim(fee_leg) else None + for i in range(n): + ret = float(np.dot(w, R[i])) + if r_hedge is not None: + ret -= float(w.sum()) * r_hedge[i] + w_t = Weff[i] + if min_order is None: + w_new = w_t.copy() + else: + move = np.abs(w_t - w) * cap >= min_order + if lot_usd is not None and px is not None: + # arrotondamento al lotto: la size eseguita e' un multiplo di lot_units + dn = np.abs(w_t - w) * cap + lots = np.floor(dn / np.maximum(lot_usd, 1e-12)) + zero = move & (lots < 1) + n_lot0 += int(zero.sum()) + move = move & (lots >= 1) + w_new = np.where(move, w_t, w) + n_fill += int(move.sum()) + n_skip += int((~move).sum()) + d = np.abs(w_new - w) + c = float((d * fee).sum()) if fee is not None else float(fee_leg) * float(d.sum()) + if r_hedge is not None: # la gamba di copertura paga anch'essa + dh = abs(float(w_new.sum()) - float(w.sum())) + c += (float(fee.mean()) if fee is not None else float(fee_leg)) * dh + net = ret - c + nets[i] = net + cap *= (1.0 + max(net, -0.99)) + eq[i] = cap + w = w_new + tot = n_fill + n_skip + return dict(net=nets, eq=eq, n_fill=n_fill, n_skip=n_skip, n_lot0=n_lot0, + pct_eseguite=(100.0 * n_fill / tot) if tot else np.nan) + + +def metriche(net: np.ndarray, idx: pd.DatetimeIndex) -> dict: + x = np.asarray(net, float) + sd = x.std(ddof=1) + sh = float(x.mean() / sd * ANN) if sd > 0 else 0.0 + eq = np.cumprod(1.0 + np.clip(x, -0.99, None)) + dd = float((1.0 - eq / np.maximum.accumulate(eq)).max()) + yrs = max((idx[-1] - idx[0]) / pd.Timedelta("365.25D"), 1e-9) + cagr = float(eq[-1] ** (1.0 / yrs) - 1.0) + return dict(sharpe=round(sh, 3), maxdd=round(100 * dd, 1), cagr=round(100 * cagr, 1)) + + +# ================================================================================================== +# 4. XS01 β€” ricostruzione dei PESI (il sleeve espone solo i ritorni) + prova d'identita' +# ================================================================================================== +def xs01_pesi(): + from src.portfolio.sleeves import XS_CFG, XS_UNIVERSE, _xsec_returns + cols = {} + for sym in XS_UNIVERSE: + p = RAW / f"hl_{sym.lower()}_1d.parquet" + if not p.exists(): + continue + d = pd.read_parquet(p) + cols[sym] = pd.Series(d["close"].values.astype(float), + index=pd.to_datetime(d["timestamp"], unit="ms", utc=True)) + C = pd.concat(cols, axis=1, join="inner").sort_index().dropna() + px = C.values + n, A = px.shape + lb, H, k, mode, tv = (XS_CFG["lookbacks"], XS_CFG["H"], XS_CFG["k"], + XS_CFG["mode"], XS_CFG["target_vol"]) + dp, mh = XS_CFG.get("disp_pct", 0), XS_CFG.get("disp_minhist", 20) + mlb = max(lb) + dret = np.vstack([np.zeros(A), px[1:] / px[:-1] - 1.0]) + W = np.zeros((n, A)) + w = np.zeros(A) + hist: list[float] = [] + for i in range(n): + if i >= mlb and i % H == 0: + rLs = [px[i] / px[i - L] - 1.0 for L in lb] + di = float(np.mean([r.std() for r in rLs])) + thr = np.percentile(hist, dp) if (dp > 0 and len(hist) >= mh) else -np.inf + if di >= thr: + sc = np.zeros(A); cnt = 0 + for rL in rLs: + sd = rL.std() + if sd > 0: + sc += (rL - rL.mean()) / sd; cnt += 1 + if cnt: + sc /= cnt + o = np.argsort(sc); w = np.zeros(A); lo, hi = o[:k], o[-k:] + if mode == "mom": w[hi] = 0.5 / k; w[lo] = -0.5 / k + else: w[lo] = 0.5 / k; w[hi] = -0.5 / k + else: + w = np.zeros(A) + hist.append(di) + W[i] = w + gross = np.zeros(n); gross[1:] = np.sum(W[:-1] * dret[1:], axis=1) + turn = np.zeros(n); turn[0] = np.abs(W[0]).sum() + turn[1:] = np.abs(np.diff(W, axis=0)).sum(axis=1) + net = gross - turn * (0.001 / 2.0) + s = pd.Series(net, index=C.index) + rv = s.rolling(30, min_periods=15).std().shift(1) * np.sqrt(365.25) + scale = np.clip(np.nan_to_num(tv / rv.replace(0, np.nan).values, nan=0.0), 0, 3.0) + # prova d'identita' col sleeve UFFICIALE (max|diff| deve essere 0.0) + off = _xsec_returns() + dif = float(np.abs(pd.Series(s.values * scale, index=C.index).reindex(off.index).values + - off.values).max()) + # posizione TENUTA durante la barra i = W[i-1]*scale[i] => posta a fine barra i: W[i]*scale[i+1] + pos_scale = np.concatenate([scale[1:], scale[-1:]]) + return W * pos_scale[:, None], dret, C, list(C.columns), dif + + +# ================================================================================================== +# MAIN +# ================================================================================================== +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--no-net", action="store_true") + ap.add_argument("--snaps", type=int, default=3) + a = ap.parse_args() + pd.set_option("display.width", 200) + + print("=" * 104) + print(" HL-EXEC β€” le regole VERE di Hyperliquid contro le soglie ASSUNTE di XS01 / XSR01") + print("=" * 104) + + # ---------------------------------------------------------------- 1. regole + meta, fee_sched = leggi_regole(a.no_net) + syms = universo_certificato() + print(f"\n[1] REGOLE DEL VENUE (fonte: {INFO_URL} = MAINNET)") + print(f" universo perp quotato: {len(meta)} strumenti; certificati da noi: {len(syms)}") + mancanti = [s for s in syms if s not in meta.index] + delist = [s for s in syms if s in meta.index and bool(meta.loc[s, "delisted"])] + print(f" certificati assenti dal venue: {mancanti or 'nessuno'} delistati: {delist or 'nessuno'}") + + ck = verifica_mainnet(meta, syms) + print(f"\n [A] verifica MAINNET (mark API vs ultima chiusura del feed certificato, " + f"eta' feed {ck['eta_h'].median():.0f}h):") + print(f" deviazione |%| mediana {ck['dev_pct'].abs().median():.2f}% " + f"max {ck['dev_pct'].abs().max():.2f}% ({ck['dev_pct'].abs().idxmax()}) " + f"asset controllati {len(ck)}") + print(" -> un feed TESTNET non supera questo controllo: i suoi prezzi sono scollegati.") + + tk = fee_sched + print(f"\n [A] FEE perp lette dal venue (tier BASE, nessuna chiave):") + print(f" taker (cross) {float(tk['cross'])*1e4:.2f} bps/lato " + f"maker (add) {float(tk['add'])*1e4:.2f} bps/lato") + print(f" primo scaglione VIP a ${float(tk['tiers']['vip'][0]['ntlCutoff']):,.0f} di " + f"volume 14g -> a $600-20k si sta SEMPRE al tier base.") + print(f" [C] la doc (gitbook/trading/fees) dichiara 0.045% / 0.015%: **le due fonti " + f"coincidono**.") + print(f" [C] pavimento d'ordine perp: \"Order must have minimum value of $10.\" " + f"(docs/for-developers/api/error-responses); nessuna esenzione documentata per reduce-only.") + print(f" [C] tick: <=5 cifre significative E <= (6 - szDecimals) decimali; size arrotondata " + f"a szDecimals.") + + sub = meta.loc[[s for s in syms if s in meta.index]].copy() + print(f"\n [A/B] LOTTO per asset (passo di size) β€” i 6 piu' grossolani e i 6 piu' fini:") + o = sub.sort_values("lot_usd", ascending=False) + for who, part in (("piu' grossolano", o.head(6)), ("piu' fine", o.tail(6))): + for s, r in part.iterrows(): + print(f" {s:<6} szDec={int(r.szDecimals)} lotto {r.lot_units:>10.5f} unita' = " + f"${r.lot_usd:>8.4f} mark ${r.markPx:<12.6g} ({who})") + print(f" lotto in $: mediana ${sub['lot_usd'].median():.4f}, max " + f"${sub['lot_usd'].max():.2f} ({sub['lot_usd'].idxmax()})") + print(f" -> il LOTTO non e' mai il vincolo: max ${sub['lot_usd'].max():.2f} << $10 " + f"di pavimento. Il vincolo e' il MIN NOTIONAL.") + print(f" [A] leva massima: BTC {int(meta.loc['BTC','maxLeverage'])}x, " + f"ETH {int(meta.loc['ETH','maxLeverage'])}x, mediana alt certificati " + f"{int(sub['maxLeverage'].median())}x, minimo {int(sub['maxLeverage'].min())}x") + + # ---------------------------------------------------------------- 2. book + print(f"\n[2] BOOK L2 β€” spread, profondita', costo di un ticket ({a.snaps} snapshot per asset)") + books = scarica_book(syms, a.snaps, a.no_net) + liq = misura_liquidita(books, meta) + mn = min_notional_osservato(books) + if mn: + print(f" [B] controllo empirico del pavimento $10 su {mn['n']} livelli a UN SOLO ordine " + f"(n==1):") + print(f" minimo ${mn['minimo']:.2f} p01 ${mn['p01']:.2f} p05 ${mn['p05']:.2f} " + f"mediana ${mn['mediana']:.0f} sotto $10: {mn['sotto10_pct']:.1f}% " + f"sotto $5: {mn['sotto5_pct']:.1f}%") + print(f" (un residuo sotto soglia NON falsifica la regola β€” i fill parziali erodono " + f"un ordine gia' piazzato β€” ma il pavimento della distribuzione dice dove taglia.)") + viol = int(liq["tick_viol"].sum()) + print(f" [B] regola tick verificata sui book veri: {viol} violazioni su " + f"{int(liq['n_snap'].sum())*40} livelli letti -> la regola [C] e' CONFERMATA dal venue.") + + print(f"\n spread e slippage (mediana degli snapshot; bps DA MID, un solo lato):") + print(f" {'sym':<7}{'vol24h $':>13}{'spread':>9}{'1/2 spr':>9}" + f"{'prof<10bp':>11}{'$50':>8}{'$100':>8}{'$300':>8}") + for s, r in liq.iterrows(): + print(f" {s:<7}{r.dayNtlVlm if 'dayNtlVlm' in r else meta.loc[s,'dayNtlVlm']:>13,.0f}" + f"{r.spread_bps:>8.1f}{r.half_spread_bps:>9.1f}{r.depth10bps_usd:>11,.0f}" + f"{r.slip50_bps:>8.1f}{r.slip100_bps:>8.1f}{r.slip300_bps:>8.1f}") + + XS19 = ["BTC", "ETH", "SOL", "BNB", "XRP", "DOGE", "AVAX", "LINK", "LTC", "ADA", + "ARB", "OP", "SUI", "APT", "INJ", "TIA", "SEI", "NEAR", "AAVE"] + maj = liq.reindex([s for s in XS19 if s in liq.index]) + coda = liq.drop(index=maj.index, errors="ignore") + print(f"\n riepilogo (mediana | p90):") + for nm, part in (("19 major (XS01)", maj), (f"coda ({len(coda)} alt, solo XSR01)", coda)): + if len(part): + print(f" {nm:<26} spread {part.spread_bps.median():>5.1f} | " + f"{part.spread_bps.quantile(.9):>5.1f} bps " + f"slip$100 {part.slip100_bps.median():>5.1f} | " + f"{part.slip100_bps.quantile(.9):>5.1f} " + f"slip$300 {part.slip300_bps.median():>5.1f} | " + f"{part.slip300_bps.quantile(.9):>5.1f}") + + # ---------------------------------------------------------------- 3. XS01 + print(f"\n[3] XS01 β€” soglia PUBBLICATA ~$20.000 (origine: stima a occhio, 'rumore arrotondamento')") + Wxs, dret, C, cols, dif = xs01_pesi() + print(f" prova d'identita' col sleeve ufficiale `_xsec_returns()`: max|diff| = {dif:.3e}") + gross_xs = np.abs(Wxs).sum(axis=1) + att = gross_xs > 1e-9 + dW = np.abs(np.diff(Wxs, axis=0, prepend=np.zeros((1, Wxs.shape[1])))) + tk_nz = dW[dW > 1e-9] + print(f" gambe simultanee: {int((np.abs(Wxs) > 1e-9).sum(axis=1)[att].max())} " + f"(k=5 long + 5 short); lordo mediano {np.median(gross_xs[att]):.2f}x il capitale " + f"(vol-target, cap 3x)") + print(f" peso mediano mosso per gamba per ribilanciamento: {np.median(tk_nz)*100:.2f}% " + f"del capitale; 10Β° pctl {np.percentile(tk_nz,10)*100:.2f}%") + print(f"\n ticket per gamba (mediano) e % di gambe ESEGUIBILI, per capitale allocato a XS01:") + print(f" {'cap XS01':>10}{'ticket med':>12}{'ticket p10':>12}" + f"{'>=$5 [D]':>10}{'>=$10 [C]':>11}{'lotto ko':>10}") + R_xs = dret + idx = C.index + base = None + for cap in CAPITALI: + med = np.median(tk_nz) * cap + p10 = np.percentile(tk_nz, 10) * cap + pct5 = 100.0 * float((tk_nz * cap >= MIN_ORDER_ASSUNTO).mean()) + pct10 = 100.0 * float((tk_nz * cap >= MIN_ORDER_HL).mean()) + lot = np.array([meta.loc[s, "lot_usd"] if s in meta.index else 0.0 for s in cols]) + lotko = 100.0 * float((tk_nz * cap < np.median(lot)).mean()) + print(f" {cap:>10,.0f}{med:>12.2f}{p10:>12.2f}{pct5:>9.0f}%{pct10:>10.0f}%{lotko:>9.1f}%") + lot_xs = np.array([float(meta.loc[s, "lot_usd"]) if s in meta.index else 0.0 for s in cols]) + px_xs = np.array([float(meta.loc[s, "markPx"]) if s in meta.index else np.nan for s in cols]) + fee_xs_mod = 0.001 / 2.0 # [D] cio' che il sleeve modella: 5 bps/lato + print(f"\n haircut di eseguibilita' (Sharpe modellato - Sharpe realistico), fee {fee_xs_mod*1e4:.1f} bps/lato:") + print(f" {'cap XS01':>10}{'Sh mod':>9}{'Sh $5 [D]':>11}{'Sh $10 [C]':>12}" + f"{'haircut $10':>13}{'esegui%':>9}") + for cap in CAPITALI: + m0 = sim_libro(Wxs, R_xs, None, cap, None, None, None, fee_xs_mod) + m5 = sim_libro(Wxs, R_xs, None, cap, MIN_ORDER_ASSUNTO, None, None, fee_xs_mod) + m10 = sim_libro(Wxs, R_xs, None, cap, MIN_ORDER_HL, lot_xs, px_xs, fee_xs_mod) + a0, a5, a10 = (metriche(m["net"], idx) for m in (m0, m5, m10)) + hc = (a0["sharpe"] - a10["sharpe"]) / abs(a0["sharpe"]) * 100 if a0["sharpe"] else np.nan + print(f" {cap:>10,.0f}{a0['sharpe']:>9.2f}{a5['sharpe']:>11.2f}{a10['sharpe']:>12.2f}" + f"{hc:>12.0f}%{m10['pct_eseguite']:>9.0f}%") + + # ---------------------------------------------------------------- 4. XSR01 + print(f"\n[4] XSR01 β€” soglia PUBBLICATA ~$5.000, gate 2026-10-23 con haircut a $5.000 <= 40%") + from scripts.live.paper_xsr import build_panel # stessa costruzione del monitor in produzione + ts, dtx, Wx, Rx, rbx, sx = build_panel() + idxx = pd.DatetimeIndex(dtx) + lot_x = np.array([float(meta.loc[s, "lot_usd"]) if s in meta.index else 0.0 for s in sx]) + px_x = np.array([float(meta.loc[s, "markPx"]) if s in meta.index else np.nan for s in sx]) + dWx = np.abs(np.diff(Wx, axis=0, prepend=np.zeros((1, Wx.shape[1])))) + tkx = dWx[dWx > 1e-9] + print(f" gambe {len(sx)} lordo mediano {np.median(np.abs(Wx).sum(axis=1)):.2f}x " + f"peso mosso mediano/gamba {np.median(tkx)*100:.3f}% p10 {np.percentile(tkx,10)*100:.3f}%") + print(f"\n {'cap':>9}{'ticket med':>12}{'Sh mod':>9}{'Sh $5 [D]':>11}{'Sh $10 [C]':>12}" + f"{'haircut $5':>12}{'haircut $10':>13}{'esegui%':>9}") + hc10_5000 = np.nan + for cap in CAPITALI: + m0 = sim_libro(Wx, Rx, rbx, cap, None, None, None, FEE_LEG_MODELLO) + m5 = sim_libro(Wx, Rx, rbx, cap, MIN_ORDER_ASSUNTO, None, None, FEE_LEG_MODELLO) + m10 = sim_libro(Wx, Rx, rbx, cap, MIN_ORDER_HL, lot_x, px_x, FEE_LEG_MODELLO) + a0, a5, a10 = (metriche(m["net"], idxx) for m in (m0, m5, m10)) + h5 = (a0["sharpe"] - a5["sharpe"]) / abs(a0["sharpe"]) * 100 if a0["sharpe"] else np.nan + h10 = (a0["sharpe"] - a10["sharpe"]) / abs(a0["sharpe"]) * 100 if a0["sharpe"] else np.nan + if abs(cap - 5000.0) < 1e-9: + hc10_5000 = h10 + print(f" {cap:>9,.0f}{np.median(tkx)*cap:>12.2f}{a0['sharpe']:>9.2f}" + f"{a5['sharpe']:>11.2f}{a10['sharpe']:>12.2f}{h5:>11.0f}%{h10:>12.0f}%" + f"{m10['pct_eseguite']:>9.0f}%") + print(f" -> haircut a $5.000 col pavimento VERO ($10): {hc10_5000:.0f}% " + f"(soglia pre-registrata {XSR_SOGLIA_HAIRCUT*100:.0f}%)") + print(f" NB: e' la lettura del PARAMETRO sul backtest, NON il gate del 23/10 β€” " + f"quello si decide sulla finestra FORWARD e anticiparlo sarebbe selezione.") + + # -------- costo reale per gamba: fee VERA + mezzo spread misurato + taker = float(tk["cross"]) + slip = liq["half_spread_bps"].reindex(sx).astype(float) / 1e4 + slip_med = float(np.nanmedian(slip.values)) + fee_real = taker + np.nan_to_num(slip.values, nan=slip_med) + print(f"\n [B] COSTO REALE PER GAMBA = taker {taker*1e4:.2f} bps + mezzo spread misurato:") + print(f" mediana {np.median(fee_real)*1e4:.1f} bps p90 {np.percentile(fee_real,90)*1e4:.1f} bps" + f" max {fee_real.max()*1e4:.1f} bps ({sx[int(np.argmax(fee_real))]})") + print(f" contro i {FEE_LEG_MODELLO*1e4:.1f} bps/gamba della config CONGELATA [D]") + print(f"\n {'variante di costo':<44}{'Sharpe':>8}{'maxDD':>8}{'CAGR':>8}") + varianti = [ + (f"[D] config congelata {FEE_LEG_MODELLO*1e4:.1f} bps piatti", FEE_LEG_MODELLO), + (f"[A] solo taker vero {taker*1e4:.2f} bps piatti", taker), + (f"[B] taker + 1/2 spread PER-ASSET (mediana {np.median(fee_real)*1e4:.1f})", fee_real), + (f"[B] taker + spread PIENO per-asset (pessimista)", taker + 2 * np.nan_to_num(slip.values, nan=slip_med)), + ] + for nm, f in varianti: + m = sim_libro(Wx, Rx, rbx, 5000.0, None, None, None, f) + a_ = metriche(m["net"], idxx) + print(f" {nm:<44}{a_['sharpe']:>8.2f}{a_['maxdd']:>7.1f}%{a_['cagr']:>7.1f}%") + print(f" (a capitale infinito = nessun vincolo di min-order: isola il COSTO dal PAVIMENTO)") + + print("\n" + "=" * 104) + print(" LIMITI DICHIARATI: gli spread sono UNO/POCHI snapshot di OGGI su un venue la cui") + print(" liquidita' e' cresciuta -> sono la stima piu' FAVOREVOLE per i 2.6 anni di backtest.") + print(" Il pavimento $10 e' [C] (doc + messaggio d'errore), non provato da un ordine: provarlo") + print(" richiederebbe di mandarne uno, e questo audit non tocca il conto.") + print("=" * 104) + + +if __name__ == "__main__": + main() diff --git a/scripts/research/r0822_oi_pin.py b/scripts/research/r0822_oi_pin.py index 7b46f3c..5744575 100644 --- a/scripts/research/r0822_oi_pin.py +++ b/scripts/research/r0822_oi_pin.py @@ -460,6 +460,7 @@ def main() -> None: P(f" {'hte':>4} {'rule':>5} {'Ξ” vs adj_r':>12} {'Ξ” vs jit_r':>12}" f" {'Ξ” vs rand':>12} {'Ξ” vs mid':>12} {'Ξ” vs mr7':>12}") npos = ntot = 0 + npos_c = ntot_c = 0 # solo i null PULITI (bilanciati nel segno): rand/mid/mr7 for hte in HTE_GRID: e = ev[ev["hte"] == hte] for rl in RULES: @@ -467,12 +468,25 @@ def main() -> None: for p in (f"adj_{rl}", f"jit_{rl}") + PLACEBOS_GLOB: dd = e[f"tw_{rl}"].to_numpy(float) - e[f"tw_{p}"].to_numpy(float) m, lo, hi, _ = cluster_boot(dd, e["exp"].to_numpy()) - cells.append(f"{m:>11.5f}{'*' if (np.isfinite(lo) and lo > 0) else ' '}") - ntot += 1 - npos += int(np.isfinite(lo) and lo > 0) + sig = bool(np.isfinite(lo) and lo > 0) + cells.append(f"{m:>11.5f}{'*' if sig else ' '}") + ntot += 1; npos += int(sig) + if p in PLACEBOS_GLOB: + ntot_c += 1; npos_c += int(sig) P(f" {hte:>4} {rl:>5} " + " ".join(cells)) P(f" celle (rule x hte x placebo) in cui lo SPECIALE batte il placebo con IC95>0:" f" {npos}/{ntot} (atteso per caso ~{0.025 * ntot:.0f})") + P(f" ... contando SOLO i null bilanciati nel segno (rand/mid/mr7): {npos_c}/{ntot_c}" + f" (atteso ~{0.025 * ntot_c:.0f}) <-- e' QUESTO il numero da citare") + P("\n ⚠ UN PLACEBO SI CONTROLLA PRIMA DI USARLO: se il suo segno non e' bilanciato rispetto") + P(" alla regola, il Ξ” non misura l'informazione, misura il ROVESCIAMENTO del segnale.") + P(f" quota di eventi con sign(K_placebo-S) == sign(K_regola-S), su tutti gli hte:") + for rl in RULES: + agr = {p: float(np.nanmean(np.sign(ev[f"d_{rl}"]) == np.sign(ev[f"d_{p}"]))) + for p in (f"adj_{rl}", f"jit_{rl}") + PLACEBOS_GLOB} + P(" " + rl + ": " + " ".join(f"{k}={v:.2f}" for k, v in agr.items())) + P(" un null PULITO sta vicino a 0.50 (rand/mid/mr7). Sotto ~0.35 il placebo e' la") + P(" strategia INVERTITA e il suo Ξ” va letto come ~2x il segnale, non come un test.") P(" LETTURA: `adj_r`/`jit_r` chiedono 'e' proprio QUELLO strike?'; `mid`/`mr7` chiedono") P(" 'serve l'open interest, o basta il centro recente del prezzo?'. La seconda e' la") P(" domanda che uccide o salva il filone.") @@ -554,7 +568,9 @@ def main() -> None: f" Β· WR-knob atteso 1/(1+RR) = {1 / (1 + rr):.1%}" if np.isfinite(rr) else " RR n.d.") P(f" expectancy NETTA per trade: {np.mean(net):+.5f} ({np.mean(net) * 100:+.3f}%)" f" | in R (R = distanza dal livello): {np.mean(net / np.maximum(dk, 1e-6)):+.3f} R") - P(f" a fee ZERO: {np.mean(tw):+.5f} -> {'l edge lordo non esiste' if np.mean(tw) <= 0 else 'edge lordo positivo, la fee lo mangia?'}") + P(f" a fee ZERO: {np.mean(tw):+.5f} Β· la fee costa {2 * FEE_SIDE_REAL:.5f}/trade =" + f" {200 * FEE_SIDE_REAL / max(np.mean(tw), 1e-9):.0f}% del lordo" + f" -> {'MORTE PER FEE' if np.mean(tw) <= 2 * FEE_SIDE_REAL else 'la fee NON e il vincolo'}") dbest, info = strat_daily(ev, best["rule"], int(best["hte"]), None if best["band"] == "nessuna" else (0.002, 0.03), @@ -652,6 +668,10 @@ def main() -> None: def _fn(rule): return lambda h: strat_daily(ev, rule, int(h), (0.002, 0.03), "sign", "all", FEE_SIDE_REAL)[0] + P(" ⚠ le 8 ancore hte NON sono 8 osservazioni indipendenti: guardano le STESSE 73 scadenze") + P(" e lo STESSO percorso di prezzo. '8/8 positive' dice che il segno non dipende") + P(" dall'ancora, NON che l'effetto sia significativo β€” quello lo dice il cluster-bootstrap") + P(" della sezione [4], che sui placebo senza opzioni (mid/mr7) NON e' significativo.") for other in ("mr7", "mid", "oi", "rand"): dl = A.anchor_luck_delta(_fn("mp"), _fn(other), offsets=list(HTE_GRID)) P(f" Ξ” appaiato mp-{other:<4}: mediana {dl.get('median_paired'):+.2f}" @@ -676,7 +696,10 @@ def main() -> None: P("\n[8] SINTESI") n_fact_pos = int((F.lo > 0).sum()) P(f" fatto : {n_fact_pos}/{len(F)} celle (rule x hte) con pinning significativo a IC95") - P(f" null loc. : {npos}/{ntot} confronti appaiati in cui il livello 'speciale' batte il placebo") + P(f" null loc. : {npos}/{ntot} confronti appaiati vinti dal livello 'speciale';" + f" {npos_c}/{ntot_c} contando solo i null PULITI") + P(f" di questi, contro il placebo SENZA OPZIONI (media 7g dello spot): " + f"{sum(1 for h in HTE_GRID for rl in RULES if (lambda t: np.isfinite(t[1]) and t[1] > 0)(cluster_boot((ev[ev.hte == h][f'tw_{rl}'] - ev[ev.hte == h]['tw_mr7']).to_numpy(float), ev[ev.hte == h]['exp'].to_numpy())))}/24") P(f" strategia : miglior Sharpe della griglia {mb['sharpe']:+.2f} su {mb['n']} giorni, DSR {dsr:.3f}") P(f" marginale : {mv.get('marginal_verdict')} (strutturalmente non ADDS-abile a 3,7 mesi)") P("=" * 100) diff --git a/scripts/research/r0822_prop_alloc.py b/scripts/research/r0822_prop_alloc.py index b6eb00e..9cb7849 100644 --- a/scripts/research/r0822_prop_alloc.py +++ b/scripts/research/r0822_prop_alloc.py @@ -205,11 +205,15 @@ def _panel_index(start: str) -> pd.DatetimeIndex: _RG_CACHE: dict = {} -def book_RG(w: dict, start: str = START_WIN) -> tuple[np.ndarray, np.ndarray]: +def book_RG(w: dict, start: str = START_WIN, + drift_mult: tuple = ()) -> tuple[np.ndarray, np.ndarray]: """(R, gap) giornalieri di un libro a pesi arbitrari sui 5 sleeve. Crypto TP01+SKH01: minimo ESATTO sul path orario condiviso (r0725_prop_coupled). - XS01/VRP01/GTAA01: il loro minimo si SOMMA (worst simultaneo β€” convenzione del 25/07).""" - key = (start,) + tuple(round(float(w.get(nm, 0.0)), 6) for nm in SLEEVES) + XS01/VRP01/GTAA01: il loro minimo si SOMMA (worst simultaneo β€” convenzione del 25/07). + `drift_mult` = (("XS01", 0.5), ...): taglia il DRIFT di quello sleeve (non la vol) β€” serve + per lo stress giudiziale su uno sleeve scoperto DENTRO la finestra di misura.""" + dm = dict(drift_mult) + key = (start, drift_mult) + tuple(round(float(w.get(nm, 0.0)), 6) for nm in SLEEVES) if key in _RG_CACHE: return _RG_CACHE[key] idx = _panel_index(start) @@ -218,15 +222,25 @@ def book_RG(w: dict, start: str = START_WIN) -> tuple[np.ndarray, np.ndarray]: M = np.zeros(len(idx)) if w_tp > 0 or w_skh > 0: c = _crypto(w_tp, w_skh).reindex(idx) - R += c["R"].values + cr = c["R"].values + if w_tp > 0 and dm.get("TP01", 1.0) != 1.0 or w_skh > 0 and dm.get("SKH01", 1.0) != 1.0: + raise NotImplementedError("stress sul drift crypto: il minimo e' path-esatto, non separabile") + R += cr M += c["m"].values for nm, fr in (("XS01", pc.xsec_daily_tuples()), ("VRP01", vrp_daily_tuples()), ("GTAA01", gtaa_daily_tuples())): wt = w.get(nm, 0.0) if wt > 0: f = fr.reindex(idx) - R += wt * f["R"].values - M += wt * f["m"].values + rv = np.nan_to_num(f["R"].values) + mv = np.nan_to_num(f["m"].values) + mult = dm.get(nm, 1.0) + if mult != 1.0: # taglia il drift, lascia la forma della coda + cut = (1.0 - mult) * rv.mean() # NB: media PRIMA del taglio + rv = rv - cut + mv = mv - cut + R += wt * rv + M += wt * mv R = np.nan_to_num(R) M = np.nan_to_num(M) _RG_CACHE[key] = (R, np.minimum(M, R) - R) @@ -299,21 +313,34 @@ def funded_sim(R: np.ndarray, G: np.ndarray, idx: np.ndarray, lev: float, firm: if (t + 1) % 365 == 0: alive_at[(t + 1) // 365] = float(alive.mean()) return dict(p_alive=float(alive.mean()), alive_at=alive_at, - e_payout=float(payout.mean()), payout=payout) + e_payout=float(payout.mean()), payout=payout, alive=alive) def objective(w: dict, lev: float, firm: str, ev_idx: np.ndarray, fu_idx: np.ndarray, lens: str = "coupled", factor: float = DELUCK, - notional: float = 100_000.0) -> dict: - R, G = book_RG(w) + notional: float = 100_000.0, drift_mult: tuple = ()) -> dict: + R, G = book_RG(w, drift_mult=drift_mult) R = deluck(R, factor) e = eval_sim(R, G, ev_idx, lev, firm, lens) f = funded_sim(R, G, fu_idx, lev, firm, notional, lens) ann = float(R.mean() * 365.0) vol = float(R.std() * np.sqrt(365.0)) + # stimatore per-percorso di J: eval e funded girano su indici di bootstrap INDIPENDENTI, + # quindi mean(passed_i * alive_i) stima p_pass*p_alive ed e' APPAIABILE fra configurazioni. + jvec = (e["passed"] & f["alive"]).astype(float) return dict(J=e["p_pass"] * f["p_alive"], p_pass=e["p_pass"], p_alive=f["p_alive"], e_payout=f["e_payout"], sharpe=(ann / vol if vol > 0 else 0.0), - vol=vol, drift=ann, t_pass=e["t_pass"]) + vol=vol, drift=ann, t_pass=e["t_pass"], jvec=jvec) + + +def paired_delta(wA: dict, levA: float, wB: dict, levB: float, firm: str, + ev_idx: np.ndarray, fu_idx: np.ndarray) -> tuple[float, float]: + """Differenza APPAIATA di J fra due configurazioni (stessi indici di bootstrap) + errore + standard della differenza. Il livello di J ha SE ~0.01; la DIFFERENZA molto meno.""" + a = objective(wA, levA, firm, ev_idx, fu_idx)["jvec"] + b = objective(wB, levB, firm, ev_idx, fu_idx)["jvec"] + d = a - b + return float(d.mean()), float(d.std(ddof=1) / np.sqrt(len(d))) # ================================================================== Β§2. IL CONTO ANALITICO @@ -358,7 +385,8 @@ def grid_scan(grid: list[dict], levs: tuple, firm: str, ev_idx: np.ndarray, fu_i for w in grid: for lev in levs: o = objective(w, lev, firm, ev_idx, fu_idx, lens, factor) - rows.append(dict(w=wkey(w), lev=lev, **{k: v for k, v in o.items()})) + rows.append(dict(w=wkey(w), lev=lev, + **{k: v for k, v in o.items() if k != "jvec"})) return pd.DataFrame(rows) diff --git a/scripts/research/r0822_skew.py b/scripts/research/r0822_skew.py index 4885d84..d665376 100644 --- a/scripts/research/r0822_skew.py +++ b/scripts/research/r0822_skew.py @@ -422,6 +422,78 @@ def vrp_decomposizione(q: pd.DataFrame, cm7: dict) -> pd.DataFrame: return pd.DataFrame(righe) +MARKFIT_OK = (0.85, 1.15) # banda del CONTROLLO, dichiarata prima di guardare i f + + +def qualita(D: pd.DataFrame) -> pd.Series: + """Filtro dichiarato: si tiene un'osservazione solo se il CONTROLLO passa, cioe' se la IV di + Deribit riprezza i suoi stessi mid entro +-15% sul credito, e se il credito conservativo e' + positivo. NON e' un filtro sull'esito (il f), e' un filtro sulla variabile di controllo: + dove il controllo fallisce non si sta misurando lo skew, si sta misurando una quota rotta.""" + return (D["f_markfit"].between(*MARKFIT_OK)) & (D["f_spread"] > 0) & D["f_term"].gt(0) + + +def attribuzione_log(D: pd.DataFrame) -> pd.DataFrame: + """Attribuzione ESATTA e additiva: log(f_tot) = log(f_term)+log(f_skew)+log(f_markfit)+ + log(f_spread), per costruzione (la catena dei rapporti telescopia). Le MEDIANE dei fattori + non si moltiplicano fra loro; i log si sommano. Da qui la QUOTA del difetto per causa.""" + g = D[qualita(D)] + parti = ["f_term", "f_skew", "f_markfit", "f_spread"] + L = np.log(g[parti]) + tot = float(np.log(g["f_tot"]).mean()) + rows = [] + for c in parti: + m = float(L[c].mean()) + rows.append(dict(fattore=c, media_log=round(m, 4), + fattore_geometrico=round(float(np.exp(m)), 3), + quota_del_difetto=round(m / tot, 3) if tot != 0 else np.nan)) + rows.append(dict(fattore="TOTALE", media_log=round(tot, 4), + fattore_geometrico=round(float(np.exp(tot)), 3), quota_del_difetto=1.0)) + return pd.DataFrame(rows) + + +def panel_f(q: pd.DataFrame, cm7: dict) -> pd.DataFrame: + """La stessa decomposizione, ma a OGNI ora in cui lo spread e' costruibile (non solo ai 22 + ingressi settimanali di VRP01). Serve alla RISOLUZIONE: 22 punti non separano due cause da + ~10% l'una. ⚠ Le ore dentro la stessa scadenza NON sono indipendenti: il campione efficace + resta il numero di SCADENZE, che si riporta accanto.""" + out = [] + for a in ASSETS: + S7 = cm7[a] + S, V = CB.spot_series(a), CB.dvol_series(a) + p = q[(q["asset"] == a) & (q["option_type"] == "P") + & (q["dte"] >= 4.0) & (q["dte"] <= 10.0)] + for (hr, exp), g in p.groupby(["hr", "exp"], sort=False): + if hr not in S7.index: + continue + legs = CB.pick_legs(g) + if legs is None: + continue + ts = g["ts"].max() + dte = float((exp - ts).total_seconds() / 86400.0) + if not (4.0 <= dte <= 10.0): + continue + spot = float(S.asof(ts)); dvol = float(V.asof(ts)) / 100.0 + atm7 = float(S7.loc[hr, "atm"]) / 100.0 + T = dte / 365.25 + c_mod = CB.bs_put(spot, legs["k_short"], T, dvol) - CB.bs_put(spot, legs["k_long"], T, dvol) + c_atm = CB.bs_put(spot, legs["k_short"], T, atm7) - CB.bs_put(spot, legs["k_long"], T, atm7) + c_sml = (CB.bs_put(spot, legs["k_short"], T, legs["iv_short"] / 100.0) + - CB.bs_put(spot, legs["k_long"], T, legs["iv_long"] / 100.0)) + c_mid = (legs["mid_short"] - legs["mid_long"]) * spot + c_real = (legs["bid_short"] - legs["ask_long"]) * spot + if not (c_mod > 0 and c_atm > 0 and c_sml > 0): + continue + hist = V[V.index < ts] + ivr = float((hist < dvol * 100).mean()) if len(hist) else np.nan + out.append(dict(asset=a, hr=hr, exp=exp, dte=dte, rr7=float(S7.loc[hr, "rr"]), + atm7=float(S7.loc[hr, "atm"]), dvol=dvol * 100, ivrank=ivr, + f_term=c_atm / c_mod, f_skew=c_sml / c_atm, + f_markfit=c_mid / c_sml, f_spread=c_real / c_mid, + f_tot=c_real / c_mod, f_tot_mid=c_mid / c_mod)) + return pd.DataFrame(out) + + # ============================================================================= def main() -> None: t_start = time.time() @@ -469,6 +541,14 @@ def main() -> None: print(f"\n {a} β€” corr fra dRR (variazione oraria dello skew) e il rendimento nella") print(" finestra indicata, ancorata al TIMESTAMP VERO delle quote (ts_max):") print(LL[LL["asset"] == a].to_string(index=False)) + fw = LL[LL["minuti"] > 0] + print(f"\n LAG IN AVANTI: {len(fw)} test ({fw['minuti'].nunique()} orizzonti x {len(ASSETS)} asset).") + print(f" |t| massimo osservato = {fw['t_dRR'].abs().max():.2f} su {len(fw)} test;") + print(f" |t| atteso come MASSIMO di {len(fw)} test indipendenti sotto il nullo ~ " + f"{float(np.abs(np.random.default_rng(0).standard_normal((4000, len(fw)))).max(axis=1).mean()):.2f}.") + print(" -> il massimo in avanti e' quello che il rumore produce da solo. LAG INDIETRO:") + print(f" |t| minimo = {LL[LL['minuti'] < 0]['t_dRR'].abs().min():.2f} su " + f"{int((LL['minuti'] < 0).sum())} test, tutti dello STESSO segno.") print("\n Controllo POSITIVO β€” la stessa misura con l'ancora ASSUNTA a :30 dell'ora:") FF = pd.concat([leadlag_ancora_sbagliata(cm30[a], a) for a in ASSETS], ignore_index=True) print(FF.to_string(index=False)) @@ -550,8 +630,13 @@ def main() -> None: z_all[a] = pd.Series(z.to_numpy(), index=pd.DatetimeIndex(pd.to_datetime(sub["datetime"], utc=True))) Z = pd.concat(z_all, axis=1).mean(axis=1) Z = Z.reindex(Bw.index).ffill() + att = int((Bw != 0).sum()) print(f" baseline TP01 sulla finestra dello skew: n={len(Bw)} giorni, " f"Sharpe {sh_of(Bw.to_numpy()):+.2f}, maxDD {dd_of(Bw.to_numpy()):.2%}") + print(f" ⚠ barre ATTIVE del baseline: {att}/{len(Bw)} ({att/len(Bw):.1%}) β€” TP01 e' FLAT il resto") + print(f" del tempo, e nella finestra il suo maxDD e' {dd_of(Bw.to_numpy()):.2%}: un gate di") + print(" de-risk non ha NIENTE da proteggere qui. Il null de-levering girera' e dira'") + print(" 'REFUTED', ma il motivo e' che manca il sinistro, non che il gate sia stato battuto.") for thr in (-0.5, -1.0, -1.5): gate = (Z.shift(1) > thr).astype(float) # de-risk quando lo skew si irripidisce (z basso) ov = (Bw * gate).to_numpy() @@ -565,8 +650,10 @@ def main() -> None: f"Sharpe {nul['sh_base_k']:+.2f} vs overlay {nul['sh_overlay']:+.2f} " f"-> {'SUPERATO' if nul['superato'] else 'REFUTED'}") if n_fire: + gia_flat = int((gg == 0).sum()) print(f" nei giorni de-riskati TP01 faceva in media {gg.mean()*100:+.3f}%/g " - f"(gli altri {Bw[gate==1].mean()*100:+.3f}%/g)") + f"(gli altri {Bw[gate==1].mean()*100:+.3f}%/g); di quei {n_fire} giorni TP01 era " + f"GIA' FLAT in {gia_flat} ({gia_flat/n_fire:.0%}) -> il gate spegne cio' che era gia' spento") print("\n ridondanza col trend β€” corr fra lo z dello skew e il rendimento di TP01: " f"{float(pd.concat({'z': Z, 'b': Bw}, axis=1).dropna().corr().iloc[0,1]):+.3f}") print(" POTENZA: con 75 giorni un gate che scatta ~10 volte non e' misurabile. Il numero da") @@ -600,11 +687,67 @@ def main() -> None: print(" Se sta a 1, RR e BF di questa sessione sono un fatto di PREZZO, non un artefatto") print(" del fit di Deribit -> vale anche come controllo di confound per Q1/Q2.") + print("\n ATTRIBUZIONE ESATTA (in log: i fattori si SOMMANO, le mediane no)") + Q = qualita(D) + print(f" osservazioni che passano il CONTROLLO f_markfit in {MARKFIT_OK} e credito>0: " + f"{int(Q.sum())}/{len(D)}") + scartate = D[~Q][["asset", "ts", "f_markfit", "f_spread"]].copy() + if len(scartate): + scartate["ts"] = pd.DatetimeIndex(scartate["ts"]).strftime("%Y-%m-%d") + print(" scartate (il controllo fallisce -> non si sta misurando lo skew):") + print(scartate.round(3).to_string(index=False)) + print(attribuzione_log(D).to_string(index=False)) + print(f" f_tot mediana sul sottoinsieme che passa il controllo: {D[Q]['f_tot'].median():.3f}") + + print("\n PANEL ORARIO β€” stessa decomposizione a OGNI ora costruibile (risoluzione)") + PN = panel_f(q, cm7) + QP = qualita(PN) + print(f" osservazioni {len(PN)} ({PN.groupby('asset').size().to_dict()}), di cui passano il " + f"controllo {int(QP.sum())}; scadenze distinte (campione EFFICACE) " + f"{PN.groupby('asset')['exp'].nunique().to_dict()}") + G = PN[QP] + for a in ASSETS + ("TUTTI",): + g = G if a == "TUTTI" else G[G["asset"] == a] + if g.empty: + continue + print(f" {a:6s} n={len(g):5d} | f_term {g['f_term'].median():.3f} | " + f"f_skew {g['f_skew'].median():.3f} | f_markfit {g['f_markfit'].median():.3f} | " + f"f_spread {g['f_spread'].median():.3f} || f_tot {g['f_tot'].median():.3f}") + print(" attribuzione log sul panel:") + print(attribuzione_log(PN).to_string(index=False)) + print("\n Il f dipende dal REGIME? (la domanda che decide se 0.73 e' conservativo o ottimista)") - D2 = D.dropna(subset=["f_tot_mid", "rr7"]) + D2 = D[qualita(D)].dropna(subset=["f_tot_mid", "rr7"]) for var in ("rr7", "atm7", "ivrank", "bf7"): c = float(D2["f_tot_mid"].corr(D2[var])) - print(f" corr(f_tot_mid, {var:7s}) = {c:+.3f} [n={len(D2)}]") + cg = float(G["f_tot_mid"].corr(G[var])) if var in G.columns else np.nan + print(f" corr(f_tot_mid, {var:7s}) = {c:+.3f} [ingressi n={len(D2)}]" + + (f" {cg:+.3f} [panel n={len(G)}]" if np.isfinite(cg) else "")) + print(" (positivo = il credito reale si avvicina al modello quando lo skew e' PIATTO;") + print(" e' il verso atteso dal meccanismo, ma n=22 e il panel e' autocorrelato.)") + print("\n IL PEZZO PIU' GRANDE (f_term) CAMBIA SEGNO COL REGIME β€” ed e' questo che decide") + print(" se 0.73 e' conservativo o ottimista dove VRP01 tradera' DAVVERO.") + G2 = G.copy() + G2["contango"] = G2["atm7"] < G2["dvol"] + print(f" ore del panel con ATM7 < DVOL30 (contango, il modello SOVRAPPREZZA): " + f"{G2['contango'].mean():.1%}") + for nome, m in (("contango (ATM7DVOL)", ~G2["contango"])): + g = G2[m] + if g.empty: + continue + print(f" {nome:26s} n={len(g):5d} | f_term {g['f_term'].median():.3f} | " + f"f_skew {g['f_skew'].median():.3f} | f_tot {g['f_tot'].median():.3f} | " + f"IV-rank mediano {g['ivrank'].median():.3f}") + qs = pd.qcut(G2["ivrank"], 4, labels=["q1", "q2", "q3", "q4"], duplicates="drop") + st = G2.groupby(qs, observed=True).agg(n=("f_term", "size"), ivr=("ivrank", "median"), + f_term=("f_term", "median"), f_skew=("f_skew", "median"), + f_tot=("f_tot", "median")) + print(" stratificato per quartile di IV-rank (DENTRO il campione: max 0.23, il gate e' 0.30):") + print(st.round(3).to_string()) + print(" -> f_term sale con l'IV-rank; f_skew no. L'estrapolazione oltre 0.30 NON e' misurata") + print(" e in questo campione non e' misurabile: l'unico episodio ad alta vol e' un RALLY") + print(" (2026-08-21, +26% in una settimana), non un crash. Manca il regime che conta.") + print(f"\n IV-rank agli ingressi: min {D['ivrank'].min():.3f} mediana {D['ivrank'].median():.3f} " f"max {D['ivrank'].max():.3f}") n_gate = int((D["ivrank"] > 0.30).sum()) diff --git a/scripts/research/r0822_term_structure.py b/scripts/research/r0822_term_structure.py index d051aa9..de5f431 100644 --- a/scripts/research/r0822_term_structure.py +++ b/scripts/research/r0822_term_structure.py @@ -5,7 +5,11 @@ DOMANDA. Tre, dichiarate prima di misurare: Q2 il CARRY di vol (roll-down lungo la curva) e' INCASSABILE al bid-ask reale? Q3 vale come GATE DI RISCHIO sopra TP01/SKH01? -IPOTESI A PRIORI (registrate prima di guardare i numeri β€” servono a poterle smentire): +IPOTESI A PRIORI (⚠️ da dove vengono: NON da una busta sigillata. Vengono dai precedenti gia' +DOCUMENTATI del progetto β€” i 4 gate di de-risk risultati ridondanti col trend, il verdetto HEDGE +su VRP-Z del 29/06, l'aritmetica fee/carry gia' fatta su VRP01 e gamma-scalping. Sono state +scritte prima di far girare QUESTO script, ma dopo una fase esplorativa sui dati: si dichiara, +perche' "l'avevo previsto" vale solo se si dice quanto costava prevederlo): Q1 contemporanea. Il front reagisce allo spot: un tuffo alza la vol a 7g piu' di quella a 30g. Se e' cosi', la pendenza e' un TERMOMETRO, non un segnale. Q2 no. Il roll-down fra 7g e 30g vale frazioni di punto-vol al giorno; il bid-ask ATM di @@ -77,6 +81,10 @@ CAPITALE = 635.0 # il conto vero # Conteggio dei trial, AL RIALZO (sezione 6). Ogni voce e' una configurazione VALUTATA. TRIALS: list[tuple[str, int]] = [] +# I pochi numeri su cui poggia il verdetto, raccolti dalle sezioni che li misurano: il verdetto +# NON li riscrive a mano (un numero ricopiato in una conclusione e' un numero che smette di +# aggiornarsi quando i dati crescono). +FACTS: dict = {} def hr(t: str = "") -> None: @@ -333,8 +341,11 @@ def sezione2(curve: pd.DataFrame) -> dict: idx = j.index r1 = lp.diff() sub(f"{a} β€” n ore = {len(j)} ({idx.min()} -> {idx.max()})") + FACTS[f"back_sev_{a}"] = float((j.slope < -5).mean()) + FACTS[f"back_{a}"] = float((j.slope < 0).mean()) print(f" pendenza iv_{BACK}-iv_{FRONT}: media {j.slope.mean():+.2f} pt-vol, " - f"sd {j.slope.std():.2f}, backwardation nel {100*(j.slope<0).mean():.1f}% delle ore") + f"sd {j.slope.std():.2f}, backwardation nel {100*(j.slope<0).mean():.1f}% delle ore, " + f"SEVERA (< -5 pt-vol) nel {100*(j.slope<-5).mean():.1f}%") print("\n (a) CROSS-CORRELAZIONE fra la VARIAZIONE oraria di pendenza e il ritorno orario.") print(" lag<0 = ritorno PRIMA della variazione (il prezzo guida) | lag>0 = DOPO (la") @@ -374,6 +385,7 @@ def sezione2(curve: pd.DataFrame) -> dict: print(f" corr(iv_{FRONT}, RV 24h passata) {float(ivf.corr(rvp)):+.3f} " f"corr(iv_{FRONT}, RV futura) {float(ivf.corr(rvf)):+.3f}") res[(a, "conf_past")], res[(a, "conf_fwd")] = c_pp, c_pf + FACTS[f"conf_past_{a}"], FACTS[f"conf_fwd_{a}"] = c_pp, c_pf print("\n (d) La correlazione col futuro SOPRAVVIVE se si controlla per il PASSATO?") print(" (regressione del ritorno futuro su pendenza + ritorno passato + RV passata;") @@ -412,6 +424,8 @@ def sezione2(curve: pd.DataFrame) -> dict: p = _block_pctl(x, y, c, L=max(3 * h, 7)) c7 = float(np.corrcoef(x[:-7], y[:-7])[0, 1]) if len(x) > 20 else np.nan c7b = float(np.corrcoef(x[7:], y[7:])[0, 1]) if len(x) > 20 else np.nan + if h == 5: + FACTS[f"c5_{a}"], FACTS[f"c5_no7_{a}"] = c, c7 print(f" h={h}g n={m.sum():3d} corr={c:+.3f} pctl vs null a blocchi={p:.3f}" f" | senza ultima settimana {c7:+.3f} | senza prima settimana {c7b:+.3f}") print(" ^ le due colonne di destra sono il test di RISOLUZIONE: se togliere 7") @@ -548,6 +562,7 @@ def sezione3(chain: pd.DataFrame, atm_exp: pd.DataFrame, curve: pd.DataFrame) -> f"un round-trip ATM 10-20g costa {sp:.3f} pt-vol") print(f" => servono {sp/rd:.1f} GIORNI di carry per pagare UN solo giro, " f"e nel frattempo si porta il vega.") + FACTS[f"giorni_carry_{a}"] = sp / rd TRIALS.append(("Q2: 5 secchi di tenore x 2 asset (costo)", 10)) sub("(d) e a $635 la domanda non e' nemmeno questa: il LOTTO MINIMO") @@ -610,6 +625,8 @@ def sezione4(curve: pd.DataFrame) -> None: sub("(a) il fatto che decide da solo la sezione") W = pd.concat(pos, axis=1).loc[TS_START:] flat = (W.abs() < 0.05).mean() + FACTS["tp01_flat"] = float(flat.mean()) + FACTS["n_giorni"] = int(len(W)) print(f" Nella finestra {TS_START.date()} -> oggi ({len(W)} giorni), TP01 e' FLAT nel " f"{flat['BTC']:.1%} (BTC) / {flat['ETH']:.1%} (ETH) dei giorni.") print(f" Esposizione media: BTC {W['BTC'].mean():.3f}, ETH {W['ETH'].mean():.3f} " @@ -665,6 +682,8 @@ def sezione4(curve: pd.DataFrame) -> None: print(f" buy&hold sulla stessa finestra/ancore: mediana {bh['median']:+.2f} " f"[{bh['lo']:+.2f}, {bh['hi']:+.2f}] <-- il vero termine di paragone in un toro") dlt = A.anchor_luck_delta(cand, base, list(range(24))) + FACTS[f"vs_bh_{a}"] = dlt["median_paired"] + FACTS[f"vs_bh_n_{a}"] = (dlt["n_positive"], dlt["n_anchors"]) print(f" mediana delle DIFFERENZE APPAIATE candidato-B&H: {dlt['median_paired']:+.2f} " f"(positiva in {dlt['n_positive']}/{dlt['n_anchors']} ancore)") TRIALS.append((f"Q3 {a}: 24 ancore x 2 varianti", 48)) @@ -697,10 +716,12 @@ def sezione4(curve: pd.DataFrame) -> None: else: verdict = ("IL CANDIDATO NON SOPRAVVIVE (il de-levering fa meglio)" if best[1] >= sh_c else "candidato sopra il null di de-levering") + FACTS[f"delev_{a}"] = bool(best[1] >= sh_c) print(f" {a}: candidato Sharpe {sh_c:+.2f} maxDD {dd_c:.2%} | buy&hold a leva " f"k={best[0]:.2f} -> stesso o minor DD ({best[2]:.2%}) con Sharpe {best[1]:+.2f}" f" => {verdict}") imp = A.implausible_sharpe(net) + FACTS[f"implaus_{a}"] = bool(imp["implausible"]) print(f" implausible_sharpe sul candidato di finestra: implausible=" f"{imp['implausible']} {imp['reasons']}") TRIALS.append((f"Q3 {a}: 60 valori di k nel null de-levering", 60)) @@ -760,6 +781,9 @@ def sezione5(curve: pd.DataFrame) -> None: print(f" era DVOL (2021-10 -> oggi, {len(era)} giorni): Sharpe {A._sh(era):+.3f} " f"maxDD {A._dd_ret(era):.2%}") rep = A.marginal_vs_tp01(cd) + FACTS["proxy_marginal"] = rep.get("marginal_verdict") + FACTS["proxy_sharpe"] = A._sh(era) + FACTS["proxy_uplift"] = rep["blends"]["w25"]["uplift_full"] print(f" marginal_vs_tp01 -> {rep.get('marginal_verdict')} | corr a TP01 " f"{rep.get('corr_full')} | uplift w25 full {rep['blends']['w25']['uplift_full']:+.3f} " f"hold {rep['blends']['w25']['uplift_hold']}") @@ -807,6 +831,95 @@ def sezione6() -> None: print(" su cui questo filone ricade (VRP-Z: earns_slot=False).") + +# =========================================================================================== +# SEZIONE 7 β€” VERDETTO +# =========================================================================================== +def sezione7() -> None: + F = FACTS + hr("SEZIONE 7 β€” VERDETTO") + print(f""" + Q1 LA PENDENZA E' UN TERMOMETRO, NON UN SEGNALE β€” e la finestra non ha risoluzione. + Β· La cross-correlazione oraria ha il picco a lag -1h/0h (il prezzo si muove PRIMA); da + +1h in poi e' rumore (|corr| <= 0.05 su ogni lag fino a 24h). + Β· corr(pendenza, RV 24h PASSATA) = {F.get('conf_past_BTC', float('nan')):+.2f} (BTC) / {F.get('conf_past_ETH', float('nan')):+.2f} (ETH) + corr(pendenza, RV 24h FUTURA) = {F.get('conf_fwd_BTC', float('nan')):+.2f} (BTC) / {F.get('conf_fwd_ETH', float('nan')):+.2f} (ETH) + La pendenza SA qualcosa della vol futura, ma sa DI PIU' di quella passata: e' un premio + di vol con la gamba realizzata sostituita da una implicita, non una variabile nuova. + Β· L'unica correlazione col ritorno futuro che sembra qualcosa (h=5g: {F.get('c5_BTC', float('nan')):+.2f} BTC / + {F.get('c5_ETH', float('nan')):+.2f} ETH) NON SOPRAVVIVE alla rimozione dell'ULTIMA SETTIMANA: + diventa {F.get('c5_no7_BTC', float('nan')):+.2f} / {F.get('c5_no7_ETH', float('nan')):+.2f}. Sette giorni tolti da un campione di 70 ne + ribaltano il SEGNO β€” ed e' il test che conta piu' del percentile contro il null. + E nella regressione controllata per il ritorno passato il t di Newey-West sta sotto 1.0 + a ogni orizzonte. Non e' "debole": e' sotto la risoluzione del campione. + + Q2 IL CARRY C'E' MA NON PAGA IL BID-ASK, e non e' nemmeno incassato. + Β· Roll-down da corto: ~0.07 (BTC) / ~0.13 (ETH) punti-vol al GIORNO. + Round-trip ATM 10-20g: 1.26 (BTC) / 1.77 (ETH) punti-vol. + => {F.get('giorni_carry_BTC', float('nan')):.0f} giorni (BTC) / {F.get('giorni_carry_ETH', float('nan')):.0f} giorni (ETH) di carry per pagare UN giro. + Β· E il roll NON viene incassato: beta(realizzato ~ implicito) e' NEGATIVO (~-1 a una + settimana), cioe' la forma della curva torna indietro piu' di quanto la curva rotoli. + La differenza realizzato-implicito e' dentro il rumore (|t| <= 0.9 a blocchi). + Β· A $635 il vincolo binding e' prima di tutto il LOTTO MINIMO: una gamba ATM costa il 26% + (BTC) / 10% (ETH) del conto in premio, la gamba corta vuole margine, e il carry lordo + di una struttura ETH e' ~$0.19/giorno. Il bersaglio dichiarato e' 50 EUR/giorno. + + Q3 GATE DI RISCHIO: RIDONDANTE COL TREND β€” 5a occorrenza, e NON MISURABILE su questa finestra. + Β· TP01 e' FLAT nel {F.get('tp01_flat', float('nan')):.0%} dei giorni della finestra: non c'e' rischio da togliere. + Un gate che spegne il 25% dei giorni trova TP01 gia' flat nel 79-90% di quei giorni, e + corr(pendenza, esposizione TP01) e' NEGATIVA (-0.29 BTC / -0.52 ETH): la backwardation + arriva quando il trend e' gia' uscito. Firma identica ai 4 precedenti. + Β· Il candidato DIREZIONALE fa Sharpe ~3 su 24/24 ancore β€” e non vuol dire niente: il + buy&hold sulla stessa finestra fa altrettanto. Mediana delle DIFFERENZE APPAIATE contro + buy&hold: {F.get('vs_bh_BTC', float('nan')):+.2f} BTC (positiva in {F.get('vs_bh_n_BTC', (0, 0))[0]}/{F.get('vs_bh_n_BTC', (0, 0))[1]} ancore) e {F.get('vs_bh_ETH', float('nan')):+.2f} ETH ({F.get('vs_bh_n_ETH', (0, 0))[0]}/{F.get('vs_bh_n_ETH', (0, 0))[1]}). + Β· Null del de-levering: ETH {'NON superato' if F.get('delev_ETH') else 'superato'}, BTC {'NON superato' if F.get('delev_BTC') else 'superato'}. + Β· `implausible_sharpe` segnala ENTRAMBI i candidati di finestra (implaus BTC={F.get('implaus_BTC')}, + ETH={F.get('implaus_ETH')}): Calmar 26-52 su 74 giorni di toro = il modo di perdita non e' nel + campione. Il gate qui funziona da controllo positivo del CAMPIONE, non della strategia. + + TEST LUNGO (proxy DVOL-RV7, 5 anni): Sharpe {F.get('proxy_sharpe', float('nan')):+.2f}, marginale vs TP01 = + {F.get('proxy_marginal')}, uplift w25 {F.get('proxy_uplift', float('nan')):+.3f} (NEGATIVO), robust_oos=False + => earns_slot = False. Il proxy e' sfocato (spiega il 21-48% della varianza della pendenza + vera), quindi il suo negativo e' evidenza PIU' DEBOLE di un negativo diretto β€” ma va nella + stessa direzione del verdetto HEDGE gia' dato a VRP-Z il 2026-06-29. + + ====================================================================================== + VERDETTO: SCARTATO come sleeve e come gate. LEAD residuo SOLO sulla term structure come + PREVISORE DI VOL (non di ritorni), con gate pre-registrato qui sotto. + ====================================================================================== + + Perche' non "LEAD" sul lato direzionale: un LEAD e' una cosa che vale la pena SORVEGLIARE, e + sorvegliare costa (la lezione DVOLSPREAD: 35 giorni di limbo). Qui il lato direzionale non ha + bisogno di altri dati per essere giudicato: e' gia' misurato su 5 anni via proxy (earns_slot + False) e la variabile e' quella di VRP-Z. Aprire un monitor sarebbe pagare due volte lo stesso + filone. Il lato del CARRY e' chiuso dall'aritmetica, che non migliora col tempo: il bid-ask + ATM di Deribit dovrebbe stringersi di 10-20x perche' il roll-down lo paghi. + + GATE PRE-REGISTRATO (l'unico pezzo che il tempo puo' cambiare): + Β· COSA: la pendenza come previsore della RV a 24h (misurata: corr {F.get('conf_fwd_BTC', float('nan')):+.2f} / {F.get('conf_fwd_ETH', float('nan')):+.2f}). + Non serve a prendere posizione sul prezzo; servirebbe come DENOMINATORE del + vol-target, cioe' esattamente cio' che `tp01_dvol_overlay` (26/06) provo' col DVOL + a un tenore solo e trovo' essere puro de-levering. La curva a piu' tenori e' l'unica + variante di quell'esperimento mai fatta. + Β· QUANDO: 2027-02-01. A quella data la catena avra' ~8 mesi di curva (vs 2,4 oggi) e la + finestra conterra' almeno un regime di vol diverso da questo toro. + Β· SOGLIE (dichiarate ORA, non allora): si riapre SOLO se, sulla finestra estesa, + (a) corr(pendenza, RV futura 24h) resta <= -0.30 su ENTRAMBI gli asset, E + (b) la pendenza batte il DVOL a tenore singolo come previsore di RV + (R2 incrementale > 0 in una regressione che contiene gia' il DVOL: se non + aggiunge nulla al DVOL, la curva non serve e basta il tenore che abbiamo dal 2021), E + (c) il null del de-levering e' superato sul vol-target risultante. + Se (b) fallisce il filone si CHIUDE definitivamente: vorra' dire che la term + structure non porta informazione oltre il livello, e il livello ce l'abbiamo gia'. + Β· COSA MI SMENTIREBBE OGGI: una finestra che contenga un vero crash. Tutte e tre le risposte + qui sono misurate su 74 giorni di TORO con TP01 flat il {F.get('tp01_flat', float('nan')):.0%} del tempo. La backwardation + severa (pendenza < -5 pt-vol) copre il {F.get('back_sev_BTC', float('nan')):.1%} delle ore BTC e il {F.get('back_sev_ETH', float('nan')):.1%} delle ore + ETH, e mai dentro un drawdown vero del libro: se in un crash + la pendenza anticipasse i ritorni di ore invece di seguirli, Q1 cambierebbe risposta. Cio' + che NON cambierebbe e' Q2 (aritmetica del bid-ask) ne' l'eseguibilita' a $635. +""") + + def main() -> None: hr("r0822_term_structure β€” TERM STRUCTURE DELLA VOL IMPLICITA BTC/ETH") chain = load_quoted_chain() @@ -820,6 +933,7 @@ def main() -> None: sezione4(curve) sezione5(curve) sezione6() + sezione7() hr("FINE") diff --git a/scripts/research/r0822_vol_size.py b/scripts/research/r0822_vol_size.py index add3258..4d488a6 100644 --- a/scripts/research/r0822_vol_size.py +++ b/scripts/research/r0822_vol_size.py @@ -46,6 +46,25 @@ migliore su ingressi E uscite). Banda d'ancora = **23 offset**, la griglia a pri (`A.anchor_luck_delta`), mai differenza delle mediane. L'ancora di TP01 e' tenuta canonica in entrambe le braccia β€” Β§6 verifica che il segno non dipenda da lei. +⚠️ IL CONTROLLO CHE DECIDE β€” ISO-PESO. Quasi ogni variante di size alza la VOL della gamba SKH, +e quindi il suo peso EFFETTIVO nel libro (i nominali restano 75/25). Ma "alzare il peso di SKH01" +e' gia' stato misurato e RESPINTO il 26/07 (argmax w=0.35, `weights_tilt_null` fallito: alzarlo non +compra Sharpe, compra dipendenza dall'ancora). Percio' il confronto pubblicato non e' vs il +baseline nudo ma vs il baseline **RI-SCALATO allo stesso peso effettivo** (colonne ISO): li' resta +solo la FORMA β€” quale trade pesa quanto. E' la regola dell'iso-rischio gia' codificata (25/07 Β§3). +Corollario: siccome lo Sharpe e' invariante alla scala, `sh(k*baseline) == sh(baseline)`, quindi il +null del de-levering in questa famiglia COINCIDE col confronto degli Sharpe. `k_iso` resta come +diagnostica di quanto si e' mosso il DD. + +DECOMPOSIZIONE DEL MECCANISMO (famiglia ACT). La vol trailing di uno sleeve all'88% di zeri +contiene DUE canali: la MAGNITUDINE degli esiti e la FREQUENZA dei trade. `VTL ... attive` isola +il primo (vol sulle sole barre attive), `ACT` isola il secondo (conteggio di barre attive nella +finestra). Se il guadagno vive in ACT la variante non e' "controllo del rischio" ma un filtro di +frequenza, e va detto. + +NULL DI PERMUTAZIONE (Β§10). Lo stesso MULTINSIEME di size riassegnato a caso ai trade: separa +"conta QUALE trade riceve quale size" (informazione) da "conta la DISTRIBUZIONE delle size". + PRESENTAZIONE. Lo Sharpe e' invariante alla scala, maxDD e CAGR no: ogni variante e' riportata anche a **ISO-VOL** (costante che pareggia la deviazione standard piena a quella del baseline). E' una scelta di presentazione, post-hoc e dichiarata; non cambia lo Sharpe, che e' cio' che @@ -690,7 +709,10 @@ def main() -> None: print(" un contributo positivo si scompone per ANNO prima di crederci) + deriva della size") print("-" * 112) yrs = sorted({int(y) for y in BK["BASE"][can].index.year}) - top = list(dict.fromkeys([best, best_iso, max(ACT, key=lambda x: RES[x]["iF"]), + best_iv = max([n for n in SZ if n.startswith("IV")], key=lambda x: RES[x]["iF"]) + best_rk = max([n for n in SZ if n.startswith("RISK")], key=lambda x: RES[x]["iF"]) + top = list(dict.fromkeys([best, best_iso, best_iv, best_rk, + max(ACT, key=lambda x: RES[x]["iF"]), max(BVT, key=lambda x: RES[x]["iF"])])) print(f" {'variante':<28}" + "".join(f"{y:>8}" for y in yrs)) for nm in top: @@ -731,13 +753,14 @@ def main() -> None: rng = np.random.default_rng(20260822) a10 = list(offs[:: max(1, len(offs) // 3)])[:3] NDRAW = 150 - for nm in dict.fromkeys([best_iso, best_sleeve]): + for nm in dict.fromkeys([best_iso, best_sleeve, best_iv, best_rk]): real = float(np.median([sh(BK[nm][o]) - sh(CTRL[nm][o]) for o in a10])) + sz0 = {o: {a: sizes_of(nm, EX[o][a], SZ, VTL, ACT) for a in ASSETS} for o in a10} nul = [] for _ in range(NDRAW): v = [] for o in a10: - szp = {a: rng.permutation(sizes_of(nm, EX[o][a], SZ, VTL, ACT)) for a in ASSETS} + szp = {a: rng.permutation(sz0[o][a]) for a in ASSETS} lg = leg_daily(EX[o], szp) m_s = vol(lg) / vol(SLE["BASE"][o]) if vol(SLE["BASE"][o]) > 0 else 1.0 v.append(sh(book(TP, lg)) - sh(book(TP, SLE["BASE"][o] * m_s))) diff --git a/scripts/research/r0822_vrp_real_quotes.py b/scripts/research/r0822_vrp_real_quotes.py index dd18101..04cb259 100644 --- a/scripts/research/r0822_vrp_real_quotes.py +++ b/scripts/research/r0822_vrp_real_quotes.py @@ -683,14 +683,18 @@ def main() -> None: if det: mm = np.array([d[1] for d in det]) ss = np.array([d[2] for d in det]) + # il rapporto si stampa SOLO se il minimo e' positivo: max/min con min<0 + # e' un numero senza significato (prima stesura: '112.240.864x'). + rm = (f"{mm.max() / mm.min():.1f}x" if mm.min() > 0 else "SEGNO CHE CAMBIA") print(f" media settimanale fra le ancore: [{mm.min():+.2%}, {mm.max():+.2%}] " - f"(rapporto {mm.max() / max(mm.min(), 1e-9):.1f}x) Β· " - f"deviazione standard: [{ss.min():.2%}, {ss.max():.2%}] " - f"(rapporto {ss.max() / max(ss.min(), 1e-9):.1f}x)") - print(f" β†’ l'ancora NON cambia quanto si guadagna, cambia il DENOMINATORE: con\n" - f" {det[0][3]} settimane e nessuna perdita la vol campionaria e' quasi\n" - f" arbitraria, e lo Sharpe con lei. Non e' che le 08:00 siano un'ora\n" - f" magica β€” e' che questa statistica non porta informazione.") + f"({rm}) Β· deviazione standard: [{ss.min():.2%}, {ss.max():.2%}] " + f"({ss.max() / ss.min():.1f}x)") + print(f" β†’ si muovono ENTRAMBI, numeratore e denominatore" + f"{' β€” e sulla media il segno CAMBIA' if mm.min() <= 0 else ''}.\n" + f" Il motivo e' meccanico e vale la pena dirlo: spostare l'ora d'ingresso\n" + f" cambia lo SNAPSHOT, quindi quali strike combaciano col delta bersaglio\n" + f" β€” non e' lo stesso trade un'ora dopo, e' un'altra struttura. Con\n" + f" {det[0][3]} settimane questo parametro di disturbo domina tutto il resto.") print("\n (d) null del de-levering / beta β€” 'e' reddito da volatilita' o crypto travestito?'") print(" Lo spread ha delta positivo (β‰ˆ +0.18 all'ingresso): in un campione che sale del") @@ -765,12 +769,16 @@ def main() -> None: IV-rank il 2026-06-07, e bastano ~+10% di DVOL per riaprirlo. Il campione e' dentro un regime di vol bassa persistente, non davanti a un muro. - 2. IL TITOLO E' QUASI TUTTO FORTUNA D'ANCORA. L'ora d'ingresso e' un max-of-k mai - dichiarato, ed e' la prima volta che si misura su questa struttura: su 9 ancore lo - Sharpe canonico BTC 32.58 sta all'89 percentile e la MEDIANA ONESTA e' 1.45, con la - banda che tocca il NEGATIVO (-0.35). Fortuna +31.13. Su ETH 3.90 -> 2.41. - Il numero da citare per BTC e' 1.45, non 32.58 β€” e a quel punto non c'e' piu' niente - di straordinario da spiegare. + 2. IL TITOLO NON SOPRAVVIVE ALL'ORA D'INGRESSO. E' un max-of-k mai dichiarato, misurato + qui per la prima volta su questa struttura: su 9 ancore lo Sharpe canonico BTC 32.58 + sta all'89 percentile, la MEDIANA ONESTA e' 1.45 e la banda tocca il NEGATIVO (-0.35). + Su ETH 3.90 -> 2.41. Il numero da citare per BTC e' 1.45, non 32.58. + ⚠️ E il meccanismo non e' quello che avevo scritto prima di guardarlo. Non e' solo la + vol campionaria a ballare: fra le ancore la MEDIA settimanale BTC va da -1.35% a + +11.22% del rischio β€” CAMBIA SEGNO β€” e la deviazione standard di 11x (ETH: media 2.9x, + sd 2.4x). Spostare l'ora d'ingresso cambia lo snapshot e quindi quali strike combaciano + col delta bersaglio: non e' lo stesso trade un'ora dopo, e' UN'ALTRA STRUTTURA. A 10 + settimane questo parametro di disturbo domina il segnale che si vorrebbe misurare. 3. LA FAMIGLIA CHE HA STORIA NON E' QUELLA CHE SI PUO' ESEGUIRE. Le quote raccolte sono le opzioni INVERSE (margine e regolamento in BTC/ETH); il conto e' in USDC e il book @@ -819,6 +827,31 @@ def main() -> None: regime sulla famiglia eseguibile. Prima di allora ogni misura su questa struttura descrivera' la sua astensione.""") + hr("COSA MI SMENTIREBBE", "-") + print(""" Tre osservazioni, tutte ottenibili, che ribalterebbero questo verdetto β€” e una che NON + lo ribalterebbe, messa qui perche' e' quella che arrivera' per prima. + + 1. IL CONTO. Se un saldo USDC puo' marginare le opzioni INVERSE, il punto 3 si sgonfia da + muro a dettaglio: la struttura ETH inverse (1 lotto, rischio ~$88, credito ~$12 la + settimana) e' gia' eseguibile a $600 oggi. Resterebbero i punti 1, 2 e 4 β€” sufficienti + a tenere il verdetto, ma il filone tornerebbe misurabile SUBITO invece che fra un anno. + Costo della verifica: un ordine di prova, come per il PRIIPs. + + 2. IL REGIME. Se, raccolte ~un anno di settimane col gate IV-rank APERTO, la media + settimanale restasse positiva CON la banda d'ancora che non contiene lo zero, allora + Β«short-vol da QUOTEΒ» sarebbe dimostrato e la regola del 19/06 andrebbe riscritta. + E' la sola prova che conta: la banda d'ancora, non la media. + + 3. IL LIBRO. Se l'open interest di ETH_USDC salisse stabilmente sopra ~100 sugli strike a + Ξ΄ -0.28/-0.10 con spread relativo sotto ~15%, esisterebbe una struttura regolata in + USDC ed eseguibile a $242 per lotto: il filone si riaprirebbe sul DATO invece che + sulla teoria. Ma senza raccoglierla adesso non lo sapremo mai β€” vedi azione (A). + + ⚠️ COSA NON MI SMENTIREBBE, ed e' quello che succedera' per primo: un'altra finestra di + 10 settimane di vol bassa con 10/10 vincenti e uno Sharpe a due cifre. Sarebbe lo + STESSO campione, piu' lungo β€” non evidenza nuova. Un campione che non contiene il modo + di perdere non diventa informativo aggiungendo settimane in cui non si perde.""") + if __name__ == "__main__": main()