# ============================================================================= # QWHPI — Quebec Weekly Housing Price Index # Author : Simon-Pierre Boucher # Contact : contact@spboucher.ai # File : api/app/services/report.py # Purpose : Automatic PDF report generation — single series, multi-series # comparison, and full market report (matplotlib, palette-styled). # ============================================================================= """PDF report factory. Every report is generated on demand from the canonical monthly parquet: - series report ....... hero metrics grid, index chart with CI band, dollar value chart, volume bars, growth & risk table - comparison page ..... rebased multi-series chart + side-by-side table - market report ....... Quebec overview, regional heatmap table, type panel Styling matches the dashboard palette; every page carries the author credit. """ from __future__ import annotations import io from datetime import date import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt # noqa: E402 import numpy as np # noqa: E402 import polars as pl # noqa: E402 from matplotlib.backends.backend_pdf import PdfPages # noqa: E402 from app.services import data, stats # noqa: E402 # Palette (mirrors web/styles/globals.css light mode) BLUE, ORANGE, AQUA, YELLOW, MAGENTA = "#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4" SERIES_COLORS = [BLUE, ORANGE, AQUA, YELLOW, MAGENTA] INK, INK2, MUTED = "#0b0b0b", "#52514e", "#8a887f" SURFACE, BORDER, BAND = "#fcfcfb", "#dddcd6", (42 / 255, 120 / 255, 214 / 255, 0.14) GOOD, BAD = "#008300", "#e34948" plt.rcParams.update({ "figure.facecolor": SURFACE, "axes.facecolor": SURFACE, "axes.edgecolor": BORDER, "axes.grid": True, "grid.color": BORDER, "grid.linestyle": ":", "grid.linewidth": 0.7, "axes.spines.top": False, "axes.spines.right": False, "text.color": INK, "axes.labelcolor": INK2, "xtick.color": INK2, "ytick.color": INK2, "font.size": 9, }) PAGE = (8.27, 11.69) # A4 portrait def _fmt_pct(v, plus=True): if v is None: return "—" s = f"{v:+.2f}%" if plus else f"{v:.2f}%" return s def _fmt_money(v): return "—" if v is None else f"${v:,.0f}" def _footer(fig, page_note=""): fig.text(0.05, 0.02, "QHPI — Quebec Housing Price Index · Simon-Pierre Boucher · contact@spboucher.ai", fontsize=7, color=MUTED) fig.text(0.95, 0.02, page_note, fontsize=7, color=MUTED, ha="right") def _title_block(fig, title, subtitle): fig.text(0.05, 0.955, title, fontsize=19, fontweight="bold", color=INK) fig.text(0.05, 0.928, subtitle, fontsize=9.5, color=INK2) fig.add_artist(plt.Line2D([0.05, 0.95], [0.915, 0.915], color=BORDER, lw=1, transform=fig.transFigure)) def _metric_cells(fig, y_top, items, ncols=4): """Grid of metric tiles: items = [(label, value, color?)]""" n = len(items) nrows = (n + ncols - 1) // ncols w = 0.9 / ncols h = 0.055 for i, item in enumerate(items): label, value = item[0], item[1] color = item[2] if len(item) > 2 else INK r, c = divmod(i, ncols) x = 0.05 + c * w y = y_top - r * (h + 0.018) fig.text(x, y, label.upper(), fontsize=6.7, color=MUTED) fig.text(x, y - 0.026, value, fontsize=13, fontweight="bold", color=color) return y_top - nrows * (h + 0.018) def _delta_color(v): if v is None: return INK return GOOD if v >= 0 else BAD def _index_axes(ax, df, unit="points", legend=True): periods = df["period"].to_list() x = np.arange(len(periods)) k = 1.0 if unit == "dollars": row = df.filter(pl.col("representative_value").is_not_null()) k = row["representative_value"][0] / row["index_smoothed"][0] smoothed = df["index_smoothed"].to_numpy() * k lo = df["lower_95"].to_numpy() * k hi = df["upper_95"].to_numpy() * k raw = df["index"].to_numpy() * k ax.fill_between(x, lo, hi, color=BAND, label="95% CI", linewidth=0) ax.plot(x, raw, color=BLUE, alpha=0.35, lw=0.8, label="raw monthly") ax.plot(x, smoothed, color=BLUE, lw=1.9, label="index (smoothed)") pad = (smoothed.max() - smoothed.min()) * 0.15 or 1 ax.set_ylim(min(smoothed.min(), np.nanmin(raw)) - pad, max(smoothed.max(), np.nanmax(raw)) + pad) step = max(1, len(x) // 8) ax.set_xticks(x[::step], [p for p in periods[::step]], fontsize=7.5) if unit == "dollars": ax.yaxis.set_major_formatter(lambda v, _: f"${v / 1000:.0f}k") if legend: ax.legend(loc="upper left", fontsize=7.5, frameon=False) def _volume_axes(ax, df): x = np.arange(df.height) ax.bar(x, df["transactions"].to_numpy(), color=BLUE, alpha=0.55, width=0.85) ax.set_xticks([]) ax.tick_params(labelsize=6.5) ax.set_title("Transactions per month", fontsize=8, loc="left", color=INK2) def _series_page(pdf, gid, ptype): df = data.series(gid, ptype).filter(~pl.col("is_partial_month")) st = stats.series_stats(gid, ptype) name = df["geography_name"][0] fig = plt.figure(figsize=PAGE) _title_block(fig, f"{name} — {ptype}", f"Monthly housing price index · base 2021 = 100 · data to {st['period']} · " f"reliability {st['reliability_grade']} · generated {date.today().isoformat()}") y = _metric_cells(fig, 0.875, [ ("Index", f"{st['index']:.1f}"), ("Dollar value", _fmt_money(st["representative_value"])), ("1 month", _fmt_pct(st["monthly_pct"]), _delta_color(st["monthly_pct"])), ("Year over year", _fmt_pct(st["yoy_pct"]), _delta_color(st["yoy_pct"])), ("3 months", _fmt_pct(st["three_month_pct"]), _delta_color(st["three_month_pct"])), ("6 months", _fmt_pct(st["six_month_pct"]), _delta_color(st["six_month_pct"])), ("Since 2021", _fmt_pct(st["since_2021_pct"]), _delta_color(st["since_2021_pct"])), ("CAGR", _fmt_pct(st["cagr_pct"]), _delta_color(st["cagr_pct"])), ("Peak", f"{st['peak_index']:.1f} ({st['peak_period']})"), ("Vs peak", _fmt_pct(st["drawdown_pct"]), GOOD if st["at_record_high"] else BAD), ("Volatility (12m)", _fmt_pct(st["volatility_12m_pct"], plus=False)), ("Momentum (3m ann.)", _fmt_pct(st["momentum_3m_ann_pct"]), _delta_color(st["momentum_3m_ann_pct"])), ("Sales (12m)", f"{st['volume_12m']:,}"), ("Volume YoY", _fmt_pct(st["volume_yoy_pct"]), _delta_color(st["volume_yoy_pct"])), ("$ volume (12m, est.)", f"${st['dollar_volume_12m'] / 1e6:,.0f}M"), ("YoY rank", f"{st['yoy_rank']}/{st['yoy_rank_of']}" if st["yoy_rank"] else "—"), ]) del y # metrics grid occupies down to ~0.56; charts use fixed slots ax1 = fig.add_axes([0.07, 0.345, 0.87, 0.185]) _index_axes(ax1, df) ax1.set_title("Index (2021 = 100) with 95% confidence band", fontsize=9, loc="left", color=INK2) ax2 = fig.add_axes([0.07, 0.125, 0.87, 0.165]) _index_axes(ax2, df, unit="dollars", legend=False) ax2.set_title("Representative dollar value (fixed 2021 basket)", fontsize=9, loc="left", color=INK2) ax3 = fig.add_axes([0.07, 0.042, 0.87, 0.05]) _volume_axes(ax3, df) _footer(fig, f"{gid}:{ptype}") pdf.savefig(fig) plt.close(fig) def _comparison_page(pdf, specs): fig = plt.figure(figsize=PAGE) _title_block(fig, "Comparison", "All series rebased to 100 at the first common month") frames = {} for gid, ptype in specs: frames[(gid, ptype)] = data.series(gid, ptype).filter(~pl.col("is_partial_month")) common = sorted(set.intersection(*[set(f["period"].to_list()) for f in frames.values()])) base_p = common[0] ax = fig.add_axes([0.07, 0.45, 0.87, 0.42]) rows = [] for i, ((gid, ptype), f) in enumerate(frames.items()): f = f.filter(pl.col("period").is_in(common)).sort("period") idx = f["index_smoothed"].to_numpy() rebased = idx / idx[0] * 100 x = np.arange(len(common)) name = f["geography_name"][0] ax.plot(x, rebased, color=SERIES_COLORS[i % 5], lw=1.9, label=f"{name} · {ptype}") st = stats.series_stats(gid, ptype) rows.append([f"{name} · {ptype}", f"{st['index']:.1f}", _fmt_pct(st["yoy_pct"]), _fmt_pct(st["since_2021_pct"]), _fmt_money(st["representative_value"]), st["reliability_grade"]]) step = max(1, len(common) // 8) ax.set_xticks(np.arange(len(common))[::step], common[::step], fontsize=7.5) ax.legend(loc="upper left", fontsize=8, frameon=False) ax.set_title(f"Rebased to 100 at {base_p}", fontsize=9, loc="left", color=INK2) tbl_ax = fig.add_axes([0.07, 0.13, 0.87, 0.24]) tbl_ax.axis("off") table = tbl_ax.table( cellText=rows, colLabels=["Series", "Index", "YoY", "Since 2021", "$ value", "Grade"], loc="upper center", cellLoc="center") table.auto_set_font_size(False) table.set_fontsize(8.5) table.scale(1, 1.6) for (r, c), cell in table.get_celld().items(): cell.set_edgecolor(BORDER) if r == 0: cell.set_text_props(color=INK2, fontweight="bold") cell.set_facecolor("#f0efec") _footer(fig, "comparison") pdf.savefig(fig) plt.close(fig) def build_series_report(specs: list[tuple[str, str]]) -> bytes: buf = io.BytesIO() with PdfPages(buf) as pdf: for gid, ptype in specs: _series_page(pdf, gid, ptype) if len(specs) > 1: _comparison_page(pdf, specs) meta = pdf.infodict() meta["Title"] = "QHPI report — " + ", ".join(f"{g}:{t}" for g, t in specs) meta["Author"] = "Simon-Pierre Boucher " return buf.getvalue() def build_market_report(ptype: str = "all") -> bytes: buf = io.BytesIO() with PdfPages(buf) as pdf: # Page 1 — province _series_page(pdf, "quebec", ptype) # Page 2 — regional heatmap table ov = stats.overview_rows("region", ptype) fig = plt.figure(figsize=PAGE) _title_block(fig, "Regions — market pulse", f"All 17 administrative regions · {ptype} · month of {ov['period']}") rows, colors = [], [] vals = [r["yoy_pct"] for r in ov["rows"] if r["yoy_pct"] is not None] lo, hi = min(vals), max(vals) def heat(v): if v is None: return SURFACE t = 0.5 if hi == lo else (v - lo) / (hi - lo) ramp = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf"] return ramp[min(4, int(t * 5))] for r in ov["rows"]: rows.append([r["geography_name"][:28], f"{r['index']:.1f}", _fmt_pct(r["monthly_pct"]), _fmt_pct(r["three_month_pct"]), _fmt_pct(r["six_month_pct"]), _fmt_pct(r["yoy_pct"]), _fmt_pct(r["drawdown_pct"]), f"{r['volume_12m']:,}", r["reliability_grade"]]) colors.append(heat(r["yoy_pct"])) ax = fig.add_axes([0.05, 0.44, 0.9, 0.44]) ax.axis("off") widths = [0.26] + [0.0925] * 8 table = ax.table( cellText=rows, colLabels=["Region", "Index", "1m", "3m", "6m", "YoY", "Vs peak", "Sales 12m", "Grade"], loc="upper center", cellLoc="center", colWidths=widths) table.auto_set_font_size(False) table.set_fontsize(7.6) table.scale(1, 1.45) for (r, c), cell in table.get_celld().items(): cell.set_edgecolor(BORDER) if c == 0: cell.set_text_props(ha="left") cell.PAD = 0.02 if r == 0: cell.set_text_props(color=INK2, fontweight="bold", ha="left" if c == 0 else "center") cell.set_facecolor("#f0efec") elif c == 5: cell.set_facecolor(colors[r - 1]) if colors[r - 1] in ("#256abf", "#3987e5"): cell.set_text_props(color="white") fig.text(0.05, 0.415, "YoY column shaded by appreciation (sequential blue ramp).", fontsize=7.5, color=MUTED) # YoY horizontal bars under the table axb = fig.add_axes([0.30, 0.075, 0.62, 0.30]) names = [r["geography_name"] for r in ov["rows"]][::-1] yoys = [r["yoy_pct"] or 0 for r in ov["rows"]][::-1] axb.barh(np.arange(len(names)), yoys, color=[heat(v) for v in yoys], height=0.72) axb.set_yticks(np.arange(len(names)), names, fontsize=7) axb.set_title("Year-over-year appreciation (%)", fontsize=9, loc="left", color=INK2) axb.tick_params(axis="x", labelsize=7) _footer(fig, "regions") pdf.savefig(fig) plt.close(fig) # Page 3 — property types (province) fig = plt.figure(figsize=PAGE) _title_block(fig, "Property types — province", "Unifamilial, condo and plex indexes (2021 = 100)") ax = fig.add_axes([0.07, 0.52, 0.87, 0.36]) for i, t in enumerate(["unifamilial", "condo", "plex"]): f = data.series("quebec", t).filter(~pl.col("is_partial_month")).sort("period") x = np.arange(f.height) ax.plot(x, f["index_smoothed"].to_numpy(), color=SERIES_COLORS[i], lw=1.9, label=t) periods = f["period"].to_list() step = max(1, len(periods) // 8) ax.set_xticks(np.arange(len(periods))[::step], periods[::step], fontsize=7.5) ax.legend(loc="upper left", fontsize=8.5, frameon=False) fig.text(0.07, 0.42, "Method", fontsize=11, fontweight="bold") fig.text(0.07, 0.20, "Robust monthly architecture: province paths estimated by rolling-time-dummy hedonic\n" "regressions (13-month Huber-weighted windows, mean-splice linking — the published\n" "history never revises); liquid cells (≥40 transactions/month) estimated directly by a\n" "local robust time-dummy; thin cells shrunk toward their parent path by a heteroskedastic\n" "local-level Kalman filter. Validated against repeat sales, stratified matched-cell\n" "medians, downsampling and composition-shock simulations. Variables with time-correlated\n" "missingness are excluded by rule. All series NSA, base 2021 average = 100.\n\n" "Full methodology: www.indexqc.house/methodology", fontsize=8.5, color=INK2, va="bottom") _footer(fig, "types & method") pdf.savefig(fig) plt.close(fig) meta = pdf.infodict() meta["Title"] = f"QHPI market report — {ptype}" meta["Author"] = "Simon-Pierre Boucher " return buf.getvalue()