TeX 53.2%
Python 46.6%
1"""Tests of the Part A benchmark (`src/toy_model.py`).23Each test is tied to an object of `research/model_specification.md` / `paper/appendix_proofs.tex`:4budget identities and debt evolution (B.11 / A.4), Fisher present-value identity, Proposition 15(threshold = numerically optimised threshold), Assumption A4 verified on the support of the6stationary transfer distribution, Proposition 2 (signs), Proposition 3 (tilt / neutrality),7Proposition 4 (leverage), Proposition 5 (persistence, chain), Proposition 6 (clearing, condition C),8Corollary (fixed supply), Proposition 7 (no segmentation), Proposition 8 (accounting, FRM vs ARM),9Proposition 9 (impact signs). Parameters are illustrative.10"""11import os12import sys1314import numpy as np15import pytest1617sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))1819import toy_model as tm # noqa: E40220from toy_model import Params, replace # noqa: E4022122PAR = Params()23EQ = tm.stationary_equilibrium(PAR)24H = 1e-5252627def num_deriv(fun, x, h=H):28 return (fun(x + h) - fun(x - h)) / (2 * h)293031# ------------------------------------------------------------------ identities32@pytest.mark.parametrize("tenure", ["O", "R"])33@pytest.mark.parametrize("contract", ["FRM", "ARM"])34@pytest.mark.parametrize("pi_u", [0.0, 0.1, -0.05])35@pytest.mark.parametrize("experiment", ["level", "persistent"])36def test_budget_identities_state_by_state(tenure, contract, pi_u, experiment):37 y3_nodes, _ = tm.y3_quad(PAR)38 for y3 in y3_nodes[::8]:39 for b in [EQ["b_hat"] + 0.05, 1.5]:40 a = tm.htm_allocation(PAR, b, EQ["p"], EQ["rho"], tenure, float(y3), pi_u1=pi_u,41 contract=contract, experiment=experiment)42 src, use = a["sources"], a["uses"]43 for j in range(3):44 assert src[j] == pytest.approx(use[j], abs=1e-12), (tenure, j)45 assert a["c1"] > 0 and a["c2"] > 0 and a["W3"] > 0 # Assumption A346 if tenure == "O":47 assert a["schedule_check"] == pytest.approx((0.0, 0.0), abs=1e-12)484950def test_debt_evolution_nominal_vs_real():51 a = tm.htm_allocation(PAR, 1.2, EQ["p"], EQ["rho"], "O", 4.0, pi_u1=0.2)52 B0, B1, B2 = a["B_nom"]53 P0, P1, P2 = a["P"]54 # nominal: balance after stage-2 payment = (1 - amort) * B0 (interest paid in full at stage 2)55 assert B1 == pytest.approx((1 - PAR.amort) * B0, abs=1e-12)56 assert B2 == 0.057 # real debt = B_nom / CPI; the surprise lowers real debt by 1/(1+pi_u) relative to the no-surprise path58 a0 = tm.htm_allocation(PAR, 1.2, EQ["p"], EQ["rho"], "O", 4.0, pi_u1=0.0)59 assert a["real_debt"][1] == pytest.approx(a0["real_debt"][1] / 1.2, rel=1e-12)60 # nominal house-price index (P p) differs from the CPI path61 assert P1 * EQ["p"] != pytest.approx(P1)626364def test_fisher_present_value_identity():65 d2, d3, _ = tm.stationary_schedule(PAR)66 assert d2 + d3 / PAR.R == pytest.approx((1 - PAR.theta) * PAR.R, rel=1e-12)67 for pibar in [0.0, 0.1, 0.5]:68 d2, d3, _ = tm.stationary_schedule(replace(PAR, pibar=pibar))69 assert d2 + d3 / PAR.R == pytest.approx((1 - PAR.theta) * PAR.R, rel=1e-12)707172def test_stage3_split_lemma1():73 a = tm.htm_allocation(PAR, 1.0, EQ["p"], EQ["rho"], "R", 4.0)74 assert a["c3"] == pytest.approx(a["W3"] / (1 + PAR.nu))75 assert a["bequest"] == pytest.approx(PAR.nu * a["W3"] / (1 + PAR.nu))767778# ------------------------------------------------------------------ Proposition 1 & A479def test_threshold_equals_numerically_optimised_threshold():80 b_num = tm.threshold_general(PAR, EQ["p"], EQ["rho"])81 assert b_num == pytest.approx(EQ["b_hat"], abs=1e-6)82 assert EQ["b_hat"] > PAR.d * EQ["p"] - PAR.y1 # strictly above the feasibility bound83 assert EQ["A5_cash"] > 0 and EQ["Delta"] > 0 # Assumption A5 holds at the equilibrium848586@pytest.mark.parametrize("tenure", ["O", "R"])87def test_htm_corner_is_optimal_on_transfer_support(tenure):88 """Assumption A4 verified (not assumed): Euler slacks positive at the threshold and at the89 largest transfer of the stationary distribution, and the general optimiser returns the corner."""90 _, _, e3 = tm.stationary_schedule(PAR)91 b_max = PAR.nu / (1 + PAR.nu) * (PAR.y3_hi + e3 * EQ["p"])92 for b in [EQ["b_hat"], 0.5 * (EQ["b_hat"] + b_max), b_max]:93 s1, s2 = tm.htm_slack(PAR, b, EQ["p"], EQ["rho"], tenure)94 assert s1 > 0 and s2 > 095 v_gen, pol = tm.value_general(PAR, b, EQ["p"], EQ["rho"], tenure, return_policy=True)96 v_htm = tm.value_htm(PAR, b, EQ["p"], EQ["rho"], tenure)97 assert v_gen == pytest.approx(v_htm, abs=1e-6)98 if tenure == "O":99 assert pol[0] == pytest.approx(1 - PAR.theta, abs=1e-6) # max LTV100 assert pol[1] == pytest.approx(0.0, abs=1e-6) and pol[2] == pytest.approx(0.0, abs=1e-6)101 else:102 assert np.allclose(pol, 0.0, atol=1e-6)103104105def test_single_crossing_general_values():106 """V^O - V^R crosses zero once on a grid (checked with the general optimiser)."""107 grid = np.linspace(PAR.d * EQ["p"] - PAR.y1 + 0.02, 3.0, 25)108 diff = np.array([tm.value_general(PAR, b, EQ["p"], EQ["rho"], "O") - tm.value_general(PAR, b, EQ["p"], EQ["rho"], "R")109 for b in grid])110 signs = np.sign(diff)111 assert np.sum(signs[1:] != signs[:-1]) == 1112 assert diff[0] < 0 < diff[-1]113114115# ------------------------------------------------------------------ Proposition 2 & 3116def test_threshold_comparative_statics_signs():117 p, rho = EQ["p"], EQ["rho"]118 assert num_deriv(lambda x: tm.threshold(PAR, x, rho), p) > 0119 assert num_deriv(lambda x: tm.threshold(PAR, p, x), rho) < 0120 for name, sign in [("theta", +1), ("kappa_b", +1), ("kappa_s", +1), ("m", +1), ("chi", -1), ("pibar", +1)]:121 v = getattr(PAR, name)122 der = num_deriv(lambda x: tm.threshold(replace(PAR, **{name: x}), p, rho), v)123 assert np.sign(der) == sign, name124 assert num_deriv(lambda x: tm.threshold(replace(PAR, y1=x), p, rho), PAR.y1) == pytest.approx(-1.0, abs=1e-6)125126127def test_anticipated_inflation_neutral_in_pv_but_tilts_payments():128 d2a, d3a, _ = tm.stationary_schedule(replace(PAR, pibar=0.0))129 d2b, d3b, _ = tm.stationary_schedule(replace(PAR, pibar=0.3))130 assert d2b > d2a and d3b < d3a # tilt131 assert d2a + d3a / PAR.R == pytest.approx(d2b + d3b / PAR.R) # PV neutrality (Fisher)132133134# ------------------------------------------------------------------ Proposition 4135def test_leverage_multiplier_and_break_even():136 ls = tm.leverage_stats(PAR, EQ["p"], EQ["rho"])137 assert ls["multiplier"] == pytest.approx((1 - PAR.kappa_s) / PAR.d)138 # multiplier falls with the down payment and with costs139 assert tm.leverage_stats(replace(PAR, theta=0.4), EQ["p"], EQ["rho"])["multiplier"] < ls["multiplier"]140 assert tm.leverage_stats(replace(PAR, kappa_b=0.06), EQ["p"], EQ["rho"])["multiplier"] < ls["multiplier"]141 # owner vs renter-investor terminal wealth changes sign exactly at g*142 g = ls["g_star"]143 assert tm.owner_vs_renter_investor(PAR, EQ["p"], EQ["rho"], g) == pytest.approx(0.0, abs=1e-10)144 assert tm.owner_vs_renter_investor(PAR, EQ["p"], EQ["rho"], g + 0.1) > 0145 assert tm.owner_vs_renter_investor(PAR, EQ["p"], EQ["rho"], g - 0.1) < 0146 # break-even growth is independent of theta, increasing in costs, decreasing in rent147 assert tm.leverage_stats(replace(PAR, theta=0.5), EQ["p"], EQ["rho"])["g_star"] == pytest.approx(g)148 assert tm.leverage_stats(replace(PAR, kappa_s=0.10), EQ["p"], EQ["rho"])["g_star"] > g149 assert tm.leverage_stats(PAR, EQ["p"], EQ["rho"] * 1.2)["g_star"] < g150 # underwater threshold: resale at (1 - x*) p leaves zero housing equity151 d2, d3, _ = tm.stationary_schedule(PAR)152 assert (1 - PAR.kappa_s) * (1 - ls["x_star"]) - d3 == pytest.approx(0.0, abs=1e-12)153154155# ------------------------------------------------------------------ Proposition 5156def test_dynasty_chain_persistence():157 tr = tm.transitions(PAR, EQ["p"], EQ["rho"])158 assert 0 < tr["qRO"] < tr["qOO"] < 1159 assert tr["lam"] == pytest.approx(tr["qOO"] - tr["qRO"])160 assert tr["lam"] == pytest.approx(float(tm.G(PAR, tr["yR"]) - tm.G(PAR, tr["yO"])))161 # stationary distribution of the two-state chain by power iteration162 Pm = np.array([[1 - tr["qRO"], tr["qRO"]], [1 - tr["qOO"], tr["qOO"]]])163 pi = np.array([0.5, 0.5])164 for _ in range(500):165 pi = pi @ Pm166 assert pi[1] == pytest.approx(tr["mu_star"], abs=1e-10)167 assert np.sort(np.abs(np.linalg.eigvals(Pm)))[0] == pytest.approx(tr["lam"], abs=1e-10)168 # persistence vanishes when the house is not net wealth at the end of life (e3 <= 0)169 tr0 = tm.transitions(PAR, EQ["p"], EQ["rho"], eq3=0.0)170 assert tr0["lam"] == pytest.approx(0.0, abs=1e-12)171 # and increases with the equity carried into stage 3 (threshold fixed)172 assert tm.transitions(PAR, EQ["p"], EQ["rho"], eq3=0.9)["lam"] > tr["lam"]173 assert tr["transfer_gap"] == pytest.approx(PAR.nu / (1 + PAR.nu) * tm.stationary_schedule(PAR)[2] * EQ["p"])174175176# ------------------------------------------------------------------ Proposition 6 + Corollary + 7177def test_stationary_equilibrium_clears_and_is_unique_under_C():178 assert abs(EQ["owner_clearing"]) < 1e-10 and abs(EQ["rental_clearing"]) < 1e-10179 assert EQ["mu"] == pytest.approx(EQ["mu_star"], abs=1e-10)180 assert tm.uniqueness_condition(PAR, EQ["p"], EQ["rho"]) > 0181 # f(mu) is decreasing on a grid -> single root182 grid = np.linspace(0.05, 0.95, 19)183 f = np.array([tm.excess_map(PAR, m) for m in grid])184 assert np.all(np.diff(f) < 0)185 assert np.sum(np.sign(f[1:]) != np.sign(f[:-1])) == 1186187188def test_fixed_supply_corollary():189 par0 = replace(PAR, eps=0.0, Hbar=0.72)190 e0 = tm.stationary_equilibrium(par0)191 assert e0["mu"] == pytest.approx(0.72)192 # relaxing the down-payment constraint does not change the ownership rate, only the price193 e1 = tm.stationary_equilibrium(replace(par0, theta=0.25))194 assert e1["mu"] == pytest.approx(0.72)195 assert e1["p"] > e0["p"]196197198def test_no_segmentation_neutrality():199 base = tm.no_segmentation_equilibrium(PAR, phi=0.3)200 for par_alt in [replace(PAR, theta=0.5), replace(PAR, nu=0.6), replace(PAR, y3_hi=9.0)]:201 alt = tm.no_segmentation_equilibrium(par_alt, phi=0.3)202 assert alt["p"] == pytest.approx(base["p"])203 assert alt["rho"] == pytest.approx(base["rho"])204 assert tm.no_segmentation_equilibrium(replace(PAR, theta=0.5), phi=0.3)["b_hat"] != pytest.approx(base["b_hat"])205206207# ------------------------------------------------------------------ Proposition 8208def test_surprise_accounting_zero_sum_and_contract_ordering():209 p, rho = EQ["p"], EQ["rho"]210 for contract in ["FRM", "ARM"]:211 for exp_ in ["level", "persistent"]:212 acc = tm.surprise_accounting(PAR, p, rho, 0.1, contract=contract, experiment=exp_,213 n_savings_age2=0.2, n_savings_age3=0.3)214 assert acc["zero_sum_residual"] == pytest.approx(0.0, abs=1e-12)215 assert acc["gain_age2_D2"] > 0 and acc["gain_age3_D3"] > 0216 assert acc["loss_savers"] > 0217 frm_l = tm.surprise_accounting(PAR, p, rho, 0.1, contract="FRM", experiment="level")218 arm_l = tm.surprise_accounting(PAR, p, rho, 0.1, contract="ARM", experiment="level")219 frm_p = tm.surprise_accounting(PAR, p, rho, 0.1, contract="FRM", experiment="persistent")220 arm_p = tm.surprise_accounting(PAR, p, rho, 0.1, contract="ARM", experiment="persistent")221 assert frm_l["gain_age2_D3"] == pytest.approx(arm_l["gain_age2_D3"]) # level: identical222 assert frm_p["gain_age2_D3"] > arm_p["gain_age2_D3"] # persistent: FRM gains more223 assert arm_p["gain_age2_D3"] == pytest.approx(arm_l["gain_age2_D3"]) # ARM reset recovers one factor224 # erosion factors exactly225 d2, d3, _ = tm.stationary_schedule(PAR)226 assert frm_l["D2_shock"] == pytest.approx(d2 * p / 1.1) and frm_l["D3_shock"] == pytest.approx(d3 * p / 1.1)227 assert frm_p["D3_shock"] == pytest.approx(d3 * p / 1.1**2)228 assert frm_l["lender_loss"] == pytest.approx(frm_l["gain_age2_D2"] + frm_l["gain_age2_D3"] + frm_l["gain_age3_D3"])229230231def test_owner_can_lose_arm_with_real_rate_response_and_unindexed_wages():232 p, rho = EQ["p"], EQ["rho"]233 arm = tm.surprise_accounting(PAR, p, rho, 0.1, contract="ARM", experiment="persistent", phi_r=2.0)234 assert arm["gain_age2_D3"] < 0 # stage-3 real payment rises235 nowage = tm.surprise_accounting(replace(PAR, eta_w=0.0), p, rho, 0.1, contract="FRM", experiment="level")236 assert nowage["net_owner_age2"] < 0 # debt erosion < real income loss237 full = tm.surprise_accounting(PAR, p, rho, 0.1, contract="FRM", experiment="level")238 assert full["net_owner_age2"] > 0239240241# ------------------------------------------------------------------ Proposition 9242def test_impact_signs_segmented_elastic():243 imp = tm.impact_response(PAR, EQ, 0.1)244 assert imp["dp"] > 0 and imp["drho"] < 0 and imp["dmu"] > 0 and imp["db_hat"] > 0245 assert imp["qRO"] < EQ["qRO"] and imp["lam"] > EQ["lam"]246 assert imp["rent_price"] < imp["rent_price_base"]247 assert imp["seller_gain"] > 0248 zero = tm.impact_response(PAR, EQ, 0.0)249 assert abs(zero["dp"]) < 1e-9 and abs(zero["dmu"]) < 1e-9250251252def test_impact_fixed_supply_is_pure_composition():253 par0 = replace(PAR, eps=0.0, Hbar=0.72)254 e0 = tm.stationary_equilibrium(par0)255 imp = tm.impact_response(par0, e0, 0.1)256 assert imp["dmu"] == pytest.approx(0.0, abs=1e-12)257 assert imp["dp"] > 0 and imp["db_hat"] > 0 and imp["qRO"] < e0["qRO"] and imp["qOO"] > e0["qOO"]258259260def test_impact_larger_price_response_when_supply_less_elastic():261 outs = []262 for eps in [2.0, 1.0, 0.3]:263 par = replace(PAR, eps=eps, Hbar=EQ["mu"] / EQ["p"] ** eps) # same stationary point264 e = tm.stationary_equilibrium(par)265 assert e["p"] == pytest.approx(EQ["p"], rel=1e-6)266 outs.append(tm.impact_response(par, e, 0.1)["dp"])267 assert outs[0] < outs[1] < outs[2]268269270def test_myopic_transition_matches_impact_and_returns_to_steady_state():271 sim = tm.simulate_myopic_transition(PAR, EQ, 0.1, T=12)272 imp = tm.impact_response(PAR, EQ, 0.1)273 assert sim["mu"][0] == pytest.approx(imp["mu_next"], abs=1e-10)274 assert sim["p"][0] == pytest.approx(imp["p"], abs=1e-10)275 assert abs(sim["mu"][-1] - EQ["mu"]) < 0.1 * abs(sim["mu"][0] - EQ["mu"])276 flat = tm.simulate_myopic_transition(PAR, EQ, 0.0, T=6)277 assert np.allclose(flat["mu"], EQ["mu"], atol=1e-10)278