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