"""Tests du chapitre 40 (Monte-Carlo et stress tests — données synthétiques, cas 3 et cas 4) : égalité stricte avec les chiffres du chapitre 47 (mêmes lois MC_LOIS, même graine 20261031), moments théoriques des lois, Cholesky/copule, erreur de Monte-Carlo, hypercube latin, mesures de risque et vérification analytique du RCD, Sobol, portefeuille, exercices. Tolérances déclarées : montants ±1 $ (valeurs citées), moments de lois ±1e-9 (formules fermées), fréquences ±1e-4, indices de Sobol ±0,02.""" import sys, pathlib, math import numpy as np, pandas as pd, pytest from scipy import stats sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) from traite import cas3, cas4 import ch40_montecarlo as m import ch47_dossier as c47 @pytest.fixture(scope="module") def r(): return cas3.tout() @pytest.fixture(scope="module") def mc(r): return m.monte_carlo(r, m.N_MC, m.GRAINE) # ----------------------------------------------------------------------------- cohérence avec le chapitre 47 def test_memes_lois_et_graine_que_ch47(): assert m.MC_LOIS is c47.MC_LOIS and m.GRAINE == c47.GRAINE == 20261031 and m.R_T_PLANCHER == c47.R_T_PLANCHER def test_chiffres_identiques_au_ch47(r, mc): st, pr = mc["stats"], mc["probabilites"] assert st["mediane"] == pytest.approx(2_191_607.87, abs=1) and st["q05"] == pytest.approx(1_735_194.21, abs=1) and st["q95"] == pytest.approx(2_876_902.35, abs=1) assert st["moyenne"] == pytest.approx(2_233_505.68, abs=1) and st["ecart_type"] == pytest.approx(350_442.57, abs=1) assert pr["rcd1_sous_1.20"] == pytest.approx(0.4936, abs=1e-4) and pr["rcd1_sous_1.00"] == 0.0 and pr["rcd6_choc200_sous_1.20"] == pytest.approx(0.0140, abs=1e-4) assert pr["V_sous_cap"] == pytest.approx(0.3094, abs=1e-4) and pr["V_sous_2M"] == pytest.approx(0.2645, abs=1e-4) and pr["V_sous_role"] == pytest.approx(0.5118, abs=1e-4) # evaluer() (chemin du ch. 40) reproduit monte_carlo() (chemin du ch. 47) tirage par tirage e = m.evaluer(r, mc["tirages"]) assert np.array_equal(e["valeur"], mc["valeur"]) and np.allclose(e["rcd1"], mc["rcd"][:, 0]) def test_valeur_deterministe_reproduit_dcf(r): assert m.valeur_deterministe(r) == pytest.approx(r["dcf"]["valeur"], rel=1e-12) assert m.valeur_deterministe(r, taux_actualisation=0.08, taux_terminal=0.055) == pytest.approx(1_947_889, abs=1) # scénario « hausse des taux » du ch. 30 # ----------------------------------------------------------------------------- lois def test_moments_lois_formules_fermees(): ln = m.moments_loi("lognormale", {"mediane": 0.039, "sigma_log": 0.30}) assert ln["moyenne"] == pytest.approx(0.039 * math.exp(0.045), abs=1e-12) and ln["q05"] == pytest.approx(stats.lognorm.ppf(0.05, 0.30, scale=0.039), abs=1e-12) assert ln["ecart_type"] == pytest.approx(stats.lognorm.std(0.30, scale=0.039), abs=1e-12) tri = m.moments_loi("triangulaire", {"min": 0.08, "mode": 0.15, "max": 0.25}); d = stats.triang(c=0.07 / 0.17, loc=0.08, scale=0.17) assert tri["moyenne"] == pytest.approx(0.16, abs=1e-12) and tri["ecart_type"] == pytest.approx(d.std(), abs=1e-12) assert float(tri["q05"]) == pytest.approx(d.ppf(0.05), abs=1e-12) and float(tri["q95"]) == pytest.approx(d.ppf(0.95), abs=1e-12) pe = m.pert(0.08, 0.15, 0.25) assert pe["moyenne"] == pytest.approx((0.08 + 4 * 0.15 + 0.25) / 6, abs=1e-12) and pe["moyenne"] == pytest.approx(pe["loi"].mean(), abs=1e-12) assert pe["ecart_type"] == pytest.approx(pe["loi"].std(), abs=1e-12) assert pe["loi"].pdf(0.15) >= max(pe["loi"].pdf(0.149), pe["loi"].pdf(0.151)) # mode en m # élicitation : inversion exacte des quantiles p = m.parametres_depuis_quantiles(0.024, 0.063, "lognormale"); mm = m.moments_loi("lognormale", p) assert mm["q05"] == pytest.approx(0.024, abs=1e-12) and mm["q95"] == pytest.approx(0.063, abs=1e-12) pn = m.parametres_depuis_quantiles(-0.01, 0.03, "normale"); assert pn["mu"] == pytest.approx(0.01) and pn["mu"] + pn["sigma"] * m.Z90 == pytest.approx(0.03, abs=1e-12) def test_quantile_loi_inverse_les_tirages(): for nom, (typ, p) in m.MC_LOIS.items(): u = np.array([0.05, 0.5, 0.95]); q = m.quantile_loi(typ, p, u) if typ == "normale": assert np.allclose(q, stats.norm.ppf(u, p["mu"], p["sigma"])) if typ == "lognormale": assert np.allclose(q, stats.lognorm.ppf(u, p["sigma_log"], scale=p["mediane"])) if typ == "triangulaire": assert np.allclose(q, stats.triang.ppf(u, c=(p["mode"] - p["min"]) / (p["max"] - p["min"]), loc=p["min"], scale=p["max"] - p["min"])) # ----------------------------------------------------------------------------- corrélations def test_cholesky_et_copule(mc): L = m.cholesky_2x2(0.005, 0.0064, 0.78); S = L @ L.T assert S[0, 0] == pytest.approx(0.005**2) and S[1, 1] == pytest.approx(0.0064**2) and S[0, 1] == pytest.approx(0.78 * 0.005 * 0.0064) assert np.allclose(L, np.linalg.cholesky(S)) c = m.correlation_r_RT(mc["tirages"]) assert c["rho_theorique"] == pytest.approx(0.005 / math.sqrt(0.005**2 + 0.004**2), abs=1e-12) and c["rho_empirique"] == pytest.approx(c["rho_theorique"], abs=0.01) assert c["spearman_empirique"] == pytest.approx(c["spearman_theorique"], abs=0.01) and c["part_plancher"] < 0.02 assert m.spearman_depuis_pearson_gaussien(0.0) == 0.0 and m.spearman_depuis_pearson_gaussien(1.0) == pytest.approx(1.0) # copule gaussienne : marginales exactes et corrélation de rang ≈ (6/pi) arcsin(rho/2) corr = np.array([[1.0, 0.78], [0.78, 1.0]]); X = m.copule_gaussienne(50_000, 3, corr, [("normale", {"mu": 0.0725, "sigma": 0.005}), ("lognormale", {"mediane": 0.039, "sigma_log": 0.30})]) assert X[:, 0].mean() == pytest.approx(0.0725, abs=1e-4) and np.median(X[:, 1]) == pytest.approx(0.039, abs=5e-4) assert stats.spearmanr(X[:, 0], X[:, 1])[0] == pytest.approx(m.spearman_depuis_pearson_gaussien(0.78), abs=0.01) # ----------------------------------------------------------------------------- erreur de Monte-Carlo, convergence, hypercube latin def test_erreur_monte_carlo(r, mc): e = m.erreurs_theoriques(mc).set_index("statistique") assert e.loc["moyenne", "se_theorique"] == pytest.approx(mc["stats"]["ecart_type"] / 100, rel=1e-9) assert e.loc["mediane", "se_theorique"] == pytest.approx(4135, abs=30) and e.loc["q95", "se_theorique"] == pytest.approx(10621, abs=80) assert e.loc["P(RCD1<1,20)", "se_theorique"] == pytest.approx(math.sqrt(0.4936 * 0.5064 / 10_000), abs=1e-5) rep = m.repetitions(r, 10_000, 30, graine0=1) assert rep["mediane"].std(ddof=1) == pytest.approx(e.loc["mediane", "se_theorique"], rel=0.35) # accord théorie/empirique (30 répétitions) assert abs(rep["p_rcd1_120"].mean() - 0.5) < 0.004 # valeur exacte 0,5 assert m.n_requis(413_451, 5000) == 26_267 and m.n_requis(0.5, 0.01) == 9_604 and m.n_requis(0.5, 0.005) == 38_415 def test_convergence_et_pente(r): c = m.convergence(r, N_max=20_000, points=(100, 1000, 10_000, 20_000)) assert len(c) == 4 and c["mediane"].iloc[-1] == pytest.approx(2_204_000, abs=15_000) s = m.se_empirique_selon_N(r, Ns=(250, 1000, 4000), k=12) assert -0.75 < s.attrs["pente_log_mediane"] < -0.3 def test_hypercube_latin(r): rng = np.random.default_rng(0); U = m.hypercube_latin(500, 3, rng) for k in range(3): # exactement un point par strate assert np.array_equal(np.sort(np.floor(U[:, k] * 500).astype(int)), np.arange(500)) x = m.tirer_lhs(2000, 1, 3) assert set(x) == set(m.tirer(2000, 1, 3)) and x["facteur_capex"].shape == (2000, 3) assert x["inoccupation_pertes"].mean() == pytest.approx(0.039 * math.exp(0.045), rel=0.01) and x["taux_actualisation"].mean() == pytest.approx(0.0725, abs=2e-5) assert np.all(x["taux_terminal"] >= m.R_T_PLANCHER) cmp = m.comparer_lhs(r, n=500, k=20).set_index("statistique") assert cmp.loc["moyenne", "ratio_variance"] > 5 and cmp.loc["p_rcd1_120", "se_lhs"] == 0.0 and cmp.loc["p_rcd1_120", "moy_lhs"] == 0.5 # ----------------------------------------------------------------------------- risque, covenants, stress def test_mesures_risque_et_rcd_analytique(r, mc): mr = m.mesures_risque(mc, r["dcf"]["valeur"]).set_index("alpha") assert mr.loc[0.05, "VaR"] == pytest.approx(r["dcf"]["valeur"] - mc["stats"]["q05"], abs=1e-6) and mr.loc[0.05, "CVaR"] >= mr.loc[0.05, "VaR"] assert mr.loc[0.05, "VaR"] == pytest.approx(473_372, abs=1) and mr.loc[0.05, "CVaR"] == pytest.approx(557_097, abs=1) assert mr.loc[0.10, "VaR"] == pytest.approx(385_254, abs=1) and mr.loc[0.10, "CVaR"] == pytest.approx(491_208, abs=1) assert m.cvar_normale(0, 1, 0.05) == pytest.approx(-stats.norm.pdf(stats.norm.ppf(0.05)) / 0.05) an = m.rcd1_analytique(r) assert an["rcd1_base"] == pytest.approx(1.20, abs=1e-9) and an["v_seuil_120"] == pytest.approx(0.039, abs=1e-9) assert an["P_rcd1_sous_120_exacte"] == pytest.approx(0.5, abs=1e-12) and an["v_seuil_100"] == pytest.approx(0.1333, abs=1e-4) and an["P_rcd1_sous_100_exacte"] < 1e-4 # la fonction affine reproduit le RCD1 simulé pour chaque tirage v = mc["tirages"]["inoccupation_pertes"]; b, hyp, f = r["base"], r["hyp"], r["financement"] de_hg = b["depenses_hors_reserve"] - 0.04 * b["rbe"] rcd_affine = (0.96 * (b["rbp_contrat"] * (1 - v) + hyp["autres_revenus_annuels"]) - de_hg - b["reserve"]) / f["service_dette"] assert np.allclose(rcd_affine, mc["rcd"][:, 0], rtol=1e-10) def test_stress_et_inverse(r, mc): st = m.stress_deterministes(r, mc) assert st["valeur"].iloc[0] == pytest.approx(r["dcf"]["valeur"], rel=1e-12) and st["P_V_inferieure"].is_monotonic_decreasing or True assert st.loc[st["scenario"].str.startswith("Hausse"), "valeur"].iloc[0] == pytest.approx(1_947_889, abs=1) assert st["P_V_inferieure"].iloc[-1] < 0.01 and st["P_V_inferieure"].iloc[1] == pytest.approx(0.2072, abs=1e-4) inv = m.stress_inverse(r) assert m.valeur_deterministe(r, taux_actualisation=inv["r_valeur_2M"], taux_terminal=inv["r_valeur_2M"] - 0.0225) == pytest.approx(2_000_000, abs=1) assert m.valeur_deterministe(r, taux_terminal=inv["RT_valeur_cap"]) == pytest.approx(r["valeur_capitalisation"], abs=1) assert inv["v_rcd1_egal_1"] == pytest.approx(0.1333, abs=1e-4) # ----------------------------------------------------------------------------- sensibilité globale def test_sobol_et_rangs(r, mc): so = m.sobol(r, n=5000).set_index("intrant") assert 0.9 < so["S1"].sum() < 1.05 and so["S1"].idxmax() == "taux_actualisation" assert so.loc["taux_actualisation", "S1"] == pytest.approx(0.528, abs=0.03) and so.loc["croissance_loyers_marche", "S1"] == pytest.approx(0.248, abs=0.03) assert (so["ST"] >= so["S1"] - 0.02).all() rg = m.correlations_rang(mc).set_index("intrant") assert rg.loc["taux_terminal", "spearman"] == pytest.approx(-0.78, abs=0.01) and rg.loc["taux_actualisation", "spearman"] == pytest.approx(-0.71, abs=0.01) assert rg.loc["croissance_loyers_marche", "spearman"] == pytest.approx(0.49, abs=0.01) reg = m.regression_standardisee(mc); assert reg["R2"] == pytest.approx(0.955, abs=0.01) # ----------------------------------------------------------------------------- portefeuille (cas 4) def test_portefeuille_structure_de_correlation(): d = cas4.charger(); pf = d["portefeuille"]; cr = cas4.croissance_rne(d["series"]) s = m.simuler_portefeuille(pf, 4000, 1, croissance=cr) assert s["valeurs"].shape == (4000, 48) and s["stats"]["nb_renouvellements_stress"] == int((pf["echeance_dette"] <= 2028).sum()) == 23 assert s["stats"]["nb_rcd_det_sous_seuil"] == 5 and s["stats"]["nb_rcd_det_sous_1"] == 1 # corrélation empirique des chocs : rho_rmr intra-RMR, rho_prov inter-RMR (sur les log-valeurs, à sigma_g près) V = np.log(s["valeurs"]); c = np.corrcoef(V.T); rmr = pf["rmr"].values intra = [c[i, j] for i in range(48) for j in range(i) if rmr[i] == rmr[j]]; inter = [c[i, j] for i in range(48) for j in range(i) if rmr[i] != rmr[j]] assert np.mean(intra) == pytest.approx(0.60, abs=0.05) and np.mean(inter) == pytest.approx(0.30, abs=0.05) ind = m.simuler_portefeuille(pf, 4000, 1, rho_rmr=0.0, rho_prov=0.0, croissance=cr)["stats"]; par = m.simuler_portefeuille(pf, 4000, 1, rho_rmr=1.0, rho_prov=1.0, croissance=cr)["stats"] assert ind["ecart_type"] < s["stats"]["ecart_type"] < par["ecart_type"] and ind["ratio_diversification"] < 0.3 and par["ratio_diversification"] > 0.95 # l'espérance du nombre de bris ne dépend pas de la corrélation (±1 immeuble d'erreur MC) assert abs(ind["nb_rcd_sous_seuil_moyen"] - par["nb_rcd_sous_seuil_moyen"]) < 0.5 # variance d'une somme à corrélation commune sig = np.array([1.0, 2.0, 3.0]); assert m.variance_somme_correlee(sig, 1.0) == pytest.approx(6.0) and m.variance_somme_correlee(sig, 0.0) == pytest.approx(math.sqrt(14)) with pytest.raises(AssertionError): m.simuler_portefeuille(pf, 10, 1, rho_rmr=0.2, rho_prov=0.5) def test_portefeuille_valeurs_citees(): d = cas4.charger(); cr = cas4.croissance_rne(d["series"]) s = m.simuler_portefeuille(d["portefeuille"], m.N_MC, m.GRAINE, croissance=cr)["stats"] assert s["valeur_2026"] == 143_363_000 and s["valeur_deterministe"] == pytest.approx(147_935_699, abs=1) assert s["ecart_type"] == pytest.approx(10_295_448, abs=1) and s["VaR05"] == pytest.approx(14_063_788, abs=1) and s["CVaR05"] == pytest.approx(17_388_940, abs=1) assert s["ratio_diversification"] == pytest.approx(0.643, abs=1e-3) and s["nb_rcd_sous_seuil_moyen"] == pytest.approx(10.19, abs=0.01) # ----------------------------------------------------------------------------- exercices def test_exercices(r, mc): ex = m.exercices(r, mc) assert ex["ex1"]["mediane"] == pytest.approx(0.03888, abs=1e-5) and ex["ex1"]["P_sup_5pct"] == pytest.approx(0.1957, abs=1e-4) assert ex["ex2"]["pert"]["alpha"] == pytest.approx(2.647, abs=1e-3) and ex["ex2"]["P_tri_sup_20"] == pytest.approx(0.147, abs=1e-3) and ex["ex2"]["P_pert_sup_20"] == pytest.approx(0.092, abs=1e-3) assert ex["ex3"]["N_mediane_5000"] == 26_267 and ex["ex3"]["N_proba_1pt"] == 9_604 assert ex["ex4"]["r_tire"] == pytest.approx(0.0785, abs=1e-6) and ex["ex4"]["RT_tire"] == pytest.approx(0.053988, abs=1e-5) assert ex["ex5"]["VaR"] == pytest.approx(385_254, abs=1) and ex["ex5"]["cvar_normale"] == pytest.approx(1_618_485, abs=1) assert ex["ex6"]["mediane"] == pytest.approx(2_184_377, abs=1) and ex["ex6"]["ecart_type"] == pytest.approx(546_896, abs=1) and ex["ex6"]["P_V_2M"] == pytest.approx(0.3558, abs=1e-4)