711 lines
38 KiB
Python
711 lines
38 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())]
|
|
pct = float((V[V.index < win.index[0]].values[:, None] < win.values).mean())
|
|
print(f" {a}: DVOL nel campione min {win.min():.1f} / mediana {win.median():.1f} / "
|
|
f"max {win.max():.1f} → percentile mediano vs storia 2021+ = {pct:.0%}")
|
|
|
|
# ------------------------------------------------------------ §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.")
|
|
|
|
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, che NON e' leggibile\n"
|
|
" da dati pubblici: si legge sul conto. E' un limite di questa misura, non un dettaglio.")
|
|
|
|
# ------------------------------------------------------------ §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%}")
|
|
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
|
|
del +25%/+57%: 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()
|