#!/usr/bin/env python3 """Chapitre 48 — Cas 4 : portefeuille multirésidentiel interrégional (DONNÉES SYNTHÉTIQUES, 48 immeubles fictifs, 6 RMR). Dossier complet d'évaluation de portefeuille : sommaire par RMR, distribution des tailles/âges/états, modèle d'évaluation de masse (régression semi-logarithmique du taux global : traite.cas4.effets_regionaux, facteur de Duan, valeur prédite par immeuble, statistiques de ratio IAAO), dynamique des RNE 2016-2026 (croissance, volatilité, choc pandémique, corrélations intra- et inter-régionales), risques (inondation, sol, concentration, dette : échéancier, RCD, renouvellement à +100/+200 pb, capacité de refinancement), agrégation de l'incertitude par Monte-Carlo avec corrélation régionale (copule gaussienne, décomposition de Cholesky, 10 000 tirages, graine 20261031 ; démonstration de la diversification : VaR du portefeuille vs somme des VaR), allocation (score rendement/croissance/risque, frontière rendement-risque par RMR), réconciliation et exercices. Les calculs partagés viennent de `traite.cas4` (sommaire, croissance_rne, effets_regionaux), `traite.finance` (hypothèques, RCD, capitalisation) et `traite.hedonique` (MCO HC1, statistiques de ratio) ; ce script n'y ajoute que les calculs propres au dossier et IMPRIME tous les chiffres cités dans manuscript/chapters/ch48.tex. Hypothèses déclarées (voir §48.1 et §48.6 du chapitre) : - date d'évaluation 2026-06-30 (fin de la série de RNE) ; valeurs et RNE « 2026 » du fichier = indications individuelles ; - dette du fichier = solde au 2026-06-30 ; service de la dette calculé sur un amortissement de 480 mois (prêts assurés multilogement, même hypothèse que le ch. 33 pour P45) ; sensibilité à 300 mois ; - renouvellement : à l'échéance, le solde (formule prospective) est refinancé sur l'amortissement résiduel au taux + Δ. Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch48_portefeuille.py [--n 10000] """ from __future__ import annotations import sys, pathlib, argparse, math import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import cas4, finance as fin, hedonique as h ROOT = pathlib.Path(__file__).resolve().parents[3] GRAINE = 20261031 DATE_EVAL = "2026-06-30" RMR = ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"] RMR_REG = ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"] # indicatrices (référence Montréal) # ----------------------------------------------------------------------------- hypothèses propres au chapitre 48 AMORT_BASE = 480 # mois (prêts assurés multilogement ; cohérent avec le ch. 33, P45) AMORT_ALT = 300 # sensibilité RCD_MIN = 1.20 # covenant conventionnel (ch. 31, 47) RPV_MAX = 0.75 CHOCS_PB = (0, 100, 200) N_MC = 10_000 # 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éries RHO_TAUX = {"global": 0.50, "regional": 0.70} OBLIG_10_ANS = 0.0384 # Banque du Canada, 2026-10-29 (notes-C §4.1) — repère pour la frontière ARRONDI_PORTEFEUILLE = 100_000 # ----------------------------------------------------------------------------- utilitaires def _d(x): return f"{x:,.0f}".replace(",", " ") + " $" def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %" def arrondir(x: float, pas: int = ARRONDI_PORTEFEUILLE) -> float: return round(x / pas) * pas # ----------------------------------------------------------------------------- 48.2 portefeuille def sommaire_etendu(pf: pd.DataFrame) -> dict: """Sommaire de traite.cas4.sommaire complété : médiane et étendue des taux, taille moyenne, âge, état, loyer, RNE/logement, part de la valeur.""" s = cas4.sommaire(pf); g = s["par_rmr"].copy() agg = pf.groupby("rmr").agg(taux_median=("taux_global", "median"), taux_min=("taux_global", "min"), taux_max=("taux_global", "max"), taille_moy=("nb_logements", "mean"), age_moy=("age", "mean"), etat_moy=("etat_1a5", "mean"), loyer_moy=("loyer_mensuel_moyen", "mean"), inondables=("zone_inondable", "sum"), sols=("contamination_sol", "sum")) g = g.join(agg); g["rne_par_logement"] = g["rne"] / g["logements"]; g["part_valeur"] = g["valeur"] / s["total"]["valeur"] g["taux_dette_pondere"] = pf.groupby("rmr").apply(lambda d: (d["dette"] * d["taux_dette"]).sum() / d["dette"].sum(), include_groups=False) g = g.loc[RMR] tot = s["total"]; tot["valeur_par_logement"] = tot["valeur"] / tot["logements"]; tot["rne_par_logement"] = tot["rne"] / tot["logements"] tot["taux_dette_pondere"] = float((pf["dette"] * pf["taux_dette"]).sum() / pf["dette"].sum()) 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)) tot["part_sol"] = float(pf.loc[pf["contamination_sol"] == 1, "valeur_2026"].sum() / tot["valeur"]) # distributions tailles = pf.groupby("nb_logements").agg(n=("id_immeuble", "count"), logements=("nb_logements", "sum"), valeur=("valeur_2026", "sum"), taux_median=("taux_global", "median")) bornes_age = [0, 30, 50, 65, 200]; etiq_age = ["< 30 ans", "30-49 ans", "50-64 ans", "65 ans et +"] 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")) 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")) # concentration (Herfindahl) par RMR et par immeuble, sur la valeur w_rmr = g["part_valeur"].values; w_imm = (pf["valeur_2026"] / tot["valeur"]).values 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()), "top5_part": float(np.sort(w_imm)[-5:].sum()), "top5_ids": pf.nlargest(5, "valeur_2026")["id_immeuble"].tolist()} return {"par_rmr": g, "total": tot, "tailles": tailles, "ages": ages, "etats": etats, "concentration": conc} # ----------------------------------------------------------------------------- 48.4 modèle d'évaluation de masse def regression_masse(pf: pd.DataFrame) -> dict: """Même spécification que traite.cas4.effets_regionaux (coefficients identiques) ; conserve résidus, R², facteur de Duan et la matrice X.""" eff = cas4.effets_regionaux(pf) X = pd.DataFrame({"const": 1.0, "etat": pf["etat_1a5"] - 3, "ln_logements": np.log(pf["nb_logements"] / 12), "inondable": pf["zone_inondable"], "sol": pf["contamination_sol"], "age": (pf["age"] - 50) / 10}) for r in RMR_REG: X[f"rmr_{r}"] = (pf["rmr"] == r).astype(float) res = h.mco(X.values, np.log(pf["taux_global"].values)) assert np.allclose(res["beta"], eff["coef"].values) duan = float(np.mean(np.exp(res["residus"]))) tab = eff.copy(); tab["t_hc1"] = tab["coef"] / tab["se_hc1"] tab["effet_pb_a_520"] = tab["effet_pct"] * 0.0520 * 1e4 # effet en points de base au taux pondéré du portefeuille 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"]) - 1 return {"table": tab, "beta": res["beta"], "colonnes": list(X.columns), "X": X.values, "residus": res["residus"], "sigma": float(np.sqrt(res["sigma2"])), "r2": float(res["r2"]), "r2_ajuste": float(res["r2_ajuste"]), "n": int(res["n"]), "k": int(res["k"]), "duan": duan} def x_ligne(rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0) -> np.ndarray: 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] return np.array(x) def taux_predit(reg: dict, rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0, duan: bool = True) -> float: return float(np.exp(x_ligne(rmr, etat, n, age, inond, sol) @ reg["beta"]) * (reg["duan"] if duan else 1.0)) def valeurs_predites(pf: pd.DataFrame, reg: dict) -> pd.DataFrame: """Taux prédit et valeur prédite (RNE / taux prédit) de chaque immeuble ; ratio valeur prédite / indication individuelle.""" t = pf.copy() t["taux_predit"] = np.exp(reg["X"] @ reg["beta"]) * reg["duan"] t["valeur_predite"] = t["rne_2026"] / t["taux_predit"] t["ratio"] = t["valeur_predite"] / t["valeur_2026"] t["ecart_pb"] = (t["taux_predit"] - t["taux_global"]) * 1e4 t["rpv"] = t["dette"] / t["valeur_2026"]; t["valeur_par_logement"] = t["valeur_2026"] / t["nb_logements"] return t def ratios_iaao(t: pd.DataFrame) -> dict: """Statistiques de ratio (IAAO) : modèle de masse vs indications individuelles, portefeuille et par RMR.""" out = {"portefeuille": h.statistiques_ratio(t["valeur_predite"].values, t["valeur_2026"].values)} for r in RMR: s = t[t["rmr"] == r] 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")} out["somme_predite"] = float(t["valeur_predite"].sum()); out["somme_individuelle"] = float(t["valeur_2026"].sum()) out["ecart_agrege_pct"] = out["somme_predite"] / out["somme_individuelle"] - 1 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"]] return out # ----------------------------------------------------------------------------- 48.5 dynamique des RNE def dynamique_rne(se: pd.DataFrame, pf: pd.DataFrame) -> dict: cr = cas4.croissance_rne(se).merge(pf[["id_immeuble", "rmr", "valeur_2026", "zone_inondable"]], on="id_immeuble") piv = se.pivot(index="trimestre", columns="id_immeuble", values="rne_trimestriel").sort_index() rmr_de = pf.set_index("id_immeuble")["rmr"] agg_rmr = piv.T.groupby(rmr_de).sum().T[RMR]; agg_tot = piv.sum(axis=1) t = np.arange(len(piv)) / 4.0 def croiss(y): b = np.polyfit(t, np.log(y.values), 1)[0]; return float(np.exp(b) - 1) def vol(y): return float(np.std(np.diff(np.log(y.values))) * 2) 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}, "croissance_mediane_immeubles": cr.groupby("rmr")["croissance_annuelle"].median(), "croissance_min": cr.groupby("rmr")["croissance_annuelle"].min(), "croissance_max": cr.groupby("rmr")["croissance_annuelle"].max(), "volatilite_mediane_immeubles": cr.groupby("rmr")["volatilite"].median(), "n": cr.groupby("rmr")["id_immeuble"].count()}).loc[RMR] tot = {"croissance_agregee": croiss(agg_tot), "volatilite_agregee": vol(agg_tot), "croissance_mediane_immeubles": float(cr["croissance_annuelle"].median()), "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), "rne_fichier_2026": float(pf["rne_2026"].sum())} # choc pandémique : écart moyen (log) à la tendance pendant 2020T2-2021T2, par RMR et total def choc(y): ly = np.log(y.values); b = np.polyfit(t, ly, 1); trend = np.polyval(b, t) m = (piv.index >= pd.Period("2020Q2", "Q")) & (piv.index <= pd.Period("2021Q2", "Q")) return float(np.exp(np.mean(ly[m] - trend[m])) - 1) pand = {r: choc(agg_rmr[r]) for r in RMR}; pand["Portefeuille"] = choc(agg_tot) # variation glissante sur 4 trimestres de l'agrégat (point bas) g4 = agg_tot / agg_tot.shift(4) - 1 # corrélations des variations trimestrielles (log) entre immeubles dl = np.log(piv).diff().dropna() C = dl.corr(); ids = list(piv.columns); r_of = rmr_de.loc[ids].values same = np.equal.outer(r_of, r_of); iu = np.triu_indices(len(ids), 1) cvals = C.values[iu]; svals = same[iu] corr = {"intra_rmr_moyenne": float(cvals[svals].mean()), "inter_rmr_moyenne": float(cvals[~svals].mean()), "toutes_moyenne": float(cvals.mean()), "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())} # corrélations hors période pandémique (le choc commun crée de la corrélation) m_hors = ~((dl.index >= pd.Period("2020Q1", "Q")) & (dl.index <= pd.Period("2021Q3", "Q"))) C2 = dl[m_hors].corr().values[iu] corr["intra_hors_pandemie"] = float(C2[svals].mean()); corr["inter_hors_pandemie"] = float(C2[~svals].mean()) corr_rmr = np.log(agg_rmr).diff().dropna().corr() return {"par_immeuble": cr, "par_rmr": reg_tab, "total": tot, "agregats_rmr": agg_rmr, "agregat_total": agg_tot, "pandemie": pand, "glissant4": g4, "correlations": corr, "corr_rmr": corr_rmr, "matrice_corr_immeubles": C, "diff_log": dl} # ----------------------------------------------------------------------------- 48.6 risques et dette def risques(pf: pd.DataFrame, reg: dict, t: pd.DataFrame) -> dict: tot_v = float(pf["valeur_2026"].sum()) inond = pf[pf["zone_inondable"] == 1].copy(); sol = pf[pf["contamination_sol"] == 1].copy() b_in = float(reg["table"].loc["inondable", "coef"]); b_sol = float(reg["table"].loc["sol", "coef"]) # valeur « sans le risque » : même RNE, taux divisé par exp(β) (effet proportionnel du modèle) 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(β) inond["decote"] = inond["valeur_2026"] / inond["valeur_sans_risque"] - 1; inond["pb"] = (inond["taux_global"] - inond["taux_sans_risque"]) * 1e4 sol["taux_sans_risque"] = sol["taux_global"] / np.exp(b_sol); sol["valeur_sans_risque"] = sol["valeur_2026"] * np.exp(b_sol) sol["decote"] = sol["valeur_2026"] / sol["valeur_sans_risque"] - 1; sol["pb"] = (sol["taux_global"] - sol["taux_sans_risque"]) * 1e4 return {"inondables": inond, "part_inondable": float(inond["valeur_2026"].sum() / tot_v), "valeur_inondable": float(inond["valeur_2026"].sum()), "decote_inondable_agregee": float(inond["valeur_2026"].sum() / inond["valeur_sans_risque"].sum() - 1), "effet_inondable_pct_taux": float(np.exp(b_in) - 1), "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), "decote_sol_agregee": float(sol["valeur_2026"].sum() / sol["valeur_sans_risque"].sum() - 1), "dette_inondable": float(inond["dette"].sum()), "rpv_inondable": float(inond["dette"].sum() / inond["valeur_2026"].sum())} def dette(pf: pd.DataFrame, amort: int = AMORT_BASE, chocs=CHOCS_PB, croissance: pd.Series | None = None) -> dict: """Service de la dette par immeuble (amortissement `amort`), RCD, échéancier, renouvellement à +Δ pb, capacité de refinancement.""" rows = [] for r in pf.to_dict("records"): hyp = fin.Hypotheque(float(r["dette"]), float(r["taux_dette"]), amort) sd = hyp.service_dette_annuel; rne = float(r["rne_2026"]) annees = int(r["echeance_dette"]) - 2026; mois = annees * 12 solde = hyp.solde(mois); amort_res = amort - mois g = float(croissance.loc[r["id_immeuble"]]) if croissance is not None else 0.0 rne_ech = rne * (1 + g) ** annees 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"], "rpv": r["dette"] / r["valeur_2026"], "service_dette": sd, "constante": hyp.constante_hypothecaire, "rcd": rne / sd, "solde_echeance": solde, "rne_echeance": rne_ech, "levier": "négatif" if hyp.constante_hypothecaire > r["taux_global"] else "positif"} for pb in chocs: h2 = fin.Hypotheque(solde, float(r["taux_dette"]) + pb / 1e4, amort_res) sd2 = h2.service_dette_annuel cap_rcd = fin.pret_max_par_rcd(rne_ech, RCD_MIN, float(r["taux_dette"]) + pb / 1e4, amort_res) cap_rpv = fin.pret_max_par_rpv(float(r["valeur_2026"]), RPV_MAX) cap = min(cap_rcd, cap_rpv) 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) rows.append(row) d = pd.DataFrame(rows) tot = {"dette": float(d["dette"].sum()), "service_dette": float(d["service_dette"].sum()), "rcd_agrege": float(pf["rne_2026"].sum() / d["service_dette"].sum()), "rcd_median": float(d["rcd"].median()), "rcd_min": float(d["rcd"].min()), "id_rcd_min": d.loc[d["rcd"].idxmin(), "id_immeuble"], "n_rcd_sous_1.20": int((d["rcd"] < 1.20).sum()), "n_rcd_sous_1.00": int((d["rcd"] < 1.0).sum()), "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()), "constante_pondere": float(d["service_dette"].sum() / d["dette"].sum()), "n_levier_negatif": int((d["levier"] == "négatif").sum()), "valeur_levier_negatif": float(d.loc[d["levier"] == "négatif", "valeur"].sum() / d["valeur"].sum())} for pb in chocs: tot[f"rcd_agrege_+{pb}"] = float(d["rne_echeance"].sum() / d[f"sd_+{pb}"].sum()) 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()) tot[f"injection_+{pb}"] = float(d[f"injection_+{pb}"].sum()); tot[f"n_injection_+{pb}"] = int((d[f"injection_+{pb}"] > 0).sum()) tot[f"solde_echeance"] = float(d["solde_echeance"].sum()) ech = d.groupby("echeance").agg(n=("id_immeuble", "count"), dette=("dette", "sum"), solde=("solde_echeance", "sum"), rne=("rne_echeance", "sum")) for pb in chocs: ech[f"sd_+{pb}"] = d.groupby("echeance")[f"sd_+{pb}"].sum(); ech[f"rcd_+{pb}"] = ech["rne"] / ech[f"sd_+{pb}"] ech[f"injection_+{pb}"] = d.groupby("echeance")[f"injection_+{pb}"].sum() ech["part_dette"] = ech["dette"] / tot["dette"] ech_rmr = d.pivot_table(index="echeance", columns="rmr", values="dette", aggfunc="sum").fillna(0.0)[RMR] par_rmr = d.groupby("rmr").agg(dette=("dette", "sum"), sd=("service_dette", "sum"), rpv=("rpv", "mean")).loc[RMR] 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] return {"par_immeuble": d, "total": tot, "echeancier": ech, "echeancier_rmr": ech_rmr, "par_rmr": par_rmr, "amortissement": amort} # ----------------------------------------------------------------------------- 48.7 Monte-Carlo avec corrélation régionale def matrice_correlation(rmr_vec: np.ndarray, rho_global: float, rho_regional: float) -> np.ndarray: """Matrice de corrélation à deux facteurs (national, régional) : ρ_ij = ρ_reg si même RMR, ρ_glob sinon, 1 sur la diagonale.""" same = np.equal.outer(rmr_vec, rmr_vec) S = np.where(same, rho_regional, rho_global).astype(float); np.fill_diagonal(S, 1.0) return S def 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, sigma_taux: float | None = None, regime: str = "declare") -> dict: """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 une structure à deux facteurs (copule gaussienne, échantillonnée par décomposition de Cholesky). σ_ε,i = volatilité annualisée estimée du RNE de l'immeuble ; σ_η = écart-type résiduel de la régression du ln taux (même pour tous). regime ∈ {declare, independant, parfait}.""" ids = pf["id_immeuble"].values; rmr_vec = pf["rmr"].values; N = len(pf) vol = dyn["par_immeuble"].set_index("id_immeuble").loc[ids, "volatilite"].values sig_eta = reg["sigma"] if sigma_taux is None else sigma_taux corr = dyn["correlations"] # estimation sur les séries : corrélation inter-RMR moyenne des variations trimestrielles ; la composante régionale additionnelle est # max(intra − inter, 0) — nulle dans ce jeu, où le seul facteur commun est le choc pandémique (voir §48.5) 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)} if regime == "independant": S_e = np.eye(N); S_n = np.eye(N) elif regime == "parfait": S_e = np.ones((N, N)); S_n = np.ones((N, N)) else: S_e = matrice_correlation(rmr_vec, rho_rne["global"], rho_rne["regional"]); S_n = matrice_correlation(rmr_vec, rho_taux["global"], rho_taux["regional"]) L_e = np.linalg.cholesky(S_e + 1e-10 * np.eye(N)); L_n = np.linalg.cholesky(S_n + 1e-10 * np.eye(N)) rng = np.random.default_rng(graine) Z_e = rng.standard_normal((n, N)) @ L_e.T; Z_n = rng.standard_normal((n, N)) @ L_n.T eps = Z_e * vol[None, :]; eta = Z_n * sig_eta V0 = pf["valeur_2026"].values.astype(float); RNE0 = pf["rne_2026"].values.astype(float); R0 = pf["taux_global"].values.astype(float) V = (RNE0[None, :] * np.exp(eps)) / (R0[None, :] * np.exp(eta)) Vp = V.sum(1); tot0 = V0.sum() q = lambda a, p: float(np.quantile(a, p)) 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()), "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())} # VaR à 95 % (perte par rapport à la valeur centrale V0) : portefeuille vs somme des VaR individuelles var_p = tot0 - q(Vp, 0.05); var_i = V0 - np.quantile(V, 0.05, axis=0); somme_var_i = float(var_i.sum()) es_p = tot0 - float(Vp[Vp <= q(Vp, 0.05)].mean()) dette_tot = float(pf["dette"].sum()) 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()), "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())} # par RMR par_rmr = {} for r in RMR: m = rmr_vec == r; Vr = V[:, m].sum(1); v0 = V0[m].sum() 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)), "somme_var_individuelles": float((V0[m] - np.quantile(V[:, m], 0.05, axis=0)).sum())} par_rmr = pd.DataFrame(par_rmr).T sigma_ln_i = np.sqrt(vol ** 2 + sig_eta ** 2) return {"n": n, "graine": graine, "regime": regime, "valeur_portefeuille": Vp, "valeurs": V, "stats": stats, "var95_portefeuille": var_p, "somme_var95_individuelles": somme_var_i, "ratio_diversification": var_p / somme_var_i, "es95_portefeuille": es_p, "var95_individuelles": var_i, "probabilites": probs, "par_rmr": par_rmr, "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())}, "sigma_ln_individuel": sigma_ln_i, "cv_individuel_median": float(np.median(V.std(axis=0, ddof=1) / V.mean(axis=0)))} def variance_normale_portefeuille(sigmas: np.ndarray, poids: np.ndarray, S: np.ndarray) -> dict: """Démonstration analytique : σ_P² = w'Σw ; comparaison avec (Σ w_i σ_i)² (corrélation parfaite) et Σ w_i² σ_i² (indépendance).""" Sig = np.outer(sigmas, sigmas) * S 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()))} # ----------------------------------------------------------------------------- 48.8 allocation et arbitrage def allocation(pf: pd.DataFrame, dyn: dict, mc: dict, det: dict) -> dict: t = pf[["id_immeuble", "rmr", "nb_logements", "age", "etat_1a5", "zone_inondable", "contamination_sol", "rne_2026", "taux_global", "valeur_2026", "dette", "echeance_dette"]].copy() cr = dyn["par_immeuble"].set_index("id_immeuble") t["croissance"] = cr.loc[t["id_immeuble"], "croissance_annuelle"].values; t["volatilite_rne"] = cr.loc[t["id_immeuble"], "volatilite"].values t["rendement_total"] = t["taux_global"] + t["croissance"] # R + g (Gordon, ch. 29) t["sigma_valeur"] = mc["sigma_ln_individuel"] t["ratio_rr"] = (t["rendement_total"] - OBLIG_10_ANS) / t["sigma_valeur"] # excédent de rendement par unité de risque d = det["par_immeuble"].set_index("id_immeuble") 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"] t["drapeaux"] = t["zone_inondable"] + t["contamination_sol"] + (t["rcd_+200"] < 1.0).astype(int) + (t["etat_1a5"] <= 2).astype(int) t["score"] = t["ratio_rr"].rank(pct=True) - 0.10 * t["drapeaux"] t = t.sort_values("score", ascending=False).reset_index(drop=True) vendre = t.tail(6).copy(); conserver = t.head(6).copy() # portefeuille après cession des 6 candidats (produit net = valeur − dette ; coûts de vente 2 %) reste = pf[~pf["id_immeuble"].isin(vendre["id_immeuble"])] apres = {"n": len(reste), "valeur": float(reste["valeur_2026"].sum()), "rne": float(reste["rne_2026"].sum()), "dette": float(reste["dette"].sum()), "taux_pondere": float(reste["rne_2026"].sum() / reste["valeur_2026"].sum()), "rpv": float(reste["dette"].sum() / reste["valeur_2026"].sum()), "produit_net_cession": float((vendre["valeur_2026"] * 0.98 - vendre["dette"]).sum()), "valeur_cedee": float(vendre["valeur_2026"].sum()), "part_inondable": float(reste.loc[reste["zone_inondable"] == 1, "valeur_2026"].sum() / reste["valeur_2026"].sum())} # frontière rendement-risque par RMR : μ_r = taux pondéré + croissance agrégée ; Σ_r = covariance des rendements simulés des agrégats régionaux g = cas4.sommaire(pf)["par_rmr"].loc[RMR] mu = (g["taux_pondere"] + dyn["par_rmr"]["croissance_agregee"]).values rmr_vec = pf["rmr"].values; V = mc["valeurs"] rend = np.column_stack([np.log(V[:, rmr_vec == r].sum(1) / pf.loc[rmr_vec == r, "valeur_2026"].sum()) for r in RMR]) Sigma = np.cov(rend.T); sig = np.sqrt(np.diag(Sigma)) w0 = (g["valeur"] / g["valeur"].sum()).values from scipy.optimize import minimize def frontiere(cibles): pts = [] for m in cibles: cons = [{"type": "eq", "fun": lambda w: w.sum() - 1}, {"type": "eq", "fun": lambda w, m=m: w @ mu - m}] 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}) if r.success: pts.append({"mu": m, "sigma": float(np.sqrt(r.fun)), "poids": r.x}) return pts cibles = np.linspace(mu.min() + 1e-6, mu.max() - 1e-6, 41) fr = frontiere(cibles) 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") w_min = r_min.x # portefeuille de même rendement attendu que l'actuel, à risque minimal mu0 = float(w0 @ mu); sig0 = float(np.sqrt(w0 @ Sigma @ w0)) 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}) # portefeuille de même risque, rendement maximal 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}) front = {"mu": mu, "sigma": sig, "Sigma": Sigma, "corr": Sigma / np.outer(sig, sig), "w_actuel": w0, "mu_actuel": mu0, "sigma_actuel": sig0, "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)), "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)} return {"table": t, "vendre": vendre, "conserver": conserver, "apres_cession": apres, "frontiere": front} # ----------------------------------------------------------------------------- 48.9 réconciliation def reconciliation(pf: pd.DataFrame, iaao: dict, mc: dict, det: dict, som: dict) -> dict: ind = float(pf["valeur_2026"].sum()); pred = iaao["somme_predite"] V = arrondir(ind) return {"somme_individuelle": ind, "somme_modele": pred, "ecart_modele_pct": pred / ind - 1, "valeur_retenue": V, "fourchette_mc": (mc["stats"]["q05"], mc["stats"]["q95"]), "fourchette_mc_pct": (mc["stats"]["q05"] / ind - 1, mc["stats"]["q95"] / ind - 1), "valeur_par_logement": V / som["total"]["logements"], "taux_implicite": som["total"]["rne"] / V, "rpv": som["total"]["dette"] / V, "rcd_agrege": det["total"]["rcd_agrege"], "par_rmr": som["par_rmr"]["valeur"], "mrb": V / float(pf["revenu_brut_potentiel"].sum()), "fonds_propres": V - som["total"]["dette"]} # ----------------------------------------------------------------------------- exercices def exercices(D: dict) -> dict: pf, reg, dyn, det, mc = D["portefeuille"], D["regression"], D["dynamique"], D["dette"], D["mc"] out = {} # ex1 : Sherbrooke — taux pondéré vs moyenne simple, valeur/logement, RPV s = pf[pf["rmr"] == "Sherbrooke"] 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()), "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()), "valeur_par_logement": float(s["valeur_2026"].sum() / s["nb_logements"].sum()), "rpv": float(s["dette"].sum() / s["valeur_2026"].sum()), "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)} # ex2 : modèle de masse — 24 log., état 3, 45 ans, Trois-Rivières, inondable ; RNE 150 000 rne = 150_000.0 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) 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, "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"]))} # ex3 : P45 — RCD 480 et 300 mois, renouvellement 2028 à +200 pb, capacité de refinancement p = pf.set_index("id_immeuble").loc["P45"] h480 = fin.Hypotheque(float(p["dette"]), float(p["taux_dette"]), 480); h300 = fin.Hypotheque(float(p["dette"]), float(p["taux_dette"]), 300) mois = (int(p["echeance_dette"]) - 2026) * 12; solde = h480.solde(mois); t2 = float(p["taux_dette"]) + 0.02 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"])) 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, "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, "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"]), "capacite": cap, "injection": max(solde - cap, 0.0), "valeur_ch33": 6_716_534.0, "rpv_ch33": float(p["dette"]) / 6_716_534.0} # ex4 : ratios IAAO sur le sous-ensemble montréalais t = D["predites"]; m = t[t["rmr"] == "Montréal"] out["ex4"] = {**h.statistiques_ratio(m["valeur_predite"].values, m["valeur_2026"].values), "portefeuille": D["iaao"]["portefeuille"]} # ex6 : choc de taux généralisé — ρ_taux global 0,90 / régional 0,95 mc5 = simuler_portefeuille(pf, dyn, reg, n=mc["n"], graine=GRAINE, rho_taux={"global": 0.90, "regional": 0.95}) out["ex6"] = {"q05": mc5["stats"]["q05"], "q95": mc5["stats"]["q95"], "cv": mc5["stats"]["cv"], "var95": mc5["var95_portefeuille"], "ratio_div": mc5["ratio_diversification"], "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"]} # ex7 : cession des 3 immeubles en zone inondable inond = pf[pf["zone_inondable"] == 1]; reste = pf[pf["zone_inondable"] == 0] mc6 = simuler_portefeuille(reste.reset_index(drop=True), dyn, reg, n=mc["n"], graine=GRAINE) d6 = dette(reste) 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()), "rne_perdu": float(inond["rne_2026"].sum()), "taux_pondere_cede": float(inond["rne_2026"].sum() / inond["valeur_2026"].sum()), "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()), "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"], "apres_ratio_div": mc6["ratio_diversification"], "avant_ratio_div": mc["ratio_diversification"]} # ex5 : croissance de P36 (72 log., Québec, inondable) vs agrégat de Québec ; choc pandémique cr = dyn["par_immeuble"].set_index("id_immeuble").loc["P36"] out["ex5"] = {"croissance_P36": float(cr["croissance_annuelle"]), "volatilite_P36": float(cr["volatilite"]), "croissance_quebec": float(dyn["par_rmr"].loc["Québec", "croissance_agregee"]), "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"])} return out # ----------------------------------------------------------------------------- dossier complet def dossier(n_mc: int = N_MC, graine: int = GRAINE) -> dict: d = cas4.charger(); pf, se = d["portefeuille"], d["series"] som = sommaire_etendu(pf) reg = regression_masse(pf); pred = valeurs_predites(pf, reg); iaao = ratios_iaao(pred) dyn = dynamique_rne(se, pf) ris = risques(pf, reg, pred) det = dette(pf, AMORT_BASE); det_alt = dette(pf, AMORT_ALT) det_g = dette(pf, AMORT_BASE, croissance=dyn["par_immeuble"].set_index("id_immeuble")["croissance_annuelle"]) mc = simuler_portefeuille(pf, dyn, reg, n_mc, graine, regime="declare") mc_ind = simuler_portefeuille(pf, dyn, reg, n_mc, graine, regime="independant") mc_parf = simuler_portefeuille(pf, dyn, reg, n_mc, graine, regime="parfait") w = pf["valeur_2026"].values / pf["valeur_2026"].sum() S_e = matrice_correlation(pf["rmr"].values, mc["parametres"]["rho_rne"]["global"], mc["parametres"]["rho_rne"]["regional"]) analytique = variance_normale_portefeuille(mc["sigma_ln_individuel"], w, 0.5 * (S_e + matrice_correlation(pf["rmr"].values, RHO_TAUX["global"], RHO_TAUX["regional"]))) alloc = allocation(pf, dyn, mc, det) rec = reconciliation(pf, iaao, mc, det, som) D = {"portefeuille": pf, "series": se, "sommaire": som, "regression": reg, "predites": pred, "iaao": iaao, "dynamique": dyn, "risques": ris, "dette": det, "dette_alt": det_alt, "dette_croissance": det_g, "mc": mc, "mc_independant": mc_ind, "mc_parfait": mc_parf, "analytique": analytique, "allocation": alloc, "reconciliation": rec} D["exercices"] = exercices(D) return D def imprimer(D: dict) -> None: pd.set_option("display.width", 250); pd.set_option("display.max_columns", 40); pd.set_option("display.float_format", lambda v: f"{v:,.4f}") pf, som, reg = D["portefeuille"], D["sommaire"], D["regression"] print("=" * 110 + "\nCH. 48 — CAS 4 : PORTEFEUILLE INTERRÉGIONAL (DONNÉES SYNTHÉTIQUES, 48 immeubles fictifs) — date d'évaluation", DATE_EVAL) 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()}) 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"]) 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"]) 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)) ia = D["iaao"]; print("IAAO portefeuille", {k: round(v, 4) for k, v in ia["portefeuille"].items()}) 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"]}) 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)) 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()}) 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()) print("corrélations", {k: round(v, 4) for k, v in dyn["correlations"].items()}); print(dyn["corr_rmr"].round(3).to_string()) 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)) print({k: (round(v, 5) if isinstance(v, float) else v) for k, v in ris.items() if not isinstance(v, pd.DataFrame)}) print(ris["sols"][["id_immeuble", "rmr", "nb_logements", "taux_global", "taux_sans_risque", "pb", "valeur_2026", "valeur_sans_risque", "decote"]].to_string(index=False)) for nom in ["dette", "dette_alt", "dette_croissance"]: 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()}) print(det["echeancier"].to_string()); print(det["par_rmr"].to_string()) 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)) print(det["echeancier_rmr"].to_string()) print("\n--- 48.7 Monte-Carlo ---") for nom in ["mc", "mc_independant", "mc_parfait"]: 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"])) 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)) print(D["mc"]["par_rmr"].to_string()); print("analytique (normale)", {k: round(v, 5) for k, v in D["analytique"].items()}) print("\n--- 48.8 Allocation ---"); al = D["allocation"] 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)) 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()}) 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()) 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)) 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)) 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)}) print("\n--- Exercices ---") 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()}) if __name__ == "__main__": ap = argparse.ArgumentParser(); ap.add_argument("--n", type=int, default=N_MC); a = ap.parse_args() imprimer(dossier(a.n))