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%
7.0 KB · 179 lines python
Raw Blame History
1#!/usr/bin/env python32# =============================================================================3# Author: Simon-Pierre Boucher4# Contact: contact@spboucher.ai5# =============================================================================6"""Step 12 — Supplementary figures (not referenced by the paper).78The working paper is tables-only by design; these figures are reproducible9visual companions built from the result CSVs, useful for talks and quick10inspection. Colors follow a CVD-validated categorical palette; every series11is direct-labeled so identity never relies on color alone.1213Inputs : results/rolling_r2.csv, results/irf_results.csv,14         results/portfolio_sort_results.csv, results/subperiod_results.csv15Outputs: figures/fig_rolling_r2.(png|pdf), figures/fig_irf.(png|pdf),16         figures/fig_portfolio_sorts.(png|pdf), figures/fig_subperiod.(png|pdf)17"""1819import warnings2021import matplotlib22matplotlib.use("Agg")23import matplotlib.pyplot as plt24import pandas as pd2526import _bootstrap  # noqa: F40127from wp7 import config2829warnings.filterwarnings('ignore')3031# CVD-validated categorical palette (see AUDIT trail: adjacent-pair ΔE ≥ 8)32BLUE, ORANGE, AQUA, YELLOW = "#2a78d6", "#eb6834", "#1baf7a", "#eda100"33INK, INK_2 = "#0b0b0b", "#52514e"3435plt.rcParams.update({36    "figure.dpi": 150,37    "font.size": 9,38    "axes.edgecolor": INK_2,39    "axes.labelcolor": INK,40    "axes.titlecolor": INK,41    "axes.spines.top": False,42    "axes.spines.right": False,43    "axes.grid": True,44    "grid.color": "#e5e4e0",45    "grid.linewidth": 0.6,46    "xtick.color": INK_2,47    "ytick.color": INK_2,48    "savefig.facecolor": "white",49    "axes.facecolor": "white",50})515253def _save(fig, name: str) -> None:54    for ext in ("png", "pdf"):55        fig.savefig(config.FIGURES_DIR / f"{name}.{ext}", bbox_inches="tight")56    plt.close(fig)57    print(f"  saved figures/{name}.png|.pdf")585960def fig_rolling_r2() -> None:61    """252-day rolling explanatory power of option-implied information."""62    df = pd.read_csv(config.RESULTS_DIR / "rolling_r2.csv", parse_dates=["date"])63    fig, ax = plt.subplots(figsize=(7.2, 3.4))64    series = [("1D_RV", "1-day RV forecasting (HAR+IV)", BLUE),65              ("5D_Return", "5-day return predictability", ORANGE)]66    for key, label, color in series:67        sub = df[df["target"] == key].sort_values("date")68        ax.plot(sub["date"], sub["r2"], color=color, lw=2)69        ax.annotate(label, xy=(sub["date"].iloc[-1], sub["r2"].iloc[-1]),70                    xytext=(6, 0), textcoords="offset points",71                    va="center", color=INK, fontsize=8.5)72    ax.set_ylabel("Rolling $R^2$ (252-day window)")73    ax.set_title("Option-implied information content through time", loc="left")74    ax.margins(x=0.02)75    fig.subplots_adjust(right=0.72)76    _save(fig, "fig_rolling_r2")777879def _dodge(values: list, min_gap: float) -> list:80    """Push label anchor positions apart until every pair clears min_gap."""81    order = sorted(range(len(values)), key=lambda i: values[i])82    adjusted = list(values)83    for prev, cur in zip(order, order[1:]):84        if adjusted[cur] - adjusted[prev] < min_gap:85            adjusted[cur] = adjusted[prev] + min_gap86    return adjusted878889def fig_irf() -> None:90    """Average VAR(5) impulse responses (20 tickers, standardized units)."""91    df = pd.read_csv(config.RESULTS_DIR / "irf_results.csv")92    avg = df.groupby("horizon")[["iv_to_iv", "rv_to_iv", "iv_to_rv", "rv_to_rv"]].mean()93    fig, ax = plt.subplots(figsize=(6.4, 3.6))94    series = [("iv_to_rv", "RV response to IV shock", BLUE),95              ("rv_to_rv", "RV response to RV shock", ORANGE),96              ("iv_to_iv", "IV response to IV shock", AQUA),97              ("rv_to_iv", "IV response to RV shock", YELLOW)]98    ends = _dodge([avg[key].iloc[-1] for key, _, _ in series], min_gap=0.07)99    for (key, label, color), label_y in zip(series, ends):100        ax.plot(avg.index, avg[key], color=color, lw=2)101        ax.annotate(label, xy=(avg.index[-1], label_y),102                    xytext=(6, 0), textcoords="offset points",103                    va="center", color=INK, fontsize=8.5)104    ax.axhline(0, color=INK_2, lw=0.8)105    ax.set_xlabel("Horizon (days)")106    ax.set_ylabel("Response (SD units)")107    ax.set_title("Average impulse responses, bivariate VAR(5): ATM IV ↔ RV",108                 loc="left")109    ax.margins(x=0.02)110    fig.subplots_adjust(right=0.70)111    _save(fig, "fig_irf")112113114def fig_portfolio_sorts() -> None:115    """Annualized long-short (Q5−Q1) returns of the 5-day sorts."""116    df = pd.read_csv(config.RESULTS_DIR / "portfolio_sort_results.csv")117    ls = df[(df["quintile"] == "L/S(5-1)") & (df["return_horizon"] == "5-Day")]118    ls = ls.sort_values("annualized_return_pct")119    colors = [BLUE if v >= 0 else ORANGE for v in ls["annualized_return_pct"]]120121    fig, ax = plt.subplots(figsize=(6.4, 3.2))122    bars = ax.barh(ls["sort_variable"], ls["annualized_return_pct"],123                   color=colors, height=0.62)124    for bar, val, sharpe in zip(bars, ls["annualized_return_pct"], ls["sharpe_ratio"]):125        ax.annotate(f"{val:+.1f}%  (SR {sharpe:.2f})",126                    xy=(val, bar.get_y() + bar.get_height() / 2),127                    xytext=(5 if val >= 0 else -5, 0), textcoords="offset points",128                    va="center", ha="left" if val >= 0 else "right",129                    color=INK, fontsize=8)130    ax.axvline(0, color=INK_2, lw=0.8)131    ax.set_xlabel("Annualized L/S return (%)")132    ax.set_title("Long-short quintile portfolios, 5-day returns (Q5 − Q1)",133                 loc="left")134    ax.grid(axis="y", visible=False)135    xmin, xmax = ax.get_xlim()136    ax.set_xlim(xmin - 0.42 * (xmax - xmin), xmax + 0.18 * (xmax - xmin))137    _save(fig, "fig_portfolio_sorts")138139140def fig_subperiod() -> None:141    """HAR-RV vs HAR+IV in-sample R² across the nine subperiods."""142    df = pd.read_csv(config.RESULTS_DIR / "subperiod_results.csv")143    rv = df[df["model"].isin(["HAR-RV", "HAR+IV"])]144    pivot = rv.pivot_table(index="subperiod", columns="model", values="r2")145    pivot = pivot.reindex(list(config.SUBPERIODS))146147    fig, ax = plt.subplots(figsize=(7.2, 3.6))148    x = range(len(pivot))149    w = 0.38150    ax.bar([i - w / 2 for i in x], pivot["HAR-RV"], width=w - 0.04,151           color=ORANGE, label="HAR-RV")152    ax.bar([i + w / 2 for i in x], pivot["HAR+IV"], width=w - 0.04,153           color=BLUE, label="HAR-RV + IV surface")154    ax.set_xticks(list(x))155    ax.set_xticklabels([s.split(" (")[0] for s in pivot.index],156                       rotation=30, ha="right", fontsize=8)157    ax.set_ylabel("In-sample $R^2$ (1-day RV)")158    ax.set_title("Stability of the IV-surface improvement across subperiods",159                 loc="left")160    ax.grid(axis="x", visible=False)161    ax.legend(frameon=False, fontsize=8.5, loc="upper left")162    _save(fig, "fig_subperiod")163164165def main():166    print("=" * 70)167    print("SUPPLEMENTARY FIGURES")168    print("=" * 70)169    config.ensure_output_dirs()170    fig_rolling_r2()171    fig_irf()172    fig_portfolio_sorts()173    fig_subperiod()174    print("\nFIGURES COMPLETE.")175176177if __name__ == "__main__":178    main()179