1"""Stationary equilibrium with free entry, posted prices, financing extension, planner, transition."""2import numpy as np3import pytest45from src.solver import (solve_equilibrium, partial_equilibrium, free_entry_residual, solve_equilibrium_posted,6 solve_equilibrium_financing, transition, frictionless_price, welfare_flow)789def test_baseline_equilibrium_residuals(params):10 eq = solve_equilibrium(params)11 assert eq.converged and len(eq.theta_roots) == 112 assert abs(eq.free_entry_resid) < 1e-1013 assert np.abs(eq.sellers.resid).max() < 1e-1014 # stock equation15 assert np.allclose(params.s * params.f, (eq.sellers.eta + params.delta) * eq.n)16 assert abs(eq.omega.sum() - 1) < 1e-12 and abs(eq.N.sum() - 1) < 1e-1217 m = eq.moments(params)18 assert m["participation_ok"]19 assert 0 < m["price_mean"] < params.G.hi20 assert m["D_urgent"] > 0 and m["mean_tom_sold"] > 021 # free-entry residual is decreasing in theta around the root (uniqueness check, heterogeneous case)22 th = eq.theta * np.array([0.5, 0.8, 1.0, 1.25, 2.0])23 res = np.array([free_entry_residual(t, params) for t in th])24 assert np.all(np.diff(res) < 0)252627def test_prop3_homogeneous_uniqueness_and_comparative_statics(params):28 p = params.copy(c=np.array([0.08]), f=np.array([1.0]))29 grid = np.geomspace(0.05, 50, 40)30 res = np.array([free_entry_residual(t, p) for t in grid])31 assert np.all(np.diff(res) < 0) # strictly decreasing => unique root32 th_phi = [solve_equilibrium(p.copy(phi=ph)).theta for ph in (0.6, 0.75, 0.9)]33 th_A = [solve_equilibrium(p.copy(A=a)).theta for a in (3.0, 4.5, 6.0)]34 assert np.all(np.diff(th_phi) > 0) and np.all(np.diff(th_A) > 0)35 # observational equivalence of A and phi in the baseline36 e1 = solve_equilibrium(p.copy(phi=0.5, A=9.0)); e2 = solve_equilibrium(p.copy(phi=1.0, A=4.5))37 assert abs(e1.theta - e2.theta) < 1e-8 and abs(e1.Lam_s - e2.Lam_s) < 1e-838 # amplification: d ln Lam_s / d ln phi > 139 lo, hi = solve_equilibrium(p.copy(phi=0.84)), solve_equilibrium(p.copy(phi=0.86))40 el = (np.log(hi.Lam_s) - np.log(lo.Lam_s)) / (np.log(0.86) - np.log(0.84))41 assert el > 1.0424344def test_prop4_liquidity_shock(params):45 base = solve_equilibrium(params)46 shock = solve_equilibrium(params.copy(A=params.A * 0.7))47 assert shock.theta < base.theta48 assert np.all(shock.sellers.V < base.sellers.V) and np.all(shock.sellers.pbar < base.sellers.pbar)49 assert np.all(shock.sellers.eta < base.sellers.eta)50 assert shock.S > base.S and shock.Q < base.Q # delta > 0: steady-state volume falls51 # impact effect: stock predetermined52 Q_impact = float((shock.sellers.eta * base.n).sum())53 assert Q_impact < base.Q54 # delta = 0: steady-state volume equals inflow and composition equals f (Proposition 6(i))55 p0 = params.copy(delta=0.0)56 e0 = solve_equilibrium(p0); e0s = solve_equilibrium(p0.copy(A=p0.A * 0.7))57 assert abs(e0.Q - p0.s) < 1e-10 and abs(e0s.Q - p0.s) < 1e-1058 assert np.allclose(e0.omega, p0.f) and np.allclose(e0s.omega, p0.f)596061def test_prop5_dispersion_bounds(params):62 eq = solve_equilibrium(params)63 m = eq.moments(params)64 assert m["price_var_between"] > 065 rng = eq.sellers.pbar[0] - eq.sellers.pbar[-1]66 assert rng <= (params.c[-1] - params.c[0]) / (params.r + params.delta)67 # beta -> 0: price = V(c); between variance <= Var(c)/(r+delta)^268 p = params.copy(beta=1e-6)69 e = solve_equilibrium(p)70 var_c = float((e.omega * (params.c - (e.omega * params.c).sum()) ** 2).sum())71 assert e.moments(p)["price_var_between"] <= var_c / (p.r + p.delta) ** 2 + 1e-12727374def test_prop6_impact_composition(params):75 """Impact decomposition of Proposition 6(ii): the composition term has the sign of Cov_omega(pbar, eps) d ln Lam_s.76 For the truncated-normal baseline h_G*M is increasing, so eps(c) increases in c and selection DAMPENS the index77 response; for exponential G (eps decreasing in c) selection AMPLIFIES it."""78 from src.model import Exponential79 for G, expect_amplify in ((params.G, False), (Exponential(rate=8.0, lo=0.7), True)):80 p = params.copy(G=G)81 base = solve_equilibrium(p)82 pe = partial_equilibrium(base.theta, p.copy(A=p.A * 0.9)) # same theta, lower Lam_s, stock predetermined83 eta_new = pe.sellers.eta84 omega_imp = eta_new * base.n / (eta_new * base.n).sum()85 P_obs = float((omega_imp * pe.sellers.pbar).sum()); P_fix = float((base.omega * pe.sellers.pbar).sum())86 P0 = base.moments(p)["price_mean"]87 eps = (np.log(eta_new) - np.log(base.sellers.eta)) / np.log(0.9)88 cov = float((base.omega * (base.sellers.pbar - P_fix) * (eps - (base.omega * eps).sum())).sum())89 assert P_fix < P0 # within-type prices fall90 assert np.sign(P_obs - P_fix) == np.sign(-cov) # composition term = Cov * d ln Lam (<0)91 if expect_amplify:92 assert np.all(np.diff(eps) < 0) and P_obs < P_fix93 else:94 assert np.all(np.diff(eps) > 0) and P_obs > P_fix95 # analytic elasticity formula eps = 1 - beta eta h_G M /(r+delta+beta eta) at the base point (small-shock check)96 sb = base.sellers97 an = 1 - p.beta * sb.eta * np.asarray(G.hazard(sb.vstar)) * sb.M / ((p.r + p.delta) + p.beta * sb.eta)98 assert np.allclose(an, eps, atol=0.02)99100101def test_posted_prices(params):102 eq = solve_equilibrium_posted(params)103 assert eq.converged and np.abs(eq.sellers.resid).max() < 1e-9 and abs(eq.free_entry_resid) < 1e-9104 assert np.all(np.diff(eq.sellers.pbar) < 0) and np.all(np.diff(eq.sellers.eta) > 0)105 # posted threshold exceeds Nash threshold at equal (V,U)106 assert np.all(eq.sellers.vstar > eq.sellers.V + params.k)107 # FOC: y - U - V = (1-G)/g108 foc = eq.sellers.vstar - params.k - eq.sellers.V - params.G.surv(eq.sellers.vstar) / params.G.pdf(eq.sellers.vstar)109 assert np.abs(foc).max() < 1e-7110111112@pytest.mark.slow113def test_financing_extension(params):114 fe = solve_equilibrium_financing(params)115 assert fe.converged and abs(fe.resid["free_entry"]) < 1e-8 and fe.resid["max_abs"] < 1e-8116 # Proposition 7(ii): the selection term has the sign of v*_m - v*_c (positive iff the pending wedge exceeds U_c - U_m)117 assert np.all(np.sign(fe.sel_term) == np.sign(fe.vstar_m - fe.vstar_c))118 wedge = (params.c + params.kappa_b + params.r * (fe.V + fe.U_m)) / (params.nu * params.phi_m)119 assert np.allclose(fe.vstar_m - fe.vstar_c, wedge - (fe.U_c - fe.U_m))120 assert np.all(np.diff(fe.Delta_b) >= -1e-12) # Delta_b nondecreasing in c121 assert fe.U_c > fe.U_m # cash buyers have the better outside option122 assert np.all(np.diff(fe.pbar_m - fe.pbar_c) > 0) # gap increasing in urgency (numerical, baseline)123 assert abs(fe.Q_c.sum() / fe.Q - params.pi) < 1e-8 # steady-state sales composition = entry composition124 assert 0 < fe.pi_tilde < 1125 # Delta_b formula check against the closed form of Proposition 7126 an = ((1 - params.beta) * (params.c + params.r * fe.V) - params.beta * (params.kappa_b + params.r * fe.U_m)) / (params.nu * params.phi_m)127 assert np.allclose(an, fe.Delta_b)128 # uniqueness: different starting points reach the same equilibrium129 f2 = solve_equilibrium_financing(params, x0=np.array([np.log(1.5), 0.0, 0.5]))130 assert abs(f2.theta - fe.theta) < 1e-6131 # nesting: nu -> infinity, pi -> 1 reproduces the baseline with phi = 1132 p2 = params.copy(nu=1e5, pi=0.999)133 fe2 = solve_equilibrium_financing(p2); eq = solve_equilibrium(params.copy(phi=1.0))134 assert abs(fe2.theta - eq.theta) / eq.theta < 1e-2 and np.abs(fe2.V - eq.sellers.V).max() < 1e-3135136137@pytest.mark.slow138def test_transition_converges_and_matches_steady_states(params):139 p_new = params.copy(A=params.A * 0.8)140 tr = transition(params, lambda t: p_new, T=6.0, dt=0.05)141 assert tr.converged142 e0 = solve_equilibrium(params); e1 = solve_equilibrium(p_new)143 assert abs(tr.P_obs[0] - 0) >= 0144 assert np.allclose(tr.n[0], e0.n)145 assert abs(tr.theta[-1] - e1.theta) / e1.theta < 2e-2146 assert np.abs(tr.n[-1] - e1.n).max() < 2e-2 * e1.n.max()147 # composition term: sign on impact follows Proposition 6 (dampening for the truncated normal: P_obs > P_fix after a148 # negative shock) and is an order of magnitude smaller than the within-type change; volume and inventory adjust slowly149 assert tr.P_obs[0] > tr.P_fix[0] # impact: composition relative to the pre-shock weights150 assert abs(np.log(tr.P_obs[0] / tr.P_fix[0])) < 0.1 * abs(np.log(tr.P_fix[0] / e0.moments(params)["price_mean"]))151 assert tr.Q[0] < tr.Q[-1] and tr.S[0] < tr.S[-1]152 assert np.all(tr.Q >= 0) and np.all(tr.S > 0)153 # exponential G: amplification (P_obs < P_fix on impact)154 from src.model import Exponential155 pe = params.copy(G=Exponential(rate=8.0, lo=0.7)); tre = transition(pe, lambda t: pe.copy(A=pe.A * 0.8), T=3.0, dt=0.05)156 assert tre.converged and tre.P_obs[0] < tre.P_fix[0]157158159def test_frictionless_price_and_welfare(params):160 ps = frictionless_price(params)161 assert abs(float(params.G.H(ps)) - params.r * params.k) < 1e-10162 eq = solve_equilibrium(params)163 w = welfare_flow(eq, params)164 assert w["total"] > 0 and w["holding_costs"] > 0165166167def test_grid_and_tolerance_convergence(params):168 """Equilibrium objects stable across the seller-type grid and root tolerances."""169 m9 = solve_equilibrium(params).moments(params)170 p25 = params.with_types(0.02, 0.3, 25, 2.0, 4.0)171 m25 = solve_equilibrium(p25).moments(p25)172 for key in ("theta", "price_mean", "mean_tom_sold", "Q"):173 assert abs(m9[key] - m25[key]) / abs(m25[key]) < 1e-2, key174 mt = solve_equilibrium(params.copy(root_xtol=1e-8)).moments(params)175 assert abs(mt["theta"] - m9["theta"]) < 1e-6176