spb/traite-evaluation-qc
Public
1"""Tests du chapitre 34 — TAL, encadrement locatif et scénarios (cas 3, données synthétiques ; IPC Québec réel).2Identités : la projection locale reproduit traite.cas3.projection_dcf avec les paramètres du dossier ; valeur de base 2 208 566 $ ;3scénario « encadrement serré » du ch. 30 (1 912 046 $) ; formule de l'art. 3.1 (3,1 % en 2026, 4,5 % en 2025) ; règles des art. 44(taux net), 5 (part) et 15.1 (arrondi) ; forme fermée E[q_t] = récursion = Monte-Carlo, limite q_∞ ; monotonie de la valeur en5rotation et en pourcentage de base ; valeurs citées dans manuscript/chapters/ch34.tex (tolérances déclarées)."""6import math, numpy as np, pytest7from traite import cas38from cases import ch34_tal as c910@pytest.fixture(scope="module")11def D():12 return cas3.tout()1314# ------------------------------------------------------------------ 1. pourcentage de base (art. 3.1), IPC réel1516def test_pourcentage_base_reproduit_tal():17 p26 = c.pourcentage_base(2026); p25 = c.pourcentage_base(2025)18 assert p26["pct_base"] == pytest.approx(0.031, abs=0.0005) # 3,095 % calculé vs 3,1 % publié (arrondi à la décimale)19 assert p25["pct_base"] == pytest.approx(0.045, abs=0.0005) # 4,496 % vs 4,5 %20 assert p26["B"] == p25["A"] and p26["C"] == p25["B"] and p26["D"] == p25["C"]21 assert p26["A"] == pytest.approx(161.033, abs=0.001) and p26["D"] == pytest.approx(146.983, abs=0.001)22 assert p26["pct_base"] == pytest.approx((p26["var_A_B"] + p26["var_B_C"] + p26["var_C_D"]) / 3)2324def test_pourcentage_base_plancher_zero():25 import pandas as pd26 moy = pd.Series({2021: 100.0, 2022: 98.0, 2023: 97.0, 2024: 96.0, 2025: 95.0})27 assert c.pourcentage_base(2026, moy)["pct_base"] == 0.0 and c.pourcentage_base(2026, moy)["pct_brut"] < 02829def test_historique_coherent():30 h = c.historique_pourcentage_base(2006, 2026)31 assert list(h["annee"]) == list(range(2006, 2027))32 assert (h["pct_formule"] >= 0).all()33 # la moyenne mobile lisse : la formule est moins dispersée que l'inflation annuelle34 assert h["pct_formule"].std() < h["inflation_ref"].std()35 assert h.loc[h["annee"] == 2023, "inflation_ref"].iloc[0] == pytest.approx(0.0667, abs=0.0005)3637# ------------------------------------------------------------------ 2. fixation : art. 4, 5, 15.13839def test_arrondi_art_15_1():40 assert c.arrondi_art_15_1(750.49) == 750 and c.arrondi_art_15_1(750.50) == 751 and c.arrondi_art_15_1(751.0) == 7514142def test_taux_net_art_4():43 assert c.taux_net(-0.042, 0.031) == 0.0 # variation négative : rien44 assert c.taux_net(0.019, 0.031) == 0.0 # variation positive inférieure à la base : rien45 assert c.taux_net(0.080, 0.031) == pytest.approx(0.049)46 assert c.taux_net(0.080, 0.0) == pytest.approx(0.080)4748def test_part_attribuable():49 assert c.part_attribuable(728, 174_900) == pytest.approx(728 * 12 / 174_900)5051def test_fixation_nouvelle_composantes():52 f = c.fixation_nouvelle(1000.0, 0.03, 0.10, (24_000, 26_400), (3_000, 3_060), (12_000, 13_200), immobilisations=60_000, nouveaux_services=1_200)53 assert f["base"] == pytest.approx(30.0)54 assert f["m_taxes_mun"] == pytest.approx((0.10 - 0.03) * 24_000 / 12 * 0.10) # 14,00 $ : taux net × mensuel de la période de référence × part55 assert f["m_taxes_scol"] == 0.0 # +2 % < 3 %56 assert f["m_assurances"] == pytest.approx((0.10 - 0.03) * 12_000 / 12 * 0.10) # 7,00 $ : mensuel de la période précédente57 assert f["m_immobilisations"] == pytest.approx(0.05 * 60_000 * 0.10 / 12) # 25,00 $58 assert f["m_services"] == pytest.approx(1_200 * 0.10 / 12) # 10,00 $59 assert f["total_brut"] == pytest.approx(30 + 14 + 7 + 25 + 10)60 assert f["nouveau_loyer"] == 1086 and f["augmentation"] == 866162def test_exemple_logement_103_valeurs_citees():63 ex = c.exemple_fixation(103)64 assert ex["loyer"] == 728 and ex["marche"] == 1047 and ex["ecart_marche"] == 31965 assert ex["revenus"] == pytest.approx(174_900) and ex["part"] == pytest.approx(0.04995, abs=1e-4)66 A, B = ex["A"], ex["B"]67 assert A["base"] == pytest.approx(22.568, abs=0.001)68 assert A["m_taxes_mun"] == 0 and A["m_taxes_scol"] == 0 and A["m_assurances"] == 0 and A["m_immobilisations"] == 069 assert A["nouveau_loyer"] == 751 and A["augmentation"] == 2370 assert B["net_taxes_mun"] == pytest.approx(0.049) and B["m_taxes_mun"] == pytest.approx(2.17, abs=0.01)71 assert B["net_assurances"] == pytest.approx(0.069) and B["m_assurances"] == pytest.approx(3.62, abs=0.01)72 assert B["m_immobilisations"] == pytest.approx(17.69, abs=0.01)73 assert B["total_brut"] == pytest.approx(46.05, abs=0.01) and B["nouveau_loyer"] == 77474 assert ex["ancienne_A"]["nouveau_loyer"] == 755 and ex["ancienne_B"]["nouveau_loyer"] == 78675 assert ex["annees_rattrapage_base"] == pytest.approx(11.9, abs=0.05)7677# ------------------------------------------------------------------ 3. trajectoire espérée7879def test_forme_fermee_egale_recursion_et_simulation():80 q0, rho, gc, gm, T = 0.695, 0.15, 0.031, 0.035, 1081 delta = (1 + gc) / (1 + gm)82 q, rec = q0, [q0]83 for _ in range(T):84 q = (1 - rho) * q * delta + rho * 1.0; rec.append(q)85 ff = c.ratio_espere(q0, rho, gc, gm, np.arange(T + 1))86 assert np.allclose(ff, rec, atol=1e-12)87 sim = c.simulation_ratio(q0, rho, gc, gm, T, n=20_000)88 assert np.abs(sim - ff).max() < 0.00689 assert ff[-1] == pytest.approx(0.9249, abs=0.0005)9091def test_limite_et_monotonie():92 gc, gm = 0.031, 0.03593 for rho in (0.05, 0.15, 0.25):94 qi = c.ratio_limite(rho, gc, gm)95 assert c.ratio_espere(0.5, rho, gc, gm, 400) == pytest.approx(qi, abs=1e-9)96 assert c.ratio_espere(1.0, rho, gc, gm, 400) == pytest.approx(qi, abs=1e-9)97 assert c.ratio_limite(0.05, gc, gm) < c.ratio_limite(0.15, gc, gm) < c.ratio_limite(0.25, gc, gm) < 198 assert c.ratio_limite(0.15, gc, gm) == pytest.approx(0.9786, abs=0.0001)99 assert c.ratio_limite(0.05, gc, gm) == pytest.approx(0.9316, abs=0.0001)100 assert c.ratio_limite(0.25, gc, gm) == pytest.approx(0.9885, abs=0.0001)101 assert c.ratio_limite(0.15, gm, gm) == pytest.approx(1.0) # g_c = g_m : aucun écart limite102103def test_trajectoire_cas3(D):104 tr = c.trajectoire_cas3_esperee(D)105 assert tr["rbp_marche"].iloc[0] == pytest.approx(186_036) and tr["rbp_esperance"].iloc[0] == pytest.approx(165_540)106 assert tr["ratio_deterministe"].iloc[-1] == pytest.approx(0.9904, abs=0.0005) and tr["ratio_esperance"].iloc[-1] == pytest.approx(0.9618, abs=0.0005)107 assert (tr["rbp_sans_relocation"] <= tr["rbp_esperance"] + 1e-9).all() and (tr["rbp_esperance"] <= tr["rbp_deterministe"] + 1e-6).all()108109# ------------------------------------------------------------------ 4. scénarios110111def test_projection_locale_reproduit_module_commun(D):112 ref = cas3.projection_dcf(D["etat"], D["dep"], D["hyp"])113 loc = c.projection_scenario(D["etat"], D["dep"], D["hyp"])114 for col in ("rbp", "rbp_marche", "rbe", "depenses", "reserve", "rne", "capex", "flux_avant_dette"):115 assert np.allclose(ref[col].values, loc[col].values, atol=1e-6), col116 assert (loc["logements_reloues"].iloc[1:] == 2).all()117118def test_valeur_base_et_scenario_ch30(D):119 assert c.valeur_scenario(D)["valeur"] == pytest.approx(2_208_566, abs=1)120 assert c.valeur_scenario(D, g_c=0.02, rotation=0.08, g_m=0.03)["valeur"] == pytest.approx(1_912_046, abs=1) # tab. 30.x « encadrement serré »121122def test_grille_monotone_et_valeurs_citees(D):123 g = c.grille_scenarios(D)124 assert len(g) == 9125 for b in (0.02, 0.031, 0.04):126 v = g[g["base"] == b].sort_values("rotation")["valeur"].values127 assert v[0] < v[1] < v[2]128 for r in (0.05, 0.15, 0.25):129 v = g[g["rotation"] == r].sort_values("base")["valeur"].values130 assert v[0] < v[1] < v[2]131 cell = lambda r, b: g[(g["rotation"] == r) & (g["base"] == b)]["valeur"].iloc[0]132 assert cell(0.15, 0.031) == pytest.approx(2_208_566, abs=1)133 assert cell(0.05, 0.02) == pytest.approx(1_974_822, abs=1)134 assert cell(0.25, 0.04) == pytest.approx(2_340_208, abs=1)135 assert cell(0.05, 0.031) == pytest.approx(2_140_916, abs=1)136 assert cell(0.25, 0.031) == pytest.approx(2_231_655, abs=1)137 assert cell(0.15, 0.02) == pytest.approx(2_118_624, abs=1)138 assert cell(0.15, 0.04) == pytest.approx(2_326_053, abs=1)139140def test_scenarios_complementaires(D):141 s = c.scenarios_complementaires(D).set_index("scenario")142 V0 = D["dcf"]["valeur"]143 assert s.loc["Indexation seule (aucune relocation)", "valeur"] == pytest.approx(1_756_414, abs=1)144 assert s.loc["Rattrapage partiel à la relocation (φ = 0,5)", "valeur"] == pytest.approx(2_056_076, abs=1)145 assert s.loc["Encadrement renforcé (base 2 %, rotation 8 %, φ = 0,5, marché 3 %)", "valeur"] == pytest.approx(1_701_507, abs=1)146 assert s.loc["Récupération de 5 % des dépenses d'immobilisation", "valeur"] == pytest.approx(2_289_503, abs=1)147 assert s.loc["Base, mode espérance (rotation fractionnaire)", "valeur"] == pytest.approx(2_091_629, abs=1)148 assert s.loc["Tout au marché dès l'année 1 (borne supérieure théorique)", "valeur"] == pytest.approx(2_267_398, abs=1)149 # ordre attendu150 assert s.loc["Encadrement renforcé (base 2 %, rotation 8 %, φ = 0,5, marché 3 %)", "valeur"] < s.loc["Indexation seule (aucune relocation)", "valeur"] < \151 s.loc["Encadrement serré (ch. 30 : base 2 %, rotation 8 %, marché 3 %)", "valeur"] < V0152 # VA de l'écart au marché : positive et décroissante avec la rotation153 assert s.loc["Indexation seule (aucune relocation)", "va_ecart_marche"] > s.loc["Base (dossier)", "va_ecart_marche"] > 0154155def test_rattrapage_partiel_et_recuperation(D):156 p1 = c.projection_scenario(D["etat"], D["dep"], D["hyp"], fraction_rattrapage=0.5)157 p0 = c.projection_scenario(D["etat"], D["dep"], D["hyp"])158 assert (p1["rbp"].iloc[1:] < p0["rbp"].iloc[1:]).all()159 pr = c.projection_scenario(D["etat"], D["dep"], D["hyp"], recuperation_capex=True)160 # la récupération n'agit qu'à partir de l'année suivant le premier CAPEX (2027 → 2028, année 3)161 assert np.allclose(pr["rbp"].iloc[:2], p0["rbp"].iloc[:2]) and (pr["rbp"].iloc[2:] > p0["rbp"].iloc[2:]).all()162 # hausse de l'année 3 ≈ 5 % × 85 000 $ = 4 250 $ (avant indexation ultérieure)163 assert pr["rbp"].iloc[2] - p0["rbp"].iloc[2] == pytest.approx(0.05 * 85_000, rel=0.02)164165def test_elasticites(D):166 e = c.elasticites(D)167 assert e["dV_par_point_rotation"] > 0 and e["dV_par_point_base"] > 0168 assert e["dV_par_point_rotation"] == pytest.approx(11_877, rel=0.01)169 assert e["dV_par_point_base"] == pytest.approx(131_236, rel=0.01)170171# ------------------------------------------------------------------ 5. exercices172173def test_exercices():174 e = c.exercices()175 assert e["ex1"]["pct"] == pytest.approx(0.03084, abs=1e-5)176 assert e["ex2"]["montant"] == pytest.approx(10.12, abs=0.01) and e["ex2"]["montant_si_2pct"] == 0.0177 assert e["ex3"]["fixation"]["nouveau_loyer"] == 1194178 assert e["ex4"]["q_inf"] == pytest.approx(0.9664, abs=0.0001) and e["ex4"]["survie_10"] == pytest.approx(0.3487, abs=0.0001)179 assert e["ex5"]["rot10_base25"] == pytest.approx(2_049_073, abs=1) and e["ex5"]["rot10_base25_phi05"] == pytest.approx(1_829_294, abs=1)180 assert e["ex5"]["rot10_base25_recup"] == pytest.approx(2_148_793, abs=1)181 assert e["ex6"]["indemnite"] == 13_104 and e["ex6"]["recuperation_ans"] == pytest.approx(3.8, abs=0.05)182 assert e["ex7"]["dq_drho_5"] > e["ex7"]["dq_drho_15"] > 0183