SPB Git

spb/qwhpi Public

QHPI — Quebec Housing Price Index: quality-adjusted, hierarchically pooled housing price indexes.

Python 63.9% TypeScript 25.4% CSS 5.5% TeX 3.5% SQL 0.8% Makefile 0.5% Dockerfile 0.5%
24.3 KB

# 🏠 QHPI — Quebec Housing Price Index

# Quality-adjusted, hierarchically pooled housing price indexes for Quebec

Province · 17 administrative regions · major municipalities × {all, unifamilial, condo, plex}

Live Model Frequency Coverage Series Transactions

Python FastAPI Next.js PostgreSQL Docker LaTeX

Author Contact

Author : Simon-Pierre Boucher — contact@spboucher.ai


# Table of contents


# 🎯 What this is

QHPI is a production-grade economic-measurement platform, not a median-price tracker. It separates price movement from composition movement: a week where only mansions sell must not register as a price increase.

The platform has four layers:

Layer What it does Where
Index Engine Reproducible econometric pipeline: cleaning → hedonic estimation → hierarchical pooling → validation → canonical Parquet engine/
Data Store Versioned Parquet lake (aggregated series only) + PostgreSQL serving layer data/processed/, db/
API FastAPI service exposing every published series with uncertainty, reliability and vintages api/
Dashboard Interactive Next.js frontend — explore, compare, map, PDF reports, live at www.indexqc.house web/

Every published observation looks like this:

text
Geography: Quebec City | Type: Condo | Period: 2026-06
Index: 163.5 (2021 = 100) | 1m: +0.4% | YoY: +8.1%
Representative value: $389,200 | Transactions: 47 | Reliability: A
95% CI: [160.9, 166.1] | Vintage: 2026-08-08 | Model: 2.1.0

Uncertainty is never hidden: every row carries a 95% confidence interval, an A–E reliability grade, effective sample size and shrinkage weight.


# 📊 Key numbers

Transactions Period Cities Regions

Metric Value
Raw transactions ingested ~745,000 (2021-01 → present)
Estimation sample after documented exclusions ~720,000
Geographic units Province + 17 administrative regions + major municipalities
Property types unifamilial, condo, plex (+ all composite)
Published cells 81 — 50 direct liquid cells + 31 hierarchical thin cells
Headline frequency Monthly (v2.1) — weekly v1 retained as a research module
Base 2021 average = 100
Reliability grades A ≥150 tx/mo · B ≥75 · C ≥40 · D ≥15 · E <15 (model-implied)
Growth horizons 1m, 3m, 6m, YoY (weekly module: 1w, 4w, 13w, 26w, YoY)
Vintages first_release preserved; every revision queryable

# 🔒 Data policy — no microdata in this repo

This repository contains NO individual transaction data.

Category In repo? Detail
Raw transaction CSV (data/raw/) excluded Individual sale records — never versioned
Transaction-level intermediates (data/interim/, transactions_clean.parquet, geo_join.parquet) excluded Anything with one row per transaction
Cadastral boundary files (SDA.gpkg) ❌ excluded (size) Source, URL, version and CRS documented in data/external/SDA_SOURCE.md
Aggregated published series ✅ included qhpi_monthly.parquet, qwhpi_weekly.parquet, liquidity, coverage matrices, assessment gap, first releases, vintages
Aggregate tables & figures (outputs/) ✅ included Audit counts, model comparisons, validation results, publication figures

The .gitignore enforces this at the repo boundary. Aggregates are cell-level (geography × type × period) with transaction counts, never transaction rows. To rerun the full pipeline you must supply your own data/raw/province_transactions.csv (see Reproducibility).


# 🏗 Platform architecture

