"""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, SCHL ELL octobre 2025, Banque du Canada), identités (parts, dissimilarité, barèmes progressifs, paiement hypothécaire) et valeurs citées dans le texte. 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.""" import sys, pathlib, math import numpy as np, pytest sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) import ch04_marches as cas from traite import finance as fin ROOT = pathlib.Path(__file__).resolve().parents[3] @pytest.fixture(scope="module") def res(): return cas.main(verbose=False) class TestRecensement: def test_totaux(self, res): t = res["types"]; tot = t[t["type_id"] == 1].set_index("geo") assert tot.loc["Québec", "total"] == 3749035 and tot.loc["Québec", "proprietaire"] == 2245600 and tot.loc["Québec", "locataire"] == 1497005 assert tot.loc["Canada", "total"] == 14978945 and tot.loc["Canada", "proprietaire"] == 9955970 assert tot.loc["Québec", "taux_propriete"] == pytest.approx(0.599, abs=5e-4) assert tot.loc["Canada", "taux_propriete"] == pytest.approx(0.665, abs=5e-4) def test_types_cites(self, res): sp = res["parc"] assert sp.loc[2, "qc_menages"] == 1672095 and sp.loc[6, "qc_menages"] == 1242980 and sp.loc[5, "qc_menages"] == 269415 assert sp.loc[3, "qc_menages"] == 225795 and sp.loc[9, "qc_menages"] == 200495 and sp.loc[8, "qc_menages"] == 99205 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) 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) assert sp.loc[6, "ca_part"] == pytest.approx(0.1828, abs=5e-4) # les huit types détaillés épuisent le total (hiérarchie 2, 3, 4 = {5,6,7,8,9}, 10) # arrondi aléatoire de Statistique Canada (multiples de 5) : la somme des huit types diffère du total d'au plus 10 ménages assert abs(sp["qc_menages"].sum() - 3749035) <= 10 and abs(sp["ca_menages"].sum() - 14978945) <= 10 assert sp["qc_part"].sum() == pytest.approx(1.0, abs=1e-5) and sp["ca_part"].sum() == pytest.approx(1.0, abs=1e-5) def test_taux_propriete_par_type(self, res): sp = res["parc"] assert sp.loc[2, "qc_taux_propriete"] == pytest.approx(0.922, abs=5e-4) assert sp.loc[5, "qc_taux_propriete"] == pytest.approx(0.429, abs=5e-4) assert sp.loc[6, "qc_taux_propriete"] == pytest.approx(0.210, abs=5e-4) assert sp.loc[3, "qc_taux_propriete"] == pytest.approx(0.2865, abs=5e-4) # propriétaires + locataires ≤ total : le solde est le mode « logement fourni par le gouvernement local, la Première Nation ou la bande » ecart = sp["qc_menages"] - sp["qc_proprietaires"] - sp["qc_locataires"] assert (ecart >= -10).all() and ecart.sum() == pytest.approx(6430, abs=40) def test_dissimilarite(self, res): sp = res["parc"] D = cas.indice_dissimilarite(sp["qc_part"].values, sp["ca_part"].values) assert D == pytest.approx(0.1715, abs=5e-4) and 0 <= D <= 1 assert cas.indice_dissimilarite(sp["qc_part"].values, sp["qc_part"].values) == 0.0 plex_like = sp.loc[[5, 6], "qc_menages"].sum() assert plex_like == 1512395 and plex_like / 3749035 == pytest.approx(0.4034, abs=5e-4) def test_menages_rmr(self, res): mr = res["menages_rmr"] assert mr.loc["RMR Montréal", "total"] == 1835705 and mr.loc["RMR Montréal", "locataire"] == 837470 assert mr.loc["RMR Montréal", "part_locataires"] == pytest.approx(0.456, abs=5e-4) assert mr.loc["RMR Québec", "total"] == 387955 and mr.loc["RMR Québec", "part_locataires"] == pytest.approx(0.417, abs=5e-4) assert mr.loc["RMR Ottawa-Gatineau (ensemble)", "part_locataires"] == pytest.approx(0.3456, abs=5e-4) assert mr.loc["Québec (province)", "part_locataires"] == pytest.approx(0.3993, abs=5e-4) class TestPopulation: def test_valeurs_2025(self, res): pop = res["population"] attendu = {"Montréal": 4597837, "Québec": 903607, "Ottawa-Gatineau (partie du Québec)": 374226, "Sherbrooke": 243911, "Trois-Rivières": 174316, "Saguenay": 170192, "Drummondville": 109151, "Ensemble des RMR du Québec": 6573240} for k, v in attendu.items(): assert pop.loc[k, 2025] == v def test_croissance(self, res): pop = res["population"] assert pop.loc["Montréal", "tcac_2020_2025"] == pytest.approx((4597837 / 4356853) ** 0.2 - 1, rel=1e-12) assert pop.loc["Montréal", "tcac_2020_2025"] == pytest.approx(0.0108, abs=5e-4) assert pop.loc["Québec", "tcac_2020_2025"] == pytest.approx(0.0146, abs=5e-4) assert pop.loc["Saguenay", "tcac_2020_2025"] == pytest.approx(0.0096, abs=5e-4) assert pop.loc["Montréal", "var_2024_2025"] == pytest.approx(0.0055, abs=5e-4) assert pop.loc[2021 if False else "Montréal", 2021] < pop.loc["Montréal", 2020] # recul de 2021 (pandémie) class TestIPLN: def test_niveaux_septembre_2026(self, res): st = res["ipln_stats"] 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.6 assert st.loc["Québec (province)", "total"] == 150.0 and st.loc["Canada", "total"] == 120.2 assert st.loc["Sherbrooke", "total"] == 112.1 and st.loc["Trois-Rivières", "total"] == 119.4 assert (st["dernier_mois"] == "2026-09").all() def test_variations(self, res): st = res["ipln_stats"] assert st.loc["Montréal", "var_12_mois"] == pytest.approx(0.0121, abs=5e-4) assert st.loc["Québec", "var_12_mois"] == pytest.approx(0.0191, abs=5e-4) assert st.loc["Gatineau (partie du Québec)", "var_12_mois"] == pytest.approx(0.0034, abs=5e-4) assert st.loc["Canada", "var_12_mois"] == pytest.approx(-0.0204, abs=5e-4) assert st.loc["Montréal", "var_2020_01_2022_12"] == pytest.approx(0.3557, abs=5e-4) assert st.loc["Gatineau (partie du Québec)", "var_2020_01_2022_12"] == pytest.approx(0.0923, abs=5e-4) assert st.loc["Montréal", "var_depuis_2016_12"] == pytest.approx(0.595, abs=1e-6) assert st.loc["Montréal", "var_2000_2016"] == pytest.approx(0.7606, abs=5e-4) assert st.loc["Canada", "sommet"] == "2022-08" and st.loc["Canada", "ecart_sommet"] == pytest.approx(-0.0468, abs=5e-4) def test_composantes(self, res): st = res["ipln_stats"] assert st.loc["Montréal", "maison"] == 169.9 and st.loc["Montréal", "terrain"] == 132.6 assert st.loc["Québec (province)", "maison"] == 158.3 and st.loc["Québec (province)", "terrain"] == 128.0 # base déc. 2016 = 100 pour toutes les séries ip = res["ipln"]; base = ip[ip["date"] == "2016-12-01"] assert np.allclose(base["indice"].values, 100.0) class TestSCHL: def test_valeurs_citees(self, res): sc = res["schl"] assert sc.loc["Montréal", "inocc_tous_2025"] == 2.9 and sc.loc["Montréal", "inocc_tous_2024"] == 2.1 assert sc.loc["Montréal", "loyer_2_chambres_2025"] == 1346 and sc.loc["Montréal", "loyer_2_chambres_2024"] == 1176 assert sc.loc["Québec", "inocc_tous_2025"] == 2.4 and sc.loc["Québec", "loyer_2_chambres_2025"] == 1277 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"] == 1460 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.3 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"] == 1016 assert sc.loc["Montréal", "univers_tous_2025"] == 667266 and sc.loc["Québec", "univers_tous_2025"] == 118693 def test_variation_brute_vs_estimative(self, res): sc = res["schl"] assert sc.loc["Montréal", "var_brute_2_chambres"] == pytest.approx(1346 / 1176 - 1, rel=1e-12) assert sc.loc["Montréal", "var_brute_2_chambres"] == pytest.approx(0.1446, abs=5e-4) assert sc.loc["Montréal", "varest_2_chambres_2025"] == 7.2 and sc.loc["Montréal", "varest_tous_2025"] == 7.4 assert sc.loc["Saguenay", "varest_tous_2025"] == 12.5 and sc.loc["Gatineau (partie du Québec)", "varest_tous_2025"] == 4.5 # effet de composition dans les deux sens : brute > estimative à Montréal (14,5 vs 7,2), brute < estimative à Sherbrooke (6,0 vs 9,8) assert sc.loc["Montréal", "var_brute_2_chambres"] * 100 > sc.loc["Montréal", "varest_2_chambres_2025"] assert sc.loc["Sherbrooke", "var_brute_2_chambres"] == pytest.approx(0.0599, abs=5e-4) assert sc.loc["Sherbrooke", "var_brute_2_chambres"] * 100 < sc.loc["Sherbrooke", "varest_2_chambres_2025"] class TestAPCIQ: def test_mois_inventaire(self, res): ap = res["apciq"] assert ap.loc["Province", "mois_inventaire"] == pytest.approx(42408 / (20935 / 3), rel=1e-12) assert ap.loc["Province", "mois_inventaire"] == pytest.approx(6.08, abs=5e-3) assert ap.loc["Montréal", "mois_inventaire"] == pytest.approx(6.59, abs=5e-3) assert ap.loc["Québec", "mois_inventaire"] == pytest.approx(3.06, abs=5e-3) assert ap.loc["Gatineau", "mois_inventaire"] == pytest.approx(6.48, abs=5e-3) assert ap.loc["Sherbrooke", "mois_inventaire"] == pytest.approx(4.35, abs=5e-3) assert ap.loc["Trois-Rivières", "mois_inventaire"] == pytest.approx(3.60, abs=5e-3) assert ap.loc["Saguenay", "mois_inventaire"] == pytest.approx(4.03, abs=5e-3) def test_rapports_et_mrb(self, res): ap = res["apciq"] assert ap.loc["Montréal", "plex_sur_unifamiliale"] == pytest.approx(1.333, abs=5e-4) assert ap.loc["Saguenay", "plex_sur_unifamiliale"] == pytest.approx(1.022, abs=5e-4) assert ap.loc["Province", "copro_sur_unifamiliale"] == pytest.approx(0.80, abs=1e-9) assert res["mrb"]["Montréal"] == pytest.approx(860500 / (36 * 1346), rel=1e-12) assert res["mrb"]["Montréal"] == pytest.approx(17.76, abs=5e-3) and res["mrb"]["Québec"] == pytest.approx(13.05, abs=5e-3) assert res["mrb"]["Saguenay"] == pytest.approx(10.53, abs=5e-3) assert cas.ratio_prix_loyer(435000, 1826) == pytest.approx(19.85, abs=5e-3) class TestBanqueDuCanada: def test_dernieres_valeurs(self, res): s = res["bdc"]["stats"] assert s["directeur_dernier"] == ("2026-10-29", 2.5) and s["hyp5_dernier"] == ("2026-10-28", 6.19) assert s["obl5_dernier"] == ("2026-10-29", 3.58) and s["obl10_dernier"] == ("2026-10-29", 3.84) assert s["directeur_min"] == ("2009-04-21", 0.25) and s["directeur_2022_03"] == 0.25 and s["directeur_2023_07_12"] == 5.0 assert s["hyp5_max_2000"] == ("2000-05-24", 8.75) and s["hyp5_min"] == ("2015-04-08", 4.64) def test_cycle_2022_2026(self, res): chg = dict(res["bdc"]["stats"]["changements_2022_2026"]) 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.5 assert len([d for d in chg if d.startswith("2022") or d.startswith("2023")]) == 10 # dix hausses mars 2022 → juillet 2023 class TestMutations: def test_bareme_provincial(self): bar = cas.BAREMES_2026["Province (barème légal)"] assert cas.droits_mutation(500000, bar) == pytest.approx(0.005 * 62900 + 0.01 * (315000 - 62900) + 0.015 * (500000 - 315000), abs=1e-9) assert cas.droits_mutation(500000, bar) == pytest.approx(5610.5, abs=1e-9) assert cas.droits_mutation(62900, bar) == pytest.approx(314.5, abs=1e-9) assert cas.droits_mutation(0, bar) == 0.0 def test_baremes_municipaux(self, res): m = res["mutations"].set_index(["bareme", "prix"])["droit"] assert m[("Montréal", 500000)] == pytest.approx(5610.5, abs=1e-9) # identique au barème légal sous 552 300 $ assert m[("Montréal", 860500)] == pytest.approx(12559.0, abs=1e-9) assert m[("Gatineau (dès le 25 février 2026)", 860500)] == pytest.approx(16425.5, abs=1e-9) assert m[("Québec (ville)", 860500)] == pytest.approx(15175.5, abs=1e-9) assert m[("Montréal", 1500000)] == pytest.approx(27325.5, abs=1e-9) assert m[("Gatineau (dès le 25 février 2026)", 1500000)] == pytest.approx(35610.5, abs=1e-9) def test_continuite_et_progressivite(self): for bar in cas.BAREMES_2026.values(): for borne, _ in bar: if borne is None: continue assert cas.droits_mutation(borne - 1e-6, bar) == pytest.approx(cas.droits_mutation(borne + 1e-6, bar), abs=1e-6) te = [cas.droits_mutation(p, bar) / p for p in (100000, 300000, 500000, 1000000, 3000000)] assert all(te[i] <= te[i + 1] + 1e-12 for i in range(len(te) - 1)) assert te[-1] < bar[-1][1] # le taux effectif reste sous le taux marginal maximal class TestTauxEtPrix: def test_paiement_reference(self, res): h = fin.Hypotheque(400000, 0.0412, 300) assert res["paiement_ref"] == pytest.approx(h.paiement, rel=1e-12) and res["paiement_ref"] == pytest.approx(2130.18, abs=0.5) assert h.taux_periodique == pytest.approx((1 + 0.0412 / 2) ** (1 / 6) - 1, rel=1e-12) def test_prix_soutenable(self, res): s = res["sensibilite"].set_index("taux") assert s.loc[0.0412, "prix_soutenable"] == pytest.approx(500000, abs=1e-6) 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) assert s.loc[0.03, "prix_soutenable"] == pytest.approx(562653, abs=0.5) assert s.loc[0.07, "variation_prix"] == pytest.approx(-0.2397, abs=5e-4) assert s.loc[0.0619, "paiement_pour_prix_ref"] == pytest.approx(2604.57, abs=0.5) # inversion : le prix soutenable au taux t redonne le paiement de référence for t, row in s.iterrows(): assert fin.Hypotheque(0.8 * row["prix_soutenable"], t, 300).paiement == pytest.approx(res["paiement_ref"], rel=1e-10) class TestFiguresEtFichiers: def test_figures_presentes(self): for slug in ("parc-types", "ipln-rmr", "schl-rmr", "carte-rmr", "taux-2000-2026", "mutations"): assert (ROOT / f"assets/figures/ch04/ch04-fig-{slug}.pdf").exists(), slug def test_coordonnees_carte(self): sys.path.insert(0, str(ROOT / "analysis/python/figures")) import ch04_carte as carte assert set(carte.CENTRES) == {"Montréal", "Québec", "Ottawa-Gatineau (partie du Québec)", "Sherbrooke", "Trois-Rivières", "Saguenay"} for lat, lon in carte.CENTRES.values(): assert 45.0 < lat < 49.0 and -76.0 < lon < -70.5 class TestExercices: @pytest.fixture(scope="class") def ex(self): return cas.exercices() def test_ex1(self, ex): e = ex["ex1"] assert e["qc_menages"] == 2053295 and e["qc_prop"] == 683170 assert e["qc_part"] == pytest.approx(0.548, abs=5e-4) and e["qc_taux_prop"] == pytest.approx(0.333, abs=5e-4) assert e["ca_part"] == pytest.approx(0.462, abs=5e-4) and e["ca_taux_prop"] == pytest.approx(0.395, abs=5e-4) def test_ex2(self, ex): e = ex["ex2"] assert e["sherbrooke"] == pytest.approx(4.35, abs=5e-3) and e["trois_rivieres"] == pytest.approx(3.60, abs=5e-3) assert e["sherbrooke_plus30"] == pytest.approx(5.65, abs=5e-3) and e["sherbrooke_plus30"] == pytest.approx(1.3 * e["sherbrooke"], rel=1e-12) def test_ex3(self, ex): e = ex["ex3"] assert e["loyer_2025"] == pytest.approx(1105.71, abs=0.005) and e["var_brute"] == pytest.approx(0.1057, abs=5e-5) assert e["var_meme"] == 0.06 and e["univers_2025"] == 21000 def test_ex4(self, ex): a, b = ex["ex4_620000"], ex["ex4_560000"] 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) assert b["base"] == pytest.approx(601800, abs=1e-9) and b["droit"] == pytest.approx(8664.5, abs=1e-9) assert b["taux_eff_prix"] == pytest.approx(0.01547, abs=5e-5) and b["taux_eff_base"] == pytest.approx(0.01440, abs=5e-5) def test_ex5(self, ex): e = ex["ex5"] assert e["tcac"] == pytest.approx(0.0146, abs=5e-5) and e["pop_2035"] == pytest.approx(1044650, abs=1) 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) def test_ex6(self, ex): e = ex["ex6"] assert e["prix_412"] == pytest.approx(540666, abs=0.5) and e["prix_619"] == pytest.approx(458582, abs=0.5) assert e["capital_412"] == pytest.approx(2400 / 2130.18 * 400000, rel=1e-4) assert e["elast_3"] == pytest.approx(-0.325, abs=5e-3) and e["elast_6"] == pytest.approx(-0.557, abs=5e-3) 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) # élasticité = taux × semi-élasticité (par unité de taux) assert e["elast_3"] == pytest.approx(0.03 * e["semi_3_par_point"] * 100, rel=2e-3) assert e["elast_6"] == pytest.approx(0.06 * e["semi_6_par_point"] * 100, rel=2e-3) assert abs(e["semi_3_par_point"]) > abs(e["semi_6_par_point"]) and abs(e["elast_3"]) < abs(e["elast_6"]) 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)