SPB Git

spb/wp7_uqo Public

UQO Working Paper No. 7 — Options-implied information for cross-asset return and volatility prediction: evidence from 3.8B option contracts.

Python 66.5% TeX 32.7% Makefile 0.8%
10.9 KB · 254 lines python
Raw Blame History
1#!/usr/bin/env python32# =============================================================================3# Author: Simon-Pierre Boucher4# Contact: contact@spboucher.ai5# =============================================================================6"""Step 04 — RQ3: Implied vs realized correlation divergence as a stress7predictor.89Stage 1 (raw-dependent): build the daily implied-correlation index from SPX10and constituent ATM IVs, the 22-day realized correlation from daily returns,11merge with VIX, define stress events, and save the combined series.12Stage 2 (parquet-only): predictive stress regressions and crisis-window13analysis. When the raw stores are absent, stage 1 is skipped and stage 214runs from the shipped ``correlation_divergence.parquet``.1516Inputs : options.duckdb + index_5min.duckdb (stage 1, optional),17         data/processed/realized_vol.parquet (stage 1),18         data/processed/correlation_divergence.parquet (stage 2 fallback)19Outputs: data/processed/correlation_divergence.parquet (stage 1),20         results/rq3_stress_prediction.csv, results/rq3_crisis_analysis.csv21"""2223import warnings2425import numpy as np26import pandas as pd2728import _bootstrap  # noqa: F40129from wp7 import config30from wp7.data_io import (RawDataUnavailableError, load_correlation_divergence,31                         load_realized_vol, open_raw_db)32from wp7.econometrics import add_constant, hc1_tstats, ols, r_squared, standardize3334warnings.filterwarnings('ignore')3536STRESS_HORIZONS = [5, 10, 20]373839# --------------------------------------------------------------------------40# Stage 1 — build the correlation-divergence dataset (needs raw stores)41# --------------------------------------------------------------------------42def build_correlation_dataset() -> pd.DataFrame:43    """Implied correlation (CBOE methodology), realized correlation, VIX."""44    constituents = config.SPX_CONSTITUENTS45    const_str = ",".join([f"'{t}'" for t in constituents])4647    # 3A. SPX and constituent 30-day ATM implied volatilities48    opt_con = open_raw_db("options")49    print("  Extracting SPX implied volatility...")50    spx_iv = opt_con.execute("""51        SELECT trade_date,52               AVG(CASE WHEN bid_iv > 0 AND ask_iv > 0 THEN (bid_iv + ask_iv)/2.053                        WHEN ask_iv > 0 THEN ask_iv ELSE bid_iv END) AS spx_iv_atm54        FROM option_chain55        WHERE ticker = 'SPX'56          AND call_put = 'c'57          AND ABS(delta - 0.5) < 0.158          AND (expiry_date - trade_date) BETWEEN 20 AND 4059          AND ask_price > 060        GROUP BY trade_date61        ORDER BY trade_date62    """).fetchdf()63    print(f"  SPX IV: {len(spx_iv)} days")6465    print("  Extracting constituent implied volatilities...")66    const_iv = opt_con.execute(f"""67        SELECT ticker, trade_date,68               AVG(CASE WHEN bid_iv > 0 AND ask_iv > 0 THEN (bid_iv + ask_iv)/2.069                        WHEN ask_iv > 0 THEN ask_iv ELSE bid_iv END) AS iv_atm70        FROM option_chain71        WHERE ticker IN ({const_str})72          AND call_put = 'c'73          AND ABS(delta - 0.5) < 0.174          AND (expiry_date - trade_date) BETWEEN 20 AND 4075          AND ask_price > 076        GROUP BY ticker, trade_date77        ORDER BY trade_date78    """).fetchdf()79    print(f"  Constituent IVs: {len(const_iv)} rows")80    opt_con.close()8182    # Implied correlation: rho = (sigma²_idx − n⁻¹·avg(sigma²_i)) /83    #                            ((1 − n⁻¹)·avg(sigma_i)²)84    const_iv['iv_sq'] = const_iv['iv_atm'] ** 285    avg_const_iv = const_iv.groupby('trade_date').agg(86        avg_const_iv_sq=('iv_sq', 'mean'),87        avg_const_iv=('iv_atm', 'mean'),88        n_constituents=('ticker', 'nunique'),89    ).reset_index()9091    ic_df = pd.merge(spx_iv, avg_const_iv, on='trade_date', how='inner')92    ic_df['spx_iv_sq'] = ic_df['spx_iv_atm'] ** 293    n = ic_df['n_constituents']94    ic_df['implied_corr'] = (ic_df['spx_iv_sq'] - (1.0 / n) * ic_df['avg_const_iv_sq']) / \95                            ((1.0 - 1.0 / n) * ic_df['avg_const_iv'] ** 2)96    ic_df['implied_corr'] = ic_df['implied_corr'].clip(0, 1)97    print(f"  Implied correlation computed: {len(ic_df)} days")9899    # 3B. Realized correlation: 22-day rolling average pairwise correlation100    #     of constituent daily returns.101    print("\n  Computing realized correlations from daily returns...")102    rv = load_realized_vol()103    rv = rv[rv['ticker'].isin(constituents)]104105    ret_pivot = rv.pivot_table(index='trade_date', columns='ticker',106                               values='daily_return', aggfunc='first')107    ret_pivot = ret_pivot.dropna(axis=0, thresh=10)108109    rolling_corr = []110    dates = ret_pivot.index.tolist()111    for i in range(21, len(dates)):112        window = ret_pivot.iloc[i - 21:i + 1]113        corr_matrix = window.corr()114        mask = np.triu(np.ones(corr_matrix.shape, dtype=bool), k=1)115        rolling_corr.append({116            'trade_date': dates[i],117            'realized_corr': corr_matrix.values[mask].mean(),118            'n_pairs': mask.sum(),119        })120    realized_corr_df = pd.DataFrame(rolling_corr)121    print(f"  Realized correlation computed: {len(realized_corr_df)} days")122123    # 3C. Merge implied and realized correlation124    ic_df['trade_date'] = pd.to_datetime(ic_df['trade_date'])125    corr_merged = pd.merge(ic_df, realized_corr_df, on='trade_date', how='inner')126    corr_merged['corr_divergence'] = corr_merged['implied_corr'] - corr_merged['realized_corr']127    corr_merged['corr_ratio'] = corr_merged['implied_corr'] / \128        corr_merged['realized_corr'].clip(0.01)129130    print(f"  Merged correlation data: {len(corr_merged)} days")131    print(f"  Avg implied corr: {corr_merged['implied_corr'].mean():.4f}")132    print(f"  Avg realized corr: {corr_merged['realized_corr'].mean():.4f}")133    print(f"  Avg divergence: {corr_merged['corr_divergence'].mean():.4f}")134135    # 3D. Stress events from VIX levels and 5-day VIX spikes136    con = open_raw_db("indices_5min")137    vix_daily = con.execute("""138        SELECT CAST(datetime AS DATE) AS trade_date,139               LAST(close) AS vix_close,140               MAX(close) AS vix_high141        FROM ohlcv142        WHERE symbol = 'VIX'143        GROUP BY CAST(datetime AS DATE)144        ORDER BY trade_date145    """).fetchdf()146    con.close()147    vix_daily['trade_date'] = pd.to_datetime(vix_daily['trade_date'])148    corr_merged = pd.merge(corr_merged, vix_daily, on='trade_date', how='left')149150    corr_merged['vix_5d_change'] = corr_merged['vix_close'].pct_change(5)151    corr_merged['stress_event'] = ((corr_merged['vix_close'] > 25) |152                                   (corr_merged['vix_5d_change'] > 0.20)).astype(int)153    for horizon in STRESS_HORIZONS:154        corr_merged[f'stress_fwd_{horizon}d'] = corr_merged['stress_event'].rolling(155            horizon, min_periods=1).max().shift(-horizon)156157    corr_merged.to_parquet(config.CORRELATION_DIVERGENCE_PARQUET, index=False)158    return corr_merged159160161# --------------------------------------------------------------------------162# Stage 2 — stress regressions and crisis windows (parquet-only)163# --------------------------------------------------------------------------164def stress_regressions(corr_merged: pd.DataFrame) -> None:165    """Linear probability models: divergence measures → forward stress."""166    print("\n--- STRESS PREDICTION REGRESSIONS ---")167    features = ['corr_divergence', 'corr_ratio', 'implied_corr', 'realized_corr',168                'spx_iv_atm', 'vix_close']169    rows = []170    for horizon in STRESS_HORIZONS:171        target = f'stress_fwd_{horizon}d'172        sub = corr_merged[features + [target]].dropna()173        if len(sub) < 100:174            continue175176        X = add_constant(standardize(sub[features].values))177        y = sub[target].values178        coefs = ols(X, y)179        r2 = r_squared(y, X @ coefs)180        _, t_stats = hc1_tstats(X, y, coefs, len(features))181182        for i, fname in enumerate(['const'] + features):183            rows.append({184                'horizon': f'{horizon}d',185                'variable': fname,186                'coefficient': coefs[i],187                't_stat': t_stats[i],188                'significant': abs(t_stats[i]) > 1.96,189            })190191        print(f"\n  {horizon}-day stress prediction: R²={r2:.4f}, N={len(sub)}")192        for i, fname in enumerate(['const'] + features):193            sig = "*" if abs(t_stats[i]) > 1.96 else ""194            sig2 = "*" if abs(t_stats[i]) > 2.576 else ""195            print(f"    {fname:20s}: β={coefs[i]:8.4f}, t={t_stats[i]:7.3f} {sig}{sig2}")196197    pd.DataFrame(rows).to_csv(config.RESULTS_DIR / "rq3_stress_prediction.csv", index=False)198199200def crisis_analysis(corr_merged: pd.DataFrame) -> None:201    """Correlation divergence in 30-day pre / 10-day post windows around crises."""202    print("\n--- CORRELATION DIVERGENCE AROUND CRISES ---")203    rows = []204    for name, date_str in config.CRISES.items():205        date = pd.Timestamp(date_str)206        pre = corr_merged[(corr_merged['trade_date'] >= date - pd.Timedelta(days=30)) &207                          (corr_merged['trade_date'] < date)]208        post = corr_merged[(corr_merged['trade_date'] >= date) &209                           (corr_merged['trade_date'] < date + pd.Timedelta(days=10))]210        if len(pre) > 0 and len(post) > 0:211            rows.append({212                'crisis': name,213                'pre_impl_corr': pre['implied_corr'].mean(),214                'pre_real_corr': pre['realized_corr'].mean(),215                'pre_divergence': pre['corr_divergence'].mean(),216                'post_impl_corr': post['implied_corr'].mean(),217                'post_real_corr': post['realized_corr'].mean(),218                'post_divergence': post['corr_divergence'].mean(),219                'divergence_change': post['corr_divergence'].mean() - pre['corr_divergence'].mean(),220                'pre_vix': pre['vix_close'].mean() if 'vix_close' in pre.columns else np.nan,221                'post_vix': post['vix_close'].mean() if 'vix_close' in post.columns else np.nan,222            })223            print(f"  {name}:")224            print(f"    Pre:  IC={pre['implied_corr'].mean():.4f}, "225                  f"RC={pre['realized_corr'].mean():.4f}, "226                  f"Div={pre['corr_divergence'].mean():.4f}")227            print(f"    Post: IC={post['implied_corr'].mean():.4f}, "228                  f"RC={post['realized_corr'].mean():.4f}, "229                  f"Div={post['corr_divergence'].mean():.4f}")230231    pd.DataFrame(rows).to_csv(config.RESULTS_DIR / "rq3_crisis_analysis.csv", index=False)232233234def main():235    print("=" * 70)236    print("RQ3: IMPLIED vs REALIZED CORRELATION DIVERGENCE")237    print("=" * 70)238    config.ensure_output_dirs()239240    try:241        corr_merged = build_correlation_dataset()242    except RawDataUnavailableError as exc:243        print(f"\n[Stage 1 skipped — raw stores unavailable]\n{exc}\n")244        print("Loading shipped correlation_divergence.parquet instead.")245        corr_merged = load_correlation_divergence()246247    stress_regressions(corr_merged)248    crisis_analysis(corr_merged)249    print("\nRQ3 COMPLETE.")250251252if __name__ == "__main__":253    main()254