SPB Git

spb/wp26_uqo

Public
13.5 KB · 72 lines markdown
1<!-- Author: Simon-Pierre Boucher — simon-pierre.boucher@uqo.ca -->2# Fundamental-value triangulation (WP 26)34Companion 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`).56## 1. Definitions (notation of theory.tex §subsec:values)78| Object | Definition | Where |9|---|---|---|10| $P_{t_1}$ | realised price of sale $(i,t_1)$ | `analytic.price` |11| $I(m,c,\tau)$ | chained monthly log-price index of municipality $m$ × class $c$ (region × class fallback), §2 | `price_index_monthly.parquet` |12| $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` |13| gap | $\log V^R_{t_1} - \log P_{t_1}$ (= $\varepsilon_{t_2} - \varepsilon_{t_1}$ under the repeat-sales assumptions) | idem |14| $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` |15| 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` |16| $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` |17| rent-implied value / price | $(R/u)/P = \text{gross yield}/u$ | idem |1819Everything 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.2021## 2. Repeat sales (`fundamental/repeat_sales.py`, `scripts/50_repeat_sales.py`)2223**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.2425**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).2627**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.2829**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.3031## 3. Rents (`fundamental/rents.py`, `scripts/52_rents_usercost.py`)3233**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.3435**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.3637**Median rents.** `rents_by_mun_year.csv`: municipality × year × rooms bucket (plus an `all` bucket), 5,713 cells.3839**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.4041## 4. User-cost diagnostic — parameter table4243| Parameter | Value | Status | Source |44|---|---|---|---|45| $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 |46| $\tau_p$, property tax | 1.0 % | assumption | Quebec effective municipal + school rates cluster around 0.7–1.2 % of market value |47| $\delta$, maintenance and depreciation | 2.5 % | assumption | Himmelberg, Mayer & Sinai (2005) |48| $\gamma$, risk and liquidity premium | 2.0 % | assumption | Himmelberg, Mayer & Sinai (2005) |49| $\pi^e$, expected nominal appreciation | 0 %, 2 %, 4 % | scenarios | bracket of long-run nominal growth |5051With 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.5253## 5. Arm comparison against the resale-implied value (`fundamental/resale_eval.py`, `scripts/51_eval_vs_resale.py`)5455For 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.5657## 6. Commands and outputs5859```bash60scp M4M64b:~/apps/immo-ka/data/rdl.db data/raw/external/        # once (15 MB)61python3 scripts/50_repeat_sales.py        # 4 s  → results/fundamental/{price_index_monthly,resale_targets,prior_targets}.parquet,62                                          #        resale_sample.{json,csv}, paper/tables/tab_resale_sample.tex, figures/fundamental_resale_gap.pdf63python3 scripts/52_rents_usercost.py      # 7 s  → leases_matched.parquet, leases_matching.json, rents_by_mun_year.csv, gross_yields.parquet,64                                          #        price_rent.csv, usercost_parameters.csv, paper/tables/tab_usercost_params.tex,65                                          #        figures/fundamental_price_rent.pdf, figures/fundamental_usercost.pdf   (--no-download → cache)66python3 scripts/51_eval_vs_resale.py      # 29 s with the baselines (B = 2000) → eval_vs_resale.csv, eval_vs_resale_contrasts.csv,67                                          #        eval_vs_resale.json, paper/tables/tab_resale.tex (NOT YET ESTIMATED row when no eligible run)68python3 -m pytest tests/test_fundamental.py -q [--real]69```7071`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.72
Files · main
Menu

Theme