research(wave-0822): ledger degli esiti — ADAPTIVE-HORIZON scartato
This commit is contained in:
@@ -0,0 +1,618 @@
|
||||
"""r0822_skew.py — la FORMA della superficie di volatilita' (skew), mai usata dal progetto.
|
||||
|
||||
Il progetto ha usato molte volte il LIVELLO della vol implicita (DVOL/ATM): VRP01 lo vende sotto
|
||||
gate IV-rank, DVOLSPREAD ne fa relative-value BTC/ETH, TP01xDVOL lo prova come denominatore del
|
||||
vol-target. Non ha MAI usato la FORMA: il risk reversal 25-delta (RR), il butterfly (BF) e la loro
|
||||
dinamica. La catena per-strike esiste da 2026-05-01 (`data/raw/cb_chain/`), quindi la domanda e'
|
||||
finalmente misurabile.
|
||||
|
||||
Tre domande, in ordine di valore dichiarato nel brief:
|
||||
Q1 lo skew ANTICIPA lo spot? ("il posizionamento in opzioni anticipa il prezzo")
|
||||
Q2 lo skew e' un GATE DI RISCHIO sopra il libro TP01?
|
||||
Q3 lo skew spiega l'errore di PREZZATURA di VRP01 (f=0.73 misurato il 30/07)?
|
||||
|
||||
Onesta' strutturale, dichiarata PRIMA dei numeri:
|
||||
* La finestra utile e' 2026-06-09 -> oggi, cioe' ~75 giorni, NON i 113 giorni di righe presenti.
|
||||
Prima del 09/06 cerbero-bite raccoglieva UNA SOLA scadenza per ora (verificato: `exps=1`),
|
||||
quindi non esiste ne' struttura a termine ne' maturita' costante. Righe presenti != dato
|
||||
presente (regola 8 del brief), e qui il conto va fatto in ORE DI SUPERFICIE RICOSTRUIBILE.
|
||||
* HOLDOUT del progetto = 2025-01-01: l'INTERO campione e' posteriore. Non esiste hold-out,
|
||||
non esiste in-sample -> `study_family_honest` e `select_cell_insample` NON sono eseguibili
|
||||
(l'in-sample e' vuoto). Il verdetto massimo ottenibile e' LEAD con gate pre-registrato.
|
||||
* Con ~75 osservazioni giornaliere SE(Sharpe annualizzato) ~ sqrt(365/75) ~ 2.2: qualunque
|
||||
Sharpe giornaliero misurato qui e' indistinguibile da zero. Percio' il peso della sessione
|
||||
sta dove la POTENZA c'e' davvero: la relazione oraria skew<->prezzo (N~1.800) e la
|
||||
decomposizione di prezzo di VRP01 (deterministica, non statistica).
|
||||
|
||||
Convenzioni di costruzione (sono SCELTE, non dettagli — vedi sezione 1):
|
||||
* "quotato" = bid>0 AND ask>0 AND ask>=bid AND iv/delta non nulli. Si contano le quote, non le righe.
|
||||
* smile per (asset, ora, scadenza): IV interpolata LINEARMENTE in |delta| a 0.25 e 0.50,
|
||||
senza MAI estrapolare (se la catena non abbraccia il delta bersaglio, la cella si scarta).
|
||||
* maturita' costante: RR/BF/ATM interpolati fra le due scadenze che abbracciano T (7g e 30g).
|
||||
Il roll e' CONTINUO per costruzione: non c'e' una data di roll, quindi non c'e' il dente di
|
||||
sega di tenore che un "scadenza piu' vicina a T" iniettera' nel segnale.
|
||||
* ogni ora porta `ts_max` = il timestamp piu' recente fra le quote usate. I rendimenti in avanti
|
||||
partono STRETTAMENTE dopo `ts_max`: senza questo l'ancora e' sbagliata di ~30 minuti e nasce
|
||||
un falso "lo skew anticipa di 15 minuti" (misurato in sessione: vedi sezione 2).
|
||||
|
||||
Uso: nice -n 19 timeout 900 uv run python scripts/research/r0822_skew.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import glob
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
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" / "alt"))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
|
||||
import altlib as A # noqa: E402
|
||||
import cblib as CB # noqa: E402
|
||||
|
||||
CHAIN = ROOT / "data" / "raw" / "cb_chain"
|
||||
SCRATCH = Path(os.environ.get("SKEW_SCRATCH", "/tmp/claude-1001/-opt-docker-PythagorasGoal/"
|
||||
"b6cc75e7-14f8-4c32-bd07-ab8a0d2aaee6/scratchpad"))
|
||||
ASSETS = ("BTC", "ETH")
|
||||
COLS = ["ts", "asset", "instrument_name", "option_type", "strike", "exp",
|
||||
"bid", "ask", "mid", "iv", "delta", "open_interest", "quote_status"]
|
||||
|
||||
# tick MISURATO sui dati (non assunto): 0.0001 sotto 0.005 di premio, 0.0005 sopra.
|
||||
def tick_of(prezzo: np.ndarray) -> np.ndarray:
|
||||
return np.where(np.asarray(prezzo, float) < 0.005, 0.0001, 0.0005)
|
||||
|
||||
|
||||
def rule(t: str) -> None:
|
||||
print("\n" + "=" * 78)
|
||||
print(t)
|
||||
print("=" * 78)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 0. DATO
|
||||
# =============================================================================
|
||||
def load_quoted() -> tuple[pd.DataFrame, dict]:
|
||||
"""Catena filtrata alle QUOTE vere. Ritorna anche la contabilita' righe-vs-quote."""
|
||||
parts, n_righe, n_status = [], 0, {}
|
||||
for f in sorted(glob.glob(str(CHAIN / "*.parquet"))):
|
||||
d = pd.read_parquet(f, columns=COLS)
|
||||
n_righe += len(d)
|
||||
for k, v in d["quote_status"].value_counts(dropna=False).items():
|
||||
n_status[str(k)] = n_status.get(str(k), 0) + int(v)
|
||||
d["ts"] = pd.to_datetime(d["ts"], utc=True).dt.tz_convert("UTC")
|
||||
d["exp"] = pd.to_datetime(d["exp"], utc=True).dt.tz_convert("UTC")
|
||||
d["strike"] = d["strike"].astype(float)
|
||||
parts.append(d)
|
||||
d = pd.concat(parts, ignore_index=True)
|
||||
d = d.drop_duplicates(subset=["ts", "instrument_name"], keep="last")
|
||||
d["ts"] = pd.DatetimeIndex(d["ts"]).as_unit("ns")
|
||||
d["exp"] = pd.DatetimeIndex(d["exp"]).as_unit("ns")
|
||||
d["dte"] = (d["exp"] - d["ts"]).dt.total_seconds() / 86400.0
|
||||
|
||||
ok = (d["bid"] > 0) & (d["ask"] > 0) & (d["ask"] >= d["bid"]) & d["iv"].notna() & d["delta"].notna()
|
||||
cont = dict(righe=n_righe, dedup=int(len(d)), quotate=int(ok.sum()),
|
||||
frac_quotate=round(float(ok.mean()), 4), quote_status=n_status)
|
||||
q = d[ok & (d["dte"] > 0.5) & (d["dte"] < 95)].copy()
|
||||
q["hr"] = q["ts"].dt.floor("h")
|
||||
q["ad"] = q["delta"].abs()
|
||||
# una riga per (ora, strumento): se in un'ora ci sono piu' giri, vale l'ULTIMO
|
||||
q = q.sort_values("ts").drop_duplicates(subset=["hr", "asset", "instrument_name"], keep="last")
|
||||
return q, cont
|
||||
|
||||
|
||||
def conta_tick(q: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Quanti tick vale lo spread, e quanti tick vale l'ask? (regola: prima di credere a un
|
||||
rapporto estremo su un prezzo piccolo, contare i tick)."""
|
||||
out = []
|
||||
for a in ASSETS:
|
||||
for nome, m in (("ala |d|~0.10", (q["ad"] > 0.06) & (q["ad"] < 0.14)),
|
||||
("25-delta", (q["ad"] > 0.20) & (q["ad"] < 0.30)),
|
||||
("ATM |d|~0.50", (q["ad"] > 0.45) & (q["ad"] < 0.55))):
|
||||
g = q[(q["asset"] == a) & m]
|
||||
if g.empty:
|
||||
continue
|
||||
tk = tick_of(g["ask"].to_numpy())
|
||||
spread_tick = (g["ask"].to_numpy() - g["bid"].to_numpy()) / tk
|
||||
ask_tick = g["ask"].to_numpy() / tk
|
||||
out.append(dict(asset=a, banda=nome, n=len(g),
|
||||
ask_tick_med=round(float(np.median(ask_tick)), 1),
|
||||
ask_tick_p5=round(float(np.percentile(ask_tick, 5)), 1),
|
||||
frac_ask_le2tick=round(float((ask_tick <= 2).mean()), 4),
|
||||
spread_tick_med=round(float(np.median(spread_tick)), 1),
|
||||
spread_su_mid_med=round(float(np.median(
|
||||
(g["ask"] - g["bid"]) / g["mid"].replace(0, np.nan))), 3)))
|
||||
return pd.DataFrame(out)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 1. SUPERFICIE: smile -> RR/BF a maturita' costante
|
||||
# =============================================================================
|
||||
def _iv_at(ad: np.ndarray, iv: np.ndarray, tgt: float) -> float:
|
||||
o = np.argsort(ad)
|
||||
ad, iv = ad[o], iv[o]
|
||||
if len(ad) < 2 or tgt < ad[0] or tgt > ad[-1]:
|
||||
return np.nan # nessuna estrapolazione, mai
|
||||
return float(np.interp(tgt, ad, iv))
|
||||
|
||||
|
||||
def build_smile(q: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Per (asset, ora, scadenza): IV a |delta|=0.25 su entrambe le ali e ATM a |delta|=0.50."""
|
||||
cache = SCRATCH / "r0822_smile.parquet"
|
||||
if cache.exists():
|
||||
try:
|
||||
return pd.read_parquet(cache)
|
||||
except Exception:
|
||||
pass
|
||||
rows = []
|
||||
for (asset, hr, exp), g in q.groupby(["asset", "hr", "exp"], sort=False):
|
||||
c = g[g["option_type"] == "C"]
|
||||
p = g[g["option_type"] == "P"]
|
||||
if len(c) < 2 or len(p) < 2:
|
||||
continue
|
||||
c25 = _iv_at(c["ad"].to_numpy(), c["iv"].to_numpy(), 0.25)
|
||||
p25 = _iv_at(p["ad"].to_numpy(), p["iv"].to_numpy(), 0.25)
|
||||
if not (np.isfinite(c25) and np.isfinite(p25)):
|
||||
continue
|
||||
c50 = _iv_at(c["ad"].to_numpy(), c["iv"].to_numpy(), 0.50)
|
||||
p50 = _iv_at(p["ad"].to_numpy(), p["iv"].to_numpy(), 0.50)
|
||||
atm = np.nanmean([x for x in (c50, p50) if np.isfinite(x)]) if (
|
||||
np.isfinite(c50) or np.isfinite(p50)) else np.nan
|
||||
rows.append((asset, hr, exp, float(g["dte"].iloc[0]), c25, p25, atm,
|
||||
g["ts"].max(), int(len(g))))
|
||||
R = pd.DataFrame(rows, columns=["asset", "hr", "exp", "dte", "ivc25", "ivp25",
|
||||
"ivatm", "ts_max", "n_gambe"])
|
||||
R["rr"] = R["ivc25"] - R["ivp25"]
|
||||
R["bf"] = 0.5 * (R["ivc25"] + R["ivp25"]) - R["ivatm"]
|
||||
try:
|
||||
SCRATCH.mkdir(parents=True, exist_ok=True)
|
||||
R.to_parquet(cache)
|
||||
except Exception:
|
||||
pass
|
||||
return R
|
||||
|
||||
|
||||
def const_maturity(R: pd.DataFrame, asset: str, T: float) -> pd.DataFrame:
|
||||
"""RR/BF/ATM a maturita' costante T giorni. Interpolazione lineare in DTE fra le due scadenze
|
||||
che ABBRACCIANO T (mai estrapolazione) -> il roll e' continuo, non c'e' data di roll."""
|
||||
g = R[(R["asset"] == asset)].dropna(subset=["rr", "ivatm"])
|
||||
out = {}
|
||||
for hr, h in g.groupby("hr"):
|
||||
h = h.sort_values("dte")
|
||||
dt = h["dte"].to_numpy()
|
||||
if len(h) < 2 or T < dt[0] or T > dt[-1]:
|
||||
continue
|
||||
out[hr] = dict(rr=float(np.interp(T, dt, h["rr"].to_numpy())),
|
||||
bf=float(np.interp(T, dt, h["bf"].to_numpy())),
|
||||
atm=float(np.interp(T, dt, h["ivatm"].to_numpy())),
|
||||
ts_max=h["ts_max"].max())
|
||||
S = pd.DataFrame.from_dict(out, orient="index").sort_index()
|
||||
S.index.name = "hr"
|
||||
if len(S):
|
||||
S["rr_n"] = S["rr"] / S["atm"] # skew normalizzato dal livello (toglie il DVOL)
|
||||
return S
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 2. Q1 — lo skew ANTICIPA lo spot?
|
||||
# =============================================================================
|
||||
def px5m(asset: str, t0: pd.Timestamp) -> pd.Series:
|
||||
p = pd.read_parquet(ROOT / "data" / "raw" / f"{asset.lower()}_5m.parquet",
|
||||
columns=["timestamp", "close"])
|
||||
s = pd.Series(p["close"].to_numpy(float),
|
||||
index=pd.DatetimeIndex(pd.to_datetime(p["timestamp"], unit="ms", utc=True)))
|
||||
return s.sort_index().loc[t0 - pd.Timedelta("3h"):]
|
||||
|
||||
|
||||
def leadlag(S: pd.DataFrame, asset: str) -> pd.DataFrame:
|
||||
"""Correlazione fra la variazione oraria dello skew e i rendimenti PRIMA e DOPO l'istante
|
||||
esatto in cui le quote sono state osservate (`ts_max`). L'ancora conta: con un'ancora
|
||||
assunta a :30 invece del `ts_max` vero compare un falso lead a +15m."""
|
||||
S = S.sort_values("ts_max")
|
||||
px = px5m(asset, S.index.min())
|
||||
anc = pd.Series(px.reindex(pd.DatetimeIndex(S["ts_max"]), method="ffill").to_numpy(),
|
||||
index=S.index)
|
||||
drr = S["rr"].diff()
|
||||
lvl = S["rr"]
|
||||
rows = []
|
||||
for mins in (-60, -30, -15, -5, 5, 15, 30, 60, 120, 240, 1440, 4320):
|
||||
tgt = pd.DatetimeIndex(S["ts_max"]) + pd.Timedelta(minutes=int(mins))
|
||||
p2 = pd.Series(px.reindex(tgt, method="ffill").to_numpy(), index=S.index)
|
||||
r = np.log(p2 / anc) if mins > 0 else np.log(anc / p2) # sempre "rendimento nel verso del tempo"
|
||||
j = pd.concat({"d": drr, "l": lvl, "r": r}, axis=1).dropna()
|
||||
n = len(j)
|
||||
rows.append(dict(asset=asset, minuti=mins, n=n,
|
||||
corr_dRR=round(float(j["d"].corr(j["r"])), 3),
|
||||
corr_RR=round(float(j["l"].corr(j["r"])), 3),
|
||||
se=round(1 / np.sqrt(max(n, 2)), 3),
|
||||
t_dRR=round(float(j["d"].corr(j["r"]) * np.sqrt(n)), 2)))
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def leadlag_ancora_sbagliata(S: pd.DataFrame, asset: str) -> pd.DataFrame:
|
||||
"""Controllo POSITIVO del punto precedente: la stessa misura con l'ancora ASSUNTA a :30
|
||||
dell'ora (invece del `ts_max` osservato) deve mostrare il falso lead. Se non lo mostra,
|
||||
la correzione non stava correggendo niente."""
|
||||
px = px5m(asset, S.index.min())
|
||||
fake = pd.DatetimeIndex(S.index) + pd.Timedelta("30min")
|
||||
anc = pd.Series(px.reindex(fake, method="ffill").to_numpy(), index=S.index)
|
||||
drr = S["rr"].diff()
|
||||
rows = []
|
||||
for mins in (5, 15, 30, 60):
|
||||
p2 = pd.Series(px.reindex(fake + pd.Timedelta(minutes=mins), method="ffill").to_numpy(),
|
||||
index=S.index)
|
||||
j = pd.concat({"d": drr, "r": np.log(p2 / anc)}, axis=1).dropna()
|
||||
rows.append(dict(asset=asset, minuti=mins, corr_dRR_ancora_finta=round(float(j["d"].corr(j["r"])), 3)))
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def daily_signal(S: pd.DataFrame, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Allinea la superficie alla griglia dei prezzi in modo CAUSALE.
|
||||
|
||||
Le barre del progetto sono open-labeled: la barra 1d etichettata D chiude alle 23:00 di D.
|
||||
Quindi al momento della decisione della barra D e' disponibile l'ultima ora <= D 23:00, e
|
||||
`eval_weights` la tiene durante D+1. Con merge_asof su epoca esplicita in millisecondi
|
||||
(lezione 01/07: `.view("int64")` su tz-aware non-ns sbaglia scala e broadcasta)."""
|
||||
bpd = A.bars_per_day(df)
|
||||
chiusura = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
|
||||
chiusura = chiusura + (pd.Timedelta("24h") - pd.Timedelta("1h") if bpd == 1 else pd.Timedelta(0))
|
||||
left = pd.DataFrame({"t_ms": chiusura.astype("datetime64[ms]").astype("int64")})
|
||||
right = pd.DataFrame({"t_ms": pd.DatetimeIndex(S["ts_max"]).astype("datetime64[ms]").astype("int64"),
|
||||
"rr": S["rr"].to_numpy(), "rr_n": S["rr_n"].to_numpy(),
|
||||
"bf": S["bf"].to_numpy(), "atm": S["atm"].to_numpy()}).sort_values("t_ms")
|
||||
m = pd.merge_asof(left.sort_values("t_ms"), right, on="t_ms", direction="backward",
|
||||
tolerance=int(pd.Timedelta("6h").total_seconds() * 1000))
|
||||
return m[["rr", "rr_n", "bf", "atm"]]
|
||||
|
||||
|
||||
def make_target(S: pd.DataFrame, feat: str, win: int, segno: int):
|
||||
"""target_fn(df, asset) -> posizione continua vol-targeted. Causale per costruzione:
|
||||
ogni feature usa solo z-score rolling su dati <= barra corrente, e `eval_weights` sposta."""
|
||||
def fn(df, asset=None):
|
||||
F = daily_signal(S, df)
|
||||
x = F["rr_n"] if feat.endswith("_n") else F["rr"]
|
||||
if feat.startswith("d"):
|
||||
x = x.diff()
|
||||
z = (x - x.rolling(win, min_periods=max(5, win // 2)).mean()) / \
|
||||
x.rolling(win, min_periods=max(5, win // 2)).std()
|
||||
d = np.tanh(np.nan_to_num(z.to_numpy(), nan=0.0)) * segno
|
||||
return A.vol_target(d, df, target_vol=0.20, leverage_cap=2.0)
|
||||
return fn
|
||||
|
||||
|
||||
def sharpe_boot(s: pd.Series, blocco: int = 10, n: int = 500, seed: int = 20260822) -> tuple:
|
||||
"""IC95% dello Sharpe con block bootstrap circolare (il segnale e' lentissimo: i giorni
|
||||
NON sono indipendenti, e una SE analitica mentirebbe)."""
|
||||
r = np.asarray(s.dropna().to_numpy(), float)
|
||||
T = len(r)
|
||||
if T < 20:
|
||||
return (np.nan, np.nan)
|
||||
rng = np.random.default_rng(seed)
|
||||
nb = int(np.ceil(T / blocco))
|
||||
out = []
|
||||
for _ in range(n):
|
||||
st = rng.integers(0, T, nb)
|
||||
idx = (st[:, None] + np.arange(blocco)[None, :]).ravel()[:T] % T
|
||||
x = r[idx]
|
||||
out.append(np.mean(x) / np.std(x) * np.sqrt(365.25) if np.std(x) > 0 else 0.0)
|
||||
return (round(float(np.percentile(out, 2.5)), 2), round(float(np.percentile(out, 97.5)), 2))
|
||||
|
||||
|
||||
def causality_finestra(S: pd.DataFrame, fn, asset: str) -> dict:
|
||||
"""`A.causality_ok` taglia il prefisso all'80%/92% della storia dei PREZZI (2024/2025):
|
||||
fuori dalla finestra dello skew, dove il segnale e' zero -> confronterebbe zeri con zeri,
|
||||
POTENZA NULLA (stessa trappola del self-check di SKH01: si campiona sugli EVENTI).
|
||||
Qui i tagli cadono DENTRO la finestra."""
|
||||
df = A.get(asset, "1d")
|
||||
full = np.nan_to_num(np.asarray(fn(df, asset), float))
|
||||
n = len(df)
|
||||
dts = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True))
|
||||
dentro = np.where(dts >= S.index.min())[0]
|
||||
peggio, controlli = 0.0, 0
|
||||
for frac in (0.4, 0.7, 0.9):
|
||||
cut = int(dentro[0] + frac * (n - dentro[0]))
|
||||
if cut <= dentro[0] + 5 or cut >= n:
|
||||
continue
|
||||
sub = df.iloc[:cut].reset_index(drop=True)
|
||||
s = np.nan_to_num(np.asarray(fn(sub, asset), float))
|
||||
coda = min(20, cut - dentro[0])
|
||||
peggio = max(peggio, float(np.max(np.abs(s[cut - coda:cut] - full[cut - coda:cut]))))
|
||||
controlli += 1
|
||||
return dict(ok=bool(peggio <= 1e-6 and controlli > 0), max_diff=round(peggio, 10),
|
||||
controlli=controlli, non_zero=int((full[dentro] != 0).sum()))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. Q2 — lo skew come GATE DI RISCHIO sopra TP01
|
||||
# =============================================================================
|
||||
def dd_of(r: np.ndarray) -> float:
|
||||
eq = np.cumprod(1.0 + np.nan_to_num(r))
|
||||
pk = np.maximum.accumulate(eq)
|
||||
return float(np.max((pk - eq) / pk)) if len(eq) else 0.0
|
||||
|
||||
|
||||
def sh_of(r: np.ndarray) -> float:
|
||||
r = np.asarray(r, float)
|
||||
return float(np.mean(r) / np.std(r) * np.sqrt(365.25)) if np.std(r) > 0 else 0.0
|
||||
|
||||
|
||||
def null_de_levering(base: np.ndarray, overlay: np.ndarray) -> dict:
|
||||
"""Il primo test di OGNI claim su meno drawdown: esiste k<=1 che, applicato al baseline,
|
||||
da' lo STESSO DD dell'overlay ma con Sharpe MIGLIORE? Se si', l'overlay e' solo meno leva."""
|
||||
dd_t = dd_of(overlay)
|
||||
lo, hi = 0.0, 1.0
|
||||
for _ in range(60):
|
||||
k = 0.5 * (lo + hi)
|
||||
if dd_of(k * base) > dd_t:
|
||||
hi = k
|
||||
else:
|
||||
lo = k
|
||||
k = 0.5 * (lo + hi)
|
||||
return dict(k=round(k, 3), dd_overlay=round(dd_t, 4), dd_base_k=round(dd_of(k * base), 4),
|
||||
sh_overlay=round(sh_of(overlay), 3), sh_base_k=round(sh_of(k * base), 3),
|
||||
superato=bool(sh_of(overlay) > sh_of(k * base)))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. Q3 — lo skew spiega l'errore di prezzo di VRP01?
|
||||
# =============================================================================
|
||||
def vrp_decomposizione(q: pd.DataFrame, cm7: dict) -> pd.DataFrame:
|
||||
"""Per ogni ingresso settimanale di VRP01 (stessa macchina di `cblib`, cosi' i numeri sono
|
||||
confrontabili con lo 0.718 pubblicato il 30/07), scompone il f in fattori MOLTIPLICATIVI:
|
||||
|
||||
f_tot = f_term x f_skew x f_markfit x f_spread
|
||||
|
||||
f_term = credito prezzato ad ATM 7g piatta / credito prezzato a DVOL30 piatta
|
||||
(quanto del difetto e' STRUTTURA A TERMINE: il sleeve prezza un'opzione a 7
|
||||
giorni con l'indice a 30 giorni)
|
||||
f_skew = credito prezzato a IV PER GAMBA / credito ad ATM 7g piatta
|
||||
(quanto e' SKEW: l'ala che si compra non vale la vol ATM)
|
||||
f_markfit = credito ai MID reali / credito a IV per gamba (controllo: deve stare a ~1,
|
||||
altrimenti la IV di Deribit non riprezza i propri mid e tutta la catena di
|
||||
ragionamento poggia su un modello invece che su un prezzo)
|
||||
f_spread = credito al fill CONSERVATIVO (vendi al bid, compri all'ask) / credito ai mid
|
||||
"""
|
||||
righe = []
|
||||
for a in ASSETS:
|
||||
S7 = cm7[a]
|
||||
E = CB.weekly_entries(a, q)
|
||||
for _, e in E.iterrows():
|
||||
prima = S7.index[S7.index <= e["ts"]]
|
||||
if len(prima) == 0 or (e["ts"] - prima[-1]) > pd.Timedelta("6h"):
|
||||
continue
|
||||
s = S7.loc[prima[-1]]
|
||||
T = e["dte"] / 365.25
|
||||
dvol = e["dvol_pct"] / 100.0
|
||||
atm7 = s["atm"] / 100.0
|
||||
c_mod = CB.bs_put(e["spot"], e["k_short"], T, dvol) - CB.bs_put(e["spot"], e["k_long"], T, dvol)
|
||||
c_atm = CB.bs_put(e["spot"], e["k_short"], T, atm7) - CB.bs_put(e["spot"], e["k_long"], T, atm7)
|
||||
c_sml = (CB.bs_put(e["spot"], e["k_short"], T, e["iv_short"] / 100.0)
|
||||
- CB.bs_put(e["spot"], e["k_long"], T, e["iv_long"] / 100.0))
|
||||
c_mid = e["cred_mid"]
|
||||
c_real = e["cred_real"]
|
||||
righe.append(dict(
|
||||
asset=a, ts=e["ts"], dte=round(e["dte"], 2), ivrank=round(e["ivrank"], 3),
|
||||
dvol=round(e["dvol_pct"], 2), atm7=round(s["atm"], 2),
|
||||
rr7=round(s["rr"], 2), bf7=round(s["bf"], 2),
|
||||
iv_short=round(e["iv_short"], 2), iv_long=round(e["iv_long"], 2),
|
||||
d_short=round(e["d_short"], 3), d_long=round(e["d_long"], 3),
|
||||
f_term=c_atm / c_mod if c_mod > 0 else np.nan,
|
||||
f_skew=c_sml / c_atm if c_atm > 0 else np.nan,
|
||||
f_markfit=c_mid / c_sml if c_sml > 0 else np.nan,
|
||||
f_spread=c_real / c_mid if c_mid > 0 else np.nan,
|
||||
f_tot_mid=c_mid / c_mod if c_mod > 0 else np.nan,
|
||||
f_tot=c_real / c_mod if c_mod > 0 else np.nan,
|
||||
quota_ala=CB.bs_put(e["spot"], e["k_long"], T, dvol) /
|
||||
CB.bs_put(e["spot"], e["k_short"], T, dvol),
|
||||
))
|
||||
return pd.DataFrame(righe)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
def main() -> None:
|
||||
t_start = time.time()
|
||||
rule("r0822_skew — SKEW (risk reversal 25-delta): la dimensione mai usata della superficie")
|
||||
|
||||
# ---------------------------------------------------------------- 0. DATO
|
||||
rule("0. IL DATO — quote, non righe")
|
||||
q, cont = load_quoted()
|
||||
print(f" righe totali nella catena : {cont['righe']:,}")
|
||||
print(f" dopo dedup (ts, strumento) : {cont['dedup']:,}")
|
||||
print(f" QUOTATE (bid>0, ask>0, iv/delta ok) : {cont['quotate']:,} = {cont['frac_quotate']:.1%}")
|
||||
print(f" quote_status : {cont['quote_status']}")
|
||||
print(f" usate (0.5<DTE<95, ultimo giro/ora) : {len(q):,}")
|
||||
print(f" finestra righe : {q['ts'].min()} -> {q['ts'].max()}")
|
||||
print("\n TICK (regola: prima di credere a un rapporto estremo su un prezzo piccolo, contarli)")
|
||||
print(conta_tick(q).to_string(index=False))
|
||||
|
||||
# ---------------------------------------------------------- 1. SUPERFICIE
|
||||
rule("1. SUPERFICIE — smile per delta, poi maturita' costante")
|
||||
t0 = time.time()
|
||||
R = build_smile(q)
|
||||
print(f" celle (asset, ora, scadenza) con smile ricostruibile: {len(R):,} [{time.time()-t0:.0f}s]")
|
||||
perday = R.groupby(["asset", pd.DatetimeIndex(R["hr"]).date]).agg(exps=("exp", "nunique"))
|
||||
print(f" giorni con >=2 scadenze (maturita' costante possibile): "
|
||||
f"{int((perday['exps'] >= 2).sum())} / {len(perday)} coppie asset-giorno")
|
||||
cm7, cm30 = {}, {}
|
||||
for a in ASSETS:
|
||||
cm7[a] = const_maturity(R, a, 7.0)
|
||||
cm30[a] = const_maturity(R, a, 30.0)
|
||||
s = cm30[a]
|
||||
print(f" {a}: CM30 ore={len(s):5d} giorni={len(np.unique(pd.DatetimeIndex(s.index).date)):3d}"
|
||||
f" {s.index.min()} -> {s.index.max()}")
|
||||
print(f" RR30 media {s['rr'].mean():+.2f} pp sd {s['rr'].std():.2f} "
|
||||
f"[{s['rr'].min():+.2f}, {s['rr'].max():+.2f}] ATM media {s['atm'].mean():.1f}% "
|
||||
f"BF30 media {s['bf'].mean():+.2f}")
|
||||
print(f" CM7 ore={len(cm7[a]):5d} RR7 media {cm7[a]['rr'].mean():+.2f} sd {cm7[a]['rr'].std():.2f}")
|
||||
print("\n ⚠ La finestra utile parte dal 2026-06-09 e NON dal 2026-05-01: prima di quella data")
|
||||
print(" cerbero-bite raccoglieva UNA sola scadenza per ora -> nessuna maturita' costante.")
|
||||
print(" righe presenti != superficie presente. Le ore perse sono ~38 giorni su 113.")
|
||||
|
||||
# ------------------------------------------------------------------ 2. Q1
|
||||
rule("2. Q1 — lo skew ANTICIPA lo spot? (potenza vera: N orario ~1.800)")
|
||||
LL = pd.concat([leadlag(cm30[a], a) for a in ASSETS], ignore_index=True)
|
||||
for a in ASSETS:
|
||||
print(f"\n {a} — corr fra dRR (variazione oraria dello skew) e il rendimento nella")
|
||||
print(" finestra indicata, ancorata al TIMESTAMP VERO delle quote (ts_max):")
|
||||
print(LL[LL["asset"] == a].to_string(index=False))
|
||||
print("\n Controllo POSITIVO — la stessa misura con l'ancora ASSUNTA a :30 dell'ora:")
|
||||
FF = pd.concat([leadlag_ancora_sbagliata(cm30[a], a) for a in ASSETS], ignore_index=True)
|
||||
print(FF.to_string(index=False))
|
||||
|
||||
# strategia giornaliera
|
||||
rule("2b. Q1 — lo skew come SEGNALE DIREZIONALE giornaliero (griglia dichiarata)")
|
||||
feats = ["rr", "rr_n", "drr"]
|
||||
wins = [10, 20, 40]
|
||||
segni = [+1, -1]
|
||||
celle, serie = [], {}
|
||||
for f in feats:
|
||||
for w in wins:
|
||||
for sg in segni:
|
||||
nome = f"{f}|w{w}|sg{sg:+d}"
|
||||
per_asset = {}
|
||||
for a in ASSETS:
|
||||
df = A.get(a, "1d")
|
||||
Sx = cm30[a]
|
||||
m = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) >= (
|
||||
Sx.index.min().floor("D"))
|
||||
sub = df[m].reset_index(drop=True)
|
||||
tgt = make_target(Sx, f, w, sg)(sub, a)
|
||||
ev = A.eval_weights(sub, tgt)
|
||||
per_asset[a] = pd.Series(ev["net"], index=ev["idx"])
|
||||
J = pd.concat(per_asset, axis=1, join="inner").fillna(0.0)
|
||||
d = 0.5 * J[ASSETS[0]] + 0.5 * J[ASSETS[1]]
|
||||
d.index = pd.DatetimeIndex(d.index)
|
||||
serie[nome] = d
|
||||
celle.append(dict(cella=nome, sharpe=round(sh_of(d.to_numpy()), 3),
|
||||
maxdd=round(dd_of(d.to_numpy()), 4),
|
||||
cagr=round(float((1 + d).prod() ** (365.25 / len(d)) - 1), 4)))
|
||||
C = pd.DataFrame(celle).sort_values("sharpe", ascending=False)
|
||||
print(f" griglia: {len(feats)} feature x {len(wins)} finestre-z x {len(segni)} segni = {len(C)} celle")
|
||||
print(C.head(6).to_string(index=False))
|
||||
print(" ...")
|
||||
print(C.tail(3).to_string(index=False))
|
||||
best = C.iloc[0]["cella"]
|
||||
bs = serie[best]
|
||||
lo, hi = sharpe_boot(bs)
|
||||
print(f"\n MIGLIORE della griglia: {best} Sharpe {C.iloc[0]['sharpe']:+.3f}")
|
||||
print(f" IC95% block-bootstrap (blocchi 10g, 500 estrazioni): [{lo:+.2f}, {hi:+.2f}] n={len(bs)} giorni")
|
||||
print(f" ⚠ ampiezza dell'IC = {hi-lo:.2f} di Sharpe: con {len(bs)} giorni la misura non separa")
|
||||
print(" un edge da zero. Nessuna cella di questa griglia e' distinguibile dal rumore.")
|
||||
dsr, sr0 = A.deflated_sharpe(C.iloc[0]["sharpe"], C["sharpe"].tolist(), bs)
|
||||
print(f" deflated Sharpe della cella migliore: DSR={dsr:.3f} (soglia 0.95), "
|
||||
f"max atteso sotto il nullo {sr0:+.2f}")
|
||||
print(" NB: e' un deflated-Sharpe BEST-OF-GRID, non in-sample-selected: `select_cell_insample`")
|
||||
print(" NON e' eseguibile qui (HOLDOUT=2025-01-01, l'in-sample e' VUOTO).")
|
||||
|
||||
print("\n marginal_vs_tp01 sulla cella migliore:")
|
||||
try:
|
||||
rep = A.marginal_vs_tp01(bs)
|
||||
for k in ("marginal_verdict", "corr_full", "n_days", "has_insample_edge", "is_hedge",
|
||||
"beats_noise_null", "robust_oos"):
|
||||
if k in rep:
|
||||
print(f" {k:20s} = {rep[k]}")
|
||||
except Exception as ex:
|
||||
print(f" non girato: {ex}")
|
||||
|
||||
print("\n causalita' (tagli DENTRO la finestra dello skew, non all'80% della storia prezzi):")
|
||||
for a in ASSETS:
|
||||
f_, w_, sg_ = best.split("|")
|
||||
fn = make_target(cm30[a], f_, int(w_[1:]), int(sg_.replace("sg", "")))
|
||||
print(f" {a}: {causality_finestra(cm30[a], fn, a)}")
|
||||
|
||||
# ------------------------------------------------------------------ 3. Q2
|
||||
rule("3. Q2 — lo skew come GATE DI RISCHIO sopra TP01")
|
||||
B = A.tp01_baseline_daily()
|
||||
inizio = min(cm30[a].index.min() for a in ASSETS).floor("D")
|
||||
Bw = B[B.index >= inizio]
|
||||
z_all = {}
|
||||
for a in ASSETS:
|
||||
df = A.get(a, "1d")
|
||||
m = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) >= inizio
|
||||
sub = df[m].reset_index(drop=True)
|
||||
F = daily_signal(cm30[a], sub)
|
||||
x = F["rr_n"]
|
||||
z = (x - x.rolling(20, min_periods=10).mean()) / x.rolling(20, min_periods=10).std()
|
||||
z_all[a] = pd.Series(z.to_numpy(), index=pd.DatetimeIndex(pd.to_datetime(sub["datetime"], utc=True)))
|
||||
Z = pd.concat(z_all, axis=1).mean(axis=1)
|
||||
Z = Z.reindex(Bw.index).ffill()
|
||||
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%}")
|
||||
for thr in (-0.5, -1.0, -1.5):
|
||||
gate = (Z.shift(1) > thr).astype(float) # de-risk quando lo skew si irripidisce (z basso)
|
||||
ov = (Bw * gate).to_numpy()
|
||||
n_fire = int((gate == 0).sum())
|
||||
nul = null_de_levering(Bw.to_numpy(), ov)
|
||||
gg = Bw[gate == 0]
|
||||
print(f" soglia z<{thr:+.1f}: giorni de-riskati {n_fire:3d}/{len(Bw)} "
|
||||
f"({n_fire/len(Bw):.1%}) Sharpe {sh_of(ov):+.2f} (base {sh_of(Bw.to_numpy()):+.2f}) "
|
||||
f"maxDD {dd_of(ov):.2%}")
|
||||
print(f" null de-levering: k={nul['k']} -> stesso DD {nul['dd_base_k']:.2%} con "
|
||||
f"Sharpe {nul['sh_base_k']:+.2f} vs overlay {nul['sh_overlay']:+.2f} "
|
||||
f"-> {'SUPERATO' if nul['superato'] else 'REFUTED'}")
|
||||
if n_fire:
|
||||
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)")
|
||||
print("\n ridondanza col trend — corr fra lo z dello skew e il rendimento di TP01: "
|
||||
f"{float(pd.concat({'z': Z, 'b': Bw}, axis=1).dropna().corr().iloc[0,1]):+.3f}")
|
||||
print(" POTENZA: con 75 giorni un gate che scatta ~10 volte non e' misurabile. Il numero da")
|
||||
print(" guardare non e' lo Sharpe dell'overlay ma il conto dei giorni in cui scatta.")
|
||||
|
||||
# ------------------------------------------------------------------ 4. Q3
|
||||
rule("4. Q3 — lo skew SPIEGA l'errore di prezzo di VRP01 (f=0.73 pubblicato il 30/07)")
|
||||
D = vrp_decomposizione(q, cm7)
|
||||
print(f" ingressi settimanali ricostruiti: {len(D)} ({D.groupby('asset').size().to_dict()})")
|
||||
print("\n REPLICA del numero pubblicato (deve tornare ~0.72 sul canonico 7g d-0.28/-0.10):")
|
||||
print(f" f_tot (fill conservativo) mediana = {D['f_tot'].median():.3f} "
|
||||
f"media = {D['f_tot'].mean():.3f}")
|
||||
print(f" f_tot ai MID mediana = {D['f_tot_mid'].median():.3f}")
|
||||
print("\n IV per gamba contro i due riferimenti (pp di vol):")
|
||||
print(f" IV(corta) - DVOL30 : {(D['iv_short']-D['dvol']).median():+.2f} pp "
|
||||
f"IV(lunga) - DVOL30 : {(D['iv_long']-D['dvol']).median():+.2f} pp [pubblicato: +0.8 / +7.5]")
|
||||
print(f" IV(corta) - ATM7 : {(D['iv_short']-D['atm7']).median():+.2f} pp "
|
||||
f"IV(lunga) - ATM7 : {(D['iv_long']-D['atm7']).median():+.2f} pp")
|
||||
print(f" ATM7 - DVOL30 : {(D['atm7']-D['dvol']).median():+.2f} pp <- pura struttura a termine")
|
||||
print("\n DECOMPOSIZIONE MOLTIPLICATIVA del f (mediane; f_tot = term x skew x markfit x spread)")
|
||||
for a in ASSETS + ("TUTTI",):
|
||||
g = D if a == "TUTTI" else D[D["asset"] == a]
|
||||
if g.empty:
|
||||
continue
|
||||
print(f" {a:6s} n={len(g):2d} | 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("\n controllo obbligatorio: f_markfit deve stare a ~1 (la IV di Deribit deve riprezzare")
|
||||
print(f" i suoi stessi mid). Mediana {D['f_markfit'].median():.3f}, "
|
||||
f"banda [{D['f_markfit'].quantile(.1):.3f}, {D['f_markfit'].quantile(.9):.3f}].")
|
||||
print(" Se sta a 1, RR e BF di questa sessione sono un fatto di PREZZO, non un artefatto")
|
||||
print(" del fit di Deribit -> vale anche come controllo di confound per Q1/Q2.")
|
||||
|
||||
print("\n Il f dipende dal REGIME? (la domanda che decide se 0.73 e' conservativo o ottimista)")
|
||||
D2 = D.dropna(subset=["f_tot_mid", "rr7"])
|
||||
for var in ("rr7", "atm7", "ivrank", "bf7"):
|
||||
c = float(D2["f_tot_mid"].corr(D2[var]))
|
||||
print(f" corr(f_tot_mid, {var:7s}) = {c:+.3f} [n={len(D2)}]")
|
||||
print(f"\n IV-rank agli ingressi: min {D['ivrank'].min():.3f} mediana {D['ivrank'].median():.3f} "
|
||||
f"max {D['ivrank'].max():.3f}")
|
||||
n_gate = int((D["ivrank"] > 0.30).sum())
|
||||
print(f" ingressi che passerebbero il gate IV-rank>0.30 di VRP01: {n_gate}/{len(D)}")
|
||||
print(" -> il f di VRP01 e' misurato INTERAMENTE nel regime in cui il sleeve sta FLAT.")
|
||||
|
||||
print("\n Tabella per ingresso:")
|
||||
cols = ["asset", "ts", "dte", "ivrank", "dvol", "atm7", "rr7", "iv_short", "iv_long",
|
||||
"f_term", "f_skew", "f_markfit", "f_spread", "f_tot_mid", "f_tot"]
|
||||
P = D[cols].copy()
|
||||
P["ts"] = pd.DatetimeIndex(P["ts"]).strftime("%Y-%m-%d %H:%M")
|
||||
for c in ("f_term", "f_skew", "f_markfit", "f_spread", "f_tot_mid", "f_tot"):
|
||||
P[c] = P[c].round(3)
|
||||
print(P.to_string(index=False))
|
||||
|
||||
rule(f"fine — {time.time()-t_start:.0f}s")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user