Files
PythagorasGoal/scripts/research/r0822_vrp_real_quotes.py
T

756 lines
41 KiB
Python

"""R0822 VRP-QUOTE-VERE — esiste una struttura di reddito in opzioni ESEGUIBILE a $600-3.000
che sopravvive a fee reali, spread reali e lotto minimo?
NON e' "rifare VRP01". La domanda e' se la regola dura del progetto — *niente short-vol da
MODELLO in deploy* (19/06, riconfermata 3 volte) — si possa sostituire con *short-vol da QUOTE*,
che e' un'altra cosa: il 30/07 il premio era ancora prezzato Black-Scholes su DVOL ATM e la
misura sulle quote reali dava f = 0.73 del modello, con il difetto NON nella gamba venduta
(f_short 1.01) ma nell'ALA che si compra (f_long 2.23). Da allora la condizione e' cambiata:
la catena vera la raccogliamo noi, oraria, per strike, con bid/ask.
ORDINE DELLE DOMANDE (deliberato). L'eseguibilita' viene PRIMA del rendimento — regola del
30/07: "prima di misurare il rendimento a un capitale dato, misurare il lotto minimo del venue".
Se non si esegue, il resto e' accademia.
§1 LIMITE DI REGIME — risultato di prima riga, non nota a pie' di pagina
§2 IL CAMPIONE — quante settimane ci sono DAVVERO, e perche'
§3 ESEGUIBILITA' — lotti, famiglie, e su quale delle due si esegue
§4 BACKTEST SU QUOTE REALI — griglia dichiarata, fill al bid/ask, fee di listino
§5 IL RISCHIO STA NEL MINIMO — non nelle chiusure settimanali
§6 GATE — inclusi quelli che NON si possono far girare
§7 VERDETTO
CONVENZIONI CHE DECIDONO IL RISULTATO (dichiarate qui, non sepolte nel codice)
(a) FILL: si vende al BID e si compra all'ASK, sempre. Il mid e' il modello sotto mentite
spoglie e compare solo come diagnostica, mai come cella selezionabile.
(b) FEE: listino Deribit vero — `min(0.03% del sottostante, 12.5% del premio della SINGOLA
opzione)` per gamba, piu' la fee di consegna sulle gambe ITM a scadenza. Lo sleeve usa
invece un forfait del 12.5% del credito NETTO: qui si misura di quanto sbaglia.
(c) SETTLEMENT: indice reale alla scadenza dal feed certificato. Deribit regola sulla media
dell'indice nei 30 minuti prima delle 08:00 UTC; qui si usa la barra 1h di chiusura —
approssimazione dichiarata, non nascosta.
(d) CAPITALE IMPEGNATO: il denominatore e' il rischio massimo della struttura
`(K_short - K_long) x lotto - credito`. E' il limite INFERIORE del capitale richiesto;
quello vero dipende dal regime di margine del conto e NON e' leggibile da dati pubblici.
Riportato anche il limite superiore (cash-secured, la convenzione del sleeve).
RAM: la catena si legge filtrata in LETTURA (solo put, solo le colonne che servono) con
pyarrow.dataset — `bite_archive.parquet` intero esplode in memoria e ucciderebbe gli altri agenti.
nice -n 19 timeout 900 uv run python scripts/research/r0822_vrp_real_quotes.py
... --no-live # salta le letture dal venue (usa la cache se c'e')
"""
from __future__ import annotations
import argparse
import json
import sys
import time
import urllib.request
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
import altlib as A # noqa: E402
import cblib as CB # noqa: E402
CHAIN_DIR = ROOT / "data" / "raw" / "cb_chain"
SNAP_JSONL = ROOT / "data" / "options_daily" / "snapshots.jsonl"
CACHE = Path("/tmp/claude-1001/-opt-docker-PythagorasGoal/"
"b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad/r0822_venue.json")
# --- listino Deribit (fonte: campo `taker_commission` letto dal venue + schema fee pubblico) ---
FEE_UNDERLYING = 0.0003 # 0.03% del sottostante per gamba, per contratto
FEE_PREMIUM_CAP = 0.125 # cap: 12.5% del premio della SINGOLA opzione
FEE_DELIVERY = 0.00015 # 0.015% del sottostante sulle gambe ITM a scadenza
SLEEVE_FEE_FRAC = 0.125 # forfait dello sleeve: 12.5% del credito NETTO
DTE_LO, DTE_HI, DTE_TARGET = 4.0, 10.0, 7.0
WEEKS_PER_YEAR = 52.0
# ---------------------------------------------------------------- griglia DICHIARATA
# Si conta al RIALZO: ogni variante provata e' un trial, anche quelle scartate.
SHORT_DELTAS = (-0.35, -0.28, -0.20, -0.12)
LONG_DELTAS = (-0.20, -0.10, -0.05)
CELLS = [(s, l) for s in SHORT_DELTAS for l in LONG_DELTAS if abs(l) < abs(s)]
FILLS = ("bid_ask", "mid") # `mid` = diagnostica, NON selezionabile
ASSETS = ("BTC", "ETH")
CANON = (-0.28, -0.10) # la cella di VRP01
def hr(t: str = "", ch: str = "=") -> None:
print("\n" + ch * 100)
if t:
print(t)
print(ch * 100)
# ================================================================== dati
def load_puts() -> pd.DataFrame:
"""Solo le PUT, solo le colonne che servono, filtrate in LETTURA."""
import pyarrow.dataset as ds
cols = ["ts", "asset", "instrument_name", "strike", "option_type", "exp",
"bid", "ask", "mid", "iv", "delta", "open_interest", "quote_status"]
d = ds.dataset(str(CHAIN_DIR), format="parquet")
df = d.to_table(columns=cols, filter=(ds.field("option_type") == "P")).to_pandas()
df["ts"] = pd.to_datetime(df["ts"], utc=True).dt.as_unit("ns")
df["exp"] = pd.to_datetime(df["exp"], utc=True)
df["dte"] = (df["exp"] - df["ts"]).dt.total_seconds() / 86400.0
return df.drop_duplicates(subset=["ts", "instrument_name"], keep="last").reset_index(drop=True)
def venue_read(live: bool) -> dict:
"""Specifiche e liquidita' lette DAL VENUE. Un fallimento si dichiara, non si inventa."""
if not live and CACHE.exists():
return json.loads(CACHE.read_text())
def api(path, **kw):
q = "&".join(f"{k}={v}" for k, v in kw.items())
with urllib.request.urlopen(
f"https://www.deribit.com/api/v2/public/{path}?{q}", timeout=25) as r:
return json.load(r)["result"]
out = {"ok": False, "ts": pd.Timestamp.now(tz="UTC").isoformat()}
try:
out["index"] = {c: api("get_index_price", index_name=f"{c}_usd")["index_price"]
for c in ("btc", "eth")}
specs, summ = {}, []
for cur in ("BTC", "ETH", "USDC"):
for i in api("get_instruments", currency=cur, kind="option", expired="false"):
fam = i["instrument_name"].split("-")[0]
if fam not in specs:
specs[fam] = {k: i.get(k) for k in
("min_trade_amount", "contract_size", "settlement_currency",
"quote_currency", "taker_commission", "base_currency")}
summ += api("get_book_summary_by_currency", currency=cur, kind="option")
time.sleep(0.4)
out["specs"] = specs
out["summary"] = [{k: r.get(k) for k in
("instrument_name", "bid_price", "ask_price", "mid_price",
"mark_price", "mark_iv", "open_interest", "volume",
"underlying_price")} for r in summ]
out["ok"] = True
CACHE.parent.mkdir(parents=True, exist_ok=True)
CACHE.write_text(json.dumps(out))
except Exception as exc: # noqa: BLE001
out["error"] = f"{type(exc).__name__}: {exc}"
if CACHE.exists():
cached = json.loads(CACHE.read_text())
cached["stale"] = out["error"]
return cached
return out
def specs_from_disk() -> dict:
"""Le specifiche gia' registrate dal progetto (`data/options_daily/snapshots.jsonl`).
Serve come controllo indipendente della lettura live: due percorsi, stesso numero."""
mins, fees, last_ts = {}, {}, {}
if not SNAP_JSONL.exists():
return {}
with SNAP_JSONL.open() as f:
for line in f:
try:
r = json.loads(line)
except Exception: # noqa: BLE001, S112
continue
if r.get("rec") != "chain":
continue
c = r["currency"]
mins.setdefault(c, set()).add(r.get("min_trade_amount"))
fees.setdefault(c, set()).add(r.get("taker_comm"))
last_ts[c] = r["snap_ts"]
return {c: {"min": sorted(x for x in mins[c] if x is not None),
"taker": sorted(x for x in fees[c] if x is not None),
"ts": pd.Timestamp(last_ts[c], unit="ms", tz="UTC")} for c in mins}
# ================================================================== struttura
def leg_fee_usd(prem_base: float, spot: float) -> float:
"""Fee di listino per UNA gamba, per contratto (1 unita' di sottostante), in USD."""
return min(FEE_UNDERLYING * spot, FEE_PREMIUM_CAP * prem_base * spot)
def entry_snapshots(puts: pd.DataFrame, asset: str, offset_h: float = 0.0) -> dict:
"""Per ogni scadenza settimanale: lo snapshot piu' vicino a (7 giorni + offset) dalla scadenza.
L'offset e' l'ANCORA: entrare alle 08:00 del venerdi' precedente e' UNA delle scelte possibili,
e questo progetto ha misurato 4 volte che l'ancora e' un max-of-k non dichiarato.
"""
p = puts[(puts["asset"] == asset) & (puts["dte"] >= DTE_LO) & (puts["dte"] <= DTE_HI)]
tgt = DTE_TARGET + offset_h / 24.0
out = {}
for exp, g in p.groupby("exp"):
ts_all = np.array(sorted(g["ts"].unique()))
if not len(ts_all):
continue
dte = (exp - pd.DatetimeIndex(ts_all)).total_seconds() / 86400.0
ok = (dte >= DTE_LO) & (dte <= DTE_HI)
if not ok.any():
continue
cand = ts_all[ok][np.argsort(np.abs(dte[ok] - tgt))]
for ts in cand[:6]: # al piu' 6 tentativi per scadenza
snap = g[g["ts"] == ts]
if snap["bid"].notna().sum() >= 2:
out[exp] = (pd.Timestamp(ts), snap)
break
return out
def build_trades(puts: pd.DataFrame, asset: str, short_d: float, long_d: float,
fill: str = "bid_ask", offset_h: float = 0.0,
lot: float = 1.0, today: pd.Timestamp | None = None) -> pd.DataFrame:
"""Un trade per scadenza: ingresso su quote REALI, tenuto a scadenza, regolato sull'indice."""
today = today or pd.Timestamp.now(tz="UTC")
S = CB.spot_series(asset)
V = CB.dvol_series(asset)
rows, rejected = [], {"gambe_mancanti": 0, "credito_non_positivo": 0}
for exp, (ts, snap) in entry_snapshots(puts, asset, offset_h).items():
if exp >= today: # non ancora regolata: non e' un trade
continue
legs = CB.pick_legs(snap, short_d, long_d)
if legs is None:
rejected["gambe_mancanti"] += 1
continue
spot = float(S.asof(ts))
dte = (exp - ts).total_seconds() / 86400.0
dvol = float(V.asof(ts))
hist = V[V.index < ts]
ivr = float((hist < dvol).mean()) if len(hist) else np.nan
if fill == "bid_ask":
ps, pl = legs["bid_short"], legs["ask_long"] # vendi al bid, compra all'ask
else:
ps, pl = legs["mid_short"], legs["mid_long"]
if not (np.isfinite(ps) and np.isfinite(pl)):
rejected["gambe_mancanti"] += 1
continue
cred = (ps - pl) * spot # USD per 1 unita' di sottostante
if cred <= 0:
# premio non monotono nello strike (difetto certificato di `certify_cb_chain`):
# una put piu' OTM quotata sopra una meno OTM. Non e' una struttura, e' una quota rotta.
rejected["credito_non_positivo"] += 1
continue
ST = float(S.asof(exp))
payoff = max(legs["k_short"] - ST, 0.0) - max(legs["k_long"] - ST, 0.0)
fee = leg_fee_usd(ps, spot) + leg_fee_usd(pl, spot)
for K in (legs["k_short"], legs["k_long"]): # consegna sulle gambe ITM
intr = max(K - ST, 0.0)
if intr > 0:
fee += min(FEE_DELIVERY * ST, FEE_PREMIUM_CAP * intr)
width = legs["k_short"] - legs["k_long"]
mod = CB.f_factors(legs, spot, dvol / 100.0, dte)
rows.append(dict(
asset=asset, ts=ts, exp=exp, dte=dte, spot=spot, ST=ST,
inst_short=legs["inst_short"], inst_long=legs["inst_long"],
cred_real=cred, # per 1 unita': lo vuole CB.close_cost_path
k_short=legs["k_short"], k_long=legs["k_long"], width=width,
d_short=legs["d_short"], d_long=legs["d_long"],
cred=cred * lot, payoff=payoff * lot, fee=fee * lot,
pnl=(cred - payoff - fee) * lot,
fee_sleeve=SLEEVE_FEE_FRAC * mod["cred_mod"] * lot,
fee_sleeve_su_reale=SLEEVE_FEE_FRAC * cred * lot,
cred_mod=mod["cred_mod"] * lot,
risk=width * lot - cred * lot,
dvol=dvol, ivrank=ivr, f_net=mod["f_net"],
und_ret=ST / spot - 1.0,
))
df = pd.DataFrame(rows)
if not df.empty:
df = df.sort_values("exp").reset_index(drop=True)
df.attrs["rejected"] = rejected
return df
def stats(tr: pd.DataFrame) -> dict:
"""Metriche di una serie di trade settimanali. Il denominatore e' il RISCHIO impegnato."""
if tr.empty or len(tr) < 2:
return dict(n=len(tr), sharpe=np.nan, mean=np.nan, t=np.nan)
r = (tr["pnl"] / tr["risk"]).to_numpy(float) # ritorno sul capitale a rischio
sd = float(np.std(r, ddof=1))
m = float(np.mean(r))
eq = np.cumprod(1.0 + r)
dd = float(np.max((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq)))
return dict(n=len(tr), mean=m, sd=sd,
sharpe=(m / sd * np.sqrt(WEEKS_PER_YEAR)) if sd > 0 else np.nan,
t=(m / (sd / np.sqrt(len(r)))) if sd > 0 else np.nan,
maxdd_close=dd, wins=int((tr["pnl"] > 0).sum()),
usd_week=float(tr["pnl"].mean()), usd_tot=float(tr["pnl"].sum()))
def weekly_series(tr: pd.DataFrame) -> pd.Series:
return pd.Series((tr["pnl"] / tr["risk"]).to_numpy(float),
index=pd.DatetimeIndex(tr["exp"])).sort_index()
def boot_ci(x: np.ndarray, n: int = 4000, seed: int = 822) -> tuple[float, float]:
rng = np.random.default_rng(seed)
if len(x) < 2:
return (np.nan, np.nan)
bs = rng.choice(x, size=(n, len(x)), replace=True).mean(axis=1)
return float(np.percentile(bs, 2.5)), float(np.percentile(bs, 97.5))
# ================================================================== main
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--no-live", action="store_true", help="non interrogare il venue")
args = ap.parse_args()
print(__doc__.split("\n\n")[0])
puts = load_puts()
today = pd.Timestamp.now(tz="UTC")
venue = venue_read(not args.no_live)
# ------------------------------------------------------------ §1 REGIME
hr("§1 LIMITE DI REGIME — il campione descrive il regime in cui la strategia sta FLAT")
print("Non e' una nota a pie' di pagina: VRP01 entra SOLO se IV-rank > 0.30 (il gate e' l'alpha,\n"
"misurato 4 volte). Se nel campione quel gate non scatta mai, il campione non contiene\n"
"la strategia — contiene la sua astensione.\n")
for a in ASSETS:
V = CB.dvol_series(a)
win = V[(V.index >= puts["ts"].min()) & (V.index <= puts["ts"].max())]
hist = V[V.index < win.index[0]].to_numpy(float)
print(f" {a}: DVOL nel campione min {win.min():.1f} / mediana {win.median():.1f} / "
f"max {win.max():.1f}")
print(f" vs storia 2021+ ({len(hist)} giorni): la MEDIANA del campione sta al "
f"{float((hist < win.median()).mean()):.0%} pctl, il suo MASSIMO al "
f"{float((hist < win.max()).mean()):.0%} pctl")
print("\n \u26a0\ufe0f Due statistiche diverse, da tenere distinte: CLAUDE.md cita «DVOL max al 24\u00b0/28\u00b0\n"
" pctl» dal campione del 30/07. Un MASSIMO cresce con la finestra — e' una statistica\n"
" di ESTREMO, non di livello. Per dire in che regime siamo la lente e' la MEDIANA, ed\n"
" e' bassissima.")
# ------------------------------------------------------------ §2 CAMPIONE
hr("§2 IL CAMPIONE — quante settimane ci sono DAVVERO")
pre = puts[puts["ts"] < pd.Timestamp("2026-06-09", tz="UTC")]
print(f" catena su disco: {puts['ts'].min():%Y-%m-%d}{puts['ts'].max():%Y-%m-%d} "
f"({(puts['ts'].max() - puts['ts'].min()).days} giorni, {len(puts):,} righe put)")
if not pre.empty:
print(f" ma PRIMA del 2026-06-09 l'archivio ereditato seguiva UNA sola scadenza, in "
f"finestra {pre['dte'].min():.0f}-{pre['dte'].max():.0f} DTE:")
print(f" → la finestra settimanale {DTE_LO:.0f}-{DTE_HI:.0f} DTE NON ESISTE prima "
f"della raccolta full-chain.")
print("\n Trade REGOLATI utilizzabili (cella canonica VRP01 δ-0.28/-0.10, fill bid/ask):")
canon: dict[str, pd.DataFrame] = {}
for a in ASSETS:
lot = 1.0
tr = build_trades(puts, a, *CANON, lot=lot, today=today)
canon[a] = tr
rej = tr.attrs.get("rejected", {}) if not tr.empty else {}
print(f" {a}: {len(tr)} settimane regolate ({tr['exp'].min():%Y-%m-%d} → "
f"{tr['exp'].max():%Y-%m-%d}) scartate: {rej}")
print("\n ⚠️ «3,7 mesi ≈ 16 scadenze settimanali» e' FALSO su questi dati: sono 10 per asset.")
print("\n Gate IV-rank>0.30 di VRP01 sulle settimane utilizzabili:")
tot = passed = 0
for a in ASSETS:
tr = canon[a]
n = int((tr["ivrank"] > 0.30).sum())
tot += len(tr)
passed += n
print(f" {a}: {n}/{len(tr)} passano (IV-rank min {tr['ivrank'].min():.3f} / "
f"max {tr['ivrank'].max():.3f})")
print(f" TOTALE: {passed}/{tot}. Il sleeve sarebbe stato FLAT per l'intero campione.")
print("\n QUANDO TORNERA' MISURABILE — il gate non e' chiuso per sempre, e la soglia e' un numero.")
print(" Regola del 19/06: «rivalutare quando la catena cattura un crash». Un criterio senza\n"
" numero non e' un criterio, quindi eccolo (IV-rank espandente causale, come nel sleeve):")
for a in ASSETS:
V = CB.dvol_series(a)
vals = V.to_numpy(float)
ivr = np.full(len(vals), np.nan)
for i in range(1, len(vals)):
ivr[i] = (vals[:i] < vals[i]).mean()
ser = pd.Series(ivr, index=V.index).dropna()
per_year = ser.groupby(ser.index.year).apply(lambda x: float((x > 0.30).mean()))
thr = float(np.quantile(vals[:-1], 0.30))
last_open = ser[ser > 0.30]
since = (V.index[-1] - last_open.index[-1]).days if len(last_open) else None
print(f" {a}: quota di giorni col gate APERTO per anno — "
+ " ".join(f"{y}:{v:.0%}" for y, v in per_year.items()))
print(f" DVOL oggi {vals[-1]:.1f} · serve DVOL > {thr:.1f} "
f"(+{thr / vals[-1] - 1:.0%}) per riaprire il gate · ultimo giorno aperto "
f"{last_open.index[-1]:%Y-%m-%d} ({since} giorni fa)")
print(" → il campione non e' 'sfortunato': cade dentro un regime di vol bassa lungo e\n"
" persistente. La misura giusta si potra' rifare quando quel numero sara' superato,\n"
" e da allora servira' comunque un anno di settimane per avere un campione.")
ret = {a: (canon[a]["ST"].iloc[-1] / canon[a]["spot"].iloc[0] - 1.0) for a in ASSETS}
print(f"\n E il sottostante nel campione: BTC {ret['BTC']:+.1%}, ETH {ret['ETH']:+.1%} — "
f"vol implicita bassa E rialzo forte\n = il regime IDEALE per vendere put. "
f"Il campione e' il migliore possibile per questa struttura, non uno neutro.")
# ------------------------------------------------------------ §3 ESEGUIBILITA'
hr("§3 ESEGUIBILITA' — e qui il filone cambia domanda")
fams = sorted({n.split("-")[0] for n in puts["instrument_name"].head(50_000)})
print(f" La catena che raccogliamo contiene le famiglie: {fams}")
print(" → sono le opzioni INVERSE: quotate, marginate e regolate in BTC/ETH.")
print(" Il conto e' in USDC ($635) e il book esegue sui perpetual LINEARI USDC.\n")
disk = specs_from_disk()
if disk:
print(" Specifiche gia' registrate dal progetto (data/options_daily/snapshots.jsonl):")
for c, v in sorted(disk.items()):
print(f" {c:6s} min_trade_amount={v['min']} taker={v['taker']} ({v['ts']:%Y-%m-%d %H:%M})")
if venue.get("ok"):
idx = venue["index"]
print(f"\n Lette dal venue ora — indice BTC ${idx['btc']:,.0f} / ETH ${idx['eth']:,.0f}:")
print(f" {'famiglia':11s} {'min lotto':>10s} {'settle':>7s} {'quote':>7s} "
f"{'$ nozionale/lotto':>18s}")
for fam in ("BTC", "ETH", "BTC_USDC", "ETH_USDC"):
s = venue["specs"].get(fam)
if not s:
continue
base = (s.get("base_currency") or fam.split("_")[0]).lower()
notion = float(s["min_trade_amount"]) * idx[base]
print(f" {fam:11s} {s['min_trade_amount']:>10} {str(s['settlement_currency']):>7s} "
f"{str(s['quote_currency']):>7s} {notion:>17,.0f}$")
print("\n 📌 Esiste una famiglia USDC-lineare per BTC ed ETH, con lotto minimo 10x PIU'")
print(" PICCOLO e regolata nella valuta del conto. Il muro «BTC fuori a $3.000» del")
print(" 30/07 — congelato in tests/test_vrp_profit_take.py — e' stato misurato sulla")
print(" famiglia che il conto NON margina.")
print(" E' la 3a occorrenza della stessa forma dopo fee_watch (21/08, sorvegliava gli")
print(" INVERSE mentre il book trada i LINEARI) e la taratura di venue_watch.")
# --- ma la famiglia eseguibile e' quotata davvero? ---
d = pd.DataFrame(venue["summary"])
parts = d["instrument_name"].str.split("-")
d["fam"] = parts.str[0]
d["typ"] = parts.str[3]
d["strike"] = pd.to_numeric(parts.str[2].str.replace("d", ".", regex=False), errors="coerce")
d["expd"] = pd.to_datetime(parts.str[1], format="%d%b%y", utc=True) + pd.Timedelta(hours=8)
d["dte"] = (d["expd"] - today).dt.total_seconds() / 86400.0
d["base"] = d["fam"].str.replace("_USDC", "", regex=False).str.lower()
d = d[d["typ"].eq("P") & d["base"].isin(["btc", "eth"])].copy()
d["S"] = d["base"].map(idx)
d["mny"] = d["strike"] / d["S"]
q = d[(d["bid_price"] > 0) & (d["ask_price"] > 0)].copy()
q["relsp"] = (q["ask_price"] - q["bid_price"]) / q["mid_price"]
# regione delle put OTM usate dalla struttura (δ -0.35..-0.05 ≈ moneyness 0.78-0.97)
w = q[(q["dte"] > 3) & (q["dte"] < 11) & q["mny"].between(0.78, 0.97)]
print("\n Liquidita' REALE nella regione di strike che serve (scadenza settimanale, ora):")
print(f" {'famiglia':11s} {'n quotate':>9s} {'spread rel. med':>16s} "
f"{'p75':>7s} {'OI med':>9s} {'vol 24h':>10s}")
for fam, g in w.groupby("fam"):
allf = d[d["fam"] == fam]
print(f" {fam:11s} {len(g):>9d} {g['relsp'].median():>15.0%} "
f"{g['relsp'].quantile(.75):>6.0%} {g['open_interest'].median():>9.1f} "
f"{allf['volume'].sum():>10,.0f}")
print("\n Lo spread relativo e' ENORME in entrambe le famiglie (il mid non e' un prezzo\n"
" eseguibile: e' la media di due prezzi lontani), e sulla famiglia USDC — l'unica\n"
" marginabile dal conto — l'open interest e' ~0: quotata da un market maker,\n"
" praticamente non tradata. Comprare l'ala li' dentro costa il doppio.")
else:
print(f"\n ⚠️ LETTURA DAL VENUE NON RIUSCITA: {venue.get('error', 'n/d')}")
print(" Le specifiche delle famiglie USDC restano NON MISURATE in questo giro.")
print("\n Capitale per UN lotto minimo, cella canonica (limiti dichiarati, vedi docstring):")
print(f" {'asset':6s} {'lotto':>7s} {'width med':>10s} {'rischio max':>12s} "
f"{'cash-secured':>13s} {'credito/sett':>13s}")
minlot = {"BTC": 0.1, "ETH": 1.0}
if venue.get("ok"):
for fam in ("BTC", "ETH"):
if fam in venue["specs"]:
minlot[fam] = float(venue["specs"][fam]["min_trade_amount"])
for a in ASSETS:
tr, lot = canon[a], minlot[a]
wid = float(tr["width"].median())
print(f" {a:6s} {lot:>7.2f} {wid:>10,.0f} {wid * lot - tr['cred'].median() * lot:>11,.0f}$ "
f"{tr['k_short'].median() * lot:>12,.0f}$ {tr['cred'].median() * lot:>12,.2f}$")
print(" Sul rischio DEFINITO un lotto minimo inverse sta dentro $600. Sul cash-secured no.\n"
" Quale dei due valga dipende dal regime di margine del conto (standard vs portfolio),\n"
" che NON e' leggibile da dati pubblici.")
print(" ⚠️ E c'e' una domanda PRIMA di quella, che nessun dato pubblico chiude: un saldo in\n"
" USDC puo' marginare un'opzione INVERSE (collaterale in BTC/ETH)? Se no, la famiglia\n"
" con 3 mesi di storia non e' negoziabile da questo conto a NESSUN capitale.\n"
" Precedente esatto: il 26/07 GTAA01 aveva 5 settimane di misure sopra l'assunzione\n"
" che i 6 ETF fossero comprabili — il blocco PRIIPs si scopri' con UN ordine di prova.\n"
" La regola che ne usci' («la negoziabilita' sul conto REALE si verifica quando lo\n"
" sleeve entra in RICERCA, non quando entra nel book») si applica qui identica, e\n"
" questo script NON puo' eseguirla: richiede il conto, non i dati.")
# ------------------------------------------------------------ §4 BACKTEST
hr("§4 BACKTEST SU QUOTE REALI — griglia dichiarata")
n_trials = len(CELLS) * len(FILLS) * (len(ASSETS) + 1)
print(f" celle δ (short,long): {len(CELLS)} fill: {len(FILLS)} "
f"asset: {len(ASSETS)} + combinato 50/50")
print(f" → TRIAL DICHIARATI AL RIALZO: {n_trials}. Nessuna griglia ridotta per budget.")
print(f" (Il fill `mid` non e' selezionabile — e' la diagnostica di quanto costa lo spread —\n"
f" ma conta come trial: e' stato guardato.)\n")
grid = []
for fill in FILLS:
for (sd, ld) in CELLS:
per, comb = {}, []
for a in ASSETS:
tr = build_trades(puts, a, sd, ld, fill=fill, lot=1.0, today=today)
per[a] = stats(tr)
if not tr.empty:
comb.append(weekly_series(tr))
row = dict(fill=fill, sd=sd, ld=ld)
for a in ASSETS:
row[f"{a}_sh"] = per[a]["sharpe"]
row[f"{a}_n"] = per[a]["n"]
if comb:
c = pd.concat(comb, axis=1).mean(axis=1).dropna()
row["comb_sh"] = (float(c.mean() / c.std(ddof=1) * np.sqrt(WEEKS_PER_YEAR))
if c.std(ddof=1) > 0 else np.nan)
row["comb_n"] = len(c)
grid.append(row)
G = pd.DataFrame(grid)
print(G.round(2).to_string(index=False))
print("\n ⚠️ COME SI LEGGE LA TABELLA SOPRA. Gli 'Sharpe' a due cifre NON sono un risultato:\n"
" sono la firma del campione. Annualizzare (x√52) 10 osservazioni settimanali in cui\n"
" la coda sinistra non si e' manifestata produce numeri privi di significato — e il\n"
" fatto che l'INTERA griglia li produca e' esattamente il segnale, non un caso da\n"
" selezionare. E' la 4a occorrenza della firma '0-perdite / Sharpe implausibile'\n"
" dopo CC01, le celle deep-OTM Albimarini e meta' della griglia VRP del 03/07.\n"
" Per lo stesso motivo NON viene riportato un CAGR: comporre 10 settimane a 52\n"
" sarebbe una fabbricazione, non una stima.")
print("\n Cella canonica di VRP01 (δ-0.28/-0.10), fill conservativo, per asset — numeri\n"
" CONCRETI (dollari per lotto minimo), che a questa taglia dicono piu' di un rapporto:")
print(f" {'asset':6s} {'n':>3s} {'vinte':>7s} {'$/sett':>9s} {'$ tot':>9s} "
f"{'% rischio/sett':>15s} {'t':>6s} {'maxDD chiusure':>15s}")
st = {}
for a in ASSETS:
tr = canon[a].copy()
tr[["cred", "payoff", "fee", "pnl", "risk", "cred_mod",
"fee_sleeve", "fee_sleeve_su_reale"]] *= minlot[a]
st[a] = stats(tr)
s = st[a]
print(f" {a:6s} {s['n']:>3d} {s['wins']:>3d}/{s['n']:<3d} {s['usd_week']:>8.2f}$ "
f"{s['usd_tot']:>8.2f}$ {s['mean']:>14.2%} {s['t']:>6.2f} {s['maxdd_close']:>14.1%}")
print(" maxDD 0.0% su BTC significa che NON C'E' STATA una settimana in perdita: e' una\n"
" proprieta' del campione, non della struttura.")
print("\n Costo dello SPREAD, misurato (stesso ingresso, fill al mid vs al bid/ask):")
for a in ASSETS:
ba = build_trades(puts, a, *CANON, fill="bid_ask", today=today)
md = build_trades(puts, a, *CANON, fill="mid", today=today)
j = ba.merge(md, on="exp", suffixes=("_ba", "_md")) # appaiato per INGRESSO
if j.empty:
continue
print(f" {a}: credito medio al mid ${j['cred_md'].mean():.2f} → al bid/ask "
f"${j['cred_ba'].mean():.2f} = si perde il "
f"{1 - j['cred_ba'].mean() / j['cred_md'].mean():.0%} del credito solo attraversando "
f"lo spread")
print("\n Il f del 30/07, ri-misurato su 10 settimane invece di 8 (fill conservativo):")
for a in ASSETS:
f = canon[a]["f_net"].dropna()
lo, hi = boot_ci(f.to_numpy())
print(f" {a}: f_net mediana {f.median():.3f} media {f.mean():.3f} "
f"IC95 [{lo:.3f}, {hi:.3f}] ≥1.0 in {int((f >= 1).sum())}/{len(f)}")
allf = pd.concat([canon[a]["f_net"] for a in ASSETS]).dropna()
lo, hi = boot_ci(allf.to_numpy())
print(f" pooled: {allf.median():.3f} (IC95 [{lo:.3f}, {hi:.3f}], n={len(allf)}) — "
f"replica indipendente dello 0.73 del 30/07")
print("\n Fee: listino vero contro il forfait dello sleeve (12.5% del credito NETTO).")
print(" ⚠️ Il confronto giusto e' col credito MODELLATO — e' quello a cui il sleeve applica\n"
" il forfait. Misurarlo sul credito reale (piu' piccolo di ~30%) sottostima l'errore:")
for a in ASSETS:
tr = canon[a]
print(f" {a}: fee reale ${tr['fee'].mean():.2f}/settimana per contratto vs forfait sul\n"
f" credito MODELLATO ${tr['fee_sleeve'].mean():.2f} (= {tr['fee_sleeve'].mean() / tr['fee'].mean():.2f}x)"
f" · sul credito reale ${tr['fee_sleeve_su_reale'].mean():.2f} "
f"(= {tr['fee_sleeve_su_reale'].mean() / tr['fee'].mean():.2f}x)")
print(" Il '~2x' pubblicato il 30/07 non si replica qui: su queste 19 settimane il forfait\n"
" sovrastima le fee di 1.1-1.9x a seconda dell'asset e della base di confronto.\n"
" Resta vero il segno (il forfait e' CONSERVATIVO), non la taglia.")
# ------------------------------------------------------------ §5 IL MINIMO
hr("§5 IL RISCHIO STA NEL MINIMO — le chiusure settimanali non lo vedono")
print(" Regola del 30/07: un rischio valutato sul minimo non si misura sulle chiusure.\n"
" Qui il mark infra-settimana viene dalle standing quotes ORARIE delle STESSE due gambe\n"
" (ricomprare la corta all'ask, rivendere la lunga al bid: di nuovo conservativo).\n")
worst_rows = []
for a in ASSETS:
print(f" {a} (lotto {minlot[a]})")
for _, r in canon[a].iterrows():
path = CB.close_cost_path(r, puts)
if path.empty:
continue
lot = minlot[a]
risk = r["width"] * lot - r["cred"] * lot
worst = float(path["pnl_aperto"].min()) * lot
worst_rows.append(dict(asset=a, exp=r["exp"], worst=worst, risk=risk,
frac=worst / risk, pnl=r["pnl"] * lot,
n_marks=len(path)))
flag = " ← chiusa in UTILE" if r["pnl"] > 0 and worst < 0 else ""
print(f" {r['exp']:%Y-%m-%d} marks={len(path):3d} "
f"peggior mark {worst:>9.2f}$ = {worst / risk:>7.1%} del rischio "
f"esito {r['pnl'] * lot:>7.2f}${flag}")
W = pd.DataFrame(worst_rows)
if not W.empty:
print(f"\n Peggior mark infra-settimana: mediana {W['frac'].median():.1%} del rischio, "
f"minimo {W['frac'].min():.1%}")
wmin = W.loc[W['frac'].idxmin()]
print(f" Denominatore, per non fabbricare una discrepanza: qui il rischio e' "
f"`width - credito`.\n"
f" CLAUDE.md cita «-81,7% del capitale» per la STESSA settimana "
f"({wmin['asset']} {wmin['exp']:%Y-%m-%d}):\n"
f" e' lo stesso mark (${wmin['worst']:.2f}) diviso per la width nuda. Due "
f"denominatori, un solo fatto.")
wl = W[W["pnl"] > 0]
print(f" Fra le sole settimane chiuse in UTILE ({len(wl)}/{len(W)}): mediana "
f"{wl['frac'].median():.1%}, minimo {wl['frac'].min():.1%}")
print(" → il maxDD sulle chiusure e' una lente che non vede il rischio che si e' corso.")
# ------------------------------------------------------------ §6 GATE
hr("§6 GATE — compresi quelli che NON si possono far girare")
print(" (a) implausible_sharpe — il gate del 26/07 sul rischio FUORI dal campione")
for a in ASSETS:
tr = canon[a]
daily = pd.Series((tr["pnl"] / tr["risk"]).to_numpy(float),
index=pd.DatetimeIndex(tr["exp"]))
rep = A.implausible_sharpe(daily, n_trades=len(tr), n_losing_trades=int((tr["pnl"] <= 0).sum()))
print(f" {a}: implausible={rep['implausible']} vinte {int((tr['pnl'] > 0).sum())}/{len(tr)}")
for why in rep["reasons"]:
print(f" · {why}")
if int((tr["pnl"] <= 0).sum()) == 0:
print(f" · regola del tre: 0 perdite su {len(tr)} lascia un tasso VERO fino a "
f"{3 / len(tr):.0%}. Su un payoff che perde {tr['risk'].median() / tr['cred'].median():.1f}x "
f"il credito, basta a ribaltare l'expectancy.")
print("\n (b) deflated_sharpe — NON CALCOLABILE, e il perche' e' il risultato")
for a in ASSETS:
s = weekly_series(canon[a])
dsr, _ = A.deflated_sharpe(st[a]["sharpe"], [r["comb_sh"] for r in grid if
np.isfinite(r.get("comb_sh", np.nan))],
s, dpy=WEEKS_PER_YEAR)
print(f" {a}: DSR = {dsr} (T={len(s)} osservazioni settimanali; la funzione "
f"richiede T>=30)")
print(" Costruire una serie GIORNALIERA per superare il T>=30 sarebbe barare: uno sleeve\n"
" settimanale su griglia giornaliera e' ~94% di zeri (lezione 26/07 su VRP01), e il\n"
" T gonfiato entra nel deflated-Sharpe come se fossero osservazioni indipendenti.\n"
" Statistica onesta a questa taglia = il t con il suo intervallo:")
for a in ASSETS:
r = (canon[a]["pnl"] / canon[a]["risk"]).to_numpy(float)
lo, hi = boot_ci(r)
print(f" {a}: media settimanale {r.mean():+.2%} del rischio, IC95 "
f"[{lo:+.2%}, {hi:+.2%}], t={st[a]['t']:.2f}, n={len(r)}")
print("\n (c) anchor_luck_band — l'ora d'ingresso e' un'ancora (max-of-k mai dichiarato)")
offsets = list(range(-24, 25, 6))
for a in ASSETS:
def by_off(o, _a=a):
return weekly_series(build_trades(puts, _a, *CANON, offset_h=float(o), today=today))
band = A.anchor_luck_band(
by_off, offsets, canonical=0,
metric=lambda s: (float(s.mean() / s.std(ddof=1) * np.sqrt(WEEKS_PER_YEAR))
if len(s) > 2 and s.std(ddof=1) > 0 else 0.0))
if band.get("reason"):
print(f" {a}: {band['reason']}")
continue
print(f" {a}: canonica {band['canonical']:.2f} (pctl {band['canonical_pctl']:.0%}) · "
f"MEDIANA ONESTA {band['median']:.2f} · banda [{band['lo']:.2f}, {band['hi']:.2f}] · "
f"positiva in {band['frac_positive']:.0%} di {band['n_anchors']} ancore · "
f"fortuna {band['luck']:+.2f}")
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(" 20-50% guadagna PER COSTRUZIONE. Regressione del PnL settimanale sul ritorno")
print(" del sottostante nella stessa settimana:")
for a in ASSETS:
tr = canon[a]
x = tr["und_ret"].to_numpy(float)
y = (tr["pnl"] / tr["risk"]).to_numpy(float)
if len(x) < 4:
continue
b, alpha = np.polyfit(x, y, 1)
resid = y - (b * x + alpha)
se = float(np.std(resid, ddof=2) / (np.std(x) * np.sqrt(len(x))))
print(f" {a}: beta {b:+.3f} (t={b / se:.2f}) alpha {alpha:+.2%}/settimana "
f"corr {np.corrcoef(x, y)[0, 1]:+.2f}")
k = abs(b)
lev = pd.Series(k * x, index=pd.DatetimeIndex(tr["exp"]))
sh_lev = (float(lev.mean() / lev.std(ddof=1) * np.sqrt(WEEKS_PER_YEAR))
if lev.std(ddof=1) > 0 else np.nan)
print(f" null: essere semplicemente LONG il sottostante a leva {k:.2f} "
f"(stesso beta) da' Sharpe {sh_lev:.2f} contro {st[a]['sharpe']:.2f} della struttura")
print("\n (e) marginal_vs_tp01 — girato, ma NON interpretabile a questa taglia")
try:
comb = pd.concat([weekly_series(canon[a]) for a in ASSETS], axis=1).mean(axis=1).dropna()
dly = comb.resample("1D").sum().fillna(0.0)
rep = A.marginal_vs_tp01(dly)
act = int((dly != 0).sum())
print(f" verdetto={rep.get('verdict')} corr={rep.get('corr'):.3f} "
f"(barre ATTIVE {act}/{len(dly)} = {act / len(dly):.0%})")
print(f" ⚠️ {len(comb)} settimane su un hold-out che non esiste: il verdetto e' "
f"rumore, si riporta per non ometterlo.")
except Exception as exc: # noqa: BLE001
print(f" NON GIRATO: {type(exc).__name__}: {exc}")
print("\n (f) causality_ok — non girato su questo candidato, e perche'")
print(" `A.causality_ok` valuta target_fn su OHLCV BTC/ETH; qui il segnale non e' una")
print(" serie di prezzo ma una selezione di strumenti da uno snapshot. Verifica")
print(" equivalente eseguita per costruzione e controllata qui sotto:")
bad = 0
for a in ASSETS:
for _, r in canon[a].iterrows():
if r["ts"] >= r["exp"] or not (DTE_LO <= r["dte"] <= DTE_HI):
bad += 1
print(f" ingressi con ts >= scadenza o fuori finestra DTE: {bad}/"
f"{sum(len(canon[a]) for a in ASSETS)} (le gambe si scelgono da UNO snapshot allo\n"
f" stesso ts; il regolamento usa l'indice alla scadenza, mai prima)")
# ------------------------------------------------------------ §7 VERDETTO
hr("§7 VERDETTO")
print(""" SCARTATO — su questi dati la domanda non e' decidibile, e i tre motivi sono
indipendenti l'uno dall'altro:
1. IL CAMPIONE NON CONTIENE LA STRATEGIA. 0 settimane su 19 passano il gate IV-rank>0.30
che E' l'alpha di VRP01. Il campione e' vol implicita al 24-28 percentile E un rialzo
forte del sottostante: il regime migliore possibile per vendere put, non uno neutro.
Le 10 settimane per asset (non 16: prima del 2026-06-09 la finestra 4-10 DTE non
esiste) danno 10/10 vincenti su BTC. La lente giusta non e' lo Sharpe, e' la regola
del tre: 0 perdite su 10 lascia un tasso vero fino al 30%, su un payoff che perde
~7x il credito.
2. 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.
La famiglia USDC-lineare esiste per BTC ed ETH ed ha un lotto minimo 10x piu' piccolo
(BTC 0.01 invece di 0.1) — quindi il muro «BTC fuori a $3.000» del 30/07, congelato
in un test, e' misurato sulla famiglia sbagliata. Ma di quella famiglia abbiamo ZERO
ore di storia e l'open interest e' ~0.
3. LO SPREAD E' IL COSTO DOMINANTE E NON E' MODELLABILE DA UN MID. Attraversare il
bid/ask costa da solo una quota grossa del credito, e l'ala che si COMPRA e' la gamba
dove il libro e' piu' largo — lo stesso meccanismo che il 30/07 misurava come f=0.73.
Cio' che NON si conclude: che la struttura non funzioni. Non e' stata osservata nel regime
in cui lavora. La regola «niente short-vol da MODELLO in deploy» NON diventa «short-vol da
QUOTE»: le quote ora ci sono, ma sono quote della famiglia sbagliata, in un regime solo.
AZIONE RACCOMANDATA (non eseguita — tocca la produzione, e la decisione e' dell'operatore):
far raccogliere a `collect_chain.py` anche `currency=USDC` (famiglie BTC_USDC/ETH_USDC).
Costa ~2 chiamate in piu' per giro; senza, fra sei mesi ci ritroveremo con un anno di
storia della famiglia che non possiamo tradare e zero di quella che potremmo.""")
if __name__ == "__main__":
main()