SPB Git

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%
88.7 KB · 1,946 lines python
Raw Blame History
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