SPB Git

spb/qwhpi Public

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

Python 63.9% TypeScript 25.4% CSS 5.5% TeX 3.5% SQL 0.8% Makefile 0.5% Dockerfile 0.5%
6.5 KB · 165 lines python
Raw Blame History
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