spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 33 — Immeubles de six logements et plus : tous les chiffres cités dans manuscript/chapters/ch33.tex3et solutions/ch33.tex (DONNÉES SYNTHÉTIQUES des cas 3 et 4, plus des données RÉELLES de la SCHL lues dans data/raw).45Contenu :6 - schl_ile_montreal() : lecture des tableaux 1.3.1/1.3.2/1.3.4 (taille d'immeuble) et 1.2.1/1.2.2 (année de7 construction) du classeur SCHL « rmr-montreal-2025-fr.xlsx » (Enquête sur les logements locatifs, octobre 2025),8 bloc « Île de Montréal (zones 1-18) » — données réelles, citées avec attribution (conditions d'utilisation SCHL).9 - sommaire_cas4() : portefeuille du cas 4 par RMR (traite.cas4.sommaire) + statistiques de taux et de taille.10 - effet_taille() : effet de la taille sur le taux global dans le cas 3 (régression linéaire de traite.cas3) et dans11 le cas 4 (régression semi-log de traite.cas4.effets_regionaux), et taux médians par classe de taille.12 - unites_cas3() : analyse par logement, par m², par chambre et multiplicateurs pour le sujet du cas 3.13 - p45() : évaluation sommaire par capitalisation d'un immeuble de 36 logements du cas 4 (P45, Gatineau) avec14 plusieurs taux candidats (Montréal cas 3, Gatineau cas 4, régression cas 4) pour isoler l'effet de RMR et de taille.15 - sensibilite_p45() : grilles RMR × état et courbe taille → taux prédit et valeur.16 - exercices() : chiffres des solutions.1718Aucune formule financière n'est réécrite : traite.finance, traite.cas3, traite.cas4, traite.hedonique.19Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch33_six_logements.py20"""21from __future__ import annotations22import sys, pathlib23import numpy as np, pandas as pd24sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))25from traite import cas3, cas4, finance as fin, hedonique as h2627ROOT = pathlib.Path(__file__).resolve().parents[3]28SCHL_XLSX = ROOT / "data/raw/schl_rml_2025/rmr-montreal-2025-fr.xlsx"2930# ----------------------------------------------------------------------------- constantes citées (sources : notes-B, notes-E, ch. 47)31V_FINALE_CH47 = 2_060_000 # valeur marchande finale du cas 3 au ch. 47 (réconciliation 45/35/20) — vérifiée par test contre ch47_dossier32TAXE_QUEBEC_1A5 = 0.7464 / 100 # Ville de Québec 2026, résidentiel 1 à 5 logements, $ par 100 $ (R.V.Q. 3492)33TAXE_QUEBEC_6PLUS = 0.7697 / 100 # Ville de Québec 2026, résidentiel 6 logements et plus34TARIF_QUEBEC_EAU = 386.0 # $ par logement (aqueduc-égout) ; + 129 $ par chambre (non utilisé ici)35TARIF_QUEBEC_MATIERES = 195.0 # $ par logement (matières résiduelles)36IPCB_MTL_BASSE_HAUTEUR_T3_2026 = 0.092 # IPCB Montréal, immeuble < 5 étages, variation annuelle T3 2026 (StatCan 18-10-0289-01)37IPCB_MTL_TOUR_T3_2026 = 0.079 # idem, tour ≥ 5 étages38ID_36 = "P45" # immeuble de 36 logements retenu dans le cas 4 (Gatineau, 1954, état 2)39RMR_LISTE = ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]404142# ----------------------------------------------------------------------------- SCHL (données réelles)4344def _lire_bloc(ws, titre_bloc: str, n_groupes: int, largeur_groupe: int, labels_arret=("Tous les immeubles",)) -> pd.DataFrame:45 """Lit les lignes du bloc `titre_bloc` d'une feuille SCHL. Chaque ligne = [libellé, g1..gk] ; chaque groupe de46 `largeur_groupe` cellules = (oct. 2024, qualité, oct. 2025, qualité[, flèche]). Retourne, par ligne, les valeurs47 d'octobre 2025 des groupes 2 chambres (3e) et tous (5e), avec leur lettre de qualité."""48 rows = list(ws.iter_rows(values_only=True))49 out, dans_bloc = [], False50 for r in rows:51 lab = r[0]52 if isinstance(lab, str) and lab.startswith(titre_bloc):53 dans_bloc = True; continue54 if not dans_bloc or lab is None:55 continue56 def val(g, off):57 v = r[1 + g * largeur_groupe + off]58 if v is None or str(v).strip() in ("**", "-", ""):59 return np.nan60 return float(str(v).replace(" ", "").replace(" ", "").replace(",", "."))61 def qual(g, off):62 q = r[1 + g * largeur_groupe + off]63 return str(q).strip() if isinstance(q, str) else ""64 out.append({"categorie": str(lab).strip(), "deux_ch_2025": val(2, 2), "deux_ch_qual": qual(2, 3),65 "tous_2025": val(4, 2), "tous_qual": qual(4, 3), "tous_2024": val(4, 0)})66 if str(lab).strip() in labels_arret:67 break68 return pd.DataFrame(out)697071def schl_ile_montreal() -> dict:72 """Tableaux SCHL (octobre 2025), île de Montréal : inoccupation, loyer moyen et rotation par taille d'immeuble ;73 inoccupation et loyer moyen par année de construction. Valeurs « tous les appartements » et « 2 chambres »."""74 import openpyxl75 wb = openpyxl.load_workbook(SCHL_XLSX, read_only=True, data_only=True)76 bloc = "Île de Montréal"77 taille_inocc = _lire_bloc(wb["Tableau 1.3.1"], bloc, 5, 5)78 taille_loyer = _lire_bloc(wb["Tableau 1.3.2"], bloc, 5, 4)79 taille_rot = _lire_bloc(wb["Tableau 1.3.4"], bloc, 5, 5)80 annee_inocc = _lire_bloc(wb["Tableau 1.2.1"], bloc, 5, 5)81 annee_loyer = _lire_bloc(wb["Tableau 1.2.2"], bloc, 5, 4)82 taille = taille_inocc[["categorie"]].copy()83 taille["inoccupation_tous"] = taille_inocc["tous_2025"].values84 taille["inoccupation_2024"] = taille_inocc["tous_2024"].values85 taille["loyer_tous"] = taille_loyer.set_index("categorie").loc[taille["categorie"], "tous_2025"].values86 taille["loyer_2ch"] = taille_loyer.set_index("categorie").loc[taille["categorie"], "deux_ch_2025"].values87 taille["rotation_tous"] = taille_rot.set_index("categorie").loc[taille["categorie"], "tous_2025"].values88 annee = annee_inocc[["categorie"]].copy()89 annee["inoccupation_tous"] = annee_inocc["tous_2025"].values90 annee["loyer_tous"] = annee_loyer.set_index("categorie").loc[annee["categorie"], "tous_2025"].values91 annee["loyer_2ch"] = annee_loyer.set_index("categorie").loc[annee["categorie"], "deux_ch_2025"].values92 annee = annee[annee["categorie"] != "Inconnue"].reset_index(drop=True)93 return {"taille": taille, "annee": annee}949596# ----------------------------------------------------------------------------- cas 4 : sommaire et effets9798def sommaire_cas4() -> dict:99 d = cas4.charger(); pf = d["portefeuille"]100 s = cas4.sommaire(pf)101 g = s["par_rmr"].copy()102 stats = pf.groupby("rmr").agg(taux_median=("taux_global", "median"), taux_min=("taux_global", "min"), taux_max=("taux_global", "max"),103 logements_moyen=("nb_logements", "mean"), loyer_moyen=("loyer_mensuel_moyen", "mean"),104 inocc_marche=("taux_inoccupation_marche", "first"), age_moyen=("age", "mean"))105 g = g.join(stats)106 g["rne_par_logement"] = g["rne"] / g["logements"]107 g = g.loc[RMR_LISTE]108 pf = pf.assign(valeur_par_logement=pf["valeur_2026"] / pf["nb_logements"], rne_par_logement=pf["rne_2026"] / pf["nb_logements"],109 mrb=pf["valeur_2026"] / pf["revenu_brut_potentiel"], rpv=pf["dette"] / pf["valeur_2026"])110 return {"par_rmr": g, "total": s["total"], "portefeuille": pf, "series": d["series"]}111112113def effet_taille(pf: pd.DataFrame) -> dict:114 """Effet de la taille sur le taux global : (i) cas 3, pente linéaire par logement (traite.cas3.taux_extraits) ;115 (ii) cas 4, élasticité semi-log (traite.cas4.effets_regionaux) ; (iii) taux médians par classe de taille (cas 4)."""116 t3 = cas3.tout()117 reg3 = t3["taux"]["regression"]118 eff4 = cas4.effets_regionaux(pf)119 b_ln = float(eff4.loc["ln_logements", "coef"]); se_ln = float(eff4.loc["ln_logements", "se_hc1"])120 classes = pd.cut(pf["nb_logements"], bins=[5, 8, 16, 24, 48, 72], labels=["6–8", "12–16", "24", "36–48", "72"])121 par_classe = pf.groupby(classes, observed=True).agg(n=("id_immeuble", "count"), taux_median=("taux_global", "median"),122 valeur_par_logement=("valeur_2026", lambda v: float(v.sum()) / float(pf.loc[v.index, "nb_logements"].sum())))123 # effet, en points de base, de passer de 12 à 36 logements, toutes choses égales par ailleurs, à un taux de 5,5 % (cas 4) et à la médiane du cas 3124 r_ref4 = float(np.exp(eff4.loc["const", "coef"])) # Montréal, état 3, 12 logements, 50 ans, sans risque125 d_pb_cas4 = (np.exp(b_ln * np.log(36 / 12)) - 1) * r_ref4 * 1e4126 d_pb_cas3 = reg3["taille"] * (36 - 12) * 1e4127 return {"cas3_pente_pb_par_logement": reg3["taille"] * 1e4, "cas3_constante": reg3["constante"], "cas3_mediane": t3["taux"]["mediane"],128 "cas3_n": t3["taux"]["n"], "cas3_comparables": t3["taux"]["comparables"], "cas3_valeur_cap": t3["valeur_capitalisation"],129 "cas3_rne": t3["base"]["rne"], "cas3_base": t3["base"], "cas3_etat": t3["etat"], "cas3_sujet": t3["sujet"], "cas3_dcf": t3["dcf"],130 "cas4_coef_ln": b_ln, "cas4_se_ln": se_ln, "cas4_effet_doublement_pct": float(np.exp(b_ln * np.log(2)) - 1),131 "cas4_effets": eff4, "cas4_r_ref": r_ref4, "d_pb_12_36_cas4": float(d_pb_cas4), "d_pb_12_36_cas3": float(d_pb_cas3), "par_classe": par_classe}132133134# ----------------------------------------------------------------------------- unités d'analyse (cas 3)135136def unites_cas3(et: dict, valeur: float = V_FINALE_CH47) -> dict:137 etat, sujet, base = et["cas3_etat"], et["cas3_sujet"], et["cas3_base"]138 n = int(sujet["nb_logements"]); m2 = float(sujet["superficie_locative_m2"])139 chambres = int(etat["chambres"].sum())140 rbp = base["rbp_contrat"]; rne = base["rne"]141 return {"valeur": valeur, "n": n, "m2": m2, "chambres": chambres, "par_logement": valeur / n, "par_m2": valeur / m2, "par_chambre": valeur / chambres,142 "m2_par_logement": m2 / n, "chambres_par_logement": chambres / n, "mrb": fin.multiplicateur_revenu_brut(valeur, rbp),143 "mrb_marche": fin.multiplicateur_revenu_brut(valeur, base["rbp_marche"]), "taux_implicite": fin.taux_global_extrait(rne, valeur),144 "rne_par_logement": rne / n, "loyer_moyen": float(etat["loyer_mensuel_contrat"].mean()), "loyer_moyen_m2": float((etat["loyer_mensuel_contrat"].sum()) / m2),145 "valeur_cap": et["cas3_valeur_cap"], "valeur_cap_par_logement": et["cas3_valeur_cap"] / n}146147148# ----------------------------------------------------------------------------- P45 : 36 logements à Gatineau149150def _X_ligne(rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0) -> np.ndarray:151 x = [1.0, etat - 3, np.log(n / 12), inond, sol, (age - 50) / 10]152 x += [1.0 if rmr == r else 0.0 for r in ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]]153 return np.array(x)154155156def _regression_cas4(pf: pd.DataFrame) -> dict:157 """Même spécification que traite.cas4.effets_regionaux, mais on conserve les résidus pour le facteur de Duan."""158 X = pd.DataFrame({"const": 1.0, "etat": pf["etat_1a5"] - 3, "ln_logements": np.log(pf["nb_logements"] / 12), "inondable": pf["zone_inondable"],159 "sol": pf["contamination_sol"], "age": (pf["age"] - 50) / 10})160 for r in ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]:161 X[f"rmr_{r}"] = (pf["rmr"] == r).astype(float)162 res = h.mco(X.values, np.log(pf["taux_global"].values))163 res["duan"] = float(np.mean(np.exp(res["residus"])))164 res["colonnes"] = list(X.columns)165 return res166167168def taux_predit(reg: dict, rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0, duan: bool = True) -> float:169 x = _X_ligne(rmr, etat, n, age, inond, sol)170 return float(np.exp(x @ reg["beta"]) * (reg["duan"] if duan else 1.0))171172173def p45(som: dict, et: dict, amortissement_mois: int = 480) -> dict:174 pf = som["portefeuille"]; row = pf.set_index("id_immeuble").loc[ID_36]175 reg = _regression_cas4(pf)176 rne = float(row["rne_2026"]); n = int(row["nb_logements"]); rmr = str(row["rmr"]); etat = int(row["etat_1a5"]); age = float(row["age"])177 g = som["par_rmr"]178 taux = {179 "montreal_cas3_mediane": et["cas3_mediane"], # erreur : taux d'un autre marché180 "gatineau_pondere": float(g.loc[rmr, "taux_pondere"]), # RNE/valeur agrégés, Gatineau (cas 4)181 "gatineau_median": float(g.loc[rmr, "taux_median"]), # médiane des 8 immeubles de Gatineau182 "regression_sujet": taux_predit(reg, rmr, etat, n, age), # régression semi-log, caractéristiques de P45183 "regression_si_montreal": taux_predit(reg, "Montréal", etat, n, age), # même immeuble à Montréal (effet RMR)184 "regression_si_12_logements": taux_predit(reg, rmr, etat, 12, age), # même immeuble à 12 logements (effet taille)185 "regression_si_etat_3": taux_predit(reg, rmr, 3, n, age), # même immeuble en état moyen186 "fichier": float(row["taux_global"]), # taux inscrit au dossier synthétique187 }188 valeurs = {k: fin.capitalisation_directe(rne, R) for k, R in taux.items()}189 par_log = {k: v / n for k, v in valeurs.items()}190 hyp = fin.Hypotheque(float(row["dette"]), float(row["taux_dette"]), amortissement_mois)191 fin_ = {"dette": float(row["dette"]), "taux_dette": float(row["taux_dette"]), "echeance": int(row["echeance_dette"]), "service_dette": hyp.service_dette_annuel,192 "rcd": fin.rcd(rne, hyp.service_dette_annuel), "rpv_fichier": float(row["dette"]) / float(row["valeur_2026"]),193 "rpv_regression": float(row["dette"]) / valeurs["regression_sujet"], "constante": hyp.constante_hypothecaire}194 # décomposition de l'écart Montréal cas 3 → régression sujet (en points de base, cumul multiplicatif)195 r_mtl12 = taux_predit(reg, "Montréal", etat, 12, age)196 decomposition = {"regression_montreal_12_log_etat2_age72": r_mtl12,197 "effet_taille_pb": (taux_predit(reg, "Montréal", etat, n, age) - r_mtl12) * 1e4,198 "effet_rmr_pb": (taux["regression_sujet"] - taux_predit(reg, "Montréal", etat, n, age)) * 1e4}199 return {"id": ID_36, "rmr": rmr, "n": n, "annee": int(row["annee_construction"]), "age": age, "etat": etat, "loyer_moyen": float(row["loyer_mensuel_moyen"]),200 "rbp": float(row["revenu_brut_potentiel"]), "ratio_depenses": float(row["ratio_depenses"]), "rne": rne, "rne_par_logement": rne / n,201 "inocc_marche": float(row["taux_inoccupation_marche"]), "valeur_fichier": float(row["valeur_2026"]), "valeur_fichier_par_logement": float(row["valeur_2026"]) / n,202 "mrb_fichier": float(row["valeur_2026"]) / float(row["revenu_brut_potentiel"]), "taux": taux, "valeurs": valeurs, "par_logement": par_log,203 "regression": reg, "financement": fin_, "decomposition": decomposition,204 "ecart_montreal_vs_sujet": valeurs["montreal_cas3_mediane"] / valeurs["regression_sujet"] - 1,205 "gatineau_valeur_par_logement": float(g.loc[rmr, "valeur_par_logement"]), "montreal_valeur_par_logement": float(g.loc["Montréal", "valeur_par_logement"])}206207208# ----------------------------------------------------------------------------- sensibilité209210def sensibilite_p45(p: dict) -> dict:211 reg, n, age, rne = p["regression"], p["n"], p["age"], p["rne"]212 grille = pd.DataFrame({r: [taux_predit(reg, r, e, n, age) for e in range(1, 6)] for r in RMR_LISTE}, index=[f"état {e}" for e in range(1, 6)])213 grille_valeur_log = grille.map(lambda R: rne / R / n)214 tailles = [6, 12, 24, 36, 48, 72]215 courbe = pd.DataFrame({"n": tailles, "taux_gatineau": [taux_predit(reg, "Gatineau", p["etat"], k, age) for k in tailles],216 "taux_montreal": [taux_predit(reg, "Montréal", p["etat"], k, age) for k in tailles]})217 courbe["valeur_par_logement_gatineau"] = rne / n / courbe["taux_gatineau"] # RNE par logement constant, taille variable218 R0 = p["taux"]["regression_sujet"]219 pb = pd.DataFrame({"delta_pb": [-50, -25, 0, 25, 50]})220 pb["taux"] = R0 + pb["delta_pb"] / 1e4; pb["valeur"] = rne / pb["taux"]; pb["variation_pct"] = pb["valeur"] / (rne / R0) - 1221 # élasticité exacte : dV/V = -dR/R222 return {"grille_taux": grille, "grille_valeur_par_logement": grille_valeur_log, "courbe_taille": courbe, "taux_pb": pb,223 "elasticite": -1.0, "variation_25pb_pct": float(R0 / (R0 + 0.0025) - 1)}224225226# ----------------------------------------------------------------------------- exercices227228def exercices(som: dict, et: dict, u: dict, p: dict) -> dict:229 ex = {}230 # ex1 — unités de comparaison : un 16 logements fictif (données de l'énoncé) évalué par les trois unités du cas 3231 n16, m2_16, ch16 = 16, 1040.0, 26232 ex["ex1"] = {"par_logement": u["par_logement"] * n16, "par_m2": u["par_m2"] * m2_16, "par_chambre": u["par_chambre"] * ch16,233 "m2_par_logement_sujet": u["m2_par_logement"], "m2_par_logement_16": m2_16 / n16, "chambres_par_logement_16": ch16 / n16}234 ex["ex1"]["ecart_max_pct"] = max(ex["ex1"]["par_logement"], ex["ex1"]["par_m2"], ex["ex1"]["par_chambre"]) / min(ex["ex1"]["par_logement"], ex["ex1"]["par_m2"], ex["ex1"]["par_chambre"]) - 1235 # ex2 — taxes de la Ville de Québec : 5 logements (1-5) vs 6 logements (6+) pour une même valeur au rôle236 role = 1_800_000237 taxe5 = role * TAXE_QUEBEC_1A5 + 5 * (TARIF_QUEBEC_EAU + TARIF_QUEBEC_MATIERES)238 taxe6 = role * TAXE_QUEBEC_6PLUS + 6 * (TARIF_QUEBEC_EAU + TARIF_QUEBEC_MATIERES)239 ex["ex2"] = {"role": role, "taxe_5": taxe5, "taxe_6": taxe6, "ecart_taux_seul": role * (TAXE_QUEBEC_6PLUS - TAXE_QUEBEC_1A5),240 "ecart_total": taxe6 - taxe5, "effet_valeur_a_5pct": role * (TAXE_QUEBEC_6PLUS - TAXE_QUEBEC_1A5) / 0.055}241 # ex3 — effet de taille (cas 3) : 36 logements au lieu de 12242 R12 = et["cas3_constante"]; R36 = et["cas3_constante"] + (et["cas3_pente_pb_par_logement"] / 1e4) * (36 - 12)243 rne_log = et["cas3_rne"] / 12244 ex["ex3"] = {"R12": R12, "R36": R36, "ecart_pb": (R36 - R12) * 1e4, "valeur_par_logement_12": rne_log / R12, "valeur_par_logement_36": rne_log / R36,245 "prime_pct": rne_log / R36 / (rne_log / R12) - 1, "cas4_doublement_pct": et["cas4_effet_doublement_pct"]}246 # ex4 — erreur de marché : P45 au taux montréalais247 ex["ex4"] = {"valeur_montreal": p["valeurs"]["montreal_cas3_mediane"], "valeur_regression": p["valeurs"]["regression_sujet"],248 "ecart": p["valeurs"]["montreal_cas3_mediane"] - p["valeurs"]["regression_sujet"], "ecart_pct": p["ecart_montreal_vs_sujet"],249 "valeur_gatineau_pondere": p["valeurs"]["gatineau_pondere"], "effet_rmr_pb": p["decomposition"]["effet_rmr_pb"], "effet_taille_pb": p["decomposition"]["effet_taille_pb"]}250 # ex5 — diligence : 4 baux surévalués de 120 $/mois dans l'état locatif de P45 ; vacance 3,8 %, gestion 4 % du RBE, R = régression251 delta_loyer = 4 * 120 * 12252 R = p["taux"]["regression_sujet"]; v = p["inocc_marche"]253 delta_rne = delta_loyer * (1 - v) * (1 - 0.04)254 ex["ex5"] = {"delta_rbp": delta_loyer, "delta_rne": delta_rne, "delta_valeur": delta_rne / R, "delta_valeur_pct": delta_rne / R / p["valeurs"]["regression_sujet"],255 "R": R, "v": v}256 # ex6 — régression : 24 logements, état 3, 45 ans, dans chaque RMR ; IC à 95 % approximatif sur l'effet RMR (HC1)257 reg = p["regression"]; cols = reg["colonnes"]258 ex6 = {}259 for r in RMR_LISTE:260 R = taux_predit(reg, r, 3, 24, 45)261 if r == "Montréal":262 lo = hi = None263 else:264 j = cols.index(f"rmr_{r}"); b, s = reg["beta"][j], reg["se_hc1"][j]265 lo, hi = np.exp(b - 1.96 * s) - 1, np.exp(b + 1.96 * s) - 1266 ex6[r] = {"taux": R, "effet_pct": (np.exp(reg["beta"][cols.index(f"rmr_{r}")]) - 1) if r != "Montréal" else 0.0, "ic_lo": lo, "ic_hi": hi}267 ex["ex6"] = {"par_rmr": ex6, "r2": reg["r2"], "n": reg["n"], "duan": reg["duan"]}268 # ex7 — immeuble neuf : coût indexé vs revenu (données de l'énoncé : coût 2025 hors terrain 6 400 000 $, terrain 1 100 000 $, 24 logements,269 # loyer 1 975 $/mois, inoccupation 5,6 % (SCHL, île, immeubles 2015+), dépenses 32 % du RBE, taux neuf CBRE Montréal 4,50-4,75 %)270 cout_2025, terrain, n_neuf, loyer_neuf, v_neuf, ratio_neuf = 6_400_000, 1_100_000, 24, 1975, 0.056, 0.32271 cout_2026 = cout_2025 * (1 + IPCB_MTL_BASSE_HAUTEUR_T3_2026)272 rbp = n_neuf * loyer_neuf * 12; rbe = fin.revenu_brut_effectif(rbp, v_neuf); rne_neuf = fin.rne(rbe, ratio_neuf * rbe)273 ex["ex7"] = {"cout_2026": cout_2026, "cout_total": cout_2026 + terrain, "cout_par_logement": (cout_2026 + terrain) / n_neuf, "rbp": rbp, "rbe": rbe, "rne": rne_neuf,274 "V_450": fin.capitalisation_directe(rne_neuf, 0.045), "V_475": fin.capitalisation_directe(rne_neuf, 0.0475),275 "taux_implicite_cout": rne_neuf / (cout_2026 + terrain), "valeur_assurable_hors_terrain": cout_2026}276 return ex277278279# ----------------------------------------------------------------------------- assemblage et impression280281def calculer() -> dict:282 som = sommaire_cas4(); et = effet_taille(som["portefeuille"]); u = unites_cas3(et); p = p45(som, et); s = sensibilite_p45(p)283 schl = schl_ile_montreal()284 return {"sommaire": som, "effet_taille": et, "unites": u, "p45": p, "sensibilite": s, "schl": schl, "exercices": exercices(som, et, u, p)}285286287def _d(x): return f"{x:,.0f}".replace(",", " ") + " $"288def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %"289290291def imprimer(R: dict) -> None:292 pd.set_option("display.width", 200); pd.set_option("display.max_columns", 20)293 print("=== SCHL, île de Montréal, octobre 2025 (données réelles) ===")294 print(R["schl"]["taille"].to_string(index=False)); print(R["schl"]["annee"].to_string(index=False))295 g = R["sommaire"]["par_rmr"]; t = R["sommaire"]["total"]296 print("\n=== Cas 4 : sommaire par RMR ===")297 print(g[["n", "logements", "valeur", "rne", "taux_pondere", "taux_median", "taux_min", "taux_max", "rpv", "valeur_par_logement", "rne_par_logement", "logements_moyen", "loyer_moyen"]].to_string())298 print(f"Total : {t['n']} immeubles, {t['logements']} logements, valeur {_d(t['valeur'])}, RNE {_d(t['rne'])}, taux pondéré {_p(t['taux_pondere'])}, RPV {_p(t['rpv'], 1)}, valeur/logement {_d(t['valeur']/t['logements'])}")299 et = R["effet_taille"]300 print("\n=== Effet de taille ===")301 print(f"Cas 3 : pente {et['cas3_pente_pb_par_logement']:.2f} pb/logement ; constante {_p(et['cas3_constante'], 3)} ; médiane {_p(et['cas3_mediane'], 3)} ; 12→36 : {et['d_pb_12_36_cas3']:.1f} pb")302 print(f"Cas 4 : coef ln(n/12) = {et['cas4_coef_ln']:.4f} (ET HC1 {et['cas4_se_ln']:.4f}) ; doublement → {_p(et['cas4_effet_doublement_pct'])} du taux ; 12→36 à R réf. {_p(et['cas4_r_ref'], 2)} : {et['d_pb_12_36_cas4']:.1f} pb")303 print(et["par_classe"].to_string()); print(et["cas4_effets"].to_string())304 u = R["unites"]305 print("\n=== Unités (cas 3, valeur finale ch. 47) ===")306 print(f"V = {_d(u['valeur'])} ; {u['n']} log., {u['m2']:.0f} m², {u['chambres']} chambres → {_d(u['par_logement'])}/log., {_d(u['par_m2'])}/m², {_d(u['par_chambre'])}/chambre ; "307 f"{u['m2_par_logement']:.1f} m²/log. ; {u['chambres_par_logement']:.2f} ch./log. ; MRB contrat {u['mrb']:.2f}, marché {u['mrb_marche']:.2f} ; taux implicite {_p(u['taux_implicite'])} ; "308 f"RNE/log. {_d(u['rne_par_logement'])} ; loyer moyen {_d(u['loyer_moyen'])}/mois ; loyer {u['loyer_moyen_m2']:.2f} $/m²/mois ; V_cap {_d(u['valeur_cap'])} ({_d(u['valeur_cap_par_logement'])}/log.)")309 p = R["p45"]310 print(f"\n=== {p['id']} : {p['n']} logements, {p['rmr']}, {p['annee']} ({p['age']:.0f} ans), état {p['etat']} ===")311 print(f"Loyer moyen {_d(p['loyer_moyen'])}/mois ; RBP {_d(p['rbp'])} ; ratio dépenses {_p(p['ratio_depenses'], 1)} ; inoccupation de marché {_p(p['inocc_marche'], 1)} ; RNE {_d(p['rne'])} ({_d(p['rne_par_logement'])}/log.)")312 print(f"Valeur au dossier {_d(p['valeur_fichier'])} ({_d(p['valeur_fichier_par_logement'])}/log., MRB {p['mrb_fichier']:.2f}, taux {_p(p['taux']['fichier'])})")313 for k in p["taux"]:314 print(f" {k:30s} R = {_p(p['taux'][k], 3)} V = {_d(p['valeurs'][k])} ({_d(p['par_logement'][k])}/log.)")315 d = p["decomposition"]316 print(f"Décomposition : Montréal 12 log. état 2 âge 72 → {_p(d['regression_montreal_12_log_etat2_age72'], 3)} ; effet taille {d['effet_taille_pb']:+.1f} pb ; effet RMR {d['effet_rmr_pb']:+.1f} pb")317 print(f"Écart Montréal cas 3 vs régression : {_p(p['ecart_montreal_vs_sujet'], 1)} ; valeur/log. Gatineau cas 4 {_d(p['gatineau_valeur_par_logement'])}, Montréal {_d(p['montreal_valeur_par_logement'])}")318 f = p["financement"]319 print(f"Dette {_d(f['dette'])} à {_p(f['taux_dette'])} (échéance {f['echeance']}) ; service (480 mois, hyp. prêt assuré) {_d(f['service_dette'])} ; RCD {f['rcd']:.2f} ; RPV dossier {_p(f['rpv_fichier'], 1)} ; RPV sur V régression {_p(f['rpv_regression'], 1)} ; constante {_p(f['constante'])}")320 reg = p["regression"]; print(f"Régression cas 4 : n = {reg['n']}, R² = {reg['r2']:.3f}, facteur de Duan {reg['duan']:.4f}")321 s = R["sensibilite"]322 print("\n=== Sensibilité P45 ===")323 print((s["grille_taux"] * 100).round(2).to_string()); print(s["grille_valeur_par_logement"].round(0).to_string()); print(s["courbe_taille"].to_string(index=False)); print(s["taux_pb"].to_string(index=False))324 print(f"Variation de la valeur pour +25 pb : {_p(s['variation_25pb_pct'])}")325 print("\n=== Exercices ===")326 for k, v in R["exercices"].items():327 print(k, {kk: (round(vv, 4) if isinstance(vv, float) else vv) for kk, vv in v.items()} if k != "ex6" else {r: {kk: (round(vv, 4) if isinstance(vv, float) else vv) for kk, vv in d.items()} for r, d in v["par_rmr"].items()} | {"r2": round(v["r2"], 3), "duan": round(v["duan"], 4)})328329330if __name__ == "__main__":331 imprimer(calculer())332