# ============================================================================= # QWHPI — Quebec Weekly Housing Price Index # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # File : api/app/routers/index.py # Purpose : Index endpoints (monthly v2.1) — series, latest, compare, vintages. # ============================================================================= """Index series endpoints.""" from __future__ import annotations import math import polars as pl from fastapi import APIRouter, HTTPException, Query, Request, Response from app.deps import csv_response, etag_for, validate_series from app.schemas import LatestResponse, Observation, SeriesResponse, VintageObservation from app.services import data router = APIRouter() def _clean(v): return None if (v is None or (isinstance(v, float) and math.isnan(v))) else v def _obs(row: dict) -> Observation: return Observation( period=row["period"], index=_clean(row["index"]), index_smoothed=row["index_smoothed"], representative_value=row["representative_value"], transactions=row["transactions"], effective_sample_size=row["effective_sample_size"], monthly_pct=row["monthly_pct"], three_month_pct=row["three_month_pct"], six_month_pct=row["six_month_pct"], yoy_pct=row["yoy_pct"], lower_95=row["lower_95"], upper_95=row["upper_95"], reliability_grade=row["reliability_grade"], shrinkage_weight=row["shrinkage_weight"], is_partial_month=row["is_partial_month"], ) @router.get("/index", response_model=SeriesResponse) def get_index( request: Request, response: Response, geography: str = Query(..., description="geography_id, e.g. quebec, region-06, montreal"), type: str = Query("all", alias="type"), from_: str | None = Query(None, alias="from", description="first period (YYYY-MM)"), to: str | None = Query("latest"), limit: int | None = Query(None, ge=1, le=1000), offset: int = Query(0, ge=0), format: str = Query("json", pattern="^(json|csv)$"), ): """Full monthly history for one series (paginated; CSV via ?format=csv).""" validate_series(geography, type) df = data.series(geography, type, from_, to) if df.height == 0: raise HTTPException(404, "no observations for this series/range") total = df.height page = df.slice(offset, limit) if limit else df.slice(offset) if format == "csv": return csv_response(page) if etag_for(request, response, geography, type, str(from_), str(to), str(limit), str(offset)): return Response(status_code=304) first = page.row(0, named=True) return SeriesResponse( geography=geography, geography_name=first["geography_name"], geography_level=first["geography_level"], property_type=type, model_version=first["model_version"], data_vintage=first["data_vintage"], total_observations=total, offset=offset, limit=limit, observations=[_obs(r) for r in page.iter_rows(named=True)], ) @router.get("/index/latest", response_model=LatestResponse) def get_latest( request: Request, response: Response, geography: str, type: str = "all", include_partial: bool = Query(False, description="include the partial (nowcast) month"), ): """Latest observation (complete month by default; nowcast on request).""" validate_series(geography, type) df = data.series(geography, type) if not include_partial: df = df.filter(~pl.col("is_partial_month")) if df.height == 0: raise HTTPException(404, "no observations") row = df.row(df.height - 1, named=True) if etag_for(request, response, geography, type, row["period"], str(include_partial)): return Response(status_code=304) return LatestResponse( geography=geography, geography_name=row["geography_name"], property_type=type, period=row["period"], latest_index=round(row["index_smoothed"], 2), latest_index_raw=(None if _clean(row["index"]) is None else round(row["index"], 2)), representative_value=row["representative_value"], monthly_change=row["monthly_pct"], three_month_change=row["three_month_pct"], yoy_change=row["yoy_pct"], transactions=row["transactions"], effective_sample_size=row["effective_sample_size"], reliability=row["reliability_grade"], lower_95=round(row["lower_95"], 2), upper_95=round(row["upper_95"], 2), is_partial_month=row["is_partial_month"], model_version=row["model_version"], data_vintage=row["data_vintage"], ) @router.get("/compare") def compare( request: Request, response: Response, series: str = Query(..., description="comma list of geography:type, e.g. " "montreal:condo,quebec-city:condo"), rebase_period: str | None = Query(None, description="rebase all series to 100 at this period"), ): """Aligned comparison of up to 8 series, optionally rebased.""" pairs = [s.strip() for s in series.split(",") if s.strip()] if not 2 <= len(pairs) <= 8: raise HTTPException(422, "provide 2 to 8 series") out = {} for p in pairs: try: g, t = p.split(":") except ValueError: raise HTTPException(422, f"bad series spec '{p}' (want geography:type)") validate_series(g, t) df = data.series(g, t).select("period", "index_smoothed", "representative_value", "reliability_grade") if rebase_period: base = df.filter(pl.col("period") == rebase_period) if base.height == 0: raise HTTPException(422, f"rebase_period {rebase_period} not found for {p}") df = df.with_columns( (pl.col("index_smoothed") / base["index_smoothed"][0] * 100) .round(3).alias("index_smoothed")) out[p] = df.to_dicts() if etag_for(request, response, series, str(rebase_period)): return Response(status_code=304) return {"series": out, "rebase_period": rebase_period, "author": "Simon-Pierre Boucher", "contact": "contact@spboucher.ai"} @router.get("/vintages") def vintages( geography: str, type: str = "all", period: str | None = None, ): """First release vs current vintage per period (revision tracking).""" validate_series(geography, type) fr = data.first_release() if fr is None: raise HTTPException(404, "no vintage history yet") cur = data.series(geography, type).select( "period", "index_smoothed", "data_vintage") j = ( fr.filter((pl.col("geography_id") == geography) & (pl.col("property_type") == type)) .join(cur, on="period", how="inner") ) if period: j = j.filter(pl.col("period") == period) obs = [ VintageObservation( period=r["period"], first_release_index_smoothed=round(r["first_release_index_smoothed"], 3), current_index_smoothed=round(r["index_smoothed"], 3), first_release_vintage=r["first_release_vintage"], current_vintage=r["data_vintage"], revision_pct=round((r["index_smoothed"] / r["first_release_index_smoothed"] - 1) * 100, 3), ).model_dump() for r in j.sort("period").iter_rows(named=True) ] return {"geography": geography, "property_type": type, "observations": obs, "author": "Simon-Pierre Boucher", "contact": "contact@spboucher.ai"}