""" CBOE (Chicago Board Options Exchange) data source. This module provides web scrapers/crawlers to download data from CBOE: - VIX Index historical data - VVIX (VIX of VIX) - VIX9D, VIX3M, VIX6M - VIX Futures term structure These are free, publicly available datasets that CBOE provides. """ import io import re from datetime import datetime, timedelta from pathlib import Path from typing import List, Optional, Dict, Tuple import pandas as pd import requests from bs4 import BeautifulSoup import logging from concurrent.futures import ThreadPoolExecutor, as_completed from tqdm import tqdm from src.data.base import BaseDataSource, DataFetchError, DataValidationError logger = logging.getLogger(__name__) # CBOE data URLs - Volatility Indices CBOE_INDEX_URLS = { 'VIX': 'https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX_History.csv', 'VVIX': 'https://cdn.cboe.com/api/global/us_indices/daily_prices/VVIX_History.csv', 'VIX9D': 'https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX9D_History.csv', 'VIX3M': 'https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX3M_History.csv', 'VIX6M': 'https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX6M_History.csv', 'VIX1Y': 'https://cdn.cboe.com/api/global/us_indices/daily_prices/VIX1Y_History.csv', 'SKEW': 'https://cdn.cboe.com/api/global/us_indices/daily_prices/SKEW_History.csv', } # Put/Call Ratio and Volume data URLs CBOE_PUTCALL_URLS = { 'TOTAL_PC': 'https://cdn.cboe.com/resources/options/volume_and_call_put_ratios/totalpc.csv', 'INDEX_PC': 'https://cdn.cboe.com/resources/options/volume_and_call_put_ratios/indexpc.csv', 'EQUITY_PC': 'https://cdn.cboe.com/resources/options/volume_and_call_put_ratios/equitypc.csv', 'VIX_PC': 'https://cdn.cboe.com/resources/options/volume_and_call_put_ratios/vixpc.csv', } # VIX Futures base URL pattern VIX_FUTURES_BASE_URL = 'https://cdn.cboe.com/data/us/futures/market_statistics/historical_data/VX/' class CBOEDataSource(BaseDataSource): """ Data source for CBOE public data. Provides access to: - Volatility indices (VIX, VVIX, VIX9D, etc.) - VIX Futures historical data Example: source = CBOEDataSource() vix = source.fetch_vix_index( start_date=datetime(2006, 1, 1), end_date=datetime.now() ) """ def __init__( self, cache_dir: Optional[Path] = None, cache_enabled: bool = True, cache_expiry_days: int = 1, request_timeout: int = 30 ): """ Initialize CBOE data source. Args: cache_dir: Directory for caching data. cache_enabled: Whether to cache downloaded data. cache_expiry_days: Days before cache expires. request_timeout: HTTP request timeout in seconds. """ super().__init__( name="cboe", cache_dir=cache_dir, cache_enabled=cache_enabled, cache_expiry_days=cache_expiry_days ) self.timeout = request_timeout self.session = requests.Session() self.session.headers.update({ 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ' 'AppleWebKit/537.36 (KHTML, like Gecko) ' 'Chrome/120.0.0.0 Safari/537.36' }) def get_available_series(self) -> List[str]: """Get list of available CBOE series.""" return list(CBOE_INDEX_URLS.keys()) + list(CBOE_PUTCALL_URLS.keys()) + ['VX_FUTURES'] def fetch( self, start_date: datetime, end_date: datetime, series: Optional[List[str]] = None, **kwargs ) -> pd.DataFrame: """ Fetch data from CBOE. Args: start_date: Start date for data retrieval. end_date: End date for data retrieval. series: List of series to fetch. Defaults to ['VIX']. Returns: DataFrame with series as columns and date index. """ if series is None: series = ['VIX'] data_frames = [] for series_id in series: if series_id == 'VX_FUTURES': # Handle futures separately df = self._fetch_vix_futures(start_date, end_date) elif series_id in CBOE_INDEX_URLS: df = self._fetch_index(series_id, start_date, end_date) elif series_id in CBOE_PUTCALL_URLS: df = self._fetch_putcall(series_id, start_date, end_date) else: logger.warning(f"Unknown CBOE series: {series_id}") continue if df is not None and not df.empty: data_frames.append(df) if not data_frames: raise DataFetchError("No data retrieved from CBOE") # Combine all series combined = pd.concat(data_frames, axis=1) combined = combined.sort_index() # Filter to requested date range combined = combined.loc[start_date:end_date] return combined def _fetch_index( self, series_id: str, start_date: datetime, end_date: datetime ) -> pd.DataFrame: """ Fetch a single volatility index from CBOE. Args: series_id: Index identifier (VIX, VVIX, etc.) start_date: Start date. end_date: End date. Returns: DataFrame with the index data. """ url = CBOE_INDEX_URLS.get(series_id) if not url: raise DataFetchError(f"Unknown series: {series_id}") logger.info(f"Fetching {series_id} from CBOE") try: response = self.session.get(url, timeout=self.timeout) response.raise_for_status() # Parse CSV df = pd.read_csv( io.StringIO(response.text), parse_dates=['DATE'], index_col='DATE' ) # Standardize column names df.columns = [f"{series_id}_{col}" for col in df.columns] # Filter to date range df = df.loc[start_date:end_date] logger.info( f"Fetched {series_id}: {len(df)} observations " f"({df.index.min()} to {df.index.max()})" ) return df except requests.RequestException as e: logger.error(f"Failed to fetch {series_id}: {e}") raise DataFetchError(f"Failed to fetch {series_id}: {e}") def _fetch_putcall( self, series_id: str, start_date: datetime, end_date: datetime ) -> pd.DataFrame: """ Fetch put/call ratio and volume data from CBOE. Args: series_id: Put/call series identifier (TOTAL_PC, INDEX_PC, etc.) start_date: Start date. end_date: End date. Returns: DataFrame with put/call data. """ url = CBOE_PUTCALL_URLS.get(series_id) if not url: raise DataFetchError(f"Unknown put/call series: {series_id}") logger.info(f"Fetching {series_id} put/call data from CBOE") try: response = self.session.get(url, timeout=self.timeout) response.raise_for_status() # Skip header rows (varies by file) lines = response.text.strip().split('\n') # Find the header row (contains DATE or Date) header_idx = 0 for i, line in enumerate(lines): if 'DATE' in line.upper() and 'RATIO' in line.upper(): header_idx = i break # Parse CSV from header row csv_text = '\n'.join(lines[header_idx:]) df = pd.read_csv(io.StringIO(csv_text)) # Standardize date column date_col = [c for c in df.columns if 'date' in c.lower()][0] df[date_col] = pd.to_datetime(df[date_col], format='mixed') df = df.set_index(date_col) df.index.name = 'DATE' # Standardize column names based on series prefix = series_id.replace('_PC', '') new_cols = {} for col in df.columns: col_lower = col.lower() if 'ratio' in col_lower or 'p/c' in col_lower: new_cols[col] = f"{prefix}_PC_RATIO" elif 'put' in col_lower and 'call' not in col_lower: new_cols[col] = f"{prefix}_PUT_VOL" elif 'call' in col_lower and 'put' not in col_lower: new_cols[col] = f"{prefix}_CALL_VOL" elif 'total' in col_lower: new_cols[col] = f"{prefix}_TOTAL_VOL" df = df.rename(columns=new_cols) # Keep only renamed columns df = df[[c for c in df.columns if prefix in c]] # Filter to date range df = df.loc[start_date:end_date] logger.info( f"Fetched {series_id}: {len(df)} observations " f"({df.index.min()} to {df.index.max()})" ) return df except Exception as e: logger.error(f"Failed to fetch {series_id}: {e}") raise DataFetchError(f"Failed to fetch {series_id}: {e}") def _fetch_vix_futures( self, start_date: datetime, end_date: datetime, max_workers: int = 5 ) -> pd.DataFrame: """ Fetch VIX futures historical data. Downloads individual contract files and constructs term structure. Args: start_date: Start date. end_date: End date. max_workers: Number of parallel download threads. Returns: DataFrame with futures term structure data. """ logger.info("Fetching VIX futures term structure from CBOE") # Get list of available contracts contract_urls = self._get_futures_contract_urls(start_date, end_date) if not contract_urls: logger.warning("No VIX futures contracts found") return pd.DataFrame() logger.info(f"Found {len(contract_urls)} VIX futures contracts to download") # Download contracts in parallel all_data = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(self._download_futures_contract, url): url for url in contract_urls } for future in tqdm(as_completed(futures), total=len(futures), desc="Downloading VIX futures"): url = futures[future] try: df = future.result() if df is not None and not df.empty: all_data.append(df) except Exception as e: logger.warning(f"Failed to download {url}: {e}") if not all_data: logger.warning("No VIX futures data downloaded") return pd.DataFrame() # Combine all contract data combined = pd.concat(all_data, ignore_index=True) # Process into term structure format term_structure = self._process_futures_to_term_structure(combined, start_date, end_date) return term_structure def _get_futures_contract_urls( self, start_date: datetime, end_date: datetime ) -> List[str]: """ Generate URLs for VIX futures contracts within date range. CBOE uses expiration date in filename: VX_YYYY-MM-DD.csv We need to fetch contracts that were active during our period. """ urls = [] # Generate monthly expiration dates (3rd Wednesday of each month, approximately) current = start_date.replace(day=1) while current <= end_date + timedelta(days=365): # Include contracts expiring up to 1 year after end # Find 3rd Wednesday first_day = current.replace(day=1) # Days until first Wednesday days_to_wed = (2 - first_day.weekday()) % 7 first_wed = first_day + timedelta(days=days_to_wed) # Third Wednesday third_wed = first_wed + timedelta(days=14) # Build URL expiry_str = third_wed.strftime('%Y-%m-%d') url = f"{VIX_FUTURES_BASE_URL}VX_{expiry_str}.csv" urls.append(url) # Move to next month if current.month == 12: current = current.replace(year=current.year + 1, month=1) else: current = current.replace(month=current.month + 1) return urls def _download_futures_contract(self, url: str) -> Optional[pd.DataFrame]: """ Download a single VIX futures contract file. Args: url: URL to the contract CSV. Returns: DataFrame with contract data, or None if failed. """ try: response = self.session.get(url, timeout=self.timeout) if response.status_code == 404: return None # Contract doesn't exist response.raise_for_status() df = pd.read_csv(io.StringIO(response.text)) # Extract expiry date from URL expiry_match = re.search(r'VX_(\d{4}-\d{2}-\d{2})\.csv', url) if expiry_match: df['Expiry'] = pd.to_datetime(expiry_match.group(1)) return df except requests.RequestException: return None def _process_futures_to_term_structure( self, df: pd.DataFrame, start_date: datetime, end_date: datetime ) -> pd.DataFrame: """ Process raw futures data into term structure format. Creates columns for front month (VX1), second month (VX2), etc. Args: df: Raw futures data with all contracts. start_date: Start date. end_date: End date. Returns: DataFrame with term structure columns. """ if df.empty: return pd.DataFrame() # Standardize column names df.columns = [col.strip().upper() for col in df.columns] # Find date column date_col = None for col in ['TRADE DATE', 'DATE', 'TRADE_DATE']: if col in df.columns: date_col = col break if date_col is None: logger.warning("Could not find date column in futures data") return pd.DataFrame() df['Date'] = pd.to_datetime(df[date_col]) # Find settle column settle_col = None for col in ['SETTLE', 'SETTLEMENT', 'CLOSE']: if col in df.columns: settle_col = col break if settle_col is None: logger.warning("Could not find settle column in futures data") return pd.DataFrame() # Convert EXPIRY to datetime if not already if 'EXPIRY' in df.columns: df['Expiry'] = pd.to_datetime(df['EXPIRY']) # For each date, rank contracts by expiry and create VX1, VX2, etc. term_structure_data = [] for date, group in df.groupby('Date'): if date < start_date or date > end_date: continue # Sort by expiry and filter to only future expiries group = group[group['Expiry'] > date].sort_values('Expiry') row = {'Date': date} for i, (_, contract) in enumerate(group.iterrows()): if i >= 9: # VX1 to VX9 break row[f'VX{i+1}'] = contract[settle_col] row[f'VX{i+1}_Expiry'] = contract['Expiry'] term_structure_data.append(row) result = pd.DataFrame(term_structure_data) if not result.empty: result = result.set_index('Date').sort_index() # Add derived term structure metrics if 'VX1' in result.columns and 'VX2' in result.columns: result['VX_Slope_1_2'] = result['VX2'] - result['VX1'] if 'VX1' in result.columns and 'VX4' in result.columns: result['VX_Slope_1_4'] = result['VX4'] - result['VX1'] logger.info(f"Processed VIX futures term structure: {len(result)} trading days") return result def validate(self, df: pd.DataFrame) -> bool: """ Validate CBOE data. Args: df: DataFrame to validate. Returns: True if valid. Raises: DataValidationError: If validation fails. """ if df.empty: raise DataValidationError("CBOE DataFrame is empty") if not isinstance(df.index, pd.DatetimeIndex): raise DataValidationError("CBOE DataFrame index is not DatetimeIndex") # Check for reasonable values in VIX columns vix_cols = [col for col in df.columns if 'VIX' in col.upper() or col.startswith('VX')] for col in vix_cols: # Skip expiry columns and slope columns (slopes can be negative in backwardation) if col.endswith('_Expiry') or 'Slope' in col: continue values = df[col].dropna() if len(values) > 0: if values.min() < 0: raise DataValidationError(f"Negative values in {col}") if values.max() > 200: # VIX rarely exceeds 100 logger.warning(f"Very high values in {col}: max={values.max()}") logger.info(f"CBOE data validation passed: {len(df)} rows") return True def fetch_vix_index( self, start_date: datetime, end_date: datetime ) -> pd.DataFrame: """ Convenience method to fetch VIX index data. Args: start_date: Start date. end_date: End date. Returns: DataFrame with VIX data. """ return self.fetch_with_cache( start_date=start_date, end_date=end_date, series=['VIX'] ) def fetch_all_vix_indices( self, start_date: datetime, end_date: datetime ) -> pd.DataFrame: """ Fetch all available VIX-related indices. Args: start_date: Start date. end_date: End date. Returns: DataFrame with VIX, VVIX, VIX9D, VIX3M, VIX6M. """ return self.fetch_with_cache( start_date=start_date, end_date=end_date, series=['VIX', 'VVIX', 'VIX9D', 'VIX3M', 'VIX6M'] ) def fetch_vix_futures( self, start_date: datetime, end_date: datetime ) -> pd.DataFrame: """ Fetch VIX futures term structure. Args: start_date: Start date. end_date: End date. Returns: DataFrame with VX1-VX9 and term structure metrics. """ return self.fetch_with_cache( start_date=start_date, end_date=end_date, series=['VX_FUTURES'] ) if __name__ == "__main__": # Test the CBOE data source logging.basicConfig(level=logging.INFO) source = CBOEDataSource() # Fetch VIX index vix = source.fetch_vix_index( start_date=datetime(2020, 1, 1), end_date=datetime.now() ) print(f"\nVIX Index Data:") print(f"Shape: {vix.shape}") print(f"Columns: {vix.columns.tolist()}") print(f"Date Range: {vix.index.min()} to {vix.index.max()}")