text
                     ┌─────────────────────────────────────────────┐
                     │              province_transactions.csv       │
                     │        (local only — NOT in this repo)       │
                     └──────────────────────┬──────────────────────┘

 ┌───────────────────────────── ENGINE (Python) ─────────────────────────────┐
 │  01 profile → 02 geography (spatial join, SDA boundaries) → 03 clean      │
 │  → 04–08 weekly research module (baseline, hierarchical, validation)      │
 │  → 09 monthly estimation (rolling time-dummy, Huber-IRLS)                 │
 │  → 10 monthly validation → 11 canonical parquet                           │
 └──────────────────────────────────┬────────────────────────────────────────┘

              data/processed/qhpi_monthly.parquet   ← source of truth
              (+ liquidity, coverage, vintages, assessment gap)

        ┌────────────── PostgreSQL (docker-compose, Alembic) ──────────────┐
        │   series · observations · geographies · liquidity · vintages     │
        └──────────────────────────────┬────────────────────────────────────┘

                      FastAPI  /v1/*  (uncertainty always returned)

                 Next.js dashboard — www.indexqc.house
                 (explore · compare · map · PDF reports · methodology)

Weekly refresh: ops/scheduler/ ingests new rows → append-only clean → re-estimation → new data_vintage → DB load → API cache invalidation.


# 🧪 Methodology

The index is a robust hedonic time-dummy index with hierarchical pooling — three estimation regimes arbitrated by empirical validation (repeat sales + stratified matched-cell medians), not by convenience:

# 1. Province × type backbone — rolling time-dummy (RTD)

  • Pooled robust regressions (log(price) on structural characteristics + fine location fixed effects + period dummies) over 13-month rolling windows with Huber-IRLS weighting.
  • Windows are combined by mean splice, making published history revision-free by construction.
  • Coefficient drift is monitored and published (outputs/tables/rtd_coefficient_drift.csv) — e.g. the floor-area elasticity moves 0.556 → 0.586 across windows, which a fixed pooled model would have hidden.

# 2. Liquid cells (median ≥ 40 tx/month) — direct local estimation

Each of the 50 liquid geography × type cells gets its own robust time-dummy regression (own hedonic coefficients, own FSA fixed effects), lightly smoothed by a state-space filter. This was arbitrated against deviation-from-pooled-surface modelling, which compressed genuine local divergence — Quebec City condos moved ~+55% (confirmed independently by repeat sales and stratified matched-cell medians) while pooled deviations reported only +42%.

# 3. Thin cells — hierarchical Kalman shrinkage

The remaining 31 thin cells follow a latent random-walk deviation from their parent's published path, estimated by Kalman filter/smoother with observation noise σ²/nₜ. Thin months shrink toward the parent trend; the shrinkage weight is published on every row.

# Hedonic specification (headline = structural only)

  • log(amount) on log(floorArea) (+ time-stable missingness indicator), age from yearBuilt via conditional-median imputation (its missingness drifts 15% → 2% over the sample, so a "missing" bin would leak time), and fine location fixed effects (FSA).
  • buildingType is banned from all hedonic models. The provider backfilled it in region-staggered waves (most regions 2022-01, Montreal 2023-01); the time-correlated missingness fabricated a −38 log-point cliff in local Montreal indexes. The ban is documented in engine/src/qwhpi/features.py.
  • Assessment values (totalArValue, previousValue) never enter the headline (valuation leakage); they power the separate Assessment Gap Index (amount / totalArValue).

# Cleaning

Raw data is immutable; every exclusion is economically justified and counted in outputs/tables/exclusions.csv (assessment-ratio band, exact duplicates, non-arm's-length transfers, …). Duplicates are flagged, never silently deleted. The indéterminé property type never contaminates type-specific indexes.

# Geography

Transactions are spatially joined (lat/lng) against the authoritative Quebec SDA cadastral boundaries (source, version and CRS documented in data/external/SDA_SOURCE.md) — the free-text city field alone is not trusted.


# ✅ Validation

Every claim below has a table or figure in outputs/:

Check Result
Repeat-sales benchmark (BMN, ~85k repeated addresses) June 2026, 2021=100: quebec/condo 132.0 vs RS 132 (exact); quebec-city/condo 163.5 vs RS 162; montreal/condo 121.6 vs RS 122; montreal/unifamilial 126.2 between stratified 120 and RS 135 (RS renovation bias on older stock)
Composition-shock simulation Mix shock moves the raw median +11.7%; the hedonic index moves −0.7% — the index measures prices, not composition
Downsampling experiment Montreal condos thinned to 100/50/25/15/10/5 tx/period; RMSE, bias, volatility, turning points and CI coverage justify the A–E liquidity tiers empirically
Weekly vs monthly Same methodology at both frequencies; signal-to-noise, turning-point detection and revision magnitude quantified in outputs/tables/weekly_vs_monthly.csv
Seasonality NSA published; no stable seasonality demonstrated (seasonality_tests.csv)
Alternative specs Hierarchical variants, mixed effects, ML residualization benchmarked in estimation_methods.csv — chosen model optimizes interpretability + stability, not transaction-level RMSE

Full write-ups: outputs/reports/ (audit, geography, cleaning, hierarchical, validation v1 & v2, methodology research).


# 🗺 Index families & coverage

Family Scope
QHPI-QC Province of Quebec
QHPI-REG 17 administrative regions
QHPI-CITY Montréal, Québec, Laval, Gatineau, Longueuil, Sherbrooke, Trois-Rivières, Saguenay, Lévis, Drummondville, … as liquidity permits
QHPI-TYPE All / Unifamilial / Condo / Plex per supported geography
Assessment Gap amount / totalArValue — separate concept, never mixed with the price index

The coverage matrix (data/processed/coverage_matrix_monthly.parquet, outputs/tables/coverage_monthly.csv) declares per municipality × type: published / conditional / not published.


# 🗃 Canonical dataset

data/processed/qhpi_monthly.parquet (7,504 rows) powers the DB, API and dashboard. Weekly research module: data/processed/qwhpi_weekly.parquet (32,592 rows).

text
period, geography_level, geography_id, geography_name, property_type,
index, index_smoothed, representative_value,
transactions, effective_sample_size,
monthly_pct, three_month_pct, six_month_pct, yoy_pct,
lower_95, upper_95, reliability_grade, shrinkage_weight,
is_partial_month, model_version, data_vintage
  • index = raw estimate, index_smoothed = one-sided (real-time safe) smoother — no look-ahead in the published real-time series.
  • representative_value = dollar value of a documented, fixed property basket per segment.
  • Vintage framework: first releases preserved under data/processed/vintages*/, revisions queryable via the API.

