Files
resonance-engine/_iv_priced_in_check.py
T
2026-06-07 12:34:31 +07:00

198 lines
8.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""_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()