"""Tests du chapitre 48 (cas 4, portefeuille interrégional — données synthétiques) : identités du sommaire, cohérence du modèle de masse avec traite.cas4.effets_regionaux et avec le ch. 33 (P45), identités financières (RCD, soldes), propriétés de la simulation (reproductibilité, diversification : VaR portefeuille ≤ somme des VaR, cas parfaitement corrélé ≈ additif, cas indépendant très inférieur), frontière, valeurs citées. Tolérances déclarées : montants ±1 $ (valeurs citées au dollar), taux ±1e-6, Monte-Carlo ±1 $ (même graine).""" import sys, pathlib import numpy as np, pytest sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) from traite import cas4, finance as fin import ch48_portefeuille as ch @pytest.fixture(scope="module") def D(): return ch.dossier(n_mc=10_000, graine=ch.GRAINE) def test_sommaire_identites(D): s = D["sommaire"]; g = s["par_rmr"]; t = s["total"]; pf = D["portefeuille"] assert t["n"] == 48 and t["logements"] == 888 and g["n"].sum() == 48 and g["logements"].sum() == 888 assert t["valeur"] == pytest.approx(143_363_000) and t["rne"] == pytest.approx(7_447_030) and t["dette"] == pytest.approx(85_766_000) assert t["taux_pondere"] == pytest.approx(t["rne"] / t["valeur"]) and abs(t["taux_pondere"] - 0.0519) < 5e-4 assert t["rpv"] == pytest.approx(0.5982, abs=5e-4) and t["part_zone_inondable"] == pytest.approx(0.0666, abs=5e-4) assert (g["valeur"] / g["logements"]).loc["Montréal"] == pytest.approx(206_237.34, abs=0.01) assert g["part_valeur"].sum() == pytest.approx(1.0) and g["valeur"].sum() == pytest.approx(t["valeur"]) # taux pondéré ≠ moyenne simple des taux (Sherbrooke, exercice 1) e1 = D["exercices"]["ex1"]; assert e1["taux_pondere"] != pytest.approx(e1["taux_moyen_simple"], abs=1e-4) assert t["valeur_par_logement"] == pytest.approx(161_444.82, abs=0.01) def test_modele_de_masse_coherent_avec_cas4_et_ch33(D): reg = D["regression"]; eff = cas4.effets_regionaux(D["portefeuille"]) assert np.allclose(reg["beta"], eff["coef"].values) assert reg["table"].loc["rmr_Gatineau", "effet_pct"] == pytest.approx(0.1962, abs=5e-4) # +19,6 % (ch. 33) assert reg["table"].loc["ln_logements", "coef"] == pytest.approx(-0.0315, abs=5e-4) # ln n −0,0315 (ch. 33) assert reg["duan"] == pytest.approx(1.0011, abs=5e-5) # facteur de Duan (ch. 33) # P45 : taux de régression 5,461 % → 6 716 534 $ (ch. 33) R = ch.taux_predit(reg, "Gatineau", 2, 36, 72) assert R == pytest.approx(0.05461, abs=5e-6) and 366_800 / R == pytest.approx(6_716_534, abs=1) t = D["predites"]; assert t.set_index("id_immeuble").loc["P45", "valeur_predite"] == pytest.approx(6_716_534, abs=1) # prédiction = exp(Xβ) × Duan pour tous les immeubles ; somme prédite vs individuelle assert np.allclose(t["valeur_predite"], t["rne_2026"] / (np.exp(reg["X"] @ reg["beta"]) * reg["duan"])) assert D["iaao"]["ecart_agrege_pct"] == pytest.approx(-0.0055, abs=5e-4) ia = D["iaao"]["portefeuille"]; assert ia["n"] == 48 and 0.99 < ia["ratio_median"] < 1.01 and ia["COD"] < 5 and 0.98 < ia["PRD"] < 1.03 and abs(ia["PRB"]) < 0.05 def test_dynamique_rne(D): dyn = D["dynamique"]; pf = D["portefeuille"] assert len(dyn["par_immeuble"]) == 48 and dyn["total"]["rne_2026T2_annualise"] == pytest.approx(pf["rne_2026"].sum(), abs=10) assert 0.025 < dyn["total"]["croissance_agregee"] < 0.035 assert dyn["par_rmr"].loc["Gatineau", "croissance_agregee"] > dyn["par_rmr"].loc["Saguenay", "croissance_agregee"] assert -0.05 < dyn["pandemie"]["Portefeuille"] < 0 # choc négatif c = dyn["correlations"]; assert 0.1 < c["inter_rmr_moyenne"] < 0.25 and abs(c["inter_hors_pandemie"]) < 0.05 # corrélation due au choc commun def test_dette_identites(D): det = D["dette"]; d = det["par_immeuble"]; pf = D["portefeuille"].set_index("id_immeuble") r = d.set_index("id_immeuble").loc["P45"]; hyp = fin.Hypotheque(5_416_000, 0.0517, 480) assert r["service_dette"] == pytest.approx(hyp.service_dette_annuel) and r["rcd"] == pytest.approx(366_800 / hyp.service_dette_annuel) assert r["rcd"] == pytest.approx(1.15, abs=5e-3) # ch. 33 : RCD 1,15 sur 40 ans assert D["dette_alt"]["par_immeuble"].set_index("id_immeuble").loc["P45", "rcd"] == pytest.approx(0.95, abs=5e-3) # ch. 33 : 0,95 sur 25 ans assert r["solde_echeance"] == pytest.approx(hyp.solde(24)) and det["total"]["rcd_agrege"] == pytest.approx(pf["rne_2026"].sum() / d["service_dette"].sum()) assert det["echeancier"]["dette"].sum() == pytest.approx(85_766_000) and det["echeancier_rmr"].values.sum() == pytest.approx(85_766_000) # RCD décroît avec la hausse de taux ; capacité RCD = RNE / (1,20 f') assert (d["rcd_+0"] >= d["rcd_+100"]).all() and (d["rcd_+100"] >= d["rcd_+200"]).all() assert det["total"]["rcd_agrege_+200"] < det["total"]["rcd_agrege_+100"] < det["total"]["rcd_agrege_+0"] assert (d["injection_+200"] >= d["injection_+0"]).all() e3 = D["exercices"]["ex3"]; assert e3["injection"] == pytest.approx(max(e3["solde_2028"] - e3["capacite"], 0)) def test_monte_carlo_reproductible_et_diversification(D): mc = D["mc"]; m2 = ch.simuler_portefeuille(D["portefeuille"], D["dynamique"], D["regression"], 10_000, ch.GRAINE) assert mc["stats"]["mediane"] == pytest.approx(m2["stats"]["mediane"], abs=1) and mc["var95_portefeuille"] == pytest.approx(m2["var95_portefeuille"], abs=1) assert mc["valeurs"].shape == (10_000, 48) and np.allclose(mc["valeurs"].sum(1), mc["valeur_portefeuille"]) # espérance ≈ V0 à l'ordre des termes de convexité ; médiane proche de V0 assert abs(mc["stats"]["mediane"] / mc["stats"]["V0"] - 1) < 0.01 # diversification : VaR portefeuille ≤ somme des VaR ; parfait ≈ additif ; indépendant ≪ déclaré assert mc["var95_portefeuille"] <= mc["somme_var95_individuelles"] assert D["mc_parfait"]["ratio_diversification"] == pytest.approx(1.0, abs=0.01) assert D["mc_independant"]["ratio_diversification"] < mc["ratio_diversification"] < 1.0 assert D["mc_independant"]["stats"]["ecart_type"] < mc["stats"]["ecart_type"] < D["mc_parfait"]["stats"]["ecart_type"] # matrice de corrélation à deux facteurs : symétrique, diagonale 1, définie positive S = ch.matrice_correlation(D["portefeuille"]["rmr"].values, 0.5, 0.7) assert np.allclose(S, S.T) and np.allclose(np.diag(S), 1) and np.linalg.eigvalsh(S).min() > 0 # version analytique normale : indépendant < portefeuille < parfait a = D["analytique"]; assert a["sigma_independant"] < a["sigma_portefeuille"] < a["sigma_parfait"] def test_frontiere_et_allocation(D): fr = D["allocation"]["frontiere"] assert fr["w_actuel"].sum() == pytest.approx(1.0) and fr["w_min_var"].sum() == pytest.approx(1.0, abs=1e-6) assert fr["sigma_min_var"] <= fr["sigma_actuel"] + 1e-9 and fr["sigma_meme_mu"] <= fr["sigma_actuel"] + 1e-9 and fr["mu_meme_sigma"] >= fr["mu_actuel"] - 1e-9 assert fr["mu_actuel"] == pytest.approx(float(fr["w_actuel"] @ fr["mu"])) al = D["allocation"]; assert len(al["vendre"]) == 6 and len(al["conserver"]) == 6 and not set(al["vendre"]["id_immeuble"]) & set(al["conserver"]["id_immeuble"]) ap = al["apres_cession"]; assert ap["n"] == 42 and ap["valeur"] == pytest.approx(143_363_000 - ap["valeur_cedee"]) def test_risques_et_reconciliation(D): ris = D["risques"]; rec = D["reconciliation"] assert ris["part_inondable"] == pytest.approx(0.0666, abs=5e-4) and len(ris["inondables"]) == 3 and len(ris["sols"]) == 2 assert ris["effet_inondable_pct_taux"] == pytest.approx(np.exp(D["regression"]["beta"][3]) - 1) assert ris["decote_inondable_agregee"] == pytest.approx(-ris["effet_inondable_pct_taux"] / (1 + ris["effet_inondable_pct_taux"]), abs=1e-9) # V/V' − 1 = 1/(1+e) − 1 assert rec["valeur_retenue"] == 143_400_000 and rec["rpv"] == pytest.approx(85_766_000 / 143_400_000) assert rec["fourchette_mc"][0] < rec["somme_individuelle"] < rec["fourchette_mc"][1] e6 = D["exercices"]["ex6"]; assert e6["apres_valeur"] == pytest.approx(143_363_000 - 9_546_000) and e6["ids"] == ["P36", "P41", "P43"]