1"""Generate every figure of the paper from saved outcomes/run artefacts. Each figure is written to2results/figures/ (PDF + PNG) and copied to paper/figures/ by `make tables`. Designs that have not been run3are skipped with a message (no figure is ever drawn from invented numbers)."""4from __future__ import annotations56import json7import sys8from pathlib import Path910import numpy as np11import pandas as pd1213ROOT = Path(__file__).resolve().parents[1]14sys.path.insert(0, str(ROOT / "src"))15from market_agents.analysis.estimators import dose_response, local_projection, paired_effects # noqa: E40216from market_agents.analysis import plots as P # noqa: E4021718RUNS = ROOT / "results/runs"19OUT = ROOT / "results/figures"20OUT.mkdir(parents=True, exist_ok=True)21LABELS = {"msd_log": "squared log pricing error", "mad_pct": "mean |P/V − 1|, %", "time_to_absorb": "time to absorb shock (ticks)",22 "response_ratio": "20-tick response ratio", "spread_bp": "quoted spread (bp)", "eff_spread_bp": "effective spread (bp)",23 "depth5": "depth, 5 levels (lots)", "lambda_bp_100": "price impact (bp / 100 lots)", "n_bubbles": "bubble episodes",24 "bubble_amp": "bubble amplitude", "max_dd": "max drawdown", "n_crashes": "crashes", "acf_absr_1_10": "vol. clustering (Σ ACF|r|)",25 "kurtosis": "excess kurtosis", "vr5": "variance ratio (5)", "acf_r_1": "return autocorrelation (lag 1)", "llm_synchrony": "LLM synchrony", "llm_pairwise_corr": "LLM order-flow corr."}262728def load(design: str) -> pd.DataFrame | None:29 p = RUNS / design / "outcomes.parquet"30 if not p.exists():31 print(f" [skip] {design}: not run yet")32 return None33 df = pd.read_parquet(p)34 df["cell"] = df["name"].str.split("/").str[-1]35 return df363738def ticks_of(run_dir: str) -> pd.DataFrame:39 return pd.read_parquet(Path(run_dir) / "ticks.parquet")404142def fig_baseline_paths():43 df = load("classical_confirmatory")44 if df is None:45 return46 rows = [df[(df.cell == c) & (df.seed == 101)].iloc[0] for c in ("control", "leverage_heavy_funding_shock", "liquidity_shock")]47 fig = P.price_paths([("control", ticks_of(rows[0].run_dir)), ("leverage 19 % + funding shock", ticks_of(rows[1].run_dir)),48 ("market makers withdraw", ticks_of(rows[2].run_dir))], shock_t=300)49 P.save(fig, OUT / "fig_baseline_paths.pdf")505152def fig_classical_mechanisms():53 df = load("classical_confirmatory")54 if df is None:55 return56 cells = ["control", "placebo_shock", "trend_heavy", "leverage_heavy", "leverage_heavy_funding_shock", "liquidity_shock",57 "info_low", "info_high", "misleading_news", "breaker_5", "poslimit_300", "auction_5"]58 lab = {"control": "control", "placebo_shock": "placebo", "trend_heavy": "trend 16 %", "leverage_heavy": "leverage 19 %",59 "leverage_heavy_funding_shock": "leverage + funding shock", "liquidity_shock": "MM withdraw", "info_low": "precision 0.5",60 "info_high": "precision 8", "misleading_news": "misleading news", "breaker_5": "circuit breaker 5 %", "poslimit_300": "position limit", "auction_5": "call auction"}61 for oc in ("mad_pct", "time_to_absorb", "max_dd", "n_bubbles", "spread_bp", "lambda_bp_100"):62 fig = P.cell_bars(df, oc, cells, lab, LABELS[oc], control="control")63 P.save(fig, OUT / f"fig_classical_{oc}.pdf")646566def fig_dose_response(design: str, tag: str):67 df = load(design)68 if df is None or "tr_arm" not in df:69 return70 d = df[df.tr_arm == "dose"].copy()71 d["share"] = d["tr_share"].astype(float)72 if d["share"].nunique() < 3:73 return74 eff = {}75 for oc in ("mad_pct", "time_to_absorb", "vr5", "spread_bp", "lambda_bp_100", "depth5", "max_dd", "acf_absr_1_10", "acf_r_1"):76 if oc in d:77 eff[oc] = dose_response(d, oc, "share", 0.0)78 fig = P.dose_response(eff, LABELS)79 P.save(fig, OUT / f"fig_dose_{tag}.pdf")80 # realized shares81 fig = P.scatter_mechanism(d, "llm_share_traders", "llm_share_volume", "share", "LLM share of trader slots", "LLM share of traded volume")82 P.save(fig, OUT / f"fig_realized_share_{tag}.pdf")83 # synchrony / correlation vs share (LLM-only cells)84 dd = d[d.share > 0]85 if "llm_pairwise_corr" in dd and dd["llm_pairwise_corr"].notna().any():86 fig = P.scatter_mechanism(dd, "share", "llm_pairwise_corr", "share", "LLM share", "pairwise order-flow correlation (LLM agents)")87 P.save(fig, OUT / f"fig_sync_{tag}.pdf")88 # local projections around the t = 300 shock: 25 % and 50 % vs 0 %89 lp = {}90 for lv in (0.25, 0.50, 1.00):91 sub = d[d.share.isin([0.0, lv])]92 if sub.share.nunique() < 2:93 continue94 tb = {r.run_dir: ticks_of(r.run_dir) for r in sub.itertuples()}95 tr = {r.run_dir: r.share for r in sub.itertuples()}96 lp[f"{int(lv * 100)} % LLM"] = local_projection(tb, tr, 300, H=60)97 if lp:98 P.save(P.local_projection_plot(lp), OUT / f"fig_lp_{tag}.pdf")99 # paths for one seed100 seed = int(d.seed.min())101 runs = [(f"{int(s * 100)} % LLM", ticks_of(d[(d.share == s) & (d.seed == seed)].iloc[0].run_dir)) for s in sorted(d.share.unique()) if ((d.share == s) & (d.seed == seed)).any()][:4]102 P.save(P.price_paths(runs, shock_t=300), OUT / f"fig_paths_{tag}.pdf")103104105def fig_arms(design: str, tag: str):106 df = load(design)107 if df is None or "tr_arm" not in df:108 return109 ref = "dose_025"110 cells = [c for c in ["dose_000", "dose_025", "div_prompts_025", "div_models_025", "memory_025", "channel_025", "adaptive_025",111 "leverage_000", "leverage_025", "breaker_025", "poslimit_025", "fair_mini_005", "fair_latency_025"] if c in set(df.cell)]112 lab = {"dose_000": "0 % (control)", "dose_025": "25 % single", "div_prompts_025": "25 % diverse prompts", "div_models_025": "25 % two models",113 "memory_025": "25 % memory", "channel_025": "25 % channel", "adaptive_025": "25 % adaptive", "leverage_000": "0 % + leverage", "leverage_025": "25 % + leverage",114 "breaker_025": "25 % + breaker", "poslimit_025": "25 % + pos. limit", "fair_mini_005": "5 % mini (equal USD)", "fair_latency_025": "25 % fast latency"}115 for oc in ("mad_pct", "time_to_absorb", "spread_bp", "n_bubbles", "max_dd", "llm_pairwise_corr", "llm_synchrony", "llm_valid_rate"):116 if oc in df:117 P.save(P.cell_bars(df, oc, cells, lab, LABELS.get(oc, oc), control=ref), OUT / f"fig_arms_{tag}_{oc}.pdf")118 if {"llm_usd", "mad_pct"} <= set(df.columns):119 dd = df[df.cell.isin(["dose_005", "dose_025", "dose_050", "dose_100", "div_models_025", "fair_mini_005"])]120 P.save(P.cost_quality(dd, "llm_usd", "mad_pct", "cell", "API cost per run (USD)", LABELS["mad_pct"]), OUT / f"fig_cost_quality_{tag}.pdf")121122123def fig_contagion():124 df = load("classical_confirmatory")125 if df is None:126 return127 cells = [c for c in ("k2_control", "k2_leverage") if c in set(df.cell)]128 if cells and "excess_comovement" in df:129 P.save(P.cell_bars(df, "excess_comovement", cells, {"k2_control": "two assets", "k2_leverage": "two assets + leverage + funding shock"},130 "excess comovement (return corr − fundamental corr)"), OUT / "fig_contagion_excess.pdf")131 P.save(P.cell_bars(df, "spillover_dy", cells, {"k2_control": "two assets", "k2_leverage": "two assets + leverage + funding shock"},132 "Diebold–Yilmaz spillover (%)"), OUT / "fig_contagion_spillover.pdf")133134135def fig_wealth():136 df = load("classical_confirmatory")137 if df is None:138 return139 r = df[(df.cell == "control") & (df.seed == 101)].iloc[0]140 ag = pd.read_parquet(Path(r.run_dir) / "agents.parquet")141 P.save(P.wealth_and_inventory(ag, ticks_of(r.run_dir)), OUT / "fig_wealth_depth.pdf")142143144if __name__ == "__main__":145 fig_baseline_paths()146 fig_classical_mechanisms()147 fig_contagion()148 fig_wealth()149 fig_dose_response("pilot_openai", "pilot")150 fig_dose_response("confirmatory_openai", "conf")151 fig_arms("confirmatory_openai", "conf")152 print("figures in", OUT)153