TeX 53.2%
Python 46.6%
1"""WP18 — LaTeX tables (booktabs, threeparttable notes) and the numbers.tex macro file.23Every number comes from a file under results/data/ (named in each table's note). The tables are4fragments to be \\input{} from paper/main.tex. Usage: python -m src.tables5"""6from __future__ import annotations78import json9import re10from pathlib import Path1112import numpy as np13import pandas as pd1415from .model import RESULTS_DIR1617DATA = RESULTS_DIR / "data"18TAB = RESULTS_DIR / "tables"19TAB.mkdir(parents=True, exist_ok=True)20MACROS: dict[str, str] = {}212223# --------------------------------------------------------------------------------------24# helpers25# --------------------------------------------------------------------------------------26def _json(path):27 with open(path) as fh:28 return json.load(fh)293031def _minus(txt: str) -> str:32 """Typeset a leading minus sign so that it is correct in text and in math mode; no negative zero."""33 if txt.startswith("-"):34 if float(txt) == 0.0:35 return txt[1:]36 return "\\ensuremath{-}" + txt[1:]37 return txt383940def _f(x, nd=3, pct=False, pp=False):41 if x is None or (isinstance(x, float) and not np.isfinite(x)):42 return "--"43 if pct:44 return _minus(f"{100 * x:.{nd}f}")45 return _minus(f"{x:.{nd}f}")464748def _esc(s: str) -> str:49 return (s.replace("_", "\\_").replace("%", "\\%").replace("&", "\\&").replace("^", "\\textasciicircum{}")50 .replace("#", "\\#"))515253def _write(name: str, body: str, caption: str, label: str, notes: str, colspec: str, header: str, size: str = "\\small", wide: bool = False):54 tab = f"\\begin{{tabular}}{{{colspec}}}\n\\toprule\n{header}\n\\midrule\n{body}\\bottomrule\n\\end{{tabular}}"55 if wide: # tables wider than the text block are scaled to \textwidth (HARMONISATION §5); threeparttable56 # cannot wrap a \resizebox, so the note is set in a minipage of the same width57 tex = ("\\begin{table}[htbp]\n\\centering\n" + size + "\n"58 f"\\caption{{{caption}}}\n\\label{{{label}}}\n\\resizebox{{\\textwidth}}{{!}}{{%\n" + tab + "}\n"59 f"\\begin{{minipage}}{{\\textwidth}}\\footnotesize\\vspace{{2pt}}\n{notes}\n\\end{{minipage}}\n\\end{{table}}\n")60 else:61 tex = ("\\begin{table}[htbp]\n\\centering\n" + size + "\n\\begin{threeparttable}\n"62 f"\\caption{{{caption}}}\n\\label{{{label}}}\n" + tab + "\n"63 f"\\begin{{tablenotes}}[flushleft]\n\\footnotesize\n\\item {notes}\n\\end{{tablenotes}}\n"64 "\\end{threeparttable}\n\\end{table}\n")65 with open(TAB / f"{name}.tex", "w") as fh:66 fh.write(tex)67 print("table:", name)686970def macro(name: str, value: str):71 """Register a \\newcommand; names must be letters only."""72 key = re.sub(r"[^A-Za-z]", "", name)73 MACROS[key] = value747576def _load(path):77 p = DATA / path78 return _json(p) if p.exists() else None798081# ======================================================================================82def tab01_notation():83 rows = [84 ("$j$", "age index, $j = 1,\\dots,J=12$ (5-year periods, 25--84)", "--"),85 ("$z$", "idiosyncratic productivity (Rouwenhorst, $n_z = 5$), frozen in retirement", "--"),86 ("$h \\in \\mathcal H$", "owner-occupied house size (four sizes, $h_1 = \\underline h$)", "housing units"),87 ("$s$", "rented housing services ($s \\ge \\underline s$)", "housing units"),88 ("$a$", "liquid real asset (nominal deposit), $a \\ge 0$", "goods"),89 ("$b^m$", "real mortgage balance at the start of the period ($B^{nom}/P$)", "goods"),90 ("$n_m$", "remaining number of mortgage payments ($N = 5$ at origination)", "--"),91 ("$p$, $p^r$, $\\rho$", "real house price, rental-unit price, rent per period", "goods / unit"),92 ("$P$, $\\pi$, $\\pi^u$", "CPI level, inflation, unexpected inflation", "--"),93 ("$R$, $i$, $i_q$", "gross real rate, nominal rate ($1+i = R(1+\\bar\\pi)$), qualifying rate", "--"),94 ("$\\theta$, $\\psi$", "minimum down payment, payment-to-income cap", "--"),95 ("$\\kappa_b$, $\\kappa_s$, $\\kappa_f$, $m$", "buying, selling, refinancing and maintenance costs", "share of value"),96 ("$b^g$, $b^e$", "inter-vivos gift (parent's age $j_g = 7$), estate (parent's age $J$)", "goods"),97 ("$\\nu$, $\\underline x$", "warm-glow weight and luxury shift", "--"),98 ("$\\varepsilon$, $\\varepsilon_r$, $\\delta_H$", "supply elasticities, depreciation of the stocks", "--"),99 ("$\\sigma$, $\\eta_\\rho$, $\\eta_w$", "segmentation, rent and wage indexation to a surprise", "--"),100 ("$\\hat a_j(z)$", "ownership-entry threshold: smallest liquid wealth at which the age-$j$ renter buys", "goods"),101 ("$q_{RO}$, $q_{OO}$, $\\lambda$", "child ownership given parent renter / owner; persistence $\\lambda = q_{OO} - q_{RO}$", "--"),102 ("$\\lambda^{CE}$", "consumption-equivalent variation (Section~\\ref{sec:welfare})", "--"),103 ]104 body = "".join(f"{a} & {b} & {c} \\\\\n" for a, b, c in rows)105 _write("tab01_notation", body, "Notation of the quantitative model", "tab:notation",106 "Units: ``goods'' = units of the consumption good; the numeraire of the simulations is the average 5-year labour income of a working-age household (E$[z]=1$). Source: research/model\\_specification.md, Part B.",107 "lp{9.3cm}l", "Symbol & Meaning & Units \\\\")108109110def tab02_parameters():111 f = DATA / "parameters.csv"112 if not f.exists():113 return114 d = pd.read_csv(f)115 body = ""116 for _, r in d.iterrows():117 v = r["value"]118 try:119 vv = float(v)120 vs = f"{int(round(vv)):,}".replace(",", "{,}") if (abs(vv) >= 1000 and float(vv).is_integer()) else f"{vv:.4g}"121 except (TypeError, ValueError):122 vs = _esc(str(v))123 body += f"{_esc(str(r['parameter']))} & {vs} & {_esc(str(r['unit']))} & {_esc(str(r['source']))} \\\\\n"124 _write("tab02_parameters", body, "Parameters of the baseline (period = 5 years)", "tab:parameters",125 "Values in period units after the conversions recorded in src/model.py (annual rates compounded over 5 years; the 5-year AR(1) keeps the unconditional variance of the annual process). ``Illustrative'' = no verified Canadian estimate in the literature matrix; the supply elasticity in particular has no verified Canadian source (research/literature\\_review.md, \\S7). Supply scales $\\bar H$, $\\bar H_r$ are set by the normalisation $p^* = p^{r*} = 1$. Source file: results/data/parameters.csv.",126 "lp{2.2cm}p{2.3cm}p{6cm}", "Parameter & Value & Unit & Source / status \\\\", size="\\footnotesize")127128129def tab03_moments():130 s = _load("baseline/summary.json")131 if s is None:132 return133 m = s["moments"]; mob = s["mobility"]134 t = pd.read_csv(DATA / "calibration_targets.csv") if (DATA / "calibration_targets.csv").exists() else None135 rows = [136 ("Ownership rate, all households", _f(m["ownership_rate_all"]), "about 0.66--0.69 (not a verified target); plateau $\\approx$ 0.75 near 65 (Hou 2010)"),137 ("Ownership rate, under 35 / 35--64 / 65+", f"{_f(m['ownership_rate_under35'], 2)} / {_f(m['ownership_rate_35_64'], 2)} / {_f(m['ownership_rate_65plus'], 2)}", "steep before 40, plateau near 65 (Hou 2010)"),138 ("Share of households with a mortgage", _f(m["share_with_mortgage"]), "0.39 (SFS 2023)"),139 ("Share of owners with a mortgage", _f(m["share_owners_with_mortgage"]), "--"),140 ("Mean net worth owners / renters, under 35", _f(m["mean_nw_ratio_owner_renter_under35"], 1), "median 457{,}100 / 44{,}000 $\\approx$ 10.4 (SFS 2023); model median of renters $= 0$"),141 ("Mean gift received at entry (5-year income units)", _f(m["mean_gift_received"]), "about \\$80{,}000 (CIBC, 2021, via Mirdamadi--Khalid 2023)"),142 ("Share of entrants receiving a positive gift", _f(m["share_gift_positive"]), "28\\% of first-time buyers received gifts (same source)"),143 ("Mean estate received at age 55--59", _f(m["mean_estate_received"]), "--"),144 ("Median house value / annual income (owners)", _f(m["median_house_value_to_annual_income"], 1), "--"),145 ("Mean LTV of new loans", _f(m["mean_ltv_new_loans"]), "--"),146 ("Annual rent--price ratio", _f(m["rent_price_ratio_annual"]), "illustrative (landlord wedge $\\phi$)"),147 ("Gini of net worth (all ages)", _f(s["gini_nw"]), "--"),148 ("P(child owns at 35--39 $\\mid$ parent renter / owner)", f"{_f(mob['q_RO_age3'], 2)} / {_f(mob['q_OO_age3'], 2)}", "children of owners more likely to own (Mirdamadi--Khalid 2023)"),149 ("Rank--rank slope of net worth (child at 55--59 vs parent at 55--59)", _f(mob["rank_rank_slope_nw"], 2), "--"),150 ("Aggregate mortgage debt / deposits (per age mass)", f"{_f(s['total_debt'], 2)} / {_f(s['total_deposits'], 2)}", "--"),151 ]152 body = "".join(f"{a} & {b} & {c} \\\\\n" for a, b, c in rows)153 _write("tab03_baseline_moments", body, "Baseline stationary equilibrium: model moments and verified comparison points", "tab:moments",154 "Model moments from results/data/baseline/summary.json (pooled cross-section of 6 periods, " + f"{s['params']['n_dyn'] * 2 * 6:,}" + " household-periods). Comparison points are quoted from the verified sources listed in research/literature\\_matrix.csv (research/literature\\_review.md \\S1, \\S7) and are not fitted: the calibration is illustrative (Table~\\ref{tab:parameters}). The young renters' median net worth is zero in the model (no liquid saving before purchase), so the median ratio is undefined; the mean ratio is reported.",155 "p{6.3cm}cp{6cm}", "Moment & Model & Comparison point (source) \\\\", size="\\footnotesize")156 macro("OwnershipAll", _f(m["ownership_rate_all"], 2)); macro("OwnershipUnderThirtyFive", _f(m["ownership_rate_under35"], 2))157 macro("ShareMortgage", _f(m["share_with_mortgage"], 2)); macro("MeanGift", _f(m["mean_gift_received"], 2))158 macro("MeanGiftAnnual", _f(m["mean_gift_in_annual_incomes"], 1)); macro("GiniNW", _f(s["gini_nw"], 2))159 macro("qROageThree", _f(mob["q_RO_age3"], 2)); macro("qOOageThree", _f(mob["q_OO_age3"], 2))160 macro("RankRankNW", _f(mob["rank_rank_slope_nw"], 2)); macro("NWRatioYoung", _f(m["mean_nw_ratio_owner_renter_under35"], 1))161 macro("LambdaAgeSeven", _f(mob["lambda_persistence"], 3)); macro("LambdaAgeThree", _f(mob["q_OO_age3"] - mob["q_RO_age3"], 2))162 macro("TotalDebt", _f(s["total_debt"], 2)); macro("TotalDeposits", _f(s["total_deposits"], 2))163 macro("ThresholdAgeOneLowZ", _f(s["thresholds"]["1"]["0"], 2)); macro("ThresholdAgeOneMidZ", _f(s["thresholds"]["1"]["2"], 2))164 macro("ThresholdAgeOneHighZ", _f(s["thresholds"]["1"]["4"], 2))165166167def tab04_convergence():168 s = _load("baseline/summary.json")169 if s is None:170 return171 g = pd.read_csv(DATA / "grid_sensitivity.csv").set_index("name") if (DATA / "grid_sensitivity.csv").exists() else None172 diag = _load("diagnostics.json")173 rows = [174 ("Owner-occupied market, relative excess demand", _f(s["excess_owner"], 6)),175 ("Rental market, relative excess demand", _f(s["excess_rental"], 6)),176 ("Tolerance (relative excess demand)", "2.5e-4"),177 ("Estate-lottery fixed point: max.\\ change (last round)", _f(float(np.max(np.abs(np.array(s["residuals"][2:7])))), 6)),178 ("Bellman residual, owners: mean / 99th pct.\\ / max", f"{_f(s['bellman']['owner_mean'], 5)} / {_f(s['bellman']['owner_p99'], 4)} / {_f(s['bellman']['owner_max'], 4)}"),179 ("Bellman residual, renters: mean / 99th pct.\\ / max", f"{_f(s['bellman']['renter_mean'], 5)} / {_f(s['bellman']['renter_p99'], 4)} / {_f(s['bellman']['renter_max'], 4)}"),180 ("Share of households at the forced-sale rule", _f(s["forced_share"], 5)),181 ("Share of households with a clipped (infeasible interpolated) policy", _f(s["clip_share"], 5)),182 ("Outer (lottery) rounds / household-block evaluations", f"{s['iterations']} / {s['supply_scales'].get('calibration_iterations', '--')} (calibration)"),183 ("Wall-clock time of the baseline (s)", _f(s["time_sec"], 0)),184 ]185 if diag is not None:186 rows.append(("Total wall-clock time of \\texttt{make simulate} (min)", _f(diag["total_sec"] / 60, 1)))187 n_tr = [r for r in diag["runs"] if r.get("group") == "inflation"]188 if n_tr:189 rows.append(("Transitions solved (GE converged / total)", f"{sum(1 for r in n_tr if r.get('converged'))} / {len(n_tr)}"))190 body = "".join(f"{a} & {b} \\\\\n" for a, b in rows)191 if g is not None:192 body += "\\midrule\n\\multicolumn{2}{l}{\\emph{Numerical variants (deviation from baseline, in percent of the baseline value)}} \\\\\n"193 for n in [x for x in ["grid_coarse", "grid_fine", "tol_tight", "sample_small", "sample_large", "seed_alt"] if x in g.index]:194 labels = {"ownership": "own.", "p": "$p^*$", "gini_nw": "Gini", "mean_gift": "gift", "rank_rank_nw": "rank--rank"}195 dev = ", ".join(f"{labels[k]} {100 * (g.loc[n, k] / g.loc['baseline', k] - 1):+.2f}" for k in labels if np.isfinite(g.loc[n, k]))196 body += f"{_esc(n)} & {dev} \\\\\n"197 _write("tab04_convergence", body, "Convergence and numerical diagnostics", "tab:convergence",198 "Bellman residuals are relative differences between the interpolated value function and a re-optimisation on a 400-point fine asset grid at 400 random off-grid states (gift age excluded). Numerical variants: grids (coarse: $n_a=30,n_b=12,n_x=80$; fine: $80,30,180$), tighter tolerance ($10^{-5}$), panel of 24{,}000 and 240{,}000 dynasties (baseline 96{,}000), alternative seed. Source files: results/data/baseline/summary.json, results/data/grid\\_sensitivity.csv, results/data/diagnostics.json.",199 "p{8.5cm}p{6cm}", "Diagnostic & Value \\\\", size="\\footnotesize")200 macro("BellmanOwnerMean", _f(s["bellman"]["owner_mean"], 5)); macro("BellmanRenterMean", _f(s["bellman"]["renter_mean"], 5))201 macro("ExcessOwner", _f(abs(s["excess_owner"]), 6)); macro("ExcessRental", _f(abs(s["excess_rental"]), 6))202 if diag is not None:203 macro("SimulateMinutes", _f(diag["total_sec"] / 60, 0))204205206def _scenario_row(name, path, label=None):207 d = _load(path)208 if d is None:209 return None210 m, mob = d["moments"], d["mobility"]211 return dict(name=label or name, eq=d["equilibrium"], conv=d["converged"], p=d["p"], rho_p=d["rho"] / d["p_r"] / 5, own=m["ownership_rate_all"],212 own_u35=m["ownership_rate_under35"], thr=d["thresholds"]["1"]["2"], qro=mob["q_RO_age3"], qoo=mob["q_OO_age3"],213 rr=mob["rank_rank_slope_nw"], gini=d["gini_nw"], gift=m["mean_gift_received"], gap4=d["gaps"]["nw_gap_owner_renter_age4"])214215216def tab05_scenarios():217 spec = [218 ("baseline", "baseline/summary.json", "Baseline ($\\theta=0.20$, $\\varepsilon=1$)"),219 ("theta_005", "theta_sweep/theta_005.json", "$\\theta = 0.05$"), ("theta_010", "theta_sweep/theta_010.json", "$\\theta = 0.10$"),220 ("theta_030", "theta_sweep/theta_030.json", "$\\theta = 0.30$"), ("theta_040", "theta_sweep/theta_040.json", "$\\theta = 0.40$"),221 ("theta_005_pe", "theta_sweep/theta_005_pe.json", "$\\theta = 0.05$, prices fixed"), ("theta_040_pe", "theta_sweep/theta_040_pe.json", "$\\theta = 0.40$, prices fixed"),222 ("psi_030", "theta_sweep/psi_030.json", "DSTI cap $\\psi = 0.30$"), ("psi_050", "theta_sweep/psi_050.json", "DSTI cap $\\psi = 0.50$"),223 ("eps_005", "supply/eps_005.json", "$\\varepsilon = 0.05$ (near-fixed supply)"), ("eps_025", "supply/eps_025.json", "$\\varepsilon = 0.25$"),224 ("eps_050", "supply/eps_050.json", "$\\varepsilon = 0.5$"), ("eps_200", "supply/eps_200.json", "$\\varepsilon = 2$"),225 ("noseg", "supply/noseg.json", "No segmentation ($\\sigma = 0$)"), ("halfseg", "supply/halfseg.json", "Half segmentation ($\\sigma = 0.5$)"),226 ("bequests_off", "bequests/bequests_off.json", "Bequests off ($\\nu = 0$)"), ("bequests_half", "bequests/bequests_half.json", "Bequests halved ($\\nu = 1$)"),227 ("transfer_tax_020", "bequests/transfer_tax_020.json", "Transfer tax 20\\% (rebated)"), ("transfer_tax_050", "bequests/transfer_tax_050.json", "Transfer tax 50\\% (rebated)"),228 ("bequests_off_pe", "bequests/bequests_off_pe.json", "Bequests off, prices fixed"),229 ]230 rows = [r for r in (_scenario_row(n, p, l) for n, p, l in spec) if r is not None]231 if not rows:232 return233 body = ""234 for r in rows:235 eq = "GE" if r["eq"] == "GE" else "PE"236 flag = "" if r["conv"] else "$^{\\dagger}$"237 body += (f"{r['name']} & {eq}{flag} & {_f(r['p'])} & {_f(r['rho_p'], 3)} & {_f(r['own'], 3)} & {_f(r['own_u35'], 3)} & {_f(r['thr'], 2)} & "238 f"{_f(r['qro'], 2)} & {_f(r['qoo'], 2)} & {_f(r['rr'], 2)} & {_f(r['gini'], 2)} & {_f(r['gift'], 2)} \\\\\n")239 _write("tab05_scenarios", body, "Stationary equilibria across credit, supply, segmentation and bequest scenarios", "tab:scenarios",240 "GE = general equilibrium (prices, estate lottery and rebate re-solved; supply scales fixed at the baseline normalisation); PE = partial equilibrium with prices fixed at the baseline (household block and distribution re-solved). $^{\\dagger}$ = not converged to tolerance (residuals in results/data/diagnostics.csv). $\\rho/p$ = annual rent--price ratio; $\\hat a_1$ = age-1 ownership-entry threshold at the median productivity state (5-year income units); $q_{RO}$, $q_{OO}$ = probability that the child owns at 35--39 given the parent's tenure at the gift; rank--rank = slope of the child's net-worth rank on the parent's (both at 55--59); gift = mean transfer received at entry. Source files: results/data/group/name.json.",241 "lcccccccccccc", "Scenario & Eq. & $p^*$ & $\\rho/p$ & Own. & Own.\\ $<$35 & $\\hat a_1$ & $q_{RO}$ & $q_{OO}$ & rank--rank & Gini & gift \\\\", size="\\footnotesize", wide=True)242 for r in rows:243 key = re.sub(r"[^A-Za-z]", "", r["name"].split("(")[0].replace("\\", "").replace("$", "").replace("=", "").replace(".", ""))[:24]244 macro(f"Own{key}", _f(r["own"], 3)); macro(f"Price{key}", _f(r["p"], 3)); macro(f"Thr{key}", _f(r["thr"], 2))245246247def tab06_inflation():248 spec = [("frm_level", "FRM, level surprise"), ("term5_level", "5-year term, level"), ("arm_level", "ARM, level"),249 ("frm_persistent", "FRM, persistent (2 periods)"), ("term5_persistent", "5-year term, persistent"), ("arm_persistent", "ARM, persistent"),250 ("frm_level_rigid_rents", "FRM, level, rigid rents ($\\eta_\\rho=0$)"), ("frm_level_unindexed_wages", "FRM, level, unindexed wages ($\\eta_w=0$)"),251 ("frm_persistent_taylor", "FRM, persistent, real-rate response"), ("term5_persistent_taylor", "5-year term, persistent, real-rate response"),252 ("frm_level_fixed_supply", "FRM, level, $\\varepsilon=0.05$"), ("frm_level_noseg", "FRM, level, no segmentation"),253 ("frm_level_static", "FRM, level, static expectations (A6)")]254 rows = []255 for n, lab in spec:256 d = _load(f"inflation/{n}.json")257 if d is None:258 continue259 w = pd.read_csv(DATA / "inflation" / f"{n}_welfare.csv") if (DATA / "inflation" / f"{n}_welfare.csv").exists() else None260261 def cev(grp):262 if w is None:263 return np.nan264 x = w[(w.group == grp) & (w.age == "all")]265 return float(x.cev.iloc[0]) if len(x) else np.nan266 rows.append(dict(lab=lab, eq=d["equilibrium"], conv=d["converged"], res=d["max_residual"], p0=d["p_impact_pct"], pk=d["p_peak_pct"],267 rho0=d["rho_impact_pct"], down=d["d_ownership_impact_pp"], down_pe=d["d_ownership_impact_pe_pp"],268 lender=d["lender_revaluation"], erosion=d["aggregate_debt_erosion"], dep=d["aggregate_deposit_loss"],269 qro_ss=d["mobility_ss"]["q_RO_age3"], qro_1=d["mobility_path"][1]["q_RO_age3"] if len(d["mobility_path"]) > 1 else np.nan,270 cev_all=cev("all"), cev_om=cev("owners_with_mortgage"), cev_r=cev("renters"), cev_pe=d["pe"]["welfare_all"], name=n))271 if not rows:272 return273 body = ""274 for r in rows:275 eq = "GE" if r["eq"] == "GE" else ("A6" if "static" in r["eq"] else "GE$^{\\dagger}$")276 body += (f"{r['lab']} & {eq} & {_f(r['p0'], 2)} & {_f(r['rho0'], 2)} & {_f(r['down'], 2)} & {_f(r['down_pe'], 2)} & "277 f"{_f(r['erosion'], 3)} & {_f(r['dep'], 3)} & {_f(r['cev_om'], 2, pct=True)} & {_f(r['cev_r'], 2, pct=True)} & {_f(r['cev_all'], 2, pct=True)} & {_f(r['cev_pe'], 2, pct=True)} \\\\\n")278 _write("tab06_inflation", body, "Unanticipated 10\\% price-level surprise: impact effects by mortgage contract and persistence", "tab:inflation",279 "Perfect-foresight transitions (GE: prices clear every period; $^{\\dagger}$ = not converged to the $1.5\\times10^{-3}$ per-period tolerance (stalled at the Monte-Carlo noise floor; maximum residual in results/data/inflation/name.json); A6 = static expectations, stationary continuation values). Columns: real house price on impact (\\%; the impact is also the peak deviation in every run), real rent on impact (\\%), change of the ownership rate on impact (pp) in GE and with prices fixed (PE), aggregate real debt erosion and real deposit loss per unit mass of one age (the lender's revaluation is their difference with the opposite sign), consumption-equivalent welfare of households alive at the shock (owners with a mortgage, renters, all; \\%), and the all-household CEV with prices fixed. Level surprise: CPI 10\\% above expectation in the shock period, inflation back to target afterwards; persistent: two periods. Source: results/data/inflation/*.json and *\\_welfare.csv.",280 "lccccccccccc", "Scenario & Eq. & $p_0$ & $\\rho_0$ & $\\Delta$own & $\\Delta$own PE & erosion & dep.\\ loss & CEV o/m & CEV r & CEV all & CEV PE \\\\", size="\\scriptsize", wide=True)281 for r in rows:282 key = re.sub(r"[^A-Za-z]", "", r["name"])283 macro(f"PImpact{key}", _f(r["p0"], 2)); macro(f"RhoImpact{key}", _f(r["rho0"], 2)); macro(f"DOwn{key}", _f(r["down"], 2))284 macro(f"CEVom{key}", _f(r["cev_om"], 2, pct=True)); macro(f"CEVr{key}", _f(r["cev_r"], 2, pct=True)); macro(f"CEVall{key}", _f(r["cev_all"], 2, pct=True))285 macro(f"Erosion{key}", _f(r["erosion"], 3))286287288def tab07_mobility():289 spec = [("baseline", "baseline/summary.json", "Baseline"), ("bequests_off", "bequests/bequests_off.json", "Bequests off"),290 ("bequests_half", "bequests/bequests_half.json", "Bequests halved"), ("transfer_tax_020", "bequests/transfer_tax_020.json", "Transfer tax 20\\%"),291 ("transfer_tax_050", "bequests/transfer_tax_050.json", "Transfer tax 50\\%"), ("rho_g_040", "sensitivity/rho_g_040.json", "Intergenerational income correlation 0.4"),292 ("sigma_low", "sensitivity/sigma_low.json", "Earnings risk $\\sigma = 0.10$"), ("sigma_high", "sensitivity/sigma_high.json", "Earnings risk $\\sigma = 0.20$"),293 ("theta_005", "theta_sweep/theta_005.json", "$\\theta = 0.05$"), ("theta_040", "theta_sweep/theta_040.json", "$\\theta = 0.40$"),294 ("eps_005", "supply/eps_005.json", "$\\varepsilon = 0.05$"), ("eps_200", "supply/eps_200.json", "$\\varepsilon = 2$"),295 ("dep_premium_002", "sensitivity/dep_premium_002.json", "Deposit return $+2$ pp (renter investment)"),296 ("r_001", "sensitivity/r_001.json", "Real rate 1\\%"), ("r_003", "sensitivity/r_003.json", "Real rate 3\\%"),297 ("pi_000", "sensitivity/pi_000.json", "Trend inflation 0\\%"), ("pi_004", "sensitivity/pi_004.json", "Trend inflation 4\\%"),298 ("phi_002", "sensitivity/phi_002.json", "Landlord wedge $\\phi = 0.02$"), ("phi_008", "sensitivity/phi_008.json", "Landlord wedge $\\phi = 0.08$")]299 rows = []300 for n, p, lab in spec:301 d = _load(p)302 if d is None:303 continue304 mob, m = d["mobility"], d["moments"]305 rows.append((lab, d["equilibrium"] == "GE", d["converged"], d["p"], m["ownership_rate_all"], mob["q_RO_age3"], mob["q_OO_age3"], mob["q_OO_age3"] - mob["q_RO_age3"],306 mob["lambda_persistence"], mob["rank_rank_slope_nw"], d["gini_nw"], d["gaps"]["nw_gap_owner_renter_age4"], m["mean_gift_received"], m["mean_estate_received"]))307 if not rows:308 return309 body = "".join(f"{r[0]} & {'GE' if r[1] else 'PE'}{'' if r[2] else '$^{\\dagger}$'} & {_f(r[3])} & {_f(r[4])} & {_f(r[5], 2)} & {_f(r[6], 2)} & {_f(r[7], 2)} & {_f(r[8], 3)} & {_f(r[9], 2)} & {_f(r[10], 2)} & {_f(r[11], 2)} & {_f(r[12], 2)} & {_f(r[13], 2)} \\\\\n" for r in rows)310 _write("tab07_mobility_inequality", body, "Intergenerational persistence and inequality across stationary equilibria", "tab:mobility",311 "$q_{RO}$, $q_{OO}$: probability that the child owns at 35--39 given the parent's tenure when the gift is made; $\\lambda_3 = q_{OO} - q_{RO}$; $\\lambda_7$: the same difference measured at 55--59 (the Part A persistence statistic); rank--rank: slope of the child's net-worth rank on the parent's at the same age (55--59); gap$_4$: mean net-worth difference between owners and renters at 40--44 (equal income process by design); gift, estate: mean transfers received. $^{\\dagger}$ = not converged to tolerance. Source: results/data/group/name.json.",312 "lcccccccccccc", "Scenario & Eq. & $p^*$ & Own. & $q_{RO}$ & $q_{OO}$ & $\\lambda_3$ & $\\lambda_7$ & rank--rank & Gini & gap$_4$ & gift & estate \\\\", size="\\scriptsize", wide=True)313314315def tab08_welfare():316 f = DATA / "welfare" / "welfare_all.csv"317 if not f.exists():318 return319 w = pd.read_csv(f)320 w["gift_quintile"] = w.gift_quintile.astype(str)321 scen = [s for s in ["theta_005", "theta_010", "theta_030", "theta_040", "psi_030", "psi_050", "bequests_off", "transfer_tax_020", "transfer_tax_050",322 "eps_005", "eps_200", "noseg", "r_001", "r_003", "pi_004", "dep_premium_002", "chi_005", "kappa_low", "kappa_high"] if s in set(w.scenario)]323 body = ""324 for s in scen:325 d = w[w.scenario == s]326 byq = []327 for q in [str(i) for i in range(1, 6)] + ["marginal"]:328 dd = d[d.gift_quintile == q]329 ok = np.isfinite(dd.cev)330 byq.append(float(np.sum(dd.cev[ok] * dd.weight[ok]) / np.sum(dd.weight[ok])) if ok.any() else np.nan)331 dq = d[d.gift_quintile != "marginal"]332 ok = np.isfinite(dq.cev)333 allv = float(np.sum(dq.cev[ok] * dq.weight[ok]) / np.sum(dq.weight[ok]))334 lowz = dq[dq.z_index == 0]; hiz = dq[dq.z_index == dq.z_index.max()]335 labels = {"theta_005": "$\\theta = 0.05$", "theta_010": "$\\theta = 0.10$", "theta_030": "$\\theta = 0.30$", "theta_040": "$\\theta = 0.40$",336 "psi_030": "DSTI cap $\\psi = 0.30$", "psi_050": "DSTI cap $\\psi = 0.50$", "bequests_off": "Bequests off ($\\nu = 0$)$^{\\ddagger}$",337 "transfer_tax_020": "Transfer tax 20\\% (rebated)", "transfer_tax_050": "Transfer tax 50\\% (rebated)",338 "eps_005": "$\\varepsilon = 0.05$", "eps_200": "$\\varepsilon = 2$", "noseg": "No segmentation", "r_001": "Real rate 1\\%", "r_003": "Real rate 3\\%",339 "pi_004": "Trend inflation 4\\%", "dep_premium_002": "Deposit return $+2$ pp", "chi_005": "Ownership premium $\\chi = 0.05$$^{\\ddagger}$",340 "kappa_low": "Transaction costs low", "kappa_high": "Transaction costs high"}341 body += f"{labels.get(s, _esc(s))} & " + " & ".join(_f(v, 2, pct=True) for v in byq) + f" & {_f(float(np.nanmean(lowz.cev)), 2, pct=True)} & {_f(float(np.nanmean(hiz.cev)), 2, pct=True)} & {_f(allv, 2, pct=True)} \\\\\n"342 macro(f"CEV{re.sub(r'[^A-Za-z]', '', s)}", _f(allv, 2, pct=True))343 if not body:344 return345 _write("tab08_welfare", body, "Welfare of entering households across stationary equilibria (consumption-equivalent variation, \\%)", "tab:welfare",346 "$\\lambda^{CE}$ such that scaling non-housing consumption by $1+\\lambda^{CE}$ in every period of the baseline makes an age-1 household with state $(z_1, a_1)$ as well off as in the alternative stationary equilibrium: $V^{alt}_1 = (1+\\lambda^{CE})^{(1-\\alpha)(1-\\gamma)} U^{base} + \\Phi^{base}$, where $U^{base}$ (discounted flow utility) and $\\Phi^{base}$ (warm glow) come from cohort simulations under baseline policies. Columns: quintiles of the transfer received at entry (averaged over $z_1$ with stationary weights), households within $\\pm10\\%$ of the age-1 ownership threshold (``marginal''), lowest and highest $z_1$, all. Comparisons are between stationary equilibria, not along transitions. $^{\\ddagger}$ = the scenario changes a preference parameter, so the number compares value functions under different utilities and is not a welfare statement. Source: results/data/welfare/welfare\\_all.csv.",347 "lccccccccc", "Scenario & Q1 & Q2 & Q3 & Q4 & Q5 & marginal & low $z_1$ & high $z_1$ & all \\\\", size="\\footnotesize", wide=True)348349350def tab09_robustness():351 f = DATA / "robustness_summary.csv"352 if not f.exists():353 return354 d = pd.read_csv(f).set_index("name")355 names = [n for n in ["baseline", "beta_094", "beta_098", "gamma_3", "alpha_025", "kappa_low", "kappa_high", "chi_005", "sigma_low", "sigma_high",356 "grid_coarse", "grid_fine", "tol_tight", "sample_small", "sample_large", "seed_alt"] if n in d.index]357 body = ""358 for n in names:359 r = d.loc[n]360 body += (f"{_esc(n)} & {'GE' if r['equilibrium'] == 'GE' else 'PE'}{'' if r['converged'] else '$^{\\dagger}$'} & {_f(r['p'])} & {_f(r['ownership'], 3)} & {_f(r['ownership_under35'], 3)} & "361 f"{_f(r['threshold_age1_zmid'], 2)} & {_f(r['gini_nw'], 3)} & {_f(r['mean_gift'], 3)} & {_f(r['q_RO_age3'], 3)} & {_f(r['q_OO_age3'], 3)} & {_f(r['rank_rank_nw'], 3)} & {_f(r['time_sec'] / 60, 1)} \\\\\n")362 _write("tab09_robustness", body, "Robustness: preferences, transaction costs, grids, tolerance, sample size and seed", "tab:robustness",363 "Each row is a full re-solution of the stationary general equilibrium (prices, lottery). beta\\_094/098: annual discount factor 0.94/0.98; gamma\\_3: $\\gamma=3$; alpha\\_025: housing share 0.25; kappa\\_low/high: transaction costs (1\\%, 2\\%) / (5\\%, 8\\%); chi\\_005: ownership utility premium 5\\%; grid\\_coarse/fine, tol\\_tight, sample\\_small/large, seed\\_alt as in Table~\\ref{tab:convergence}. Source: results/data/robustness\\_summary.csv.",364 "lccccccccccc", "Variant & Eq. & $p^*$ & Own. & Own.\\ $<$35 & $\\hat a_1$ & Gini & gift & $q_{RO}$ & $q_{OO}$ & rank--rank & min \\\\", size="\\scriptsize", wide=True)365366367# ======================================================================================368# Extra macros used in the manuscript text (Phase 4): every value is read from results/data/.369# ======================================================================================370def _sign_changes(x):371 x = np.asarray(x, dtype=float)372 x = x[np.abs(x) > 1e-12]373 return int(np.sum(np.sign(x[1:]) != np.sign(x[:-1])))374375376def _welfare_group(name, grp, col="cev"):377 f = DATA / "inflation" / f"{name}_welfare.csv"378 if not f.exists():379 return np.nan380 w = pd.read_csv(f)381 x = w[(w.group == grp) & (w.age.astype(str) == "all")]382 return float(x[col].iloc[0]) if len(x) else np.nan383384385def _impact_group(name, grp, age, col):386 f = DATA / "inflation" / f"{name}_impact.csv"387 if not f.exists():388 return np.nan389 w = pd.read_csv(f)390 x = w[(w.group == grp) & (w.age.astype(str) == str(age))]391 return float(x[col].iloc[0]) if len(x) else np.nan392393394def extra_macros():395 s = _load("baseline/summary.json")396 if s is None:397 return398 m, mob, gaps = s["moments"], s["mobility"], s["gaps"]399 by_age = m["ownership_rate_by_age"]400 macro("OwnAgeOne", _f(by_age[0], 2)); macro("OwnPeak", _f(max(by_age), 2)); macro("OwnAgeTwelve", _f(by_age[-1], 2))401 macro("OwnThirtyFiveSixtyFour", _f(m["ownership_rate_35_64"], 2)); macro("OwnSixtyFivePlus", _f(m["ownership_rate_65plus"], 2))402 macro("ShareGiftPositivePct", _f(m["share_gift_positive"], 0, pct=True)); macro("ShareFirstBuyersGiftPct", _f(m["share_first_buyers_with_gift"], 0, pct=True))403 macro("MeanEstate", _f(m["mean_estate_received"], 2)); macro("ShareEstatePositivePct", _f(m["share_estate_positive"], 0, pct=True))404 macro("GiniAgeSeven", _f(m["gini_net_worth_age7"], 2)); macro("MedianGift", _f(m["median_gift_received"], 2))405 macro("ShareOwnersMortgage", _f(m["share_owners_with_mortgage"], 2)); macro("MeanLTVNew", _f(m["mean_ltv_new_loans"], 2))406 macro("HouseValueIncome", _f(m["median_house_value_to_annual_income"], 1))407 for k, lab in [("age2", "Two"), ("age4", "Four"), ("age7", "Seven"), ("age10", "Ten")]:408 macro(f"GapAge{lab}", _f(gaps[f"nw_gap_owner_renter_{k}"], 2))409 macro("qOOageSeven", _f(mob["q_OO"], 2)); macro("qROageSeven", _f(mob["q_RO"], 2)); macro("HalfLifeGen", _f(mob["half_life_generations"], 2))410 macro("ShareParentOwnerPct", _f(mob["share_parent_owner"], 0, pct=True))411 th = s["thresholds"]412 macro("ThresholdAgeOneZtwo", _f(th["1"]["1"], 2)); macro("ThresholdAgeOneZfour", _f(th["1"]["3"], 2))413 macro("ThresholdAgeThreeMidZ", _f(th["3"]["2"], 2)); macro("ThresholdAgeThreeHighZ", _f(th["3"]["4"], 2)); macro("ThresholdAgeTwoHighZ", _f(th["2"]["4"], 2))414 macro("DebtDepositRatio", _f(s["total_deposits"] / s["total_debt"], 1))415 macro("SupplyScaleOwner", _f(s["H_bar"], 2)); macro("SupplyScaleRental", _f(s["Hr_bar"], 2)); macro("RhoStar", _f(s["rho"], 3))416 macro("BellmanOwnerPnn", _f(s["bellman"]["owner_p99"], 1, pct=True)); macro("BellmanOwnerMax", _f(s["bellman"]["owner_max"], 1, pct=True))417 macro("BellmanRenterMax", _f(s["bellman"]["renter_max"], 2, pct=True)); macro("ClipSharePct", f"{100 * s['clip_share']:.3f}")418 macro("BaselineSeconds", _f(s["time_sec"], 0)); macro("LotteryRounds", str(s["supply_scales"].get("calibration_iterations", "--")))419 macro("HouseholdPeriods", f"{s['params']['n_dyn'] * 2 * 6:,}".replace(",", "{,}"))420 # gift quintiles421 f = DATA / "baseline" / "by_gift_quintile.csv"422 if f.exists():423 q = pd.read_csv(f)424 names = ["One", "Two", "Three", "Four", "Five"]425 for i in range(1, 6):426 r1 = q[(q.age == 1) & (q.gift_quintile == i)].iloc[0]; r7 = q[(q.age == 7) & (q.gift_quintile == i)].iloc[0]427 macro(f"OwnAgeOneQ{names[i-1]}", _f(r1["ownership"], 2)); macro(f"OwnAgeSevenQ{names[i-1]}", _f(r7["ownership"], 2))428 macro(f"MedNWAgeSevenQ{names[i-1]}", _f(r7["nw_median"], 2))429 g1 = q[(q.age == 1) & (q.gift_quintile == 5)].iloc[0]["ownership"] - q[(q.age == 1) & (q.gift_quintile == 2)].iloc[0]["ownership"]430 g7 = q[(q.age == 7) & (q.gift_quintile == 5)].iloc[0]["ownership"] - q[(q.age == 7) & (q.gift_quintile == 2)].iloc[0]["ownership"]431 macro("GapQtwoQfiveAgeOnePP", _f(100 * g1, 0)); macro("GapQtwoQfiveAgeSevenPP", _f(100 * g7, 0))432 # equal-income paths433 f = DATA / "baseline" / "equal_income_paths.csv"434 if f.exists():435 e = pd.read_csv(f)436 def first_buy(path):437 d = e[e.path == path]; o = d[d.owner == 1]438 return int(o.age.min()) if len(o) else 0439 macro("EqIncFirstBuyAgeLowGift", str(first_buy("q10"))); macro("EqIncFirstBuyAgeMinus", str(first_buy("threshold_minus"))); macro("EqIncFirstBuyAgePlus", str(first_buy("threshold_plus")))440 macro("EqIncAoneMinus", _f(e[e.path == "threshold_minus"].a1.iloc[0], 3)); macro("EqIncAonePlus", _f(e[e.path == "threshold_plus"].a1.iloc[0], 3))441 macro("EqIncAoneMedian", _f(e[e.path == "q50"].a1.iloc[0], 2))442 for age, lab in [(4, "Four"), (7, "Seven")]:443 nm = float(e[(e.path == "threshold_minus") & (e.age == age)].nw.iloc[0]); npl = float(e[(e.path == "threshold_plus") & (e.age == age)].nw.iloc[0])444 macro(f"EqIncNWMinusAge{lab}", _f(nm, 2)); macro(f"EqIncNWPlusAge{lab}", _f(npl, 2))445 macro(f"EqIncGapPctAge{lab}", _f(100 * (npl / nm - 1), 0))446 macro("EqIncEndowDiffPct", _f(100 * (e[e.path == "threshold_plus"].a1.iloc[0] / e[e.path == "threshold_minus"].a1.iloc[0] - 1), 0))447 # price-drop accounting448 f = DATA / "baseline" / "price_drop_accounting.csv"449 if f.exists():450 d = pd.read_csv(f)451 a = d[d.age == "all"]; n = d[d.age == "new_loans_at_origination"]452 macro("MeanLTVOwners", _f(float(a.mean_ltv.iloc[0]), 2))453 for drop, lab in [(0.1, "Ten"), (0.2, "Twenty"), (0.3, "Thirty")]:454 ra = a[np.isclose(a["drop"], drop)].iloc[0]; rn = n[np.isclose(n["drop"], drop)].iloc[0]455 macro(f"UnderwaterAll{lab}", _f(ra.underwater_share, 0, pct=True)); macro(f"UnderwaterNew{lab}", _f(rn.underwater_share, 0, pct=True))456 macro(f"LossNWAll{lab}", _f(ra.mean_loss_over_nw, 0, pct=True)); macro(f"LossNWNew{lab}", _f(rn.mean_loss_over_nw, 0, pct=True))457 # stationary scenarios458 def sc(path):459 return _load(path)460 def put(prefix, d):461 if d is None:462 return463 mm, mb = d["moments"], d["mobility"]464 macro(f"{prefix}P", _f(d["p"], 3)); macro(f"{prefix}Own", _f(mm["ownership_rate_all"], 3)); macro(f"{prefix}OwnUthirtyfive", _f(mm["ownership_rate_under35"], 3))465 macro(f"{prefix}Thr", _f(d["thresholds"]["1"]["2"], 2)); macro(f"{prefix}ThrLowZ", _f(d["thresholds"]["1"]["0"], 2)); macro(f"{prefix}ThrHighZ", _f(d["thresholds"]["1"]["4"], 2))466 macro(f"{prefix}qRO", _f(mb["q_RO_age3"], 3)); macro(f"{prefix}qOO", _f(mb["q_OO_age3"], 3)); macro(f"{prefix}LamThree", _f(mb["q_OO_age3"] - mb["q_RO_age3"], 2))467 macro(f"{prefix}LamSeven", _f(mb["lambda_persistence"], 3)); macro(f"{prefix}RR", _f(mb["rank_rank_slope_nw"], 2)); macro(f"{prefix}Gini", _f(d["gini_nw"], 2))468 macro(f"{prefix}Gift", _f(mm["mean_gift_received"], 2)); macro(f"{prefix}Estate", _f(mm["mean_estate_received"], 2)); macro(f"{prefix}GapFour", _f(d["gaps"]["nw_gap_owner_renter_age4"], 2))469 macro(f"{prefix}Rho", _f(d["rho"], 3)); macro(f"{prefix}RhoDevPct", _f(100 * (d["rho"] / s["rho"] - 1), 1)); macro(f"{prefix}PDevPct", _f(100 * (d["p"] - 1), 1))470 if "rebate" in d:471 macro(f"{prefix}Rebate", _f(d["rebate"], 3))472 for prefix, path in [("ThetaFive", "theta_sweep/theta_005.json"), ("ThetaTen", "theta_sweep/theta_010.json"), ("ThetaThirty", "theta_sweep/theta_030.json"),473 ("ThetaForty", "theta_sweep/theta_040.json"), ("ThetaFivePE", "theta_sweep/theta_005_pe.json"), ("ThetaFortyPE", "theta_sweep/theta_040_pe.json"),474 ("PsiThirty", "theta_sweep/psi_030.json"), ("PsiFifty", "theta_sweep/psi_050.json"),475 ("BeqOff", "bequests/bequests_off.json"), ("BeqOffPE", "bequests/bequests_off_pe.json"), ("BeqHalf", "bequests/bequests_half.json"),476 ("TaxTwenty", "bequests/transfer_tax_020.json"), ("TaxFifty", "bequests/transfer_tax_050.json"),477 ("EpsLowThetaFive", "supply/eps_005_theta005.json"), ("EpsHighThetaFive", "supply/eps_200_theta005.json"),478 ("EpsLowThetaForty", "supply/eps_005_theta040.json"), ("EpsHighThetaForty", "supply/eps_200_theta040.json"),479 ("Rone", "sensitivity/r_001.json"), ("Rthree", "sensitivity/r_003.json"), ("PiZero", "sensitivity/pi_000.json"), ("PiFour", "sensitivity/pi_004.json"),480 ("PiFourPE", "sensitivity/pi_004_pe.json"), ("PhiLow", "sensitivity/phi_002.json"), ("PhiHigh", "sensitivity/phi_008.json"),481 ("SigLow", "sensitivity/sigma_low.json"), ("SigHigh", "sensitivity/sigma_high.json"), ("RhoG", "sensitivity/rho_g_040.json"),482 ("DepPrem", "sensitivity/dep_premium_002.json"), ("BetaLow", "robustness/beta_094.json"), ("BetaHigh", "robustness/beta_098.json"),483 ("GammaThree", "robustness/gamma_3.json"), ("AlphaHigh", "robustness/alpha_025.json"), ("KappaLow", "robustness/kappa_low.json"),484 ("KappaHigh", "robustness/kappa_high.json"), ("ChiFive", "robustness/chi_005.json"), ("GridCoarse", "robustness/grid_coarse.json"),485 ("GridFine", "robustness/grid_fine.json")]:486 put(prefix, sc(path))487 # PE absorption shares488 b, t5, t5pe, t40, t40pe = s, sc("theta_sweep/theta_005.json"), sc("theta_sweep/theta_005_pe.json"), sc("theta_sweep/theta_040.json"), sc("theta_sweep/theta_040_pe.json")489 if all(x is not None for x in (t5, t5pe, t40, t40pe)):490 o = b["moments"]["ownership_rate_all"]491 macro("AbsorbThetaFivePct", _f(1 - (t5["moments"]["ownership_rate_all"] - o) / (t5pe["moments"]["ownership_rate_all"] - o), 0, pct=True))492 macro("AbsorbThetaFortyPct", _f(1 - (t40["moments"]["ownership_rate_all"] - o) / (t40pe["moments"]["ownership_rate_all"] - o), 0, pct=True))493 bo, bope = sc("bequests/bequests_off.json"), sc("bequests/bequests_off_pe.json")494 if bo is not None and bope is not None:495 o = b["moments"]["ownership_rate_all"]496 macro("BeqOffPEDropPP", _f(100 * (o - bope["moments"]["ownership_rate_all"]), 0)); macro("BeqOffGEDropPP", _f(100 * (o - bo["moments"]["ownership_rate_all"]), 0))497 p4, p4pe = sc("sensitivity/pi_004.json"), sc("sensitivity/pi_004_pe.json")498 if p4 is not None and p4pe is not None:499 u = b["moments"]["ownership_rate_under35"]500 macro("PiFourPEDropUthirtyfivePP", _f(100 * (u - p4pe["moments"]["ownership_rate_under35"]), 1))501 macro("PiFourGEOffsetPct", _f(1 - (u - p4["moments"]["ownership_rate_under35"]) / (u - p4pe["moments"]["ownership_rate_under35"]), 0, pct=True))502 # multiplicity scans503 mult = _load("multiplicity/multiplicity_summary.json")504 if mult is not None:505 for k, lab in [("baseline", "Base"), ("eps_005", "EpsLow"), ("eps_005_theta005", "EpsLowThetaFive")]:506 if k in mult:507 ex, ow, pg = np.array(mult[k]["excess_owner"]), np.array(mult[k]["ownership"]), np.array(mult[k]["p_grid"])508 macro(f"Mult{lab}ExcessLow", _f(ex[0], 2)); macro(f"Mult{lab}ExcessHigh", _f(ex[-1], 2))509 macro(f"Mult{lab}OwnLow", _f(ow[0], 2)); macro(f"Mult{lab}OwnHigh", _f(ow[-1], 2))510 i = int(np.argmin(np.abs(ex))); macro(f"Mult{lab}Root", _f(pg[i], 3))511 j = np.where(np.isclose(pg, 1.0))[0][0]512 slope = (ex[j + 1] - ex[j - 1]) / (np.log(pg[j + 1]) - np.log(pg[j - 1])); macro(f"Mult{lab}Slope", _f(slope, 1))513 macro("MultPgridLow", _f(min(mult["baseline"]["p_grid"]), 2)); macro("MultPgridHigh", _f(max(mult["baseline"]["p_grid"]), 2))514 # grid sensitivity515 f = DATA / "grid_sensitivity.csv"516 if f.exists():517 g = pd.read_csv(f).set_index("name")518 for n, lab in [("grid_coarse", "Coarse"), ("grid_fine", "Fine"), ("sample_large", "SampleLarge"), ("seed_alt", "Seed"), ("sample_small", "SampleSmall")]:519 if n in g.index:520 macro(f"Dev{lab}OwnPP", _f(100 * g.loc[n, "ownership_dev"], 1)); macro(f"Dev{lab}PPct", _f(100 * g.loc[n, "p_dev"], 1))521 macro(f"Dev{lab}Thr", _minus(f"{g.loc[n, 'threshold_age1_zmid_dev']:+.2f}")); macro(f"Dev{lab}LamSeven", _minus(f"{g.loc[n, 'lambda_age7_dev']:+.3f}"))522 macro(f"Dev{lab}RR", _minus(f"{g.loc[n, 'rank_rank_nw_dev']:+.3f}")); macro(f"Dev{lab}Gini", _minus(f"{g.loc[n, 'gini_nw_dev']:+.3f}"))523 # transitions524 diag = _load("diagnostics.json")525 if diag is not None:526 runs = diag["runs"]527 ss = [r for r in runs if r.get("equilibrium") == "GE" and r.get("group") != "inflation"]528 tr = [r for r in runs if r.get("group") == "inflation"]529 macro("NScenariosSS", str(len(ss))); macro("NScenariosSSConverged", str(sum(1 for r in ss if r.get("converged"))))530 macro("NTransitions", str(len(tr))); macro("NTransitionsConverged", str(sum(1 for r in tr if r.get("converged"))))531 macro("SimulateMinutesOne", _f(diag["total_sec"] / 60, 1))532 for n, lab in [("frm_level", "FrmLevel"), ("term5_level", "TermLevel"), ("arm_level", "ArmLevel"), ("frm_persistent", "FrmPers"), ("term5_persistent", "TermPers"),533 ("arm_persistent", "ArmPers"), ("frm_level_rigid_rents", "RigidRents"), ("frm_level_unindexed_wages", "Unindexed"), ("frm_persistent_taylor", "FrmTaylor"),534 ("term5_persistent_taylor", "TermTaylor"), ("frm_level_fixed_supply", "FixedSupply"), ("frm_level_noseg", "NoSeg"), ("frm_level_static", "Static"),535 ("rate_shock_theta020", "RateTwenty"), ("rate_shock_theta040", "RateForty"), ("rate_shock_theta005", "RateFive")]:536 d = _load(f"inflation/{n}.json")537 if d is None:538 continue539 macro(f"{lab}MaxRes", f"{d['max_residual']:.1e}".replace("e-0", r"\times10^{-").replace("e-", r"\times10^{-") + "}")540 macro(f"{lab}ResT", f"{d['residual_at_T']:.0e}".replace("e-0", r"\times10^{-").replace("e-", r"\times10^{-") + "}")541 macro(f"{lab}Steps", str(d["iterations"])); macro(f"{lab}Secs", _f(d["time_sec"], 0))542 macro(f"{lab}Pzero", _f(d["p_impact_pct"], 2)); macro(f"{lab}RhoZero", _f(d["rho_impact_pct"], 2))543 macro(f"{lab}DOwnGE", _f(d["d_ownership_impact_pp"], 2)); macro(f"{lab}DOwnPE", _f(d["d_ownership_impact_pe_pp"], 2))544 if abs(d["d_ownership_impact_pe_pp"]) > 1e-9:545 macro(f"{lab}AbsorbPct", _f(1 - d["d_ownership_impact_pp"] / d["d_ownership_impact_pe_pp"], 0, pct=True))546 macro(f"{lab}Erosion", _f(d["aggregate_debt_erosion"], 2)); macro(f"{lab}DepLoss", _f(d["aggregate_deposit_loss"], 2)); macro(f"{lab}Lender", _f(d["lender_revaluation"], 2))547 mp, ms = d["mobility_path"], d["mobility_ss"]548 macro(f"{lab}qROsevenSS", _f(ms["q_RO"], 3)); macro(f"{lab}qROsevenZero", _f(mp[0]["q_RO"], 3)); macro(f"{lab}LamSevenSS", _f(ms["lambda_persistence"], 3)); macro(f"{lab}LamSevenZero", _f(mp[0]["lambda_persistence"], 3))549 macro(f"{lab}qROthreeSS", _f(ms["q_RO_age3"], 3)); macro(f"{lab}qROthreeZero", _f(mp[0]["q_RO_age3"], 3))550 if len(mp) > 1:551 macro(f"{lab}qROthreeOne", _f(mp[1]["q_RO_age3"], 3))552 macro(f"{lab}ThrSS", _f(d["threshold_ss"]["1"]["2"], 2)); macro(f"{lab}ThrZero", _f(d["threshold_impact"]["1"]["2"], 2))553 macro(f"{lab}ThrChangePct", _f(100 * (d["threshold_impact"]["1"]["2"] / d["threshold_ss"]["1"]["2"] - 1), 0))554 for grp, gl in [("all", "All"), ("owners_with_mortgage", "OM"), ("owners_no_mortgage", "ONM"), ("renters", "R"), ("owners", "O")]:555 macro(f"{lab}CEV{gl}", _f(_welfare_group(n, grp), 2, pct=True)); macro(f"{lab}Better{gl}Pct", _f(_welfare_group(n, grp, "share_better_off"), 0, pct=True))556 macro(f"{lab}CEVAllPE", _f(d["pe"]["welfare_all"], 2, pct=True))557 pth = DATA / "inflation" / f"{n}_path.csv"558 if pth.exists():559 pa = pd.read_csv(pth)560 macro(f"{lab}PriceSignChanges", str(_sign_changes(pa.p_pct.values[:-1]))); macro(f"{lab}PriceMaxPos", _f(float(pa.p_pct.max()), 1))561 yrs = pa.years.values; back = yrs[np.where(np.abs(pa.p_pct.values) < 0.1)[0][0]] if (np.abs(pa.p_pct.values) < 0.1).any() else np.nan562 macro(f"{lab}PriceBackYears", _f(float(back), 0))563 d0 = 100 * (pa.debt_total.iloc[0] / pa.debt_total.iloc[-1] - 1); macro(f"{lab}DebtZeroPct", _f(d0, 1))564 macro(f"{lab}NominalRateZero", _f(float(pa.i_new.iloc[0]), 3))565 for grp, age, col, gl in [("all", "all", "d_consumption_pct", "ConsAll"), ("renters", "all", "d_consumption_pct", "ConsR"), ("owners", "all", "d_consumption_pct", "ConsO"),566 ("renters", "all", "net_nominal_gain", "NetR"), ("owners", "all", "net_nominal_gain", "NetO"), ("all", "all", "wage_loss", "WageAll"), ("owners", "all", "wage_loss", "WageO"),567 ("renters", "all", "rent_rigidity_gain", "RentGainR"), ("all", "all", "d_nw", "NWAll"), ("owners", "all", "d_nw", "NWO"), ("renters", "all", "d_nw", "NWR")]:568 v = _impact_group(n, grp, age, col)569 if np.isfinite(v):570 macro(f"{lab}{gl}", _f(v, 2) if "Cons" not in gl else _f(v, 1))571 # owners by age bands (impact csv, group=owners)572 f = DATA / "inflation" / f"{n}_impact.csv"573 if f.exists():574 w = pd.read_csv(f); w = w[w.age.astype(str) != "all"]; w["age"] = w.age.astype(int)575 o = w[w.group == "owners"]576 young = o[o.age.isin([2, 3, 4])]; old = o[o.age.isin([8, 9, 10])]577 if len(young) and len(old):578 macro(f"{lab}OwnerYoungGainMax", _f(float(young.net_nominal_gain.max()), 2)); macro(f"{lab}OwnerYoungConsMin", _f(float(young.d_consumption_pct.min()), 1)); macro(f"{lab}OwnerYoungConsMax", _f(float(young.d_consumption_pct.max()), 1))579 macro(f"{lab}OwnerOldLossMin", _f(float(-old.net_nominal_gain.max()), 2)); macro(f"{lab}OwnerOldLossMax", _f(float(-old.net_nominal_gain.min()), 2))580 macro(f"{lab}OwnerOldConsMin", _f(float(old.d_consumption_pct.max()), 1)); macro(f"{lab}OwnerOldConsMax", _f(float(old.d_consumption_pct.min()), 1))581 # welfare quintiles (stationary)582 f = DATA / "welfare" / "welfare_all.csv"583 if f.exists():584 w = pd.read_csv(f); w["gift_quintile"] = w.gift_quintile.astype(str)585 for sname, lab in [("theta_005", "ThetaFive"), ("theta_040", "ThetaForty"), ("transfer_tax_020", "TaxTwenty"), ("transfer_tax_050", "TaxFifty"), ("eps_005", "EpsLow"), ("eps_200", "EpsHigh"),586 ("r_001", "Rone"), ("r_003", "Rthree"), ("pi_000", "PiZero"), ("pi_004", "PiFour"), ("phi_002", "PhiLow"), ("phi_008", "PhiHigh"), ("sigma_low", "SigLow"), ("sigma_high", "SigHigh"),587 ("rho_g_040", "RhoG"), ("dep_premium_002", "DepPrem"), ("kappa_low", "KappaLow"), ("kappa_high", "KappaHigh"), ("bequests_off", "BeqOff"), ("psi_030", "PsiThirty"), ("psi_050", "PsiFifty"),588 ("eps_005_theta005", "EpsLowThetaFive"), ("eps_200_theta005", "EpsHighThetaFive"), ("tol_tight", "TolTight"), ("sample_large", "SampleLarge"), ("seed_alt", "Seed")]:589 d = w[w.scenario == sname]590 if not len(d):591 continue592 def wavg(dd):593 ok = np.isfinite(dd.cev)594 return float(np.sum(dd.cev[ok] * dd.weight[ok]) / np.sum(dd.weight[ok])) if ok.any() else np.nan595 dq = d[d.gift_quintile != "marginal"]596 macro(f"CEV{lab}All", _f(wavg(dq), 2, pct=True)); macro(f"CEV{lab}Qone", _f(wavg(d[d.gift_quintile == "1"]), 2, pct=True)); macro(f"CEV{lab}Qtwo", _f(wavg(d[d.gift_quintile == "2"]), 2, pct=True))597 macro(f"CEV{lab}Qfive", _f(wavg(d[d.gift_quintile == "5"]), 2, pct=True)); macro(f"CEV{lab}Marg", _f(wavg(d[d.gift_quintile == "marginal"]), 2, pct=True))598 macro(f"CEV{lab}BottomTwo", _f(wavg(d[d.gift_quintile.isin(["1", "2"])]), 2, pct=True))599 if {"V_base", "V_base_simulated"}.issubset(w.columns):600 rel = np.abs(w.V_base_simulated / w.V_base - 1.0)601 macro("WelfareCheckMaxPct", _f(float(np.nanmax(rel)), 1, pct=True))602 num = w[w.scenario.isin(["tol_tight", "sample_large", "sample_small", "seed_alt"])]603 if len(num):604 macro("CEVNumericalMaxPct", _f(float(np.nanmax(np.abs(num.cev))), 2, pct=True))605 # robustness summary: Bellman residual range606 f = DATA / "robustness_summary.csv"607 if f.exists():608 r = pd.read_csv(f)609 macro("BellmanOwnerMinAll", f"{r.bellman_owner_mean.min():.1e}".replace("e-0", r"\times10^{-") + "}"); macro("BellmanOwnerMaxAll", f"{r.bellman_owner_mean.max():.1e}".replace("e-0", r"\times10^{-") + "}")610 macro("NRobustnessRuns", str(len(r)))611612613def write_macros():614 s = _load("baseline/summary.json")615 if s is not None:616 macro("Pstar", _f(s["p"], 3)); macro("RentPriceAnnual", _f(s["moments"]["rent_price_ratio_annual"], 3))617 macro("NDynasties", f"{s['params']['n_dyn']:,}".replace(",", "{,}"))618 mult = _load("multiplicity/multiplicity_summary.json")619 if mult is not None:620 for k, v in mult.items():621 macro(f"SignChanges{re.sub(r'[^A-Za-z]', '', k)}", str(v["n_sign_changes"]))622 # absolute-value twins of every negative macro, for sentences of the form "falls by \\XAbs\\%"623 for k, v in list(MACROS.items()):624 if v.startswith("\\ensuremath{-}") and k + "Abs" not in MACROS:625 MACROS[k + "Abs"] = v[len("\\ensuremath{-}"):]626 with open(TAB / "numbers.tex", "w") as fh:627 fh.write("% generated by src/tables.py — one macro per key number; every value traces to results/data/\n")628 for k, v in sorted(MACROS.items()):629 fh.write(f"\\newcommand{{\\{k}}}{{{v}}}\n")630 print(f"numbers.tex: {len(MACROS)} macros")631632633def main():634 for fn in (tab01_notation, tab02_parameters, tab03_moments, tab04_convergence, tab05_scenarios, tab06_inflation, tab07_mobility,635 tab08_welfare, tab09_robustness, extra_macros):636 try:637 fn()638 except Exception as exc: # noqa: BLE001639 print(f"TABLE FAILED {fn.__name__}: {exc!r}")640 raise641 write_macros()642643644if __name__ == "__main__":645 main()646