spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 34 — TAL, encadrement locatif et scénarios : calculs du chapitre (cas 3, 12 logements, Montréal,3DONNÉES SYNTHÉTIQUES, sauf l'IPC du Québec qui est réel : Statistique Canada, tableau 18-10-0004-01).45Contenu :6 1. Pourcentage de base de la fixation (Règlement sur les critères de fixation de loyer, art. 3.1, tel que remplacé par7 D. 1455-2025) recalculé à partir des moyennes annuelles de l'IPC du Québec (vérification des 4,5 % de 2025 et des8 3,1 % de 2026 publiés par le TAL) et historique « contrefactuel » de la formule depuis 2006.9 2. Calcul pas à pas d'une augmentation de loyer selon la méthode en vigueur depuis le 1er janvier 2026 (art. 3, 3.1, 4,10 5 et 15.1) pour un logement du cas 3, et reconstitution pédagogique de la structure de l'ancienne méthode.11 3. Trajectoire du loyer contractuel sous contrainte : forme fermée de l'espérance du rapport loyer/marché sous rotation12 aléatoire, vérifiée par simulation, et comparaison avec la projection déterministe de traite.cas3.projection_dcf.13 4. Scénarios : fonction locale `projection_scenario` qui reprend la logique de traite.cas3.projection_dcf avec des14 paramètres variés (rotation, pourcentage de base, croissance du marché, rattrapage partiel à la relocation,15 récupération de 5 % des dépenses d'immobilisation, mode déterministe ou espérance) SANS modifier traite/ ; valeur16 DCF de chaque scénario par traite.cas3.valeur_dcf.17Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch34_tal.py18Les fonctions sont importées par analysis/python/figures/ch34_*.py et analysis/python/tests/test_ch34.py."""19from __future__ import annotations20import sys, csv, copy, math, pathlib21import numpy as np, pandas as pd22sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))23from traite import cas3, finance as fin2425ROOT = pathlib.Path(__file__).resolve().parents[3]26IPC_RAW = ROOT / "data/raw/statcan_18100004/18100004.csv"27IPC_PROC = ROOT / "data/processed/ipc_quebec_mensuel.csv"28VECTEUR_IPC_QC = "v41691783" # IPC Québec, Ensemble, 2002 = 100, non désaisonnalisé (tableau 18-10-0004-01)29PCT_TAL_PUBLIES = {2025: 0.045, 2026: 0.031} # TAL, « Pourcentages applicables », page lue le 2026-10-3130GRAINE = 202610313132# ============================================================================ 1. IPC du Québec et pourcentage de base (art. 3.1)3334def ipc_quebec_mensuel() -> pd.DataFrame:35 """Série mensuelle de l'IPC du Québec (Ensemble, 2002 = 100). Extraite une fois du fichier brut de Statistique Canada36 (170 Mo) vers data/processed/ipc_quebec_mensuel.csv ; les appels suivants lisent le fichier traité."""37 if IPC_PROC.exists():38 return pd.read_csv(IPC_PROC)39 if not IPC_RAW.exists():40 raise FileNotFoundError(f"ni {IPC_PROC} ni {IPC_RAW} : télécharger le tableau 18-10-0004-01 (voir data/raw/SOURCES.md)")41 rows = []42 with IPC_RAW.open(encoding="utf-8-sig", newline="") as f:43 for r in csv.reader(f, delimiter=";"):44 if len(r) > 10 and r[8] == VECTEUR_IPC_QC and r[10] not in ("", ".."):45 rows.append({"periode": r[0], "ipc": float(r[10])})46 df = pd.DataFrame(rows).sort_values("periode").reset_index(drop=True)47 IPC_PROC.parent.mkdir(parents=True, exist_ok=True)48 df.to_csv(IPC_PROC, index=False)49 return df5051def ipc_moyennes_annuelles(df: pd.DataFrame | None = None) -> pd.Series:52 """Moyenne annuelle de l'IPC (années civiles complètes seulement, 12 observations)."""53 df = ipc_quebec_mensuel() if df is None else df54 d = df.assign(an=df["periode"].str[:4].astype(int))55 g = d.groupby("an")["ipc"].agg(["mean", "count"])56 return g.loc[g["count"] == 12, "mean"]5758def pourcentage_base(annee_fixation: int, moy: pd.Series | None = None) -> dict:59 """Art. 3.1 : { [(A−B)/B] + [(B−C)/C] + [(C−D)/D] } / 3, A = IPC moyen de la période de référence (année civile précédant le60 terme du bail pour un bail se terminant du 1er avril au 31 décembre, art. 1), B, C, D = les trois périodes de 12 mois61 antérieures ; résultat négatif réputé nul (3e al.)."""62 moy = ipc_moyennes_annuelles() if moy is None else moy63 A, B, C, D = (float(moy.loc[annee_fixation - k]) for k in (1, 2, 3, 4))64 v1, v2, v3 = (A - B) / B, (B - C) / C, (C - D) / D65 brut = (v1 + v2 + v3) / 366 return {"annee": annee_fixation, "A": A, "B": B, "C": C, "D": D, "var_A_B": v1, "var_B_C": v2, "var_C_D": v3,67 "pct_brut": brut, "pct_base": max(brut, 0.0), "pct_publie": PCT_TAL_PUBLIES.get(annee_fixation)}6869def historique_pourcentage_base(debut: int = 2006, fin: int = 2026) -> pd.DataFrame:70 """Pour chaque année de fixation : inflation de la période de référence (A−B)/B et pourcentage de base qu'aurait donné la71 formule de l'art. 3.1 (contrefactuel avant 2026, car la formule n'existait pas ; valeurs réelles 2025-2026 à comparer aux72 pourcentages publiés par le TAL)."""73 moy = ipc_moyennes_annuelles()74 rows = []75 for an in range(debut, fin + 1):76 if an - 4 in moy.index and an - 1 in moy.index:77 p = pourcentage_base(an, moy)78 rows.append({"annee": an, "inflation_ref": p["var_A_B"], "pct_formule": p["pct_base"], "pct_publie": p["pct_publie"]})79 return pd.DataFrame(rows)8081# ============================================================================ 2. Fixation du loyer : nouvelle méthode (2026) et ancienne8283def arrondi_art_15_1(x: float) -> float:84 """Art. 15.1 : fraction < 0,50 $ → dollar inférieur ; ≥ 0,50 $ → dollar supérieur (arrondi « demi vers le haut », non bancaire)."""85 return math.floor(x + 0.5)8687def part_attribuable(loyer_mensuel: float, revenus_annuels: float) -> float:88 """Art. 5 : part attribuable au logement = loyer au terme du bail / revenus de l'immeuble (ici loyer annuel / revenus annuels,89 loyers résidentiels et autres revenus compris ; aucune part pour un local non résidentiel)."""90 return loyer_mensuel * 12 / revenus_annuels9192def taux_net(variation: float, pct_base: float) -> float:93 """Art. 4, 2e al. : si la variation est positive, on en soustrait la valeur absolue du pourcentage de base (brut, avant94 plancher à zéro) ; si le résultat est négatif, il est réputé nul ; une variation négative ne donne rien."""95 if variation <= 0:96 return 0.097 return max(variation - abs(pct_base), 0.0)9899def fixation_nouvelle(loyer: float, pct_base: float, part: float, taxes_mun: tuple[float, float], taxes_scol: tuple[float, float],100 assurances: tuple[float, float], immobilisations: float = 0.0, nouveaux_services: float = 0.0,101 pct_immobilisations: float = 0.05) -> dict:102 """Augmentation mensuelle selon les art. 3, 3.1, 4, 5 et 15.1 du Règlement (version en vigueur le 1er janvier 2026).103 taxes_mun = (exigibles pendant la période de référence, exigibles l'année suivante) ; taxes_scol et assurances =104 (année précédant la période de référence, période de référence). Montants annuels pour l'immeuble entier.105 Reconstitution pédagogique : le formulaire officiel du TAL (Outil de calcul) fait foi."""106 base = loyer * pct_base107 v_mun = (taxes_mun[1] - taxes_mun[0]) / taxes_mun[0]108 v_scol = (taxes_scol[1] - taxes_scol[0]) / taxes_scol[0]109 v_ass = (assurances[1] - assurances[0]) / assurances[0]110 n_mun, n_scol, n_ass = taux_net(v_mun, pct_base), taux_net(v_scol, pct_base), taux_net(v_ass, pct_base)111 m_mun = n_mun * taxes_mun[0] / 12 * part # art. 4, 3e al., 1° : montant mensuel de la période de référence112 m_scol = n_scol * taxes_scol[0] / 12 * part # art. 4, 3e al., 2° : montant mensuel de la période précédente113 m_ass = n_ass * assurances[0] / 12 * part114 m_immo = pct_immobilisations * immobilisations * part / 12115 m_serv = nouveaux_services * part / 12116 total = base + m_mun + m_scol + m_ass + m_immo + m_serv117 nouveau = arrondi_art_15_1(loyer + total)118 return {"loyer": loyer, "pct_base": pct_base, "part": part, "base": base,119 "var_taxes_mun": v_mun, "net_taxes_mun": n_mun, "m_taxes_mun": m_mun,120 "var_taxes_scol": v_scol, "net_taxes_scol": n_scol, "m_taxes_scol": m_scol,121 "var_assurances": v_ass, "net_assurances": n_ass, "m_assurances": m_ass,122 "immobilisations": immobilisations, "m_immobilisations": m_immo, "m_services": m_serv,123 "total_brut": total, "augmentation": nouveau - loyer, "nouveau_loyer": nouveau, "taux_effectif": (nouveau - loyer) / loyer}124125PCT_ANCIENNE_2026 = {"electricite": 0.029, "gaz": 0.137, "mazout": 0.049, "entretien": 0.057, "services_immeuble": 0.034,126 "gestion": 0.072, "revenu_net": 0.057, "immobilisations": 0.04}127PCT_ANCIENNE_2025 = {"electricite": 0.029, "gaz": -0.058, "mazout": -0.029, "entretien": 0.069, "services_immeuble": 0.045,128 "gestion": 0.082, "revenu_net": 0.069, "immobilisations": 0.047}129130def fixation_ancienne(loyer: float, part: float, revenus: float, depenses: dict, pct: dict, taxes_mun: tuple[float, float],131 taxes_scol: tuple[float, float], assurances: tuple[float, float], immobilisations: float = 0.0,132 gestion_forfait: float = 0.05) -> dict:133 """Reconstitution PÉDAGOGIQUE de la structure de l'ancienne méthode (avis donnés avant le 1er janvier 2026 ; formulaire de134 calcul du TAL) : chaque poste de dépenses d'exploitation est ajusté par le pourcentage de sa composante (énergie,135 entretien, services, gestion forfaitaire de 5 % des revenus), les taxes et assurances passent par leur variation réelle,136 le revenu net (revenus − dépenses d'exploitation) est majoré du pourcentage « revenu net » et les dépenses d'immobilisation137 du pourcentage « dépenses d'immobilisation » ; la somme est répartie selon la part attribuable. Le détail exact des lignes138 du formulaire officiel (et ses exclusions) fait foi ; `depenses` : électricité, gaz, mazout, entretien, services_immeuble."""139 gestion = gestion_forfait * revenus140 aj = {k: depenses.get(k, 0.0) * pct.get(k, 0.0) for k in ("electricite", "gaz", "mazout", "entretien", "services_immeuble")}141 aj["gestion"] = gestion * pct["gestion"]142 aj["taxes_mun"] = taxes_mun[1] - taxes_mun[0]143 aj["taxes_scol"] = taxes_scol[1] - taxes_scol[0]144 aj["assurances"] = assurances[1] - assurances[0]145 dep_expl = sum(depenses.get(k, 0.0) for k in ("electricite", "gaz", "mazout", "entretien", "services_immeuble")) + gestion + taxes_mun[0] + taxes_scol[0] + assurances[0]146 revenu_net = revenus - dep_expl147 aj["revenu_net"] = revenu_net * pct["revenu_net"]148 aj["immobilisations"] = immobilisations * pct["immobilisations"]149 total_immeuble = sum(aj.values())150 mensuel = total_immeuble * part / 12151 nouveau = arrondi_art_15_1(loyer + mensuel)152 return {"ajustements_immeuble": aj, "depenses_exploitation": dep_expl, "revenu_net": revenu_net, "total_immeuble": total_immeuble,153 "mensuel_brut": mensuel, "augmentation": nouveau - loyer, "nouveau_loyer": nouveau, "taux_effectif": (nouveau - loyer) / loyer}154155def donnees_immeuble_cas3(d: dict | None = None) -> dict:156 """Montants de l'immeuble du cas 3 utiles à la fixation : revenus (loyers contractuels + autres revenus), taxes 2024-2026,157 assurances 2024-2025, dépenses 2025 par composante (données synthétiques)."""158 d = cas3.charger() if d is None else d159 etat, dep, hyp = d["etat"], d["dep"], d["hyp"]160 p = dep.pivot(index="poste", columns="annee", values="montant").astype(float)161 rev = cas3.revenus(etat, hyp)162 taxes_mun_2026 = hyp["valeur_role_2026"] * hyp["taux_taxe_fonciere"]163 return {"revenus": rev["rbp_contrat"] + rev["autres_revenus"], "rbp_contrat": rev["rbp_contrat"], "autres_revenus": rev["autres_revenus"],164 "taxes_mun": {2024: p.loc["Taxes municipales", 2024], 2025: p.loc["Taxes municipales", 2025], 2026: taxes_mun_2026},165 "taxes_scol": {2024: p.loc["Taxe scolaire", 2024], 2025: p.loc["Taxe scolaire", 2025]},166 "assurances": {2024: p.loc["Assurances", 2024], 2025: p.loc["Assurances", 2025]},167 "depenses_2025": {"electricite": p.loc["Électricité (aires communes)", 2025], "gaz": p.loc["Chauffage (aires communes, gaz)", 2025], "mazout": 0.0,168 "entretien": p.loc["Entretien et réparations", 2025],169 "services_immeuble": p.loc["Conciergerie", 2025] + p.loc["Déneigement et paysagement", 2025]},170 "etat": etat}171172def exemple_fixation(numero: int = 103, d: dict | None = None) -> dict:173 """Exemple intégral : logement `numero` du cas 3, pourcentages 2026 (3,1 % ; 5 %).174 Variante A : données du dossier (taxes municipales 2025 → 2026 normalisées, scolaire et assurances 2024 → 2025, aucune175 dépense d'immobilisation en 2025). Variante B (illustrative) : mêmes loyers, mais hausse de taxes municipales de 8 %,176 hausse d'assurances de 10 % et dépenses d'immobilisation de 85 000 $ (les travaux de maçonnerie et de balcons programmés177 en 2027 dans le dossier, supposés réalisés pendant la période de référence)."""178 im = donnees_immeuble_cas3(d)179 etat = im["etat"]; row = etat[etat["logement"] == numero].iloc[0]180 loyer = float(row["loyer_mensuel_contrat"]); marche = float(row["loyer_mensuel_marche"])181 part = part_attribuable(loyer, im["revenus"])182 pb = PCT_TAL_PUBLIES[2026]183 A = fixation_nouvelle(loyer, pb, part, (im["taxes_mun"][2025], im["taxes_mun"][2026]), (im["taxes_scol"][2024], im["taxes_scol"][2025]),184 (im["assurances"][2024], im["assurances"][2025]), immobilisations=0.0)185 B = fixation_nouvelle(loyer, pb, part, (im["taxes_mun"][2025], im["taxes_mun"][2025] * 1.08), (im["taxes_scol"][2024], im["taxes_scol"][2025]),186 (im["assurances"][2024], im["assurances"][2024] * 1.10), immobilisations=85_000.0)187 anc = fixation_ancienne(loyer, part, im["revenus"], im["depenses_2025"], PCT_ANCIENNE_2026, (im["taxes_mun"][2025], im["taxes_mun"][2026]),188 (im["taxes_scol"][2024], im["taxes_scol"][2025]), (im["assurances"][2024], im["assurances"][2025]), immobilisations=0.0)189 anc_B = fixation_ancienne(loyer, part, im["revenus"], im["depenses_2025"], PCT_ANCIENNE_2026, (im["taxes_mun"][2025], im["taxes_mun"][2025] * 1.08),190 (im["taxes_scol"][2024], im["taxes_scol"][2025]), (im["assurances"][2024], im["assurances"][2024] * 1.10), immobilisations=85_000.0)191 return {"numero": numero, "loyer": loyer, "marche": marche, "ecart_marche": marche - loyer, "ecart_marche_pct": marche / loyer - 1,192 "part": part, "revenus": im["revenus"], "immeuble": im, "A": A, "B": B, "ancienne_A": anc, "ancienne_B": anc_B,193 "annees_rattrapage_base": math.log(marche / loyer) / math.log(1 + pb)}194195# ============================================================================ 3. Trajectoire espérée sous rotation aléatoire196197def ratio_espere(q0: float, rho: float, g_c: float, g_m: float, t: int | np.ndarray) -> np.ndarray:198 """E[q_t] = q0·a^t + ρ·(1 − a^t)/(1 − a), a = (1 − ρ)·δ, δ = (1 + g_c)/(1 + g_m) : rapport espéré loyer/marché d'un logement dont199 le locataire part chaque année avec probabilité ρ (i.i.d.), le logement étant alors reloué au marché, puis indexé à g_c."""200 delta = (1 + g_c) / (1 + g_m); a = (1 - rho) * delta201 t = np.asarray(t, dtype=float)202 return q0 * a ** t + rho * (1 - a ** t) / (1 - a)203204def ratio_limite(rho: float, g_c: float, g_m: float) -> float:205 """q_∞ = ρ / (1 − (1 − ρ)·δ)."""206 delta = (1 + g_c) / (1 + g_m)207 return rho / (1 - (1 - rho) * delta)208209def ratio_sans_relocation(q0: float, g_c: float, g_m: float, t) -> np.ndarray:210 delta = (1 + g_c) / (1 + g_m)211 return q0 * delta ** np.asarray(t, dtype=float)212213def simulation_ratio(q0: float, rho: float, g_c: float, g_m: float, T: int, n: int = 20_000, graine: int = GRAINE) -> np.ndarray:214 """Vérification Monte-Carlo de la forme fermée : n trajectoires, départ i.i.d. de probabilité ρ par année."""215 rng = np.random.default_rng(graine); delta = (1 + g_c) / (1 + g_m)216 q = np.full(n, q0, dtype=float); out = [q.mean()]217 for _ in range(T):218 q = q * delta219 depart = rng.random(n) < rho220 q[depart] = 1.0221 out.append(q.mean())222 return np.array(out)223224def trajectoire_cas3_esperee(d: dict | None = None, rho: float | None = None, g_c: float | None = None, g_m: float | None = None, T: int = 10) -> pd.DataFrame:225 """Espérance du RBP contractuel du cas 3 sous rotation aléatoire (forme fermée appliquée logement par logement) et226 comparaison avec la projection déterministe « les plus sous le marché d'abord » de traite.cas3.projection_dcf."""227 d = cas3.charger() if d is None else d228 hyp = d["hyp"]; etat = d["etat"]229 rho = hyp["rotation_annuelle"] if rho is None else rho; g_c = hyp["croissance_loyers_contrat"] if g_c is None else g_c230 g_m = hyp["croissance_loyers_marche"] if g_m is None else g_m231 L = etat["loyer_mensuel_contrat"].astype(float).values; M = etat["loyer_mensuel_marche"].astype(float).values232 t = np.arange(0, T + 1)233 marche_t = np.outer((1 + g_m) ** t, M) # (T+1) × n234 q_t = np.column_stack([ratio_espere(L[i] / M[i], rho, g_c, g_m, t) for i in range(len(L))])235 rbp_esp = (q_t * marche_t).sum(axis=1) * 12236 rbp_marche = marche_t.sum(axis=1) * 12237 rbp_sans = (np.column_stack([ratio_sans_relocation(L[i] / M[i], g_c, g_m, t) for i in range(len(L))]) * marche_t).sum(axis=1) * 12238 proj = projection_scenario(etat, d["dep"], hyp, rotation=rho, g_c=g_c, g_m=g_m, horizon=T)239 det = proj.loc[proj["annee"] <= T + 1, "rbp"].values # années 1..T+1 ↔ t = 0..T240 return pd.DataFrame({"t": t, "exercice": int(hyp["date_evaluation"][:4]) + t, "rbp_marche": rbp_marche, "rbp_esperance": rbp_esp,241 "rbp_deterministe": det, "rbp_sans_relocation": rbp_sans,242 "ratio_esperance": rbp_esp / rbp_marche, "ratio_deterministe": det / rbp_marche, "ratio_sans": rbp_sans / rbp_marche})243244# ============================================================================ 4. Scénarios : projection locale paramétrée et valeur DCF245246def projection_scenario(etat: pd.DataFrame, dep: pd.DataFrame, hyp: dict, rotation: float | None = None, g_c: float | None = None,247 g_m: float | None = None, fraction_rattrapage: float = 1.0, mode: str = "deterministe",248 recuperation_capex: bool = False, pct_recuperation: float = 0.05, horizon: int | None = None) -> pd.DataFrame:249 """Reprend la logique de traite.cas3.projection_dcf (indexation des baux à g_c, relocation d'une fraction `rotation` des250 logements au loyer de marché croissant à g_m, dépenses +g_d, réserve constante, CAPEX aux années prévues) avec :251 - mode « deterministe » : k = round(rotation × n) logements, les plus sous le marché d'abord (identique au module commun252 lorsque les paramètres sont ceux du dossier) ;253 - mode « esperance » : chaque loyer devient (1 − ρ)·L(1+g_c) + ρ·[L(1+g_c) + φ·(M − L(1+g_c))], soit l'espérance sous départ254 i.i.d. (rotation fractionnaire, sans arrondi) ;255 - fraction_rattrapage φ ∈ [0, 1] : à la relocation, le nouveau loyer n'absorbe qu'une fraction φ de l'écart au marché256 (φ < 1 représente un recours en fixation du nouveau locataire, art. 1896 et 1950 C.c.Q., ou un rattrapage partiel) ;257 - recuperation_capex : l'année qui suit une dépense d'immobilisation, les loyers sont majorés de pct_recuperation × CAPEX258 (art. 3, par. 5° du Règlement), réparti selon la part attribuable (proportionnellement aux loyers).259 Colonnes identiques à projection_dcf, plus `ecart_marche` et `logements_reloues`."""260 T = horizon or hyp["horizon_ans"]261 base = cas3.rne(etat, dep, hyp)262 loyers = etat["loyer_mensuel_contrat"].astype(float).values.copy(); marche = etat["loyer_mensuel_marche"].astype(float).values.copy()263 rot = hyp["rotation_annuelle"] if rotation is None else rotation264 gc = hyp["croissance_loyers_contrat"] if g_c is None else g_c265 gm = hyp["croissance_loyers_marche"] if g_m is None else g_m266 g_d = hyp["croissance_depenses"]; v = hyp["taux_inoccupation_pertes"]; autres = hyp["autres_revenus_annuels"]267 de0 = base["depenses_hors_reserve"]; res0 = base["reserve"]268 annee0 = int(hyp["date_evaluation"][:4]); rows = []; capex_prec = 0.0269 for t in range(1, T + 2):270 k = 0271 if t > 1:272 loyers *= (1 + gc); marche *= (1 + gm)273 if mode == "deterministe":274 k = int(round(rot * len(loyers)))275 idx = np.argsort(loyers / marche)[:k]276 loyers[idx] = loyers[idx] + fraction_rattrapage * (marche[idx] - loyers[idx])277 elif mode == "esperance":278 loyers = (1 - rot) * loyers + rot * (loyers + fraction_rattrapage * (marche - loyers))279 k = rot * len(loyers)280 else:281 raise ValueError(mode)282 if recuperation_capex and capex_prec > 0:283 loyers *= 1 + pct_recuperation * capex_prec / (loyers.sum() * 12)284 rbp = loyers.sum() * 12; rbe = fin.revenu_brut_effectif(rbp, v, autres * (1 + g_d) ** (t - 1))285 de = de0 * (1 + g_d) ** (t - 1); res = res0; noi = rbe - de - res286 capex = float(hyp["capex"].get(str(annee0 + t - 1), 0.0)) if t <= T else 0.0287 rows.append({"annee": t, "exercice": annee0 + t - 1, "rbp": rbp, "rbp_marche": marche.sum() * 12, "rbe": rbe, "depenses": de, "reserve": res,288 "rne": noi, "capex": capex, "flux_avant_dette": noi - capex, "ecart_marche": marche.sum() * 12 - rbp, "logements_reloues": k})289 capex_prec = capex290 return pd.DataFrame(rows)291292def valeur_scenario(d: dict, **kw) -> dict:293 """Projection locale + valeur DCF (traite.cas3.valeur_dcf, hypothèses r, R_T, c_v, T du dossier) + mesures de l'écart au marché."""294 hyp = d["hyp"]; T = hyp["horizon_ans"]; r = hyp["taux_actualisation"]295 proj = projection_scenario(d["etat"], d["dep"], hyp, **kw)296 val = cas3.valeur_dcf(proj, hyp)297 p = proj[proj["annee"] <= T]298 va_ecart = float(sum(e * fin.facteur_va(r, int(t)) for e, t in zip(p["ecart_marche"], p["annee"])))299 return {**val, "projection": proj, "rne_1": float(proj.loc[proj["annee"] == 1, "rne"].iloc[0]), "rbp_1": float(proj.loc[proj["annee"] == 1, "rbp"].iloc[0]),300 "rbp_T1": float(proj.loc[proj["annee"] == T + 1, "rbp"].iloc[0]), "ecart_T1_pct": float(proj.loc[proj["annee"] == T + 1, "ecart_marche"].iloc[0] / proj.loc[proj["annee"] == T + 1, "rbp_marche"].iloc[0]),301 "va_ecart_marche": va_ecart, "croissance_rbp": (float(proj.loc[proj["annee"] == T + 1, "rbp"].iloc[0]) / float(proj.loc[proj["annee"] == 1, "rbp"].iloc[0])) ** (1 / T) - 1}302303def grille_scenarios(d: dict, rotations=(0.05, 0.15, 0.25), bases=(0.02, 0.031, 0.04), **kw) -> pd.DataFrame:304 """Valeur DCF pour chaque combinaison rotation × pourcentage de base (autres hypothèses du dossier inchangées)."""305 V0 = d["dcf"]["valeur"] if "dcf" in d else None306 rows = []307 for rho in rotations:308 for b in bases:309 v = valeur_scenario(d, rotation=rho, g_c=b, **kw)310 rows.append({"rotation": rho, "base": b, "logements_par_an": int(round(rho * len(d["etat"]))) if kw.get("mode", "deterministe") == "deterministe" else rho * len(d["etat"]),311 "valeur": v["valeur"], "rne_T1": v["rne_T1"], "rbp_T1": v["rbp_T1"], "ecart_T1_pct": v["ecart_T1_pct"],312 "va_ecart_marche": v["va_ecart_marche"], "part_terminale": v["part_terminale"], "croissance_rbp": v["croissance_rbp"]})313 g = pd.DataFrame(rows)314 if V0 is not None:315 g["ecart_pct_base"] = g["valeur"] / V0 - 1316 return g317318def scenarios_complementaires(d: dict) -> pd.DataFrame:319 """Scénarios narratifs autour du cas de base (rotation 15 %, base 3,1 %, marché 3,5 %, relocation intégrale au marché)."""320 V0 = d["dcf"]["valeur"]321 defs = [322 ("Base (dossier)", {}),323 ("Base, mode espérance (rotation fractionnaire)", {"mode": "esperance"}),324 ("Indexation seule (aucune relocation)", {"rotation": 0.0}),325 ("Encadrement serré (ch. 30 : base 2 %, rotation 8 %, marché 3 %)", {"g_c": 0.02, "rotation": 0.08, "g_m": 0.03}),326 ("Rattrapage partiel à la relocation (φ = 0,5)", {"fraction_rattrapage": 0.5}),327 ("Encadrement renforcé (base 2 %, rotation 8 %, φ = 0,5, marché 3 %)", {"g_c": 0.02, "rotation": 0.08, "fraction_rattrapage": 0.5, "g_m": 0.03}),328 ("Récupération de 5 % des dépenses d'immobilisation", {"recuperation_capex": True}),329 ("Rotation 25 % et base 4 %", {"rotation": 0.25, "g_c": 0.04}),330 ("Tout au marché dès l'année 1 (borne supérieure théorique)", {"rotation": 1.0}),331 ]332 rows = []333 for nom, kw in defs:334 v = valeur_scenario(d, **kw)335 rows.append({"scenario": nom, **{k: kw.get(k) for k in ("rotation", "g_c", "g_m", "fraction_rattrapage", "mode", "recuperation_capex")},336 "rne_1": v["rne_1"], "rne_T1": v["rne_T1"], "rbp_T1": v["rbp_T1"], "ecart_T1_pct": v["ecart_T1_pct"], "va_ecart_marche": v["va_ecart_marche"],337 "valeur": v["valeur"], "ecart_pct_base": v["valeur"] / V0 - 1, "part_terminale": v["part_terminale"]})338 return pd.DataFrame(rows)339340def elasticites(d: dict, h_rot: float = 0.05, h_base: float = 0.005) -> dict:341 """Variation de valeur par point de rotation et par point de pourcentage de base autour de la base (différences centrées,342 mode espérance pour éviter les sauts d'arrondi du nombre de logements)."""343 hyp = d["hyp"]; rho = hyp["rotation_annuelle"]; b = hyp["croissance_loyers_contrat"]344 vb = valeur_scenario(d, mode="esperance")["valeur"]345 v_rp = valeur_scenario(d, mode="esperance", rotation=rho + h_rot)["valeur"]; v_rm = valeur_scenario(d, mode="esperance", rotation=rho - h_rot)["valeur"]346 v_bp = valeur_scenario(d, mode="esperance", g_c=b + h_base)["valeur"]; v_bm = valeur_scenario(d, mode="esperance", g_c=b - h_base)["valeur"]347 return {"valeur_esperance": vb, "dV_par_point_rotation": (v_rp - v_rm) / (2 * h_rot * 100), "dV_par_point_base": (v_bp - v_bm) / (2 * h_base * 100)}348349# ============================================================================ 5. Exercices350351def exercices() -> dict:352 out = {}353 # Ex. 1 : pourcentage de base avec des IPC fictifs (A=161,0 ; B=157,2 ; C=153,6 ; D=147,0)354 A, B, C, D = 161.0, 157.2, 153.6, 147.0355 out["ex1"] = {"v1": (A - B) / B, "v2": (B - C) / C, "v3": (C - D) / D, "pct": ((A - B) / B + (B - C) / C + (C - D) / D) / 3}356 # Ex. 2 : taxes municipales 24 000 → 26 400 (+10 %), base 3,1 %, loyer 1 100 sur revenus 180 000357 part = part_attribuable(1100, 180_000)358 out["ex2"] = {"part": part, "variation": 0.10, "net": taux_net(0.10, 0.031), "montant": taux_net(0.10, 0.031) * 24_000 / 12 * part,359 "montant_si_6pct": taux_net(0.06, 0.031) * 24_000 / 12 * part, "montant_si_2pct": taux_net(0.02, 0.031) * 24_000 / 12 * part}360 # Ex. 3 : fixation complète logement 201 du cas 3 avec données du dossier (variante A) et travaux 40 000 $ (fenêtres)361 ex = exemple_fixation(201)362 im = ex["immeuble"]363 f3 = fixation_nouvelle(ex["loyer"], 0.031, ex["part"], (im["taxes_mun"][2025], im["taxes_mun"][2025] * 1.05), (im["taxes_scol"][2024], im["taxes_scol"][2025]),364 (im["assurances"][2024], im["assurances"][2025]), immobilisations=40_000.0)365 out["ex3"] = {"loyer": ex["loyer"], "marche": ex["marche"], "part": ex["part"], "fixation": f3}366 # Ex. 4 : forme fermée, q0 = 0,70, ρ = 0,10, g_c = 0,031, g_m = 0,035367 out["ex4"] = {"q": ratio_espere(0.70, 0.10, 0.031, 0.035, np.array([0, 1, 5, 10])), "q_inf": ratio_limite(0.10, 0.031, 0.035),368 "q_inf_20": ratio_limite(0.20, 0.031, 0.035), "survie_10": 0.9 ** 10, "temps_moyen": 1 / 0.10}369 # Ex. 5 : scénarios par script370 d = cas3.tout()371 out["ex5"] = {"base": d["dcf"]["valeur"], "rot10_base25": valeur_scenario(d, rotation=0.10, g_c=0.025)["valeur"],372 "rot10_base25_phi05": valeur_scenario(d, rotation=0.10, g_c=0.025, fraction_rattrapage=0.5)["valeur"],373 "rot10_base25_recup": valeur_scenario(d, rotation=0.10, g_c=0.025, recuperation_capex=True)["valeur"]}374 # Ex. 6 : éviction — indemnité (art. 1965) pour le logement 103 (bail depuis 2008, 18 ans → plafond 24 mois) vs gain annuel au marché375 loyer103 = 728.0; marche103 = 1047.0376 indemnite = min(max(18, 3), 24) * loyer103377 out["ex6"] = {"mois": min(max(18, 3), 24), "indemnite": indemnite, "gain_annuel": (marche103 - loyer103) * 12, "demenagement_hyp": 1_500.0,378 "recuperation_ans": (indemnite + 1_500.0) / ((marche103 - loyer103) * 12)}379 # Ex. 7 : dérivée de q_inf par rapport à ρ380 delta = 1.031 / 1.035381 out["ex7"] = {"delta": delta, "dq_drho_15": (1 - delta) / (1 - (1 - 0.15) * delta) ** 2, "dq_drho_5": (1 - delta) / (1 - (1 - 0.05) * delta) ** 2,382 "q_inf_5": ratio_limite(0.05, 0.031, 0.035), "q_inf_15": ratio_limite(0.15, 0.031, 0.035), "q_inf_25": ratio_limite(0.25, 0.031, 0.035)}383 return out384385# ============================================================================ impression386387def _d(x): return f"{x:,.0f}".replace(",", " ") + " $"388def _d2(x): return f"{x:,.2f}".replace(",", " ").replace(".", ",") + " $"389def _p(x, d=2): return f"{x*100:.{d}f}".replace(".", ",") + " %"390391def main():392 print("=== 1. Pourcentage de base (art. 3.1) à partir de l'IPC du Québec (StatCan 18-10-0004-01, v41691783) ===")393 for an in (2025, 2026):394 p = pourcentage_base(an)395 print(f"{an} : A={p['A']:.3f} B={p['B']:.3f} C={p['C']:.3f} D={p['D']:.3f} ; variations {_p(p['var_A_B'])}, {_p(p['var_B_C'])}, {_p(p['var_C_D'])} ; "396 f"moyenne {_p(p['pct_base'], 3)} ; publié TAL {_p(p['pct_publie'], 1)}")397 h = historique_pourcentage_base()398 print(h.to_string(index=False, float_format=lambda x: f"{x:.4f}"))399400 print("\n=== 2. Exemple de fixation 2026, logement 103 du cas 3 ===")401 ex = exemple_fixation(103)402 im = ex["immeuble"]403 print(f"loyer {_d(ex['loyer'])}/mois ; marché {_d(ex['marche'])} ; écart {_d(ex['ecart_marche'])} ({_p(ex['ecart_marche_pct'],1)}) ; revenus immeuble {_d(ex['revenus'])} ; part {_p(ex['part'],3)}")404 print(f"taxes mun. 2025 {_d(im['taxes_mun'][2025])} → 2026 {_d(im['taxes_mun'][2026])} ; scolaire 2024 {_d(im['taxes_scol'][2024])} → 2025 {_d(im['taxes_scol'][2025])} ; "405 f"assurances 2024 {_d(im['assurances'][2024])} → 2025 {_d(im['assurances'][2025])}")406 for nom in ("A", "B"):407 f = ex[nom]408 print(f"-- variante {nom} : base {_d2(f['base'])} ; taxes mun. var {_p(f['var_taxes_mun'],1)} net {_p(f['net_taxes_mun'],1)} → {_d2(f['m_taxes_mun'])} ; "409 f"scol var {_p(f['var_taxes_scol'],1)} net {_p(f['net_taxes_scol'],1)} → {_d2(f['m_taxes_scol'])} ; assur var {_p(f['var_assurances'],1)} net {_p(f['net_assurances'],1)} → {_d2(f['m_assurances'])} ; "410 f"immob {_d(f['immobilisations'])} → {_d2(f['m_immobilisations'])} ; total {_d2(f['total_brut'])} ; nouveau loyer {_d(f['nouveau_loyer'])} (+{_d(f['augmentation'])}, {_p(f['taux_effectif'])})")411 for nom in ("ancienne_A", "ancienne_B"):412 a = ex[nom]413 print(f"-- {nom} (reconstitution) : ajustements immeuble " + ", ".join(f"{k} {_d(v)}" for k, v in a["ajustements_immeuble"].items()) +414 f" ; dépenses expl. {_d(a['depenses_exploitation'])} ; revenu net {_d(a['revenu_net'])} ; total {_d(a['total_immeuble'])} ; mensuel {_d2(a['mensuel_brut'])} ; nouveau loyer {_d(a['nouveau_loyer'])} ({_p(a['taux_effectif'])})")415 print(f"années de rattrapage du marché à 3,1 %/an si le marché était figé : {ex['annees_rattrapage_base']:.1f}")416417 print("\n=== 3. Trajectoire espérée (forme fermée) et vérification Monte-Carlo ===")418 for rho in (0.05, 0.15, 0.25):419 q = ratio_espere(0.695, rho, 0.031, 0.035, np.array([0, 1, 2, 5, 10]))420 print(f"ρ={rho:.2f} : q_t(t=0,1,2,5,10) = " + ", ".join(f"{x:.4f}" for x in q) + f" ; q_inf = {ratio_limite(rho, 0.031, 0.035):.4f} ; survie 10 ans {(1-rho)**10:.3f}")421 sim = simulation_ratio(0.695, 0.15, 0.031, 0.035, 10)422 print("Monte-Carlo ρ=0,15 (t=0..10) : " + ", ".join(f"{x:.4f}" for x in sim))423 d = cas3.tout()424 tr = trajectoire_cas3_esperee(d)425 print(tr.to_string(index=False, float_format=lambda x: f"{x:,.4f}"))426427 print("\n=== 4. Scénarios : grille rotation × base ===")428 g = grille_scenarios(d)429 print(g.to_string(index=False, float_format=lambda x: f"{x:,.4f}"))430 print("\n=== 4b. Scénarios complémentaires ===")431 s = scenarios_complementaires(d)432 print(s[["scenario", "rne_1", "rne_T1", "rbp_T1", "ecart_T1_pct", "va_ecart_marche", "valeur", "ecart_pct_base", "part_terminale"]].to_string(index=False, float_format=lambda x: f"{x:,.4f}"))433 print("\n=== 4c. Élasticités (mode espérance) ===")434 print(elasticites(d))435 print("\n=== 5. Exercices ===")436 for k, v in exercices().items():437 print(k, v)438439if __name__ == "__main__":440 main()441