SPB Git
14.2 KB · 152 lines python
1#!/usr/bin/env python32"""Chapitre 48 — figures du dossier « portefeuille multirésidentiel interrégional » (DONNÉES SYNTHÉTIQUES, graine 20261031).3Produit assets/figures/ch48/ch48-fig-{carte,rmr,rne,taux,montecarlo,dette,frontiere}.pdf4Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch48_portefeuille_figures.py"""5from __future__ import annotations6import sys, pathlib, math7import numpy as np, pandas as pd8import matplotlib.pyplot as plt9from matplotlib.ticker import FuncFormatter10sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))11sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))12from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars, fmt_milliers13import ch48_portefeuille as ch1415RMR = ch.RMR16COUL = {r: palette.cat[i] for i, r in enumerate(RMR)}17pct2 = FuncFormatter(lambda v, p: f"{v*100:.2f}".replace(".", ",") + " %")18pct1 = FuncFormatter(lambda v, p: f"{v*100:.1f}".replace(".", ",") + " %")19mdol = FuncFormatter(lambda v, p: f"{v/1e6:.0f} M$")20kdol = FuncFormatter(lambda v, p: f"{v/1e3:.0f} k$")21# Coordonnées des centres (Base de données toponymiques du Canada, RNCan, geonames, consulté le 2026-10-31 ; mêmes valeurs que ch04_carte.py)22CENTRES = {"Montréal": (45.508822, -73.554077), "Québec": (46.813819, -71.207997), "Gatineau": (45.427929, -75.710938),23           "Sherbrooke": (45.404047, -71.893171), "Trois-Rivières": (46.343077, -72.543383), "Saguenay": (48.428221, -71.062202)}24DECAL = {"Montréal": (-1.35, -0.05), "Québec": (0.55, 0.25), "Gatineau": (0.0, 0.45), "Sherbrooke": (0.85, -0.12), "Trois-Rivières": (-0.05, 0.40), "Saguenay": (0.0, 0.40)}2526def fig_carte(D):27    pf = D["portefeuille"]; g = D["sommaire"]["par_rmr"]; k = math.cos(math.radians(47.0))28    fig, ax = plt.subplots(figsize=(LARGEUR, 4.0))29    pts = [CENTRES["Montréal"], CENTRES["Trois-Rivières"], CENTRES["Québec"]]30    ax.plot([p[1] * k for p in pts], [p[0] for p in pts], color=palette.grid, lw=5, alpha=0.9, zorder=1, solid_capstyle="round")31    ref = g["valeur"].max()32    for r in RMR:33        lat, lon = CENTRES[r]; x, y = lon * k, lat34        ax.scatter([x], [y], s=max(2600 * g.loc[r, "valeur"] / ref, 40), color=COUL[r], alpha=0.30, edgecolor=COUL[r], lw=1.0, zorder=2)35        s = pf[pf["rmr"] == r]36        ax.scatter(s["lon"] * k, s["lat"], s=6 + s["nb_logements"] * 0.9, color=COUL[r], edgecolor="white", lw=0.4, zorder=4, alpha=0.9)37        dx, dy = DECAL[r]38        ax.text(x + dx, y + dy, f"{r}\n{int(g.loc[r, 'n'])} imm. · {int(g.loc[r, 'logements'])} log.\n{g.loc[r, 'valeur']/1e6:.1f} M$ · {g.loc[r, 'taux_pondere']*100:.2f} %".replace(".", ","), ha="center", va="center", fontsize=7, color=palette.ink, zorder=5)39    z = pf[pf["zone_inondable"] == 1]; ax.scatter(z["lon"] * k, z["lat"], s=90, facecolor="none", edgecolor=palette.cat[3], lw=1.3, zorder=6, label="zone inondable (3 immeubles)")40    c = pf[pf["contamination_sol"] == 1]; ax.scatter(c["lon"] * k, c["lat"], s=90, facecolor="none", edgecolor=palette.ink, lw=1.0, ls="--", zorder=6, label="contamination du sol (2 immeubles)")41    ax.set_xlim(-76.2 * k - 0.9, -70.5 * k + 0.9); ax.set_ylim(44.35, 49.3)42    ax.set_xticks([lon * k for lon in (-76, -75, -74, -73, -72, -71)]); ax.set_xticklabels([f"{abs(l)}° O" for l in (-76, -75, -74, -73, -72, -71)])43    ax.set_yticks([45, 46, 47, 48, 49]); ax.set_yticklabels([f"{v}° N" for v in (45, 46, 47, 48, 49)])44    ax.set_aspect("equal"); ax.grid(True, lw=0.4); ax.legend(loc="upper left", fontsize=7)45    ax.text(-76.2 * k - 0.8, 44.45, "Cercles : valeur 2026 agrégée par RMR (surface proportionnelle) ; points : immeubles (surface ∝ logements) ; schéma sans fond de carte", fontsize=6.8, color=palette.ink2)46    return savefig(fig, "ch48/ch48-fig-carte.pdf")4748def fig_rmr(D):49    g = D["sommaire"]["par_rmr"]; pf = D["portefeuille"]; x = np.arange(len(RMR))50    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"wspace": 0.42})51    ax1.bar(x, g["valeur_par_logement"], 0.62, color=[COUL[r] for r in RMR], alpha=0.85)52    for i, r in enumerate(RMR):53        ax1.text(i, g.loc[r, "valeur_par_logement"] + 4000, fmt_milliers(round(g.loc[r, "valeur_par_logement"], -3)), ha="center", fontsize=7)54        ax1.text(i, 6000, f"RPV {g.loc[r, 'rpv']*100:.0f} %", ha="center", fontsize=6.5, color="white", rotation=90, va="bottom")55    ax1.axhline(D["sommaire"]["total"]["valeur_par_logement"], color=palette.ink, ls=":", lw=1); ax1.text(5.45, D["sommaire"]["total"]["valeur_par_logement"] + 3000, "portefeuille", fontsize=6.5, ha="right")56    ax1.set_xticks(x); ax1.set_xticklabels(RMR, rotation=28, ha="right", fontsize=7.5); ax1.yaxis.set_major_formatter(kdol); ax1.set_ylim(0, 240_000); ax1.set_title("Valeur 2026 par logement", loc="left")57    # taux : étendue, médiane, pondéré ; fourchettes CBRE (Montréal, Québec, basse hauteur B, T3 2026)58    cbre = {"Montréal": (0.0425, 0.0475), "Québec": (0.05, 0.06)}59    for i, r in enumerate(RMR):60        s = pf[pf["rmr"] == r]61        ax2.vlines(i, s["taux_global"].min(), s["taux_global"].max(), color=palette.ink2, lw=1)62        ax2.scatter(np.full(len(s), i) + np.linspace(-0.12, 0.12, len(s)), s["taux_global"], s=14, color=COUL[r], zorder=3, alpha=0.9)63        ax2.scatter([i], [g.loc[r, "taux_pondere"]], marker="_", s=300, color=palette.ink, linewidths=2.2, zorder=4)64        if r in cbre: ax2.add_patch(plt.Rectangle((i - 0.32, cbre[r][0]), 0.64, cbre[r][1] - cbre[r][0], color=palette.grid, alpha=0.9, zorder=1))65    ax2.scatter([], [], marker="_", s=120, color=palette.ink, linewidths=2.2, label="taux pondéré (RNE / valeur)"); ax2.scatter([], [], s=14, color=palette.ink2, label="immeubles")66    ax2.add_patch(plt.Rectangle((0, 0), 0, 0, color=palette.grid, label="fourchette CBRE T3 2026 (basse hauteur B)"))67    ax2.set_xticks(x); ax2.set_xticklabels(RMR, rotation=28, ha="right", fontsize=7.5); ax2.yaxis.set_major_formatter(pct1); ax2.set_ylim(0.035, 0.075)68    ax2.set_title("Taux global par RMR", loc="left"); ax2.legend(loc="upper left", fontsize=6.5)69    fig.text(0.99, -0.06, "DONNÉES SYNTHÉTIQUES — cas 4 (48 immeubles, 6 RMR) ; CBRE : repères publiés, Montréal et Québec seulement", ha="right", fontsize=6.8, color=palette.ink2)70    return savefig(fig, "ch48/ch48-fig-rmr.pdf")7172def fig_rne(D):73    dyn = D["dynamique"]; agg = dyn["agregats_rmr"]; tot = dyn["agregat_total"]74    idx = [p.to_timestamp() for p in agg.index]75    fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))76    ax.axvspan(pd.Timestamp("2020-04-01"), pd.Timestamp("2021-06-30"), color=palette.grid, alpha=0.7, lw=0, label="choc pandémique (2020T2-2021T2)")77    for r in RMR:78        ax.plot(idx, 100 * agg[r] / agg[r].iloc[0], color=COUL[r], lw=1.2, label=f"{r} (g = {dyn['par_rmr'].loc[r, 'croissance_agregee']*100:.1f} %/an)".replace(".", ","))79    ax.plot(idx, 100 * tot / tot.iloc[0], color=palette.ink, lw=2.2, label=f"Portefeuille (g = {dyn['total']['croissance_agregee']*100:.1f} %/an)".replace(".", ","))80    ax.set_ylabel("RNE trimestriel agrégé, indice 2016T1 = 100"); ax.set_ylim(90, 170); ax.legend(loc="upper left", fontsize=6.8, ncol=2)81    ax.text(pd.Timestamp("2026-05-01"), 92, "DONNÉES SYNTHÉTIQUES", ha="right", fontsize=6.8, color=palette.ink2)82    return savefig(fig, "ch48/ch48-fig-rne.pdf")8384def fig_taux(D):85    t = D["predites"]; reg = D["regression"]86    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"wspace": 0.35, "width_ratios": [1.25, 1]})87    for i, r in enumerate(RMR):88        s = t[t["rmr"] == r].sort_values("taux_global"); off = np.linspace(-0.2, 0.2, len(s))89        ax1.scatter(i + off, s["taux_global"], s=20, color=COUL[r], zorder=3, label="taux retenu (évaluation individuelle)" if i == 0 else None)90        ax1.scatter(i + off, s["taux_predit"], s=26, facecolor="none", edgecolor=palette.ink, lw=0.8, zorder=4, label="taux prédit par le modèle de masse" if i == 0 else None)91        for o, a, b in zip(off, s["taux_global"], s["taux_predit"]): ax1.plot([i + o, i + o], [a, b], color=palette.ink2, lw=0.5, zorder=2)92    ax1.set_xticks(range(6)); ax1.set_xticklabels(RMR, rotation=28, ha="right", fontsize=7.5); ax1.yaxis.set_major_formatter(pct1); ax1.set_ylim(0.035, 0.075)93    ax1.set_title("Taux retenus et taux prédits", loc="left"); ax1.legend(loc="upper left", fontsize=6.8)94    ax2.hist(reg["residus"] * 1e4 * 0.0520, bins=14, color=palette.cat[0], alpha=0.85, edgecolor="white")95    ax2.axvline(0, color=palette.ink, lw=1)96    ax2.set_xlabel("Résidu du modèle, en points de base\n(au taux pondéré de 5,20 %)"); ax2.set_ylabel("Nombre d'immeubles"); ax2.set_title(f"Résidus (σ = {reg['sigma']:.3f} en log, R² = {reg['r2']:.2f})".replace(".", ","), loc="left")97    fig.text(0.99, -0.08, "DONNÉES SYNTHÉTIQUES — régression semi-logarithmique du taux global (traite.cas4.effets_regionaux), n = 48", ha="right", fontsize=6.8, color=palette.ink2)98    return savefig(fig, "ch48/ch48-fig-taux.pdf")99100def fig_montecarlo(D):101    fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))102    V0 = D["mc"]["stats"]["V0"] / 1e6; bins = np.linspace(110, 185, 76)103    for nom, lab, c, ls in [("mc_independant", "immeubles indépendants (ρ = 0)", palette.cat[2], "-"), ("mc", "corrélation déclarée (taux : 0,50 / 0,70 ; RNE : 0,17)", palette.cat[0], "-"), ("mc_parfait", "corrélation parfaite (ρ = 1)", palette.cat[3], "-")]:104        m = D[nom]; V = m["valeur_portefeuille"] / 1e6105        ax.hist(V, bins=bins, histtype="stepfilled" if nom == "mc" else "step", color=c, alpha=0.55 if nom == "mc" else 1.0, lw=1.4, density=True, label=f"{lab} — IC 90 % : {m['stats']['q05']/1e6:.1f} à {m['stats']['q95']/1e6:.1f} M$".replace(".", ","))106    st = D["mc"]["stats"]107    for q, lab in [(st["q05"], "q 5 %"), (st["q95"], "q 95 %")]:108        ax.axvline(q / 1e6, color=palette.ink, ls="--", lw=0.9); ax.text(q / 1e6, ax.get_ylim()[1] * 0.45, lab, ha="center", fontsize=7, backgroundcolor=palette.surface)109    ax.axvline(V0, color=palette.ink, lw=1.4); ax.annotate(f"somme des indications\nindividuelles : {V0:.1f} M$".replace(".", ","), (V0, ax.get_ylim()[1] * 0.30), xytext=(158, ax.get_ylim()[1] * 0.42), fontsize=7, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))110    ax.set_xlabel("Valeur simulée du portefeuille (M$)"); ax.set_ylabel("Densité"); ax.set_yticks([]); ax.set_ylim(0, ax.get_ylim()[1] * 1.35); ax.legend(loc="upper left", fontsize=6.6)111    ax.text(184, ax.get_ylim()[1] * 0.02, "10 000 tirages, graine 20261031 — DONNÉES SYNTHÉTIQUES", ha="right", fontsize=6.8, color=palette.ink2)112    return savefig(fig, "ch48/ch48-fig-montecarlo.pdf")113114def fig_dette(D):115    det = D["dette"]; ech = det["echeancier"]; er = det["echeancier_rmr"]; x = np.arange(len(ech.index))116    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"wspace": 0.38})117    bas = np.zeros(len(x))118    for r in RMR:119        v = er[r].reindex(ech.index).fillna(0).values / 1e6120        ax1.bar(x, v, 0.62, bottom=bas, color=COUL[r], label=r); bas += v121    for i, e in enumerate(ech.index): ax1.text(i, bas[i] + 0.5, f"{ech.loc[e, 'dette']/1e6:.1f} M$\n({ech.loc[e, 'part_dette']*100:.0f} %)".replace(".", ","), ha="center", fontsize=6.8)122    ax1.set_xticks(x); ax1.set_xticklabels([str(e) for e in ech.index]); ax1.set_ylabel("Dette arrivant à échéance (M$)"); ax1.set_ylim(0, 46); ax1.set_title("Échéancier de la dette par RMR", loc="left"); ax1.legend(fontsize=6.3, ncol=3, loc="upper center")123    for pb, c, mk in [(0, palette.cat[2], "o"), (100, palette.cat[0], "s"), (200, palette.cat[3], "D")]:124        ax2.plot(x, ech[f"rcd_+{pb}"], marker=mk, ms=5, color=c, lw=1.3, label=f"renouvellement au taux +{pb} pb")125    ax2.axhline(1.20, color=palette.ink, ls="--", lw=0.9); ax2.text(4.1, 1.21, "covenant 1,20", fontsize=6.8, ha="right")126    ax2.axhline(1.0, color=palette.ink, ls=":", lw=0.9); ax2.text(4.1, 1.01, "RCD = 1,00", fontsize=6.8, ha="right")127    ax2.set_xticks(x); ax2.set_xticklabels([str(e) for e in ech.index]); ax2.set_ylim(0.9, 2.3); ax2.set_ylabel("RCD agrégé des prêts de l'échéance"); ax2.set_title("Couverture au renouvellement (RNE 2026 constant)", loc="left", fontsize=8.5); ax2.legend(fontsize=6.6, loc="upper left")128    fig.text(0.99, -0.06, "DONNÉES SYNTHÉTIQUES — amortissement 480 mois ; solde refinancé sur l'amortissement résiduel", ha="right", fontsize=6.8, color=palette.ink2)129    return savefig(fig, "ch48/ch48-fig-dette.pdf")130131def fig_frontiere(D):132    fr = D["allocation"]["frontiere"]133    fig, ax = plt.subplots(figsize=(LARGEUR, 3.4))134    pts = fr["frontiere"]; ax.plot([p["sigma"] for p in pts], [p["mu"] for p in pts], color=palette.ink, lw=1.4, label="frontière (6 RMR, sans vente à découvert)")135    for i, r in enumerate(RMR):136        ax.scatter(fr["sigma"][i], fr["mu"][i], s=40, color=COUL[r], zorder=4); ax.annotate(r, (fr["sigma"][i], fr["mu"][i]), xytext=(5, 3), textcoords="offset points", fontsize=7)137    ax.scatter(fr["sigma_actuel"], fr["mu_actuel"], s=110, marker="*", color=palette.cat[3], edgecolor=palette.ink, lw=0.6, zorder=5, label=f"portefeuille actuel (μ = {fr['mu_actuel']*100:.2f} %, σ = {fr['sigma_actuel']*100:.2f} %)".replace(".", ","))138    ax.scatter(fr["sigma_min_var"], fr["mu_min_var"], s=60, marker="s", color=palette.cat[2], edgecolor=palette.ink, lw=0.6, zorder=5, label="variance minimale")139    ax.scatter(fr["sigma_meme_mu"], fr["mu_actuel"], s=60, marker="^", color=palette.cat[1], edgecolor=palette.ink, lw=0.6, zorder=5, label="même rendement attendu, risque minimal")140    ax.scatter(fr["sigma_actuel"], fr["mu_meme_sigma"], s=60, marker="v", color=palette.cat[4], edgecolor=palette.ink, lw=0.6, zorder=5, label="même risque, rendement maximal")141    ax.xaxis.set_major_formatter(pct2); ax.yaxis.set_major_formatter(pct1); ax.set_xlabel("Risque : écart-type du log de la valeur régionale simulée (un an)"); ax.set_ylabel("Rendement attendu : taux pondéré + croissance du RNE")142    ax.set_xlim(0.0415, 0.0545); ax.set_ylim(0.070, 0.106); ax.legend(loc="upper left", fontsize=6.6)143    ax.text(0.0417, 0.0705, "DONNÉES SYNTHÉTIQUES — covariances : simulation Monte-Carlo (10 000 tirages)", fontsize=6.8, color=palette.ink2)144    return savefig(fig, "ch48/ch48-fig-frontiere.pdf")145146def main():147    setup(); D = ch.dossier()148    for f in [fig_carte, fig_rmr, fig_rne, fig_taux, fig_montecarlo, fig_dette, fig_frontiere]: print("OK", f(D))149150if __name__ == "__main__":151    main()152
Files · da8a4ee0304be0aa9c54ed0aad08d985fc4539fc
Menu

Theme