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%
14.7 KB · 346 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/report.py6# Purpose : Automatic PDF report generation — single series, multi-series7#           comparison, and full market report (matplotlib, palette-styled).8# =============================================================================9"""PDF report factory.1011Every report is generated on demand from the canonical monthly parquet:12- series report ....... hero metrics grid, index chart with CI band, dollar13                        value chart, volume bars, growth & risk table14- comparison page ..... rebased multi-series chart + side-by-side table15- market report ....... Quebec overview, regional heatmap table, type panel1617Styling matches the dashboard palette; every page carries the author credit.18"""1920from __future__ import annotations2122import io23from datetime import date2425import matplotlib2627matplotlib.use("Agg")28import matplotlib.pyplot as plt  # noqa: E40229import numpy as np  # noqa: E40230import polars as pl  # noqa: E40231from matplotlib.backends.backend_pdf import PdfPages  # noqa: E4023233from app.services import data, stats  # noqa: E4023435# Palette (mirrors web/styles/globals.css light mode)36BLUE, ORANGE, AQUA, YELLOW, MAGENTA = "#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4"37SERIES_COLORS = [BLUE, ORANGE, AQUA, YELLOW, MAGENTA]38INK, INK2, MUTED = "#0b0b0b", "#52514e", "#8a887f"39SURFACE, BORDER, BAND = "#fcfcfb", "#dddcd6", (42 / 255, 120 / 255, 214 / 255, 0.14)40GOOD, BAD = "#008300", "#e34948"4142plt.rcParams.update({43    "figure.facecolor": SURFACE, "axes.facecolor": SURFACE,44    "axes.edgecolor": BORDER, "axes.grid": True, "grid.color": BORDER,45    "grid.linestyle": ":", "grid.linewidth": 0.7,46    "axes.spines.top": False, "axes.spines.right": False,47    "text.color": INK, "axes.labelcolor": INK2,48    "xtick.color": INK2, "ytick.color": INK2, "font.size": 9,49})5051PAGE = (8.27, 11.69)  # A4 portrait525354def _fmt_pct(v, plus=True):55    if v is None:56        return "—"57    s = f"{v:+.2f}%" if plus else f"{v:.2f}%"58    return s596061def _fmt_money(v):62    return "—" if v is None else f"${v:,.0f}"636465def _footer(fig, page_note=""):66    fig.text(0.05, 0.02, "QHPI — Quebec Housing Price Index · Simon-Pierre Boucher · contact@spboucher.ai",67             fontsize=7, color=MUTED)68    fig.text(0.95, 0.02, page_note, fontsize=7, color=MUTED, ha="right")697071def _title_block(fig, title, subtitle):72    fig.text(0.05, 0.955, title, fontsize=19, fontweight="bold", color=INK)73    fig.text(0.05, 0.928, subtitle, fontsize=9.5, color=INK2)74    fig.add_artist(plt.Line2D([0.05, 0.95], [0.915, 0.915],75                              color=BORDER, lw=1, transform=fig.transFigure))767778def _metric_cells(fig, y_top, items, ncols=4):79    """Grid of metric tiles: items = [(label, value, color?)]"""80    n = len(items)81    nrows = (n + ncols - 1) // ncols82    w = 0.9 / ncols83    h = 0.05584    for i, item in enumerate(items):85        label, value = item[0], item[1]86        color = item[2] if len(item) > 2 else INK87        r, c = divmod(i, ncols)88        x = 0.05 + c * w89        y = y_top - r * (h + 0.018)90        fig.text(x, y, label.upper(), fontsize=6.7, color=MUTED)91        fig.text(x, y - 0.026, value, fontsize=13, fontweight="bold", color=color)92    return y_top - nrows * (h + 0.018)939495def _delta_color(v):96    if v is None:97        return INK98    return GOOD if v >= 0 else BAD99100101def _index_axes(ax, df, unit="points", legend=True):102    periods = df["period"].to_list()103    x = np.arange(len(periods))104    k = 1.0105    if unit == "dollars":106        row = df.filter(pl.col("representative_value").is_not_null())107        k = row["representative_value"][0] / row["index_smoothed"][0]108    smoothed = df["index_smoothed"].to_numpy() * k109    lo = df["lower_95"].to_numpy() * k110    hi = df["upper_95"].to_numpy() * k111    raw = df["index"].to_numpy() * k112    ax.fill_between(x, lo, hi, color=BAND, label="95% CI", linewidth=0)113    ax.plot(x, raw, color=BLUE, alpha=0.35, lw=0.8, label="raw monthly")114    ax.plot(x, smoothed, color=BLUE, lw=1.9, label="index (smoothed)")115    pad = (smoothed.max() - smoothed.min()) * 0.15 or 1116    ax.set_ylim(min(smoothed.min(), np.nanmin(raw)) - pad,117                max(smoothed.max(), np.nanmax(raw)) + pad)118    step = max(1, len(x) // 8)119    ax.set_xticks(x[::step], [p for p in periods[::step]], fontsize=7.5)120    if unit == "dollars":121        ax.yaxis.set_major_formatter(lambda v, _: f"${v / 1000:.0f}k")122    if legend:123        ax.legend(loc="upper left", fontsize=7.5, frameon=False)124125126def _volume_axes(ax, df):127    x = np.arange(df.height)128    ax.bar(x, df["transactions"].to_numpy(), color=BLUE, alpha=0.55, width=0.85)129    ax.set_xticks([])130    ax.tick_params(labelsize=6.5)131    ax.set_title("Transactions per month", fontsize=8, loc="left", color=INK2)132133134def _series_page(pdf, gid, ptype):135    df = data.series(gid, ptype).filter(~pl.col("is_partial_month"))136    st = stats.series_stats(gid, ptype)137    name = df["geography_name"][0]138    fig = plt.figure(figsize=PAGE)139    _title_block(fig, f"{name}{ptype}",140                 f"Monthly housing price index · base 2021 = 100 · data to {st['period']} · "141                 f"reliability {st['reliability_grade']} · generated {date.today().isoformat()}")142143    y = _metric_cells(fig, 0.875, [144        ("Index", f"{st['index']:.1f}"),145        ("Dollar value", _fmt_money(st["representative_value"])),146        ("1 month", _fmt_pct(st["monthly_pct"]), _delta_color(st["monthly_pct"])),147        ("Year over year", _fmt_pct(st["yoy_pct"]), _delta_color(st["yoy_pct"])),148        ("3 months", _fmt_pct(st["three_month_pct"]), _delta_color(st["three_month_pct"])),149        ("6 months", _fmt_pct(st["six_month_pct"]), _delta_color(st["six_month_pct"])),150        ("Since 2021", _fmt_pct(st["since_2021_pct"]), _delta_color(st["since_2021_pct"])),151        ("CAGR", _fmt_pct(st["cagr_pct"]), _delta_color(st["cagr_pct"])),152        ("Peak", f"{st['peak_index']:.1f} ({st['peak_period']})"),153        ("Vs peak", _fmt_pct(st["drawdown_pct"]),154         GOOD if st["at_record_high"] else BAD),155        ("Volatility (12m)", _fmt_pct(st["volatility_12m_pct"], plus=False)),156        ("Momentum (3m ann.)", _fmt_pct(st["momentum_3m_ann_pct"]),157         _delta_color(st["momentum_3m_ann_pct"])),158        ("Sales (12m)", f"{st['volume_12m']:,}"),159        ("Volume YoY", _fmt_pct(st["volume_yoy_pct"]),160         _delta_color(st["volume_yoy_pct"])),161        ("$ volume (12m, est.)", f"${st['dollar_volume_12m'] / 1e6:,.0f}M"),162        ("YoY rank", f"{st['yoy_rank']}/{st['yoy_rank_of']}" if st["yoy_rank"] else "—"),163    ])164165    del y  # metrics grid occupies down to ~0.56; charts use fixed slots166    ax1 = fig.add_axes([0.07, 0.345, 0.87, 0.185])167    _index_axes(ax1, df)168    ax1.set_title("Index (2021 = 100) with 95% confidence band",169                  fontsize=9, loc="left", color=INK2)170171    ax2 = fig.add_axes([0.07, 0.125, 0.87, 0.165])172    _index_axes(ax2, df, unit="dollars", legend=False)173    ax2.set_title("Representative dollar value (fixed 2021 basket)",174                  fontsize=9, loc="left", color=INK2)175176    ax3 = fig.add_axes([0.07, 0.042, 0.87, 0.05])177    _volume_axes(ax3, df)178179    _footer(fig, f"{gid}:{ptype}")180    pdf.savefig(fig)181    plt.close(fig)182183184def _comparison_page(pdf, specs):185    fig = plt.figure(figsize=PAGE)186    _title_block(fig, "Comparison", "All series rebased to 100 at the first common month")187    frames = {}188    for gid, ptype in specs:189        frames[(gid, ptype)] = data.series(gid, ptype).filter(~pl.col("is_partial_month"))190    common = sorted(set.intersection(*[set(f["period"].to_list()) for f in frames.values()]))191    base_p = common[0]192193    ax = fig.add_axes([0.07, 0.45, 0.87, 0.42])194    rows = []195    for i, ((gid, ptype), f) in enumerate(frames.items()):196        f = f.filter(pl.col("period").is_in(common)).sort("period")197        idx = f["index_smoothed"].to_numpy()198        rebased = idx / idx[0] * 100199        x = np.arange(len(common))200        name = f["geography_name"][0]201        ax.plot(x, rebased, color=SERIES_COLORS[i % 5], lw=1.9, label=f"{name} · {ptype}")202        st = stats.series_stats(gid, ptype)203        rows.append([f"{name} · {ptype}", f"{st['index']:.1f}",204                     _fmt_pct(st["yoy_pct"]), _fmt_pct(st["since_2021_pct"]),205                     _fmt_money(st["representative_value"]), st["reliability_grade"]])206    step = max(1, len(common) // 8)207    ax.set_xticks(np.arange(len(common))[::step], common[::step], fontsize=7.5)208    ax.legend(loc="upper left", fontsize=8, frameon=False)209    ax.set_title(f"Rebased to 100 at {base_p}", fontsize=9, loc="left", color=INK2)210211    tbl_ax = fig.add_axes([0.07, 0.13, 0.87, 0.24])212    tbl_ax.axis("off")213    table = tbl_ax.table(214        cellText=rows,215        colLabels=["Series", "Index", "YoY", "Since 2021", "$ value", "Grade"],216        loc="upper center", cellLoc="center")217    table.auto_set_font_size(False)218    table.set_fontsize(8.5)219    table.scale(1, 1.6)220    for (r, c), cell in table.get_celld().items():221        cell.set_edgecolor(BORDER)222        if r == 0:223            cell.set_text_props(color=INK2, fontweight="bold")224            cell.set_facecolor("#f0efec")225    _footer(fig, "comparison")226    pdf.savefig(fig)227    plt.close(fig)228229230def build_series_report(specs: list[tuple[str, str]]) -> bytes:231    buf = io.BytesIO()232    with PdfPages(buf) as pdf:233        for gid, ptype in specs:234            _series_page(pdf, gid, ptype)235        if len(specs) > 1:236            _comparison_page(pdf, specs)237        meta = pdf.infodict()238        meta["Title"] = "QHPI report — " + ", ".join(f"{g}:{t}" for g, t in specs)239        meta["Author"] = "Simon-Pierre Boucher <contact@spboucher.ai>"240    return buf.getvalue()241242243def build_market_report(ptype: str = "all") -> bytes:244    buf = io.BytesIO()245    with PdfPages(buf) as pdf:246        # Page 1 — province247        _series_page(pdf, "quebec", ptype)248249        # Page 2 — regional heatmap table250        ov = stats.overview_rows("region", ptype)251        fig = plt.figure(figsize=PAGE)252        _title_block(fig, "Regions — market pulse",253                     f"All 17 administrative regions · {ptype} · month of {ov['period']}")254        rows, colors = [], []255        vals = [r["yoy_pct"] for r in ov["rows"] if r["yoy_pct"] is not None]256        lo, hi = min(vals), max(vals)257258        def heat(v):259            if v is None:260                return SURFACE261            t = 0.5 if hi == lo else (v - lo) / (hi - lo)262            ramp = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf"]263            return ramp[min(4, int(t * 5))]264265        for r in ov["rows"]:266            rows.append([r["geography_name"][:28], f"{r['index']:.1f}",267                         _fmt_pct(r["monthly_pct"]), _fmt_pct(r["three_month_pct"]),268                         _fmt_pct(r["six_month_pct"]), _fmt_pct(r["yoy_pct"]),269                         _fmt_pct(r["drawdown_pct"]), f"{r['volume_12m']:,}",270                         r["reliability_grade"]])271            colors.append(heat(r["yoy_pct"]))272        ax = fig.add_axes([0.05, 0.44, 0.9, 0.44])273        ax.axis("off")274        widths = [0.26] + [0.0925] * 8275        table = ax.table(276            cellText=rows,277            colLabels=["Region", "Index", "1m", "3m", "6m", "YoY", "Vs peak", "Sales 12m", "Grade"],278            loc="upper center", cellLoc="center", colWidths=widths)279        table.auto_set_font_size(False)280        table.set_fontsize(7.6)281        table.scale(1, 1.45)282        for (r, c), cell in table.get_celld().items():283            cell.set_edgecolor(BORDER)284            if c == 0:285                cell.set_text_props(ha="left")286                cell.PAD = 0.02287            if r == 0:288                cell.set_text_props(color=INK2, fontweight="bold",289                                    ha="left" if c == 0 else "center")290                cell.set_facecolor("#f0efec")291            elif c == 5:292                cell.set_facecolor(colors[r - 1])293                if colors[r - 1] in ("#256abf", "#3987e5"):294                    cell.set_text_props(color="white")295        fig.text(0.05, 0.415, "YoY column shaded by appreciation (sequential blue ramp).",296                 fontsize=7.5, color=MUTED)297298        # YoY horizontal bars under the table299        axb = fig.add_axes([0.30, 0.075, 0.62, 0.30])300        names = [r["geography_name"] for r in ov["rows"]][::-1]301        yoys = [r["yoy_pct"] or 0 for r in ov["rows"]][::-1]302        axb.barh(np.arange(len(names)), yoys,303                 color=[heat(v) for v in yoys], height=0.72)304        axb.set_yticks(np.arange(len(names)), names, fontsize=7)305        axb.set_title("Year-over-year appreciation (%)", fontsize=9,306                      loc="left", color=INK2)307        axb.tick_params(axis="x", labelsize=7)308        _footer(fig, "regions")309        pdf.savefig(fig)310        plt.close(fig)311312        # Page 3 — property types (province)313        fig = plt.figure(figsize=PAGE)314        _title_block(fig, "Property types — province",315                     "Unifamilial, condo and plex indexes (2021 = 100)")316        ax = fig.add_axes([0.07, 0.52, 0.87, 0.36])317        for i, t in enumerate(["unifamilial", "condo", "plex"]):318            f = data.series("quebec", t).filter(~pl.col("is_partial_month")).sort("period")319            x = np.arange(f.height)320            ax.plot(x, f["index_smoothed"].to_numpy(), color=SERIES_COLORS[i],321                    lw=1.9, label=t)322        periods = f["period"].to_list()323        step = max(1, len(periods) // 8)324        ax.set_xticks(np.arange(len(periods))[::step], periods[::step], fontsize=7.5)325        ax.legend(loc="upper left", fontsize=8.5, frameon=False)326327        fig.text(0.07, 0.42, "Method", fontsize=11, fontweight="bold")328        fig.text(0.07, 0.20,329                 "Robust monthly architecture: province paths estimated by rolling-time-dummy hedonic\n"330                 "regressions (13-month Huber-weighted windows, mean-splice linking — the published\n"331                 "history never revises); liquid cells (≥40 transactions/month) estimated directly by a\n"332                 "local robust time-dummy; thin cells shrunk toward their parent path by a heteroskedastic\n"333                 "local-level Kalman filter. Validated against repeat sales, stratified matched-cell\n"334                 "medians, downsampling and composition-shock simulations. Variables with time-correlated\n"335                 "missingness are excluded by rule. All series NSA, base 2021 average = 100.\n\n"336                 "Full methodology: www.indexqc.house/methodology",337                 fontsize=8.5, color=INK2, va="bottom")338        _footer(fig, "types & method")339        pdf.savefig(fig)340        plt.close(fig)341342        meta = pdf.infodict()343        meta["Title"] = f"QHPI market report — {ptype}"344        meta["Author"] = "Simon-Pierre Boucher <contact@spboucher.ai>"345    return buf.getvalue()346