spb/uqo_cours_public Public
UQO — matériel de cours en évaluation immobilière (version publique, sans examens ni corrigés).
TeX 89.1%
Python 10.9%
1# -*- coding: utf-8 -*-2# =============================================================================3# Auteur : Simon-Pierre Boucher4# Fonction : Professeur5# Département : Département des sciences administratives6# Institution : Université du Québec en Outaouais (UQO)7# Courriel : simon-pierre.boucher@uqo.ca8# -----------------------------------------------------------------------------9# Projet : IMM-QUEBEC — Analyse du marché immobilier résidentiel québécois10# Données : (1) Indice des prix des propriétés MLS® (IPP MLS®), ACI/CREA,11# janvier 2005 – juin 2026, base 100 = janvier 2005 ;12# (2) Séries macroéconomiques canadiennes, FRED (St. Louis Fed).13# Rôle : Charge les données, calcule les statistiques et produit14# l'ensemble des figures (PDF vectoriel) et des tables LaTeX15# utilisées dans rapport_immobilier_quebec.tex16# Exécution : python3 code/analyse_hpi_quebec.py (depuis la racine du projet)17# =============================================================================1819import json20import os21import urllib.request22from pathlib import Path2324import numpy as np25import pandas as pd26import matplotlib27matplotlib.use("Agg")28import matplotlib.pyplot as plt29import matplotlib.dates as mdates30from matplotlib.colors import LinearSegmentedColormap, TwoSlopeNorm31from matplotlib.patches import Patch32from matplotlib.ticker import FuncFormatter3334# -----------------------------------------------------------------------------35# Chemins et constantes36# -----------------------------------------------------------------------------37ROOT = Path(__file__).resolve().parent.parent38DATA = ROOT / "data"39FIG = ROOT / "figures"40TAB = ROOT / "tables"41FREDCACHE = DATA / "fred"42for p in (FIG, TAB, FREDCACHE):43 p.mkdir(exist_ok=True)4445FRED_KEY = os.environ.get("FRED_API_KEY", "5d6f76382d7d188e9166bb3b96c8f934")4647SRC_ACI = "Source : ACI/CREA, indice des prix des propriétés MLS® — calculs de l'auteur (S.-P. Boucher, UQO)."48SRC_FRED = "Source : FRED, Federal Reserve Bank of St. Louis (données OCDE/StatCan) — calculs de l'auteur (S.-P. Boucher, UQO)."49SRC_MIX = "Sources : ACI/CREA et FRED — calculs de l'auteur (S.-P. Boucher, UQO)."5051# -----------------------------------------------------------------------------52# Charte graphique (palette validée — ordre catégoriel fixe, jamais recyclé)53# -----------------------------------------------------------------------------54PAL = {55 "blue": "#2a78d6",56 "orange": "#eb6834",57 "aqua": "#1baf7a",58 "yellow": "#eda100",59 "magenta": "#e87ba4",60 "green": "#008300",61 "violet": "#4a3aa7",62 "red": "#e34948",63}64CAT_ORDER = ["blue", "orange", "aqua", "yellow", "magenta", "green", "violet", "red"]65INK = "#0b0b0b"66INK2 = "#52514e"67MUTED = "#898781"68GRID = "#e1e0d9"69AXIS = "#c3c2b7"7071plt.rcParams.update({72 "font.family": "sans-serif",73 "font.sans-serif": ["Helvetica Neue", "Helvetica", "Arial", "DejaVu Sans"],74 "font.size": 10,75 "axes.labelsize": 9.5,76 "axes.labelcolor": INK2,77 "axes.edgecolor": AXIS,78 "axes.linewidth": 0.8,79 "xtick.color": INK2,80 "ytick.color": INK2,81 "xtick.labelsize": 9,82 "ytick.labelsize": 9,83 "legend.frameon": False,84 "legend.fontsize": 9.5,85 "figure.facecolor": "white",86 "axes.facecolor": "white",87 "savefig.dpi": 300,88})8990SEQ_CMAP = LinearSegmentedColormap.from_list(91 "seq_blue", ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"]92)93DIV_CMAP = LinearSegmentedColormap.from_list(94 "div_bluered", ["#104281", "#3987e5", "#9ec5f4", "#f0efec", "#f2a1a0", "#e34948", "#8f1d1c"]95)9697EVENTS = [98 (pd.Timestamp("2008-09-01"), pd.Timestamp("2009-06-01"), "Crise\nfinancière"),99 (pd.Timestamp("2020-03-01"), pd.Timestamp("2020-08-01"), "Pandémie"),100 (pd.Timestamp("2022-03-01"), pd.Timestamp("2023-07-01"), "Resserrement\nmonétaire"),101]102103104# =============================================================================105# Boîte à outils graphique106# =============================================================================107def new_fig(w=10.5, h=5.5, left=0.072, right=0.9, top=0.80, bottom=0.115):108 fig, ax = plt.subplots(figsize=(w, h))109 fig.subplots_adjust(left=left, right=right, top=top, bottom=bottom)110 return fig, ax111112113def style_ax(ax, ygrid=True):114 """Grille discrète, axes en retrait (chrome récessif)."""115 for side in ("top", "right", "left"):116 ax.spines[side].set_visible(False)117 ax.spines["bottom"].set_color(AXIS)118 if ygrid:119 ax.grid(axis="y", color=GRID, linewidth=0.7)120 ax.set_axisbelow(True)121 ax.tick_params(length=0)122123124def event_bands(ax, labels=True, y=0.985):125 """Bandes grisées des grands épisodes macro-financiers."""126 for a, b, lab in EVENTS:127 ax.axvspan(a, b, color=INK, alpha=0.05, zorder=0, lw=0)128 if labels:129 mid = a + (b - a) / 2130 ax.text(mid, y, lab, transform=ax.get_xaxis_transform(),131 fontsize=7.3, color=MUTED, ha="center", va="top",132 linespacing=1.1)133134135def finish(fig, ax, title, subtitle, source, fname, legend_ncols=0):136 """Titre + sous-titre alignés à gauche, légende horizontale, source en pied."""137 if legend_ncols:138 ax.legend(loc="lower left", bbox_to_anchor=(-0.005, 1.01),139 ncols=legend_ncols, columnspacing=1.3, handlelength=1.5,140 handletextpad=0.5, borderaxespad=0)141 fig.text(0.012, 0.955, title, fontsize=14, fontweight="bold", color=INK)142 fig.text(0.012, 0.902, subtitle, fontsize=10, color=INK2)143 fig.text(0.012, 0.022, source, fontsize=7.6, color=MUTED)144 fig.savefig(FIG / fname)145 plt.close(fig)146147148def date_axis(ax, start="2004-08-01", end=None, step=2):149 ax.set_xlim(pd.Timestamp(start), end)150 ax.xaxis.set_major_locator(mdates.YearLocator(step))151 ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))152153154def end_labels(ax, items, fmt):155 """Étiquettes de fin de série (valeur), avec anti-chevauchement vertical."""156 ymin, ymax = ax.get_ylim()157 gap = (ymax - ymin) * 0.052158 items = sorted(items, key=lambda t: t[1])159 ys = []160 for _, y, _ in items:161 yy = y if not ys else max(y, ys[-1] + gap)162 ys.append(yy)163 for (x, y, col), yy in zip(items, ys):164 ax.plot([x], [y], "o", ms=4.5, color=col, zorder=5, clip_on=False)165 ax.annotate(fmt(y), (x, yy), xytext=(7, 0), textcoords="offset points",166 va="center", ha="left", fontsize=8.6, fontweight="bold",167 color=col, clip_on=False, annotation_clip=False)168169170def fr(v, dec=1):171 return f"{v:,.{dec}f}".replace(",", " ").replace(".", ",")172173174def kfmt(v, _=None):175 return f"{v:,.0f}".replace(",", " ")176177178# =============================================================================179# Chargement des données ACI/CREA180# =============================================================================181F_M_NSA = DATA / "Not Seasonally Adjusted (M).xlsx"182F_M_SA = DATA / "Seasonally Adjusted (M).xlsx"183F_A = DATA / "Not Seasonally Adjusted (A).xlsx"184185NOMS = {186 "AGGREGATE": "Canada",187 "QUEBEC": "Québec (province)",188 "MONTREAL_CMA": "RMR de Montréal",189 "QUEBEC_CMA": "RMR de Québec",190 "ESTRIE": "Estrie",191 "MAURICIE": "Mauricie",192 "CENTRE_DU_QUEBEC": "Centre-du-Québec",193 "ONTARIO": "Ontario",194 "BRITISH_COLUMBIA": "Colombie-Britannique",195 "ALBERTA": "Alberta",196 "GREATER_TORONTO": "Grand Toronto",197 "GREATER_VANCOUVER": "Grand Vancouver",198 "OTTAWA": "Ottawa",199 "CALGARY": "Calgary",200 "HALIFAX_DARTMOUTH": "Halifax-Dartmouth",201 "WINNIPEG": "Winnipeg",202}203QC_REGIONS = ["QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE", "CENTRE_DU_QUEBEC"]204MOIS_FR = ["janv.", "févr.", "mars", "avr.", "mai", "juin",205 "juill.", "août", "sept.", "oct.", "nov.", "déc."]206207208def load_sheet(path, sheet):209 df = pd.read_excel(path, sheet_name=sheet)210 df.columns = [c.replace("_SA", "") for c in df.columns]211 df["Date"] = pd.to_datetime(df["Date"])212 return df.set_index("Date")213214215print("Chargement des données ACI/CREA…")216nsa = {s: load_sheet(F_M_NSA, s) for s in NOMS}217sa = {s: load_sheet(F_M_SA, s) for s in NOMS}218annual = pd.read_excel(F_A, sheet_name=None)219LAST = nsa["QUEBEC"].index[-1]220XEND = LAST + pd.DateOffset(months=3)221print(f"Dernière observation IPP : {LAST:%Y-%m}")222223224# =============================================================================225# Chargement des données macroéconomiques (FRED, avec cache local)226# =============================================================================227def fred(series_id):228 cache = FREDCACHE / f"{series_id}.json"229 if cache.exists():230 payload = json.loads(cache.read_text())231 else:232 url = ("https://api.stlouisfed.org/fred/series/observations"233 f"?series_id={series_id}&api_key={FRED_KEY}&file_type=json"234 "&observation_start=2003-01-01")235 with urllib.request.urlopen(url, timeout=60) as r:236 payload = json.load(r)237 cache.write_text(json.dumps(payload))238 obs = {pd.Timestamp(o["date"]): float(o["value"])239 for o in payload["observations"] if o["value"] != "."}240 return pd.Series(obs).sort_index()241242243print("Chargement des données FRED…")244taux3m = fred("IR3TIB01CAM156N") # taux interbancaire 3 mois, Canada245taux10a = fred("IRLTLT01CAM156N") # rendement obligataire 10 ans, Canada246infl = fred("CPALTT01CAM659N") # inflation IPC, glissement annuel247ipc = fred("CANCPIALLMINMEI") # IPC, niveau (2015 = 100)248chomage = fred("LRUNTTTTCAM156S") # taux de chômage, Canada249pib = fred("NGDPRSAXDCCAQ") # PIB réel trimestriel, Canada250pib_yoy = pib.pct_change(4) * 100251print(f"FRED : taux jusqu'à {taux3m.index[-1]:%Y-%m}, IPC jusqu'à {ipc.index[-1]:%Y-%m}")252253254# =============================================================================255# Fonctions statistiques256# =============================================================================257def yoy(series, k=12):258 return series.pct_change(k) * 100259260261def cagr(series, start=None, end=None):262 s = series.dropna()263 if start is not None:264 s = s[s.index >= start]265 if end is not None:266 s = s[s.index <= end]267 years = (s.index[-1] - s.index[0]).days / 365.25268 return ((s.iloc[-1] / s.iloc[0]) ** (1 / years) - 1) * 100269270271def drawdown(series):272 s = series.dropna()273 return (s / s.cummax() - 1) * 100274275276def fr_num(x, dec=1):277 return f"{x:,.{dec}f}".replace(",", "~").replace(".", ",")278279280def fr_money(x):281 return fr_num(x, 0) + "~\\$"282283284def fr_pct(x, dec=1, sign=False):285 s = fr_num(x, dec)286 if sign and x > 0:287 s = "+" + s288 return s + "~\\%"289290291# =============================================================================292# SECTION MACRO — figures FRED293# =============================================================================294print("Figures macroéconomiques…")295296# --- macro01 : taux d'intérêt -------------------------------------------------297fig, ax = new_fig()298t3 = taux3m[taux3m.index >= "2004-08-01"]299t10 = taux10a[taux10a.index >= "2004-08-01"]300ax.plot(t3.index, t3.values, color=PAL["blue"], lw=2.1, label="Taux court (3 mois)")301ax.plot(t10.index, t10.values, color=PAL["orange"], lw=2.1, label="Obligations 10 ans")302event_bands(ax)303style_ax(ax)304date_axis(ax, end=XEND)305ax.set_ylabel("Taux (%)")306end_labels(ax, [(t3.index[-1], t3.iloc[-1], PAL["blue"]),307 (t10.index[-1], t10.iloc[-1], PAL["orange"])],308 lambda v: fr(v, 1) + " %")309finish(fig, ax, "Le loyer de l'argent : vingt ans de taux canadiens",310 "Taux interbancaire 3 mois et rendement des obligations fédérales 10 ans, en % — janv. 2005 à juin 2026",311 SRC_FRED, "macro01_taux.pdf", legend_ncols=2)312313# --- macro02 : inflation ------------------------------------------------------314fig, ax = new_fig()315ii = infl[infl.index >= "2004-08-01"]316ax.axhspan(1, 3, color="#cde2fb", alpha=0.55, zorder=0, lw=0)317ax.text(pd.Timestamp("2005-01-01"), 2.62, "Fourchette cible de la Banque du Canada (1–3 %)",318 fontsize=8, color="#1c5cab", va="center")319ax.plot(ii.index, ii.values, color=PAL["blue"], lw=2.1, label="Inflation IPC (glissement annuel)")320ax.axhline(0, color=AXIS, lw=0.8)321event_bands(ax)322style_ax(ax)323date_axis(ax, end=XEND)324ax.set_ylabel("Variation sur 12 mois (%)")325end_labels(ax, [(ii.index[-1], ii.iloc[-1], PAL["blue"])], lambda v: fr(v, 1) + " %")326finish(fig, ax, "L'inflation canadienne : la poussée de 2021-2022 et sa résorption",327 "Variation sur 12 mois de l'IPC d'ensemble, Canada, en % — la bande bleue marque la cible de 1 à 3 %",328 SRC_FRED, "macro02_inflation.pdf", legend_ncols=1)329330# --- macro03 : croissance du PIB réel ----------------------------------------331fig, ax = new_fig()332g = pib_yoy[pib_yoy.index >= "2004-10-01"]333colors = [PAL["blue"] if v >= 0 else PAL["red"] for v in g.values]334ax.bar(g.index, g.values, width=80, color=colors, zorder=3)335ax.axhline(0, color=AXIS, lw=0.8)336style_ax(ax)337date_axis(ax, end=XEND)338ax.set_ylabel("Variation sur 4 trimestres (%)")339ax.legend(handles=[Patch(color=PAL["blue"], label="Croissance"),340 Patch(color=PAL["red"], label="Contraction")],341 loc="lower left", bbox_to_anchor=(-0.005, 1.01), ncols=2,342 columnspacing=1.3, handlelength=1.5, handletextpad=0.5, borderaxespad=0)343fig.text(0.012, 0.955, "L'économie réelle : croissance du PIB canadien",344 fontsize=14, fontweight="bold", color=INK)345fig.text(0.012, 0.902, "PIB réel trimestriel, variation sur 4 trimestres, en % — deux chocs : 2009 et 2020",346 fontsize=10, color=INK2)347fig.text(0.012, 0.022, SRC_FRED, fontsize=7.6, color=MUTED)348fig.savefig(FIG / "macro03_pib.pdf")349plt.close(fig)350351# --- macro04 : chômage --------------------------------------------------------352fig, ax = new_fig()353u = chomage[chomage.index >= "2004-08-01"]354ax.plot(u.index, u.values, color=PAL["blue"], lw=2.1, label="Taux de chômage (15 ans et +, dés.)")355event_bands(ax)356style_ax(ax)357date_axis(ax, end=XEND)358ax.set_ylabel("Taux de chômage (%)")359end_labels(ax, [(u.index[-1], u.iloc[-1], PAL["blue"])], lambda v: fr(v, 1) + " %")360finish(fig, ax, "Le marché du travail canadien",361 "Taux de chômage mensuel désaisonnalisé, 15 ans et plus, en % — le pic pandémique de 2020 dépasse 13 %",362 SRC_FRED, "macro04_chomage.pdf", legend_ncols=1)363364# --- macro05 : transmission taux -> prix (2 panneaux) --------------------------365fig, axes = plt.subplots(2, 1, figsize=(10.5, 7.2), sharex=True,366 height_ratios=[1, 1.25])367fig.subplots_adjust(left=0.072, right=0.9, top=0.855, bottom=0.085, hspace=0.14)368ax1, ax2 = axes369ax1.plot(t3.index, t3.values, color=PAL["violet"], lw=2.1, label="Taux court 3 mois (%)")370ax1.set_ylabel("Taux (%)")371event_bands(ax1, labels=False)372style_ax(ax1)373ax1.legend(loc="upper left", handlelength=1.5)374y_qc = yoy(nsa["QUEBEC"]["Composite_HPI"])375y_ca = yoy(nsa["AGGREGATE"]["Composite_HPI"])376ax2.axhline(0, color=AXIS, lw=0.8)377ax2.plot(y_ca.index, y_ca.values, color=PAL["blue"], lw=2.1, label="IPP Canada (var. 12 mois, %)")378ax2.plot(y_qc.index, y_qc.values, color=PAL["orange"], lw=2.1, label="IPP Québec (var. 12 mois, %)")379event_bands(ax2, labels=False)380style_ax(ax2)381ax2.legend(loc="upper left", ncols=2, handlelength=1.5)382ax2.set_ylabel("Variation sur 12 mois (%)")383date_axis(ax2, end=XEND)384end_labels(ax2, [(y_ca.index[-1], y_ca.iloc[-1], PAL["blue"]),385 (y_qc.index[-1], y_qc.iloc[-1], PAL["orange"])],386 lambda v: fr(v, 1) + " %")387fig.text(0.012, 0.965, "La transmission monétaire : mêmes taux, réponses opposées",388 fontsize=14, fontweight="bold", color=INK)389fig.text(0.012, 0.925, "Haut : taux court canadien. Bas : croissance des prix de l'habitation — "390 "le choc de taux de 2022 fait plonger le Canada, le Québec ne fait que ralentir",391 fontsize=10, color=INK2)392fig.text(0.012, 0.018, SRC_MIX, fontsize=7.6, color=MUTED)393fig.savefig(FIG / "macro05_transmission.pdf")394plt.close(fig)395396# --- macro06 : prix nominal vs réel -------------------------------------------397fig, ax = new_fig()398h_qc = nsa["QUEBEC"]["Composite_HPI"]399ipc_m = ipc.reindex(h_qc.index).ffill()400reel = (h_qc / ipc_m) * ipc_m.iloc[0]401reel = reel[reel.index <= ipc.index[-1]]402ax.plot(h_qc.index, h_qc.values, color=PAL["blue"], lw=2.1, label="IPP nominal")403ax.plot(reel.index, reel.values, color=PAL["orange"], lw=2.1,404 label="IPP réel (déflaté par l'IPC)")405ax.axhline(100, color=AXIS, lw=0.8)406event_bands(ax)407style_ax(ax)408date_axis(ax, end=XEND)409ax.set_ylabel("Indice (janv. 2005 = 100)")410end_labels(ax, [(h_qc.index[-1], h_qc.iloc[-1], PAL["blue"]),411 (reel.index[-1], reel.iloc[-1], PAL["orange"])],412 lambda v: fr(v, 0))413finish(fig, ax, "Au-delà de l'inflation : le prix réel des propriétés québécoises",414 "IPP composite du Québec, nominal et déflaté par l'IPC canadien (janv. 2005 = 100) — série réelle jusqu'en mars 2025",415 SRC_MIX, "macro06_reel.pdf", legend_ncols=2)416417reel_mult = reel.iloc[-1] / 100418reel_cagr = cagr(reel)419420# =============================================================================421# FIGURES PRINCIPALES — IPP MLS®422# =============================================================================423print("Figures IPP…")424425# --- fig01 : provinces ---------------------------------------------------------426fig, ax = new_fig(top=0.78)427series_f1 = [428 ("AGGREGATE", "Canada", PAL["blue"]),429 ("QUEBEC", "Québec", PAL["orange"]),430 ("ONTARIO", "Ontario", PAL["aqua"]),431 ("BRITISH_COLUMBIA", "Colombie-Britannique", PAL["yellow"]),432 ("ALBERTA", "Alberta", PAL["magenta"]),433]434ends = []435for sheet, lab, col in series_f1:436 s = nsa[sheet]["Composite_HPI"]437 ax.plot(s.index, s.values, color=col, lw=2.1, label=lab)438 ends.append((s.index[-1], s.iloc[-1], col))439event_bands(ax)440style_ax(ax)441date_axis(ax, end=XEND)442ax.set_ylabel("IPP composite (janv. 2005 = 100)")443end_labels(ax, ends, lambda v: fr(v, 0))444finish(fig, ax, "Vingt ans de prix immobiliers : le Québec dépasse le Canada",445 "IPP MLS® composite, janv. 2005 = 100 — l'Ontario et la C.-B. corrigent depuis 2022, le Québec poursuit sa hausse",446 SRC_ACI, "fig01_hpi_provinces.pdf", legend_ncols=5)447448# --- fig02 : régions du Québec --------------------------------------------------449fig, ax = new_fig(top=0.78)450ends = []451for sheet, colk in zip(QC_REGIONS, CAT_ORDER[:6]):452 s = nsa[sheet]["Composite_HPI"]453 ax.plot(s.index, s.values, color=PAL[colk], lw=2.1, label=NOMS[sheet])454 ends.append((s.index[-1], s.iloc[-1], PAL[colk]))455event_bands(ax)456style_ax(ax)457date_axis(ax, end=XEND)458ax.set_ylabel("IPP composite (janv. 2005 = 100)")459end_labels(ax, ends, lambda v: fr(v, 0))460finish(fig, ax, "Le grand rattrapage des régions québécoises",461 "IPP MLS® composite des six marchés québécois, janv. 2005 = 100 — les régions dépassent Montréal après 2020",462 SRC_ACI, "fig02_hpi_quebec_regions.pdf", legend_ncols=6)463464# --- fig03 : prix de référence --------------------------------------------------465fig, ax = new_fig(top=0.78)466series_f3 = [467 ("AGGREGATE", "Canada", PAL["blue"]),468 ("QUEBEC", "Québec (province)", PAL["orange"]),469 ("MONTREAL_CMA", "RMR de Montréal", PAL["aqua"]),470 ("QUEBEC_CMA", "RMR de Québec", PAL["yellow"]),471]472ends = []473for sheet, lab, col in series_f3:474 s = nsa[sheet]["Composite_Benchmark"] / 1000475 ax.plot(s.index, s.values, color=col, lw=2.1, label=lab)476 ends.append((s.index[-1], s.iloc[-1], col))477event_bands(ax)478style_ax(ax)479date_axis(ax, end=XEND)480ax.set_ylabel("Prix de référence (milliers de $)")481ax.yaxis.set_major_formatter(FuncFormatter(kfmt))482end_labels(ax, ends, lambda v: fr(v, 0) + " k$")483finish(fig, ax, "Combien coûte la propriété type ? L'écart Québec-Canada se referme",484 "Prix de référence composite, en milliers de dollars courants — janv. 2005 à juin 2026",485 SRC_ACI, "fig03_benchmark.pdf", legend_ncols=4)486487# --- fig04 : glissement annuel QC vs Canada -------------------------------------488fig, ax = new_fig(top=0.78)489ax.axhline(0, color=AXIS, lw=0.8)490ax.plot(y_ca.index, y_ca.values, color=PAL["blue"], lw=2.1, label="Canada")491ax.plot(y_qc.index, y_qc.values, color=PAL["orange"], lw=2.1, label="Québec")492event_bands(ax)493style_ax(ax)494date_axis(ax, end=XEND)495ax.set_ylabel("Variation sur 12 mois (%)")496end_labels(ax, [(y_ca.index[-1], y_ca.iloc[-1], PAL["blue"]),497 (y_qc.index[-1], y_qc.iloc[-1], PAL["orange"])],498 lambda v: fr(v, 1) + " %")499finish(fig, ax, "Deux cycles qui divergent depuis 2024",500 "Variation sur 12 mois de l'IPP composite, en % — hausse soutenue au Québec, prix en baisse au Canada",501 SRC_ACI, "fig04_yoy.pdf", legend_ncols=2)502503# --- fig05 : types de propriété, Québec -----------------------------------------504TYPES5 = {505 "Single_Family_HPI": "Unifamiliale",506 "One_Storey_HPI": "Plain-pied",507 "Two_Storey_HPI": "À étages",508 "Townhouse_HPI": "En rangée",509 "Apartment_HPI": "Appartement",510}511fig, ax = new_fig(top=0.78)512ends = []513for (col_name, lab), colk in zip(TYPES5.items(), CAT_ORDER[:5]):514 s = nsa["QUEBEC"][col_name]515 ax.plot(s.index, s.values, color=PAL[colk], lw=2.1, label=lab)516 ends.append((s.index[-1], s.iloc[-1], PAL[colk]))517event_bands(ax)518style_ax(ax)519date_axis(ax, end=XEND)520ax.set_ylabel("IPP (janv. 2005 = 100)")521end_labels(ax, ends, lambda v: fr(v, 0))522finish(fig, ax, "La prime au terrain : maisons contre copropriétés",523 "IPP MLS® par type de propriété, Québec, janv. 2005 = 100 — l'appartement décroche après 2012",524 SRC_ACI, "fig05_types_quebec.pdf", legend_ncols=5)525526# --- fig06 : carte thermique annuelle -------------------------------------------527heat_rows = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE",528 "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO", "GREATER_TORONTO",529 "BRITISH_COLUMBIA", "GREATER_VANCOUVER", "ALBERTA", "CALGARY"]530years = None531mat = []532for sheet in heat_rows:533 da = annual[sheet].set_index("Date")["Composite_HPI"]534 g = (da.pct_change() * 100).dropna()535 if years is None:536 years = g.index.tolist()537 mat.append(g.reindex(years).values)538mat = np.array(mat)539540fig, ax = plt.subplots(figsize=(10.5, 5.6))541fig.subplots_adjust(left=0.155, right=0.985, top=0.845, bottom=0.09)542vmax = np.nanmax(np.abs(mat))543im = ax.imshow(mat, aspect="auto", cmap=DIV_CMAP,544 norm=TwoSlopeNorm(vcenter=0, vmin=-vmax, vmax=vmax))545ax.set_xticks(range(len(years)))546ax.set_xticklabels([str(y) for y in years], fontsize=8.5)547ax.set_yticks(range(len(heat_rows)))548ax.set_yticklabels([NOMS[s] for s in heat_rows], fontsize=9)549for i in range(mat.shape[0]):550 for j in range(mat.shape[1]):551 v = mat[i, j]552 if np.isfinite(v):553 ax.text(j, i, f"{v:.0f}", ha="center", va="center", fontsize=7.2,554 color="white" if abs(v) / vmax > 0.5 else INK2)555ax.set_xticks(np.arange(-0.5, len(years), 1), minor=True)556ax.set_yticks(np.arange(-0.5, len(heat_rows), 1), minor=True)557ax.grid(which="minor", color="white", linewidth=1.6)558ax.tick_params(which="both", length=0)559for s in ax.spines.values():560 s.set_visible(False)561fig.text(0.012, 0.955, "Vingt ans d'histoire en une image : les variations annuelles par marché",562 fontsize=14, fontweight="bold", color=INK)563fig.text(0.012, 0.905, "Variation annuelle de l'IPP composite, en % — rouge : hausse ; bleu : baisse. "564 "Lire la divergence Québec / Ontario-C.-B. après 2022.",565 fontsize=10, color=INK2)566fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED)567fig.savefig(FIG / "fig06_heatmap_yoy.pdf")568plt.close(fig)569570# --- fig07 : repli depuis le sommet ---------------------------------------------571fig, ax = new_fig(top=0.78)572series_f7 = [573 ("AGGREGATE", "Canada", PAL["blue"]),574 ("QUEBEC", "Québec", PAL["orange"]),575 ("GREATER_TORONTO", "Grand Toronto", PAL["aqua"]),576 ("GREATER_VANCOUVER", "Grand Vancouver", PAL["yellow"]),577]578ends = []579for sheet, lab, col in series_f7:580 dd = drawdown(sa[sheet]["Composite_HPI"])581 ax.plot(dd.index, dd.values, color=col, lw=2.1, label=lab)582 ends.append((dd.index[-1], dd.iloc[-1], col))583ax.axhline(0, color=AXIS, lw=0.8)584event_bands(ax, y=0.13)585style_ax(ax)586date_axis(ax, end=XEND)587ax.set_ylabel("Écart au sommet historique (%)")588end_labels(ax, ends, lambda v: fr(v, 1) + " %")589finish(fig, ax, "La correction que le Québec n'a pas eue",590 "Écart au sommet historique de l'IPP composite désaisonnalisé, en % — Toronto reste 26 % sous son pic de 2022",591 SRC_ACI, "fig07_drawdown.pdf", legend_ncols=4)592593# --- fig08 : saisonnalité -------------------------------------------------------594ratio = (nsa["QUEBEC"]["Composite_HPI"] / sa["QUEBEC"]["Composite_HPI"] - 1) * 100595saison = ratio.groupby(ratio.index.month).mean()596fig, ax = new_fig(w=9, h=4.6, right=0.97)597colors = [PAL["blue"] if v >= 0 else PAL["red"] for v in saison.values]598ax.bar(range(1, 13), saison.values, color=colors, width=0.62, zorder=3)599for m, v in saison.items():600 ax.text(m, v + (0.05 if v >= 0 else -0.05), fr(v, 2).replace(",", ",\u200a"),601 ha="center", va="bottom" if v >= 0 else "top", fontsize=8.2,602 color=INK2)603ax.axhline(0, color=AXIS, lw=0.8)604ax.set_xticks(range(1, 13))605ax.set_xticklabels(MOIS_FR)606ax.set_ylabel("Écart brut / désaisonnalisé (%)")607ax.set_ylim(saison.min() - 0.45, saison.max() + 0.45)608style_ax(ax)609ax.legend(handles=[Patch(color=PAL["blue"], label="Prix au-dessus de la tendance"),610 Patch(color=PAL["red"], label="Prix sous la tendance")],611 loc="lower left", bbox_to_anchor=(-0.005, 1.01), ncols=2,612 columnspacing=1.3, handlelength=1.5, handletextpad=0.5, borderaxespad=0)613fig.text(0.012, 0.955, "Le rythme des saisons : acheter en décembre, vendre en avril",614 fontsize=14, fontweight="bold", color=INK)615fig.text(0.012, 0.902, "Facteur saisonnier moyen de l'IPP composite québécois, par mois, 2005-2026, en %",616 fontsize=10, color=INK2)617fig.text(0.012, 0.022, SRC_ACI, fontsize=7.6, color=MUTED)618fig.savefig(FIG / "fig08_saisonnalite.pdf")619plt.close(fig)620621# --- fig09 : matrice de corrélations --------------------------------------------622corr_sheets = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE",623 "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO", "GREATER_TORONTO",624 "BRITISH_COLUMBIA", "GREATER_VANCOUVER", "ALBERTA"]625dfc = pd.DataFrame({NOMS[s]: yoy(nsa[s]["Composite_HPI"]) for s in corr_sheets}).dropna()626C = dfc.corr()627fig, ax = plt.subplots(figsize=(9.4, 8.2))628fig.subplots_adjust(left=0.20, right=0.98, top=0.83, bottom=0.185)629im = ax.imshow(C.values, cmap=DIV_CMAP, vmin=-1, vmax=1)630ax.set_xticks(range(len(C)))631ax.set_xticklabels(C.columns, rotation=42, ha="right", fontsize=8.7)632ax.set_yticks(range(len(C)))633ax.set_yticklabels(C.columns, fontsize=8.7)634for i in range(len(C)):635 for j in range(len(C)):636 v = C.values[i, j]637 ax.text(j, i, f"{v:.2f}".replace(".", ","), ha="center", va="center",638 fontsize=7.4, color="white" if abs(v) > 0.72 else INK2)639ax.set_xticks(np.arange(-0.5, len(C), 1), minor=True)640ax.set_yticks(np.arange(-0.5, len(C), 1), minor=True)641ax.grid(which="minor", color="white", linewidth=1.6)642ax.tick_params(which="both", length=0)643for s in ax.spines.values():644 s.set_visible(False)645fig.text(0.012, 0.965, "Deux blocs étanches : le cycle québécois n'est pas le cycle canadien",646 fontsize=14, fontweight="bold", color=INK)647fig.text(0.012, 0.925, "Corrélations des variations sur 12 mois de l'IPP composite, 2006-2026 — "648 "rouge foncé : co-mouvement fort",649 fontsize=10, color=INK2)650fig.text(0.012, 0.014, SRC_ACI, fontsize=7.6, color=MUTED)651fig.savefig(FIG / "fig09_correlations.pdf")652plt.close(fig)653654# --- fig10 : croissance annuelle moyenne 2005-2026 -------------------------------655bars_sheets = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE",656 "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO", "GREATER_TORONTO",657 "OTTAWA", "BRITISH_COLUMBIA", "GREATER_VANCOUVER", "ALBERTA",658 "CALGARY", "HALIFAX_DARTMOUTH", "WINNIPEG"]659cg = {NOMS[s]: cagr(nsa[s]["Composite_HPI"]) for s in bars_sheets}660qc_set = {NOMS[s] for s in QC_REGIONS}661cg = dict(sorted(cg.items(), key=lambda kv: kv[1]))662fig, ax = plt.subplots(figsize=(9.4, 6.4))663fig.subplots_adjust(left=0.21, right=0.955, top=0.845, bottom=0.10)664labels = list(cg.keys())665vals = list(cg.values())666colors = [PAL["orange"] if l in qc_set else PAL["blue"] for l in labels]667ax.barh(range(len(vals)), vals, color=colors, height=0.62, zorder=3)668ax.set_yticks(range(len(labels)))669ax.set_yticklabels(labels, fontsize=9.5)670for i, v in enumerate(vals):671 ax.text(v + 0.07, i, fr(v, 1) + " %", va="center", fontsize=8.6, color=INK2)672ax.set_xlabel("Croissance annuelle moyenne de l'IPP composite (%)")673ax.set_xlim(0, max(vals) * 1.13)674style_ax(ax, ygrid=False)675ax.grid(axis="x", color=GRID, linewidth=0.7)676ax.legend(handles=[Patch(color=PAL["orange"], label="Marchés québécois"),677 Patch(color=PAL["blue"], label="Reste du Canada")],678 loc="lower right")679fig.text(0.012, 0.955, "Palmarès 2005-2026 : les quatre premiers sont québécois",680 fontsize=14, fontweight="bold", color=INK)681fig.text(0.012, 0.91, "Croissance annuelle moyenne composée de l'IPP composite, seize marchés canadiens, en %",682 fontsize=10, color=INK2)683fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED)684fig.savefig(FIG / "fig10_cagr.pdf")685plt.close(fig)686687# --- fig11 : prix de référence par type, Montréal --------------------------------688BENCH_TYPES = {689 "Single_Family_Benchmark": "Unifamiliale",690 "One_Storey_Benchmark": "Plain-pied",691 "Two_Storey_Benchmark": "À étages",692 "Townhouse_Benchmark": "En rangée",693 "Apartment_Benchmark": "Appartement",694}695fig, ax = new_fig(top=0.78)696ends = []697for (col_name, lab), colk in zip(BENCH_TYPES.items(), CAT_ORDER[:5]):698 s = nsa["MONTREAL_CMA"][col_name] / 1000699 ax.plot(s.index, s.values, color=PAL[colk], lw=2.1, label=lab)700 ends.append((s.index[-1], s.iloc[-1], PAL[colk]))701event_bands(ax)702style_ax(ax)703date_axis(ax, end=XEND)704ax.set_ylabel("Prix de référence (milliers de $)")705ax.yaxis.set_major_formatter(FuncFormatter(kfmt))706end_labels(ax, ends, lambda v: fr(v, 0) + " k$")707finish(fig, ax, "L'éventail montréalais : de 447 à 841 milliers de dollars selon le segment",708 "Prix de référence par type de propriété, RMR de Montréal, milliers de dollars courants",709 SRC_ACI, "fig11_types_montreal.pdf", legend_ncols=5)710711# --- fig12 : depuis 2020 ----------------------------------------------------------712fig, ax = new_fig(top=0.78)713base = pd.Timestamp("2020-01-01")714series_f12 = [("AGGREGATE", "Canada", PAL["blue"])] + \715 [(s, NOMS[s], PAL[c]) for s, c in zip(QC_REGIONS, CAT_ORDER[1:7])]716ends = []717for sheet, lab, col in series_f12:718 s = nsa[sheet]["Composite_HPI"]719 s = s[s.index >= base]720 s = s / s.iloc[0] * 100721 ax.plot(s.index, s.values, color=col, lw=2.1, label=lab)722 ends.append((s.index[-1], s.iloc[-1], col))723ax.axhline(100, color=AXIS, lw=0.8)724style_ax(ax)725ax.set_xlim(base, LAST + pd.DateOffset(months=1))726ax.xaxis.set_major_locator(mdates.YearLocator(1))727ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))728ax.set_ylabel("IPP composite (janv. 2020 = 100)")729end_labels(ax, ends, lambda v: "+" + fr(v - 100, 0) + " %")730finish(fig, ax, "Depuis la pandémie : +66 % au Québec, +26 % au Canada",731 "IPP composite rebasé à 100 en janvier 2020 — étiquettes : croissance cumulée depuis janv. 2020",732 SRC_ACI, "fig12_depuis2020.pdf", legend_ncols=4)733734# =============================================================================735# FIGURES ANALYTIQUES SUPPLÉMENTAIRES736# =============================================================================737print("Figures analytiques…")738739# --- fig13 : petits multiples, glissement annuel par région ----------------------740fig, axes = plt.subplots(2, 3, figsize=(11.5, 6.4), sharex=True, sharey=True)741fig.subplots_adjust(left=0.06, right=0.985, top=0.775, bottom=0.09,742 hspace=0.34, wspace=0.07)743y_can = yoy(nsa["AGGREGATE"]["Composite_HPI"])744for axx, sheet in zip(axes.flat, QC_REGIONS):745 yy_r = yoy(nsa[sheet]["Composite_HPI"])746 axx.axhline(0, color=AXIS, lw=0.7)747 axx.plot(y_can.index, y_can.values, color=AXIS, lw=1.3)748 axx.plot(yy_r.index, yy_r.values, color=PAL["orange"], lw=1.9)749 axx.set_title(NOMS[sheet], fontsize=10, fontweight="bold", color=INK, pad=4)750 style_ax(axx)751 axx.xaxis.set_major_locator(mdates.YearLocator(5))752 axx.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))753 axx.text(0.02, 0.93, f"+{fr(yy_r.iloc[-1], 1)} %" if yy_r.iloc[-1] > 0754 else f"{fr(yy_r.iloc[-1], 1)} %",755 transform=axx.transAxes, fontsize=9.5, fontweight="bold",756 color=PAL["orange"], va="top")757handles = [plt.Line2D([], [], color=PAL["orange"], lw=1.9, label="Marché régional"),758 plt.Line2D([], [], color=AXIS, lw=1.3, label="Canada (référence)")]759fig.legend(handles=handles, loc="lower left", bbox_to_anchor=(0.052, 0.845),760 ncols=2, frameon=False)761fig.text(0.012, 0.955, "Six marchés, un même tournant : le glissement annuel région par région",762 fontsize=14, fontweight="bold", color=INK)763fig.text(0.012, 0.912, "Variation sur 12 mois de l'IPP composite, en % — la valeur affichée est celle de juin 2026 ; "764 "gris : Canada", fontsize=10, color=INK2)765fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED)766fig.savefig(FIG / "fig13_smallmultiples_yoy.pdf")767plt.close(fig)768769# --- fig14 : volatilité mobile ----------------------------------------------------770fig, ax = new_fig(top=0.78)771vol_ca = (sa["AGGREGATE"]["Composite_HPI"].pct_change()772 .rolling(24).std() * np.sqrt(12) * 100)773vol_qc = (sa["QUEBEC"]["Composite_HPI"].pct_change()774 .rolling(24).std() * np.sqrt(12) * 100)775ax.plot(vol_ca.index, vol_ca.values, color=PAL["blue"], lw=2.1, label="Canada")776ax.plot(vol_qc.index, vol_qc.values, color=PAL["orange"], lw=2.1, label="Québec")777event_bands(ax)778style_ax(ax)779date_axis(ax, end=XEND)780ax.set_ylabel("Volatilité annualisée (%)")781end_labels(ax, [(vol_ca.index[-1], vol_ca.iloc[-1], PAL["blue"]),782 (vol_qc.index[-1], vol_qc.iloc[-1], PAL["orange"])],783 lambda v: fr(v, 1) + " %")784finish(fig, ax, "Le risque de prix : les grands emballements sont canadiens",785 "Volatilité annualisée des variations mensuelles désaisonnalisées, fenêtre de 24 mois, en % — "786 "les grands pics (2009, 2018, 2022-2023) sont canadiens",787 SRC_ACI, "fig14_volatilite.pdf", legend_ncols=2)788789# --- fig15 : corrélation mobile ----------------------------------------------------790fig, ax = new_fig(top=0.78)791r_mtl = sa["MONTREAL_CMA"]["Composite_HPI"].pct_change()792r_tor = sa["GREATER_TORONTO"]["Composite_HPI"].pct_change()793r_qc = sa["QUEBEC"]["Composite_HPI"].pct_change()794r_ca = sa["AGGREGATE"]["Composite_HPI"].pct_change()795c1 = r_mtl.rolling(60).corr(r_tor)796c2 = r_qc.rolling(60).corr(r_ca)797ax.plot(c2.index, c2.values, color=PAL["blue"], lw=2.1, label="Québec / Canada")798ax.plot(c1.index, c1.values, color=PAL["orange"], lw=2.1, label="Montréal / Grand Toronto")799ax.axhline(0, color=AXIS, lw=0.8)800ax.set_ylim(-0.65, 1.05)801event_bands(ax, y=0.115)802style_ax(ax)803date_axis(ax, start="2009-06-01", end=XEND)804ax.set_ylabel("Corrélation mobile (60 mois)")805end_labels(ax, [(c2.index[-1], c2.iloc[-1], PAL["blue"]),806 (c1.index[-1], c1.iloc[-1], PAL["orange"])],807 lambda v: fr(v, 2))808finish(fig, ax, "Le découplage : Montréal et Toronto ne dansent plus ensemble",809 "Corrélation mobile (60 mois) des variations mensuelles désaisonnalisées de l'IPP composite",810 SRC_ACI, "fig15_corr_mobile.pdf", legend_ncols=2)811812# --- fig16 : ratio Québec / Canada --------------------------------------------------813fig, ax = new_fig(top=0.80)814ratio_qc = nsa["QUEBEC"]["Composite_HPI"] / nsa["AGGREGATE"]["Composite_HPI"] * 100815ax.plot(ratio_qc.index, ratio_qc.values, color=PAL["orange"], lw=2.2,816 label="IPP Québec / IPP Canada (×100)")817ax.axhline(100, color=AXIS, lw=0.9)818ax.text(pd.Timestamp("2005-06-01"), 100.8, "Parité avec le Canada", fontsize=8,819 color=MUTED)820cross = ratio_qc[ratio_qc >= 100].index[ratio_qc[ratio_qc >= 100].index >821 pd.Timestamp("2015-01-01")][0]822ax.annotate(f"Croisement : {MOIS_FR[cross.month-1]} {cross.year}",823 xy=(cross, 100), xytext=(cross - pd.DateOffset(months=86), 106),824 fontsize=8.5, color=INK2,825 arrowprops=dict(arrowstyle="-", color=MUTED, lw=0.8))826event_bands(ax)827style_ax(ax)828date_axis(ax, end=XEND)829ax.set_ylabel("Ratio (Canada = 100)")830end_labels(ax, [(ratio_qc.index[-1], ratio_qc.iloc[-1], PAL["orange"])],831 lambda v: fr(v, 0))832finish(fig, ax, "De 68 à 112 : la remontée relative du Québec",833 "Ratio de l'IPP composite du Québec sur celui du Canada (×100) — sous 100 : le Québec croît moins vite depuis 2005",834 SRC_ACI, "fig16_ratio_qc_canada.pdf", legend_ncols=1)835836# --- fig17 : nuage rattrapage 2005-2019 vs 2020-2026 ---------------------------------837fig, ax = plt.subplots(figsize=(9.6, 6.8))838fig.subplots_adjust(left=0.08, right=0.975, top=0.845, bottom=0.10)839cut = pd.Timestamp("2020-01-01")840pts = []841for sheet in bars_sheets:842 h = nsa[sheet]["Composite_HPI"]843 pts.append((NOMS[sheet], cagr(h, end=cut), cagr(h, start=cut),844 sheet in QC_REGIONS))845xs = [p[1] for p in pts]846ys = [p[2] for p in pts]847lims = [min(xs + ys) - 0.9, max(xs + ys) + 0.9]848ax.plot(lims, lims, ls=(0, (4, 4)), color=AXIS, lw=1)849ax.text(lims[1] - 0.15, lims[1] + 0.12, "Même rythme avant / après 2020",850 fontsize=8, color=MUTED, ha="right", rotation=32, rotation_mode="anchor")851OFFSETS = {852 "Estrie": (-9, 0, "right"),853 "Mauricie": (9, 4, "left"),854 "Centre-du-Québec": (9, -4, "left"),855 "Halifax-Dartmouth": (-9, 0, "right"),856 "RMR de Québec": (9, 0, "left"),857 "Québec (province)": (-9, 4, "right"),858 "RMR de Montréal": (9, -7, "left"),859 "Winnipeg": (9, 0, "left"),860 "Calgary": (-9, 4, "right"),861 "Ottawa": (-9, -4, "right"),862 "Alberta": (9, -5, "left"),863 "Canada": (9, 3, "left"),864 "Colombie-Britannique": (-9, 3, "right"),865 "Ontario": (-9, -7, "right"),866 "Grand Vancouver": (9, 3, "left"),867 "Grand Toronto": (9, -5, "left"),868}869for name, x, y, is_qc in pts:870 col = PAL["orange"] if is_qc else PAL["blue"]871 ax.scatter(x, y, s=64, color=col, zorder=4, edgecolor="white", lw=1.2)872 dx, dy, ha = OFFSETS.get(name, (0, 8, "center"))873 ax.annotate(name, (x, y), xytext=(dx, dy), textcoords="offset points",874 fontsize=8, color=INK2, ha=ha, va="center")875ax.set_xlim(*lims)876ax.set_ylim(*lims)877style_ax(ax)878ax.grid(axis="x", color=GRID, linewidth=0.7)879ax.set_xlabel("Croissance annuelle moyenne 2005-2019 (%)")880ax.set_ylabel("Croissance annuelle moyenne 2020-2026 (%)")881ax.legend(handles=[Patch(color=PAL["orange"], label="Marchés québécois"),882 Patch(color=PAL["blue"], label="Reste du Canada")],883 loc="lower right")884fig.text(0.012, 0.955, "Le renversement de régime : lents avant 2020, premiers après",885 fontsize=14, fontweight="bold", color=INK)886fig.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",887 fontsize=10, color=INK2)888fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED)889fig.savefig(FIG / "fig17_scatter_rattrapage.pdf")890plt.close(fig)891892# --- fig18 : ratio appartement / unifamiliale -----------------------------------------893fig, ax = new_fig(top=0.78)894ra_qc = nsa["QUEBEC"]["Apartment_HPI"] / nsa["QUEBEC"]["Single_Family_HPI"] * 100895ra_mtl = (nsa["MONTREAL_CMA"]["Apartment_HPI"] /896 nsa["MONTREAL_CMA"]["Single_Family_HPI"] * 100)897ax.plot(ra_qc.index, ra_qc.values, color=PAL["orange"], lw=2.1, label="Québec (province)")898ax.plot(ra_mtl.index, ra_mtl.values, color=PAL["violet"], lw=2.1, label="RMR de Montréal")899ax.axhline(100, color=AXIS, lw=0.8)900event_bands(ax, y=0.13)901style_ax(ax)902date_axis(ax, end=XEND)903ax.set_ylabel("IPP appartement / IPP unifamiliale (×100)")904end_labels(ax, [(ra_qc.index[-1], ra_qc.iloc[-1], PAL["orange"]),905 (ra_mtl.index[-1], ra_mtl.iloc[-1], PAL["violet"])],906 lambda v: fr(v, 0))907finish(fig, ax, "La copropriété décroche : quinze ans de sous-performance",908 "Ratio de l'IPP appartement sur l'IPP unifamiliale (×100) — sous 100, l'appartement croît moins vite qu'en 2005",909 SRC_ACI, "fig18_ratio_app_unifam.pdf", legend_ncols=2)910911# --- fig19 : le récit en une courbe (événements annotés) --------------------------------912fig, ax = new_fig(h=5.6, top=0.82, right=0.94)913s = nsa["QUEBEC"]["Composite_HPI"]914ax.plot(s.index, s.values, color=PAL["orange"], lw=2.4)915ax.fill_between(s.index, s.values, 90, color=PAL["orange"], alpha=0.06)916event_bands(ax, labels=False)917918919def note(ax, when, txt, dxm, dy, ha="left"):920 t = pd.Timestamp(when)921 v = s.asof(t)922 ax.annotate(txt, xy=(t, v),923 xytext=(t + pd.DateOffset(months=dxm), v + dy),924 fontsize=8.4, color=INK2, ha=ha, va="center", linespacing=1.25,925 arrowprops=dict(arrowstyle="-", color=MUTED, lw=0.8,926 shrinkA=2, shrinkB=3))927928929note(ax, "2008-10-01", "Crise financière :\nsimple pause au Québec\n(−9 % dans l'Ouest)", -8, 62)930note(ax, "2016-06-01", "Décennie lente :\n+3 % par an\nde 2011 à 2016", 4, -48)931note(ax, "2020-03-01", "Pandémie : télétravail,\népargne forcée, taux planchers", -88, 46)932note(ax, "2022-03-01", "Le resserrement impose\nun plateau… sans correction", -66, 68)933note(ax, "2025-06-01", "Reprise : sommet\nhistorique à 319\nen juin 2026", -46, 40)934ax.set_ylim(90, 345)935style_ax(ax)936date_axis(ax, end=XEND)937ax.set_ylabel("IPP composite (janv. 2005 = 100)")938end_labels(ax, [(s.index[-1], s.iloc[-1], PAL["orange"])], lambda v: fr(v, 0))939fig.text(0.012, 0.955, "Vingt ans du marché québécois : le récit en une courbe",940 fontsize=14, fontweight="bold", color=INK)941fig.text(0.012, 0.912, "IPP MLS® composite, Québec, janv. 2005 = 100 — épisodes clés annotés",942 fontsize=10, color=INK2)943fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED)944fig.savefig(FIG / "fig19_evenements.pdf")945plt.close(fig)946947# --- fig20 : variation sur 12 mois par marché (barres) -----------------------------------948fig, ax = plt.subplots(figsize=(9.4, 6.4))949fig.subplots_adjust(left=0.21, right=0.94, top=0.845, bottom=0.10)950yy_last = {NOMS[s]: yoy(nsa[s]["Composite_HPI"]).iloc[-1] for s in bars_sheets}951yy_last = dict(sorted(yy_last.items(), key=lambda kv: kv[1]))952labels = list(yy_last.keys())953vals = list(yy_last.values())954colors = [PAL["blue"] if v >= 0 else PAL["red"] for v in vals]955ax.barh(range(len(vals)), vals, color=colors, height=0.62, zorder=3)956ax.axvline(0, color=AXIS, lw=0.9)957ax.set_yticks(range(len(labels)))958ax.set_yticklabels(labels, fontsize=9.5)959for i, v in enumerate(vals):960 ax.text(v + (0.12 if v >= 0 else -0.12), i,961 ("+" if v > 0 else "") + fr(v, 1) + " %",962 va="center", ha="left" if v >= 0 else "right", fontsize=8.6, color=INK2)963ax.set_xlabel("Variation sur 12 mois de l'IPP composite (%)")964ax.set_xlim(min(vals) * 1.35, max(vals) * 1.22)965style_ax(ax, ygrid=False)966ax.grid(axis="x", color=GRID, linewidth=0.7)967ax.legend(handles=[Patch(color=PAL["blue"], label="Hausse sur 12 mois"),968 Patch(color=PAL["red"], label="Baisse sur 12 mois")],969 loc="lower right")970fig.text(0.012, 0.955, "Juin 2026 : la carte des hausses est presque entièrement québécoise",971 fontsize=14, fontweight="bold", color=INK)972fig.text(0.012, 0.91, "Variation sur 12 mois de l'IPP composite, seize marchés, en %",973 fontsize=10, color=INK2)974fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED)975fig.savefig(FIG / "fig20_yoy_barres.pdf")976plt.close(fig)977978# =============================================================================979# MACRO COMPLÉMENTAIRE — démographie et taux de change980# =============================================================================981print("Figures macro complémentaires…")982983pop = fred("POPTOTCAA647NWDB") # population du Canada (annuel)984fx = fred("DEXCAUS") # $CA par $US (quotidien)985fx_m = fx.resample("MS").mean()986987# --- macro07 : croissance démographique -----------------------------------------988fig, ax = new_fig()989pg = (pop.pct_change() * 100).dropna()990pg = pg[pg.index >= "2005-01-01"]991colors = [PAL["orange"] if v >= 1.5 else PAL["blue"] for v in pg.values]992ax.bar(pg.index, pg.values, width=290, color=colors, zorder=3)993ax.axhline(0, color=AXIS, lw=0.8)994style_ax(ax)995date_axis(ax, end=XEND)996ax.set_ylabel("Croissance de la population (%)")997ax.legend(handles=[Patch(color=PAL["orange"], label="Croissance ≥ 1,5 %"),998 Patch(color=PAL["blue"], label="Croissance < 1,5 %")],999 loc="lower left", bbox_to_anchor=(-0.005, 1.01), ncols=2,1000 columnspacing=1.3, handlelength=1.5, handletextpad=0.5, borderaxespad=0)1001fig.text(0.012, 0.955, "Le choc démographique : la demande fondamentale s'emballe après 2021",1002 fontsize=14, fontweight="bold", color=INK)1003fig.text(0.012, 0.902, "Croissance annuelle de la population canadienne, en % — l'immigration record de 2022-2024 "1004 "soutient la demande de logements", fontsize=10, color=INK2)1005fig.text(0.012, 0.022, SRC_FRED, fontsize=7.6, color=MUTED)1006fig.savefig(FIG / "macro07_population.pdf")1007plt.close(fig)10081009# --- macro08 : taux de change ----------------------------------------------------1010fig, ax = new_fig()1011fxp = fx_m[fx_m.index >= "2004-08-01"]1012ax.plot(fxp.index, fxp.values, color=PAL["blue"], lw=2.1,1013 label="Dollars canadiens par dollar américain (moyenne mensuelle)")1014ax.axhline(1, color=AXIS, lw=0.8)1015event_bands(ax)1016style_ax(ax)1017date_axis(ax, end=XEND)1018ax.set_ylabel("Dollars CA par dollar US")1019end_labels(ax, [(fxp.index[-1], fxp.iloc[-1], PAL["blue"])], lambda v: fr(v, 2))1020finish(fig, ax, "Le huard : de la parité de 2007-2011 à la fourchette 1,35-1,45",1021 "Taux de change $ CA / $ US, moyenne mensuelle — un dollar faible renchérit les intrants de construction",1022 SRC_FRED, "macro08_cadusd.pdf", legend_ncols=1)10231024# =============================================================================1025# PROFILS RÉGIONAUX — 4 figures + 1 table par marché québécois1026# =============================================================================1027print("Profils régionaux…")10281029TYPE_COLS = list(TYPES5.keys())1030BENCH_COLS = {1031 "Single_Family_Benchmark": "Unifamiliale",1032 "One_Storey_Benchmark": "Plain-pied",1033 "Two_Storey_Benchmark": "À étages",1034 "Townhouse_Benchmark": "En rangée",1035 "Apartment_Benchmark": "Appartement",1036}1037region_summaries = {}10381039for sheet in QC_REGIONS:1040 slug = sheet.lower()1041 nom = NOMS[sheet]1042 d = nsa[sheet]1043 types_dispo = {c: l for c, l in TYPES5.items() if c in d.columns}1044 bench_dispo = {c: l for c, l in BENCH_COLS.items() if c in d.columns}10451046 # --- A : indices par type + composite ---------------------------------------1047 fig, ax = new_fig(top=0.78)1048 s_comp = d["Composite_HPI"]1049 ax.plot(s_comp.index, s_comp.values, color=INK2, lw=2.6, label="Composite")1050 ends = [(s_comp.index[-1], s_comp.iloc[-1], INK2)]1051 for (col_name, lab), colk in zip(types_dispo.items(), CAT_ORDER[:5]):1052 s = d[col_name]1053 ax.plot(s.index, s.values, color=PAL[colk], lw=1.7, label=lab)1054 ends.append((s.index[-1], s.iloc[-1], PAL[colk]))1055 event_bands(ax)1056 style_ax(ax)1057 date_axis(ax, end=XEND)1058 ax.set_ylabel("IPP (janv. 2005 = 100)")1059 end_labels(ax, ends, lambda v: fr(v, 0))1060 finish(fig, ax, f"{nom} : l'indice composite et ses cinq segments",1061 "IPP MLS® par type de propriété, janv. 2005 = 100",1062 SRC_ACI, f"reg_{slug}_types.pdf", legend_ncols=6)10631064 # --- B : prix de référence par type ------------------------------------------1065 fig, ax = new_fig(top=0.78)1066 ends = []1067 for (col_name, lab), colk in zip(bench_dispo.items(), CAT_ORDER[:5]):1068 s = d[col_name] / 10001069 ax.plot(s.index, s.values, color=PAL[colk], lw=1.9, label=lab)1070 ends.append((s.index[-1], s.iloc[-1], PAL[colk]))1071 event_bands(ax)1072 style_ax(ax)1073 date_axis(ax, end=XEND)1074 ax.set_ylabel("Prix de référence (milliers de $)")1075 ax.yaxis.set_major_formatter(FuncFormatter(kfmt))1076 end_labels(ax, ends, lambda v: fr(v, 0) + " k$")1077 finish(fig, ax, f"{nom} : les prix de référence en dollars",1078 "Prix de référence par type de propriété, milliers de dollars courants",1079 SRC_ACI, f"reg_{slug}_bench.pdf", legend_ncols=5)10801081 # --- C : glissement annuel vs Canada ------------------------------------------1082 fig, ax = new_fig(h=4.4, top=0.78)1083 yy_r = yoy(d["Composite_HPI"])1084 ax.axhline(0, color=AXIS, lw=0.8)1085 ax.plot(y_can.index, y_can.values, color=AXIS, lw=1.5, label="Canada (référence)")1086 ax.plot(yy_r.index, yy_r.values, color=PAL["orange"], lw=2.1, label=nom)1087 event_bands(ax)1088 style_ax(ax)1089 date_axis(ax, end=XEND)1090 ax.set_ylabel("Variation sur 12 mois (%)")1091 end_labels(ax, [(yy_r.index[-1], yy_r.iloc[-1], PAL["orange"])],1092 lambda v: ("+" if v > 0 else "") + fr(v, 1) + " %")1093 finish(fig, ax, f"{nom} : le rythme annuel des prix",1094 "Variation sur 12 mois de l'IPP composite, en % — gris : Canada",1095 SRC_ACI, f"reg_{slug}_yoy.pdf", legend_ncols=2)10961097 # --- D : repli depuis le sommet ------------------------------------------------1098 fig, ax = new_fig(h=4.4, top=0.78)1099 dd_r = drawdown(sa[sheet]["Composite_HPI"])1100 dd_c = drawdown(sa["AGGREGATE"]["Composite_HPI"])1101 ax.plot(dd_c.index, dd_c.values, color=AXIS, lw=1.5, label="Canada (référence)")1102 ax.plot(dd_r.index, dd_r.values, color=PAL["orange"], lw=2.1, label=nom)1103 ax.fill_between(dd_r.index, dd_r.values, 0, color=PAL["orange"], alpha=0.14, lw=0)1104 ax.axhline(0, color=AXIS, lw=0.8)1105 style_ax(ax)1106 date_axis(ax, end=XEND)1107 ax.set_ylabel("Écart au sommet historique (%)")1108 end_labels(ax, [(dd_r.index[-1], dd_r.iloc[-1], PAL["orange"])],1109 lambda v: fr(v, 1) + " %")1110 finish(fig, ax, f"{nom} : la résistance aux corrections",1111 "Écart au sommet historique de l'IPP composite désaisonnalisé, en % — gris : Canada",1112 SRC_ACI, f"reg_{slug}_dd.pdf", legend_ncols=2)11131114 # --- table régionale ------------------------------------------------------------1115 rows = [("Composite", "Composite_HPI", "Composite_Benchmark")] + [1116 (lab, hcol, bcol) for (hcol, lab), bcol in zip(types_dispo.items(), bench_dispo)1117 ]1118 lines = []1119 for lab, hcol, bcol in rows:1120 h = d[hcol]1121 b = d[bcol]1122 lines.append(1123 f"{lab} & {fr_num(h.iloc[-1])} & {fr_money(b.iloc[-1])} & "1124 f"{fr_pct(yoy(h).iloc[-1], sign=True)} & "1125 f"{fr_pct(cagr(h, start=LAST - pd.DateOffset(years=5)), sign=True)} & "1126 f"{fr_pct(cagr(h, start=LAST - pd.DateOffset(years=10)), sign=True)} & "1127 f"{fr_pct(cagr(h), sign=True)} \\\\"1128 + ("\n\\midrule" if lab == "Composite" else "")1129 )1130 tabr = (1131 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1132 "\\begin{tabular}{lrrrrrr}\n\\toprule\n"1133 "Segment & \\makecell{IPP} & \\makecell{Prix de\\\\référence} & "1134 "\\makecell{Var.\\\\12 mois} & \\makecell{Croiss. ann.\\\\5 ans} & "1135 "\\makecell{Croiss. ann.\\\\10 ans} & \\makecell{Croiss. ann.\\\\2005--2026} \\\\\n"1136 "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1137 )1138 (TAB / f"tabreg_{slug}.tex").write_text(tabr)11391140 # --- sommaire JSON ---------------------------------------------------------------1141 hs = sa[sheet]["Composite_HPI"]1142 region_summaries[nom] = {1143 "hpi": round(float(s_comp.iloc[-1]), 1),1144 "bench": int(d["Composite_Benchmark"].iloc[-1]),1145 "yoy": round(float(yoy(s_comp).iloc[-1]), 2),1146 "cagr": round(float(cagr(s_comp)), 2),1147 "cagr_2020": round(float(cagr(s_comp, start=pd.Timestamp("2020-01-01"))), 2),1148 "dd_actuel": round(float(drawdown(hs).iloc[-1]), 2),1149 "dd_max": round(float(drawdown(hs).min()), 2),1150 "pic": str(hs.idxmax().date()),1151 "app_vs_unifam": round(float(d["Apartment_HPI"].iloc[-1] /1152 d["Single_Family_HPI"].iloc[-1] * 100), 1),1153 "bench_types": {lab: int(d[bcol].iloc[-1]) for bcol, lab in bench_dispo.items()},1154 "yoy_types": {lab: round(float(yoy(d[hcol]).iloc[-1]), 1)1155 for hcol, lab in types_dispo.items()},1156 }11571158# =============================================================================1159# TYPES DE PROPRIÉTÉ — comparaisons entre marchés1160# =============================================================================1161print("Figures par type…")1162TYPE_SLUGS = {1163 "Single_Family_HPI": ("unifamiliale", "L'unifamiliale"),1164 "One_Storey_HPI": ("plainpied", "Le plain-pied"),1165 "Two_Storey_HPI": ("etages", "La maison à étages"),1166 "Townhouse_HPI": ("rangee", "La maison en rangée"),1167 "Apartment_HPI": ("appartement", "L'appartement en copropriété"),1168}1169for hcol, (slug, titre) in TYPE_SLUGS.items():1170 fig, ax = new_fig(top=0.78)1171 series_t = [1172 ("AGGREGATE", "Canada", PAL["blue"]),1173 ("QUEBEC", "Québec (province)", PAL["orange"]),1174 ("MONTREAL_CMA", "RMR de Montréal", PAL["aqua"]),1175 ("QUEBEC_CMA", "RMR de Québec", PAL["yellow"]),1176 ]1177 ends = []1178 for sheet, lab, col in series_t:1179 s = nsa[sheet][hcol]1180 ax.plot(s.index, s.values, color=col, lw=2.1, label=lab)1181 ends.append((s.index[-1], s.iloc[-1], col))1182 event_bands(ax)1183 style_ax(ax)1184 date_axis(ax, end=XEND)1185 ax.set_ylabel("IPP (janv. 2005 = 100)")1186 end_labels(ax, ends, lambda v: fr(v, 0))1187 finish(fig, ax, f"{titre} : Québec, Montréal, Québec et Canada",1188 f"IPP MLS® du segment, janv. 2005 = 100",1189 SRC_ACI, f"type_{slug}.pdf", legend_ncols=4)11901191# =============================================================================1192# LES GRANDS MARCHÉS DU RESTE DU CANADA1193# =============================================================================1194print("Figures reste du Canada…")1195ROC = ["GREATER_TORONTO", "GREATER_VANCOUVER", "CALGARY",1196 "OTTAWA", "HALIFAX_DARTMOUTH", "WINNIPEG"]11971198# --- roc01 : petits multiples, indices --------------------------------------------1199fig, axes = plt.subplots(2, 3, figsize=(11.5, 6.5), sharex=True, sharey=True)1200fig.subplots_adjust(left=0.06, right=0.985, top=0.775, bottom=0.09,1201 hspace=0.34, wspace=0.07)1202s_can = nsa["AGGREGATE"]["Composite_HPI"]1203for axx, sheet in zip(axes.flat, ROC):1204 s = nsa[sheet]["Composite_HPI"]1205 axx.plot(s_can.index, s_can.values, color=AXIS, lw=1.3)1206 axx.plot(s.index, s.values, color=PAL["blue"], lw=1.9)1207 axx.set_title(NOMS[sheet], fontsize=10, fontweight="bold", color=INK, pad=4)1208 style_ax(axx)1209 axx.xaxis.set_major_locator(mdates.YearLocator(5))1210 axx.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))1211 axx.text(0.02, 0.93, fr(s.iloc[-1], 0), transform=axx.transAxes,1212 fontsize=9.5, fontweight="bold", color=PAL["blue"], va="top")1213handles = [plt.Line2D([], [], color=PAL["blue"], lw=1.9, label="Marché"),1214 plt.Line2D([], [], color=AXIS, lw=1.3, label="Canada (référence)")]1215fig.legend(handles=handles, loc="lower left", bbox_to_anchor=(0.052, 0.845),1216 ncols=2, frameon=False)1217fig.text(0.012, 0.955, "Six grands marchés hors Québec : niveaux d'indice",1218 fontsize=14, fontweight="bold", color=INK)1219fig.text(0.012, 0.912, "IPP composite, janv. 2005 = 100 — la valeur affichée est celle de juin 2026 ; gris : Canada",1220 fontsize=10, color=INK2)1221fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED)1222fig.savefig(FIG / "roc01_grid.pdf")1223plt.close(fig)12241225# --- roc02 : petits multiples, drawdown ---------------------------------------------1226fig, axes = plt.subplots(2, 3, figsize=(11.5, 6.5), sharex=True, sharey=True)1227fig.subplots_adjust(left=0.06, right=0.985, top=0.775, bottom=0.09,1228 hspace=0.34, wspace=0.07)1229dd_can = drawdown(sa["AGGREGATE"]["Composite_HPI"])1230for axx, sheet in zip(axes.flat, ROC):1231 ddx = drawdown(sa[sheet]["Composite_HPI"])1232 axx.plot(dd_can.index, dd_can.values, color=AXIS, lw=1.3)1233 axx.plot(ddx.index, ddx.values, color=PAL["red"], lw=1.9)1234 axx.axhline(0, color=AXIS, lw=0.7)1235 axx.set_title(NOMS[sheet], fontsize=10, fontweight="bold", color=INK, pad=4)1236 style_ax(axx)1237 axx.xaxis.set_major_locator(mdates.YearLocator(5))1238 axx.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))1239 axx.text(0.02, 0.09, fr(ddx.iloc[-1], 1) + " %", transform=axx.transAxes,1240 fontsize=9.5, fontweight="bold", color=PAL["red"], va="bottom")1241handles = [plt.Line2D([], [], color=PAL["red"], lw=1.9, label="Marché"),1242 plt.Line2D([], [], color=AXIS, lw=1.3, label="Canada (référence)")]1243fig.legend(handles=handles, loc="lower left", bbox_to_anchor=(0.052, 0.845),1244 ncols=2, frameon=False)1245fig.text(0.012, 0.955, "Six grands marchés hors Québec : l'ampleur des corrections",1246 fontsize=14, fontweight="bold", color=INK)1247fig.text(0.012, 0.912, "Écart au sommet historique de l'IPP composite désaisonnalisé, en % — "1248 "la valeur affichée est celle de juin 2026", fontsize=10, color=INK2)1249fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED)1250fig.savefig(FIG / "roc02_dd.pdf")1251plt.close(fig)12521253# --- roc03 : prix de référence ----------------------------------------------------1254fig, ax = new_fig(top=0.78)1255roc_colors = dict(zip(ROC, CAT_ORDER[:6]))1256ends = []1257for sheet in ROC:1258 s = nsa[sheet]["Composite_Benchmark"] / 10001259 ax.plot(s.index, s.values, color=PAL[roc_colors[sheet]], lw=1.9, label=NOMS[sheet])1260 ends.append((s.index[-1], s.iloc[-1], PAL[roc_colors[sheet]]))1261event_bands(ax)1262style_ax(ax)1263date_axis(ax, end=XEND)1264ax.set_ylabel("Prix de référence (milliers de $)")1265ax.yaxis.set_major_formatter(FuncFormatter(kfmt))1266end_labels(ax, ends, lambda v: fr(v, 0) + " k$")1267finish(fig, ax, "De Winnipeg à Vancouver : l'éventail canadien des prix",1268 "Prix de référence composite, milliers de dollars courants, six grands marchés hors Québec",1269 SRC_ACI, "roc03_bench.pdf", legend_ncols=3)12701271# --- tab05 : vue d'ensemble ROC ------------------------------------------------------1272lines = []1273for sheet in ROC:1274 h = nsa[sheet]["Composite_HPI"]1275 b = nsa[sheet]["Composite_Benchmark"]1276 ddx = drawdown(sa[sheet]["Composite_HPI"])1277 lines.append(1278 f"{NOMS[sheet]} & {fr_money(b.iloc[-1])} & {fr_num(h.iloc[-1])} & "1279 f"{fr_pct(yoy(h).iloc[-1], sign=True)} & {fr_pct(cagr(h), sign=True)} & "1280 f"{fr_pct(ddx.iloc[-1])} & {fr_pct(ddx.min())} \\\\"1281 )1282tab5 = (1283 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1284 "\\begin{tabular}{lrrrrrr}\n\\toprule\n"1285 "Marché & \\makecell{Prix de\\\\référence} & \\makecell{IPP\\\\composite} & "1286 "\\makecell{Var.\\\\12 mois} & \\makecell{Croiss. ann.\\\\2005--2026} & "1287 "\\makecell{Écart au\\\\sommet} & \\makecell{Repli\\\\maximal} \\\\\n"1288 "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1289)1290(TAB / "tab05_roc.tex").write_text(tab5)12911292# =============================================================================1293# RISQUE — distributions, bêta mobile, épisodes de repli1294# =============================================================================1295print("Figures de risque…")12961297# --- risk01 : histogrammes des variations mensuelles ---------------------------------1298fig, axes = plt.subplots(1, 2, figsize=(10.5, 4.6), sharex=True, sharey=True)1299fig.subplots_adjust(left=0.07, right=0.975, top=0.80, bottom=0.13, wspace=0.08)1300r_qc_m = sa["QUEBEC"]["Composite_HPI"].pct_change().dropna() * 1001301r_ca_m = sa["AGGREGATE"]["Composite_HPI"].pct_change().dropna() * 1001302bins = np.arange(-2.6, 3.61, 0.2)1303for axx, (r, lab, col) in zip(axes, [(r_qc_m, "Québec", PAL["orange"]),1304 (r_ca_m, "Canada", PAL["blue"])]):1305 axx.hist(r.values, bins=bins, color=col, edgecolor="white", linewidth=0.8,1306 zorder=3)1307 axx.axvline(0, color=AXIS, lw=0.8)1308 axx.axvline(r.mean(), color=INK2, lw=1.4, ls=(0, (4, 3)))1309 axx.set_title(lab, fontsize=10.5, fontweight="bold", color=INK, pad=4)1310 axx.set_xlabel("Variation mensuelle (%)")1311 style_ax(axx)1312 axx.text(0.97, 0.94, f"moyenne : {fr(r.mean(), 2)} %\nécart-type : {fr(r.std(), 2)} %"1313 f"\nmin. : {fr(r.min(), 1)} %\nmax. : {fr(r.max(), 1)} %",1314 transform=axx.transAxes, fontsize=8.4, color=INK2, ha="right",1315 va="top", linespacing=1.45)1316axes[0].set_ylabel("Nombre de mois")1317fig.text(0.012, 0.955, "La distribution des variations mensuelles : un Québec plus régulier",1318 fontsize=14, fontweight="bold", color=INK)1319fig.text(0.012, 0.905, "Variations mensuelles de l'IPP composite désaisonnalisé, 2005-2026 — "1320 "pointillé : moyenne", fontsize=10, color=INK2)1321fig.text(0.012, 0.022, SRC_ACI, fontsize=7.6, color=MUTED)1322fig.savefig(FIG / "risk01_hist.pdf")1323plt.close(fig)13241325# --- risk02 : distribution des variations annuelles par marché ------------------------1326fig, ax = plt.subplots(figsize=(9.6, 6.6))1327fig.subplots_adjust(left=0.21, right=0.965, top=0.85, bottom=0.10)1328box_sheets = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE",1329 "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO", "GREATER_TORONTO",1330 "BRITISH_COLUMBIA", "GREATER_VANCOUVER", "ALBERTA"]1331box_data, box_cols = [], []1332for sheet in box_sheets:1333 da = annual[sheet].set_index("Date")["Composite_HPI"]1334 box_data.append((da.pct_change() * 100).dropna().values)1335 box_cols.append(PAL["orange"] if sheet in QC_REGIONS else PAL["blue"])1336bp = ax.boxplot(box_data, vert=False, patch_artist=True, widths=0.55,1337 medianprops=dict(color="white", lw=1.6),1338 whiskerprops=dict(color=AXIS), capprops=dict(color=AXIS),1339 flierprops=dict(marker="o", markersize=4, markerfacecolor=MUTED,1340 markeredgecolor="none"))1341for patch, col in zip(bp["boxes"], box_cols):1342 patch.set_facecolor(col)1343 patch.set_edgecolor("white")1344ax.axvline(0, color=AXIS, lw=0.9)1345ax.set_yticklabels([NOMS[s] for s in box_sheets], fontsize=9.5)1346ax.set_xlabel("Variation annuelle de l'IPP composite (%)")1347style_ax(ax, ygrid=False)1348ax.grid(axis="x", color=GRID, linewidth=0.7)1349ax.legend(handles=[Patch(color=PAL["orange"], label="Marchés québécois"),1350 Patch(color=PAL["blue"], label="Reste du Canada")],1351 loc="lower right")1352fig.text(0.012, 0.955, "Vingt et une années de variations annuelles : la boîte à moustaches",1353 fontsize=14, fontweight="bold", color=INK)1354fig.text(0.012, 0.912, "Distribution des variations annuelles de l'IPP composite, 2006-2026 — "1355 "médiane en blanc, points : années extrêmes", fontsize=10, color=INK2)1356fig.text(0.012, 0.018, SRC_ACI, fontsize=7.6, color=MUTED)1357fig.savefig(FIG / "risk02_box.pdf")1358plt.close(fig)13591360# --- risk03 : bêta mobile du Québec sur le Canada --------------------------------------1361fig, ax = new_fig(top=0.80)1362cov = r_qc_m.rolling(60).cov(r_ca_m)1363var = r_ca_m.rolling(60).var()1364beta = (cov / var).dropna()1365ax.plot(beta.index, beta.values, color=PAL["violet"], lw=2.2,1366 label="Bêta mobile (60 mois) du Québec sur le Canada")1367ax.axhline(1, color=AXIS, lw=0.9)1368ax.text(pd.Timestamp("2010-06-01"), 1.04, "Bêta = 1 : sensibilité identique au marché canadien",1369 fontsize=8, color=MUTED)1370ax.axhline(0, color=AXIS, lw=0.8)1371event_bands(ax, y=0.97)1372style_ax(ax)1373date_axis(ax, start="2009-06-01", end=XEND)1374ax.set_ylabel("Bêta (fenêtre de 60 mois)")1375end_labels(ax, [(beta.index[-1], beta.iloc[-1], PAL["violet"])], lambda v: fr(v, 2))1376finish(fig, ax, "Un bêta durablement inférieur à un",1377 "Pente de la régression mobile des variations mensuelles du Québec sur celles du Canada (60 mois)",1378 SRC_ACI, "risk03_beta.pdf", legend_ncols=1)13791380# --- tab06 : épisodes de repli maximal ---------------------------------------------------1381dd_sheets = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA",1382 "GREATER_TORONTO", "GREATER_VANCOUVER", "CALGARY", "OTTAWA"]1383lines = []1384for sheet in dd_sheets:1385 hs = sa[sheet]["Composite_HPI"]1386 ddx = drawdown(hs)1387 trough = ddx.idxmin()1388 peak = hs[:trough].idxmax()1389 duree = (trough.year - peak.year) * 12 + trough.month - peak.month1390 apres = hs[trough:]1391 recov = apres[apres >= hs[peak]]1392 recouvre = (f"{MOIS_FR[recov.index[0].month-1]}~{recov.index[0].year}"1393 if len(recov) else "non récupéré")1394 lines.append(1395 f"{NOMS[sheet]} & {fr_pct(ddx.min())} & "1396 f"{MOIS_FR[peak.month-1]}~{peak.year} & {MOIS_FR[trough.month-1]}~{trough.year} & "1397 f"{duree} & {recouvre} & {fr_pct(ddx.iloc[-1])} \\\\"1398 )1399tab6 = (1400 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1401 "\\begin{tabular}{lrrrrrr}\n\\toprule\n"1402 "Marché & \\makecell{Repli\\\\maximal} & \\makecell{Sommet\\\\(pré-repli)} & "1403 "\\makecell{Creux} & \\makecell{Durée\\\\(mois)} & \\makecell{Sommet\\\\récupéré en} & "1404 "\\makecell{Écart actuel\\\\au sommet} \\\\\n"1405 "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1406)1407(TAB / "tab06_drawdowns.tex").write_text(tab6)14081409# =============================================================================1410# SAISONNALITÉ RÉGIONALE1411# =============================================================================1412print("Figures de saisonnalité régionale…")14131414# --- sais01 : carte thermique mois × marché ----------------------------------------------1415sais_mat = []1416for sheet in QC_REGIONS:1417 rr = (nsa[sheet]["Composite_HPI"] / sa[sheet]["Composite_HPI"] - 1) * 1001418 sais_mat.append(rr.groupby(rr.index.month).mean().reindex(range(1, 13)).values)1419sais_mat = np.array(sais_mat)1420fig, ax = plt.subplots(figsize=(10.5, 3.9))1421fig.subplots_adjust(left=0.155, right=0.985, top=0.80, bottom=0.11)1422vmax_s = np.nanmax(np.abs(sais_mat))1423im = ax.imshow(sais_mat, aspect="auto", cmap=DIV_CMAP,1424 norm=TwoSlopeNorm(vcenter=0, vmin=-vmax_s, vmax=vmax_s))1425ax.set_xticks(range(12))1426ax.set_xticklabels(MOIS_FR, fontsize=8.8)1427ax.set_yticks(range(len(QC_REGIONS)))1428ax.set_yticklabels([NOMS[s] for s in QC_REGIONS], fontsize=9)1429for i in range(sais_mat.shape[0]):1430 for j in range(sais_mat.shape[1]):1431 v = sais_mat[i, j]1432 ax.text(j, i, fr(v, 1), ha="center", va="center", fontsize=7.4,1433 color="white" if abs(v) / vmax_s > 0.55 else INK2)1434ax.set_xticks(np.arange(-0.5, 12, 1), minor=True)1435ax.set_yticks(np.arange(-0.5, len(QC_REGIONS), 1), minor=True)1436ax.grid(which="minor", color="white", linewidth=1.6)1437ax.tick_params(which="both", length=0)1438for sp in ax.spines.values():1439 sp.set_visible(False)1440fig.text(0.012, 0.945, "La saisonnalité région par région",1441 fontsize=14, fontweight="bold", color=INK)1442fig.text(0.012, 0.882, "Facteur saisonnier moyen (écart brut / désaisonnalisé), en % — "1443 "rouge : prix au-dessus de la tendance ; bleu : au-dessous",1444 fontsize=10, color=INK2)1445fig.text(0.012, 0.022, SRC_ACI, fontsize=7.6, color=MUTED)1446fig.savefig(FIG / "sais01_heatmap.pdf")1447plt.close(fig)14481449# --- sais02 : Montréal vs Québec, barres groupées -----------------------------------------1450fig, ax = new_fig(w=9.5, h=4.4, right=0.97)1451r_m = (nsa["MONTREAL_CMA"]["Composite_HPI"] / sa["MONTREAL_CMA"]["Composite_HPI"] - 1) * 1001452r_q = (nsa["QUEBEC_CMA"]["Composite_HPI"] / sa["QUEBEC_CMA"]["Composite_HPI"] - 1) * 1001453sm = r_m.groupby(r_m.index.month).mean()1454sq = r_q.groupby(r_q.index.month).mean()1455x = np.arange(1, 13)1456ax.bar(x - 0.19, sm.values, width=0.36, color=PAL["blue"], label="RMR de Montréal", zorder=3)1457ax.bar(x + 0.19, sq.values, width=0.36, color=PAL["orange"], label="RMR de Québec", zorder=3)1458ax.axhline(0, color=AXIS, lw=0.8)1459ax.set_xticks(x)1460ax.set_xticklabels(MOIS_FR)1461ax.set_ylabel("Écart brut / désaisonnalisé (%)")1462style_ax(ax)1463finish(fig, ax, "Deux métropoles, un même printemps",1464 "Facteur saisonnier moyen de l'IPP composite, RMR de Montréal et RMR de Québec, 2005-2026",1465 SRC_ACI, "sais02_rmr.pdf", legend_ncols=2)14661467# =============================================================================1468# TABLEAUX D'ANNEXE — données annuelles1469# =============================================================================1470print("Tableaux d'annexe…")14711472annexe_g1 = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE",1473 "MAURICIE", "CENTRE_DU_QUEBEC", "ONTARIO"]1474annexe_g2 = ["GREATER_TORONTO", "OTTAWA", "BRITISH_COLUMBIA", "GREATER_VANCOUVER",1475 "ALBERTA", "CALGARY", "HALIFAX_DARTMOUTH", "WINNIPEG"]1476COURTS = {1477 "AGGREGATE": "Canada", "QUEBEC": "Québec", "MONTREAL_CMA": "Montréal",1478 "QUEBEC_CMA": "Québec (RMR)", "ESTRIE": "Estrie", "MAURICIE": "Mauricie",1479 "CENTRE_DU_QUEBEC": "Centre-du-Qc", "ONTARIO": "Ontario",1480 "GREATER_TORONTO": "Toronto", "OTTAWA": "Ottawa",1481 "BRITISH_COLUMBIA": "C.-B.", "GREATER_VANCOUVER": "Vancouver",1482 "ALBERTA": "Alberta", "CALGARY": "Calgary",1483 "HALIFAX_DARTMOUTH": "Halifax", "WINNIPEG": "Winnipeg",1484}148514861487def table_annuelle(sheets, fname, yoy_mode=False):1488 dfs = {COURTS[s]: annual[s].set_index("Date")["Composite_HPI"] for s in sheets}1489 dfa = pd.DataFrame(dfs)1490 if yoy_mode:1491 dfa = (dfa.pct_change() * 100).iloc[1:]1492 head = "Année & " + " & ".join(dfa.columns) + " \\\\"1493 lines = []1494 for yr, row in dfa.iterrows():1495 if yoy_mode:1496 cells = [fr_pct(v, 1, sign=True) if np.isfinite(v) else "---"1497 for v in row.values]1498 else:1499 cells = [fr_num(v) if np.isfinite(v) else "---" for v in row.values]1500 lines.append(f"{yr} & " + " & ".join(cells) + " \\\\")1501 out = (1502 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1503 "\\begin{tabular}{l" + "r" * len(dfa.columns) + "}\n\\toprule\n"1504 + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1505 )1506 (TAB / fname).write_text(out)150715081509table_annuelle(annexe_g1, "taba1_hpi_annuel_1.tex")1510table_annuelle(annexe_g2, "taba1_hpi_annuel_2.tex")1511table_annuelle(annexe_g1, "taba5_yoy_annuel_1.tex", yoy_mode=True)1512table_annuelle(annexe_g2, "taba5_yoy_annuel_2.tex", yoy_mode=True)15131514# benchmark annuel (moyenne des mois, en milliers) — marchés québécois + Canada1515bench_sheets = ["AGGREGATE"] + QC_REGIONS1516dfb = pd.DataFrame({COURTS[s]: nsa[s]["Composite_Benchmark"].resample("YE").mean() / 10001517 for s in bench_sheets})1518dfb.index = dfb.index.year1519head = "Année & " + " & ".join(dfb.columns) + " \\\\"1520lines = []1521for yr, row in dfb.iterrows():1522 cells = [fr_num(v, 0) for v in row.values]1523 lines.append(f"{yr} & " + " & ".join(cells) + " \\\\")1524tabb = (1525 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1526 "\\begin{tabular}{l" + "r" * len(dfb.columns) + "}\n\\toprule\n"1527 + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1528)1529(TAB / "taba2_bench_annuel.tex").write_text(tabb)15301531# facteurs saisonniers par mois × marché1532head = "Mois & " + " & ".join(COURTS[s] for s in QC_REGIONS) + " \\\\"1533lines = []1534for j, mois in enumerate(MOIS_FR):1535 cells = [fr_num(sais_mat[i, j], 2) for i in range(len(QC_REGIONS))]1536 lines.append(f"{mois.capitalize()} & " + " & ".join(cells) + " \\\\")1537tabs_out = (1538 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1539 "\\begin{tabular}{l" + "r" * len(QC_REGIONS) + "}\n\\toprule\n"1540 + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1541)1542(TAB / "taba3_saison.tex").write_text(tabs_out)15431544# CAM 2005-2026 par type × marché1545head = ("Segment & " + " & ".join(COURTS[s] for s in ["AGGREGATE"] + QC_REGIONS) + " \\\\")1546lines = []1547for hcol, lab in TYPES5.items():1548 cells = [fr_pct(cagr(nsa[s][hcol]), sign=True) if hcol in nsa[s].columns else "---"1549 for s in ["AGGREGATE"] + QC_REGIONS]1550 lines.append(f"{lab} & " + " & ".join(cells) + " \\\\")1551cells = [fr_pct(cagr(nsa[s]["Composite_HPI"]), sign=True)1552 for s in ["AGGREGATE"] + QC_REGIONS]1553lines.append("\\midrule\nComposite & " + " & ".join(cells) + " \\\\")1554taba4 = (1555 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1556 "\\begin{tabular}{l" + "r" * 7 + "}\n\\toprule\n"1557 + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1558)1559(TAB / "taba4_cagr_types.tex").write_text(taba4)15601561# =============================================================================1562# TABLES LaTeX1563# =============================================================================1564print("Tables…")156515661567def stats_market(sheet):1568 h = nsa[sheet]["Composite_HPI"]1569 b = nsa[sheet]["Composite_Benchmark"]1570 hs = sa[sheet]["Composite_HPI"]1571 dd = drawdown(hs)1572 return {1573 "bench": b.iloc[-1],1574 "hpi": h.iloc[-1],1575 "yoy": yoy(h).iloc[-1],1576 "an5": cagr(h, start=LAST - pd.DateOffset(years=5)),1577 "an10": cagr(h, start=LAST - pd.DateOffset(years=10)),1578 "cagr": cagr(h),1579 "peak": hs.idxmax(),1580 "dd": dd.iloc[-1],1581 }158215831584rows_t1 = ["MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE", "CENTRE_DU_QUEBEC",1585 "QUEBEC", "AGGREGATE"]1586stats = {sh: stats_market(sh) for sh in rows_t1}15871588lines = []1589for sh in rows_t1:1590 st = stats[sh]1591 sep = "\\midrule\n" if sh == "QUEBEC" else ""1592 lines.append(1593 sep + f"{NOMS[sh]} & {fr_money(st['bench'])} & {fr_num(st['hpi'])} & "1594 f"{fr_pct(st['yoy'], sign=True)} & {fr_pct(st['an5'], sign=True)} & "1595 f"{fr_pct(st['an10'], sign=True)} & {fr_pct(st['cagr'], sign=True)} \\\\"1596 )1597tab1 = (1598 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1599 "\\begin{tabular}{lrrrrrr}\n\\toprule\n"1600 "Marché & \\makecell{Prix de\\\\référence} & \\makecell{IPP\\\\composite} & "1601 "\\makecell{Var.\\\\12 mois} & \\makecell{Croiss. ann.\\\\5 ans} & "1602 "\\makecell{Croiss. ann.\\\\10 ans} & \\makecell{Croiss. ann.\\\\2005--2026} \\\\\n"1603 "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1604)1605(TAB / "tab01_apercu.tex").write_text(tab1)16061607periods = [("2005", "2010"), ("2010", "2015"), ("2015", "2020"), ("2020", "2026")]1608rows_t2 = ["AGGREGATE", "QUEBEC", "MONTREAL_CMA", "QUEBEC_CMA", "ESTRIE", "MAURICIE",1609 "CENTRE_DU_QUEBEC", "ONTARIO", "GREATER_TORONTO", "BRITISH_COLUMBIA",1610 "GREATER_VANCOUVER", "ALBERTA"]1611lines = []1612for sh in rows_t2:1613 h = nsa[sh]["Composite_HPI"]1614 cells = []1615 for a, b in periods:1616 cells.append(fr_pct(cagr(h, start=pd.Timestamp(f"{a}-01-01"),1617 end=pd.Timestamp(f"{b}-01-01") if b != "2026" else LAST),1618 sign=True))1619 cells.append(fr_pct(cagr(h), sign=True))1620 lines.append(f"{NOMS[sh]} & " + " & ".join(cells) + " \\\\")1621tab2 = (1622 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1623 "\\begin{tabular}{lrrrrr}\n\\toprule\n"1624 "Marché & 2005--2010 & 2010--2015 & 2015--2020 & 2020--2026 & Ensemble \\\\\n"1625 "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1626)1627(TAB / "tab02_cagr_periodes.tex").write_text(tab2)16281629BENCH_MAP = {1630 "Unifamiliale": "Single_Family_Benchmark",1631 "Plain-pied": "One_Storey_Benchmark",1632 "À étages": "Two_Storey_Benchmark",1633 "En rangée": "Townhouse_Benchmark",1634 "Appartement": "Apartment_Benchmark",1635}1636mkts_t3 = ["MONTREAL_CMA", "QUEBEC_CMA", "QUEBEC"]1637lines = []1638for lab, colb in BENCH_MAP.items():1639 cells = [lab]1640 for m in mkts_t3:1641 b = nsa[m][colb]1642 cells.append(fr_money(b.iloc[-1]))1643 cells.append(fr_pct(yoy(b).iloc[-1], sign=True))1644 lines.append(" & ".join(cells) + " \\\\")1645tab3 = (1646 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1647 "\\begin{tabular}{lrrrrrr}\n\\toprule\n"1648 " & \\multicolumn{2}{c}{RMR de Montréal} & \\multicolumn{2}{c}{RMR de Québec} & "1649 "\\multicolumn{2}{c}{Québec (province)} \\\\\n"1650 "\\cmidrule(lr){2-3}\\cmidrule(lr){4-5}\\cmidrule(lr){6-7}\n"1651 "Type & Prix réf. & Var. 12 m. & Prix réf. & Var. 12 m. & Prix réf. & Var. 12 m. \\\\\n"1652 "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1653)1654(TAB / "tab03_types.tex").write_text(tab3)16551656# --- table 4 : tableau de bord macroéconomique --------------------------------1657macro_rows = [1658 ("Taux court 3 mois", taux3m, "pct"),1659 ("Rendement obligataire 10 ans", taux10a, "pct"),1660 ("Inflation IPC (glissement annuel)", infl, "pct"),1661 ("Taux de chômage", chomage, "pct"),1662 ("PIB réel (variation sur 4 trim.)", pib_yoy.dropna(), "pct"),1663]1664lines = []1665for lab, s, _ in macro_rows:1666 v_now = s.iloc[-1]1667 d_now = s.index[-1]1668 v_5 = s.asof(d_now - pd.DateOffset(years=5))1669 v_pre = s.asof(pd.Timestamp("2020-01-01"))1670 lines.append(1671 f"{lab} & {fr_pct(v_now)} & {fr_pct(v_pre)} & {fr_pct(v_5)} & "1672 f"{MOIS_FR[d_now.month-1]}~{d_now.year} \\\\"1673 )1674tab4 = (1675 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1676 "\\begin{tabular}{lrrrr}\n\\toprule\n"1677 "Indicateur (Canada) & Dernière valeur & Janv. 2020 & Il y a 5 ans & Observation \\\\\n"1678 "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1679)1680(TAB / "tab04_macro.tex").write_text(tab4)16811682# =============================================================================1683# ABORDABILITÉ — charge hypothécaire par marché et par taux1684# =============================================================================1685print("Figures d'abordabilité…")168616871688def paiement_mensuel(prix, taux_pct, mise_de_fonds=0.20, annees=25):1689 """Paiement mensuel d'un prêt hypothécaire à amortissement constant."""1690 principal = prix * (1 - mise_de_fonds)1691 r = taux_pct / 100 / 121692 n = annees * 121693 return principal * r / (1 - (1 + r) ** (-n))169416951696AFF_MARKETS = [1697 ("GREATER_TORONTO", PAL["blue"]),1698 ("AGGREGATE", PAL["orange"]),1699 ("MONTREAL_CMA", PAL["aqua"]),1700 ("QUEBEC", PAL["yellow"]),1701 ("ESTRIE", PAL["magenta"]),1702 ("QUEBEC_CMA", PAL["green"]),1703 ("CENTRE_DU_QUEBEC", PAL["violet"]),1704 ("MAURICIE", PAL["red"]),1705]1706rates_grid = np.linspace(2, 7, 51)1707fig, ax = new_fig(h=5.6, top=0.80, right=0.88)1708ends = []1709for sheet, col in AFF_MARKETS:1710 bench = nsa[sheet]["Composite_Benchmark"].iloc[-1]1711 pays = [paiement_mensuel(bench, r) for r in rates_grid]1712 ax.plot(rates_grid, pays, color=col, lw=2.0, label=NOMS[sheet])1713 ends.append((rates_grid[-1], pays[-1], col))1714style_ax(ax)1715ax.set_xlabel("Taux hypothécaire (%)")1716ax.set_ylabel("Paiement mensuel ($)")1717ax.yaxis.set_major_formatter(FuncFormatter(kfmt))1718gap = (ax.get_ylim()[1] - ax.get_ylim()[0]) * 0.0451719ends_sorted = sorted(ends, key=lambda t: t[1])1720ys = []1721for _, y, _ in ends_sorted:1722 yy = y if not ys else max(y, ys[-1] + gap)1723 ys.append(yy)1724for (x, y, col), yy in zip(ends_sorted, ys):1725 ax.plot([x], [y], "o", ms=4.5, color=col, zorder=5, clip_on=False)1726 ax.annotate(kfmt(y) + " $", (x, yy), xytext=(7, 0), textcoords="offset points",1727 va="center", ha="left", fontsize=8.4, fontweight="bold",1728 color=col, clip_on=False, annotation_clip=False)1729ax.legend(loc="upper left", ncols=2, fontsize=8.8)1730fig.text(0.012, 0.955, "Ce que coûte vraiment la propriété type : la charge hypothécaire",1731 fontsize=14, fontweight="bold", color=INK)1732fig.text(0.012, 0.905, "Paiement mensuel selon le taux — prix de référence de juin 2026, mise de fonds de 20 %, "1733 "amortissement de 25 ans", fontsize=10, color=INK2)1734fig.text(0.012, 0.022, SRC_ACI, fontsize=7.6, color=MUTED)1735fig.savefig(FIG / "afford01_paiement.pdf")1736plt.close(fig)17371738# --- tab07 : paiements par marché et par taux -----------------------------------1739rates_tab = [3, 4, 5, 6, 7]1740lines = []1741for sheet, _ in AFF_MARKETS:1742 bench = nsa[sheet]["Composite_Benchmark"].iloc[-1]1743 cells = [NOMS[sheet], fr_money(bench)]1744 for r in rates_tab:1745 cells.append(fr_money(paiement_mensuel(bench, r)))1746 lines.append(" & ".join(cells) + " \\\\")1747tab7 = (1748 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1749 "\\begin{tabular}{lrrrrrr}\n\\toprule\n"1750 " & & \\multicolumn{5}{c}{Paiement mensuel selon le taux} \\\\\n"1751 "\\cmidrule(lr){3-7}\n"1752 "Marché & \\makecell{Prix de\\\\référence} & 3~\\% & 4~\\% & 5~\\% & 6~\\% & 7~\\% \\\\\n"1753 "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1754)1755(TAB / "tab07_abordabilite.tex").write_text(tab7)17561757# =============================================================================1758# DYNAMIQUE TRIMESTRIELLE1759# =============================================================================1760print("Figures trimestrielles…")1761F_Q = DATA / "Not Seasonally Adjusted (Q).xlsx"1762qdata = pd.read_excel(F_Q, sheet_name=None)176317641765def qser(sheet):1766 d = qdata[sheet].set_index("Date")["Composite_HPI"]1767 return d176817691770q_qc = qser("QUEBEC").pct_change() * 1001771q_ca = qser("AGGREGATE").pct_change() * 1001772lastq = q_qc.index[-1]1773q_qc = q_qc.iloc[-12:]1774q_ca = q_ca.iloc[-12:]1775x = np.arange(len(q_qc))1776fig, ax = new_fig(h=4.6, right=0.97)1777ax.bar(x - 0.19, q_ca.values, width=0.36, color=PAL["blue"], label="Canada", zorder=3)1778ax.bar(x + 0.19, q_qc.values, width=0.36, color=PAL["orange"], label="Québec", zorder=3)1779ax.axhline(0, color=AXIS, lw=0.9)1780ax.set_xticks(x)1781ax.set_xticklabels([str(i) for i in q_qc.index], rotation=45, ha="right", fontsize=8.2)1782ax.set_ylabel("Variation trimestrielle (%)")1783style_ax(ax)1784fig.text(0.012, 0.955, "Le pouls trimestriel : douze trimestres de divergence",1785 fontsize=14, fontweight="bold", color=INK)1786fig.text(0.012, 0.902, "Variation trimestrielle de l'IPP composite (données brutes), "1787 f"{q_qc.index[0]} à {lastq}", fontsize=10, color=INK2)1788fig.text(0.012, 0.022, SRC_ACI, fontsize=7.6, color=MUTED)1789ax.legend(loc="lower left", bbox_to_anchor=(-0.005, 1.01), ncols=2,1790 columnspacing=1.3, handlelength=1.5, handletextpad=0.5, borderaxespad=0)1791fig.savefig(FIG / "fig21_trimestres.pdf")1792plt.close(fig)17931794# --- tabq1 : indices trimestriels récents -----------------------------------------1795q_sheets = ["AGGREGATE"] + QC_REGIONS1796dfq = pd.DataFrame({COURTS[s]: qser(s) for s in q_sheets}).iloc[-14:]1797head = "Trimestre & " + " & ".join(dfq.columns) + " \\\\"1798lines = []1799for qtr, row in dfq.iterrows():1800 lines.append(f"{qtr} & " + " & ".join(fr_num(v) for v in row.values) + " \\\\")1801tabq = (1802 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1803 "\\begin{tabular}{l" + "r" * len(dfq.columns) + "}\n\\toprule\n"1804 + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1805)1806(TAB / "tabq1_trimestres.tex").write_text(tabq)18071808# =============================================================================1809# ANNEXE — tables mensuelles récentes par marché québécois1810# =============================================================================1811print("Tables mensuelles…")1812for sheet in QC_REGIONS:1813 slug = sheet.lower()1814 d = nsa[sheet]1815 lines = []1816 for dt in d.index[-24:]:1817 h = d.loc[dt, "Composite_HPI"]1818 b = d.loc[dt, "Composite_Benchmark"]1819 g = yoy(d["Composite_HPI"]).loc[dt]1820 lines.append(f"{MOIS_FR[dt.month-1]}~{dt.year} & {fr_num(h)} & "1821 f"{fr_money(b)} & {fr_pct(g, sign=True)} \\\\")1822 out = (1823 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1824 "\\begin{tabular}{lrrr}\n\\toprule\n"1825 "Mois & IPP composite & Prix de référence & Var. 12 mois \\\\\n"1826 "\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1827 )1828 (TAB / f"tabm_{slug}.tex").write_text(out)18291830# --- taba6 : prix de référence par segment et par marché ---------------------------1831seg_rows = [("Composite", "Composite_Benchmark")] + \1832 [(lab, bcol) for bcol, lab in BENCH_COLS.items()]1833mk_cols = ["AGGREGATE"] + QC_REGIONS1834head = "Segment & " + " & ".join(COURTS[s] for s in mk_cols) + " \\\\"1835lines = []1836for lab, bcol in seg_rows:1837 cells = [lab]1838 for s in mk_cols:1839 if bcol in nsa[s].columns:1840 cells.append(fr_num(nsa[s][bcol].iloc[-1] / 1000, 0))1841 else:1842 cells.append("---")1843 lines.append(" & ".join(cells) + " \\\\")1844taba6 = (1845 "% Généré par code/analyse_hpi_quebec.py — Simon-Pierre Boucher (UQO)\n"1846 "\\begin{tabular}{l" + "r" * len(mk_cols) + "}\n\\toprule\n"1847 + head + "\n\\midrule\n" + "\n".join(lines) + "\n\\bottomrule\n\\end{tabular}\n"1848)1849(TAB / "taba6_bench_segments.tex").write_text(taba6)18501851# =============================================================================1852# Valeurs clés (macros LaTeX) + sommaire JSON1853# =============================================================================1854qc = stats["QUEBEC"]1855mtl = stats["MONTREAL_CMA"]1856qcc = stats["QUEBEC_CMA"]1857can = stats["AGGREGATE"]1858dd_tor = drawdown(sa["GREATER_TORONTO"]["Composite_HPI"]).iloc[-1]1859dd_van = drawdown(sa["GREATER_VANCOUVER"]["Composite_HPI"]).iloc[-1]1860g2020_qc = (nsa["QUEBEC"]["Composite_HPI"].iloc[-1] /1861 nsa["QUEBEC"]["Composite_HPI"].loc["2020-01-01"] - 1) * 1001862g2020_can = (nsa["AGGREGATE"]["Composite_HPI"].iloc[-1] /1863 nsa["AGGREGATE"]["Composite_HPI"].loc["2020-01-01"] - 1) * 1001864corr_mtl_tor = C.loc["RMR de Montréal", "Grand Toronto"]18651866macros = f"""% =============================================================================1867% Auteur : Simon-Pierre Boucher1868% Fonction : Professeur1869% Département : Département des sciences administratives1870% Institution : Université du Québec en Outaouais (UQO)1871% Courriel : simon-pierre.boucher@uqo.ca1872% -----------------------------------------------------------------------------1873% Valeurs clés générées par code/analyse_hpi_quebec.py — ne pas éditer à la main1874% =============================================================================1875\\newcommand{{\\VcDerniereObs}}{{juin 2026}}1876\\newcommand{{\\VcQcHPI}}{{{fr_num(qc['hpi'])}}}1877\\newcommand{{\\VcQcBench}}{{{fr_money(qc['bench'])}}}1878\\newcommand{{\\VcQcYoY}}{{{fr_pct(qc['yoy'], sign=True)}}}1879\\newcommand{{\\VcQcCagr}}{{{fr_pct(qc['cagr'])}}}1880\\newcommand{{\\VcQcMult}}{{{fr_num(qc['hpi'] / 100)}}}1881\\newcommand{{\\VcCanHPI}}{{{fr_num(can['hpi'])}}}1882\\newcommand{{\\VcCanBench}}{{{fr_money(can['bench'])}}}1883\\newcommand{{\\VcCanYoY}}{{{fr_pct(can['yoy'], sign=True)}}}1884\\newcommand{{\\VcCanCagr}}{{{fr_pct(can['cagr'])}}}1885\\newcommand{{\\VcMtlBench}}{{{fr_money(mtl['bench'])}}}1886\\newcommand{{\\VcMtlYoY}}{{{fr_pct(mtl['yoy'], sign=True)}}}1887\\newcommand{{\\VcMtlCagr}}{{{fr_pct(mtl['cagr'])}}}1888\\newcommand{{\\VcQccBench}}{{{fr_money(qcc['bench'])}}}1889\\newcommand{{\\VcQccYoY}}{{{fr_pct(qcc['yoy'], sign=True)}}}1890\\newcommand{{\\VcCanDD}}{{{fr_pct(can['dd'])}}}1891\\newcommand{{\\VcTorDD}}{{{fr_pct(dd_tor)}}}1892\\newcommand{{\\VcVanDD}}{{{fr_pct(dd_van)}}}1893\\newcommand{{\\VcQcDepuisVingt}}{{{fr_pct(g2020_qc, sign=True)}}}1894\\newcommand{{\\VcCanDepuisVingt}}{{{fr_pct(g2020_can, sign=True)}}}1895\\newcommand{{\\VcCorrMtlTor}}{{{fr_num(corr_mtl_tor, 2)}}}1896\\newcommand{{\\VcSaisonMax}}{{{fr_pct(saison.max())}}}1897\\newcommand{{\\VcSaisonMin}}{{{fr_pct(saison.min())}}}1898\\newcommand{{\\VcTauxTroisMois}}{{{fr_pct(taux3m.iloc[-1])}}}1899\\newcommand{{\\VcTauxDixAns}}{{{fr_pct(taux10a.iloc[-1])}}}1900\\newcommand{{\\VcInflation}}{{{fr_pct(infl.iloc[-1])}}}1901\\newcommand{{\\VcChomage}}{{{fr_pct(chomage.iloc[-1])}}}1902\\newcommand{{\\VcPibYoY}}{{{fr_pct(pib_yoy.iloc[-1], sign=True)}}}1903\\newcommand{{\\VcQcReelMult}}{{{fr_num(reel_mult)}}}1904\\newcommand{{\\VcQcReelCagr}}{{{fr_pct(reel_cagr)}}}1905"""1906(TAB / "valeurs_cles.tex").write_text(macros)19071908summary = {1909 "derniere_obs": str(LAST.date()),1910 "quebec": {k: (str(v) if isinstance(v, pd.Timestamp) else round(float(v), 2))1911 for k, v in qc.items()},1912 "canada": {k: (str(v) if isinstance(v, pd.Timestamp) else round(float(v), 2))1913 for k, v in can.items()},1914 "macro": {1915 "taux3m": round(float(taux3m.iloc[-1]), 2),1916 "taux10a": round(float(taux10a.iloc[-1]), 2),1917 "inflation": round(float(infl.iloc[-1]), 2),1918 "inflation_date": str(infl.index[-1].date()),1919 "chomage": round(float(chomage.iloc[-1]), 2),1920 "pib_yoy": round(float(pib_yoy.iloc[-1]), 2),1921 "pib_date": str(pib_yoy.index[-1].date()),1922 "hpi_reel_mult": round(float(reel_mult), 2),1923 "hpi_reel_cagr": round(float(reel_cagr), 2),1924 },1925 "croisement_qc_canada": str(cross.date()),1926 "volatilite": {"qc": round(float(vol_qc.iloc[-1]), 2),1927 "canada": round(float(vol_ca.iloc[-1]), 2)},1928 "corr_mobile": {"qc_canada": round(float(c2.iloc[-1]), 2),1929 "mtl_toronto": round(float(c1.iloc[-1]), 2)},1930 "regions": region_summaries,1931 "pop_growth_2023": round(float((pop.pct_change() * 100).loc["2023-01-01"]), 2),1932 "pop_growth_last": {str(k.year): round(float(v), 2)1933 for k, v in (pop.pct_change() * 100).dropna().tail(4).items()},1934 "fx_last": round(float(fx_m.iloc[-1]), 3),1935 "beta_last": round(float(beta.iloc[-1]), 2),1936 "beta_mean": round(float(beta.mean()), 2),1937 "roc": {NOMS[s]: {"hpi": round(float(nsa[s]['Composite_HPI'].iloc[-1]), 1),1938 "bench": int(nsa[s]['Composite_Benchmark'].iloc[-1]),1939 "yoy": round(float(yoy(nsa[s]['Composite_HPI']).iloc[-1]), 2),1940 "dd": round(float(drawdown(sa[s]['Composite_HPI']).iloc[-1]), 2),1941 "dd_max": round(float(drawdown(sa[s]['Composite_HPI']).min()), 2)}1942 for s in ROC},1943}1944print(json.dumps(summary, indent=2, ensure_ascii=False))1945print("\nTerminé : 26 figures dans figures/, 5 fichiers dans tables/.")1946