"""Journal-style figures (vector PDF): Times/STIX typography, monochrome marks distinguished by shape and line style, panel labels, inward ticks, no chart junk. Every exhibit is drawn from saved artifacts; the only schematic (timeline) is labelled as such. Widths follow journal columns: 3.25 in (single) and 6.5 in (double).""" from __future__ import annotations from pathlib import Path import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt # noqa: E402 import numpy as np # noqa: E402 import pandas as pd # noqa: E402 from matplotlib import ticker # noqa: E402 INK, INK2, INK3, INK4 = "#000000", "#4d4d4d", "#8c8c8c", "#c8c8c8" ACCENT = "#1f4e79" # single restrained accent (1-minute series) — passes the contrast check on white # Fixed encoding per information set: marker, line style, colour, label STYLE = { "B1": dict(marker="x", ls=":", color=INK3, label="B1 fundamentals"), "B2": dict(marker="o", ls="-", color=INK, label="B2 + daily"), "B3": dict(marker="s", ls="--", color=INK2, label="B3 + 30-min"), "B4": dict(marker="^", ls="-.", color=INK3, label="B4 + 5-min"), "B5": dict(marker="D", ls="-", color=ACCENT, label="B5 + 1-min"), "B5all": dict(marker="v", ls=":", color=INK2, label="all frequencies"), } for k in list(STYLE): STYLE["L" + k] = dict(STYLE[k], label=STYLE[k]["label"].replace("B", "LB", 1)) plt.rcParams.update({ "font.family": "serif", "font.serif": ["Times New Roman", "Times", "STIX Two Text", "DejaVu Serif"], "mathtext.fontset": "stix", "font.size": 9, "axes.titlesize": 9, "axes.labelsize": 9, "legend.fontsize": 8, "xtick.labelsize": 8, "ytick.labelsize": 8, "axes.spines.top": False, "axes.spines.right": False, "axes.edgecolor": INK, "axes.linewidth": 0.6, "xtick.direction": "in", "ytick.direction": "in", "xtick.major.size": 3, "ytick.major.size": 3, "xtick.major.width": 0.6, "ytick.major.width": 0.6, "axes.grid": False, "legend.frameon": False, "lines.linewidth": 1.1, "lines.markersize": 4.5, "lines.markeredgewidth": 0.8, "figure.dpi": 150, "savefig.bbox": "tight", "savefig.pad_inches": 0.02, "pdf.fonttype": 42, }) W1, W2 = 3.25, 6.5 def _panel(ax, letter: str, title: str): ax.set_title(f"Panel {letter}. {title}", loc="left", fontsize=9, fontweight="bold", pad=6) def _thousands(ax, axis="y"): (ax.yaxis if axis == "y" else ax.xaxis).set_major_formatter(ticker.FuncFormatter(lambda v, _: f"{v:,.0f}")) def _save(fig, out: Path, name: str): out.mkdir(parents=True, exist_ok=True) fig.savefig(out / f"{name}.pdf") fig.savefig(out / f"{name}.png", dpi=220) plt.close(fig) def _errorbar(ax, x, y, lo, hi, **kw): ax.errorbar(x, y, yerr=[np.asarray(y) - np.asarray(lo), np.asarray(hi) - np.asarray(y)], capsize=2.5, capthick=0.7, elinewidth=0.7, **kw) # ----------------------------------------------------------------------------------------------------- Figure 1 def fig_timeline(out: Path, h_list=(40, 20, 10, 5, 1)): """Schematic (not to scale beyond the cutoffs): events after A_q are spread out for legibility.""" fig, ax = plt.subplots(figsize=(W2, 2.5)) ax.set_xlim(-74, 24); ax.set_ylim(-1.9, 2.45); ax.axis("off") ax.annotate("", xy=(22, 0), xytext=(-74, 0), arrowprops=dict(arrowstyle="-|>", lw=0.9, color=INK)) ax.text(22.5, 0, "time", va="center", ha="left", fontsize=7, color=INK2) # feature window for h = 20 ax.add_patch(plt.Rectangle((-40, -0.26), 20, 0.52, facecolor=INK4, edgecolor="none")) ax.text(-30, 0.95, "20-session feature window for $h=20$\n(bars with session $\\leq c$)", ha="center", va="bottom", fontsize=7) # events with alternating label positions and leader lines events = [(-63, "$A_{q-1}$: previous release", -1.05, INK2), (0, "$A_q$: earnings release\n(reveal date)", -1.05, INK), (7, "$F_q$: SEC filing", 1.08, INK2), (12, "$V_q=F_q+1$: facts available", 1.75, INK2)] for x, lab, ytxt, c in events: ax.plot([x, x], [-0.42, 0.42], color=c, lw=1.2) if ytxt < 0: ax.plot([x, x], [-0.42, ytxt + 0.1], color=c, lw=0.5, ls=":") ax.text(x, ytxt, lab, ha="center", va="top", fontsize=7, color=c) else: ax.plot([x, x], [0.42, ytxt - 0.08], color=c, lw=0.5, ls=":") ax.text(x, ytxt, lab, ha="center", va="bottom", fontsize=7, color=c) for h in h_list: ax.plot([-h], [0], marker="D", ms=4, color=ACCENT, mec="white", mew=0.6, zorder=5) ax.text(-h, 0.3, f"$-{h}$", ha="center", va="bottom", fontsize=6.5, color=ACCENT) 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}$", ha="left", va="top", fontsize=7.5) 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") _save(fig, out, "fig01_timeline") # ----------------------------------------------------------------------------------------------------- Figure 2 def fig_coverage(out: Path, years: pd.DataFrame, sizes: pd.DataFrame | None = None): fig, axes = plt.subplots(1, 2, figsize=(W2, 2.5)) ax = axes[0] ax.bar(years["year"], years["firms"], color=INK2, width=0.72, edgecolor="none") _panel(ax, "A", "Firms in the primary cohort by year") ax.set_xlabel("Year of cutoff"); ax.set_ylabel("Number of firms"); _thousands(ax) ax.set_xticks(years["year"][::2]); ax.tick_params(axis="x", rotation=0) ax = axes[1] ax.plot(years["year"], 100 * years["adverse_rate"], color=INK, marker="o", ms=4, mfc="white") _panel(ax, "B", "Adverse-surprise rate by year (%)") ax.set_xlabel("Year of cutoff"); ax.set_ylabel("Share of firm-quarters (%)"); ax.set_ylim(0, None) ax.set_xticks(years["year"][::2]) 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) fig.subplots_adjust(wspace=0.32) _save(fig, out, "fig02_coverage") # ----------------------------------------------------------------------------------------------------- Figure 3 def fig_prevalence(out: Path, prev: pd.DataFrame): fig, ax = plt.subplots(figsize=(W2, 2.9)) y = np.arange(len(prev)) ax.hlines(y, 100 * prev["lo"], 100 * prev["hi"], color=INK, lw=0.9) ax.plot(100 * prev["rate"], y, "o", color=INK, ms=5, mfc="white") for yi, r in zip(y, prev.itertuples()): ax.text(100 * r.hi + 0.4, yi, f"{100 * r.rate:.1f}", va="center", ha="left", fontsize=7, color=INK2) ax.set_yticks(y); ax.set_yticklabels(prev["label"]); ax.invert_yaxis() ax.set_xlabel("Adverse operating-income surprise, share of firm-quarters (%), test period") ax.axvline(100 * prev["rate"].iloc[0], color=INK4, lw=0.8, zorder=0) ax.spines["left"].set_visible(False); ax.tick_params(axis="y", length=0) _save(fig, out, "fig03_prevalence") # ----------------------------------------------------------------------------------------------------- Figure 4 def fig_frequency_gains(out: Path, contrasts: pd.DataFrame): fams = [("logistic", "", "Penalised logistic regression"), ("lightgbm", "L", "Gradient-boosted trees (equal budget)")] fig, axes = plt.subplots(1, 2, figsize=(W2, 2.9), sharey=True) order = ["B3", "B4", "B5", "B5all"] for ax, (fam, pre, title), letter in zip(axes, fams, "AB"): sub = contrasts[(contrasts.family == fam) & (contrasts.b == pre + "B2")].copy() sub["key"] = sub["a"].str.replace("^L", "", regex=True) sub = sub.set_index("key").reindex([o for o in order if o in sub.key.values]).reset_index() x = np.arange(len(sub)) for i, r in sub.iterrows(): st = STYLE[r["key"]] _errorbar(ax, [i], [r["d_logloss"]], [r["ci_lo"]], [r["ci_hi"]], fmt=st["marker"], color=st["color"], mfc="white", ms=5.5) ax.text(i, r["ci_hi"] + 0.0002, f"{r['d_logloss']:+.4f}", ha="center", va="bottom", fontsize=7) ax.axhline(0, color=INK, lw=0.7) ax.set_xticks(x); ax.set_xticklabels(["+ 30-min", "+ 5-min", "+ 1-min", "+ all"][: len(sub)]) ax.set_xlim(-0.6, len(sub) - 0.4) _panel(ax, letter, title) axes[0].set_ylabel("$\\Delta$ log loss relative to B2 (daily + accounting)") axes[0].text(-0.5, axes[0].get_ylim()[0] * 0.95 if axes[0].get_ylim()[0] < 0 else 0, "", fontsize=7) axes[0].yaxis.set_major_formatter(ticker.FormatStrFormatter("%+.3f")) fig.subplots_adjust(wspace=0.12) _save(fig, out, "fig04_frequency_gains") # ----------------------------------------------------------------------------------------------------- Figure 5 def fig_reliability(out: Path, rel: pd.DataFrame): fig, axes = plt.subplots(1, 2, figsize=(W2, 2.9), sharey=True) for ax, models, letter, title in ((axes[0], ("B2", "B5"), "A", "Logistic"), (axes[1], ("LB2", "LB5"), "B", "Gradient-boosted trees")): m = rel[rel.model.isin(models)] top = max(m["mean_pred"].max(), m["obs_rate"].max()) * 1.05 if len(m) else 1 ax.plot([0, top], [0, top], color=INK4, lw=0.8) for mod in models: s = rel[rel.model == mod] st = STYLE[mod] ax.plot(s["mean_pred"], s["obs_rate"], marker=st["marker"], ls=st["ls"], color=st["color"], mfc="white", label=st["label"]) ax.set_xlabel("Mean predicted probability (decile)"); _panel(ax, letter, title); ax.legend(loc="upper left") ax.set_xlim(0, top); ax.set_ylim(0, top) axes[0].set_ylabel("Observed adverse rate") fig.subplots_adjust(wspace=0.08) _save(fig, out, "fig05_reliability") # ----------------------------------------------------------------------------------------------------- Figure 6 def fig_signal_evolution(out: Path, ladder: pd.DataFrame): lad = ladder[ladder.h > 0] hs = [40, 20, 10, 5, 1] pos = {h: i for i, h in enumerate(hs)} fig, axes = plt.subplots(1, 2, figsize=(W2, 2.9)) ax = axes[0] for fam, b, a in (("logistic", "B2", "B5"), ("lightgbm", "LB2", "LB5")): s = lad[lad.family == fam].sort_values("h", ascending=False) x = [pos[h] for h in s["h"]] 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) 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) 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) ax.set_xlabel("Trading days before the release"); ax.set_ylabel("Test AUC"); _panel(ax, "A", "Discrimination by horizon") ax.legend(loc="center", fontsize=7.5); ax.set_ylim(0.70, 0.84) ax = axes[1] for fam, mk, c, lab, off in (("logistic", "o", INK, "logistic", -0.08), ("lightgbm", "D", ACCENT, "gradient-boosted trees", 0.08)): s = lad[lad.family == fam].sort_values("h", ascending=False) x = np.array([pos[h] for h in s["h"]]) + off _errorbar(ax, x, s["d_logloss"], s["ci_lo"], s["ci_hi"], fmt=mk, color=c, mfc="white", ls="-", lw=0.9, label=lab) ax.axhline(0, color=INK, lw=0.7); ax.set_xlabel("Trading days before the release") ax.set_ylabel("$\\Delta$ log loss, B5 $-$ B2"); _panel(ax, "B", "The 1-minute increment by horizon") 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) ax.yaxis.set_major_formatter(ticker.FormatStrFormatter("%+.3f")) for a_ in axes: a_.set_xticks(range(len(hs))); a_.set_xticklabels([str(h) for h in hs]); a_.set_xlim(-0.5, len(hs) - 0.5) fig.subplots_adjust(wspace=0.32) _save(fig, out, "fig06_signal_evolution") # ----------------------------------------------------------------------------------------------------- Figure 7 def fig_heterogeneity(out: Path, sub: pd.DataFrame, groups: list[str]): labels = {"all": "All firm-quarters", "size_small": "Size: bottom tercile", "size_large": "Size: top tercile", "illiq_high": "Illiquidity: top tercile", "illiq_low": "Illiquidity: bottom tercile", "lev_high": "Leverage: top tercile", "lev_low": "Leverage: bottom tercile", "loss_firms": "Loss-making firms", "profit_firms": "Profitable firms", "covid_2020": "2020", "ex_covid": "Excluding 2020", "window_clean": "Window excludes previous release", "window_straddles_prev_release": "Window contains previous release", "dense_bars": "Dense bars ($\\geq$ 98%)", "sparse_bars": "Sparser bars", "sector_finance": "Financials", "sector_nonfinance": "Non-financials", "after_close_release": "Release after close", "before_open_release": "Release before open"} s = sub[(sub.family == "lightgbm") & sub.subgroup.isin(groups)].set_index("subgroup").reindex(groups).reset_index().dropna(subset=["n"]) fig, ax = plt.subplots(figsize=(W2, 3.6)) y = np.arange(len(s)) ax.hlines(y, s["ci_lo"], s["ci_hi"], color=INK, lw=0.9) ax.plot(s["mean"], y, "D", color=ACCENT, ms=4.5, mfc="white") xmax = s["ci_hi"].max() for yi, r in zip(y, s.itertuples()): ax.text(xmax + 0.0004, yi, f"{int(r.n):,}", va="center", ha="left", fontsize=7, color=INK2) ax.text(xmax + 0.0004, -1.0, "$N$", ha="left", va="center", fontsize=7, color=INK2) ax.axvline(0, color=INK, lw=0.7) ax.set_yticks(y); ax.set_yticklabels([labels.get(g, g) for g in s["subgroup"]]); ax.invert_yaxis() ax.set_xlabel("$\\Delta$ log loss, LB5 $-$ LB2 (test period); 95% block-bootstrap interval") ax.xaxis.set_major_formatter(ticker.FormatStrFormatter("%+.3f")) ax.spines["left"].set_visible(False); ax.tick_params(axis="y", length=0) ax.set_ylim(len(s) - 0.4, -1.4) _save(fig, out, "fig07_heterogeneity") # ----------------------------------------------------------------------------------------------------- Figure 8 def fig_rolling(out: Path, wf: pd.DataFrame, wfc: pd.DataFrame): fig, axes = plt.subplots(1, 2, figsize=(W2, 2.9)) ax = axes[0] for m in ("B2", "B5", "LB2", "LB5"): s = wf[wf.model == m].sort_values("year") if len(s): st = STYLE[m] ax.plot(s["year"], s["auc"], marker=st["marker"], ls=st["ls"], color=st["color"], mfc="white", label=st["label"]) ax.set_ylabel("Out-of-fold AUC"); ax.set_xlabel("Year of cutoff"); _panel(ax, "A", "Annual expanding-window refits") ax.legend(ncol=2, fontsize=7, loc="lower center"); ax.set_ylim(0.66, 0.86) ax = axes[1] for fam, mk, c, lab, off in (("logistic", "o", INK, "logistic", -0.12), ("lightgbm", "D", ACCENT, "gradient-boosted trees", 0.12)): s = wfc[(wfc.family == fam) & (wfc.year.astype(str) != "all")].copy(); s["year"] = s["year"].astype(int); s = s.sort_values("year") _errorbar(ax, s["year"] + off, s["d_logloss"], s["ci_lo"], s["ci_hi"], fmt=mk, color=c, mfc="white", ls="none", label=lab) ax.axhline(0, color=INK, lw=0.7); ax.set_ylabel("$\\Delta$ log loss, B5 $-$ B2"); ax.set_xlabel("Year of cutoff") _panel(ax, "B", "The 1-minute increment by year"); ax.legend(loc="upper left"); ax.yaxis.set_major_formatter(ticker.FormatStrFormatter("%+.3f")) for a in axes: a.set_xticks(range(2016, 2027, 2)) fig.subplots_adjust(wspace=0.32) _save(fig, out, "fig08_rolling") # ----------------------------------------------------------------------------------------------------- Figure 9 def fig_economic(out: Path, port: pd.DataFrame, alphas: dict): fig, ax = plt.subplots(figsize=(W1 + 0.6, 2.7)) dm = port.groupby("decile")["ret"].mean() * 100 se = port.groupby("decile")["ret"].std() / np.sqrt(port.groupby("decile")["ret"].count()) * 100 ax.bar(dm.index, dm.values, color=INK3, width=0.7, edgecolor="none") ax.errorbar(dm.index, dm.values, yerr=1.96 * se.values, fmt="none", ecolor=INK, elinewidth=0.7, capsize=2) ax.axhline(0, color=INK, lw=0.7); ax.set_xticks(range(1, 11)) ax.set_xlabel("Decile of ex-ante deterioration score (10 = most likely)"); ax.set_ylabel("Mean monthly return (%)") g, n = alphas["gross"], alphas["net20bps"] ax.set_title(f"L/S (1$-$10) FF5+MOM alpha: {100 * g['alpha_monthly']:+.2f}% per month ($t={g['alpha_t']:.2f}$) gross, " f"{100 * n['alpha_monthly']:+.2f}% net of 20 bps", loc="left", fontsize=7.5) _save(fig, out, "fig09_economic")