research(wave-0822): VRP-QUOTE-VERE scartato — e collect_chain raccoglie la famiglia di contratti che il conto non puo' marginare

This commit is contained in:
Adriano Dal Pastro
2026-08-22 17:19:07 +00:00
parent f4b14f305b
commit 75606d0f44
9 changed files with 1800 additions and 37 deletions
+45
View File
@@ -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 | | 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** | | 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) | | 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 ## 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 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 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. *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%).
+787
View File
@@ -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()
+567
View File
@@ -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()
+28 -5
View File
@@ -460,6 +460,7 @@ def main() -> None:
P(f" {'hte':>4} {'rule':>5} {'Δ vs adj_r':>12} {'Δ vs jit_r':>12}" P(f" {'hte':>4} {'rule':>5} {'Δ vs adj_r':>12} {'Δ vs jit_r':>12}"
f" {'Δ vs rand':>12} {'Δ vs mid':>12} {'Δ vs mr7':>12}") f" {'Δ vs rand':>12} {'Δ vs mid':>12} {'Δ vs mr7':>12}")
npos = ntot = 0 npos = ntot = 0
npos_c = ntot_c = 0 # solo i null PULITI (bilanciati nel segno): rand/mid/mr7
for hte in HTE_GRID: for hte in HTE_GRID:
e = ev[ev["hte"] == hte] e = ev[ev["hte"] == hte]
for rl in RULES: for rl in RULES:
@@ -467,12 +468,25 @@ def main() -> None:
for p in (f"adj_{rl}", f"jit_{rl}") + PLACEBOS_GLOB: 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) 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()) m, lo, hi, _ = cluster_boot(dd, e["exp"].to_numpy())
cells.append(f"{m:>11.5f}{'*' if (np.isfinite(lo) and lo > 0) else ' '}") sig = bool(np.isfinite(lo) and lo > 0)
ntot += 1 cells.append(f"{m:>11.5f}{'*' if sig else ' '}")
npos += int(np.isfinite(lo) and lo > 0) 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" {hte:>4} {rl:>5} " + " ".join(cells))
P(f" celle (rule x hte x placebo) in cui lo SPECIALE batte il placebo con IC95>0:" 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})") 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(" 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(" 'serve l'open interest, o basta il centro recente del prezzo?'. La seconda e' la")
P(" domanda che uccide o salva il filone.") 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.") 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}%)" 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") 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"]), dbest, info = strat_daily(ev, best["rule"], int(best["hte"]),
None if best["band"] == "nessuna" else (0.002, 0.03), None if best["band"] == "nessuna" else (0.002, 0.03),
@@ -652,6 +668,10 @@ def main() -> None:
def _fn(rule): def _fn(rule):
return lambda h: strat_daily(ev, rule, int(h), (0.002, 0.03), "sign", "all", return lambda h: strat_daily(ev, rule, int(h), (0.002, 0.03), "sign", "all",
FEE_SIDE_REAL)[0] 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"): for other in ("mr7", "mid", "oi", "rand"):
dl = A.anchor_luck_delta(_fn("mp"), _fn(other), offsets=list(HTE_GRID)) 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}" P(f" Δ appaiato mp-{other:<4}: mediana {dl.get('median_paired'):+.2f}"
@@ -676,7 +696,10 @@ def main() -> None:
P("\n[8] SINTESI") P("\n[8] SINTESI")
n_fact_pos = int((F.lo > 0).sum()) 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" 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" 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(f" marginale : {mv.get('marginal_verdict')} (strutturalmente non ADDS-abile a 3,7 mesi)")
P("=" * 100) P("=" * 100)
+39 -11
View File
@@ -205,11 +205,15 @@ def _panel_index(start: str) -> pd.DatetimeIndex:
_RG_CACHE: dict = {} _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. """(R, gap) giornalieri di un libro a pesi arbitrari sui 5 sleeve.
Crypto TP01+SKH01: minimo ESATTO sul path orario condiviso (r0725_prop_coupled). 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).""" 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) `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: if key in _RG_CACHE:
return _RG_CACHE[key] return _RG_CACHE[key]
idx = _panel_index(start) 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)) M = np.zeros(len(idx))
if w_tp > 0 or w_skh > 0: if w_tp > 0 or w_skh > 0:
c = _crypto(w_tp, w_skh).reindex(idx) 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 M += c["m"].values
for nm, fr in (("XS01", pc.xsec_daily_tuples()), ("VRP01", vrp_daily_tuples()), for nm, fr in (("XS01", pc.xsec_daily_tuples()), ("VRP01", vrp_daily_tuples()),
("GTAA01", gtaa_daily_tuples())): ("GTAA01", gtaa_daily_tuples())):
wt = w.get(nm, 0.0) wt = w.get(nm, 0.0)
if wt > 0: if wt > 0:
f = fr.reindex(idx) f = fr.reindex(idx)
R += wt * f["R"].values rv = np.nan_to_num(f["R"].values)
M += wt * f["m"].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) R = np.nan_to_num(R)
M = np.nan_to_num(M) M = np.nan_to_num(M)
_RG_CACHE[key] = (R, np.minimum(M, R) - R) _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: if (t + 1) % 365 == 0:
alive_at[(t + 1) // 365] = float(alive.mean()) alive_at[(t + 1) // 365] = float(alive.mean())
return dict(p_alive=float(alive.mean()), alive_at=alive_at, 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, def objective(w: dict, lev: float, firm: str, ev_idx: np.ndarray, fu_idx: np.ndarray,
lens: str = "coupled", factor: float = DELUCK, lens: str = "coupled", factor: float = DELUCK,
notional: float = 100_000.0) -> dict: notional: float = 100_000.0, drift_mult: tuple = ()) -> dict:
R, G = book_RG(w) R, G = book_RG(w, drift_mult=drift_mult)
R = deluck(R, factor) R = deluck(R, factor)
e = eval_sim(R, G, ev_idx, lev, firm, lens) e = eval_sim(R, G, ev_idx, lev, firm, lens)
f = funded_sim(R, G, fu_idx, lev, firm, notional, lens) f = funded_sim(R, G, fu_idx, lev, firm, notional, lens)
ann = float(R.mean() * 365.0) ann = float(R.mean() * 365.0)
vol = float(R.std() * np.sqrt(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"], 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), 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 # ================================================================== §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 w in grid:
for lev in levs: for lev in levs:
o = objective(w, lev, firm, ev_idx, fu_idx, lens, factor) 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) return pd.DataFrame(rows)
+146 -3
View File
@@ -422,6 +422,78 @@ def vrp_decomposizione(q: pd.DataFrame, cm7: dict) -> pd.DataFrame:
return pd.DataFrame(righe) 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: def main() -> None:
t_start = time.time() 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(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(" finestra indicata, ancorata al TIMESTAMP VERO delle quote (ts_max):")
print(LL[LL["asset"] == a].to_string(index=False)) 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:") 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) FF = pd.concat([leadlag_ancora_sbagliata(cm30[a], a) for a in ASSETS], ignore_index=True)
print(FF.to_string(index=False)) 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_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 = pd.concat(z_all, axis=1).mean(axis=1)
Z = Z.reindex(Bw.index).ffill() Z = Z.reindex(Bw.index).ffill()
att = int((Bw != 0).sum())
print(f" baseline TP01 sulla finestra dello skew: n={len(Bw)} giorni, " 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%}") 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): 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) gate = (Z.shift(1) > thr).astype(float) # de-risk quando lo skew si irripidisce (z basso)
ov = (Bw * gate).to_numpy() 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"Sharpe {nul['sh_base_k']:+.2f} vs overlay {nul['sh_overlay']:+.2f} "
f"-> {'SUPERATO' if nul['superato'] else 'REFUTED'}") f"-> {'SUPERATO' if nul['superato'] else 'REFUTED'}")
if n_fire: if n_fire:
gia_flat = int((gg == 0).sum())
print(f" nei giorni de-riskati TP01 faceva in media {gg.mean()*100:+.3f}%/g " 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: " 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}") 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") 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(" 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(" 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)") 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"): for var in ("rr7", "atm7", "ivrank", "bf7"):
c = float(D2["f_tot_mid"].corr(D2[var])) 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 (ATM7<DVOL)", G2["contango"]), ("backwardation (ATM7>DVOL)", ~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} " print(f"\n IV-rank agli ingressi: min {D['ivrank'].min():.3f} mediana {D['ivrank'].median():.3f} "
f"max {D['ivrank'].max():.3f}") f"max {D['ivrank'].max():.3f}")
n_gate = int((D["ivrank"] > 0.30).sum()) n_gate = int((D["ivrank"] > 0.30).sum())
+116 -2
View File
@@ -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? 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? 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. 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. 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 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. # Conteggio dei trial, AL RIALZO (sezione 6). Ogni voce e' una configurazione VALUTATA.
TRIALS: list[tuple[str, int]] = [] 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: def hr(t: str = "") -> None:
@@ -333,8 +341,11 @@ def sezione2(curve: pd.DataFrame) -> dict:
idx = j.index idx = j.index
r1 = lp.diff() r1 = lp.diff()
sub(f"{a} — n ore = {len(j)} ({idx.min()} -> {idx.max()})") 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, " 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("\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") 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} " print(f" corr(iv_{FRONT}, RV 24h passata) {float(ivf.corr(rvp)):+.3f} "
f"corr(iv_{FRONT}, RV futura) {float(ivf.corr(rvf)):+.3f}") f"corr(iv_{FRONT}, RV futura) {float(ivf.corr(rvf)):+.3f}")
res[(a, "conf_past")], res[(a, "conf_fwd")] = c_pp, c_pf 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("\n (d) La correlazione col futuro SOPRAVVIVE se si controlla per il PASSATO?")
print(" (regressione del ritorno futuro su pendenza + ritorno passato + RV passata;") 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)) 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 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 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}" 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}") 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") 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") 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, " print(f" => servono {sp/rd:.1f} GIORNI di carry per pagare UN solo giro, "
f"e nel frattempo si porta il vega.") 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)) 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") 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") sub("(a) il fatto che decide da solo la sezione")
W = pd.concat(pos, axis=1).loc[TS_START:] W = pd.concat(pos, axis=1).loc[TS_START:]
flat = (W.abs() < 0.05).mean() 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 " 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.") 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} " 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} " 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") 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))) 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} " print(f" mediana delle DIFFERENZE APPAIATE candidato-B&H: {dlt['median_paired']:+.2f} "
f"(positiva in {dlt['n_positive']}/{dlt['n_anchors']} ancore)") f"(positiva in {dlt['n_positive']}/{dlt['n_anchors']} ancore)")
TRIALS.append((f"Q3 {a}: 24 ancore x 2 varianti", 48)) TRIALS.append((f"Q3 {a}: 24 ancore x 2 varianti", 48))
@@ -697,10 +716,12 @@ def sezione4(curve: pd.DataFrame) -> None:
else: else:
verdict = ("IL CANDIDATO NON SOPRAVVIVE (il de-levering fa meglio)" verdict = ("IL CANDIDATO NON SOPRAVVIVE (il de-levering fa meglio)"
if best[1] >= sh_c else "candidato sopra il null di de-levering") 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 " 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"k={best[0]:.2f} -> stesso o minor DD ({best[2]:.2%}) con Sharpe {best[1]:+.2f}"
f" => {verdict}") f" => {verdict}")
imp = A.implausible_sharpe(net) imp = A.implausible_sharpe(net)
FACTS[f"implaus_{a}"] = bool(imp["implausible"])
print(f" implausible_sharpe sul candidato di finestra: implausible=" print(f" implausible_sharpe sul candidato di finestra: implausible="
f"{imp['implausible']} {imp['reasons']}") f"{imp['implausible']} {imp['reasons']}")
TRIALS.append((f"Q3 {a}: 60 valori di k nel null de-levering", 60)) 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} " print(f" era DVOL (2021-10 -> oggi, {len(era)} giorni): Sharpe {A._sh(era):+.3f} "
f"maxDD {A._dd_ret(era):.2%}") f"maxDD {A._dd_ret(era):.2%}")
rep = A.marginal_vs_tp01(cd) 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 " 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"{rep.get('corr_full')} | uplift w25 full {rep['blends']['w25']['uplift_full']:+.3f} "
f"hold {rep['blends']['w25']['uplift_hold']}") 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).") 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: def main() -> None:
hr("r0822_term_structure — TERM STRUCTURE DELLA VOL IMPLICITA BTC/ETH") hr("r0822_term_structure — TERM STRUCTURE DELLA VOL IMPLICITA BTC/ETH")
chain = load_quoted_chain() chain = load_quoted_chain()
@@ -820,6 +933,7 @@ def main() -> None:
sezione4(curve) sezione4(curve)
sezione5(curve) sezione5(curve)
sezione6() sezione6()
sezione7()
hr("FINE") hr("FINE")
+26 -3
View File
@@ -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 (`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. 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 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). 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 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(" un contributo positivo si scompone per ANNO prima di crederci) + deriva della size")
print("-" * 112) print("-" * 112)
yrs = sorted({int(y) for y in BK["BASE"][can].index.year}) 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"])])) max(BVT, key=lambda x: RES[x]["iF"])]))
print(f" {'variante':<28}" + "".join(f"{y:>8}" for y in yrs)) print(f" {'variante':<28}" + "".join(f"{y:>8}" for y in yrs))
for nm in top: for nm in top:
@@ -731,13 +753,14 @@ def main() -> None:
rng = np.random.default_rng(20260822) rng = np.random.default_rng(20260822)
a10 = list(offs[:: max(1, len(offs) // 3)])[:3] a10 = list(offs[:: max(1, len(offs) // 3)])[:3]
NDRAW = 150 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])) 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 = [] nul = []
for _ in range(NDRAW): for _ in range(NDRAW):
v = [] v = []
for o in a10: 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) lg = leg_daily(EX[o], szp)
m_s = vol(lg) / vol(SLE["BASE"][o]) if vol(SLE["BASE"][o]) > 0 else 1.0 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))) v.append(sh(book(TP, lg)) - sh(book(TP, SLE["BASE"][o] * m_s)))
+46 -13
View File
@@ -683,14 +683,18 @@ def main() -> None:
if det: if det:
mm = np.array([d[1] for d in det]) mm = np.array([d[1] for d in det])
ss = np.array([d[2] 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%}] " 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"({rm}) · deviazione standard: [{ss.min():.2%}, {ss.max():.2%}] "
f"deviazione standard: [{ss.min():.2%}, {ss.max():.2%}] " f"({ss.max() / ss.min():.1f}x)")
f"(rapporto {ss.max() / max(ss.min(), 1e-9):.1f}x)") print(f" → si muovono ENTRAMBI, numeratore e denominatore"
print(f" → l'ancora NON cambia quanto si guadagna, cambia il DENOMINATORE: con\n" f"{' — e sulla media il segno CAMBIA' if mm.min() <= 0 else ''}.\n"
f" {det[0][3]} settimane e nessuna perdita la vol campionaria e' quasi\n" f" Il motivo e' meccanico e vale la pena dirlo: spostare l'ora d'ingresso\n"
f" arbitraria, e lo Sharpe con lei. Non e' che le 08:00 siano un'ora\n" f" cambia lo SNAPSHOT, quindi quali strike combaciano col delta bersaglio\n"
f" magica — e' che questa statistica non porta informazione.") 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("\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") 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 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. 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 2. IL TITOLO NON SOPRAVVIVE ALL'ORA D'INGRESSO. E' un max-of-k mai dichiarato, misurato
dichiarato, ed e' la prima volta che si misura su questa struttura: su 9 ancore lo qui per la prima volta su questa struttura: su 9 ancore lo Sharpe canonico BTC 32.58
Sharpe canonico BTC 32.58 sta all'89 percentile e la MEDIANA ONESTA e' 1.45, con la sta all'89 percentile, la MEDIANA ONESTA e' 1.45 e la banda tocca il NEGATIVO (-0.35).
banda che tocca il NEGATIVO (-0.35). Fortuna +31.13. Su ETH 3.90 -> 2.41. Su ETH 3.90 -> 2.41. Il numero da citare per BTC e' 1.45, non 32.58.
Il numero da citare per BTC e' 1.45, non 32.58 — e a quel punto non c'e' piu' niente E il meccanismo non e' quello che avevo scritto prima di guardarlo. Non e' solo la
di straordinario da spiegare. 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 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 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 regime sulla famiglia eseguibile. Prima di allora ogni misura su questa struttura
descrivera' la sua astensione.""") 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__": if __name__ == "__main__":
main() main()