#!/usr/bin/env python3 """Chapitre 33 — Immeubles de six logements et plus : tous les chiffres cités dans manuscript/chapters/ch33.tex et 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). Contenu : - 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 de construction) du classeur SCHL « rmr-montreal-2025-fr.xlsx » (Enquête sur les logements locatifs, octobre 2025), bloc « Île de Montréal (zones 1-18) » — données réelles, citées avec attribution (conditions d'utilisation SCHL). - sommaire_cas4() : portefeuille du cas 4 par RMR (traite.cas4.sommaire) + statistiques de taux et de taille. - effet_taille() : effet de la taille sur le taux global dans le cas 3 (régression linéaire de traite.cas3) et dans le cas 4 (régression semi-log de traite.cas4.effets_regionaux), et taux médians par classe de taille. - unites_cas3() : analyse par logement, par m², par chambre et multiplicateurs pour le sujet du cas 3. - p45() : évaluation sommaire par capitalisation d'un immeuble de 36 logements du cas 4 (P45, Gatineau) avec plusieurs taux candidats (Montréal cas 3, Gatineau cas 4, régression cas 4) pour isoler l'effet de RMR et de taille. - sensibilite_p45() : grilles RMR × état et courbe taille → taux prédit et valeur. - exercices() : chiffres des solutions. Aucune formule financière n'est réécrite : traite.finance, traite.cas3, traite.cas4, traite.hedonique. Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch33_six_logements.py """ from __future__ import annotations import sys, pathlib import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import cas3, cas4, finance as fin, hedonique as h ROOT = pathlib.Path(__file__).resolve().parents[3] SCHL_XLSX = ROOT / "data/raw/schl_rml_2025/rmr-montreal-2025-fr.xlsx" # ----------------------------------------------------------------------------- constantes citées (sources : notes-B, notes-E, ch. 47) V_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_dossier TAXE_QUEBEC_1A5 = 0.7464 / 100 # Ville de Québec 2026, résidentiel 1 à 5 logements, $ par 100 $ (R.V.Q. 3492) TAXE_QUEBEC_6PLUS = 0.7697 / 100 # Ville de Québec 2026, résidentiel 6 logements et plus TARIF_QUEBEC_EAU = 386.0 # $ par logement (aqueduc-égout) ; + 129 $ par chambre (non utilisé ici) TARIF_QUEBEC_MATIERES = 195.0 # $ par logement (matières résiduelles) IPCB_MTL_BASSE_HAUTEUR_T3_2026 = 0.092 # IPCB Montréal, immeuble < 5 étages, variation annuelle T3 2026 (StatCan 18-10-0289-01) IPCB_MTL_TOUR_T3_2026 = 0.079 # idem, tour ≥ 5 étages ID_36 = "P45" # immeuble de 36 logements retenu dans le cas 4 (Gatineau, 1954, état 2) RMR_LISTE = ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"] # ----------------------------------------------------------------------------- SCHL (données réelles) def _lire_bloc(ws, titre_bloc: str, n_groupes: int, largeur_groupe: int, labels_arret=("Tous les immeubles",)) -> pd.DataFrame: """Lit les lignes du bloc `titre_bloc` d'une feuille SCHL. Chaque ligne = [libellé, g1..gk] ; chaque groupe de `largeur_groupe` cellules = (oct. 2024, qualité, oct. 2025, qualité[, flèche]). Retourne, par ligne, les valeurs d'octobre 2025 des groupes 2 chambres (3e) et tous (5e), avec leur lettre de qualité.""" rows = list(ws.iter_rows(values_only=True)) out, dans_bloc = [], False for r in rows: lab = r[0] if isinstance(lab, str) and lab.startswith(titre_bloc): dans_bloc = True; continue if not dans_bloc or lab is None: continue def val(g, off): v = r[1 + g * largeur_groupe + off] if v is None or str(v).strip() in ("**", "-", ""): return np.nan return float(str(v).replace(" ", "").replace(" ", "").replace(",", ".")) def qual(g, off): q = r[1 + g * largeur_groupe + off] return str(q).strip() if isinstance(q, str) else "" out.append({"categorie": str(lab).strip(), "deux_ch_2025": val(2, 2), "deux_ch_qual": qual(2, 3), "tous_2025": val(4, 2), "tous_qual": qual(4, 3), "tous_2024": val(4, 0)}) if str(lab).strip() in labels_arret: break return pd.DataFrame(out) def schl_ile_montreal() -> dict: """Tableaux SCHL (octobre 2025), île de Montréal : inoccupation, loyer moyen et rotation par taille d'immeuble ; inoccupation et loyer moyen par année de construction. Valeurs « tous les appartements » et « 2 chambres ».""" import openpyxl wb = openpyxl.load_workbook(SCHL_XLSX, read_only=True, data_only=True) bloc = "Île de Montréal" taille_inocc = _lire_bloc(wb["Tableau 1.3.1"], bloc, 5, 5) taille_loyer = _lire_bloc(wb["Tableau 1.3.2"], bloc, 5, 4) taille_rot = _lire_bloc(wb["Tableau 1.3.4"], bloc, 5, 5) annee_inocc = _lire_bloc(wb["Tableau 1.2.1"], bloc, 5, 5) annee_loyer = _lire_bloc(wb["Tableau 1.2.2"], bloc, 5, 4) annee_rot = _lire_bloc(wb["Tableau 1.2.3"], bloc, 5, 5) taille = taille_inocc[["categorie"]].copy() taille["inoccupation_tous"] = taille_inocc["tous_2025"].values taille["inoccupation_2024"] = taille_inocc["tous_2024"].values taille["loyer_tous"] = taille_loyer.set_index("categorie").loc[taille["categorie"], "tous_2025"].values taille["loyer_2ch"] = taille_loyer.set_index("categorie").loc[taille["categorie"], "deux_ch_2025"].values taille["rotation_tous"] = taille_rot.set_index("categorie").loc[taille["categorie"], "tous_2025"].values annee = annee_inocc[["categorie"]].copy() annee["inoccupation_tous"] = annee_inocc["tous_2025"].values annee["loyer_tous"] = annee_loyer.set_index("categorie").loc[annee["categorie"], "tous_2025"].values annee["loyer_2ch"] = annee_loyer.set_index("categorie").loc[annee["categorie"], "deux_ch_2025"].values annee["rotation_tous"] = annee_rot.set_index("categorie").loc[annee["categorie"], "tous_2025"].values annee = annee[annee["categorie"] != "Inconnue"].reset_index(drop=True) return {"taille": taille, "annee": annee} # ----------------------------------------------------------------------------- cas 4 : sommaire et effets def sommaire_cas4() -> dict: d = cas4.charger(); pf = d["portefeuille"] s = cas4.sommaire(pf) g = s["par_rmr"].copy() stats = pf.groupby("rmr").agg(taux_median=("taux_global", "median"), taux_min=("taux_global", "min"), taux_max=("taux_global", "max"), logements_moyen=("nb_logements", "mean"), loyer_moyen=("loyer_mensuel_moyen", "mean"), inocc_marche=("taux_inoccupation_marche", "first"), age_moyen=("age", "mean")) g = g.join(stats) g["rne_par_logement"] = g["rne"] / g["logements"] g = g.loc[RMR_LISTE] pf = pf.assign(valeur_par_logement=pf["valeur_2026"] / pf["nb_logements"], rne_par_logement=pf["rne_2026"] / pf["nb_logements"], mrb=pf["valeur_2026"] / pf["revenu_brut_potentiel"], rpv=pf["dette"] / pf["valeur_2026"]) return {"par_rmr": g, "total": s["total"], "portefeuille": pf, "series": d["series"]} def effet_taille(pf: pd.DataFrame) -> dict: """Effet de la taille sur le taux global : (i) cas 3, pente linéaire par logement (traite.cas3.taux_extraits) ; (ii) cas 4, élasticité semi-log (traite.cas4.effets_regionaux) ; (iii) taux médians par classe de taille (cas 4).""" t3 = cas3.tout() reg3 = t3["taux"]["regression"] eff4 = cas4.effets_regionaux(pf) b_ln = float(eff4.loc["ln_logements", "coef"]); se_ln = float(eff4.loc["ln_logements", "se_hc1"]) classes = pd.cut(pf["nb_logements"], bins=[5, 8, 16, 24, 48, 72], labels=["6–8", "12–16", "24", "36–48", "72"]) par_classe = pf.groupby(classes, observed=True).agg(n=("id_immeuble", "count"), taux_median=("taux_global", "median"), valeur_par_logement=("valeur_2026", lambda v: float(v.sum()) / float(pf.loc[v.index, "nb_logements"].sum()))) # 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 3 r_ref4 = float(np.exp(eff4.loc["const", "coef"])) # Montréal, état 3, 12 logements, 50 ans, sans risque d_pb_cas4 = (np.exp(b_ln * np.log(36 / 12)) - 1) * r_ref4 * 1e4 d_pb_cas3 = reg3["taille"] * (36 - 12) * 1e4 return {"cas3_pente_pb_par_logement": reg3["taille"] * 1e4, "cas3_constante": reg3["constante"], "cas3_mediane": t3["taux"]["mediane"], "cas3_n": t3["taux"]["n"], "cas3_comparables": t3["taux"]["comparables"], "cas3_valeur_cap": t3["valeur_capitalisation"], "cas3_rne": t3["base"]["rne"], "cas3_base": t3["base"], "cas3_etat": t3["etat"], "cas3_sujet": t3["sujet"], "cas3_dcf": t3["dcf"], "cas4_coef_ln": b_ln, "cas4_se_ln": se_ln, "cas4_effet_doublement_pct": float(np.exp(b_ln * np.log(2)) - 1), "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} # ----------------------------------------------------------------------------- unités d'analyse (cas 3) def unites_cas3(et: dict, valeur: float = V_FINALE_CH47) -> dict: etat, sujet, base = et["cas3_etat"], et["cas3_sujet"], et["cas3_base"] n = int(sujet["nb_logements"]); m2 = float(sujet["superficie_locative_m2"]) chambres = int(etat["chambres"].sum()) rbp = base["rbp_contrat"]; rne = base["rne"] return {"valeur": valeur, "n": n, "m2": m2, "chambres": chambres, "par_logement": valeur / n, "par_m2": valeur / m2, "par_chambre": valeur / chambres, "m2_par_logement": m2 / n, "chambres_par_logement": chambres / n, "mrb": fin.multiplicateur_revenu_brut(valeur, rbp), "mrb_marche": fin.multiplicateur_revenu_brut(valeur, base["rbp_marche"]), "taux_implicite": fin.taux_global_extrait(rne, valeur), "rne_par_logement": rne / n, "loyer_moyen": float(etat["loyer_mensuel_contrat"].mean()), "loyer_moyen_m2": float((etat["loyer_mensuel_contrat"].sum()) / m2), "valeur_cap": et["cas3_valeur_cap"], "valeur_cap_par_logement": et["cas3_valeur_cap"] / n, "mrb_cap": et["cas3_valeur_cap"] / rbp, # proposition 33.1 : MRB = (1 - v)(1 - rho) / R, autres revenus négligés "mrb_theorique": (1 - base["taux_vacance_pertes"]) * (1 - base["ratio_depenses"]) / et["cas3_mediane"], "mrb_theorique_rho55": (1 - base["taux_vacance_pertes"]) * (1 - 0.55) / et["cas3_mediane"]} # ----------------------------------------------------------------------------- P45 : 36 logements à Gatineau def _X_ligne(rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0) -> np.ndarray: x = [1.0, etat - 3, np.log(n / 12), inond, sol, (age - 50) / 10] x += [1.0 if rmr == r else 0.0 for r in ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]] return np.array(x) def _regression_cas4(pf: pd.DataFrame) -> dict: """Même spécification que traite.cas4.effets_regionaux, mais on conserve les résidus pour le facteur de Duan.""" X = pd.DataFrame({"const": 1.0, "etat": pf["etat_1a5"] - 3, "ln_logements": np.log(pf["nb_logements"] / 12), "inondable": pf["zone_inondable"], "sol": pf["contamination_sol"], "age": (pf["age"] - 50) / 10}) for r in ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]: X[f"rmr_{r}"] = (pf["rmr"] == r).astype(float) res = h.mco(X.values, np.log(pf["taux_global"].values)) res["duan"] = float(np.mean(np.exp(res["residus"]))) res["colonnes"] = list(X.columns) return res def taux_predit(reg: dict, rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0, duan: bool = True) -> float: x = _X_ligne(rmr, etat, n, age, inond, sol) return float(np.exp(x @ reg["beta"]) * (reg["duan"] if duan else 1.0)) def p45(som: dict, et: dict, amortissement_mois: int = 480) -> dict: pf = som["portefeuille"]; row = pf.set_index("id_immeuble").loc[ID_36] reg = _regression_cas4(pf) rne = float(row["rne_2026"]); n = int(row["nb_logements"]); rmr = str(row["rmr"]); etat = int(row["etat_1a5"]); age = float(row["age"]) g = som["par_rmr"] taux = { "montreal_cas3_mediane": et["cas3_mediane"], # erreur : taux d'un autre marché "gatineau_pondere": float(g.loc[rmr, "taux_pondere"]), # RNE/valeur agrégés, Gatineau (cas 4) "gatineau_median": float(g.loc[rmr, "taux_median"]), # médiane des 8 immeubles de Gatineau "regression_sujet": taux_predit(reg, rmr, etat, n, age), # régression semi-log, caractéristiques de P45 "regression_si_montreal": taux_predit(reg, "Montréal", etat, n, age), # même immeuble à Montréal (effet RMR) "regression_si_12_logements": taux_predit(reg, rmr, etat, 12, age), # même immeuble à 12 logements (effet taille) "regression_si_etat_3": taux_predit(reg, rmr, 3, n, age), # même immeuble en état moyen "fichier": float(row["taux_global"]), # taux inscrit au dossier synthétique } valeurs = {k: fin.capitalisation_directe(rne, R) for k, R in taux.items()} par_log = {k: v / n for k, v in valeurs.items()} hyp = fin.Hypotheque(float(row["dette"]), float(row["taux_dette"]), amortissement_mois) fin_ = {"dette": float(row["dette"]), "taux_dette": float(row["taux_dette"]), "echeance": int(row["echeance_dette"]), "service_dette": hyp.service_dette_annuel, "rcd": fin.rcd(rne, hyp.service_dette_annuel), "rpv_fichier": float(row["dette"]) / float(row["valeur_2026"]), "rpv_regression": float(row["dette"]) / valeurs["regression_sujet"], "constante": hyp.constante_hypothecaire} # décomposition de l'écart Montréal cas 3 → régression sujet (en points de base, cumul multiplicatif) r_mtl12 = taux_predit(reg, "Montréal", etat, 12, age) decomposition = {"regression_montreal_12_log_etat2_age72": r_mtl12, "effet_taille_pb": (taux_predit(reg, "Montréal", etat, n, age) - r_mtl12) * 1e4, "effet_rmr_pb": (taux["regression_sujet"] - taux_predit(reg, "Montréal", etat, n, age)) * 1e4} return {"id": ID_36, "rmr": rmr, "n": n, "annee": int(row["annee_construction"]), "age": age, "etat": etat, "loyer_moyen": float(row["loyer_mensuel_moyen"]), "rbp": float(row["revenu_brut_potentiel"]), "ratio_depenses": float(row["ratio_depenses"]), "rne": rne, "rne_par_logement": rne / n, "inocc_marche": float(row["taux_inoccupation_marche"]), "valeur_fichier": float(row["valeur_2026"]), "valeur_fichier_par_logement": float(row["valeur_2026"]) / n, "mrb_fichier": float(row["valeur_2026"]) / float(row["revenu_brut_potentiel"]), "taux": taux, "valeurs": valeurs, "par_logement": par_log, "regression": reg, "financement": fin_, "decomposition": decomposition, "ecart_montreal_vs_sujet": valeurs["montreal_cas3_mediane"] / valeurs["regression_sujet"] - 1, "gatineau_valeur_par_logement": float(g.loc[rmr, "valeur_par_logement"]), "montreal_valeur_par_logement": float(g.loc["Montréal", "valeur_par_logement"])} # ----------------------------------------------------------------------------- sensibilité def sensibilite_p45(p: dict) -> dict: reg, n, age, rne = p["regression"], p["n"], p["age"], p["rne"] 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)]) grille_valeur_log = grille.map(lambda R: rne / R / n) tailles = [6, 12, 24, 36, 48, 72] courbe = pd.DataFrame({"n": tailles, "taux_gatineau": [taux_predit(reg, "Gatineau", p["etat"], k, age) for k in tailles], "taux_montreal": [taux_predit(reg, "Montréal", p["etat"], k, age) for k in tailles]}) courbe["valeur_par_logement_gatineau"] = rne / n / courbe["taux_gatineau"] # RNE par logement constant, taille variable R0 = p["taux"]["regression_sujet"] pb = pd.DataFrame({"delta_pb": [-50, -25, 0, 25, 50]}) pb["taux"] = R0 + pb["delta_pb"] / 1e4; pb["valeur"] = rne / pb["taux"]; pb["variation_pct"] = pb["valeur"] / (rne / R0) - 1 # élasticité exacte : dV/V = -dR/R return {"grille_taux": grille, "grille_valeur_par_logement": grille_valeur_log, "courbe_taille": courbe, "taux_pb": pb, "elasticite": -1.0, "variation_25pb_pct": float(R0 / (R0 + 0.0025) - 1), "valeur_un_pb": rne / R0 * 1e-4 / R0, "pb_par_million_p45": 1e6 * 1e-4 / R0, "pb_par_million_cas3": 1e6 * 1e-4 / p["taux"]["montreal_cas3_mediane"]} # ----------------------------------------------------------------------------- exercices def exercices(som: dict, et: dict, u: dict, p: dict) -> dict: ex = {} # ex1 — unités de comparaison : un 16 logements fictif (données de l'énoncé) évalué par les trois unités du cas 3 n16, m2_16, ch16 = 16, 1040.0, 26 ex["ex1"] = {"par_logement": u["par_logement"] * n16, "par_m2": u["par_m2"] * m2_16, "par_chambre": u["par_chambre"] * ch16, "m2_par_logement_sujet": u["m2_par_logement"], "m2_par_logement_16": m2_16 / n16, "chambres_par_logement_16": ch16 / n16} 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"]) - 1 # ex2 — taxes de la Ville de Québec : 5 logements (1-5) vs 6 logements (6+) pour une même valeur au rôle role = 1_800_000 taxe5 = role * TAXE_QUEBEC_1A5 + 5 * (TARIF_QUEBEC_EAU + TARIF_QUEBEC_MATIERES) taxe6 = role * TAXE_QUEBEC_6PLUS + 6 * (TARIF_QUEBEC_EAU + TARIF_QUEBEC_MATIERES) ex["ex2"] = {"role": role, "taxe_5": taxe5, "taxe_6": taxe6, "ecart_taux_seul": role * (TAXE_QUEBEC_6PLUS - TAXE_QUEBEC_1A5), "ecart_total": taxe6 - taxe5, "effet_valeur_a_5pct": role * (TAXE_QUEBEC_6PLUS - TAXE_QUEBEC_1A5) / 0.055} # ex3 — effet de taille (cas 3) : 36 logements au lieu de 12 R12 = et["cas3_constante"]; R36 = et["cas3_constante"] + (et["cas3_pente_pb_par_logement"] / 1e4) * (36 - 12) rne_log = et["cas3_rne"] / 12 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, "prime_pct": rne_log / R36 / (rne_log / R12) - 1, "cas4_doublement_pct": et["cas4_effet_doublement_pct"]} # ex4 — erreur de marché : P45 au taux montréalais ex["ex4"] = {"valeur_montreal": p["valeurs"]["montreal_cas3_mediane"], "valeur_regression": p["valeurs"]["regression_sujet"], "ecart": p["valeurs"]["montreal_cas3_mediane"] - p["valeurs"]["regression_sujet"], "ecart_pct": p["ecart_montreal_vs_sujet"], "valeur_gatineau_pondere": p["valeurs"]["gatineau_pondere"], "effet_rmr_pb": p["decomposition"]["effet_rmr_pb"], "effet_taille_pb": p["decomposition"]["effet_taille_pb"]} # 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égression delta_loyer = 4 * 120 * 12 R = p["taux"]["regression_sujet"]; v = p["inocc_marche"] delta_rne = delta_loyer * (1 - v) * (1 - 0.04) 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"], "R": R, "v": v} # ex6 — régression : 24 logements, état 3, 45 ans, dans chaque RMR ; IC à 95 % approximatif sur l'effet RMR (HC1) reg = p["regression"]; cols = reg["colonnes"] ex6 = {} for r in RMR_LISTE: R = taux_predit(reg, r, 3, 24, 45) if r == "Montréal": lo = hi = None else: j = cols.index(f"rmr_{r}"); b, s = reg["beta"][j], reg["se_hc1"][j] lo, hi = np.exp(b - 1.96 * s) - 1, np.exp(b + 1.96 * s) - 1 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} ex["ex6"] = {"par_rmr": ex6, "r2": reg["r2"], "n": reg["n"], "duan": reg["duan"]} # 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, # 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 %) cout_2025, terrain, n_neuf, loyer_neuf, v_neuf, ratio_neuf = 6_400_000, 1_100_000, 24, 1975, 0.056, 0.32 cout_2026 = cout_2025 * (1 + IPCB_MTL_BASSE_HAUTEUR_T3_2026) rbp = n_neuf * loyer_neuf * 12; rbe = fin.revenu_brut_effectif(rbp, v_neuf); rne_neuf = fin.rne(rbe, ratio_neuf * rbe) 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, "V_450": fin.capitalisation_directe(rne_neuf, 0.045), "V_475": fin.capitalisation_directe(rne_neuf, 0.0475), "taux_implicite_cout": rne_neuf / (cout_2026 + terrain), "valeur_assurable_hors_terrain": cout_2026} return ex # ----------------------------------------------------------------------------- assemblage et impression def calculer() -> dict: som = sommaire_cas4(); et = effet_taille(som["portefeuille"]); u = unites_cas3(et); p = p45(som, et); s = sensibilite_p45(p) schl = schl_ile_montreal() return {"sommaire": som, "effet_taille": et, "unites": u, "p45": p, "sensibilite": s, "schl": schl, "exercices": exercices(som, et, u, p)} def _d(x): return f"{x:,.0f}".replace(",", " ") + " $" def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %" def imprimer(R: dict) -> None: pd.set_option("display.width", 200); pd.set_option("display.max_columns", 20) print("=== SCHL, île de Montréal, octobre 2025 (données réelles) ===") print(R["schl"]["taille"].to_string(index=False)); print(R["schl"]["annee"].to_string(index=False)) g = R["sommaire"]["par_rmr"]; t = R["sommaire"]["total"] print("\n=== Cas 4 : sommaire par RMR ===") 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()) 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'])}") et = R["effet_taille"] print("\n=== Effet de taille ===") 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") 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") print(et["par_classe"].to_string()); print(et["cas4_effets"].to_string()) u = R["unites"] print("\n=== Unités (cas 3, valeur finale ch. 47) ===") 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 ; " 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'])} ; " f"MRB théorique (prop. 33.1) {u['mrb_theorique']:.2f} (ρ = 55 % : {u['mrb_theorique_rho55']:.2f}) ; MRB de V_cap {u['mrb_cap']:.2f} ; 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.)") p = R["p45"] print(f"\n=== {p['id']} : {p['n']} logements, {p['rmr']}, {p['annee']} ({p['age']:.0f} ans), état {p['etat']} ===") 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.)") 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'])})") for k in p["taux"]: print(f" {k:30s} R = {_p(p['taux'][k], 3)} V = {_d(p['valeurs'][k])} ({_d(p['par_logement'][k])}/log.)") d = p["decomposition"] 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") 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'])}") f = p["financement"] 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'])}") reg = p["regression"]; print(f"Régression cas 4 : n = {reg['n']}, R² = {reg['r2']:.3f}, facteur de Duan {reg['duan']:.4f}") s = R["sensibilite"] print("\n=== Sensibilité P45 ===") 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)) print(f"Variation de la valeur pour +25 pb : {_p(s['variation_25pb_pct'])} ; 1 pb = {_d(s['valeur_un_pb'])} pour P45 ; par million : {_d(s['pb_par_million_p45'])} à R P45, {_d(s['pb_par_million_cas3'])} à 4,415 %") print("\n=== Exercices ===") for k, v in R["exercices"].items(): 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)}) if __name__ == "__main__": imprimer(calculer())