#!/usr/bin/env python3 """Chapitre 47 — figures du dossier « 12 logements à Montréal » (DONNÉES SYNTHÉTIQUES, graine 20261031). Produit assets/figures/ch47/ch47-fig-{etat-locatif,depenses,taux-extraits,flux-dcf,montecarlo,tornade,rcd-scenarios}.pdf Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch47_dossier_figures.py""" from __future__ import annotations import sys, pathlib import numpy as np, pandas as pd import matplotlib.pyplot as plt from matplotlib.ticker import FuncFormatter, PercentFormatter sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars import ch47_dossier as ch ETIQ = {"inoccupation_pertes": "Inoccupation et pertes", "croissance_loyers_marche": "Croissance des loyers de marché", "rotation_annuelle": "Rotation annuelle", "croissance_depenses": "Croissance des dépenses", "taux_actualisation": "Taux d'actualisation", "ecart_terminal": "Écart terminal (r − R_T)", "facteur_capex": "Facteur CAPEX"} def fig_etat_locatif(D): e = D["etat"]; x = np.arange(len(e)); w = 0.38 fig, ax = plt.subplots(figsize=(LARGEUR, 3.1)) ax.bar(x - w / 2, e["loyer_mensuel_contrat"], w, color=palette.cat[0], label="Loyer contractuel (bail 2026-2027)") ax.bar(x + w / 2, e["loyer_mensuel_marche"], w, color=palette.cat[1], label="Loyer de marché estimé") for i, r in e.iterrows(): ecart = (r["loyer_mensuel_marche"] - r["loyer_mensuel_contrat"]) / r["loyer_mensuel_marche"] ax.text(x[i], r["loyer_mensuel_marche"] + 25, f"−{ecart*100:.0f} %".replace(".", ","), ha="center", va="bottom", fontsize=7, color=palette.ink) ax.set_xticks(x); ax.set_xticklabels([f"{l}\n{p}" for l, p in zip(e["logement"], e["pieces"])], fontsize=7.5) ax.set_ylabel("Loyer mensuel ($)"); ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.set_ylim(0, 1900) ax.legend(loc="upper left", ncol=2); ax.grid(axis="x", visible=False) savefig(fig, "ch47/ch47-fig-etat-locatif.pdf") def fig_depenses(D): d = D["base"]["detail"].copy(); d = d[d["normalise"] > 0].sort_values("normalise") coul = {"fixe": palette.cat[0], "variable": palette.cat[1], "réserve": palette.cat[2]} fig, ax = plt.subplots(figsize=(LARGEUR, 3.4)) noms = [p.replace(" (par logement)", "").replace(" (aires communes)", " (communs)").replace(" (aires communes, gaz)", " (communs, gaz)") for p in d["poste"]] ax.barh(noms, d["normalise"], color=[coul[t] for t in d["type"]]) ax.scatter(d["historique_2025"], noms, marker="|", s=90, color=palette.ink, zorder=3, label="Historique 2025 (non normalisé)") for n, v, h in zip(noms, d["normalise"], d["historique_2025"].fillna(0)): ax.text(max(v, h) + 180, n, fmt_dollars(v), va="center", fontsize=7.5, color=palette.ink) from matplotlib.patches import Patch h = [Patch(color=coul["fixe"], label="Fixes (taxes, assurances)"), Patch(color=coul["variable"], label="Variables et gestion imputée"), Patch(color=coul["réserve"], label="Réserve de remplacement")] h.append(ax.collections[0]); ax.legend(handles=h, loc="lower right", fontsize=7.5) ax.set_xlabel("Dépense normalisée, première année ($)"); ax.xaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.set_xlim(0, 14500); ax.grid(axis="y", visible=False) savefig(fig, "ch47/ch47-fig-depenses.pdf") def fig_taux_extraits(D): c = D["taux"]["comparables"]; tx = D["taux"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) ax.axhspan(tx["q1"] * 100, tx["q3"] * 100, color=palette.grid, alpha=0.6, lw=0, label="Intervalle interquartile (Q1–Q3)") for i, et in enumerate([2, 3, 4]): s = c[c["etat_1a5"] == et] ax.scatter(s["nb_logements"], s["taux_global"] * 100, color=palette.cat[i], s=28, label=f"État {et}/5 (n = {len(s)})", edgecolor="white", linewidth=0.5) ax.axhline(tx["mediane"] * 100, color=palette.ink, lw=1.2, ls="--") ax.text(30.3, tx["mediane"] * 100, f"médiane {tx['mediane']*100:.3f} %".replace(".", ","), va="center", fontsize=7.5) ax.scatter([12], [tx["mediane"] * 100], marker="D", s=70, facecolor="white", edgecolor=palette.ink, linewidth=1.4, zorder=5) ax.annotate("sujet (12 log., taux retenu)", (12, tx["mediane"] * 100), xytext=(13.2, 3.78), fontsize=7.5, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6)) ax.set_xlabel("Nombre de logements du comparable"); ax.set_ylabel("Taux global extrait (%)"); ax.set_xlim(4, 34); ax.set_ylim(3.5, 5.7) ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.1f}".replace(".", ","))) ax.legend(loc="upper right", fontsize=7.5, ncol=2) savefig(fig, "ch47/ch47-fig-taux-extraits.pdf") def fig_flux_dcf(D): p = D["projection"]; p10 = p[p["annee"] <= 10]; sd = D["financement"]["service_dette"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)); x = p10["annee"].values ax.bar(x, p10["rne"], 0.6, color=palette.cat[0], label="RNE (après réserve)") ax.bar(x, -p10["capex"], 0.6, color=palette.cat[1], label="CAPEX (dépenses en capital ponctuelles)") ax.plot(x, p10["flux_avant_dette"], color=palette.ink, marker="o", ms=4, lw=1.3, label="Flux avant dette (RNE − CAPEX)") ax.axhline(sd, color=palette.cat[3], ls="--", lw=1.2); ax.text(10.45, sd, "service de la dette\n(prêt RCD 1,20)", va="center", fontsize=7.2, color=palette.ink) ax.axhline(0, color=palette.ink2, lw=0.6) ax.set_xticks(x); ax.set_xticklabels([f"{a}\n{e}" for a, e in zip(p10["annee"], p10["exercice"])], fontsize=7.5) ax.set_xlabel("Année de projection (exercice)"); ax.set_ylabel("Montant annuel ($)"); ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars)) ax.set_ylim(-135000, 175000); ax.set_xlim(0.3, 12.2); ax.legend(loc="lower left", fontsize=7.5, ncol=1); ax.grid(axis="x", visible=False) savefig(fig, "ch47/ch47-fig-flux-dcf.pdf") def fig_montecarlo(D): mc = D["mc"]; V = mc["valeur"] / 1e6; st = mc["stats"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) ax.hist(V, bins=80, range=(1.2, 3.8), color=palette.cat[0], alpha=0.85, edgecolor="white", linewidth=0.3) ymax = ax.get_ylim()[1] for q, lab in [(st["q05"], "q05"), (st["mediane"], "médiane"), (st["q95"], "q95")]: ax.axvline(q / 1e6, color=palette.ink, lw=1.0, ls="--"); ax.text(q / 1e6, ymax * 0.97, f"{lab}\n{q/1e6:.2f} M$".replace(".", ","), ha="center", va="top", fontsize=7.2) ax.axvline(D["dcf"]["valeur"] / 1e6, color=palette.cat[1], lw=1.6); ax.text(D["dcf"]["valeur"] / 1e6 + 0.03, ymax * 0.36, "DCF déterministe\n2,21 M$", fontsize=7.2, color=palette.ink) ax.axvline(D["valeur_capitalisation"] / 1e6, color=palette.cat[2], lw=1.6); ax.text(D["valeur_capitalisation"] / 1e6 - 0.03, ymax * 0.52, "capitalisation\ndirecte 2,04 M$", fontsize=7.2, color=palette.ink, ha="right") ax.set_xlabel("Valeur simulée (millions de dollars)"); ax.set_ylabel("Nombre de tirages (sur 10 000)") ax.xaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.1f}".replace(".", ","))); ax.set_xlim(1.2, 3.8) savefig(fig, "ch47/ch47-fig-montecarlo.pdf") def fig_tornade(D): t = D["tornade"].iloc[::-1]; V0 = t["V_base"].iloc[0] / 1e6 fig, ax = plt.subplots(figsize=(LARGEUR, 3.0)) y = np.arange(len(t)) lo = np.minimum(t["V_bas"], t["V_haut"]) / 1e6; hi = np.maximum(t["V_bas"], t["V_haut"]) / 1e6 ax.barh(y, t["V_bas"] / 1e6 - V0, left=V0, color=palette.cat[0], height=0.6, label="Intrant au quantile 10 %") ax.barh(y, t["V_haut"] / 1e6 - V0, left=V0, color=palette.cat[1], height=0.6, label="Intrant au quantile 90 %") ax.axvline(V0, color=palette.ink, lw=1.0) for yi, l, h in zip(y, lo, hi): ax.text(l - 0.02, yi, f"{l:.2f}".replace(".", ","), va="center", ha="right", fontsize=7.2); ax.text(h + 0.02, yi, f"{h:.2f}".replace(".", ","), va="center", ha="left", fontsize=7.2) ax.set_yticks(y); ax.set_yticklabels([ETIQ[i] for i in t["intrant"]], fontsize=8) ax.set_xlabel("Valeur DCF (millions de dollars), autres intrants à leur valeur de base"); ax.set_xlim(1.75, 2.75) ax.xaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.2f}".replace(".", ","))); ax.legend(loc="lower right", fontsize=7.5); ax.grid(axis="y", visible=False) savefig(fig, "ch47/ch47-fig-tornade.pdf") def fig_rcd_scenarios(D): r = D["rcd_par_annee"]; cols = [c for c in r.columns if c != "annee"] fig, ax = plt.subplots(figsize=(LARGEUR, 3.0)) for i, c in enumerate(cols): ax.plot(r["annee"], r[c], marker="o", ms=3.5, color=palette.cat[i], label=f"Renouvellement à l'an 6 : {c}") ax.axhline(1.20, color=palette.ink, ls="--", lw=1.0); ax.text(10.15, 1.20, "RCD 1,20 (prêteur)", va="center", fontsize=7.5) ax.axhline(1.00, color=palette.cat[5], ls=":", lw=1.2); ax.text(10.15, 1.00, "RCD 1,00 (défaut technique)", va="center", fontsize=7.5) ax.axvspan(5.5, 10.5, color=palette.grid, alpha=0.5, lw=0); ax.text(8, 2.12, "second terme (taux renouvelé)", ha="center", fontsize=7.5, color=palette.ink2) ax.set_xticks(r["annee"]); ax.set_xlim(0.7, 12.4); ax.set_ylim(0.9, 2.2); ax.set_xlabel("Année de projection"); ax.set_ylabel("Ratio de couverture de la dette (RNE / SD)") ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.1f}".replace(".", ","))); ax.legend(loc="upper left", fontsize=7.5) savefig(fig, "ch47/ch47-fig-rcd-scenarios.pdf") if __name__ == "__main__": setup(); D = ch.dossier() for f in (fig_etat_locatif, fig_depenses, fig_taux_extraits, fig_flux_dcf, fig_montecarlo, fig_tornade, fig_rcd_scenarios): f(D); print("OK", f.__name__)