# -*- coding: utf-8 -*- # ============================================================================= # Auteur : Simon-Pierre Boucher # Fonction : Professeur # Département : Département des sciences administratives # Institution : Université du Québec en Outaouais (UQO) # Courriel : simon-pierre.boucher@uqo.ca # ----------------------------------------------------------------------------- # Projet : IMM-QUEBEC — Analyse du marché immobilier résidentiel québécois # Données : (1) Indice des prix des propriétés MLS® (IPP MLS®), ACI/CREA, # janvier 2005 – juin 2026, base 100 = janvier 2005 ; # (2) Séries macroéconomiques canadiennes, FRED (St. Louis Fed). # Rôle : Charge les données, calcule les statistiques et produit # l'ensemble des figures (PDF vectoriel) et des tables LaTeX # utilisées dans rapport_immobilier_quebec.tex # Exécution : python3 code/analyse_hpi_quebec.py (depuis la racine du projet) # ============================================================================= import json import os import urllib.request from pathlib import Path import numpy as np import pandas as pd import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.dates as mdates from matplotlib.colors import LinearSegmentedColormap, TwoSlopeNorm from matplotlib.patches import Patch from matplotlib.ticker import FuncFormatter # ----------------------------------------------------------------------------- # Chemins et constantes # ----------------------------------------------------------------------------- ROOT = Path(__file__).resolve().parent.parent DATA = ROOT / "data" FIG = ROOT / "figures" TAB = ROOT / "tables" FREDCACHE = DATA / "fred" for p in (FIG, TAB, FREDCACHE): p.mkdir(exist_ok=True) FRED_KEY = os.environ.get("FRED_API_KEY", "5d6f76382d7d188e9166bb3b96c8f934") SRC_ACI = "Source : ACI/CREA, indice des prix des propriétés MLS® — calculs de l'auteur (S.-P. Boucher, UQO)." SRC_FRED = "Source : FRED, Federal Reserve Bank of St. Louis (données OCDE/StatCan) — calculs de l'auteur (S.-P. Boucher, UQO)." SRC_MIX = "Sources : ACI/CREA et FRED — calculs de l'auteur (S.-P. Boucher, UQO)." # ----------------------------------------------------------------------------- # Charte graphique (palette validée — ordre catégoriel fixe, jamais recyclé) # ----------------------------------------------------------------------------- PAL = { "blue": "#2a78d6", "orange": "#eb6834", "aqua": "#1baf7a", "yellow": "#eda100", "magenta": "#e87ba4", "green": "#008300", "violet": "#4a3aa7", "red": "#e34948", } CAT_ORDER = ["blue", "orange", "aqua", "yellow", "magenta", "green", "violet", "red"] INK = "#0b0b0b" INK2 = "#52514e" MUTED = "#898781" GRID = "#e1e0d9" AXIS = "#c3c2b7" plt.rcParams.update({ "font.family": "sans-serif", "font.sans-serif": ["Helvetica Neue", "Helvetica", "Arial", "DejaVu Sans"], "font.size": 10, "axes.labelsize": 9.5, "axes.labelcolor": INK2, "axes.edgecolor": AXIS, "axes.linewidth": 0.8, "xtick.color": INK2, "ytick.color": INK2, "xtick.labelsize": 9, "ytick.labelsize": 9, "legend.frameon": False, "legend.fontsize": 9.5, "figure.facecolor": "white", "axes.facecolor": "white", "savefig.dpi": 300, }) SEQ_CMAP = LinearSegmentedColormap.from_list( "seq_blue", ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"] ) DIV_CMAP = LinearSegmentedColormap.from_list( "div_bluered", ["#104281", "#3987e5", "#9ec5f4", "#f0efec", "#f2a1a0", "#e34948", "#8f1d1c"] ) EVENTS = [ (pd.Timestamp("2008-09-01"), pd.Timestamp("2009-06-01"), "Crise\nfinancière"), (pd.Timestamp("2020-03-01"), pd.Timestamp("2020-08-01"), "Pandémie"), (pd.Timestamp("2022-03-01"), pd.Timestamp("2023-07-01"), "Resserrement\nmonétaire"), ] # ============================================================================= # Boîte à outils graphique # ============================================================================= def new_fig(w=10.5, h=5.5, left=0.072, right=0.9, top=0.80, bottom=0.115): fig, ax = plt.subplots(figsize=(w, h)) fig.subplots_adjust(left=left, right=right, top=top, bottom=bottom) return fig, ax def style_ax(ax, ygrid=True): """Grille discrète, axes en retrait (chrome récessif).""" for side in ("top", "right", "left"): ax.spines[side].set_visible(False) ax.spines["bottom"].set_color(AXIS) if ygrid: ax.grid(axis="y", color=GRID, linewidth=0.7) ax.set_axisbelow(True) ax.tick_params(length=0) def event_bands(ax, labels=True, y=0.985): """Bandes grisées des grands épisodes macro-financiers.""" for a, b, lab in EVENTS: ax.axvspan(a, b, color=INK, alpha=0.05, zorder=0, lw=0) if labels: mid = a + (b - a) / 2 ax.text(mid, y, lab, transform=ax.get_xaxis_transform(), fontsize=7.3, color=MUTED, ha="center", va="top", linespacing=1.1) def finish(fig, ax, title, subtitle, source, fname, legend_ncols=0): """Titre + sous-titre alignés à gauche, légende horizontale, source en pied.""" if legend_ncols: ax.legend(loc="lower left", bbox_to_anchor=(-0.005, 1.01), ncols=legend_ncols, columnspacing=1.3, handlelength=1.5, handletextpad=0.5, borderaxespad=0) fig.text(0.012, 0.955, title, fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.902, subtitle, fontsize=10, color=INK2) fig.text(0.012, 0.022, source, fontsize=7.6, color=MUTED) fig.savefig(FIG / fname) plt.close(fig) def date_axis(ax, start="2004-08-01", end=None, step=2): ax.set_xlim(pd.Timestamp(start), end) ax.xaxis.set_major_locator(mdates.YearLocator(step)) ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) def end_labels(ax, items, fmt): """Étiquettes de fin de série (valeur), avec anti-chevauchement vertical.""" ymin, ymax = ax.get_ylim() gap = (ymax - ymin) * 0.052 items = sorted(items, key=lambda t: t[1]) ys = [] for _, y, _ in items: yy = y if not ys else max(y, ys[-1] + gap) ys.append(yy) for (x, y, col), yy in zip(items, ys): ax.plot([x], [y], "o", ms=4.5, color=col, zorder=5, clip_on=False) ax.annotate(fmt(y), (x, yy), xytext=(7, 0), textcoords="offset points", va="center", ha="left", fontsize=8.6, fontweight="bold", color=col, clip_on=False, annotation_clip=False) def fr(v, dec=1): return f"{v:,.{dec}f}".replace(",", " ").replace(".", ",") def kfmt(v, _=None): return f"{v:,.0f}".replace(",", " ") # ============================================================================= # Chargement des données ACI/CREA # ============================================================================= F_M_NSA = DATA / "Not Seasonally Adjusted (M).xlsx" F_M_SA = DATA / "Seasonally Adjusted (M).xlsx" F_A = DATA / "Not Seasonally Adjusted (A).xlsx" NOMS = { "AGGREGATE": "Canada", "QUEBEC": "Québec (province)", "MONTREAL_CMA": "RMR de Montréal", "QUEBEC_CMA": "RMR de Québec", "ESTRIE": "Estrie", "MAURICIE": "Mauricie", "CENTRE_DU_QUEBEC": "Centre-du-Québec", "ONTARIO": "Ontario", "BRITISH_COLUMBIA": "Colombie-Britannique", "ALBERTA": "Alberta", "GREATER_TORONTO": "Grand Toronto", "GREATER_VANCOUVER": "Grand Vancouver", "OTTAWA": "Ottawa", "CALGARY": "Calgary", "HALIFAX_DARTMOUTH": "Halifax-Dartmouth", "WINNIPEG": "Winnipeg", } QC_REGIONS = ["QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE", "CENTRE_DU_QUEBEC"] MOIS_FR = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juill.", "août", "sept.", "oct.", "nov.", "déc."] def load_sheet(path, sheet): df = pd.read_excel(path, sheet_name=sheet) df.columns = [c.replace("_SA", "") for c in df.columns] df["Date"] = pd.to_datetime(df["Date"]) return df.set_index("Date") print("Chargement des données ACI/CREA…") nsa = {s: load_sheet(F_M_NSA, s) for s in NOMS} sa = {s: load_sheet(F_M_SA, s) for s in NOMS} annual = pd.read_excel(F_A, sheet_name=None) LAST = nsa["QUEBEC"].index[-1] XEND = LAST + pd.DateOffset(months=3) print(f"Dernière observation IPP : {LAST:%Y-%m}") # ============================================================================= # Chargement des données macroéconomiques (FRED, avec cache local) # ============================================================================= def fred(series_id): cache = FREDCACHE / f"{series_id}.json" if cache.exists(): payload = json.loads(cache.read_text()) else: url = ("https://api.stlouisfed.org/fred/series/observations" f"?series_id={series_id}&api_key={FRED_KEY}&file_type=json" "&observation_start=2003-01-01") with urllib.request.urlopen(url, timeout=60) as r: payload = json.load(r) cache.write_text(json.dumps(payload)) obs = {pd.Timestamp(o["date"]): float(o["value"]) for o in payload["observations"] if o["value"] != "."} return pd.Series(obs).sort_index() print("Chargement des données FRED…") taux3m = fred("IR3TIB01CAM156N") # taux interbancaire 3 mois, Canada taux10a = fred("IRLTLT01CAM156N") # rendement obligataire 10 ans, Canada infl = fred("CPALTT01CAM659N") # inflation IPC, glissement annuel ipc = fred("CANCPIALLMINMEI") # IPC, niveau (2015 = 100) chomage = fred("LRUNTTTTCAM156S") # taux de chômage, Canada pib = fred("NGDPRSAXDCCAQ") # PIB réel trimestriel, Canada pib_yoy = pib.pct_change(4) * 100 print(f"FRED : taux jusqu'à {taux3m.index[-1]:%Y-%m}, IPC jusqu'à {ipc.index[-1]:%Y-%m}") # ============================================================================= # Fonctions statistiques # ============================================================================= def yoy(series, k=12): return series.pct_change(k) * 100 def cagr(series, start=None, end=None): s = series.dropna() if start is not None: s = s[s.index >= start] if end is not None: s = s[s.index <= end] years = (s.index[-1] - s.index[0]).days / 365.25 return ((s.iloc[-1] / s.iloc[0]) ** (1 / years) - 1) * 100 def drawdown(series): s = series.dropna() return (s / s.cummax() - 1) * 100 def fr_num(x, dec=1): return f"{x:,.{dec}f}".replace(",", "~").replace(".", ",") def fr_money(x): return fr_num(x, 0) + "~\\$" def fr_pct(x, dec=1, sign=False): s = fr_num(x, dec) if sign and x > 0: s = "+" + s return s + "~\\%" # ============================================================================= # SECTION MACRO — figures FRED # ============================================================================= print("Figures macroéconomiques…") # --- macro01 : taux d'intérêt ------------------------------------------------- fig, ax = new_fig() t3 = taux3m[taux3m.index >= "2004-08-01"] t10 = taux10a[taux10a.index >= "2004-08-01"] ax.plot(t3.index, t3.values, color=PAL["blue"], lw=2.1, label="Taux court (3 mois)") ax.plot(t10.index, t10.values, color=PAL["orange"], lw=2.1, label="Obligations 10 ans") event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Taux (%)") end_labels(ax, [(t3.index[-1], t3.iloc[-1], PAL["blue"]), (t10.index[-1], t10.iloc[-1], PAL["orange"])], lambda v: fr(v, 1) + " %") finish(fig, ax, "Le loyer de l'argent : vingt ans de taux canadiens", "Taux interbancaire 3 mois et rendement des obligations fédérales 10 ans, en % — janv. 2005 à juin 2026", SRC_FRED, "macro01_taux.pdf", legend_ncols=2) # --- macro02 : inflation ------------------------------------------------------ fig, ax = new_fig() ii = infl[infl.index >= "2004-08-01"] ax.axhspan(1, 3, color="#cde2fb", alpha=0.55, zorder=0, lw=0) ax.text(pd.Timestamp("2005-01-01"), 2.62, "Fourchette cible de la Banque du Canada (1–3 %)", fontsize=8, color="#1c5cab", va="center") ax.plot(ii.index, ii.values, color=PAL["blue"], lw=2.1, label="Inflation IPC (glissement annuel)") ax.axhline(0, color=AXIS, lw=0.8) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Variation sur 12 mois (%)") end_labels(ax, [(ii.index[-1], ii.iloc[-1], PAL["blue"])], lambda v: fr(v, 1) + " %") finish(fig, ax, "L'inflation canadienne : la poussée de 2021-2022 et sa résorption", "Variation sur 12 mois de l'IPC d'ensemble, Canada, en % — la bande bleue marque la cible de 1 à 3 %", SRC_FRED, "macro02_inflation.pdf", legend_ncols=1) # --- macro03 : croissance du PIB réel ---------------------------------------- fig, ax = new_fig() g = pib_yoy[pib_yoy.index >= "2004-10-01"] colors = [PAL["blue"] if v >= 0 else PAL["red"] for v in g.values] ax.bar(g.index, g.values, width=80, color=colors, zorder=3) ax.axhline(0, color=AXIS, lw=0.8) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Variation sur 4 trimestres (%)") ax.legend(handles=[Patch(color=PAL["blue"], label="Croissance"), Patch(color=PAL["red"], label="Contraction")], loc="lower left", bbox_to_anchor=(-0.005, 1.01), ncols=2, columnspacing=1.3, handlelength=1.5, handletextpad=0.5, borderaxespad=0) fig.text(0.012, 0.955, "L'économie réelle : croissance du PIB canadien", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.902, "PIB réel trimestriel, variation sur 4 trimestres, en % — deux chocs : 2009 et 2020", fontsize=10, color=INK2) fig.text(0.012, 0.022, SRC_FRED, fontsize=7.6, color=MUTED) fig.savefig(FIG / "macro03_pib.pdf") plt.close(fig) # --- macro04 : chômage -------------------------------------------------------- fig, ax = new_fig() u = chomage[chomage.index >= "2004-08-01"] ax.plot(u.index, u.values, color=PAL["blue"], lw=2.1, label="Taux de chômage (15 ans et +, dés.)") event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Taux de chômage (%)") end_labels(ax, [(u.index[-1], u.iloc[-1], PAL["blue"])], lambda v: fr(v, 1) + " %") finish(fig, ax, "Le marché du travail canadien", "Taux de chômage mensuel désaisonnalisé, 15 ans et plus, en % — le pic pandémique de 2020 dépasse 13 %", SRC_FRED, "macro04_chomage.pdf", legend_ncols=1) # --- macro05 : transmission taux -> prix (2 panneaux) -------------------------- fig, axes = plt.subplots(2, 1, figsize=(10.5, 7.2), sharex=True, height_ratios=[1, 1.25]) fig.subplots_adjust(left=0.072, right=0.9, top=0.855, bottom=0.085, hspace=0.14) ax1, ax2 = axes ax1.plot(t3.index, t3.values, color=PAL["violet"], lw=2.1, label="Taux court 3 mois (%)") ax1.set_ylabel("Taux (%)") event_bands(ax1, labels=False) style_ax(ax1) ax1.legend(loc="upper left", handlelength=1.5) y_qc = yoy(nsa["QUEBEC"]["Composite_HPI"]) y_ca = yoy(nsa["AGGREGATE"]["Composite_HPI"]) ax2.axhline(0, color=AXIS, lw=0.8) ax2.plot(y_ca.index, y_ca.values, color=PAL["blue"], lw=2.1, label="IPP Canada (var. 12 mois, %)") ax2.plot(y_qc.index, y_qc.values, color=PAL["orange"], lw=2.1, label="IPP Québec (var. 12 mois, %)") event_bands(ax2, labels=False) style_ax(ax2) ax2.legend(loc="upper left", ncols=2, handlelength=1.5) ax2.set_ylabel("Variation sur 12 mois (%)") date_axis(ax2, end=XEND) end_labels(ax2, [(y_ca.index[-1], y_ca.iloc[-1], PAL["blue"]), (y_qc.index[-1], y_qc.iloc[-1], PAL["orange"])], lambda v: fr(v, 1) + " %") fig.text(0.012, 0.965, "La transmission monétaire : mêmes taux, réponses opposées", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.925, "Haut : taux court canadien. Bas : croissance des prix de l'habitation — " "le choc de taux de 2022 fait plonger le Canada, le Québec ne fait que ralentir", fontsize=10, color=INK2) fig.text(0.012, 0.018, SRC_MIX, fontsize=7.6, color=MUTED) fig.savefig(FIG / "macro05_transmission.pdf") plt.close(fig) # --- macro06 : prix nominal vs réel ------------------------------------------- fig, ax = new_fig() h_qc = nsa["QUEBEC"]["Composite_HPI"] ipc_m = ipc.reindex(h_qc.index).ffill() reel = (h_qc / ipc_m) * ipc_m.iloc[0] reel = reel[reel.index <= ipc.index[-1]] ax.plot(h_qc.index, h_qc.values, color=PAL["blue"], lw=2.1, label="IPP nominal") ax.plot(reel.index, reel.values, color=PAL["orange"], lw=2.1, label="IPP réel (déflaté par l'IPC)") ax.axhline(100, color=AXIS, lw=0.8) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Indice (janv. 2005 = 100)") end_labels(ax, [(h_qc.index[-1], h_qc.iloc[-1], PAL["blue"]), (reel.index[-1], reel.iloc[-1], PAL["orange"])], lambda v: fr(v, 0)) finish(fig, ax, "Au-delà de l'inflation : le prix réel des propriétés québécoises", "IPP composite du Québec, nominal et déflaté par l'IPC canadien (janv. 2005 = 100) — série réelle jusqu'en mars 2025", SRC_MIX, "macro06_reel.pdf", legend_ncols=2) reel_mult = reel.iloc[-1] / 100 reel_cagr = cagr(reel) # ============================================================================= # FIGURES PRINCIPALES — IPP MLS® # ============================================================================= print("Figures IPP…") # --- fig01 : provinces --------------------------------------------------------- fig, ax = new_fig(top=0.78) series_f1 = [ ("AGGREGATE", "Canada", PAL["blue"]), ("QUEBEC", "Québec", PAL["orange"]), ("ONTARIO", "Ontario", PAL["aqua"]), ("BRITISH_COLUMBIA", "Colombie-Britannique", PAL["yellow"]), ("ALBERTA", "Alberta", PAL["magenta"]), ] ends = [] for sheet, lab, col in series_f1: s = nsa[sheet]["Composite_HPI"] ax.plot(s.index, s.values, color=col, lw=2.1, label=lab) ends.append((s.index[-1], s.iloc[-1], col)) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("IPP composite (janv. 2005 = 100)") end_labels(ax, ends, lambda v: fr(v, 0)) finish(fig, ax, "Vingt ans de prix immobiliers : le Québec dépasse le Canada", "IPP MLS® composite, janv. 2005 = 100 — l'Ontario et la C.-B. corrigent depuis 2022, le Québec poursuit sa hausse", SRC_ACI, "fig01_hpi_provinces.pdf", legend_ncols=5) # --- fig02 : régions du Québec -------------------------------------------------- fig, ax = new_fig(top=0.78) ends = [] for sheet, colk in zip(QC_REGIONS, CAT_ORDER[:6]): s = nsa[sheet]["Composite_HPI"] ax.plot(s.index, s.values, color=PAL[colk], lw=2.1, label=NOMS[sheet]) ends.append((s.index[-1], s.iloc[-1], PAL[colk])) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("IPP composite (janv. 2005 = 100)") end_labels(ax, ends, lambda v: fr(v, 0)) finish(fig, ax, "Le grand rattrapage des régions québécoises", "IPP MLS® composite des six marchés québécois, janv. 2005 = 100 — les régions dépassent Montréal après 2020", SRC_ACI, "fig02_hpi_quebec_regions.pdf", legend_ncols=6) # --- fig03 : prix de référence -------------------------------------------------- fig, ax = new_fig(top=0.78) series_f3 = [ ("AGGREGATE", "Canada", PAL["blue"]), ("QUEBEC", "Québec (province)", PAL["orange"]), ("MONTREAL_CMA", "RMR de Montréal", PAL["aqua"]), ("QUEBEC_CMA", "RMR de Québec", PAL["yellow"]), ] ends = [] for sheet, lab, col in series_f3: s = nsa[sheet]["Composite_Benchmark"] / 1000 ax.plot(s.index, s.values, color=col, lw=2.1, label=lab) ends.append((s.index[-1], s.iloc[-1], col)) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Prix de référence (milliers de $)") ax.yaxis.set_major_formatter(FuncFormatter(kfmt)) end_labels(ax, ends, lambda v: fr(v, 0) + " k$") finish(fig, ax, "Combien coûte la propriété type ? L'écart Québec-Canada se referme", "Prix de référence composite, en milliers de dollars courants — janv. 2005 à juin 2026", SRC_ACI, "fig03_benchmark.pdf", legend_ncols=4) # --- fig04 : glissement annuel QC vs Canada ------------------------------------- fig, ax = new_fig(top=0.78) ax.axhline(0, color=AXIS, lw=0.8) ax.plot(y_ca.index, y_ca.values, color=PAL["blue"], lw=2.1, label="Canada") ax.plot(y_qc.index, y_qc.values, color=PAL["orange"], lw=2.1, label="Québec") event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Variation sur 12 mois (%)") end_labels(ax, [(y_ca.index[-1], y_ca.iloc[-1], PAL["blue"]), (y_qc.index[-1], y_qc.iloc[-1], PAL["orange"])], lambda v: fr(v, 1) + " %") finish(fig, ax, "Deux cycles qui divergent depuis 2024", "Variation sur 12 mois de l'IPP composite, en % — hausse soutenue au Québec, prix en baisse au Canada", SRC_ACI, "fig04_yoy.pdf", legend_ncols=2) # --- fig05 : types de propriété, Québec ----------------------------------------- TYPES5 = { "Single_Family_HPI": "Unifamiliale", "One_Storey_HPI": "Plain-pied", "Two_Storey_HPI": "À étages", "Townhouse_HPI": "En rangée", "Apartment_HPI": "Appartement", } fig, ax = new_fig(top=0.78) ends = [] for (col_name, lab), colk in zip(TYPES5.items(), CAT_ORDER[:5]): s = nsa["QUEBEC"][col_name] ax.plot(s.index, s.values, color=PAL[colk], lw=2.1, label=lab) ends.append((s.index[-1], s.iloc[-1], PAL[colk])) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("IPP (janv. 2005 = 100)") end_labels(ax, ends, lambda v: fr(v, 0)) finish(fig, ax, "La prime au terrain : maisons contre copropriétés", "IPP MLS® par type de propriété, Québec, janv. 2005 = 100 — l'appartement décroche après 2012", SRC_ACI, "fig05_types_quebec.pdf", legend_ncols=5) # --- fig06 : carte thermique annuelle ------------------------------------------- heat_rows = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO", "GREATER_TORONTO", "BRITISH_COLUMBIA", "GREATER_VANCOUVER", "ALBERTA", "CALGARY"] years = None mat = [] for sheet in heat_rows: da = annual[sheet].set_index("Date")["Composite_HPI"] g = (da.pct_change() * 100).dropna() if years is None: years = g.index.tolist() mat.append(g.reindex(years).values) mat = np.array(mat) fig, ax = plt.subplots(figsize=(10.5, 5.6)) fig.subplots_adjust(left=0.155, right=0.985, top=0.845, bottom=0.09) vmax = np.nanmax(np.abs(mat)) im = ax.imshow(mat, aspect="auto", cmap=DIV_CMAP, norm=TwoSlopeNorm(vcenter=0, vmin=-vmax, vmax=vmax)) ax.set_xticks(range(len(years))) ax.set_xticklabels([str(y) for y in years], fontsize=8.5) ax.set_yticks(range(len(heat_rows))) ax.set_yticklabels([NOMS[s] for s in heat_rows], fontsize=9) for i in range(mat.shape[0]): for j in range(mat.shape[1]): v = mat[i, j] if np.isfinite(v): ax.text(j, i, f"{v:.0f}", ha="center", va="center", fontsize=7.2, color="white" if abs(v) / vmax > 0.5 else INK2) ax.set_xticks(np.arange(-0.5, len(years), 1), minor=True) ax.set_yticks(np.arange(-0.5, len(heat_rows), 1), minor=True) ax.grid(which="minor", color="white", linewidth=1.6) ax.tick_params(which="both", length=0) for s in ax.spines.values(): s.set_visible(False) fig.text(0.012, 0.955, "Vingt ans d'histoire en une image : les variations annuelles par marché", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.905, "Variation annuelle de l'IPP composite, en % — rouge : hausse ; bleu : baisse. " "Lire la divergence Québec / Ontario-C.-B. après 2022.", fontsize=10, color=INK2) fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "fig06_heatmap_yoy.pdf") plt.close(fig) # --- fig07 : repli depuis le sommet --------------------------------------------- fig, ax = new_fig(top=0.78) series_f7 = [ ("AGGREGATE", "Canada", PAL["blue"]), ("QUEBEC", "Québec", PAL["orange"]), ("GREATER_TORONTO", "Grand Toronto", PAL["aqua"]), ("GREATER_VANCOUVER", "Grand Vancouver", PAL["yellow"]), ] ends = [] for sheet, lab, col in series_f7: dd = drawdown(sa[sheet]["Composite_HPI"]) ax.plot(dd.index, dd.values, color=col, lw=2.1, label=lab) ends.append((dd.index[-1], dd.iloc[-1], col)) ax.axhline(0, color=AXIS, lw=0.8) event_bands(ax, y=0.13) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Écart au sommet historique (%)") end_labels(ax, ends, lambda v: fr(v, 1) + " %") finish(fig, ax, "La correction que le Québec n'a pas eue", "Écart au sommet historique de l'IPP composite désaisonnalisé, en % — Toronto reste 26 % sous son pic de 2022", SRC_ACI, "fig07_drawdown.pdf", legend_ncols=4) # --- fig08 : saisonnalité ------------------------------------------------------- ratio = (nsa["QUEBEC"]["Composite_HPI"] / sa["QUEBEC"]["Composite_HPI"] - 1) * 100 saison = ratio.groupby(ratio.index.month).mean() fig, ax = new_fig(w=9, h=4.6, right=0.97) colors = [PAL["blue"] if v >= 0 else PAL["red"] for v in saison.values] ax.bar(range(1, 13), saison.values, color=colors, width=0.62, zorder=3) for m, v in saison.items(): ax.text(m, v + (0.05 if v >= 0 else -0.05), fr(v, 2).replace(",", ",\u200a"), ha="center", va="bottom" if v >= 0 else "top", fontsize=8.2, color=INK2) ax.axhline(0, color=AXIS, lw=0.8) ax.set_xticks(range(1, 13)) ax.set_xticklabels(MOIS_FR) ax.set_ylabel("Écart brut / désaisonnalisé (%)") ax.set_ylim(saison.min() - 0.45, saison.max() + 0.45) style_ax(ax) ax.legend(handles=[Patch(color=PAL["blue"], label="Prix au-dessus de la tendance"), Patch(color=PAL["red"], label="Prix sous la tendance")], loc="lower left", bbox_to_anchor=(-0.005, 1.01), ncols=2, columnspacing=1.3, handlelength=1.5, handletextpad=0.5, borderaxespad=0) fig.text(0.012, 0.955, "Le rythme des saisons : acheter en décembre, vendre en avril", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.902, "Facteur saisonnier moyen de l'IPP composite québécois, par mois, 2005-2026, en %", fontsize=10, color=INK2) fig.text(0.012, 0.022, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "fig08_saisonnalite.pdf") plt.close(fig) # --- fig09 : matrice de corrélations -------------------------------------------- corr_sheets = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO", "GREATER_TORONTO", "BRITISH_COLUMBIA", "GREATER_VANCOUVER", "ALBERTA"] dfc = pd.DataFrame({NOMS[s]: yoy(nsa[s]["Composite_HPI"]) for s in corr_sheets}).dropna() C = dfc.corr() fig, ax = plt.subplots(figsize=(9.4, 8.2)) fig.subplots_adjust(left=0.20, right=0.98, top=0.83, bottom=0.185) im = ax.imshow(C.values, cmap=DIV_CMAP, vmin=-1, vmax=1) ax.set_xticks(range(len(C))) ax.set_xticklabels(C.columns, rotation=42, ha="right", fontsize=8.7) ax.set_yticks(range(len(C))) ax.set_yticklabels(C.columns, fontsize=8.7) for i in range(len(C)): for j in range(len(C)): v = C.values[i, j] ax.text(j, i, f"{v:.2f}".replace(".", ","), ha="center", va="center", fontsize=7.4, color="white" if abs(v) > 0.72 else INK2) ax.set_xticks(np.arange(-0.5, len(C), 1), minor=True) ax.set_yticks(np.arange(-0.5, len(C), 1), minor=True) ax.grid(which="minor", color="white", linewidth=1.6) ax.tick_params(which="both", length=0) for s in ax.spines.values(): s.set_visible(False) fig.text(0.012, 0.965, "Deux blocs étanches : le cycle québécois n'est pas le cycle canadien", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.925, "Corrélations des variations sur 12 mois de l'IPP composite, 2006-2026 — " "rouge foncé : co-mouvement fort", fontsize=10, color=INK2) fig.text(0.012, 0.014, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "fig09_correlations.pdf") plt.close(fig) # --- fig10 : croissance annuelle moyenne 2005-2026 ------------------------------- bars_sheets = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO", "GREATER_TORONTO", "OTTAWA", "BRITISH_COLUMBIA", "GREATER_VANCOUVER", "ALBERTA", "CALGARY", "HALIFAX_DARTMOUTH", "WINNIPEG"] cg = {NOMS[s]: cagr(nsa[s]["Composite_HPI"]) for s in bars_sheets} qc_set = {NOMS[s] for s in QC_REGIONS} cg = dict(sorted(cg.items(), key=lambda kv: kv[1])) fig, ax = plt.subplots(figsize=(9.4, 6.4)) fig.subplots_adjust(left=0.21, right=0.955, top=0.845, bottom=0.10) labels = list(cg.keys()) vals = list(cg.values()) colors = [PAL["orange"] if l in qc_set else PAL["blue"] for l in labels] ax.barh(range(len(vals)), vals, color=colors, height=0.62, zorder=3) ax.set_yticks(range(len(labels))) ax.set_yticklabels(labels, fontsize=9.5) for i, v in enumerate(vals): ax.text(v + 0.07, i, fr(v, 1) + " %", va="center", fontsize=8.6, color=INK2) ax.set_xlabel("Croissance annuelle moyenne de l'IPP composite (%)") ax.set_xlim(0, max(vals) * 1.13) style_ax(ax, ygrid=False) ax.grid(axis="x", color=GRID, linewidth=0.7) ax.legend(handles=[Patch(color=PAL["orange"], label="Marchés québécois"), Patch(color=PAL["blue"], label="Reste du Canada")], loc="lower right") fig.text(0.012, 0.955, "Palmarès 2005-2026 : les quatre premiers sont québécois", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.91, "Croissance annuelle moyenne composée de l'IPP composite, seize marchés canadiens, en %", fontsize=10, color=INK2) fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "fig10_cagr.pdf") plt.close(fig) # --- fig11 : prix de référence par type, Montréal -------------------------------- BENCH_TYPES = { "Single_Family_Benchmark": "Unifamiliale", "One_Storey_Benchmark": "Plain-pied", "Two_Storey_Benchmark": "À étages", "Townhouse_Benchmark": "En rangée", "Apartment_Benchmark": "Appartement", } fig, ax = new_fig(top=0.78) ends = [] for (col_name, lab), colk in zip(BENCH_TYPES.items(), CAT_ORDER[:5]): s = nsa["MONTREAL_CMA"][col_name] / 1000 ax.plot(s.index, s.values, color=PAL[colk], lw=2.1, label=lab) ends.append((s.index[-1], s.iloc[-1], PAL[colk])) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Prix de référence (milliers de $)") ax.yaxis.set_major_formatter(FuncFormatter(kfmt)) end_labels(ax, ends, lambda v: fr(v, 0) + " k$") finish(fig, ax, "L'éventail montréalais : de 447 à 841 milliers de dollars selon le segment", "Prix de référence par type de propriété, RMR de Montréal, milliers de dollars courants", SRC_ACI, "fig11_types_montreal.pdf", legend_ncols=5) # --- fig12 : depuis 2020 ---------------------------------------------------------- fig, ax = new_fig(top=0.78) base = pd.Timestamp("2020-01-01") series_f12 = [("AGGREGATE", "Canada", PAL["blue"])] + \ [(s, NOMS[s], PAL[c]) for s, c in zip(QC_REGIONS, CAT_ORDER[1:7])] ends = [] for sheet, lab, col in series_f12: s = nsa[sheet]["Composite_HPI"] s = s[s.index >= base] s = s / s.iloc[0] * 100 ax.plot(s.index, s.values, color=col, lw=2.1, label=lab) ends.append((s.index[-1], s.iloc[-1], col)) ax.axhline(100, color=AXIS, lw=0.8) style_ax(ax) ax.set_xlim(base, LAST + pd.DateOffset(months=1)) ax.xaxis.set_major_locator(mdates.YearLocator(1)) ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) ax.set_ylabel("IPP composite (janv. 2020 = 100)") end_labels(ax, ends, lambda v: "+" + fr(v - 100, 0) + " %") finish(fig, ax, "Depuis la pandémie : +66 % au Québec, +26 % au Canada", "IPP composite rebasé à 100 en janvier 2020 — étiquettes : croissance cumulée depuis janv. 2020", SRC_ACI, "fig12_depuis2020.pdf", legend_ncols=4) # ============================================================================= # FIGURES ANALYTIQUES SUPPLÉMENTAIRES # ============================================================================= print("Figures analytiques…") # --- fig13 : petits multiples, glissement annuel par région ---------------------- fig, axes = plt.subplots(2, 3, figsize=(11.5, 6.4), sharex=True, sharey=True) fig.subplots_adjust(left=0.06, right=0.985, top=0.775, bottom=0.09, hspace=0.34, wspace=0.07) y_can = yoy(nsa["AGGREGATE"]["Composite_HPI"]) for axx, sheet in zip(axes.flat, QC_REGIONS): yy_r = yoy(nsa[sheet]["Composite_HPI"]) axx.axhline(0, color=AXIS, lw=0.7) axx.plot(y_can.index, y_can.values, color=AXIS, lw=1.3) axx.plot(yy_r.index, yy_r.values, color=PAL["orange"], lw=1.9) axx.set_title(NOMS[sheet], fontsize=10, fontweight="bold", color=INK, pad=4) style_ax(axx) axx.xaxis.set_major_locator(mdates.YearLocator(5)) axx.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) axx.text(0.02, 0.93, f"+{fr(yy_r.iloc[-1], 1)} %" if yy_r.iloc[-1] > 0 else f"{fr(yy_r.iloc[-1], 1)} %", transform=axx.transAxes, fontsize=9.5, fontweight="bold", color=PAL["orange"], va="top") handles = [plt.Line2D([], [], color=PAL["orange"], lw=1.9, label="Marché régional"), plt.Line2D([], [], color=AXIS, lw=1.3, label="Canada (référence)")] fig.legend(handles=handles, loc="lower left", bbox_to_anchor=(0.052, 0.845), ncols=2, frameon=False) fig.text(0.012, 0.955, "Six marchés, un même tournant : le glissement annuel région par région", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.912, "Variation sur 12 mois de l'IPP composite, en % — la valeur affichée est celle de juin 2026 ; " "gris : Canada", fontsize=10, color=INK2) fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "fig13_smallmultiples_yoy.pdf") plt.close(fig) # --- fig14 : volatilité mobile ---------------------------------------------------- fig, ax = new_fig(top=0.78) vol_ca = (sa["AGGREGATE"]["Composite_HPI"].pct_change() .rolling(24).std() * np.sqrt(12) * 100) vol_qc = (sa["QUEBEC"]["Composite_HPI"].pct_change() .rolling(24).std() * np.sqrt(12) * 100) ax.plot(vol_ca.index, vol_ca.values, color=PAL["blue"], lw=2.1, label="Canada") ax.plot(vol_qc.index, vol_qc.values, color=PAL["orange"], lw=2.1, label="Québec") event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Volatilité annualisée (%)") end_labels(ax, [(vol_ca.index[-1], vol_ca.iloc[-1], PAL["blue"]), (vol_qc.index[-1], vol_qc.iloc[-1], PAL["orange"])], lambda v: fr(v, 1) + " %") finish(fig, ax, "Le risque de prix : les grands emballements sont canadiens", "Volatilité annualisée des variations mensuelles désaisonnalisées, fenêtre de 24 mois, en % — " "les grands pics (2009, 2018, 2022-2023) sont canadiens", SRC_ACI, "fig14_volatilite.pdf", legend_ncols=2) # --- fig15 : corrélation mobile ---------------------------------------------------- fig, ax = new_fig(top=0.78) r_mtl = sa["MONTREAL_CMA"]["Composite_HPI"].pct_change() r_tor = sa["GREATER_TORONTO"]["Composite_HPI"].pct_change() r_qc = sa["QUEBEC"]["Composite_HPI"].pct_change() r_ca = sa["AGGREGATE"]["Composite_HPI"].pct_change() c1 = r_mtl.rolling(60).corr(r_tor) c2 = r_qc.rolling(60).corr(r_ca) ax.plot(c2.index, c2.values, color=PAL["blue"], lw=2.1, label="Québec / Canada") ax.plot(c1.index, c1.values, color=PAL["orange"], lw=2.1, label="Montréal / Grand Toronto") ax.axhline(0, color=AXIS, lw=0.8) ax.set_ylim(-0.65, 1.05) event_bands(ax, y=0.115) style_ax(ax) date_axis(ax, start="2009-06-01", end=XEND) ax.set_ylabel("Corrélation mobile (60 mois)") end_labels(ax, [(c2.index[-1], c2.iloc[-1], PAL["blue"]), (c1.index[-1], c1.iloc[-1], PAL["orange"])], lambda v: fr(v, 2)) finish(fig, ax, "Le découplage : Montréal et Toronto ne dansent plus ensemble", "Corrélation mobile (60 mois) des variations mensuelles désaisonnalisées de l'IPP composite", SRC_ACI, "fig15_corr_mobile.pdf", legend_ncols=2) # --- fig16 : ratio Québec / Canada -------------------------------------------------- fig, ax = new_fig(top=0.80) ratio_qc = nsa["QUEBEC"]["Composite_HPI"] / nsa["AGGREGATE"]["Composite_HPI"] * 100 ax.plot(ratio_qc.index, ratio_qc.values, color=PAL["orange"], lw=2.2, label="IPP Québec / IPP Canada (×100)") ax.axhline(100, color=AXIS, lw=0.9) ax.text(pd.Timestamp("2005-06-01"), 100.8, "Parité avec le Canada", fontsize=8, color=MUTED) cross = ratio_qc[ratio_qc >= 100].index[ratio_qc[ratio_qc >= 100].index > pd.Timestamp("2015-01-01")][0] ax.annotate(f"Croisement : {MOIS_FR[cross.month-1]} {cross.year}", xy=(cross, 100), xytext=(cross - pd.DateOffset(months=86), 106), fontsize=8.5, color=INK2, arrowprops=dict(arrowstyle="-", color=MUTED, lw=0.8)) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Ratio (Canada = 100)") end_labels(ax, [(ratio_qc.index[-1], ratio_qc.iloc[-1], PAL["orange"])], lambda v: fr(v, 0)) finish(fig, ax, "De 68 à 112 : la remontée relative du Québec", "Ratio de l'IPP composite du Québec sur celui du Canada (×100) — sous 100 : le Québec croît moins vite depuis 2005", SRC_ACI, "fig16_ratio_qc_canada.pdf", legend_ncols=1) # --- fig17 : nuage rattrapage 2005-2019 vs 2020-2026 --------------------------------- fig, ax = plt.subplots(figsize=(9.6, 6.8)) fig.subplots_adjust(left=0.08, right=0.975, top=0.845, bottom=0.10) cut = pd.Timestamp("2020-01-01") pts = [] for sheet in bars_sheets: h = nsa[sheet]["Composite_HPI"] pts.append((NOMS[sheet], cagr(h, end=cut), cagr(h, start=cut), sheet in QC_REGIONS)) xs = [p[1] for p in pts] ys = [p[2] for p in pts] lims = [min(xs + ys) - 0.9, max(xs + ys) + 0.9] ax.plot(lims, lims, ls=(0, (4, 4)), color=AXIS, lw=1) ax.text(lims[1] - 0.15, lims[1] + 0.12, "Même rythme avant / après 2020", fontsize=8, color=MUTED, ha="right", rotation=32, rotation_mode="anchor") OFFSETS = { "Estrie": (-9, 0, "right"), "Mauricie": (9, 4, "left"), "Centre-du-Québec": (9, -4, "left"), "Halifax-Dartmouth": (-9, 0, "right"), "RMR de Québec": (9, 0, "left"), "Québec (province)": (-9, 4, "right"), "RMR de Montréal": (9, -7, "left"), "Winnipeg": (9, 0, "left"), "Calgary": (-9, 4, "right"), "Ottawa": (-9, -4, "right"), "Alberta": (9, -5, "left"), "Canada": (9, 3, "left"), "Colombie-Britannique": (-9, 3, "right"), "Ontario": (-9, -7, "right"), "Grand Vancouver": (9, 3, "left"), "Grand Toronto": (9, -5, "left"), } for name, x, y, is_qc in pts: col = PAL["orange"] if is_qc else PAL["blue"] ax.scatter(x, y, s=64, color=col, zorder=4, edgecolor="white", lw=1.2) dx, dy, ha = OFFSETS.get(name, (0, 8, "center")) ax.annotate(name, (x, y), xytext=(dx, dy), textcoords="offset points", fontsize=8, color=INK2, ha=ha, va="center") ax.set_xlim(*lims) ax.set_ylim(*lims) style_ax(ax) ax.grid(axis="x", color=GRID, linewidth=0.7) ax.set_xlabel("Croissance annuelle moyenne 2005-2019 (%)") ax.set_ylabel("Croissance annuelle moyenne 2020-2026 (%)") ax.legend(handles=[Patch(color=PAL["orange"], label="Marchés québécois"), Patch(color=PAL["blue"], label="Reste du Canada")], loc="lower right") fig.text(0.012, 0.955, "Le renversement de régime : lents avant 2020, premiers après", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.912, "Chaque point est un marché ; au-dessus de la diagonale, la croissance a accéléré après janvier 2020", fontsize=10, color=INK2) fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "fig17_scatter_rattrapage.pdf") plt.close(fig) # --- fig18 : ratio appartement / unifamiliale ----------------------------------------- fig, ax = new_fig(top=0.78) ra_qc = nsa["QUEBEC"]["Apartment_HPI"] / nsa["QUEBEC"]["Single_Family_HPI"] * 100 ra_mtl = (nsa["MONTREAL_CMA"]["Apartment_HPI"] / nsa["MONTREAL_CMA"]["Single_Family_HPI"] * 100) ax.plot(ra_qc.index, ra_qc.values, color=PAL["orange"], lw=2.1, label="Québec (province)") ax.plot(ra_mtl.index, ra_mtl.values, color=PAL["violet"], lw=2.1, label="RMR de Montréal") ax.axhline(100, color=AXIS, lw=0.8) event_bands(ax, y=0.13) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("IPP appartement / IPP unifamiliale (×100)") end_labels(ax, [(ra_qc.index[-1], ra_qc.iloc[-1], PAL["orange"]), (ra_mtl.index[-1], ra_mtl.iloc[-1], PAL["violet"])], lambda v: fr(v, 0)) finish(fig, ax, "La copropriété décroche : quinze ans de sous-performance", "Ratio de l'IPP appartement sur l'IPP unifamiliale (×100) — sous 100, l'appartement croît moins vite qu'en 2005", SRC_ACI, "fig18_ratio_app_unifam.pdf", legend_ncols=2) # --- fig19 : le récit en une courbe (événements annotés) -------------------------------- fig, ax = new_fig(h=5.6, top=0.82, right=0.94) s = nsa["QUEBEC"]["Composite_HPI"] ax.plot(s.index, s.values, color=PAL["orange"], lw=2.4) ax.fill_between(s.index, s.values, 90, color=PAL["orange"], alpha=0.06) event_bands(ax, labels=False) def note(ax, when, txt, dxm, dy, ha="left"): t = pd.Timestamp(when) v = s.asof(t) ax.annotate(txt, xy=(t, v), xytext=(t + pd.DateOffset(months=dxm), v + dy), fontsize=8.4, color=INK2, ha=ha, va="center", linespacing=1.25, arrowprops=dict(arrowstyle="-", color=MUTED, lw=0.8, shrinkA=2, shrinkB=3)) note(ax, "2008-10-01", "Crise financière :\nsimple pause au Québec\n(−9 % dans l'Ouest)", -8, 62) note(ax, "2016-06-01", "Décennie lente :\n+3 % par an\nde 2011 à 2016", 4, -48) note(ax, "2020-03-01", "Pandémie : télétravail,\népargne forcée, taux planchers", -88, 46) note(ax, "2022-03-01", "Le resserrement impose\nun plateau… sans correction", -66, 68) note(ax, "2025-06-01", "Reprise : sommet\nhistorique à 319\nen juin 2026", -46, 40) ax.set_ylim(90, 345) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("IPP composite (janv. 2005 = 100)") end_labels(ax, [(s.index[-1], s.iloc[-1], PAL["orange"])], lambda v: fr(v, 0)) fig.text(0.012, 0.955, "Vingt ans du marché québécois : le récit en une courbe", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.912, "IPP MLS® composite, Québec, janv. 2005 = 100 — épisodes clés annotés", fontsize=10, color=INK2) fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "fig19_evenements.pdf") plt.close(fig) # --- fig20 : variation sur 12 mois par marché (barres) ----------------------------------- fig, ax = plt.subplots(figsize=(9.4, 6.4)) fig.subplots_adjust(left=0.21, right=0.94, top=0.845, bottom=0.10) yy_last = {NOMS[s]: yoy(nsa[s]["Composite_HPI"]).iloc[-1] for s in bars_sheets} yy_last = dict(sorted(yy_last.items(), key=lambda kv: kv[1])) labels = list(yy_last.keys()) vals = list(yy_last.values()) colors = [PAL["blue"] if v >= 0 else PAL["red"] for v in vals] ax.barh(range(len(vals)), vals, color=colors, height=0.62, zorder=3) ax.axvline(0, color=AXIS, lw=0.9) ax.set_yticks(range(len(labels))) ax.set_yticklabels(labels, fontsize=9.5) for i, v in enumerate(vals): ax.text(v + (0.12 if v >= 0 else -0.12), i, ("+" if v > 0 else "") + fr(v, 1) + " %", va="center", ha="left" if v >= 0 else "right", fontsize=8.6, color=INK2) ax.set_xlabel("Variation sur 12 mois de l'IPP composite (%)") ax.set_xlim(min(vals) * 1.35, max(vals) * 1.22) style_ax(ax, ygrid=False) ax.grid(axis="x", color=GRID, linewidth=0.7) ax.legend(handles=[Patch(color=PAL["blue"], label="Hausse sur 12 mois"), Patch(color=PAL["red"], label="Baisse sur 12 mois")], loc="lower right") fig.text(0.012, 0.955, "Juin 2026 : la carte des hausses est presque entièrement québécoise", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.91, "Variation sur 12 mois de l'IPP composite, seize marchés, en %", fontsize=10, color=INK2) fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "fig20_yoy_barres.pdf") plt.close(fig) # ============================================================================= # MACRO COMPLÉMENTAIRE — démographie et taux de change # ============================================================================= print("Figures macro complémentaires…") pop = fred("POPTOTCAA647NWDB") # population du Canada (annuel) fx = fred("DEXCAUS") # $CA par $US (quotidien) fx_m = fx.resample("MS").mean() # --- macro07 : croissance démographique ----------------------------------------- fig, ax = new_fig() pg = (pop.pct_change() * 100).dropna() pg = pg[pg.index >= "2005-01-01"] colors = [PAL["orange"] if v >= 1.5 else PAL["blue"] for v in pg.values] ax.bar(pg.index, pg.values, width=290, color=colors, zorder=3) ax.axhline(0, color=AXIS, lw=0.8) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Croissance de la population (%)") ax.legend(handles=[Patch(color=PAL["orange"], label="Croissance ≥ 1,5 %"), Patch(color=PAL["blue"], label="Croissance < 1,5 %")], loc="lower left", bbox_to_anchor=(-0.005, 1.01), ncols=2, columnspacing=1.3, handlelength=1.5, handletextpad=0.5, borderaxespad=0) fig.text(0.012, 0.955, "Le choc démographique : la demande fondamentale s'emballe après 2021", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.902, "Croissance annuelle de la population canadienne, en % — l'immigration record de 2022-2024 " "soutient la demande de logements", fontsize=10, color=INK2) fig.text(0.012, 0.022, SRC_FRED, fontsize=7.6, color=MUTED) fig.savefig(FIG / "macro07_population.pdf") plt.close(fig) # --- macro08 : taux de change ---------------------------------------------------- fig, ax = new_fig() fxp = fx_m[fx_m.index >= "2004-08-01"] ax.plot(fxp.index, fxp.values, color=PAL["blue"], lw=2.1, label="Dollars canadiens par dollar américain (moyenne mensuelle)") ax.axhline(1, color=AXIS, lw=0.8) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Dollars CA par dollar US") end_labels(ax, [(fxp.index[-1], fxp.iloc[-1], PAL["blue"])], lambda v: fr(v, 2)) finish(fig, ax, "Le huard : de la parité de 2007-2011 à la fourchette 1,35-1,45", "Taux de change $ CA / $ US, moyenne mensuelle — un dollar faible renchérit les intrants de construction", SRC_FRED, "macro08_cadusd.pdf", legend_ncols=1) # ============================================================================= # PROFILS RÉGIONAUX — 4 figures + 1 table par marché québécois # ============================================================================= print("Profils régionaux…") TYPE_COLS = list(TYPES5.keys()) BENCH_COLS = { "Single_Family_Benchmark": "Unifamiliale", "One_Storey_Benchmark": "Plain-pied", "Two_Storey_Benchmark": "À étages", "Townhouse_Benchmark": "En rangée", "Apartment_Benchmark": "Appartement", } region_summaries = {} for sheet in QC_REGIONS: slug = sheet.lower() nom = NOMS[sheet] d = nsa[sheet] types_dispo = {c: l for c, l in TYPES5.items() if c in d.columns} bench_dispo = {c: l for c, l in BENCH_COLS.items() if c in d.columns} # --- A : indices par type + composite --------------------------------------- fig, ax = new_fig(top=0.78) s_comp = d["Composite_HPI"] ax.plot(s_comp.index, s_comp.values, color=INK2, lw=2.6, label="Composite") ends = [(s_comp.index[-1], s_comp.iloc[-1], INK2)] for (col_name, lab), colk in zip(types_dispo.items(), CAT_ORDER[:5]): s = d[col_name] ax.plot(s.index, s.values, color=PAL[colk], lw=1.7, label=lab) ends.append((s.index[-1], s.iloc[-1], PAL[colk])) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("IPP (janv. 2005 = 100)") end_labels(ax, ends, lambda v: fr(v, 0)) finish(fig, ax, f"{nom} : l'indice composite et ses cinq segments", "IPP MLS® par type de propriété, janv. 2005 = 100", SRC_ACI, f"reg_{slug}_types.pdf", legend_ncols=6) # --- B : prix de référence par type ------------------------------------------ fig, ax = new_fig(top=0.78) ends = [] for (col_name, lab), colk in zip(bench_dispo.items(), CAT_ORDER[:5]): s = d[col_name] / 1000 ax.plot(s.index, s.values, color=PAL[colk], lw=1.9, label=lab) ends.append((s.index[-1], s.iloc[-1], PAL[colk])) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Prix de référence (milliers de $)") ax.yaxis.set_major_formatter(FuncFormatter(kfmt)) end_labels(ax, ends, lambda v: fr(v, 0) + " k$") finish(fig, ax, f"{nom} : les prix de référence en dollars", "Prix de référence par type de propriété, milliers de dollars courants", SRC_ACI, f"reg_{slug}_bench.pdf", legend_ncols=5) # --- C : glissement annuel vs Canada ------------------------------------------ fig, ax = new_fig(h=4.4, top=0.78) yy_r = yoy(d["Composite_HPI"]) ax.axhline(0, color=AXIS, lw=0.8) ax.plot(y_can.index, y_can.values, color=AXIS, lw=1.5, label="Canada (référence)") ax.plot(yy_r.index, yy_r.values, color=PAL["orange"], lw=2.1, label=nom) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Variation sur 12 mois (%)") end_labels(ax, [(yy_r.index[-1], yy_r.iloc[-1], PAL["orange"])], lambda v: ("+" if v > 0 else "") + fr(v, 1) + " %") finish(fig, ax, f"{nom} : le rythme annuel des prix", "Variation sur 12 mois de l'IPP composite, en % — gris : Canada", SRC_ACI, f"reg_{slug}_yoy.pdf", legend_ncols=2) # --- D : repli depuis le sommet ------------------------------------------------ fig, ax = new_fig(h=4.4, top=0.78) dd_r = drawdown(sa[sheet]["Composite_HPI"]) dd_c = drawdown(sa["AGGREGATE"]["Composite_HPI"]) ax.plot(dd_c.index, dd_c.values, color=AXIS, lw=1.5, label="Canada (référence)") ax.plot(dd_r.index, dd_r.values, color=PAL["orange"], lw=2.1, label=nom) ax.fill_between(dd_r.index, dd_r.values, 0, color=PAL["orange"], alpha=0.14, lw=0) ax.axhline(0, color=AXIS, lw=0.8) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Écart au sommet historique (%)") end_labels(ax, [(dd_r.index[-1], dd_r.iloc[-1], PAL["orange"])], lambda v: fr(v, 1) + " %") finish(fig, ax, f"{nom} : la résistance aux corrections", "Écart au sommet historique de l'IPP composite désaisonnalisé, en % — gris : Canada", SRC_ACI, f"reg_{slug}_dd.pdf", legend_ncols=2) # --- table régionale ------------------------------------------------------------ rows = [("Composite", "Composite_HPI", "Composite_Benchmark")] + [ (lab, hcol, bcol) for (hcol, lab), bcol in zip(types_dispo.items(), bench_dispo) ] lines = [] for lab, hcol, bcol in rows: h = d[hcol] b = d[bcol] lines.append( f"{lab} & {fr_num(h.iloc[-1])} & {fr_money(b.iloc[-1])} & " f"{fr_pct(yoy(h).iloc[-1], sign=True)} & " f"{fr_pct(cagr(h, start=LAST - pd.DateOffset(years=5)), sign=True)} & " f"{fr_pct(cagr(h, start=LAST - pd.DateOffset(years=10)), sign=True)} & " f"{fr_pct(cagr(h), sign=True)} \\\\" + ("\n\\midrule" if lab == "Composite" else "") ) tabr = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{lrrrrrr}\n\\toprule\n" "Segment & \\makecell{IPP} & \\makecell{Prix de\\\\référence} & " "\\makecell{Var.\\\\12 mois} & \\makecell{Croiss. ann.\\\\5 ans} & " "\\makecell{Croiss. ann.\\\\10 ans} & \\makecell{Croiss. ann.\\\\2005--2026} \\\\\n" "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / f"tabreg_{slug}.tex").write_text(tabr) # --- sommaire JSON --------------------------------------------------------------- hs = sa[sheet]["Composite_HPI"] region_summaries[nom] = { "hpi": round(float(s_comp.iloc[-1]), 1), "bench": int(d["Composite_Benchmark"].iloc[-1]), "yoy": round(float(yoy(s_comp).iloc[-1]), 2), "cagr": round(float(cagr(s_comp)), 2), "cagr_2020": round(float(cagr(s_comp, start=pd.Timestamp("2020-01-01"))), 2), "dd_actuel": round(float(drawdown(hs).iloc[-1]), 2), "dd_max": round(float(drawdown(hs).min()), 2), "pic": str(hs.idxmax().date()), "app_vs_unifam": round(float(d["Apartment_HPI"].iloc[-1] / d["Single_Family_HPI"].iloc[-1] * 100), 1), "bench_types": {lab: int(d[bcol].iloc[-1]) for bcol, lab in bench_dispo.items()}, "yoy_types": {lab: round(float(yoy(d[hcol]).iloc[-1]), 1) for hcol, lab in types_dispo.items()}, } # ============================================================================= # TYPES DE PROPRIÉTÉ — comparaisons entre marchés # ============================================================================= print("Figures par type…") TYPE_SLUGS = { "Single_Family_HPI": ("unifamiliale", "L'unifamiliale"), "One_Storey_HPI": ("plainpied", "Le plain-pied"), "Two_Storey_HPI": ("etages", "La maison à étages"), "Townhouse_HPI": ("rangee", "La maison en rangée"), "Apartment_HPI": ("appartement", "L'appartement en copropriété"), } for hcol, (slug, titre) in TYPE_SLUGS.items(): fig, ax = new_fig(top=0.78) series_t = [ ("AGGREGATE", "Canada", PAL["blue"]), ("QUEBEC", "Québec (province)", PAL["orange"]), ("MONTREAL_CMA", "RMR de Montréal", PAL["aqua"]), ("QUEBEC_CMA", "RMR de Québec", PAL["yellow"]), ] ends = [] for sheet, lab, col in series_t: s = nsa[sheet][hcol] ax.plot(s.index, s.values, color=col, lw=2.1, label=lab) ends.append((s.index[-1], s.iloc[-1], col)) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("IPP (janv. 2005 = 100)") end_labels(ax, ends, lambda v: fr(v, 0)) finish(fig, ax, f"{titre} : Québec, Montréal, Québec et Canada", f"IPP MLS® du segment, janv. 2005 = 100", SRC_ACI, f"type_{slug}.pdf", legend_ncols=4) # ============================================================================= # LES GRANDS MARCHÉS DU RESTE DU CANADA # ============================================================================= print("Figures reste du Canada…") ROC = ["GREATER_TORONTO", "GREATER_VANCOUVER", "CALGARY", "OTTAWA", "HALIFAX_DARTMOUTH", "WINNIPEG"] # --- roc01 : petits multiples, indices -------------------------------------------- fig, axes = plt.subplots(2, 3, figsize=(11.5, 6.5), sharex=True, sharey=True) fig.subplots_adjust(left=0.06, right=0.985, top=0.775, bottom=0.09, hspace=0.34, wspace=0.07) s_can = nsa["AGGREGATE"]["Composite_HPI"] for axx, sheet in zip(axes.flat, ROC): s = nsa[sheet]["Composite_HPI"] axx.plot(s_can.index, s_can.values, color=AXIS, lw=1.3) axx.plot(s.index, s.values, color=PAL["blue"], lw=1.9) axx.set_title(NOMS[sheet], fontsize=10, fontweight="bold", color=INK, pad=4) style_ax(axx) axx.xaxis.set_major_locator(mdates.YearLocator(5)) axx.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) axx.text(0.02, 0.93, fr(s.iloc[-1], 0), transform=axx.transAxes, fontsize=9.5, fontweight="bold", color=PAL["blue"], va="top") handles = [plt.Line2D([], [], color=PAL["blue"], lw=1.9, label="Marché"), plt.Line2D([], [], color=AXIS, lw=1.3, label="Canada (référence)")] fig.legend(handles=handles, loc="lower left", bbox_to_anchor=(0.052, 0.845), ncols=2, frameon=False) fig.text(0.012, 0.955, "Six grands marchés hors Québec : niveaux d'indice", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.912, "IPP composite, janv. 2005 = 100 — la valeur affichée est celle de juin 2026 ; gris : Canada", fontsize=10, color=INK2) fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "roc01_grid.pdf") plt.close(fig) # --- roc02 : petits multiples, drawdown --------------------------------------------- fig, axes = plt.subplots(2, 3, figsize=(11.5, 6.5), sharex=True, sharey=True) fig.subplots_adjust(left=0.06, right=0.985, top=0.775, bottom=0.09, hspace=0.34, wspace=0.07) dd_can = drawdown(sa["AGGREGATE"]["Composite_HPI"]) for axx, sheet in zip(axes.flat, ROC): ddx = drawdown(sa[sheet]["Composite_HPI"]) axx.plot(dd_can.index, dd_can.values, color=AXIS, lw=1.3) axx.plot(ddx.index, ddx.values, color=PAL["red"], lw=1.9) axx.axhline(0, color=AXIS, lw=0.7) axx.set_title(NOMS[sheet], fontsize=10, fontweight="bold", color=INK, pad=4) style_ax(axx) axx.xaxis.set_major_locator(mdates.YearLocator(5)) axx.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) axx.text(0.02, 0.09, fr(ddx.iloc[-1], 1) + " %", transform=axx.transAxes, fontsize=9.5, fontweight="bold", color=PAL["red"], va="bottom") handles = [plt.Line2D([], [], color=PAL["red"], lw=1.9, label="Marché"), plt.Line2D([], [], color=AXIS, lw=1.3, label="Canada (référence)")] fig.legend(handles=handles, loc="lower left", bbox_to_anchor=(0.052, 0.845), ncols=2, frameon=False) fig.text(0.012, 0.955, "Six grands marchés hors Québec : l'ampleur des corrections", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.912, "Écart au sommet historique de l'IPP composite désaisonnalisé, en % — " "la valeur affichée est celle de juin 2026", fontsize=10, color=INK2) fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "roc02_dd.pdf") plt.close(fig) # --- roc03 : prix de référence ---------------------------------------------------- fig, ax = new_fig(top=0.78) roc_colors = dict(zip(ROC, CAT_ORDER[:6])) ends = [] for sheet in ROC: s = nsa[sheet]["Composite_Benchmark"] / 1000 ax.plot(s.index, s.values, color=PAL[roc_colors[sheet]], lw=1.9, label=NOMS[sheet]) ends.append((s.index[-1], s.iloc[-1], PAL[roc_colors[sheet]])) event_bands(ax) style_ax(ax) date_axis(ax, end=XEND) ax.set_ylabel("Prix de référence (milliers de $)") ax.yaxis.set_major_formatter(FuncFormatter(kfmt)) end_labels(ax, ends, lambda v: fr(v, 0) + " k$") finish(fig, ax, "De Winnipeg à Vancouver : l'éventail canadien des prix", "Prix de référence composite, milliers de dollars courants, six grands marchés hors Québec", SRC_ACI, "roc03_bench.pdf", legend_ncols=3) # --- tab05 : vue d'ensemble ROC ------------------------------------------------------ lines = [] for sheet in ROC: h = nsa[sheet]["Composite_HPI"] b = nsa[sheet]["Composite_Benchmark"] ddx = drawdown(sa[sheet]["Composite_HPI"]) lines.append( f"{NOMS[sheet]} & {fr_money(b.iloc[-1])} & {fr_num(h.iloc[-1])} & " f"{fr_pct(yoy(h).iloc[-1], sign=True)} & {fr_pct(cagr(h), sign=True)} & " f"{fr_pct(ddx.iloc[-1])} & {fr_pct(ddx.min())} \\\\" ) tab5 = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{lrrrrrr}\n\\toprule\n" "Marché & \\makecell{Prix de\\\\référence} & \\makecell{IPP\\\\composite} & " "\\makecell{Var.\\\\12 mois} & \\makecell{Croiss. ann.\\\\2005--2026} & " "\\makecell{Écart au\\\\sommet} & \\makecell{Repli\\\\maximal} \\\\\n" "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "tab05_roc.tex").write_text(tab5) # ============================================================================= # RISQUE — distributions, bêta mobile, épisodes de repli # ============================================================================= print("Figures de risque…") # --- risk01 : histogrammes des variations mensuelles --------------------------------- fig, axes = plt.subplots(1, 2, figsize=(10.5, 4.6), sharex=True, sharey=True) fig.subplots_adjust(left=0.07, right=0.975, top=0.80, bottom=0.13, wspace=0.08) r_qc_m = sa["QUEBEC"]["Composite_HPI"].pct_change().dropna() * 100 r_ca_m = sa["AGGREGATE"]["Composite_HPI"].pct_change().dropna() * 100 bins = np.arange(-2.6, 3.61, 0.2) for axx, (r, lab, col) in zip(axes, [(r_qc_m, "Québec", PAL["orange"]), (r_ca_m, "Canada", PAL["blue"])]): axx.hist(r.values, bins=bins, color=col, edgecolor="white", linewidth=0.8, zorder=3) axx.axvline(0, color=AXIS, lw=0.8) axx.axvline(r.mean(), color=INK2, lw=1.4, ls=(0, (4, 3))) axx.set_title(lab, fontsize=10.5, fontweight="bold", color=INK, pad=4) axx.set_xlabel("Variation mensuelle (%)") style_ax(axx) axx.text(0.97, 0.94, f"moyenne : {fr(r.mean(), 2)} %\nécart-type : {fr(r.std(), 2)} %" f"\nmin. : {fr(r.min(), 1)} %\nmax. : {fr(r.max(), 1)} %", transform=axx.transAxes, fontsize=8.4, color=INK2, ha="right", va="top", linespacing=1.45) axes[0].set_ylabel("Nombre de mois") fig.text(0.012, 0.955, "La distribution des variations mensuelles : un Québec plus régulier", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.905, "Variations mensuelles de l'IPP composite désaisonnalisé, 2005-2026 — " "pointillé : moyenne", fontsize=10, color=INK2) fig.text(0.012, 0.022, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "risk01_hist.pdf") plt.close(fig) # --- risk02 : distribution des variations annuelles par marché ------------------------ fig, ax = plt.subplots(figsize=(9.6, 6.6)) fig.subplots_adjust(left=0.21, right=0.965, top=0.85, bottom=0.10) box_sheets = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO", "GREATER_TORONTO", "BRITISH_COLUMBIA", "GREATER_VANCOUVER", "ALBERTA"] box_data, box_cols = [], [] for sheet in box_sheets: da = annual[sheet].set_index("Date")["Composite_HPI"] box_data.append((da.pct_change() * 100).dropna().values) box_cols.append(PAL["orange"] if sheet in QC_REGIONS else PAL["blue"]) bp = ax.boxplot(box_data, vert=False, patch_artist=True, widths=0.55, medianprops=dict(color="white", lw=1.6), whiskerprops=dict(color=AXIS), capprops=dict(color=AXIS), flierprops=dict(marker="o", markersize=4, markerfacecolor=MUTED, markeredgecolor="none")) for patch, col in zip(bp["boxes"], box_cols): patch.set_facecolor(col) patch.set_edgecolor("white") ax.axvline(0, color=AXIS, lw=0.9) ax.set_yticklabels([NOMS[s] for s in box_sheets], fontsize=9.5) ax.set_xlabel("Variation annuelle de l'IPP composite (%)") style_ax(ax, ygrid=False) ax.grid(axis="x", color=GRID, linewidth=0.7) ax.legend(handles=[Patch(color=PAL["orange"], label="Marchés québécois"), Patch(color=PAL["blue"], label="Reste du Canada")], loc="lower right") fig.text(0.012, 0.955, "Vingt et une années de variations annuelles : la boîte à moustaches", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.912, "Distribution des variations annuelles de l'IPP composite, 2006-2026 — " "médiane en blanc, points : années extrêmes", fontsize=10, color=INK2) fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "risk02_box.pdf") plt.close(fig) # --- risk03 : bêta mobile du Québec sur le Canada -------------------------------------- fig, ax = new_fig(top=0.80) cov = r_qc_m.rolling(60).cov(r_ca_m) var = r_ca_m.rolling(60).var() beta = (cov / var).dropna() ax.plot(beta.index, beta.values, color=PAL["violet"], lw=2.2, label="Bêta mobile (60 mois) du Québec sur le Canada") ax.axhline(1, color=AXIS, lw=0.9) ax.text(pd.Timestamp("2010-06-01"), 1.04, "Bêta = 1 : sensibilité identique au marché canadien", fontsize=8, color=MUTED) ax.axhline(0, color=AXIS, lw=0.8) event_bands(ax, y=0.97) style_ax(ax) date_axis(ax, start="2009-06-01", end=XEND) ax.set_ylabel("Bêta (fenêtre de 60 mois)") end_labels(ax, [(beta.index[-1], beta.iloc[-1], PAL["violet"])], lambda v: fr(v, 2)) finish(fig, ax, "Un bêta durablement inférieur à un", "Pente de la régression mobile des variations mensuelles du Québec sur celles du Canada (60 mois)", SRC_ACI, "risk03_beta.pdf", legend_ncols=1) # --- tab06 : épisodes de repli maximal --------------------------------------------------- dd_sheets = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "GREATER_TORONTO", "GREATER_VANCOUVER", "CALGARY", "OTTAWA"] lines = [] for sheet in dd_sheets: hs = sa[sheet]["Composite_HPI"] ddx = drawdown(hs) trough = ddx.idxmin() peak = hs[:trough].idxmax() duree = (trough.year - peak.year) * 12 + trough.month - peak.month apres = hs[trough:] recov = apres[apres >= hs[peak]] recouvre = (f"{MOIS_FR[recov.index[0].month-1]}~{recov.index[0].year}" if len(recov) else "non récupéré") lines.append( f"{NOMS[sheet]} & {fr_pct(ddx.min())} & " f"{MOIS_FR[peak.month-1]}~{peak.year} & {MOIS_FR[trough.month-1]}~{trough.year} & " f"{duree} & {recouvre} & {fr_pct(ddx.iloc[-1])} \\\\" ) tab6 = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{lrrrrrr}\n\\toprule\n" "Marché & \\makecell{Repli\\\\maximal} & \\makecell{Sommet\\\\(pré-repli)} & " "\\makecell{Creux} & \\makecell{Durée\\\\(mois)} & \\makecell{Sommet\\\\récupéré en} & " "\\makecell{Écart actuel\\\\au sommet} \\\\\n" "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "tab06_drawdowns.tex").write_text(tab6) # ============================================================================= # SAISONNALITÉ RÉGIONALE # ============================================================================= print("Figures de saisonnalité régionale…") # --- sais01 : carte thermique mois × marché ---------------------------------------------- sais_mat = [] for sheet in QC_REGIONS: rr = (nsa[sheet]["Composite_HPI"] / sa[sheet]["Composite_HPI"] - 1) * 100 sais_mat.append(rr.groupby(rr.index.month).mean().reindex(range(1, 13)).values) sais_mat = np.array(sais_mat) fig, ax = plt.subplots(figsize=(10.5, 3.9)) fig.subplots_adjust(left=0.155, right=0.985, top=0.80, bottom=0.11) vmax_s = np.nanmax(np.abs(sais_mat)) im = ax.imshow(sais_mat, aspect="auto", cmap=DIV_CMAP, norm=TwoSlopeNorm(vcenter=0, vmin=-vmax_s, vmax=vmax_s)) ax.set_xticks(range(12)) ax.set_xticklabels(MOIS_FR, fontsize=8.8) ax.set_yticks(range(len(QC_REGIONS))) ax.set_yticklabels([NOMS[s] for s in QC_REGIONS], fontsize=9) for i in range(sais_mat.shape[0]): for j in range(sais_mat.shape[1]): v = sais_mat[i, j] ax.text(j, i, fr(v, 1), ha="center", va="center", fontsize=7.4, color="white" if abs(v) / vmax_s > 0.55 else INK2) ax.set_xticks(np.arange(-0.5, 12, 1), minor=True) ax.set_yticks(np.arange(-0.5, len(QC_REGIONS), 1), minor=True) ax.grid(which="minor", color="white", linewidth=1.6) ax.tick_params(which="both", length=0) for sp in ax.spines.values(): sp.set_visible(False) fig.text(0.012, 0.945, "La saisonnalité région par région", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.882, "Facteur saisonnier moyen (écart brut / désaisonnalisé), en % — " "rouge : prix au-dessus de la tendance ; bleu : au-dessous", fontsize=10, color=INK2) fig.text(0.012, 0.022, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "sais01_heatmap.pdf") plt.close(fig) # --- sais02 : Montréal vs Québec, barres groupées ----------------------------------------- fig, ax = new_fig(w=9.5, h=4.4, right=0.97) r_m = (nsa["MONTREAL_CMA"]["Composite_HPI"] / sa["MONTREAL_CMA"]["Composite_HPI"] - 1) * 100 r_q = (nsa["QUEBEC_CMA"]["Composite_HPI"] / sa["QUEBEC_CMA"]["Composite_HPI"] - 1) * 100 sm = r_m.groupby(r_m.index.month).mean() sq = r_q.groupby(r_q.index.month).mean() x = np.arange(1, 13) ax.bar(x - 0.19, sm.values, width=0.36, color=PAL["blue"], label="RMR de Montréal", zorder=3) ax.bar(x + 0.19, sq.values, width=0.36, color=PAL["orange"], label="RMR de Québec", zorder=3) ax.axhline(0, color=AXIS, lw=0.8) ax.set_xticks(x) ax.set_xticklabels(MOIS_FR) ax.set_ylabel("Écart brut / désaisonnalisé (%)") style_ax(ax) finish(fig, ax, "Deux métropoles, un même printemps", "Facteur saisonnier moyen de l'IPP composite, RMR de Montréal et RMR de Québec, 2005-2026", SRC_ACI, "sais02_rmr.pdf", legend_ncols=2) # ============================================================================= # TABLEAUX D'ANNEXE — données annuelles # ============================================================================= print("Tableaux d'annexe…") annexe_g1 = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO"] annexe_g2 = ["GREATER_TORONTO", "OTTAWA", "BRITISH_COLUMBIA", "GREATER_VANCOUVER", "ALBERTA", "CALGARY", "HALIFAX_DARTMOUTH", "WINNIPEG"] COURTS = { "AGGREGATE": "Canada", "QUEBEC": "Québec", "MONTREAL_CMA": "Montréal", "QUEBEC_CMA": "Québec (RMR)", "ESTRIE": "Estrie", "MAURICIE": "Mauricie", "CENTRE_DU_QUEBEC": "Centre-du-Qc", "ONTARIO": "Ontario", "GREATER_TORONTO": "Toronto", "OTTAWA": "Ottawa", "BRITISH_COLUMBIA": "C.-B.", "GREATER_VANCOUVER": "Vancouver", "ALBERTA": "Alberta", "CALGARY": "Calgary", "HALIFAX_DARTMOUTH": "Halifax", "WINNIPEG": "Winnipeg", } def table_annuelle(sheets, fname, yoy_mode=False): dfs = {COURTS[s]: annual[s].set_index("Date")["Composite_HPI"] for s in sheets} dfa = pd.DataFrame(dfs) if yoy_mode: dfa = (dfa.pct_change() * 100).iloc[1:] head = "Année & " + " & ".join(dfa.columns) + " \\\\" lines = [] for yr, row in dfa.iterrows(): if yoy_mode: cells = [fr_pct(v, 1, sign=True) if np.isfinite(v) else "---" for v in row.values] else: cells = [fr_num(v) if np.isfinite(v) else "---" for v in row.values] lines.append(f"{yr} & " + " & ".join(cells) + " \\\\") out = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{l" + "r" * len(dfa.columns) + "}\n\\toprule\n" + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / fname).write_text(out) table_annuelle(annexe_g1, "taba1_hpi_annuel_1.tex") table_annuelle(annexe_g2, "taba1_hpi_annuel_2.tex") table_annuelle(annexe_g1, "taba5_yoy_annuel_1.tex", yoy_mode=True) table_annuelle(annexe_g2, "taba5_yoy_annuel_2.tex", yoy_mode=True) # benchmark annuel (moyenne des mois, en milliers) — marchés québécois + Canada bench_sheets = ["AGGREGATE"] + QC_REGIONS dfb = pd.DataFrame({COURTS[s]: nsa[s]["Composite_Benchmark"].resample("YE").mean() / 1000 for s in bench_sheets}) dfb.index = dfb.index.year head = "Année & " + " & ".join(dfb.columns) + " \\\\" lines = [] for yr, row in dfb.iterrows(): cells = [fr_num(v, 0) for v in row.values] lines.append(f"{yr} & " + " & ".join(cells) + " \\\\") tabb = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{l" + "r" * len(dfb.columns) + "}\n\\toprule\n" + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "taba2_bench_annuel.tex").write_text(tabb) # facteurs saisonniers par mois × marché head = "Mois & " + " & ".join(COURTS[s] for s in QC_REGIONS) + " \\\\" lines = [] for j, mois in enumerate(MOIS_FR): cells = [fr_num(sais_mat[i, j], 2) for i in range(len(QC_REGIONS))] lines.append(f"{mois.capitalize()} & " + " & ".join(cells) + " \\\\") tabs_out = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{l" + "r" * len(QC_REGIONS) + "}\n\\toprule\n" + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "taba3_saison.tex").write_text(tabs_out) # CAM 2005-2026 par type × marché head = ("Segment & " + " & ".join(COURTS[s] for s in ["AGGREGATE"] + QC_REGIONS) + " \\\\") lines = [] for hcol, lab in TYPES5.items(): cells = [fr_pct(cagr(nsa[s][hcol]), sign=True) if hcol in nsa[s].columns else "---" for s in ["AGGREGATE"] + QC_REGIONS] lines.append(f"{lab} & " + " & ".join(cells) + " \\\\") cells = [fr_pct(cagr(nsa[s]["Composite_HPI"]), sign=True) for s in ["AGGREGATE"] + QC_REGIONS] lines.append("\\midrule\nComposite & " + " & ".join(cells) + " \\\\") taba4 = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{l" + "r" * 7 + "}\n\\toprule\n" + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "taba4_cagr_types.tex").write_text(taba4) # ============================================================================= # TABLES LaTeX # ============================================================================= print("Tables…") def stats_market(sheet): h = nsa[sheet]["Composite_HPI"] b = nsa[sheet]["Composite_Benchmark"] hs = sa[sheet]["Composite_HPI"] dd = drawdown(hs) return { "bench": b.iloc[-1], "hpi": h.iloc[-1], "yoy": yoy(h).iloc[-1], "an5": cagr(h, start=LAST - pd.DateOffset(years=5)), "an10": cagr(h, start=LAST - pd.DateOffset(years=10)), "cagr": cagr(h), "peak": hs.idxmax(), "dd": dd.iloc[-1], } rows_t1 = ["MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE", "CENTRE_DU_QUEBEC", "QUEBEC", "AGGREGATE"] stats = {sh: stats_market(sh) for sh in rows_t1} lines = [] for sh in rows_t1: st = stats[sh] sep = "\\midrule\n" if sh == "QUEBEC" else "" lines.append( sep + f"{NOMS[sh]} & {fr_money(st['bench'])} & {fr_num(st['hpi'])} & " f"{fr_pct(st['yoy'], sign=True)} & {fr_pct(st['an5'], sign=True)} & " f"{fr_pct(st['an10'], sign=True)} & {fr_pct(st['cagr'], sign=True)} \\\\" ) tab1 = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{lrrrrrr}\n\\toprule\n" "Marché & \\makecell{Prix de\\\\référence} & \\makecell{IPP\\\\composite} & " "\\makecell{Var.\\\\12 mois} & \\makecell{Croiss. ann.\\\\5 ans} & " "\\makecell{Croiss. ann.\\\\10 ans} & \\makecell{Croiss. ann.\\\\2005--2026} \\\\\n" "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "tab01_apercu.tex").write_text(tab1) periods = [("2005", "2010"), ("2010", "2015"), ("2015", "2020"), ("2020", "2026")] rows_t2 = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO", "GREATER_TORONTO", "BRITISH_COLUMBIA", "GREATER_VANCOUVER", "ALBERTA"] lines = [] for sh in rows_t2: h = nsa[sh]["Composite_HPI"] cells = [] for a, b in periods: cells.append(fr_pct(cagr(h, start=pd.Timestamp(f"{a}-01-01"), end=pd.Timestamp(f"{b}-01-01") if b != "2026" else LAST), sign=True)) cells.append(fr_pct(cagr(h), sign=True)) lines.append(f"{NOMS[sh]} & " + " & ".join(cells) + " \\\\") tab2 = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{lrrrrr}\n\\toprule\n" "Marché & 2005--2010 & 2010--2015 & 2015--2020 & 2020--2026 & Ensemble \\\\\n" "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "tab02_cagr_periodes.tex").write_text(tab2) BENCH_MAP = { "Unifamiliale": "Single_Family_Benchmark", "Plain-pied": "One_Storey_Benchmark", "À étages": "Two_Storey_Benchmark", "En rangée": "Townhouse_Benchmark", "Appartement": "Apartment_Benchmark", } mkts_t3 = ["MONTREAL_CMA", "QUEBEC_CMA", "QUEBEC"] lines = [] for lab, colb in BENCH_MAP.items(): cells = [lab] for m in mkts_t3: b = nsa[m][colb] cells.append(fr_money(b.iloc[-1])) cells.append(fr_pct(yoy(b).iloc[-1], sign=True)) lines.append(" & ".join(cells) + " \\\\") tab3 = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{lrrrrrr}\n\\toprule\n" " & \\multicolumn{2}{c}{RMR de Montréal} & \\multicolumn{2}{c}{RMR de Québec} & " "\\multicolumn{2}{c}{Québec (province)} \\\\\n" "\\cmidrule(lr){2-3}\\cmidrule(lr){4-5}\\cmidrule(lr){6-7}\n" "Type & Prix réf. & Var. 12 m. & Prix réf. & Var. 12 m. & Prix réf. & Var. 12 m. \\\\\n" "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "tab03_types.tex").write_text(tab3) # --- table 4 : tableau de bord macroéconomique -------------------------------- macro_rows = [ ("Taux court 3 mois", taux3m, "pct"), ("Rendement obligataire 10 ans", taux10a, "pct"), ("Inflation IPC (glissement annuel)", infl, "pct"), ("Taux de chômage", chomage, "pct"), ("PIB réel (variation sur 4 trim.)", pib_yoy.dropna(), "pct"), ] lines = [] for lab, s, _ in macro_rows: v_now = s.iloc[-1] d_now = s.index[-1] v_5 = s.asof(d_now - pd.DateOffset(years=5)) v_pre = s.asof(pd.Timestamp("2020-01-01")) lines.append( f"{lab} & {fr_pct(v_now)} & {fr_pct(v_pre)} & {fr_pct(v_5)} & " f"{MOIS_FR[d_now.month-1]}~{d_now.year} \\\\" ) tab4 = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{lrrrr}\n\\toprule\n" "Indicateur (Canada) & Dernière valeur & Janv. 2020 & Il y a 5 ans & Observation \\\\\n" "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "tab04_macro.tex").write_text(tab4) # ============================================================================= # ABORDABILITÉ — charge hypothécaire par marché et par taux # ============================================================================= print("Figures d'abordabilité…") def paiement_mensuel(prix, taux_pct, mise_de_fonds=0.20, annees=25): """Paiement mensuel d'un prêt hypothécaire à amortissement constant.""" principal = prix * (1 - mise_de_fonds) r = taux_pct / 100 / 12 n = annees * 12 return principal * r / (1 - (1 + r) ** (-n)) AFF_MARKETS = [ ("GREATER_TORONTO", PAL["blue"]), ("AGGREGATE", PAL["orange"]), ("MONTREAL_CMA", PAL["aqua"]), ("QUEBEC", PAL["yellow"]), ("ESTRIE", PAL["magenta"]), ("QUEBEC_CMA", PAL["green"]), ("CENTRE_DU_QUEBEC", PAL["violet"]), ("MAURICIE", PAL["red"]), ] rates_grid = np.linspace(2, 7, 51) fig, ax = new_fig(h=5.6, top=0.80, right=0.88) ends = [] for sheet, col in AFF_MARKETS: bench = nsa[sheet]["Composite_Benchmark"].iloc[-1] pays = [paiement_mensuel(bench, r) for r in rates_grid] ax.plot(rates_grid, pays, color=col, lw=2.0, label=NOMS[sheet]) ends.append((rates_grid[-1], pays[-1], col)) style_ax(ax) ax.set_xlabel("Taux hypothécaire (%)") ax.set_ylabel("Paiement mensuel ($)") ax.yaxis.set_major_formatter(FuncFormatter(kfmt)) gap = (ax.get_ylim()[1] - ax.get_ylim()[0]) * 0.045 ends_sorted = sorted(ends, key=lambda t: t[1]) ys = [] for _, y, _ in ends_sorted: yy = y if not ys else max(y, ys[-1] + gap) ys.append(yy) for (x, y, col), yy in zip(ends_sorted, ys): ax.plot([x], [y], "o", ms=4.5, color=col, zorder=5, clip_on=False) ax.annotate(kfmt(y) + " $", (x, yy), xytext=(7, 0), textcoords="offset points", va="center", ha="left", fontsize=8.4, fontweight="bold", color=col, clip_on=False, annotation_clip=False) ax.legend(loc="upper left", ncols=2, fontsize=8.8) fig.text(0.012, 0.955, "Ce que coûte vraiment la propriété type : la charge hypothécaire", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.905, "Paiement mensuel selon le taux — prix de référence de juin 2026, mise de fonds de 20 %, " "amortissement de 25 ans", fontsize=10, color=INK2) fig.text(0.012, 0.022, SRC_ACI, fontsize=7.6, color=MUTED) fig.savefig(FIG / "afford01_paiement.pdf") plt.close(fig) # --- tab07 : paiements par marché et par taux ----------------------------------- rates_tab = [3, 4, 5, 6, 7] lines = [] for sheet, _ in AFF_MARKETS: bench = nsa[sheet]["Composite_Benchmark"].iloc[-1] cells = [NOMS[sheet], fr_money(bench)] for r in rates_tab: cells.append(fr_money(paiement_mensuel(bench, r))) lines.append(" & ".join(cells) + " \\\\") tab7 = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{lrrrrrr}\n\\toprule\n" " & & \\multicolumn{5}{c}{Paiement mensuel selon le taux} \\\\\n" "\\cmidrule(lr){3-7}\n" "Marché & \\makecell{Prix de\\\\référence} & 3~\\% & 4~\\% & 5~\\% & 6~\\% & 7~\\% \\\\\n" "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "tab07_abordabilite.tex").write_text(tab7) # ============================================================================= # DYNAMIQUE TRIMESTRIELLE # ============================================================================= print("Figures trimestrielles…") F_Q = DATA / "Not Seasonally Adjusted (Q).xlsx" qdata = pd.read_excel(F_Q, sheet_name=None) def qser(sheet): d = qdata[sheet].set_index("Date")["Composite_HPI"] return d q_qc = qser("QUEBEC").pct_change() * 100 q_ca = qser("AGGREGATE").pct_change() * 100 lastq = q_qc.index[-1] q_qc = q_qc.iloc[-12:] q_ca = q_ca.iloc[-12:] x = np.arange(len(q_qc)) fig, ax = new_fig(h=4.6, right=0.97) ax.bar(x - 0.19, q_ca.values, width=0.36, color=PAL["blue"], label="Canada", zorder=3) ax.bar(x + 0.19, q_qc.values, width=0.36, color=PAL["orange"], label="Québec", zorder=3) ax.axhline(0, color=AXIS, lw=0.9) ax.set_xticks(x) ax.set_xticklabels([str(i) for i in q_qc.index], rotation=45, ha="right", fontsize=8.2) ax.set_ylabel("Variation trimestrielle (%)") style_ax(ax) fig.text(0.012, 0.955, "Le pouls trimestriel : douze trimestres de divergence", fontsize=14, fontweight="bold", color=INK) fig.text(0.012, 0.902, "Variation trimestrielle de l'IPP composite (données brutes), " f"{q_qc.index[0]} à {lastq}", fontsize=10, color=INK2) fig.text(0.012, 0.022, SRC_ACI, fontsize=7.6, color=MUTED) ax.legend(loc="lower left", bbox_to_anchor=(-0.005, 1.01), ncols=2, columnspacing=1.3, handlelength=1.5, handletextpad=0.5, borderaxespad=0) fig.savefig(FIG / "fig21_trimestres.pdf") plt.close(fig) # --- tabq1 : indices trimestriels récents ----------------------------------------- q_sheets = ["AGGREGATE"] + QC_REGIONS dfq = pd.DataFrame({COURTS[s]: qser(s) for s in q_sheets}).iloc[-14:] head = "Trimestre & " + " & ".join(dfq.columns) + " \\\\" lines = [] for qtr, row in dfq.iterrows(): lines.append(f"{qtr} & " + " & ".join(fr_num(v) for v in row.values) + " \\\\") tabq = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{l" + "r" * len(dfq.columns) + "}\n\\toprule\n" + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "tabq1_trimestres.tex").write_text(tabq) # ============================================================================= # ANNEXE — tables mensuelles récentes par marché québécois # ============================================================================= print("Tables mensuelles…") for sheet in QC_REGIONS: slug = sheet.lower() d = nsa[sheet] lines = [] for dt in d.index[-24:]: h = d.loc[dt, "Composite_HPI"] b = d.loc[dt, "Composite_Benchmark"] g = yoy(d["Composite_HPI"]).loc[dt] lines.append(f"{MOIS_FR[dt.month-1]}~{dt.year} & {fr_num(h)} & " f"{fr_money(b)} & {fr_pct(g, sign=True)} \\\\") out = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{lrrr}\n\\toprule\n" "Mois & IPP composite & Prix de référence & Var. 12 mois \\\\\n" "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / f"tabm_{slug}.tex").write_text(out) # --- taba6 : prix de référence par segment et par marché --------------------------- seg_rows = [("Composite", "Composite_Benchmark")] + \ [(lab, bcol) for bcol, lab in BENCH_COLS.items()] mk_cols = ["AGGREGATE"] + QC_REGIONS head = "Segment & " + " & ".join(COURTS[s] for s in mk_cols) + " \\\\" lines = [] for lab, bcol in seg_rows: cells = [lab] for s in mk_cols: if bcol in nsa[s].columns: cells.append(fr_num(nsa[s][bcol].iloc[-1] / 1000, 0)) else: cells.append("---") lines.append(" & ".join(cells) + " \\\\") taba6 = ( "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n" "\\begin{tabular}{l" + "r" * len(mk_cols) + "}\n\\toprule\n" + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n" ) (TAB / "taba6_bench_segments.tex").write_text(taba6) # ============================================================================= # Valeurs clés (macros LaTeX) + sommaire JSON # ============================================================================= qc = stats["QUEBEC"] mtl = stats["MONTREAL_CMA"] qcc = stats["QUEBEC_CMA"] can = stats["AGGREGATE"] dd_tor = drawdown(sa["GREATER_TORONTO"]["Composite_HPI"]).iloc[-1] dd_van = drawdown(sa["GREATER_VANCOUVER"]["Composite_HPI"]).iloc[-1] g2020_qc = (nsa["QUEBEC"]["Composite_HPI"].iloc[-1] / nsa["QUEBEC"]["Composite_HPI"].loc["2020-01-01"] - 1) * 100 g2020_can = (nsa["AGGREGATE"]["Composite_HPI"].iloc[-1] / nsa["AGGREGATE"]["Composite_HPI"].loc["2020-01-01"] - 1) * 100 corr_mtl_tor = C.loc["RMR de Montréal", "Grand Toronto"] macros = f"""% ============================================================================= % Auteur : Simon-Pierre Boucher % Fonction : Professeur % Département : Département des sciences administratives % Institution : Université du Québec en Outaouais (UQO) % Courriel : simon-pierre.boucher@uqo.ca % ----------------------------------------------------------------------------- % Valeurs clés générées par code/analyse_hpi_quebec.py — ne pas éditer à la main % ============================================================================= \\newcommand{{\\VcDerniereObs}}{{juin 2026}} \\newcommand{{\\VcQcHPI}}{{{fr_num(qc['hpi'])}}} \\newcommand{{\\VcQcBench}}{{{fr_money(qc['bench'])}}} \\newcommand{{\\VcQcYoY}}{{{fr_pct(qc['yoy'], sign=True)}}} \\newcommand{{\\VcQcCagr}}{{{fr_pct(qc['cagr'])}}} \\newcommand{{\\VcQcMult}}{{{fr_num(qc['hpi'] / 100)}}} \\newcommand{{\\VcCanHPI}}{{{fr_num(can['hpi'])}}} \\newcommand{{\\VcCanBench}}{{{fr_money(can['bench'])}}} \\newcommand{{\\VcCanYoY}}{{{fr_pct(can['yoy'], sign=True)}}} \\newcommand{{\\VcCanCagr}}{{{fr_pct(can['cagr'])}}} \\newcommand{{\\VcMtlBench}}{{{fr_money(mtl['bench'])}}} \\newcommand{{\\VcMtlYoY}}{{{fr_pct(mtl['yoy'], sign=True)}}} \\newcommand{{\\VcMtlCagr}}{{{fr_pct(mtl['cagr'])}}} \\newcommand{{\\VcQccBench}}{{{fr_money(qcc['bench'])}}} \\newcommand{{\\VcQccYoY}}{{{fr_pct(qcc['yoy'], sign=True)}}} \\newcommand{{\\VcCanDD}}{{{fr_pct(can['dd'])}}} \\newcommand{{\\VcTorDD}}{{{fr_pct(dd_tor)}}} \\newcommand{{\\VcVanDD}}{{{fr_pct(dd_van)}}} \\newcommand{{\\VcQcDepuisVingt}}{{{fr_pct(g2020_qc, sign=True)}}} \\newcommand{{\\VcCanDepuisVingt}}{{{fr_pct(g2020_can, sign=True)}}} \\newcommand{{\\VcCorrMtlTor}}{{{fr_num(corr_mtl_tor, 2)}}} \\newcommand{{\\VcSaisonMax}}{{{fr_pct(saison.max())}}} \\newcommand{{\\VcSaisonMin}}{{{fr_pct(saison.min())}}} \\newcommand{{\\VcTauxTroisMois}}{{{fr_pct(taux3m.iloc[-1])}}} \\newcommand{{\\VcTauxDixAns}}{{{fr_pct(taux10a.iloc[-1])}}} \\newcommand{{\\VcInflation}}{{{fr_pct(infl.iloc[-1])}}} \\newcommand{{\\VcChomage}}{{{fr_pct(chomage.iloc[-1])}}} \\newcommand{{\\VcPibYoY}}{{{fr_pct(pib_yoy.iloc[-1], sign=True)}}} \\newcommand{{\\VcQcReelMult}}{{{fr_num(reel_mult)}}} \\newcommand{{\\VcQcReelCagr}}{{{fr_pct(reel_cagr)}}} """ (TAB / "valeurs_cles.tex").write_text(macros) summary = { "derniere_obs": str(LAST.date()), "quebec": {k: (str(v) if isinstance(v, pd.Timestamp) else round(float(v), 2)) for k, v in qc.items()}, "canada": {k: (str(v) if isinstance(v, pd.Timestamp) else round(float(v), 2)) for k, v in can.items()}, "macro": { "taux3m": round(float(taux3m.iloc[-1]), 2), "taux10a": round(float(taux10a.iloc[-1]), 2), "inflation": round(float(infl.iloc[-1]), 2), "inflation_date": str(infl.index[-1].date()), "chomage": round(float(chomage.iloc[-1]), 2), "pib_yoy": round(float(pib_yoy.iloc[-1]), 2), "pib_date": str(pib_yoy.index[-1].date()), "hpi_reel_mult": round(float(reel_mult), 2), "hpi_reel_cagr": round(float(reel_cagr), 2), }, "croisement_qc_canada": str(cross.date()), "volatilite": {"qc": round(float(vol_qc.iloc[-1]), 2), "canada": round(float(vol_ca.iloc[-1]), 2)}, "corr_mobile": {"qc_canada": round(float(c2.iloc[-1]), 2), "mtl_toronto": round(float(c1.iloc[-1]), 2)}, "regions": region_summaries, "pop_growth_2023": round(float((pop.pct_change() * 100).loc["2023-01-01"]), 2), "pop_growth_last": {str(k.year): round(float(v), 2) for k, v in (pop.pct_change() * 100).dropna().tail(4).items()}, "fx_last": round(float(fx_m.iloc[-1]), 3), "beta_last": round(float(beta.iloc[-1]), 2), "beta_mean": round(float(beta.mean()), 2), "roc": {NOMS[s]: {"hpi": round(float(nsa[s]['Composite_HPI'].iloc[-1]), 1), "bench": int(nsa[s]['Composite_Benchmark'].iloc[-1]), "yoy": round(float(yoy(nsa[s]['Composite_HPI']).iloc[-1]), 2), "dd": round(float(drawdown(sa[s]['Composite_HPI']).iloc[-1]), 2), "dd_max": round(float(drawdown(sa[s]['Composite_HPI']).min()), 2)} for s in ROC}, } print(json.dumps(summary, indent=2, ensure_ascii=False)) print("\nTerminé : 26 figures dans figures/, 5 fichiers dans tables/.")