1"""Publication figures (matplotlib, vector PDF). Palette validated with the dataviz checker (light surface)."""2from __future__ import annotations34from pathlib import Path56import matplotlib7matplotlib.use("Agg")8import matplotlib.pyplot as plt # noqa: E4029import numpy as np # noqa: E40210import pandas as pd # noqa: E4021112SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#6250d6", "#008300", "#e34948"]13INK, INK2, GRID = "#0b0b0b", "#52514e", "#e6e5e1"1415plt.rcParams.update({16 "font.family": "serif", "font.size": 9, "axes.labelsize": 9, "axes.titlesize": 9.5, "legend.fontsize": 8,17 "xtick.labelsize": 8, "ytick.labelsize": 8, "axes.edgecolor": INK2, "axes.linewidth": 0.6, "axes.grid": True,18 "grid.color": GRID, "grid.linewidth": 0.5, "axes.spines.top": False, "axes.spines.right": False,19 "lines.linewidth": 1.6, "figure.dpi": 150, "savefig.bbox": "tight", "pdf.fonttype": 42, "text.color": INK,20 "axes.labelcolor": INK, "xtick.color": INK2, "ytick.color": INK2, "legend.frameon": False,21})222324def save(fig, path: str | Path) -> None:25 path = Path(path)26 path.parent.mkdir(parents=True, exist_ok=True)27 fig.savefig(path)28 fig.savefig(path.with_suffix(".png"), dpi=200)29 plt.close(fig)303132def price_paths(runs: list[tuple[str, pd.DataFrame]], shock_t: int | None = None, title: str = "") -> plt.Figure:33 """Mid vs latent value for several runs (one panel per run)."""34 n = len(runs)35 fig, axes = plt.subplots(1, n, figsize=(2.9 * n, 2.4), sharey=True)36 axes = np.atleast_1d(axes)37 for ax, (label, tk), c in zip(axes, runs, SERIES):38 d = tk[tk.asset == 0]39 ax.plot(d.t, d.full_value / 100, color=INK2, lw=1.0, ls="--", label="latent value")40 ax.plot(d.t, d.public_value / 100, color=INK2, lw=0.7, ls=":", label="public value")41 ax.plot(d.t, d.mid / 100, color=c, lw=1.2, label="mid")42 if shock_t:43 ax.axvline(shock_t, color=GRID, lw=1.0)44 ax.set_title(label)45 ax.set_xlabel("tick")46 axes[0].set_ylabel("price")47 axes[0].legend(loc="upper left")48 if title:49 fig.suptitle(title, fontsize=9.5)50 return fig515253def dose_response(eff: dict[str, pd.DataFrame], ylabels: dict[str, str], x_col: str = "level", ncols: int = 3) -> plt.Figure:54 """eff: outcome -> DataFrame with columns level, diff, ci_lo, ci_hi (paired effects vs control)."""55 keys = list(eff)56 nrows = int(np.ceil(len(keys) / ncols))57 fig, axes = plt.subplots(nrows, ncols, figsize=(2.6 * ncols, 2.1 * nrows))58 axes = np.atleast_1d(axes).ravel()59 for ax, k in zip(axes, keys):60 d = eff[k]61 x = d[x_col].astype(float).to_numpy()62 ax.axhline(0, color=INK2, lw=0.7)63 ax.fill_between(x, d.ci_lo, d.ci_hi, color=SERIES[0], alpha=0.18, lw=0)64 ax.plot(x, d["diff"], color=SERIES[0], marker="o", ms=4)65 ax.set_title(ylabels.get(k, k))66 ax.set_xlabel("LLM share of trader slots")67 for ax in axes[len(keys):]:68 ax.axis("off")69 return fig707172def cell_bars(df: pd.DataFrame, outcome: str, cells: list[str], labels: dict[str, str] | None = None, ylabel: str = "",73 control: str | None = None) -> plt.Figure:74 g = df[df.cell.isin(cells)].groupby("cell")[outcome]75 m, se = g.mean().reindex(cells), (g.std(ddof=1) / np.sqrt(g.size())).reindex(cells)76 fig, ax = plt.subplots(figsize=(0.55 * len(cells) + 1.5, 2.3))77 cols = [SERIES[0] if c != control else INK2 for c in cells]78 ax.bar(range(len(cells)), m, yerr=1.96 * se, color=cols, width=0.62, error_kw={"lw": 0.8, "capsize": 2})79 ax.set_xticks(range(len(cells)))80 ax.set_xticklabels([(labels or {}).get(c, c) for c in cells], rotation=30, ha="right")81 ax.set_ylabel(ylabel or outcome)82 ax.grid(axis="x", visible=False)83 return fig848586def local_projection_plot(lp: dict[str, pd.DataFrame], title: str = "") -> plt.Figure:87 fig, ax = plt.subplots(figsize=(3.6, 2.4))88 for (label, d), c in zip(lp.items(), SERIES):89 ax.plot(d.h, 100 * d.beta, color=c, label=label)90 ax.fill_between(d.h, 100 * (d.beta - 1.96 * d.se), 100 * (d.beta + 1.96 * d.se), color=c, alpha=0.15, lw=0)91 ax.axhline(0, color=INK2, lw=0.7)92 ax.set_xlabel("ticks after the fundamental shock")93 ax.set_ylabel("treatment effect on log(P/V) gap, %")94 ax.legend()95 if title:96 ax.set_title(title)97 return fig9899100def scatter_mechanism(df: pd.DataFrame, x: str, y: str, hue: str, xlabel: str, ylabel: str) -> plt.Figure:101 fig, ax = plt.subplots(figsize=(3.4, 2.6))102 for (k, d), c in zip(df.groupby(hue), SERIES):103 ax.scatter(d[x], d[y], s=14, color=c, label=str(k), alpha=0.85, edgecolor="white", linewidth=0.4)104 ax.set_xlabel(xlabel)105 ax.set_ylabel(ylabel)106 ax.legend(title=hue)107 return fig108109110def wealth_and_inventory(agents: pd.DataFrame, ticks: pd.DataFrame | None = None) -> plt.Figure:111 fig, axes = plt.subplots(1, 2, figsize=(6.0, 2.4))112 ax = axes[0]113 for (pol, d), c in zip(agents.groupby("policy"), SERIES):114 ax.hist(d["pnl_pct"], bins=25, alpha=0.6, color=c, label=pol)115 ax.set_xlabel("P&L, % of initial wealth")116 ax.set_ylabel("traders")117 ax.legend()118 ax = axes[1]119 if ticks is not None:120 d = ticks[ticks.asset == 0]121 ax.plot(d.t, d.depth_bid5, color=SERIES[0], label="bid depth (5 levels)")122 ax.plot(d.t, d.depth_ask5, color=SERIES[1], label="ask depth")123 ax.set_xlabel("tick")124 ax.set_ylabel("lots")125 ax.legend()126 return fig127128129def cost_quality(df: pd.DataFrame, xcol: str, ycol: str, label_col: str, xlabel: str, ylabel: str) -> plt.Figure:130 fig, ax = plt.subplots(figsize=(3.4, 2.6))131 for (k, d), c in zip(df.groupby(label_col), SERIES):132 ax.errorbar(d[xcol].mean(), d[ycol].mean(), yerr=1.96 * d[ycol].std(ddof=1) / np.sqrt(len(d)) if len(d) > 1 else 0,133 fmt="o", color=c, ms=5, capsize=2, label=str(k))134 ax.set_xlabel(xlabel)135 ax.set_ylabel(ylabel)136 ax.legend()137 return fig138