MK_Quant_Monitor / volatility.py
maxkru92's picture
Upload volatility.py with huggingface_hub
ed9c561 verified
Raw
History Blame Contribute Delete
21.5 kB
"""
Volatility Analysis Module
==========================
Computes realized volatility, implied volatility analysis,
term structure, skew metrics, and volatility regime detection.
Functions:
- realized_vol: Historical/realized volatility
- parkinson_vol: Parkinson high-low volatility estimator
- garman_klass_vol: Garman-Klass OHLC volatility estimator
- vol_cone: Volatility cone (percentiles over rolling windows)
- iv_term_structure: Implied vol term structure from chain
- iv_skew_metrics: Skew and smile metrics
- vol_regime: Volatility regime detection
- vol_risk_premium: IV - RV spread (vol risk premium)
- vix_fair_value: VIX fair value from options chain
- term_structure_slope: Contango/backwardation slope
"""
import numpy as np
import pandas as pd
from typing import Optional, Dict, List, Any, Tuple
from scipy import stats
# ── Realized Volatility ─────────────────────────────────────────────────────
def realized_vol(
prices: pd.Series,
window: int = 20,
annualize: bool = True
) -> pd.Series:
"""
Compute rolling realized volatility from price series.
Parameters
----------
prices : Price series (close prices)
window : Rolling window (trading days)
annualize : If True, multiply by sqrt(252)
Returns
-------
Series of realized volatility (decimal)
"""
if prices is None or len(prices) < 2:
return pd.Series(dtype=float)
log_returns = np.log(prices / prices.shift(1))
vol = log_returns.rolling(window=window).std()
if annualize:
vol = vol * np.sqrt(252)
return vol
def parkinson_vol(
high: pd.Series,
low: pd.Series,
window: int = 20,
annualize: bool = True
) -> pd.Series:
"""
Parkinson (1980) volatility estimator using high-low range.
More efficient than close-to-close estimator.
sigma^2 = (1 / (4 * N * ln(2))) * sum(ln(Hi/Li)^2)
"""
if high is None or low is None or len(high) < 2:
return pd.Series(dtype=float)
log_hl = np.log(high / low)
factor = 1.0 / (4.0 * np.log(2))
vol_sq = (log_hl ** 2).rolling(window=window).mean() * factor
vol = np.sqrt(vol_sq)
if annualize:
vol = vol * np.sqrt(252)
return vol
def garman_klass_vol(
open_: pd.Series,
high: pd.Series,
low: pd.Series,
close: pd.Series,
window: int = 20,
annualize: bool = True
) -> pd.Series:
"""
Garman-Klass (1980) volatility estimator using OHLC.
More efficient than Parkinson (uses open and close).
sigma^2 = 0.5 * ln(H/L)^2 - (2*ln(2)-1) * ln(C/O)^2
"""
if any(s is None for s in [open_, high, low, close]):
return pd.Series(dtype=float)
log_hl = np.log(high / low)
log_co = np.log(close / open_)
vol_sq = (0.5 * log_hl**2 - (2 * np.log(2) - 1) * log_co**2).rolling(window=window).mean()
vol = np.sqrt(vol_sq.clip(lower=0)) # Clip negative values
if annualize:
vol = vol * np.sqrt(252)
return vol
def rogers_satchell_vol(
open_: pd.Series,
high: pd.Series,
low: pd.Series,
close: pd.Series,
window: int = 20,
annualize: bool = True
) -> pd.Series:
"""
Rogers-Satchell (1991) volatility estimator.
Works with drifting assets (unlike Garman-Klass).
"""
if any(s is None for s in [open_, high, low, close]):
return pd.Series(dtype=float)
log_ho = np.log(high / open_)
log_hc = np.log(high / close)
log_lo = np.log(low / open_)
log_lc = np.log(low / close)
vol_sq = (log_ho * log_hc + log_lo * log_lc).rolling(window=window).mean()
vol = np.sqrt(vol_sq.clip(lower=0))
if annualize:
vol = vol * np.sqrt(252)
return vol
# ── Volatility Cone ─────────────────────────────────────────────────────────
def vol_cone(
prices: pd.Series,
windows: List[int] = None,
percentiles: List[float] = None,
) -> pd.DataFrame:
"""
Compute volatility cone.
Shows realized volatility percentiles across different time horizons.
Useful for determining if current IV is rich/cheap vs historical RV.
Parameters
----------
prices : Price series
windows : List of lookback windows (default: [5, 10, 20, 30, 60, 90, 120, 252])
percentiles : Percentiles to compute (default: [5, 25, 50, 75, 95])
Returns
-------
DataFrame with windows as rows, percentiles as columns
"""
if prices is None or len(prices) < 252:
return pd.DataFrame()
if windows is None:
windows = [5, 10, 20, 30, 60, 90, 120, 252]
if percentiles is None:
percentiles = [5, 25, 50, 75, 95]
log_returns = np.log(prices / prices.shift(1)).dropna()
data = {}
for w in windows:
if len(log_returns) < w:
continue
# Rolling vol for each possible window
rolling_vols = []
for i in range(w, len(log_returns)):
vol = log_returns.iloc[i-w:i].std() * np.sqrt(252)
rolling_vols.append(vol)
if rolling_vols:
row = {}
for p in percentiles:
row[f'p{p}'] = np.percentile(rolling_vols, p)
data[w] = row
if not data:
return pd.DataFrame()
df = pd.DataFrame(data).T
df.index.name = 'window'
return df
# ── IV Term Structure ──────────────────────────────────────────────────────
def iv_term_structure(
chain_records: List[Dict[str, Any]],
spot: float
) -> pd.DataFrame:
"""
Compute implied volatility term structure from options chain.
Groups by expiry and computes average ATM IV for each tenor.
Returns DataFrame with columns:
expiry, dte, atm_iv_call, atm_iv_put, avg_iv, skew
"""
if not chain_records:
return pd.DataFrame()
# Group by expiry
by_expiry: Dict[str, List[Dict]] = {}
for rec in chain_records:
exp = rec.get('expiry', 'unknown')
if exp not in by_expiry:
by_expiry[exp] = []
by_expiry[exp].append(rec)
rows = []
for exp, records in sorted(by_expiry.items()):
# Find ATM records (closest to spot)
atm_records = sorted(records, key=lambda r: abs(r.get('strike', 0) - spot))[:4]
if not atm_records:
continue
iv_calls = [r.get('iv_call', 0) for r in atm_records if r.get('iv_call', 0) > 0]
iv_puts = [r.get('iv_put', 0) for r in atm_records if r.get('iv_put', 0) > 0]
atm_iv_c = np.mean(iv_calls) if iv_calls else 0
atm_iv_p = np.mean(iv_puts) if iv_puts else 0
avg_iv = (atm_iv_c + atm_iv_p) / 2.0
skew = atm_iv_p - atm_iv_c
# Compute DTE
try:
exp_date = pd.to_datetime(exp)
dte = max(0, (exp_date - pd.Timestamp.now()).days)
except:
dte = 0
rows.append({
'expiry': exp,
'dte': dte,
'atm_iv_call': atm_iv_c,
'atm_iv_put': atm_iv_p,
'avg_iv': avg_iv,
'skew': skew,
})
return pd.DataFrame(rows).sort_values('dte').reset_index(drop=True)
def term_structure_slope(term_struct_df: pd.DataFrame) -> Dict[str, float]:
"""
Compute term structure slope metrics.
Returns dict with:
- front_month_iv: IV of nearest expiry
- back_month_iv: IV of furthest expiry
- slope: Back - Front (positive = contango)
- slope_pct: Slope as percentage of front month
- structure: 'Contango' or 'Backwardation'
"""
if term_struct_df.empty or len(term_struct_df) < 2:
return {
'front_month_iv': 0, 'back_month_iv': 0,
'slope': 0, 'slope_pct': 0, 'structure': 'N/A',
}
front = term_struct_df.iloc[0]['avg_iv']
back = term_struct_df.iloc[-1]['avg_iv']
slope = back - front
slope_pct = (slope / front * 100) if front > 0 else 0
return {
'front_month_iv': front,
'back_month_iv': back,
'slope': slope,
'slope_pct': slope_pct,
'structure': 'Contango' if slope > 0 else 'Backwardation',
}
# ── Skew Metrics ────────────────────────────────────────────────────────────
def compute_iv_skew(
chain_records: List[Dict[str, Any]],
spot: float
) -> Dict[str, Any]:
"""
Compute comprehensive IV skew metrics.
Returns dict with:
- raw_skew: Put IV - Call IV at same strike (average)
- delta_skew: 25d risk reversal
- butterfly: 25d butterfly
- smile_curvature: Second derivative of IV smile
- skew_slope: Slope of skew (IV change per 1% moneyness)
"""
if not chain_records:
return {
'raw_skew': 0, 'delta_skew': 0, 'butterfly': 0,
'smile_curvature': 0, 'skew_slope': 0,
}
# Compute moneyness and skew for each record
data = []
for rec in chain_records:
strike = rec.get('strike', 0)
if strike <= 0:
continue
iv_c = rec.get('iv_call', 0)
iv_p = rec.get('iv_put', 0)
moneyness = (strike / spot - 1) * 100 # Percent
data.append({
'strike': strike,
'moneyness': moneyness,
'iv_call': iv_c,
'iv_put': iv_p,
'raw_skew': iv_p - iv_c,
})
if not data:
return {
'raw_skew': 0, 'delta_skew': 0, 'butterfly': 0,
'smile_curvature': 0, 'skew_slope': 0,
}
df = pd.DataFrame(data)
# Raw skew: average put-call IV difference
raw_skew = df['raw_skew'].mean()
# Skew slope: regression of raw skew on moneyness
if len(df) > 2:
slope, intercept, r_value, p_value, std_err = stats.linregress(
df['moneyness'], df['raw_skew']
)
skew_slope = slope
else:
skew_slope = 0
# ATM skew (at moneyness ~ 0)
atm_records = df[df['moneyness'].abs() < 1.0]
atm_skew = atm_records['raw_skew'].mean() if len(atm_records) > 0 else raw_skew
# Wing skew (OTM puts vs OTM calls)
otm_puts = df[df['moneyness'] < -2]
otm_calls = df[df['moneyness'] > 2]
wing_skew = (
otm_puts['iv_put'].mean() - otm_calls['iv_call'].mean()
if len(otm_puts) > 0 and len(otm_calls) > 0
else 0
)
return {
'raw_skew': raw_skew,
'atm_skew': atm_skew,
'wing_skew': wing_skew,
'skew_slope': skew_slope,
'delta_skew': wing_skew, # Approximation
'butterfly': atm_skew, # Simplified
}
# ── Volatility Regime ───────────────────────────────────────────────────────
def vol_regime(
current_iv: float,
rv_20d: float,
rv_60d: float,
iv_rank: float,
iv_percentile: float,
term_slope: float
) -> Dict[str, Any]:
"""
Determine current volatility regime.
Parameters
----------
current_iv : Current implied vol (e.g., VIX)
rv_20d : 20-day realized vol
rv_60d : 60-day realized vol
iv_rank : IV rank (0-100)
iv_percentile : IV percentile (0-100)
term_slope : Term structure slope (back - front)
Returns
-------
Dict with regime classification and signals.
"""
# Regime classification
if iv_rank > 75:
regime = 'High Vol'
regime_color = 'red'
elif iv_rank > 50:
regime = 'Above Normal'
regime_color = 'orange'
elif iv_rank > 25:
regime = 'Below Normal'
regime_color = 'yellow'
else:
regime = 'Low Vol'
regime_color = 'green'
# Vol risk premium
vrp = current_iv - rv_20d
# Term structure signal
if term_slope > 2:
term_signal = 'Strong Contango'
elif term_slope > 0.5:
term_signal = 'Contango'
elif term_slope > -0.5:
term_signal = 'Flat'
elif term_slope > -2:
term_signal = 'Backwardation'
else:
term_signal = 'Strong Backwardation'
# RV trend
if rv_20d > rv_60d * 1.2:
rv_trend = 'Rising'
elif rv_20d < rv_60d * 0.8:
rv_trend = 'Falling'
else:
rv_trend = 'Stable'
# Composite signal
signals = []
if iv_rank > 80:
signals.append('IV Elevated - Consider selling vol')
elif iv_rank < 20:
signals.append('IV Compressed - Consider buying vol')
if vrp > 5:
signals.append('High Vol Risk Premium - Favor vol selling')
elif vrp < -2:
signals.append('Negative VRP - Favor vol buying')
if 'Backwardation' in term_signal:
signals.append('Term Structure Inverted - Stress signal')
if rv_trend == 'Rising' and iv_rank < 50:
signals.append('RV rising but IV not catching up - Potential vol buy')
return {
'regime': regime,
'regime_color': regime_color,
'iv_rank': iv_rank,
'iv_percentile': iv_percentile,
'vrp': vrp,
'term_signal': term_signal,
'rv_trend': rv_trend,
'signals': signals,
}
def compute_iv_rank(iv_series: pd.Series, lookback: int = 252) -> float:
"""
Compute IV Rank: (Current IV - 52w Low) / (52w High - 52w Low) * 100
IV Rank measures where current IV sits in its 52-week range.
"""
if iv_series is None or len(iv_series) < 20:
return 50.0 # Neutral default
recent = iv_series.tail(lookback)
current = iv_series.iloc[-1]
low = recent.min()
high = recent.max()
if high <= low:
return 50.0
return float((current - low) / (high - low) * 100)
def compute_iv_percentile(iv_series: pd.Series, lookback: int = 252) -> float:
"""
Compute IV Percentile: % of days in lookback where IV was below current.
More robust than IV Rank (not affected by single outlier).
"""
if iv_series is None or len(iv_series) < 20:
return 50.0
recent = iv_series.tail(lookback)
current = iv_series.iloc[-1]
below = (recent < current).sum()
return float(below / len(recent) * 100)
# ── VIX Fair Value ──────────────────────────────────────────────────────────
def vix_fair_value(
chain_records: List[Dict[str, Any]],
spot: float,
dte_target: int = 30
) -> Dict[str, float]:
"""
Estimate VIX fair value from SPX options chain.
Uses the VIX methodology: weighted sum of OTM option prices
to estimate 30-day implied variance.
Returns dict with:
- vix_fair: Estimated VIX fair value
- vix_30d: 30-day expiry IV (if available)
- variance_30d: 30-day variance
"""
if not chain_records:
return {'vix_fair': 0, 'vix_30d': 0, 'variance_30d': 0}
# Find closest expiry to 30 DTE
by_expiry: Dict[str, List[Dict]] = {}
for rec in chain_records:
exp = rec.get('expiry', '')
if exp not in by_expiry:
by_expiry[exp] = []
by_expiry[exp].append(rec)
best_expiry = None
best_dte_diff = 999
for exp in by_expiry:
try:
exp_date = pd.to_datetime(exp)
dte = (exp_date - pd.Timestamp.now()).days
if abs(dte - dte_target) < best_dte_diff:
best_dte_diff = abs(dte - dte_target)
best_expiry = exp
except:
continue
if best_expiry is None:
return {'vix_fair': 0, 'vix_30d': 0, 'variance_30d': 0}
records = by_expiry[best_expiry]
# Compute ATM IV as proxy for VIX
atm_records = sorted(records, key=lambda r: abs(r.get('strike', 0) - spot))[:6]
ivs = []
for r in atm_records:
iv_c = r.get('iv_call', 0)
iv_p = r.get('iv_put', 0)
if iv_c > 0:
ivs.append(iv_c)
if iv_p > 0:
ivs.append(iv_p)
atm_iv = np.mean(ivs) if ivs else 0.20
# Simple VIX approximation: ATM IV * 100
vix_fair = atm_iv * 100
return {
'vix_fair': vix_fair,
'vix_30d': atm_iv * 100,
'variance_30d': atm_iv ** 2,
'expiry_used': best_expiry,
'dte_used': dte_target,
}
# ── Vol Risk Premium ────────────────────────────────────────────────────────
def vol_risk_premium(
iv_series: pd.Series,
rv_series: pd.Series,
window: int = 20
) -> Dict[str, float]:
"""
Compute volatility risk premium (IV - RV).
Positive VRP = Implied vol > Realized vol (normal, vol sellers win on avg)
Negative VRP = Realized vol > Implied vol (rare, stress periods)
Returns dict with:
- current_vrp: Current IV - RV
- avg_vrp: Average VRP over window
- vrp_zscore: Z-score of current VRP
- vrp_regime: 'High', 'Normal', 'Low', 'Negative'
"""
if iv_series is None or rv_series is None:
return {'current_vrp': 0, 'avg_vrp': 0, 'vrp_zscore': 0, 'vrp_regime': 'N/A'}
# Align series
common_idx = iv_series.index.intersection(rv_series.index)
if len(common_idx) < 10:
return {'current_vrp': 0, 'avg_vrp': 0, 'vrp_zscore': 0, 'vrp_regime': 'N/A'}
iv = iv_series.loc[common_idx]
rv = rv_series.loc[common_idx]
vrp = iv - rv
current_vrp = vrp.iloc[-1]
avg_vrp = vrp.tail(window).mean()
std_vrp = vrp.tail(window).std()
zscore = (current_vrp - avg_vrp) / std_vrp if std_vrp > 0 else 0
if current_vrp < 0:
regime = 'Negative'
elif zscore > 1.5:
regime = 'High'
elif zscore < -1.0:
regime = 'Low'
else:
regime = 'Normal'
return {
'current_vrp': float(current_vrp),
'avg_vrp': float(avg_vrp),
'vrp_zscore': float(zscore),
'vrp_regime': regime,
}
# ── Summary ─────────────────────────────────────────────────────────────────
def vol_summary(
prices: pd.Series,
current_iv: float,
chain_records: Optional[List[Dict[str, Any]]] = None,
spot: Optional[float] = None,
) -> Dict[str, Any]:
"""
Compute comprehensive volatility summary.
Returns dict with all key vol metrics.
"""
if prices is None or len(prices) < 60:
return {'error': 'Insufficient price data'}
# Realized vols
rv_5d = realized_vol(prices, window=5).iloc[-1] * 100 if len(prices) > 5 else 0
rv_20d = realized_vol(prices, window=20).iloc[-1] * 100 if len(prices) > 20 else 0
rv_60d = realized_vol(prices, window=60).iloc[-1] * 100 if len(prices) > 60 else 0
# IV rank/percentile (use current_iv as proxy if no series)
iv_rank = 50.0 # Default
iv_pct = 50.0
# VRP
vrp = current_iv - rv_20d if rv_20d > 0 else 0
# Term structure
term_slope = 0.0
if chain_records and spot:
ts = iv_term_structure(chain_records, spot)
if not ts.empty and len(ts) >= 2:
term_slope = ts.iloc[-1]['avg_iv'] - ts.iloc[0]['avg_iv']
# Regime
regime = vol_regime(current_iv, rv_20d, rv_60d, iv_rank, iv_pct, term_slope)
return {
'current_iv': current_iv,
'rv_5d': rv_5d,
'rv_20d': rv_20d,
'rv_60d': rv_60d,
'iv_rank': iv_rank,
'iv_percentile': iv_pct,
'vrp': vrp,
'term_slope': term_slope,
'regime': regime['regime'],
'regime_color': regime['regime_color'],
'signals': regime['signals'],
}
if __name__ == '__main__':
# Smoke test
np.random.seed(42)
n = 252
returns = np.random.normal(0.0003, 0.012, n)
prices = pd.Series(100 * np.exp(np.cumsum(returns)),
index=pd.date_range('2025-01-01', periods=n, freq='B'))
print("Volatility Summary:")
rv_20 = realized_vol(prices, window=20)
rv_60 = realized_vol(prices, window=60)
print(f" RV 20d: {rv_20.iloc[-1]*100:.2f}%")
print(f" RV 60d: {rv_60.iloc[-1]*100:.2f}%")
cone = vol_cone(prices)
if not cone.empty:
print(f"\nVol Cone (20d):")
print(f" 5th %ile: {cone.loc[20, 'p5']*100:.2f}%")
print(f" Median: {cone.loc[20, 'p50']*100:.2f}%")
print(f" 95th %ile: {cone.loc[20, 'p95']*100:.2f}%")
summary = vol_summary(prices, current_iv=18.5)
print(f"\nFull Summary:")
for k, v in summary.items():
if isinstance(v, float):
print(f" {k}: {v:.2f}")
elif isinstance(v, list):
print(f" {k}: {v}")
else:
print(f" {k}: {v}")