# 🔌 API

FastAPI service (OpenAPI docs at /docs), CSV/JSON export toggle, ETag caching, pagination, rate limiting, CORS. Reliability and CIs are always returned.

Endpoint Purpose
GET /v1/index?geography=quebec-city&type=condo&from=2021-01&to=latest Full series
GET /v1/index/latest?geography=…&type=… Latest observation
GET /v1/geographies · GET /v1/geographies/coverage Hierarchy + coverage matrix
GET /v1/liquidity?geography=…&type=… Transaction counts & effective N
GET /v1/compare?series=montreal:condo,quebec-city:condo Multi-series comparison
GET /v1/map?metric=yoy&level=region Choropleth payloads (+ period for time-lapse)
GET /v1/stats · GET /v1/stats/overview Peak/drawdown/CAGR/volatility/momentum/rank, heatmap rows
GET /v1/report?series=… · GET /v1/report/market Publication-grade PDF reports (matplotlib)
GET /v1/vintages?geography=…&type=… Revision history
GET /v1/meta model_version, data_vintage, methodology link, author credit
GET /v1/health Liveness

Sample response:

json
{
  "geography": "Quebec City",
  "property_type": "condo",
  "frequency": "monthly",
  "latest_index": 163.5,
  "representative_value": 389200,
  "monthly_change": 0.4,
  "yoy_change": 8.1,
  "transactions": 47,
  "reliability": "A",
  "lower_95": 160.9,
  "upper_95": 166.1,
  "is_partial_month": false,
  "author": "Simon-Pierre Boucher",
  "contact": "contact@spboucher.ai"
}

# 💻 Dashboard

Live at www.indexqc.house — Next.js + TypeScript, dark/light themes, fully responsive (audited at 390/360 px), prefers-reduced-motion respected, zero-dependency custom hero chart, ⌘K command palette over all 112 series.

Page Contents
/ Market pulse: heat-shaded region table with sparklines, 12 metric tiles, ticker tape, top movers, market-report PDF
/explore Series picker, CI band, raw-median & smoothed toggles, 1Y/3Y/5Y/YTD/MAX ranges, Bank-of-Canada event annotations, brush zoom, PNG/CSV export, sortable data table, shareable URLs
/compare Multi-series with rebasing tool, toggleable legend chips, comparison PDF
/map Region choropleth (YoY, 3m, level, assessment gap) with monthly slider + ▶ time-lapse
/methodology Rendered methodology + link to the paper
/api-docs Live playground (run requests in-page) + copyable curl examples

Low-reliability series are visually flagged, never hidden. Footer credit: Simon-Pierre Boucher — contact@spboucher.ai.


# 📁 Repository map

