"""_iv_priced_in_check.py Pull Deribit BTC DVOL (1-min) for April 2026, join to HL microstructure data, and answer the project-killing question: At top-decile trade_count minutes, is implied vol elevated vs other minutes? If yes → market makers see the same signal, the straddle is already priced. If no → the signal is exploitable (modulo bid-ask). Methodology: - DVOL is BTC's 30-day forward at-the-money annualized IV (Deribit's index). Same construction as VIX for SPX. Public endpoint, no auth. - Convert DVOL to a fair-value short-tenor ATM straddle cost in bps via Brenner-Subrahmanyam: straddle/S ≈ 0.7979 · σ · sqrt(T) For 60-min horizon: cost_bps ≈ DVOL_pct · 0.853 - Compute trade_count decile per minute (April), report DVOL and implied fair-value 60-min straddle cost per decile. - "Edge if free options": E[|R_60|] - fair_value_straddle, per decile. Caveats noted in output: - Bid-ask not modelled; real Deribit ATM 1-day straddle costs another ~5-15bps of underlying depending on regime. - Shortest tradable Deribit tenor is 1-day; h=60 captures only the first hour of a 1-day option, so realised |R| underestimates option payoff at expiry. - DVOL is 30-day forward vol; short-tenor IV typically higher when there's intraday clustering. So this check is OPTIMISTIC for the strategy. """ from __future__ import annotations import glob, time, json from pathlib import Path import numpy as np import pandas as pd import requests DATA_GLOB = "/mnt/d/PaperTrader/research/hl_data/minutes/202604*" DVOL_OUT = Path("/mnt/d/Resonance_Engine/traj/deribit_btc_dvol_202604.parquet") API = "https://www.deribit.com/api/v2/public/get_volatility_index_data" # April 2026 in ms APRIL_START_MS = int(pd.Timestamp("2026-04-01 00:00:00", tz="UTC").timestamp() * 1000) APRIL_END_MS = int(pd.Timestamp("2026-05-01 00:00:00", tz="UTC").timestamp() * 1000) # Brenner-Subrahmanyam approx for ATM straddle cost # cost / S = sqrt(2/pi) * sigma * sqrt(T) ≈ 0.7979 * sigma * sqrt(T) HOURS_PER_YEAR = 24 * 365 def iv_pct_to_60min_straddle_bps(iv_pct: float) -> float: sigma = iv_pct / 100.0 T = 1.0 / HOURS_PER_YEAR # 60 min in years cost = 0.7979 * sigma * np.sqrt(T) return cost * 10000.0 def fetch_dvol_window(start_ms: int, end_ms: int, resolution: int = 60) -> list: """Fetch DVOL in chunks. Deribit returns up to ~5000 points per call but we paginate by time to be safe.""" all_rows = [] chunk_ms = 4 * 24 * 3600 * 1000 # 4 days per request cur = start_ms while cur < end_ms: nxt = min(cur + chunk_ms, end_ms) r = requests.get(API, params={ "currency": "BTC", "start_timestamp": cur, "end_timestamp": nxt, "resolution": resolution, }, timeout=30) r.raise_for_status() j = r.json() data = j.get("result", {}).get("data", []) all_rows.extend(data) print(f" fetched {len(data):>5} rows cum={len(all_rows):>6} " f"window={pd.Timestamp(cur, unit='ms', tz='UTC')} -> " f"{pd.Timestamp(nxt, unit='ms', tz='UTC')}") cur = nxt time.sleep(0.2) # be polite, public endpoint return all_rows def load_or_fetch_dvol() -> pd.DataFrame: if DVOL_OUT.exists(): print(f"loading cached DVOL: {DVOL_OUT}") return pd.read_parquet(DVOL_OUT) print(f"fetching DVOL from Deribit for April 2026 ...") rows = fetch_dvol_window(APRIL_START_MS, APRIL_END_MS, resolution=60) df = pd.DataFrame(rows, columns=["ts_ms", "open", "high", "low", "close"]) df["minute"] = pd.to_datetime(df["ts_ms"], unit="ms", utc=True).dt.tz_localize(None) df["dvol"] = df["close"] df = df[["minute", "dvol"]].drop_duplicates("minute").sort_values("minute").reset_index(drop=True) DVOL_OUT.parent.mkdir(parents=True, exist_ok=True) df.to_parquet(DVOL_OUT) print(f"saved {len(df)} rows -> {DVOL_OUT}") return df def load_btc_april() -> pd.DataFrame: dfs = [] for d in sorted(glob.glob(DATA_GLOB)): for f in sorted(glob.glob(f"{d}/*.parquet")): try: df = pd.read_parquet(f) except Exception: continue if "coin" in df.columns: df = df[df["coin"] == "BTC"] if len(df): dfs.append(df) df = pd.concat(dfs, ignore_index=True) df = df.sort_values("minute").drop_duplicates("minute").reset_index(drop=True) # `minute` is integer minutes-since-epoch -> convert to datetime df["minute"] = pd.to_datetime(df["minute"].astype("int64") * 60, unit="s", utc=True).dt.tz_localize(None) return df def add_fwd_60(df: pd.DataFrame) -> pd.DataFrame: p = df["mid_price"].astype(float).values fut = pd.Series(p).shift(-60).values df["fwd_60"] = (fut - p) / p df["abs_fwd_60"] = np.abs(df["fwd_60"]) return df def main(): pd.set_option("display.width", 200) pd.set_option("display.float_format", "{:+.3f}".format) print("=== loading data ===") btc = add_fwd_60(load_btc_april()) print(f" HL minutes: {len(btc)} (range {btc.minute.min()} -> {btc.minute.max()})") dvol = load_or_fetch_dvol() print(f" DVOL minutes: {len(dvol)} " f"(range {dvol.minute.min()} -> {dvol.minute.max()})") print(f" DVOL stats: mean={dvol.dvol.mean():.2f} std={dvol.dvol.std():.2f} " f"min={dvol.dvol.min():.2f} max={dvol.dvol.max():.2f}") print("\n=== joining ===") m = btc.merge(dvol, on="minute", how="inner") print(f" joined rows: {len(m)} ({len(m)/len(btc)*100:.1f}% of HL coverage)") # Per-decile of trade_count m = m.dropna(subset=["trade_count", "dvol", "abs_fwd_60"]).copy() m["dec"] = pd.qcut(m["trade_count"].rank(method="first"), 10, labels=False) m["fair_straddle_bps"] = m["dvol"].apply(iv_pct_to_60min_straddle_bps) m["abs_fwd_60_bps"] = m["abs_fwd_60"] * 10000 print(f"\n=== APRIL: TRADE_COUNT DECILE × DVOL ANALYSIS ===") print("(top decile = high activity; project killer = DVOL ramps with decile)") g = m.groupby("dec").agg( n=("dec", "size"), tc_mean=("trade_count", "mean"), dvol_mean=("dvol", "mean"), dvol_med=("dvol", "median"), fair_straddle_mean_bps=("fair_straddle_bps", "mean"), realized_mean_bps=("abs_fwd_60_bps", "mean"), realized_med_bps=("abs_fwd_60_bps", "median"), ) g["edge_free_options_bps"] = g["realized_mean_bps"] - g["fair_straddle_mean_bps"] g["edge_median_bps"] = g["realized_med_bps"] - g["fair_straddle_mean_bps"] g["dvol_pct_vs_mid"] = (g["dvol_mean"] / g["dvol_mean"].median() - 1) * 100 print(g.to_string()) # Top-decile vs bottom-decile DVOL: are MMs reacting to the signal? top, bot = g.iloc[-1], g.iloc[0] print(f"\n--- KEY READS ---") print(f"DVOL bottom-decile mean: {bot['dvol_mean']:.2f}") print(f"DVOL top-decile mean: {top['dvol_mean']:.2f}") print(f"DVOL top/bot ratio: {top['dvol_mean']/bot['dvol_mean']:.3f}x") print(f"DVOL delta (top - bot): {top['dvol_mean'] - bot['dvol_mean']:+.2f} pct") spearman = m.groupby("dec")["dvol"].mean().rank().corr( pd.Series(range(10)), method="spearman") print(f"Spearman(decile, DVOL_mean): {spearman:+.4f} " f"(+1 = perfect monotonic = MMs fully see the signal)") print(f"\nTop-decile realized 60m |R| mean : {top['realized_mean_bps']:.1f} bps") print(f"Top-decile fair-value straddle : {top['fair_straddle_mean_bps']:.1f} bps") print(f"Top-decile RAW EDGE (no bid-ask) : {top['edge_free_options_bps']:+.1f} bps") # Conservative bid-ask assumption: 10bps round-trip for short-dated BTC BID_ASK_BPS = 10.0 print(f"\nApply bid-ask {BID_ASK_BPS}bps round-trip:") print(f" Top-decile net edge: {top['edge_free_options_bps'] - BID_ASK_BPS:+.1f} bps") BID_ASK_BPS_WIDE = 20.0 print(f"Apply bid-ask {BID_ASK_BPS_WIDE}bps round-trip (low-vol regime):") print(f" Top-decile net edge: {top['edge_free_options_bps'] - BID_ASK_BPS_WIDE:+.1f} bps") # Conditional check: of top-decile firings, what fraction has realized > straddle? top_mask = m["dec"] == 9 top = m[top_mask] winners = (top["abs_fwd_60_bps"] > top["fair_straddle_bps"]).mean() * 100 print(f"\nTop-decile fraction where realized > fair-value straddle: {winners:.1f}%") print(f"(50% = no edge; >50% = real but bid-ask still matters)") if __name__ == "__main__": main()