SPB Git forge

spb/wp18_uqo

Public
6commits 1branches 0releases
5.4 MBsize
maindefault branch
yesterdaylast push
TeX 53.2% Python 46.6%
8.7 KB · 184 lines python
Raw Blame History
1"""Simulation: budget identities state by state, debt law of motion, feasibility, transfers,2market clearing and residuals of the small general equilibrium, zero-sum revaluation."""3import numpy as np4import pytest56from src import distributions as D7from src import equilibrium as E8from src import model as M91011def _hv(P, h, p):12    return np.where(h >= 0, P.h_grid[np.maximum(h, 0)], 0.0) * p131415def test_budget_identity_every_branch(P_small, sim, prices):16    """Uses = sources for every household with an unclipped policy, by branch."""17    P = P_small18    rec, _ = sim19    p, rho = rec.p, rec.rho20    y = P.ybar[rec.age - 1] * P.z_grid[rec.z]21    reb = np.where(rec.age == 1, prices.transfer_rebate, 0.0)22    sources = y + rec.a0 * prices.R_a + reb23    ok = rec.flag == 024    uses = np.full(len(rec.age), np.nan)25    own0 = rec.h0 >= 026    # stay27    m = ok & (rec.choice == 0)28    uses[m] = rec.c[m] + rec.pay[m] + P.m * _hv(P, rec.h0[m], p) + rec.a1[m] + rec.gift[m]29    # refinance30    m = ok & (rec.choice == 1)31    uses[m] = rec.c[m] + rec.a1[m] + rec.gift[m] + rec.b0[m] + (P.kappa_f + P.m) * _hv(P, rec.h0[m], p) - rec.Bnew[m] + rec.pay[m]32    # buy (renter or mover): sources include the sale of the old house net of the balance33    m = ok & (rec.choice == 2)34    sale = np.where(own0, (1 - P.kappa_s) * _hv(P, rec.h0, p) - rec.b0, 0.0)35    uses[m] = rec.c[m] + (1 + P.kappa_b + P.m) * _hv(P, rec.h1[m], p) - rec.Bnew[m] + rec.pay[m] + rec.a1[m] + rec.gift[m] - sale[m]36    # rent (sitting tenant pays rho, seller pays rho as a new tenant: identical in the stationary state)37    m = ok & (rec.choice == 3)38    uses[m] = rec.c[m] + rho * rec.s[m] + rec.a1[m] + rec.gift[m] - sale[m]39    gap = uses - sources40    assert ok.mean() > 0.9941    assert np.nanmax(np.abs(gap[ok])) < 1e-842    assert (rec.flag == 1).sum() == 0, "forced sales in the stationary baseline"434445def test_budget_identity_with_transitory_income(P_small, pols, prices):46    """With the transitory income component on, uses = sources with the realised income."""47    P = M.build_params(M.load_config(overrides={**{k: v for k, v in P_small.cfg["simulation"].items() and {}},48                                                "simulation.n_dynasties": P_small.n_dyn, "simulation.burn_periods": 6,49                                                "grids.n_a": P_small.n_a, "grids.n_x": P_small.n_x, "income.sigma_transitory": 0.03}))50    recs, _ = D.simulate_stationary(P, pols, prices, n_record=1, burn=6)51    rec = recs[0]52    assert rec.y_shock.std() > 0.0153    y = P.ybar[rec.age - 1] * P.z_grid[rec.z] * rec.y_shock54    sources = y + rec.a0 * prices.R_a55    m = (rec.flag == 0) & (rec.choice == 3) & (rec.h0 < 0)       # sitting renters56    uses = rec.c[m] + rec.rho * rec.s[m] + rec.a1[m] + rec.gift[m]57    assert np.max(np.abs(uses - sources[m])) < 1e-858    m = (rec.flag == 0) & (rec.choice == 0)                        # owners who stay59    uses = rec.c[m] + rec.pay[m] + P.m * _hv(P, rec.h0[m], rec.p) + rec.a1[m] + rec.gift[m]60    assert np.max(np.abs(uses - sources[m])) < 1e-8616263def test_debt_law_of_motion_in_simulation(P_small, sim, prices):64    P = P_small65    rec, _ = sim66    m = (rec.choice == 0) & (rec.b0 > 0)67    b_next = np.array([M.next_balance(b, n, prices.i_new, prices.pi_next) for b, n in zip(rec.b0[m], rec.n0[m])])68    assert np.allclose(rec.b1[m], b_next, atol=1e-10)69    # new loans: balance after the first payment, remaining N-1 payments70    m = rec.Bnew > 071    b_next = np.array([M.next_balance(B, P.N, prices.i_new, prices.pi_next) for B in rec.Bnew[m]])72    assert np.allclose(rec.b1[m], b_next, atol=1e-10)73    assert np.all(rec.n1[m] == P.N - 1)74    # loans are repaid after N payments: nobody with n = 1 carries a balance forward75    m = (rec.choice == 0) & (rec.n0 == 1)76    assert np.all(rec.b1[m] < 1e-9)777879def test_feasibility_and_constraints(P_small, sim, prices):80    P = P_small81    rec, _ = sim82    assert np.all(rec.c >= P.c_min - 1e-12)83    assert np.all(rec.a1 >= 0)84    assert np.all(rec.s[rec.h1 < 0] >= P.s_min - 1e-12)85    assert np.all(rec.b1 >= 0)86    # LTV at origination87    m = rec.Bnew > 088    assert np.all(rec.Bnew[m] <= (1 - P.theta) * _hv(P, rec.h1[m], rec.p) + 1e-9)89    # DSTI at origination at the qualifying rate90    y = P.ybar[rec.age - 1] * P.z_grid[rec.z]91    assert np.all([M.payment(B, P.N, prices.i_q) <= P.psi * yy + 1e-8 for B, yy in zip(rec.Bnew[m], y[m])])92    # gifts only at the gift age, estates only at J93    assert np.all(rec.gift[rec.age != P.j_gift] == 0)94    assert np.all(rec.estate[rec.age != P.J] == 0)959697def test_estate_terminal_identity(P_small, sim):98    P = P_small99    rec, _ = sim100    m = rec.age == P.J101    est = rec.a1[m] + (1 - P.kappa_s) * _hv(P, rec.h1[m], rec.p) - rec.b1[m]102    assert np.allclose(rec.estate[m], np.maximum(est, 0), atol=1e-10)103104105def test_dynasty_link_consistency(P_small, sim):106    """Gifts received by age-1 members equal (1 - tau) x the gifts given at age j_gift in the same107    slot and period; the age distribution is uniform."""108    P = P_small109    rec, _ = sim110    counts = np.bincount(rec.age, minlength=P.J + 1)[1:]111    assert np.all(counts == counts[0])112    # per period: young age 1 and old age 7 share a slot113    n_rec = len(rec.age) // (2 * P.n_dyn)114    for k in range(n_rec):115        sl = slice(k * 2 * P.n_dyn, (k + 1) * 2 * P.n_dyn)116        a, g, gr, slot, old = rec.age[sl], rec.gift[sl], rec.gift_received[sl], rec.slot[sl], rec.is_old[sl]117        giv = {s: v for s, v, aa, o in zip(slot, g, a, old) if o and aa == P.j_gift}118        got = {s: v for s, v, aa, o in zip(slot, gr, a, old) if (not o) and aa == 1}119        assert set(giv) == set(got)120        assert all(abs((1 - P.tau_b) * giv[s] - got[s]) < 1e-10 for s in giv)121122123def test_lottery_is_mean_preserving(P_small, sim):124    P = P_small125    rec, _ = sim126    pts, w = D.estate_lottery(P, rec)127    e = rec.estate_received[rec.is_old & (rec.age == P.j_estate)]128    assert abs(pts @ w - e.mean()) < 1e-6 and np.all(np.diff(pts) >= 0)129130131def test_common_random_numbers(P_small, pols, prices):132    r1, _ = D.simulate_stationary(P_small, pols, prices, n_record=1, burn=6)133    r2, _ = D.simulate_stationary(P_small, pols, prices, n_record=1, burn=6)134    assert np.array_equal(r1[0].c, r2[0].c)135136137@pytest.mark.slow138def test_general_equilibrium_clears(ge_small):139    P, res = ge_small140    assert res.equilibrium == "GE"141    assert res.converged, f"small GE did not converge: residuals {res.residuals}"142    tol = P.cfg["equilibrium"]["tol_excess"]143    assert abs(res.residuals[0]) < tol and abs(res.residuals[1]) < tol144    # calibration normalises the baseline at p = p_r = 1145    assert abs(res.p - 1.0) < 0.01 and abs(res.ptil_r - 1.0) < 0.01146    assert res.bellman["owner_mean"] < 5e-3 and res.bellman["renter_mean"] < 5e-3147    # the lottery fixed point is consistent with the simulated estates148    assert np.max(np.abs(res.ev.lottery_new[0] - res.lottery[0])) < 5e-3 * max(1.0, res.lottery[0].max())149150151@pytest.mark.slow152def test_surprise_revaluation_zero_sum_and_contract_ordering(ge_small):153    """Partial-equilibrium impact of a 10 % surprise: households' real gain on nominal154    positions = - lender revaluation (identity), FRM = TERM5 for a level surprise (balance155    erosion only), ARM erodes nothing."""156    P, res = ge_small157    core = E.ss_core(res)158    out = {}159    for contract in ("frm", "term5", "arm"):160        sh = E.Shock(kind="inflation", pi_u=0.10, persistence=1, contract=contract)161        paths = E.shock_paths(P, sh, 4)162        out[contract] = paths163    assert abs(out["frm"]["balance_factor"] - 1 / 1.1) < 1e-12164    assert abs(out["term5"]["balance_factor"] - 1 / 1.1) < 1e-12165    assert out["arm"]["balance_factor"] == 1.0166    assert abs(out["frm"]["R_a"][0] - P.R / 1.1) < 1e-12            # nominal savers lose167    # persistent surprise: FRM contract rate stays at i*, term5 reprices to the new nominal rate168    sh = E.Shock(kind="inflation", pi_u=0.10, persistence=2, contract="frm")169    paths = E.shock_paths(P, sh, 4)170    assert paths["i_new"][0] > P.i_nom + 1e-6171    rates0 = E.frm_rate_by_n(P, paths, 0)172    assert np.allclose(rates0[: P.N - 1], P.i_nom)                      # pre-shock originations keep i*173    assert abs(rates0[P.N - 1] - paths["i_new"][0]) < 1e-12              # a loan with N payments left at t=0 is new174    assert abs(E.frm_rate_by_n(P, paths, 1)[P.N - 1] - paths["i_new"][1]) < 1e-12   # new loan at t=1 carries i_1175    assert abs(E.frm_rate_by_n(P, paths, 1)[P.N - 2] - paths["i_new"][0]) < 1e-12   # originated at t=0176    # zero sum: households' aggregate nominal revaluation equals minus the lender's177    rec = res.ev.rec178    bf = out["frm"]["balance_factor"]179    debt_gain = (1 - bf) * rec.b0180    dep_loss = rec.a0 * (P.R - P.R / 1.1)181    households = (debt_gain - dep_loss).sum()182    lender = -(1 - bf) * rec.b0.sum() + (P.R - P.R / 1.1) * rec.a0.sum()183    assert abs(households + lender) < 1e-8 * max(1.0, abs(households))184