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%
9.9 KB · 288 lines python
Raw Blame History
1# =============================================================================2# Author: Simon-Pierre Boucher3# Contact: contact@spboucher.ai4# =============================================================================5"""Econometric primitives shared by every analysis script.67All estimators are deliberately implemented with :func:`numpy.linalg.lstsq`8and explicit sandwich formulas — exactly as in the original study — so that9re-running the pipeline reproduces the published numbers bit-for-bit. Do not10"modernize" the numerics (e.g. switch to statsmodels) without re-validating11every result table against ``results/``.12"""1314import numpy as np15import pandas as pd16from numpy.linalg import lstsq171819# --------------------------------------------------------------------------20# Transformations21# --------------------------------------------------------------------------22def winsorize(s: pd.Series, q: float = 0.01) -> pd.Series:23    """Clip a series at its ``q`` and ``1-q`` empirical quantiles."""24    lo, hi = s.quantile(q), s.quantile(1 - q)25    return s.clip(lo, hi)262728def standardize(X: np.ndarray) -> np.ndarray:29    """Column-standardize (population std, zero-variance columns left at 1)."""30    means = X.mean(axis=0)31    stds = X.std(axis=0)32    stds[stds == 0] = 133    return (X - means) / stds343536def add_constant(X: np.ndarray) -> np.ndarray:37    """Prepend an intercept column of ones."""38    return np.column_stack([np.ones(len(X)), X])394041def design_matrix(data: pd.DataFrame, features: list) -> np.ndarray:42    """Standardized feature matrix with intercept, ready for ``lstsq``."""43    return add_constant(standardize(data[features].values))444546# --------------------------------------------------------------------------47# OLS with robust inference48# --------------------------------------------------------------------------49def ols(X: np.ndarray, y: np.ndarray) -> np.ndarray:50    """Least-squares coefficients."""51    coefs, _, _, _ = lstsq(X, y, rcond=None)52    return coefs535455def r_squared(y: np.ndarray, y_pred: np.ndarray) -> float:56    ss_res = np.sum((y - y_pred) ** 2)57    ss_tot = np.sum((y - y.mean()) ** 2)58    return 1 - ss_res / ss_tot if ss_tot > 0 else 0596061def adjusted_r_squared(r2: float, n: int, k: int) -> float:62    return 1 - (1 - r2) * (n - 1) / (n - k - 1)636465def _xtx_inverse(X: np.ndarray) -> np.ndarray:66    try:67        return np.linalg.inv(X.T @ X)68    except np.linalg.LinAlgError:69        return np.linalg.pinv(X.T @ X)707172def hc1_tstats(X: np.ndarray, y: np.ndarray, coefs: np.ndarray,73               n_features: int) -> tuple:74    """Heteroskedasticity-robust (HC1) standard errors and t-statistics.7576    ``n_features`` excludes the intercept; the small-sample factor is77    ``n / (n - k - 1)`` as in the original study.78    """79    n = len(y)80    e = y - X @ coefs81    XtX_inv = _xtx_inverse(X)82    # X' diag(e²) X computed by broadcasting (identical to the dense-diagonal83    # formula, but O(nk) memory instead of O(n²)).84    S = (X * (e ** 2)[:, None]).T @ X * n / (n - n_features - 1)85    se = np.sqrt(np.abs(np.diag(XtX_inv @ S @ XtX_inv)))86    t_stats = coefs / np.where(se > 0, se, 1)87    return se, t_stats888990def newey_west_tstats(X: np.ndarray, y: np.ndarray, coefs: np.ndarray,91                      n_lags: int = 5) -> tuple:92    """Newey-West HAC standard errors with Bartlett kernel weights."""93    n, k = X.shape94    e = y - X @ coefs95    XtX_inv = _xtx_inverse(X)9697    S = np.zeros((k, k))98    for j in range(n):99        S += e[j] ** 2 * np.outer(X[j], X[j])100    for lag in range(1, n_lags + 1):101        w = 1 - lag / (n_lags + 1)102        for j in range(lag, n):103            cross = e[j] * e[j - lag] * np.outer(X[j], X[j - lag])104            S += w * (cross + cross.T)105106    V = XtX_inv @ S @ XtX_inv107    se = np.sqrt(np.abs(np.diag(V)))108    t_stats = coefs / np.where(se > 0, se, 1)109    return se, t_stats110111112def double_clustered_tstats(X: np.ndarray, y: np.ndarray, coefs: np.ndarray,113                            tickers: np.ndarray, dates: np.ndarray) -> tuple:114    """Two-way (ticker + date) clustered SEs, Cameron-Gelbach-Miller (2011)."""115    k = X.shape[1]116    e = y - X @ coefs117    XtX_inv = _xtx_inverse(X)118119    def cluster_meat(labels: np.ndarray) -> np.ndarray:120        S = np.zeros((k, k))121        for g in np.unique(labels):122            mask = labels == g123            u = (X[mask].T * e[mask]).sum(axis=1, keepdims=True)124            S += u @ u.T125        return S126127    S_hc = (X * (e ** 2)[:, None]).T @ X128    V = XtX_inv @ (cluster_meat(tickers) + cluster_meat(dates) - S_hc) @ XtX_inv129    se = np.sqrt(np.abs(np.diag(V)))130    t_stats = coefs / np.where(se > 0, se, 1)131    return se, t_stats132133134def quantile_regression(X: np.ndarray, y: np.ndarray, tau: float,135                        max_iter: int = 50) -> np.ndarray:136    """Quantile regression via iteratively reweighted least squares."""137    coefs = ols(X, y)138    for _ in range(max_iter):139        residuals = y - X @ coefs140        weights = np.where(residuals >= 0, tau, 1 - tau)141        weights = np.maximum(weights / (np.abs(residuals) + 1e-6), 1e-6)142        Xw = X * weights[:, None]143        try:144            coefs = np.linalg.solve(Xw.T @ X, Xw.T @ y)145        except np.linalg.LinAlgError:146            break147    return coefs148149150def pooled_regression_summary(data: pd.DataFrame, features: list, target: str,151                              label: str = "", min_obs: int = 100):152    """Winsorize → standardize → pooled OLS with HC1; compact summary dict.153154    Returns ``None`` when fewer than ``min_obs`` complete observations exist.155    """156    sub = data[features + [target]].dropna()157    if len(sub) < min_obs:158        return None159    for f in features:160        sub[f] = winsorize(sub[f])161    sub[target] = winsorize(sub[target])162163    X = design_matrix(sub, features)164    y = sub[target].values165    coefs = ols(X, y)166    r2 = r_squared(y, X @ coefs)167    _, t = hc1_tstats(X, y, coefs, len(features))168    return {169        'label': label,170        'n_obs': len(sub),171        'r2': r2,172        'adj_r2': adjusted_r_squared(r2, len(y), len(features)),173        'n_significant': int(np.sum(np.abs(t[1:]) > 1.96)),174    }175176177# --------------------------------------------------------------------------178# Fama-MacBeth179# --------------------------------------------------------------------------180def fama_macbeth(data: pd.DataFrame, features: list, target: str,181                 min_obs_per_day: int = 10, min_periods: int = 30):182    """Daily cross-sectional regressions; time-series means and t-statistics.183184    Returns ``(results_df, n_periods, n_tickers)`` or ``None`` when the panel185    is too small.186    """187    sub = data[features + [target, 'ticker', 'trade_date']].dropna()188    if len(sub) < 100:189        return None190    for f in features:191        sub[f] = winsorize(sub[f])192    sub[target] = winsorize(sub[target])193194    coef_series = []195    for dt in sub['trade_date'].unique():196        day = sub[sub['trade_date'] == dt]197        if len(day) < min_obs_per_day:198            continue199        X = add_constant(standardize(day[features].values))200        try:201            coef_series.append(ols(X, day[target].values))202        except np.linalg.LinAlgError:203            continue204205    if len(coef_series) < min_periods:206        return None207208    coef_array = np.array(coef_series)209    avg = coef_array.mean(axis=0)210    se = coef_array.std(axis=0) / np.sqrt(len(coef_series))211    t = avg / se212    results = pd.DataFrame({213        'variable': ['const'] + features,214        'fm_coefficient': avg,215        'fm_std_error': se,216        'fm_t_stat': t,217        'fm_significant_5pct': np.abs(t) > 1.96,218        'fm_significant_1pct': np.abs(t) > 2.576,219    })220    return results, len(coef_series), sub['ticker'].nunique()221222223# --------------------------------------------------------------------------224# Time-series: Granger causality and bivariate VAR225# --------------------------------------------------------------------------226def granger_test(y: np.ndarray, x: np.ndarray, max_lag: int = 5):227    """F-test of the null that ``x`` does not Granger-cause ``y``."""228    n = len(y)229    if n < max_lag + 50:230        return None231232    Y = y[max_lag:]233    X_r = np.column_stack([y[max_lag - i - 1:n - i - 1] for i in range(max_lag)])234    X_r = add_constant(X_r)235    ssr_r = np.sum((Y - X_r @ ols(X_r, Y)) ** 2)236237    X_u = np.column_stack([X_r] + [x[max_lag - i - 1:n - i - 1] for i in range(max_lag)])238    ssr_u = np.sum((Y - X_u @ ols(X_u, Y)) ** 2)239240    n_eff = len(Y)241    f_stat = ((ssr_r - ssr_u) / max_lag) / (ssr_u / (n_eff - 2 * max_lag - 1))242243    from scipy import stats244    try:245        p_value = 1 - stats.f.cdf(f_stat, max_lag, n_eff - 2 * max_lag - 1)246    except Exception:247        p_value = np.nan248    return {'f_stat': f_stat, 'p_value': p_value, 'n_obs': n_eff}249250251def estimate_var(y1: np.ndarray, y2: np.ndarray, lags: int = 5,252                 irf_periods: int = 20):253    """Bivariate VAR(p) by equation-wise OLS, with companion-form IRFs."""254    n = len(y1)255    if n < lags + 50:256        return None257258    Y1, Y2 = y1[lags:], y2[lags:]259    X_lags = []260    for lag in range(1, lags + 1):261        X_lags.append(y1[lags - lag:n - lag])262        X_lags.append(y2[lags - lag:n - lag])263    X = np.column_stack([np.ones(len(Y1))] + X_lags)264265    coefs1 = ols(X, Y1)266    r2_1 = 1 - np.sum((Y1 - X @ coefs1) ** 2) / np.sum((Y1 - Y1.mean()) ** 2)267    coefs2 = ols(X, Y2)268    r2_2 = 1 - np.sum((Y2 - X @ coefs2) ** 2) / np.sum((Y2 - Y2.mean()) ** 2)269270    # Companion matrix271    A = np.zeros((2 * lags, 2 * lags))272    for lag in range(lags):273        A[0, 2 * lag] = coefs1[1 + 2 * lag]274        A[0, 2 * lag + 1] = coefs1[1 + 2 * lag + 1]275        A[1, 2 * lag] = coefs2[1 + 2 * lag]276        A[1, 2 * lag + 1] = coefs2[1 + 2 * lag + 1]277    for i in range(2, 2 * lags):278        A[i, i - 2] = 1.0279280    irf = np.zeros((irf_periods, 2, 2))281    for h in range(irf_periods):282        power = np.eye(2 * lags) if h == 0 else power @ A283        irf[h] = power[:2, :2]284285    return {'coefs_eq1': coefs1, 'coefs_eq2': coefs2,286            'r2_eq1': r2_1, 'r2_eq2': r2_2,287            'irf': irf, 'n_obs': len(Y1)}288