"""Figures du chapitre 32 — Duplex à quintuplex (données SYNTHÉTIQUES du cas 2, sauf la figure des médianes APCIQ, citée). Produit : assets/figures/ch32/ch32-fig-{mrb-taille-quartier,taux-loyer-relatif,prix-logement-quartier,plex-apciq-rmr,sensibilite-occupant}.pdf Exécution : python3 analysis/python/figures/ch32_figures.py (depuis la racine).""" from __future__ import annotations import sys, pathlib import numpy as np import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter ROOT = pathlib.Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) from traite.style import setup, palette, savefig, LARGEUR, DEMI, fmt_dollars, fmt_milliers # noqa: E402 import ch32_plex as C # noqa: E402 QUARTIERS = ["Limoilou", "Saint-Roch", "Saint-Sauveur", "Maizerets"] def _boxstyle(bp, color): for k in ("boxes", "whiskers", "caps", "medians"): for a in bp[k]: a.set_color(color); a.set_linewidth(1.2) for a in bp["medians"]: a.set_color(palette.ink); a.set_linewidth(1.6) for a in bp["fliers"]: a.set(marker="o", markersize=3, markerfacecolor="none", markeredgecolor=color, alpha=0.7) def fig_mrb(R): c = R["comparables"] fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.0), gridspec_kw={"width_ratios": [1, 2]}) ax = axes[0] data = [c.loc[c["nb_logements"] == n, "mrb"] for n in (2, 3)] bp = ax.boxplot(data, widths=0.55, patch_artist=False); _boxstyle(bp, palette.cat[0]) ax.set_xticks([1, 2]); ax.set_xticklabels([f"Duplex\n(n = {len(data[0])})", f"Triplex\n(n = {len(data[1])})"]) ax.set_ylabel("Multiplicateur du revenu brut (MRB)"); ax.set_title("Selon la taille", loc="left") for i, d in enumerate(data, 1): ax.text(i + 0.33, d.median(), f"{d.median():.1f}".replace(".", ","), va="center", fontsize=8, color=palette.ink) ax = axes[1] data = [c.loc[c["quartier"] == q, "mrb"] for q in QUARTIERS] bp = ax.boxplot(data, widths=0.55); _boxstyle(bp, palette.cat[1]) ax.set_xticks(range(1, 5)); ax.set_xticklabels([f"{q}\n(n = {len(d)})" for q, d in zip(QUARTIERS, data)]) ax.set_title("Selon le quartier (2 et 3 logements confondus)", loc="left") for i, d in enumerate(data, 1): ax.text(i + 0.33, d.median(), f"{d.median():.1f}".replace(".", ","), va="center", fontsize=8, color=palette.ink) ymin = min(c["mrb"]) - 1; ymax = max(c["mrb"]) + 1 for ax in axes: ax.set_ylim(ymin, ymax); ax.grid(axis="x", visible=False) fig.tight_layout(); savefig(fig, "ch32/ch32-fig-mrb-taille-quartier.pdf") def fig_taux_rho(R): g = R["decomposition"]; reg = R["regression"]; I = R["indications"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.3)) for n, col, lab in ((2, palette.cat[0], "Duplex"), (3, palette.cat[1], "Triplex")): s = g[g["nb_logements"] == n] ax.scatter(s["loyer_relatif"], 100 * s["taux_global"], s=16, color=col, alpha=0.75, label=lab, edgecolors="none") xs = np.linspace(g["loyer_relatif"].min() - 0.01, 1.01, 50) ax.plot(xs, 100 * (reg["a"] + reg["b_rho"] * (xs - 1)), color=palette.ink, linewidth=1.4, label="Régression $R = a + b(\\rho - 1)$") ax.axhline(100 * R["indicateurs"]["taux_median"], color=palette.ink2, linestyle=":", linewidth=1) ax.text(xs[0], 100 * R["indicateurs"]["taux_median"] + 0.04, "médiane des 127 ventes", fontsize=7.5, color=palette.ink2) ax.scatter([I["rho_sujet"]], [100 * I["taux_ajuste"]], marker="*", s=140, color=palette.cat[2], zorder=5, label="Sujet : $\\rho$ = 0,848, taux ajusté", edgecolors=palette.ink, linewidths=0.5) ax.set_xlabel("Loyer contractuel moyen / loyer de marché estimé ($\\rho$)"); ax.set_ylabel("Taux global extrait (%)") ax.yaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.1f}".replace(".", ","))) ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.2f}".replace(".", ","))) ax.legend(loc="upper left", fontsize=7.5, ncol=2); fig.tight_layout() savefig(fig, "ch32/ch32-fig-taux-loyer-relatif.pdf") def fig_prix_logement(R): c = R["comparables"]; comp5 = R["comparables5"]; I = R["indications"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) data = [c.loc[c["quartier"] == q, "prix_par_logement"] / 1000 for q in QUARTIERS] bp = ax.boxplot(data, widths=0.55); _boxstyle(bp, palette.cat[0]) rng = np.random.default_rng(20261031) for i, q in enumerate(QUARTIERS, 1): s = c[c["quartier"] == q] ax.scatter(i + rng.uniform(-0.18, 0.18, len(s)), s["prix_par_logement"] / 1000, s=9, color=palette.cat[0], alpha=0.35, edgecolors="none") ax.scatter(np.ones(len(comp5)) + 0.0, comp5["prix_par_logement"] / 1000, marker="D", s=30, color=palette.cat[2], zorder=5, label="Cinq comparables retenus (triplex, Limoilou)", edgecolors=palette.ink, linewidths=0.4) ax.axhline(I["prix_log_final"] / 1000, color=palette.ink, linestyle="--", linewidth=1.1) ax.text(4.45, I["prix_log_final"] / 1000 + 4, "valeur pondérée\ndu sujet, par logement", fontsize=7.5, ha="right", color=palette.ink) ax.set_xticks(range(1, 5)); ax.set_xticklabels([f"{q}\n(n = {len(d)})" for q, d in zip(QUARTIERS, data)]) ax.set_ylabel("Prix par logement (milliers de $)"); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers)) ax.grid(axis="x", visible=False); ax.legend(loc="upper right", fontsize=7.5); fig.tight_layout() savefig(fig, "ch32/ch32-fig-prix-logement-quartier.pdf") # Médianes APCIQ (Centris) des plex de 2 à 5 logements, T3 2026 — chiffres cités (communiqué du 14 octobre 2026 et baromètres), sources/notes-E.md APCIQ_PLEX_T3_2026 = [ ("Province de Québec", 699_000), ("RMR de Montréal", 860_500), ("Île de Montréal", 900_000), ("RMR de Québec", 600_000), ("Agglomération de Québec", 618_500), ("Secteur La Cité–Basse-Ville et Limoilou", 647_500), ("RMR de Gatineau", 617_500), ("RMR de Sherbrooke", 544_500), ("RMR de Trois-Rivières", 420_000), ("RMR de Saguenay", 373_500)] def fig_apciq(): fig, ax = plt.subplots(figsize=(LARGEUR, 3.1)) labels = [l for l, _ in APCIQ_PLEX_T3_2026][::-1]; vals = [v for _, v in APCIQ_PLEX_T3_2026][::-1] cols = [palette.cat[1] if ("Québec" in l and "Province" not in l) or "Limoilou" in l else palette.cat[0] for l in labels] ax.barh(labels, vals, color=cols, height=0.65) for i, v in enumerate(vals): ax.text(v + 8000, i, fmt_dollars(v), va="center", fontsize=7.5, color=palette.ink) ax.set_xlim(0, 1_050_000); ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: fmt_milliers(x / 1000) + " k$")) ax.set_xlabel("Prix médian des plex (2 à 5 logements), 3ᵉ trimestre 2026"); ax.grid(axis="y", visible=False); fig.tight_layout() savefig(fig, "ch32/ch32-fig-plex-apciq-rmr.pdf") def fig_sensibilite(R): S = R["sensibilite"]; base = R["base"]; I = R["indications"]; loyers = R["loyers"] fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.1)) ax = axes[0]; L = S["loyers"] ax.plot(L["rho"], L["v_mrb"] / 1000, marker="o", color=palette.cat[0], label="MRB des comparables × RBP") ax.plot(L["rho"], L["v_cap_taux_median"] / 1000, marker="s", color=palette.cat[1], label="Capitalisation, taux médian fixe") ax.plot(L["rho"], L["v_cap_ajuste"] / 1000, marker="^", color=palette.cat[2], label="Capitalisation, taux ajusté à $\\rho$") ax.axvline(I["rho_sujet"], color=palette.ink2, linestyle=":", linewidth=1); ax.text(I["rho_sujet"] + 0.004, L["v_mrb"].max() / 1000, "sujet", fontsize=7.5, color=palette.ink2, va="top") ax.set_xlabel("Loyers contractuels / loyers de marché ($\\rho$)"); ax.set_ylabel("Indication de valeur (milliers de $)") ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.2f}".replace(".", ","))); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers)) ax.legend(fontsize=7, loc="upper left"); ax.set_title("Loyers sous le marché", loc="left") ax = axes[1] prix = I["valeur_arrondie"]; taux = np.arange(0.0312, 0.0713, 0.0025) for mf, col, lab in ((C.mise_de_fonds_minimale_assuree(prix), palette.cat[0], "Mise de fonds minimale assurée (7,1 %)"), (0.20 * prix, palette.cat[1], "Mise de fonds de 20 %")): y = [C.cout_occupation(C.financement_occupant(prix, mf, t), base, loyers, "RC")["cout_net_mensuel_hors_capital"] for t in taux] ax.plot(100 * taux, y, color=col, label=lab) ax.axhline(1430, color=palette.ink2, linestyle="--", linewidth=1); ax.text(100 * taux[0] + 0.05, 1430 + 40, "loyer de marché du logement occupé (1 430 $)", fontsize=7.5, color=palette.ink2) ax.axvline(100 * C.TAUX_ASSURE, color=palette.ink2, linestyle=":", linewidth=1); ax.text(100 * C.TAUX_ASSURE + 0.05, 3000, "4,12 %\n(assuré,\naoût 2026)", fontsize=7, color=palette.ink2) ax.set_xlabel("Taux hypothécaire (%)"); ax.set_ylabel("Coût net mensuel d'occupation, hors capital ($)") ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.1f}".replace(".", ","))); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers)) ax.legend(fontsize=7, loc="upper left"); ax.set_title("Financement de l'acheteur-occupant", loc="left") fig.tight_layout(); savefig(fig, "ch32/ch32-fig-sensibilite-occupant.pdf") if __name__ == "__main__": setup(); R = C.calculer() fig_mrb(R); fig_taux_rho(R); fig_prix_logement(R); fig_apciq(); fig_sensibilite(R) print("figures ch32 écrites dans", ROOT / "assets/figures/ch32")