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%
7.6 KB · 198 lines python
Raw Blame History
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