1"""Journal-style figures (vector PDF): Times/STIX typography, monochrome marks distinguished by shape and line style,2panel labels, inward ticks, no chart junk. Every exhibit is drawn from saved artifacts; the only schematic (timeline) is3labelled as such. Widths follow journal columns: 3.25 in (single) and 6.5 in (double)."""4from __future__ import annotations56from pathlib import Path78import matplotlib9matplotlib.use("Agg")10import matplotlib.pyplot as plt # noqa: E40211import numpy as np # noqa: E40212import pandas as pd # noqa: E40213from matplotlib import ticker # noqa: E4021415INK, INK2, INK3, INK4 = "#000000", "#4d4d4d", "#8c8c8c", "#c8c8c8"16ACCENT = "#1f4e79" # single restrained accent (1-minute series) — passes the contrast check on white1718# Fixed encoding per information set: marker, line style, colour, label19STYLE = {20 "B1": dict(marker="x", ls=":", color=INK3, label="B1 fundamentals"),21 "B2": dict(marker="o", ls="-", color=INK, label="B2 + daily"),22 "B3": dict(marker="s", ls="--", color=INK2, label="B3 + 30-min"),23 "B4": dict(marker="^", ls="-.", color=INK3, label="B4 + 5-min"),24 "B5": dict(marker="D", ls="-", color=ACCENT, label="B5 + 1-min"),25 "B5all": dict(marker="v", ls=":", color=INK2, label="all frequencies"),26}27for k in list(STYLE):28 STYLE["L" + k] = dict(STYLE[k], label=STYLE[k]["label"].replace("B", "LB", 1))2930plt.rcParams.update({31 "font.family": "serif", "font.serif": ["Times New Roman", "Times", "STIX Two Text", "DejaVu Serif"],32 "mathtext.fontset": "stix", "font.size": 9, "axes.titlesize": 9, "axes.labelsize": 9, "legend.fontsize": 8,33 "xtick.labelsize": 8, "ytick.labelsize": 8,34 "axes.spines.top": False, "axes.spines.right": False, "axes.edgecolor": INK, "axes.linewidth": 0.6,35 "xtick.direction": "in", "ytick.direction": "in", "xtick.major.size": 3, "ytick.major.size": 3,36 "xtick.major.width": 0.6, "ytick.major.width": 0.6, "axes.grid": False, "legend.frameon": False,37 "lines.linewidth": 1.1, "lines.markersize": 4.5, "lines.markeredgewidth": 0.8,38 "figure.dpi": 150, "savefig.bbox": "tight", "savefig.pad_inches": 0.02, "pdf.fonttype": 42,39})40W1, W2 = 3.25, 6.5414243def _panel(ax, letter: str, title: str):44 ax.set_title(f"Panel {letter}. {title}", loc="left", fontsize=9, fontweight="bold", pad=6)454647def _thousands(ax, axis="y"):48 (ax.yaxis if axis == "y" else ax.xaxis).set_major_formatter(ticker.FuncFormatter(lambda v, _: f"{v:,.0f}"))495051def _save(fig, out: Path, name: str):52 out.mkdir(parents=True, exist_ok=True)53 fig.savefig(out / f"{name}.pdf")54 fig.savefig(out / f"{name}.png", dpi=220)55 plt.close(fig)565758def _errorbar(ax, x, y, lo, hi, **kw):59 ax.errorbar(x, y, yerr=[np.asarray(y) - np.asarray(lo), np.asarray(hi) - np.asarray(y)], capsize=2.5, capthick=0.7,60 elinewidth=0.7, **kw)616263# ----------------------------------------------------------------------------------------------------- Figure 164def fig_timeline(out: Path, h_list=(40, 20, 10, 5, 1)):65 """Schematic (not to scale beyond the cutoffs): events after A_q are spread out for legibility."""66 fig, ax = plt.subplots(figsize=(W2, 2.5))67 ax.set_xlim(-74, 24); ax.set_ylim(-1.9, 2.45); ax.axis("off")68 ax.annotate("", xy=(22, 0), xytext=(-74, 0), arrowprops=dict(arrowstyle="-|>", lw=0.9, color=INK))69 ax.text(22.5, 0, "time", va="center", ha="left", fontsize=7, color=INK2)70 # feature window for h = 2071 ax.add_patch(plt.Rectangle((-40, -0.26), 20, 0.52, facecolor=INK4, edgecolor="none"))72 ax.text(-30, 0.95, "20-session feature window for $h=20$\n(bars with session $\\leq c$)", ha="center", va="bottom", fontsize=7)73 # events with alternating label positions and leader lines74 events = [(-63, "$A_{q-1}$: previous release", -1.05, INK2), (0, "$A_q$: earnings release\n(reveal date)", -1.05, INK),75 (7, "$F_q$: SEC filing", 1.08, INK2), (12, "$V_q=F_q+1$: facts available", 1.75, INK2)]76 for x, lab, ytxt, c in events:77 ax.plot([x, x], [-0.42, 0.42], color=c, lw=1.2)78 if ytxt < 0:79 ax.plot([x, x], [-0.42, ytxt + 0.1], color=c, lw=0.5, ls=":")80 ax.text(x, ytxt, lab, ha="center", va="top", fontsize=7, color=c)81 else:82 ax.plot([x, x], [0.42, ytxt - 0.08], color=c, lw=0.5, ls=":")83 ax.text(x, ytxt, lab, ha="center", va="bottom", fontsize=7, color=c)84 for h in h_list:85 ax.plot([-h], [0], marker="D", ms=4, color=ACCENT, mec="white", mew=0.6, zorder=5)86 ax.text(-h, 0.3, f"$-{h}$", ha="center", va="bottom", fontsize=6.5, color=ACCENT)87 ax.text(-74, 2.3, "cutoffs $c=A_q-h$ trading days, $h\\in\\{40,20,10,5,1\\}$; admissible iff $c<\\min(A_q,F_q)$ and $c>A_{q-1}$",88 ha="left", va="top", fontsize=7.5)89 ax.text(22, -1.85, "Schematic of the protocol (not data; spacing after $A_q$ exaggerated).", ha="right", va="bottom", fontsize=6.5, color=INK3, style="italic")90 _save(fig, out, "fig01_timeline")919293# ----------------------------------------------------------------------------------------------------- Figure 294def fig_coverage(out: Path, years: pd.DataFrame, sizes: pd.DataFrame | None = None):95 fig, axes = plt.subplots(1, 2, figsize=(W2, 2.5))96 ax = axes[0]97 ax.bar(years["year"], years["firms"], color=INK2, width=0.72, edgecolor="none")98 _panel(ax, "A", "Firms in the primary cohort by year")99 ax.set_xlabel("Year of cutoff"); ax.set_ylabel("Number of firms"); _thousands(ax)100 ax.set_xticks(years["year"][::2]); ax.tick_params(axis="x", rotation=0)101 ax = axes[1]102 ax.plot(years["year"], 100 * years["adverse_rate"], color=INK, marker="o", ms=4, mfc="white")103 _panel(ax, "B", "Adverse-surprise rate by year (%)")104 ax.set_xlabel("Year of cutoff"); ax.set_ylabel("Share of firm-quarters (%)"); ax.set_ylim(0, None)105 ax.set_xticks(years["year"][::2])106 ax.axvspan(2020.5, 2026.9, color=INK4, alpha=0.35, lw=0); ax.text(2023.7, 2, "test period", ha="center", fontsize=7, color=INK2)107 fig.subplots_adjust(wspace=0.32)108 _save(fig, out, "fig02_coverage")109110111# ----------------------------------------------------------------------------------------------------- Figure 3112def fig_prevalence(out: Path, prev: pd.DataFrame):113 fig, ax = plt.subplots(figsize=(W2, 2.9))114 y = np.arange(len(prev))115 ax.hlines(y, 100 * prev["lo"], 100 * prev["hi"], color=INK, lw=0.9)116 ax.plot(100 * prev["rate"], y, "o", color=INK, ms=5, mfc="white")117 for yi, r in zip(y, prev.itertuples()):118 ax.text(100 * r.hi + 0.4, yi, f"{100 * r.rate:.1f}", va="center", ha="left", fontsize=7, color=INK2)119 ax.set_yticks(y); ax.set_yticklabels(prev["label"]); ax.invert_yaxis()120 ax.set_xlabel("Adverse operating-income surprise, share of firm-quarters (%), test period")121 ax.axvline(100 * prev["rate"].iloc[0], color=INK4, lw=0.8, zorder=0)122 ax.spines["left"].set_visible(False); ax.tick_params(axis="y", length=0)123 _save(fig, out, "fig03_prevalence")124125126# ----------------------------------------------------------------------------------------------------- Figure 4127def fig_frequency_gains(out: Path, contrasts: pd.DataFrame):128 fams = [("logistic", "", "Penalised logistic regression"), ("lightgbm", "L", "Gradient-boosted trees (equal budget)")]129 fig, axes = plt.subplots(1, 2, figsize=(W2, 2.9), sharey=True)130 order = ["B3", "B4", "B5", "B5all"]131 for ax, (fam, pre, title), letter in zip(axes, fams, "AB"):132 sub = contrasts[(contrasts.family == fam) & (contrasts.b == pre + "B2")].copy()133 sub["key"] = sub["a"].str.replace("^L", "", regex=True)134 sub = sub.set_index("key").reindex([o for o in order if o in sub.key.values]).reset_index()135 x = np.arange(len(sub))136 for i, r in sub.iterrows():137 st = STYLE[r["key"]]138 _errorbar(ax, [i], [r["d_logloss"]], [r["ci_lo"]], [r["ci_hi"]], fmt=st["marker"], color=st["color"], mfc="white", ms=5.5)139 ax.text(i, r["ci_hi"] + 0.0002, f"{r['d_logloss']:+.4f}", ha="center", va="bottom", fontsize=7)140 ax.axhline(0, color=INK, lw=0.7)141 ax.set_xticks(x); ax.set_xticklabels(["+ 30-min", "+ 5-min", "+ 1-min", "+ all"][: len(sub)])142 ax.set_xlim(-0.6, len(sub) - 0.4)143 _panel(ax, letter, title)144 axes[0].set_ylabel("$\\Delta$ log loss relative to B2 (daily + accounting)")145 axes[0].text(-0.5, axes[0].get_ylim()[0] * 0.95 if axes[0].get_ylim()[0] < 0 else 0, "", fontsize=7)146 axes[0].yaxis.set_major_formatter(ticker.FormatStrFormatter("%+.3f"))147 fig.subplots_adjust(wspace=0.12)148 _save(fig, out, "fig04_frequency_gains")149150151# ----------------------------------------------------------------------------------------------------- Figure 5152def fig_reliability(out: Path, rel: pd.DataFrame):153 fig, axes = plt.subplots(1, 2, figsize=(W2, 2.9), sharey=True)154 for ax, models, letter, title in ((axes[0], ("B2", "B5"), "A", "Logistic"), (axes[1], ("LB2", "LB5"), "B", "Gradient-boosted trees")):155 m = rel[rel.model.isin(models)]156 top = max(m["mean_pred"].max(), m["obs_rate"].max()) * 1.05 if len(m) else 1157 ax.plot([0, top], [0, top], color=INK4, lw=0.8)158 for mod in models:159 s = rel[rel.model == mod]160 st = STYLE[mod]161 ax.plot(s["mean_pred"], s["obs_rate"], marker=st["marker"], ls=st["ls"], color=st["color"], mfc="white", label=st["label"])162 ax.set_xlabel("Mean predicted probability (decile)"); _panel(ax, letter, title); ax.legend(loc="upper left")163 ax.set_xlim(0, top); ax.set_ylim(0, top)164 axes[0].set_ylabel("Observed adverse rate")165 fig.subplots_adjust(wspace=0.08)166 _save(fig, out, "fig05_reliability")167168169# ----------------------------------------------------------------------------------------------------- Figure 6170def fig_signal_evolution(out: Path, ladder: pd.DataFrame):171 lad = ladder[ladder.h > 0]172 hs = [40, 20, 10, 5, 1]173 pos = {h: i for i, h in enumerate(hs)}174 fig, axes = plt.subplots(1, 2, figsize=(W2, 2.9))175 ax = axes[0]176 for fam, b, a in (("logistic", "B2", "B5"), ("lightgbm", "LB2", "LB5")):177 s = lad[lad.family == fam].sort_values("h", ascending=False)178 x = [pos[h] for h in s["h"]]179 ax.plot(x, s["auc_b"], marker=STYLE[b]["marker"], ls=STYLE[b]["ls"], color=STYLE[b]["color"], mfc="white", label="B2 (+ daily)" if fam == "logistic" else None)180 ax.plot(x, s["auc_a"], marker=STYLE[a]["marker"], ls=STYLE[a]["ls"], color=STYLE[a]["color"], mfc="white", label="B5 (+ 1-min)" if fam == "logistic" else None)181 ax.text(len(hs) - 0.9, float(s["auc_b"].iloc[-1]) + 0.012, "gradient-boosted trees" if fam == "lightgbm" else "logistic", ha="right", va="bottom", fontsize=7.5, color=INK2)182 ax.set_xlabel("Trading days before the release"); ax.set_ylabel("Test AUC"); _panel(ax, "A", "Discrimination by horizon")183 ax.legend(loc="center", fontsize=7.5); ax.set_ylim(0.70, 0.84)184 ax = axes[1]185 for fam, mk, c, lab, off in (("logistic", "o", INK, "logistic", -0.08), ("lightgbm", "D", ACCENT, "gradient-boosted trees", 0.08)):186 s = lad[lad.family == fam].sort_values("h", ascending=False)187 x = np.array([pos[h] for h in s["h"]]) + off188 _errorbar(ax, x, s["d_logloss"], s["ci_lo"], s["ci_hi"], fmt=mk, color=c, mfc="white", ls="-", lw=0.9, label=lab)189 ax.axhline(0, color=INK, lw=0.7); ax.set_xlabel("Trading days before the release")190 ax.set_ylabel("$\\Delta$ log loss, B5 $-$ B2"); _panel(ax, "B", "The 1-minute increment by horizon")191 lo, hi = ax.get_ylim(); ax.set_ylim(lo, hi + 0.45 * (hi - lo)); ax.legend(loc="upper left", fontsize=7.5, ncol=2, columnspacing=1.0)192 ax.yaxis.set_major_formatter(ticker.FormatStrFormatter("%+.3f"))193 for a_ in axes:194 a_.set_xticks(range(len(hs))); a_.set_xticklabels([str(h) for h in hs]); a_.set_xlim(-0.5, len(hs) - 0.5)195 fig.subplots_adjust(wspace=0.32)196 _save(fig, out, "fig06_signal_evolution")197198199# ----------------------------------------------------------------------------------------------------- Figure 7200def fig_heterogeneity(out: Path, sub: pd.DataFrame, groups: list[str]):201 labels = {"all": "All firm-quarters", "size_small": "Size: bottom tercile", "size_large": "Size: top tercile",202 "illiq_high": "Illiquidity: top tercile", "illiq_low": "Illiquidity: bottom tercile", "lev_high": "Leverage: top tercile",203 "lev_low": "Leverage: bottom tercile", "loss_firms": "Loss-making firms", "profit_firms": "Profitable firms",204 "covid_2020": "2020", "ex_covid": "Excluding 2020", "window_clean": "Window excludes previous release",205 "window_straddles_prev_release": "Window contains previous release", "dense_bars": "Dense bars ($\\geq$ 98%)",206 "sparse_bars": "Sparser bars", "sector_finance": "Financials", "sector_nonfinance": "Non-financials",207 "after_close_release": "Release after close", "before_open_release": "Release before open"}208 s = sub[(sub.family == "lightgbm") & sub.subgroup.isin(groups)].set_index("subgroup").reindex(groups).reset_index().dropna(subset=["n"])209 fig, ax = plt.subplots(figsize=(W2, 3.6))210 y = np.arange(len(s))211 ax.hlines(y, s["ci_lo"], s["ci_hi"], color=INK, lw=0.9)212 ax.plot(s["mean"], y, "D", color=ACCENT, ms=4.5, mfc="white")213 xmax = s["ci_hi"].max()214 for yi, r in zip(y, s.itertuples()):215 ax.text(xmax + 0.0004, yi, f"{int(r.n):,}", va="center", ha="left", fontsize=7, color=INK2)216 ax.text(xmax + 0.0004, -1.0, "$N$", ha="left", va="center", fontsize=7, color=INK2)217 ax.axvline(0, color=INK, lw=0.7)218 ax.set_yticks(y); ax.set_yticklabels([labels.get(g, g) for g in s["subgroup"]]); ax.invert_yaxis()219 ax.set_xlabel("$\\Delta$ log loss, LB5 $-$ LB2 (test period); 95% block-bootstrap interval")220 ax.xaxis.set_major_formatter(ticker.FormatStrFormatter("%+.3f"))221 ax.spines["left"].set_visible(False); ax.tick_params(axis="y", length=0)222 ax.set_ylim(len(s) - 0.4, -1.4)223 _save(fig, out, "fig07_heterogeneity")224225226# ----------------------------------------------------------------------------------------------------- Figure 8227def fig_rolling(out: Path, wf: pd.DataFrame, wfc: pd.DataFrame):228 fig, axes = plt.subplots(1, 2, figsize=(W2, 2.9))229 ax = axes[0]230 for m in ("B2", "B5", "LB2", "LB5"):231 s = wf[wf.model == m].sort_values("year")232 if len(s):233 st = STYLE[m]234 ax.plot(s["year"], s["auc"], marker=st["marker"], ls=st["ls"], color=st["color"], mfc="white", label=st["label"])235 ax.set_ylabel("Out-of-fold AUC"); ax.set_xlabel("Year of cutoff"); _panel(ax, "A", "Annual expanding-window refits")236 ax.legend(ncol=2, fontsize=7, loc="lower center"); ax.set_ylim(0.66, 0.86)237 ax = axes[1]238 for fam, mk, c, lab, off in (("logistic", "o", INK, "logistic", -0.12), ("lightgbm", "D", ACCENT, "gradient-boosted trees", 0.12)):239 s = wfc[(wfc.family == fam) & (wfc.year.astype(str) != "all")].copy(); s["year"] = s["year"].astype(int); s = s.sort_values("year")240 _errorbar(ax, s["year"] + off, s["d_logloss"], s["ci_lo"], s["ci_hi"], fmt=mk, color=c, mfc="white", ls="none", label=lab)241 ax.axhline(0, color=INK, lw=0.7); ax.set_ylabel("$\\Delta$ log loss, B5 $-$ B2"); ax.set_xlabel("Year of cutoff")242 _panel(ax, "B", "The 1-minute increment by year"); ax.legend(loc="upper left"); ax.yaxis.set_major_formatter(ticker.FormatStrFormatter("%+.3f"))243 for a in axes:244 a.set_xticks(range(2016, 2027, 2))245 fig.subplots_adjust(wspace=0.32)246 _save(fig, out, "fig08_rolling")247248249# ----------------------------------------------------------------------------------------------------- Figure 9250def fig_economic(out: Path, port: pd.DataFrame, alphas: dict):251 fig, ax = plt.subplots(figsize=(W1 + 0.6, 2.7))252 dm = port.groupby("decile")["ret"].mean() * 100253 se = port.groupby("decile")["ret"].std() / np.sqrt(port.groupby("decile")["ret"].count()) * 100254 ax.bar(dm.index, dm.values, color=INK3, width=0.7, edgecolor="none")255 ax.errorbar(dm.index, dm.values, yerr=1.96 * se.values, fmt="none", ecolor=INK, elinewidth=0.7, capsize=2)256 ax.axhline(0, color=INK, lw=0.7); ax.set_xticks(range(1, 11))257 ax.set_xlabel("Decile of ex-ante deterioration score (10 = most likely)"); ax.set_ylabel("Mean monthly return (%)")258 g, n = alphas["gross"], alphas["net20bps"]259 ax.set_title(f"L/S (1$-$10) FF5+MOM alpha: {100 * g['alpha_monthly']:+.2f}% per month ($t={g['alpha_t']:.2f}$) gross, "260 f"{100 * n['alpha_monthly']:+.2f}% net of 20 bps", loc="left", fontsize=7.5)261 _save(fig, out, "fig09_economic")262