"""WP18 — calibration targets and model moments. The model is illustrative: parameters come from configs/baseline.yaml with their provenance ("illustrative" unless a verified source is given). This module (i) lists the verified Canadian moments that can be compared with the model (research/literature_review.md §1 and §7; all in research/literature_matrix.csv), (ii) computes the corresponding model moments from a pooled cross-section, and (iii) writes results/data/calibration_targets.csv. No moment is fitted by an estimator; the comparison is reported as is. Units: model values are in units of the average 5-year labour income (so "x annual incomes" = 5 x model units). """ from __future__ import annotations import numpy as np import pandas as pd from .distributions import PeriodRecord, gini, rank_rank_slope from .model import Params # Verified targets (source key = BibTeX key in paper/references.bib). Values quoted verbatim in # research/literature_review.md; "comparison only" = not used to set any parameter. VERIFIED_TARGETS = [ dict(moment="ownership_rate_all", description="Share of households owning (model: all ages; Canada: plateau near 3/4 by 65, Hou 2010)", target=0.75, target_note="plateau near age 65, about three quarters (age profile, not the aggregate)", source="hou2010homeownership", status="verified, comparison only"), dict(moment="median_nw_ratio_owner_renter_under35", description="Median net worth, owners / non-owners, families under 35", target=457100 / 44000, target_note="$457,100 vs $44,000 (SFS 2023)", source="statcan2024survey", status="verified, comparison only"), dict(moment="share_with_mortgage", description="Share of families holding a mortgage", target=0.39, target_note="39 % of families (SFS 2023)", source="statcan2024survey", status="verified, comparison only"), dict(moment="share_first_buyers_with_gift", description="Share of first-time buyers receiving a family gift", target=0.28, target_note="28 % in 2021 (CIBC analysis quoted by Mirdamadi and Khalid 2023)", source="mirdamadi2023parents", status="verified (secondary quotation), comparison only"), dict(moment="mean_gift_in_annual_incomes", description="Average family gift to first-time buyers, in years of average income", target=np.nan, target_note="about $80,000 in 2021 (CIBC via Mirdamadi and Khalid 2023); no verified income denominator", source="mirdamadi2023parents", status="verified amount, denominator illustrative"), dict(moment="supply_elasticity", description="Long-run price elasticity of housing supply", target=np.nan, target_note="no verified Canadian estimate (literature_review §7); eps = 1 illustrative, range 0.05-2 in experiment 7", source="—", status="ILLUSTRATIVE"), ] def model_moments(P: Params, rec: PeriodRecord, p: float, rho: float) -> dict: age = rec.age own = rec.owner1() nw = rec.net_worth1(P) under35 = age <= 2 mid = (age >= 3) & (age <= 8) old = age >= 9 m = {} m["ownership_rate_all"] = float(own.mean()) m["ownership_rate_under35"] = float(own[under35].mean()) m["ownership_rate_35_64"] = float(own[mid].mean()) m["ownership_rate_65plus"] = float(own[old].mean()) m["ownership_rate_by_age"] = [float(own[age == j].mean()) for j in range(1, P.J + 1)] med_o = float(np.median(nw[under35 & own])) if (under35 & own).any() else np.nan med_r = float(np.median(nw[under35 & ~own])) if (under35 & ~own).any() else np.nan m["median_nw_owner_under35"] = med_o m["median_nw_renter_under35"] = med_r m["median_nw_ratio_owner_renter_under35"] = med_o / med_r if med_r and med_r > 1e-6 else np.inf m["mean_nw_ratio_owner_renter_under35"] = float(nw[under35 & own].mean() / max(nw[under35 & ~own].mean(), 1e-9)) m["share_with_mortgage"] = float((rec.b1 > 1e-8).mean()) m["share_owners_with_mortgage"] = float((rec.b1[own] > 1e-8).mean()) if own.any() else np.nan first_buy = (rec.choice == 2) & (rec.h0 < 0) & (~rec.is_old) gifts = rec.gift_received[first_buy] m["share_first_buyers_with_gift"] = float((gifts > 1e-6).mean()) if first_buy.any() else np.nan g1 = rec.gift_received[(age == 1) & ~rec.is_old] m["mean_gift_received"] = float(g1.mean()) m["mean_gift_in_annual_incomes"] = float(g1.mean() * P.period_years) m["median_gift_received"] = float(np.median(g1)) m["share_gift_positive"] = float((g1 > 1e-6).mean()) e7 = rec.estate_received[(age == P.j_estate) & rec.is_old] e7 = e7[np.isfinite(e7)] m["mean_estate_received"] = float(e7.mean()) if len(e7) else np.nan m["share_estate_positive"] = float((e7 > 1e-6).mean()) if len(e7) else np.nan m["gini_net_worth"] = gini(nw) m["gini_net_worth_age7"] = gini(nw[age == P.j_gift]) m["gini_consumption"] = gini(rec.c) hv = np.where(own, P.h_grid[np.maximum(rec.h1, 0)], 0.0) * p m["median_house_value_to_annual_income"] = float(np.median(hv[own]) * P.period_years / 1.0) if own.any() else np.nan m["mean_ltv_new_loans"] = float((rec.Bnew[rec.Bnew > 0] / hv[rec.Bnew > 0]).mean()) if (rec.Bnew > 0).any() else np.nan m["rent_price_ratio_annual"] = float(rho / p / P.period_years) m["share_forced_sale"] = float((rec.flag == 1).mean()) m["share_policy_clipped"] = float((rec.flag == 2).mean()) # mobility: parent tenure at gift vs child tenure at the same age (j_gift) — young members know parent tenure child7 = (age == P.j_gift) & rec.is_old # the old member at age 7 was the young member of the same slot: parent tenure stored at birth is not # carried after promotion in the record; mobility statistics are computed in simulation.py from the panel. return m def targets_table(P: Params, moments: dict) -> pd.DataFrame: rows = [] for t in VERIFIED_TARGETS: rows.append(dict(moment=t["moment"], description=t["description"], target=t["target"], target_note=t["target_note"], model=moments.get(t["moment"], np.nan), source=t["source"], status=t["status"])) return pd.DataFrame(rows)