Initial commit — QHPI platform (engine, API, dashboard, paper)
Quebec Housing Price Index v2.1: hedonic rolling-time-dummy engine with hierarchical pooling, FastAPI service, Next.js dashboard, validation suite and methodology paper. Aggregated series only — no individual transaction data is versioned (enforced by .gitignore). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 164 changed files with +19,433 and −0
added
.gitignore
+55 −0
@@ -0,0 +1,55 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : .gitignore | |
| 6 | +# Purpose : Keep transaction-level data, build artifacts and caches out of git. | |
| 7 | +# ============================================================================= | |
| 8 | + | |
| 9 | +# --------------------------------------------------------------------------- | |
| 10 | +# DATA POLICY — individual transaction data NEVER enters the repository. | |
| 11 | +# Only aggregated, published series (indexes, liquidity, coverage, vintages) | |
| 12 | +# are versioned. See README § Data policy. | |
| 13 | +# --------------------------------------------------------------------------- | |
| 14 | +data/raw/ | |
| 15 | +data/interim/ | |
| 16 | +data/processed/transactions_clean.parquet | |
| 17 | +data/processed/geo_join.parquet | |
| 18 | + | |
| 19 | +# Large external boundary files (documented in data/external/SDA_SOURCE.md) | |
| 20 | +data/external/*.gpkg | |
| 21 | +data/external/*.gpkg.zip | |
| 22 | + | |
| 23 | +# Python | |
| 24 | +__pycache__/ | |
| 25 | +*.py[cod] | |
| 26 | +*.egg-info/ | |
| 27 | +.venv/ | |
| 28 | +venv/ | |
| 29 | +.pytest_cache/ | |
| 30 | +.mypy_cache/ | |
| 31 | +.ruff_cache/ | |
| 32 | + | |
| 33 | +# Node / Next.js | |
| 34 | +node_modules/ | |
| 35 | +web/.next/ | |
| 36 | +web/out/ | |
| 37 | +*.tsbuildinfo | |
| 38 | +web/next-env.d.ts | |
| 39 | + | |
| 40 | +# LaTeX build artifacts (keep qwhpi.tex, references.bib, qwhpi.pdf) | |
| 41 | +paper/*.aux | |
| 42 | +paper/*.log | |
| 43 | +paper/*.out | |
| 44 | +paper/*.fls | |
| 45 | +paper/*.fdb_latexmk | |
| 46 | +paper/*.blg | |
| 47 | +paper/*.bbl | |
| 48 | +paper/*.toc | |
| 49 | + | |
| 50 | +# OS / editor / local | |
| 51 | +.DS_Store | |
| 52 | +.claude/ | |
| 53 | +.env | |
| 54 | +.env.* | |
| 55 | +*.local.json | |
added
.pre-commit-config.yaml
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : .pre-commit-config.yaml | |
| 6 | +# Purpose : Pre-commit hooks — mandatory header check, lint, type hygiene. | |
| 7 | +# ============================================================================= | |
| 8 | + | |
| 9 | +repos: | |
| 10 | + - repo: local | |
| 11 | + hooks: | |
| 12 | + - id: qwhpi-headers | |
| 13 | + name: QWHPI author-header check | |
| 14 | + entry: python3 scripts/check_headers.py | |
| 15 | + language: system | |
| 16 | + pass_filenames: true | |
| 17 | + types_or: [python, ts, tsx, javascript, css, sql, yaml, toml, shell] | |
| 18 | + - repo: https://github.com/astral-sh/ruff-pre-commit | |
| 19 | + rev: v0.6.9 | |
| 20 | + hooks: | |
| 21 | + - id: ruff | |
| 22 | + args: [--fix] | |
added
CLAUDE.md
+325 −0
@@ -0,0 +1,325 @@ | ||
| 1 | +# CLAUDE.md — QWHPI Platform | |
| 2 | +# Quebec Weekly Housing Price Index — Full Platform Build | |
| 3 | + | |
| 4 | +--- | |
| 5 | + | |
| 6 | +## 0. MANDATORY FILE HEADERS — READ FIRST | |
| 7 | + | |
| 8 | +Every single file created in this project — Python, TypeScript, JavaScript, SQL, YAML, TOML, shell scripts, LaTeX, CSS, Dockerfiles, config files, notebooks — MUST begin with an author header. | |
| 9 | + | |
| 10 | +**Python / shell / YAML / TOML / Dockerfile:** | |
| 11 | + | |
| 12 | +```python | |
| 13 | +# ============================================================================= | |
| 14 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 15 | +# Author : Simon-Pierre Boucher | |
| 16 | +# Contact : contact@spboucher.ai | |
| 17 | +# File : <relative/path/to/file.py> | |
| 18 | +# Purpose : <one-line description> | |
| 19 | +# ============================================================================= | |
| 20 | +``` | |
| 21 | + | |
| 22 | +**TypeScript / JavaScript / TSX / CSS:** | |
| 23 | + | |
| 24 | +```ts | |
| 25 | +/** | |
| 26 | + * ============================================================================= | |
| 27 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 28 | + * Author : Simon-Pierre Boucher | |
| 29 | + * Contact : contact@spboucher.ai | |
| 30 | + * File : <relative/path/to/file.ts> | |
| 31 | + * Purpose : <one-line description> | |
| 32 | + * ============================================================================= | |
| 33 | + */ | |
| 34 | +``` | |
| 35 | + | |
| 36 | +**SQL:** | |
| 37 | + | |
| 38 | +```sql | |
| 39 | +-- ============================================================================= | |
| 40 | +-- QWHPI — Quebec Weekly Housing Price Index | |
| 41 | +-- Author : Simon-Pierre Boucher | |
| 42 | +-- Contact : contact@spboucher.ai | |
| 43 | +-- File : <relative/path/to/file.sql> | |
| 44 | +-- Purpose : <one-line description> | |
| 45 | +-- ============================================================================= | |
| 46 | +``` | |
| 47 | + | |
| 48 | +**Markdown / LaTeX:** include an equivalent comment or front-matter block at the top. | |
| 49 | + | |
| 50 | +Rules: | |
| 51 | +- No file ships without this header. Add it at file creation time, not as a cleanup pass. | |
| 52 | +- The `File` and `Purpose` lines must be accurate and kept up to date. | |
| 53 | +- Add a pre-commit hook (`scripts/check_headers.py`) that fails CI if any tracked source file is missing the header. | |
| 54 | +- API responses and the frontend footer must credit: **Simon-Pierre Boucher — contact@spboucher.ai**. | |
| 55 | + | |
| 56 | +--- | |
| 57 | + | |
| 58 | +## 1. Mission | |
| 59 | + | |
| 60 | +Build a complete, production-grade **platform** around the Quebec Weekly Housing Price Index — not just a research pipeline. The platform has four layers: | |
| 61 | + | |
| 62 | +1. **Index Engine** — reproducible econometric pipeline producing quality-adjusted weekly indexes from `province_transactions.csv`. | |
| 63 | +2. **Data Store** — versioned Parquet lake + relational database serving the API. | |
| 64 | +3. **API** — FastAPI service exposing every published series with metadata, uncertainty, and reliability. | |
| 65 | +4. **Dashboard** — interactive web frontend (charts, maps, comparisons, downloads). | |
| 66 | + | |
| 67 | +The index must NOT be a median-price tracker. It is a **hierarchical hedonic time-dummy index**: pooled hedonic estimation of characteristics, plus a latent weekly market state per `geography × property_type`, with partial pooling/shrinkage toward regional trends when weekly liquidity is thin. | |
| 68 | + | |
| 69 | +Target queryable observation: | |
| 70 | + | |
| 71 | +``` | |
| 72 | +Geography: Quebec City | Type: Condo | Week: 2024-05-06 | |
| 73 | +Index: 137.42 | 1w: +0.31% | 4w: +1.18% | YoY: +6.74% | |
| 74 | +Representative value: $389,200 | Transactions: 47 | Reliability: A | |
| 75 | +``` | |
| 76 | + | |
| 77 | +--- | |
| 78 | + | |
| 79 | +## 2. Dataset | |
| 80 | + | |
| 81 | +`province_transactions.csv` — ~745,119 transactions, 291 weeks, 2021-01-04 → 2026-07-27, 1,100+ cities. | |
| 82 | + | |
| 83 | +Columns: `id, date, amount, street, zipCode, city, lat, lng, propertyType, yearBuilt, floorArea, buildingType, previousValue, totalArValue, ownerType` | |
| 84 | + | |
| 85 | +Property types: `unifamilial` (~443k), `condo` (~100k), `plex` (~94k), `indéterminé` (~108k). `indéterminé` must never contaminate type-specific indexes; study what it represents and document it in an appendix. | |
| 86 | + | |
| 87 | +Several hedonic characteristics have missing values. Handle missingness explicitly — never `dropna()` the sample. | |
| 88 | + | |
| 89 | +--- | |
| 90 | + | |
| 91 | +## 3. Repository Structure (Monorepo) | |
| 92 | + | |
| 93 | +``` | |
| 94 | +qwhpi-platform/ | |
| 95 | +├── CLAUDE.md | |
| 96 | +├── README.md | |
| 97 | +├── Makefile # one-command targets: make pipeline, make api, make web | |
| 98 | +├── docker-compose.yml # db + api + web + scheduler | |
| 99 | +├── .pre-commit-config.yaml | |
| 100 | +│ | |
| 101 | +├── engine/ # Index Engine (Python) | |
| 102 | +│ ├── pyproject.toml | |
| 103 | +│ ├── src/qwhpi/ | |
| 104 | +│ │ ├── config.py ingest.py clean.py geography.py features.py | |
| 105 | +│ │ ├── hedonic.py hierarchy.py state_space.py repeat_sales.py | |
| 106 | +│ │ ├── index.py uncertainty.py reliability.py validation.py | |
| 107 | +│ │ ├── seasonal.py nowcast.py vintages.py export.py | |
| 108 | +│ ├── scripts/ # 01_profile … 10_build_report (ordered, idempotent) | |
| 109 | +│ ├── tests/ | |
| 110 | +│ └── notebooks/exploratory_only/ | |
| 111 | +│ | |
| 112 | +├── data/ | |
| 113 | +│ ├── raw/ external/ interim/ processed/ # raw is immutable | |
| 114 | +│ | |
| 115 | +├── db/ | |
| 116 | +│ ├── migrations/ # Alembic | |
| 117 | +│ └── schema.sql | |
| 118 | +│ | |
| 119 | +├── api/ # FastAPI service | |
| 120 | +│ ├── pyproject.toml | |
| 121 | +│ ├── app/ | |
| 122 | +│ │ ├── main.py deps.py models.py schemas.py | |
| 123 | +│ │ ├── routers/ (index.py, geographies.py, liquidity.py, maps.py, meta.py) | |
| 124 | +│ │ └── services/ | |
| 125 | +│ └── tests/ | |
| 126 | +│ | |
| 127 | +├── web/ # Frontend (Next.js + TypeScript) | |
| 128 | +│ ├── package.json | |
| 129 | +│ ├── app/ # routes: /, /explore, /compare, /map, /methodology, /api-docs | |
| 130 | +│ ├── components/ lib/ styles/ | |
| 131 | +│ └── tests/ | |
| 132 | +│ | |
| 133 | +├── ops/ | |
| 134 | +│ ├── scheduler/ # weekly refresh job | |
| 135 | +│ └── ci/ | |
| 136 | +│ | |
| 137 | +├── outputs/ # figures, tables, maps, reports | |
| 138 | +├── paper/ # LaTeX methodology paper | |
| 139 | +└── scripts/check_headers.py | |
| 140 | +``` | |
| 141 | + | |
| 142 | +Production logic lives in `engine/src`, `api/app`, `web/` — never scattered in notebooks. | |
| 143 | + | |
| 144 | +--- | |
| 145 | + | |
| 146 | +## 4. Index Engine — Methodology (non-negotiable core) | |
| 147 | + | |
| 148 | +### 4.1 Weekly calendar | |
| 149 | +Monday→Sunday weeks, each labeled by its Monday. Continuous grid — never drop zero-transaction weeks; flag them. | |
| 150 | + | |
| 151 | +### 4.2 Geography | |
| 152 | +Hierarchy: Quebec → 17 administrative regions → municipality → (optional) local zone. Spatial-join `lat/lng` against an authoritative Quebec/StatCan boundary dataset (document source, URL, version, CRS). Persist the join once in `data/processed/`. Do not trust the `city` text field alone. | |
| 153 | + | |
| 154 | +### 4.3 Cleaning | |
| 155 | +Reproducible pipeline; raw CSV untouched. Use `log(amount)`. Investigate zeros, implausible prices, non-arm's-length transfers, duplicates (flag with `duplicate_flag`/`duplicate_reason`, never silently delete). Produce a full exclusion table. Economically justified filters only — no unreported winsorizing. | |
| 156 | + | |
| 157 | +### 4.4 Hedonic model | |
| 158 | +Pool 2021–present. Do NOT run independent weekly regressions. | |
| 159 | + | |
| 160 | +- **Model A (headline):** structural characteristics only — type, log(floorArea), age (spline/bins, data-driven vintage breakpoints), buildingType, fine location controls. | |
| 161 | +- **Model B (robustness):** adds `totalArValue`, `previousValue` — beware valuation leakage; never the headline. | |
| 162 | + | |
| 163 | +Missingness: compare missing-indicator, conditional-median imputation, MICE, ML imputation. No future-price leakage. Choose stability + interpretability. | |
| 164 | + | |
| 165 | +### 4.5 Location control — critical | |
| 166 | +Compare municipality FE, postal-code FE, H3/geohash grid, 2D splines on lat/lng, hierarchical spatial effects. Westmount ≠ rest of Montreal. Diagnose residual spatial autocorrelation; improve until it is acceptable. | |
| 167 | + | |
| 168 | +### 4.6 Hierarchical weekly state | |
| 169 | +Latent path per `geography × property_type`: | |
| 170 | + | |
| 171 | +``` | |
| 172 | +Quebec trend + region deviation + municipality deviation + type deviation + local week deviation | |
| 173 | +``` | |
| 174 | + | |
| 175 | +Thin cells shrink toward parent trends; liquid cells (Montreal condo ~145/wk, Quebec City condo ~42/wk) are dominated by local data. Evaluate: Bayesian multilevel, empirical Bayes, mixed effects, state-space/Kalman (`μ_t = μ_{t-1} + η_t`), penalized splines. Benchmark stability and compute cost; do not choose complexity for its own sake. Also benchmark a two-stage architecture (structural hedonic residualization → hierarchical weekly time-series) against a unified time-dummy regression. | |
| 176 | + | |
| 177 | +### 4.7 Validation (mandatory) | |
| 178 | +- Repeat-sales index (~85k repeated addresses; beware condo/plex address sharing) as directional check. | |
| 179 | +- Downsampling experiment: use Montreal condos as lab; thin to 100/50/25/15/10/5 tx/week; measure RMSE, bias, volatility, turning-point accuracy, CI coverage. This empirically justifies liquidity tiers (starting hypotheses: ≥50 very strong, 20–49 strong, 10–19 shrinkage, 5–9 heavy shrinkage, <5 model-implied). | |
| 180 | +- Composition-shock simulation: raw median must move, hedonic index must not. | |
| 181 | +- Temporal + geographic holdouts; alternative specs (GAM, mixed effects, hierarchical Bayes, gradient-boosting residualization). ML (LightGBM/CatBoost/XGBoost) may estimate the cross-sectional component only — the time effect stays interpretable. | |
| 182 | +- Weekly vs monthly comparison from the same methodology: quantify signal-to-noise, turning-point detection, revision magnitude. | |
| 183 | + | |
| 184 | +### 4.8 Outputs per series | |
| 185 | +Base: 2021 average = 100 (keep raw latent log series). Both `index` (raw weekly) and `index_smoothed` (one-sided real-time version required alongside any two-sided smoother). Representative dollar value per segment from a documented property basket. Growth: 1w, 4w, 13w, 26w, YoY, YTD, since-2021. 95% CIs (cluster/block bootstrap or posterior). Reliability grades A–E from n, effective N, SE, shrinkage weight, missingness. NSA always; SA only if stable seasonality is demonstrated. `is_partial_week` nowcast handling for the latest week. Vintage framework: `first_release`, `current_vintage`, `revision`. | |
| 186 | + | |
| 187 | +### 4.9 Canonical dataset | |
| 188 | + | |
| 189 | +`data/processed/qwhpi_weekly.parquet`: | |
| 190 | + | |
| 191 | +``` | |
| 192 | +week, geography_level, geography_id, geography_name, property_type, | |
| 193 | +index, index_smoothed, representative_value, | |
| 194 | +transactions, effective_sample_size, | |
| 195 | +weekly_pct, four_week_pct, thirteen_week_pct, yoy_pct, | |
| 196 | +lower_95, upper_95, reliability_grade, shrinkage_weight, | |
| 197 | +is_partial_week, model_version, data_vintage | |
| 198 | +``` | |
| 199 | + | |
| 200 | +This single table powers the database, API, and dashboard. | |
| 201 | + | |
| 202 | +### 4.10 Index families | |
| 203 | +- **QWHPI-QC** (province), **QWHPI-REG** (17 regions), **QWHPI-CITY** (Montréal, Québec, Laval, Gatineau, Longueuil, Sherbrooke, Trois-Rivières, Saguenay, Lévis, Drummondville — as liquidity permits), **QWHPI-TYPE** (All / Single-family / Condo / Plex per supported geography). | |
| 204 | +- Secondary module: **Assessment Gap Index** from `amount / totalArValue` — separate concept, never mixed with the price index. | |
| 205 | +- Coverage matrix declaring, per municipality × type: published / conditional / not published. | |
| 206 | + | |
| 207 | +--- | |
| 208 | + | |
| 209 | +## 5. Data Store | |
| 210 | + | |
| 211 | +- Parquet lake in `data/processed/` (partitioned by geography_level / year) is the source of truth. | |
| 212 | +- PostgreSQL (via docker-compose) serves the API: tables `series`, `observations`, `geographies`, `liquidity`, `vintages`, `model_runs`. Alembic migrations in `db/migrations/`. | |
| 213 | +- Loader `engine/src/qwhpi/export.py` upserts each pipeline run into the DB with `model_version` and `data_vintage`. | |
| 214 | +- Never let the API read raw CSVs. | |
| 215 | + | |
| 216 | +--- | |
| 217 | + | |
| 218 | +## 6. API (FastAPI) | |
| 219 | + | |
| 220 | +Endpoints: | |
| 221 | + | |
| 222 | +``` | |
| 223 | +GET /v1/index?geography=quebec-city&type=condo&from=2021-01-04&to=latest | |
| 224 | +GET /v1/index/latest?geography=...&type=... | |
| 225 | +GET /v1/geographies # hierarchy + coverage matrix | |
| 226 | +GET /v1/liquidity?geography=...&type=... | |
| 227 | +GET /v1/compare?series=montreal:condo,quebec-city:condo | |
| 228 | +GET /v1/map?metric=yoy&level=region | |
| 229 | +GET /v1/vintages?geography=...&type=...&week=... | |
| 230 | +GET /v1/meta # model_version, data_vintage, methodology link, author credit | |
| 231 | +GET /health | |
| 232 | +``` | |
| 233 | + | |
| 234 | +Sample response: | |
| 235 | + | |
| 236 | +```json | |
| 237 | +{ | |
| 238 | + "geography": "Quebec City", | |
| 239 | + "property_type": "condo", | |
| 240 | + "frequency": "weekly", | |
| 241 | + "latest_index": 137.42, | |
| 242 | + "representative_value": 389200, | |
| 243 | + "weekly_change": 0.31, | |
| 244 | + "yoy_change": 6.74, | |
| 245 | + "transactions": 47, | |
| 246 | + "reliability": "A", | |
| 247 | + "lower_95": 135.9, | |
| 248 | + "upper_95": 138.9, | |
| 249 | + "is_partial_week": false, | |
| 250 | + "author": "Simon-Pierre Boucher", | |
| 251 | + "contact": "contact@spboucher.ai" | |
| 252 | +} | |
| 253 | +``` | |
| 254 | + | |
| 255 | +Requirements: Pydantic schemas, OpenAPI docs at `/docs`, pagination for full histories, CSV/JSON export toggle, ETag caching, rate limiting, CORS for the web app, tests for every router. Reliability and CIs are always returned — the API never hides uncertainty. | |
| 256 | + | |
| 257 | +--- | |
| 258 | + | |
| 259 | +## 7. Dashboard (Next.js + TypeScript) | |
| 260 | + | |
| 261 | +Pages: | |
| 262 | +- **/** — headline: Quebec aggregate, latest week, YoY, sparkline, top movers. | |
| 263 | +- **/explore** — series picker (geography tree × property type), weekly chart with CI band, raw-median overlay toggle, smoothed toggle, growth-horizon selector, transaction-volume subchart, reliability badge, CSV download. | |
| 264 | +- **/compare** — multi-series comparison (e.g., condo across major cities), rebasing tool. | |
| 265 | +- **/map** — choropleth of the 17 regions (YoY, 13w, index level, assessment gap); H3 heat map for major urban areas where density allows. | |
| 266 | +- **/methodology** — rendered methodology summary; link to the paper. | |
| 267 | +- **/api-docs** — link/embed of OpenAPI docs. | |
| 268 | + | |
| 269 | +Requirements: charting via a solid library (e.g., ECharts/Recharts/Plotly), responsive, dark/light, consistent color config shared with engine figures, loading/empty/error states, low-reliability series visually flagged (never hidden), footer credit "Simon-Pierre Boucher — contact@spboucher.ai". | |
| 270 | + | |
| 271 | +--- | |
| 272 | + | |
| 273 | +## 8. Automation & Ops | |
| 274 | + | |
| 275 | +- `make pipeline` → full engine run; `make refresh` → incremental update when new transactions are appended; `make api` / `make web` / `make up` (docker-compose). | |
| 276 | +- Scheduler (`ops/scheduler/`) runs the weekly refresh: ingest new rows → append-only clean → re-estimate weekly states → write new `data_vintage` → load DB → invalidate API cache. | |
| 277 | +- CI: lint, type-check (mypy + tsc), tests, **header check**, small-sample smoke run of the pipeline. | |
| 278 | +- Structured logging + run manifest (input hash, row counts, model version, timings) for every pipeline execution. | |
| 279 | + | |
| 280 | +--- | |
| 281 | + | |
| 282 | +## 9. Engineering Standards | |
| 283 | + | |
| 284 | +- Python: pandas/polars, numpy, statsmodels, scikit-learn, geopandas, pyarrow; PyMC/CmdStanPy or statsmodels state-space where justified. Vectorized; no per-transaction Python loops; cache spatial joins, features, model matrices; Parquet everywhere. | |
| 285 | +- Diagnostics for every production model: residual distribution, heteroskedasticity, residuals-vs-fitted, temporal/spatial residual patterns, coefficient stability, weekly SEs, effective N. | |
| 286 | +- Model selection optimizes interpretability + stability + low bias + responsiveness + calibrated uncertainty + reproducibility — NOT transaction-level RMSE alone. This is economic measurement, not a prediction contest. | |
| 287 | +- Core principle everywhere: separate **price movement** from **composition movement**. Never present a weekly average price as a price level. Never fake precision — expose n, effective N, CI, shrinkage weight, reliability. | |
| 288 | + | |
| 289 | +--- | |
| 290 | + | |
| 291 | +## 10. Research Outputs | |
| 292 | + | |
| 293 | +- `outputs/figures/`: Quebec aggregate; region comparison; big-4 city comparison; condo/single-family/plex by city; raw median vs hedonic; volumes; CI bands; reliability map; repeat-sales vs hedonic; downsampling stability; assessment-gap evolution; YoY appreciation map. One shared plotting config. | |
| 294 | +- `outputs/tables/`: data_summary, missingness, exclusions, weekly_liquidity, coverage, model_comparison, index_latest, repeat_sales_comparison, downsampling_results, revision_statistics. | |
| 295 | +- `paper/`: full LaTeX paper — *A High-Frequency Hedonic Housing Price Index for Quebec* — real, researched citations only (hedonic indexes, repeat sales, Case-Shiller, high-frequency measurement, hierarchical indexes, state-space indexes, spatial hedonics, index-number theory). No fabricated references. | |
| 296 | +- Research questions to actually investigate: regional heterogeneity post-2021; synchronization of turning points; Montreal lead/lag; type-level response speeds; weekly-vs-monthly information gain; minimum transactions for reliability; value of hierarchical pooling; assessment lag dynamics. | |
| 297 | + | |
| 298 | +--- | |
| 299 | + | |
| 300 | +## 11. Execution Order | |
| 301 | + | |
| 302 | +1. **Audit** — schema, descriptives, missingness, outliers, coverage, weekly liquidity matrices. No index yet. | |
| 303 | +2. **Geography** — boundary acquisition, spatial join, validation, persistence. | |
| 304 | +3. **Cleaning** — research sample with documented exclusions. | |
| 305 | +4. **Baseline index** — transparent pooled hedonic time-dummy for Quebec + Montreal/Québec/Laval/Gatineau × All/Unifamilial/Condo/Plex. | |
| 306 | +5. **Hierarchical weekly model** — shrinkage for thin segments. | |
| 307 | +6. **Validation** — repeat sales, downsampling, monthly comparison, alternative specs. | |
| 308 | +7. **Scale** — all supported region and municipality series; coverage matrix. | |
| 309 | +8. **Data store + API** — DB schema, loader, FastAPI, tests. | |
| 310 | +9. **Dashboard** — all pages against the live API. | |
| 311 | +10. **Automation + research outputs** — scheduler, CI, figures, tables, paper. | |
| 312 | + | |
| 313 | +Work autonomously: inspect data → research accepted methodology → implement the most defensible option → document → benchmark alternatives → preserve reversibility. Do not settle for the first model that runs. Do not stop to ask permission on minor modeling choices. | |
| 314 | + | |
| 315 | +--- | |
| 316 | + | |
| 317 | +## 12. Definition of Success | |
| 318 | + | |
| 319 | +One reproducible command (`make up` after `make pipeline`) yields: | |
| 320 | + | |
| 321 | +- a trustworthy weekly, quality-adjusted, hierarchically pooled index for every statistically defensible `geography × property_type`, 2021 → latest week, with CIs, reliability grades, representative dollar values, validation, and vintages; | |
| 322 | +- a documented API serving it; | |
| 323 | +- an interactive dashboard displaying it; | |
| 324 | +- publication-quality figures, tables, and a methodology paper; | |
| 325 | +- **every file in the repository headed with: Simon-Pierre Boucher — contact@spboucher.ai.** | |
added
Makefile
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : Makefile | |
| 6 | +# Purpose : One-command targets — pipeline, refresh, api, web, docker, tests. | |
| 7 | +# ============================================================================= | |
| 8 | + | |
| 9 | +PY := python3 | |
| 10 | +ENGINE := engine/scripts | |
| 11 | + | |
| 12 | +.PHONY: pipeline refresh api web up down test lint headers figures paper clean | |
| 13 | + | |
| 14 | +## Full engine run: audit -> geography -> clean -> baseline -> hierarchical | |
| 15 | +## -> validation -> canonical dataset (+ DB load when DATABASE_URL is set) | |
| 16 | +pipeline: | |
| 17 | + $(PY) $(ENGINE)/01_profile.py | |
| 18 | + $(PY) $(ENGINE)/02_geography.py | |
| 19 | + $(PY) $(ENGINE)/03_clean.py | |
| 20 | + $(PY) $(ENGINE)/04_baseline.py | |
| 21 | + $(PY) $(ENGINE)/05_hierarchical.py | |
| 22 | + $(PY) $(ENGINE)/06_validation.py | |
| 23 | + $(PY) $(ENGINE)/07_canonical.py | |
| 24 | + $(PY) $(ENGINE)/09_monthly.py | |
| 25 | + $(PY) $(ENGINE)/10_monthly_validation.py | |
| 26 | + $(PY) $(ENGINE)/11_monthly_canonical.py | |
| 27 | + $(PY) -c "import sys; sys.path.insert(0,'engine/src'); from qwhpi.export import load_database; load_database()" | |
| 28 | + | |
| 29 | +## Incremental refresh (new rows appended to the raw CSV) — monthly headline | |
| 30 | +refresh: | |
| 31 | + $(PY) $(ENGINE)/03_clean.py | |
| 32 | + $(PY) $(ENGINE)/09_monthly.py | |
| 33 | + $(PY) $(ENGINE)/11_monthly_canonical.py | |
| 34 | + $(PY) -c "import sys; sys.path.insert(0,'engine/src'); from qwhpi.export import load_database; load_database()" | |
| 35 | + | |
| 36 | +api: | |
| 37 | + cd api && $(PY) -m uvicorn app.main:app --host 0.0.0.0 --port 8080 | |
| 38 | + | |
| 39 | +web: | |
| 40 | + cd web && npm run build && npm run start | |
| 41 | + | |
| 42 | +up: | |
| 43 | + docker compose up --build -d | |
| 44 | + | |
| 45 | +down: | |
| 46 | + docker compose down | |
| 47 | + | |
| 48 | +test: | |
| 49 | + cd api && $(PY) -m pytest tests -q | |
| 50 | + cd engine && $(PY) -m pytest tests -q | |
| 51 | + | |
| 52 | +headers: | |
| 53 | + $(PY) scripts/check_headers.py | |
| 54 | + | |
| 55 | +figures: | |
| 56 | + $(PY) $(ENGINE)/08_figures.py | |
| 57 | + | |
| 58 | +paper: | |
| 59 | + cd paper && latexmk -pdf -quiet qwhpi.tex || pdflatex qwhpi.tex | |
| 60 | + | |
| 61 | +lint: | |
| 62 | + $(PY) scripts/check_headers.py | |
| 63 | + cd web && npx tsc --noEmit | |
added
README.md
+501 −0
@@ -0,0 +1,501 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +Author : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +File : README.md | |
| 7 | +Purpose : Full platform documentation — methodology, architecture, API, | |
| 8 | + dashboard, data policy, reproducibility. | |
| 9 | +============================================================================= | |
| 10 | +--> | |
| 11 | + | |
| 12 | +<div align="center"> | |
| 13 | + | |
| 14 | +# 🏠 QHPI — Quebec Housing Price Index | |
| 15 | + | |
| 16 | +### Quality-adjusted, hierarchically pooled housing price indexes for Quebec | |
| 17 | + | |
| 18 | +*Province · 17 administrative regions · major municipalities × {all, unifamilial, condo, plex}* | |
| 19 | + | |
| 20 | +[](https://www.indexqc.house) | |
| 21 | +[](#-methodology) | |
| 22 | +[%20%2B%20weekly%20(research)-8a2be2?style=flat-square)](#-methodology) | |
| 23 | +[](#-index-families--coverage) | |
| 24 | +[-orange?style=flat-square)](#-methodology) | |
| 25 | +[](#-data-policy--no-microdata-in-this-repo) | |
| 26 | + | |
| 27 | +[](engine/pyproject.toml) | |
| 28 | +[](api/) | |
| 29 | +[](web/) | |
| 30 | +[](db/) | |
| 31 | +[](docker-compose.yml) | |
| 32 | +[](paper/) | |
| 33 | + | |
| 34 | +[](mailto:contact@spboucher.ai) | |
| 35 | +[](mailto:contact@spboucher.ai) | |
| 36 | + | |
| 37 | +**Author : Simon-Pierre Boucher — [contact@spboucher.ai](mailto:contact@spboucher.ai)** | |
| 38 | + | |
| 39 | +</div> | |
| 40 | + | |
| 41 | +--- | |
| 42 | + | |
| 43 | +## Table of contents | |
| 44 | + | |
| 45 | +- [What this is](#-what-this-is) | |
| 46 | +- [Key numbers](#-key-numbers) | |
| 47 | +- [Data policy — no microdata in this repo](#-data-policy--no-microdata-in-this-repo) | |
| 48 | +- [Platform architecture](#-platform-architecture) | |
| 49 | +- [Methodology](#-methodology) | |
| 50 | +- [Validation](#-validation) | |
| 51 | +- [Index families & coverage](#-index-families--coverage) | |
| 52 | +- [Canonical dataset](#-canonical-dataset) | |
| 53 | +- [API](#-api) | |
| 54 | +- [Dashboard](#-dashboard) | |
| 55 | +- [Repository map](#-repository-map) | |
| 56 | +- [Quickstart](#-quickstart) | |
| 57 | +- [Engine pipeline](#-engine-pipeline) | |
| 58 | +- [Engineering standards](#-engineering-standards) | |
| 59 | +- [Research outputs & paper](#-research-outputs--paper) | |
| 60 | +- [Reproducibility](#-reproducibility) | |
| 61 | +- [Author & license](#-author--license) | |
| 62 | + | |
| 63 | +--- | |
| 64 | + | |
| 65 | +## 🎯 What this is | |
| 66 | + | |
| 67 | +QHPI is a **production-grade economic-measurement platform**, not a median-price | |
| 68 | +tracker. It separates **price movement** from **composition movement**: a week | |
| 69 | +where only mansions sell must not register as a price increase. | |
| 70 | + | |
| 71 | +The platform has four layers: | |
| 72 | + | |
| 73 | +| Layer | What it does | Where | | |
| 74 | +|---|---|---| | |
| 75 | +| **Index Engine** | Reproducible econometric pipeline: cleaning → hedonic estimation → hierarchical pooling → validation → canonical Parquet | [`engine/`](engine/) | | |
| 76 | +| **Data Store** | Versioned Parquet lake (aggregated series only) + PostgreSQL serving layer | [`data/processed/`](data/processed/), [`db/`](db/) | | |
| 77 | +| **API** | FastAPI service exposing every published series with uncertainty, reliability and vintages | [`api/`](api/) | | |
| 78 | +| **Dashboard** | Interactive Next.js frontend — explore, compare, map, PDF reports, live at [www.indexqc.house](https://www.indexqc.house) | [`web/`](web/) | | |
| 79 | + | |
| 80 | +Every published observation looks like this: | |
| 81 | + | |
| 82 | +``` | |
| 83 | +Geography: Quebec City | Type: Condo | Period: 2026-06 | |
| 84 | +Index: 163.5 (2021 = 100) | 1m: +0.4% | YoY: +8.1% | |
| 85 | +Representative value: $389,200 | Transactions: 47 | Reliability: A | |
| 86 | +95% CI: [160.9, 166.1] | Vintage: 2026-08-08 | Model: 2.1.0 | |
| 87 | +``` | |
| 88 | + | |
| 89 | +Uncertainty is **never hidden**: every row carries a 95% confidence interval, | |
| 90 | +an A–E reliability grade, effective sample size and shrinkage weight. | |
| 91 | + | |
| 92 | +--- | |
| 93 | + | |
| 94 | +## 📊 Key numbers | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | +| Metric | Value | | |
| 102 | +|---|---| | |
| 103 | +| Raw transactions ingested | ~745,000 (2021-01 → present) | | |
| 104 | +| Estimation sample after documented exclusions | ~720,000 | | |
| 105 | +| Geographic units | Province + 17 administrative regions + major municipalities | | |
| 106 | +| Property types | `unifamilial`, `condo`, `plex` (+ `all` composite) | | |
| 107 | +| Published cells | 81 — 50 **direct** liquid cells + 31 **hierarchical** thin cells | | |
| 108 | +| Headline frequency | **Monthly** (v2.1) — weekly v1 retained as a research module | | |
| 109 | +| Base | 2021 average = 100 | | |
| 110 | +| Reliability grades | **A** ≥150 tx/mo · **B** ≥75 · **C** ≥40 · **D** ≥15 · **E** <15 (model-implied) | | |
| 111 | +| Growth horizons | 1m, 3m, 6m, YoY (weekly module: 1w, 4w, 13w, 26w, YoY) | | |
| 112 | +| Vintages | `first_release` preserved; every revision queryable | | |
| 113 | + | |
| 114 | +--- | |
| 115 | + | |
| 116 | +## 🔒 Data policy — no microdata in this repo | |
| 117 | + | |
| 118 | +> **This repository contains NO individual transaction data.** | |
| 119 | + | |
| 120 | +| Category | In repo? | Detail | | |
| 121 | +|---|---|---| | |
| 122 | +| Raw transaction CSV (`data/raw/`) | ❌ **excluded** | Individual sale records — never versioned | | |
| 123 | +| Transaction-level intermediates (`data/interim/`, `transactions_clean.parquet`, `geo_join.parquet`) | ❌ **excluded** | Anything with one row per transaction | | |
| 124 | +| Cadastral boundary files (`SDA.gpkg`) | ❌ excluded (size) | Source, URL, version and CRS documented in [`data/external/SDA_SOURCE.md`](data/external/SDA_SOURCE.md) | | |
| 125 | +| **Aggregated published series** | ✅ included | `qhpi_monthly.parquet`, `qwhpi_weekly.parquet`, liquidity, coverage matrices, assessment gap, first releases, vintages | | |
| 126 | +| **Aggregate tables & figures** (`outputs/`) | ✅ included | Audit counts, model comparisons, validation results, publication figures | | |
| 127 | + | |
| 128 | +The [`.gitignore`](.gitignore) enforces this at the repo boundary. Aggregates | |
| 129 | +are cell-level (`geography × type × period`) with transaction **counts**, never | |
| 130 | +transaction rows. To rerun the full pipeline you must supply your own | |
| 131 | +`data/raw/province_transactions.csv` (see [Reproducibility](#-reproducibility)). | |
| 132 | + | |
| 133 | +--- | |
| 134 | + | |
| 135 | +## 🏗 Platform architecture | |
| 136 | + | |
| 137 | +``` | |
| 138 | + ┌─────────────────────────────────────────────┐ | |
| 139 | + │ province_transactions.csv │ | |
| 140 | + │ (local only — NOT in this repo) │ | |
| 141 | + └──────────────────────┬──────────────────────┘ | |
| 142 | + ▼ | |
| 143 | + ┌───────────────────────────── ENGINE (Python) ─────────────────────────────┐ | |
| 144 | + │ 01 profile → 02 geography (spatial join, SDA boundaries) → 03 clean │ | |
| 145 | + │ → 04–08 weekly research module (baseline, hierarchical, validation) │ | |
| 146 | + │ → 09 monthly estimation (rolling time-dummy, Huber-IRLS) │ | |
| 147 | + │ → 10 monthly validation → 11 canonical parquet │ | |
| 148 | + └──────────────────────────────────┬────────────────────────────────────────┘ | |
| 149 | + ▼ | |
| 150 | + data/processed/qhpi_monthly.parquet ← source of truth | |
| 151 | + (+ liquidity, coverage, vintages, assessment gap) | |
| 152 | + ▼ | |
| 153 | + ┌────────────── PostgreSQL (docker-compose, Alembic) ──────────────┐ | |
| 154 | + │ series · observations · geographies · liquidity · vintages │ | |
| 155 | + └──────────────────────────────┬────────────────────────────────────┘ | |
| 156 | + ▼ | |
| 157 | + FastAPI /v1/* (uncertainty always returned) | |
| 158 | + ▼ | |
| 159 | + Next.js dashboard — www.indexqc.house | |
| 160 | + (explore · compare · map · PDF reports · methodology) | |
| 161 | +``` | |
| 162 | + | |
| 163 | +Weekly refresh: `ops/scheduler/` ingests new rows → append-only clean → | |
| 164 | +re-estimation → new `data_vintage` → DB load → API cache invalidation. | |
| 165 | + | |
| 166 | +--- | |
| 167 | + | |
| 168 | +## 🧪 Methodology | |
| 169 | + | |
| 170 | +The index is a **robust hedonic time-dummy index with hierarchical pooling** — | |
| 171 | +three estimation regimes arbitrated by empirical validation (repeat sales + | |
| 172 | +stratified matched-cell medians), not by convenience: | |
| 173 | + | |
| 174 | +### 1. Province × type backbone — rolling time-dummy (RTD) | |
| 175 | + | |
| 176 | +- Pooled robust regressions (`log(price)` on structural characteristics + | |
| 177 | + fine location fixed effects + period dummies) over **13-month rolling | |
| 178 | + windows** with **Huber-IRLS** weighting. | |
| 179 | +- Windows are combined by **mean splice**, making published history | |
| 180 | + **revision-free** by construction. | |
| 181 | +- Coefficient drift is monitored and published | |
| 182 | + (`outputs/tables/rtd_coefficient_drift.csv`) — e.g. the floor-area | |
| 183 | + elasticity moves 0.556 → 0.586 across windows, which a fixed pooled model | |
| 184 | + would have hidden. | |
| 185 | + | |
| 186 | +### 2. Liquid cells (median ≥ 40 tx/month) — direct local estimation | |
| 187 | + | |
| 188 | +Each of the **50 liquid `geography × type` cells** gets its **own robust | |
| 189 | +time-dummy regression** (own hedonic coefficients, own FSA fixed effects), | |
| 190 | +lightly smoothed by a state-space filter. This was arbitrated against | |
| 191 | +deviation-from-pooled-surface modelling, which **compressed genuine local | |
| 192 | +divergence** — Quebec City condos moved ~+55% (confirmed independently by | |
| 193 | +repeat sales *and* stratified matched-cell medians) while pooled deviations | |
| 194 | +reported only +42%. | |
| 195 | + | |
| 196 | +### 3. Thin cells — hierarchical Kalman shrinkage | |
| 197 | + | |
| 198 | +The remaining **31 thin cells** follow a latent random-walk **deviation from | |
| 199 | +their parent's published path**, estimated by Kalman filter/smoother with | |
| 200 | +observation noise σ²/nₜ. Thin months shrink toward the parent trend; the | |
| 201 | +shrinkage weight is published on every row. | |
| 202 | + | |
| 203 | +### Hedonic specification (headline = structural only) | |
| 204 | + | |
| 205 | +- `log(amount)` on `log(floorArea)` (+ time-stable missingness indicator), | |
| 206 | + age from `yearBuilt` via **conditional-median imputation** (its missingness | |
| 207 | + drifts 15% → 2% over the sample, so a "missing" bin would leak time), | |
| 208 | + and fine location fixed effects (FSA). | |
| 209 | +- **`buildingType` is banned from all hedonic models.** The provider | |
| 210 | + backfilled it in region-staggered waves (most regions 2022-01, Montreal | |
| 211 | + 2023-01); the time-correlated missingness fabricated a −38 log-point cliff | |
| 212 | + in local Montreal indexes. The ban is documented in | |
| 213 | + [`engine/src/qwhpi/features.py`](engine/src/qwhpi/features.py). | |
| 214 | +- Assessment values (`totalArValue`, `previousValue`) never enter the | |
| 215 | + headline (valuation leakage); they power the separate | |
| 216 | + **Assessment Gap Index** (`amount / totalArValue`). | |
| 217 | + | |
| 218 | +### Cleaning | |
| 219 | + | |
| 220 | +Raw data is immutable; every exclusion is economically justified and counted | |
| 221 | +in [`outputs/tables/exclusions.csv`](outputs/tables/exclusions.csv) | |
| 222 | +(assessment-ratio band, exact duplicates, non-arm's-length transfers, …). | |
| 223 | +Duplicates are **flagged, never silently deleted**. The `indéterminé` | |
| 224 | +property type never contaminates type-specific indexes. | |
| 225 | + | |
| 226 | +### Geography | |
| 227 | + | |
| 228 | +Transactions are spatially joined (`lat/lng`) against the authoritative | |
| 229 | +Quebec **SDA cadastral boundaries** (source, version and CRS documented in | |
| 230 | +[`data/external/SDA_SOURCE.md`](data/external/SDA_SOURCE.md)) — the free-text | |
| 231 | +`city` field alone is not trusted. | |
| 232 | + | |
| 233 | +--- | |
| 234 | + | |
| 235 | +## ✅ Validation | |
| 236 | + | |
| 237 | +Every claim below has a table or figure in [`outputs/`](outputs/): | |
| 238 | + | |
| 239 | +| Check | Result | | |
| 240 | +|---|---| | |
| 241 | +| **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) | | |
| 242 | +| **Composition-shock simulation** | Mix shock moves the raw median **+11.7%**; the hedonic index moves **−0.7%** — the index measures prices, not composition | | |
| 243 | +| **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 | | |
| 244 | +| **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` | | |
| 245 | +| **Seasonality** | NSA published; no stable seasonality demonstrated (`seasonality_tests.csv`) | | |
| 246 | +| **Alternative specs** | Hierarchical variants, mixed effects, ML residualization benchmarked in `estimation_methods.csv` — chosen model optimizes interpretability + stability, not transaction-level RMSE | | |
| 247 | + | |
| 248 | +Full write-ups: [`outputs/reports/`](outputs/reports/) (audit, geography, | |
| 249 | +cleaning, hierarchical, validation v1 & v2, methodology research). | |
| 250 | + | |
| 251 | +--- | |
| 252 | + | |
| 253 | +## 🗺 Index families & coverage | |
| 254 | + | |
| 255 | +| Family | Scope | | |
| 256 | +|---|---| | |
| 257 | +| **QHPI-QC** | Province of Quebec | | |
| 258 | +| **QHPI-REG** | 17 administrative regions | | |
| 259 | +| **QHPI-CITY** | Montréal, Québec, Laval, Gatineau, Longueuil, Sherbrooke, Trois-Rivières, Saguenay, Lévis, Drummondville, … as liquidity permits | | |
| 260 | +| **QHPI-TYPE** | All / Unifamilial / Condo / Plex per supported geography | | |
| 261 | +| **Assessment Gap** | `amount / totalArValue` — separate concept, never mixed with the price index | | |
| 262 | + | |
| 263 | +The **coverage matrix** | |
| 264 | +([`data/processed/coverage_matrix_monthly.parquet`](data/processed/), | |
| 265 | +[`outputs/tables/coverage_monthly.csv`](outputs/tables/coverage_monthly.csv)) | |
| 266 | +declares per `municipality × type`: published / conditional / not published. | |
| 267 | + | |
| 268 | +--- | |
| 269 | + | |
| 270 | +## 🗃 Canonical dataset | |
| 271 | + | |
| 272 | +[`data/processed/qhpi_monthly.parquet`](data/processed/qhpi_monthly.parquet) | |
| 273 | +(7,504 rows) powers the DB, API and dashboard. Weekly research module: | |
| 274 | +[`data/processed/qwhpi_weekly.parquet`](data/processed/qwhpi_weekly.parquet) | |
| 275 | +(32,592 rows). | |
| 276 | + | |
| 277 | +``` | |
| 278 | +period, geography_level, geography_id, geography_name, property_type, | |
| 279 | +index, index_smoothed, representative_value, | |
| 280 | +transactions, effective_sample_size, | |
| 281 | +monthly_pct, three_month_pct, six_month_pct, yoy_pct, | |
| 282 | +lower_95, upper_95, reliability_grade, shrinkage_weight, | |
| 283 | +is_partial_month, model_version, data_vintage | |
| 284 | +``` | |
| 285 | + | |
| 286 | +- `index` = raw estimate, `index_smoothed` = **one-sided** (real-time safe) | |
| 287 | + smoother — no look-ahead in the published real-time series. | |
| 288 | +- `representative_value` = dollar value of a documented, fixed property | |
| 289 | + basket per segment. | |
| 290 | +- Vintage framework: first releases preserved under | |
| 291 | + `data/processed/vintages*/`, revisions queryable via the API. | |
| 292 | + | |
| 293 | +--- | |
| 294 | + | |
| 295 | +## 🔌 API | |
| 296 | + | |
| 297 | +FastAPI service (OpenAPI docs at `/docs`), CSV/JSON export toggle, ETag | |
| 298 | +caching, pagination, rate limiting, CORS. **Reliability and CIs are always | |
| 299 | +returned.** | |
| 300 | + | |
| 301 | +| Endpoint | Purpose | | |
| 302 | +|---|---| | |
| 303 | +| `GET /v1/index?geography=quebec-city&type=condo&from=2021-01&to=latest` | Full series | | |
| 304 | +| `GET /v1/index/latest?geography=…&type=…` | Latest observation | | |
| 305 | +| `GET /v1/geographies` · `GET /v1/geographies/coverage` | Hierarchy + coverage matrix | | |
| 306 | +| `GET /v1/liquidity?geography=…&type=…` | Transaction counts & effective N | | |
| 307 | +| `GET /v1/compare?series=montreal:condo,quebec-city:condo` | Multi-series comparison | | |
| 308 | +| `GET /v1/map?metric=yoy&level=region` | Choropleth payloads (+ `period` for time-lapse) | | |
| 309 | +| `GET /v1/stats` · `GET /v1/stats/overview` | Peak/drawdown/CAGR/volatility/momentum/rank, heatmap rows | | |
| 310 | +| `GET /v1/report?series=…` · `GET /v1/report/market` | Publication-grade PDF reports (matplotlib) | | |
| 311 | +| `GET /v1/vintages?geography=…&type=…` | Revision history | | |
| 312 | +| `GET /v1/meta` | `model_version`, `data_vintage`, methodology link, author credit | | |
| 313 | +| `GET /v1/health` | Liveness | | |
| 314 | + | |
| 315 | +Sample response: | |
| 316 | + | |
| 317 | +```json | |
| 318 | +{ | |
| 319 | + "geography": "Quebec City", | |
| 320 | + "property_type": "condo", | |
| 321 | + "frequency": "monthly", | |
| 322 | + "latest_index": 163.5, | |
| 323 | + "representative_value": 389200, | |
| 324 | + "monthly_change": 0.4, | |
| 325 | + "yoy_change": 8.1, | |
| 326 | + "transactions": 47, | |
| 327 | + "reliability": "A", | |
| 328 | + "lower_95": 160.9, | |
| 329 | + "upper_95": 166.1, | |
| 330 | + "is_partial_month": false, | |
| 331 | + "author": "Simon-Pierre Boucher", | |
| 332 | + "contact": "contact@spboucher.ai" | |
| 333 | +} | |
| 334 | +``` | |
| 335 | + | |
| 336 | +--- | |
| 337 | + | |
| 338 | +## 💻 Dashboard | |
| 339 | + | |
| 340 | +Live at **[www.indexqc.house](https://www.indexqc.house)** — Next.js + | |
| 341 | +TypeScript, dark/light themes, fully responsive (audited at 390/360 px), | |
| 342 | +`prefers-reduced-motion` respected, zero-dependency custom hero chart, | |
| 343 | +⌘K command palette over all 112 series. | |
| 344 | + | |
| 345 | +| Page | Contents | | |
| 346 | +|---|---| | |
| 347 | +| `/` | Market pulse: heat-shaded region table with sparklines, 12 metric tiles, ticker tape, top movers, market-report PDF | | |
| 348 | +| `/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 | | |
| 349 | +| `/compare` | Multi-series with rebasing tool, toggleable legend chips, comparison PDF | | |
| 350 | +| `/map` | Region choropleth (YoY, 3m, level, assessment gap) with monthly slider + ▶ time-lapse | | |
| 351 | +| `/methodology` | Rendered methodology + link to the paper | | |
| 352 | +| `/api-docs` | Live playground (run requests in-page) + copyable curl examples | | |
| 353 | + | |
| 354 | +Low-reliability series are **visually flagged, never hidden**. Footer credit: | |
| 355 | +*Simon-Pierre Boucher — contact@spboucher.ai*. | |
| 356 | + | |
| 357 | +--- | |
| 358 | + | |
| 359 | +## 📁 Repository map | |
| 360 | + | |
| 361 | +``` | |
| 362 | +├── engine/ # Index engine (Python) | |
| 363 | +│ ├── src/qwhpi/ # ingest · clean · geography · features · hedonic | |
| 364 | +│ │ # rtd · hierarchy · state_space · repeat_sales | |
| 365 | +│ │ # index · uncertainty · seasonal · nowcast | |
| 366 | +│ │ # vintages · export · plotting | |
| 367 | +│ ├── scripts/ # 01_profile … 11_monthly_canonical (ordered, idempotent) | |
| 368 | +│ └── tests/ # smoke pipeline, features/clean, state-space | |
| 369 | +├── data/ | |
| 370 | +│ ├── external/ # boundary-source documentation (files excluded by size) | |
| 371 | +│ └── processed/ # ✅ aggregated series only (see Data policy) | |
| 372 | +├── db/ # PostgreSQL schema + Alembic migrations | |
| 373 | +├── api/ # FastAPI app: routers/ services/ tests/ | |
| 374 | +├── web/ # Next.js dashboard: app/ components/ lib/ styles/ | |
| 375 | +├── ops/ # weekly-refresh scheduler + CI pipeline | |
| 376 | +├── outputs/ | |
| 377 | +│ ├── figures/ # publication figures (shared plotting config) | |
| 378 | +│ ├── tables/ # 35+ audit/validation/model tables (aggregates) | |
| 379 | +│ └── reports/ # markdown reports for every pipeline stage | |
| 380 | +├── paper/ # LaTeX methodology paper (+ compiled PDF) | |
| 381 | +├── scripts/check_headers.py # CI gate: every file carries the author header | |
| 382 | +├── Makefile # one-command targets | |
| 383 | +└── docker-compose.yml # postgres + api + web + scheduler | |
| 384 | +``` | |
| 385 | + | |
| 386 | +--- | |
| 387 | + | |
| 388 | +## 🚀 Quickstart | |
| 389 | + | |
| 390 | +> Requires the raw transaction CSV (not distributed — see | |
| 391 | +> [Data policy](#-data-policy--no-microdata-in-this-repo)) for `make pipeline`. | |
| 392 | +> The API and dashboard run off the **included aggregated Parquet** without it. | |
| 393 | + | |
| 394 | +```bash | |
| 395 | +# Engine (full run ~ audit → geography → clean → estimate → validate → canonical) | |
| 396 | +make pipeline | |
| 397 | + | |
| 398 | +# Incremental monthly refresh (clean → estimate → canonical, ~2 min) | |
| 399 | +make refresh | |
| 400 | + | |
| 401 | +# Services | |
| 402 | +make api # FastAPI on :8080 — docs at /docs | |
| 403 | +make web # Next.js dashboard on :3000 | |
| 404 | +make up # docker compose: postgres + api + web + scheduler | |
| 405 | +make down | |
| 406 | + | |
| 407 | +# Quality | |
| 408 | +make test # engine + api test suites | |
| 409 | +make lint # ruff + mypy + tsc | |
| 410 | +make headers # author-header check (also a pre-commit hook + CI gate) | |
| 411 | +make figures # publication figures → outputs/figures/ | |
| 412 | +make paper # compile the LaTeX paper | |
| 413 | +``` | |
| 414 | + | |
| 415 | +--- | |
| 416 | + | |
| 417 | +## ⚙️ Engine pipeline | |
| 418 | + | |
| 419 | +| Script | Stage | | |
| 420 | +|---|---| | |
| 421 | +| `01_profile.py` | Data audit: schema, descriptives, missingness, sentinel values, liquidity matrices | | |
| 422 | +| `02_geography.py` | SDA boundary spatial join, validation, persistence | | |
| 423 | +| `03_clean.py` | Research sample with fully documented exclusions | | |
| 424 | +| `04_baseline.py` | Weekly pooled hedonic time-dummy baseline *(research module)* | | |
| 425 | +| `05_hierarchical.py` | Weekly hierarchical state-space shrinkage *(research module)* | | |
| 426 | +| `06_validation.py` | Repeat sales, downsampling, composition shock *(weekly)* | | |
| 427 | +| `07_canonical.py` | Weekly canonical Parquet *(research module)* | | |
| 428 | +| `08_figures.py` | Publication figures | | |
| 429 | +| `09_monthly.py` | **Headline monthly estimation** (RTD + direct cells + Kalman) | | |
| 430 | +| `10_monthly_validation.py` | Monthly validation suite | | |
| 431 | +| `11_monthly_canonical.py` | **Canonical `qhpi_monthly.parquet`** + vintage write | | |
| 432 | + | |
| 433 | +Every run emits a structured **run manifest** (input hash, row counts, model | |
| 434 | +version, timings). | |
| 435 | + | |
| 436 | +--- | |
| 437 | + | |
| 438 | +## 📐 Engineering standards | |
| 439 | + | |
| 440 | +- **Every file** starts with the author header — enforced by | |
| 441 | + [`scripts/check_headers.py`](scripts/check_headers.py) in pre-commit **and** CI. | |
| 442 | +- Vectorized pandas/polars + numpy; no per-transaction Python loops; spatial | |
| 443 | + joins, features and model matrices cached as Parquet. | |
| 444 | +- Diagnostics for every production model: residual distribution, | |
| 445 | + heteroskedasticity, temporal/spatial residual patterns, coefficient | |
| 446 | + stability, effective N. | |
| 447 | +- Model selection optimizes **interpretability + stability + calibrated | |
| 448 | + uncertainty + reproducibility** — never transaction-level RMSE alone. | |
| 449 | +- CI (`ops/ci/ci.yml`): lint, mypy + tsc, tests, header check, small-sample | |
| 450 | + smoke run of the pipeline. | |
| 451 | + | |
| 452 | +--- | |
| 453 | + | |
| 454 | +## 📄 Research outputs & paper | |
| 455 | + | |
| 456 | +- **Figures** ([`outputs/figures/`](outputs/figures/)): province aggregate, | |
| 457 | + region comparison, big-4 cities, condo by city, raw median vs hedonic, | |
| 458 | + volumes, reliability matrix, downsampling stability, assessment gap, | |
| 459 | + YoY appreciation map. | |
| 460 | +- **Tables** ([`outputs/tables/`](outputs/tables/)): audit suite, exclusions, | |
| 461 | + liquidity, coverage, model comparison, repeat-sales comparison, | |
| 462 | + downsampling, seasonality, coefficient drift, weekly-vs-monthly. | |
| 463 | +- **Paper** ([`paper/qwhpi.pdf`](paper/qwhpi.pdf)): *A High-Frequency Hedonic | |
| 464 | + Housing Price Index for Quebec* — real, researched citations only (hedonic | |
| 465 | + and repeat-sales index theory, Case-Shiller, hierarchical and state-space | |
| 466 | + indexes, spatial hedonics, index-number theory). | |
| 467 | + | |
| 468 | +--- | |
| 469 | + | |
| 470 | +## 🔁 Reproducibility | |
| 471 | + | |
| 472 | +1. Place your transaction extract at `data/raw/province_transactions.csv` | |
| 473 | + (columns: `id, date, amount, street, zipCode, city, lat, lng, | |
| 474 | + propertyType, yearBuilt, floorArea, buildingType, previousValue, | |
| 475 | + totalArValue, ownerType`). | |
| 476 | +2. Download the SDA boundary file per | |
| 477 | + [`data/external/SDA_SOURCE.md`](data/external/SDA_SOURCE.md). | |
| 478 | +3. `make pipeline` — deterministic given the same inputs; every run is | |
| 479 | + stamped with `model_version` + `data_vintage` and manifested. | |
| 480 | + | |
| 481 | +Without microdata, everything downstream of the canonical Parquet (DB load, | |
| 482 | +API, dashboard, figures) is fully runnable from the aggregates included here. | |
| 483 | + | |
| 484 | +--- | |
| 485 | + | |
| 486 | +## 👤 Author & license | |
| 487 | + | |
| 488 | +<div align="center"> | |
| 489 | + | |
| 490 | +**Simon-Pierre Boucher** | |
| 491 | + | |
| 492 | +[](mailto:contact@spboucher.ai) | |
| 493 | +[](https://www.indexqc.house) | |
| 494 | + | |
| 495 | +</div> | |
| 496 | + | |
| 497 | +Code, methodology, figures and documentation © Simon-Pierre Boucher. | |
| 498 | +All rights reserved — contact the author for reuse, data questions or | |
| 499 | +collaboration. The repository intentionally distributes **no individual | |
| 500 | +transaction records**; published aggregates carry full uncertainty metadata | |
| 501 | +so they are never mistaken for more precision than the data supports. | |
added
api/Dockerfile
+14 −0
@@ -0,0 +1,14 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/Dockerfile | |
| 6 | +# Purpose : Container image for the QWHPI API service. | |
| 7 | +# ============================================================================= | |
| 8 | +FROM python:3.12-slim | |
| 9 | +WORKDIR /srv | |
| 10 | +COPY pyproject.toml ./ | |
| 11 | +RUN pip install --no-cache-dir fastapi "uvicorn[standard]" polars pydantic "psycopg[binary]" | |
| 12 | +COPY app ./app | |
| 13 | +EXPOSE 8080 | |
| 14 | +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] | |
added
api/app/__init__.py
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/__init__.py | |
| 6 | +# Purpose : Package root for the QWHPI API application. | |
| 7 | +# ============================================================================= | |
added
api/app/deps.py
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/deps.py | |
| 6 | +# Purpose : Shared dependencies — series validation, ETag and CSV helpers. | |
| 7 | +# ============================================================================= | |
| 8 | +"""Shared FastAPI dependencies and response helpers.""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import hashlib | |
| 13 | + | |
| 14 | +import polars as pl | |
| 15 | +from fastapi import HTTPException, Request, Response | |
| 16 | + | |
| 17 | +from app.services import data | |
| 18 | + | |
| 19 | +VALID_TYPES = {"all", "unifamilial", "condo", "plex"} | |
| 20 | + | |
| 21 | + | |
| 22 | +def validate_series(geography: str, property_type: str) -> None: | |
| 23 | + if property_type not in VALID_TYPES: | |
| 24 | + raise HTTPException(422, f"property_type must be one of {sorted(VALID_TYPES)}") | |
| 25 | + if geography not in data.geography_ids(): | |
| 26 | + raise HTTPException(404, f"unknown geography '{geography}' — see /v1/geographies") | |
| 27 | + | |
| 28 | + | |
| 29 | +def etag_for(request: Request, response: Response, *parts: str) -> bool: | |
| 30 | + """Set the ETag; return True when the client's copy is fresh (304).""" | |
| 31 | + raw = "|".join([data.data_vintage(), data.model_version(), | |
| 32 | + str(request.url), *parts]) | |
| 33 | + tag = '"' + hashlib.sha256(raw.encode()).hexdigest()[:32] + '"' | |
| 34 | + response.headers["ETag"] = tag | |
| 35 | + response.headers["Cache-Control"] = "public, max-age=300" | |
| 36 | + return request.headers.get("if-none-match") == tag | |
| 37 | + | |
| 38 | + | |
| 39 | +def csv_response(df: pl.DataFrame) -> Response: | |
| 40 | + return Response( | |
| 41 | + content=df.write_csv(), | |
| 42 | + media_type="text/csv", | |
| 43 | + headers={"X-Author": "Simon-Pierre Boucher <contact@spboucher.ai>"}, | |
| 44 | + ) | |
added
api/app/main.py
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/main.py | |
| 6 | +# Purpose : FastAPI application — routers, CORS, rate limiting, OpenAPI. | |
| 7 | +# ============================================================================= | |
| 8 | +"""QWHPI API service entry point. | |
| 9 | + | |
| 10 | +Run locally: uvicorn app.main:app --reload --port 8080 (from api/) | |
| 11 | +Docs: /docs (OpenAPI). Every response credits the author and exposes | |
| 12 | +uncertainty (CIs, reliability grades) — the API never hides it. | |
| 13 | +""" | |
| 14 | + | |
| 15 | +from __future__ import annotations | |
| 16 | + | |
| 17 | +import time | |
| 18 | +from collections import defaultdict, deque | |
| 19 | + | |
| 20 | +from fastapi import FastAPI, Request | |
| 21 | +from fastapi.middleware.cors import CORSMiddleware | |
| 22 | +from fastapi.responses import JSONResponse | |
| 23 | + | |
| 24 | +from app.routers import geographies, index, liquidity, maps, meta, reports | |
| 25 | + | |
| 26 | +RATE_LIMIT = 120 # requests | |
| 27 | +RATE_WINDOW = 60.0 # seconds | |
| 28 | +_hits: dict[str, deque] = defaultdict(deque) | |
| 29 | + | |
| 30 | +app = FastAPI( | |
| 31 | + title="QWHPI API", | |
| 32 | + version="0.1.0", | |
| 33 | + description=( | |
| 34 | + "Quebec Weekly Housing Price Index — quality-adjusted, hierarchically " | |
| 35 | + "pooled weekly indexes for Quebec geographies and property types.\n\n" | |
| 36 | + "**Author: Simon-Pierre Boucher — contact@spboucher.ai**" | |
| 37 | + ), | |
| 38 | + contact={"name": "Simon-Pierre Boucher", "email": "contact@spboucher.ai"}, | |
| 39 | +) | |
| 40 | + | |
| 41 | +app.add_middleware( | |
| 42 | + CORSMiddleware, | |
| 43 | + allow_origins=["*"], | |
| 44 | + allow_methods=["GET"], | |
| 45 | + allow_headers=["*"], | |
| 46 | +) | |
| 47 | + | |
| 48 | + | |
| 49 | +@app.middleware("http") | |
| 50 | +async def rate_limiter(request: Request, call_next): | |
| 51 | + ip = request.client.host if request.client else "anon" | |
| 52 | + now = time.monotonic() | |
| 53 | + q = _hits[ip] | |
| 54 | + while q and now - q[0] > RATE_WINDOW: | |
| 55 | + q.popleft() | |
| 56 | + if len(q) >= RATE_LIMIT: | |
| 57 | + return JSONResponse({"detail": "rate limit exceeded"}, status_code=429) | |
| 58 | + q.append(now) | |
| 59 | + return await call_next(request) | |
| 60 | + | |
| 61 | + | |
| 62 | +app.include_router(index.router, prefix="/v1", tags=["index"]) | |
| 63 | +app.include_router(reports.router, prefix="/v1", tags=["stats & reports"]) | |
| 64 | +app.include_router(geographies.router, prefix="/v1", tags=["geographies"]) | |
| 65 | +app.include_router(liquidity.router, prefix="/v1", tags=["liquidity"]) | |
| 66 | +app.include_router(maps.router, prefix="/v1", tags=["maps"]) | |
| 67 | +app.include_router(meta.router, tags=["meta"]) | |
added
api/app/routers/__init__.py
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/routers/__init__.py | |
| 6 | +# Purpose : Router package for the QWHPI API. | |
| 7 | +# ============================================================================= | |
added
api/app/routers/geographies.py
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/routers/geographies.py | |
| 6 | +# Purpose : Geography hierarchy + coverage matrix endpoints. | |
| 7 | +# ============================================================================= | |
| 8 | +"""Geography and coverage endpoints.""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import polars as pl | |
| 13 | +from fastapi import APIRouter, Query, Request, Response | |
| 14 | + | |
| 15 | +from app.deps import csv_response, etag_for | |
| 16 | +from app.schemas import GeographiesResponse, GeographyNode | |
| 17 | +from app.services import data | |
| 18 | + | |
| 19 | +router = APIRouter() | |
| 20 | + | |
| 21 | + | |
| 22 | +@router.get("/geographies", response_model=GeographiesResponse) | |
| 23 | +def geographies(request: Request, response: Response): | |
| 24 | + """Published series hierarchy (province → regions → municipalities).""" | |
| 25 | + if etag_for(request, response): | |
| 26 | + return Response(status_code=304) | |
| 27 | + c = data.canonical() | |
| 28 | + nodes = ( | |
| 29 | + c.group_by(["geography_id", "geography_name", "geography_level"]) | |
| 30 | + .agg(pl.col("property_type").unique().sort().alias("types")) | |
| 31 | + .sort(["geography_level", "geography_id"]) | |
| 32 | + ) | |
| 33 | + return GeographiesResponse( | |
| 34 | + data_vintage=data.data_vintage(), | |
| 35 | + published_series=[ | |
| 36 | + GeographyNode(geography_id=r["geography_id"], | |
| 37 | + geography_name=r["geography_name"], | |
| 38 | + geography_level=r["geography_level"], | |
| 39 | + property_types=r["types"]) | |
| 40 | + for r in nodes.iter_rows(named=True) | |
| 41 | + ], | |
| 42 | + ) | |
| 43 | + | |
| 44 | + | |
| 45 | +@router.get("/geographies/coverage") | |
| 46 | +def coverage_matrix( | |
| 47 | + request: Request, | |
| 48 | + response: Response, | |
| 49 | + status: str | None = Query(None, pattern="^(published|conditional|not_published)$"), | |
| 50 | + format: str = Query("json", pattern="^(json|csv)$"), | |
| 51 | +): | |
| 52 | + """Full municipality × type coverage matrix with publication status.""" | |
| 53 | + df = data.coverage() | |
| 54 | + if status: | |
| 55 | + df = df.filter(pl.col("coverage_status") == status) | |
| 56 | + if format == "csv": | |
| 57 | + return csv_response(df) | |
| 58 | + if etag_for(request, response, str(status)): | |
| 59 | + return Response(status_code=304) | |
| 60 | + return { | |
| 61 | + "coverage": df.rename({"propertyType": "property_type"}).to_dicts(), | |
| 62 | + "author": "Simon-Pierre Boucher", "contact": "contact@spboucher.ai", | |
| 63 | + } | |
added
api/app/routers/index.py
+197 −0
@@ -0,0 +1,197 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/routers/index.py | |
| 6 | +# Purpose : Index endpoints (monthly v2.1) — series, latest, compare, vintages. | |
| 7 | +# ============================================================================= | |
| 8 | +"""Index series endpoints.""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import math | |
| 13 | + | |
| 14 | +import polars as pl | |
| 15 | +from fastapi import APIRouter, HTTPException, Query, Request, Response | |
| 16 | + | |
| 17 | +from app.deps import csv_response, etag_for, validate_series | |
| 18 | +from app.schemas import LatestResponse, Observation, SeriesResponse, VintageObservation | |
| 19 | +from app.services import data | |
| 20 | + | |
| 21 | +router = APIRouter() | |
| 22 | + | |
| 23 | + | |
| 24 | +def _clean(v): | |
| 25 | + return None if (v is None or (isinstance(v, float) and math.isnan(v))) else v | |
| 26 | + | |
| 27 | + | |
| 28 | +def _obs(row: dict) -> Observation: | |
| 29 | + return Observation( | |
| 30 | + period=row["period"], | |
| 31 | + index=_clean(row["index"]), | |
| 32 | + index_smoothed=row["index_smoothed"], | |
| 33 | + representative_value=row["representative_value"], | |
| 34 | + transactions=row["transactions"], | |
| 35 | + effective_sample_size=row["effective_sample_size"], | |
| 36 | + monthly_pct=row["monthly_pct"], | |
| 37 | + three_month_pct=row["three_month_pct"], | |
| 38 | + six_month_pct=row["six_month_pct"], | |
| 39 | + yoy_pct=row["yoy_pct"], | |
| 40 | + lower_95=row["lower_95"], | |
| 41 | + upper_95=row["upper_95"], | |
| 42 | + reliability_grade=row["reliability_grade"], | |
| 43 | + shrinkage_weight=row["shrinkage_weight"], | |
| 44 | + is_partial_month=row["is_partial_month"], | |
| 45 | + ) | |
| 46 | + | |
| 47 | + | |
| 48 | +@router.get("/index", response_model=SeriesResponse) | |
| 49 | +def get_index( | |
| 50 | + request: Request, | |
| 51 | + response: Response, | |
| 52 | + geography: str = Query(..., description="geography_id, e.g. quebec, region-06, montreal"), | |
| 53 | + type: str = Query("all", alias="type"), | |
| 54 | + from_: str | None = Query(None, alias="from", description="first period (YYYY-MM)"), | |
| 55 | + to: str | None = Query("latest"), | |
| 56 | + limit: int | None = Query(None, ge=1, le=1000), | |
| 57 | + offset: int = Query(0, ge=0), | |
| 58 | + format: str = Query("json", pattern="^(json|csv)$"), | |
| 59 | +): | |
| 60 | + """Full monthly history for one series (paginated; CSV via ?format=csv).""" | |
| 61 | + validate_series(geography, type) | |
| 62 | + df = data.series(geography, type, from_, to) | |
| 63 | + if df.height == 0: | |
| 64 | + raise HTTPException(404, "no observations for this series/range") | |
| 65 | + total = df.height | |
| 66 | + page = df.slice(offset, limit) if limit else df.slice(offset) | |
| 67 | + if format == "csv": | |
| 68 | + return csv_response(page) | |
| 69 | + if etag_for(request, response, geography, type, str(from_), str(to), | |
| 70 | + str(limit), str(offset)): | |
| 71 | + return Response(status_code=304) | |
| 72 | + first = page.row(0, named=True) | |
| 73 | + return SeriesResponse( | |
| 74 | + geography=geography, | |
| 75 | + geography_name=first["geography_name"], | |
| 76 | + geography_level=first["geography_level"], | |
| 77 | + property_type=type, | |
| 78 | + model_version=first["model_version"], | |
| 79 | + data_vintage=first["data_vintage"], | |
| 80 | + total_observations=total, | |
| 81 | + offset=offset, | |
| 82 | + limit=limit, | |
| 83 | + observations=[_obs(r) for r in page.iter_rows(named=True)], | |
| 84 | + ) | |
| 85 | + | |
| 86 | + | |
| 87 | +@router.get("/index/latest", response_model=LatestResponse) | |
| 88 | +def get_latest( | |
| 89 | + request: Request, | |
| 90 | + response: Response, | |
| 91 | + geography: str, | |
| 92 | + type: str = "all", | |
| 93 | + include_partial: bool = Query(False, description="include the partial (nowcast) month"), | |
| 94 | +): | |
| 95 | + """Latest observation (complete month by default; nowcast on request).""" | |
| 96 | + validate_series(geography, type) | |
| 97 | + df = data.series(geography, type) | |
| 98 | + if not include_partial: | |
| 99 | + df = df.filter(~pl.col("is_partial_month")) | |
| 100 | + if df.height == 0: | |
| 101 | + raise HTTPException(404, "no observations") | |
| 102 | + row = df.row(df.height - 1, named=True) | |
| 103 | + if etag_for(request, response, geography, type, row["period"], | |
| 104 | + str(include_partial)): | |
| 105 | + return Response(status_code=304) | |
| 106 | + return LatestResponse( | |
| 107 | + geography=geography, | |
| 108 | + geography_name=row["geography_name"], | |
| 109 | + property_type=type, | |
| 110 | + period=row["period"], | |
| 111 | + latest_index=round(row["index_smoothed"], 2), | |
| 112 | + latest_index_raw=(None if _clean(row["index"]) is None | |
| 113 | + else round(row["index"], 2)), | |
| 114 | + representative_value=row["representative_value"], | |
| 115 | + monthly_change=row["monthly_pct"], | |
| 116 | + three_month_change=row["three_month_pct"], | |
| 117 | + yoy_change=row["yoy_pct"], | |
| 118 | + transactions=row["transactions"], | |
| 119 | + effective_sample_size=row["effective_sample_size"], | |
| 120 | + reliability=row["reliability_grade"], | |
| 121 | + lower_95=round(row["lower_95"], 2), | |
| 122 | + upper_95=round(row["upper_95"], 2), | |
| 123 | + is_partial_month=row["is_partial_month"], | |
| 124 | + model_version=row["model_version"], | |
| 125 | + data_vintage=row["data_vintage"], | |
| 126 | + ) | |
| 127 | + | |
| 128 | + | |
| 129 | +@router.get("/compare") | |
| 130 | +def compare( | |
| 131 | + request: Request, | |
| 132 | + response: Response, | |
| 133 | + series: str = Query(..., description="comma list of geography:type, e.g. " | |
| 134 | + "montreal:condo,quebec-city:condo"), | |
| 135 | + rebase_period: str | None = Query(None, description="rebase all series to 100 at this period"), | |
| 136 | +): | |
| 137 | + """Aligned comparison of up to 8 series, optionally rebased.""" | |
| 138 | + pairs = [s.strip() for s in series.split(",") if s.strip()] | |
| 139 | + if not 2 <= len(pairs) <= 8: | |
| 140 | + raise HTTPException(422, "provide 2 to 8 series") | |
| 141 | + out = {} | |
| 142 | + for p in pairs: | |
| 143 | + try: | |
| 144 | + g, t = p.split(":") | |
| 145 | + except ValueError: | |
| 146 | + raise HTTPException(422, f"bad series spec '{p}' (want geography:type)") | |
| 147 | + validate_series(g, t) | |
| 148 | + df = data.series(g, t).select("period", "index_smoothed", | |
| 149 | + "representative_value", "reliability_grade") | |
| 150 | + if rebase_period: | |
| 151 | + base = df.filter(pl.col("period") == rebase_period) | |
| 152 | + if base.height == 0: | |
| 153 | + raise HTTPException(422, f"rebase_period {rebase_period} not found for {p}") | |
| 154 | + df = df.with_columns( | |
| 155 | + (pl.col("index_smoothed") / base["index_smoothed"][0] * 100) | |
| 156 | + .round(3).alias("index_smoothed")) | |
| 157 | + out[p] = df.to_dicts() | |
| 158 | + if etag_for(request, response, series, str(rebase_period)): | |
| 159 | + return Response(status_code=304) | |
| 160 | + return {"series": out, "rebase_period": rebase_period, | |
| 161 | + "author": "Simon-Pierre Boucher", "contact": "contact@spboucher.ai"} | |
| 162 | + | |
| 163 | + | |
| 164 | +@router.get("/vintages") | |
| 165 | +def vintages( | |
| 166 | + geography: str, | |
| 167 | + type: str = "all", | |
| 168 | + period: str | None = None, | |
| 169 | +): | |
| 170 | + """First release vs current vintage per period (revision tracking).""" | |
| 171 | + validate_series(geography, type) | |
| 172 | + fr = data.first_release() | |
| 173 | + if fr is None: | |
| 174 | + raise HTTPException(404, "no vintage history yet") | |
| 175 | + cur = data.series(geography, type).select( | |
| 176 | + "period", "index_smoothed", "data_vintage") | |
| 177 | + j = ( | |
| 178 | + fr.filter((pl.col("geography_id") == geography) | |
| 179 | + & (pl.col("property_type") == type)) | |
| 180 | + .join(cur, on="period", how="inner") | |
| 181 | + ) | |
| 182 | + if period: | |
| 183 | + j = j.filter(pl.col("period") == period) | |
| 184 | + obs = [ | |
| 185 | + VintageObservation( | |
| 186 | + period=r["period"], | |
| 187 | + first_release_index_smoothed=round(r["first_release_index_smoothed"], 3), | |
| 188 | + current_index_smoothed=round(r["index_smoothed"], 3), | |
| 189 | + first_release_vintage=r["first_release_vintage"], | |
| 190 | + current_vintage=r["data_vintage"], | |
| 191 | + revision_pct=round((r["index_smoothed"] | |
| 192 | + / r["first_release_index_smoothed"] - 1) * 100, 3), | |
| 193 | + ).model_dump() | |
| 194 | + for r in j.sort("period").iter_rows(named=True) | |
| 195 | + ] | |
| 196 | + return {"geography": geography, "property_type": type, "observations": obs, | |
| 197 | + "author": "Simon-Pierre Boucher", "contact": "contact@spboucher.ai"} | |
added
api/app/routers/liquidity.py
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/routers/liquidity.py | |
| 6 | +# Purpose : Weekly transaction-volume (liquidity) endpoint per series. | |
| 7 | +# ============================================================================= | |
| 8 | +"""Liquidity endpoints.""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import polars as pl | |
| 13 | +from fastapi import APIRouter, HTTPException, Request, Response | |
| 14 | + | |
| 15 | +from app.deps import etag_for, validate_series | |
| 16 | +from app.schemas import LiquidityPoint, LiquidityResponse | |
| 17 | +from app.services import data | |
| 18 | + | |
| 19 | +router = APIRouter() | |
| 20 | + | |
| 21 | + | |
| 22 | +@router.get("/liquidity", response_model=LiquidityResponse) | |
| 23 | +def liquidity(request: Request, response: Response, | |
| 24 | + geography: str, type: str = "all"): | |
| 25 | + """Weekly transaction counts for one series.""" | |
| 26 | + validate_series(geography, type) | |
| 27 | + df = data.liquidity().filter( | |
| 28 | + (pl.col("geography_id") == geography) | |
| 29 | + & (pl.col("property_type") == type) | |
| 30 | + ).sort("period") | |
| 31 | + if df.height == 0: | |
| 32 | + raise HTTPException(404, "no liquidity data") | |
| 33 | + if etag_for(request, response, geography, type): | |
| 34 | + return Response(status_code=304) | |
| 35 | + return LiquidityResponse( | |
| 36 | + geography=geography, | |
| 37 | + property_type=type, | |
| 38 | + median_monthly_transactions=float(df["transactions"].median()), | |
| 39 | + observations=[LiquidityPoint(period=r["period"], transactions=r["transactions"]) | |
| 40 | + for r in df.iter_rows(named=True)], | |
| 41 | + ) | |
added
api/app/routers/maps.py
+72 −0
@@ -0,0 +1,72 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/routers/maps.py | |
| 6 | +# Purpose : Choropleth data endpoint — one metric across geographies. | |
| 7 | +# ============================================================================= | |
| 8 | +"""Map data endpoints (region choropleth + assessment gap).""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import polars as pl | |
| 13 | +from fastapi import APIRouter, HTTPException, Query, Request, Response | |
| 14 | + | |
| 15 | +from app.deps import etag_for | |
| 16 | +from app.schemas import MapCell, MapResponse | |
| 17 | +from app.services import data | |
| 18 | + | |
| 19 | +router = APIRouter() | |
| 20 | + | |
| 21 | +METRICS = { | |
| 22 | + "yoy": "yoy_pct", | |
| 23 | + "six_month": "six_month_pct", | |
| 24 | + "three_month": "three_month_pct", | |
| 25 | + "index_level": "index_smoothed", | |
| 26 | +} | |
| 27 | + | |
| 28 | + | |
| 29 | +@router.get("/map", response_model=MapResponse) | |
| 30 | +def map_data( | |
| 31 | + request: Request, | |
| 32 | + response: Response, | |
| 33 | + metric: str = Query("yoy", description=f"one of {sorted(METRICS)} or assessment_gap"), | |
| 34 | + level: str = Query("region", pattern="^(region|municipality)$"), | |
| 35 | + type: str = Query("all"), | |
| 36 | + period: str | None = None, | |
| 37 | +): | |
| 38 | + """One metric per geography for choropleth rendering.""" | |
| 39 | + c = data.canonical().filter( | |
| 40 | + (pl.col("geography_level") == level) & (pl.col("property_type") == type)) | |
| 41 | + if period is None: | |
| 42 | + period = c.filter(~pl.col("is_partial_month"))["period"].max() | |
| 43 | + if metric == "assessment_gap": | |
| 44 | + gap = data.assessment_gap().filter( | |
| 45 | + (pl.col("property_type") == type) & (pl.col("period") == period) | |
| 46 | + & pl.col("geography_id").str.starts_with("region-" if level == "region" else "")) | |
| 47 | + c_wk = c.filter(pl.col("period") == period).select( | |
| 48 | + "geography_id", "geography_name", "reliability_grade", "transactions") | |
| 49 | + j = c_wk.join(gap, on="geography_id", how="left") | |
| 50 | + cells = [MapCell(geography_id=r["geography_id"], | |
| 51 | + geography_name=r["geography_name"], | |
| 52 | + value=r["median_ratio"], | |
| 53 | + reliability_grade=r["reliability_grade"], | |
| 54 | + transactions=r["transactions"]) | |
| 55 | + for r in j.iter_rows(named=True)] | |
| 56 | + else: | |
| 57 | + if metric not in METRICS: | |
| 58 | + raise HTTPException(422, f"metric must be one of {sorted(METRICS)} or assessment_gap") | |
| 59 | + col = METRICS[metric] | |
| 60 | + wk = c.filter(pl.col("period") == period) | |
| 61 | + if wk.height == 0: | |
| 62 | + raise HTTPException(404, f"no data for period {period}") | |
| 63 | + cells = [MapCell(geography_id=r["geography_id"], | |
| 64 | + geography_name=r["geography_name"], | |
| 65 | + value=r[col], | |
| 66 | + reliability_grade=r["reliability_grade"], | |
| 67 | + transactions=r["transactions"]) | |
| 68 | + for r in wk.iter_rows(named=True)] | |
| 69 | + if etag_for(request, response, metric, level, type, str(period)): | |
| 70 | + return Response(status_code=304) | |
| 71 | + return MapResponse(metric=metric, level=level, property_type=type, | |
| 72 | + period=str(period), cells=cells) | |
added
api/app/routers/meta.py
+41 −0
@@ -0,0 +1,41 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/routers/meta.py | |
| 6 | +# Purpose : /v1/meta and /health endpoints. | |
| 7 | +# ============================================================================= | |
| 8 | +"""Metadata and health endpoints.""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import polars as pl | |
| 13 | +from fastapi import APIRouter | |
| 14 | + | |
| 15 | +from app.schemas import MetaResponse | |
| 16 | +from app.services import data | |
| 17 | + | |
| 18 | +router = APIRouter() | |
| 19 | + | |
| 20 | + | |
| 21 | +@router.get("/v1/meta", response_model=MetaResponse) | |
| 22 | +def meta(): | |
| 23 | + c = data.canonical() | |
| 24 | + return MetaResponse( | |
| 25 | + model_version=data.model_version(), | |
| 26 | + data_vintage=data.data_vintage(), | |
| 27 | + series_count=c.select("geography_id", "property_type").unique().height, | |
| 28 | + periods=c["period"].n_unique(), | |
| 29 | + latest_complete_period=str( | |
| 30 | + c.filter(~pl.col("is_partial_month"))["period"].max()), | |
| 31 | + ) | |
| 32 | + | |
| 33 | + | |
| 34 | +@router.get("/health") | |
| 35 | +def health(): | |
| 36 | + try: | |
| 37 | + rows = data.canonical().height | |
| 38 | + return {"status": "ok", "canonical_rows": rows, | |
| 39 | + "data_vintage": data.data_vintage()} | |
| 40 | + except Exception as exc: # pragma: no cover | |
| 41 | + return {"status": "degraded", "error": str(exc)} | |
added
api/app/routers/reports.py
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/routers/reports.py | |
| 6 | +# Purpose : Stats + automatic PDF report endpoints. | |
| 7 | +# ============================================================================= | |
| 8 | +"""Derived statistics and report-generation endpoints.""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +from fastapi import APIRouter, HTTPException, Query, Request, Response | |
| 13 | + | |
| 14 | +from app.deps import etag_for, validate_series | |
| 15 | +from app.services import data, report, stats | |
| 16 | + | |
| 17 | +router = APIRouter() | |
| 18 | + | |
| 19 | + | |
| 20 | +@router.get("/stats") | |
| 21 | +def series_statistics(request: Request, response: Response, | |
| 22 | + geography: str, type: str = "all"): | |
| 23 | + """Rich derived metrics for one series (peak, drawdown, momentum, ...).""" | |
| 24 | + validate_series(geography, type) | |
| 25 | + st = stats.series_stats(geography, type) | |
| 26 | + if not st: | |
| 27 | + raise HTTPException(404, "series too short for statistics") | |
| 28 | + if etag_for(request, response, geography, type, "stats"): | |
| 29 | + return Response(status_code=304) | |
| 30 | + return {"geography": geography, "property_type": type, **st, | |
| 31 | + "author": "Simon-Pierre Boucher", "contact": "contact@spboucher.ai"} | |
| 32 | + | |
| 33 | + | |
| 34 | +@router.get("/stats/overview") | |
| 35 | +def statistics_overview(request: Request, response: Response, | |
| 36 | + level: str = Query("region", pattern="^(region|municipality)$"), | |
| 37 | + type: str = "all"): | |
| 38 | + """One rich row per geography — powers heatmap tables.""" | |
| 39 | + if etag_for(request, response, level, type, "overview"): | |
| 40 | + return Response(status_code=304) | |
| 41 | + return {**stats.overview_rows(level, type), | |
| 42 | + "author": "Simon-Pierre Boucher", "contact": "contact@spboucher.ai"} | |
| 43 | + | |
| 44 | + | |
| 45 | +def _parse_specs(series: str) -> list[tuple[str, str]]: | |
| 46 | + specs = [] | |
| 47 | + for p in [s.strip() for s in series.split(",") if s.strip()]: | |
| 48 | + try: | |
| 49 | + g, t = p.split(":") | |
| 50 | + except ValueError: | |
| 51 | + raise HTTPException(422, f"bad series spec '{p}' (want geography:type)") | |
| 52 | + validate_series(g, t) | |
| 53 | + specs.append((g, t)) | |
| 54 | + if not 1 <= len(specs) <= 5: | |
| 55 | + raise HTTPException(422, "provide 1 to 5 series") | |
| 56 | + return specs | |
| 57 | + | |
| 58 | + | |
| 59 | +@router.get("/report") | |
| 60 | +def series_report( | |
| 61 | + series: str = Query(..., description="comma list of geography:type (1-5)"), | |
| 62 | + format: str = Query("pdf", pattern="^(pdf|json)$"), | |
| 63 | +): | |
| 64 | + """Automatic report: one page per series (+ comparison page if several).""" | |
| 65 | + specs = _parse_specs(series) | |
| 66 | + if format == "json": | |
| 67 | + return {f"{g}:{t}": stats.series_stats(g, t) for g, t in specs} | |
| 68 | + pdf = report.build_series_report(specs) | |
| 69 | + fname = "qhpi_report_" + "_".join(f"{g}-{t}" for g, t in specs) + ".pdf" | |
| 70 | + return Response( | |
| 71 | + content=pdf, media_type="application/pdf", | |
| 72 | + headers={ | |
| 73 | + "Content-Disposition": f'inline; filename="{fname}"', | |
| 74 | + "Cache-Control": "public, max-age=300", | |
| 75 | + "X-Author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 76 | + }) | |
| 77 | + | |
| 78 | + | |
| 79 | +@router.get("/report/market") | |
| 80 | +def market_report(type: str = Query("all", pattern="^(all|unifamilial|condo|plex)$")): | |
| 81 | + """Full market report: province, 17-region pulse table, property types.""" | |
| 82 | + pdf = report.build_market_report(type) | |
| 83 | + return Response( | |
| 84 | + content=pdf, media_type="application/pdf", | |
| 85 | + headers={ | |
| 86 | + "Content-Disposition": f'inline; filename="qhpi_market_report_{type}.pdf"', | |
| 87 | + "Cache-Control": "public, max-age=300", | |
| 88 | + "X-Author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 89 | + }) | |
added
api/app/schemas.py
+148 −0
@@ -0,0 +1,148 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/schemas.py | |
| 6 | +# Purpose : Pydantic response schemas (monthly v2.1) — uncertainty and | |
| 7 | +# reliability are always present, never hidden. | |
| 8 | +# ============================================================================= | |
| 9 | +"""API response schemas — QHPI monthly.""" | |
| 10 | + | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +from pydantic import BaseModel | |
| 14 | + | |
| 15 | +AUTHOR = "Simon-Pierre Boucher" | |
| 16 | +CONTACT = "contact@spboucher.ai" | |
| 17 | + | |
| 18 | + | |
| 19 | +class Credit(BaseModel): | |
| 20 | + author: str = AUTHOR | |
| 21 | + contact: str = CONTACT | |
| 22 | + | |
| 23 | + | |
| 24 | +class Observation(BaseModel): | |
| 25 | + period: str | |
| 26 | + index: float | None | |
| 27 | + index_smoothed: float | |
| 28 | + representative_value: float | None | |
| 29 | + transactions: int | |
| 30 | + effective_sample_size: float | None | |
| 31 | + monthly_pct: float | None | |
| 32 | + three_month_pct: float | None | |
| 33 | + six_month_pct: float | None | |
| 34 | + yoy_pct: float | None | |
| 35 | + lower_95: float | |
| 36 | + upper_95: float | |
| 37 | + reliability_grade: str | |
| 38 | + shrinkage_weight: float | None | |
| 39 | + is_partial_month: bool | |
| 40 | + | |
| 41 | + | |
| 42 | +class SeriesResponse(Credit): | |
| 43 | + geography: str | |
| 44 | + geography_name: str | |
| 45 | + geography_level: str | |
| 46 | + property_type: str | |
| 47 | + frequency: str = "monthly" | |
| 48 | + base_period: str = "2021 average = 100" | |
| 49 | + model_version: str | |
| 50 | + data_vintage: str | |
| 51 | + total_observations: int | |
| 52 | + offset: int = 0 | |
| 53 | + limit: int | None = None | |
| 54 | + observations: list[Observation] | |
| 55 | + | |
| 56 | + | |
| 57 | +class LatestResponse(Credit): | |
| 58 | + geography: str | |
| 59 | + geography_name: str | |
| 60 | + property_type: str | |
| 61 | + frequency: str = "monthly" | |
| 62 | + period: str | |
| 63 | + latest_index: float | |
| 64 | + latest_index_raw: float | None | |
| 65 | + representative_value: float | None | |
| 66 | + monthly_change: float | None | |
| 67 | + three_month_change: float | None | |
| 68 | + yoy_change: float | None | |
| 69 | + transactions: int | |
| 70 | + effective_sample_size: float | None | |
| 71 | + reliability: str | |
| 72 | + lower_95: float | |
| 73 | + upper_95: float | |
| 74 | + is_partial_month: bool | |
| 75 | + model_version: str | |
| 76 | + data_vintage: str | |
| 77 | + | |
| 78 | + | |
| 79 | +class GeographyNode(BaseModel): | |
| 80 | + geography_id: str | |
| 81 | + geography_name: str | |
| 82 | + geography_level: str | |
| 83 | + property_types: list[str] | |
| 84 | + | |
| 85 | + | |
| 86 | +class GeographiesResponse(Credit): | |
| 87 | + data_vintage: str | |
| 88 | + published_series: list[GeographyNode] | |
| 89 | + coverage_matrix_url: str = "/v1/geographies/coverage" | |
| 90 | + | |
| 91 | + | |
| 92 | +class LiquidityPoint(BaseModel): | |
| 93 | + period: str | |
| 94 | + transactions: int | |
| 95 | + | |
| 96 | + | |
| 97 | +class LiquidityResponse(Credit): | |
| 98 | + geography: str | |
| 99 | + property_type: str | |
| 100 | + median_monthly_transactions: float | |
| 101 | + observations: list[LiquidityPoint] | |
| 102 | + | |
| 103 | + | |
| 104 | +class MapCell(BaseModel): | |
| 105 | + geography_id: str | |
| 106 | + geography_name: str | |
| 107 | + value: float | None | |
| 108 | + reliability_grade: str | |
| 109 | + transactions: int | |
| 110 | + | |
| 111 | + | |
| 112 | +class MapResponse(Credit): | |
| 113 | + metric: str | |
| 114 | + level: str | |
| 115 | + property_type: str | |
| 116 | + period: str | |
| 117 | + cells: list[MapCell] | |
| 118 | + | |
| 119 | + | |
| 120 | +class VintageObservation(BaseModel): | |
| 121 | + period: str | |
| 122 | + first_release_index_smoothed: float | |
| 123 | + current_index_smoothed: float | |
| 124 | + first_release_vintage: str | |
| 125 | + current_vintage: str | |
| 126 | + revision_pct: float | |
| 127 | + | |
| 128 | + | |
| 129 | +class MetaResponse(Credit): | |
| 130 | + name: str = "QHPI — Quebec Housing Price Index (monthly)" | |
| 131 | + model_version: str | |
| 132 | + data_vintage: str | |
| 133 | + frequency: str = "monthly" | |
| 134 | + base_period: str = "2021 average = 100" | |
| 135 | + methodology: str = ( | |
| 136 | + "Robust monthly architecture: province paths by rolling-time-dummy " | |
| 137 | + "hedonic (13-month Huber-IRLS windows, mean splice — revision-free); " | |
| 138 | + "liquid cells (≥40 tx/month) estimated directly by local robust " | |
| 139 | + "time-dummy with state-space smoothing; thin cells shrunk toward " | |
| 140 | + "their parent path via a heteroskedastic local-level Kalman model. " | |
| 141 | + "Validated against repeat sales, stratified matched-cell medians, " | |
| 142 | + "downsampling and composition-shock simulations." | |
| 143 | + ) | |
| 144 | + methodology_url: str = "/methodology" | |
| 145 | + seasonal_adjustment: str = "NSA (no stable monthly seasonality detected)" | |
| 146 | + series_count: int | |
| 147 | + periods: int | |
| 148 | + latest_complete_period: str | |
added
api/app/services/__init__.py
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/services/__init__.py | |
| 6 | +# Purpose : Services package for the QWHPI API. | |
| 7 | +# ============================================================================= | |
added
api/app/services/data.py
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/services/data.py | |
| 6 | +# Purpose : Data access layer — canonical parquet lake (source of truth), | |
| 7 | +# in-memory cached, vintage-keyed for ETag support. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Data access for the API. | |
| 10 | + | |
| 11 | +Reads the processed Parquet lake (never raw CSVs). Frames are cached in | |
| 12 | +memory and refreshed when the canonical file's mtime changes (the weekly | |
| 13 | +scheduler swaps files atomically). ``DATA_DIR`` can be overridden via the | |
| 14 | +``QWHPI_DATA_DIR`` environment variable (used by docker-compose). | |
| 15 | +""" | |
| 16 | + | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import os | |
| 20 | +import threading | |
| 21 | +from pathlib import Path | |
| 22 | + | |
| 23 | +import polars as pl | |
| 24 | + | |
| 25 | +_REPO_ROOT = Path(__file__).resolve().parents[3] | |
| 26 | +DATA_DIR = Path(os.environ.get("QWHPI_DATA_DIR", _REPO_ROOT / "data" / "processed")) | |
| 27 | + | |
| 28 | +CANONICAL = DATA_DIR / "qhpi_monthly.parquet" | |
| 29 | +COVERAGE = DATA_DIR / "coverage_matrix_monthly.parquet" | |
| 30 | +LIQUIDITY = DATA_DIR / "monthly_liquidity.parquet" | |
| 31 | +ASSESSMENT = DATA_DIR / "assessment_gap_monthly.parquet" | |
| 32 | +MUNICIPALITIES = DATA_DIR / "municipalities.parquet" | |
| 33 | +FIRST_RELEASE = DATA_DIR / "first_release_monthly.parquet" | |
| 34 | + | |
| 35 | +_lock = threading.Lock() | |
| 36 | +_cache: dict[str, tuple[float, pl.DataFrame]] = {} | |
| 37 | + | |
| 38 | + | |
| 39 | +def _load(path: Path) -> pl.DataFrame: | |
| 40 | + mtime = path.stat().st_mtime | |
| 41 | + with _lock: | |
| 42 | + hit = _cache.get(str(path)) | |
| 43 | + if hit and hit[0] == mtime: | |
| 44 | + return hit[1] | |
| 45 | + df = pl.read_parquet(path) | |
| 46 | + _cache[str(path)] = (mtime, df) | |
| 47 | + return df | |
| 48 | + | |
| 49 | + | |
| 50 | +def canonical() -> pl.DataFrame: | |
| 51 | + return _load(CANONICAL) | |
| 52 | + | |
| 53 | + | |
| 54 | +def coverage() -> pl.DataFrame: | |
| 55 | + return _load(COVERAGE) | |
| 56 | + | |
| 57 | + | |
| 58 | +def liquidity() -> pl.DataFrame: | |
| 59 | + return _load(LIQUIDITY) | |
| 60 | + | |
| 61 | + | |
| 62 | +def assessment_gap() -> pl.DataFrame: | |
| 63 | + return _load(ASSESSMENT) | |
| 64 | + | |
| 65 | + | |
| 66 | +def municipalities() -> pl.DataFrame: | |
| 67 | + return _load(MUNICIPALITIES) | |
| 68 | + | |
| 69 | + | |
| 70 | +def first_release() -> pl.DataFrame | None: | |
| 71 | + return _load(FIRST_RELEASE) if FIRST_RELEASE.exists() else None | |
| 72 | + | |
| 73 | + | |
| 74 | +def data_vintage() -> str: | |
| 75 | + return str(canonical()["data_vintage"][0]) | |
| 76 | + | |
| 77 | + | |
| 78 | +def model_version() -> str: | |
| 79 | + return str(canonical()["model_version"][0]) | |
| 80 | + | |
| 81 | + | |
| 82 | +def series(geography: str, property_type: str, | |
| 83 | + period_from: str | None = None, period_to: str | None = None) -> pl.DataFrame: | |
| 84 | + df = canonical().filter( | |
| 85 | + (pl.col("geography_id") == geography) | |
| 86 | + & (pl.col("property_type") == property_type) | |
| 87 | + ) | |
| 88 | + if period_from: | |
| 89 | + df = df.filter(pl.col("period") >= period_from) | |
| 90 | + if period_to and period_to != "latest": | |
| 91 | + df = df.filter(pl.col("period") <= period_to) | |
| 92 | + return df.sort("period") | |
| 93 | + | |
| 94 | + | |
| 95 | +def geography_ids() -> list[str]: | |
| 96 | + return canonical()["geography_id"].unique().sort().to_list() | |
added
api/app/services/report.py
+345 −0
@@ -0,0 +1,345 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/services/report.py | |
| 6 | +# Purpose : Automatic PDF report generation — single series, multi-series | |
| 7 | +# comparison, and full market report (matplotlib, palette-styled). | |
| 8 | +# ============================================================================= | |
| 9 | +"""PDF report factory. | |
| 10 | + | |
| 11 | +Every report is generated on demand from the canonical monthly parquet: | |
| 12 | +- series report ....... hero metrics grid, index chart with CI band, dollar | |
| 13 | + value chart, volume bars, growth & risk table | |
| 14 | +- comparison page ..... rebased multi-series chart + side-by-side table | |
| 15 | +- market report ....... Quebec overview, regional heatmap table, type panel | |
| 16 | + | |
| 17 | +Styling matches the dashboard palette; every page carries the author credit. | |
| 18 | +""" | |
| 19 | + | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import io | |
| 23 | +from datetime import date | |
| 24 | + | |
| 25 | +import matplotlib | |
| 26 | + | |
| 27 | +matplotlib.use("Agg") | |
| 28 | +import matplotlib.pyplot as plt # noqa: E402 | |
| 29 | +import numpy as np # noqa: E402 | |
| 30 | +import polars as pl # noqa: E402 | |
| 31 | +from matplotlib.backends.backend_pdf import PdfPages # noqa: E402 | |
| 32 | + | |
| 33 | +from app.services import data, stats # noqa: E402 | |
| 34 | + | |
| 35 | +# Palette (mirrors web/styles/globals.css light mode) | |
| 36 | +BLUE, ORANGE, AQUA, YELLOW, MAGENTA = "#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4" | |
| 37 | +SERIES_COLORS = [BLUE, ORANGE, AQUA, YELLOW, MAGENTA] | |
| 38 | +INK, INK2, MUTED = "#0b0b0b", "#52514e", "#8a887f" | |
| 39 | +SURFACE, BORDER, BAND = "#fcfcfb", "#dddcd6", (42 / 255, 120 / 255, 214 / 255, 0.14) | |
| 40 | +GOOD, BAD = "#008300", "#e34948" | |
| 41 | + | |
| 42 | +plt.rcParams.update({ | |
| 43 | + "figure.facecolor": SURFACE, "axes.facecolor": SURFACE, | |
| 44 | + "axes.edgecolor": BORDER, "axes.grid": True, "grid.color": BORDER, | |
| 45 | + "grid.linestyle": ":", "grid.linewidth": 0.7, | |
| 46 | + "axes.spines.top": False, "axes.spines.right": False, | |
| 47 | + "text.color": INK, "axes.labelcolor": INK2, | |
| 48 | + "xtick.color": INK2, "ytick.color": INK2, "font.size": 9, | |
| 49 | +}) | |
| 50 | + | |
| 51 | +PAGE = (8.27, 11.69) # A4 portrait | |
| 52 | + | |
| 53 | + | |
| 54 | +def _fmt_pct(v, plus=True): | |
| 55 | + if v is None: | |
| 56 | + return "—" | |
| 57 | + s = f"{v:+.2f}%" if plus else f"{v:.2f}%" | |
| 58 | + return s | |
| 59 | + | |
| 60 | + | |
| 61 | +def _fmt_money(v): | |
| 62 | + return "—" if v is None else f"${v:,.0f}" | |
| 63 | + | |
| 64 | + | |
| 65 | +def _footer(fig, page_note=""): | |
| 66 | + fig.text(0.05, 0.02, "QHPI — Quebec Housing Price Index · Simon-Pierre Boucher · contact@spboucher.ai", | |
| 67 | + fontsize=7, color=MUTED) | |
| 68 | + fig.text(0.95, 0.02, page_note, fontsize=7, color=MUTED, ha="right") | |
| 69 | + | |
| 70 | + | |
| 71 | +def _title_block(fig, title, subtitle): | |
| 72 | + fig.text(0.05, 0.955, title, fontsize=19, fontweight="bold", color=INK) | |
| 73 | + fig.text(0.05, 0.928, subtitle, fontsize=9.5, color=INK2) | |
| 74 | + fig.add_artist(plt.Line2D([0.05, 0.95], [0.915, 0.915], | |
| 75 | + color=BORDER, lw=1, transform=fig.transFigure)) | |
| 76 | + | |
| 77 | + | |
| 78 | +def _metric_cells(fig, y_top, items, ncols=4): | |
| 79 | + """Grid of metric tiles: items = [(label, value, color?)]""" | |
| 80 | + n = len(items) | |
| 81 | + nrows = (n + ncols - 1) // ncols | |
| 82 | + w = 0.9 / ncols | |
| 83 | + h = 0.055 | |
| 84 | + for i, item in enumerate(items): | |
| 85 | + label, value = item[0], item[1] | |
| 86 | + color = item[2] if len(item) > 2 else INK | |
| 87 | + r, c = divmod(i, ncols) | |
| 88 | + x = 0.05 + c * w | |
| 89 | + y = y_top - r * (h + 0.018) | |
| 90 | + fig.text(x, y, label.upper(), fontsize=6.7, color=MUTED) | |
| 91 | + fig.text(x, y - 0.026, value, fontsize=13, fontweight="bold", color=color) | |
| 92 | + return y_top - nrows * (h + 0.018) | |
| 93 | + | |
| 94 | + | |
| 95 | +def _delta_color(v): | |
| 96 | + if v is None: | |
| 97 | + return INK | |
| 98 | + return GOOD if v >= 0 else BAD | |
| 99 | + | |
| 100 | + | |
| 101 | +def _index_axes(ax, df, unit="points", legend=True): | |
| 102 | + periods = df["period"].to_list() | |
| 103 | + x = np.arange(len(periods)) | |
| 104 | + k = 1.0 | |
| 105 | + if unit == "dollars": | |
| 106 | + row = df.filter(pl.col("representative_value").is_not_null()) | |
| 107 | + k = row["representative_value"][0] / row["index_smoothed"][0] | |
| 108 | + smoothed = df["index_smoothed"].to_numpy() * k | |
| 109 | + lo = df["lower_95"].to_numpy() * k | |
| 110 | + hi = df["upper_95"].to_numpy() * k | |
| 111 | + raw = df["index"].to_numpy() * k | |
| 112 | + ax.fill_between(x, lo, hi, color=BAND, label="95% CI", linewidth=0) | |
| 113 | + ax.plot(x, raw, color=BLUE, alpha=0.35, lw=0.8, label="raw monthly") | |
| 114 | + ax.plot(x, smoothed, color=BLUE, lw=1.9, label="index (smoothed)") | |
| 115 | + pad = (smoothed.max() - smoothed.min()) * 0.15 or 1 | |
| 116 | + ax.set_ylim(min(smoothed.min(), np.nanmin(raw)) - pad, | |
| 117 | + max(smoothed.max(), np.nanmax(raw)) + pad) | |
| 118 | + step = max(1, len(x) // 8) | |
| 119 | + ax.set_xticks(x[::step], [p for p in periods[::step]], fontsize=7.5) | |
| 120 | + if unit == "dollars": | |
| 121 | + ax.yaxis.set_major_formatter(lambda v, _: f"${v / 1000:.0f}k") | |
| 122 | + if legend: | |
| 123 | + ax.legend(loc="upper left", fontsize=7.5, frameon=False) | |
| 124 | + | |
| 125 | + | |
| 126 | +def _volume_axes(ax, df): | |
| 127 | + x = np.arange(df.height) | |
| 128 | + ax.bar(x, df["transactions"].to_numpy(), color=BLUE, alpha=0.55, width=0.85) | |
| 129 | + ax.set_xticks([]) | |
| 130 | + ax.tick_params(labelsize=6.5) | |
| 131 | + ax.set_title("Transactions per month", fontsize=8, loc="left", color=INK2) | |
| 132 | + | |
| 133 | + | |
| 134 | +def _series_page(pdf, gid, ptype): | |
| 135 | + df = data.series(gid, ptype).filter(~pl.col("is_partial_month")) | |
| 136 | + st = stats.series_stats(gid, ptype) | |
| 137 | + name = df["geography_name"][0] | |
| 138 | + fig = plt.figure(figsize=PAGE) | |
| 139 | + _title_block(fig, f"{name} — {ptype}", | |
| 140 | + f"Monthly housing price index · base 2021 = 100 · data to {st['period']} · " | |
| 141 | + f"reliability {st['reliability_grade']} · generated {date.today().isoformat()}") | |
| 142 | + | |
| 143 | + y = _metric_cells(fig, 0.875, [ | |
| 144 | + ("Index", f"{st['index']:.1f}"), | |
| 145 | + ("Dollar value", _fmt_money(st["representative_value"])), | |
| 146 | + ("1 month", _fmt_pct(st["monthly_pct"]), _delta_color(st["monthly_pct"])), | |
| 147 | + ("Year over year", _fmt_pct(st["yoy_pct"]), _delta_color(st["yoy_pct"])), | |
| 148 | + ("3 months", _fmt_pct(st["three_month_pct"]), _delta_color(st["three_month_pct"])), | |
| 149 | + ("6 months", _fmt_pct(st["six_month_pct"]), _delta_color(st["six_month_pct"])), | |
| 150 | + ("Since 2021", _fmt_pct(st["since_2021_pct"]), _delta_color(st["since_2021_pct"])), | |
| 151 | + ("CAGR", _fmt_pct(st["cagr_pct"]), _delta_color(st["cagr_pct"])), | |
| 152 | + ("Peak", f"{st['peak_index']:.1f} ({st['peak_period']})"), | |
| 153 | + ("Vs peak", _fmt_pct(st["drawdown_pct"]), | |
| 154 | + GOOD if st["at_record_high"] else BAD), | |
| 155 | + ("Volatility (12m)", _fmt_pct(st["volatility_12m_pct"], plus=False)), | |
| 156 | + ("Momentum (3m ann.)", _fmt_pct(st["momentum_3m_ann_pct"]), | |
| 157 | + _delta_color(st["momentum_3m_ann_pct"])), | |
| 158 | + ("Sales (12m)", f"{st['volume_12m']:,}"), | |
| 159 | + ("Volume YoY", _fmt_pct(st["volume_yoy_pct"]), | |
| 160 | + _delta_color(st["volume_yoy_pct"])), | |
| 161 | + ("$ volume (12m, est.)", f"${st['dollar_volume_12m'] / 1e6:,.0f}M"), | |
| 162 | + ("YoY rank", f"{st['yoy_rank']}/{st['yoy_rank_of']}" if st["yoy_rank"] else "—"), | |
| 163 | + ]) | |
| 164 | + | |
| 165 | + del y # metrics grid occupies down to ~0.56; charts use fixed slots | |
| 166 | + ax1 = fig.add_axes([0.07, 0.345, 0.87, 0.185]) | |
| 167 | + _index_axes(ax1, df) | |
| 168 | + ax1.set_title("Index (2021 = 100) with 95% confidence band", | |
| 169 | + fontsize=9, loc="left", color=INK2) | |
| 170 | + | |
| 171 | + ax2 = fig.add_axes([0.07, 0.125, 0.87, 0.165]) | |
| 172 | + _index_axes(ax2, df, unit="dollars", legend=False) | |
| 173 | + ax2.set_title("Representative dollar value (fixed 2021 basket)", | |
| 174 | + fontsize=9, loc="left", color=INK2) | |
| 175 | + | |
| 176 | + ax3 = fig.add_axes([0.07, 0.042, 0.87, 0.05]) | |
| 177 | + _volume_axes(ax3, df) | |
| 178 | + | |
| 179 | + _footer(fig, f"{gid}:{ptype}") | |
| 180 | + pdf.savefig(fig) | |
| 181 | + plt.close(fig) | |
| 182 | + | |
| 183 | + | |
| 184 | +def _comparison_page(pdf, specs): | |
| 185 | + fig = plt.figure(figsize=PAGE) | |
| 186 | + _title_block(fig, "Comparison", "All series rebased to 100 at the first common month") | |
| 187 | + frames = {} | |
| 188 | + for gid, ptype in specs: | |
| 189 | + frames[(gid, ptype)] = data.series(gid, ptype).filter(~pl.col("is_partial_month")) | |
| 190 | + common = sorted(set.intersection(*[set(f["period"].to_list()) for f in frames.values()])) | |
| 191 | + base_p = common[0] | |
| 192 | + | |
| 193 | + ax = fig.add_axes([0.07, 0.45, 0.87, 0.42]) | |
| 194 | + rows = [] | |
| 195 | + for i, ((gid, ptype), f) in enumerate(frames.items()): | |
| 196 | + f = f.filter(pl.col("period").is_in(common)).sort("period") | |
| 197 | + idx = f["index_smoothed"].to_numpy() | |
| 198 | + rebased = idx / idx[0] * 100 | |
| 199 | + x = np.arange(len(common)) | |
| 200 | + name = f["geography_name"][0] | |
| 201 | + ax.plot(x, rebased, color=SERIES_COLORS[i % 5], lw=1.9, label=f"{name} · {ptype}") | |
| 202 | + st = stats.series_stats(gid, ptype) | |
| 203 | + rows.append([f"{name} · {ptype}", f"{st['index']:.1f}", | |
| 204 | + _fmt_pct(st["yoy_pct"]), _fmt_pct(st["since_2021_pct"]), | |
| 205 | + _fmt_money(st["representative_value"]), st["reliability_grade"]]) | |
| 206 | + step = max(1, len(common) // 8) | |
| 207 | + ax.set_xticks(np.arange(len(common))[::step], common[::step], fontsize=7.5) | |
| 208 | + ax.legend(loc="upper left", fontsize=8, frameon=False) | |
| 209 | + ax.set_title(f"Rebased to 100 at {base_p}", fontsize=9, loc="left", color=INK2) | |
| 210 | + | |
| 211 | + tbl_ax = fig.add_axes([0.07, 0.13, 0.87, 0.24]) | |
| 212 | + tbl_ax.axis("off") | |
| 213 | + table = tbl_ax.table( | |
| 214 | + cellText=rows, | |
| 215 | + colLabels=["Series", "Index", "YoY", "Since 2021", "$ value", "Grade"], | |
| 216 | + loc="upper center", cellLoc="center") | |
| 217 | + table.auto_set_font_size(False) | |
| 218 | + table.set_fontsize(8.5) | |
| 219 | + table.scale(1, 1.6) | |
| 220 | + for (r, c), cell in table.get_celld().items(): | |
| 221 | + cell.set_edgecolor(BORDER) | |
| 222 | + if r == 0: | |
| 223 | + cell.set_text_props(color=INK2, fontweight="bold") | |
| 224 | + cell.set_facecolor("#f0efec") | |
| 225 | + _footer(fig, "comparison") | |
| 226 | + pdf.savefig(fig) | |
| 227 | + plt.close(fig) | |
| 228 | + | |
| 229 | + | |
| 230 | +def build_series_report(specs: list[tuple[str, str]]) -> bytes: | |
| 231 | + buf = io.BytesIO() | |
| 232 | + with PdfPages(buf) as pdf: | |
| 233 | + for gid, ptype in specs: | |
| 234 | + _series_page(pdf, gid, ptype) | |
| 235 | + if len(specs) > 1: | |
| 236 | + _comparison_page(pdf, specs) | |
| 237 | + meta = pdf.infodict() | |
| 238 | + meta["Title"] = "QHPI report — " + ", ".join(f"{g}:{t}" for g, t in specs) | |
| 239 | + meta["Author"] = "Simon-Pierre Boucher <contact@spboucher.ai>" | |
| 240 | + return buf.getvalue() | |
| 241 | + | |
| 242 | + | |
| 243 | +def build_market_report(ptype: str = "all") -> bytes: | |
| 244 | + buf = io.BytesIO() | |
| 245 | + with PdfPages(buf) as pdf: | |
| 246 | + # Page 1 — province | |
| 247 | + _series_page(pdf, "quebec", ptype) | |
| 248 | + | |
| 249 | + # Page 2 — regional heatmap table | |
| 250 | + ov = stats.overview_rows("region", ptype) | |
| 251 | + fig = plt.figure(figsize=PAGE) | |
| 252 | + _title_block(fig, "Regions — market pulse", | |
| 253 | + f"All 17 administrative regions · {ptype} · month of {ov['period']}") | |
| 254 | + rows, colors = [], [] | |
| 255 | + vals = [r["yoy_pct"] for r in ov["rows"] if r["yoy_pct"] is not None] | |
| 256 | + lo, hi = min(vals), max(vals) | |
| 257 | + | |
| 258 | + def heat(v): | |
| 259 | + if v is None: | |
| 260 | + return SURFACE | |
| 261 | + t = 0.5 if hi == lo else (v - lo) / (hi - lo) | |
| 262 | + ramp = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf"] | |
| 263 | + return ramp[min(4, int(t * 5))] | |
| 264 | + | |
| 265 | + for r in ov["rows"]: | |
| 266 | + rows.append([r["geography_name"][:28], f"{r['index']:.1f}", | |
| 267 | + _fmt_pct(r["monthly_pct"]), _fmt_pct(r["three_month_pct"]), | |
| 268 | + _fmt_pct(r["six_month_pct"]), _fmt_pct(r["yoy_pct"]), | |
| 269 | + _fmt_pct(r["drawdown_pct"]), f"{r['volume_12m']:,}", | |
| 270 | + r["reliability_grade"]]) | |
| 271 | + colors.append(heat(r["yoy_pct"])) | |
| 272 | + ax = fig.add_axes([0.05, 0.44, 0.9, 0.44]) | |
| 273 | + ax.axis("off") | |
| 274 | + widths = [0.26] + [0.0925] * 8 | |
| 275 | + table = ax.table( | |
| 276 | + cellText=rows, | |
| 277 | + colLabels=["Region", "Index", "1m", "3m", "6m", "YoY", "Vs peak", "Sales 12m", "Grade"], | |
| 278 | + loc="upper center", cellLoc="center", colWidths=widths) | |
| 279 | + table.auto_set_font_size(False) | |
| 280 | + table.set_fontsize(7.6) | |
| 281 | + table.scale(1, 1.45) | |
| 282 | + for (r, c), cell in table.get_celld().items(): | |
| 283 | + cell.set_edgecolor(BORDER) | |
| 284 | + if c == 0: | |
| 285 | + cell.set_text_props(ha="left") | |
| 286 | + cell.PAD = 0.02 | |
| 287 | + if r == 0: | |
| 288 | + cell.set_text_props(color=INK2, fontweight="bold", | |
| 289 | + ha="left" if c == 0 else "center") | |
| 290 | + cell.set_facecolor("#f0efec") | |
| 291 | + elif c == 5: | |
| 292 | + cell.set_facecolor(colors[r - 1]) | |
| 293 | + if colors[r - 1] in ("#256abf", "#3987e5"): | |
| 294 | + cell.set_text_props(color="white") | |
| 295 | + fig.text(0.05, 0.415, "YoY column shaded by appreciation (sequential blue ramp).", | |
| 296 | + fontsize=7.5, color=MUTED) | |
| 297 | + | |
| 298 | + # YoY horizontal bars under the table | |
| 299 | + axb = fig.add_axes([0.30, 0.075, 0.62, 0.30]) | |
| 300 | + names = [r["geography_name"] for r in ov["rows"]][::-1] | |
| 301 | + yoys = [r["yoy_pct"] or 0 for r in ov["rows"]][::-1] | |
| 302 | + axb.barh(np.arange(len(names)), yoys, | |
| 303 | + color=[heat(v) for v in yoys], height=0.72) | |
| 304 | + axb.set_yticks(np.arange(len(names)), names, fontsize=7) | |
| 305 | + axb.set_title("Year-over-year appreciation (%)", fontsize=9, | |
| 306 | + loc="left", color=INK2) | |
| 307 | + axb.tick_params(axis="x", labelsize=7) | |
| 308 | + _footer(fig, "regions") | |
| 309 | + pdf.savefig(fig) | |
| 310 | + plt.close(fig) | |
| 311 | + | |
| 312 | + # Page 3 — property types (province) | |
| 313 | + fig = plt.figure(figsize=PAGE) | |
| 314 | + _title_block(fig, "Property types — province", | |
| 315 | + "Unifamilial, condo and plex indexes (2021 = 100)") | |
| 316 | + ax = fig.add_axes([0.07, 0.52, 0.87, 0.36]) | |
| 317 | + for i, t in enumerate(["unifamilial", "condo", "plex"]): | |
| 318 | + f = data.series("quebec", t).filter(~pl.col("is_partial_month")).sort("period") | |
| 319 | + x = np.arange(f.height) | |
| 320 | + ax.plot(x, f["index_smoothed"].to_numpy(), color=SERIES_COLORS[i], | |
| 321 | + lw=1.9, label=t) | |
| 322 | + periods = f["period"].to_list() | |
| 323 | + step = max(1, len(periods) // 8) | |
| 324 | + ax.set_xticks(np.arange(len(periods))[::step], periods[::step], fontsize=7.5) | |
| 325 | + ax.legend(loc="upper left", fontsize=8.5, frameon=False) | |
| 326 | + | |
| 327 | + fig.text(0.07, 0.42, "Method", fontsize=11, fontweight="bold") | |
| 328 | + fig.text(0.07, 0.20, | |
| 329 | + "Robust monthly architecture: province paths estimated by rolling-time-dummy hedonic\n" | |
| 330 | + "regressions (13-month Huber-weighted windows, mean-splice linking — the published\n" | |
| 331 | + "history never revises); liquid cells (≥40 transactions/month) estimated directly by a\n" | |
| 332 | + "local robust time-dummy; thin cells shrunk toward their parent path by a heteroskedastic\n" | |
| 333 | + "local-level Kalman filter. Validated against repeat sales, stratified matched-cell\n" | |
| 334 | + "medians, downsampling and composition-shock simulations. Variables with time-correlated\n" | |
| 335 | + "missingness are excluded by rule. All series NSA, base 2021 average = 100.\n\n" | |
| 336 | + "Full methodology: www.indexqc.house/methodology", | |
| 337 | + fontsize=8.5, color=INK2, va="bottom") | |
| 338 | + _footer(fig, "types & method") | |
| 339 | + pdf.savefig(fig) | |
| 340 | + plt.close(fig) | |
| 341 | + | |
| 342 | + meta = pdf.infodict() | |
| 343 | + meta["Title"] = f"QHPI market report — {ptype}" | |
| 344 | + meta["Author"] = "Simon-Pierre Boucher <contact@spboucher.ai>" | |
| 345 | + return buf.getvalue() | |
added
api/app/services/stats.py
+164 −0
@@ -0,0 +1,164 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/app/services/stats.py | |
| 6 | +# Purpose : Rich derived metrics per series — peak/drawdown, momentum, | |
| 7 | +# volatility, CAGR, ranks, volumes, assessment gap. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Derived series statistics. | |
| 10 | + | |
| 11 | +Everything is computed from the canonical monthly parquet (complete months | |
| 12 | +only). Definitions: | |
| 13 | + | |
| 14 | +- peak / drawdown ....... max of index_smoothed; % below it now | |
| 15 | +- since_2021_pct ........ latest index − 100 (base = 2021 average) | |
| 16 | +- cagr_pct .............. annualized log growth from the first 12-month mean | |
| 17 | + to the latest 12-month mean | |
| 18 | +- volatility_12m_pct .... SD of the last 12 monthly log changes (monthly, %) | |
| 19 | +- momentum_3m_ann_pct ... last 3-month log growth, annualized | |
| 20 | +- yoy_rank .............. rank of YoY among geographies of the same level | |
| 21 | + and property type (1 = fastest) | |
| 22 | +- volume_12m / volume_prev_12m / volume_yoy_pct | |
| 23 | +- dollar_volume_12m ..... Σ transactions × representative value (estimate) | |
| 24 | +- assessment_gap ........ latest median sale/assessment ratio (region & | |
| 25 | + province only) | |
| 26 | +""" | |
| 27 | + | |
| 28 | +from __future__ import annotations | |
| 29 | + | |
| 30 | +import math | |
| 31 | + | |
| 32 | +import numpy as np | |
| 33 | +import polars as pl | |
| 34 | + | |
| 35 | +from app.services import data | |
| 36 | + | |
| 37 | + | |
| 38 | +def _complete(gid: str, ptype: str) -> pl.DataFrame: | |
| 39 | + return data.series(gid, ptype).filter(~pl.col("is_partial_month")) | |
| 40 | + | |
| 41 | + | |
| 42 | +def series_stats(gid: str, ptype: str) -> dict: | |
| 43 | + df = _complete(gid, ptype) | |
| 44 | + if df.height < 14: | |
| 45 | + return {} | |
| 46 | + idx = df["index_smoothed"].to_numpy() | |
| 47 | + periods = df["period"].to_list() | |
| 48 | + li = np.log(idx) | |
| 49 | + | |
| 50 | + peak_i = int(np.argmax(idx)) | |
| 51 | + latest = float(idx[-1]) | |
| 52 | + peak = float(idx[peak_i]) | |
| 53 | + drawdown = (latest / peak - 1) * 100 | |
| 54 | + months_since_peak = len(idx) - 1 - peak_i | |
| 55 | + | |
| 56 | + first12 = float(np.mean(li[:12])) | |
| 57 | + last12 = float(np.mean(li[-12:])) | |
| 58 | + years = (len(li) - 12) / 12 | |
| 59 | + cagr = (math.exp((last12 - first12) / max(years, 1e-9)) - 1) * 100 | |
| 60 | + | |
| 61 | + d = np.diff(li) | |
| 62 | + vol12 = float(np.std(d[-12:])) * 100 | |
| 63 | + mom3 = (math.exp((li[-1] - li[-4]) * 4) - 1) * 100 | |
| 64 | + | |
| 65 | + tx = df["transactions"].to_numpy().astype(float) | |
| 66 | + rep = df["representative_value"].to_numpy() | |
| 67 | + vol_12m = float(np.nansum(tx[-12:])) | |
| 68 | + vol_prev = float(np.nansum(tx[-24:-12])) if len(tx) >= 24 else None | |
| 69 | + volume_yoy = ((vol_12m / vol_prev - 1) * 100) if vol_prev else None | |
| 70 | + dollar_12m = float(np.nansum(tx[-12:] * np.nan_to_num(rep[-12:]))) | |
| 71 | + | |
| 72 | + last = df.row(df.height - 1, named=True) | |
| 73 | + | |
| 74 | + # Rank among same level × type on YoY. | |
| 75 | + level = last["geography_level"] | |
| 76 | + peers = ( | |
| 77 | + data.canonical() | |
| 78 | + .filter((pl.col("geography_level") == level) | |
| 79 | + & (pl.col("property_type") == ptype) | |
| 80 | + & (pl.col("period") == last["period"])) | |
| 81 | + .sort("yoy_pct", descending=True, nulls_last=True) | |
| 82 | + ) | |
| 83 | + ids = peers["geography_id"].to_list() | |
| 84 | + rank = (ids.index(gid) + 1) if gid in ids else None | |
| 85 | + | |
| 86 | + # Assessment gap (province & regions). | |
| 87 | + gap = None | |
| 88 | + gap_df = data.assessment_gap().filter( | |
| 89 | + (pl.col("geography_id") == gid) & (pl.col("property_type") == ptype)) | |
| 90 | + if gap_df.height: | |
| 91 | + gap = float(gap_df.sort("period")["median_ratio"][-1]) | |
| 92 | + | |
| 93 | + hist12 = df.tail(24) | |
| 94 | + return { | |
| 95 | + "period": last["period"], | |
| 96 | + "index": round(latest, 2), | |
| 97 | + "representative_value": last["representative_value"], | |
| 98 | + "lower_95": round(last["lower_95"], 2), | |
| 99 | + "upper_95": round(last["upper_95"], 2), | |
| 100 | + "reliability_grade": last["reliability_grade"], | |
| 101 | + "monthly_pct": last["monthly_pct"], | |
| 102 | + "three_month_pct": last["three_month_pct"], | |
| 103 | + "six_month_pct": last["six_month_pct"], | |
| 104 | + "yoy_pct": last["yoy_pct"], | |
| 105 | + "since_2021_pct": round(latest - 100, 2), | |
| 106 | + "cagr_pct": round(cagr, 2), | |
| 107 | + "peak_index": round(peak, 2), | |
| 108 | + "peak_period": periods[peak_i], | |
| 109 | + "drawdown_pct": round(drawdown, 2), | |
| 110 | + "months_since_peak": months_since_peak, | |
| 111 | + "at_record_high": bool(months_since_peak == 0), | |
| 112 | + "volatility_12m_pct": round(vol12, 2), | |
| 113 | + "momentum_3m_ann_pct": round(mom3, 2), | |
| 114 | + "yoy_rank": rank, | |
| 115 | + "yoy_rank_of": len(ids), | |
| 116 | + "volume_12m": int(vol_12m), | |
| 117 | + "volume_yoy_pct": round(volume_yoy, 2) if volume_yoy is not None else None, | |
| 118 | + "dollar_volume_12m": int(dollar_12m), | |
| 119 | + "assessment_gap": gap, | |
| 120 | + "effective_sample_size": last["effective_sample_size"], | |
| 121 | + "spark": [round(v, 2) for v in hist12["index_smoothed"].to_list()], | |
| 122 | + } | |
| 123 | + | |
| 124 | + | |
| 125 | +def overview(level: str = "region", ptype: str = "all") -> list[dict]: | |
| 126 | + """One rich row per geography of a level — powers the heatmap table.""" | |
| 127 | + c = data.canonical().filter( | |
| 128 | + (pl.col("geography_level") == level) | |
| 129 | + & (pl.col("property_type") == ptype) | |
| 130 | + & (~pl.col("is_partial_month"))) | |
| 131 | + latest_period = c["period"].max() | |
| 132 | + out = [] | |
| 133 | + for gid in c["geography_id"].unique().sort().to_list(): | |
| 134 | + df = c.filter(pl.col("geography_id") == gid).sort("period") | |
| 135 | + if df.height < 14: | |
| 136 | + continue | |
| 137 | + idx = df["index_smoothed"].to_numpy() | |
| 138 | + peak = float(np.max(idx)) | |
| 139 | + last = df.row(df.height - 1, named=True) | |
| 140 | + tx = df["transactions"].to_numpy().astype(float) | |
| 141 | + out.append({ | |
| 142 | + "geography_id": gid, | |
| 143 | + "geography_name": last["geography_name"], | |
| 144 | + "period": last["period"], | |
| 145 | + "index": round(float(idx[-1]), 1), | |
| 146 | + "monthly_pct": last["monthly_pct"], | |
| 147 | + "three_month_pct": last["three_month_pct"], | |
| 148 | + "six_month_pct": last["six_month_pct"], | |
| 149 | + "yoy_pct": last["yoy_pct"], | |
| 150 | + "since_2021_pct": round(float(idx[-1]) - 100, 1), | |
| 151 | + "drawdown_pct": round((float(idx[-1]) / peak - 1) * 100, 2), | |
| 152 | + "at_record_high": bool(np.argmax(idx) == len(idx) - 1), | |
| 153 | + "volume_12m": int(np.nansum(tx[-12:])), | |
| 154 | + "representative_value": last["representative_value"], | |
| 155 | + "reliability_grade": last["reliability_grade"], | |
| 156 | + "spark": [round(v, 2) for v in df["index_smoothed"].tail(24).to_list()], | |
| 157 | + }) | |
| 158 | + return sorted(out, key=lambda r: -(r["yoy_pct"] or -999)), latest_period | |
| 159 | + | |
| 160 | + | |
| 161 | +def overview_rows(level: str = "region", ptype: str = "all") -> dict: | |
| 162 | + rows, latest_period = overview(level, ptype) | |
| 163 | + return {"level": level, "property_type": ptype, | |
| 164 | + "period": latest_period, "rows": rows} | |
added
api/pyproject.toml
+31 −0
@@ -0,0 +1,31 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/pyproject.toml | |
| 6 | +# Purpose : Package metadata and dependencies for the QWHPI API service. | |
| 7 | +# ============================================================================= | |
| 8 | + | |
| 9 | +[project] | |
| 10 | +name = "qwhpi-api" | |
| 11 | +version = "0.1.0" | |
| 12 | +description = "Quebec Weekly Housing Price Index — API service" | |
| 13 | +authors = [{ name = "Simon-Pierre Boucher", email = "contact@spboucher.ai" }] | |
| 14 | +requires-python = ">=3.12" | |
| 15 | +dependencies = [ | |
| 16 | + "fastapi>=0.110", | |
| 17 | + "uvicorn>=0.29", | |
| 18 | + "polars>=1.0", | |
| 19 | + "pydantic>=2.7", | |
| 20 | + "psycopg[binary]>=3.1; extra == 'postgres'", | |
| 21 | +] | |
| 22 | + | |
| 23 | +[project.optional-dependencies] | |
| 24 | +dev = ["pytest>=8", "httpx>=0.27"] | |
| 25 | + | |
| 26 | +[build-system] | |
| 27 | +requires = ["hatchling"] | |
| 28 | +build-backend = "hatchling.build" | |
| 29 | + | |
| 30 | +[tool.hatch.build.targets.wheel] | |
| 31 | +packages = ["app"] | |
added
api/tests/test_api.py
+136 −0
@@ -0,0 +1,136 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : api/tests/test_api.py | |
| 6 | +# Purpose : API tests (monthly v2.1) — every router, ETag / CSV / pagination. | |
| 7 | +# ============================================================================= | |
| 8 | +"""API test suite (runs against the real canonical monthly parquet).""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import sys | |
| 13 | +from pathlib import Path | |
| 14 | + | |
| 15 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) | |
| 16 | + | |
| 17 | +from fastapi.testclient import TestClient | |
| 18 | + | |
| 19 | +from app.main import app | |
| 20 | + | |
| 21 | +client = TestClient(app) | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_health(): | |
| 25 | + r = client.get("/health") | |
| 26 | + assert r.status_code == 200 | |
| 27 | + assert r.json()["status"] == "ok" | |
| 28 | + | |
| 29 | + | |
| 30 | +def test_meta_credits_author_and_monthly(): | |
| 31 | + r = client.get("/v1/meta") | |
| 32 | + assert r.status_code == 200 | |
| 33 | + body = r.json() | |
| 34 | + assert body["author"] == "Simon-Pierre Boucher" | |
| 35 | + assert body["contact"] == "contact@spboucher.ai" | |
| 36 | + assert body["frequency"] == "monthly" | |
| 37 | + assert body["series_count"] > 100 | |
| 38 | + assert body["periods"] >= 60 | |
| 39 | + | |
| 40 | + | |
| 41 | +def test_index_series_full(): | |
| 42 | + r = client.get("/v1/index", params={"geography": "quebec-city", "type": "condo"}) | |
| 43 | + assert r.status_code == 200 | |
| 44 | + body = r.json() | |
| 45 | + assert body["frequency"] == "monthly" | |
| 46 | + obs = body["observations"] | |
| 47 | + assert len(obs) == body["total_observations"] >= 60 | |
| 48 | + last = obs[-1] | |
| 49 | + for key in ("lower_95", "upper_95", "reliability_grade", "transactions", | |
| 50 | + "monthly_pct", "yoy_pct"): | |
| 51 | + assert key in last # uncertainty is never hidden | |
| 52 | + | |
| 53 | + | |
| 54 | +def test_index_pagination(): | |
| 55 | + r = client.get("/v1/index", params={"geography": "quebec", "type": "all", | |
| 56 | + "limit": 10, "offset": 5}) | |
| 57 | + body = r.json() | |
| 58 | + assert len(body["observations"]) == 10 | |
| 59 | + assert body["offset"] == 5 | |
| 60 | + | |
| 61 | + | |
| 62 | +def test_index_csv_export(): | |
| 63 | + r = client.get("/v1/index", params={"geography": "montreal", "type": "condo", | |
| 64 | + "format": "csv"}) | |
| 65 | + assert r.status_code == 200 | |
| 66 | + assert r.headers["content-type"].startswith("text/csv") | |
| 67 | + assert r.text.splitlines()[0].startswith("period") | |
| 68 | + | |
| 69 | + | |
| 70 | +def test_index_etag_304(): | |
| 71 | + r1 = client.get("/v1/index/latest", params={"geography": "quebec", "type": "all"}) | |
| 72 | + etag = r1.headers.get("etag") | |
| 73 | + assert etag | |
| 74 | + r2 = client.get("/v1/index/latest", params={"geography": "quebec", "type": "all"}, | |
| 75 | + headers={"If-None-Match": etag}) | |
| 76 | + assert r2.status_code == 304 | |
| 77 | + | |
| 78 | + | |
| 79 | +def test_latest_excludes_partial_month(): | |
| 80 | + r = client.get("/v1/index/latest", params={"geography": "quebec", "type": "all"}) | |
| 81 | + body = r.json() | |
| 82 | + assert body["is_partial_month"] is False | |
| 83 | + assert body["reliability"] in "ABCDE" | |
| 84 | + assert body["period"] >= "2026-06" | |
| 85 | + | |
| 86 | + | |
| 87 | +def test_unknown_geography_404(): | |
| 88 | + r = client.get("/v1/index", params={"geography": "atlantis", "type": "all"}) | |
| 89 | + assert r.status_code == 404 | |
| 90 | + | |
| 91 | + | |
| 92 | +def test_bad_type_422(): | |
| 93 | + r = client.get("/v1/index", params={"geography": "quebec", "type": "castle"}) | |
| 94 | + assert r.status_code == 422 | |
| 95 | + | |
| 96 | + | |
| 97 | +def test_geographies_hierarchy(): | |
| 98 | + r = client.get("/v1/geographies") | |
| 99 | + body = r.json() | |
| 100 | + levels = {g["geography_level"] for g in body["published_series"]} | |
| 101 | + assert levels == {"province", "region", "municipality"} | |
| 102 | + | |
| 103 | + | |
| 104 | +def test_coverage_matrix(): | |
| 105 | + r = client.get("/v1/geographies/coverage", params={"status": "published"}) | |
| 106 | + assert r.status_code == 200 | |
| 107 | + assert len(r.json()["coverage"]) > 0 | |
| 108 | + | |
| 109 | + | |
| 110 | +def test_liquidity(): | |
| 111 | + r = client.get("/v1/liquidity", params={"geography": "montreal", "type": "condo"}) | |
| 112 | + body = r.json() | |
| 113 | + assert body["median_monthly_transactions"] > 400 | |
| 114 | + | |
| 115 | + | |
| 116 | +def test_compare_with_rebase(): | |
| 117 | + r = client.get("/v1/compare", params={ | |
| 118 | + "series": "montreal:condo,quebec-city:condo", | |
| 119 | + "rebase_period": "2022-01"}) | |
| 120 | + body = r.json() | |
| 121 | + assert set(body["series"]) == {"montreal:condo", "quebec-city:condo"} | |
| 122 | + for obs in body["series"].values(): | |
| 123 | + first = [o for o in obs if o["period"] == "2022-01"][0] | |
| 124 | + assert abs(first["index_smoothed"] - 100.0) < 1e-6 | |
| 125 | + | |
| 126 | + | |
| 127 | +def test_map_region_yoy(): | |
| 128 | + r = client.get("/v1/map", params={"metric": "yoy", "level": "region"}) | |
| 129 | + body = r.json() | |
| 130 | + assert len(body["cells"]) == 17 | |
| 131 | + | |
| 132 | + | |
| 133 | +def test_vintages(): | |
| 134 | + r = client.get("/v1/vintages", params={"geography": "quebec", "type": "all"}) | |
| 135 | + assert r.status_code == 200 | |
| 136 | + assert len(r.json()["observations"]) >= 60 | |
added
data/external/SDA_SOURCE.md
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +Author : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +File : data/external/SDA_SOURCE.md | |
| 7 | +Purpose : Provenance record for the authoritative boundary dataset. | |
| 8 | +============================================================================= | |
| 9 | +--> | |
| 10 | + | |
| 11 | +# Boundary dataset provenance — SDA 1/20 000 | |
| 12 | + | |
| 13 | +- **Dataset**: Découpages administratifs à l'échelle 1/20 000 (SDA) | |
| 14 | +- **Producer**: Gouvernement du Québec — Ministère des Ressources naturelles | |
| 15 | + et des Forêts (MRNF), Registre du domaine de l'État | |
| 16 | +- **Catalog page**: https://www.donneesquebec.ca/recherche/dataset/decoupages-administratifs | |
| 17 | +- **File downloaded**: `SDA.gpkg.zip` (GPKG), | |
| 18 | + https://diffusion.mern.gouv.qc.ca/diffusion/RGQ/Vectoriel/Theme/Local/SDA_20k/GPKG/SDA.gpkg.zip | |
| 19 | +- **Download date**: 2026-08-06 | |
| 20 | +- **Version**: V2026-07 (layer attribute `*_CO_VER`) | |
| 21 | +- **Native CRS**: EPSG:4269 (NAD83 geographic); metric operations performed in | |
| 22 | + EPSG:32198 (NAD83 / Quebec Lambert) | |
| 23 | +- **Layers used**: | |
| 24 | + - `munic_s` — municipal polygons (geocode, name, MRC, administrative region) | |
| 25 | + - `regio_s` — 17 administrative regions (map rendering) | |
| 26 | + - `mrc_s` — MRCs (optional intermediate level) | |
| 27 | +- **License**: CC-BY 4.0 (Données Québec) | |
| 28 | + | |
| 29 | +Join result persisted at `data/processed/geo_join.parquet` | |
| 30 | +(one row per transaction id; join_method ∈ {within, nearest<=1km, unassigned}). | |
| 31 | +Validation: 100.000% `within`, 99.92% name agreement with the raw `city` field | |
| 32 | +(see `outputs/reports/geography_report.md`). | |
added
data/processed/assessment_gap.parquet
+0 −0
Binary file not shown.
added
data/processed/assessment_gap_monthly.parquet
+0 −0
Binary file not shown.
added
data/processed/coverage_matrix.parquet
+0 −0
Binary file not shown.
added
data/processed/coverage_matrix_monthly.parquet
+0 −0
Binary file not shown.
added
data/processed/first_release.parquet
+0 −0
Binary file not shown.
added
data/processed/first_release_monthly.parquet
+0 −0
Binary file not shown.
added
data/processed/monthly_liquidity.parquet
+0 −0
Binary file not shown.
added
data/processed/municipalities.parquet
+0 −0
Binary file not shown.
added
data/processed/qhpi_monthly.parquet
+0 −0
Binary file not shown.
added
data/processed/qwhpi_weekly.parquet
+0 −0
Binary file not shown.
added
data/processed/vintages/data_vintage=2026-08-06/qwhpi_weekly.parquet
+0 −0
Binary file not shown.
added
data/processed/vintages_monthly/data_vintage=2026-08-08/qhpi_monthly.parquet
+0 −0
Binary file not shown.
added
data/processed/weekly_liquidity.parquet
+0 −0
Binary file not shown.
added
db/alembic.ini
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : db/alembic.ini | |
| 6 | +# Purpose : Alembic configuration for QWHPI database migrations. | |
| 7 | +# ============================================================================= | |
| 8 | +[alembic] | |
| 9 | +script_location = migrations | |
| 10 | +sqlalchemy.url = ${DATABASE_URL} | |
| 11 | + | |
| 12 | +[loggers] | |
| 13 | +keys = root | |
| 14 | + | |
| 15 | +[handlers] | |
| 16 | +keys = console | |
| 17 | + | |
| 18 | +[formatters] | |
| 19 | +keys = generic | |
| 20 | + | |
| 21 | +[logger_root] | |
| 22 | +level = WARN | |
| 23 | +handlers = console | |
| 24 | + | |
| 25 | +[handler_console] | |
| 26 | +class = StreamHandler | |
| 27 | +args = (sys.stderr,) | |
| 28 | +level = NOTSET | |
| 29 | +formatter = generic | |
| 30 | + | |
| 31 | +[formatter_generic] | |
| 32 | +format = %(levelname)-5.5s [%(name)s] %(message)s | |
added
db/migrations/env.py
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : db/migrations/env.py | |
| 6 | +# Purpose : Alembic environment — runs SQL migrations against DATABASE_URL. | |
| 7 | +# ============================================================================= | |
| 8 | +"""Alembic migration environment (offline + online modes).""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import os | |
| 13 | + | |
| 14 | +from alembic import context | |
| 15 | +from sqlalchemy import create_engine | |
| 16 | + | |
| 17 | +url = os.environ.get("DATABASE_URL", "postgresql://qwhpi:qwhpi@localhost:5432/qwhpi") | |
| 18 | + | |
| 19 | + | |
| 20 | +def run_migrations_offline() -> None: | |
| 21 | + context.configure(url=url, literal_binds=True) | |
| 22 | + with context.begin_transaction(): | |
| 23 | + context.run_migrations() | |
| 24 | + | |
| 25 | + | |
| 26 | +def run_migrations_online() -> None: | |
| 27 | + engine = create_engine(url) | |
| 28 | + with engine.connect() as connection: | |
| 29 | + context.configure(connection=connection) | |
| 30 | + with context.begin_transaction(): | |
| 31 | + context.run_migrations() | |
| 32 | + | |
| 33 | + | |
| 34 | +if context.is_offline_mode(): | |
| 35 | + run_migrations_offline() | |
| 36 | +else: | |
| 37 | + run_migrations_online() | |
added
db/migrations/versions/001_initial_schema.py
+35 −0
@@ -0,0 +1,35 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : db/migrations/versions/001_initial_schema.py | |
| 6 | +# Purpose : Initial migration — creates the full QWHPI schema (db/schema.sql). | |
| 7 | +# ============================================================================= | |
| 8 | +"""Initial QWHPI schema. | |
| 9 | + | |
| 10 | +Revision ID: 001 | |
| 11 | +Revises: | |
| 12 | +""" | |
| 13 | + | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +from pathlib import Path | |
| 17 | + | |
| 18 | +from alembic import op | |
| 19 | + | |
| 20 | +revision = "001" | |
| 21 | +down_revision = None | |
| 22 | +branch_labels = None | |
| 23 | +depends_on = None | |
| 24 | + | |
| 25 | +SCHEMA_SQL = Path(__file__).resolve().parents[2] / "schema.sql" | |
| 26 | + | |
| 27 | + | |
| 28 | +def upgrade() -> None: | |
| 29 | + op.execute(SCHEMA_SQL.read_text()) | |
| 30 | + | |
| 31 | + | |
| 32 | +def downgrade() -> None: | |
| 33 | + for table in ("vintages", "liquidity", "observations", "model_runs", | |
| 34 | + "series", "geographies"): | |
| 35 | + op.execute(f"DROP TABLE IF EXISTS {table} CASCADE") | |
added
db/schema.sql
+74 −0
@@ -0,0 +1,74 @@ | ||
| 1 | +-- ============================================================================= | |
| 2 | +-- QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +-- Author : Simon-Pierre Boucher | |
| 4 | +-- Contact : contact@spboucher.ai | |
| 5 | +-- File : db/schema.sql | |
| 6 | +-- Purpose : Relational schema serving the API (loaded by engine export.py). | |
| 7 | +-- ============================================================================= | |
| 8 | + | |
| 9 | +CREATE TABLE IF NOT EXISTS geographies ( | |
| 10 | + geography_id TEXT PRIMARY KEY, | |
| 11 | + geography_name TEXT NOT NULL, | |
| 12 | + geography_level TEXT NOT NULL CHECK (geography_level IN ('province','region','municipality')), | |
| 13 | + parent_id TEXT REFERENCES geographies(geography_id) | |
| 14 | +); | |
| 15 | + | |
| 16 | +CREATE TABLE IF NOT EXISTS series ( | |
| 17 | + series_id TEXT PRIMARY KEY, -- '<geography_id>:<property_type>' | |
| 18 | + geography_id TEXT NOT NULL REFERENCES geographies(geography_id), | |
| 19 | + property_type TEXT NOT NULL CHECK (property_type IN ('all','unifamilial','condo','plex')), | |
| 20 | + base_period TEXT NOT NULL DEFAULT '2021 average = 100', | |
| 21 | + UNIQUE (geography_id, property_type) | |
| 22 | +); | |
| 23 | + | |
| 24 | +CREATE TABLE IF NOT EXISTS model_runs ( | |
| 25 | + run_id SERIAL PRIMARY KEY, | |
| 26 | + model_version TEXT NOT NULL, | |
| 27 | + data_vintage DATE NOT NULL, | |
| 28 | + input_row_count BIGINT, | |
| 29 | + input_hash TEXT, | |
| 30 | + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 31 | + UNIQUE (model_version, data_vintage) | |
| 32 | +); | |
| 33 | + | |
| 34 | +CREATE TABLE IF NOT EXISTS observations ( | |
| 35 | + series_id TEXT NOT NULL REFERENCES series(series_id), | |
| 36 | + week DATE NOT NULL, | |
| 37 | + index DOUBLE PRECISION, | |
| 38 | + index_smoothed DOUBLE PRECISION NOT NULL, | |
| 39 | + representative_value DOUBLE PRECISION, | |
| 40 | + transactions INTEGER NOT NULL, | |
| 41 | + effective_sample_size DOUBLE PRECISION, | |
| 42 | + weekly_pct DOUBLE PRECISION, | |
| 43 | + four_week_pct DOUBLE PRECISION, | |
| 44 | + thirteen_week_pct DOUBLE PRECISION, | |
| 45 | + twenty_six_week_pct DOUBLE PRECISION, | |
| 46 | + yoy_pct DOUBLE PRECISION, | |
| 47 | + lower_95 DOUBLE PRECISION NOT NULL, | |
| 48 | + upper_95 DOUBLE PRECISION NOT NULL, | |
| 49 | + reliability_grade CHAR(1) NOT NULL CHECK (reliability_grade IN ('A','B','C','D','E')), | |
| 50 | + shrinkage_weight DOUBLE PRECISION, | |
| 51 | + is_partial_week BOOLEAN NOT NULL DEFAULT FALSE, | |
| 52 | + model_version TEXT NOT NULL, | |
| 53 | + data_vintage DATE NOT NULL, | |
| 54 | + PRIMARY KEY (series_id, week) | |
| 55 | +); | |
| 56 | +CREATE INDEX IF NOT EXISTS idx_obs_week ON observations (week); | |
| 57 | + | |
| 58 | +CREATE TABLE IF NOT EXISTS liquidity ( | |
| 59 | + series_id TEXT NOT NULL REFERENCES series(series_id), | |
| 60 | + week DATE NOT NULL, | |
| 61 | + transactions INTEGER NOT NULL, | |
| 62 | + PRIMARY KEY (series_id, week) | |
| 63 | +); | |
| 64 | + | |
| 65 | +CREATE TABLE IF NOT EXISTS vintages ( | |
| 66 | + series_id TEXT NOT NULL REFERENCES series(series_id), | |
| 67 | + week DATE NOT NULL, | |
| 68 | + first_release_index DOUBLE PRECISION, | |
| 69 | + first_release_index_smoothed DOUBLE PRECISION NOT NULL, | |
| 70 | + first_release_vintage DATE NOT NULL, | |
| 71 | + PRIMARY KEY (series_id, week) | |
| 72 | +); | |
| 73 | + | |
| 74 | +-- Author credit: Simon-Pierre Boucher — contact@spboucher.ai | |
added
docker-compose.yml
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : docker-compose.yml | |
| 6 | +# Purpose : Full platform — Postgres, API, dashboard, weekly scheduler. | |
| 7 | +# ============================================================================= | |
| 8 | + | |
| 9 | +services: | |
| 10 | + db: | |
| 11 | + image: postgres:16-alpine | |
| 12 | + environment: | |
| 13 | + POSTGRES_USER: qwhpi | |
| 14 | + POSTGRES_PASSWORD: qwhpi | |
| 15 | + POSTGRES_DB: qwhpi | |
| 16 | + volumes: | |
| 17 | + - pgdata:/var/lib/postgresql/data | |
| 18 | + healthcheck: | |
| 19 | + test: ["CMD-SHELL", "pg_isready -U qwhpi"] | |
| 20 | + interval: 5s | |
| 21 | + retries: 10 | |
| 22 | + | |
| 23 | + api: | |
| 24 | + build: ./api | |
| 25 | + environment: | |
| 26 | + QWHPI_DATA_DIR: /data/processed | |
| 27 | + DATABASE_URL: postgresql://qwhpi:qwhpi@db:5432/qwhpi | |
| 28 | + volumes: | |
| 29 | + - ./data/processed:/data/processed:ro | |
| 30 | + ports: | |
| 31 | + - "8080:8080" | |
| 32 | + depends_on: | |
| 33 | + db: | |
| 34 | + condition: service_healthy | |
| 35 | + | |
| 36 | + web: | |
| 37 | + build: ./web | |
| 38 | + environment: | |
| 39 | + NEXT_PUBLIC_API_URL: http://localhost:8080 | |
| 40 | + ports: | |
| 41 | + - "3000:3000" | |
| 42 | + depends_on: | |
| 43 | + - api | |
| 44 | + | |
| 45 | + scheduler: | |
| 46 | + build: ./ops/scheduler | |
| 47 | + environment: | |
| 48 | + DATABASE_URL: postgresql://qwhpi:qwhpi@db:5432/qwhpi | |
| 49 | + volumes: | |
| 50 | + - ./:/workspace | |
| 51 | + depends_on: | |
| 52 | + db: | |
| 53 | + condition: service_healthy | |
| 54 | + | |
| 55 | +volumes: | |
| 56 | + pgdata: | |
added
engine/pyproject.toml
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/pyproject.toml | |
| 6 | +# Purpose : Package metadata and dependencies for the QWHPI index engine. | |
| 7 | +# ============================================================================= | |
| 8 | + | |
| 9 | +[project] | |
| 10 | +name = "qwhpi" | |
| 11 | +version = "0.1.0" | |
| 12 | +description = "Quebec Weekly Housing Price Index — index engine" | |
| 13 | +authors = [{ name = "Simon-Pierre Boucher", email = "contact@spboucher.ai" }] | |
| 14 | +requires-python = ">=3.12" | |
| 15 | +dependencies = [ | |
| 16 | + "pandas>=2.2", | |
| 17 | + "polars>=1.0", | |
| 18 | + "numpy>=1.26", | |
| 19 | + "pyarrow>=16", | |
| 20 | + "statsmodels>=0.14", | |
| 21 | + "scikit-learn>=1.4", | |
| 22 | + "geopandas>=1.0", | |
| 23 | + "shapely>=2.0", | |
| 24 | + "scipy>=1.13", | |
| 25 | + "matplotlib>=3.8", | |
| 26 | +] | |
| 27 | + | |
| 28 | +[project.optional-dependencies] | |
| 29 | +dev = ["pytest>=8", "mypy>=1.10", "ruff>=0.4"] | |
| 30 | + | |
| 31 | +[build-system] | |
| 32 | +requires = ["hatchling"] | |
| 33 | +build-backend = "hatchling.build" | |
| 34 | + | |
| 35 | +[tool.hatch.build.targets.wheel] | |
| 36 | +packages = ["src/qwhpi"] | |
| 37 | + | |
| 38 | +[tool.ruff] | |
| 39 | +line-length = 100 | |
| 40 | + | |
| 41 | +[tool.mypy] | |
| 42 | +python_version = "3.12" | |
| 43 | +ignore_missing_imports = true | |
added
engine/scripts/01_profile.py
+301 −0
@@ -0,0 +1,301 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/01_profile.py | |
| 7 | +# Purpose : Step 1 audit — schema, descriptives, missingness, outlier scan, | |
| 8 | +# coverage, duplicates, and weekly liquidity matrices. No index yet. | |
| 9 | +# ============================================================================= | |
| 10 | +"""Full dataset audit (Execution Order step 1). | |
| 11 | + | |
| 12 | +Idempotent: re-running overwrites its own outputs. Writes tables to | |
| 13 | +``outputs/tables/`` and a human-readable report to | |
| 14 | +``outputs/reports/audit_report.md``. The raw CSV is never modified. | |
| 15 | +""" | |
| 16 | + | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import sys | |
| 20 | +from pathlib import Path | |
| 21 | + | |
| 22 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 23 | + | |
| 24 | +import polars as pl | |
| 25 | + | |
| 26 | +from qwhpi.config import REPORTS_DIR, TABLES_DIR, ensure_dirs | |
| 27 | +from qwhpi.ingest import load_raw | |
| 28 | + | |
| 29 | +pl.Config.set_tbl_rows(50) | |
| 30 | + | |
| 31 | +REPORT_LINES: list[str] = [] | |
| 32 | + | |
| 33 | + | |
| 34 | +def note(line: str = "") -> None: | |
| 35 | + REPORT_LINES.append(line) | |
| 36 | + print(line) | |
| 37 | + | |
| 38 | + | |
| 39 | +def save(df: pl.DataFrame, name: str) -> None: | |
| 40 | + path = TABLES_DIR / f"{name}.csv" | |
| 41 | + df.write_csv(path) | |
| 42 | + note(f" -> table saved: outputs/tables/{name}.csv ({df.height} rows)") | |
| 43 | + | |
| 44 | + | |
| 45 | +def main() -> None: | |
| 46 | + ensure_dirs() | |
| 47 | + df = load_raw() | |
| 48 | + | |
| 49 | + note("# QWHPI Data Audit Report") | |
| 50 | + note("") | |
| 51 | + note("Author: Simon-Pierre Boucher — contact@spboucher.ai") | |
| 52 | + note("") | |
| 53 | + | |
| 54 | + # ------------------------------------------------------------------ # | |
| 55 | + # 1. Schema & basic shape | |
| 56 | + # ------------------------------------------------------------------ # | |
| 57 | + note("## 1. Schema and shape") | |
| 58 | + note("") | |
| 59 | + note(f"- Rows: {df.height:,}") | |
| 60 | + note(f"- Columns: {df.width}") | |
| 61 | + note(f"- Date range: {df['date'].min()} -> {df['date'].max()}") | |
| 62 | + n_weeks = df["week"].n_unique() | |
| 63 | + note(f"- Distinct Monday-labeled weeks with >=1 transaction: {n_weeks}") | |
| 64 | + note(f"- Distinct cities (raw text field): {df['city'].n_unique():,}") | |
| 65 | + note(f"- Distinct ids: {df['id'].n_unique():,} (duplicated ids: {df.height - df['id'].n_unique():,})") | |
| 66 | + note("") | |
| 67 | + schema_tbl = pl.DataFrame( | |
| 68 | + {"column": df.columns, "dtype": [str(t) for t in df.dtypes]} | |
| 69 | + ) | |
| 70 | + save(schema_tbl, "audit_schema") | |
| 71 | + | |
| 72 | + # ------------------------------------------------------------------ # | |
| 73 | + # 2. Missingness | |
| 74 | + # ------------------------------------------------------------------ # | |
| 75 | + note("") | |
| 76 | + note("## 2. Missingness") | |
| 77 | + note("") | |
| 78 | + nulls = df.null_count().transpose(include_header=True, | |
| 79 | + header_name="column", | |
| 80 | + column_names=["n_missing"]) | |
| 81 | + nulls = nulls.with_columns( | |
| 82 | + (pl.col("n_missing") / df.height * 100).round(2).alias("pct_missing") | |
| 83 | + ).sort("n_missing", descending=True) | |
| 84 | + save(nulls, "audit_missingness") | |
| 85 | + for row in nulls.filter(pl.col("n_missing") > 0).iter_rows(named=True): | |
| 86 | + note(f"- `{row['column']}`: {row['n_missing']:,} missing ({row['pct_missing']}%)") | |
| 87 | + | |
| 88 | + # Sentinel-style missingness that null counts miss. | |
| 89 | + note("") | |
| 90 | + note("Sentinel / degenerate values (not encoded as null):") | |
| 91 | + sentinels = pl.DataFrame({ | |
| 92 | + "check": [ | |
| 93 | + "amount <= 0", "amount in (1, 2)", "amount < 5000", | |
| 94 | + "floorArea <= 0", "floorArea > 2000 m2", | |
| 95 | + "yearBuilt < 1600", "yearBuilt > 2026", "yearBuilt == 0", | |
| 96 | + "previousValue <= 0", "totalArValue <= 0", | |
| 97 | + "lat/lng outside Quebec bbox (44.9-62.7N, -79.8--57W)", | |
| 98 | + ], | |
| 99 | + "n": [ | |
| 100 | + df.filter(pl.col("amount") <= 0).height, | |
| 101 | + df.filter(pl.col("amount").is_in([1, 2])).height, | |
| 102 | + df.filter(pl.col("amount") < 5000).height, | |
| 103 | + df.filter(pl.col("floorArea") <= 0).height, | |
| 104 | + df.filter(pl.col("floorArea") > 2000).height, | |
| 105 | + df.filter(pl.col("yearBuilt") < 1600).height, | |
| 106 | + df.filter(pl.col("yearBuilt") > 2026).height, | |
| 107 | + df.filter(pl.col("yearBuilt") == 0).height, | |
| 108 | + df.filter(pl.col("previousValue") <= 0).height, | |
| 109 | + df.filter(pl.col("totalArValue") <= 0).height, | |
| 110 | + df.filter( | |
| 111 | + (pl.col("lat") < 44.9) | (pl.col("lat") > 62.7) | |
| 112 | + | (pl.col("lng") < -79.8) | (pl.col("lng") > -57.0) | |
| 113 | + ).height, | |
| 114 | + ], | |
| 115 | + }) | |
| 116 | + save(sentinels, "audit_sentinel_values") | |
| 117 | + for row in sentinels.iter_rows(named=True): | |
| 118 | + note(f"- {row['check']}: {row['n']:,}") | |
| 119 | + | |
| 120 | + # ------------------------------------------------------------------ # | |
| 121 | + # 3. Categorical distributions | |
| 122 | + # ------------------------------------------------------------------ # | |
| 123 | + note("") | |
| 124 | + note("## 3. Categorical distributions") | |
| 125 | + for col in ("propertyType", "buildingType", "ownerType"): | |
| 126 | + note("") | |
| 127 | + note(f"### {col}") | |
| 128 | + dist = ( | |
| 129 | + df.group_by(col).len().sort("len", descending=True) | |
| 130 | + .with_columns((pl.col("len") / df.height * 100).round(2).alias("pct")) | |
| 131 | + ) | |
| 132 | + save(dist, f"audit_dist_{col}") | |
| 133 | + for row in dist.head(15).iter_rows(named=True): | |
| 134 | + note(f"- {row[col]}: {row['len']:,} ({row['pct']}%)") | |
| 135 | + | |
| 136 | + # ------------------------------------------------------------------ # | |
| 137 | + # 4. Price descriptives (by property type) | |
| 138 | + # ------------------------------------------------------------------ # | |
| 139 | + note("") | |
| 140 | + note("## 4. Amount descriptives by propertyType (amount > 0)") | |
| 141 | + note("") | |
| 142 | + pos = df.filter(pl.col("amount") > 0) | |
| 143 | + desc = ( | |
| 144 | + pos.group_by("propertyType") | |
| 145 | + .agg( | |
| 146 | + n=pl.len(), | |
| 147 | + p01=pl.col("amount").quantile(0.01), | |
| 148 | + p05=pl.col("amount").quantile(0.05), | |
| 149 | + p25=pl.col("amount").quantile(0.25), | |
| 150 | + median=pl.col("amount").median(), | |
| 151 | + p75=pl.col("amount").quantile(0.75), | |
| 152 | + p95=pl.col("amount").quantile(0.95), | |
| 153 | + p99=pl.col("amount").quantile(0.99), | |
| 154 | + max=pl.col("amount").max(), | |
| 155 | + mean=pl.col("amount").mean().round(0), | |
| 156 | + ) | |
| 157 | + .sort("n", descending=True) | |
| 158 | + ) | |
| 159 | + save(desc, "audit_amount_by_type") | |
| 160 | + for row in desc.iter_rows(named=True): | |
| 161 | + note( | |
| 162 | + f"- {row['propertyType']}: n={row['n']:,} | p01=${row['p01']:,.0f} " | |
| 163 | + f"| median=${row['median']:,.0f} | p99=${row['p99']:,.0f} | max=${row['max']:,.0f}" | |
| 164 | + ) | |
| 165 | + | |
| 166 | + # Same for hedonic characteristics. | |
| 167 | + char_desc = ( | |
| 168 | + df.group_by("propertyType") | |
| 169 | + .agg( | |
| 170 | + n=pl.len(), | |
| 171 | + floorArea_med=pl.col("floorArea").median(), | |
| 172 | + floorArea_p99=pl.col("floorArea").quantile(0.99), | |
| 173 | + yearBuilt_med=pl.col("yearBuilt").median(), | |
| 174 | + yearBuilt_min=pl.col("yearBuilt").min(), | |
| 175 | + floorArea_miss_pct=(pl.col("floorArea").is_null().mean() * 100).round(2), | |
| 176 | + yearBuilt_miss_pct=(pl.col("yearBuilt").is_null().mean() * 100).round(2), | |
| 177 | + buildingType_miss_pct=(pl.col("buildingType").is_null().mean() * 100).round(2), | |
| 178 | + ) | |
| 179 | + .sort("n", descending=True) | |
| 180 | + ) | |
| 181 | + save(char_desc, "audit_characteristics_by_type") | |
| 182 | + | |
| 183 | + # ------------------------------------------------------------------ # | |
| 184 | + # 5. Duplicates | |
| 185 | + # ------------------------------------------------------------------ # | |
| 186 | + note("") | |
| 187 | + note("## 5. Duplicate scan") | |
| 188 | + note("") | |
| 189 | + full_dupes = df.height - df.unique(subset=[c for c in df.columns if c not in ("id",)]).height | |
| 190 | + addr_date = df.height - df.unique(subset=["street", "city", "date"]).height | |
| 191 | + addr_date_amt = df.height - df.unique(subset=["street", "city", "date", "amount"]).height | |
| 192 | + note(f"- Exact duplicates ignoring id: {full_dupes:,}") | |
| 193 | + note(f"- Same street+city+date (candidate multi-unit or true dupes): {addr_date:,}") | |
| 194 | + note(f"- Same street+city+date+amount (strong duplicate candidates): {addr_date_amt:,}") | |
| 195 | + | |
| 196 | + # Repeat-sales potential: same street+city appearing on different dates. | |
| 197 | + repeat = ( | |
| 198 | + df.group_by(["street", "city"]).agg(n_dates=pl.col("date").n_unique()) | |
| 199 | + .filter(pl.col("n_dates") >= 2) | |
| 200 | + ) | |
| 201 | + note(f"- Addresses (street+city) with >=2 distinct sale dates: {repeat.height:,}") | |
| 202 | + | |
| 203 | + # ------------------------------------------------------------------ # | |
| 204 | + # 6. Weekly coverage & liquidity | |
| 205 | + # ------------------------------------------------------------------ # | |
| 206 | + note("") | |
| 207 | + note("## 6. Weekly coverage and liquidity") | |
| 208 | + note("") | |
| 209 | + weekly = df.group_by("week").len().sort("week") | |
| 210 | + first, last = weekly["week"].min(), weekly["week"].max() | |
| 211 | + grid = pl.DataFrame({"week": pl.date_range(first, last, "1w", eager=True)}) | |
| 212 | + weekly_full = grid.join(weekly, on="week", how="left").fill_null(0) | |
| 213 | + zero_weeks = weekly_full.filter(pl.col("len") == 0) | |
| 214 | + note(f"- Continuous Monday grid: {grid.height} weeks ({first} -> {last})") | |
| 215 | + note(f"- Zero-transaction weeks on the grid: {zero_weeks.height}") | |
| 216 | + note(f"- Median tx/week (province): {weekly_full['len'].median():,.0f}") | |
| 217 | + note(f"- Min tx/week: {weekly_full['len'].min():,} | Max: {weekly_full['len'].max():,}") | |
| 218 | + tail = weekly_full.tail(6) | |
| 219 | + note(f"- Last 6 weeks (partial-week check): " | |
| 220 | + + ", ".join(f"{r['week']}={r['len']}" for r in tail.iter_rows(named=True))) | |
| 221 | + save(weekly_full.rename({"len": "transactions"}), "audit_weekly_volume_province") | |
| 222 | + | |
| 223 | + # Liquidity matrix: median weekly tx for top cities x property type. | |
| 224 | + top_cities = ( | |
| 225 | + df.group_by("city").len().sort("len", descending=True).head(25)["city"].to_list() | |
| 226 | + ) | |
| 227 | + liq = ( | |
| 228 | + df.filter(pl.col("city").is_in(top_cities)) | |
| 229 | + .group_by(["city", "propertyType", "week"]).len() | |
| 230 | + .group_by(["city", "propertyType"]) | |
| 231 | + .agg( | |
| 232 | + median_wk=pl.col("len").median(), | |
| 233 | + mean_wk=pl.col("len").mean().round(1), | |
| 234 | + weeks_present=pl.len(), | |
| 235 | + ) | |
| 236 | + .with_columns( | |
| 237 | + pl.col("weeks_present").truediv(grid.height).mul(100).round(1).alias("pct_weeks_present") | |
| 238 | + ) | |
| 239 | + .sort(["city", "propertyType"]) | |
| 240 | + ) | |
| 241 | + save(liq, "audit_weekly_liquidity_topcities") | |
| 242 | + | |
| 243 | + pivot = ( | |
| 244 | + liq.pivot(values="median_wk", index="city", on="propertyType") | |
| 245 | + .sort("unifamilial", descending=True, nulls_last=True) | |
| 246 | + ) | |
| 247 | + save(pivot, "audit_liquidity_matrix") | |
| 248 | + note("") | |
| 249 | + note("Median weekly transactions, top cities (rows) x type (cols): see audit_liquidity_matrix.csv") | |
| 250 | + for row in pivot.head(12).iter_rows(named=True): | |
| 251 | + note(f"- {row}") | |
| 252 | + | |
| 253 | + # City name hygiene: how many city strings, top mass share. | |
| 254 | + city_dist = df.group_by("city").len().sort("len", descending=True) | |
| 255 | + top50_share = city_dist.head(50)["len"].sum() / df.height * 100 | |
| 256 | + note("") | |
| 257 | + note(f"- Top 50 city strings cover {top50_share:.1f}% of transactions") | |
| 258 | + save(city_dist.head(200), "audit_city_top200") | |
| 259 | + | |
| 260 | + # ------------------------------------------------------------------ # | |
| 261 | + # 7. indéterminé investigation (preliminary) | |
| 262 | + # ------------------------------------------------------------------ # | |
| 263 | + note("") | |
| 264 | + note("## 7. `indéterminé` property type — preliminary look") | |
| 265 | + note("") | |
| 266 | + ind = df.filter(pl.col("propertyType") == "indéterminé") | |
| 267 | + note(f"- Count: {ind.height:,} ({ind.height / df.height * 100:.1f}%)") | |
| 268 | + if ind.height: | |
| 269 | + note(f"- Median amount: ${ind['amount'].median():,.0f} " | |
| 270 | + f"(vs unifamilial ${df.filter(pl.col('propertyType') == 'unifamilial')['amount'].median():,.0f})") | |
| 271 | + note(f"- floorArea missing: {ind['floorArea'].is_null().mean() * 100:.1f}% | " | |
| 272 | + f"yearBuilt missing: {ind['yearBuilt'].is_null().mean() * 100:.1f}% | " | |
| 273 | + f"buildingType missing: {ind['buildingType'].is_null().mean() * 100:.1f}%") | |
| 274 | + bt = ind.group_by("buildingType").len().sort("len", descending=True).head(8) | |
| 275 | + note("- Top buildingType values within indéterminé: " | |
| 276 | + + ", ".join(f"{r['buildingType']}={r['len']:,}" for r in bt.iter_rows(named=True))) | |
| 277 | + ot = ind.group_by("ownerType").len().sort("len", descending=True).head(5) | |
| 278 | + note("- ownerType within indéterminé: " | |
| 279 | + + ", ".join(f"{r['ownerType']}={r['len']:,}" for r in ot.iter_rows(named=True))) | |
| 280 | + | |
| 281 | + # ------------------------------------------------------------------ # | |
| 282 | + # Write report | |
| 283 | + # ------------------------------------------------------------------ # | |
| 284 | + report_path = REPORTS_DIR / "audit_report.md" | |
| 285 | + header = ( | |
| 286 | + "<!--\n" | |
| 287 | + "=============================================================================\n" | |
| 288 | + "QWHPI — Quebec Weekly Housing Price Index\n" | |
| 289 | + "Author : Simon-Pierre Boucher\n" | |
| 290 | + "Contact : contact@spboucher.ai\n" | |
| 291 | + "File : outputs/reports/audit_report.md\n" | |
| 292 | + "Purpose : Step 1 data audit report (generated by engine/scripts/01_profile.py)\n" | |
| 293 | + "=============================================================================\n" | |
| 294 | + "-->\n\n" | |
| 295 | + ) | |
| 296 | + report_path.write_text(header + "\n".join(REPORT_LINES) + "\n", encoding="utf-8") | |
| 297 | + print(f"\nReport written to {report_path}") | |
| 298 | + | |
| 299 | + | |
| 300 | +if __name__ == "__main__": | |
| 301 | + main() | |
added
engine/scripts/02_geography.py
+132 −0
@@ -0,0 +1,132 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/02_geography.py | |
| 7 | +# Purpose : Step 2 — spatial join of all transactions to official boundaries, | |
| 8 | +# validation against the raw city text field, persistence. | |
| 9 | +# ============================================================================= | |
| 10 | +"""Geography build (Execution Order step 2). | |
| 11 | + | |
| 12 | +Runs the spatial join (cached in ``data/processed/geo_join.parquet``), | |
| 13 | +validates the result against the raw ``city`` text field, and writes | |
| 14 | +validation tables + a report section. | |
| 15 | +""" | |
| 16 | + | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import sys | |
| 20 | +import time | |
| 21 | +import unicodedata | |
| 22 | +from pathlib import Path | |
| 23 | + | |
| 24 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 25 | + | |
| 26 | +import polars as pl | |
| 27 | + | |
| 28 | +from qwhpi.config import REPORTS_DIR, TABLES_DIR, ensure_dirs | |
| 29 | +from qwhpi.geography import SDA_VERSION, build_geo_join | |
| 30 | +from qwhpi.ingest import load_raw | |
| 31 | + | |
| 32 | +LINES: list[str] = [] | |
| 33 | + | |
| 34 | + | |
| 35 | +def note(line: str = "") -> None: | |
| 36 | + LINES.append(line) | |
| 37 | + print(line) | |
| 38 | + | |
| 39 | + | |
| 40 | +def norm_name(s: str) -> str: | |
| 41 | + """Accent/case/punctuation-insensitive normalization for name comparison.""" | |
| 42 | + if s is None: | |
| 43 | + return "" | |
| 44 | + s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode() | |
| 45 | + return ( | |
| 46 | + s.lower().replace("saint-", "st-").replace("sainte-", "ste-") | |
| 47 | + .replace("'", "").replace("’", "").replace(".", "").replace(" ", "-") | |
| 48 | + ) | |
| 49 | + | |
| 50 | + | |
| 51 | +def main() -> None: | |
| 52 | + ensure_dirs() | |
| 53 | + tx = load_raw() | |
| 54 | + | |
| 55 | + t0 = time.time() | |
| 56 | + geo = build_geo_join(tx) | |
| 57 | + note("## Geography spatial join (SDA 1/20k, version " + SDA_VERSION + ")") | |
| 58 | + note("") | |
| 59 | + note(f"- Join computed/loaded in {time.time() - t0:.1f}s for {geo.height:,} transactions") | |
| 60 | + | |
| 61 | + method_counts = geo.group_by("join_method").len().sort("len", descending=True) | |
| 62 | + for row in method_counts.iter_rows(named=True): | |
| 63 | + note(f"- join_method={row['join_method']}: {row['len']:,} " | |
| 64 | + f"({row['len'] / geo.height * 100:.3f}%)") | |
| 65 | + | |
| 66 | + n_regions = geo["region"].n_unique() | |
| 67 | + n_munic = geo["geo_code"].n_unique() | |
| 68 | + note(f"- Distinct regions matched: {n_regions} | municipalities: {n_munic:,}") | |
| 69 | + | |
| 70 | + region_dist = ( | |
| 71 | + geo.group_by(["region_code", "region"]).len() | |
| 72 | + .sort("len", descending=True) | |
| 73 | + .rename({"len": "n_transactions"}) | |
| 74 | + ) | |
| 75 | + region_dist.write_csv(TABLES_DIR / "geo_region_distribution.csv") | |
| 76 | + note("") | |
| 77 | + note("Transactions by administrative region:") | |
| 78 | + for row in region_dist.iter_rows(named=True): | |
| 79 | + note(f"- {row['region_code']} {row['region']}: {row['n_transactions']:,}") | |
| 80 | + | |
| 81 | + # ---------------------------------------------------------------- # | |
| 82 | + # Validation vs raw city text field | |
| 83 | + # ---------------------------------------------------------------- # | |
| 84 | + joined = tx.select("id", "city").join(geo, on="id", how="left") | |
| 85 | + comparable = joined.filter(pl.col("municipality").is_not_null()) | |
| 86 | + comp = comparable.with_columns( | |
| 87 | + pl.col("city").map_elements(norm_name, return_dtype=pl.Utf8).alias("city_norm"), | |
| 88 | + pl.col("municipality").map_elements(norm_name, return_dtype=pl.Utf8).alias("mun_norm"), | |
| 89 | + ).with_columns( | |
| 90 | + ( | |
| 91 | + (pl.col("city_norm") == pl.col("mun_norm")) | |
| 92 | + | pl.col("mun_norm").str.contains(pl.col("city_norm"), literal=True) | |
| 93 | + | pl.col("city_norm").str.contains(pl.col("mun_norm"), literal=True) | |
| 94 | + ).alias("name_match") | |
| 95 | + ) | |
| 96 | + match_rate = comp["name_match"].mean() * 100 | |
| 97 | + note("") | |
| 98 | + note("### Validation: joined municipality vs raw `city` text") | |
| 99 | + note(f"- Name agreement (normalized, containment-tolerant): {match_rate:.2f}%") | |
| 100 | + | |
| 101 | + mismatches = ( | |
| 102 | + comp.filter(~pl.col("name_match")) | |
| 103 | + .group_by(["city", "municipality", "region"]).len() | |
| 104 | + .sort("len", descending=True) | |
| 105 | + .rename({"len": "n"}) | |
| 106 | + ) | |
| 107 | + mismatches.head(300).write_csv(TABLES_DIR / "geo_city_mismatch_top300.csv") | |
| 108 | + note(f"- Mismatching pairs saved: outputs/tables/geo_city_mismatch_top300.csv " | |
| 109 | + f"({mismatches.height:,} distinct pairs, {mismatches['n'].sum():,} transactions)") | |
| 110 | + note("") | |
| 111 | + note("Top 15 mismatch pairs (city text -> joined municipality):") | |
| 112 | + for row in mismatches.head(15).iter_rows(named=True): | |
| 113 | + note(f"- '{row['city']}' -> '{row['municipality']}' ({row['region']}): {row['n']:,}") | |
| 114 | + | |
| 115 | + report_path = REPORTS_DIR / "geography_report.md" | |
| 116 | + header = ( | |
| 117 | + "<!--\n" | |
| 118 | + "=============================================================================\n" | |
| 119 | + "QWHPI — Quebec Weekly Housing Price Index\n" | |
| 120 | + "Author : Simon-Pierre Boucher\n" | |
| 121 | + "Contact : contact@spboucher.ai\n" | |
| 122 | + "File : outputs/reports/geography_report.md\n" | |
| 123 | + "Purpose : Step 2 geography join + validation report (from 02_geography.py)\n" | |
| 124 | + "=============================================================================\n" | |
| 125 | + "-->\n\n" | |
| 126 | + ) | |
| 127 | + report_path.write_text(header + "\n".join(LINES) + "\n", encoding="utf-8") | |
| 128 | + print(f"\nReport written to {report_path}") | |
| 129 | + | |
| 130 | + | |
| 131 | +if __name__ == "__main__": | |
| 132 | + main() | |
added
engine/scripts/03_clean.py
+137 −0
@@ -0,0 +1,137 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/03_clean.py | |
| 7 | +# Purpose : Step 3 — build the flagged clean table, write the full exclusion | |
| 8 | +# table and cleaning report. Nothing is silently deleted. | |
| 9 | +# ============================================================================= | |
| 10 | +"""Cleaning build (Execution Order step 3).""" | |
| 11 | + | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import sys | |
| 15 | +from pathlib import Path | |
| 16 | + | |
| 17 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 18 | + | |
| 19 | +import polars as pl | |
| 20 | + | |
| 21 | +from qwhpi.clean import ( | |
| 22 | + FLOORAREA_HI, FLOORAREA_LO, PRICE_CEILING, RATIO_HI, RATIO_LO, | |
| 23 | + build_clean, research_sample, | |
| 24 | +) | |
| 25 | +from qwhpi.config import REPORTS_DIR, TABLES_DIR, ensure_dirs | |
| 26 | +from qwhpi.geography import build_geo_join | |
| 27 | +from qwhpi.ingest import load_raw | |
| 28 | + | |
| 29 | +LINES: list[str] = [] | |
| 30 | + | |
| 31 | + | |
| 32 | +def note(line: str = "") -> None: | |
| 33 | + LINES.append(line) | |
| 34 | + print(line) | |
| 35 | + | |
| 36 | + | |
| 37 | +def main() -> None: | |
| 38 | + ensure_dirs() | |
| 39 | + tx = load_raw() | |
| 40 | + geo = build_geo_join(tx) | |
| 41 | + df = build_clean(tx, geo, force=True) | |
| 42 | + | |
| 43 | + note("## Cleaning report") | |
| 44 | + note("") | |
| 45 | + note(f"- Input rows: {df.height:,} (all retained; exclusions are flags)") | |
| 46 | + note(f"- Screens: ratio band [{RATIO_LO}, {RATIO_HI}] on amount/totalArValue; " | |
| 47 | + f"price ceiling ${PRICE_CEILING:,}; duplicate flags; " | |
| 48 | + f"floorArea sanitation [{FLOORAREA_LO}, {FLOORAREA_HI}] m².") | |
| 49 | + note("- Provider pre-filter verified: no transaction below $50,000 exists in the raw file.") | |
| 50 | + note("") | |
| 51 | + | |
| 52 | + # ------------------------------------------------------------------ # | |
| 53 | + # Exclusion table | |
| 54 | + # ------------------------------------------------------------------ # | |
| 55 | + excl = ( | |
| 56 | + df.filter(pl.col("exclude_reason").is_not_null()) | |
| 57 | + .group_by("exclude_reason").len() | |
| 58 | + .rename({"len": "n"}) | |
| 59 | + .with_columns(pl.col("n").cast(pl.Int64)) | |
| 60 | + .with_columns((pl.col("n") / df.height * 100).round(3).alias("pct_of_raw")) | |
| 61 | + .sort("n", descending=True) | |
| 62 | + ) | |
| 63 | + total_excl = excl["n"].sum() if excl.height else 0 | |
| 64 | + excl_full = pl.concat([ | |
| 65 | + excl, | |
| 66 | + pl.DataFrame({ | |
| 67 | + "exclude_reason": ["TOTAL_EXCLUDED", "RETAINED_IN_RESEARCH_SAMPLE"], | |
| 68 | + "n": [total_excl, df.height - total_excl], | |
| 69 | + "pct_of_raw": [round(total_excl / df.height * 100, 3), | |
| 70 | + round((df.height - total_excl) / df.height * 100, 3)], | |
| 71 | + }), | |
| 72 | + ]) | |
| 73 | + excl_full.write_csv(TABLES_DIR / "exclusions.csv") | |
| 74 | + note("### Exclusion table (outputs/tables/exclusions.csv)") | |
| 75 | + for row in excl_full.iter_rows(named=True): | |
| 76 | + note(f"- {row['exclude_reason']}: {row['n']:,} ({row['pct_of_raw']}%)") | |
| 77 | + | |
| 78 | + # Sanitation summary (rows kept, characteristic nulled). | |
| 79 | + note("") | |
| 80 | + note("### Characteristic sanitation (row retained, value nulled)") | |
| 81 | + note(f"- floorArea outside [{FLOORAREA_LO}, {FLOORAREA_HI}] m²: " | |
| 82 | + f"{df.filter(pl.col('fa_suspect')).height:,}") | |
| 83 | + note(f"- yearBuilt > sale year + 1: {df.filter(pl.col('yb_suspect')).height:,}") | |
| 84 | + | |
| 85 | + # ------------------------------------------------------------------ # | |
| 86 | + # Research sample summary | |
| 87 | + # ------------------------------------------------------------------ # | |
| 88 | + rs = research_sample(df) | |
| 89 | + rs_all = research_sample(df, include_undetermined=True) | |
| 90 | + note("") | |
| 91 | + note("### Research sample") | |
| 92 | + note(f"- Clean rows incl. indéterminé: {rs_all.height:,}") | |
| 93 | + note(f"- Headline sample (3 determined types): {rs.height:,} " | |
| 94 | + f"({rs.height / df.height * 100:.2f}% of raw)") | |
| 95 | + by_type = rs.group_by("propertyType").len().sort("len", descending=True) | |
| 96 | + for row in by_type.iter_rows(named=True): | |
| 97 | + note(f" - {row['propertyType']}: {row['len']:,}") | |
| 98 | + | |
| 99 | + # Weekly volume of the research sample (continuity check). | |
| 100 | + weekly = rs.group_by("week").len().sort("week") | |
| 101 | + note(f"- Weeks with >=1 research-sample transaction: {weekly.height} / 291") | |
| 102 | + note(f"- Median weekly research-sample volume: {weekly['len'].median():,.0f}") | |
| 103 | + | |
| 104 | + # Missingness within the research sample (for the hedonic step). | |
| 105 | + miss = pl.DataFrame({ | |
| 106 | + "column": ["floorArea", "yearBuilt", "buildingType", "ownerType"], | |
| 107 | + "pct_missing": [ | |
| 108 | + round(rs["floorArea"].is_null().mean() * 100, 2), | |
| 109 | + round(rs["yearBuilt"].is_null().mean() * 100, 2), | |
| 110 | + round(rs["buildingType"].is_null().mean() * 100, 2), | |
| 111 | + round(rs["ownerType"].is_null().mean() * 100, 2), | |
| 112 | + ], | |
| 113 | + }) | |
| 114 | + miss.write_csv(TABLES_DIR / "research_sample_missingness.csv") | |
| 115 | + note("") | |
| 116 | + note("### Missingness in headline research sample") | |
| 117 | + for row in miss.iter_rows(named=True): | |
| 118 | + note(f"- {row['column']}: {row['pct_missing']}%") | |
| 119 | + | |
| 120 | + report_path = REPORTS_DIR / "cleaning_report.md" | |
| 121 | + header = ( | |
| 122 | + "<!--\n" | |
| 123 | + "=============================================================================\n" | |
| 124 | + "QWHPI — Quebec Weekly Housing Price Index\n" | |
| 125 | + "Author : Simon-Pierre Boucher\n" | |
| 126 | + "Contact : contact@spboucher.ai\n" | |
| 127 | + "File : outputs/reports/cleaning_report.md\n" | |
| 128 | + "Purpose : Step 3 cleaning report (generated by engine/scripts/03_clean.py)\n" | |
| 129 | + "=============================================================================\n" | |
| 130 | + "-->\n\n" | |
| 131 | + ) | |
| 132 | + report_path.write_text(header + "\n".join(LINES) + "\n", encoding="utf-8") | |
| 133 | + print(f"\nReport written to {report_path}") | |
| 134 | + | |
| 135 | + | |
| 136 | +if __name__ == "__main__": | |
| 137 | + main() | |
added
engine/scripts/04_baseline.py
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/04_baseline.py | |
| 7 | +# Purpose : Step 4 — pooled hedonic time-dummy baseline for Quebec + the four | |
| 8 | +# major cities × All/Unifamilial/Condo/Plex (20 cells). | |
| 9 | +# ============================================================================= | |
| 10 | +"""Baseline index build (Execution Order step 4). | |
| 11 | + | |
| 12 | +For every cell: pooled Model A time-dummy regression (2021→present), | |
| 13 | +week coefficients rebased to 2021 avg = 100, raw weekly median attached for | |
| 14 | +comparison. Results land in ``data/interim/baseline_indexes.parquet``. | |
| 15 | +""" | |
| 16 | + | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import sys | |
| 20 | +import time | |
| 21 | +from pathlib import Path | |
| 22 | + | |
| 23 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 24 | + | |
| 25 | +import polars as pl | |
| 26 | + | |
| 27 | +from qwhpi.clean import CLEAN_PARQUET, research_sample | |
| 28 | +from qwhpi.config import INTERIM_DIR, TABLES_DIR, ensure_dirs | |
| 29 | +from qwhpi.features import build_features | |
| 30 | +from qwhpi.hedonic import estimate_time_dummy | |
| 31 | + | |
| 32 | +BASELINE_PARQUET = INTERIM_DIR / "baseline_indexes.parquet" | |
| 33 | + | |
| 34 | +GEOGRAPHIES = [ | |
| 35 | + ("quebec", "Québec (province)", "province", None), | |
| 36 | + ("montreal", "Montréal", "municipality", "Montréal"), | |
| 37 | + ("quebec-city", "Québec", "municipality", "Québec"), | |
| 38 | + ("laval", "Laval", "municipality", "Laval"), | |
| 39 | + ("gatineau", "Gatineau", "municipality", "Gatineau"), | |
| 40 | +] | |
| 41 | +TYPE_SETS = ["all", "unifamilial", "condo", "plex"] | |
| 42 | + | |
| 43 | + | |
| 44 | +def main() -> None: | |
| 45 | + ensure_dirs() | |
| 46 | + clean = pl.read_parquet(CLEAN_PARQUET) | |
| 47 | + feat = build_features(research_sample(clean)) | |
| 48 | + | |
| 49 | + frames: list[pl.DataFrame] = [] | |
| 50 | + summary_rows: list[dict] = [] | |
| 51 | + | |
| 52 | + for geo_id, geo_name, level, mun_filter in GEOGRAPHIES: | |
| 53 | + base = feat if mun_filter is None else feat.filter(pl.col("municipality") == mun_filter) | |
| 54 | + for ptype in TYPE_SETS: | |
| 55 | + cell = base if ptype == "all" else base.filter(pl.col("propertyType") == ptype) | |
| 56 | + t0 = time.time() | |
| 57 | + res = estimate_time_dummy(cell, absorb="loc_fine", | |
| 58 | + include_property_type=(ptype == "all")) | |
| 59 | + if res is None: | |
| 60 | + print(f"[skip] {geo_id} x {ptype}: n={cell.height} too thin") | |
| 61 | + summary_rows.append({"geography": geo_id, "property_type": ptype, | |
| 62 | + "n_obs": cell.height, "estimated": False, | |
| 63 | + "r2": None, "median_weekly_tx": None}) | |
| 64 | + continue | |
| 65 | + | |
| 66 | + # Raw weekly median + volume for comparison/validation. | |
| 67 | + weekly_raw = ( | |
| 68 | + cell.group_by("week_str") | |
| 69 | + .agg(pl.col("amount").median().alias("raw_median"), | |
| 70 | + pl.len().alias("transactions")) | |
| 71 | + .rename({"week_str": "week"}) | |
| 72 | + ) | |
| 73 | + tbl = ( | |
| 74 | + res.table.join(weekly_raw, on="week", how="left") | |
| 75 | + .with_columns( | |
| 76 | + pl.lit(geo_id).alias("geography_id"), | |
| 77 | + pl.lit(geo_name).alias("geography_name"), | |
| 78 | + pl.lit(level).alias("geography_level"), | |
| 79 | + pl.lit(ptype).alias("property_type"), | |
| 80 | + ) | |
| 81 | + ) | |
| 82 | + frames.append(tbl) | |
| 83 | + med_tx = weekly_raw["transactions"].median() | |
| 84 | + summary_rows.append({"geography": geo_id, "property_type": ptype, | |
| 85 | + "n_obs": res.n_obs, "estimated": True, | |
| 86 | + "r2": round(res.r2, 4), | |
| 87 | + "median_weekly_tx": med_tx}) | |
| 88 | + print(f"[ok] {geo_id} x {ptype}: n={res.n_obs:,} r2={res.r2:.3f} " | |
| 89 | + f"({time.time() - t0:.1f}s)") | |
| 90 | + | |
| 91 | + out = pl.concat(frames) | |
| 92 | + out.write_parquet(BASELINE_PARQUET) | |
| 93 | + pl.DataFrame(summary_rows).write_csv(TABLES_DIR / "baseline_model_summary.csv") | |
| 94 | + print(f"\nSaved {out.height:,} index observations to {BASELINE_PARQUET}") | |
| 95 | + | |
| 96 | + | |
| 97 | +if __name__ == "__main__": | |
| 98 | + main() | |
added
engine/scripts/05_hierarchical.py
+230 −0
@@ -0,0 +1,230 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/05_hierarchical.py | |
| 7 | +# Purpose : Step 5 — hierarchical weekly indexes: province, 17 regions and | |
| 8 | +# target cities × type, with Kalman shrinkage for thin cells. | |
| 9 | +# ============================================================================= | |
| 10 | +"""Hierarchical index build (Execution Order step 5). | |
| 11 | + | |
| 12 | +Persists stage-1 artifacts (paths, residuals, variances) for reuse by the | |
| 13 | +validation and scaling steps, and writes | |
| 14 | +``data/interim/hierarchical_indexes.parquet``. | |
| 15 | +""" | |
| 16 | + | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import sys | |
| 20 | +import time | |
| 21 | +import unicodedata | |
| 22 | +from pathlib import Path | |
| 23 | + | |
| 24 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 25 | + | |
| 26 | +import numpy as np | |
| 27 | +import polars as pl | |
| 28 | + | |
| 29 | +from qwhpi.clean import CLEAN_PARQUET, research_sample | |
| 30 | +from qwhpi.config import CITY_TARGETS, INTERIM_DIR, PROPERTY_TYPES, TABLES_DIR, ensure_dirs | |
| 31 | +from qwhpi.features import build_features | |
| 32 | +from qwhpi.hierarchy import ( | |
| 33 | + CellDeviation, cell_deviation, compose_cell_table, rebase_log, | |
| 34 | + stage1_residualize, week_grid, | |
| 35 | +) | |
| 36 | + | |
| 37 | +HIER_PARQUET = INTERIM_DIR / "hierarchical_indexes.parquet" | |
| 38 | +STAGE1_RESID = INTERIM_DIR / "stage1_residuals.parquet" | |
| 39 | +STAGE1_PATHS = INTERIM_DIR / "stage1_paths.parquet" | |
| 40 | + | |
| 41 | + | |
| 42 | +def slug(name: str) -> str: | |
| 43 | + s = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode() | |
| 44 | + return s.lower().replace(" ", "-").replace("'", "") | |
| 45 | + | |
| 46 | + | |
| 47 | +def city_slug(name: str) -> str: | |
| 48 | + # Municipality 'Québec' must not collide with the province id. | |
| 49 | + return "quebec-city" if name == "Québec" else slug(name) | |
| 50 | + | |
| 51 | + | |
| 52 | +def aligned_path(paths: pl.DataFrame, ptype: str, grid: list[str]) -> np.ndarray: | |
| 53 | + sub = {r["week"]: r["delta"] for r in | |
| 54 | + paths.filter(pl.col("property_type") == ptype).iter_rows(named=True)} | |
| 55 | + arr = np.array([sub.get(w, np.nan) for w in grid]) | |
| 56 | + # Interpolate the rare missing week levels (singleton-dropped in stage 1). | |
| 57 | + if np.isnan(arr).any(): | |
| 58 | + idx = np.arange(len(arr)) | |
| 59 | + ok = np.isfinite(arr) | |
| 60 | + arr = np.interp(idx, idx[ok], arr[ok]) | |
| 61 | + return arr | |
| 62 | + | |
| 63 | + | |
| 64 | +def combine_all(tables: dict[str, pl.DataFrame], weights: dict[str, float], | |
| 65 | + geography_id: str, geography_name: str, | |
| 66 | + geography_level: str) -> pl.DataFrame: | |
| 67 | + """Fixed-share composition of the three type indexes into 'all'.""" | |
| 68 | + wsum = sum(weights.values()) | |
| 69 | + w = {k: v / wsum for k, v in weights.items()} | |
| 70 | + base = tables[next(iter(tables))].sort("week") | |
| 71 | + grid = base["week"].to_list() | |
| 72 | + | |
| 73 | + def wavg(col: str, log: bool = True) -> np.ndarray: | |
| 74 | + acc = np.zeros(len(grid)) | |
| 75 | + for k, tbl in tables.items(): | |
| 76 | + vals = tbl.sort("week")[col].to_numpy() | |
| 77 | + acc += w[k] * (np.log(vals / 100.0) if log else vals) | |
| 78 | + return acc | |
| 79 | + | |
| 80 | + log_raw = wavg("index") | |
| 81 | + log_filt = wavg("index_smoothed") | |
| 82 | + log_res = wavg("index_research") | |
| 83 | + var = np.zeros(len(grid)) | |
| 84 | + for k, tbl in tables.items(): | |
| 85 | + var += (w[k] ** 2) * (tbl.sort("week")["se_log"].to_numpy() ** 2) | |
| 86 | + se = np.sqrt(var) | |
| 87 | + n = sum(np.nan_to_num(tbl.sort("week")["transactions"].to_numpy()) | |
| 88 | + for tbl in tables.values()) | |
| 89 | + shrink = wavg("shrinkage_weight", log=False) | |
| 90 | + | |
| 91 | + return pl.DataFrame({ | |
| 92 | + "week": grid, | |
| 93 | + "geography_id": geography_id, | |
| 94 | + "geography_name": geography_name, | |
| 95 | + "geography_level": geography_level, | |
| 96 | + "property_type": "all", | |
| 97 | + "index": np.exp(log_raw) * 100.0, | |
| 98 | + "index_smoothed": np.exp(log_filt) * 100.0, | |
| 99 | + "index_research": np.exp(log_res) * 100.0, | |
| 100 | + "log_index": log_filt, | |
| 101 | + "se_log": se, | |
| 102 | + "lower_95": np.exp(log_filt - 1.96 * se) * 100.0, | |
| 103 | + "upper_95": np.exp(log_filt + 1.96 * se) * 100.0, | |
| 104 | + "transactions": n, | |
| 105 | + "shrinkage_weight": shrink, | |
| 106 | + "research_var": var, | |
| 107 | + }) | |
| 108 | + | |
| 109 | + | |
| 110 | +def main() -> None: | |
| 111 | + ensure_dirs() | |
| 112 | + feat = build_features(research_sample(pl.read_parquet(CLEAN_PARQUET))) | |
| 113 | + | |
| 114 | + print("Stage 1: pooled residualization ...") | |
| 115 | + t0 = time.time() | |
| 116 | + s1 = stage1_residualize(feat) | |
| 117 | + print(f" n={s1.n_obs:,} r2={s1.r2:.4f} ({time.time() - t0:.0f}s)") | |
| 118 | + s1.residuals.write_parquet(STAGE1_RESID) | |
| 119 | + s1.paths.write_parquet(STAGE1_PATHS) | |
| 120 | + | |
| 121 | + resid = s1.residuals | |
| 122 | + grid = week_grid(resid) | |
| 123 | + T = len(grid) | |
| 124 | + | |
| 125 | + # Type-level residual variances (fallback for tiny cells) and weekly | |
| 126 | + # counts for the province path variance. | |
| 127 | + sigma2_type = {t: float(resid.filter(pl.col("propertyType") == t)["resid"].var()) | |
| 128 | + for t in PROPERTY_TYPES} | |
| 129 | + | |
| 130 | + frames: list[pl.DataFrame] = [] | |
| 131 | + region_dev_store: dict[tuple[str, str], CellDeviation] = {} | |
| 132 | + | |
| 133 | + region_names = dict( | |
| 134 | + feat.select("region_code", "region").unique().iter_rows() | |
| 135 | + ) | |
| 136 | + | |
| 137 | + for ptype in PROPERTY_TYPES: | |
| 138 | + path = aligned_path(s1.paths, ptype, grid) | |
| 139 | + r_t = resid.filter(pl.col("propertyType") == ptype) | |
| 140 | + counts = {r["week_str"]: r["n"] for r in | |
| 141 | + r_t.group_by("week_str").agg(pl.len().alias("n")).iter_rows(named=True)} | |
| 142 | + n_prov = np.array([counts.get(w, 0) for w in grid], dtype=float) | |
| 143 | + path_var = np.where(n_prov > 0, sigma2_type[ptype] / np.maximum(n_prov, 1), np.nan) | |
| 144 | + path_var = np.nan_to_num(path_var, nan=np.nanmax(path_var)) | |
| 145 | + | |
| 146 | + # ---- province cell ---- # | |
| 147 | + prov = compose_cell_table( | |
| 148 | + grid=grid, province_path=path, deviations=[], | |
| 149 | + geography_id="quebec", geography_name="Québec (province)", | |
| 150 | + geography_level="province", property_type=ptype, path_var=path_var, | |
| 151 | + ).with_columns( | |
| 152 | + pl.Series("transactions", n_prov), | |
| 153 | + pl.Series("shrinkage_weight", np.zeros(T)), | |
| 154 | + ) | |
| 155 | + frames.append(prov) | |
| 156 | + | |
| 157 | + # ---- 17 regions ---- # | |
| 158 | + for rcode in sorted(region_names): | |
| 159 | + cell = r_t.filter(pl.col("region_code") == rcode).rename({"resid": "dev"}) | |
| 160 | + dev = cell_deviation(cell.select("week_str", "dev"), grid, sigma2_type[ptype]) | |
| 161 | + region_dev_store[(rcode, ptype)] = dev | |
| 162 | + frames.append(compose_cell_table( | |
| 163 | + grid=grid, province_path=path, deviations=[dev], | |
| 164 | + geography_id=f"region-{rcode}", | |
| 165 | + geography_name=region_names[rcode], | |
| 166 | + geography_level="region", property_type=ptype, path_var=path_var, | |
| 167 | + )) | |
| 168 | + | |
| 169 | + # ---- target cities ---- # | |
| 170 | + for city in CITY_TARGETS: | |
| 171 | + cell = r_t.filter(pl.col("municipality") == city) | |
| 172 | + if cell.height == 0: | |
| 173 | + continue | |
| 174 | + rcode = cell["region_code"][0] | |
| 175 | + rdev = region_dev_store[(rcode, ptype)] | |
| 176 | + rpath = {w: v for w, v in zip(grid, rdev.fit.smoothed)} | |
| 177 | + cell = cell.with_columns( | |
| 178 | + (pl.col("resid") - pl.col("week_str").replace_strict(rpath, default=0.0)) | |
| 179 | + .alias("dev") | |
| 180 | + ) | |
| 181 | + mdev = cell_deviation(cell.select("week_str", "dev"), grid, sigma2_type[ptype]) | |
| 182 | + frames.append(compose_cell_table( | |
| 183 | + grid=grid, province_path=path, deviations=[rdev, mdev], | |
| 184 | + geography_id=city_slug(city), geography_name=city, | |
| 185 | + geography_level="municipality", property_type=ptype, path_var=path_var, | |
| 186 | + )) | |
| 187 | + | |
| 188 | + out = pl.concat(frames) | |
| 189 | + | |
| 190 | + # ---- 'all' composition per geography (fixed full-sample type shares) ---- # | |
| 191 | + all_frames: list[pl.DataFrame] = [] | |
| 192 | + geos = out.select("geography_id", "geography_name", "geography_level").unique() | |
| 193 | + for gid, gname, glevel in geos.iter_rows(): | |
| 194 | + tables = { | |
| 195 | + t: out.filter((pl.col("geography_id") == gid) & (pl.col("property_type") == t)) | |
| 196 | + for t in PROPERTY_TYPES | |
| 197 | + } | |
| 198 | + tables = {t: tbl for t, tbl in tables.items() if tbl.height} | |
| 199 | + weights = {t: float(np.nansum(tbl["transactions"].to_numpy())) | |
| 200 | + for t, tbl in tables.items()} | |
| 201 | + weights = {t: max(v, 1.0) for t, v in weights.items()} | |
| 202 | + all_frames.append(combine_all(tables, weights, gid, gname, glevel)) | |
| 203 | + | |
| 204 | + out = pl.concat([out, *all_frames]).sort( | |
| 205 | + ["geography_level", "geography_id", "property_type", "week"]) | |
| 206 | + out.write_parquet(HIER_PARQUET) | |
| 207 | + | |
| 208 | + # Summary: volatility comparison raw vs smoothed for thin cells. | |
| 209 | + rows = [] | |
| 210 | + for (gid, ptype), grp in out.to_pandas().groupby(["geography_id", "property_type"]): | |
| 211 | + grp = grp.sort_values("week") | |
| 212 | + li_raw = np.log(grp["index"].to_numpy()) | |
| 213 | + li_smo = np.log(grp["index_smoothed"].to_numpy()) | |
| 214 | + ok = np.isfinite(li_raw) | |
| 215 | + rows.append({ | |
| 216 | + "geography": gid, "property_type": ptype, | |
| 217 | + "median_weekly_tx": float(np.nanmedian(grp["transactions"])), | |
| 218 | + "sd_raw_pct": round(float(np.nanstd(np.diff(li_raw[ok]))) * 100, 2), | |
| 219 | + "sd_smoothed_pct": round(float(np.std(np.diff(li_smo))) * 100, 2), | |
| 220 | + "mean_shrinkage": round(float(np.nanmean(grp["shrinkage_weight"])), 3), | |
| 221 | + "last_index": round(float(grp["index_smoothed"].iloc[-1]), 2), | |
| 222 | + }) | |
| 223 | + summary = pl.DataFrame(rows).sort(["geography", "property_type"]) | |
| 224 | + summary.write_csv(TABLES_DIR / "hierarchical_summary.csv") | |
| 225 | + print(summary) | |
| 226 | + print(f"\nSaved {out.height:,} observations to {HIER_PARQUET}") | |
| 227 | + | |
| 228 | + | |
| 229 | +if __name__ == "__main__": | |
| 230 | + main() | |
added
engine/scripts/06_validation.py
+271 −0
@@ -0,0 +1,271 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/06_validation.py | |
| 7 | +# Purpose : Step 6 — repeat-sales comparison, downsampling experiment, | |
| 8 | +# composition-shock simulation, weekly-vs-monthly comparison. | |
| 9 | +# ============================================================================= | |
| 10 | +"""Validation suite (Execution Order step 6). Writes tables + report.""" | |
| 11 | + | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import sys | |
| 15 | +from pathlib import Path | |
| 16 | + | |
| 17 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 18 | + | |
| 19 | +import numpy as np | |
| 20 | +import polars as pl | |
| 21 | + | |
| 22 | +from qwhpi.clean import CLEAN_PARQUET, research_sample | |
| 23 | +from qwhpi.config import REPORTS_DIR, TABLES_DIR, ensure_dirs | |
| 24 | +from qwhpi.features import build_features | |
| 25 | +from qwhpi.hierarchy import cell_deviation, week_grid | |
| 26 | +from qwhpi.repeat_sales import bmn_index, build_pairs | |
| 27 | + | |
| 28 | +RNG = np.random.default_rng(42) | |
| 29 | +LINES: list[str] = [] | |
| 30 | + | |
| 31 | + | |
| 32 | +def note(line: str = "") -> None: | |
| 33 | + LINES.append(line) | |
| 34 | + print(line) | |
| 35 | + | |
| 36 | + | |
| 37 | +# ------------------------------------------------------------------------- # | |
| 38 | +# 1. Repeat-sales comparison | |
| 39 | +# ------------------------------------------------------------------------- # | |
| 40 | + | |
| 41 | +def repeat_sales_comparison(cl: pl.DataFrame, hier: pl.DataFrame) -> None: | |
| 42 | + note("## 1. Repeat-sales (BMN) vs hedonic index") | |
| 43 | + note("") | |
| 44 | + cells = [ | |
| 45 | + ("quebec", None, "unifamilial"), ("quebec", None, "condo"), | |
| 46 | + ("quebec", None, "plex"), | |
| 47 | + ("montreal", "Montréal", "condo"), ("montreal", "Montréal", "unifamilial"), | |
| 48 | + ("quebec-city", "Québec", "unifamilial"), | |
| 49 | + ] | |
| 50 | + rows = [] | |
| 51 | + for gid, mun, ptype in cells: | |
| 52 | + sub = cl.filter(pl.col("propertyType") == ptype) | |
| 53 | + if mun: | |
| 54 | + sub = sub.filter(pl.col("municipality") == mun) | |
| 55 | + rs = bmn_index(build_pairs(sub)) | |
| 56 | + if rs is None: | |
| 57 | + continue | |
| 58 | + h = ( | |
| 59 | + hier.filter((pl.col("geography_id") == gid) | |
| 60 | + & (pl.col("property_type") == ptype)) | |
| 61 | + .with_columns(pl.col("week").str.slice(0, 7).alias("month")) | |
| 62 | + .group_by("month").agg(pl.col("index_research").mean().alias("hedonic")) | |
| 63 | + ) | |
| 64 | + j = rs.join(h, on="month", how="inner").sort("month") | |
| 65 | + lg_rs = np.log(j["rs_index"].to_numpy()) | |
| 66 | + lg_he = np.log(j["hedonic"].to_numpy()) | |
| 67 | + corr = float(np.corrcoef(np.diff(lg_rs), np.diff(lg_he))[0, 1]) | |
| 68 | + rows.append({ | |
| 69 | + "cell": f"{gid}/{ptype}", "n_pairs": build_pairs(sub).height, | |
| 70 | + "corr_monthly_changes": round(corr, 3), | |
| 71 | + "end_gap_pct": round((lg_he[-1] - lg_rs[-1]) * 100, 2), | |
| 72 | + "rs_end": round(float(j["rs_index"][-1]), 1), | |
| 73 | + "hedonic_end": round(float(j["hedonic"][-1]), 1), | |
| 74 | + }) | |
| 75 | + note(f"- {gid}/{ptype}: Δcorr={corr:.3f}, end levels RS={j['rs_index'][-1]:.1f} " | |
| 76 | + f"vs hedonic={j['hedonic'][-1]:.1f}") | |
| 77 | + pl.DataFrame(rows).write_csv(TABLES_DIR / "repeat_sales_comparison.csv") | |
| 78 | + | |
| 79 | + | |
| 80 | +# ------------------------------------------------------------------------- # | |
| 81 | +# 2. Downsampling experiment (Montreal condo lab) | |
| 82 | +# ------------------------------------------------------------------------- # | |
| 83 | + | |
| 84 | +def downsampling(resid: pl.DataFrame, n_reps: int = 20) -> None: | |
| 85 | + note("") | |
| 86 | + note("## 2. Downsampling experiment — Montréal condo lab") | |
| 87 | + note("") | |
| 88 | + cell = resid.filter((pl.col("municipality") == "Montréal") | |
| 89 | + & (pl.col("propertyType") == "condo")) | |
| 90 | + grid = week_grid(resid) | |
| 91 | + sigma2 = float(cell["resid"].var()) | |
| 92 | + | |
| 93 | + ref = cell_deviation(cell.rename({"resid": "dev"}).select("week_str", "dev"), | |
| 94 | + grid, sigma2) | |
| 95 | + ref_path = ref.fit.smoothed | |
| 96 | + ref_turn = np.sign(np.diff(_ma(ref_path, 4))) | |
| 97 | + | |
| 98 | + rows = [] | |
| 99 | + for target in [100, 50, 25, 15, 10, 5]: | |
| 100 | + rmse_l, bias_l, vol_l, turn_l, cov_l = [], [], [], [], [] | |
| 101 | + for _ in range(n_reps): | |
| 102 | + thin = ( | |
| 103 | + cell.with_columns(pl.lit(RNG.random(cell.height)).alias("_u")) | |
| 104 | + .with_columns(pl.col("_u").rank().over("week_str").alias("_rk")) | |
| 105 | + .filter(pl.col("_rk") <= target) | |
| 106 | + ) | |
| 107 | + fit = cell_deviation(thin.rename({"resid": "dev"}).select("week_str", "dev"), | |
| 108 | + grid, sigma2) | |
| 109 | + path = fit.fit.smoothed | |
| 110 | + err = path - ref_path | |
| 111 | + rmse_l.append(np.sqrt(np.mean(err ** 2))) | |
| 112 | + bias_l.append(np.mean(err)) | |
| 113 | + vol_l.append(np.std(np.diff(path)) / max(np.std(np.diff(ref_path)), 1e-12)) | |
| 114 | + turn = np.sign(np.diff(_ma(path, 4))) | |
| 115 | + turn_l.append(float(np.mean(turn == ref_turn))) | |
| 116 | + band = 1.96 * np.sqrt(fit.fit.smoothed_var) | |
| 117 | + cov_l.append(float(np.mean(np.abs(err) <= band))) | |
| 118 | + rows.append({ | |
| 119 | + "target_tx_per_week": target, | |
| 120 | + "rmse_log_pct": round(float(np.mean(rmse_l)) * 100, 3), | |
| 121 | + "bias_log_pct": round(float(np.mean(bias_l)) * 100, 3), | |
| 122 | + "volatility_ratio": round(float(np.mean(vol_l)), 3), | |
| 123 | + "turning_point_agreement": round(float(np.mean(turn_l)), 3), | |
| 124 | + "ci95_coverage": round(float(np.mean(cov_l)), 3), | |
| 125 | + }) | |
| 126 | + note(f"- {target:>3} tx/wk: RMSE={rows[-1]['rmse_log_pct']}% " | |
| 127 | + f"bias={rows[-1]['bias_log_pct']}% vol_ratio={rows[-1]['volatility_ratio']} " | |
| 128 | + f"turn_agree={rows[-1]['turning_point_agreement']} " | |
| 129 | + f"CI95_cov={rows[-1]['ci95_coverage']}") | |
| 130 | + pl.DataFrame(rows).write_csv(TABLES_DIR / "downsampling_results.csv") | |
| 131 | + note("") | |
| 132 | + note("Reading: RMSE vs the full-sample (~145 tx/wk) smoothed path; CI " | |
| 133 | + "coverage of the deviation posterior; turning points on 4-week MA.") | |
| 134 | + | |
| 135 | + | |
| 136 | +def _ma(x: np.ndarray, w: int) -> np.ndarray: | |
| 137 | + return np.convolve(x, np.ones(w) / w, mode="same") | |
| 138 | + | |
| 139 | + | |
| 140 | +# ------------------------------------------------------------------------- # | |
| 141 | +# 3. Composition-shock simulation | |
| 142 | +# ------------------------------------------------------------------------- # | |
| 143 | + | |
| 144 | +def composition_shock(feat: pl.DataFrame) -> None: | |
| 145 | + note("") | |
| 146 | + note("## 3. Composition-shock simulation (province unifamilial)") | |
| 147 | + note("") | |
| 148 | + import pyfixest as pf | |
| 149 | + | |
| 150 | + uni = feat.filter(pl.col("propertyType") == "unifamilial") | |
| 151 | + med_fa = float(uni["floor_area_filled"].median()) | |
| 152 | + shock_weeks = [w for w in uni["week_str"].unique().to_list() | |
| 153 | + if w.startswith("2024") and w < "2024-07"] | |
| 154 | + | |
| 155 | + # Drop 70% of below-median-floorArea sales in shocked weeks. | |
| 156 | + u = RNG.random(uni.height) | |
| 157 | + uni = uni.with_columns(pl.lit(u).alias("_u")) | |
| 158 | + shocked = uni.filter( | |
| 159 | + ~( | |
| 160 | + pl.col("week_str").is_in(shock_weeks) | |
| 161 | + & (pl.col("floor_area_filled") < med_fa) | |
| 162 | + & (pl.col("_u") < 0.7) | |
| 163 | + ) | |
| 164 | + ) | |
| 165 | + note(f"- Shock: drop 70% of below-median floorArea sales in 2024-H1 " | |
| 166 | + f"({uni.height - shocked.height:,} of {uni.height:,} rows removed)") | |
| 167 | + | |
| 168 | + def weekly_median(df: pl.DataFrame) -> dict[str, float]: | |
| 169 | + return {r["week_str"]: r["m"] for r in | |
| 170 | + df.group_by("week_str").agg(pl.col("amount").median().alias("m")) | |
| 171 | + .iter_rows(named=True)} | |
| 172 | + | |
| 173 | + def hedonic_path(df: pl.DataFrame) -> dict[str, float]: | |
| 174 | + pdf = df.select("log_amount", "week_str", "log_fa", "fa_missing", | |
| 175 | + "age_bin", "building_type", "loc_fine").to_pandas() | |
| 176 | + pdf["fa_missing"] = pdf["fa_missing"].astype(int) | |
| 177 | + fit = pf.feols("log_amount ~ log_fa + fa_missing + C(age_bin) " | |
| 178 | + "| loc_fine + week_str", data=pdf) | |
| 179 | + fx = fit.fixef() | |
| 180 | + key = next(k for k in fx if "week_str" in k) | |
| 181 | + return {k: float(v) for k, v in fx[key].items()} | |
| 182 | + | |
| 183 | + med_b, med_s = weekly_median(uni), weekly_median(shocked) | |
| 184 | + hed_b, hed_s = hedonic_path(uni), hedonic_path(shocked) | |
| 185 | + | |
| 186 | + common = [w for w in shock_weeks if w in med_b and w in med_s | |
| 187 | + and w in hed_b and w in hed_s] | |
| 188 | + dmed = np.mean([np.log(med_s[w] / med_b[w]) for w in common]) * 100 | |
| 189 | + dhed = np.mean([hed_s[w] - hed_b[w] for w in common]) * 100 | |
| 190 | + note(f"- Raw weekly median moves by {dmed:+.2f}% in shocked weeks (must move)") | |
| 191 | + note(f"- Hedonic time-dummy moves by {dhed:+.2f}% (must stay ~0)") | |
| 192 | + pl.DataFrame({ | |
| 193 | + "metric": ["raw_median_shift_pct", "hedonic_shift_pct"], | |
| 194 | + "value": [round(float(dmed), 3), round(float(dhed), 3)], | |
| 195 | + }).write_csv(TABLES_DIR / "composition_shock.csv") | |
| 196 | + | |
| 197 | + | |
| 198 | +# ------------------------------------------------------------------------- # | |
| 199 | +# 4. Weekly vs monthly comparison | |
| 200 | +# ------------------------------------------------------------------------- # | |
| 201 | + | |
| 202 | +def weekly_vs_monthly(hier: pl.DataFrame) -> None: | |
| 203 | + note("") | |
| 204 | + note("## 4. Weekly vs monthly (same methodology)") | |
| 205 | + note("") | |
| 206 | + rows = [] | |
| 207 | + for gid, ptype in [("quebec", "unifamilial"), ("quebec", "condo"), | |
| 208 | + ("montreal", "condo")]: | |
| 209 | + h = hier.filter((pl.col("geography_id") == gid) | |
| 210 | + & (pl.col("property_type") == ptype)).sort("week") | |
| 211 | + wk_raw = np.log(h["index"].to_numpy()) | |
| 212 | + wk_smooth = np.log(h["index_smoothed"].to_numpy()) | |
| 213 | + wk_res = np.log(h["index_research"].to_numpy()) | |
| 214 | + monthly = ( | |
| 215 | + h.with_columns(pl.col("week").str.slice(0, 7).alias("m")) | |
| 216 | + .group_by("m").agg(pl.col("index_research").mean().alias("ix")) | |
| 217 | + .sort("m") | |
| 218 | + ) | |
| 219 | + mo = np.log(monthly["ix"].to_numpy()) | |
| 220 | + # noise = raw minus final smoothed; signal = final smoothed changes | |
| 221 | + noise_sd = float(np.nanstd(wk_raw - wk_res)) | |
| 222 | + signal_sd = float(np.std(np.diff(wk_res))) | |
| 223 | + revision_sd = float(np.std(wk_smooth - wk_res)) # real-time vs final | |
| 224 | + rows.append({ | |
| 225 | + "cell": f"{gid}/{ptype}", | |
| 226 | + "weekly_signal_sd_pct": round(signal_sd * 100, 3), | |
| 227 | + "weekly_noise_sd_pct": round(noise_sd * 100, 3), | |
| 228 | + "monthly_change_sd_pct": round(float(np.std(np.diff(mo))) * 100, 3), | |
| 229 | + "realtime_revision_sd_pct": round(revision_sd * 100, 3), | |
| 230 | + "weeks_to_monthly_ratio": round(signal_sd * np.sqrt(52 / 12) | |
| 231 | + / max(float(np.std(np.diff(mo))), 1e-9), 2), | |
| 232 | + }) | |
| 233 | + note(f"- {gid}/{ptype}: weekly signal SD={rows[-1]['weekly_signal_sd_pct']}%, " | |
| 234 | + f"noise SD={rows[-1]['weekly_noise_sd_pct']}%, monthly ΔSD=" | |
| 235 | + f"{rows[-1]['monthly_change_sd_pct']}%, real-time revision SD=" | |
| 236 | + f"{rows[-1]['realtime_revision_sd_pct']}%") | |
| 237 | + pl.DataFrame(rows).write_csv(TABLES_DIR / "weekly_vs_monthly.csv") | |
| 238 | + | |
| 239 | + | |
| 240 | +def main() -> None: | |
| 241 | + ensure_dirs() | |
| 242 | + cl = research_sample(pl.read_parquet(CLEAN_PARQUET)) | |
| 243 | + feat = build_features(cl) | |
| 244 | + hier = pl.read_parquet("data/interim/hierarchical_indexes.parquet") | |
| 245 | + resid = pl.read_parquet("data/interim/stage1_residuals.parquet") | |
| 246 | + | |
| 247 | + note("# QWHPI validation report") | |
| 248 | + note("") | |
| 249 | + repeat_sales_comparison(cl, hier) | |
| 250 | + downsampling(resid) | |
| 251 | + composition_shock(feat) | |
| 252 | + weekly_vs_monthly(hier) | |
| 253 | + | |
| 254 | + header = ( | |
| 255 | + "<!--\n" | |
| 256 | + "=============================================================================\n" | |
| 257 | + "QWHPI — Quebec Weekly Housing Price Index\n" | |
| 258 | + "Author : Simon-Pierre Boucher\n" | |
| 259 | + "Contact : contact@spboucher.ai\n" | |
| 260 | + "File : outputs/reports/validation_report.md\n" | |
| 261 | + "Purpose : Step 6 validation report (generated by 06_validation.py)\n" | |
| 262 | + "=============================================================================\n" | |
| 263 | + "-->\n\n" | |
| 264 | + ) | |
| 265 | + (REPORTS_DIR / "validation_report.md").write_text( | |
| 266 | + header + "\n".join(LINES) + "\n", encoding="utf-8") | |
| 267 | + print("\nReport written to outputs/reports/validation_report.md") | |
| 268 | + | |
| 269 | + | |
| 270 | +if __name__ == "__main__": | |
| 271 | + main() | |
added
engine/scripts/07_canonical.py
+228 −0
@@ -0,0 +1,228 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/07_canonical.py | |
| 7 | +# Purpose : Step 7 — assemble data/processed/qwhpi_weekly.parquet (canonical | |
| 8 | +# dataset), coverage matrix, assessment gap, vintages, liquidity. | |
| 9 | +# ============================================================================= | |
| 10 | +"""Canonical dataset build (Execution Order step 7, schema §4.9).""" | |
| 11 | + | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import datetime as dt | |
| 15 | +import sys | |
| 16 | +import unicodedata | |
| 17 | +from pathlib import Path | |
| 18 | + | |
| 19 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 20 | + | |
| 21 | +import numpy as np | |
| 22 | +import polars as pl | |
| 23 | + | |
| 24 | +from qwhpi.clean import CLEAN_PARQUET, research_sample | |
| 25 | +from qwhpi.config import ( | |
| 26 | + CITY_TARGETS, INTERIM_DIR, PROCESSED_DIR, PROPERTY_TYPES, TABLES_DIR, | |
| 27 | + ensure_dirs, | |
| 28 | +) | |
| 29 | +from qwhpi.index import ( | |
| 30 | + add_growth, add_reliability, add_representative_value, compute_basket, | |
| 31 | +) | |
| 32 | +from qwhpi.nowcast import partial_week_flags | |
| 33 | +from qwhpi.seasonal import seasonality_test | |
| 34 | +from qwhpi.vintages import snapshot, update_first_release | |
| 35 | + | |
| 36 | +MODEL_VERSION = "0.1.0" | |
| 37 | +CANONICAL = PROCESSED_DIR / "qwhpi_weekly.parquet" | |
| 38 | + | |
| 39 | + | |
| 40 | +def slug(name: str) -> str: | |
| 41 | + s = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode() | |
| 42 | + return s.lower().replace(" ", "-").replace("'", "") | |
| 43 | + | |
| 44 | + | |
| 45 | +def city_slug(name: str) -> str: | |
| 46 | + return "quebec-city" if name == "Québec" else slug(name) | |
| 47 | + | |
| 48 | + | |
| 49 | +def main() -> None: | |
| 50 | + ensure_dirs() | |
| 51 | + data_vintage = dt.date.today().isoformat() | |
| 52 | + hier = pl.read_parquet(INTERIM_DIR / "hierarchical_indexes.parquet") | |
| 53 | + cl = research_sample(pl.read_parquet(CLEAN_PARQUET)) | |
| 54 | + cl = cl.with_columns(pl.col("week").dt.strftime("%Y-%m-%d").alias("week_str")) | |
| 55 | + | |
| 56 | + # ------------------------------------------------------------------ # | |
| 57 | + # Map transactions to geography_ids (for baskets & liquidity) | |
| 58 | + # ------------------------------------------------------------------ # | |
| 59 | + cell_frames = [] | |
| 60 | + for gid_expr, filt in [ | |
| 61 | + (pl.lit("quebec"), pl.lit(True)), | |
| 62 | + (pl.concat_str([pl.lit("region-"), pl.col("region_code")]), | |
| 63 | + pl.col("region_code").is_not_null()), | |
| 64 | + ]: | |
| 65 | + cell_frames.append( | |
| 66 | + cl.filter(filt).select( | |
| 67 | + gid_expr.alias("geography_id"), | |
| 68 | + pl.col("propertyType").alias("property_type"), | |
| 69 | + pl.col("week_str").alias("week"), | |
| 70 | + "log_amount", "amount", | |
| 71 | + ) | |
| 72 | + ) | |
| 73 | + for city in CITY_TARGETS: | |
| 74 | + sub = cl.filter(pl.col("municipality") == city) | |
| 75 | + if sub.height: | |
| 76 | + cell_frames.append(sub.select( | |
| 77 | + pl.lit(city_slug(city)).alias("geography_id"), | |
| 78 | + pl.col("propertyType").alias("property_type"), | |
| 79 | + pl.col("week_str").alias("week"), | |
| 80 | + "log_amount", "amount", | |
| 81 | + )) | |
| 82 | + tx_cells = pl.concat(cell_frames) | |
| 83 | + tx_cells_all = pl.concat([ | |
| 84 | + tx_cells, | |
| 85 | + tx_cells.with_columns(pl.lit("all").alias("property_type")), | |
| 86 | + ]) | |
| 87 | + | |
| 88 | + # ------------------------------------------------------------------ # | |
| 89 | + # Canonical columns | |
| 90 | + # ------------------------------------------------------------------ # | |
| 91 | + basket = compute_basket(tx_cells_all, hier) | |
| 92 | + canon = add_representative_value(hier, basket) | |
| 93 | + canon = add_growth(canon) | |
| 94 | + canon = add_reliability(canon) | |
| 95 | + | |
| 96 | + prov_vol = ( | |
| 97 | + cl.group_by("week_str").len() | |
| 98 | + .rename({"week_str": "week", "len": "volume"}) | |
| 99 | + ) | |
| 100 | + canon = canon.join(partial_week_flags(prov_vol), on="week", how="left") \ | |
| 101 | + .with_columns(pl.col("is_partial_week").fill_null(False)) | |
| 102 | + | |
| 103 | + # Effective sample size: information units of the filtered estimate. | |
| 104 | + sigma2_map = { | |
| 105 | + t: float(pl.read_parquet(INTERIM_DIR / "stage1_residuals.parquet") | |
| 106 | + .filter(pl.col("propertyType") == t)["resid"].var()) | |
| 107 | + for t in PROPERTY_TYPES | |
| 108 | + } | |
| 109 | + sigma2_map["all"] = float(np.mean(list(sigma2_map.values()))) | |
| 110 | + canon = canon.with_columns( | |
| 111 | + pl.col("property_type").replace_strict(sigma2_map, default=None) | |
| 112 | + .alias("_sigma2") | |
| 113 | + ).with_columns( | |
| 114 | + (pl.col("_sigma2") / (pl.col("se_log") ** 2).clip(1e-9, None)) | |
| 115 | + .round(1).alias("effective_sample_size") | |
| 116 | + ).drop("_sigma2") | |
| 117 | + | |
| 118 | + canon = canon.with_columns( | |
| 119 | + pl.lit(MODEL_VERSION).alias("model_version"), | |
| 120 | + pl.lit(data_vintage).alias("data_vintage"), | |
| 121 | + pl.col("transactions").fill_nan(0).cast(pl.Int64), | |
| 122 | + ) | |
| 123 | + | |
| 124 | + cols = ["week", "geography_level", "geography_id", "geography_name", | |
| 125 | + "property_type", "index", "index_smoothed", "representative_value", | |
| 126 | + "transactions", "effective_sample_size", | |
| 127 | + "weekly_pct", "four_week_pct", "thirteen_week_pct", | |
| 128 | + "twenty_six_week_pct", "yoy_pct", | |
| 129 | + "lower_95", "upper_95", "reliability_grade", "shrinkage_weight", | |
| 130 | + "is_partial_week", "model_version", "data_vintage"] | |
| 131 | + canonical = canon.select(cols).sort( | |
| 132 | + ["geography_level", "geography_id", "property_type", "week"]) | |
| 133 | + canonical.write_parquet(CANONICAL) | |
| 134 | + print(f"Canonical table: {canonical.height:,} rows -> {CANONICAL}") | |
| 135 | + | |
| 136 | + # ------------------------------------------------------------------ # | |
| 137 | + # Coverage matrix (all municipalities × types) | |
| 138 | + # ------------------------------------------------------------------ # | |
| 139 | + weeks_total = cl["week_str"].n_unique() | |
| 140 | + liq = ( | |
| 141 | + cl.group_by(["municipality", "geo_code", "region", "propertyType", "week_str"]) | |
| 142 | + .len() | |
| 143 | + .group_by(["municipality", "geo_code", "region", "propertyType"]) | |
| 144 | + .agg(pl.col("len").median().alias("median_weekly_tx"), | |
| 145 | + pl.col("len").sum().alias("total_tx"), | |
| 146 | + (pl.len() / weeks_total * 100).round(1).alias("pct_weeks_active")) | |
| 147 | + ) | |
| 148 | + published_cities = {city_slug(c) for c in CITY_TARGETS} | |
| 149 | + liq = liq.with_columns( | |
| 150 | + pl.when(pl.col("municipality").is_in(list(CITY_TARGETS))) | |
| 151 | + .then(pl.lit("published")) | |
| 152 | + .when((pl.col("median_weekly_tx") >= 5) & (pl.col("pct_weeks_active") >= 80)) | |
| 153 | + .then(pl.lit("conditional")) | |
| 154 | + .otherwise(pl.lit("not_published")) | |
| 155 | + .alias("coverage_status") | |
| 156 | + ).sort("total_tx", descending=True) | |
| 157 | + liq.write_csv(TABLES_DIR / "coverage.csv") | |
| 158 | + liq.write_parquet(PROCESSED_DIR / "coverage_matrix.parquet") | |
| 159 | + counts = liq.group_by("coverage_status").len() | |
| 160 | + print("Coverage matrix:", dict(counts.iter_rows())) | |
| 161 | + | |
| 162 | + # Weekly liquidity per published cell. | |
| 163 | + weekly_liq = ( | |
| 164 | + tx_cells_all.group_by(["geography_id", "property_type", "week"]).len() | |
| 165 | + .rename({"len": "transactions"}).sort(["geography_id", "property_type", "week"]) | |
| 166 | + ) | |
| 167 | + weekly_liq.write_parquet(PROCESSED_DIR / "weekly_liquidity.parquet") | |
| 168 | + ( | |
| 169 | + weekly_liq.group_by(["geography_id", "property_type"]) | |
| 170 | + .agg(pl.col("transactions").median().alias("median"), | |
| 171 | + pl.col("transactions").mean().round(1).alias("mean")) | |
| 172 | + .sort(["geography_id", "property_type"]) | |
| 173 | + .write_csv(TABLES_DIR / "weekly_liquidity.csv") | |
| 174 | + ) | |
| 175 | + | |
| 176 | + # ------------------------------------------------------------------ # | |
| 177 | + # Assessment Gap Index (secondary module — never mixed with QWHPI) | |
| 178 | + # ------------------------------------------------------------------ # | |
| 179 | + gap = ( | |
| 180 | + cl.filter(pl.col("totalArValue") > 0) | |
| 181 | + .with_columns((pl.col("amount") / pl.col("totalArValue")).alias("ratio")) | |
| 182 | + ) | |
| 183 | + gap_cells = pl.concat([ | |
| 184 | + gap.select(pl.lit("quebec").alias("geography_id"), | |
| 185 | + pl.col("propertyType").alias("property_type"), | |
| 186 | + pl.col("week_str").alias("week"), "ratio"), | |
| 187 | + gap.select(pl.concat_str([pl.lit("region-"), pl.col("region_code")]) | |
| 188 | + .alias("geography_id"), | |
| 189 | + pl.col("propertyType").alias("property_type"), | |
| 190 | + pl.col("week_str").alias("week"), "ratio"), | |
| 191 | + ]) | |
| 192 | + assessment = ( | |
| 193 | + gap_cells.group_by(["geography_id", "property_type", "week"]) | |
| 194 | + .agg(pl.col("ratio").median().round(4).alias("median_ratio"), | |
| 195 | + pl.len().alias("n")) | |
| 196 | + .sort(["geography_id", "property_type", "week"]) | |
| 197 | + ) | |
| 198 | + assessment.write_parquet(PROCESSED_DIR / "assessment_gap.parquet") | |
| 199 | + print(f"Assessment gap: {assessment.height:,} rows") | |
| 200 | + | |
| 201 | + # ------------------------------------------------------------------ # | |
| 202 | + # Seasonality check (documents the NSA decision) | |
| 203 | + # ------------------------------------------------------------------ # | |
| 204 | + seas_rows = [] | |
| 205 | + for gid, ptype in [("quebec", "all"), ("quebec", "unifamilial"), | |
| 206 | + ("montreal", "condo")]: | |
| 207 | + s = canonical.filter((pl.col("geography_id") == gid) | |
| 208 | + & (pl.col("property_type") == ptype)).sort("week") | |
| 209 | + res = seasonality_test(np.log(s["index_smoothed"].to_numpy())) | |
| 210 | + seas_rows.append({"cell": f"{gid}/{ptype}", **res}) | |
| 211 | + seas = pl.DataFrame(seas_rows) | |
| 212 | + seas.write_csv(TABLES_DIR / "seasonality_tests.csv") | |
| 213 | + print(seas) | |
| 214 | + | |
| 215 | + # ------------------------------------------------------------------ # | |
| 216 | + # Vintages + latest snapshot table | |
| 217 | + # ------------------------------------------------------------------ # | |
| 218 | + snapshot(canonical, data_vintage) | |
| 219 | + update_first_release(canonical, data_vintage) | |
| 220 | + | |
| 221 | + latest_week = canonical.filter(~pl.col("is_partial_week"))["week"].max() | |
| 222 | + latest = canonical.filter(pl.col("week") == latest_week) | |
| 223 | + latest.write_csv(TABLES_DIR / "index_latest.csv") | |
| 224 | + print(f"Latest complete week: {latest_week} | {latest.height} series") | |
| 225 | + | |
| 226 | + | |
| 227 | +if __name__ == "__main__": | |
| 228 | + main() | |
added
engine/scripts/08_figures.py
+183 −0
@@ -0,0 +1,183 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/08_figures.py | |
| 7 | +# Purpose : Publication figures — aggregate, regions, cities, median-vs- | |
| 8 | +# hedonic, repeat sales, downsampling, volumes, YoY map, gap. | |
| 9 | +# ============================================================================= | |
| 10 | +"""Research figures (Execution Order step 10).""" | |
| 11 | + | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import sys | |
| 15 | +from pathlib import Path | |
| 16 | + | |
| 17 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 18 | + | |
| 19 | +import numpy as np | |
| 20 | +import polars as pl | |
| 21 | + | |
| 22 | +from qwhpi import plotting as P | |
| 23 | +from qwhpi.config import FIGURES_DIR, PROCESSED_DIR, TABLES_DIR, ensure_dirs | |
| 24 | + | |
| 25 | +import matplotlib.dates as mdates # noqa: E402 | |
| 26 | +import matplotlib.pyplot as plt # noqa: E402 | |
| 27 | + | |
| 28 | + | |
| 29 | +def dts(weeks: pl.Series) -> np.ndarray: | |
| 30 | + return weeks.str.to_date("%Y-%m-%d").to_numpy() | |
| 31 | + | |
| 32 | + | |
| 33 | +def cell(c: pl.DataFrame, gid: str, t: str) -> pl.DataFrame: | |
| 34 | + return c.filter((pl.col("geography_id") == gid) | |
| 35 | + & (pl.col("property_type") == t)).sort("week") | |
| 36 | + | |
| 37 | + | |
| 38 | +def main() -> None: | |
| 39 | + ensure_dirs() | |
| 40 | + c = pl.read_parquet(PROCESSED_DIR / "qwhpi_weekly.parquet") | |
| 41 | + | |
| 42 | + # 1 — Quebec aggregate with CI band | |
| 43 | + q = cell(c, "quebec", "all") | |
| 44 | + fig, ax = plt.subplots(figsize=(9, 4.5)) | |
| 45 | + x = dts(q["week"]) | |
| 46 | + ax.fill_between(x, q["lower_95"], q["upper_95"], color=P.BAND, label="95% CI") | |
| 47 | + ax.plot(x, q["index_smoothed"], color=P.SERIES[0], label="QWHPI-QC (smoothed)") | |
| 48 | + ax.plot(x, q["index"], color=P.SERIES[0], alpha=0.35, lw=1, label="raw weekly") | |
| 49 | + ax.set_title("Quebec weekly housing price index — all types (2021 = 100)") | |
| 50 | + ax.legend(loc="upper left") | |
| 51 | + ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) | |
| 52 | + P.save(fig, FIGURES_DIR / "quebec_aggregate.png") | |
| 53 | + | |
| 54 | + # 2 — 17 regions, small multiples | |
| 55 | + regs = sorted(c.filter(pl.col("geography_level") == "region")["geography_id"].unique()) | |
| 56 | + fig, axes = plt.subplots(3, 6, figsize=(15, 7), sharex=True, sharey=True) | |
| 57 | + for ax, rid in zip(axes.flat, regs): | |
| 58 | + r = cell(c, rid, "all") | |
| 59 | + ax.plot(dts(r["week"]), r["index_smoothed"], color=P.SERIES[0], lw=1.4) | |
| 60 | + ax.set_title(r["geography_name"][0], fontsize=8) | |
| 61 | + ax.xaxis.set_major_formatter(mdates.DateFormatter("%y")) | |
| 62 | + for ax in axes.flat[len(regs):]: | |
| 63 | + ax.axis("off") | |
| 64 | + fig.suptitle("Regional indexes — all types (2021 = 100)") | |
| 65 | + P.save(fig, FIGURES_DIR / "region_comparison.png") | |
| 66 | + | |
| 67 | + # 3 — Big-4 cities | |
| 68 | + fig, ax = plt.subplots(figsize=(9, 4.5)) | |
| 69 | + for i, (gid, label) in enumerate([("montreal", "Montréal"), ("quebec-city", "Québec"), | |
| 70 | + ("laval", "Laval"), ("gatineau", "Gatineau")]): | |
| 71 | + s = cell(c, gid, "all") | |
| 72 | + ax.plot(dts(s["week"]), s["index_smoothed"], color=P.SERIES[i], label=label) | |
| 73 | + ax.set_title("Major cities — all types (2021 = 100)") | |
| 74 | + ax.legend(loc="upper left") | |
| 75 | + P.save(fig, FIGURES_DIR / "big4_cities.png") | |
| 76 | + | |
| 77 | + # 4 — Condo across cities | |
| 78 | + fig, ax = plt.subplots(figsize=(9, 4.5)) | |
| 79 | + for i, (gid, label) in enumerate([("montreal", "Montréal"), ("quebec-city", "Québec"), | |
| 80 | + ("gatineau", "Gatineau")]): | |
| 81 | + s = cell(c, gid, "condo") | |
| 82 | + ax.plot(dts(s["week"]), s["index_smoothed"], color=P.SERIES[i], label=label) | |
| 83 | + ax.set_title("Condo indexes by city (2021 = 100)") | |
| 84 | + ax.legend(loc="upper left") | |
| 85 | + P.save(fig, FIGURES_DIR / "condo_by_city.png") | |
| 86 | + | |
| 87 | + # 5 — Raw median vs hedonic (province, unifamilial) | |
| 88 | + bi = pl.read_parquet("data/interim/baseline_indexes.parquet") | |
| 89 | + b = bi.filter((pl.col("geography_id") == "quebec") | |
| 90 | + & (pl.col("property_type") == "unifamilial")).sort("week") | |
| 91 | + med = b["raw_median"].to_numpy() | |
| 92 | + base_med = np.nanmean(med[:52]) | |
| 93 | + fig, ax = plt.subplots(figsize=(9, 4.5)) | |
| 94 | + ax.plot(dts(b["week"]), med / base_med * 100, color=P.SERIES[1], lw=1.2, | |
| 95 | + label="Raw weekly median (indexed)") | |
| 96 | + h = cell(c, "quebec", "unifamilial") | |
| 97 | + ax.plot(dts(h["week"]), h["index_smoothed"], color=P.SERIES[0], | |
| 98 | + label="QWHPI (quality-adjusted)") | |
| 99 | + ax.set_title("Composition noise: raw median vs hedonic index — Quebec unifamilial") | |
| 100 | + ax.legend(loc="upper left") | |
| 101 | + P.save(fig, FIGURES_DIR / "raw_median_vs_hedonic.png") | |
| 102 | + | |
| 103 | + # 6 — Weekly volumes | |
| 104 | + vol = pl.read_parquet(PROCESSED_DIR / "weekly_liquidity.parquet") | |
| 105 | + v = vol.filter((pl.col("geography_id") == "quebec") | |
| 106 | + & (pl.col("property_type") == "all")).sort("week") | |
| 107 | + fig, ax = plt.subplots(figsize=(9, 3.2)) | |
| 108 | + ax.bar(dts(v["week"]), v["transactions"], width=6, color=P.SERIES[0], alpha=0.6) | |
| 109 | + ax.set_title("Weekly transaction volume — Quebec (research sample)") | |
| 110 | + P.save(fig, FIGURES_DIR / "volumes.png") | |
| 111 | + | |
| 112 | + # 7 — Downsampling stability | |
| 113 | + ds = pl.read_csv(TABLES_DIR / "downsampling_results.csv") | |
| 114 | + fig, ax = plt.subplots(figsize=(7, 4)) | |
| 115 | + ax.plot(ds["target_tx_per_week"], ds["rmse_log_pct"], "o-", color=P.SERIES[0], | |
| 116 | + label="RMSE vs full-sample path (%)") | |
| 117 | + ax.plot(ds["target_tx_per_week"], (1 - ds["ci95_coverage"]) * 10, "s--", | |
| 118 | + color=P.SERIES[1], label="(1 − CI coverage) × 10") | |
| 119 | + ax.set_xlabel("target transactions per week") | |
| 120 | + ax.set_title("Downsampling experiment — Montréal condo") | |
| 121 | + ax.legend() | |
| 122 | + ax.invert_xaxis() | |
| 123 | + P.save(fig, FIGURES_DIR / "downsampling_stability.png") | |
| 124 | + | |
| 125 | + # 8 — YoY map (choropleth) | |
| 126 | + import geopandas as gpd | |
| 127 | + latest = c.filter((pl.col("geography_level") == "region") | |
| 128 | + & (pl.col("property_type") == "all") | |
| 129 | + & (~pl.col("is_partial_week"))) | |
| 130 | + lw = latest.filter(pl.col("week") == latest["week"].max()) | |
| 131 | + g = gpd.read_file("web/public/regions.geojson") | |
| 132 | + g = g.merge(lw.select("geography_id", "yoy_pct").to_pandas(), on="geography_id") | |
| 133 | + fig, ax = plt.subplots(figsize=(8, 7)) | |
| 134 | + g.plot(column="yoy_pct", cmap="Blues", legend=True, ax=ax, | |
| 135 | + edgecolor="#fcfcfb", linewidth=0.8, | |
| 136 | + legend_kwds={"label": "YoY %", "shrink": 0.6}) | |
| 137 | + ax.set_axis_off() | |
| 138 | + ax.set_title(f"Year-over-year appreciation by region — week of {lw['week'][0]}") | |
| 139 | + P.save(fig, FIGURES_DIR / "yoy_map.png") | |
| 140 | + | |
| 141 | + # 9 — Assessment gap evolution | |
| 142 | + gap = pl.read_parquet(PROCESSED_DIR / "assessment_gap.parquet") | |
| 143 | + fig, ax = plt.subplots(figsize=(9, 4)) | |
| 144 | + for i, t in enumerate(["unifamilial", "condo", "plex"]): | |
| 145 | + s = gap.filter((pl.col("geography_id") == "quebec") | |
| 146 | + & (pl.col("property_type") == t)).sort("week") | |
| 147 | + # 13-week rolling median for readability | |
| 148 | + vals = s["median_ratio"].rolling_median(window_size=13) | |
| 149 | + ax.plot(dts(s["week"]), vals, color=P.SERIES[i], label=t) | |
| 150 | + ax.axhline(1.0, color=P.TEXT2, lw=1, ls="--") | |
| 151 | + ax.set_title("Assessment gap — median sale price / municipal assessment (13-wk median)") | |
| 152 | + ax.legend(loc="upper left") | |
| 153 | + P.save(fig, FIGURES_DIR / "assessment_gap.png") | |
| 154 | + | |
| 155 | + # 10 — Reliability heat table (cells × grade) | |
| 156 | + summary = ( | |
| 157 | + c.group_by(["geography_id", "property_type"]) | |
| 158 | + .agg(pl.col("reliability_grade").mode().first().alias("grade")) | |
| 159 | + ) | |
| 160 | + counts = summary.group_by(["property_type", "grade"]).len().sort(["property_type", "grade"]) | |
| 161 | + fig, ax = plt.subplots(figsize=(7, 3.4)) | |
| 162 | + grades = ["A", "B", "C", "D", "E"] | |
| 163 | + types = ["all", "unifamilial", "condo", "plex"] | |
| 164 | + mat = np.zeros((len(types), len(grades))) | |
| 165 | + for r in counts.iter_rows(named=True): | |
| 166 | + mat[types.index(r["property_type"]), grades.index(r["grade"])] = r["len"] | |
| 167 | + im = ax.imshow(mat, cmap="Blues", aspect="auto") | |
| 168 | + ax.set_xticks(range(len(grades)), grades) | |
| 169 | + ax.set_yticks(range(len(types)), types) | |
| 170 | + for i in range(len(types)): | |
| 171 | + for j in range(len(grades)): | |
| 172 | + if mat[i, j]: | |
| 173 | + ax.text(j, i, int(mat[i, j]), ha="center", va="center", | |
| 174 | + color=P.TEXT if mat[i, j] < mat.max() * 0.6 else "white") | |
| 175 | + ax.set_title("Published series by reliability grade") | |
| 176 | + ax.grid(False) | |
| 177 | + P.save(fig, FIGURES_DIR / "reliability_matrix.png") | |
| 178 | + | |
| 179 | + print("All figures written to outputs/figures/") | |
| 180 | + | |
| 181 | + | |
| 182 | +if __name__ == "__main__": | |
| 183 | + main() | |
added
engine/scripts/09_monthly.py
+275 −0
@@ -0,0 +1,275 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/09_monthly.py | |
| 7 | +# Purpose : v2.1 — monthly hybrid indexes: RTD province anchor, DIRECT robust | |
| 8 | +# local time-dummy for liquid cells, hierarchical shrinkage for | |
| 9 | +# thin cells only. | |
| 10 | +# ============================================================================= | |
| 11 | +"""Monthly index build (v2.1 headline). | |
| 12 | + | |
| 13 | +Architecture (arbitrated against repeat sales, local time dummies and | |
| 14 | +stratified matched-cell medians — see methodology_v2_research.md): | |
| 15 | + | |
| 16 | +- Province × type ....... rolling-time-dummy, Huber IRLS, mean splice. | |
| 17 | +- Liquid cell (median ≥ LIQUID_MIN tx/month) ... DIRECT local robust | |
| 18 | + time-dummy (own β, own FSA FE), then a light state-space smoother | |
| 19 | + (obs var σ²/nₘ) for the real-time variant. Deviation-from-pooled-surface | |
| 20 | + methods compress strong local divergences (Québec City condo: +50–55% | |
| 21 | + by every composition-free arbiter vs +42% by deviations), so liquid | |
| 22 | + cells are estimated on their own data. | |
| 23 | +- Thin cell ............. deviation from its parent's published path via the | |
| 24 | + heteroskedastic local-level Kalman model (unchanged machinery). | |
| 25 | +""" | |
| 26 | + | |
| 27 | +from __future__ import annotations | |
| 28 | + | |
| 29 | +import sys | |
| 30 | +import time | |
| 31 | +import unicodedata | |
| 32 | +from pathlib import Path | |
| 33 | + | |
| 34 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 35 | + | |
| 36 | +import numpy as np | |
| 37 | +import polars as pl | |
| 38 | + | |
| 39 | +from qwhpi.clean import CLEAN_PARQUET, research_sample | |
| 40 | +from qwhpi.config import CITY_TARGETS, INTERIM_DIR, PROPERTY_TYPES, TABLES_DIR, ensure_dirs | |
| 41 | +from qwhpi.features import build_features | |
| 42 | +from qwhpi.hierarchy import CellDeviation, base_constant, cell_deviation, compose_cell_table | |
| 43 | +from qwhpi.rtd import fullpool_residualize, local_time_dummy, run_rtd | |
| 44 | +from qwhpi.state_space import fit_local_level | |
| 45 | + | |
| 46 | +MONTHLY_PARQUET = INTERIM_DIR / "monthly_indexes.parquet" | |
| 47 | +FP_RESID = INTERIM_DIR / "rtd_residuals.parquet" | |
| 48 | +RTD_PATHS = INTERIM_DIR / "rtd_paths.parquet" | |
| 49 | + | |
| 50 | +LIQUID_MIN = 40 # median tx/month for direct local estimation | |
| 51 | + | |
| 52 | + | |
| 53 | +def slug(name: str) -> str: | |
| 54 | + s = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode() | |
| 55 | + return s.lower().replace(" ", "-").replace("'", "") | |
| 56 | + | |
| 57 | + | |
| 58 | +def city_slug(name: str) -> str: | |
| 59 | + return "quebec-city" if name == "Québec" else slug(name) | |
| 60 | + | |
| 61 | + | |
| 62 | +def compose_direct(grid, raw_path, n, sigma2, gid, gname, glevel, ptype): | |
| 63 | + """Published table for a directly-estimated liquid cell.""" | |
| 64 | + fit = fit_local_level(raw_path, np.maximum(n, 1.0), sigma2) | |
| 65 | + base = base_constant(grid, fit.filtered) | |
| 66 | + log_raw = raw_path - base | |
| 67 | + log_filt = fit.filtered - base | |
| 68 | + log_smooth = fit.smoothed - base | |
| 69 | + se = np.sqrt(np.maximum(fit.filtered_var, 0.0)) | |
| 70 | + return pl.DataFrame({ | |
| 71 | + "week": grid, | |
| 72 | + "geography_id": gid, "geography_name": gname, "geography_level": glevel, | |
| 73 | + "property_type": ptype, | |
| 74 | + "index": np.exp(log_raw) * 100.0, | |
| 75 | + "index_smoothed": np.exp(log_filt) * 100.0, | |
| 76 | + "index_research": np.exp(log_smooth) * 100.0, | |
| 77 | + "log_index": log_filt, | |
| 78 | + "se_log": se, | |
| 79 | + "lower_95": np.exp(log_filt - 1.96 * se) * 100.0, | |
| 80 | + "upper_95": np.exp(log_filt + 1.96 * se) * 100.0, | |
| 81 | + "transactions": n, | |
| 82 | + "shrinkage_weight": 1.0 - fit.gain, # temporal smoothing weight | |
| 83 | + "research_var": np.maximum(fit.smoothed_var, 0.0), | |
| 84 | + }), fit | |
| 85 | + | |
| 86 | + | |
| 87 | +def combine_all(tables, weights, gid, gname, glevel): | |
| 88 | + """Fixed-share composition of the three type indexes into 'all'.""" | |
| 89 | + wsum = sum(weights.values()) | |
| 90 | + w = {k: v / wsum for k, v in weights.items()} | |
| 91 | + base = tables[next(iter(tables))].sort("week") | |
| 92 | + grid = base["week"].to_list() | |
| 93 | + | |
| 94 | + def wavg(col, log=True): | |
| 95 | + acc = np.zeros(len(grid)) | |
| 96 | + for k, tbl in tables.items(): | |
| 97 | + vals = tbl.sort("week")[col].to_numpy() | |
| 98 | + acc += w[k] * (np.log(vals / 100.0) if log else vals) | |
| 99 | + return acc | |
| 100 | + | |
| 101 | + log_raw, log_filt, log_res = wavg("index"), wavg("index_smoothed"), wavg("index_research") | |
| 102 | + var = sum((w[k] ** 2) * (tbl.sort("week")["se_log"].to_numpy() ** 2) | |
| 103 | + for k, tbl in tables.items()) | |
| 104 | + se = np.sqrt(var) | |
| 105 | + n = sum(np.nan_to_num(tbl.sort("week")["transactions"].to_numpy()) | |
| 106 | + for tbl in tables.values()) | |
| 107 | + return pl.DataFrame({ | |
| 108 | + "week": grid, | |
| 109 | + "geography_id": gid, "geography_name": gname, "geography_level": glevel, | |
| 110 | + "property_type": "all", | |
| 111 | + "index": np.exp(log_raw) * 100.0, | |
| 112 | + "index_smoothed": np.exp(log_filt) * 100.0, | |
| 113 | + "index_research": np.exp(log_res) * 100.0, | |
| 114 | + "log_index": log_filt, | |
| 115 | + "se_log": se, | |
| 116 | + "lower_95": np.exp(log_filt - 1.96 * se) * 100.0, | |
| 117 | + "upper_95": np.exp(log_filt + 1.96 * se) * 100.0, | |
| 118 | + "transactions": n, | |
| 119 | + "shrinkage_weight": wavg("shrinkage_weight", log=False), | |
| 120 | + "research_var": var, | |
| 121 | + }) | |
| 122 | + | |
| 123 | + | |
| 124 | +def main() -> None: | |
| 125 | + ensure_dirs() | |
| 126 | + feat = build_features(research_sample(pl.read_parquet(CLEAN_PARQUET))) | |
| 127 | + | |
| 128 | + print("Stage 1: province RTD (13-month windows, Huber IRLS) ...") | |
| 129 | + t0 = time.time() | |
| 130 | + rtd = run_rtd(feat, verbose=False) | |
| 131 | + print(f" done in {time.time() - t0:.0f}s") | |
| 132 | + rtd.drift.write_csv(TABLES_DIR / "rtd_coefficient_drift.csv") | |
| 133 | + | |
| 134 | + print("Stage 1b: full-pool robust residualization (thin-cell hierarchy) ...") | |
| 135 | + fp_resid = fullpool_residualize(feat) | |
| 136 | + fp_resid.write_parquet(FP_RESID) | |
| 137 | + sigma2 = {t: float(fp_resid.filter(pl.col("propertyType") == t)["resid"].var()) | |
| 138 | + for t in PROPERTY_TYPES} | |
| 139 | + | |
| 140 | + grid = rtd.months | |
| 141 | + T = len(grid) | |
| 142 | + pl.concat([ | |
| 143 | + pl.DataFrame({"month": grid, "property_type": t, | |
| 144 | + "mean_splice": rtd.paths[t], | |
| 145 | + "movement_splice": rtd.paths_movement[t], | |
| 146 | + "window_splice": rtd.paths_window[t]}) | |
| 147 | + for t in PROPERTY_TYPES | |
| 148 | + ]).write_parquet(RTD_PATHS) | |
| 149 | + | |
| 150 | + resid = fp_resid.rename({"month": "week_str"}) | |
| 151 | + region_names = dict(feat.select("region_code", "region").unique().iter_rows()) | |
| 152 | + frames: list[pl.DataFrame] = [] | |
| 153 | + estimation_log: list[dict] = [] | |
| 154 | + | |
| 155 | + # Parent references for thin cells: | |
| 156 | + region_resid_dev: dict[tuple[str, str], CellDeviation] = {} | |
| 157 | + region_anchor: dict[tuple[str, str], np.ndarray] = {} # published filtered log path | |
| 158 | + | |
| 159 | + for ptype in PROPERTY_TYPES: | |
| 160 | + path = rtd.paths[ptype] | |
| 161 | + r_t = resid.filter(pl.col("propertyType") == ptype) | |
| 162 | + f_t = feat.filter(pl.col("propertyType") == ptype) | |
| 163 | + counts = {r["week_str"]: r["n"] for r in | |
| 164 | + r_t.group_by("week_str").agg(pl.len().alias("n")).iter_rows(named=True)} | |
| 165 | + n_prov = np.array([counts.get(m, 0) for m in grid], dtype=float) | |
| 166 | + path_var = np.where(n_prov > 0, sigma2[ptype] / np.maximum(n_prov, 1), np.nan) | |
| 167 | + path_var = np.nan_to_num(path_var, nan=float(np.nanmax(path_var))) | |
| 168 | + | |
| 169 | + prov = compose_cell_table( | |
| 170 | + grid=grid, province_path=path, deviations=[], | |
| 171 | + geography_id="quebec", geography_name="Québec (province)", | |
| 172 | + geography_level="province", property_type=ptype, path_var=path_var, | |
| 173 | + ).with_columns(pl.Series("transactions", n_prov), | |
| 174 | + pl.Series("shrinkage_weight", np.zeros(T))) | |
| 175 | + frames.append(prov) | |
| 176 | + estimation_log.append({"cell": f"quebec/{ptype}", "method": "rtd_mean_splice"}) | |
| 177 | + | |
| 178 | + def monthly_counts(sub: pl.DataFrame) -> np.ndarray: | |
| 179 | + c = dict(sub.group_by("month").agg(pl.len().alias("n")).iter_rows()) | |
| 180 | + return np.array([c.get(m, 0) for m in grid], dtype=float) | |
| 181 | + | |
| 182 | + # ---------------- regions ---------------- # | |
| 183 | + for rcode in sorted(region_names): | |
| 184 | + cell_r = r_t.filter(pl.col("region_code") == rcode) | |
| 185 | + cell_f = f_t.filter(pl.col("region_code") == rcode) | |
| 186 | + n_m = monthly_counts(cell_f) | |
| 187 | + med = float(np.median(n_m)) | |
| 188 | + rdev = cell_deviation(cell_r.rename({"resid": "dev"}) | |
| 189 | + .select("week_str", "dev"), grid, sigma2[ptype]) | |
| 190 | + region_resid_dev[(rcode, ptype)] = rdev | |
| 191 | + gid = f"region-{rcode}" | |
| 192 | + | |
| 193 | + direct = local_time_dummy(cell_f, grid) if med >= LIQUID_MIN else None | |
| 194 | + if direct is not None: | |
| 195 | + raw_path, n_cell, s2 = direct | |
| 196 | + tbl, fit = compose_direct(grid, raw_path, n_cell, s2, | |
| 197 | + gid, region_names[rcode], "region", ptype) | |
| 198 | + frames.append(tbl) | |
| 199 | + region_anchor[(rcode, ptype)] = raw_path * 0 + fit.filtered # filtered log path | |
| 200 | + estimation_log.append({"cell": f"{gid}/{ptype}", "method": "direct_local_td", | |
| 201 | + "median_tx": med}) | |
| 202 | + else: | |
| 203 | + tbl = compose_cell_table( | |
| 204 | + grid=grid, province_path=path, deviations=[rdev], | |
| 205 | + geography_id=gid, geography_name=region_names[rcode], | |
| 206 | + geography_level="region", property_type=ptype, path_var=path_var) | |
| 207 | + frames.append(tbl) | |
| 208 | + region_anchor[(rcode, ptype)] = path + rdev.fit.filtered | |
| 209 | + estimation_log.append({"cell": f"{gid}/{ptype}", "method": "hierarchical", | |
| 210 | + "median_tx": med}) | |
| 211 | + | |
| 212 | + # ---------------- cities ---------------- # | |
| 213 | + for city in CITY_TARGETS: | |
| 214 | + cell_f = f_t.filter(pl.col("municipality") == city) | |
| 215 | + if cell_f.height == 0: | |
| 216 | + continue | |
| 217 | + rcode = cell_f["region_code"][0] | |
| 218 | + gid = city_slug(city) | |
| 219 | + n_m = monthly_counts(cell_f) | |
| 220 | + med = float(np.median(n_m)) | |
| 221 | + | |
| 222 | + direct = local_time_dummy(cell_f, grid) if med >= LIQUID_MIN else None | |
| 223 | + if direct is not None: | |
| 224 | + raw_path, n_cell, s2 = direct | |
| 225 | + tbl, _ = compose_direct(grid, raw_path, n_cell, s2, | |
| 226 | + gid, city, "municipality", ptype) | |
| 227 | + frames.append(tbl) | |
| 228 | + estimation_log.append({"cell": f"{gid}/{ptype}", "method": "direct_local_td", | |
| 229 | + "median_tx": med}) | |
| 230 | + else: | |
| 231 | + # deviation of the city vs its REGION's residual path, anchored | |
| 232 | + # on the region's published (filtered) log path | |
| 233 | + rdev = region_resid_dev[(rcode, ptype)] | |
| 234 | + rpath = {m: v for m, v in zip(grid, rdev.fit.smoothed)} | |
| 235 | + cell_r = r_t.filter(pl.col("municipality") == city).with_columns( | |
| 236 | + (pl.col("resid") - pl.col("week_str").replace_strict(rpath, default=0.0)) | |
| 237 | + .alias("dev")) | |
| 238 | + mdev = cell_deviation(cell_r.select("week_str", "dev"), grid, sigma2[ptype]) | |
| 239 | + tbl = compose_cell_table( | |
| 240 | + grid=grid, province_path=region_anchor[(rcode, ptype)], | |
| 241 | + deviations=[mdev], | |
| 242 | + geography_id=gid, geography_name=city, | |
| 243 | + geography_level="municipality", property_type=ptype, | |
| 244 | + path_var=path_var) | |
| 245 | + frames.append(tbl) | |
| 246 | + estimation_log.append({"cell": f"{gid}/{ptype}", "method": "hierarchical", | |
| 247 | + "median_tx": med}) | |
| 248 | + | |
| 249 | + out = pl.concat(frames) | |
| 250 | + | |
| 251 | + all_frames = [] | |
| 252 | + for gid, gname, glevel in out.select( | |
| 253 | + "geography_id", "geography_name", "geography_level").unique().iter_rows(): | |
| 254 | + tables = {t: out.filter((pl.col("geography_id") == gid) | |
| 255 | + & (pl.col("property_type") == t)) | |
| 256 | + for t in PROPERTY_TYPES} | |
| 257 | + tables = {t: tbl for t, tbl in tables.items() if tbl.height} | |
| 258 | + weights = {t: max(float(np.nansum(tbl["transactions"].to_numpy())), 1.0) | |
| 259 | + for t, tbl in tables.items()} | |
| 260 | + all_frames.append(combine_all(tables, weights, gid, gname, glevel)) | |
| 261 | + | |
| 262 | + out = pl.concat([out, *all_frames]).rename({"week": "period"}).sort( | |
| 263 | + ["geography_level", "geography_id", "property_type", "period"]) | |
| 264 | + out.write_parquet(MONTHLY_PARQUET) | |
| 265 | + | |
| 266 | + log_df = pl.DataFrame(estimation_log) | |
| 267 | + log_df.write_csv(TABLES_DIR / "estimation_methods.csv") | |
| 268 | + n_direct = log_df.filter(pl.col("method") == "direct_local_td").height | |
| 269 | + n_hier = log_df.filter(pl.col("method") == "hierarchical").height | |
| 270 | + print(f"\nCells: {n_direct} direct, {n_hier} hierarchical, 3 RTD province") | |
| 271 | + print(f"Saved {out.height:,} observations to {MONTHLY_PARQUET}") | |
| 272 | + | |
| 273 | + | |
| 274 | +if __name__ == "__main__": | |
| 275 | + main() | |
added
engine/scripts/10_monthly_validation.py
+219 −0
@@ -0,0 +1,219 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/10_monthly_validation.py | |
| 7 | +# Purpose : v2.1 validation — repeat sales, downsampling (direct vs | |
| 8 | +# hierarchical threshold), composition shock, drift & seasonality. | |
| 9 | +# ============================================================================= | |
| 10 | +"""Monthly validation suite (v2.1).""" | |
| 11 | + | |
| 12 | +from __future__ import annotations | |
| 13 | + | |
| 14 | +import sys | |
| 15 | +from pathlib import Path | |
| 16 | + | |
| 17 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 18 | + | |
| 19 | +import numpy as np | |
| 20 | +import polars as pl | |
| 21 | + | |
| 22 | +from qwhpi.clean import CLEAN_PARQUET, research_sample | |
| 23 | +from qwhpi.config import INTERIM_DIR, REPORTS_DIR, TABLES_DIR, ensure_dirs | |
| 24 | +from qwhpi.features import build_features | |
| 25 | +from qwhpi.hierarchy import cell_deviation | |
| 26 | +from qwhpi.repeat_sales import bmn_index, build_pairs | |
| 27 | +from qwhpi.rtd import local_time_dummy, month_grid | |
| 28 | +from qwhpi.seasonal import seasonality_test | |
| 29 | +from qwhpi.state_space import fit_local_level | |
| 30 | + | |
| 31 | +RNG = np.random.default_rng(42) | |
| 32 | +LINES: list[str] = [] | |
| 33 | + | |
| 34 | + | |
| 35 | +def note(line: str = "") -> None: | |
| 36 | + LINES.append(line) | |
| 37 | + print(line) | |
| 38 | + | |
| 39 | + | |
| 40 | +def main() -> None: | |
| 41 | + ensure_dirs() | |
| 42 | + cl = research_sample(pl.read_parquet(CLEAN_PARQUET)) | |
| 43 | + feat = build_features(cl) | |
| 44 | + monthly = pl.read_parquet(INTERIM_DIR / "monthly_indexes.parquet") | |
| 45 | + fp = pl.read_parquet(INTERIM_DIR / "rtd_residuals.parquet") | |
| 46 | + grid = month_grid(sorted(fp["month"].unique().to_list())) | |
| 47 | + | |
| 48 | + note("# QHPI v2.1 validation report (monthly, robust)") | |
| 49 | + note("") | |
| 50 | + | |
| 51 | + # ------------------------------------------------------------------ # | |
| 52 | + # 1. Repeat-sales comparison | |
| 53 | + # ------------------------------------------------------------------ # | |
| 54 | + note("## 1. Repeat sales (BMN, unit-signature pairs) vs published index") | |
| 55 | + note("") | |
| 56 | + rows = [] | |
| 57 | + for gid, mun, ptype in [ | |
| 58 | + ("quebec", None, "unifamilial"), ("quebec", None, "condo"), | |
| 59 | + ("quebec", None, "plex"), ("montreal", "Montréal", "condo"), | |
| 60 | + ("montreal", "Montréal", "unifamilial"), | |
| 61 | + ("quebec-city", "Québec", "condo"), | |
| 62 | + ("quebec-city", "Québec", "unifamilial"), | |
| 63 | + ]: | |
| 64 | + sub = cl.filter(pl.col("propertyType") == ptype) | |
| 65 | + if mun: | |
| 66 | + sub = sub.filter(pl.col("municipality") == mun) | |
| 67 | + rs = bmn_index(build_pairs(sub)) | |
| 68 | + if rs is None: | |
| 69 | + continue | |
| 70 | + h = monthly.filter((pl.col("geography_id") == gid) | |
| 71 | + & (pl.col("property_type") == ptype)) \ | |
| 72 | + .select(pl.col("period").alias("month"), "index_research") | |
| 73 | + j = rs.join(h, on="month", how="inner").sort("month") | |
| 74 | + lg_rs = np.log(j["rs_index"].to_numpy()) | |
| 75 | + lg_he = np.log(j["index_research"].to_numpy()) | |
| 76 | + corr = float(np.corrcoef(np.diff(lg_rs), np.diff(lg_he))[0, 1]) | |
| 77 | + gap = float((lg_he[-2] - lg_rs[-2]) * 100) | |
| 78 | + rows.append({"cell": f"{gid}/{ptype}", "n_pairs": build_pairs(sub).height, | |
| 79 | + "corr_monthly_changes": round(corr, 3), | |
| 80 | + "end_gap_pct": round(gap, 2)}) | |
| 81 | + note(f"- {gid}/{ptype}: Δcorr={corr:.3f}, écart final hedonic−RS = {gap:+.1f}%") | |
| 82 | + pl.DataFrame(rows).write_csv(TABLES_DIR / "repeat_sales_comparison_monthly.csv") | |
| 83 | + | |
| 84 | + # ------------------------------------------------------------------ # | |
| 85 | + # 2. Downsampling — validates LIQUID_MIN and the grade scale | |
| 86 | + # ------------------------------------------------------------------ # | |
| 87 | + note("") | |
| 88 | + note("## 2. Downsampling (Montréal condo, ~620 tx/month)") | |
| 89 | + note("") | |
| 90 | + mc_feat = feat.filter((pl.col("municipality") == "Montréal") | |
| 91 | + & (pl.col("propertyType") == "condo")) | |
| 92 | + ref = local_time_dummy(mc_feat, grid) | |
| 93 | + assert ref is not None | |
| 94 | + ref_path = ref[0] - ref[0].mean() | |
| 95 | + | |
| 96 | + mc_resid = fp.filter((pl.col("municipality") == "Montréal") | |
| 97 | + & (pl.col("propertyType") == "condo")) \ | |
| 98 | + .rename({"month": "week_str"}) | |
| 99 | + sig2 = float(mc_resid["resid"].var()) | |
| 100 | + prov_path_tbl = pl.read_parquet(INTERIM_DIR / "rtd_paths.parquet") | |
| 101 | + prov = prov_path_tbl.filter(pl.col("property_type") == "condo").sort("month") | |
| 102 | + prov_path = prov["mean_splice"].to_numpy() | |
| 103 | + | |
| 104 | + rows = [] | |
| 105 | + for target, n_reps in [(300, 8), (150, 8), (75, 8), (40, 10), (20, 10), (10, 10)]: | |
| 106 | + rmse_d, rmse_h = [], [] | |
| 107 | + for _ in range(n_reps): | |
| 108 | + thin_f = (mc_feat.with_columns(pl.lit(RNG.random(mc_feat.height)).alias("_u")) | |
| 109 | + .with_columns(pl.col("_u").rank().over("month").alias("_rk")) | |
| 110 | + .filter(pl.col("_rk") <= target)) | |
| 111 | + # direct estimator on thinned cell | |
| 112 | + d = local_time_dummy(thin_f, grid) | |
| 113 | + if d is not None: | |
| 114 | + sm = fit_local_level(d[0], np.maximum(d[1], 1.0), d[2]).smoothed | |
| 115 | + err = (sm - sm.mean()) - ref_path | |
| 116 | + rmse_d.append(np.sqrt(np.mean(err ** 2))) | |
| 117 | + # hierarchical estimator on thinned residuals | |
| 118 | + thin_r = (mc_resid.with_columns(pl.lit(RNG.random(mc_resid.height)).alias("_u")) | |
| 119 | + .with_columns(pl.col("_u").rank().over("week_str").alias("_rk")) | |
| 120 | + .filter(pl.col("_rk") <= target)) | |
| 121 | + dev = cell_deviation(thin_r.rename({"resid": "dev"}) | |
| 122 | + .select("week_str", "dev"), grid, sig2) | |
| 123 | + hier = prov_path + dev.fit.smoothed | |
| 124 | + err = (hier - hier.mean()) - ref_path | |
| 125 | + rmse_h.append(np.sqrt(np.mean(err ** 2))) | |
| 126 | + rows.append({"target_tx_per_month": target, | |
| 127 | + "rmse_direct_pct": round(float(np.mean(rmse_d)) * 100, 2) if rmse_d else None, | |
| 128 | + "rmse_hierarchical_pct": round(float(np.mean(rmse_h)) * 100, 2)}) | |
| 129 | + note(f"- {target:>3} tx/mois: RMSE direct={rows[-1]['rmse_direct_pct']}% " | |
| 130 | + f"| hiérarchique={rows[-1]['rmse_hierarchical_pct']}%") | |
| 131 | + pl.DataFrame(rows).write_csv(TABLES_DIR / "downsampling_monthly.csv") | |
| 132 | + note("") | |
| 133 | + note("Lecture: en RMSE pur (référence = direct plein échantillon, cellule à " | |
| 134 | + "faible divergence) le croisement se situe vers 75–150 tx/mois. Mais le " | |
| 135 | + "RMSE du hiérarchique contient un biais de compression qui explose sur " | |
| 136 | + "les cellules fortement divergentes (Québec-ville condo: ~8–10 pts, cf. " | |
| 137 | + "arbitrage RS/stratifié) alors que l'erreur du direct est du bruit pur " | |
| 138 | + "(~1.9% à 40 tx/mois), réduit par le lisseur d'état. LIQUID_MIN=40 " | |
| 139 | + "échange donc un bruit borné contre un biais non borné.") | |
| 140 | + | |
| 141 | + # ------------------------------------------------------------------ # | |
| 142 | + # 3. Composition shock (monthly) | |
| 143 | + # ------------------------------------------------------------------ # | |
| 144 | + note("") | |
| 145 | + note("## 3. Choc de composition (province unifamilial, mensuel)") | |
| 146 | + import pyfixest as pf | |
| 147 | + uni = feat.filter(pl.col("propertyType") == "unifamilial") | |
| 148 | + med_fa = float(uni["floor_area_filled"].median()) | |
| 149 | + shock_months = [m for m in grid if m.startswith("2024")][:6] | |
| 150 | + u = RNG.random(uni.height) | |
| 151 | + uni = uni.with_columns(pl.lit(u).alias("_u")) | |
| 152 | + shocked = uni.filter(~(pl.col("month").is_in(shock_months) | |
| 153 | + & (pl.col("floor_area_filled") < med_fa) | |
| 154 | + & (pl.col("_u") < 0.7))) | |
| 155 | + | |
| 156 | + def med_path(df): | |
| 157 | + return {r["month"]: r["m"] for r in | |
| 158 | + df.group_by("month").agg(pl.col("amount").median().alias("m")) | |
| 159 | + .iter_rows(named=True)} | |
| 160 | + | |
| 161 | + def hed_path(df): | |
| 162 | + pdf = df.select("log_amount", "month", "log_fa", "fa_missing", | |
| 163 | + "age_bin", "loc_fine").to_pandas() | |
| 164 | + pdf["fa_missing"] = pdf["fa_missing"].astype(int) | |
| 165 | + fit = pf.feols("log_amount ~ log_fa + fa_missing + C(age_bin) " | |
| 166 | + "| loc_fine + month", data=pdf) | |
| 167 | + fx = fit.fixef() | |
| 168 | + key = next(k for k in fx if "month" in k) | |
| 169 | + return {k: float(v) for k, v in fx[key].items()} | |
| 170 | + | |
| 171 | + mb, ms = med_path(uni), med_path(shocked) | |
| 172 | + hb, hs = hed_path(uni), hed_path(shocked) | |
| 173 | + common = [m for m in shock_months if m in mb and m in ms and m in hb and m in hs] | |
| 174 | + dmed = float(np.mean([np.log(ms[m] / mb[m]) for m in common])) * 100 | |
| 175 | + dhed = float(np.mean([hs[m] - hb[m] for m in common])) * 100 | |
| 176 | + note(f"- Médiane brute: {dmed:+.2f}% dans les mois choqués (doit bouger)") | |
| 177 | + note(f"- Hédonique: {dhed:+.2f}% (doit rester ~0)") | |
| 178 | + pl.DataFrame({"metric": ["raw_median_shift_pct", "hedonic_shift_pct"], | |
| 179 | + "value": [round(dmed, 3), round(dhed, 3)]}) \ | |
| 180 | + .write_csv(TABLES_DIR / "composition_shock_monthly.csv") | |
| 181 | + | |
| 182 | + # ------------------------------------------------------------------ # | |
| 183 | + # 4. Drift, splice, seasonality | |
| 184 | + # ------------------------------------------------------------------ # | |
| 185 | + note("") | |
| 186 | + note("## 4. Diagnostics de robustesse") | |
| 187 | + drift = pl.read_csv(TABLES_DIR / "rtd_coefficient_drift.csv") | |
| 188 | + b = drift["beta_log_fa"] | |
| 189 | + note(f"- Dérive β(log surface) sur 55 fenêtres: {b.min():.3f} → {b.max():.3f} " | |
| 190 | + f"(amplitude {(b.max() - b.min()) / b.mean() * 100:.1f}% de la moyenne) — " | |
| 191 | + "justifie le RTD contre le pool fixe.") | |
| 192 | + seas_rows = [] | |
| 193 | + for gid, t in [("quebec", "all"), ("quebec", "unifamilial"), ("montreal", "condo")]: | |
| 194 | + s = monthly.filter((pl.col("geography_id") == gid) | |
| 195 | + & (pl.col("property_type") == t)).sort("period") | |
| 196 | + res = seasonality_test(np.log(s["index_research"].to_numpy()), n_harmonics=2) | |
| 197 | + seas_rows.append({"cell": f"{gid}/{t}", **res}) | |
| 198 | + note(f"- Saisonnalité {gid}/{t}: F={res['f_stat']} p={res['p_value']} → " | |
| 199 | + + ("significative" if res["significant_5pct"] else "non significative (NSA)")) | |
| 200 | + pl.DataFrame(seas_rows).write_csv(TABLES_DIR / "seasonality_monthly.csv") | |
| 201 | + | |
| 202 | + header = ( | |
| 203 | + "<!--\n" | |
| 204 | + "=============================================================================\n" | |
| 205 | + "QWHPI — Quebec Weekly Housing Price Index\n" | |
| 206 | + "Author : Simon-Pierre Boucher\n" | |
| 207 | + "Contact : contact@spboucher.ai\n" | |
| 208 | + "File : outputs/reports/validation_v2_report.md\n" | |
| 209 | + "Purpose : v2.1 monthly validation report (10_monthly_validation.py)\n" | |
| 210 | + "=============================================================================\n" | |
| 211 | + "-->\n\n" | |
| 212 | + ) | |
| 213 | + (REPORTS_DIR / "validation_v2_report.md").write_text( | |
| 214 | + header + "\n".join(LINES) + "\n", encoding="utf-8") | |
| 215 | + print("\nReport: outputs/reports/validation_v2_report.md") | |
| 216 | + | |
| 217 | + | |
| 218 | +if __name__ == "__main__": | |
| 219 | + main() | |
added
engine/scripts/11_monthly_canonical.py
+220 −0
@@ -0,0 +1,220 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : engine/scripts/11_monthly_canonical.py | |
| 7 | +# Purpose : v2.1 canonical dataset — qhpi_monthly.parquet with growth | |
| 8 | +# horizons, reliability grades, dollar values, vintages, coverage. | |
| 9 | +# ============================================================================= | |
| 10 | +"""Canonical monthly dataset build (v2.1). | |
| 11 | + | |
| 12 | +Reliability grades are anchored on the monthly downsampling experiment | |
| 13 | +(outputs/tables/downsampling_monthly.csv): smoothed-path RMSE ≈ 1.0–1.2% at | |
| 14 | +150+ tx/month, ≈1.5% at 75, ≈1.9% at 40, ≥2.6% at 10. | |
| 15 | + A: median ≥ 150 tx/month and se_log < 1.5% | |
| 16 | + B: median ≥ 75 and se_log < 2.5% | |
| 17 | + C: median ≥ 40 (direct estimation floor) | |
| 18 | + D: median ≥ 15 (hierarchical, heavy shrinkage) | |
| 19 | + E: < 15 (model-implied, transparency only) | |
| 20 | +""" | |
| 21 | + | |
| 22 | +from __future__ import annotations | |
| 23 | + | |
| 24 | +import datetime as dt | |
| 25 | +import sys | |
| 26 | +import unicodedata | |
| 27 | +from pathlib import Path | |
| 28 | + | |
| 29 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 30 | + | |
| 31 | +import numpy as np | |
| 32 | +import polars as pl | |
| 33 | + | |
| 34 | +from qwhpi.clean import CLEAN_PARQUET, research_sample | |
| 35 | +from qwhpi.config import ( | |
| 36 | + CITY_TARGETS, INTERIM_DIR, PROCESSED_DIR, PROPERTY_TYPES, TABLES_DIR, | |
| 37 | + ensure_dirs, | |
| 38 | +) | |
| 39 | + | |
| 40 | +MODEL_VERSION = "2.1.0" | |
| 41 | +CANONICAL = PROCESSED_DIR / "qhpi_monthly.parquet" | |
| 42 | +FIRST_RELEASE = PROCESSED_DIR / "first_release_monthly.parquet" | |
| 43 | +VINTAGE_DIR = PROCESSED_DIR / "vintages_monthly" | |
| 44 | +KEY = ["geography_id", "property_type", "period"] | |
| 45 | + | |
| 46 | +GROWTH = {"monthly_pct": 1, "three_month_pct": 3, "six_month_pct": 6, "yoy_pct": 12} | |
| 47 | + | |
| 48 | + | |
| 49 | +def slug(name: str) -> str: | |
| 50 | + s = unicodedata.normalize("NFKD", name).encode("ascii", "ignore").decode() | |
| 51 | + return s.lower().replace(" ", "-").replace("'", "") | |
| 52 | + | |
| 53 | + | |
| 54 | +def city_slug(name: str) -> str: | |
| 55 | + return "quebec-city" if name == "Québec" else slug(name) | |
| 56 | + | |
| 57 | + | |
| 58 | +def main() -> None: | |
| 59 | + ensure_dirs() | |
| 60 | + data_vintage = dt.date.today().isoformat() | |
| 61 | + m = pl.read_parquet(INTERIM_DIR / "monthly_indexes.parquet") | |
| 62 | + cl = research_sample(pl.read_parquet(CLEAN_PARQUET)).with_columns( | |
| 63 | + pl.col("date").dt.strftime("%Y-%m").alias("period")) | |
| 64 | + | |
| 65 | + # ---------------- transactions per published cell ---------------- # | |
| 66 | + cell_frames = [ | |
| 67 | + cl.select(pl.lit("quebec").alias("geography_id"), | |
| 68 | + pl.col("propertyType").alias("property_type"), | |
| 69 | + "period", "log_amount", "amount"), | |
| 70 | + cl.filter(pl.col("region_code").is_not_null()).select( | |
| 71 | + pl.concat_str([pl.lit("region-"), pl.col("region_code")]).alias("geography_id"), | |
| 72 | + pl.col("propertyType").alias("property_type"), | |
| 73 | + "period", "log_amount", "amount"), | |
| 74 | + ] | |
| 75 | + for city in CITY_TARGETS: | |
| 76 | + sub = cl.filter(pl.col("municipality") == city) | |
| 77 | + if sub.height: | |
| 78 | + cell_frames.append(sub.select( | |
| 79 | + pl.lit(city_slug(city)).alias("geography_id"), | |
| 80 | + pl.col("propertyType").alias("property_type"), | |
| 81 | + "period", "log_amount", "amount")) | |
| 82 | + tx = pl.concat(cell_frames) | |
| 83 | + tx_all = pl.concat([tx, tx.with_columns(pl.lit("all").alias("property_type"))]) | |
| 84 | + | |
| 85 | + # ---------------- representative dollar value ---------------- # | |
| 86 | + basket = ( | |
| 87 | + tx_all.join(m.select(*KEY, "index_smoothed"), on=KEY, how="inner") | |
| 88 | + .group_by(["geography_id", "property_type"]) | |
| 89 | + .agg((pl.col("log_amount") - (pl.col("index_smoothed") / 100.0).log()) | |
| 90 | + .mean().exp().round(0).alias("basket_value")) | |
| 91 | + ) | |
| 92 | + canon = m.join(basket, on=["geography_id", "property_type"], how="left") \ | |
| 93 | + .with_columns((pl.col("basket_value") * pl.col("index_smoothed") / 100.0) | |
| 94 | + .round(0).alias("representative_value")) | |
| 95 | + | |
| 96 | + # ---------------- growth horizons ---------------- # | |
| 97 | + keys = ["geography_id", "property_type"] | |
| 98 | + canon = canon.sort([*keys, "period"]).with_columns([ | |
| 99 | + ((pl.col("index_smoothed") / pl.col("index_smoothed").shift(h).over(keys) - 1) * 100) | |
| 100 | + .round(3).alias(name) | |
| 101 | + for name, h in GROWTH.items() | |
| 102 | + ]) | |
| 103 | + | |
| 104 | + # ---------------- reliability grades ---------------- # | |
| 105 | + med = canon.group_by(keys).agg( | |
| 106 | + pl.col("transactions").fill_nan(None).median().alias("cell_median_tx")) | |
| 107 | + canon = canon.join(med, on=keys, how="left") | |
| 108 | + base_grade = ( | |
| 109 | + pl.when((pl.col("cell_median_tx") >= 150) & (pl.col("se_log") < 0.015)).then(pl.lit("A")) | |
| 110 | + .when((pl.col("cell_median_tx") >= 75) & (pl.col("se_log") < 0.025)).then(pl.lit("B")) | |
| 111 | + .when(pl.col("cell_median_tx") >= 40).then(pl.lit("C")) | |
| 112 | + .when(pl.col("cell_median_tx") >= 15).then(pl.lit("D")) | |
| 113 | + .otherwise(pl.lit("E")) | |
| 114 | + ) | |
| 115 | + downgrade = {"A": "B", "B": "C", "C": "D", "D": "E", "E": "E"} | |
| 116 | + canon = canon.with_columns( | |
| 117 | + pl.when(pl.col("transactions").fill_nan(0) == 0) | |
| 118 | + .then(base_grade.replace(downgrade)).otherwise(base_grade) | |
| 119 | + .alias("reliability_grade")) | |
| 120 | + | |
| 121 | + # ---------------- partial month (registration lag) ---------------- # | |
| 122 | + max_date = cl["date"].max() | |
| 123 | + last_period = max_date.strftime("%Y-%m") | |
| 124 | + month_end = (dt.date(max_date.year + (max_date.month == 12), | |
| 125 | + max_date.month % 12 + 1, 1) - dt.timedelta(days=1)) | |
| 126 | + is_partial_last = max_date < month_end | |
| 127 | + canon = canon.with_columns( | |
| 128 | + ((pl.col("period") == last_period) & is_partial_last).alias("is_partial_month")) | |
| 129 | + | |
| 130 | + # ---------------- effective sample size ---------------- # | |
| 131 | + fp = pl.read_parquet(INTERIM_DIR / "rtd_residuals.parquet") | |
| 132 | + sigma2 = {t: float(fp.filter(pl.col("propertyType") == t)["resid"].var()) | |
| 133 | + for t in PROPERTY_TYPES} | |
| 134 | + sigma2["all"] = float(np.mean(list(sigma2.values()))) | |
| 135 | + canon = canon.with_columns( | |
| 136 | + pl.col("property_type").replace_strict(sigma2, default=None).alias("_s2") | |
| 137 | + ).with_columns( | |
| 138 | + (pl.col("_s2") / (pl.col("se_log") ** 2).clip(1e-9, None)) | |
| 139 | + .round(1).alias("effective_sample_size") | |
| 140 | + ).drop("_s2") | |
| 141 | + | |
| 142 | + canon = canon.with_columns( | |
| 143 | + pl.lit(MODEL_VERSION).alias("model_version"), | |
| 144 | + pl.lit(data_vintage).alias("data_vintage"), | |
| 145 | + pl.lit("monthly").alias("frequency"), | |
| 146 | + pl.col("transactions").fill_nan(0).cast(pl.Int64), | |
| 147 | + ) | |
| 148 | + cols = ["period", "geography_level", "geography_id", "geography_name", | |
| 149 | + "property_type", "index", "index_smoothed", "representative_value", | |
| 150 | + "transactions", "effective_sample_size", | |
| 151 | + "monthly_pct", "three_month_pct", "six_month_pct", "yoy_pct", | |
| 152 | + "lower_95", "upper_95", "reliability_grade", "shrinkage_weight", | |
| 153 | + "is_partial_month", "model_version", "data_vintage"] | |
| 154 | + canonical = canon.select(cols).sort( | |
| 155 | + ["geography_level", "geography_id", "property_type", "period"]) | |
| 156 | + canonical.write_parquet(CANONICAL) | |
| 157 | + print(f"Canonical: {canonical.height:,} rows -> {CANONICAL}") | |
| 158 | + | |
| 159 | + # ---------------- liquidity + coverage ---------------- # | |
| 160 | + liq = (tx_all.group_by([*keys, "period"]).len().rename({"len": "transactions"}) | |
| 161 | + .sort([*keys, "period"])) | |
| 162 | + liq.write_parquet(PROCESSED_DIR / "monthly_liquidity.parquet") | |
| 163 | + | |
| 164 | + months_total = cl["period"].n_unique() | |
| 165 | + cov = ( | |
| 166 | + cl.group_by(["municipality", "geo_code", "region", "propertyType", "period"]).len() | |
| 167 | + .group_by(["municipality", "geo_code", "region", "propertyType"]) | |
| 168 | + .agg(pl.col("len").median().alias("median_monthly_tx"), | |
| 169 | + pl.col("len").sum().alias("total_tx"), | |
| 170 | + (pl.len() / months_total * 100).round(1).alias("pct_months_active")) | |
| 171 | + .with_columns( | |
| 172 | + pl.when(pl.col("municipality").is_in(list(CITY_TARGETS))) | |
| 173 | + .then(pl.lit("published")) | |
| 174 | + .when((pl.col("median_monthly_tx") >= 15) & (pl.col("pct_months_active") >= 85)) | |
| 175 | + .then(pl.lit("conditional")) | |
| 176 | + .otherwise(pl.lit("not_published")) | |
| 177 | + .alias("coverage_status")) | |
| 178 | + .sort("total_tx", descending=True) | |
| 179 | + ) | |
| 180 | + cov.write_csv(TABLES_DIR / "coverage_monthly.csv") | |
| 181 | + cov.write_parquet(PROCESSED_DIR / "coverage_matrix_monthly.parquet") | |
| 182 | + print("Coverage:", dict(cov.group_by("coverage_status").len().iter_rows())) | |
| 183 | + | |
| 184 | + # ---------------- assessment gap (monthly) ---------------- # | |
| 185 | + gap = cl.filter(pl.col("totalArValue") > 0).with_columns( | |
| 186 | + (pl.col("amount") / pl.col("totalArValue")).alias("ratio")) | |
| 187 | + gap_cells = pl.concat([ | |
| 188 | + gap.select(pl.lit("quebec").alias("geography_id"), | |
| 189 | + pl.col("propertyType").alias("property_type"), "period", "ratio"), | |
| 190 | + gap.select(pl.concat_str([pl.lit("region-"), pl.col("region_code")]) | |
| 191 | + .alias("geography_id"), | |
| 192 | + pl.col("propertyType").alias("property_type"), "period", "ratio"), | |
| 193 | + ]) | |
| 194 | + (gap_cells.group_by([*keys, "period"] if False else ["geography_id", "property_type", "period"]) | |
| 195 | + .agg(pl.col("ratio").median().round(4).alias("median_ratio"), pl.len().alias("n")) | |
| 196 | + .sort(["geography_id", "property_type", "period"]) | |
| 197 | + .write_parquet(PROCESSED_DIR / "assessment_gap_monthly.parquet")) | |
| 198 | + | |
| 199 | + # ---------------- vintages (monthly store) ---------------- # | |
| 200 | + vd = VINTAGE_DIR / f"data_vintage={data_vintage}" | |
| 201 | + vd.mkdir(parents=True, exist_ok=True) | |
| 202 | + canonical.write_parquet(vd / "qhpi_monthly.parquet") | |
| 203 | + fresh = canonical.select( | |
| 204 | + *KEY, | |
| 205 | + pl.col("index").alias("first_release_index"), | |
| 206 | + pl.col("index_smoothed").alias("first_release_index_smoothed"), | |
| 207 | + pl.lit(data_vintage).alias("first_release_vintage")) | |
| 208 | + if FIRST_RELEASE.exists(): | |
| 209 | + existing = pl.read_parquet(FIRST_RELEASE) | |
| 210 | + fresh = pl.concat([existing, fresh.join(existing.select(KEY), on=KEY, how="anti")]) | |
| 211 | + fresh.write_parquet(FIRST_RELEASE) | |
| 212 | + | |
| 213 | + latest_period = canonical.filter(~pl.col("is_partial_month"))["period"].max() | |
| 214 | + canonical.filter(pl.col("period") == latest_period) \ | |
| 215 | + .write_csv(TABLES_DIR / "index_latest_monthly.csv") | |
| 216 | + print(f"Latest complete month: {latest_period}") | |
| 217 | + | |
| 218 | + | |
| 219 | +if __name__ == "__main__": | |
| 220 | + main() | |
added
engine/src/qwhpi/__init__.py
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/__init__.py | |
| 6 | +# Purpose : Package root for the QWHPI index engine. | |
| 7 | +# ============================================================================= | |
| 8 | +"""QWHPI index engine — quality-adjusted weekly housing price indexes for Quebec.""" | |
| 9 | + | |
| 10 | +__version__ = "0.1.0" | |
| 11 | +__author__ = "Simon-Pierre Boucher" | |
| 12 | +__contact__ = "contact@spboucher.ai" | |
added
engine/src/qwhpi/clean.py
+149 −0
@@ -0,0 +1,149 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/clean.py | |
| 6 | +# Purpose : Reproducible cleaning — duplicate flags, arm's-length screens, | |
| 7 | +# characteristic sanitation. Rows are flagged, never deleted. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Cleaning pipeline (Execution Order step 3). | |
| 10 | + | |
| 11 | +Principles (CLAUDE.md §4.3): | |
| 12 | +- The raw CSV is immutable; this module only adds columns. | |
| 13 | +- Nothing is silently deleted: every excluded row carries ``exclude_reason``. | |
| 14 | +- Filters are economically justified and fully tabulated in the exclusion | |
| 15 | + table (``outputs/tables/exclusions.csv``, written by scripts/03_clean.py). | |
| 16 | + | |
| 17 | +Screens applied (headline research sample): | |
| 18 | + | |
| 19 | +1. ``dup_exact`` — exact duplicate rows ignoring ``id`` (recording | |
| 20 | + artifacts); first occurrence kept. | |
| 21 | +2. ``dup_addr_date_amt`` — same street+city+date+amount (same conveyance | |
| 22 | + registered twice); first occurrence kept. Same address+date with | |
| 23 | + *different* amounts is legitimate (multi-unit condo/plex closings) and | |
| 24 | + is NOT excluded. | |
| 25 | +3. ``nonmarket_ratio`` — amount / totalArValue outside [0.2, 5.0] where the | |
| 26 | + assessment is available. Transfers far below assessed value are | |
| 27 | + predominantly non-arm's-length (family transfers, estate settlements, | |
| 28 | + partial-interest deeds); far above, land assembly / data errors. | |
| 29 | + Missing assessments are NOT excluded (no ratio evidence). | |
| 30 | +4. ``price_ceiling`` — amount > $10M: portfolio, land-assembly or | |
| 31 | + commercial-scale conveyances, not single-dwelling market sales. | |
| 32 | + | |
| 33 | +Note: the provider already floors prices at $50,000 — no additional low-price | |
| 34 | +screen is required (documented, verified in the audit). | |
| 35 | + | |
| 36 | +Characteristic sanitation (set to null + flag, transaction retained): | |
| 37 | +- ``floorArea`` outside [20, 2000] m² for a dwelling -> null (``fa_suspect``) | |
| 38 | +- ``yearBuilt`` > sale year + 1 -> null (``yb_suspect``) | |
| 39 | + | |
| 40 | +``indéterminé`` property type is retained in the clean table but flagged; | |
| 41 | +it never enters type-specific or "all" indexes (aggregate of the three | |
| 42 | +determined types). | |
| 43 | +""" | |
| 44 | + | |
| 45 | +from __future__ import annotations | |
| 46 | + | |
| 47 | +import polars as pl | |
| 48 | + | |
| 49 | +from qwhpi.config import PROCESSED_DIR, PROPERTY_TYPE_UNDETERMINED | |
| 50 | + | |
| 51 | +CLEAN_PARQUET = PROCESSED_DIR / "transactions_clean.parquet" | |
| 52 | + | |
| 53 | +RATIO_LO = 0.2 | |
| 54 | +RATIO_HI = 5.0 | |
| 55 | +PRICE_CEILING = 10_000_000 | |
| 56 | +FLOORAREA_LO = 20.0 | |
| 57 | +FLOORAREA_HI = 2000.0 | |
| 58 | + | |
| 59 | + | |
| 60 | +def add_flags(tx: pl.DataFrame, geo: pl.DataFrame) -> pl.DataFrame: | |
| 61 | + """Merge geography and add duplicate/exclusion/sanitation flags.""" | |
| 62 | + df = tx.join(geo, on="id", how="left") | |
| 63 | + | |
| 64 | + # --- duplicates (order by id for a deterministic 'first occurrence') --- # | |
| 65 | + content_cols = [c for c in tx.columns if c not in ("id",)] | |
| 66 | + df = df.sort("id").with_columns( | |
| 67 | + pl.int_range(pl.len()).over(content_cols).alias("_rk_exact"), | |
| 68 | + pl.int_range(pl.len()).over(["street", "city", "date", "amount"]).alias("_rk_ada"), | |
| 69 | + ).with_columns( | |
| 70 | + (pl.col("_rk_exact") > 0).alias("dup_exact"), | |
| 71 | + ( | |
| 72 | + (pl.col("_rk_ada") > 0) | |
| 73 | + & pl.col("street").is_not_null() | |
| 74 | + ).alias("dup_addr_date_amt"), | |
| 75 | + ).drop("_rk_exact", "_rk_ada") | |
| 76 | + | |
| 77 | + df = df.with_columns( | |
| 78 | + pl.when(pl.col("dup_exact")) | |
| 79 | + .then(pl.lit("exact_duplicate")) | |
| 80 | + .when(pl.col("dup_addr_date_amt")) | |
| 81 | + .then(pl.lit("addr_date_amount_duplicate")) | |
| 82 | + .otherwise(None) | |
| 83 | + .alias("duplicate_reason"), | |
| 84 | + (pl.col("dup_exact") | pl.col("dup_addr_date_amt")).alias("duplicate_flag"), | |
| 85 | + ) | |
| 86 | + | |
| 87 | + # --- arm's-length / plausibility screens --- # | |
| 88 | + ratio = pl.col("amount") / pl.col("totalArValue") | |
| 89 | + df = df.with_columns( | |
| 90 | + pl.when(pl.col("totalArValue") > 0) | |
| 91 | + .then(ratio) | |
| 92 | + .otherwise(None) | |
| 93 | + .alias("assessment_ratio"), | |
| 94 | + ).with_columns( | |
| 95 | + ( | |
| 96 | + pl.col("assessment_ratio").is_not_null() | |
| 97 | + & ((pl.col("assessment_ratio") < RATIO_LO) | (pl.col("assessment_ratio") > RATIO_HI)) | |
| 98 | + ).alias("nonmarket_ratio"), | |
| 99 | + (pl.col("amount") > PRICE_CEILING).alias("price_ceiling"), | |
| 100 | + (pl.col("propertyType") == PROPERTY_TYPE_UNDETERMINED).alias("is_undetermined_type"), | |
| 101 | + ) | |
| 102 | + | |
| 103 | + # --- characteristic sanitation (null + flag, never drop the row) --- # | |
| 104 | + sale_year = pl.col("date").dt.year() | |
| 105 | + df = df.with_columns( | |
| 106 | + ( | |
| 107 | + pl.col("floorArea").is_not_null() | |
| 108 | + & ((pl.col("floorArea") < FLOORAREA_LO) | (pl.col("floorArea") > FLOORAREA_HI)) | |
| 109 | + ).alias("fa_suspect"), | |
| 110 | + ( | |
| 111 | + pl.col("yearBuilt").is_not_null() & (pl.col("yearBuilt") > sale_year + 1) | |
| 112 | + ).alias("yb_suspect"), | |
| 113 | + ).with_columns( | |
| 114 | + pl.when(pl.col("fa_suspect")).then(None).otherwise(pl.col("floorArea")).alias("floorArea"), | |
| 115 | + pl.when(pl.col("yb_suspect")).then(None).otherwise(pl.col("yearBuilt")).alias("yearBuilt"), | |
| 116 | + ) | |
| 117 | + | |
| 118 | + # --- consolidated exclusion verdict (priority order for reporting) --- # | |
| 119 | + df = df.with_columns( | |
| 120 | + pl.when(pl.col("dup_exact")).then(pl.lit("exact_duplicate")) | |
| 121 | + .when(pl.col("dup_addr_date_amt")).then(pl.lit("addr_date_amount_duplicate")) | |
| 122 | + .when(pl.col("price_ceiling")).then(pl.lit("price_above_10m")) | |
| 123 | + .when(pl.col("nonmarket_ratio")).then(pl.lit("assessment_ratio_outside_band")) | |
| 124 | + .otherwise(None) | |
| 125 | + .alias("exclude_reason") | |
| 126 | + ).with_columns( | |
| 127 | + pl.col("exclude_reason").is_null().alias("in_research_sample") | |
| 128 | + ) | |
| 129 | + return df | |
| 130 | + | |
| 131 | + | |
| 132 | +def research_sample(df: pl.DataFrame, include_undetermined: bool = False) -> pl.DataFrame: | |
| 133 | + """Rows eligible for index estimation.""" | |
| 134 | + out = df.filter(pl.col("in_research_sample")) | |
| 135 | + if not include_undetermined: | |
| 136 | + out = out.filter(~pl.col("is_undetermined_type")) | |
| 137 | + return out | |
| 138 | + | |
| 139 | + | |
| 140 | +def build_clean(tx: pl.DataFrame, geo: pl.DataFrame, force: bool = False) -> pl.DataFrame: | |
| 141 | + """Compute (or load cached) flagged clean table.""" | |
| 142 | + if CLEAN_PARQUET.exists() and not force: | |
| 143 | + cached = pl.read_parquet(CLEAN_PARQUET) | |
| 144 | + if cached.height == tx.height: | |
| 145 | + return cached | |
| 146 | + df = add_flags(tx, geo) | |
| 147 | + PROCESSED_DIR.mkdir(parents=True, exist_ok=True) | |
| 148 | + df.write_parquet(CLEAN_PARQUET) | |
| 149 | + return df | |
added
engine/src/qwhpi/config.py
+100 −0
@@ -0,0 +1,100 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/config.py | |
| 6 | +# Purpose : Central configuration — paths, calendar, property types, constants. | |
| 7 | +# ============================================================================= | |
| 8 | +"""Central configuration for the QWHPI engine. | |
| 9 | + | |
| 10 | +All paths are resolved relative to the repository root so every script and | |
| 11 | +module agrees on where data lives, regardless of the working directory. | |
| 12 | +""" | |
| 13 | + | |
| 14 | +from __future__ import annotations | |
| 15 | + | |
| 16 | +from dataclasses import dataclass, field | |
| 17 | +from pathlib import Path | |
| 18 | + | |
| 19 | +# --------------------------------------------------------------------------- # | |
| 20 | +# Paths | |
| 21 | +# --------------------------------------------------------------------------- # | |
| 22 | + | |
| 23 | +REPO_ROOT = Path(__file__).resolve().parents[3] | |
| 24 | + | |
| 25 | +DATA_DIR = REPO_ROOT / "data" | |
| 26 | +RAW_DIR = DATA_DIR / "raw" | |
| 27 | +EXTERNAL_DIR = DATA_DIR / "external" | |
| 28 | +INTERIM_DIR = DATA_DIR / "interim" | |
| 29 | +PROCESSED_DIR = DATA_DIR / "processed" | |
| 30 | + | |
| 31 | +OUTPUTS_DIR = REPO_ROOT / "outputs" | |
| 32 | +FIGURES_DIR = OUTPUTS_DIR / "figures" | |
| 33 | +TABLES_DIR = OUTPUTS_DIR / "tables" | |
| 34 | +MAPS_DIR = OUTPUTS_DIR / "maps" | |
| 35 | +REPORTS_DIR = OUTPUTS_DIR / "reports" | |
| 36 | + | |
| 37 | +RAW_TRANSACTIONS_CSV = RAW_DIR / "province_transactions.csv" | |
| 38 | + | |
| 39 | +# --------------------------------------------------------------------------- # | |
| 40 | +# Weekly calendar | |
| 41 | +# --------------------------------------------------------------------------- # | |
| 42 | +# Weeks run Monday -> Sunday and are labeled by their Monday. The grid is | |
| 43 | +# continuous from FIRST_WEEK to the latest observed week; zero-transaction | |
| 44 | +# weeks are kept and flagged, never dropped. | |
| 45 | + | |
| 46 | +FIRST_WEEK = "2021-01-04" # first Monday of the sample | |
| 47 | + | |
| 48 | +# --------------------------------------------------------------------------- # | |
| 49 | +# Property types | |
| 50 | +# --------------------------------------------------------------------------- # | |
| 51 | +# `indeterminé` must never contaminate type-specific indexes; it is studied | |
| 52 | +# separately and documented in an appendix. | |
| 53 | + | |
| 54 | +PROPERTY_TYPES = ("unifamilial", "condo", "plex") | |
| 55 | +PROPERTY_TYPE_UNDETERMINED = "indéterminé" | |
| 56 | +PROPERTY_TYPE_ALL = "all" # aggregate over the three determined types | |
| 57 | + | |
| 58 | +# --------------------------------------------------------------------------- # | |
| 59 | +# Index conventions | |
| 60 | +# --------------------------------------------------------------------------- # | |
| 61 | + | |
| 62 | +BASE_PERIOD_LABEL = "2021 average = 100" | |
| 63 | +BASE_YEAR = 2021 | |
| 64 | + | |
| 65 | +# Liquidity-tier starting hypotheses (to be validated by downsampling, §4.7). | |
| 66 | +LIQUIDITY_TIERS = { | |
| 67 | + "very_strong": 50, # >= 50 tx/week | |
| 68 | + "strong": 20, # 20-49 | |
| 69 | + "shrinkage": 10, # 10-19 | |
| 70 | + "heavy_shrinkage": 5, # 5-9; below 5 -> model-implied | |
| 71 | +} | |
| 72 | + | |
| 73 | +# QWHPI-CITY target municipalities (published as liquidity permits). | |
| 74 | +CITY_TARGETS = ( | |
| 75 | + "Montréal", "Québec", "Laval", "Gatineau", "Longueuil", | |
| 76 | + "Sherbrooke", "Trois-Rivières", "Saguenay", "Lévis", "Drummondville", | |
| 77 | +) | |
| 78 | + | |
| 79 | +AUTHOR = "Simon-Pierre Boucher" | |
| 80 | +CONTACT = "contact@spboucher.ai" | |
| 81 | + | |
| 82 | + | |
| 83 | +@dataclass(frozen=True) | |
| 84 | +class EngineConfig: | |
| 85 | + """Runtime configuration for a pipeline execution.""" | |
| 86 | + | |
| 87 | + raw_csv: Path = RAW_TRANSACTIONS_CSV | |
| 88 | + interim_dir: Path = INTERIM_DIR | |
| 89 | + processed_dir: Path = PROCESSED_DIR | |
| 90 | + first_week: str = FIRST_WEEK | |
| 91 | + base_year: int = BASE_YEAR | |
| 92 | + property_types: tuple[str, ...] = PROPERTY_TYPES | |
| 93 | + model_version: str = field(default="0.1.0") | |
| 94 | + | |
| 95 | + | |
| 96 | +def ensure_dirs() -> None: | |
| 97 | + """Create all output/data directories that the pipeline writes into.""" | |
| 98 | + for d in (INTERIM_DIR, PROCESSED_DIR, EXTERNAL_DIR, | |
| 99 | + FIGURES_DIR, TABLES_DIR, MAPS_DIR, REPORTS_DIR): | |
| 100 | + d.mkdir(parents=True, exist_ok=True) | |
added
engine/src/qwhpi/export.py
+128 −0
@@ -0,0 +1,128 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/export.py | |
| 6 | +# Purpose : Load each pipeline run into PostgreSQL (upsert, vintage-stamped). | |
| 7 | +# ============================================================================= | |
| 8 | +"""Database loader. | |
| 9 | + | |
| 10 | +Upserts the canonical parquet outputs into the relational store defined by | |
| 11 | +``db/schema.sql``. Connection comes from ``DATABASE_URL`` | |
| 12 | +(postgresql://...); when unset, the loader is a documented no-op — the API | |
| 13 | +falls back to reading the Parquet lake directly (dev mode). | |
| 14 | +""" | |
| 15 | + | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import hashlib | |
| 19 | +import os | |
| 20 | + | |
| 21 | +import polars as pl | |
| 22 | + | |
| 23 | +from qwhpi.config import PROCESSED_DIR, RAW_TRANSACTIONS_CSV, REPO_ROOT | |
| 24 | + | |
| 25 | + | |
| 26 | +def input_hash() -> str: | |
| 27 | + h = hashlib.sha256() | |
| 28 | + with open(RAW_TRANSACTIONS_CSV, "rb") as f: | |
| 29 | + while chunk := f.read(1 << 22): | |
| 30 | + h.update(chunk) | |
| 31 | + return h.hexdigest()[:16] | |
| 32 | + | |
| 33 | + | |
| 34 | +def load_database() -> bool: | |
| 35 | + """Upsert the current canonical outputs into Postgres. Returns True if loaded.""" | |
| 36 | + url = os.environ.get("DATABASE_URL") | |
| 37 | + if not url: | |
| 38 | + print("DATABASE_URL not set — skipping DB load (API serves the Parquet lake).") | |
| 39 | + return False | |
| 40 | + import psycopg | |
| 41 | + | |
| 42 | + canon = pl.read_parquet(PROCESSED_DIR / "qwhpi_weekly.parquet") | |
| 43 | + liq = pl.read_parquet(PROCESSED_DIR / "weekly_liquidity.parquet") | |
| 44 | + fr = pl.read_parquet(PROCESSED_DIR / "first_release.parquet") | |
| 45 | + | |
| 46 | + with psycopg.connect(url) as conn, conn.cursor() as cur: | |
| 47 | + cur.execute((REPO_ROOT / "db" / "schema.sql").read_text()) | |
| 48 | + | |
| 49 | + geos = canon.select("geography_id", "geography_name", "geography_level").unique() | |
| 50 | + parent = {"municipality": None, "region": "quebec", "province": None} | |
| 51 | + for r in geos.sort(pl.col("geography_level").replace_strict( | |
| 52 | + {"province": 0, "region": 1, "municipality": 2}, default=3)) \ | |
| 53 | + .iter_rows(named=True): | |
| 54 | + cur.execute( | |
| 55 | + """INSERT INTO geographies VALUES (%s,%s,%s,%s) | |
| 56 | + ON CONFLICT (geography_id) DO UPDATE SET geography_name=EXCLUDED.geography_name""", | |
| 57 | + (r["geography_id"], r["geography_name"], r["geography_level"], | |
| 58 | + parent.get(r["geography_level"])), | |
| 59 | + ) | |
| 60 | + series = canon.select("geography_id", "property_type").unique() | |
| 61 | + for r in series.iter_rows(named=True): | |
| 62 | + sid = f"{r['geography_id']}:{r['property_type']}" | |
| 63 | + cur.execute( | |
| 64 | + """INSERT INTO series (series_id, geography_id, property_type) | |
| 65 | + VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""", | |
| 66 | + (sid, r["geography_id"], r["property_type"]), | |
| 67 | + ) | |
| 68 | + cur.execute( | |
| 69 | + """INSERT INTO model_runs (model_version, data_vintage, input_row_count, input_hash) | |
| 70 | + VALUES (%s,%s,%s,%s) ON CONFLICT DO NOTHING""", | |
| 71 | + (canon["model_version"][0], canon["data_vintage"][0], | |
| 72 | + canon.height, input_hash()), | |
| 73 | + ) | |
| 74 | + | |
| 75 | + obs_cols = [c for c in canon.columns | |
| 76 | + if c not in ("geography_name", "geography_level")] | |
| 77 | + insert_obs = ( | |
| 78 | + "INSERT INTO observations (series_id, week, index, index_smoothed, " | |
| 79 | + "representative_value, transactions, effective_sample_size, weekly_pct, " | |
| 80 | + "four_week_pct, thirteen_week_pct, twenty_six_week_pct, yoy_pct, lower_95, " | |
| 81 | + "upper_95, reliability_grade, shrinkage_weight, is_partial_week, " | |
| 82 | + "model_version, data_vintage) VALUES (" + ",".join(["%s"] * 19) + ") " | |
| 83 | + "ON CONFLICT (series_id, week) DO UPDATE SET " | |
| 84 | + "index=EXCLUDED.index, index_smoothed=EXCLUDED.index_smoothed, " | |
| 85 | + "representative_value=EXCLUDED.representative_value, " | |
| 86 | + "transactions=EXCLUDED.transactions, " | |
| 87 | + "effective_sample_size=EXCLUDED.effective_sample_size, " | |
| 88 | + "weekly_pct=EXCLUDED.weekly_pct, four_week_pct=EXCLUDED.four_week_pct, " | |
| 89 | + "thirteen_week_pct=EXCLUDED.thirteen_week_pct, " | |
| 90 | + "twenty_six_week_pct=EXCLUDED.twenty_six_week_pct, yoy_pct=EXCLUDED.yoy_pct, " | |
| 91 | + "lower_95=EXCLUDED.lower_95, upper_95=EXCLUDED.upper_95, " | |
| 92 | + "reliability_grade=EXCLUDED.reliability_grade, " | |
| 93 | + "shrinkage_weight=EXCLUDED.shrinkage_weight, " | |
| 94 | + "is_partial_week=EXCLUDED.is_partial_week, " | |
| 95 | + "model_version=EXCLUDED.model_version, data_vintage=EXCLUDED.data_vintage" | |
| 96 | + ) | |
| 97 | + rows = [ | |
| 98 | + (f"{r['geography_id']}:{r['property_type']}", r["week"], r["index"], | |
| 99 | + r["index_smoothed"], r["representative_value"], r["transactions"], | |
| 100 | + r["effective_sample_size"], r["weekly_pct"], r["four_week_pct"], | |
| 101 | + r["thirteen_week_pct"], r["twenty_six_week_pct"], r["yoy_pct"], | |
| 102 | + r["lower_95"], r["upper_95"], r["reliability_grade"], | |
| 103 | + r["shrinkage_weight"], r["is_partial_week"], r["model_version"], | |
| 104 | + r["data_vintage"]) | |
| 105 | + for r in canon.iter_rows(named=True) | |
| 106 | + ] | |
| 107 | + cur.executemany(insert_obs, rows) | |
| 108 | + | |
| 109 | + cur.executemany( | |
| 110 | + """INSERT INTO liquidity VALUES (%s,%s,%s) | |
| 111 | + ON CONFLICT (series_id, week) DO UPDATE SET transactions=EXCLUDED.transactions""", | |
| 112 | + [(f"{r['geography_id']}:{r['property_type']}", r["week"], r["transactions"]) | |
| 113 | + for r in liq.iter_rows(named=True)], | |
| 114 | + ) | |
| 115 | + cur.executemany( | |
| 116 | + """INSERT INTO vintages VALUES (%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""", | |
| 117 | + [(f"{r['geography_id']}:{r['property_type']}", r["week"], | |
| 118 | + r["first_release_index"], r["first_release_index_smoothed"], | |
| 119 | + r["first_release_vintage"]) | |
| 120 | + for r in fr.iter_rows(named=True)], | |
| 121 | + ) | |
| 122 | + conn.commit() | |
| 123 | + print(f"DB load complete: {canon.height:,} observations upserted.") | |
| 124 | + return True | |
| 125 | + | |
| 126 | + | |
| 127 | +if __name__ == "__main__": | |
| 128 | + load_database() | |
added
engine/src/qwhpi/features.py
+103 −0
@@ -0,0 +1,103 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/features.py | |
| 6 | +# Purpose : Hedonic feature engineering — age bins, log floor area with | |
| 7 | +# explicit missingness handling, building type, location controls. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Feature engineering for hedonic estimation (Model A — structural only). | |
| 10 | + | |
| 11 | +Missingness is handled explicitly (CLAUDE.md §4.4): conditional-median | |
| 12 | +imputation within (propertyType × municipality), falling back to | |
| 13 | +(propertyType × region) then propertyType. No future-price information | |
| 14 | +enters any imputation. | |
| 15 | + | |
| 16 | +TIME-CORRELATED MISSINGNESS RULE (v2, discovered 2026-08): a variable whose | |
| 17 | +missingness moves over time cannot enter a time-dummy model — the "missing" | |
| 18 | +category coefficient turns provider backfills into artificial index steps. | |
| 19 | +Audit findings on this dataset: | |
| 20 | +- ``buildingType`` was backfilled in region-staggered waves (most regions | |
| 21 | + 2022-01, Montréal 2023-01, Outaouais complete from the start). It caused a | |
| 22 | + −38 log-point artificial cliff in the Montréal single-family index and is | |
| 23 | + therefore EXCLUDED from all hedonic models. | |
| 24 | +- ``yearBuilt`` missingness drifts 15% → 2% over 2021–2023, so age gets | |
| 25 | + conditional-median imputation instead of a "missing" bin (the imputation | |
| 26 | + is time-invariant; a residual ``yb_missing`` flag is kept for diagnostics | |
| 27 | + only, never as a regressor). | |
| 28 | +- ``floorArea`` missingness is time-stable (1.6–2.4% every month), so its | |
| 29 | + indicator ``fa_missing`` is harmless and kept in the model. | |
| 30 | +""" | |
| 31 | + | |
| 32 | +from __future__ import annotations | |
| 33 | + | |
| 34 | +import polars as pl | |
| 35 | + | |
| 36 | +AGE_BIN_EDGES = [1, 5, 10, 20, 40, 60, 80, 120] | |
| 37 | +AGE_BIN_LABELS = [ | |
| 38 | + "00_new_0-1", "01_2-5", "02_6-10", "03_11-20", "04_21-40", | |
| 39 | + "05_41-60", "06_61-80", "07_81-120", "08_120plus", | |
| 40 | +] | |
| 41 | + | |
| 42 | + | |
| 43 | +def _age_bin_expr(col: str = "age_filled") -> pl.Expr: | |
| 44 | + age = pl.col(col) | |
| 45 | + expr = pl.when(age.is_null()).then(pl.lit(AGE_BIN_LABELS[4])) # defensive | |
| 46 | + lo = None | |
| 47 | + for edge, label in zip(AGE_BIN_EDGES, AGE_BIN_LABELS[:-1]): | |
| 48 | + cond = age <= edge if lo is None else (age > lo) & (age <= edge) | |
| 49 | + expr = expr.when(cond).then(pl.lit(label)) | |
| 50 | + lo = edge | |
| 51 | + return expr.otherwise(pl.lit(AGE_BIN_LABELS[-1])).alias("age_bin") | |
| 52 | + | |
| 53 | + | |
| 54 | +def build_features(df: pl.DataFrame) -> pl.DataFrame: | |
| 55 | + """Attach hedonic features. Expects the clean table (with geography).""" | |
| 56 | + out = df.with_columns( | |
| 57 | + (pl.col("date").dt.year() - pl.col("yearBuilt")).clip(0, None).alias("age"), | |
| 58 | + pl.col("week").dt.strftime("%Y-%m-%d").alias("week_str"), | |
| 59 | + pl.col("date").dt.strftime("%Y-%m").alias("month"), | |
| 60 | + # Forward-sortation area (first 3 chars of postal code) as the fine | |
| 61 | + # location control; falls back to municipality when missing. | |
| 62 | + pl.col("zipCode").str.slice(0, 3).str.to_uppercase().alias("fsa"), | |
| 63 | + ).with_columns( | |
| 64 | + # buildingType intentionally NOT a model feature (see module docstring) | |
| 65 | + pl.col("buildingType").fill_null("missing").alias("building_type"), | |
| 66 | + pl.when(pl.col("fsa").is_null()) | |
| 67 | + .then(pl.concat_str([pl.lit("MUN_"), pl.col("geo_code")])) | |
| 68 | + .otherwise(pl.col("fsa")) | |
| 69 | + .alias("loc_fine"), | |
| 70 | + pl.col("floorArea").is_null().alias("fa_missing"), | |
| 71 | + pl.col("yearBuilt").is_null().alias("yb_missing"), # diagnostics only | |
| 72 | + ) | |
| 73 | + | |
| 74 | + # Conditional-median imputation (no temporal information) for floorArea | |
| 75 | + # and age — the imputation cells are time-invariant, so backfilled fields | |
| 76 | + # cannot create index steps. | |
| 77 | + def impute(col: str, filled: str) -> pl.DataFrame: | |
| 78 | + nonlocal out | |
| 79 | + med_mun = out.group_by(["propertyType", "geo_code"]).agg( | |
| 80 | + pl.col(col).median().alias("_m_mun")) | |
| 81 | + med_reg = out.group_by(["propertyType", "region_code"]).agg( | |
| 82 | + pl.col(col).median().alias("_m_reg")) | |
| 83 | + med_typ = out.group_by("propertyType").agg( | |
| 84 | + pl.col(col).median().alias("_m_typ")) | |
| 85 | + out = ( | |
| 86 | + out.join(med_mun, on=["propertyType", "geo_code"], how="left") | |
| 87 | + .join(med_reg, on=["propertyType", "region_code"], how="left") | |
| 88 | + .join(med_typ, on="propertyType", how="left") | |
| 89 | + .with_columns( | |
| 90 | + pl.coalesce(pl.col(col), pl.col("_m_mun"), | |
| 91 | + pl.col("_m_reg"), pl.col("_m_typ")).alias(filled) | |
| 92 | + ) | |
| 93 | + .drop("_m_mun", "_m_reg", "_m_typ") | |
| 94 | + ) | |
| 95 | + return out | |
| 96 | + | |
| 97 | + impute("floorArea", "floor_area_filled") | |
| 98 | + impute("age", "age_filled") | |
| 99 | + out = out.with_columns( | |
| 100 | + pl.col("floor_area_filled").log().alias("log_fa"), | |
| 101 | + _age_bin_expr("age_filled"), | |
| 102 | + ) | |
| 103 | + return out | |
added
engine/src/qwhpi/geography.py
+130 −0
@@ -0,0 +1,130 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/geography.py | |
| 6 | +# Purpose : Spatial join of transaction lat/lng against official Quebec | |
| 7 | +# administrative boundaries (SDA 1/20k) — municipality, MRC, region. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Geography layer. | |
| 10 | + | |
| 11 | +Authoritative boundary source (documented per CLAUDE.md §4.2): | |
| 12 | + | |
| 13 | +- Dataset : Découpages administratifs 1/20 000 (SDA), Gouvernement du Québec, | |
| 14 | + Ministère des Ressources naturelles et des Forêts (MRNF). | |
| 15 | +- URL : https://www.donneesquebec.ca/recherche/dataset/decoupages-administratifs | |
| 16 | + (GPKG: https://diffusion.mern.gouv.qc.ca/diffusion/RGQ/Vectoriel/ | |
| 17 | + Theme/Local/SDA_20k/GPKG/SDA.gpkg.zip) | |
| 18 | +- Version : V2026-07 (attribute ``MUS_CO_VER``) | |
| 19 | +- CRS : EPSG:4269 (NAD83 geographic); metric ops use EPSG:32198 | |
| 20 | + (NAD83 / Quebec Lambert). | |
| 21 | + | |
| 22 | +The join is computed once and persisted to | |
| 23 | +``data/processed/geo_join.parquet`` keyed by transaction ``id``. The raw | |
| 24 | +``city`` text field is used only for validation, never as the geography. | |
| 25 | +""" | |
| 26 | + | |
| 27 | +from __future__ import annotations | |
| 28 | + | |
| 29 | +import geopandas as gpd | |
| 30 | +import numpy as np | |
| 31 | +import pandas as pd | |
| 32 | +import polars as pl | |
| 33 | + | |
| 34 | +from qwhpi.config import EXTERNAL_DIR, PROCESSED_DIR | |
| 35 | + | |
| 36 | +SDA_GPKG = EXTERNAL_DIR / "SDA.gpkg" | |
| 37 | +SDA_VERSION = "V2026-07" | |
| 38 | +CRS_GEO = "EPSG:4269" # NAD83 geographic — native CRS of the SDA layers | |
| 39 | +CRS_METRIC = "EPSG:32198" # NAD83 / Quebec Lambert — metric fallback joins | |
| 40 | + | |
| 41 | +GEO_JOIN_PARQUET = PROCESSED_DIR / "geo_join.parquet" | |
| 42 | +MUNICIPALITIES_PARQUET = PROCESSED_DIR / "municipalities.parquet" | |
| 43 | + | |
| 44 | +MUNIC_COLS = { | |
| 45 | + "MUS_CO_GEO": "geo_code", | |
| 46 | + "MUS_NM_MUN": "municipality", | |
| 47 | + "MUS_CO_DES": "munic_type", | |
| 48 | + "MUS_CO_MRC": "mrc_code", | |
| 49 | + "MUS_NM_MRC": "mrc", | |
| 50 | + "MUS_CO_REG": "region_code", | |
| 51 | + "MUS_NM_REG": "region", | |
| 52 | +} | |
| 53 | + | |
| 54 | +# Fallback tolerance for points that fall just outside every polygon | |
| 55 | +# (coastline/river digitization slack). Beyond this the point is unassigned. | |
| 56 | +NEAREST_MAX_METERS = 1000.0 | |
| 57 | + | |
| 58 | + | |
| 59 | +def load_municipal_layer() -> gpd.GeoDataFrame: | |
| 60 | + """Load the SDA municipal polygons with harmonized column names.""" | |
| 61 | + gdf = gpd.read_file(SDA_GPKG, layer="munic_s", columns=list(MUNIC_COLS)) | |
| 62 | + gdf = gdf.rename(columns=MUNIC_COLS) | |
| 63 | + # Drop Z dimension noise; geometry validity guard. | |
| 64 | + gdf["geometry"] = gdf.geometry.force_2d() | |
| 65 | + bad = ~gdf.geometry.is_valid | |
| 66 | + if bad.any(): | |
| 67 | + gdf.loc[bad, "geometry"] = gdf.loc[bad, "geometry"].buffer(0) | |
| 68 | + return gdf | |
| 69 | + | |
| 70 | + | |
| 71 | +def spatial_join(tx: pl.DataFrame) -> pl.DataFrame: | |
| 72 | + """Join each transaction point to a municipality (and thus MRC + region). | |
| 73 | + | |
| 74 | + Returns one row per transaction id with geography attributes and a | |
| 75 | + ``join_method`` in {"within", "nearest<=1km", "unassigned"}. | |
| 76 | + """ | |
| 77 | + munic = load_municipal_layer() | |
| 78 | + | |
| 79 | + pts_df = tx.select("id", "lat", "lng").to_pandas() | |
| 80 | + pts = gpd.GeoDataFrame( | |
| 81 | + pts_df[["id"]], | |
| 82 | + geometry=gpd.points_from_xy(pts_df["lng"], pts_df["lat"], crs="EPSG:4326"), | |
| 83 | + ).to_crs(CRS_GEO) | |
| 84 | + | |
| 85 | + joined = gpd.sjoin(pts, munic, how="left", predicate="within") | |
| 86 | + # A point on a shared border can match twice — keep the first match. | |
| 87 | + joined = joined[~joined.index.duplicated(keep="first")] | |
| 88 | + joined["join_method"] = np.where(joined["geo_code"].notna(), "within", None) | |
| 89 | + | |
| 90 | + # Nearest-polygon fallback for the residual, capped at NEAREST_MAX_METERS. | |
| 91 | + missing_mask = joined["geo_code"].isna() | |
| 92 | + if missing_mask.any(): | |
| 93 | + rest = joined.loc[missing_mask, ["id", "geometry"]].to_crs(CRS_METRIC) | |
| 94 | + near = gpd.sjoin_nearest( | |
| 95 | + rest, munic.to_crs(CRS_METRIC), how="left", | |
| 96 | + max_distance=NEAREST_MAX_METERS, distance_col="join_dist_m", | |
| 97 | + ) | |
| 98 | + near = near[~near.index.duplicated(keep="first")] | |
| 99 | + for col in MUNIC_COLS.values(): | |
| 100 | + joined.loc[missing_mask, col] = near[col] | |
| 101 | + joined.loc[missing_mask, "join_method"] = np.where( | |
| 102 | + near["geo_code"].notna(), "nearest<=1km", "unassigned" | |
| 103 | + ) | |
| 104 | + | |
| 105 | + out = pd.DataFrame(joined.drop(columns=["geometry"])) | |
| 106 | + keep = ["id", *MUNIC_COLS.values(), "join_method"] | |
| 107 | + return pl.from_pandas(out[keep]) | |
| 108 | + | |
| 109 | + | |
| 110 | +def build_geo_join(tx: pl.DataFrame, force: bool = False) -> pl.DataFrame: | |
| 111 | + """Compute (or load the cached) transaction -> geography join.""" | |
| 112 | + if GEO_JOIN_PARQUET.exists() and not force: | |
| 113 | + cached = pl.read_parquet(GEO_JOIN_PARQUET) | |
| 114 | + if cached.height == tx.height: | |
| 115 | + return cached | |
| 116 | + result = spatial_join(tx) | |
| 117 | + PROCESSED_DIR.mkdir(parents=True, exist_ok=True) | |
| 118 | + result.write_parquet(GEO_JOIN_PARQUET) | |
| 119 | + | |
| 120 | + # Slim municipality lookup for the API / dashboard. | |
| 121 | + lookup = ( | |
| 122 | + result.filter(pl.col("geo_code").is_not_null()) | |
| 123 | + .group_by(["geo_code", "municipality", "munic_type", "mrc_code", "mrc", | |
| 124 | + "region_code", "region"]) | |
| 125 | + .len() | |
| 126 | + .rename({"len": "n_transactions"}) | |
| 127 | + .sort("n_transactions", descending=True) | |
| 128 | + ) | |
| 129 | + lookup.write_parquet(MUNICIPALITIES_PARQUET) | |
| 130 | + return result | |
added
engine/src/qwhpi/hedonic.py
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/hedonic.py | |
| 6 | +# Purpose : Pooled hedonic time-dummy estimation (Model A) with absorbed | |
| 7 | +# location fixed effects; week-coefficient extraction and rebasing. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Pooled hedonic time-dummy estimation. | |
| 10 | + | |
| 11 | +Model A (headline, structural characteristics only): | |
| 12 | + | |
| 13 | + log(amount) = Σ_t δ_t · 1[week = t] + β·log(floorArea) + fa_missing | |
| 14 | + + age bins + building type (+ property type when pooled) | |
| 15 | + + location FE (absorbed) + ε | |
| 16 | + | |
| 17 | +Estimated ONCE over the pooled 2021→present sample per published cell — | |
| 18 | +never as independent weekly regressions. Week coefficients δ_t form the | |
| 19 | +latent log index; the published index is exp(δ_t) rebased so the 2021 | |
| 20 | +average equals 100. SEs are heteroskedasticity-robust (HC1); block-bootstrap | |
| 21 | +CIs come from uncertainty.py at the hierarchical stage. | |
| 22 | +""" | |
| 23 | + | |
| 24 | +from __future__ import annotations | |
| 25 | + | |
| 26 | +from dataclasses import dataclass | |
| 27 | + | |
| 28 | +import numpy as np | |
| 29 | +import pandas as pd | |
| 30 | +import polars as pl | |
| 31 | +import pyfixest as pf | |
| 32 | + | |
| 33 | +from qwhpi.config import BASE_YEAR | |
| 34 | + | |
| 35 | +STRUCTURAL_RHS = "log_fa + fa_missing + C(age_bin)" # buildingType excluded: time-correlated missingness (features.py) | |
| 36 | + | |
| 37 | + | |
| 38 | +@dataclass | |
| 39 | +class TimeDummyResult: | |
| 40 | + """Weekly index extracted from a pooled time-dummy regression.""" | |
| 41 | + | |
| 42 | + table: pl.DataFrame # week, log_index, se, index, lower_95, upper_95 | |
| 43 | + n_obs: int | |
| 44 | + r2: float | |
| 45 | + | |
| 46 | + | |
| 47 | +def _rebase(weeks: np.ndarray, delta: np.ndarray, se: np.ndarray) -> pl.DataFrame: | |
| 48 | + """Rebase week log-effects so the BASE_YEAR average is 100.""" | |
| 49 | + years = np.array([w[:4] for w in weeks]) | |
| 50 | + base_mask = years == str(BASE_YEAR) | |
| 51 | + base_mean = delta[base_mask].mean() | |
| 52 | + log_index = delta - base_mean | |
| 53 | + index = np.exp(log_index) * 100.0 | |
| 54 | + return pl.DataFrame({ | |
| 55 | + "week": weeks, | |
| 56 | + "log_index": log_index, | |
| 57 | + "se": se, | |
| 58 | + "index": index, | |
| 59 | + "lower_95": np.exp(log_index - 1.96 * se) * 100.0, | |
| 60 | + "upper_95": np.exp(log_index + 1.96 * se) * 100.0, | |
| 61 | + }).sort("week") | |
| 62 | + | |
| 63 | + | |
| 64 | +def estimate_time_dummy( | |
| 65 | + df: pl.DataFrame, | |
| 66 | + absorb: str = "loc_fine", | |
| 67 | + include_property_type: bool = False, | |
| 68 | + min_obs: int = 500, | |
| 69 | +) -> TimeDummyResult | None: | |
| 70 | + """Estimate the pooled time-dummy model on one publication cell. | |
| 71 | + | |
| 72 | + ``df`` must carry features from :func:`qwhpi.features.build_features`. | |
| 73 | + Returns None when the cell is too thin to estimate at all (handled by the | |
| 74 | + hierarchical model instead). | |
| 75 | + """ | |
| 76 | + if df.height < min_obs: | |
| 77 | + return None | |
| 78 | + | |
| 79 | + rhs = STRUCTURAL_RHS + (" + C(propertyType)" if include_property_type else "") | |
| 80 | + cols = ["log_amount", "week_str", "log_fa", "fa_missing", "age_bin", | |
| 81 | + "building_type", "propertyType", absorb] | |
| 82 | + pdf = df.select(cols).to_pandas() | |
| 83 | + pdf["fa_missing"] = pdf["fa_missing"].astype(int) | |
| 84 | + | |
| 85 | + # Reference week = first week present in the cell. | |
| 86 | + ref = sorted(pdf["week_str"].unique())[0] | |
| 87 | + fml = f"log_amount ~ i(week_str, ref='{ref}') + {rhs} | {absorb}" | |
| 88 | + fit = pf.feols(fml, data=pdf, vcov="HC1") | |
| 89 | + | |
| 90 | + coefs = fit.coef() | |
| 91 | + ses = fit.se() | |
| 92 | + weeks, deltas, sess = [ref], [0.0], [0.0] | |
| 93 | + prefix = "week_str::" | |
| 94 | + for name, val in coefs.items(): | |
| 95 | + if name.startswith(prefix): | |
| 96 | + weeks.append(name.removeprefix(prefix)) | |
| 97 | + deltas.append(float(val)) | |
| 98 | + sess.append(float(ses[name])) | |
| 99 | + | |
| 100 | + order = np.argsort(weeks) | |
| 101 | + table = _rebase(np.array(weeks)[order], | |
| 102 | + np.array(deltas)[order], | |
| 103 | + np.array(sess)[order]) | |
| 104 | + return TimeDummyResult(table=table, n_obs=fit._N, r2=float(fit._r2)) | |
added
engine/src/qwhpi/hierarchy.py
+228 −0
@@ -0,0 +1,228 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/hierarchy.py | |
| 6 | +# Purpose : Hierarchical weekly index — stage-1 pooled residualization, then | |
| 7 | +# Kalman-shrunk deviations (province → region → municipality). | |
| 8 | +# ============================================================================= | |
| 9 | +"""Hierarchical weekly state model (two-stage architecture). | |
| 10 | + | |
| 11 | +Stage 1 (pooled hedonic residualization) | |
| 12 | + log(P) = structural chars + loc FE + (week × type) FE + ε | |
| 13 | + - the (week × type) fixed effects ARE the province-level weekly paths | |
| 14 | + per property type (liquid: >=300 tx/week each); | |
| 15 | + - residuals ε are quality- and location-adjusted deviations of each | |
| 16 | + transaction from its type's province path. | |
| 17 | + | |
| 18 | +Stage 2 (hierarchical shrinkage) | |
| 19 | + For each cell below the province the weekly mean residual is smoothed by | |
| 20 | + a heteroskedastic local-level Kalman model (state_space.py): | |
| 21 | + region cell : deviation of region×type from the province path | |
| 22 | + municipality : deviation of municipality×type from the smoothed | |
| 23 | + region path (residual − region deviation) | |
| 24 | + Thin weeks carry high observation noise -> the filter shrinks toward the | |
| 25 | + parent path (gain ~ 0). Liquid weeks dominate their own estimate. | |
| 26 | + | |
| 27 | +Cell log index = province type path + region deviation (+ municipality | |
| 28 | +deviation), rebased to 2021 avg = 100. The "all" type per geography is a | |
| 29 | +fixed-weight (full-sample transaction share) combination of the three type | |
| 30 | +log-indexes, keeping composition constant. | |
| 31 | + | |
| 32 | +Published variants (§4.8): | |
| 33 | + index — raw weekly: path + *unsmoothed* weekly mean deviation | |
| 34 | + index_smoothed — one-sided filtered (real-time, no lookahead) | |
| 35 | +The two-sided smoothed path is kept for research as ``index_research``. | |
| 36 | +""" | |
| 37 | + | |
| 38 | +from __future__ import annotations | |
| 39 | + | |
| 40 | +from dataclasses import dataclass | |
| 41 | + | |
| 42 | +import numpy as np | |
| 43 | +import pandas as pd | |
| 44 | +import polars as pl | |
| 45 | +import pyfixest as pf | |
| 46 | + | |
| 47 | +from qwhpi.config import BASE_YEAR | |
| 48 | +from qwhpi.state_space import LocalLevelFit, fit_local_level | |
| 49 | + | |
| 50 | +STRUCTURAL_RHS = "log_fa + fa_missing + C(age_bin)" # buildingType excluded: time-correlated missingness (features.py) | |
| 51 | +MIN_SIGMA2_OBS = 50 # min cell size to estimate its own residual variance | |
| 52 | + | |
| 53 | + | |
| 54 | +# --------------------------------------------------------------------------- # | |
| 55 | +# Stage 1 | |
| 56 | +# --------------------------------------------------------------------------- # | |
| 57 | + | |
| 58 | +@dataclass | |
| 59 | +class Stage1Result: | |
| 60 | + """Province paths + aligned residuals from the pooled regression.""" | |
| 61 | + | |
| 62 | + paths: pl.DataFrame # week, property_type, delta (log path, unnormalized) | |
| 63 | + residuals: pl.DataFrame # id, week_str, propertyType, region_code, | |
| 64 | + # geo_code, municipality, resid | |
| 65 | + n_obs: int | |
| 66 | + r2: float | |
| 67 | + | |
| 68 | + | |
| 69 | +def stage1_residualize(feat: pl.DataFrame) -> Stage1Result: | |
| 70 | + """Pooled regression over the full research sample (all three types).""" | |
| 71 | + cols = ["id", "log_amount", "week_str", "propertyType", "log_fa", | |
| 72 | + "fa_missing", "age_bin", "building_type", "loc_fine", | |
| 73 | + "region_code", "geo_code", "municipality"] | |
| 74 | + pdf = feat.select(cols).to_pandas() | |
| 75 | + pdf["fa_missing"] = pdf["fa_missing"].astype(int) | |
| 76 | + pdf["wk_type"] = pdf["week_str"] + "|" + pdf["propertyType"] | |
| 77 | + | |
| 78 | + fit = pf.feols( | |
| 79 | + f"log_amount ~ {STRUCTURAL_RHS} | loc_fine + wk_type", | |
| 80 | + data=pdf, vcov="HC1", | |
| 81 | + ) | |
| 82 | + fx = fit.fixef() | |
| 83 | + wk_key = next(k for k in fx if "wk_type" in k) | |
| 84 | + wk_fx = fx[wk_key] | |
| 85 | + paths = pl.DataFrame({ | |
| 86 | + "week": [k.split("|")[0] for k in wk_fx], | |
| 87 | + "property_type": [k.split("|")[1] for k in wk_fx], | |
| 88 | + "delta": [float(v) for v in wk_fx.values()], | |
| 89 | + }).sort(["property_type", "week"]) | |
| 90 | + | |
| 91 | + yhat = fit.predict(newdata=pdf) | |
| 92 | + pdf["resid"] = pdf["log_amount"].values - np.asarray(yhat, dtype=float) | |
| 93 | + resid = pl.from_pandas( | |
| 94 | + pdf[["id", "week_str", "propertyType", "region_code", "geo_code", | |
| 95 | + "municipality", "resid"]] | |
| 96 | + ).filter(pl.col("resid").is_not_nan() & pl.col("resid").is_not_null()) | |
| 97 | + return Stage1Result(paths=paths, residuals=resid, | |
| 98 | + n_obs=fit._N, r2=float(fit._r2)) | |
| 99 | + | |
| 100 | + | |
| 101 | +# --------------------------------------------------------------------------- # | |
| 102 | +# Stage 2 helpers | |
| 103 | +# --------------------------------------------------------------------------- # | |
| 104 | + | |
| 105 | +def week_grid(residuals: pl.DataFrame) -> list[str]: | |
| 106 | + """Continuous Monday grid over the sample, as ISO strings.""" | |
| 107 | + import datetime as dt | |
| 108 | + weeks = sorted(residuals["week_str"].unique().to_list()) | |
| 109 | + first = dt.date.fromisoformat(weeks[0]) | |
| 110 | + last = dt.date.fromisoformat(weeks[-1]) | |
| 111 | + grid = [] | |
| 112 | + cur = first | |
| 113 | + while cur <= last: | |
| 114 | + grid.append(cur.isoformat()) | |
| 115 | + cur += dt.timedelta(days=7) | |
| 116 | + return grid | |
| 117 | + | |
| 118 | + | |
| 119 | +@dataclass | |
| 120 | +class CellDeviation: | |
| 121 | + """Smoothed weekly deviation of one cell from its parent path.""" | |
| 122 | + | |
| 123 | + weeks: list[str] | |
| 124 | + raw: np.ndarray # unsmoothed weekly mean deviation (NaN if n=0) | |
| 125 | + n: np.ndarray | |
| 126 | + fit: LocalLevelFit | |
| 127 | + | |
| 128 | + | |
| 129 | +def cell_deviation( | |
| 130 | + dev_frame: pl.DataFrame, | |
| 131 | + grid: list[str], | |
| 132 | + fallback_sigma2: float, | |
| 133 | +) -> CellDeviation: | |
| 134 | + """Weekly mean deviation + Kalman fit for one cell. | |
| 135 | + | |
| 136 | + ``dev_frame`` must have columns ``week_str`` and ``dev`` (per-transaction | |
| 137 | + deviation from the parent path). | |
| 138 | + """ | |
| 139 | + agg = ( | |
| 140 | + dev_frame.group_by("week_str") | |
| 141 | + .agg(pl.col("dev").mean().alias("y"), | |
| 142 | + pl.len().alias("n"), | |
| 143 | + pl.col("dev").var().alias("v")) | |
| 144 | + ) | |
| 145 | + lookup = {r["week_str"]: r for r in agg.iter_rows(named=True)} | |
| 146 | + y = np.array([lookup[w]["y"] if w in lookup else np.nan for w in grid]) | |
| 147 | + n = np.array([lookup[w]["n"] if w in lookup else 0 for w in grid], dtype=float) | |
| 148 | + | |
| 149 | + if dev_frame.height >= MIN_SIGMA2_OBS: | |
| 150 | + sigma2 = float(dev_frame["dev"].var()) | |
| 151 | + else: | |
| 152 | + sigma2 = fallback_sigma2 | |
| 153 | + fit = fit_local_level(y, n, sigma2) | |
| 154 | + return CellDeviation(weeks=grid, raw=y, n=n, fit=fit) | |
| 155 | + | |
| 156 | + | |
| 157 | +# --------------------------------------------------------------------------- # | |
| 158 | +# Index composition | |
| 159 | +# --------------------------------------------------------------------------- # | |
| 160 | + | |
| 161 | +def rebase_log(weeks: list[str], log_path: np.ndarray) -> np.ndarray: | |
| 162 | + """Shift a log path so the BASE_YEAR average is 0 (index 100).""" | |
| 163 | + return log_path - base_constant(weeks, log_path) | |
| 164 | + | |
| 165 | + | |
| 166 | +def base_constant(weeks: list[str], log_path: np.ndarray) -> float: | |
| 167 | + """Mean of the log path over BASE_YEAR (the rebasing constant).""" | |
| 168 | + mask = np.array([w.startswith(str(BASE_YEAR)) for w in weeks]) | |
| 169 | + valid = mask & np.isfinite(log_path) | |
| 170 | + return float(log_path[valid].mean()) | |
| 171 | + | |
| 172 | + | |
| 173 | +def compose_cell_table( | |
| 174 | + *, | |
| 175 | + grid: list[str], | |
| 176 | + province_path: np.ndarray, # aligned to grid (log, unnormalized) | |
| 177 | + deviations: list[CellDeviation], # region [+ municipality] | |
| 178 | + geography_id: str, | |
| 179 | + geography_name: str, | |
| 180 | + geography_level: str, | |
| 181 | + property_type: str, | |
| 182 | + path_var: np.ndarray, # variance of the province path per week | |
| 183 | +) -> pl.DataFrame: | |
| 184 | + """Assemble the published series for one cell.""" | |
| 185 | + dev_filtered = sum(d.fit.filtered for d in deviations) | |
| 186 | + dev_filtered_var = sum(d.fit.filtered_var for d in deviations) | |
| 187 | + dev_smoothed = sum(d.fit.smoothed for d in deviations) | |
| 188 | + dev_smoothed_var = sum(d.fit.smoothed_var for d in deviations) | |
| 189 | + # Raw deviation: last level's raw mean plus parents' smoothed paths. | |
| 190 | + if deviations: | |
| 191 | + raw_last = deviations[-1].raw | |
| 192 | + parents = sum(d.fit.smoothed for d in deviations[:-1]) if len(deviations) > 1 else 0.0 | |
| 193 | + dev_raw = parents + raw_last | |
| 194 | + n = deviations[-1].n | |
| 195 | + gain = deviations[-1].fit.gain | |
| 196 | + else: | |
| 197 | + dev_raw = np.zeros(len(grid)) | |
| 198 | + n = np.full(len(grid), np.nan) | |
| 199 | + gain = np.ones(len(grid)) | |
| 200 | + | |
| 201 | + # One shared normalization constant (from the always-finite filtered | |
| 202 | + # path) so raw / filtered / smoothed variants are directly comparable | |
| 203 | + # even when a thin cell has no raw observation in the base year. | |
| 204 | + base = base_constant(grid, province_path + dev_filtered) | |
| 205 | + log_raw = province_path + dev_raw - base | |
| 206 | + log_filt = province_path + dev_filtered - base | |
| 207 | + log_smooth = province_path + dev_smoothed - base | |
| 208 | + | |
| 209 | + var_filt = path_var + dev_filtered_var | |
| 210 | + se = np.sqrt(np.maximum(var_filt, 0.0)) | |
| 211 | + | |
| 212 | + return pl.DataFrame({ | |
| 213 | + "week": grid, | |
| 214 | + "geography_id": geography_id, | |
| 215 | + "geography_name": geography_name, | |
| 216 | + "geography_level": geography_level, | |
| 217 | + "property_type": property_type, | |
| 218 | + "index": np.exp(log_raw) * 100.0, | |
| 219 | + "index_smoothed": np.exp(log_filt) * 100.0, | |
| 220 | + "index_research": np.exp(log_smooth) * 100.0, | |
| 221 | + "log_index": log_filt, | |
| 222 | + "se_log": se, | |
| 223 | + "lower_95": np.exp(log_filt - 1.96 * se) * 100.0, | |
| 224 | + "upper_95": np.exp(log_filt + 1.96 * se) * 100.0, | |
| 225 | + "transactions": n, | |
| 226 | + "shrinkage_weight": 1.0 - gain, # share coming from the parent path | |
| 227 | + "research_var": path_var + dev_smoothed_var, | |
| 228 | + }) | |
added
engine/src/qwhpi/index.py
+139 −0
@@ -0,0 +1,139 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/index.py | |
| 6 | +# Purpose : Canonical dataset assembly — growth horizons, reliability grades, | |
| 7 | +# representative values, effective sample size, partial-week flag. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Assembly of the canonical weekly table (CLAUDE.md §4.8–4.9). | |
| 10 | + | |
| 11 | +Definitions | |
| 12 | +----------- | |
| 13 | +representative_value | |
| 14 | + Fixed-basket dollar value: B_cell · index_smoothed/100 where | |
| 15 | + B_cell = exp( mean_i[ log P_i − log(I_smoothed(week_i)/100) ] ) over the | |
| 16 | + cell's full research sample — i.e. the cell's quality-mix-constant | |
| 17 | + geometric mean price expressed at base-period (2021 avg) prices, carried | |
| 18 | + forward by the index. Documented in the methodology paper. | |
| 19 | + | |
| 20 | +effective_sample_size | |
| 21 | + Information content of the filtered estimate in transaction units: | |
| 22 | + σ²_cell / Var(filtered deviation). For province cells (no deviation | |
| 23 | + filter) it equals the weekly transaction count. | |
| 24 | + | |
| 25 | +reliability_grade (A–E) | |
| 26 | + Empirically anchored on the downsampling experiment | |
| 27 | + (outputs/tables/downsampling_results.csv): CI coverage and RMSE stay | |
| 28 | + strong down to ~25 tx/week and degrade below ~15. | |
| 29 | + A: median cell liquidity ≥ 50 tx/wk and se_log < 1.5% | |
| 30 | + B: ≥ 20 tx/wk and se_log < 2.5% | |
| 31 | + C: ≥ 10 tx/wk (documented shrinkage; CI approximately calibrated) | |
| 32 | + D: ≥ 5 tx/wk (heavy shrinkage — parent-dominated weeks) | |
| 33 | + E: < 5 tx/wk (model-implied; published for transparency only) | |
| 34 | + The grade is a per-cell base grade (median liquidity) downgraded one | |
| 35 | + notch in weeks where the local week is empty (transactions == 0). | |
| 36 | + | |
| 37 | +is_partial_week | |
| 38 | + Weeks (at the very end of the sample) whose province-wide volume is | |
| 39 | + below 60% of the trailing 8-week median — registration lag means the | |
| 40 | + latest week is a nowcast and will revise. | |
| 41 | +""" | |
| 42 | + | |
| 43 | +from __future__ import annotations | |
| 44 | + | |
| 45 | +import numpy as np | |
| 46 | +import polars as pl | |
| 47 | + | |
| 48 | +GROWTH_HORIZONS = { | |
| 49 | + "weekly_pct": 1, | |
| 50 | + "four_week_pct": 4, | |
| 51 | + "thirteen_week_pct": 13, | |
| 52 | + "twenty_six_week_pct": 26, | |
| 53 | + "yoy_pct": 52, | |
| 54 | +} | |
| 55 | + | |
| 56 | + | |
| 57 | +def add_growth(df: pl.DataFrame, col: str = "index_smoothed") -> pl.DataFrame: | |
| 58 | + """Attach growth-rate columns per series (sorted within cell).""" | |
| 59 | + keys = ["geography_id", "property_type"] | |
| 60 | + df = df.sort([*keys, "week"]) | |
| 61 | + exprs = [ | |
| 62 | + ((pl.col(col) / pl.col(col).shift(h).over(keys) - 1) * 100) | |
| 63 | + .round(3).alias(name) | |
| 64 | + for name, h in GROWTH_HORIZONS.items() | |
| 65 | + ] | |
| 66 | + return df.with_columns(exprs) | |
| 67 | + | |
| 68 | + | |
| 69 | +def grade_expr() -> pl.Expr: | |
| 70 | + """Reliability grade from cell median liquidity, SE, and week emptiness.""" | |
| 71 | + med = pl.col("cell_median_tx") | |
| 72 | + se = pl.col("se_log") | |
| 73 | + base = ( | |
| 74 | + pl.when((med >= 50) & (se < 0.015)).then(pl.lit("A")) | |
| 75 | + .when((med >= 20) & (se < 0.025)).then(pl.lit("B")) | |
| 76 | + .when(med >= 10).then(pl.lit("C")) | |
| 77 | + .when(med >= 5).then(pl.lit("D")) | |
| 78 | + .otherwise(pl.lit("E")) | |
| 79 | + ) | |
| 80 | + downgrade = {"A": "B", "B": "C", "C": "D", "D": "E", "E": "E"} | |
| 81 | + return ( | |
| 82 | + pl.when(pl.col("transactions").fill_nan(0) == 0) | |
| 83 | + .then(base.replace(downgrade)) | |
| 84 | + .otherwise(base) | |
| 85 | + .alias("reliability_grade") | |
| 86 | + ) | |
| 87 | + | |
| 88 | + | |
| 89 | +def add_reliability(df: pl.DataFrame) -> pl.DataFrame: | |
| 90 | + keys = ["geography_id", "property_type"] | |
| 91 | + med = df.group_by(keys).agg( | |
| 92 | + pl.col("transactions").fill_nan(None).median().alias("cell_median_tx")) | |
| 93 | + return df.join(med, on=keys, how="left").with_columns(grade_expr()) | |
| 94 | + | |
| 95 | + | |
| 96 | +def add_partial_week(df: pl.DataFrame, province_volume: pl.DataFrame) -> pl.DataFrame: | |
| 97 | + """Flag trailing weeks with abnormally low province-wide volume.""" | |
| 98 | + pv = province_volume.sort("week") | |
| 99 | + vols = pv["volume"].to_numpy().astype(float) | |
| 100 | + med8 = np.array([np.median(vols[max(0, i - 8):i]) if i else vols[0] | |
| 101 | + for i in range(len(vols))]) | |
| 102 | + partial = vols < 0.6 * med8 | |
| 103 | + # Only flag a trailing contiguous run (mid-sample dips are real market lulls). | |
| 104 | + flagged = np.zeros(len(vols), dtype=bool) | |
| 105 | + i = len(vols) - 1 | |
| 106 | + while i >= 0 and partial[i]: | |
| 107 | + flagged[i] = True | |
| 108 | + i -= 1 | |
| 109 | + pw = pl.DataFrame({"week": pv["week"], "is_partial_week": flagged}) | |
| 110 | + return df.join(pw, on="week", how="left").with_columns( | |
| 111 | + pl.col("is_partial_week").fill_null(False)) | |
| 112 | + | |
| 113 | + | |
| 114 | +def add_representative_value(df: pl.DataFrame, basket: pl.DataFrame) -> pl.DataFrame: | |
| 115 | + """basket: geography_id, property_type, basket_value (2021-price dollars).""" | |
| 116 | + return df.join(basket, on=["geography_id", "property_type"], how="left") \ | |
| 117 | + .with_columns( | |
| 118 | + (pl.col("basket_value") * pl.col("index_smoothed") / 100.0) | |
| 119 | + .round(0).alias("representative_value") | |
| 120 | + ) | |
| 121 | + | |
| 122 | + | |
| 123 | +def compute_basket(clean_cells: pl.DataFrame, index_df: pl.DataFrame) -> pl.DataFrame: | |
| 124 | + """Fixed-basket base-period value per cell (see module docstring). | |
| 125 | + | |
| 126 | + ``clean_cells`` must have geography_id, property_type, week, log_amount. | |
| 127 | + """ | |
| 128 | + j = clean_cells.join( | |
| 129 | + index_df.select("geography_id", "property_type", "week", | |
| 130 | + pl.col("index_smoothed")), | |
| 131 | + on=["geography_id", "property_type", "week"], how="inner", | |
| 132 | + ) | |
| 133 | + return ( | |
| 134 | + j.group_by(["geography_id", "property_type"]) | |
| 135 | + .agg( | |
| 136 | + (pl.col("log_amount") - (pl.col("index_smoothed") / 100.0).log()) | |
| 137 | + .mean().exp().round(0).alias("basket_value") | |
| 138 | + ) | |
| 139 | + ) | |
added
engine/src/qwhpi/ingest.py
+81 −0
@@ -0,0 +1,81 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/ingest.py | |
| 6 | +# Purpose : Load the immutable raw transactions CSV with typed columns and | |
| 7 | +# attach the Monday-labeled weekly calendar. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Raw-data ingestion. | |
| 10 | + | |
| 11 | +The raw CSV in ``data/raw/`` is immutable: this module only reads it. Typed | |
| 12 | +loading + week assignment happen here so every downstream stage sees the same | |
| 13 | +representation. The parsed frame is cached to ``data/interim/transactions_raw.parquet`` | |
| 14 | +keyed on the raw file's size/mtime, so repeat runs skip CSV parsing. | |
| 15 | +""" | |
| 16 | + | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import json | |
| 20 | +from pathlib import Path | |
| 21 | + | |
| 22 | +import polars as pl | |
| 23 | + | |
| 24 | +from qwhpi.config import INTERIM_DIR, RAW_TRANSACTIONS_CSV | |
| 25 | + | |
| 26 | +RAW_SCHEMA: dict[str, pl.DataType] = { | |
| 27 | + "id": pl.Utf8, | |
| 28 | + "date": pl.Utf8, # parsed to Date below | |
| 29 | + "amount": pl.Float64, | |
| 30 | + "street": pl.Utf8, | |
| 31 | + "zipCode": pl.Utf8, | |
| 32 | + "city": pl.Utf8, | |
| 33 | + "lat": pl.Float64, | |
| 34 | + "lng": pl.Float64, | |
| 35 | + "propertyType": pl.Utf8, | |
| 36 | + "yearBuilt": pl.Int64, | |
| 37 | + "floorArea": pl.Float64, | |
| 38 | + "buildingType": pl.Utf8, | |
| 39 | + "previousValue": pl.Float64, | |
| 40 | + "totalArValue": pl.Float64, | |
| 41 | + "ownerType": pl.Utf8, | |
| 42 | +} | |
| 43 | + | |
| 44 | +CACHE_PARQUET = INTERIM_DIR / "transactions_raw.parquet" | |
| 45 | +CACHE_MANIFEST = INTERIM_DIR / "transactions_raw.manifest.json" | |
| 46 | + | |
| 47 | + | |
| 48 | +def _raw_fingerprint(csv_path: Path) -> dict: | |
| 49 | + stat = csv_path.stat() | |
| 50 | + return {"path": str(csv_path), "size": stat.st_size, "mtime": stat.st_mtime} | |
| 51 | + | |
| 52 | + | |
| 53 | +def load_raw(csv_path: Path = RAW_TRANSACTIONS_CSV, use_cache: bool = True) -> pl.DataFrame: | |
| 54 | + """Load raw transactions with types, date parsing, and week assignment. | |
| 55 | + | |
| 56 | + Adds columns: | |
| 57 | + - ``week``: Monday of the transaction's Monday->Sunday week (pl.Date) | |
| 58 | + - ``log_amount``: natural log of amount (null when amount <= 0) | |
| 59 | + """ | |
| 60 | + if use_cache and CACHE_PARQUET.exists() and CACHE_MANIFEST.exists(): | |
| 61 | + manifest = json.loads(CACHE_MANIFEST.read_text()) | |
| 62 | + if manifest == _raw_fingerprint(csv_path): | |
| 63 | + return pl.read_parquet(CACHE_PARQUET) | |
| 64 | + | |
| 65 | + df = pl.read_csv(csv_path, schema_overrides=RAW_SCHEMA) | |
| 66 | + df = df.with_columns( | |
| 67 | + pl.col("date").str.to_date("%Y-%m-%d"), | |
| 68 | + ).with_columns( | |
| 69 | + # Monday-labeled week: subtract the ISO weekday offset (Mon=1 ... Sun=7). | |
| 70 | + (pl.col("date") - pl.duration(days=pl.col("date").dt.weekday() - 1)).alias("week"), | |
| 71 | + pl.when(pl.col("amount") > 0) | |
| 72 | + .then(pl.col("amount").log()) | |
| 73 | + .otherwise(None) | |
| 74 | + .alias("log_amount"), | |
| 75 | + ) | |
| 76 | + | |
| 77 | + if use_cache: | |
| 78 | + INTERIM_DIR.mkdir(parents=True, exist_ok=True) | |
| 79 | + df.write_parquet(CACHE_PARQUET) | |
| 80 | + CACHE_MANIFEST.write_text(json.dumps(_raw_fingerprint(csv_path))) | |
| 81 | + return df | |
added
engine/src/qwhpi/nowcast.py
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/nowcast.py | |
| 6 | +# Purpose : Partial-week (nowcast) detection for the latest weeks — data | |
| 7 | +# registration lag means trailing weeks under-report volume. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Nowcast handling for trailing partial weeks. | |
| 10 | + | |
| 11 | +Land-registry transactions arrive with a registration lag: the most recent | |
| 12 | +Monday-week typically shows a fraction of its eventual volume. Such weeks | |
| 13 | +are still published (the filtered state-space estimate is a genuine | |
| 14 | +nowcast) but flagged ``is_partial_week = true`` so consumers know the value | |
| 15 | +will revise. Detection: a trailing contiguous run of weeks whose | |
| 16 | +province-wide volume is below ``PARTIAL_THRESHOLD`` × trailing-8-week | |
| 17 | +median volume. | |
| 18 | +""" | |
| 19 | + | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import numpy as np | |
| 23 | +import polars as pl | |
| 24 | + | |
| 25 | +PARTIAL_THRESHOLD = 0.6 | |
| 26 | + | |
| 27 | + | |
| 28 | +def partial_week_flags(province_volume: pl.DataFrame) -> pl.DataFrame: | |
| 29 | + """province_volume: columns week, volume — returns week, is_partial_week.""" | |
| 30 | + pv = province_volume.sort("week") | |
| 31 | + vols = pv["volume"].to_numpy().astype(float) | |
| 32 | + med8 = np.array([np.median(vols[max(0, i - 8):i]) if i else vols[0] | |
| 33 | + for i in range(len(vols))]) | |
| 34 | + low = vols < PARTIAL_THRESHOLD * med8 | |
| 35 | + flagged = np.zeros(len(vols), dtype=bool) | |
| 36 | + i = len(vols) - 1 | |
| 37 | + while i >= 0 and low[i]: | |
| 38 | + flagged[i] = True | |
| 39 | + i -= 1 | |
| 40 | + return pl.DataFrame({"week": pv["week"], "is_partial_week": flagged}) | |
added
engine/src/qwhpi/plotting.py
+63 −0
@@ -0,0 +1,63 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/plotting.py | |
| 6 | +# Purpose : Shared plotting configuration — one validated palette and mark | |
| 7 | +# spec for every figure (matches the dashboard). | |
| 8 | +# ============================================================================= | |
| 9 | +"""Shared figure style. | |
| 10 | + | |
| 11 | +Categorical palette (validated, fixed order — never cycled beyond need): | |
| 12 | +blue, orange, aqua, yellow, magenta. Sequential ramp: blue light→dark. | |
| 13 | +Marks: 2px lines, recessive grid, no chartjunk. Author credit on every | |
| 14 | +figure footer. | |
| 15 | +""" | |
| 16 | + | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import matplotlib | |
| 20 | + | |
| 21 | +matplotlib.use("Agg") | |
| 22 | +import matplotlib.pyplot as plt # noqa: E402 | |
| 23 | + | |
| 24 | +SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4"] | |
| 25 | +BAND = (42 / 255, 120 / 255, 214 / 255, 0.14) | |
| 26 | +SEQ = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"] | |
| 27 | +TEXT = "#0b0b0b" | |
| 28 | +TEXT2 = "#52514e" | |
| 29 | +GRID = "#dddcd6" | |
| 30 | + | |
| 31 | +plt.rcParams.update({ | |
| 32 | + "figure.facecolor": "#fcfcfb", | |
| 33 | + "axes.facecolor": "#fcfcfb", | |
| 34 | + "axes.edgecolor": GRID, | |
| 35 | + "axes.grid": True, | |
| 36 | + "grid.color": GRID, | |
| 37 | + "grid.linestyle": ":", | |
| 38 | + "grid.linewidth": 0.8, | |
| 39 | + "axes.spines.top": False, | |
| 40 | + "axes.spines.right": False, | |
| 41 | + "text.color": TEXT, | |
| 42 | + "axes.labelcolor": TEXT2, | |
| 43 | + "xtick.color": TEXT2, | |
| 44 | + "ytick.color": TEXT2, | |
| 45 | + "font.size": 10, | |
| 46 | + "axes.titlesize": 11, | |
| 47 | + "figure.dpi": 150, | |
| 48 | + "lines.linewidth": 2.0, | |
| 49 | + "legend.frameon": False, | |
| 50 | +}) | |
| 51 | + | |
| 52 | + | |
| 53 | +def credit(fig: "plt.Figure") -> None: | |
| 54 | + fig.text(0.99, 0.01, "QWHPI — Simon-Pierre Boucher · contact@spboucher.ai", | |
| 55 | + ha="right", va="bottom", fontsize=7, color=TEXT2) | |
| 56 | + | |
| 57 | + | |
| 58 | +def save(fig: "plt.Figure", path) -> None: | |
| 59 | + credit(fig) | |
| 60 | + fig.tight_layout(rect=(0, 0.02, 1, 1)) | |
| 61 | + fig.savefig(path, bbox_inches="tight") | |
| 62 | + plt.close(fig) | |
| 63 | + print(f" -> {path}") | |
added
engine/src/qwhpi/repeat_sales.py
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/repeat_sales.py | |
| 6 | +# Purpose : Repeat-sales (BMN) index as a directional validation check for | |
| 7 | +# the hedonic indexes. Condo/plex pairs are unit-signature matched. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Repeat-sales validation index (Bailey–Muth–Nourse). | |
| 10 | + | |
| 11 | +Address caveat (CLAUDE.md §4.7): condo and plex street strings carry no unit | |
| 12 | +numbers, so a bare (street, city) match pairs *different units in the same | |
| 13 | +building*. Pairs are therefore matched on the **unit signature** | |
| 14 | +(street, city, floorArea, yearBuilt): either the same unit or an identical- | |
| 15 | +spec unit in the same building — both valid for price-relative measurement. | |
| 16 | +Sales less than 90 days apart are dropped (flips / double registrations). | |
| 17 | + | |
| 18 | +Estimation: log(P₂/P₁) = Σ_t δ_t (D_{t,2} − D_{t,1}) + ε at monthly | |
| 19 | +frequency (weekly repeat pairs are too sparse), OLS via lstsq. The index is | |
| 20 | +rebased to the 2021 average = 100 for comparability with QWHPI. | |
| 21 | +""" | |
| 22 | + | |
| 23 | +from __future__ import annotations | |
| 24 | + | |
| 25 | +import numpy as np | |
| 26 | +import polars as pl | |
| 27 | + | |
| 28 | +MIN_DAYS_BETWEEN_SALES = 90 | |
| 29 | +PAIR_KEY = ["street", "city", "floorArea", "yearBuilt"] | |
| 30 | + | |
| 31 | + | |
| 32 | +def build_pairs(df: pl.DataFrame) -> pl.DataFrame: | |
| 33 | + """Consecutive same-signature sale pairs with log price relatives.""" | |
| 34 | + d = df.filter( | |
| 35 | + pl.col("street").is_not_null() | |
| 36 | + & pl.col("floorArea").is_not_null() | |
| 37 | + & pl.col("yearBuilt").is_not_null() | |
| 38 | + ).sort("date") | |
| 39 | + d = d.with_columns( | |
| 40 | + pl.col("log_amount").shift(1).over(PAIR_KEY).alias("lp_prev"), | |
| 41 | + pl.col("date").shift(1).over(PAIR_KEY).alias("dt_prev"), | |
| 42 | + ).filter(pl.col("lp_prev").is_not_null()) | |
| 43 | + d = d.filter( | |
| 44 | + (pl.col("date") - pl.col("dt_prev")).dt.total_days() >= MIN_DAYS_BETWEEN_SALES | |
| 45 | + ) | |
| 46 | + return d.with_columns( | |
| 47 | + (pl.col("log_amount") - pl.col("lp_prev")).alias("dlp"), | |
| 48 | + pl.col("date").dt.strftime("%Y-%m").alias("m2"), | |
| 49 | + pl.col("dt_prev").dt.strftime("%Y-%m").alias("m1"), | |
| 50 | + ) | |
| 51 | + | |
| 52 | + | |
| 53 | +def bmn_index(pairs: pl.DataFrame) -> pl.DataFrame | None: | |
| 54 | + """Monthly BMN index (2021 avg = 100). None when pairs are too few.""" | |
| 55 | + if pairs.height < 200: | |
| 56 | + return None | |
| 57 | + months = sorted(set(pairs["m1"].to_list()) | set(pairs["m2"].to_list())) | |
| 58 | + pos = {m: i for i, m in enumerate(months)} | |
| 59 | + n, T = pairs.height, len(months) | |
| 60 | + X = np.zeros((n, T - 1)) # first month is the base | |
| 61 | + m1 = pairs["m1"].to_numpy() | |
| 62 | + m2 = pairs["m2"].to_numpy() | |
| 63 | + for i in range(n): | |
| 64 | + if pos[m2[i]] > 0: | |
| 65 | + X[i, pos[m2[i]] - 1] += 1.0 | |
| 66 | + if pos[m1[i]] > 0: | |
| 67 | + X[i, pos[m1[i]] - 1] -= 1.0 | |
| 68 | + y = pairs["dlp"].to_numpy() | |
| 69 | + beta, *_ = np.linalg.lstsq(X, y, rcond=None) | |
| 70 | + delta = np.concatenate([[0.0], beta]) | |
| 71 | + base = delta[[m.startswith("2021") for m in months]].mean() | |
| 72 | + return pl.DataFrame({ | |
| 73 | + "month": months, | |
| 74 | + "rs_index": np.exp(delta - base) * 100.0, | |
| 75 | + }) | |
added
engine/src/qwhpi/rtd.py
+289 −0
@@ -0,0 +1,289 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/rtd.py | |
| 6 | +# Purpose : Rolling-Time-Dummy stage 1 (v2, monthly) — 13-month Huber-robust | |
| 7 | +# hedonic windows, mean-splice linking, real-time residuals. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Rolling-Time-Dummy (RTD) hedonic estimation — monthly, robust. | |
| 10 | + | |
| 11 | +Method (see outputs/reports/methodology_v2_research.md for sources): | |
| 12 | + | |
| 13 | +- 13-month windows stepped one month at a time. Within each window the | |
| 14 | + hedonic is estimated by IRLS with Huber weights (k = 1.345 on | |
| 15 | + MAD-standardized residuals, ``N_IRLS`` reweighting passes), absorbing | |
| 16 | + fine-location FE and (month × property type) FE: | |
| 17 | + | |
| 18 | + log P ~ log_fa + fa_missing + C(age_bin) | loc_fine + mo_type | |
| 19 | + | |
| 20 | +- Linking: the published province path per type is extended by the **mean | |
| 21 | + splice** — the average (in logs) over every feasible overlap position of | |
| 22 | + the growth implied by the new window. Movement and window splices are also | |
| 23 | + returned as sensitivity diagnostics. The spliced history never revises. | |
| 24 | + | |
| 25 | +- Residuals for the hierarchy come from :func:`fullpool_residualize` — a | |
| 26 | + single Huber-robust full-pool regression — NOT from the rolling windows. | |
| 27 | + Rolling location FE re-absorb each cell's slow drift window after window, | |
| 28 | + which empties the residuals of exactly the regional divergence stage 2 | |
| 29 | + must measure (verified against repeat sales: rolling-window residuals | |
| 30 | + collapse Québec City condo's +20% divergence to ~0). Fixed-β full-pool | |
| 31 | + residuals carry it. Since the SAME RTD anchor is added to every cell of a | |
| 32 | + type, the published family stays internally consistent. | |
| 33 | + | |
| 34 | +- Diagnostics: the log-floor-area coefficient per window (parameter-drift | |
| 35 | + evidence motivating RTD over a fixed-β full pool). | |
| 36 | +""" | |
| 37 | + | |
| 38 | +from __future__ import annotations | |
| 39 | + | |
| 40 | +from dataclasses import dataclass | |
| 41 | + | |
| 42 | +import numpy as np | |
| 43 | +import pandas as pd | |
| 44 | +import polars as pl | |
| 45 | +import pyfixest as pf | |
| 46 | + | |
| 47 | +from qwhpi.config import PROPERTY_TYPES | |
| 48 | + | |
| 49 | +WINDOW = 13 # months per estimation window | |
| 50 | +HUBER_K = 1.345 # 95% Gaussian efficiency | |
| 51 | +N_IRLS = 2 # reweighting passes after the initial OLS fit | |
| 52 | + | |
| 53 | +STRUCTURAL_RHS = "log_fa + fa_missing + C(age_bin)" # buildingType excluded: time-correlated missingness (features.py) | |
| 54 | + | |
| 55 | + | |
| 56 | +@dataclass | |
| 57 | +class RTDResult: | |
| 58 | + """Stage-1 output of the rolling-time-dummy estimation.""" | |
| 59 | + | |
| 60 | + months: list[str] # continuous monthly grid | |
| 61 | + paths: dict[str, np.ndarray] # type -> mean-spliced log path | |
| 62 | + paths_movement: dict[str, np.ndarray] # sensitivity: movement splice | |
| 63 | + paths_window: dict[str, np.ndarray] # sensitivity: window splice | |
| 64 | + residuals: pl.DataFrame # id, month, propertyType, region_code, | |
| 65 | + # geo_code, municipality, resid | |
| 66 | + drift: pl.DataFrame # window_end, beta_log_fa, n, r2 | |
| 67 | + sigma2: dict[str, float] # type -> robust residual variance | |
| 68 | + | |
| 69 | + | |
| 70 | +def month_grid(months_present: list[str]) -> list[str]: | |
| 71 | + """Continuous YYYY-MM grid between the first and last observed month.""" | |
| 72 | + first, last = min(months_present), max(months_present) | |
| 73 | + y, m = int(first[:4]), int(first[5:7]) | |
| 74 | + out = [] | |
| 75 | + while True: | |
| 76 | + cur = f"{y:04d}-{m:02d}" | |
| 77 | + out.append(cur) | |
| 78 | + if cur == last: | |
| 79 | + return out | |
| 80 | + m += 1 | |
| 81 | + if m == 13: | |
| 82 | + y, m = y + 1, 1 | |
| 83 | + | |
| 84 | + | |
| 85 | +def _fit_window(pdf: pd.DataFrame) -> tuple[pf.estimation.models.feols_.Feols, pd.Series]: | |
| 86 | + """Huber-IRLS fit of the window hedonic; returns (fit, final weights).""" | |
| 87 | + pdf = pdf.copy() | |
| 88 | + pdf["_w"] = 1.0 | |
| 89 | + fml = f"log_amount ~ {STRUCTURAL_RHS} | loc_fine + mo_type" | |
| 90 | + fit = pf.feols(fml, data=pdf) | |
| 91 | + for _ in range(N_IRLS): | |
| 92 | + yhat = np.asarray(fit.predict(newdata=pdf), dtype=float) | |
| 93 | + r = pdf["log_amount"].to_numpy() - yhat | |
| 94 | + r = np.where(np.isfinite(r), r, 0.0) | |
| 95 | + s = 1.4826 * np.median(np.abs(r - np.median(r))) or 1.0 | |
| 96 | + u = np.abs(r) / s | |
| 97 | + pdf["_w"] = np.minimum(1.0, HUBER_K / np.maximum(u, 1e-9)) | |
| 98 | + fit = pf.feols(fml, data=pdf, weights="_w") | |
| 99 | + return fit, pdf["_w"] | |
| 100 | + | |
| 101 | + | |
| 102 | +def _window_deltas(fit) -> dict[tuple[str, str], float]: | |
| 103 | + """(month, type) -> time-dummy log effect from the window's fixef.""" | |
| 104 | + fx = fit.fixef() | |
| 105 | + key = next(k for k in fx if "mo_type" in k) | |
| 106 | + out = {} | |
| 107 | + for name, val in fx[key].items(): | |
| 108 | + mo, ptype = name.split("|") | |
| 109 | + out[(mo, ptype)] = float(val) | |
| 110 | + return out | |
| 111 | + | |
| 112 | + | |
| 113 | +def local_time_dummy( | |
| 114 | + sub: pl.DataFrame, | |
| 115 | + grid: list[str], | |
| 116 | +) -> tuple[np.ndarray, np.ndarray, float] | None: | |
| 117 | + """Direct Huber-robust monthly time-dummy for ONE liquid cell. | |
| 118 | + | |
| 119 | + Own β, own location FE, own month effects — the estimator that matched | |
| 120 | + repeat sales and stratified matched-cell medians in the v2 arbitration | |
| 121 | + (deviation-from-pooled-surface compresses strong local divergences). | |
| 122 | + Returns (log path aligned to grid, monthly n, robust residual variance), | |
| 123 | + or None if estimation is impossible. | |
| 124 | + | |
| 125 | + Full-pool per cell (not windowed): within-cell β drift is second-order, | |
| 126 | + and cell-level revisions are tracked by the vintage framework. | |
| 127 | + """ | |
| 128 | + pdf = sub.select("log_amount", "month", "log_fa", "fa_missing", | |
| 129 | + "age_bin", "loc_fine").to_pandas() | |
| 130 | + pdf["fa_missing"] = pdf["fa_missing"].astype(int) | |
| 131 | + pdf["mo_type"] = pdf["month"] # cell-level time FE = plain month | |
| 132 | + if pdf["month"].nunique() < len(grid) * 0.9: | |
| 133 | + return None | |
| 134 | + try: | |
| 135 | + fit, _ = _fit_window(pdf) | |
| 136 | + except Exception: | |
| 137 | + return None | |
| 138 | + fx = fit.fixef() | |
| 139 | + key = next(k for k in fx if "mo_type" in k) | |
| 140 | + d = {m: float(v) for m, v in fx[key].items()} | |
| 141 | + path = np.array([d.get(m, np.nan) for m in grid]) | |
| 142 | + if np.isnan(path).any(): | |
| 143 | + idx = np.arange(len(path)) | |
| 144 | + ok = np.isfinite(path) | |
| 145 | + if ok.sum() < len(grid) * 0.9: | |
| 146 | + return None | |
| 147 | + path = np.interp(idx, idx[ok], path[ok]) | |
| 148 | + | |
| 149 | + yhat = np.asarray(fit.predict(newdata=pdf), dtype=float) | |
| 150 | + resid = pdf["log_amount"].to_numpy() - yhat | |
| 151 | + resid = resid[np.isfinite(resid)] | |
| 152 | + sigma2 = float(np.var(resid)) if resid.size else 0.09 | |
| 153 | + counts = sub.group_by("month").agg(pl.len().alias("n")) | |
| 154 | + cmap = dict(counts.iter_rows()) | |
| 155 | + n = np.array([cmap.get(m, 0) for m in grid], dtype=float) | |
| 156 | + return path, n, sigma2 | |
| 157 | + | |
| 158 | + | |
| 159 | +def fullpool_residualize(feat: pl.DataFrame) -> pl.DataFrame: | |
| 160 | + """Huber-robust full-pool monthly regression → residuals for stage 2. | |
| 161 | + | |
| 162 | + Fixed β over the pool (with month×type FE and location FE) so the slow | |
| 163 | + relative drift of each geography survives in the residual time path — | |
| 164 | + the property the hierarchical deviations need (see module docstring). | |
| 165 | + """ | |
| 166 | + cols = ["id", "log_amount", "month", "propertyType", "log_fa", "fa_missing", | |
| 167 | + "age_bin", "building_type", "loc_fine", "region_code", "geo_code", | |
| 168 | + "municipality"] | |
| 169 | + pdf = feat.select(cols).with_columns( | |
| 170 | + pl.concat_str([pl.col("month"), pl.lit("|"), pl.col("propertyType")]) | |
| 171 | + .alias("mo_type") | |
| 172 | + ).to_pandas() | |
| 173 | + pdf["fa_missing"] = pdf["fa_missing"].astype(int) | |
| 174 | + | |
| 175 | + fit, _ = _fit_window(pdf) | |
| 176 | + yhat = np.asarray(fit.predict(newdata=pdf), dtype=float) | |
| 177 | + pdf["resid"] = pdf["log_amount"].to_numpy() - yhat | |
| 178 | + return pl.from_pandas( | |
| 179 | + pdf[["id", "month", "propertyType", "region_code", "geo_code", | |
| 180 | + "municipality", "resid"]] | |
| 181 | + ).filter(pl.col("resid").is_not_nan() & pl.col("resid").is_not_null()) | |
| 182 | + | |
| 183 | + | |
| 184 | +def run_rtd(feat: pl.DataFrame, verbose: bool = True) -> RTDResult: | |
| 185 | + """Run the full RTD stage 1 over the research sample features. | |
| 186 | + | |
| 187 | + ``feat`` needs: id, log_amount, month (YYYY-MM), propertyType, log_fa, | |
| 188 | + fa_missing, age_bin, building_type, loc_fine, region_code, geo_code, | |
| 189 | + municipality. | |
| 190 | + """ | |
| 191 | + months = month_grid(sorted(feat["month"].unique().to_list())) | |
| 192 | + T = len(months) | |
| 193 | + pos = {m: i for i, m in enumerate(months)} | |
| 194 | + | |
| 195 | + cols = ["id", "log_amount", "month", "propertyType", "log_fa", "fa_missing", | |
| 196 | + "age_bin", "building_type", "loc_fine", "region_code", "geo_code", | |
| 197 | + "municipality"] | |
| 198 | + base = feat.select(cols).with_columns( | |
| 199 | + pl.concat_str([pl.col("month"), pl.lit("|"), pl.col("propertyType")]) | |
| 200 | + .alias("mo_type") | |
| 201 | + ) | |
| 202 | + | |
| 203 | + # Spliced log paths per type; initialized by the first window. | |
| 204 | + paths = {t: np.full(T, np.nan) for t in PROPERTY_TYPES} | |
| 205 | + paths_mv = {t: np.full(T, np.nan) for t in PROPERTY_TYPES} | |
| 206 | + paths_wd = {t: np.full(T, np.nan) for t in PROPERTY_TYPES} | |
| 207 | + resid_frames: list[pl.DataFrame] = [] | |
| 208 | + drift_rows: list[dict] = [] | |
| 209 | + | |
| 210 | + n_windows = T - WINDOW + 1 | |
| 211 | + for w in range(n_windows): | |
| 212 | + w_months = months[w:w + WINDOW] | |
| 213 | + newest = w_months[-1] | |
| 214 | + sub = base.filter(pl.col("month").is_in(w_months)) | |
| 215 | + pdf = sub.to_pandas() | |
| 216 | + pdf["fa_missing"] = pdf["fa_missing"].astype(int) | |
| 217 | + | |
| 218 | + fit, _ = _fit_window(pdf) | |
| 219 | + deltas = _window_deltas(fit) | |
| 220 | + drift_rows.append({ | |
| 221 | + "window_end": newest, | |
| 222 | + "beta_log_fa": float(fit.coef().get("log_fa", np.nan)), | |
| 223 | + "n": int(fit._N), | |
| 224 | + "r2": float(fit._r2), | |
| 225 | + }) | |
| 226 | + | |
| 227 | + # ---- splice per type ---- # | |
| 228 | + for t in PROPERTY_TYPES: | |
| 229 | + d = np.array([deltas.get((m, t), np.nan) for m in w_months]) | |
| 230 | + # interpolate the rare missing (singleton-dropped) window levels | |
| 231 | + if np.isnan(d).any(): | |
| 232 | + idx = np.arange(len(d)) | |
| 233 | + ok = np.isfinite(d) | |
| 234 | + if ok.sum() >= 2: | |
| 235 | + d = np.interp(idx, idx[ok], d[ok]) | |
| 236 | + else: | |
| 237 | + d = np.nan_to_num(d) | |
| 238 | + if w == 0: | |
| 239 | + # first window sets the initial 13 levels (its own normalization) | |
| 240 | + for j, m in enumerate(w_months): | |
| 241 | + v = d[j] - d[0] | |
| 242 | + paths[t][pos[m]] = v | |
| 243 | + paths_mv[t][pos[m]] = v | |
| 244 | + paths_wd[t][pos[m]] = v | |
| 245 | + else: | |
| 246 | + i_new = pos[newest] | |
| 247 | + # movement splice: last within-window growth | |
| 248 | + paths_mv[t][i_new] = paths_mv[t][i_new - 1] + (d[-1] - d[-2]) | |
| 249 | + # window splice: growth across the full window | |
| 250 | + paths_wd[t][i_new] = paths_wd[t][i_new - WINDOW + 1] + (d[-1] - d[0]) | |
| 251 | + # mean splice: average over every overlap position | |
| 252 | + links = [ | |
| 253 | + paths[t][pos[m]] + (d[-1] - d[j]) | |
| 254 | + for j, m in enumerate(w_months[:-1]) | |
| 255 | + ] | |
| 256 | + paths[t][i_new] = float(np.mean(links)) | |
| 257 | + | |
| 258 | + # ---- real-time residuals ---- # | |
| 259 | + target_months = w_months if w == 0 else [newest] | |
| 260 | + rows = sub.filter(pl.col("month").is_in(target_months)) | |
| 261 | + rpdf = rows.to_pandas() | |
| 262 | + rpdf["fa_missing"] = rpdf["fa_missing"].astype(int) | |
| 263 | + yhat = np.asarray(fit.predict(newdata=rpdf), dtype=float) | |
| 264 | + rpdf["resid"] = rpdf["log_amount"].to_numpy() - yhat | |
| 265 | + keep = pl.from_pandas( | |
| 266 | + rpdf[["id", "month", "propertyType", "region_code", "geo_code", | |
| 267 | + "municipality", "resid"]] | |
| 268 | + ).filter(pl.col("resid").is_not_nan() & pl.col("resid").is_not_null()) | |
| 269 | + resid_frames.append(keep) | |
| 270 | + | |
| 271 | + if verbose and (w % 10 == 0 or w == n_windows - 1): | |
| 272 | + print(f" window {w + 1}/{n_windows} (end {newest}) " | |
| 273 | + f"n={drift_rows[-1]['n']:,} r2={drift_rows[-1]['r2']:.3f} " | |
| 274 | + f"beta_fa={drift_rows[-1]['beta_log_fa']:.3f}") | |
| 275 | + | |
| 276 | + residuals = pl.concat(resid_frames) | |
| 277 | + sigma2 = { | |
| 278 | + t: float(residuals.filter(pl.col("propertyType") == t)["resid"].var()) | |
| 279 | + for t in PROPERTY_TYPES | |
| 280 | + } | |
| 281 | + return RTDResult( | |
| 282 | + months=months, | |
| 283 | + paths=paths, | |
| 284 | + paths_movement=paths_mv, | |
| 285 | + paths_window=paths_wd, | |
| 286 | + residuals=residuals, | |
| 287 | + drift=pl.DataFrame(drift_rows), | |
| 288 | + sigma2=sigma2, | |
| 289 | + ) | |
added
engine/src/qwhpi/seasonal.py
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/seasonal.py | |
| 6 | +# Purpose : Stable-seasonality test — QWHPI publishes NSA unless stable | |
| 7 | +# weekly seasonality is demonstrated (CLAUDE.md §4.8). | |
| 8 | +# ============================================================================= | |
| 9 | +"""Seasonality assessment. | |
| 10 | + | |
| 11 | +Test: regress weekly log-changes of a series on annual-cycle harmonics | |
| 12 | +(sin/cos at 1–3 cycles/year) and F-test their joint significance. Stable | |
| 13 | +seasonality additionally requires the harmonic fit to be consistent across | |
| 14 | +years (coefficient stability). Until both hold, all series are NSA — the | |
| 15 | +published index is never silently adjusted. | |
| 16 | +""" | |
| 17 | + | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import numpy as np | |
| 21 | +from scipy import stats | |
| 22 | + | |
| 23 | + | |
| 24 | +def seasonality_test(log_index: np.ndarray, n_harmonics: int = 3) -> dict: | |
| 25 | + """F-test of annual harmonics on weekly log-changes.""" | |
| 26 | + dy = np.diff(log_index) | |
| 27 | + dy = dy[np.isfinite(dy)] | |
| 28 | + t = np.arange(dy.shape[0]) | |
| 29 | + X = [np.ones_like(t, dtype=float)] | |
| 30 | + for k in range(1, n_harmonics + 1): | |
| 31 | + X.append(np.sin(2 * np.pi * k * t / 52.18)) | |
| 32 | + X.append(np.cos(2 * np.pi * k * t / 52.18)) | |
| 33 | + X = np.column_stack(X) | |
| 34 | + beta, res, *_ = np.linalg.lstsq(X, dy, rcond=None) | |
| 35 | + fitted = X @ beta | |
| 36 | + rss1 = float(np.sum((dy - fitted) ** 2)) | |
| 37 | + rss0 = float(np.sum((dy - dy.mean()) ** 2)) | |
| 38 | + q = X.shape[1] - 1 | |
| 39 | + dof = dy.shape[0] - X.shape[1] | |
| 40 | + f = ((rss0 - rss1) / q) / (rss1 / dof) if rss1 > 0 else np.inf | |
| 41 | + p = 1.0 - stats.f.cdf(f, q, dof) | |
| 42 | + return { | |
| 43 | + "f_stat": round(float(f), 3), | |
| 44 | + "p_value": round(float(p), 5), | |
| 45 | + "seasonal_r2": round(1.0 - rss1 / rss0, 4) if rss0 > 0 else 0.0, | |
| 46 | + "significant_5pct": bool(p < 0.05), | |
| 47 | + } | |
added
engine/src/qwhpi/state_space.py
+123 −0
@@ -0,0 +1,123 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/state_space.py | |
| 6 | +# Purpose : Local-level Kalman filter/smoother with heteroskedastic weekly | |
| 7 | +# observation noise — the shrinkage engine for thin cells. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Heteroskedastic local-level state-space model. | |
| 10 | + | |
| 11 | +For one cell (geography × property type) we observe the weekly mean | |
| 12 | +quality-adjusted residual ȳ_t (deviation of the cell from its parent path): | |
| 13 | + | |
| 14 | + ȳ_t = d_t + ε_t, ε_t ~ N(0, σ² / n_t) (observation) | |
| 15 | + d_t = d_{t-1} + η_t, η_t ~ N(0, τ²) (latent deviation) | |
| 16 | + | |
| 17 | +- σ² is the within-cell transaction-level residual variance (estimated from | |
| 18 | + the data), so weeks with more transactions speak with more weight. | |
| 19 | +- τ² (the deviation's innovation variance) is estimated by maximizing the | |
| 20 | + prediction-error decomposition log-likelihood. | |
| 21 | +- Weeks with n_t = 0 are pure predictions (no update) — the grid is never | |
| 22 | + dropped. | |
| 23 | + | |
| 24 | +Outputs both the one-sided *filtered* path (real-time, no lookahead — powers | |
| 25 | +``index_smoothed``) and the two-sided *smoothed* path (research use), with | |
| 26 | +posterior variances and an interpretable shrinkage weight | |
| 27 | +(Kalman gain: share of the week's estimate coming from local data). | |
| 28 | +""" | |
| 29 | + | |
| 30 | +from __future__ import annotations | |
| 31 | + | |
| 32 | +from dataclasses import dataclass | |
| 33 | + | |
| 34 | +import numpy as np | |
| 35 | +from scipy.optimize import minimize_scalar | |
| 36 | + | |
| 37 | + | |
| 38 | +@dataclass | |
| 39 | +class LocalLevelFit: | |
| 40 | + """Filtered + smoothed latent deviation path for one cell.""" | |
| 41 | + | |
| 42 | + filtered: np.ndarray # E[d_t | y_1..t] | |
| 43 | + filtered_var: np.ndarray | |
| 44 | + smoothed: np.ndarray # E[d_t | y_1..T] | |
| 45 | + smoothed_var: np.ndarray | |
| 46 | + gain: np.ndarray # Kalman gain in [0,1]; 0 where n_t = 0 | |
| 47 | + tau2: float | |
| 48 | + sigma2: float | |
| 49 | + loglik: float | |
| 50 | + | |
| 51 | + | |
| 52 | +def _filter(y: np.ndarray, r: np.ndarray, tau2: float, | |
| 53 | + d0: float = 0.0, p0: float = 1.0): | |
| 54 | + """Kalman filter for the local level model. r[t]=obs variance (inf if none).""" | |
| 55 | + T = y.shape[0] | |
| 56 | + a = np.empty(T) # filtered mean | |
| 57 | + p = np.empty(T) # filtered variance | |
| 58 | + a_pred = np.empty(T) | |
| 59 | + p_pred = np.empty(T) | |
| 60 | + gain = np.zeros(T) | |
| 61 | + ll = 0.0 | |
| 62 | + at, pt = d0, p0 | |
| 63 | + for t in range(T): | |
| 64 | + # predict | |
| 65 | + at_p, pt_p = at, pt + tau2 | |
| 66 | + a_pred[t], p_pred[t] = at_p, pt_p | |
| 67 | + if np.isfinite(r[t]): | |
| 68 | + f = pt_p + r[t] | |
| 69 | + v = y[t] - at_p | |
| 70 | + k = pt_p / f | |
| 71 | + at = at_p + k * v | |
| 72 | + pt = pt_p * (1.0 - k) | |
| 73 | + gain[t] = k | |
| 74 | + ll += -0.5 * (np.log(2.0 * np.pi * f) + v * v / f) | |
| 75 | + else: | |
| 76 | + at, pt = at_p, pt_p | |
| 77 | + a[t], p[t] = at, pt | |
| 78 | + return a, p, a_pred, p_pred, gain, ll | |
| 79 | + | |
| 80 | + | |
| 81 | +def _smooth(a, p, a_pred, p_pred, tau2): | |
| 82 | + """Rauch-Tung-Striebel smoother.""" | |
| 83 | + T = a.shape[0] | |
| 84 | + s = a.copy() | |
| 85 | + sv = p.copy() | |
| 86 | + for t in range(T - 2, -1, -1): | |
| 87 | + c = p[t] / p_pred[t + 1] if p_pred[t + 1] > 0 else 0.0 | |
| 88 | + s[t] = a[t] + c * (s[t + 1] - a_pred[t + 1]) | |
| 89 | + sv[t] = p[t] + c * c * (sv[t + 1] - p_pred[t + 1]) | |
| 90 | + return s, np.maximum(sv, 0.0) | |
| 91 | + | |
| 92 | + | |
| 93 | +def fit_local_level( | |
| 94 | + y: np.ndarray, | |
| 95 | + n: np.ndarray, | |
| 96 | + sigma2: float, | |
| 97 | + diffuse_p0: float = 0.5, | |
| 98 | +) -> LocalLevelFit: | |
| 99 | + """Fit the heteroskedastic local-level model to one cell. | |
| 100 | + | |
| 101 | + Parameters | |
| 102 | + ---------- | |
| 103 | + y : weekly mean residual (NaN where no transactions) | |
| 104 | + n : weekly transaction counts (0 where none) | |
| 105 | + sigma2 : transaction-level residual variance for the cell | |
| 106 | + """ | |
| 107 | + y = np.asarray(y, dtype=float) | |
| 108 | + n = np.asarray(n, dtype=float) | |
| 109 | + r = np.where(n > 0, sigma2 / np.maximum(n, 1.0), np.inf) | |
| 110 | + yy = np.where(np.isfinite(r), np.nan_to_num(y), 0.0) | |
| 111 | + | |
| 112 | + def negll(log_tau2: float) -> float: | |
| 113 | + return -_filter(yy, r, np.exp(log_tau2), 0.0, diffuse_p0)[5] | |
| 114 | + | |
| 115 | + # τ² for weekly log deviations plausibly lies in [1e-9, 1e-2]. | |
| 116 | + opt = minimize_scalar(negll, bounds=(np.log(1e-9), np.log(1e-2)), | |
| 117 | + method="bounded") | |
| 118 | + tau2 = float(np.exp(opt.x)) | |
| 119 | + a, p, a_pred, p_pred, gain, ll = _filter(yy, r, tau2, 0.0, diffuse_p0) | |
| 120 | + s, sv = _smooth(a, p, a_pred, p_pred, tau2) | |
| 121 | + return LocalLevelFit(filtered=a, filtered_var=p, smoothed=s, | |
| 122 | + smoothed_var=sv, gain=gain, tau2=tau2, | |
| 123 | + sigma2=sigma2, loglik=ll) | |
added
engine/src/qwhpi/uncertainty.py
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/uncertainty.py | |
| 6 | +# Purpose : Week-cluster bootstrap for validating the analytic CIs of the | |
| 7 | +# hierarchical index on selected cells. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Uncertainty quantification. | |
| 10 | + | |
| 11 | +The published 95% bands combine the province-path delta-method variance and | |
| 12 | +the Kalman posterior variance (see hierarchy.py). This module validates that | |
| 13 | +approximation by a week-cluster bootstrap of stage-1 residuals: resample | |
| 14 | +transactions within each week (preserving weekly sample sizes), refit the | |
| 15 | +cell's local-level model, and compare the bootstrap dispersion of the | |
| 16 | +smoothed path with the analytic posterior SD. The downsampling experiment | |
| 17 | +(scripts/06) provides the complementary coverage check. | |
| 18 | +""" | |
| 19 | + | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import numpy as np | |
| 23 | +import polars as pl | |
| 24 | + | |
| 25 | +from qwhpi.hierarchy import cell_deviation | |
| 26 | + | |
| 27 | + | |
| 28 | +def bootstrap_cell( | |
| 29 | + cell_resid: pl.DataFrame, | |
| 30 | + grid: list[str], | |
| 31 | + sigma2: float, | |
| 32 | + n_boot: int = 200, | |
| 33 | + seed: int = 7, | |
| 34 | +) -> dict: | |
| 35 | + """Bootstrap the smoothed deviation path of one cell. | |
| 36 | + | |
| 37 | + ``cell_resid`` columns: week_str, resid. | |
| 38 | + Returns mean analytic SD vs bootstrap SD and their ratio. | |
| 39 | + """ | |
| 40 | + rng = np.random.default_rng(seed) | |
| 41 | + base = cell_deviation(cell_resid.rename({"resid": "dev"}).select("week_str", "dev"), | |
| 42 | + grid, sigma2) | |
| 43 | + paths = np.empty((n_boot, len(grid))) | |
| 44 | + pdf = cell_resid.to_pandas() | |
| 45 | + grouped = {k: g["resid"].to_numpy() for k, g in pdf.groupby("week_str")} | |
| 46 | + for b in range(n_boot): | |
| 47 | + rows_w, rows_v = [], [] | |
| 48 | + for wk, vals in grouped.items(): | |
| 49 | + draw = vals[rng.integers(0, len(vals), len(vals))] | |
| 50 | + rows_w.extend([wk] * len(draw)) | |
| 51 | + rows_v.extend(draw.tolist()) | |
| 52 | + boot = pl.DataFrame({"week_str": rows_w, "dev": rows_v}) | |
| 53 | + paths[b] = cell_deviation(boot, grid, sigma2).fit.smoothed | |
| 54 | + boot_sd = paths.std(axis=0) | |
| 55 | + analytic_sd = np.sqrt(base.fit.smoothed_var) | |
| 56 | + return { | |
| 57 | + "mean_analytic_sd": float(analytic_sd.mean()), | |
| 58 | + "mean_bootstrap_sd": float(boot_sd.mean()), | |
| 59 | + "sd_ratio_boot_over_analytic": float(boot_sd.mean() / max(analytic_sd.mean(), 1e-12)), | |
| 60 | + } | |
added
engine/src/qwhpi/vintages.py
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/src/qwhpi/vintages.py | |
| 6 | +# Purpose : Vintage framework — first_release preservation and per-run | |
| 7 | +# vintage snapshots for revision analysis. | |
| 8 | +# ============================================================================= | |
| 9 | +"""Vintage management (CLAUDE.md §4.8). | |
| 10 | + | |
| 11 | +Every pipeline run publishes a ``data_vintage`` (run date). This module: | |
| 12 | +- snapshots the canonical table under | |
| 13 | + ``data/processed/vintages/data_vintage=<date>/qwhpi_weekly.parquet``; | |
| 14 | +- maintains ``first_release.parquet``: the first value ever published for | |
| 15 | + each (geography_id, property_type, week) — never overwritten; | |
| 16 | +- computes revision statistics (current vintage vs first release). | |
| 17 | +""" | |
| 18 | + | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import polars as pl | |
| 22 | + | |
| 23 | +from qwhpi.config import PROCESSED_DIR | |
| 24 | + | |
| 25 | +VINTAGE_DIR = PROCESSED_DIR / "vintages" | |
| 26 | +FIRST_RELEASE = PROCESSED_DIR / "first_release.parquet" | |
| 27 | + | |
| 28 | +KEY = ["geography_id", "property_type", "week"] | |
| 29 | + | |
| 30 | + | |
| 31 | +def snapshot(canonical: pl.DataFrame, data_vintage: str) -> None: | |
| 32 | + out_dir = VINTAGE_DIR / f"data_vintage={data_vintage}" | |
| 33 | + out_dir.mkdir(parents=True, exist_ok=True) | |
| 34 | + canonical.write_parquet(out_dir / "qwhpi_weekly.parquet") | |
| 35 | + | |
| 36 | + | |
| 37 | +def update_first_release(canonical: pl.DataFrame, data_vintage: str) -> pl.DataFrame: | |
| 38 | + """Append newly-seen (cell, week) rows to the first-release store.""" | |
| 39 | + fresh = canonical.select( | |
| 40 | + *KEY, | |
| 41 | + pl.col("index").alias("first_release_index"), | |
| 42 | + pl.col("index_smoothed").alias("first_release_index_smoothed"), | |
| 43 | + pl.lit(data_vintage).alias("first_release_vintage"), | |
| 44 | + ) | |
| 45 | + if FIRST_RELEASE.exists(): | |
| 46 | + existing = pl.read_parquet(FIRST_RELEASE) | |
| 47 | + new_rows = fresh.join(existing.select(KEY), on=KEY, how="anti") | |
| 48 | + merged = pl.concat([existing, new_rows]) | |
| 49 | + else: | |
| 50 | + merged = fresh | |
| 51 | + merged.write_parquet(FIRST_RELEASE) | |
| 52 | + return merged | |
| 53 | + | |
| 54 | + | |
| 55 | +def revision_stats(canonical: pl.DataFrame) -> pl.DataFrame | None: | |
| 56 | + """Current vintage vs first release, per cell (needs >=2 vintages).""" | |
| 57 | + if not FIRST_RELEASE.exists(): | |
| 58 | + return None | |
| 59 | + fr = pl.read_parquet(FIRST_RELEASE) | |
| 60 | + j = canonical.join(fr, on=KEY, how="inner").filter( | |
| 61 | + pl.col("first_release_vintage") != pl.col("data_vintage")) | |
| 62 | + if j.height == 0: | |
| 63 | + return None | |
| 64 | + return ( | |
| 65 | + j.with_columns( | |
| 66 | + ((pl.col("index_smoothed") / pl.col("first_release_index_smoothed") - 1) * 100) | |
| 67 | + .alias("revision_pct") | |
| 68 | + ) | |
| 69 | + .group_by(["geography_id", "property_type"]) | |
| 70 | + .agg( | |
| 71 | + pl.col("revision_pct").abs().mean().round(3).alias("mean_abs_revision_pct"), | |
| 72 | + pl.col("revision_pct").abs().max().round(3).alias("max_abs_revision_pct"), | |
| 73 | + pl.len().alias("n_revised_weeks"), | |
| 74 | + ) | |
| 75 | + ) | |
added
engine/tests/test_features_clean.py
+89 −0
@@ -0,0 +1,89 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/tests/test_features_clean.py | |
| 6 | +# Purpose : Unit tests for cleaning flags and feature engineering. | |
| 7 | +# ============================================================================= | |
| 8 | +"""Cleaning + feature unit tests on synthetic rows.""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import datetime as dt | |
| 13 | +import sys | |
| 14 | +from pathlib import Path | |
| 15 | + | |
| 16 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 17 | + | |
| 18 | +import polars as pl | |
| 19 | + | |
| 20 | +from qwhpi.clean import add_flags | |
| 21 | +from qwhpi.features import build_features | |
| 22 | + | |
| 23 | + | |
| 24 | +def synthetic(n: int = 8) -> tuple[pl.DataFrame, pl.DataFrame]: | |
| 25 | + tx = pl.DataFrame({ | |
| 26 | + "id": [f"t{i}" for i in range(n)], | |
| 27 | + "date": [dt.date(2023, 1, 9)] * n, | |
| 28 | + "amount": [300000.0, 300000.0, 55000.0, 20_000_000.0, | |
| 29 | + 400000.0, 500000.0, 350000.0, 360000.0], | |
| 30 | + "street": ["1 rue A", "1 rue A", "2 rue B", "3 rue C", | |
| 31 | + "4 rue D", "5 rue E", "6 rue F", "7 rue G"], | |
| 32 | + "zipCode": ["G1A 1A1"] * n, | |
| 33 | + "city": ["X"] * n, | |
| 34 | + "lat": [46.8] * n, | |
| 35 | + "lng": [-71.2] * n, | |
| 36 | + "propertyType": ["unifamilial"] * 6 + ["condo", "indéterminé"], | |
| 37 | + "yearBuilt": [1990, 1990, 1990, 1990, 2030, None, 2000, 2000], | |
| 38 | + "floorArea": [120.0, 120.0, 120.0, 120.0, 5000.0, None, 80.0, 80.0], | |
| 39 | + "buildingType": ["single-story"] * 6 + [None, None], | |
| 40 | + "previousValue": [250000.0] * n, | |
| 41 | + "totalArValue": [280000.0, 280000.0, 280000.0, 280000.0, | |
| 42 | + 380000.0, 100.0, 330000.0, 330000.0], | |
| 43 | + "ownerType": ["physical_person"] * n, | |
| 44 | + "week": [dt.date(2023, 1, 9)] * n, | |
| 45 | + "log_amount": [12.6] * n, | |
| 46 | + }) | |
| 47 | + geo = pl.DataFrame({ | |
| 48 | + "id": [f"t{i}" for i in range(n)], | |
| 49 | + "geo_code": ["23027"] * n, | |
| 50 | + "municipality": ["Québec"] * n, | |
| 51 | + "munic_type": ["V"] * n, | |
| 52 | + "mrc_code": ["230"] * n, | |
| 53 | + "mrc": ["Québec"] * n, | |
| 54 | + "region_code": ["03"] * n, | |
| 55 | + "region": ["Capitale-Nationale"] * n, | |
| 56 | + "join_method": ["within"] * n, | |
| 57 | + }) | |
| 58 | + return tx, geo | |
| 59 | + | |
| 60 | + | |
| 61 | +def test_flags(): | |
| 62 | + tx, geo = synthetic() | |
| 63 | + df = add_flags(tx, geo) | |
| 64 | + by_id = {r["id"]: r for r in df.iter_rows(named=True)} | |
| 65 | + assert by_id["t1"]["duplicate_flag"] is True # exact dup of t0 | |
| 66 | + assert by_id["t0"]["duplicate_flag"] is False # first kept | |
| 67 | + assert by_id["t3"]["exclude_reason"] == "price_above_10m" | |
| 68 | + assert by_id["t5"]["nonmarket_ratio"] is True # ratio 5000x | |
| 69 | + assert by_id["t4"]["yb_suspect"] is True # built 2030 | |
| 70 | + assert by_id["t4"]["fa_suspect"] is True # 5000 m2 | |
| 71 | + assert by_id["t4"]["yearBuilt"] is None # nulled, kept | |
| 72 | + assert by_id["t7"]["is_undetermined_type"] is True | |
| 73 | + # nothing is deleted | |
| 74 | + assert df.height == tx.height | |
| 75 | + | |
| 76 | + | |
| 77 | +def test_features_age_bins_and_imputation(): | |
| 78 | + tx, geo = synthetic() | |
| 79 | + feat = build_features(add_flags(tx, geo)) | |
| 80 | + by_id = {r["id"]: r for r in feat.iter_rows(named=True)} | |
| 81 | + assert by_id["t0"]["age_bin"] == "04_21-40" # age 33 | |
| 82 | + # missing yearBuilt -> conditional-median age imputation, NO missing bin | |
| 83 | + # (time-correlated missingness rule — see features.py docstring) | |
| 84 | + assert by_id["t5"]["yb_missing"] is True | |
| 85 | + assert by_id["t5"]["age_filled"] is not None | |
| 86 | + assert by_id["t5"]["age_bin"] != "99_missing" | |
| 87 | + assert by_id["t5"]["fa_missing"] is True | |
| 88 | + assert by_id["t5"]["floor_area_filled"] is not None # imputed | |
| 89 | + assert by_id["t0"]["loc_fine"] == "G1A" | |
added
engine/tests/test_smoke_pipeline.py
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/tests/test_smoke_pipeline.py | |
| 6 | +# Purpose : CI smoke test — synthetic market through stage 1 + stage 2; | |
| 7 | +# the recovered index must track the simulated price path. | |
| 8 | +# ============================================================================= | |
| 9 | +"""End-to-end smoke test on a fully synthetic market (no data files).""" | |
| 10 | + | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import datetime as dt | |
| 14 | +import sys | |
| 15 | +from pathlib import Path | |
| 16 | + | |
| 17 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 18 | + | |
| 19 | +import numpy as np | |
| 20 | +import polars as pl | |
| 21 | + | |
| 22 | +from qwhpi.hierarchy import cell_deviation, stage1_residualize, week_grid | |
| 23 | + | |
| 24 | +RNG = np.random.default_rng(3) | |
| 25 | + | |
| 26 | + | |
| 27 | +def simulate(n_weeks: int = 60, per_week: int = 150) -> tuple[pl.DataFrame, np.ndarray]: | |
| 28 | + """Synthetic province with a known weekly log price path.""" | |
| 29 | + path = np.cumsum(RNG.normal(0.005, 0.003, n_weeks)) # clear boom | |
| 30 | + rows = [] | |
| 31 | + start = dt.date(2021, 1, 4) | |
| 32 | + for w in range(n_weeks): | |
| 33 | + week = start + dt.timedelta(weeks=w) | |
| 34 | + for i in range(per_week): | |
| 35 | + fa = float(np.exp(RNG.normal(4.7, 0.35))) | |
| 36 | + age_yrs = int(RNG.integers(0, 90)) | |
| 37 | + ptype = ["unifamilial", "condo", "plex"][int(RNG.integers(0, 3))] | |
| 38 | + base = 12.0 + 0.6 * (np.log(fa) - 4.7) - 0.002 * age_yrs \ | |
| 39 | + + {"unifamilial": 0.0, "condo": -0.15, "plex": 0.2}[ptype] | |
| 40 | + lp = base + path[w] + RNG.normal(0, 0.25) | |
| 41 | + rows.append({ | |
| 42 | + "id": f"{w}-{i}", | |
| 43 | + "log_amount": lp, | |
| 44 | + "week_str": week.isoformat(), | |
| 45 | + "propertyType": ptype, | |
| 46 | + "log_fa": np.log(fa), | |
| 47 | + "fa_missing": False, | |
| 48 | + "age_bin": f"bin{age_yrs // 20}", | |
| 49 | + "building_type": "single-story", | |
| 50 | + "loc_fine": f"Z{int(RNG.integers(0, 6))}", | |
| 51 | + "region_code": "03" if RNG.random() < 0.5 else "06", | |
| 52 | + "geo_code": "23027", | |
| 53 | + "municipality": "Québec", | |
| 54 | + }) | |
| 55 | + return pl.DataFrame(rows), path | |
| 56 | + | |
| 57 | + | |
| 58 | +def test_pipeline_recovers_simulated_path(): | |
| 59 | + df, truth = simulate() | |
| 60 | + s1 = stage1_residualize(df) | |
| 61 | + # province path per type ~ truth (up to a constant) | |
| 62 | + uni = s1.paths.filter(pl.col("property_type") == "unifamilial").sort("week") | |
| 63 | + est = uni["delta"].to_numpy() | |
| 64 | + est = est - est.mean() | |
| 65 | + tr = truth[-len(est):] - truth[-len(est):].mean() | |
| 66 | + corr = np.corrcoef(est, tr)[0, 1] | |
| 67 | + assert corr > 0.9, f"stage-1 path corr {corr:.3f}" | |
| 68 | + | |
| 69 | + # stage 2: a region with no true deviation should stay near zero | |
| 70 | + grid = week_grid(s1.residuals) | |
| 71 | + cell = s1.residuals.filter( | |
| 72 | + (pl.col("region_code") == "03") | |
| 73 | + & (pl.col("propertyType") == "unifamilial")).rename({"resid": "dev"}) | |
| 74 | + dev = cell_deviation(cell.select("week_str", "dev"), grid, | |
| 75 | + float(cell["dev"].var())) | |
| 76 | + assert np.abs(dev.fit.smoothed).max() < 0.05 | |
added
engine/tests/test_state_space.py
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : engine/tests/test_state_space.py | |
| 6 | +# Purpose : Unit tests for the heteroskedastic local-level Kalman engine. | |
| 7 | +# ============================================================================= | |
| 8 | +"""State-space unit tests.""" | |
| 9 | + | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import sys | |
| 13 | +from pathlib import Path | |
| 14 | + | |
| 15 | +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) | |
| 16 | + | |
| 17 | +import numpy as np | |
| 18 | + | |
| 19 | +from qwhpi.state_space import fit_local_level | |
| 20 | + | |
| 21 | +RNG = np.random.default_rng(0) | |
| 22 | + | |
| 23 | + | |
| 24 | +def test_recovers_constant_signal(): | |
| 25 | + T, n = 150, 30 | |
| 26 | + y = 0.05 + RNG.normal(0, 0.2 / np.sqrt(n), T) | |
| 27 | + fit = fit_local_level(y, np.full(T, n), sigma2=0.04) | |
| 28 | + assert abs(fit.smoothed[T // 2] - 0.05) < 0.02 | |
| 29 | + assert fit.tau2 < 1e-4 # constant state -> tiny innovation variance | |
| 30 | + | |
| 31 | + | |
| 32 | +def test_tracks_drifting_signal(): | |
| 33 | + T, n = 200, 50 | |
| 34 | + truth = np.cumsum(RNG.normal(0, 0.01, T)) | |
| 35 | + y = truth + RNG.normal(0, 0.3 / np.sqrt(n), T) | |
| 36 | + fit = fit_local_level(y, np.full(T, n), sigma2=0.09) | |
| 37 | + rmse = np.sqrt(np.mean((fit.smoothed - truth) ** 2)) | |
| 38 | + assert rmse < 0.02 | |
| 39 | + | |
| 40 | + | |
| 41 | +def test_empty_weeks_are_predicted_not_dropped(): | |
| 42 | + T = 100 | |
| 43 | + n = np.full(T, 20.0) | |
| 44 | + n[40:60] = 0 # 20-week gap | |
| 45 | + truth = np.cumsum(RNG.normal(0, 0.02, T)) # real innovation -> tau2 > 0 | |
| 46 | + y = np.where(n > 0, truth + RNG.normal(0, 0.05, T), np.nan) | |
| 47 | + fit = fit_local_level(y, n, sigma2=0.05) | |
| 48 | + # gap weeks still get an estimate; uncertainty grows through the gap | |
| 49 | + assert np.all(np.isfinite(fit.smoothed)) | |
| 50 | + assert fit.filtered_var[59] > fit.filtered_var[41] > fit.filtered_var[39] | |
| 51 | + assert fit.gain[50] == 0.0 # no local data -> fully parent/past-driven | |
| 52 | + | |
| 53 | + | |
| 54 | +def test_thin_weeks_shrink_more_than_liquid_weeks(): | |
| 55 | + T = 120 | |
| 56 | + n = np.full(T, 2.0) | |
| 57 | + n[:60] = 200.0 | |
| 58 | + y = RNG.normal(0, 0.05, T) | |
| 59 | + fit = fit_local_level(y, n, sigma2=0.09) | |
| 60 | + assert fit.gain[:60].mean() > fit.gain[60:].mean() | |
added
ops/ci/ci.yml
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : ops/ci/ci.yml | |
| 6 | +# Purpose : CI pipeline — headers, lint, type-check, tests, smoke pipeline. | |
| 7 | +# (Symlink/copy to .github/workflows/ci.yml when hosted on GitHub.) | |
| 8 | +# ============================================================================= | |
| 9 | + | |
| 10 | +name: qwhpi-ci | |
| 11 | +on: [push, pull_request] | |
| 12 | + | |
| 13 | +jobs: | |
| 14 | + checks: | |
| 15 | + runs-on: ubuntu-latest | |
| 16 | + steps: | |
| 17 | + - uses: actions/checkout@v4 | |
| 18 | + - uses: actions/setup-python@v5 | |
| 19 | + with: { python-version: "3.12" } | |
| 20 | + - uses: actions/setup-node@v4 | |
| 21 | + with: { node-version: "22" } | |
| 22 | + | |
| 23 | + - name: Install engine + api deps | |
| 24 | + run: | | |
| 25 | + pip install polars pandas numpy pyarrow scipy statsmodels pyfixest \ | |
| 26 | + fastapi uvicorn pydantic pytest httpx ruff mypy | |
| 27 | + | |
| 28 | + - name: Header check (mandatory) | |
| 29 | + run: python scripts/check_headers.py | |
| 30 | + | |
| 31 | + - name: Ruff lint | |
| 32 | + run: ruff check engine api scripts ops | |
| 33 | + | |
| 34 | + - name: Mypy (engine core) | |
| 35 | + run: mypy engine/src/qwhpi --ignore-missing-imports || true | |
| 36 | + | |
| 37 | + - name: Engine unit tests | |
| 38 | + run: cd engine && python -m pytest tests -q | |
| 39 | + | |
| 40 | + - name: Smoke pipeline (synthetic small sample) | |
| 41 | + run: cd engine && python -m pytest tests/test_smoke_pipeline.py -q | |
| 42 | + | |
| 43 | + - name: TypeScript check | |
| 44 | + run: cd web && npm ci && npx tsc --noEmit | |
added
ops/scheduler/Dockerfile
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : ops/scheduler/Dockerfile | |
| 6 | +# Purpose : Container image for the weekly refresh scheduler. | |
| 7 | +# ============================================================================= | |
| 8 | +FROM python:3.12-slim | |
| 9 | +RUN pip install --no-cache-dir polars numpy pyarrow scipy pandas pyfixest \ | |
| 10 | + "psycopg[binary]" schedule | |
| 11 | +WORKDIR /workspace | |
| 12 | +COPY refresh.py /opt/refresh.py | |
| 13 | +CMD ["python", "/opt/refresh.py"] | |
added
ops/scheduler/refresh.py
+90 −0
@@ -0,0 +1,90 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : ops/scheduler/refresh.py | |
| 7 | +# Purpose : Weekly refresh job — re-run the incremental pipeline every Monday | |
| 8 | +# 06:00, write a run manifest, load the DB, log structured events. | |
| 9 | +# ============================================================================= | |
| 10 | +"""Weekly refresh scheduler. | |
| 11 | + | |
| 12 | +Every Monday 06:00 (container time): ingest new rows → re-clean (append-only) | |
| 13 | +→ re-estimate monthly indexes → new data_vintage → canonical rebuild → DB load. | |
| 14 | +A JSON run manifest (input hash, row counts, timings) is written to | |
| 15 | +``ops/scheduler/runs/``. The API's parquet cache invalidates automatically on | |
| 16 | +file mtime; ETag values include the data vintage, so HTTP caches roll over. | |
| 17 | +""" | |
| 18 | + | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import json | |
| 22 | +import logging | |
| 23 | +import subprocess | |
| 24 | +import sys | |
| 25 | +import time | |
| 26 | +from datetime import datetime, timezone | |
| 27 | +from pathlib import Path | |
| 28 | + | |
| 29 | +import schedule | |
| 30 | + | |
| 31 | +WORKSPACE = Path("/workspace") | |
| 32 | +RUNS_DIR = WORKSPACE / "ops" / "scheduler" / "runs" | |
| 33 | + | |
| 34 | +logging.basicConfig( | |
| 35 | + level=logging.INFO, | |
| 36 | + format='{"ts":"%(asctime)s","level":"%(levelname)s","msg":"%(message)s"}', | |
| 37 | +) | |
| 38 | +log = logging.getLogger("qwhpi.scheduler") | |
| 39 | + | |
| 40 | +STEPS = [ | |
| 41 | + "engine/scripts/03_clean.py", | |
| 42 | + "engine/scripts/09_monthly.py", | |
| 43 | + "engine/scripts/11_monthly_canonical.py", | |
| 44 | +] | |
| 45 | + | |
| 46 | + | |
| 47 | +def run_refresh() -> None: | |
| 48 | + started = time.time() | |
| 49 | + manifest: dict = {"started_at": datetime.now(timezone.utc).isoformat(), | |
| 50 | + "steps": []} | |
| 51 | + ok = True | |
| 52 | + for step in STEPS: | |
| 53 | + t0 = time.time() | |
| 54 | + proc = subprocess.run([sys.executable, str(WORKSPACE / step)], | |
| 55 | + cwd=WORKSPACE, capture_output=True, text=True) | |
| 56 | + manifest["steps"].append({ | |
| 57 | + "step": step, | |
| 58 | + "seconds": round(time.time() - t0, 1), | |
| 59 | + "returncode": proc.returncode, | |
| 60 | + }) | |
| 61 | + if proc.returncode != 0: | |
| 62 | + log.error("step %s failed: %s", step, proc.stderr[-2000:]) | |
| 63 | + ok = False | |
| 64 | + break | |
| 65 | + log.info("step %s ok (%.0fs)", step, time.time() - t0) | |
| 66 | + | |
| 67 | + if ok: | |
| 68 | + sys.path.insert(0, str(WORKSPACE / "engine" / "src")) | |
| 69 | + try: | |
| 70 | + from qwhpi.export import input_hash, load_database | |
| 71 | + manifest["input_hash"] = input_hash() | |
| 72 | + manifest["db_loaded"] = load_database() | |
| 73 | + except Exception as exc: # noqa: BLE001 | |
| 74 | + log.error("db load failed: %s", exc) | |
| 75 | + manifest["db_loaded"] = False | |
| 76 | + | |
| 77 | + manifest["ok"] = ok | |
| 78 | + manifest["total_seconds"] = round(time.time() - started, 1) | |
| 79 | + RUNS_DIR.mkdir(parents=True, exist_ok=True) | |
| 80 | + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") | |
| 81 | + (RUNS_DIR / f"run_{stamp}.json").write_text(json.dumps(manifest, indent=2)) | |
| 82 | + log.info("refresh finished ok=%s in %.0fs", ok, manifest["total_seconds"]) | |
| 83 | + | |
| 84 | + | |
| 85 | +if __name__ == "__main__": | |
| 86 | + log.info("scheduler up — weekly refresh Mondays 06:00") | |
| 87 | + schedule.every().monday.at("06:00").do(run_refresh) | |
| 88 | + while True: | |
| 89 | + schedule.run_pending() | |
| 90 | + time.sleep(60) | |
added
outputs/figures/assessment_gap.png
+0 −0
Binary file not shown.
added
outputs/figures/big4_cities.png
+0 −0
Binary file not shown.
added
outputs/figures/condo_by_city.png
+0 −0
Binary file not shown.
added
outputs/figures/downsampling_stability.png
+0 −0
Binary file not shown.
added
outputs/figures/quebec_aggregate.png
+0 −0
Binary file not shown.
added
outputs/figures/raw_median_vs_hedonic.png
+0 −0
Binary file not shown.
added
outputs/figures/region_comparison.png
+0 −0
Binary file not shown.
added
outputs/figures/reliability_matrix.png
+0 −0
Binary file not shown.
added
outputs/figures/volumes.png
+0 −0
Binary file not shown.
added
outputs/figures/yoy_map.png
+0 −0
Binary file not shown.
added
outputs/reports/audit_report.md
+127 −0
@@ -0,0 +1,127 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +Author : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +File : outputs/reports/audit_report.md | |
| 7 | +Purpose : Step 1 data audit report (generated by engine/scripts/01_profile.py) | |
| 8 | +============================================================================= | |
| 9 | +--> | |
| 10 | + | |
| 11 | +# QWHPI Data Audit Report | |
| 12 | + | |
| 13 | +Author: Simon-Pierre Boucher — contact@spboucher.ai | |
| 14 | + | |
| 15 | +## 1. Schema and shape | |
| 16 | + | |
| 17 | +- Rows: 745,119 | |
| 18 | +- Columns: 17 | |
| 19 | +- Date range: 2021-01-04 -> 2026-07-27 | |
| 20 | +- Distinct Monday-labeled weeks with >=1 transaction: 291 | |
| 21 | +- Distinct cities (raw text field): 1,100 | |
| 22 | +- Distinct ids: 745,119 (duplicated ids: 0) | |
| 23 | + | |
| 24 | + -> table saved: outputs/tables/audit_schema.csv (17 rows) | |
| 25 | + | |
| 26 | +## 2. Missingness | |
| 27 | + | |
| 28 | + -> table saved: outputs/tables/audit_missingness.csv (17 rows) | |
| 29 | +- `buildingType`: 283,206 missing (38.01%) | |
| 30 | +- `ownerType`: 224,999 missing (30.2%) | |
| 31 | +- `yearBuilt`: 137,315 missing (18.43%) | |
| 32 | +- `floorArea`: 115,153 missing (15.45%) | |
| 33 | +- `previousValue`: 78,581 missing (10.55%) | |
| 34 | +- `zipCode`: 75,369 missing (10.12%) | |
| 35 | +- `totalArValue`: 63,160 missing (8.48%) | |
| 36 | +- `street`: 6,688 missing (0.9%) | |
| 37 | + | |
| 38 | +Sentinel / degenerate values (not encoded as null): | |
| 39 | + -> table saved: outputs/tables/audit_sentinel_values.csv (11 rows) | |
| 40 | +- amount <= 0: 0 | |
| 41 | +- amount in (1, 2): 0 | |
| 42 | +- amount < 5000: 0 | |
| 43 | +- floorArea <= 0: 0 | |
| 44 | +- floorArea > 2000 m2: 375 | |
| 45 | +- yearBuilt < 1600: 0 | |
| 46 | +- yearBuilt > 2026: 0 | |
| 47 | +- yearBuilt == 0: 0 | |
| 48 | +- previousValue <= 0: 1,640 | |
| 49 | +- totalArValue <= 0: 17 | |
| 50 | +- lat/lng outside Quebec bbox (44.9-62.7N, -79.8--57W): 0 | |
| 51 | + | |
| 52 | +## 3. Categorical distributions | |
| 53 | + | |
| 54 | +### propertyType | |
| 55 | + -> table saved: outputs/tables/audit_dist_propertyType.csv (4 rows) | |
| 56 | +- unifamilial: 443,078 (59.46%) | |
| 57 | +- indéterminé: 107,656 (14.45%) | |
| 58 | +- condo: 100,218 (13.45%) | |
| 59 | +- plex: 94,167 (12.64%) | |
| 60 | + | |
| 61 | +### buildingType | |
| 62 | + -> table saved: outputs/tables/audit_dist_buildingType.csv (6 rows) | |
| 63 | +- None: 283,206 (38.01%) | |
| 64 | +- single-story: 205,924 (27.64%) | |
| 65 | +- full-story: 188,584 (25.31%) | |
| 66 | +- mansard: 52,808 (7.09%) | |
| 67 | +- split-level: 8,743 (1.17%) | |
| 68 | +- single-wide: 5,854 (0.79%) | |
| 69 | + | |
| 70 | +### ownerType | |
| 71 | + -> table saved: outputs/tables/audit_dist_ownerType.csv (4 rows) | |
| 72 | +- physical_person: 496,404 (66.62%) | |
| 73 | +- None: 224,999 (30.2%) | |
| 74 | +- legal_person: 22,351 (3.0%) | |
| 75 | +- mixed: 1,365 (0.18%) | |
| 76 | + | |
| 77 | +## 4. Amount descriptives by propertyType (amount > 0) | |
| 78 | + | |
| 79 | + -> table saved: outputs/tables/audit_amount_by_type.csv (4 rows) | |
| 80 | +- unifamilial: n=443,078 | p01=$60,000 | median=$378,000 | p99=$1,635,000 | max=$345,000,000 | |
| 81 | +- indéterminé: n=107,656 | p01=$52,185 | median=$319,252 | p99=$1,400,000 | max=$75,000,000 | |
| 82 | +- condo: n=100,218 | p01=$103,500 | median=$360,000 | p99=$1,300,000 | max=$18,750,000 | |
| 83 | +- plex: n=94,167 | p01=$75,000 | median=$470,577 | p99=$1,710,000 | max=$42,000,000 | |
| 84 | + -> table saved: outputs/tables/audit_characteristics_by_type.csv (4 rows) | |
| 85 | + | |
| 86 | +## 5. Duplicate scan | |
| 87 | + | |
| 88 | +- Exact duplicates ignoring id: 665 | |
| 89 | +- Same street+city+date (candidate multi-unit or true dupes): 10,925 | |
| 90 | +- Same street+city+date+amount (strong duplicate candidates): 784 | |
| 91 | +- Addresses (street+city) with >=2 distinct sale dates: 87,787 | |
| 92 | + | |
| 93 | +## 6. Weekly coverage and liquidity | |
| 94 | + | |
| 95 | +- Continuous Monday grid: 291 weeks (2021-01-04 -> 2026-07-27) | |
| 96 | +- Zero-transaction weeks on the grid: 0 | |
| 97 | +- Median tx/week (province): 2,351 | |
| 98 | +- Min tx/week: 35 | Max: 7,845 | |
| 99 | +- Last 6 weeks (partial-week check): 2026-06-22=4226, 2026-06-29=3581, 2026-07-06=2934, 2026-07-13=2973, 2026-07-20=2197, 2026-07-27=318 | |
| 100 | + -> table saved: outputs/tables/audit_weekly_volume_province.csv (291 rows) | |
| 101 | + -> table saved: outputs/tables/audit_weekly_liquidity_topcities.csv (100 rows) | |
| 102 | + -> table saved: outputs/tables/audit_liquidity_matrix.csv (25 rows) | |
| 103 | + | |
| 104 | +Median weekly transactions, top cities (rows) x type (cols): see audit_liquidity_matrix.csv | |
| 105 | +- {'city': 'Québec', 'condo': 41.5, 'indéterminé': 6.0, 'plex': 21.0, 'unifamilial': 66.0} | |
| 106 | +- {'city': 'Montréal', 'condo': 145.0, 'indéterminé': 39.0, 'plex': 80.0, 'unifamilial': 64.0} | |
| 107 | +- {'city': 'Gatineau', 'condo': 7.0, 'indéterminé': 12.0, 'plex': 12.0, 'unifamilial': 54.0} | |
| 108 | +- {'city': 'Laval', 'condo': 21.0, 'indéterminé': 8.0, 'plex': 8.0, 'unifamilial': 54.0} | |
| 109 | +- {'city': 'Sherbrooke', 'condo': 2.0, 'indéterminé': 6.0, 'plex': 7.0, 'unifamilial': 29.0} | |
| 110 | +- {'city': 'Longueuil', 'condo': 15.0, 'indéterminé': 10.0, 'plex': 9.0, 'unifamilial': 28.0} | |
| 111 | +- {'city': 'Lévis', 'condo': 4.0, 'indéterminé': 6.0, 'plex': 5.0, 'unifamilial': 26.0} | |
| 112 | +- {'city': 'Saguenay', 'condo': 2.0, 'indéterminé': 4.0, 'plex': 8.0, 'unifamilial': 26.0} | |
| 113 | +- {'city': 'Trois-Rivières', 'condo': 2.0, 'indéterminé': 5.0, 'plex': 8.0, 'unifamilial': 23.0} | |
| 114 | +- {'city': 'Terrebonne', 'condo': 3.0, 'indéterminé': 6.0, 'plex': 3.0, 'unifamilial': 22.0} | |
| 115 | +- {'city': 'Saint-Jean-sur-Richelieu', 'condo': 2.0, 'indéterminé': 4.0, 'plex': 3.0, 'unifamilial': 18.0} | |
| 116 | +- {'city': 'Drummondville', 'condo': 1.0, 'indéterminé': 3.0, 'plex': 4.0, 'unifamilial': 16.0} | |
| 117 | + | |
| 118 | +- Top 50 city strings cover 61.4% of transactions | |
| 119 | + -> table saved: outputs/tables/audit_city_top200.csv (200 rows) | |
| 120 | + | |
| 121 | +## 7. `indéterminé` property type — preliminary look | |
| 122 | + | |
| 123 | +- Count: 107,656 (14.4%) | |
| 124 | +- Median amount: $319,252 (vs unifamilial $378,000) | |
| 125 | +- floorArea missing: 95.6% | yearBuilt missing: 95.2% | buildingType missing: 97.9% | |
| 126 | +- Top buildingType values within indéterminé: None=105,377, single-story=1,089, mansard=601, full-story=557, single-wide=26, split-level=6 | |
| 127 | +- ownerType within indéterminé: None=73,911, physical_person=20,620, legal_person=12,657, mixed=468 | |
added
outputs/reports/cleaning_report.md
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +Author : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +File : outputs/reports/cleaning_report.md | |
| 7 | +Purpose : Step 3 cleaning report (generated by engine/scripts/03_clean.py) | |
| 8 | +============================================================================= | |
| 9 | +--> | |
| 10 | + | |
| 11 | +## Cleaning report | |
| 12 | + | |
| 13 | +- Input rows: 745,119 (all retained; exclusions are flags) | |
| 14 | +- Screens: ratio band [0.2, 5.0] on amount/totalArValue; price ceiling $10,000,000; duplicate flags; floorArea sanitation [20.0, 2000.0] m². | |
| 15 | +- Provider pre-filter verified: no transaction below $50,000 exists in the raw file. | |
| 16 | + | |
| 17 | +### Exclusion table (outputs/tables/exclusions.csv) | |
| 18 | +- assessment_ratio_outside_band: 24,976 (3.352%) | |
| 19 | +- exact_duplicate: 665 (0.089%) | |
| 20 | +- addr_date_amount_duplicate: 107 (0.014%) | |
| 21 | +- price_above_10m: 46 (0.006%) | |
| 22 | +- TOTAL_EXCLUDED: 25,794 (3.462%) | |
| 23 | +- RETAINED_IN_RESEARCH_SAMPLE: 719,325 (96.538%) | |
| 24 | + | |
| 25 | +### Characteristic sanitation (row retained, value nulled) | |
| 26 | +- floorArea outside [20.0, 2000.0] m²: 576 | |
| 27 | +- yearBuilt > sale year + 1: 19 | |
| 28 | + | |
| 29 | +### Research sample | |
| 30 | +- Clean rows incl. indéterminé: 719,325 | |
| 31 | +- Headline sample (3 determined types): 626,127 (84.03% of raw) | |
| 32 | + - unifamilial: 439,118 | |
| 33 | + - condo: 94,843 | |
| 34 | + - plex: 92,166 | |
| 35 | +- Weeks with >=1 research-sample transaction: 291 / 291 | |
| 36 | +- Median weekly research-sample volume: 1,999 | |
| 37 | + | |
| 38 | +### Missingness in headline research sample | |
| 39 | +- floorArea: 1.85% | |
| 40 | +- yearBuilt: 5.4% | |
| 41 | +- buildingType: 27.61% | |
| 42 | +- ownerType: 23.7% | |
added
outputs/reports/geography_report.md
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +Author : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +File : outputs/reports/geography_report.md | |
| 7 | +Purpose : Step 2 geography join + validation report (from 02_geography.py) | |
| 8 | +============================================================================= | |
| 9 | +--> | |
| 10 | + | |
| 11 | +## Geography spatial join (SDA 1/20k, version V2026-07) | |
| 12 | + | |
| 13 | +- Join computed/loaded in 2.1s for 745,119 transactions | |
| 14 | +- join_method=within: 745,119 (100.000%) | |
| 15 | +- Distinct regions matched: 17 | municipalities: 1,125 | |
| 16 | + | |
| 17 | +Transactions by administrative region: | |
| 18 | +- 16 Montérégie: 134,113 | |
| 19 | +- 06 Montréal: 116,682 | |
| 20 | +- 15 Laurentides: 77,374 | |
| 21 | +- 03 Capitale-Nationale: 73,093 | |
| 22 | +- 14 Lanaudière: 57,064 | |
| 23 | +- 05 Estrie: 52,133 | |
| 24 | +- 07 Outaouais: 44,087 | |
| 25 | +- 12 Chaudière-Appalaches: 42,729 | |
| 26 | +- 13 Laval: 28,651 | |
| 27 | +- 02 Saguenay–Lac-Saint-Jean: 26,725 | |
| 28 | +- 04 Mauricie: 26,218 | |
| 29 | +- 17 Centre-du-Québec: 22,995 | |
| 30 | +- 01 Bas-Saint-Laurent: 17,245 | |
| 31 | +- 08 Abitibi-Témiscamingue: 12,273 | |
| 32 | +- 11 Gaspésie–Îles-de-la-Madeleine: 6,452 | |
| 33 | +- 09 Côte-Nord: 5,911 | |
| 34 | +- 10 Nord-du-Québec: 1,374 | |
| 35 | + | |
| 36 | +### Validation: joined municipality vs raw `city` text | |
| 37 | +- Name agreement (normalized, containment-tolerant): 99.92% | |
| 38 | +- Mismatching pairs saved: outputs/tables/geo_city_mismatch_top300.csv (7 distinct pairs, 618 transactions) | |
| 39 | + | |
| 40 | +Top 15 mismatch pairs (city text -> joined municipality): | |
| 41 | +- 'Saint-Bruno' -> 'Hébertville' (Saguenay–Lac-Saint-Jean): 292 | |
| 42 | +- 'Courcelles-Saint-Évariste' -> 'Courcelles–Saint-Évariste' (Chaudière-Appalaches): 140 | |
| 43 | +- 'Saint-Félix-de-Dalquier' -> 'Amos' (Abitibi-Témiscamingue): 95 | |
| 44 | +- 'Saint-Onésime-d'Ixworth' -> 'La Pocatière' (Bas-Saint-Laurent): 61 | |
| 45 | +- 'Passes-Dangereuses' -> 'Sainte-Élisabeth-de-Proulx' (Saguenay–Lac-Saint-Jean): 16 | |
| 46 | +- 'Saint-Guy' -> 'Lac-des-Aigles' (Bas-Saint-Laurent): 13 | |
| 47 | +- 'TNO aquatique de la MRC d'Argenteuil' -> 'Grenville-sur-la-Rouge' (Laurentides): 1 | |
added
outputs/reports/hierarchical_report.md
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +Author : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +File : outputs/reports/hierarchical_report.md | |
| 7 | +Purpose : Step 5 — hierarchical weekly model: architecture, benchmarks, | |
| 8 | + and the pooled-β vs city-β arbitration by repeat sales. | |
| 9 | +============================================================================= | |
| 10 | +--> | |
| 11 | + | |
| 12 | +# Hierarchical weekly model — design record | |
| 13 | + | |
| 14 | +## Architecture chosen (two-stage) | |
| 15 | + | |
| 16 | +1. **Stage 1 — pooled residualization** (pyfixest, n=625,882, R²=0.605, ~3s): | |
| 17 | + `log(P) ~ log(floorArea) + fa_missing + age bins + building type | FSA FE + (week × type) FE`. | |
| 18 | + The absorbed week×type effects are the province-level weekly paths per | |
| 19 | + property type; residuals are quality- and location-adjusted deviations. | |
| 20 | +2. **Stage 2 — heteroskedastic local-level Kalman** per cell: | |
| 21 | + weekly mean residual observed with variance σ²/n_t; latent deviation is a | |
| 22 | + random walk with innovation τ² estimated by MLE per cell. Region cells | |
| 23 | + deviate from the province path; municipality cells deviate from the | |
| 24 | + smoothed region path. Weeks with zero transactions are pure predictions — | |
| 25 | + the grid is never dropped. | |
| 26 | + | |
| 27 | +Published variants: `index` (raw weekly), `index_smoothed` (one-sided filter, | |
| 28 | +real-time), `index_research` (two-sided smoother). All three share one | |
| 29 | +rebasing constant (2021 avg = 100), so they are directly comparable. | |
| 30 | + | |
| 31 | +## Shrinkage behaviour (outputs/tables/hierarchical_summary.csv) | |
| 32 | + | |
| 33 | +- Trois-Rivières condo (median 1 tx/week): raw weekly SD 30.4% → smoothed 4.2%. | |
| 34 | +- Drummondville condo (median 0 tx/week): still produces a continuous path, | |
| 35 | + ~98% parent-driven (shrinkage weight). | |
| 36 | +- Liquid cells (province types, ~300–1,500 tx/week) are data-dominated. | |
| 37 | + | |
| 38 | +## Benchmark: two-stage vs unified city-specific time-dummy (Step 4 baseline) | |
| 39 | + | |
| 40 | +Liquid province cells agree almost exactly (corr 0.9996, Québec unifamilial). | |
| 41 | +Montréal condo exposed a genuine specification fork: by July 2026 the | |
| 42 | +two-stage index reads 126.9 vs 118.6 for the city-β baseline. | |
| 43 | + | |
| 44 | +**Arbitration by pseudo repeat-sales** (BMN, yearly dummies; condo pairs | |
| 45 | +matched on street + floorArea + yearBuilt because condo addresses carry no | |
| 46 | +unit numbers — 6,079 Montréal condo pairs, ≥90 days between sales): | |
| 47 | + | |
| 48 | +| Measure (2026 level, 2021=100) | Montréal condo | Province condo | Relative | | |
| 49 | +|--------------------------------------------|---------------:|---------------:|---------:| | |
| 50 | +| Pseudo repeat-sales (composition-free) | 120.4 | 132.0 | −8.8% | | |
| 51 | +| Two-stage hierarchical (pooled β) | 126.9 | 138.2 | −8.2% | | |
| 52 | +| City-specific time-dummy baseline | 118.6 | ~139 | −14% | | |
| 53 | + | |
| 54 | +The two-stage model reproduces the composition-free *relative* path almost | |
| 55 | +exactly; the city-β baseline overstates Montréal's divergence. Hedonic levels | |
| 56 | +sit a few percent above repeat-sales, the standard direction (depreciation / | |
| 57 | +renovation bias in repeat pairs). **Decision: the two-stage hierarchical | |
| 58 | +model is the headline architecture.** The unified per-cell time-dummy remains | |
| 59 | +as a robustness spec. | |
| 60 | + | |
| 61 | +## Compute cost | |
| 62 | + | |
| 63 | +Full stage 1 + 112 cells of stage 2 run in under one minute on a laptop — | |
| 64 | +weekly refresh is trivially cheap. A Bayesian multilevel variant (PyMC) was | |
| 65 | +rejected for the headline: equivalent shrinkage behaviour at orders of | |
| 66 | +magnitude more compute, and harder vintage reproducibility. | |
| 67 | + | |
| 68 | +## Known caveats (tracked) | |
| 69 | + | |
| 70 | +- `se_log` combines the province-path delta-method variance and the Kalman | |
| 71 | + posterior variance; block-bootstrap validation of CI coverage is part of | |
| 72 | + Step 6. | |
| 73 | +- The Kalman gain-based `shrinkage_weight` measures how much of a week's | |
| 74 | + *movement* comes from the parent path; reliability grades therefore weigh | |
| 75 | + n / effective N / SE primarily (Step 7). | |
| 76 | +- Montréal plex R² is low (0.20) in any spec — unobserved revenue | |
| 77 | + characteristics; reliability grading must reflect this. | |
| 78 | + | |
| 79 | +Author: Simon-Pierre Boucher — contact@spboucher.ai | |
added
outputs/reports/methodology_v2_research.md
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +Author : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +File : outputs/reports/methodology_v2_research.md | |
| 7 | +Purpose : Web-researched design record for the v2 (monthly, robust) index. | |
| 8 | +============================================================================= | |
| 9 | +--> | |
| 10 | + | |
| 11 | +# QHPI v2 — monthly, robust: research record and design decisions | |
| 12 | + | |
| 13 | +## Why revisit v1 | |
| 14 | + | |
| 15 | +The v1 weekly index estimated ONE pooled hedonic over 2021–2026 (fixed | |
| 16 | +coefficients for 5.5 years) and published weekly points whose thin-cell noise | |
| 17 | +had to be repaired by heavy shrinkage. Two structural weaknesses: | |
| 18 | + | |
| 19 | +1. **Fixed hedonic coefficients over the full pool.** The implicit prices of | |
| 20 | + floor area, age and location are not constant across a boom, a rate-hike | |
| 21 | + correction and a recovery. The literature flags the fixed-parameter | |
| 22 | + restriction as the main drawback of the full-pool time-dummy method. | |
| 23 | +2. **Full-sample estimation revises history on every run** — each refresh | |
| 24 | + re-estimates every β, so past index points move. | |
| 25 | + | |
| 26 | +## What the literature recommends | |
| 27 | + | |
| 28 | +- **Rolling-Time-Dummy (RTD)**: estimate the time-dummy hedonic on a rolling | |
| 29 | + window of recent periods only, then link (splice) each new estimate onto | |
| 30 | + the existing series. Used by official HPIs; requires less data, adapts to | |
| 31 | + parameter drift, and is "well suited for computing higher frequency HPIs". | |
| 32 | + Hill, Scholz, Shimizu & Steurer (2022, *Journal of Official Statistics* | |
| 33 | + 38(1):127–151) treat the three design choices: window length, linking | |
| 34 | + variant (movement / window / half / **mean splice**), and low-volume | |
| 35 | + robustness. | |
| 36 | +- **Chained/spliced time dummies produce a revision-free series** and relax | |
| 37 | + the fixed-parameter assumption (Eurostat/IMF/OECD *RPPI Handbook*, ch. 5). | |
| 38 | +- **Mean splice** (geometric mean over all feasible link positions) is the | |
| 39 | + robust default in the splicing literature (Diewert & Fox 2021 for | |
| 40 | + multilateral updating): no single anomalous overlap period drives the link. | |
| 41 | +- **Robust estimation**: OLS hedonics chase influential observations; | |
| 42 | + M-estimation (Huber weights) or documented influence-trimming is standard | |
| 43 | + practice for official filters (cf. IMF WP/16/213, MNB HPI outlier | |
| 44 | + procedures). | |
| 45 | +- **Monthly frequency**: weekly cells below the metro level are too thin for | |
| 46 | + publication-grade points without aggressive shrinkage; monthly multiplies | |
| 47 | + cell counts ~4.3× and is the modal frequency of official HPIs. | |
| 48 | + | |
| 49 | +Sources: | |
| 50 | +- Hill, Scholz, Shimizu & Steurer (2022) — https://journals.sagepub.com/doi/abs/10.2478/jos-2022-0007 | |
| 51 | +- Eurostat et al., Handbook on Residential Property Prices Indices — https://www.oecd.org/content/dam/oecd/en/publications/reports/2013/04/handbook-on-residential-property-price-indices_g1g2e251/9789264197183-en.pdf | |
| 52 | +- RPPI Handbook ch.5 (hedonic methods) — https://www.elibrary.imf.org/display/book/9789279259845/ch005.xml | |
| 53 | +- IMF WP/16/213, "How to Better Measure Hedonic RPPIs" — https://www.imf.org/external/pubs/ft/wp/2016/wp16213.pdf | |
| 54 | +- IMF RPPI Practical Guide — https://www.imf.org/-/media/files/data/guides/rppi/rppi-guide.pdf | |
| 55 | + | |
| 56 | +## v2 design (implemented) | |
| 57 | + | |
| 58 | +1. **Frequency: monthly** (labels `YYYY-MM`, continuous grid, trailing month | |
| 59 | + flagged partial while registrations arrive). | |
| 60 | +2. **Stage 1 — RTD, 13-month rolling windows, monthly step.** Within each | |
| 61 | + window: hedonic `log P ~ log_fa + fa_missing + age bins + building type | |
| 62 | + | FSA FE + (month × type) FE`, estimated by **iteratively reweighted least | |
| 63 | + squares with Huber weights** (k = 1.345 on MAD-standardized residuals, | |
| 64 | + 2 reweighting passes) — outliers are downweighted inside the regression | |
| 65 | + instead of silently trimmed. A 13-month window spans every calendar month | |
| 66 | + once (seasonal balance) and holds ~120k transactions. | |
| 67 | +3. **Linking: mean splice.** For each new window the province path per type | |
| 68 | + is extended by the geometric mean of the growth implied at every feasible | |
| 69 | + overlap position. Movement and window splices are computed as published | |
| 70 | + sensitivity diagnostics. The published history never revises. | |
| 71 | +4. **Stage 2 — unchanged hierarchy, monthly grid.** Each transaction's | |
| 72 | + quality-adjusted residual comes from the window in which its month is the | |
| 73 | + NEWEST month (fully real-time, β local in time). Cell deviations | |
| 74 | + (region → municipality) follow the heteroskedastic local-level Kalman | |
| 75 | + model; thin months shrink toward the parent path. | |
| 76 | +5. **Growth horizons**: 1m, 3m, 6m, 12m (YoY). Reliability grades | |
| 77 | + recalibrated by a monthly downsampling experiment. | |
| 78 | +6. **New diagnostics**: hedonic coefficient drift across windows (the | |
| 79 | + empirical justification for RTD), RTD vs full-pool comparison, splice | |
| 80 | + variant sensitivity. | |
| 81 | +7. Cleaning screens, geography, `indéterminé` exclusion, fixed-share "all" | |
| 82 | + composition, assessment-gap module: unchanged from v1 (already validated). | |
| 83 | + | |
| 84 | +Author: Simon-Pierre Boucher — contact@spboucher.ai | |
added
outputs/reports/validation_report.md
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +Author : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +File : outputs/reports/validation_report.md | |
| 7 | +Purpose : Step 6 validation report (generated by 06_validation.py) | |
| 8 | +============================================================================= | |
| 9 | +--> | |
| 10 | + | |
| 11 | +# QWHPI validation report | |
| 12 | + | |
| 13 | +## 1. Repeat-sales (BMN) vs hedonic index | |
| 14 | + | |
| 15 | +- quebec/unifamilial: Δcorr=0.691, end levels RS=161.5 vs hedonic=157.9 | |
| 16 | +- quebec/condo: Δcorr=0.322, end levels RS=133.8 vs hedonic=140.0 | |
| 17 | +- quebec/plex: Δcorr=0.454, end levels RS=149.6 vs hedonic=158.5 | |
| 18 | +- montreal/condo: Δcorr=0.000, end levels RS=121.8 vs hedonic=128.7 | |
| 19 | +- montreal/unifamilial: Δcorr=0.264, end levels RS=125.6 vs hedonic=132.8 | |
| 20 | +- quebec-city/unifamilial: Δcorr=0.073, end levels RS=165.7 vs hedonic=166.0 | |
| 21 | + | |
| 22 | +## 2. Downsampling experiment — Montréal condo lab | |
| 23 | + | |
| 24 | +- 100 tx/wk: RMSE=0.317% bias=-0.046% vol_ratio=1.051 turn_agree=0.861 CI95_cov=1.0 | |
| 25 | +- 50 tx/wk: RMSE=0.636% bias=-0.177% vol_ratio=0.987 turn_agree=0.799 CI95_cov=0.992 | |
| 26 | +- 25 tx/wk: RMSE=0.799% bias=0.002% vol_ratio=0.762 turn_agree=0.771 CI95_cov=0.977 | |
| 27 | +- 15 tx/wk: RMSE=1.15% bias=-0.154% vol_ratio=0.449 turn_agree=0.778 CI95_cov=0.881 | |
| 28 | +- 10 tx/wk: RMSE=1.361% bias=-0.18% vol_ratio=0.358 turn_agree=0.735 CI95_cov=0.828 | |
| 29 | +- 5 tx/wk: RMSE=1.633% bias=0.189% vol_ratio=0.457 turn_agree=0.685 CI95_cov=0.856 | |
| 30 | + | |
| 31 | +Reading: RMSE vs the full-sample (~145 tx/wk) smoothed path; CI coverage of the deviation posterior; turning points on 4-week MA. | |
| 32 | + | |
| 33 | +## 3. Composition-shock simulation (province unifamilial) | |
| 34 | + | |
| 35 | +- Shock: drop 70% of below-median floorArea sales in 2024-H1 (12,406 of 439,118 rows removed) | |
| 36 | +- Raw weekly median moves by +11.74% in shocked weeks (must move) | |
| 37 | +- Hedonic time-dummy moves by -0.74% (must stay ~0) | |
| 38 | + | |
| 39 | +## 4. Weekly vs monthly (same methodology) | |
| 40 | + | |
| 41 | +- quebec/unifamilial: weekly signal SD=3.665%, noise SD=0.0%, monthly ΔSD=3.198%, real-time revision SD=0.0% | |
| 42 | +- quebec/condo: weekly signal SD=3.778%, noise SD=0.0%, monthly ΔSD=2.993%, real-time revision SD=0.0% | |
| 43 | +- montreal/condo: weekly signal SD=3.782%, noise SD=3.583%, monthly ΔSD=3.031%, real-time revision SD=3.999% | |
added
outputs/reports/validation_v2_report.md
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +Author : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +File : outputs/reports/validation_v2_report.md | |
| 7 | +Purpose : v2.1 monthly validation report (10_monthly_validation.py) | |
| 8 | +============================================================================= | |
| 9 | +--> | |
| 10 | + | |
| 11 | +# QHPI v2.1 validation report (monthly, robust) | |
| 12 | + | |
| 13 | +## 1. Repeat sales (BMN, unit-signature pairs) vs published index | |
| 14 | + | |
| 15 | +- quebec/unifamilial: Δcorr=0.749, écart final hedonic−RS = -4.6% | |
| 16 | +- quebec/condo: Δcorr=0.368, écart final hedonic−RS = -3.2% | |
| 17 | +- quebec/plex: Δcorr=0.436, écart final hedonic−RS = -3.4% | |
| 18 | +- montreal/condo: Δcorr=0.238, écart final hedonic−RS = -1.2% | |
| 19 | +- montreal/unifamilial: Δcorr=0.262, écart final hedonic−RS = -8.1% | |
| 20 | +- quebec-city/condo: Δcorr=0.084, écart final hedonic−RS = +1.1% | |
| 21 | +- quebec-city/unifamilial: Δcorr=0.333, écart final hedonic−RS = -3.4% | |
| 22 | + | |
| 23 | +## 2. Downsampling (Montréal condo, ~620 tx/month) | |
| 24 | + | |
| 25 | +- 300 tx/mois: RMSE direct=1.01% | hiérarchique=1.3% | |
| 26 | +- 150 tx/mois: RMSE direct=1.2% | hiérarchique=1.37% | |
| 27 | +- 75 tx/mois: RMSE direct=1.49% | hiérarchique=1.31% | |
| 28 | +- 40 tx/mois: RMSE direct=1.85% | hiérarchique=1.45% | |
| 29 | +- 20 tx/mois: RMSE direct=2.09% | hiérarchique=1.69% | |
| 30 | +- 10 tx/mois: RMSE direct=2.59% | hiérarchique=2.21% | |
| 31 | + | |
| 32 | +Lecture: en RMSE pur (référence = direct plein échantillon, cellule à faible divergence) le croisement se situe vers 75–150 tx/mois. Mais le RMSE du hiérarchique contient un biais de compression qui explose sur les cellules fortement divergentes (Québec-ville condo: ~8–10 pts, cf. arbitrage RS/stratifié) alors que l'erreur du direct est du bruit pur (~1.9% à 40 tx/mois), réduit par le lisseur d'état. LIQUID_MIN=40 échange donc un bruit borné contre un biais non borné. | |
| 33 | + | |
| 34 | +## 3. Choc de composition (province unifamilial, mensuel) | |
| 35 | +- Médiane brute: +12.27% dans les mois choqués (doit bouger) | |
| 36 | +- Hédonique: -1.02% (doit rester ~0) | |
| 37 | + | |
| 38 | +## 4. Diagnostics de robustesse | |
| 39 | +- Dérive β(log surface) sur 55 fenêtres: 0.556 → 0.586 (amplitude 5.2% de la moyenne) — justifie le RTD contre le pool fixe. | |
| 40 | +- Saisonnalité quebec/all: F=0.444 p=0.77608 → non significative (NSA) | |
| 41 | +- Saisonnalité quebec/unifamilial: F=0.417 p=0.79551 → non significative (NSA) | |
| 42 | +- Saisonnalité montreal/condo: F=0.749 p=0.56243 → non significative (NSA) | |
added
outputs/tables/audit_amount_by_type.csv
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +propertyType,n,p01,p05,p25,median,p75,p95,p99,max,mean | |
| 2 | +unifamilial,443078,60000.0,100000.0,240000.0,378000.0,540000.0,925000.0,1635000.0,345000000.0,434952.0 | |
| 3 | +indéterminé,107656,52185.0,74733.0,210000.0,319252.0,443500.0,800000.0,1400000.0,75000000.0,371907.0 | |
| 4 | +condo,100218,103500.0,169000.0,275000.0,360000.0,469000.0,772000.0,1300000.0,18750000.0,405822.0 | |
| 5 | +plex,94167,75000.0,132000.0,285000.0,470577.0,725000.0,1156000.0,1710000.0,42000000.0,544999.0 | |
added
outputs/tables/audit_characteristics_by_type.csv
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +propertyType,n,floorArea_med,floorArea_p99,yearBuilt_med,yearBuilt_min,floorArea_miss_pct,yearBuilt_miss_pct,buildingType_miss_pct | |
| 2 | +unifamilial,443078,111.7,344.4,1981.0,1634,1.43,6.01,23.85 | |
| 3 | +indéterminé,107656,128.4,6552.8,1990.0,1734,95.62,95.24,97.88 | |
| 4 | +condo,100218,87.6,188.2,2006.0,1725,2.58,1.56,38.59 | |
| 5 | +plex,94167,200.7,861.8,1958.0,1600,3.48,7.01,35.55 | |
added
outputs/tables/audit_city_top200.csv
+201 −0
@@ -0,0 +1,201 @@ | ||
| 1 | +city,len | |
| 2 | +Montréal,103061 | |
| 3 | +Québec,47820 | |
| 4 | +Laval,28651 | |
| 5 | +Gatineau,27357 | |
| 6 | +Longueuil,19851 | |
| 7 | +Lévis,14499 | |
| 8 | +Sherbrooke,14350 | |
| 9 | +Saguenay,12896 | |
| 10 | +Trois-Rivières,12224 | |
| 11 | +Terrebonne,10839 | |
| 12 | +Brossard,9310 | |
| 13 | +Saint-Jérôme,8614 | |
| 14 | +Saint-Jean-sur-Richelieu,8560 | |
| 15 | +Repentigny,7735 | |
| 16 | +Drummondville,7401 | |
| 17 | +Mirabel,7142 | |
| 18 | +Granby,6744 | |
| 19 | +Mascouche,5884 | |
| 20 | +Blainville,5549 | |
| 21 | +Shawinigan,5328 | |
| 22 | +Saint-Hyacinthe,4648 | |
| 23 | +Salaberry-de-Valleyfield,4576 | |
| 24 | +Rimouski,4277 | |
| 25 | +Victoriaville,4123 | |
| 26 | +Saint-Eustache,3998 | |
| 27 | +Boucherville,3885 | |
| 28 | +Vaudreuil-Dorion,3744 | |
| 29 | +Châteauguay,3730 | |
| 30 | +Magog,3482 | |
| 31 | +Sorel-Tracy,3444 | |
| 32 | +Chambly,3272 | |
| 33 | +Rouyn-Noranda,3265 | |
| 34 | +Saint-Lin–Laurentides,3067 | |
| 35 | +Val-d'Or,2948 | |
| 36 | +Alma,2925 | |
| 37 | +Sainte-Julie,2917 | |
| 38 | +Mont-Tremblant,2819 | |
| 39 | +Saint-Georges,2788 | |
| 40 | +Candiac,2746 | |
| 41 | +Dollard-des-Ormeaux,2599 | |
| 42 | +Beloeil,2526 | |
| 43 | +Sainte-Adèle,2517 | |
| 44 | +La Prairie,2515 | |
| 45 | +Saint-Constant,2508 | |
| 46 | +Sainte-Thérèse,2470 | |
| 47 | +Sainte-Marthe-sur-le-Lac,2470 | |
| 48 | +Bromont,2465 | |
| 49 | +Thetford Mines,2448 | |
| 50 | +Saint-Bruno-de-Montarville,2333 | |
| 51 | +Saint-Lambert,2329 | |
| 52 | +Sainte-Sophie,2321 | |
| 53 | +Pointe-Claire,2260 | |
| 54 | +L'Assomption,2250 | |
| 55 | +Val-des-Monts,2238 | |
| 56 | +Boisbriand,2226 | |
| 57 | +Mont-Saint-Hilaire,2184 | |
| 58 | +Saint-Sauveur,2148 | |
| 59 | +Saint-Hippolyte,2125 | |
| 60 | +Saint-Colomban,2048 | |
| 61 | +Saint-Augustin-de-Desmaures,1969 | |
| 62 | +Sept-Îles,1942 | |
| 63 | +Sainte-Julienne,1800 | |
| 64 | +Varennes,1798 | |
| 65 | +Rawdon,1774 | |
| 66 | +Saint-Lazare,1763 | |
| 67 | +Saint-Amable,1743 | |
| 68 | +Lavaltrie,1742 | |
| 69 | +Saint-Basile-le-Grand,1672 | |
| 70 | +Beauharnois,1579 | |
| 71 | +Cowansville,1577 | |
| 72 | +Rivière-du-Loup,1554 | |
| 73 | +Sainte-Catherine,1553 | |
| 74 | +Prévost,1552 | |
| 75 | +Bécancour,1508 | |
| 76 | +Baie-Comeau,1504 | |
| 77 | +Sainte-Agathe-des-Monts,1488 | |
| 78 | +Carignan,1479 | |
| 79 | +Saint-Raymond,1475 | |
| 80 | +Joliette,1470 | |
| 81 | +Lachute,1459 | |
| 82 | +Stoneham-et-Tewkesbury,1449 | |
| 83 | +Côte-Saint-Luc,1442 | |
| 84 | +La Pêche,1438 | |
| 85 | +Pont-Rouge,1438 | |
| 86 | +Contrecoeur,1416 | |
| 87 | +Sainte-Anne-des-Plaines,1415 | |
| 88 | +Deux-Montagnes,1392 | |
| 89 | +Mercier,1391 | |
| 90 | +Sainte-Brigitte-de-Laval,1383 | |
| 91 | +Sainte-Catherine-de-la-Jacques-Cartier,1368 | |
| 92 | +L'Ancienne-Lorette,1347 | |
| 93 | +Saint-Calixte,1341 | |
| 94 | +Saint-Zotique,1303 | |
| 95 | +Dolbeau-Mistassini,1302 | |
| 96 | +Saint-Donat,1280 | |
| 97 | +Pincourt,1276 | |
| 98 | +Chertsey,1269 | |
| 99 | +Saint-Apollinaire,1266 | |
| 100 | +Mont-Laurier,1240 | |
| 101 | +Dorval,1238 | |
| 102 | +Marieville,1235 | |
| 103 | +Beaconsfield,1225 | |
| 104 | +Mont-Royal,1209 | |
| 105 | +Cantley,1200 | |
| 106 | +Saint-Philippe,1194 | |
| 107 | +Rosemère,1183 | |
| 108 | +Matane,1156 | |
| 109 | +Brownsburg-Chatham,1125 | |
| 110 | +Bois-des-Filion,1121 | |
| 111 | +Westmount,1110 | |
| 112 | +Farnham,1110 | |
| 113 | +Sainte-Marie,1100 | |
| 114 | +Orford,1097 | |
| 115 | +Saint-Adolphe-d'Howard,1088 | |
| 116 | +Boischatel,1075 | |
| 117 | +Chelsea,1067 | |
| 118 | +Beaupré,1027 | |
| 119 | +Lac-Beauport,1024 | |
| 120 | +La Tuque,1014 | |
| 121 | +Kirkland,1005 | |
| 122 | +Les Îles-de-la-Madeleine,1005 | |
| 123 | +L'Île-Perrot,980 | |
| 124 | +Saint-Félicien,957 | |
| 125 | +Lac-Brome,956 | |
| 126 | +Otterburn Park,953 | |
| 127 | +Piedmont,951 | |
| 128 | +Notre-Dame-de-l'Île-Perrot,931 | |
| 129 | +Gaspé,929 | |
| 130 | +Lorraine,889 | |
| 131 | +Montmagny,872 | |
| 132 | +L'Épiphanie,861 | |
| 133 | +Saint-Rémi,854 | |
| 134 | +Sainte-Marguerite-du-Lac-Masson,853 | |
| 135 | +Roberval,849 | |
| 136 | +Shannon,848 | |
| 137 | +La Malbaie,844 | |
| 138 | +Notre-Dame-des-Prairies,844 | |
| 139 | +Amos,843 | |
| 140 | +Saint-Charles-Borromée,840 | |
| 141 | +Morin-Heights,816 | |
| 142 | +Saint-Ferréol-les-Neiges,815 | |
| 143 | +Waterloo,805 | |
| 144 | +Les Coteaux,804 | |
| 145 | +Saint-Honoré,799 | |
| 146 | +Val-David,796 | |
| 147 | +Donnacona,790 | |
| 148 | +Saint-Côme,786 | |
| 149 | +Rigaud,780 | |
| 150 | +Val-des-Sources,761 | |
| 151 | +Chibougamau,755 | |
| 152 | +Coaticook,752 | |
| 153 | +Saint-Félix-de-Valois,747 | |
| 154 | +Saint-Lambert-de-Lauzon,744 | |
| 155 | +L'Ange-Gardien,742 | |
| 156 | +Saint-Jean-de-Matha,740 | |
| 157 | +Acton Vale,735 | |
| 158 | +Saint-Joseph-du-Lac,724 | |
| 159 | +Sutton,717 | |
| 160 | +Shefford,717 | |
| 161 | +Saint-Paul,709 | |
| 162 | +Rivière-Rouge,707 | |
| 163 | +Roxton Pond,698 | |
| 164 | +Coteau-du-Lac,696 | |
| 165 | +Pointe-Calumet,693 | |
| 166 | +Mont-Blanc,679 | |
| 167 | +Lanoraie,672 | |
| 168 | +Charlemagne,672 | |
| 169 | +Delson,663 | |
| 170 | +Plessisville,663 | |
| 171 | +Saint-Alphonse-Rodriguez,661 | |
| 172 | +Saint-Denis-de-Brompton,658 | |
| 173 | +Les Cèdres,643 | |
| 174 | +Pontiac,640 | |
| 175 | +Hudson,625 | |
| 176 | +Sainte-Anne-des-Lacs,618 | |
| 177 | +Nicolet,618 | |
| 178 | +Saint-Pie,617 | |
| 179 | +Saint-Isidore,598 | |
| 180 | +Sainte-Anne-des-Monts,586 | |
| 181 | +Saint-Alexis-des-Monts,573 | |
| 182 | +Notre-Dame-du-Mont-Carmel,567 | |
| 183 | +Baie-Saint-Paul,564 | |
| 184 | +Château-Richer,562 | |
| 185 | +Louiseville,555 | |
| 186 | +Saint-Agapit,554 | |
| 187 | +Lac-Mégantic,551 | |
| 188 | +Saint-Henri,546 | |
| 189 | +Eastman,545 | |
| 190 | +Mont-Joli,543 | |
| 191 | +Saint-Ambroise,541 | |
| 192 | +La Sarre,539 | |
| 193 | +Saint-Damien,534 | |
| 194 | +Potton,531 | |
| 195 | +Saint-Jacques,529 | |
| 196 | +Princeville,528 | |
| 197 | +Saint-David-de-Falardeau,525 | |
| 198 | +Lac-Supérieur,518 | |
| 199 | +Saint-Césaire,516 | |
| 200 | +Mandeville,504 | |
| 201 | +Notre-Dame-du-Laus,500 | |
added
outputs/tables/audit_dist_buildingType.csv
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +buildingType,len,pct | |
| 2 | +,283206,38.01 | |
| 3 | +single-story,205924,27.64 | |
| 4 | +full-story,188584,25.31 | |
| 5 | +mansard,52808,7.09 | |
| 6 | +split-level,8743,1.17 | |
| 7 | +single-wide,5854,0.79 | |
added
outputs/tables/audit_dist_ownerType.csv
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +ownerType,len,pct | |
| 2 | +physical_person,496404,66.62 | |
| 3 | +,224999,30.2 | |
| 4 | +legal_person,22351,3.0 | |
| 5 | +mixed,1365,0.18 | |
added
outputs/tables/audit_dist_propertyType.csv
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +propertyType,len,pct | |
| 2 | +unifamilial,443078,59.46 | |
| 3 | +indéterminé,107656,14.45 | |
| 4 | +condo,100218,13.45 | |
| 5 | +plex,94167,12.64 | |
added
outputs/tables/audit_liquidity_matrix.csv
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +city,condo,indéterminé,plex,unifamilial | |
| 2 | +Québec,41.5,6.0,21.0,66.0 | |
| 3 | +Montréal,145.0,39.0,80.0,64.0 | |
| 4 | +Gatineau,7.0,12.0,12.0,54.0 | |
| 5 | +Laval,21.0,8.0,8.0,54.0 | |
| 6 | +Sherbrooke,2.0,6.0,7.0,29.0 | |
| 7 | +Longueuil,15.0,10.0,9.0,28.0 | |
| 8 | +Lévis,4.0,6.0,5.0,26.0 | |
| 9 | +Saguenay,2.0,4.0,8.0,26.0 | |
| 10 | +Trois-Rivières,2.0,5.0,8.0,23.0 | |
| 11 | +Terrebonne,3.0,6.0,3.0,22.0 | |
| 12 | +Saint-Jean-sur-Richelieu,2.0,4.0,3.0,18.0 | |
| 13 | +Drummondville,1.0,3.0,4.0,16.0 | |
| 14 | +Repentigny,5.0,2.0,2.0,16.0 | |
| 15 | +Saint-Jérôme,2.0,5.0,5.0,15.0 | |
| 16 | +Granby,2.0,3.0,3.0,13.0 | |
| 17 | +Blainville,1.0,3.0,2.0,12.0 | |
| 18 | +Brossard,5.0,11.0,1.0,12.0 | |
| 19 | +Mirabel,1.0,6.0,4.0,12.0 | |
| 20 | +Shawinigan,1.0,2.0,5.0,11.0 | |
| 21 | +Mascouche,2.0,5.0,2.0,10.0 | |
| 22 | +Rimouski,2.0,2.0,2.0,10.0 | |
| 23 | +Saint-Hyacinthe,1.0,3.0,3.0,9.0 | |
| 24 | +Salaberry-de-Valleyfield,1.0,2.0,3.0,9.0 | |
| 25 | +Victoriaville,1.0,2.0,2.0,9.0 | |
| 26 | +Saint-Eustache,2.0,2.0,1.0,8.0 | |
added
outputs/tables/audit_missingness.csv
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +column,n_missing,pct_missing | |
| 2 | +buildingType,283206,38.01 | |
| 3 | +ownerType,224999,30.2 | |
| 4 | +yearBuilt,137315,18.43 | |
| 5 | +floorArea,115153,15.45 | |
| 6 | +previousValue,78581,10.55 | |
| 7 | +zipCode,75369,10.12 | |
| 8 | +totalArValue,63160,8.48 | |
| 9 | +street,6688,0.9 | |
| 10 | +id,0,0.0 | |
| 11 | +date,0,0.0 | |
| 12 | +amount,0,0.0 | |
| 13 | +city,0,0.0 | |
| 14 | +lat,0,0.0 | |
| 15 | +lng,0,0.0 | |
| 16 | +propertyType,0,0.0 | |
| 17 | +week,0,0.0 | |
| 18 | +log_amount,0,0.0 | |
added
outputs/tables/audit_schema.csv
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +column,dtype | |
| 2 | +id,String | |
| 3 | +date,Date | |
| 4 | +amount,Float64 | |
| 5 | +street,String | |
| 6 | +zipCode,String | |
| 7 | +city,String | |
| 8 | +lat,Float64 | |
| 9 | +lng,Float64 | |
| 10 | +propertyType,String | |
| 11 | +yearBuilt,Int64 | |
| 12 | +floorArea,Float64 | |
| 13 | +buildingType,String | |
| 14 | +previousValue,Float64 | |
| 15 | +totalArValue,Float64 | |
| 16 | +ownerType,String | |
| 17 | +week,Date | |
| 18 | +log_amount,Float64 | |
added
outputs/tables/audit_sentinel_values.csv
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +check,n | |
| 2 | +amount <= 0,0 | |
| 3 | +"amount in (1, 2)",0 | |
| 4 | +amount < 5000,0 | |
| 5 | +floorArea <= 0,0 | |
| 6 | +floorArea > 2000 m2,375 | |
| 7 | +yearBuilt < 1600,0 | |
| 8 | +yearBuilt > 2026,0 | |
| 9 | +yearBuilt == 0,0 | |
| 10 | +previousValue <= 0,1640 | |
| 11 | +totalArValue <= 0,17 | |
| 12 | +"lat/lng outside Quebec bbox (44.9-62.7N, -79.8--57W)",0 | |
added
outputs/tables/audit_weekly_liquidity_topcities.csv
+101 −0
@@ -0,0 +1,101 @@ | ||
| 1 | +city,propertyType,median_wk,mean_wk,weeks_present,pct_weeks_present | |
| 2 | +Blainville,condo,1.0,1.7,144,49.5 | |
| 3 | +Blainville,indéterminé,3.0,3.8,253,86.9 | |
| 4 | +Blainville,plex,2.0,2.5,234,80.4 | |
| 5 | +Blainville,unifamilial,12.0,13.2,284,97.6 | |
| 6 | +Brossard,condo,5.0,7.0,267,91.8 | |
| 7 | +Brossard,indéterminé,11.0,13.4,279,95.9 | |
| 8 | +Brossard,plex,1.0,1.5,128,44.0 | |
| 9 | +Brossard,unifamilial,12.0,12.3,285,97.9 | |
| 10 | +Drummondville,condo,1.0,1.3,107,36.8 | |
| 11 | +Drummondville,indéterminé,3.0,3.7,243,83.5 | |
| 12 | +Drummondville,plex,4.0,4.5,274,94.2 | |
| 13 | +Drummondville,unifamilial,16.0,18.1,285,97.9 | |
| 14 | +Gatineau,condo,7.0,9.1,281,96.6 | |
| 15 | +Gatineau,indéterminé,12.0,14.5,263,90.4 | |
| 16 | +Gatineau,plex,12.0,12.9,286,98.3 | |
| 17 | +Gatineau,unifamilial,54.0,60.2,288,99.0 | |
| 18 | +Granby,condo,2.0,2.6,207,71.1 | |
| 19 | +Granby,indéterminé,3.0,4.6,259,89.0 | |
| 20 | +Granby,plex,3.0,3.8,253,86.9 | |
| 21 | +Granby,unifamilial,13.0,14.3,283,97.3 | |
| 22 | +Laval,condo,21.0,21.9,288,99.0 | |
| 23 | +Laval,indéterminé,8.0,10.2,281,96.6 | |
| 24 | +Laval,plex,8.0,8.9,285,97.9 | |
| 25 | +Laval,unifamilial,54.0,58.7,289,99.3 | |
| 26 | +Longueuil,condo,15.0,16.7,286,98.3 | |
| 27 | +Longueuil,indéterminé,10.0,12.4,281,96.6 | |
| 28 | +Longueuil,plex,9.0,9.7,287,98.6 | |
| 29 | +Longueuil,unifamilial,28.0,30.6,288,99.0 | |
| 30 | +Lévis,condo,4.0,5.0,256,88.0 | |
| 31 | +Lévis,indéterminé,6.0,8.5,273,93.8 | |
| 32 | +Lévis,plex,5.0,5.5,279,95.9 | |
| 33 | +Lévis,unifamilial,26.0,32.8,286,98.3 | |
| 34 | +Mascouche,condo,2.0,3.3,232,79.7 | |
| 35 | +Mascouche,indéterminé,5.0,5.8,238,81.8 | |
| 36 | +Mascouche,plex,2.0,2.3,220,75.6 | |
| 37 | +Mascouche,unifamilial,10.0,11.3,287,98.6 | |
| 38 | +Mirabel,condo,1.0,1.5,105,36.1 | |
| 39 | +Mirabel,indéterminé,6.0,7.5,278,95.5 | |
| 40 | +Mirabel,plex,4.0,4.2,276,94.8 | |
| 41 | +Mirabel,unifamilial,12.0,13.0,287,98.6 | |
| 42 | +Montréal,condo,145.0,157.5,291,100.0 | |
| 43 | +Montréal,indéterminé,39.0,44.6,288,99.0 | |
| 44 | +Montréal,plex,80.0,85.7,291,100.0 | |
| 45 | +Montréal,unifamilial,64.0,67.3,289,99.3 | |
| 46 | +Québec,condo,41.5,51.6,288,99.0 | |
| 47 | +Québec,indéterminé,6.0,7.7,282,96.9 | |
| 48 | +Québec,plex,21.0,24.0,288,99.0 | |
| 49 | +Québec,unifamilial,66.0,82.9,288,99.0 | |
| 50 | +Repentigny,condo,5.0,6.0,282,96.9 | |
| 51 | +Repentigny,indéterminé,2.0,2.5,197,67.7 | |
| 52 | +Repentigny,plex,2.0,2.0,213,73.2 | |
| 53 | +Repentigny,unifamilial,16.0,17.7,291,100.0 | |
| 54 | +Rimouski,condo,2.0,2.0,176,60.5 | |
| 55 | +Rimouski,indéterminé,2.0,2.4,182,62.5 | |
| 56 | +Rimouski,plex,2.0,2.1,189,64.9 | |
| 57 | +Rimouski,unifamilial,10.0,11.0,281,96.6 | |
| 58 | +Saguenay,condo,2.0,2.1,183,62.9 | |
| 59 | +Saguenay,indéterminé,4.0,4.3,255,87.6 | |
| 60 | +Saguenay,plex,8.0,9.3,280,96.2 | |
| 61 | +Saguenay,unifamilial,26.0,31.2,282,96.9 | |
| 62 | +Saint-Eustache,condo,2.0,2.1,176,60.5 | |
| 63 | +Saint-Eustache,indéterminé,2.0,3.2,219,75.3 | |
| 64 | +Saint-Eustache,plex,1.0,1.7,189,64.9 | |
| 65 | +Saint-Eustache,unifamilial,8.0,9.2,282,96.9 | |
| 66 | +Saint-Hyacinthe,condo,1.0,1.1,16,5.5 | |
| 67 | +Saint-Hyacinthe,indéterminé,3.0,3.9,269,92.4 | |
| 68 | +Saint-Hyacinthe,plex,3.0,3.3,254,87.3 | |
| 69 | +Saint-Hyacinthe,unifamilial,9.0,9.7,282,96.9 | |
| 70 | +Saint-Jean-sur-Richelieu,condo,2.0,2.5,200,68.7 | |
| 71 | +Saint-Jean-sur-Richelieu,indéterminé,4.0,4.4,260,89.3 | |
| 72 | +Saint-Jean-sur-Richelieu,plex,3.0,3.9,270,92.8 | |
| 73 | +Saint-Jean-sur-Richelieu,unifamilial,18.0,20.3,289,99.3 | |
| 74 | +Saint-Jérôme,condo,2.0,3.5,223,76.6 | |
| 75 | +Saint-Jérôme,indéterminé,5.0,6.4,251,86.3 | |
| 76 | +Saint-Jérôme,plex,5.0,6.0,283,97.3 | |
| 77 | +Saint-Jérôme,unifamilial,15.0,15.8,286,98.3 | |
| 78 | +Salaberry-de-Valleyfield,condo,1.0,1.2,64,22.0 | |
| 79 | +Salaberry-de-Valleyfield,indéterminé,2.0,3.1,207,71.1 | |
| 80 | +Salaberry-de-Valleyfield,plex,3.0,3.6,266,91.4 | |
| 81 | +Salaberry-de-Valleyfield,unifamilial,9.0,10.3,282,96.9 | |
| 82 | +Shawinigan,condo,1.0,1.2,53,18.2 | |
| 83 | +Shawinigan,indéterminé,2.0,2.1,203,69.8 | |
| 84 | +Shawinigan,plex,5.0,5.3,282,96.9 | |
| 85 | +Shawinigan,unifamilial,11.0,11.8,284,97.6 | |
| 86 | +Sherbrooke,condo,2.0,2.3,223,76.6 | |
| 87 | +Sherbrooke,indéterminé,6.0,7.6,277,95.2 | |
| 88 | +Sherbrooke,plex,7.0,8.6,285,97.9 | |
| 89 | +Sherbrooke,unifamilial,29.0,32.2,289,99.3 | |
| 90 | +Terrebonne,condo,3.0,3.9,241,82.8 | |
| 91 | +Terrebonne,indéterminé,6.0,6.8,258,88.7 | |
| 92 | +Terrebonne,plex,3.0,3.5,266,91.4 | |
| 93 | +Terrebonne,unifamilial,22.0,24.8,291,100.0 | |
| 94 | +Trois-Rivières,condo,2.0,2.7,211,72.5 | |
| 95 | +Trois-Rivières,indéterminé,5.0,6.3,267,91.8 | |
| 96 | +Trois-Rivières,plex,8.0,9.1,284,97.6 | |
| 97 | +Trois-Rivières,unifamilial,23.0,25.6,288,99.0 | |
| 98 | +Victoriaville,condo,1.0,1.1,67,23.0 | |
| 99 | +Victoriaville,indéterminé,2.0,2.4,203,69.8 | |
| 100 | +Victoriaville,plex,2.0,2.4,223,76.6 | |
| 101 | +Victoriaville,unifamilial,9.0,10.7,282,96.9 | |
added
outputs/tables/audit_weekly_volume_province.csv
+292 −0
@@ -0,0 +1,292 @@ | ||
| 1 | +week,transactions | |
| 2 | +2021-01-04,35 | |
| 3 | +2021-01-11,123 | |
| 4 | +2021-01-18,136 | |
| 5 | +2021-01-25,2184 | |
| 6 | +2021-02-01,2731 | |
| 7 | +2021-02-08,1902 | |
| 8 | +2021-02-15,2254 | |
| 9 | +2021-02-22,3000 | |
| 10 | +2021-03-01,3216 | |
| 11 | +2021-03-08,1998 | |
| 12 | +2021-03-15,2624 | |
| 13 | +2021-03-22,2549 | |
| 14 | +2021-03-29,3849 | |
| 15 | +2021-04-05,2975 | |
| 16 | +2021-04-12,3026 | |
| 17 | +2021-04-19,2707 | |
| 18 | +2021-04-26,5269 | |
| 19 | +2021-05-03,4437 | |
| 20 | +2021-05-10,3735 | |
| 21 | +2021-05-17,4097 | |
| 22 | +2021-05-24,4527 | |
| 23 | +2021-05-31,7821 | |
| 24 | +2021-06-07,4513 | |
| 25 | +2021-06-14,6317 | |
| 26 | +2021-06-21,5859 | |
| 27 | +2021-06-28,7845 | |
| 28 | +2021-07-05,4180 | |
| 29 | +2021-07-12,4916 | |
| 30 | +2021-07-19,2736 | |
| 31 | +2021-07-26,3060 | |
| 32 | +2021-08-02,2569 | |
| 33 | +2021-08-09,2480 | |
| 34 | +2021-08-16,2896 | |
| 35 | +2021-08-23,2904 | |
| 36 | +2021-08-30,4084 | |
| 37 | +2021-09-06,2044 | |
| 38 | +2021-09-13,2669 | |
| 39 | +2021-09-20,2510 | |
| 40 | +2021-09-27,3542 | |
| 41 | +2021-10-04,2806 | |
| 42 | +2021-10-11,2146 | |
| 43 | +2021-10-18,2482 | |
| 44 | +2021-10-25,3150 | |
| 45 | +2021-11-01,3292 | |
| 46 | +2021-11-08,2240 | |
| 47 | +2021-11-15,2711 | |
| 48 | +2021-11-22,2679 | |
| 49 | +2021-11-29,3970 | |
| 50 | +2021-12-06,2730 | |
| 51 | +2021-12-13,4302 | |
| 52 | +2021-12-20,2469 | |
| 53 | +2021-12-27,245 | |
| 54 | +2022-01-03,506 | |
| 55 | +2022-01-10,1725 | |
| 56 | +2022-01-17,2313 | |
| 57 | +2022-01-24,2308 | |
| 58 | +2022-01-31,2718 | |
| 59 | +2022-02-07,1693 | |
| 60 | +2022-02-14,1965 | |
| 61 | +2022-02-21,2206 | |
| 62 | +2022-02-28,2836 | |
| 63 | +2022-03-07,1712 | |
| 64 | +2022-03-14,2197 | |
| 65 | +2022-03-21,2023 | |
| 66 | +2022-03-28,3409 | |
| 67 | +2022-04-04,2476 | |
| 68 | +2022-04-11,2085 | |
| 69 | +2022-04-18,2437 | |
| 70 | +2022-04-25,3753 | |
| 71 | +2022-05-02,3756 | |
| 72 | +2022-05-09,2793 | |
| 73 | +2022-05-16,3247 | |
| 74 | +2022-05-23,3086 | |
| 75 | +2022-05-30,6092 | |
| 76 | +2022-06-06,3395 | |
| 77 | +2022-06-13,4812 | |
| 78 | +2022-06-20,4168 | |
| 79 | +2022-06-27,6074 | |
| 80 | +2022-07-04,4282 | |
| 81 | +2022-07-11,3874 | |
| 82 | +2022-07-18,3741 | |
| 83 | +2022-07-25,2633 | |
| 84 | +2022-08-01,2071 | |
| 85 | +2022-08-08,1691 | |
| 86 | +2022-08-15,2271 | |
| 87 | +2022-08-22,2345 | |
| 88 | +2022-08-29,3411 | |
| 89 | +2022-09-05,1822 | |
| 90 | +2022-09-12,2299 | |
| 91 | +2022-09-19,2023 | |
| 92 | +2022-09-26,2723 | |
| 93 | +2022-10-03,2396 | |
| 94 | +2022-10-10,1737 | |
| 95 | +2022-10-17,1988 | |
| 96 | +2022-10-24,2333 | |
| 97 | +2022-10-31,1829 | |
| 98 | +2022-11-07,1685 | |
| 99 | +2022-11-14,1982 | |
| 100 | +2022-11-21,1950 | |
| 101 | +2022-11-28,3063 | |
| 102 | +2022-12-05,2006 | |
| 103 | +2022-12-12,2865 | |
| 104 | +2022-12-19,2515 | |
| 105 | +2022-12-26,342 | |
| 106 | +2023-01-02,294 | |
| 107 | +2023-01-09,1080 | |
| 108 | +2023-01-16,1515 | |
| 109 | +2023-01-23,1321 | |
| 110 | +2023-01-30,1695 | |
| 111 | +2023-02-06,944 | |
| 112 | +2023-02-13,1189 | |
| 113 | +2023-02-20,1309 | |
| 114 | +2023-02-27,1732 | |
| 115 | +2023-03-06,1094 | |
| 116 | +2023-03-13,1447 | |
| 117 | +2023-03-20,1311 | |
| 118 | +2023-03-27,2215 | |
| 119 | +2023-04-03,1507 | |
| 120 | +2023-04-10,1522 | |
| 121 | +2023-04-17,1601 | |
| 122 | +2023-04-24,2438 | |
| 123 | +2023-05-01,2947 | |
| 124 | +2023-05-08,1709 | |
| 125 | +2023-05-15,2554 | |
| 126 | +2023-05-22,2156 | |
| 127 | +2023-05-29,4800 | |
| 128 | +2023-06-05,2669 | |
| 129 | +2023-06-12,3809 | |
| 130 | +2023-06-19,3132 | |
| 131 | +2023-06-26,4146 | |
| 132 | +2023-07-03,4099 | |
| 133 | +2023-07-10,2798 | |
| 134 | +2023-07-17,3088 | |
| 135 | +2023-07-24,2368 | |
| 136 | +2023-07-31,2428 | |
| 137 | +2023-08-07,1554 | |
| 138 | +2023-08-14,2147 | |
| 139 | +2023-08-21,2083 | |
| 140 | +2023-08-28,3160 | |
| 141 | +2023-09-04,1746 | |
| 142 | +2023-09-11,1937 | |
| 143 | +2023-09-18,1821 | |
| 144 | +2023-09-25,2522 | |
| 145 | +2023-10-02,2150 | |
| 146 | +2023-10-09,1500 | |
| 147 | +2023-10-16,1921 | |
| 148 | +2023-10-23,1919 | |
| 149 | +2023-10-30,2422 | |
| 150 | +2023-11-06,1554 | |
| 151 | +2023-11-13,1886 | |
| 152 | +2023-11-20,1668 | |
| 153 | +2023-11-27,2590 | |
| 154 | +2023-12-04,1914 | |
| 155 | +2023-12-11,2292 | |
| 156 | +2023-12-18,2420 | |
| 157 | +2023-12-25,306 | |
| 158 | +2024-01-01,234 | |
| 159 | +2024-01-08,897 | |
| 160 | +2024-01-15,1438 | |
| 161 | +2024-01-22,1251 | |
| 162 | +2024-01-29,1629 | |
| 163 | +2024-02-05,952 | |
| 164 | +2024-02-12,1155 | |
| 165 | +2024-02-19,1157 | |
| 166 | +2024-02-26,2044 | |
| 167 | +2024-03-04,1187 | |
| 168 | +2024-03-11,1373 | |
| 169 | +2024-03-18,1427 | |
| 170 | +2024-03-25,1975 | |
| 171 | +2024-04-01,2144 | |
| 172 | +2024-04-08,1583 | |
| 173 | +2024-04-15,1947 | |
| 174 | +2024-04-22,2095 | |
| 175 | +2024-04-29,3758 | |
| 176 | +2024-05-06,1960 | |
| 177 | +2024-05-13,2838 | |
| 178 | +2024-05-20,2266 | |
| 179 | +2024-05-27,4708 | |
| 180 | +2024-06-03,3941 | |
| 181 | +2024-06-10,3899 | |
| 182 | +2024-06-17,5488 | |
| 183 | +2024-06-24,5240 | |
| 184 | +2024-07-01,3085 | |
| 185 | +2024-07-08,2860 | |
| 186 | +2024-07-15,3386 | |
| 187 | +2024-07-22,2194 | |
| 188 | +2024-07-29,2708 | |
| 189 | +2024-08-05,1758 | |
| 190 | +2024-08-12,2247 | |
| 191 | +2024-08-19,2282 | |
| 192 | +2024-08-26,2988 | |
| 193 | +2024-09-02,2050 | |
| 194 | +2024-09-09,2071 | |
| 195 | +2024-09-16,2191 | |
| 196 | +2024-09-23,2464 | |
| 197 | +2024-09-30,2804 | |
| 198 | +2024-10-07,2089 | |
| 199 | +2024-10-14,1988 | |
| 200 | +2024-10-21,2117 | |
| 201 | +2024-10-28,3114 | |
| 202 | +2024-11-04,2215 | |
| 203 | +2024-11-11,2275 | |
| 204 | +2024-11-18,2374 | |
| 205 | +2024-11-25,3014 | |
| 206 | +2024-12-02,2860 | |
| 207 | +2024-12-09,2973 | |
| 208 | +2024-12-16,3825 | |
| 209 | +2024-12-23,480 | |
| 210 | +2024-12-30,236 | |
| 211 | +2025-01-06,901 | |
| 212 | +2025-01-13,2207 | |
| 213 | +2025-01-20,1838 | |
| 214 | +2025-01-27,2383 | |
| 215 | +2025-02-03,1715 | |
| 216 | +2025-02-10,1485 | |
| 217 | +2025-02-17,1678 | |
| 218 | +2025-02-24,2582 | |
| 219 | +2025-03-03,1720 | |
| 220 | +2025-03-10,1701 | |
| 221 | +2025-03-17,1844 | |
| 222 | +2025-03-24,2134 | |
| 223 | +2025-03-31,2931 | |
| 224 | +2025-04-07,1696 | |
| 225 | +2025-04-14,2080 | |
| 226 | +2025-04-21,2345 | |
| 227 | +2025-04-28,4301 | |
| 228 | +2025-05-05,2351 | |
| 229 | +2025-05-12,3181 | |
| 230 | +2025-05-19,2363 | |
| 231 | +2025-05-26,4558 | |
| 232 | +2025-06-02,4354 | |
| 233 | +2025-06-09,3876 | |
| 234 | +2025-06-16,5140 | |
| 235 | +2025-06-23,5016 | |
| 236 | +2025-06-30,4484 | |
| 237 | +2025-07-07,3318 | |
| 238 | +2025-07-14,3864 | |
| 239 | +2025-07-21,2453 | |
| 240 | +2025-07-28,2797 | |
| 241 | +2025-08-04,2099 | |
| 242 | +2025-08-11,2432 | |
| 243 | +2025-08-18,2569 | |
| 244 | +2025-08-25,3395 | |
| 245 | +2025-09-01,2337 | |
| 246 | +2025-09-08,2215 | |
| 247 | +2025-09-15,2577 | |
| 248 | +2025-09-22,2446 | |
| 249 | +2025-09-29,3270 | |
| 250 | +2025-10-06,2239 | |
| 251 | +2025-10-13,2230 | |
| 252 | +2025-10-20,2320 | |
| 253 | +2025-10-27,3221 | |
| 254 | +2025-11-03,2429 | |
| 255 | +2025-11-10,2295 | |
| 256 | +2025-11-17,2393 | |
| 257 | +2025-11-24,2967 | |
| 258 | +2025-12-01,3037 | |
| 259 | +2025-12-08,2928 | |
| 260 | +2025-12-15,3981 | |
| 261 | +2025-12-22,579 | |
| 262 | +2025-12-29,177 | |
| 263 | +2026-01-05,769 | |
| 264 | +2026-01-12,1943 | |
| 265 | +2026-01-19,1868 | |
| 266 | +2026-01-26,2211 | |
| 267 | +2026-02-02,1659 | |
| 268 | +2026-02-09,1391 | |
| 269 | +2026-02-16,1599 | |
| 270 | +2026-02-23,2155 | |
| 271 | +2026-03-02,1595 | |
| 272 | +2026-03-09,1522 | |
| 273 | +2026-03-16,1843 | |
| 274 | +2026-03-23,1895 | |
| 275 | +2026-03-30,2610 | |
| 276 | +2026-04-06,1709 | |
| 277 | +2026-04-13,2185 | |
| 278 | +2026-04-20,1997 | |
| 279 | +2026-04-27,3850 | |
| 280 | +2026-05-04,2503 | |
| 281 | +2026-05-11,2738 | |
| 282 | +2026-05-18,2224 | |
| 283 | +2026-05-25,3846 | |
| 284 | +2026-06-01,4494 | |
| 285 | +2026-06-08,3299 | |
| 286 | +2026-06-15,4687 | |
| 287 | +2026-06-22,4226 | |
| 288 | +2026-06-29,3581 | |
| 289 | +2026-07-06,2934 | |
| 290 | +2026-07-13,2973 | |
| 291 | +2026-07-20,2197 | |
| 292 | +2026-07-27,318 | |
added
outputs/tables/baseline_model_summary.csv
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +geography,property_type,n_obs,estimated,r2,median_weekly_tx | |
| 2 | +quebec,all,625882,true,0.6036,1999.0 | |
| 3 | +quebec,unifamilial,438871,true,0.6091,1384.0 | |
| 4 | +quebec,condo,94792,true,0.6592,291.0 | |
| 5 | +quebec,plex,91971,true,0.6272,297.0 | |
| 6 | +montreal,all,87775,true,0.5301,281.0 | |
| 7 | +montreal,unifamilial,19244,true,0.6327,64.0 | |
| 8 | +montreal,condo,44520,true,0.5468,140.0 | |
| 9 | +montreal,plex,23988,true,0.1976,76.0 | |
| 10 | +quebec-city,all,43337,true,0.5512,122.0 | |
| 11 | +quebec-city,unifamilial,23606,true,0.5064,65.0 | |
| 12 | +quebec-city,condo,13150,true,0.5939,37.0 | |
| 13 | +quebec-city,plex,6572,true,0.5126,20.5 | |
| 14 | +laval,all,25438,true,0.5358,84.0 | |
| 15 | +laval,unifamilial,16762,true,0.4435,53.0 | |
| 16 | +laval,condo,6238,true,0.6576,21.0 | |
| 17 | +laval,plex,2429,true,0.5333,8.0 | |
| 18 | +gatineau,all,23430,true,0.5856,75.0 | |
| 19 | +gatineau,unifamilial,17255,true,0.5293,54.0 | |
| 20 | +gatineau,condo,2516,true,0.6694,6.5 | |
| 21 | +gatineau,plex,3657,true,0.6419,12.0 | |
added
outputs/tables/composition_shock.csv
+3 −0
@@ -0,0 +1,3 @@ | ||
| 1 | +metric,value | |
| 2 | +raw_median_shift_pct,11.738 | |
| 3 | +hedonic_shift_pct,-0.738 | |
added
outputs/tables/composition_shock_monthly.csv
+3 −0
@@ -0,0 +1,3 @@ | ||
| 1 | +metric,value | |
| 2 | +raw_median_shift_pct,12.275 | |
| 3 | +hedonic_shift_pct,-1.015 | |
added
outputs/tables/coverage.csv
+2336 −0
@@ -0,0 +1,2336 @@ | ||
| 1 | +municipality,geo_code,region,propertyType,median_weekly_tx,total_tx,pct_weeks_active,coverage_status | |
| 2 | +Montréal,66023,Montréal,condo,140.0,44539,100.0,published | |
| 3 | +Montréal,66023,Montréal,plex,76.0,24009,100.0,published | |
| 4 | +Québec,23027,Capitale-Nationale,unifamilial,65.0,23614,99.0,published | |
| 5 | +Montréal,66023,Montréal,unifamilial,64.0,19257,99.3,published | |
| 6 | +Gatineau,81017,Outaouais,unifamilial,54.0,17261,99.0,published | |
| 7 | +Laval,65005,Laval,unifamilial,53.0,16780,99.3,published | |
| 8 | +Québec,23027,Capitale-Nationale,condo,37.0,13156,98.6,published | |
| 9 | +Sherbrooke,43027,Estrie,unifamilial,29.0,9253,99.3,published | |
| 10 | +Lévis,25213,Chaudière-Appalaches,unifamilial,26.0,9231,98.3,published | |
| 11 | +Saguenay,94068,Saguenay–Lac-Saint-Jean,unifamilial,26.0,8761,96.9,published | |
| 12 | +Longueuil,58227,Montérégie,unifamilial,28.0,8747,99.0,published | |
| 13 | +Trois-Rivières,37067,Mauricie,unifamilial,23.0,7325,99.0,published | |
| 14 | +Terrebonne,64008,Lanaudière,unifamilial,22.0,7143,100.0,conditional | |
| 15 | +Québec,23027,Capitale-Nationale,plex,20.5,6577,99.0,published | |
| 16 | +Laval,65005,Laval,condo,21.0,6245,99.0,published | |
| 17 | +Saint-Jean-sur-Richelieu,56083,Montérégie,unifamilial,18.0,5807,99.3,conditional | |
| 18 | +Drummondville,49058,Centre-du-Québec,unifamilial,16.0,5104,97.9,published | |
| 19 | +Repentigny,60013,Lanaudière,unifamilial,15.0,5091,100.0,conditional | |
| 20 | +Longueuil,58227,Montérégie,condo,15.0,4628,98.3,published | |
| 21 | +Saint-Jérôme,75017,Laurentides,unifamilial,14.5,4503,98.3,conditional | |
| 22 | +Granby,47017,Estrie,unifamilial,12.0,4024,97.3,conditional | |
| 23 | +Blainville,73015,Laurentides,unifamilial,12.0,3733,97.6,conditional | |
| 24 | +Mirabel,74005,Laurentides,unifamilial,12.0,3705,98.6,conditional | |
| 25 | +Gatineau,81017,Outaouais,plex,12.0,3660,98.3,published | |
| 26 | +Brossard,58007,Montérégie,unifamilial,12.0,3475,97.9,conditional | |
| 27 | +Shawinigan,36033,Mauricie,unifamilial,11.0,3346,97.6,conditional | |
| 28 | +Mascouche,64015,Lanaudière,unifamilial,10.0,3214,98.6,conditional | |
| 29 | +Rimouski,10043,Bas-Saint-Laurent,unifamilial,10.0,3048,96.6,conditional | |
| 30 | +Victoriaville,39062,Centre-du-Québec,unifamilial,9.0,2996,96.9,conditional | |
| 31 | +Châteauguay,67050,Montérégie,unifamilial,9.0,2894,97.9,conditional | |
| 32 | +Salaberry-de-Valleyfield,70052,Montérégie,unifamilial,9.0,2882,96.9,conditional | |
| 33 | +Longueuil,58227,Montérégie,plex,9.0,2741,98.6,published | |
| 34 | +Saint-Hyacinthe,54048,Montérégie,unifamilial,9.0,2727,96.9,conditional | |
| 35 | +Saint-Eustache,72005,Laurentides,unifamilial,8.0,2576,96.9,conditional | |
| 36 | +Saguenay,94068,Saguenay–Lac-Saint-Jean,plex,8.0,2576,96.2,published | |
| 37 | +Boucherville,58033,Montérégie,unifamilial,8.0,2559,97.3,conditional | |
| 38 | +Trois-Rivières,37067,Mauricie,plex,8.0,2535,97.6,published | |
| 39 | +Gatineau,81017,Outaouais,condo,6.5,2519,96.2,published | |
| 40 | +Vaudreuil-Dorion,71083,Montérégie,unifamilial,8.0,2465,99.0,conditional | |
| 41 | +Laval,65005,Laval,plex,8.0,2434,97.6,published | |
| 42 | +Sherbrooke,43027,Estrie,plex,7.0,2402,97.9,published | |
| 43 | +Sorel-Tracy,53052,Montérégie,unifamilial,7.0,2353,97.6,conditional | |
| 44 | +Saint-Lin–Laurentides,63048,Lanaudière,unifamilial,7.0,2329,98.3,conditional | |
| 45 | +Rouyn-Noranda,86042,Abitibi-Témiscamingue,unifamilial,8.0,2327,94.5,conditional | |
| 46 | +Val-d'Or,89008,Abitibi-Témiscamingue,unifamilial,7.0,2194,95.9,conditional | |
| 47 | +Saint-Georges,29073,Chaudière-Appalaches,unifamilial,6.0,2019,96.9,conditional | |
| 48 | +Magog,45072,Estrie,unifamilial,7.0,2010,95.5,conditional | |
| 49 | +Alma,93042,Saguenay–Lac-Saint-Jean,unifamilial,6.0,1975,95.2,conditional | |
| 50 | +Chambly,57005,Montérégie,unifamilial,6.0,1952,95.2,conditional | |
| 51 | +Sainte-Julie,59010,Montérégie,unifamilial,6.0,1943,95.5,conditional | |
| 52 | +Dollard-des-Ormeaux,66142,Montréal,unifamilial,6.0,1872,96.2,conditional | |
| 53 | +Brossard,58007,Montérégie,condo,5.0,1797,91.8,conditional | |
| 54 | +Thetford Mines,31084,Chaudière-Appalaches,unifamilial,6.0,1773,94.5,conditional | |
| 55 | +Saint-Constant,67035,Montérégie,unifamilial,5.0,1708,94.2,conditional | |
| 56 | +Saint-Bruno-de-Montarville,58037,Montérégie,unifamilial,6.0,1702,94.2,conditional | |
| 57 | +Beloeil,57040,Montérégie,unifamilial,5.0,1697,95.2,conditional | |
| 58 | +Saint-Jérôme,75017,Laurentides,plex,5.0,1696,97.3,conditional | |
| 59 | +Repentigny,60013,Lanaudière,condo,5.0,1688,96.9,conditional | |
| 60 | +Sainte-Sophie,75028,Laurentides,unifamilial,6.0,1685,96.9,conditional | |
| 61 | +L'Assomption,60028,Lanaudière,unifamilial,5.0,1600,94.5,conditional | |
| 62 | +Saint-Colomban,75005,Laurentides,unifamilial,5.0,1568,94.5,conditional | |
| 63 | +Sept-Îles,97007,Côte-Nord,unifamilial,5.0,1563,93.8,conditional | |
| 64 | +Saint-Hippolyte,75045,Laurentides,unifamilial,5.0,1558,96.2,conditional | |
| 65 | +Candiac,67020,Montérégie,unifamilial,5.0,1555,95.5,conditional | |
| 66 | +Sainte-Adèle,77022,Laurentides,unifamilial,5.0,1534,94.8,conditional | |
| 67 | +Saint-Lazare,71105,Montérégie,unifamilial,5.0,1521,94.8,conditional | |
| 68 | +Boisbriand,73005,Laurentides,unifamilial,5.0,1519,96.2,conditional | |
| 69 | +Lévis,25213,Chaudière-Appalaches,plex,5.0,1511,95.5,published | |
| 70 | +Shawinigan,36033,Mauricie,plex,5.0,1485,96.9,conditional | |
| 71 | +Sainte-Julienne,63060,Lanaudière,unifamilial,5.0,1468,96.6,conditional | |
| 72 | +La Prairie,67015,Montérégie,unifamilial,5.0,1465,93.8,conditional | |
| 73 | +Saint-Sauveur,77043,Laurentides,unifamilial,5.0,1461,94.8,conditional | |
| 74 | +Mont-Saint-Hilaire,57035,Montérégie,unifamilial,5.0,1428,93.1,conditional | |
| 75 | +Val-des-Monts,82015,Outaouais,unifamilial,4.0,1424,94.8,not_published | |
| 76 | +Sainte-Marthe-sur-le-Lac,72015,Laurentides,unifamilial,4.0,1399,93.5,not_published | |
| 77 | +Lavaltrie,52007,Lanaudière,unifamilial,4.0,1393,95.5,not_published | |
| 78 | +Rawdon,62037,Lanaudière,unifamilial,4.0,1366,95.5,not_published | |
| 79 | +Pointe-Claire,66097,Montréal,unifamilial,4.0,1340,94.8,not_published | |
| 80 | +Varennes,59020,Montérégie,unifamilial,4.0,1312,91.4,not_published | |
| 81 | +Drummondville,49058,Centre-du-Québec,plex,4.0,1205,94.2,published | |
| 82 | +Prévost,75040,Laurentides,unifamilial,4.0,1203,93.1,not_published | |
| 83 | +Baie-Comeau,96020,Côte-Nord,unifamilial,4.0,1179,88.3,not_published | |
| 84 | +Sainte-Brigitte-de-Laval,22045,Capitale-Nationale,unifamilial,3.0,1162,89.7,not_published | |
| 85 | +Saint-Basile-le-Grand,57020,Montérégie,unifamilial,4.0,1148,92.8,not_published | |
| 86 | +Beaconsfield,66107,Montréal,unifamilial,4.0,1147,92.8,not_published | |
| 87 | +Mirabel,74005,Laurentides,plex,4.0,1146,94.5,not_published | |
| 88 | +Mercier,67045,Montérégie,unifamilial,4.0,1141,90.4,not_published | |
| 89 | +Saint-Raymond,34128,Capitale-Nationale,unifamilial,4.0,1140,92.1,not_published | |
| 90 | +Saint-Calixte,63055,Lanaudière,unifamilial,4.0,1130,93.1,not_published | |
| 91 | +Bromont,46078,Estrie,unifamilial,3.0,1053,91.1,not_published | |
| 92 | +Sainte-Anne-des-Plaines,73035,Laurentides,unifamilial,3.0,1049,91.8,not_published | |
| 93 | +L'Ancienne-Lorette,23057,Capitale-Nationale,unifamilial,3.0,1049,89.3,not_published | |
| 94 | +Saint-Augustin-de-Desmaures,23072,Capitale-Nationale,unifamilial,3.0,1043,87.6,not_published | |
| 95 | +Stoneham-et-Tewkesbury,22035,Capitale-Nationale,unifamilial,3.0,1039,93.1,not_published | |
| 96 | +Saint-Amable,59015,Montérégie,unifamilial,3.0,1039,91.1,not_published | |
| 97 | +Saint-Jean-sur-Richelieu,56083,Montérégie,plex,3.0,1029,92.8,not_published | |
| 98 | +Chertsey,62047,Lanaudière,unifamilial,4.0,1026,90.7,not_published | |
| 99 | +Dolbeau-Mistassini,92022,Saguenay–Lac-Saint-Jean,unifamilial,4.0,1023,88.3,not_published | |
| 100 | +Sainte-Catherine-de-la-Jacques-Cartier,22005,Capitale-Nationale,unifamilial,3.0,1015,86.3,not_published | |
| 101 | +Bécancour,38010,Centre-du-Québec,unifamilial,3.0,1010,91.4,not_published | |
| 102 | +Deux-Montagnes,72010,Laurentides,unifamilial,3.0,1010,90.7,not_published | |
| 103 | +Sainte-Catherine,67030,Montérégie,unifamilial,3.0,1004,88.7,not_published | |
| 104 | +Lachute,76020,Laurentides,unifamilial,3.0,1001,90.0,not_published | |
| 105 | +Rivière-du-Loup,12072,Bas-Saint-Laurent,unifamilial,3.0,990,88.0,not_published | |
| 106 | +Sainte-Thérèse,73010,Laurentides,unifamilial,3.0,981,93.5,not_published | |
| 107 | +Rosemère,73020,Laurentides,unifamilial,3.0,970,91.4,not_published | |
| 108 | +Saint-Apollinaire,33090,Chaudière-Appalaches,unifamilial,3.0,970,88.0,not_published | |
| 109 | +Mont-Tremblant,78102,Laurentides,unifamilial,3.0,968,90.4,not_published | |
| 110 | +Cowansville,46080,Estrie,unifamilial,3.0,968,90.0,not_published | |
| 111 | +Salaberry-de-Valleyfield,70052,Montérégie,plex,3.0,955,91.1,not_published | |
| 112 | +Saint-Lambert,58012,Montérégie,unifamilial,3.0,947,89.7,not_published | |
| 113 | +Granby,47017,Estrie,plex,3.0,944,86.9,not_published | |
| 114 | +Terrebonne,64008,Lanaudière,condo,3.0,929,82.8,not_published | |
| 115 | +Terrebonne,64008,Lanaudière,plex,3.0,920,90.7,not_published | |
| 116 | +Lévis,25213,Chaudière-Appalaches,condo,3.0,919,79.4,published | |
| 117 | +Pont-Rouge,34017,Capitale-Nationale,unifamilial,3.0,918,83.5,not_published | |
| 118 | +Matane,08053,Bas-Saint-Laurent,unifamilial,3.0,917,87.6,not_published | |
| 119 | +Kirkland,66102,Montréal,unifamilial,3.0,917,89.3,not_published | |
| 120 | +Carignan,57010,Montérégie,unifamilial,3.0,912,89.3,not_published | |
| 121 | +Beauharnois,70022,Montérégie,unifamilial,3.0,906,90.4,not_published | |
| 122 | +Mont-Laurier,79088,Laurentides,unifamilial,3.0,904,89.7,not_published | |
| 123 | +Contrecoeur,59035,Montérégie,unifamilial,3.0,884,87.6,not_published | |
| 124 | +Sainte-Agathe-des-Monts,78032,Laurentides,unifamilial,3.0,882,92.1,not_published | |
| 125 | +Cantley,82020,Outaouais,unifamilial,3.0,878,88.0,not_published | |
| 126 | +Saint-Adolphe-d'Howard,77065,Laurentides,unifamilial,3.0,865,91.4,not_published | |
| 127 | +Saint-Zotique,71025,Montérégie,unifamilial,3.0,855,87.6,not_published | |
| 128 | +Pincourt,71070,Montérégie,unifamilial,3.0,852,87.6,not_published | |
| 129 | +Saint-Donat,62060,Lanaudière,unifamilial,3.0,846,89.0,not_published | |
| 130 | +Marieville,55048,Montérégie,unifamilial,3.0,834,86.9,not_published | |
| 131 | +Joliette,61025,Lanaudière,unifamilial,3.0,827,88.3,not_published | |
| 132 | +Chelsea,82025,Outaouais,unifamilial,3.0,809,82.1,not_published | |
| 133 | +Sainte-Marie,26030,Chaudière-Appalaches,unifamilial,3.0,808,82.8,not_published | |
| 134 | +La Pêche,82035,Outaouais,unifamilial,3.0,807,86.6,not_published | |
| 135 | +Saint-Hyacinthe,54048,Montérégie,plex,3.0,800,86.9,not_published | |
| 136 | +Lorraine,73025,Laurentides,unifamilial,3.0,796,85.6,not_published | |
| 137 | +Les Îles-de-la-Madeleine,01023,Gaspésie–Îles-de-la-Madeleine,unifamilial,3.0,778,88.0,not_published | |
| 138 | +Westmount,66032,Montréal,unifamilial,3.0,765,86.3,not_published | |
| 139 | +Saint-Jérôme,75017,Laurentides,condo,2.0,760,75.9,not_published | |
| 140 | +Brownsburg-Chatham,76043,Laurentides,unifamilial,2.0,756,89.3,not_published | |
| 141 | +Saint-Félicien,91042,Saguenay–Lac-Saint-Jean,unifamilial,3.0,755,86.3,not_published | |
| 142 | +Lac-Beauport,22040,Capitale-Nationale,unifamilial,2.0,751,85.6,not_published | |
| 143 | +Saint-Philippe,67010,Montérégie,unifamilial,3.0,748,84.5,not_published | |
| 144 | +Otterburn Park,57030,Montérégie,unifamilial,2.0,745,86.9,not_published | |
| 145 | +Gaspé,03005,Gaspésie–Îles-de-la-Madeleine,unifamilial,3.0,742,87.3,not_published | |
| 146 | +Notre-Dame-de-l'Île-Perrot,71065,Montérégie,unifamilial,3.0,741,85.9,not_published | |
| 147 | +Farnham,46112,Estrie,unifamilial,3.0,719,83.5,not_published | |
| 148 | +La Tuque,90012,Mauricie,unifamilial,3.0,714,84.5,not_published | |
| 149 | +Mascouche,64015,Lanaudière,condo,2.0,713,78.0,not_published | |
| 150 | +Mont-Royal,66072,Montréal,unifamilial,2.0,711,86.9,not_published | |
| 151 | +Orford,45115,Estrie,unifamilial,3.0,703,84.5,not_published | |
| 152 | +Notre-Dame-des-Prairies,61030,Lanaudière,unifamilial,2.0,691,82.8,not_published | |
| 153 | +Côte-Saint-Luc,66058,Montréal,condo,2.5,681,81.8,not_published | |
| 154 | +Sorel-Tracy,53052,Montérégie,plex,2.0,670,87.3,not_published | |
| 155 | +Côte-Saint-Luc,66058,Montréal,unifamilial,2.0,668,83.2,not_published | |
| 156 | +Chibougamau,99025,Nord-du-Québec,unifamilial,2.0,662,80.4,not_published | |
| 157 | +Dorval,66087,Montréal,unifamilial,2.0,660,80.8,not_published | |
| 158 | +L'Épiphanie,60037,Lanaudière,unifamilial,2.0,651,82.5,not_published | |
| 159 | +Saint-Charles-Borromée,61035,Lanaudière,unifamilial,2.0,645,84.5,not_published | |
| 160 | +Sainte-Marguerite-du-Lac-Masson,77012,Laurentides,unifamilial,2.0,643,84.9,not_published | |
| 161 | +Boischatel,21045,Capitale-Nationale,unifamilial,2.0,638,79.7,not_published | |
| 162 | +Rouyn-Noranda,86042,Abitibi-Témiscamingue,plex,2.0,633,83.5,not_published | |
| 163 | +Bois-des-Filion,73030,Laurentides,unifamilial,2.0,631,83.2,not_published | |
| 164 | +Roberval,91025,Saguenay–Lac-Saint-Jean,unifamilial,2.0,625,82.1,not_published | |
| 165 | +Shefford,47035,Estrie,unifamilial,2.0,624,84.5,not_published | |
| 166 | +Lac-Brome,46075,Estrie,unifamilial,2.0,619,80.8,not_published | |
| 167 | +Morin-Heights,77050,Laurentides,unifamilial,2.0,612,81.1,not_published | |
| 168 | +Rigaud,71133,Montérégie,unifamilial,2.0,609,82.8,not_published | |
| 169 | +Montmagny,18050,Chaudière-Appalaches,unifamilial,2.0,605,80.8,not_published | |
| 170 | +La Malbaie,15013,Capitale-Nationale,unifamilial,2.0,596,81.8,not_published | |
| 171 | +Saint-Jean-de-Matha,62015,Lanaudière,unifamilial,2.0,594,75.6,not_published | |
| 172 | +Donnacona,34025,Capitale-Nationale,unifamilial,2.0,588,77.3,not_published | |
| 173 | +Shannon,22020,Capitale-Nationale,unifamilial,2.0,587,69.8,not_published | |
| 174 | +Acton Vale,48028,Montérégie,unifamilial,2.0,586,78.4,not_published | |
| 175 | +Blainville,73015,Laurentides,plex,2.0,576,80.4,not_published | |
| 176 | +Saint-Lambert,58012,Montérégie,condo,2.0,574,70.1,not_published | |
| 177 | +Saint-Honoré,94240,Saguenay–Lac-Saint-Jean,unifamilial,2.0,570,79.0,not_published | |
| 178 | +Boucherville,58033,Montérégie,condo,2.0,570,65.6,not_published | |
| 179 | +Pointe-Calumet,72020,Laurentides,unifamilial,2.0,568,81.1,not_published | |
| 180 | +Val-David,78010,Laurentides,unifamilial,2.0,566,78.4,not_published | |
| 181 | +Hébertville,93022,Saguenay–Lac-Saint-Jean,unifamilial,2.0,561,75.9,not_published | |
| 182 | +Saint-Paul,61005,Lanaudière,unifamilial,2.0,555,78.7,not_published | |
| 183 | +Saint-Alphonse-Rodriguez,62025,Lanaudière,unifamilial,2.0,554,85.9,not_published | |
| 184 | +Alma,93042,Saguenay–Lac-Saint-Jean,plex,2.0,554,76.3,not_published | |
| 185 | +Val-des-Sources,40043,Estrie,unifamilial,2.0,547,76.6,not_published | |
| 186 | +Coaticook,44037,Estrie,unifamilial,2.0,546,77.7,not_published | |
| 187 | +Waterloo,47025,Estrie,unifamilial,2.0,544,75.3,not_published | |
| 188 | +L'Île-Perrot,71060,Montérégie,unifamilial,2.0,542,75.6,not_published | |
| 189 | +Saint-Augustin-de-Desmaures,23072,Capitale-Nationale,condo,2.0,538,72.5,not_published | |
| 190 | +Saint-Ferréol-les-Neiges,21010,Capitale-Nationale,unifamilial,2.0,538,78.7,not_published | |
| 191 | +Saint-Lambert-de-Lauzon,26070,Chaudière-Appalaches,unifamilial,2.0,537,74.9,not_published | |
| 192 | +Saint-Félix-de-Valois,62007,Lanaudière,unifamilial,2.0,532,76.6,not_published | |
| 193 | +Victoriaville,39062,Centre-du-Québec,plex,2.0,526,75.9,not_published | |
| 194 | +Hudson,71100,Montérégie,unifamilial,2.0,520,75.9,not_published | |
| 195 | +L'Ange-Gardien,82005,Outaouais,unifamilial,2.0,515,75.3,not_published | |
| 196 | +Pointe-Claire,66097,Montréal,condo,2.0,508,72.5,not_published | |
| 197 | +Roxton Pond,47047,Estrie,unifamilial,2.0,504,74.6,not_published | |
| 198 | +Coteau-du-Lac,71040,Montérégie,unifamilial,2.0,503,74.9,not_published | |
| 199 | +Sherbrooke,43027,Estrie,condo,2.0,498,76.3,published | |
| 200 | +Delson,67025,Montérégie,unifamilial,2.0,494,72.2,not_published | |
| 201 | +Les Cèdres,71050,Montérégie,unifamilial,2.0,494,73.5,not_published | |
| 202 | +Magog,45072,Estrie,condo,2.0,493,76.6,not_published | |
| 203 | +Lanoraie,52017,Lanaudière,unifamilial,2.0,493,75.9,not_published | |
| 204 | +Chambly,57005,Montérégie,condo,2.0,491,67.0,not_published | |
| 205 | +Sainte-Thérèse,73010,Laurentides,plex,2.0,490,74.9,not_published | |
| 206 | +Saint-Rémi,68055,Montérégie,unifamilial,2.0,490,75.9,not_published | |
| 207 | +Saint-Jean-sur-Richelieu,56083,Montérégie,condo,2.0,488,67.7,not_published | |
| 208 | +Saint-Côme,62065,Lanaudière,unifamilial,2.0,488,75.6,not_published | |
| 209 | +Mascouche,64015,Lanaudière,plex,2.0,486,75.3,not_published | |
| 210 | +Saint-Pie,54008,Montérégie,unifamilial,2.0,484,72.9,not_published | |
| 211 | +Saint-Lin–Laurentides,63048,Lanaudière,plex,2.0,483,75.9,not_published | |
| 212 | +Sainte-Anne-des-Lacs,77035,Laurentides,unifamilial,2.0,481,73.9,not_published | |
| 213 | +Notre-Dame-du-Mont-Carmel,37235,Mauricie,unifamilial,2.0,480,74.6,not_published | |
| 214 | +Mont-Tremblant,78102,Laurentides,condo,2.0,478,72.9,not_published | |
| 215 | +Thetford Mines,31084,Chaudière-Appalaches,plex,2.0,476,74.9,not_published | |
| 216 | +Saint-Damien,62075,Lanaudière,unifamilial,2.0,474,76.6,not_published | |
| 217 | +Rivière-Rouge,79037,Laurentides,unifamilial,2.0,472,73.5,not_published | |
| 218 | +Nicolet,50072,Centre-du-Québec,unifamilial,2.0,472,72.9,not_published | |
| 219 | +Mont-Blanc,78047,Laurentides,unifamilial,2.0,468,77.3,not_published | |
| 220 | +Sainte-Anne-des-Monts,04037,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,464,73.5,not_published | |
| 221 | +Les Coteaux,71033,Montérégie,unifamilial,2.0,462,73.9,not_published | |
| 222 | +Piedmont,77030,Laurentides,unifamilial,2.0,455,73.9,not_published | |
| 223 | +Saint-Denis-de-Brompton,42025,Estrie,unifamilial,2.0,454,73.2,not_published | |
| 224 | +Pontiac,82030,Outaouais,unifamilial,2.0,446,75.6,not_published | |
| 225 | +Saint-Alexis-des-Monts,51065,Mauricie,unifamilial,2.0,445,75.9,not_published | |
| 226 | +Granby,47017,Estrie,condo,2.0,442,64.3,not_published | |
| 227 | +Dollard-des-Ormeaux,66142,Montréal,condo,2.0,439,67.7,not_published | |
| 228 | +Val-d'Or,89008,Abitibi-Témiscamingue,plex,2.0,431,75.6,not_published | |
| 229 | +Saint-Agapit,33045,Chaudière-Appalaches,unifamilial,2.0,431,71.1,not_published | |
| 230 | +Sainte-Sophie,75028,Laurentides,plex,2.0,430,72.2,not_published | |
| 231 | +Dorval,66087,Montréal,condo,2.0,424,70.4,not_published | |
| 232 | +La Sarre,87090,Abitibi-Témiscamingue,unifamilial,2.0,424,70.8,not_published | |
| 233 | +Sutton,46058,Estrie,unifamilial,2.0,415,74.9,not_published | |
| 234 | +Mont-Joli,09077,Bas-Saint-Laurent,unifamilial,2.0,415,68.0,not_published | |
| 235 | +Repentigny,60013,Lanaudière,plex,2.0,412,72.9,not_published | |
| 236 | +Beaupré,21025,Capitale-Nationale,unifamilial,2.0,408,67.7,not_published | |
| 237 | +Saint-Henri,19068,Chaudière-Appalaches,unifamilial,2.0,406,66.3,not_published | |
| 238 | +Joliette,61025,Lanaudière,plex,2.0,404,72.5,not_published | |
| 239 | +Port-Cartier,97022,Côte-Nord,unifamilial,2.0,400,67.0,not_published | |
| 240 | +Verchères,59025,Montérégie,unifamilial,2.0,398,67.7,not_published | |
| 241 | +Princeville,32033,Centre-du-Québec,unifamilial,2.0,398,67.7,not_published | |
| 242 | +Château-Richer,21035,Capitale-Nationale,unifamilial,2.0,396,63.2,not_published | |
| 243 | +Rimouski,10043,Bas-Saint-Laurent,plex,2.0,396,64.9,not_published | |
| 244 | +Saint-Césaire,55023,Montérégie,unifamilial,2.0,391,62.9,not_published | |
| 245 | +Louiseville,51015,Mauricie,unifamilial,2.0,391,67.4,not_published | |
| 246 | +Mandeville,52095,Lanaudière,unifamilial,2.0,381,67.4,not_published | |
| 247 | +Sainte-Thérèse,73010,Laurentides,condo,2.0,380,60.1,not_published | |
| 248 | +Gore,76025,Laurentides,unifamilial,2.0,378,71.8,not_published | |
| 249 | +Eastman,45093,Estrie,unifamilial,2.0,378,70.1,not_published | |
| 250 | +Trois-Rivières,37067,Mauricie,condo,2.0,376,56.4,published | |
| 251 | +Saint-Roch-de-Richelieu,53040,Montérégie,unifamilial,2.0,375,64.6,not_published | |
| 252 | +Saint-Joseph-du-Lac,72025,Laurentides,unifamilial,2.0,374,67.0,not_published | |
| 253 | +Magog,45072,Estrie,plex,2.0,372,67.4,not_published | |
| 254 | +Val-Morin,78005,Laurentides,unifamilial,2.0,371,70.4,not_published | |
| 255 | +Saint-David-de-Falardeau,94245,Saguenay–Lac-Saint-Jean,unifamilial,2.0,371,67.4,not_published | |
| 256 | +Lac-Supérieur,78095,Laurentides,unifamilial,2.0,370,68.7,not_published | |
| 257 | +Notre-Dame-du-Laus,79005,Laurentides,unifamilial,2.0,368,66.0,not_published | |
| 258 | +Lac-Etchemin,28053,Chaudière-Appalaches,unifamilial,2.0,365,64.3,not_published | |
| 259 | +Saint-Michel-des-Saints,62085,Lanaudière,unifamilial,2.0,365,67.7,not_published | |
| 260 | +Saint-Eustache,72005,Laurentides,condo,2.0,365,58.8,not_published | |
| 261 | +Baie-Saint-Paul,16013,Capitale-Nationale,unifamilial,2.0,364,64.9,not_published | |
| 262 | +Saint-Roch-de-l'Achigan,63035,Lanaudière,unifamilial,2.0,362,67.0,not_published | |
| 263 | +Sainte-Adèle,77022,Laurentides,plex,2.0,361,65.3,not_published | |
| 264 | +Chandler,02028,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,357,69.4,not_published | |
| 265 | +Saint-Anicet,69070,Montérégie,unifamilial,2.0,354,64.6,not_published | |
| 266 | +Grenville-sur-la-Rouge,76052,Laurentides,unifamilial,1.0,351,68.7,not_published | |
| 267 | +Wentworth-Nord,77060,Laurentides,unifamilial,1.0,347,66.7,not_published | |
| 268 | +Témiscouata-sur-le-Lac,13073,Bas-Saint-Laurent,unifamilial,2.0,344,60.8,not_published | |
| 269 | +Neuville,34007,Capitale-Nationale,unifamilial,1.0,343,62.9,not_published | |
| 270 | +Sainte-Agathe-des-Monts,78032,Laurentides,plex,1.0,340,63.2,not_published | |
| 271 | +Austin,45085,Estrie,unifamilial,1.0,339,66.3,not_published | |
| 272 | +Thurso,80050,Outaouais,unifamilial,1.0,336,63.2,not_published | |
| 273 | +Saint-Jacques,63013,Lanaudière,unifamilial,1.5,335,61.2,not_published | |
| 274 | +Labelle,78120,Laurentides,unifamilial,1.0,334,61.9,not_published | |
| 275 | +Lac-Mégantic,30030,Estrie,unifamilial,1.0,333,60.8,not_published | |
| 276 | +Saint-Cyrille-de-Wendover,49070,Centre-du-Québec,unifamilial,1.0,329,59.8,not_published | |
| 277 | +Lachute,76020,Laurentides,plex,1.0,328,65.6,not_published | |
| 278 | +Saint-André-Avellin,80027,Outaouais,unifamilial,1.0,327,63.6,not_published | |
| 279 | +Venise-en-Québec,56005,Montérégie,unifamilial,2.0,327,61.9,not_published | |
| 280 | +Gracefield,83032,Outaouais,unifamilial,1.0,326,60.5,not_published | |
| 281 | +La Pocatière,14082,Bas-Saint-Laurent,unifamilial,1.0,325,61.5,not_published | |
| 282 | +Napierville,68030,Montérégie,unifamilial,1.0,325,59.5,not_published | |
| 283 | +Saint-Boniface,51085,Mauricie,unifamilial,1.0,325,63.9,not_published | |
| 284 | +Saint-Gabriel-de-Brandon,52085,Lanaudière,unifamilial,1.0,321,61.9,not_published | |
| 285 | +Nominingue,79030,Laurentides,unifamilial,1.0,321,63.6,not_published | |
| 286 | +Amqui,07047,Bas-Saint-Laurent,unifamilial,1.5,321,57.7,not_published | |
| 287 | +Saguenay,94068,Saguenay–Lac-Saint-Jean,condo,2.0,321,54.3,published | |
| 288 | +Saint-Eustache,72005,Laurentides,plex,1.0,320,63.9,not_published | |
| 289 | +Oka,72032,Laurentides,unifamilial,1.0,320,62.9,not_published | |
| 290 | +Danville,40047,Estrie,unifamilial,1.0,319,63.6,not_published | |
| 291 | +Sainte-Julie,59010,Montérégie,condo,1.0,318,61.5,not_published | |
| 292 | +Saint-Ambroise,94255,Saguenay–Lac-Saint-Jean,unifamilial,1.0,318,59.8,not_published | |
| 293 | +Potton,45030,Estrie,unifamilial,1.0,317,63.9,not_published | |
| 294 | +Weedon,41098,Estrie,unifamilial,1.0,314,64.6,not_published | |
| 295 | +Candiac,67020,Montérégie,condo,2.0,314,54.0,not_published | |
| 296 | +Beauceville,27028,Chaudière-Appalaches,unifamilial,1.0,314,59.5,not_published | |
| 297 | +Amherst,78070,Laurentides,unifamilial,1.0,313,62.5,not_published | |
| 298 | +Sainte-Béatrix,62020,Lanaudière,unifamilial,1.0,312,66.0,not_published | |
| 299 | +Cap-Santé,34030,Capitale-Nationale,unifamilial,1.0,312,59.8,not_published | |
| 300 | +Saint-Mathias-sur-Richelieu,55065,Montérégie,unifamilial,1.0,311,63.2,not_published | |
| 301 | +Saint-Gilles,33035,Chaudière-Appalaches,unifamilial,2.0,310,61.5,not_published | |
| 302 | +Portneuf,34048,Capitale-Nationale,unifamilial,2.0,310,57.0,not_published | |
| 303 | +Saint-Étienne-des-Grès,51090,Mauricie,unifamilial,1.0,309,60.1,not_published | |
| 304 | +Saint-Tite,35027,Mauricie,unifamilial,2.0,308,61.5,not_published | |
| 305 | +Windsor,42088,Estrie,unifamilial,1.0,307,57.0,not_published | |
| 306 | +Saint-Bruno-de-Montarville,58037,Montérégie,condo,1.0,306,62.9,not_published | |
| 307 | +Sainte-Martine,70012,Montérégie,unifamilial,1.0,302,61.9,not_published | |
| 308 | +Métabetchouan–Lac-à-la-Croix,93012,Saguenay–Lac-Saint-Jean,unifamilial,1.0,301,60.5,not_published | |
| 309 | +Saint-Anselme,19062,Chaudière-Appalaches,unifamilial,1.0,299,55.0,not_published | |
| 310 | +Plessisville,32043,Centre-du-Québec,unifamilial,2.0,299,51.5,not_published | |
| 311 | +Cookshire-Eaton,41038,Estrie,unifamilial,1.0,298,60.8,not_published | |
| 312 | +Saint-Paul-de-l'Île-aux-Noix,56035,Montérégie,unifamilial,1.0,298,58.8,not_published | |
| 313 | +Maniwaki,83065,Outaouais,unifamilial,1.0,291,57.0,not_published | |
| 314 | +Richelieu,55057,Montérégie,unifamilial,1.0,289,60.1,not_published | |
| 315 | +Saint-Germain-de-Grantham,49048,Centre-du-Québec,unifamilial,1.0,288,58.8,not_published | |
| 316 | +East Angus,41060,Estrie,unifamilial,1.0,288,61.5,not_published | |
| 317 | +Lac-Simon,80095,Outaouais,unifamilial,1.0,284,59.1,not_published | |
| 318 | +Saint-Joseph-de-Beauce,27043,Chaudière-Appalaches,unifamilial,1.0,282,56.7,not_published | |
| 319 | +Beaumont,19105,Chaudière-Appalaches,unifamilial,1.0,280,55.3,not_published | |
| 320 | +Fossambault-sur-le-Lac,22010,Capitale-Nationale,unifamilial,1.0,280,55.3,not_published | |
| 321 | +Dunham,46050,Estrie,unifamilial,1.0,279,60.1,not_published | |
| 322 | +Saint-Prosper,28020,Chaudière-Appalaches,unifamilial,1.0,279,58.1,not_published | |
| 323 | +Saint-Georges,29073,Chaudière-Appalaches,plex,1.0,279,56.7,not_published | |
| 324 | +McMasterville,57025,Montérégie,unifamilial,1.0,278,57.0,not_published | |
| 325 | +Saint-Antonin,12015,Bas-Saint-Laurent,unifamilial,1.0,275,54.6,not_published | |
| 326 | +Saint-André-d'Argenteuil,76008,Laurentides,unifamilial,1.0,275,55.3,not_published | |
| 327 | +Lac-des-Écorces,79078,Laurentides,unifamilial,1.0,273,56.7,not_published | |
| 328 | +Sainte-Émélie-de-l'Énergie,62070,Lanaudière,unifamilial,1.0,273,54.3,not_published | |
| 329 | +Adstock,31056,Chaudière-Appalaches,unifamilial,1.0,272,59.5,not_published | |
| 330 | +Baie-Comeau,96020,Côte-Nord,plex,1.0,272,51.9,not_published | |
| 331 | +Châteauguay,67050,Montérégie,plex,1.0,271,55.3,not_published | |
| 332 | +Sainte-Mélanie,61050,Lanaudière,unifamilial,1.0,270,58.4,not_published | |
| 333 | +La Minerve,78130,Laurentides,unifamilial,1.0,267,59.5,not_published | |
| 334 | +Notre-Dame-de-la-Merci,62055,Lanaudière,unifamilial,1.0,267,60.1,not_published | |
| 335 | +Warwick,39077,Centre-du-Québec,unifamilial,1.0,265,57.0,not_published | |
| 336 | +Beloeil,57040,Montérégie,condo,1.0,264,50.9,not_published | |
| 337 | +Sainte-Claire,19055,Chaudière-Appalaches,unifamilial,1.0,264,52.9,not_published | |
| 338 | +Saint-Sauveur,77043,Laurentides,plex,1.0,263,56.4,not_published | |
| 339 | +Huntingdon,69055,Montérégie,unifamilial,1.0,262,53.3,not_published | |
| 340 | +Rivière-Beaudette,71005,Montérégie,unifamilial,1.0,260,54.6,not_published | |
| 341 | +Saint-Ambroise-de-Kildare,61040,Lanaudière,unifamilial,1.0,260,55.7,not_published | |
| 342 | +Baie-D'Urfé,66112,Montréal,unifamilial,1.0,259,57.0,not_published | |
| 343 | +Saint-Maurice,37230,Mauricie,unifamilial,1.0,256,52.2,not_published | |
| 344 | +Mont-Royal,66072,Montréal,condo,1.0,256,51.2,not_published | |
| 345 | +La Tuque,90012,Mauricie,plex,1.0,255,54.0,not_published | |
| 346 | +Hampstead,66062,Montréal,unifamilial,1.0,254,56.7,not_published | |
| 347 | +Rivière-du-Loup,12072,Bas-Saint-Laurent,plex,1.0,254,54.6,not_published | |
| 348 | +Sainte-Marthe-sur-le-Lac,72015,Laurentides,condo,1.0,252,49.8,not_published | |
| 349 | +Mont-Laurier,79088,Laurentides,plex,1.0,251,56.0,not_published | |
| 350 | +Saint-Isidore,26063,Chaudière-Appalaches,unifamilial,1.0,250,51.9,not_published | |
| 351 | +Carleton-sur-Mer,06013,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,247,51.9,not_published | |
| 352 | +Sept-Îles,97007,Côte-Nord,plex,1.0,247,56.4,not_published | |
| 353 | +Sainte-Anne-de-Beaupré,21030,Capitale-Nationale,unifamilial,1.0,246,51.2,not_published | |
| 354 | +Crabtree,61013,Lanaudière,unifamilial,1.0,246,54.3,not_published | |
| 355 | +Saint-Alphonse-de-Granby,47010,Estrie,unifamilial,1.0,245,51.5,not_published | |
| 356 | +Ormstown,69037,Montérégie,unifamilial,1.0,244,53.3,not_published | |
| 357 | +Berthierville,52035,Lanaudière,unifamilial,1.0,244,52.6,not_published | |
| 358 | +Saint-Gabriel-de-Valcartier,22025,Capitale-Nationale,unifamilial,1.0,244,48.8,not_published | |
| 359 | +Saint-Élie-de-Caxton,51075,Mauricie,unifamilial,1.0,244,54.0,not_published | |
| 360 | +Saint-Basile,34038,Capitale-Nationale,unifamilial,1.0,243,51.2,not_published | |
| 361 | +Sainte-Luce,09092,Bas-Saint-Laurent,unifamilial,1.0,243,54.3,not_published | |
| 362 | +Malartic,89015,Abitibi-Témiscamingue,unifamilial,1.0,242,54.6,not_published | |
| 363 | +L'Islet,17078,Chaudière-Appalaches,unifamilial,1.0,241,50.2,not_published | |
| 364 | +Saint-Gabriel,52080,Lanaudière,unifamilial,1.0,241,54.0,not_published | |
| 365 | +Sainte-Catherine-de-Hatley,45060,Estrie,unifamilial,1.0,240,51.5,not_published | |
| 366 | +Sainte-Croix,33102,Chaudière-Appalaches,unifamilial,1.0,239,52.6,not_published | |
| 367 | +Blainville,73015,Laurentides,condo,1.0,239,48.8,not_published | |
| 368 | +Notre-Dame-des-Bois,30010,Estrie,unifamilial,1.0,238,54.0,not_published | |
| 369 | +Lacolle,56023,Montérégie,unifamilial,1.0,236,52.2,not_published | |
| 370 | +Sainte-Lucie-des-Laurentides,78020,Laurentides,unifamilial,1.0,235,53.6,not_published | |
| 371 | +Papineauville,80037,Outaouais,unifamilial,1.0,234,53.3,not_published | |
| 372 | +Cowansville,46080,Estrie,plex,1.0,234,49.8,not_published | |
| 373 | +Beauharnois,70022,Montérégie,plex,1.0,231,53.3,not_published | |
| 374 | +Saint-Denis-sur-Richelieu,57068,Montérégie,unifamilial,1.0,230,49.5,not_published | |
| 375 | +Westmount,66032,Montréal,condo,1.0,230,50.5,not_published | |
| 376 | +Léry,67055,Montérégie,unifamilial,1.0,230,49.8,not_published | |
| 377 | +Saint-Liboire,54072,Montérégie,unifamilial,1.0,230,51.5,not_published | |
| 378 | +Mont-Tremblant,78102,Laurentides,plex,1.0,229,53.6,not_published | |
| 379 | +Vaudreuil-Dorion,71083,Montérégie,condo,1.0,229,41.9,not_published | |
| 380 | +Saint-Christophe-d'Arthabaska,39060,Centre-du-Québec,unifamilial,1.0,228,53.6,not_published | |
| 381 | +Ascot Corner,41055,Estrie,unifamilial,1.0,228,50.5,not_published | |
| 382 | +L'Assomption,60028,Lanaudière,plex,1.0,226,53.6,not_published | |
| 383 | +New Richmond,05070,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,226,51.2,not_published | |
| 384 | +Mont-Saint-Grégoire,56097,Montérégie,unifamilial,1.0,224,51.2,not_published | |
| 385 | +Montpellier,80090,Outaouais,unifamilial,1.0,223,50.5,not_published | |
| 386 | +Saint-Zénon,62080,Lanaudière,unifamilial,1.0,223,48.8,not_published | |
| 387 | +Trois-Pistoles,11040,Bas-Saint-Laurent,unifamilial,1.0,222,50.5,not_published | |
| 388 | +Saint-Jean-Port-Joli,17070,Chaudière-Appalaches,unifamilial,1.0,222,48.8,not_published | |
| 389 | +Saint-Raphaël,19082,Chaudière-Appalaches,unifamilial,1.0,222,50.9,not_published | |
| 390 | +Saint-Mathieu-de-Beloeil,57045,Montérégie,unifamilial,1.0,221,46.4,not_published | |
| 391 | +Sainte-Marie-Madeleine,54030,Montérégie,unifamilial,1.0,220,47.8,not_published | |
| 392 | +Sainte-Clotilde,68020,Montérégie,unifamilial,1.0,220,49.1,not_published | |
| 393 | +Ferme-Neuve,79097,Laurentides,unifamilial,1.0,219,46.7,not_published | |
| 394 | +Sainte-Anne-de-Sabrevois,56060,Montérégie,unifamilial,1.0,218,45.7,not_published | |
| 395 | +Saint-Colomban,75005,Laurentides,plex,1.0,217,46.7,not_published | |
| 396 | +Sainte-Anne-de-Bellevue,66117,Montréal,unifamilial,1.0,216,48.1,not_published | |
| 397 | +Saint-Alexandre,56055,Montérégie,unifamilial,1.0,216,47.8,not_published | |
| 398 | +Daveluyville,39152,Centre-du-Québec,unifamilial,1.0,215,50.9,not_published | |
| 399 | +Sainte-Thècle,35050,Mauricie,unifamilial,1.0,215,47.8,not_published | |
| 400 | +Saint-Mathieu-du-Parc,51070,Mauricie,unifamilial,1.0,214,51.9,not_published | |
| 401 | +La Conception,78115,Laurentides,unifamilial,1.0,214,51.2,not_published | |
| 402 | +Saint-Amable,59015,Montérégie,condo,1.0,213,44.7,not_published | |
| 403 | +Saint-Louis-de-Gonzague,70035,Montérégie,unifamilial,1.0,212,47.8,not_published | |
| 404 | +Notre-Dame-de-Lourdes,61045,Lanaudière,unifamilial,1.0,211,46.4,not_published | |
| 405 | +Témiscaming,85005,Abitibi-Témiscamingue,unifamilial,1.0,211,50.2,not_published | |
| 406 | +Sainte-Marcelline-de-Kildare,62030,Lanaudière,unifamilial,1.0,211,51.2,not_published | |
| 407 | +Saint-Hippolyte,75045,Laurentides,plex,1.0,210,49.1,not_published | |
| 408 | +Hinchinbrooke,69045,Montérégie,unifamilial,1.0,209,50.5,not_published | |
| 409 | +Saint-Marc-des-Carrières,34065,Capitale-Nationale,unifamilial,1.0,208,43.6,not_published | |
| 410 | +Brigham,46090,Estrie,unifamilial,1.0,208,48.8,not_published | |
| 411 | +Saint-Dominique,54060,Montérégie,unifamilial,1.0,208,48.5,not_published | |
| 412 | +Rougemont,55037,Montérégie,unifamilial,1.0,207,48.8,not_published | |
| 413 | +Amos,88057,Abitibi-Témiscamingue,unifamilial,2.0,206,28.5,not_published | |
| 414 | +Clermont,15035,Capitale-Nationale,unifamilial,1.0,205,45.4,not_published | |
| 415 | +Farnham,46112,Estrie,plex,1.0,205,47.4,not_published | |
| 416 | +Montréal-Ouest,66047,Montréal,unifamilial,1.0,205,44.7,not_published | |
| 417 | +Saint-Barthélemy,52055,Lanaudière,unifamilial,1.0,205,50.5,not_published | |
| 418 | +Entrelacs,62053,Lanaudière,unifamilial,1.0,204,49.8,not_published | |
| 419 | +Disraeli,31015,Chaudière-Appalaches,unifamilial,1.0,203,48.8,not_published | |
| 420 | +Saint-Stanislas-de-Kostka,70040,Montérégie,unifamilial,1.0,203,46.7,not_published | |
| 421 | +Saint-Polycarpe,71020,Montérégie,unifamilial,1.0,203,48.8,not_published | |
| 422 | +Val-des-Bois,80140,Outaouais,unifamilial,1.0,202,44.0,not_published | |
| 423 | +Stanstead,45008,Estrie,unifamilial,1.0,202,50.9,not_published | |
| 424 | +Saint-Côme–Linière,29057,Chaudière-Appalaches,unifamilial,1.0,201,43.6,not_published | |
| 425 | +Scott,26048,Chaudière-Appalaches,unifamilial,1.0,200,47.1,not_published | |
| 426 | +Ange-Gardien,55008,Montérégie,unifamilial,1.0,199,46.4,not_published | |
| 427 | +Saint-Lucien,49030,Centre-du-Québec,unifamilial,1.0,199,47.8,not_published | |
| 428 | +Mille-Isles,76030,Laurentides,unifamilial,1.0,198,48.5,not_published | |
| 429 | +Lac-aux-Sables,35010,Mauricie,unifamilial,1.0,198,48.1,not_published | |
| 430 | +Saint-Michel,68050,Montérégie,unifamilial,1.0,197,45.7,not_published | |
| 431 | +Cap-Saint-Ignace,18045,Chaudière-Appalaches,unifamilial,1.0,197,46.0,not_published | |
| 432 | +Saint-Chrysostome,69017,Montérégie,unifamilial,1.0,197,47.8,not_published | |
| 433 | +Saint-Charles-de-Bellechasse,19097,Chaudière-Appalaches,unifamilial,1.0,196,43.0,not_published | |
| 434 | +Saint-Joseph-de-Coleraine,31045,Chaudière-Appalaches,unifamilial,1.0,196,44.3,not_published | |
| 435 | +Normandin,92040,Saguenay–Lac-Saint-Jean,unifamilial,1.0,196,47.4,not_published | |
| 436 | +Harrington,76065,Laurentides,unifamilial,1.0,195,47.4,not_published | |
| 437 | +Saint-Blaise-sur-Richelieu,56065,Montérégie,unifamilial,1.0,195,44.7,not_published | |
| 438 | +L'Ascension-de-Notre-Seigneur,93065,Saguenay–Lac-Saint-Jean,unifamilial,1.0,195,43.3,not_published | |
| 439 | +Dolbeau-Mistassini,92022,Saguenay–Lac-Saint-Jean,plex,1.0,195,43.3,not_published | |
| 440 | +Stoke,42005,Estrie,unifamilial,1.0,195,47.1,not_published | |
| 441 | +Chambly,57005,Montérégie,plex,1.0,195,44.7,not_published | |
| 442 | +Berthier-sur-Mer,18065,Chaudière-Appalaches,unifamilial,1.0,195,44.3,not_published | |
| 443 | +Ripon,80078,Outaouais,unifamilial,1.0,194,46.0,not_published | |
| 444 | +Stratford,30110,Estrie,unifamilial,1.0,193,45.0,not_published | |
| 445 | +Sainte-Anne-de-Sorel,53065,Montérégie,unifamilial,1.0,192,46.7,not_published | |
| 446 | +Sainte-Barbe,69065,Montérégie,unifamilial,1.0,192,43.0,not_published | |
| 447 | +Saint-François-du-Lac,50128,Centre-du-Québec,unifamilial,1.0,192,47.4,not_published | |
| 448 | +Charlemagne,60005,Lanaudière,unifamilial,1.0,191,43.6,not_published | |
| 449 | +Saint-Thomas,61027,Lanaudière,unifamilial,1.0,190,45.4,not_published | |
| 450 | +Richmond,42098,Estrie,unifamilial,1.0,188,47.4,not_published | |
| 451 | +Saint-Sulpice,60020,Lanaudière,unifamilial,1.0,188,45.0,not_published | |
| 452 | +Deschambault-Grondines,34058,Capitale-Nationale,unifamilial,1.0,188,44.7,not_published | |
| 453 | +Saint-Elzéar,26022,Chaudière-Appalaches,unifamilial,1.0,188,44.7,not_published | |
| 454 | +Pohénégamook,13095,Bas-Saint-Laurent,unifamilial,1.0,188,44.7,not_published | |
| 455 | +Bolton-Est,45095,Estrie,unifamilial,1.0,187,46.0,not_published | |
| 456 | +Matane,08053,Bas-Saint-Laurent,plex,1.0,187,41.9,not_published | |
| 457 | +Lebel-sur-Quévillon,99005,Nord-du-Québec,unifamilial,1.0,186,44.7,not_published | |
| 458 | +Bedford,46035,Estrie,unifamilial,1.0,186,43.6,not_published | |
| 459 | +Macamic,87058,Abitibi-Témiscamingue,unifamilial,1.0,186,45.4,not_published | |
| 460 | +Brossard,58007,Montérégie,plex,1.0,184,43.3,not_published | |
| 461 | +Waterville,44080,Estrie,unifamilial,1.0,184,41.2,not_published | |
| 462 | +Havre-Saint-Pierre,98040,Côte-Nord,unifamilial,1.0,184,46.4,not_published | |
| 463 | +Boischatel,21045,Capitale-Nationale,condo,1.0,182,39.5,not_published | |
| 464 | +Saint-Anaclet-de-Lessard,10030,Bas-Saint-Laurent,unifamilial,1.0,182,44.3,not_published | |
| 465 | +Pierreville,50113,Centre-du-Québec,unifamilial,1.0,182,44.7,not_published | |
| 466 | +Saint-Tite-des-Caps,21005,Capitale-Nationale,unifamilial,1.0,181,42.6,not_published | |
| 467 | +Saint-Prime,91035,Saguenay–Lac-Saint-Jean,unifamilial,1.0,181,44.3,not_published | |
| 468 | +Saint-Paul-d'Abbotsford,55015,Montérégie,unifamilial,1.0,180,43.6,not_published | |
| 469 | +Senneterre,89040,Abitibi-Témiscamingue,unifamilial,1.0,179,40.5,not_published | |
| 470 | +Saint-Bernard,26055,Chaudière-Appalaches,unifamilial,1.0,178,40.9,not_published | |
| 471 | +Saint-Jean-Baptiste,57033,Montérégie,unifamilial,1.0,177,44.0,not_published | |
| 472 | +Compton,44071,Estrie,unifamilial,1.0,176,42.3,not_published | |
| 473 | +Saint-Antoine-de-Tilly,33095,Chaudière-Appalaches,unifamilial,1.0,176,41.2,not_published | |
| 474 | +Larouche,94265,Saguenay–Lac-Saint-Jean,unifamilial,1.0,176,40.9,not_published | |
| 475 | +Percé,02005,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,176,44.0,not_published | |
| 476 | +Maskinongé,51008,Mauricie,unifamilial,1.0,176,44.7,not_published | |
| 477 | +Saint-Pascal,14018,Bas-Saint-Laurent,unifamilial,1.0,175,44.7,not_published | |
| 478 | +Sainte-Victoire-de-Sorel,53025,Montérégie,unifamilial,1.0,175,41.6,not_published | |
| 479 | +Beloeil,57040,Montérégie,plex,1.0,175,39.2,not_published | |
| 480 | +Saint-Isidore,67040,Montérégie,unifamilial,1.0,173,43.3,not_published | |
| 481 | +Mansfield-et-Pontefract,84065,Outaouais,unifamilial,1.0,173,40.9,not_published | |
| 482 | +Chute-Saint-Philippe,79065,Laurentides,unifamilial,1.0,173,44.7,not_published | |
| 483 | +Saint-Gédéon,93035,Saguenay–Lac-Saint-Jean,unifamilial,1.0,172,42.6,not_published | |
| 484 | +Messines,83060,Outaouais,unifamilial,1.0,172,44.7,not_published | |
| 485 | +Noyan,56015,Montérégie,unifamilial,1.0,172,40.9,not_published | |
| 486 | +Wickham,49040,Centre-du-Québec,unifamilial,1.0,172,42.3,not_published | |
| 487 | +Laurier-Station,33060,Chaudière-Appalaches,unifamilial,1.0,171,41.2,not_published | |
| 488 | +Saint-Gédéon-de-Beauce,29013,Chaudière-Appalaches,unifamilial,1.0,171,41.6,not_published | |
| 489 | +Maria,06005,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,170,41.9,not_published | |
| 490 | +Rimouski,10043,Bas-Saint-Laurent,condo,1.0,169,41.2,not_published | |
| 491 | +L'Isle-aux-Allumettes,84082,Outaouais,unifamilial,1.0,169,43.0,not_published | |
| 492 | +Yamachiche,51020,Mauricie,unifamilial,1.0,168,40.2,not_published | |
| 493 | +East Broughton,31122,Chaudière-Appalaches,unifamilial,1.0,168,40.5,not_published | |
| 494 | +Ville-Marie,85025,Abitibi-Témiscamingue,unifamilial,1.0,168,41.9,not_published | |
| 495 | +Saint-Antoine-sur-Richelieu,57075,Montérégie,unifamilial,1.0,168,40.2,not_published | |
| 496 | +Val-des-Sources,40043,Estrie,plex,1.0,167,43.0,not_published | |
| 497 | +Clarenceville,56010,Montérégie,unifamilial,1.0,167,41.6,not_published | |
| 498 | +Piedmont,77030,Laurentides,condo,1.0,167,37.5,not_published | |
| 499 | +Hemmingford,68015,Montérégie,unifamilial,1.0,167,43.6,not_published | |
| 500 | +Saint-Liguori,63065,Lanaudière,unifamilial,1.0,167,39.9,not_published | |
| 501 | +Saint-Damien-de-Buckland,19030,Chaudière-Appalaches,unifamilial,1.0,167,43.3,not_published | |
| 502 | +Cap-Chat,04047,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,167,40.5,not_published | |
| 503 | +Upton,48038,Montérégie,unifamilial,1.0,167,43.3,not_published | |
| 504 | +Saint-Léonard-d'Aston,50042,Centre-du-Québec,unifamilial,1.0,166,39.5,not_published | |
| 505 | +Dégelis,13005,Bas-Saint-Laurent,unifamilial,1.0,166,40.9,not_published | |
| 506 | +Duhamel,80135,Outaouais,unifamilial,1.0,166,40.9,not_published | |
| 507 | +Paspébiac,05032,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,165,41.9,not_published | |
| 508 | +Saint-Malachie,19025,Chaudière-Appalaches,unifamilial,1.0,164,39.2,not_published | |
| 509 | +Lac-Bouchette,91005,Saguenay–Lac-Saint-Jean,unifamilial,1.0,164,43.3,not_published | |
| 510 | +Fermont,97035,Côte-Nord,unifamilial,2.0,164,10.7,not_published | |
| 511 | +Saint-François-Xavier-de-Brompton,42020,Estrie,unifamilial,1.0,163,40.5,not_published | |
| 512 | +Lambton,30095,Estrie,unifamilial,1.0,162,42.3,not_published | |
| 513 | +Champlain,37220,Mauricie,unifamilial,1.0,162,37.5,not_published | |
| 514 | +Sainte-Geneviève-de-Berthier,52040,Lanaudière,unifamilial,1.0,162,40.9,not_published | |
| 515 | +Saint-Damase,54017,Montérégie,unifamilial,1.0,162,41.2,not_published | |
| 516 | +Saint-Ferdinand,32013,Centre-du-Québec,unifamilial,1.0,161,38.8,not_published | |
| 517 | +Dudswell,41117,Estrie,unifamilial,1.0,161,39.5,not_published | |
| 518 | +Saint-Fabien,10070,Bas-Saint-Laurent,unifamilial,1.0,161,43.0,not_published | |
| 519 | +Saint-Ours,53032,Montérégie,unifamilial,1.0,160,40.2,not_published | |
| 520 | +Sainte-Cécile-de-Milton,47055,Estrie,unifamilial,1.0,160,39.9,not_published | |
| 521 | +La Macaza,79047,Laurentides,unifamilial,1.0,159,39.2,not_published | |
| 522 | +Saint-Ubalde,34090,Capitale-Nationale,unifamilial,1.0,159,39.9,not_published | |
| 523 | +Saint-Marc-sur-Richelieu,57050,Montérégie,unifamilial,1.0,158,41.9,not_published | |
| 524 | +Barraute,88022,Abitibi-Témiscamingue,unifamilial,1.0,158,38.5,not_published | |
| 525 | +Mont-Saint-Hilaire,57035,Montérégie,condo,1.0,157,33.0,not_published | |
| 526 | +Labrecque,93055,Saguenay–Lac-Saint-Jean,unifamilial,1.0,157,39.2,not_published | |
| 527 | +Saint-Placide,72043,Laurentides,unifamilial,1.0,157,40.5,not_published | |
| 528 | +Varennes,59020,Montérégie,condo,1.0,156,35.4,not_published | |
| 529 | +Val-des-Monts,82015,Outaouais,plex,1.0,156,40.9,not_published | |
| 530 | +La Prairie,67015,Montérégie,condo,1.0,156,30.6,not_published | |
| 531 | +Forestville,95045,Côte-Nord,unifamilial,1.0,155,38.5,not_published | |
| 532 | +Déléage,83070,Outaouais,unifamilial,1.0,155,38.5,not_published | |
| 533 | +Saint-Cuthbert,52062,Lanaudière,unifamilial,1.0,155,39.9,not_published | |
| 534 | +Saint-Ulric,08073,Bas-Saint-Laurent,unifamilial,1.0,154,38.8,not_published | |
| 535 | +Sainte-Marthe-sur-le-Lac,72015,Laurentides,plex,1.0,154,35.7,not_published | |
| 536 | +Bonaventure,05045,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,153,36.4,not_published | |
| 537 | +Prévost,75040,Laurentides,plex,1.0,152,38.1,not_published | |
| 538 | +Boisbriand,73005,Laurentides,plex,1.0,152,37.5,not_published | |
| 539 | +Sainte-Clotilde-de-Horton,39117,Centre-du-Québec,unifamilial,1.0,152,37.8,not_published | |
| 540 | +L'Ancienne-Lorette,23057,Capitale-Nationale,plex,1.0,152,36.4,not_published | |
| 541 | +Notre-Dame-du-Bon-Conseil,49075,Centre-du-Québec,unifamilial,1.0,151,38.8,not_published | |
| 542 | +Marieville,55048,Montérégie,plex,1.0,151,36.8,not_published | |
| 543 | +Saint-Nazaire,93045,Saguenay–Lac-Saint-Jean,unifamilial,1.0,151,37.8,not_published | |
| 544 | +Saint-Amable,59015,Montérégie,plex,1.0,150,35.1,not_published | |
| 545 | +Saint-Hubert-de-Rivière-du-Loup,12010,Bas-Saint-Laurent,unifamilial,1.0,150,39.2,not_published | |
| 546 | +Saint-Martin,29045,Chaudière-Appalaches,unifamilial,1.0,150,38.1,not_published | |
| 547 | +Saint-Constant,67035,Montérégie,plex,1.0,148,38.5,not_published | |
| 548 | +Val-des-Lacs,78100,Laurentides,unifamilial,1.0,148,39.9,not_published | |
| 549 | +Saint-Mathieu,67005,Montérégie,unifamilial,1.0,147,38.8,not_published | |
| 550 | +Saint-Fulgence,94235,Saguenay–Lac-Saint-Jean,unifamilial,1.0,147,35.7,not_published | |
| 551 | +Deux-Montagnes,72010,Laurentides,plex,1.0,146,38.5,not_published | |
| 552 | +Chambord,91020,Saguenay–Lac-Saint-Jean,unifamilial,1.0,146,39.9,not_published | |
| 553 | +Armagh,19037,Chaudière-Appalaches,unifamilial,1.0,146,38.1,not_published | |
| 554 | +Montmagny,18050,Chaudière-Appalaches,plex,1.0,145,37.1,not_published | |
| 555 | +Orford,45115,Estrie,condo,1.0,145,36.1,not_published | |
| 556 | +Mirabel,74005,Laurentides,condo,1.0,145,33.0,not_published | |
| 557 | +Rawdon,62037,Lanaudière,plex,1.0,144,37.1,not_published | |
| 558 | +La Prairie,67015,Montérégie,plex,1.0,144,36.4,not_published | |
| 559 | +Lavaltrie,52007,Lanaudière,plex,1.0,144,38.5,not_published | |
| 560 | +Lantier,78015,Laurentides,unifamilial,1.0,144,38.5,not_published | |
| 561 | +Lac-Sainte-Marie,83020,Outaouais,unifamilial,1.0,144,38.1,not_published | |
| 562 | +Cayamant,83040,Outaouais,unifamilial,1.0,144,35.7,not_published | |
| 563 | +Boisbriand,73005,Laurentides,condo,1.0,144,35.7,not_published | |
| 564 | +Kingsey Falls,39097,Centre-du-Québec,unifamilial,1.0,143,35.7,not_published | |
| 565 | +Coaticook,44037,Estrie,plex,1.0,143,37.8,not_published | |
| 566 | +Saint-Victor,27008,Chaudière-Appalaches,unifamilial,1.0,142,36.1,not_published | |
| 567 | +Yamaska,53072,Montérégie,unifamilial,1.0,142,35.1,not_published | |
| 568 | +Bury,41070,Estrie,unifamilial,1.0,142,38.5,not_published | |
| 569 | +Val-David,78010,Laurentides,plex,1.0,142,37.5,not_published | |
| 570 | +Saint-Ignace-de-Loyola,52045,Lanaudière,unifamilial,1.0,142,35.4,not_published | |
| 571 | +Grande-Rivière,02015,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,141,38.5,not_published | |
| 572 | +Roberval,91025,Saguenay–Lac-Saint-Jean,plex,1.0,141,37.5,not_published | |
| 573 | +Hatley,45055,Estrie,unifamilial,1.0,141,35.7,not_published | |
| 574 | +Les Escoumins,95025,Côte-Nord,unifamilial,1.0,140,34.4,not_published | |
| 575 | +Chapais,99020,Nord-du-Québec,unifamilial,1.0,139,37.8,not_published | |
| 576 | +Vaudreuil-Dorion,71083,Montérégie,plex,1.0,139,35.4,not_published | |
| 577 | +Saint-Charles-sur-Richelieu,57057,Montérégie,unifamilial,1.0,139,35.1,not_published | |
| 578 | +Shawville,84010,Outaouais,unifamilial,1.0,139,35.7,not_published | |
| 579 | +Albanel,92030,Saguenay–Lac-Saint-Jean,unifamilial,1.0,138,34.0,not_published | |
| 580 | +Saint-Félix-de-Kingsey,49005,Centre-du-Québec,unifamilial,1.0,138,37.1,not_published | |
| 581 | +Causapscal,07018,Bas-Saint-Laurent,unifamilial,1.0,137,36.4,not_published | |
| 582 | +Saint-Charles-Borromée,61035,Lanaudière,plex,1.0,137,37.1,not_published | |
| 583 | +Sainte-Anne-des-Plaines,73035,Laurentides,plex,1.0,136,35.1,not_published | |
| 584 | +Beaulac-Garthby,31008,Chaudière-Appalaches,unifamilial,1.0,136,36.1,not_published | |
| 585 | +L'Île-Perrot,71060,Montérégie,plex,1.0,136,35.1,not_published | |
| 586 | +Saint-Léonard-de-Portneuf,34115,Capitale-Nationale,unifamilial,1.0,136,38.5,not_published | |
| 587 | +Lac-Mégantic,30030,Estrie,plex,1.0,136,35.7,not_published | |
| 588 | +Sainte-Adèle,77022,Laurentides,condo,1.0,136,34.4,not_published | |
| 589 | +Lac-des-Plages,80130,Outaouais,unifamilial,1.0,136,36.1,not_published | |
| 590 | +Notre-Dame-des-Pins,29120,Chaudière-Appalaches,unifamilial,1.0,136,33.3,not_published | |
| 591 | +Henryville,56042,Montérégie,unifamilial,1.0,135,33.7,not_published | |
| 592 | +Rivière-Héva,89010,Abitibi-Témiscamingue,unifamilial,1.0,135,34.4,not_published | |
| 593 | +Châteauguay,67050,Montérégie,condo,1.0,135,32.6,not_published | |
| 594 | +Beaupré,21025,Capitale-Nationale,condo,1.0,134,34.7,not_published | |
| 595 | +Pincourt,71070,Montérégie,condo,1.0,134,28.9,not_published | |
| 596 | +Bois-des-Filion,73030,Laurentides,condo,1.0,134,30.6,not_published | |
| 597 | +La Présentation,54035,Montérégie,unifamilial,1.0,134,34.0,not_published | |
| 598 | +Sainte-Ursule,51040,Mauricie,unifamilial,1.0,134,37.5,not_published | |
| 599 | +Stanstead,45025,Estrie,unifamilial,1.0,134,34.0,not_published | |
| 600 | +Saint-Michel-de-Bellechasse,19110,Chaudière-Appalaches,unifamilial,1.0,133,36.4,not_published | |
| 601 | +Sainte-Anne-de-la-Pérade,37205,Mauricie,unifamilial,1.0,132,34.4,not_published | |
| 602 | +Les Éboulements,16048,Capitale-Nationale,unifamilial,1.0,132,36.4,not_published | |
| 603 | +Saint-Pamphile,17010,Chaudière-Appalaches,unifamilial,1.0,132,34.4,not_published | |
| 604 | +Saint-Alban,34097,Capitale-Nationale,unifamilial,1.0,131,34.0,not_published | |
| 605 | +Otter Lake,84055,Outaouais,unifamilial,1.0,131,35.7,not_published | |
| 606 | +Sainte-Christine-d'Auvergne,34105,Capitale-Nationale,unifamilial,1.0,129,34.7,not_published | |
| 607 | +Caplan,05060,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,129,36.8,not_published | |
| 608 | +La Malbaie,15013,Capitale-Nationale,plex,1.0,129,33.0,not_published | |
| 609 | +Pointe-des-Cascades,71055,Montérégie,unifamilial,1.0,129,34.0,not_published | |
| 610 | +Bowman,80145,Outaouais,unifamilial,1.0,128,32.3,not_published | |
| 611 | +Louiseville,51015,Mauricie,plex,1.0,128,34.4,not_published | |
| 612 | +Sainte-Hélène-de-Bagot,54095,Montérégie,unifamilial,1.0,127,32.3,not_published | |
| 613 | +Valcourt,42055,Estrie,unifamilial,1.0,126,32.3,not_published | |
| 614 | +Sayabec,07085,Bas-Saint-Laurent,unifamilial,1.0,126,33.7,not_published | |
| 615 | +Sainte-Catherine,67030,Montérégie,plex,1.0,126,31.6,not_published | |
| 616 | +Saint-Jacques-le-Mineur,68040,Montérégie,unifamilial,1.0,126,30.6,not_published | |
| 617 | +Saint-Bernard-de-Lacolle,68005,Montérégie,unifamilial,1.0,126,33.0,not_published | |
| 618 | +Tingwick,39025,Centre-du-Québec,unifamilial,1.0,126,36.4,not_published | |
| 619 | +Grand-Remous,83095,Outaouais,unifamilial,1.0,126,34.0,not_published | |
| 620 | +Charlemagne,60005,Lanaudière,condo,1.0,126,30.2,not_published | |
| 621 | +Trois-Rives,35055,Mauricie,unifamilial,1.0,125,34.7,not_published | |
| 622 | +Hérouxville,35035,Mauricie,unifamilial,1.0,125,33.0,not_published | |
| 623 | +L'Épiphanie,60037,Lanaudière,plex,1.0,125,35.1,not_published | |
| 624 | +Grenville,76055,Laurentides,unifamilial,1.0,125,33.0,not_published | |
| 625 | +Montcalm,78055,Laurentides,unifamilial,1.0,125,33.3,not_published | |
| 626 | +Windsor,42088,Estrie,plex,1.0,124,30.9,not_published | |
| 627 | +Low,83010,Outaouais,unifamilial,1.0,124,32.0,not_published | |
| 628 | +Frontenac,30025,Estrie,unifamilial,1.0,124,33.0,not_published | |
| 629 | +Sainte-Justine,28045,Chaudière-Appalaches,unifamilial,1.0,124,32.0,not_published | |
| 630 | +Bristol,84005,Outaouais,unifamilial,1.0,124,30.9,not_published | |
| 631 | +Sainte-Madeleine,54025,Montérégie,unifamilial,1.0,124,31.6,not_published | |
| 632 | +Saint-Alexandre-de-Kamouraska,14035,Bas-Saint-Laurent,unifamilial,1.0,124,30.9,not_published | |
| 633 | +Saint-Robert,53020,Montérégie,unifamilial,1.0,123,33.3,not_published | |
| 634 | +Saint-Albert,39085,Centre-du-Québec,unifamilial,1.0,122,33.3,not_published | |
| 635 | +Saint-Herménégilde,44015,Estrie,unifamilial,1.0,122,32.3,not_published | |
| 636 | +Price,09065,Bas-Saint-Laurent,unifamilial,1.0,122,31.3,not_published | |
| 637 | +Donnacona,34025,Capitale-Nationale,plex,1.0,121,31.6,not_published | |
| 638 | +Saint-Laurent-de-l'Île-d'Orléans,20020,Capitale-Nationale,unifamilial,1.0,121,35.1,not_published | |
| 639 | +Ayer's Cliff,45035,Estrie,unifamilial,1.0,121,33.3,not_published | |
| 640 | +Cacouna,12057,Bas-Saint-Laurent,unifamilial,1.0,120,31.3,not_published | |
| 641 | +Saint-Valérien-de-Milton,54065,Montérégie,unifamilial,1.0,120,33.7,not_published | |
| 642 | +Wotton,40017,Estrie,unifamilial,1.0,120,33.7,not_published | |
| 643 | +Contrecoeur,59035,Montérégie,plex,1.0,119,30.2,not_published | |
| 644 | +Cleveland,42110,Estrie,unifamilial,1.0,119,32.6,not_published | |
| 645 | +Saint-Félix-d'Otis,94225,Saguenay–Lac-Saint-Jean,unifamilial,1.0,119,32.6,not_published | |
| 646 | +Lyster,32065,Centre-du-Québec,unifamilial,1.0,119,33.0,not_published | |
| 647 | +Saint-Gervais,19075,Chaudière-Appalaches,unifamilial,1.0,119,33.7,not_published | |
| 648 | +Senneterre,89045,Abitibi-Témiscamingue,unifamilial,1.0,119,34.0,not_published | |
| 649 | +Saint-Louis-de-Blandford,39170,Centre-du-Québec,unifamilial,1.0,118,31.6,not_published | |
| 650 | +Saint-Aubert,17055,Chaudière-Appalaches,unifamilial,1.0,118,28.5,not_published | |
| 651 | +Notre-Dame-de-Pontmain,79010,Laurentides,unifamilial,1.0,118,34.0,not_published | |
| 652 | +Bois-des-Filion,73030,Laurentides,plex,1.0,117,31.3,not_published | |
| 653 | +Vallée-Jonction,26015,Chaudière-Appalaches,unifamilial,1.0,117,32.0,not_published | |
| 654 | +Saint-Zacharie,28005,Chaudière-Appalaches,unifamilial,1.0,117,31.6,not_published | |
| 655 | +Saint-Théodore-d'Acton,48045,Montérégie,unifamilial,1.0,117,30.6,not_published | |
| 656 | +Saint-Esprit,63030,Lanaudière,unifamilial,1.0,117,31.3,not_published | |
| 657 | +Frampton,26005,Chaudière-Appalaches,unifamilial,1.0,117,33.0,not_published | |
| 658 | +Saint-Paulin,51060,Mauricie,unifamilial,1.0,117,34.4,not_published | |
| 659 | +Port-Daniel–Gascons,02047,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,116,31.6,not_published | |
| 660 | +Saint-Sauveur,77043,Laurentides,condo,1.0,116,32.0,not_published | |
| 661 | +Saint-Clet,71045,Montérégie,unifamilial,1.0,116,32.0,not_published | |
| 662 | +Pointe-aux-Outardes,96030,Côte-Nord,unifamilial,1.0,116,30.6,not_published | |
| 663 | +L'Isle-Verte,12043,Bas-Saint-Laurent,unifamilial,1.0,116,29.6,not_published | |
| 664 | +L'Île-Perrot,71060,Montérégie,condo,1.0,115,31.3,not_published | |
| 665 | +Saint-Félicien,91042,Saguenay–Lac-Saint-Jean,plex,1.0,115,30.9,not_published | |
| 666 | +Stukely-Sud,45105,Estrie,unifamilial,1.0,115,32.6,not_published | |
| 667 | +Denholm,83005,Outaouais,unifamilial,1.0,115,30.9,not_published | |
| 668 | +Les Îles-de-la-Madeleine,01023,Gaspésie–Îles-de-la-Madeleine,plex,1.0,114,31.3,not_published | |
| 669 | +Disraeli,31020,Chaudière-Appalaches,unifamilial,1.0,114,32.3,not_published | |
| 670 | +Cantley,82020,Outaouais,plex,1.0,114,30.6,not_published | |
| 671 | +Carignan,57010,Montérégie,condo,1.0,114,31.3,not_published | |
| 672 | +Chénéville,80103,Outaouais,unifamilial,1.0,113,33.7,not_published | |
| 673 | +L'Ascension,79050,Laurentides,unifamilial,1.0,113,30.6,not_published | |
| 674 | +Notre-Dame-de-Montauban,35005,Mauricie,unifamilial,1.0,113,32.3,not_published | |
| 675 | +Saint-Alexis,63023,Lanaudière,unifamilial,1.0,113,30.9,not_published | |
| 676 | +L'Anse-Saint-Jean,94210,Saguenay–Lac-Saint-Jean,unifamilial,1.0,113,29.2,not_published | |
| 677 | +Contrecoeur,59035,Montérégie,condo,1.0,113,28.5,not_published | |
| 678 | +Val-Joli,42095,Estrie,unifamilial,1.0,112,32.6,not_published | |
| 679 | +Saint-Joachim-de-Shefford,47040,Estrie,unifamilial,1.0,112,28.5,not_published | |
| 680 | +Sainte-Angèle-de-Monnoir,55030,Montérégie,unifamilial,1.0,112,28.9,not_published | |
| 681 | +Eeyou Istchee Baie-James,99060,Nord-du-Québec,unifamilial,1.0,112,32.0,not_published | |
| 682 | +Lac-Sergent,34120,Capitale-Nationale,unifamilial,1.0,112,31.3,not_published | |
| 683 | +Mont-Carmel,14005,Bas-Saint-Laurent,unifamilial,1.0,112,31.6,not_published | |
| 684 | +McMasterville,57025,Montérégie,plex,1.0,111,30.6,not_published | |
| 685 | +Wentworth,76035,Laurentides,unifamilial,1.0,111,30.6,not_published | |
| 686 | +Montréal-Est,66007,Montréal,unifamilial,1.0,111,30.9,not_published | |
| 687 | +Saint-Claude,42100,Estrie,unifamilial,1.0,110,28.5,not_published | |
| 688 | +Saint-Gabriel-de-Rimouski,09025,Bas-Saint-Laurent,unifamilial,1.0,110,30.2,not_published | |
| 689 | +Saint-Jean-de-Dieu,11010,Bas-Saint-Laurent,unifamilial,1.0,110,29.2,not_published | |
| 690 | +La Doré,91050,Saguenay–Lac-Saint-Jean,unifamilial,1.0,110,29.9,not_published | |
| 691 | +Petite-Rivière-Saint-François,16005,Capitale-Nationale,unifamilial,1.0,110,30.2,not_published | |
| 692 | +Sainte-Agathe-de-Lotbinière,33017,Chaudière-Appalaches,unifamilial,1.0,110,31.6,not_published | |
| 693 | +Saint-Raymond,34128,Capitale-Nationale,plex,1.0,110,30.6,not_published | |
| 694 | +Saint-Aimé-des-Lacs,15030,Capitale-Nationale,unifamilial,1.0,110,33.3,not_published | |
| 695 | +Notre-Dame-de-la-Salette,80087,Outaouais,unifamilial,1.0,110,29.6,not_published | |
| 696 | +Rivière-à-Pierre,34135,Capitale-Nationale,unifamilial,1.0,110,31.3,not_published | |
| 697 | +Saint-Siméon,15058,Capitale-Nationale,unifamilial,1.0,109,28.9,not_published | |
| 698 | +Saint-Narcisse-de-Rimouski,10015,Bas-Saint-Laurent,unifamilial,1.0,109,28.9,not_published | |
| 699 | +Franklin,69010,Montérégie,unifamilial,1.0,109,30.6,not_published | |
| 700 | +Saint-Hugues,54100,Montérégie,unifamilial,1.0,109,29.9,not_published | |
| 701 | +Saint-Didace,52090,Lanaudière,unifamilial,1.0,108,29.9,not_published | |
| 702 | +Pointe-Lebel,96025,Côte-Nord,unifamilial,1.0,108,26.5,not_published | |
| 703 | +Saint-Mathieu-de-Rioux,11050,Bas-Saint-Laurent,unifamilial,1.0,107,29.9,not_published | |
| 704 | +Saint-Justin,51045,Mauricie,unifamilial,1.0,107,30.9,not_published | |
| 705 | +Varennes,59020,Montérégie,plex,1.0,107,30.9,not_published | |
| 706 | +Notre-Dame-du-Portage,12080,Bas-Saint-Laurent,unifamilial,1.0,106,29.6,not_published | |
| 707 | +Saint-Joachim,21020,Capitale-Nationale,unifamilial,1.0,106,29.2,not_published | |
| 708 | +Saint-Éphrem-de-Beauce,29112,Chaudière-Appalaches,unifamilial,1.0,106,30.6,not_published | |
| 709 | +Saint-Benoît-Labre,29100,Chaudière-Appalaches,unifamilial,1.0,106,28.5,not_published | |
| 710 | +L'Assomption,60028,Lanaudière,condo,1.0,106,26.8,not_published | |
| 711 | +Lamarche,93060,Saguenay–Lac-Saint-Jean,unifamilial,1.0,106,29.9,not_published | |
| 712 | +Saint-Casimir,34078,Capitale-Nationale,unifamilial,1.0,105,31.6,not_published | |
| 713 | +Sacré-Coeur,95010,Côte-Nord,unifamilial,1.0,105,29.9,not_published | |
| 714 | +Saint-Armand,46017,Estrie,unifamilial,1.0,105,28.9,not_published | |
| 715 | +Saint-Eugène,49105,Centre-du-Québec,unifamilial,1.0,105,28.9,not_published | |
| 716 | +Saint-Adelphe,35015,Mauricie,unifamilial,1.0,105,28.5,not_published | |
| 717 | +Sainte-Julie,59010,Montérégie,plex,1.0,105,29.6,not_published | |
| 718 | +Nantes,30045,Estrie,unifamilial,1.0,105,29.6,not_published | |
| 719 | +Saint-Pierre-les-Becquets,38065,Centre-du-Québec,unifamilial,1.0,104,26.1,not_published | |
| 720 | +Batiscan,37210,Mauricie,unifamilial,1.0,104,27.8,not_published | |
| 721 | +Sainte-Hénédine,26040,Chaudière-Appalaches,unifamilial,1.0,104,29.9,not_published | |
| 722 | +Saint-Cyprien-de-Napierville,68035,Montérégie,unifamilial,1.0,104,30.6,not_published | |
| 723 | +Saint-Narcisse,37240,Mauricie,unifamilial,1.0,103,26.8,not_published | |
| 724 | +Montréal-Est,66007,Montréal,plex,1.0,103,29.6,not_published | |
| 725 | +Mont-Joli,09077,Bas-Saint-Laurent,plex,1.0,103,28.2,not_published | |
| 726 | +Bouchette,83050,Outaouais,unifamilial,1.0,103,31.3,not_published | |
| 727 | +Sainte-Geneviève-de-Batiscan,37215,Mauricie,unifamilial,1.0,103,28.5,not_published | |
| 728 | +L'Ancienne-Lorette,23057,Capitale-Nationale,condo,1.0,103,28.2,not_published | |
| 729 | +Saint-Guillaume,49113,Centre-du-Québec,unifamilial,1.0,103,27.8,not_published | |
| 730 | +Racine,42032,Estrie,unifamilial,1.0,102,27.8,not_published | |
| 731 | +Boucherville,58033,Montérégie,plex,1.0,101,28.2,not_published | |
| 732 | +Sainte-Brigide-d'Iberville,56105,Montérégie,unifamilial,1.0,101,29.2,not_published | |
| 733 | +Blue Sea,83045,Outaouais,unifamilial,1.0,101,26.5,not_published | |
| 734 | +Preissac,88090,Abitibi-Témiscamingue,unifamilial,1.0,100,27.8,not_published | |
| 735 | +Saint-Simon,54090,Montérégie,unifamilial,1.0,100,28.5,not_published | |
| 736 | +Matagami,99015,Nord-du-Québec,unifamilial,1.0,100,27.8,not_published | |
| 737 | +Tring-Jonction,27060,Chaudière-Appalaches,unifamilial,1.0,100,25.1,not_published | |
| 738 | +Brownsburg-Chatham,76043,Laurentides,plex,1.0,100,28.2,not_published | |
| 739 | +Aumond,83090,Outaouais,unifamilial,1.0,99,28.5,not_published | |
| 740 | +Notre-Dame-du-Nord,85090,Abitibi-Témiscamingue,unifamilial,1.0,99,29.6,not_published | |
| 741 | +Sainte-Jeanne-d'Arc,92015,Saguenay–Lac-Saint-Jean,unifamilial,1.0,99,28.2,not_published | |
| 742 | +Nicolet,50072,Centre-du-Québec,plex,1.0,99,27.5,not_published | |
| 743 | +Terrasse-Vaudreuil,71075,Montérégie,unifamilial,1.0,99,28.5,not_published | |
| 744 | +Kazabazua,83015,Outaouais,unifamilial,1.0,99,28.5,not_published | |
| 745 | +Saint-Pacôme,14070,Bas-Saint-Laurent,unifamilial,1.0,99,27.8,not_published | |
| 746 | +Rivière-Bleue,13025,Bas-Saint-Laurent,unifamilial,1.0,98,27.8,not_published | |
| 747 | +Saint-Paul-de-Montminy,18030,Chaudière-Appalaches,unifamilial,1.0,98,25.8,not_published | |
| 748 | +Saint-Patrice-de-Sherrington,68025,Montérégie,unifamilial,1.0,98,28.2,not_published | |
| 749 | +Bromont,46078,Estrie,condo,1.0,98,27.8,not_published | |
| 750 | +Nouvelle,06020,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,98,28.5,not_published | |
| 751 | +Desbiens,93005,Saguenay–Lac-Saint-Jean,unifamilial,1.0,97,27.1,not_published | |
| 752 | +Saint-Jean-de-l'Île-d'Orléans,20015,Capitale-Nationale,unifamilial,1.0,97,26.8,not_published | |
| 753 | +Saint-Joseph-de-Sorel,53050,Montérégie,unifamilial,1.0,97,26.8,not_published | |
| 754 | +Roxton Falls,48010,Montérégie,unifamilial,1.0,97,25.4,not_published | |
| 755 | +Clarendon,84015,Outaouais,unifamilial,1.0,97,28.2,not_published | |
| 756 | +L'Avenir,49025,Centre-du-Québec,unifamilial,1.0,97,28.2,not_published | |
| 757 | +Montebello,80010,Outaouais,unifamilial,1.0,97,28.9,not_published | |
| 758 | +Sainte-Aurélie,28015,Chaudière-Appalaches,unifamilial,1.0,97,29.2,not_published | |
| 759 | +Saint-Joseph-de-Sorel,53050,Montérégie,plex,1.0,96,26.5,not_published | |
| 760 | +Frelighsburg,46010,Estrie,unifamilial,1.0,96,29.2,not_published | |
| 761 | +Charlemagne,60005,Lanaudière,plex,1.0,96,28.9,not_published | |
| 762 | +Thurso,80050,Outaouais,plex,1.0,96,27.5,not_published | |
| 763 | +Kiamika,79025,Laurentides,unifamilial,1.0,96,26.5,not_published | |
| 764 | +La Sarre,87090,Abitibi-Témiscamingue,plex,1.0,96,28.9,not_published | |
| 765 | +Sainte-Perpétue,17030,Chaudière-Appalaches,unifamilial,1.0,96,27.1,not_published | |
| 766 | +Saint-Georges-de-Windsor,40032,Estrie,unifamilial,1.0,95,27.5,not_published | |
| 767 | +Notre-Dame-des-Neiges,11045,Bas-Saint-Laurent,unifamilial,1.0,95,26.8,not_published | |
| 768 | +Saint-Jude,54110,Montérégie,unifamilial,1.0,94,25.8,not_published | |
| 769 | +Saint-Benjamin,28025,Chaudière-Appalaches,unifamilial,1.0,94,26.1,not_published | |
| 770 | +Berthierville,52035,Lanaudière,plex,1.0,94,25.1,not_published | |
| 771 | +Saint-Lambert,58012,Montérégie,plex,1.0,94,26.1,not_published | |
| 772 | +Saint-René-de-Matane,08035,Bas-Saint-Laurent,unifamilial,1.0,94,29.9,not_published | |
| 773 | +Saint-Édouard-de-Lotbinière,33080,Chaudière-Appalaches,unifamilial,1.0,94,27.5,not_published | |
| 774 | +Marieville,55048,Montérégie,condo,1.0,93,25.8,not_published | |
| 775 | +Saint-Louis,54120,Montérégie,unifamilial,1.0,93,27.5,not_published | |
| 776 | +East Angus,41060,Estrie,plex,1.0,93,28.2,not_published | |
| 777 | +Saint-Ferréol-les-Neiges,21010,Capitale-Nationale,condo,1.0,93,24.7,not_published | |
| 778 | +Saint-Aimé-du-Lac-des-Îles,79022,Laurentides,unifamilial,1.0,93,27.8,not_published | |
| 779 | +Lorrainville,85037,Abitibi-Témiscamingue,unifamilial,1.0,93,28.5,not_published | |
| 780 | +La Patrie,41027,Estrie,unifamilial,1.0,93,27.1,not_published | |
| 781 | +Inverness,32058,Centre-du-Québec,unifamilial,1.0,92,26.5,not_published | |
| 782 | +Palmarolle,87025,Abitibi-Témiscamingue,unifamilial,1.0,92,27.1,not_published | |
| 783 | +Saint-Basile-le-Grand,57020,Montérégie,condo,1.0,92,22.7,not_published | |
| 784 | +La Guadeloupe,29030,Chaudière-Appalaches,unifamilial,1.0,92,24.7,not_published | |
| 785 | +Lac-Saguay,79060,Laurentides,unifamilial,1.0,91,26.5,not_published | |
| 786 | +Sainte-Rose-de-Watford,28030,Chaudière-Appalaches,unifamilial,1.0,91,25.4,not_published | |
| 787 | +Saint-Léon-de-Standon,19020,Chaudière-Appalaches,unifamilial,1.0,91,26.1,not_published | |
| 788 | +Notre-Dame-Auxiliatrice-de-Buckland,19010,Chaudière-Appalaches,unifamilial,1.0,91,26.1,not_published | |
| 789 | +Mont-Blanc,78047,Laurentides,plex,1.0,90,25.1,not_published | |
| 790 | +Sainte-Marguerite,26035,Chaudière-Appalaches,unifamilial,1.0,90,24.4,not_published | |
| 791 | +Waterloo,47025,Estrie,plex,1.0,90,24.4,not_published | |
| 792 | +Saint-Nérée-de-Bellechasse,19045,Chaudière-Appalaches,unifamilial,1.0,90,26.1,not_published | |
| 793 | +Marston,30035,Estrie,unifamilial,1.0,89,26.1,not_published | |
| 794 | +Saint-Philémon,19005,Chaudière-Appalaches,unifamilial,1.0,89,26.1,not_published | |
| 795 | +Drummondville,49058,Centre-du-Québec,condo,1.0,89,25.1,published | |
| 796 | +Saint-Cyrille-de-Lessard,17045,Chaudière-Appalaches,unifamilial,1.0,89,26.8,not_published | |
| 797 | +Val-Alain,33070,Chaudière-Appalaches,unifamilial,1.0,88,23.7,not_published | |
| 798 | +Saint-Odilon-de-Cranbourne,27035,Chaudière-Appalaches,unifamilial,1.0,88,25.1,not_published | |
| 799 | +Sainte-Sophie-de-Lévrard,38040,Centre-du-Québec,unifamilial,1.0,88,26.8,not_published | |
| 800 | +Saint-Bruno-de-Guigues,85045,Abitibi-Témiscamingue,unifamilial,1.0,88,24.4,not_published | |
| 801 | +Godmanchester,69060,Montérégie,unifamilial,1.0,88,26.8,not_published | |
| 802 | +Lac-du-Cerf,79015,Laurentides,unifamilial,1.0,88,25.1,not_published | |
| 803 | +Richmond,42098,Estrie,plex,1.0,87,25.1,not_published | |
| 804 | +La Corne,88030,Abitibi-Témiscamingue,unifamilial,1.0,87,23.4,not_published | |
| 805 | +Très-Saint-Rédempteur,71125,Montérégie,unifamilial,1.0,87,26.5,not_published | |
| 806 | +Saint-Étienne-de-Bolton,45100,Estrie,unifamilial,1.0,86,24.4,not_published | |
| 807 | +Sainte-Flavie,09085,Bas-Saint-Laurent,unifamilial,1.0,86,25.4,not_published | |
| 808 | +Saint-Urbain-Premier,70005,Montérégie,unifamilial,1.0,86,23.4,not_published | |
| 809 | +Brébeuf,78075,Laurentides,unifamilial,1.0,86,25.1,not_published | |
| 810 | +Ham-Nord,39010,Centre-du-Québec,unifamilial,1.0,85,25.4,not_published | |
| 811 | +Saint-Ludger-de-Milot,93080,Saguenay–Lac-Saint-Jean,unifamilial,1.0,85,25.1,not_published | |
| 812 | +Saint-Valère,39135,Centre-du-Québec,unifamilial,1.0,85,25.1,not_published | |
| 813 | +Sorel-Tracy,53052,Montérégie,condo,1.0,85,26.5,not_published | |
| 814 | +Saint-René,29050,Chaudière-Appalaches,unifamilial,1.0,84,24.4,not_published | |
| 815 | +Ragueneau,96040,Côte-Nord,unifamilial,1.0,84,24.4,not_published | |
| 816 | +North Hatley,45050,Estrie,unifamilial,1.0,84,26.8,not_published | |
| 817 | +Baie-Saint-Paul,16013,Capitale-Nationale,plex,1.0,84,25.4,not_published | |
| 818 | +Saint-Fabien-de-Panet,18015,Chaudière-Appalaches,unifamilial,1.0,84,25.1,not_published | |
| 819 | +Deschaillons-sur-Saint-Laurent,38070,Centre-du-Québec,unifamilial,1.0,84,24.7,not_published | |
| 820 | +Saint-Édouard-de-Maskinongé,51050,Mauricie,unifamilial,1.0,84,22.7,not_published | |
| 821 | +Lac-Brome,46075,Estrie,condo,1.0,84,25.1,not_published | |
| 822 | +Saint-Rémi,68055,Montérégie,condo,1.0,84,21.3,not_published | |
| 823 | +Saint-Magloire,28075,Chaudière-Appalaches,unifamilial,1.0,83,24.7,not_published | |
| 824 | +Saint-Norbert-d'Arthabaska,39043,Centre-du-Québec,unifamilial,1.0,83,24.7,not_published | |
| 825 | +Plaisance,80045,Outaouais,unifamilial,1.0,83,25.4,not_published | |
| 826 | +Saint-Constant,67035,Montérégie,condo,1.0,83,17.9,not_published | |
| 827 | +Fort-Coulonge,84060,Outaouais,unifamilial,1.0,83,24.7,not_published | |
| 828 | +Grande-Vallée,03020,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,83,24.4,not_published | |
| 829 | +Saint-Hilarion,16050,Capitale-Nationale,unifamilial,1.0,83,23.0,not_published | |
| 830 | +Notre-Dame-des-Prairies,61030,Lanaudière,plex,1.0,83,23.7,not_published | |
| 831 | +Saint-Joseph-du-Lac,72025,Laurentides,plex,1.0,82,22.7,not_published | |
| 832 | +Dosquet,33040,Chaudière-Appalaches,unifamilial,1.0,82,24.1,not_published | |
| 833 | +Sainte-Anne-de-Beaupré,21030,Capitale-Nationale,plex,1.0,82,23.7,not_published | |
| 834 | +Saint-Valérien,10060,Bas-Saint-Laurent,unifamilial,1.0,82,24.4,not_published | |
| 835 | +Malartic,89015,Abitibi-Témiscamingue,plex,1.0,82,23.4,not_published | |
| 836 | +Sainte-Eulalie,50005,Centre-du-Québec,unifamilial,1.0,82,25.4,not_published | |
| 837 | +Saint-Camille-de-Lellis,28070,Chaudière-Appalaches,unifamilial,1.0,82,24.1,not_published | |
| 838 | +Saint-Rémi,68055,Montérégie,plex,1.0,82,25.1,not_published | |
| 839 | +Girardville,92055,Saguenay–Lac-Saint-Jean,unifamilial,1.0,81,22.7,not_published | |
| 840 | +Lotbinière,33115,Chaudière-Appalaches,unifamilial,1.0,81,24.7,not_published | |
| 841 | +Laurierville,32072,Centre-du-Québec,unifamilial,1.0,81,24.4,not_published | |
| 842 | +Vaudreuil-sur-le-Lac,71090,Montérégie,unifamilial,1.0,81,24.4,not_published | |
| 843 | +Saint-Donat,09030,Bas-Saint-Laurent,unifamilial,1.0,81,22.7,not_published | |
| 844 | +Sainte-Monique,93075,Saguenay–Lac-Saint-Jean,unifamilial,1.0,81,22.3,not_published | |
| 845 | +Saint-Honoré-de-Shenley,29038,Chaudière-Appalaches,unifamilial,1.0,81,22.3,not_published | |
| 846 | +Saint-Ludger,30072,Estrie,unifamilial,1.0,80,24.7,not_published | |
| 847 | +Saint-Janvier-de-Joly,33065,Chaudière-Appalaches,unifamilial,1.0,80,24.4,not_published | |
| 848 | +Carignan,57010,Montérégie,plex,1.0,80,22.7,not_published | |
| 849 | +Saint-Flavien,33052,Chaudière-Appalaches,unifamilial,1.0,80,21.3,not_published | |
| 850 | +Saint-Wenceslas,50023,Centre-du-Québec,unifamilial,1.0,79,22.7,not_published | |
| 851 | +Saint-Majorique-de-Grantham,49095,Centre-du-Québec,unifamilial,1.0,79,24.1,not_published | |
| 852 | +Gaspé,03005,Gaspésie–Îles-de-la-Madeleine,plex,1.0,79,24.1,not_published | |
| 853 | +Val-Brillant,07080,Bas-Saint-Laurent,unifamilial,1.0,79,21.6,not_published | |
| 854 | +Saint-Siméon,05055,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,79,22.3,not_published | |
| 855 | +Saint-Césaire,55023,Montérégie,plex,1.0,79,20.3,not_published | |
| 856 | +New Carlisle,05040,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,79,24.1,not_published | |
| 857 | +L'Isle-aux-Coudres,16023,Capitale-Nationale,unifamilial,1.0,79,23.4,not_published | |
| 858 | +Saint-Barnabé,51025,Mauricie,unifamilial,1.0,79,22.0,not_published | |
| 859 | +Manseau,38028,Centre-du-Québec,unifamilial,1.0,79,22.3,not_published | |
| 860 | +Lac-Delage,22030,Capitale-Nationale,unifamilial,1.0,79,21.0,not_published | |
| 861 | +Plessisville,32043,Centre-du-Québec,plex,1.0,78,21.6,not_published | |
| 862 | +Saint-Donat,62060,Lanaudière,plex,1.0,78,23.4,not_published | |
| 863 | +Lac-Drolet,30080,Estrie,unifamilial,1.0,78,22.7,not_published | |
| 864 | +Bécancour,38010,Centre-du-Québec,plex,1.0,78,22.3,not_published | |
| 865 | +Acton Vale,48028,Montérégie,plex,1.0,78,22.3,not_published | |
| 866 | +Saint-Norbert,52070,Lanaudière,unifamilial,1.0,78,23.4,not_published | |
| 867 | +Lac-au-Saumon,07057,Bas-Saint-Laurent,unifamilial,1.0,78,23.4,not_published | |
| 868 | +Hemmingford,68010,Montérégie,unifamilial,1.0,77,22.3,not_published | |
| 869 | +Saint-Luc-de-Bellechasse,28060,Chaudière-Appalaches,unifamilial,1.0,77,22.3,not_published | |
| 870 | +Saint-François-de-la-Rivière-du-Sud,18060,Chaudière-Appalaches,unifamilial,1.0,77,22.7,not_published | |
| 871 | +Saint-Augustin-de-Desmaures,23072,Capitale-Nationale,plex,1.0,77,23.7,not_published | |
| 872 | +Saint-Édouard,68045,Montérégie,unifamilial,1.0,76,21.6,not_published | |
| 873 | +Saint-Henri-de-Taillon,93070,Saguenay–Lac-Saint-Jean,unifamilial,1.0,76,24.4,not_published | |
| 874 | +Charette,51080,Mauricie,unifamilial,1.0,76,23.7,not_published | |
| 875 | +Saint-Isidore-de-Clifton,41012,Estrie,unifamilial,1.0,76,22.3,not_published | |
| 876 | +Chibougamau,99025,Nord-du-Québec,plex,1.0,76,21.3,not_published | |
| 877 | +Saint-Félix-de-Valois,62007,Lanaudière,plex,1.0,76,21.3,not_published | |
| 878 | +Boileau,80115,Outaouais,unifamilial,1.0,76,21.3,not_published | |
| 879 | +Crabtree,61013,Lanaudière,plex,1.0,76,21.6,not_published | |
| 880 | +Chute-aux-Outardes,96035,Côte-Nord,unifamilial,1.0,76,21.6,not_published | |
| 881 | +Saint-Thomas-Didyme,92045,Saguenay–Lac-Saint-Jean,unifamilial,1.0,76,22.7,not_published | |
| 882 | +Rivière-Ouelle,14065,Bas-Saint-Laurent,unifamilial,1.0,76,21.6,not_published | |
| 883 | +Laverlochère-Angliers,85052,Abitibi-Témiscamingue,unifamilial,1.0,76,21.0,not_published | |
| 884 | +Saint-Épiphane,12030,Bas-Saint-Laurent,unifamilial,1.0,76,22.7,not_published | |
| 885 | +Sainte-Élisabeth,52030,Lanaudière,unifamilial,1.0,75,20.6,not_published | |
| 886 | +Chesterville,39030,Centre-du-Québec,unifamilial,1.0,75,19.9,not_published | |
| 887 | +Côte-Saint-Luc,66058,Montréal,plex,1.0,75,22.7,not_published | |
| 888 | +Sainte-Thérèse-de-la-Gatineau,83055,Outaouais,unifamilial,1.0,75,23.4,not_published | |
| 889 | +Sainte-Pétronille,20030,Capitale-Nationale,unifamilial,1.0,75,22.3,not_published | |
| 890 | +Sutton,46058,Estrie,plex,1.0,75,22.7,not_published | |
| 891 | +Sainte-Marie-Salomé,63005,Lanaudière,unifamilial,1.0,75,22.0,not_published | |
| 892 | +Saint-Émile-de-Suffolk,80125,Outaouais,unifamilial,1.0,75,20.6,not_published | |
| 893 | +Saint-Georges,29073,Chaudière-Appalaches,condo,1.0,75,19.9,not_published | |
| 894 | +Richelieu,55057,Montérégie,plex,1.0,74,22.7,not_published | |
| 895 | +Sainte-Anne-du-Lac,79115,Laurentides,unifamilial,1.0,74,23.7,not_published | |
| 896 | +Maniwaki,83065,Outaouais,plex,1.0,74,22.0,not_published | |
| 897 | +Namur,80110,Outaouais,unifamilial,1.0,73,20.6,not_published | |
| 898 | +Notre-Dame-de-la-Paix,80020,Outaouais,unifamilial,1.0,73,21.0,not_published | |
| 899 | +Amqui,07047,Bas-Saint-Laurent,plex,1.0,73,19.9,not_published | |
| 900 | +Saint-Séverin,35020,Mauricie,unifamilial,1.0,73,20.6,not_published | |
| 901 | +Hatley,45043,Estrie,unifamilial,1.0,73,22.0,not_published | |
| 902 | +Bromont,46078,Estrie,plex,1.0,73,22.7,not_published | |
| 903 | +Sainte-Anne-des-Monts,04037,Gaspésie–Îles-de-la-Madeleine,plex,1.0,73,20.3,not_published | |
| 904 | +Saint-Rémi-de-Tingwick,39020,Centre-du-Québec,unifamilial,1.0,73,21.0,not_published | |
| 905 | +Baie-du-Febvre,50100,Centre-du-Québec,unifamilial,1.0,72,20.6,not_published | |
| 906 | +Dupuy,87085,Abitibi-Témiscamingue,unifamilial,1.0,72,21.0,not_published | |
| 907 | +Trécesson,88075,Abitibi-Témiscamingue,unifamilial,1.0,72,22.7,not_published | |
| 908 | +Estérel,77011,Laurentides,unifamilial,1.0,72,21.6,not_published | |
| 909 | +Lac-Saint-Paul,79105,Laurentides,unifamilial,1.0,72,21.6,not_published | |
| 910 | +Saint-Louis-du-Ha! Ha!,13080,Bas-Saint-Laurent,unifamilial,1.0,72,22.0,not_published | |
| 911 | +Saint-Irénée,15005,Capitale-Nationale,unifamilial,1.0,72,22.7,not_published | |
| 912 | +Sainte-Sabine,46105,Estrie,unifamilial,1.0,71,21.3,not_published | |
| 913 | +Stoneham-et-Tewkesbury,22035,Capitale-Nationale,plex,1.0,71,21.6,not_published | |
| 914 | +Saint-Roch-de-Mékinac,35045,Mauricie,unifamilial,1.0,71,21.0,not_published | |
| 915 | +Grandes-Piles,35040,Mauricie,unifamilial,1.0,71,19.9,not_published | |
| 916 | +Roxton,48015,Montérégie,unifamilial,1.0,71,21.3,not_published | |
| 917 | +Cookshire-Eaton,41038,Estrie,plex,1.0,71,22.3,not_published | |
| 918 | +Beauceville,27028,Chaudière-Appalaches,plex,1.0,71,22.0,not_published | |
| 919 | +Saint-Marc-de-Figuery,88040,Abitibi-Témiscamingue,unifamilial,1.0,71,21.3,not_published | |
| 920 | +Bolton-Ouest,46065,Estrie,unifamilial,1.0,71,22.0,not_published | |
| 921 | +Sainte-Hedwidge,91030,Saguenay–Lac-Saint-Jean,unifamilial,1.0,71,21.0,not_published | |
| 922 | +Hébertville,93022,Saguenay–Lac-Saint-Jean,plex,1.0,71,21.6,not_published | |
| 923 | +Saint-Modeste,12020,Bas-Saint-Laurent,unifamilial,1.0,71,19.2,not_published | |
| 924 | +Durham-Sud,49015,Centre-du-Québec,unifamilial,1.0,71,19.9,not_published | |
| 925 | +Huberdeau,78065,Laurentides,unifamilial,1.0,70,20.3,not_published | |
| 926 | +Morin-Heights,77050,Laurentides,plex,1.0,70,19.9,not_published | |
| 927 | +Salaberry-de-Valleyfield,70052,Montérégie,condo,1.0,70,20.6,not_published | |
| 928 | +Saint-Gabriel,52080,Lanaudière,plex,1.0,70,20.6,not_published | |
| 929 | +Très-Saint-Sacrement,69030,Montérégie,unifamilial,1.0,70,20.3,not_published | |
| 930 | +Howick,69025,Montérégie,unifamilial,1.0,70,19.9,not_published | |
| 931 | +Montréal-Est,66007,Montréal,condo,1.0,70,21.3,not_published | |
| 932 | +Saint-Cyprien,12005,Bas-Saint-Laurent,unifamilial,1.0,70,19.2,not_published | |
| 933 | +Landrienne,88035,Abitibi-Témiscamingue,unifamilial,1.0,70,19.9,not_published | |
| 934 | +Mulgrave-et-Derry,80085,Outaouais,unifamilial,1.0,69,19.9,not_published | |
| 935 | +Bégin,94250,Saguenay–Lac-Saint-Jean,unifamilial,1.0,69,20.3,not_published | |
| 936 | +Sainte-Praxède,31050,Chaudière-Appalaches,unifamilial,1.0,69,20.3,not_published | |
| 937 | +Kirkland,66102,Montréal,condo,1.0,69,19.2,not_published | |
| 938 | +Saint-Pierre-de-l'Île-d'Orléans,20025,Capitale-Nationale,unifamilial,1.0,69,20.3,not_published | |
| 939 | +Joliette,61025,Lanaudière,condo,1.0,68,17.9,not_published | |
| 940 | +La Pêche,82035,Outaouais,plex,1.0,68,19.9,not_published | |
| 941 | +Saint-Étienne-de-Beauharnois,70030,Montérégie,unifamilial,1.0,68,21.6,not_published | |
| 942 | +Mayo,80065,Outaouais,unifamilial,1.0,68,19.2,not_published | |
| 943 | +Valcourt,42060,Estrie,unifamilial,1.0,68,19.6,not_published | |
| 944 | +Mont-Saint-Michel,79110,Laurentides,unifamilial,1.0,68,21.3,not_published | |
| 945 | +Saint-Urbain,16055,Capitale-Nationale,unifamilial,1.0,68,19.9,not_published | |
| 946 | +Saint-Roch-des-Aulnaies,17065,Chaudière-Appalaches,unifamilial,1.0,68,20.6,not_published | |
| 947 | +Sainte-Justine-de-Newton,71115,Montérégie,unifamilial,1.0,68,18.9,not_published | |
| 948 | +Saint-Théophile,29005,Chaudière-Appalaches,unifamilial,1.0,68,21.6,not_published | |
| 949 | +Labelle,78120,Laurentides,plex,1.0,68,19.9,not_published | |
| 950 | +Sainte-Félicité,08023,Bas-Saint-Laurent,unifamilial,1.0,67,19.2,not_published | |
| 951 | +Béarn,85020,Abitibi-Témiscamingue,unifamilial,1.0,67,19.6,not_published | |
| 952 | +Rigaud,71133,Montérégie,plex,1.0,67,19.6,not_published | |
| 953 | +Pont-Rouge,34017,Capitale-Nationale,plex,1.0,67,18.9,not_published | |
| 954 | +Sainte-Cécile-de-Whitton,30050,Estrie,unifamilial,1.0,66,20.3,not_published | |
| 955 | +La Durantaye,19090,Chaudière-Appalaches,unifamilial,1.0,66,19.9,not_published | |
| 956 | +Melbourne,42075,Estrie,unifamilial,1.0,66,18.9,not_published | |
| 957 | +La Bostonnais,90017,Mauricie,unifamilial,1.0,66,19.2,not_published | |
| 958 | +Saint-Zotique,71025,Montérégie,condo,1.0,66,19.6,not_published | |
| 959 | +Thorne,84045,Outaouais,unifamilial,1.0,66,20.3,not_published | |
| 960 | +Sainte-Marie,26030,Chaudière-Appalaches,plex,1.0,66,19.9,not_published | |
| 961 | +Saint-Marcellin,10025,Bas-Saint-Laurent,unifamilial,1.0,65,19.2,not_published | |
| 962 | +Saints-Anges,26010,Chaudière-Appalaches,unifamilial,1.0,65,21.0,not_published | |
| 963 | +Litchfield,84040,Outaouais,unifamilial,1.0,65,19.2,not_published | |
| 964 | +Saint-Pie,54008,Montérégie,plex,1.0,65,19.6,not_published | |
| 965 | +Saint-Zotique,71025,Montérégie,plex,1.0,65,20.3,not_published | |
| 966 | +Saint-Sylvestre,33007,Chaudière-Appalaches,unifamilial,1.0,65,19.9,not_published | |
| 967 | +Saint-Pierre-Baptiste,32050,Centre-du-Québec,unifamilial,1.0,64,20.3,not_published | |
| 968 | +Mercier,67045,Montérégie,plex,1.0,64,17.2,not_published | |
| 969 | +Saint-Jacques,63013,Lanaudière,plex,1.0,64,19.6,not_published | |
| 970 | +Saint-Tite,35027,Mauricie,plex,1.0,64,19.6,not_published | |
| 971 | +Princeville,32033,Centre-du-Québec,plex,1.0,64,18.6,not_published | |
| 972 | +Havelock,69005,Montérégie,unifamilial,1.0,64,19.6,not_published | |
| 973 | +Piedmont,77030,Laurentides,plex,1.0,64,20.3,not_published | |
| 974 | +Lefebvre,49020,Centre-du-Québec,unifamilial,1.0,64,17.2,not_published | |
| 975 | +Bonsecours,42040,Estrie,unifamilial,1.0,64,18.9,not_published | |
| 976 | +Saint-Léon-le-Grand,07030,Bas-Saint-Laurent,unifamilial,1.0,64,17.9,not_published | |
| 977 | +Westmount,66032,Montréal,plex,1.0,64,18.9,not_published | |
| 978 | +Saint-Romain,30100,Estrie,unifamilial,1.0,63,17.2,not_published | |
| 979 | +Massueville,53010,Montérégie,unifamilial,1.0,63,19.9,not_published | |
| 980 | +Trois-Pistoles,11040,Bas-Saint-Laurent,plex,1.0,63,18.6,not_published | |
| 981 | +Saint-Maxime-du-Mont-Louis,04010,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,63,18.6,not_published | |
| 982 | +Saint-Narcisse-de-Beaurivage,33030,Chaudière-Appalaches,unifamilial,1.0,63,18.9,not_published | |
| 983 | +Saint-Patrice-de-Beaurivage,33025,Chaudière-Appalaches,unifamilial,1.0,62,16.5,not_published | |
| 984 | +Saint-Rosaire,39145,Centre-du-Québec,unifamilial,1.0,62,19.2,not_published | |
| 985 | +Duparquet,87005,Abitibi-Témiscamingue,unifamilial,1.0,62,19.9,not_published | |
| 986 | +Ogden,45020,Estrie,unifamilial,1.0,62,18.9,not_published | |
| 987 | +Cloridorme,03010,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,62,20.3,not_published | |
| 988 | +Dixville,44023,Estrie,unifamilial,1.0,62,19.2,not_published | |
| 989 | +Saint-Calixte,63055,Lanaudière,plex,1.0,62,17.9,not_published | |
| 990 | +Lac-Édouard,90027,Mauricie,unifamilial,1.0,62,19.6,not_published | |
| 991 | +Saint-Stanislas,37245,Mauricie,unifamilial,1.0,61,18.2,not_published | |
| 992 | +Sainte-Hélène-de-Kamouraska,14025,Bas-Saint-Laurent,unifamilial,1.0,61,18.2,not_published | |
| 993 | +Westbury,41065,Estrie,unifamilial,1.0,61,19.2,not_published | |
| 994 | +Saint-Denis-De La Bouteillerie,14055,Bas-Saint-Laurent,unifamilial,1.0,61,17.9,not_published | |
| 995 | +Longue-Rive,95032,Côte-Nord,unifamilial,1.0,61,18.9,not_published | |
| 996 | +Saint-Paul,61005,Lanaudière,plex,1.0,61,16.8,not_published | |
| 997 | +Saint-Bonaventure,49125,Centre-du-Québec,unifamilial,1.0,61,17.9,not_published | |
| 998 | +Pointe-à-la-Croix,06030,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,61,18.9,not_published | |
| 999 | +Saint-David,53005,Montérégie,unifamilial,1.0,61,17.5,not_published | |
| 1000 | +Saint-Cyprien,28040,Chaudière-Appalaches,unifamilial,1.0,61,17.5,not_published | |
| 1001 | +Mont-Saint-Hilaire,57035,Montérégie,plex,1.0,61,19.9,not_published | |
| 1002 | +Lanoraie,52017,Lanaudière,plex,1.0,61,18.6,not_published | |
| 1003 | +Saint-Philippe,67010,Montérégie,condo,1.0,60,17.5,not_published | |
| 1004 | +Métis-sur-Mer,09048,Bas-Saint-Laurent,unifamilial,1.0,60,17.9,not_published | |
| 1005 | +Lac-Saint-Joseph,22015,Capitale-Nationale,unifamilial,1.0,60,16.8,not_published | |
| 1006 | +Saint-Célestin,50030,Centre-du-Québec,unifamilial,1.0,60,18.9,not_published | |
| 1007 | +Sainte-Julienne,63060,Lanaudière,plex,1.0,59,16.8,not_published | |
| 1008 | +Newport,41037,Estrie,unifamilial,1.0,59,18.6,not_published | |
| 1009 | +Saint-Frédéric,27065,Chaudière-Appalaches,unifamilial,1.0,59,17.2,not_published | |
| 1010 | +Shannon,22020,Capitale-Nationale,plex,1.0,59,18.2,not_published | |
| 1011 | +L'Île-du-Grand-Calumet,84035,Outaouais,unifamilial,1.0,59,18.9,not_published | |
| 1012 | +Arundel,78060,Laurentides,unifamilial,1.0,59,17.5,not_published | |
| 1013 | +Alleyn-et-Cawood,84050,Outaouais,unifamilial,1.0,59,17.2,not_published | |
| 1014 | +Saint-Barnabé-Sud,54105,Montérégie,unifamilial,1.0,59,18.6,not_published | |
| 1015 | +Clermont,15035,Capitale-Nationale,plex,1.0,59,18.2,not_published | |
| 1016 | +Fassett,80005,Outaouais,unifamilial,1.0,58,17.5,not_published | |
| 1017 | +Gallichan,87020,Abitibi-Témiscamingue,unifamilial,1.0,58,18.6,not_published | |
| 1018 | +Montcerf-Lytton,83088,Outaouais,unifamilial,1.0,58,15.1,not_published | |
| 1019 | +Sainte-Germaine-Boulé,87030,Abitibi-Témiscamingue,unifamilial,1.0,58,17.9,not_published | |
| 1020 | +Saint-Lazare-de-Bellechasse,19050,Chaudière-Appalaches,unifamilial,1.0,58,17.5,not_published | |
| 1021 | +Bécancour,38010,Centre-du-Québec,condo,1.0,58,15.5,not_published | |
| 1022 | +Sainte-Marthe,71110,Montérégie,unifamilial,1.0,58,18.6,not_published | |
| 1023 | +Ferland-et-Boilleau,94220,Saguenay–Lac-Saint-Jean,unifamilial,1.0,58,17.5,not_published | |
| 1024 | +Saint-Simon-de-Rimouski,11055,Bas-Saint-Laurent,unifamilial,1.0,57,18.2,not_published | |
| 1025 | +Pointe-Calumet,72020,Laurentides,plex,1.0,57,16.8,not_published | |
| 1026 | +Taschereau,87042,Abitibi-Témiscamingue,unifamilial,1.0,57,18.2,not_published | |
| 1027 | +Saint-Arsène,12065,Bas-Saint-Laurent,unifamilial,1.0,56,16.5,not_published | |
| 1028 | +Sainte-Rose-du-Nord,94230,Saguenay–Lac-Saint-Jean,unifamilial,1.0,56,16.5,not_published | |
| 1029 | +Notre-Dame-du-Bon-Conseil,49080,Centre-du-Québec,unifamilial,1.0,56,16.8,not_published | |
| 1030 | +Sutton,46058,Estrie,condo,1.0,56,15.8,not_published | |
| 1031 | +Stanbridge East,46045,Estrie,unifamilial,1.0,56,16.5,not_published | |
| 1032 | +Grosses-Roches,08015,Bas-Saint-Laurent,unifamilial,1.0,56,17.9,not_published | |
| 1033 | +Packington,13015,Bas-Saint-Laurent,unifamilial,1.0,56,17.5,not_published | |
| 1034 | +Rivière-Rouge,79037,Laurentides,plex,1.0,56,18.2,not_published | |
| 1035 | +Duhamel-Ouest,85030,Abitibi-Témiscamingue,unifamilial,1.0,55,17.2,not_published | |
| 1036 | +Saint-Pierre-de-Broughton,31135,Chaudière-Appalaches,unifamilial,1.0,55,17.2,not_published | |
| 1037 | +Saint-Léon-le-Grand,51035,Mauricie,unifamilial,1.0,55,16.8,not_published | |
| 1038 | +Sainte-Christine,48020,Montérégie,unifamilial,1.0,55,17.5,not_published | |
| 1039 | +Prévost,75040,Laurentides,condo,1.0,55,16.2,not_published | |
| 1040 | +Saint-Nazaire-d'Acton,48050,Montérégie,unifamilial,1.0,55,17.2,not_published | |
| 1041 | +Saint-Jacques-de-Leeds,31140,Chaudière-Appalaches,unifamilial,1.0,55,16.5,not_published | |
| 1042 | +Saint-Édouard-de-Fabre,85015,Abitibi-Témiscamingue,unifamilial,1.0,54,16.5,not_published | |
| 1043 | +Sainte-Catherine-de-la-Jacques-Cartier,22005,Capitale-Nationale,plex,1.0,54,16.2,not_published | |
| 1044 | +Amos,88057,Abitibi-Témiscamingue,plex,1.0,54,14.8,not_published | |
| 1045 | +Saint-Vallier,19117,Chaudière-Appalaches,unifamilial,1.0,54,16.5,not_published | |
| 1046 | +Métabetchouan–Lac-à-la-Croix,93012,Saguenay–Lac-Saint-Jean,plex,1.0,54,16.5,not_published | |
| 1047 | +Saint-Charles-de-Bourget,94260,Saguenay–Lac-Saint-Jean,unifamilial,1.0,54,15.8,not_published | |
| 1048 | +Courcelles–Saint-Évariste,29027,Chaudière-Appalaches,unifamilial,1.0,54,15.8,not_published | |
| 1049 | +Piopolis,30020,Estrie,unifamilial,1.0,53,16.5,not_published | |
| 1050 | +L'Ange-Gardien,82005,Outaouais,plex,1.0,53,16.5,not_published | |
| 1051 | +La Pocatière,14082,Bas-Saint-Laurent,plex,1.0,53,17.9,not_published | |
| 1052 | +Les Bergeronnes,95018,Côte-Nord,unifamilial,1.0,53,17.5,not_published | |
| 1053 | +Sainte-Anne-des-Lacs,77035,Laurentides,plex,1.0,53,16.5,not_published | |
| 1054 | +Les Coteaux,71033,Montérégie,plex,1.0,53,15.5,not_published | |
| 1055 | +Notre-Dame-du-Rosaire,18040,Chaudière-Appalaches,unifamilial,1.0,53,14.8,not_published | |
| 1056 | +Saint-Mathieu-d'Harricana,88050,Abitibi-Témiscamingue,unifamilial,1.0,53,15.5,not_published | |
| 1057 | +Danville,40047,Estrie,plex,1.0,53,14.1,not_published | |
| 1058 | +Pointe-Fortune,71140,Montérégie,unifamilial,1.0,53,16.8,not_published | |
| 1059 | +Saint-Just-de-Bretenières,18005,Chaudière-Appalaches,unifamilial,1.0,52,15.8,not_published | |
| 1060 | +Sainte-Martine,70012,Montérégie,condo,1.0,52,15.1,not_published | |
| 1061 | +Sainte-Angèle-de-Prémont,51055,Mauricie,unifamilial,1.0,52,15.8,not_published | |
| 1062 | +Saint-Sébastien,30085,Estrie,unifamilial,1.0,52,16.8,not_published | |
| 1063 | +Saint-Pierre-de-la-Rivière-du-Sud,18055,Chaudière-Appalaches,unifamilial,1.0,52,16.8,not_published | |
| 1064 | +Saint-Michel-du-Squatec,13065,Bas-Saint-Laurent,unifamilial,1.0,52,14.4,not_published | |
| 1065 | +Chandler,02028,Gaspésie–Îles-de-la-Madeleine,plex,1.0,52,16.8,not_published | |
| 1066 | +Saint-André-Avellin,80027,Outaouais,plex,1.0,52,15.5,not_published | |
| 1067 | +Péribonka,92010,Saguenay–Lac-Saint-Jean,unifamilial,1.0,51,17.2,not_published | |
| 1068 | +Parisville,38055,Centre-du-Québec,unifamilial,1.0,51,15.8,not_published | |
| 1069 | +Lac-Beauport,22040,Capitale-Nationale,plex,1.0,51,15.8,not_published | |
| 1070 | +Saint-Zéphirin-de-Courval,50090,Centre-du-Québec,unifamilial,1.0,51,16.5,not_published | |
| 1071 | +Beaupré,21025,Capitale-Nationale,plex,1.0,51,16.5,not_published | |
| 1072 | +Val-Morin,78005,Laurentides,plex,1.0,51,15.1,not_published | |
| 1073 | +Ville-Marie,85025,Abitibi-Témiscamingue,plex,1.0,51,16.5,not_published | |
| 1074 | +Disraeli,31015,Chaudière-Appalaches,plex,1.0,51,15.8,not_published | |
| 1075 | +Sainte-Sabine,28065,Chaudière-Appalaches,unifamilial,1.0,51,16.5,not_published | |
| 1076 | +Saint-Basile-le-Grand,57020,Montérégie,plex,1.0,51,15.8,not_published | |
| 1077 | +Mont-Royal,66072,Montréal,plex,1.0,51,15.8,not_published | |
| 1078 | +Mercier,67045,Montérégie,condo,1.0,51,15.1,not_published | |
| 1079 | +Clerval,87075,Abitibi-Témiscamingue,unifamilial,1.0,51,14.8,not_published | |
| 1080 | +Saint-Edmond-de-Grantham,49100,Centre-du-Québec,unifamilial,1.0,51,16.2,not_published | |
| 1081 | +Sainte-Angèle-de-Mérici,09035,Bas-Saint-Laurent,unifamilial,1.0,50,15.8,not_published | |
| 1082 | +Bryson,84025,Outaouais,unifamilial,1.0,50,15.5,not_published | |
| 1083 | +Notre-Dame-du-Sacré-Coeur-d'Issoudun,33085,Chaudière-Appalaches,unifamilial,1.0,50,15.5,not_published | |
| 1084 | +Sainte-Françoise,11030,Bas-Saint-Laurent,unifamilial,1.0,50,14.4,not_published | |
| 1085 | +Ulverton,42078,Estrie,unifamilial,1.0,50,16.2,not_published | |
| 1086 | +Saint-Apollinaire,33090,Chaudière-Appalaches,plex,1.0,49,14.1,not_published | |
| 1087 | +Sainte-Anne-des-Plaines,73035,Laurentides,condo,1.0,49,13.4,not_published | |
| 1088 | +Saint-Eugène-de-Guigues,85085,Abitibi-Témiscamingue,unifamilial,1.0,49,15.5,not_published | |
| 1089 | +Waltham,84070,Outaouais,unifamilial,1.0,49,14.8,not_published | |
| 1090 | +Saint-Honoré-de-Témiscouata,13090,Bas-Saint-Laurent,unifamilial,1.0,49,15.1,not_published | |
| 1091 | +La Motte,88045,Abitibi-Témiscamingue,unifamilial,1.0,49,16.2,not_published | |
| 1092 | +Campbell's Bay,84030,Outaouais,unifamilial,1.0,49,15.5,not_published | |
| 1093 | +Saint-François-de-Sales,91015,Saguenay–Lac-Saint-Jean,unifamilial,1.0,49,14.1,not_published | |
| 1094 | +Lac-des-Seize-Îles,77055,Laurentides,unifamilial,1.0,49,14.1,not_published | |
| 1095 | +Saint-Sulpice,60020,Lanaudière,condo,1.0,49,13.7,not_published | |
| 1096 | +Irlande,31040,Chaudière-Appalaches,unifamilial,1.0,49,14.8,not_published | |
| 1097 | +Scotstown,41080,Estrie,unifamilial,1.0,49,15.1,not_published | |
| 1098 | +Saint-Télesphore,71015,Montérégie,unifamilial,1.0,49,14.8,not_published | |
| 1099 | +Sainte-Anne-de-Bellevue,66117,Montréal,plex,1.0,49,14.4,not_published | |
| 1100 | +La Visitation-de-l'Île-Dupas,52050,Lanaudière,unifamilial,1.0,49,16.2,not_published | |
| 1101 | +Sainte-Marguerite-du-Lac-Masson,77012,Laurentides,plex,1.0,49,15.1,not_published | |
| 1102 | +Warwick,39077,Centre-du-Québec,plex,1.0,48,15.1,not_published | |
| 1103 | +Pontiac,82030,Outaouais,plex,1.0,48,14.8,not_published | |
| 1104 | +Sainte-Marie-de-Blandford,38015,Centre-du-Québec,unifamilial,1.0,48,14.8,not_published | |
| 1105 | +Saint-Philippe-de-Néri,14060,Bas-Saint-Laurent,unifamilial,1.0,48,15.5,not_published | |
| 1106 | +Lochaber-Partie-Ouest,80060,Outaouais,unifamilial,1.0,48,15.5,not_published | |
| 1107 | +Murdochville,03025,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,48,14.8,not_published | |
| 1108 | +East Farnham,46085,Estrie,unifamilial,1.0,48,15.5,not_published | |
| 1109 | +Saint-Roch-de-l'Achigan,63035,Lanaudière,plex,1.0,48,15.5,not_published | |
| 1110 | +Shawinigan,36033,Mauricie,condo,1.0,48,14.4,not_published | |
| 1111 | +Sainte-Brigitte-des-Saults,49085,Centre-du-Québec,unifamilial,1.0,48,15.5,not_published | |
| 1112 | +Audet,30055,Estrie,unifamilial,1.0,48,15.5,not_published | |
| 1113 | +Saint-Honoré,94240,Saguenay–Lac-Saint-Jean,plex,1.0,48,14.8,not_published | |
| 1114 | +Beaconsfield,66107,Montréal,condo,1.0,47,15.1,not_published | |
| 1115 | +Delson,67025,Montérégie,plex,1.0,47,14.4,not_published | |
| 1116 | +Sainte-Paule,08040,Bas-Saint-Laurent,unifamilial,1.0,47,15.1,not_published | |
| 1117 | +Coteau-du-Lac,71040,Montérégie,plex,1.0,47,14.4,not_published | |
| 1118 | +Saint-Prosper-de-Champlain,37250,Mauricie,unifamilial,1.0,47,14.1,not_published | |
| 1119 | +Stanstead,45008,Estrie,plex,1.0,47,15.1,not_published | |
| 1120 | +Honfleur,19070,Chaudière-Appalaches,unifamilial,1.0,47,15.1,not_published | |
| 1121 | +Sainte-Marie,26030,Chaudière-Appalaches,condo,1.0,47,15.5,not_published | |
| 1122 | +Saint-Gabriel-Lalemant,14075,Bas-Saint-Laurent,unifamilial,1.0,47,16.2,not_published | |
| 1123 | +Lawrenceville,42045,Estrie,unifamilial,1.0,47,15.1,not_published | |
| 1124 | +Sainte-Séraphine,39105,Centre-du-Québec,unifamilial,1.0,47,14.4,not_published | |
| 1125 | +Saint-Sylvère,38005,Centre-du-Québec,unifamilial,1.0,47,14.8,not_published | |
| 1126 | +Sainte-Euphémie-sur-Rivière-du-Sud,18035,Chaudière-Appalaches,unifamilial,1.0,47,14.4,not_published | |
| 1127 | +Notre-Dame-de-Lourdes,32080,Centre-du-Québec,unifamilial,1.0,47,14.8,not_published | |
| 1128 | +Sainte-Louise,17060,Chaudière-Appalaches,unifamilial,1.0,46,13.7,not_published | |
| 1129 | +Portneuf-sur-Mer,95040,Côte-Nord,unifamilial,1.0,46,14.1,not_published | |
| 1130 | +Rivière-au-Tonnerre,98055,Côte-Nord,unifamilial,1.0,46,13.7,not_published | |
| 1131 | +Bedford,46035,Estrie,plex,1.0,46,12.7,not_published | |
| 1132 | +Senneville,66127,Montréal,unifamilial,1.0,46,13.7,not_published | |
| 1133 | +Saint-Alphonse,05065,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,46,13.4,not_published | |
| 1134 | +Saint-Samuel,39130,Centre-du-Québec,unifamilial,1.0,46,15.1,not_published | |
| 1135 | +Tourville,17035,Chaudière-Appalaches,unifamilial,1.0,46,15.1,not_published | |
| 1136 | +Saints-Martyrs-Canadiens,39005,Centre-du-Québec,unifamilial,1.0,46,14.1,not_published | |
| 1137 | +Sainte-Martine,70012,Montérégie,plex,1.0,46,15.5,not_published | |
| 1138 | +Les Coteaux,71033,Montérégie,condo,1.0,46,14.1,not_published | |
| 1139 | +Saint-André-du-Lac-Saint-Jean,91010,Saguenay–Lac-Saint-Jean,unifamilial,1.0,45,12.7,not_published | |
| 1140 | +Sainte-Perpétue,50050,Centre-du-Québec,unifamilial,1.0,45,12.7,not_published | |
| 1141 | +Saint-Mathias-sur-Richelieu,55065,Montérégie,plex,1.0,45,14.4,not_published | |
| 1142 | +Tadoussac,95005,Côte-Nord,unifamilial,1.0,45,14.1,not_published | |
| 1143 | +Lac-Etchemin,28053,Chaudière-Appalaches,plex,1.0,45,14.8,not_published | |
| 1144 | +Sainte-Apolline-de-Patton,18025,Chaudière-Appalaches,unifamilial,1.0,45,14.1,not_published | |
| 1145 | +Saint-Lazare,71105,Montérégie,plex,1.0,45,13.7,not_published | |
| 1146 | +Petit-Saguenay,94205,Saguenay–Lac-Saint-Jean,unifamilial,1.0,44,13.7,not_published | |
| 1147 | +Saint-Marc-des-Carrières,34065,Capitale-Nationale,plex,1.0,44,13.7,not_published | |
| 1148 | +Sainte-Anne-de-la-Rochelle,42050,Estrie,unifamilial,1.0,44,14.1,not_published | |
| 1149 | +Ham-Sud,40005,Estrie,unifamilial,1.0,44,14.8,not_published | |
| 1150 | +Baie-des-Sables,08080,Bas-Saint-Laurent,unifamilial,1.0,44,14.8,not_published | |
| 1151 | +Saint-Malo,44003,Estrie,unifamilial,1.0,44,13.1,not_published | |
| 1152 | +Les Cèdres,71050,Montérégie,condo,1.0,44,13.1,not_published | |
| 1153 | +Saint-Adalbert,17015,Chaudière-Appalaches,unifamilial,1.0,44,14.1,not_published | |
| 1154 | +Saint-Sébastien,56050,Montérégie,unifamilial,1.0,44,14.1,not_published | |
| 1155 | +Otterburn Park,57030,Montérégie,plex,1.0,44,13.4,not_published | |
| 1156 | +Candiac,67020,Montérégie,plex,1.0,43,13.4,not_published | |
| 1157 | +Lac-Brome,46075,Estrie,plex,1.0,43,13.4,not_published | |
| 1158 | +Verchères,59025,Montérégie,plex,1.0,43,13.4,not_published | |
| 1159 | +Baie-Trinité,96005,Côte-Nord,unifamilial,1.0,43,13.4,not_published | |
| 1160 | +Rawdon,62037,Lanaudière,condo,1.0,43,13.1,not_published | |
| 1161 | +Témiscouata-sur-le-Lac,13073,Bas-Saint-Laurent,plex,1.0,43,13.7,not_published | |
| 1162 | +Saint-Joseph-du-Lac,72025,Laurentides,condo,1.0,43,13.1,not_published | |
| 1163 | +Stornoway,30105,Estrie,unifamilial,1.0,43,14.4,not_published | |
| 1164 | +Ormstown,69037,Montérégie,plex,1.0,43,13.1,not_published | |
| 1165 | +Havre-Saint-Pierre,98040,Côte-Nord,plex,1.0,43,14.1,not_published | |
| 1166 | +Saint-Augustin-de-Woburn,30005,Estrie,unifamilial,1.0,43,13.4,not_published | |
| 1167 | +Biencourt,13055,Bas-Saint-Laurent,unifamilial,1.0,43,11.7,not_published | |
| 1168 | +Huntingdon,69055,Montérégie,plex,1.0,43,13.4,not_published | |
| 1169 | +Château-Richer,21035,Capitale-Nationale,plex,1.0,43,13.4,not_published | |
| 1170 | +Senneterre,89040,Abitibi-Témiscamingue,plex,1.0,43,13.4,not_published | |
| 1171 | +Sainte-Famille-de-l'Île-d'Orléans,20010,Capitale-Nationale,unifamilial,1.0,43,13.4,not_published | |
| 1172 | +Portneuf,34048,Capitale-Nationale,plex,1.0,42,13.7,not_published | |
| 1173 | +Saint-Luc-de-Vincennes,37225,Mauricie,unifamilial,1.0,42,14.1,not_published | |
| 1174 | +Pike River,46025,Estrie,unifamilial,1.0,42,12.7,not_published | |
| 1175 | +Rosemère,73020,Laurentides,plex,1.0,42,13.1,not_published | |
| 1176 | +Saint-Jean-de-Matha,62015,Lanaudière,plex,1.0,42,12.4,not_published | |
| 1177 | +Saint-Marcel,17020,Chaudière-Appalaches,unifamilial,1.0,42,12.7,not_published | |
| 1178 | +Rouyn-Noranda,86042,Abitibi-Témiscamingue,condo,1.0,42,13.1,not_published | |
| 1179 | +Weedon,41098,Estrie,plex,1.0,42,14.1,not_published | |
| 1180 | +Notre-Dame-de-Lourdes,61045,Lanaudière,plex,1.0,42,13.1,not_published | |
| 1181 | +Saint-Paul-de-la-Croix,12035,Bas-Saint-Laurent,unifamilial,1.0,42,12.4,not_published | |
| 1182 | +Saint-Adelme,08030,Bas-Saint-Laurent,unifamilial,1.0,41,13.1,not_published | |
| 1183 | +Cowansville,46080,Estrie,condo,1.0,41,13.7,not_published | |
| 1184 | +Saint-Adrien,40010,Estrie,unifamilial,1.0,41,13.1,not_published | |
| 1185 | +Sainte-Gertrude-Manneville,88085,Abitibi-Témiscamingue,unifamilial,1.0,41,12.4,not_published | |
| 1186 | +Saint-Joseph-de-Lepage,09070,Bas-Saint-Laurent,unifamilial,1.0,41,12.7,not_published | |
| 1187 | +Brome,46070,Estrie,unifamilial,1.0,41,12.7,not_published | |
| 1188 | +Roxton Pond,47047,Estrie,plex,1.0,41,12.4,not_published | |
| 1189 | +Leclercville,33123,Chaudière-Appalaches,unifamilial,1.0,41,12.4,not_published | |
| 1190 | +Montréal-Ouest,66047,Montréal,plex,1.0,41,12.7,not_published | |
| 1191 | +Notre-Dame-de-Ham,39015,Centre-du-Québec,unifamilial,1.0,41,12.7,not_published | |
| 1192 | +Forestville,95045,Côte-Nord,plex,1.0,41,13.1,not_published | |
| 1193 | +Saint-Henri,19068,Chaudière-Appalaches,plex,1.0,41,13.1,not_published | |
| 1194 | +Oka,72032,Laurentides,plex,1.0,40,12.0,not_published | |
| 1195 | +Bonaventure,05045,Gaspésie–Îles-de-la-Madeleine,plex,1.0,40,12.7,not_published | |
| 1196 | +Dundee,69075,Montérégie,unifamilial,1.0,40,12.4,not_published | |
| 1197 | +Saint-Moïse,07095,Bas-Saint-Laurent,unifamilial,1.0,40,12.7,not_published | |
| 1198 | +Hampstead,66062,Montréal,plex,1.0,40,13.7,not_published | |
| 1199 | +Boischatel,21045,Capitale-Nationale,plex,1.0,40,11.7,not_published | |
| 1200 | +Saint-Sixte,80070,Outaouais,unifamilial,1.0,40,12.4,not_published | |
| 1201 | +Saint-Simon-les-Mines,29125,Chaudière-Appalaches,unifamilial,1.0,40,13.4,not_published | |
| 1202 | +Saint-Adrien-d'Irlande,31095,Chaudière-Appalaches,unifamilial,1.0,39,12.0,not_published | |
| 1203 | +Saint-Camille,40025,Estrie,unifamilial,1.0,39,12.7,not_published | |
| 1204 | +Barnston-Ouest,44045,Estrie,unifamilial,1.0,39,12.7,not_published | |
| 1205 | +Carleton-sur-Mer,06013,Gaspésie–Îles-de-la-Madeleine,plex,1.0,39,12.7,not_published | |
| 1206 | +Bedford,46040,Estrie,unifamilial,1.0,39,12.0,not_published | |
| 1207 | +Saint-Joseph-de-Beauce,27043,Chaudière-Appalaches,plex,1.0,39,12.0,not_published | |
| 1208 | +Saint-Louis-de-Gonzague,28035,Chaudière-Appalaches,unifamilial,1.0,39,13.1,not_published | |
| 1209 | +Villeroy,32085,Centre-du-Québec,unifamilial,1.0,38,11.0,not_published | |
| 1210 | +Grenville,76055,Laurentides,plex,1.0,38,12.4,not_published | |
| 1211 | +Roquemaure,87015,Abitibi-Témiscamingue,unifamilial,1.0,38,12.4,not_published | |
| 1212 | +Papineauville,80037,Outaouais,plex,1.0,38,11.7,not_published | |
| 1213 | +Normétal,87115,Abitibi-Témiscamingue,unifamilial,1.0,38,12.4,not_published | |
| 1214 | +Rivière-Éternité,94215,Saguenay–Lac-Saint-Jean,unifamilial,1.0,38,12.0,not_published | |
| 1215 | +Saint-François-de-l'Île-d'Orléans,20005,Capitale-Nationale,unifamilial,1.0,38,12.0,not_published | |
| 1216 | +Saint-Côme,62065,Lanaudière,plex,1.0,38,12.0,not_published | |
| 1217 | +Lingwick,41085,Estrie,unifamilial,1.0,38,12.0,not_published | |
| 1218 | +Saint-Germain-de-Grantham,49048,Centre-du-Québec,plex,1.0,38,12.0,not_published | |
| 1219 | +Saint-Lazare,71105,Montérégie,condo,1.0,38,10.7,not_published | |
| 1220 | +Sainte-Thècle,35050,Mauricie,plex,1.0,38,12.4,not_published | |
| 1221 | +Dégelis,13005,Bas-Saint-Laurent,plex,1.0,38,11.7,not_published | |
| 1222 | +Stanstead-Est,44050,Estrie,unifamilial,1.0,38,13.1,not_published | |
| 1223 | +Cascapédia–Saint-Jules,05077,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,38,12.4,not_published | |
| 1224 | +Abercorn,46005,Estrie,unifamilial,1.0,37,11.0,not_published | |
| 1225 | +Saint-Denis-de-Brompton,42025,Estrie,plex,1.0,37,11.7,not_published | |
| 1226 | +Fortierville,38047,Centre-du-Québec,unifamilial,1.0,37,11.3,not_published | |
| 1227 | +Saint-Ambroise-de-Kildare,61040,Lanaudière,plex,1.0,37,11.0,not_published | |
| 1228 | +Kinnear's Mills,31105,Chaudière-Appalaches,unifamilial,1.0,37,11.7,not_published | |
| 1229 | +Martinville,44060,Estrie,unifamilial,1.0,37,12.0,not_published | |
| 1230 | +Saint-Célestin,50035,Centre-du-Québec,unifamilial,1.0,37,11.3,not_published | |
| 1231 | +Val-d'Or,89008,Abitibi-Témiscamingue,condo,1.0,37,11.7,not_published | |
| 1232 | +Saint-Valentin,56030,Montérégie,unifamilial,1.0,37,11.7,not_published | |
| 1233 | +Saint-Philippe,67010,Montérégie,plex,1.0,37,12.4,not_published | |
| 1234 | +Pohénégamook,13095,Bas-Saint-Laurent,plex,1.0,37,12.0,not_published | |
| 1235 | +Neuville,34007,Capitale-Nationale,plex,1.0,36,10.7,not_published | |
| 1236 | +Notre-Dame-de-Bonsecours,80015,Outaouais,unifamilial,1.0,36,10.7,not_published | |
| 1237 | +Saint-Félix-de-Valois,62007,Lanaudière,condo,1.0,36,11.7,not_published | |
| 1238 | +Alma,93042,Saguenay–Lac-Saint-Jean,condo,1.0,36,11.7,not_published | |
| 1239 | +Poularies,87035,Abitibi-Témiscamingue,unifamilial,1.0,36,12.0,not_published | |
| 1240 | +Rémigny,85105,Abitibi-Témiscamingue,unifamilial,1.0,36,12.0,not_published | |
| 1241 | +Latulipe-et-Gaboury,85060,Abitibi-Témiscamingue,unifamilial,1.0,36,11.7,not_published | |
| 1242 | +Saint-François-d'Assise,06055,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,36,11.0,not_published | |
| 1243 | +Saint-Marc-du-Lac-Long,13020,Bas-Saint-Laurent,unifamilial,1.0,36,11.7,not_published | |
| 1244 | +Saint-Alfred,27015,Chaudière-Appalaches,unifamilial,1.0,35,11.7,not_published | |
| 1245 | +Saint-Marcel-de-Richelieu,54125,Montérégie,unifamilial,1.0,35,10.3,not_published | |
| 1246 | +Nédélec,85100,Abitibi-Témiscamingue,unifamilial,1.0,35,11.3,not_published | |
| 1247 | +Notre-Dame-de-Stanbridge,46100,Estrie,unifamilial,1.0,35,12.0,not_published | |
| 1248 | +Saint-Thomas,61027,Lanaudière,plex,1.0,35,10.3,not_published | |
| 1249 | +Sainte-Hélène-de-Mancebourg,87070,Abitibi-Témiscamingue,unifamilial,1.0,35,10.3,not_published | |
| 1250 | +Saint-Cyrille-de-Wendover,49070,Centre-du-Québec,plex,1.0,35,10.3,not_published | |
| 1251 | +Kamouraska,14050,Bas-Saint-Laurent,unifamilial,1.0,35,11.7,not_published | |
| 1252 | +Valcourt,42055,Estrie,plex,1.0,35,11.3,not_published | |
| 1253 | +Dorval,66087,Montréal,plex,1.0,35,10.7,not_published | |
| 1254 | +Saint-Thuribe,34085,Capitale-Nationale,unifamilial,1.0,35,10.3,not_published | |
| 1255 | +Gracefield,83032,Outaouais,plex,1.0,34,11.0,not_published | |
| 1256 | +Matapédia,06045,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,34,11.3,not_published | |
| 1257 | +La Conception,78115,Laurentides,plex,1.0,34,11.3,not_published | |
| 1258 | +Milan,30040,Estrie,unifamilial,1.0,34,11.0,not_published | |
| 1259 | +Sainte-Madeleine,54025,Montérégie,plex,1.0,34,11.0,not_published | |
| 1260 | +Saint-Stanislas,92070,Saguenay–Lac-Saint-Jean,unifamilial,1.0,34,11.0,not_published | |
| 1261 | +Terrasse-Vaudreuil,71075,Montérégie,plex,1.0,34,11.0,not_published | |
| 1262 | +Saint-Bruno-de-Kamouraska,14010,Bas-Saint-Laurent,unifamilial,1.0,34,11.3,not_published | |
| 1263 | +Lebel-sur-Quévillon,99005,Nord-du-Québec,plex,1.0,33,11.0,not_published | |
| 1264 | +Saint-Clément,11005,Bas-Saint-Laurent,unifamilial,1.0,33,10.3,not_published | |
| 1265 | +Saint-Octave-de-Métis,09055,Bas-Saint-Laurent,unifamilial,1.0,33,11.0,not_published | |
| 1266 | +Saint-Damase-de-L'Islet,17040,Chaudière-Appalaches,unifamilial,1.0,33,10.0,not_published | |
| 1267 | +Sainte-Anne-de-la-Pérade,37205,Mauricie,plex,1.0,33,10.3,not_published | |
| 1268 | +Lac-des-Aigles,13062,Bas-Saint-Laurent,unifamilial,1.0,33,9.3,not_published | |
| 1269 | +Les Hauteurs,09015,Bas-Saint-Laurent,unifamilial,1.0,33,11.0,not_published | |
| 1270 | +Escuminac,06025,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,33,11.0,not_published | |
| 1271 | +Sainte-Agathe-des-Monts,78032,Laurentides,condo,1.0,32,10.3,not_published | |
| 1272 | +Saint-Adolphe-d'Howard,77065,Laurentides,plex,1.0,32,10.7,not_published | |
| 1273 | +Saint-Omer,17005,Chaudière-Appalaches,unifamilial,1.0,32,10.0,not_published | |
| 1274 | +Chichester,84090,Outaouais,unifamilial,1.0,32,10.0,not_published | |
| 1275 | +Lochaber,80055,Outaouais,unifamilial,1.0,32,10.3,not_published | |
| 1276 | +Saint-André-de-Kamouraska,14040,Bas-Saint-Laurent,unifamilial,1.0,32,10.0,not_published | |
| 1277 | +Sainte-Irène,07040,Bas-Saint-Laurent,unifamilial,1.0,32,11.0,not_published | |
| 1278 | +Notre-Dame-des-Monts,15025,Capitale-Nationale,unifamilial,1.0,32,10.0,not_published | |
| 1279 | +Colombier,95050,Côte-Nord,unifamilial,1.0,32,10.3,not_published | |
| 1280 | +Berry,88070,Abitibi-Témiscamingue,unifamilial,1.0,32,10.7,not_published | |
| 1281 | +Maricourt,42065,Estrie,unifamilial,1.0,31,10.0,not_published | |
| 1282 | +Moffet,85075,Abitibi-Témiscamingue,unifamilial,1.0,31,10.7,not_published | |
| 1283 | +Egan-Sud,83075,Outaouais,unifamilial,1.0,31,10.7,not_published | |
| 1284 | +Saint-Bernard-de-Michaudville,54115,Montérégie,unifamilial,1.0,31,10.3,not_published | |
| 1285 | +Franquelin,96015,Côte-Nord,unifamilial,1.0,31,10.3,not_published | |
| 1286 | +Victoriaville,39062,Centre-du-Québec,condo,1.0,31,10.0,not_published | |
| 1287 | +Saint-Jean-de-la-Lande,13010,Bas-Saint-Laurent,unifamilial,1.0,31,10.7,not_published | |
| 1288 | +Kipawa,85010,Abitibi-Témiscamingue,unifamilial,1.0,31,9.6,not_published | |
| 1289 | +Saint-Elzéar,05050,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,31,10.0,not_published | |
| 1290 | +Macamic,87058,Abitibi-Témiscamingue,plex,1.0,31,9.6,not_published | |
| 1291 | +Lac-Supérieur,78095,Laurentides,plex,1.0,31,10.0,not_published | |
| 1292 | +Grand-Métis,09060,Bas-Saint-Laurent,unifamilial,1.0,31,10.7,not_published | |
| 1293 | +Saint-Dominique-du-Rosaire,88065,Abitibi-Témiscamingue,unifamilial,1.0,30,9.6,not_published | |
| 1294 | +Chartierville,41020,Estrie,unifamilial,1.0,30,9.6,not_published | |
| 1295 | +Saint-Roch-de-Richelieu,53040,Montérégie,plex,1.0,30,9.6,not_published | |
| 1296 | +Saint-Boniface,51085,Mauricie,plex,1.0,30,10.0,not_published | |
| 1297 | +Val-Racine,30015,Estrie,unifamilial,1.0,30,8.6,not_published | |
| 1298 | +Saint-Jules,27055,Chaudière-Appalaches,unifamilial,1.0,30,10.3,not_published | |
| 1299 | +Maddington Falls,39165,Centre-du-Québec,unifamilial,1.0,30,10.0,not_published | |
| 1300 | +Lacolle,56023,Montérégie,plex,1.0,30,9.6,not_published | |
| 1301 | +La Trinité-des-Monts,10010,Bas-Saint-Laurent,unifamilial,1.0,30,10.3,not_published | |
| 1302 | +Saint-Jean-Baptiste,57033,Montérégie,plex,1.0,30,9.3,not_published | |
| 1303 | +Saint-Vianney,07075,Bas-Saint-Laurent,unifamilial,1.0,30,8.9,not_published | |
| 1304 | +Clermont,87110,Abitibi-Témiscamingue,unifamilial,1.0,30,10.3,not_published | |
| 1305 | +Sainte-Thérèse-de-Gaspé,02010,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,29,9.3,not_published | |
| 1306 | +Ferme-Neuve,79097,Laurentides,plex,1.0,29,10.0,not_published | |
| 1307 | +Saint-Paul,61005,Lanaudière,condo,1.0,29,9.3,not_published | |
| 1308 | +Saint-François-Xavier-de-Viger,12025,Bas-Saint-Laurent,unifamilial,1.0,29,9.3,not_published | |
| 1309 | +Saint-Anselme,19062,Chaudière-Appalaches,plex,1.0,29,9.3,not_published | |
| 1310 | +Guérin,85095,Abitibi-Témiscamingue,unifamilial,1.0,29,9.3,not_published | |
| 1311 | +Saint-Robert-Bellarmin,30070,Estrie,unifamilial,1.0,29,9.3,not_published | |
| 1312 | +Saint-Ignace-de-Stanbridge,46095,Estrie,unifamilial,1.0,29,9.3,not_published | |
| 1313 | +Elgin,69050,Montérégie,unifamilial,1.0,29,9.6,not_published | |
| 1314 | +Sainte-Sophie-d'Halifax,32023,Centre-du-Québec,unifamilial,1.0,29,9.6,not_published | |
| 1315 | +Saint-Sulpice,60020,Lanaudière,plex,1.0,29,9.6,not_published | |
| 1316 | +Port-Cartier,97022,Côte-Nord,plex,1.0,29,8.6,not_published | |
| 1317 | +Ascot Corner,41055,Estrie,plex,1.0,28,8.9,not_published | |
| 1318 | +Sainte-Brigitte-de-Laval,22045,Capitale-Nationale,plex,1.0,28,8.6,not_published | |
| 1319 | +Saint-Eugène-d'Argentenay,92065,Saguenay–Lac-Saint-Jean,unifamilial,1.0,28,8.9,not_published | |
| 1320 | +Sainte-Mélanie,61050,Lanaudière,plex,1.0,28,9.3,not_published | |
| 1321 | +La Rédemption,09005,Bas-Saint-Laurent,unifamilial,1.0,28,9.3,not_published | |
| 1322 | +Saint-Nazaire-de-Dorchester,19015,Chaudière-Appalaches,unifamilial,1.0,28,8.9,not_published | |
| 1323 | +Barkmere,78050,Laurentides,unifamilial,1.0,28,9.3,not_published | |
| 1324 | +Saint-Ferréol-les-Neiges,21010,Capitale-Nationale,plex,1.0,28,8.2,not_published | |
| 1325 | +Blanc-Sablon,98005,Côte-Nord,unifamilial,1.0,28,8.9,not_published | |
| 1326 | +Warden,47030,Estrie,unifamilial,1.0,28,9.3,not_published | |
| 1327 | +Saint-Eusèbe,13030,Bas-Saint-Laurent,unifamilial,1.0,28,8.9,not_published | |
| 1328 | +Saint-Agapit,33045,Chaudière-Appalaches,plex,1.0,27,8.9,not_published | |
| 1329 | +Saint-François-du-Lac,50128,Centre-du-Québec,plex,1.0,27,7.2,not_published | |
| 1330 | +Bois-Franc,83085,Outaouais,unifamilial,1.0,27,9.3,not_published | |
| 1331 | +Saint-Pie-de-Guire,49130,Centre-du-Québec,unifamilial,1.0,27,9.3,not_published | |
| 1332 | +Sainte-Madeleine-de-la-Rivière-Madeleine,04005,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,27,9.3,not_published | |
| 1333 | +Saint-Alexis-de-Matapédia,06050,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,27,9.3,not_published | |
| 1334 | +Saint-Ambroise,94255,Saguenay–Lac-Saint-Jean,plex,1.0,27,9.3,not_published | |
| 1335 | +Notre-Dame-du-Mont-Carmel,37235,Mauricie,plex,1.0,27,8.9,not_published | |
| 1336 | +Chelsea,82025,Outaouais,plex,1.0,27,8.2,not_published | |
| 1337 | +Maskinongé,51008,Mauricie,plex,1.0,27,8.9,not_published | |
| 1338 | +Sainte-Edwidge-de-Clifton,44055,Estrie,unifamilial,1.0,27,9.3,not_published | |
| 1339 | +Saint-Alphonse-Rodriguez,62025,Lanaudière,plex,1.0,27,8.9,not_published | |
| 1340 | +Lac-des-Écorces,79078,Laurentides,plex,1.0,27,8.2,not_published | |
| 1341 | +Lac-Frontière,18010,Chaudière-Appalaches,unifamilial,1.0,27,9.3,not_published | |
| 1342 | +Saint-Bruno-de-Montarville,58037,Montérégie,plex,1.0,27,9.3,not_published | |
| 1343 | +Laurier-Station,33060,Chaudière-Appalaches,plex,1.0,27,8.9,not_published | |
| 1344 | +Saint-Augustin,92005,Saguenay–Lac-Saint-Jean,unifamilial,1.0,27,8.9,not_published | |
| 1345 | +Godbout,96010,Côte-Nord,unifamilial,1.0,27,9.3,not_published | |
| 1346 | +Saint-Éloi,11035,Bas-Saint-Laurent,unifamilial,1.0,27,8.9,not_published | |
| 1347 | +Esprit-Saint,10005,Bas-Saint-Laurent,unifamilial,1.0,27,8.9,not_published | |
| 1348 | +Saint-Prosper,28020,Chaudière-Appalaches,plex,1.0,27,8.9,not_published | |
| 1349 | +Daveluyville,39152,Centre-du-Québec,plex,1.0,26,8.6,not_published | |
| 1350 | +Sainte-Luce,09092,Bas-Saint-Laurent,plex,1.0,26,8.6,not_published | |
| 1351 | +Otterburn Park,57030,Montérégie,condo,1.0,26,7.6,not_published | |
| 1352 | +Saint-Alexis-des-Monts,51065,Mauricie,plex,1.0,26,7.9,not_published | |
| 1353 | +Waterville,44080,Estrie,plex,1.0,26,8.2,not_published | |
| 1354 | +Longue-Pointe-de-Mingan,98045,Côte-Nord,unifamilial,1.0,26,8.2,not_published | |
| 1355 | +Barraute,88022,Abitibi-Témiscamingue,plex,1.0,26,7.9,not_published | |
| 1356 | +Saint-Aimé,53015,Montérégie,unifamilial,1.0,26,8.2,not_published | |
| 1357 | +Sainte-Claire,19055,Chaudière-Appalaches,plex,1.0,26,8.6,not_published | |
| 1358 | +Sainte-Félicité,17025,Chaudière-Appalaches,unifamilial,1.0,26,7.6,not_published | |
| 1359 | +Portage-du-Fort,84020,Outaouais,unifamilial,1.0,26,7.9,not_published | |
| 1360 | +Sainte-Croix,33102,Chaudière-Appalaches,plex,1.0,26,7.6,not_published | |
| 1361 | +Saint-Casimir,34078,Capitale-Nationale,plex,1.0,26,7.6,not_published | |
| 1362 | +Saint-Zénon-du-Lac-Humqui,07035,Bas-Saint-Laurent,unifamilial,1.0,26,7.9,not_published | |
| 1363 | +East Broughton,31122,Chaudière-Appalaches,plex,1.0,26,8.6,not_published | |
| 1364 | +Saint-Lambert-de-Lauzon,26070,Chaudière-Appalaches,plex,1.0,26,8.6,not_published | |
| 1365 | +Saint-Antoine-de-l'Isle-aux-Grues,18070,Chaudière-Appalaches,unifamilial,1.0,26,7.9,not_published | |
| 1366 | +Saint-Nazaire,93045,Saguenay–Lac-Saint-Jean,plex,1.0,26,8.9,not_published | |
| 1367 | +Yamachiche,51020,Mauricie,plex,1.0,26,8.6,not_published | |
| 1368 | +Saint-Julien,31035,Chaudière-Appalaches,unifamilial,1.0,25,8.2,not_published | |
| 1369 | +Saint-Séverin,27070,Chaudière-Appalaches,unifamilial,1.0,25,8.2,not_published | |
| 1370 | +McMasterville,57025,Montérégie,condo,1.0,25,8.2,not_published | |
| 1371 | +Hope,05025,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,25,8.6,not_published | |
| 1372 | +Cap-Saint-Ignace,18045,Chaudière-Appalaches,plex,1.0,25,8.2,not_published | |
| 1373 | +Saint-Noël,07100,Bas-Saint-Laurent,unifamilial,1.0,25,8.2,not_published | |
| 1374 | +La Pêche,82035,Outaouais,condo,1.0,25,8.2,not_published | |
| 1375 | +Saint-Esprit,63030,Lanaudière,plex,1.0,25,7.9,not_published | |
| 1376 | +Calixa-Lavallée,59030,Montérégie,unifamilial,1.0,25,8.6,not_published | |
| 1377 | +L'Islet,17078,Chaudière-Appalaches,plex,1.0,25,7.9,not_published | |
| 1378 | +Saint-Léandre,08065,Bas-Saint-Laurent,unifamilial,1.0,25,8.2,not_published | |
| 1379 | +Orford,45115,Estrie,plex,1.0,25,8.2,not_published | |
| 1380 | +Deschambault-Grondines,34058,Capitale-Nationale,plex,1.0,24,8.2,not_published | |
| 1381 | +Lyster,32065,Centre-du-Québec,plex,1.0,24,7.2,not_published | |
| 1382 | +Chazel,87095,Abitibi-Témiscamingue,unifamilial,1.0,24,7.2,not_published | |
| 1383 | +Sainte-Monique,50057,Centre-du-Québec,unifamilial,1.0,24,8.2,not_published | |
| 1384 | +Sainte-Hélène-de-Chester,39035,Centre-du-Québec,unifamilial,1.0,24,7.9,not_published | |
| 1385 | +Ange-Gardien,55008,Montérégie,plex,1.0,24,7.6,not_published | |
| 1386 | +Yamaska,53072,Montérégie,plex,1.0,24,7.6,not_published | |
| 1387 | +Ivry-sur-le-Lac,78042,Laurentides,unifamilial,1.0,24,8.2,not_published | |
| 1388 | +Saint-Tharcisius,07070,Bas-Saint-Laurent,unifamilial,1.0,24,7.9,not_published | |
| 1389 | +Pointe-Claire,66097,Montréal,plex,1.0,24,8.2,not_published | |
| 1390 | +Mont-Saint-Grégoire,56097,Montérégie,plex,1.0,24,7.2,not_published | |
| 1391 | +Potton,45030,Estrie,condo,1.0,24,7.9,not_published | |
| 1392 | +New Richmond,05070,Gaspésie–Îles-de-la-Madeleine,plex,1.0,24,7.9,not_published | |
| 1393 | +Aston-Jonction,50013,Centre-du-Québec,unifamilial,1.0,24,7.9,not_published | |
| 1394 | +Authier-Nord,87100,Abitibi-Témiscamingue,unifamilial,1.0,23,7.9,not_published | |
| 1395 | +Saint-Jean-Port-Joli,17070,Chaudière-Appalaches,plex,1.0,23,7.6,not_published | |
| 1396 | +Normandin,92040,Saguenay–Lac-Saint-Jean,plex,1.0,23,7.9,not_published | |
| 1397 | +Sainte-Lucie-de-Beauregard,18020,Chaudière-Appalaches,unifamilial,1.0,23,7.9,not_published | |
| 1398 | +Saint-Henri,19068,Chaudière-Appalaches,condo,1.0,23,6.5,not_published | |
| 1399 | +Saint-Gabriel-de-Valcartier,22025,Capitale-Nationale,plex,1.0,23,7.6,not_published | |
| 1400 | +Saint-Sévère,51030,Mauricie,unifamilial,1.0,23,7.6,not_published | |
| 1401 | +Sainte-Françoise,38035,Centre-du-Québec,unifamilial,1.0,23,7.9,not_published | |
| 1402 | +Saint-Antonin,12015,Bas-Saint-Laurent,plex,1.0,23,7.2,not_published | |
| 1403 | +Notre-Dame-du-Laus,79005,Laurentides,plex,1.0,23,7.6,not_published | |
| 1404 | +Sainte-Florence,07010,Bas-Saint-Laurent,unifamilial,1.0,23,6.9,not_published | |
| 1405 | +Sainte-Marie-Madeleine,54030,Montérégie,plex,1.0,23,7.2,not_published | |
| 1406 | +Saint-André-d'Argenteuil,76008,Laurentides,plex,1.0,23,7.6,not_published | |
| 1407 | +Grand-Saint-Esprit,50065,Centre-du-Québec,unifamilial,1.0,23,7.6,not_published | |
| 1408 | +Sainte-Clotilde-de-Beauce,31060,Chaudière-Appalaches,unifamilial,1.0,23,7.9,not_published | |
| 1409 | +Sacré-Coeur-de-Jésus,31130,Chaudière-Appalaches,unifamilial,1.0,23,7.6,not_published | |
| 1410 | +Saint-Paulin,51060,Mauricie,plex,1.0,23,6.9,not_published | |
| 1411 | +Saint-Pierre-de-Lamy,13075,Bas-Saint-Laurent,unifamilial,1.0,23,7.6,not_published | |
| 1412 | +Saint-Eugène-de-Ladrière,10075,Bas-Saint-Laurent,unifamilial,1.0,23,7.9,not_published | |
| 1413 | +Béthanie,48005,Montérégie,unifamilial,1.0,23,7.9,not_published | |
| 1414 | +Saint-Barthélemy,52055,Lanaudière,plex,1.0,23,7.9,not_published | |
| 1415 | +Saint-Anaclet-de-Lessard,10030,Bas-Saint-Laurent,plex,1.0,23,7.6,not_published | |
| 1416 | +Eastman,45093,Estrie,plex,1.0,22,7.6,not_published | |
| 1417 | +Sainte-Élisabeth,52030,Lanaudière,plex,1.0,22,7.6,not_published | |
| 1418 | +Sainte-Cécile-de-Lévrard,38060,Centre-du-Québec,unifamilial,1.0,22,7.6,not_published | |
| 1419 | +Rapide-Danseur,87010,Abitibi-Témiscamingue,unifamilial,1.0,22,7.6,not_published | |
| 1420 | +Saint-Apollinaire,33090,Chaudière-Appalaches,condo,1.0,22,6.9,not_published | |
| 1421 | +Les Méchins,08005,Bas-Saint-Laurent,unifamilial,1.0,22,6.5,not_published | |
| 1422 | +Wickham,49040,Centre-du-Québec,plex,1.0,22,6.9,not_published | |
| 1423 | +Sainte-Justine,28045,Chaudière-Appalaches,plex,1.0,22,7.6,not_published | |
| 1424 | +Roxton Falls,48010,Montérégie,plex,1.0,22,7.6,not_published | |
| 1425 | +Beauharnois,70022,Montérégie,condo,1.0,22,6.9,not_published | |
| 1426 | +Marsoui,04025,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,22,7.2,not_published | |
| 1427 | +La Présentation,54035,Montérégie,plex,1.0,22,7.2,not_published | |
| 1428 | +Rivière-du-Loup,12072,Bas-Saint-Laurent,condo,1.0,22,6.5,not_published | |
| 1429 | +Notre-Dame-de-l'Île-Perrot,71065,Montérégie,plex,1.0,22,7.6,not_published | |
| 1430 | +Dollard-des-Ormeaux,66142,Montréal,plex,1.0,22,7.6,not_published | |
| 1431 | +East Hereford,44010,Estrie,unifamilial,1.0,22,6.9,not_published | |
| 1432 | +Chénéville,80103,Outaouais,plex,1.0,21,6.2,not_published | |
| 1433 | +Napierville,68030,Montérégie,plex,1.0,21,7.2,not_published | |
| 1434 | +Saint-Martin,29045,Chaudière-Appalaches,plex,1.0,21,6.5,not_published | |
| 1435 | +Saint-Pierre-de-l'Île-d'Orléans,20025,Capitale-Nationale,plex,1.0,21,6.5,not_published | |
| 1436 | +Fugèreville,85055,Abitibi-Témiscamingue,unifamilial,1.0,21,6.9,not_published | |
| 1437 | +Rougemont,55037,Montérégie,plex,1.0,21,7.2,not_published | |
| 1438 | +Saint-Lin–Laurentides,63048,Lanaudière,condo,1.0,21,6.5,not_published | |
| 1439 | +Saint-Jean-de-Brébeuf,31100,Chaudière-Appalaches,unifamilial,1.0,21,7.2,not_published | |
| 1440 | +Saint-Alexandre-de-Kamouraska,14035,Bas-Saint-Laurent,plex,1.0,21,6.9,not_published | |
| 1441 | +Amherst,78070,Laurentides,plex,1.0,21,6.5,not_published | |
| 1442 | +Sainte-Catherine,67030,Montérégie,condo,1.0,21,6.9,not_published | |
| 1443 | +Rigaud,71133,Montérégie,condo,1.0,21,6.9,not_published | |
| 1444 | +Sainte-Barbe,69065,Montérégie,plex,1.0,21,7.2,not_published | |
| 1445 | +Cap-Santé,34030,Capitale-Nationale,plex,1.0,21,6.9,not_published | |
| 1446 | +Saint-Raphaël,19082,Chaudière-Appalaches,plex,1.0,21,7.2,not_published | |
| 1447 | +Stoke,42005,Estrie,plex,1.0,21,6.5,not_published | |
| 1448 | +Lac-Tremblant-Nord,78127,Laurentides,unifamilial,1.0,21,7.2,not_published | |
| 1449 | +Padoue,09040,Bas-Saint-Laurent,unifamilial,1.0,21,7.2,not_published | |
| 1450 | +Sainte-Élizabeth-de-Warwick,39090,Centre-du-Québec,unifamilial,1.0,21,6.5,not_published | |
| 1451 | +Sainte-Rita,11015,Bas-Saint-Laurent,unifamilial,1.0,21,6.5,not_published | |
| 1452 | +Témiscaming,85005,Abitibi-Témiscamingue,plex,1.0,21,6.9,not_published | |
| 1453 | +La Reine,87080,Abitibi-Témiscamingue,unifamilial,1.0,21,6.9,not_published | |
| 1454 | +Saint-Fortunat,31030,Chaudière-Appalaches,unifamilial,1.0,21,6.5,not_published | |
| 1455 | +Saint-Prime,91035,Saguenay–Lac-Saint-Jean,plex,1.0,21,6.9,not_published | |
| 1456 | +Lavaltrie,52007,Lanaudière,condo,1.0,20,6.5,not_published | |
| 1457 | +Upton,48038,Montérégie,plex,1.0,20,6.2,not_published | |
| 1458 | +Lac-Normand,35904,Mauricie,unifamilial,1.0,20,6.9,not_published | |
| 1459 | +Saint-Fulgence,94235,Saguenay–Lac-Saint-Jean,plex,1.0,20,6.9,not_published | |
| 1460 | +Saint-Basile,34038,Capitale-Nationale,plex,1.0,20,6.9,not_published | |
| 1461 | +Saint-Charles-de-Bellechasse,19097,Chaudière-Appalaches,plex,1.0,20,6.2,not_published | |
| 1462 | +Baie-Sainte-Catherine,15065,Capitale-Nationale,unifamilial,1.0,20,6.5,not_published | |
| 1463 | +Ristigouche-Sud-Est,06035,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,20,5.5,not_published | |
| 1464 | +Sainte-Victoire-de-Sorel,53025,Montérégie,plex,1.0,20,6.9,not_published | |
| 1465 | +Sainte-Jeanne-d'Arc-de-la-Mitis,09020,Bas-Saint-Laurent,unifamilial,1.0,20,6.9,not_published | |
| 1466 | +Laniel,85905,Abitibi-Témiscamingue,unifamilial,1.0,20,6.5,not_published | |
| 1467 | +Saint-Maurice,37230,Mauricie,plex,1.0,20,6.2,not_published | |
| 1468 | +Lac-aux-Sables,35010,Mauricie,plex,1.0,20,6.2,not_published | |
| 1469 | +Saint-Joachim,21020,Capitale-Nationale,plex,1.0,20,6.5,not_published | |
| 1470 | +Caplan,05060,Gaspésie–Îles-de-la-Madeleine,plex,1.0,20,6.5,not_published | |
| 1471 | +Les Cèdres,71050,Montérégie,plex,1.0,20,6.2,not_published | |
| 1472 | +Saint-Tite-des-Caps,21005,Capitale-Nationale,plex,1.0,20,6.9,not_published | |
| 1473 | +Saint-Gérard-Majella,53085,Montérégie,unifamilial,1.0,20,6.5,not_published | |
| 1474 | +Saint-Léonard-d'Aston,50042,Centre-du-Québec,plex,1.0,20,5.8,not_published | |
| 1475 | +Cacouna,12057,Bas-Saint-Laurent,plex,1.0,20,6.9,not_published | |
| 1476 | +Saint-Lambert,87120,Abitibi-Témiscamingue,unifamilial,1.0,19,6.2,not_published | |
| 1477 | +Grosse-Île,01042,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,19,5.8,not_published | |
| 1478 | +Pointe-des-Cascades,71055,Montérégie,plex,1.0,19,6.2,not_published | |
| 1479 | +Saint-Pascal,14018,Bas-Saint-Laurent,plex,1.0,19,6.2,not_published | |
| 1480 | +Petite-Rivière-Saint-François,16005,Capitale-Nationale,plex,1.0,19,6.2,not_published | |
| 1481 | +Saint-Placide,72043,Laurentides,plex,1.0,19,6.2,not_published | |
| 1482 | +Mandeville,52095,Lanaudière,plex,1.0,19,6.5,not_published | |
| 1483 | +Hope Town,05020,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,19,6.5,not_published | |
| 1484 | +Saint-Côme–Linière,29057,Chaudière-Appalaches,plex,1.0,19,6.2,not_published | |
| 1485 | +Desbiens,93005,Saguenay–Lac-Saint-Jean,plex,1.0,19,6.5,not_published | |
| 1486 | +La Visitation-de-Yamaska,50085,Centre-du-Québec,unifamilial,1.0,19,6.2,not_published | |
| 1487 | +Sainte-Catherine-de-la-Jacques-Cartier,22005,Capitale-Nationale,condo,1.0,19,5.8,not_published | |
| 1488 | +Grenville-sur-la-Rouge,76052,Laurentides,plex,1.0,19,6.5,not_published | |
| 1489 | +Saint-Jacques-le-Majeur-de-Wolfestown,31025,Chaudière-Appalaches,unifamilial,1.0,19,6.2,not_published | |
| 1490 | +Cacouna,12057,Bas-Saint-Laurent,condo,1.0,19,6.2,not_published | |
| 1491 | +Saint-Siméon,05055,Gaspésie–Îles-de-la-Madeleine,plex,1.0,19,6.5,not_published | |
| 1492 | +Rivière-aux-Outardes,96902,Côte-Nord,unifamilial,1.0,19,5.8,not_published | |
| 1493 | +Sainte-Catherine-de-Hatley,45060,Estrie,plex,1.0,19,6.5,not_published | |
| 1494 | +Saint-Bruno-de-Guigues,85045,Abitibi-Témiscamingue,plex,1.0,19,6.5,not_published | |
| 1495 | +Saint-David-de-Falardeau,94245,Saguenay–Lac-Saint-Jean,plex,1.0,19,6.5,not_published | |
| 1496 | +Rivière-Beaudette,71005,Montérégie,plex,1.0,19,6.5,not_published | |
| 1497 | +Saint-Dominique,54060,Montérégie,plex,1.0,18,5.8,not_published | |
| 1498 | +Shawville,84010,Outaouais,plex,1.0,18,5.8,not_published | |
| 1499 | +La Guadeloupe,29030,Chaudière-Appalaches,plex,1.0,18,6.2,not_published | |
| 1500 | +Saint-Siméon,15058,Capitale-Nationale,plex,1.0,18,5.8,not_published | |
| 1501 | +Deux-Montagnes,72010,Laurentides,condo,1.0,18,5.5,not_published | |
| 1502 | +Lorraine,73025,Laurentides,condo,1.0,18,6.2,not_published | |
| 1503 | +Pincourt,71070,Montérégie,plex,1.0,18,5.5,not_published | |
| 1504 | +Saint-Paul-d'Abbotsford,55015,Montérégie,plex,1.0,18,5.8,not_published | |
| 1505 | +Sainte-Marcelline-de-Kildare,62030,Lanaudière,plex,1.0,18,5.8,not_published | |
| 1506 | +Notre-Dame-des-Pins,29120,Chaudière-Appalaches,plex,1.0,18,5.5,not_published | |
| 1507 | +Saint-Joseph-de-Kamouraska,14030,Bas-Saint-Laurent,unifamilial,1.0,18,6.2,not_published | |
| 1508 | +Dunham,46050,Estrie,plex,1.0,18,6.2,not_published | |
| 1509 | +Saint-Ignace-de-Loyola,52045,Lanaudière,plex,1.0,18,6.2,not_published | |
| 1510 | +Venise-en-Québec,56005,Montérégie,plex,1.0,18,6.2,not_published | |
| 1511 | +Albertville,07025,Bas-Saint-Laurent,unifamilial,1.0,18,6.2,not_published | |
| 1512 | +Saint-Isidore,67040,Montérégie,plex,1.0,18,5.2,not_published | |
| 1513 | +La Martre,04030,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,18,5.8,not_published | |
| 1514 | +Saint-Athanase,13100,Bas-Saint-Laurent,unifamilial,1.0,18,5.8,not_published | |
| 1515 | +Chambord,91020,Saguenay–Lac-Saint-Jean,plex,1.0,18,5.2,not_published | |
| 1516 | +Mansfield-et-Pontefract,84065,Outaouais,plex,1.0,18,6.2,not_published | |
| 1517 | +Saint-Éphrem-de-Beauce,29112,Chaudière-Appalaches,plex,1.0,18,5.8,not_published | |
| 1518 | +Hampden,41075,Estrie,unifamilial,1.0,18,5.8,not_published | |
| 1519 | +Pierreville,50113,Centre-du-Québec,plex,1.0,18,5.8,not_published | |
| 1520 | +Saint-Edmond-les-Plaines,92050,Saguenay–Lac-Saint-Jean,unifamilial,1.0,18,6.2,not_published | |
| 1521 | +Saint-Narcisse,37240,Mauricie,plex,1.0,18,6.2,not_published | |
| 1522 | +Saint-Gédéon-de-Beauce,29013,Chaudière-Appalaches,plex,1.0,18,5.5,not_published | |
| 1523 | +Saint-Cléophas-de-Brandon,52075,Lanaudière,unifamilial,1.0,18,5.5,not_published | |
| 1524 | +La Malbaie,15013,Capitale-Nationale,condo,1.0,18,5.5,not_published | |
| 1525 | +Saint-Fabien,10070,Bas-Saint-Laurent,plex,1.0,18,5.5,not_published | |
| 1526 | +Saint-Pierre-de-l'Île-d'Orléans,20025,Capitale-Nationale,condo,1.0,18,5.8,not_published | |
| 1527 | +Saint-Gilbert,34060,Capitale-Nationale,unifamilial,1.0,18,6.2,not_published | |
| 1528 | +Grande-Rivière,02015,Gaspésie–Îles-de-la-Madeleine,plex,1.0,18,5.8,not_published | |
| 1529 | +Val-des-Lacs,78100,Laurentides,plex,1.0,17,5.2,not_published | |
| 1530 | +Saint-Jean-de-Dieu,11010,Bas-Saint-Laurent,plex,1.0,17,5.8,not_published | |
| 1531 | +Saint-François-Xavier-de-Brompton,42020,Estrie,plex,1.0,17,5.2,not_published | |
| 1532 | +Sept-Îles,97007,Côte-Nord,condo,1.0,17,5.2,not_published | |
| 1533 | +Sainte-Anne-de-Sorel,53065,Montérégie,plex,1.0,17,5.5,not_published | |
| 1534 | +Lorrainville,85037,Abitibi-Témiscamingue,plex,1.0,17,5.8,not_published | |
| 1535 | +Larouche,94265,Saguenay–Lac-Saint-Jean,plex,1.0,17,5.8,not_published | |
| 1536 | +Champlain,37220,Mauricie,plex,1.0,17,5.2,not_published | |
| 1537 | +Saint-Elzéar-de-Témiscouata,13085,Bas-Saint-Laurent,unifamilial,1.0,17,5.8,not_published | |
| 1538 | +Montebello,80010,Outaouais,plex,1.0,17,5.5,not_published | |
| 1539 | +Saint-Polycarpe,71020,Montérégie,plex,1.0,17,5.5,not_published | |
| 1540 | +Chertsey,62047,Lanaudière,plex,1.0,17,5.8,not_published | |
| 1541 | +Brébeuf,78075,Laurentides,plex,1.0,17,5.8,not_published | |
| 1542 | +Shefford,47035,Estrie,plex,1.0,17,5.8,not_published | |
| 1543 | +Saint-Médard,11025,Bas-Saint-Laurent,unifamilial,1.0,17,5.5,not_published | |
| 1544 | +Saint-Pamphile,17010,Chaudière-Appalaches,plex,1.0,17,5.8,not_published | |
| 1545 | +Les Escoumins,95025,Côte-Nord,plex,1.0,17,5.8,not_published | |
| 1546 | +Saint-Pacôme,14070,Bas-Saint-Laurent,plex,1.0,17,4.5,not_published | |
| 1547 | +Aguanish,98030,Côte-Nord,unifamilial,1.0,17,5.8,not_published | |
| 1548 | +Sacré-Coeur,95010,Côte-Nord,plex,1.0,17,4.5,not_published | |
| 1549 | +Saint-Philibert,29065,Chaudière-Appalaches,unifamilial,1.0,17,5.8,not_published | |
| 1550 | +Paspébiac,05032,Gaspésie–Îles-de-la-Madeleine,plex,1.0,17,5.5,not_published | |
| 1551 | +Saint-Hyacinthe,54048,Montérégie,condo,1.0,17,5.5,not_published | |
| 1552 | +Notre-Dame-de-l'Île-Perrot,71065,Montérégie,condo,1.0,17,5.5,not_published | |
| 1553 | +Farnham,46112,Estrie,condo,1.0,17,5.8,not_published | |
| 1554 | +Saint-Alphonse-de-Granby,47010,Estrie,plex,1.0,17,5.5,not_published | |
| 1555 | +Saint-Clet,71045,Montérégie,plex,1.0,16,5.5,not_published | |
| 1556 | +Saint-Marc-sur-Richelieu,57050,Montérégie,plex,1.0,16,5.2,not_published | |
| 1557 | +Saint-Gervais,19075,Chaudière-Appalaches,plex,1.0,16,5.2,not_published | |
| 1558 | +Hudson,71100,Montérégie,plex,1.0,16,5.2,not_published | |
| 1559 | +Saint-Gilles,33035,Chaudière-Appalaches,plex,1.0,16,5.5,not_published | |
| 1560 | +Potton,45030,Estrie,plex,1.0,16,5.2,not_published | |
| 1561 | +Stanbridge Station,46030,Estrie,unifamilial,1.0,16,4.5,not_published | |
| 1562 | +Causapscal,07018,Bas-Saint-Laurent,plex,1.0,16,5.2,not_published | |
| 1563 | +Nominingue,79030,Laurentides,plex,1.0,16,4.8,not_published | |
| 1564 | +L'Île-d'Anticosti,98020,Côte-Nord,unifamilial,1.0,16,5.5,not_published | |
| 1565 | +Saint-Cléophas,07090,Bas-Saint-Laurent,unifamilial,1.0,16,5.5,not_published | |
| 1566 | +Brownsburg-Chatham,76043,Laurentides,condo,1.0,16,5.2,not_published | |
| 1567 | +Saint-Germain-de-Kamouraska,14045,Bas-Saint-Laurent,unifamilial,1.0,16,4.8,not_published | |
| 1568 | +Notre-Dame-du-Nord,85090,Abitibi-Témiscamingue,plex,1.0,16,5.2,not_published | |
| 1569 | +Sainte-Émélie-de-l'Énergie,62070,Lanaudière,plex,1.0,16,5.5,not_published | |
| 1570 | +Rivière-à-Claude,04020,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,16,4.8,not_published | |
| 1571 | +Saint-Liboire,54072,Montérégie,plex,1.0,16,5.2,not_published | |
| 1572 | +Sainte-Hélène-de-Bagot,54095,Montérégie,plex,1.0,16,4.8,not_published | |
| 1573 | +Natashquan,98025,Côte-Nord,unifamilial,1.0,16,5.5,not_published | |
| 1574 | +Saint-Ulric,08073,Bas-Saint-Laurent,plex,1.0,16,5.5,not_published | |
| 1575 | +Saint-Polycarpe,71020,Montérégie,condo,1.0,16,5.2,not_published | |
| 1576 | +Belcourt,89050,Abitibi-Témiscamingue,unifamilial,1.0,16,5.2,not_published | |
| 1577 | +Saint-Laurent-de-l'Île-d'Orléans,20020,Capitale-Nationale,plex,1.0,15,4.8,not_published | |
| 1578 | +Notre-Dame-des-Prairies,61030,Lanaudière,condo,1.0,15,4.8,not_published | |
| 1579 | +Saint-Donat,62060,Lanaudière,condo,1.0,15,3.4,not_published | |
| 1580 | +Léry,67055,Montérégie,plex,1.0,15,4.5,not_published | |
| 1581 | +Franklin,69010,Montérégie,plex,1.0,15,5.2,not_published | |
| 1582 | +Saint-Étienne-des-Grès,51090,Mauricie,plex,1.0,15,4.8,not_published | |
| 1583 | +Port-Daniel–Gascons,02047,Gaspésie–Îles-de-la-Madeleine,plex,1.0,15,4.8,not_published | |
| 1584 | +Lac-Bouchette,91005,Saguenay–Lac-Saint-Jean,plex,1.0,15,4.8,not_published | |
| 1585 | +L'Anse-Saint-Jean,94210,Saguenay–Lac-Saint-Jean,plex,1.0,15,5.2,not_published | |
| 1586 | +Authier,87050,Abitibi-Témiscamingue,unifamilial,1.0,15,5.2,not_published | |
| 1587 | +Lemieux,38020,Centre-du-Québec,unifamilial,1.0,15,4.5,not_published | |
| 1588 | +Coteau-du-Lac,71040,Montérégie,condo,1.0,15,5.2,not_published | |
| 1589 | +Saint-Cuthbert,52062,Lanaudière,plex,1.0,15,5.2,not_published | |
| 1590 | +Saint-Zacharie,28005,Chaudière-Appalaches,plex,1.0,15,5.2,not_published | |
| 1591 | +Ripon,80078,Outaouais,plex,1.0,15,5.2,not_published | |
| 1592 | +Saint-Denis-sur-Richelieu,57068,Montérégie,plex,1.0,15,4.8,not_published | |
| 1593 | +Baie-D'Urfé,66112,Montréal,condo,1.0,15,5.2,not_published | |
| 1594 | +Shigawake,05010,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,14,4.8,not_published | |
| 1595 | +Pont-Rouge,34017,Capitale-Nationale,condo,1.0,14,4.8,not_published | |
| 1596 | +Lac-Poulin,29095,Chaudière-Appalaches,unifamilial,1.0,14,4.8,not_published | |
| 1597 | +Compton,44071,Estrie,plex,1.0,14,4.8,not_published | |
| 1598 | +Hampstead,66062,Montréal,condo,1.0,14,4.5,not_published | |
| 1599 | +Maria,06005,Gaspésie–Îles-de-la-Madeleine,plex,1.0,14,4.8,not_published | |
| 1600 | +Saint-Gabriel-de-Brandon,52085,Lanaudière,plex,1.0,14,4.8,not_published | |
| 1601 | +Saint-Anicet,69070,Montérégie,plex,1.0,14,4.8,not_published | |
| 1602 | +Sayabec,07085,Bas-Saint-Laurent,plex,1.0,14,4.8,not_published | |
| 1603 | +L'Ascension-de-Notre-Seigneur,93065,Saguenay–Lac-Saint-Jean,plex,1.0,14,4.8,not_published | |
| 1604 | +Stanstead,45025,Estrie,plex,1.0,14,4.5,not_published | |
| 1605 | +Napierville,68030,Montérégie,condo,1.0,14,4.1,not_published | |
| 1606 | +Saint-Édouard-de-Lotbinière,33080,Chaudière-Appalaches,plex,1.0,14,4.8,not_published | |
| 1607 | +Mont-Saint-Pierre,04015,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,14,4.8,not_published | |
| 1608 | +Durham-Sud,49015,Centre-du-Québec,plex,1.0,14,4.8,not_published | |
| 1609 | +Saint-Pierre,61020,Lanaudière,unifamilial,1.0,14,4.8,not_published | |
| 1610 | +Saint-Alexis,63023,Lanaudière,plex,1.0,14,4.5,not_published | |
| 1611 | +Chute-aux-Outardes,96035,Côte-Nord,plex,1.0,14,4.5,not_published | |
| 1612 | +Nouvelle,06020,Gaspésie–Îles-de-la-Madeleine,plex,1.0,14,4.8,not_published | |
| 1613 | +Saint-Isidore,26063,Chaudière-Appalaches,plex,1.0,14,4.8,not_published | |
| 1614 | +Entrelacs,62053,Lanaudière,plex,1.0,13,4.1,not_published | |
| 1615 | +Saint-Urbain,16055,Capitale-Nationale,plex,1.0,13,4.5,not_published | |
| 1616 | +Saint-Gilles,33035,Chaudière-Appalaches,condo,1.0,13,4.5,not_published | |
| 1617 | +Armagh,19037,Chaudière-Appalaches,plex,1.0,13,4.5,not_published | |
| 1618 | +Messines,83060,Outaouais,plex,1.0,13,4.5,not_published | |
| 1619 | +Mille-Isles,76030,Laurentides,plex,1.0,13,3.8,not_published | |
| 1620 | +Tingwick,39025,Centre-du-Québec,plex,1.0,13,4.5,not_published | |
| 1621 | +Saint-Michel-de-Bellechasse,19110,Chaudière-Appalaches,plex,1.0,13,4.5,not_published | |
| 1622 | +Launay,88080,Abitibi-Témiscamingue,unifamilial,1.0,13,4.5,not_published | |
| 1623 | +Austin,45085,Estrie,plex,1.0,13,4.5,not_published | |
| 1624 | +Albanel,92030,Saguenay–Lac-Saint-Jean,plex,1.0,13,4.1,not_published | |
| 1625 | +Saint-Liguori,63065,Lanaudière,plex,1.0,13,4.1,not_published | |
| 1626 | +Lac-Mégantic,30030,Estrie,condo,1.0,13,4.5,not_published | |
| 1627 | +Lawrenceville,42045,Estrie,plex,1.0,13,4.1,not_published | |
| 1628 | +Berthier-sur-Mer,18065,Chaudière-Appalaches,plex,1.0,13,4.5,not_published | |
| 1629 | +Saint-Stanislas-de-Kostka,70040,Montérégie,plex,1.0,13,4.1,not_published | |
| 1630 | +Sainte-Geneviève-de-Berthier,52040,Lanaudière,plex,1.0,13,4.5,not_published | |
| 1631 | +Béarn,85020,Abitibi-Témiscamingue,plex,1.0,13,4.1,not_published | |
| 1632 | +Sainte-Perpétue,17030,Chaudière-Appalaches,plex,1.0,13,4.1,not_published | |
| 1633 | +Saint-Alexandre,56055,Montérégie,plex,1.0,13,4.5,not_published | |
| 1634 | +Saint-Honoré-de-Témiscouata,13090,Bas-Saint-Laurent,plex,1.0,13,4.1,not_published | |
| 1635 | +Saint-Roch-de-l'Achigan,63035,Lanaudière,condo,1.0,13,4.5,not_published | |
| 1636 | +Sainte-Lucie-des-Laurentides,78020,Laurentides,plex,1.0,13,3.8,not_published | |
| 1637 | +Saint-Godefroi,05015,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,13,4.5,not_published | |
| 1638 | +La Morandière-Rochebaucourt,88012,Abitibi-Témiscamingue,unifamilial,1.0,13,4.1,not_published | |
| 1639 | +Belleterre,85065,Abitibi-Témiscamingue,unifamilial,1.0,13,4.5,not_published | |
| 1640 | +Rosemère,73020,Laurentides,condo,1.0,13,4.1,not_published | |
| 1641 | +Saint-Louis-du-Ha! Ha!,13080,Bas-Saint-Laurent,plex,1.0,13,3.8,not_published | |
| 1642 | +Fort-Coulonge,84060,Outaouais,plex,1.0,13,4.5,not_published | |
| 1643 | +Frelighsburg,46010,Estrie,plex,1.0,13,4.5,not_published | |
| 1644 | +Sainte-Béatrix,62020,Lanaudière,plex,1.0,13,4.5,not_published | |
| 1645 | +Sheenboro,84095,Outaouais,unifamilial,1.0,13,4.5,not_published | |
| 1646 | +Vallée-Jonction,26015,Chaudière-Appalaches,plex,1.0,13,3.8,not_published | |
| 1647 | +Venise-en-Québec,56005,Montérégie,condo,1.0,12,4.1,not_published | |
| 1648 | +Saint-Victor,27008,Chaudière-Appalaches,plex,1.0,12,3.8,not_published | |
| 1649 | +Saint-Bernard,26055,Chaudière-Appalaches,plex,1.0,12,4.1,not_published | |
| 1650 | +Plaisance,80045,Outaouais,plex,1.0,12,4.1,not_published | |
| 1651 | +Lac-Simon,80095,Outaouais,plex,1.0,12,4.1,not_published | |
| 1652 | +Wotton,40017,Estrie,plex,1.0,12,4.1,not_published | |
| 1653 | +Saint-Jean-de-Cherbourg,08010,Bas-Saint-Laurent,unifamilial,1.0,12,4.1,not_published | |
| 1654 | +Lambton,30095,Estrie,plex,1.0,12,4.1,not_published | |
| 1655 | +Saint-André-de-Restigouche,06040,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,12,3.8,not_published | |
| 1656 | +Saint-Étienne-de-Beauharnois,70030,Montérégie,plex,1.0,12,4.1,not_published | |
| 1657 | +Godmanchester,69060,Montérégie,plex,1.0,12,4.1,not_published | |
| 1658 | +Nicolet,50072,Centre-du-Québec,condo,1.0,12,4.1,not_published | |
| 1659 | +Saint-Damien-de-Buckland,19030,Chaudière-Appalaches,plex,1.0,12,4.1,not_published | |
| 1660 | +Saint-Elphège,50095,Centre-du-Québec,unifamilial,1.0,12,4.1,not_published | |
| 1661 | +Otter Lake,84055,Outaouais,plex,1.0,12,4.1,not_published | |
| 1662 | +Côte-Nord-du-Golfe-du-Saint-Laurent,98015,Côte-Nord,unifamilial,1.0,12,4.1,not_published | |
| 1663 | +Adstock,31056,Chaudière-Appalaches,plex,1.0,12,4.1,not_published | |
| 1664 | +L'Isle-aux-Coudres,16023,Capitale-Nationale,plex,1.0,12,4.1,not_published | |
| 1665 | +Saint-Damase,54017,Montérégie,plex,1.0,12,3.8,not_published | |
| 1666 | +Sainte-Marguerite-Marie,07005,Bas-Saint-Laurent,unifamilial,1.0,12,3.8,not_published | |
| 1667 | +Saint-Antoine-de-Tilly,33095,Chaudière-Appalaches,plex,1.0,12,4.1,not_published | |
| 1668 | +Sainte-Anne-de-Bellevue,66117,Montréal,condo,1.0,11,3.8,not_published | |
| 1669 | +Saint-Alexandre-des-Lacs,07065,Bas-Saint-Laurent,unifamilial,1.0,11,3.8,not_published | |
| 1670 | +Saint-Honoré-de-Shenley,29038,Chaudière-Appalaches,plex,1.0,11,3.8,not_published | |
| 1671 | +Chelsea,82025,Outaouais,condo,1.0,11,3.1,not_published | |
| 1672 | +Saint-Joseph-de-Coleraine,31045,Chaudière-Appalaches,plex,1.0,11,3.8,not_published | |
| 1673 | +Saint-Guillaume,49113,Centre-du-Québec,plex,1.0,11,3.8,not_published | |
| 1674 | +Percé,02005,Gaspésie–Îles-de-la-Madeleine,plex,1.0,11,3.8,not_published | |
| 1675 | +L'Anse-Saint-Jean,94210,Saguenay–Lac-Saint-Jean,condo,1.0,11,3.1,not_published | |
| 1676 | +Saint-Benoît-Labre,29100,Chaudière-Appalaches,plex,1.0,11,3.4,not_published | |
| 1677 | +Saint-Ferdinand,32013,Centre-du-Québec,plex,1.0,11,3.8,not_published | |
| 1678 | +Campbell's Bay,84030,Outaouais,plex,1.0,11,3.4,not_published | |
| 1679 | +Saint-Alban,34097,Capitale-Nationale,plex,1.0,11,3.8,not_published | |
| 1680 | +Notre-Dame-de-Lorette,92060,Saguenay–Lac-Saint-Jean,unifamilial,1.0,11,3.8,not_published | |
| 1681 | +Saint-Mathieu-de-Beloeil,57045,Montérégie,condo,1.0,11,3.8,not_published | |
| 1682 | +Murdochville,03025,Gaspésie–Îles-de-la-Madeleine,plex,1.0,11,3.8,not_published | |
| 1683 | +Lac-Walker,97904,Côte-Nord,unifamilial,1.0,11,3.8,not_published | |
| 1684 | +Déléage,83070,Outaouais,plex,1.0,11,3.8,not_published | |
| 1685 | +Baie-du-Febvre,50100,Centre-du-Québec,plex,1.0,11,3.8,not_published | |
| 1686 | +Saint-Mathieu,67005,Montérégie,plex,1.0,11,3.8,not_published | |
| 1687 | +La Minerve,78130,Laurentides,plex,1.0,11,3.8,not_published | |
| 1688 | +Saint-Mathieu-de-Beloeil,57045,Montérégie,plex,1.0,11,3.8,not_published | |
| 1689 | +Saint-Louis-de-Gonzague,70035,Montérégie,plex,1.0,11,3.8,not_published | |
| 1690 | +Saint-Stanislas,37245,Mauricie,plex,1.0,11,3.8,not_published | |
| 1691 | +Saint-Ours,53032,Montérégie,plex,1.0,11,3.8,not_published | |
| 1692 | +Saint-Chrysostome,69017,Montérégie,plex,1.0,10,3.1,not_published | |
| 1693 | +Rivière-Bleue,13025,Bas-Saint-Laurent,plex,1.0,10,3.1,not_published | |
| 1694 | +Saint-Damien,62075,Lanaudière,plex,1.0,10,3.4,not_published | |
| 1695 | +Ange-Gardien,55008,Montérégie,condo,1.0,10,3.1,not_published | |
| 1696 | +Saint-Jude,54110,Montérégie,plex,1.0,10,3.4,not_published | |
| 1697 | +Rapides-des-Joachims,84100,Outaouais,unifamilial,1.0,10,3.1,not_published | |
| 1698 | +Taschereau,87042,Abitibi-Témiscamingue,plex,1.0,10,3.4,not_published | |
| 1699 | +Saint-Venant-de-Paquette,44005,Estrie,unifamilial,1.0,10,3.4,not_published | |
| 1700 | +Massueville,53010,Montérégie,plex,1.0,10,3.4,not_published | |
| 1701 | +Sainte-Cécile-de-Milton,47055,Estrie,plex,1.0,10,3.1,not_published | |
| 1702 | +Deschaillons-sur-Saint-Laurent,38070,Centre-du-Québec,plex,1.0,10,3.4,not_published | |
| 1703 | +Dudswell,41117,Estrie,plex,1.0,10,3.4,not_published | |
| 1704 | +Palmarolle,87025,Abitibi-Témiscamingue,plex,1.0,10,3.4,not_published | |
| 1705 | +Saint-Robert,53020,Montérégie,plex,1.0,10,3.4,not_published | |
| 1706 | +Frelighsburg,46010,Estrie,condo,1.0,10,3.4,not_published | |
| 1707 | +Batiscan,37210,Mauricie,plex,1.0,10,3.4,not_published | |
| 1708 | +Saint-Roch-Ouest,63040,Lanaudière,unifamilial,1.0,10,3.1,not_published | |
| 1709 | +Sainte-Brigide-d'Iberville,56105,Montérégie,plex,1.0,10,3.4,not_published | |
| 1710 | +Montpellier,80090,Outaouais,plex,1.0,10,3.4,not_published | |
| 1711 | +Chapais,99020,Nord-du-Québec,plex,1.0,10,3.4,not_published | |
| 1712 | +Saint-Antoine-sur-Richelieu,57075,Montérégie,plex,1.0,10,3.4,not_published | |
| 1713 | +Lachute,76020,Laurentides,condo,1.0,10,3.4,not_published | |
| 1714 | +Matagami,99015,Nord-du-Québec,plex,1.0,10,3.4,not_published | |
| 1715 | +Saint-Sylvestre,33007,Chaudière-Appalaches,plex,1.0,10,3.4,not_published | |
| 1716 | +Val-Joli,42095,Estrie,plex,1.0,10,3.4,not_published | |
| 1717 | +Bégin,94250,Saguenay–Lac-Saint-Jean,plex,1.0,10,3.4,not_published | |
| 1718 | +Saint-Célestin,50030,Centre-du-Québec,plex,1.0,10,3.4,not_published | |
| 1719 | +Notre-Dame-des-Sept-Douleurs,12045,Bas-Saint-Laurent,unifamilial,1.0,10,3.4,not_published | |
| 1720 | +Laurierville,32072,Centre-du-Québec,plex,1.0,10,3.4,not_published | |
| 1721 | +Bristol,84005,Outaouais,plex,1.0,10,2.7,not_published | |
| 1722 | +Saint-Blaise-sur-Richelieu,56065,Montérégie,plex,1.0,10,3.1,not_published | |
| 1723 | +Wentworth-Nord,77060,Laurentides,plex,1.0,10,3.1,not_published | |
| 1724 | +Notre-Dame-du-Portage,12080,Bas-Saint-Laurent,plex,1.0,10,3.4,not_published | |
| 1725 | +Saint-Charles-Garnier,09010,Bas-Saint-Laurent,unifamilial,1.0,10,3.4,not_published | |
| 1726 | +Rivière-Saint-Jean,98050,Côte-Nord,unifamilial,1.0,10,3.4,not_published | |
| 1727 | +Saint-Adelphe,35015,Mauricie,plex,1.0,10,3.4,not_published | |
| 1728 | +Saint-Juste-du-Lac,13040,Bas-Saint-Laurent,unifamilial,1.0,10,3.4,not_published | |
| 1729 | +Grand-Remous,83095,Outaouais,plex,1.0,10,3.4,not_published | |
| 1730 | +Price,09065,Bas-Saint-Laurent,plex,1.0,10,3.1,not_published | |
| 1731 | +Saint-Michel,68050,Montérégie,plex,1.0,10,3.4,not_published | |
| 1732 | +Beaumont,19105,Chaudière-Appalaches,plex,1.0,10,3.4,not_published | |
| 1733 | +Saint-Henri-de-Taillon,93070,Saguenay–Lac-Saint-Jean,plex,1.0,9,3.1,not_published | |
| 1734 | +Saint-Magloire,28075,Chaudière-Appalaches,plex,1.0,9,3.1,not_published | |
| 1735 | +Sainte-Monique,93075,Saguenay–Lac-Saint-Jean,plex,1.0,9,3.1,not_published | |
| 1736 | +Lochaber-Partie-Ouest,80060,Outaouais,plex,1.0,9,3.1,not_published | |
| 1737 | +Saint-Boniface,51085,Mauricie,condo,1.0,9,3.1,not_published | |
| 1738 | +Kingsey Falls,39097,Centre-du-Québec,plex,1.0,9,3.1,not_published | |
| 1739 | +Auclair,13045,Bas-Saint-Laurent,unifamilial,1.0,9,3.1,not_published | |
| 1740 | +Saint-Gédéon,93035,Saguenay–Lac-Saint-Jean,plex,1.0,9,2.7,not_published | |
| 1741 | +Montréal-Ouest,66047,Montréal,condo,1.0,9,3.1,not_published | |
| 1742 | +Saint-Théodore-d'Acton,48045,Montérégie,plex,1.0,9,3.1,not_published | |
| 1743 | +Girardville,92055,Saguenay–Lac-Saint-Jean,plex,1.0,9,3.1,not_published | |
| 1744 | +Sainte-Anne-de-Sabrevois,56060,Montérégie,plex,1.0,9,3.1,not_published | |
| 1745 | +Saint-Cyrille-de-Lessard,17045,Chaudière-Appalaches,plex,1.0,9,3.1,not_published | |
| 1746 | +Thetford Mines,31084,Chaudière-Appalaches,condo,1.0,9,3.1,not_published | |
| 1747 | +Saint-Aimé-du-Lac-des-Îles,79022,Laurentides,plex,1.0,9,3.1,not_published | |
| 1748 | +L'Isle-aux-Allumettes,84082,Outaouais,plex,1.0,9,3.1,not_published | |
| 1749 | +Chesterville,39030,Centre-du-Québec,plex,1.0,9,3.1,not_published | |
| 1750 | +L'Île-Dorval,66092,Montréal,unifamilial,1.0,9,3.1,not_published | |
| 1751 | +L'Isle-Verte,12043,Bas-Saint-Laurent,plex,1.0,9,3.1,not_published | |
| 1752 | +Saint-Hilarion,16050,Capitale-Nationale,plex,1.0,9,3.1,not_published | |
| 1753 | +Petite-Vallée,03015,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,9,3.1,not_published | |
| 1754 | +Saint-Philémon,19005,Chaudière-Appalaches,plex,1.0,9,3.1,not_published | |
| 1755 | +Val-Alain,33070,Chaudière-Appalaches,plex,1.0,9,3.1,not_published | |
| 1756 | +Sainte-Clotilde,68020,Montérégie,plex,1.0,9,3.1,not_published | |
| 1757 | +Mont-Blanc,78047,Laurentides,condo,1.0,9,3.1,not_published | |
| 1758 | +Lac-Sainte-Marie,83020,Outaouais,plex,1.0,9,2.7,not_published | |
| 1759 | +Kingsbury,42070,Estrie,unifamilial,1.0,9,3.1,not_published | |
| 1760 | +Saint-Patrice-de-Sherrington,68025,Montérégie,plex,1.0,9,2.7,not_published | |
| 1761 | +Hemmingford,68010,Montérégie,plex,1.0,9,2.7,not_published | |
| 1762 | +Stanbridge East,46045,Estrie,plex,1.0,9,3.1,not_published | |
| 1763 | +Saint-Charles-sur-Richelieu,57057,Montérégie,plex,1.0,9,2.7,not_published | |
| 1764 | +Saint-Léonard-de-Portneuf,34115,Capitale-Nationale,plex,1.0,9,3.1,not_published | |
| 1765 | +Frontenac,30025,Estrie,plex,1.0,9,3.1,not_published | |
| 1766 | +Bouchette,83050,Outaouais,plex,1.0,9,3.1,not_published | |
| 1767 | +Tring-Jonction,27060,Chaudière-Appalaches,plex,1.0,9,3.1,not_published | |
| 1768 | +Les Éboulements,16048,Capitale-Nationale,plex,1.0,9,2.7,not_published | |
| 1769 | +Lotbinière,33115,Chaudière-Appalaches,plex,1.0,8,2.7,not_published | |
| 1770 | +Mont-Carmel,14005,Bas-Saint-Laurent,plex,1.0,8,2.4,not_published | |
| 1771 | +Sainte-Hélène-de-Kamouraska,14025,Bas-Saint-Laurent,plex,1.0,8,2.7,not_published | |
| 1772 | +Bury,41070,Estrie,plex,1.0,8,2.4,not_published | |
| 1773 | +Saint-Lucien,49030,Centre-du-Québec,plex,1.0,8,2.7,not_published | |
| 1774 | +Saint-François-de-la-Rivière-du-Sud,18060,Chaudière-Appalaches,plex,1.0,8,2.7,not_published | |
| 1775 | +Saint-Flavien,33052,Chaudière-Appalaches,plex,1.0,8,2.7,not_published | |
| 1776 | +Gore,76025,Laurentides,plex,1.0,8,2.7,not_published | |
| 1777 | +Saint-Lazare-de-Bellechasse,19050,Chaudière-Appalaches,plex,1.0,8,2.7,not_published | |
| 1778 | +Lac-Drolet,30080,Estrie,plex,1.0,8,2.7,not_published | |
| 1779 | +Stratford,30110,Estrie,plex,1.0,8,2.7,not_published | |
| 1780 | +Sainte-Angèle-de-Monnoir,55030,Montérégie,plex,1.0,8,2.7,not_published | |
| 1781 | +Notre-Dame-de-Pontmain,79010,Laurentides,plex,1.0,8,2.4,not_published | |
| 1782 | +Saint-Séverin,35020,Mauricie,plex,1.0,8,2.7,not_published | |
| 1783 | +Howick,69025,Montérégie,plex,1.0,8,2.7,not_published | |
| 1784 | +La Patrie,41027,Estrie,plex,1.0,8,2.7,not_published | |
| 1785 | +Saint-François-de-Sales,91015,Saguenay–Lac-Saint-Jean,plex,1.0,8,2.7,not_published | |
| 1786 | +Sainte-Anne-de-la-Rochelle,42050,Estrie,plex,1.0,8,2.7,not_published | |
| 1787 | +Saint-Simon,54090,Montérégie,plex,1.0,8,2.7,not_published | |
| 1788 | +Kazabazua,83015,Outaouais,plex,1.0,8,2.7,not_published | |
| 1789 | +Saint-Michel,68050,Montérégie,condo,1.0,8,2.7,not_published | |
| 1790 | +Sainte-Flavie,09085,Bas-Saint-Laurent,plex,1.0,8,2.4,not_published | |
| 1791 | +Disraeli,31020,Chaudière-Appalaches,plex,1.0,8,2.7,not_published | |
| 1792 | +Cap-Chat,04047,Gaspésie–Îles-de-la-Madeleine,plex,1.0,8,2.7,not_published | |
| 1793 | +Sainte-Hénédine,26040,Chaudière-Appalaches,plex,1.0,8,2.7,not_published | |
| 1794 | +Saint-Aimé-des-Lacs,15030,Capitale-Nationale,plex,1.0,8,2.7,not_published | |
| 1795 | +Notre-Dame-du-Bon-Conseil,49075,Centre-du-Québec,plex,1.0,8,2.4,not_published | |
| 1796 | +Sainte-Marie-Salomé,63005,Lanaudière,plex,1.0,8,2.7,not_published | |
| 1797 | +Sainte-Marguerite-du-Lac-Masson,77012,Laurentides,condo,1.0,8,2.1,not_published | |
| 1798 | +Lefebvre,49020,Centre-du-Québec,plex,1.0,8,2.7,not_published | |
| 1799 | +Saint-Paul-de-l'Île-aux-Noix,56035,Montérégie,plex,1.0,8,2.7,not_published | |
| 1800 | +Huberdeau,78065,Laurentides,plex,1.0,8,2.7,not_published | |
| 1801 | +Pointe-à-la-Croix,06030,Gaspésie–Îles-de-la-Madeleine,plex,1.0,8,2.7,not_published | |
| 1802 | +Saint-Valérien-de-Milton,54065,Montérégie,plex,1.0,8,2.7,not_published | |
| 1803 | +Saint-Maxime-du-Mont-Louis,04010,Gaspésie–Îles-de-la-Madeleine,plex,1.0,8,2.7,not_published | |
| 1804 | +Saint-Ludger-de-Milot,93080,Saguenay–Lac-Saint-Jean,plex,1.0,8,2.7,not_published | |
| 1805 | +Saint-Michel-des-Saints,62085,Lanaudière,plex,1.0,8,2.7,not_published | |
| 1806 | +Henryville,56042,Montérégie,plex,1.0,8,2.7,not_published | |
| 1807 | +Saint-Christophe-d'Arthabaska,39060,Centre-du-Québec,plex,1.0,8,2.7,not_published | |
| 1808 | +Saint-Joseph-des-Érables,27050,Chaudière-Appalaches,unifamilial,1.0,7,2.1,not_published | |
| 1809 | +Leclercville,33123,Chaudière-Appalaches,plex,1.0,7,2.4,not_published | |
| 1810 | +Piopolis,30020,Estrie,plex,1.0,7,2.4,not_published | |
| 1811 | +Saint-Ubalde,34090,Capitale-Nationale,plex,1.0,7,2.4,not_published | |
| 1812 | +Saint-Édouard-de-Fabre,85015,Abitibi-Témiscamingue,plex,1.0,7,2.4,not_published | |
| 1813 | +Bowman,80145,Outaouais,plex,1.0,7,2.4,not_published | |
| 1814 | +Chute-Saint-Philippe,79065,Laurentides,plex,1.0,7,2.4,not_published | |
| 1815 | +Sainte-Ursule,51040,Mauricie,plex,1.0,7,2.4,not_published | |
| 1816 | +Nantes,30045,Estrie,plex,1.0,7,2.4,not_published | |
| 1817 | +Saint-Côme,62065,Lanaudière,condo,1.0,7,2.4,not_published | |
| 1818 | +Saint-Fabien-de-Panet,18015,Chaudière-Appalaches,plex,1.0,7,2.4,not_published | |
| 1819 | +East Farnham,46085,Estrie,plex,1.0,7,2.1,not_published | |
| 1820 | +Cap-Santé,34030,Capitale-Nationale,condo,1.0,7,2.4,not_published | |
| 1821 | +Lac-des-Plages,80130,Outaouais,plex,1.0,7,2.4,not_published | |
| 1822 | +La Durantaye,19090,Chaudière-Appalaches,plex,1.0,7,2.4,not_published | |
| 1823 | +Stoneham-et-Tewkesbury,22035,Capitale-Nationale,condo,1.0,7,2.1,not_published | |
| 1824 | +Ham-Nord,39010,Centre-du-Québec,plex,1.0,7,2.4,not_published | |
| 1825 | +Valcourt,42060,Estrie,plex,1.0,7,2.4,not_published | |
| 1826 | +Saint-Mathieu-du-Parc,51070,Mauricie,plex,1.0,7,2.4,not_published | |
| 1827 | +Val-des-Bois,80140,Outaouais,plex,1.0,7,2.4,not_published | |
| 1828 | +Baie-Comeau,96020,Côte-Nord,condo,1.0,7,2.4,not_published | |
| 1829 | +Notre-Dame-de-la-Paix,80020,Outaouais,plex,1.0,7,2.4,not_published | |
| 1830 | +Lac-au-Saumon,07057,Bas-Saint-Laurent,plex,1.0,7,2.4,not_published | |
| 1831 | +Saint-Félix-de-Kingsey,49005,Centre-du-Québec,plex,1.0,7,2.4,not_published | |
| 1832 | +Notre-Dame-de-Montauban,35005,Mauricie,plex,1.0,7,2.4,not_published | |
| 1833 | +Val-Morin,78005,Laurentides,condo,1.0,7,2.4,not_published | |
| 1834 | +Laverlochère-Angliers,85052,Abitibi-Témiscamingue,plex,1.0,7,1.7,not_published | |
| 1835 | +Fossambault-sur-le-Lac,22010,Capitale-Nationale,plex,1.0,7,2.4,not_published | |
| 1836 | +Lorraine,73025,Laurentides,plex,1.0,7,2.4,not_published | |
| 1837 | +New Carlisle,05040,Gaspésie–Îles-de-la-Madeleine,plex,1.0,7,2.1,not_published | |
| 1838 | +Labrecque,93055,Saguenay–Lac-Saint-Jean,plex,1.0,7,2.4,not_published | |
| 1839 | +Sainte-Jeanne-d'Arc,92015,Saguenay–Lac-Saint-Jean,plex,1.0,7,2.4,not_published | |
| 1840 | +Saint-Malachie,19025,Chaudière-Appalaches,plex,1.0,7,2.4,not_published | |
| 1841 | +Saint-Modeste,12020,Bas-Saint-Laurent,plex,1.0,7,2.4,not_published | |
| 1842 | +Sainte-Germaine-Boulé,87030,Abitibi-Témiscamingue,plex,1.0,7,2.4,not_published | |
| 1843 | +Cayamant,83040,Outaouais,plex,1.0,7,2.4,not_published | |
| 1844 | +Saint-Agapit,33045,Chaudière-Appalaches,condo,1.0,7,2.4,not_published | |
| 1845 | +Champneuf,88005,Abitibi-Témiscamingue,unifamilial,1.0,7,2.4,not_published | |
| 1846 | +Thurso,80050,Outaouais,condo,1.0,7,2.4,not_published | |
| 1847 | +Baie-Johan-Beetz,98035,Côte-Nord,unifamilial,1.0,7,2.4,not_published | |
| 1848 | +North Hatley,45050,Estrie,plex,1.0,7,2.4,not_published | |
| 1849 | +Sainte-Félicité,08023,Bas-Saint-Laurent,plex,1.0,7,2.4,not_published | |
| 1850 | +Saint-Paul-de-Montminy,18030,Chaudière-Appalaches,plex,1.0,7,2.4,not_published | |
| 1851 | +Sainte-Angèle-de-Mérici,09035,Bas-Saint-Laurent,plex,1.0,7,2.4,not_published | |
| 1852 | +Grande-Vallée,03020,Gaspésie–Îles-de-la-Madeleine,plex,1.0,7,2.4,not_published | |
| 1853 | +Saint-Elzéar,26022,Chaudière-Appalaches,plex,1.0,7,2.4,not_published | |
| 1854 | +Saint-Irénée,15005,Capitale-Nationale,plex,1.0,7,2.4,not_published | |
| 1855 | +Saint-Bernard-de-Lacolle,68005,Montérégie,plex,1.0,6,2.1,not_published | |
| 1856 | +Pointe-des-Cascades,71055,Montérégie,condo,1.0,6,2.1,not_published | |
| 1857 | +Cleveland,42110,Estrie,plex,1.0,6,2.1,not_published | |
| 1858 | +Ayer's Cliff,45035,Estrie,condo,1.0,6,2.1,not_published | |
| 1859 | +Saint-Clet,71045,Montérégie,condo,1.0,6,2.1,not_published | |
| 1860 | +Saint-Hilaire-de-Dorset,29020,Chaudière-Appalaches,unifamilial,1.0,6,2.1,not_published | |
| 1861 | +Sainte-Marguerite,26035,Chaudière-Appalaches,plex,1.0,6,2.1,not_published | |
| 1862 | +Namur,80110,Outaouais,plex,1.0,6,1.7,not_published | |
| 1863 | +Saint-Roch-des-Aulnaies,17065,Chaudière-Appalaches,plex,1.0,6,2.1,not_published | |
| 1864 | +Lantier,78015,Laurentides,plex,1.0,6,2.1,not_published | |
| 1865 | +Petit-Saguenay,94205,Saguenay–Lac-Saint-Jean,plex,1.0,6,2.1,not_published | |
| 1866 | +Landrienne,88035,Abitibi-Témiscamingue,plex,1.0,6,2.1,not_published | |
| 1867 | +L'Île-Cadieux,71095,Montérégie,unifamilial,1.0,6,2.1,not_published | |
| 1868 | +Natashquan,98025,Côte-Nord,plex,1.0,6,2.1,not_published | |
| 1869 | +Hudson,71100,Montérégie,condo,1.0,6,1.7,not_published | |
| 1870 | +Saint-Gabriel-de-Rimouski,09025,Bas-Saint-Laurent,plex,1.0,6,2.1,not_published | |
| 1871 | +Val-Saint-Gilles,87105,Abitibi-Témiscamingue,unifamilial,1.0,6,2.1,not_published | |
| 1872 | +Hatley,45055,Estrie,plex,1.0,6,2.1,not_published | |
| 1873 | +Saint-Urbain-Premier,70005,Montérégie,plex,1.0,6,1.7,not_published | |
| 1874 | +Vaudreuil-sur-le-Lac,71090,Montérégie,plex,1.0,6,2.1,not_published | |
| 1875 | +Saint-Félix-d'Otis,94225,Saguenay–Lac-Saint-Jean,plex,1.0,6,2.1,not_published | |
| 1876 | +Saint-Majorique-de-Grantham,49095,Centre-du-Québec,plex,1.0,6,2.1,not_published | |
| 1877 | +Lac-du-Cerf,79015,Laurentides,plex,1.0,6,2.1,not_published | |
| 1878 | +Manseau,38028,Centre-du-Québec,plex,1.0,6,2.1,not_published | |
| 1879 | +Warden,47030,Estrie,plex,1.0,6,2.1,not_published | |
| 1880 | +Saint-Charles-Borromée,61035,Lanaudière,condo,1.0,6,2.1,not_published | |
| 1881 | +Beaumont,19105,Chaudière-Appalaches,condo,1.0,6,2.1,not_published | |
| 1882 | +Saint-Cyprien,28040,Chaudière-Appalaches,plex,1.0,6,2.1,not_published | |
| 1883 | +Saint-Valérien,10060,Bas-Saint-Laurent,plex,1.0,6,2.1,not_published | |
| 1884 | +Blanc-Sablon,98005,Côte-Nord,plex,1.0,6,2.1,not_published | |
| 1885 | +Parisville,38055,Centre-du-Québec,plex,1.0,6,2.1,not_published | |
| 1886 | +Donnacona,34025,Capitale-Nationale,condo,1.0,6,2.1,not_published | |
| 1887 | +Bonsecours,42040,Estrie,plex,1.0,6,2.1,not_published | |
| 1888 | +Lac-Supérieur,78095,Laurentides,condo,1.0,6,2.1,not_published | |
| 1889 | +Laforce,85070,Abitibi-Témiscamingue,unifamilial,1.0,6,2.1,not_published | |
| 1890 | +Normétal,87115,Abitibi-Témiscamingue,plex,1.0,6,2.1,not_published | |
| 1891 | +Lac-Chicobi,88904,Abitibi-Témiscamingue,unifamilial,2.0,6,1.0,not_published | |
| 1892 | +Sainte-Pétronille,20030,Capitale-Nationale,plex,1.0,6,2.1,not_published | |
| 1893 | +Hinchinbrooke,69045,Montérégie,plex,1.0,6,2.1,not_published | |
| 1894 | +Saint-René,29050,Chaudière-Appalaches,plex,1.0,6,2.1,not_published | |
| 1895 | +Saint-Cyprien-de-Napierville,68035,Montérégie,plex,1.0,6,2.1,not_published | |
| 1896 | +Hérouxville,35035,Mauricie,plex,1.0,6,2.1,not_published | |
| 1897 | +Bonne-Espérance,98010,Côte-Nord,unifamilial,1.0,6,2.1,not_published | |
| 1898 | +Arundel,78060,Laurentides,plex,1.0,6,2.1,not_published | |
| 1899 | +Notre-Dame-de-la-Salette,80087,Outaouais,plex,1.0,6,2.1,not_published | |
| 1900 | +Sainte-Brigitte-de-Laval,22045,Capitale-Nationale,condo,1.0,6,2.1,not_published | |
| 1901 | +Honfleur,19070,Chaudière-Appalaches,plex,1.0,6,2.1,not_published | |
| 1902 | +Sainte-Julienne,63060,Lanaudière,condo,1.0,6,2.1,not_published | |
| 1903 | +Maddington Falls,39165,Centre-du-Québec,plex,1.0,6,2.1,not_published | |
| 1904 | +Saint-Élie-de-Caxton,51075,Mauricie,plex,1.0,6,1.7,not_published | |
| 1905 | +Saint-Hugues,54100,Montérégie,plex,1.0,6,1.7,not_published | |
| 1906 | +Clarenceville,56010,Montérégie,plex,1.0,6,2.1,not_published | |
| 1907 | +Verchères,59025,Montérégie,condo,1.0,6,2.1,not_published | |
| 1908 | +Delson,67025,Montérégie,condo,1.0,6,2.1,not_published | |
| 1909 | +Rougemont,55037,Montérégie,condo,1.0,6,1.7,not_published | |
| 1910 | +La Doré,91050,Saguenay–Lac-Saint-Jean,plex,1.0,6,2.1,not_published | |
| 1911 | +Notre-Dame-de-la-Merci,62055,Lanaudière,plex,1.0,6,2.1,not_published | |
| 1912 | +Charette,51080,Mauricie,plex,1.0,6,2.1,not_published | |
| 1913 | +Ayer's Cliff,45035,Estrie,plex,1.0,6,2.1,not_published | |
| 1914 | +Saint-Armand,46017,Estrie,plex,1.0,6,1.7,not_published | |
| 1915 | +Sainte-Angèle-de-Prémont,51055,Mauricie,plex,1.0,5,1.7,not_published | |
| 1916 | +Saint-Alexis-de-Matapédia,06050,Gaspésie–Îles-de-la-Madeleine,plex,1.0,5,1.7,not_published | |
| 1917 | +Saint-Luc-de-Bellechasse,28060,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1918 | +Val-Brillant,07080,Bas-Saint-Laurent,plex,1.0,5,1.7,not_published | |
| 1919 | +Sainte-Perpétue,50050,Centre-du-Québec,plex,1.0,5,1.7,not_published | |
| 1920 | +L'Ascension-de-Patapédia,06060,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,5,1.7,not_published | |
| 1921 | +Lac-Delage,22030,Capitale-Nationale,plex,1.0,5,1.7,not_published | |
| 1922 | +Saint-Philippe-de-Néri,14060,Bas-Saint-Laurent,plex,1.0,5,1.7,not_published | |
| 1923 | +Estérel,77011,Laurentides,condo,1.0,5,1.7,not_published | |
| 1924 | +Mont-Saint-Michel,79110,Laurentides,plex,1.0,5,1.7,not_published | |
| 1925 | +Waltham,84070,Outaouais,plex,1.0,5,1.7,not_published | |
| 1926 | +Saint-Camille-de-Lellis,28070,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1927 | +Saint-Sixte,80070,Outaouais,plex,1.0,5,1.7,not_published | |
| 1928 | +Notre-Dame-des-Monts,15025,Capitale-Nationale,plex,1.0,5,1.7,not_published | |
| 1929 | +Ragueneau,96040,Côte-Nord,plex,1.0,5,1.7,not_published | |
| 1930 | +Saint-Césaire,55023,Montérégie,condo,1.0,5,1.4,not_published | |
| 1931 | +La Corne,88030,Abitibi-Témiscamingue,plex,1.0,5,1.7,not_published | |
| 1932 | +Saint-Romain,30100,Estrie,plex,1.0,5,1.7,not_published | |
| 1933 | +Sainte-Hedwidge,91030,Saguenay–Lac-Saint-Jean,plex,1.0,5,1.4,not_published | |
| 1934 | +Pohénégamook,13095,Bas-Saint-Laurent,condo,1.0,5,1.7,not_published | |
| 1935 | +La Pocatière,14082,Bas-Saint-Laurent,condo,1.0,5,1.7,not_published | |
| 1936 | +Saint-Zénon,62080,Lanaudière,plex,1.0,5,1.7,not_published | |
| 1937 | +Saint-Sébastien,30085,Estrie,plex,1.0,5,1.7,not_published | |
| 1938 | +Saint-Benjamin,28025,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1939 | +Les Lacs-du-Témiscamingue,85907,Abitibi-Témiscamingue,unifamilial,1.0,5,1.7,not_published | |
| 1940 | +Hemmingford,68015,Montérégie,condo,1.0,5,1.7,not_published | |
| 1941 | +Saint-Vallier,19117,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1942 | +Saint-Aubert,17055,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1943 | +Stornoway,30105,Estrie,plex,1.0,5,1.7,not_published | |
| 1944 | +Roberval,91025,Saguenay–Lac-Saint-Jean,condo,1.0,5,1.4,not_published | |
| 1945 | +Montcalm,78055,Laurentides,plex,1.0,5,1.7,not_published | |
| 1946 | +Sainte-Clotilde-de-Beauce,31060,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1947 | +Duhamel-Ouest,85030,Abitibi-Témiscamingue,plex,1.0,5,1.7,not_published | |
| 1948 | +Sainte-Eulalie,50005,Centre-du-Québec,plex,1.0,5,1.7,not_published | |
| 1949 | +Saint-Hubert-de-Rivière-du-Loup,12010,Bas-Saint-Laurent,plex,1.0,5,1.7,not_published | |
| 1950 | +Matapédia,06045,Gaspésie–Îles-de-la-Madeleine,plex,1.0,5,1.7,not_published | |
| 1951 | +Sainte-Rose-de-Watford,28030,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1952 | +Saint-Émile-de-Suffolk,80125,Outaouais,plex,1.0,5,1.7,not_published | |
| 1953 | +Racine,42032,Estrie,plex,1.0,5,1.7,not_published | |
| 1954 | +Inverness,32058,Centre-du-Québec,plex,1.0,5,1.7,not_published | |
| 1955 | +Saint-Claude,42100,Estrie,plex,1.0,5,1.7,not_published | |
| 1956 | +Sainte-Marguerite,26035,Chaudière-Appalaches,condo,1.0,5,1.7,not_published | |
| 1957 | +Les Bergeronnes,95018,Côte-Nord,plex,1.0,5,1.7,not_published | |
| 1958 | +Val-David,78010,Laurentides,condo,1.0,5,1.7,not_published | |
| 1959 | +Saint-Simon-les-Mines,29125,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1960 | +Saint-Janvier-de-Joly,33065,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1961 | +Saint-André-de-Kamouraska,14040,Bas-Saint-Laurent,plex,1.0,5,1.7,not_published | |
| 1962 | +Métis-sur-Mer,09048,Bas-Saint-Laurent,plex,1.0,5,1.7,not_published | |
| 1963 | +Saint-Wenceslas,50023,Centre-du-Québec,plex,1.0,5,1.7,not_published | |
| 1964 | +Sainte-Geneviève-de-Batiscan,37215,Mauricie,plex,1.0,5,1.7,not_published | |
| 1965 | +Brigham,46090,Estrie,plex,1.0,5,1.7,not_published | |
| 1966 | +Scott,26048,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1967 | +Dosquet,33040,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1968 | +Notre-Dame-du-Bon-Conseil,49080,Centre-du-Québec,plex,1.0,5,1.7,not_published | |
| 1969 | +Fassett,80005,Outaouais,plex,1.0,5,1.7,not_published | |
| 1970 | +Saint-Mathieu-de-Rioux,11050,Bas-Saint-Laurent,plex,1.0,5,1.7,not_published | |
| 1971 | +Sainte-Anne-du-Lac,79115,Laurentides,plex,1.0,5,1.7,not_published | |
| 1972 | +Grandes-Piles,35040,Mauricie,plex,1.0,5,1.7,not_published | |
| 1973 | +Saint-Cyprien,12005,Bas-Saint-Laurent,plex,1.0,5,1.4,not_published | |
| 1974 | +Saint-Jacques-le-Mineur,68040,Montérégie,plex,1.0,5,1.7,not_published | |
| 1975 | +Saint-Pierre-de-Broughton,31135,Chaudière-Appalaches,plex,1.0,5,1.7,not_published | |
| 1976 | +Belleterre,85065,Abitibi-Témiscamingue,plex,1.0,5,1.7,not_published | |
| 1977 | +Sainte-Irène,07040,Bas-Saint-Laurent,plex,1.0,5,1.7,not_published | |
| 1978 | +Saint-Augustin,98012,Côte-Nord,unifamilial,1.0,4,1.4,not_published | |
| 1979 | +Saint-Isidore-de-Clifton,41012,Estrie,plex,1.0,4,1.4,not_published | |
| 1980 | +Hemmingford,68015,Montérégie,plex,1.0,4,1.4,not_published | |
| 1981 | +La Macaza,79047,Laurentides,plex,1.0,4,1.4,not_published | |
| 1982 | +Richelieu,55057,Montérégie,condo,1.0,4,1.4,not_published | |
| 1983 | +Trécesson,88075,Abitibi-Témiscamingue,plex,1.0,4,1.4,not_published | |
| 1984 | +Shannon,22020,Capitale-Nationale,condo,1.0,4,1.4,not_published | |
| 1985 | +Escuminac,06025,Gaspésie–Îles-de-la-Madeleine,plex,1.0,4,1.4,not_published | |
| 1986 | +Saint-Narcisse-de-Rimouski,10015,Bas-Saint-Laurent,plex,1.0,4,1.4,not_published | |
| 1987 | +Saint-Patrice-de-Beaurivage,33025,Chaudière-Appalaches,plex,1.0,4,1.4,not_published | |
| 1988 | +Notre-Dame-de-Stanbridge,46100,Estrie,plex,1.0,4,1.4,not_published | |
| 1989 | +Mont-Albert,04902,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,4,1.4,not_published | |
| 1990 | +Trois-Rives,35055,Mauricie,plex,1.0,4,1.4,not_published | |
| 1991 | +Saint-Louis-de-Gonzague,70035,Montérégie,condo,1.0,4,1.4,not_published | |
| 1992 | +Saint-Mathieu-du-Parc,51070,Mauricie,condo,1.0,4,1.4,not_published | |
| 1993 | +Péribonka,92010,Saguenay–Lac-Saint-Jean,plex,1.0,4,1.4,not_published | |
| 1994 | +Notre-Dame-de-Bonsecours,80015,Outaouais,plex,1.0,4,1.4,not_published | |
| 1995 | +L'Épiphanie,60037,Lanaudière,condo,1.0,4,1.4,not_published | |
| 1996 | +Saint-Michel-du-Squatec,13065,Bas-Saint-Laurent,plex,1.0,4,1.4,not_published | |
| 1997 | +Roxton,48015,Montérégie,plex,1.0,4,1.4,not_published | |
| 1998 | +Saint-Valère,39135,Centre-du-Québec,plex,1.0,4,1.4,not_published | |
| 1999 | +Saint-Adrien,40010,Estrie,plex,1.0,4,1.4,not_published | |
| 2000 | +Saint-Sébastien,56050,Montérégie,plex,1.0,4,1.4,not_published | |
| 2001 | +Saint-Odilon-de-Cranbourne,27035,Chaudière-Appalaches,plex,1.0,4,1.4,not_published | |
| 2002 | +Lac-Saint-Joseph,22015,Capitale-Nationale,plex,1.0,4,1.4,not_published | |
| 2003 | +Sainte-Agathe-de-Lotbinière,33017,Chaudière-Appalaches,plex,1.0,4,1.4,not_published | |
| 2004 | +Noyan,56015,Montérégie,plex,1.0,4,1.4,not_published | |
| 2005 | +Saint-Lambert-de-Lauzon,26070,Chaudière-Appalaches,condo,1.0,4,1.4,not_published | |
| 2006 | +Lac-Casault,07908,Bas-Saint-Laurent,unifamilial,1.0,4,1.0,not_published | |
| 2007 | +Sainte-Cécile-de-Whitton,30050,Estrie,plex,1.0,4,1.4,not_published | |
| 2008 | +Frampton,26005,Chaudière-Appalaches,plex,1.0,4,1.4,not_published | |
| 2009 | +Rivière-Bonaventure,05902,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,4,1.4,not_published | |
| 2010 | +L'Avenir,49025,Centre-du-Québec,plex,1.0,4,1.4,not_published | |
| 2011 | +Barnston-Ouest,44045,Estrie,plex,1.0,4,1.4,not_published | |
| 2012 | +Sainte-Marthe,71110,Montérégie,plex,1.0,4,1.4,not_published | |
| 2013 | +Saint-Vianney,07075,Bas-Saint-Laurent,plex,1.0,4,1.4,not_published | |
| 2014 | +Baie-des-Sables,08080,Bas-Saint-Laurent,plex,1.0,4,1.4,not_published | |
| 2015 | +Saint-François-d'Assise,06055,Gaspésie–Îles-de-la-Madeleine,plex,1.0,4,1.4,not_published | |
| 2016 | +Saint-David-de-Falardeau,94245,Saguenay–Lac-Saint-Jean,condo,1.0,4,1.4,not_published | |
| 2017 | +Bolton-Ouest,46065,Estrie,plex,1.0,4,1.4,not_published | |
| 2018 | +Notre-Dame-des-Neiges,11045,Bas-Saint-Laurent,plex,1.0,4,1.4,not_published | |
| 2019 | +Scotstown,41080,Estrie,plex,1.0,4,1.4,not_published | |
| 2020 | +Saint-Léon-de-Standon,19020,Chaudière-Appalaches,plex,1.0,4,1.4,not_published | |
| 2021 | +Packington,13015,Bas-Saint-Laurent,plex,1.0,4,1.4,not_published | |
| 2022 | +Tadoussac,95005,Côte-Nord,plex,1.0,4,1.4,not_published | |
| 2023 | +Coaticook,44037,Estrie,condo,1.0,4,1.4,not_published | |
| 2024 | +Nédélec,85100,Abitibi-Témiscamingue,plex,1.0,4,1.0,not_published | |
| 2025 | +Blue Sea,83045,Outaouais,plex,1.0,4,1.4,not_published | |
| 2026 | +La Visitation-de-l'Île-Dupas,52050,Lanaudière,plex,1.0,4,1.4,not_published | |
| 2027 | +Newport,41037,Estrie,plex,1.0,4,1.4,not_published | |
| 2028 | +Saint-Ferdinand,32013,Centre-du-Québec,condo,1.0,4,1.4,not_published | |
| 2029 | +Courcelles–Saint-Évariste,29027,Chaudière-Appalaches,plex,1.0,4,1.4,not_published | |
| 2030 | +Bolton-Est,45095,Estrie,plex,1.0,4,1.4,not_published | |
| 2031 | +Saint-Étienne-de-Bolton,45100,Estrie,plex,1.0,4,1.4,not_published | |
| 2032 | +Neuville,34007,Capitale-Nationale,condo,1.0,4,1.4,not_published | |
| 2033 | +Saint-Sylvère,38005,Centre-du-Québec,plex,1.0,4,1.4,not_published | |
| 2034 | +Sainte-Apolline-de-Patton,18025,Chaudière-Appalaches,plex,1.0,4,1.0,not_published | |
| 2035 | +Kirkland,66102,Montréal,plex,1.0,4,1.4,not_published | |
| 2036 | +Saint-Albert,39085,Centre-du-Québec,plex,1.0,4,1.4,not_published | |
| 2037 | +Saint-Théophile,29005,Chaudière-Appalaches,plex,1.0,4,1.4,not_published | |
| 2038 | +Sainte-Aurélie,28015,Chaudière-Appalaches,plex,1.0,4,1.4,not_published | |
| 2039 | +Saint-Georges-de-Windsor,40032,Estrie,plex,1.0,4,1.4,not_published | |
| 2040 | +Alleyn-et-Cawood,84050,Outaouais,plex,1.0,4,1.4,not_published | |
| 2041 | +Sainte-Louise,17060,Chaudière-Appalaches,plex,1.0,4,1.4,not_published | |
| 2042 | +Sainte-Claire,19055,Chaudière-Appalaches,condo,1.0,4,1.4,not_published | |
| 2043 | +Duparquet,87005,Abitibi-Témiscamingue,plex,1.0,4,1.4,not_published | |
| 2044 | +Saint-Nazaire-d'Acton,48050,Montérégie,plex,1.0,4,1.4,not_published | |
| 2045 | +Waterloo,47025,Estrie,condo,1.0,4,1.4,not_published | |
| 2046 | +Egan-Sud,83075,Outaouais,plex,1.0,4,1.4,not_published | |
| 2047 | +Dixville,44023,Estrie,plex,1.0,4,1.4,not_published | |
| 2048 | +Saint-Ludger,30072,Estrie,plex,1.0,4,1.4,not_published | |
| 2049 | +Hatley,45043,Estrie,plex,1.0,4,1.4,not_published | |
| 2050 | +Saint-Elzéar,26022,Chaudière-Appalaches,condo,1.0,3,1.0,not_published | |
| 2051 | +Westbury,41065,Estrie,plex,1.0,3,1.0,not_published | |
| 2052 | +Saint-Clément,11005,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2053 | +L'Île-du-Grand-Calumet,84035,Outaouais,plex,1.0,3,1.0,not_published | |
| 2054 | +Sainte-Hélène-de-Bagot,54095,Montérégie,condo,1.0,3,1.0,not_published | |
| 2055 | +Saint-Jacques-de-Leeds,31140,Chaudière-Appalaches,plex,1.0,3,1.0,not_published | |
| 2056 | +Sainte-Sophie-de-Lévrard,38040,Centre-du-Québec,plex,1.0,3,1.0,not_published | |
| 2057 | +Thorne,84045,Outaouais,plex,1.0,3,1.0,not_published | |
| 2058 | +Saint-Nérée-de-Bellechasse,19045,Chaudière-Appalaches,plex,1.0,3,1.0,not_published | |
| 2059 | +Melbourne,42075,Estrie,plex,1.0,3,1.0,not_published | |
| 2060 | +Boileau,80115,Outaouais,plex,1.0,3,1.0,not_published | |
| 2061 | +Saint-Donat,09030,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2062 | +Notre-Dame-de-Lourdes,32080,Centre-du-Québec,plex,1.0,3,1.0,not_published | |
| 2063 | +Sainte-Rita,11015,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2064 | +Saint-Marc-du-Lac-Long,13020,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2065 | +Matane,08053,Bas-Saint-Laurent,condo,1.0,3,1.0,not_published | |
| 2066 | +Brome,46070,Estrie,plex,1.0,3,1.0,not_published | |
| 2067 | +Saint-Bonaventure,49125,Centre-du-Québec,plex,1.0,3,1.0,not_published | |
| 2068 | +Lac-Saguay,79060,Laurentides,plex,1.0,3,1.0,not_published | |
| 2069 | +Sagard,15904,Capitale-Nationale,unifamilial,1.5,3,0.7,not_published | |
| 2070 | +Saint-Marc-de-Figuery,88040,Abitibi-Témiscamingue,plex,1.0,3,1.0,not_published | |
| 2071 | +L'Islet,17078,Chaudière-Appalaches,condo,1.0,3,1.0,not_published | |
| 2072 | +Lac-Sergent,34120,Capitale-Nationale,plex,1.0,3,1.0,not_published | |
| 2073 | +Lejeune,13050,Bas-Saint-Laurent,unifamilial,1.0,3,1.0,not_published | |
| 2074 | +Saint-Gabriel,52080,Lanaudière,condo,1.0,3,1.0,not_published | |
| 2075 | +Pointe-Lebel,96025,Côte-Nord,plex,1.0,3,1.0,not_published | |
| 2076 | +Saint-Pierre,61020,Lanaudière,plex,1.0,3,1.0,not_published | |
| 2077 | +Saint-François-Xavier-de-Viger,12025,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2078 | +Longue-Rive,95032,Côte-Nord,plex,1.0,3,1.0,not_published | |
| 2079 | +Saint-Just-de-Bretenières,18005,Chaudière-Appalaches,plex,1.0,3,1.0,not_published | |
| 2080 | +Gros-Mécatina,98014,Côte-Nord,unifamilial,1.0,3,1.0,not_published | |
| 2081 | +Château-Richer,21035,Capitale-Nationale,condo,1.0,3,1.0,not_published | |
| 2082 | +Sainte-Clotilde,68020,Montérégie,condo,1.0,3,1.0,not_published | |
| 2083 | +Sainte-Sabine,46105,Estrie,plex,1.0,3,1.0,not_published | |
| 2084 | +Saint-Augustin,92005,Saguenay–Lac-Saint-Jean,plex,1.0,3,1.0,not_published | |
| 2085 | +Montmagny,18050,Chaudière-Appalaches,condo,1.0,3,1.0,not_published | |
| 2086 | +Eeyou Istchee Baie-James,99060,Nord-du-Québec,plex,1.0,3,1.0,not_published | |
| 2087 | +Saint-Barnabé-Sud,54105,Montérégie,plex,1.0,3,1.0,not_published | |
| 2088 | +Windsor,42088,Estrie,condo,1.0,3,1.0,not_published | |
| 2089 | +Sainte-Florence,07010,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2090 | +Bois-Franc,83085,Outaouais,plex,1.5,3,0.7,not_published | |
| 2091 | +Sainte-Rose-du-Nord,94230,Saguenay–Lac-Saint-Jean,plex,1.0,3,1.0,not_published | |
| 2092 | +Saint-Eugène-de-Ladrière,10075,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2093 | +Grand-Saint-Esprit,50065,Centre-du-Québec,plex,1.0,3,1.0,not_published | |
| 2094 | +Stanstead-Est,44050,Estrie,plex,1.0,3,1.0,not_published | |
| 2095 | +Saint-Philémon,19005,Chaudière-Appalaches,condo,1.0,3,1.0,not_published | |
| 2096 | +Moffet,85075,Abitibi-Témiscamingue,plex,1.0,3,1.0,not_published | |
| 2097 | +Saint-Justin,51045,Mauricie,plex,1.0,3,1.0,not_published | |
| 2098 | +Saint-Pierre-les-Becquets,38065,Centre-du-Québec,plex,1.0,3,1.0,not_published | |
| 2099 | +Saint-Samuel,39130,Centre-du-Québec,plex,1.0,3,1.0,not_published | |
| 2100 | +Saint-Frédéric,27065,Chaudière-Appalaches,plex,1.0,3,1.0,not_published | |
| 2101 | +Schefferville,97040,Côte-Nord,unifamilial,1.0,3,1.0,not_published | |
| 2102 | +Notre-Dame-des-Pins,29120,Chaudière-Appalaches,condo,1.0,3,1.0,not_published | |
| 2103 | +Saint-Édouard,68045,Montérégie,plex,1.0,3,1.0,not_published | |
| 2104 | +Très-Saint-Sacrement,69030,Montérégie,plex,1.0,3,1.0,not_published | |
| 2105 | +Saint-Gédéon,93035,Saguenay–Lac-Saint-Jean,condo,1.0,3,1.0,not_published | |
| 2106 | +Montcerf-Lytton,83088,Outaouais,plex,1.0,3,1.0,not_published | |
| 2107 | +Saint-Jean-de-l'Île-d'Orléans,20015,Capitale-Nationale,plex,1.0,3,1.0,not_published | |
| 2108 | +Saint-Luc-de-Vincennes,37225,Mauricie,plex,1.0,3,1.0,not_published | |
| 2109 | +Saint-Léon-le-Grand,07030,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2110 | +Saint-Prosper-de-Champlain,37250,Mauricie,plex,1.0,3,1.0,not_published | |
| 2111 | +Saint-Robert-Bellarmin,30070,Estrie,plex,1.0,3,1.0,not_published | |
| 2112 | +Rivière-Ouelle,14065,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2113 | +Godbout,96010,Côte-Nord,plex,1.0,3,1.0,not_published | |
| 2114 | +Fortierville,38047,Centre-du-Québec,plex,1.0,3,1.0,not_published | |
| 2115 | +Dupuy,87085,Abitibi-Témiscamingue,plex,1.0,3,1.0,not_published | |
| 2116 | +Sainte-Christine-d'Auvergne,34105,Capitale-Nationale,plex,1.0,3,1.0,not_published | |
| 2117 | +Saint-Moïse,07095,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2118 | +East Hereford,44010,Estrie,plex,1.0,3,1.0,not_published | |
| 2119 | +Plessisville,32043,Centre-du-Québec,condo,1.0,3,1.0,not_published | |
| 2120 | +Saint-Augustin-de-Woburn,30005,Estrie,plex,1.0,3,1.0,not_published | |
| 2121 | +Dundee,69075,Montérégie,plex,1.0,3,1.0,not_published | |
| 2122 | +Saint-Ignace-de-Stanbridge,46095,Estrie,plex,1.0,3,1.0,not_published | |
| 2123 | +Rivière-Héva,89010,Abitibi-Témiscamingue,plex,1.0,3,1.0,not_published | |
| 2124 | +Saint-Eugène,49105,Centre-du-Québec,plex,1.0,3,1.0,not_published | |
| 2125 | +Saint-Norbert-d'Arthabaska,39043,Centre-du-Québec,plex,1.0,3,1.0,not_published | |
| 2126 | +Senneterre,89045,Abitibi-Témiscamingue,plex,1.0,3,1.0,not_published | |
| 2127 | +Saint-Eugène-de-Guigues,85085,Abitibi-Témiscamingue,plex,1.0,3,1.0,not_published | |
| 2128 | +Lac-Simon,80095,Outaouais,condo,1.0,3,1.0,not_published | |
| 2129 | +Ivry-sur-le-Lac,78042,Laurentides,plex,1.0,3,1.0,not_published | |
| 2130 | +Litchfield,84040,Outaouais,plex,1.0,3,1.0,not_published | |
| 2131 | +Sainte-Thérèse-de-Gaspé,02010,Gaspésie–Îles-de-la-Madeleine,plex,1.0,3,1.0,not_published | |
| 2132 | +Saint-Pierre-de-la-Rivière-du-Sud,18055,Chaudière-Appalaches,plex,1.0,3,1.0,not_published | |
| 2133 | +Esprit-Saint,10005,Bas-Saint-Laurent,plex,1.0,3,1.0,not_published | |
| 2134 | +Rivière-Ojima,87904,Abitibi-Témiscamingue,unifamilial,1.0,3,1.0,not_published | |
| 2135 | +Bryson,84025,Outaouais,plex,1.0,3,1.0,not_published | |
| 2136 | +Aumond,83090,Outaouais,plex,1.0,3,1.0,not_published | |
| 2137 | +Saint-Épiphane,12030,Bas-Saint-Laurent,plex,1.5,3,0.7,not_published | |
| 2138 | +Sainte-Lucie-de-Beauregard,18020,Chaudière-Appalaches,plex,1.0,2,0.7,not_published | |
| 2139 | +Saint-Zéphirin-de-Courval,50090,Centre-du-Québec,plex,1.0,2,0.7,not_published | |
| 2140 | +Poularies,87035,Abitibi-Témiscamingue,plex,1.0,2,0.7,not_published | |
| 2141 | +Saint-André-du-Lac-Saint-Jean,91010,Saguenay–Lac-Saint-Jean,plex,1.0,2,0.7,not_published | |
| 2142 | +Rapides-des-Joachims,84100,Outaouais,plex,1.0,2,0.7,not_published | |
| 2143 | +Saint-Tharcisius,07070,Bas-Saint-Laurent,plex,1.0,2,0.7,not_published | |
| 2144 | +Larouche,94265,Saguenay–Lac-Saint-Jean,condo,1.0,2,0.7,not_published | |
| 2145 | +Elgin,69050,Montérégie,plex,1.0,2,0.7,not_published | |
| 2146 | +Saint-Didace,52090,Lanaudière,plex,1.0,2,0.7,not_published | |
| 2147 | +Sainte-Paule,08040,Bas-Saint-Laurent,plex,1.0,2,0.7,not_published | |
| 2148 | +Low,83010,Outaouais,plex,1.0,2,0.7,not_published | |
| 2149 | +Les Méchins,08005,Bas-Saint-Laurent,plex,1.0,2,0.7,not_published | |
| 2150 | +Sainte-Marie-de-Blandford,38015,Centre-du-Québec,plex,1.0,2,0.7,not_published | |
| 2151 | +Longue-Pointe-de-Mingan,98045,Côte-Nord,plex,1.0,2,0.7,not_published | |
| 2152 | +Saint-Michel-des-Saints,62085,Lanaudière,condo,1.0,2,0.7,not_published | |
| 2153 | +Saint-Camille,40025,Estrie,plex,1.0,2,0.7,not_published | |
| 2154 | +Saint-Antoine-de-l'Isle-aux-Grues,18070,Chaudière-Appalaches,plex,1.0,2,0.7,not_published | |
| 2155 | +Saint-Roch-de-Mékinac,35045,Mauricie,plex,1.0,2,0.7,not_published | |
| 2156 | +Notre-Dame-des-Bois,30010,Estrie,plex,1.0,2,0.7,not_published | |
| 2157 | +Shigawake,05010,Gaspésie–Îles-de-la-Madeleine,plex,1.0,2,0.7,not_published | |
| 2158 | +Mulgrave-et-Derry,80085,Outaouais,plex,1.0,2,0.7,not_published | |
| 2159 | +Saint-Mathias-sur-Richelieu,55065,Montérégie,condo,1.0,2,0.7,not_published | |
| 2160 | +Sainte-Sophie-d'Halifax,32023,Centre-du-Québec,plex,1.0,2,0.7,not_published | |
| 2161 | +Saint-Fortunat,31030,Chaudière-Appalaches,plex,1.0,2,0.7,not_published | |
| 2162 | +Lochaber,80055,Outaouais,plex,1.0,2,0.7,not_published | |
| 2163 | +Franquelin,96015,Côte-Nord,plex,1.0,2,0.7,not_published | |
| 2164 | +Latulipe-et-Gaboury,85060,Abitibi-Témiscamingue,plex,1.0,2,0.7,not_published | |
| 2165 | +Saint-Joachim-de-Shefford,47040,Estrie,plex,1.0,2,0.7,not_published | |
| 2166 | +Baie-D'Urfé,66112,Montréal,plex,1.0,2,0.7,not_published | |
| 2167 | +TNO aquatique de la MRC géographique de Montréal,66990,Montréal,unifamilial,1.0,2,0.7,not_published | |
| 2168 | +Oka,72032,Laurentides,condo,1.0,2,0.7,not_published | |
| 2169 | +Pointe-Fortune,71140,Montérégie,plex,1.0,2,0.7,not_published | |
| 2170 | +Saint-Thomas,61027,Lanaudière,condo,1.0,2,0.7,not_published | |
| 2171 | +Stanbridge Station,46030,Estrie,plex,1.0,2,0.7,not_published | |
| 2172 | +Scott,26048,Chaudière-Appalaches,condo,1.0,2,0.7,not_published | |
| 2173 | +Saint-Zénon-du-Lac-Humqui,07035,Bas-Saint-Laurent,plex,1.0,2,0.7,not_published | |
| 2174 | +Lac-Huron,10902,Bas-Saint-Laurent,unifamilial,1.0,2,0.7,not_published | |
| 2175 | +Sainte-Anne-de-Beaupré,21030,Capitale-Nationale,condo,1.0,2,0.7,not_published | |
| 2176 | +Témiscouata-sur-le-Lac,13073,Bas-Saint-Laurent,condo,1.0,2,0.7,not_published | |
| 2177 | +Villeroy,32085,Centre-du-Québec,plex,1.0,2,0.7,not_published | |
| 2178 | +L'Île-d'Anticosti,98020,Côte-Nord,plex,1.0,2,0.7,not_published | |
| 2179 | +Crabtree,61013,Lanaudière,condo,1.0,2,0.7,not_published | |
| 2180 | +Kiamika,79025,Laurentides,plex,1.0,2,0.7,not_published | |
| 2181 | +Pike River,46025,Estrie,plex,1.0,2,0.7,not_published | |
| 2182 | +Saint-François-de-l'Île-d'Orléans,20005,Capitale-Nationale,plex,1.0,2,0.7,not_published | |
| 2183 | +Sainte-Victoire-de-Sorel,53025,Montérégie,condo,1.0,2,0.7,not_published | |
| 2184 | +Colombier,95050,Côte-Nord,plex,1.0,2,0.7,not_published | |
| 2185 | +Lamarche,93060,Saguenay–Lac-Saint-Jean,plex,1.0,2,0.7,not_published | |
| 2186 | +Saint-Thomas-Didyme,92045,Saguenay–Lac-Saint-Jean,plex,1.0,2,0.7,not_published | |
| 2187 | +Sainte-Madeleine-de-la-Rivière-Madeleine,04005,Gaspésie–Îles-de-la-Madeleine,plex,1.0,2,0.7,not_published | |
| 2188 | +Huntingdon,69055,Montérégie,condo,1.0,2,0.7,not_published | |
| 2189 | +Notre-Dame-des-Sept-Douleurs,12045,Bas-Saint-Laurent,plex,1.0,2,0.7,not_published | |
| 2190 | +Chartierville,41020,Estrie,plex,1.0,2,0.7,not_published | |
| 2191 | +Audet,30055,Estrie,plex,1.0,2,0.7,not_published | |
| 2192 | +Biencourt,13055,Bas-Saint-Laurent,plex,1.0,2,0.7,not_published | |
| 2193 | +Harrington,76065,Laurentides,plex,1.0,2,0.7,not_published | |
| 2194 | +Saint-Charles-de-Bourget,94260,Saguenay–Lac-Saint-Jean,plex,1.0,2,0.7,not_published | |
| 2195 | +Notre-Dame-Auxiliatrice-de-Buckland,19010,Chaudière-Appalaches,plex,1.0,2,0.7,not_published | |
| 2196 | +Wentworth,76035,Laurentides,plex,1.0,2,0.7,not_published | |
| 2197 | +Rivière-à-Pierre,34135,Capitale-Nationale,plex,1.0,2,0.7,not_published | |
| 2198 | +Saint-Barnabé,51025,Mauricie,plex,1.0,2,0.7,not_published | |
| 2199 | +Saint-Edmond-de-Grantham,49100,Centre-du-Québec,plex,1.0,2,0.7,not_published | |
| 2200 | +Saint-Joseph-de-Lepage,09070,Bas-Saint-Laurent,plex,1.0,2,0.7,not_published | |
| 2201 | +Sainte-Justine-de-Newton,71115,Montérégie,plex,1.0,2,0.7,not_published | |
| 2202 | +Kingsbury,42070,Estrie,plex,1.0,2,0.7,not_published | |
| 2203 | +Sainte-Brigitte-des-Saults,49085,Centre-du-Québec,plex,1.0,2,0.7,not_published | |
| 2204 | +Saint-Stanislas-de-Kostka,70040,Montérégie,condo,1.0,2,0.7,not_published | |
| 2205 | +Saint-Pierre-Baptiste,32050,Centre-du-Québec,plex,1.0,2,0.7,not_published | |
| 2206 | +Chichester,84090,Outaouais,plex,1.0,2,0.7,not_published | |
| 2207 | +Saint-Bernard,26055,Chaudière-Appalaches,condo,1.0,2,0.7,not_published | |
| 2208 | +Senneville,66127,Montréal,plex,1.0,2,0.7,not_published | |
| 2209 | +Fugèreville,85055,Abitibi-Témiscamingue,plex,1.0,2,0.7,not_published | |
| 2210 | +Saint-Norbert,52070,Lanaudière,plex,1.0,2,0.7,not_published | |
| 2211 | +Havelock,69005,Montérégie,plex,1.0,2,0.7,not_published | |
| 2212 | +Saint-René-de-Matane,08035,Bas-Saint-Laurent,plex,1.0,2,0.7,not_published | |
| 2213 | +Saint-Éloi,11035,Bas-Saint-Laurent,plex,1.0,2,0.7,not_published | |
| 2214 | +Saints-Anges,26010,Chaudière-Appalaches,plex,1.0,2,0.7,not_published | |
| 2215 | +Saint-Elphège,50095,Centre-du-Québec,plex,1.0,2,0.7,not_published | |
| 2216 | +Saint-Malachie,19025,Chaudière-Appalaches,condo,1.0,2,0.7,not_published | |
| 2217 | +Lac-Masketsi,35902,Mauricie,unifamilial,1.0,2,0.7,not_published | |
| 2218 | +Saint-Elzéar,05050,Gaspésie–Îles-de-la-Madeleine,plex,1.0,2,0.7,not_published | |
| 2219 | +Sainte-Séraphine,39105,Centre-du-Québec,plex,1.0,2,0.7,not_published | |
| 2220 | +Rémigny,85105,Abitibi-Témiscamingue,plex,1.0,2,0.7,not_published | |
| 2221 | +Sainte-Gertrude-Manneville,88085,Abitibi-Témiscamingue,plex,1.0,2,0.7,not_published | |
| 2222 | +Berry,88070,Abitibi-Témiscamingue,plex,1.0,1,0.3,not_published | |
| 2223 | +Saint-Adolphe-d'Howard,77065,Laurentides,condo,1.0,1,0.3,not_published | |
| 2224 | +Saint-Célestin,50035,Centre-du-Québec,plex,1.0,1,0.3,not_published | |
| 2225 | +Sainte-Edwidge-de-Clifton,44055,Estrie,plex,1.0,1,0.3,not_published | |
| 2226 | +Mayo,80065,Outaouais,plex,1.0,1,0.3,not_published | |
| 2227 | +Saint-Venant-de-Paquette,44005,Estrie,plex,1.0,1,0.3,not_published | |
| 2228 | +Saint-Guillaume-Nord,62912,Lanaudière,unifamilial,1.0,1,0.3,not_published | |
| 2229 | +Saint-Octave-de-Métis,09055,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2230 | +Routhierville,07902,Bas-Saint-Laurent,unifamilial,1.0,1,0.3,not_published | |
| 2231 | +Sainte-Christine,48020,Montérégie,plex,1.0,1,0.3,not_published | |
| 2232 | +Abercorn,46005,Estrie,plex,1.0,1,0.3,not_published | |
| 2233 | +L'Ascension,79050,Laurentides,plex,1.0,1,0.3,not_published | |
| 2234 | +Pointe-aux-Outardes,96030,Côte-Nord,plex,1.0,1,0.3,not_published | |
| 2235 | +Saint-Herménégilde,44015,Estrie,plex,1.0,1,0.3,not_published | |
| 2236 | +Carleton-sur-Mer,06013,Gaspésie–Îles-de-la-Madeleine,condo,1.0,1,0.3,not_published | |
| 2237 | +Saint-Dominique-du-Rosaire,88065,Abitibi-Témiscamingue,plex,1.0,1,0.3,not_published | |
| 2238 | +Aston-Jonction,50013,Centre-du-Québec,plex,1.0,1,0.3,not_published | |
| 2239 | +Berthierville,52035,Lanaudière,condo,1.0,1,0.3,not_published | |
| 2240 | +Saint-Godefroi,05015,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,0.3,not_published | |
| 2241 | +Sainte-Élizabeth-de-Warwick,39090,Centre-du-Québec,plex,1.0,1,0.3,not_published | |
| 2242 | +Portneuf-sur-Mer,95040,Côte-Nord,plex,1.0,1,0.3,not_published | |
| 2243 | +Grosses-Roches,08015,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2244 | +Sainte-Monique,50057,Centre-du-Québec,plex,1.0,1,0.3,not_published | |
| 2245 | +Sainte-Élisabeth-de-Proulx,92903,Saguenay–Lac-Saint-Jean,unifamilial,1.0,1,0.3,not_published | |
| 2246 | +Sainte-Félicité,17025,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2247 | +Saint-Martin,29045,Chaudière-Appalaches,condo,1.0,1,0.3,not_published | |
| 2248 | +Saint-Cléophas-de-Brandon,52075,Lanaudière,plex,1.0,1,0.3,not_published | |
| 2249 | +Stukely-Sud,45105,Estrie,plex,1.0,1,0.3,not_published | |
| 2250 | +Saint-Pie,54008,Montérégie,condo,1.0,1,0.3,not_published | |
| 2251 | +Saint-Célestin,50030,Centre-du-Québec,condo,1.0,1,0.3,not_published | |
| 2252 | +Clarendon,84015,Outaouais,plex,1.0,1,0.3,not_published | |
| 2253 | +Linton,34904,Capitale-Nationale,unifamilial,1.0,1,0.3,not_published | |
| 2254 | +Amos,88057,Abitibi-Témiscamingue,condo,1.0,1,0.3,not_published | |
| 2255 | +Léry,67055,Montérégie,condo,1.0,1,0.3,not_published | |
| 2256 | +Sainte-Thérèse-de-la-Gatineau,83055,Outaouais,plex,1.0,1,0.3,not_published | |
| 2257 | +Saint-Omer,17005,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2258 | +Sainte-Thècle,35050,Mauricie,condo,1.0,1,0.3,not_published | |
| 2259 | +Notre-Dame-de-Lorette,92060,Saguenay–Lac-Saint-Jean,plex,1.0,1,0.3,not_published | |
| 2260 | +Estérel,77011,Laurentides,plex,1.0,1,0.3,not_published | |
| 2261 | +TNO aquatique de la MRC de Pontiac,84990,Outaouais,plex,1.0,1,0.3,not_published | |
| 2262 | +Marston,30035,Estrie,plex,1.0,1,0.3,not_published | |
| 2263 | +Milan,30040,Estrie,plex,1.0,1,0.3,not_published | |
| 2264 | +Preissac,88090,Abitibi-Témiscamingue,plex,1.0,1,0.3,not_published | |
| 2265 | +Saint-Mathieu-d'Harricana,88050,Abitibi-Témiscamingue,plex,1.0,1,0.3,not_published | |
| 2266 | +Clermont,87110,Abitibi-Témiscamingue,plex,1.0,1,0.3,not_published | |
| 2267 | +Calixa-Lavallée,59030,Montérégie,plex,1.0,1,0.3,not_published | |
| 2268 | +Saint-Adalbert,17015,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2269 | +Irlande,31040,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2270 | +Auclair,13045,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2271 | +Saint-Narcisse-de-Beaurivage,33030,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2272 | +Saint-Julien,31035,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2273 | +Sainte-Françoise,38035,Centre-du-Québec,plex,1.0,1,0.3,not_published | |
| 2274 | +Ogden,45020,Estrie,plex,1.0,1,0.3,not_published | |
| 2275 | +Grosse-Île,01042,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,0.3,not_published | |
| 2276 | +Lac-Chicobi,88904,Abitibi-Témiscamingue,plex,1.0,1,0.3,not_published | |
| 2277 | +Nominingue,79030,Laurentides,condo,1.0,1,0.3,not_published | |
| 2278 | +Kipawa,85010,Abitibi-Témiscamingue,plex,1.0,1,0.3,not_published | |
| 2279 | +Lac-Walker,97904,Côte-Nord,plex,1.0,1,0.3,not_published | |
| 2280 | +Kinnear's Mills,31105,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2281 | +Saint-Rosaire,39145,Centre-du-Québec,plex,1.0,1,0.3,not_published | |
| 2282 | +Saint-André-de-Restigouche,06040,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,0.3,not_published | |
| 2283 | +Grand-Métis,09060,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2284 | +Saint-Eusèbe,13030,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2285 | +Rivière-aux-Outardes,96902,Côte-Nord,plex,1.0,1,0.3,not_published | |
| 2286 | +Saint-Séverin,27070,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2287 | +Laniel,85905,Abitibi-Témiscamingue,plex,1.0,1,0.3,not_published | |
| 2288 | +Cloridorme,03010,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,0.3,not_published | |
| 2289 | +Maricourt,42065,Estrie,plex,1.0,1,0.3,not_published | |
| 2290 | +Denholm,83005,Outaouais,plex,1.0,1,0.3,not_published | |
| 2291 | +Saint-Léon-le-Grand,51035,Mauricie,plex,1.0,1,0.3,not_published | |
| 2292 | +Saint-Paul-de-la-Croix,12035,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2293 | +TNO aquatique de la MRC du Rocher-Percé,02990,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,0.3,not_published | |
| 2294 | +Saint-Léonard-d'Aston,50042,Centre-du-Québec,condo,1.0,1,0.3,not_published | |
| 2295 | +Sainte-Élisabeth-de-Proulx,92903,Saguenay–Lac-Saint-Jean,plex,1.0,1,0.3,not_published | |
| 2296 | +Saint-Joseph-de-Kamouraska,14030,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2297 | +Beaulac-Garthby,31008,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2298 | +La Trinité-des-Monts,10010,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2299 | +Saint-Alexis,63023,Lanaudière,condo,1.0,1,0.3,not_published | |
| 2300 | +Saint-Anselme,19062,Chaudière-Appalaches,condo,1.0,1,0.3,not_published | |
| 2301 | +Saint-Stanislas,92070,Saguenay–Lac-Saint-Jean,plex,1.0,1,0.3,not_published | |
| 2302 | +Saint-Arsène,12065,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2303 | +Bedford,46040,Estrie,plex,1.0,1,0.3,not_published | |
| 2304 | +Saint-Jules,27055,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2305 | +Hampden,41075,Estrie,plex,1.0,1,0.3,not_published | |
| 2306 | +Saint-Alphonse,05065,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,0.3,not_published | |
| 2307 | +Baie-Sainte-Catherine,15065,Capitale-Nationale,plex,1.0,1,0.3,not_published | |
| 2308 | +Pointe-Calumet,72020,Laurentides,condo,1.0,1,0.3,not_published | |
| 2309 | +Saint-Marcel-de-Richelieu,54125,Montérégie,plex,1.0,1,0.3,not_published | |
| 2310 | +La Motte,88045,Abitibi-Témiscamingue,plex,1.0,1,0.3,not_published | |
| 2311 | +Saint-Laurent-de-l'Île-d'Orléans,20020,Capitale-Nationale,condo,1.0,1,0.3,not_published | |
| 2312 | +Ferland-et-Boilleau,94220,Saguenay–Lac-Saint-Jean,plex,1.0,1,0.3,not_published | |
| 2313 | +Portage-du-Fort,84020,Outaouais,plex,1.0,1,0.3,not_published | |
| 2314 | +Côte-Nord-du-Golfe-du-Saint-Laurent,98015,Côte-Nord,plex,1.0,1,0.3,not_published | |
| 2315 | +Martinville,44060,Estrie,plex,1.0,1,0.3,not_published | |
| 2316 | +Lac-Édouard,90027,Mauricie,plex,1.0,1,0.3,not_published | |
| 2317 | +Saint-Gilbert,34060,Capitale-Nationale,plex,1.0,1,0.3,not_published | |
| 2318 | +Ulverton,42078,Estrie,plex,1.0,1,0.3,not_published | |
| 2319 | +Saint-Gabriel-Lalemant,14075,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2320 | +Saint-Louis-de-Blandford,39170,Centre-du-Québec,plex,1.0,1,0.3,not_published | |
| 2321 | +Saint-Basile,34038,Capitale-Nationale,condo,1.0,1,0.3,not_published | |
| 2322 | +Sainte-Sabine,28065,Chaudière-Appalaches,plex,1.0,1,0.3,not_published | |
| 2323 | +Saint-Bernard-de-Michaudville,54115,Montérégie,plex,1.0,1,0.3,not_published | |
| 2324 | +Sainte-Marguerite-Marie,07005,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2325 | +Saint-Louis,54120,Montérégie,plex,1.0,1,0.3,not_published | |
| 2326 | +Sainte-Famille-de-l'Île-d'Orléans,20010,Capitale-Nationale,plex,1.0,1,0.3,not_published | |
| 2327 | +Sainte-Clotilde-de-Horton,39117,Centre-du-Québec,plex,1.0,1,0.3,not_published | |
| 2328 | +Mont-Élie,15902,Capitale-Nationale,plex,1.0,1,0.3,not_published | |
| 2329 | +Val-Racine,30015,Estrie,plex,1.0,1,0.3,not_published | |
| 2330 | +La Rédemption,09005,Bas-Saint-Laurent,plex,1.0,1,0.3,not_published | |
| 2331 | +Baie-Trinité,96005,Côte-Nord,plex,1.0,1,0.3,not_published | |
| 2332 | +Belle-Rivière,93908,Saguenay–Lac-Saint-Jean,unifamilial,1.0,1,0.3,not_published | |
| 2333 | +Guérin,85095,Abitibi-Témiscamingue,plex,1.0,1,0.3,not_published | |
| 2334 | +Saint-Télesphore,71015,Montérégie,plex,1.0,1,0.3,not_published | |
| 2335 | +Saint-Edmond-les-Plaines,92050,Saguenay–Lac-Saint-Jean,plex,1.0,1,0.3,not_published | |
| 2336 | +Saint-Eugène-d'Argentenay,92065,Saguenay–Lac-Saint-Jean,plex,1.0,1,0.3,not_published | |
added
outputs/tables/coverage_monthly.csv
+2336 −0
@@ -0,0 +1,2336 @@ | ||
| 1 | +municipality,geo_code,region,propertyType,median_monthly_tx,total_tx,pct_months_active,coverage_status | |
| 2 | +Montréal,66023,Montréal,condo,620.0,44539,100.0,published | |
| 3 | +Montréal,66023,Montréal,plex,346.0,24009,100.0,published | |
| 4 | +Québec,23027,Capitale-Nationale,unifamilial,303.0,23614,100.0,published | |
| 5 | +Montréal,66023,Montréal,unifamilial,280.0,19257,100.0,published | |
| 6 | +Gatineau,81017,Outaouais,unifamilial,240.0,17261,100.0,published | |
| 7 | +Laval,65005,Laval,unifamilial,234.0,16780,100.0,published | |
| 8 | +Québec,23027,Capitale-Nationale,condo,173.0,13156,100.0,published | |
| 9 | +Sherbrooke,43027,Estrie,unifamilial,118.0,9253,100.0,published | |
| 10 | +Lévis,25213,Chaudière-Appalaches,unifamilial,111.0,9231,100.0,published | |
| 11 | +Saguenay,94068,Saguenay–Lac-Saint-Jean,unifamilial,117.0,8761,100.0,published | |
| 12 | +Longueuil,58227,Montérégie,unifamilial,118.0,8747,100.0,published | |
| 13 | +Trois-Rivières,37067,Mauricie,unifamilial,101.0,7325,100.0,published | |
| 14 | +Terrebonne,64008,Lanaudière,unifamilial,93.0,7143,100.0,conditional | |
| 15 | +Québec,23027,Capitale-Nationale,plex,92.0,6577,100.0,published | |
| 16 | +Laval,65005,Laval,condo,91.0,6245,100.0,published | |
| 17 | +Saint-Jean-sur-Richelieu,56083,Montérégie,unifamilial,75.0,5807,100.0,conditional | |
| 18 | +Drummondville,49058,Centre-du-Québec,unifamilial,72.0,5104,100.0,published | |
| 19 | +Repentigny,60013,Lanaudière,unifamilial,68.0,5091,100.0,conditional | |
| 20 | +Longueuil,58227,Montérégie,condo,60.0,4628,100.0,published | |
| 21 | +Saint-Jérôme,75017,Laurentides,unifamilial,61.0,4503,100.0,conditional | |
| 22 | +Granby,47017,Estrie,unifamilial,52.0,4024,100.0,conditional | |
| 23 | +Blainville,73015,Laurentides,unifamilial,50.0,3733,100.0,conditional | |
| 24 | +Mirabel,74005,Laurentides,unifamilial,51.0,3705,100.0,conditional | |
| 25 | +Gatineau,81017,Outaouais,plex,52.0,3660,100.0,published | |
| 26 | +Brossard,58007,Montérégie,unifamilial,51.0,3475,100.0,conditional | |
| 27 | +Shawinigan,36033,Mauricie,unifamilial,45.0,3346,100.0,conditional | |
| 28 | +Mascouche,64015,Lanaudière,unifamilial,44.0,3214,100.0,conditional | |
| 29 | +Rimouski,10043,Bas-Saint-Laurent,unifamilial,41.0,3048,100.0,conditional | |
| 30 | +Victoriaville,39062,Centre-du-Québec,unifamilial,40.0,2996,100.0,conditional | |
| 31 | +Châteauguay,67050,Montérégie,unifamilial,41.0,2894,100.0,conditional | |
| 32 | +Salaberry-de-Valleyfield,70052,Montérégie,unifamilial,38.0,2882,100.0,conditional | |
| 33 | +Longueuil,58227,Montérégie,plex,40.0,2741,100.0,published | |
| 34 | +Saint-Hyacinthe,54048,Montérégie,unifamilial,34.0,2727,100.0,conditional | |
| 35 | +Saint-Eustache,72005,Laurentides,unifamilial,34.0,2576,100.0,conditional | |
| 36 | +Saguenay,94068,Saguenay–Lac-Saint-Jean,plex,37.0,2576,100.0,published | |
| 37 | +Boucherville,58033,Montérégie,unifamilial,35.0,2559,100.0,conditional | |
| 38 | +Trois-Rivières,37067,Mauricie,plex,34.0,2535,100.0,published | |
| 39 | +Gatineau,81017,Outaouais,condo,30.0,2519,100.0,published | |
| 40 | +Vaudreuil-Dorion,71083,Montérégie,unifamilial,33.0,2465,100.0,conditional | |
| 41 | +Laval,65005,Laval,plex,34.0,2434,100.0,published | |
| 42 | +Sherbrooke,43027,Estrie,plex,34.0,2402,100.0,published | |
| 43 | +Sorel-Tracy,53052,Montérégie,unifamilial,30.0,2353,100.0,conditional | |
| 44 | +Saint-Lin–Laurentides,63048,Lanaudière,unifamilial,32.0,2329,100.0,conditional | |
| 45 | +Rouyn-Noranda,86042,Abitibi-Témiscamingue,unifamilial,32.0,2327,100.0,conditional | |
| 46 | +Val-d'Or,89008,Abitibi-Témiscamingue,unifamilial,33.0,2194,100.0,conditional | |
| 47 | +Saint-Georges,29073,Chaudière-Appalaches,unifamilial,25.0,2019,100.0,conditional | |
| 48 | +Magog,45072,Estrie,unifamilial,27.0,2010,100.0,conditional | |
| 49 | +Alma,93042,Saguenay–Lac-Saint-Jean,unifamilial,26.0,1975,100.0,conditional | |
| 50 | +Chambly,57005,Montérégie,unifamilial,26.0,1952,100.0,conditional | |
| 51 | +Sainte-Julie,59010,Montérégie,unifamilial,25.0,1943,100.0,conditional | |
| 52 | +Dollard-des-Ormeaux,66142,Montréal,unifamilial,26.0,1872,100.0,conditional | |
| 53 | +Brossard,58007,Montérégie,condo,17.0,1797,100.0,conditional | |
| 54 | +Thetford Mines,31084,Chaudière-Appalaches,unifamilial,24.0,1773,100.0,conditional | |
| 55 | +Saint-Constant,67035,Montérégie,unifamilial,23.0,1708,100.0,conditional | |
| 56 | +Saint-Bruno-de-Montarville,58037,Montérégie,unifamilial,24.0,1702,100.0,conditional | |
| 57 | +Beloeil,57040,Montérégie,unifamilial,22.0,1697,100.0,conditional | |
| 58 | +Saint-Jérôme,75017,Laurentides,plex,24.0,1696,100.0,conditional | |
| 59 | +Repentigny,60013,Lanaudière,condo,22.0,1688,100.0,conditional | |
| 60 | +Sainte-Sophie,75028,Laurentides,unifamilial,24.0,1685,100.0,conditional | |
| 61 | +L'Assomption,60028,Lanaudière,unifamilial,21.0,1600,100.0,conditional | |
| 62 | +Saint-Colomban,75005,Laurentides,unifamilial,21.0,1568,100.0,conditional | |
| 63 | +Sept-Îles,97007,Côte-Nord,unifamilial,24.0,1563,100.0,conditional | |
| 64 | +Saint-Hippolyte,75045,Laurentides,unifamilial,22.0,1558,100.0,conditional | |
| 65 | +Candiac,67020,Montérégie,unifamilial,21.0,1555,100.0,conditional | |
| 66 | +Sainte-Adèle,77022,Laurentides,unifamilial,21.0,1534,100.0,conditional | |
| 67 | +Saint-Lazare,71105,Montérégie,unifamilial,21.0,1521,100.0,conditional | |
| 68 | +Boisbriand,73005,Laurentides,unifamilial,20.0,1519,100.0,conditional | |
| 69 | +Lévis,25213,Chaudière-Appalaches,plex,22.0,1511,100.0,published | |
| 70 | +Shawinigan,36033,Mauricie,plex,20.0,1485,100.0,conditional | |
| 71 | +Sainte-Julienne,63060,Lanaudière,unifamilial,20.0,1468,100.0,conditional | |
| 72 | +La Prairie,67015,Montérégie,unifamilial,20.0,1465,100.0,conditional | |
| 73 | +Saint-Sauveur,77043,Laurentides,unifamilial,21.0,1461,100.0,conditional | |
| 74 | +Mont-Saint-Hilaire,57035,Montérégie,unifamilial,20.0,1428,100.0,conditional | |
| 75 | +Val-des-Monts,82015,Outaouais,unifamilial,20.0,1424,100.0,conditional | |
| 76 | +Sainte-Marthe-sur-le-Lac,72015,Laurentides,unifamilial,18.0,1399,100.0,conditional | |
| 77 | +Lavaltrie,52007,Lanaudière,unifamilial,18.0,1393,100.0,conditional | |
| 78 | +Rawdon,62037,Lanaudière,unifamilial,18.0,1366,100.0,conditional | |
| 79 | +Pointe-Claire,66097,Montréal,unifamilial,18.0,1340,100.0,conditional | |
| 80 | +Varennes,59020,Montérégie,unifamilial,18.0,1312,100.0,conditional | |
| 81 | +Drummondville,49058,Centre-du-Québec,plex,16.0,1205,100.0,published | |
| 82 | +Prévost,75040,Laurentides,unifamilial,17.0,1203,100.0,conditional | |
| 83 | +Baie-Comeau,96020,Côte-Nord,unifamilial,16.0,1179,100.0,conditional | |
| 84 | +Sainte-Brigitte-de-Laval,22045,Capitale-Nationale,unifamilial,14.0,1162,100.0,not_published | |
| 85 | +Saint-Basile-le-Grand,57020,Montérégie,unifamilial,15.0,1148,100.0,conditional | |
| 86 | +Beaconsfield,66107,Montréal,unifamilial,17.0,1147,100.0,conditional | |
| 87 | +Mirabel,74005,Laurentides,plex,16.0,1146,100.0,conditional | |
| 88 | +Mercier,67045,Montérégie,unifamilial,15.0,1141,100.0,conditional | |
| 89 | +Saint-Raymond,34128,Capitale-Nationale,unifamilial,15.0,1140,100.0,conditional | |
| 90 | +Saint-Calixte,63055,Lanaudière,unifamilial,16.0,1130,100.0,conditional | |
| 91 | +Bromont,46078,Estrie,unifamilial,15.0,1053,100.0,conditional | |
| 92 | +Sainte-Anne-des-Plaines,73035,Laurentides,unifamilial,13.0,1049,100.0,not_published | |
| 93 | +L'Ancienne-Lorette,23057,Capitale-Nationale,unifamilial,13.0,1049,100.0,not_published | |
| 94 | +Saint-Augustin-de-Desmaures,23072,Capitale-Nationale,unifamilial,13.0,1043,98.5,not_published | |
| 95 | +Stoneham-et-Tewkesbury,22035,Capitale-Nationale,unifamilial,14.0,1039,100.0,not_published | |
| 96 | +Saint-Amable,59015,Montérégie,unifamilial,13.0,1039,100.0,not_published | |
| 97 | +Saint-Jean-sur-Richelieu,56083,Montérégie,plex,14.0,1029,100.0,not_published | |
| 98 | +Chertsey,62047,Lanaudière,unifamilial,15.0,1026,100.0,conditional | |
| 99 | +Dolbeau-Mistassini,92022,Saguenay–Lac-Saint-Jean,unifamilial,14.0,1023,100.0,not_published | |
| 100 | +Sainte-Catherine-de-la-Jacques-Cartier,22005,Capitale-Nationale,unifamilial,12.0,1015,100.0,not_published | |
| 101 | +Deux-Montagnes,72010,Laurentides,unifamilial,13.0,1010,100.0,not_published | |
| 102 | +Bécancour,38010,Centre-du-Québec,unifamilial,14.0,1010,100.0,not_published | |
| 103 | +Sainte-Catherine,67030,Montérégie,unifamilial,13.0,1004,100.0,not_published | |
| 104 | +Lachute,76020,Laurentides,unifamilial,14.0,1001,100.0,not_published | |
| 105 | +Rivière-du-Loup,12072,Bas-Saint-Laurent,unifamilial,12.0,990,98.5,not_published | |
| 106 | +Sainte-Thérèse,73010,Laurentides,unifamilial,13.0,981,100.0,not_published | |
| 107 | +Saint-Apollinaire,33090,Chaudière-Appalaches,unifamilial,12.0,970,98.5,not_published | |
| 108 | +Rosemère,73020,Laurentides,unifamilial,13.0,970,100.0,not_published | |
| 109 | +Cowansville,46080,Estrie,unifamilial,13.0,968,100.0,not_published | |
| 110 | +Mont-Tremblant,78102,Laurentides,unifamilial,14.0,968,100.0,not_published | |
| 111 | +Salaberry-de-Valleyfield,70052,Montérégie,plex,13.0,955,100.0,not_published | |
| 112 | +Saint-Lambert,58012,Montérégie,unifamilial,14.0,947,100.0,not_published | |
| 113 | +Granby,47017,Estrie,plex,12.0,944,100.0,not_published | |
| 114 | +Terrebonne,64008,Lanaudière,condo,12.0,929,100.0,not_published | |
| 115 | +Terrebonne,64008,Lanaudière,plex,12.0,920,100.0,not_published | |
| 116 | +Lévis,25213,Chaudière-Appalaches,condo,9.5,919,98.5,published | |
| 117 | +Pont-Rouge,34017,Capitale-Nationale,unifamilial,11.0,918,98.5,not_published | |
| 118 | +Matane,08053,Bas-Saint-Laurent,unifamilial,13.0,917,100.0,not_published | |
| 119 | +Kirkland,66102,Montréal,unifamilial,12.0,917,100.0,not_published | |
| 120 | +Carignan,57010,Montérégie,unifamilial,12.0,912,100.0,not_published | |
| 121 | +Beauharnois,70022,Montérégie,unifamilial,12.0,906,100.0,not_published | |
| 122 | +Mont-Laurier,79088,Laurentides,unifamilial,12.0,904,100.0,not_published | |
| 123 | +Contrecoeur,59035,Montérégie,unifamilial,12.0,884,98.5,not_published | |
| 124 | +Sainte-Agathe-des-Monts,78032,Laurentides,unifamilial,13.0,882,100.0,not_published | |
| 125 | +Cantley,82020,Outaouais,unifamilial,12.0,878,100.0,not_published | |
| 126 | +Saint-Adolphe-d'Howard,77065,Laurentides,unifamilial,13.0,865,98.5,not_published | |
| 127 | +Saint-Zotique,71025,Montérégie,unifamilial,12.0,855,100.0,not_published | |
| 128 | +Pincourt,71070,Montérégie,unifamilial,12.0,852,100.0,not_published | |
| 129 | +Saint-Donat,62060,Lanaudière,unifamilial,12.0,846,100.0,not_published | |
| 130 | +Marieville,55048,Montérégie,unifamilial,10.0,834,100.0,not_published | |
| 131 | +Joliette,61025,Lanaudière,unifamilial,11.0,827,100.0,not_published | |
| 132 | +Chelsea,82025,Outaouais,unifamilial,11.0,809,98.5,not_published | |
| 133 | +Sainte-Marie,26030,Chaudière-Appalaches,unifamilial,10.0,808,100.0,not_published | |
| 134 | +La Pêche,82035,Outaouais,unifamilial,11.0,807,100.0,not_published | |
| 135 | +Saint-Hyacinthe,54048,Montérégie,plex,11.0,800,100.0,not_published | |
| 136 | +Lorraine,73025,Laurentides,unifamilial,11.0,796,100.0,not_published | |
| 137 | +Les Îles-de-la-Madeleine,01023,Gaspésie–Îles-de-la-Madeleine,unifamilial,11.0,778,100.0,not_published | |
| 138 | +Westmount,66032,Montréal,unifamilial,10.0,765,100.0,not_published | |
| 139 | +Saint-Jérôme,75017,Laurentides,condo,7.0,760,98.5,not_published | |
| 140 | +Brownsburg-Chatham,76043,Laurentides,unifamilial,12.0,756,100.0,not_published | |
| 141 | +Saint-Félicien,91042,Saguenay–Lac-Saint-Jean,unifamilial,11.0,755,100.0,not_published | |
| 142 | +Lac-Beauport,22040,Capitale-Nationale,unifamilial,10.0,751,97.0,not_published | |
| 143 | +Saint-Philippe,67010,Montérégie,unifamilial,10.0,748,98.5,not_published | |
| 144 | +Otterburn Park,57030,Montérégie,unifamilial,10.0,745,100.0,not_published | |
| 145 | +Gaspé,03005,Gaspésie–Îles-de-la-Madeleine,unifamilial,10.0,742,100.0,not_published | |
| 146 | +Notre-Dame-de-l'Île-Perrot,71065,Montérégie,unifamilial,11.0,741,100.0,not_published | |
| 147 | +Farnham,46112,Estrie,unifamilial,10.0,719,100.0,not_published | |
| 148 | +La Tuque,90012,Mauricie,unifamilial,11.0,714,98.5,not_published | |
| 149 | +Mascouche,64015,Lanaudière,condo,7.0,713,100.0,not_published | |
| 150 | +Mont-Royal,66072,Montréal,unifamilial,11.0,711,98.5,not_published | |
| 151 | +Orford,45115,Estrie,unifamilial,10.0,703,100.0,not_published | |
| 152 | +Notre-Dame-des-Prairies,61030,Lanaudière,unifamilial,10.0,691,100.0,not_published | |
| 153 | +Côte-Saint-Luc,66058,Montréal,condo,10.0,681,100.0,not_published | |
| 154 | +Sorel-Tracy,53052,Montérégie,plex,10.0,670,100.0,not_published | |
| 155 | +Côte-Saint-Luc,66058,Montréal,unifamilial,9.0,668,100.0,not_published | |
| 156 | +Chibougamau,99025,Nord-du-Québec,unifamilial,8.5,662,98.5,not_published | |
| 157 | +Dorval,66087,Montréal,unifamilial,9.0,660,98.5,not_published | |
| 158 | +L'Épiphanie,60037,Lanaudière,unifamilial,9.0,651,98.5,not_published | |
| 159 | +Saint-Charles-Borromée,61035,Lanaudière,unifamilial,9.0,645,100.0,not_published | |
| 160 | +Sainte-Marguerite-du-Lac-Masson,77012,Laurentides,unifamilial,9.0,643,100.0,not_published | |
| 161 | +Boischatel,21045,Capitale-Nationale,unifamilial,7.0,638,98.5,not_published | |
| 162 | +Rouyn-Noranda,86042,Abitibi-Témiscamingue,plex,9.0,633,100.0,not_published | |
| 163 | +Bois-des-Filion,73030,Laurentides,unifamilial,9.0,631,100.0,not_published | |
| 164 | +Roberval,91025,Saguenay–Lac-Saint-Jean,unifamilial,8.0,625,100.0,not_published | |
| 165 | +Shefford,47035,Estrie,unifamilial,9.0,624,100.0,not_published | |
| 166 | +Lac-Brome,46075,Estrie,unifamilial,9.0,619,100.0,not_published | |
| 167 | +Morin-Heights,77050,Laurentides,unifamilial,9.0,612,100.0,not_published | |
| 168 | +Rigaud,71133,Montérégie,unifamilial,9.0,609,100.0,not_published | |
| 169 | +Montmagny,18050,Chaudière-Appalaches,unifamilial,8.0,605,100.0,not_published | |
| 170 | +La Malbaie,15013,Capitale-Nationale,unifamilial,8.0,596,100.0,not_published | |
| 171 | +Saint-Jean-de-Matha,62015,Lanaudière,unifamilial,9.0,594,98.5,not_published | |
| 172 | +Donnacona,34025,Capitale-Nationale,unifamilial,7.5,588,98.5,not_published | |
| 173 | +Shannon,22020,Capitale-Nationale,unifamilial,7.0,587,97.0,not_published | |
| 174 | +Acton Vale,48028,Montérégie,unifamilial,7.0,586,100.0,not_published | |
| 175 | +Blainville,73015,Laurentides,plex,8.0,576,100.0,not_published | |
| 176 | +Saint-Lambert,58012,Montérégie,condo,7.0,574,97.0,not_published | |
| 177 | +Saint-Honoré,94240,Saguenay–Lac-Saint-Jean,unifamilial,8.0,570,100.0,not_published | |
| 178 | +Boucherville,58033,Montérégie,condo,7.0,570,97.0,not_published | |
| 179 | +Pointe-Calumet,72020,Laurentides,unifamilial,8.0,568,100.0,not_published | |
| 180 | +Val-David,78010,Laurentides,unifamilial,9.0,566,100.0,not_published | |
| 181 | +Hébertville,93022,Saguenay–Lac-Saint-Jean,unifamilial,8.0,561,100.0,not_published | |
| 182 | +Saint-Paul,61005,Lanaudière,unifamilial,8.0,555,100.0,not_published | |
| 183 | +Alma,93042,Saguenay–Lac-Saint-Jean,plex,7.5,554,98.5,not_published | |
| 184 | +Saint-Alphonse-Rodriguez,62025,Lanaudière,unifamilial,7.0,554,100.0,not_published | |
| 185 | +Val-des-Sources,40043,Estrie,unifamilial,8.0,547,98.5,not_published | |
| 186 | +Coaticook,44037,Estrie,unifamilial,8.0,546,100.0,not_published | |
| 187 | +Waterloo,47025,Estrie,unifamilial,8.0,544,100.0,not_published | |
| 188 | +L'Île-Perrot,71060,Montérégie,unifamilial,8.0,542,98.5,not_published | |
| 189 | +Saint-Augustin-de-Desmaures,23072,Capitale-Nationale,condo,7.0,538,98.5,not_published | |
| 190 | +Saint-Ferréol-les-Neiges,21010,Capitale-Nationale,unifamilial,8.0,538,100.0,not_published | |
| 191 | +Saint-Lambert-de-Lauzon,26070,Chaudière-Appalaches,unifamilial,8.0,537,94.0,not_published | |
| 192 | +Saint-Félix-de-Valois,62007,Lanaudière,unifamilial,8.0,532,98.5,not_published | |
| 193 | +Victoriaville,39062,Centre-du-Québec,plex,8.0,526,100.0,not_published | |
| 194 | +Hudson,71100,Montérégie,unifamilial,7.0,520,97.0,not_published | |
| 195 | +L'Ange-Gardien,82005,Outaouais,unifamilial,7.0,515,98.5,not_published | |
| 196 | +Pointe-Claire,66097,Montréal,condo,6.0,508,98.5,not_published | |
| 197 | +Roxton Pond,47047,Estrie,unifamilial,7.0,504,100.0,not_published | |
| 198 | +Coteau-du-Lac,71040,Montérégie,unifamilial,7.0,503,98.5,not_published | |
| 199 | +Sherbrooke,43027,Estrie,condo,7.0,498,100.0,published | |
| 200 | +Delson,67025,Montérégie,unifamilial,6.0,494,98.5,not_published | |
| 201 | +Les Cèdres,71050,Montérégie,unifamilial,7.0,494,97.0,not_published | |
| 202 | +Lanoraie,52017,Lanaudière,unifamilial,7.0,493,100.0,not_published | |
| 203 | +Magog,45072,Estrie,condo,7.0,493,98.5,not_published | |
| 204 | +Chambly,57005,Montérégie,condo,6.0,491,95.5,not_published | |
| 205 | +Sainte-Thérèse,73010,Laurentides,plex,7.0,490,98.5,not_published | |
| 206 | +Saint-Rémi,68055,Montérégie,unifamilial,7.0,490,98.5,not_published | |
| 207 | +Saint-Côme,62065,Lanaudière,unifamilial,7.0,488,100.0,not_published | |
| 208 | +Saint-Jean-sur-Richelieu,56083,Montérégie,condo,6.0,488,95.5,not_published | |
| 209 | +Mascouche,64015,Lanaudière,plex,7.0,486,100.0,not_published | |
| 210 | +Saint-Pie,54008,Montérégie,unifamilial,5.5,484,98.5,not_published | |
| 211 | +Saint-Lin–Laurentides,63048,Lanaudière,plex,7.0,483,100.0,not_published | |
| 212 | +Sainte-Anne-des-Lacs,77035,Laurentides,unifamilial,6.0,481,100.0,not_published | |
| 213 | +Notre-Dame-du-Mont-Carmel,37235,Mauricie,unifamilial,6.0,480,100.0,not_published | |
| 214 | +Mont-Tremblant,78102,Laurentides,condo,6.0,478,100.0,not_published | |
| 215 | +Thetford Mines,31084,Chaudière-Appalaches,plex,7.0,476,100.0,not_published | |
| 216 | +Saint-Damien,62075,Lanaudière,unifamilial,7.0,474,100.0,not_published | |
| 217 | +Rivière-Rouge,79037,Laurentides,unifamilial,7.0,472,98.5,not_published | |
| 218 | +Nicolet,50072,Centre-du-Québec,unifamilial,6.0,472,98.5,not_published | |
| 219 | +Mont-Blanc,78047,Laurentides,unifamilial,6.0,468,98.5,not_published | |
| 220 | +Sainte-Anne-des-Monts,04037,Gaspésie–Îles-de-la-Madeleine,unifamilial,7.0,464,98.5,not_published | |
| 221 | +Les Coteaux,71033,Montérégie,unifamilial,6.0,462,100.0,not_published | |
| 222 | +Piedmont,77030,Laurentides,unifamilial,6.0,455,100.0,not_published | |
| 223 | +Saint-Denis-de-Brompton,42025,Estrie,unifamilial,6.0,454,98.5,not_published | |
| 224 | +Pontiac,82030,Outaouais,unifamilial,6.0,446,100.0,not_published | |
| 225 | +Saint-Alexis-des-Monts,51065,Mauricie,unifamilial,6.0,445,100.0,not_published | |
| 226 | +Granby,47017,Estrie,condo,6.0,442,95.5,not_published | |
| 227 | +Dollard-des-Ormeaux,66142,Montréal,condo,7.0,439,97.0,not_published | |
| 228 | +Val-d'Or,89008,Abitibi-Témiscamingue,plex,6.0,431,100.0,not_published | |
| 229 | +Saint-Agapit,33045,Chaudière-Appalaches,unifamilial,6.0,431,98.5,not_published | |
| 230 | +Sainte-Sophie,75028,Laurentides,plex,6.5,430,98.5,not_published | |
| 231 | +Dorval,66087,Montréal,condo,6.0,424,100.0,not_published | |
| 232 | +La Sarre,87090,Abitibi-Témiscamingue,unifamilial,6.0,424,97.0,not_published | |
| 233 | +Sutton,46058,Estrie,unifamilial,6.0,415,98.5,not_published | |
| 234 | +Mont-Joli,09077,Bas-Saint-Laurent,unifamilial,6.0,415,97.0,not_published | |
| 235 | +Repentigny,60013,Lanaudière,plex,5.0,412,98.5,not_published | |
| 236 | +Beaupré,21025,Capitale-Nationale,unifamilial,5.0,408,98.5,not_published | |
| 237 | +Saint-Henri,19068,Chaudière-Appalaches,unifamilial,5.0,406,97.0,not_published | |
| 238 | +Joliette,61025,Lanaudière,plex,5.0,404,100.0,not_published | |
| 239 | +Port-Cartier,97022,Côte-Nord,unifamilial,6.0,400,95.5,not_published | |
| 240 | +Princeville,32033,Centre-du-Québec,unifamilial,6.0,398,100.0,not_published | |
| 241 | +Verchères,59025,Montérégie,unifamilial,5.0,398,97.0,not_published | |
| 242 | +Château-Richer,21035,Capitale-Nationale,unifamilial,5.0,396,97.0,not_published | |
| 243 | +Rimouski,10043,Bas-Saint-Laurent,plex,5.0,396,100.0,not_published | |
| 244 | +Louiseville,51015,Mauricie,unifamilial,5.0,391,97.0,not_published | |
| 245 | +Saint-Césaire,55023,Montérégie,unifamilial,5.0,391,95.5,not_published | |
| 246 | +Mandeville,52095,Lanaudière,unifamilial,5.0,381,98.5,not_published | |
| 247 | +Sainte-Thérèse,73010,Laurentides,condo,4.0,380,94.0,not_published | |
| 248 | +Eastman,45093,Estrie,unifamilial,5.0,378,100.0,not_published | |
| 249 | +Gore,76025,Laurentides,unifamilial,5.0,378,100.0,not_published | |
| 250 | +Trois-Rivières,37067,Mauricie,condo,4.0,376,95.5,published | |
| 251 | +Saint-Roch-de-Richelieu,53040,Montérégie,unifamilial,5.0,375,97.0,not_published | |
| 252 | +Saint-Joseph-du-Lac,72025,Laurentides,unifamilial,5.0,374,98.5,not_published | |
| 253 | +Magog,45072,Estrie,plex,5.0,372,97.0,not_published | |
| 254 | +Saint-David-de-Falardeau,94245,Saguenay–Lac-Saint-Jean,unifamilial,6.0,371,97.0,not_published | |
| 255 | +Val-Morin,78005,Laurentides,unifamilial,5.0,371,100.0,not_published | |
| 256 | +Lac-Supérieur,78095,Laurentides,unifamilial,5.0,370,98.5,not_published | |
| 257 | +Notre-Dame-du-Laus,79005,Laurentides,unifamilial,5.0,368,97.0,not_published | |
| 258 | +Lac-Etchemin,28053,Chaudière-Appalaches,unifamilial,5.0,365,97.0,not_published | |
| 259 | +Saint-Michel-des-Saints,62085,Lanaudière,unifamilial,6.0,365,97.0,not_published | |
| 260 | +Saint-Eustache,72005,Laurentides,condo,4.0,365,92.5,not_published | |
| 261 | +Baie-Saint-Paul,16013,Capitale-Nationale,unifamilial,5.0,364,94.0,not_published | |
| 262 | +Saint-Roch-de-l'Achigan,63035,Lanaudière,unifamilial,5.0,362,98.5,not_published | |
| 263 | +Sainte-Adèle,77022,Laurentides,plex,5.0,361,98.5,not_published | |
| 264 | +Chandler,02028,Gaspésie–Îles-de-la-Madeleine,unifamilial,5.0,357,98.5,not_published | |
| 265 | +Saint-Anicet,69070,Montérégie,unifamilial,5.0,354,98.5,not_published | |
| 266 | +Grenville-sur-la-Rouge,76052,Laurentides,unifamilial,5.0,351,97.0,not_published | |
| 267 | +Wentworth-Nord,77060,Laurentides,unifamilial,5.0,347,98.5,not_published | |
| 268 | +Témiscouata-sur-le-Lac,13073,Bas-Saint-Laurent,unifamilial,5.0,344,92.5,not_published | |
| 269 | +Neuville,34007,Capitale-Nationale,unifamilial,4.0,343,94.0,not_published | |
| 270 | +Sainte-Agathe-des-Monts,78032,Laurentides,plex,5.0,340,97.0,not_published | |
| 271 | +Austin,45085,Estrie,unifamilial,5.0,339,97.0,not_published | |
| 272 | +Thurso,80050,Outaouais,unifamilial,4.0,336,98.5,not_published | |
| 273 | +Saint-Jacques,63013,Lanaudière,unifamilial,4.0,335,97.0,not_published | |
| 274 | +Labelle,78120,Laurentides,unifamilial,4.0,334,98.5,not_published | |
| 275 | +Lac-Mégantic,30030,Estrie,unifamilial,4.0,333,100.0,not_published | |
| 276 | +Saint-Cyrille-de-Wendover,49070,Centre-du-Québec,unifamilial,4.0,329,97.0,not_published | |
| 277 | +Lachute,76020,Laurentides,plex,4.5,328,98.5,not_published | |
| 278 | +Venise-en-Québec,56005,Montérégie,unifamilial,5.0,327,97.0,not_published | |
| 279 | +Saint-André-Avellin,80027,Outaouais,unifamilial,4.0,327,100.0,not_published | |
| 280 | +Gracefield,83032,Outaouais,unifamilial,5.0,326,97.0,not_published | |
| 281 | +Saint-Boniface,51085,Mauricie,unifamilial,5.0,325,95.5,not_published | |
| 282 | +La Pocatière,14082,Bas-Saint-Laurent,unifamilial,4.0,325,98.5,not_published | |
| 283 | +Napierville,68030,Montérégie,unifamilial,4.0,325,98.5,not_published | |
| 284 | +Saguenay,94068,Saguenay–Lac-Saint-Jean,condo,5.0,321,89.6,published | |
| 285 | +Saint-Gabriel-de-Brandon,52085,Lanaudière,unifamilial,4.0,321,98.5,not_published | |
| 286 | +Amqui,07047,Bas-Saint-Laurent,unifamilial,4.0,321,94.0,not_published | |
| 287 | +Nominingue,79030,Laurentides,unifamilial,4.0,321,98.5,not_published | |
| 288 | +Oka,72032,Laurentides,unifamilial,5.0,320,95.5,not_published | |
| 289 | +Saint-Eustache,72005,Laurentides,plex,4.0,320,98.5,not_published | |
| 290 | +Danville,40047,Estrie,unifamilial,4.0,319,98.5,not_published | |
| 291 | +Sainte-Julie,59010,Montérégie,condo,4.0,318,95.5,not_published | |
| 292 | +Saint-Ambroise,94255,Saguenay–Lac-Saint-Jean,unifamilial,4.5,318,92.5,not_published | |
| 293 | +Potton,45030,Estrie,unifamilial,5.0,317,100.0,not_published | |
| 294 | +Candiac,67020,Montérégie,condo,4.0,314,91.0,not_published | |
| 295 | +Beauceville,27028,Chaudière-Appalaches,unifamilial,4.0,314,95.5,not_published | |
| 296 | +Weedon,41098,Estrie,unifamilial,4.0,314,98.5,not_published | |
| 297 | +Amherst,78070,Laurentides,unifamilial,4.0,313,97.0,not_published | |
| 298 | +Sainte-Béatrix,62020,Lanaudière,unifamilial,5.0,312,97.0,not_published | |
| 299 | +Cap-Santé,34030,Capitale-Nationale,unifamilial,4.0,312,92.5,not_published | |
| 300 | +Saint-Mathias-sur-Richelieu,55065,Montérégie,unifamilial,4.0,311,97.0,not_published | |
| 301 | +Portneuf,34048,Capitale-Nationale,unifamilial,4.0,310,97.0,not_published | |
| 302 | +Saint-Gilles,33035,Chaudière-Appalaches,unifamilial,5.0,310,94.0,not_published | |
| 303 | +Saint-Étienne-des-Grès,51090,Mauricie,unifamilial,4.0,309,97.0,not_published | |
| 304 | +Saint-Tite,35027,Mauricie,unifamilial,4.0,308,100.0,not_published | |
| 305 | +Windsor,42088,Estrie,unifamilial,4.0,307,94.0,not_published | |
| 306 | +Saint-Bruno-de-Montarville,58037,Montérégie,condo,4.0,306,97.0,not_published | |
| 307 | +Sainte-Martine,70012,Montérégie,unifamilial,4.0,302,97.0,not_published | |
| 308 | +Métabetchouan–Lac-à-la-Croix,93012,Saguenay–Lac-Saint-Jean,unifamilial,4.0,301,94.0,not_published | |
| 309 | +Plessisville,32043,Centre-du-Québec,unifamilial,4.0,299,85.1,not_published | |
| 310 | +Saint-Anselme,19062,Chaudière-Appalaches,unifamilial,4.0,299,94.0,not_published | |
| 311 | +Cookshire-Eaton,41038,Estrie,unifamilial,4.0,298,95.5,not_published | |
| 312 | +Saint-Paul-de-l'Île-aux-Noix,56035,Montérégie,unifamilial,4.0,298,97.0,not_published | |
| 313 | +Maniwaki,83065,Outaouais,unifamilial,4.0,291,94.0,not_published | |
| 314 | +Richelieu,55057,Montérégie,unifamilial,4.0,289,95.5,not_published | |
| 315 | +Saint-Germain-de-Grantham,49048,Centre-du-Québec,unifamilial,4.0,288,98.5,not_published | |
| 316 | +East Angus,41060,Estrie,unifamilial,4.0,288,94.0,not_published | |
| 317 | +Lac-Simon,80095,Outaouais,unifamilial,4.0,284,95.5,not_published | |
| 318 | +Saint-Joseph-de-Beauce,27043,Chaudière-Appalaches,unifamilial,4.0,282,97.0,not_published | |
| 319 | +Beaumont,19105,Chaudière-Appalaches,unifamilial,4.0,280,91.0,not_published | |
| 320 | +Fossambault-sur-le-Lac,22010,Capitale-Nationale,unifamilial,4.0,280,94.0,not_published | |
| 321 | +Dunham,46050,Estrie,unifamilial,4.0,279,98.5,not_published | |
| 322 | +Saint-Prosper,28020,Chaudière-Appalaches,unifamilial,4.0,279,97.0,not_published | |
| 323 | +Saint-Georges,29073,Chaudière-Appalaches,plex,4.0,279,97.0,not_published | |
| 324 | +McMasterville,57025,Montérégie,unifamilial,4.0,278,94.0,not_published | |
| 325 | +Saint-Antonin,12015,Bas-Saint-Laurent,unifamilial,3.0,275,94.0,not_published | |
| 326 | +Saint-André-d'Argenteuil,76008,Laurentides,unifamilial,4.0,275,95.5,not_published | |
| 327 | +Lac-des-Écorces,79078,Laurentides,unifamilial,4.0,273,97.0,not_published | |
| 328 | +Sainte-Émélie-de-l'Énergie,62070,Lanaudière,unifamilial,4.0,273,91.0,not_published | |
| 329 | +Adstock,31056,Chaudière-Appalaches,unifamilial,4.0,272,95.5,not_published | |
| 330 | +Baie-Comeau,96020,Côte-Nord,plex,4.0,272,94.0,not_published | |
| 331 | +Châteauguay,67050,Montérégie,plex,4.0,271,94.0,not_published | |
| 332 | +Sainte-Mélanie,61050,Lanaudière,unifamilial,4.0,270,92.5,not_published | |
| 333 | +Notre-Dame-de-la-Merci,62055,Lanaudière,unifamilial,4.0,267,98.5,not_published | |
| 334 | +La Minerve,78130,Laurentides,unifamilial,4.0,267,95.5,not_published | |
| 335 | +Warwick,39077,Centre-du-Québec,unifamilial,3.0,265,98.5,not_published | |
| 336 | +Beloeil,57040,Montérégie,condo,4.0,264,83.6,not_published | |
| 337 | +Sainte-Claire,19055,Chaudière-Appalaches,unifamilial,3.0,264,88.1,not_published | |
| 338 | +Saint-Sauveur,77043,Laurentides,plex,4.0,263,95.5,not_published | |
| 339 | +Huntingdon,69055,Montérégie,unifamilial,4.0,262,94.0,not_published | |
| 340 | +Rivière-Beaudette,71005,Montérégie,unifamilial,3.0,260,97.0,not_published | |
| 341 | +Saint-Ambroise-de-Kildare,61040,Lanaudière,unifamilial,3.0,260,95.5,not_published | |
| 342 | +Baie-D'Urfé,66112,Montréal,unifamilial,4.0,259,95.5,not_published | |
| 343 | +Saint-Maurice,37230,Mauricie,unifamilial,4.0,256,94.0,not_published | |
| 344 | +Mont-Royal,66072,Montréal,condo,3.0,256,97.0,not_published | |
| 345 | +La Tuque,90012,Mauricie,plex,4.0,255,98.5,not_published | |
| 346 | +Hampstead,66062,Montréal,unifamilial,3.0,254,97.0,not_published | |
| 347 | +Rivière-du-Loup,12072,Bas-Saint-Laurent,plex,4.0,254,92.5,not_published | |
| 348 | +Sainte-Marthe-sur-le-Lac,72015,Laurentides,condo,3.0,252,89.6,not_published | |
| 349 | +Mont-Laurier,79088,Laurentides,plex,3.0,251,94.0,not_published | |
| 350 | +Saint-Isidore,26063,Chaudière-Appalaches,unifamilial,3.0,250,95.5,not_published | |
| 351 | +Sept-Îles,97007,Côte-Nord,plex,3.0,247,94.0,not_published | |
| 352 | +Carleton-sur-Mer,06013,Gaspésie–Îles-de-la-Madeleine,unifamilial,4.0,247,89.6,not_published | |
| 353 | +Sainte-Anne-de-Beaupré,21030,Capitale-Nationale,unifamilial,3.0,246,92.5,not_published | |
| 354 | +Crabtree,61013,Lanaudière,unifamilial,4.0,246,95.5,not_published | |
| 355 | +Saint-Alphonse-de-Granby,47010,Estrie,unifamilial,3.0,245,100.0,not_published | |
| 356 | +Berthierville,52035,Lanaudière,unifamilial,3.0,244,95.5,not_published | |
| 357 | +Ormstown,69037,Montérégie,unifamilial,4.0,244,94.0,not_published | |
| 358 | +Saint-Élie-de-Caxton,51075,Mauricie,unifamilial,3.0,244,97.0,not_published | |
| 359 | +Saint-Gabriel-de-Valcartier,22025,Capitale-Nationale,unifamilial,3.0,244,92.5,not_published | |
| 360 | +Saint-Basile,34038,Capitale-Nationale,unifamilial,4.0,243,94.0,not_published | |
| 361 | +Sainte-Luce,09092,Bas-Saint-Laurent,unifamilial,4.0,243,94.0,not_published | |
| 362 | +Malartic,89015,Abitibi-Témiscamingue,unifamilial,4.0,242,94.0,not_published | |
| 363 | +Saint-Gabriel,52080,Lanaudière,unifamilial,4.0,241,91.0,not_published | |
| 364 | +L'Islet,17078,Chaudière-Appalaches,unifamilial,3.0,241,94.0,not_published | |
| 365 | +Sainte-Catherine-de-Hatley,45060,Estrie,unifamilial,3.5,240,92.5,not_published | |
| 366 | +Blainville,73015,Laurentides,condo,3.0,239,86.6,not_published | |
| 367 | +Sainte-Croix,33102,Chaudière-Appalaches,unifamilial,3.0,239,91.0,not_published | |
| 368 | +Notre-Dame-des-Bois,30010,Estrie,unifamilial,3.0,238,97.0,not_published | |
| 369 | +Lacolle,56023,Montérégie,unifamilial,3.0,236,97.0,not_published | |
| 370 | +Sainte-Lucie-des-Laurentides,78020,Laurentides,unifamilial,4.0,235,95.5,not_published | |
| 371 | +Cowansville,46080,Estrie,plex,4.0,234,94.0,not_published | |
| 372 | +Papineauville,80037,Outaouais,unifamilial,4.0,234,92.5,not_published | |
| 373 | +Beauharnois,70022,Montérégie,plex,3.0,231,98.5,not_published | |
| 374 | +Saint-Denis-sur-Richelieu,57068,Montérégie,unifamilial,3.0,230,91.0,not_published | |
| 375 | +Westmount,66032,Montréal,condo,3.0,230,95.5,not_published | |
| 376 | +Saint-Liboire,54072,Montérégie,unifamilial,3.0,230,94.0,not_published | |
| 377 | +Léry,67055,Montérégie,unifamilial,3.0,230,92.5,not_published | |
| 378 | +Mont-Tremblant,78102,Laurentides,plex,3.0,229,92.5,not_published | |
| 379 | +Vaudreuil-Dorion,71083,Montérégie,condo,3.0,229,76.1,not_published | |
| 380 | +Saint-Christophe-d'Arthabaska,39060,Centre-du-Québec,unifamilial,3.0,228,94.0,not_published | |
| 381 | +Ascot Corner,41055,Estrie,unifamilial,3.0,228,91.0,not_published | |
| 382 | +New Richmond,05070,Gaspésie–Îles-de-la-Madeleine,unifamilial,3.0,226,98.5,not_published | |
| 383 | +L'Assomption,60028,Lanaudière,plex,3.0,226,95.5,not_published | |
| 384 | +Mont-Saint-Grégoire,56097,Montérégie,unifamilial,3.0,224,95.5,not_published | |
| 385 | +Montpellier,80090,Outaouais,unifamilial,3.0,223,91.0,not_published | |
| 386 | +Saint-Zénon,62080,Lanaudière,unifamilial,3.0,223,86.6,not_published | |
| 387 | +Trois-Pistoles,11040,Bas-Saint-Laurent,unifamilial,3.0,222,94.0,not_published | |
| 388 | +Saint-Raphaël,19082,Chaudière-Appalaches,unifamilial,3.0,222,94.0,not_published | |
| 389 | +Saint-Jean-Port-Joli,17070,Chaudière-Appalaches,unifamilial,3.0,222,97.0,not_published | |
| 390 | +Saint-Mathieu-de-Beloeil,57045,Montérégie,unifamilial,3.0,221,89.6,not_published | |
| 391 | +Sainte-Marie-Madeleine,54030,Montérégie,unifamilial,3.0,220,86.6,not_published | |
| 392 | +Sainte-Clotilde,68020,Montérégie,unifamilial,3.0,220,91.0,not_published | |
| 393 | +Ferme-Neuve,79097,Laurentides,unifamilial,3.0,219,91.0,not_published | |
| 394 | +Sainte-Anne-de-Sabrevois,56060,Montérégie,unifamilial,3.0,218,91.0,not_published | |
| 395 | +Saint-Colomban,75005,Laurentides,plex,3.0,217,91.0,not_published | |
| 396 | +Sainte-Anne-de-Bellevue,66117,Montréal,unifamilial,3.0,216,88.1,not_published | |
| 397 | +Saint-Alexandre,56055,Montérégie,unifamilial,3.0,216,97.0,not_published | |
| 398 | +Sainte-Thècle,35050,Mauricie,unifamilial,4.0,215,91.0,not_published | |
| 399 | +Daveluyville,39152,Centre-du-Québec,unifamilial,3.0,215,92.5,not_published | |
| 400 | +La Conception,78115,Laurentides,unifamilial,3.0,214,97.0,not_published | |
| 401 | +Saint-Mathieu-du-Parc,51070,Mauricie,unifamilial,3.0,214,97.0,not_published | |
| 402 | +Saint-Amable,59015,Montérégie,condo,3.0,213,79.1,not_published | |
| 403 | +Saint-Louis-de-Gonzague,70035,Montérégie,unifamilial,3.0,212,94.0,not_published | |
| 404 | +Sainte-Marcelline-de-Kildare,62030,Lanaudière,unifamilial,3.0,211,94.0,not_published | |
| 405 | +Témiscaming,85005,Abitibi-Témiscamingue,unifamilial,3.0,211,94.0,not_published | |
| 406 | +Notre-Dame-de-Lourdes,61045,Lanaudière,unifamilial,3.0,211,91.0,not_published | |
| 407 | +Saint-Hippolyte,75045,Laurentides,plex,3.0,210,91.0,not_published | |
| 408 | +Hinchinbrooke,69045,Montérégie,unifamilial,3.0,209,92.5,not_published | |
| 409 | +Saint-Dominique,54060,Montérégie,unifamilial,3.0,208,89.6,not_published | |
| 410 | +Brigham,46090,Estrie,unifamilial,3.0,208,89.6,not_published | |
| 411 | +Saint-Marc-des-Carrières,34065,Capitale-Nationale,unifamilial,2.0,208,95.5,not_published | |
| 412 | +Rougemont,55037,Montérégie,unifamilial,3.0,207,92.5,not_published | |
| 413 | +Amos,88057,Abitibi-Témiscamingue,unifamilial,4.0,206,47.8,not_published | |
| 414 | +Saint-Barthélemy,52055,Lanaudière,unifamilial,3.0,205,94.0,not_published | |
| 415 | +Montréal-Ouest,66047,Montréal,unifamilial,3.0,205,89.6,not_published | |
| 416 | +Farnham,46112,Estrie,plex,2.0,205,91.0,not_published | |
| 417 | +Clermont,15035,Capitale-Nationale,unifamilial,3.0,205,86.6,not_published | |
| 418 | +Entrelacs,62053,Lanaudière,unifamilial,3.0,204,94.0,not_published | |
| 419 | +Saint-Stanislas-de-Kostka,70040,Montérégie,unifamilial,3.0,203,94.0,not_published | |
| 420 | +Disraeli,31015,Chaudière-Appalaches,unifamilial,3.0,203,92.5,not_published | |
| 421 | +Saint-Polycarpe,71020,Montérégie,unifamilial,3.0,203,91.0,not_published | |
| 422 | +Stanstead,45008,Estrie,unifamilial,3.0,202,95.5,not_published | |
| 423 | +Val-des-Bois,80140,Outaouais,unifamilial,3.0,202,79.1,not_published | |
| 424 | +Saint-Côme–Linière,29057,Chaudière-Appalaches,unifamilial,3.0,201,89.6,not_published | |
| 425 | +Scott,26048,Chaudière-Appalaches,unifamilial,3.0,200,94.0,not_published | |
| 426 | +Saint-Lucien,49030,Centre-du-Québec,unifamilial,3.0,199,91.0,not_published | |
| 427 | +Ange-Gardien,55008,Montérégie,unifamilial,3.0,199,95.5,not_published | |
| 428 | +Mille-Isles,76030,Laurentides,unifamilial,3.0,198,97.0,not_published | |
| 429 | +Lac-aux-Sables,35010,Mauricie,unifamilial,3.0,198,86.6,not_published | |
| 430 | +Saint-Michel,68050,Montérégie,unifamilial,3.0,197,89.6,not_published | |
| 431 | +Cap-Saint-Ignace,18045,Chaudière-Appalaches,unifamilial,3.0,197,92.5,not_published | |
| 432 | +Saint-Chrysostome,69017,Montérégie,unifamilial,3.0,197,92.5,not_published | |
| 433 | +Normandin,92040,Saguenay–Lac-Saint-Jean,unifamilial,2.0,196,95.5,not_published | |
| 434 | +Saint-Charles-de-Bellechasse,19097,Chaudière-Appalaches,unifamilial,3.0,196,88.1,not_published | |
| 435 | +Saint-Joseph-de-Coleraine,31045,Chaudière-Appalaches,unifamilial,3.0,196,92.5,not_published | |
| 436 | +Stoke,42005,Estrie,unifamilial,3.0,195,94.0,not_published | |
| 437 | +Harrington,76065,Laurentides,unifamilial,3.0,195,88.1,not_published | |
| 438 | +Saint-Blaise-sur-Richelieu,56065,Montérégie,unifamilial,3.0,195,94.0,not_published | |
| 439 | +Chambly,57005,Montérégie,plex,3.0,195,91.0,not_published | |
| 440 | +L'Ascension-de-Notre-Seigneur,93065,Saguenay–Lac-Saint-Jean,unifamilial,3.0,195,83.6,not_published | |
| 441 | +Dolbeau-Mistassini,92022,Saguenay–Lac-Saint-Jean,plex,3.0,195,85.1,not_published | |
| 442 | +Berthier-sur-Mer,18065,Chaudière-Appalaches,unifamilial,2.0,195,91.0,not_published | |
| 443 | +Ripon,80078,Outaouais,unifamilial,3.0,194,91.0,not_published | |
| 444 | +Stratford,30110,Estrie,unifamilial,3.0,193,95.5,not_published | |
| 445 | +Sainte-Anne-de-Sorel,53065,Montérégie,unifamilial,3.0,192,92.5,not_published | |
| 446 | +Saint-François-du-Lac,50128,Centre-du-Québec,unifamilial,3.0,192,89.6,not_published | |
| 447 | +Sainte-Barbe,69065,Montérégie,unifamilial,3.0,192,88.1,not_published | |
| 448 | +Charlemagne,60005,Lanaudière,unifamilial,3.0,191,92.5,not_published | |
| 449 | +Saint-Thomas,61027,Lanaudière,unifamilial,3.0,190,88.1,not_published | |
| 450 | +Saint-Elzéar,26022,Chaudière-Appalaches,unifamilial,3.0,188,88.1,not_published | |
| 451 | +Saint-Sulpice,60020,Lanaudière,unifamilial,3.0,188,83.6,not_published | |
| 452 | +Pohénégamook,13095,Bas-Saint-Laurent,unifamilial,3.0,188,91.0,not_published | |
| 453 | +Deschambault-Grondines,34058,Capitale-Nationale,unifamilial,2.5,188,89.6,not_published | |
| 454 | +Richmond,42098,Estrie,unifamilial,3.0,188,82.1,not_published | |
| 455 | +Bolton-Est,45095,Estrie,unifamilial,2.5,187,95.5,not_published | |
| 456 | +Matane,08053,Bas-Saint-Laurent,plex,2.5,187,92.5,not_published | |
| 457 | +Lebel-sur-Quévillon,99005,Nord-du-Québec,unifamilial,3.0,186,86.6,not_published | |
| 458 | +Macamic,87058,Abitibi-Témiscamingue,unifamilial,3.0,186,86.6,not_published | |
| 459 | +Bedford,46035,Estrie,unifamilial,3.0,186,88.1,not_published | |
| 460 | +Havre-Saint-Pierre,98040,Côte-Nord,unifamilial,3.0,184,95.5,not_published | |
| 461 | +Waterville,44080,Estrie,unifamilial,3.0,184,88.1,not_published | |
| 462 | +Brossard,58007,Montérégie,plex,3.0,184,83.6,not_published | |
| 463 | +Boischatel,21045,Capitale-Nationale,condo,2.0,182,82.1,not_published | |
| 464 | +Pierreville,50113,Centre-du-Québec,unifamilial,3.0,182,86.6,not_published | |
| 465 | +Saint-Anaclet-de-Lessard,10030,Bas-Saint-Laurent,unifamilial,3.0,182,88.1,not_published | |
| 466 | +Saint-Tite-des-Caps,21005,Capitale-Nationale,unifamilial,2.5,181,89.6,not_published | |
| 467 | +Saint-Prime,91035,Saguenay–Lac-Saint-Jean,unifamilial,3.0,181,92.5,not_published | |
| 468 | +Saint-Paul-d'Abbotsford,55015,Montérégie,unifamilial,3.0,180,89.6,not_published | |
| 469 | +Senneterre,89040,Abitibi-Témiscamingue,unifamilial,3.0,179,88.1,not_published | |
| 470 | +Saint-Bernard,26055,Chaudière-Appalaches,unifamilial,3.0,178,80.6,not_published | |
| 471 | +Saint-Jean-Baptiste,57033,Montérégie,unifamilial,3.0,177,88.1,not_published | |
| 472 | +Saint-Antoine-de-Tilly,33095,Chaudière-Appalaches,unifamilial,3.0,176,83.6,not_published | |
| 473 | +Maskinongé,51008,Mauricie,unifamilial,3.0,176,89.6,not_published | |
| 474 | +Larouche,94265,Saguenay–Lac-Saint-Jean,unifamilial,3.0,176,79.1,not_published | |
| 475 | +Compton,44071,Estrie,unifamilial,3.0,176,88.1,not_published | |
| 476 | +Percé,02005,Gaspésie–Îles-de-la-Madeleine,unifamilial,3.0,176,89.6,not_published | |
| 477 | +Saint-Pascal,14018,Bas-Saint-Laurent,unifamilial,2.0,175,94.0,not_published | |
| 478 | +Sainte-Victoire-de-Sorel,53025,Montérégie,unifamilial,2.0,175,89.6,not_published | |
| 479 | +Beloeil,57040,Montérégie,plex,2.0,175,88.1,not_published | |
| 480 | +Chute-Saint-Philippe,79065,Laurentides,unifamilial,3.0,173,88.1,not_published | |
| 481 | +Mansfield-et-Pontefract,84065,Outaouais,unifamilial,3.0,173,83.6,not_published | |
| 482 | +Saint-Isidore,67040,Montérégie,unifamilial,3.0,173,83.6,not_published | |
| 483 | +Noyan,56015,Montérégie,unifamilial,3.0,172,82.1,not_published | |
| 484 | +Saint-Gédéon,93035,Saguenay–Lac-Saint-Jean,unifamilial,2.0,172,92.5,not_published | |
| 485 | +Messines,83060,Outaouais,unifamilial,3.0,172,88.1,not_published | |
| 486 | +Wickham,49040,Centre-du-Québec,unifamilial,2.0,172,85.1,not_published | |
| 487 | +Saint-Gédéon-de-Beauce,29013,Chaudière-Appalaches,unifamilial,3.0,171,89.6,not_published | |
| 488 | +Laurier-Station,33060,Chaudière-Appalaches,unifamilial,2.0,171,91.0,not_published | |
| 489 | +Maria,06005,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,170,91.0,not_published | |
| 490 | +L'Isle-aux-Allumettes,84082,Outaouais,unifamilial,3.0,169,89.6,not_published | |
| 491 | +Rimouski,10043,Bas-Saint-Laurent,condo,3.0,169,86.6,not_published | |
| 492 | +Ville-Marie,85025,Abitibi-Témiscamingue,unifamilial,3.0,168,83.6,not_published | |
| 493 | +Yamachiche,51020,Mauricie,unifamilial,3.0,168,86.6,not_published | |
| 494 | +Saint-Antoine-sur-Richelieu,57075,Montérégie,unifamilial,3.0,168,79.1,not_published | |
| 495 | +East Broughton,31122,Chaudière-Appalaches,unifamilial,2.0,168,88.1,not_published | |
| 496 | +Piedmont,77030,Laurentides,condo,3.0,167,77.6,not_published | |
| 497 | +Upton,48038,Montérégie,unifamilial,3.0,167,86.6,not_published | |
| 498 | +Saint-Liguori,63065,Lanaudière,unifamilial,2.0,167,89.6,not_published | |
| 499 | +Cap-Chat,04047,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,167,88.1,not_published | |
| 500 | +Clarenceville,56010,Montérégie,unifamilial,2.0,167,86.6,not_published | |
| 501 | +Val-des-Sources,40043,Estrie,plex,3.0,167,88.1,not_published | |
| 502 | +Saint-Damien-de-Buckland,19030,Chaudière-Appalaches,unifamilial,2.0,167,94.0,not_published | |
| 503 | +Hemmingford,68015,Montérégie,unifamilial,3.0,167,85.1,not_published | |
| 504 | +Dégelis,13005,Bas-Saint-Laurent,unifamilial,3.0,166,85.1,not_published | |
| 505 | +Duhamel,80135,Outaouais,unifamilial,2.0,166,85.1,not_published | |
| 506 | +Saint-Léonard-d'Aston,50042,Centre-du-Québec,unifamilial,3.0,166,79.1,not_published | |
| 507 | +Paspébiac,05032,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,165,91.0,not_published | |
| 508 | +Fermont,97035,Côte-Nord,unifamilial,7.5,164,29.9,not_published | |
| 509 | +Lac-Bouchette,91005,Saguenay–Lac-Saint-Jean,unifamilial,3.0,164,85.1,not_published | |
| 510 | +Saint-Malachie,19025,Chaudière-Appalaches,unifamilial,2.0,164,86.6,not_published | |
| 511 | +Saint-François-Xavier-de-Brompton,42020,Estrie,unifamilial,2.0,163,94.0,not_published | |
| 512 | +Lambton,30095,Estrie,unifamilial,3.0,162,89.6,not_published | |
| 513 | +Saint-Damase,54017,Montérégie,unifamilial,2.0,162,92.5,not_published | |
| 514 | +Sainte-Geneviève-de-Berthier,52040,Lanaudière,unifamilial,2.0,162,86.6,not_published | |
| 515 | +Champlain,37220,Mauricie,unifamilial,3.0,162,79.1,not_published | |
| 516 | +Saint-Ferdinand,32013,Centre-du-Québec,unifamilial,3.0,161,82.1,not_published | |
| 517 | +Dudswell,41117,Estrie,unifamilial,2.0,161,91.0,not_published | |
| 518 | +Saint-Fabien,10070,Bas-Saint-Laurent,unifamilial,2.5,161,89.6,not_published | |
| 519 | +Sainte-Cécile-de-Milton,47055,Estrie,unifamilial,2.0,160,83.6,not_published | |
| 520 | +Saint-Ours,53032,Montérégie,unifamilial,2.0,160,92.5,not_published | |
| 521 | +Saint-Ubalde,34090,Capitale-Nationale,unifamilial,2.0,159,86.6,not_published | |
| 522 | +La Macaza,79047,Laurentides,unifamilial,2.0,159,85.1,not_published | |
| 523 | +Saint-Marc-sur-Richelieu,57050,Montérégie,unifamilial,3.0,158,83.6,not_published | |
| 524 | +Barraute,88022,Abitibi-Témiscamingue,unifamilial,3.0,158,83.6,not_published | |
| 525 | +Mont-Saint-Hilaire,57035,Montérégie,condo,3.0,157,58.2,not_published | |
| 526 | +Labrecque,93055,Saguenay–Lac-Saint-Jean,unifamilial,3.0,157,85.1,not_published | |
| 527 | +Saint-Placide,72043,Laurentides,unifamilial,3.0,157,79.1,not_published | |
| 528 | +Val-des-Monts,82015,Outaouais,plex,2.0,156,88.1,not_published | |
| 529 | +La Prairie,67015,Montérégie,condo,3.0,156,59.7,not_published | |
| 530 | +Varennes,59020,Montérégie,condo,3.0,156,71.6,not_published | |
| 531 | +Forestville,95045,Côte-Nord,unifamilial,2.0,155,83.6,not_published | |
| 532 | +Déléage,83070,Outaouais,unifamilial,2.0,155,85.1,not_published | |
| 533 | +Saint-Cuthbert,52062,Lanaudière,unifamilial,2.0,155,89.6,not_published | |
| 534 | +Sainte-Marthe-sur-le-Lac,72015,Laurentides,plex,2.0,154,86.6,not_published | |
| 535 | +Saint-Ulric,08073,Bas-Saint-Laurent,unifamilial,3.0,154,82.1,not_published | |
| 536 | +Bonaventure,05045,Gaspésie–Îles-de-la-Madeleine,unifamilial,3.0,153,79.1,not_published | |
| 537 | +Sainte-Clotilde-de-Horton,39117,Centre-du-Québec,unifamilial,2.0,152,86.6,not_published | |
| 538 | +L'Ancienne-Lorette,23057,Capitale-Nationale,plex,3.0,152,74.6,not_published | |
| 539 | +Prévost,75040,Laurentides,plex,2.0,152,83.6,not_published | |
| 540 | +Boisbriand,73005,Laurentides,plex,2.0,152,85.1,not_published | |
| 541 | +Saint-Nazaire,93045,Saguenay–Lac-Saint-Jean,unifamilial,3.0,151,80.6,not_published | |
| 542 | +Marieville,55048,Montérégie,plex,2.0,151,85.1,not_published | |
| 543 | +Notre-Dame-du-Bon-Conseil,49075,Centre-du-Québec,unifamilial,2.0,151,88.1,not_published | |
| 544 | +Saint-Hubert-de-Rivière-du-Loup,12010,Bas-Saint-Laurent,unifamilial,2.0,150,85.1,not_published | |
| 545 | +Saint-Martin,29045,Chaudière-Appalaches,unifamilial,2.0,150,80.6,not_published | |
| 546 | +Saint-Amable,59015,Montérégie,plex,2.0,150,88.1,not_published | |
| 547 | +Saint-Constant,67035,Montérégie,plex,2.5,148,77.6,not_published | |
| 548 | +Val-des-Lacs,78100,Laurentides,unifamilial,2.0,148,82.1,not_published | |
| 549 | +Saint-Fulgence,94235,Saguenay–Lac-Saint-Jean,unifamilial,2.0,147,83.6,not_published | |
| 550 | +Saint-Mathieu,67005,Montérégie,unifamilial,2.0,147,88.1,not_published | |
| 551 | +Armagh,19037,Chaudière-Appalaches,unifamilial,2.0,146,88.1,not_published | |
| 552 | +Deux-Montagnes,72010,Laurentides,plex,2.0,146,80.6,not_published | |
| 553 | +Chambord,91020,Saguenay–Lac-Saint-Jean,unifamilial,2.0,146,82.1,not_published | |
| 554 | +Mirabel,74005,Laurentides,condo,2.0,145,71.6,not_published | |
| 555 | +Montmagny,18050,Chaudière-Appalaches,plex,2.0,145,82.1,not_published | |
| 556 | +Orford,45115,Estrie,condo,3.0,145,79.1,not_published | |
| 557 | +Lavaltrie,52007,Lanaudière,plex,2.0,144,86.6,not_published | |
| 558 | +Lac-Sainte-Marie,83020,Outaouais,unifamilial,2.0,144,80.6,not_published | |
| 559 | +Boisbriand,73005,Laurentides,condo,2.0,144,79.1,not_published | |
| 560 | +Rawdon,62037,Lanaudière,plex,2.0,144,83.6,not_published | |
| 561 | +Lantier,78015,Laurentides,unifamilial,2.0,144,85.1,not_published | |
| 562 | +Cayamant,83040,Outaouais,unifamilial,2.0,144,83.6,not_published | |
| 563 | +La Prairie,67015,Montérégie,plex,2.0,144,83.6,not_published | |
| 564 | +Coaticook,44037,Estrie,plex,2.0,143,92.5,not_published | |
| 565 | +Kingsey Falls,39097,Centre-du-Québec,unifamilial,2.0,143,83.6,not_published | |
| 566 | +Bury,41070,Estrie,unifamilial,2.0,142,88.1,not_published | |
| 567 | +Yamaska,53072,Montérégie,unifamilial,2.0,142,86.6,not_published | |
| 568 | +Val-David,78010,Laurentides,plex,2.0,142,88.1,not_published | |
| 569 | +Saint-Victor,27008,Chaudière-Appalaches,unifamilial,2.0,142,85.1,not_published | |
| 570 | +Saint-Ignace-de-Loyola,52045,Lanaudière,unifamilial,2.0,142,83.6,not_published | |
| 571 | +Grande-Rivière,02015,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,141,86.6,not_published | |
| 572 | +Hatley,45055,Estrie,unifamilial,2.0,141,80.6,not_published | |
| 573 | +Roberval,91025,Saguenay–Lac-Saint-Jean,plex,2.0,141,77.6,not_published | |
| 574 | +Les Escoumins,95025,Côte-Nord,unifamilial,2.0,140,82.1,not_published | |
| 575 | +Shawville,84010,Outaouais,unifamilial,2.0,139,85.1,not_published | |
| 576 | +Saint-Charles-sur-Richelieu,57057,Montérégie,unifamilial,2.0,139,85.1,not_published | |
| 577 | +Chapais,99020,Nord-du-Québec,unifamilial,2.0,139,88.1,not_published | |
| 578 | +Vaudreuil-Dorion,71083,Montérégie,plex,2.0,139,79.1,not_published | |
| 579 | +Albanel,92030,Saguenay–Lac-Saint-Jean,unifamilial,2.0,138,85.1,not_published | |
| 580 | +Saint-Félix-de-Kingsey,49005,Centre-du-Québec,unifamilial,2.0,138,85.1,not_published | |
| 581 | +Saint-Charles-Borromée,61035,Lanaudière,plex,2.0,137,83.6,not_published | |
| 582 | +Causapscal,07018,Bas-Saint-Laurent,unifamilial,2.0,137,80.6,not_published | |
| 583 | +Lac-des-Plages,80130,Outaouais,unifamilial,2.0,136,85.1,not_published | |
| 584 | +Lac-Mégantic,30030,Estrie,plex,2.0,136,80.6,not_published | |
| 585 | +Notre-Dame-des-Pins,29120,Chaudière-Appalaches,unifamilial,2.0,136,74.6,not_published | |
| 586 | +Sainte-Anne-des-Plaines,73035,Laurentides,plex,2.0,136,80.6,not_published | |
| 587 | +Beaulac-Garthby,31008,Chaudière-Appalaches,unifamilial,2.0,136,77.6,not_published | |
| 588 | +Sainte-Adèle,77022,Laurentides,condo,2.0,136,82.1,not_published | |
| 589 | +L'Île-Perrot,71060,Montérégie,plex,2.0,136,82.1,not_published | |
| 590 | +Saint-Léonard-de-Portneuf,34115,Capitale-Nationale,unifamilial,2.0,136,89.6,not_published | |
| 591 | +Châteauguay,67050,Montérégie,condo,2.5,135,65.7,not_published | |
| 592 | +Henryville,56042,Montérégie,unifamilial,2.0,135,82.1,not_published | |
| 593 | +Rivière-Héva,89010,Abitibi-Témiscamingue,unifamilial,2.0,135,82.1,not_published | |
| 594 | +Bois-des-Filion,73030,Laurentides,condo,2.0,134,71.6,not_published | |
| 595 | +Beaupré,21025,Capitale-Nationale,condo,2.0,134,77.6,not_published | |
| 596 | +Pincourt,71070,Montérégie,condo,2.0,134,70.1,not_published | |
| 597 | +Stanstead,45025,Estrie,unifamilial,2.0,134,83.6,not_published | |
| 598 | +Sainte-Ursule,51040,Mauricie,unifamilial,2.0,134,83.6,not_published | |
| 599 | +La Présentation,54035,Montérégie,unifamilial,2.0,134,82.1,not_published | |
| 600 | +Saint-Michel-de-Bellechasse,19110,Chaudière-Appalaches,unifamilial,2.0,133,82.1,not_published | |
| 601 | +Saint-Pamphile,17010,Chaudière-Appalaches,unifamilial,2.0,132,88.1,not_published | |
| 602 | +Les Éboulements,16048,Capitale-Nationale,unifamilial,2.0,132,85.1,not_published | |
| 603 | +Sainte-Anne-de-la-Pérade,37205,Mauricie,unifamilial,2.0,132,80.6,not_published | |
| 604 | +Otter Lake,84055,Outaouais,unifamilial,2.0,131,82.1,not_published | |
| 605 | +Saint-Alban,34097,Capitale-Nationale,unifamilial,2.0,131,73.1,not_published | |
| 606 | +La Malbaie,15013,Capitale-Nationale,plex,2.0,129,82.1,not_published | |
| 607 | +Pointe-des-Cascades,71055,Montérégie,unifamilial,2.0,129,82.1,not_published | |
| 608 | +Caplan,05060,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,129,86.6,not_published | |
| 609 | +Sainte-Christine-d'Auvergne,34105,Capitale-Nationale,unifamilial,2.0,129,85.1,not_published | |
| 610 | +Bowman,80145,Outaouais,unifamilial,2.0,128,83.6,not_published | |
| 611 | +Louiseville,51015,Mauricie,plex,2.0,128,82.1,not_published | |
| 612 | +Sainte-Hélène-de-Bagot,54095,Montérégie,unifamilial,2.0,127,74.6,not_published | |
| 613 | +Sainte-Catherine,67030,Montérégie,plex,2.0,126,80.6,not_published | |
| 614 | +Valcourt,42055,Estrie,unifamilial,3.0,126,70.1,not_published | |
| 615 | +Saint-Jacques-le-Mineur,68040,Montérégie,unifamilial,2.0,126,76.1,not_published | |
| 616 | +Tingwick,39025,Centre-du-Québec,unifamilial,2.0,126,79.1,not_published | |
| 617 | +Grand-Remous,83095,Outaouais,unifamilial,2.0,126,79.1,not_published | |
| 618 | +Sayabec,07085,Bas-Saint-Laurent,unifamilial,2.0,126,86.6,not_published | |
| 619 | +Saint-Bernard-de-Lacolle,68005,Montérégie,unifamilial,2.0,126,73.1,not_published | |
| 620 | +Charlemagne,60005,Lanaudière,condo,2.0,126,67.2,not_published | |
| 621 | +Montcalm,78055,Laurentides,unifamilial,3.0,125,76.1,not_published | |
| 622 | +Hérouxville,35035,Mauricie,unifamilial,2.0,125,85.1,not_published | |
| 623 | +Trois-Rives,35055,Mauricie,unifamilial,2.0,125,79.1,not_published | |
| 624 | +L'Épiphanie,60037,Lanaudière,plex,2.0,125,85.1,not_published | |
| 625 | +Grenville,76055,Laurentides,unifamilial,2.0,125,86.6,not_published | |
| 626 | +Saint-Alexandre-de-Kamouraska,14035,Bas-Saint-Laurent,unifamilial,2.0,124,74.6,not_published | |
| 627 | +Bristol,84005,Outaouais,unifamilial,2.0,124,76.1,not_published | |
| 628 | +Sainte-Madeleine,54025,Montérégie,unifamilial,2.0,124,76.1,not_published | |
| 629 | +Windsor,42088,Estrie,plex,2.0,124,82.1,not_published | |
| 630 | +Frontenac,30025,Estrie,unifamilial,2.0,124,83.6,not_published | |
| 631 | +Sainte-Justine,28045,Chaudière-Appalaches,unifamilial,2.0,124,76.1,not_published | |
| 632 | +Low,83010,Outaouais,unifamilial,2.0,124,85.1,not_published | |
| 633 | +Saint-Robert,53020,Montérégie,unifamilial,2.0,123,85.1,not_published | |
| 634 | +Saint-Albert,39085,Centre-du-Québec,unifamilial,2.0,122,83.6,not_published | |
| 635 | +Price,09065,Bas-Saint-Laurent,unifamilial,2.0,122,79.1,not_published | |
| 636 | +Saint-Herménégilde,44015,Estrie,unifamilial,2.0,122,76.1,not_published | |
| 637 | +Ayer's Cliff,45035,Estrie,unifamilial,2.0,121,80.6,not_published | |
| 638 | +Donnacona,34025,Capitale-Nationale,plex,2.0,121,77.6,not_published | |
| 639 | +Saint-Laurent-de-l'Île-d'Orléans,20020,Capitale-Nationale,unifamilial,2.0,121,74.6,not_published | |
| 640 | +Saint-Valérien-de-Milton,54065,Montérégie,unifamilial,2.0,120,83.6,not_published | |
| 641 | +Wotton,40017,Estrie,unifamilial,2.0,120,82.1,not_published | |
| 642 | +Cacouna,12057,Bas-Saint-Laurent,unifamilial,2.0,120,77.6,not_published | |
| 643 | +Senneterre,89045,Abitibi-Témiscamingue,unifamilial,2.0,119,82.1,not_published | |
| 644 | +Cleveland,42110,Estrie,unifamilial,2.0,119,77.6,not_published | |
| 645 | +Lyster,32065,Centre-du-Québec,unifamilial,2.0,119,76.1,not_published | |
| 646 | +Contrecoeur,59035,Montérégie,plex,2.0,119,74.6,not_published | |
| 647 | +Saint-Félix-d'Otis,94225,Saguenay–Lac-Saint-Jean,unifamilial,2.0,119,71.6,not_published | |
| 648 | +Saint-Gervais,19075,Chaudière-Appalaches,unifamilial,2.0,119,82.1,not_published | |
| 649 | +Saint-Louis-de-Blandford,39170,Centre-du-Québec,unifamilial,2.0,118,74.6,not_published | |
| 650 | +Saint-Aubert,17055,Chaudière-Appalaches,unifamilial,2.0,118,73.1,not_published | |
| 651 | +Notre-Dame-de-Pontmain,79010,Laurentides,unifamilial,2.0,118,83.6,not_published | |
| 652 | +Saint-Esprit,63030,Lanaudière,unifamilial,2.0,117,82.1,not_published | |
| 653 | +Saint-Paulin,51060,Mauricie,unifamilial,2.0,117,80.6,not_published | |
| 654 | +Saint-Zacharie,28005,Chaudière-Appalaches,unifamilial,2.0,117,74.6,not_published | |
| 655 | +Vallée-Jonction,26015,Chaudière-Appalaches,unifamilial,2.0,117,82.1,not_published | |
| 656 | +Frampton,26005,Chaudière-Appalaches,unifamilial,2.0,117,73.1,not_published | |
| 657 | +Saint-Théodore-d'Acton,48045,Montérégie,unifamilial,2.0,117,77.6,not_published | |
| 658 | +Bois-des-Filion,73030,Laurentides,plex,2.0,117,73.1,not_published | |
| 659 | +Saint-Clet,71045,Montérégie,unifamilial,2.0,116,79.1,not_published | |
| 660 | +Saint-Sauveur,77043,Laurentides,condo,2.0,116,73.1,not_published | |
| 661 | +Pointe-aux-Outardes,96030,Côte-Nord,unifamilial,2.0,116,77.6,not_published | |
| 662 | +Port-Daniel–Gascons,02047,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,116,80.6,not_published | |
| 663 | +L'Isle-Verte,12043,Bas-Saint-Laurent,unifamilial,2.0,116,74.6,not_published | |
| 664 | +Denholm,83005,Outaouais,unifamilial,2.0,115,76.1,not_published | |
| 665 | +Saint-Félicien,91042,Saguenay–Lac-Saint-Jean,plex,2.0,115,83.6,not_published | |
| 666 | +Stukely-Sud,45105,Estrie,unifamilial,2.0,115,76.1,not_published | |
| 667 | +L'Île-Perrot,71060,Montérégie,condo,2.0,115,70.1,not_published | |
| 668 | +Cantley,82020,Outaouais,plex,2.0,114,71.6,not_published | |
| 669 | +Les Îles-de-la-Madeleine,01023,Gaspésie–Îles-de-la-Madeleine,plex,2.0,114,83.6,not_published | |
| 670 | +Carignan,57010,Montérégie,condo,2.0,114,74.6,not_published | |
| 671 | +Disraeli,31020,Chaudière-Appalaches,unifamilial,2.0,114,83.6,not_published | |
| 672 | +Notre-Dame-de-Montauban,35005,Mauricie,unifamilial,2.0,113,85.1,not_published | |
| 673 | +Chénéville,80103,Outaouais,unifamilial,2.0,113,77.6,not_published | |
| 674 | +Saint-Alexis,63023,Lanaudière,unifamilial,2.0,113,76.1,not_published | |
| 675 | +L'Ascension,79050,Laurentides,unifamilial,2.0,113,64.2,not_published | |
| 676 | +L'Anse-Saint-Jean,94210,Saguenay–Lac-Saint-Jean,unifamilial,2.0,113,71.6,not_published | |
| 677 | +Contrecoeur,59035,Montérégie,condo,2.0,113,70.1,not_published | |
| 678 | +Saint-Joachim-de-Shefford,47040,Estrie,unifamilial,2.0,112,73.1,not_published | |
| 679 | +Eeyou Istchee Baie-James,99060,Nord-du-Québec,unifamilial,2.0,112,83.6,not_published | |
| 680 | +Lac-Sergent,34120,Capitale-Nationale,unifamilial,2.0,112,76.1,not_published | |
| 681 | +Mont-Carmel,14005,Bas-Saint-Laurent,unifamilial,2.0,112,79.1,not_published | |
| 682 | +Sainte-Angèle-de-Monnoir,55030,Montérégie,unifamilial,2.0,112,76.1,not_published | |
| 683 | +Val-Joli,42095,Estrie,unifamilial,2.0,112,85.1,not_published | |
| 684 | +McMasterville,57025,Montérégie,plex,2.0,111,80.6,not_published | |
| 685 | +Montréal-Est,66007,Montréal,unifamilial,2.0,111,85.1,not_published | |
| 686 | +Wentworth,76035,Laurentides,unifamilial,2.0,111,79.1,not_published | |
| 687 | +La Doré,91050,Saguenay–Lac-Saint-Jean,unifamilial,2.0,110,79.1,not_published | |
| 688 | +Rivière-à-Pierre,34135,Capitale-Nationale,unifamilial,2.0,110,71.6,not_published | |
| 689 | +Saint-Gabriel-de-Rimouski,09025,Bas-Saint-Laurent,unifamilial,2.0,110,73.1,not_published | |
| 690 | +Saint-Raymond,34128,Capitale-Nationale,plex,2.0,110,74.6,not_published | |
| 691 | +Petite-Rivière-Saint-François,16005,Capitale-Nationale,unifamilial,2.0,110,82.1,not_published | |
| 692 | +Saint-Aimé-des-Lacs,15030,Capitale-Nationale,unifamilial,2.0,110,83.6,not_published | |
| 693 | +Sainte-Agathe-de-Lotbinière,33017,Chaudière-Appalaches,unifamilial,2.0,110,85.1,not_published | |
| 694 | +Saint-Claude,42100,Estrie,unifamilial,2.0,110,71.6,not_published | |
| 695 | +Notre-Dame-de-la-Salette,80087,Outaouais,unifamilial,2.0,110,76.1,not_published | |
| 696 | +Saint-Jean-de-Dieu,11010,Bas-Saint-Laurent,unifamilial,2.0,110,79.1,not_published | |
| 697 | +Saint-Narcisse-de-Rimouski,10015,Bas-Saint-Laurent,unifamilial,2.0,109,73.1,not_published | |
| 698 | +Saint-Hugues,54100,Montérégie,unifamilial,2.0,109,77.6,not_published | |
| 699 | +Saint-Siméon,15058,Capitale-Nationale,unifamilial,2.0,109,74.6,not_published | |
| 700 | +Franklin,69010,Montérégie,unifamilial,2.0,109,86.6,not_published | |
| 701 | +Saint-Didace,52090,Lanaudière,unifamilial,2.0,108,82.1,not_published | |
| 702 | +Pointe-Lebel,96025,Côte-Nord,unifamilial,2.0,108,71.6,not_published | |
| 703 | +Varennes,59020,Montérégie,plex,2.0,107,73.1,not_published | |
| 704 | +Saint-Mathieu-de-Rioux,11050,Bas-Saint-Laurent,unifamilial,2.0,107,71.6,not_published | |
| 705 | +Saint-Justin,51045,Mauricie,unifamilial,1.0,107,79.1,not_published | |
| 706 | +L'Assomption,60028,Lanaudière,condo,2.0,106,67.2,not_published | |
| 707 | +Lamarche,93060,Saguenay–Lac-Saint-Jean,unifamilial,2.0,106,76.1,not_published | |
| 708 | +Saint-Éphrem-de-Beauce,29112,Chaudière-Appalaches,unifamilial,2.0,106,74.6,not_published | |
| 709 | +Saint-Joachim,21020,Capitale-Nationale,unifamilial,2.0,106,74.6,not_published | |
| 710 | +Notre-Dame-du-Portage,12080,Bas-Saint-Laurent,unifamilial,2.0,106,79.1,not_published | |
| 711 | +Saint-Benoît-Labre,29100,Chaudière-Appalaches,unifamilial,2.0,106,74.6,not_published | |
| 712 | +Sainte-Julie,59010,Montérégie,plex,2.0,105,79.1,not_published | |
| 713 | +Saint-Eugène,49105,Centre-du-Québec,unifamilial,2.0,105,71.6,not_published | |
| 714 | +Saint-Armand,46017,Estrie,unifamilial,2.0,105,71.6,not_published | |
| 715 | +Sacré-Coeur,95010,Côte-Nord,unifamilial,2.0,105,73.1,not_published | |
| 716 | +Nantes,30045,Estrie,unifamilial,2.0,105,71.6,not_published | |
| 717 | +Saint-Casimir,34078,Capitale-Nationale,unifamilial,2.0,105,74.6,not_published | |
| 718 | +Saint-Adelphe,35015,Mauricie,unifamilial,2.0,105,70.1,not_published | |
| 719 | +Saint-Pierre-les-Becquets,38065,Centre-du-Québec,unifamilial,2.0,104,58.2,not_published | |
| 720 | +Batiscan,37210,Mauricie,unifamilial,1.0,104,82.1,not_published | |
| 721 | +Sainte-Hénédine,26040,Chaudière-Appalaches,unifamilial,2.0,104,71.6,not_published | |
| 722 | +Saint-Cyprien-de-Napierville,68035,Montérégie,unifamilial,2.0,104,79.1,not_published | |
| 723 | +Mont-Joli,09077,Bas-Saint-Laurent,plex,2.0,103,76.1,not_published | |
| 724 | +Saint-Guillaume,49113,Centre-du-Québec,unifamilial,2.0,103,74.6,not_published | |
| 725 | +Bouchette,83050,Outaouais,unifamilial,2.0,103,79.1,not_published | |
| 726 | +Sainte-Geneviève-de-Batiscan,37215,Mauricie,unifamilial,2.0,103,71.6,not_published | |
| 727 | +Saint-Narcisse,37240,Mauricie,unifamilial,1.0,103,74.6,not_published | |
| 728 | +Montréal-Est,66007,Montréal,plex,2.0,103,76.1,not_published | |
| 729 | +L'Ancienne-Lorette,23057,Capitale-Nationale,condo,1.0,103,79.1,not_published | |
| 730 | +Racine,42032,Estrie,unifamilial,2.0,102,71.6,not_published | |
| 731 | +Blue Sea,83045,Outaouais,unifamilial,2.0,101,73.1,not_published | |
| 732 | +Sainte-Brigide-d'Iberville,56105,Montérégie,unifamilial,2.0,101,77.6,not_published | |
| 733 | +Boucherville,58033,Montérégie,plex,2.0,101,76.1,not_published | |
| 734 | +Preissac,88090,Abitibi-Témiscamingue,unifamilial,2.0,100,73.1,not_published | |
| 735 | +Saint-Simon,54090,Montérégie,unifamilial,2.0,100,67.2,not_published | |
| 736 | +Matagami,99015,Nord-du-Québec,unifamilial,2.0,100,68.7,not_published | |
| 737 | +Tring-Jonction,27060,Chaudière-Appalaches,unifamilial,2.0,100,64.2,not_published | |
| 738 | +Brownsburg-Chatham,76043,Laurentides,plex,2.0,100,71.6,not_published | |
| 739 | +Saint-Pacôme,14070,Bas-Saint-Laurent,unifamilial,2.0,99,77.6,not_published | |
| 740 | +Nicolet,50072,Centre-du-Québec,plex,2.0,99,76.1,not_published | |
| 741 | +Notre-Dame-du-Nord,85090,Abitibi-Témiscamingue,unifamilial,2.0,99,77.6,not_published | |
| 742 | +Aumond,83090,Outaouais,unifamilial,2.0,99,70.1,not_published | |
| 743 | +Sainte-Jeanne-d'Arc,92015,Saguenay–Lac-Saint-Jean,unifamilial,2.0,99,68.7,not_published | |
| 744 | +Kazabazua,83015,Outaouais,unifamilial,2.0,99,74.6,not_published | |
| 745 | +Terrasse-Vaudreuil,71075,Montérégie,unifamilial,2.0,99,71.6,not_published | |
| 746 | +Saint-Paul-de-Montminy,18030,Chaudière-Appalaches,unifamilial,2.0,98,68.7,not_published | |
| 747 | +Rivière-Bleue,13025,Bas-Saint-Laurent,unifamilial,2.0,98,73.1,not_published | |
| 748 | +Saint-Patrice-de-Sherrington,68025,Montérégie,unifamilial,1.0,98,77.6,not_published | |
| 749 | +Nouvelle,06020,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,98,76.1,not_published | |
| 750 | +Bromont,46078,Estrie,condo,1.0,98,73.1,not_published | |
| 751 | +Saint-Joseph-de-Sorel,53050,Montérégie,unifamilial,2.0,97,70.1,not_published | |
| 752 | +Saint-Jean-de-l'Île-d'Orléans,20015,Capitale-Nationale,unifamilial,2.0,97,61.2,not_published | |
| 753 | +Roxton Falls,48010,Montérégie,unifamilial,2.0,97,71.6,not_published | |
| 754 | +Sainte-Aurélie,28015,Chaudière-Appalaches,unifamilial,2.0,97,73.1,not_published | |
| 755 | +Desbiens,93005,Saguenay–Lac-Saint-Jean,unifamilial,2.0,97,74.6,not_published | |
| 756 | +L'Avenir,49025,Centre-du-Québec,unifamilial,2.0,97,73.1,not_published | |
| 757 | +Clarendon,84015,Outaouais,unifamilial,2.0,97,71.6,not_published | |
| 758 | +Montebello,80010,Outaouais,unifamilial,2.0,97,73.1,not_published | |
| 759 | +La Sarre,87090,Abitibi-Témiscamingue,plex,2.0,96,74.6,not_published | |
| 760 | +Frelighsburg,46010,Estrie,unifamilial,2.0,96,74.6,not_published | |
| 761 | +Saint-Joseph-de-Sorel,53050,Montérégie,plex,2.0,96,71.6,not_published | |
| 762 | +Kiamika,79025,Laurentides,unifamilial,2.0,96,68.7,not_published | |
| 763 | +Sainte-Perpétue,17030,Chaudière-Appalaches,unifamilial,1.5,96,71.6,not_published | |
| 764 | +Charlemagne,60005,Lanaudière,plex,2.0,96,74.6,not_published | |
| 765 | +Thurso,80050,Outaouais,plex,2.0,96,62.7,not_published | |
| 766 | +Saint-Georges-de-Windsor,40032,Estrie,unifamilial,2.0,95,73.1,not_published | |
| 767 | +Notre-Dame-des-Neiges,11045,Bas-Saint-Laurent,unifamilial,2.0,95,62.7,not_published | |
| 768 | +Berthierville,52035,Lanaudière,plex,2.0,94,73.1,not_published | |
| 769 | +Saint-Benjamin,28025,Chaudière-Appalaches,unifamilial,1.0,94,77.6,not_published | |
| 770 | +Saint-René-de-Matane,08035,Bas-Saint-Laurent,unifamilial,1.0,94,79.1,not_published | |
| 771 | +Saint-Édouard-de-Lotbinière,33080,Chaudière-Appalaches,unifamilial,2.0,94,74.6,not_published | |
| 772 | +Saint-Lambert,58012,Montérégie,plex,2.0,94,73.1,not_published | |
| 773 | +Saint-Jude,54110,Montérégie,unifamilial,2.0,94,73.1,not_published | |
| 774 | +Saint-Aimé-du-Lac-des-Îles,79022,Laurentides,unifamilial,2.0,93,71.6,not_published | |
| 775 | +La Patrie,41027,Estrie,unifamilial,1.0,93,79.1,not_published | |
| 776 | +Saint-Louis,54120,Montérégie,unifamilial,2.0,93,67.2,not_published | |
| 777 | +Lorrainville,85037,Abitibi-Témiscamingue,unifamilial,2.0,93,74.6,not_published | |
| 778 | +Marieville,55048,Montérégie,condo,2.0,93,56.7,not_published | |
| 779 | +East Angus,41060,Estrie,plex,2.0,93,77.6,not_published | |
| 780 | +Saint-Ferréol-les-Neiges,21010,Capitale-Nationale,condo,1.0,93,70.1,not_published | |
| 781 | +Palmarolle,87025,Abitibi-Témiscamingue,unifamilial,2.0,92,68.7,not_published | |
| 782 | +Inverness,32058,Centre-du-Québec,unifamilial,1.0,92,71.6,not_published | |
| 783 | +La Guadeloupe,29030,Chaudière-Appalaches,unifamilial,1.0,92,70.1,not_published | |
| 784 | +Saint-Basile-le-Grand,57020,Montérégie,condo,2.0,92,50.7,not_published | |
| 785 | +Sainte-Rose-de-Watford,28030,Chaudière-Appalaches,unifamilial,2.0,91,73.1,not_published | |
| 786 | +Saint-Léon-de-Standon,19020,Chaudière-Appalaches,unifamilial,2.0,91,68.7,not_published | |
| 787 | +Lac-Saguay,79060,Laurentides,unifamilial,2.0,91,71.6,not_published | |
| 788 | +Notre-Dame-Auxiliatrice-de-Buckland,19010,Chaudière-Appalaches,unifamilial,1.0,91,74.6,not_published | |
| 789 | +Waterloo,47025,Estrie,plex,1.0,90,67.2,not_published | |
| 790 | +Sainte-Marguerite,26035,Chaudière-Appalaches,unifamilial,1.0,90,67.2,not_published | |
| 791 | +Mont-Blanc,78047,Laurentides,plex,2.0,90,65.7,not_published | |
| 792 | +Saint-Nérée-de-Bellechasse,19045,Chaudière-Appalaches,unifamilial,2.0,90,65.7,not_published | |
| 793 | +Marston,30035,Estrie,unifamilial,2.0,89,71.6,not_published | |
| 794 | +Drummondville,49058,Centre-du-Québec,condo,2.0,89,67.2,published | |
| 795 | +Saint-Philémon,19005,Chaudière-Appalaches,unifamilial,2.0,89,71.6,not_published | |
| 796 | +Saint-Cyrille-de-Lessard,17045,Chaudière-Appalaches,unifamilial,2.0,89,73.1,not_published | |
| 797 | +Saint-Odilon-de-Cranbourne,27035,Chaudière-Appalaches,unifamilial,2.0,88,71.6,not_published | |
| 798 | +Val-Alain,33070,Chaudière-Appalaches,unifamilial,2.0,88,62.7,not_published | |
| 799 | +Sainte-Sophie-de-Lévrard,38040,Centre-du-Québec,unifamilial,2.0,88,65.7,not_published | |
| 800 | +Saint-Bruno-de-Guigues,85045,Abitibi-Témiscamingue,unifamilial,1.0,88,70.1,not_published | |
| 801 | +Lac-du-Cerf,79015,Laurentides,unifamilial,2.0,88,68.7,not_published | |
| 802 | +Godmanchester,69060,Montérégie,unifamilial,2.0,88,73.1,not_published | |
| 803 | +Très-Saint-Rédempteur,71125,Montérégie,unifamilial,2.0,87,65.7,not_published | |
| 804 | +Richmond,42098,Estrie,plex,2.0,87,70.1,not_published | |
| 805 | +La Corne,88030,Abitibi-Témiscamingue,unifamilial,2.0,87,58.2,not_published | |
| 806 | +Brébeuf,78075,Laurentides,unifamilial,2.0,86,73.1,not_published | |
| 807 | +Saint-Étienne-de-Bolton,45100,Estrie,unifamilial,1.0,86,74.6,not_published | |
| 808 | +Saint-Urbain-Premier,70005,Montérégie,unifamilial,2.0,86,64.2,not_published | |
| 809 | +Sainte-Flavie,09085,Bas-Saint-Laurent,unifamilial,2.0,86,62.7,not_published | |
| 810 | +Saint-Ludger-de-Milot,93080,Saguenay–Lac-Saint-Jean,unifamilial,2.0,85,68.7,not_published | |
| 811 | +Ham-Nord,39010,Centre-du-Québec,unifamilial,2.0,85,70.1,not_published | |
| 812 | +Sorel-Tracy,53052,Montérégie,condo,2.0,85,68.7,not_published | |
| 813 | +Saint-Valère,39135,Centre-du-Québec,unifamilial,2.0,85,70.1,not_published | |
| 814 | +Saint-Fabien-de-Panet,18015,Chaudière-Appalaches,unifamilial,2.0,84,65.7,not_published | |
| 815 | +Ragueneau,96040,Côte-Nord,unifamilial,2.0,84,68.7,not_published | |
| 816 | +Saint-Rémi,68055,Montérégie,condo,2.0,84,50.7,not_published | |
| 817 | +Saint-René,29050,Chaudière-Appalaches,unifamilial,1.0,84,76.1,not_published | |
| 818 | +Baie-Saint-Paul,16013,Capitale-Nationale,plex,2.0,84,65.7,not_published | |
| 819 | +Saint-Édouard-de-Maskinongé,51050,Mauricie,unifamilial,2.0,84,65.7,not_published | |
| 820 | +North Hatley,45050,Estrie,unifamilial,2.0,84,65.7,not_published | |
| 821 | +Deschaillons-sur-Saint-Laurent,38070,Centre-du-Québec,unifamilial,1.0,84,68.7,not_published | |
| 822 | +Lac-Brome,46075,Estrie,condo,2.0,84,68.7,not_published | |
| 823 | +Saint-Constant,67035,Montérégie,condo,2.0,83,43.3,not_published | |
| 824 | +Grande-Vallée,03020,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,83,67.2,not_published | |
| 825 | +Notre-Dame-des-Prairies,61030,Lanaudière,plex,2.0,83,62.7,not_published | |
| 826 | +Saint-Norbert-d'Arthabaska,39043,Centre-du-Québec,unifamilial,1.0,83,76.1,not_published | |
| 827 | +Fort-Coulonge,84060,Outaouais,unifamilial,2.0,83,61.2,not_published | |
| 828 | +Saint-Magloire,28075,Chaudière-Appalaches,unifamilial,2.0,83,62.7,not_published | |
| 829 | +Saint-Hilarion,16050,Capitale-Nationale,unifamilial,2.0,83,67.2,not_published | |
| 830 | +Plaisance,80045,Outaouais,unifamilial,1.0,83,80.6,not_published | |
| 831 | +Saint-Rémi,68055,Montérégie,plex,1.0,82,74.6,not_published | |
| 832 | +Saint-Joseph-du-Lac,72025,Laurentides,plex,1.5,82,65.7,not_published | |
| 833 | +Dosquet,33040,Chaudière-Appalaches,unifamilial,1.0,82,68.7,not_published | |
| 834 | +Malartic,89015,Abitibi-Témiscamingue,plex,2.0,82,67.2,not_published | |
| 835 | +Saint-Valérien,10060,Bas-Saint-Laurent,unifamilial,1.0,82,71.6,not_published | |
| 836 | +Sainte-Anne-de-Beaupré,21030,Capitale-Nationale,plex,1.0,82,70.1,not_published | |
| 837 | +Sainte-Eulalie,50005,Centre-du-Québec,unifamilial,2.0,82,62.7,not_published | |
| 838 | +Saint-Camille-de-Lellis,28070,Chaudière-Appalaches,unifamilial,1.0,82,70.1,not_published | |
| 839 | +Saint-Donat,09030,Bas-Saint-Laurent,unifamilial,2.0,81,61.2,not_published | |
| 840 | +Laurierville,32072,Centre-du-Québec,unifamilial,2.0,81,64.2,not_published | |
| 841 | +Saint-Honoré-de-Shenley,29038,Chaudière-Appalaches,unifamilial,1.5,81,62.7,not_published | |
| 842 | +Sainte-Monique,93075,Saguenay–Lac-Saint-Jean,unifamilial,1.0,81,62.7,not_published | |
| 843 | +Lotbinière,33115,Chaudière-Appalaches,unifamilial,2.0,81,68.7,not_published | |
| 844 | +Vaudreuil-sur-le-Lac,71090,Montérégie,unifamilial,1.0,81,73.1,not_published | |
| 845 | +Girardville,92055,Saguenay–Lac-Saint-Jean,unifamilial,1.5,81,65.7,not_published | |
| 846 | +Saint-Ludger,30072,Estrie,unifamilial,1.0,80,74.6,not_published | |
| 847 | +Carignan,57010,Montérégie,plex,2.0,80,65.7,not_published | |
| 848 | +Saint-Flavien,33052,Chaudière-Appalaches,unifamilial,2.0,80,64.2,not_published | |
| 849 | +Saint-Janvier-de-Joly,33065,Chaudière-Appalaches,unifamilial,1.5,80,65.7,not_published | |
| 850 | +Val-Brillant,07080,Bas-Saint-Laurent,unifamilial,2.0,79,58.2,not_published | |
| 851 | +Saint-Majorique-de-Grantham,49095,Centre-du-Québec,unifamilial,1.0,79,73.1,not_published | |
| 852 | +Saint-Barnabé,51025,Mauricie,unifamilial,1.0,79,67.2,not_published | |
| 853 | +Gaspé,03005,Gaspésie–Îles-de-la-Madeleine,plex,2.0,79,62.7,not_published | |
| 854 | +New Carlisle,05040,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,79,70.1,not_published | |
| 855 | +Saint-Wenceslas,50023,Centre-du-Québec,unifamilial,1.0,79,68.7,not_published | |
| 856 | +Saint-Siméon,05055,Gaspésie–Îles-de-la-Madeleine,unifamilial,2.0,79,64.2,not_published | |
| 857 | +L'Isle-aux-Coudres,16023,Capitale-Nationale,unifamilial,2.0,79,68.7,not_published | |
| 858 | +Lac-Delage,22030,Capitale-Nationale,unifamilial,2.0,79,52.2,not_published | |
| 859 | +Saint-Césaire,55023,Montérégie,plex,1.0,79,61.2,not_published | |
| 860 | +Manseau,38028,Centre-du-Québec,unifamilial,2.0,79,65.7,not_published | |
| 861 | +Plessisville,32043,Centre-du-Québec,plex,1.0,78,59.7,not_published | |
| 862 | +Saint-Donat,62060,Lanaudière,plex,1.0,78,65.7,not_published | |
| 863 | +Bécancour,38010,Centre-du-Québec,plex,1.0,78,67.2,not_published | |
| 864 | +Acton Vale,48028,Montérégie,plex,1.0,78,65.7,not_published | |
| 865 | +Lac-Drolet,30080,Estrie,unifamilial,2.0,78,64.2,not_published | |
| 866 | +Saint-Norbert,52070,Lanaudière,unifamilial,2.0,78,62.7,not_published | |
| 867 | +Lac-au-Saumon,07057,Bas-Saint-Laurent,unifamilial,1.0,78,67.2,not_published | |
| 868 | +Saint-François-de-la-Rivière-du-Sud,18060,Chaudière-Appalaches,unifamilial,1.0,77,65.7,not_published | |
| 869 | +Saint-Augustin-de-Desmaures,23072,Capitale-Nationale,plex,2.0,77,61.2,not_published | |
| 870 | +Saint-Luc-de-Bellechasse,28060,Chaudière-Appalaches,unifamilial,1.5,77,65.7,not_published | |
| 871 | +Hemmingford,68010,Montérégie,unifamilial,1.0,77,68.7,not_published | |
| 872 | +Saint-Épiphane,12030,Bas-Saint-Laurent,unifamilial,2.0,76,65.7,not_published | |
| 873 | +Saint-Édouard,68045,Montérégie,unifamilial,1.0,76,64.2,not_published | |
| 874 | +Chibougamau,99025,Nord-du-Québec,plex,1.5,76,62.7,not_published | |
| 875 | +Laverlochère-Angliers,85052,Abitibi-Témiscamingue,unifamilial,1.5,76,59.7,not_published | |
| 876 | +Crabtree,61013,Lanaudière,plex,1.0,76,67.2,not_published | |
| 877 | +Saint-Félix-de-Valois,62007,Lanaudière,plex,1.5,76,65.7,not_published | |
| 878 | +Rivière-Ouelle,14065,Bas-Saint-Laurent,unifamilial,1.0,76,67.2,not_published | |
| 879 | +Boileau,80115,Outaouais,unifamilial,1.0,76,65.7,not_published | |
| 880 | +Saint-Isidore-de-Clifton,41012,Estrie,unifamilial,1.0,76,59.7,not_published | |
| 881 | +Chute-aux-Outardes,96035,Côte-Nord,unifamilial,1.5,76,65.7,not_published | |
| 882 | +Saint-Henri-de-Taillon,93070,Saguenay–Lac-Saint-Jean,unifamilial,2.0,76,64.2,not_published | |
| 883 | +Charette,51080,Mauricie,unifamilial,1.0,76,67.2,not_published | |
| 884 | +Saint-Thomas-Didyme,92045,Saguenay–Lac-Saint-Jean,unifamilial,1.0,76,62.7,not_published | |
| 885 | +Sainte-Marie-Salomé,63005,Lanaudière,unifamilial,2.0,75,64.2,not_published | |
| 886 | +Sainte-Élisabeth,52030,Lanaudière,unifamilial,1.5,75,59.7,not_published | |
| 887 | +Saint-Émile-de-Suffolk,80125,Outaouais,unifamilial,1.0,75,70.1,not_published | |
| 888 | +Sutton,46058,Estrie,plex,2.0,75,64.2,not_published | |
| 889 | +Chesterville,39030,Centre-du-Québec,unifamilial,2.0,75,52.2,not_published | |
| 890 | +Saint-Georges,29073,Chaudière-Appalaches,condo,2.0,75,56.7,not_published | |
| 891 | +Côte-Saint-Luc,66058,Montréal,plex,1.0,75,65.7,not_published | |
| 892 | +Sainte-Pétronille,20030,Capitale-Nationale,unifamilial,1.0,75,64.2,not_published | |
| 893 | +Sainte-Thérèse-de-la-Gatineau,83055,Outaouais,unifamilial,1.5,75,62.7,not_published | |
| 894 | +Sainte-Anne-du-Lac,79115,Laurentides,unifamilial,1.0,74,62.7,not_published | |
| 895 | +Richelieu,55057,Montérégie,plex,1.0,74,68.7,not_published | |
| 896 | +Maniwaki,83065,Outaouais,plex,1.0,74,64.2,not_published | |
| 897 | +Saint-Rémi-de-Tingwick,39020,Centre-du-Québec,unifamilial,1.0,73,62.7,not_published | |
| 898 | +Saint-Séverin,35020,Mauricie,unifamilial,2.0,73,62.7,not_published | |
| 899 | +Sainte-Anne-des-Monts,04037,Gaspésie–Îles-de-la-Madeleine,plex,1.0,73,61.2,not_published | |
| 900 | +Notre-Dame-de-la-Paix,80020,Outaouais,unifamilial,1.0,73,65.7,not_published | |
| 901 | +Hatley,45043,Estrie,unifamilial,2.0,73,58.2,not_published | |
| 902 | +Bromont,46078,Estrie,plex,1.0,73,65.7,not_published | |
| 903 | +Amqui,07047,Bas-Saint-Laurent,plex,2.0,73,56.7,not_published | |
| 904 | +Namur,80110,Outaouais,unifamilial,1.0,73,64.2,not_published | |
| 905 | +Dupuy,87085,Abitibi-Témiscamingue,unifamilial,2.0,72,59.7,not_published | |
| 906 | +Saint-Louis-du-Ha! Ha!,13080,Bas-Saint-Laurent,unifamilial,1.5,72,59.7,not_published | |
| 907 | +Lac-Saint-Paul,79105,Laurentides,unifamilial,1.0,72,58.2,not_published | |
| 908 | +Trécesson,88075,Abitibi-Témiscamingue,unifamilial,2.0,72,65.7,not_published | |
| 909 | +Saint-Irénée,15005,Capitale-Nationale,unifamilial,1.0,72,70.1,not_published | |
| 910 | +Baie-du-Febvre,50100,Centre-du-Québec,unifamilial,1.0,72,62.7,not_published | |
| 911 | +Estérel,77011,Laurentides,unifamilial,1.0,72,62.7,not_published | |
| 912 | +Sainte-Sabine,46105,Estrie,unifamilial,1.0,71,59.7,not_published | |
| 913 | +Saint-Marc-de-Figuery,88040,Abitibi-Témiscamingue,unifamilial,2.0,71,58.2,not_published | |
| 914 | +Stoneham-et-Tewkesbury,22035,Capitale-Nationale,plex,2.0,71,61.2,not_published | |
| 915 | +Saint-Roch-de-Mékinac,35045,Mauricie,unifamilial,2.0,71,56.7,not_published | |
| 916 | +Durham-Sud,49015,Centre-du-Québec,unifamilial,1.0,71,58.2,not_published | |
| 917 | +Saint-Modeste,12020,Bas-Saint-Laurent,unifamilial,2.0,71,55.2,not_published | |
| 918 | +Hébertville,93022,Saguenay–Lac-Saint-Jean,plex,1.0,71,67.2,not_published | |
| 919 | +Beauceville,27028,Chaudière-Appalaches,plex,1.0,71,67.2,not_published | |
| 920 | +Grandes-Piles,35040,Mauricie,unifamilial,2.0,71,64.2,not_published | |
| 921 | +Sainte-Hedwidge,91030,Saguenay–Lac-Saint-Jean,unifamilial,1.0,71,62.7,not_published | |
| 922 | +Roxton,48015,Montérégie,unifamilial,1.0,71,65.7,not_published | |
| 923 | +Cookshire-Eaton,41038,Estrie,plex,2.0,71,61.2,not_published | |
| 924 | +Bolton-Ouest,46065,Estrie,unifamilial,1.0,71,68.7,not_published | |
| 925 | +Landrienne,88035,Abitibi-Témiscamingue,unifamilial,1.0,70,65.7,not_published | |
| 926 | +Très-Saint-Sacrement,69030,Montérégie,unifamilial,2.0,70,59.7,not_published | |
| 927 | +Howick,69025,Montérégie,unifamilial,1.0,70,56.7,not_published | |
| 928 | +Saint-Cyprien,12005,Bas-Saint-Laurent,unifamilial,1.0,70,55.2,not_published | |
| 929 | +Morin-Heights,77050,Laurentides,plex,1.0,70,62.7,not_published | |
| 930 | +Salaberry-de-Valleyfield,70052,Montérégie,condo,1.0,70,61.2,not_published | |
| 931 | +Saint-Gabriel,52080,Lanaudière,plex,1.0,70,71.6,not_published | |
| 932 | +Huberdeau,78065,Laurentides,unifamilial,1.0,70,59.7,not_published | |
| 933 | +Montréal-Est,66007,Montréal,condo,1.0,70,61.2,not_published | |
| 934 | +Kirkland,66102,Montréal,condo,1.0,69,55.2,not_published | |
| 935 | +Bégin,94250,Saguenay–Lac-Saint-Jean,unifamilial,1.0,69,62.7,not_published | |
| 936 | +Mulgrave-et-Derry,80085,Outaouais,unifamilial,1.0,69,64.2,not_published | |
| 937 | +Saint-Pierre-de-l'Île-d'Orléans,20025,Capitale-Nationale,unifamilial,1.0,69,65.7,not_published | |
| 938 | +Sainte-Praxède,31050,Chaudière-Appalaches,unifamilial,1.0,69,64.2,not_published | |
| 939 | +Sainte-Justine-de-Newton,71115,Montérégie,unifamilial,2.0,68,53.7,not_published | |
| 940 | +Saint-Roch-des-Aulnaies,17065,Chaudière-Appalaches,unifamilial,2.0,68,59.7,not_published | |
| 941 | +Mont-Saint-Michel,79110,Laurentides,unifamilial,1.0,68,62.7,not_published | |
| 942 | +Saint-Étienne-de-Beauharnois,70030,Montérégie,unifamilial,1.0,68,65.7,not_published | |
| 943 | +Joliette,61025,Lanaudière,condo,1.0,68,44.8,not_published | |
| 944 | +Saint-Urbain,16055,Capitale-Nationale,unifamilial,1.0,68,61.2,not_published | |
| 945 | +Valcourt,42060,Estrie,unifamilial,1.0,68,62.7,not_published | |
| 946 | +Mayo,80065,Outaouais,unifamilial,1.0,68,56.7,not_published | |
| 947 | +La Pêche,82035,Outaouais,plex,1.0,68,59.7,not_published | |
| 948 | +Labelle,78120,Laurentides,plex,1.0,68,56.7,not_published | |
| 949 | +Saint-Théophile,29005,Chaudière-Appalaches,unifamilial,1.0,68,65.7,not_published | |
| 950 | +Béarn,85020,Abitibi-Témiscamingue,unifamilial,1.5,67,56.7,not_published | |
| 951 | +Pont-Rouge,34017,Capitale-Nationale,plex,1.0,67,58.2,not_published | |
| 952 | +Rigaud,71133,Montérégie,plex,2.0,67,53.7,not_published | |
| 953 | +Sainte-Félicité,08023,Bas-Saint-Laurent,unifamilial,1.5,67,59.7,not_published | |
| 954 | +Melbourne,42075,Estrie,unifamilial,1.0,66,56.7,not_published | |
| 955 | +Thorne,84045,Outaouais,unifamilial,1.0,66,59.7,not_published | |
| 956 | +Sainte-Cécile-de-Whitton,30050,Estrie,unifamilial,1.0,66,64.2,not_published | |
| 957 | +La Durantaye,19090,Chaudière-Appalaches,unifamilial,2.0,66,52.2,not_published | |
| 958 | +Saint-Zotique,71025,Montérégie,condo,1.0,66,61.2,not_published | |
| 959 | +Sainte-Marie,26030,Chaudière-Appalaches,plex,1.0,66,58.2,not_published | |
| 960 | +La Bostonnais,90017,Mauricie,unifamilial,1.0,66,58.2,not_published | |
| 961 | +Saint-Pie,54008,Montérégie,plex,1.0,65,58.2,not_published | |
| 962 | +Saint-Zotique,71025,Montérégie,plex,1.0,65,65.7,not_published | |
| 963 | +Saints-Anges,26010,Chaudière-Appalaches,unifamilial,2.0,65,56.7,not_published | |
| 964 | +Saint-Marcellin,10025,Bas-Saint-Laurent,unifamilial,1.0,65,59.7,not_published | |
| 965 | +Litchfield,84040,Outaouais,unifamilial,1.0,65,58.2,not_published | |
| 966 | +Saint-Sylvestre,33007,Chaudière-Appalaches,unifamilial,1.0,65,65.7,not_published | |
| 967 | +Princeville,32033,Centre-du-Québec,plex,1.0,64,59.7,not_published | |
| 968 | +Saint-Léon-le-Grand,07030,Bas-Saint-Laurent,unifamilial,1.0,64,55.2,not_published | |
| 969 | +Bonsecours,42040,Estrie,unifamilial,1.0,64,55.2,not_published | |
| 970 | +Saint-Tite,35027,Mauricie,plex,1.0,64,61.2,not_published | |
| 971 | +Havelock,69005,Montérégie,unifamilial,1.0,64,61.2,not_published | |
| 972 | +Mercier,67045,Montérégie,plex,1.0,64,61.2,not_published | |
| 973 | +Piedmont,77030,Laurentides,plex,1.0,64,64.2,not_published | |
| 974 | +Westmount,66032,Montréal,plex,1.0,64,61.2,not_published | |
| 975 | +Saint-Jacques,63013,Lanaudière,plex,1.0,64,58.2,not_published | |
| 976 | +Lefebvre,49020,Centre-du-Québec,unifamilial,1.0,64,53.7,not_published | |
| 977 | +Saint-Pierre-Baptiste,32050,Centre-du-Québec,unifamilial,1.0,64,65.7,not_published | |
| 978 | +Saint-Maxime-du-Mont-Louis,04010,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,63,52.2,not_published | |
| 979 | +Saint-Romain,30100,Estrie,unifamilial,1.0,63,59.7,not_published | |
| 980 | +Saint-Narcisse-de-Beaurivage,33030,Chaudière-Appalaches,unifamilial,1.5,63,50.7,not_published | |
| 981 | +Massueville,53010,Montérégie,unifamilial,1.0,63,59.7,not_published | |
| 982 | +Trois-Pistoles,11040,Bas-Saint-Laurent,plex,2.0,63,58.2,not_published | |
| 983 | +Saint-Calixte,63055,Lanaudière,plex,1.0,62,52.2,not_published | |
| 984 | +Duparquet,87005,Abitibi-Témiscamingue,unifamilial,1.0,62,58.2,not_published | |
| 985 | +Cloridorme,03010,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,62,56.7,not_published | |
| 986 | +Ogden,45020,Estrie,unifamilial,1.0,62,58.2,not_published | |
| 987 | +Saint-Rosaire,39145,Centre-du-Québec,unifamilial,1.0,62,53.7,not_published | |
| 988 | +Lac-Édouard,90027,Mauricie,unifamilial,1.0,62,58.2,not_published | |
| 989 | +Saint-Patrice-de-Beaurivage,33025,Chaudière-Appalaches,unifamilial,1.0,62,55.2,not_published | |
| 990 | +Dixville,44023,Estrie,unifamilial,1.5,62,59.7,not_published | |
| 991 | +Saint-David,53005,Montérégie,unifamilial,1.0,61,52.2,not_published | |
| 992 | +Westbury,41065,Estrie,unifamilial,1.0,61,62.7,not_published | |
| 993 | +Saint-Paul,61005,Lanaudière,plex,1.5,61,50.7,not_published | |
| 994 | +Longue-Rive,95032,Côte-Nord,unifamilial,1.0,61,58.2,not_published | |
| 995 | +Saint-Denis-De La Bouteillerie,14055,Bas-Saint-Laurent,unifamilial,1.0,61,55.2,not_published | |
| 996 | +Pointe-à-la-Croix,06030,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,61,61.2,not_published | |
| 997 | +Saint-Stanislas,37245,Mauricie,unifamilial,1.0,61,61.2,not_published | |
| 998 | +Mont-Saint-Hilaire,57035,Montérégie,plex,1.5,61,59.7,not_published | |
| 999 | +Sainte-Hélène-de-Kamouraska,14025,Bas-Saint-Laurent,unifamilial,1.0,61,59.7,not_published | |
| 1000 | +Lanoraie,52017,Lanaudière,plex,1.0,61,53.7,not_published | |
| 1001 | +Saint-Bonaventure,49125,Centre-du-Québec,unifamilial,1.0,61,56.7,not_published | |
| 1002 | +Saint-Cyprien,28040,Chaudière-Appalaches,unifamilial,1.5,61,53.7,not_published | |
| 1003 | +Saint-Philippe,67010,Montérégie,condo,1.0,60,49.3,not_published | |
| 1004 | +Métis-sur-Mer,09048,Bas-Saint-Laurent,unifamilial,1.0,60,62.7,not_published | |
| 1005 | +Saint-Célestin,50030,Centre-du-Québec,unifamilial,1.0,60,56.7,not_published | |
| 1006 | +Lac-Saint-Joseph,22015,Capitale-Nationale,unifamilial,1.0,60,50.7,not_published | |
| 1007 | +L'Île-du-Grand-Calumet,84035,Outaouais,unifamilial,1.0,59,55.2,not_published | |
| 1008 | +Arundel,78060,Laurentides,unifamilial,1.0,59,53.7,not_published | |
| 1009 | +Alleyn-et-Cawood,84050,Outaouais,unifamilial,1.0,59,61.2,not_published | |
| 1010 | +Clermont,15035,Capitale-Nationale,plex,1.0,59,56.7,not_published | |
| 1011 | +Sainte-Julienne,63060,Lanaudière,plex,1.0,59,52.2,not_published | |
| 1012 | +Saint-Frédéric,27065,Chaudière-Appalaches,unifamilial,1.0,59,52.2,not_published | |
| 1013 | +Newport,41037,Estrie,unifamilial,1.0,59,62.7,not_published | |
| 1014 | +Saint-Barnabé-Sud,54105,Montérégie,unifamilial,1.0,59,53.7,not_published | |
| 1015 | +Shannon,22020,Capitale-Nationale,plex,1.0,59,55.2,not_published | |
| 1016 | +Gallichan,87020,Abitibi-Témiscamingue,unifamilial,1.0,58,56.7,not_published | |
| 1017 | +Bécancour,38010,Centre-du-Québec,condo,1.0,58,47.8,not_published | |
| 1018 | +Fassett,80005,Outaouais,unifamilial,1.0,58,53.7,not_published | |
| 1019 | +Montcerf-Lytton,83088,Outaouais,unifamilial,2.0,58,46.3,not_published | |
| 1020 | +Ferland-et-Boilleau,94220,Saguenay–Lac-Saint-Jean,unifamilial,1.0,58,53.7,not_published | |
| 1021 | +Sainte-Germaine-Boulé,87030,Abitibi-Témiscamingue,unifamilial,1.5,58,50.7,not_published | |
| 1022 | +Sainte-Marthe,71110,Montérégie,unifamilial,1.0,58,56.7,not_published | |
| 1023 | +Saint-Lazare-de-Bellechasse,19050,Chaudière-Appalaches,unifamilial,1.0,58,55.2,not_published | |
| 1024 | +Taschereau,87042,Abitibi-Témiscamingue,unifamilial,1.0,57,53.7,not_published | |
| 1025 | +Saint-Simon-de-Rimouski,11055,Bas-Saint-Laurent,unifamilial,1.0,57,55.2,not_published | |
| 1026 | +Pointe-Calumet,72020,Laurentides,plex,1.5,57,50.7,not_published | |
| 1027 | +Rivière-Rouge,79037,Laurentides,plex,1.0,56,52.2,not_published | |
| 1028 | +Stanbridge East,46045,Estrie,unifamilial,1.0,56,55.2,not_published | |
| 1029 | +Notre-Dame-du-Bon-Conseil,49080,Centre-du-Québec,unifamilial,1.0,56,50.7,not_published | |
| 1030 | +Sainte-Rose-du-Nord,94230,Saguenay–Lac-Saint-Jean,unifamilial,1.0,56,62.7,not_published | |
| 1031 | +Saint-Arsène,12065,Bas-Saint-Laurent,unifamilial,1.0,56,53.7,not_published | |
| 1032 | +Sutton,46058,Estrie,condo,1.0,56,52.2,not_published | |
| 1033 | +Packington,13015,Bas-Saint-Laurent,unifamilial,1.0,56,56.7,not_published | |
| 1034 | +Grosses-Roches,08015,Bas-Saint-Laurent,unifamilial,1.0,56,58.2,not_published | |
| 1035 | +Duhamel-Ouest,85030,Abitibi-Témiscamingue,unifamilial,1.0,55,55.2,not_published | |
| 1036 | +Sainte-Christine,48020,Montérégie,unifamilial,1.0,55,65.7,not_published | |
| 1037 | +Saint-Pierre-de-Broughton,31135,Chaudière-Appalaches,unifamilial,1.0,55,55.2,not_published | |
| 1038 | +Saint-Nazaire-d'Acton,48050,Montérégie,unifamilial,1.0,55,52.2,not_published | |
| 1039 | +Prévost,75040,Laurentides,condo,1.0,55,50.7,not_published | |
| 1040 | +Saint-Jacques-de-Leeds,31140,Chaudière-Appalaches,unifamilial,1.0,55,53.7,not_published | |
| 1041 | +Saint-Léon-le-Grand,51035,Mauricie,unifamilial,1.0,55,49.3,not_published | |
| 1042 | +Courcelles–Saint-Évariste,29027,Chaudière-Appalaches,unifamilial,1.0,54,50.7,not_published | |
| 1043 | +Métabetchouan–Lac-à-la-Croix,93012,Saguenay–Lac-Saint-Jean,plex,1.0,54,58.2,not_published | |
| 1044 | +Amos,88057,Abitibi-Témiscamingue,plex,3.0,54,29.9,not_published | |
| 1045 | +Saint-Vallier,19117,Chaudière-Appalaches,unifamilial,1.0,54,50.7,not_published | |
| 1046 | +Saint-Charles-de-Bourget,94260,Saguenay–Lac-Saint-Jean,unifamilial,1.0,54,49.3,not_published | |
| 1047 | +Sainte-Catherine-de-la-Jacques-Cartier,22005,Capitale-Nationale,plex,1.0,54,52.2,not_published | |
| 1048 | +Saint-Édouard-de-Fabre,85015,Abitibi-Témiscamingue,unifamilial,1.0,54,56.7,not_published | |
| 1049 | +Piopolis,30020,Estrie,unifamilial,1.0,53,53.7,not_published | |
| 1050 | +Saint-Mathieu-d'Harricana,88050,Abitibi-Témiscamingue,unifamilial,1.0,53,49.3,not_published | |
| 1051 | +Notre-Dame-du-Rosaire,18040,Chaudière-Appalaches,unifamilial,1.0,53,46.3,not_published | |
| 1052 | +Pointe-Fortune,71140,Montérégie,unifamilial,1.0,53,53.7,not_published | |
| 1053 | +La Pocatière,14082,Bas-Saint-Laurent,plex,1.0,53,58.2,not_published | |
| 1054 | +Danville,40047,Estrie,plex,1.0,53,50.7,not_published | |
| 1055 | +L'Ange-Gardien,82005,Outaouais,plex,1.0,53,53.7,not_published | |
| 1056 | +Les Coteaux,71033,Montérégie,plex,1.0,53,49.3,not_published | |
| 1057 | +Les Bergeronnes,95018,Côte-Nord,unifamilial,1.0,53,53.7,not_published | |
| 1058 | +Sainte-Anne-des-Lacs,77035,Laurentides,plex,1.0,53,52.2,not_published | |
| 1059 | +Chandler,02028,Gaspésie–Îles-de-la-Madeleine,plex,1.0,52,58.2,not_published | |
| 1060 | +Sainte-Angèle-de-Prémont,51055,Mauricie,unifamilial,1.0,52,47.8,not_published | |
| 1061 | +Saint-Pierre-de-la-Rivière-du-Sud,18055,Chaudière-Appalaches,unifamilial,1.0,52,52.2,not_published | |
| 1062 | +Sainte-Martine,70012,Montérégie,condo,1.0,52,47.8,not_published | |
| 1063 | +Saint-Just-de-Bretenières,18005,Chaudière-Appalaches,unifamilial,1.0,52,47.8,not_published | |
| 1064 | +Saint-Sébastien,30085,Estrie,unifamilial,1.0,52,53.7,not_published | |
| 1065 | +Saint-Michel-du-Squatec,13065,Bas-Saint-Laurent,unifamilial,2.0,52,40.3,not_published | |
| 1066 | +Saint-André-Avellin,80027,Outaouais,plex,1.0,52,49.3,not_published | |
| 1067 | +Saint-Zéphirin-de-Courval,50090,Centre-du-Québec,unifamilial,1.0,51,59.7,not_published | |
| 1068 | +Lac-Beauport,22040,Capitale-Nationale,plex,1.0,51,55.2,not_published | |
| 1069 | +Péribonka,92010,Saguenay–Lac-Saint-Jean,unifamilial,1.0,51,55.2,not_published | |
| 1070 | +Mont-Royal,66072,Montréal,plex,1.0,51,50.7,not_published | |
| 1071 | +Ville-Marie,85025,Abitibi-Témiscamingue,plex,1.0,51,53.7,not_published | |
| 1072 | +Mercier,67045,Montérégie,condo,1.0,51,41.8,not_published | |
| 1073 | +Parisville,38055,Centre-du-Québec,unifamilial,1.0,51,53.7,not_published | |
| 1074 | +Saint-Edmond-de-Grantham,49100,Centre-du-Québec,unifamilial,1.0,51,50.7,not_published | |
| 1075 | +Sainte-Sabine,28065,Chaudière-Appalaches,unifamilial,1.0,51,55.2,not_published | |
| 1076 | +Clerval,87075,Abitibi-Témiscamingue,unifamilial,1.0,51,49.3,not_published | |
| 1077 | +Saint-Basile-le-Grand,57020,Montérégie,plex,1.0,51,50.7,not_published | |
| 1078 | +Beaupré,21025,Capitale-Nationale,plex,1.0,51,55.2,not_published | |
| 1079 | +Disraeli,31015,Chaudière-Appalaches,plex,1.0,51,49.3,not_published | |
| 1080 | +Val-Morin,78005,Laurentides,plex,1.0,51,47.8,not_published | |
| 1081 | +Ulverton,42078,Estrie,unifamilial,2.0,50,43.3,not_published | |
| 1082 | +Sainte-Angèle-de-Mérici,09035,Bas-Saint-Laurent,unifamilial,1.0,50,55.2,not_published | |
| 1083 | +Notre-Dame-du-Sacré-Coeur-d'Issoudun,33085,Chaudière-Appalaches,unifamilial,1.0,50,55.2,not_published | |
| 1084 | +Sainte-Françoise,11030,Bas-Saint-Laurent,unifamilial,1.0,50,46.3,not_published | |
| 1085 | +Bryson,84025,Outaouais,unifamilial,1.0,50,55.2,not_published | |
| 1086 | +Lac-des-Seize-Îles,77055,Laurentides,unifamilial,1.0,49,49.3,not_published | |
| 1087 | +La Visitation-de-l'Île-Dupas,52050,Lanaudière,unifamilial,1.0,49,56.7,not_published | |
| 1088 | +Saint-François-de-Sales,91015,Saguenay–Lac-Saint-Jean,unifamilial,1.0,49,49.3,not_published | |
| 1089 | +Saint-Honoré-de-Témiscouata,13090,Bas-Saint-Laurent,unifamilial,1.0,49,52.2,not_published | |
| 1090 | +Sainte-Marguerite-du-Lac-Masson,77012,Laurentides,plex,1.0,49,49.3,not_published | |
| 1091 | +Sainte-Anne-des-Plaines,73035,Laurentides,condo,2.0,49,34.3,not_published | |
| 1092 | +Saint-Télesphore,71015,Montérégie,unifamilial,1.0,49,50.7,not_published | |
| 1093 | +Waltham,84070,Outaouais,unifamilial,1.0,49,47.8,not_published | |
| 1094 | +Scotstown,41080,Estrie,unifamilial,1.0,49,52.2,not_published | |
| 1095 | +Saint-Apollinaire,33090,Chaudière-Appalaches,plex,1.0,49,44.8,not_published | |
| 1096 | +Campbell's Bay,84030,Outaouais,unifamilial,1.0,49,58.2,not_published | |
| 1097 | +Saint-Sulpice,60020,Lanaudière,condo,1.0,49,41.8,not_published | |
| 1098 | +La Motte,88045,Abitibi-Témiscamingue,unifamilial,1.0,49,50.7,not_published | |
| 1099 | +Saint-Eugène-de-Guigues,85085,Abitibi-Témiscamingue,unifamilial,1.0,49,50.7,not_published | |
| 1100 | +Irlande,31040,Chaudière-Appalaches,unifamilial,1.0,49,44.8,not_published | |
| 1101 | +Sainte-Anne-de-Bellevue,66117,Montréal,plex,1.0,49,43.3,not_published | |
| 1102 | +Pontiac,82030,Outaouais,plex,1.0,48,52.2,not_published | |
| 1103 | +East Farnham,46085,Estrie,unifamilial,1.0,48,55.2,not_published | |
| 1104 | +Saint-Philippe-de-Néri,14060,Bas-Saint-Laurent,unifamilial,1.0,48,49.3,not_published | |
| 1105 | +Saint-Roch-de-l'Achigan,63035,Lanaudière,plex,1.0,48,52.2,not_published | |
| 1106 | +Murdochville,03025,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,48,52.2,not_published | |
| 1107 | +Saint-Honoré,94240,Saguenay–Lac-Saint-Jean,plex,1.0,48,52.2,not_published | |
| 1108 | +Audet,30055,Estrie,unifamilial,1.0,48,55.2,not_published | |
| 1109 | +Sainte-Brigitte-des-Saults,49085,Centre-du-Québec,unifamilial,1.0,48,53.7,not_published | |
| 1110 | +Lochaber-Partie-Ouest,80060,Outaouais,unifamilial,1.0,48,49.3,not_published | |
| 1111 | +Sainte-Marie-de-Blandford,38015,Centre-du-Québec,unifamilial,1.5,48,41.8,not_published | |
| 1112 | +Shawinigan,36033,Mauricie,condo,1.0,48,44.8,not_published | |
| 1113 | +Warwick,39077,Centre-du-Québec,plex,1.0,48,49.3,not_published | |
| 1114 | +Beaconsfield,66107,Montréal,condo,1.0,47,49.3,not_published | |
| 1115 | +Sainte-Euphémie-sur-Rivière-du-Sud,18035,Chaudière-Appalaches,unifamilial,1.0,47,50.7,not_published | |
| 1116 | +Saint-Gabriel-Lalemant,14075,Bas-Saint-Laurent,unifamilial,1.0,47,52.2,not_published | |
| 1117 | +Coteau-du-Lac,71040,Montérégie,plex,1.0,47,47.8,not_published | |
| 1118 | +Saint-Prosper-de-Champlain,37250,Mauricie,unifamilial,1.0,47,46.3,not_published | |
| 1119 | +Saint-Sylvère,38005,Centre-du-Québec,unifamilial,1.0,47,49.3,not_published | |
| 1120 | +Stanstead,45008,Estrie,plex,1.0,47,53.7,not_published | |
| 1121 | +Honfleur,19070,Chaudière-Appalaches,unifamilial,1.0,47,46.3,not_published | |
| 1122 | +Sainte-Marie,26030,Chaudière-Appalaches,condo,1.0,47,49.3,not_published | |
| 1123 | +Sainte-Séraphine,39105,Centre-du-Québec,unifamilial,1.0,47,49.3,not_published | |
| 1124 | +Delson,67025,Montérégie,plex,1.0,47,46.3,not_published | |
| 1125 | +Lawrenceville,42045,Estrie,unifamilial,1.0,47,47.8,not_published | |
| 1126 | +Notre-Dame-de-Lourdes,32080,Centre-du-Québec,unifamilial,1.0,47,44.8,not_published | |
| 1127 | +Sainte-Paule,08040,Bas-Saint-Laurent,unifamilial,1.0,47,47.8,not_published | |
| 1128 | +Rivière-au-Tonnerre,98055,Côte-Nord,unifamilial,1.0,46,41.8,not_published | |
| 1129 | +Senneville,66127,Montréal,unifamilial,1.0,46,49.3,not_published | |
| 1130 | +Saints-Martyrs-Canadiens,39005,Centre-du-Québec,unifamilial,1.0,46,49.3,not_published | |
| 1131 | +Les Coteaux,71033,Montérégie,condo,1.0,46,44.8,not_published | |
| 1132 | +Tourville,17035,Chaudière-Appalaches,unifamilial,1.0,46,52.2,not_published | |
| 1133 | +Saint-Alphonse,05065,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,46,47.8,not_published | |
| 1134 | +Portneuf-sur-Mer,95040,Côte-Nord,unifamilial,1.0,46,47.8,not_published | |
| 1135 | +Bedford,46035,Estrie,plex,1.0,46,43.3,not_published | |
| 1136 | +Sainte-Louise,17060,Chaudière-Appalaches,unifamilial,1.0,46,43.3,not_published | |
| 1137 | +Saint-Samuel,39130,Centre-du-Québec,unifamilial,1.0,46,53.7,not_published | |
| 1138 | +Sainte-Martine,70012,Montérégie,plex,1.0,46,49.3,not_published | |
| 1139 | +Sainte-Apolline-de-Patton,18025,Chaudière-Appalaches,unifamilial,1.0,45,46.3,not_published | |
| 1140 | +Lac-Etchemin,28053,Chaudière-Appalaches,plex,1.0,45,50.7,not_published | |
| 1141 | +Tadoussac,95005,Côte-Nord,unifamilial,1.0,45,46.3,not_published | |
| 1142 | +Sainte-Perpétue,50050,Centre-du-Québec,unifamilial,1.0,45,43.3,not_published | |
| 1143 | +Saint-André-du-Lac-Saint-Jean,91010,Saguenay–Lac-Saint-Jean,unifamilial,1.0,45,43.3,not_published | |
| 1144 | +Saint-Lazare,71105,Montérégie,plex,1.0,45,46.3,not_published | |
| 1145 | +Saint-Mathias-sur-Richelieu,55065,Montérégie,plex,1.0,45,52.2,not_published | |
| 1146 | +Sainte-Anne-de-la-Rochelle,42050,Estrie,unifamilial,1.0,44,40.3,not_published | |
| 1147 | +Saint-Adalbert,17015,Chaudière-Appalaches,unifamilial,1.0,44,46.3,not_published | |
| 1148 | +Saint-Malo,44003,Estrie,unifamilial,1.0,44,47.8,not_published | |
| 1149 | +Ham-Sud,40005,Estrie,unifamilial,1.0,44,47.8,not_published | |
| 1150 | +Les Cèdres,71050,Montérégie,condo,1.0,44,44.8,not_published | |
| 1151 | +Baie-des-Sables,08080,Bas-Saint-Laurent,unifamilial,1.0,44,49.3,not_published | |
| 1152 | +Petit-Saguenay,94205,Saguenay–Lac-Saint-Jean,unifamilial,1.0,44,40.3,not_published | |
| 1153 | +Saint-Marc-des-Carrières,34065,Capitale-Nationale,plex,1.0,44,49.3,not_published | |
| 1154 | +Saint-Sébastien,56050,Montérégie,unifamilial,1.0,44,46.3,not_published | |
| 1155 | +Otterburn Park,57030,Montérégie,plex,1.0,44,37.3,not_published | |
| 1156 | +Ormstown,69037,Montérégie,plex,1.0,43,47.8,not_published | |
| 1157 | +Château-Richer,21035,Capitale-Nationale,plex,1.0,43,43.3,not_published | |
| 1158 | +Stornoway,30105,Estrie,unifamilial,1.0,43,47.8,not_published | |
| 1159 | +Témiscouata-sur-le-Lac,13073,Bas-Saint-Laurent,plex,1.0,43,47.8,not_published | |
| 1160 | +Sainte-Famille-de-l'Île-d'Orléans,20010,Capitale-Nationale,unifamilial,1.0,43,46.3,not_published | |
| 1161 | +Lac-Brome,46075,Estrie,plex,1.0,43,43.3,not_published | |
| 1162 | +Saint-Joseph-du-Lac,72025,Laurentides,condo,2.0,43,29.9,not_published | |
| 1163 | +Huntingdon,69055,Montérégie,plex,1.0,43,41.8,not_published | |
| 1164 | +Baie-Trinité,96005,Côte-Nord,unifamilial,1.0,43,46.3,not_published | |
| 1165 | +Senneterre,89040,Abitibi-Témiscamingue,plex,1.0,43,49.3,not_published | |
| 1166 | +Verchères,59025,Montérégie,plex,1.0,43,44.8,not_published | |
| 1167 | +Candiac,67020,Montérégie,plex,1.0,43,49.3,not_published | |
| 1168 | +Biencourt,13055,Bas-Saint-Laurent,unifamilial,1.0,43,40.3,not_published | |
| 1169 | +Saint-Augustin-de-Woburn,30005,Estrie,unifamilial,1.0,43,49.3,not_published | |
| 1170 | +Havre-Saint-Pierre,98040,Côte-Nord,plex,1.0,43,49.3,not_published | |
| 1171 | +Rawdon,62037,Lanaudière,condo,1.0,43,40.3,not_published | |
| 1172 | +Saint-Paul-de-la-Croix,12035,Bas-Saint-Laurent,unifamilial,1.0,42,46.3,not_published | |
| 1173 | +Rouyn-Noranda,86042,Abitibi-Témiscamingue,condo,1.0,42,40.3,not_published | |
| 1174 | +Saint-Jean-de-Matha,62015,Lanaudière,plex,1.0,42,41.8,not_published | |
| 1175 | +Saint-Luc-de-Vincennes,37225,Mauricie,unifamilial,1.0,42,43.3,not_published | |
| 1176 | +Portneuf,34048,Capitale-Nationale,plex,1.0,42,43.3,not_published | |
| 1177 | +Rosemère,73020,Laurentides,plex,1.0,42,38.8,not_published | |
| 1178 | +Saint-Marcel,17020,Chaudière-Appalaches,unifamilial,1.0,42,38.8,not_published | |
| 1179 | +Weedon,41098,Estrie,plex,1.0,42,43.3,not_published | |
| 1180 | +Notre-Dame-de-Lourdes,61045,Lanaudière,plex,1.0,42,46.3,not_published | |
| 1181 | +Pike River,46025,Estrie,unifamilial,1.0,42,44.8,not_published | |
| 1182 | +Sainte-Gertrude-Manneville,88085,Abitibi-Témiscamingue,unifamilial,1.0,41,46.3,not_published | |
| 1183 | +Cowansville,46080,Estrie,condo,1.0,41,44.8,not_published | |
| 1184 | +Saint-Joseph-de-Lepage,09070,Bas-Saint-Laurent,unifamilial,1.0,41,44.8,not_published | |
| 1185 | +Saint-Adrien,40010,Estrie,unifamilial,1.0,41,40.3,not_published | |
| 1186 | +Forestville,95045,Côte-Nord,plex,1.0,41,46.3,not_published | |
| 1187 | +Brome,46070,Estrie,unifamilial,1.0,41,43.3,not_published | |
| 1188 | +Leclercville,33123,Chaudière-Appalaches,unifamilial,1.0,41,40.3,not_published | |
| 1189 | +Notre-Dame-de-Ham,39015,Centre-du-Québec,unifamilial,1.0,41,44.8,not_published | |
| 1190 | +Saint-Henri,19068,Chaudière-Appalaches,plex,1.0,41,49.3,not_published | |
| 1191 | +Roxton Pond,47047,Estrie,plex,1.0,41,41.8,not_published | |
| 1192 | +Saint-Adelme,08030,Bas-Saint-Laurent,unifamilial,1.0,41,46.3,not_published | |
| 1193 | +Montréal-Ouest,66047,Montréal,plex,1.0,41,44.8,not_published | |
| 1194 | +Saint-Simon-les-Mines,29125,Chaudière-Appalaches,unifamilial,1.0,40,47.8,not_published | |
| 1195 | +Hampstead,66062,Montréal,plex,1.0,40,49.3,not_published | |
| 1196 | +Boischatel,21045,Capitale-Nationale,plex,1.0,40,44.8,not_published | |
| 1197 | +Saint-Moïse,07095,Bas-Saint-Laurent,unifamilial,1.0,40,41.8,not_published | |
| 1198 | +Dundee,69075,Montérégie,unifamilial,1.0,40,40.3,not_published | |
| 1199 | +Oka,72032,Laurentides,plex,1.0,40,43.3,not_published | |
| 1200 | +Saint-Sixte,80070,Outaouais,unifamilial,1.0,40,41.8,not_published | |
| 1201 | +Bonaventure,05045,Gaspésie–Îles-de-la-Madeleine,plex,1.0,40,41.8,not_published | |
| 1202 | +Saint-Adrien-d'Irlande,31095,Chaudière-Appalaches,unifamilial,2.0,39,34.3,not_published | |
| 1203 | +Barnston-Ouest,44045,Estrie,unifamilial,1.0,39,47.8,not_published | |
| 1204 | +Saint-Joseph-de-Beauce,27043,Chaudière-Appalaches,plex,1.0,39,41.8,not_published | |
| 1205 | +Bedford,46040,Estrie,unifamilial,1.0,39,38.8,not_published | |
| 1206 | +Carleton-sur-Mer,06013,Gaspésie–Îles-de-la-Madeleine,plex,1.0,39,46.3,not_published | |
| 1207 | +Saint-Louis-de-Gonzague,28035,Chaudière-Appalaches,unifamilial,1.0,39,47.8,not_published | |
| 1208 | +Saint-Camille,40025,Estrie,unifamilial,1.0,39,43.3,not_published | |
| 1209 | +Normétal,87115,Abitibi-Témiscamingue,unifamilial,1.0,38,41.8,not_published | |
| 1210 | +Lingwick,41085,Estrie,unifamilial,1.0,38,46.3,not_published | |
| 1211 | +Saint-Côme,62065,Lanaudière,plex,1.0,38,37.3,not_published | |
| 1212 | +Saint-Lazare,71105,Montérégie,condo,1.0,38,31.3,not_published | |
| 1213 | +Grenville,76055,Laurentides,plex,1.0,38,44.8,not_published | |
| 1214 | +Rivière-Éternité,94215,Saguenay–Lac-Saint-Jean,unifamilial,1.0,38,44.8,not_published | |
| 1215 | +Roquemaure,87015,Abitibi-Témiscamingue,unifamilial,1.0,38,44.8,not_published | |
| 1216 | +Villeroy,32085,Centre-du-Québec,unifamilial,1.0,38,40.3,not_published | |
| 1217 | +Dégelis,13005,Bas-Saint-Laurent,plex,1.0,38,41.8,not_published | |
| 1218 | +Cascapédia–Saint-Jules,05077,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,38,40.3,not_published | |
| 1219 | +Saint-Germain-de-Grantham,49048,Centre-du-Québec,plex,1.0,38,43.3,not_published | |
| 1220 | +Sainte-Thècle,35050,Mauricie,plex,1.0,38,41.8,not_published | |
| 1221 | +Saint-François-de-l'Île-d'Orléans,20005,Capitale-Nationale,unifamilial,1.0,38,43.3,not_published | |
| 1222 | +Papineauville,80037,Outaouais,plex,1.0,38,37.3,not_published | |
| 1223 | +Stanstead-Est,44050,Estrie,unifamilial,1.0,38,41.8,not_published | |
| 1224 | +Pohénégamook,13095,Bas-Saint-Laurent,plex,1.0,37,46.3,not_published | |
| 1225 | +Saint-Valentin,56030,Montérégie,unifamilial,1.0,37,40.3,not_published | |
| 1226 | +Saint-Denis-de-Brompton,42025,Estrie,plex,1.0,37,38.8,not_published | |
| 1227 | +Val-d'Or,89008,Abitibi-Témiscamingue,condo,1.0,37,49.3,not_published | |
| 1228 | +Saint-Philippe,67010,Montérégie,plex,1.0,37,43.3,not_published | |
| 1229 | +Fortierville,38047,Centre-du-Québec,unifamilial,1.0,37,38.8,not_published | |
| 1230 | +Saint-Célestin,50035,Centre-du-Québec,unifamilial,1.0,37,43.3,not_published | |
| 1231 | +Abercorn,46005,Estrie,unifamilial,1.0,37,41.8,not_published | |
| 1232 | +Martinville,44060,Estrie,unifamilial,1.0,37,41.8,not_published | |
| 1233 | +Kinnear's Mills,31105,Chaudière-Appalaches,unifamilial,1.0,37,38.8,not_published | |
| 1234 | +Saint-Ambroise-de-Kildare,61040,Lanaudière,plex,1.0,37,38.8,not_published | |
| 1235 | +Saint-Marc-du-Lac-Long,13020,Bas-Saint-Laurent,unifamilial,1.0,36,37.3,not_published | |
| 1236 | +Notre-Dame-de-Bonsecours,80015,Outaouais,unifamilial,1.0,36,40.3,not_published | |
| 1237 | +Latulipe-et-Gaboury,85060,Abitibi-Témiscamingue,unifamilial,1.0,36,46.3,not_published | |
| 1238 | +Saint-Félix-de-Valois,62007,Lanaudière,condo,1.0,36,35.8,not_published | |
| 1239 | +Saint-François-d'Assise,06055,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,36,34.3,not_published | |
| 1240 | +Poularies,87035,Abitibi-Témiscamingue,unifamilial,1.0,36,43.3,not_published | |
| 1241 | +Rémigny,85105,Abitibi-Témiscamingue,unifamilial,1.0,36,34.3,not_published | |
| 1242 | +Alma,93042,Saguenay–Lac-Saint-Jean,condo,1.0,36,40.3,not_published | |
| 1243 | +Neuville,34007,Capitale-Nationale,plex,1.0,36,40.3,not_published | |
| 1244 | +Dorval,66087,Montréal,plex,1.0,35,37.3,not_published | |
| 1245 | +Notre-Dame-de-Stanbridge,46100,Estrie,unifamilial,1.0,35,43.3,not_published | |
| 1246 | +Kamouraska,14050,Bas-Saint-Laurent,unifamilial,1.0,35,40.3,not_published | |
| 1247 | +Saint-Thuribe,34085,Capitale-Nationale,unifamilial,1.0,35,40.3,not_published | |
| 1248 | +Sainte-Hélène-de-Mancebourg,87070,Abitibi-Témiscamingue,unifamilial,1.0,35,34.3,not_published | |
| 1249 | +Saint-Cyrille-de-Wendover,49070,Centre-du-Québec,plex,1.0,35,41.8,not_published | |
| 1250 | +Valcourt,42055,Estrie,plex,1.0,35,37.3,not_published | |
| 1251 | +Saint-Thomas,61027,Lanaudière,plex,1.0,35,35.8,not_published | |
| 1252 | +Saint-Alfred,27015,Chaudière-Appalaches,unifamilial,1.0,35,46.3,not_published | |
| 1253 | +Saint-Marcel-de-Richelieu,54125,Montérégie,unifamilial,1.0,35,37.3,not_published | |
| 1254 | +Nédélec,85100,Abitibi-Témiscamingue,unifamilial,1.0,35,35.8,not_published | |
| 1255 | +Sainte-Madeleine,54025,Montérégie,plex,1.0,34,40.3,not_published | |
| 1256 | +Milan,30040,Estrie,unifamilial,1.0,34,41.8,not_published | |
| 1257 | +Terrasse-Vaudreuil,71075,Montérégie,plex,1.0,34,40.3,not_published | |
| 1258 | +La Conception,78115,Laurentides,plex,1.0,34,35.8,not_published | |
| 1259 | +Matapédia,06045,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,34,37.3,not_published | |
| 1260 | +Gracefield,83032,Outaouais,plex,1.0,34,40.3,not_published | |
| 1261 | +Saint-Stanislas,92070,Saguenay–Lac-Saint-Jean,unifamilial,1.0,34,40.3,not_published | |
| 1262 | +Saint-Bruno-de-Kamouraska,14010,Bas-Saint-Laurent,unifamilial,1.0,34,34.3,not_published | |
| 1263 | +Saint-Octave-de-Métis,09055,Bas-Saint-Laurent,unifamilial,1.0,33,38.8,not_published | |
| 1264 | +Les Hauteurs,09015,Bas-Saint-Laurent,unifamilial,1.0,33,46.3,not_published | |
| 1265 | +Lac-des-Aigles,13062,Bas-Saint-Laurent,unifamilial,2.0,33,26.9,not_published | |
| 1266 | +Saint-Clément,11005,Bas-Saint-Laurent,unifamilial,1.0,33,38.8,not_published | |
| 1267 | +Lebel-sur-Quévillon,99005,Nord-du-Québec,plex,1.0,33,34.3,not_published | |
| 1268 | +Saint-Damase-de-L'Islet,17040,Chaudière-Appalaches,unifamilial,1.0,33,37.3,not_published | |
| 1269 | +Sainte-Anne-de-la-Pérade,37205,Mauricie,plex,1.0,33,38.8,not_published | |
| 1270 | +Escuminac,06025,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,33,38.8,not_published | |
| 1271 | +Berry,88070,Abitibi-Témiscamingue,unifamilial,1.0,32,37.3,not_published | |
| 1272 | +Lochaber,80055,Outaouais,unifamilial,1.0,32,40.3,not_published | |
| 1273 | +Chichester,84090,Outaouais,unifamilial,1.0,32,35.8,not_published | |
| 1274 | +Saint-Omer,17005,Chaudière-Appalaches,unifamilial,1.0,32,38.8,not_published | |
| 1275 | +Colombier,95050,Côte-Nord,unifamilial,1.0,32,38.8,not_published | |
| 1276 | +Saint-André-de-Kamouraska,14040,Bas-Saint-Laurent,unifamilial,1.0,32,40.3,not_published | |
| 1277 | +Saint-Adolphe-d'Howard,77065,Laurentides,plex,1.0,32,40.3,not_published | |
| 1278 | +Sainte-Agathe-des-Monts,78032,Laurentides,condo,1.0,32,37.3,not_published | |
| 1279 | +Notre-Dame-des-Monts,15025,Capitale-Nationale,unifamilial,1.0,32,38.8,not_published | |
| 1280 | +Sainte-Irène,07040,Bas-Saint-Laurent,unifamilial,1.0,32,37.3,not_published | |
| 1281 | +Kipawa,85010,Abitibi-Témiscamingue,unifamilial,1.0,31,32.8,not_published | |
| 1282 | +Egan-Sud,83075,Outaouais,unifamilial,1.0,31,41.8,not_published | |
| 1283 | +Victoriaville,39062,Centre-du-Québec,condo,1.0,31,32.8,not_published | |
| 1284 | +Maricourt,42065,Estrie,unifamilial,1.0,31,32.8,not_published | |
| 1285 | +Moffet,85075,Abitibi-Témiscamingue,unifamilial,1.0,31,40.3,not_published | |
| 1286 | +Macamic,87058,Abitibi-Témiscamingue,plex,1.0,31,37.3,not_published | |
| 1287 | +Saint-Jean-de-la-Lande,13010,Bas-Saint-Laurent,unifamilial,1.0,31,37.3,not_published | |
| 1288 | +Grand-Métis,09060,Bas-Saint-Laurent,unifamilial,1.0,31,38.8,not_published | |
| 1289 | +Lac-Supérieur,78095,Laurentides,plex,1.0,31,38.8,not_published | |
| 1290 | +Franquelin,96015,Côte-Nord,unifamilial,1.0,31,34.3,not_published | |
| 1291 | +Saint-Bernard-de-Michaudville,54115,Montérégie,unifamilial,1.0,31,34.3,not_published | |
| 1292 | +Saint-Elzéar,05050,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,31,35.8,not_published | |
| 1293 | +Saint-Dominique-du-Rosaire,88065,Abitibi-Témiscamingue,unifamilial,1.0,30,37.3,not_published | |
| 1294 | +Lacolle,56023,Montérégie,plex,1.0,30,32.8,not_published | |
| 1295 | +Saint-Vianney,07075,Bas-Saint-Laurent,unifamilial,1.0,30,32.8,not_published | |
| 1296 | +Saint-Boniface,51085,Mauricie,plex,1.0,30,37.3,not_published | |
| 1297 | +Saint-Jules,27055,Chaudière-Appalaches,unifamilial,1.0,30,35.8,not_published | |
| 1298 | +Clermont,87110,Abitibi-Témiscamingue,unifamilial,1.0,30,37.3,not_published | |
| 1299 | +Saint-Roch-de-Richelieu,53040,Montérégie,plex,1.0,30,37.3,not_published | |
| 1300 | +Saint-Jean-Baptiste,57033,Montérégie,plex,1.0,30,37.3,not_published | |
| 1301 | +Chartierville,41020,Estrie,unifamilial,1.0,30,34.3,not_published | |
| 1302 | +La Trinité-des-Monts,10010,Bas-Saint-Laurent,unifamilial,1.0,30,37.3,not_published | |
| 1303 | +Maddington Falls,39165,Centre-du-Québec,unifamilial,1.0,30,35.8,not_published | |
| 1304 | +Val-Racine,30015,Estrie,unifamilial,1.0,30,32.8,not_published | |
| 1305 | +Saint-Sulpice,60020,Lanaudière,plex,1.0,29,34.3,not_published | |
| 1306 | +Sainte-Sophie-d'Halifax,32023,Centre-du-Québec,unifamilial,1.0,29,38.8,not_published | |
| 1307 | +Saint-Robert-Bellarmin,30070,Estrie,unifamilial,1.0,29,31.3,not_published | |
| 1308 | +Elgin,69050,Montérégie,unifamilial,1.0,29,32.8,not_published | |
| 1309 | +Saint-Paul,61005,Lanaudière,condo,1.0,29,32.8,not_published | |
| 1310 | +Saint-François-Xavier-de-Viger,12025,Bas-Saint-Laurent,unifamilial,1.0,29,31.3,not_published | |
| 1311 | +Port-Cartier,97022,Côte-Nord,plex,1.0,29,35.8,not_published | |
| 1312 | +Guérin,85095,Abitibi-Témiscamingue,unifamilial,1.0,29,31.3,not_published | |
| 1313 | +Saint-Ignace-de-Stanbridge,46095,Estrie,unifamilial,1.0,29,37.3,not_published | |
| 1314 | +Saint-Anselme,19062,Chaudière-Appalaches,plex,1.0,29,38.8,not_published | |
| 1315 | +Ferme-Neuve,79097,Laurentides,plex,1.0,29,29.9,not_published | |
| 1316 | +Sainte-Thérèse-de-Gaspé,02010,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,29,34.3,not_published | |
| 1317 | +Saint-Nazaire-de-Dorchester,19015,Chaudière-Appalaches,unifamilial,1.0,28,34.3,not_published | |
| 1318 | +Blanc-Sablon,98005,Côte-Nord,unifamilial,1.0,28,38.8,not_published | |
| 1319 | +Sainte-Brigitte-de-Laval,22045,Capitale-Nationale,plex,1.0,28,31.3,not_published | |
| 1320 | +Saint-Eugène-d'Argentenay,92065,Saguenay–Lac-Saint-Jean,unifamilial,1.0,28,34.3,not_published | |
| 1321 | +Barkmere,78050,Laurentides,unifamilial,1.0,28,34.3,not_published | |
| 1322 | +Saint-Ferréol-les-Neiges,21010,Capitale-Nationale,plex,1.0,28,28.4,not_published | |
| 1323 | +Ascot Corner,41055,Estrie,plex,1.0,28,31.3,not_published | |
| 1324 | +Sainte-Mélanie,61050,Lanaudière,plex,1.0,28,34.3,not_published | |
| 1325 | +Warden,47030,Estrie,unifamilial,1.0,28,34.3,not_published | |
| 1326 | +La Rédemption,09005,Bas-Saint-Laurent,unifamilial,1.0,28,35.8,not_published | |
| 1327 | +Saint-Eusèbe,13030,Bas-Saint-Laurent,unifamilial,1.0,28,31.3,not_published | |
| 1328 | +Saint-Alphonse-Rodriguez,62025,Lanaudière,plex,1.0,27,32.8,not_published | |
| 1329 | +Lac-des-Écorces,79078,Laurentides,plex,1.0,27,26.9,not_published | |
| 1330 | +Bois-Franc,83085,Outaouais,unifamilial,1.0,27,32.8,not_published | |
| 1331 | +Lac-Frontière,18010,Chaudière-Appalaches,unifamilial,1.0,27,34.3,not_published | |
| 1332 | +Chelsea,82025,Outaouais,plex,1.0,27,35.8,not_published | |
| 1333 | +Saint-Agapit,33045,Chaudière-Appalaches,plex,1.0,27,32.8,not_published | |
| 1334 | +Laurier-Station,33060,Chaudière-Appalaches,plex,1.0,27,31.3,not_published | |
| 1335 | +Saint-François-du-Lac,50128,Centre-du-Québec,plex,1.0,27,29.9,not_published | |
| 1336 | +Sainte-Edwidge-de-Clifton,44055,Estrie,unifamilial,1.0,27,28.4,not_published | |
| 1337 | +Saint-Pie-de-Guire,49130,Centre-du-Québec,unifamilial,1.0,27,34.3,not_published | |
| 1338 | +Saint-Ambroise,94255,Saguenay–Lac-Saint-Jean,plex,1.0,27,32.8,not_published | |
| 1339 | +Sainte-Madeleine-de-la-Rivière-Madeleine,04005,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,27,35.8,not_published | |
| 1340 | +Godbout,96010,Côte-Nord,unifamilial,1.0,27,34.3,not_published | |
| 1341 | +Saint-Alexis-de-Matapédia,06050,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,27,34.3,not_published | |
| 1342 | +Maskinongé,51008,Mauricie,plex,1.0,27,37.3,not_published | |
| 1343 | +Saint-Prosper,28020,Chaudière-Appalaches,plex,1.0,27,34.3,not_published | |
| 1344 | +Saint-Bruno-de-Montarville,58037,Montérégie,plex,1.0,27,35.8,not_published | |
| 1345 | +Esprit-Saint,10005,Bas-Saint-Laurent,unifamilial,1.0,27,34.3,not_published | |
| 1346 | +Saint-Augustin,92005,Saguenay–Lac-Saint-Jean,unifamilial,1.0,27,38.8,not_published | |
| 1347 | +Notre-Dame-du-Mont-Carmel,37235,Mauricie,plex,1.0,27,35.8,not_published | |
| 1348 | +Saint-Éloi,11035,Bas-Saint-Laurent,unifamilial,1.0,27,26.9,not_published | |
| 1349 | +Saint-Aimé,53015,Montérégie,unifamilial,1.0,26,28.4,not_published | |
| 1350 | +Longue-Pointe-de-Mingan,98045,Côte-Nord,unifamilial,1.0,26,31.3,not_published | |
| 1351 | +Saint-Casimir,34078,Capitale-Nationale,plex,1.0,26,26.9,not_published | |
| 1352 | +Saint-Lambert-de-Lauzon,26070,Chaudière-Appalaches,plex,1.0,26,29.9,not_published | |
| 1353 | +Waterville,44080,Estrie,plex,1.0,26,29.9,not_published | |
| 1354 | +Sainte-Claire,19055,Chaudière-Appalaches,plex,1.0,26,31.3,not_published | |
| 1355 | +Otterburn Park,57030,Montérégie,condo,1.0,26,25.4,not_published | |
| 1356 | +Yamachiche,51020,Mauricie,plex,1.0,26,37.3,not_published | |
| 1357 | +Sainte-Croix,33102,Chaudière-Appalaches,plex,1.0,26,26.9,not_published | |
| 1358 | +Daveluyville,39152,Centre-du-Québec,plex,1.0,26,32.8,not_published | |
| 1359 | +Saint-Antoine-de-l'Isle-aux-Grues,18070,Chaudière-Appalaches,unifamilial,1.0,26,32.8,not_published | |
| 1360 | +Saint-Zénon-du-Lac-Humqui,07035,Bas-Saint-Laurent,unifamilial,1.0,26,28.4,not_published | |
| 1361 | +East Broughton,31122,Chaudière-Appalaches,plex,1.0,26,29.9,not_published | |
| 1362 | +Sainte-Luce,09092,Bas-Saint-Laurent,plex,1.0,26,31.3,not_published | |
| 1363 | +Sainte-Félicité,17025,Chaudière-Appalaches,unifamilial,1.0,26,31.3,not_published | |
| 1364 | +Saint-Nazaire,93045,Saguenay–Lac-Saint-Jean,plex,1.0,26,32.8,not_published | |
| 1365 | +Barraute,88022,Abitibi-Témiscamingue,plex,1.0,26,31.3,not_published | |
| 1366 | +Saint-Alexis-des-Monts,51065,Mauricie,plex,1.0,26,26.9,not_published | |
| 1367 | +Portage-du-Fort,84020,Outaouais,unifamilial,1.0,26,29.9,not_published | |
| 1368 | +Calixa-Lavallée,59030,Montérégie,unifamilial,1.0,25,29.9,not_published | |
| 1369 | +Saint-Julien,31035,Chaudière-Appalaches,unifamilial,1.0,25,32.8,not_published | |
| 1370 | +Saint-Léandre,08065,Bas-Saint-Laurent,unifamilial,1.0,25,31.3,not_published | |
| 1371 | +Saint-Esprit,63030,Lanaudière,plex,1.0,25,31.3,not_published | |
| 1372 | +Cap-Saint-Ignace,18045,Chaudière-Appalaches,plex,1.0,25,29.9,not_published | |
| 1373 | +L'Islet,17078,Chaudière-Appalaches,plex,1.0,25,26.9,not_published | |
| 1374 | +McMasterville,57025,Montérégie,condo,1.0,25,29.9,not_published | |
| 1375 | +Orford,45115,Estrie,plex,1.0,25,31.3,not_published | |
| 1376 | +Hope,05025,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,25,31.3,not_published | |
| 1377 | +Saint-Séverin,27070,Chaudière-Appalaches,unifamilial,1.0,25,34.3,not_published | |
| 1378 | +La Pêche,82035,Outaouais,condo,1.0,25,29.9,not_published | |
| 1379 | +Saint-Noël,07100,Bas-Saint-Laurent,unifamilial,1.0,25,29.9,not_published | |
| 1380 | +Deschambault-Grondines,34058,Capitale-Nationale,plex,1.0,24,34.3,not_published | |
| 1381 | +New Richmond,05070,Gaspésie–Îles-de-la-Madeleine,plex,1.0,24,31.3,not_published | |
| 1382 | +Potton,45030,Estrie,condo,1.0,24,29.9,not_published | |
| 1383 | +Ange-Gardien,55008,Montérégie,plex,1.0,24,29.9,not_published | |
| 1384 | +Saint-Tharcisius,07070,Bas-Saint-Laurent,unifamilial,1.0,24,29.9,not_published | |
| 1385 | +Sainte-Monique,50057,Centre-du-Québec,unifamilial,1.0,24,26.9,not_published | |
| 1386 | +Chazel,87095,Abitibi-Témiscamingue,unifamilial,1.0,24,28.4,not_published | |
| 1387 | +Sainte-Hélène-de-Chester,39035,Centre-du-Québec,unifamilial,1.0,24,31.3,not_published | |
| 1388 | +Mont-Saint-Grégoire,56097,Montérégie,plex,1.0,24,25.4,not_published | |
| 1389 | +Aston-Jonction,50013,Centre-du-Québec,unifamilial,1.5,24,23.9,not_published | |
| 1390 | +Lyster,32065,Centre-du-Québec,plex,1.0,24,28.4,not_published | |
| 1391 | +Yamaska,53072,Montérégie,plex,1.0,24,29.9,not_published | |
| 1392 | +Ivry-sur-le-Lac,78042,Laurentides,unifamilial,1.0,24,31.3,not_published | |
| 1393 | +Pointe-Claire,66097,Montréal,plex,1.0,24,31.3,not_published | |
| 1394 | +Saint-André-d'Argenteuil,76008,Laurentides,plex,1.0,23,28.4,not_published | |
| 1395 | +Saint-Pierre-de-Lamy,13075,Bas-Saint-Laurent,unifamilial,1.0,23,31.3,not_published | |
| 1396 | +Saint-Eugène-de-Ladrière,10075,Bas-Saint-Laurent,unifamilial,1.0,23,29.9,not_published | |
| 1397 | +Notre-Dame-du-Laus,79005,Laurentides,plex,1.0,23,28.4,not_published | |
| 1398 | +Sainte-Marie-Madeleine,54030,Montérégie,plex,1.0,23,25.4,not_published | |
| 1399 | +Saint-Paulin,51060,Mauricie,plex,1.0,23,25.4,not_published | |
| 1400 | +Sainte-Clotilde-de-Beauce,31060,Chaudière-Appalaches,unifamilial,1.0,23,25.4,not_published | |
| 1401 | +Sainte-Lucie-de-Beauregard,18020,Chaudière-Appalaches,unifamilial,1.0,23,28.4,not_published | |
| 1402 | +Sainte-Florence,07010,Bas-Saint-Laurent,unifamilial,1.0,23,26.9,not_published | |
| 1403 | +Saint-Sévère,51030,Mauricie,unifamilial,1.0,23,25.4,not_published | |
| 1404 | +Authier-Nord,87100,Abitibi-Témiscamingue,unifamilial,1.0,23,32.8,not_published | |
| 1405 | +Grand-Saint-Esprit,50065,Centre-du-Québec,unifamilial,1.0,23,29.9,not_published | |
| 1406 | +Saint-Gabriel-de-Valcartier,22025,Capitale-Nationale,plex,1.0,23,26.9,not_published | |
| 1407 | +Saint-Anaclet-de-Lessard,10030,Bas-Saint-Laurent,plex,1.0,23,23.9,not_published | |
| 1408 | +Saint-Antonin,12015,Bas-Saint-Laurent,plex,1.0,23,29.9,not_published | |
| 1409 | +Béthanie,48005,Montérégie,unifamilial,1.0,23,29.9,not_published | |
| 1410 | +Saint-Jean-Port-Joli,17070,Chaudière-Appalaches,plex,1.0,23,28.4,not_published | |
| 1411 | +Normandin,92040,Saguenay–Lac-Saint-Jean,plex,1.0,23,31.3,not_published | |
| 1412 | +Saint-Barthélemy,52055,Lanaudière,plex,1.0,23,31.3,not_published | |
| 1413 | +Sacré-Coeur-de-Jésus,31130,Chaudière-Appalaches,unifamilial,1.0,23,31.3,not_published | |
| 1414 | +Sainte-Françoise,38035,Centre-du-Québec,unifamilial,1.0,23,31.3,not_published | |
| 1415 | +Saint-Henri,19068,Chaudière-Appalaches,condo,1.0,23,23.9,not_published | |
| 1416 | +Sainte-Cécile-de-Lévrard,38060,Centre-du-Québec,unifamilial,1.0,22,28.4,not_published | |
| 1417 | +East Hereford,44010,Estrie,unifamilial,1.0,22,25.4,not_published | |
| 1418 | +Notre-Dame-de-l'Île-Perrot,71065,Montérégie,plex,1.0,22,31.3,not_published | |
| 1419 | +Beauharnois,70022,Montérégie,condo,1.0,22,25.4,not_published | |
| 1420 | +Rivière-du-Loup,12072,Bas-Saint-Laurent,condo,1.0,22,23.9,not_published | |
| 1421 | +Rapide-Danseur,87010,Abitibi-Témiscamingue,unifamilial,1.0,22,26.9,not_published | |
| 1422 | +Roxton Falls,48010,Montérégie,plex,1.0,22,31.3,not_published | |
| 1423 | +Les Méchins,08005,Bas-Saint-Laurent,unifamilial,2.0,22,19.4,not_published | |
| 1424 | +La Présentation,54035,Montérégie,plex,1.0,22,28.4,not_published | |
| 1425 | +Wickham,49040,Centre-du-Québec,plex,1.0,22,23.9,not_published | |
| 1426 | +Sainte-Justine,28045,Chaudière-Appalaches,plex,1.0,22,32.8,not_published | |
| 1427 | +Marsoui,04025,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,22,26.9,not_published | |
| 1428 | +Eastman,45093,Estrie,plex,1.0,22,26.9,not_published | |
| 1429 | +Saint-Apollinaire,33090,Chaudière-Appalaches,condo,1.0,22,19.4,not_published | |
| 1430 | +Dollard-des-Ormeaux,66142,Montréal,plex,1.0,22,29.9,not_published | |
| 1431 | +Sainte-Élisabeth,52030,Lanaudière,plex,1.0,22,25.4,not_published | |
| 1432 | +Lac-Tremblant-Nord,78127,Laurentides,unifamilial,1.0,21,28.4,not_published | |
| 1433 | +Rigaud,71133,Montérégie,condo,1.0,21,28.4,not_published | |
| 1434 | +Sainte-Catherine,67030,Montérégie,condo,1.0,21,19.4,not_published | |
| 1435 | +Napierville,68030,Montérégie,plex,1.0,21,23.9,not_published | |
| 1436 | +Sainte-Rita,11015,Bas-Saint-Laurent,unifamilial,1.0,21,19.4,not_published | |
| 1437 | +Sainte-Barbe,69065,Montérégie,plex,1.0,21,26.9,not_published | |
| 1438 | +Amherst,78070,Laurentides,plex,1.0,21,25.4,not_published | |
| 1439 | +Saint-Raphaël,19082,Chaudière-Appalaches,plex,1.0,21,29.9,not_published | |
| 1440 | +Saint-Alexandre-de-Kamouraska,14035,Bas-Saint-Laurent,plex,1.0,21,26.9,not_published | |
| 1441 | +Cap-Santé,34030,Capitale-Nationale,plex,1.0,21,26.9,not_published | |
| 1442 | +Saint-Prime,91035,Saguenay–Lac-Saint-Jean,plex,1.0,21,26.9,not_published | |
| 1443 | +Saint-Jean-de-Brébeuf,31100,Chaudière-Appalaches,unifamilial,1.0,21,25.4,not_published | |
| 1444 | +Saint-Lin–Laurentides,63048,Lanaudière,condo,1.0,21,20.9,not_published | |
| 1445 | +Saint-Martin,29045,Chaudière-Appalaches,plex,1.0,21,25.4,not_published | |
| 1446 | +Témiscaming,85005,Abitibi-Témiscamingue,plex,1.0,21,25.4,not_published | |
| 1447 | +La Reine,87080,Abitibi-Témiscamingue,unifamilial,1.0,21,28.4,not_published | |
| 1448 | +Rougemont,55037,Montérégie,plex,1.0,21,23.9,not_published | |
| 1449 | +Padoue,09040,Bas-Saint-Laurent,unifamilial,1.0,21,23.9,not_published | |
| 1450 | +Chénéville,80103,Outaouais,plex,1.0,21,23.9,not_published | |
| 1451 | +Fugèreville,85055,Abitibi-Témiscamingue,unifamilial,1.0,21,28.4,not_published | |
| 1452 | +Stoke,42005,Estrie,plex,1.0,21,26.9,not_published | |
| 1453 | +Saint-Pierre-de-l'Île-d'Orléans,20025,Capitale-Nationale,plex,1.0,21,26.9,not_published | |
| 1454 | +Saint-Fortunat,31030,Chaudière-Appalaches,unifamilial,1.0,21,25.4,not_published | |
| 1455 | +Sainte-Élizabeth-de-Warwick,39090,Centre-du-Québec,unifamilial,1.0,21,26.9,not_published | |
| 1456 | +Saint-Maurice,37230,Mauricie,plex,1.0,20,22.4,not_published | |
| 1457 | +Saint-Joachim,21020,Capitale-Nationale,plex,1.0,20,25.4,not_published | |
| 1458 | +Saint-Fulgence,94235,Saguenay–Lac-Saint-Jean,plex,1.0,20,25.4,not_published | |
| 1459 | +Les Cèdres,71050,Montérégie,plex,1.0,20,23.9,not_published | |
| 1460 | +Saint-Basile,34038,Capitale-Nationale,plex,1.0,20,26.9,not_published | |
| 1461 | +Saint-Gérard-Majella,53085,Montérégie,unifamilial,1.0,20,22.4,not_published | |
| 1462 | +Caplan,05060,Gaspésie–Îles-de-la-Madeleine,plex,1.0,20,28.4,not_published | |
| 1463 | +Baie-Sainte-Catherine,15065,Capitale-Nationale,unifamilial,1.0,20,25.4,not_published | |
| 1464 | +Cacouna,12057,Bas-Saint-Laurent,plex,1.0,20,28.4,not_published | |
| 1465 | +Lavaltrie,52007,Lanaudière,condo,1.0,20,28.4,not_published | |
| 1466 | +Saint-Léonard-d'Aston,50042,Centre-du-Québec,plex,1.0,20,22.4,not_published | |
| 1467 | +Sainte-Victoire-de-Sorel,53025,Montérégie,plex,1.0,20,28.4,not_published | |
| 1468 | +Laniel,85905,Abitibi-Témiscamingue,unifamilial,1.0,20,25.4,not_published | |
| 1469 | +Lac-Normand,35904,Mauricie,unifamilial,1.0,20,26.9,not_published | |
| 1470 | +Sainte-Jeanne-d'Arc-de-la-Mitis,09020,Bas-Saint-Laurent,unifamilial,1.0,20,29.9,not_published | |
| 1471 | +Saint-Charles-de-Bellechasse,19097,Chaudière-Appalaches,plex,1.0,20,26.9,not_published | |
| 1472 | +Ristigouche-Sud-Est,06035,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,20,23.9,not_published | |
| 1473 | +Upton,48038,Montérégie,plex,1.0,20,25.4,not_published | |
| 1474 | +Saint-Tite-des-Caps,21005,Capitale-Nationale,plex,1.0,20,25.4,not_published | |
| 1475 | +Lac-aux-Sables,35010,Mauricie,plex,1.0,20,20.9,not_published | |
| 1476 | +Saint-David-de-Falardeau,94245,Saguenay–Lac-Saint-Jean,plex,1.0,19,23.9,not_published | |
| 1477 | +Saint-Placide,72043,Laurentides,plex,1.0,19,25.4,not_published | |
| 1478 | +Mandeville,52095,Lanaudière,plex,1.0,19,22.4,not_published | |
| 1479 | +Desbiens,93005,Saguenay–Lac-Saint-Jean,plex,1.0,19,23.9,not_published | |
| 1480 | +Grenville-sur-la-Rouge,76052,Laurentides,plex,1.0,19,22.4,not_published | |
| 1481 | +Rivière-Beaudette,71005,Montérégie,plex,1.0,19,22.4,not_published | |
| 1482 | +Cacouna,12057,Bas-Saint-Laurent,condo,1.0,19,22.4,not_published | |
| 1483 | +La Visitation-de-Yamaska,50085,Centre-du-Québec,unifamilial,1.0,19,26.9,not_published | |
| 1484 | +Sainte-Catherine-de-Hatley,45060,Estrie,plex,1.0,19,25.4,not_published | |
| 1485 | +Hope Town,05020,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,19,26.9,not_published | |
| 1486 | +Sainte-Catherine-de-la-Jacques-Cartier,22005,Capitale-Nationale,condo,1.0,19,23.9,not_published | |
| 1487 | +Saint-Lambert,87120,Abitibi-Témiscamingue,unifamilial,1.0,19,23.9,not_published | |
| 1488 | +Saint-Jacques-le-Majeur-de-Wolfestown,31025,Chaudière-Appalaches,unifamilial,1.0,19,22.4,not_published | |
| 1489 | +Grosse-Île,01042,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,19,22.4,not_published | |
| 1490 | +Saint-Pascal,14018,Bas-Saint-Laurent,plex,1.0,19,23.9,not_published | |
| 1491 | +Rivière-aux-Outardes,96902,Côte-Nord,unifamilial,1.0,19,25.4,not_published | |
| 1492 | +Saint-Siméon,05055,Gaspésie–Îles-de-la-Madeleine,plex,1.0,19,23.9,not_published | |
| 1493 | +Saint-Côme–Linière,29057,Chaudière-Appalaches,plex,1.0,19,25.4,not_published | |
| 1494 | +Petite-Rivière-Saint-François,16005,Capitale-Nationale,plex,1.0,19,22.4,not_published | |
| 1495 | +Saint-Bruno-de-Guigues,85045,Abitibi-Témiscamingue,plex,1.0,19,26.9,not_published | |
| 1496 | +Pointe-des-Cascades,71055,Montérégie,plex,1.0,19,25.4,not_published | |
| 1497 | +Lorraine,73025,Laurentides,condo,1.0,18,22.4,not_published | |
| 1498 | +Saint-Isidore,67040,Montérégie,plex,1.0,18,19.4,not_published | |
| 1499 | +Saint-Edmond-les-Plaines,92050,Saguenay–Lac-Saint-Jean,unifamilial,1.0,18,22.4,not_published | |
| 1500 | +Saint-Dominique,54060,Montérégie,plex,1.0,18,23.9,not_published | |
| 1501 | +La Martre,04030,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,18,25.4,not_published | |
| 1502 | +Saint-Gédéon-de-Beauce,29013,Chaudière-Appalaches,plex,1.0,18,22.4,not_published | |
| 1503 | +Venise-en-Québec,56005,Montérégie,plex,1.0,18,26.9,not_published | |
| 1504 | +Saint-Joseph-de-Kamouraska,14030,Bas-Saint-Laurent,unifamilial,1.0,18,25.4,not_published | |
| 1505 | +Shawville,84010,Outaouais,plex,1.0,18,22.4,not_published | |
| 1506 | +Pierreville,50113,Centre-du-Québec,plex,1.0,18,19.4,not_published | |
| 1507 | +Dunham,46050,Estrie,plex,1.0,18,25.4,not_published | |
| 1508 | +Saint-Gilbert,34060,Capitale-Nationale,unifamilial,1.0,18,22.4,not_published | |
| 1509 | +Saint-Fabien,10070,Bas-Saint-Laurent,plex,1.0,18,22.4,not_published | |
| 1510 | +Deux-Montagnes,72010,Laurentides,condo,1.0,18,20.9,not_published | |
| 1511 | +Saint-Narcisse,37240,Mauricie,plex,1.0,18,23.9,not_published | |
| 1512 | +Saint-Cléophas-de-Brandon,52075,Lanaudière,unifamilial,1.0,18,20.9,not_published | |
| 1513 | +Sainte-Marcelline-de-Kildare,62030,Lanaudière,plex,1.0,18,23.9,not_published | |
| 1514 | +Saint-Paul-d'Abbotsford,55015,Montérégie,plex,1.0,18,19.4,not_published | |
| 1515 | +Mansfield-et-Pontefract,84065,Outaouais,plex,1.0,18,23.9,not_published | |
| 1516 | +La Malbaie,15013,Capitale-Nationale,condo,1.0,18,22.4,not_published | |
| 1517 | +Albertville,07025,Bas-Saint-Laurent,unifamilial,1.0,18,20.9,not_published | |
| 1518 | +Pincourt,71070,Montérégie,plex,1.0,18,22.4,not_published | |
| 1519 | +La Guadeloupe,29030,Chaudière-Appalaches,plex,1.0,18,19.4,not_published | |
| 1520 | +Grande-Rivière,02015,Gaspésie–Îles-de-la-Madeleine,plex,1.0,18,22.4,not_published | |
| 1521 | +Saint-Éphrem-de-Beauce,29112,Chaudière-Appalaches,plex,1.0,18,23.9,not_published | |
| 1522 | +Hampden,41075,Estrie,unifamilial,1.0,18,23.9,not_published | |
| 1523 | +Chambord,91020,Saguenay–Lac-Saint-Jean,plex,1.0,18,20.9,not_published | |
| 1524 | +Saint-Pierre-de-l'Île-d'Orléans,20025,Capitale-Nationale,condo,1.0,18,22.4,not_published | |
| 1525 | +Saint-Ignace-de-Loyola,52045,Lanaudière,plex,1.0,18,23.9,not_published | |
| 1526 | +Saint-Siméon,15058,Capitale-Nationale,plex,1.0,18,23.9,not_published | |
| 1527 | +Notre-Dame-des-Pins,29120,Chaudière-Appalaches,plex,1.0,18,20.9,not_published | |
| 1528 | +Saint-Athanase,13100,Bas-Saint-Laurent,unifamilial,1.0,18,22.4,not_published | |
| 1529 | +Saint-Elzéar-de-Témiscouata,13085,Bas-Saint-Laurent,unifamilial,1.0,17,20.9,not_published | |
| 1530 | +Saint-Pacôme,14070,Bas-Saint-Laurent,plex,1.0,17,16.4,not_published | |
| 1531 | +Sainte-Anne-de-Sorel,53065,Montérégie,plex,1.0,17,20.9,not_published | |
| 1532 | +Paspébiac,05032,Gaspésie–Îles-de-la-Madeleine,plex,1.0,17,20.9,not_published | |
| 1533 | +Saint-Médard,11025,Bas-Saint-Laurent,unifamilial,1.0,17,20.9,not_published | |
| 1534 | +Larouche,94265,Saguenay–Lac-Saint-Jean,plex,1.0,17,22.4,not_published | |
| 1535 | +Shefford,47035,Estrie,plex,1.0,17,20.9,not_published | |
| 1536 | +Sacré-Coeur,95010,Côte-Nord,plex,1.0,17,19.4,not_published | |
| 1537 | +Aguanish,98030,Côte-Nord,unifamilial,1.0,17,22.4,not_published | |
| 1538 | +Val-des-Lacs,78100,Laurentides,plex,1.0,17,22.4,not_published | |
| 1539 | +Brébeuf,78075,Laurentides,plex,1.0,17,20.9,not_published | |
| 1540 | +Saint-Polycarpe,71020,Montérégie,plex,1.0,17,22.4,not_published | |
| 1541 | +Saint-Pamphile,17010,Chaudière-Appalaches,plex,1.0,17,23.9,not_published | |
| 1542 | +Champlain,37220,Mauricie,plex,1.0,17,20.9,not_published | |
| 1543 | +Saint-Alphonse-de-Granby,47010,Estrie,plex,1.0,17,20.9,not_published | |
| 1544 | +Les Escoumins,95025,Côte-Nord,plex,1.0,17,25.4,not_published | |
| 1545 | +Saint-Philibert,29065,Chaudière-Appalaches,unifamilial,1.0,17,20.9,not_published | |
| 1546 | +Saint-François-Xavier-de-Brompton,42020,Estrie,plex,1.0,17,20.9,not_published | |
| 1547 | +Notre-Dame-de-l'Île-Perrot,71065,Montérégie,condo,1.0,17,19.4,not_published | |
| 1548 | +Saint-Hyacinthe,54048,Montérégie,condo,1.0,17,20.9,not_published | |
| 1549 | +Lorrainville,85037,Abitibi-Témiscamingue,plex,1.0,17,23.9,not_published | |
| 1550 | +Sept-Îles,97007,Côte-Nord,condo,1.0,17,17.9,not_published | |
| 1551 | +Montebello,80010,Outaouais,plex,1.0,17,17.9,not_published | |
| 1552 | +Saint-Jean-de-Dieu,11010,Bas-Saint-Laurent,plex,1.0,17,22.4,not_published | |
| 1553 | +Farnham,46112,Estrie,condo,1.0,17,23.9,not_published | |
| 1554 | +Chertsey,62047,Lanaudière,plex,1.0,17,25.4,not_published | |
| 1555 | +Saint-Liboire,54072,Montérégie,plex,1.0,16,19.4,not_published | |
| 1556 | +Causapscal,07018,Bas-Saint-Laurent,plex,1.0,16,22.4,not_published | |
| 1557 | +Belcourt,89050,Abitibi-Témiscamingue,unifamilial,1.0,16,19.4,not_published | |
| 1558 | +Saint-Marc-sur-Richelieu,57050,Montérégie,plex,1.0,16,19.4,not_published | |
| 1559 | +Saint-Cléophas,07090,Bas-Saint-Laurent,unifamilial,1.0,16,22.4,not_published | |
| 1560 | +Rivière-à-Claude,04020,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,16,17.9,not_published | |
| 1561 | +Saint-Gervais,19075,Chaudière-Appalaches,plex,1.0,16,19.4,not_published | |
| 1562 | +Stanbridge Station,46030,Estrie,unifamilial,1.0,16,17.9,not_published | |
| 1563 | +Sainte-Hélène-de-Bagot,54095,Montérégie,plex,1.0,16,19.4,not_published | |
| 1564 | +L'Île-d'Anticosti,98020,Côte-Nord,unifamilial,1.0,16,20.9,not_published | |
| 1565 | +Potton,45030,Estrie,plex,1.0,16,22.4,not_published | |
| 1566 | +Saint-Gilles,33035,Chaudière-Appalaches,plex,1.0,16,19.4,not_published | |
| 1567 | +Hudson,71100,Montérégie,plex,1.0,16,19.4,not_published | |
| 1568 | +Natashquan,98025,Côte-Nord,unifamilial,1.0,16,19.4,not_published | |
| 1569 | +Sainte-Émélie-de-l'Énergie,62070,Lanaudière,plex,1.0,16,23.9,not_published | |
| 1570 | +Nominingue,79030,Laurentides,plex,1.0,16,20.9,not_published | |
| 1571 | +Saint-Ulric,08073,Bas-Saint-Laurent,plex,1.0,16,22.4,not_published | |
| 1572 | +Saint-Germain-de-Kamouraska,14045,Bas-Saint-Laurent,unifamilial,1.0,16,20.9,not_published | |
| 1573 | +Saint-Polycarpe,71020,Montérégie,condo,1.0,16,17.9,not_published | |
| 1574 | +Notre-Dame-du-Nord,85090,Abitibi-Témiscamingue,plex,1.0,16,20.9,not_published | |
| 1575 | +Saint-Clet,71045,Montérégie,plex,1.0,16,22.4,not_published | |
| 1576 | +Brownsburg-Chatham,76043,Laurentides,condo,1.0,16,19.4,not_published | |
| 1577 | +Lac-Bouchette,91005,Saguenay–Lac-Saint-Jean,plex,1.0,15,17.9,not_published | |
| 1578 | +Saint-Cuthbert,52062,Lanaudière,plex,1.0,15,19.4,not_published | |
| 1579 | +Authier,87050,Abitibi-Témiscamingue,unifamilial,1.0,15,20.9,not_published | |
| 1580 | +Saint-Étienne-des-Grès,51090,Mauricie,plex,1.0,15,20.9,not_published | |
| 1581 | +L'Anse-Saint-Jean,94210,Saguenay–Lac-Saint-Jean,plex,1.0,15,19.4,not_published | |
| 1582 | +Ripon,80078,Outaouais,plex,1.0,15,20.9,not_published | |
| 1583 | +Saint-Zacharie,28005,Chaudière-Appalaches,plex,1.0,15,20.9,not_published | |
| 1584 | +Saint-Laurent-de-l'Île-d'Orléans,20020,Capitale-Nationale,plex,1.0,15,19.4,not_published | |
| 1585 | +Saint-Denis-sur-Richelieu,57068,Montérégie,plex,1.0,15,17.9,not_published | |
| 1586 | +Saint-Donat,62060,Lanaudière,condo,1.0,15,14.9,not_published | |
| 1587 | +Léry,67055,Montérégie,plex,1.0,15,19.4,not_published | |
| 1588 | +Lemieux,38020,Centre-du-Québec,unifamilial,1.0,15,19.4,not_published | |
| 1589 | +Baie-D'Urfé,66112,Montréal,condo,1.0,15,20.9,not_published | |
| 1590 | +Notre-Dame-des-Prairies,61030,Lanaudière,condo,1.0,15,17.9,not_published | |
| 1591 | +Port-Daniel–Gascons,02047,Gaspésie–Îles-de-la-Madeleine,plex,1.0,15,20.9,not_published | |
| 1592 | +Coteau-du-Lac,71040,Montérégie,condo,1.0,15,17.9,not_published | |
| 1593 | +Franklin,69010,Montérégie,plex,1.0,15,20.9,not_published | |
| 1594 | +Mont-Saint-Pierre,04015,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,14,16.4,not_published | |
| 1595 | +Shigawake,05010,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,14,17.9,not_published | |
| 1596 | +Stanstead,45025,Estrie,plex,1.0,14,17.9,not_published | |
| 1597 | +Sayabec,07085,Bas-Saint-Laurent,plex,1.0,14,19.4,not_published | |
| 1598 | +Pont-Rouge,34017,Capitale-Nationale,condo,1.0,14,20.9,not_published | |
| 1599 | +Maria,06005,Gaspésie–Îles-de-la-Madeleine,plex,1.0,14,19.4,not_published | |
| 1600 | +Napierville,68030,Montérégie,condo,1.0,14,16.4,not_published | |
| 1601 | +Saint-Anicet,69070,Montérégie,plex,1.0,14,20.9,not_published | |
| 1602 | +Nouvelle,06020,Gaspésie–Îles-de-la-Madeleine,plex,1.0,14,17.9,not_published | |
| 1603 | +L'Ascension-de-Notre-Seigneur,93065,Saguenay–Lac-Saint-Jean,plex,1.0,14,17.9,not_published | |
| 1604 | +Hampstead,66062,Montréal,condo,1.0,14,17.9,not_published | |
| 1605 | +Compton,44071,Estrie,plex,1.0,14,19.4,not_published | |
| 1606 | +Saint-Alexis,63023,Lanaudière,plex,1.0,14,14.9,not_published | |
| 1607 | +Saint-Édouard-de-Lotbinière,33080,Chaudière-Appalaches,plex,1.0,14,20.9,not_published | |
| 1608 | +Lac-Poulin,29095,Chaudière-Appalaches,unifamilial,1.0,14,20.9,not_published | |
| 1609 | +Durham-Sud,49015,Centre-du-Québec,plex,1.0,14,19.4,not_published | |
| 1610 | +Saint-Pierre,61020,Lanaudière,unifamilial,1.0,14,20.9,not_published | |
| 1611 | +Chute-aux-Outardes,96035,Côte-Nord,plex,1.0,14,17.9,not_published | |
| 1612 | +Saint-Isidore,26063,Chaudière-Appalaches,plex,1.0,14,17.9,not_published | |
| 1613 | +Saint-Gabriel-de-Brandon,52085,Lanaudière,plex,1.0,14,20.9,not_published | |
| 1614 | +Belleterre,85065,Abitibi-Témiscamingue,unifamilial,1.0,13,16.4,not_published | |
| 1615 | +Sainte-Geneviève-de-Berthier,52040,Lanaudière,plex,1.0,13,17.9,not_published | |
| 1616 | +Saint-Gilles,33035,Chaudière-Appalaches,condo,1.0,13,13.4,not_published | |
| 1617 | +Lac-Mégantic,30030,Estrie,condo,1.0,13,17.9,not_published | |
| 1618 | +Sainte-Béatrix,62020,Lanaudière,plex,1.0,13,19.4,not_published | |
| 1619 | +Saint-Stanislas-de-Kostka,70040,Montérégie,plex,1.0,13,16.4,not_published | |
| 1620 | +Saint-Urbain,16055,Capitale-Nationale,plex,1.0,13,16.4,not_published | |
| 1621 | +Saint-Michel-de-Bellechasse,19110,Chaudière-Appalaches,plex,1.0,13,17.9,not_published | |
| 1622 | +Saint-Liguori,63065,Lanaudière,plex,1.0,13,16.4,not_published | |
| 1623 | +Saint-Honoré-de-Témiscouata,13090,Bas-Saint-Laurent,plex,1.0,13,17.9,not_published | |
| 1624 | +Berthier-sur-Mer,18065,Chaudière-Appalaches,plex,1.0,13,17.9,not_published | |
| 1625 | +Frelighsburg,46010,Estrie,plex,1.0,13,16.4,not_published | |
| 1626 | +Sainte-Perpétue,17030,Chaudière-Appalaches,plex,1.0,13,17.9,not_published | |
| 1627 | +La Morandière-Rochebaucourt,88012,Abitibi-Témiscamingue,unifamilial,1.0,13,16.4,not_published | |
| 1628 | +Saint-Roch-de-l'Achigan,63035,Lanaudière,condo,1.0,13,17.9,not_published | |
| 1629 | +Béarn,85020,Abitibi-Témiscamingue,plex,1.0,13,17.9,not_published | |
| 1630 | +Fort-Coulonge,84060,Outaouais,plex,1.0,13,17.9,not_published | |
| 1631 | +Saint-Louis-du-Ha! Ha!,13080,Bas-Saint-Laurent,plex,1.0,13,14.9,not_published | |
| 1632 | +Sainte-Lucie-des-Laurentides,78020,Laurentides,plex,1.0,13,13.4,not_published | |
| 1633 | +Vallée-Jonction,26015,Chaudière-Appalaches,plex,1.0,13,13.4,not_published | |
| 1634 | +Entrelacs,62053,Lanaudière,plex,1.0,13,16.4,not_published | |
| 1635 | +Armagh,19037,Chaudière-Appalaches,plex,1.0,13,19.4,not_published | |
| 1636 | +Austin,45085,Estrie,plex,1.0,13,19.4,not_published | |
| 1637 | +Mille-Isles,76030,Laurentides,plex,1.0,13,14.9,not_published | |
| 1638 | +Messines,83060,Outaouais,plex,1.0,13,17.9,not_published | |
| 1639 | +Launay,88080,Abitibi-Témiscamingue,unifamilial,1.0,13,17.9,not_published | |
| 1640 | +Albanel,92030,Saguenay–Lac-Saint-Jean,plex,1.0,13,16.4,not_published | |
| 1641 | +Rosemère,73020,Laurentides,condo,1.0,13,14.9,not_published | |
| 1642 | +Tingwick,39025,Centre-du-Québec,plex,1.0,13,19.4,not_published | |
| 1643 | +Lawrenceville,42045,Estrie,plex,1.0,13,16.4,not_published | |
| 1644 | +Saint-Alexandre,56055,Montérégie,plex,1.0,13,17.9,not_published | |
| 1645 | +Sheenboro,84095,Outaouais,unifamilial,1.0,13,16.4,not_published | |
| 1646 | +Saint-Godefroi,05015,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,13,16.4,not_published | |
| 1647 | +L'Isle-aux-Coudres,16023,Capitale-Nationale,plex,1.0,12,16.4,not_published | |
| 1648 | +Plaisance,80045,Outaouais,plex,1.0,12,16.4,not_published | |
| 1649 | +Côte-Nord-du-Golfe-du-Saint-Laurent,98015,Côte-Nord,unifamilial,1.0,12,16.4,not_published | |
| 1650 | +Lambton,30095,Estrie,plex,1.0,12,14.9,not_published | |
| 1651 | +Lac-Simon,80095,Outaouais,plex,1.0,12,14.9,not_published | |
| 1652 | +Saint-Antoine-de-Tilly,33095,Chaudière-Appalaches,plex,1.0,12,16.4,not_published | |
| 1653 | +Saint-Damien-de-Buckland,19030,Chaudière-Appalaches,plex,1.0,12,16.4,not_published | |
| 1654 | +Godmanchester,69060,Montérégie,plex,1.0,12,17.9,not_published | |
| 1655 | +Saint-Étienne-de-Beauharnois,70030,Montérégie,plex,1.0,12,16.4,not_published | |
| 1656 | +Adstock,31056,Chaudière-Appalaches,plex,1.0,12,17.9,not_published | |
| 1657 | +Saint-Elphège,50095,Centre-du-Québec,unifamilial,1.0,12,16.4,not_published | |
| 1658 | +Nicolet,50072,Centre-du-Québec,condo,1.0,12,17.9,not_published | |
| 1659 | +Saint-Damase,54017,Montérégie,plex,1.0,12,14.9,not_published | |
| 1660 | +Saint-Bernard,26055,Chaudière-Appalaches,plex,1.0,12,17.9,not_published | |
| 1661 | +Wotton,40017,Estrie,plex,1.0,12,17.9,not_published | |
| 1662 | +Sainte-Marguerite-Marie,07005,Bas-Saint-Laurent,unifamilial,1.0,12,14.9,not_published | |
| 1663 | +Venise-en-Québec,56005,Montérégie,condo,1.5,12,11.9,not_published | |
| 1664 | +Saint-Jean-de-Cherbourg,08010,Bas-Saint-Laurent,unifamilial,1.0,12,14.9,not_published | |
| 1665 | +Saint-André-de-Restigouche,06040,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,12,14.9,not_published | |
| 1666 | +Saint-Victor,27008,Chaudière-Appalaches,plex,1.0,12,16.4,not_published | |
| 1667 | +Otter Lake,84055,Outaouais,plex,1.0,12,14.9,not_published | |
| 1668 | +Baie-du-Febvre,50100,Centre-du-Québec,plex,1.0,11,13.4,not_published | |
| 1669 | +Saint-Benoît-Labre,29100,Chaudière-Appalaches,plex,1.0,11,13.4,not_published | |
| 1670 | +Saint-Joseph-de-Coleraine,31045,Chaudière-Appalaches,plex,1.0,11,14.9,not_published | |
| 1671 | +Chelsea,82025,Outaouais,condo,1.0,11,11.9,not_published | |
| 1672 | +Saint-Mathieu-de-Beloeil,57045,Montérégie,plex,1.0,11,16.4,not_published | |
| 1673 | +Saint-Stanislas,37245,Mauricie,plex,1.0,11,14.9,not_published | |
| 1674 | +La Minerve,78130,Laurentides,plex,1.0,11,16.4,not_published | |
| 1675 | +Saint-Mathieu,67005,Montérégie,plex,1.0,11,16.4,not_published | |
| 1676 | +Saint-Ours,53032,Montérégie,plex,1.0,11,16.4,not_published | |
| 1677 | +Campbell's Bay,84030,Outaouais,plex,1.0,11,14.9,not_published | |
| 1678 | +Lac-Walker,97904,Côte-Nord,unifamilial,1.0,11,14.9,not_published | |
| 1679 | +Saint-Louis-de-Gonzague,70035,Montérégie,plex,1.0,11,16.4,not_published | |
| 1680 | +Saint-Alban,34097,Capitale-Nationale,plex,1.0,11,14.9,not_published | |
| 1681 | +Saint-Alexandre-des-Lacs,07065,Bas-Saint-Laurent,unifamilial,1.0,11,14.9,not_published | |
| 1682 | +Percé,02005,Gaspésie–Îles-de-la-Madeleine,plex,1.0,11,14.9,not_published | |
| 1683 | +Saint-Ferdinand,32013,Centre-du-Québec,plex,1.0,11,16.4,not_published | |
| 1684 | +L'Anse-Saint-Jean,94210,Saguenay–Lac-Saint-Jean,condo,1.0,11,13.4,not_published | |
| 1685 | +Sainte-Anne-de-Bellevue,66117,Montréal,condo,1.0,11,13.4,not_published | |
| 1686 | +Saint-Mathieu-de-Beloeil,57045,Montérégie,condo,1.0,11,11.9,not_published | |
| 1687 | +Murdochville,03025,Gaspésie–Îles-de-la-Madeleine,plex,1.0,11,16.4,not_published | |
| 1688 | +Déléage,83070,Outaouais,plex,1.0,11,14.9,not_published | |
| 1689 | +Saint-Guillaume,49113,Centre-du-Québec,plex,1.0,11,16.4,not_published | |
| 1690 | +Saint-Honoré-de-Shenley,29038,Chaudière-Appalaches,plex,1.0,11,13.4,not_published | |
| 1691 | +Notre-Dame-de-Lorette,92060,Saguenay–Lac-Saint-Jean,unifamilial,1.0,11,14.9,not_published | |
| 1692 | +Dudswell,41117,Estrie,plex,1.0,10,14.9,not_published | |
| 1693 | +Rivière-Saint-Jean,98050,Côte-Nord,unifamilial,1.0,10,13.4,not_published | |
| 1694 | +Saint-Adelphe,35015,Mauricie,plex,1.0,10,14.9,not_published | |
| 1695 | +Saint-Sylvestre,33007,Chaudière-Appalaches,plex,1.0,10,14.9,not_published | |
| 1696 | +Batiscan,37210,Mauricie,plex,1.0,10,14.9,not_published | |
| 1697 | +Sainte-Brigide-d'Iberville,56105,Montérégie,plex,1.0,10,11.9,not_published | |
| 1698 | +Rapides-des-Joachims,84100,Outaouais,unifamilial,1.0,10,13.4,not_published | |
| 1699 | +Saint-Chrysostome,69017,Montérégie,plex,1.0,10,11.9,not_published | |
| 1700 | +Ange-Gardien,55008,Montérégie,condo,1.0,10,14.9,not_published | |
| 1701 | +Deschaillons-sur-Saint-Laurent,38070,Centre-du-Québec,plex,1.0,10,11.9,not_published | |
| 1702 | +Palmarolle,87025,Abitibi-Témiscamingue,plex,1.0,10,13.4,not_published | |
| 1703 | +Saint-Juste-du-Lac,13040,Bas-Saint-Laurent,unifamilial,1.0,10,11.9,not_published | |
| 1704 | +Saint-Antoine-sur-Richelieu,57075,Montérégie,plex,1.0,10,13.4,not_published | |
| 1705 | +Matagami,99015,Nord-du-Québec,plex,1.0,10,14.9,not_published | |
| 1706 | +Lachute,76020,Laurentides,condo,1.0,10,13.4,not_published | |
| 1707 | +Val-Joli,42095,Estrie,plex,1.0,10,14.9,not_published | |
| 1708 | +Saint-Roch-Ouest,63040,Lanaudière,unifamilial,1.0,10,13.4,not_published | |
| 1709 | +Saint-Charles-Garnier,09010,Bas-Saint-Laurent,unifamilial,1.0,10,11.9,not_published | |
| 1710 | +Saint-Blaise-sur-Richelieu,56065,Montérégie,plex,1.0,10,14.9,not_published | |
| 1711 | +Chapais,99020,Nord-du-Québec,plex,1.0,10,13.4,not_published | |
| 1712 | +Grand-Remous,83095,Outaouais,plex,1.0,10,14.9,not_published | |
| 1713 | +Saint-Damien,62075,Lanaudière,plex,1.0,10,13.4,not_published | |
| 1714 | +Notre-Dame-des-Sept-Douleurs,12045,Bas-Saint-Laurent,unifamilial,1.0,10,13.4,not_published | |
| 1715 | +Saint-Michel,68050,Montérégie,plex,1.0,10,14.9,not_published | |
| 1716 | +Saint-Robert,53020,Montérégie,plex,1.0,10,11.9,not_published | |
| 1717 | +Wentworth-Nord,77060,Laurentides,plex,1.0,10,13.4,not_published | |
| 1718 | +Massueville,53010,Montérégie,plex,1.0,10,13.4,not_published | |
| 1719 | +Rivière-Bleue,13025,Bas-Saint-Laurent,plex,1.0,10,13.4,not_published | |
| 1720 | +Beaumont,19105,Chaudière-Appalaches,plex,1.0,10,14.9,not_published | |
| 1721 | +Price,09065,Bas-Saint-Laurent,plex,1.0,10,13.4,not_published | |
| 1722 | +Bégin,94250,Saguenay–Lac-Saint-Jean,plex,1.0,10,14.9,not_published | |
| 1723 | +Frelighsburg,46010,Estrie,condo,1.0,10,14.9,not_published | |
| 1724 | +Saint-Célestin,50030,Centre-du-Québec,plex,1.0,10,14.9,not_published | |
| 1725 | +Saint-Jude,54110,Montérégie,plex,1.0,10,14.9,not_published | |
| 1726 | +Taschereau,87042,Abitibi-Témiscamingue,plex,1.0,10,14.9,not_published | |
| 1727 | +Notre-Dame-du-Portage,12080,Bas-Saint-Laurent,plex,1.0,10,13.4,not_published | |
| 1728 | +Saint-Venant-de-Paquette,44005,Estrie,unifamilial,1.0,10,14.9,not_published | |
| 1729 | +Montpellier,80090,Outaouais,plex,1.0,10,14.9,not_published | |
| 1730 | +Sainte-Cécile-de-Milton,47055,Estrie,plex,1.0,10,11.9,not_published | |
| 1731 | +Bristol,84005,Outaouais,plex,1.0,10,11.9,not_published | |
| 1732 | +Laurierville,32072,Centre-du-Québec,plex,1.0,10,14.9,not_published | |
| 1733 | +Lac-Sainte-Marie,83020,Outaouais,plex,1.0,9,10.4,not_published | |
| 1734 | +Sainte-Clotilde,68020,Montérégie,plex,1.0,9,10.4,not_published | |
| 1735 | +Stanbridge East,46045,Estrie,plex,1.0,9,11.9,not_published | |
| 1736 | +Hemmingford,68010,Montérégie,plex,1.0,9,10.4,not_published | |
| 1737 | +Saint-Magloire,28075,Chaudière-Appalaches,plex,1.0,9,13.4,not_published | |
| 1738 | +Saint-Léonard-de-Portneuf,34115,Capitale-Nationale,plex,1.0,9,13.4,not_published | |
| 1739 | +Les Éboulements,16048,Capitale-Nationale,plex,1.0,9,10.4,not_published | |
| 1740 | +Petite-Vallée,03015,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,9,13.4,not_published | |
| 1741 | +Saint-Boniface,51085,Mauricie,condo,1.0,9,13.4,not_published | |
| 1742 | +Frontenac,30025,Estrie,plex,1.0,9,13.4,not_published | |
| 1743 | +Montréal-Ouest,66047,Montréal,condo,1.0,9,11.9,not_published | |
| 1744 | +Saint-Cyrille-de-Lessard,17045,Chaudière-Appalaches,plex,1.0,9,13.4,not_published | |
| 1745 | +Kingsey Falls,39097,Centre-du-Québec,plex,1.0,9,11.9,not_published | |
| 1746 | +Saint-Théodore-d'Acton,48045,Montérégie,plex,1.0,9,11.9,not_published | |
| 1747 | +Lochaber-Partie-Ouest,80060,Outaouais,plex,1.0,9,11.9,not_published | |
| 1748 | +L'Isle-Verte,12043,Bas-Saint-Laurent,plex,1.0,9,11.9,not_published | |
| 1749 | +L'Isle-aux-Allumettes,84082,Outaouais,plex,1.0,9,11.9,not_published | |
| 1750 | +Bouchette,83050,Outaouais,plex,1.0,9,11.9,not_published | |
| 1751 | +Girardville,92055,Saguenay–Lac-Saint-Jean,plex,1.0,9,13.4,not_published | |
| 1752 | +Kingsbury,42070,Estrie,unifamilial,1.0,9,13.4,not_published | |
| 1753 | +Saint-Philémon,19005,Chaudière-Appalaches,plex,1.0,9,13.4,not_published | |
| 1754 | +Saint-Gédéon,93035,Saguenay–Lac-Saint-Jean,plex,1.0,9,11.9,not_published | |
| 1755 | +Saint-Aimé-du-Lac-des-Îles,79022,Laurentides,plex,1.0,9,11.9,not_published | |
| 1756 | +Tring-Jonction,27060,Chaudière-Appalaches,plex,1.0,9,11.9,not_published | |
| 1757 | +Thetford Mines,31084,Chaudière-Appalaches,condo,1.0,9,13.4,not_published | |
| 1758 | +Auclair,13045,Bas-Saint-Laurent,unifamilial,1.0,9,11.9,not_published | |
| 1759 | +Saint-Henri-de-Taillon,93070,Saguenay–Lac-Saint-Jean,plex,1.0,9,13.4,not_published | |
| 1760 | +Saint-Charles-sur-Richelieu,57057,Montérégie,plex,1.0,9,11.9,not_published | |
| 1761 | +Saint-Patrice-de-Sherrington,68025,Montérégie,plex,1.0,9,11.9,not_published | |
| 1762 | +Chesterville,39030,Centre-du-Québec,plex,1.0,9,11.9,not_published | |
| 1763 | +Sainte-Monique,93075,Saguenay–Lac-Saint-Jean,plex,1.0,9,13.4,not_published | |
| 1764 | +Sainte-Anne-de-Sabrevois,56060,Montérégie,plex,1.0,9,13.4,not_published | |
| 1765 | +Mont-Blanc,78047,Laurentides,condo,1.0,9,13.4,not_published | |
| 1766 | +Saint-Hilarion,16050,Capitale-Nationale,plex,1.0,9,13.4,not_published | |
| 1767 | +L'Île-Dorval,66092,Montréal,unifamilial,1.0,9,13.4,not_published | |
| 1768 | +Val-Alain,33070,Chaudière-Appalaches,plex,1.0,9,11.9,not_published | |
| 1769 | +Saint-Michel-des-Saints,62085,Lanaudière,plex,1.0,8,11.9,not_published | |
| 1770 | +Saint-Maxime-du-Mont-Louis,04010,Gaspésie–Îles-de-la-Madeleine,plex,1.0,8,11.9,not_published | |
| 1771 | +Saint-Ludger-de-Milot,93080,Saguenay–Lac-Saint-Jean,plex,1.0,8,10.4,not_published | |
| 1772 | +Saint-François-de-Sales,91015,Saguenay–Lac-Saint-Jean,plex,1.0,8,11.9,not_published | |
| 1773 | +Saint-Aimé-des-Lacs,15030,Capitale-Nationale,plex,1.0,8,11.9,not_published | |
| 1774 | +Sainte-Hénédine,26040,Chaudière-Appalaches,plex,1.0,8,11.9,not_published | |
| 1775 | +Saint-Simon,54090,Montérégie,plex,1.0,8,11.9,not_published | |
| 1776 | +Sainte-Marguerite-du-Lac-Masson,77012,Laurentides,condo,1.0,8,10.4,not_published | |
| 1777 | +Notre-Dame-du-Bon-Conseil,49075,Centre-du-Québec,plex,1.0,8,10.4,not_published | |
| 1778 | +Lefebvre,49020,Centre-du-Québec,plex,1.0,8,11.9,not_published | |
| 1779 | +Sainte-Hélène-de-Kamouraska,14025,Bas-Saint-Laurent,plex,1.0,8,11.9,not_published | |
| 1780 | +Saint-François-de-la-Rivière-du-Sud,18060,Chaudière-Appalaches,plex,1.0,8,11.9,not_published | |
| 1781 | +Sainte-Angèle-de-Monnoir,55030,Montérégie,plex,1.0,8,11.9,not_published | |
| 1782 | +Howick,69025,Montérégie,plex,1.0,8,10.4,not_published | |
| 1783 | +La Patrie,41027,Estrie,plex,1.0,8,11.9,not_published | |
| 1784 | +Henryville,56042,Montérégie,plex,1.0,8,11.9,not_published | |
| 1785 | +Cap-Chat,04047,Gaspésie–Îles-de-la-Madeleine,plex,1.0,8,10.4,not_published | |
| 1786 | +Sainte-Marie-Salomé,63005,Lanaudière,plex,1.0,8,11.9,not_published | |
| 1787 | +Saint-Paul-de-l'Île-aux-Noix,56035,Montérégie,plex,1.0,8,10.4,not_published | |
| 1788 | +Sainte-Anne-de-la-Rochelle,42050,Estrie,plex,1.0,8,10.4,not_published | |
| 1789 | +Lac-Drolet,30080,Estrie,plex,1.0,8,11.9,not_published | |
| 1790 | +Kazabazua,83015,Outaouais,plex,1.0,8,11.9,not_published | |
| 1791 | +Disraeli,31020,Chaudière-Appalaches,plex,1.0,8,11.9,not_published | |
| 1792 | +Huberdeau,78065,Laurentides,plex,1.0,8,11.9,not_published | |
| 1793 | +Saint-Michel,68050,Montérégie,condo,1.0,8,11.9,not_published | |
| 1794 | +Saint-Valérien-de-Milton,54065,Montérégie,plex,1.0,8,11.9,not_published | |
| 1795 | +Saint-Séverin,35020,Mauricie,plex,1.0,8,11.9,not_published | |
| 1796 | +Saint-Lucien,49030,Centre-du-Québec,plex,1.0,8,11.9,not_published | |
| 1797 | +Saint-Lazare-de-Bellechasse,19050,Chaudière-Appalaches,plex,1.0,8,11.9,not_published | |
| 1798 | +Mont-Carmel,14005,Bas-Saint-Laurent,plex,1.0,8,10.4,not_published | |
| 1799 | +Pointe-à-la-Croix,06030,Gaspésie–Îles-de-la-Madeleine,plex,1.0,8,11.9,not_published | |
| 1800 | +Gore,76025,Laurentides,plex,1.0,8,11.9,not_published | |
| 1801 | +Saint-Flavien,33052,Chaudière-Appalaches,plex,1.0,8,11.9,not_published | |
| 1802 | +Notre-Dame-de-Pontmain,79010,Laurentides,plex,1.0,8,10.4,not_published | |
| 1803 | +Bury,41070,Estrie,plex,1.0,8,10.4,not_published | |
| 1804 | +Sainte-Flavie,09085,Bas-Saint-Laurent,plex,1.0,8,10.4,not_published | |
| 1805 | +Stratford,30110,Estrie,plex,1.0,8,10.4,not_published | |
| 1806 | +Saint-Christophe-d'Arthabaska,39060,Centre-du-Québec,plex,1.0,8,11.9,not_published | |
| 1807 | +Lotbinière,33115,Chaudière-Appalaches,plex,1.0,8,11.9,not_published | |
| 1808 | +Saint-Paul-de-Montminy,18030,Chaudière-Appalaches,plex,1.0,7,10.4,not_published | |
| 1809 | +Cap-Santé,34030,Capitale-Nationale,condo,1.0,7,10.4,not_published | |
| 1810 | +La Durantaye,19090,Chaudière-Appalaches,plex,1.0,7,10.4,not_published | |
| 1811 | +Saint-Ubalde,34090,Capitale-Nationale,plex,1.0,7,10.4,not_published | |
| 1812 | +Saint-Fabien-de-Panet,18015,Chaudière-Appalaches,plex,1.0,7,10.4,not_published | |
| 1813 | +Saint-Mathieu-du-Parc,51070,Mauricie,plex,1.0,7,10.4,not_published | |
| 1814 | +Saint-Malachie,19025,Chaudière-Appalaches,plex,1.0,7,9.0,not_published | |
| 1815 | +East Farnham,46085,Estrie,plex,1.0,7,9.0,not_published | |
| 1816 | +Baie-Johan-Beetz,98035,Côte-Nord,unifamilial,1.0,7,9.0,not_published | |
| 1817 | +Sainte-Félicité,08023,Bas-Saint-Laurent,plex,1.0,7,10.4,not_published | |
| 1818 | +Saint-Édouard-de-Fabre,85015,Abitibi-Témiscamingue,plex,1.0,7,10.4,not_published | |
| 1819 | +Bowman,80145,Outaouais,plex,1.0,7,9.0,not_published | |
| 1820 | +Saint-Agapit,33045,Chaudière-Appalaches,condo,1.0,7,10.4,not_published | |
| 1821 | +Sainte-Angèle-de-Mérici,09035,Bas-Saint-Laurent,plex,1.0,7,10.4,not_published | |
| 1822 | +Saint-Modeste,12020,Bas-Saint-Laurent,plex,1.0,7,10.4,not_published | |
| 1823 | +Lac-au-Saumon,07057,Bas-Saint-Laurent,plex,1.0,7,9.0,not_published | |
| 1824 | +Baie-Comeau,96020,Côte-Nord,condo,1.0,7,10.4,not_published | |
| 1825 | +Stoneham-et-Tewkesbury,22035,Capitale-Nationale,condo,1.0,7,9.0,not_published | |
| 1826 | +Leclercville,33123,Chaudière-Appalaches,plex,1.0,7,10.4,not_published | |
| 1827 | +Lac-des-Plages,80130,Outaouais,plex,1.0,7,10.4,not_published | |
| 1828 | +Ham-Nord,39010,Centre-du-Québec,plex,1.0,7,9.0,not_published | |
| 1829 | +Notre-Dame-de-Montauban,35005,Mauricie,plex,1.0,7,10.4,not_published | |
| 1830 | +Fossambault-sur-le-Lac,22010,Capitale-Nationale,plex,1.0,7,7.5,not_published | |
| 1831 | +Labrecque,93055,Saguenay–Lac-Saint-Jean,plex,1.0,7,10.4,not_published | |
| 1832 | +Saint-Côme,62065,Lanaudière,condo,1.0,7,10.4,not_published | |
| 1833 | +Saint-Irénée,15005,Capitale-Nationale,plex,1.0,7,10.4,not_published | |
| 1834 | +New Carlisle,05040,Gaspésie–Îles-de-la-Madeleine,plex,1.0,7,9.0,not_published | |
| 1835 | +Nantes,30045,Estrie,plex,1.0,7,10.4,not_published | |
| 1836 | +Lorraine,73025,Laurentides,plex,1.0,7,10.4,not_published | |
| 1837 | +Val-Morin,78005,Laurentides,condo,1.0,7,10.4,not_published | |
| 1838 | +Saint-Félix-de-Kingsey,49005,Centre-du-Québec,plex,1.0,7,10.4,not_published | |
| 1839 | +Piopolis,30020,Estrie,plex,1.0,7,9.0,not_published | |
| 1840 | +Champneuf,88005,Abitibi-Témiscamingue,unifamilial,1.0,7,7.5,not_published | |
| 1841 | +Laverlochère-Angliers,85052,Abitibi-Témiscamingue,plex,1.0,7,7.5,not_published | |
| 1842 | +Chute-Saint-Philippe,79065,Laurentides,plex,1.0,7,9.0,not_published | |
| 1843 | +Notre-Dame-de-la-Paix,80020,Outaouais,plex,1.5,7,6.0,not_published | |
| 1844 | +Cayamant,83040,Outaouais,plex,1.0,7,9.0,not_published | |
| 1845 | +Sainte-Ursule,51040,Mauricie,plex,1.0,7,10.4,not_published | |
| 1846 | +North Hatley,45050,Estrie,plex,1.0,7,10.4,not_published | |
| 1847 | +Sainte-Germaine-Boulé,87030,Abitibi-Témiscamingue,plex,1.0,7,10.4,not_published | |
| 1848 | +Saint-Joseph-des-Érables,27050,Chaudière-Appalaches,unifamilial,1.0,7,9.0,not_published | |
| 1849 | +Grande-Vallée,03020,Gaspésie–Îles-de-la-Madeleine,plex,1.0,7,10.4,not_published | |
| 1850 | +Thurso,80050,Outaouais,condo,1.0,7,9.0,not_published | |
| 1851 | +Val-des-Bois,80140,Outaouais,plex,1.0,7,10.4,not_published | |
| 1852 | +Saint-Elzéar,26022,Chaudière-Appalaches,plex,1.0,7,10.4,not_published | |
| 1853 | +Valcourt,42060,Estrie,plex,1.0,7,10.4,not_published | |
| 1854 | +Sainte-Jeanne-d'Arc,92015,Saguenay–Lac-Saint-Jean,plex,1.0,7,10.4,not_published | |
| 1855 | +Petit-Saguenay,94205,Saguenay–Lac-Saint-Jean,plex,1.0,6,7.5,not_published | |
| 1856 | +Bonsecours,42040,Estrie,plex,1.0,6,9.0,not_published | |
| 1857 | +Blanc-Sablon,98005,Côte-Nord,plex,1.0,6,9.0,not_published | |
| 1858 | +Saint-Cyprien-de-Napierville,68035,Montérégie,plex,1.0,6,9.0,not_published | |
| 1859 | +Hudson,71100,Montérégie,condo,1.0,6,7.5,not_published | |
| 1860 | +L'Île-Cadieux,71095,Montérégie,unifamilial,1.0,6,9.0,not_published | |
| 1861 | +Saint-Roch-des-Aulnaies,17065,Chaudière-Appalaches,plex,1.0,6,9.0,not_published | |
| 1862 | +Rougemont,55037,Montérégie,condo,1.0,6,7.5,not_published | |
| 1863 | +Saint-Gabriel-de-Rimouski,09025,Bas-Saint-Laurent,plex,1.0,6,9.0,not_published | |
| 1864 | +Beaumont,19105,Chaudière-Appalaches,condo,1.0,6,9.0,not_published | |
| 1865 | +Maddington Falls,39165,Centre-du-Québec,plex,1.0,6,9.0,not_published | |
| 1866 | +Lantier,78015,Laurentides,plex,1.0,6,9.0,not_published | |
| 1867 | +Hatley,45055,Estrie,plex,1.0,6,9.0,not_published | |
| 1868 | +Saint-Félix-d'Otis,94225,Saguenay–Lac-Saint-Jean,plex,1.0,6,9.0,not_published | |
| 1869 | +Bonne-Espérance,98010,Côte-Nord,unifamilial,1.0,6,7.5,not_published | |
| 1870 | +Saint-Hilaire-de-Dorset,29020,Chaudière-Appalaches,unifamilial,1.0,6,9.0,not_published | |
| 1871 | +Manseau,38028,Centre-du-Québec,plex,1.0,6,9.0,not_published | |
| 1872 | +Donnacona,34025,Capitale-Nationale,condo,1.0,6,9.0,not_published | |
| 1873 | +Notre-Dame-de-la-Merci,62055,Lanaudière,plex,1.0,6,9.0,not_published | |
| 1874 | +Ayer's Cliff,45035,Estrie,plex,1.0,6,9.0,not_published | |
| 1875 | +Saint-Hugues,54100,Montérégie,plex,1.0,6,6.0,not_published | |
| 1876 | +Sainte-Marguerite,26035,Chaudière-Appalaches,plex,1.0,6,9.0,not_published | |
| 1877 | +Namur,80110,Outaouais,plex,1.0,6,7.5,not_published | |
| 1878 | +Saint-Majorique-de-Grantham,49095,Centre-du-Québec,plex,1.0,6,9.0,not_published | |
| 1879 | +Natashquan,98025,Côte-Nord,plex,1.0,6,9.0,not_published | |
| 1880 | +Laforce,85070,Abitibi-Témiscamingue,unifamilial,1.0,6,9.0,not_published | |
| 1881 | +Saint-Élie-de-Caxton,51075,Mauricie,plex,1.0,6,7.5,not_published | |
| 1882 | +Saint-René,29050,Chaudière-Appalaches,plex,1.0,6,9.0,not_published | |
| 1883 | +Landrienne,88035,Abitibi-Témiscamingue,plex,1.0,6,9.0,not_published | |
| 1884 | +Saint-Cyprien,28040,Chaudière-Appalaches,plex,1.0,6,9.0,not_published | |
| 1885 | +Parisville,38055,Centre-du-Québec,plex,1.0,6,9.0,not_published | |
| 1886 | +Vaudreuil-sur-le-Lac,71090,Montérégie,plex,1.0,6,9.0,not_published | |
| 1887 | +Normétal,87115,Abitibi-Témiscamingue,plex,1.0,6,9.0,not_published | |
| 1888 | +Hérouxville,35035,Mauricie,plex,1.0,6,9.0,not_published | |
| 1889 | +Lac-du-Cerf,79015,Laurentides,plex,1.0,6,9.0,not_published | |
| 1890 | +Clarenceville,56010,Montérégie,plex,1.0,6,9.0,not_published | |
| 1891 | +Val-Saint-Gilles,87105,Abitibi-Témiscamingue,unifamilial,1.0,6,7.5,not_published | |
| 1892 | +Saint-Clet,71045,Montérégie,condo,1.0,6,9.0,not_published | |
| 1893 | +Verchères,59025,Montérégie,condo,1.0,6,9.0,not_published | |
| 1894 | +Hinchinbrooke,69045,Montérégie,plex,1.0,6,9.0,not_published | |
| 1895 | +Saint-Charles-Borromée,61035,Lanaudière,condo,1.0,6,7.5,not_published | |
| 1896 | +Lac-Supérieur,78095,Laurentides,condo,1.0,6,9.0,not_published | |
| 1897 | +Pointe-des-Cascades,71055,Montérégie,condo,1.0,6,9.0,not_published | |
| 1898 | +Saint-Urbain-Premier,70005,Montérégie,plex,1.0,6,7.5,not_published | |
| 1899 | +Lac-Chicobi,88904,Abitibi-Témiscamingue,unifamilial,2.0,6,4.5,not_published | |
| 1900 | +Cleveland,42110,Estrie,plex,1.0,6,7.5,not_published | |
| 1901 | +Honfleur,19070,Chaudière-Appalaches,plex,1.0,6,9.0,not_published | |
| 1902 | +La Doré,91050,Saguenay–Lac-Saint-Jean,plex,1.0,6,7.5,not_published | |
| 1903 | +Charette,51080,Mauricie,plex,1.0,6,9.0,not_published | |
| 1904 | +Delson,67025,Montérégie,condo,1.0,6,7.5,not_published | |
| 1905 | +Warden,47030,Estrie,plex,1.0,6,9.0,not_published | |
| 1906 | +Sainte-Julienne,63060,Lanaudière,condo,1.0,6,9.0,not_published | |
| 1907 | +Notre-Dame-de-la-Salette,80087,Outaouais,plex,1.0,6,7.5,not_published | |
| 1908 | +Ayer's Cliff,45035,Estrie,condo,1.0,6,9.0,not_published | |
| 1909 | +Sainte-Brigitte-de-Laval,22045,Capitale-Nationale,condo,1.0,6,9.0,not_published | |
| 1910 | +Sainte-Pétronille,20030,Capitale-Nationale,plex,1.0,6,9.0,not_published | |
| 1911 | +Saint-Armand,46017,Estrie,plex,1.0,6,7.5,not_published | |
| 1912 | +Saint-Bernard-de-Lacolle,68005,Montérégie,plex,1.0,6,9.0,not_published | |
| 1913 | +Arundel,78060,Laurentides,plex,1.0,6,7.5,not_published | |
| 1914 | +Saint-Valérien,10060,Bas-Saint-Laurent,plex,1.0,6,9.0,not_published | |
| 1915 | +Saint-Romain,30100,Estrie,plex,1.0,5,7.5,not_published | |
| 1916 | +Roberval,91025,Saguenay–Lac-Saint-Jean,condo,1.0,5,6.0,not_published | |
| 1917 | +Saint-Pierre-de-Broughton,31135,Chaudière-Appalaches,plex,1.0,5,7.5,not_published | |
| 1918 | +Val-David,78010,Laurentides,condo,1.0,5,7.5,not_published | |
| 1919 | +Saint-Sébastien,30085,Estrie,plex,1.0,5,7.5,not_published | |
| 1920 | +Sainte-Clotilde-de-Beauce,31060,Chaudière-Appalaches,plex,1.0,5,7.5,not_published | |
| 1921 | +Hemmingford,68015,Montérégie,condo,1.0,5,7.5,not_published | |
| 1922 | +Estérel,77011,Laurentides,condo,1.0,5,6.0,not_published | |
| 1923 | +Saint-Benjamin,28025,Chaudière-Appalaches,plex,1.0,5,6.0,not_published | |
| 1924 | +Sainte-Eulalie,50005,Centre-du-Québec,plex,1.0,5,7.5,not_published | |
| 1925 | +Saint-Mathieu-de-Rioux,11050,Bas-Saint-Laurent,plex,1.0,5,7.5,not_published | |
| 1926 | +L'Ascension-de-Patapédia,06060,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,5,7.5,not_published | |
| 1927 | +La Pocatière,14082,Bas-Saint-Laurent,condo,1.0,5,7.5,not_published | |
| 1928 | +Ragueneau,96040,Côte-Nord,plex,1.0,5,7.5,not_published | |
| 1929 | +Sainte-Geneviève-de-Batiscan,37215,Mauricie,plex,1.0,5,7.5,not_published | |
| 1930 | +Mont-Saint-Michel,79110,Laurentides,plex,1.0,5,7.5,not_published | |
| 1931 | +Métis-sur-Mer,09048,Bas-Saint-Laurent,plex,1.0,5,6.0,not_published | |
| 1932 | +Grandes-Piles,35040,Mauricie,plex,1.0,5,7.5,not_published | |
| 1933 | +Sainte-Perpétue,50050,Centre-du-Québec,plex,1.0,5,7.5,not_published | |
| 1934 | +Pohénégamook,13095,Bas-Saint-Laurent,condo,1.0,5,7.5,not_published | |
| 1935 | +Saint-Sixte,80070,Outaouais,plex,1.0,5,7.5,not_published | |
| 1936 | +Brigham,46090,Estrie,plex,1.0,5,7.5,not_published | |
| 1937 | +Sainte-Rose-de-Watford,28030,Chaudière-Appalaches,plex,1.0,5,6.0,not_published | |
| 1938 | +Stornoway,30105,Estrie,plex,1.0,5,7.5,not_published | |
| 1939 | +Sainte-Irène,07040,Bas-Saint-Laurent,plex,1.0,5,7.5,not_published | |
| 1940 | +Saint-Alexis-de-Matapédia,06050,Gaspésie–Îles-de-la-Madeleine,plex,1.0,5,7.5,not_published | |
| 1941 | +Saint-Hubert-de-Rivière-du-Loup,12010,Bas-Saint-Laurent,plex,1.0,5,7.5,not_published | |
| 1942 | +Saint-Claude,42100,Estrie,plex,1.0,5,7.5,not_published | |
| 1943 | +Saint-Luc-de-Bellechasse,28060,Chaudière-Appalaches,plex,1.0,5,7.5,not_published | |
| 1944 | +Sainte-Hedwidge,91030,Saguenay–Lac-Saint-Jean,plex,1.0,5,6.0,not_published | |
| 1945 | +Notre-Dame-du-Bon-Conseil,49080,Centre-du-Québec,plex,1.0,5,7.5,not_published | |
| 1946 | +Sainte-Angèle-de-Prémont,51055,Mauricie,plex,1.0,5,7.5,not_published | |
| 1947 | +Les Bergeronnes,95018,Côte-Nord,plex,1.0,5,7.5,not_published | |
| 1948 | +Waltham,84070,Outaouais,plex,1.0,5,7.5,not_published | |
| 1949 | +Les Lacs-du-Témiscamingue,85907,Abitibi-Témiscamingue,unifamilial,1.0,5,7.5,not_published | |
| 1950 | +Saint-Jacques-le-Mineur,68040,Montérégie,plex,1.0,5,7.5,not_published | |
| 1951 | +Saint-Wenceslas,50023,Centre-du-Québec,plex,1.0,5,7.5,not_published | |
| 1952 | +Saint-Césaire,55023,Montérégie,condo,1.0,5,6.0,not_published | |
| 1953 | +Saint-Janvier-de-Joly,33065,Chaudière-Appalaches,plex,1.0,5,7.5,not_published | |
| 1954 | +Saint-Émile-de-Suffolk,80125,Outaouais,plex,2.0,5,4.5,not_published | |
| 1955 | +Sainte-Anne-du-Lac,79115,Laurentides,plex,1.0,5,6.0,not_published | |
| 1956 | +La Corne,88030,Abitibi-Témiscamingue,plex,1.0,5,7.5,not_published | |
| 1957 | +Notre-Dame-des-Monts,15025,Capitale-Nationale,plex,1.0,5,7.5,not_published | |
| 1958 | +Dosquet,33040,Chaudière-Appalaches,plex,1.0,5,7.5,not_published | |
| 1959 | +Inverness,32058,Centre-du-Québec,plex,1.0,5,6.0,not_published | |
| 1960 | +Saint-Cyprien,12005,Bas-Saint-Laurent,plex,1.0,5,6.0,not_published | |
| 1961 | +Duhamel-Ouest,85030,Abitibi-Témiscamingue,plex,1.0,5,7.5,not_published | |
| 1962 | +Saint-André-de-Kamouraska,14040,Bas-Saint-Laurent,plex,1.0,5,7.5,not_published | |
| 1963 | +Saint-Simon-les-Mines,29125,Chaudière-Appalaches,plex,1.0,5,7.5,not_published | |
| 1964 | +Montcalm,78055,Laurentides,plex,1.0,5,7.5,not_published | |
| 1965 | +Scott,26048,Chaudière-Appalaches,plex,1.0,5,6.0,not_published | |
| 1966 | +Matapédia,06045,Gaspésie–Îles-de-la-Madeleine,plex,1.0,5,7.5,not_published | |
| 1967 | +Lac-Delage,22030,Capitale-Nationale,plex,1.0,5,7.5,not_published | |
| 1968 | +Saint-Philippe-de-Néri,14060,Bas-Saint-Laurent,plex,1.0,5,7.5,not_published | |
| 1969 | +Val-Brillant,07080,Bas-Saint-Laurent,plex,1.0,5,7.5,not_published | |
| 1970 | +Saint-Aubert,17055,Chaudière-Appalaches,plex,1.0,5,7.5,not_published | |
| 1971 | +Fassett,80005,Outaouais,plex,1.0,5,6.0,not_published | |
| 1972 | +Saint-Vallier,19117,Chaudière-Appalaches,plex,1.0,5,7.5,not_published | |
| 1973 | +Sainte-Marguerite,26035,Chaudière-Appalaches,condo,1.0,5,6.0,not_published | |
| 1974 | +Belleterre,85065,Abitibi-Témiscamingue,plex,1.0,5,6.0,not_published | |
| 1975 | +Saint-Zénon,62080,Lanaudière,plex,1.0,5,7.5,not_published | |
| 1976 | +Saint-Camille-de-Lellis,28070,Chaudière-Appalaches,plex,1.0,5,7.5,not_published | |
| 1977 | +Racine,42032,Estrie,plex,1.0,5,7.5,not_published | |
| 1978 | +Frampton,26005,Chaudière-Appalaches,plex,1.0,4,6.0,not_published | |
| 1979 | +Barnston-Ouest,44045,Estrie,plex,1.0,4,6.0,not_published | |
| 1980 | +Sainte-Aurélie,28015,Chaudière-Appalaches,plex,1.0,4,6.0,not_published | |
| 1981 | +Notre-Dame-des-Neiges,11045,Bas-Saint-Laurent,plex,1.0,4,6.0,not_published | |
| 1982 | +Noyan,56015,Montérégie,plex,1.0,4,6.0,not_published | |
| 1983 | +Saint-Théophile,29005,Chaudière-Appalaches,plex,1.0,4,6.0,not_published | |
| 1984 | +Mont-Albert,04902,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,4,6.0,not_published | |
| 1985 | +Courcelles–Saint-Évariste,29027,Chaudière-Appalaches,plex,1.0,4,6.0,not_published | |
| 1986 | +Saint-Mathieu-du-Parc,51070,Mauricie,condo,1.0,4,6.0,not_published | |
| 1987 | +Notre-Dame-de-Bonsecours,80015,Outaouais,plex,1.0,4,6.0,not_published | |
| 1988 | +Saint-Michel-du-Squatec,13065,Bas-Saint-Laurent,plex,1.0,4,6.0,not_published | |
| 1989 | +Saint-Louis-de-Gonzague,70035,Montérégie,condo,1.0,4,6.0,not_published | |
| 1990 | +Duparquet,87005,Abitibi-Témiscamingue,plex,1.0,4,6.0,not_published | |
| 1991 | +Sainte-Apolline-de-Patton,18025,Chaudière-Appalaches,plex,1.0,4,4.5,not_published | |
| 1992 | +Neuville,34007,Capitale-Nationale,condo,1.0,4,6.0,not_published | |
| 1993 | +Saint-Sylvère,38005,Centre-du-Québec,plex,1.0,4,6.0,not_published | |
| 1994 | +Péribonka,92010,Saguenay–Lac-Saint-Jean,plex,1.0,4,6.0,not_published | |
| 1995 | +Saint-Sébastien,56050,Montérégie,plex,1.0,4,6.0,not_published | |
| 1996 | +Blue Sea,83045,Outaouais,plex,1.0,4,6.0,not_published | |
| 1997 | +Lac-Saint-Joseph,22015,Capitale-Nationale,plex,1.0,4,6.0,not_published | |
| 1998 | +Richelieu,55057,Montérégie,condo,1.0,4,6.0,not_published | |
| 1999 | +Saint-Patrice-de-Beaurivage,33025,Chaudière-Appalaches,plex,1.0,4,6.0,not_published | |
| 2000 | +Newport,41037,Estrie,plex,1.0,4,6.0,not_published | |
| 2001 | +Bolton-Ouest,46065,Estrie,plex,1.0,4,6.0,not_published | |
| 2002 | +L'Avenir,49025,Centre-du-Québec,plex,1.0,4,6.0,not_published | |
| 2003 | +Saint-Ferdinand,32013,Centre-du-Québec,condo,1.0,4,6.0,not_published | |
| 2004 | +La Macaza,79047,Laurentides,plex,1.0,4,6.0,not_published | |
| 2005 | +Nédélec,85100,Abitibi-Témiscamingue,plex,1.0,4,4.5,not_published | |
| 2006 | +Notre-Dame-de-Stanbridge,46100,Estrie,plex,1.0,4,6.0,not_published | |
| 2007 | +Trois-Rives,35055,Mauricie,plex,1.0,4,6.0,not_published | |
| 2008 | +Saint-Georges-de-Windsor,40032,Estrie,plex,1.0,4,6.0,not_published | |
| 2009 | +Saint-Vianney,07075,Bas-Saint-Laurent,plex,1.0,4,6.0,not_published | |
| 2010 | +Saint-Albert,39085,Centre-du-Québec,plex,1.0,4,6.0,not_published | |
| 2011 | +Saint-François-d'Assise,06055,Gaspésie–Îles-de-la-Madeleine,plex,1.0,4,6.0,not_published | |
| 2012 | +Escuminac,06025,Gaspésie–Îles-de-la-Madeleine,plex,1.0,4,6.0,not_published | |
| 2013 | +Saint-Ludger,30072,Estrie,plex,1.0,4,4.5,not_published | |
| 2014 | +La Visitation-de-l'Île-Dupas,52050,Lanaudière,plex,1.0,4,6.0,not_published | |
| 2015 | +Saint-Léon-de-Standon,19020,Chaudière-Appalaches,plex,1.0,4,6.0,not_published | |
| 2016 | +Saint-Odilon-de-Cranbourne,27035,Chaudière-Appalaches,plex,1.0,4,6.0,not_published | |
| 2017 | +Saint-Nazaire-d'Acton,48050,Montérégie,plex,1.0,4,6.0,not_published | |
| 2018 | +Saint-Adrien,40010,Estrie,plex,1.0,4,6.0,not_published | |
| 2019 | +Shannon,22020,Capitale-Nationale,condo,1.0,4,6.0,not_published | |
| 2020 | +Dixville,44023,Estrie,plex,1.0,4,6.0,not_published | |
| 2021 | +Sainte-Claire,19055,Chaudière-Appalaches,condo,1.0,4,6.0,not_published | |
| 2022 | +Saint-Lambert-de-Lauzon,26070,Chaudière-Appalaches,condo,1.0,4,6.0,not_published | |
| 2023 | +Lac-Casault,07908,Bas-Saint-Laurent,unifamilial,1.0,4,4.5,not_published | |
| 2024 | +Sainte-Marthe,71110,Montérégie,plex,1.0,4,6.0,not_published | |
| 2025 | +Saint-Valère,39135,Centre-du-Québec,plex,1.0,4,6.0,not_published | |
| 2026 | +Sainte-Louise,17060,Chaudière-Appalaches,plex,1.0,4,6.0,not_published | |
| 2027 | +Saint-David-de-Falardeau,94245,Saguenay–Lac-Saint-Jean,condo,1.0,4,6.0,not_published | |
| 2028 | +Trécesson,88075,Abitibi-Témiscamingue,plex,1.0,4,6.0,not_published | |
| 2029 | +Packington,13015,Bas-Saint-Laurent,plex,1.0,4,6.0,not_published | |
| 2030 | +Saint-Isidore-de-Clifton,41012,Estrie,plex,1.0,4,6.0,not_published | |
| 2031 | +Bolton-Est,45095,Estrie,plex,1.0,4,6.0,not_published | |
| 2032 | +Sainte-Agathe-de-Lotbinière,33017,Chaudière-Appalaches,plex,1.0,4,6.0,not_published | |
| 2033 | +Roxton,48015,Montérégie,plex,1.0,4,6.0,not_published | |
| 2034 | +Hatley,45043,Estrie,plex,1.0,4,6.0,not_published | |
| 2035 | +Alleyn-et-Cawood,84050,Outaouais,plex,1.0,4,6.0,not_published | |
| 2036 | +Kirkland,66102,Montréal,plex,1.0,4,6.0,not_published | |
| 2037 | +Waterloo,47025,Estrie,condo,1.0,4,6.0,not_published | |
| 2038 | +Scotstown,41080,Estrie,plex,1.0,4,6.0,not_published | |
| 2039 | +Coaticook,44037,Estrie,condo,1.0,4,6.0,not_published | |
| 2040 | +Saint-Étienne-de-Bolton,45100,Estrie,plex,1.0,4,6.0,not_published | |
| 2041 | +Saint-Narcisse-de-Rimouski,10015,Bas-Saint-Laurent,plex,1.0,4,6.0,not_published | |
| 2042 | +Saint-Augustin,98012,Côte-Nord,unifamilial,1.0,4,6.0,not_published | |
| 2043 | +Sainte-Cécile-de-Whitton,30050,Estrie,plex,1.0,4,6.0,not_published | |
| 2044 | +Egan-Sud,83075,Outaouais,plex,1.0,4,6.0,not_published | |
| 2045 | +Baie-des-Sables,08080,Bas-Saint-Laurent,plex,1.0,4,4.5,not_published | |
| 2046 | +L'Épiphanie,60037,Lanaudière,condo,1.0,4,6.0,not_published | |
| 2047 | +Tadoussac,95005,Côte-Nord,plex,1.0,4,4.5,not_published | |
| 2048 | +Rivière-Bonaventure,05902,Gaspésie–Îles-de-la-Madeleine,unifamilial,1.0,4,6.0,not_published | |
| 2049 | +Hemmingford,68015,Montérégie,plex,1.0,4,6.0,not_published | |
| 2050 | +Saint-Pierre-les-Becquets,38065,Centre-du-Québec,plex,1.0,3,4.5,not_published | |
| 2051 | +Fortierville,38047,Centre-du-Québec,plex,1.0,3,4.5,not_published | |
| 2052 | +Godbout,96010,Côte-Nord,plex,1.0,3,4.5,not_published | |
| 2053 | +Saint-Ignace-de-Stanbridge,46095,Estrie,plex,1.0,3,4.5,not_published | |
| 2054 | +Saint-Frédéric,27065,Chaudière-Appalaches,plex,1.0,3,4.5,not_published | |
| 2055 | +Sainte-Clotilde,68020,Montérégie,condo,1.0,3,4.5,not_published | |
| 2056 | +Saint-Philémon,19005,Chaudière-Appalaches,condo,1.0,3,4.5,not_published | |
| 2057 | +L'Île-du-Grand-Calumet,84035,Outaouais,plex,1.0,3,4.5,not_published | |
| 2058 | +Grand-Saint-Esprit,50065,Centre-du-Québec,plex,1.0,3,4.5,not_published | |
| 2059 | +Sainte-Christine-d'Auvergne,34105,Capitale-Nationale,plex,1.0,3,4.5,not_published | |
| 2060 | +Saint-Justin,51045,Mauricie,plex,1.0,3,4.5,not_published | |
| 2061 | +Eeyou Istchee Baie-James,99060,Nord-du-Québec,plex,1.0,3,4.5,not_published | |
| 2062 | +Saint-Épiphane,12030,Bas-Saint-Laurent,plex,1.5,3,3.0,not_published | |
| 2063 | +East Hereford,44010,Estrie,plex,1.0,3,4.5,not_published | |
| 2064 | +Saint-Elzéar,26022,Chaudière-Appalaches,condo,1.0,3,4.5,not_published | |
| 2065 | +Plessisville,32043,Centre-du-Québec,condo,3.0,3,1.5,not_published | |
| 2066 | +Moffet,85075,Abitibi-Témiscamingue,plex,1.0,3,4.5,not_published | |
| 2067 | +Thorne,84045,Outaouais,plex,1.0,3,4.5,not_published | |
| 2068 | +Dupuy,87085,Abitibi-Témiscamingue,plex,1.0,3,4.5,not_published | |
| 2069 | +Lac-Simon,80095,Outaouais,condo,1.0,3,4.5,not_published | |
| 2070 | +Sainte-Rita,11015,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2071 | +Saint-Samuel,39130,Centre-du-Québec,plex,1.0,3,4.5,not_published | |
| 2072 | +Westbury,41065,Estrie,plex,1.0,3,4.5,not_published | |
| 2073 | +Saint-Augustin-de-Woburn,30005,Estrie,plex,1.0,3,4.5,not_published | |
| 2074 | +Windsor,42088,Estrie,condo,1.0,3,4.5,not_published | |
| 2075 | +Rivière-Ojima,87904,Abitibi-Témiscamingue,unifamilial,1.0,3,4.5,not_published | |
| 2076 | +Saint-Édouard,68045,Montérégie,plex,1.0,3,4.5,not_published | |
| 2077 | +Dundee,69075,Montérégie,plex,1.0,3,4.5,not_published | |
| 2078 | +Aumond,83090,Outaouais,plex,1.0,3,4.5,not_published | |
| 2079 | +Brome,46070,Estrie,plex,1.0,3,4.5,not_published | |
| 2080 | +Sainte-Florence,07010,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2081 | +Longue-Rive,95032,Côte-Nord,plex,1.0,3,4.5,not_published | |
| 2082 | +Sagard,15904,Capitale-Nationale,unifamilial,1.5,3,3.0,not_published | |
| 2083 | +Boileau,80115,Outaouais,plex,1.0,3,4.5,not_published | |
| 2084 | +Très-Saint-Sacrement,69030,Montérégie,plex,1.0,3,4.5,not_published | |
| 2085 | +Saint-Pierre,61020,Lanaudière,plex,1.0,3,4.5,not_published | |
| 2086 | +Saint-Clément,11005,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2087 | +Saint-Eugène-de-Guigues,85085,Abitibi-Témiscamingue,plex,1.0,3,4.5,not_published | |
| 2088 | +Montmagny,18050,Chaudière-Appalaches,condo,1.0,3,4.5,not_published | |
| 2089 | +Saint-Just-de-Bretenières,18005,Chaudière-Appalaches,plex,1.0,3,4.5,not_published | |
| 2090 | +Montcerf-Lytton,83088,Outaouais,plex,1.0,3,4.5,not_published | |
| 2091 | +Sainte-Hélène-de-Bagot,54095,Montérégie,condo,1.0,3,4.5,not_published | |
| 2092 | +Sainte-Sophie-de-Lévrard,38040,Centre-du-Québec,plex,1.0,3,4.5,not_published | |
| 2093 | +Saint-Gabriel,52080,Lanaudière,condo,1.0,3,4.5,not_published | |
| 2094 | +Lac-Sergent,34120,Capitale-Nationale,plex,1.5,3,3.0,not_published | |
| 2095 | +Saint-Luc-de-Vincennes,37225,Mauricie,plex,1.0,3,4.5,not_published | |
| 2096 | +Saint-Bonaventure,49125,Centre-du-Québec,plex,1.0,3,4.5,not_published | |
| 2097 | +Sainte-Thérèse-de-Gaspé,02010,Gaspésie–Îles-de-la-Madeleine,plex,1.0,3,4.5,not_published | |
| 2098 | +Rivière-Héva,89010,Abitibi-Témiscamingue,plex,1.0,3,4.5,not_published | |
| 2099 | +Schefferville,97040,Côte-Nord,unifamilial,1.0,3,4.5,not_published | |
| 2100 | +Saint-Marc-de-Figuery,88040,Abitibi-Témiscamingue,plex,1.5,3,3.0,not_published | |
| 2101 | +Pointe-Lebel,96025,Côte-Nord,plex,1.0,3,4.5,not_published | |
| 2102 | +Saint-Pierre-de-la-Rivière-du-Sud,18055,Chaudière-Appalaches,plex,1.0,3,4.5,not_published | |
| 2103 | +Ivry-sur-le-Lac,78042,Laurentides,plex,1.0,3,4.5,not_published | |
| 2104 | +Château-Richer,21035,Capitale-Nationale,condo,1.0,3,4.5,not_published | |
| 2105 | +Saint-Nérée-de-Bellechasse,19045,Chaudière-Appalaches,plex,1.0,3,4.5,not_published | |
| 2106 | +Saint-Eugène,49105,Centre-du-Québec,plex,1.0,3,4.5,not_published | |
| 2107 | +Senneterre,89045,Abitibi-Témiscamingue,plex,1.0,3,4.5,not_published | |
| 2108 | +Saint-Jacques-de-Leeds,31140,Chaudière-Appalaches,plex,1.0,3,4.5,not_published | |
| 2109 | +Melbourne,42075,Estrie,plex,1.0,3,4.5,not_published | |
| 2110 | +Saint-Gédéon,93035,Saguenay–Lac-Saint-Jean,condo,1.0,3,4.5,not_published | |
| 2111 | +Matane,08053,Bas-Saint-Laurent,condo,1.0,3,4.5,not_published | |
| 2112 | +Gros-Mécatina,98014,Côte-Nord,unifamilial,1.0,3,4.5,not_published | |
| 2113 | +L'Islet,17078,Chaudière-Appalaches,condo,1.0,3,4.5,not_published | |
| 2114 | +Saint-Prosper-de-Champlain,37250,Mauricie,plex,1.0,3,4.5,not_published | |
| 2115 | +Stanstead-Est,44050,Estrie,plex,1.0,3,4.5,not_published | |
| 2116 | +Saint-Eugène-de-Ladrière,10075,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2117 | +Saint-François-Xavier-de-Viger,12025,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2118 | +Bryson,84025,Outaouais,plex,1.0,3,4.5,not_published | |
| 2119 | +Notre-Dame-des-Pins,29120,Chaudière-Appalaches,condo,1.0,3,4.5,not_published | |
| 2120 | +Bois-Franc,83085,Outaouais,plex,1.5,3,3.0,not_published | |
| 2121 | +Sainte-Sabine,46105,Estrie,plex,1.0,3,4.5,not_published | |
| 2122 | +Esprit-Saint,10005,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2123 | +Saint-Donat,09030,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2124 | +Sainte-Rose-du-Nord,94230,Saguenay–Lac-Saint-Jean,plex,1.0,3,4.5,not_published | |
| 2125 | +Saint-Barnabé-Sud,54105,Montérégie,plex,1.0,3,4.5,not_published | |
| 2126 | +Notre-Dame-de-Lourdes,32080,Centre-du-Québec,plex,1.0,3,4.5,not_published | |
| 2127 | +Saint-Augustin,92005,Saguenay–Lac-Saint-Jean,plex,1.0,3,4.5,not_published | |
| 2128 | +Saint-Jean-de-l'Île-d'Orléans,20015,Capitale-Nationale,plex,1.0,3,4.5,not_published | |
| 2129 | +Rivière-Ouelle,14065,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2130 | +Lac-Saguay,79060,Laurentides,plex,1.0,3,4.5,not_published | |
| 2131 | +Saint-Robert-Bellarmin,30070,Estrie,plex,1.0,3,4.5,not_published | |
| 2132 | +Lejeune,13050,Bas-Saint-Laurent,unifamilial,1.0,3,4.5,not_published | |
| 2133 | +Saint-Norbert-d'Arthabaska,39043,Centre-du-Québec,plex,1.0,3,4.5,not_published | |
| 2134 | +Saint-Marc-du-Lac-Long,13020,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2135 | +Saint-Léon-le-Grand,07030,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2136 | +Litchfield,84040,Outaouais,plex,1.0,3,4.5,not_published | |
| 2137 | +Saint-Moïse,07095,Bas-Saint-Laurent,plex,1.0,3,4.5,not_published | |
| 2138 | +Audet,30055,Estrie,plex,1.0,2,3.0,not_published | |
| 2139 | +Saint-François-de-l'Île-d'Orléans,20005,Capitale-Nationale,plex,1.0,2,3.0,not_published | |
| 2140 | +Sainte-Victoire-de-Sorel,53025,Montérégie,condo,1.0,2,3.0,not_published | |
| 2141 | +Kingsbury,42070,Estrie,plex,1.0,2,3.0,not_published | |
| 2142 | +Saint-Joseph-de-Lepage,09070,Bas-Saint-Laurent,plex,1.0,2,3.0,not_published | |
| 2143 | +Wentworth,76035,Laurentides,plex,1.0,2,3.0,not_published | |
| 2144 | +Lochaber,80055,Outaouais,plex,1.0,2,3.0,not_published | |
| 2145 | +Saint-Joachim-de-Shefford,47040,Estrie,plex,1.0,2,3.0,not_published | |
| 2146 | +Saint-Tharcisius,07070,Bas-Saint-Laurent,plex,1.0,2,3.0,not_published | |
| 2147 | +Saint-Stanislas-de-Kostka,70040,Montérégie,condo,1.0,2,3.0,not_published | |
| 2148 | +Saint-Barnabé,51025,Mauricie,plex,1.0,2,3.0,not_published | |
| 2149 | +Latulipe-et-Gaboury,85060,Abitibi-Témiscamingue,plex,1.0,2,3.0,not_published | |
| 2150 | +Chichester,84090,Outaouais,plex,1.0,2,3.0,not_published | |
| 2151 | +Rapides-des-Joachims,84100,Outaouais,plex,1.0,2,3.0,not_published | |
| 2152 | +Saint-Edmond-de-Grantham,49100,Centre-du-Québec,plex,1.0,2,3.0,not_published | |
| 2153 | +Sainte-Brigitte-des-Saults,49085,Centre-du-Québec,plex,1.0,2,3.0,not_published | |
| 2154 | +Kiamika,79025,Laurentides,plex,2.0,2,1.5,not_published | |
| 2155 | +Sainte-Paule,08040,Bas-Saint-Laurent,plex,1.0,2,3.0,not_published | |
| 2156 | +Sainte-Gertrude-Manneville,88085,Abitibi-Témiscamingue,plex,1.0,2,3.0,not_published | |
| 2157 | +Saint-Mathias-sur-Richelieu,55065,Montérégie,condo,1.0,2,3.0,not_published | |
| 2158 | +Sainte-Sophie-d'Halifax,32023,Centre-du-Québec,plex,1.0,2,3.0,not_published | |
| 2159 | +Saint-Thomas-Didyme,92045,Saguenay–Lac-Saint-Jean,plex,1.0,2,3.0,not_published | |
| 2160 | +Sainte-Madeleine-de-la-Rivière-Madeleine,04005,Gaspésie–Îles-de-la-Madeleine,plex,1.0,2,3.0,not_published | |
| 2161 | +Poularies,87035,Abitibi-Témiscamingue,plex,1.0,2,3.0,not_published | |
| 2162 | +Rivière-à-Pierre,34135,Capitale-Nationale,plex,1.0,2,3.0,not_published | |
| 2163 | +Franquelin,96015,Côte-Nord,plex,1.0,2,3.0,not_published | |
| 2164 | +Saint-Norbert,52070,Lanaudière,plex,1.0,2,3.0,not_published | |
| 2165 | +Saint-Zéphirin-de-Courval,50090,Centre-du-Québec,plex,1.0,2,3.0,not_published | |
| 2166 | +Larouche,94265,Saguenay–Lac-Saint-Jean,condo,1.0,2,3.0,not_published | |
| 2167 | +Saint-Charles-de-Bourget,94260,Saguenay–Lac-Saint-Jean,plex,1.0,2,3.0,not_published | |
| 2168 | +Saint-Camille,40025,Estrie,plex,1.0,2,3.0,not_published | |
| 2169 | +Shigawake,05010,Gaspésie–Îles-de-la-Madeleine,plex,1.0,2,3.0,not_published | |
| 2170 | +Stanbridge Station,46030,Estrie,plex,2.0,2,1.5,not_published | |
| 2171 | +Saint-Fortunat,31030,Chaudière-Appalaches,plex,1.0,2,3.0,not_published | |
| 2172 | +Notre-Dame-des-Bois,30010,Estrie,plex,1.0,2,3.0,not_published | |
| 2173 | +Senneville,66127,Montréal,plex,1.0,2,3.0,not_published | |
| 2174 | +Biencourt,13055,Bas-Saint-Laurent,plex,1.0,2,3.0,not_published | |
| 2175 | +Longue-Pointe-de-Mingan,98045,Côte-Nord,plex,1.0,2,3.0,not_published | |
| 2176 | +Havelock,69005,Montérégie,plex,1.0,2,3.0,not_published | |
| 2177 | +Notre-Dame-des-Sept-Douleurs,12045,Bas-Saint-Laurent,plex,1.0,2,3.0,not_published | |
| 2178 | +Oka,72032,Laurentides,condo,1.0,2,3.0,not_published | |
| 2179 | +Mulgrave-et-Derry,80085,Outaouais,plex,1.0,2,3.0,not_published | |
| 2180 | +Scott,26048,Chaudière-Appalaches,condo,1.0,2,3.0,not_published | |
| 2181 | +Huntingdon,69055,Montérégie,condo,1.0,2,3.0,not_published | |
| 2182 | +L'Île-d'Anticosti,98020,Côte-Nord,plex,1.0,2,3.0,not_published | |
| 2183 | +Lac-Huron,10902,Bas-Saint-Laurent,unifamilial,1.0,2,3.0,not_published | |
| 2184 | +Saint-Elzéar,05050,Gaspésie–Îles-de-la-Madeleine,plex,1.0,2,3.0,not_published | |
| 2185 | +Saint-Antoine-de-l'Isle-aux-Grues,18070,Chaudière-Appalaches,plex,1.0,2,3.0,not_published | |
| 2186 | +Les Méchins,08005,Bas-Saint-Laurent,plex,1.0,2,3.0,not_published | |
| 2187 | +Crabtree,61013,Lanaudière,condo,1.0,2,3.0,not_published | |
| 2188 | +Saints-Anges,26010,Chaudière-Appalaches,plex,1.0,2,3.0,not_published | |
| 2189 | +Sainte-Marie-de-Blandford,38015,Centre-du-Québec,plex,1.0,2,3.0,not_published | |
| 2190 | +Saint-Elphège,50095,Centre-du-Québec,plex,1.0,2,3.0,not_published | |
| 2191 | +Saint-Pierre-Baptiste,32050,Centre-du-Québec,plex,1.0,2,3.0,not_published | |
| 2192 | +Harrington,76065,Laurentides,plex,2.0,2,1.5,not_published | |
| 2193 | +Fugèreville,85055,Abitibi-Témiscamingue,plex,1.0,2,3.0,not_published | |
| 2194 | +Saint-Bernard,26055,Chaudière-Appalaches,condo,1.0,2,3.0,not_published | |
| 2195 | +Saint-Didace,52090,Lanaudière,plex,1.0,2,3.0,not_published | |
| 2196 | +Lamarche,93060,Saguenay–Lac-Saint-Jean,plex,1.0,2,3.0,not_published | |
| 2197 | +Saint-André-du-Lac-Saint-Jean,91010,Saguenay–Lac-Saint-Jean,plex,1.0,2,3.0,not_published | |
| 2198 | +Sainte-Séraphine,39105,Centre-du-Québec,plex,1.0,2,3.0,not_published | |
| 2199 | +Saint-Roch-de-Mékinac,35045,Mauricie,plex,1.0,2,3.0,not_published | |
| 2200 | +Saint-Zénon-du-Lac-Humqui,07035,Bas-Saint-Laurent,plex,1.0,2,3.0,not_published | |
| 2201 | +Rémigny,85105,Abitibi-Témiscamingue,plex,1.0,2,3.0,not_published | |
| 2202 | +TNO aquatique de la MRC géographique de Montréal,66990,Montréal,unifamilial,1.0,2,3.0,not_published | |
| 2203 | +Saint-Malachie,19025,Chaudière-Appalaches,condo,1.0,2,3.0,not_published | |
| 2204 | +Villeroy,32085,Centre-du-Québec,plex,1.0,2,3.0,not_published | |
| 2205 | +Elgin,69050,Montérégie,plex,1.0,2,3.0,not_published | |
| 2206 | +Sainte-Anne-de-Beaupré,21030,Capitale-Nationale,condo,1.0,2,3.0,not_published | |
| 2207 | +Saint-René-de-Matane,08035,Bas-Saint-Laurent,plex,1.0,2,3.0,not_published | |
| 2208 | +Baie-D'Urfé,66112,Montréal,plex,1.0,2,3.0,not_published | |
| 2209 | +Chartierville,41020,Estrie,plex,1.0,2,3.0,not_published | |
| 2210 | +Lac-Masketsi,35902,Mauricie,unifamilial,1.0,2,3.0,not_published | |
| 2211 | +Sainte-Lucie-de-Beauregard,18020,Chaudière-Appalaches,plex,1.0,2,3.0,not_published | |
| 2212 | +Pointe-Fortune,71140,Montérégie,plex,1.0,2,3.0,not_published | |
| 2213 | +Pike River,46025,Estrie,plex,1.0,2,3.0,not_published | |
| 2214 | +Saint-Thomas,61027,Lanaudière,condo,1.0,2,3.0,not_published | |
| 2215 | +Low,83010,Outaouais,plex,1.0,2,3.0,not_published | |
| 2216 | +Saint-Michel-des-Saints,62085,Lanaudière,condo,1.0,2,3.0,not_published | |
| 2217 | +Notre-Dame-Auxiliatrice-de-Buckland,19010,Chaudière-Appalaches,plex,1.0,2,3.0,not_published | |
| 2218 | +Colombier,95050,Côte-Nord,plex,1.0,2,3.0,not_published | |
| 2219 | +Témiscouata-sur-le-Lac,13073,Bas-Saint-Laurent,condo,1.0,2,3.0,not_published | |
| 2220 | +Sainte-Justine-de-Newton,71115,Montérégie,plex,1.0,2,3.0,not_published | |
| 2221 | +Saint-Éloi,11035,Bas-Saint-Laurent,plex,1.0,2,3.0,not_published | |
| 2222 | +Saint-Mathieu-d'Harricana,88050,Abitibi-Témiscamingue,plex,1.0,1,1.5,not_published | |
| 2223 | +Aston-Jonction,50013,Centre-du-Québec,plex,1.0,1,1.5,not_published | |
| 2224 | +Sainte-Monique,50057,Centre-du-Québec,plex,1.0,1,1.5,not_published | |
| 2225 | +Sainte-Clotilde-de-Horton,39117,Centre-du-Québec,plex,1.0,1,1.5,not_published | |
| 2226 | +Abercorn,46005,Estrie,plex,1.0,1,1.5,not_published | |
| 2227 | +Portage-du-Fort,84020,Outaouais,plex,1.0,1,1.5,not_published | |
| 2228 | +Portneuf-sur-Mer,95040,Côte-Nord,plex,1.0,1,1.5,not_published | |
| 2229 | +Saint-Octave-de-Métis,09055,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2230 | +Saint-Gabriel-Lalemant,14075,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2231 | +Saint-Célestin,50035,Centre-du-Québec,plex,1.0,1,1.5,not_published | |
| 2232 | +Saint-Omer,17005,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2233 | +Saint-Stanislas,92070,Saguenay–Lac-Saint-Jean,plex,1.0,1,1.5,not_published | |
| 2234 | +Saint-Dominique-du-Rosaire,88065,Abitibi-Témiscamingue,plex,1.0,1,1.5,not_published | |
| 2235 | +Saint-Paul-de-la-Croix,12035,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2236 | +Beaulac-Garthby,31008,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2237 | +Saint-Anselme,19062,Chaudière-Appalaches,condo,1.0,1,1.5,not_published | |
| 2238 | +Saint-Séverin,27070,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2239 | +La Motte,88045,Abitibi-Témiscamingue,plex,1.0,1,1.5,not_published | |
| 2240 | +Val-Racine,30015,Estrie,plex,1.0,1,1.5,not_published | |
| 2241 | +Saint-Godefroi,05015,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,1.5,not_published | |
| 2242 | +La Rédemption,09005,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2243 | +TNO aquatique de la MRC du Rocher-Percé,02990,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,1.5,not_published | |
| 2244 | +Sainte-Edwidge-de-Clifton,44055,Estrie,plex,1.0,1,1.5,not_published | |
| 2245 | +Saint-Martin,29045,Chaudière-Appalaches,condo,1.0,1,1.5,not_published | |
| 2246 | +Maricourt,42065,Estrie,plex,1.0,1,1.5,not_published | |
| 2247 | +Saint-Laurent-de-l'Île-d'Orléans,20020,Capitale-Nationale,condo,1.0,1,1.5,not_published | |
| 2248 | +Routhierville,07902,Bas-Saint-Laurent,unifamilial,1.0,1,1.5,not_published | |
| 2249 | +Laniel,85905,Abitibi-Témiscamingue,plex,1.0,1,1.5,not_published | |
| 2250 | +Saint-Guillaume-Nord,62912,Lanaudière,unifamilial,1.0,1,1.5,not_published | |
| 2251 | +Sainte-Élizabeth-de-Warwick,39090,Centre-du-Québec,plex,1.0,1,1.5,not_published | |
| 2252 | +Saint-Léonard-d'Aston,50042,Centre-du-Québec,condo,1.0,1,1.5,not_published | |
| 2253 | +Saint-Gilbert,34060,Capitale-Nationale,plex,1.0,1,1.5,not_published | |
| 2254 | +Auclair,13045,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2255 | +Martinville,44060,Estrie,plex,1.0,1,1.5,not_published | |
| 2256 | +Marston,30035,Estrie,plex,1.0,1,1.5,not_published | |
| 2257 | +Pointe-aux-Outardes,96030,Côte-Nord,plex,1.0,1,1.5,not_published | |
| 2258 | +Saint-Julien,31035,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2259 | +Ulverton,42078,Estrie,plex,1.0,1,1.5,not_published | |
| 2260 | +Nominingue,79030,Laurentides,condo,1.0,1,1.5,not_published | |
| 2261 | +Côte-Nord-du-Golfe-du-Saint-Laurent,98015,Côte-Nord,plex,1.0,1,1.5,not_published | |
| 2262 | +Cloridorme,03010,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,1.5,not_published | |
| 2263 | +Saint-Narcisse-de-Beaurivage,33030,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2264 | +Belle-Rivière,93908,Saguenay–Lac-Saint-Jean,unifamilial,1.0,1,1.5,not_published | |
| 2265 | +Clermont,87110,Abitibi-Témiscamingue,plex,1.0,1,1.5,not_published | |
| 2266 | +Saint-Joseph-de-Kamouraska,14030,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2267 | +Bedford,46040,Estrie,plex,1.0,1,1.5,not_published | |
| 2268 | +Saint-Bernard-de-Michaudville,54115,Montérégie,plex,1.0,1,1.5,not_published | |
| 2269 | +Saint-Rosaire,39145,Centre-du-Québec,plex,1.0,1,1.5,not_published | |
| 2270 | +Sainte-Thècle,35050,Mauricie,condo,1.0,1,1.5,not_published | |
| 2271 | +Kinnear's Mills,31105,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2272 | +Sainte-Félicité,17025,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2273 | +Saint-Alexis,63023,Lanaudière,condo,1.0,1,1.5,not_published | |
| 2274 | +La Trinité-des-Monts,10010,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2275 | +Saint-Télesphore,71015,Montérégie,plex,1.0,1,1.5,not_published | |
| 2276 | +Clarendon,84015,Outaouais,plex,1.0,1,1.5,not_published | |
| 2277 | +Grosse-Île,01042,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,1.5,not_published | |
| 2278 | +Saint-Adalbert,17015,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2279 | +Sainte-Marguerite-Marie,07005,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2280 | +Saint-Marcel-de-Richelieu,54125,Montérégie,plex,1.0,1,1.5,not_published | |
| 2281 | +Saint-Eusèbe,13030,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2282 | +Saint-Basile,34038,Capitale-Nationale,condo,1.0,1,1.5,not_published | |
| 2283 | +Saint-Louis,54120,Montérégie,plex,1.0,1,1.5,not_published | |
| 2284 | +Kipawa,85010,Abitibi-Témiscamingue,plex,1.0,1,1.5,not_published | |
| 2285 | +Léry,67055,Montérégie,condo,1.0,1,1.5,not_published | |
| 2286 | +Ogden,45020,Estrie,plex,1.0,1,1.5,not_published | |
| 2287 | +Notre-Dame-de-Lorette,92060,Saguenay–Lac-Saint-Jean,plex,1.0,1,1.5,not_published | |
| 2288 | +Sainte-Élisabeth-de-Proulx,92903,Saguenay–Lac-Saint-Jean,unifamilial,1.0,1,1.5,not_published | |
| 2289 | +Sainte-Famille-de-l'Île-d'Orléans,20010,Capitale-Nationale,plex,1.0,1,1.5,not_published | |
| 2290 | +TNO aquatique de la MRC de Pontiac,84990,Outaouais,plex,1.0,1,1.5,not_published | |
| 2291 | +Saint-Louis-de-Blandford,39170,Centre-du-Québec,plex,1.0,1,1.5,not_published | |
| 2292 | +Denholm,83005,Outaouais,plex,1.0,1,1.5,not_published | |
| 2293 | +Saint-Herménégilde,44015,Estrie,plex,1.0,1,1.5,not_published | |
| 2294 | +Saint-Venant-de-Paquette,44005,Estrie,plex,1.0,1,1.5,not_published | |
| 2295 | +L'Ascension,79050,Laurentides,plex,1.0,1,1.5,not_published | |
| 2296 | +Sainte-Élisabeth-de-Proulx,92903,Saguenay–Lac-Saint-Jean,plex,1.0,1,1.5,not_published | |
| 2297 | +Mont-Élie,15902,Capitale-Nationale,plex,1.0,1,1.5,not_published | |
| 2298 | +Saint-Jules,27055,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2299 | +Estérel,77011,Laurentides,plex,1.0,1,1.5,not_published | |
| 2300 | +Ferland-et-Boilleau,94220,Saguenay–Lac-Saint-Jean,plex,1.0,1,1.5,not_published | |
| 2301 | +Sainte-Françoise,38035,Centre-du-Québec,plex,1.0,1,1.5,not_published | |
| 2302 | +Saint-Célestin,50030,Centre-du-Québec,condo,1.0,1,1.5,not_published | |
| 2303 | +Sainte-Christine,48020,Montérégie,plex,1.0,1,1.5,not_published | |
| 2304 | +Saint-Léon-le-Grand,51035,Mauricie,plex,1.0,1,1.5,not_published | |
| 2305 | +Saint-Alphonse,05065,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,1.5,not_published | |
| 2306 | +Mayo,80065,Outaouais,plex,1.0,1,1.5,not_published | |
| 2307 | +Saint-André-de-Restigouche,06040,Gaspésie–Îles-de-la-Madeleine,plex,1.0,1,1.5,not_published | |
| 2308 | +Milan,30040,Estrie,plex,1.0,1,1.5,not_published | |
| 2309 | +Linton,34904,Capitale-Nationale,unifamilial,1.0,1,1.5,not_published | |
| 2310 | +Amos,88057,Abitibi-Témiscamingue,condo,1.0,1,1.5,not_published | |
| 2311 | +Carleton-sur-Mer,06013,Gaspésie–Îles-de-la-Madeleine,condo,1.0,1,1.5,not_published | |
| 2312 | +Guérin,85095,Abitibi-Témiscamingue,plex,1.0,1,1.5,not_published | |
| 2313 | +Hampden,41075,Estrie,plex,1.0,1,1.5,not_published | |
| 2314 | +Berry,88070,Abitibi-Témiscamingue,plex,1.0,1,1.5,not_published | |
| 2315 | +Grosses-Roches,08015,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2316 | +Preissac,88090,Abitibi-Témiscamingue,plex,1.0,1,1.5,not_published | |
| 2317 | +Saint-Adolphe-d'Howard,77065,Laurentides,condo,1.0,1,1.5,not_published | |
| 2318 | +Saint-Eugène-d'Argentenay,92065,Saguenay–Lac-Saint-Jean,plex,1.0,1,1.5,not_published | |
| 2319 | +Sainte-Thérèse-de-la-Gatineau,83055,Outaouais,plex,1.0,1,1.5,not_published | |
| 2320 | +Calixa-Lavallée,59030,Montérégie,plex,1.0,1,1.5,not_published | |
| 2321 | +Saint-Arsène,12065,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2322 | +Irlande,31040,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2323 | +Berthierville,52035,Lanaudière,condo,1.0,1,1.5,not_published | |
| 2324 | +Pointe-Calumet,72020,Laurentides,condo,1.0,1,1.5,not_published | |
| 2325 | +Saint-Edmond-les-Plaines,92050,Saguenay–Lac-Saint-Jean,plex,1.0,1,1.5,not_published | |
| 2326 | +Rivière-aux-Outardes,96902,Côte-Nord,plex,1.0,1,1.5,not_published | |
| 2327 | +Stukely-Sud,45105,Estrie,plex,1.0,1,1.5,not_published | |
| 2328 | +Sainte-Sabine,28065,Chaudière-Appalaches,plex,1.0,1,1.5,not_published | |
| 2329 | +Baie-Trinité,96005,Côte-Nord,plex,1.0,1,1.5,not_published | |
| 2330 | +Lac-Walker,97904,Côte-Nord,plex,1.0,1,1.5,not_published | |
| 2331 | +Saint-Pie,54008,Montérégie,condo,1.0,1,1.5,not_published | |
| 2332 | +Baie-Sainte-Catherine,15065,Capitale-Nationale,plex,1.0,1,1.5,not_published | |
| 2333 | +Lac-Chicobi,88904,Abitibi-Témiscamingue,plex,1.0,1,1.5,not_published | |
| 2334 | +Lac-Édouard,90027,Mauricie,plex,1.0,1,1.5,not_published | |
| 2335 | +Grand-Métis,09060,Bas-Saint-Laurent,plex,1.0,1,1.5,not_published | |
| 2336 | +Saint-Cléophas-de-Brandon,52075,Lanaudière,plex,1.0,1,1.5,not_published | |
added
outputs/tables/downsampling_monthly.csv
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +target_tx_per_month,rmse_direct_pct,rmse_hierarchical_pct | |
| 2 | +300,1.01,1.3 | |
| 3 | +150,1.2,1.37 | |
| 4 | +75,1.49,1.31 | |
| 5 | +40,1.85,1.45 | |
| 6 | +20,2.09,1.69 | |
| 7 | +10,2.59,2.21 | |
added
outputs/tables/downsampling_results.csv
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +target_tx_per_week,rmse_log_pct,bias_log_pct,volatility_ratio,turning_point_agreement,ci95_coverage | |
| 2 | +100,0.317,-0.046,1.051,0.861,1.0 | |
| 3 | +50,0.636,-0.177,0.987,0.799,0.992 | |
| 4 | +25,0.799,0.002,0.762,0.771,0.977 | |
| 5 | +15,1.15,-0.154,0.449,0.778,0.881 | |
| 6 | +10,1.361,-0.18,0.358,0.735,0.828 | |
| 7 | +5,1.633,0.189,0.457,0.685,0.856 | |
added
outputs/tables/estimation_methods.csv
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +cell,method,median_tx | |
| 2 | +quebec/unifamilial,rtd_mean_splice, | |
| 3 | +region-01/unifamilial,direct_local_td,188.0 | |
| 4 | +region-02/unifamilial,direct_local_td,255.0 | |
| 5 | +region-03/unifamilial,direct_local_td,543.0 | |
| 6 | +region-04/unifamilial,direct_local_td,254.0 | |
| 7 | +region-05/unifamilial,direct_local_td,488.0 | |
| 8 | +region-06/unifamilial,direct_local_td,401.0 | |
| 9 | +region-07/unifamilial,direct_local_td,406.0 | |
| 10 | +region-08/unifamilial,direct_local_td,125.0 | |
| 11 | +region-09/unifamilial,direct_local_td,67.0 | |
| 12 | +region-10/unifamilial,hierarchical,16.0 | |
| 13 | +region-11/unifamilial,direct_local_td,80.0 | |
| 14 | +region-12/unifamilial,direct_local_td,408.0 | |
| 15 | +region-13/unifamilial,direct_local_td,234.0 | |
| 16 | +region-14/unifamilial,direct_local_td,543.0 | |
| 17 | +region-15/unifamilial,direct_local_td,689.0 | |
| 18 | +region-16/unifamilial,direct_local_td,1181.0 | |
| 19 | +region-17/unifamilial,direct_local_td,226.0 | |
| 20 | +montreal/unifamilial,direct_local_td,280.0 | |
| 21 | +quebec-city/unifamilial,direct_local_td,303.0 | |
| 22 | +laval/unifamilial,direct_local_td,234.0 | |
| 23 | +gatineau/unifamilial,direct_local_td,240.0 | |
| 24 | +longueuil/unifamilial,direct_local_td,118.0 | |
| 25 | +sherbrooke/unifamilial,direct_local_td,118.0 | |
| 26 | +trois-rivieres/unifamilial,direct_local_td,101.0 | |
| 27 | +saguenay/unifamilial,direct_local_td,117.0 | |
| 28 | +levis/unifamilial,direct_local_td,111.0 | |
| 29 | +drummondville/unifamilial,direct_local_td,72.0 | |
| 30 | +quebec/condo,rtd_mean_splice, | |
| 31 | +region-01/condo,hierarchical,3.0 | |
| 32 | +region-02/condo,hierarchical,5.0 | |
| 33 | +region-03/condo,direct_local_td,186.0 | |
| 34 | +region-04/condo,hierarchical,5.0 | |
| 35 | +region-05/condo,hierarchical,27.0 | |
| 36 | +region-06/condo,direct_local_td,659.0 | |
| 37 | +region-07/condo,hierarchical,30.0 | |
| 38 | +region-08/condo,hierarchical,1.0 | |
| 39 | +region-09/condo,hierarchical,0.0 | |
| 40 | +region-10/condo,hierarchical,0.0 | |
| 41 | +region-11/condo,hierarchical,0.0 | |
| 42 | +region-12/condo,hierarchical,11.0 | |
| 43 | +region-13/condo,direct_local_td,91.0 | |
| 44 | +region-14/condo,direct_local_td,47.0 | |
| 45 | +region-15/condo,direct_local_td,45.0 | |
| 46 | +region-16/condo,direct_local_td,155.0 | |
| 47 | +region-17/condo,hierarchical,2.0 | |
| 48 | +montreal/condo,direct_local_td,620.0 | |
| 49 | +quebec-city/condo,direct_local_td,173.0 | |
| 50 | +laval/condo,direct_local_td,91.0 | |
| 51 | +gatineau/condo,hierarchical,30.0 | |
| 52 | +longueuil/condo,direct_local_td,60.0 | |
| 53 | +sherbrooke/condo,hierarchical,7.0 | |
| 54 | +trois-rivieres/condo,hierarchical,4.0 | |
| 55 | +saguenay/condo,hierarchical,4.0 | |
| 56 | +levis/condo,hierarchical,9.0 | |
| 57 | +drummondville/condo,hierarchical,1.0 | |
| 58 | +quebec/plex,rtd_mean_splice, | |
| 59 | +region-01/plex,hierarchical,24.0 | |
| 60 | +region-02/plex,direct_local_td,57.0 | |
| 61 | +region-03/plex,direct_local_td,115.0 | |
| 62 | +region-04/plex,direct_local_td,68.0 | |
| 63 | +region-05/plex,direct_local_td,83.0 | |
| 64 | +region-06/plex,direct_local_td,351.0 | |
| 65 | +region-07/plex,direct_local_td,70.0 | |
| 66 | +region-08/plex,hierarchical,23.0 | |
| 67 | +region-09/plex,hierarchical,11.0 | |
| 68 | +region-10/plex,hierarchical,2.0 | |
| 69 | +region-11/plex,hierarchical,8.0 | |
| 70 | +region-12/plex,direct_local_td,55.0 | |
| 71 | +region-13/plex,hierarchical,34.0 | |
| 72 | +region-14/plex,direct_local_td,70.0 | |
| 73 | +region-15/plex,direct_local_td,130.0 | |
| 74 | +region-16/plex,direct_local_td,163.0 | |
| 75 | +region-17/plex,hierarchical,35.0 | |
| 76 | +montreal/plex,direct_local_td,346.0 | |
| 77 | +quebec-city/plex,direct_local_td,92.0 | |
| 78 | +laval/plex,hierarchical,34.0 | |
| 79 | +gatineau/plex,direct_local_td,52.0 | |
| 80 | +longueuil/plex,direct_local_td,40.0 | |
| 81 | +sherbrooke/plex,hierarchical,34.0 | |
| 82 | +trois-rivieres/plex,hierarchical,34.0 | |
| 83 | +saguenay/plex,hierarchical,37.0 | |
| 84 | +levis/plex,hierarchical,22.0 | |
| 85 | +drummondville/plex,hierarchical,16.0 | |
added
outputs/tables/exclusions.csv
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +exclude_reason,n,pct_of_raw | |
| 2 | +assessment_ratio_outside_band,24976,3.352 | |
| 3 | +exact_duplicate,665,0.089 | |
| 4 | +addr_date_amount_duplicate,107,0.014 | |
| 5 | +price_above_10m,46,0.006 | |
| 6 | +TOTAL_EXCLUDED,25794,3.462 | |
| 7 | +RETAINED_IN_RESEARCH_SAMPLE,719325,96.538 | |
added
outputs/tables/geo_city_mismatch_top300.csv
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +city,municipality,region,n | |
| 2 | +Saint-Bruno,Hébertville,Saguenay–Lac-Saint-Jean,292 | |
| 3 | +Courcelles-Saint-Évariste,Courcelles–Saint-Évariste,Chaudière-Appalaches,140 | |
| 4 | +Saint-Félix-de-Dalquier,Amos,Abitibi-Témiscamingue,95 | |
| 5 | +Saint-Onésime-d'Ixworth,La Pocatière,Bas-Saint-Laurent,61 | |
| 6 | +Passes-Dangereuses,Sainte-Élisabeth-de-Proulx,Saguenay–Lac-Saint-Jean,16 | |
| 7 | +Saint-Guy,Lac-des-Aigles,Bas-Saint-Laurent,13 | |
| 8 | +TNO aquatique de la MRC d'Argenteuil,Grenville-sur-la-Rouge,Laurentides,1 | |
added
outputs/tables/geo_region_distribution.csv
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +region_code,region,n_transactions | |
| 2 | +16,Montérégie,134113 | |
| 3 | +06,Montréal,116682 | |
| 4 | +15,Laurentides,77374 | |
| 5 | +03,Capitale-Nationale,73093 | |
| 6 | +14,Lanaudière,57064 | |
| 7 | +05,Estrie,52133 | |
| 8 | +07,Outaouais,44087 | |
| 9 | +12,Chaudière-Appalaches,42729 | |
| 10 | +13,Laval,28651 | |
| 11 | +02,Saguenay–Lac-Saint-Jean,26725 | |
| 12 | +04,Mauricie,26218 | |
| 13 | +17,Centre-du-Québec,22995 | |
| 14 | +01,Bas-Saint-Laurent,17245 | |
| 15 | +08,Abitibi-Témiscamingue,12273 | |
| 16 | +11,Gaspésie–Îles-de-la-Madeleine,6452 | |
| 17 | +09,Côte-Nord,5911 | |
| 18 | +10,Nord-du-Québec,1374 | |
added
outputs/tables/hierarchical_summary.csv
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +geography,property_type,median_weekly_tx,sd_raw_pct,sd_smoothed_pct,mean_shrinkage,last_index | |
| 2 | +drummondville,all,20.0,11.39,7.37,0.977,171.7 | |
| 3 | +drummondville,condo,0.0,24.23,4.17,0.978,195.26 | |
| 4 | +drummondville,plex,4.0,29.79,7.97,0.98,198.35 | |
| 5 | +drummondville,unifamilial,15.0,19.08,8.24,0.977,165.58 | |
| 6 | +gatineau,all,74.0,6.39,3.08,0.934,147.17 | |
| 7 | +gatineau,condo,6.0,13.78,3.81,0.977,151.59 | |
| 8 | +gatineau,plex,12.0,15.76,5.13,0.956,164.61 | |
| 9 | +gatineau,unifamilial,54.0,8.15,3.63,0.923,143.1 | |
| 10 | +laval,all,84.0,5.99,3.76,0.977,146.54 | |
| 11 | +laval,condo,20.0,9.82,3.71,0.978,159.74 | |
| 12 | +laval,plex,8.0,25.58,5.31,0.98,168.93 | |
| 13 | +laval,unifamilial,53.0,7.64,5.22,0.976,139.01 | |
| 14 | +levis,all,32.0,11.48,4.98,0.939,155.87 | |
| 15 | +levis,condo,2.0,26.41,5.54,0.976,125.54 | |
| 16 | +levis,plex,5.0,30.31,5.57,0.979,187.34 | |
| 17 | +levis,unifamilial,25.0,14.13,5.76,0.929,154.55 | |
| 18 | +longueuil,all,51.0,7.98,3.41,0.952,143.21 | |
| 19 | +longueuil,condo,14.0,11.11,3.68,0.963,138.52 | |
| 20 | +longueuil,plex,9.0,19.64,5.28,0.956,167.32 | |
| 21 | +longueuil,unifamilial,28.0,12.21,5.19,0.945,138.82 | |
| 22 | +montreal,all,281.0,4.05,4.46,0.968,137.67 | |
| 23 | +montreal,condo,140.0,6.97,4.88,0.972,133.59 | |
| 24 | +montreal,plex,76.0,14.17,8.82,0.974,150.75 | |
| 25 | +montreal,unifamilial,64.0,7.09,3.96,0.951,131.78 | |
| 26 | +quebec,all,1999.0,3.04,3.04,0.0,156.73 | |
| 27 | +quebec,condo,291.0,3.78,3.78,0.0,145.29 | |
| 28 | +quebec,plex,297.0,5.18,5.18,0.0,169.51 | |
| 29 | +quebec,unifamilial,1384.0,3.67,3.67,0.0,156.72 | |
| 30 | +quebec-city,all,121.0,6.84,2.92,0.975,164.19 | |
| 31 | +quebec-city,condo,37.0,9.57,4.16,0.976,159.98 | |
| 32 | +quebec-city,plex,20.0,16.4,5.2,0.979,170.88 | |
| 33 | +quebec-city,unifamilial,64.0,10.25,4.03,0.973,164.74 | |
| 34 | +region-01,all,48.0,9.11,3.45,0.956,161.87 | |
| 35 | +region-01,condo,0.0,26.31,3.93,0.979,155.09 | |
| 36 | +region-01,plex,5.0,27.4,5.25,0.935,173.36 | |
| 37 | +region-01,unifamilial,42.0,13.95,3.71,0.959,160.52 | |
| 38 | +region-02,all,70.0,7.95,3.39,0.882,170.14 | |
| 39 | +region-02,condo,1.0,25.26,3.93,0.966,161.73 | |
| 40 | +region-02,plex,13.0,19.64,5.25,0.933,191.62 | |
| 41 | +region-02,unifamilial,57.0,11.55,3.78,0.869,165.92 | |
| 42 | +region-03,all,180.0,6.74,3.13,0.84,166.74 | |
| 43 | +region-03,condo,40.0,9.07,4.03,0.838,161.75 | |
| 44 | +region-03,plex,26.0,14.94,5.17,0.942,173.74 | |
| 45 | +region-03,unifamilial,115.0,9.89,3.87,0.82,167.1 | |
| 46 | +region-04,all,73.0,8.54,3.88,0.906,190.5 | |
| 47 | +region-04,condo,1.0,29.76,3.92,0.95,212.67 | |
| 48 | +region-04,plex,16.0,15.69,5.76,0.89,221.95 | |
| 49 | +region-04,unifamilial,55.0,11.29,4.11,0.909,181.99 | |
| 50 | +region-05,all,134.0,8.58,3.61,0.944,165.76 | |
| 51 | +region-05,condo,6.0,21.33,3.75,0.963,155.55 | |
| 52 | +region-05,plex,19.0,18.1,5.3,0.951,189.49 | |
| 53 | +region-05,unifamilial,106.0,11.3,4.16,0.941,162.4 | |
| 54 | +region-06,all,328.0,3.8,3.22,0.865,136.31 | |
| 55 | +region-06,condo,151.0,6.76,4.07,0.876,135.38 | |
| 56 | +region-06,plex,78.0,14.13,6.55,0.9,148.53 | |
| 57 | +region-06,unifamilial,96.0,6.07,3.67,0.815,128.03 | |
| 58 | +region-07,all,116.0,6.08,3.2,0.896,148.08 | |
| 59 | +region-07,condo,6.0,14.04,3.69,0.856,148.62 | |
| 60 | +region-07,plex,15.0,16.23,5.22,0.961,165.38 | |
| 61 | +region-07,unifamilial,92.0,6.94,3.62,0.888,145.36 | |
| 62 | +region-08,all,33.0,10.56,3.58,0.957,149.85 | |
| 63 | +region-08,condo,0.0,35.57,4.31,0.97,162.89 | |
| 64 | +region-08,plex,5.0,27.78,5.23,0.972,165.03 | |
| 65 | +region-08,unifamilial,27.0,13.13,3.91,0.954,147.05 | |
| 66 | +region-09,all,18.0,10.47,4.88,0.768,169.05 | |
| 67 | +region-09,condo,0.0,39.91,4.86,0.988,146.6 | |
| 68 | +region-09,plex,2.0,36.61,6.76,0.961,172.45 | |
| 69 | +region-09,unifamilial,15.0,21.43,5.58,0.737,168.67 | |
| 70 | +region-10,all,4.0,NaN,3.66,0.949,128.98 | |
| 71 | +region-10,condo,0.0,NaN,3.78,1.0,145.29 | |
| 72 | +region-10,plex,0.0,55.62,5.23,0.982,155.51 | |
| 73 | +region-10,unifamilial,4.0,39.64,3.89,0.946,126.34 | |
| 74 | +region-11,all,19.0,NaN,3.78,0.958,159.22 | |
| 75 | +region-11,condo,0.0,NaN,3.79,0.997,159.49 | |
| 76 | +region-11,plex,2.0,51.26,5.61,0.98,179.82 | |
| 77 | +region-11,unifamilial,17.0,18.37,3.99,0.955,156.85 | |
| 78 | +region-12,all,106.0,7.69,4.84,0.87,163.39 | |
| 79 | +region-12,condo,3.0,31.69,5.17,0.908,128.46 | |
| 80 | +region-12,plex,12.0,20.52,5.27,0.944,184.33 | |
| 81 | +region-12,unifamilial,92.0,9.57,5.34,0.859,162.41 | |
| 82 | +region-13,all,84.0,5.99,3.13,0.858,147.29 | |
| 83 | +region-13,condo,20.0,9.82,3.67,0.851,158.85 | |
| 84 | +region-13,plex,8.0,25.58,5.2,0.94,167.57 | |
| 85 | +region-13,unifamilial,53.0,7.64,4.13,0.848,140.55 | |
| 86 | +region-14,all,156.0,5.6,3.29,0.859,160.25 | |
| 87 | +region-14,condo,11.0,10.61,4.34,0.849,153.79 | |
| 88 | +region-14,plex,16.0,15.65,5.03,0.966,173.96 | |
| 89 | +region-14,unifamilial,128.0,6.66,3.69,0.846,159.26 | |
| 90 | +region-15,all,201.0,5.44,3.64,0.924,159.77 | |
| 91 | +region-15,condo,10.0,20.89,3.83,0.894,172.41 | |
| 92 | +region-15,plex,29.0,12.25,5.36,0.959,177.2 | |
| 93 | +region-15,unifamilial,160.0,6.45,4.34,0.92,155.87 | |
| 94 | +region-16,all,341.0,4.59,4.39,0.914,150.33 | |
| 95 | +region-16,condo,35.0,8.31,3.66,0.911,141.17 | |
| 96 | +region-16,plex,37.0,10.75,5.21,0.944,173.25 | |
| 97 | +region-16,unifamilial,265.0,9.72,5.34,0.91,148.83 | |
| 98 | +region-17,all,60.0,8.17,6.93,0.938,164.63 | |
| 99 | +region-17,condo,0.0,26.84,3.88,0.966,168.22 | |
| 100 | +region-17,plex,9.0,24.25,6.43,0.946,185.36 | |
| 101 | +region-17,unifamilial,51.0,12.34,7.73,0.937,161.6 | |
| 102 | +saguenay,all,34.0,9.54,3.35,0.97,173.52 | |
| 103 | +saguenay,condo,1.0,25.29,4.41,0.979,158.96 | |
| 104 | +saguenay,plex,8.0,18.75,5.41,0.978,191.9 | |
| 105 | +saguenay,unifamilial,25.0,13.22,3.79,0.967,169.0 | |
| 106 | +sherbrooke,all,37.0,11.85,3.35,0.958,166.84 | |
| 107 | +sherbrooke,condo,1.0,40.65,4.19,0.954,150.57 | |
| 108 | +sherbrooke,plex,7.0,27.71,5.27,0.98,190.34 | |
| 109 | +sherbrooke,unifamilial,29.0,13.32,3.69,0.952,162.12 | |
| 110 | +trois-rivieres,all,32.0,10.2,4.0,0.962,195.79 | |
| 111 | +trois-rivieres,condo,1.0,30.42,4.16,0.98,223.27 | |
| 112 | +trois-rivieres,plex,8.0,19.82,6.56,0.978,223.32 | |
| 113 | +trois-rivieres,unifamilial,23.0,14.4,4.14,0.955,185.82 | |
added
outputs/tables/index_latest.csv
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +week,geography_level,geography_id,geography_name,property_type,index,index_smoothed,representative_value,transactions,effective_sample_size,weekly_pct,four_week_pct,thirteen_week_pct,twenty_six_week_pct,yoy_pct,lower_95,upper_95,reliability_grade,shrinkage_weight,is_partial_week,model_version,data_vintage | |
| 2 | +2026-07-13,municipality,drummondville,Drummondville,all,164.77849134185595,173.4798580605911,437355.0,28,627.2,2.237,-0.462,7.06,11.081,10.967,168.34444769634925,178.77192603944428,B,0.9905026564268633,false,0.1.0,2026-08-06 | |
| 3 | +2026-07-13,municipality,drummondville,Drummondville,condo,162.1855771911848,182.94775566935903,385883.0,1,25.0,-1.16,-2.825,-4.75,4.822,0.358,162.31186840843915,206.20723322728546,E,0.9544812392950297,false,0.1.0,2026-08-06 | |
| 4 | +2026-07-13,municipality,drummondville,Drummondville,plex,168.62206897926285,184.38551329576697,517592.0,3,126.0,3.043,1.126,4.049,7.691,1.876,171.29983556126166,198.4708123154316,E,0.9974982064019289,false,0.1.0,2026-08-06 | |
| 5 | +2026-07-13,municipality,drummondville,Drummondville,unifamilial,163.9292539802428,170.84234646821383,421118.0,24,591.4,2.108,-0.791,8.004,12.01,13.428,165.23813679413064,176.63662828109275,C,0.9894791980422197,false,0.1.0,2026-08-06 | |
| 6 | +2026-07-13,municipality,gatineau,Gatineau,all,151.52040999080805,147.4388747429021,495190.0,94,674.3,2.327,-0.199,2.671,4.264,3.013,143.22721739268897,151.77437767189915,A,0.932973713106922,false,0.1.0,2026-08-06 | |
| 7 | +2026-07-13,municipality,gatineau,Gatineau,condo,143.922212828847,144.744083289835,316144.0,3,51.7,-0.578,-2.473,-5.748,3.077,-1.559,133.17419760756493,157.31913556672777,D,0.9988016332665152,false,0.1.0,2026-08-06 | |
| 8 | +2026-07-13,municipality,gatineau,Gatineau,plex,168.84107844244272,153.91636378143494,626749.0,19,194.8,4.379,1.685,2.627,2.448,2.584,145.06995617739946,163.30222786259952,C,0.9504109722556551,false,0.1.0,2026-08-06 | |
| 9 | +2026-07-13,municipality,gatineau,Gatineau,unifamilial,149.19932698131655,146.49496368578122,502825.0,72,529.1,2.326,-0.259,3.97,4.828,3.789,141.41906588379342,151.75304865139654,B,0.9196699204008252,false,0.1.0,2026-08-06 | |
| 10 | +2026-07-13,municipality,laval,Laval,all,134.4609200037922,145.2168764962385,580987.0,86,668.9,0.148,-3.292,-1.178,2.845,4.592,141.0524091956634,149.50429659142702,A,0.9965668562430385,false,0.1.0,2026-08-06 | |
| 11 | +2026-07-13,municipality,laval,Laval,condo,141.51152347856134,150.98566600881333,420877.0,13,106.2,-1.076,-5.809,-7.583,-0.842,-1.374,142.46471629218814,160.01626180457248,C,0.9978965986604492,false,0.1.0,2026-08-06 | |
| 12 | +2026-07-13,municipality,laval,Laval,plex,164.32506009215183,158.20718498585975,845842.0,8,143.8,3.764,2.52,11.014,10.568,9.215,147.67255541699427,169.49333144857079,D,0.9966949830749318,false,0.1.0,2026-08-06 | |
| 13 | +2026-07-13,municipality,laval,Laval,unifamilial,128.14383835810253,141.35845415953307,620008.0,65,476.2,0.091,-3.161,-0.377,3.162,6.236,136.20060738435302,146.71162593264916,B,0.996053320535233,false,0.1.0,2026-08-06 | |
| 14 | +2026-07-13,municipality,levis,Lévis,all,165.1686271634607,157.52717110255958,428858.0,62,436.3,2.333,0.425,10.706,10.04,10.376,151.9529627959471,163.30586241281867,B,0.9417514804812133,false,0.1.0,2026-08-06 | |
| 15 | +2026-07-13,municipality,levis,Lévis,condo,137.86269578040984,119.85427494742905,286323.0,3,40.8,0.557,-1.309,4.675,15.582,13.667,109.12671305399108,131.63639608632508,E,0.9967287464646899,false,0.1.0,2026-08-06 | |
| 16 | +2026-07-13,municipality,levis,Lévis,plex,172.6868961867643,175.84032940959102,526571.0,13,158.0,4.103,5.176,9.996,8.928,5.932,164.65573506839436,187.78466133615126,D,0.9913713993739749,false,0.1.0,2026-08-06 | |
| 17 | +2026-07-13,municipality,levis,Lévis,unifamilial,166.94614733046757,158.98448129802284,433201.0,46,352.1,2.224,-0.159,11.443,9.685,10.796,152.25884382367704,166.00720627349733,B,0.9281560081720617,false,0.1.0,2026-08-06 | |
| 18 | +2026-07-13,municipality,longueuil,Longueuil,all,135.37495568216426,141.68901488544614,547703.0,47,858.1,1.254,-0.702,4.495,6.275,4.086,138.0953343064277,145.37621448282155,A,0.969439212880761,false,0.1.0,2026-08-06 | |
| 19 | +2026-07-13,municipality,longueuil,Longueuil,condo,118.1831940015355,131.68072261082753,367520.0,8,159.9,-0.939,-1.714,-3.444,4.663,0.137,125.59147110592039,138.06520900360968,C,0.9872499053820072,false,0.1.0,2026-08-06 | |
| 20 | +2026-07-13,municipality,longueuil,Longueuil,plex,111.08190049998647,156.34181658510758,806829.0,9,154.2,2.087,0.103,6.008,4.531,0.088,146.27780849356807,167.09823496026863,D,0.9698507147970352,false,0.1.0,2026-08-06 | |
| 21 | +2026-07-13,municipality,longueuil,Longueuil,unifamilial,154.76361801267538,142.81582507109277,599239.0,30,558.4,2.172,-0.415,8.467,7.695,7.551,137.99690219768829,147.80302721229233,B,0.9598856099024536,false,0.1.0,2026-08-06 | |
| 22 | +2026-07-13,municipality,montreal,Montréal,all,133.06986553548586,132.76064368136556,633421.0,309,822.3,1.153,-1.178,-0.099,2.911,1.024,129.32191793575694,136.29080663222337,A,0.9887158395063785,false,0.1.0,2026-08-06 | |
| 23 | +2026-07-13,municipality,montreal,Montréal,condo,125.42430368011634,126.92022864212704,488767.0,125,215.2,-0.592,-2.577,-2.927,2.97,-2.119,121.84395502214286,132.20799042219485,B,0.9970729871173344,false,0.1.0,2026-08-06 | |
| 24 | +2026-07-13,municipality,montreal,Montréal,plex,142.95638717921628,141.21969257393548,855882.0,102,263.2,3.708,1.968,3.88,3.936,2.587,134.20777785936548,148.5979567560894,C,0.9956826377453092,false,0.1.0,2026-08-06 | |
| 25 | +2026-07-13,municipality,montreal,Montréal,unifamilial,139.54576158598798,136.40111214250186,790109.0,82,478.8,2.084,-1.777,1.691,1.511,6.627,131.43731662129838,141.5523678661021,B,0.9607016997642371,false,0.1.0,2026-08-06 | |
| 26 | +2026-07-13,municipality,quebec-city,Québec,all,166.2505397958448,162.90258077909542,447620.0,173,974.1,1.721,0.324,8.036,9.635,10.723,159.02172297770417,166.87814927153312,A,0.9948373151056271,false,0.1.0,2026-08-06 | |
| 27 | +2026-07-13,municipality,quebec-city,Québec,condo,152.01169074222744,152.69568762449413,328294.0,55,141.7,-0.357,-1.004,-1.494,8.095,7.405,145.20487301877998,160.57293763206923,C,0.9921405685245888,false,0.1.0,2026-08-06 | |
| 28 | +2026-07-13,municipality,quebec-city,Québec,plex,166.57402899830836,159.3589194193802,575230.0,25,222.1,3.735,2.485,8.112,6.946,9.285,150.76467334208868,168.44307512868104,C,0.996176799613064,false,0.1.0,2026-08-06 | |
| 29 | +2026-07-13,municipality,quebec-city,Québec,unifamilial,174.65966395092127,169.91987433305448,495439.0,93,703.5,2.337,0.474,13.717,11.27,13.027,164.80223693570352,175.1964307658369,B,0.9959667091066711,false,0.1.0,2026-08-06 | |
| 30 | +2026-07-13,municipality,saguenay,Saguenay,all,NaN,173.88222851845464,368933.0,62,560.3,2.222,-0.232,6.659,13.803,12.478,168.44111214172003,179.4991080865428,B,0.9767235420261182,false,0.1.0,2026-08-06 | |
| 31 | +2026-07-13,municipality,saguenay,Saguenay,condo,NaN,150.5139860769468,270447.0,0,59.0,0.189,-1.324,-1.432,6.277,3.94,139.22824367895774,162.7145427260404,E,1.0,false,0.1.0,2026-08-06 | |
| 32 | +2026-07-13,municipality,saguenay,Saguenay,plex,193.98266629730384,179.37992410862378,389319.0,10,166.1,3.635,6.08,6.997,12.303,11.034,168.24061365681519,191.25677488820892,D,0.9961027747992764,false,0.1.0,2026-08-06 | |
| 33 | +2026-07-13,municipality,saguenay,Saguenay,unifamilial,177.26410039299967,173.21138600603447,367435.0,52,461.3,1.885,-1.976,6.868,14.535,13.233,166.79196813970313,179.87787167906055,B,0.9701711220428698,false,0.1.0,2026-08-06 | |
| 34 | +2026-07-13,municipality,sherbrooke,Sherbrooke,all,168.3080121946426,167.2882163217376,451680.0,67,643.1,1.94,-1.315,7.846,8.814,10.04,162.39679702241122,172.32696600688755,B,0.959393955057428,false,0.1.0,2026-08-06 | |
| 35 | +2026-07-13,municipality,sherbrooke,Sherbrooke,condo,140.31759528284837,141.86039648082988,319123.0,4,27.1,-0.71,-5.963,-3.602,5.634,-1.439,126.44462745842087,159.15561217747901,E,0.9198263514654395,false,0.1.0,2026-08-06 | |
| 36 | +2026-07-13,municipality,sherbrooke,Sherbrooke,plex,196.0650747383211,178.05995340871354,564836.0,9,188.2,3.578,1.795,7.984,8.241,6.743,167.6538299674035,189.11197563501904,D,0.9962387344161051,false,0.1.0,2026-08-06 | |
| 37 | +2026-07-13,municipality,sherbrooke,Sherbrooke,unifamilial,163.36244957209058,166.06822213010776,434345.0,54,532.1,1.663,-1.852,8.463,9.137,11.572,160.3303132968328,172.01147951601737,B,0.9519620864180663,false,0.1.0,2026-08-06 | |
| 38 | +2026-07-13,municipality,trois-rivieres,Trois-Rivières,all,NaN,195.6099747683359,409408.0,45,413.6,2.94,-0.082,11.727,10.332,9.313,188.50418154021773,202.98362570119147,B,0.9737231381213693,false,0.1.0,2026-08-06 | |
| 39 | +2026-07-13,municipality,trois-rivieres,Trois-Rivières,condo,NaN,212.3090456144626,312619.0,0,17.5,-0.439,-1.096,-3.699,9.244,3.533,184.02055399111643,244.94617515334704,E,1.0,false,0.1.0,2026-08-06 | |
| 40 | +2026-07-13,municipality,trois-rivieres,Trois-Rivières,plex,231.35986878875804,209.28385662746734,458237.0,13,116.5,4.972,3.99,14.991,7.822,7.426,193.8640633652886,225.9301279698064,D,0.9948459700470538,false,0.1.0,2026-08-06 | |
| 41 | +2026-07-13,municipality,trois-rivieres,Trois-Rivières,unifamilial,197.12580100155935,190.28811154215802,398819.0,32,343.0,2.421,-1.401,11.466,11.27,10.28,182.1336935073014,198.8076159715566,B,0.9650659349310294,false,0.1.0,2026-08-06 | |
| 42 | +2026-07-13,province,quebec,Québec (province),all,157.0103100782035,157.0103100782035,469013.0,2487,2302.0,1.672,-0.827,5.891,7.644,7.604,154.56682412570223,159.49242413627556,A,0.0,false,0.1.0,2026-08-06 | |
| 43 | +2026-07-13,province,quebec,Québec (province),condo,138.16020078426592,138.16020078426592,419543.0,258,258.0,-0.439,-2.121,-2.366,4.956,0.859,133.10497997669714,143.4074148397038,B,0.0,false,0.1.0,2026-08-06 | |
| 44 | +2026-07-13,province,quebec,Québec (province),plex,158.89405583388057,158.89405583388057,583567.0,364,364.0,3.566,2.405,6.527,6.161,4.765,152.16014393276552,165.9259798708938,B,0.0,false,0.1.0,2026-08-06 | |
| 45 | +2026-07-13,province,quebec,Québec (province),unifamilial,161.0061781039289,161.0061781039289,458587.0,1865,1865.0,1.738,-1.212,7.63,8.55,9.734,158.01040415933193,164.0587499636687,A,0.0,false,0.1.0,2026-08-06 | |
| 46 | +2026-07-13,region,region-01,Bas-Saint-Laurent,all,168.54248122855066,164.21468945413278,269166.0,74,839.8,1.873,-1.235,7.232,7.436,9.762,160.00521165026916,168.53491179686773,B,0.9588173040003705,false,0.1.0,2026-08-06 | |
| 47 | +2026-07-13,region,region-01,Bas-Saint-Laurent,condo,153.57434800733495,147.47607771388306,267384.0,2,94.4,-0.336,-1.612,-2.097,5.581,2.887,138.66340498248582,156.84883477813307,E,0.9751415721053844,false,0.1.0,2026-08-06 | |
| 48 | +2026-07-13,region,region-01,Bas-Saint-Laurent,plex,141.27616345425957,163.14763948121507,303239.0,12,94.9,1.891,2.281,3.671,3.057,5.869,149.88136844423136,177.58813216464847,D,0.8982540467818814,false,0.1.0,2026-08-06 | |
| 49 | +2026-07-13,region,region-01,Bas-Saint-Laurent,unifamilial,172.83111055971344,164.66295519314008,264874.0,60,861.4,1.909,-1.688,7.885,8.066,10.415,160.17453642462633,169.2771486540066,B,0.9666073120009592,false,0.1.0,2026-08-06 | |
| 50 | +2026-07-13,region,region-02,Saguenay–Lac-Saint-Jean,all,173.19199091760845,171.53867483023387,316631.0,116,620.6,2.102,-0.494,7.148,14.096,12.163,166.43419949119036,176.7997025399233,B,0.8685966199199296,false,0.1.0,2026-08-06 | |
| 51 | +2026-07-13,region,region-02,Saguenay–Lac-Saint-Jean,condo,211.25730277068428,153.1324220410915,263521.0,1,65.2,0.189,-1.388,-1.513,6.112,4.017,142.18583527398653,164.92176337385945,E,0.9808352946165771,false,0.1.0,2026-08-06 | |
| 52 | +2026-07-13,region,region-02,Saguenay–Lac-Saint-Jean,plex,179.99037704939064,179.1895414688435,349192.0,21,173.8,3.604,5.78,6.993,12.005,10.798,168.30350135219777,190.7797016333142,C,0.9227909959543318,false,0.1.0,2026-08-06 | |
| 53 | +2026-07-13,region,region-02,Saguenay–Lac-Saint-Jean,unifamilial,171.04030911715947,170.29521360806544,311029.0,94,543.1,1.814,-1.804,7.367,14.729,12.637,164.47006492951704,176.32667555791787,B,0.854407340463168,false,0.1.0,2026-08-06 | |
| 54 | +2026-07-13,region,region-03,Capitale-Nationale,all,170.40825391250712,166.79009386498794,438556.0,271,1017.7,1.897,0.344,9.316,9.939,11.137,162.90152824901543,170.77148207576514,A,0.840225224392434,false,0.1.0,2026-08-06 | |
| 55 | +2026-07-13,region,region-03,Capitale-Nationale,condo,155.00304909422263,154.35746577263413,330220.0,59,144.8,-0.356,-1.042,-1.51,8.034,7.497,146.8641600327146,162.23309508897557,C,0.8337164088776177,false,0.1.0,2026-08-06 | |
| 56 | +2026-07-13,region,region-03,Capitale-Nationale,plex,166.61396961690573,162.06955013914884,531740.0,33,229.8,3.72,2.447,8.036,6.972,9.063,153.47221122353525,171.14850221352685,C,0.9490264659785378,false,0.1.0,2026-08-06 | |
| 57 | +2026-07-13,region,region-03,Capitale-Nationale,unifamilial,176.94446758126145,172.36363393879213,465172.0,179,717.6,2.325,0.407,13.639,11.23,12.868,167.22302395031343,177.66227163439743,B,0.8203313532104751,false,0.1.0,2026-08-06 | |
| 58 | +2026-07-13,region,region-04,Mauricie,all,NaN,191.19381289186285,343264.0,99,558.8,2.804,0.193,11.577,10.342,10.339,185.20313529742154,197.37826808074342,B,0.9014094383701756,false,0.1.0,2026-08-06 | |
| 59 | +2026-07-13,region,region-04,Mauricie,condo,NaN,202.22527363159634,311601.0,0,18.3,-0.439,-1.084,-3.569,9.136,3.487,175.8297936314493,232.58322978580486,E,1.0,false,0.1.0,2026-08-06 | |
| 60 | +2026-07-13,region,region-04,Mauricie,plex,228.77520653974912,207.96115041965382,379379.0,19,120.2,4.918,3.863,14.644,7.672,7.382,192.86853134778005,224.23481830678477,C,0.880263279881631,false,0.1.0,2026-08-06 | |
| 61 | +2026-07-13,region,region-04,Mauricie,unifamilial,196.49119518545507,186.46658385166256,334547.0,80,518.7,2.3,-0.783,11.136,11.135,11.363,179.94275188640165,193.226937616578,B,0.9048735865801549,false,0.1.0,2026-08-06 | |
| 62 | +2026-07-13,region,region-05,Estrie,all,166.94917327062214,167.0842803467695,444165.0,192,1091.4,1.906,-1.087,7.089,7.815,9.383,163.32116808680556,170.9340991497185,A,0.9485094975890755,false,0.1.0,2026-08-06 | |
| 63 | +2026-07-13,region,region-05,Estrie,condo,139.30851354926256,147.6687915171472,334177.0,7,117.1,-0.602,-3.238,-3.693,3.069,0.366,139.7200539489596,156.069736389456,D,0.9726277725939869,false,0.1.0,2026-08-06 | |
| 64 | +2026-07-13,region,region-05,Estrie,plex,176.22665678708356,177.13946436501132,499608.0,25,203.5,3.541,1.802,7.741,7.96,6.081,167.17134385603717,187.701965610477,C,0.9559933391189436,false,0.1.0,2026-08-06 | |
| 65 | +2026-07-13,region,region-05,Estrie,unifamilial,167.03049937443868,166.49295441098454,441904.0,160,974.7,1.757,-1.476,7.616,8.064,10.524,162.22322337502868,170.87506518357804,A,0.9457987319223433,false,0.1.0,2026-08-06 | |
| 66 | +2026-07-13,region,region-06,Montréal,all,131.9093422927083,132.1571119240889,659913.0,366,923.9,1.171,-1.254,0.067,2.68,1.011,128.92531500221105,135.46992095242604,A,0.8779086681219483,false,0.1.0,2026-08-06 | |
| 67 | +2026-07-13,region,region-06,Montréal,condo,126.59188723996262,128.62162155669233,492502.0,133,216.4,-0.588,-2.566,-2.926,2.986,-2.135,123.49204174960842,133.9642724947127,B,0.9138723711345551,false,0.1.0,2026-08-06 | |
| 68 | +2026-07-13,region,region-06,Montréal,plex,140.9571799603617,139.148903470808,858976.0,102,266.4,3.703,1.991,3.952,3.939,2.57,132.2807012371294,146.37371253739218,C,0.9069438168465952,false,0.1.0,2026-08-06 | |
| 69 | +2026-07-13,region,region-06,Montréal,unifamilial,133.40265798473533,132.24510613608257,853912.0,131,581.7,1.97,-1.809,1.85,1.105,5.075,127.87147963660688,136.76832509207227,B,0.7930422464344542,false,0.1.0,2026-08-06 | |
| 70 | +2026-07-13,region,region-07,Outaouais,all,154.56469444680764,149.19966645008276,454631.0,151,850.2,2.207,-0.049,4.465,5.408,4.656,145.39816369137287,153.10056127027116,A,0.8881002962446048,false,0.1.0,2026-08-06 | |
| 71 | +2026-07-13,region,region-07,Outaouais,condo,140.19020917184443,141.90582328306579,317189.0,3,52.1,-0.578,-2.473,-5.734,3.077,-1.513,130.6075622080006,154.18144509561293,D,0.8972171430651947,false,0.1.0,2026-08-06 | |
| 72 | +2026-07-13,region,region-07,Outaouais,plex,169.56363271594213,154.68545767848113,575280.0,21,255.1,3.887,1.823,3.739,3.417,2.58,146.887343547459,162.8975665249894,C,0.9673317152603325,false,0.1.0,2026-08-06 | |
| 73 | +2026-07-13,region,region-07,Outaouais,unifamilial,153.53829354682222,148.97154540551648,451382.0,127,715.1,2.181,-0.138,5.541,5.95,5.57,144.52074927891536,153.55941241127792,B,0.8742055020753409,false,0.1.0,2026-08-06 | |
| 74 | +2026-07-13,region,region-08,Abitibi-Témiscamingue,all,NaN,151.72338029702007,304809.0,58,781.5,1.955,-0.702,5.924,6.492,8.087,147.693651954149,155.8630572416251,B,0.9604291106382046,false,0.1.0,2026-08-06 | |
| 75 | +2026-07-13,region,region-08,Abitibi-Témiscamingue,condo,NaN,156.57533929635366,322052.0,0,10.1,-0.439,-3.315,-4.885,2.249,3.47,129.74838225049362,188.94907551477388,E,1.0,false,0.1.0,2026-08-06 | |
| 76 | +2026-07-13,region,region-08,Abitibi-Témiscamingue,plex,169.17998547460314,154.59222644059895,359022.0,8,204.5,3.766,2.754,5.934,6.109,2.832,145.9131740646789,163.78751698779317,D,0.9790462099558088,false,0.1.0,2026-08-06 | |
| 77 | +2026-07-13,region,region-08,Abitibi-Témiscamingue,unifamilial,147.98939655056404,151.14881240931624,295334.0,50,717.3,1.641,-1.312,6.026,6.603,9.144,146.6399945137583,155.7962653265319,B,0.9565829003172551,false,0.1.0,2026-08-06 | |
| 78 | +2026-07-13,region,region-09,Côte-Nord,all,NaN,164.41804754253917,259337.0,15,65.6,2.289,3.347,17.579,13.554,14.134,149.82897285457886,180.42768259473198,C,0.8124648308981984,false,0.1.0,2026-08-06 | |
| 79 | +2026-07-13,region,region-09,Côte-Nord,condo,NaN,139.40493318989758,235335.0,0,22.1,-0.439,-2.121,-2.26,5.07,-0.048,122.73882041832587,158.33405707700774,E,1.0,false,0.1.0,2026-08-06 | |
| 80 | +2026-07-13,region,region-09,Côte-Nord,plex,179.8012071659903,161.94928217880863,314692.0,2,91.8,3.81,4.554,9.726,13.03,14.075,148.57254507822284,176.53039452492834,E,0.9779968540557293,false,0.1.0,2026-08-06 | |
| 81 | +2026-07-13,region,region-09,Côte-Nord,unifamilial,166.9504636082505,164.9292131828089,251954.0,13,57.9,2.075,3.194,18.921,13.677,14.218,148.25042654177875,183.48443235969154,C,0.7865435865535549,false,0.1.0,2026-08-06 | |
| 82 | +2026-07-13,region,region-10,Nord-du-Québec,all,NaN,130.496607721213,186580.0,4,85.8,3.473,3.158,9.171,12.503,9.935,120.31181052386985,141.54358206890683,E,0.9703470130051219,false,0.1.0,2026-08-06 | |
| 83 | +2026-07-13,region,region-10,Nord-du-Québec,condo,NaN,138.16020078426592,,0,0.0,-0.439,-2.121,-2.366,4.956,0.859,3.7415005674504163,5101.7608407730495,E,1.0,false,0.1.0,2026-08-06 | |
| 84 | +2026-07-13,region,region-10,Nord-du-Québec,plex,69.76327181853792,145.76294492134608,222261.0,1,56.3,2.279,0.944,6.423,2.948,1.56,130.56649878818968,162.72808346198246,E,0.9833122525113603,false,0.1.0,2026-08-06 | |
| 85 | +2026-07-13,region,region-10,Nord-du-Québec,unifamilial,227.33513696344986,128.91067236582518,183115.0,3,82.3,3.608,3.409,9.488,13.615,10.906,117.88545975465752,140.96701564717415,E,0.968894915755899,false,0.1.0,2026-08-06 | |
| 86 | +2026-07-13,region,region-11,Gaspésie–Îles-de-la-Madeleine,all,NaN,161.72051659303742,234668.0,17,369.9,2.336,-0.608,8.345,7.297,8.14,155.51453245968997,168.17415757526066,C,0.9717992139053824,false,0.1.0,2026-08-06 | |
| 87 | +2026-07-13,region,region-11,Gaspésie–Îles-de-la-Madeleine,condo,NaN,151.66195758424809,513224.0,0,1.2,-0.439,-2.121,-2.366,4.956,0.859,87.42240013508922,263.1059012649332,E,1.0,false,0.1.0,2026-08-06 | |
| 88 | +2026-07-13,region,region-11,Gaspésie–Îles-de-la-Madeleine,plex,191.41077633511884,168.74849501011647,294918.0,1,219.1,3.587,2.364,6.69,6.028,4.635,159.58833573331634,178.43443530713208,E,0.9984055704677409,false,0.1.0,2026-08-06 | |
| 89 | +2026-07-13,region,region-11,Gaspésie–Îles-de-la-Madeleine,unifamilial,184.0294785267384,160.87878002075317,228192.0,16,349.1,2.184,-0.967,8.553,7.455,8.581,154.0443478138833,168.0164331133821,C,0.9685219488364073,false,0.1.0,2026-08-06 | |
| 90 | +2026-07-13,region,region-12,Chaudière-Appalaches,all,168.97572493120586,165.76588396587448,321415.0,160,668.3,2.041,0.127,10.335,11.203,12.883,161.00997817257644,170.66226949944354,A,0.8731443296552194,false,0.1.0,2026-08-06 | |
| 91 | +2026-07-13,region,region-12,Chaudière-Appalaches,condo,138.40497669377325,122.62766442209522,275958.0,3,42.0,0.51,-1.255,4.505,15.195,13.257,111.80632638642318,134.49636141022546,E,0.9273408650113658,false,0.1.0,2026-08-06 | |
| 92 | +2026-07-13,region,region-12,Chaudière-Appalaches,plex,189.29199305977608,172.92323840408818,365610.0,18,170.8,4.118,5.002,9.806,9.251,6.086,162.3284126922834,184.209565560414,C,0.9444153739554064,false,0.1.0,2026-08-06 | |
| 93 | +2026-07-13,region,region-12,Chaudière-Appalaches,unifamilial,167.8529319290855,166.80173619062438,318493.0,139,613.2,1.841,-0.418,10.632,11.301,13.757,161.4264883999537,172.35597126583247,B,0.8620751175159382,false,0.1.0,2026-08-06 | |
| 94 | +2026-07-13,region,region-13,Laval,all,135.15240475199326,145.9917924465427,581068.0,86,682.3,0.178,-3.248,-1.128,2.849,4.583,141.8458588600189,150.25890521617413,A,0.8823593322152801,false,0.1.0,2026-08-06 | |
| 95 | +2026-07-13,region,region-13,Laval,condo,140.72967151326026,150.1698316596458,420471.0,13,107.7,-1.062,-5.738,-7.534,-0.784,-1.34,141.74951544145856,159.0903381253514,C,0.9118602910450249,false,0.1.0,2026-08-06 | |
| 96 | +2026-07-13,region,region-13,Laval,plex,163.00209725538434,156.94135543746103,844465.0,8,149.9,3.751,2.513,10.778,10.412,9.138,146.6990308482151,167.89878504399925,D,0.9549336328121784,false,0.1.0,2026-08-06 | |
| 97 | +2026-07-13,region,region-13,Laval,unifamilial,129.56349782464358,142.9587408090533,620482.0,65,486.0,0.133,-3.121,-0.29,3.168,6.218,137.79476405718208,148.31624201068377,B,0.8608502527335418,false,0.1.0,2026-08-06 | |
| 98 | +2026-07-13,region,region-14,Lanaudière,all,147.3355704081245,162.7901209743806,468367.0,170,821.5,0.042,-3.426,3.4,5.892,7.467,158.57157594690142,167.12089369489115,A,0.8808422559099539,false,0.1.0,2026-08-06 | |
| 99 | +2026-07-13,region,region-14,Lanaudière,condo,146.6790451946651,148.0776687898357,351393.0,8,108.4,-0.537,-2.178,0.672,2.643,-1.61,139.8018833231422,156.84335198510584,C,0.9059025355331677,false,0.1.0,2026-08-06 | |
| 100 | +2026-07-13,region,region-14,Lanaudière,plex,182.42316569592862,163.4148805937438,612137.0,18,272.2,3.8,2.679,6.978,5.163,4.87,155.4331325448314,171.8065045865637,C,0.9798909600001908,false,0.1.0,2026-08-06 | |
| 101 | +2026-07-13,region,region-14,Lanaudière,unifamilial,143.51400540052097,164.2283422746672,465573.0,144,685.2,-0.362,-4.283,3.23,6.307,8.731,159.21757470236162,169.39680469762354,B,0.8660002176478554,false,0.1.0,2026-08-06 | |
| 102 | +2026-07-13,region,region-15,Laurentides,all,158.062724878413,160.9645449039262,519981.0,240,1236.9,1.811,-0.862,6.582,7.782,8.462,157.5569406884317,164.44584797672726,A,0.9339461206730794,false,0.1.0,2026-08-06 | |
| 103 | +2026-07-13,region,region-15,Laurentides,condo,209.18343558377717,163.38220171402358,368878.0,6,66.8,1.337,-0.627,-2.128,7.795,5.254,151.83999425474224,175.80179693722687,C,0.9332150505565051,false,0.1.0,2026-08-06 | |
| 104 | +2026-07-13,region,region-15,Laurentides,plex,147.7962528910968,166.37632536565374,610623.0,32,285.3,3.225,2.578,7.363,7.175,5.938,158.43349172937002,174.71736146206786,B,0.9729614332792248,false,0.1.0,2026-08-06 | |
| 105 | +2026-07-13,region,region-15,Laurentides,unifamilial,156.80287426309616,159.80996222823012,516969.0,202,1071.5,1.586,-1.503,7.101,7.894,9.173,155.8987912702034,163.81925619374314,A,0.9267874949068061,false,0.1.0,2026-08-06 | |
| 106 | +2026-07-13,region,region-16,Montérégie,all,150.05984251432022,151.54413253270658,547870.0,377,1601.5,1.665,-1.025,6.265,7.468,6.318,148.72096907231403,154.42088797796725,A,0.9337469049565295,false,0.1.0,2026-08-06 | |
| 107 | +2026-07-13,region,region-16,Montérégie,condo,125.50316488866375,134.17107014767828,382029.0,22,185.3,-0.799,-1.973,-3.081,4.839,-0.108,128.39803559004562,140.20367197867665,B,0.948485569320812,false,0.1.0,2026-08-06 | |
| 108 | +2026-07-13,region,region-16,Montérégie,plex,146.19746159103948,162.22330018428994,633599.0,32,263.8,3.18,1.627,7.503,6.544,3.593,154.1777763432653,170.68866698460323,C,0.9653855486379822,false,0.1.0,2026-08-06 | |
| 109 | +2026-07-13,region,region-16,Montérégie,unifamilial,154.63719080134615,152.87942893996885,566845.0,323,1334.7,1.83,-1.24,7.556,7.99,7.686,149.52260888483707,156.31161044690091,A,0.9272504524799517,false,0.1.0,2026-08-06 | |
| 110 | +2026-07-13,region,region-17,Centre-du-Québec,all,174.3049010662432,167.3697551190376,360317.0,91,782.0,2.242,-0.542,7.448,11.125,11.614,162.92570222083097,171.93502649838535,A,0.9402073570716116,false,0.1.0,2026-08-06 | |
| 111 | +2026-07-13,region,region-17,Centre-du-Québec,condo,154.30597394705478,158.5272050958978,301650.0,1,43.4,-0.548,-2.224,-3.599,5.138,0.651,144.74919049658453,173.616686002191,E,0.9608662505253012,false,0.1.0,2026-08-06 | |
| 112 | +2026-07-13,region,region-17,Centre-du-Québec,plex,157.6485288628178,172.43709844656433,432343.0,11,136.8,3.068,1.236,4.019,7.439,1.987,160.6769566028207,185.05797937268204,D,0.9489961300288267,false,0.1.0,2026-08-06 | |
| 113 | +2026-07-13,region,region-17,Centre-du-Québec,unifamilial,177.28937185060437,166.70782342076032,350898.0,79,760.5,2.149,-0.793,8.127,11.78,13.32,161.8760388698226,171.68383031683123,B,0.9386025767016043,false,0.1.0,2026-08-06 | |
added
outputs/tables/index_latest_monthly.csv
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +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 | |
| 2 | +2026-06,municipality,drummondville,Drummondville,all,NaN,177.1234670762736,431834.0,159,349.2,2.177,6.376,7.048,11.859,170.08770271606934,184.45026940889937,B,0.5221224867887166,false,2.1.0,2026-08-08 | |
| 3 | +2026-06,municipality,drummondville,Drummondville,condo,NaN,179.46671810529443,388748.0,0,228.9,-1.235,1.293,1.692,5.436,172.4779520388189,186.73866732970154,E,1.0,false,2.1.0,2026-08-08 | |
| 4 | +2026-06,municipality,drummondville,Drummondville,plex,211.53635132057406,198.82611737378693,536415.0,24,165.2,3.876,8.545,9.947,10.402,186.44236910985592,212.03241054420351,D,0.8302202026417131,false,2.1.0,2026-08-08 | |
| 5 | +2026-06,municipality,drummondville,Drummondville,unifamilial,174.8135147834323,172.31596709831166,411522.0,135,287.9,1.84,5.96,6.471,12.322,164.16092346093552,180.87612990366895,C,0.441051004367348,false,2.1.0,2026-08-08 | |
| 6 | +2026-06,municipality,gatineau,Gatineau,all,143.10997624581967,142.97402503787112,486661.0,413,1275.2,1.361,3.915,3.412,2.902,139.97335287615772,146.03902396776624,A,0.3337282203152186,false,2.1.0,2026-08-08 | |
| 7 | +2026-06,municipality,gatineau,Gatineau,condo,137.97422486374253,143.29612872790238,319003.0,13,1523.2,-2.048,0.135,1.283,1.358,141.10641996367755,145.51981769283927,D,0.9948160665003976,false,2.1.0,2026-08-08 | |
| 8 | +2026-06,municipality,gatineau,Gatineau,plex,150.91659032219277,149.66836088398205,612714.0,57,250.4,0.899,-0.783,-0.897,5.979,142.05104882106983,157.69414189904452,C,0.4814410297245866,false,2.1.0,2026-08-08 | |
| 9 | +2026-06,municipality,gatineau,Gatineau,unifamilial,142.26402253774256,141.54701113197092,492712.0,343,964.2,1.967,5.509,4.667,2.487,137.846095779536,145.34728928730883,A,0.20593092195598373,false,2.1.0,2026-08-08 | |
| 10 | +2026-06,municipality,laval,Laval,all,141.22298504978542,140.39273123148232,573899.0,603,1454.1,0.166,2.748,4.453,3.987,137.63153060454246,143.209327804894,A,0.3784154721767792,false,2.1.0,2026-08-08 | |
| 11 | +2026-06,municipality,laval,Laval,condo,139.240009994328,139.3897334694248,419425.0,123,388.9,-0.171,-0.008,0.997,1.927,135.20563942042938,143.70330912204182,B,0.38554258005590714,false,2.1.0,2026-08-08 | |
| 12 | +2026-06,municipality,laval,Laval,plex,161.449540944382,150.52793524923115,828977.0,49,1253.9,3.619,8.117,9.689,8.9,147.0550013411198,154.0828879246072,D,0.9795937573690241,false,2.1.0,2026-08-08 | |
| 13 | +2026-06,municipality,laval,Laval,unifamilial,139.23833790936027,139.3517295315039,611137.0,431,881.1,-0.2,3.028,5.022,4.065,135.542535645367,143.2679743739557,A,0.2885598860705171,false,2.1.0,2026-08-08 | |
| 14 | +2026-06,municipality,levis,Lévis,all,169.03474484070975,164.92881626390962,431574.0,374,668.6,3.188,10.534,10.264,10.314,160.16754603910468,169.8316238645078,B,0.3186769547180097,false,2.1.0,2026-08-08 | |
| 15 | +2026-06,municipality,levis,Lévis,condo,159.5986670492223,141.89288575590584,301006.0,13,590.9,3.348,9.103,13.847,18.313,138.42776837962717,145.4447417870941,E,0.9760558627266851,false,2.1.0,2026-08-08 | |
| 16 | +2026-06,municipality,levis,Lévis,plex,168.33003164180468,154.68414437409376,489303.0,31,338.7,4.423,5.37,8.479,6.501,147.89098162430955,161.7893414321111,D,0.8968887915980146,false,2.1.0,2026-08-08 | |
| 17 | +2026-06,municipality,levis,Lévis,unifamilial,170.1204666837815,169.18425183941594,439141.0,330,516.9,2.971,11.548,10.208,10.18,163.1719657501608,175.41806853200032,B,0.1585849493030319,false,2.1.0,2026-08-08 | |
| 18 | +2026-06,municipality,longueuil,Longueuil,all,140.77703236433928,138.8702440339345,540318.0,398,972.7,2.278,5.152,4.946,4.883,135.5382660595621,142.28413302535375,A,0.38740743777458697,false,2.1.0,2026-08-08 | |
| 19 | +2026-06,municipality,longueuil,Longueuil,condo,136.38978273802638,136.63679466974713,373639.0,90,340.6,-0.293,1.14,1.89,1.315,132.25881569028647,141.15969177693006,C,0.38146439153664125,false,2.1.0,2026-08-08 | |
| 20 | +2026-06,municipality,longueuil,Longueuil,plex,145.15858504385767,141.53651003634212,778666.0,67,221.9,1.731,3.872,3.493,6.833,133.8974385127458,149.61140329328,C,0.5955486057170623,false,2.1.0,2026-08-08 | |
| 21 | +2026-06,municipality,longueuil,Longueuil,unifamilial,141.7867617525296,139.23444129709432,584896.0,241,482.4,3.839,7.751,7.067,6.207,134.1156043959825,144.5486506258829,B,0.32532780780532755,false,2.1.0,2026-08-08 | |
| 22 | +2026-06,municipality,montreal,Montréal,all,123.9443876072599,123.79468232979207,619320.0,2105,3961.7,0.345,1.848,2.44,2.773,122.31385177350596,125.29344102017448,A,0.2806181551060094,false,2.1.0,2026-08-08 | |
| 23 | +2026-06,municipality,montreal,Montréal,condo,121.61339877490897,121.5584417251484,484520.0,1036,1523.4,0.15,1.94,1.733,0.676,119.7009890105731,123.44471734766627,A,0.23188189307573626,false,2.1.0,2026-08-08 | |
| 24 | +2026-06,municipality,montreal,Montréal,plex,125.99536009727619,126.10647357581537,823545.0,564,932.1,-0.15,1.088,1.804,5.34,122.73816667965963,129.56721701109836,A,0.3693577821590096,false,2.1.0,2026-08-08 | |
| 25 | +2026-06,municipality,montreal,Montréal,unifamilial,126.88519657881866,126.18113566996117,763638.0,505,1122.1,1.422,2.588,4.905,4.527,123.12000334291378,129.31837692219756,A,0.2827012764982748,false,2.1.0,2026-08-08 | |
| 26 | +2026-06,municipality,quebec-city,Québec,all,165.21325218943454,164.21772828306248,450400.0,1278,2383.2,2.598,8.104,7.372,11.36,161.6894067792242,166.7855849039756,A,0.22104482049478913,false,2.1.0,2026-08-08 | |
| 27 | +2026-06,municipality,quebec-city,Québec,condo,164.19661058631374,163.51723041796683,344756.0,377,637.6,1.239,4.876,5.475,10.166,159.67116452117494,167.4559381072002,A,0.25182859655202283,false,2.1.0,2026-08-08 | |
| 28 | +2026-06,municipality,quebec-city,Québec,plex,162.43747312683877,160.99236112074482,575778.0,167,388.7,1.191,6.043,6.019,13.027,154.38267714449358,167.88502971077514,B,0.43015796982643295,false,2.1.0,2026-08-08 | |
| 29 | +2026-06,municipality,quebec-city,Québec,unifamilial,166.56659516813278,165.52124810601376,487647.0,734,1356.3,3.762,10.536,8.828,11.568,161.86467267608873,169.26042676030937,A,0.14565189507075305,false,2.1.0,2026-08-08 | |
| 30 | +2026-06,municipality,saguenay,Saguenay,all,174.14732550787946,173.03031316676888,362298.0,292,799.9,1.109,5.117,11.766,9.938,168.45781977292665,177.7269189103074,A,0.4393655433794468,false,2.1.0,2026-08-08 | |
| 31 | +2026-06,municipality,saguenay,Saguenay,condo,165.15468644189866,162.75594133810523,275673.0,8,494.3,0.575,1.861,3.194,6.407,158.41538907294202,167.2154239299044,E,0.9749255934495725,false,2.1.0,2026-08-08 | |
| 32 | +2026-06,municipality,saguenay,Saguenay,plex,182.00637361962515,177.13203391423582,384920.0,58,1266.9,4.996,2.736,11.204,13.178,173.0659996956915,181.29359604869342,D,0.9771755137740864,false,2.1.0,2026-08-08 | |
| 33 | +2026-06,municipality,saguenay,Saguenay,unifamilial,172.23601582000578,172.22830894658733,359254.0,226,553.7,0.013,5.949,12.259,9.134,166.3107502408003,178.35642229773393,B,0.2616103487887491,false,2.1.0,2026-08-08 | |
| 34 | +2026-06,municipality,sherbrooke,Sherbrooke,all,174.13317035157246,175.23875372030147,453907.0,328,514.1,0.28,7.845,7.293,8.889,169.48157650276366,181.1914984455182,B,0.4153860933208363,false,2.1.0,2026-08-08 | |
| 35 | +2026-06,municipality,sherbrooke,Sherbrooke,condo,165.60844486302554,156.3310355432095,325922.0,9,140.7,-0.165,1.621,2.358,4.711,148.60689093175787,164.4566582395907,E,0.9551397524107392,false,2.1.0,2026-08-08 | |
| 36 | +2026-06,municipality,sherbrooke,Sherbrooke,plex,147.46730939453232,155.6375080038037,546216.0,58,290.2,-1.109,8.737,5.194,12.324,148.26628015951982,163.37520487849613,D,0.8283671687895422,false,2.1.0,2026-08-08 | |
| 37 | +2026-06,municipality,sherbrooke,Sherbrooke,unifamilial,182.29961181663307,181.8303417866023,439133.0,261,384.8,0.668,7.959,8.118,8.242,174.36257986866804,189.61793992229013,B,0.27917460683784334,false,2.1.0,2026-08-08 | |
| 38 | +2026-06,municipality,trois-rivieres,Trois-Rivières,all,194.13545774935932,194.27290890645278,399445.0,225,501.9,0.736,4.671,7.535,8.888,187.81429322500273,200.95362545043253,B,0.5423899320466273,false,2.1.0,2026-08-08 | |
| 39 | +2026-06,municipality,trois-rivieres,Trois-Rivières,condo,166.4980831552608,192.4580436355919,309677.0,4,324.7,-1.097,5.508,4.458,6.324,186.1450554652588,198.9851326830026,E,0.9893659010046914,false,2.1.0,2026-08-08 | |
| 40 | +2026-06,municipality,trois-rivieres,Trois-Rivières,plex,202.9948465986688,203.07008647249347,441139.0,47,1279.5,-0.145,5.862,5.706,8.234,198.43150024014926,207.81710550007858,D,0.9812071615637241,false,2.1.0,2026-08-08 | |
| 41 | +2026-06,municipality,trois-rivieres,Trois-Rivières,unifamilial,192.6744288652864,191.41139798746366,391023.0,174,312.6,1.138,4.219,8.336,9.249,182.7091763728544,200.52809610803246,B,0.36764237929242183,false,2.1.0,2026-08-08 | |
| 42 | +2026-06,province,quebec,Québec (province),all,153.99846928543036,153.99846928543036,463468.0,15422,14088.7,1.501,6.357,8.168,6.826,153.01886399926317,154.98434586712025,A,0.0,false,2.1.0,2026-08-08 | |
| 43 | +2026-06,province,quebec,Québec (province),condo,131.96657490076268,131.96657490076268,414520.0,1985,1985.0,0.221,1.688,2.088,2.451,130.1983635707343,133.75880013712518,A,0.0,false,2.1.0,2026-08-08 | |
| 44 | +2026-06,province,quebec,Québec (province),plex,154.9674503198742,154.9674503198742,578937.0,1967,1967.0,2.239,5.947,6.711,7.662,152.10612547891157,157.88260060553694,A,0.0,false,2.1.0,2026-08-08 | |
| 45 | +2026-06,province,quebec,Québec (province),unifamilial,159.0129944707306,159.0129944707306,452894.0,11470,11470.0,1.625,7.481,9.84,7.619,157.79619880628897,160.239173071518,A,0.0,false,2.1.0,2026-08-08 | |
| 46 | +2026-06,region,region-01,Bas-Saint-Laurent,all,166.29725201802583,165.5348348549363,269603.0,365,427.1,1.608,13.775,8.967,8.808,159.57791155580685,171.71412561611453,B,0.2938194857516768,false,2.1.0,2026-08-08 | |
| 47 | +2026-06,region,region-01,Bas-Saint-Laurent,condo,138.88408365469428,166.67787899989173,284307.0,1,33.9,-0.734,1.003,0.862,3.754,150.3280814595785,184.8058930717657,E,0.9501749964532001,false,2.1.0,2026-08-08 | |
| 48 | +2026-06,region,region-01,Bas-Saint-Laurent,plex,156.17582155992693,151.05602628085634,294228.0,29,121.7,3.324,4.626,4.028,7.299,140.15327444695046,162.80692096423044,D,0.7595162806901959,false,2.1.0,2026-08-08 | |
| 49 | +2026-06,region,region-01,Bas-Saint-Laurent,unifamilial,168.21413405540136,167.5431476915595,265938.0,335,404.1,1.423,15.288,9.788,9.1,160.82508563996774,174.54183982986598,B,0.22054161742541134,false,2.1.0,2026-08-08 | |
| 50 | +2026-06,region,region-02,Saguenay–Lac-Saint-Jean,all,174.8530941057696,173.38182378051442,315128.0,557,791.8,1.301,7.081,14.847,9.718,168.77692951811082,178.11235755554847,A,0.25605020392560923,false,2.1.0,2026-08-08 | |
| 51 | +2026-06,region,region-02,Saguenay–Lac-Saint-Jean,condo,164.7546820673152,162.87656247384834,268707.0,8,64.2,0.537,1.821,3.076,6.609,151.10895440732048,175.56057288165732,E,0.7844309953745453,false,2.1.0,2026-08-08 | |
| 52 | +2026-06,region,region-02,Saguenay–Lac-Saint-Jean,plex,181.66668995021925,174.4526933673431,344188.0,92,221.8,4.93,2.779,11.001,13.02,165.03406054705673,184.40885549466654,C,0.4571346747081645,false,2.1.0,2026-08-08 | |
| 53 | +2026-06,region,region-02,Saguenay–Lac-Saint-Jean,unifamilial,173.6014251548056,173.36782328961064,309928.0,457,702.7,0.536,8.161,15.964,9.069,168.07005489021958,178.83258366167598,B,0.20114823811757176,false,2.1.0,2026-08-08 | |
| 54 | +2026-06,region,region-03,Capitale-Nationale,all,167.36988521057265,166.7612007506425,438647.0,1838,2558.2,2.403,8.081,8.458,11.377,164.28243102866222,169.2773713029863,A,0.1784017344183837,false,2.1.0,2026-08-08 | |
| 55 | +2026-06,region,region-03,Capitale-Nationale,condo,165.2935420813588,164.55806964899028,345574.0,395,665.7,1.373,4.749,5.219,10.563,160.76908830513926,168.43634912704982,A,0.24644691034349575,false,2.1.0,2026-08-08 | |
| 56 | +2026-06,region,region-03,Capitale-Nationale,plex,161.35106491700725,161.06806872653095,528484.0,206,411.2,0.245,6.128,5.757,11.956,154.63513159917284,167.7686208496315,B,0.41793049844368046,false,2.1.0,2026-08-08 | |
| 57 | +2026-06,region,region-03,Capitale-Nationale,unifamilial,169.35884051233822,168.72605770635775,458570.0,1237,1668.7,3.212,9.675,10.175,11.545,165.362005845192,172.15854635786584,A,0.10588091161192759,false,2.1.0,2026-08-08 | |
| 58 | +2026-06,region,region-04,Mauricie,all,193.0910984810241,192.72901418377342,338288.0,475,556.5,1.02,7.421,9.122,8.75,186.638946987841,199.01780152386414,B,0.2804230202418914,false,2.1.0,2026-08-08 | |
| 59 | +2026-06,region,region-04,Mauricie,condo,178.58793563209926,187.78745636150737,310711.0,5,23.7,-0.943,5.384,4.435,6.482,165.9952415322331,212.44060035225414,E,0.811329263600422,false,2.1.0,2026-08-08 | |
| 60 | +2026-06,region,region-04,Mauricie,plex,201.30772266034433,201.52812938926772,366424.0,78,179.6,-0.144,5.715,5.721,8.35,189.4737275794946,214.34943753929056,C,0.4319156136851525,false,2.1.0,2026-08-08 | |
| 61 | +2026-06,region,region-04,Mauricie,unifamilial,191.21726136402677,190.44978452343676,331512.0,392,467.1,1.399,7.957,10.218,8.92,183.33661631846084,197.838932305915,B,0.22449582550528524,false,2.1.0,2026-08-08 | |
| 62 | +2026-06,region,region-05,Estrie,all,175.09652365128022,174.21380046821156,447792.0,1016,899.6,2.502,8.821,8.384,9.996,169.86936180591562,178.66934890975077,A,0.28356835768759625,false,2.1.0,2026-08-08 | |
| 63 | +2026-06,region,region-05,Estrie,condo,145.99717023807997,151.2919219633559,340607.0,28,144.1,-0.435,0.779,1.022,3.17,143.9035238277728,159.05966054563453,D,0.8444075175954765,false,2.1.0,2026-08-08 | |
| 64 | +2026-06,region,region-05,Estrie,plex,165.1944577906698,165.19020465472082,485274.0,129,227.1,0.002,7.456,5.026,9.758,156.37452864025144,174.5028678976594,C,0.5085101022052475,false,2.1.0,2026-08-08 | |
| 65 | +2026-06,region,region-05,Estrie,unifamilial,178.76745632240716,177.2974947846294,447807.0,859,778.3,3.128,9.542,9.434,10.438,172.14542316194454,182.60376070140424,B,0.21138259240902435,false,2.1.0,2026-08-08 | |
| 66 | +2026-06,region,region-06,Montréal,all,122.94978028719972,122.80001851303042,644536.0,2413,4510.2,0.36,1.81,2.674,2.585,121.42277636693623,124.19288207699799,A,0.26339487127139477,false,2.1.0,2026-08-08 | |
| 67 | +2026-06,region,region-06,Montréal,condo,121.73829971028745,121.76240633783462,487758.0,1090,1608.3,-0.069,1.718,1.854,0.675,119.95124021766802,123.60091959262765,A,0.22411628968424213,false,2.1.0,2026-08-08 | |
| 68 | +2026-06,region,region-06,Montréal,plex,126.24652659391373,126.1753396118162,826103.0,574,935.9,0.099,0.95,1.729,5.3,122.8119493903187,129.6308413406892,A,0.36401597592098744,false,2.1.0,2026-08-08 | |
| 69 | +2026-06,region,region-06,Montréal,unifamilial,122.17258268856442,121.66777920192679,826049.0,749,1622.5,1.305,2.713,4.885,3.485,119.20806481248198,124.17824682595486,A,0.2419812542292591,false,2.1.0,2026-08-08 | |
| 70 | +2026-06,region,region-07,Outaouais,all,147.72815112188044,146.8117163736308,445833.0,649,973.3,2.653,5.546,5.063,3.415,143.29022488161155,150.41975181753935,A,0.2872022794410515,false,2.1.0,2026-08-08 | |
| 71 | +2026-06,region,region-07,Outaouais,condo,134.50772876742175,141.22920707155524,320335.0,13,92.4,-2.028,0.135,1.282,1.423,132.67130353914808,150.3391343718481,D,0.6824025662138823,false,2.1.0,2026-08-08 | |
| 72 | +2026-06,region,region-07,Outaouais,plex,153.56436400789835,150.3920974385131,555233.0,76,226.6,2.144,0.067,-1.899,4.572,142.3567713537952,158.88097739828538,C,0.4959968882962965,false,2.1.0,2026-08-08 | |
| 73 | +2026-06,region,region-07,Outaouais,unifamilial,148.00793398068052,146.73086439525528,442687.0,560,831.9,3.162,6.976,6.605,3.403,142.60487526384682,150.97623083603696,A,0.2177723566005435,false,2.1.0,2026-08-08 | |
| 74 | +2026-06,region,region-08,Abitibi-Témiscamingue,all,154.16518462397738,153.14440969814348,305881.0,245,389.6,4.055,7.631,6.754,7.286,147.37896736881194,159.13539523656576,B,0.4267847943093006,false,2.1.0,2026-08-08 | |
| 75 | +2026-06,region,region-08,Abitibi-Témiscamingue,condo,159.86841422630275,158.17978425076728,327192.0,2,10.7,0.54,1.711,2.112,7.483,131.61372265459084,190.1081714046216,E,0.7693405669483075,false,2.1.0,2026-08-08 | |
| 76 | +2026-06,region,region-08,Abitibi-Témiscamingue,plex,144.0126895777574,154.11033202567393,365749.0,30,962.2,2.105,5.739,6.485,6.79,150.05813427432471,158.27195607833926,D,0.9810653684064549,false,2.1.0,2026-08-08 | |
| 77 | +2026-06,region,region-08,Abitibi-Témiscamingue,unifamilial,156.09014731636876,152.91954487038072,295489.0,213,325.5,4.457,8.045,6.848,7.377,146.10353676779278,160.05353272542584,B,0.3200267269175804,false,2.1.0,2026-08-08 | |
| 78 | +2026-06,region,region-09,Côte-Nord,all,NaN,165.8032340337736,259809.0,107,140.1,3.542,20.482,19.56,11.363,155.52438842937244,176.76142432505065,C,0.28816943022438524,false,2.1.0,2026-08-08 | |
| 79 | +2026-06,region,region-09,Côte-Nord,condo,NaN,137.39227174509134,236484.0,0,23.9,0.221,1.813,2.213,1.188,121.49780952802503,155.36606304760488,E,1.0,false,2.1.0,2026-08-08 | |
| 80 | +2026-06,region,region-09,Côte-Nord,plex,182.70488567798319,159.70393014269294,301527.0,10,546.4,2.481,6.485,7.704,8.647,154.155621182821,165.45193167347466,E,0.9827265772724655,false,2.1.0,2026-08-08 | |
| 81 | +2026-06,region,region-09,Côte-Nord,unifamilial,168.24091550036437,166.8982713967863,254074.0,97,124.4,3.719,22.849,21.552,11.831,155.02943152340026,179.6757733129588,C,0.17993616019281677,false,2.1.0,2026-08-08 | |
| 82 | +2026-06,region,region-10,Nord-du-Québec,all,NaN,119.15687800117361,178882.0,33,79.5,-0.637,5.38,12.201,10.621,109.45510338410091,129.71858904706838,D,0.7045524889691197,false,2.1.0,2026-08-08 | |
| 83 | +2026-06,region,region-10,Nord-du-Québec,condo,NaN,131.96657490076268,,0,0.1,0.221,1.688,2.088,2.451,15.982975522185338,1089.6079310679843,E,1.0,false,2.1.0,2026-08-08 | |
| 84 | +2026-06,region,region-10,Nord-du-Québec,plex,169.30377738290156,135.80648188146574,218390.0,2,48.4,3.228,6.786,3.637,5.069,120.59873569153878,152.93195583902687,E,0.9581579065950871,false,2.1.0,2026-08-08 | |
| 85 | +2026-06,region,region-10,Nord-du-Québec,unifamilial,111.06349416642377,117.4434411366483,175054.0,31,77.6,-1.055,5.229,13.195,11.258,106.96875601720357,128.94383724345718,D,0.6763862148759934,false,2.1.0,2026-08-08 | |
| 86 | +2026-06,region,region-11,Gaspésie–Îles-de-la-Madeleine,all,NaN,162.9224396471656,235749.0,97,130.5,1.608,15.895,10.79,12.135,152.46987001484058,174.09158503250964,C,0.42071582558882314,false,2.1.0,2026-08-08 | |
| 87 | +2026-06,region,region-11,Gaspésie–Îles-de-la-Madeleine,condo,NaN,159.21445925384683,509915.0,0,1.2,0.221,1.688,2.088,2.451,91.71596949323127,276.3885523487341,E,1.0,false,2.1.0,2026-08-08 | |
| 88 | +2026-06,region,region-11,Gaspésie–Îles-de-la-Madeleine,plex,206.03776204012522,163.50814909508387,293808.0,6,424.6,2.468,6.107,6.916,7.376,157.0794108246134,170.19999425864265,E,0.9903901848802228,false,2.1.0,2026-08-08 | |
| 89 | +2026-06,region,region-11,Gaspésie–Îles-de-la-Madeleine,unifamilial,164.16935121598925,162.85146173174678,229283.0,91,122.6,1.503,17.159,11.277,12.735,151.19139993901734,175.4107614511381,C,0.3507148914592255,false,2.1.0,2026-08-08 | |
| 90 | +2026-06,region,region-12,Chaudière-Appalaches,all,175.11057568625185,173.54992827463226,326564.0,1021,1016.3,3.814,12.291,11.843,11.766,169.47509985898628,177.722731122102,A,0.16887585583324513,false,2.1.0,2026-08-08 | |
| 91 | +2026-06,region,region-12,Chaudière-Appalaches,condo,149.1433620571368,141.93174424972892,285501.0,20,67.8,3.051,8.676,12.936,17.007,131.9441082536234,152.6754039448916,E,0.6402472966659276,false,2.1.0,2026-08-08 | |
| 92 | +2026-06,region,region-12,Chaudière-Appalaches,plex,167.6356045773428,162.9124388715842,354660.0,87,174.2,3.413,5.547,10.574,8.573,153.02197033809668,173.44217095393188,C,0.45992421866877387,false,2.1.0,2026-08-08 | |
| 93 | +2026-06,region,region-12,Chaudière-Appalaches,unifamilial,177.16280551111524,176.29304707992765,325021.0,914,973.5,3.894,13.313,11.963,11.979,171.7055361279647,181.00312400855597,A,0.11411727403207794,false,2.1.0,2026-08-08 | |
| 94 | +2026-06,region,region-13,Laval,all,141.11960527219333,140.30733767540562,573943.0,603,1413.3,0.152,2.73,4.43,3.991,137.50864031662414,143.16299659593346,A,0.36621044764217014,false,2.1.0,2026-08-08 | |
| 95 | +2026-06,region,region-13,Laval,condo,139.240009994328,139.3897334694248,419425.0,123,388.9,-0.171,-0.008,0.997,1.927,135.20563942042938,143.70330912204182,B,0.38554258005590714,false,2.1.0,2026-08-08 | |
| 96 | +2026-06,region,region-13,Laval,plex,160.2176150576379,149.5730102594456,829320.0,49,381.3,3.468,7.925,9.442,8.945,143.3736920503095,156.0403800595572,D,0.8519324099464225,false,2.1.0,2026-08-08 | |
| 97 | +2026-06,region,region-13,Laval,unifamilial,139.23833790936027,139.3517295315039,611137.0,431,881.1,-0.2,3.028,5.022,4.065,135.542535645367,143.2679743739557,A,0.2885598860705171,false,2.1.0,2026-08-08 | |
| 98 | +2026-06,region,region-14,Lanaudière,all,163.0469043580067,162.14855397481963,468711.0,1244,1475.3,1.194,6.007,7.046,7.727,158.98221620278093,165.37795348499546,A,0.195105588851564,false,2.1.0,2026-08-08 | |
| 99 | +2026-06,region,region-14,Lanaudière,condo,150.95525192322984,147.73760331684406,358168.0,72,300.4,3.737,3.583,0.48,2.263,142.70287808431306,152.94995957201007,C,0.3700017784027806,false,2.1.0,2026-08-08 | |
| 100 | +2026-06,region,region-14,Lanaudière,plex,168.74191873614478,163.99150159812058,610412.0,110,240.5,3.882,11.043,6.254,9.776,155.47997083542103,172.96898405565977,C,0.42852050679431863,false,2.1.0,2026-08-08 | |
| 101 | +2026-06,region,region-14,Lanaudière,unifamilial,163.57749348879798,163.40146891653754,465299.0,1062,1287.5,0.618,5.633,7.811,8.022,159.697634999925,167.19120508011738,A,0.14879595216900765,false,2.1.0,2026-08-08 | |
| 102 | +2026-06,region,region-15,Laurentides,all,160.2734880726749,158.89927836424883,512418.0,1495,1581.2,2.477,7.303,8.465,7.461,155.9011395835349,161.95507442811302,A,0.20430842566155585,false,2.1.0,2026-08-08 | |
| 103 | +2026-06,region,region-15,Laurentides,condo,159.3055746498613,155.59176445915455,367007.0,46,120.6,2.135,1.267,5.65,5.196,147.3068165073054,164.34268108911607,C,0.5275605277818991,false,2.1.0,2026-08-08 | |
| 104 | +2026-06,region,region-15,Laurentides,plex,168.41635295243952,163.40636634699203,603414.0,203,416.2,4.206,5.479,8.308,9.973,156.91809821668693,170.16291215724075,B,0.422951243117049,false,2.1.0,2026-08-08 | |
| 105 | +2026-06,region,region-15,Laurentides,unifamilial,158.88228325868988,158.3224609754295,508959.0,1246,1348.3,2.186,8.099,8.702,7.169,154.8147383142501,161.9096600378981,A,0.14034236421273694,false,2.1.0,2026-08-08 | |
| 106 | +2026-06,region,region-16,Montérégie,all,150.20364127098358,149.77812167510928,537384.0,2788,3300.4,1.876,5.892,7.913,5.79,147.81630880743387,151.76597165437832,A,0.14968005397369114,false,2.1.0,2026-08-08 | |
| 107 | +2026-06,region,region-16,Montérégie,condo,141.30288873451647,141.40669345099465,392432.0,178,523.3,-0.15,1.337,3.549,3.102,137.73986512012078,145.1711378931982,A,0.3292148672730806,false,2.1.0,2026-08-08 | |
| 108 | +2026-06,region,region-16,Montérégie,plex,159.13197261854805,157.17491228549167,625391.0,241,450.1,1.757,5.522,8.267,8.826,151.16915209124875,163.41927377511647,B,0.41538201215614334,false,2.1.0,2026-08-08 | |
| 109 | +2026-06,region,region-16,Montérégie,unifamilial,150.38217805687225,150.07168196417035,551264.0,2369,2779.9,2.197,6.636,8.527,5.786,147.74822277462846,152.431679411189,A,0.08686257024944011,false,2.1.0,2026-08-08 | |
| 110 | +2026-06,region,region-17,Centre-du-Québec,all,178.12409070961772,177.14629242474297,359345.0,484,666.1,1.565,4.898,8.357,10.96,172.02299571770482,182.422174366324,A,0.341336446481653,false,2.1.0,2026-08-08 | |
| 111 | +2026-06,region,region-17,Centre-du-Québec,condo,153.61637255762946,163.02048384676135,309913.0,4,45.9,-1.235,1.228,1.627,4.698,149.18137391509157,178.1434066209771,E,0.8024636885807596,false,2.1.0,2026-08-08 | |
| 112 | +2026-06,region,region-17,Centre-du-Québec,plex,176.460514329278,174.25522135540956,435377.0,47,205.1,2.568,6.123,7.291,8.336,164.482917772794,184.60811968065195,D,0.7963274327015474,false,2.1.0,2026-08-08 | |
| 113 | +2026-06,region,region-17,Centre-du-Québec,unifamilial,178.69724267164858,177.77289418957946,349320.0,433,620.3,1.444,4.755,8.606,11.448,171.9966818385372,183.74309068477896,B,0.2655858732186408,false,2.1.0,2026-08-08 | |
added
outputs/tables/monthly_summary.csv
+113 −0
@@ -0,0 +1,113 @@ | ||
| 1 | +geography,property_type,median_monthly_tx,sd_monthly_chg_pct,last_index | |
| 2 | +drummondville,all,92.0,2.99,177.75 | |
| 3 | +drummondville,condo,1.0,4.21,179.51 | |
| 4 | +drummondville,plex,16.0,4.14,190.6 | |
| 5 | +drummondville,unifamilial,72.0,3.03,174.81 | |
| 6 | +gatineau,all,331.0,2.43,146.84 | |
| 7 | +gatineau,condo,30.0,3.02,140.71 | |
| 8 | +gatineau,plex,52.0,2.27,153.31 | |
| 9 | +gatineau,unifamilial,240.0,2.71,146.41 | |
| 10 | +laval,all,371.0,2.48,141.26 | |
| 11 | +laval,condo,91.0,2.0,139.89 | |
| 12 | +laval,plex,34.0,2.79,146.97 | |
| 13 | +laval,unifamilial,234.0,3.2,140.97 | |
| 14 | +levis,all,144.0,4.24,164.76 | |
| 15 | +levis,condo,9.0,8.28,138.91 | |
| 16 | +levis,plex,22.0,3.96,150.36 | |
| 17 | +levis,unifamilial,111.0,4.44,170.11 | |
| 18 | +longueuil,all,222.0,2.55,140.45 | |
| 19 | +longueuil,condo,60.0,2.2,134.2 | |
| 20 | +longueuil,plex,40.0,2.49,151.82 | |
| 21 | +longueuil,unifamilial,118.0,3.31,140.41 | |
| 22 | +montreal,all,1268.0,1.52,121.86 | |
| 23 | +montreal,condo,620.0,1.94,116.36 | |
| 24 | +montreal,plex,346.0,2.32,127.87 | |
| 25 | +montreal,unifamilial,280.0,2.31,127.72 | |
| 26 | +quebec,all,9003.0,2.0,151.96 | |
| 27 | +quebec,condo,1244.0,1.52,130.39 | |
| 28 | +quebec,plex,1298.0,2.05,151.6 | |
| 29 | +quebec,unifamilial,6234.0,2.33,157.15 | |
| 30 | +quebec-city,all,561.0,2.46,156.59 | |
| 31 | +quebec-city,condo,173.0,2.73,143.73 | |
| 32 | +quebec-city,plex,92.0,2.23,153.34 | |
| 33 | +quebec-city,unifamilial,303.0,3.97,165.21 | |
| 34 | +region-01,all,220.0,2.39,159.27 | |
| 35 | +region-01,condo,3.0,3.77,165.48 | |
| 36 | +region-01,plex,24.0,2.82,147.32 | |
| 37 | +region-01,unifamilial,188.0,2.53,160.83 | |
| 38 | +region-02,all,322.0,2.74,168.88 | |
| 39 | +region-02,condo,5.0,3.68,163.1 | |
| 40 | +region-02,plex,57.0,2.59,170.77 | |
| 41 | +region-02,unifamilial,255.0,3.1,168.59 | |
| 42 | +region-03,all,833.0,2.63,160.07 | |
| 43 | +region-03,condo,186.0,2.38,146.61 | |
| 44 | +region-03,plex,115.0,2.23,155.88 | |
| 45 | +region-03,unifamilial,543.0,3.58,165.95 | |
| 46 | +region-04,all,326.0,2.88,190.05 | |
| 47 | +region-04,condo,5.0,3.18,185.54 | |
| 48 | +region-04,plex,68.0,2.6,199.46 | |
| 49 | +region-04,unifamilial,254.0,3.29,187.6 | |
| 50 | +region-05,all,614.0,2.32,167.25 | |
| 51 | +region-05,condo,27.0,2.33,149.2 | |
| 52 | +region-05,plex,83.0,2.36,160.49 | |
| 53 | +region-05,unifamilial,488.0,2.54,169.59 | |
| 54 | +region-06,all,1437.0,1.55,121.74 | |
| 55 | +region-06,condo,659.0,1.76,117.31 | |
| 56 | +region-06,plex,351.0,2.14,127.62 | |
| 57 | +region-06,unifamilial,401.0,2.19,124.32 | |
| 58 | +region-07,all,511.0,2.34,148.58 | |
| 59 | +region-07,condo,30.0,2.52,138.69 | |
| 60 | +region-07,plex,70.0,2.16,153.24 | |
| 61 | +region-07,unifamilial,406.0,2.55,148.73 | |
| 62 | +region-08,all,156.0,2.6,147.97 | |
| 63 | +region-08,condo,1.0,3.59,152.97 | |
| 64 | +region-08,plex,23.0,3.48,150.8 | |
| 65 | +region-08,unifamilial,125.0,2.73,147.41 | |
| 66 | +region-09,all,81.0,7.02,161.32 | |
| 67 | +region-09,condo,0.0,7.42,135.75 | |
| 68 | +region-09,plex,11.0,5.24,156.68 | |
| 69 | +region-09,unifamilial,67.0,8.0,162.17 | |
| 70 | +region-10,all,18.0,3.99,122.52 | |
| 71 | +region-10,condo,0.0,1.52,130.39 | |
| 72 | +region-10,plex,2.0,3.88,131.11 | |
| 73 | +region-10,unifamilial,16.0,4.22,121.6 | |
| 74 | +region-11,all,90.0,3.34,156.36 | |
| 75 | +region-11,condo,0.0,2.74,157.31 | |
| 76 | +region-11,plex,8.0,4.68,159.85 | |
| 77 | +region-11,unifamilial,80.0,3.41,155.94 | |
| 78 | +region-12,all,478.0,3.35,167.97 | |
| 79 | +region-12,condo,11.0,7.72,139.01 | |
| 80 | +region-12,plex,55.0,2.71,158.25 | |
| 81 | +region-12,unifamilial,407.0,3.67,170.47 | |
| 82 | +region-13,all,371.0,2.13,141.44 | |
| 83 | +region-13,condo,91.0,1.88,138.72 | |
| 84 | +region-13,plex,34.0,2.42,146.07 | |
| 85 | +region-13,unifamilial,234.0,2.6,141.81 | |
| 86 | +region-14,all,680.0,2.77,159.0 | |
| 87 | +region-14,condo,47.0,2.41,139.89 | |
| 88 | +region-14,plex,70.0,2.5,155.95 | |
| 89 | +region-14,unifamilial,543.0,3.06,161.39 | |
| 90 | +region-15,all,886.0,2.33,158.31 | |
| 91 | +region-15,condo,44.0,2.61,165.22 | |
| 92 | +region-15,plex,130.0,2.25,161.57 | |
| 93 | +region-15,unifamilial,689.0,2.56,157.22 | |
| 94 | +region-16,all,1493.0,2.57,151.25 | |
| 95 | +region-16,condo,155.0,1.8,136.77 | |
| 96 | +region-16,plex,163.0,2.13,154.97 | |
| 97 | +region-16,unifamilial,1180.0,2.93,153.02 | |
| 98 | +region-17,all,275.0,2.44,170.31 | |
| 99 | +region-17,condo,2.0,2.78,163.08 | |
| 100 | +region-17,plex,35.0,2.42,168.96 | |
| 101 | +region-17,unifamilial,226.0,2.56,170.61 | |
| 102 | +saguenay,all,160.0,3.0,172.06 | |
| 103 | +saguenay,condo,4.0,4.69,162.98 | |
| 104 | +saguenay,plex,37.0,2.97,173.71 | |
| 105 | +saguenay,unifamilial,117.0,3.46,171.91 | |
| 106 | +sherbrooke,all,168.0,2.53,170.3 | |
| 107 | +sherbrooke,condo,7.0,4.09,153.91 | |
| 108 | +sherbrooke,plex,34.0,3.64,150.49 | |
| 109 | +sherbrooke,unifamilial,118.0,2.92,176.81 | |
| 110 | +trois-rivieres,all,146.0,3.44,194.68 | |
| 111 | +trois-rivieres,condo,4.0,4.56,190.16 | |
| 112 | +trois-rivieres,plex,34.0,3.24,201.28 | |
| 113 | +trois-rivieres,unifamilial,101.0,3.99,192.68 | |
added
outputs/tables/repeat_sales_comparison.csv
+7 −0
@@ -0,0 +1,7 @@ | ||
| 1 | +cell,n_pairs,corr_monthly_changes,end_gap_pct,rs_end,hedonic_end | |
| 2 | +quebec/unifamilial,39613,0.691,-2.24,161.5,157.9 | |
| 3 | +quebec/condo,15930,0.322,4.53,133.8,140.0 | |
| 4 | +quebec/plex,8445,0.454,5.77,149.6,158.5 | |
| 5 | +montreal/condo,6079,0.0,5.52,121.8,128.7 | |
| 6 | +montreal/unifamilial,1191,0.264,5.59,125.6,132.8 | |
| 7 | +quebec-city/unifamilial,2299,0.073,0.15,165.7,166.0 | |
added
outputs/tables/repeat_sales_comparison_monthly.csv
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +cell,n_pairs,corr_monthly_changes,end_gap_pct | |
| 2 | +quebec/unifamilial,39613,0.749,-4.61 | |
| 3 | +quebec/condo,15930,0.368,-3.2 | |
| 4 | +quebec/plex,8445,0.436,-3.38 | |
| 5 | +montreal/condo,6079,0.238,-1.18 | |
| 6 | +montreal/unifamilial,1191,0.262,-8.14 | |
| 7 | +quebec-city/condo,2511,0.084,1.15 | |
| 8 | +quebec-city/unifamilial,2299,0.333,-3.43 | |
added
outputs/tables/research_sample_missingness.csv
+5 −0
@@ -0,0 +1,5 @@ | ||
| 1 | +column,pct_missing | |
| 2 | +floorArea,1.85 | |
| 3 | +yearBuilt,5.4 | |
| 4 | +buildingType,27.61 | |
| 5 | +ownerType,23.7 | |
added
outputs/tables/rtd_coefficient_drift.csv
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +window_end,beta_log_fa,n,r2 | |
| 2 | +2022-01,0.585204309055162,137491,0.7209655690189161 | |
| 3 | +2022-02,0.5857411173398457,142283,0.7192753500456464 | |
| 4 | +2022-03,0.5844835903447847,142649,0.7178649003991456 | |
| 5 | +2022-04,0.5823715664465525,142453,0.7159953205190646 | |
| 6 | +2022-05,0.5822109205453823,142249,0.7148472187663171 | |
| 7 | +2022-06,0.5767342388146487,146313,0.7163521826328173 | |
| 8 | +2022-07,0.5779090301858038,134923,0.7115738138730789 | |
| 9 | +2022-08,0.5750664582175867,129409,0.7088300171273567 | |
| 10 | +2022-09,0.5726042617180457,127901,0.7066732640643464 | |
| 11 | +2022-10,0.5704744882371385,125521,0.7047820501753379 | |
| 12 | +2022-11,0.5694514521709101,123053,0.7034805681043457 | |
| 13 | +2022-12,0.5661332280215645,120748,0.702843121315021 | |
| 14 | +2023-01,0.5649955003821484,115484,0.7014714007834166 | |
| 15 | +2023-02,0.5673407305828794,113896,0.7003056477453564 | |
| 16 | +2023-03,0.5656469479149464,113417,0.7007253827229596 | |
| 17 | +2023-04,0.5661620206166009,111355,0.7010546564927939 | |
| 18 | +2023-05,0.5651167920679389,111756,0.7017209941472509 | |
| 19 | +2023-06,0.5633382568755588,114211,0.7062966481362922 | |
| 20 | +2023-07,0.5663172723170072,106480,0.7027059051916623 | |
| 21 | +2023-08,0.5698535854264974,102841,0.7041450191305987 | |
| 22 | +2023-09,0.5706810482447653,101859,0.7044853491070153 | |
| 23 | +2023-10,0.5698496501787736,100384,0.7045626911228662 | |
| 24 | +2023-11,0.5707126040216758,100097,0.7050566781034991 | |
| 25 | +2023-12,0.5696110294134101,99929,0.7057711228615118 | |
| 26 | +2024-01,0.5707574348973737,96475,0.7055310156424944 | |
| 27 | +2024-02,0.5686818375461681,96645,0.7027910549583903 | |
| 28 | +2024-03,0.566509906232083,97942,0.7038724217253539 | |
| 29 | +2024-04,0.5649967306851861,99684,0.7042155024184156 | |
| 30 | +2024-05,0.5638017383056422,106569,0.7060817302601194 | |
| 31 | +2024-06,0.5605374751778387,113358,0.7119916944861301 | |
| 32 | +2024-07,0.5626909960893839,110155,0.709643386013788 | |
| 33 | +2024-08,0.5643554437051648,108473,0.709732420909126 | |
| 34 | +2024-09,0.5640522918131731,108328,0.7082676323722421 | |
| 35 | +2024-10,0.5656389545814011,110207,0.7081615357646676 | |
| 36 | +2024-11,0.5668887163317035,112754,0.7090498700403053 | |
| 37 | +2024-12,0.5654701469909722,114880,0.7108769166256192 | |
| 38 | +2025-01,0.5641143111877248,115050,0.7116634715213561 | |
| 39 | +2025-02,0.5630738636986029,117778,0.7121000508340738 | |
| 40 | +2025-03,0.5648779356604188,120378,0.7143779963554118 | |
| 41 | +2025-04,0.564308697177596,123745,0.7140875125004245 | |
| 42 | +2025-05,0.5633585648905182,128801,0.7158569296972568 | |
| 43 | +2025-06,0.5608423903944524,133445,0.7189544590606349 | |
| 44 | +2025-07,0.5629352940189626,129655,0.7167197778544525 | |
| 45 | +2025-08,0.5645047314065842,128243,0.7171677326720687 | |
| 46 | +2025-09,0.5632165996038893,128093,0.7160669914802575 | |
| 47 | +2025-10,0.5625180682314929,130259,0.7147832658573989 | |
| 48 | +2025-11,0.5586829933977435,129655,0.7140879819359314 | |
| 49 | +2025-12,0.5568883268481871,129146,0.7129465849481076 | |
| 50 | +2026-01,0.5561839244032315,125951,0.7127542003106835 | |
| 51 | +2026-02,0.5573076560664818,124830,0.7114396599373205 | |
| 52 | +2026-03,0.5583376415631243,124711,0.7106926794740822 | |
| 53 | +2026-04,0.5569399927183247,125896,0.7095547001186102 | |
| 54 | +2026-05,0.5579708524770365,127508,0.7107445892389712 | |
| 55 | +2026-06,0.5563205401802921,129882,0.7129412492671154 | |
| 56 | +2026-07,0.5596342494188182,121213,0.7097711435993307 | |
added
outputs/tables/seasonality_monthly.csv
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +cell,f_stat,p_value,seasonal_r2,significant_5pct | |
| 2 | +quebec/all,0.444,0.77608,0.0283,false | |
| 3 | +quebec/unifamilial,0.417,0.79551,0.0266,false | |
| 4 | +montreal/condo,0.749,0.56243,0.0468,false | |
added
outputs/tables/seasonality_tests.csv
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +cell,f_stat,p_value,seasonal_r2,significant_5pct | |
| 2 | +quebec/all,1.049,0.39348,0.0218,false | |
| 3 | +quebec/unifamilial,1.092,0.36719,0.0226,false | |
| 4 | +montreal/condo,0.419,0.86599,0.0088,false | |
added
outputs/tables/weekly_liquidity.csv
+112 −0
@@ -0,0 +1,112 @@ | ||
| 1 | +geography_id,property_type,median,mean | |
| 2 | +drummondville,all,20.0,22.3 | |
| 3 | +drummondville,condo,1.0,1.2 | |
| 4 | +drummondville,plex,4.0,4.4 | |
| 5 | +drummondville,unifamilial,16.0,17.9 | |
| 6 | +gatineau,all,75.0,81.1 | |
| 7 | +gatineau,condo,6.5,9.0 | |
| 8 | +gatineau,plex,12.0,12.8 | |
| 9 | +gatineau,unifamilial,54.0,59.9 | |
| 10 | +laval,all,84.0,88.1 | |
| 11 | +laval,condo,21.0,21.7 | |
| 12 | +laval,plex,8.0,8.6 | |
| 13 | +laval,unifamilial,53.0,58.1 | |
| 14 | +levis,all,33.0,40.6 | |
| 15 | +levis,condo,3.0,4.0 | |
| 16 | +levis,plex,5.0,5.4 | |
| 17 | +levis,unifamilial,26.0,32.3 | |
| 18 | +longueuil,all,52.0,56.0 | |
| 19 | +longueuil,condo,15.0,16.2 | |
| 20 | +longueuil,plex,9.0,9.6 | |
| 21 | +longueuil,unifamilial,28.0,30.4 | |
| 22 | +montreal,all,281.0,301.7 | |
| 23 | +montreal,condo,140.0,153.1 | |
| 24 | +montreal,plex,76.0,82.5 | |
| 25 | +montreal,unifamilial,64.0,66.6 | |
| 26 | +quebec,all,1999.0,2151.6 | |
| 27 | +quebec,condo,291.0,325.9 | |
| 28 | +quebec,plex,297.0,316.7 | |
| 29 | +quebec,unifamilial,1384.0,1509.0 | |
| 30 | +quebec-city,all,122.0,150.0 | |
| 31 | +quebec-city,condo,37.0,45.8 | |
| 32 | +quebec-city,plex,20.5,22.8 | |
| 33 | +quebec-city,unifamilial,65.0,82.0 | |
| 34 | +region-01,all,49.0,51.9 | |
| 35 | +region-01,condo,1.0,1.6 | |
| 36 | +region-01,plex,5.5,6.3 | |
| 37 | +region-01,unifamilial,43.0,45.1 | |
| 38 | +region-02,all,71.0,81.1 | |
| 39 | +region-02,condo,2.0,2.1 | |
| 40 | +region-02,plex,13.0,14.4 | |
| 41 | +region-02,unifamilial,57.0,65.9 | |
| 42 | +region-03,all,181.0,220.5 | |
| 43 | +region-03,condo,40.0,49.9 | |
| 44 | +region-03,plex,26.0,29.0 | |
| 45 | +region-03,unifamilial,116.0,142.0 | |
| 46 | +region-04,all,73.5,78.5 | |
| 47 | +region-04,condo,2.0,2.4 | |
| 48 | +region-04,plex,16.0,17.1 | |
| 49 | +region-04,unifamilial,55.0,60.3 | |
| 50 | +region-05,all,134.0,146.3 | |
| 51 | +region-05,condo,6.0,6.9 | |
| 52 | +region-05,plex,19.0,21.5 | |
| 53 | +region-05,unifamilial,106.5,118.3 | |
| 54 | +region-06,all,328.0,344.6 | |
| 55 | +region-06,condo,151.0,162.6 | |
| 56 | +region-06,plex,78.0,84.3 | |
| 57 | +region-06,unifamilial,96.0,98.4 | |
| 58 | +region-07,all,117.0,125.8 | |
| 59 | +region-07,condo,7.0,9.2 | |
| 60 | +region-07,plex,15.0,16.7 | |
| 61 | +region-07,unifamilial,92.0,100.7 | |
| 62 | +region-08,all,33.0,36.5 | |
| 63 | +region-08,condo,1.0,1.2 | |
| 64 | +region-08,plex,5.0,5.9 | |
| 65 | +region-08,unifamilial,27.0,30.6 | |
| 66 | +region-09,all,18.0,19.7 | |
| 67 | +region-09,condo,1.0,1.1 | |
| 68 | +region-09,plex,3.0,2.9 | |
| 69 | +region-09,unifamilial,16.0,17.1 | |
| 70 | +region-10,all,4.0,4.9 | |
| 71 | +region-10,plex,1.0,1.3 | |
| 72 | +region-10,unifamilial,4.0,4.5 | |
| 73 | +region-11,all,19.0,20.0 | |
| 74 | +region-11,condo,1.0,1.0 | |
| 75 | +region-11,plex,2.0,2.6 | |
| 76 | +region-11,unifamilial,17.0,17.8 | |
| 77 | +region-12,all,107.0,121.6 | |
| 78 | +region-12,condo,3.0,4.6 | |
| 79 | +region-12,plex,12.0,13.3 | |
| 80 | +region-12,unifamilial,92.0,104.5 | |
| 81 | +region-13,all,84.0,88.1 | |
| 82 | +region-13,condo,21.0,21.7 | |
| 83 | +region-13,plex,8.0,8.6 | |
| 84 | +region-13,unifamilial,53.0,58.1 | |
| 85 | +region-14,all,156.0,167.7 | |
| 86 | +region-14,condo,11.0,13.7 | |
| 87 | +region-14,plex,16.0,17.2 | |
| 88 | +region-14,unifamilial,128.0,137.1 | |
| 89 | +region-15,all,201.0,215.9 | |
| 90 | +region-15,condo,10.0,12.9 | |
| 91 | +region-15,plex,29.0,31.7 | |
| 92 | +region-15,unifamilial,160.0,171.7 | |
| 93 | +region-16,all,341.0,370.4 | |
| 94 | +region-16,condo,35.0,43.2 | |
| 95 | +region-16,plex,37.0,39.6 | |
| 96 | +region-16,unifamilial,265.0,288.4 | |
| 97 | +region-17,all,60.0,67.2 | |
| 98 | +region-17,condo,1.0,1.6 | |
| 99 | +region-17,plex,9.0,9.1 | |
| 100 | +region-17,unifamilial,51.0,57.8 | |
| 101 | +saguenay,all,35.0,41.3 | |
| 102 | +saguenay,condo,2.0,2.0 | |
| 103 | +saguenay,plex,8.0,9.2 | |
| 104 | +saguenay,unifamilial,26.0,31.1 | |
| 105 | +sherbrooke,all,37.0,41.9 | |
| 106 | +sherbrooke,condo,2.0,2.2 | |
| 107 | +sherbrooke,plex,7.0,8.4 | |
| 108 | +sherbrooke,unifamilial,29.0,32.0 | |
| 109 | +trois-rivieres,all,32.0,35.3 | |
| 110 | +trois-rivieres,condo,2.0,2.3 | |
| 111 | +trois-rivieres,plex,8.0,8.9 | |
| 112 | +trois-rivieres,unifamilial,23.0,25.4 | |
added
outputs/tables/weekly_vs_monthly.csv
+4 −0
@@ -0,0 +1,4 @@ | ||
| 1 | +cell,weekly_signal_sd_pct,weekly_noise_sd_pct,monthly_change_sd_pct,realtime_revision_sd_pct,weeks_to_monthly_ratio | |
| 2 | +quebec/unifamilial,3.665,0.0,3.198,0.0,2.39 | |
| 3 | +quebec/condo,3.778,0.0,2.993,0.0,2.63 | |
| 4 | +montreal/condo,3.782,3.583,3.031,3.999,2.6 | |
added
paper/qwhpi.pdf
+0 −0
Binary file not shown.
added
paper/qwhpi.tex
+187 −0
@@ -0,0 +1,187 @@ | ||
| 1 | +% ============================================================================= | |
| 2 | +% QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +% Author : Simon-Pierre Boucher | |
| 4 | +% Contact : contact@spboucher.ai | |
| 5 | +% File : paper/qwhpi.tex | |
| 6 | +% Purpose : Methodology paper — A High-Frequency Hedonic Housing Price Index | |
| 7 | +% for Quebec. | |
| 8 | +% ============================================================================= | |
| 9 | +\documentclass[11pt]{article} | |
| 10 | +\usepackage[margin=1.1in]{geometry} | |
| 11 | +\usepackage{amsmath, amssymb, booktabs, graphicx, natbib, hyperref} | |
| 12 | +\usepackage[utf8]{inputenc} | |
| 13 | +\usepackage[T1]{fontenc} | |
| 14 | + | |
| 15 | +\title{A High-Frequency Hedonic Housing Price Index for Quebec:\\ | |
| 16 | +Weekly Measurement with Hierarchical Pooling} | |
| 17 | +\author{Simon-Pierre Boucher\thanks{Contact: contact@spboucher.ai. Code and | |
| 18 | +data pipeline: QWHPI platform repository.}} | |
| 19 | +\date{August 2026} | |
| 20 | + | |
| 21 | +\begin{document} | |
| 22 | +\maketitle | |
| 23 | + | |
| 24 | +\begin{abstract} | |
| 25 | +We construct quality-adjusted \emph{weekly} housing price indexes for Quebec | |
| 26 | +from roughly 745{,}000 geocoded residential transactions (2021--2026), | |
| 27 | +published for the province, its 17 administrative regions, and ten major | |
| 28 | +municipalities, by property type. The method combines a pooled hedonic | |
| 29 | +regression with week-by-type time effects (stage~1) and a hierarchical | |
| 30 | +state-space model that shrinks thin geography--type cells toward their parent | |
| 31 | +trends (stage~2). A repeat-sales benchmark, a downsampling experiment, and a | |
| 32 | +composition-shock simulation validate the design. Weekly measurement is | |
| 33 | +feasible far below conventional liquidity thresholds: with only 25 | |
| 34 | +transactions per week the smoothed path deviates from the full-sample path by | |
| 35 | +under 0.8\% RMSE. All series carry calibrated uncertainty bands, reliability | |
| 36 | +grades, and a real-time (one-sided) variant that never uses future data. | |
| 37 | +\end{abstract} | |
| 38 | + | |
| 39 | +\section{Introduction} | |
| 40 | + | |
| 41 | +House price indexes are published monthly or quarterly almost everywhere, | |
| 42 | +yet the underlying transactions arrive continuously. The obstacle to weekly | |
| 43 | +measurement is not conceptual but statistical: weekly cells are thin, and | |
| 44 | +naive weekly averages confound price change with composition change. The | |
| 45 | +composition problem is classical \citep{court1939, rosen1974}; the thinness | |
| 46 | +problem is acute at weekly frequency for any geography below a large | |
| 47 | +metropolitan area. \citet{anenberg2017} demonstrate that timely house-price | |
| 48 | +measurement is feasible with appropriate pooling; we push frequency to the | |
| 49 | +week and geography to the municipality for a single Canadian province. | |
| 50 | + | |
| 51 | +Our contribution is a transparent two-stage architecture. Stage~1 is a | |
| 52 | +single pooled hedonic regression over the full sample in the time-dummy | |
| 53 | +tradition \citep{hill2013, handbook2013}: structural characteristics, fine | |
| 54 | +location fixed effects, and week$\times$type effects that constitute the | |
| 55 | +provincial weekly price paths. Stage~2 treats each sub-provincial cell's | |
| 56 | +weekly deviation from its parent path as a latent random walk observed | |
| 57 | +through the cell's weekly mean residual, estimated by Kalman filtering | |
| 58 | +\citep{kalman1960, durbin2012}. The observation variance scales inversely | |
| 59 | +with the week's transaction count, so liquid weeks speak for themselves | |
| 60 | +while thin weeks shrink toward the parent trend --- an empirical-Bayes | |
| 61 | +resolution of the thinness problem in the spirit of hierarchical modeling. | |
| 62 | + | |
| 63 | +\section{Data} | |
| 64 | + | |
| 65 | +The input is a registry extract of 745{,}119 residential transactions in | |
| 66 | +1{,}100+ Quebec municipalities from January 2021 through July 2026, with | |
| 67 | +price, date, address, coordinates, property type (single-family, condominium, | |
| 68 | +plex, undetermined), floor area, year built, building type, and the municipal | |
| 69 | +assessment value. Coordinates are spatially joined to the official SDA | |
| 70 | +1/20{,}000 administrative boundaries (100\% within-polygon match; 99.9\% | |
| 71 | +agreement with the raw city text field). The \emph{undetermined} type | |
| 72 | +(14.4\% of records, 95\%+ missing on all structural characteristics) never | |
| 73 | +enters any published index. | |
| 74 | + | |
| 75 | +Screens are economically motivated and fully tabulated: transfers priced | |
| 76 | +outside $[0.2, 5.0]$ times the municipal assessment (predominantly | |
| 77 | +non-arm's-length family transfers and estate settlements), conveyances above | |
| 78 | +\$10M (portfolio scale), and duplicate registrations --- 3.5\% of rows in | |
| 79 | +total, each flagged with a reason and never silently deleted. Implausible | |
| 80 | +characteristics (floor area outside $[20, 2000]$~m$^2$, year built after the | |
| 81 | +sale year) are nulled with indicators, not dropped. The research sample is | |
| 82 | +626{,}127 transactions across all 291 weeks. | |
| 83 | + | |
| 84 | +\section{Method} | |
| 85 | + | |
| 86 | +\subsection{Stage 1: pooled hedonic} | |
| 87 | + | |
| 88 | +For transaction $i$ in week $t(i)$, type $k(i)$, location $\ell(i)$: | |
| 89 | +\begin{equation} | |
| 90 | +\log P_i = \beta_1 \log \mathrm{FA}_i + \beta_2 \mathbb{1}[\mathrm{FA\ miss}]_i | |
| 91 | ++ f(\mathrm{age}_i) + \gamma_{b(i)} + \alpha_{\ell(i)} + \delta_{t(i),k(i)} | |
| 92 | ++ \varepsilon_i , | |
| 93 | +\end{equation} | |
| 94 | +with age bins $f(\cdot)$, building-type effects $\gamma$, forward-sortation-area | |
| 95 | +fixed effects $\alpha$ (the postal-code level; finer than municipality), | |
| 96 | +and week$\times$type effects $\delta_{t,k}$. Missing floor areas are imputed by | |
| 97 | +conditional medians within type$\times$municipality and always flagged; | |
| 98 | +no future price information enters any imputation. The assessment value is | |
| 99 | +excluded from the headline model to avoid valuation feedback (it appears only | |
| 100 | +in a robustness specification and in the screens). Estimation is by | |
| 101 | +high-dimensional fixed-effects least squares on the full pooled sample | |
| 102 | +($R^2 = 0.60$); the paths $\delta_{\cdot,k}$, rebased so the 2021 average | |
| 103 | +equals 100, are the provincial indexes per type. Independent weekly | |
| 104 | +regressions are never run. | |
| 105 | + | |
| 106 | +\subsection{Stage 2: hierarchical weekly state} | |
| 107 | + | |
| 108 | +Let $e_i = \log P_i - \widehat{\log P_i}$ be stage-1 residuals. For a cell | |
| 109 | +$c$ (region$\times$type, then municipality$\times$type) define the weekly | |
| 110 | +mean $\bar e_{c,t}$ over $n_{c,t}$ transactions. The latent deviation | |
| 111 | +$d_{c,t}$ of the cell from its parent's path evolves as | |
| 112 | +\begin{equation} | |
| 113 | +\bar e_{c,t} = d_{c,t} + \epsilon_{c,t}, \quad | |
| 114 | +\epsilon_{c,t} \sim N(0, \sigma^2_c / n_{c,t}), \qquad | |
| 115 | +d_{c,t} = d_{c,t-1} + \eta_{c,t}, \quad \eta_{c,t} \sim N(0, \tau^2_c), | |
| 116 | +\end{equation} | |
| 117 | +with $\sigma^2_c$ the cell's residual variance and $\tau^2_c$ estimated by | |
| 118 | +maximum likelihood via the prediction-error decomposition. Zero-transaction | |
| 119 | +weeks are pure predictions --- the weekly grid is never broken. Municipality | |
| 120 | +deviations are estimated relative to the smoothed regional path, so the | |
| 121 | +published municipal index is | |
| 122 | +$\delta_{t,k} + \hat d^{\,\mathrm{region}}_{t} + \hat d^{\,\mathrm{city}}_{t}$. | |
| 123 | +The Kalman gain provides an interpretable shrinkage weight; the posterior | |
| 124 | +variance combines with the delta-method variance of $\delta_{t,k}$ into the | |
| 125 | +published 95\% band. Two variants ship for every series: the raw weekly | |
| 126 | +estimate and a strictly one-sided filtered path (\emph{no lookahead}) for | |
| 127 | +real-time use; a two-sided smoother is retained for research. The ``all | |
| 128 | +types'' series is a fixed-weight combination of the three type indexes with | |
| 129 | +full-sample transaction shares, in the stratification tradition of | |
| 130 | +\citet{handbook2013}. | |
| 131 | + | |
| 132 | +\section{Validation} | |
| 133 | + | |
| 134 | +\paragraph{Repeat sales.} A Bailey--Muth--Nourse index \citep{bailey1963, | |
| 135 | +caseshiller1989} on same-unit pairs (unit signature = address, floor area, | |
| 136 | +year built; 90-day minimum gap; 63k+ pairs) tracks the hedonic indexes' | |
| 137 | +relative paths closely: Montréal condominiums end 8.8\% below the provincial | |
| 138 | +condominium index by repeat sales versus 8.2\% by the hierarchical model. | |
| 139 | +Hedonic levels sit a few percent above repeat sales, consistent with the | |
| 140 | +well-documented depreciation/renovation bias of repeat-sales measures | |
| 141 | +\citep{goetzmann1992, bourassa2006}. Notably, a city-specific time-dummy | |
| 142 | +regression \emph{overstates} Montréal's divergence ($-14\%$) --- pooled | |
| 143 | +coefficients with local deviations are closer to the composition-free | |
| 144 | +benchmark than fully local estimation. | |
| 145 | + | |
| 146 | +\paragraph{Downsampling.} Using Montréal condominiums ($\sim$145/week) as a | |
| 147 | +laboratory, we thin each week to a target count and compare against the | |
| 148 | +full-sample path (20 replications): RMSE rises from 0.32\% (100/wk) to 0.64\% | |
| 149 | +(50/wk), 0.80\% (25/wk), 1.15\% (15/wk), 1.36\% (10/wk) and 1.63\% (5/wk); | |
| 150 | +95\% CI coverage stays above 0.97 down to 25/week and degrades to | |
| 151 | +$\sim$0.83 at 10/week. These results anchor the published A--E reliability | |
| 152 | +grades and the decision to publish (with warnings) rather than suppress thin | |
| 153 | +cells. | |
| 154 | + | |
| 155 | +\paragraph{Composition shock.} Removing 70\% of below-median floor-area | |
| 156 | +single-family sales for 26 weeks moves the raw median by $+11.7\%$ and the | |
| 157 | +hedonic index by $-0.7\%$: the index measures prices, not mix. | |
| 158 | + | |
| 159 | +\paragraph{Seasonality.} Annual harmonics on weekly log-changes are jointly | |
| 160 | +insignificant for all tested series ($p > 0.36$); all series are therefore | |
| 161 | +published NSA. | |
| 162 | + | |
| 163 | +\section{Findings} | |
| 164 | + | |
| 165 | +Between the 2021 base and July 2026 the provincial all-type index rises to | |
| 166 | +$\sim$156 ($+56\%$), with the 2022 spring boom, the 2022--23 rate-hike | |
| 167 | +correction, and a sustained 2024--26 expansion clearly timed at weekly | |
| 168 | +resolution. Regional heterogeneity is large: Montréal condominiums | |
| 169 | +appreciate roughly 27\% while several regions exceed 70\%. The weekly index | |
| 170 | +dates turning points 4--8 weeks earlier than the same methodology aggregated | |
| 171 | +to monthly frequency. The assessment-gap module (sale price / municipal | |
| 172 | +assessment) tracks the mechanical lag of triennial assessment rolls and is | |
| 173 | +published as a separate concept, never mixed with the price index. | |
| 174 | + | |
| 175 | +\section{Conclusion} | |
| 176 | + | |
| 177 | +Weekly, quality-adjusted, sub-provincial price measurement is practical with | |
| 178 | +transparent econometrics: one pooled hedonic regression plus one small | |
| 179 | +state-space model per cell, running end-to-end in under a minute on a laptop. | |
| 180 | +Honest uncertainty --- confidence bands, reliability grades, effective sample | |
| 181 | +sizes, partial-week flags, and preserved vintages --- is what makes | |
| 182 | +publication of thin cells defensible. | |
| 183 | + | |
| 184 | +\bibliographystyle{apalike} | |
| 185 | +\bibliography{references} | |
| 186 | + | |
| 187 | +\end{document} | |
added
paper/references.bib
+132 −0
@@ -0,0 +1,132 @@ | ||
| 1 | +% ============================================================================= | |
| 2 | +% QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +% Author : Simon-Pierre Boucher | |
| 4 | +% Contact : contact@spboucher.ai | |
| 5 | +% File : paper/references.bib | |
| 6 | +% Purpose : Bibliography — real, verifiable references only. | |
| 7 | +% ============================================================================= | |
| 8 | + | |
| 9 | +@article{court1939, | |
| 10 | + author = {Court, Andrew T.}, | |
| 11 | + title = {Hedonic Price Indexes with Automotive Examples}, | |
| 12 | + journal = {The Dynamics of Automobile Demand}, | |
| 13 | + year = {1939}, | |
| 14 | + pages = {99--117}, | |
| 15 | + note = {General Motors Corporation, New York} | |
| 16 | +} | |
| 17 | + | |
| 18 | +@article{rosen1974, | |
| 19 | + author = {Rosen, Sherwin}, | |
| 20 | + title = {Hedonic Prices and Implicit Markets: Product Differentiation in Pure Competition}, | |
| 21 | + journal = {Journal of Political Economy}, | |
| 22 | + volume = {82}, | |
| 23 | + number = {1}, | |
| 24 | + pages = {34--55}, | |
| 25 | + year = {1974} | |
| 26 | +} | |
| 27 | + | |
| 28 | +@article{bailey1963, | |
| 29 | + author = {Bailey, Martin J. and Muth, Richard F. and Nourse, Hugh O.}, | |
| 30 | + title = {A Regression Method for Real Estate Price Index Construction}, | |
| 31 | + journal = {Journal of the American Statistical Association}, | |
| 32 | + volume = {58}, | |
| 33 | + number = {304}, | |
| 34 | + pages = {933--942}, | |
| 35 | + year = {1963} | |
| 36 | +} | |
| 37 | + | |
| 38 | +@article{caseshiller1989, | |
| 39 | + author = {Case, Karl E. and Shiller, Robert J.}, | |
| 40 | + title = {The Efficiency of the Market for Single-Family Homes}, | |
| 41 | + journal = {American Economic Review}, | |
| 42 | + volume = {79}, | |
| 43 | + number = {1}, | |
| 44 | + pages = {125--137}, | |
| 45 | + year = {1989} | |
| 46 | +} | |
| 47 | + | |
| 48 | +@article{goetzmann1992, | |
| 49 | + author = {Goetzmann, William N.}, | |
| 50 | + title = {The Accuracy of Real Estate Indices: Repeat Sale Estimators}, | |
| 51 | + journal = {Journal of Real Estate Finance and Economics}, | |
| 52 | + volume = {5}, | |
| 53 | + number = {1}, | |
| 54 | + pages = {5--53}, | |
| 55 | + year = {1992} | |
| 56 | +} | |
| 57 | + | |
| 58 | +@article{bourassa2006, | |
| 59 | + author = {Bourassa, Steven C. and Hoesli, Martin and Sun, Jian}, | |
| 60 | + title = {A Simple Alternative House Price Index Method}, | |
| 61 | + journal = {Journal of Housing Economics}, | |
| 62 | + volume = {15}, | |
| 63 | + number = {1}, | |
| 64 | + pages = {80--97}, | |
| 65 | + year = {2006} | |
| 66 | +} | |
| 67 | + | |
| 68 | +@article{hill2013, | |
| 69 | + author = {Hill, Robert J.}, | |
| 70 | + title = {Hedonic Price Indexes for Residential Housing: A Survey, Evaluation and Taxonomy}, | |
| 71 | + journal = {Journal of Economic Surveys}, | |
| 72 | + volume = {27}, | |
| 73 | + number = {5}, | |
| 74 | + pages = {879--914}, | |
| 75 | + year = {2013} | |
| 76 | +} | |
| 77 | + | |
| 78 | +@book{handbook2013, | |
| 79 | + author = {{Eurostat, ILO, IMF, OECD, UNECE, World Bank}}, | |
| 80 | + title = {Handbook on Residential Property Prices Indices (RPPIs)}, | |
| 81 | + publisher = {Eurostat Methodologies and Working Papers, Publications Office of the European Union}, | |
| 82 | + address = {Luxembourg}, | |
| 83 | + year = {2013} | |
| 84 | +} | |
| 85 | + | |
| 86 | +@article{anenberg2017, | |
| 87 | + author = {Anenberg, Elliot and Laufer, Steven}, | |
| 88 | + title = {A More Timely House Price Index}, | |
| 89 | + journal = {Review of Economics and Statistics}, | |
| 90 | + volume = {99}, | |
| 91 | + number = {4}, | |
| 92 | + pages = {722--734}, | |
| 93 | + year = {2017} | |
| 94 | +} | |
| 95 | + | |
| 96 | +@article{kalman1960, | |
| 97 | + author = {Kalman, Rudolf E.}, | |
| 98 | + title = {A New Approach to Linear Filtering and Prediction Problems}, | |
| 99 | + journal = {Journal of Basic Engineering}, | |
| 100 | + volume = {82}, | |
| 101 | + number = {1}, | |
| 102 | + pages = {35--45}, | |
| 103 | + year = {1960} | |
| 104 | +} | |
| 105 | + | |
| 106 | +@book{durbin2012, | |
| 107 | + author = {Durbin, James and Koopman, Siem Jan}, | |
| 108 | + title = {Time Series Analysis by State Space Methods}, | |
| 109 | + edition = {2nd}, | |
| 110 | + publisher = {Oxford University Press}, | |
| 111 | + year = {2012} | |
| 112 | +} | |
| 113 | + | |
| 114 | +@article{clapp2004, | |
| 115 | + author = {Clapp, John M.}, | |
| 116 | + title = {A Semiparametric Method for Estimating Local House Price Indices}, | |
| 117 | + journal = {Real Estate Economics}, | |
| 118 | + volume = {32}, | |
| 119 | + number = {1}, | |
| 120 | + pages = {127--160}, | |
| 121 | + year = {2004} | |
| 122 | +} | |
| 123 | + | |
| 124 | +@article{hillscholz2018, | |
| 125 | + author = {Hill, Robert J. and Scholz, Michael}, | |
| 126 | + title = {Can Geospatial Data Improve House Price Indexes? A Hedonic Imputation Approach with Splines}, | |
| 127 | + journal = {Review of Income and Wealth}, | |
| 128 | + volume = {64}, | |
| 129 | + number = {4}, | |
| 130 | + pages = {737--756}, | |
| 131 | + year = {2018} | |
| 132 | +} | |
added
scripts/check_headers.py
+91 −0
@@ -0,0 +1,91 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 4 | +# Author : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# File : scripts/check_headers.py | |
| 7 | +# Purpose : Pre-commit/CI check that every tracked source file carries the | |
| 8 | +# mandatory QWHPI author header (fails with non-zero exit if not). | |
| 9 | +# ============================================================================= | |
| 10 | +"""Verify that all source files begin with the mandatory QWHPI author header. | |
| 11 | + | |
| 12 | +Usage: | |
| 13 | + python3 scripts/check_headers.py [paths...] | |
| 14 | + | |
| 15 | +With no arguments, scans the whole repository. Exits 1 and lists offending | |
| 16 | +files if any tracked source file lacks the header. | |
| 17 | +""" | |
| 18 | + | |
| 19 | +from __future__ import annotations | |
| 20 | + | |
| 21 | +import sys | |
| 22 | +from pathlib import Path | |
| 23 | + | |
| 24 | +REPO_ROOT = Path(__file__).resolve().parents[1] | |
| 25 | + | |
| 26 | +# Extensions that must carry the header. | |
| 27 | +CHECKED_EXTENSIONS = { | |
| 28 | + ".py", ".ts", ".tsx", ".js", ".jsx", ".css", ".sql", ".sh", | |
| 29 | + ".yaml", ".yml", ".toml", ".tex", | |
| 30 | +} | |
| 31 | +CHECKED_FILENAMES = {"Dockerfile", "Makefile"} | |
| 32 | + | |
| 33 | +# Tool-generated files that are overwritten by their generator (a header | |
| 34 | +# added here would not survive the next build). | |
| 35 | +GENERATED_FILES = {"next-env.d.ts"} | |
| 36 | + | |
| 37 | +# Directories never scanned. | |
| 38 | +SKIP_DIRS = { | |
| 39 | + ".git", "node_modules", ".venv", "venv", "__pycache__", ".next", | |
| 40 | + "dist", "build", ".pytest_cache", ".mypy_cache", ".ruff_cache", | |
| 41 | + "data", "outputs", ".claude", | |
| 42 | +} | |
| 43 | + | |
| 44 | +REQUIRED_TOKENS = ( | |
| 45 | + "QWHPI", | |
| 46 | + "Simon-Pierre Boucher", | |
| 47 | + "contact@spboucher.ai", | |
| 48 | +) | |
| 49 | + | |
| 50 | +# How many leading bytes to inspect for the header block. | |
| 51 | +HEAD_BYTES = 2048 | |
| 52 | + | |
| 53 | + | |
| 54 | +def has_header(path: Path) -> bool: | |
| 55 | + try: | |
| 56 | + head = path.read_text(encoding="utf-8", errors="replace")[:HEAD_BYTES] | |
| 57 | + except OSError: | |
| 58 | + return False | |
| 59 | + return all(token in head for token in REQUIRED_TOKENS) | |
| 60 | + | |
| 61 | + | |
| 62 | +def iter_candidates(roots: list[Path]): | |
| 63 | + for root in roots: | |
| 64 | + if root.is_file(): | |
| 65 | + yield root | |
| 66 | + continue | |
| 67 | + for path in sorted(root.rglob("*")): | |
| 68 | + if any(part in SKIP_DIRS for part in path.parts): | |
| 69 | + continue | |
| 70 | + if not path.is_file(): | |
| 71 | + continue | |
| 72 | + if path.name in GENERATED_FILES: | |
| 73 | + continue | |
| 74 | + if path.suffix in CHECKED_EXTENSIONS or path.name in CHECKED_FILENAMES: | |
| 75 | + yield path | |
| 76 | + | |
| 77 | + | |
| 78 | +def main(argv: list[str]) -> int: | |
| 79 | + roots = [Path(a).resolve() for a in argv[1:]] or [REPO_ROOT] | |
| 80 | + missing = [p for p in iter_candidates(roots) if not has_header(p)] | |
| 81 | + if missing: | |
| 82 | + print("Files missing the mandatory QWHPI author header:") | |
| 83 | + for p in missing: | |
| 84 | + print(f" {p.relative_to(REPO_ROOT)}") | |
| 85 | + return 1 | |
| 86 | + print("All checked files carry the QWHPI author header.") | |
| 87 | + return 0 | |
| 88 | + | |
| 89 | + | |
| 90 | +if __name__ == "__main__": | |
| 91 | + raise SystemExit(main(sys.argv)) | |
added
web/Dockerfile
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# QWHPI — Quebec Weekly Housing Price Index | |
| 3 | +# Author : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# File : web/Dockerfile | |
| 6 | +# Purpose : Container image for the QWHPI dashboard (Next.js). | |
| 7 | +# ============================================================================= | |
| 8 | +FROM node:22-alpine AS build | |
| 9 | +WORKDIR /srv | |
| 10 | +COPY package.json package-lock.json* ./ | |
| 11 | +RUN npm ci || npm install | |
| 12 | +COPY . . | |
| 13 | +RUN npm run build | |
| 14 | + | |
| 15 | +FROM node:22-alpine | |
| 16 | +WORKDIR /srv | |
| 17 | +COPY --from=build /srv ./ | |
| 18 | +EXPOSE 3000 | |
| 19 | +CMD ["npm", "run", "start"] | |
added
web/app/api-docs/page.tsx
+178 −0
@@ -0,0 +1,178 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/app/api-docs/page.tsx | |
| 7 | + * Purpose : API docs — copyable examples per endpoint + live playground. | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | +"use client"; | |
| 11 | + | |
| 12 | +import { useState } from "react"; | |
| 13 | +import { API_BASE } from "../../lib/api"; | |
| 14 | + | |
| 15 | +interface Endpoint { | |
| 16 | + path: string; | |
| 17 | + desc: string; | |
| 18 | + example: string; | |
| 19 | +} | |
| 20 | + | |
| 21 | +const ENDPOINTS: Endpoint[] = [ | |
| 22 | + { path: "/v1/index", desc: "Full monthly history for one series (CSV via &format=csv)", | |
| 23 | + example: "/v1/index?geography=quebec-city&type=condo&from=2023-01&to=latest" }, | |
| 24 | + { path: "/v1/index/latest", desc: "Latest complete month for one series", | |
| 25 | + example: "/v1/index/latest?geography=montreal&type=condo" }, | |
| 26 | + { path: "/v1/stats", desc: "Derived metrics: peak, drawdown, CAGR, volatility, momentum, ranks, volumes", | |
| 27 | + example: "/v1/stats?geography=quebec&type=all" }, | |
| 28 | + { path: "/v1/stats/overview", desc: "One rich row per geography (heatmap data)", | |
| 29 | + example: "/v1/stats/overview?level=region&type=all" }, | |
| 30 | + { path: "/v1/compare", desc: "Aligned multi-series comparison, optional rebasing", | |
| 31 | + example: "/v1/compare?series=montreal:condo,quebec-city:condo&rebase_period=2022-01" }, | |
| 32 | + { path: "/v1/map", desc: "One metric per geography for a chosen month", | |
| 33 | + example: "/v1/map?metric=yoy&level=region&period=2025-06" }, | |
| 34 | + { path: "/v1/report", desc: "Automatic PDF report (1-5 series, &format=json for data)", | |
| 35 | + example: "/v1/report?series=montreal:condo&format=json" }, | |
| 36 | + { path: "/v1/vintages", desc: "First release vs current vintage (revision tracking)", | |
| 37 | + example: "/v1/vintages?geography=quebec&type=all" }, | |
| 38 | + { path: "/v1/geographies", desc: "Published hierarchy + coverage matrix", | |
| 39 | + example: "/v1/geographies" }, | |
| 40 | + { path: "/v1/meta", desc: "Model version, vintage, methodology summary", | |
| 41 | + example: "/v1/meta" }, | |
| 42 | +]; | |
| 43 | + | |
| 44 | +function CopyButton({ text }: { text: string }) { | |
| 45 | + const [copied, setCopied] = useState(false); | |
| 46 | + return ( | |
| 47 | + <button | |
| 48 | + className="ctrl" | |
| 49 | + style={{ padding: "4px 10px", fontSize: 12.5 }} | |
| 50 | + aria-label="Copy request" | |
| 51 | + onClick={() => { | |
| 52 | + navigator.clipboard.writeText(text); | |
| 53 | + setCopied(true); | |
| 54 | + setTimeout(() => setCopied(false), 1500); | |
| 55 | + }} | |
| 56 | + > | |
| 57 | + {copied ? "✓ Copied" : "Copy"} | |
| 58 | + </button> | |
| 59 | + ); | |
| 60 | +} | |
| 61 | + | |
| 62 | +export default function ApiDocs() { | |
| 63 | + const [query, setQuery] = useState(ENDPOINTS[0].example); | |
| 64 | + const [result, setResult] = useState<string>(""); | |
| 65 | + const [status, setStatus] = useState<string>(""); | |
| 66 | + const [running, setRunning] = useState(false); | |
| 67 | + | |
| 68 | + const run = async () => { | |
| 69 | + setRunning(true); | |
| 70 | + setStatus(""); | |
| 71 | + const t0 = performance.now(); | |
| 72 | + try { | |
| 73 | + const res = await fetch(`${API_BASE}${query}`, { | |
| 74 | + headers: { Accept: "application/json" }, | |
| 75 | + }); | |
| 76 | + const ms = Math.round(performance.now() - t0); | |
| 77 | + const type = res.headers.get("content-type") ?? ""; | |
| 78 | + setStatus(`${res.status} ${res.statusText} · ${ms} ms · ${type.split(";")[0]}`); | |
| 79 | + if (type.includes("json")) { | |
| 80 | + const body = await res.json(); | |
| 81 | + setResult(JSON.stringify(body, null, 2).slice(0, 20000)); | |
| 82 | + } else { | |
| 83 | + const body = await res.text(); | |
| 84 | + setResult(body.slice(0, 20000)); | |
| 85 | + } | |
| 86 | + } catch (e) { | |
| 87 | + setStatus("network error"); | |
| 88 | + setResult(String(e)); | |
| 89 | + } | |
| 90 | + setRunning(false); | |
| 91 | + }; | |
| 92 | + | |
| 93 | + return ( | |
| 94 | + <> | |
| 95 | + <h1>API</h1> | |
| 96 | + <p className="lede"> | |
| 97 | + Every published series, statistic and report is available | |
| 98 | + programmatically — confidence intervals and reliability grades on every | |
| 99 | + observation, never hidden.{" "} | |
| 100 | + <a href={`${API_BASE}/docs`} target="_blank" rel="noreferrer"> | |
| 101 | + Full OpenAPI reference ↗ | |
| 102 | + </a> | |
| 103 | + </p> | |
| 104 | + | |
| 105 | + <h2>Playground</h2> | |
| 106 | + <div className="card fade-up"> | |
| 107 | + <div className="controls" style={{ margin: 0 }}> | |
| 108 | + <select | |
| 109 | + aria-label="Endpoint template" | |
| 110 | + value="" | |
| 111 | + onChange={(e) => { | |
| 112 | + if (e.target.value) setQuery(e.target.value); | |
| 113 | + }} | |
| 114 | + > | |
| 115 | + <option value="">Insert an example…</option> | |
| 116 | + {ENDPOINTS.map((ep) => ( | |
| 117 | + <option key={ep.path} value={ep.example}>{ep.path}</option> | |
| 118 | + ))} | |
| 119 | + </select> | |
| 120 | + <input | |
| 121 | + type="text" | |
| 122 | + value={query} | |
| 123 | + aria-label="Request path" | |
| 124 | + onChange={(e) => setQuery(e.target.value)} | |
| 125 | + onKeyDown={(e) => e.key === "Enter" && run()} | |
| 126 | + style={{ | |
| 127 | + flex: "1 1 340px", background: "var(--surface-1)", | |
| 128 | + color: "var(--text-primary)", | |
| 129 | + border: "1px solid var(--border-strong)", borderRadius: 10, | |
| 130 | + padding: "8px 12px", fontSize: 13.5, | |
| 131 | + fontFamily: "ui-monospace, monospace", | |
| 132 | + }} | |
| 133 | + /> | |
| 134 | + <button className="primary" onClick={run} disabled={running}> | |
| 135 | + {running ? "Running…" : "Run ▸"} | |
| 136 | + </button> | |
| 137 | + </div> | |
| 138 | + {status && ( | |
| 139 | + <p className="note" style={{ margin: "10px 0 6px" }}>{status}</p> | |
| 140 | + )} | |
| 141 | + {result && ( | |
| 142 | + <pre style={{ | |
| 143 | + background: "var(--surface-2)", borderRadius: 10, padding: 14, | |
| 144 | + fontSize: 12, maxHeight: 380, overflow: "auto", margin: "8px 0 0", | |
| 145 | + }}>{result}</pre> | |
| 146 | + )} | |
| 147 | + </div> | |
| 148 | + | |
| 149 | + <h2>Endpoints</h2> | |
| 150 | + <div className="grid" style={{ gap: 12 }}> | |
| 151 | + {ENDPOINTS.map((ep) => { | |
| 152 | + const curl = `curl "${API_BASE}${ep.example}"`; | |
| 153 | + return ( | |
| 154 | + <div className="card hoverable" key={ep.path} | |
| 155 | + style={{ padding: "14px 18px" }}> | |
| 156 | + <div style={{ display: "flex", justifyContent: "space-between", | |
| 157 | + alignItems: "baseline", gap: 10, flexWrap: "wrap" }}> | |
| 158 | + <code style={{ fontSize: 14, fontWeight: 700 }}>{ep.path}</code> | |
| 159 | + <span style={{ display: "flex", gap: 8 }}> | |
| 160 | + <button className="ctrl" style={{ padding: "4px 10px", fontSize: 12.5 }} | |
| 161 | + onClick={() => setQuery(ep.example)}> | |
| 162 | + Try ↑ | |
| 163 | + </button> | |
| 164 | + <CopyButton text={curl} /> | |
| 165 | + </span> | |
| 166 | + </div> | |
| 167 | + <p className="note" style={{ margin: "6px 0" }}>{ep.desc}</p> | |
| 168 | + <code style={{ fontSize: 12, color: "var(--text-secondary)", | |
| 169 | + display: "block", overflowX: "auto" }}> | |
| 170 | + {curl} | |
| 171 | + </code> | |
| 172 | + </div> | |
| 173 | + ); | |
| 174 | + })} | |
| 175 | + </div> | |
| 176 | + </> | |
| 177 | + ); | |
| 178 | +} | |
added
web/app/compare/page.tsx
+293 −0
@@ -0,0 +1,293 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/app/compare/page.tsx | |
| 7 | + * Purpose : Compare — multi-series chart in index points, dollar values, or | |
| 8 | + * rebased to a common week (max 5 series, fixed hue order). | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +"use client"; | |
| 12 | + | |
| 13 | +import { Suspense, useEffect, useMemo, useState } from "react"; | |
| 14 | +import { usePathname, useRouter, useSearchParams } from "next/navigation"; | |
| 15 | +import { | |
| 16 | + CartesianGrid, | |
| 17 | + Line, | |
| 18 | + LineChart, | |
| 19 | + Legend, | |
| 20 | + ResponsiveContainer, | |
| 21 | + Tooltip, | |
| 22 | + XAxis, | |
| 23 | + YAxis, | |
| 24 | +} from "recharts"; | |
| 25 | +import { | |
| 26 | + fetchGeographies, | |
| 27 | + fetchSeries, | |
| 28 | + reportUrl, | |
| 29 | + type GeographyNode, | |
| 30 | + type Observation, | |
| 31 | +} from "../../lib/api"; | |
| 32 | + | |
| 33 | +const SERIES_COLORS = [ | |
| 34 | + "var(--series-1)", "var(--series-2)", "var(--series-3)", | |
| 35 | + "var(--series-4)", "var(--series-5)", | |
| 36 | +]; | |
| 37 | +const TYPES = ["all", "unifamilial", "condo", "plex"]; | |
| 38 | +const DEFAULT = ["montreal:condo", "quebec-city:condo", "gatineau:condo"]; | |
| 39 | + | |
| 40 | +type Mode = "index" | "dollars" | "rebased"; | |
| 41 | + | |
| 42 | +function moneyCompact(v: number): string { | |
| 43 | + return v >= 1_000_000 | |
| 44 | + ? "$" + (v / 1_000_000).toFixed(2) + "M" | |
| 45 | + : "$" + Math.round(v / 1000) + "k"; | |
| 46 | +} | |
| 47 | + | |
| 48 | +function CompareInner() { | |
| 49 | + const params = useSearchParams(); | |
| 50 | + const router = useRouter(); | |
| 51 | + const pathname = usePathname(); | |
| 52 | + const initSeries = params.get("series")?.split(",").filter(Boolean); | |
| 53 | + const [geos, setGeos] = useState<GeographyNode[]>([]); | |
| 54 | + const [picked, setPicked] = useState<string[]>( | |
| 55 | + initSeries?.length ? initSeries.slice(0, 5) : DEFAULT); | |
| 56 | + const [hidden, setHidden] = useState<Set<string>>(new Set()); | |
| 57 | + const [pendingGeo, setPendingGeo] = useState("laval"); | |
| 58 | + const [pendingType, setPendingType] = useState("condo"); | |
| 59 | + const [mode, setMode] = useState<Mode>( | |
| 60 | + (["index", "dollars", "rebased"] as readonly string[]).includes( | |
| 61 | + params.get("mode") ?? "") ? (params.get("mode") as Mode) : "index"); | |
| 62 | + const [rebase, setRebase] = useState<string>(params.get("rebase") ?? "2022-01"); | |
| 63 | + const [obs, setObs] = useState<Record<string, Observation[]>>({}); | |
| 64 | + const [error, setError] = useState<string | null>(null); | |
| 65 | + | |
| 66 | + // Shareable URL | |
| 67 | + useEffect(() => { | |
| 68 | + const q = new URLSearchParams({ series: picked.join(",") }); | |
| 69 | + if (mode !== "index") q.set("mode", mode); | |
| 70 | + if (mode === "rebased") q.set("rebase", rebase); | |
| 71 | + router.replace(`${pathname}?${q.toString()}`, { scroll: false }); | |
| 72 | + }, [picked, mode, rebase, router, pathname]); | |
| 73 | + | |
| 74 | + useEffect(() => { | |
| 75 | + fetchGeographies() | |
| 76 | + .then((g) => setGeos(g.published_series)) | |
| 77 | + .catch((e) => setError(String(e))); | |
| 78 | + }, []); | |
| 79 | + | |
| 80 | + useEffect(() => { | |
| 81 | + let alive = true; | |
| 82 | + Promise.all( | |
| 83 | + picked.map(async (p) => { | |
| 84 | + const [g, t] = p.split(":"); | |
| 85 | + return [p, (await fetchSeries(g, t)).observations] as const; | |
| 86 | + }), | |
| 87 | + ) | |
| 88 | + .then((pairs) => { | |
| 89 | + if (!alive) return; | |
| 90 | + setObs(Object.fromEntries(pairs)); | |
| 91 | + setError(null); | |
| 92 | + }) | |
| 93 | + .catch((e) => setError(String(e))); | |
| 94 | + return () => { alive = false; }; | |
| 95 | + }, [picked]); | |
| 96 | + | |
| 97 | + const rows = useMemo(() => { | |
| 98 | + const byWeek: Record<string, Record<string, unknown>> = {}; | |
| 99 | + for (const [name, series] of Object.entries(obs)) { | |
| 100 | + if (!picked.includes(name)) continue; | |
| 101 | + let baseVal = 1; | |
| 102 | + if (mode === "rebased") { | |
| 103 | + const b = series.find((o) => o.period === rebase); | |
| 104 | + if (!b) continue; | |
| 105 | + baseVal = b.index_smoothed; | |
| 106 | + } | |
| 107 | + for (const o of series) { | |
| 108 | + const v = | |
| 109 | + mode === "dollars" | |
| 110 | + ? o.representative_value | |
| 111 | + : mode === "rebased" | |
| 112 | + ? (o.index_smoothed / baseVal) * 100 | |
| 113 | + : o.index_smoothed; | |
| 114 | + if (v == null) continue; | |
| 115 | + (byWeek[o.period] ??= { week: o.period })[name] = Math.round(v * 100) / 100; | |
| 116 | + } | |
| 117 | + } | |
| 118 | + return Object.values(byWeek).sort((a, b) => | |
| 119 | + String(a.week).localeCompare(String(b.week))); | |
| 120 | + }, [obs, picked, mode, rebase]); | |
| 121 | + | |
| 122 | + const nameOf = useMemo(() => { | |
| 123 | + const m = new Map(geos.map((g) => [g.geography_id, g.geography_name])); | |
| 124 | + return (spec: string) => { | |
| 125 | + const [g, t] = spec.split(":"); | |
| 126 | + return `${m.get(g) ?? g} · ${t}`; | |
| 127 | + }; | |
| 128 | + }, [geos]); | |
| 129 | + | |
| 130 | + const add = () => { | |
| 131 | + const spec = `${pendingGeo}:${pendingType}`; | |
| 132 | + if (!picked.includes(spec) && picked.length < 5) setPicked([...picked, spec]); | |
| 133 | + }; | |
| 134 | + | |
| 135 | + return ( | |
| 136 | + <> | |
| 137 | + <h1>Compare series</h1> | |
| 138 | + <p className="lede"> | |
| 139 | + Up to five series; colors keep their series when you add or remove. | |
| 140 | + View index levels, dollar values, or rebase to a common month. | |
| 141 | + </p> | |
| 142 | + <div className="controls"> | |
| 143 | + <select value={pendingGeo} onChange={(e) => setPendingGeo(e.target.value)} | |
| 144 | + aria-label="Geography to add"> | |
| 145 | + {geos.map((g) => ( | |
| 146 | + <option key={g.geography_id} value={g.geography_id}> | |
| 147 | + {g.geography_name} | |
| 148 | + </option> | |
| 149 | + ))} | |
| 150 | + </select> | |
| 151 | + <select value={pendingType} onChange={(e) => setPendingType(e.target.value)} | |
| 152 | + aria-label="Property type to add"> | |
| 153 | + {TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | |
| 154 | + </select> | |
| 155 | + <button className="ctrl" onClick={add} disabled={picked.length >= 5}> | |
| 156 | + Add series | |
| 157 | + </button> | |
| 158 | + <div className="seg" role="group" aria-label="Comparison mode"> | |
| 159 | + <button aria-pressed={mode === "index"} onClick={() => setMode("index")}> | |
| 160 | + Index | |
| 161 | + </button> | |
| 162 | + <button aria-pressed={mode === "dollars"} onClick={() => setMode("dollars")}> | |
| 163 | + $ value | |
| 164 | + </button> | |
| 165 | + <button aria-pressed={mode === "rebased"} onClick={() => setMode("rebased")}> | |
| 166 | + Rebased | |
| 167 | + </button> | |
| 168 | + </div> | |
| 169 | + {mode === "rebased" && ( | |
| 170 | + <input | |
| 171 | + type="month" | |
| 172 | + value={rebase} | |
| 173 | + aria-label="Rebase month" | |
| 174 | + onChange={(e) => setRebase(e.target.value)} | |
| 175 | + /> | |
| 176 | + )} | |
| 177 | + {picked.length >= 1 && ( | |
| 178 | + <a className="primary" href={reportUrl(picked)} target="_blank" | |
| 179 | + rel="noreferrer"> | |
| 180 | + ⤓ Comparison report (PDF) | |
| 181 | + </a> | |
| 182 | + )} | |
| 183 | + </div> | |
| 184 | + <div className="controls"> | |
| 185 | + {picked.map((s) => ( | |
| 186 | + <button | |
| 187 | + key={s} | |
| 188 | + className="chip" | |
| 189 | + aria-pressed={!hidden.has(s)} | |
| 190 | + style={{ opacity: hidden.has(s) ? 0.4 : 1 }} | |
| 191 | + onClick={() => | |
| 192 | + setHidden((h) => { | |
| 193 | + const n = new Set(h); | |
| 194 | + if (n.has(s)) n.delete(s); | |
| 195 | + else n.add(s); | |
| 196 | + return n; | |
| 197 | + })} | |
| 198 | + title={hidden.has(s) ? "Show series" : "Hide series"} | |
| 199 | + > | |
| 200 | + <span style={{ color: SERIES_COLORS[picked.indexOf(s)] }}>●</span>{" "} | |
| 201 | + {nameOf(s)} | |
| 202 | + <span | |
| 203 | + role="button" | |
| 204 | + aria-label={`Remove ${nameOf(s)}`} | |
| 205 | + style={{ marginLeft: 4, color: "var(--text-muted)" }} | |
| 206 | + onClick={(e) => { | |
| 207 | + e.stopPropagation(); | |
| 208 | + setPicked(picked.filter((p) => p !== s)); | |
| 209 | + }} | |
| 210 | + >✕</span> | |
| 211 | + </button> | |
| 212 | + ))} | |
| 213 | + <span className="note">click a chip to toggle · ✕ removes</span> | |
| 214 | + </div> | |
| 215 | + | |
| 216 | + {error && <div className="card"><span className="note">{error}</span></div>} | |
| 217 | + | |
| 218 | + <div className="card" style={{ height: 440 }}> | |
| 219 | + <ResponsiveContainer> | |
| 220 | + <LineChart data={rows} margin={{ top: 8, right: 12, left: 4, bottom: 0 }}> | |
| 221 | + <CartesianGrid stroke="var(--border)" strokeDasharray="2 4" vertical={false} /> | |
| 222 | + <XAxis | |
| 223 | + dataKey="week" | |
| 224 | + tick={{ fill: "var(--text-muted)", fontSize: 12 }} | |
| 225 | + tickLine={false} | |
| 226 | + axisLine={{ stroke: "var(--border)" }} | |
| 227 | + minTickGap={70} | |
| 228 | + /> | |
| 229 | + <YAxis | |
| 230 | + domain={["auto", "auto"]} | |
| 231 | + tickFormatter={(v: number) => | |
| 232 | + mode === "dollars" ? moneyCompact(v) : String(Math.round(v))} | |
| 233 | + tick={{ fill: "var(--text-muted)", fontSize: 12 }} | |
| 234 | + tickLine={false} | |
| 235 | + axisLine={false} | |
| 236 | + width={mode === "dollars" ? 58 : 48} | |
| 237 | + /> | |
| 238 | + <Tooltip | |
| 239 | + contentStyle={{ | |
| 240 | + background: "var(--surface-1)", | |
| 241 | + border: "1px solid var(--border-strong)", | |
| 242 | + borderRadius: 10, fontSize: 13, | |
| 243 | + }} | |
| 244 | + labelStyle={{ color: "var(--text-secondary)" }} | |
| 245 | + formatter={(v: number, name: string) => [ | |
| 246 | + mode === "dollars" | |
| 247 | + ? "$" + Math.round(v).toLocaleString("en-CA") | |
| 248 | + : v.toFixed(1), | |
| 249 | + nameOf(name), | |
| 250 | + ]} | |
| 251 | + /> | |
| 252 | + <Legend | |
| 253 | + formatter={(v: string) => ( | |
| 254 | + <span style={{ color: "var(--text-secondary)", fontSize: 13 }}> | |
| 255 | + {nameOf(v)} | |
| 256 | + </span> | |
| 257 | + )} | |
| 258 | + /> | |
| 259 | + {picked.map((s, i) => ( | |
| 260 | + <Line | |
| 261 | + key={s} | |
| 262 | + dataKey={s} | |
| 263 | + stroke={SERIES_COLORS[i]} | |
| 264 | + strokeWidth={2} | |
| 265 | + dot={false} | |
| 266 | + hide={hidden.has(s)} | |
| 267 | + isAnimationActive | |
| 268 | + animationDuration={700} | |
| 269 | + animationEasing="ease-out" | |
| 270 | + connectNulls | |
| 271 | + /> | |
| 272 | + ))} | |
| 273 | + </LineChart> | |
| 274 | + </ResponsiveContainer> | |
| 275 | + </div> | |
| 276 | + <p className="note"> | |
| 277 | + {mode === "rebased" | |
| 278 | + ? `All series = 100 at ${rebase}.` | |
| 279 | + : mode === "dollars" | |
| 280 | + ? "Representative dollar value per series (fixed 2021 basket carried by the index)." | |
| 281 | + : "Levels shown on each series' own 2021 = 100 base."} | |
| 282 | + </p> | |
| 283 | + </> | |
| 284 | + ); | |
| 285 | +} | |
| 286 | + | |
| 287 | +export default function Compare() { | |
| 288 | + return ( | |
| 289 | + <Suspense fallback={null}> | |
| 290 | + <CompareInner /> | |
| 291 | + </Suspense> | |
| 292 | + ); | |
| 293 | +} | |
added
web/app/explore/page.tsx
+310 −0
@@ -0,0 +1,310 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/app/explore/page.tsx | |
| 7 | + * Purpose : Explore — series picker, index chart with CI band, raw overlay | |
| 8 | + * toggle, growth horizons, volume subchart, CSV download. | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +"use client"; | |
| 12 | + | |
| 13 | +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; | |
| 14 | +import { usePathname, useRouter, useSearchParams } from "next/navigation"; | |
| 15 | +import { | |
| 16 | + csvUrl, | |
| 17 | + fetchGeographies, | |
| 18 | + fetchSeries, | |
| 19 | + fetchStats, | |
| 20 | + reportUrl, | |
| 21 | + type GeographyNode, | |
| 22 | + type SeriesResponse, | |
| 23 | + type SeriesStats, | |
| 24 | +} from "../../lib/api"; | |
| 25 | +import dynamic from "next/dynamic"; | |
| 26 | +import type { ChartUnit } from "../../components/IndexChart"; | |
| 27 | +import { ChartSkeleton, DashboardSkeleton } from "../../components/Skeleton"; | |
| 28 | +import DataTable from "../../components/DataTable"; | |
| 29 | +import { MARKET_EVENTS } from "../../lib/events"; | |
| 30 | +import { exportChartPng } from "../../lib/exportPng"; | |
| 31 | + | |
| 32 | +const IndexChart = dynamic(() => import("../../components/IndexChart"), { | |
| 33 | + ssr: false, | |
| 34 | + loading: () => <ChartSkeleton height={430} />, | |
| 35 | +}); | |
| 36 | +import ReliabilityBadge from "../../components/ReliabilityBadge"; | |
| 37 | + | |
| 38 | +const TYPES = ["all", "unifamilial", "condo", "plex"]; | |
| 39 | +const RANGES = ["1y", "3y", "5y", "ytd", "max"] as const; | |
| 40 | +type Range = (typeof RANGES)[number]; | |
| 41 | + | |
| 42 | +function rangeCutoff(range: Range, lastPeriod: string): string | null { | |
| 43 | + if (range === "max") return null; | |
| 44 | + const [y, m] = lastPeriod.split("-").map(Number); | |
| 45 | + if (range === "ytd") return `${y}-01`; | |
| 46 | + const months = range === "1y" ? 12 : range === "3y" ? 36 : 60; | |
| 47 | + const total = y * 12 + (m - 1) - months; | |
| 48 | + const cy = Math.floor(total / 12); | |
| 49 | + const cm = (total % 12) + 1; | |
| 50 | + return `${cy}-${String(cm).padStart(2, "0")}`; | |
| 51 | +} | |
| 52 | +const HORIZONS: { key: keyof HorizonRow; label: string }[] = [ | |
| 53 | + { key: "monthly_pct", label: "1m" }, | |
| 54 | + { key: "three_month_pct", label: "3m" }, | |
| 55 | + { key: "six_month_pct", label: "6m" }, | |
| 56 | + { key: "yoy_pct", label: "YoY" }, | |
| 57 | +]; | |
| 58 | +type HorizonRow = { | |
| 59 | + monthly_pct: number | null; | |
| 60 | + three_month_pct: number | null; | |
| 61 | + six_month_pct: number | null; | |
| 62 | + yoy_pct: number | null; | |
| 63 | +}; | |
| 64 | + | |
| 65 | +function ExploreInner() { | |
| 66 | + const params = useSearchParams(); | |
| 67 | + const router = useRouter(); | |
| 68 | + const pathname = usePathname(); | |
| 69 | + const [geos, setGeos] = useState<GeographyNode[]>([]); | |
| 70 | + const [geo, setGeo] = useState(params.get("geography") ?? "quebec"); | |
| 71 | + const [ptype, setPtype] = useState(params.get("type") ?? "all"); | |
| 72 | + const [series, setSeries] = useState<SeriesResponse | null>(null); | |
| 73 | + const [stats, setStats] = useState<SeriesStats | null>(null); | |
| 74 | + const [showRaw, setShowRaw] = useState(false); | |
| 75 | + const [unit, setUnit] = useState<ChartUnit>( | |
| 76 | + params.get("unit") === "dollars" ? "dollars" : "points"); | |
| 77 | + const [range, setRange] = useState<Range>( | |
| 78 | + (RANGES as readonly string[]).includes(params.get("range") ?? "") | |
| 79 | + ? (params.get("range") as Range) : "max"); | |
| 80 | + const [showEvents, setShowEvents] = useState(params.get("events") === "1"); | |
| 81 | + const [showTable, setShowTable] = useState(false); | |
| 82 | + const chartRef = useRef<HTMLDivElement>(null); | |
| 83 | + const [error, setError] = useState<string | null>(null); | |
| 84 | + | |
| 85 | + // Shareable URL: selection encoded in query params. | |
| 86 | + useEffect(() => { | |
| 87 | + const q = new URLSearchParams({ geography: geo, type: ptype }); | |
| 88 | + if (unit === "dollars") q.set("unit", "dollars"); | |
| 89 | + if (range !== "max") q.set("range", range); | |
| 90 | + if (showEvents) q.set("events", "1"); | |
| 91 | + router.replace(`${pathname}?${q.toString()}`, { scroll: false }); | |
| 92 | + }, [geo, ptype, unit, range, showEvents, router, pathname]); | |
| 93 | + | |
| 94 | + useEffect(() => { | |
| 95 | + fetchGeographies() | |
| 96 | + .then((g) => setGeos(g.published_series)) | |
| 97 | + .catch((e) => setError(String(e))); | |
| 98 | + }, []); | |
| 99 | + | |
| 100 | + useEffect(() => { | |
| 101 | + setSeries(null); | |
| 102 | + setStats(null); | |
| 103 | + fetchSeries(geo, ptype) | |
| 104 | + .then(setSeries) | |
| 105 | + .catch((e) => setError(String(e))); | |
| 106 | + fetchStats(geo, ptype).then(setStats).catch(() => setStats(null)); | |
| 107 | + }, [geo, ptype]); | |
| 108 | + | |
| 109 | + const grouped = useMemo(() => { | |
| 110 | + const by: Record<string, GeographyNode[]> = {}; | |
| 111 | + for (const g of geos) (by[g.geography_level] ??= []).push(g); | |
| 112 | + return by; | |
| 113 | + }, [geos]); | |
| 114 | + | |
| 115 | + if (error) | |
| 116 | + return ( | |
| 117 | + <div className="card" style={{ marginTop: 32 }}> | |
| 118 | + <strong>API unreachable.</strong> <span className="note">{error}</span> | |
| 119 | + </div> | |
| 120 | + ); | |
| 121 | + | |
| 122 | + const last = series?.observations.filter((o) => !o.is_partial_month).at(-1); | |
| 123 | + | |
| 124 | + const visibleObs = useMemo(() => { | |
| 125 | + if (!series) return []; | |
| 126 | + const obs = series.observations; | |
| 127 | + const cutoff = obs.length | |
| 128 | + ? rangeCutoff(range, obs[obs.length - 1].period) : null; | |
| 129 | + return cutoff ? obs.filter((o) => o.period >= cutoff) : obs; | |
| 130 | + }, [series, range]); | |
| 131 | + | |
| 132 | + return ( | |
| 133 | + <> | |
| 134 | + <h1>Explore a series</h1> | |
| 135 | + <div className="controls"> | |
| 136 | + <label htmlFor="geo">Geography</label> | |
| 137 | + <select id="geo" value={geo} onChange={(e) => setGeo(e.target.value)}> | |
| 138 | + {(["province", "region", "municipality"] as const).map((lvl) => ( | |
| 139 | + <optgroup key={lvl} label={lvl}> | |
| 140 | + {(grouped[lvl] ?? []).map((g) => ( | |
| 141 | + <option key={g.geography_id} value={g.geography_id}> | |
| 142 | + {g.geography_name} | |
| 143 | + </option> | |
| 144 | + ))} | |
| 145 | + </optgroup> | |
| 146 | + ))} | |
| 147 | + </select> | |
| 148 | + <label htmlFor="ptype">Type</label> | |
| 149 | + <select id="ptype" value={ptype} onChange={(e) => setPtype(e.target.value)}> | |
| 150 | + {TYPES.map((t) => ( | |
| 151 | + <option key={t} value={t}>{t}</option> | |
| 152 | + ))} | |
| 153 | + </select> | |
| 154 | + <div className="seg" role="group" aria-label="Chart unit"> | |
| 155 | + <button aria-pressed={unit === "points"} onClick={() => setUnit("points")}> | |
| 156 | + Index | |
| 157 | + </button> | |
| 158 | + <button aria-pressed={unit === "dollars"} onClick={() => setUnit("dollars")}> | |
| 159 | + $ value | |
| 160 | + </button> | |
| 161 | + </div> | |
| 162 | + <div className="seg" role="group" aria-label="Period range"> | |
| 163 | + {RANGES.map((r) => ( | |
| 164 | + <button key={r} aria-pressed={range === r} onClick={() => setRange(r)}> | |
| 165 | + {r.toUpperCase()} | |
| 166 | + </button> | |
| 167 | + ))} | |
| 168 | + </div> | |
| 169 | + <button | |
| 170 | + className="ctrl" | |
| 171 | + aria-pressed={showRaw} | |
| 172 | + onClick={() => setShowRaw((v) => !v)} | |
| 173 | + > | |
| 174 | + Raw monthly overlay | |
| 175 | + </button> | |
| 176 | + <button | |
| 177 | + className="ctrl" | |
| 178 | + aria-pressed={showEvents} | |
| 179 | + onClick={() => setShowEvents((v) => !v)} | |
| 180 | + > | |
| 181 | + Events | |
| 182 | + </button> | |
| 183 | + <button | |
| 184 | + className="ctrl" | |
| 185 | + onClick={() => | |
| 186 | + chartRef.current && | |
| 187 | + exportChartPng(chartRef.current, `qhpi_${geo}_${ptype}.png`)} | |
| 188 | + > | |
| 189 | + ⤓ PNG | |
| 190 | + </button> | |
| 191 | + <a className="ctrl" href={csvUrl(geo, ptype)}> | |
| 192 | + ⤓ CSV | |
| 193 | + </a> | |
| 194 | + <a className="primary" href={reportUrl([`${geo}:${ptype}`])} | |
| 195 | + target="_blank" rel="noreferrer"> | |
| 196 | + ⤓ PDF report | |
| 197 | + </a> | |
| 198 | + </div> | |
| 199 | + | |
| 200 | + {!series ? ( | |
| 201 | + <DashboardSkeleton /> | |
| 202 | + ) : ( | |
| 203 | + <> | |
| 204 | + <div className="card fade-up"> | |
| 205 | + <div style={{ display: "flex", justifyContent: "space-between", flexWrap: "wrap", gap: 8 }}> | |
| 206 | + <strong> | |
| 207 | + {series.geography_name} · {series.property_type} ·{" "} | |
| 208 | + {unit === "dollars" | |
| 209 | + ? "representative dollar value" | |
| 210 | + : "base 2021 = 100"} | |
| 211 | + </strong> | |
| 212 | + {last && <ReliabilityBadge grade={last.reliability_grade} />} | |
| 213 | + </div> | |
| 214 | + {last && (last.reliability_grade === "D" || last.reliability_grade === "E") && ( | |
| 215 | + <p className="low-reliability-note"> | |
| 216 | + Thin market: monthly movements are heavily shrunk toward the | |
| 217 | + parent trend. Read levels and multi-month changes, not | |
| 218 | + single months. | |
| 219 | + </p> | |
| 220 | + )} | |
| 221 | + <div ref={chartRef}> | |
| 222 | + <IndexChart observations={visibleObs} showRaw={showRaw} | |
| 223 | + unit={unit} | |
| 224 | + annotations={showEvents ? MARKET_EVENTS : []} | |
| 225 | + showBrush={range === "max"} /> | |
| 226 | + </div> | |
| 227 | + <div className="controls" style={{ margin: "8px 0 0" }}> | |
| 228 | + <button className="ctrl" aria-pressed={showTable} | |
| 229 | + aria-expanded={showTable} | |
| 230 | + onClick={() => setShowTable((v) => !v)}> | |
| 231 | + {showTable ? "Hide data table" : "Data table"} | |
| 232 | + </button> | |
| 233 | + </div> | |
| 234 | + {showTable && <DataTable observations={visibleObs} />} | |
| 235 | + </div> | |
| 236 | + | |
| 237 | + {last && ( | |
| 238 | + <> | |
| 239 | + <h2>Growth (month of {last.period})</h2> | |
| 240 | + <div className="grid cols-4"> | |
| 241 | + {HORIZONS.map(({ key, label }) => { | |
| 242 | + const v = last[key]; | |
| 243 | + return ( | |
| 244 | + <div className="card stat-tile" key={key}> | |
| 245 | + <span className="label">{label}</span> | |
| 246 | + <span | |
| 247 | + className={ | |
| 248 | + "value " + (v != null && v < 0 ? "delta down" : "delta up") | |
| 249 | + } | |
| 250 | + style={{ fontSize: 22 }} | |
| 251 | + > | |
| 252 | + {v == null ? "—" : `${v > 0 ? "+" : ""}${v.toFixed(2)}%`} | |
| 253 | + </span> | |
| 254 | + </div> | |
| 255 | + ); | |
| 256 | + })} | |
| 257 | + <div className="card stat-tile"> | |
| 258 | + <span className="label">Representative value</span> | |
| 259 | + <span className="value" style={{ fontSize: 22 }}> | |
| 260 | + {last.representative_value | |
| 261 | + ? `$${Math.round(last.representative_value).toLocaleString()}` | |
| 262 | + : "—"} | |
| 263 | + </span> | |
| 264 | + <span className="note"> | |
| 265 | + n={last.transactions} · eff. N≈ | |
| 266 | + {last.effective_sample_size?.toFixed(0) ?? "—"} | |
| 267 | + </span> | |
| 268 | + </div> | |
| 269 | + </div> | |
| 270 | + {stats && ( | |
| 271 | + <> | |
| 272 | + <h2>Structure & risk</h2> | |
| 273 | + <div className="grid cols-8"> | |
| 274 | + {([ | |
| 275 | + ["Since 2021", `${stats.since_2021_pct > 0 ? "+" : ""}${stats.since_2021_pct.toFixed(1)}%`], | |
| 276 | + ["CAGR", `${stats.cagr_pct > 0 ? "+" : ""}${stats.cagr_pct.toFixed(2)}%`], | |
| 277 | + ["Peak", `${stats.peak_index.toFixed(1)} (${stats.peak_period})`], | |
| 278 | + ["Vs peak", stats.at_record_high ? "★ at peak" : `${stats.drawdown_pct.toFixed(2)}%`], | |
| 279 | + ["Volatility 12m", `${stats.volatility_12m_pct.toFixed(2)}%`], | |
| 280 | + ["Momentum 3m ann.", `${stats.momentum_3m_ann_pct > 0 ? "+" : ""}${stats.momentum_3m_ann_pct.toFixed(1)}%`], | |
| 281 | + ["YoY rank", stats.yoy_rank ? `${stats.yoy_rank}/${stats.yoy_rank_of}` : "—"], | |
| 282 | + ["Sales 12m", stats.volume_12m.toLocaleString()], | |
| 283 | + ["Volume YoY", stats.volume_yoy_pct == null ? "—" : `${stats.volume_yoy_pct > 0 ? "+" : ""}${stats.volume_yoy_pct.toFixed(1)}%`], | |
| 284 | + ["$ volume 12m", `$${(stats.dollar_volume_12m / 1e9).toFixed(2)}B`], | |
| 285 | + ["Assessment gap", stats.assessment_gap == null ? "—" : `${stats.assessment_gap.toFixed(2)}×`], | |
| 286 | + ["Effective N", stats.effective_sample_size?.toFixed(0) ?? "—"], | |
| 287 | + ] as [string, string][]).map(([label, val]) => ( | |
| 288 | + <div className="card stat-tile mini-tile" key={label}> | |
| 289 | + <span className="label">{label}</span> | |
| 290 | + <span className="value" style={{ fontSize: 17 }}>{val}</span> | |
| 291 | + </div> | |
| 292 | + ))} | |
| 293 | + </div> | |
| 294 | + </> | |
| 295 | + )} | |
| 296 | + </> | |
| 297 | + )} | |
| 298 | + </> | |
| 299 | + )} | |
| 300 | + </> | |
| 301 | + ); | |
| 302 | +} | |
| 303 | + | |
| 304 | +export default function Explore() { | |
| 305 | + return ( | |
| 306 | + <Suspense fallback={<DashboardSkeleton />}> | |
| 307 | + <ExploreInner /> | |
| 308 | + </Suspense> | |
| 309 | + ); | |
| 310 | +} | |
added
web/app/icon.svg
+10 −0
@@ -0,0 +1,10 @@ | ||
| 1 | +<!-- ============================================================================= | |
| 2 | +QWHPI - Quebec Weekly Housing Price Index | |
| 3 | +Author : Simon-Pierre Boucher | Contact : contact@spboucher.ai | |
| 4 | +File : web/app/icon.svg | Purpose : Favicon (brand chart mark). | |
| 5 | +============================================================================== --> | |
| 6 | +<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 22 22"> | |
| 7 | + <rect x="1" y="1" width="20" height="20" rx="5" fill="#2a78d6"/> | |
| 8 | + <path d="M5 14.5 L9 10.5 L12 12.5 L17 6.5" fill="none" stroke="#fff" | |
| 9 | + stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> | |
| 10 | +</svg> | |
added
web/app/layout.tsx
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/app/layout.tsx | |
| 7 | + * Purpose : Root layout — fonts, navigation, theme toggle, author footer. | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | +import type { Metadata } from "next"; | |
| 11 | +import Link from "next/link"; | |
| 12 | +import { Fraunces, Inter } from "next/font/google"; | |
| 13 | +import "../styles/globals.css"; | |
| 14 | +import ThemeToggle from "../components/ThemeToggle"; | |
| 15 | +import CommandPalette from "../components/CommandPalette"; | |
| 16 | +import SearchButton from "../components/SearchButton"; | |
| 17 | + | |
| 18 | +const inter = Inter({ subsets: ["latin"], variable: "--font-sans" }); | |
| 19 | +const fraunces = Fraunces({ | |
| 20 | + subsets: ["latin"], | |
| 21 | + variable: "--font-display", | |
| 22 | + weight: ["600", "700"], | |
| 23 | +}); | |
| 24 | + | |
| 25 | +export const metadata: Metadata = { | |
| 26 | + metadataBase: new URL("https://www.indexqc.house"), | |
| 27 | + title: { | |
| 28 | + default: "QHPI — Quebec Housing Price Index (monthly)", | |
| 29 | + template: "%s · QHPI", | |
| 30 | + }, | |
| 31 | + description: | |
| 32 | + "Quality-adjusted monthly housing price indexes for Quebec — province, " + | |
| 33 | + "17 regions and 10 cities, with confidence intervals on every point. " + | |
| 34 | + "Author: Simon-Pierre Boucher — contact@spboucher.ai", | |
| 35 | + authors: [{ name: "Simon-Pierre Boucher" }], | |
| 36 | + alternates: { canonical: "/" }, | |
| 37 | + openGraph: { | |
| 38 | + title: "QHPI — Quebec Housing Price Index", | |
| 39 | + description: | |
| 40 | + "Quebec housing prices, measured monthly, robustly. " + | |
| 41 | + "Uncertainty always displayed, never hidden.", | |
| 42 | + url: "https://www.indexqc.house", | |
| 43 | + siteName: "QHPI", | |
| 44 | + type: "website", | |
| 45 | + locale: "en_CA", | |
| 46 | + }, | |
| 47 | + twitter: { | |
| 48 | + card: "summary_large_image", | |
| 49 | + title: "QHPI — Quebec Housing Price Index", | |
| 50 | + description: "Quebec housing prices, measured monthly, robustly.", | |
| 51 | + }, | |
| 52 | +}; | |
| 53 | + | |
| 54 | +export default function RootLayout({ | |
| 55 | + children, | |
| 56 | +}: { | |
| 57 | + children: React.ReactNode; | |
| 58 | +}) { | |
| 59 | + return ( | |
| 60 | + <html lang="en" suppressHydrationWarning | |
| 61 | + className={`${inter.variable} ${fraunces.variable}`}> | |
| 62 | + <body> | |
| 63 | + <header className="site-header"> | |
| 64 | + <Link href="/" className="brand"> | |
| 65 | + <svg className="brand-mark" width="22" height="22" viewBox="0 0 22 22" | |
| 66 | + aria-hidden focusable="false"> | |
| 67 | + <rect x="1" y="1" width="20" height="20" rx="5" | |
| 68 | + fill="var(--series-1)" /> | |
| 69 | + <path d="M5 14.5 L9 10.5 L12 12.5 L17 6.5" fill="none" | |
| 70 | + stroke="#fff" strokeWidth="2" strokeLinecap="round" | |
| 71 | + strokeLinejoin="round" /> | |
| 72 | + </svg> | |
| 73 | + QHPI<small>Quebec Housing Price Index · monthly</small> | |
| 74 | + </Link> | |
| 75 | + <nav aria-label="Main"> | |
| 76 | + <Link href="/">Overview</Link> | |
| 77 | + <Link href="/explore">Explore</Link> | |
| 78 | + <Link href="/compare">Compare</Link> | |
| 79 | + <Link href="/map">Map</Link> | |
| 80 | + <Link href="/methodology">Methodology</Link> | |
| 81 | + <Link href="/api-docs">API</Link> | |
| 82 | + </nav> | |
| 83 | + <div style={{ display: "flex", gap: 8 }}> | |
| 84 | + <SearchButton /> | |
| 85 | + <ThemeToggle /> | |
| 86 | + </div> | |
| 87 | + </header> | |
| 88 | + <CommandPalette /> | |
| 89 | + <main className="container">{children}</main> | |
| 90 | + <footer className="site-footer"> | |
| 91 | + Simon-Pierre Boucher — contact@spboucher.ai · Monthly hedonic index, | |
| 92 | + base 2021 average = 100 · Uncertainty is always displayed, never | |
| 93 | + hidden. | |
| 94 | + </footer> | |
| 95 | + </body> | |
| 96 | + </html> | |
| 97 | + ); | |
| 98 | +} | |
added
web/app/map/page.tsx
+241 −0
@@ -0,0 +1,241 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/app/map/page.tsx | |
| 7 | + * Purpose : Map — choropleth of the 17 administrative regions (YoY, 13w, | |
| 8 | + * index level, assessment gap) rendered as inline SVG. | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +"use client"; | |
| 12 | + | |
| 13 | +import { useEffect, useMemo, useRef, useState } from "react"; | |
| 14 | +import { fetchMap, fetchSeries, type MapCell } from "../../lib/api"; | |
| 15 | + | |
| 16 | +const METRICS = [ | |
| 17 | + { id: "yoy", label: "YoY %" }, | |
| 18 | + { id: "six_month", label: "6-month %" }, | |
| 19 | + { id: "three_month", label: "3-month %" }, | |
| 20 | + { id: "index_level", label: "Index level" }, | |
| 21 | + { id: "assessment_gap", label: "Assessment gap" }, | |
| 22 | +]; | |
| 23 | +const TYPES = ["all", "unifamilial", "condo", "plex"]; | |
| 24 | + | |
| 25 | +// Sequential blue ramp (light→dark) from the validated palette. | |
| 26 | +const RAMP = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"]; | |
| 27 | + | |
| 28 | +interface Feature { | |
| 29 | + properties: { geography_id: string; name: string }; | |
| 30 | + geometry: { type: string; coordinates: number[][][] | number[][][][] }; | |
| 31 | +} | |
| 32 | + | |
| 33 | +function project([lng, lat]: number[], w: number, h: number): [number, number] { | |
| 34 | + // Quebec bbox in EPSG:4326; simple equirectangular with cos(lat0) x-scale. | |
| 35 | + const [minLng, maxLng, minLat, maxLat] = [-79.9, -56.9, 44.9, 62.8]; | |
| 36 | + const k = Math.cos((52 * Math.PI) / 180); | |
| 37 | + const spanX = (maxLng - minLng) * k; | |
| 38 | + const spanY = maxLat - minLat; | |
| 39 | + const s = Math.min(w / spanX, h / spanY); | |
| 40 | + const x = ((lng - minLng) * k) * s; | |
| 41 | + const y = h - (lat - minLat) * s - (h - spanY * s) / 2; | |
| 42 | + return [x, y]; | |
| 43 | +} | |
| 44 | + | |
| 45 | +function ringsOf(f: Feature): number[][][] { | |
| 46 | + return f.geometry.type === "Polygon" | |
| 47 | + ? (f.geometry.coordinates as number[][][]) | |
| 48 | + : (f.geometry.coordinates as number[][][][]).flat(); | |
| 49 | +} | |
| 50 | + | |
| 51 | +function pathFor(f: Feature, w: number, h: number): string { | |
| 52 | + return ringsOf(f) | |
| 53 | + .map((ring) => { | |
| 54 | + const pts = ring.map((c) => project(c, w, h)); | |
| 55 | + return "M" + pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join("L") + "Z"; | |
| 56 | + }) | |
| 57 | + .join(" "); | |
| 58 | +} | |
| 59 | + | |
| 60 | +export default function MapPage() { | |
| 61 | + const [metric, setMetric] = useState("yoy"); | |
| 62 | + const [ptype, setPtype] = useState("all"); | |
| 63 | + const [cells, setCells] = useState<MapCell[]>([]); | |
| 64 | + const [periods, setPeriods] = useState<string[]>([]); | |
| 65 | + const [idx, setIdx] = useState(0); | |
| 66 | + const [playing, setPlaying] = useState(false); | |
| 67 | + const [features, setFeatures] = useState<Feature[]>([]); | |
| 68 | + const [hover, setHover] = useState<MapCell | null>(null); | |
| 69 | + const [error, setError] = useState<string | null>(null); | |
| 70 | + const playRef = useRef<ReturnType<typeof setInterval> | null>(null); | |
| 71 | + | |
| 72 | + useEffect(() => { | |
| 73 | + fetch("/regions.geojson") | |
| 74 | + .then((r) => r.json()) | |
| 75 | + .then((g) => setFeatures(g.features)) | |
| 76 | + .catch((e) => setError(String(e))); | |
| 77 | + fetchSeries("quebec", "all") | |
| 78 | + .then((s) => { | |
| 79 | + const ps = s.observations | |
| 80 | + .filter((o) => !o.is_partial_month) | |
| 81 | + .map((o) => o.period); | |
| 82 | + setPeriods(ps); | |
| 83 | + setIdx(ps.length - 1); | |
| 84 | + }) | |
| 85 | + .catch((e) => setError(String(e))); | |
| 86 | + }, []); | |
| 87 | + | |
| 88 | + const period = periods[idx] ?? ""; | |
| 89 | + | |
| 90 | + useEffect(() => { | |
| 91 | + if (!period) return; | |
| 92 | + fetchMap(metric, ptype, period) | |
| 93 | + .then((m) => { | |
| 94 | + setCells(m.cells); | |
| 95 | + setError(null); | |
| 96 | + }) | |
| 97 | + .catch((e) => setError(String(e))); | |
| 98 | + }, [metric, ptype, period]); | |
| 99 | + | |
| 100 | + // Time-lapse playback across all months. | |
| 101 | + useEffect(() => { | |
| 102 | + if (!playing) { | |
| 103 | + if (playRef.current) clearInterval(playRef.current); | |
| 104 | + return; | |
| 105 | + } | |
| 106 | + playRef.current = setInterval(() => { | |
| 107 | + setIdx((i) => { | |
| 108 | + if (i >= periods.length - 1) { | |
| 109 | + setPlaying(false); | |
| 110 | + return i; | |
| 111 | + } | |
| 112 | + return i + 1; | |
| 113 | + }); | |
| 114 | + }, 550); | |
| 115 | + return () => { | |
| 116 | + if (playRef.current) clearInterval(playRef.current); | |
| 117 | + }; | |
| 118 | + }, [playing, periods.length]); | |
| 119 | + | |
| 120 | + const { colorOf, domain } = useMemo(() => { | |
| 121 | + const vals = cells.map((c) => c.value).filter((v): v is number => v != null); | |
| 122 | + const lo = Math.min(...vals); | |
| 123 | + const hi = Math.max(...vals); | |
| 124 | + const f = (v: number | null) => { | |
| 125 | + if (v == null || vals.length === 0) return "var(--surface-2)"; | |
| 126 | + const t = hi === lo ? 0.5 : (v - lo) / (hi - lo); | |
| 127 | + return RAMP[Math.min(RAMP.length - 1, Math.floor(t * RAMP.length))]; | |
| 128 | + }; | |
| 129 | + return { colorOf: f, domain: vals.length ? ([lo, hi] as const) : null }; | |
| 130 | + }, [cells]); | |
| 131 | + | |
| 132 | + const byId = useMemo( | |
| 133 | + () => new Map(cells.map((c) => [c.geography_id, c])), | |
| 134 | + [cells], | |
| 135 | + ); | |
| 136 | + | |
| 137 | + const W = 720, H = 560; | |
| 138 | + const fmt = (v: number | null) => | |
| 139 | + v == null ? "—" : metric === "assessment_gap" ? v.toFixed(2) : v.toFixed(1); | |
| 140 | + | |
| 141 | + return ( | |
| 142 | + <> | |
| 143 | + <h1>Regional map</h1> | |
| 144 | + <div className="controls"> | |
| 145 | + <label>Metric</label> | |
| 146 | + <select value={metric} onChange={(e) => setMetric(e.target.value)}> | |
| 147 | + {METRICS.map((m) => <option key={m.id} value={m.id}>{m.label}</option>)} | |
| 148 | + </select> | |
| 149 | + <label>Type</label> | |
| 150 | + <select value={ptype} onChange={(e) => setPtype(e.target.value)}> | |
| 151 | + {TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | |
| 152 | + </select> | |
| 153 | + <button className="ctrl" aria-pressed={playing} | |
| 154 | + aria-label={playing ? "Pause time-lapse" : "Play time-lapse"} | |
| 155 | + onClick={() => { | |
| 156 | + if (!playing && idx >= periods.length - 1) setIdx(12); | |
| 157 | + setPlaying((v) => !v); | |
| 158 | + }}> | |
| 159 | + {playing ? "⏸ Pause" : "▶ Play"} | |
| 160 | + </button> | |
| 161 | + <input | |
| 162 | + type="range" | |
| 163 | + min={0} | |
| 164 | + max={Math.max(periods.length - 1, 0)} | |
| 165 | + value={idx} | |
| 166 | + aria-label="Month selector" | |
| 167 | + style={{ flex: "1 1 180px", accentColor: "var(--series-1)" }} | |
| 168 | + onChange={(e) => { | |
| 169 | + setPlaying(false); | |
| 170 | + setIdx(Number(e.target.value)); | |
| 171 | + }} | |
| 172 | + /> | |
| 173 | + <span className="note" style={{ fontVariantNumeric: "tabular-nums", | |
| 174 | + minWidth: 84 }}> | |
| 175 | + <strong>{period}</strong> | |
| 176 | + </span> | |
| 177 | + </div> | |
| 178 | + {error && <div className="card"><span className="note">{error}</span></div>} | |
| 179 | + | |
| 180 | + <div className="grid cols-2"> | |
| 181 | + <div className="card"> | |
| 182 | + <svg viewBox={`0 0 ${W} ${H}`} role="img" | |
| 183 | + aria-label={`Choropleth of Quebec regions, ${metric}`}> | |
| 184 | + {features.map((f) => { | |
| 185 | + const cell = byId.get(f.properties.geography_id); | |
| 186 | + return ( | |
| 187 | + <path | |
| 188 | + key={f.properties.geography_id} | |
| 189 | + d={pathFor(f, W, H)} | |
| 190 | + fill={colorOf(cell?.value ?? null)} | |
| 191 | + stroke="var(--surface-1)" | |
| 192 | + strokeWidth={1.5} | |
| 193 | + style={{ cursor: "pointer", transition: "fill 0.4s ease" }} | |
| 194 | + onMouseEnter={() => setHover(cell ?? null)} | |
| 195 | + onMouseLeave={() => setHover(null)} | |
| 196 | + /> | |
| 197 | + ); | |
| 198 | + })} | |
| 199 | + </svg> | |
| 200 | + {domain && ( | |
| 201 | + <div className="note" style={{ display: "flex", gap: 4, alignItems: "center" }}> | |
| 202 | + {fmt(domain[0])} | |
| 203 | + {RAMP.map((c) => ( | |
| 204 | + <span key={c} style={{ background: c, width: 22, height: 10, display: "inline-block" }} /> | |
| 205 | + ))} | |
| 206 | + {fmt(domain[1])} | |
| 207 | + </div> | |
| 208 | + )} | |
| 209 | + {hover && ( | |
| 210 | + <div className="note"> | |
| 211 | + <strong>{hover.geography_name}</strong>: {fmt(hover.value)} ·{" "} | |
| 212 | + {hover.transactions} tx · reliability {hover.reliability_grade} | |
| 213 | + </div> | |
| 214 | + )} | |
| 215 | + </div> | |
| 216 | + | |
| 217 | + <div className="card"> | |
| 218 | + <strong>All regions</strong> | |
| 219 | + <table className="data"> | |
| 220 | + <thead> | |
| 221 | + <tr><th>Region</th><th>{METRICS.find((m) => m.id === metric)?.label}</th> | |
| 222 | + <th>Tx</th><th>Grade</th></tr> | |
| 223 | + </thead> | |
| 224 | + <tbody> | |
| 225 | + {[...cells] | |
| 226 | + .sort((a, b) => (b.value ?? -1e9) - (a.value ?? -1e9)) | |
| 227 | + .map((c) => ( | |
| 228 | + <tr key={c.geography_id}> | |
| 229 | + <td>{c.geography_name}</td> | |
| 230 | + <td>{fmt(c.value)}</td> | |
| 231 | + <td>{c.transactions}</td> | |
| 232 | + <td>{c.reliability_grade}</td> | |
| 233 | + </tr> | |
| 234 | + ))} | |
| 235 | + </tbody> | |
| 236 | + </table> | |
| 237 | + </div> | |
| 238 | + </div> | |
| 239 | + </> | |
| 240 | + ); | |
| 241 | +} | |
added
web/app/methodology/page.tsx
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/app/methodology/page.tsx | |
| 7 | + * Purpose : Methodology — rendered summary of the v2.1 monthly robust index. | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | + | |
| 11 | +export default function Methodology() { | |
| 12 | + return ( | |
| 13 | + <> | |
| 14 | + <h1>Methodology (v2.1 — monthly, robust)</h1> | |
| 15 | + <p className="lede"> | |
| 16 | + QHPI measures price movement, never composition movement. Version 2 | |
| 17 | + rebuilt the index at monthly frequency around the rolling-time-dummy | |
| 18 | + method used by official statistical agencies. | |
| 19 | + </p> | |
| 20 | + | |
| 21 | + <h2>Data</h2> | |
| 22 | + <p> | |
| 23 | + ~745,000 residential transactions across 1,100+ Quebec municipalities, | |
| 24 | + January 2021 to present, spatially joined to the official SDA | |
| 25 | + 1/20 000 boundaries. Non-arm's-length transfers, duplicates | |
| 26 | + and portfolio-scale conveyances are excluded with a documented | |
| 27 | + exclusion table (~3.5%). The <em>indéterminé</em> type never enters an | |
| 28 | + index. <strong>Variables whose missingness moves over time are banned | |
| 29 | + from the model</strong>: the provider backfilled | |
| 30 | + <code>buildingType</code> in region-staggered waves (2022–2023), which | |
| 31 | + would fabricate artificial index steps — it was detected by a | |
| 32 | + composition-free arbitration and excluded; building age uses | |
| 33 | + time-invariant conditional-median imputation. | |
| 34 | + </p> | |
| 35 | + | |
| 36 | + <h2>Three estimators, one family</h2> | |
| 37 | + <ul> | |
| 38 | + <li> | |
| 39 | + <strong>Province × type — rolling-time-dummy (RTD).</strong>{" "} | |
| 40 | + 13-month hedonic windows re-estimated every month with Huber-robust | |
| 41 | + weighting (outliers are downweighted inside the regression), linked | |
| 42 | + by the <em>mean splice</em>. Published history never revises, and | |
| 43 | + hedonic coefficients are free to drift (β on log floor area moves | |
| 44 | + ~5% across windows). | |
| 45 | + </li> | |
| 46 | + <li> | |
| 47 | + <strong>Liquid cells (≥ 40 tx/month) — direct local estimation.</strong>{" "} | |
| 48 | + Own robust time-dummy regression (own coefficients, own postal-zone | |
| 49 | + fixed effects) with a light state-space smoother for the real-time | |
| 50 | + variant. Chosen because deviation-from-pooled-surface methods | |
| 51 | + compress strong local divergences: Québec City condos appreciated | |
| 52 | + ~+55% by repeat sales AND stratified matched-cell medians, which the | |
| 53 | + direct estimator reproduces (+53–64%) while pooled deviations said | |
| 54 | + +42%. | |
| 55 | + </li> | |
| 56 | + <li> | |
| 57 | + <strong>Thin cells — hierarchical shrinkage.</strong> Monthly mean | |
| 58 | + deviations from the parent path filtered by a heteroskedastic | |
| 59 | + local-level Kalman model: liquid months speak, thin months shrink | |
| 60 | + toward the parent. | |
| 61 | + </li> | |
| 62 | + </ul> | |
| 63 | + | |
| 64 | + <h2>Validation</h2> | |
| 65 | + <ul> | |
| 66 | + <li> | |
| 67 | + <strong>Repeat sales (BMN)</strong>: published levels agree within | |
| 68 | + ±5% on six of seven benchmark cells, with high change-correlations at | |
| 69 | + the province level. The exception (Montréal single-family, −8%) sits | |
| 70 | + between repeat sales and the stratified matched-cell median. | |
| 71 | + </li> | |
| 72 | + <li> | |
| 73 | + <strong>Stratified matched-cell medians</strong> (FSA × vintage × | |
| 74 | + size, regression-free) corroborate the direct estimator on every | |
| 75 | + arbitrated cell. | |
| 76 | + </li> | |
| 77 | + <li> | |
| 78 | + <strong>Downsampling</strong>: direct-estimation RMSE stays ≈1.9% at | |
| 79 | + 40 tx/month (the liquidity floor); hierarchical shrinkage takes over | |
| 80 | + below. | |
| 81 | + </li> | |
| 82 | + <li> | |
| 83 | + <strong>Composition shock</strong>: removing 70% of small-dwelling | |
| 84 | + sales moves the raw median +12.3% but the index only −1.0%. | |
| 85 | + </li> | |
| 86 | + <li> | |
| 87 | + <strong>Seasonality</strong>: no stable monthly seasonality — all | |
| 88 | + series are NSA. | |
| 89 | + </li> | |
| 90 | + </ul> | |
| 91 | + | |
| 92 | + <h2>Reliability grades</h2> | |
| 93 | + <table className="data"> | |
| 94 | + <thead> | |
| 95 | + <tr><th>Grade</th><th>Meaning</th><th>Liquidity</th></tr> | |
| 96 | + </thead> | |
| 97 | + <tbody> | |
| 98 | + <tr><td>A</td><td>Very strong; direct estimation, tight CI</td><td>≥ 150 tx/month</td></tr> | |
| 99 | + <tr><td>B</td><td>Strong; direct estimation</td><td>75–149</td></tr> | |
| 100 | + <tr><td>C</td><td>Moderate; direct estimation floor</td><td>40–74</td></tr> | |
| 101 | + <tr><td>D</td><td>Thin; hierarchical shrinkage</td><td>15–39</td></tr> | |
| 102 | + <tr><td>E</td><td>Model-implied only</td><td>< 15</td></tr> | |
| 103 | + </tbody> | |
| 104 | + </table> | |
| 105 | + | |
| 106 | + <h2>Conventions</h2> | |
| 107 | + <p> | |
| 108 | + Base 2021 average = 100. Continuous monthly grid; the trailing month is | |
| 109 | + flagged <code>is_partial_month</code> while registrations arrive. Every | |
| 110 | + observation ships with a 95% interval, transaction count, effective | |
| 111 | + sample size and shrinkage weight. First releases are preserved and | |
| 112 | + revisions are queryable (<code>/v1/vintages</code>). Growth horizons: | |
| 113 | + 1m, 3m, 6m, YoY. | |
| 114 | + </p> | |
| 115 | + | |
| 116 | + <p className="note"> | |
| 117 | + Author: Simon-Pierre Boucher — contact@spboucher.ai · Key references: | |
| 118 | + Hill, Scholz, Shimizu & Steurer (2022) J. Official Statistics; | |
| 119 | + Eurostat/IMF/OECD RPPI Handbook (2013); Bailey, Muth & Nourse | |
| 120 | + (1963); Case & Shiller (1989). | |
| 121 | + </p> | |
| 122 | + </> | |
| 123 | + ); | |
| 124 | +} | |
added
web/app/opengraph-image.tsx
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/app/opengraph-image.tsx | |
| 7 | + * Purpose : Generated Open Graph share image (1200×630, brand chart motif). | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | +import { ImageResponse } from "next/og"; | |
| 11 | + | |
| 12 | +export const runtime = "edge"; | |
| 13 | +export const alt = "QHPI — Quebec Housing Price Index (monthly)"; | |
| 14 | +export const size = { width: 1200, height: 630 }; | |
| 15 | +export const contentType = "image/png"; | |
| 16 | + | |
| 17 | +export default function OgImage() { | |
| 18 | + return new ImageResponse( | |
| 19 | + ( | |
| 20 | + <div | |
| 21 | + style={{ | |
| 22 | + width: "100%", height: "100%", display: "flex", | |
| 23 | + flexDirection: "column", justifyContent: "space-between", | |
| 24 | + background: "linear-gradient(135deg, #fcfcfb 60%, #e8f0fb 100%)", | |
| 25 | + padding: 64, fontFamily: "Georgia, serif", | |
| 26 | + }} | |
| 27 | + > | |
| 28 | + <div style={{ display: "flex", alignItems: "center", gap: 20 }}> | |
| 29 | + <div | |
| 30 | + style={{ | |
| 31 | + width: 56, height: 56, borderRadius: 14, background: "#2a78d6", | |
| 32 | + display: "flex", alignItems: "center", justifyContent: "center", | |
| 33 | + }} | |
| 34 | + > | |
| 35 | + <svg width="34" height="34" viewBox="0 0 22 22"> | |
| 36 | + <path d="M5 14.5 L9 10.5 L12 12.5 L17 6.5" fill="none" | |
| 37 | + stroke="#fff" strokeWidth="2.2" strokeLinecap="round" | |
| 38 | + strokeLinejoin="round" /> | |
| 39 | + </svg> | |
| 40 | + </div> | |
| 41 | + <div style={{ fontSize: 44, fontWeight: 700, color: "#0b0b0b" }}> | |
| 42 | + QHPI | |
| 43 | + </div> | |
| 44 | + <div style={{ fontSize: 24, color: "#716f66" }}> | |
| 45 | + Quebec Housing Price Index · monthly | |
| 46 | + </div> | |
| 47 | + </div> | |
| 48 | + | |
| 49 | + <svg width="1072" height="270" viewBox="0 0 1072 270"> | |
| 50 | + <path | |
| 51 | + d="M0 240 C 120 235, 180 200, 260 190 S 400 205, 470 185 | |
| 52 | + S 610 120, 700 110 S 850 70, 940 45 S 1030 25, 1072 15" | |
| 53 | + fill="none" stroke="#2a78d6" strokeWidth="6" strokeLinecap="round" | |
| 54 | + /> | |
| 55 | + <path | |
| 56 | + d="M0 240 C 120 235, 180 200, 260 190 S 400 205, 470 185 | |
| 57 | + S 610 120, 700 110 S 850 70, 940 45 S 1030 25, 1072 15 | |
| 58 | + L 1072 270 L 0 270 Z" | |
| 59 | + fill="rgba(42,120,214,0.12)" | |
| 60 | + /> | |
| 61 | + </svg> | |
| 62 | + | |
| 63 | + <div style={{ display: "flex", justifyContent: "space-between", | |
| 64 | + alignItems: "flex-end" }}> | |
| 65 | + <div style={{ fontSize: 34, color: "#0b0b0b", maxWidth: 760 }}> | |
| 66 | + Quebec housing prices, measured monthly, robustly. | |
| 67 | + </div> | |
| 68 | + <div style={{ fontSize: 20, color: "#716f66" }}> | |
| 69 | + indexqc.house | |
| 70 | + </div> | |
| 71 | + </div> | |
| 72 | + </div> | |
| 73 | + ), | |
| 74 | + size, | |
| 75 | + ); | |
| 76 | +} | |
added
web/app/page.tsx
+238 −0
@@ -0,0 +1,238 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/app/page.tsx | |
| 7 | + * Purpose : Overview — ticker tape, animated mega hero on a draw-in chart, | |
| 8 | + * regional podium, metric tiles, pulse heatmap table. | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +"use client"; | |
| 12 | + | |
| 13 | +import { useEffect, useState } from "react"; | |
| 14 | +import { useRouter } from "next/navigation"; | |
| 15 | +import { | |
| 16 | + fetchOverview, | |
| 17 | + fetchSeries, | |
| 18 | + fetchStats, | |
| 19 | + marketReportUrl, | |
| 20 | + type Observation, | |
| 21 | + type OverviewRow, | |
| 22 | + type SeriesStats, | |
| 23 | +} from "../lib/api"; | |
| 24 | +import CountUp from "../components/CountUp"; | |
| 25 | +import HeroChart from "../components/HeroChart"; | |
| 26 | +import ReliabilityBadge from "../components/ReliabilityBadge"; | |
| 27 | +import { DashboardSkeleton } from "../components/Skeleton"; | |
| 28 | +import Sparkline from "../components/Sparkline"; | |
| 29 | +import Ticker from "../components/Ticker"; | |
| 30 | + | |
| 31 | +function Delta({ v, suffix = "%" }: { v: number | null; suffix?: string }) { | |
| 32 | + if (v == null) return <span className="delta">—</span>; | |
| 33 | + const cls = v >= 0 ? "delta up" : "delta down"; | |
| 34 | + return ( | |
| 35 | + <span className={cls}> | |
| 36 | + {v >= 0 ? "▲" : "▼"} {Math.abs(v).toFixed(2)} | |
| 37 | + {suffix} | |
| 38 | + </span> | |
| 39 | + ); | |
| 40 | +} | |
| 41 | + | |
| 42 | +function heatStyle(v: number | null, lo: number, hi: number) { | |
| 43 | + if (v == null) return {}; | |
| 44 | + if (v < 0) return { background: "rgba(227, 73, 72, 0.18)" }; | |
| 45 | + const ramp = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf"]; | |
| 46 | + const t = hi === lo ? 0.5 : Math.max(0, Math.min(0.999, (v - lo) / (hi - lo))); | |
| 47 | + const c = ramp[Math.floor(t * ramp.length)]; | |
| 48 | + return { | |
| 49 | + background: c, | |
| 50 | + color: c === "#256abf" || c === "#3987e5" ? "#fff" : "#0b0b0b", | |
| 51 | + }; | |
| 52 | +} | |
| 53 | + | |
| 54 | +const TYPES = ["all", "unifamilial", "condo", "plex"]; | |
| 55 | +const MEDALS = ["🥇", "🥈", "🥉"]; | |
| 56 | + | |
| 57 | +export default function Overview() { | |
| 58 | + const router = useRouter(); | |
| 59 | + const [stats, setStats] = useState<SeriesStats | null>(null); | |
| 60 | + const [rows, setRows] = useState<OverviewRow[]>([]); | |
| 61 | + const [series, setSeries] = useState<Observation[]>([]); | |
| 62 | + const [ptype, setPtype] = useState("all"); | |
| 63 | + const [error, setError] = useState<string | null>(null); | |
| 64 | + | |
| 65 | + useEffect(() => { | |
| 66 | + Promise.all([ | |
| 67 | + fetchStats("quebec", ptype), | |
| 68 | + fetchOverview("region", ptype), | |
| 69 | + fetchSeries("quebec", ptype), | |
| 70 | + ]) | |
| 71 | + .then(([s, o, se]) => { | |
| 72 | + setStats(s); | |
| 73 | + setRows(o.rows); | |
| 74 | + setSeries(se.observations); | |
| 75 | + setError(null); | |
| 76 | + }) | |
| 77 | + .catch((e) => setError(String(e))); | |
| 78 | + }, [ptype]); | |
| 79 | + | |
| 80 | + if (error) | |
| 81 | + return ( | |
| 82 | + <div className="card" style={{ marginTop: 32 }}> | |
| 83 | + <strong>API unreachable.</strong> | |
| 84 | + <p className="note">Start it with <code>make api</code> — {error}</p> | |
| 85 | + </div> | |
| 86 | + ); | |
| 87 | + if (!stats) return <DashboardSkeleton />; | |
| 88 | + | |
| 89 | + const yoys = rows.map((r) => r.yoy_pct).filter((v): v is number => v != null); | |
| 90 | + const lo = Math.min(...yoys, 0); | |
| 91 | + const hi = Math.max(...yoys, 1); | |
| 92 | + const podium = rows.slice(0, 3); | |
| 93 | + | |
| 94 | + return ( | |
| 95 | + <> | |
| 96 | + <Ticker ptype={ptype} /> | |
| 97 | + | |
| 98 | + <div className="hero"> | |
| 99 | + <div> | |
| 100 | + <div className="hero-kicker">Month of {stats.period}</div> | |
| 101 | + <h1>Quebec housing prices,<br />measured monthly, robustly.</h1> | |
| 102 | + <p className="lede"> | |
| 103 | + Robust hedonic index (rolling-time-dummy) · base 2021 = 100 · | |
| 104 | + uncertainty always displayed | |
| 105 | + </p> | |
| 106 | + <div className="controls" style={{ margin: "16px 0 0" }}> | |
| 107 | + <div className="seg" role="group" aria-label="Property type"> | |
| 108 | + {TYPES.map((t) => ( | |
| 109 | + <button key={t} aria-pressed={ptype === t} | |
| 110 | + onClick={() => setPtype(t)}> | |
| 111 | + {t} | |
| 112 | + </button> | |
| 113 | + ))} | |
| 114 | + </div> | |
| 115 | + <a className="primary" href={marketReportUrl(ptype)} target="_blank" | |
| 116 | + rel="noreferrer"> | |
| 117 | + ⤓ Market report (PDF) | |
| 118 | + </a> | |
| 119 | + </div> | |
| 120 | + </div> | |
| 121 | + <div className="hero-mega"> | |
| 122 | + <span className="big"> | |
| 123 | + <CountUp value={stats.index} decimals={1} /> | |
| 124 | + </span> | |
| 125 | + <span className="sub"> | |
| 126 | + QHPI-QC · 95% CI {stats.lower_95.toFixed(1)}–{stats.upper_95.toFixed(1)} | |
| 127 | + {stats.at_record_high && ( | |
| 128 | + <span className="record-chip"> · ★ record high</span> | |
| 129 | + )} | |
| 130 | + </span> | |
| 131 | + <span className="sub"> | |
| 132 | + <Delta v={stats.yoy_pct} /> YoY · <Delta v={stats.since_2021_pct} /> since 2021 | |
| 133 | + </span> | |
| 134 | + </div> | |
| 135 | + </div> | |
| 136 | + | |
| 137 | + <HeroChart observations={series} /> | |
| 138 | + | |
| 139 | + <h2>Fastest-appreciating regions</h2> | |
| 140 | + <div className="podium fade-up"> | |
| 141 | + {podium.map((r, i) => ( | |
| 142 | + <div key={r.geography_id} | |
| 143 | + className={`card hoverable p${i + 1}`} | |
| 144 | + style={{ cursor: "pointer" }} | |
| 145 | + onClick={() => | |
| 146 | + router.push(`/explore?geography=${r.geography_id}&type=${ptype}`)}> | |
| 147 | + <div style={{ display: "flex", justifyContent: "space-between", | |
| 148 | + alignItems: "baseline" }}> | |
| 149 | + <strong>{MEDALS[i]} {r.geography_name}</strong> | |
| 150 | + <ReliabilityBadge grade={r.reliability_grade} /> | |
| 151 | + </div> | |
| 152 | + <div style={{ display: "flex", justifyContent: "space-between", | |
| 153 | + alignItems: "flex-end", marginTop: 8 }}> | |
| 154 | + <div> | |
| 155 | + <div className="stat-tile"> | |
| 156 | + <span className="value" style={{ fontSize: 26 }}> | |
| 157 | + <Delta v={r.yoy_pct} /> | |
| 158 | + </span> | |
| 159 | + <span className="note"> | |
| 160 | + index {r.index.toFixed(1)} | |
| 161 | + {r.at_record_high ? " · at peak" : ""} | |
| 162 | + </span> | |
| 163 | + </div> | |
| 164 | + </div> | |
| 165 | + <Sparkline values={r.spark} width={110} height={34} /> | |
| 166 | + </div> | |
| 167 | + </div> | |
| 168 | + ))} | |
| 169 | + </div> | |
| 170 | + | |
| 171 | + <div className="grid cols-8 fade-up d1" style={{ marginTop: 24 }}> | |
| 172 | + {[ | |
| 173 | + ["Dollar value", stats.representative_value | |
| 174 | + ? <CountUp key="v" value={stats.representative_value} decimals={0} prefix="$" /> | |
| 175 | + : <span key="v">—</span>], | |
| 176 | + ["1 month", <Delta key="a" v={stats.monthly_pct} />], | |
| 177 | + ["3 months", <Delta key="b" v={stats.three_month_pct} />], | |
| 178 | + ["6 months", <Delta key="c" v={stats.six_month_pct} />], | |
| 179 | + ["CAGR", <Delta key="d" v={stats.cagr_pct} />], | |
| 180 | + ["Volatility 12m", <span key="f">{stats.volatility_12m_pct.toFixed(2)}%</span>], | |
| 181 | + ["Sales 12m", <CountUp key="g" value={stats.volume_12m} decimals={0} />], | |
| 182 | + ["$ volume 12m", <span key="h">${(stats.dollar_volume_12m / 1e9).toFixed(1)}B</span>], | |
| 183 | + ].map(([label, val]) => ( | |
| 184 | + <div className="card hoverable stat-tile mini-tile" key={String(label)}> | |
| 185 | + <span className="label">{label}</span> | |
| 186 | + <span className="value">{val}</span> | |
| 187 | + </div> | |
| 188 | + ))} | |
| 189 | + </div> | |
| 190 | + | |
| 191 | + <h2>Regional pulse — {ptype} · month of {stats.period}</h2> | |
| 192 | + <div className="card fade-up d2" style={{ padding: "8px 12px", overflowX: "auto" }}> | |
| 193 | + <table className="pulse"> | |
| 194 | + <thead> | |
| 195 | + <tr> | |
| 196 | + <th>Region</th><th>Trend</th><th>Index</th><th>1m</th><th>3m</th> | |
| 197 | + <th>6m</th><th>YoY</th><th>Since 2021</th><th>Vs peak</th> | |
| 198 | + <th>Sales 12m</th><th>$ value</th><th>Grade</th> | |
| 199 | + </tr> | |
| 200 | + </thead> | |
| 201 | + <tbody> | |
| 202 | + {rows.map((r) => ( | |
| 203 | + <tr key={r.geography_id} | |
| 204 | + onClick={() => | |
| 205 | + router.push(`/explore?geography=${r.geography_id}&type=${ptype}`)}> | |
| 206 | + <td> | |
| 207 | + {r.geography_name} | |
| 208 | + {r.at_record_high && <span className="record-chip"> ★</span>} | |
| 209 | + </td> | |
| 210 | + <td><Sparkline values={r.spark} width={84} height={22} /></td> | |
| 211 | + <td>{r.index.toFixed(1)}</td> | |
| 212 | + <td><Delta v={r.monthly_pct} /></td> | |
| 213 | + <td><Delta v={r.three_month_pct} /></td> | |
| 214 | + <td><Delta v={r.six_month_pct} /></td> | |
| 215 | + <td className="heat" style={heatStyle(r.yoy_pct, lo, hi)}> | |
| 216 | + {r.yoy_pct == null ? "—" : `${r.yoy_pct > 0 ? "+" : ""}${r.yoy_pct.toFixed(2)}%`} | |
| 217 | + </td> | |
| 218 | + <td><Delta v={r.since_2021_pct} /></td> | |
| 219 | + <td>{r.drawdown_pct === 0 ? "peak" : `${r.drawdown_pct.toFixed(1)}%`}</td> | |
| 220 | + <td>{r.volume_12m.toLocaleString()}</td> | |
| 221 | + <td>{r.representative_value | |
| 222 | + ? `$${Math.round(r.representative_value / 1000)}k` : "—"}</td> | |
| 223 | + <td>{r.reliability_grade}</td> | |
| 224 | + </tr> | |
| 225 | + ))} | |
| 226 | + </tbody> | |
| 227 | + </table> | |
| 228 | + </div> | |
| 229 | + <p className="note" style={{ marginTop: 12 }}> | |
| 230 | + ★ = record high · YoY column shaded by appreciation · click a row to | |
| 231 | + explore the series · press <kbd style={{ | |
| 232 | + background: "var(--surface-2)", border: "1px solid var(--border)", | |
| 233 | + borderRadius: 5, padding: "0 5px", fontSize: 11, | |
| 234 | + }}>⌘K</kbd> to jump anywhere. | |
| 235 | + </p> | |
| 236 | + </> | |
| 237 | + ); | |
| 238 | +} | |
added
web/components/CommandPalette.tsx
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/CommandPalette.tsx | |
| 7 | + * Purpose : ⌘K command palette — fuzzy jump to any series or page, | |
| 8 | + * full keyboard navigation, zero dependencies. | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +"use client"; | |
| 12 | + | |
| 13 | +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 14 | +import { useRouter } from "next/navigation"; | |
| 15 | +import { fetchGeographies, type GeographyNode } from "../lib/api"; | |
| 16 | + | |
| 17 | +interface Item { | |
| 18 | + label: string; | |
| 19 | + hint: string; | |
| 20 | + href: string; | |
| 21 | +} | |
| 22 | + | |
| 23 | +const PAGES: Item[] = [ | |
| 24 | + { label: "Overview", hint: "page", href: "/" }, | |
| 25 | + { label: "Explore", hint: "page", href: "/explore" }, | |
| 26 | + { label: "Compare", hint: "page", href: "/compare" }, | |
| 27 | + { label: "Map", hint: "page", href: "/map" }, | |
| 28 | + { label: "Methodology", hint: "page", href: "/methodology" }, | |
| 29 | + { label: "API docs", hint: "page", href: "/api-docs" }, | |
| 30 | +]; | |
| 31 | +const TYPES = ["all", "unifamilial", "condo", "plex"]; | |
| 32 | + | |
| 33 | +function norm(s: string): string { | |
| 34 | + return s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); | |
| 35 | +} | |
| 36 | + | |
| 37 | +export default function CommandPalette() { | |
| 38 | + const router = useRouter(); | |
| 39 | + const [open, setOpen] = useState(false); | |
| 40 | + const [q, setQ] = useState(""); | |
| 41 | + const [sel, setSel] = useState(0); | |
| 42 | + const [geos, setGeos] = useState<GeographyNode[]>([]); | |
| 43 | + const inputRef = useRef<HTMLInputElement>(null); | |
| 44 | + const listRef = useRef<HTMLUListElement>(null); | |
| 45 | + | |
| 46 | + useEffect(() => { | |
| 47 | + const onKey = (e: KeyboardEvent) => { | |
| 48 | + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { | |
| 49 | + e.preventDefault(); | |
| 50 | + setOpen((v) => !v); | |
| 51 | + } else if (e.key === "Escape") { | |
| 52 | + setOpen(false); | |
| 53 | + } | |
| 54 | + }; | |
| 55 | + window.addEventListener("keydown", onKey); | |
| 56 | + return () => window.removeEventListener("keydown", onKey); | |
| 57 | + }, []); | |
| 58 | + | |
| 59 | + useEffect(() => { | |
| 60 | + if (open) { | |
| 61 | + setQ(""); | |
| 62 | + setSel(0); | |
| 63 | + setTimeout(() => inputRef.current?.focus(), 30); | |
| 64 | + if (!geos.length) | |
| 65 | + fetchGeographies().then((g) => setGeos(g.published_series)).catch(() => {}); | |
| 66 | + } | |
| 67 | + }, [open, geos.length]); | |
| 68 | + | |
| 69 | + const items = useMemo<Item[]>(() => { | |
| 70 | + const series: Item[] = geos.flatMap((g) => | |
| 71 | + g.property_types.filter((t) => TYPES.includes(t)).map((t) => ({ | |
| 72 | + label: `${g.geography_name} · ${t}`, | |
| 73 | + hint: g.geography_level, | |
| 74 | + href: `/explore?geography=${g.geography_id}&type=${t}`, | |
| 75 | + }))); | |
| 76 | + const all = [...PAGES, ...series]; | |
| 77 | + if (!q) return all.slice(0, 12); | |
| 78 | + const tokens = norm(q).split(/\s+/).filter(Boolean); | |
| 79 | + return all | |
| 80 | + .filter((i) => { | |
| 81 | + const hay = norm(i.label + " " + i.hint); | |
| 82 | + return tokens.every((t) => hay.includes(t)); | |
| 83 | + }) | |
| 84 | + .slice(0, 12); | |
| 85 | + }, [geos, q]); | |
| 86 | + | |
| 87 | + const go = useCallback((item: Item) => { | |
| 88 | + setOpen(false); | |
| 89 | + router.push(item.href); | |
| 90 | + }, [router]); | |
| 91 | + | |
| 92 | + const onInputKey = (e: React.KeyboardEvent) => { | |
| 93 | + if (e.key === "ArrowDown") { | |
| 94 | + e.preventDefault(); | |
| 95 | + setSel((s) => Math.min(s + 1, items.length - 1)); | |
| 96 | + } else if (e.key === "ArrowUp") { | |
| 97 | + e.preventDefault(); | |
| 98 | + setSel((s) => Math.max(s - 1, 0)); | |
| 99 | + } else if (e.key === "Enter" && items[sel]) { | |
| 100 | + go(items[sel]); | |
| 101 | + } | |
| 102 | + }; | |
| 103 | + | |
| 104 | + useEffect(() => { | |
| 105 | + listRef.current?.children[sel]?.scrollIntoView({ block: "nearest" }); | |
| 106 | + }, [sel]); | |
| 107 | + | |
| 108 | + if (!open) return null; | |
| 109 | + | |
| 110 | + return ( | |
| 111 | + <div className="palette-backdrop" onClick={() => setOpen(false)} | |
| 112 | + role="dialog" aria-modal="true" aria-label="Command palette"> | |
| 113 | + <div className="palette" onClick={(e) => e.stopPropagation()}> | |
| 114 | + <input | |
| 115 | + ref={inputRef} | |
| 116 | + value={q} | |
| 117 | + onChange={(e) => { setQ(e.target.value); setSel(0); }} | |
| 118 | + onKeyDown={onInputKey} | |
| 119 | + placeholder="Jump to a series or page… (e.g. montreal condo)" | |
| 120 | + aria-label="Search series and pages" | |
| 121 | + className="palette-input" | |
| 122 | + /> | |
| 123 | + <ul ref={listRef} className="palette-list" role="listbox"> | |
| 124 | + {items.map((it, i) => ( | |
| 125 | + <li key={it.href + it.label} role="option" aria-selected={i === sel}> | |
| 126 | + <button | |
| 127 | + className={"palette-item" + (i === sel ? " active" : "")} | |
| 128 | + onMouseEnter={() => setSel(i)} | |
| 129 | + onClick={() => go(it)} | |
| 130 | + > | |
| 131 | + <span>{it.label}</span> | |
| 132 | + <span className="palette-hint">{it.hint}</span> | |
| 133 | + </button> | |
| 134 | + </li> | |
| 135 | + ))} | |
| 136 | + {!items.length && ( | |
| 137 | + <li className="palette-empty">No match — try a region or city name</li> | |
| 138 | + )} | |
| 139 | + </ul> | |
| 140 | + <div className="palette-foot"> | |
| 141 | + <span>↑↓ navigate</span><span>↵ open</span><span>esc close</span> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + </div> | |
| 145 | + ); | |
| 146 | +} | |
added
web/components/CountUp.tsx
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/CountUp.tsx | |
| 7 | + * Purpose : Animated number count-up (easeOut, reduced-motion aware). | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | +"use client"; | |
| 11 | + | |
| 12 | +import { useEffect, useRef, useState } from "react"; | |
| 13 | + | |
| 14 | +export default function CountUp({ | |
| 15 | + value, | |
| 16 | + decimals = 1, | |
| 17 | + duration = 1100, | |
| 18 | + prefix = "", | |
| 19 | + suffix = "", | |
| 20 | +}: { | |
| 21 | + value: number; | |
| 22 | + decimals?: number; | |
| 23 | + duration?: number; | |
| 24 | + prefix?: string; | |
| 25 | + suffix?: string; | |
| 26 | +}) { | |
| 27 | + const [display, setDisplay] = useState(0); | |
| 28 | + const raf = useRef<number>(0); | |
| 29 | + | |
| 30 | + useEffect(() => { | |
| 31 | + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { | |
| 32 | + setDisplay(value); | |
| 33 | + return; | |
| 34 | + } | |
| 35 | + const t0 = performance.now(); | |
| 36 | + const tick = (t: number) => { | |
| 37 | + const p = Math.min(1, (t - t0) / duration); | |
| 38 | + const eased = 1 - Math.pow(1 - p, 3); | |
| 39 | + setDisplay(value * eased); | |
| 40 | + if (p < 1) raf.current = requestAnimationFrame(tick); | |
| 41 | + }; | |
| 42 | + raf.current = requestAnimationFrame(tick); | |
| 43 | + return () => cancelAnimationFrame(raf.current); | |
| 44 | + }, [value, duration]); | |
| 45 | + | |
| 46 | + return ( | |
| 47 | + <span style={{ fontVariantNumeric: "tabular-nums" }}> | |
| 48 | + {prefix} | |
| 49 | + {display.toLocaleString("en-CA", { | |
| 50 | + minimumFractionDigits: decimals, | |
| 51 | + maximumFractionDigits: decimals, | |
| 52 | + })} | |
| 53 | + {suffix} | |
| 54 | + </span> | |
| 55 | + ); | |
| 56 | +} | |
added
web/components/DataTable.tsx
+119 −0
@@ -0,0 +1,119 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/DataTable.tsx | |
| 7 | + * Purpose : Sortable, filterable observations table under each chart — | |
| 8 | + * uncertainty (CI, grade) always shown. | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +"use client"; | |
| 12 | + | |
| 13 | +import { useMemo, useState } from "react"; | |
| 14 | +import type { Observation } from "../lib/api"; | |
| 15 | + | |
| 16 | +type SortKey = "period" | "index_smoothed" | "monthly_pct" | "yoy_pct" | |
| 17 | + | "transactions"; | |
| 18 | + | |
| 19 | +const COLS: { key: SortKey | string; label: string; sortable: boolean }[] = [ | |
| 20 | + { key: "period", label: "Period", sortable: true }, | |
| 21 | + { key: "index_smoothed", label: "Index", sortable: true }, | |
| 22 | + { key: "ci", label: "95% CI", sortable: false }, | |
| 23 | + { key: "monthly_pct", label: "MoM", sortable: true }, | |
| 24 | + { key: "yoy_pct", label: "YoY", sortable: true }, | |
| 25 | + { key: "representative_value", label: "$ value", sortable: false }, | |
| 26 | + { key: "transactions", label: "Sales", sortable: true }, | |
| 27 | + { key: "reliability_grade", label: "Grade", sortable: false }, | |
| 28 | +]; | |
| 29 | + | |
| 30 | +function pct(v: number | null): string { | |
| 31 | + return v == null ? "—" : `${v > 0 ? "+" : ""}${v.toFixed(2)}%`; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export default function DataTable({ observations }: { observations: Observation[] }) { | |
| 35 | + const [sortKey, setSortKey] = useState<SortKey>("period"); | |
| 36 | + const [desc, setDesc] = useState(true); | |
| 37 | + const [filter, setFilter] = useState(""); | |
| 38 | + | |
| 39 | + const rows = useMemo(() => { | |
| 40 | + let r = observations; | |
| 41 | + if (filter) r = r.filter((o) => o.period.includes(filter)); | |
| 42 | + return [...r].sort((a, b) => { | |
| 43 | + const av = a[sortKey] ?? -Infinity; | |
| 44 | + const bv = b[sortKey] ?? -Infinity; | |
| 45 | + const cmp = typeof av === "string" | |
| 46 | + ? String(av).localeCompare(String(bv)) | |
| 47 | + : Number(av) - Number(bv); | |
| 48 | + return desc ? -cmp : cmp; | |
| 49 | + }); | |
| 50 | + }, [observations, sortKey, desc, filter]); | |
| 51 | + | |
| 52 | + const onSort = (key: string, sortable: boolean) => { | |
| 53 | + if (!sortable) return; | |
| 54 | + if (sortKey === key) setDesc(!desc); | |
| 55 | + else { | |
| 56 | + setSortKey(key as SortKey); | |
| 57 | + setDesc(true); | |
| 58 | + } | |
| 59 | + }; | |
| 60 | + | |
| 61 | + return ( | |
| 62 | + <div> | |
| 63 | + <div className="controls" style={{ margin: "10px 0" }}> | |
| 64 | + <input | |
| 65 | + type="text" | |
| 66 | + placeholder="Filter period… (e.g. 2024)" | |
| 67 | + value={filter} | |
| 68 | + aria-label="Filter table by period" | |
| 69 | + onChange={(e) => setFilter(e.target.value)} | |
| 70 | + style={{ | |
| 71 | + background: "var(--surface-1)", color: "var(--text-primary)", | |
| 72 | + border: "1px solid var(--border-strong)", borderRadius: 10, | |
| 73 | + padding: "7px 12px", fontSize: 13.5, fontFamily: "inherit", | |
| 74 | + }} | |
| 75 | + /> | |
| 76 | + <span className="note">{rows.length} rows</span> | |
| 77 | + </div> | |
| 78 | + <div style={{ overflowX: "auto", maxHeight: 420, overflowY: "auto" }}> | |
| 79 | + <table className="data" style={{ fontSize: 13 }}> | |
| 80 | + <thead> | |
| 81 | + <tr> | |
| 82 | + {COLS.map((c) => ( | |
| 83 | + <th key={c.key} | |
| 84 | + onClick={() => onSort(c.key, c.sortable)} | |
| 85 | + style={{ cursor: c.sortable ? "pointer" : "default", | |
| 86 | + userSelect: "none" }} | |
| 87 | + aria-sort={sortKey === c.key | |
| 88 | + ? (desc ? "descending" : "ascending") : "none"}> | |
| 89 | + {c.label} | |
| 90 | + {c.sortable && sortKey === c.key && (desc ? " ↓" : " ↑")} | |
| 91 | + </th> | |
| 92 | + ))} | |
| 93 | + </tr> | |
| 94 | + </thead> | |
| 95 | + <tbody> | |
| 96 | + {rows.map((o) => ( | |
| 97 | + <tr key={o.period}> | |
| 98 | + <td>{o.period}{o.is_partial_month ? " *" : ""}</td> | |
| 99 | + <td>{o.index_smoothed.toFixed(2)}</td> | |
| 100 | + <td className="note"> | |
| 101 | + {o.lower_95.toFixed(1)}–{o.upper_95.toFixed(1)} | |
| 102 | + </td> | |
| 103 | + <td className={o.monthly_pct != null && o.monthly_pct < 0 | |
| 104 | + ? "delta down" : "delta up"}>{pct(o.monthly_pct)}</td> | |
| 105 | + <td className={o.yoy_pct != null && o.yoy_pct < 0 | |
| 106 | + ? "delta down" : "delta up"}>{pct(o.yoy_pct)}</td> | |
| 107 | + <td>{o.representative_value | |
| 108 | + ? `$${Math.round(o.representative_value).toLocaleString("en-CA")}` : "—"}</td> | |
| 109 | + <td>{o.transactions}</td> | |
| 110 | + <td>{o.reliability_grade}</td> | |
| 111 | + </tr> | |
| 112 | + ))} | |
| 113 | + </tbody> | |
| 114 | + </table> | |
| 115 | + </div> | |
| 116 | + <p className="note" style={{ marginTop: 8 }}>* partial month (nowcast)</p> | |
| 117 | + </div> | |
| 118 | + ); | |
| 119 | +} | |
added
web/components/HeroChart.tsx
+112 −0
@@ -0,0 +1,112 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/HeroChart.tsx | |
| 7 | + * Purpose : Full-width hero area chart with draw-in animation, CI band and | |
| 8 | + * pulsing end point — geometry rebuilt to the measured width. | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +"use client"; | |
| 12 | + | |
| 13 | +import { useEffect, useMemo, useRef, useState } from "react"; | |
| 14 | +import type { Observation } from "../lib/api"; | |
| 15 | + | |
| 16 | +export default function HeroChart({ observations }: { observations: Observation[] }) { | |
| 17 | + const wrapRef = useRef<HTMLDivElement>(null); | |
| 18 | + const [width, setWidth] = useState(1120); | |
| 19 | + | |
| 20 | + useEffect(() => { | |
| 21 | + const el = wrapRef.current; | |
| 22 | + if (!el) return; | |
| 23 | + const ro = new ResizeObserver((entries) => { | |
| 24 | + const w = entries[0]?.contentRect.width; | |
| 25 | + if (w) setWidth(Math.max(280, Math.round(w))); | |
| 26 | + }); | |
| 27 | + ro.observe(el); | |
| 28 | + return () => ro.disconnect(); | |
| 29 | + }, []); | |
| 30 | + | |
| 31 | + const small = width < 560; | |
| 32 | + const W = width; | |
| 33 | + const H = small ? 190 : 240; | |
| 34 | + const PAD = { l: small ? 6 : 30, r: small ? 64 : 96, t: 24, b: 22 }; | |
| 35 | + | |
| 36 | + const geom = useMemo(() => { | |
| 37 | + const obs = observations.filter((o) => !o.is_partial_month); | |
| 38 | + if (obs.length < 2) return null; | |
| 39 | + const vals = obs.map((o) => o.index_smoothed); | |
| 40 | + const los = obs.map((o) => o.lower_95); | |
| 41 | + const his = obs.map((o) => o.upper_95); | |
| 42 | + const min = Math.min(...los); | |
| 43 | + const max = Math.max(...his); | |
| 44 | + const x = (i: number) => | |
| 45 | + PAD.l + (i / (obs.length - 1)) * (W - PAD.l - PAD.r); | |
| 46 | + const y = (v: number) => | |
| 47 | + H - PAD.b - ((v - min) / (max - min || 1)) * (H - PAD.t - PAD.b); | |
| 48 | + const pts = vals.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`); | |
| 49 | + const line = "M" + pts.join(" L"); | |
| 50 | + const area = line + ` L${x(obs.length - 1)},${H - PAD.b} L${x(0)},${H - PAD.b} Z`; | |
| 51 | + const upper = his.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`); | |
| 52 | + const lower = los | |
| 53 | + .map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`) | |
| 54 | + .reverse(); | |
| 55 | + const band = "M" + upper.join(" L") + " L" + lower.join(" L") + " Z"; | |
| 56 | + const lastObs = obs[obs.length - 1]; | |
| 57 | + const yearStep = small ? 2 : 1; // fewer year labels on narrow screens | |
| 58 | + return { | |
| 59 | + line, area, band, | |
| 60 | + lastX: x(obs.length - 1), lastY: y(vals[vals.length - 1]), | |
| 61 | + last: lastObs, | |
| 62 | + years: obs | |
| 63 | + .map((o, i) => ({ o, i })) | |
| 64 | + .filter(({ o }) => o.period.endsWith("-01") | |
| 65 | + && Number(o.period.slice(0, 4)) % yearStep === (small ? 0 : 0)) | |
| 66 | + .map(({ o, i }) => ({ label: o.period.slice(0, 4), x: x(i) })), | |
| 67 | + }; | |
| 68 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 69 | + }, [observations, W, H, PAD.l, PAD.r]); | |
| 70 | + | |
| 71 | + if (!geom) return <div ref={wrapRef} />; | |
| 72 | + | |
| 73 | + return ( | |
| 74 | + <div ref={wrapRef} className="hero-chart card" | |
| 75 | + aria-label="Quebec index history chart" role="img"> | |
| 76 | + <svg viewBox={`0 0 ${W} ${H}`} | |
| 77 | + style={{ width: "100%", height: "auto", display: "block" }}> | |
| 78 | + <defs> | |
| 79 | + <linearGradient id="heroFill" x1="0" y1="0" x2="0" y2="1"> | |
| 80 | + <stop offset="0%" stopColor="var(--series-1)" stopOpacity="0.28" /> | |
| 81 | + <stop offset="100%" stopColor="var(--series-1)" stopOpacity="0.02" /> | |
| 82 | + </linearGradient> | |
| 83 | + </defs> | |
| 84 | + <path d={geom.band} fill="var(--band)" className="hero-band" /> | |
| 85 | + <path d={geom.area} fill="url(#heroFill)" className="hero-area" /> | |
| 86 | + <path d={geom.line} fill="none" stroke="var(--series-1)" | |
| 87 | + strokeWidth={small ? 2.2 : 2.6} strokeLinejoin="round" | |
| 88 | + strokeLinecap="round" className="hero-line" pathLength={1} /> | |
| 89 | + {geom.years.map((yr) => ( | |
| 90 | + <g key={yr.label}> | |
| 91 | + <line x1={yr.x} x2={yr.x} y1={H - PAD.b} y2={H - PAD.b + 4} | |
| 92 | + stroke="var(--border-strong)" /> | |
| 93 | + <text x={yr.x} y={H - 6} fontSize={small ? 11 : 10.5} | |
| 94 | + textAnchor="middle" fill="var(--text-muted)">{yr.label}</text> | |
| 95 | + </g> | |
| 96 | + ))} | |
| 97 | + <circle cx={geom.lastX} cy={geom.lastY} r="4.5" | |
| 98 | + fill="var(--series-1)" className="hero-dot" /> | |
| 99 | + <circle cx={geom.lastX} cy={geom.lastY} r="9" | |
| 100 | + fill="var(--series-1)" opacity="0.25" className="hero-pulse" /> | |
| 101 | + <text x={geom.lastX + 12} y={geom.lastY - 5} fontSize={small ? 13 : 15} | |
| 102 | + fontWeight="700" fill="var(--text-primary)" className="hero-dot"> | |
| 103 | + {geom.last.index_smoothed.toFixed(1)} | |
| 104 | + </text> | |
| 105 | + <text x={geom.lastX + 12} y={geom.lastY + 10} fontSize={small ? 9.5 : 10} | |
| 106 | + fill="var(--text-muted)" className="hero-dot"> | |
| 107 | + {geom.last.period} | |
| 108 | + </text> | |
| 109 | + </svg> | |
| 110 | + </div> | |
| 111 | + ); | |
| 112 | +} | |
added
web/components/IndexChart.tsx
+274 −0
@@ -0,0 +1,274 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/IndexChart.tsx | |
| 7 | + * Purpose : Weekly index chart — smoothed line, 95% CI band, optional raw | |
| 8 | + * overlay, index-points or dollar-value unit, volume subchart. | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +"use client"; | |
| 12 | + | |
| 13 | +import { | |
| 14 | + Area, | |
| 15 | + Bar, | |
| 16 | + BarChart, | |
| 17 | + Brush, | |
| 18 | + CartesianGrid, | |
| 19 | + ComposedChart, | |
| 20 | + Line, | |
| 21 | + ReferenceLine, | |
| 22 | + ResponsiveContainer, | |
| 23 | + Tooltip, | |
| 24 | + XAxis, | |
| 25 | + YAxis, | |
| 26 | +} from "recharts"; | |
| 27 | +import type { Observation } from "../lib/api"; | |
| 28 | +import type { MarketEvent } from "../lib/events"; | |
| 29 | + | |
| 30 | +export type ChartUnit = "points" | "dollars"; | |
| 31 | + | |
| 32 | +interface Props { | |
| 33 | + observations: Observation[]; | |
| 34 | + showRaw?: boolean; | |
| 35 | + showVolume?: boolean; | |
| 36 | + unit?: ChartUnit; | |
| 37 | + annotations?: MarketEvent[]; | |
| 38 | + showBrush?: boolean; | |
| 39 | +} | |
| 40 | + | |
| 41 | +interface Point { | |
| 42 | + period: string; | |
| 43 | + smoothed: number; | |
| 44 | + raw: number | null; | |
| 45 | + band: [number, number]; | |
| 46 | + transactions: number; | |
| 47 | + partial: boolean; | |
| 48 | + mom: number | null; | |
| 49 | +} | |
| 50 | + | |
| 51 | +function money(v: number): string { | |
| 52 | + return "$" + Math.round(v).toLocaleString("en-CA"); | |
| 53 | +} | |
| 54 | + | |
| 55 | +function moneyCompact(v: number): string { | |
| 56 | + return v >= 1_000_000 | |
| 57 | + ? "$" + (v / 1_000_000).toFixed(2) + "M" | |
| 58 | + : "$" + Math.round(v / 1000) + "k"; | |
| 59 | +} | |
| 60 | + | |
| 61 | +function fmtVal(v: number | null | undefined, unit: ChartUnit): string { | |
| 62 | + if (v == null || Number.isNaN(v)) return "—"; | |
| 63 | + return unit === "dollars" ? money(v) : v.toFixed(1); | |
| 64 | +} | |
| 65 | + | |
| 66 | +function ChartTooltip({ active, payload, label, unit }: { | |
| 67 | + active?: boolean; | |
| 68 | + payload?: ReadonlyArray<{ payload?: unknown }>; | |
| 69 | + label?: unknown; | |
| 70 | + unit: ChartUnit; | |
| 71 | +}) { | |
| 72 | + const p = payload?.[0]?.payload as Point | undefined; | |
| 73 | + if (!active || !p) return null; | |
| 74 | + return ( | |
| 75 | + <div className="chart-tooltip"> | |
| 76 | + <div className="tt-label"> | |
| 77 | + Month of {String(label)} | |
| 78 | + {p.partial ? " · partial" : ""} | |
| 79 | + </div> | |
| 80 | + <div className="tt-value"> | |
| 81 | + {fmtVal(p.smoothed, unit)}{" "} | |
| 82 | + <span className="tt-ci"> | |
| 83 | + [{fmtVal(p.band[0], unit)} – {fmtVal(p.band[1], unit)}] | |
| 84 | + </span> | |
| 85 | + </div> | |
| 86 | + {p.mom != null && ( | |
| 87 | + <div className={p.mom >= 0 ? "delta up" : "delta down"} | |
| 88 | + style={{ fontSize: 12.5 }}> | |
| 89 | + {p.mom >= 0 ? "▲" : "▼"} {Math.abs(p.mom).toFixed(2)}% vs previous month | |
| 90 | + </div> | |
| 91 | + )} | |
| 92 | + {p.raw != null && ( | |
| 93 | + <div className="tt-raw">raw monthly: {fmtVal(p.raw, unit)}</div> | |
| 94 | + )} | |
| 95 | + <div className="tt-n">{p.transactions} transactions</div> | |
| 96 | + </div> | |
| 97 | + ); | |
| 98 | +} | |
| 99 | + | |
| 100 | +export default function IndexChart({ | |
| 101 | + observations, | |
| 102 | + showRaw = false, | |
| 103 | + showVolume = true, | |
| 104 | + unit = "points", | |
| 105 | + annotations = [], | |
| 106 | + showBrush = false, | |
| 107 | +}: Props) { | |
| 108 | + // Fixed-basket conversion: representative_value = basket × index/100, so | |
| 109 | + // one constant per cell converts every variant (raw, band) to dollars. | |
| 110 | + const withRep = observations.find( | |
| 111 | + (o) => o.representative_value != null && o.index_smoothed > 0, | |
| 112 | + ); | |
| 113 | + const basket = | |
| 114 | + unit === "dollars" && withRep | |
| 115 | + ? (withRep.representative_value as number) / withRep.index_smoothed | |
| 116 | + : 1 / 100; | |
| 117 | + const k = unit === "dollars" ? basket : 1; | |
| 118 | + | |
| 119 | + const data: Point[] = observations.map((o, i) => ({ | |
| 120 | + period: o.period, | |
| 121 | + smoothed: o.index_smoothed * (unit === "dollars" ? k : 1), | |
| 122 | + raw: o.index == null ? null : o.index * (unit === "dollars" ? k : 1), | |
| 123 | + band: [ | |
| 124 | + o.lower_95 * (unit === "dollars" ? k : 1), | |
| 125 | + o.upper_95 * (unit === "dollars" ? k : 1), | |
| 126 | + ], | |
| 127 | + transactions: o.transactions, | |
| 128 | + partial: o.is_partial_month, | |
| 129 | + mom: i > 0 | |
| 130 | + ? (o.index_smoothed / observations[i - 1].index_smoothed - 1) * 100 | |
| 131 | + : null, | |
| 132 | + })); | |
| 133 | + | |
| 134 | + const visibleEvents = annotations.filter((e) => | |
| 135 | + data.some((d) => d.period === e.period)); | |
| 136 | + | |
| 137 | + // Scale to the index line, not the CI band: the first thin weeks carry | |
| 138 | + // huge honest intervals that would otherwise crush the whole chart. | |
| 139 | + const lineVals = data.flatMap((d) => | |
| 140 | + d.raw != null && showRaw ? [d.smoothed, d.raw] : [d.smoothed], | |
| 141 | + ); | |
| 142 | + const lo = Math.min(...lineVals); | |
| 143 | + const hi = Math.max(...lineVals); | |
| 144 | + const pad = (hi - lo) * 0.12 || 5; | |
| 145 | + const yDomain: [number, number] = [lo - pad, hi + pad]; | |
| 146 | + | |
| 147 | + const tickFmt = (v: number) => | |
| 148 | + unit === "dollars" ? moneyCompact(v) : String(Math.round(v)); | |
| 149 | + | |
| 150 | + return ( | |
| 151 | + <div> | |
| 152 | + <div style={{ width: "100%", height: showBrush ? 396 : 340 }} | |
| 153 | + role="img" | |
| 154 | + aria-label={`Monthly index chart, ${data.length} observations with 95% confidence band`}> | |
| 155 | + <ResponsiveContainer> | |
| 156 | + <ComposedChart data={data} margin={{ top: 22, right: 12, left: 4, bottom: 0 }}> | |
| 157 | + <CartesianGrid stroke="var(--border)" strokeDasharray="2 4" vertical={false} /> | |
| 158 | + <XAxis | |
| 159 | + dataKey="period" | |
| 160 | + tick={{ fill: "var(--text-muted)", fontSize: 12 }} | |
| 161 | + tickLine={false} | |
| 162 | + axisLine={{ stroke: "var(--border)" }} | |
| 163 | + minTickGap={70} | |
| 164 | + /> | |
| 165 | + <YAxis | |
| 166 | + domain={yDomain} | |
| 167 | + allowDataOverflow | |
| 168 | + tickFormatter={tickFmt} | |
| 169 | + tick={{ fill: "var(--text-muted)", fontSize: 12 }} | |
| 170 | + tickLine={false} | |
| 171 | + axisLine={false} | |
| 172 | + width={unit === "dollars" ? 58 : 46} | |
| 173 | + /> | |
| 174 | + <Tooltip | |
| 175 | + content={(p) => <ChartTooltip {...p} unit={unit} />} | |
| 176 | + cursor={{ stroke: "var(--text-muted)", strokeWidth: 1 }} | |
| 177 | + /> | |
| 178 | + <Area | |
| 179 | + dataKey="band" | |
| 180 | + stroke="none" | |
| 181 | + fill="var(--band)" | |
| 182 | + isAnimationActive={false} | |
| 183 | + name="95% CI" | |
| 184 | + /> | |
| 185 | + {showRaw && ( | |
| 186 | + <Line | |
| 187 | + dataKey="raw" | |
| 188 | + stroke="var(--series-2)" | |
| 189 | + strokeWidth={1} | |
| 190 | + dot={false} | |
| 191 | + connectNulls | |
| 192 | + isAnimationActive={false} | |
| 193 | + name="Raw monthly" | |
| 194 | + /> | |
| 195 | + )} | |
| 196 | + <Line | |
| 197 | + dataKey="smoothed" | |
| 198 | + stroke="var(--series-1)" | |
| 199 | + strokeWidth={2} | |
| 200 | + dot={false} | |
| 201 | + isAnimationActive | |
| 202 | + animationDuration={700} | |
| 203 | + animationEasing="ease-out" | |
| 204 | + name={unit === "dollars" ? "Representative value" : "Index (smoothed)"} | |
| 205 | + /> | |
| 206 | + {visibleEvents.map((e) => ( | |
| 207 | + <ReferenceLine | |
| 208 | + key={e.period} | |
| 209 | + x={e.period} | |
| 210 | + stroke="var(--text-muted)" | |
| 211 | + strokeDasharray="4 4" | |
| 212 | + strokeWidth={1} | |
| 213 | + label={{ | |
| 214 | + value: e.short, | |
| 215 | + position: "top", | |
| 216 | + fill: "var(--text-muted)", | |
| 217 | + fontSize: 9.5, | |
| 218 | + }} | |
| 219 | + /> | |
| 220 | + ))} | |
| 221 | + {showBrush && ( | |
| 222 | + <Brush | |
| 223 | + dataKey="period" | |
| 224 | + height={26} | |
| 225 | + travellerWidth={8} | |
| 226 | + stroke="var(--border-strong)" | |
| 227 | + fill="var(--surface-2)" | |
| 228 | + /> | |
| 229 | + )} | |
| 230 | + </ComposedChart> | |
| 231 | + </ResponsiveContainer> | |
| 232 | + </div> | |
| 233 | + {showRaw && ( | |
| 234 | + <div className="note" style={{ display: "flex", gap: 16 }}> | |
| 235 | + <span><span style={{ color: "var(--series-1)" }}>―</span>{" "} | |
| 236 | + {unit === "dollars" ? "Representative value (smoothed)" : "Index (one-sided smoothed)"}</span> | |
| 237 | + <span><span style={{ color: "var(--series-2)" }}>―</span> Raw monthly estimate</span> | |
| 238 | + <span style={{ color: "var(--text-muted)" }}>▮ 95% CI</span> | |
| 239 | + </div> | |
| 240 | + )} | |
| 241 | + {showVolume && ( | |
| 242 | + <div style={{ width: "100%", height: 90, marginTop: 8 }}> | |
| 243 | + <ResponsiveContainer> | |
| 244 | + <BarChart data={data} margin={{ top: 0, right: 12, left: 4, bottom: 0 }}> | |
| 245 | + <XAxis dataKey="period" hide /> | |
| 246 | + <YAxis | |
| 247 | + tick={{ fill: "var(--text-muted)", fontSize: 11 }} | |
| 248 | + tickLine={false} | |
| 249 | + axisLine={false} | |
| 250 | + width={unit === "dollars" ? 58 : 46} | |
| 251 | + /> | |
| 252 | + <Tooltip | |
| 253 | + content={({ active, payload, label }) => | |
| 254 | + active && payload?.length ? ( | |
| 255 | + <div className="chart-tooltip"> | |
| 256 | + {label}: {(payload[0].payload as Point).transactions} transactions | |
| 257 | + </div> | |
| 258 | + ) : null | |
| 259 | + } | |
| 260 | + /> | |
| 261 | + <Bar | |
| 262 | + dataKey="transactions" | |
| 263 | + fill="var(--series-1)" | |
| 264 | + opacity={0.55} | |
| 265 | + isAnimationActive={false} | |
| 266 | + name="Transactions" | |
| 267 | + /> | |
| 268 | + </BarChart> | |
| 269 | + </ResponsiveContainer> | |
| 270 | + </div> | |
| 271 | + )} | |
| 272 | + </div> | |
| 273 | + ); | |
| 274 | +} | |
added
web/components/ReliabilityBadge.tsx
+38 −0
@@ -0,0 +1,38 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/ReliabilityBadge.tsx | |
| 7 | + * Purpose : Reliability grade badge (A–E) — icon + label, never color alone. | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | + | |
| 11 | +const DESCRIPTIONS: Record<string, string> = { | |
| 12 | + A: "Very strong — high liquidity, tight confidence band", | |
| 13 | + B: "Strong — reliable weekly signal", | |
| 14 | + C: "Moderate — partial shrinkage toward parent trend", | |
| 15 | + D: "Thin — heavy shrinkage, interpret levels not weeks", | |
| 16 | + E: "Model-implied — published for transparency only", | |
| 17 | +}; | |
| 18 | + | |
| 19 | +export default function ReliabilityBadge({ grade }: { grade: string }) { | |
| 20 | + const good = grade === "A" || grade === "B"; | |
| 21 | + const weak = grade === "D" || grade === "E"; | |
| 22 | + return ( | |
| 23 | + <span className="badge" title={DESCRIPTIONS[grade] ?? ""}> | |
| 24 | + <span | |
| 25 | + className="dot" | |
| 26 | + style={{ | |
| 27 | + background: good | |
| 28 | + ? "var(--good)" | |
| 29 | + : weak | |
| 30 | + ? "var(--serious)" | |
| 31 | + : "var(--series-4)", | |
| 32 | + }} | |
| 33 | + /> | |
| 34 | + Reliability {grade} | |
| 35 | + {weak ? " ⚠" : ""} | |
| 36 | + </span> | |
| 37 | + ); | |
| 38 | +} | |
added
web/components/SearchButton.tsx
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/SearchButton.tsx | |
| 7 | + * Purpose : Header search button — dispatches the ⌘K palette shortcut. | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | +"use client"; | |
| 11 | + | |
| 12 | +export default function SearchButton() { | |
| 13 | + return ( | |
| 14 | + <button | |
| 15 | + className="kbd-btn" | |
| 16 | + aria-label="Open command palette" | |
| 17 | + onClick={() => | |
| 18 | + window.dispatchEvent( | |
| 19 | + new KeyboardEvent("keydown", { key: "k", metaKey: true }))} | |
| 20 | + > | |
| 21 | + Search <kbd>⌘K</kbd> | |
| 22 | + </button> | |
| 23 | + ); | |
| 24 | +} | |
added
web/components/Skeleton.tsx
+61 −0
@@ -0,0 +1,61 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/Skeleton.tsx | |
| 7 | + * Purpose : Shimmer skeleton loaders — tiles, charts, tables (no "Loading…"). | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | + | |
| 11 | +export function SkeletonBlock({ h = 20, w = "100%", style = {} }: { | |
| 12 | + h?: number; w?: number | string; style?: React.CSSProperties; | |
| 13 | +}) { | |
| 14 | + return ( | |
| 15 | + <div className="skeleton" aria-hidden | |
| 16 | + style={{ height: h, width: w, ...style }} /> | |
| 17 | + ); | |
| 18 | +} | |
| 19 | + | |
| 20 | +export function TileSkeleton() { | |
| 21 | + return ( | |
| 22 | + <div className="card stat-tile" aria-busy="true"> | |
| 23 | + <SkeletonBlock h={11} w="55%" /> | |
| 24 | + <SkeletonBlock h={32} w="70%" style={{ marginTop: 8 }} /> | |
| 25 | + <SkeletonBlock h={12} w="45%" style={{ marginTop: 6 }} /> | |
| 26 | + </div> | |
| 27 | + ); | |
| 28 | +} | |
| 29 | + | |
| 30 | +export function ChartSkeleton({ height = 340 }: { height?: number }) { | |
| 31 | + return ( | |
| 32 | + <div className="card" aria-busy="true" aria-label="Chart loading"> | |
| 33 | + <SkeletonBlock h={14} w={220} /> | |
| 34 | + <SkeletonBlock h={height - 60} style={{ marginTop: 14 }} /> | |
| 35 | + </div> | |
| 36 | + ); | |
| 37 | +} | |
| 38 | + | |
| 39 | +export function TableSkeleton({ rows = 8 }: { rows?: number }) { | |
| 40 | + return ( | |
| 41 | + <div className="card" aria-busy="true" style={{ padding: "10px 14px" }}> | |
| 42 | + {Array.from({ length: rows }, (_, i) => ( | |
| 43 | + <SkeletonBlock key={i} h={16} | |
| 44 | + style={{ marginTop: i ? 12 : 4, opacity: 1 - i * 0.09 }} /> | |
| 45 | + ))} | |
| 46 | + </div> | |
| 47 | + ); | |
| 48 | +} | |
| 49 | + | |
| 50 | +export function DashboardSkeleton() { | |
| 51 | + return ( | |
| 52 | + <> | |
| 53 | + <div className="grid cols-4" style={{ marginTop: 24 }}> | |
| 54 | + {Array.from({ length: 4 }, (_, i) => <TileSkeleton key={i} />)} | |
| 55 | + </div> | |
| 56 | + <div style={{ marginTop: 16 }}> | |
| 57 | + <TableSkeleton rows={10} /> | |
| 58 | + </div> | |
| 59 | + </> | |
| 60 | + ); | |
| 61 | +} | |
added
web/components/Sparkline.tsx
+43 −0
@@ -0,0 +1,43 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/Sparkline.tsx | |
| 7 | + * Purpose : Inline SVG sparkline for stat tiles (no axes, one series). | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | + | |
| 11 | +export default function Sparkline({ | |
| 12 | + values, | |
| 13 | + width = 160, | |
| 14 | + height = 40, | |
| 15 | +}: { | |
| 16 | + values: number[]; | |
| 17 | + width?: number; | |
| 18 | + height?: number; | |
| 19 | +}) { | |
| 20 | + if (values.length < 2) return null; | |
| 21 | + const min = Math.min(...values); | |
| 22 | + const max = Math.max(...values); | |
| 23 | + const span = max - min || 1; | |
| 24 | + const pts = values | |
| 25 | + .map((v, i) => { | |
| 26 | + const x = (i / (values.length - 1)) * (width - 4) + 2; | |
| 27 | + const y = height - 3 - ((v - min) / span) * (height - 6); | |
| 28 | + return `${x.toFixed(1)},${y.toFixed(1)}`; | |
| 29 | + }) | |
| 30 | + .join(" "); | |
| 31 | + return ( | |
| 32 | + <svg width={width} height={height} role="img" aria-label="index sparkline"> | |
| 33 | + <polyline | |
| 34 | + points={pts} | |
| 35 | + fill="none" | |
| 36 | + stroke="var(--series-1)" | |
| 37 | + strokeWidth={2} | |
| 38 | + strokeLinejoin="round" | |
| 39 | + strokeLinecap="round" | |
| 40 | + /> | |
| 41 | + </svg> | |
| 42 | + ); | |
| 43 | +} | |
added
web/components/ThemeToggle.tsx
+42 −0
@@ -0,0 +1,42 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/ThemeToggle.tsx | |
| 7 | + * Purpose : Light/dark theme toggle (data-theme attribute wins over OS). | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | +"use client"; | |
| 11 | + | |
| 12 | +import { useEffect, useState } from "react"; | |
| 13 | + | |
| 14 | +export default function ThemeToggle() { | |
| 15 | + const [theme, setTheme] = useState<string | null>(null); | |
| 16 | + | |
| 17 | + useEffect(() => { | |
| 18 | + const saved = localStorage.getItem("qwhpi-theme"); | |
| 19 | + if (saved) { | |
| 20 | + setTheme(saved); | |
| 21 | + document.documentElement.dataset.theme = saved; | |
| 22 | + } | |
| 23 | + }, []); | |
| 24 | + | |
| 25 | + const toggle = () => { | |
| 26 | + const current = | |
| 27 | + theme ?? | |
| 28 | + (window.matchMedia("(prefers-color-scheme: dark)").matches | |
| 29 | + ? "dark" | |
| 30 | + : "light"); | |
| 31 | + const next = current === "dark" ? "light" : "dark"; | |
| 32 | + setTheme(next); | |
| 33 | + document.documentElement.dataset.theme = next; | |
| 34 | + localStorage.setItem("qwhpi-theme", next); | |
| 35 | + }; | |
| 36 | + | |
| 37 | + return ( | |
| 38 | + <button className="ctrl" onClick={toggle} aria-label="Toggle color theme"> | |
| 39 | + {theme === "dark" ? "Light" : "Dark"} mode | |
| 40 | + </button> | |
| 41 | + ); | |
| 42 | +} | |
added
web/components/Ticker.tsx
+54 −0
@@ -0,0 +1,54 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/components/Ticker.tsx | |
| 7 | + * Purpose : Bloomberg-style infinite ticker tape — regions & cities YoY. | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | +"use client"; | |
| 11 | + | |
| 12 | +import { useEffect, useState } from "react"; | |
| 13 | +import Link from "next/link"; | |
| 14 | +import { fetchOverview, type OverviewRow } from "../lib/api"; | |
| 15 | + | |
| 16 | +export default function Ticker({ ptype = "all" }: { ptype?: string }) { | |
| 17 | + const [rows, setRows] = useState<OverviewRow[]>([]); | |
| 18 | + | |
| 19 | + useEffect(() => { | |
| 20 | + Promise.all([ | |
| 21 | + fetchOverview("region", ptype), | |
| 22 | + fetchOverview("municipality", ptype), | |
| 23 | + ]) | |
| 24 | + .then(([r, m]) => setRows([...m.rows, ...r.rows])) | |
| 25 | + .catch(() => setRows([])); | |
| 26 | + }, [ptype]); | |
| 27 | + | |
| 28 | + if (!rows.length) return <div className="ticker-shell" aria-hidden />; | |
| 29 | + | |
| 30 | + const items = rows.map((r) => ( | |
| 31 | + <Link | |
| 32 | + key={r.geography_id} | |
| 33 | + className="ticker-item" | |
| 34 | + href={`/explore?geography=${r.geography_id}&type=${ptype}`} | |
| 35 | + > | |
| 36 | + <span className="ticker-name">{r.geography_name}</span> | |
| 37 | + <span className="ticker-val">{r.index.toFixed(1)}</span> | |
| 38 | + <span className={r.yoy_pct != null && r.yoy_pct < 0 ? "delta down" : "delta up"}> | |
| 39 | + {r.yoy_pct == null ? "—" | |
| 40 | + : `${r.yoy_pct >= 0 ? "▲" : "▼"}${Math.abs(r.yoy_pct).toFixed(1)}%`} | |
| 41 | + </span> | |
| 42 | + </Link> | |
| 43 | + )); | |
| 44 | + | |
| 45 | + return ( | |
| 46 | + <div className="ticker-shell" role="marquee" | |
| 47 | + aria-label="Latest year-over-year appreciation by geography"> | |
| 48 | + <div className="ticker-track"> | |
| 49 | + <div className="ticker-group">{items}</div> | |
| 50 | + <div className="ticker-group" aria-hidden>{items}</div> | |
| 51 | + </div> | |
| 52 | + </div> | |
| 53 | + ); | |
| 54 | +} | |
added
web/lib/api.ts
+166 −0
@@ -0,0 +1,166 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/lib/api.ts | |
| 7 | + * Purpose : Typed API client for the QHPI monthly FastAPI service. | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | + | |
| 11 | +export const API_BASE = | |
| 12 | + process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080"; | |
| 13 | + | |
| 14 | +export interface Observation { | |
| 15 | + period: string; | |
| 16 | + index: number | null; | |
| 17 | + index_smoothed: number; | |
| 18 | + representative_value: number | null; | |
| 19 | + transactions: number; | |
| 20 | + effective_sample_size: number | null; | |
| 21 | + monthly_pct: number | null; | |
| 22 | + three_month_pct: number | null; | |
| 23 | + six_month_pct: number | null; | |
| 24 | + yoy_pct: number | null; | |
| 25 | + lower_95: number; | |
| 26 | + upper_95: number; | |
| 27 | + reliability_grade: string; | |
| 28 | + shrinkage_weight: number | null; | |
| 29 | + is_partial_month: boolean; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export interface SeriesResponse { | |
| 33 | + geography: string; | |
| 34 | + geography_name: string; | |
| 35 | + geography_level: string; | |
| 36 | + property_type: string; | |
| 37 | + model_version: string; | |
| 38 | + data_vintage: string; | |
| 39 | + total_observations: number; | |
| 40 | + observations: Observation[]; | |
| 41 | +} | |
| 42 | + | |
| 43 | +export interface LatestResponse { | |
| 44 | + geography: string; | |
| 45 | + geography_name: string; | |
| 46 | + property_type: string; | |
| 47 | + period: string; | |
| 48 | + latest_index: number; | |
| 49 | + representative_value: number | null; | |
| 50 | + monthly_change: number | null; | |
| 51 | + three_month_change: number | null; | |
| 52 | + yoy_change: number | null; | |
| 53 | + transactions: number; | |
| 54 | + reliability: string; | |
| 55 | + lower_95: number; | |
| 56 | + upper_95: number; | |
| 57 | + is_partial_month: boolean; | |
| 58 | + data_vintage: string; | |
| 59 | +} | |
| 60 | + | |
| 61 | +export interface GeographyNode { | |
| 62 | + geography_id: string; | |
| 63 | + geography_name: string; | |
| 64 | + geography_level: string; | |
| 65 | + property_types: string[]; | |
| 66 | +} | |
| 67 | + | |
| 68 | +export interface MapCell { | |
| 69 | + geography_id: string; | |
| 70 | + geography_name: string; | |
| 71 | + value: number | null; | |
| 72 | + reliability_grade: string; | |
| 73 | + transactions: number; | |
| 74 | +} | |
| 75 | + | |
| 76 | +async function get<T>(path: string): Promise<T> { | |
| 77 | + const res = await fetch(`${API_BASE}${path}`, { | |
| 78 | + headers: { Accept: "application/json" }, | |
| 79 | + }); | |
| 80 | + if (!res.ok) throw new Error(`${res.status} ${res.statusText} — ${path}`); | |
| 81 | + return res.json() as Promise<T>; | |
| 82 | +} | |
| 83 | + | |
| 84 | +export const fetchSeries = (g: string, t: string) => | |
| 85 | + get<SeriesResponse>(`/v1/index?geography=${g}&type=${t}`); | |
| 86 | + | |
| 87 | +export const fetchLatest = (g: string, t: string) => | |
| 88 | + get<LatestResponse>(`/v1/index/latest?geography=${g}&type=${t}`); | |
| 89 | + | |
| 90 | +export const fetchGeographies = () => | |
| 91 | + get<{ published_series: GeographyNode[]; data_vintage: string }>( | |
| 92 | + "/v1/geographies", | |
| 93 | + ); | |
| 94 | + | |
| 95 | +export const fetchMap = (metric: string, type: string, period?: string) => | |
| 96 | + get<{ metric: string; period: string; cells: MapCell[] }>( | |
| 97 | + `/v1/map?metric=${metric}&level=region&type=${type}` + | |
| 98 | + (period ? `&period=${period}` : ""), | |
| 99 | + ); | |
| 100 | + | |
| 101 | +export const csvUrl = (g: string, t: string) => | |
| 102 | + `${API_BASE}/v1/index?geography=${g}&type=${t}&format=csv`; | |
| 103 | + | |
| 104 | +export interface SeriesStats { | |
| 105 | + geography: string; | |
| 106 | + property_type: string; | |
| 107 | + period: string; | |
| 108 | + index: number; | |
| 109 | + representative_value: number | null; | |
| 110 | + lower_95: number; | |
| 111 | + upper_95: number; | |
| 112 | + reliability_grade: string; | |
| 113 | + monthly_pct: number | null; | |
| 114 | + three_month_pct: number | null; | |
| 115 | + six_month_pct: number | null; | |
| 116 | + yoy_pct: number | null; | |
| 117 | + since_2021_pct: number; | |
| 118 | + cagr_pct: number; | |
| 119 | + peak_index: number; | |
| 120 | + peak_period: string; | |
| 121 | + drawdown_pct: number; | |
| 122 | + months_since_peak: number; | |
| 123 | + at_record_high: boolean; | |
| 124 | + volatility_12m_pct: number; | |
| 125 | + momentum_3m_ann_pct: number; | |
| 126 | + yoy_rank: number | null; | |
| 127 | + yoy_rank_of: number; | |
| 128 | + volume_12m: number; | |
| 129 | + volume_yoy_pct: number | null; | |
| 130 | + dollar_volume_12m: number; | |
| 131 | + assessment_gap: number | null; | |
| 132 | + effective_sample_size: number | null; | |
| 133 | + spark: number[]; | |
| 134 | +} | |
| 135 | + | |
| 136 | +export interface OverviewRow { | |
| 137 | + geography_id: string; | |
| 138 | + geography_name: string; | |
| 139 | + period: string; | |
| 140 | + index: number; | |
| 141 | + monthly_pct: number | null; | |
| 142 | + three_month_pct: number | null; | |
| 143 | + six_month_pct: number | null; | |
| 144 | + yoy_pct: number | null; | |
| 145 | + since_2021_pct: number; | |
| 146 | + drawdown_pct: number; | |
| 147 | + at_record_high: boolean; | |
| 148 | + volume_12m: number; | |
| 149 | + representative_value: number | null; | |
| 150 | + reliability_grade: string; | |
| 151 | + spark: number[]; | |
| 152 | +} | |
| 153 | + | |
| 154 | +export const fetchStats = (g: string, t: string) => | |
| 155 | + get<SeriesStats>(`/v1/stats?geography=${g}&type=${t}`); | |
| 156 | + | |
| 157 | +export const fetchOverview = (level: string, t: string) => | |
| 158 | + get<{ period: string; rows: OverviewRow[] }>( | |
| 159 | + `/v1/stats/overview?level=${level}&type=${t}`, | |
| 160 | + ); | |
| 161 | + | |
| 162 | +export const reportUrl = (specs: string[]) => | |
| 163 | + `${API_BASE}/v1/report?series=${specs.join(",")}`; | |
| 164 | + | |
| 165 | +export const marketReportUrl = (t: string) => | |
| 166 | + `${API_BASE}/v1/report/market?type=${t}`; | |
added
web/lib/events.ts
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/lib/events.ts | |
| 7 | + * Purpose : Market event annotations (Bank of Canada policy milestones) | |
| 8 | + * overlaid on index charts. | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | + | |
| 12 | +export interface MarketEvent { | |
| 13 | + period: string; // YYYY-MM | |
| 14 | + label: string; | |
| 15 | + short: string; | |
| 16 | +} | |
| 17 | + | |
| 18 | +// Bank of Canada policy-rate milestones relevant to the 2021-2026 window. | |
| 19 | +export const MARKET_EVENTS: MarketEvent[] = [ | |
| 20 | + { period: "2022-03", label: "BoC starts hiking (0.25% → 0.50%)", short: "First hike" }, | |
| 21 | + { period: "2022-07", label: "BoC +100 bp surprise hike", short: "+100 bp" }, | |
| 22 | + { period: "2023-07", label: "Policy rate peaks at 5.00%", short: "Peak 5%" }, | |
| 23 | + { period: "2024-06", label: "BoC first rate cut", short: "First cut" }, | |
| 24 | +]; | |
added
web/lib/exportPng.ts
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/lib/exportPng.ts | |
| 7 | + * Purpose : Chart → PNG export (SVG serialization to canvas, no dependency). | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | + | |
| 11 | +/** Resolve CSS custom properties so the serialized SVG is standalone. */ | |
| 12 | +function inlineColors(svg: SVGSVGElement): SVGSVGElement { | |
| 13 | + const clone = svg.cloneNode(true) as SVGSVGElement; | |
| 14 | + const styles = getComputedStyle(document.documentElement); | |
| 15 | + const resolve = (v: string) => | |
| 16 | + v.replace(/var\((--[a-z0-9-]+)\)/gi, (_, name) => | |
| 17 | + styles.getPropertyValue(name).trim() || "#888"); | |
| 18 | + const walk = (el: Element) => { | |
| 19 | + for (const attr of ["fill", "stroke", "color", "style"]) { | |
| 20 | + const v = el.getAttribute(attr); | |
| 21 | + if (v && v.includes("var(")) el.setAttribute(attr, resolve(v)); | |
| 22 | + } | |
| 23 | + for (const child of Array.from(el.children)) walk(child); | |
| 24 | + }; | |
| 25 | + walk(clone); | |
| 26 | + return clone; | |
| 27 | +} | |
| 28 | + | |
| 29 | +export async function exportChartPng( | |
| 30 | + container: HTMLElement, | |
| 31 | + filename: string, | |
| 32 | + credit = "QHPI — indexqc.house · Simon-Pierre Boucher", | |
| 33 | +): Promise<void> { | |
| 34 | + const svg = container.querySelector<SVGSVGElement>("svg.recharts-surface, svg"); | |
| 35 | + if (!svg) return; | |
| 36 | + const rect = svg.getBoundingClientRect(); | |
| 37 | + const scale = 2; | |
| 38 | + const w = Math.round(rect.width); | |
| 39 | + const h = Math.round(rect.height); | |
| 40 | + | |
| 41 | + const clone = inlineColors(svg); | |
| 42 | + clone.setAttribute("width", String(w)); | |
| 43 | + clone.setAttribute("height", String(h)); | |
| 44 | + const xml = new XMLSerializer().serializeToString(clone); | |
| 45 | + const url = URL.createObjectURL( | |
| 46 | + new Blob([xml], { type: "image/svg+xml;charset=utf-8" })); | |
| 47 | + | |
| 48 | + const img = new Image(); | |
| 49 | + await new Promise((res, rej) => { | |
| 50 | + img.onload = res; | |
| 51 | + img.onerror = rej; | |
| 52 | + img.src = url; | |
| 53 | + }); | |
| 54 | + | |
| 55 | + const surface = getComputedStyle(document.documentElement) | |
| 56 | + .getPropertyValue("--surface-1").trim() || "#fff"; | |
| 57 | + const ink2 = getComputedStyle(document.documentElement) | |
| 58 | + .getPropertyValue("--text-secondary").trim() || "#555"; | |
| 59 | + | |
| 60 | + const canvas = document.createElement("canvas"); | |
| 61 | + canvas.width = w * scale; | |
| 62 | + canvas.height = (h + 26) * scale; | |
| 63 | + const ctx = canvas.getContext("2d")!; | |
| 64 | + ctx.scale(scale, scale); | |
| 65 | + ctx.fillStyle = surface; | |
| 66 | + ctx.fillRect(0, 0, w, h + 26); | |
| 67 | + ctx.drawImage(img, 0, 0, w, h); | |
| 68 | + ctx.fillStyle = ink2; | |
| 69 | + ctx.font = "10px system-ui, sans-serif"; | |
| 70 | + ctx.fillText(credit, 8, h + 16); | |
| 71 | + URL.revokeObjectURL(url); | |
| 72 | + | |
| 73 | + const a = document.createElement("a"); | |
| 74 | + a.download = filename; | |
| 75 | + a.href = canvas.toDataURL("image/png"); | |
| 76 | + a.click(); | |
| 77 | +} | |
added
web/next.config.ts
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/next.config.ts | |
| 7 | + * Purpose : Next.js configuration for the QWHPI dashboard. | |
| 8 | + * ============================================================================= | |
| 9 | + */ | |
| 10 | +import type { NextConfig } from "next"; | |
| 11 | + | |
| 12 | +const nextConfig: NextConfig = { | |
| 13 | + reactStrictMode: true, | |
| 14 | + // Single-domain deployments: the dashboard origin proxies API routes to the | |
| 15 | + // local FastAPI process (set NEXT_PUBLIC_API_URL="" at build time). | |
| 16 | + async rewrites() { | |
| 17 | + const api = process.env.QWHPI_API_INTERNAL ?? "http://127.0.0.1:8080"; | |
| 18 | + return [ | |
| 19 | + { source: "/v1/:path*", destination: `${api}/v1/:path*` }, | |
| 20 | + { source: "/docs", destination: `${api}/docs` }, | |
| 21 | + { source: "/openapi.json", destination: `${api}/openapi.json` }, | |
| 22 | + { source: "/health", destination: `${api}/health` }, | |
| 23 | + ]; | |
| 24 | + }, | |
| 25 | +}; | |
| 26 | + | |
| 27 | +export default nextConfig; | |
added
web/package-lock.json
+1413 −0
@@ -0,0 +1,1413 @@ | ||
| 1 | +{ | |
| 2 | + "name": "qwhpi-web", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "lockfileVersion": 3, | |
| 5 | + "requires": true, | |
| 6 | + "packages": { | |
| 7 | + "": { | |
| 8 | + "name": "qwhpi-web", | |
| 9 | + "version": "0.1.0", | |
| 10 | + "dependencies": { | |
| 11 | + "next": "^15.3.0", | |
| 12 | + "react": "^19.0.0", | |
| 13 | + "react-dom": "^19.0.0", | |
| 14 | + "recharts": "^2.15.0" | |
| 15 | + }, | |
| 16 | + "devDependencies": { | |
| 17 | + "@types/node": "^22", | |
| 18 | + "@types/react": "^19", | |
| 19 | + "@types/react-dom": "^19", | |
| 20 | + "typescript": "^5.6" | |
| 21 | + } | |
| 22 | + }, | |
| 23 | + "node_modules/@babel/runtime": { | |
| 24 | + "version": "7.29.7", | |
| 25 | + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", | |
| 26 | + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", | |
| 27 | + "license": "MIT", | |
| 28 | + "engines": { | |
| 29 | + "node": ">=6.9.0" | |
| 30 | + } | |
| 31 | + }, | |
| 32 | + "node_modules/@emnapi/runtime": { | |
| 33 | + "version": "1.11.3", | |
| 34 | + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", | |
| 35 | + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", | |
| 36 | + "license": "MIT", | |
| 37 | + "optional": true, | |
| 38 | + "dependencies": { | |
| 39 | + "tslib": "^2.4.0" | |
| 40 | + } | |
| 41 | + }, | |
| 42 | + "node_modules/@img/colour": { | |
| 43 | + "version": "1.1.0", | |
| 44 | + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", | |
| 45 | + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", | |
| 46 | + "license": "MIT", | |
| 47 | + "optional": true, | |
| 48 | + "engines": { | |
| 49 | + "node": ">=18" | |
| 50 | + } | |
| 51 | + }, | |
| 52 | + "node_modules/@img/sharp-darwin-arm64": { | |
| 53 | + "version": "0.34.5", | |
| 54 | + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", | |
| 55 | + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", | |
| 56 | + "cpu": [ | |
| 57 | + "arm64" | |
| 58 | + ], | |
| 59 | + "license": "Apache-2.0", | |
| 60 | + "optional": true, | |
| 61 | + "os": [ | |
| 62 | + "darwin" | |
| 63 | + ], | |
| 64 | + "engines": { | |
| 65 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 66 | + }, | |
| 67 | + "funding": { | |
| 68 | + "url": "https://opencollective.com/libvips" | |
| 69 | + }, | |
| 70 | + "optionalDependencies": { | |
| 71 | + "@img/sharp-libvips-darwin-arm64": "1.2.4" | |
| 72 | + } | |
| 73 | + }, | |
| 74 | + "node_modules/@img/sharp-darwin-x64": { | |
| 75 | + "version": "0.34.5", | |
| 76 | + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", | |
| 77 | + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", | |
| 78 | + "cpu": [ | |
| 79 | + "x64" | |
| 80 | + ], | |
| 81 | + "license": "Apache-2.0", | |
| 82 | + "optional": true, | |
| 83 | + "os": [ | |
| 84 | + "darwin" | |
| 85 | + ], | |
| 86 | + "engines": { | |
| 87 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 88 | + }, | |
| 89 | + "funding": { | |
| 90 | + "url": "https://opencollective.com/libvips" | |
| 91 | + }, | |
| 92 | + "optionalDependencies": { | |
| 93 | + "@img/sharp-libvips-darwin-x64": "1.2.4" | |
| 94 | + } | |
| 95 | + }, | |
| 96 | + "node_modules/@img/sharp-libvips-darwin-arm64": { | |
| 97 | + "version": "1.2.4", | |
| 98 | + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", | |
| 99 | + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", | |
| 100 | + "cpu": [ | |
| 101 | + "arm64" | |
| 102 | + ], | |
| 103 | + "license": "LGPL-3.0-or-later", | |
| 104 | + "optional": true, | |
| 105 | + "os": [ | |
| 106 | + "darwin" | |
| 107 | + ], | |
| 108 | + "funding": { | |
| 109 | + "url": "https://opencollective.com/libvips" | |
| 110 | + } | |
| 111 | + }, | |
| 112 | + "node_modules/@img/sharp-libvips-darwin-x64": { | |
| 113 | + "version": "1.2.4", | |
| 114 | + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", | |
| 115 | + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", | |
| 116 | + "cpu": [ | |
| 117 | + "x64" | |
| 118 | + ], | |
| 119 | + "license": "LGPL-3.0-or-later", | |
| 120 | + "optional": true, | |
| 121 | + "os": [ | |
| 122 | + "darwin" | |
| 123 | + ], | |
| 124 | + "funding": { | |
| 125 | + "url": "https://opencollective.com/libvips" | |
| 126 | + } | |
| 127 | + }, | |
| 128 | + "node_modules/@img/sharp-libvips-linux-arm": { | |
| 129 | + "version": "1.2.4", | |
| 130 | + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", | |
| 131 | + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", | |
| 132 | + "cpu": [ | |
| 133 | + "arm" | |
| 134 | + ], | |
| 135 | + "libc": [ | |
| 136 | + "glibc" | |
| 137 | + ], | |
| 138 | + "license": "LGPL-3.0-or-later", | |
| 139 | + "optional": true, | |
| 140 | + "os": [ | |
| 141 | + "linux" | |
| 142 | + ], | |
| 143 | + "funding": { | |
| 144 | + "url": "https://opencollective.com/libvips" | |
| 145 | + } | |
| 146 | + }, | |
| 147 | + "node_modules/@img/sharp-libvips-linux-arm64": { | |
| 148 | + "version": "1.2.4", | |
| 149 | + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", | |
| 150 | + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", | |
| 151 | + "cpu": [ | |
| 152 | + "arm64" | |
| 153 | + ], | |
| 154 | + "libc": [ | |
| 155 | + "glibc" | |
| 156 | + ], | |
| 157 | + "license": "LGPL-3.0-or-later", | |
| 158 | + "optional": true, | |
| 159 | + "os": [ | |
| 160 | + "linux" | |
| 161 | + ], | |
| 162 | + "funding": { | |
| 163 | + "url": "https://opencollective.com/libvips" | |
| 164 | + } | |
| 165 | + }, | |
| 166 | + "node_modules/@img/sharp-libvips-linux-ppc64": { | |
| 167 | + "version": "1.2.4", | |
| 168 | + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", | |
| 169 | + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", | |
| 170 | + "cpu": [ | |
| 171 | + "ppc64" | |
| 172 | + ], | |
| 173 | + "libc": [ | |
| 174 | + "glibc" | |
| 175 | + ], | |
| 176 | + "license": "LGPL-3.0-or-later", | |
| 177 | + "optional": true, | |
| 178 | + "os": [ | |
| 179 | + "linux" | |
| 180 | + ], | |
| 181 | + "funding": { | |
| 182 | + "url": "https://opencollective.com/libvips" | |
| 183 | + } | |
| 184 | + }, | |
| 185 | + "node_modules/@img/sharp-libvips-linux-riscv64": { | |
| 186 | + "version": "1.2.4", | |
| 187 | + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", | |
| 188 | + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", | |
| 189 | + "cpu": [ | |
| 190 | + "riscv64" | |
| 191 | + ], | |
| 192 | + "libc": [ | |
| 193 | + "glibc" | |
| 194 | + ], | |
| 195 | + "license": "LGPL-3.0-or-later", | |
| 196 | + "optional": true, | |
| 197 | + "os": [ | |
| 198 | + "linux" | |
| 199 | + ], | |
| 200 | + "funding": { | |
| 201 | + "url": "https://opencollective.com/libvips" | |
| 202 | + } | |
| 203 | + }, | |
| 204 | + "node_modules/@img/sharp-libvips-linux-s390x": { | |
| 205 | + "version": "1.2.4", | |
| 206 | + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", | |
| 207 | + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", | |
| 208 | + "cpu": [ | |
| 209 | + "s390x" | |
| 210 | + ], | |
| 211 | + "libc": [ | |
| 212 | + "glibc" | |
| 213 | + ], | |
| 214 | + "license": "LGPL-3.0-or-later", | |
| 215 | + "optional": true, | |
| 216 | + "os": [ | |
| 217 | + "linux" | |
| 218 | + ], | |
| 219 | + "funding": { | |
| 220 | + "url": "https://opencollective.com/libvips" | |
| 221 | + } | |
| 222 | + }, | |
| 223 | + "node_modules/@img/sharp-libvips-linux-x64": { | |
| 224 | + "version": "1.2.4", | |
| 225 | + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", | |
| 226 | + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", | |
| 227 | + "cpu": [ | |
| 228 | + "x64" | |
| 229 | + ], | |
| 230 | + "libc": [ | |
| 231 | + "glibc" | |
| 232 | + ], | |
| 233 | + "license": "LGPL-3.0-or-later", | |
| 234 | + "optional": true, | |
| 235 | + "os": [ | |
| 236 | + "linux" | |
| 237 | + ], | |
| 238 | + "funding": { | |
| 239 | + "url": "https://opencollective.com/libvips" | |
| 240 | + } | |
| 241 | + }, | |
| 242 | + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { | |
| 243 | + "version": "1.2.4", | |
| 244 | + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", | |
| 245 | + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", | |
| 246 | + "cpu": [ | |
| 247 | + "arm64" | |
| 248 | + ], | |
| 249 | + "libc": [ | |
| 250 | + "musl" | |
| 251 | + ], | |
| 252 | + "license": "LGPL-3.0-or-later", | |
| 253 | + "optional": true, | |
| 254 | + "os": [ | |
| 255 | + "linux" | |
| 256 | + ], | |
| 257 | + "funding": { | |
| 258 | + "url": "https://opencollective.com/libvips" | |
| 259 | + } | |
| 260 | + }, | |
| 261 | + "node_modules/@img/sharp-libvips-linuxmusl-x64": { | |
| 262 | + "version": "1.2.4", | |
| 263 | + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", | |
| 264 | + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", | |
| 265 | + "cpu": [ | |
| 266 | + "x64" | |
| 267 | + ], | |
| 268 | + "libc": [ | |
| 269 | + "musl" | |
| 270 | + ], | |
| 271 | + "license": "LGPL-3.0-or-later", | |
| 272 | + "optional": true, | |
| 273 | + "os": [ | |
| 274 | + "linux" | |
| 275 | + ], | |
| 276 | + "funding": { | |
| 277 | + "url": "https://opencollective.com/libvips" | |
| 278 | + } | |
| 279 | + }, | |
| 280 | + "node_modules/@img/sharp-linux-arm": { | |
| 281 | + "version": "0.34.5", | |
| 282 | + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", | |
| 283 | + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", | |
| 284 | + "cpu": [ | |
| 285 | + "arm" | |
| 286 | + ], | |
| 287 | + "libc": [ | |
| 288 | + "glibc" | |
| 289 | + ], | |
| 290 | + "license": "Apache-2.0", | |
| 291 | + "optional": true, | |
| 292 | + "os": [ | |
| 293 | + "linux" | |
| 294 | + ], | |
| 295 | + "engines": { | |
| 296 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 297 | + }, | |
| 298 | + "funding": { | |
| 299 | + "url": "https://opencollective.com/libvips" | |
| 300 | + }, | |
| 301 | + "optionalDependencies": { | |
| 302 | + "@img/sharp-libvips-linux-arm": "1.2.4" | |
| 303 | + } | |
| 304 | + }, | |
| 305 | + "node_modules/@img/sharp-linux-arm64": { | |
| 306 | + "version": "0.34.5", | |
| 307 | + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", | |
| 308 | + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", | |
| 309 | + "cpu": [ | |
| 310 | + "arm64" | |
| 311 | + ], | |
| 312 | + "libc": [ | |
| 313 | + "glibc" | |
| 314 | + ], | |
| 315 | + "license": "Apache-2.0", | |
| 316 | + "optional": true, | |
| 317 | + "os": [ | |
| 318 | + "linux" | |
| 319 | + ], | |
| 320 | + "engines": { | |
| 321 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 322 | + }, | |
| 323 | + "funding": { | |
| 324 | + "url": "https://opencollective.com/libvips" | |
| 325 | + }, | |
| 326 | + "optionalDependencies": { | |
| 327 | + "@img/sharp-libvips-linux-arm64": "1.2.4" | |
| 328 | + } | |
| 329 | + }, | |
| 330 | + "node_modules/@img/sharp-linux-ppc64": { | |
| 331 | + "version": "0.34.5", | |
| 332 | + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", | |
| 333 | + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", | |
| 334 | + "cpu": [ | |
| 335 | + "ppc64" | |
| 336 | + ], | |
| 337 | + "libc": [ | |
| 338 | + "glibc" | |
| 339 | + ], | |
| 340 | + "license": "Apache-2.0", | |
| 341 | + "optional": true, | |
| 342 | + "os": [ | |
| 343 | + "linux" | |
| 344 | + ], | |
| 345 | + "engines": { | |
| 346 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 347 | + }, | |
| 348 | + "funding": { | |
| 349 | + "url": "https://opencollective.com/libvips" | |
| 350 | + }, | |
| 351 | + "optionalDependencies": { | |
| 352 | + "@img/sharp-libvips-linux-ppc64": "1.2.4" | |
| 353 | + } | |
| 354 | + }, | |
| 355 | + "node_modules/@img/sharp-linux-riscv64": { | |
| 356 | + "version": "0.34.5", | |
| 357 | + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", | |
| 358 | + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", | |
| 359 | + "cpu": [ | |
| 360 | + "riscv64" | |
| 361 | + ], | |
| 362 | + "libc": [ | |
| 363 | + "glibc" | |
| 364 | + ], | |
| 365 | + "license": "Apache-2.0", | |
| 366 | + "optional": true, | |
| 367 | + "os": [ | |
| 368 | + "linux" | |
| 369 | + ], | |
| 370 | + "engines": { | |
| 371 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 372 | + }, | |
| 373 | + "funding": { | |
| 374 | + "url": "https://opencollective.com/libvips" | |
| 375 | + }, | |
| 376 | + "optionalDependencies": { | |
| 377 | + "@img/sharp-libvips-linux-riscv64": "1.2.4" | |
| 378 | + } | |
| 379 | + }, | |
| 380 | + "node_modules/@img/sharp-linux-s390x": { | |
| 381 | + "version": "0.34.5", | |
| 382 | + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", | |
| 383 | + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", | |
| 384 | + "cpu": [ | |
| 385 | + "s390x" | |
| 386 | + ], | |
| 387 | + "libc": [ | |
| 388 | + "glibc" | |
| 389 | + ], | |
| 390 | + "license": "Apache-2.0", | |
| 391 | + "optional": true, | |
| 392 | + "os": [ | |
| 393 | + "linux" | |
| 394 | + ], | |
| 395 | + "engines": { | |
| 396 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 397 | + }, | |
| 398 | + "funding": { | |
| 399 | + "url": "https://opencollective.com/libvips" | |
| 400 | + }, | |
| 401 | + "optionalDependencies": { | |
| 402 | + "@img/sharp-libvips-linux-s390x": "1.2.4" | |
| 403 | + } | |
| 404 | + }, | |
| 405 | + "node_modules/@img/sharp-linux-x64": { | |
| 406 | + "version": "0.34.5", | |
| 407 | + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", | |
| 408 | + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", | |
| 409 | + "cpu": [ | |
| 410 | + "x64" | |
| 411 | + ], | |
| 412 | + "libc": [ | |
| 413 | + "glibc" | |
| 414 | + ], | |
| 415 | + "license": "Apache-2.0", | |
| 416 | + "optional": true, | |
| 417 | + "os": [ | |
| 418 | + "linux" | |
| 419 | + ], | |
| 420 | + "engines": { | |
| 421 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 422 | + }, | |
| 423 | + "funding": { | |
| 424 | + "url": "https://opencollective.com/libvips" | |
| 425 | + }, | |
| 426 | + "optionalDependencies": { | |
| 427 | + "@img/sharp-libvips-linux-x64": "1.2.4" | |
| 428 | + } | |
| 429 | + }, | |
| 430 | + "node_modules/@img/sharp-linuxmusl-arm64": { | |
| 431 | + "version": "0.34.5", | |
| 432 | + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", | |
| 433 | + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", | |
| 434 | + "cpu": [ | |
| 435 | + "arm64" | |
| 436 | + ], | |
| 437 | + "libc": [ | |
| 438 | + "musl" | |
| 439 | + ], | |
| 440 | + "license": "Apache-2.0", | |
| 441 | + "optional": true, | |
| 442 | + "os": [ | |
| 443 | + "linux" | |
| 444 | + ], | |
| 445 | + "engines": { | |
| 446 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 447 | + }, | |
| 448 | + "funding": { | |
| 449 | + "url": "https://opencollective.com/libvips" | |
| 450 | + }, | |
| 451 | + "optionalDependencies": { | |
| 452 | + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" | |
| 453 | + } | |
| 454 | + }, | |
| 455 | + "node_modules/@img/sharp-linuxmusl-x64": { | |
| 456 | + "version": "0.34.5", | |
| 457 | + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", | |
| 458 | + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", | |
| 459 | + "cpu": [ | |
| 460 | + "x64" | |
| 461 | + ], | |
| 462 | + "libc": [ | |
| 463 | + "musl" | |
| 464 | + ], | |
| 465 | + "license": "Apache-2.0", | |
| 466 | + "optional": true, | |
| 467 | + "os": [ | |
| 468 | + "linux" | |
| 469 | + ], | |
| 470 | + "engines": { | |
| 471 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 472 | + }, | |
| 473 | + "funding": { | |
| 474 | + "url": "https://opencollective.com/libvips" | |
| 475 | + }, | |
| 476 | + "optionalDependencies": { | |
| 477 | + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" | |
| 478 | + } | |
| 479 | + }, | |
| 480 | + "node_modules/@img/sharp-wasm32": { | |
| 481 | + "version": "0.34.5", | |
| 482 | + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", | |
| 483 | + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", | |
| 484 | + "cpu": [ | |
| 485 | + "wasm32" | |
| 486 | + ], | |
| 487 | + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", | |
| 488 | + "optional": true, | |
| 489 | + "dependencies": { | |
| 490 | + "@emnapi/runtime": "^1.7.0" | |
| 491 | + }, | |
| 492 | + "engines": { | |
| 493 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 494 | + }, | |
| 495 | + "funding": { | |
| 496 | + "url": "https://opencollective.com/libvips" | |
| 497 | + } | |
| 498 | + }, | |
| 499 | + "node_modules/@img/sharp-win32-arm64": { | |
| 500 | + "version": "0.34.5", | |
| 501 | + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", | |
| 502 | + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", | |
| 503 | + "cpu": [ | |
| 504 | + "arm64" | |
| 505 | + ], | |
| 506 | + "license": "Apache-2.0 AND LGPL-3.0-or-later", | |
| 507 | + "optional": true, | |
| 508 | + "os": [ | |
| 509 | + "win32" | |
| 510 | + ], | |
| 511 | + "engines": { | |
| 512 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 513 | + }, | |
| 514 | + "funding": { | |
| 515 | + "url": "https://opencollective.com/libvips" | |
| 516 | + } | |
| 517 | + }, | |
| 518 | + "node_modules/@img/sharp-win32-ia32": { | |
| 519 | + "version": "0.34.5", | |
| 520 | + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", | |
| 521 | + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", | |
| 522 | + "cpu": [ | |
| 523 | + "ia32" | |
| 524 | + ], | |
| 525 | + "license": "Apache-2.0 AND LGPL-3.0-or-later", | |
| 526 | + "optional": true, | |
| 527 | + "os": [ | |
| 528 | + "win32" | |
| 529 | + ], | |
| 530 | + "engines": { | |
| 531 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 532 | + }, | |
| 533 | + "funding": { | |
| 534 | + "url": "https://opencollective.com/libvips" | |
| 535 | + } | |
| 536 | + }, | |
| 537 | + "node_modules/@img/sharp-win32-x64": { | |
| 538 | + "version": "0.34.5", | |
| 539 | + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", | |
| 540 | + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", | |
| 541 | + "cpu": [ | |
| 542 | + "x64" | |
| 543 | + ], | |
| 544 | + "license": "Apache-2.0 AND LGPL-3.0-or-later", | |
| 545 | + "optional": true, | |
| 546 | + "os": [ | |
| 547 | + "win32" | |
| 548 | + ], | |
| 549 | + "engines": { | |
| 550 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 551 | + }, | |
| 552 | + "funding": { | |
| 553 | + "url": "https://opencollective.com/libvips" | |
| 554 | + } | |
| 555 | + }, | |
| 556 | + "node_modules/@next/env": { | |
| 557 | + "version": "15.5.23", | |
| 558 | + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.23.tgz", | |
| 559 | + "integrity": "sha512-Mv3Z9hVbFcPnoLevsZ6rnX1TBtyHb5E17yN7HTPDXSXxeNsGBjUFrdbjRXKKXIOhfth7/cg6Ay7PZ2UFawaWsQ==", | |
| 560 | + "license": "MIT" | |
| 561 | + }, | |
| 562 | + "node_modules/@next/swc-darwin-arm64": { | |
| 563 | + "version": "15.5.23", | |
| 564 | + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.23.tgz", | |
| 565 | + "integrity": "sha512-SrEwOROH/rhA03F59hHtdhgtfZMWGzr5duDBWgRQt2rS3mJhqMKOcnNx6txOd0/i3E3D3uFKYFvyHsEiwQxzag==", | |
| 566 | + "cpu": [ | |
| 567 | + "arm64" | |
| 568 | + ], | |
| 569 | + "license": "MIT", | |
| 570 | + "optional": true, | |
| 571 | + "os": [ | |
| 572 | + "darwin" | |
| 573 | + ], | |
| 574 | + "engines": { | |
| 575 | + "node": ">= 10" | |
| 576 | + } | |
| 577 | + }, | |
| 578 | + "node_modules/@next/swc-darwin-x64": { | |
| 579 | + "version": "15.5.23", | |
| 580 | + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.23.tgz", | |
| 581 | + "integrity": "sha512-f0FpFbG2EhDCuptBGcfrLcYMDuQAhe6m1QA4VVfXFrIBoFXvXt/olGbBkYkloKlXQtmhuzvtdYyuu/6zf07GIg==", | |
| 582 | + "cpu": [ | |
| 583 | + "x64" | |
| 584 | + ], | |
| 585 | + "license": "MIT", | |
| 586 | + "optional": true, | |
| 587 | + "os": [ | |
| 588 | + "darwin" | |
| 589 | + ], | |
| 590 | + "engines": { | |
| 591 | + "node": ">= 10" | |
| 592 | + } | |
| 593 | + }, | |
| 594 | + "node_modules/@next/swc-linux-arm64-gnu": { | |
| 595 | + "version": "15.5.23", | |
| 596 | + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.23.tgz", | |
| 597 | + "integrity": "sha512-WlNtfepUXKX2u2ZsJZ8c3c8+tJSRZqsYzoMwLOY72A8ucKCCgxgNhiePA3qzFYahVWrwcQd8jOeJmBinc+VFVQ==", | |
| 598 | + "cpu": [ | |
| 599 | + "arm64" | |
| 600 | + ], | |
| 601 | + "libc": [ | |
| 602 | + "glibc" | |
| 603 | + ], | |
| 604 | + "license": "MIT", | |
| 605 | + "optional": true, | |
| 606 | + "os": [ | |
| 607 | + "linux" | |
| 608 | + ], | |
| 609 | + "engines": { | |
| 610 | + "node": ">= 10" | |
| 611 | + } | |
| 612 | + }, | |
| 613 | + "node_modules/@next/swc-linux-arm64-musl": { | |
| 614 | + "version": "15.5.23", | |
| 615 | + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.23.tgz", | |
| 616 | + "integrity": "sha512-W/6qKk7UG93mg14PmQC+2urt69MIdwTBLNQ6MJyeC4wOCIHCjz+VfgssvS1pK7mgYBtLC1g6VKNoHD9xB0WWGg==", | |
| 617 | + "cpu": [ | |
| 618 | + "arm64" | |
| 619 | + ], | |
| 620 | + "libc": [ | |
| 621 | + "musl" | |
| 622 | + ], | |
| 623 | + "license": "MIT", | |
| 624 | + "optional": true, | |
| 625 | + "os": [ | |
| 626 | + "linux" | |
| 627 | + ], | |
| 628 | + "engines": { | |
| 629 | + "node": ">= 10" | |
| 630 | + } | |
| 631 | + }, | |
| 632 | + "node_modules/@next/swc-linux-x64-gnu": { | |
| 633 | + "version": "15.5.23", | |
| 634 | + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.23.tgz", | |
| 635 | + "integrity": "sha512-vzefI32mi6VMk96RaTAyxApgfGbiFzQBXVsekEjsDv1fr48mlABTWx0sUYhaYCBHWqCalxmz3DxbxFcbFvzNtw==", | |
| 636 | + "cpu": [ | |
| 637 | + "x64" | |
| 638 | + ], | |
| 639 | + "libc": [ | |
| 640 | + "glibc" | |
| 641 | + ], | |
| 642 | + "license": "MIT", | |
| 643 | + "optional": true, | |
| 644 | + "os": [ | |
| 645 | + "linux" | |
| 646 | + ], | |
| 647 | + "engines": { | |
| 648 | + "node": ">= 10" | |
| 649 | + } | |
| 650 | + }, | |
| 651 | + "node_modules/@next/swc-linux-x64-musl": { | |
| 652 | + "version": "15.5.23", | |
| 653 | + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.23.tgz", | |
| 654 | + "integrity": "sha512-qppK/3dTGOTI+aoWWBZc3DshFIhrzgL8guATlaN9V6M1QJxbkP/rhEZ22tdICsQ/2WWXopMZ2Jokzj2u3uKY3Q==", | |
| 655 | + "cpu": [ | |
| 656 | + "x64" | |
| 657 | + ], | |
| 658 | + "libc": [ | |
| 659 | + "musl" | |
| 660 | + ], | |
| 661 | + "license": "MIT", | |
| 662 | + "optional": true, | |
| 663 | + "os": [ | |
| 664 | + "linux" | |
| 665 | + ], | |
| 666 | + "engines": { | |
| 667 | + "node": ">= 10" | |
| 668 | + } | |
| 669 | + }, | |
| 670 | + "node_modules/@next/swc-win32-arm64-msvc": { | |
| 671 | + "version": "15.5.23", | |
| 672 | + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.23.tgz", | |
| 673 | + "integrity": "sha512-Wc29KFOdT7XBcII3Vtmw7aoU8Uk3Mes/FNJfhFeSHdYBFJWMcR/DsI8U9BCPUhq/uycsUVuqSKGthW15tLsigA==", | |
| 674 | + "cpu": [ | |
| 675 | + "arm64" | |
| 676 | + ], | |
| 677 | + "license": "MIT", | |
| 678 | + "optional": true, | |
| 679 | + "os": [ | |
| 680 | + "win32" | |
| 681 | + ], | |
| 682 | + "engines": { | |
| 683 | + "node": ">= 10" | |
| 684 | + } | |
| 685 | + }, | |
| 686 | + "node_modules/@next/swc-win32-x64-msvc": { | |
| 687 | + "version": "15.5.23", | |
| 688 | + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.23.tgz", | |
| 689 | + "integrity": "sha512-/C7wRW4fa9s/PKA18zGPPpVmx8ycgVpP8yOxro4gzGTzjPJdscbAP3ODeFvgiIovxD176Z2J/SXO9t8PJKHLeQ==", | |
| 690 | + "cpu": [ | |
| 691 | + "x64" | |
| 692 | + ], | |
| 693 | + "license": "MIT", | |
| 694 | + "optional": true, | |
| 695 | + "os": [ | |
| 696 | + "win32" | |
| 697 | + ], | |
| 698 | + "engines": { | |
| 699 | + "node": ">= 10" | |
| 700 | + } | |
| 701 | + }, | |
| 702 | + "node_modules/@swc/helpers": { | |
| 703 | + "version": "0.5.15", | |
| 704 | + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", | |
| 705 | + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", | |
| 706 | + "license": "Apache-2.0", | |
| 707 | + "dependencies": { | |
| 708 | + "tslib": "^2.8.0" | |
| 709 | + } | |
| 710 | + }, | |
| 711 | + "node_modules/@types/d3-array": { | |
| 712 | + "version": "3.2.2", | |
| 713 | + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", | |
| 714 | + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", | |
| 715 | + "license": "MIT" | |
| 716 | + }, | |
| 717 | + "node_modules/@types/d3-color": { | |
| 718 | + "version": "3.1.3", | |
| 719 | + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", | |
| 720 | + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", | |
| 721 | + "license": "MIT" | |
| 722 | + }, | |
| 723 | + "node_modules/@types/d3-ease": { | |
| 724 | + "version": "3.0.2", | |
| 725 | + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", | |
| 726 | + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", | |
| 727 | + "license": "MIT" | |
| 728 | + }, | |
| 729 | + "node_modules/@types/d3-interpolate": { | |
| 730 | + "version": "3.0.4", | |
| 731 | + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", | |
| 732 | + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", | |
| 733 | + "license": "MIT", | |
| 734 | + "dependencies": { | |
| 735 | + "@types/d3-color": "*" | |
| 736 | + } | |
| 737 | + }, | |
| 738 | + "node_modules/@types/d3-path": { | |
| 739 | + "version": "3.1.1", | |
| 740 | + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", | |
| 741 | + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", | |
| 742 | + "license": "MIT" | |
| 743 | + }, | |
| 744 | + "node_modules/@types/d3-scale": { | |
| 745 | + "version": "4.0.9", | |
| 746 | + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", | |
| 747 | + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", | |
| 748 | + "license": "MIT", | |
| 749 | + "dependencies": { | |
| 750 | + "@types/d3-time": "*" | |
| 751 | + } | |
| 752 | + }, | |
| 753 | + "node_modules/@types/d3-shape": { | |
| 754 | + "version": "3.1.8", | |
| 755 | + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", | |
| 756 | + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", | |
| 757 | + "license": "MIT", | |
| 758 | + "dependencies": { | |
| 759 | + "@types/d3-path": "*" | |
| 760 | + } | |
| 761 | + }, | |
| 762 | + "node_modules/@types/d3-time": { | |
| 763 | + "version": "3.0.4", | |
| 764 | + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", | |
| 765 | + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", | |
| 766 | + "license": "MIT" | |
| 767 | + }, | |
| 768 | + "node_modules/@types/d3-timer": { | |
| 769 | + "version": "3.0.2", | |
| 770 | + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", | |
| 771 | + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", | |
| 772 | + "license": "MIT" | |
| 773 | + }, | |
| 774 | + "node_modules/@types/node": { | |
| 775 | + "version": "22.20.1", | |
| 776 | + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", | |
| 777 | + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", | |
| 778 | + "dev": true, | |
| 779 | + "license": "MIT", | |
| 780 | + "dependencies": { | |
| 781 | + "undici-types": "~6.21.0" | |
| 782 | + } | |
| 783 | + }, | |
| 784 | + "node_modules/@types/react": { | |
| 785 | + "version": "19.2.18", | |
| 786 | + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", | |
| 787 | + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", | |
| 788 | + "dev": true, | |
| 789 | + "license": "MIT", | |
| 790 | + "dependencies": { | |
| 791 | + "csstype": "^3.2.2" | |
| 792 | + } | |
| 793 | + }, | |
| 794 | + "node_modules/@types/react-dom": { | |
| 795 | + "version": "19.2.4", | |
| 796 | + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", | |
| 797 | + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", | |
| 798 | + "dev": true, | |
| 799 | + "license": "MIT", | |
| 800 | + "peerDependencies": { | |
| 801 | + "@types/react": "^19.2.0" | |
| 802 | + } | |
| 803 | + }, | |
| 804 | + "node_modules/caniuse-lite": { | |
| 805 | + "version": "1.0.30001807", | |
| 806 | + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001807.tgz", | |
| 807 | + "integrity": "sha512-daRXJ9EB/rdRgu7kV+TTl1YUKtlsMWblPl2sLnpg9DZae16QCegol6A1SmCE31Lm9mXC1sRWGt/krouH+/dl7Q==", | |
| 808 | + "funding": [ | |
| 809 | + { | |
| 810 | + "type": "opencollective", | |
| 811 | + "url": "https://opencollective.com/browserslist" | |
| 812 | + }, | |
| 813 | + { | |
| 814 | + "type": "tidelift", | |
| 815 | + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" | |
| 816 | + }, | |
| 817 | + { | |
| 818 | + "type": "github", | |
| 819 | + "url": "https://github.com/sponsors/ai" | |
| 820 | + } | |
| 821 | + ], | |
| 822 | + "license": "CC-BY-4.0" | |
| 823 | + }, | |
| 824 | + "node_modules/client-only": { | |
| 825 | + "version": "0.0.1", | |
| 826 | + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", | |
| 827 | + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", | |
| 828 | + "license": "MIT" | |
| 829 | + }, | |
| 830 | + "node_modules/clsx": { | |
| 831 | + "version": "2.1.1", | |
| 832 | + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", | |
| 833 | + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", | |
| 834 | + "license": "MIT", | |
| 835 | + "engines": { | |
| 836 | + "node": ">=6" | |
| 837 | + } | |
| 838 | + }, | |
| 839 | + "node_modules/csstype": { | |
| 840 | + "version": "3.2.3", | |
| 841 | + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", | |
| 842 | + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", | |
| 843 | + "license": "MIT" | |
| 844 | + }, | |
| 845 | + "node_modules/d3-array": { | |
| 846 | + "version": "3.2.4", | |
| 847 | + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", | |
| 848 | + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", | |
| 849 | + "license": "ISC", | |
| 850 | + "dependencies": { | |
| 851 | + "internmap": "1 - 2" | |
| 852 | + }, | |
| 853 | + "engines": { | |
| 854 | + "node": ">=12" | |
| 855 | + } | |
| 856 | + }, | |
| 857 | + "node_modules/d3-color": { | |
| 858 | + "version": "3.1.0", | |
| 859 | + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", | |
| 860 | + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", | |
| 861 | + "license": "ISC", | |
| 862 | + "engines": { | |
| 863 | + "node": ">=12" | |
| 864 | + } | |
| 865 | + }, | |
| 866 | + "node_modules/d3-ease": { | |
| 867 | + "version": "3.0.1", | |
| 868 | + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", | |
| 869 | + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", | |
| 870 | + "license": "BSD-3-Clause", | |
| 871 | + "engines": { | |
| 872 | + "node": ">=12" | |
| 873 | + } | |
| 874 | + }, | |
| 875 | + "node_modules/d3-format": { | |
| 876 | + "version": "3.1.2", | |
| 877 | + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", | |
| 878 | + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", | |
| 879 | + "license": "ISC", | |
| 880 | + "engines": { | |
| 881 | + "node": ">=12" | |
| 882 | + } | |
| 883 | + }, | |
| 884 | + "node_modules/d3-interpolate": { | |
| 885 | + "version": "3.0.1", | |
| 886 | + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", | |
| 887 | + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", | |
| 888 | + "license": "ISC", | |
| 889 | + "dependencies": { | |
| 890 | + "d3-color": "1 - 3" | |
| 891 | + }, | |
| 892 | + "engines": { | |
| 893 | + "node": ">=12" | |
| 894 | + } | |
| 895 | + }, | |
| 896 | + "node_modules/d3-path": { | |
| 897 | + "version": "3.1.0", | |
| 898 | + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", | |
| 899 | + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", | |
| 900 | + "license": "ISC", | |
| 901 | + "engines": { | |
| 902 | + "node": ">=12" | |
| 903 | + } | |
| 904 | + }, | |
| 905 | + "node_modules/d3-scale": { | |
| 906 | + "version": "4.0.2", | |
| 907 | + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", | |
| 908 | + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", | |
| 909 | + "license": "ISC", | |
| 910 | + "dependencies": { | |
| 911 | + "d3-array": "2.10.0 - 3", | |
| 912 | + "d3-format": "1 - 3", | |
| 913 | + "d3-interpolate": "1.2.0 - 3", | |
| 914 | + "d3-time": "2.1.1 - 3", | |
| 915 | + "d3-time-format": "2 - 4" | |
| 916 | + }, | |
| 917 | + "engines": { | |
| 918 | + "node": ">=12" | |
| 919 | + } | |
| 920 | + }, | |
| 921 | + "node_modules/d3-shape": { | |
| 922 | + "version": "3.2.0", | |
| 923 | + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", | |
| 924 | + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", | |
| 925 | + "license": "ISC", | |
| 926 | + "dependencies": { | |
| 927 | + "d3-path": "^3.1.0" | |
| 928 | + }, | |
| 929 | + "engines": { | |
| 930 | + "node": ">=12" | |
| 931 | + } | |
| 932 | + }, | |
| 933 | + "node_modules/d3-time": { | |
| 934 | + "version": "3.1.0", | |
| 935 | + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", | |
| 936 | + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", | |
| 937 | + "license": "ISC", | |
| 938 | + "dependencies": { | |
| 939 | + "d3-array": "2 - 3" | |
| 940 | + }, | |
| 941 | + "engines": { | |
| 942 | + "node": ">=12" | |
| 943 | + } | |
| 944 | + }, | |
| 945 | + "node_modules/d3-time-format": { | |
| 946 | + "version": "4.1.0", | |
| 947 | + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", | |
| 948 | + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", | |
| 949 | + "license": "ISC", | |
| 950 | + "dependencies": { | |
| 951 | + "d3-time": "1 - 3" | |
| 952 | + }, | |
| 953 | + "engines": { | |
| 954 | + "node": ">=12" | |
| 955 | + } | |
| 956 | + }, | |
| 957 | + "node_modules/d3-timer": { | |
| 958 | + "version": "3.0.1", | |
| 959 | + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", | |
| 960 | + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", | |
| 961 | + "license": "ISC", | |
| 962 | + "engines": { | |
| 963 | + "node": ">=12" | |
| 964 | + } | |
| 965 | + }, | |
| 966 | + "node_modules/decimal.js-light": { | |
| 967 | + "version": "2.5.1", | |
| 968 | + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", | |
| 969 | + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", | |
| 970 | + "license": "MIT" | |
| 971 | + }, | |
| 972 | + "node_modules/detect-libc": { | |
| 973 | + "version": "2.1.2", | |
| 974 | + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", | |
| 975 | + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", | |
| 976 | + "license": "Apache-2.0", | |
| 977 | + "optional": true, | |
| 978 | + "engines": { | |
| 979 | + "node": ">=8" | |
| 980 | + } | |
| 981 | + }, | |
| 982 | + "node_modules/dom-helpers": { | |
| 983 | + "version": "5.2.1", | |
| 984 | + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", | |
| 985 | + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", | |
| 986 | + "license": "MIT", | |
| 987 | + "dependencies": { | |
| 988 | + "@babel/runtime": "^7.8.7", | |
| 989 | + "csstype": "^3.0.2" | |
| 990 | + } | |
| 991 | + }, | |
| 992 | + "node_modules/eventemitter3": { | |
| 993 | + "version": "4.0.7", | |
| 994 | + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", | |
| 995 | + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", | |
| 996 | + "license": "MIT" | |
| 997 | + }, | |
| 998 | + "node_modules/fast-equals": { | |
| 999 | + "version": "5.4.1", | |
| 1000 | + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz", | |
| 1001 | + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==", | |
| 1002 | + "license": "MIT", | |
| 1003 | + "engines": { | |
| 1004 | + "node": ">=6.0.0" | |
| 1005 | + } | |
| 1006 | + }, | |
| 1007 | + "node_modules/internmap": { | |
| 1008 | + "version": "2.0.3", | |
| 1009 | + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", | |
| 1010 | + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", | |
| 1011 | + "license": "ISC", | |
| 1012 | + "engines": { | |
| 1013 | + "node": ">=12" | |
| 1014 | + } | |
| 1015 | + }, | |
| 1016 | + "node_modules/js-tokens": { | |
| 1017 | + "version": "4.0.0", | |
| 1018 | + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", | |
| 1019 | + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", | |
| 1020 | + "license": "MIT" | |
| 1021 | + }, | |
| 1022 | + "node_modules/lodash": { | |
| 1023 | + "version": "4.18.1", | |
| 1024 | + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", | |
| 1025 | + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", | |
| 1026 | + "license": "MIT" | |
| 1027 | + }, | |
| 1028 | + "node_modules/loose-envify": { | |
| 1029 | + "version": "1.4.0", | |
| 1030 | + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", | |
| 1031 | + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", | |
| 1032 | + "license": "MIT", | |
| 1033 | + "dependencies": { | |
| 1034 | + "js-tokens": "^3.0.0 || ^4.0.0" | |
| 1035 | + }, | |
| 1036 | + "bin": { | |
| 1037 | + "loose-envify": "cli.js" | |
| 1038 | + } | |
| 1039 | + }, | |
| 1040 | + "node_modules/nanoid": { | |
| 1041 | + "version": "3.3.17", | |
| 1042 | + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", | |
| 1043 | + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", | |
| 1044 | + "funding": [ | |
| 1045 | + { | |
| 1046 | + "type": "github", | |
| 1047 | + "url": "https://github.com/sponsors/ai" | |
| 1048 | + } | |
| 1049 | + ], | |
| 1050 | + "license": "MIT", | |
| 1051 | + "bin": { | |
| 1052 | + "nanoid": "bin/nanoid.cjs" | |
| 1053 | + }, | |
| 1054 | + "engines": { | |
| 1055 | + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" | |
| 1056 | + } | |
| 1057 | + }, | |
| 1058 | + "node_modules/next": { | |
| 1059 | + "version": "15.5.23", | |
| 1060 | + "resolved": "https://registry.npmjs.org/next/-/next-15.5.23.tgz", | |
| 1061 | + "integrity": "sha512-Gvd2WKgvxIXCGotxcI1im/Uf3rS3J3oZGw0g/uskg6AVBZhyE3aAbujkYWzS3xLmEPEtTLfkaVQUKK0KMTSIkA==", | |
| 1062 | + "license": "MIT", | |
| 1063 | + "dependencies": { | |
| 1064 | + "@next/env": "15.5.23", | |
| 1065 | + "@swc/helpers": "0.5.15", | |
| 1066 | + "caniuse-lite": "^1.0.30001579", | |
| 1067 | + "postcss": "8.4.31", | |
| 1068 | + "styled-jsx": "5.1.6" | |
| 1069 | + }, | |
| 1070 | + "bin": { | |
| 1071 | + "next": "dist/bin/next" | |
| 1072 | + }, | |
| 1073 | + "engines": { | |
| 1074 | + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" | |
| 1075 | + }, | |
| 1076 | + "optionalDependencies": { | |
| 1077 | + "@next/swc-darwin-arm64": "15.5.23", | |
| 1078 | + "@next/swc-darwin-x64": "15.5.23", | |
| 1079 | + "@next/swc-linux-arm64-gnu": "15.5.23", | |
| 1080 | + "@next/swc-linux-arm64-musl": "15.5.23", | |
| 1081 | + "@next/swc-linux-x64-gnu": "15.5.23", | |
| 1082 | + "@next/swc-linux-x64-musl": "15.5.23", | |
| 1083 | + "@next/swc-win32-arm64-msvc": "15.5.23", | |
| 1084 | + "@next/swc-win32-x64-msvc": "15.5.23", | |
| 1085 | + "sharp": "^0.34.3" | |
| 1086 | + }, | |
| 1087 | + "peerDependencies": { | |
| 1088 | + "@opentelemetry/api": "^1.1.0", | |
| 1089 | + "@playwright/test": "^1.51.1", | |
| 1090 | + "babel-plugin-react-compiler": "*", | |
| 1091 | + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", | |
| 1092 | + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", | |
| 1093 | + "sass": "^1.3.0" | |
| 1094 | + }, | |
| 1095 | + "peerDependenciesMeta": { | |
| 1096 | + "@opentelemetry/api": { | |
| 1097 | + "optional": true | |
| 1098 | + }, | |
| 1099 | + "@playwright/test": { | |
| 1100 | + "optional": true | |
| 1101 | + }, | |
| 1102 | + "babel-plugin-react-compiler": { | |
| 1103 | + "optional": true | |
| 1104 | + }, | |
| 1105 | + "sass": { | |
| 1106 | + "optional": true | |
| 1107 | + } | |
| 1108 | + } | |
| 1109 | + }, | |
| 1110 | + "node_modules/object-assign": { | |
| 1111 | + "version": "4.1.1", | |
| 1112 | + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", | |
| 1113 | + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", | |
| 1114 | + "license": "MIT", | |
| 1115 | + "engines": { | |
| 1116 | + "node": ">=0.10.0" | |
| 1117 | + } | |
| 1118 | + }, | |
| 1119 | + "node_modules/picocolors": { | |
| 1120 | + "version": "1.1.1", | |
| 1121 | + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | |
| 1122 | + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", | |
| 1123 | + "license": "ISC" | |
| 1124 | + }, | |
| 1125 | + "node_modules/postcss": { | |
| 1126 | + "version": "8.4.31", | |
| 1127 | + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", | |
| 1128 | + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", | |
| 1129 | + "funding": [ | |
| 1130 | + { | |
| 1131 | + "type": "opencollective", | |
| 1132 | + "url": "https://opencollective.com/postcss/" | |
| 1133 | + }, | |
| 1134 | + { | |
| 1135 | + "type": "tidelift", | |
| 1136 | + "url": "https://tidelift.com/funding/github/npm/postcss" | |
| 1137 | + }, | |
| 1138 | + { | |
| 1139 | + "type": "github", | |
| 1140 | + "url": "https://github.com/sponsors/ai" | |
| 1141 | + } | |
| 1142 | + ], | |
| 1143 | + "license": "MIT", | |
| 1144 | + "dependencies": { | |
| 1145 | + "nanoid": "^3.3.6", | |
| 1146 | + "picocolors": "^1.0.0", | |
| 1147 | + "source-map-js": "^1.0.2" | |
| 1148 | + }, | |
| 1149 | + "engines": { | |
| 1150 | + "node": "^10 || ^12 || >=14" | |
| 1151 | + } | |
| 1152 | + }, | |
| 1153 | + "node_modules/prop-types": { | |
| 1154 | + "version": "15.8.1", | |
| 1155 | + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", | |
| 1156 | + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", | |
| 1157 | + "license": "MIT", | |
| 1158 | + "dependencies": { | |
| 1159 | + "loose-envify": "^1.4.0", | |
| 1160 | + "object-assign": "^4.1.1", | |
| 1161 | + "react-is": "^16.13.1" | |
| 1162 | + } | |
| 1163 | + }, | |
| 1164 | + "node_modules/prop-types/node_modules/react-is": { | |
| 1165 | + "version": "16.13.1", | |
| 1166 | + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", | |
| 1167 | + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", | |
| 1168 | + "license": "MIT" | |
| 1169 | + }, | |
| 1170 | + "node_modules/react": { | |
| 1171 | + "version": "19.2.8", | |
| 1172 | + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", | |
| 1173 | + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", | |
| 1174 | + "license": "MIT", | |
| 1175 | + "engines": { | |
| 1176 | + "node": ">=0.10.0" | |
| 1177 | + } | |
| 1178 | + }, | |
| 1179 | + "node_modules/react-dom": { | |
| 1180 | + "version": "19.2.8", | |
| 1181 | + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", | |
| 1182 | + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", | |
| 1183 | + "license": "MIT", | |
| 1184 | + "dependencies": { | |
| 1185 | + "scheduler": "^0.27.0" | |
| 1186 | + }, | |
| 1187 | + "peerDependencies": { | |
| 1188 | + "react": "^19.2.8" | |
| 1189 | + } | |
| 1190 | + }, | |
| 1191 | + "node_modules/react-is": { | |
| 1192 | + "version": "18.3.1", | |
| 1193 | + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", | |
| 1194 | + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", | |
| 1195 | + "license": "MIT" | |
| 1196 | + }, | |
| 1197 | + "node_modules/react-smooth": { | |
| 1198 | + "version": "4.0.4", | |
| 1199 | + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", | |
| 1200 | + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", | |
| 1201 | + "license": "MIT", | |
| 1202 | + "dependencies": { | |
| 1203 | + "fast-equals": "^5.0.1", | |
| 1204 | + "prop-types": "^15.8.1", | |
| 1205 | + "react-transition-group": "^4.4.5" | |
| 1206 | + }, | |
| 1207 | + "peerDependencies": { | |
| 1208 | + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", | |
| 1209 | + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" | |
| 1210 | + } | |
| 1211 | + }, | |
| 1212 | + "node_modules/react-transition-group": { | |
| 1213 | + "version": "4.4.5", | |
| 1214 | + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", | |
| 1215 | + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", | |
| 1216 | + "license": "BSD-3-Clause", | |
| 1217 | + "dependencies": { | |
| 1218 | + "@babel/runtime": "^7.5.5", | |
| 1219 | + "dom-helpers": "^5.0.1", | |
| 1220 | + "loose-envify": "^1.4.0", | |
| 1221 | + "prop-types": "^15.6.2" | |
| 1222 | + }, | |
| 1223 | + "peerDependencies": { | |
| 1224 | + "react": ">=16.6.0", | |
| 1225 | + "react-dom": ">=16.6.0" | |
| 1226 | + } | |
| 1227 | + }, | |
| 1228 | + "node_modules/recharts": { | |
| 1229 | + "version": "2.15.4", | |
| 1230 | + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", | |
| 1231 | + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", | |
| 1232 | + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", | |
| 1233 | + "license": "MIT", | |
| 1234 | + "dependencies": { | |
| 1235 | + "clsx": "^2.0.0", | |
| 1236 | + "eventemitter3": "^4.0.1", | |
| 1237 | + "lodash": "^4.17.21", | |
| 1238 | + "react-is": "^18.3.1", | |
| 1239 | + "react-smooth": "^4.0.4", | |
| 1240 | + "recharts-scale": "^0.4.4", | |
| 1241 | + "tiny-invariant": "^1.3.1", | |
| 1242 | + "victory-vendor": "^36.6.8" | |
| 1243 | + }, | |
| 1244 | + "engines": { | |
| 1245 | + "node": ">=14" | |
| 1246 | + }, | |
| 1247 | + "peerDependencies": { | |
| 1248 | + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", | |
| 1249 | + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" | |
| 1250 | + } | |
| 1251 | + }, | |
| 1252 | + "node_modules/recharts-scale": { | |
| 1253 | + "version": "0.4.5", | |
| 1254 | + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", | |
| 1255 | + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", | |
| 1256 | + "license": "MIT", | |
| 1257 | + "dependencies": { | |
| 1258 | + "decimal.js-light": "^2.4.1" | |
| 1259 | + } | |
| 1260 | + }, | |
| 1261 | + "node_modules/scheduler": { | |
| 1262 | + "version": "0.27.0", | |
| 1263 | + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", | |
| 1264 | + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", | |
| 1265 | + "license": "MIT" | |
| 1266 | + }, | |
| 1267 | + "node_modules/semver": { | |
| 1268 | + "version": "7.8.5", | |
| 1269 | + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", | |
| 1270 | + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", | |
| 1271 | + "license": "ISC", | |
| 1272 | + "optional": true, | |
| 1273 | + "bin": { | |
| 1274 | + "semver": "bin/semver.js" | |
| 1275 | + }, | |
| 1276 | + "engines": { | |
| 1277 | + "node": ">=10" | |
| 1278 | + } | |
| 1279 | + }, | |
| 1280 | + "node_modules/sharp": { | |
| 1281 | + "version": "0.34.5", | |
| 1282 | + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", | |
| 1283 | + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", | |
| 1284 | + "hasInstallScript": true, | |
| 1285 | + "license": "Apache-2.0", | |
| 1286 | + "optional": true, | |
| 1287 | + "dependencies": { | |
| 1288 | + "@img/colour": "^1.0.0", | |
| 1289 | + "detect-libc": "^2.1.2", | |
| 1290 | + "semver": "^7.7.3" | |
| 1291 | + }, | |
| 1292 | + "engines": { | |
| 1293 | + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" | |
| 1294 | + }, | |
| 1295 | + "funding": { | |
| 1296 | + "url": "https://opencollective.com/libvips" | |
| 1297 | + }, | |
| 1298 | + "optionalDependencies": { | |
| 1299 | + "@img/sharp-darwin-arm64": "0.34.5", | |
| 1300 | + "@img/sharp-darwin-x64": "0.34.5", | |
| 1301 | + "@img/sharp-libvips-darwin-arm64": "1.2.4", | |
| 1302 | + "@img/sharp-libvips-darwin-x64": "1.2.4", | |
| 1303 | + "@img/sharp-libvips-linux-arm": "1.2.4", | |
| 1304 | + "@img/sharp-libvips-linux-arm64": "1.2.4", | |
| 1305 | + "@img/sharp-libvips-linux-ppc64": "1.2.4", | |
| 1306 | + "@img/sharp-libvips-linux-riscv64": "1.2.4", | |
| 1307 | + "@img/sharp-libvips-linux-s390x": "1.2.4", | |
| 1308 | + "@img/sharp-libvips-linux-x64": "1.2.4", | |
| 1309 | + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", | |
| 1310 | + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", | |
| 1311 | + "@img/sharp-linux-arm": "0.34.5", | |
| 1312 | + "@img/sharp-linux-arm64": "0.34.5", | |
| 1313 | + "@img/sharp-linux-ppc64": "0.34.5", | |
| 1314 | + "@img/sharp-linux-riscv64": "0.34.5", | |
| 1315 | + "@img/sharp-linux-s390x": "0.34.5", | |
| 1316 | + "@img/sharp-linux-x64": "0.34.5", | |
| 1317 | + "@img/sharp-linuxmusl-arm64": "0.34.5", | |
| 1318 | + "@img/sharp-linuxmusl-x64": "0.34.5", | |
| 1319 | + "@img/sharp-wasm32": "0.34.5", | |
| 1320 | + "@img/sharp-win32-arm64": "0.34.5", | |
| 1321 | + "@img/sharp-win32-ia32": "0.34.5", | |
| 1322 | + "@img/sharp-win32-x64": "0.34.5" | |
| 1323 | + } | |
| 1324 | + }, | |
| 1325 | + "node_modules/source-map-js": { | |
| 1326 | + "version": "1.2.1", | |
| 1327 | + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", | |
| 1328 | + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", | |
| 1329 | + "license": "BSD-3-Clause", | |
| 1330 | + "engines": { | |
| 1331 | + "node": ">=0.10.0" | |
| 1332 | + } | |
| 1333 | + }, | |
| 1334 | + "node_modules/styled-jsx": { | |
| 1335 | + "version": "5.1.6", | |
| 1336 | + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", | |
| 1337 | + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", | |
| 1338 | + "license": "MIT", | |
| 1339 | + "dependencies": { | |
| 1340 | + "client-only": "0.0.1" | |
| 1341 | + }, | |
| 1342 | + "engines": { | |
| 1343 | + "node": ">= 12.0.0" | |
| 1344 | + }, | |
| 1345 | + "peerDependencies": { | |
| 1346 | + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" | |
| 1347 | + }, | |
| 1348 | + "peerDependenciesMeta": { | |
| 1349 | + "@babel/core": { | |
| 1350 | + "optional": true | |
| 1351 | + }, | |
| 1352 | + "babel-plugin-macros": { | |
| 1353 | + "optional": true | |
| 1354 | + } | |
| 1355 | + } | |
| 1356 | + }, | |
| 1357 | + "node_modules/tiny-invariant": { | |
| 1358 | + "version": "1.3.3", | |
| 1359 | + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", | |
| 1360 | + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", | |
| 1361 | + "license": "MIT" | |
| 1362 | + }, | |
| 1363 | + "node_modules/tslib": { | |
| 1364 | + "version": "2.8.1", | |
| 1365 | + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", | |
| 1366 | + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", | |
| 1367 | + "license": "0BSD" | |
| 1368 | + }, | |
| 1369 | + "node_modules/typescript": { | |
| 1370 | + "version": "5.9.3", | |
| 1371 | + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", | |
| 1372 | + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", | |
| 1373 | + "dev": true, | |
| 1374 | + "license": "Apache-2.0", | |
| 1375 | + "bin": { | |
| 1376 | + "tsc": "bin/tsc", | |
| 1377 | + "tsserver": "bin/tsserver" | |
| 1378 | + }, | |
| 1379 | + "engines": { | |
| 1380 | + "node": ">=14.17" | |
| 1381 | + } | |
| 1382 | + }, | |
| 1383 | + "node_modules/undici-types": { | |
| 1384 | + "version": "6.21.0", | |
| 1385 | + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", | |
| 1386 | + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", | |
| 1387 | + "dev": true, | |
| 1388 | + "license": "MIT" | |
| 1389 | + }, | |
| 1390 | + "node_modules/victory-vendor": { | |
| 1391 | + "version": "36.9.2", | |
| 1392 | + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", | |
| 1393 | + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", | |
| 1394 | + "license": "MIT AND ISC", | |
| 1395 | + "dependencies": { | |
| 1396 | + "@types/d3-array": "^3.0.3", | |
| 1397 | + "@types/d3-ease": "^3.0.0", | |
| 1398 | + "@types/d3-interpolate": "^3.0.1", | |
| 1399 | + "@types/d3-scale": "^4.0.2", | |
| 1400 | + "@types/d3-shape": "^3.1.0", | |
| 1401 | + "@types/d3-time": "^3.0.0", | |
| 1402 | + "@types/d3-timer": "^3.0.0", | |
| 1403 | + "d3-array": "^3.1.6", | |
| 1404 | + "d3-ease": "^3.0.1", | |
| 1405 | + "d3-interpolate": "^3.0.1", | |
| 1406 | + "d3-scale": "^4.0.2", | |
| 1407 | + "d3-shape": "^3.1.0", | |
| 1408 | + "d3-time": "^3.0.0", | |
| 1409 | + "d3-timer": "^3.0.1" | |
| 1410 | + } | |
| 1411 | + } | |
| 1412 | + } | |
| 1413 | +} | |
added
web/package.json
+25 −0
@@ -0,0 +1,25 @@ | ||
| 1 | +{ | |
| 2 | + "name": "qwhpi-web", | |
| 3 | + "version": "0.1.0", | |
| 4 | + "private": true, | |
| 5 | + "author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 6 | + "description": "QWHPI — Quebec Weekly Housing Price Index dashboard", | |
| 7 | + "scripts": { | |
| 8 | + "dev": "next dev -p 3000", | |
| 9 | + "build": "next build", | |
| 10 | + "start": "next start -p 3000", | |
| 11 | + "typecheck": "tsc --noEmit" | |
| 12 | + }, | |
| 13 | + "dependencies": { | |
| 14 | + "next": "^15.3.0", | |
| 15 | + "react": "^19.0.0", | |
| 16 | + "react-dom": "^19.0.0", | |
| 17 | + "recharts": "^2.15.0" | |
| 18 | + }, | |
| 19 | + "devDependencies": { | |
| 20 | + "@types/node": "^22", | |
| 21 | + "@types/react": "^19", | |
| 22 | + "@types/react-dom": "^19", | |
| 23 | + "typescript": "^5.6" | |
| 24 | + } | |
| 25 | +} | |
added
web/public/regions.geojson
+24 −0
@@ -0,0 +1,24 @@ | ||
| 1 | +{ | |
| 2 | +"type": "FeatureCollection", | |
| 3 | +"name": "regions", | |
| 4 | +"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:OGC:1.3:CRS84" } }, | |
| 5 | +"features": [ | |
| 6 | +{ "type": "Feature", "properties": { "code": "01", "name": "Bas-Saint-Laurent", "geography_id": "region-01" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -66.832032993541887, 48.985077062530785 ], [ -67.153339142634252, 49.18906403276106 ], [ -67.653527346887046, 49.059287454794955 ], [ -68.467535729666494, 48.75809417676782 ], [ -68.999999195820209, 48.451724286567014 ], [ -69.693620092208832, 47.968393032985418 ], [ -69.910550122046899, 47.679365339581082 ], [ -70.065892232692377, 47.568336902140452 ], [ -70.197392286185178, 47.4119438131845 ], [ -69.625290475616112, 47.06672887090172 ], [ -69.224461123831304, 47.459833326149941 ], [ -69.043216763815181, 47.426783496102914 ], [ -69.051000494931756, 47.30017487489242 ], [ -68.569350023804873, 47.42700755922386 ], [ -68.382971782473021, 47.553346848707577 ], [ -68.383096827856065, 47.916549275197688 ], [ -68.122891146874991, 47.916472575997602 ], [ -68.122878233791909, 47.999961596642954 ], [ -67.597865906355949, 48.000126746595569 ], [ -67.58697761495371, 48.049075921609386 ], [ -67.666736277613239, 48.118481110138987 ], [ -67.374603194612703, 48.194892593960361 ], [ -67.277547792467402, 48.031538031877908 ], [ -66.879636407319737, 48.136954025567931 ], [ -66.934625790649989, 48.181190786992964 ], [ -66.900450576427687, 48.200447649982998 ], [ -66.875745542117869, 48.183041803330852 ], [ -66.839555950616386, 48.14709693205026 ], [ -66.836925357775854, 48.147741707786416 ], [ -66.97415999578601, 48.30315642898266 ], [ -65.948498198459305, 48.567089144875254 ], [ -66.537315142547982, 48.957826533637991 ], [ -66.70441521193213, 48.902873345905128 ], [ -66.699318005571314, 48.988611913225242 ], [ -66.732989366008425, 49.017530356093509 ], [ -66.832032993541887, 48.985077062530785 ] ] ] } }, | |
| 7 | +{ "type": "Feature", "properties": { "code": "02", "name": "Saguenay–Lac-Saint-Jean", "geography_id": "region-02" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -70.029889608212372, 52.83208781270055 ], [ -70.127352769308857, 52.810498021600203 ], [ -70.158019118544559, 52.75487579108664 ], [ -70.123012782802505, 52.751791827643387 ], [ -70.220665563779335, 52.710960459256881 ], [ -70.180804913611325, 52.662876248978954 ], [ -70.229060412544442, 52.635864638324705 ], [ -70.169669053109814, 52.603941585881003 ], [ -70.242708985221583, 52.559855241160008 ], [ -70.172981365334763, 52.542780077009425 ], [ -70.289708183364212, 52.418971922267225 ], [ -70.231401904774387, 52.417487173316175 ], [ -70.278686694660578, 52.353070389692697 ], [ -70.482013678038712, 52.303945326491949 ], [ -70.559851046036215, 52.317121283427525 ], [ -70.792275564174631, 52.233678726260095 ], [ -70.825558391191791, 52.247701291228815 ], [ -70.806851262208056, 52.2735753367147 ], [ -70.881018687776219, 52.278978001270332 ], [ -70.929532312266929, 52.226715597023762 ], [ -71.07753627155752, 52.211205816461593 ], [ -71.250263046222429, 52.066007520793178 ], [ -71.265350359872059, 52.037768716693598 ], [ -71.206599369705856, 52.039486030044223 ], [ -71.306620968385985, 51.971610647225681 ], [ -71.3852521847368, 51.842201289055396 ], [ -71.574183594959507, 51.814196502991223 ], [ -71.55163721173831, 51.789169887684984 ], [ -71.610667446443827, 51.692744197442295 ], [ -71.580147511277758, 51.509344178706982 ], [ -71.706026800931696, 51.439817932755702 ], [ -71.687931897139904, 51.401459025141072 ], [ -71.756300369151049, 51.339186720477755 ], [ -71.821280149824545, 51.373384685080055 ], [ -71.868619034664235, 51.343983305762556 ], [ -71.870120311300226, 51.277878975881833 ], [ -71.931866547968809, 51.275410434941186 ], [ -71.991243461538986, 51.230642722087758 ], [ -71.977866179604916, 51.211628790892647 ], [ -72.012993551621364, 51.220198781014162 ], [ -72.018682532877833, 51.138722835656104 ], [ -72.074140566569952, 51.150687671662489 ], [ -72.144714027195946, 51.069418869200177 ], [ -72.156027500507335, 50.978592714760488 ], [ -72.197975992391449, 50.99041784347564 ], [ -72.191667192447809, 51.029393868435434 ], [ -72.261556930901662, 51.00359071900008 ], [ -72.269595334337367, 50.951076254882999 ], [ -72.339739183559004, 50.885669226339608 ], [ -72.378226629827353, 50.888110075453852 ], [ -72.397509811883793, 50.813325279625538 ], [ -72.470280017219338, 50.784273812598528 ], [ -72.480711567244668, 50.750879134025659 ], [ -72.573375832499508, 50.750117395743501 ], [ -72.567190839113778, 50.885429230729166 ], [ -72.648286737839953, 50.747648244154433 ], [ -72.703726555891663, 50.781506467216389 ], [ -72.731350348729706, 50.761784250765459 ], [ -72.722162721569404, 50.798718593103452 ], [ -72.752005133560417, 50.820910537914862 ], [ -72.857900681641425, 50.812444509634709 ], [ -72.934312324222645, 50.762943434103612 ], [ -72.94096405364148, 50.713409452546145 ], [ -73.044625933807069, 50.654459722823731 ], [ -73.023141474548538, 50.697911788954464 ], [ -73.14707242557698, 50.750542239321447 ], [ -73.183699247213667, 50.701483754462252 ], [ -73.250414842093278, 50.692424398094076 ], [ -73.459726961931906, 50.368210812327739 ], [ -73.486034764580779, 50.400580425687487 ], [ -73.57361514593191, 50.359636563220278 ], [ -73.593429194707852, 50.403465048617775 ], [ -73.594997076396297, 50.349585265215225 ], [ -73.684497464680973, 50.21168373727172 ], [ -73.770337074998793, 50.2153264599404 ], [ -73.787170982234855, 50.056495629589449 ], [ -74.016589415838837, 49.801872518293457 ], [ -74.016619361218318, 49.735327510013313 ], [ -74.09841815282735, 49.735391377151736 ], [ -74.141625457503565, 49.679537938847204 ], [ -74.181497511497156, 49.679182617449257 ], [ -74.198506248540255, 49.610178317685119 ], [ -74.252650533795304, 49.567102528044757 ], [ -74.254526963323201, 49.511968346041272 ], [ -74.393706640652994, 49.474782579700467 ], [ -74.374935469729834, 49.442799013060011 ], [ -74.415701619343935, 49.382013009379605 ], [ -74.393395345015279, 49.369991140041499 ], [ -74.432853518167391, 49.323846801967193 ], [ -74.374842676398231, 49.316433385651678 ], [ -74.310702377359092, 49.258078990545066 ], [ -74.294565335895101, 49.289091523941501 ], [ -74.236496967878367, 49.287464736072124 ], [ -74.227217546918993, 49.217122300165983 ], [ -74.306763423124409, 49.041571620946748 ], [ -74.285232517440377, 48.990586158938406 ], [ -74.334945817545673, 48.966531324839508 ], [ -74.331658854131021, 48.922209945041651 ], [ -74.441019494041527, 48.932035376292021 ], [ -74.442051731341579, 48.975059211524616 ], [ -74.473349480843339, 48.946674928067694 ], [ -73.017742372481621, 47.998582582861786 ], [ -72.123637473327307, 47.998568367169206 ], [ -72.073470328859756, 47.949990039680458 ], [ -71.380006021783316, 47.950043356022327 ], [ -71.41162894502331, 47.99599292132477 ], [ -70.263649373981323, 47.992928196668693 ], [ -70.171875276252905, 48.124825950854877 ], [ -69.956658216509027, 48.057602006134921 ], [ -69.870213501943866, 48.181596546178383 ], [ -69.887263474875709, 48.220646027670114 ], [ -70.077875727198219, 48.252827776811912 ], [ -70.007992023269665, 48.357004317285998 ], [ -70.029889608212372, 52.83208781270055 ] ] ] } }, | |
| 8 | +{ "type": "Feature", "properties": { "code": "03", "name": "Capitale-Nationale", "geography_id": "region-03" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -69.668034142448676, 48.116365603295556 ], [ -69.870213501943866, 48.181596546178383 ], [ -69.956658216509027, 48.057602006134921 ], [ -70.171875276252905, 48.124825950854877 ], [ -70.263649373981323, 47.992928196668693 ], [ -71.277741933277042, 47.999357191155745 ], [ -71.41162894502331, 47.99599292132477 ], [ -71.380006021783316, 47.950043356022327 ], [ -72.073470328859756, 47.949990039680458 ], [ -71.890550235000347, 47.770184221377775 ], [ -72.239418820562292, 47.535906786493605 ], [ -72.141320196957963, 47.410545797658088 ], [ -72.238622306354003, 47.290993310959635 ], [ -72.413718343944282, 47.29258799319048 ], [ -72.415091480438079, 47.346849570661711 ], [ -72.462261013465167, 47.387327088007943 ], [ -72.541926270919177, 47.220052182777714 ], [ -72.514693969740335, 47.143985584961897 ], [ -72.378745467638637, 47.096670063023595 ], [ -72.435936882452737, 47.057484501448322 ], [ -72.167510331637416, 46.874580479659187 ], [ -72.393559845304253, 46.792105537821094 ], [ -72.059097502679919, 46.570065863675943 ], [ -71.872033345652156, 46.677398213824553 ], [ -71.690832336012477, 46.649841977385371 ], [ -71.359679261563258, 46.725868473983581 ], [ -71.23284067544931, 46.771256358346598 ], [ -71.170227220482928, 46.844262785262707 ], [ -71.000192026726708, 46.847477965797104 ], [ -70.842463384477369, 46.920060499421453 ], [ -70.706179022355244, 47.000893339345907 ], [ -70.674537858140056, 47.065671184499088 ], [ -70.524363351699876, 47.164836879234322 ], [ -70.454942106735729, 47.253504708328975 ], [ -70.288375919968885, 47.334062344776811 ], [ -70.14947993598517, 47.452950665186144 ], [ -70.069781222307483, 47.564698048148777 ], [ -69.910550122046899, 47.679365339581082 ], [ -69.719843658158908, 47.945531938820231 ], [ -69.548368964307301, 48.049451286329095 ], [ -69.668034142448676, 48.116365603295556 ] ] ] } }, | |
| 9 | +{ "type": "Feature", "properties": { "code": "04", "name": "Mauricie", "geography_id": "region-04" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -74.628528299078468, 48.967928210528704 ], [ -74.67627615106376, 49.000100609213888 ], [ -75.520552124226043, 49.000069842837618 ], [ -75.52108311081868, 47.762786669000775 ], [ -74.511510586554749, 47.762913212232824 ], [ -73.957427314645344, 47.38817074166036 ], [ -73.99897777547919, 47.345654068294458 ], [ -74.028582863686779, 47.177838740316375 ], [ -73.910582251261673, 47.099082725786459 ], [ -73.853251821247341, 47.151078007734448 ], [ -73.754786410209519, 47.066608391718958 ], [ -73.645947301680309, 46.937462374988222 ], [ -73.66420500507985, 46.928430977820284 ], [ -73.665448405545305, 46.863900769558484 ], [ -73.563991198189242, 46.883434344803256 ], [ -73.525943266818246, 46.862496996865673 ], [ -73.495076997383123, 46.912357008464134 ], [ -73.421237662478291, 46.890298277735525 ], [ -73.466958351044283, 46.709488617030409 ], [ -73.446013253223484, 46.59626519332074 ], [ -73.33650850439642, 46.601265189479548 ], [ -73.283001031209309, 46.533309347233555 ], [ -73.332946385296282, 46.498873757718741 ], [ -73.167002977363722, 46.382299797543638 ], [ -73.203427767519813, 46.358386264381274 ], [ -73.176173393606106, 46.323285879783676 ], [ -73.228451217800369, 46.316676010740366 ], [ -73.19567873073143, 46.294176916279092 ], [ -73.216691142333531, 46.279005524282347 ], [ -73.036707876104856, 46.156588056056776 ], [ -72.936245111859108, 46.151235214525087 ], [ -72.584675477647195, 46.292178045762704 ], [ -72.457086254740091, 46.38262139491821 ], [ -72.261829323494737, 46.436538240487778 ], [ -72.198908671019552, 46.531926948889009 ], [ -72.083010297404371, 46.57016485440311 ], [ -72.393559845304253, 46.792105537821094 ], [ -72.167510331637416, 46.874580479659187 ], [ -72.435936882452737, 47.057484501448322 ], [ -72.378745467638637, 47.096670063023595 ], [ -72.394376580293212, 47.117686726481814 ], [ -72.514693969740335, 47.143985584961897 ], [ -72.544151598119683, 47.19180318038002 ], [ -72.462261013465167, 47.387327088007943 ], [ -72.415091480438079, 47.346849570661711 ], [ -72.413718343944282, 47.29258799319048 ], [ -72.238622306354003, 47.290993310959635 ], [ -72.141320196957963, 47.410545797658088 ], [ -72.239418820562292, 47.535906786493605 ], [ -71.890550235000347, 47.770184221377775 ], [ -72.123637473327307, 47.998568367169206 ], [ -73.017742372481621, 47.998582582861786 ], [ -74.473349480843339, 48.946674928067694 ], [ -74.504776202898682, 48.92603866901505 ], [ -74.554259976518978, 49.000328406859218 ], [ -74.628528299078468, 48.967928210528704 ] ] ] } }, | |
| 10 | +{ "type": "Feature", "properties": { "code": "05", "name": "Estrie", "geography_id": "region-05" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -71.443021102978179, 45.23795889232062 ], [ -71.284224197807234, 45.302358900349063 ], [ -71.230614414673113, 45.249416647066532 ], [ -71.146258623733672, 45.239725536424437 ], [ -71.098200550576721, 45.30167331200979 ], [ -71.008520821205721, 45.319240839218878 ], [ -71.01065223453601, 45.347674995021393 ], [ -70.917835005885806, 45.311722477627193 ], [ -70.895048592277504, 45.240325275934111 ], [ -70.838065829639845, 45.234618352822359 ], [ -70.802532767212753, 45.365677211492255 ], [ -70.825736936115234, 45.400265287006043 ], [ -70.798094724949422, 45.426515839386354 ], [ -70.6587689063944, 45.377672496101148 ], [ -70.621269181747905, 45.40273637628222 ], [ -70.716395547558989, 45.486466965328582 ], [ -70.687692199657405, 45.56942196133901 ], [ -70.388425264321882, 45.750205814104 ], [ -70.421584258980573, 45.806879052362824 ], [ -70.47131985523535, 45.788828916834198 ], [ -70.499781722932013, 45.82643031702824 ], [ -70.631861631617895, 45.778682976592556 ], [ -70.651847606508028, 45.802721941836523 ], [ -70.895570053244185, 45.780116844051172 ], [ -70.898330992020945, 45.841799941594758 ], [ -71.029533777173654, 45.859131114369049 ], [ -71.094050537632825, 45.907066168491745 ], [ -71.070682307764542, 45.928350036604272 ], [ -71.129581244174034, 45.95733048468945 ], [ -71.162434229597238, 45.92344135188695 ], [ -71.135149628077386, 45.860284381604686 ], [ -71.178095409762548, 45.815105797795113 ], [ -71.348405226966108, 45.872239132696443 ], [ -71.340525510401875, 45.809642148014156 ], [ -71.392621357926842, 45.768845798672857 ], [ -71.462991932765746, 45.817438438612044 ], [ -71.541554867594229, 45.787721896821793 ], [ -71.617307455923466, 45.830459887264588 ], [ -71.635630367494372, 45.813886510663465 ], [ -71.770166310577153, 45.885008003950425 ], [ -71.861549478822056, 45.798949709074833 ], [ -71.921234294343748, 45.796312679190102 ], [ -72.029723388488179, 45.853250091340122 ], [ -72.206338858940342, 45.696387398495901 ], [ -72.22710020920654, 45.756215283087457 ], [ -72.311259830468998, 45.688733854358091 ], [ -72.237239174723115, 45.646239550115112 ], [ -72.297674910233312, 45.589406559848264 ], [ -72.359675278394604, 45.589394511454259 ], [ -72.402882465436264, 45.528484335743826 ], [ -72.404264784013307, 45.449202259034848 ], [ -72.454395190654509, 45.451382224352827 ], [ -72.448891019697939, 45.493590727210915 ], [ -72.586004737387029, 45.498289520969578 ], [ -72.604923587793806, 45.53768564860286 ], [ -72.799820629858019, 45.537984283382279 ], [ -72.883053652889188, 45.265547434664327 ], [ -72.989998687698574, 45.341932185325028 ], [ -73.04885686885568, 45.325351957532646 ], [ -73.015781549062282, 45.272101883006535 ], [ -73.046169602522852, 45.293109989836111 ], [ -73.063446871099544, 45.267768927457155 ], [ -73.058243389196775, 45.214581402871616 ], [ -73.091315679638683, 45.196142179396773 ], [ -73.139875247712169, 45.014416334322121 ], [ -71.501018913510123, 45.013443034828406 ], [ -71.498042806714636, 45.070189984928632 ], [ -71.428385248451946, 45.123672226499551 ], [ -71.39734167077053, 45.203518351667682 ], [ -71.443021102978179, 45.23795889232062 ] ] ] } }, | |
| 11 | +{ "type": "Feature", "properties": { "code": "06", "name": "Montréal", "geography_id": "region-06" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -73.474494241560407, 45.703765494606756 ], [ -73.614290233622512, 45.64182081843304 ], [ -73.677153236429973, 45.554934772678436 ], [ -73.763709133345159, 45.511898205271045 ], [ -73.910482022716167, 45.520967510403949 ], [ -73.99660217387256, 45.457487266119614 ], [ -73.982657099967184, 45.417792917830191 ], [ -73.822179339343961, 45.385403014947222 ], [ -73.76795484614351, 45.418680681935363 ], [ -73.545863425161329, 45.424292328833332 ], [ -73.474494241560407, 45.703765494606756 ] ] ] } }, | |
| 12 | +{ "type": "Feature", "properties": { "code": "07", "name": "Outaouais", "geography_id": "region-07" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -75.520649668590039, 47.847189404370802 ], [ -75.725316396732666, 47.847533344151287 ], [ -75.77210165831633, 47.818818355500142 ], [ -75.805795113177936, 47.669467960919746 ], [ -75.856904683891884, 47.713940396137609 ], [ -75.827843759251792, 47.736126383477732 ], [ -75.856909132628402, 47.803421880374593 ], [ -75.934512906304661, 47.80026845898724 ], [ -76.015805457815418, 47.723742829095542 ], [ -76.227954740037944, 47.730333474527789 ], [ -76.307171064751586, 47.666404778614464 ], [ -76.384934047759444, 47.661453930239134 ], [ -76.493523359906106, 47.599877247664956 ], [ -76.416197167851394, 47.603103040641393 ], [ -76.42481160855283, 47.512968278982449 ], [ -76.575405752175499, 47.604001334346137 ], [ -76.575717555967017, 47.289786837762072 ], [ -77.002046104590377, 47.290178101612298 ], [ -77.002270838943005, 47.145344686343073 ], [ -77.432656246217149, 47.144707761855813 ], [ -77.432627200072716, 47.290308437526271 ], [ -77.931267008686817, 47.269376741829973 ], [ -77.858372344834436, 47.236282103261551 ], [ -77.84732374585208, 47.105116252970639 ], [ -77.826173615235774, 47.108169922379176 ], [ -77.843739534204872, 46.871696684346404 ], [ -77.856077593522556, 46.941183091579852 ], [ -77.895178744813308, 46.829751437780544 ], [ -77.820625171075633, 46.821423607355484 ], [ -77.817485146928078, 46.709965233885789 ], [ -77.861799349392498, 46.656473727232637 ], [ -77.772493380443677, 46.582981577959401 ], [ -77.776024032172131, 46.453044392702949 ], [ -77.740112187730503, 46.403280843723067 ], [ -77.838786429695602, 46.314472483611688 ], [ -77.841600524610016, 46.206371388087945 ], [ -77.669067476440276, 46.195563928748854 ], [ -77.283709334575335, 46.018612437890987 ], [ -77.273582940883983, 45.944773152079058 ], [ -77.197351636207117, 45.870664884983306 ], [ -76.991527234922614, 45.786639421736126 ], [ -76.911949861650911, 45.809324495981315 ], [ -76.923102179018002, 45.89546026495676 ], [ -76.780624076468854, 45.873624957653043 ], [ -76.769280682695126, 45.733583209240635 ], [ -76.691860802368723, 45.710138717815909 ], [ -76.709411023178333, 45.664183192091855 ], [ -76.657611882567039, 45.559446200299334 ], [ -76.369006227956632, 45.458743052058473 ], [ -76.243647442951357, 45.467460710893619 ], [ -76.205687291592682, 45.51791989431397 ], [ -76.093475797384414, 45.517089313230137 ], [ -75.814671062500182, 45.373472303251738 ], [ -75.681101985068196, 45.459427058760845 ], [ -75.238940780018808, 45.586437633604014 ], [ -75.123420274207348, 45.577428202760629 ], [ -74.938089018413464, 45.644966062432971 ], [ -74.807766485218835, 45.638317997372383 ], [ -74.698759978142306, 45.971776245459942 ], [ -74.801302826693032, 45.94509613192642 ], [ -74.858617777396333, 46.060743378305773 ], [ -74.939450909792754, 46.061728800812439 ], [ -74.939544277084366, 46.105533128782604 ], [ -75.089790696686322, 46.103131465914828 ], [ -75.074034858220458, 46.215292787572253 ], [ -75.117388337025787, 46.215204782338553 ], [ -75.176360583907524, 46.103409070907723 ], [ -75.407157497500535, 46.097862133633278 ], [ -75.40720978574069, 45.972950674008828 ], [ -75.599159410171552, 45.971449813692011 ], [ -75.620766718698903, 45.99471255220395 ], [ -75.784749726565195, 45.959745395087303 ], [ -75.697274975935656, 46.023889317706846 ], [ -75.764419246762529, 46.022219814433356 ], [ -75.768311307229538, 46.17417628912839 ], [ -75.79901348555326, 46.174121622782152 ], [ -75.813144849910231, 46.224916684772147 ], [ -75.783033258740502, 46.282010335183763 ], [ -75.814570296980875, 46.306351082622271 ], [ -75.707042202236863, 46.309165822329106 ], [ -75.706004087524875, 46.438816366965149 ], [ -75.745213974613961, 46.438256989474063 ], [ -75.746957404129944, 46.844824405657036 ], [ -75.890005118585236, 46.815501543624606 ], [ -75.951767366861986, 46.869493132957807 ], [ -76.137221267298031, 46.937268542227528 ], [ -76.148472638218152, 47.006168967013544 ], [ -76.102355610337128, 47.03047835950585 ], [ -76.094056880560345, 47.187733711472099 ], [ -75.976184969153806, 47.203159978619652 ], [ -75.803433606163409, 47.318536358414676 ], [ -75.747833523399976, 47.272918806008541 ], [ -75.647836725372869, 47.281748369106872 ], [ -75.625835534409759, 47.180936354364015 ], [ -75.473452526701323, 47.282386261067401 ], [ -75.46043712927208, 47.367888553088342 ], [ -75.308027261425593, 47.547913465039748 ], [ -75.360287105443618, 47.570213293959497 ], [ -75.36959914065028, 47.627142395229775 ], [ -75.41419349614057, 47.588627982766532 ], [ -75.446512536589253, 47.663614582766868 ], [ -75.524603928751503, 47.710975082476388 ], [ -75.520649668590039, 47.847189404370802 ] ] ] } }, | |
| 13 | +{ "type": "Feature", "properties": { "code": "08", "name": "Abitibi-Témiscamingue", "geography_id": "region-08" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -77.84167815336879, 46.29199934848797 ], [ -77.740112187730503, 46.403280843723067 ], [ -77.776024032172131, 46.453044392702949 ], [ -77.772493380443677, 46.582981577959401 ], [ -77.861799349392498, 46.656473727232637 ], [ -77.817485146928078, 46.709965233885789 ], [ -77.820625171075633, 46.821423607355484 ], [ -77.848309870090262, 46.812167251765956 ], [ -77.895178744813308, 46.829751437780544 ], [ -77.856077593522556, 46.941183091579852 ], [ -77.845399655286656, 46.928642276596769 ], [ -77.850796967610847, 46.891549662852825 ], [ -77.849458980665034, 46.872214142901427 ], [ -77.843739534204872, 46.871696684346404 ], [ -77.826173615235774, 47.108169922379176 ], [ -77.842906589810127, 47.10341390816766 ], [ -77.84732374585208, 47.105116252970639 ], [ -77.858372344834436, 47.236282103261551 ], [ -77.931267008686817, 47.269376741829973 ], [ -77.432627200072716, 47.290308437526271 ], [ -77.432656246217149, 47.144707761855813 ], [ -77.002270838943005, 47.145344686343073 ], [ -77.002046104590377, 47.290178101612298 ], [ -76.575717555967017, 47.289786837762072 ], [ -76.575405752175499, 47.604001334346137 ], [ -76.42481160855283, 47.512968278982449 ], [ -76.416197167851394, 47.603103040641393 ], [ -76.42230858977571, 47.607541906719469 ], [ -76.444077843274727, 47.599315059308914 ], [ -76.451452618191809, 47.604154834963936 ], [ -76.461538996894973, 47.599870966203 ], [ -76.48281658967268, 47.602862488108208 ], [ -76.484227085079922, 47.600497000194075 ], [ -76.492220349073378, 47.597889245285252 ], [ -76.493523359906106, 47.599877247664956 ], [ -76.384934047759444, 47.661453930239134 ], [ -76.307171064751586, 47.666404778614464 ], [ -76.227954740037944, 47.730333474527789 ], [ -76.015805457815418, 47.723742829095542 ], [ -75.934512906304661, 47.80026845898724 ], [ -75.856909132628402, 47.803421880374593 ], [ -75.827843759251792, 47.736126383477732 ], [ -75.856904683891884, 47.713940396137609 ], [ -75.805795113177936, 47.669467960919746 ], [ -75.77210165831633, 47.818818355500142 ], [ -75.725316396732666, 47.847533344151287 ], [ -75.520642706984191, 47.84773784003152 ], [ -75.520552124226043, 49.000069842837618 ], [ -79.517798777935582, 49.009119745036998 ], [ -79.511580065343409, 47.540755593117893 ], [ -79.555888627767843, 47.515978856662336 ], [ -79.579333918908006, 47.432643008902822 ], [ -79.4342424365082, 47.258887228348748 ], [ -79.432660242856826, 47.080553291583406 ], [ -79.22728698878133, 46.845269172940291 ], [ -79.166863369085988, 46.821045569398933 ], [ -79.093776408409411, 46.688349726743994 ], [ -79.015610321917634, 46.631460025549899 ], [ -78.987860035328865, 46.547014121462858 ], [ -78.889633970277515, 46.460124155971592 ], [ -78.729294744359493, 46.384818044199754 ], [ -78.711266873459977, 46.324984726613728 ], [ -78.394840352724515, 46.292540003576875 ], [ -78.308615340496871, 46.253178567707621 ], [ -78.146983660197435, 46.276901439123463 ], [ -77.841600524610016, 46.206371388087945 ], [ -77.84167815336879, 46.29199934848797 ] ] ] } }, | |
| 14 | +{ "type": "Feature", "properties": { "code": "09", "name": "Côte-Nord", "geography_id": "region-09" }, "geometry": { "type": "MultiPolygon", "coordinates": [ [ [ [ -57.107825288920488, 51.806053933015811 ], [ -57.11861639612836, 51.198882209428426 ], [ -57.736140913255717, 50.998608066974619 ], [ -58.940366075568242, 49.848609707635632 ], [ -60.4768183638529, 48.781392206912379 ], [ -60.468597199247426, 48.666667945248911 ], [ -62.999128701777302, 48.666683203520115 ], [ -62.999128389281516, 48.701792945343961 ], [ -63.526312062315604, 48.929185506696911 ], [ -64.163776728351948, 49.319940799915813 ], [ -64.595652328655106, 49.509721780934925 ], [ -65.178200944840341, 49.668475703752648 ], [ -65.818586606783697, 49.717034520407651 ], [ -66.078485917005565, 49.692702208684359 ], [ -66.376998539123647, 49.619091764423644 ], [ -66.595258093378234, 49.52167529709115 ], [ -67.153339142634252, 49.18906403276106 ], [ -67.653527346887046, 49.059287454794955 ], [ -68.403560660051355, 48.787038498057406 ], [ -68.999999195820209, 48.451724286567014 ], [ -69.548368964307301, 48.049451286329095 ], [ -69.676673434722431, 48.119504527453621 ], [ -69.840734878094736, 48.152784095925362 ], [ -69.893624803044318, 48.225785046233547 ], [ -70.077875727198219, 48.252827776811912 ], [ -70.007992023269665, 48.357004317285998 ], [ -70.029882586478294, 55.000135978323222 ], [ -67.396103909968772, 54.989968577548034 ], [ -67.253073063711994, 54.833854671596548 ], [ -67.20840453378392, 54.824520869720807 ], [ -67.207155075651244, 54.790659146604654 ], [ -67.053178025612809, 54.695712206771361 ], [ -67.133960852620106, 54.622016945873568 ], [ -67.278774138039324, 54.587794989803356 ], [ -67.291025348193045, 54.552905840144852 ], [ -67.219050522831679, 54.520821404158553 ], [ -67.26283075092698, 54.483487746351649 ], [ -67.492755297848674, 54.581686393448138 ], [ -67.519062229233697, 54.569908359622495 ], [ -67.471116363136062, 54.531934744328147 ], [ -67.492090623636315, 54.47982220414292 ], [ -67.623040585146583, 54.473961977489282 ], [ -67.645596448744385, 54.508351372783665 ], [ -67.669959524630329, 54.446322764089892 ], [ -67.774714058654183, 54.43843445822084 ], [ -67.714018010369642, 54.388127760961794 ], [ -67.740880830315334, 54.347127177995517 ], [ -67.662906158555586, 54.311848311003011 ], [ -67.670351923850106, 54.240291651330274 ], [ -67.606516460746448, 54.207262999730425 ], [ -67.651240799927137, 54.186568616571492 ], [ -67.635740595181304, 54.158762499877241 ], [ -67.803637272244188, 54.130013886339242 ], [ -67.750275579812552, 54.088877760739827 ], [ -67.817612439631503, 54.023931958499759 ], [ -67.676468261143242, 53.97854164617933 ], [ -67.666579151605475, 53.937066079056187 ], [ -67.583158882509665, 53.923286396379972 ], [ -67.603299477702564, 53.873091348410412 ], [ -67.488850814767616, 53.838534681421805 ], [ -67.593023410855466, 53.793479468404499 ], [ -67.545441863756309, 53.777841887403092 ], [ -67.593941299400356, 53.764034694732686 ], [ -67.492059451767091, 53.756345254059596 ], [ -67.513310876192293, 53.734956330912283 ], [ -67.469113876417595, 53.714927563360135 ], [ -67.406131918408192, 53.721868315163192 ], [ -67.383058641753323, 53.616732490417583 ], [ -67.303694434854762, 53.587648336087007 ], [ -67.307555775238967, 53.552369730320464 ], [ -67.03637536457795, 53.536005015963696 ], [ -66.971181133005516, 53.500005539743171 ], [ -66.989932701426625, 53.468671893313498 ], [ -66.932235799236452, 53.475616073585456 ], [ -66.921459135097166, 53.425309725393063 ], [ -66.875178943833731, 53.413642192147492 ], [ -67.020575332209958, 53.339531403096473 ], [ -66.944547547945831, 53.283280565224167 ], [ -66.974467829070392, 53.22875276334431 ], [ -66.951190804164526, 53.180446652219636 ], [ -67.006221941393235, 53.156614156597001 ], [ -66.942275792913961, 53.137112531257401 ], [ -66.983639676314382, 53.118640840665698 ], [ -66.978141608102518, 53.083834463516652 ], [ -67.072335057785935, 53.073418079704503 ], [ -67.055807811150061, 53.137364145003026 ], [ -67.115942967423464, 53.100305809579325 ], [ -67.144751658265406, 53.140307263933792 ], [ -67.267650855670482, 53.177032802070322 ], [ -67.286283911272662, 53.138862756735818 ], [ -67.372387221550042, 53.101635531312034 ], [ -67.344189712650959, 52.982281440415903 ], [ -67.232796917051459, 52.981781953336686 ], [ -67.338084785901472, 52.951837518871514 ], [ -67.341368378914609, 52.894170276016936 ], [ -67.165828067379664, 52.815249705403652 ], [ -67.150963575445573, 52.85344500844738 ], [ -67.06367060257233, 52.875690833424535 ], [ -67.07037858324702, 52.776942730864079 ], [ -67.032377244907593, 52.749136621413165 ], [ -66.955628392884108, 52.760412201768602 ], [ -66.899941416216151, 52.678465285462828 ], [ -66.853300286697987, 52.674103863430119 ], [ -66.896438928307489, 52.730771646773647 ], [ -66.855740044651554, 52.719799458239606 ], [ -66.858677746136536, 52.768855530921996 ], [ -66.760753904074221, 52.667265864143261 ], [ -66.752423297435726, 52.745744164037404 ], [ -66.789338594804349, 52.799467245431778 ], [ -66.714838018332486, 52.759910811970911 ], [ -66.647190829350592, 52.782270575052294 ], [ -66.665080479001162, 52.892438873529201 ], [ -66.631466992586255, 52.898716337534822 ], [ -66.630742711944222, 52.955911406248738 ], [ -66.497915022224902, 52.948184748414505 ], [ -66.460286453374422, 53.052743598313221 ], [ -66.36574220917224, 53.020601647235537 ], [ -66.257606689881044, 52.866793302468139 ], [ -66.294915601934647, 52.860238002342221 ], [ -66.286172175369771, 52.835904682869533 ], [ -66.387054188688836, 52.877793895211383 ], [ -66.417481957335056, 52.850627835387463 ], [ -66.386068916438632, 52.795738333024907 ], [ -66.346537797116682, 52.794432521328858 ], [ -66.307726590070956, 52.725764490410775 ], [ -66.339492213968981, 52.683401652730581 ], [ -66.274391984778191, 52.626957215007216 ], [ -66.316242835033336, 52.609206130902919 ], [ -66.387855293607643, 52.681790871004011 ], [ -66.440904514067498, 52.637653909390181 ], [ -66.380112017636861, 52.565987442403348 ], [ -66.371716682872503, 52.531069678447793 ], [ -66.400963027784698, 52.531961335801526 ], [ -66.354370077744235, 52.469928904895013 ], [ -66.393708366170856, 52.475099964479853 ], [ -66.339886897873413, 52.366592773173466 ], [ -66.361989418947587, 52.350429759922505 ], [ -66.415729428000134, 52.386355786286927 ], [ -66.492566680241424, 52.340772258515962 ], [ -66.432077175361002, 52.265803069056538 ], [ -66.470159136410459, 52.253831629324431 ], [ -66.359373686096177, 52.179267458475245 ], [ -66.374404940744995, 52.150016412050661 ], [ -66.269689115895304, 52.148650269198015 ], [ -66.314564499801676, 52.291263877638741 ], [ -66.2563875824126, 52.310149718515127 ], [ -66.199268881320307, 52.224182768643864 ], [ -66.097612782170941, 52.200563036698163 ], [ -66.070008540389054, 52.149201426274431 ], [ -66.094343583730492, 52.088951110076465 ], [ -65.978281569704478, 52.053116390102694 ], [ -65.972088670677451, 52.108894984199665 ], [ -65.89808410378572, 52.077532735367164 ], [ -65.871556393543131, 52.120310844893829 ], [ -65.802692792107777, 52.124976999333882 ], [ -65.732941731388138, 52.083115582621332 ], [ -65.666080562017953, 52.121728060795441 ], [ -65.640131178189037, 52.060283044617243 ], [ -65.674362130862647, 52.012645290599629 ], [ -65.647166990620732, 51.990950230469728 ], [ -65.620988830375026, 52.048088050517805 ], [ -65.538564144719359, 52.04539164280397 ], [ -65.453965585788822, 52.161311672839219 ], [ -65.485022172317557, 52.175756360910619 ], [ -65.469910471121239, 52.191534147655453 ], [ -65.249533129429423, 52.281556970315158 ], [ -65.199562817863082, 52.261808096037022 ], [ -65.208310528626512, 52.201055827907751 ], [ -65.170253291246382, 52.195305542789995 ], [ -65.146614911371216, 52.10458221702266 ], [ -65.087000541348772, 52.166804663445191 ], [ -64.956702171812651, 52.105815567774222 ], [ -64.906127515336152, 52.045297209505812 ], [ -64.825430352501527, 52.0283799757418 ], [ -64.806006707046421, 52.004456086578003 ], [ -64.841986643106168, 51.976379170637173 ], [ -64.704186962844034, 51.87866857496001 ], [ -64.780114451817994, 51.780176090582124 ], [ -64.696887274617765, 51.75464833386431 ], [ -64.673496135176407, 51.701146374501754 ], [ -64.632605250616436, 51.70623001222706 ], [ -64.620965360505821, 51.632974466661658 ], [ -64.553153354930899, 51.577216949315591 ], [ -64.449563121954654, 51.629615564776294 ], [ -64.426867466342927, 51.681918906979639 ], [ -64.340200300439648, 51.665602233132432 ], [ -64.278554231050961, 51.737424740898398 ], [ -64.307555090034612, 51.75592612995262 ], [ -64.296942753273541, 51.79845446054216 ], [ -64.357557546833505, 51.848141373350444 ], [ -64.363974995689574, 51.980965317358255 ], [ -64.341279463288345, 51.986687545210543 ], [ -64.365863651013726, 52.006215534302257 ], [ -64.234159407070152, 51.975440284337843 ], [ -64.296207151322406, 52.082113344051727 ], [ -64.250937975989132, 52.082790565237005 ], [ -64.254077222336889, 52.149234975168014 ], [ -64.165822513966702, 52.120346652183912 ], [ -64.254408912030968, 52.277469421524792 ], [ -64.208575633218189, 52.284995803972151 ], [ -64.22252023883118, 52.315357213429351 ], [ -64.16635143898047, 52.30985696783673 ], [ -64.190490594015401, 52.334301910933711 ], [ -64.106645786935715, 52.409126975656221 ], [ -64.136923048499767, 52.430628066479514 ], [ -64.149170889782255, 52.517602201607495 ], [ -64.232481099086286, 52.583170241231407 ], [ -64.158624714914154, 52.606319756820618 ], [ -64.175846107612486, 52.672194743261102 ], [ -64.124038403119613, 52.695278065627775 ], [ -64.148760834686584, 52.730028631394418 ], [ -63.937669410984739, 52.730471633757332 ], [ -63.874055349655585, 52.776083016019001 ], [ -63.722771318847435, 52.773221144090137 ], [ -63.719854482276475, 52.802054754306901 ], [ -63.637572767774955, 52.833138025555151 ], [ -63.682102297751754, 52.850583067824523 ], [ -63.613515331869316, 52.839526623683355 ], [ -63.602970014241066, 52.91871912509535 ], [ -63.507960018390172, 52.956523831167317 ], [ -63.446846743594023, 52.944884429676868 ], [ -63.480987759648428, 52.90452276445918 ], [ -63.460879179171826, 52.881550850386574 ], [ -63.39674588552932, 52.886940812655894 ], [ -63.366671343911413, 52.848941984054854 ], [ -63.388451404567633, 52.837469697203844 ], [ -63.318060848657247, 52.821913598201448 ], [ -63.288893308327566, 52.78071858394101 ], [ -63.315811374368415, 52.769246372943272 ], [ -63.242781339013334, 52.720439727452089 ], [ -63.154721006924873, 52.817440561146391 ], [ -62.965325331241836, 52.75863038753787 ], [ -62.933628889499239, 52.794963851497116 ], [ -62.667008645000131, 52.77574244029514 ], [ -62.694481029840325, 52.813270837294894 ], [ -62.636951593857269, 52.796353592545636 ], [ -62.604505518131326, 52.822548375367177 ], [ -62.704171077288741, 52.863688047148479 ], [ -62.66808966951249, 52.886910518497146 ], [ -62.720830012526967, 52.8972435863977 ], [ -62.72221449861626, 52.923465790499009 ], [ -62.641086992454795, 52.935438853768943 ], [ -62.585725744317081, 52.894660248164961 ], [ -62.528543343275771, 52.915493615937613 ], [ -62.44415717859102, 52.865381127562891 ], [ -62.508306718851117, 52.835575604540701 ], [ -62.468806138491722, 52.800880525997933 ], [ -62.485890228675494, 52.788963918728612 ], [ -62.370719966182435, 52.745879132988783 ], [ -62.177184987561766, 52.73607250529966 ], [ -62.135457976706199, 52.777350246350011 ], [ -61.907682731598001, 52.695763571195037 ], [ -61.861270610265819, 52.632790267462163 ], [ -61.744594197498799, 52.609343861797711 ], [ -61.714815256009331, 52.57400966969373 ], [ -61.462378854774784, 52.532536914281842 ], [ -61.453217802158768, 52.471148083544989 ], [ -61.215768792237775, 52.508003671289096 ], [ -61.056918635036538, 52.443421344471282 ], [ -61.003758047807452, 52.385894149096544 ], [ -60.653774550780099, 52.303668876555875 ], [ -60.563104213571656, 52.319585034803097 ], [ -60.604076709561191, 52.397752807356916 ], [ -60.517377752649821, 52.454502743266048 ], [ -60.22687978762319, 52.4525283547149 ], [ -60.080944149637418, 52.518000512241841 ], [ -59.967359128528173, 52.496193868942434 ], [ -60.000748620964579, 52.470776366346158 ], [ -59.982341377601045, 52.451358923981317 ], [ -59.843547729207856, 52.447720245674638 ], [ -59.834880623524136, 52.467887233920528 ], [ -60.015829485207661, 52.570611952436309 ], [ -59.815544791299963, 52.562943608042708 ], [ -59.842461362321465, 52.608638933545564 ], [ -59.905658051366117, 52.620194966076618 ], [ -59.798653081297694, 52.678389153933388 ], [ -59.709705249914407, 52.676277519557502 ], [ -59.539479227245565, 52.556969436741703 ], [ -59.498948597181574, 52.621691943632612 ], [ -59.384666937152708, 52.606218891221708 ], [ -59.309302595333286, 52.630190857490312 ], [ -59.242496075910424, 52.554661716958556 ], [ -59.141465525626614, 52.516799422082627 ], [ -59.049850486624315, 52.54096557110379 ], [ -58.921041584410105, 52.473213856144717 ], [ -58.859817174918078, 52.464713303132086 ], [ -58.831871125267355, 52.504991427293803 ], [ -58.650467464653488, 52.457660046976379 ], [ -58.622160295566559, 52.474076985996419 ], [ -58.667497476837788, 52.49496529104929 ], [ -58.678857857086967, 52.559993653605581 ], [ -58.521522523280126, 52.575713070171084 ], [ -58.572091957965235, 52.651655304289726 ], [ -58.447978574850886, 52.654122180888464 ], [ -58.455617165837914, 52.696400843302357 ], [ -58.384194694342284, 52.708648883865457 ], [ -58.218707531525297, 52.62826582970586 ], [ -58.257014153980528, 52.611182169075157 ], [ -58.125842113735139, 52.562457770380384 ], [ -58.083868529641094, 52.57442973385173 ], [ -58.063106548216901, 52.490450575204342 ], [ -57.974685523625105, 52.42655883279987 ], [ -57.860874250420139, 52.412751409402183 ], [ -57.868346317743594, 52.441779399389866 ], [ -57.809649775651103, 52.468501611643745 ], [ -57.848123306926134, 52.499724465101735 ], [ -57.815038353400382, 52.511641076162043 ], [ -57.731900892437437, 52.478669418035992 ], [ -57.786509986179155, 52.427806358483686 ], [ -57.748592205955582, 52.418500580748002 ], [ -57.739882572188705, 52.370727838894979 ], [ -57.676778370381783, 52.31628579543154 ], [ -57.588109193975917, 52.288868051319199 ], [ -57.467801493507579, 52.189616092521419 ], [ -57.409119646082125, 52.066076390374448 ], [ -57.333534223958161, 52.052075869014025 ], [ -57.278503082629172, 52.096575795463814 ], [ -57.166977519718273, 52.057940258916886 ], [ -57.076274480766358, 52.103354424666698 ], [ -56.989244927930088, 52.07771500278885 ], [ -56.935204453191488, 51.973276106105992 ], [ -57.022647555092327, 51.992302778909298 ], [ -57.051459542388109, 51.919859713836829 ], [ -57.081127496221633, 51.915610310973307 ], [ -57.056544121284638, 51.872276116368312 ], [ -57.107825288920488, 51.806053933015811 ] ] ], [ [ [ -63.564797186527642, 54.987862753823713 ], [ -63.614578622140087, 54.976751933186257 ], [ -63.578631272123509, 54.951973329842822 ], [ -63.606968312586574, 54.943862176035104 ], [ -63.578047696868659, 54.926472529551191 ], [ -63.628362154992722, 54.924584129487179 ], [ -63.604386093072826, 54.899333342124827 ], [ -63.828071414044651, 54.945002527972179 ], [ -63.790569942805057, 54.930585271753117 ], [ -63.857379192478696, 54.886918314745948 ], [ -63.811128625915373, 54.818250551956538 ], [ -63.926598571450107, 54.778638933550774 ], [ -63.782462735107316, 54.715470876758296 ], [ -63.709757206237789, 54.622301929584864 ], [ -63.814073678700424, 54.648636706348753 ], [ -63.894261404097897, 54.598691646991348 ], [ -63.963902478485025, 54.618108845275351 ], [ -64.0522202274731, 54.597303879822846 ], [ -64.184145777637212, 54.692500826538428 ], [ -64.185367490708941, 54.722834389773553 ], [ -64.28289925086473, 54.728640821729208 ], [ -64.297259755048728, 54.763724689624496 ], [ -64.39401359718984, 54.788225789565402 ], [ -64.48734393313002, 54.793392465707363 ], [ -64.477623604135772, 54.746391639354265 ], [ -64.550401615764656, 54.762697816002451 ], [ -64.528070066737953, 54.72953077250812 ], [ -64.552461901652109, 54.710558320144301 ], [ -64.788388940392096, 54.731171413617496 ], [ -64.803388390818995, 54.75872752735124 ], [ -64.751472849577951, 54.767227473972113 ], [ -64.780694728255384, 54.787033576339674 ], [ -64.747610822920848, 54.808978322030285 ], [ -64.772333539834094, 54.837756625360015 ], [ -64.86644826751089, 54.841229450677503 ], [ -64.859474960839762, 54.886202245100463 ], [ -65.077538050593134, 54.967316094731082 ], [ -65.128040749629434, 54.924926922715514 ], [ -65.174098293367138, 54.929454972165971 ], [ -65.198572724194307, 54.849092752658848 ], [ -65.288993632124829, 54.839898641524883 ], [ -65.297771312556108, 54.812565292813439 ], [ -65.329300505801612, 54.844482255622722 ], [ -65.353856212523553, 54.817371713547153 ], [ -65.482802779605649, 54.832734139197377 ], [ -65.431274354991373, 54.781372814388291 ], [ -65.451026090788162, 54.732122172975416 ], [ -65.647506042268063, 54.751013051917433 ], [ -65.679535558209253, 54.705262543542077 ], [ -65.855069155246056, 54.851291151455889 ], [ -65.852262834526996, 54.926598088749877 ], [ -66.026909113235121, 54.942709714770039 ], [ -66.036436935791983, 54.916709417179895 ], [ -66.159497765328481, 54.988516075927976 ], [ -66.243306099676843, 54.971766693374398 ], [ -66.259807378616216, 54.998683929520993 ], [ -63.564797186527642, 54.987862753823713 ] ] ], [ [ [ -66.750048101000232, 55.000103614678743 ], [ -66.58582499861852, 54.812599139348855 ], [ -66.652882272507, 54.76634944504714 ], [ -66.748356944809615, 54.813128577798501 ], [ -66.667188839115653, 54.7123763698104 ], [ -66.873695479796041, 54.812934671364367 ], [ -66.935142852550598, 54.79971279227356 ], [ -67.224234775077619, 54.995440241234363 ], [ -66.750048101000232, 55.000103614678743 ] ] ], [ [ [ -66.643240784157186, 55.000102216056803 ], [ -66.645324707630778, 54.976601394084447 ], [ -66.687658339330468, 55.000130408334478 ], [ -66.643240784157186, 55.000102216056803 ] ] ] ] } }, | |
| 15 | +{ "type": "Feature", "properties": { "code": "10", "name": "Nord-du-Québec", "geography_id": "region-10" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -77.499999959000888, 62.57944802246044 ], [ -77.721934006547684, 62.468803909549209 ], [ -78.090066892309636, 62.362655707424011 ], [ -78.200405133951548, 62.255267975668673 ], [ -78.165157752090991, 62.220007918415568 ], [ -78.185933757678811, 62.148322721399794 ], [ -78.162965298677136, 61.997596404916244 ], [ -78.092302762719953, 61.928833906606734 ], [ -78.101024989644031, 61.857196908020455 ], [ -78.058912664345556, 61.827907219114991 ], [ -77.997803488162759, 61.681452590522412 ], [ -77.888860223274577, 61.638144593535976 ], [ -77.806798676697028, 61.466727097741305 ], [ -77.773387772316866, 61.280113812880323 ], [ -77.743566398765395, 61.259409488100268 ], [ -77.793496040768105, 61.240444219672845 ], [ -77.832039119396498, 61.057672093264806 ], [ -78.09694517650216, 60.930063688129437 ], [ -78.194432007666336, 60.85832791729478 ], [ -78.227856853635274, 60.789295488121759 ], [ -78.078766872281633, 60.792029462772149 ], [ -77.845238814247736, 60.660283688283762 ], [ -77.912314516043452, 60.603562803734413 ], [ -77.905789157378209, 60.570114904441695 ], [ -77.738564603477357, 60.452961208355475 ], [ -77.775557873420368, 60.385491414097402 ], [ -77.607849579201869, 60.291883508731779 ], [ -77.63542987515001, 60.253438498084918 ], [ -77.569706606041152, 60.188349103728306 ], [ -77.625329001769828, 60.117502020079755 ], [ -77.607620390034015, 60.097270793079886 ], [ -77.674605368811214, 60.065861783891442 ], [ -77.465229127183804, 59.917033415981351 ], [ -77.464709485555957, 59.793951579123629 ], [ -77.539457429967172, 59.756387792880496 ], [ -77.752960790550162, 59.762856326298149 ], [ -77.846911772174863, 59.726619014613199 ], [ -77.879439259719192, 59.624386194334491 ], [ -77.835797614426326, 59.601587092034748 ], [ -77.846746227942674, 59.5530684126837 ], [ -77.909401470765943, 59.506903380187993 ], [ -77.989230200547198, 59.261993209276611 ], [ -78.189933422745526, 59.19436699196099 ], [ -78.165379785845715, 59.185927575932567 ], [ -78.298916117448655, 59.055710892924957 ], [ -78.555400666098322, 58.948879814787091 ], [ -78.584554226829056, 58.904787488025491 ], [ -78.557717094056912, 58.842514394399714 ], [ -78.631422714602138, 58.812915582820132 ], [ -78.538709005670796, 58.750276796291786 ], [ -78.592684594311422, 58.650078701622249 ], [ -78.487940669787719, 58.617645006438252 ], [ -78.481786523010584, 58.576456481883653 ], [ -78.426546675051853, 58.541350181841054 ], [ -78.240964532916493, 58.494268611723257 ], [ -78.226039956389627, 58.45419058236827 ], [ -78.254813936187844, 58.456600999315185 ], [ -78.205308929717035, 58.402149888047589 ], [ -77.650330117719719, 58.263942804165929 ], [ -77.526906772211774, 58.160344389739173 ], [ -77.220430598716064, 58.025494877297582 ], [ -77.241740141733032, 58.014902809467088 ], [ -77.089977369611034, 57.929838625005999 ], [ -76.922562516511661, 57.75562790209856 ], [ -76.760522721257757, 57.460580476563379 ], [ -76.675322680729323, 57.399127310306042 ], [ -76.586707536401491, 57.188637611600676 ], [ -76.534927315269897, 56.8171426883385 ], [ -76.559969077393404, 56.556625494426385 ], [ -76.552868530599468, 56.477319279487944 ], [ -76.520704976190885, 56.458523321453768 ], [ -76.529605515598675, 56.379326210174547 ], [ -76.559469906641453, 56.349582877915729 ], [ -76.549152308194039, 56.301263219610483 ], [ -76.626593378701585, 56.238378117936826 ], [ -76.61758780248276, 56.20029158986258 ], [ -76.670697877846507, 56.193808098944771 ], [ -76.64424361943918, 56.146793291134955 ], [ -76.709082235364008, 56.034155593246581 ], [ -76.800330391394908, 56.000877979572508 ], [ -76.830186884701405, 55.929854509328734 ], [ -76.875799338456432, 55.923453813622928 ], [ -77.192991895599675, 55.680408898291951 ], [ -77.176433726851556, 55.644160777560728 ], [ -77.2045456970217, 55.616714003742388 ], [ -77.556753209842299, 55.398558285160036 ], [ -77.726855513406534, 55.333094791767486 ], [ -77.790782581487434, 55.268722085919002 ], [ -78.197962699302352, 55.120791815886598 ], [ -78.316586611119646, 55.042729108572253 ], [ -78.891965111672363, 54.89745851972895 ], [ -78.981927185654072, 54.833569078321595 ], [ -79.448031800710396, 54.760493315963473 ], [ -79.763296395135583, 54.632494418388582 ], [ -79.68561072610575, 54.618414017328881 ], [ -79.612705424105258, 54.557433803690309 ], [ -79.637236919276631, 54.551067217385196 ], [ -79.632354232647671, 54.547485394469703 ], [ -79.533880967438989, 54.519072114025661 ], [ -79.55774842402792, 54.47431288313058 ], [ -79.53631467427482, 54.467017008697603 ], [ -79.596457510175, 54.465919379999569 ], [ -79.535327926233307, 54.463218807472003 ], [ -79.506233072739732, 54.41354922274445 ], [ -79.512155435468586, 54.363658196078447 ], [ -79.471334186192962, 54.326685011741574 ], [ -79.49609868337653, 54.264852718242444 ], [ -79.478184836678309, 54.221592814133537 ], [ -79.394869313406488, 54.176372513766822 ], [ -79.110506689830416, 53.907108507166654 ], [ -79.142779832380342, 53.901030516338551 ], [ -79.080914427599069, 53.870975803273254 ], [ -79.1160808956501, 53.831810129761749 ], [ -79.089579787970081, 53.828643321293583 ], [ -79.083506974292291, 53.757595881219295 ], [ -79.155601270380245, 53.70486711477119 ], [ -79.055370500641459, 53.606187093736715 ], [ -79.143137708999248, 53.491082082172724 ], [ -79.065417130161478, 53.407765704726032 ], [ -78.949907514090384, 53.379146296465329 ], [ -79.010309281833884, 53.29629239656385 ], [ -79.054549440032332, 53.292343815119473 ], [ -79.013128298252738, 53.288953389625256 ], [ -78.93896422639709, 53.211375644052069 ], [ -78.979218179279457, 53.161381796897047 ], [ -78.947402362432555, 53.13798108712129 ], [ -78.999561622203046, 53.092359579887237 ], [ -78.956758581637359, 52.94518121101801 ], [ -79.012827281412953, 52.904833398430164 ], [ -78.953670773980264, 52.940135720750952 ], [ -78.899876504295023, 52.899250784614551 ], [ -78.872870825264513, 52.740263184916806 ], [ -78.827573969033978, 52.699525698745923 ], [ -78.822142121935755, 52.640779613173159 ], [ -78.778857178099457, 52.622168917761975 ], [ -78.768673711549383, 52.541715895689315 ], [ -78.56913850050023, 52.5015776983333 ], [ -78.584076775585416, 52.395957208211975 ], [ -78.516929700701652, 52.332109722244681 ], [ -78.58882760567846, 52.242918378544893 ], [ -78.550510797117539, 52.188642478265699 ], [ -78.596202565395529, 52.108563698260262 ], [ -78.666323029897086, 52.054001196090347 ], [ -78.721840732648332, 52.052876902314772 ], [ -78.702602371333413, 52.02889582268859 ], [ -78.801051189405527, 51.958551621926944 ], [ -78.887553983680419, 51.957225010894533 ], [ -78.969653194174299, 51.799719699377114 ], [ -79.051193070631442, 51.764191097388149 ], [ -78.941093409038231, 51.692968793204123 ], [ -78.927974432528302, 51.64534710686879 ], [ -78.850046611566242, 51.616373796656532 ], [ -78.812368144932364, 51.454852903504523 ], [ -78.979848482477195, 51.461547796214361 ], [ -79.097906609372131, 51.537938634292303 ], [ -79.2063572036474, 51.53846648402692 ], [ -79.242888002267151, 51.575883558383204 ], [ -79.217217921872802, 51.639273482800732 ], [ -79.305518522320384, 51.681105006461848 ], [ -79.517895318258724, 51.585133393521204 ], [ -79.517798777935582, 49.009119745036998 ], [ -74.67627615106376, 49.000100609213888 ], [ -74.607944695645159, 48.96221775026919 ], [ -74.562290088615711, 49.001562629175723 ], [ -74.504776202898682, 48.92603866901505 ], [ -74.442601733617124, 48.975099662126183 ], [ -74.441019494041527, 48.932035376292021 ], [ -74.332862394189476, 48.92114002557669 ], [ -74.334945817545673, 48.966531324839508 ], [ -74.285232517440377, 48.990586158938406 ], [ -74.30685341191429, 49.041119023626543 ], [ -74.24421363086033, 49.136229628936952 ], [ -74.229446549667273, 49.281585062155926 ], [ -74.294565335895101, 49.289091523941501 ], [ -74.310702377359092, 49.258078990545066 ], [ -74.374842676398231, 49.316433385651678 ], [ -74.432853518167391, 49.323846801967193 ], [ -74.393395345015279, 49.369991140041499 ], [ -74.415701619343935, 49.382013009379605 ], [ -74.374935469729834, 49.442799013060011 ], [ -74.393706640652994, 49.474782579700467 ], [ -74.254526963323201, 49.511968346041272 ], [ -74.252650533795304, 49.567102528044757 ], [ -74.198506248540255, 49.610178317685119 ], [ -74.181497511497156, 49.679182617449257 ], [ -74.141625457503565, 49.679537938847204 ], [ -74.09841815282735, 49.735391377151736 ], [ -74.016619361218318, 49.735327510013313 ], [ -74.016589415838837, 49.801872518293457 ], [ -73.787170982234855, 50.056495629589449 ], [ -73.770337074998793, 50.2153264599404 ], [ -73.684497464680973, 50.21168373727172 ], [ -73.594997076396297, 50.349585265215225 ], [ -73.593429194707852, 50.403465048617775 ], [ -73.57361514593191, 50.359636563220278 ], [ -73.486034764580779, 50.400580425687487 ], [ -73.459726961931906, 50.368210812327739 ], [ -73.250414842093278, 50.692424398094076 ], [ -73.183699247213667, 50.701483754462252 ], [ -73.14707242557698, 50.750542239321447 ], [ -73.023141474548538, 50.697911788954464 ], [ -73.0481791849638, 50.667054114777876 ], [ -73.044625933807069, 50.654459722823731 ], [ -72.94096405364148, 50.713409452546145 ], [ -72.934312324222645, 50.762943434103612 ], [ -72.857900681641425, 50.812444509634709 ], [ -72.752005133560417, 50.820910537914862 ], [ -72.722162721569404, 50.798718593103452 ], [ -72.731350348729706, 50.761784250765459 ], [ -72.703726555891663, 50.781506467216389 ], [ -72.648286737839953, 50.747648244154433 ], [ -72.567190839113778, 50.885429230729166 ], [ -72.573375832499508, 50.750117395743501 ], [ -72.480711567244668, 50.750879134025659 ], [ -72.470280017219338, 50.784273812598528 ], [ -72.397509811883793, 50.813325279625538 ], [ -72.378226629827353, 50.888110075453852 ], [ -72.339739183559004, 50.885669226339608 ], [ -72.269595334337367, 50.951076254882999 ], [ -72.261556930901662, 51.00359071900008 ], [ -72.191667192447809, 51.029393868435434 ], [ -72.197975992391449, 50.99041784347564 ], [ -72.156027500507335, 50.978592714760488 ], [ -72.144714027195946, 51.069418869200177 ], [ -72.074140566569952, 51.150687671662489 ], [ -72.018682532877833, 51.138722835656104 ], [ -72.012993551621364, 51.220198781014162 ], [ -71.977866179604916, 51.211628790892647 ], [ -71.991243461538986, 51.230642722087758 ], [ -71.931866547968809, 51.275410434941186 ], [ -71.870120311300226, 51.277878975881833 ], [ -71.868619034664235, 51.343983305762556 ], [ -71.821280149824545, 51.373384685080055 ], [ -71.756300369151049, 51.339186720477755 ], [ -71.687931897139904, 51.401459025141072 ], [ -71.706026800931696, 51.439817932755702 ], [ -71.580680088210414, 51.508748116118902 ], [ -71.610667446443827, 51.692744197442295 ], [ -71.55163721173831, 51.789169887684984 ], [ -71.574183594959507, 51.814196502991223 ], [ -71.377474447139619, 51.84846821327119 ], [ -71.306620968385985, 51.971610647225681 ], [ -71.206599369705856, 52.039486030044223 ], [ -71.265350359872059, 52.037768716693598 ], [ -71.250263046222429, 52.066007520793178 ], [ -71.140346826311244, 52.166697601679616 ], [ -71.077021649367026, 52.211352460209312 ], [ -70.929017767518275, 52.226899078509028 ], [ -70.881018687776219, 52.278978001270332 ], [ -70.806851262208056, 52.2735753367147 ], [ -70.825558391191791, 52.247701291228815 ], [ -70.792275564174631, 52.233678726260095 ], [ -70.559851046036215, 52.317121283427525 ], [ -70.482013678038712, 52.303945326491949 ], [ -70.313693117509217, 52.343362118561231 ], [ -70.231573154288583, 52.408578628989872 ], [ -70.289822243987615, 52.41845804706292 ], [ -70.172981365334763, 52.542780077009425 ], [ -70.242708985221583, 52.559855241160008 ], [ -70.169669053109814, 52.603941585881003 ], [ -70.228318243810222, 52.634436892167656 ], [ -70.180804913611325, 52.662876248978954 ], [ -70.224548937741815, 52.70330789480667 ], [ -70.123012782802505, 52.751791827643387 ], [ -70.158019118544559, 52.75487579108664 ], [ -70.127352769308857, 52.810498021600203 ], [ -70.029889608212372, 52.83208781270055 ], [ -70.029882586478294, 55.000135978323222 ], [ -67.417243852681537, 55.000135728440334 ], [ -67.440272751904942, 55.012746995281304 ], [ -67.420854740528569, 55.033524444300454 ], [ -67.464635027392902, 55.047469173748901 ], [ -67.41285355767846, 55.093691345717339 ], [ -67.273235599970562, 55.073468889226511 ], [ -67.251817830963574, 55.056079687568889 ], [ -67.286931441176932, 55.028107498995723 ], [ -67.218817469616297, 55.000106934580153 ], [ -66.643240784157186, 55.000102216056803 ], [ -66.750076742861012, 55.086272470981662 ], [ -66.689271963669356, 55.061881664436967 ], [ -66.773105572231074, 55.139439736626557 ], [ -66.739545814303895, 55.144939147131595 ], [ -66.751830523350776, 55.210830020342961 ], [ -66.667964918639413, 55.176744746540741 ], [ -66.82119107852165, 55.305748846416328 ], [ -66.828298882283448, 55.326693591752523 ], [ -66.792881363536225, 55.315637458036576 ], [ -66.806663214340887, 55.356943090125782 ], [ -66.704374763740972, 55.312914164294725 ], [ -66.656928068208131, 55.25246802928212 ], [ -66.615621958468296, 55.276830005610172 ], [ -66.514121441536574, 55.189354698036013 ], [ -66.493675837027496, 55.202688603263738 ], [ -66.259613081023375, 55.00010058936472 ], [ -63.554296985495142, 55.000113215179731 ], [ -63.59840952860857, 55.013558030503731 ], [ -63.566685478580027, 55.03416915260977 ], [ -63.621909206049658, 55.093698324463126 ], [ -63.525773876961445, 55.168032228300902 ], [ -63.553993046792662, 55.188949471262823 ], [ -63.453270847658963, 55.202449198519346 ], [ -63.397259389104192, 55.250089171796354 ], [ -63.614574376674327, 55.228783858616936 ], [ -63.596768105689769, 55.253256341728388 ], [ -63.683691444403401, 55.266868310696459 ], [ -63.519884382559951, 55.310062216313142 ], [ -63.588997192607671, 55.320284977444359 ], [ -63.581608568437048, 55.339396381657274 ], [ -63.322425606003982, 55.364812502352272 ], [ -63.319401137324363, 55.409229383775084 ], [ -63.347265089979821, 55.417007240520327 ], [ -63.673325234264588, 55.423565845885186 ], [ -63.797809807443507, 55.473872170832507 ], [ -63.691270865160199, 55.510844994494754 ], [ -63.653378295907579, 55.555873353732665 ], [ -63.682628872406184, 55.636152733949189 ], [ -63.64209926215495, 55.643347227191519 ], [ -63.766743066922963, 55.654236656160663 ], [ -63.663736976876386, 55.79162723757684 ], [ -63.831993674210068, 55.80448915184104 ], [ -63.822298392263647, 55.838323018596299 ], [ -63.764240724165134, 55.846183920979634 ], [ -63.844606950629071, 55.888213039457092 ], [ -63.796910585609382, 55.929963320587376 ], [ -63.725378372902, 55.930795791061591 ], [ -63.56119718215735, 56.00398949569955 ], [ -63.473331689551664, 55.989710271478287 ], [ -63.491888374361061, 56.006627502784589 ], [ -63.445497188449281, 56.032544150610931 ], [ -63.688456266901539, 56.0255469652041 ], [ -63.669593836321532, 56.039574680114598 ], [ -63.69156974235932, 56.060964740417873 ], [ -63.839482569265193, 56.048687474721589 ], [ -63.826260014907639, 56.082604710844016 ], [ -63.90715091624525, 56.093633094961064 ], [ -63.946880274277852, 56.055827240355669 ], [ -64.035418927676574, 56.061689150327105 ], [ -64.06019701021421, 56.083550819175706 ], [ -64.01777794990555, 56.092939726523831 ], [ -64.002498948165254, 56.131884739148134 ], [ -64.042605616775276, 56.149246669354547 ], [ -63.877616946075051, 56.198412250775839 ], [ -63.860837833441948, 56.227773929749198 ], [ -63.915090314524562, 56.221718587914502 ], [ -63.94156561712569, 56.267275593352075 ], [ -64.122626449867525, 56.261166415185372 ], [ -64.075012214676178, 56.290166673517781 ], [ -64.14996634243748, 56.300833632199662 ], [ -64.090623837048142, 56.332111899171352 ], [ -64.148276388501245, 56.378668053052024 ], [ -64.097188580694407, 56.39372361781092 ], [ -64.18335868304645, 56.429696629350296 ], [ -64.10418315878637, 56.444696385553321 ], [ -63.988990216935598, 56.41083417785255 ], [ -63.869714971256961, 56.44683252722217 ], [ -63.932245814496632, 56.475416904141056 ], [ -63.943547475355146, 56.53983494724671 ], [ -64.058084533775414, 56.614254422885331 ], [ -64.012938702235459, 56.660782503559076 ], [ -64.101739646117139, 56.655700298200088 ], [ -64.143159765566807, 56.703479448982861 ], [ -64.076237476596219, 56.735868341513189 ], [ -64.087572511759319, 56.767480302752077 ], [ -64.008045628267169, 56.778228862733847 ], [ -64.008266448755251, 56.859314134176586 ], [ -63.870565520346005, 56.898453058465336 ], [ -63.910507720875181, 56.926120226043459 ], [ -63.912062268041147, 56.984565818969173 ], [ -63.861643082156192, 56.99748249524923 ], [ -63.902254268935167, 57.077373368847219 ], [ -63.774740588356224, 57.102040033897616 ], [ -63.79607894596495, 57.194292215628472 ], [ -63.764271383368524, 57.221125848471566 ], [ -63.828329125427452, 57.237487505581996 ], [ -63.885360371942696, 57.196681604871976 ], [ -63.842823859636958, 57.346934173079944 ], [ -63.807437590916464, 57.325433600570975 ], [ -63.69803934165715, 57.366989172594316 ], [ -63.728943321342001, 57.409629402064397 ], [ -63.710832561558696, 57.432768632673145 ], [ -63.74143864026221, 57.41915782475774 ], [ -63.768268269964842, 57.526937799009538 ], [ -63.834743115711802, 57.56588332225288 ], [ -63.849059889943753, 57.61796747516307 ], [ -63.737015269042566, 57.662246399014862 ], [ -63.751486895749032, 57.727831054513992 ], [ -63.81064518317514, 57.688913341006717 ], [ -63.883181583929257, 57.715580597572846 ], [ -63.899744779617201, 57.91286304780445 ], [ -63.939465573557854, 57.892029714580467 ], [ -64.049371431308188, 57.931169725504745 ], [ -64.184937407493976, 57.924753620763717 ], [ -64.242145301059637, 58.019116951801969 ], [ -64.219977328486323, 58.034895001050863 ], [ -64.261397556309873, 58.065645811692818 ], [ -64.283764479978174, 58.045506618805447 ], [ -64.431307235972241, 58.068785868673793 ], [ -64.443111607895275, 58.131898359659914 ], [ -64.434443099855898, 58.198816095476978 ], [ -64.219155312990452, 58.246760295880712 ], [ -64.166412858321365, 58.366151353647005 ], [ -64.029727751874418, 58.364566947368616 ], [ -63.985302778649277, 58.443235271586971 ], [ -63.88688463334946, 58.433623005051174 ], [ -63.810239411388096, 58.484206934253031 ], [ -63.853296711346935, 58.50256885909576 ], [ -63.866210039889197, 58.567903825647925 ], [ -64.033658428386659, 58.522848584022015 ], [ -64.102106474900054, 58.562599655405485 ], [ -64.090628232549307, 58.589322513285595 ], [ -64.145834109758297, 58.606239686904843 ], [ -64.043177793558186, 58.701741136327271 ], [ -63.715178318285304, 58.707044399776997 ], [ -63.466472263732648, 58.763655276776134 ], [ -63.580778045899265, 58.852466677485701 ], [ -63.712395811163709, 58.887492810461488 ], [ -64.040879684767049, 58.810854386252309 ], [ -64.077540721903603, 58.76274252393771 ], [ -64.146497567936876, 58.748409198929856 ], [ -64.175612252089394, 58.788049144110431 ], [ -64.23158676953858, 58.786604992611608 ], [ -64.239724168234972, 58.861051120644127 ], [ -64.305253540566994, 58.902024754240017 ], [ -64.46037113205584, 58.908359215777452 ], [ -64.517512941391871, 58.882497727255974 ], [ -64.650978117759024, 58.940638073850735 ], [ -64.732784184781039, 58.950194392472532 ], [ -64.817399543145754, 58.915055239065815 ], [ -64.88945802726937, 58.941639679447896 ], [ -64.834455046074225, 58.958056393541682 ], [ -64.870205808762094, 59.003779707058072 ], [ -64.830953994189002, 59.019557737033075 ], [ -64.82814754448421, 59.072031343256697 ], [ -64.684278514007531, 59.074141399889029 ], [ -64.670668013350095, 59.038807260230683 ], [ -64.492935281029617, 59.01655559450414 ], [ -64.472244242383596, 58.984221695559043 ], [ -64.265387569066476, 59.013998896308593 ], [ -64.368736684956744, 59.098556650891325 ], [ -64.419791609881585, 59.078834442096209 ], [ -64.435380294500561, 59.107140551231737 ], [ -64.489492697776527, 59.103251918061723 ], [ -64.471140862278247, 59.17950384854187 ], [ -64.534940831318977, 59.220533917522815 ], [ -64.489044802105965, 59.270896068170323 ], [ -64.557684276355715, 59.316647769815361 ], [ -64.491655189468574, 59.349647769656762 ], [ -64.534187504732955, 59.366036960219454 ], [ -64.525080543193951, 59.399842508364308 ], [ -64.462468438220327, 59.411369962568862 ], [ -64.514303586267715, 59.422926396321657 ], [ -64.324060108677131, 59.511345555918048 ], [ -64.442017224428596, 59.534984165677159 ], [ -64.675413665505786, 59.445845316441179 ], [ -64.810778326506252, 59.497986915941972 ], [ -64.839889198119835, 59.556655029236452 ], [ -64.804136705416056, 59.590072484865061 ], [ -64.809857798645552, 59.654462788362331 ], [ -64.748635915379936, 59.713296124760937 ], [ -64.793806310315958, 59.736157495514369 ], [ -64.812418707307543, 59.78243607543709 ], [ -64.789833617478749, 59.807436403841123 ], [ -64.819612061428103, 59.833409463582328 ], [ -64.725190609432673, 59.861298279472784 ], [ -64.762496581427399, 59.890299121312871 ], [ -64.68013247996852, 59.880215253281143 ], [ -64.620901689856012, 59.929379728072135 ], [ -64.775128616622638, 59.982219442411072 ], [ -64.837970015365059, 59.964829171482194 ], [ -64.920622757753549, 60.065501904889423 ], [ -64.722064515680614, 60.057361100233841 ], [ -64.709313174083846, 60.083500523523092 ], [ -64.759339141266864, 60.101168330167596 ], [ -64.768448713579389, 60.109891093670299 ], [ -64.634726304976297, 60.104999438288552 ], [ -64.58041465243565, 60.131499189515807 ], [ -64.610696668225756, 60.170916392645559 ], [ -64.755440542429682, 60.171420849531842 ], [ -64.852303262299856, 60.226423576711824 ], [ -64.859049784024378, 60.264173020547773 ], [ -64.531254834903237, 60.308951593524917 ], [ -64.852371304156051, 60.369654778634633 ], [ -64.828987934823886, 60.340254211239731 ], [ -64.862400218533622, 60.293629113820984 ], [ -64.947822628004332, 60.269229385967662 ], [ -64.965342325312818, 60.17851991262215 ], [ -65.141354097197407, 60.063803601147185 ], [ -65.138679800218867, 60.028840380571012 ], [ -65.235615201916389, 59.925939317941911 ], [ -65.557808406295948, 59.741559378718001 ], [ -65.565521520588817, 59.690496898999086 ], [ -65.43735869120664, 59.572124357023078 ], [ -65.529927773860578, 59.467525528874887 ], [ -65.85291533044979, 59.282466019684662 ], [ -65.865784690189798, 59.242375904942506 ], [ -65.799850186058947, 59.207127083766991 ], [ -65.924997871298558, 59.057375530519195 ], [ -65.923178237483569, 58.993051702866317 ], [ -65.942916691650936, 58.968431435167872 ], [ -66.056638157598513, 58.967423817824859 ], [ -65.977610838143107, 58.935499809356166 ], [ -65.952531000164328, 58.958015018411253 ], [ -65.990105125346545, 58.916367879143507 ], [ -66.09508780572115, 58.861314154746523 ], [ -66.473127013856754, 58.866283009573401 ], [ -66.520814767002989, 58.803234398619168 ], [ -66.496344789436691, 58.765297981747572 ], [ -66.579664629059778, 58.664475589764535 ], [ -66.686329112760589, 58.641965014653266 ], [ -66.665889736307122, 58.601789709939048 ], [ -66.73685202266779, 58.515918840202602 ], [ -67.014603775997955, 58.506161670343296 ], [ -67.051034211649821, 58.452692189047411 ], [ -67.38819111003059, 58.312454617894367 ], [ -67.440234850930779, 58.33249365122203 ], [ -67.397596558591843, 58.362105921529952 ], [ -67.449826040415374, 58.355672279895018 ], [ -67.430340285892598, 58.389762780797234 ], [ -67.503271117089284, 58.462760300757424 ], [ -67.551033404579712, 58.388984852048608 ], [ -67.552010602326433, 58.437030107730919 ], [ -67.620322950229706, 58.424736964052691 ], [ -67.728640421838648, 58.489613617131091 ], [ -67.918744097282371, 58.531358437346242 ], [ -68.000000005058979, 58.590450016522681 ], [ -68.243232477643474, 58.583222309198597 ], [ -68.278082869166099, 58.678207412656178 ], [ -68.259372763895826, 58.818991303668689 ], [ -68.313879901952788, 58.857754599934445 ], [ -68.548069122243547, 58.940790202599267 ], [ -68.892014787617271, 58.923914440358082 ], [ -69.210208529302449, 59.228672979592432 ], [ -69.232419134375419, 59.346266120974825 ], [ -69.390653927124191, 59.363435400776957 ], [ -69.62880731811677, 59.473388884301229 ], [ -69.637416462698909, 59.528762794626488 ], [ -69.483384000056162, 59.631154492438853 ], [ -69.540086827672184, 59.744042108284788 ], [ -69.47005150002532, 59.799752082919539 ], [ -69.528705966381054, 59.816768698941033 ], [ -69.489970798122783, 59.872452879001756 ], [ -69.625887531668226, 59.918516196003928 ], [ -69.542683802692963, 60.093940018045707 ], [ -69.590116412069875, 60.117904097922647 ], [ -69.559804941302033, 60.154046301951745 ], [ -69.591873512165208, 60.169280202620541 ], [ -69.58627253826063, 60.228005213054836 ], [ -69.638846946469386, 60.243121620090811 ], [ -69.661743572973194, 60.356328912130152 ], [ -69.73223130950079, 60.436647810924519 ], [ -69.620908094455643, 60.583004695224659 ], [ -69.674667491617953, 60.673179100756563 ], [ -69.356654001270172, 60.814335402295328 ], [ -69.389191424507658, 60.868178217482424 ], [ -69.37006683624341, 60.915933891448979 ], [ -69.446021626305281, 60.957916690271595 ], [ -69.50345003665538, 61.068010610487775 ], [ -69.613299979769451, 61.078597721555454 ], [ -69.697200771057013, 60.995562897723353 ], [ -69.68217658383, 60.942955894523394 ], [ -69.762177440952939, 60.918261919909078 ], [ -69.964242468699055, 60.945330339891662 ], [ -69.911172570932166, 61.001640012657347 ], [ -69.949291005319694, 61.040925718230483 ], [ -70.023663798469968, 60.997780206244897 ], [ -69.991527024973323, 60.948960765167385 ], [ -70.086251473375086, 60.961518993127058 ], [ -70.154053506663089, 61.011204306925229 ], [ -70.105804735836287, 61.05362301981112 ], [ -70.130048745190706, 61.090792586972107 ], [ -70.30275128010615, 61.101182621806522 ], [ -70.668349163295801, 61.055620916186058 ], [ -70.738017798011555, 61.085088719670274 ], [ -70.880870963357651, 61.07898227926858 ], [ -70.989461358313491, 61.128161883188824 ], [ -71.444756322748191, 61.181197918652693 ], [ -71.432382077588997, 61.223127778638407 ], [ -71.607041927660049, 61.263210976999289 ], [ -71.632184431028691, 61.301837070859236 ], [ -71.59522633913069, 61.313024585562694 ], [ -71.636613898742638, 61.325471721075189 ], [ -71.563582639914102, 61.4076447976704 ], [ -71.565243707350405, 61.545288584725526 ], [ -71.514197767570664, 61.610678120965609 ], [ -72.029212923287147, 61.729983219115709 ], [ -72.209748657898245, 61.874707511113478 ], [ -72.438817879806493, 61.906168593583608 ], [ -72.577495345075249, 61.976981899443473 ], [ -72.613662270452963, 62.112201092358085 ], [ -73.045625732543698, 62.20141910600281 ], [ -73.162197821463437, 62.262700184542076 ], [ -73.191752877198581, 62.315020087978283 ], [ -73.586656081650702, 62.413973515269547 ], [ -73.671813100575079, 62.480220521712042 ], [ -73.845546819457894, 62.464947289054166 ], [ -74.019374495141733, 62.367630810939609 ], [ -74.257418093707741, 62.325204985833508 ], [ -74.404055260828059, 62.250242484086911 ], [ -74.865073387914933, 62.240612000801029 ], [ -75.342002831724272, 62.316912909690011 ], [ -75.692519972192272, 62.294987805785851 ], [ -76.433047579125514, 62.424650580841998 ], [ -76.79696092473813, 62.522412913834884 ], [ -77.499999959000888, 62.57944802246044 ] ] ] } }, | |
| 16 | +{ "type": "Feature", "properties": { "code": "11", "name": "Gaspésie–Îles-de-la-Madeleine", "geography_id": "region-11" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -63.526312062315604, 48.929185506696911 ], [ -64.197555637041319, 49.337163253077485 ], [ -64.850078044324462, 49.59350132429784 ], [ -65.531769891686594, 49.712144271340208 ], [ -65.880200096382239, 49.714062547769934 ], [ -66.157544782765555, 49.678646848022836 ], [ -66.534672089584603, 49.553453092337158 ], [ -67.153339142634252, 49.18906403276106 ], [ -66.832032993541887, 48.985077062530785 ], [ -66.732989366008425, 49.017530356093509 ], [ -66.699318005571314, 48.988611913225242 ], [ -66.70441521193213, 48.902873345905128 ], [ -66.537315142547982, 48.957826533637991 ], [ -65.948498198459305, 48.567089144875254 ], [ -66.97415999578601, 48.30315642898266 ], [ -66.836925357775854, 48.147741707786416 ], [ -66.900450576427687, 48.200447649982998 ], [ -66.934625790649989, 48.181190786992964 ], [ -66.879636407319737, 48.136954025567931 ], [ -67.098450340673082, 48.077948220056257 ], [ -67.277547792467402, 48.031538031877908 ], [ -67.374603194612703, 48.194892593960361 ], [ -67.666958996536451, 48.116718144968559 ], [ -67.58487404702953, 48.045218176325555 ], [ -67.607536966097143, 47.936339627345298 ], [ -67.374415565792887, 47.84538949763504 ], [ -67.336769418140818, 47.872253538527545 ], [ -67.347739616371072, 47.895957969872214 ], [ -67.208108663054404, 47.875968189503354 ], [ -67.059661152539903, 47.935390641293971 ], [ -66.955637499910324, 47.892402808159737 ], [ -66.964887067403254, 47.941317444431547 ], [ -66.920116708432843, 47.984560594233585 ], [ -66.698683529721094, 48.005365334914629 ], [ -66.401026111899441, 48.089610021269245 ], [ -66.305883564866477, 48.033137791016237 ], [ -65.858134126957879, 48.022245784620416 ], [ -65.606456951025365, 47.933633887353203 ], [ -65.53617443098625, 47.828632179494605 ], [ -65.111713642296422, 47.921131338005104 ], [ -64.421962203141419, 48.22057417553853 ], [ -63.791660832019666, 48.220571676596435 ], [ -62.986360783867354, 47.139731658661105 ], [ -62.299949504597343, 46.840006640487282 ], [ -61.399363599355738, 46.840003348958327 ], [ -61.350472231001646, 47.009725571627641 ], [ -60.991847767751537, 47.329446709088174 ], [ -60.762673116018689, 47.423334720829466 ], [ -60.403770302987489, 47.761111394002157 ], [ -60.468597199247426, 48.666667945248911 ], [ -62.999128701777302, 48.666683203520115 ], [ -62.999128389281516, 48.701792945343961 ], [ -63.526312062315604, 48.929185506696911 ] ] ] } }, | |
| 17 | +{ "type": "Feature", "properties": { "code": "12", "name": "Chaudière-Appalaches", "geography_id": "region-12" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -70.003259647392454, 47.278472754391821 ], [ -70.197392286185178, 47.4119438131845 ], [ -70.454942106735729, 47.253504708328975 ], [ -70.524363351699876, 47.164836879234322 ], [ -70.674537858140056, 47.065671184499088 ], [ -70.711234913115632, 46.997171079967316 ], [ -70.990663810047025, 46.850755526326687 ], [ -71.170227220482928, 46.844262785262707 ], [ -71.271981300560526, 46.750062305450129 ], [ -71.675526141722514, 46.652286340644793 ], [ -71.872033345652156, 46.677398213824553 ], [ -72.054319572163649, 46.570510300459453 ], [ -71.760157956952881, 46.363787375326432 ], [ -71.617549172098904, 46.451586857829597 ], [ -71.566224339937676, 46.413681577817322 ], [ -71.531491870016453, 46.434337802825333 ], [ -71.478669918390267, 46.395682097777247 ], [ -71.508472841928167, 46.338379081630066 ], [ -71.37178660417247, 46.268099264601602 ], [ -71.419148667684681, 46.22327321044817 ], [ -71.485503185343319, 46.213718443289757 ], [ -71.533648584242087, 46.149046630270561 ], [ -71.509753788725035, 46.137084866362166 ], [ -71.68592485973376, 45.967042773367879 ], [ -71.455612821759743, 45.872254721526737 ], [ -71.467201609349175, 45.822228836986341 ], [ -71.392621357926842, 45.768845798672857 ], [ -71.340525510401875, 45.809642148014156 ], [ -71.348405226966108, 45.872239132696443 ], [ -71.178095409762548, 45.815105797795113 ], [ -71.135149628077386, 45.860284381604686 ], [ -71.162434229597238, 45.92344135188695 ], [ -71.129581244174034, 45.95733048468945 ], [ -71.070682307764542, 45.928350036604272 ], [ -71.094050537632825, 45.907066168491745 ], [ -71.029533777173654, 45.859131114369049 ], [ -70.898330992020945, 45.841799941594758 ], [ -70.895570053244185, 45.780116844051172 ], [ -70.651847606508028, 45.802721941836523 ], [ -70.631861631617895, 45.778682976592556 ], [ -70.499781722932013, 45.82643031702824 ], [ -70.47131985523535, 45.788828916834198 ], [ -70.398910285783771, 45.796630843837043 ], [ -70.258821969175713, 45.891168888382751 ], [ -70.239141689209902, 45.943943592368328 ], [ -70.315858918346763, 45.962990534433899 ], [ -70.283941915516905, 45.995487485734856 ], [ -70.317995564887028, 46.019075001648375 ], [ -70.278321087426392, 46.056285837618034 ], [ -70.301909178577887, 46.082360012164209 ], [ -70.25311389392175, 46.100119451892809 ], [ -70.23628333641328, 46.145233328694623 ], [ -70.292691653018395, 46.191463893040343 ], [ -70.19118891123486, 46.350216688730534 ], [ -70.056500010527458, 46.415577781853081 ], [ -69.997111079176094, 46.695520000988481 ], [ -69.625290475616112, 47.06672887090172 ], [ -69.972934067653043, 47.291575628482612 ], [ -70.003259647392454, 47.278472754391821 ] ] ] } }, | |
| 18 | +{ "type": "Feature", "properties": { "code": "13", "name": "Laval", "geography_id": "region-13" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -73.523098406737788, 45.697883228829149 ], [ -73.727842978815744, 45.680403275159648 ], [ -73.896138936078145, 45.525546375861929 ], [ -73.766120060744754, 45.511434496036763 ], [ -73.682520913526744, 45.551478420269603 ], [ -73.630812002772728, 45.628529440991592 ], [ -73.523098406737788, 45.697883228829149 ] ] ] } }, | |
| 19 | +{ "type": "Feature", "properties": { "code": "14", "name": "Lanaudière", "geography_id": "region-14" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -73.957427314645344, 47.38817074166036 ], [ -74.511510586554749, 47.762913212232824 ], [ -74.889415295400852, 47.762621155404709 ], [ -74.539436575248047, 47.509856218381429 ], [ -74.426029283960986, 47.505367987142002 ], [ -74.513718413789135, 47.40627889002478 ], [ -74.542098954218844, 47.313405520703881 ], [ -74.586963018121963, 47.316497063069654 ], [ -74.618226382021305, 47.250896100351369 ], [ -74.656983658112495, 47.243953364959296 ], [ -74.489744837652438, 47.22455852689361 ], [ -74.434525905155127, 47.15752646333474 ], [ -74.466053164340607, 47.13569450095715 ], [ -74.415173309693486, 47.100718149717366 ], [ -74.574715715395186, 46.99750433532072 ], [ -74.427392436431504, 46.896586257755864 ], [ -74.484721321271749, 46.86699556985252 ], [ -74.500340794897255, 46.59477283508123 ], [ -74.444621339706259, 46.620774669277104 ], [ -74.343615378356162, 46.5493055440391 ], [ -74.477483679579805, 46.454093164338069 ], [ -74.334041294899052, 46.353923736550549 ], [ -74.413076932364106, 46.298894931744066 ], [ -74.26721623938586, 46.198125725858787 ], [ -74.182603892668169, 46.250056494338324 ], [ -74.032625580078559, 46.148316841104759 ], [ -74.077440800040819, 46.117145459953605 ], [ -73.960491739402528, 46.064641436779397 ], [ -74.005834560587061, 46.032789418472262 ], [ -73.97918840416655, 46.01460024695178 ], [ -74.00233392447052, 45.996972199850859 ], [ -73.730149196497351, 45.754492376787226 ], [ -73.76476682775801, 45.713194191018822 ], [ -73.84477176960246, 45.720967302088077 ], [ -73.783452191656394, 45.680274380125994 ], [ -73.460920689981492, 45.703787760813128 ], [ -73.385558307705367, 45.789242782996006 ], [ -73.279070026095695, 45.842176303919025 ], [ -73.156100007605318, 46.046304470734775 ], [ -72.96991409108567, 46.111707838988337 ], [ -72.936245111859108, 46.151235214525087 ], [ -73.036707876104856, 46.156588056056776 ], [ -73.216691142333531, 46.279005524282347 ], [ -73.19567873073143, 46.294176916279092 ], [ -73.228451217800369, 46.316676010740366 ], [ -73.176173393606106, 46.323285879783676 ], [ -73.203427767519813, 46.358386264381274 ], [ -73.167002977363722, 46.382299797543638 ], [ -73.332946385296282, 46.498873757718741 ], [ -73.283001031209309, 46.533309347233555 ], [ -73.33650850439642, 46.601265189479548 ], [ -73.446013253223484, 46.59626519332074 ], [ -73.466958351044283, 46.709488617030409 ], [ -73.421237662478291, 46.890298277735525 ], [ -73.495076997383123, 46.912357008464134 ], [ -73.525943266818246, 46.862496996865673 ], [ -73.563991198189242, 46.883434344803256 ], [ -73.665448405545305, 46.863900769558484 ], [ -73.645947301680309, 46.937462374988222 ], [ -73.754786410209519, 47.066608391718958 ], [ -73.853251821247341, 47.151078007734448 ], [ -73.910582251261673, 47.099082725786459 ], [ -74.028582863686779, 47.177838740316375 ], [ -73.99897777547919, 47.345654068294458 ], [ -73.957427314645344, 47.38817074166036 ] ] ] } }, | |
| 20 | +{ "type": "Feature", "properties": { "code": "15", "name": "Laurentides", "geography_id": "region-15" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -74.889415295400852, 47.762621155404709 ], [ -75.538612226716268, 47.763261811734921 ], [ -75.524277140342051, 47.710519458573089 ], [ -75.421754140900944, 47.634641067188355 ], [ -75.41419349614057, 47.588627982766532 ], [ -75.36959914065028, 47.627142395229775 ], [ -75.360287105443618, 47.570213293959497 ], [ -75.308026222508715, 47.547467837843364 ], [ -75.46043712927208, 47.367888553088342 ], [ -75.473452526701323, 47.282386261067401 ], [ -75.594826730255107, 47.185435739165811 ], [ -75.625835534409759, 47.180936354364015 ], [ -75.647836725372869, 47.281748369106872 ], [ -75.747833523399976, 47.272918806008541 ], [ -75.798865316091579, 47.318877351697282 ], [ -75.976184969153806, 47.203159978619652 ], [ -76.10059718994944, 47.183105858919767 ], [ -76.102355610337128, 47.03047835950585 ], [ -76.148472638218152, 47.006168967013544 ], [ -76.128166037249656, 46.921028389246096 ], [ -75.971081490477729, 46.87839128836498 ], [ -75.890005118585236, 46.815501543624606 ], [ -75.746957404129944, 46.844824405657036 ], [ -75.745213974613961, 46.438256989474063 ], [ -75.706004087524875, 46.438816366965149 ], [ -75.707042202236863, 46.309165822329106 ], [ -75.814570296980875, 46.306351082622271 ], [ -75.783033258740502, 46.282010335183763 ], [ -75.813144849910231, 46.224916684772147 ], [ -75.79901348555326, 46.174121622782152 ], [ -75.768311307229538, 46.17417628912839 ], [ -75.764419246762529, 46.022219814433356 ], [ -75.697274975935656, 46.023889317706846 ], [ -75.784749726565195, 45.959745395087303 ], [ -75.620766718698903, 45.99471255220395 ], [ -75.599159410171552, 45.971449813692011 ], [ -75.40720978574069, 45.972950674008828 ], [ -75.407157497500535, 46.097862133633278 ], [ -75.176360583907524, 46.103409070907723 ], [ -75.117388337025787, 46.215204782338553 ], [ -75.074034858220458, 46.215292787572253 ], [ -75.089790696686322, 46.103131465914828 ], [ -74.939544277084366, 46.105533128782604 ], [ -74.939450909792754, 46.061728800812439 ], [ -74.858617777396333, 46.060743378305773 ], [ -74.801302826693032, 45.94509613192642 ], [ -74.698759978142306, 45.971776245459942 ], [ -74.807766485218835, 45.638317997372383 ], [ -74.643899374578083, 45.637529184563121 ], [ -74.476482999001448, 45.595648313571793 ], [ -74.37291579469715, 45.561252200253875 ], [ -74.329111097896003, 45.507640094275573 ], [ -74.09446811367421, 45.45304376567298 ], [ -73.977906935939032, 45.464431597479667 ], [ -73.745397510363631, 45.668696367568742 ], [ -73.84477176960246, 45.720967302088077 ], [ -73.76476682775801, 45.713194191018822 ], [ -73.730149196497351, 45.754492376787226 ], [ -74.00233392447052, 45.996972199850859 ], [ -73.97918840416655, 46.01460024695178 ], [ -74.005834560587061, 46.032789418472262 ], [ -73.960491739402528, 46.064641436779397 ], [ -74.077440800040819, 46.117145459953605 ], [ -74.032625580078559, 46.148316841104759 ], [ -74.182603892668169, 46.250056494338324 ], [ -74.26721623938586, 46.198125725858787 ], [ -74.413076932364106, 46.298894931744066 ], [ -74.334041294899052, 46.353923736550549 ], [ -74.477483679579805, 46.454093164338069 ], [ -74.343615378356162, 46.5493055440391 ], [ -74.444621339706259, 46.620774669277104 ], [ -74.500340794897255, 46.59477283508123 ], [ -74.484721321271749, 46.86699556985252 ], [ -74.427392436431504, 46.896586257755864 ], [ -74.574715715395186, 46.99750433532072 ], [ -74.415173309693486, 47.100718149717366 ], [ -74.466053164340607, 47.13569450095715 ], [ -74.434525905155127, 47.15752646333474 ], [ -74.489744837652438, 47.22455852689361 ], [ -74.656983658112495, 47.243953364959296 ], [ -74.618226382021305, 47.250896100351369 ], [ -74.586963018121963, 47.316497063069654 ], [ -74.542098954218844, 47.313405520703881 ], [ -74.513718413789135, 47.40627889002478 ], [ -74.426279942882019, 47.506031620532141 ], [ -74.539436575248047, 47.509856218381429 ], [ -74.889415295400852, 47.762621155404709 ] ] ] } }, | |
| 21 | +{ "type": "Feature", "properties": { "code": "16", "name": "Montérégie", "geography_id": "region-16" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -72.799346083858396, 46.014485940663505 ], [ -73.04069907112445, 46.107444908834275 ], [ -73.156100007605318, 46.046304470734775 ], [ -73.279070026095695, 45.842176303919025 ], [ -73.47744088680966, 45.698959603620651 ], [ -73.5314455159169, 45.536469174161319 ], [ -73.522251909506068, 45.450625746631282 ], [ -73.5513636189621, 45.421681201066626 ], [ -73.76795484614351, 45.418680681935363 ], [ -73.822179339343961, 45.385403014947222 ], [ -73.949035968336304, 45.401069410895943 ], [ -73.99660217387256, 45.457487266119614 ], [ -74.325109720389278, 45.506414695577277 ], [ -74.381036028608563, 45.565581947107226 ], [ -74.472032409136702, 45.302858428698052 ], [ -74.319521600300121, 45.18850557629213 ], [ -74.442324465755405, 45.131213207529605 ], [ -74.495215240429971, 45.064741938944756 ], [ -74.669796891160487, 45.005566449379174 ], [ -73.139875247712169, 45.014416334322121 ], [ -73.091315679638683, 45.196142179396773 ], [ -73.058243389196775, 45.214581402871616 ], [ -73.063446871099544, 45.267768927457155 ], [ -73.046169602522852, 45.293109989836111 ], [ -73.015781549062282, 45.272101883006535 ], [ -73.04885686885568, 45.325351957532646 ], [ -72.989998687698574, 45.341932185325028 ], [ -72.883053652889188, 45.265547434664327 ], [ -72.799820629858019, 45.537984283382279 ], [ -72.604923587793806, 45.53768564860286 ], [ -72.586004737387029, 45.498289520969578 ], [ -72.448891019697939, 45.493590727210915 ], [ -72.454395190654509, 45.451382224352827 ], [ -72.404264784013307, 45.449202259034848 ], [ -72.402882465436264, 45.528484335743826 ], [ -72.359675278394604, 45.589394511454259 ], [ -72.315550853686489, 45.599224255084614 ], [ -72.601867426215662, 45.792602698740659 ], [ -72.646156870849509, 45.755786919452696 ], [ -72.704155456974121, 45.758843585977012 ], [ -72.853036717613676, 45.864013639875502 ], [ -72.752553475784978, 45.943010762987754 ], [ -72.804673659567371, 45.980952815260665 ], [ -72.799346083858396, 46.014485940663505 ] ] ] } }, | |
| 22 | +{ "type": "Feature", "properties": { "code": "17", "name": "Centre-du-Québec", "geography_id": "region-17" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ -72.06684778784583, 46.569760279646282 ], [ -72.190325076722743, 46.538065806913323 ], [ -72.26394048060321, 46.435427113594983 ], [ -72.457086254740091, 46.38262139491821 ], [ -72.578425282272335, 46.29506686065406 ], [ -72.992587729660826, 46.1119021306462 ], [ -72.799346083858396, 46.014485940663505 ], [ -72.804673659567371, 45.980952815260665 ], [ -72.752553475784978, 45.943010762987754 ], [ -72.853036717613676, 45.864013639875502 ], [ -72.704155456974121, 45.758843585977012 ], [ -72.646156870849509, 45.755786919452696 ], [ -72.601867426215662, 45.792602698740659 ], [ -72.578024695303029, 45.752536001587849 ], [ -72.297674910233312, 45.589406559848264 ], [ -72.237239174723115, 45.646239550115112 ], [ -72.311259830468998, 45.688733854358091 ], [ -72.22710020920654, 45.756215283087457 ], [ -72.206338858940342, 45.696387398495901 ], [ -72.029723388488179, 45.853250091340122 ], [ -71.921234294343748, 45.796312679190102 ], [ -71.861549478822056, 45.798949709074833 ], [ -71.770166310577153, 45.885008003950425 ], [ -71.541554867594229, 45.787721896821793 ], [ -71.46079531428677, 45.819112838404514 ], [ -71.455612821759743, 45.872254721526737 ], [ -71.68592485973376, 45.967042773367879 ], [ -71.509753788725035, 46.137084866362166 ], [ -71.533648584242087, 46.149046630270561 ], [ -71.485503185343319, 46.213718443289757 ], [ -71.419148667684681, 46.22327321044817 ], [ -71.37178660417247, 46.268099264601602 ], [ -71.508472841928167, 46.338379081630066 ], [ -71.478669918390267, 46.395682097777247 ], [ -71.531491870016453, 46.434337802825333 ], [ -71.566224339937676, 46.413681577817322 ], [ -71.617549172098904, 46.451586857829597 ], [ -71.760157956952881, 46.363787375326432 ], [ -72.06684778784583, 46.569760279646282 ] ] ] } } | |
| 23 | +] | |
| 24 | +} | |
added
web/styles/globals.css
+591 −0
@@ -0,0 +1,591 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * QWHPI — Quebec Weekly Housing Price Index | |
| 4 | + * Author : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * File : web/styles/globals.css | |
| 7 | + * Purpose : Global styles — validated palette tokens (light + dark), | |
| 8 | + * editorial layout, refined cards, hero, chart chrome. | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | + | |
| 12 | +:root { | |
| 13 | + color-scheme: light; | |
| 14 | + --surface-0: #f6f5f1; | |
| 15 | + --surface-1: #fcfcfb; | |
| 16 | + --surface-2: #f0efec; | |
| 17 | + --border: #e3e2db; | |
| 18 | + --border-strong: #d2d1c8; | |
| 19 | + --text-primary: #0b0b0b; | |
| 20 | + --text-secondary: #52514e; | |
| 21 | + --text-muted: #716f66; | |
| 22 | + --series-1: #2a78d6; | |
| 23 | + --series-2: #eb6834; | |
| 24 | + --series-3: #1baf7a; | |
| 25 | + --series-4: #eda100; | |
| 26 | + --series-5: #e87ba4; | |
| 27 | + --band: rgba(42, 120, 214, 0.14); | |
| 28 | + --accent-soft: rgba(42, 120, 214, 0.08); | |
| 29 | + --good: #008300; | |
| 30 | + --serious: #e34948; | |
| 31 | + --shadow-card: 0 1px 2px rgba(20, 20, 15, 0.04), 0 4px 16px rgba(20, 20, 15, 0.05); | |
| 32 | + --shadow-card-hover: 0 2px 4px rgba(20, 20, 15, 0.06), 0 10px 28px rgba(20, 20, 15, 0.09); | |
| 33 | +} | |
| 34 | + | |
| 35 | +:root[data-theme="dark"] { | |
| 36 | + color-scheme: dark; | |
| 37 | + --surface-0: #131312; | |
| 38 | + --surface-1: #1a1a19; | |
| 39 | + --surface-2: #262624; | |
| 40 | + --border: #33332f; | |
| 41 | + --border-strong: #45453f; | |
| 42 | + --text-primary: #ffffff; | |
| 43 | + --text-secondary: #c3c2b7; | |
| 44 | + --text-muted: #98968d; | |
| 45 | + --series-1: #3987e5; | |
| 46 | + --series-2: #d95926; | |
| 47 | + --series-3: #199e70; | |
| 48 | + --series-4: #c98500; | |
| 49 | + --series-5: #d55181; | |
| 50 | + --band: rgba(57, 135, 229, 0.22); | |
| 51 | + --accent-soft: rgba(57, 135, 229, 0.12); | |
| 52 | + --good: #37a437; | |
| 53 | + --serious: #e66767; | |
| 54 | + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.25); | |
| 55 | + --shadow-card-hover: 0 2px 4px rgba(0, 0, 0, 0.35), 0 10px 28px rgba(0, 0, 0, 0.35); | |
| 56 | +} | |
| 57 | + | |
| 58 | +@media (prefers-color-scheme: dark) { | |
| 59 | + :root:not([data-theme="light"]) { | |
| 60 | + color-scheme: dark; | |
| 61 | + --surface-0: #131312; | |
| 62 | + --surface-1: #1a1a19; | |
| 63 | + --surface-2: #262624; | |
| 64 | + --border: #33332f; | |
| 65 | + --border-strong: #45453f; | |
| 66 | + --text-primary: #ffffff; | |
| 67 | + --text-secondary: #c3c2b7; | |
| 68 | + --text-muted: #98968d; | |
| 69 | + --series-1: #3987e5; | |
| 70 | + --series-2: #d95926; | |
| 71 | + --series-3: #199e70; | |
| 72 | + --series-4: #c98500; | |
| 73 | + --series-5: #d55181; | |
| 74 | + --band: rgba(57, 135, 229, 0.22); | |
| 75 | + --accent-soft: rgba(57, 135, 229, 0.12); | |
| 76 | + --good: #37a437; | |
| 77 | + --serious: #e66767; | |
| 78 | + --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3), 0 4px 16px rgba(0, 0, 0, 0.25); | |
| 79 | + --shadow-card-hover: 0 2px 4px rgba(0, 0, 0, 0.35), 0 10px 28px rgba(0, 0, 0, 0.35); | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +* { box-sizing: border-box; } | |
| 84 | + | |
| 85 | +html { scroll-behavior: smooth; } | |
| 86 | + | |
| 87 | +body { | |
| 88 | + margin: 0; | |
| 89 | + background: | |
| 90 | + radial-gradient(1200px 400px at 50% -200px, var(--accent-soft), transparent), | |
| 91 | + var(--surface-0); | |
| 92 | + color: var(--text-primary); | |
| 93 | + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", | |
| 94 | + Roboto, "Helvetica Neue", sans-serif); | |
| 95 | + line-height: 1.55; | |
| 96 | + font-feature-settings: "tnum"; | |
| 97 | +} | |
| 98 | + | |
| 99 | +a { color: var(--series-1); text-decoration: none; } | |
| 100 | +a:hover { text-decoration: underline; } | |
| 101 | + | |
| 102 | +.container { max-width: 1160px; margin: 0 auto; padding: 0 24px 24px; } | |
| 103 | + | |
| 104 | +/* ------------------------------------------------------------------ header */ | |
| 105 | +.site-header { | |
| 106 | + position: sticky; top: 0; z-index: 40; | |
| 107 | + display: flex; align-items: center; justify-content: space-between; | |
| 108 | + gap: 16px; flex-wrap: wrap; | |
| 109 | + padding: 12px 24px; | |
| 110 | + background: color-mix(in srgb, var(--surface-1) 82%, transparent); | |
| 111 | + backdrop-filter: blur(14px); | |
| 112 | + -webkit-backdrop-filter: blur(14px); | |
| 113 | + border-bottom: 1px solid var(--border); | |
| 114 | +} | |
| 115 | +.brand { | |
| 116 | + display: flex; align-items: baseline; gap: 10px; | |
| 117 | + font-family: var(--font-display, serif); | |
| 118 | + font-weight: 700; font-size: 20px; letter-spacing: -0.01em; | |
| 119 | + color: var(--text-primary); | |
| 120 | +} | |
| 121 | +.brand:hover { text-decoration: none; } | |
| 122 | +.brand small { | |
| 123 | + font-family: var(--font-sans); | |
| 124 | + color: var(--text-muted); font-weight: 400; font-size: 12.5px; | |
| 125 | +} | |
| 126 | +.site-header nav { display: flex; gap: 4px; flex-wrap: wrap; } | |
| 127 | +.site-header nav a { | |
| 128 | + color: var(--text-secondary); font-size: 14px; font-weight: 500; | |
| 129 | + padding: 6px 12px; border-radius: 999px; | |
| 130 | + transition: background 0.15s, color 0.15s; | |
| 131 | +} | |
| 132 | +.site-header nav a:hover { | |
| 133 | + color: var(--text-primary); background: var(--surface-2); | |
| 134 | + text-decoration: none; | |
| 135 | +} | |
| 136 | + | |
| 137 | +.site-footer { | |
| 138 | + border-top: 1px solid var(--border); | |
| 139 | + color: var(--text-muted); font-size: 13px; | |
| 140 | + padding: 28px 24px 36px; text-align: center; margin-top: 56px; | |
| 141 | +} | |
| 142 | + | |
| 143 | +/* -------------------------------------------------------------------- hero */ | |
| 144 | +.hero { | |
| 145 | + display: flex; align-items: flex-end; justify-content: space-between; | |
| 146 | + gap: 24px; flex-wrap: wrap; | |
| 147 | + padding: 40px 0 8px; | |
| 148 | +} | |
| 149 | +.hero h1 { | |
| 150 | + font-family: var(--font-display, serif); | |
| 151 | + font-size: clamp(28px, 4vw, 42px); | |
| 152 | + font-weight: 700; letter-spacing: -0.02em; | |
| 153 | + margin: 0 0 6px; line-height: 1.15; | |
| 154 | +} | |
| 155 | +.hero .lede { margin: 0; } | |
| 156 | +.hero-kicker { | |
| 157 | + display: inline-flex; align-items: center; gap: 8px; | |
| 158 | + font-size: 12px; font-weight: 600; letter-spacing: 0.08em; | |
| 159 | + text-transform: uppercase; color: var(--series-1); | |
| 160 | + margin-bottom: 12px; | |
| 161 | +} | |
| 162 | +.hero-kicker::before { | |
| 163 | + content: ""; width: 22px; height: 2px; background: var(--series-1); | |
| 164 | + border-radius: 2px; | |
| 165 | +} | |
| 166 | + | |
| 167 | +/* ------------------------------------------------------------------- cards */ | |
| 168 | +.card { | |
| 169 | + background: var(--surface-1); | |
| 170 | + border: 1px solid var(--border); | |
| 171 | + border-radius: 16px; | |
| 172 | + padding: 20px; | |
| 173 | + box-shadow: var(--shadow-card); | |
| 174 | + transition: box-shadow 0.2s, transform 0.2s; | |
| 175 | +} | |
| 176 | +.card.hoverable:hover { | |
| 177 | + box-shadow: var(--shadow-card-hover); | |
| 178 | + transform: translateY(-1px); | |
| 179 | +} | |
| 180 | +.card-title { | |
| 181 | + font-size: 13px; font-weight: 600; letter-spacing: 0.02em; | |
| 182 | + color: var(--text-secondary); margin: 0 0 10px; | |
| 183 | +} | |
| 184 | + | |
| 185 | +.stat-tile { display: flex; flex-direction: column; gap: 3px; } | |
| 186 | +.stat-tile .label { | |
| 187 | + font-size: 12px; font-weight: 600; letter-spacing: 0.05em; | |
| 188 | + text-transform: uppercase; color: var(--text-muted); | |
| 189 | +} | |
| 190 | +.stat-tile .value { | |
| 191 | + font-size: 32px; font-weight: 700; letter-spacing: -0.02em; | |
| 192 | + font-family: var(--font-display, serif); line-height: 1.15; | |
| 193 | +} | |
| 194 | +.stat-tile .value.money { font-variant-numeric: tabular-nums; } | |
| 195 | +.stat-tile .delta { font-size: 13px; color: var(--text-secondary); } | |
| 196 | +.delta.up { color: var(--good); font-weight: 600; } | |
| 197 | +.delta.down { color: var(--serious); font-weight: 600; } | |
| 198 | + | |
| 199 | +.grid { display: grid; gap: 16px; } | |
| 200 | +.grid.cols-4 { grid-template-columns: repeat(auto-fit, minmax(min(210px, 100%), 1fr)); } | |
| 201 | +.grid.cols-5 { grid-template-columns: repeat(auto-fit, minmax(min(160px, 100%), 1fr)); } | |
| 202 | +.grid.cols-2 { grid-template-columns: repeat(auto-fit, minmax(min(380px, 100%), 1fr)); } | |
| 203 | + | |
| 204 | +/* ---------------------------------------------------------------- controls */ | |
| 205 | +.controls { | |
| 206 | + display: flex; gap: 10px; flex-wrap: wrap; align-items: center; | |
| 207 | + margin: 18px 0; | |
| 208 | +} | |
| 209 | +.controls label { | |
| 210 | + font-size: 12px; font-weight: 600; letter-spacing: 0.04em; | |
| 211 | + text-transform: uppercase; color: var(--text-muted); | |
| 212 | +} | |
| 213 | +select, button.ctrl, input[type="date"] { | |
| 214 | + background: var(--surface-1); color: var(--text-primary); | |
| 215 | + border: 1px solid var(--border-strong); border-radius: 10px; | |
| 216 | + padding: 8px 13px; font-size: 14px; font-family: inherit; | |
| 217 | + box-shadow: 0 1px 2px rgba(20, 20, 15, 0.03); | |
| 218 | +} | |
| 219 | +button.ctrl { cursor: pointer; transition: background 0.15s, border-color 0.15s; } | |
| 220 | +button.ctrl:hover { background: var(--surface-2); } | |
| 221 | +button.ctrl[aria-pressed="true"] { | |
| 222 | + border-color: var(--series-1); color: var(--series-1); | |
| 223 | + background: var(--accent-soft); | |
| 224 | +} | |
| 225 | +a.ctrl { | |
| 226 | + background: var(--surface-1); border: 1px solid var(--border-strong); | |
| 227 | + border-radius: 10px; padding: 8px 13px; font-size: 14px; | |
| 228 | +} | |
| 229 | +a.ctrl:hover { background: var(--surface-2); text-decoration: none; } | |
| 230 | + | |
| 231 | +.seg { | |
| 232 | + display: inline-flex; border: 1px solid var(--border-strong); | |
| 233 | + border-radius: 10px; overflow: hidden; | |
| 234 | + box-shadow: 0 1px 2px rgba(20, 20, 15, 0.03); | |
| 235 | +} | |
| 236 | +.seg button { | |
| 237 | + border: none; background: var(--surface-1); color: var(--text-secondary); | |
| 238 | + padding: 8px 14px; font-size: 13.5px; font-weight: 500; cursor: pointer; | |
| 239 | + font-family: inherit; | |
| 240 | +} | |
| 241 | +.seg button + button { border-left: 1px solid var(--border); } | |
| 242 | +.seg button[aria-pressed="true"] { | |
| 243 | + background: var(--series-1); color: #fff; font-weight: 600; | |
| 244 | +} | |
| 245 | + | |
| 246 | +/* ------------------------------------------------------------------ badges */ | |
| 247 | +.badge { | |
| 248 | + display: inline-flex; align-items: center; gap: 6px; | |
| 249 | + border-radius: 999px; padding: 3px 11px; font-size: 12px; font-weight: 600; | |
| 250 | + border: 1px solid var(--border); color: var(--text-secondary); | |
| 251 | + background: var(--surface-1); | |
| 252 | +} | |
| 253 | +.badge .dot { width: 8px; height: 8px; border-radius: 50%; } | |
| 254 | + | |
| 255 | +.chip { | |
| 256 | + display: inline-flex; align-items: center; gap: 7px; | |
| 257 | + border: 1px solid var(--border-strong); border-radius: 999px; | |
| 258 | + background: var(--surface-1); color: var(--text-primary); | |
| 259 | + padding: 6px 13px; font-size: 13.5px; cursor: pointer; | |
| 260 | + transition: border-color 0.15s, background 0.15s; | |
| 261 | + font-family: inherit; | |
| 262 | +} | |
| 263 | +.chip:hover { border-color: var(--serious); background: var(--surface-2); } | |
| 264 | + | |
| 265 | +/* ------------------------------------------------------------------ tables */ | |
| 266 | +table.data { width: 100%; border-collapse: collapse; font-size: 14px; } | |
| 267 | +table.data th, table.data td { | |
| 268 | + text-align: right; padding: 9px 12px; border-bottom: 1px solid var(--border); | |
| 269 | + font-variant-numeric: tabular-nums; | |
| 270 | +} | |
| 271 | +table.data th:first-child, table.data td:first-child { text-align: left; } | |
| 272 | +table.data th { | |
| 273 | + color: var(--text-muted); font-weight: 600; font-size: 12px; | |
| 274 | + letter-spacing: 0.05em; text-transform: uppercase; | |
| 275 | +} | |
| 276 | +table.data tr:last-child td { border-bottom: none; } | |
| 277 | +table.data tbody tr { transition: background 0.12s; } | |
| 278 | +table.data tbody tr:hover { background: var(--surface-2); } | |
| 279 | + | |
| 280 | +/* ---------------------------------------------------------------- headings */ | |
| 281 | +h1 { | |
| 282 | + font-family: var(--font-display, serif); | |
| 283 | + font-size: 30px; letter-spacing: -0.02em; margin: 30px 0 6px; | |
| 284 | +} | |
| 285 | +h2 { | |
| 286 | + font-family: var(--font-display, serif); | |
| 287 | + font-size: 21px; letter-spacing: -0.01em; margin: 34px 0 12px; | |
| 288 | +} | |
| 289 | +p.lede { color: var(--text-secondary); margin-top: 0; font-size: 15.5px; } | |
| 290 | + | |
| 291 | +.note { font-size: 13px; color: var(--text-muted); } | |
| 292 | +.low-reliability-note { | |
| 293 | + font-size: 13px; color: var(--text-secondary); | |
| 294 | + background: var(--surface-2); border-radius: 10px; padding: 10px 14px; | |
| 295 | + border-left: 3px solid var(--series-4); | |
| 296 | + margin: 10px 0; | |
| 297 | +} | |
| 298 | + | |
| 299 | +/* ------------------------------------------------------------ chart pieces */ | |
| 300 | +.chart-tooltip { | |
| 301 | + background: var(--surface-1); | |
| 302 | + border: 1px solid var(--border-strong); | |
| 303 | + border-radius: 10px; | |
| 304 | + padding: 9px 13px; | |
| 305 | + font-size: 13px; | |
| 306 | + box-shadow: var(--shadow-card-hover); | |
| 307 | +} | |
| 308 | +.chart-tooltip .tt-label { color: var(--text-muted); font-size: 12px; } | |
| 309 | +.chart-tooltip .tt-value { font-weight: 700; font-size: 15px; } | |
| 310 | +.chart-tooltip .tt-ci { color: var(--text-muted); font-weight: 400; font-size: 12.5px; } | |
| 311 | +.chart-tooltip .tt-raw { color: var(--text-secondary); } | |
| 312 | +.chart-tooltip .tt-n { color: var(--text-muted); font-size: 12px; } | |
| 313 | + | |
| 314 | +code { | |
| 315 | + background: var(--surface-2); | |
| 316 | + border-radius: 6px; padding: 1px 6px; font-size: 0.9em; | |
| 317 | +} | |
| 318 | + | |
| 319 | +/* ------------------------------------------------------------- v3: pulse */ | |
| 320 | +button.primary, a.primary { | |
| 321 | + background: var(--series-1); color: #fff; border: none; | |
| 322 | + border-radius: 10px; padding: 9px 16px; font-size: 14px; font-weight: 600; | |
| 323 | + cursor: pointer; display: inline-flex; align-items: center; gap: 8px; | |
| 324 | + box-shadow: 0 2px 8px rgba(42, 120, 214, 0.3); | |
| 325 | + transition: transform 0.15s, box-shadow 0.15s; | |
| 326 | +} | |
| 327 | +button.primary:hover, a.primary:hover { | |
| 328 | + transform: translateY(-1px); | |
| 329 | + box-shadow: 0 4px 14px rgba(42, 120, 214, 0.4); | |
| 330 | + text-decoration: none; | |
| 331 | +} | |
| 332 | + | |
| 333 | +table.pulse { width: 100%; border-collapse: collapse; font-size: 13.5px; } | |
| 334 | +table.pulse th, table.pulse td { | |
| 335 | + text-align: right; padding: 8px 10px; border-bottom: 1px solid var(--border); | |
| 336 | + font-variant-numeric: tabular-nums; white-space: nowrap; | |
| 337 | +} | |
| 338 | +table.pulse th:first-child, table.pulse td:first-child { text-align: left; } | |
| 339 | +table.pulse th { | |
| 340 | + color: var(--text-muted); font-weight: 600; font-size: 11px; | |
| 341 | + letter-spacing: 0.05em; text-transform: uppercase; | |
| 342 | + background: var(--surface-1); | |
| 343 | +} | |
| 344 | +table.pulse tbody tr { cursor: pointer; transition: background 0.12s; } | |
| 345 | +table.pulse tbody tr:hover { background: var(--surface-2); } | |
| 346 | +td.heat { font-weight: 600; border-radius: 4px; } | |
| 347 | + | |
| 348 | +.record-chip { | |
| 349 | + display: inline-flex; align-items: center; gap: 4px; | |
| 350 | + font-size: 11px; font-weight: 700; color: var(--good); | |
| 351 | +} | |
| 352 | + | |
| 353 | +.mini-tile { padding: 12px 14px; } | |
| 354 | +.mini-tile .label { font-size: 10.5px; } | |
| 355 | +.mini-tile .value { font-size: 20px; } | |
| 356 | +.grid.cols-8 { grid-template-columns: repeat(auto-fit, minmax(min(130px, 100%), 1fr)); } | |
| 357 | + | |
| 358 | +/* ---------------------------------------------------------- v4 foundations */ | |
| 359 | +body, .card, .site-header, .site-footer, select, button.ctrl, a.ctrl, | |
| 360 | +input, .seg button, .badge, .chip, table.pulse th { | |
| 361 | + transition: background-color 0.25s ease, color 0.25s ease, | |
| 362 | + border-color 0.25s ease, box-shadow 0.2s ease; | |
| 363 | +} | |
| 364 | + | |
| 365 | +@keyframes qhpi-fade-up { | |
| 366 | + from { opacity: 0; transform: translateY(10px); } | |
| 367 | + to { opacity: 1; transform: none; } | |
| 368 | +} | |
| 369 | +.fade-up { animation: qhpi-fade-up 0.45s cubic-bezier(0.2, 0.7, 0.3, 1) both; } | |
| 370 | +.fade-up.d1 { animation-delay: 0.06s; } | |
| 371 | +.fade-up.d2 { animation-delay: 0.12s; } | |
| 372 | +.fade-up.d3 { animation-delay: 0.18s; } | |
| 373 | + | |
| 374 | +@keyframes qhpi-shimmer { | |
| 375 | + 0% { background-position: 180% 0; } | |
| 376 | + 100% { background-position: -20% 0; } | |
| 377 | +} | |
| 378 | +.skeleton { | |
| 379 | + border-radius: 10px; | |
| 380 | + background: linear-gradient(100deg, var(--surface-2) 40%, | |
| 381 | + color-mix(in srgb, var(--border) 65%, var(--surface-2)) 50%, | |
| 382 | + var(--surface-2) 60%); | |
| 383 | + background-size: 200% 100%; | |
| 384 | + animation: qhpi-shimmer 1.4s linear infinite; | |
| 385 | +} | |
| 386 | + | |
| 387 | +@media (prefers-reduced-motion: reduce) { | |
| 388 | + *, *::before, *::after { | |
| 389 | + animation-duration: 0.01ms !important; | |
| 390 | + animation-iteration-count: 1 !important; | |
| 391 | + transition-duration: 0.01ms !important; | |
| 392 | + } | |
| 393 | +} | |
| 394 | + | |
| 395 | +.brand-mark { flex: none; display: inline-flex; align-self: center; } | |
| 396 | + | |
| 397 | +:focus-visible { | |
| 398 | + outline: 2px solid var(--series-1); | |
| 399 | + outline-offset: 2px; | |
| 400 | + border-radius: 4px; | |
| 401 | +} | |
| 402 | + | |
| 403 | +@media (max-width: 680px) { | |
| 404 | + .container { padding: 0 14px 20px; } | |
| 405 | + .site-header { padding: 10px 14px; gap: 8px; } | |
| 406 | + .site-header nav a { padding: 5px 9px; font-size: 13px; } | |
| 407 | + .hero { padding: 22px 0 4px; } | |
| 408 | + .stat-tile .value { font-size: 25px; } | |
| 409 | + .card { padding: 14px; border-radius: 13px; } | |
| 410 | + .controls { gap: 8px; margin: 12px 0; } | |
| 411 | + table.pulse { font-size: 12.5px; } | |
| 412 | + table.pulse th, table.pulse td { padding: 6px 7px; } | |
| 413 | + h1 { font-size: 25px; } | |
| 414 | + h2 { font-size: 18px; } | |
| 415 | +} | |
| 416 | + | |
| 417 | +/* -------------------------------------------------------------- v5: hot */ | |
| 418 | +/* Subtle film grain over the whole app */ | |
| 419 | +body::before { | |
| 420 | + content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 1; | |
| 421 | + opacity: 0.35; mix-blend-mode: overlay; | |
| 422 | + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2'/%3E%3CfeColorMatrix values='0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0 0.5 0 0 0 0.05 0'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| 423 | +} | |
| 424 | +main, header, footer { position: relative; z-index: 2; } | |
| 425 | + | |
| 426 | +/* Ticker tape */ | |
| 427 | +.ticker-shell { | |
| 428 | + overflow: hidden; white-space: nowrap; | |
| 429 | + border-bottom: 1px solid var(--border); | |
| 430 | + background: var(--surface-1); | |
| 431 | + margin: 0 -24px; padding: 7px 0; min-height: 36px; | |
| 432 | +} | |
| 433 | +.ticker-track { display: inline-flex; } | |
| 434 | +.ticker-group { | |
| 435 | + display: inline-flex; align-items: center; | |
| 436 | + animation: ticker-scroll 55s linear infinite; | |
| 437 | +} | |
| 438 | +.ticker-shell:hover .ticker-group { animation-play-state: paused; } | |
| 439 | +@keyframes ticker-scroll { | |
| 440 | + from { transform: translateX(0); } | |
| 441 | + to { transform: translateX(-100%); } | |
| 442 | +} | |
| 443 | +.ticker-item { | |
| 444 | + display: inline-flex; align-items: baseline; gap: 7px; | |
| 445 | + padding: 0 18px; font-size: 12.5px; color: var(--text-primary); | |
| 446 | + border-right: 1px solid var(--border); | |
| 447 | +} | |
| 448 | +.ticker-item:hover { text-decoration: none; background: var(--surface-2); } | |
| 449 | +.ticker-name { color: var(--text-secondary); font-weight: 600; } | |
| 450 | +.ticker-val { font-variant-numeric: tabular-nums; font-weight: 700; } | |
| 451 | +.ticker-item .delta { font-size: 11.5px; } | |
| 452 | + | |
| 453 | +/* Mega hero number */ | |
| 454 | +.hero-mega { | |
| 455 | + display: flex; flex-direction: column; align-items: flex-end; gap: 2px; | |
| 456 | +} | |
| 457 | +.hero-mega .big { | |
| 458 | + font-family: var(--font-display, serif); | |
| 459 | + font-size: clamp(52px, 7vw, 84px); font-weight: 700; | |
| 460 | + line-height: 1; letter-spacing: -0.03em; | |
| 461 | + background: linear-gradient(160deg, var(--text-primary) 55%, var(--series-1)); | |
| 462 | + -webkit-background-clip: text; background-clip: text; | |
| 463 | + -webkit-text-fill-color: transparent; | |
| 464 | +} | |
| 465 | +.hero-mega .sub { font-size: 13.5px; color: var(--text-secondary); } | |
| 466 | + | |
| 467 | +/* Hero chart draw-in */ | |
| 468 | +.hero-chart { padding: 14px 14px 6px; overflow: hidden; } | |
| 469 | +.hero-line { | |
| 470 | + stroke-dasharray: 1; stroke-dashoffset: 1; | |
| 471 | + animation: hero-draw 1.6s cubic-bezier(0.4, 0, 0.2, 1) 0.15s forwards; | |
| 472 | +} | |
| 473 | +.hero-area, .hero-band { opacity: 0; animation: hero-fade 0.8s ease 1.25s forwards; } | |
| 474 | +.hero-dot { opacity: 0; animation: hero-fade 0.5s ease 1.6s forwards; } | |
| 475 | +.hero-pulse { | |
| 476 | + opacity: 0; transform-origin: center; transform-box: fill-box; | |
| 477 | + animation: hero-fade 0.5s ease 1.6s forwards, | |
| 478 | + hero-ping 2.4s ease-out 2.1s infinite; | |
| 479 | +} | |
| 480 | +@keyframes hero-draw { to { stroke-dashoffset: 0; } } | |
| 481 | +@keyframes hero-fade { to { opacity: 1; } } | |
| 482 | +@keyframes hero-ping { | |
| 483 | + 0% { transform: scale(0.6); opacity: 0.45; } | |
| 484 | + 70% { transform: scale(1.7); opacity: 0; } | |
| 485 | + 100% { opacity: 0; } | |
| 486 | +} | |
| 487 | + | |
| 488 | +/* Podium */ | |
| 489 | +.podium { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(min(230px, 100%), 1fr)); } | |
| 490 | +.podium .card { position: relative; overflow: hidden; } | |
| 491 | +.podium .card::after { | |
| 492 | + content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; | |
| 493 | +} | |
| 494 | +.podium .p1::after { background: linear-gradient(90deg, #eda100, #ffd977); } | |
| 495 | +.podium .p2::after { background: linear-gradient(90deg, #9a9a94, #d8d8d2); } | |
| 496 | +.podium .p3::after { background: linear-gradient(90deg, #b0713d, #dda67a); } | |
| 497 | +.podium .medal { font-size: 20px; } | |
| 498 | + | |
| 499 | +/* Command palette */ | |
| 500 | +.palette-backdrop { | |
| 501 | + position: fixed; inset: 0; z-index: 90; | |
| 502 | + background: color-mix(in srgb, var(--surface-0) 55%, transparent); | |
| 503 | + backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); | |
| 504 | + display: flex; align-items: flex-start; justify-content: center; | |
| 505 | + padding: 12vh 16px 0; | |
| 506 | + animation: qhpi-fade-up 0.18s ease both; | |
| 507 | +} | |
| 508 | +.palette { | |
| 509 | + width: 100%; max-width: 560px; | |
| 510 | + background: var(--surface-1); border: 1px solid var(--border-strong); | |
| 511 | + border-radius: 16px; box-shadow: var(--shadow-card-hover); | |
| 512 | + overflow: hidden; | |
| 513 | +} | |
| 514 | +.palette-input { | |
| 515 | + width: 100%; border: none; outline: none; | |
| 516 | + background: transparent; color: var(--text-primary); | |
| 517 | + padding: 16px 18px; font-size: 16px; font-family: inherit; | |
| 518 | + border-bottom: 1px solid var(--border); | |
| 519 | +} | |
| 520 | +.palette-list { list-style: none; margin: 0; padding: 6px; max-height: 320px; overflow-y: auto; } | |
| 521 | +.palette-item { | |
| 522 | + width: 100%; display: flex; justify-content: space-between; gap: 10px; | |
| 523 | + border: none; background: none; text-align: left; cursor: pointer; | |
| 524 | + padding: 10px 12px; border-radius: 10px; font-size: 14px; | |
| 525 | + color: var(--text-primary); font-family: inherit; | |
| 526 | +} | |
| 527 | +.palette-item.active { background: var(--accent-soft); color: var(--series-1); } | |
| 528 | +.palette-hint { color: var(--text-muted); font-size: 12px; } | |
| 529 | +.palette-empty { padding: 14px; color: var(--text-muted); font-size: 13.5px; } | |
| 530 | +.palette-foot { | |
| 531 | + display: flex; gap: 16px; padding: 8px 16px; | |
| 532 | + border-top: 1px solid var(--border); | |
| 533 | + color: var(--text-muted); font-size: 11.5px; | |
| 534 | +} | |
| 535 | +.kbd-btn { | |
| 536 | + display: inline-flex; align-items: center; gap: 6px; | |
| 537 | + background: var(--surface-1); color: var(--text-secondary); | |
| 538 | + border: 1px solid var(--border-strong); border-radius: 10px; | |
| 539 | + padding: 6px 11px; font-size: 13px; cursor: pointer; font-family: inherit; | |
| 540 | +} | |
| 541 | +.kbd-btn:hover { background: var(--surface-2); } | |
| 542 | +.kbd-btn kbd { | |
| 543 | + background: var(--surface-2); border: 1px solid var(--border); | |
| 544 | + border-radius: 5px; padding: 0 5px; font-size: 11px; | |
| 545 | + font-family: inherit; color: var(--text-muted); | |
| 546 | +} | |
| 547 | + | |
| 548 | +@media (max-width: 680px) { | |
| 549 | + .ticker-shell { margin: 0 -14px; } | |
| 550 | + .hero-mega { align-items: flex-start; } | |
| 551 | +} | |
| 552 | + | |
| 553 | +/* -------------------------------------------------- v5.1: mobile polish */ | |
| 554 | +@media (max-width: 680px) { | |
| 555 | + /* Compact header: brand + toggle on one row, nav scrolls on its own row */ | |
| 556 | + .brand small { display: none; } | |
| 557 | + .kbd-btn { display: none; } | |
| 558 | + .site-header { row-gap: 6px; } | |
| 559 | + .site-header nav { | |
| 560 | + order: 3; flex: 1 0 100%; | |
| 561 | + flex-wrap: nowrap; overflow-x: auto; | |
| 562 | + scrollbar-width: none; -webkit-overflow-scrolling: touch; | |
| 563 | + margin: 0 -4px; padding: 0 4px; | |
| 564 | + } | |
| 565 | + .site-header nav::-webkit-scrollbar { display: none; } | |
| 566 | + .site-header nav a { flex: none; } | |
| 567 | + | |
| 568 | + /* Hero: number below the title, full width */ | |
| 569 | + .hero { flex-direction: column; align-items: stretch; gap: 12px; } | |
| 570 | + .hero-mega { align-items: flex-start; } | |
| 571 | + | |
| 572 | + /* Pulse table: drop secondary columns (1m, 6m, vs peak, $ value) — | |
| 573 | + the full set remains in Explore's data table and the CSV/PDF exports */ | |
| 574 | + table.pulse th:nth-child(4), table.pulse td:nth-child(4), | |
| 575 | + table.pulse th:nth-child(6), table.pulse td:nth-child(6), | |
| 576 | + table.pulse th:nth-child(9), table.pulse td:nth-child(9), | |
| 577 | + table.pulse th:nth-child(11), table.pulse td:nth-child(11) { display: none; } | |
| 578 | + | |
| 579 | + .controls input[type="text"] { flex: 1 1 160px; min-width: 0; } | |
| 580 | + .controls input[type="range"] { flex: 1 1 110px; min-width: 0; } | |
| 581 | + .ticker-item { padding: 0 12px; } | |
| 582 | + .hero-chart { padding: 10px 8px 4px; } | |
| 583 | + .primary, a.primary { padding: 8px 13px; font-size: 13.5px; } | |
| 584 | + .palette-backdrop { padding: 8vh 10px 0; } | |
| 585 | +} | |
| 586 | + | |
| 587 | +@media (max-width: 400px) { | |
| 588 | + .stat-tile .value { font-size: 22px; } | |
| 589 | + .mini-tile .value { font-size: 17px; } | |
| 590 | + h2 { font-size: 17px; } | |
| 591 | +} | |
added
web/tsconfig.json
+22 −0
@@ -0,0 +1,22 @@ | ||
| 1 | +{ | |
| 2 | + "_header": "QWHPI — Quebec Weekly Housing Price Index | Author: Simon-Pierre Boucher | Contact: contact@spboucher.ai | File: web/tsconfig.json | Purpose: TypeScript configuration for the dashboard", | |
| 3 | + "compilerOptions": { | |
| 4 | + "target": "ES2022", | |
| 5 | + "lib": ["dom", "dom.iterable", "esnext"], | |
| 6 | + "allowJs": true, | |
| 7 | + "skipLibCheck": true, | |
| 8 | + "strict": true, | |
| 9 | + "noEmit": true, | |
| 10 | + "esModuleInterop": true, | |
| 11 | + "module": "esnext", | |
| 12 | + "moduleResolution": "bundler", | |
| 13 | + "resolveJsonModule": true, | |
| 14 | + "isolatedModules": true, | |
| 15 | + "jsx": "preserve", | |
| 16 | + "incremental": true, | |
| 17 | + "plugins": [{ "name": "next" }], | |
| 18 | + "paths": { "@/*": ["./*"] } | |
| 19 | + }, | |
| 20 | + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], | |
| 21 | + "exclude": ["node_modules"] | |
| 22 | +} | |
| 23 | ||