MK_Quant_Monitor / cboe_adapter.py
maxkru92's picture
Upload cboe_adapter.py with huggingface_hub
b59acbe verified
Raw
History Blame Contribute Delete
13.9 kB
"""
CBOE Adapter Module
===================
Fetches and normalizes options chain data from CBOE delayed quotes API.
Supports:
- SPX (S&P 500 Index options)
- VIX (Volatility Index options)
- Other CBOE-listed index options
Functions:
- fetch_cboe_chain: Fetch raw CBOE options chain
- parse_cboe_chain: Parse raw JSON into normalized DataFrames
- fetch_spx_chain: Convenience function for SPX
- fetch_vix_chain: Convenience function for VIX
- get_spot_price: Get current spot price from CBOE
"""
import requests
import pandas as pd
import numpy as np
import os
import logging
from typing import Optional, Dict, Any, List, Tuple
from datetime import datetime, timedelta
logger = logging.getLogger("mk_quant.cboe")
CBOE_BASE_URL = "https://cdn.cboe.com/api/global/delayed_quotes/options"
# Map of supported symbols to their CBOE API identifiers
CBOE_SYMBOLS = {
"SPX": "SPX",
"VIX": "VIX",
"NDX": "NDX",
"RUT": "RUT",
"DJX": "DJX",
"SPY": "SPY",
"QQQ": "QQQ",
}
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Accept": "application/json",
}
def fetch_cboe_chain(symbol: str) -> Optional[Dict[str, Any]]:
"""
Fetch raw options chain from CBOE delayed quotes API.
Parameters
----------
symbol : CBOE symbol (e.g., 'SPX', 'VIX')
Returns
-------
Raw JSON response dict or None on failure
"""
cboe_symbol = CBOE_SYMBOLS.get(symbol.upper(), symbol.upper())
url = f"{CBOE_BASE_URL}/{cboe_symbol}.json"
demo_mode = os.environ.get("DEMO_MODE", "0") == "1"
if demo_mode:
logger.info(f"DEMO_MODE: Skipping CBOE fetch for {symbol}")
return None
try:
r = requests.get(url, headers=HEADERS, timeout=15)
r.raise_for_status()
data = r.json()
if "data" not in data:
logger.warning(f"CBOE response missing 'data' key for {symbol}")
return None
return data
except requests.exceptions.Timeout:
logger.warning(f"CBOE timeout for {symbol}")
except requests.exceptions.HTTPError as e:
logger.warning(f"CBOE HTTP error for {symbol}: {e}")
except Exception as e:
logger.warning(f"CBOE fetch error for {symbol}: {e}")
return None
def parse_cboe_chain(raw_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Parse raw CBOE JSON into normalized format.
Returns dict with:
- spot: Current spot price
- records: List of strike records with keys:
strike, expiry, option_type, iv, delta, gamma, theta, vega,
open_interest, bid, ask, last
- calls_df: DataFrame of call options
- puts_df: DataFrame of put options
- expirations: Sorted list of expiration dates
- timestamp: Fetch timestamp
"""
if not raw_data or "data" not in raw_data:
return {"spot": 0, "records": [], "calls_df": pd.DataFrame(),
"puts_df": pd.DataFrame(), "expirations": [], "timestamp": ""}
data = raw_data["data"]
spot = float(data.get("current_price", 0))
options = data.get("options", [])
timestamp = datetime.utcnow().isoformat()
if not options:
return {"spot": spot, "records": [], "calls_df": pd.DataFrame(),
"puts_df": pd.DataFrame(), "expirations": [], "timestamp": timestamp}
# Parse all options into records
records = []
calls_records = []
puts_records = []
for opt in options:
try:
strike = float(opt.get("strike", 0))
if strike <= 0:
continue
expiry = str(opt.get("expiration", ""))
otype = str(opt.get("option_type", "")).upper()
record = {
"strike": strike,
"expiry": expiry,
"option_type": otype,
"iv": float(opt.get("iv", 0) or 0),
"delta": float(opt.get("delta", 0) or 0),
"gamma": float(opt.get("gamma", 0) or 0),
"theta": float(opt.get("theta", 0) or 0),
"vega": float(opt.get("vega", 0) or 0),
"open_interest": int(opt.get("open_interest", 0) or 0),
"bid": float(opt.get("bid", 0) or 0),
"ask": float(opt.get("ask", 0) or 0),
"last": float(opt.get("last_price", 0) or 0),
"volume": int(opt.get("volume", 0) or 0),
}
records.append(record)
if otype == "C":
calls_records.append(record)
elif otype == "P":
puts_records.append(record)
except (ValueError, TypeError) as e:
logger.debug(f"Skipping malformed option record: {e}")
continue
calls_df = pd.DataFrame(calls_records) if calls_records else pd.DataFrame()
puts_df = pd.DataFrame(puts_records) if puts_records else pd.DataFrame()
# Get unique expirations sorted
expirations = sorted(set(r["expiry"] for r in records if r["expiry"]))
return {
"spot": spot,
"records": records,
"calls_df": calls_df,
"puts_df": puts_df,
"expirations": expirations,
"timestamp": timestamp,
"source": "cboe_live",
}
def fetch_spx_chain() -> Optional[Dict[str, Any]]:
"""
Fetch SPX options chain from CBOE.
Returns parsed chain dict or None on failure.
"""
raw = fetch_cboe_chain("SPX")
if raw is None:
return None
return parse_cboe_chain(raw)
def fetch_vix_chain() -> Optional[Dict[str, Any]]:
"""
Fetch VIX options chain from CBOE.
Returns parsed chain dict or None on failure.
"""
raw = fetch_cboe_chain("VIX")
if raw is None:
return None
return parse_cboe_chain(raw)
def get_spot_price(symbol: str) -> Optional[float]:
"""
Get current spot price for a CBOE-listed symbol.
Returns float price or None on failure.
"""
raw = fetch_cboe_chain(symbol)
if raw and "data" in raw:
return float(raw["data"].get("current_price", 0))
return None
def fetch_cboe_multi_expiry(symbol: str, max_expiries: int = 6) -> Dict[str, Any]:
"""
Fetch options chain and organize by expiry.
Returns dict with:
- spot: Current spot price
- by_expiry: Dict mapping expiry -> {calls_df, puts_df, records}
- all_records: All records combined
- expirations: List of expirations
"""
chain = fetch_cboe_chain(symbol)
if chain is None:
return {"spot": 0, "by_expiry": {}, "all_records": [], "expirations": []}
parsed = parse_cboe_chain(chain)
by_expiry = {}
for exp in parsed["expirations"][:max_expiries]:
exp_records = [r for r in parsed["records"] if r["expiry"] == exp]
exp_calls = [r for r in exp_records if r["option_type"] == "C"]
exp_puts = [r for r in exp_records if r["option_type"] == "P"]
by_expiry[exp] = {
"records": exp_records,
"calls_df": pd.DataFrame(exp_calls) if exp_calls else pd.DataFrame(),
"puts_df": pd.DataFrame(exp_puts) if exp_puts else pd.DataFrame(),
}
return {
"spot": parsed["spot"],
"by_expiry": by_expiry,
"all_records": parsed["records"],
"expirations": parsed["expirations"][:max_expiries],
"timestamp": parsed["timestamp"],
}
def generate_synthetic_chain(
spot: float = 6632.0,
n_strikes: int = 48,
strike_step: int = 25,
expiries: List[str] = None,
) -> Dict[str, Any]:
"""
Generate synthetic options chain for demo/testing.
Creates realistic-looking options data with proper skew and gamma profile.
"""
rng = np.random.default_rng(42)
if expiries is None:
# Generate 4 weekly + 2 monthly expiries
today = datetime.utcnow().date()
expiries = []
for i in range(1, 5):
d = today + timedelta(weeks=i)
expiries.append(d.strftime("%Y-%m-%d"))
for i in range(1, 3):
d = today + timedelta(days=30*i)
expiries.append(d.strftime("%Y-%m-%d"))
center = spot
strikes = np.arange(center - (n_strikes//2)*strike_step,
center + (n_strikes//2)*strike_step + 1,
strike_step)
all_records = []
for exp in expiries:
try:
exp_date = datetime.strptime(exp, "%Y-%m-%d").date()
dte = max(1, (exp_date - datetime.utcnow().date()).days)
except:
dte = 30
T = dte / 365.0
for strike in strikes:
atm_dist = (strike - spot) / spot
# Realistic IV skew
base_iv = 0.18 + abs(atm_dist) * 0.3 - atm_dist * 0.05
iv_call = max(0.05, base_iv + rng.normal(0, 0.005))
iv_put = max(0.05, iv_call + 0.01 + max(0.0, -atm_dist * 0.08) + rng.normal(0, 0.005))
# Gamma profile (peaks ATM, decays with distance)
gamma_base = 0.003 * np.exp(-50 * atm_dist**2)
gamma_call = max(0.00001, gamma_base * (1 + rng.normal(0, 0.1)))
gamma_put = max(0.00001, gamma_base * (1 + rng.normal(0, 0.1)))
# Delta
if T > 0 and iv_call > 0:
d1 = (np.log(spot/strike) + (0.5 * iv_call**2) * T) / (iv_call * np.sqrt(T))
delta_call = float(max(0.01, min(0.99, 0.5 + d1 * 0.4)))
else:
delta_call = 0.5
if T > 0 and iv_put > 0:
d1 = (np.log(spot/strike) + (0.5 * iv_put**2) * T) / (iv_put * np.sqrt(T))
delta_put = float(max(-0.99, min(-0.01, -0.5 + d1 * 0.4)))
else:
delta_put = -0.5
# OI (higher ATM, lower wings)
oi_base = abs(rng.normal(8000, 3000))
oi_factor = np.exp(-30 * atm_dist**2) + 0.1
oi_call = int(oi_base * oi_factor)
oi_put = int(oi_base * oi_factor * 1.2) # Slightly more put OI
# Theta
theta_call = -gamma_call * spot * spot * iv_call / (2 * np.sqrt(T)) / 365 if T > 0 else 0
theta_put = -gamma_put * spot * spot * iv_put / (2 * np.sqrt(T)) / 365 if T > 0 else 0
# Vega
vega_val = spot * np.sqrt(T) * np.exp(-0.5 * ((np.log(spot/strike)/iv_call)**2 if iv_call > 0 else 0)) * 0.01 if T > 0 and iv_call > 0 else 0
# Vanna
vanna_val = -gamma_call * (1 - delta_call) / iv_call if iv_call > 0 else 0
# Bid/ask (synthetic spread)
intrinsic_c = max(0, spot - strike)
time_value_c = max(0.01, iv_call * spot * np.sqrt(T) * 0.4)
mid_c = intrinsic_c + time_value_c
spread_c = max(0.1, mid_c * 0.02)
intrinsic_p = max(0, strike - spot)
time_value_p = max(0.01, iv_put * spot * np.sqrt(T) * 0.4)
mid_p = intrinsic_p + time_value_p
spread_p = max(0.1, mid_p * 0.02)
# Call record
all_records.append({
"strike": float(strike),
"expiry": exp,
"option_type": "C",
"iv": round(iv_call, 4),
"delta": round(delta_call, 4),
"gamma": round(gamma_call, 6),
"theta": round(theta_call, 4),
"vega": round(vega_val, 4),
"vanna": round(vanna_val, 4),
"open_interest": oi_call,
"bid": round(max(0.01, mid_c - spread_c/2), 2),
"ask": round(mid_c + spread_c/2, 2),
"last": round(mid_c, 2),
"volume": int(abs(rng.normal(1000, 500))),
})
# Put record
all_records.append({
"strike": float(strike),
"expiry": exp,
"option_type": "P",
"iv": round(iv_put, 4),
"delta": round(delta_put, 4),
"gamma": round(gamma_put, 6),
"theta": round(theta_put, 4),
"vega": round(vega_val, 4),
"vanna": round(vanna_val, 4),
"open_interest": oi_put,
"bid": round(max(0.01, mid_p - spread_p/2), 2),
"ask": round(mid_p + spread_p/2, 2),
"last": round(mid_p, 2),
"volume": int(abs(rng.normal(1000, 500))),
})
calls_df = pd.DataFrame([r for r in all_records if r["option_type"] == "C"])
puts_df = pd.DataFrame([r for r in all_records if r["option_type"] == "P"])
return {
"spot": spot,
"records": all_records,
"calls_df": calls_df,
"puts_df": puts_df,
"expirations": expiries,
"timestamp": datetime.utcnow().isoformat(),
"source": "synthetic",
}
if __name__ == "__main__":
# Test live fetch
print("Testing CBOE SPX chain fetch...")
chain = fetch_spx_chain()
if chain:
print(f"Spot: {chain['spot']}")
print(f"Records: {len(chain['records'])}")
print(f"Expirations: {chain['expirations'][:5]}")
if not chain['calls_df'].empty:
print(f"Calls columns: {list(chain['calls_df'].columns)}")
print(chain['calls_df'].head(3).to_string())
else:
print("Live fetch failed, generating synthetic chain...")
synth = generate_synthetic_chain()
print(f"Synthetic spot: {synth['spot']}")
print(f"Synthetic records: {len(synth['records'])}")
print(f"Synthetic expirations: {synth['expirations']}")