SPB Git
19.0 KB · 290 lines python
1"""Tests du chapitre 4 — marchés résidentiels québécois : lecture des données réelles (Recensement 2021, 17-10-0148-01, IPLN 18-10-0205-01,2SCHL ELL octobre 2025, Banque du Canada), identités (parts, dissimilarité, barèmes progressifs, paiement hypothécaire) et valeurs citées dans le3texte. Tolérances : valeurs d'indices et de taux exactes (lecture des fichiers bruts) ; 0,5 $ sur les montants arrondis ; 1e-9 sur les identités."""4import sys, pathlib, math5import numpy as np, pytest6sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))7import ch04_marches as cas8from traite import finance as fin910ROOT = pathlib.Path(__file__).resolve().parents[3]111213@pytest.fixture(scope="module")14def res():15    return cas.main(verbose=False)161718class TestRecensement:19    def test_totaux(self, res):20        t = res["types"]; tot = t[t["type_id"] == 1].set_index("geo")21        assert tot.loc["Québec", "total"] == 3749035 and tot.loc["Québec", "proprietaire"] == 2245600 and tot.loc["Québec", "locataire"] == 149700522        assert tot.loc["Canada", "total"] == 14978945 and tot.loc["Canada", "proprietaire"] == 995597023        assert tot.loc["Québec", "taux_propriete"] == pytest.approx(0.599, abs=5e-4)24        assert tot.loc["Canada", "taux_propriete"] == pytest.approx(0.665, abs=5e-4)2526    def test_types_cites(self, res):27        sp = res["parc"]28        assert sp.loc[2, "qc_menages"] == 1672095 and sp.loc[6, "qc_menages"] == 1242980 and sp.loc[5, "qc_menages"] == 26941529        assert sp.loc[3, "qc_menages"] == 225795 and sp.loc[9, "qc_menages"] == 200495 and sp.loc[8, "qc_menages"] == 9920530        assert sp.loc[2, "qc_part"] == pytest.approx(0.446, abs=5e-4) and sp.loc[6, "qc_part"] == pytest.approx(0.3315, abs=5e-4)31        assert sp.loc[5, "qc_part"] == pytest.approx(0.0719, abs=5e-4) and sp.loc[2, "ca_part"] == pytest.approx(0.5256, abs=5e-4)32        assert sp.loc[6, "ca_part"] == pytest.approx(0.1828, abs=5e-4)33        # les huit types détaillés épuisent le total (hiérarchie 2, 3, 4 = {5,6,7,8,9}, 10)34        # arrondi aléatoire de Statistique Canada (multiples de 5) : la somme des huit types diffère du total d'au plus 10 ménages35        assert abs(sp["qc_menages"].sum() - 3749035) <= 10 and abs(sp["ca_menages"].sum() - 14978945) <= 1036        assert sp["qc_part"].sum() == pytest.approx(1.0, abs=1e-5) and sp["ca_part"].sum() == pytest.approx(1.0, abs=1e-5)3738    def test_taux_propriete_par_type(self, res):39        sp = res["parc"]40        assert sp.loc[2, "qc_taux_propriete"] == pytest.approx(0.922, abs=5e-4)41        assert sp.loc[5, "qc_taux_propriete"] == pytest.approx(0.429, abs=5e-4)42        assert sp.loc[6, "qc_taux_propriete"] == pytest.approx(0.210, abs=5e-4)43        assert sp.loc[3, "qc_taux_propriete"] == pytest.approx(0.2865, abs=5e-4)44        # propriétaires + locataires ≤ total : le solde est le mode « logement fourni par le gouvernement local, la Première Nation ou la bande »45        ecart = sp["qc_menages"] - sp["qc_proprietaires"] - sp["qc_locataires"]46        assert (ecart >= -10).all() and ecart.sum() == pytest.approx(6430, abs=40)4748    def test_dissimilarite(self, res):49        sp = res["parc"]50        D = cas.indice_dissimilarite(sp["qc_part"].values, sp["ca_part"].values)51        assert D == pytest.approx(0.1715, abs=5e-4) and 0 <= D <= 152        assert cas.indice_dissimilarite(sp["qc_part"].values, sp["qc_part"].values) == 0.053        plex_like = sp.loc[[5, 6], "qc_menages"].sum()54        assert plex_like == 1512395 and plex_like / 3749035 == pytest.approx(0.4034, abs=5e-4)5556    def test_menages_rmr(self, res):57        mr = res["menages_rmr"]58        assert mr.loc["RMR Montréal", "total"] == 1835705 and mr.loc["RMR Montréal", "locataire"] == 83747059        assert mr.loc["RMR Montréal", "part_locataires"] == pytest.approx(0.456, abs=5e-4)60        assert mr.loc["RMR Québec", "total"] == 387955 and mr.loc["RMR Québec", "part_locataires"] == pytest.approx(0.417, abs=5e-4)61        assert mr.loc["RMR Ottawa-Gatineau (ensemble)", "part_locataires"] == pytest.approx(0.3456, abs=5e-4)62        assert mr.loc["Québec (province)", "part_locataires"] == pytest.approx(0.3993, abs=5e-4)636465class TestPopulation:66    def test_valeurs_2025(self, res):67        pop = res["population"]68        attendu = {"Montréal": 4597837, "Québec": 903607, "Ottawa-Gatineau (partie du Québec)": 374226, "Sherbrooke": 243911,69                   "Trois-Rivières": 174316, "Saguenay": 170192, "Drummondville": 109151, "Ensemble des RMR du Québec": 6573240}70        for k, v in attendu.items():71            assert pop.loc[k, 2025] == v7273    def test_croissance(self, res):74        pop = res["population"]75        assert pop.loc["Montréal", "tcac_2020_2025"] == pytest.approx((4597837 / 4356853) ** 0.2 - 1, rel=1e-12)76        assert pop.loc["Montréal", "tcac_2020_2025"] == pytest.approx(0.0108, abs=5e-4)77        assert pop.loc["Québec", "tcac_2020_2025"] == pytest.approx(0.0146, abs=5e-4)78        assert pop.loc["Saguenay", "tcac_2020_2025"] == pytest.approx(0.0096, abs=5e-4)79        assert pop.loc["Montréal", "var_2024_2025"] == pytest.approx(0.0055, abs=5e-4)80        assert pop.loc[2021 if False else "Montréal", 2021] < pop.loc["Montréal", 2020]  # recul de 2021 (pandémie)818283class TestIPLN:84    def test_niveaux_septembre_2026(self, res):85        st = res["ipln_stats"]86        assert st.loc["Montréal", "total"] == 159.5 and st.loc["Québec", "total"] == 144.2 and st.loc["Gatineau (partie du Québec)", "total"] == 116.687        assert st.loc["Québec (province)", "total"] == 150.0 and st.loc["Canada", "total"] == 120.288        assert st.loc["Sherbrooke", "total"] == 112.1 and st.loc["Trois-Rivières", "total"] == 119.489        assert (st["dernier_mois"] == "2026-09").all()9091    def test_variations(self, res):92        st = res["ipln_stats"]93        assert st.loc["Montréal", "var_12_mois"] == pytest.approx(0.0121, abs=5e-4)94        assert st.loc["Québec", "var_12_mois"] == pytest.approx(0.0191, abs=5e-4)95        assert st.loc["Gatineau (partie du Québec)", "var_12_mois"] == pytest.approx(0.0034, abs=5e-4)96        assert st.loc["Canada", "var_12_mois"] == pytest.approx(-0.0204, abs=5e-4)97        assert st.loc["Montréal", "var_2020_01_2022_12"] == pytest.approx(0.3557, abs=5e-4)98        assert st.loc["Gatineau (partie du Québec)", "var_2020_01_2022_12"] == pytest.approx(0.0923, abs=5e-4)99        assert st.loc["Montréal", "var_depuis_2016_12"] == pytest.approx(0.595, abs=1e-6)100        assert st.loc["Montréal", "var_2000_2016"] == pytest.approx(0.7606, abs=5e-4)101        assert st.loc["Canada", "sommet"] == "2022-08" and st.loc["Canada", "ecart_sommet"] == pytest.approx(-0.0468, abs=5e-4)102103    def test_composantes(self, res):104        st = res["ipln_stats"]105        assert st.loc["Montréal", "maison"] == 169.9 and st.loc["Montréal", "terrain"] == 132.6106        assert st.loc["Québec (province)", "maison"] == 158.3 and st.loc["Québec (province)", "terrain"] == 128.0107        # base déc. 2016 = 100 pour toutes les séries108        ip = res["ipln"]; base = ip[ip["date"] == "2016-12-01"]109        assert np.allclose(base["indice"].values, 100.0)110111112class TestSCHL:113    def test_valeurs_citees(self, res):114        sc = res["schl"]115        assert sc.loc["Montréal", "inocc_tous_2025"] == 2.9 and sc.loc["Montréal", "inocc_tous_2024"] == 2.1116        assert sc.loc["Montréal", "loyer_2_chambres_2025"] == 1346 and sc.loc["Montréal", "loyer_2_chambres_2024"] == 1176117        assert sc.loc["Québec", "inocc_tous_2025"] == 2.4 and sc.loc["Québec", "loyer_2_chambres_2025"] == 1277118        assert sc.loc["Gatineau (partie du Québec)", "inocc_tous_2025"] == 3.8 and sc.loc["Gatineau (partie du Québec)", "loyer_2_chambres_2025"] == 1460119        assert sc.loc["Sherbrooke", "inocc_tous_2025"] == 2.7 and sc.loc["Trois-Rivières", "inocc_tous_2025"] == 2.7 and sc.loc["Saguenay", "inocc_tous_2025"] == 1.3120        assert sc.loc["Saguenay", "loyer_2_chambres_2025"] == 985 and sc.loc["Sherbrooke", "loyer_2_chambres_2025"] == 1062 and sc.loc["Trois-Rivières", "loyer_2_chambres_2025"] == 1016121        assert sc.loc["Montréal", "univers_tous_2025"] == 667266 and sc.loc["Québec", "univers_tous_2025"] == 118693122123    def test_variation_brute_vs_estimative(self, res):124        sc = res["schl"]125        assert sc.loc["Montréal", "var_brute_2_chambres"] == pytest.approx(1346 / 1176 - 1, rel=1e-12)126        assert sc.loc["Montréal", "var_brute_2_chambres"] == pytest.approx(0.1446, abs=5e-4)127        assert sc.loc["Montréal", "varest_2_chambres_2025"] == 7.2 and sc.loc["Montréal", "varest_tous_2025"] == 7.4128        assert sc.loc["Saguenay", "varest_tous_2025"] == 12.5 and sc.loc["Gatineau (partie du Québec)", "varest_tous_2025"] == 4.5129        # effet de composition dans les deux sens : brute > estimative à Montréal (14,5 vs 7,2), brute < estimative à Sherbrooke (6,0 vs 9,8)130        assert sc.loc["Montréal", "var_brute_2_chambres"] * 100 > sc.loc["Montréal", "varest_2_chambres_2025"]131        assert sc.loc["Sherbrooke", "var_brute_2_chambres"] == pytest.approx(0.0599, abs=5e-4)132        assert sc.loc["Sherbrooke", "var_brute_2_chambres"] * 100 < sc.loc["Sherbrooke", "varest_2_chambres_2025"]133134135class TestAPCIQ:136    def test_mois_inventaire(self, res):137        ap = res["apciq"]138        assert ap.loc["Province", "mois_inventaire"] == pytest.approx(42408 / (20935 / 3), rel=1e-12)139        assert ap.loc["Province", "mois_inventaire"] == pytest.approx(6.08, abs=5e-3)140        assert ap.loc["Montréal", "mois_inventaire"] == pytest.approx(6.59, abs=5e-3)141        assert ap.loc["Québec", "mois_inventaire"] == pytest.approx(3.06, abs=5e-3)142        assert ap.loc["Gatineau", "mois_inventaire"] == pytest.approx(6.48, abs=5e-3)143        assert ap.loc["Sherbrooke", "mois_inventaire"] == pytest.approx(4.35, abs=5e-3)144        assert ap.loc["Trois-Rivières", "mois_inventaire"] == pytest.approx(3.60, abs=5e-3)145        assert ap.loc["Saguenay", "mois_inventaire"] == pytest.approx(4.03, abs=5e-3)146147    def test_rapports_et_mrb(self, res):148        ap = res["apciq"]149        assert ap.loc["Montréal", "plex_sur_unifamiliale"] == pytest.approx(1.333, abs=5e-4)150        assert ap.loc["Saguenay", "plex_sur_unifamiliale"] == pytest.approx(1.022, abs=5e-4)151        assert ap.loc["Province", "copro_sur_unifamiliale"] == pytest.approx(0.80, abs=1e-9)152        assert res["mrb"]["Montréal"] == pytest.approx(860500 / (36 * 1346), rel=1e-12)153        assert res["mrb"]["Montréal"] == pytest.approx(17.76, abs=5e-3) and res["mrb"]["Québec"] == pytest.approx(13.05, abs=5e-3)154        assert res["mrb"]["Saguenay"] == pytest.approx(10.53, abs=5e-3)155        assert cas.ratio_prix_loyer(435000, 1826) == pytest.approx(19.85, abs=5e-3)156157158class TestMisesEnChantier:159    def test_valeurs(self, res):160        mc = res["mises_en_chantier"]; st = res["mc_stats"]161        assert mc.loc[2025, "total"] == 59864 and mc.loc[2021, "total"] == 67810 and mc.loc[2023, "total"] == 38912 and mc.loc[2000, "total"] == 24695162        assert mc.loc[2025, "individuelles"] == 8477 and mc.loc[2000, "individuelles"] == 15349 and mc.loc[2025, "appartements"] == 47671163        assert st["part_app_2025"] == pytest.approx(0.796, abs=5e-4) and st["part_app_2000"] == pytest.approx(0.291, abs=5e-4)164        assert mc.loc[2000, "part_individuelles"] == pytest.approx(0.62, abs=5e-3) and mc.loc[2025, "part_individuelles"] == pytest.approx(0.14, abs=5e-3)165        assert st["moyenne_2000_2019"] == pytest.approx(44205, abs=1) and st["moyenne_2020_2025"] == pytest.approx(54412, abs=1)166        assert st["derniere_annee"] == 2025  # 2026 incomplète (trois trimestres)167        assert mc.loc[1988:2020, "total"].max() < 67810 < mc.loc[1987, "total"]  # « niveau inégalé depuis 1987 »168        # identité : total = individuelles + jumelés + en bande + appartements (à l'arrondi près)169        m = mc.loc[2000:]  # ventilation par type disponible sur la période récente170        assert np.allclose(m["total"], m[["individuelles", "jumeles", "en_bande", "appartements"]].sum(axis=1), atol=1)171172173class TestBanqueDuCanada:174    def test_dernieres_valeurs(self, res):175        s = res["bdc"]["stats"]176        assert s["directeur_dernier"] == ("2026-10-29", 2.5) and s["hyp5_dernier"] == ("2026-10-28", 6.19)177        assert s["obl5_dernier"] == ("2026-10-29", 3.58) and s["obl10_dernier"] == ("2026-10-29", 3.84)178        assert s["directeur_min"] == ("2009-04-21", 0.25) and s["directeur_2022_03"] == 0.25 and s["directeur_2023_07_12"] == 5.0179        assert s["hyp5_max_2000"] == ("2000-05-24", 8.75) and s["hyp5_min"] == ("2015-04-08", 4.64)180181    def test_cycle_2022_2026(self, res):182        chg = dict(res["bdc"]["stats"]["changements_2022_2026"])183        assert chg["2022-03-03"] == 0.5 and chg["2023-07-13"] == 5.0 and chg["2024-06-06"] == 4.75 and chg["2025-10-30"] == 2.25 and chg["2026-10-29"] == 2.5184        assert len([d for d in chg if d.startswith("2022") or d.startswith("2023")]) == 10  # dix hausses mars 2022 → juillet 2023185186187class TestMutations:188    def test_bareme_provincial(self):189        bar = cas.BAREMES_2026["Province (barème légal)"]190        assert cas.droits_mutation(500000, bar) == pytest.approx(0.005 * 62900 + 0.01 * (315000 - 62900) + 0.015 * (500000 - 315000), abs=1e-9)191        assert cas.droits_mutation(500000, bar) == pytest.approx(5610.5, abs=1e-9)192        assert cas.droits_mutation(62900, bar) == pytest.approx(314.5, abs=1e-9)193        assert cas.droits_mutation(0, bar) == 0.0194195    def test_baremes_municipaux(self, res):196        m = res["mutations"].set_index(["bareme", "prix"])["droit"]197        assert m[("Montréal", 500000)] == pytest.approx(5610.5, abs=1e-9)          # identique au barème légal sous 552 300 $198        assert m[("Montréal", 860500)] == pytest.approx(12559.0, abs=1e-9)199        assert m[("Gatineau (dès le 25 février 2026)", 860500)] == pytest.approx(16425.5, abs=1e-9)200        assert m[("Québec (ville)", 860500)] == pytest.approx(15175.5, abs=1e-9)201        assert m[("Montréal", 1500000)] == pytest.approx(27325.5, abs=1e-9)202        assert m[("Gatineau (dès le 25 février 2026)", 1500000)] == pytest.approx(35610.5, abs=1e-9)203204    def test_continuite_et_progressivite(self):205        for bar in cas.BAREMES_2026.values():206            for borne, _ in bar:207                if borne is None: continue208                assert cas.droits_mutation(borne - 1e-6, bar) == pytest.approx(cas.droits_mutation(borne + 1e-6, bar), abs=1e-6)209            te = [cas.droits_mutation(p, bar) / p for p in (100000, 300000, 500000, 1000000, 3000000)]210            assert all(te[i] <= te[i + 1] + 1e-12 for i in range(len(te) - 1))211            assert te[-1] < bar[-1][1]  # le taux effectif reste sous le taux marginal maximal212213214class TestTauxEtPrix:215    def test_paiement_reference(self, res):216        h = fin.Hypotheque(400000, 0.0412, 300)217        assert res["paiement_ref"] == pytest.approx(h.paiement, rel=1e-12) and res["paiement_ref"] == pytest.approx(2130.18, abs=0.5)218        assert h.taux_periodique == pytest.approx((1 + 0.0412 / 2) ** (1 / 6) - 1, rel=1e-12)219220    def test_prix_soutenable(self, res):221        s = res["sensibilite"].set_index("taux")222        assert s.loc[0.0412, "prix_soutenable"] == pytest.approx(500000, abs=1e-6)223        assert s.loc[0.0619, "prix_soutenable"] == pytest.approx(408931, abs=0.5) and s.loc[0.0619, "variation_prix"] == pytest.approx(-0.1821, abs=5e-4)224        assert s.loc[0.03, "prix_soutenable"] == pytest.approx(562653, abs=0.5)225        assert s.loc[0.07, "variation_prix"] == pytest.approx(-0.2397, abs=5e-4)226        assert s.loc[0.0619, "paiement_pour_prix_ref"] == pytest.approx(2604.57, abs=0.5)227        # inversion : le prix soutenable au taux t redonne le paiement de référence228        for t, row in s.iterrows():229            assert fin.Hypotheque(0.8 * row["prix_soutenable"], t, 300).paiement == pytest.approx(res["paiement_ref"], rel=1e-10)230231232class TestFiguresEtFichiers:233    def test_figures_presentes(self):234        for slug in ("parc-types", "ipln-rmr", "schl-rmr", "carte-rmr", "taux-2000-2026", "mutations"):235            assert (ROOT / f"assets/figures/ch04/ch04-fig-{slug}.pdf").exists(), slug236237    def test_coordonnees_carte(self):238        sys.path.insert(0, str(ROOT / "analysis/python/figures"))239        import ch04_carte as carte240        assert set(carte.CENTRES) == {"Montréal", "Québec", "Ottawa-Gatineau (partie du Québec)", "Sherbrooke", "Trois-Rivières", "Saguenay"}241        for lat, lon in carte.CENTRES.values():242            assert 45.0 < lat < 49.0 and -76.0 < lon < -70.5243244245@pytest.fixture(scope="module")246def ex():247    return cas.exercices()248249250class TestExercices:251252    def test_ex1(self, ex):253        e = ex["ex1"]254        assert e["qc_menages"] == 2053295 and e["qc_prop"] == 683170255        assert e["qc_part"] == pytest.approx(0.548, abs=5e-4) and e["qc_taux_prop"] == pytest.approx(0.333, abs=5e-4)256        assert e["ca_part"] == pytest.approx(0.462, abs=5e-4) and e["ca_taux_prop"] == pytest.approx(0.395, abs=5e-4)257258    def test_ex2(self, ex):259        e = ex["ex2"]260        assert e["sherbrooke"] == pytest.approx(4.35, abs=5e-3) and e["trois_rivieres"] == pytest.approx(3.60, abs=5e-3)261        assert e["sherbrooke_plus30"] == pytest.approx(5.65, abs=5e-3) and e["sherbrooke_plus30"] == pytest.approx(1.3 * e["sherbrooke"], rel=1e-12)262263    def test_ex3(self, ex):264        e = ex["ex3"]265        assert e["loyer_2025"] == pytest.approx(1105.71, abs=0.005) and e["var_brute"] == pytest.approx(0.1057, abs=5e-5)266        assert e["var_meme"] == 0.06 and e["univers_2025"] == 21000267268    def test_ex4(self, ex):269        a, b = ex["ex4_620000"], ex["ex4_560000"]270        assert a["base"] == 620000 and a["droit"] == pytest.approx(9210.5, abs=1e-9) and a["taux_eff_prix"] == pytest.approx(0.01486, abs=5e-5)271        assert b["base"] == pytest.approx(601800, abs=1e-9) and b["droit"] == pytest.approx(8664.5, abs=1e-9)272        assert b["taux_eff_prix"] == pytest.approx(0.01547, abs=5e-5) and b["taux_eff_base"] == pytest.approx(0.01440, abs=5e-5)273274    def test_ex5(self, ex):275        e = ex["ex5"]276        assert e["tcac"] == pytest.approx(0.0146, abs=5e-5) and e["pop_2035"] == pytest.approx(1044650, abs=1)277        assert e["tcac_isq"] == pytest.approx(0.00573, abs=1e-5) and e["pop_2051_isq"] == pytest.approx(1048184, abs=1) and e["pop_2051_tendance"] == pytest.approx(1317519, abs=1)278279    def test_ex6(self, ex):280        e = ex["ex6"]281        assert e["prix_412"] == pytest.approx(540666, abs=0.5) and e["prix_619"] == pytest.approx(458582, abs=0.5)282        assert e["capital_412"] == pytest.approx(2400 / 2130.18 * 400000, rel=1e-4)283        assert e["elast_3"] == pytest.approx(-0.325, abs=5e-3) and e["elast_6"] == pytest.approx(-0.557, abs=5e-3)284        assert e["semi_3_par_point"] == pytest.approx(-0.1084, abs=5e-4) and e["semi_6_par_point"] == pytest.approx(-0.0929, abs=5e-4)285        # élasticité = taux × semi-élasticité (par unité de taux)286        assert e["elast_3"] == pytest.approx(0.03 * e["semi_3_par_point"] * 100, rel=2e-3)287        assert e["elast_6"] == pytest.approx(0.06 * e["semi_6_par_point"] * 100, rel=2e-3)288        assert abs(e["semi_3_par_point"]) > abs(e["semi_6_par_point"]) and abs(e["elast_3"]) < abs(e["elast_6"])289        assert e["pente_3_35_par_point"] == pytest.approx(-0.107, abs=5e-4) and e["pente_619_7_par_point"] == pytest.approx(-0.090, abs=5e-4)290
Files · 317d87e96c9fbe3102824f20418c9f14e795f254
Menu

Theme