SPB Git

spb/wp39_uqo

Public
10.1 KB · 157 lines python
1"""Journal figures (vector PDF, matplotlib). One axis per panel, thin marks, fixed categorical order, direct labels.2Palette validated with the dataviz validator (light surface): blue, orange, aqua, yellow (+ gray for references)."""3from __future__ import annotations45from pathlib import Path67import matplotlib8matplotlib.use("Agg")9import matplotlib.pyplot as plt  # noqa: E40210import numpy as np  # noqa: E40211import pandas as pd  # noqa: E4021213SERIES = {"daily": "#2a78d6", "30m": "#eb6834", "5m": "#1baf7a", "1m": "#eda100", "fund": "#6250d6", "ref": "#8a8a86", "neg": "#e34948"}14MODEL_COLOR = {"B1": SERIES["fund"], "B2": SERIES["daily"], "B3": SERIES["30m"], "B4": SERIES["5m"], "B5": SERIES["1m"], "B5all": "#52514e",15               "LB1": SERIES["fund"], "LB2": SERIES["daily"], "LB3": SERIES["30m"], "LB4": SERIES["5m"], "LB5": SERIES["1m"], "LB5all": "#52514e"}16MODEL_LABEL = {"B1": "B1 fundamentals", "B2": "B2 + daily", "B3": "B3 + 30-min", "B4": "B4 + 5-min", "B5": "B5 + 1-min", "B5all": "all frequencies"}1718plt.rcParams.update({"font.family": "serif", "font.size": 9, "axes.spines.top": False, "axes.spines.right": False,19                     "axes.edgecolor": "#8a8a86", "axes.linewidth": 0.6, "xtick.color": "#52514e", "ytick.color": "#52514e",20                     "axes.labelcolor": "#0b0b0b", "grid.color": "#e6e5e1", "grid.linewidth": 0.5, "legend.frameon": False,21                     "figure.dpi": 150, "savefig.bbox": "tight"})222324def _save(fig, out: Path, name: str):25    out.mkdir(parents=True, exist_ok=True)26    fig.savefig(out / f"{name}.pdf"); fig.savefig(out / f"{name}.png", dpi=200); plt.close(fig)272829def fig_timeline(out: Path, h_list=(40, 20, 10, 5, 1)):30    """Event-time timeline and the no-leakage rule (schematic drawn from the protocol, labelled as such)."""31    fig, ax = plt.subplots(figsize=(6.4, 2.2))32    ax.set_xlim(-75, 15); ax.set_ylim(0, 3); ax.axis("off")33    ax.plot([-75, 12], [1, 1], color="#52514e", lw=1)34    for x, lab, c in ((-63, "$A_{q-1}$\nprevious release", SERIES["ref"]), (0, "$A_q$\nrelease of quarter $q$", SERIES["neg"]),35                      (1, "$F_q$ filing", SERIES["ref"]), (2, "$V_q=F_q+1$\nfacts available", SERIES["ref"])):36        ax.plot([x], [1], marker="|", ms=14, color=c, mew=1.5)37        ax.text(x, 0.15 if x not in (1,) else 1.55, lab, ha="center", va="center", fontsize=7, color="#0b0b0b")38    for i, h in enumerate(h_list):39        ax.plot([-h], [1], marker="o", ms=4, color=SERIES["1m"], mec="white", mew=0.8)40        ax.text(-h, 1.35 + 0.28 * (i % 2), f"$c=A_q-{h}$", ha="center", fontsize=6.5, color="#52514e")41    ax.add_patch(plt.Rectangle((-40, 0.85), 20, 0.3, color=SERIES["1m"], alpha=0.25, lw=0))42    ax.text(-30, 2.6, "20-session feature window ends at $c$; bars after $c$ and facts with $V_j>c$ never enter the information set",43            ha="center", fontsize=7, color="#52514e")44    ax.set_title("Event-time design (schematic of the protocol, not data)", fontsize=8, loc="left")45    _save(fig, out, "fig01_timeline")464748def fig_coverage(out: Path, years: pd.DataFrame, sizes: pd.DataFrame | None = None):49    """years: columns year, firms, firm_quarters, adverse_rate."""50    fig, axes = plt.subplots(1, 2, figsize=(6.4, 2.4))51    ax = axes[0]52    ax.bar(years["year"], years["firms"], color=SERIES["daily"], width=0.8)53    ax.set_ylabel("Firms in sample"); ax.set_xlabel("Year of cutoff"); ax.grid(axis="y")54    for x, v in zip(years["year"].iloc[::3], years["firms"].iloc[::3]):55        ax.text(x, v, f"{int(v):,}", ha="center", va="bottom", fontsize=6, color="#52514e")56    ax = axes[1]57    ax.plot(years["year"], years["adverse_rate"], color=SERIES["neg"], lw=2, marker="o", ms=4, mec="white")58    ax.set_ylabel("Adverse-surprise rate"); ax.set_xlabel("Year of cutoff"); ax.grid(axis="y"); ax.set_ylim(0, None)59    _save(fig, out, "fig02_coverage")606162def fig_prevalence(out: Path, prev: pd.DataFrame):63    """prev: columns group (str), label, rate, lo, hi  — conditional adverse prevalence by ex-ante characteristics."""64    fig, ax = plt.subplots(figsize=(6.0, 2.6))65    y = np.arange(len(prev))66    ax.hlines(y, prev["lo"], prev["hi"], color="#c3c2b7", lw=2)67    ax.plot(prev["rate"], y, "o", color=SERIES["neg"], ms=5, mec="white")68    ax.set_yticks(y); ax.set_yticklabels(prev["label"]); ax.invert_yaxis()69    ax.set_xlabel("Share of firm-quarters with an adverse operating-income surprise (test period)"); ax.grid(axis="x")70    _save(fig, out, "fig03_prevalence")717273def fig_frequency_gains(out: Path, contrasts: pd.DataFrame):74    """Paired Δ log loss vs B2 for B3/B4/B5 (logistic and LightGBM), with 95 % block-bootstrap intervals."""75    fam = [("logistic", ""), ("lightgbm", "L")]76    fig, axes = plt.subplots(1, 2, figsize=(6.4, 2.6), sharey=True)77    for ax, (name, pre) in zip(axes, fam):78        sub = contrasts[(contrasts.family == name) & (contrasts.b == pre + "B2") & (contrasts.a.isin([pre + "B3", pre + "B4", pre + "B5", pre + "B5all"]))]79        order = [pre + "B3", pre + "B4", pre + "B5", pre + "B5all"]80        sub = sub.set_index("a").reindex([o for o in order if o in sub.a.values]).reset_index()81        x = np.arange(len(sub))82        for i, r in sub.iterrows():83            c = MODEL_COLOR.get(r["a"].replace("L", "", 1) if r["a"].startswith("L") else r["a"], "#52514e")84            ax.vlines(i, r["ci_lo"], r["ci_hi"], color=c, lw=2)85            ax.plot(i, r["d_logloss"], "o", color=c, ms=6, mec="white")86            ax.text(i, r["ci_hi"], f"{r['d_logloss']:+.4f}", ha="center", va="bottom", fontsize=6.5, color="#52514e")87        ax.axhline(0, color="#8a8a86", lw=0.8)88        ax.set_xticks(x); ax.set_xticklabels([MODEL_LABEL[a.replace("L", "", 1) if a.startswith("L") else a].replace("B5all", "all") for a in sub.a], fontsize=7)89        ax.set_title(f"{name}", fontsize=8, loc="left"); ax.grid(axis="y")90    axes[0].set_ylabel("Δ log loss vs. B2 (daily + accounting)\nnegative = better")91    _save(fig, out, "fig04_frequency_gains")929394def fig_reliability(out: Path, rel: pd.DataFrame):95    fig, ax = plt.subplots(figsize=(3.6, 3.2))96    ax.plot([0, rel[["mean_pred", "obs_rate"]].max().max()], [0, rel[["mean_pred", "obs_rate"]].max().max()], color="#8a8a86", lw=0.8, ls="--")97    for m in ("LB2", "LB5", "B2", "B5"):98        s = rel[rel.model == m]99        if len(s):100            ax.plot(s["mean_pred"], s["obs_rate"], marker="o", ms=4, mec="white", lw=1.6, color=MODEL_COLOR[m], label=m)101    ax.set_xlabel("Mean predicted probability (decile)"); ax.set_ylabel("Observed adverse rate"); ax.legend(fontsize=7); ax.grid()102    _save(fig, out, "fig05_reliability")103104105def fig_signal_evolution(out: Path, ladder: pd.DataFrame):106    """Mean AUC and Δ log loss by horizon (all firm-quarters, no selection)."""107    fig, axes = plt.subplots(1, 2, figsize=(6.4, 2.5))108    for fam, c in (("logistic", SERIES["daily"]), ("lightgbm", SERIES["1m"])):109        s = ladder[ladder.family == fam].sort_values("h", ascending=False)110        axes[0].plot(-s["h"], s["auc_a"], marker="o", ms=4, mec="white", lw=1.6, color=c, label=f"{fam}: B5")111        axes[0].plot(-s["h"], s["auc_b"], marker="s", ms=3.5, mec="white", lw=1.2, ls="--", color=c, label=f"{fam}: B2")112        axes[1].errorbar(-s["h"], s["d_logloss"], yerr=[s["d_logloss"] - s["ci_lo"], s["ci_hi"] - s["d_logloss"]], fmt="o-", ms=4, lw=1.4,113                         color=c, capsize=2, label=fam)114    axes[0].set_xlabel("Trading days before the release (−h)"); axes[0].set_ylabel("Test AUC"); axes[0].legend(fontsize=6); axes[0].grid()115    axes[1].axhline(0, color="#8a8a86", lw=0.8); axes[1].set_xlabel("Trading days before the release (−h)")116    axes[1].set_ylabel("Δ log loss B5 − B2"); axes[1].legend(fontsize=7); axes[1].grid()117    _save(fig, out, "fig06_signal_evolution")118119120def fig_heterogeneity(out: Path, sub: pd.DataFrame, groups: list[str]):121    s = sub[(sub.family == "lightgbm") & sub.subgroup.isin(groups)].set_index("subgroup").reindex(groups).reset_index()122    fig, ax = plt.subplots(figsize=(6.0, 2.8))123    y = np.arange(len(s))124    ax.hlines(y, s["ci_lo"], s["ci_hi"], color="#c3c2b7", lw=2)125    ax.plot(s["mean"], y, "o", color=SERIES["1m"], ms=5, mec="white")126    for yi, r in zip(y, s.itertuples()):127        ax.text(r.ci_hi, yi, f"  n={int(r.n):,}", va="center", fontsize=6, color="#52514e")128    ax.axvline(0, color="#8a8a86", lw=0.8)129    ax.set_yticks(y); ax.set_yticklabels([g.replace("_", " ") for g in s["subgroup"]]); ax.invert_yaxis()130    ax.set_xlabel("Δ log loss LB5 − LB2 (test period), 95 % block-bootstrap CI"); ax.grid(axis="x")131    _save(fig, out, "fig07_heterogeneity")132133134def fig_rolling(out: Path, wf: pd.DataFrame, wfc: pd.DataFrame):135    fig, axes = plt.subplots(1, 2, figsize=(6.4, 2.5))136    for m in ("LB2", "LB5", "B2", "B5"):137        s = wf[wf.model == m].sort_values("year")138        if len(s):139            axes[0].plot(s["year"], s["auc"], marker="o", ms=3.5, mec="white", lw=1.4, color=MODEL_COLOR[m], ls="-" if m.startswith("L") else "--", label=m)140    axes[0].set_ylabel("Out-of-fold AUC (annual refits)"); axes[0].legend(fontsize=6, ncol=2); axes[0].grid()141    for fam, c in (("lightgbm", SERIES["1m"]), ("logistic", SERIES["daily"])):142        s = wfc[(wfc.family == fam) & (wfc.year != "all")].copy(); s["year"] = s["year"].astype(int); s = s.sort_values("year")143        axes[1].errorbar(s["year"], s["d_logloss"], yerr=[s["d_logloss"] - s["ci_lo"], s["ci_hi"] - s["d_logloss"]], fmt="o-", ms=3.5, lw=1.2, capsize=2, color=c, label=fam)144    axes[1].axhline(0, color="#8a8a86", lw=0.8); axes[1].set_ylabel("Δ log loss B5 − B2 by year"); axes[1].legend(fontsize=7); axes[1].grid()145    _save(fig, out, "fig08_rolling")146147148def fig_economic(out: Path, port: pd.DataFrame, alphas: dict):149    fig, ax = plt.subplots(figsize=(4.2, 2.6))150    dm = port.groupby("decile")["ret"].mean() * 100151    ax.bar(dm.index, dm.values, color=SERIES["daily"], width=0.8)152    ax.axhline(0, color="#8a8a86", lw=0.8); ax.set_xlabel("Decile of ex-ante deterioration score (10 = most likely)")153    ax.set_ylabel("Mean monthly return (%)"); ax.grid(axis="y")154    ax.set_title(f"L/S 1−10 alpha: {alphas['gross']['alpha_monthly']*100:+.2f}% (t={alphas['gross']['alpha_t']:.1f}) gross, "155                 f"{alphas['net20bps']['alpha_monthly']*100:+.2f}% net of 20 bps", fontsize=7, loc="left")156    _save(fig, out, "fig09_economic")157
Files · 9c1ba37e427b8a0b6f723e8da9a1de7cbc69fcbe
Menu

Theme