""" 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']}")