SPB Git
32.8 KB · 441 lines python
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
Files · ae9e02e7cd803a16ec66687feb5b5de2a15d673f
Menu

Theme