Fundamental-value triangulation (WP 26)
Companion of paper/sections/fundamental.tex and of src/agentic_valuation/fundamental/. Realised prices are noisy, selected proxies of value (theory.tex, Lemma lem:proxy): the price-based metrics of docs/evaluation_protocol.md rank arms by their distance to the selected market value and say nothing about the structural fundamental value $V^F$. This document defines the three proxies used to say something about it, their data, their limits, and the commands that produce every number. Numbers below come from the runs of 2026-10-27 on the Vrai Prix snapshot of 2026-10-21 (results/fundamental/*.json).
1. Definitions (notation of theory.tex §subsec:values)
| Object | Definition | Where |
|---|---|---|
| $P_{t_1}$ | realised price of sale $(i,t_1)$ | analytic.price |
| $I(m,c,\tau)$ | chained monthly log-price index of municipality $m$ × class $c$ (region × class fallback), §2 | price_index_monthly.parquet |
| $V^R_{t_1}$ | resale-implied value: $\log V^R_{t_1} = \log P_{t_2} - [I(m,c,\tau_2) - I(m,c,\tau_1)]$ for the next sale $(t_2 > t_1)$ of the same parcel | resale_targets.parquet |
| gap | $\log V^R_{t_1} - \log P_{t_1}$ (= $\varepsilon_{t_2} - \varepsilon_{t_1}$ under the repeat-sales assumptions) | idem |
| $e^P$, $e^R$ | an arm's log error against $P_{t_1}$ and against $V^R_{t_1}$; $\Delta = \overline{\lvert e^R\rvert}(\text{arm}) - \overline{\lvert e^R\rvert}(\text{price})$ | eval_vs_resale*.csv |
| gross yield | $12 \times$ median monthly rent (same municipality, year, rooms bucket) $\times$ units $/$ price, condos (units = 1) and plexes (units = n_units) |
gross_yields.parquet |
| $u$ | user cost $i + \tau_p + \delta + \gamma - \pi^e$ (Poterba 1984; Himmelberg, Mayer & Sinai 2005; Canadian tax treatment, theory.tex eq. usercost) |
price_rent.csv |
| rent-implied value / price | $(R/u)/P = \text{gross yield}/u$ | idem |
Everything here is ex post (the index uses the whole snapshot) and is never shown to any arm; the as-of rules of docs/architecture.md §2 are untouched.
2. Repeat sales (fundamental/repeat_sales.py, scripts/50_repeat_sales.py)
Index. For each municipality × class × month, the median of $\log(\text{price}/\text{floor area})$ over the sales of the cell (sales without floor area, 1.2 %, are dropped from the index only). Cells with fewer than 5 sales are replaced by the region-group × class cell. Each series is smoothed with a centred 3-month rolling median (computed on valid months only; a month below the threshold never receives a value). The index is chained: $I(\tau) = \sum_{s \le \tau} \Delta_s$, where $\Delta_s$ is the change of the smoothed municipality series when both $s-1$ and $s$ are valid there, the change of the region series otherwise, and 0 (flagged carry) when neither exists. A switch between sources therefore never produces a level jump. Region × class series are stored too (level = "region") and used when a municipality × class has no series at all. Real data: 2,283 municipality × class series over 70 months (2021-01 … 2026-10); 16,003 monthly changes come from the municipality source and 141,524 from the region source — most municipalities are thin at the monthly frequency, so the deflator is in practice a regional index with municipal corrections in the large cities. Municipality changes are noisier (s.d. 0.075 per month) than regional ones (0.029); the first and last month of the grid carry the edge effect of the centred window.
Pairs. Consecutive sales of the same parcel_id in the full snapshot (769,995 rows, not only the 631,866 headline sales), after removing rows that cannot be a leg (exact_duplicate, same_parcel_same_date_diff_price, shared_parcel, multi_parcel_candidate, land, non_residential, built_after_sale, missing parcel or price) and same-date pairs. The later leg keeps its exclude_reason and headline flag (resale_flagged when either is set, 1.8 % of pairs). Both directions are produced: resale_targets (first sale in the analytic table → later price brought back) and prior_targets (later sale in the analytic table → earlier price brought forward).
Sample (2026-10-27). 103,501 pairs in the full snapshot; 92,089 analytic sales (14.6 %) have a later resale: condo 30,900, house 50,178, plex 11,011; Montréal CMA 39,256, Québec CMA 8,641, Outaouais 5,731, other 38,461. Median holding period 23.5 months; 28.8 % are held less than 12 months (short_hold; among holds under one month, 11 % resell at the identical price, i.e. re-registrations rather than resales). Median annualised log return 8.0 %. Gap $\log V^R - \log P$: mean +0.015, s.d. 0.438 (condo 0.30, house 0.49, plex 0.50), median absolute gap 0.128. The gap is the difference of two transaction noises plus unobserved renovations plus index error; its dispersion is the noise floor of the proxy and is why resale-based contrasts need large samples.
Selection, documented but not corrected. Properties that resell within a 2021–2026 window are flips, relocations, forced sales and renovations; the resale decision is correlated with $\varepsilon_{t_1}$ (Genesove & Mayer 2001). Renovations are unobserved: the roll attributes are one snapshot shared by both legs. Holding periods are right-censored by the snapshot date (2026-10-07). Consequences for the evaluation partitions: first sales in test (2025-04 … 2025-12) have at most 18 months to resell and 80 % of their pairs are short holds; holdout (2026) pairs are all short holds. After excluding short holds and flagged legs, 729 test-partition sales (of 96,935) and 0 holdout sales have a usable $V^R$; on the LLM subsamples, 6 of 600 test and 0 of 300 holdout properties (23 and 10 before the holding-period filter). The arm comparison against $V^R$ therefore becomes informative only with a later snapshot (scripts/60_fetch_prospective.py or a refreshed Vrai Prix export); scripts/51_eval_vs_resale.py is written so that re-running it after a refresh is all that is needed.
3. Rents (fundamental/rents.py, scripts/52_rents_usercost.py)
Source. data/raw/external/rdl.db (table rdl_housings, 15 MB, copied from M4M64b:~/apps/immo-ka/data/rdl.db on 2026-10-27), a sample of residential leases with monthly rent, rooms, lease year, optional start date, address and coordinates. It is described as leases registered with the Tribunal administratif du logement; the raw table also contains Ontario cities (Toronto, Ottawa, Hamilton, …), which suggests a self-reported registry rather than an administrative extract. Limits that cannot be repaired: (i) a self-selected sample of registered leases, not the rental stock, with a strong 2022–2025 concentration; (ii) no floor area — a rooms-to-area bridge to the sales table does not exist; (iii) rooms is an undocumented integer (mode 2, 90 % in 1–3), read here as bedrooms; (iv) start_date is missing for 40 % of rows, so leases are placed by year; (v) rents are contract rents of new or renewed leases, detached from market rents for sitting tenants under Quebec rent regulation; (vi) no vacancy, operating cost or quality information.
Cleaning funnel (2026-10-27). raw 57,433 → coordinates 57,433 → rent in [200, 15 000] CAD 57,337 → year in [2015, 2026] 57,035 → rooms recorded 56,961 → deduplicated 56,305. Matching: nearest analytic sale within 2 km (Quebec Lambert, EPSG:32198) gives the municipality; 49,082 leases matched (Montréal CMA 32,232; Québec CMA 5,359; Outaouais 1,127; other 10,364), 48,188 also fall in a 2021 dissemination-area polygon (quartier.db); 7,223 unmatched (Ontario and remote addresses). Rooms buckets among matched leases: 0–1 15,875; 2 21,204; 3 9,563; 4+ 2,440.
Median rents. rents_by_mun_year.csv: municipality × year × rooms bucket (plus an all bucket), 5,713 cells.
Gross yields. For condo and plex sales, the municipality-year median rent of the all bucket (cells with ≥ 10 leases; --condo-bucket/--plex-bucket select a rooms bucket for sensitivity), annualised, times units, divided by the price: 162,981 sales matched (condo 103,553; plex 59,428). Median yield condo 3.6 %, plex 5.6 % (2025: Montréal 3.5 % / 4.8 %, Québec 4.0 % / 5.8 %, Outaouais 5.0 % / 6.2 %, other 3.6 % / 6.3 %). These are crude ratios — the rent of an average registered apartment against the price of a sold condo or plex — and are read as a distribution diagnostic (figure fundamental_price_rent.pdf), not as a valuation.
4. User-cost diagnostic — parameter table
| Parameter | Value | Status | Source |
|---|---|---|---|
| $i$, 5-year conventional mortgage rate | 2021 4.79 %, 2022 5.65 %, 2023 6.68 %, 2024 6.73 %, 2025 6.23 %, 2026 6.10 % (annual means) | measured | Bank of Canada Valet V80691335 "Conventional mortgage, 5-year", weekly, 616 obs 2015-01-07 … 2026-10-21, cached in data/raw/external/boc_V80691335.json; left NaN (never invented) when neither download nor cache is available |
| $\tau_p$, property tax | 1.0 % | assumption | Quebec effective municipal + school rates cluster around 0.7–1.2 % of market value |
| $\delta$, maintenance and depreciation | 2.5 % | assumption | Himmelberg, Mayer & Sinai (2005) |
| $\gamma$, risk and liquidity premium | 2.0 % | assumption | Himmelberg, Mayer & Sinai (2005) |
| $\pi^e$, expected nominal appreciation | 0 %, 2 %, 4 % | scenarios | bracket of long-run nominal growth |
With these inputs $u$ is 8.3–12.2 % depending on year and scenario, and the median rent-implied value / price (price_rent.csv, figure fundamental_usercost.pdf) is 0.29–0.61 for condos and 0.45–0.99 for plexes. The level reflects the quality mismatch between registered leases and sold units and the regulation of contract rents at least as much as any over-valuation; only the time profile and the cross-sectional dispersion are read, and the figure is labelled illustrative.
5. Arm comparison against the resale-implied value (fundamental/resale_eval.py, scripts/51_eval_vs_resale.py)
For every live run under results/runs/ (ids starting with _ are mock/synthetic and skipped), one configuration per arm (preference: mixed_random / median, then any committee with median, then the baseline none/model), restricted to the requested partitions (default test,holdout) and to properties with a usable $V^R$ (short holds and flagged legs excluded unless --keep-short / --keep-flagged). The realised price enters as a pseudo-arm P_price. Each arm's mean $|e|$, RMSE and bias are reported against $P_{t_1}$ and against $V^R$, and $\Delta$ = mean $|e^R|$(arm) − mean $|e^R|$(price) is estimated with the paired block bootstrap of evaluation.inference (blocks = municipality × month, B = 2000, Holm within the family), together with the pre-registered arm pairs. $\Delta < 0$ means the arm is closer to the market-adjusted resale than the first transaction price was. Status 2026-10-27 (run of 23:38 UTC, 29 s, B = 2000): estimated for the statistical baselines (results/runs/baselines/predictions.parquet, arms A–D on the full test partition → 729 test sales with a usable $V^R$, 399 blocks; spatial variants 63) and NOT YET ESTIMATED for the LLM arms (live runs exist only on the validation subsample, validation_*). Headline: the realised price is on average 0.073 log points below $V^R$ in this subsample (mean $|e^R_P|$ = 0.243) — the flip/renovation selection; C_catboost / C_gbm / C_xgboost / D_avm are closer to $V^R$ than the price ($\Delta$ = −0.032 / −0.028 / −0.038 / −0.028, Holm $p$ < 0.05) with near-zero bias against $V^R$; A_hedonic is no closer ($\Delta$ = +0.006, $p$ = 1) and B_hierarchical is further (+0.030, $p$ = 0.035). The ordering against $V^R$ equals the ordering against $P$. C_gbm and D_avm share the same point estimates in the current baseline run. A dry run on the validation pilots (--splits validation, 40 predicted properties) finds a single property with a usable resale and is not reported. Re-run scripts/51_eval_vs_resale.py after every new run under results/runs/ and after a snapshot refresh.
6. Commands and outputs
scp M4M64b:~/apps/immo-ka/data/rdl.db data/raw/external/ # once (15 MB)
python3 scripts/50_repeat_sales.py # 4 s → results/fundamental/{price_index_monthly,resale_targets,prior_targets}.parquet,
# resale_sample.{json,csv}, paper/tables/tab_resale_sample.tex, figures/fundamental_resale_gap.pdf
python3 scripts/52_rents_usercost.py # 7 s → leases_matched.parquet, leases_matching.json, rents_by_mun_year.csv, gross_yields.parquet,
# price_rent.csv, usercost_parameters.csv, paper/tables/tab_usercost_params.tex,
# figures/fundamental_price_rent.pdf, figures/fundamental_usercost.pdf (--no-download → cache)
python3 scripts/51_eval_vs_resale.py # 29 s with the baselines (B = 2000) → eval_vs_resale.csv, eval_vs_resale_contrasts.csv,
# eval_vs_resale.json, paper/tables/tab_resale.tex (NOT YET ESTIMATED row when no eligible run)
python3 -m pytest tests/test_fundamental.py -q [--real]resale_targets(analytic, full=None, index=None) returns tx_id, resale_tx_id, resale_price, resale_date, months_held, index_change, resale_implied_log_value, resale_implied_value, gap, annualised_log_return, short_hold, class_changed, resale_flagged (+ keys). Every figure and table of §sec:fundamental is produced by these three scripts; nothing is hand-edited.