SPB Git forge

spb/wp18_uqo

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