SPB Git
24.2 KB · 357 lines python
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
Files · fd7f44c8789c8b7d81811c1898b290bc997b5c13
Menu

Theme