""" Motore del gioco-OPZIONI: prezza e backtesta strutture in opzioni proposte dagli agenti ciechi, sui prezzi REALI ETH/BTC, con Black-Scholes + skew fittato + DVOL storica. NON usa la chain reale (solo 6 settimane, un regime): prezza sinteticamente con la vol implicita storica (DVOL Deribit, dal 2021-03) e la curva di skew fittata sulle IV reali della ricerca credit-spread (iv/atm = 1 - 0.664*k + 3.494*k^2, k=ln(K/S)). Costi: haircut bid/ask sulle opzioni (il fill reale e' peggiore del mid). Roll giornaliero, hold-to-expiry (terminale model-free dai prezzi reali). PnL per-trade ADDITIVO. Caveat onesto (dalla ricerca del progetto): il premium-selling a skew negativo vince nei campioni calmi e restituisce tutto nei crash -> il gioco lo mostrera'. """ from __future__ import annotations import sys 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)) import json as _json from src.data.downloader import load_data from scripts.analysis.option_overlay_lab import bs_put, bs_call, dvol_for # skew fittato (fallback se manca la calibrazione reale): iv/atm in funzione di k=ln(K/S). SKEW_A, SKEW_B = -0.664, 3.494 MIN_TRADES_PER_MONTH = 10.0 TRADING_DAYS_MONTH = 30.0 # --- pricing REALE: superficie premi/spread da cerbero-bite (scripts/games/opt_calibrate.py) --- _CALIB_DIR = PROJECT_ROOT / "data" / "games" _CALIB = {} def _load_calib(asset): if asset not in _CALIB: f = _CALIB_DIR / f"opt_calib_{asset.lower()}.json" _CALIB[asset] = _json.loads(f.read_text()) if f.exists() else None return _CALIB[asset] def _surf_lookup(cal, typ, otm_signed, dte): """Premio% e spread reali per (otm firmato, dte): punto di griglia piu' vicino. Ritorna (prem_pct, spread, sellable) o None se fuori dalla superficie liquida.""" s = cal["surface"][typ] og = cal["otm_grid"]; tg = cal["ten_grid"] o = min(og, key=lambda x: abs(x - otm_signed)) t = min(tg, key=lambda x: abs(x - dte)) if abs(o - otm_signed) > 0.06: # troppo lontano dagli strike reali -> illiquido return None v = s.get(f"{o:+.2f}|{t}") if not v or v["sellable"] < 0.5: return None return v["prem"], v["spread"], v["sellable"] def iv_skew(k: float, atm: float) -> float: """IV per moneyness k=ln(K/S) dato l'ATM vol. Clamp a [0.3x, 3x] atm.""" mult = 1.0 + SKEW_A * k + SKEW_B * k * k mult = min(max(mult, 0.3), 3.0) return atm * mult def load_opt(asset: str = "ETH"): """Prezzi GIORNALIERI (resample 1h->1d) + DVOL allineata. asset reale nascosto.""" df = load_data(asset, "1h").copy() df["dt"] = pd.to_datetime(df["datetime"]) g = df.set_index("dt").resample("1D").agg( {"timestamp": "first", "open": "first", "high": "max", "low": "min", "close": "last"}).dropna(subset=["close"]).reset_index(drop=True) g["timestamp"] = g["timestamp"].astype("int64") dv = dvol_for(g, asset) cal = _load_calib(asset) dvol_chain = (cal["dvol_chain"] / 100.0) if cal else float(np.nanmedian(dv)) return {"close": g["close"].to_numpy(float), "high": g["high"].to_numpy(float), "low": g["low"].to_numpy(float), "dvol": dv, "asset": asset, "dvol_chain": dvol_chain, "real": cal is not None, "dt": pd.to_datetime(g["timestamp"], unit="ms", utc=True).to_numpy(), "n": len(g)} # -------------------------------------------------------------------------- # Pricing di una struttura: ritorna (premio_netto_incassato, funzione_payoff(ST)) # premio>0 = struttura a CREDITO (vendi); payoff e' il valore terminale (>=0 per long opt). # Convenzione PnL trade: net = (premio_incassato - payoff_terminale)/S0 - costi (per credito) # Tutto normalizzato sul SPOT (frazione), cosi' e' confrontabile fra asset/epoche. # -------------------------------------------------------------------------- STRUCTURES = ["short_put", "short_call", "short_strangle", "put_spread", "call_spread", "iron_condor", "long_put", "long_call", "long_straddle"] def _legs_for(struct, S, otm, width): kp = S * (1 - otm); kc = S * (1 + otm) kp2 = S * (1 - otm - width); kc2 = S * (1 + otm + width) return { "short_put": [("P", kp, -1)], "short_call": [("C", kc, -1)], "short_strangle": [("P", kp, -1), ("C", kc, -1)], "put_spread": [("P", kp, -1), ("P", kp2, +1)], "call_spread": [("C", kc, -1), ("C", kc2, +1)], "iron_condor": [("P", kp, -1), ("P", kp2, +1), ("C", kc, -1), ("C", kc2, +1)], "long_put": [("P", kp, +1)], "long_call": [("C", kc, +1)], "long_straddle": [("P", S, +1), ("C", S, +1)], }[struct] def _price_real(struct, S, dte, scale, otm, width, cal): """Pricing REALE dalla superficie cerbero-bite. Ritorna (entry_cf_frac, legs, ok). entry_cf_frac = cassa d'ingresso in frazione di spot (>0 = incassi); side-aware bid/ask; ok=False se una gamba e' fuori dagli strike liquidi reali.""" legs = _legs_for(struct, S, otm, width) entry = 0.0 for typ, K, sgn in legs: q = _surf_lookup(cal, typ, K / S - 1.0, dte) if q is None: return 0.0, legs, False prem, spread, _ = q pf = prem / 100.0 * scale # premio frazione di spot, scalato a DVOL del giorno if sgn < 0: # short: incassi il BID (~ ask*(1-spread)) entry += pf * (1 - spread) else: # long: paghi l'ASK entry -= pf return entry, legs, True def _price(struct, S, T, atm, otm, width): """Fallback SINTETICO (BS+skew). Usato solo se manca la calibrazione reale.""" legs = _legs_for(struct, S, otm, width) prem = gross = 0.0 for typ, K, sgn in legs: px = bs_put(S, K, T, iv_skew(np.log(K / S), atm)) if typ == "P" \ else bs_call(S, K, T, iv_skew(np.log(K / S), atm)) prem += -sgn * px / S gross += abs(px) / S return prem - 0.06 * gross, legs, True def _payoff(legs, ST): v = 0.0 for typ, K, sgn in legs: intr = max(K - ST, 0.0) if typ == "P" else max(ST - K, 0.0) v += sgn * intr # valore terminale delle opzioni che POSSIEDI/devi return v # per le short questo e' cio' che PAGHI (sgn<0 -> negativo = debito) def evaluate(data, spec, sl=None): """Backtest della struttura: roll giornaliero, hold dte giorni, PnL additivo. spec = {structure, otm, width, dte}. Ritorna metriche con scoring PNL+%win, >=10 tr/mese. """ c, dv = data["close"], data["dvol"] n = data["n"] s, e = (sl if sl else (0, n)) struct = spec["structure"] otm = float(spec["otm"]); width = float(spec.get("width", 0.05)) dte = int(spec["dte"]) T = dte / 365.0 cal = _load_calib(data["asset"]); dvol_chain = data["dvol_chain"] rets = [] i = s while i < e - dte: S0 = c[i]; atm = dv[i] if S0 <= 0 or atm <= 0: i += 1; continue if cal is not None: # PRICING REALE (cerbero-bite), scalato a DVOL del giorno scale = min(max(atm / dvol_chain, 0.3), 4.0) entry, legs, ok = _price_real(struct, S0, dte, scale, otm, width, cal) if not ok: # strike fuori dalla superficie liquida reale -> non eseguibile i += 1; continue net = entry + _payoff(legs, c[i + dte]) / S0 else: # fallback sintetico prem, legs, _ = _price(struct, S0, T, atm, otm, width) net = prem + _payoff(legs, c[i + dte]) / S0 rets.append(net) i += 1 # roll giornaliero (posizioni sovrapposte) rets = np.array(rets) nbars = e - s months = nbars / TRADING_DAYS_MONTH n_tr = len(rets) tpm = n_tr / months if months > 0 else 0.0 if n_tr == 0: return dict(n_trades=0, win_rate=0.0, pnl_pct=0.0, tpm=0.0, sharpe=0.0, avg_ret=0.0, qualified=False, fitness=-1e6) win = float(np.mean(rets > 0)) pnl = float(np.sum(rets)) * 100 avg = float(np.mean(rets)) * 100 sharpe = float(np.mean(rets) / (np.std(rets) + 1e-12) * np.sqrt(tpm * 12)) \ if np.std(rets) > 0 else 0.0 qualified = tpm >= MIN_TRADES_PER_MONTH fitness = pnl + 50.0 * win if not qualified: fitness = -1e6 + pnl return dict(n_trades=n_tr, win_rate=win, pnl_pct=pnl, tpm=tpm, sharpe=sharpe, avg_ret=avg, qualified=qualified, fitness=fitness) def splits3(data, train_frac=0.60, valid_frac=0.20): n = data["n"] c1 = int(n * train_frac); c2 = int(n * (train_frac + valid_frac)) return (0, c1), (c1, c2), (c2, n) if __name__ == "__main__": d = load_opt("ETH") print("loaded", d["n"], "giorni", str(d["dt"][0])[:10], "->", str(d["dt"][-1])[:10], "| dvol", round(float(np.nanmean(d["dvol"])), 2)) tr, va, te = splits3(d) for st in ["short_put", "short_strangle", "iron_condor", "long_straddle", "put_spread"]: sp = {"structure": st, "otm": 0.05, "width": 0.05, "dte": 14} f = evaluate(d, sp, None); o = evaluate(d, sp, te) print(f"{st:14} FULL pnl{f['pnl_pct']:8.0f} win{f['win_rate']*100:4.0f} tpm{f['tpm']:5.0f} " f"Sh{f['sharpe']:6.1f} | OOS pnl{o['pnl_pct']:8.0f} win{o['win_rate']*100:4.0f} Sh{o['sharpe']:6.1f}")