"""WP18 — LaTeX tables (booktabs, threeparttable notes) and the numbers.tex macro file. Every number comes from a file under results/data/ (named in each table's note). The tables are fragments to be \\input{} from paper/main.tex. Usage: python -m src.tables """ from __future__ import annotations import json import re from pathlib import Path import numpy as np import pandas as pd from .model import RESULTS_DIR DATA = RESULTS_DIR / "data" TAB = RESULTS_DIR / "tables" TAB.mkdir(parents=True, exist_ok=True) MACROS: dict[str, str] = {} # -------------------------------------------------------------------------------------- # helpers # -------------------------------------------------------------------------------------- def _json(path): with open(path) as fh: return json.load(fh) def _minus(txt: str) -> str: """Typeset a leading minus sign so that it is correct in text and in math mode; no negative zero.""" if txt.startswith("-"): if float(txt) == 0.0: return txt[1:] return "\\ensuremath{-}" + txt[1:] return txt def _f(x, nd=3, pct=False, pp=False): if x is None or (isinstance(x, float) and not np.isfinite(x)): return "--" if pct: return _minus(f"{100 * x:.{nd}f}") return _minus(f"{x:.{nd}f}") def _esc(s: str) -> str: return (s.replace("_", "\\_").replace("%", "\\%").replace("&", "\\&").replace("^", "\\textasciicircum{}") .replace("#", "\\#")) def _write(name: str, body: str, caption: str, label: str, notes: str, colspec: str, header: str, size: str = "\\small", wide: bool = False): tab = f"\\begin{{tabular}}{{{colspec}}}\n\\toprule\n{header}\n\\midrule\n{body}\\bottomrule\n\\end{{tabular}}" if wide: # tables wider than the text block are scaled to \textwidth (HARMONISATION §5); threeparttable # cannot wrap a \resizebox, so the note is set in a minipage of the same width tex = ("\\begin{table}[htbp]\n\\centering\n" + size + "\n" f"\\caption{{{caption}}}\n\\label{{{label}}}\n\\resizebox{{\\textwidth}}{{!}}{{%\n" + tab + "}\n" f"\\begin{{minipage}}{{\\textwidth}}\\footnotesize\\vspace{{2pt}}\n{notes}\n\\end{{minipage}}\n\\end{{table}}\n") else: tex = ("\\begin{table}[htbp]\n\\centering\n" + size + "\n\\begin{threeparttable}\n" f"\\caption{{{caption}}}\n\\label{{{label}}}\n" + tab + "\n" f"\\begin{{tablenotes}}[flushleft]\n\\footnotesize\n\\item {notes}\n\\end{{tablenotes}}\n" "\\end{threeparttable}\n\\end{table}\n") with open(TAB / f"{name}.tex", "w") as fh: fh.write(tex) print("table:", name) def macro(name: str, value: str): """Register a \\newcommand; names must be letters only.""" key = re.sub(r"[^A-Za-z]", "", name) MACROS[key] = value def _load(path): p = DATA / path return _json(p) if p.exists() else None # ====================================================================================== def tab01_notation(): rows = [ ("$j$", "age index, $j = 1,\\dots,J=12$ (5-year periods, 25--84)", "--"), ("$z$", "idiosyncratic productivity (Rouwenhorst, $n_z = 5$), frozen in retirement", "--"), ("$h \\in \\mathcal H$", "owner-occupied house size (four sizes, $h_1 = \\underline h$)", "housing units"), ("$s$", "rented housing services ($s \\ge \\underline s$)", "housing units"), ("$a$", "liquid real asset (nominal deposit), $a \\ge 0$", "goods"), ("$b^m$", "real mortgage balance at the start of the period ($B^{nom}/P$)", "goods"), ("$n_m$", "remaining number of mortgage payments ($N = 5$ at origination)", "--"), ("$p$, $p^r$, $\\rho$", "real house price, rental-unit price, rent per period", "goods / unit"), ("$P$, $\\pi$, $\\pi^u$", "CPI level, inflation, unexpected inflation", "--"), ("$R$, $i$, $i_q$", "gross real rate, nominal rate ($1+i = R(1+\\bar\\pi)$), qualifying rate", "--"), ("$\\theta$, $\\psi$", "minimum down payment, payment-to-income cap", "--"), ("$\\kappa_b$, $\\kappa_s$, $\\kappa_f$, $m$", "buying, selling, refinancing and maintenance costs", "share of value"), ("$b^g$, $b^e$", "inter-vivos gift (parent's age $j_g = 7$), estate (parent's age $J$)", "goods"), ("$\\nu$, $\\underline x$", "warm-glow weight and luxury shift", "--"), ("$\\varepsilon$, $\\varepsilon_r$, $\\delta_H$", "supply elasticities, depreciation of the stocks", "--"), ("$\\sigma$, $\\eta_\\rho$, $\\eta_w$", "segmentation, rent and wage indexation to a surprise", "--"), ("$\\hat a_j(z)$", "ownership-entry threshold: smallest liquid wealth at which the age-$j$ renter buys", "goods"), ("$q_{RO}$, $q_{OO}$, $\\lambda$", "child ownership given parent renter / owner; persistence $\\lambda = q_{OO} - q_{RO}$", "--"), ("$\\lambda^{CE}$", "consumption-equivalent variation (Section~\\ref{sec:welfare})", "--"), ] body = "".join(f"{a} & {b} & {c} \\\\\n" for a, b, c in rows) _write("tab01_notation", body, "Notation of the quantitative model", "tab:notation", "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.", "lp{9.3cm}l", "Symbol & Meaning & Units \\\\") def tab02_parameters(): f = DATA / "parameters.csv" if not f.exists(): return d = pd.read_csv(f) body = "" for _, r in d.iterrows(): v = r["value"] try: vv = float(v) vs = f"{int(round(vv)):,}".replace(",", "{,}") if (abs(vv) >= 1000 and float(vv).is_integer()) else f"{vv:.4g}" except (TypeError, ValueError): vs = _esc(str(v)) body += f"{_esc(str(r['parameter']))} & {vs} & {_esc(str(r['unit']))} & {_esc(str(r['source']))} \\\\\n" _write("tab02_parameters", body, "Parameters of the baseline (period = 5 years)", "tab:parameters", "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.", "lp{2.2cm}p{2.3cm}p{6cm}", "Parameter & Value & Unit & Source / status \\\\", size="\\footnotesize") def tab03_moments(): s = _load("baseline/summary.json") if s is None: return m = s["moments"]; mob = s["mobility"] t = pd.read_csv(DATA / "calibration_targets.csv") if (DATA / "calibration_targets.csv").exists() else None rows = [ ("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)"), ("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)"), ("Share of households with a mortgage", _f(m["share_with_mortgage"]), "0.39 (SFS 2023)"), ("Share of owners with a mortgage", _f(m["share_owners_with_mortgage"]), "--"), ("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$"), ("Mean gift received at entry (5-year income units)", _f(m["mean_gift_received"]), "about \\$80{,}000 (CIBC, 2021, via Mirdamadi--Khalid 2023)"), ("Share of entrants receiving a positive gift", _f(m["share_gift_positive"]), "28\\% of first-time buyers received gifts (same source)"), ("Mean estate received at age 55--59", _f(m["mean_estate_received"]), "--"), ("Median house value / annual income (owners)", _f(m["median_house_value_to_annual_income"], 1), "--"), ("Mean LTV of new loans", _f(m["mean_ltv_new_loans"]), "--"), ("Annual rent--price ratio", _f(m["rent_price_ratio_annual"]), "illustrative (landlord wedge $\\phi$)"), ("Gini of net worth (all ages)", _f(s["gini_nw"]), "--"), ("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)"), ("Rank--rank slope of net worth (child at 55--59 vs parent at 55--59)", _f(mob["rank_rank_slope_nw"], 2), "--"), ("Aggregate mortgage debt / deposits (per age mass)", f"{_f(s['total_debt'], 2)} / {_f(s['total_deposits'], 2)}", "--"), ] body = "".join(f"{a} & {b} & {c} \\\\\n" for a, b, c in rows) _write("tab03_baseline_moments", body, "Baseline stationary equilibrium: model moments and verified comparison points", "tab:moments", "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.", "p{6.3cm}cp{6cm}", "Moment & Model & Comparison point (source) \\\\", size="\\footnotesize") macro("OwnershipAll", _f(m["ownership_rate_all"], 2)); macro("OwnershipUnderThirtyFive", _f(m["ownership_rate_under35"], 2)) macro("ShareMortgage", _f(m["share_with_mortgage"], 2)); macro("MeanGift", _f(m["mean_gift_received"], 2)) macro("MeanGiftAnnual", _f(m["mean_gift_in_annual_incomes"], 1)); macro("GiniNW", _f(s["gini_nw"], 2)) macro("qROageThree", _f(mob["q_RO_age3"], 2)); macro("qOOageThree", _f(mob["q_OO_age3"], 2)) macro("RankRankNW", _f(mob["rank_rank_slope_nw"], 2)); macro("NWRatioYoung", _f(m["mean_nw_ratio_owner_renter_under35"], 1)) macro("LambdaAgeSeven", _f(mob["lambda_persistence"], 3)); macro("LambdaAgeThree", _f(mob["q_OO_age3"] - mob["q_RO_age3"], 2)) macro("TotalDebt", _f(s["total_debt"], 2)); macro("TotalDeposits", _f(s["total_deposits"], 2)) macro("ThresholdAgeOneLowZ", _f(s["thresholds"]["1"]["0"], 2)); macro("ThresholdAgeOneMidZ", _f(s["thresholds"]["1"]["2"], 2)) macro("ThresholdAgeOneHighZ", _f(s["thresholds"]["1"]["4"], 2)) def tab04_convergence(): s = _load("baseline/summary.json") if s is None: return g = pd.read_csv(DATA / "grid_sensitivity.csv").set_index("name") if (DATA / "grid_sensitivity.csv").exists() else None diag = _load("diagnostics.json") rows = [ ("Owner-occupied market, relative excess demand", _f(s["excess_owner"], 6)), ("Rental market, relative excess demand", _f(s["excess_rental"], 6)), ("Tolerance (relative excess demand)", "2.5e-4"), ("Estate-lottery fixed point: max.\\ change (last round)", _f(float(np.max(np.abs(np.array(s["residuals"][2:7])))), 6)), ("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)}"), ("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)}"), ("Share of households at the forced-sale rule", _f(s["forced_share"], 5)), ("Share of households with a clipped (infeasible interpolated) policy", _f(s["clip_share"], 5)), ("Outer (lottery) rounds / household-block evaluations", f"{s['iterations']} / {s['supply_scales'].get('calibration_iterations', '--')} (calibration)"), ("Wall-clock time of the baseline (s)", _f(s["time_sec"], 0)), ] if diag is not None: rows.append(("Total wall-clock time of \\texttt{make simulate} (min)", _f(diag["total_sec"] / 60, 1))) n_tr = [r for r in diag["runs"] if r.get("group") == "inflation"] if n_tr: rows.append(("Transitions solved (GE converged / total)", f"{sum(1 for r in n_tr if r.get('converged'))} / {len(n_tr)}")) body = "".join(f"{a} & {b} \\\\\n" for a, b in rows) if g is not None: body += "\\midrule\n\\multicolumn{2}{l}{\\emph{Numerical variants (deviation from baseline, in percent of the baseline value)}} \\\\\n" for n in [x for x in ["grid_coarse", "grid_fine", "tol_tight", "sample_small", "sample_large", "seed_alt"] if x in g.index]: labels = {"ownership": "own.", "p": "$p^*$", "gini_nw": "Gini", "mean_gift": "gift", "rank_rank_nw": "rank--rank"} 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])) body += f"{_esc(n)} & {dev} \\\\\n" _write("tab04_convergence", body, "Convergence and numerical diagnostics", "tab:convergence", "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.", "p{8.5cm}p{6cm}", "Diagnostic & Value \\\\", size="\\footnotesize") macro("BellmanOwnerMean", _f(s["bellman"]["owner_mean"], 5)); macro("BellmanRenterMean", _f(s["bellman"]["renter_mean"], 5)) macro("ExcessOwner", _f(abs(s["excess_owner"]), 6)); macro("ExcessRental", _f(abs(s["excess_rental"]), 6)) if diag is not None: macro("SimulateMinutes", _f(diag["total_sec"] / 60, 0)) def _scenario_row(name, path, label=None): d = _load(path) if d is None: return None m, mob = d["moments"], d["mobility"] 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"], own_u35=m["ownership_rate_under35"], thr=d["thresholds"]["1"]["2"], qro=mob["q_RO_age3"], qoo=mob["q_OO_age3"], rr=mob["rank_rank_slope_nw"], gini=d["gini_nw"], gift=m["mean_gift_received"], gap4=d["gaps"]["nw_gap_owner_renter_age4"]) def tab05_scenarios(): spec = [ ("baseline", "baseline/summary.json", "Baseline ($\\theta=0.20$, $\\varepsilon=1$)"), ("theta_005", "theta_sweep/theta_005.json", "$\\theta = 0.05$"), ("theta_010", "theta_sweep/theta_010.json", "$\\theta = 0.10$"), ("theta_030", "theta_sweep/theta_030.json", "$\\theta = 0.30$"), ("theta_040", "theta_sweep/theta_040.json", "$\\theta = 0.40$"), ("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"), ("psi_030", "theta_sweep/psi_030.json", "DSTI cap $\\psi = 0.30$"), ("psi_050", "theta_sweep/psi_050.json", "DSTI cap $\\psi = 0.50$"), ("eps_005", "supply/eps_005.json", "$\\varepsilon = 0.05$ (near-fixed supply)"), ("eps_025", "supply/eps_025.json", "$\\varepsilon = 0.25$"), ("eps_050", "supply/eps_050.json", "$\\varepsilon = 0.5$"), ("eps_200", "supply/eps_200.json", "$\\varepsilon = 2$"), ("noseg", "supply/noseg.json", "No segmentation ($\\sigma = 0$)"), ("halfseg", "supply/halfseg.json", "Half segmentation ($\\sigma = 0.5$)"), ("bequests_off", "bequests/bequests_off.json", "Bequests off ($\\nu = 0$)"), ("bequests_half", "bequests/bequests_half.json", "Bequests halved ($\\nu = 1$)"), ("transfer_tax_020", "bequests/transfer_tax_020.json", "Transfer tax 20\\% (rebated)"), ("transfer_tax_050", "bequests/transfer_tax_050.json", "Transfer tax 50\\% (rebated)"), ("bequests_off_pe", "bequests/bequests_off_pe.json", "Bequests off, prices fixed"), ] rows = [r for r in (_scenario_row(n, p, l) for n, p, l in spec) if r is not None] if not rows: return body = "" for r in rows: eq = "GE" if r["eq"] == "GE" else "PE" flag = "" if r["conv"] else "$^{\\dagger}$" 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)} & " f"{_f(r['qro'], 2)} & {_f(r['qoo'], 2)} & {_f(r['rr'], 2)} & {_f(r['gini'], 2)} & {_f(r['gift'], 2)} \\\\\n") _write("tab05_scenarios", body, "Stationary equilibria across credit, supply, segmentation and bequest scenarios", "tab:scenarios", "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.", "lcccccccccccc", "Scenario & Eq. & $p^*$ & $\\rho/p$ & Own. & Own.\\ $<$35 & $\\hat a_1$ & $q_{RO}$ & $q_{OO}$ & rank--rank & Gini & gift \\\\", size="\\footnotesize", wide=True) for r in rows: key = re.sub(r"[^A-Za-z]", "", r["name"].split("(")[0].replace("\\", "").replace("$", "").replace("=", "").replace(".", ""))[:24] macro(f"Own{key}", _f(r["own"], 3)); macro(f"Price{key}", _f(r["p"], 3)); macro(f"Thr{key}", _f(r["thr"], 2)) def tab06_inflation(): spec = [("frm_level", "FRM, level surprise"), ("term5_level", "5-year term, level"), ("arm_level", "ARM, level"), ("frm_persistent", "FRM, persistent (2 periods)"), ("term5_persistent", "5-year term, persistent"), ("arm_persistent", "ARM, persistent"), ("frm_level_rigid_rents", "FRM, level, rigid rents ($\\eta_\\rho=0$)"), ("frm_level_unindexed_wages", "FRM, level, unindexed wages ($\\eta_w=0$)"), ("frm_persistent_taylor", "FRM, persistent, real-rate response"), ("term5_persistent_taylor", "5-year term, persistent, real-rate response"), ("frm_level_fixed_supply", "FRM, level, $\\varepsilon=0.05$"), ("frm_level_noseg", "FRM, level, no segmentation"), ("frm_level_static", "FRM, level, static expectations (A6)")] rows = [] for n, lab in spec: d = _load(f"inflation/{n}.json") if d is None: continue w = pd.read_csv(DATA / "inflation" / f"{n}_welfare.csv") if (DATA / "inflation" / f"{n}_welfare.csv").exists() else None def cev(grp): if w is None: return np.nan x = w[(w.group == grp) & (w.age == "all")] return float(x.cev.iloc[0]) if len(x) else np.nan 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"], rho0=d["rho_impact_pct"], down=d["d_ownership_impact_pp"], down_pe=d["d_ownership_impact_pe_pp"], lender=d["lender_revaluation"], erosion=d["aggregate_debt_erosion"], dep=d["aggregate_deposit_loss"], 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, cev_all=cev("all"), cev_om=cev("owners_with_mortgage"), cev_r=cev("renters"), cev_pe=d["pe"]["welfare_all"], name=n)) if not rows: return body = "" for r in rows: eq = "GE" if r["eq"] == "GE" else ("A6" if "static" in r["eq"] else "GE$^{\\dagger}$") body += (f"{r['lab']} & {eq} & {_f(r['p0'], 2)} & {_f(r['rho0'], 2)} & {_f(r['down'], 2)} & {_f(r['down_pe'], 2)} & " 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") _write("tab06_inflation", body, "Unanticipated 10\\% price-level surprise: impact effects by mortgage contract and persistence", "tab:inflation", "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.", "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) for r in rows: key = re.sub(r"[^A-Za-z]", "", r["name"]) macro(f"PImpact{key}", _f(r["p0"], 2)); macro(f"RhoImpact{key}", _f(r["rho0"], 2)); macro(f"DOwn{key}", _f(r["down"], 2)) 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)) macro(f"Erosion{key}", _f(r["erosion"], 3)) def tab07_mobility(): spec = [("baseline", "baseline/summary.json", "Baseline"), ("bequests_off", "bequests/bequests_off.json", "Bequests off"), ("bequests_half", "bequests/bequests_half.json", "Bequests halved"), ("transfer_tax_020", "bequests/transfer_tax_020.json", "Transfer tax 20\\%"), ("transfer_tax_050", "bequests/transfer_tax_050.json", "Transfer tax 50\\%"), ("rho_g_040", "sensitivity/rho_g_040.json", "Intergenerational income correlation 0.4"), ("sigma_low", "sensitivity/sigma_low.json", "Earnings risk $\\sigma = 0.10$"), ("sigma_high", "sensitivity/sigma_high.json", "Earnings risk $\\sigma = 0.20$"), ("theta_005", "theta_sweep/theta_005.json", "$\\theta = 0.05$"), ("theta_040", "theta_sweep/theta_040.json", "$\\theta = 0.40$"), ("eps_005", "supply/eps_005.json", "$\\varepsilon = 0.05$"), ("eps_200", "supply/eps_200.json", "$\\varepsilon = 2$"), ("dep_premium_002", "sensitivity/dep_premium_002.json", "Deposit return $+2$ pp (renter investment)"), ("r_001", "sensitivity/r_001.json", "Real rate 1\\%"), ("r_003", "sensitivity/r_003.json", "Real rate 3\\%"), ("pi_000", "sensitivity/pi_000.json", "Trend inflation 0\\%"), ("pi_004", "sensitivity/pi_004.json", "Trend inflation 4\\%"), ("phi_002", "sensitivity/phi_002.json", "Landlord wedge $\\phi = 0.02$"), ("phi_008", "sensitivity/phi_008.json", "Landlord wedge $\\phi = 0.08$")] rows = [] for n, p, lab in spec: d = _load(p) if d is None: continue mob, m = d["mobility"], d["moments"] 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"], 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"])) if not rows: return 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) _write("tab07_mobility_inequality", body, "Intergenerational persistence and inequality across stationary equilibria", "tab:mobility", "$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.", "lcccccccccccc", "Scenario & Eq. & $p^*$ & Own. & $q_{RO}$ & $q_{OO}$ & $\\lambda_3$ & $\\lambda_7$ & rank--rank & Gini & gap$_4$ & gift & estate \\\\", size="\\scriptsize", wide=True) def tab08_welfare(): f = DATA / "welfare" / "welfare_all.csv" if not f.exists(): return w = pd.read_csv(f) w["gift_quintile"] = w.gift_quintile.astype(str) 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", "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)] body = "" for s in scen: d = w[w.scenario == s] byq = [] for q in [str(i) for i in range(1, 6)] + ["marginal"]: dd = d[d.gift_quintile == q] ok = np.isfinite(dd.cev) byq.append(float(np.sum(dd.cev[ok] * dd.weight[ok]) / np.sum(dd.weight[ok])) if ok.any() else np.nan) dq = d[d.gift_quintile != "marginal"] ok = np.isfinite(dq.cev) allv = float(np.sum(dq.cev[ok] * dq.weight[ok]) / np.sum(dq.weight[ok])) lowz = dq[dq.z_index == 0]; hiz = dq[dq.z_index == dq.z_index.max()] labels = {"theta_005": "$\\theta = 0.05$", "theta_010": "$\\theta = 0.10$", "theta_030": "$\\theta = 0.30$", "theta_040": "$\\theta = 0.40$", "psi_030": "DSTI cap $\\psi = 0.30$", "psi_050": "DSTI cap $\\psi = 0.50$", "bequests_off": "Bequests off ($\\nu = 0$)$^{\\ddagger}$", "transfer_tax_020": "Transfer tax 20\\% (rebated)", "transfer_tax_050": "Transfer tax 50\\% (rebated)", "eps_005": "$\\varepsilon = 0.05$", "eps_200": "$\\varepsilon = 2$", "noseg": "No segmentation", "r_001": "Real rate 1\\%", "r_003": "Real rate 3\\%", "pi_004": "Trend inflation 4\\%", "dep_premium_002": "Deposit return $+2$ pp", "chi_005": "Ownership premium $\\chi = 0.05$$^{\\ddagger}$", "kappa_low": "Transaction costs low", "kappa_high": "Transaction costs high"} 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" macro(f"CEV{re.sub(r'[^A-Za-z]', '', s)}", _f(allv, 2, pct=True)) if not body: return _write("tab08_welfare", body, "Welfare of entering households across stationary equilibria (consumption-equivalent variation, \\%)", "tab:welfare", "$\\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.", "lccccccccc", "Scenario & Q1 & Q2 & Q3 & Q4 & Q5 & marginal & low $z_1$ & high $z_1$ & all \\\\", size="\\footnotesize", wide=True) def tab09_robustness(): f = DATA / "robustness_summary.csv" if not f.exists(): return d = pd.read_csv(f).set_index("name") names = [n for n in ["baseline", "beta_094", "beta_098", "gamma_3", "alpha_025", "kappa_low", "kappa_high", "chi_005", "sigma_low", "sigma_high", "grid_coarse", "grid_fine", "tol_tight", "sample_small", "sample_large", "seed_alt"] if n in d.index] body = "" for n in names: r = d.loc[n] 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)} & " 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") _write("tab09_robustness", body, "Robustness: preferences, transaction costs, grids, tolerance, sample size and seed", "tab:robustness", "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.", "lccccccccccc", "Variant & Eq. & $p^*$ & Own. & Own.\\ $<$35 & $\\hat a_1$ & Gini & gift & $q_{RO}$ & $q_{OO}$ & rank--rank & min \\\\", size="\\scriptsize", wide=True) # ====================================================================================== # Extra macros used in the manuscript text (Phase 4): every value is read from results/data/. # ====================================================================================== def _sign_changes(x): x = np.asarray(x, dtype=float) x = x[np.abs(x) > 1e-12] return int(np.sum(np.sign(x[1:]) != np.sign(x[:-1]))) def _welfare_group(name, grp, col="cev"): f = DATA / "inflation" / f"{name}_welfare.csv" if not f.exists(): return np.nan w = pd.read_csv(f) x = w[(w.group == grp) & (w.age.astype(str) == "all")] return float(x[col].iloc[0]) if len(x) else np.nan def _impact_group(name, grp, age, col): f = DATA / "inflation" / f"{name}_impact.csv" if not f.exists(): return np.nan w = pd.read_csv(f) x = w[(w.group == grp) & (w.age.astype(str) == str(age))] return float(x[col].iloc[0]) if len(x) else np.nan def extra_macros(): s = _load("baseline/summary.json") if s is None: return m, mob, gaps = s["moments"], s["mobility"], s["gaps"] by_age = m["ownership_rate_by_age"] macro("OwnAgeOne", _f(by_age[0], 2)); macro("OwnPeak", _f(max(by_age), 2)); macro("OwnAgeTwelve", _f(by_age[-1], 2)) macro("OwnThirtyFiveSixtyFour", _f(m["ownership_rate_35_64"], 2)); macro("OwnSixtyFivePlus", _f(m["ownership_rate_65plus"], 2)) macro("ShareGiftPositivePct", _f(m["share_gift_positive"], 0, pct=True)); macro("ShareFirstBuyersGiftPct", _f(m["share_first_buyers_with_gift"], 0, pct=True)) macro("MeanEstate", _f(m["mean_estate_received"], 2)); macro("ShareEstatePositivePct", _f(m["share_estate_positive"], 0, pct=True)) macro("GiniAgeSeven", _f(m["gini_net_worth_age7"], 2)); macro("MedianGift", _f(m["median_gift_received"], 2)) macro("ShareOwnersMortgage", _f(m["share_owners_with_mortgage"], 2)); macro("MeanLTVNew", _f(m["mean_ltv_new_loans"], 2)) macro("HouseValueIncome", _f(m["median_house_value_to_annual_income"], 1)) for k, lab in [("age2", "Two"), ("age4", "Four"), ("age7", "Seven"), ("age10", "Ten")]: macro(f"GapAge{lab}", _f(gaps[f"nw_gap_owner_renter_{k}"], 2)) macro("qOOageSeven", _f(mob["q_OO"], 2)); macro("qROageSeven", _f(mob["q_RO"], 2)); macro("HalfLifeGen", _f(mob["half_life_generations"], 2)) macro("ShareParentOwnerPct", _f(mob["share_parent_owner"], 0, pct=True)) th = s["thresholds"] macro("ThresholdAgeOneZtwo", _f(th["1"]["1"], 2)); macro("ThresholdAgeOneZfour", _f(th["1"]["3"], 2)) macro("ThresholdAgeThreeMidZ", _f(th["3"]["2"], 2)); macro("ThresholdAgeThreeHighZ", _f(th["3"]["4"], 2)); macro("ThresholdAgeTwoHighZ", _f(th["2"]["4"], 2)) macro("DebtDepositRatio", _f(s["total_deposits"] / s["total_debt"], 1)) macro("SupplyScaleOwner", _f(s["H_bar"], 2)); macro("SupplyScaleRental", _f(s["Hr_bar"], 2)); macro("RhoStar", _f(s["rho"], 3)) macro("BellmanOwnerPnn", _f(s["bellman"]["owner_p99"], 1, pct=True)); macro("BellmanOwnerMax", _f(s["bellman"]["owner_max"], 1, pct=True)) macro("BellmanRenterMax", _f(s["bellman"]["renter_max"], 2, pct=True)); macro("ClipSharePct", f"{100 * s['clip_share']:.3f}") macro("BaselineSeconds", _f(s["time_sec"], 0)); macro("LotteryRounds", str(s["supply_scales"].get("calibration_iterations", "--"))) macro("HouseholdPeriods", f"{s['params']['n_dyn'] * 2 * 6:,}".replace(",", "{,}")) # gift quintiles f = DATA / "baseline" / "by_gift_quintile.csv" if f.exists(): q = pd.read_csv(f) names = ["One", "Two", "Three", "Four", "Five"] for i in range(1, 6): r1 = q[(q.age == 1) & (q.gift_quintile == i)].iloc[0]; r7 = q[(q.age == 7) & (q.gift_quintile == i)].iloc[0] macro(f"OwnAgeOneQ{names[i-1]}", _f(r1["ownership"], 2)); macro(f"OwnAgeSevenQ{names[i-1]}", _f(r7["ownership"], 2)) macro(f"MedNWAgeSevenQ{names[i-1]}", _f(r7["nw_median"], 2)) g1 = q[(q.age == 1) & (q.gift_quintile == 5)].iloc[0]["ownership"] - q[(q.age == 1) & (q.gift_quintile == 2)].iloc[0]["ownership"] g7 = q[(q.age == 7) & (q.gift_quintile == 5)].iloc[0]["ownership"] - q[(q.age == 7) & (q.gift_quintile == 2)].iloc[0]["ownership"] macro("GapQtwoQfiveAgeOnePP", _f(100 * g1, 0)); macro("GapQtwoQfiveAgeSevenPP", _f(100 * g7, 0)) # equal-income paths f = DATA / "baseline" / "equal_income_paths.csv" if f.exists(): e = pd.read_csv(f) def first_buy(path): d = e[e.path == path]; o = d[d.owner == 1] return int(o.age.min()) if len(o) else 0 macro("EqIncFirstBuyAgeLowGift", str(first_buy("q10"))); macro("EqIncFirstBuyAgeMinus", str(first_buy("threshold_minus"))); macro("EqIncFirstBuyAgePlus", str(first_buy("threshold_plus"))) macro("EqIncAoneMinus", _f(e[e.path == "threshold_minus"].a1.iloc[0], 3)); macro("EqIncAonePlus", _f(e[e.path == "threshold_plus"].a1.iloc[0], 3)) macro("EqIncAoneMedian", _f(e[e.path == "q50"].a1.iloc[0], 2)) for age, lab in [(4, "Four"), (7, "Seven")]: 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]) macro(f"EqIncNWMinusAge{lab}", _f(nm, 2)); macro(f"EqIncNWPlusAge{lab}", _f(npl, 2)) macro(f"EqIncGapPctAge{lab}", _f(100 * (npl / nm - 1), 0)) macro("EqIncEndowDiffPct", _f(100 * (e[e.path == "threshold_plus"].a1.iloc[0] / e[e.path == "threshold_minus"].a1.iloc[0] - 1), 0)) # price-drop accounting f = DATA / "baseline" / "price_drop_accounting.csv" if f.exists(): d = pd.read_csv(f) a = d[d.age == "all"]; n = d[d.age == "new_loans_at_origination"] macro("MeanLTVOwners", _f(float(a.mean_ltv.iloc[0]), 2)) for drop, lab in [(0.1, "Ten"), (0.2, "Twenty"), (0.3, "Thirty")]: ra = a[np.isclose(a["drop"], drop)].iloc[0]; rn = n[np.isclose(n["drop"], drop)].iloc[0] macro(f"UnderwaterAll{lab}", _f(ra.underwater_share, 0, pct=True)); macro(f"UnderwaterNew{lab}", _f(rn.underwater_share, 0, pct=True)) 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)) # stationary scenarios def sc(path): return _load(path) def put(prefix, d): if d is None: return mm, mb = d["moments"], d["mobility"] 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)) 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)) 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)) 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)) 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)) 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)) if "rebate" in d: macro(f"{prefix}Rebate", _f(d["rebate"], 3)) for prefix, path in [("ThetaFive", "theta_sweep/theta_005.json"), ("ThetaTen", "theta_sweep/theta_010.json"), ("ThetaThirty", "theta_sweep/theta_030.json"), ("ThetaForty", "theta_sweep/theta_040.json"), ("ThetaFivePE", "theta_sweep/theta_005_pe.json"), ("ThetaFortyPE", "theta_sweep/theta_040_pe.json"), ("PsiThirty", "theta_sweep/psi_030.json"), ("PsiFifty", "theta_sweep/psi_050.json"), ("BeqOff", "bequests/bequests_off.json"), ("BeqOffPE", "bequests/bequests_off_pe.json"), ("BeqHalf", "bequests/bequests_half.json"), ("TaxTwenty", "bequests/transfer_tax_020.json"), ("TaxFifty", "bequests/transfer_tax_050.json"), ("EpsLowThetaFive", "supply/eps_005_theta005.json"), ("EpsHighThetaFive", "supply/eps_200_theta005.json"), ("EpsLowThetaForty", "supply/eps_005_theta040.json"), ("EpsHighThetaForty", "supply/eps_200_theta040.json"), ("Rone", "sensitivity/r_001.json"), ("Rthree", "sensitivity/r_003.json"), ("PiZero", "sensitivity/pi_000.json"), ("PiFour", "sensitivity/pi_004.json"), ("PiFourPE", "sensitivity/pi_004_pe.json"), ("PhiLow", "sensitivity/phi_002.json"), ("PhiHigh", "sensitivity/phi_008.json"), ("SigLow", "sensitivity/sigma_low.json"), ("SigHigh", "sensitivity/sigma_high.json"), ("RhoG", "sensitivity/rho_g_040.json"), ("DepPrem", "sensitivity/dep_premium_002.json"), ("BetaLow", "robustness/beta_094.json"), ("BetaHigh", "robustness/beta_098.json"), ("GammaThree", "robustness/gamma_3.json"), ("AlphaHigh", "robustness/alpha_025.json"), ("KappaLow", "robustness/kappa_low.json"), ("KappaHigh", "robustness/kappa_high.json"), ("ChiFive", "robustness/chi_005.json"), ("GridCoarse", "robustness/grid_coarse.json"), ("GridFine", "robustness/grid_fine.json")]: put(prefix, sc(path)) # PE absorption shares 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") if all(x is not None for x in (t5, t5pe, t40, t40pe)): o = b["moments"]["ownership_rate_all"] macro("AbsorbThetaFivePct", _f(1 - (t5["moments"]["ownership_rate_all"] - o) / (t5pe["moments"]["ownership_rate_all"] - o), 0, pct=True)) macro("AbsorbThetaFortyPct", _f(1 - (t40["moments"]["ownership_rate_all"] - o) / (t40pe["moments"]["ownership_rate_all"] - o), 0, pct=True)) bo, bope = sc("bequests/bequests_off.json"), sc("bequests/bequests_off_pe.json") if bo is not None and bope is not None: o = b["moments"]["ownership_rate_all"] macro("BeqOffPEDropPP", _f(100 * (o - bope["moments"]["ownership_rate_all"]), 0)); macro("BeqOffGEDropPP", _f(100 * (o - bo["moments"]["ownership_rate_all"]), 0)) p4, p4pe = sc("sensitivity/pi_004.json"), sc("sensitivity/pi_004_pe.json") if p4 is not None and p4pe is not None: u = b["moments"]["ownership_rate_under35"] macro("PiFourPEDropUthirtyfivePP", _f(100 * (u - p4pe["moments"]["ownership_rate_under35"]), 1)) macro("PiFourGEOffsetPct", _f(1 - (u - p4["moments"]["ownership_rate_under35"]) / (u - p4pe["moments"]["ownership_rate_under35"]), 0, pct=True)) # multiplicity scans mult = _load("multiplicity/multiplicity_summary.json") if mult is not None: for k, lab in [("baseline", "Base"), ("eps_005", "EpsLow"), ("eps_005_theta005", "EpsLowThetaFive")]: if k in mult: ex, ow, pg = np.array(mult[k]["excess_owner"]), np.array(mult[k]["ownership"]), np.array(mult[k]["p_grid"]) macro(f"Mult{lab}ExcessLow", _f(ex[0], 2)); macro(f"Mult{lab}ExcessHigh", _f(ex[-1], 2)) macro(f"Mult{lab}OwnLow", _f(ow[0], 2)); macro(f"Mult{lab}OwnHigh", _f(ow[-1], 2)) i = int(np.argmin(np.abs(ex))); macro(f"Mult{lab}Root", _f(pg[i], 3)) j = np.where(np.isclose(pg, 1.0))[0][0] 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)) macro("MultPgridLow", _f(min(mult["baseline"]["p_grid"]), 2)); macro("MultPgridHigh", _f(max(mult["baseline"]["p_grid"]), 2)) # grid sensitivity f = DATA / "grid_sensitivity.csv" if f.exists(): g = pd.read_csv(f).set_index("name") for n, lab in [("grid_coarse", "Coarse"), ("grid_fine", "Fine"), ("sample_large", "SampleLarge"), ("seed_alt", "Seed"), ("sample_small", "SampleSmall")]: if n in g.index: 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)) 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}")) 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}")) # transitions diag = _load("diagnostics.json") if diag is not None: runs = diag["runs"] ss = [r for r in runs if r.get("equilibrium") == "GE" and r.get("group") != "inflation"] tr = [r for r in runs if r.get("group") == "inflation"] macro("NScenariosSS", str(len(ss))); macro("NScenariosSSConverged", str(sum(1 for r in ss if r.get("converged")))) macro("NTransitions", str(len(tr))); macro("NTransitionsConverged", str(sum(1 for r in tr if r.get("converged")))) macro("SimulateMinutesOne", _f(diag["total_sec"] / 60, 1)) for n, lab in [("frm_level", "FrmLevel"), ("term5_level", "TermLevel"), ("arm_level", "ArmLevel"), ("frm_persistent", "FrmPers"), ("term5_persistent", "TermPers"), ("arm_persistent", "ArmPers"), ("frm_level_rigid_rents", "RigidRents"), ("frm_level_unindexed_wages", "Unindexed"), ("frm_persistent_taylor", "FrmTaylor"), ("term5_persistent_taylor", "TermTaylor"), ("frm_level_fixed_supply", "FixedSupply"), ("frm_level_noseg", "NoSeg"), ("frm_level_static", "Static"), ("rate_shock_theta020", "RateTwenty"), ("rate_shock_theta040", "RateForty"), ("rate_shock_theta005", "RateFive")]: d = _load(f"inflation/{n}.json") if d is None: continue macro(f"{lab}MaxRes", f"{d['max_residual']:.1e}".replace("e-0", r"\times10^{-").replace("e-", r"\times10^{-") + "}") macro(f"{lab}ResT", f"{d['residual_at_T']:.0e}".replace("e-0", r"\times10^{-").replace("e-", r"\times10^{-") + "}") macro(f"{lab}Steps", str(d["iterations"])); macro(f"{lab}Secs", _f(d["time_sec"], 0)) macro(f"{lab}Pzero", _f(d["p_impact_pct"], 2)); macro(f"{lab}RhoZero", _f(d["rho_impact_pct"], 2)) macro(f"{lab}DOwnGE", _f(d["d_ownership_impact_pp"], 2)); macro(f"{lab}DOwnPE", _f(d["d_ownership_impact_pe_pp"], 2)) if abs(d["d_ownership_impact_pe_pp"]) > 1e-9: macro(f"{lab}AbsorbPct", _f(1 - d["d_ownership_impact_pp"] / d["d_ownership_impact_pe_pp"], 0, pct=True)) 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)) mp, ms = d["mobility_path"], d["mobility_ss"] 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)) macro(f"{lab}qROthreeSS", _f(ms["q_RO_age3"], 3)); macro(f"{lab}qROthreeZero", _f(mp[0]["q_RO_age3"], 3)) if len(mp) > 1: macro(f"{lab}qROthreeOne", _f(mp[1]["q_RO_age3"], 3)) macro(f"{lab}ThrSS", _f(d["threshold_ss"]["1"]["2"], 2)); macro(f"{lab}ThrZero", _f(d["threshold_impact"]["1"]["2"], 2)) macro(f"{lab}ThrChangePct", _f(100 * (d["threshold_impact"]["1"]["2"] / d["threshold_ss"]["1"]["2"] - 1), 0)) for grp, gl in [("all", "All"), ("owners_with_mortgage", "OM"), ("owners_no_mortgage", "ONM"), ("renters", "R"), ("owners", "O")]: 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)) macro(f"{lab}CEVAllPE", _f(d["pe"]["welfare_all"], 2, pct=True)) pth = DATA / "inflation" / f"{n}_path.csv" if pth.exists(): pa = pd.read_csv(pth) macro(f"{lab}PriceSignChanges", str(_sign_changes(pa.p_pct.values[:-1]))); macro(f"{lab}PriceMaxPos", _f(float(pa.p_pct.max()), 1)) 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.nan macro(f"{lab}PriceBackYears", _f(float(back), 0)) d0 = 100 * (pa.debt_total.iloc[0] / pa.debt_total.iloc[-1] - 1); macro(f"{lab}DebtZeroPct", _f(d0, 1)) macro(f"{lab}NominalRateZero", _f(float(pa.i_new.iloc[0]), 3)) for grp, age, col, gl in [("all", "all", "d_consumption_pct", "ConsAll"), ("renters", "all", "d_consumption_pct", "ConsR"), ("owners", "all", "d_consumption_pct", "ConsO"), ("renters", "all", "net_nominal_gain", "NetR"), ("owners", "all", "net_nominal_gain", "NetO"), ("all", "all", "wage_loss", "WageAll"), ("owners", "all", "wage_loss", "WageO"), ("renters", "all", "rent_rigidity_gain", "RentGainR"), ("all", "all", "d_nw", "NWAll"), ("owners", "all", "d_nw", "NWO"), ("renters", "all", "d_nw", "NWR")]: v = _impact_group(n, grp, age, col) if np.isfinite(v): macro(f"{lab}{gl}", _f(v, 2) if "Cons" not in gl else _f(v, 1)) # owners by age bands (impact csv, group=owners) f = DATA / "inflation" / f"{n}_impact.csv" if f.exists(): w = pd.read_csv(f); w = w[w.age.astype(str) != "all"]; w["age"] = w.age.astype(int) o = w[w.group == "owners"] young = o[o.age.isin([2, 3, 4])]; old = o[o.age.isin([8, 9, 10])] if len(young) and len(old): 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)) macro(f"{lab}OwnerOldLossMin", _f(float(-old.net_nominal_gain.max()), 2)); macro(f"{lab}OwnerOldLossMax", _f(float(-old.net_nominal_gain.min()), 2)) macro(f"{lab}OwnerOldConsMin", _f(float(old.d_consumption_pct.max()), 1)); macro(f"{lab}OwnerOldConsMax", _f(float(old.d_consumption_pct.min()), 1)) # welfare quintiles (stationary) f = DATA / "welfare" / "welfare_all.csv" if f.exists(): w = pd.read_csv(f); w["gift_quintile"] = w.gift_quintile.astype(str) for sname, lab in [("theta_005", "ThetaFive"), ("theta_040", "ThetaForty"), ("transfer_tax_020", "TaxTwenty"), ("transfer_tax_050", "TaxFifty"), ("eps_005", "EpsLow"), ("eps_200", "EpsHigh"), ("r_001", "Rone"), ("r_003", "Rthree"), ("pi_000", "PiZero"), ("pi_004", "PiFour"), ("phi_002", "PhiLow"), ("phi_008", "PhiHigh"), ("sigma_low", "SigLow"), ("sigma_high", "SigHigh"), ("rho_g_040", "RhoG"), ("dep_premium_002", "DepPrem"), ("kappa_low", "KappaLow"), ("kappa_high", "KappaHigh"), ("bequests_off", "BeqOff"), ("psi_030", "PsiThirty"), ("psi_050", "PsiFifty"), ("eps_005_theta005", "EpsLowThetaFive"), ("eps_200_theta005", "EpsHighThetaFive"), ("tol_tight", "TolTight"), ("sample_large", "SampleLarge"), ("seed_alt", "Seed")]: d = w[w.scenario == sname] if not len(d): continue def wavg(dd): ok = np.isfinite(dd.cev) return float(np.sum(dd.cev[ok] * dd.weight[ok]) / np.sum(dd.weight[ok])) if ok.any() else np.nan dq = d[d.gift_quintile != "marginal"] 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)) 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)) macro(f"CEV{lab}BottomTwo", _f(wavg(d[d.gift_quintile.isin(["1", "2"])]), 2, pct=True)) if {"V_base", "V_base_simulated"}.issubset(w.columns): rel = np.abs(w.V_base_simulated / w.V_base - 1.0) macro("WelfareCheckMaxPct", _f(float(np.nanmax(rel)), 1, pct=True)) num = w[w.scenario.isin(["tol_tight", "sample_large", "sample_small", "seed_alt"])] if len(num): macro("CEVNumericalMaxPct", _f(float(np.nanmax(np.abs(num.cev))), 2, pct=True)) # robustness summary: Bellman residual range f = DATA / "robustness_summary.csv" if f.exists(): r = pd.read_csv(f) 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^{-") + "}") macro("NRobustnessRuns", str(len(r))) def write_macros(): s = _load("baseline/summary.json") if s is not None: macro("Pstar", _f(s["p"], 3)); macro("RentPriceAnnual", _f(s["moments"]["rent_price_ratio_annual"], 3)) macro("NDynasties", f"{s['params']['n_dyn']:,}".replace(",", "{,}")) mult = _load("multiplicity/multiplicity_summary.json") if mult is not None: for k, v in mult.items(): macro(f"SignChanges{re.sub(r'[^A-Za-z]', '', k)}", str(v["n_sign_changes"])) # absolute-value twins of every negative macro, for sentences of the form "falls by \\XAbs\\%" for k, v in list(MACROS.items()): if v.startswith("\\ensuremath{-}") and k + "Abs" not in MACROS: MACROS[k + "Abs"] = v[len("\\ensuremath{-}"):] with open(TAB / "numbers.tex", "w") as fh: fh.write("% generated by src/tables.py — one macro per key number; every value traces to results/data/\n") for k, v in sorted(MACROS.items()): fh.write(f"\\newcommand{{\\{k}}}{{{v}}}\n") print(f"numbers.tex: {len(MACROS)} macros") def main(): for fn in (tab01_notation, tab02_parameters, tab03_moments, tab04_convergence, tab05_scenarios, tab06_inflation, tab07_mobility, tab08_welfare, tab09_robustness, extra_macros): try: fn() except Exception as exc: # noqa: BLE001 print(f"TABLE FAILED {fn.__name__}: {exc!r}") raise write_macros() if __name__ == "__main__": main()