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/routers/index.py6# Purpose : Index endpoints (monthly v2.1) — series, latest, compare, vintages.7# =============================================================================8"""Index series endpoints."""910from __future__ import annotations1112import math1314import polars as pl15from fastapi import APIRouter, HTTPException, Query, Request, Response1617from app.deps import csv_response, etag_for, validate_series18from app.schemas import LatestResponse, Observation, SeriesResponse, VintageObservation19from app.services import data2021router = APIRouter()222324def _clean(v):25 return None if (v is None or (isinstance(v, float) and math.isnan(v))) else v262728def _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 )464748@router.get("/index", response_model=SeriesResponse)49def 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.height66 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 )858687@router.get("/index/latest", response_model=LatestResponse)88def 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 None113 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 )127128129@router.get("/compare")130def 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"}162163164@router.get("/vintages")165def 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"}198