MK_Quant_Monitor / cboe.py
maxkru92's picture
feat: vol-regime module
107af29 verified
Raw
History Blame Contribute Delete
20.7 kB
"""
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()}")