spb/qwhpi Public
QHPI — Quebec Housing Price Index: quality-adjusted, hierarchically pooled housing price indexes.
Python 63.9%
TypeScript 25.4%
CSS 5.5%
TeX 3.5%
SQL 0.8%
Makefile 0.5%
Dockerfile 0.5%
1# =============================================================================2# QWHPI — Quebec Weekly Housing Price Index3# Author : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# File : api/app/services/stats.py6# Purpose : Rich derived metrics per series — peak/drawdown, momentum,7# volatility, CAGR, ranks, volumes, assessment gap.8# =============================================================================9"""Derived series statistics.1011Everything is computed from the canonical monthly parquet (complete months12only). Definitions:1314- peak / drawdown ....... max of index_smoothed; % below it now15- since_2021_pct ........ latest index − 100 (base = 2021 average)16- cagr_pct .............. annualized log growth from the first 12-month mean17 to the latest 12-month mean18- volatility_12m_pct .... SD of the last 12 monthly log changes (monthly, %)19- momentum_3m_ann_pct ... last 3-month log growth, annualized20- yoy_rank .............. rank of YoY among geographies of the same level21 and property type (1 = fastest)22- volume_12m / volume_prev_12m / volume_yoy_pct23- dollar_volume_12m ..... Σ transactions × representative value (estimate)24- assessment_gap ........ latest median sale/assessment ratio (region &25 province only)26"""2728from __future__ import annotations2930import math3132import numpy as np33import polars as pl3435from app.services import data363738def _complete(gid: str, ptype: str) -> pl.DataFrame:39 return data.series(gid, ptype).filter(~pl.col("is_partial_month"))404142def 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)4950 peak_i = int(np.argmax(idx))51 latest = float(idx[-1])52 peak = float(idx[peak_i])53 drawdown = (latest / peak - 1) * 10054 months_since_peak = len(idx) - 1 - peak_i5556 first12 = float(np.mean(li[:12]))57 last12 = float(np.mean(li[-12:]))58 years = (len(li) - 12) / 1259 cagr = (math.exp((last12 - first12) / max(years, 1e-9)) - 1) * 1006061 d = np.diff(li)62 vol12 = float(np.std(d[-12:])) * 10063 mom3 = (math.exp((li[-1] - li[-4]) * 4) - 1) * 1006465 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 None69 volume_yoy = ((vol_12m / vol_prev - 1) * 100) if vol_prev else None70 dollar_12m = float(np.nansum(tx[-12:] * np.nan_to_num(rep[-12:])))7172 last = df.row(df.height - 1, named=True)7374 # 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 None8586 # Assessment gap (province & regions).87 gap = None88 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])9293 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 }123124125def 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 continue137 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_period159160161def 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}165