#!/usr/bin/env python3 """Chapitre 34 — TAL, encadrement locatif et scénarios : calculs du chapitre (cas 3, 12 logements, Montréal, DONNÉES SYNTHÉTIQUES, sauf l'IPC du Québec qui est réel : Statistique Canada, tableau 18-10-0004-01). Contenu : 1. Pourcentage de base de la fixation (Règlement sur les critères de fixation de loyer, art. 3.1, tel que remplacé par D. 1455-2025) recalculé à partir des moyennes annuelles de l'IPC du Québec (vérification des 4,5 % de 2025 et des 3,1 % de 2026 publiés par le TAL) et historique « contrefactuel » de la formule depuis 2006. 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, 5 et 15.1) pour un logement du cas 3, et reconstitution pédagogique de la structure de l'ancienne méthode. 3. Trajectoire du loyer contractuel sous contrainte : forme fermée de l'espérance du rapport loyer/marché sous rotation aléatoire, vérifiée par simulation, et comparaison avec la projection déterministe de traite.cas3.projection_dcf. 4. Scénarios : fonction locale `projection_scenario` qui reprend la logique de traite.cas3.projection_dcf avec des paramètres variés (rotation, pourcentage de base, croissance du marché, rattrapage partiel à la relocation, récupération de 5 % des dépenses d'immobilisation, mode déterministe ou espérance) SANS modifier traite/ ; valeur DCF de chaque scénario par traite.cas3.valeur_dcf. Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch34_tal.py Les fonctions sont importées par analysis/python/figures/ch34_*.py et analysis/python/tests/test_ch34.py.""" from __future__ import annotations import sys, csv, copy, math, pathlib import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import cas3, finance as fin ROOT = pathlib.Path(__file__).resolve().parents[3] IPC_RAW = ROOT / "data/raw/statcan_18100004/18100004.csv" IPC_PROC = ROOT / "data/processed/ipc_quebec_mensuel.csv" VECTEUR_IPC_QC = "v41691783" # IPC Québec, Ensemble, 2002 = 100, non désaisonnalisé (tableau 18-10-0004-01) PCT_TAL_PUBLIES = {2025: 0.045, 2026: 0.031} # TAL, « Pourcentages applicables », page lue le 2026-10-31 GRAINE = 20261031 # ============================================================================ 1. IPC du Québec et pourcentage de base (art. 3.1) def ipc_quebec_mensuel() -> pd.DataFrame: """Série mensuelle de l'IPC du Québec (Ensemble, 2002 = 100). Extraite une fois du fichier brut de Statistique Canada (170 Mo) vers data/processed/ipc_quebec_mensuel.csv ; les appels suivants lisent le fichier traité.""" if IPC_PROC.exists(): return pd.read_csv(IPC_PROC) if not IPC_RAW.exists(): raise FileNotFoundError(f"ni {IPC_PROC} ni {IPC_RAW} : télécharger le tableau 18-10-0004-01 (voir data/raw/SOURCES.md)") rows = [] with IPC_RAW.open(encoding="utf-8-sig", newline="") as f: for r in csv.reader(f, delimiter=";"): if len(r) > 10 and r[8] == VECTEUR_IPC_QC and r[10] not in ("", ".."): rows.append({"periode": r[0], "ipc": float(r[10])}) df = pd.DataFrame(rows).sort_values("periode").reset_index(drop=True) IPC_PROC.parent.mkdir(parents=True, exist_ok=True) df.to_csv(IPC_PROC, index=False) return df def ipc_moyennes_annuelles(df: pd.DataFrame | None = None) -> pd.Series: """Moyenne annuelle de l'IPC (années civiles complètes seulement, 12 observations).""" df = ipc_quebec_mensuel() if df is None else df d = df.assign(an=df["periode"].str[:4].astype(int)) g = d.groupby("an")["ipc"].agg(["mean", "count"]) return g.loc[g["count"] == 12, "mean"] def pourcentage_base(annee_fixation: int, moy: pd.Series | None = None) -> dict: """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 le 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 mois antérieures ; résultat négatif réputé nul (3e al.).""" moy = ipc_moyennes_annuelles() if moy is None else moy A, B, C, D = (float(moy.loc[annee_fixation - k]) for k in (1, 2, 3, 4)) v1, v2, v3 = (A - B) / B, (B - C) / C, (C - D) / D brut = (v1 + v2 + v3) / 3 return {"annee": annee_fixation, "A": A, "B": B, "C": C, "D": D, "var_A_B": v1, "var_B_C": v2, "var_C_D": v3, "pct_brut": brut, "pct_base": max(brut, 0.0), "pct_publie": PCT_TAL_PUBLIES.get(annee_fixation)} def historique_pourcentage_base(debut: int = 2006, fin: int = 2026) -> pd.DataFrame: """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é la formule de l'art. 3.1 (contrefactuel avant 2026, car la formule n'existait pas ; valeurs réelles 2025-2026 à comparer aux pourcentages publiés par le TAL).""" moy = ipc_moyennes_annuelles() rows = [] for an in range(debut, fin + 1): if an - 4 in moy.index and an - 1 in moy.index: p = pourcentage_base(an, moy) rows.append({"annee": an, "inflation_ref": p["var_A_B"], "pct_formule": p["pct_base"], "pct_publie": p["pct_publie"]}) return pd.DataFrame(rows) # ============================================================================ 2. Fixation du loyer : nouvelle méthode (2026) et ancienne def arrondi_art_15_1(x: float) -> float: """Art. 15.1 : fraction < 0,50 $ → dollar inférieur ; ≥ 0,50 $ → dollar supérieur (arrondi « demi vers le haut », non bancaire).""" return math.floor(x + 0.5) def part_attribuable(loyer_mensuel: float, revenus_annuels: float) -> float: """Art. 5 : part attribuable au logement = loyer au terme du bail / revenus de l'immeuble (ici loyer annuel / revenus annuels, loyers résidentiels et autres revenus compris ; aucune part pour un local non résidentiel).""" return loyer_mensuel * 12 / revenus_annuels def taux_net(variation: float, pct_base: float) -> float: """Art. 4, 2e al. : si la variation est positive, on en soustrait la valeur absolue du pourcentage de base (brut, avant plancher à zéro) ; si le résultat est négatif, il est réputé nul ; une variation négative ne donne rien.""" if variation <= 0: return 0.0 return max(variation - abs(pct_base), 0.0) def fixation_nouvelle(loyer: float, pct_base: float, part: float, taxes_mun: tuple[float, float], taxes_scol: tuple[float, float], assurances: tuple[float, float], immobilisations: float = 0.0, nouveaux_services: float = 0.0, pct_immobilisations: float = 0.05) -> dict: """Augmentation mensuelle selon les art. 3, 3.1, 4, 5 et 15.1 du Règlement (version en vigueur le 1er janvier 2026). taxes_mun = (exigibles pendant la période de référence, exigibles l'année suivante) ; taxes_scol et assurances = (année précédant la période de référence, période de référence). Montants annuels pour l'immeuble entier. Reconstitution pédagogique : le formulaire officiel du TAL (Outil de calcul) fait foi.""" base = loyer * pct_base v_mun = (taxes_mun[1] - taxes_mun[0]) / taxes_mun[0] v_scol = (taxes_scol[1] - taxes_scol[0]) / taxes_scol[0] v_ass = (assurances[1] - assurances[0]) / assurances[0] n_mun, n_scol, n_ass = taux_net(v_mun, pct_base), taux_net(v_scol, pct_base), taux_net(v_ass, pct_base) m_mun = n_mun * taxes_mun[0] / 12 * part # art. 4, 3e al., 1° : montant mensuel de la période de référence m_scol = n_scol * taxes_scol[0] / 12 * part # art. 4, 3e al., 2° : montant mensuel de la période précédente m_ass = n_ass * assurances[0] / 12 * part m_immo = pct_immobilisations * immobilisations * part / 12 m_serv = nouveaux_services * part / 12 total = base + m_mun + m_scol + m_ass + m_immo + m_serv nouveau = arrondi_art_15_1(loyer + total) return {"loyer": loyer, "pct_base": pct_base, "part": part, "base": base, "var_taxes_mun": v_mun, "net_taxes_mun": n_mun, "m_taxes_mun": m_mun, "var_taxes_scol": v_scol, "net_taxes_scol": n_scol, "m_taxes_scol": m_scol, "var_assurances": v_ass, "net_assurances": n_ass, "m_assurances": m_ass, "immobilisations": immobilisations, "m_immobilisations": m_immo, "m_services": m_serv, "total_brut": total, "augmentation": nouveau - loyer, "nouveau_loyer": nouveau, "taux_effectif": (nouveau - loyer) / loyer} PCT_ANCIENNE_2026 = {"electricite": 0.029, "gaz": 0.137, "mazout": 0.049, "entretien": 0.057, "services_immeuble": 0.034, "gestion": 0.072, "revenu_net": 0.057, "immobilisations": 0.04} PCT_ANCIENNE_2025 = {"electricite": 0.029, "gaz": -0.058, "mazout": -0.029, "entretien": 0.069, "services_immeuble": 0.045, "gestion": 0.082, "revenu_net": 0.069, "immobilisations": 0.047} def fixation_ancienne(loyer: float, part: float, revenus: float, depenses: dict, pct: dict, taxes_mun: tuple[float, float], taxes_scol: tuple[float, float], assurances: tuple[float, float], immobilisations: float = 0.0, gestion_forfait: float = 0.05) -> dict: """Reconstitution PÉDAGOGIQUE de la structure de l'ancienne méthode (avis donnés avant le 1er janvier 2026 ; formulaire de calcul du TAL) : chaque poste de dépenses d'exploitation est ajusté par le pourcentage de sa composante (énergie, entretien, services, gestion forfaitaire de 5 % des revenus), les taxes et assurances passent par leur variation réelle, le revenu net (revenus − dépenses d'exploitation) est majoré du pourcentage « revenu net » et les dépenses d'immobilisation du pourcentage « dépenses d'immobilisation » ; la somme est répartie selon la part attribuable. Le détail exact des lignes du formulaire officiel (et ses exclusions) fait foi ; `depenses` : électricité, gaz, mazout, entretien, services_immeuble.""" gestion = gestion_forfait * revenus aj = {k: depenses.get(k, 0.0) * pct.get(k, 0.0) for k in ("electricite", "gaz", "mazout", "entretien", "services_immeuble")} aj["gestion"] = gestion * pct["gestion"] aj["taxes_mun"] = taxes_mun[1] - taxes_mun[0] aj["taxes_scol"] = taxes_scol[1] - taxes_scol[0] aj["assurances"] = assurances[1] - assurances[0] 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] revenu_net = revenus - dep_expl aj["revenu_net"] = revenu_net * pct["revenu_net"] aj["immobilisations"] = immobilisations * pct["immobilisations"] total_immeuble = sum(aj.values()) mensuel = total_immeuble * part / 12 nouveau = arrondi_art_15_1(loyer + mensuel) return {"ajustements_immeuble": aj, "depenses_exploitation": dep_expl, "revenu_net": revenu_net, "total_immeuble": total_immeuble, "mensuel_brut": mensuel, "augmentation": nouveau - loyer, "nouveau_loyer": nouveau, "taux_effectif": (nouveau - loyer) / loyer} def donnees_immeuble_cas3(d: dict | None = None) -> dict: """Montants de l'immeuble du cas 3 utiles à la fixation : revenus (loyers contractuels + autres revenus), taxes 2024-2026, assurances 2024-2025, dépenses 2025 par composante (données synthétiques).""" d = cas3.charger() if d is None else d etat, dep, hyp = d["etat"], d["dep"], d["hyp"] p = dep.pivot(index="poste", columns="annee", values="montant").astype(float) rev = cas3.revenus(etat, hyp) taxes_mun_2026 = hyp["valeur_role_2026"] * hyp["taux_taxe_fonciere"] return {"revenus": rev["rbp_contrat"] + rev["autres_revenus"], "rbp_contrat": rev["rbp_contrat"], "autres_revenus": rev["autres_revenus"], "taxes_mun": {2024: p.loc["Taxes municipales", 2024], 2025: p.loc["Taxes municipales", 2025], 2026: taxes_mun_2026}, "taxes_scol": {2024: p.loc["Taxe scolaire", 2024], 2025: p.loc["Taxe scolaire", 2025]}, "assurances": {2024: p.loc["Assurances", 2024], 2025: p.loc["Assurances", 2025]}, "depenses_2025": {"electricite": p.loc["Électricité (aires communes)", 2025], "gaz": p.loc["Chauffage (aires communes, gaz)", 2025], "mazout": 0.0, "entretien": p.loc["Entretien et réparations", 2025], "services_immeuble": p.loc["Conciergerie", 2025] + p.loc["Déneigement et paysagement", 2025]}, "etat": etat} def exemple_fixation(numero: int = 103, d: dict | None = None) -> dict: """Exemple intégral : logement `numero` du cas 3, pourcentages 2026 (3,1 % ; 5 %). Variante A : données du dossier (taxes municipales 2025 → 2026 normalisées, scolaire et assurances 2024 → 2025, aucune dépense d'immobilisation en 2025). Variante B (illustrative) : mêmes loyers, mais hausse de taxes municipales de 8 %, hausse d'assurances de 10 % et dépenses d'immobilisation de 85 000 $ (les travaux de maçonnerie et de balcons programmés en 2027 dans le dossier, supposés réalisés pendant la période de référence).""" im = donnees_immeuble_cas3(d) etat = im["etat"]; row = etat[etat["logement"] == numero].iloc[0] loyer = float(row["loyer_mensuel_contrat"]); marche = float(row["loyer_mensuel_marche"]) part = part_attribuable(loyer, im["revenus"]) pb = PCT_TAL_PUBLIES[2026] A = fixation_nouvelle(loyer, pb, part, (im["taxes_mun"][2025], im["taxes_mun"][2026]), (im["taxes_scol"][2024], im["taxes_scol"][2025]), (im["assurances"][2024], im["assurances"][2025]), immobilisations=0.0) B = fixation_nouvelle(loyer, pb, part, (im["taxes_mun"][2025], im["taxes_mun"][2025] * 1.08), (im["taxes_scol"][2024], im["taxes_scol"][2025]), (im["assurances"][2024], im["assurances"][2024] * 1.10), immobilisations=85_000.0) anc = fixation_ancienne(loyer, part, im["revenus"], im["depenses_2025"], PCT_ANCIENNE_2026, (im["taxes_mun"][2025], im["taxes_mun"][2026]), (im["taxes_scol"][2024], im["taxes_scol"][2025]), (im["assurances"][2024], im["assurances"][2025]), immobilisations=0.0) anc_B = fixation_ancienne(loyer, part, im["revenus"], im["depenses_2025"], PCT_ANCIENNE_2026, (im["taxes_mun"][2025], im["taxes_mun"][2025] * 1.08), (im["taxes_scol"][2024], im["taxes_scol"][2025]), (im["assurances"][2024], im["assurances"][2024] * 1.10), immobilisations=85_000.0) return {"numero": numero, "loyer": loyer, "marche": marche, "ecart_marche": marche - loyer, "ecart_marche_pct": marche / loyer - 1, "part": part, "revenus": im["revenus"], "immeuble": im, "A": A, "B": B, "ancienne_A": anc, "ancienne_B": anc_B, "annees_rattrapage_base": math.log(marche / loyer) / math.log(1 + pb)} # ============================================================================ 3. Trajectoire espérée sous rotation aléatoire def ratio_espere(q0: float, rho: float, g_c: float, g_m: float, t: int | np.ndarray) -> np.ndarray: """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 dont le locataire part chaque année avec probabilité ρ (i.i.d.), le logement étant alors reloué au marché, puis indexé à g_c.""" delta = (1 + g_c) / (1 + g_m); a = (1 - rho) * delta t = np.asarray(t, dtype=float) return q0 * a ** t + rho * (1 - a ** t) / (1 - a) def ratio_limite(rho: float, g_c: float, g_m: float) -> float: """q_∞ = ρ / (1 − (1 − ρ)·δ).""" delta = (1 + g_c) / (1 + g_m) return rho / (1 - (1 - rho) * delta) def ratio_sans_relocation(q0: float, g_c: float, g_m: float, t) -> np.ndarray: delta = (1 + g_c) / (1 + g_m) return q0 * delta ** np.asarray(t, dtype=float) def simulation_ratio(q0: float, rho: float, g_c: float, g_m: float, T: int, n: int = 20_000, graine: int = GRAINE) -> np.ndarray: """Vérification Monte-Carlo de la forme fermée : n trajectoires, départ i.i.d. de probabilité ρ par année.""" rng = np.random.default_rng(graine); delta = (1 + g_c) / (1 + g_m) q = np.full(n, q0, dtype=float); out = [q.mean()] for _ in range(T): q = q * delta depart = rng.random(n) < rho q[depart] = 1.0 out.append(q.mean()) return np.array(out) def 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: """Espérance du RBP contractuel du cas 3 sous rotation aléatoire (forme fermée appliquée logement par logement) et comparaison avec la projection déterministe « les plus sous le marché d'abord » de traite.cas3.projection_dcf.""" d = cas3.charger() if d is None else d hyp = d["hyp"]; etat = d["etat"] rho = hyp["rotation_annuelle"] if rho is None else rho; g_c = hyp["croissance_loyers_contrat"] if g_c is None else g_c g_m = hyp["croissance_loyers_marche"] if g_m is None else g_m L = etat["loyer_mensuel_contrat"].astype(float).values; M = etat["loyer_mensuel_marche"].astype(float).values t = np.arange(0, T + 1) marche_t = np.outer((1 + g_m) ** t, M) # (T+1) × n q_t = np.column_stack([ratio_espere(L[i] / M[i], rho, g_c, g_m, t) for i in range(len(L))]) rbp_esp = (q_t * marche_t).sum(axis=1) * 12 rbp_marche = marche_t.sum(axis=1) * 12 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) * 12 proj = projection_scenario(etat, d["dep"], hyp, rotation=rho, g_c=g_c, g_m=g_m, horizon=T) det = proj.loc[proj["annee"] <= T + 1, "rbp"].values # années 1..T+1 ↔ t = 0..T return pd.DataFrame({"t": t, "exercice": int(hyp["date_evaluation"][:4]) + t, "rbp_marche": rbp_marche, "rbp_esperance": rbp_esp, "rbp_deterministe": det, "rbp_sans_relocation": rbp_sans, "ratio_esperance": rbp_esp / rbp_marche, "ratio_deterministe": det / rbp_marche, "ratio_sans": rbp_sans / rbp_marche}) # ============================================================================ 4. Scénarios : projection locale paramétrée et valeur DCF def projection_scenario(etat: pd.DataFrame, dep: pd.DataFrame, hyp: dict, rotation: float | None = None, g_c: float | None = None, g_m: float | None = None, fraction_rattrapage: float = 1.0, mode: str = "deterministe", recuperation_capex: bool = False, pct_recuperation: float = 0.05, horizon: int | None = None) -> pd.DataFrame: """Reprend la logique de traite.cas3.projection_dcf (indexation des baux à g_c, relocation d'une fraction `rotation` des logements au loyer de marché croissant à g_m, dépenses +g_d, réserve constante, CAPEX aux années prévues) avec : - mode « deterministe » : k = round(rotation × n) logements, les plus sous le marché d'abord (identique au module commun lorsque les paramètres sont ceux du dossier) ; - 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épart i.i.d. (rotation fractionnaire, sans arrondi) ; - fraction_rattrapage φ ∈ [0, 1] : à la relocation, le nouveau loyer n'absorbe qu'une fraction φ de l'écart au marché (φ < 1 représente un recours en fixation du nouveau locataire, art. 1896 et 1950 C.c.Q., ou un rattrapage partiel) ; - recuperation_capex : l'année qui suit une dépense d'immobilisation, les loyers sont majorés de pct_recuperation × CAPEX (art. 3, par. 5° du Règlement), réparti selon la part attribuable (proportionnellement aux loyers). Colonnes identiques à projection_dcf, plus `ecart_marche` et `logements_reloues`.""" T = horizon or hyp["horizon_ans"] base = cas3.rne(etat, dep, hyp) loyers = etat["loyer_mensuel_contrat"].astype(float).values.copy(); marche = etat["loyer_mensuel_marche"].astype(float).values.copy() rot = hyp["rotation_annuelle"] if rotation is None else rotation gc = hyp["croissance_loyers_contrat"] if g_c is None else g_c gm = hyp["croissance_loyers_marche"] if g_m is None else g_m g_d = hyp["croissance_depenses"]; v = hyp["taux_inoccupation_pertes"]; autres = hyp["autres_revenus_annuels"] de0 = base["depenses_hors_reserve"]; res0 = base["reserve"] annee0 = int(hyp["date_evaluation"][:4]); rows = []; capex_prec = 0.0 for t in range(1, T + 2): k = 0 if t > 1: loyers *= (1 + gc); marche *= (1 + gm) if mode == "deterministe": k = int(round(rot * len(loyers))) idx = np.argsort(loyers / marche)[:k] loyers[idx] = loyers[idx] + fraction_rattrapage * (marche[idx] - loyers[idx]) elif mode == "esperance": loyers = (1 - rot) * loyers + rot * (loyers + fraction_rattrapage * (marche - loyers)) k = rot * len(loyers) else: raise ValueError(mode) if recuperation_capex and capex_prec > 0: loyers *= 1 + pct_recuperation * capex_prec / (loyers.sum() * 12) rbp = loyers.sum() * 12; rbe = fin.revenu_brut_effectif(rbp, v, autres * (1 + g_d) ** (t - 1)) de = de0 * (1 + g_d) ** (t - 1); res = res0; noi = rbe - de - res capex = float(hyp["capex"].get(str(annee0 + t - 1), 0.0)) if t <= T else 0.0 rows.append({"annee": t, "exercice": annee0 + t - 1, "rbp": rbp, "rbp_marche": marche.sum() * 12, "rbe": rbe, "depenses": de, "reserve": res, "rne": noi, "capex": capex, "flux_avant_dette": noi - capex, "ecart_marche": marche.sum() * 12 - rbp, "logements_reloues": k}) capex_prec = capex return pd.DataFrame(rows) def valeur_scenario(d: dict, **kw) -> dict: """Projection locale + valeur DCF (traite.cas3.valeur_dcf, hypothèses r, R_T, c_v, T du dossier) + mesures de l'écart au marché.""" hyp = d["hyp"]; T = hyp["horizon_ans"]; r = hyp["taux_actualisation"] proj = projection_scenario(d["etat"], d["dep"], hyp, **kw) val = cas3.valeur_dcf(proj, hyp) p = proj[proj["annee"] <= T] va_ecart = float(sum(e * fin.facteur_va(r, int(t)) for e, t in zip(p["ecart_marche"], p["annee"]))) 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]), "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]), "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} def grille_scenarios(d: dict, rotations=(0.05, 0.15, 0.25), bases=(0.02, 0.031, 0.04), **kw) -> pd.DataFrame: """Valeur DCF pour chaque combinaison rotation × pourcentage de base (autres hypothèses du dossier inchangées).""" V0 = d["dcf"]["valeur"] if "dcf" in d else None rows = [] for rho in rotations: for b in bases: v = valeur_scenario(d, rotation=rho, g_c=b, **kw) 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"]), "valeur": v["valeur"], "rne_T1": v["rne_T1"], "rbp_T1": v["rbp_T1"], "ecart_T1_pct": v["ecart_T1_pct"], "va_ecart_marche": v["va_ecart_marche"], "part_terminale": v["part_terminale"], "croissance_rbp": v["croissance_rbp"]}) g = pd.DataFrame(rows) if V0 is not None: g["ecart_pct_base"] = g["valeur"] / V0 - 1 return g def scenarios_complementaires(d: dict) -> pd.DataFrame: """Scénarios narratifs autour du cas de base (rotation 15 %, base 3,1 %, marché 3,5 %, relocation intégrale au marché).""" V0 = d["dcf"]["valeur"] defs = [ ("Base (dossier)", {}), ("Base, mode espérance (rotation fractionnaire)", {"mode": "esperance"}), ("Indexation seule (aucune relocation)", {"rotation": 0.0}), ("Encadrement serré (ch. 30 : base 2 %, rotation 8 %, marché 3 %)", {"g_c": 0.02, "rotation": 0.08, "g_m": 0.03}), ("Rattrapage partiel à la relocation (φ = 0,5)", {"fraction_rattrapage": 0.5}), ("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}), ("Récupération de 5 % des dépenses d'immobilisation", {"recuperation_capex": True}), ("Rotation 25 % et base 4 %", {"rotation": 0.25, "g_c": 0.04}), ("Tout au marché dès l'année 1 (borne supérieure théorique)", {"rotation": 1.0}), ] rows = [] for nom, kw in defs: v = valeur_scenario(d, **kw) rows.append({"scenario": nom, **{k: kw.get(k) for k in ("rotation", "g_c", "g_m", "fraction_rattrapage", "mode", "recuperation_capex")}, "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"], "valeur": v["valeur"], "ecart_pct_base": v["valeur"] / V0 - 1, "part_terminale": v["part_terminale"]}) return pd.DataFrame(rows) def elasticites(d: dict, h_rot: float = 0.05, h_base: float = 0.005) -> dict: """Variation de valeur par point de rotation et par point de pourcentage de base autour de la base (différences centrées, mode espérance pour éviter les sauts d'arrondi du nombre de logements).""" hyp = d["hyp"]; rho = hyp["rotation_annuelle"]; b = hyp["croissance_loyers_contrat"] vb = valeur_scenario(d, mode="esperance")["valeur"] v_rp = valeur_scenario(d, mode="esperance", rotation=rho + h_rot)["valeur"]; v_rm = valeur_scenario(d, mode="esperance", rotation=rho - h_rot)["valeur"] 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"] 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)} # ============================================================================ 5. Exercices def exercices() -> dict: out = {} # Ex. 1 : pourcentage de base avec des IPC fictifs (A=161,0 ; B=157,2 ; C=153,6 ; D=147,0) A, B, C, D = 161.0, 157.2, 153.6, 147.0 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} # Ex. 2 : taxes municipales 24 000 → 26 400 (+10 %), base 3,1 %, loyer 1 100 sur revenus 180 000 part = part_attribuable(1100, 180_000) 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, "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} # Ex. 3 : fixation complète logement 201 du cas 3 avec données du dossier (variante A) et travaux 40 000 $ (fenêtres) ex = exemple_fixation(201) im = ex["immeuble"] 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]), (im["assurances"][2024], im["assurances"][2025]), immobilisations=40_000.0) out["ex3"] = {"loyer": ex["loyer"], "marche": ex["marche"], "part": ex["part"], "fixation": f3} # Ex. 4 : forme fermée, q0 = 0,70, ρ = 0,10, g_c = 0,031, g_m = 0,035 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), "q_inf_20": ratio_limite(0.20, 0.031, 0.035), "survie_10": 0.9 ** 10, "temps_moyen": 1 / 0.10} # Ex. 5 : scénarios par script d = cas3.tout() out["ex5"] = {"base": d["dcf"]["valeur"], "rot10_base25": valeur_scenario(d, rotation=0.10, g_c=0.025)["valeur"], "rot10_base25_phi05": valeur_scenario(d, rotation=0.10, g_c=0.025, fraction_rattrapage=0.5)["valeur"], "rot10_base25_recup": valeur_scenario(d, rotation=0.10, g_c=0.025, recuperation_capex=True)["valeur"]} # Ex. 6 : éviction — indemnité (art. 1965) pour le logement 103 (bail depuis 2008, 18 ans → plafond 24 mois) vs gain annuel au marché loyer103 = 728.0; marche103 = 1047.0 indemnite = min(max(18, 3), 24) * loyer103 out["ex6"] = {"mois": min(max(18, 3), 24), "indemnite": indemnite, "gain_annuel": (marche103 - loyer103) * 12, "demenagement_hyp": 1_500.0, "recuperation_ans": (indemnite + 1_500.0) / ((marche103 - loyer103) * 12)} # Ex. 7 : dérivée de q_inf par rapport à ρ delta = 1.031 / 1.035 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, "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)} return out # ============================================================================ impression def _d(x): return f"{x:,.0f}".replace(",", " ") + " $" def _d2(x): return f"{x:,.2f}".replace(",", " ").replace(".", ",") + " $" def _p(x, d=2): return f"{x*100:.{d}f}".replace(".", ",") + " %" def main(): print("=== 1. Pourcentage de base (art. 3.1) à partir de l'IPC du Québec (StatCan 18-10-0004-01, v41691783) ===") for an in (2025, 2026): p = pourcentage_base(an) 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'])} ; " f"moyenne {_p(p['pct_base'], 3)} ; publié TAL {_p(p['pct_publie'], 1)}") h = historique_pourcentage_base() print(h.to_string(index=False, float_format=lambda x: f"{x:.4f}")) print("\n=== 2. Exemple de fixation 2026, logement 103 du cas 3 ===") ex = exemple_fixation(103) im = ex["immeuble"] 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)}") 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])} ; " f"assurances 2024 {_d(im['assurances'][2024])} → 2025 {_d(im['assurances'][2025])}") for nom in ("A", "B"): f = ex[nom] 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'])} ; " 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'])} ; " 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'])})") for nom in ("ancienne_A", "ancienne_B"): a = ex[nom] print(f"-- {nom} (reconstitution) : ajustements immeuble " + ", ".join(f"{k} {_d(v)}" for k, v in a["ajustements_immeuble"].items()) + 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'])})") print(f"années de rattrapage du marché à 3,1 %/an si le marché était figé : {ex['annees_rattrapage_base']:.1f}") print("\n=== 3. Trajectoire espérée (forme fermée) et vérification Monte-Carlo ===") for rho in (0.05, 0.15, 0.25): q = ratio_espere(0.695, rho, 0.031, 0.035, np.array([0, 1, 2, 5, 10])) 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}") sim = simulation_ratio(0.695, 0.15, 0.031, 0.035, 10) print("Monte-Carlo ρ=0,15 (t=0..10) : " + ", ".join(f"{x:.4f}" for x in sim)) d = cas3.tout() tr = trajectoire_cas3_esperee(d) print(tr.to_string(index=False, float_format=lambda x: f"{x:,.4f}")) print("\n=== 4. Scénarios : grille rotation × base ===") g = grille_scenarios(d) print(g.to_string(index=False, float_format=lambda x: f"{x:,.4f}")) print("\n=== 4b. Scénarios complémentaires ===") s = scenarios_complementaires(d) 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}")) print("\n=== 4c. Élasticités (mode espérance) ===") print(elasticites(d)) print("\n=== 5. Exercices ===") for k, v in exercices().items(): print(k, v) if __name__ == "__main__": main()