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()
|