1"""Publication figures for WP19, generated only from results/processed/*.csv (Phase 5).2Colour-blind-safe categorical palette (validated reference palette); vector PDF + PNG; copies to paper/figures/.34Run: python -m src.plotting5"""6from __future__ import annotations78import shutil9from pathlib import Path1011import matplotlib12matplotlib.use("Agg")13import matplotlib.pyplot as plt14import numpy as np15import pandas as pd16import yaml1718ROOT = Path(__file__).resolve().parents[1]19PRO = ROOT / "results" / "processed"; FIG = ROOT / "results" / "figures"; PFIG = ROOT / "paper" / "figures"20FIG.mkdir(parents=True, exist_ok=True); PFIG.mkdir(parents=True, exist_ok=True)2122PAL = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6", "#e34948"]23INK = "#0b0b0b"; INK2 = "#52514e"; GRID = "#e6e5e1"2425plt.rcParams.update({26 "font.family": "serif", "font.size": 9, "axes.titlesize": 9.5, "axes.labelsize": 9, "legend.fontsize": 8,27 "xtick.labelsize": 8, "ytick.labelsize": 8, "axes.edgecolor": INK2, "axes.labelcolor": INK, "xtick.color": INK2,28 "ytick.color": INK2, "axes.grid": True, "grid.color": GRID, "grid.linewidth": 0.6, "axes.spines.top": False,29 "axes.spines.right": False, "lines.linewidth": 1.8, "legend.frameon": False, "figure.dpi": 150, "savefig.dpi": 300,30 "axes.prop_cycle": matplotlib.cycler(color=PAL),31})323334def save(fig, name):35 fig.tight_layout()36 for ext in ("pdf", "png"):37 fig.savefig(FIG / f"{name}.{ext}", bbox_inches="tight")38 shutil.copy(FIG / f"{name}.pdf", PFIG / f"{name}.pdf")39 plt.close(fig)40 print("figure", name)414243def rd(name):44 return pd.read_csv(PRO / name)454647def fig01_equilibrium():48 fe = rd("exp01_free_entry_curve.csv"); m = rd("exp01_baseline_moments.csv").iloc[0]49 cfg = yaml.safe_load((ROOT / "config" / "baseline.yaml").read_text()); A = cfg["matching"]["A"]; al = cfg["matching"]["alpha"]50 fig, ax = plt.subplots(1, 2, figsize=(7.2, 2.9))51 ax[0].semilogx(fe.theta, fe.residual, color=PAL[0]); ax[0].axhline(0, color=INK2, lw=0.8)52 ax[0].axvline(m.theta, color=PAL[1], ls="--", lw=1); ax[0].set_xlabel(r"tightness $\theta=B/S$ (log scale)")53 ax[0].set_ylabel("free-entry residual $R(\\theta)$"); ax[0].set_title("(a) Free entry: unique root")54 ax[0].annotate(fr"$\theta^*={m.theta:.2f}$", (m.theta, 0), xytext=(m.theta * 1.6, 0.4 * fe.residual.max()), color=PAL[1], fontsize=8,55 arrowprops=dict(arrowstyle="-", color=PAL[1], lw=0.8))56 th = np.geomspace(0.1, 40, 200)57 ax[1].loglog(th, A * th ** al, color=PAL[0], label=r"seller meeting rate $\lambda_s(\theta)$")58 ax[1].loglog(th, A * th ** (al - 1), color=PAL[2], label=r"buyer meeting rate $\lambda_b(\theta)$")59 ax[1].axvline(m.theta, color=PAL[1], ls="--", lw=1); ax[1].set_xlabel(r"tightness $\theta$ (log scale)"); ax[1].set_ylabel("meetings per year (log scale)")60 ax[1].set_title("(b) Matching technology"); ax[1].legend(loc="lower left")61 save(fig, "fig01_equilibrium_matching")626364def fig02_urgency_prices():65 d = rd("exp02_urgency_gradient.csv")66 fig, ax = plt.subplots(1, 2, figsize=(7.2, 2.9))67 for i, (mult, g) in enumerate(d.groupby("A_mult")):68 g = g[g.participation]69 ax[0].plot(g.c, g.V, color=PAL[i], label=fr"$A\times{mult}$ ($\Lambda_s$={g.Lam_s.iloc[0]:.1f})")70 ax[1].plot(g.c, g.pbar, color=PAL[i])71 ax[0].set_xlabel("carrying cost $c$ (share of value per year)"); ax[0].set_ylabel("reservation value $V(c)$"); ax[0].set_title("(a) Reservation value")72 ax[1].set_xlabel("carrying cost $c$"); ax[1].set_ylabel(r"expected transaction price $\bar p(c)$"); ax[1].set_title("(b) Expected price conditional on sale")73 ax[0].legend(title="partial equilibrium, $\\theta$ fixed", loc="lower left")74 save(fig, "fig02_urgency_reservation_price")757677def fig03_urgency_hazard():78 d = rd("exp02_urgency_gradient.csv"); t = rd("exp01_baseline_types.csv")79 fig, ax = plt.subplots(1, 2, figsize=(7.2, 2.9))80 for i, (mult, g) in enumerate(d.groupby("A_mult")):81 g = g[g.participation]82 ax[0].plot(g.c, g.eta, color=PAL[i], label=fr"$A\times{mult}$")83 ax[1].plot(g.c, g.E_tau_months, color=PAL[i])84 ax[0].scatter(t.c, t.eta, color=INK, s=12, zorder=5, label="baseline types")85 ax[0].set_xlabel("carrying cost $c$"); ax[0].set_ylabel(r"sale hazard $\eta(c)$ (per year)"); ax[0].set_title("(a) Sale hazard"); ax[0].legend(loc="upper left")86 ax[1].set_xlabel("carrying cost $c$"); ax[1].set_ylabel("expected listing duration (months)"); ax[1].set_title("(b) Expected time on market")87 save(fig, "fig03_urgency_sale_hazard")888990def fig04_discount_vs_arrivals():91 a = rd("exp03_arrival_shock.csv"); g = rd("exp03_gamma_sign_change.csv")92 fig, ax = plt.subplots(1, 3, figsize=(9.6, 2.9))93 for kind, ls in (("GE", "-"), ("PE", "--")):94 s = a[a.kind == kind]95 ax[0].plot(s.A_mult, 100 * s.D_urgent, ls=ls, color=PAL[0], label=f"most urgent type ({kind})")96 ax[0].plot(s.A_mult, 100 * s.D_mean, ls=ls, color=PAL[1], label=f"transaction-weighted ({kind})")97 ax[1].plot(s.A_mult, s.price_mean, ls=ls, color=PAL[0], label=f"mean price ({kind})")98 ax[0].set_xlabel("matching efficiency (multiple of baseline $A$)"); ax[0].set_ylabel("urgency discount $D$ (%)"); ax[0].set_title("(a) Urgency discount"); ax[0].legend()99 ax[1].set_xlabel("matching efficiency (multiple of baseline $A$)"); ax[1].set_ylabel("transaction-weighted mean price"); ax[1].set_title("(b) Price level"); ax[1].legend()100 ax[2].semilogx(g.Lam_s, 100 * g.Gamma, color=PAL[2]); ax[2].axhline(0, color=INK2, lw=0.8)101 ax[2].set_xlabel(r"effective arrival rate $\Lambda_s$ (log scale)"); ax[2].set_ylabel(r"market liquidity gap $\Gamma$ (%)"); ax[2].set_title("(c) Gap to Walrasian benchmark, patient seller")102 save(fig, "fig04_liquidity_discount_vs_arrivals")103104105def fig05_financing():106 d = rd("exp04_financing_phi.csv"); base = d[np.isclose(d.phi_m, 0.85)]107 fig, ax = plt.subplots(1, 3, figsize=(9.6, 2.9))108 ax[0].plot(base.c, 100 * base.Delta_b, color=PAL[0], label="delay-bargaining term $\\Delta_b(c)$")109 ax[0].plot(base.c, 100 * base.outside_term, color=PAL[3], label="outside-option term")110 ax[0].plot(base.c, 100 * base.sel_term, color=PAL[2], label="selection term")111 ax[0].plot(base.c, 100 * base.gap, color=INK, lw=1.2, ls="--", label="total mortgage$-$cash gap")112 ax[0].axhline(0, color=INK2, lw=0.8); ax[0].set_xlabel("carrying cost $c$"); ax[0].set_ylabel("% of value"); ax[0].set_title("(a) Decomposition by seller type"); ax[0].legend(fontsize=7)113 agg = d.groupby("phi_m").apply(lambda g: pd.Series({"gap": 100 * ((g.gap * g.f).sum() / g.f.sum()), "tom": (g.E_tau_listed_months * g.f).sum(), "theta": g.theta.iloc[0]}), include_groups=False).reset_index()114 ax[1].plot(agg.phi_m, agg.gap, marker="o", ms=4, color=PAL[0]); ax[1].set_xlabel(r"approval probability $\phi$"); ax[1].set_ylabel("mean mortgage$-$cash gap (%)"); ax[1].set_title("(b) Gap vs approval probability")115 nu = rd("exp04_financing_nu.csv"); aggn = nu[nu.nu < 1e3].groupby("nu").apply(lambda g: pd.Series({"gap": 100 * ((g.gap * g.f).sum() / g.f.sum()), "tom": (g.E_tau_listed_months * g.f).sum()}), include_groups=False).reset_index()116 ax[2].plot(12 / aggn.nu, aggn.gap, marker="o", ms=4, color=PAL[1]); ax[2].set_xlabel(r"expected approval delay $12/\nu$ (months)"); ax[2].set_ylabel("mean mortgage$-$cash gap (%)"); ax[2].set_title("(c) Gap vs financing delay")117 save(fig, "fig05_financing_counterfactual")118119120def fig06_volume_price():121 a = rd("exp03_arrival_shock.csv"); s = a[a.kind == "GE"].sort_values("A_mult")122 fig, ax = plt.subplots(1, 2, figsize=(7.2, 2.9))123 sc = ax[0].scatter(s.Q, s.price_mean, c=s.A_mult, cmap="Blues", s=18, edgecolor=INK2, linewidth=0.3)124 ax[0].plot(s.Q, s.price_mean, color=INK2, lw=0.6)125 ax[0].set_xlabel("steady-state sales flow $Q$ (per year, inflow $s=1$)"); ax[0].set_ylabel("mean transaction price"); ax[0].set_title("(a) Volume and price across liquidity states")126 cb = fig.colorbar(sc, ax=ax[0], pad=0.02); cb.set_label("$A$ multiple")127 ax[1].plot(s.S, s.mean_tom_sold_months, color=PAL[1], marker="o", ms=3)128 ax[1].set_xlabel("inventory of listings $S$"); ax[1].set_ylabel("mean time on market of sold listings (months)"); ax[1].set_title("(b) Inventory and time on market")129 save(fig, "fig06_volume_vs_price")130131132def fig07_survival():133 d = rd("exp01_baseline_survival.csv"); t = rd("exp01_baseline_types.csv")134 fig, ax = plt.subplots(1, 2, figsize=(7.2, 2.9))135 cols = [c for c in d.columns if c.startswith("survival_c")]136 for i, c in enumerate(cols):137 idx = int(c.split("c")[-1]); ax[0].plot(d.tau_months, d[c], color=PAL[i + 1], lw=1.2, label=fr"type $c={t.c.iloc[idx]:.3f}$")138 ax[0].plot(d.tau_months, d.survival_population, color=INK, lw=2, label="population (mixture)")139 ax[0].set_xlabel("listing duration (months)"); ax[0].set_ylabel("survival probability"); ax[0].set_title("(a) Survival of listings"); ax[0].legend(); ax[0].set_xlim(0, 18)140 ax[1].plot(d.tau_months[1:-1], d.hazard_population[1:-1], color=INK); ax[1].set_xlim(0, 18)141 ax[1].set_xlabel("listing duration (months)"); ax[1].set_ylabel("population exit hazard (per year)"); ax[1].set_title("(b) Spurious negative duration dependence")142 save(fig, "fig07_tom_survival")143144145def fig08_price_distributions():146 b = rd("exp08_price_density_baseline.csv"); lo = rd("exp08_price_density_lowA.csv")147 fig, ax = plt.subplots(1, 2, figsize=(7.2, 2.9))148 types = [c for c in b.columns if c.startswith("type")]149 bottom = np.zeros(len(b)); cmap = plt.get_cmap("Blues")150 for i, c in enumerate(types):151 col = cmap(0.25 + 0.7 * i / max(len(types) - 1, 1))152 ax[0].fill_between(b.price, bottom, bottom + b[c], color=col, alpha=0.95, lw=0, label=("most patient" if i == 0 else "most urgent" if i == len(types) - 1 else None))153 bottom = bottom + b[c].values154 ax[0].plot(b.price, b.density, color=INK, lw=1); ax[0].set_xlabel("transaction price (units of mean valuation)"); ax[0].set_ylabel("density"); ax[0].set_title("(a) Mixture by seller type, baseline"); ax[0].legend(title="seller types (light = patient)", fontsize=7)155 ax[1].plot(b.price, b.density, color=PAL[0], label="baseline $A$"); ax[1].plot(lo.price, lo.density, color=PAL[1], label=r"$A\times0.6$ (illiquid)")156 ax[1].set_xlabel("transaction price"); ax[1].set_ylabel("density"); ax[1].set_title("(b) Liquidity and the price distribution"); ax[1].legend()157 save(fig, "fig08_seller_type_price_distributions")158159160def fig09_index_selection():161 d = rd("exp10_selection_decomposition.csv"); d = d[(d.kind == "GE") & d.A_mult.isin([0.7, 1.3]) & (d.c_max == 0.3)]162 h = rd("exp10_hazard_times_mrl.csv")163 fig, ax = plt.subplots(1, 3, figsize=(10.2, 3.0))164 Gs = list(dict.fromkeys(d.G)); x = np.arange(len(Gs)); w = 0.36165 for j, (mult, lab) in enumerate(((0.7, r"$A\times0.7$"), (1.3, r"$A\times1.3$"))):166 s_ = d[d.A_mult == mult].set_index("G").reindex(Gs)167 ax[0].bar(x + (j - 0.5) * w, 100 * s_.within_log, w * 0.92, color=PAL[j], label=lab)168 ax[1].bar(x + (j - 0.5) * w, 1e4 * s_.composition_impact_log, w * 0.92, color=PAL[j], label=lab)169 for a_ in ax[:2]:170 a_.axhline(0, color=INK2, lw=0.8); a_.set_xticks(x); a_.set_xticklabels(Gs, fontsize=7.5); a_.legend(fontsize=7)171 ax[0].set_ylabel("within-type (liquidity) term, %"); ax[0].set_title("(a) Within-type price change on impact")172 ax[1].set_ylabel("composition (selection) term, basis points"); ax[1].set_title("(b) Composition term on impact")173 for i, (g, s_) in enumerate(h.groupby("G")):174 ax[2].plot(s_.v, s_.hG_times_M, color=PAL[i], label=g)175 ax[2].axhline(1, color=INK2, lw=0.8, ls=":"); ax[2].set_xlabel("valuation $v$"); ax[2].set_ylabel("$h_G(v)\\,M(v)$"); ax[2].set_title("(c) Sign condition: hazard × mean residual valuation"); ax[2].legend(fontsize=7); ax[2].set_xlim(0.7, 1.6)176 save(fig, "fig09_observed_vs_latent_index")177178179def fig10_impulse_responses():180 scen = {"perm_A_minus20": r"permanent $A\times0.8$", "temp_A_minus20_1y": r"temporary $A\times0.8$ (1 year)", "perm_A_plus20": r"permanent $A\times1.2$"}181 P0 = rd("exp01_baseline_moments.csv").iloc[0].price_mean182 fig, ax = plt.subplots(2, 3, figsize=(9.6, 5.2))183 for i, (k, lab) in enumerate(scen.items()):184 d = rd(f"exp09_transition_{k}.csv"); d = d[d.t <= 4]185 ax[0, 0].plot(d.t, d.theta, color=PAL[i], label=lab); ax[0, 1].plot(d.t, 100 * (d.P_obs / P0 - 1), color=PAL[i])186 ax[0, 2].plot(d.t, d.Q, color=PAL[i]); ax[1, 0].plot(d.t, d.S, color=PAL[i]); ax[1, 1].plot(d.t, d.mean_tom_sold_months, color=PAL[i])187 ax[1, 2].plot(d.t, 1e4 * np.log(d.P_obs / d.P_fix), color=PAL[i])188 for a_, (t_, y_) in zip(ax.ravel(), [("(a) tightness $\\theta_t$", "buyers per listing"), ("(b) observed price index", "% from initial steady state"), ("(c) sales flow $Q_t$", "per year"), ("(d) inventory $S_t$", "listings"), ("(e) mean time on market of sales", "months"), ("(f) composition term $\\ln(P_t/P^{fix}_t)$", "basis points")]):189 a_.set_title(t_); a_.set_ylabel(y_); a_.set_xlabel("years after shock")190 ax[0, 0].legend(fontsize=7)191 save(fig, "fig10_shock_impulse_responses")192193194def fig11_welfare():195 w = rd("exp05_welfare_surface.csv"); b = rd("exp05_bargaining_power.csv")196 fig, ax = plt.subplots(1, 2, figsize=(7.6, 3.0))197 s = w[w.sellers == "heterogeneous"].pivot(index="alpha", columns="beta", values="welfare_total")198 im = ax[0].imshow(s.values, origin="lower", aspect="auto", cmap="Blues", extent=[s.columns.min(), s.columns.max(), s.index.min(), s.index.max()])199 ax[0].plot(1 - s.index.values, s.index.values, color=PAL[1], ls="--", label=r"Hosios $\beta=1-\alpha$")200 best = s.idxmax(axis=1); ax[0].scatter(best.values, best.index, color=INK, s=14, zorder=5, label="welfare-maximising $\\beta$")201 ax[0].set_xlabel(r"seller bargaining share $\beta$"); ax[0].set_ylabel(r"matching elasticity $\alpha$"); ax[0].set_title("(a) Steady-state flow welfare (heterogeneous sellers)"); ax[0].legend(fontsize=7, loc="upper right")202 fig.colorbar(im, ax=ax[0], pad=0.02, label="welfare flow")203 for i, (lab, g) in enumerate(b.groupby("sellers")):204 ax[1].plot(g.beta, g.welfare_total, color=PAL[i], label=lab); 205 ax[1].axvline(0.5, color=PAL[1], ls="--", lw=1, label="Hosios ($\\alpha=0.5$)")206 ax[1].set_xlabel(r"seller bargaining share $\beta$"); ax[1].set_ylabel("welfare flow"); ax[1].set_title("(b) Welfare and bargaining power, $\\alpha=0.5$"); ax[1].legend(fontsize=7)207 save(fig, "fig11_welfare_friction_contours")208209210def fig12_tornado():211 d = rd("exp12_sensitivity_oat.csv")212 fig, ax = plt.subplots(1, 2, figsize=(7.6, 3.4))213 for a_, key, lab in ((ax[0], "D_urgent", "urgency discount of the most urgent type (pp)"), (ax[1], "mean_tom_sold_months", "mean time on market of sales (months)")):214 g = d.groupby("parameter").agg(lo=(f"d_{key}", "min"), hi=(f"d_{key}", "max")).assign(span=lambda x: x.hi - x.lo).sort_values("span")215 if key == "D_urgent": g[["lo", "hi"]] *= 100216 y = np.arange(len(g)); a_.barh(y, g.hi, color=PAL[0], height=0.6, label="upper parameter value"); a_.barh(y, g.lo, color=PAL[1], height=0.6, label="lower parameter value")217 a_.set_yticks(y); a_.set_yticklabels([f"${p}$" if p in ("r", "A", "k") else p.replace("kappa_b", r"$\kappa_b$").replace("delta", r"$\delta$").replace("alpha", r"$\alpha$").replace("beta", r"$\beta$").replace("phi", r"$\phi$").replace("w0", "$w_0$").replace("sigma_w", r"$\sigma_w$").replace("c_max", "$c_{max}$") for p in g.index])218 a_.axvline(0, color=INK2, lw=0.8); a_.set_xlabel(f"change in {lab}"); a_.set_title(r"$\pm$25% one-at-a-time ($\sigma_w$: 0.05–0.20; $c_{max}$: 0.2–0.45)", fontsize=8)219 ax[0].legend(fontsize=7, loc="lower right")220 save(fig, "fig12_sensitivity_tornado")221222223def fig13_convergence():224 t = rd("exp12_convergence_types.csv"); dt = rd("exp12_convergence_dt.csv")225 fig, ax = plt.subplots(1, 2, figsize=(7.2, 2.9))226 ref = t[t.n_types == t.n_types.max()].iloc[0]227 for i, key in enumerate(("theta", "price_mean", "mean_tom_sold", "D_mean")):228 ax[0].semilogx(t.n_types, np.abs(t[key] - ref[key]) / abs(ref[key]) + 1e-16, marker="o", ms=3, color=PAL[i], label=key.replace("_", " "))229 ax[0].set_yscale("log"); ax[0].set_xlabel("number of seller types in the discretisation of $F$"); ax[0].set_ylabel("relative deviation from finest grid"); ax[0].set_title("(a) Convergence in the seller-type grid"); ax[0].legend(fontsize=7)230 ref2 = dt[dt.dt == dt.dt.min()].iloc[0]231 for i, key in enumerate(("P_obs_1y", "Q_1y", "S_1y", "theta_impact")):232 ax[1].loglog(dt.dt, np.abs(dt[key] - ref2[key]) / abs(ref2[key]) + 1e-16, marker="o", ms=3, color=PAL[i], label=key.replace("_", " "))233 ax[1].set_xlabel("time step $\\Delta t$ (years)"); ax[1].set_ylabel("relative deviation from finest step"); ax[1].set_title("(b) Convergence of the transition solver"); ax[1].legend(fontsize=7)234 save(fig, "fig13_numerical_convergence")235236237def fig14_posted_vs_nash():238 n = rd("exp11_types_nash.csv"); p_ = rd("exp11_types_posted.csv")239 fig, ax = plt.subplots(1, 2, figsize=(7.2, 2.9))240 ax[0].plot(n.c, n.pbar, color=PAL[0], marker="o", ms=3, label="Nash bargaining (expected price)"); ax[0].plot(p_.c, p_.pbar, color=PAL[1], marker="s", ms=3, label="posted price")241 ax[0].set_xlabel("carrying cost $c$"); ax[0].set_ylabel("price"); ax[0].set_title("(a) Price by seller type"); ax[0].legend(fontsize=7)242 ax[1].plot(n.c, n.eta, color=PAL[0], marker="o", ms=3); ax[1].plot(p_.c, p_.eta, color=PAL[1], marker="s", ms=3)243 ax[1].set_xlabel("carrying cost $c$"); ax[1].set_ylabel("sale hazard (per year)"); ax[1].set_title("(b) Sale hazard by seller type")244 save(fig, "fig14_posted_vs_nash")245246247ALL = [fig01_equilibrium, fig02_urgency_prices, fig03_urgency_hazard, fig04_discount_vs_arrivals, fig05_financing, fig06_volume_price,248 fig07_survival, fig08_price_distributions, fig09_index_selection, fig10_impulse_responses, fig11_welfare, fig12_tornado,249 fig13_convergence, fig14_posted_vs_nash]250251if __name__ == "__main__":252 import sys253 failed = []254 for f in ALL:255 try:256 f()257 except Exception as ex:258 failed.append((f.__name__, repr(ex))); print("FAILED", f.__name__, repr(ex))259 if failed:260 sys.exit(1)261