"""Tests du chapitre 25 — Évaluation du terrain : jeu synthétique de terrains (graine 20261031), modèle hédonique des terrains, comparaison directe (grille et réconciliation), allocation et prix implicite marginal (cas 1), extraction (cinq comparables du ch. 16), technique résiduelle (cas 3), lotissement actualisé, rente foncière, valeur retenue et exercices. Tolérances : 1 $ sur les montants recalculés ; 0,5 $ ou 0,05 % sur les valeurs arrondies citées ; 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 ch25_terrain as cas from traite import finance as fin @pytest.fixture(scope="module") def S(): return cas.synthese() class TestJeuTerrains: def test_reproductibilite(self, S): df = S["comparaison"]["terrains"] regen = cas.generer_terrains() assert len(df) == 200 and len(regen) == 200 assert int(df["desservi"].sum()) == 156 assert df["prix_vente"].sum() == pytest.approx(52_123_500.0, abs=0.5) assert np.allclose(df["prix_vente"].values, regen["prix_vente"].values) assert df.iloc[0]["id_vente"] == "T001" and df.iloc[0]["prix_vente"] == 180_500.0 assert (df["prix_vente"] % 500 == 0).all() def test_structure(self, S): df = S["comparaison"]["terrains"] assert df.groupby("secteur").size().to_dict() == {"Aylmer": 40, "Buckingham": 15, "Gatineau": 60, "Hull": 70, "Masson-Angers": 15} assert df["date_vente"].min() >= np.datetime64("2023-01-01") and df["date_vente"].max() <= np.datetime64("2026-08-31") # valeur unitaire décroissante avec la superficie, desservis plus chers au m² d1, d0 = df[df["desservi"] == 1], df[df["desservi"] == 0] assert d1["prix_m2"].median() > 2 * d0["prix_m2"].median() assert np.corrcoef(np.log(d1["superficie_m2"]), np.log(d1["prix_m2"]))[0, 1] < -0.5 class TestHedoniqueTerrains: def test_recuperation_des_lois(self, S): m = S["comparaison"]["modele"]; b = m["beta"] assert m["n"] == 200 and 0.85 < m["r2"] < 0.95 assert b["ln_T"] == pytest.approx(cas.ELAST_T, abs=0.07) assert b["non_desservi"] == pytest.approx(cas.B_NON_DESSERVI, abs=0.08) assert b["roc"] < b["pente"] < 0 and b["logements_sup"] > 0.08 assert b["sect_Aylmer"] > 0 > b["sect_Buckingham"] and b["mois"] > 0 assert b["ln_T"] == pytest.approx(0.5267, abs=5e-4) and b["mois"] == pytest.approx(0.0028, abs=5e-4) def test_prediction_sujet(self, S): assert S["comparaison"]["prediction_hedonique"] == pytest.approx(253_412, abs=1) class TestComparaison: def test_entonnoir_et_selection(self, S): sel = S["comparaison"]["selection"] assert sel["etapes"] == {"total": 200, "fenetre": 101, "rayon": 29, "desservi_unifamilial": 21, "superficie": 20} assert sel["comparables"]["id_vente"].tolist() == ["T059", "T004", "T030", "T038", "T049"] assert (sel["comparables"]["secteur"] == "Hull").all() and (sel["comparables"]["desservi"] == 1).all() def test_grille_identites(self, S): g = S["comparaison"]["grille"] for _, r in g.iterrows(): assert r["prix_ajuste"] == pytest.approx(r["prix_vente"] + sum(r[k] for k in cas.GROUPES), rel=1e-12) assert r["ajust_net_pct"] == pytest.approx((r["prix_ajuste"] - r["prix_vente"]) / r["prix_vente"], rel=1e-9) assert r["ajust_brut_pct"] >= abs(r["ajust_net_pct"]) - 1e-12 assert (g["localisation"] == 0).all() # tous les comparables sont dans le secteur du sujet assert g.set_index("id_vente").loc["T049", "forme"] == pytest.approx(9_046, abs=1) assert g["prix_ajuste"].round(0).tolist() == [265_383, 259_943, 274_166, 294_813, 232_086] def test_reconciliation(self, S): r = S["comparaison"]["reconciliation"] assert sum(r["poids"]) == pytest.approx(1.0, abs=1e-12) assert np.round(r["poids"], 3).tolist() == [0.402, 0.131, 0.217, 0.146, 0.104] assert r["valeur_ponderee"] == pytest.approx(267_388, abs=1) assert r["moyenne"] == pytest.approx(265_278, abs=1) and r["mediane"] == pytest.approx(265_383, abs=1) assert r["min"] == pytest.approx(232_086, abs=1) and r["max"] == pytest.approx(294_813, abs=1) assert r["etendue_pct"] == pytest.approx(0.235, abs=1e-3) assert S["comparaison"]["valeur_m2"] == pytest.approx(431.3, abs=0.1) class TestAllocationExtraction: def test_allocation(self, S): a = S["allocation"]; e = S["extraction"] 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) assert a["P_grille"] == pytest.approx(581_595, abs=1) and a["P_hed"] == pytest.approx(637_953, abs=1) assert a["sur_grille"] == pytest.approx(280_412, abs=1) and a["sur_hedonique"] == pytest.approx(307_584, abs=1) assert a["sur_grille"] == pytest.approx(a["ratio"] * a["P_grille"], rel=1e-12) def test_prix_implicite_marginal(self, S): a = S["allocation"] assert a["beta_ln_T"] == pytest.approx(0.0937, abs=5e-4) assert a["prix_marginal_m2"] == pytest.approx(96.4, abs=0.1) assert a["contribution_marginale"] == pytest.approx(59_750, abs=1) assert a["prix_marginal_m2"] * 620 == pytest.approx(a["contribution_marginale"], rel=1e-12) assert a["sujet_age_factor"] == pytest.approx(0.8852, abs=5e-4) and a["delta_marche_sujet"] == pytest.approx(0.208, abs=5e-4) def test_extraction_comparables(self, S): e = S["extraction"]["comparables"].set_index("id_vente") # identités : V_L = P − C_N(1 − δ) sous les deux conventions for k, r in e.iterrows(): assert r["V_L_agevie"] == pytest.approx(r["prix_vente"] - r["C_N"] * (1 - r["age"] / cas.VIE_ECONOMIQUE), rel=1e-12) assert r["V_L_marche"] == pytest.approx(r["prix_vente"] - r["C_N"] * (1 - r["delta_marche"]), rel=1e-9) assert r["V_L_marche"] == pytest.approx(r["P_neuf"] - r["C_N"], rel=1e-12) g = e.loc["G0477"] assert g["C_N"] == pytest.approx(391_729, abs=1) and g["delta_agevie"] == pytest.approx(0.40, abs=1e-9) assert g["V_L_agevie"] == pytest.approx(302_963, abs=1) and g["V_L_marche"] == pytest.approx(207_093, abs=1) assert g["P_neuf"] == pytest.approx(598_821, abs=1) and g["delta_marche"] == pytest.approx(0.155, abs=5e-4) 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) assert e.loc["G0575", "V_L_marche_m2"] == pytest.approx(618.7, abs=0.1) assert S["extraction"]["indication_marche"] == pytest.approx(242_545, abs=1) assert S["extraction"]["indication_agevie"] == pytest.approx(354_826, abs=1) assert S["extraction"]["V_L_hull_mediane"] == pytest.approx(243_421, abs=1) def test_cout_neuf_comparable(self): assert cas.cout_neuf_comparable(165, 3) == pytest.approx(cas.C_N_SUJET, rel=1e-12) assert cas.cout_neuf_comparable(146, 4) == pytest.approx(391_729, abs=1) class TestResiduelle: def test_cas_de_base(self, S): r = S["residuelle"] assert r["noi"] == pytest.approx(89_955.75, abs=0.01) and r["R"] == pytest.approx(0.04415, abs=1e-6) assert r["C_N"] == pytest.approx(2_375_000, abs=0.01) and r["C_B"] == pytest.approx(1_140_000, abs=0.01) assert r["R_B"] == pytest.approx(0.0625, abs=1e-12) and r["revenu_batiment"] == pytest.approx(71_250, abs=0.01) assert r["revenu_terrain"] == pytest.approx(18_705.75, abs=0.01) assert r["V_L"] == pytest.approx(498_820, abs=1) and r["V_L_m2"] == pytest.approx(674.1, abs=0.1) # identité de la bande d'investissement : RNE = R_L V_L + R_B C_B assert r["R_L"] * r["V_L"] + r["R_B"] * r["C_B"] == pytest.approx(r["noi"], rel=1e-12) assert r["somme"] == pytest.approx(1_638_820, abs=1) and r["ecart_vs_cap"] == pytest.approx(-0.196, abs=1e-3) assert r["V_cap"] == pytest.approx(2_037_503, abs=1) and r["V_cap"] - r["C_B"] == pytest.approx(897_503, abs=1) assert r["R_L_implicite"] == pytest.approx(0.0208, abs=5e-4) and r["part_terrain"] == pytest.approx(0.304, abs=5e-4) def test_sensibilite(self, S): s = cas.sensibilite_residuelle(S["cas3"]).set_index(["taux_depreciation", "n_restante", "R_L"])["V_L"] assert s.loc[(0.44, 30, 0.0425)] == pytest.approx(-256_531, abs=1) # valeur négative : la technique « casse » assert s.loc[(0.52, 40, 0.0425)] == pytest.approx(306_018, abs=1) assert s.loc[(0.52, 40, 0.0325)] == pytest.approx(750_946, abs=1) assert s.loc[(0.60, 50, 0.0325)] == pytest.approx(1_233_254, abs=1) 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) # effet de levier : −50 pb sur R_L (3,75 → 4,25 %) retranche 38,6 % à V_L assert s.loc[(0.52, 40, 0.0425)] / s.loc[(0.52, 40, 0.0375)] - 1 == pytest.approx(-0.386, abs=1e-3) class TestLotissement: def test_cas_de_base(self, S): L = S["lotissement"]; p = L["params"] assert p["n_lots"] * p["lot_m2"] + p["rue_m2"] + p["parc_m2"] == p["site_m2"] assert p["parc_m2"] / p["site_m2"] == pytest.approx(0.10, abs=1e-12) # plafond de l'art. 117.4 LAU assert L["prix_lot"] == pytest.approx(254_486, abs=1) and L["produit_brut"] == pytest.approx(3_053_830, abs=1) assert L["n_ans"] == 3 and len(L["flux"]) == 4 assert L["valeur_actualisee"] == pytest.approx(fin.van(L["flux"], p["taux"]), rel=1e-12) assert L["valeur_actualisee"] == pytest.approx(1_230_878, abs=1) and L["valeur_m2"] == pytest.approx(123.1, abs=0.1) assert L["valeur_statique"] == pytest.approx(1_579_602, abs=1) and L["valeur_statique_m2"] == pytest.approx(158.0, abs=0.1) assert L["ratio_terrain_brut_sur_produit"] == pytest.approx(0.403, abs=1e-3) d = L["detail"] assert d["revenus"].sum() == pytest.approx(L["produit_brut"], rel=1e-12) assert d.loc[1, "infrastructure"] == pytest.approx(780_000, abs=0.01) and d.loc[1, "profit"] == pytest.approx(152_692, abs=1) assert d["flux"].round(0).tolist() == [-90_000, 32_534, 816_534, 820_534] def test_sensibilite(self, S): sl = cas.sensibilite_lotissement(S["comparaison"]["modele"]) assert sl["absorption"][2] == pytest.approx(963_545, abs=1) and sl["absorption"][6] == pytest.approx(1_331_556, abs=1) assert sl["taux"][0.08] == pytest.approx(1_291_537, abs=1) and sl["taux"][0.15] == pytest.approx(1_095_222, abs=1) assert sl["prix"][-0.10] == pytest.approx(1_025_828, abs=1) and sl["prix"][0.10] == pytest.approx(1_435_927, abs=1) assert sl["infrastructure"][80_000] == pytest.approx(1_067_241, abs=1) and sl["profit"][0.20] == pytest.approx(1_104_304, abs=1) # effet de levier du prix : ±10 % sur les lots → ±16,7 % sur le site brut assert sl["prix"][0.10] / sl["base"] - 1 == pytest.approx(0.1666, abs=5e-4) class TestRenteEtSynthese: def test_rente(self, S): r = S["rente"] assert r["perpetuelle"] == pytest.approx(220_000, abs=0.01) and r["gordon"] == pytest.approx(366_667, abs=1) 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) assert r["gordon"] == pytest.approx(11_000 / (0.05 - 0.02), rel=1e-12) def test_rapprochement_ch24(self, S): c = S["ch24"]; rp = S["rapprochement"] 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) assert rp["ecart_comp_pct"] == pytest.approx(-0.035, abs=1e-3) and rp["ecart_alloc_pct"] == pytest.approx(0.012, abs=1e-3) 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) assert abs(rp["z_comp"]) < 2 and rp["z_comp"] == pytest.approx(0.60, abs=0.01) and rp["confirmee"] assert rp["mediane_trois"] == pytest.approx(277_000, abs=0.01) def test_valeur_retenue(self, S): assert S["valeur_retenue"] == 277_000 and S["valeur_retenue"] == S["ch24"]["retenue"] assert S["valeur_retenue"] / 620 == pytest.approx(446.8, abs=0.1) assert S["part_retenue_grille"] == pytest.approx(0.476, abs=5e-4) and S["part_retenue_hed"] == pytest.approx(0.434, abs=5e-4) # cohérence : la valeur retenue est encadrée par la comparaison directe et l'allocation sur la grille assert S["comparaison"]["reconciliation"]["valeur_ponderee"] < S["valeur_retenue"] < S["allocation"]["sur_grille"] class TestExercices: def test_ex1_unites(self): P, T, F = [240_000, 268_000, 227_500], [560, 720, 480], [17, 20, 15] pm = [p / t for p, t in zip(P, T)]; pf = [p / f for p, f in zip(P, F)] 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] assert float(np.median(pm)) * 620 == pytest.approx(265_714, abs=1) and float(np.median(pf)) * 20 == pytest.approx(282_353, abs=1) def test_ex2_allocation(self): assert 0.45 * 560_000 == pytest.approx(252_000) and 180_000 / 480_000 == pytest.approx(0.375) def test_ex3_extraction(self, S): g = S["extraction"]["comparables"].set_index("id_vente").loc["G0477"] assert g["V_L_agevie"] == pytest.approx(302_963, abs=1) and g["V_L_marche"] == pytest.approx(207_093, abs=1) 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) assert g["V_L_agevie_sujet"] == pytest.approx(354_826, abs=1) and g["V_L_marche_sujet"] == pytest.approx(242_545, abs=1) def test_ex4_residuelle(self, S): r0 = S["residuelle"]; r1 = cas.residuelle_cas3({"R_L": 0.0425}, S["cas3"]) 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) assert r1["V_L"] / r0["V_L"] - 1 == pytest.approx(-0.386, abs=1e-3) def test_ex5_superficie(self): assert 283_500 * (620 / 743.1) ** 0.5 == pytest.approx(258_956, abs=1) assert 283_500 / 743.1 * 620 == pytest.approx(236_536, abs=1) def test_ex6_lotissement(self, S): m = S["comparaison"]["modele"]; L = S["lotissement"] L2 = cas.lotissement(m, {"lots_par_an": 2, "taux": 0.12}) assert L2["n_ans"] == 6 and L2["valeur_actualisee"] == pytest.approx(877_093, abs=1) assert L2["valeur_actualisee"] / L["valeur_actualisee"] - 1 == pytest.approx(-0.287, abs=1e-3) perte_parc = 1.6 * L["prix_lot"] * (1 - 0.04 - 0.15) / 1.1 ** 3 assert perte_parc == pytest.approx(247_794, abs=1) def test_ex7_rente(self, S): r = cas.rente_fonciere(rente=11_000, R_L=0.05, g=0.02, n=40, valeur_reversion=300_000) assert r["bail_n"] == pytest.approx(294_278, abs=1) 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)