198 lines
8.4 KiB
Python
198 lines
8.4 KiB
Python
"""_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()
|