spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 48 — Cas 4 : portefeuille multirésidentiel interrégional (DONNÉES SYNTHÉTIQUES, 48 immeubles fictifs, 6 RMR).34Dossier complet d'évaluation de portefeuille : sommaire par RMR, distribution des tailles/âges/états, modèle d'évaluation de5masse (régression semi-logarithmique du taux global : traite.cas4.effets_regionaux, facteur de Duan, valeur prédite par immeuble,6statistiques de ratio IAAO), dynamique des RNE 2016-2026 (croissance, volatilité, choc pandémique, corrélations intra- et7inter-régionales), risques (inondation, sol, concentration, dette : échéancier, RCD, renouvellement à +100/+200 pb, capacité de8refinancement), agrégation de l'incertitude par Monte-Carlo avec corrélation régionale (copule gaussienne, décomposition de Cholesky,910 000 tirages, graine 20261031 ; démonstration de la diversification : VaR du portefeuille vs somme des VaR), allocation10(score rendement/croissance/risque, frontière rendement-risque par RMR), réconciliation et exercices.1112Les calculs partagés viennent de `traite.cas4` (sommaire, croissance_rne, effets_regionaux), `traite.finance` (hypothèques,13RCD, capitalisation) et `traite.hedonique` (MCO HC1, statistiques de ratio) ; ce script n'y ajoute que les calculs propres au14dossier et IMPRIME tous les chiffres cités dans manuscript/chapters/ch48.tex.1516Hypothèses déclarées (voir §48.1 et §48.6 du chapitre) :17 - date d'évaluation 2026-06-30 (fin de la série de RNE) ; valeurs et RNE « 2026 » du fichier = indications individuelles ;18 - dette du fichier = solde au 2026-06-30 ; service de la dette calculé sur un amortissement de 480 mois (prêts assurés19 multilogement, même hypothèse que le ch. 33 pour P45) ; sensibilité à 300 mois ;20 - renouvellement : à l'échéance, le solde (formule prospective) est refinancé sur l'amortissement résiduel au taux + Δ.2122Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch48_portefeuille.py [--n 10000]23"""24from __future__ import annotations25import sys, pathlib, argparse, math26import numpy as np, pandas as pd27sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))28from traite import cas4, finance as fin, hedonique as h2930ROOT = pathlib.Path(__file__).resolve().parents[3]31GRAINE = 2026103132DATE_EVAL = "2026-06-30"33RMR = ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]34RMR_REG = ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"] # indicatrices (référence Montréal)3536# ----------------------------------------------------------------------------- hypothèses propres au chapitre 4837AMORT_BASE = 480 # mois (prêts assurés multilogement ; cohérent avec le ch. 33, P45)38AMORT_ALT = 300 # sensibilité39RCD_MIN = 1.20 # covenant conventionnel (ch. 31, 47)40RPV_MAX = 0.7541CHOCS_PB = (0, 100, 200)42N_MC = 10_00043# corrélations déclarées du choc de TAUX (facteur commun des marchés de capitaux) ; celles du choc de RNE sont estimées sur les séries44RHO_TAUX = {"global": 0.50, "regional": 0.70}45OBLIG_10_ANS = 0.0384 # Banque du Canada, 2026-10-29 (notes-C §4.1) — repère pour la frontière46ARRONDI_PORTEFEUILLE = 100_0004748# ----------------------------------------------------------------------------- utilitaires4950def _d(x): return f"{x:,.0f}".replace(",", " ") + " $"51def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %"5253def arrondir(x: float, pas: int = ARRONDI_PORTEFEUILLE) -> float:54 return round(x / pas) * pas5556# ----------------------------------------------------------------------------- 48.2 portefeuille5758def sommaire_etendu(pf: pd.DataFrame) -> dict:59 """Sommaire de traite.cas4.sommaire complété : médiane et étendue des taux, taille moyenne, âge, état, loyer, RNE/logement, part de la valeur."""60 s = cas4.sommaire(pf); g = s["par_rmr"].copy()61 agg = pf.groupby("rmr").agg(taux_median=("taux_global", "median"), taux_min=("taux_global", "min"), taux_max=("taux_global", "max"),62 taille_moy=("nb_logements", "mean"), age_moy=("age", "mean"), etat_moy=("etat_1a5", "mean"),63 loyer_moy=("loyer_mensuel_moyen", "mean"), inondables=("zone_inondable", "sum"), sols=("contamination_sol", "sum"))64 g = g.join(agg); g["rne_par_logement"] = g["rne"] / g["logements"]; g["part_valeur"] = g["valeur"] / s["total"]["valeur"]65 g["taux_dette_pondere"] = pf.groupby("rmr").apply(lambda d: (d["dette"] * d["taux_dette"]).sum() / d["dette"].sum(), include_groups=False)66 g = g.loc[RMR]67 tot = s["total"]; tot["valeur_par_logement"] = tot["valeur"] / tot["logements"]; tot["rne_par_logement"] = tot["rne"] / tot["logements"]68 tot["taux_dette_pondere"] = float((pf["dette"] * pf["taux_dette"]).sum() / pf["dette"].sum())69 tot["taux_median"] = float(pf["taux_global"].median()); tot["taux_moyen"] = float(pf["taux_global"].mean()); tot["taux_sd"] = float(pf["taux_global"].std(ddof=1))70 tot["part_sol"] = float(pf.loc[pf["contamination_sol"] == 1, "valeur_2026"].sum() / tot["valeur"])71 # distributions72 tailles = pf.groupby("nb_logements").agg(n=("id_immeuble", "count"), logements=("nb_logements", "sum"), valeur=("valeur_2026", "sum"), taux_median=("taux_global", "median"))73 bornes_age = [0, 30, 50, 65, 200]; etiq_age = ["< 30 ans", "30-49 ans", "50-64 ans", "65 ans et +"]74 ages = pf.assign(classe=pd.cut(pf["age"], bornes_age, labels=etiq_age, right=False)).groupby("classe", observed=False).agg(n=("id_immeuble", "count"), valeur=("valeur_2026", "sum"), taux_median=("taux_global", "median"), etat_moy=("etat_1a5", "mean"))75 etats = pf.groupby("etat_1a5").agg(n=("id_immeuble", "count"), valeur=("valeur_2026", "sum"), taux_median=("taux_global", "median"), loyer_moy=("loyer_mensuel_moyen", "mean"))76 # concentration (Herfindahl) par RMR et par immeuble, sur la valeur77 w_rmr = g["part_valeur"].values; w_imm = (pf["valeur_2026"] / tot["valeur"]).values78 conc = {"hhi_rmr": float((w_rmr ** 2).sum()), "hhi_immeuble": float((w_imm ** 2).sum()), "n_equiv_rmr": float(1 / (w_rmr ** 2).sum()), "n_equiv_immeuble": float(1 / (w_imm ** 2).sum()),79 "top5_part": float(np.sort(w_imm)[-5:].sum()), "top5_ids": pf.nlargest(5, "valeur_2026")["id_immeuble"].tolist()}80 return {"par_rmr": g, "total": tot, "tailles": tailles, "ages": ages, "etats": etats, "concentration": conc}8182# ----------------------------------------------------------------------------- 48.4 modèle d'évaluation de masse8384def regression_masse(pf: pd.DataFrame) -> dict:85 """Même spécification que traite.cas4.effets_regionaux (coefficients identiques) ; conserve résidus, R², facteur de Duan et la matrice X."""86 eff = cas4.effets_regionaux(pf)87 X = pd.DataFrame({"const": 1.0, "etat": pf["etat_1a5"] - 3, "ln_logements": np.log(pf["nb_logements"] / 12), "inondable": pf["zone_inondable"],88 "sol": pf["contamination_sol"], "age": (pf["age"] - 50) / 10})89 for r in RMR_REG: X[f"rmr_{r}"] = (pf["rmr"] == r).astype(float)90 res = h.mco(X.values, np.log(pf["taux_global"].values))91 assert np.allclose(res["beta"], eff["coef"].values)92 duan = float(np.mean(np.exp(res["residus"])))93 tab = eff.copy(); tab["t_hc1"] = tab["coef"] / tab["se_hc1"]94 tab["effet_pb_a_520"] = tab["effet_pct"] * 0.0520 * 1e4 # effet en points de base au taux pondéré du portefeuille95 tab["ic_bas_pct"] = np.exp(tab["coef"] - 1.96 * tab["se_hc1"]) - 1; tab["ic_haut_pct"] = np.exp(tab["coef"] + 1.96 * tab["se_hc1"]) - 196 return {"table": tab, "beta": res["beta"], "colonnes": list(X.columns), "X": X.values, "residus": res["residus"], "sigma": float(np.sqrt(res["sigma2"])),97 "r2": float(res["r2"]), "r2_ajuste": float(res["r2_ajuste"]), "n": int(res["n"]), "k": int(res["k"]), "duan": duan}9899def x_ligne(rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0) -> np.ndarray:100 x = [1.0, etat - 3, math.log(n / 12), inond, sol, (age - 50) / 10] + [1.0 if rmr == r else 0.0 for r in RMR_REG]101 return np.array(x)102103def taux_predit(reg: dict, rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0, duan: bool = True) -> float:104 return float(np.exp(x_ligne(rmr, etat, n, age, inond, sol) @ reg["beta"]) * (reg["duan"] if duan else 1.0))105106def valeurs_predites(pf: pd.DataFrame, reg: dict) -> pd.DataFrame:107 """Taux prédit et valeur prédite (RNE / taux prédit) de chaque immeuble ; ratio valeur prédite / indication individuelle."""108 t = pf.copy()109 t["taux_predit"] = np.exp(reg["X"] @ reg["beta"]) * reg["duan"]110 t["valeur_predite"] = t["rne_2026"] / t["taux_predit"]111 t["ratio"] = t["valeur_predite"] / t["valeur_2026"]112 t["ecart_pb"] = (t["taux_predit"] - t["taux_global"]) * 1e4113 t["rpv"] = t["dette"] / t["valeur_2026"]; t["valeur_par_logement"] = t["valeur_2026"] / t["nb_logements"]114 return t115116def ratios_iaao(t: pd.DataFrame) -> dict:117 """Statistiques de ratio (IAAO) : modèle de masse vs indications individuelles, portefeuille et par RMR."""118 out = {"portefeuille": h.statistiques_ratio(t["valeur_predite"].values, t["valeur_2026"].values)}119 for r in RMR:120 s = t[t["rmr"] == r]121 out[r] = h.statistiques_ratio(s["valeur_predite"].values, s["valeur_2026"].values) if len(s) >= 4 else {"n": len(s), "ratio_median": float((s["valeur_predite"] / s["valeur_2026"]).median()), "COD": float("nan"), "PRD": float("nan"), "PRB": float("nan")}122 out["somme_predite"] = float(t["valeur_predite"].sum()); out["somme_individuelle"] = float(t["valeur_2026"].sum())123 out["ecart_agrege_pct"] = out["somme_predite"] / out["somme_individuelle"] - 1124 out["extremes"] = t.loc[(t["ratio"] - 1).abs().nlargest(5).index, ["id_immeuble", "rmr", "nb_logements", "etat_1a5", "taux_global", "taux_predit", "valeur_2026", "valeur_predite", "ratio"]]125 return out126127# ----------------------------------------------------------------------------- 48.5 dynamique des RNE128129def dynamique_rne(se: pd.DataFrame, pf: pd.DataFrame) -> dict:130 cr = cas4.croissance_rne(se).merge(pf[["id_immeuble", "rmr", "valeur_2026", "zone_inondable"]], on="id_immeuble")131 piv = se.pivot(index="trimestre", columns="id_immeuble", values="rne_trimestriel").sort_index()132 rmr_de = pf.set_index("id_immeuble")["rmr"]133 agg_rmr = piv.T.groupby(rmr_de).sum().T[RMR]; agg_tot = piv.sum(axis=1)134 t = np.arange(len(piv)) / 4.0135 def croiss(y):136 b = np.polyfit(t, np.log(y.values), 1)[0]; return float(np.exp(b) - 1)137 def vol(y): return float(np.std(np.diff(np.log(y.values))) * 2)138 reg_tab = pd.DataFrame({"croissance_agregee": {r: croiss(agg_rmr[r]) for r in RMR}, "volatilite_agregee": {r: vol(agg_rmr[r]) for r in RMR},139 "croissance_mediane_immeubles": cr.groupby("rmr")["croissance_annuelle"].median(), "croissance_min": cr.groupby("rmr")["croissance_annuelle"].min(),140 "croissance_max": cr.groupby("rmr")["croissance_annuelle"].max(), "volatilite_mediane_immeubles": cr.groupby("rmr")["volatilite"].median(),141 "n": cr.groupby("rmr")["id_immeuble"].count()}).loc[RMR]142 tot = {"croissance_agregee": croiss(agg_tot), "volatilite_agregee": vol(agg_tot), "croissance_mediane_immeubles": float(cr["croissance_annuelle"].median()),143 "volatilite_mediane_immeubles": float(cr["volatilite"].median()), "rne_2016T1_annualise": float(agg_tot.iloc[0] * 4), "rne_2026T2_annualise": float(agg_tot.iloc[-1] * 4),144 "rne_fichier_2026": float(pf["rne_2026"].sum())}145 # choc pandémique : écart moyen (log) à la tendance pendant 2020T2-2021T2, par RMR et total146 def choc(y):147 ly = np.log(y.values); b = np.polyfit(t, ly, 1); trend = np.polyval(b, t)148 m = (piv.index >= pd.Period("2020Q2", "Q")) & (piv.index <= pd.Period("2021Q2", "Q"))149 return float(np.exp(np.mean(ly[m] - trend[m])) - 1)150 pand = {r: choc(agg_rmr[r]) for r in RMR}; pand["Portefeuille"] = choc(agg_tot)151 # variation glissante sur 4 trimestres de l'agrégat (point bas)152 g4 = agg_tot / agg_tot.shift(4) - 1153 # corrélations des variations trimestrielles (log) entre immeubles154 dl = np.log(piv).diff().dropna()155 C = dl.corr(); ids = list(piv.columns); r_of = rmr_de.loc[ids].values156 same = np.equal.outer(r_of, r_of); iu = np.triu_indices(len(ids), 1)157 cvals = C.values[iu]; svals = same[iu]158 corr = {"intra_rmr_moyenne": float(cvals[svals].mean()), "inter_rmr_moyenne": float(cvals[~svals].mean()), "toutes_moyenne": float(cvals.mean()),159 "intra_rmr_mediane": float(np.median(cvals[svals])), "inter_rmr_mediane": float(np.median(cvals[~svals])), "n_paires_intra": int(svals.sum()), "n_paires_inter": int((~svals).sum())}160 # corrélations hors période pandémique (le choc commun crée de la corrélation)161 m_hors = ~((dl.index >= pd.Period("2020Q1", "Q")) & (dl.index <= pd.Period("2021Q3", "Q")))162 C2 = dl[m_hors].corr().values[iu]163 corr["intra_hors_pandemie"] = float(C2[svals].mean()); corr["inter_hors_pandemie"] = float(C2[~svals].mean())164 corr_rmr = np.log(agg_rmr).diff().dropna().corr()165 return {"par_immeuble": cr, "par_rmr": reg_tab, "total": tot, "agregats_rmr": agg_rmr, "agregat_total": agg_tot, "pandemie": pand, "glissant4": g4,166 "correlations": corr, "corr_rmr": corr_rmr, "matrice_corr_immeubles": C, "diff_log": dl}167168# ----------------------------------------------------------------------------- 48.6 risques et dette169170def risques(pf: pd.DataFrame, reg: dict, t: pd.DataFrame) -> dict:171 tot_v = float(pf["valeur_2026"].sum())172 inond = pf[pf["zone_inondable"] == 1].copy(); sol = pf[pf["contamination_sol"] == 1].copy()173 b_in = float(reg["table"].loc["inondable", "coef"]); b_sol = float(reg["table"].loc["sol", "coef"])174 # valeur « sans le risque » : même RNE, taux divisé par exp(β) (effet proportionnel du modèle)175 inond["taux_sans_risque"] = inond["taux_global"] / np.exp(b_in); inond["valeur_sans_risque"] = inond["valeur_2026"] * np.exp(b_in) # même RNE, taux ÷ exp(β)176 inond["decote"] = inond["valeur_2026"] / inond["valeur_sans_risque"] - 1; inond["pb"] = (inond["taux_global"] - inond["taux_sans_risque"]) * 1e4177 sol["taux_sans_risque"] = sol["taux_global"] / np.exp(b_sol); sol["valeur_sans_risque"] = sol["valeur_2026"] * np.exp(b_sol)178 sol["decote"] = sol["valeur_2026"] / sol["valeur_sans_risque"] - 1; sol["pb"] = (sol["taux_global"] - sol["taux_sans_risque"]) * 1e4179 return {"inondables": inond, "part_inondable": float(inond["valeur_2026"].sum() / tot_v), "valeur_inondable": float(inond["valeur_2026"].sum()),180 "decote_inondable_agregee": float(inond["valeur_2026"].sum() / inond["valeur_sans_risque"].sum() - 1), "effet_inondable_pct_taux": float(np.exp(b_in) - 1),181 "sols": sol, "part_sol": float(sol["valeur_2026"].sum() / tot_v), "valeur_sol": float(sol["valeur_2026"].sum()), "effet_sol_pct_taux": float(np.exp(b_sol) - 1),182 "decote_sol_agregee": float(sol["valeur_2026"].sum() / sol["valeur_sans_risque"].sum() - 1),183 "dette_inondable": float(inond["dette"].sum()), "rpv_inondable": float(inond["dette"].sum() / inond["valeur_2026"].sum())}184185def dette(pf: pd.DataFrame, amort: int = AMORT_BASE, chocs=CHOCS_PB, croissance: pd.Series | None = None) -> dict:186 """Service de la dette par immeuble (amortissement `amort`), RCD, échéancier, renouvellement à +Δ pb, capacité de refinancement."""187 rows = []188 for r in pf.to_dict("records"):189 hyp = fin.Hypotheque(float(r["dette"]), float(r["taux_dette"]), amort)190 sd = hyp.service_dette_annuel; rne = float(r["rne_2026"])191 annees = int(r["echeance_dette"]) - 2026; mois = annees * 12192 solde = hyp.solde(mois); amort_res = amort - mois193 g = float(croissance.loc[r["id_immeuble"]]) if croissance is not None else 0.0194 rne_ech = rne * (1 + g) ** annees195 row = {"id_immeuble": r["id_immeuble"], "rmr": r["rmr"], "valeur": r["valeur_2026"], "dette": r["dette"], "taux_dette": r["taux_dette"], "echeance": r["echeance_dette"],196 "rpv": r["dette"] / r["valeur_2026"], "service_dette": sd, "constante": hyp.constante_hypothecaire, "rcd": rne / sd, "solde_echeance": solde, "rne_echeance": rne_ech,197 "levier": "négatif" if hyp.constante_hypothecaire > r["taux_global"] else "positif"}198 for pb in chocs:199 h2 = fin.Hypotheque(solde, float(r["taux_dette"]) + pb / 1e4, amort_res)200 sd2 = h2.service_dette_annuel201 cap_rcd = fin.pret_max_par_rcd(rne_ech, RCD_MIN, float(r["taux_dette"]) + pb / 1e4, amort_res)202 cap_rpv = fin.pret_max_par_rpv(float(r["valeur_2026"]), RPV_MAX)203 cap = min(cap_rcd, cap_rpv)204 row[f"sd_+{pb}"] = sd2; row[f"rcd_+{pb}"] = rne_ech / sd2; row[f"capacite_+{pb}"] = cap; row[f"injection_+{pb}"] = max(solde - cap, 0.0)205 rows.append(row)206 d = pd.DataFrame(rows)207 tot = {"dette": float(d["dette"].sum()), "service_dette": float(d["service_dette"].sum()), "rcd_agrege": float(pf["rne_2026"].sum() / d["service_dette"].sum()),208 "rcd_median": float(d["rcd"].median()), "rcd_min": float(d["rcd"].min()), "id_rcd_min": d.loc[d["rcd"].idxmin(), "id_immeuble"],209 "n_rcd_sous_1.20": int((d["rcd"] < 1.20).sum()), "n_rcd_sous_1.00": int((d["rcd"] < 1.0).sum()),210 "dette_rcd_sous_1.20": float(d.loc[d["rcd"] < 1.20, "dette"].sum()), "dette_rcd_sous_1.00": float(d.loc[d["rcd"] < 1.0, "dette"].sum()),211 "constante_pondere": float(d["service_dette"].sum() / d["dette"].sum()), "n_levier_negatif": int((d["levier"] == "négatif").sum()),212 "valeur_levier_negatif": float(d.loc[d["levier"] == "négatif", "valeur"].sum() / d["valeur"].sum())}213 for pb in chocs:214 tot[f"rcd_agrege_+{pb}"] = float(d["rne_echeance"].sum() / d[f"sd_+{pb}"].sum())215 tot[f"n_rcd_sous_1.20_+{pb}"] = int((d[f"rcd_+{pb}"] < 1.20).sum()); tot[f"n_rcd_sous_1.00_+{pb}"] = int((d[f"rcd_+{pb}"] < 1.0).sum())216 tot[f"injection_+{pb}"] = float(d[f"injection_+{pb}"].sum()); tot[f"n_injection_+{pb}"] = int((d[f"injection_+{pb}"] > 0).sum())217 tot[f"solde_echeance"] = float(d["solde_echeance"].sum())218 ech = d.groupby("echeance").agg(n=("id_immeuble", "count"), dette=("dette", "sum"), solde=("solde_echeance", "sum"), rne=("rne_echeance", "sum"))219 for pb in chocs:220 ech[f"sd_+{pb}"] = d.groupby("echeance")[f"sd_+{pb}"].sum(); ech[f"rcd_+{pb}"] = ech["rne"] / ech[f"sd_+{pb}"]221 ech[f"injection_+{pb}"] = d.groupby("echeance")[f"injection_+{pb}"].sum()222 ech["part_dette"] = ech["dette"] / tot["dette"]223 ech_rmr = d.pivot_table(index="echeance", columns="rmr", values="dette", aggfunc="sum").fillna(0.0)[RMR]224 par_rmr = d.groupby("rmr").agg(dette=("dette", "sum"), sd=("service_dette", "sum"), rpv=("rpv", "mean")).loc[RMR]225 par_rmr["rcd"] = pf.groupby("rmr")["rne_2026"].sum().loc[RMR] / par_rmr["sd"]; par_rmr["rpv_pondere"] = par_rmr["dette"] / pf.groupby("rmr")["valeur_2026"].sum().loc[RMR]226 return {"par_immeuble": d, "total": tot, "echeancier": ech, "echeancier_rmr": ech_rmr, "par_rmr": par_rmr, "amortissement": amort}227228# ----------------------------------------------------------------------------- 48.7 Monte-Carlo avec corrélation régionale229230def matrice_correlation(rmr_vec: np.ndarray, rho_global: float, rho_regional: float) -> np.ndarray:231 """Matrice de corrélation à deux facteurs (national, régional) : ρ_ij = ρ_reg si même RMR, ρ_glob sinon, 1 sur la diagonale."""232 same = np.equal.outer(rmr_vec, rmr_vec)233 S = np.where(same, rho_regional, rho_global).astype(float); np.fill_diagonal(S, 1.0)234 return S235236def simuler_portefeuille(pf: pd.DataFrame, dyn: dict, reg: dict, n: int = N_MC, graine: int = GRAINE, rho_taux: dict = RHO_TAUX, rho_rne: dict | None = None,237 sigma_taux: float | None = None, regime: str = "declare") -> dict:238 """Simulation de la valeur de chaque immeuble : ln V_i = ln RNE_i + ε_i − ln R_i − η_i, avec (ε, η) gaussiens corrélés entre immeubles par une239 structure à deux facteurs (copule gaussienne, échantillonnée par décomposition de Cholesky). σ_ε,i = volatilité annualisée estimée du RNE de240 l'immeuble ; σ_η = écart-type résiduel de la régression du ln taux (même pour tous). regime ∈ {declare, independant, parfait}."""241 ids = pf["id_immeuble"].values; rmr_vec = pf["rmr"].values; N = len(pf)242 vol = dyn["par_immeuble"].set_index("id_immeuble").loc[ids, "volatilite"].values243 sig_eta = reg["sigma"] if sigma_taux is None else sigma_taux244 corr = dyn["correlations"]245 # estimation sur les séries : corrélation inter-RMR moyenne des variations trimestrielles ; la composante régionale additionnelle est246 # max(intra − inter, 0) — nulle dans ce jeu, où le seul facteur commun est le choc pandémique (voir §48.5)247 if rho_rne is None: rho_rne = {"global": max(corr["inter_rmr_moyenne"], 0.0), "regional": max(corr["intra_rmr_moyenne"], corr["inter_rmr_moyenne"], 0.0)}248 if regime == "independant": S_e = np.eye(N); S_n = np.eye(N)249 elif regime == "parfait": S_e = np.ones((N, N)); S_n = np.ones((N, N))250 else:251 S_e = matrice_correlation(rmr_vec, rho_rne["global"], rho_rne["regional"]); S_n = matrice_correlation(rmr_vec, rho_taux["global"], rho_taux["regional"])252 L_e = np.linalg.cholesky(S_e + 1e-10 * np.eye(N)); L_n = np.linalg.cholesky(S_n + 1e-10 * np.eye(N))253 rng = np.random.default_rng(graine)254 Z_e = rng.standard_normal((n, N)) @ L_e.T; Z_n = rng.standard_normal((n, N)) @ L_n.T255 eps = Z_e * vol[None, :]; eta = Z_n * sig_eta256 V0 = pf["valeur_2026"].values.astype(float); RNE0 = pf["rne_2026"].values.astype(float); R0 = pf["taux_global"].values.astype(float)257 V = (RNE0[None, :] * np.exp(eps)) / (R0[None, :] * np.exp(eta))258 Vp = V.sum(1); tot0 = V0.sum()259 q = lambda a, p: float(np.quantile(a, p))260 stats = {"V0": float(tot0), "moyenne": float(Vp.mean()), "mediane": q(Vp, 0.5), "ecart_type": float(Vp.std(ddof=1)), "cv": float(Vp.std(ddof=1) / Vp.mean()),261 "q05": q(Vp, 0.05), "q25": q(Vp, 0.25), "q75": q(Vp, 0.75), "q95": q(Vp, 0.95), "q01": q(Vp, 0.01), "min": float(Vp.min()), "max": float(Vp.max())}262 # VaR à 95 % (perte par rapport à la valeur centrale V0) : portefeuille vs somme des VaR individuelles263 var_p = tot0 - q(Vp, 0.05); var_i = V0 - np.quantile(V, 0.05, axis=0); somme_var_i = float(var_i.sum())264 es_p = tot0 - float(Vp[Vp <= q(Vp, 0.05)].mean())265 dette_tot = float(pf["dette"].sum())266 probs = {"rpv_sup_0.65": float((dette_tot / Vp > 0.65).mean()), "rpv_sup_0.70": float((dette_tot / Vp > 0.70).mean()), "rpv_sup_0.75": float((dette_tot / Vp > 0.75).mean()),267 "V_sous_130M": float((Vp < 130e6).mean()), "V_sous_120M": float((Vp < 120e6).mean()), "baisse_sup_10pct": float((Vp < 0.9 * tot0).mean()), "baisse_sup_20pct": float((Vp < 0.8 * tot0).mean())}268 # par RMR269 par_rmr = {}270 for r in RMR:271 m = rmr_vec == r; Vr = V[:, m].sum(1); v0 = V0[m].sum()272 par_rmr[r] = {"V0": float(v0), "mediane": q(Vr, 0.5), "q05": q(Vr, 0.05), "q95": q(Vr, 0.95), "cv": float(Vr.std(ddof=1) / Vr.mean()), "var95": float(v0 - q(Vr, 0.05)),273 "somme_var_individuelles": float((V0[m] - np.quantile(V[:, m], 0.05, axis=0)).sum())}274 par_rmr = pd.DataFrame(par_rmr).T275 sigma_ln_i = np.sqrt(vol ** 2 + sig_eta ** 2)276 return {"n": n, "graine": graine, "regime": regime, "valeur_portefeuille": Vp, "valeurs": V, "stats": stats, "var95_portefeuille": var_p, "somme_var95_individuelles": somme_var_i,277 "ratio_diversification": var_p / somme_var_i, "es95_portefeuille": es_p, "var95_individuelles": var_i, "probabilites": probs, "par_rmr": par_rmr,278 "parametres": {"sigma_taux": sig_eta, "rho_taux": rho_taux, "rho_rne": rho_rne, "vol_rne_mediane": float(np.median(vol)), "vol_rne_min": float(vol.min()), "vol_rne_max": float(vol.max())},279 "sigma_ln_individuel": sigma_ln_i, "cv_individuel_median": float(np.median(V.std(axis=0, ddof=1) / V.mean(axis=0)))}280281def variance_normale_portefeuille(sigmas: np.ndarray, poids: np.ndarray, S: np.ndarray) -> dict:282 """Démonstration analytique : σ_P² = w'Σw ; comparaison avec (Σ w_i σ_i)² (corrélation parfaite) et Σ w_i² σ_i² (indépendance)."""283 Sig = np.outer(sigmas, sigmas) * S284 return {"sigma_portefeuille": float(np.sqrt(poids @ Sig @ poids)), "sigma_parfait": float((poids * sigmas).sum()), "sigma_independant": float(np.sqrt((poids ** 2 * sigmas ** 2).sum()))}285286# ----------------------------------------------------------------------------- 48.8 allocation et arbitrage287288def allocation(pf: pd.DataFrame, dyn: dict, mc: dict, det: dict) -> dict:289 t = pf[["id_immeuble", "rmr", "nb_logements", "age", "etat_1a5", "zone_inondable", "contamination_sol", "rne_2026", "taux_global", "valeur_2026", "dette", "echeance_dette"]].copy()290 cr = dyn["par_immeuble"].set_index("id_immeuble")291 t["croissance"] = cr.loc[t["id_immeuble"], "croissance_annuelle"].values; t["volatilite_rne"] = cr.loc[t["id_immeuble"], "volatilite"].values292 t["rendement_total"] = t["taux_global"] + t["croissance"] # R + g (Gordon, ch. 29)293 t["sigma_valeur"] = mc["sigma_ln_individuel"]294 t["ratio_rr"] = (t["rendement_total"] - OBLIG_10_ANS) / t["sigma_valeur"] # excédent de rendement par unité de risque295 d = det["par_immeuble"].set_index("id_immeuble")296 t["rcd"] = d.loc[t["id_immeuble"], "rcd"].values; t["rcd_+200"] = d.loc[t["id_immeuble"], "rcd_+200"].values; t["rpv"] = t["dette"] / t["valeur_2026"]297 t["drapeaux"] = t["zone_inondable"] + t["contamination_sol"] + (t["rcd_+200"] < 1.0).astype(int) + (t["etat_1a5"] <= 2).astype(int)298 t["score"] = t["ratio_rr"].rank(pct=True) - 0.10 * t["drapeaux"]299 t = t.sort_values("score", ascending=False).reset_index(drop=True)300 vendre = t.tail(6).copy(); conserver = t.head(6).copy()301 # portefeuille après cession des 6 candidats (produit net = valeur − dette ; coûts de vente 2 %)302 reste = pf[~pf["id_immeuble"].isin(vendre["id_immeuble"])]303 apres = {"n": len(reste), "valeur": float(reste["valeur_2026"].sum()), "rne": float(reste["rne_2026"].sum()), "dette": float(reste["dette"].sum()),304 "taux_pondere": float(reste["rne_2026"].sum() / reste["valeur_2026"].sum()), "rpv": float(reste["dette"].sum() / reste["valeur_2026"].sum()),305 "produit_net_cession": float((vendre["valeur_2026"] * 0.98 - vendre["dette"]).sum()), "valeur_cedee": float(vendre["valeur_2026"].sum()),306 "part_inondable": float(reste.loc[reste["zone_inondable"] == 1, "valeur_2026"].sum() / reste["valeur_2026"].sum())}307 # frontière rendement-risque par RMR : μ_r = taux pondéré + croissance agrégée ; Σ_r = covariance des rendements simulés des agrégats régionaux308 g = cas4.sommaire(pf)["par_rmr"].loc[RMR]309 mu = (g["taux_pondere"] + dyn["par_rmr"]["croissance_agregee"]).values310 rmr_vec = pf["rmr"].values; V = mc["valeurs"]311 rend = np.column_stack([np.log(V[:, rmr_vec == r].sum(1) / pf.loc[rmr_vec == r, "valeur_2026"].sum()) for r in RMR])312 Sigma = np.cov(rend.T); sig = np.sqrt(np.diag(Sigma))313 w0 = (g["valeur"] / g["valeur"].sum()).values314 from scipy.optimize import minimize315 def frontiere(cibles):316 pts = []317 for m in cibles:318 cons = [{"type": "eq", "fun": lambda w: w.sum() - 1}, {"type": "eq", "fun": lambda w, m=m: w @ mu - m}]319 r = minimize(lambda w: w @ Sigma @ w, np.full(6, 1 / 6), bounds=[(0, 1)] * 6, constraints=cons, method="SLSQP", options={"ftol": 1e-12, "maxiter": 500})320 if r.success: pts.append({"mu": m, "sigma": float(np.sqrt(r.fun)), "poids": r.x})321 return pts322 cibles = np.linspace(mu.min() + 1e-6, mu.max() - 1e-6, 41)323 fr = frontiere(cibles)324 r_min = minimize(lambda w: w @ Sigma @ w, np.full(6, 1 / 6), bounds=[(0, 1)] * 6, constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}], method="SLSQP")325 w_min = r_min.x326 # portefeuille de même rendement attendu que l'actuel, à risque minimal327 mu0 = float(w0 @ mu); sig0 = float(np.sqrt(w0 @ Sigma @ w0))328 r_same = minimize(lambda w: w @ Sigma @ w, w0, bounds=[(0, 1)] * 6, constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}, {"type": "eq", "fun": lambda w: w @ mu - mu0}], method="SLSQP", options={"ftol": 1e-12})329 # portefeuille de même risque, rendement maximal330 r_maxmu = minimize(lambda w: -(w @ mu), w0, bounds=[(0, 1)] * 6, constraints=[{"type": "eq", "fun": lambda w: w.sum() - 1}, {"type": "ineq", "fun": lambda w: sig0 ** 2 - w @ Sigma @ w}], method="SLSQP", options={"ftol": 1e-12})331 front = {"mu": mu, "sigma": sig, "Sigma": Sigma, "corr": Sigma / np.outer(sig, sig), "w_actuel": w0, "mu_actuel": mu0, "sigma_actuel": sig0,332 "frontiere": fr, "w_min_var": w_min, "mu_min_var": float(w_min @ mu), "sigma_min_var": float(np.sqrt(w_min @ Sigma @ w_min)),333 "w_meme_mu": r_same.x, "sigma_meme_mu": float(np.sqrt(r_same.x @ Sigma @ r_same.x)), "w_meme_sigma": r_maxmu.x, "mu_meme_sigma": float(r_maxmu.x @ mu)}334 return {"table": t, "vendre": vendre, "conserver": conserver, "apres_cession": apres, "frontiere": front}335336# ----------------------------------------------------------------------------- 48.9 réconciliation337338def reconciliation(pf: pd.DataFrame, iaao: dict, mc: dict, det: dict, som: dict) -> dict:339 ind = float(pf["valeur_2026"].sum()); pred = iaao["somme_predite"]340 V = arrondir(ind)341 return {"somme_individuelle": ind, "somme_modele": pred, "ecart_modele_pct": pred / ind - 1, "valeur_retenue": V,342 "fourchette_mc": (mc["stats"]["q05"], mc["stats"]["q95"]), "fourchette_mc_pct": (mc["stats"]["q05"] / ind - 1, mc["stats"]["q95"] / ind - 1),343 "valeur_par_logement": V / som["total"]["logements"], "taux_implicite": som["total"]["rne"] / V, "rpv": som["total"]["dette"] / V,344 "rcd_agrege": det["total"]["rcd_agrege"], "par_rmr": som["par_rmr"]["valeur"], "mrb": V / float(pf["revenu_brut_potentiel"].sum()),345 "fonds_propres": V - som["total"]["dette"]}346347# ----------------------------------------------------------------------------- exercices348349def exercices(D: dict) -> dict:350 pf, reg, dyn, det, mc = D["portefeuille"], D["regression"], D["dynamique"], D["dette"], D["mc"]351 out = {}352 # ex1 : Sherbrooke — taux pondéré vs moyenne simple, valeur/logement, RPV353 s = pf[pf["rmr"] == "Sherbrooke"]354 out["ex1"] = {"n": len(s), "logements": int(s["nb_logements"].sum()), "valeur": float(s["valeur_2026"].sum()), "rne": float(s["rne_2026"].sum()), "dette": float(s["dette"].sum()),355 "taux_pondere": float(s["rne_2026"].sum() / s["valeur_2026"].sum()), "taux_moyen_simple": float(s["taux_global"].mean()), "taux_median": float(s["taux_global"].median()),356 "valeur_par_logement": float(s["valeur_2026"].sum() / s["nb_logements"].sum()), "rpv": float(s["dette"].sum() / s["valeur_2026"].sum()),357 "valeur_au_taux_moyen": float(s["rne_2026"].sum() / s["taux_global"].mean()), "ecart_pct": float(s["rne_2026"].sum() / s["taux_global"].mean() / s["valeur_2026"].sum() - 1)}358 # ex2 : modèle de masse — 24 log., état 3, 45 ans, Trois-Rivières, inondable ; RNE 150 000359 rne = 150_000.0360 R_in = taux_predit(reg, "Trois-Rivières", 3, 24, 45, inond=1); R_hors = taux_predit(reg, "Trois-Rivières", 3, 24, 45, inond=0); R_mtl = taux_predit(reg, "Montréal", 3, 24, 45, inond=0)361 out["ex2"] = {"taux_inondable": R_in, "taux_hors_zone": R_hors, "taux_montreal": R_mtl, "valeur_inondable": rne / R_in, "valeur_hors_zone": rne / R_hors, "valeur_montreal": rne / R_mtl,362 "ecart_inondation": rne / R_in / (rne / R_hors) - 1, "pb_inondation": (R_in - R_hors) * 1e4, "duan": reg["duan"], "sans_duan": float(np.exp(x_ligne("Trois-Rivières", 3, 24, 45, 1) @ reg["beta"]))}363 # ex3 : P45 — RCD 480 et 300 mois, renouvellement 2028 à +200 pb, capacité de refinancement364 p = pf.set_index("id_immeuble").loc["P45"]365 h480 = fin.Hypotheque(float(p["dette"]), float(p["taux_dette"]), 480); h300 = fin.Hypotheque(float(p["dette"]), float(p["taux_dette"]), 300)366 mois = (int(p["echeance_dette"]) - 2026) * 12; solde = h480.solde(mois); t2 = float(p["taux_dette"]) + 0.02367 h2 = fin.Hypotheque(solde, t2, 480 - mois); cap = min(fin.pret_max_par_rcd(float(p["rne_2026"]), RCD_MIN, t2, 480 - mois), 0.75 * float(p["valeur_2026"]))368 out["ex3"] = {"dette": float(p["dette"]), "taux": float(p["taux_dette"]), "rne": float(p["rne_2026"]), "sd_480": h480.service_dette_annuel, "rcd_480": float(p["rne_2026"]) / h480.service_dette_annuel,369 "sd_300": h300.service_dette_annuel, "rcd_300": float(p["rne_2026"]) / h300.service_dette_annuel, "solde_2028": solde, "taux_renouvele": t2, "sd_renouvele": h2.service_dette_annuel,370 "rcd_renouvele": float(p["rne_2026"]) / h2.service_dette_annuel, "capacite_rcd": fin.pret_max_par_rcd(float(p["rne_2026"]), RCD_MIN, t2, 480 - mois), "capacite_rpv": 0.75 * float(p["valeur_2026"]),371 "capacite": cap, "injection": max(solde - cap, 0.0), "valeur_ch33": 6_716_534.0, "rpv_ch33": float(p["dette"]) / 6_716_534.0}372 # ex4 : ratios IAAO sur le sous-ensemble montréalais373 t = D["predites"]; m = t[t["rmr"] == "Montréal"]374 out["ex4"] = {**h.statistiques_ratio(m["valeur_predite"].values, m["valeur_2026"].values), "portefeuille": D["iaao"]["portefeuille"]}375 # ex6 : choc de taux généralisé — ρ_taux global 0,90 / régional 0,95376 mc5 = simuler_portefeuille(pf, dyn, reg, n=mc["n"], graine=GRAINE, rho_taux={"global": 0.90, "regional": 0.95})377 out["ex6"] = {"q05": mc5["stats"]["q05"], "q95": mc5["stats"]["q95"], "cv": mc5["stats"]["cv"], "var95": mc5["var95_portefeuille"], "ratio_div": mc5["ratio_diversification"],378 "p_rpv_sup_0.75": mc5["probabilites"]["rpv_sup_0.75"], "base_var95": mc["var95_portefeuille"], "base_ratio_div": mc["ratio_diversification"], "base_cv": mc["stats"]["cv"]}379 # ex7 : cession des 3 immeubles en zone inondable380 inond = pf[pf["zone_inondable"] == 1]; reste = pf[pf["zone_inondable"] == 0]381 mc6 = simuler_portefeuille(reste.reset_index(drop=True), dyn, reg, n=mc["n"], graine=GRAINE)382 d6 = dette(reste)383 out["ex7"] = {"ids": inond["id_immeuble"].tolist(), "valeur_cedee": float(inond["valeur_2026"].sum()), "dette_remboursee": float(inond["dette"].sum()), "produit_net": float(inond["valeur_2026"].sum() * 0.98 - inond["dette"].sum()),384 "rne_perdu": float(inond["rne_2026"].sum()), "taux_pondere_cede": float(inond["rne_2026"].sum() / inond["valeur_2026"].sum()),385 "apres_valeur": float(reste["valeur_2026"].sum()), "apres_taux_pondere": float(reste["rne_2026"].sum() / reste["valeur_2026"].sum()), "apres_rpv": float(reste["dette"].sum() / reste["valeur_2026"].sum()),386 "apres_rcd": d6["total"]["rcd_agrege"], "avant_rcd": det["total"]["rcd_agrege"], "apres_cv": mc6["stats"]["cv"], "avant_cv": mc["stats"]["cv"], "apres_var95": mc6["var95_portefeuille"], "avant_var95": mc["var95_portefeuille"],387 "apres_ratio_div": mc6["ratio_diversification"], "avant_ratio_div": mc["ratio_diversification"]}388 # ex5 : croissance de P36 (72 log., Québec, inondable) vs agrégat de Québec ; choc pandémique389 cr = dyn["par_immeuble"].set_index("id_immeuble").loc["P36"]390 out["ex5"] = {"croissance_P36": float(cr["croissance_annuelle"]), "volatilite_P36": float(cr["volatilite"]), "croissance_quebec": float(dyn["par_rmr"].loc["Québec", "croissance_agregee"]),391 "pandemie_quebec": dyn["pandemie"]["Québec"], "pandemie_portefeuille": dyn["pandemie"]["Portefeuille"], "rendement_total_P36": float(pf.set_index("id_immeuble").loc["P36", "taux_global"]) + float(cr["croissance_annuelle"])}392 return out393394# ----------------------------------------------------------------------------- dossier complet395396def dossier(n_mc: int = N_MC, graine: int = GRAINE) -> dict:397 d = cas4.charger(); pf, se = d["portefeuille"], d["series"]398 som = sommaire_etendu(pf)399 reg = regression_masse(pf); pred = valeurs_predites(pf, reg); iaao = ratios_iaao(pred)400 dyn = dynamique_rne(se, pf)401 ris = risques(pf, reg, pred)402 det = dette(pf, AMORT_BASE); det_alt = dette(pf, AMORT_ALT)403 det_g = dette(pf, AMORT_BASE, croissance=dyn["par_immeuble"].set_index("id_immeuble")["croissance_annuelle"])404 mc = simuler_portefeuille(pf, dyn, reg, n_mc, graine, regime="declare")405 mc_ind = simuler_portefeuille(pf, dyn, reg, n_mc, graine, regime="independant")406 mc_parf = simuler_portefeuille(pf, dyn, reg, n_mc, graine, regime="parfait")407 w = pf["valeur_2026"].values / pf["valeur_2026"].sum()408 S_e = matrice_correlation(pf["rmr"].values, mc["parametres"]["rho_rne"]["global"], mc["parametres"]["rho_rne"]["regional"])409 analytique = variance_normale_portefeuille(mc["sigma_ln_individuel"], w, 0.5 * (S_e + matrice_correlation(pf["rmr"].values, RHO_TAUX["global"], RHO_TAUX["regional"])))410 alloc = allocation(pf, dyn, mc, det)411 rec = reconciliation(pf, iaao, mc, det, som)412 D = {"portefeuille": pf, "series": se, "sommaire": som, "regression": reg, "predites": pred, "iaao": iaao, "dynamique": dyn, "risques": ris,413 "dette": det, "dette_alt": det_alt, "dette_croissance": det_g, "mc": mc, "mc_independant": mc_ind, "mc_parfait": mc_parf, "analytique": analytique,414 "allocation": alloc, "reconciliation": rec}415 D["exercices"] = exercices(D)416 return D417418def imprimer(D: dict) -> None:419 pd.set_option("display.width", 250); pd.set_option("display.max_columns", 40); pd.set_option("display.float_format", lambda v: f"{v:,.4f}")420 pf, som, reg = D["portefeuille"], D["sommaire"], D["regression"]421 print("=" * 110 + "\nCH. 48 — CAS 4 : PORTEFEUILLE INTERRÉGIONAL (DONNÉES SYNTHÉTIQUES, 48 immeubles fictifs) — date d'évaluation", DATE_EVAL)422 print("\n--- 48.2 Sommaire par RMR ---"); print(som["par_rmr"].to_string()); print({k: (round(v, 6) if isinstance(v, float) else v) for k, v in som["total"].items()})423 print("tailles :\n", som["tailles"].to_string()); print("âges :\n", som["ages"].to_string()); print("états :\n", som["etats"].to_string()); print("concentration", som["concentration"])424 print("\n--- 48.4 Modèle de masse ---"); print(reg["table"].to_string()); print("R²", round(reg["r2"], 4), "R² aj.", round(reg["r2_ajuste"], 4), "σ résiduel", round(reg["sigma"], 5), "Duan", round(reg["duan"], 5), "n", reg["n"], "k", reg["k"])425 t = D["predites"]; print(t[["id_immeuble", "rmr", "nb_logements", "age", "etat_1a5", "zone_inondable", "contamination_sol", "rne_2026", "taux_global", "taux_predit", "ecart_pb", "valeur_2026", "valeur_predite", "ratio", "dette", "rpv", "echeance_dette"]].to_string(index=False))426 ia = D["iaao"]; print("IAAO portefeuille", {k: round(v, 4) for k, v in ia["portefeuille"].items()})427 for r in RMR: print(" ", r, {k: (round(v, 4) if isinstance(v, float) else v) for k, v in ia[r].items() if k in ["n", "ratio_median", "COD", "PRD", "PRB"]})428 print("somme prédite", _d(ia["somme_predite"]), "vs individuelle", _d(ia["somme_individuelle"]), _p(ia["ecart_agrege_pct"])); print(ia["extremes"].to_string(index=False))429 print("\n--- 48.5 Dynamique des RNE ---"); dyn = D["dynamique"]; print(dyn["par_rmr"].to_string()); print({k: round(v, 5) for k, v in dyn["total"].items()})430 print("choc pandémique (écart à la tendance, 2020T2-2021T2)", {k: round(v, 4) for k, v in dyn["pandemie"].items()}); print("variation glissante 4 trim. min :", round(dyn["glissant4"].min(), 4), dyn["glissant4"].idxmin())431 print("corrélations", {k: round(v, 4) for k, v in dyn["correlations"].items()}); print(dyn["corr_rmr"].round(3).to_string())432 print("\n--- 48.6 Risques ---"); ris = D["risques"]; print(ris["inondables"][["id_immeuble", "rmr", "nb_logements", "taux_global", "taux_sans_risque", "pb", "valeur_2026", "valeur_sans_risque", "decote", "dette"]].to_string(index=False))433 print({k: (round(v, 5) if isinstance(v, float) else v) for k, v in ris.items() if not isinstance(v, pd.DataFrame)})434 print(ris["sols"][["id_immeuble", "rmr", "nb_logements", "taux_global", "taux_sans_risque", "pb", "valeur_2026", "valeur_sans_risque", "decote"]].to_string(index=False))435 for nom in ["dette", "dette_alt", "dette_croissance"]:436 det = D[nom]; print(f"\n--- Dette ({nom}, amortissement {det['amortissement']} mois) ---"); print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in det["total"].items()})437 print(det["echeancier"].to_string()); print(det["par_rmr"].to_string())438 det = D["dette"]; print(det["par_immeuble"][["id_immeuble", "rmr", "dette", "taux_dette", "echeance", "rpv", "service_dette", "constante", "rcd", "levier", "solde_echeance", "rcd_+0", "rcd_+100", "rcd_+200", "capacite_+200", "injection_+200"]].to_string(index=False))439 print(det["echeancier_rmr"].to_string())440 print("\n--- 48.7 Monte-Carlo ---")441 for nom in ["mc", "mc_independant", "mc_parfait"]:442 m = D[nom]; print(nom, m["regime"], {k: round(v, 1) for k, v in m["stats"].items()}); print(" VaR95 portefeuille", _d(m["var95_portefeuille"]), "somme VaR95 indiv.", _d(m["somme_var95_individuelles"]), "ratio", round(m["ratio_diversification"], 4), "ES95", _d(m["es95_portefeuille"]))443 print(" probabilités", {k: round(v, 4) for k, v in m["probabilites"].items()}); print(" paramètres", m["parametres"], "CV individuel médian", round(m["cv_individuel_median"], 4))444 print(D["mc"]["par_rmr"].to_string()); print("analytique (normale)", {k: round(v, 5) for k, v in D["analytique"].items()})445 print("\n--- 48.8 Allocation ---"); al = D["allocation"]446 print(al["table"][["id_immeuble", "rmr", "nb_logements", "etat_1a5", "taux_global", "croissance", "rendement_total", "sigma_valeur", "ratio_rr", "rcd", "rcd_+200", "rpv", "drapeaux", "score"]].to_string(index=False))447 print("vendre :", al["vendre"]["id_immeuble"].tolist(), "conserver :", al["conserver"]["id_immeuble"].tolist()); print("après cession", {k: round(v, 4) for k, v in al["apres_cession"].items()})448 fr = al["frontiere"]; print("μ par RMR", dict(zip(RMR, fr["mu"].round(5)))); print("σ par RMR", dict(zip(RMR, fr["sigma"].round(5)))); print("corr RMR\n", pd.DataFrame(fr["corr"], index=RMR, columns=RMR).round(3).to_string())449 print("actuel : w", fr["w_actuel"].round(3), "μ", round(fr["mu_actuel"], 5), "σ", round(fr["sigma_actuel"], 5)); print("min var : w", fr["w_min_var"].round(3), "μ", round(fr["mu_min_var"], 5), "σ", round(fr["sigma_min_var"], 5))450 print("même μ : w", fr["w_meme_mu"].round(3), "σ", round(fr["sigma_meme_mu"], 5)); print("même σ : w", fr["w_meme_sigma"].round(3), "μ", round(fr["mu_meme_sigma"], 5))451 print("\n--- 48.9 Réconciliation ---"); print({k: (round(v, 5) if isinstance(v, float) else v) for k, v in D["reconciliation"].items() if not isinstance(v, pd.Series)})452 print("\n--- Exercices ---")453 for k, v in D["exercices"].items(): print(k, {kk: (round(vv, 5) if isinstance(vv, float) else vv) for kk, vv in v.items()})454455if __name__ == "__main__":456 ap = argparse.ArgumentParser(); ap.add_argument("--n", type=int, default=N_MC); a = ap.parse_args()457 imprimer(dossier(a.n))458