spb/traite-evaluation-qc
Public
1"""Tests du chapitre 25 — Évaluation du terrain : jeu synthétique de terrains (graine 20261031), modèle hédonique des terrains,2comparaison directe (grille et réconciliation), allocation et prix implicite marginal (cas 1), extraction (cinq comparables du ch. 16),3technique résiduelle (cas 3), lotissement actualisé, rente foncière, valeur retenue et exercices.4Tolérances : 1 $ sur les montants recalculés ; 0,5 $ ou 0,05 % sur les valeurs arrondies citées ; 1e-9 sur les identités."""5import sys, pathlib, math6import numpy as np, pytest7sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))8import ch25_terrain as cas9from traite import finance as fin101112@pytest.fixture(scope="module")13def S():14 return cas.synthese()151617class TestJeuTerrains:18 def test_reproductibilite(self, S):19 df = S["comparaison"]["terrains"]20 regen = cas.generer_terrains()21 assert len(df) == 200 and len(regen) == 20022 assert int(df["desservi"].sum()) == 15623 assert df["prix_vente"].sum() == pytest.approx(52_123_500.0, abs=0.5)24 assert np.allclose(df["prix_vente"].values, regen["prix_vente"].values)25 assert df.iloc[0]["id_vente"] == "T001" and df.iloc[0]["prix_vente"] == 180_500.026 assert (df["prix_vente"] % 500 == 0).all()2728 def test_structure(self, S):29 df = S["comparaison"]["terrains"]30 assert df.groupby("secteur").size().to_dict() == {"Aylmer": 40, "Buckingham": 15, "Gatineau": 60, "Hull": 70, "Masson-Angers": 15}31 assert df["date_vente"].min() >= np.datetime64("2023-01-01") and df["date_vente"].max() <= np.datetime64("2026-08-31")32 # valeur unitaire décroissante avec la superficie, desservis plus chers au m²33 d1, d0 = df[df["desservi"] == 1], df[df["desservi"] == 0]34 assert d1["prix_m2"].median() > 2 * d0["prix_m2"].median()35 assert np.corrcoef(np.log(d1["superficie_m2"]), np.log(d1["prix_m2"]))[0, 1] < -0.5363738class TestHedoniqueTerrains:39 def test_recuperation_des_lois(self, S):40 m = S["comparaison"]["modele"]; b = m["beta"]41 assert m["n"] == 200 and 0.85 < m["r2"] < 0.9542 assert b["ln_T"] == pytest.approx(cas.ELAST_T, abs=0.07)43 assert b["non_desservi"] == pytest.approx(cas.B_NON_DESSERVI, abs=0.08)44 assert b["roc"] < b["pente"] < 0 and b["logements_sup"] > 0.0845 assert b["sect_Aylmer"] > 0 > b["sect_Buckingham"] and b["mois"] > 046 assert b["ln_T"] == pytest.approx(0.5267, abs=5e-4) and b["mois"] == pytest.approx(0.0028, abs=5e-4)4748 def test_prediction_sujet(self, S):49 assert S["comparaison"]["prediction_hedonique"] == pytest.approx(253_412, abs=1)505152class TestComparaison:53 def test_entonnoir_et_selection(self, S):54 sel = S["comparaison"]["selection"]55 assert sel["etapes"] == {"total": 200, "fenetre": 101, "rayon": 29, "desservi_unifamilial": 21, "superficie": 20}56 assert sel["comparables"]["id_vente"].tolist() == ["T059", "T004", "T030", "T038", "T049"]57 assert (sel["comparables"]["secteur"] == "Hull").all() and (sel["comparables"]["desservi"] == 1).all()5859 def test_grille_identites(self, S):60 g = S["comparaison"]["grille"]61 for _, r in g.iterrows():62 assert r["prix_ajuste"] == pytest.approx(r["prix_vente"] + sum(r[k] for k in cas.GROUPES), rel=1e-12)63 assert r["ajust_net_pct"] == pytest.approx((r["prix_ajuste"] - r["prix_vente"]) / r["prix_vente"], rel=1e-9)64 assert r["ajust_brut_pct"] >= abs(r["ajust_net_pct"]) - 1e-1265 assert (g["localisation"] == 0).all() # tous les comparables sont dans le secteur du sujet66 assert g.set_index("id_vente").loc["T049", "forme"] == pytest.approx(9_046, abs=1)67 assert g["prix_ajuste"].round(0).tolist() == [265_383, 259_943, 274_166, 294_813, 232_086]6869 def test_reconciliation(self, S):70 r = S["comparaison"]["reconciliation"]71 assert sum(r["poids"]) == pytest.approx(1.0, abs=1e-12)72 assert np.round(r["poids"], 3).tolist() == [0.402, 0.131, 0.217, 0.146, 0.104]73 assert r["valeur_ponderee"] == pytest.approx(267_388, abs=1)74 assert r["moyenne"] == pytest.approx(265_278, abs=1) and r["mediane"] == pytest.approx(265_383, abs=1)75 assert r["min"] == pytest.approx(232_086, abs=1) and r["max"] == pytest.approx(294_813, abs=1)76 assert r["etendue_pct"] == pytest.approx(0.235, abs=1e-3)77 assert S["comparaison"]["valeur_m2"] == pytest.approx(431.3, abs=0.1)787980class TestAllocationExtraction:81 def test_allocation(self, S):82 a = S["allocation"]; e = S["extraction"]83 assert a["ratio"] == pytest.approx(0.482, abs=5e-4) and e["part_hull_q1"] == pytest.approx(0.424, abs=5e-4) and e["part_hull_q3"] == pytest.approx(0.543, abs=5e-4)84 assert a["P_grille"] == pytest.approx(581_595, abs=1) and a["P_hed"] == pytest.approx(637_953, abs=1)85 assert a["sur_grille"] == pytest.approx(280_412, abs=1) and a["sur_hedonique"] == pytest.approx(307_584, abs=1)86 assert a["sur_grille"] == pytest.approx(a["ratio"] * a["P_grille"], rel=1e-12)8788 def test_prix_implicite_marginal(self, S):89 a = S["allocation"]90 assert a["beta_ln_T"] == pytest.approx(0.0937, abs=5e-4)91 assert a["prix_marginal_m2"] == pytest.approx(96.4, abs=0.1)92 assert a["contribution_marginale"] == pytest.approx(59_750, abs=1)93 assert a["prix_marginal_m2"] * 620 == pytest.approx(a["contribution_marginale"], rel=1e-12)94 assert a["sujet_age_factor"] == pytest.approx(0.8852, abs=5e-4) and a["delta_marche_sujet"] == pytest.approx(0.208, abs=5e-4)9596 def test_extraction_comparables(self, S):97 e = S["extraction"]["comparables"].set_index("id_vente")98 # identités : V_L = P − C_N(1 − δ) sous les deux conventions99 for k, r in e.iterrows():100 assert r["V_L_agevie"] == pytest.approx(r["prix_vente"] - r["C_N"] * (1 - r["age"] / cas.VIE_ECONOMIQUE), rel=1e-12)101 assert r["V_L_marche"] == pytest.approx(r["prix_vente"] - r["C_N"] * (1 - r["delta_marche"]), rel=1e-9)102 assert r["V_L_marche"] == pytest.approx(r["P_neuf"] - r["C_N"], rel=1e-12)103 g = e.loc["G0477"]104 assert g["C_N"] == pytest.approx(391_729, abs=1) and g["delta_agevie"] == pytest.approx(0.40, abs=1e-9)105 assert g["V_L_agevie"] == pytest.approx(302_963, abs=1) and g["V_L_marche"] == pytest.approx(207_093, abs=1)106 assert g["P_neuf"] == pytest.approx(598_821, abs=1) and g["delta_marche"] == pytest.approx(0.155, abs=5e-4)107 assert e.loc["G0574", "V_L_marche"] == pytest.approx(281_458, abs=1) and e.loc["G0404", "V_L_marche"] == pytest.approx(174_883, abs=1)108 assert e.loc["G0575", "V_L_marche_m2"] == pytest.approx(618.7, abs=0.1)109 assert S["extraction"]["indication_marche"] == pytest.approx(242_545, abs=1)110 assert S["extraction"]["indication_agevie"] == pytest.approx(354_826, abs=1)111 assert S["extraction"]["V_L_hull_mediane"] == pytest.approx(243_421, abs=1)112113 def test_cout_neuf_comparable(self):114 assert cas.cout_neuf_comparable(165, 3) == pytest.approx(cas.C_N_SUJET, rel=1e-12)115 assert cas.cout_neuf_comparable(146, 4) == pytest.approx(391_729, abs=1)116117118class TestResiduelle:119 def test_cas_de_base(self, S):120 r = S["residuelle"]121 assert r["noi"] == pytest.approx(89_955.75, abs=0.01) and r["R"] == pytest.approx(0.04415, abs=1e-6)122 assert r["C_N"] == pytest.approx(2_375_000, abs=0.01) and r["C_B"] == pytest.approx(1_140_000, abs=0.01)123 assert r["R_B"] == pytest.approx(0.0625, abs=1e-12) and r["revenu_batiment"] == pytest.approx(71_250, abs=0.01)124 assert r["revenu_terrain"] == pytest.approx(18_705.75, abs=0.01)125 assert r["V_L"] == pytest.approx(498_820, abs=1) and r["V_L_m2"] == pytest.approx(674.1, abs=0.1)126 # identité de la bande d'investissement : RNE = R_L V_L + R_B C_B127 assert r["R_L"] * r["V_L"] + r["R_B"] * r["C_B"] == pytest.approx(r["noi"], rel=1e-12)128 assert r["somme"] == pytest.approx(1_638_820, abs=1) and r["ecart_vs_cap"] == pytest.approx(-0.196, abs=1e-3)129 assert r["V_cap"] == pytest.approx(2_037_503, abs=1) and r["V_cap"] - r["C_B"] == pytest.approx(897_503, abs=1)130 assert r["R_L_implicite"] == pytest.approx(0.0208, abs=5e-4) and r["part_terrain"] == pytest.approx(0.304, abs=5e-4)131132 def test_sensibilite(self, S):133 s = cas.sensibilite_residuelle(S["cas3"]).set_index(["taux_depreciation", "n_restante", "R_L"])["V_L"]134 assert s.loc[(0.44, 30, 0.0425)] == pytest.approx(-256_531, abs=1) # valeur négative : la technique « casse »135 assert s.loc[(0.52, 40, 0.0425)] == pytest.approx(306_018, abs=1)136 assert s.loc[(0.52, 40, 0.0325)] == pytest.approx(750_946, abs=1)137 assert s.loc[(0.60, 50, 0.0325)] == pytest.approx(1_233_254, abs=1)138 assert s.loc[(0.52, 30, 0.0375)] == pytest.approx(245_487, abs=1) and s.loc[(0.52, 50, 0.0375)] == pytest.approx(650_820, abs=1)139 # effet de levier : −50 pb sur R_L (3,75 → 4,25 %) retranche 38,6 % à V_L140 assert s.loc[(0.52, 40, 0.0425)] / s.loc[(0.52, 40, 0.0375)] - 1 == pytest.approx(-0.386, abs=1e-3)141142143class TestLotissement:144 def test_cas_de_base(self, S):145 L = S["lotissement"]; p = L["params"]146 assert p["n_lots"] * p["lot_m2"] + p["rue_m2"] + p["parc_m2"] == p["site_m2"]147 assert p["parc_m2"] / p["site_m2"] == pytest.approx(0.10, abs=1e-12) # plafond de l'art. 117.4 LAU148 assert L["prix_lot"] == pytest.approx(254_486, abs=1) and L["produit_brut"] == pytest.approx(3_053_830, abs=1)149 assert L["n_ans"] == 3 and len(L["flux"]) == 4150 assert L["valeur_actualisee"] == pytest.approx(fin.van(L["flux"], p["taux"]), rel=1e-12)151 assert L["valeur_actualisee"] == pytest.approx(1_230_878, abs=1) and L["valeur_m2"] == pytest.approx(123.1, abs=0.1)152 assert L["valeur_statique"] == pytest.approx(1_579_602, abs=1) and L["valeur_statique_m2"] == pytest.approx(158.0, abs=0.1)153 assert L["ratio_terrain_brut_sur_produit"] == pytest.approx(0.403, abs=1e-3)154 d = L["detail"]155 assert d["revenus"].sum() == pytest.approx(L["produit_brut"], rel=1e-12)156 assert d.loc[1, "infrastructure"] == pytest.approx(780_000, abs=0.01) and d.loc[1, "profit"] == pytest.approx(152_692, abs=1)157 assert d["flux"].round(0).tolist() == [-90_000, 32_534, 816_534, 820_534]158159 def test_sensibilite(self, S):160 sl = cas.sensibilite_lotissement(S["comparaison"]["modele"])161 assert sl["absorption"][2] == pytest.approx(963_545, abs=1) and sl["absorption"][6] == pytest.approx(1_331_556, abs=1)162 assert sl["taux"][0.08] == pytest.approx(1_291_537, abs=1) and sl["taux"][0.15] == pytest.approx(1_095_222, abs=1)163 assert sl["prix"][-0.10] == pytest.approx(1_025_828, abs=1) and sl["prix"][0.10] == pytest.approx(1_435_927, abs=1)164 assert sl["infrastructure"][80_000] == pytest.approx(1_067_241, abs=1) and sl["profit"][0.20] == pytest.approx(1_104_304, abs=1)165 # effet de levier du prix : ±10 % sur les lots → ±16,7 % sur le site brut166 assert sl["prix"][0.10] / sl["base"] - 1 == pytest.approx(0.1666, abs=5e-4)167168169class TestRenteEtSynthese:170 def test_rente(self, S):171 r = S["rente"]172 assert r["perpetuelle"] == pytest.approx(220_000, abs=0.01) and r["gordon"] == pytest.approx(366_667, abs=1)173 assert r["bail_n"] == pytest.approx(294_278, abs=1) and r["va_reversion"] == pytest.approx(42_614, abs=1) and r["va_rentes"] == pytest.approx(251_664, abs=1)174 assert r["gordon"] == pytest.approx(11_000 / (0.05 - 0.02), rel=1e-12)175176 def test_rapprochement_ch24(self, S):177 c = S["ch24"]; rp = S["rapprochement"]178 assert c["n"] == 13 and c["retenue"] == 277_000 and c["mediane"] == pytest.approx(276_954, abs=1) and c["part"] == pytest.approx(0.505, abs=5e-4)179 assert rp["ecart_comp_pct"] == pytest.approx(-0.035, abs=1e-3) and rp["ecart_alloc_pct"] == pytest.approx(0.012, abs=1e-3)180 assert rp["ecart_extr_marche_pct"] == pytest.approx(-0.124, abs=1e-3) and rp["ecart_hed_pct"] == pytest.approx(-0.085, abs=1e-3)181 assert abs(rp["z_comp"]) < 2 and rp["z_comp"] == pytest.approx(0.60, abs=0.01) and rp["confirmee"]182 assert rp["mediane_trois"] == pytest.approx(277_000, abs=0.01)183184 def test_valeur_retenue(self, S):185 assert S["valeur_retenue"] == 277_000 and S["valeur_retenue"] == S["ch24"]["retenue"]186 assert S["valeur_retenue"] / 620 == pytest.approx(446.8, abs=0.1)187 assert S["part_retenue_grille"] == pytest.approx(0.476, abs=5e-4) and S["part_retenue_hed"] == pytest.approx(0.434, abs=5e-4)188 # cohérence : la valeur retenue est encadrée par la comparaison directe et l'allocation sur la grille189 assert S["comparaison"]["reconciliation"]["valeur_ponderee"] < S["valeur_retenue"] < S["allocation"]["sur_grille"]190191192class TestExercices:193 def test_ex1_unites(self):194 P, T, F = [240_000, 268_000, 227_500], [560, 720, 480], [17, 20, 15]195 pm = [p / t for p, t in zip(P, T)]; pf = [p / f for p, f in zip(P, F)]196 assert [round(x, 1) for x in pm] == [428.6, 372.2, 474.0] and [round(x) for x in pf] == [14_118, 13_400, 15_167]197 assert float(np.median(pm)) * 620 == pytest.approx(265_714, abs=1) and float(np.median(pf)) * 20 == pytest.approx(282_353, abs=1)198199 def test_ex2_allocation(self):200 assert 0.45 * 560_000 == pytest.approx(252_000) and 180_000 / 480_000 == pytest.approx(0.375)201202 def test_ex3_extraction(self, S):203 g = S["extraction"]["comparables"].set_index("id_vente").loc["G0477"]204 assert g["V_L_agevie"] == pytest.approx(302_963, abs=1) and g["V_L_marche"] == pytest.approx(207_093, abs=1)205 assert g["V_L_agevie"] / 452 == pytest.approx(670.3, abs=0.1) and g["V_L_marche"] / 452 == pytest.approx(458.2, abs=0.1)206 assert g["V_L_agevie_sujet"] == pytest.approx(354_826, abs=1) and g["V_L_marche_sujet"] == pytest.approx(242_545, abs=1)207208 def test_ex4_residuelle(self, S):209 r0 = S["residuelle"]; r1 = cas.residuelle_cas3({"R_L": 0.0425}, S["cas3"])210 assert r0["V_L"] == pytest.approx(498_820, abs=1) and r1["V_L"] == pytest.approx(306_018, abs=1) and r1["R_B"] == pytest.approx(0.0675, abs=1e-12)211 assert r1["V_L"] / r0["V_L"] - 1 == pytest.approx(-0.386, abs=1e-3)212213 def test_ex5_superficie(self):214 assert 283_500 * (620 / 743.1) ** 0.5 == pytest.approx(258_956, abs=1)215 assert 283_500 / 743.1 * 620 == pytest.approx(236_536, abs=1)216217 def test_ex6_lotissement(self, S):218 m = S["comparaison"]["modele"]; L = S["lotissement"]219 L2 = cas.lotissement(m, {"lots_par_an": 2, "taux": 0.12})220 assert L2["n_ans"] == 6 and L2["valeur_actualisee"] == pytest.approx(877_093, abs=1)221 assert L2["valeur_actualisee"] / L["valeur_actualisee"] - 1 == pytest.approx(-0.287, abs=1e-3)222 perte_parc = 1.6 * L["prix_lot"] * (1 - 0.04 - 0.15) / 1.1 ** 3223 assert perte_parc == pytest.approx(247_794, abs=1)224225 def test_ex7_rente(self, S):226 r = cas.rente_fonciere(rente=11_000, R_L=0.05, g=0.02, n=40, valeur_reversion=300_000)227 assert r["bail_n"] == pytest.approx(294_278, abs=1)228 assert cas.rente_fonciere(g=0.0, n=40)["va_rentes"] == pytest.approx(11_000 * fin.facteur_va_annuite(0.05, 40), rel=1e-9)229