spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 27 — Revenus potentiels, vacance et pertes : calculs du cas 3 (12 logements, Rosemont) et du cas 2 (triplex,3Limoilou) — DONNÉES SYNTHÉTIQUES — et lecture des tableaux publics de la SCHL (Enquête sur les logements locatifs,4octobre 2025, data/raw/schl_rml_2025/) — DONNÉES RÉELLES citées avec attribution.56Tous les chiffres cités dans manuscript/chapters/ch27.tex et solutions/ch27.tex sortent d'ici. Les revenus des cas7viennent de traite.cas3.revenus / traite.cas2.revenus et la projection des loyers de traite.cas3.projection_dcf, pour8coïncider avec les chapitres 29, 30, 32, 46 et 47 ; les formules viennent de traite.finance (aucune n'est réécrite).910Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch27_revenus.py11"""12from __future__ import annotations13import copy, math, pathlib, sys14import numpy as np, pandas as pd15sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))16from traite import cas2, cas3, finance as fin1718ROOT = pathlib.Path(__file__).resolve().parents[3]19RAW = ROOT / "data/raw/schl_rml_2025"20SIZES = ["3 à 5 unités", "6 à 19 unités", "20 à 49 unités", "50 à 99 unités", "100 unités et +", "Tous les immeubles"]21TYPES = ["Studio", "1 chambre", "2 chambres", "3 chambres +", "Tous"]22RMR_FICHIER = {"Montréal": "rmr-montreal-2025-fr.xlsx", "Québec": "rmr-quebec-2025-fr.xlsx", "Gatineau (partie QC)": "rmr-gatineau-2025-fr.xlsx",23 "Sherbrooke": "rmr-sherbrooke-2025-fr.xlsx", "Trois-Rivières": "rmr-trois-rivieres-2025-fr.xlsx", "Saguenay": "rmr-saguenay-2025-fr.xlsx"}24# Libellés de la RMR dans les fichiers (tableaux par centre du fichier Québec ; tableaux par zone des autres fichiers)25RMR_LIBELLE_QC = {"Montréal": "Montréal (RMR)", "Québec": "Québec (RMR)", "Gatineau (partie QC)": "Ottawa-Gatineau (RMR) (partie QC)",26 "Sherbrooke": "Sherbrooke (RMR)", "Trois-Rivières": "Trois-Rivières (RMR)", "Saguenay": "Saguenay (RMR)"}272829# ----------------------------------------------------------------------------- lecture des tableaux SCHL (réels)30def _valeurs(tokens) -> list[float]:31 """Convertit une ligne SCHL en nombres : « 2,9 » → 2.9 ; « ** » (supprimé) → NaN ; lettres de qualité a-d et flèches ignorées."""32 out = []33 for t in tokens:34 s = str(t).strip()35 if s == "**":36 out.append(float("nan")); continue37 if s in {"a", "b", "c", "d", "↑", "↓", "-", "–", "O", "N"}:38 continue39 try:40 out.append(float(s.replace(",", ".").replace(" ", "").replace(" ", "")))41 except ValueError:42 continue43 return out444546def _lignes(fichier: str, tableau: str) -> list[tuple[str, list[float]]]:47 import openpyxl48 wb = openpyxl.load_workbook(RAW / fichier, read_only=True, data_only=True)49 ws = wb[tableau]50 rows = []51 for r in ws.iter_rows(values_only=True):52 cells = [c for c in r if c is not None]53 if not cells:54 continue55 rows.append((str(cells[0]).strip(), _valeurs(cells[1:])))56 return rows575859def _bloc(rows, debut: str, libelles: list[str]) -> dict[str, list[float]]:60 """Extrait, dans le bloc commençant par la ligne `debut`, les lignes dont le libellé figure dans `libelles`."""61 out, actif = {}, False62 for lab, vals in rows:63 if lab == debut:64 actif = True; continue65 if actif and lab in libelles and len(vals) >= 2:66 out[lab] = vals67 if actif and lab in libelles and lab == libelles[-1]:68 break69 return out707172def schl_inoccupation_rmr() -> pd.DataFrame:73 """Taux d'inoccupation (%), tous les appartements, oct. 2024 et oct. 2025, par RMR (tableau 1.1.1 du fichier Québec, par centre)."""74 rows = _lignes("rmr-quebec-2025-fr.xlsx", "Tableau 1.1.1")75 rec = []76 for rmr, lab in RMR_LIBELLE_QC.items():77 v = [vals for l, vals in rows if l == lab][0]78 # 5 types × (2024, 2025) = 10 valeurs ; « Tous » = les deux dernières79 rec.append({"rmr": rmr, "inocc_2024": v[-2], "inocc_2025": v[-1], "inocc_2ch_2025": v[5]})80 return pd.DataFrame(rec).set_index("rmr")818283def schl_rotation_rmr() -> pd.DataFrame:84 """Taux de rotation (%), tous les appartements, oct. 2024 et 2025, par RMR (tableau 1.1.6 du fichier Québec)."""85 rows = _lignes("rmr-quebec-2025-fr.xlsx", "Tableau 1.1.6")86 rec = []87 for rmr, lab in RMR_LIBELLE_QC.items():88 v = [vals for l, vals in rows if l == lab][0]89 rec.append({"rmr": rmr, "rotation_2024": v[-2], "rotation_2025": v[-1]})90 return pd.DataFrame(rec).set_index("rmr")919293def schl_taille_montreal() -> pd.DataFrame:94 """Inoccupation (tous les appartements, %) et rotation (%) selon la taille de l'immeuble, oct. 2025 : RMR de Montréal et île de Montréal95 (tableaux 1.3.1 et 1.3.4 du fichier Montréal)."""96 inoc = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.3.1"); rot = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.3.4")97 rec = []98 for zone, debut in (("Île de Montréal", "Île de Montréal (zones 1-18)"), ("RMR de Montréal", "Montréal (RMR)")):99 bi = _bloc(inoc, debut, SIZES); br = _bloc(rot, debut, SIZES)100 for s in SIZES:101 vi = bi.get(s, []); vr = br.get(s, [])102 rec.append({"zone": zone, "taille": s, "inocc_2024": vi[-2] if len(vi) >= 2 else np.nan, "inocc_2025": vi[-1] if vi else np.nan,103 "rotation_2024": vr[-2] if len(vr) >= 2 else np.nan, "rotation_2025": vr[-1] if vr else np.nan})104 return pd.DataFrame(rec)105106107def schl_quartiles_montreal() -> pd.DataFrame:108 """Inoccupation (%) selon le quartile de loyer, tous les appartements, RMR de Montréal, oct. 2024 et 2025 (tableau 1.4)."""109 rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.4")110 b = _bloc(rows, "Montréal (RMR)", ["Q1", "Q2", "Q3", "Q4"])111 return pd.DataFrame([{"quartile": q, "inocc_2024": b[q][-2], "inocc_2025": b[q][-1]} for q in ["Q1", "Q2", "Q3", "Q4"]]).set_index("quartile")112113114def schl_inoccupes_occupes_ile() -> pd.DataFrame:115 """Loyer moyen ($) des logements inoccupés et occupés, île de Montréal, oct. 2025, par taille de logement (tableau 1.1.9)."""116 rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.1.9")117 v = [vals for l, vals in rows if l.startswith("Île-de-Montréal")][0]118 rec = [{"type": t, "inoccupes": v[2 * i], "occupes": v[2 * i + 1], "ecart_pct": (v[2 * i] / v[2 * i + 1] - 1) * 100} for i, t in enumerate(TYPES)]119 return pd.DataFrame(rec).set_index("type")120121122def schl_annee_construction_ile() -> pd.DataFrame:123 """Inoccupation (%) selon l'année de construction, tous les appartements, île de Montréal, oct. 2025 (tableau 1.2.1)."""124 rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.2.1")125 libs = ["Avant 1940", "1940 à 1959", "1960 à 1974", "1975 à 1989", "1990 à 2004", "2005 à 2014", "2015 ou après", "Tous les immeubles"]126 b = _bloc(rows, "Île de Montréal (zones 1-18)", libs)127 return pd.DataFrame([{"periode": k, "inocc_2025": b[k][-1]} for k in libs if k in b]).set_index("periode")128129130def ipc_loyer_quebec() -> dict:131 """IPC « Loyer », Québec, non désaisonnalisé (Statistique Canada 18-10-0004-01, 2002 = 100) : variation sept. 2026 / sept. 2025132 et croissance annuelle moyenne sur 10 ans (sept. 2016 → sept. 2026)."""133 p = ROOT / "data/raw/statcan_18100004/18100004.csv"134 import csv135 vals = {}136 with p.open(encoding="utf-8-sig") as f:137 for row in csv.reader(f, delimiter=";"):138 if len(row) > 10 and row[1] == "Québec" and row[3] == "Loyer" and row[0] >= "2015-01":139 try:140 vals[row[0]] = float(row[10])141 except ValueError:142 pass143 s = pd.Series(vals).sort_index()144 return {"serie": s, "sept2026": s["2026-09"], "sept2025": s["2025-09"], "var_12m": s["2026-09"] / s["2025-09"] - 1,145 "cagr_10ans": (s["2026-09"] / s["2016-09"]) ** (1 / 10) - 1, "cagr_5ans": (s["2026-09"] / s["2021-09"]) ** (1 / 5) - 1}146147148# ----------------------------------------------------------------------------- cas 3 et cas 2 (synthétiques)149def cascade_cas3() -> dict:150 d = cas3.charger(); etat, hyp = d["etat"], d["hyp"]151 r = cas3.revenus(etat, hyp)152 n = len(etat); sup = float(etat["superficie_m2"].sum())153 v = r["taux_vacance_pertes"]154 r.update({155 "n_logements": n, "superficie_m2": sup, "loyer_mensuel_contrat": float(etat["loyer_mensuel_contrat"].sum()),156 "loyer_mensuel_marche": float(etat["loyer_mensuel_marche"].sum()),157 "deficit_loyer": r["ecart_marche"], "deficit_pct": r["ecart_marche"] / r["rbp_marche"],158 "rbp_contrat_sur_marche": r["rbp_contrat"] / r["rbp_marche"],159 "loyers_effectifs": r["rbp_contrat"] * (1 - v),160 "taux_occupation_economique": r["rbp_contrat"] * (1 - v) / r["rbp_marche"], # loyers encaissés / loyers de marché161 "rbe_sur_rbp": r["rbe"] / r["rbp_contrat"], "autres_sur_rbp": r["autres_revenus"] / r["rbp_contrat"],162 "autres_par_logement_mois": r["autres_revenus"] / n / 12,163 "rbe_par_logement": r["rbe"] / n, "rbe_par_m2": r["rbe"] / sup, "rbp_par_m2": r["rbp_contrat"] / sup,164 "rbe_marche": fin.revenu_brut_effectif(r["rbp_marche"], v, r["autres_revenus"]),165 # variantes de convention (même v) : v appliqué au RBP + autres revenus ; autres revenus à 50 % vacants ; v sur RBP de marché166 "rbe_variante_v_sur_tout": (r["rbp_contrat"] + r["autres_revenus"]) * (1 - v),167 "rbe_variante_v_sur_marche": r["rbp_marche"] * (1 - v) + r["autres_revenus"],168 })169 r["ecart_variante_v_sur_tout"] = r["rbe_variante_v_sur_tout"] - r["rbe"]170 # décomposition de v (hypothèse pédagogique déclarée, cohérente avec le ch. 47) : frictionnel = rotation × durée ; reste = créances + structurel171 rot, duree = hyp["rotation_annuelle"], 1.0172 r["v_frictionnel_dossier"] = fin.taux_vacance_frictionnelle(rot, duree)173 r["v_residuel_dossier"] = v - r["v_frictionnel_dossier"]174 r["etat"] = etat; r["hyp"] = hyp; r["sujet"] = d["sujet"]175 return r176177178def par_type_cas3(etat: pd.DataFrame) -> pd.DataFrame:179 g = etat.groupby("pieces").agg(n=("logement", "count"), superficie=("superficie_m2", "sum"), contrat=("loyer_mensuel_contrat", "sum"),180 marche=("loyer_mensuel_marche", "sum"), anciennete=("locataire_depuis", lambda s: float((2026 - s).mean())))181 g["contrat_moyen"] = g["contrat"] / g["n"]; g["marche_moyen"] = g["marche"] / g["n"]182 g["ecart"] = g["marche"] - g["contrat"]; g["ecart_pct"] = g["ecart"] / g["marche"] * 100183 g["rbp_contrat"] = g["contrat"] * 12; g["rbp_marche"] = g["marche"] * 12184 tot = g.sum(numeric_only=True); tot["contrat_moyen"] = tot["contrat"] / tot["n"]; tot["marche_moyen"] = tot["marche"] / tot["n"]185 tot["ecart_pct"] = tot["ecart"] / tot["marche"] * 100; tot["anciennete"] = float((2026 - etat["locataire_depuis"]).mean())186 g.loc["Total"] = tot187 return g188189190def cascade_cas2() -> dict:191 c = cas2.charger(); loyers = c["loyers"]192 r = cas2.revenus(loyers)193 v = r["taux_vacance_pertes"]194 r.update({"n_logements": len(loyers), "superficie_m2": float(loyers["superficie_m2"].sum()), "vacance_pertes": r["rbp_contrat"] * v,195 "deficit_pct": r["ecart_marche"] / r["rbp_marche"], "taux_occupation_economique": r["rbp_contrat"] * (1 - v) / r["rbp_marche"],196 "rbe_par_logement": r["rbe"] / len(loyers), "autres_revenus": 0.0,197 # décomposition déclarée du 3 % du dossier : inoccupation SCHL Québec 2,4 % + créances ≈ 0,6 %198 "v_inoccupation_schl": 0.024, "v_creances": v - 0.024,199 # occupant : le logement du propriétaire (RC, hypothèse du ch. 46) n'a pas de risque d'inoccupation marchand → RBE si l'on200 # n'applique v qu'aux deux logements loués (variante pédagogique)201 "loyers": loyers})202 loue = loyers[loyers["logement"] != "RC"]203 r["rbe_variante_occupant"] = float(loyers.loc[loyers["logement"] == "RC", "loyer_mensuel"].sum() * 12) + fin.revenu_brut_effectif(float(loue["loyer_mensuel"].sum() * 12), v)204 return r205206207# ----------------------------------------------------------------------------- modèle d'inoccupation frictionnelle et grilles208def grille_frictionnelle(rotations=(0.05, 0.094, 0.13, 0.15, 0.20, 0.25), durees=(0.5, 1.0, 1.5, 2.0, 3.0)) -> pd.DataFrame:209 g = pd.DataFrame({d: [fin.taux_vacance_frictionnelle(r, d) * 100 for r in rotations] for d in durees}, index=[r * 100 for r in rotations])210 g.index.name = "rotation_pct"; g.columns.name = "duree_mois"211 return g212213214def sensibilite_v(base: dict, taux=(0.02, 0.029, 0.039, 0.05, 0.06, 0.08)) -> pd.DataFrame:215 """RBE, RNE normalisé et valeur par capitalisation (taux médian du cas 3) pour différents v ; dépenses recalculées par traite.cas3.rne216 (la gestion imputée à 4 % du RBE varie avec v)."""217 d = cas3.charger(); R = cas3.taux_extraits(d["comps"])["mediane"]218 rows = []219 for v in taux:220 h = copy.deepcopy(d["hyp"]); h["taux_inoccupation_pertes"] = v221 b = cas3.rne(d["etat"], d["dep"], h)222 rows.append({"v": v, "vacance_pertes": b["vacance_pertes"], "rbe": b["rbe"], "rne": b["rne"], "valeur": fin.capitalisation_directe(b["rne"], R)})223 s = pd.DataFrame(rows)224 h0 = copy.deepcopy(d["hyp"]); b0 = cas3.rne(d["etat"], d["dep"], h0) # référence = v du dossier, même si absent de la grille225 ref = {"rbe": b0["rbe"], "valeur": fin.capitalisation_directe(b0["rne"], R)}226 s["d_rbe"] = s["rbe"] - ref["rbe"]; s["d_valeur"] = s["valeur"] - ref["valeur"]; s["d_valeur_pct"] = s["valeur"] / ref["valeur"] - 1227 s.attrs["R"] = R228 # dérivées analytiques : ∂RBE/∂v = −RBP ; ∂RNE/∂v = −RBP(1−0,04) ; ∂V/∂v = ∂RNE/∂v / R229 s.attrs["dRBE_dv_pt"] = -base["rbp_contrat"] / 100230 s.attrs["dRNE_dv_pt"] = -base["rbp_contrat"] * (1 - 0.04) / 100231 s.attrs["dV_dv_pt"] = -base["rbp_contrat"] * (1 - 0.04) / 100 / R232 return s233234235# ----------------------------------------------------------------------------- trajectoires des loyers (cas 3)236def trajectoires_cas3() -> dict:237 """Trois trajectoires du RBP sur l'horizon du DCF : (i) indexation TAL seule (rotation 0) ; (ii) base du dossier (TAL 3,1 % + rotation 15 %238 relouée au marché, marché +3,5 %) ; (iii) RBP au marché (indice) ; variante (iv) rotation SCHL 9,4 %."""239 d = cas3.charger(); etat, dep, hyp = d["etat"], d["dep"], d["hyp"]240 def proj(**chg):241 h = copy.deepcopy(hyp); h.update(chg)242 return cas3.projection_dcf(etat, dep, h)243 base = proj(); tal = proj(rotation_annuelle=0.0); rot_schl = proj(rotation_annuelle=0.094)244 rot_schl_marche_schl = proj(rotation_annuelle=0.094, croissance_loyers_marche=0.033) # marché au rythme de l'IPC-loyer récent245 t = pd.DataFrame({"annee": base["annee"], "exercice": base["exercice"], "tal_seul": tal["rbp"], "base_rot15": base["rbp"],246 "rot_schl_094": rot_schl["rbp"], "marche": base["rbp_marche"], "rot094_marche033": rot_schl_marche_schl["rbp"],247 "rbe_tal_seul": tal["rbe"], "rbe_base": base["rbe"], "rbe_rot094": rot_schl["rbe"], "rbe_marche_plein": base["rbp_marche"] * (1 - hyp["taux_inoccupation_pertes"]) + hyp["autres_revenus_annuels"] * (1 + hyp["croissance_depenses"]) ** (base["annee"] - 1)})248 t["ecart_base_pct"] = (1 - t["base_rot15"] / t["marche"]) * 100249 t["ecart_tal_pct"] = (1 - t["tal_seul"] / t["marche"]) * 100250 t["ecart_rot094_pct"] = (1 - t["rot_schl_094"] / t["marche"]) * 100251 T = hyp["horizon_ans"]252 res = {"table": t, "hyp": hyp, "T": T,253 "cagr": {k: (t.loc[t["annee"] == T, k].iloc[0] / t.loc[t["annee"] == 1, k].iloc[0]) ** (1 / (T - 1)) - 1 for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]},254 "rbp_T": {k: float(t.loc[t["annee"] == T, k].iloc[0]) for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]},255 "somme_rbp_1_T": {k: float(t.loc[t["annee"] <= T, k].sum()) for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]}}256 # valeur DCF associée à chaque trajectoire (pour mémoire : sensibilité de la valeur à la trajectoire ; ch. 30/34 approfondissent)257 res["valeur_dcf"] = {"tal_seul": cas3.valeur_dcf(tal, hyp)["valeur"], "base_rot15": cas3.valeur_dcf(base, hyp)["valeur"],258 "rot_schl_094": cas3.valeur_dcf(rot_schl, hyp)["valeur"]}259 return res260261262def mini_projection(loyers: list[float], marche: float, g_c: float, g_m: float, k_par_an: int, annees: int) -> pd.DataFrame:263 """Petit modèle de trajectoire (exercice 27.6) : chaque année, les loyers reconduits croissent de g_c, le marché de g_m, et les k logements les264 plus sous le marché sont reloués au marché. Année 1 = loyers en place. Reproduit la logique de traite.cas3.projection_dcf à petite échelle."""265 L = np.array(loyers, dtype=float); M = float(marche); rows = []266 for t in range(1, annees + 1):267 if t > 1:268 L = L * (1 + g_c); M = M * (1 + g_m)269 idx = np.argsort(L / M)[:k_par_an]; L[idx] = M270 rows.append({"annee": t, "loyers": L.copy(), "marche": M, "rbp": L.sum() * 12, "rbp_marche": M * len(L) * 12})271 return pd.DataFrame(rows)272273274# ----------------------------------------------------------------------------- exercices (solutions/ch27.tex)275def exercices(base3: dict, base2: dict) -> dict:276 ex = {}277 # 27.1 — RBP contractuel, au marché, déficit ; six logements278 L = [895, 910, 1040, 1150, 1225, 1310]; M = [1100, 1100, 1150, 1250, 1300, 1350]279 ex["1"] = {"loyers": L, "marche": M, "rbp_c": sum(L) * 12, "rbp_m": sum(M) * 12, "deficit": (sum(M) - sum(L)) * 12,280 "deficit_pct": 1 - sum(L) / sum(M), "ecarts_pct": [1 - l / m for l, m in zip(L, M)]}281 # 27.2 — RBE selon trois conventions ; v = 4 %, autres 4 800 $282 rbp = ex["1"]["rbp_c"]; v = 0.04; autres = 4800.0283 ex["2"] = {"rbp": rbp, "v": v, "autres": autres, "rbe_livre": fin.revenu_brut_effectif(rbp, v, autres), "rbe_v_sur_tout": (rbp + autres) * (1 - v),284 "rbe_autres_demi": rbp * (1 - v) + autres * (1 - v / 2), "vacance": rbp * v}285 ex["2"]["ecart_tout"] = ex["2"]["rbe_v_sur_tout"] - ex["2"]["rbe_livre"]286 # 27.3 — inoccupation frictionnelle : Québec rotation 13 %, durées 1 et 1,5 mois ; créances 0,5 % ; comparer à SCHL 2,4 %287 ex["3"] = {"rot": 0.13, "vf_1": fin.taux_vacance_frictionnelle(0.13, 1.0), "vf_15": fin.taux_vacance_frictionnelle(0.13, 1.5),288 "creances": 0.005, "schl": 0.024}289 ex["3"]["v_1"] = ex["3"]["vf_1"] + 0.005; ex["3"]["v_15"] = ex["3"]["vf_15"] + 0.005290 ex["3"]["duree_implicite_schl"] = 0.024 * 12 / 0.13 # durée (mois) qui reproduirait 2,4 % avec ρ = 13 %291 # 27.4 — concession : un mois gratuit sur 12 à 1 300 $ ; équivalent en v ; deux mois sur 24292 ex["4"] = {"L": 1300.0, "L_eff_1_12": fin.loyer_effectif_concession(1300, 1, 12), "L_eff_2_24": fin.loyer_effectif_concession(1300, 2, 24),293 "v_equiv_1_12": 1 / 12, "v_equiv_2_24": 2 / 24, "double_compte": 1300 * 12 * (1 - 1 / 12) * (1 - 1 / 12)}294 ex["4"]["rbp_annuel"] = 1300 * 12; ex["4"]["rbe_correct"] = 1300 * 12 * (1 - 1 / 12)295 # 27.5 — triplex (cas 2) : cascade, RBE marché, occupation économique, variante occupant296 ex["5"] = {k: base2[k] for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "taux_vacance_pertes", "vacance_pertes", "rbe", "rbe_marche", "taux_occupation_economique", "rbe_variante_occupant", "rbe_par_logement"]}297 # 27.6 — mini-projection : 4 logements298 mp = mini_projection([900, 950, 1000, 1100], 1200, 0.031, 0.035, 1, 4)299 ex["6"] = {"table": mp, "rbp": mp["rbp"].tolist(), "rbp_marche": mp["rbp_marche"].tolist(), "ecart_pct": (1 - mp["rbp"] / mp["rbp_marche"]).tolist(),300 "tal_seul": [sum([900, 950, 1000, 1100]) * 12 * 1.031 ** (t - 1) for t in range(1, 5)]}301 # 27.7 — valeur et v : cas 3 à v = 2,9 % et 5,9 % ; R = médiane ; via traite.cas3.rne302 s = sensibilite_v(base3, taux=(0.029, 0.039, 0.059))303 ex["7"] = {"table": s, "R": s.attrs["R"], "dV_dv_pt": s.attrs["dV_dv_pt"], "dRBE_dv_pt": s.attrs["dRBE_dv_pt"],304 "V_029": float(s.loc[np.isclose(s["v"], 0.029), "valeur"].iloc[0]), "V_039": float(s.loc[np.isclose(s["v"], 0.039), "valeur"].iloc[0]),305 "V_059": float(s.loc[np.isclose(s["v"], 0.059), "valeur"].iloc[0]),306 "rne_029": float(s.loc[np.isclose(s["v"], 0.029), "rne"].iloc[0]), "rne_059": float(s.loc[np.isclose(s["v"], 0.059), "rne"].iloc[0])}307 ex["7"]["approx_lineaire_2pts"] = 2 * ex["7"]["dV_dv_pt"]308 # 27.8 — lecture SCHL : quartiles, loyers inoccupés/occupés, implication pour un immeuble au-dessus du marché309 q = schl_quartiles_montreal(); io = schl_inoccupes_occupes_ile()310 ex["8"] = {"quartiles": q, "inoccupes_occupes": io, "ratio_tous": io.loc["Tous", "inoccupes"] / io.loc["Tous", "occupes"],311 "ratio_2ch": io.loc["2 chambres", "inoccupes"] / io.loc["2 chambres", "occupes"], "q4_sur_q1": q.loc["Q4", "inocc_2025"] / q.loc["Q1", "inocc_2025"]}312 return ex313314315def calculer() -> dict:316 base3 = cascade_cas3(); base2 = cascade_cas2()317 res = {"cas3": base3, "cas2": base2, "par_type": par_type_cas3(base3["etat"]),318 "schl_rmr": schl_inoccupation_rmr().join(schl_rotation_rmr()), "schl_taille": schl_taille_montreal(),319 "schl_quartiles": schl_quartiles_montreal(), "schl_inocc_occ": schl_inoccupes_occupes_ile(), "schl_annee": schl_annee_construction_ile(),320 "ipc_loyer": ipc_loyer_quebec(), "grille_frictionnelle": grille_frictionnelle(), "sensibilite_v": sensibilite_v(base3),321 "trajectoires": trajectoires_cas3()}322 res["exercices"] = exercices(base3, base2)323 return res324325326def _fmt(x, d=2):327 return f"{x:,.{d}f}".replace(",", " ").replace(".", ",")328329330if __name__ == "__main__":331 r = calculer(); c3 = r["cas3"]; c2 = r["cas2"]332 print("=== Cas 3 — cascade RBP → RBE (données synthétiques)")333 for k in ["loyer_mensuel_contrat", "loyer_mensuel_marche", "rbp_contrat", "rbp_marche", "deficit_loyer", "deficit_pct", "taux_vacance_pertes", "vacance_pertes",334 "loyers_effectifs", "autres_revenus", "autres_par_logement_mois", "rbe", "rbe_sur_rbp", "rbe_par_logement", "rbe_par_m2", "rbp_par_m2",335 "taux_occupation_economique", "rbe_marche", "rbe_variante_v_sur_tout", "ecart_variante_v_sur_tout", "rbe_variante_v_sur_marche",336 "v_frictionnel_dossier", "v_residuel_dossier"]:337 print(f" {k:32s} {_fmt(c3[k], 4 if abs(c3[k]) < 2 else 2)}")338 print(r["par_type"].round(2).to_string())339 print("=== Cas 2 — triplex")340 for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "deficit_pct", "taux_vacance_pertes", "vacance_pertes", "rbe", "rbe_marche", "taux_occupation_economique", "rbe_variante_occupant", "rbe_par_logement"]:341 print(f" {k:32s} {_fmt(c2[k], 4 if abs(c2[k]) < 2 else 2)}")342 print("=== SCHL oct. 2025 (réel) — inoccupation et rotation par RMR"); print(r["schl_rmr"].to_string())343 print("=== SCHL — taille d'immeuble (Montréal)"); print(r["schl_taille"].to_string())344 print("=== SCHL — quartiles RMR Montréal"); print(r["schl_quartiles"].to_string())345 print("=== SCHL — loyers inoccupés / occupés, île"); print(r["schl_inocc_occ"].round(1).to_string())346 print("=== SCHL — année de construction, île"); print(r["schl_annee"].to_string())347 ipc = r["ipc_loyer"]; print(f"=== IPC loyer Québec : sept. 2026 {ipc['sept2026']} / sept. 2025 {ipc['sept2025']} → {ipc['var_12m']*100:.2f} % ; TCAC 10 ans {ipc['cagr_10ans']*100:.2f} % ; 5 ans {ipc['cagr_5ans']*100:.2f} %")348 print("=== Grille frictionnelle (v_f en %)"); print(r["grille_frictionnelle"].round(2).to_string())349 s = r["sensibilite_v"]; print("=== Sensibilité à v (R = %.4f %%)" % (s.attrs["R"] * 100)); print(s.round(2).to_string())350 print(f" dRBE/dv (par point) {s.attrs['dRBE_dv_pt']:.2f} ; dRNE/dv {s.attrs['dRNE_dv_pt']:.2f} ; dV/dv {s.attrs['dV_dv_pt']:.2f}")351 t = r["trajectoires"]; print("=== Trajectoires RBP"); print(t["table"].round(2).to_string()); print("TCAC", {k: round(v * 100, 3) for k, v in t["cagr"].items()})352 print("RBP_T", {k: round(v, 2) for k, v in t["rbp_T"].items()}); print("Somme 1..T", {k: round(v, 2) for k, v in t["somme_rbp_1_T"].items()}); print("V_DCF", {k: round(v, 2) for k, v in t["valeur_dcf"].items()})353 print("=== Exercices")354 for k, v in r["exercices"].items():355 print(k, {kk: (vv if not isinstance(vv, (pd.DataFrame, pd.Series)) else "<table>") for kk, vv in v.items()})356 print(r["exercices"]["6"]["table"].to_string()); print(r["exercices"]["7"]["table"].round(2).to_string())357