"""Simulation: budget identities state by state, debt law of motion, feasibility, transfers, market clearing and residuals of the small general equilibrium, zero-sum revaluation.""" import numpy as np import pytest from src import distributions as D from src import equilibrium as E from src import model as M def _hv(P, h, p): return np.where(h >= 0, P.h_grid[np.maximum(h, 0)], 0.0) * p def test_budget_identity_every_branch(P_small, sim, prices): """Uses = sources for every household with an unclipped policy, by branch.""" P = P_small rec, _ = sim p, rho = rec.p, rec.rho y = P.ybar[rec.age - 1] * P.z_grid[rec.z] reb = np.where(rec.age == 1, prices.transfer_rebate, 0.0) sources = y + rec.a0 * prices.R_a + reb ok = rec.flag == 0 uses = np.full(len(rec.age), np.nan) own0 = rec.h0 >= 0 # stay m = ok & (rec.choice == 0) uses[m] = rec.c[m] + rec.pay[m] + P.m * _hv(P, rec.h0[m], p) + rec.a1[m] + rec.gift[m] # refinance m = ok & (rec.choice == 1) 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] # buy (renter or mover): sources include the sale of the old house net of the balance m = ok & (rec.choice == 2) sale = np.where(own0, (1 - P.kappa_s) * _hv(P, rec.h0, p) - rec.b0, 0.0) 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] # rent (sitting tenant pays rho, seller pays rho as a new tenant: identical in the stationary state) m = ok & (rec.choice == 3) uses[m] = rec.c[m] + rho * rec.s[m] + rec.a1[m] + rec.gift[m] - sale[m] gap = uses - sources assert ok.mean() > 0.99 assert np.nanmax(np.abs(gap[ok])) < 1e-8 assert (rec.flag == 1).sum() == 0, "forced sales in the stationary baseline" def test_budget_identity_with_transitory_income(P_small, pols, prices): """With the transitory income component on, uses = sources with the realised income.""" P = M.build_params(M.load_config(overrides={**{k: v for k, v in P_small.cfg["simulation"].items() and {}}, "simulation.n_dynasties": P_small.n_dyn, "simulation.burn_periods": 6, "grids.n_a": P_small.n_a, "grids.n_x": P_small.n_x, "income.sigma_transitory": 0.03})) recs, _ = D.simulate_stationary(P, pols, prices, n_record=1, burn=6) rec = recs[0] assert rec.y_shock.std() > 0.01 y = P.ybar[rec.age - 1] * P.z_grid[rec.z] * rec.y_shock sources = y + rec.a0 * prices.R_a m = (rec.flag == 0) & (rec.choice == 3) & (rec.h0 < 0) # sitting renters uses = rec.c[m] + rec.rho * rec.s[m] + rec.a1[m] + rec.gift[m] assert np.max(np.abs(uses - sources[m])) < 1e-8 m = (rec.flag == 0) & (rec.choice == 0) # owners who stay uses = rec.c[m] + rec.pay[m] + P.m * _hv(P, rec.h0[m], rec.p) + rec.a1[m] + rec.gift[m] assert np.max(np.abs(uses - sources[m])) < 1e-8 def test_debt_law_of_motion_in_simulation(P_small, sim, prices): P = P_small rec, _ = sim m = (rec.choice == 0) & (rec.b0 > 0) 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])]) assert np.allclose(rec.b1[m], b_next, atol=1e-10) # new loans: balance after the first payment, remaining N-1 payments m = rec.Bnew > 0 b_next = np.array([M.next_balance(B, P.N, prices.i_new, prices.pi_next) for B in rec.Bnew[m]]) assert np.allclose(rec.b1[m], b_next, atol=1e-10) assert np.all(rec.n1[m] == P.N - 1) # loans are repaid after N payments: nobody with n = 1 carries a balance forward m = (rec.choice == 0) & (rec.n0 == 1) assert np.all(rec.b1[m] < 1e-9) def test_feasibility_and_constraints(P_small, sim, prices): P = P_small rec, _ = sim assert np.all(rec.c >= P.c_min - 1e-12) assert np.all(rec.a1 >= 0) assert np.all(rec.s[rec.h1 < 0] >= P.s_min - 1e-12) assert np.all(rec.b1 >= 0) # LTV at origination m = rec.Bnew > 0 assert np.all(rec.Bnew[m] <= (1 - P.theta) * _hv(P, rec.h1[m], rec.p) + 1e-9) # DSTI at origination at the qualifying rate y = P.ybar[rec.age - 1] * P.z_grid[rec.z] 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])]) # gifts only at the gift age, estates only at J assert np.all(rec.gift[rec.age != P.j_gift] == 0) assert np.all(rec.estate[rec.age != P.J] == 0) def test_estate_terminal_identity(P_small, sim): P = P_small rec, _ = sim m = rec.age == P.J est = rec.a1[m] + (1 - P.kappa_s) * _hv(P, rec.h1[m], rec.p) - rec.b1[m] assert np.allclose(rec.estate[m], np.maximum(est, 0), atol=1e-10) def test_dynasty_link_consistency(P_small, sim): """Gifts received by age-1 members equal (1 - tau) x the gifts given at age j_gift in the same slot and period; the age distribution is uniform.""" P = P_small rec, _ = sim counts = np.bincount(rec.age, minlength=P.J + 1)[1:] assert np.all(counts == counts[0]) # per period: young age 1 and old age 7 share a slot n_rec = len(rec.age) // (2 * P.n_dyn) for k in range(n_rec): sl = slice(k * 2 * P.n_dyn, (k + 1) * 2 * P.n_dyn) a, g, gr, slot, old = rec.age[sl], rec.gift[sl], rec.gift_received[sl], rec.slot[sl], rec.is_old[sl] giv = {s: v for s, v, aa, o in zip(slot, g, a, old) if o and aa == P.j_gift} got = {s: v for s, v, aa, o in zip(slot, gr, a, old) if (not o) and aa == 1} assert set(giv) == set(got) assert all(abs((1 - P.tau_b) * giv[s] - got[s]) < 1e-10 for s in giv) def test_lottery_is_mean_preserving(P_small, sim): P = P_small rec, _ = sim pts, w = D.estate_lottery(P, rec) e = rec.estate_received[rec.is_old & (rec.age == P.j_estate)] assert abs(pts @ w - e.mean()) < 1e-6 and np.all(np.diff(pts) >= 0) def test_common_random_numbers(P_small, pols, prices): r1, _ = D.simulate_stationary(P_small, pols, prices, n_record=1, burn=6) r2, _ = D.simulate_stationary(P_small, pols, prices, n_record=1, burn=6) assert np.array_equal(r1[0].c, r2[0].c) @pytest.mark.slow def test_general_equilibrium_clears(ge_small): P, res = ge_small assert res.equilibrium == "GE" assert res.converged, f"small GE did not converge: residuals {res.residuals}" tol = P.cfg["equilibrium"]["tol_excess"] assert abs(res.residuals[0]) < tol and abs(res.residuals[1]) < tol # calibration normalises the baseline at p = p_r = 1 assert abs(res.p - 1.0) < 0.01 and abs(res.ptil_r - 1.0) < 0.01 assert res.bellman["owner_mean"] < 5e-3 and res.bellman["renter_mean"] < 5e-3 # the lottery fixed point is consistent with the simulated estates assert np.max(np.abs(res.ev.lottery_new[0] - res.lottery[0])) < 5e-3 * max(1.0, res.lottery[0].max()) @pytest.mark.slow def test_surprise_revaluation_zero_sum_and_contract_ordering(ge_small): """Partial-equilibrium impact of a 10 % surprise: households' real gain on nominal positions = - lender revaluation (identity), FRM = TERM5 for a level surprise (balance erosion only), ARM erodes nothing.""" P, res = ge_small core = E.ss_core(res) out = {} for contract in ("frm", "term5", "arm"): sh = E.Shock(kind="inflation", pi_u=0.10, persistence=1, contract=contract) paths = E.shock_paths(P, sh, 4) out[contract] = paths assert abs(out["frm"]["balance_factor"] - 1 / 1.1) < 1e-12 assert abs(out["term5"]["balance_factor"] - 1 / 1.1) < 1e-12 assert out["arm"]["balance_factor"] == 1.0 assert abs(out["frm"]["R_a"][0] - P.R / 1.1) < 1e-12 # nominal savers lose # persistent surprise: FRM contract rate stays at i*, term5 reprices to the new nominal rate sh = E.Shock(kind="inflation", pi_u=0.10, persistence=2, contract="frm") paths = E.shock_paths(P, sh, 4) assert paths["i_new"][0] > P.i_nom + 1e-6 rates0 = E.frm_rate_by_n(P, paths, 0) assert np.allclose(rates0[: P.N - 1], P.i_nom) # pre-shock originations keep i* assert abs(rates0[P.N - 1] - paths["i_new"][0]) < 1e-12 # a loan with N payments left at t=0 is new 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_1 assert abs(E.frm_rate_by_n(P, paths, 1)[P.N - 2] - paths["i_new"][0]) < 1e-12 # originated at t=0 # zero sum: households' aggregate nominal revaluation equals minus the lender's rec = res.ev.rec bf = out["frm"]["balance_factor"] debt_gain = (1 - bf) * rec.b0 dep_loss = rec.a0 * (P.R - P.R / 1.1) households = (debt_gain - dep_loss).sum() lender = -(1 - bf) * rec.b0.sum() + (P.R - P.R / 1.1) * rec.a0.sum() assert abs(households + lender) < 1e-8 * max(1.0, abs(households))