SPB Git forge

spb/wp18_uqo

Public
6commits 1branches 0releases
5.4 MBsize
maindefault branch
yesterdaylast push
TeX 53.2% Python 46.6%
14.3 KB · 278 lines python
Raw Blame History
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