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%
3.3 KB · 79 lines python
Raw Blame History
1# =============================================================================2# Author: Simon-Pierre Boucher3# Contact: contact@spboucher.ai4# =============================================================================5"""Portfolio construction: daily cross-sectional quintile sorts, double6sorts, and performance statistics (annualized returns, Sharpe ratios)."""78import numpy as np9import pandas as pd101112def portfolio_sort(data: pd.DataFrame, sort_var: str, ret_var: str,13                   n_quantiles: int = 5):14    """Daily equal-weighted quantile portfolios sorted on ``sort_var``.1516    Returns a dict keyed by quantile (1..n) plus ``'LS_5_1'`` (top minus17    bottom), each holding performance statistics, or ``None`` when the18    sample is too small. Annualization: 252 for 1-day returns, 52 otherwise.19    """20    data = data[[sort_var, ret_var, 'ticker', 'trade_date']].dropna()21    if len(data) < 1000:22        return None2324    data['quantile'] = data.groupby('trade_date')[sort_var].transform(25        lambda x: pd.qcut(x, n_quantiles, labels=False, duplicates='drop') + 126        if len(x) >= n_quantiles else np.nan27    )28    data = data.dropna(subset=['quantile'])29    data['quantile'] = data['quantile'].astype(int)3031    port_ret = data.groupby(['trade_date', 'quantile'])[ret_var].mean().reset_index()32    port_ret = port_ret.pivot(index='trade_date', columns='quantile', values=ret_var)3334    # Long-short: top quantile minus bottom (key 'LS_5_1' for quintiles,35    # 'LS_10_1' for deciles, ...)36    ls_key = f'LS_{n_quantiles}_1'37    if n_quantiles in port_ret.columns and 1 in port_ret.columns:38        port_ret[ls_key] = port_ret[n_quantiles] - port_ret[1]39    else:40        return None4142    results = {}43    for q in list(range(1, n_quantiles + 1)) + [ls_key]:44        if q not in port_ret.columns:45            continue46        s = port_ret[q].dropna()47        ann_factor = 252 if '1d' in ret_var or ret_var == 'ret_1d' else 5248        results[q] = {49            'mean_daily': s.mean(),50            'std_daily': s.std(),51            'annualized_return': s.mean() * ann_factor,52            'annualized_vol': s.std() * np.sqrt(ann_factor),53            'sharpe': (s.mean() / s.std() * np.sqrt(ann_factor)) if s.std() > 0 else 0,54            't_stat': (s.mean() / (s.std() / np.sqrt(len(s)))) if s.std() > 0 else 0,55            'n_days': len(s),56            'pct_positive': (s > 0).mean(),57            'max_drawdown': (s.cumsum() - s.cumsum().cummax()).min(),58        }59    return results606162def double_sort(data: pd.DataFrame, var1: str, var2: str, ret_var: str,63                n1: int = 3, n2: int = 3) -> pd.DataFrame:64    """Independent daily double sort; mean returns and t-stats per cell."""65    data = data.copy()66    for var, col, n in ((var1, 'q1', n1), (var2, 'q2', n2)):67        data[col] = data.groupby('trade_date')[var].transform(68            lambda x, n=n: pd.qcut(x, n, labels=False, duplicates='drop') + 169            if len(x) >= n else np.nan70        )71    data = data.dropna(subset=['q1', 'q2'])72    data['q1'] = data['q1'].astype(int)73    data['q2'] = data['q2'].astype(int)7475    results = data.groupby(['q1', 'q2'])[ret_var].agg(['mean', 'std', 'count']).reset_index()76    results['mean_bps'] = results['mean'] * 1000077    results['t_stat'] = results['mean'] / (results['std'] / np.sqrt(results['count']))78    return results79