#!/usr/bin/env python3 """Chapitre 27 — Revenus potentiels, vacance et pertes : calculs du cas 3 (12 logements, Rosemont) et du cas 2 (triplex, Limoilou) — DONNÉES SYNTHÉTIQUES — et lecture des tableaux publics de la SCHL (Enquête sur les logements locatifs, octobre 2025, data/raw/schl_rml_2025/) — DONNÉES RÉELLES citées avec attribution. Tous les chiffres cités dans manuscript/chapters/ch27.tex et solutions/ch27.tex sortent d'ici. Les revenus des cas viennent de traite.cas3.revenus / traite.cas2.revenus et la projection des loyers de traite.cas3.projection_dcf, pour coïncider avec les chapitres 29, 30, 32, 46 et 47 ; les formules viennent de traite.finance (aucune n'est réécrite). Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch27_revenus.py """ from __future__ import annotations import copy, math, pathlib, sys import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import cas2, cas3, finance as fin ROOT = pathlib.Path(__file__).resolve().parents[3] RAW = ROOT / "data/raw/schl_rml_2025" SIZES = ["3 à 5 unités", "6 à 19 unités", "20 à 49 unités", "50 à 99 unités", "100 unités et +", "Tous les immeubles"] TYPES = ["Studio", "1 chambre", "2 chambres", "3 chambres +", "Tous"] RMR_FICHIER = {"Montréal": "rmr-montreal-2025-fr.xlsx", "Québec": "rmr-quebec-2025-fr.xlsx", "Gatineau (partie QC)": "rmr-gatineau-2025-fr.xlsx", "Sherbrooke": "rmr-sherbrooke-2025-fr.xlsx", "Trois-Rivières": "rmr-trois-rivieres-2025-fr.xlsx", "Saguenay": "rmr-saguenay-2025-fr.xlsx"} # Libellés de la RMR dans les fichiers (tableaux par centre du fichier Québec ; tableaux par zone des autres fichiers) RMR_LIBELLE_QC = {"Montréal": "Montréal (RMR)", "Québec": "Québec (RMR)", "Gatineau (partie QC)": "Ottawa-Gatineau (RMR) (partie QC)", "Sherbrooke": "Sherbrooke (RMR)", "Trois-Rivières": "Trois-Rivières (RMR)", "Saguenay": "Saguenay (RMR)"} # ----------------------------------------------------------------------------- lecture des tableaux SCHL (réels) def _valeurs(tokens) -> list[float]: """Convertit une ligne SCHL en nombres : « 2,9 » → 2.9 ; « ** » (supprimé) → NaN ; lettres de qualité a-d et flèches ignorées.""" out = [] for t in tokens: s = str(t).strip() if s == "**": out.append(float("nan")); continue if s in {"a", "b", "c", "d", "↑", "↓", "-", "–", "O", "N"}: continue try: out.append(float(s.replace(",", ".").replace(" ", "").replace(" ", ""))) except ValueError: continue return out def _lignes(fichier: str, tableau: str) -> list[tuple[str, list[float]]]: import openpyxl wb = openpyxl.load_workbook(RAW / fichier, read_only=True, data_only=True) ws = wb[tableau] rows = [] for r in ws.iter_rows(values_only=True): cells = [c for c in r if c is not None] if not cells: continue rows.append((str(cells[0]).strip(), _valeurs(cells[1:]))) return rows def _bloc(rows, debut: str, libelles: list[str]) -> dict[str, list[float]]: """Extrait, dans le bloc commençant par la ligne `debut`, les lignes dont le libellé figure dans `libelles`.""" out, actif = {}, False for lab, vals in rows: if lab == debut: actif = True; continue if actif and lab in libelles and len(vals) >= 2: out[lab] = vals if actif and lab in libelles and lab == libelles[-1]: break return out def schl_inoccupation_rmr() -> pd.DataFrame: """Taux d'inoccupation (%), tous les appartements, oct. 2024 et oct. 2025, par RMR (tableau 1.1.1 du fichier Québec, par centre).""" rows = _lignes("rmr-quebec-2025-fr.xlsx", "Tableau 1.1.1") rec = [] for rmr, lab in RMR_LIBELLE_QC.items(): v = [vals for l, vals in rows if l == lab][0] # 5 types × (2024, 2025) = 10 valeurs ; « Tous » = les deux dernières rec.append({"rmr": rmr, "inocc_2024": v[-2], "inocc_2025": v[-1], "inocc_2ch_2025": v[5]}) return pd.DataFrame(rec).set_index("rmr") def schl_rotation_rmr() -> pd.DataFrame: """Taux de rotation (%), tous les appartements, oct. 2024 et 2025, par RMR (tableau 1.1.6 du fichier Québec).""" rows = _lignes("rmr-quebec-2025-fr.xlsx", "Tableau 1.1.6") rec = [] for rmr, lab in RMR_LIBELLE_QC.items(): v = [vals for l, vals in rows if l == lab][0] rec.append({"rmr": rmr, "rotation_2024": v[-2], "rotation_2025": v[-1]}) return pd.DataFrame(rec).set_index("rmr") def schl_taille_montreal() -> pd.DataFrame: """Inoccupation (tous les appartements, %) et rotation (%) selon la taille de l'immeuble, oct. 2025 : RMR de Montréal et île de Montréal (tableaux 1.3.1 et 1.3.4 du fichier Montréal).""" inoc = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.3.1"); rot = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.3.4") rec = [] for zone, debut in (("Île de Montréal", "Île de Montréal (zones 1-18)"), ("RMR de Montréal", "Montréal (RMR)")): bi = _bloc(inoc, debut, SIZES); br = _bloc(rot, debut, SIZES) for s in SIZES: vi = bi.get(s, []); vr = br.get(s, []) 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, "rotation_2024": vr[-2] if len(vr) >= 2 else np.nan, "rotation_2025": vr[-1] if vr else np.nan}) return pd.DataFrame(rec) def schl_quartiles_montreal() -> pd.DataFrame: """Inoccupation (%) selon le quartile de loyer, tous les appartements, RMR de Montréal, oct. 2024 et 2025 (tableau 1.4).""" rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.4") b = _bloc(rows, "Montréal (RMR)", ["Q1", "Q2", "Q3", "Q4"]) 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") def schl_inoccupes_occupes_ile() -> pd.DataFrame: """Loyer moyen ($) des logements inoccupés et occupés, île de Montréal, oct. 2025, par taille de logement (tableau 1.1.9).""" rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.1.9") v = [vals for l, vals in rows if l.startswith("Île-de-Montréal")][0] 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)] return pd.DataFrame(rec).set_index("type") def schl_annee_construction_ile() -> pd.DataFrame: """Inoccupation (%) selon l'année de construction, tous les appartements, île de Montréal, oct. 2025 (tableau 1.2.1).""" rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.2.1") libs = ["Avant 1940", "1940 à 1959", "1960 à 1974", "1975 à 1989", "1990 à 2004", "2005 à 2014", "2015 ou après", "Tous les immeubles"] b = _bloc(rows, "Île de Montréal (zones 1-18)", libs) return pd.DataFrame([{"periode": k, "inocc_2025": b[k][-1]} for k in libs if k in b]).set_index("periode") def ipc_loyer_quebec() -> dict: """IPC « Loyer », Québec, non désaisonnalisé (Statistique Canada 18-10-0004-01, 2002 = 100) : variation sept. 2026 / sept. 2025 et croissance annuelle moyenne sur 10 ans (sept. 2016 → sept. 2026).""" p = ROOT / "data/raw/statcan_18100004/18100004.csv" import csv vals = {} with p.open(encoding="utf-8-sig") as f: for row in csv.reader(f, delimiter=";"): if len(row) > 10 and row[1] == "Québec" and row[3] == "Loyer" and row[0] >= "2015-01": try: vals[row[0]] = float(row[10]) except ValueError: pass s = pd.Series(vals).sort_index() return {"serie": s, "sept2026": s["2026-09"], "sept2025": s["2025-09"], "var_12m": s["2026-09"] / s["2025-09"] - 1, "cagr_10ans": (s["2026-09"] / s["2016-09"]) ** (1 / 10) - 1, "cagr_5ans": (s["2026-09"] / s["2021-09"]) ** (1 / 5) - 1} # ----------------------------------------------------------------------------- cas 3 et cas 2 (synthétiques) def cascade_cas3() -> dict: d = cas3.charger(); etat, hyp = d["etat"], d["hyp"] r = cas3.revenus(etat, hyp) n = len(etat); sup = float(etat["superficie_m2"].sum()) v = r["taux_vacance_pertes"] r.update({ "n_logements": n, "superficie_m2": sup, "loyer_mensuel_contrat": float(etat["loyer_mensuel_contrat"].sum()), "loyer_mensuel_marche": float(etat["loyer_mensuel_marche"].sum()), "deficit_loyer": r["ecart_marche"], "deficit_pct": r["ecart_marche"] / r["rbp_marche"], "rbp_contrat_sur_marche": r["rbp_contrat"] / r["rbp_marche"], "loyers_effectifs": r["rbp_contrat"] * (1 - v), "taux_occupation_economique": r["rbp_contrat"] * (1 - v) / r["rbp_marche"], # loyers encaissés / loyers de marché "rbe_sur_rbp": r["rbe"] / r["rbp_contrat"], "autres_sur_rbp": r["autres_revenus"] / r["rbp_contrat"], "autres_par_logement_mois": r["autres_revenus"] / n / 12, "rbe_par_logement": r["rbe"] / n, "rbe_par_m2": r["rbe"] / sup, "rbp_par_m2": r["rbp_contrat"] / sup, "rbe_marche": fin.revenu_brut_effectif(r["rbp_marche"], v, r["autres_revenus"]), # variantes de convention (même v) : v appliqué au RBP + autres revenus ; autres revenus à 50 % vacants ; v sur RBP de marché "rbe_variante_v_sur_tout": (r["rbp_contrat"] + r["autres_revenus"]) * (1 - v), "rbe_variante_v_sur_marche": r["rbp_marche"] * (1 - v) + r["autres_revenus"], }) r["ecart_variante_v_sur_tout"] = r["rbe_variante_v_sur_tout"] - r["rbe"] # décomposition de v (hypothèse pédagogique déclarée, cohérente avec le ch. 47) : frictionnel = rotation × durée ; reste = créances + structurel rot, duree = hyp["rotation_annuelle"], 1.0 r["v_frictionnel_dossier"] = fin.taux_vacance_frictionnelle(rot, duree) r["v_residuel_dossier"] = v - r["v_frictionnel_dossier"] r["etat"] = etat; r["hyp"] = hyp; r["sujet"] = d["sujet"] return r def par_type_cas3(etat: pd.DataFrame) -> pd.DataFrame: g = etat.groupby("pieces").agg(n=("logement", "count"), superficie=("superficie_m2", "sum"), contrat=("loyer_mensuel_contrat", "sum"), marche=("loyer_mensuel_marche", "sum"), anciennete=("locataire_depuis", lambda s: float((2026 - s).mean()))) g["contrat_moyen"] = g["contrat"] / g["n"]; g["marche_moyen"] = g["marche"] / g["n"] g["ecart"] = g["marche"] - g["contrat"]; g["ecart_pct"] = g["ecart"] / g["marche"] * 100 g["rbp_contrat"] = g["contrat"] * 12; g["rbp_marche"] = g["marche"] * 12 tot = g.sum(numeric_only=True); tot["contrat_moyen"] = tot["contrat"] / tot["n"]; tot["marche_moyen"] = tot["marche"] / tot["n"] tot["ecart_pct"] = tot["ecart"] / tot["marche"] * 100; tot["anciennete"] = float((2026 - etat["locataire_depuis"]).mean()) g.loc["Total"] = tot return g def cascade_cas2() -> dict: c = cas2.charger(); loyers = c["loyers"] r = cas2.revenus(loyers) v = r["taux_vacance_pertes"] r.update({"n_logements": len(loyers), "superficie_m2": float(loyers["superficie_m2"].sum()), "vacance_pertes": r["rbp_contrat"] * v, "deficit_pct": r["ecart_marche"] / r["rbp_marche"], "taux_occupation_economique": r["rbp_contrat"] * (1 - v) / r["rbp_marche"], "rbe_par_logement": r["rbe"] / len(loyers), "autres_revenus": 0.0, # décomposition déclarée du 3 % du dossier : inoccupation SCHL Québec 2,4 % + créances ≈ 0,6 % "v_inoccupation_schl": 0.024, "v_creances": v - 0.024, # occupant : le logement du propriétaire (RC, hypothèse du ch. 46) n'a pas de risque d'inoccupation marchand → RBE si l'on # n'applique v qu'aux deux logements loués (variante pédagogique) "loyers": loyers}) loue = loyers[loyers["logement"] != "RC"] 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) return r # ----------------------------------------------------------------------------- modèle d'inoccupation frictionnelle et grilles def 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: 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]) g.index.name = "rotation_pct"; g.columns.name = "duree_mois" return g def sensibilite_v(base: dict, taux=(0.02, 0.029, 0.039, 0.05, 0.06, 0.08)) -> pd.DataFrame: """RBE, RNE normalisé et valeur par capitalisation (taux médian du cas 3) pour différents v ; dépenses recalculées par traite.cas3.rne (la gestion imputée à 4 % du RBE varie avec v).""" d = cas3.charger(); R = cas3.taux_extraits(d["comps"])["mediane"] rows = [] for v in taux: h = copy.deepcopy(d["hyp"]); h["taux_inoccupation_pertes"] = v b = cas3.rne(d["etat"], d["dep"], h) rows.append({"v": v, "vacance_pertes": b["vacance_pertes"], "rbe": b["rbe"], "rne": b["rne"], "valeur": fin.capitalisation_directe(b["rne"], R)}) s = pd.DataFrame(rows) 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 grille ref = {"rbe": b0["rbe"], "valeur": fin.capitalisation_directe(b0["rne"], R)} s["d_rbe"] = s["rbe"] - ref["rbe"]; s["d_valeur"] = s["valeur"] - ref["valeur"]; s["d_valeur_pct"] = s["valeur"] / ref["valeur"] - 1 s.attrs["R"] = R # dérivées analytiques : ∂RBE/∂v = −RBP ; ∂RNE/∂v = −RBP(1−0,04) ; ∂V/∂v = ∂RNE/∂v / R s.attrs["dRBE_dv_pt"] = -base["rbp_contrat"] / 100 s.attrs["dRNE_dv_pt"] = -base["rbp_contrat"] * (1 - 0.04) / 100 s.attrs["dV_dv_pt"] = -base["rbp_contrat"] * (1 - 0.04) / 100 / R return s # ----------------------------------------------------------------------------- trajectoires des loyers (cas 3) def trajectoires_cas3() -> dict: """Trois trajectoires du RBP sur l'horizon du DCF : (i) indexation TAL seule (rotation 0) ; (ii) base du dossier (TAL 3,1 % + rotation 15 % relouée au marché, marché +3,5 %) ; (iii) RBP au marché (indice) ; variante (iv) rotation SCHL 9,4 %.""" d = cas3.charger(); etat, dep, hyp = d["etat"], d["dep"], d["hyp"] def proj(**chg): h = copy.deepcopy(hyp); h.update(chg) return cas3.projection_dcf(etat, dep, h) base = proj(); tal = proj(rotation_annuelle=0.0); rot_schl = proj(rotation_annuelle=0.094) rot_schl_marche_schl = proj(rotation_annuelle=0.094, croissance_loyers_marche=0.033) # marché au rythme de l'IPC-loyer récent t = pd.DataFrame({"annee": base["annee"], "exercice": base["exercice"], "tal_seul": tal["rbp"], "base_rot15": base["rbp"], "rot_schl_094": rot_schl["rbp"], "marche": base["rbp_marche"], "rot094_marche033": rot_schl_marche_schl["rbp"], "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)}) t["ecart_base_pct"] = (1 - t["base_rot15"] / t["marche"]) * 100 t["ecart_tal_pct"] = (1 - t["tal_seul"] / t["marche"]) * 100 t["ecart_rot094_pct"] = (1 - t["rot_schl_094"] / t["marche"]) * 100 T = hyp["horizon_ans"] res = {"table": t, "hyp": hyp, "T": T, "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"]}, "rbp_T": {k: float(t.loc[t["annee"] == T, k].iloc[0]) for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]}, "somme_rbp_1_T": {k: float(t.loc[t["annee"] <= T, k].sum()) for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]}} # valeur DCF associée à chaque trajectoire (pour mémoire : sensibilité de la valeur à la trajectoire ; ch. 30/34 approfondissent) res["valeur_dcf"] = {"tal_seul": cas3.valeur_dcf(tal, hyp)["valeur"], "base_rot15": cas3.valeur_dcf(base, hyp)["valeur"], "rot_schl_094": cas3.valeur_dcf(rot_schl, hyp)["valeur"]} return res def mini_projection(loyers: list[float], marche: float, g_c: float, g_m: float, k_par_an: int, annees: int) -> pd.DataFrame: """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 les 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.""" L = np.array(loyers, dtype=float); M = float(marche); rows = [] for t in range(1, annees + 1): if t > 1: L = L * (1 + g_c); M = M * (1 + g_m) idx = np.argsort(L / M)[:k_par_an]; L[idx] = M rows.append({"annee": t, "loyers": L.copy(), "marche": M, "rbp": L.sum() * 12, "rbp_marche": M * len(L) * 12}) return pd.DataFrame(rows) # ----------------------------------------------------------------------------- exercices (solutions/ch27.tex) def exercices(base3: dict, base2: dict) -> dict: ex = {} # 27.1 — RBP contractuel, au marché, déficit ; six logements L = [895, 910, 1040, 1150, 1225, 1310]; M = [1100, 1100, 1150, 1250, 1300, 1350] ex["1"] = {"loyers": L, "marche": M, "rbp_c": sum(L) * 12, "rbp_m": sum(M) * 12, "deficit": (sum(M) - sum(L)) * 12, "deficit_pct": 1 - sum(L) / sum(M), "ecarts_pct": [1 - l / m for l, m in zip(L, M)]} # 27.2 — RBE selon trois conventions ; v = 4 %, autres 4 800 $ rbp = ex["1"]["rbp_c"]; v = 0.04; autres = 4800.0 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), "rbe_autres_demi": rbp * (1 - v) + autres * (1 - v / 2), "vacance": rbp * v} ex["2"]["ecart_tout"] = ex["2"]["rbe_v_sur_tout"] - ex["2"]["rbe_livre"] # 27.3 — inoccupation frictionnelle : Québec rotation 13 %, durées 1 et 1,5 mois ; créances 0,5 % ; comparer à SCHL 2,4 % 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), "creances": 0.005, "schl": 0.024} ex["3"]["v_1"] = ex["3"]["vf_1"] + 0.005; ex["3"]["v_15"] = ex["3"]["vf_15"] + 0.005 ex["3"]["duree_implicite_schl"] = 0.024 * 12 / 0.13 # durée (mois) qui reproduirait 2,4 % avec ρ = 13 % # 27.4 — concession : un mois gratuit sur 12 à 1 300 $ ; équivalent en v ; deux mois sur 24 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), "v_equiv_1_12": 1 / 12, "v_equiv_2_24": 2 / 24, "double_compte": 1300 * 12 * (1 - 1 / 12) * (1 - 1 / 12)} ex["4"]["rbp_annuel"] = 1300 * 12; ex["4"]["rbe_correct"] = 1300 * 12 * (1 - 1 / 12) # 27.5 — triplex (cas 2) : cascade, RBE marché, occupation économique, variante occupant 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"]} # 27.6 — mini-projection : 4 logements mp = mini_projection([900, 950, 1000, 1100], 1200, 0.031, 0.035, 1, 4) ex["6"] = {"table": mp, "rbp": mp["rbp"].tolist(), "rbp_marche": mp["rbp_marche"].tolist(), "ecart_pct": (1 - mp["rbp"] / mp["rbp_marche"]).tolist(), "tal_seul": [sum([900, 950, 1000, 1100]) * 12 * 1.031 ** (t - 1) for t in range(1, 5)]} # 27.7 — valeur et v : cas 3 à v = 2,9 % et 5,9 % ; R = médiane ; via traite.cas3.rne s = sensibilite_v(base3, taux=(0.029, 0.039, 0.059)) ex["7"] = {"table": s, "R": s.attrs["R"], "dV_dv_pt": s.attrs["dV_dv_pt"], "dRBE_dv_pt": s.attrs["dRBE_dv_pt"], "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]), "V_059": float(s.loc[np.isclose(s["v"], 0.059), "valeur"].iloc[0]), "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])} ex["7"]["approx_lineaire_2pts"] = 2 * ex["7"]["dV_dv_pt"] # 27.8 — lecture SCHL : quartiles, loyers inoccupés/occupés, implication pour un immeuble au-dessus du marché q = schl_quartiles_montreal(); io = schl_inoccupes_occupes_ile() ex["8"] = {"quartiles": q, "inoccupes_occupes": io, "ratio_tous": io.loc["Tous", "inoccupes"] / io.loc["Tous", "occupes"], "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"]} return ex def calculer() -> dict: base3 = cascade_cas3(); base2 = cascade_cas2() res = {"cas3": base3, "cas2": base2, "par_type": par_type_cas3(base3["etat"]), "schl_rmr": schl_inoccupation_rmr().join(schl_rotation_rmr()), "schl_taille": schl_taille_montreal(), "schl_quartiles": schl_quartiles_montreal(), "schl_inocc_occ": schl_inoccupes_occupes_ile(), "schl_annee": schl_annee_construction_ile(), "ipc_loyer": ipc_loyer_quebec(), "grille_frictionnelle": grille_frictionnelle(), "sensibilite_v": sensibilite_v(base3), "trajectoires": trajectoires_cas3()} res["exercices"] = exercices(base3, base2) return res def _fmt(x, d=2): return f"{x:,.{d}f}".replace(",", " ").replace(".", ",") if __name__ == "__main__": r = calculer(); c3 = r["cas3"]; c2 = r["cas2"] print("=== Cas 3 — cascade RBP → RBE (données synthétiques)") for k in ["loyer_mensuel_contrat", "loyer_mensuel_marche", "rbp_contrat", "rbp_marche", "deficit_loyer", "deficit_pct", "taux_vacance_pertes", "vacance_pertes", "loyers_effectifs", "autres_revenus", "autres_par_logement_mois", "rbe", "rbe_sur_rbp", "rbe_par_logement", "rbe_par_m2", "rbp_par_m2", "taux_occupation_economique", "rbe_marche", "rbe_variante_v_sur_tout", "ecart_variante_v_sur_tout", "rbe_variante_v_sur_marche", "v_frictionnel_dossier", "v_residuel_dossier"]: print(f" {k:32s} {_fmt(c3[k], 4 if abs(c3[k]) < 2 else 2)}") print(r["par_type"].round(2).to_string()) print("=== Cas 2 — triplex") 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"]: print(f" {k:32s} {_fmt(c2[k], 4 if abs(c2[k]) < 2 else 2)}") print("=== SCHL oct. 2025 (réel) — inoccupation et rotation par RMR"); print(r["schl_rmr"].to_string()) print("=== SCHL — taille d'immeuble (Montréal)"); print(r["schl_taille"].to_string()) print("=== SCHL — quartiles RMR Montréal"); print(r["schl_quartiles"].to_string()) print("=== SCHL — loyers inoccupés / occupés, île"); print(r["schl_inocc_occ"].round(1).to_string()) print("=== SCHL — année de construction, île"); print(r["schl_annee"].to_string()) 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} %") print("=== Grille frictionnelle (v_f en %)"); print(r["grille_frictionnelle"].round(2).to_string()) s = r["sensibilite_v"]; print("=== Sensibilité à v (R = %.4f %%)" % (s.attrs["R"] * 100)); print(s.round(2).to_string()) 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}") 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()}) 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()}) print("=== Exercices") for k, v in r["exercices"].items(): print(k, {kk: (vv if not isinstance(vv, (pd.DataFrame, pd.Series)) else "") for kk, vv in v.items()}) print(r["exercices"]["6"]["table"].to_string()); print(r["exercices"]["7"]["table"].round(2).to_string())