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%
5.6 KB · 135 lines python
Raw Blame History
1#!/usr/bin/env python32# =============================================================================3# Author: Simon-Pierre Boucher4# Contact: contact@spboucher.ai5# =============================================================================6"""Step 09 — Portfolio sorts and economic significance.78Daily equal-weighted quintile sorts on each option-implied variable9(long-short Q5−Q1 performance), a 3×3 double sort on ATM IV × implied10skewness, and transaction-cost sensitivity of the implied-skewness strategy.1112Inputs : data/processed/merged_options_rv.parquet13Outputs: results/portfolio_sort_results.csv, results/double_sort_iv_skew.csv,14         results/transaction_cost_analysis.csv15"""1617import warnings1819import numpy as np20import pandas as pd2122import _bootstrap  # noqa: F40123from wp7 import config24from wp7.data_io import load_merged25from wp7.portfolio import double_sort, portfolio_sort2627warnings.filterwarnings('ignore')282930def main():31    print("=" * 70)32    print("PORTFOLIO SORTS & ECONOMIC SIGNIFICANCE")33    print("=" * 70)34    config.ensure_output_dirs()3536    df = load_merged()37    stocks = df[~df['ticker'].isin(config.NON_STOCK_TICKERS)].copy()3839    # ── Single sorts ──40    all_sort_results = []41    for sort_var, sort_label in config.SORT_VARIABLES.items():42        for ret_var, ret_label in [('ret_1d', '1-Day'), ('ret_5d', '5-Day')]:43            res = portfolio_sort(stocks, sort_var, ret_var, n_quantiles=5)44            if res is None:45                continue4647            print(f"\n  {sort_label}{ret_label} Returns:")48            print(f"  {'Q':>6} {'Mean(bps)':>10} {'Ann.Ret%':>10} {'Ann.Vol%':>10} "49                  f"{'Sharpe':>8} {'t-stat':>8} {'N':>6}")5051            for q in [1, 2, 3, 4, 5, 'LS_5_1']:52                if q not in res:53                    continue54                r = res[q]55                q_label = f"Q{q}" if isinstance(q, int) else "L/S(5-1)"56                print(f"  {q_label:>6} {r['mean_daily']*10000:>10.2f} "57                      f"{r['annualized_return']*100:>10.2f} "58                      f"{r['annualized_vol']*100:>10.2f} {r['sharpe']:>8.3f} "59                      f"{r['t_stat']:>8.3f} {r['n_days']:>6}")6061                all_sort_results.append({62                    'sort_variable': sort_label,63                    'return_horizon': ret_label,64                    'quintile': q_label,65                    'mean_daily_bps': r['mean_daily'] * 10000,66                    'annualized_return_pct': r['annualized_return'] * 100,67                    'annualized_vol_pct': r['annualized_vol'] * 100,68                    'sharpe_ratio': r['sharpe'],69                    't_statistic': r['t_stat'],70                    'n_days': r['n_days'],71                    'pct_positive': r['pct_positive'],72                    'max_drawdown_pct': r['max_drawdown'] * 100,73                })7475    sort_df = pd.DataFrame(all_sort_results)76    sort_df.to_csv(config.RESULTS_DIR / "portfolio_sort_results.csv", index=False)7778    print("\n" + "=" * 70)79    print("LONG-SHORT PORTFOLIO SUMMARY (Q5 - Q1)")80    print("=" * 70)81    ls = sort_df[sort_df['quintile'] == 'L/S(5-1)'].copy()82    print(ls[['sort_variable', 'return_horizon', 'mean_daily_bps',83              'annualized_return_pct', 'sharpe_ratio', 't_statistic']]84          .round(3).to_string(index=False))8586    # ── Double sort: ATM IV × implied skewness → 5-day returns ──87    print("\n" + "=" * 70)88    print("DOUBLE SORT: IV_ATM x IMPLIED_SKEWNESS → 5-Day Returns")89    print("=" * 70)90    ds = stocks[['iv_atm_30d', 'implied_skewness', 'ret_5d', 'ticker', 'trade_date']].dropna()91    ds_results = double_sort(ds, 'iv_atm_30d', 'implied_skewness', 'ret_5d')9293    ds_pivot = ds_results.pivot(index='q1', columns='q2', values='mean_bps')94    ds_pivot.index = ['Low IV', 'Med IV', 'High IV']95    ds_pivot.columns = ['Low Skew', 'Med Skew', 'High Skew']96    print(ds_pivot.round(2).to_string())9798    ds_t = ds_results.pivot(index='q1', columns='q2', values='t_stat')99    ds_t.index = ['Low IV', 'Med IV', 'High IV']100    ds_t.columns = ['Low Skew', 'Med Skew', 'High Skew']101    print("\nt-statistics:")102    print(ds_t.round(3).to_string())103104    ds_results.to_csv(config.RESULTS_DIR / "double_sort_iv_skew.csv", index=False)105106    # ── Transaction-cost sensitivity (implied-skewness L/S, weekly) ──107    print("\n" + "=" * 70)108    print("TRANSACTION COST SENSITIVITY (Long-Short on Implied Skewness, 5D)")109    print("=" * 70)110    res_skew = portfolio_sort(stocks, 'implied_skewness', 'ret_5d', n_quantiles=5)111    if res_skew and 'LS_5_1' in res_skew:112        ls_gross = res_skew['LS_5_1']113        print(f"  {'TC (bps)':>10} {'Net Ret(bps)':>12} {'Ann.Ret%':>10} {'Sharpe':>8}")114        tc_results = []115        for tc_bps in [0, 5, 10, 15, 20, 30, 50]:116            # Weekly rebalance: full two-sided turnover spread over 5 days117            turnover_per_day = 2.0 / 5118            daily_tc = tc_bps / 10000 * turnover_per_day119            net_daily = ls_gross['mean_daily'] - daily_tc120            net_ann = net_daily * 52121            net_sharpe = (net_daily / ls_gross['std_daily'] * np.sqrt(52)) \122                if ls_gross['std_daily'] > 0 else 0123            print(f"  {tc_bps:>10} {net_daily*10000:>12.2f} "124                  f"{net_ann*100:>10.2f} {net_sharpe:>8.3f}")125            tc_results.append({'tc_bps': tc_bps, 'net_daily_bps': net_daily * 10000,126                               'net_ann_ret_pct': net_ann * 100, 'net_sharpe': net_sharpe})127        pd.DataFrame(tc_results).to_csv(128            config.RESULTS_DIR / "transaction_cost_analysis.csv", index=False)129130    print("\nPORTFOLIO SORTS COMPLETE.")131132133if __name__ == "__main__":134    main()135