"""Chapitre 35 — Indices immobiliers et ventes répétées : identités et valeurs citées dans le texte, les tableaux et les solutions. Modèle de référence : traite.cas1 ; estimateurs : traite.hedonique (indice_dummies_temps, ventes_repetees_bmn, ventes_repetees_case_shiller). Tolérances déclarées dans chaque assertion.""" import sys, pathlib, math import numpy as np, pytest sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) import ch35_indices as c35 from traite import hedonique as h @pytest.fixture(scope="module") def res(): return c35.main(verbose=False) def test_verite_et_donnees(res): assert len(res["ventes"]) == 600 and res["naifs"]["n"].sum() == 600 and list(res["naifs"]["n"]) == [39, 39, 42, 40, 50, 44, 51, 39, 32, 43, 50, 30, 33, 46, 22] assert c35.BETA_VRAI == pytest.approx(math.log(1 + ((1 + 0.0906) ** 0.5 - 1)) / 12, rel=1e-9) assert (math.exp(12 * c35.BETA_VRAI) - 1) * 100 == pytest.approx(4.43, abs=0.005) assert res["vrai"][0] == 100 and res["vrai"][-1] == pytest.approx(116.37, abs=0.005) def test_indice_hedonique_identites(res): td = res["td"] assert td["k"] == 29 and td["sigma"] == pytest.approx(0.0830, abs=0.0005) and td["r2_ajuste"] == pytest.approx(0.849, abs=0.001) assert td["indice"][0] == 100.0 and np.all(td["bas"] <= td["indice"]) and np.all(td["indice"] <= td["haut"]) # exp(τ_t) = indice / 100 assert np.allclose(td["indice"], 100 * np.exp(td["tau"])) assert td["indice"][-1] == pytest.approx(122.04, abs=0.005) and td["indice"][9] == pytest.approx(112.13, abs=0.005) assert res["precision"]["hedonique"]["largeur_ic_finale_pct"] == pytest.approx(7.47, abs=0.01) # imputation géométrique (Laspeyres = Paasche = Fisher) = indice à indicatrices quand les coefficients sont communs imp = res["imputation"] assert imp["ecart_max_vs_td"] < 1e-9 and np.allclose(imp["laspeyres"], imp["paasche"], atol=1e-9) # chaînage : I_t = I_{t-1} exp(δ_t) ch = res["chaine"]; assert np.allclose(ch["indice"], 100 * np.exp(np.cumsum(ch["delta"]))) and ch["indice"][-1] == pytest.approx(121.69, abs=0.005) def test_mediane_et_composition(res): n = res["naifs"] assert n["indice_mediane"].iloc[-1] == pytest.approx(128.63, abs=0.005) and n["indice_mediane"].max() == pytest.approx(133.68, abs=0.005) assert n["indice_moy_geo"].iloc[-1] == pytest.approx(127.78, abs=0.005) # décomposition exacte : ln(moyenne géométrique)_t − ln(…)_0 = composition_t + τ_t (les résidus MCO somment à zéro par trimestre) lhs = np.log(n["moy_geo"].to_numpy() / n["moy_geo"].iloc[0]); rhs = n["composition_log"].to_numpy() + res["td"]["tau"] assert np.allclose(lhs, rhs, atol=1e-9) assert np.allclose(n["indice_moy_geo_corrigee"].to_numpy(), res["td"]["indice"], atol=1e-6) assert res["corr_ecart_composition"] == pytest.approx(0.86, abs=0.01) assert n["composition_log"].iloc[12] == pytest.approx(0.101, abs=0.001) and n["part_aylmer"].iloc[-1] == pytest.approx(0.364, abs=0.001) p = res["precision"] assert p["mediane"]["EAM_pct"] == pytest.approx(10.97, abs=0.01) and p["mediane"]["croissance_totale_pct"] == pytest.approx(28.63, abs=0.01) assert p["hedonique"]["EAM_pct"] == pytest.approx(2.81, abs=0.01) and p["hedonique"]["croissance_totale_pct"] == pytest.approx(22.04, abs=0.01) def test_reventes_synthetiques(res): p = res["paires"]; r = res["resume_paires"] assert r["n_paires"] == 220 and r["n_flips"] == 47 and r["intervalle_median"] == 10.0 and r["intervalle_min"] == 3 and r["intervalle_max"] == 38 assert (p["date_2"] <= c35.FIN_FENETRE).all() and (p["t_2"] > p["t_1"]).all() and r["meme_trimestre"] == 0 assert (p["prix_2"] % 500 == 0).all() assert r["part_moins_12"] == pytest.approx(0.559, abs=0.001) and r["sd_ln_ratio"] == pytest.approx(0.144, abs=0.001) # reproductibilité (graine fixée) p2 = c35.generer_reventes(res["ventes"]); assert len(p2) == 220 and np.allclose(p2["prix_2"], p["prix_2"]) assert p[p["flip"]]["ln_ratio"].mean() == pytest.approx(0.0957, abs=0.0005) and p[~p["flip"]]["ln_ratio"].mean() == pytest.approx(0.0436, abs=0.0005) def test_bmn_identites(res): b = res["bmn"] D = b["D"]; assert D.shape == (220, 14) # chaque ligne de la matrice complète (période 0 comprise) somme à zéro : ici, −1 à t1 et +1 à t2, période 0 omise assert set(np.unique(D)) <= {-1.0, 0.0, 1.0} and np.all(np.abs(D).sum(1) <= 2) assert np.allclose(b["beta"], b["beta_ferme"], atol=1e-10) assert np.allclose(b["indice"], 100 * np.exp(np.concatenate([[0.0], b["beta"]]))) assert b["indice"][-1] == pytest.approx(120.68, abs=0.005) and b["indice"][9] == pytest.approx(113.42, abs=0.005) assert b["sigma"] == pytest.approx(0.1455, abs=0.0005) and b["se"][-1] == pytest.approx(0.0483, abs=0.0005) assert res["precision"]["bmn"]["EAM_pct"] == pytest.approx(3.02, abs=0.01) and res["precision"]["bmn"]["largeur_ic_finale_pct"] == pytest.approx(20.83, abs=0.01) def test_case_shiller(res): cs = res["cs"] assert cs["indice"][-1] == pytest.approx(120.48, abs=0.005) and res["precision"]["cs"]["EAM_pct"] == pytest.approx(2.90, abs=0.01) a = cs["coef_variance"]; assert a[0] == pytest.approx(0.0208, abs=0.0001) and a[1] == pytest.approx(-0.00023, abs=0.00001) # sans les reventes rénovées, la pente redevient positive (structure 2σ² + δ²Δ de la loi de génération) ao = res["sensibilite"]["filtre"]["sans les reventes rénovées (oracle)"]["cs_coef_variance"] assert ao[0] == pytest.approx(0.0157, abs=0.0001) and ao[1] == pytest.approx(0.00053, abs=0.00001) assert cs["variance_theorique"][0] == pytest.approx(2 * 0.085 ** 2) and cs["variance_theorique"][1] == pytest.approx(0.0012) # poids = 1/sqrt(var) ; var minorée assert np.allclose(cs["poids"], 1 / np.sqrt(np.maximum(a[0] + a[1] * cs["gap_trimestres"], 1e-8))) # CS ≈ BMN quand les poids sont presque uniformes assert np.max(np.abs(cs["indice"] - res["bmn"]["indice"])) < 0.5 def test_spar(res): sp = res["spar"]; v = res["ventes"] assert sp["indice_geo"][0] == 100 and sp["indice_arith"][0] == 100 assert sp["indice_geo"][-1] == pytest.approx(115.06, abs=0.005) and sp["indice_arith"][-1] == pytest.approx(114.36, abs=0.005) assert sp["ratio_median_global"] == pytest.approx(1.095, abs=0.001) and sp["sd_ln_ratio"] == pytest.approx(0.087, abs=0.001) assert res["precision"]["spar"]["EAM_pct"] == pytest.approx(1.26, abs=0.01) # identité arithmétique : ratio des sommes t0 = v[v["trim"] == 0]; assert sp["ratio_arith"][0] == pytest.approx(t0["prix_vente"].sum() / t0["valeur_role_2025"].sum()) def test_ajustement_et_sensibilite(res): a = res["ajustement"] assert a["prix"] == 506_500 and a["trimestre_vente"] == "2025T2" and a["mois_ecoules"] == pytest.approx(15.2, abs=0.05) assert a["hedonique"] == pytest.approx(44_760, abs=1) and a["bmn"] == pytest.approx(32_406, abs=1) and a["cs"] == pytest.approx(32_359, abs=1) assert a["mediane"] == pytest.approx(44_009, abs=1) and a["spar"] == pytest.approx(26_575, abs=1) and a["tendance"] == pytest.approx(28_733, abs=1) assert a["vrai"] == pytest.approx(28_558, abs=1) and a["chaine"] == pytest.approx(30_987, abs=1) s = res["sensibilite"] r = s["revisions"]["2025T2"]; assert r["bmn_v2025T2"] == pytest.approx(116.39, abs=0.005) and r["bmn_v2025T4"] == pytest.approx(114.69, abs=0.005) and r["bmn_v2026T3"] == pytest.approx(113.42, abs=0.005) r4 = s["revisions"]["2024T4"]; assert r4["bmn_v2025T2"] == pytest.approx(114.45, abs=0.005) and r4["bmn_v2026T3"] == pytest.approx(109.99, abs=0.005) f = s["filtre"] assert f["toutes"]["n"] == 220 and f["intervalle ≥ 12 mois"]["n"] == 97 and f["intervalle ≥ 18 mois"]["n"] == 45 and f["sans les reventes rénovées (oracle)"]["n"] == 173 assert f["toutes"]["bmn"]["croissance_totale_pct"] == pytest.approx(20.68, abs=0.01) and f["intervalle ≥ 12 mois"]["bmn"]["croissance_totale_pct"] == pytest.approx(17.98, abs=0.01) assert f["sans les reventes rénovées (oracle)"]["bmn"]["croissance_totale_pct"] == pytest.approx(18.39, abs=0.01) and f["sans les reventes rénovées (oracle)"]["bmn"]["EAM_pct"] == pytest.approx(2.01, abs=0.01) assert f["intervalle ≥ 12 mois"]["bmn"]["EAM_pct"] == pytest.approx(2.76, abs=0.01) and f["intervalle ≥ 18 mois"]["bmn"]["EAM_pct"] == pytest.approx(6.56, abs=0.01) t = s["taille"]; assert t[0.5]["EAM_moyen_pct"] == pytest.approx(4.26, abs=0.01) and t[0.25]["EAM_moyen_pct"] == pytest.approx(6.46, abs=0.01) assert t[0.5]["sd_indice_final"] == pytest.approx(4.77, abs=0.01) and t[0.25]["sd_indice_final"] == pytest.approx(8.96, abs=0.01) assert s["fenetre"]["n"] == 295 and s["fenetre"]["ecart_max_pct"] == pytest.approx(0.37, abs=0.01) def test_solutions_exercices(res): e = res["exercices"] assert e["1"]["var_mediane_pct"] == pytest.approx(6.67, abs=0.01) and e["1"]["composition_pct"] == pytest.approx(3.79, abs=0.01) and e["1"]["variation_pure_pct"] == pytest.approx(2.77, abs=0.01) assert np.allclose(e["2"]["indice"], [100, 103.15, 105.97, 110.74], atol=0.01) and e["2"]["variation_t3_t2_pct"] == pytest.approx(4.50, abs=0.01) assert e["2"]["ic_t3"][0] == pytest.approx(106.27, abs=0.01) and e["2"]["ic_t3"][1] == pytest.approx(115.39, abs=0.01) assert np.allclose(e["3"]["DtD"], [[3, -1], [-1, 2]]) and np.allclose(e["3"]["Dty"], [0.0288, 0.1726], atol=0.0001) assert np.allclose(e["3"]["beta"], [0.0461, 0.1093], atol=0.0001) and np.allclose(e["3"]["indice"], [100, 104.71, 111.55], atol=0.01) assert abs(e["3"]["residus"] @ np.array([[1, 0], [-1, 1], [0, 1], [1, 0]])).max() < 1e-12 # orthogonalité des résidus aux colonnes de D assert np.allclose(e["4"]["poids_rel"], [1, 0.902, 0.807, 0.737], atol=0.001) and np.allclose(e["4"]["sd_pct"], [12.53, 13.89, 15.52, 17.00], atol=0.01) assert e["5"]["ratio_arith"] == pytest.approx(1.0802, abs=0.0001) and e["5"]["ratio_geo"] == pytest.approx(1.0781, abs=0.0001) assert e["5"]["spar_arith"] == pytest.approx(103.36, abs=0.01) and e["5"]["spar_geo"] == pytest.approx(103.16, abs=0.01) assert e["6"]["ajustements"]["hedonique"] == pytest.approx(44_760, abs=1) and e["6"]["ajustements"]["bmn"] == pytest.approx(32_406, abs=1) assert e["6"]["ajustements"]["mediane"] == pytest.approx(44_009, abs=1) and e["6"]["tendance"] == pytest.approx(28_774, abs=1) def test_bibliotheque_bmn_cas_simple(): """Deux périodes, deux paires : l'indice BMN est la moyenne géométrique des ratios de prix (forme fermée).""" r = h.ventes_repetees_bmn([100, 200], [110, 230], [0, 0], [1, 1], 2) assert r["indice"][1] == pytest.approx(100 * math.sqrt(1.10 * 1.15), rel=1e-12) idx = h.indice_dummies_temps([0.05, 0.10]); assert np.allclose(idx, [100, 100 * math.exp(0.05), 100 * math.exp(0.10)])