text
├── engine/                  # Index engine (Python)
│   ├── src/qwhpi/           #   ingest · clean · geography · features · hedonic
│   │                        #   rtd · hierarchy · state_space · repeat_sales
│   │                        #   index · uncertainty · seasonal · nowcast
│   │                        #   vintages · export · plotting
│   ├── scripts/             #   01_profile … 11_monthly_canonical (ordered, idempotent)
│   └── tests/               #   smoke pipeline, features/clean, state-space
├── data/
│   ├── external/            # boundary-source documentation (files excluded by size)
│   └── processed/           # ✅ aggregated series only (see Data policy)
├── db/                      # PostgreSQL schema + Alembic migrations
├── api/                     # FastAPI app: routers/ services/ tests/
├── web/                     # Next.js dashboard: app/ components/ lib/ styles/
├── ops/                     # weekly-refresh scheduler + CI pipeline
├── outputs/
│   ├── figures/             # publication figures (shared plotting config)
│   ├── tables/              # 35+ audit/validation/model tables (aggregates)
│   └── reports/             # markdown reports for every pipeline stage
├── paper/                   # LaTeX methodology paper (+ compiled PDF)
├── scripts/check_headers.py # CI gate: every file carries the author header
├── Makefile                 # one-command targets
└── docker-compose.yml       # postgres + api + web + scheduler

# 🚀 Quickstart

Requires the raw transaction CSV (not distributed — see Data policy) for make pipeline. The API and dashboard run off the included aggregated Parquet without it.

bash
# Engine (full run ~ audit → geography → clean → estimate → validate → canonical)
make pipeline

# Incremental monthly refresh (clean → estimate → canonical, ~2 min)
make refresh

# Services
make api          # FastAPI on :8080 — docs at /docs
make web          # Next.js dashboard on :3000
make up           # docker compose: postgres + api + web + scheduler
make down

# Quality
make test         # engine + api test suites
make lint         # ruff + mypy + tsc
make headers      # author-header check (also a pre-commit hook + CI gate)
make figures      # publication figures → outputs/figures/
make paper        # compile the LaTeX paper

# ⚙️ Engine pipeline

Script Stage
01_profile.py Data audit: schema, descriptives, missingness, sentinel values, liquidity matrices
02_geography.py SDA boundary spatial join, validation, persistence
03_clean.py Research sample with fully documented exclusions
04_baseline.py Weekly pooled hedonic time-dummy baseline (research module)
05_hierarchical.py Weekly hierarchical state-space shrinkage (research module)
06_validation.py Repeat sales, downsampling, composition shock (weekly)
07_canonical.py Weekly canonical Parquet (research module)
08_figures.py Publication figures
09_monthly.py Headline monthly estimation (RTD + direct cells + Kalman)
10_monthly_validation.py Monthly validation suite
11_monthly_canonical.py Canonical qhpi_monthly.parquet + vintage write

Every run emits a structured run manifest (input hash, row counts, model version, timings).


# 📐 Engineering standards

  • Every file starts with the author header — enforced by scripts/check_headers.py in pre-commit and CI.
  • Vectorized pandas/polars + numpy; no per-transaction Python loops; spatial joins, features and model matrices cached as Parquet.
  • Diagnostics for every production model: residual distribution, heteroskedasticity, temporal/spatial residual patterns, coefficient stability, effective N.
  • Model selection optimizes interpretability + stability + calibrated uncertainty + reproducibility — never transaction-level RMSE alone.
  • CI (ops/ci/ci.yml): lint, mypy + tsc, tests, header check, small-sample smoke run of the pipeline.

# 📄 Research outputs & paper

  • Figures (outputs/figures/): province aggregate, region comparison, big-4 cities, condo by city, raw median vs hedonic, volumes, reliability matrix, downsampling stability, assessment gap, YoY appreciation map.
  • Tables (outputs/tables/): audit suite, exclusions, liquidity, coverage, model comparison, repeat-sales comparison, downsampling, seasonality, coefficient drift, weekly-vs-monthly.
  • Paper (paper/qwhpi.pdf): A High-Frequency Hedonic Housing Price Index for Quebec — real, researched citations only (hedonic and repeat-sales index theory, Case-Shiller, hierarchical and state-space indexes, spatial hedonics, index-number theory).

# 🔁 Reproducibility

  1. Place your transaction extract at data/raw/province_transactions.csv (columns: id, date, amount, street, zipCode, city, lat, lng, propertyType, yearBuilt, floorArea, buildingType, previousValue, totalArValue, ownerType).
  2. Download the SDA boundary file per data/external/SDA_SOURCE.md.
  3. make pipeline — deterministic given the same inputs; every run is stamped with model_version + data_vintage and manifested.

Without microdata, everything downstream of the canonical Parquet (DB load, API, dashboard, figures) is fully runnable from the aggregates included here.


# 👤 Author & license

Simon-Pierre Boucher

Email Site

Code, methodology, figures and documentation © Simon-Pierre Boucher. All rights reserved — contact the author for reuse, data questions or collaboration. The repository intentionally distributes no individual transaction records; published aggregates carry full uncertainty metadata so they are never mistaken for more precision than the data supports.