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/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