SPB Git

Ch. 20 livré et validé (compilation isolée, tests, fusion des registres)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Oct 31, 2026) parent 9dc3847 Browse files

69 changed files +5,128 −47

69 changed files
modified analysis/python/cases/ch20_reconciliation.py +5 −3
@@ -89,12 +89,12 @@ def valeur_ponderee(P_aj: np.ndarray, brut: np.ndarray, plancher: float = PLANCH
89 89
90 90
91 # ---------------------------------------------------------------- 2. bootstrap des comparables 91 # ---------------------------------------------------------------- 2. bootstrap des comparables
92−def bootstrap(b: dict, B: int = B, graine: int = GRAINE) -> dict: 92+def bootstrap(b: dict, B: int = B, graine: int = GRAINE, k: int | None = None) -> dict:
93 """Trois rééchantillonnages : (A) les 16 candidats avec remise puis sélection des 5 plus proches (modèle fixe) ; 93 """Trois rééchantillonnages : (A) les 16 candidats avec remise puis sélection des 5 plus proches (modèle fixe) ;
94 (B) les 600 ventes avec remise, réestimation du modèle, grille des 5 comparables fixes et prédiction du sujet ; 94 (B) les 600 ventes avec remise, réestimation du modèle, grille des 5 comparables fixes et prédiction du sujet ;
95 (C) les deux à la fois (même réplication b pour la corrélation) ; variante paramétrique β* ~ N(β̂, V_HC1).""" 95 (C) les deux à la fois (même réplication b pour la corrélation) ; variante paramétrique β* ~ N(β̂, V_HC1)."""
96 rng = np.random.default_rng(graine) 96 rng = np.random.default_rng(graine)
97− n_c = len(b["P16"]); k = len(b["comps"]) 97+ n_c = len(b["P16"]); k = k or len(b["comps"])
98 g0 = grille_rapide(b["beta"], b["Xs"], b["Xc16"], b["P16"], b["G"]) 98 g0 = grille_rapide(b["beta"], b["Xs"], b["Xc16"], b["P16"], b["G"])
99 P_aj16, brut16 = g0["P_aj"], g0["brut"] 99 P_aj16, brut16 = g0["P_aj"], g0["brut"]
100 # (A) candidats 100 # (A) candidats
@@ -335,7 +335,9 @@ def exercices(b: dict, bs: dict, co: dict, cb: dict) -> dict:
335 w2 = np.array([0.30, 0.55, 0.15]); w1 = np.array([POIDS_RAISONNES["comparaison"], POIDS_RAISONNES["hedonique"], POIDS_RAISONNES["cout"]]) 335 w2 = np.array([0.30, 0.55, 0.15]); w1 = np.array([POIDS_RAISONNES["comparaison"], POIDS_RAISONNES["hedonique"], POIDS_RAISONNES["cout"]])
336 V = np.array([b["V_comp"], b["V_hed"], co["valeur"]]); V16v = np.array([V16, b["V_hed"], co["valeur"]]) 336 V = np.array([b["V_comp"], b["V_hed"], co["valeur"]]); V16v = np.array([V16, b["V_hed"], co["valeur"]])
337 a = ARRONDI_CONCLUSION 337 a = ARRONDI_CONCLUSION
338− out["ex6"] = {"V_30_55_15": float(w2 @ V), "arrondi_30_55_15": round(float(w2 @ V) / a) * a, "V16": V16, 338+ bs8 = bootstrap(b, B=B, k=8)
339+ out["ex6"] = {"sd_conjoint_k8": bs8["resume"]["conjoint"]["ecart_type"], "V_k8": float(valeur_ponderee(bs["P_aj16"][:8], bs["brut16"][:8])),
340+ "V_30_55_15": float(w2 @ V), "arrondi_30_55_15": round(float(w2 @ V) / a) * a, "V16": V16,
339 "V_k16_raisonnee": float(w1 @ V16v), "arrondi_k16": round(float(w1 @ V16v) / a) * a, 341 "V_k16_raisonnee": float(w1 @ V16v), "arrondi_k16": round(float(w1 @ V16v) / a) * a,
340 "V_55_30_15": float(np.array([0.55, 0.30, 0.15]) @ V), "arrondi_55_30_15": round(float(np.array([0.55, 0.30, 0.15]) @ V) / a) * a} 342 "V_55_30_15": float(np.array([0.55, 0.30, 0.15]) @ V), "arrondi_55_30_15": round(float(np.array([0.55, 0.30, 0.15]) @ V) / a) * a}
341 # ex. 7 : incidence d'une hypothèse extraordinaire (eq. 10.incidence / 10.esperance) 343 # ex. 7 : incidence d'une hypothèse extraordinaire (eq. 10.incidence / 10.esperance)
modified analysis/python/cases/ch36_panel.py +20 −1
@@ -236,6 +236,24 @@ def exercices() -> dict:
236 out["ex6"] = {"att": att, "moyenne_post": float(np.mean([att[2], att[3]]))} 236 out["ex6"] = {"att": att, "moyenne_post": float(np.mean([att[2], att[3]]))}
237 return out 237 return out
238 238
239+def exercice_7(se: pd.DataFrame) -> pd.DataFrame:
240+ """Contrôle synthétique de la cohorte A selon le bassin de donneurs, et placebo temporel (mise en service fictive 2020T3)."""
241+ W = se.pivot(index="id_immeuble", columns="t", values="y"); coh = se.groupby("id_immeuble")["cohorte"].first()
242+ rmr = se.groupby("id_immeuble")["rmr"].first()
243+ A = W.loc[coh[coh == "A"].index].mean(axis=0).values
244+ rows = []
245+ for nom, ids, T0 in [("35 donneurs, six RMR", coh[coh == "jamais"].index, T_A),
246+ ("9 donneurs, Montréal", coh[(coh == "jamais") & (rmr == RMR_A)].index, T_A),
247+ ("26 donneurs, hors Montréal", coh[(coh == "jamais") & (rmr != RMR_A)].index, T_A),
248+ ("placebo 2020T3, 35 donneurs", coh[coh == "jamais"].index, 18)]:
249+ D = W.loc[ids]; Y0 = (D.values - D.values[:, :T0].mean(axis=1, keepdims=True)).T; A_c = A - A[:T0].mean()
250+ r = pn.controle_synthetique(Y0, A_c, T0)
251+ fin = T_A if T0 < T_A else W.shape[1] # placebo : écart moyen jusqu'à la vraie mise en service
252+ effet = float(r["ecarts"][T0:fin].mean())
253+ w = pd.Series(r["poids"], index=ids); top = ", ".join(f"{i} {v:.2f}" for i, v in w.sort_values(ascending=False).head(3).items())
254+ rows.append({"variante": nom, "n_donneurs": len(ids), "rmspe_pre": r["rmspe_pre"], "effet_post": effet, "n_poids_sup_1pct": int((w > 0.01).sum()), "trois_premiers": top})
255+ return pd.DataFrame(rows)
256+
239 # ----------------------------------------------------------------------------- orchestration 257 # ----------------------------------------------------------------------------- orchestration
240 258
241 def main(verbose: bool = True) -> dict: 259 def main(verbose: bool = True) -> dict:
@@ -252,7 +270,7 @@ def main(verbose: bool = True) -> dict:
252 res["rdd"] = discontinuite() 270 res["rdd"] = discontinuite()
253 res["sens"] = sensibilite(se); res["temoins"] = groupes_temoins(se) 271 res["sens"] = sensibilite(se); res["temoins"] = groupes_temoins(se)
254 res["labo"] = laboratoire() 272 res["labo"] = laboratoire()
255− res["ex"] = exercices() 273+ res["ex"] = exercices(); res["ex7"] = exercice_7(se)
256 _CACHE["res"] = res 274 _CACHE["res"] = res
257 if verbose: imprimer(res) 275 if verbose: imprimer(res)
258 return res 276 return res
@@ -279,6 +297,7 @@ def imprimer(res: dict) -> None:
279 print(sv["event_study"][["coef", "se_grappes"]].round(4).T) 297 print(sv["event_study"][["coef", "se_grappes"]].round(4).T)
280 print("Témoins :\n", res["temoins"].round(4)) 298 print("Témoins :\n", res["temoins"].round(4))
281 L = res["labo"]; print(f"Laboratoire : TWFE {L['twfe']:.4f} (se {L['se_grappes']:.4f}) ; vérité {L['verite']:.4f} (k {L['verite_k']:.4f}, l {L['verite_l']:.4f}) ; CS {L['cs']['globale']:.4f} (se {L['cs']['globale_se_boot']:.4f})\n{L['cs']['par_cohorte'].round(4)}\n{L['bacon']['table'].round(4)}\n pondéré {L['bacon']['beta_pondere']:.6f} TWFE {L['bacon']['beta_twfe']:.6f}") 299 L = res["labo"]; print(f"Laboratoire : TWFE {L['twfe']:.4f} (se {L['se_grappes']:.4f}) ; vérité {L['verite']:.4f} (k {L['verite_k']:.4f}, l {L['verite_l']:.4f}) ; CS {L['cs']['globale']:.4f} (se {L['cs']['globale_se_boot']:.4f})\n{L['cs']['par_cohorte'].round(4)}\n{L['bacon']['table'].round(4)}\n pondéré {L['bacon']['beta_pondere']:.6f} TWFE {L['bacon']['beta_twfe']:.6f}")
300+ print("Exercice 7 :\n", res["ex7"].round(4).to_string())
282 print("Exercices :", {k: (vv if not isinstance(vv, dict) else {kk: (np.round(x, 4) if isinstance(x, (float, np.ndarray)) else x) for kk, x in vv.items()}) for k, vv in res["ex"].items()}) 301 print("Exercices :", {k: (vv if not isinstance(vv, dict) else {kk: (np.round(x, 4) if isinstance(x, (float, np.ndarray)) else x) for kk, x in vv.items()}) for k, vv in res["ex"].items()})
283 302
284 def controle_croise(res: dict) -> None: 303 def controle_croise(res: dict) -> None:
modified analysis/python/cases/ch37_ml.py +5 −6
@@ -17,7 +17,7 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
17 from traite import cas1 17 from traite import cas1
18 from traite import hedonique as h 18 from traite import hedonique as h
19 19
20−warnings.filterwarnings("ignore", category=FutureWarning) 20+warnings.filterwarnings("ignore", category=FutureWarning); warnings.filterwarnings("ignore", category=UserWarning)
21 ROOT = pathlib.Path(__file__).resolve().parents[3] 21 ROOT = pathlib.Path(__file__).resolve().parents[3]
22 GRAINE = 20261031 22 GRAINE = 20261031
23 N_ARBRES_FORET = 500 23 N_ARBRES_FORET = 500
@@ -219,7 +219,7 @@ def importance_permutation(Xh: pd.DataFrame, Xm: pd.DataFrame, y: np.ndarray, pl
219 for nom, ctor in (("Gradient boosting", _boosting), ("Forêt aléatoire", _foret)): 219 for nom, ctor in (("Gradient boosting", _boosting), ("Forêt aléatoire", _foret)):
220 gain = np.zeros(Xm.shape[1]); base_err = 0.0 220 gain = np.zeros(Xm.shape[1]); base_err = 0.0
221 for f in plis: 221 for f in plis:
222− tr = np.setdiff1d(np.arange(n), f); m = ctor(); m.fit(Xm.iloc[tr], y[tr]); Xt = Xm.iloc[f].to_numpy(); e0 = np.mean((m.predict(Xt) - y[f]) ** 2); base_err += e0 * len(f) / n 222+ tr = np.setdiff1d(np.arange(n), f); m = ctor(); m.fit(Xm.iloc[tr].to_numpy(), y[tr]); Xt = Xm.iloc[f].to_numpy(); e0 = np.mean((m.predict(Xt) - y[f]) ** 2); base_err += e0 * len(f) / n
223 for j in range(Xm.shape[1]): 223 for j in range(Xm.shape[1]):
224 acc = 0.0 224 acc = 0.0
225 for _ in range(repetitions): 225 for _ in range(repetitions):
@@ -309,16 +309,15 @@ def exercices(v: pd.DataFrame, comp: dict, secteurs: dict, scen: dict, sh: dict)
309 for _ in range(3): F = F + nu * (yv - F); traj.append(F) 309 for _ in range(3): F = F + nu * (yv - F); traj.append(F)
310 e["3"] = {"nu": nu, "F": traj, "residus": [yv - t for t in traj], "m_1pct": math.log(0.01) / math.log(1 - nu), "F_nu1": yv} 310 e["3"] = {"nu": nu, "F": traj, "residus": [yv - t for t in traj], "m_1pct": math.log(0.01) / math.log(1 - nu), "F_nu1": yv}
311 # 37.4 : valeurs de Shapley d'un jeu à trois joueurs (S, Q, L) ; v(S) en milliers de dollars au-dessus de la base 311 # 37.4 : valeurs de Shapley d'un jeu à trois joueurs (S, Q, L) ; v(S) en milliers de dollars au-dessus de la base
312− vS = {(): 0.0, ("S",): 60.0, ("Q",): 30.0, ("L",): 20.0, ("S", "Q"): 100.0, ("S", "L"): 85.0, ("Q", "L"): 45.0, ("S", "Q", "L"): 130.0} 312+ vS = {frozenset(): 0.0, frozenset("S"): 60.0, frozenset("Q"): 30.0, frozenset("L"): 20.0, frozenset("SQ"): 100.0, frozenset("SL"): 85.0, frozenset("QL"): 45.0, frozenset("SQL"): 130.0}
313 import itertools 313 import itertools
314 joueurs = ["S", "Q", "L"]; phi = {} 314 joueurs = ["S", "Q", "L"]; phi = {}
315 for j in joueurs: 315 for j in joueurs:
316 tot = 0.0 316 tot = 0.0
317 for perm in itertools.permutations(joueurs): 317 for perm in itertools.permutations(joueurs):
318− avant = tuple(sorted(perm[:perm.index(j)])); apres = tuple(sorted(avant + (j,))) 318+ avant = frozenset(perm[:perm.index(j)]); tot += vS[avant | {j}] - vS[avant]
319− tot += vS[apres] - vS[avant]
320 phi[j] = tot / 6 319 phi[j] = tot / 6
321− e["4"] = {"v": {"+".join(k) if k else "∅": val for k, val in vS.items()}, "phi": phi, "somme": sum(phi.values()), "total": vS[("S", "Q", "L")]} 320+ e["4"] = {"v": {"+".join(sorted(k)) if k else "∅": val for k, val in vS.items()}, "phi": phi, "somme": sum(phi.values()), "total": vS[frozenset("SQL")]}
322 # 37.5 : dix prédictions d'un AVM fictif 321 # 37.5 : dix prédictions d'un AVM fictif
323 prix = np.array([312000, 358500, 401000, 445000, 489500, 532000, 598000, 655000, 742000, 895000], float) 322 prix = np.array([312000, 358500, 401000, 445000, 489500, 532000, 598000, 655000, 742000, 895000], float)
324 pred = np.array([335000, 349000, 388000, 468000, 471000, 561000, 575000, 702000, 718000, 839000], float) 323 pred = np.array([335000, 349000, 388000, 468000, 471000, 561000, 575000, 702000, 718000, 839000], float)
modified analysis/python/cases/ch38_sig.py +45 −0
@@ -348,5 +348,50 @@ def main():
348 print("\n=== Capitalisation du risque (exemple)\n", r["capitalisation"].round(4).to_string()) 348 print("\n=== Capitalisation du risque (exemple)\n", r["capitalisation"].round(4).to_string())
349 349
350 350
351+# ----------------------------------------------------------------------------- exercices 6 et 7 (solutions/ch38.tex)
352+
353+def exercice_6(n_poly: int = 1000, rayon_km: float = 3.0) -> dict:
354+ """Disques aléatoires de 3 km (1 000 tirages) pour les trois spécifications, puis comparaison classique / HC1 / HC3 à 0,5 km (référence)."""
355+ r = tout(); b, v = r["base"], r["ventes"]
356+ global RAYONS_POLYGONES_KM
357+ sauve = RAYONS_POLYGONES_KM; RAYONS_POLYGONES_KM = [rayon_km]
358+ try:
359+ fp = faux_positifs(b, v, n_poly=n_poly)["tableau"]
360+ finally:
361+ RAYONS_POLYGONES_KM = sauve
362+ rng = np.random.default_rng(GRAINE); X = _X_ref(v); y = np.log(v["prix_vente"].values.astype(float)); xy = b["xy_m"]
363+ ts = {"classique": [], "HC1": [], "HC3": []}
364+ for cc in rng.integers(0, len(v), size=N_POLYGONES_ALEATOIRES):
365+ d = np.sqrt(((xy - xy[cc]) ** 2).sum(1)) / 1000; ind = (d <= 0.5).astype(float); n_in = int(ind.sum())
366+ if n_in < 10 or n_in > len(v) - 10:
367+ continue
368+ Xa = np.column_stack([X.values, ind]); m = h.mco(Xa, y); e = m["residus"]
369+ XtXi = np.linalg.pinv(Xa.T @ Xa); lev = np.einsum("ij,jk,ik->i", Xa, XtXi, Xa)
370+ V3 = XtXi @ ((Xa * ((e / (1 - lev)) ** 2)[:, None]).T @ Xa) @ XtXi
371+ ts["classique"].append(m["beta"][-1] / m["se"][-1]); ts["HC1"].append(m["beta"][-1] / m["se_hc1"][-1]); ts["HC3"].append(m["beta"][-1] / np.sqrt(V3[-1, -1]))
372+ comp = {k: {"n": len(t), "part_5pct": float(np.mean(np.abs(t) > 1.96)), "ecart_type": float(np.std(t))} for k, t in ts.items()}
373+ return {"tableau_3km": fp, "comparaison_05km": comp}
374+
375+
376+def exercice_7() -> pd.DataFrame:
377+ """Médiane du prix au m² par cellule pour trois rayons hexagonaux et une grille carrée : cellules, bornes de Jenks, GVF, part intercellulaire de la variance."""
378+ from shapely.geometry import Polygon, box
379+ gpd = _gpd(); r = tout(); b, v = r["base"], r["ventes"]; xy = b["xy_m"]
380+ pts = v[["geometry", "prix_vente", "superficie_habitable_m2"]].copy(); pts["prix_m2"] = pts["prix_vente"] / pts["superficie_habitable_m2"]
381+ xmin, ymin, xmax, ymax = xy[:, 0].min(), xy[:, 1].min(), xy[:, 0].max(), xy[:, 1].max(); tot = ((pts["prix_m2"] - pts["prix_m2"].mean()) ** 2).sum()
382+ def evaluer(cells, nom):
383+ g = gpd.GeoDataFrame({"cellule": range(len(cells))}, geometry=cells, crs=EPSG_MTM9)
384+ j = gpd.sjoin(pts, g, how="inner", predicate="within"); assert len(j) == len(pts)
385+ agg = j.groupby("cellule").agg(n=("prix_m2", "count"), med=("prix_m2", "median"), moy=("prix_m2", "mean")); ok = agg[agg["n"] >= MIN_VENTES_CELLULE]
386+ z = ok["med"].values; bj = geo.bornes_jenks(z, K_CLASSES); inter = float((agg["n"] * (agg["moy"] - pts["prix_m2"].mean()) ** 2).sum())
387+ return {"grille": nom, "cellules": len(g), "occupees": len(agg), "n_min5": len(ok), "bornes_jenks": np.round(bj, 0), "gvf": geo.gvf(z, bj), "part_inter": inter / tot}
388+ rows = []
389+ for R in (800.0, 1200.0, 2000.0):
390+ rows.append(evaluer([Polygon(cc) for cc in geo.grille_hexagonale(xmin - R, ymin - R, xmax, ymax, R)], f"hexagones R = {R:.0f} m"))
391+ pas = 2000.0
392+ rows.append(evaluer([box(x, y_, x + pas, y_ + pas) for x in np.arange(xmin - 0.5 * pas, xmax + pas, pas) for y_ in np.arange(ymin - 0.5 * pas, ymax + pas, pas)], "carrés de 2 km"))
393+ return pd.DataFrame(rows)
394+
395+
351 if __name__ == "__main__": 396 if __name__ == "__main__":
352 main() 397 main()
modified analysis/python/cases/ch40_montecarlo.py +6 −3
@@ -356,11 +356,12 @@ def simuler_portefeuille(pf: pd.DataFrame, n: int = N_MC, graine: int = GRAINE,
356 return {"valeurs": V, "valeur_portefeuille": VP, "rne": rne, "rcd": rcd, "service_dette": sd, "stats": stats_, 356 return {"valeurs": V, "valeur_portefeuille": VP, "rne": rne, "rcd": rcd, "service_dette": sd, "stats": stats_,
357 "par_rmr": pd.DataFrame({"rmr": rmrs.cat.categories, "ecart_type_valeur": [V[:, k == j].sum(1).std(ddof=1) for j in range(K)], "valeur_2026": [pf.loc[k == j, "valeur_2026"].sum() for j in range(K)]})} 357 "par_rmr": pd.DataFrame({"rmr": rmrs.cat.categories, "ecart_type_valeur": [V[:, k == j].sum(1).std(ddof=1) for j in range(K)], "valeur_2026": [pf.loc[k == j, "valeur_2026"].sum() for j in range(K)]})}
358 358
359−def comparer_structures_correlation(pf: pd.DataFrame, croissance: pd.DataFrame, n: int = N_MC) -> pd.DataFrame: 359+def comparer_structures_correlation(pf: pd.DataFrame, croissance: pd.DataFrame | None, n: int = N_MC, **kw) -> pd.DataFrame:
360− """Trois structures : indépendance (0, 0), corrélation régionale (0,30 ; 0,60), corrélation parfaite (1, 1).""" 360+ """Trois structures : indépendance (0, 0), corrélation régionale (0,30 ; 0,60), corrélation parfaite (1, 1). kw → simuler_portefeuille
361+ (p. ex. croissance=None, sigma_g_defaut=0.10 pour un choc de revenu de type récession)."""
361 rows = [] 362 rows = []
362 for nom, rp, rr in [("Indépendance", 0.0, 0.0), ("Régionale (0,30 ; 0,60)", 0.30, 0.60), ("Parfaite", 1.0, 1.0)]: 363 for nom, rp, rr in [("Indépendance", 0.0, 0.0), ("Régionale (0,30 ; 0,60)", 0.30, 0.60), ("Parfaite", 1.0, 1.0)]:
363− s = simuler_portefeuille(pf, n, GRAINE, rho_rmr=rr, rho_prov=rp, croissance=croissance)["stats"] 364+ s = simuler_portefeuille(pf, n, GRAINE, rho_rmr=rr, rho_prov=rp, croissance=croissance, **kw)["stats"]
364 rows.append({"structure": nom, "rho_prov": rp, "rho_rmr": rr, **{k: s[k] for k in ["mediane", "ecart_type", "q05", "VaR05", "CVaR05", "ratio_diversification", "nb_rcd_sous_seuil_moyen", "nb_rcd_sous_seuil_q95", "nb_rcd_sous_1_moyen", "nb_rcd_sous_1_q95", "P_plus_de_10_sous_1", "rcd_portefeuille_mediane"]}}) 365 rows.append({"structure": nom, "rho_prov": rp, "rho_rmr": rr, **{k: s[k] for k in ["mediane", "ecart_type", "q05", "VaR05", "CVaR05", "ratio_diversification", "nb_rcd_sous_seuil_moyen", "nb_rcd_sous_seuil_q95", "nb_rcd_sous_1_moyen", "nb_rcd_sous_1_q95", "P_plus_de_10_sous_1", "rcd_portefeuille_mediane"]}})
365 return pd.DataFrame(rows) 366 return pd.DataFrame(rows)
366 367
@@ -417,6 +418,7 @@ def tout(rapide: bool = False) -> dict:
417 D["lhs"] = comparer_lhs(r, n=1000, k=k_rep) 418 D["lhs"] = comparer_lhs(r, n=1000, k=k_rep)
418 c4 = cas4.charger(); cr = cas4.croissance_rne(c4["series"]) 419 c4 = cas4.charger(); cr = cas4.croissance_rne(c4["series"])
419 D["portefeuille"] = simuler_portefeuille(c4["portefeuille"], N_MC, GRAINE, croissance=cr); D["portefeuille_structures"] = comparer_structures_correlation(c4["portefeuille"], cr) 420 D["portefeuille"] = simuler_portefeuille(c4["portefeuille"], N_MC, GRAINE, croissance=cr); D["portefeuille_structures"] = comparer_structures_correlation(c4["portefeuille"], cr)
421+ D["portefeuille_recession"] = comparer_structures_correlation(c4["portefeuille"], None, sigma_g_defaut=0.10) # choc de revenu sigma_g = 10 %, g = 0
420 D["cas4"] = c4; D["cas4_croissance"] = cr 422 D["cas4"] = c4; D["cas4_croissance"] = cr
421 D["exercices"] = exercices(r, mc) 423 D["exercices"] = exercices(r, mc)
422 return D 424 return D
@@ -456,6 +458,7 @@ def imprimer(D: dict) -> None:
456 print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in D["portefeuille"]["stats"].items()}) 458 print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in D["portefeuille"]["stats"].items()})
457 print(D["portefeuille"]["par_rmr"].to_string(index=False)) 459 print(D["portefeuille"]["par_rmr"].to_string(index=False))
458 print("\nTrois structures de corrélation :\n", D["portefeuille_structures"].to_string(index=False)) 460 print("\nTrois structures de corrélation :\n", D["portefeuille_structures"].to_string(index=False))
461+ print("\nTrois structures, choc de revenu sigma_g = 10 % (g = 0) :\n", D["portefeuille_recession"].to_string(index=False))
459 print("\n=== Exercices") 462 print("\n=== Exercices")
460 for k, v in D["exercices"].items(): print(k, v) 463 for k, v in D["exercices"].items(): print(k, v)
461 464
added analysis/python/cases/ch41_phmu.py +386 −0
@@ -0,0 +1,386 @@
1+#!/usr/bin/env python3
2+"""Chapitre 41 — Plus haute et meilleure utilisation, terrains et développement : calculs de l'exemple intégral.
3+
4+Sujet : le terrain de 620 m² du cas 1 (cottage de 1998, 165 m², secteur Hull de Gatineau ; DONNÉES SYNTHÉTIQUES,
5+data/synthetic/cas1_gatineau_*). Le zonage retenu pour l'exemple est FICTIF (zone « H-2f » : 1 à 3 logements).
6+
7+Quatre usages sont confrontés :
8+ S0 — maison actuelle conservée (valeur dans l'usage actuel : conclusion du ch. 20, 600 000 $) ;
9+ U — démolition et maison neuve de 165 m² (prix de sortie : modèle hédonique du cas 1, âge 0) ;
10+ D — démolition et duplex (2 × 120 m², vendus en copropriété divise) ;
11+ T — démolition et triplex (3 × 100 m²).
12+Pour chacun : quatre tests de la PHMU, méthode résiduelle de développement avec calendrier (financement intérimaire
13+explicite via traite.finance.interets_intermediaires, actualisation du résidu, contribution pour fins de parcs),
14+sensibilité, puis option réelle de redéveloppement (arbre binomial à deux périodes) et seuil de Capozza–Helsley.
15+
16+Réutilise : traite.cas1 (hedonique, predire), traite.finance (interets_intermediaires, marge_sur_prix, marge_sur_cout),
17+ch21_cout_neuf (devis du cas 1 : D + I), ch08_zonage (densite_admissible, contribution_parcs, prime_option,
18+valeur_residuelle_redeveloppement pour la version statique).
19+
20+Usage : python3 analysis/python/cases/ch41_phmu.py
21+"""
22+from __future__ import annotations
23+import sys, pathlib, math
24+import numpy as np
25+import pandas as pd
26+
27+ROOT = pathlib.Path(__file__).resolve().parents[3]
28+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
29+from traite import cas1, finance as fin # noqa: E402
30+import ch21_cout_neuf as c21 # noqa: E402
31+import ch08_zonage as c08 # noqa: E402
32+
33+# ----------------------------------------------------------------------------- constantes partagées (provenance déclarée)
34+V_ACT = 600_000.0 # ch. 20 : valeur la plus probable conclue pour la maison du cas 1 (fourchette 575 000 – 640 000 $)
35+V_L_MARCHE = 277_000.0 # ch. 24/25 : valeur du terrain du sujet retenue par comparaison/extraction (fourchette 240 000 – 285 000 $)
36+IPCB_MAISON_T3_2026 = 116.5 # Statistique Canada 18-10-0289-01, Montréal, maison individuelle, T3 2026 (SRC-E-017)
37+IPCB_APPART_T3_2026 = 118.5 # idem, immeuble d'appartements de moins de cinq étages
38+TAUX_PRET = 0.0619 # taux hypothécaire affiché 5 ans, prêt conventionnel, 28 octobre 2026 (Banque du Canada, série V80691335 ; ch. 23)
39+PART_PRET = 0.75 # prêt de construction : 75 % des coûts (hypothèse, comme au ch. 23)
40+EFFICACITE = 0.85 # superficie habitable / aire brute de plancher (hypothèse, comme au ch. 23)
41+
42+# zone FICTIVE « H-2f » : hypothèses de zonage déclarées (grille calquée sur la colonne H2 de Val-Fictif, ch. 8, plafond ramené à 3 logements)
43+GRILLE = {"s_min_lot": 450.0, "s_min_par_logement": 150.0, "logements_max": 3, "cos_max": 0.60, "hauteur_max_etages": 3,
44+ "stationnement_par_logement": 1, "marge_avant": 6.0, "marge_lat": 2.0, "marge_arr": 9.0}
45+
46+# hypothèses communes du promoteur (déclarées dans le texte ; sensibilité à la fin)
47+HYP = {"demolition": 30_000.0, # démolition, désamiantage sommaire, préparation du site (hypothèse, idem ch. 8)
48+ "frais_vente": 0.04, # courtage, notaire, mise en marché : 4 % du produit de la vente (hypothèse)
49+ "marge": 0.15, "marge_base": "prix", # marge du promoteur-marchand : 15 % du produit de la vente (hypothèse, idem ch. 8)
50+ "taux_parcs": 0.10, # contribution pour fins de parcs exigée au redéveloppement : 10 % de la valeur du site (LAU 117.4, plafond)
51+ "taux_pret": TAUX_PRET, "part_pret": PART_PRET, "r_portage": TAUX_PRET}
52+
53+# variante « construction sur commande » (acheteur-occupant qui fait bâtir) : la convention du ch. 21 — incitatif de 10 % des coûts de
54+# construction (directs, indirects, démolition, financement), pas de frais de vente (rien n'est vendu), pas de contribution pour fins de parcs
55+HYP_OCCUPANT = {**HYP, "frais_vente": 0.0, "marge": 0.10, "marge_base": "cout", "taux_parcs": 0.0}
56+
57+# programmes de construction (hypothèses) ; facteur_prix = décote déclarée d'une unité superposée/contiguë vendue en copropriété divise
58+# par rapport à la maison isolée neuve de même superficie que prédit le modèle hédonique (1,00 = maison isolée)
59+PROGRAMMES = {
60+ "S0": {"nom": "Maison actuelle conservée", "n": 1, "s_unite": 165.0, "garage": 1, "sous_sol": 1, "facteur_prix": 1.0, "duree_ans": 0.0, "neuf": False},
61+ "U": {"nom": "Maison neuve de 165 m²", "n": 1, "s_unite": 165.0, "garage": 1, "sous_sol": 1, "facteur_prix": 1.00, "duree_ans": 1.00, "neuf": True},
62+ "D": {"nom": "Duplex, 2 × 120 m²", "n": 2, "s_unite": 120.0, "garage": 0, "sous_sol": 1, "facteur_prix": 0.90, "duree_ans": 1.25, "neuf": True},
63+ "T": {"nom": "Triplex, 3 × 100 m²", "n": 3, "s_unite": 100.0, "garage": 0, "sous_sol": 1, "facteur_prix": 0.85, "duree_ans": 1.50, "neuf": True},
64+ "Q": {"nom": "Quadruplex, 4 × 85 m²", "n": 4, "s_unite": 85.0, "garage": 0, "sous_sol": 1, "facteur_prix": 0.80, "duree_ans": 1.75, "neuf": True},
65+}
66+
67+
68+# ----------------------------------------------------------------------------- intrants : coûts (ch. 21) et prix de sortie (cas 1)
69+
70+def couts_unitaires() -> dict:
71+ """Coût direct + indirect par m² habitable du devis du cas 1 (ch. 21), et transposition aux plex par le ratio IPCB (ch. 23)."""
72+ devis, sujet = c21.charger(); cn = c21.cout_neuf_sujet(devis)
73+ hab = float(sujet["superficie_habitable_m2"])
74+ di_m2 = (cn["direct"] + cn["indirects"]) / hab
75+ ratio = IPCB_APPART_T3_2026 / IPCB_MAISON_T3_2026
76+ return {"direct": cn["direct"], "indirects": cn["indirects"], "incitatif": cn["incitatif"], "cout_neuf": cn["total"],
77+ "di_m2_maison": di_m2, "ratio_ipcb_plex": ratio, "di_m2_plex": di_m2 * ratio, "cn_m2_maison": cn["total"] / hab, "sujet": sujet}
78+
79+
80+def modele_cas1() -> dict:
81+ d = cas1.charger(); m = cas1.hedonique(d["ventes"]); return {"modele": m, "sujet": d["sujet"]}
82+
83+
84+def prix_unite(m: dict, sujet: dict, s_unite: float, n: int, garage: int, sous_sol: int, neuf: bool = True) -> float:
85+ """Prix prédit par le modèle hédonique du cas 1 pour une habitation isolée neuve de s_unite m² sur 620/n m² de terrain,
86+ à Hull, à la date d'évaluation (correction de Duan incluse). La décote d'unité superposée est appliquée ensuite (facteur_prix)."""
87+ x = dict(sujet); x.update({"superficie_habitable_m2": s_unite, "superficie_terrain_m2": sujet["superficie_terrain_m2"] / n,
88+ "garage_places": garage, "sous_sol_fini": sous_sol})
89+ if neuf:
90+ x["age"] = 0; x["annee_construction"] = 2026
91+ return float(cas1.predire(m, cas1.sujet_df(x))[0])
92+
93+
94+def produit_vente(m: dict, sujet: dict, prog: dict) -> dict:
95+ p_isolee = prix_unite(m, sujet, prog["s_unite"], prog["n"], prog["garage"], prog["sous_sol"], prog["neuf"])
96+ p_unite = p_isolee * prog["facteur_prix"]
97+ return {"prix_isolee_equiv": p_isolee, "prix_unite": p_unite, "gdv": p_unite * prog["n"], "s_total": prog["s_unite"] * prog["n"]}
98+
99+
100+# ----------------------------------------------------------------------------- les quatre tests
101+
102+def test_legal(prog: dict, grille: dict = GRILLE, s_terrain: float = 620.0) -> dict:
103+ g = c08.densite_admissible(s_terrain, grille["s_min_par_logement"], grille["logements_max"], grille["cos_max"], grille["s_min_lot"])
104+ brut = prog["n"] * prog["s_unite"] / EFFICACITE
105+ ok_n = prog["n"] <= g["logements_admissibles"]; ok_cos = brut <= g["aire_plancher_max_m2"]
106+ return {"logements_admissibles": g["logements_admissibles"], "aire_max_m2": g["aire_plancher_max_m2"], "aire_brute_m2": brut,
107+ "ok_logements": ok_n, "ok_cos": ok_cos, "permis": bool(g["lot_conforme"] and ok_n and ok_cos)}
108+
109+
110+def test_physique(prog: dict, grille: dict = GRILLE, facade_m: float = 20.0, profondeur_m: float = 31.0) -> dict:
111+ """Enveloppe constructible après marges (façade et profondeur du lot : hypothèses, le dossier du cas ne les précise pas) ;
112+ stationnement : 1 case par logement de 2,6 m × 5,5 m en cour avant/latérale (hypothèse)."""
113+ largeur = facade_m - 2 * grille["marge_lat"]; prof = profondeur_m - grille["marge_avant"] - grille["marge_arr"]
114+ empreinte_max = largeur * prof
115+ brut = prog["n"] * prog["s_unite"] / EFFICACITE
116+ etages = math.ceil(brut / empreinte_max) if empreinte_max > 0 else 99
117+ ok_etages = etages <= grille["hauteur_max_etages"]
118+ cases = prog["n"] * grille["stationnement_par_logement"]; ok_stat = cases * 2.6 <= largeur + 2 * grille["marge_lat"] # cases côte à côte en façade
119+ return {"empreinte_max_m2": empreinte_max, "etages_requis": etages, "ok_etages": ok_etages, "cases": cases, "ok_stationnement": ok_stat,
120+ "possible": bool(ok_etages and ok_stat)}
121+
122+
123+# ----------------------------------------------------------------------------- méthode résiduelle de développement (dynamique)
124+
125+def residuel_developpement(gdv: float, s_total: float, di_m2: float, duree_ans: float, hyp: dict = HYP, demolir: bool = True) -> dict:
126+ """Valeur résiduelle du terrain avec calendrier :
127+ V_L = [GDV(1 − c_v) − C − D − F − m·GDV] / [(1 + τ_parcs)(1 + r)^T]
128+ où C = coûts directs + indirects (di_m2 × s_total), D = démolition, F = intérêts intérimaires du prêt de construction
129+ (tirage uniforme, mise de fonds d'abord), m = marge sur le prix, τ_parcs = contribution pour fins de parcs (assise sur
130+ la valeur du site), r = taux de portage du terrain, T = durée totale (chantier + vente). r = 0 redonne la version
131+ statique du ch. 8 (contribution incluse) ; r = 0 et τ = 0 redonnent le résidu « de manuel »."""
132+ C = di_m2 * s_total; D = hyp["demolition"] if demolir else 0.0
133+ couts_fin = C + D
134+ pret = hyp["part_pret"] * couts_fin
135+ F = fin.interets_intermediaires(pret, hyp["taux_pret"], duree_ans, mode="continu", mise_de_fonds=couts_fin - pret, cout_total=couts_fin)["interets"] if duree_ans > 0 else 0.0
136+ frais = hyp["frais_vente"] * gdv
137+ marge = hyp["marge"] * gdv if hyp.get("marge_base", "prix") == "prix" else hyp["marge"] * (C + D + F)
138+ reste = gdv - frais - C - D - F - marge
139+ facteur_temps = (1.0 + hyp["r_portage"]) ** duree_ans
140+ v_l = reste / ((1.0 + hyp["taux_parcs"]) * facteur_temps)
141+ return {"gdv": gdv, "frais_vente": frais, "couts_construction": C, "demolition": D, "pret": pret, "financement": F, "marge": marge,
142+ "reste": reste, "facteur_temps": facteur_temps, "valeur_residuelle": v_l, "contribution_parcs": v_l * hyp["taux_parcs"],
143+ "reste_statique": reste, "v_l_statique": reste / (1.0 + hyp["taux_parcs"]), "cout_total_hors_terrain": C + D + F + frais + marge,
144+ "marge_sur_cout": marge / (C + D + F + frais + v_l) if (C + D + F + frais + v_l) > 0 else float("nan")}
145+
146+
147+def seuil_gdv(res: dict, v_cible: float, hyp: dict = HYP) -> float:
148+ """Produit de vente qui rend la valeur résiduelle égale à v_cible (résolution de la forme linéaire en GDV)."""
149+ a, b = _a_b(res, hyp)
150+ return (v_cible + b) / a
151+
152+
153+def _a_b(res: dict, hyp: dict) -> tuple[float, float]:
154+ """Forme linéaire du résidu : V_L = a·GDV − b. La marge entre dans a si elle est assise sur le prix, dans b si elle est assise sur le coût."""
155+ den = (1.0 + hyp["taux_parcs"]) * res["facteur_temps"]
156+ fixes = res["couts_construction"] + res["demolition"] + res["financement"]
157+ if hyp.get("marge_base", "prix") == "prix":
158+ return (1.0 - hyp["frais_vente"] - hyp["marge"]) / den, fixes / den
159+ return (1.0 - hyp["frais_vente"]) / den, fixes * (1.0 + hyp["marge"]) / den
160+
161+
162+# ----------------------------------------------------------------------------- option réelle : arbre binomial
163+
164+def parametres_binomiaux(sigma: float, dt: float, r: float, y: float) -> dict:
165+ """u = e^{σ√Δt}, d = 1/u ; probabilité risque-neutre d'un actif à rendement de détention y :
166+ q = (F − d)/(u − d), F = [(1 + r)/(1 + y)]^Δt (facteur de croissance à terme)."""
167+ u = math.exp(sigma * math.sqrt(dt)); d = 1.0 / u
168+ F = ((1.0 + r) / (1.0 + y)) ** dt
169+ q = (F - d) / (u - d)
170+ return {"u": u, "d": d, "F": F, "q": q, "disc": (1.0 + r) ** (-dt)}
171+
172+
173+def option_redeveloppement(gdv0: float, a: float, b: float, sigma: float, dt: float, r: float, y: float, n: int = 2) -> dict:
174+ """Option américaine de redéveloppement sur n périodes de Δt années. À chaque nœud, le propriétaire peut vendre à un
175+ promoteur (ou se faire promoteur) pour V_L(GDV) = a·GDV − b' ; la prime par rapport à la valeur en usage V_act est
176+ max(a·GDV − b, 0) avec b = b' + V_act. Rend la prime (américaine et européenne), la règle VAN immédiate et la politique
177+ d'exercice par nœud."""
178+ p = parametres_binomiaux(sigma, dt, r, y); u, d, q, disc = p["u"], p["d"], p["q"], p["disc"]
179+ payoff = lambda g: max(a * g - b, 0.0)
180+ noeuds = {}
181+ V = [payoff(gdv0 * u ** j * d ** (n - j)) for j in range(n + 1)]
182+ for j in range(n + 1):
183+ noeuds[(n, j)] = {"gdv": gdv0 * u ** j * d ** (n - j), "exercice": V[j], "continuation": None, "valeur": V[j]}
184+ V_eur = list(V)
185+ for t in range(n - 1, -1, -1):
186+ Vn, Vn_eur = [], []
187+ for j in range(t + 1):
188+ g = gdv0 * u ** j * d ** (t - j)
189+ cont = disc * (q * V[j + 1] + (1 - q) * V[j]); ex = payoff(g)
190+ Vn.append(max(ex, cont)); Vn_eur.append(disc * (q * V_eur[j + 1] + (1 - q) * V_eur[j]))
191+ noeuds[(t, j)] = {"gdv": g, "exercice": ex, "continuation": cont, "valeur": max(ex, cont), "exercer": ex > cont and ex > 0}
192+ V, V_eur = Vn, Vn_eur
193+ return {**p, "prime_americaine": V[0], "prime_europeenne": V_eur[0], "van_immediate": payoff(gdv0), "noeuds": noeuds,
194+ "gdv_seuil_van": b / a, "a": a, "b": b, "n": n, "dt": dt, "sigma": sigma, "r": r, "y": y}
195+
196+
197+def coefficients_option(res: dict, v_act: float, hyp: dict = HYP) -> tuple[float, float]:
198+ """V_L(GDV) = a·GDV − b' (forme linéaire du résidu) ; prime d'exercice = a·GDV − (b' + V_act)."""
199+ a, b_prime = _a_b(res, hyp)
200+ return a, b_prime + v_act
201+
202+
203+def frontiere_exercice_t1(a: float, b: float, sigma: float, dt: float, r: float, y: float) -> float:
204+ """Plus petit GDV au nœud t = 1 (arbre à deux périodes) où l'exercice immédiat vaut au moins la continuation (bissection)."""
205+ p = parametres_binomiaux(sigma, dt, r, y); u, d, q, disc = p["u"], p["d"], p["q"], p["disc"]
206+ f = lambda g: (a * g - b) - disc * (q * max(a * g * u - b, 0.0) + (1 - q) * max(a * g * d - b, 0.0))
207+ lo, hi = b / a, 50 * b / a
208+ if f(hi) < 0:
209+ return float("inf")
210+ for _ in range(200):
211+ mid = 0.5 * (lo + hi)
212+ if f(mid) >= 0:
213+ hi = mid
214+ else:
215+ lo = mid
216+ return hi
217+
218+
219+# ----------------------------------------------------------------------------- Capozza–Helsley (version simplifiée)
220+
221+def seuil_capozza_helsley(R_A: float, C: float, r: float, g: float, sigma: float) -> dict:
222+ """Rente de réserve R* déclenchant la conversion lorsque la rente du nouvel usage suit dR = g dt + σ dz :
223+ R* = R_A + rC − g/r + 1/α, α = [−g + sqrt(g² + 2 r σ²)] / σ² (σ → 0 : R* → R_A + rC).
224+ Prime d'irréversibilité = 1/α − g/r (nulle sans incertitude)."""
225+ if sigma <= 0:
226+ alpha = r / g if g > 0 else float("inf"); prime = 0.0
227+ else:
228+ alpha = 2.0 * r / (g + math.sqrt(g * g + 2.0 * r * sigma * sigma)) # forme rationalisée de [−g + √(g² + 2rσ²)]/σ², stable quand σ → 0
229+ prime = 1.0 / alpha - g / r
230+ R_cert = R_A + r * C
231+ return {"alpha": alpha, "R_certitude": R_cert, "prime_irreversibilite": prime, "R_seuil": R_cert + prime}
232+
233+
234+# ----------------------------------------------------------------------------- exemple intégral
235+
236+OPTION = {"sigma": 0.10, "dt": 5.0, "r": 0.02, "y": 0.045, "n": 2} # volatilité réelle annuelle du prix des unités, pas de 5 ans, taux réel, rendement locatif net
237+
238+
239+def prix_logement_plex_existant() -> dict:
240+ """Prix par logement des triplex existants du cas 2 (Québec, Limoilou ; traite.cas2) — contrepoint « vente en bloc »."""
241+ from traite import cas2
242+ d = cas2.charger(); c = cas2.comparables_plex(d["ventes"], d["sujet"]); ind = cas2.indicateurs_marche(c)
243+ return {"prix_par_logement_triplex": ind["prix_par_logement_triplex"], "mrb_triplex": ind["mrb_median_triplex"], "n": ind["n"]}
244+
245+
246+def exemple_complet() -> dict:
247+ cu = couts_unitaires(); mc = modele_cas1(); m, sujet = mc["modele"], mc["sujet"]
248+ rows, res, tests = [], {}, {}
249+ for k, prog in PROGRAMMES.items():
250+ pv = produit_vente(m, sujet, prog) if prog["neuf"] else {"prix_isolee_equiv": float(cas1.predire(m, cas1.sujet_df(sujet))[0]), "prix_unite": V_ACT, "gdv": V_ACT, "s_total": 165.0}
251+ tl = test_legal(prog); tp = test_physique(prog)
252+ di = cu["di_m2_maison"] if prog["n"] == 1 else cu["di_m2_plex"]
253+ if prog["neuf"]:
254+ r_prom = residuel_developpement(pv["gdv"], pv["s_total"], di, prog["duree_ans"], HYP)
255+ r_occ = residuel_developpement(pv["gdv"], pv["s_total"], di, prog["duree_ans"], HYP_OCCUPANT)
256+ else:
257+ r_prom = r_occ = None
258+ res[k] = {"programme": prog, "vente": pv, "legal": tl, "physique": tp, "promoteur": r_prom, "occupant": r_occ, "di_m2": di}
259+ rows.append({"usage": k, "nom": prog["nom"], "n": prog["n"], "s_total": pv["s_total"], "prix_unite": pv["prix_unite"], "gdv": pv["gdv"],
260+ "permis": tl["permis"], "possible": tp["possible"],
261+ "v_l_promoteur": r_prom["valeur_residuelle"] if r_prom else float("nan"),
262+ "v_l_occupant": r_occ["valeur_residuelle"] if r_occ else float("nan")})
263+ tableau = pd.DataFrame(rows)
264+ # contrepoint : triplex vendu en bloc à un investisseur au prix par logement des triplex existants du cas 2
265+ bloc = prix_logement_plex_existant(); uT = res["T"]
266+ gdv_bloc = bloc["prix_par_logement_triplex"] * 3
267+ res["T_bloc"] = {"gdv": gdv_bloc, "vente": {"gdv": gdv_bloc}, "promoteur": residuel_developpement(gdv_bloc, uT["vente"]["s_total"], uT["di_m2"], uT["programme"]["duree_ans"], HYP), **bloc}
268+ # PHMU du terrain vacant : usages permis et possibles, résidu positif ; maximalement productif = plus grande valeur résiduelle
269+ # (marge du promoteur pour les plex ; marge du constructeur sur commande pour la maison, usage dont l'acheteur type est un occupant)
270+ cand = tableau[(tableau.usage != "S0") & tableau.permis & tableau.possible].copy()
271+ cand["v_l_retenue"] = np.where(cand.n == 1, cand.v_l_occupant, cand.v_l_promoteur)
272+ cand["faisable"] = cand["v_l_retenue"] > 0
273+ cand_f = cand[cand.faisable]
274+ phmu_vacant = cand_f.loc[cand_f.v_l_retenue.idxmax(), "usage"]
275+ phmu_vacant_promoteur = cand_f.loc[cand_f.v_l_promoteur.idxmax(), "usage"]
276+ # PHMU du bien amélioré : max(V_act, V_L − D) — la démolition est déjà incluse dans V_L
277+ meilleure_redev = cand["v_l_promoteur"].max()
278+ primes = {u: c08.prime_option(V_ACT, v) for u, v in zip(cand.usage, cand.v_l_promoteur)}
279+ phmu_ameliore = "S0" if V_ACT >= meilleure_redev else cand.loc[cand.v_l_promoteur.idxmax(), "usage"]
280+ contribution_batiment = V_ACT - V_L_MARCHE
281+ # seuils de bascule (triplex, convention promoteur)
282+ rT = res["T"]["promoteur"]; seuil_T = seuil_gdv(rT, V_ACT); hausse_T = seuil_T / rT["gdv"] - 1
283+ seuil_T_marche = seuil_gdv(rT, V_L_MARCHE); hausse_T_marche = seuil_T_marche / rT["gdv"] - 1
284+ # option réelle (triplex) : arbre binomial à deux périodes de 5 ans
285+ a, b = coefficients_option(rT, V_ACT); O = OPTION
286+ opt = option_redeveloppement(rT["gdv"], a, b, O["sigma"], O["dt"], O["r"], O["y"], O["n"])
287+ front = frontiere_exercice_t1(a, b, O["sigma"], O["dt"], O["r"], O["y"])
288+ # sensibilité de l'option : σ et V_act
289+ sens_opt = {}
290+ for v_act in (600_000.0, 450_000.0, 300_000.0):
291+ a_, b_ = coefficients_option(rT, v_act)
292+ sens_opt[v_act] = {s: option_redeveloppement(rT["gdv"], a_, b_, s, O["dt"], O["r"], O["y"], O["n"])["prime_americaine"] for s in (0.05, 0.10, 0.15, 0.20, 0.25)}
293+ # Capozza–Helsley : rente implicite de la maison R_A = y·V_act ; rente du triplex R = y·GDV ; coût de conversion C = coûts + démolition + financement
294+ ch = {"R_A": O["y"] * V_ACT, "R_T": O["y"] * rT["gdv"], "C": rT["couts_construction"] + rT["demolition"] + rT["financement"]}
295+ ch["g"] = 0.01 * ch["R_T"]; ch["sigma"] = 0.10 * ch["R_T"]
296+ ch["resultat"] = seuil_capozza_helsley(ch["R_A"], ch["C"], O["y"], ch["g"], ch["sigma"])
297+ ch["hausse_requise_certitude"] = ch["resultat"]["R_certitude"] / ch["R_T"] - 1; ch["hausse_requise_incertitude"] = ch["resultat"]["R_seuil"] / ch["R_T"] - 1
298+ # sensibilité de la valeur résiduelle du triplex (convention promoteur)
299+ sens = sensibilite_triplex(res["T"], cu)
300+ return {"couts": cu, "modele": m, "sujet": sujet, "usages": res, "tableau": tableau, "candidats": cand, "phmu_vacant": phmu_vacant,
301+ "phmu_vacant_promoteur": phmu_vacant_promoteur, "phmu_ameliore": phmu_ameliore, "primes": primes, "contribution_batiment": contribution_batiment,
302+ "seuil_gdv_T": seuil_T, "hausse_requise_T": hausse_T, "seuil_gdv_T_marche": seuil_T_marche, "hausse_requise_T_marche": hausse_T_marche,
303+ "option": opt, "a": a, "b": b, "frontiere_t1": front, "sens_option": sens_opt, "capozza": ch, "sensibilite": sens, "v_act": V_ACT, "v_l_marche": V_L_MARCHE}
304+
305+
306+def sensibilite_triplex(uT: dict, cu: dict) -> pd.DataFrame:
307+ """Variations une à une autour du cas de base (triplex, convention promoteur)."""
308+ prog = uT["programme"]; pv = uT["vente"]; base = uT["promoteur"]["valeur_residuelle"]
309+ def run(gdv=pv["gdv"], di=cu["di_m2_plex"], duree=prog["duree_ans"], **kw):
310+ h = {**HYP, **kw}; return residuel_developpement(gdv, pv["s_total"], di, duree, h)["valeur_residuelle"]
311+ lignes = [
312+ ("Prix des unités", "−15 %", run(gdv=pv["gdv"] * 0.85), "+15 %", run(gdv=pv["gdv"] * 1.15)),
313+ ("Coût de construction", "−10 %", run(di=cu["di_m2_plex"] * 0.90), "+10 %", run(di=cu["di_m2_plex"] * 1.10)),
314+ ("Marge du promoteur", "10 % du prix", run(marge=0.10), "20 % du prix", run(marge=0.20)),
315+ ("Durée totale", "12 mois", run(duree=1.0), "24 mois", run(duree=2.0)),
316+ ("Taux (prêt et portage)", "5,00 %", run(taux_pret=0.05, r_portage=0.05), "7,50 %", run(taux_pret=0.075, r_portage=0.075)),
317+ ("Contribution pour fins de parcs", "0 %", run(taux_parcs=0.0), "10 %", base),
318+ ("Frais de vente", "2 %", run(frais_vente=0.02), "6 %", run(frais_vente=0.06)),
319+ ]
320+ return pd.DataFrame(lignes, columns=["parametre", "bas_etiquette", "bas", "haut_etiquette", "haut"]).assign(base=base)
321+
322+
323+def courbe_densite(res: dict, cu: dict, facteurs_prix=(0.85, 1.0, 1.15)) -> pd.DataFrame:
324+ """Valeur résiduelle (convention promoteur) selon le nombre de logements, pour plusieurs niveaux de prix."""
325+ rows = []
326+ for k in ("U", "D", "T", "Q"):
327+ u = res["usages"][k]; prog = u["programme"]
328+ for f in facteurs_prix:
329+ r = residuel_developpement(u["vente"]["gdv"] * f, u["vente"]["s_total"], u["di_m2"], prog["duree_ans"], HYP)
330+ rows.append({"usage": k, "n": prog["n"], "facteur_prix": f, "v_l": r["valeur_residuelle"], "permis": u["legal"]["permis"]})
331+ return pd.DataFrame(rows)
332+
333+
334+def surface_prix_cout(uT: dict, cu: dict, fp=np.linspace(0.80, 1.40, 61), fc=np.linspace(0.80, 1.20, 41)) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
335+ prog = uT["programme"]; pv = uT["vente"]
336+ Z = np.array([[residuel_developpement(pv["gdv"] * p, pv["s_total"], cu["di_m2_plex"] * c, prog["duree_ans"], HYP)["valeur_residuelle"] for p in fp] for c in fc])
337+ return fp, fc, Z
338+
339+
340+def _d(x): return f"{x:,.0f} $".replace(",", " ")
341+
342+
343+def main() -> dict:
344+ R = exemple_complet(); cu = R["couts"]
345+ print("=== Ch. 41 — PHMU du terrain du cas 1 (620 m², Hull ; DONNÉES SYNTHÉTIQUES ; zonage FICTIF H-2f) ===")
346+ print(f"Coûts ch. 21 : D = {_d(cu['direct'])} ; I = {_d(cu['indirects'])} ; D+I/m² hab. = {cu['di_m2_maison']:.2f} $/m² ; ratio IPCB plex {cu['ratio_ipcb_plex']:.4f} → {cu['di_m2_plex']:.2f} $/m²")
347+ print(f"V_act (ch. 20) = {_d(R['v_act'])} ; V_L marché (ch. 24/25) = {_d(R['v_l_marche'])} ; contribution du bâtiment = {_d(R['contribution_batiment'])}")
348+ print("\n--- Programmes, tests et produits de vente ---")
349+ for k, u in R["usages"].items():
350+ if "legal" not in u:
351+ continue
352+ tl, tp, pv = u["legal"], u["physique"], u["vente"]
353+ print(f"{k:>2} {u['programme']['nom']:<28} n={u['programme']['n']} S={pv['s_total']:.0f} m² brut={tl['aire_brute_m2']:.0f} (max {tl['aire_max_m2']:.0f}) "
354+ f"permis={tl['permis']} possible={tp['possible']} (étages {tp['etages_requis']}, cases {tp['cases']}) | prix isolée équiv. {_d(pv['prix_isolee_equiv'])} → unité {_d(pv['prix_unite'])} ; GDV {_d(pv['gdv'])}")
355+ print("\n--- Résidu de développement (convention promoteur : marge 15 % du prix, frais de vente 4 %, parcs 10 %, prêt 75 % à 6,19 %, portage 6,19 %) ---")
356+ for k in ("U", "D", "T", "Q"):
357+ r = R["usages"][k]["promoteur"]
358+ print(f"{k}: GDV {_d(r['gdv'])} − frais {_d(r['frais_vente'])} − C {_d(r['couts_construction'])} − démol. {_d(r['demolition'])} − F {_d(r['financement'])} − marge {_d(r['marge'])} = reste {_d(r['reste'])} ; "
359+ f"/(1,10 × {r['facteur_temps']:.4f}) → V_L = {_d(r['valeur_residuelle'])} (parcs {_d(r['contribution_parcs'])}) ; statique {_d(r['v_l_statique'])}")
360+ print("--- Convention constructeur sur commande (incitatif 10 % des coûts de construction, pas de frais de vente ni de parcs) ---")
361+ for k in ("U", "D", "T"):
362+ r = R["usages"][k]["occupant"]; print(f"{k}: V_L = {_d(r['valeur_residuelle'])} (marge {_d(r['marge'])}, reste {_d(r['reste'])}, F {_d(r['financement'])})")
363+ tb = R["usages"]["T_bloc"]; rb = tb["promoteur"]
364+ print(f"--- Triplex vendu en bloc (cas 2 : {_d(tb['prix_par_logement_triplex'])} par logement, n = {tb['n']} ventes) : GDV {_d(tb['gdv'])} → reste {_d(rb['reste'])} → V_L = {_d(rb['valeur_residuelle'])}")
365+ print(f"\nPHMU du terrain vacant : {R['phmu_vacant']} (par résidu promoteur seul : {R['phmu_vacant_promoteur']}) ; PHMU du bien amélioré : {R['phmu_ameliore']}")
366+ print("Primes d'option immédiates max(0, V_L − V_act) : " + ", ".join(f"{u} {_d(p)}" for u, p in R["primes"].items()))
367+ print(f"Seuil GDV triplex pour égaler V_act : {_d(R['seuil_gdv_T'])} (+{R['hausse_requise_T']*100:.1f} %) ; pour égaler V_L marché : {_d(R['seuil_gdv_T_marche'])} ({R['hausse_requise_T_marche']*100:+.1f} %)")
368+ o = R["option"]
369+ print(f"\n--- Option réelle (triplex) : σ = {o['sigma']:.2f}, Δt = {o['dt']:.0f} ans, r = {o['r']:.3f}, y = {o['y']:.3f} ; u = {o['u']:.4f}, d = {o['d']:.4f}, F = {o['F']:.4f}, q = {o['q']:.4f}, disc = {o['disc']:.4f}")
370+ print(f"a = {R['a']:.4f} ; b = {_d(R['b'])} ; GDV* (VAN nulle) = {_d(o['gdv_seuil_van'])} ; frontière d'exercice à t = 1 : {_d(R['frontiere_t1'])}")
371+ for (t, j), nd in sorted(o["noeuds"].items()):
372+ print(f" nœud t={t} j={j}: GDV {_d(nd['gdv'])} ; exercice {_d(nd['exercice'])} ; continuation {('—' if nd['continuation'] is None else _d(nd['continuation']))} ; valeur {_d(nd['valeur'])}")
373+ print(f"Prime américaine {_d(o['prime_americaine'])} ({o['prime_americaine']/R['v_act']*100:.2f} % de V_act) ; européenne {_d(o['prime_europeenne'])} ; VAN immédiate {_d(o['van_immediate'])}")
374+ print("Sensibilité de la prime (lignes V_act, colonnes σ = 5/10/15/20/25 %) :")
375+ for v, d in R["sens_option"].items():
376+ print(f" V_act {_d(v)} : " + " ".join(_d(x) for x in d.values()))
377+ ch = R["capozza"]; cr = ch["resultat"]
378+ print(f"\n--- Capozza–Helsley : R_A {_d(ch['R_A'])}/an ; R_T {_d(ch['R_T'])}/an ; C {_d(ch['C'])} ; g {_d(ch['g'])}/an ; σ {_d(ch['sigma'])}/an ; r = y = {OPTION['y']}")
379+ print(f"α = {cr['alpha']:.6e} ; R* certitude {_d(cr['R_certitude'])} (+{ch['hausse_requise_certitude']*100:.1f} %) ; prime d'irréversibilité {_d(cr['prime_irreversibilite'])} ; R* {_d(cr['R_seuil'])} (+{ch['hausse_requise_incertitude']*100:.1f} %)")
380+ print("\n--- Sensibilité V_L triplex (promoteur) ---")
381+ print(R["sensibilite"].assign(bas=lambda d: d.bas.round(0), haut=lambda d: d.haut.round(0), base=lambda d: d.base.round(0)).to_string(index=False))
382+ return R
383+
384+
385+if __name__ == "__main__":
386+ main()
modified analysis/python/cases/ch42_copro.py +26 −0
@@ -152,6 +152,28 @@ def copro_locative(valeur: float, loyer: float = LOYER_F4, inocc: float = INOCC_
152 "mrb": fin.multiplicateur_revenu_brut(valeur, rbp)} 152 "mrb": fin.multiplicateur_revenu_brut(valeur, rbp)}
153 153
154 154
155+# ----------------------------------------------------------------------------- C'. financement de l'acheteur (fraction divise vs part indivise)
156+TAUX_ASSURE = 0.0412 # BdC, fonds avancés, prêts assurés à taux fixe ≥ 5 ans, août 2026 (sources/notes-E.md, ch. 31)
157+AMORT_MOIS = 300
158+MISE_DIVISE = 0.05 # mise de fonds minimale assurée (prix ≤ 500 000 $ ; Finances Canada, 15 décembre 2024)
159+MISE_INDIVISE = 0.20 # pratique des prêteurs pour une part indivise (NON VÉRIFIÉE dans une source normative ; hypothèse déclarée)
160+
161+
162+def financement_acheteur(prix: float, mise: float = MISE_DIVISE, taux: float = TAUX_ASSURE, amort: int = AMORT_MOIS,
163+ charges_mensuelles: float | None = None, taxes_annuelles: float = TAXES_F4) -> dict:
164+ """Mise de fonds, prêt (hors prime d'assurance), paiement mensuel (composition semestrielle), coût mensuel total et paiement au taux
165+ admissible minimal (max(taux + 2 %, 5,25 %), BSIF)."""
166+ if charges_mensuelles is None:
167+ charges_mensuelles = float(c7.repartition_charges(c7.cotisation_seuil(), QP)["par_fraction_total"][3]) / 12.0
168+ mdf = mise * prix; pret = prix - mdf
169+ h = fin.Hypotheque(pret, taux, amort)
170+ tam = fin.taux_admissible_minimal(taux)
171+ h_tam = fin.Hypotheque(pret, tam, amort)
172+ return {"mise_de_fonds": mdf, "pret": pret, "paiement": h.paiement, "charges_mensuelles": charges_mensuelles,
173+ "taxes_mensuelles": taxes_annuelles / 12.0, "cout_mensuel": h.paiement + charges_mensuelles + taxes_annuelles / 12.0,
174+ "tam": tam, "paiement_tam": h_tam.paiement}
175+
176+
155 # ----------------------------------------------------------------------------- D. conversion locatif → copropriété divise 177 # ----------------------------------------------------------------------------- D. conversion locatif → copropriété divise
156 # L'immeuble fictif de huit fractions, vu AVANT conversion comme un immeuble locatif de 8 logements (hypothèses déclarées). 178 # L'immeuble fictif de huit fractions, vu AVANT conversion comme un immeuble locatif de 8 logements (hypothèses déclarées).
157 LOYERS_LOG = {"F1": 1_250.0, "F2": 1_650.0, "F3": 1_300.0, "F4": 1_700.0, "F5": 1_450.0, "F6": 1_850.0, "F7": 1_500.0, "F8": 1_525.0} # $/mois, loyers contractuels 179 LOYERS_LOG = {"F1": 1_250.0, "F2": 1_650.0, "F3": 1_300.0, "F4": 1_700.0, "F5": 1_450.0, "F6": 1_850.0, "F7": 1_500.0, "F8": 1_525.0} # $/mois, loyers contractuels
@@ -299,6 +321,10 @@ def main() -> dict:
299 print(f"\n== C. F4 louée {fmt(LOYER_F4)} $/mois : RBP {fmt(cl['rbp'])} ; RBE {fmt(cl['rbe'])} ; dépenses {fmt(cl['depenses'])} (frais copro {fmt(cl['frais_copro'])}) ; RNE {fmt(cl['noi'])} $") 321 print(f"\n== C. F4 louée {fmt(LOYER_F4)} $/mois : RBP {fmt(cl['rbp'])} ; RBE {fmt(cl['rbe'])} ; dépenses {fmt(cl['depenses'])} (frais copro {fmt(cl['frais_copro'])}) ; RNE {fmt(cl['noi'])} $")
300 print(f" RNE hors cotisation au fonds ({fmt(cl['fonds'])} $) = {fmt(cl['noi_sans_reserve'])} $ → taux {cl['taux_implicite_sans_reserve']:.2%}") 322 print(f" RNE hors cotisation au fonds ({fmt(cl['fonds'])} $) = {fmt(cl['noi_sans_reserve'])} $ → taux {cl['taux_implicite_sans_reserve']:.2%}")
301 print(f" taux implicite sur {fmt(v_f4)} $ = {cl['taux_implicite']:.2%} ; ratio de dépenses {cl['ratio_depenses']:.1%} ; MRB {cl['mrb']:.1f} ; valeur d'investissement à {TAUX_PLEX:.2%} = {fmt(cl['valeur_investissement_5_25'])} $") 323 print(f" taux implicite sur {fmt(v_f4)} $ = {cl['taux_implicite']:.2%} ; ratio de dépenses {cl['ratio_depenses']:.1%} ; MRB {cl['mrb']:.1f} ; valeur d'investissement à {TAUX_PLEX:.2%} = {fmt(cl['valeur_investissement_5_25'])} $")
324+ fa = financement_acheteur(380_000.0); fi = financement_acheteur(380_000.0, MISE_INDIVISE); out["fin_divise"] = fa; out["fin_indivise"] = fi
325+ print(f"\n== C'. Financement d'un acheteur à 380 000 $ : divise (mise {MISE_DIVISE:.0%}) → mise {fmt(fa['mise_de_fonds'])}, prêt {fmt(fa['pret'])}, paiement {fa['paiement']:,.0f} $/mois à {TAUX_ASSURE:.2%} ; "
326+ f"charges {fa['charges_mensuelles']:,.0f} + taxes {fa['taxes_mensuelles']:,.0f} → coût mensuel {fa['cout_mensuel']:,.0f} $ ; TAM {fa['tam']:.2%} → paiement {fa['paiement_tam']:,.0f} $")
327+ print(f" part indivise (mise {MISE_INDIVISE:.0%}, hypothèse) → mise {fmt(fi['mise_de_fonds'])}, prêt {fmt(fi['pret'])}, paiement {fi['paiement']:,.0f} $/mois ; écart de mise de fonds {fmt(fi['mise_de_fonds'] - fa['mise_de_fonds'])} $")
302 fb = faisabilite(); out["faisabilite"] = fb 328 fb = faisabilite(); out["faisabilite"] = fb
303 b = fb["bloc"] 329 b = fb["bloc"]
304 print(f"\n== D. Conversion — immeuble locatif de 8 logements : RBP {fmt(b['rbp'])} ; RBE {fmt(b['rbe'])} ; DE {fmt(b['depenses'])} ; RNE {fmt(b['noi'])} ; R {b['taux']:.2%} → V_bloc {fmt(b['valeur'])} $ ({fmt(b['valeur']/8)} $/log., MRB {b['mrb']:.1f})") 330 print(f"\n== D. Conversion — immeuble locatif de 8 logements : RBP {fmt(b['rbp'])} ; RBE {fmt(b['rbe'])} ; DE {fmt(b['depenses'])} ; RNE {fmt(b['noi'])} ; R {b['taux']:.2%} → V_bloc {fmt(b['valeur'])} $ ({fmt(b['valeur']/8)} $/log., MRB {b['mrb']:.1f})")
added analysis/python/cases/ch44_renovation.py +382 −0
@@ -0,0 +1,382 @@
1+#!/usr/bin/env python3
2+"""Chapitre 44 — rénovation et efficacité énergétique : analyse coût-bénéfice de deux mesures sur le sujet du cas 1
3+(cottage de Hull, Gatineau, DONNÉES SYNTHÉTIQUES) et lectures de la contribution à la valeur.
4+
5+Imprime tous les chiffres cités dans le chapitre 44 :
6+ * facture d'électricité du sujet au tarif D d'Hydro-Québec en vigueur le 1er avril 2026 (structure vérifiée :
7+ 46,154 ¢/jour ; 7,065 ¢/kWh jusqu'à 40 kWh/jour ; 11,142 ¢/kWh au-delà) pour une consommation DÉCLARÉE
8+ (hypothèse pédagogique) ; prix moyen et prix marginal du kilowattheure ;
9+ * deux mesures : (A) remplacement de la thermopompe centrale de 2016 par un appareil ENERGY STAR climat froid
10+ (subvention LogisVert 120 $ par 1 000 BTU/h, plafond 6 700 $) ; (B) isolation de l'entretoit (Rénoclimat, plafond
11+ « toit » 2 000 $ pour une maison) ; et leur combinaison (économies non additives) ;
12+ * flux annuels, VAN, TRI, délai de récupération simple et actualisé, annuité équivalente, coût de l'énergie
13+ économisée (traite.finance : van, tri, delai_recuperation, facteur_recouvrement) ;
14+ * sensibilité : grille VAN × (taux d'actualisation, croissance du prix de l'électricité), durée de vie, économie,
15+ coût, subvention ; seuils d'équilibre ;
16+ * lectures de la contribution à la valeur : coût, coût net, valeur actuelle des économies, prime verte de la
17+ littérature appliquée à la prédiction hédonique du cas 1 (traite.cas1.predire) ;
18+ * paires de ventes « qualité 4 vs qualité 3 » dans les 600 ventes du cas 1 (traite.comparables.ventes_appariees),
19+ comme indicateur de la contribution d'une rénovation non énergétique (cuisine, salles de bain) ;
20+ * composantes du sujet en fin de vie (fiche d'état du ch. 11 : analysis/python/cases/ch11_inspection.py) ;
21+ * chiffres des exercices.
22+
23+Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch44_renovation.py
24+Toutes les formules financières proviennent de analysis/python/traite/finance.py (aucune réécriture)."""
25+from __future__ import annotations
26+import sys, pathlib, math
27+import numpy as np, pandas as pd
28+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
29+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
30+from traite import finance as fin, cas1
31+from traite import comparables as cp
32+
33+ROOT = pathlib.Path(__file__).resolve().parents[3]
34+
35+# --- Tarif D d'Hydro-Québec, en vigueur le 1er avril 2026 (hydrotarifs2026, section 2.5 ; vérifié le 2026-10-31) --------
36+FRAIS_ACCES_JOUR = 0.46154 # $ par jour
37+PRIX_TRANCHE_1 = 0.07065 # $/kWh jusqu'à 40 kWh par jour
38+SEUIL_KWH_JOUR = 40.0 # kWh par jour
39+PRIX_TRANCHE_2 = 0.11142 # $/kWh au-delà
40+JOURS = 365
41+
42+# --- Consommation du sujet : HYPOTHÈSE PÉDAGOGIQUE (aucune facture réelle ; Hydro-Québec ne publie pas de facture
43+# moyenne par type de maison — notes-E) --------------------------------------------------------------------------
44+CONSO_KWH = 22000.0 # kWh/an, maison tout électrique de 165 m² (1998) avec thermopompe centrale de 2016
45+PART_CHAUFFAGE = 0.50 # part du chauffage des locaux dans la consommation annuelle
46+PART_EAU_CHAUDE = 0.20
47+PART_AUTRES = 0.30
48+
49+# --- Paramètres financiers ----------------------------------------------------------------------------------------
50+TAUX_ACTUALISATION = 0.0458 # taux effectif des fonds avancés, prêts non assurés à taux fixe ≥ 5 ans, août 2026 (BdC, groupe A4)
51+CROISSANCE_ENERGIE = 0.030 # hausse domestique 3,0 % en 2026 et 2027 (Régie, D-2026-033) ; maintenue par hypothèse au-delà
52+VIE_RESTANTE_BATIMENT = 31 # ans (ch. 11 et 24 : vie économique 60 ans, âge effectif 29 ans)
53+
54+# --- Mesure A : thermopompe centrale ENERGY STAR climat froid -------------------------------------------------------
55+TP_CAPACITE_BTU = 36000 # BTU/h à −8 °C (3 tonnes ; hypothèse cohérente avec 165 m²)
56+TP_COUT_STANDARD = 10481.32 * 1.33043 # poste « Thermopompe centrale et plinthes » du devis du cas 1, majoré (ch. 21)
57+TP_COUT = 16500.0 # coût installé d'un appareil climat froid (hypothèse pédagogique, ≈ +18 % sur l'appareil standard)
58+TP_LOGISVERT_STANDARD = 50.0 # $ par 1 000 BTU/h, appareil ENERGY STAR
59+TP_LOGISVERT_FROID = 120.0 # $ par 1 000 BTU/h, appareil climat froid
60+TP_LOGISVERT_MAX = 6700.0
61+TP_ECONOMIE_CHAUFFAGE = 0.30 # fraction de l'énergie de chauffage économisée par rapport à l'appareil de 2016 (hypothèse)
62+TP_VIE = 15 # ans (fiche d'état du ch. 11 : vie utile 15 ans)
63+TP_ECONOMIE_INCR = 0.20 # fraction du chauffage économisée par l'appareil climat froid PAR RAPPORT à un appareil standard neuf (hypothèse)
64+
65+# --- Mesure B : isolation de l'entretoit ---------------------------------------------------------------------------
66+ISO_SURFACE_M2 = 95.0 # empreinte du bâtiment (ch. 22)
67+ISO_COUT_M2 = 44.0 # $/m² installé, cellulose soufflée R-20 → R-60 (hypothèse pédagogique)
68+ISO_COUT = ISO_SURFACE_M2 * ISO_COUT_M2
69+ISO_RENOCLIMAT_MAX = 2000.0 # plafond « toit », maison (Rénoclimat refondu le 1er octobre 2026)
70+ISO_SUBVENTION = 2000.0 # hypothèse : travaux admissibles au plafond
71+ISO_ECONOMIE_CHAUFFAGE = 0.08 # fraction de l'énergie de chauffage économisée (hypothèse)
72+ISO_VIE = VIE_RESTANTE_BATIMENT # composante de longue durée : horizon = vie économique restante du bâtiment
73+
74+# --- Prime verte : résultats publiés (vérifiés le 2026-10-31, voir ch44-claims.csv) ---------------------------------
75+PRIME_VERTE = [
76+ # (clé, pays/marché, mesure, bas %, haut %, point %, type)
77+ ("brounenKok2011", "Pays-Bas, 2008-2009", "étiquette A, B ou C vs D-G", 3.6, 3.7, 3.7, "prix"),
78+ ("brounenKok2011", "Pays-Bas, 2008-2009", "étiquette A vs D ; G vs D", -5.0, 10.2, None, "prix"),
79+ ("kahnKok2014", "Californie, 2007-2012", "maison étiquetée verte vs non étiquetée", 4.5, 5.0, 5.0, "prix"),
80+ ("hylandLyonsLyons2013", "Irlande, 2008-2012", "cote A vs D (prix) ; 1 échelon BER", 1.3, 9.3, 9.3, "prix"),
81+ ("hylandLyonsLyons2013", "Irlande, 2008-2012", "cote A vs D (loyers)", 0.5, 1.8, 1.8, "loyer"),
82+ ("fuerstMcallisterNandaWyatt2015", "Angleterre, 1995-2012", "EPC A/B vs D ; C vs D ; G vs D", -6.0, 5.0, None, "prix"),
83+ ("aydinBrounenKok2020", "Pays-Bas", "+10 % d'efficacité énergétique mesurée", 2.2, 2.2, 2.2, "prix"),
84+ ("cespedeslopez2019", "Méta-analyse, 66 documents", "prix avec certificat : monde ; Europe ; Amérique", 2.32, 5.36, 4.20, "prix"),
85+ ("eichholtzKokQuigley2010", "É.-U., bureaux", "immeuble certifié : loyer ; loyer effectif ; prix", 3.0, 16.0, None, "bureaux"),
86+]
87+HYPOTHESE_COUT_CUISINE_SDB = 65000.0 # coût d'une rénovation complète cuisine + 2 salles de bain (hypothèse pédagogique)
88+
89+
90+# ------------------------------------------------------------------------------------------------------- tarif D
91+def facture_tarif_d(kwh: float, jours: int = JOURS, indice: float = 1.0) -> dict:
92+ """Facture annuelle au tarif D (hors taxes) : frais d'accès + énergie en deux tranches ; `indice` multiplie les prix."""
93+ seuil = SEUIL_KWH_JOUR * jours
94+ t1 = min(kwh, seuil); t2 = max(kwh - seuil, 0.0)
95+ acces = FRAIS_ACCES_JOUR * jours * indice
96+ e1 = t1 * PRIX_TRANCHE_1 * indice; e2 = t2 * PRIX_TRANCHE_2 * indice
97+ total = acces + e1 + e2
98+ return {"kwh": kwh, "acces": acces, "tranche1_kwh": t1, "tranche2_kwh": t2, "energie_t1": e1, "energie_t2": e2,
99+ "total": total, "prix_moyen": total / kwh if kwh else float("nan"),
100+ "prix_marginal": (PRIX_TRANCHE_2 if kwh > seuil else PRIX_TRANCHE_1) * indice}
101+
102+
103+def economie_annuelle(kwh_avant: float, kwh_apres: float, indice: float = 1.0) -> float:
104+ """Économie exacte = différence de factures (tient compte d'un éventuel franchissement du seuil)."""
105+ return facture_tarif_d(kwh_avant, indice=indice)["total"] - facture_tarif_d(kwh_apres, indice=indice)["total"]
106+
107+
108+# ------------------------------------------------------------------------------------------------------- mesures
109+def mesures() -> dict:
110+ chauffage = CONSO_KWH * PART_CHAUFFAGE
111+ eco_tp = chauffage * TP_ECONOMIE_CHAUFFAGE
112+ eco_iso = chauffage * ISO_ECONOMIE_CHAUFFAGE
113+ # combinaison : l'isolation réduit d'abord la charge ; la thermopompe économise une fraction du reste
114+ eco_combi = eco_iso + (chauffage - eco_iso) * TP_ECONOMIE_CHAUFFAGE
115+ sub_tp = min(TP_LOGISVERT_FROID * TP_CAPACITE_BTU / 1000.0, TP_LOGISVERT_MAX)
116+ sub_tp_std = min(TP_LOGISVERT_STANDARD * TP_CAPACITE_BTU / 1000.0, TP_LOGISVERT_MAX)
117+ return {
118+ "A": {"nom": "Thermopompe climat froid", "cout": TP_COUT, "subvention": sub_tp, "kwh": eco_tp, "vie": TP_VIE},
119+ "B": {"nom": "Isolation de l'entretoit", "cout": ISO_COUT, "subvention": ISO_SUBVENTION, "kwh": eco_iso, "vie": ISO_VIE},
120+ "AB": {"nom": "Les deux mesures", "cout": TP_COUT + ISO_COUT, "subvention": sub_tp + ISO_SUBVENTION, "kwh": eco_combi,
121+ "vie": TP_VIE}, # horizon commun = vie de la thermopompe (l'isolation continue au-delà : voir texte)
122+ # décision incrémentale à la fin de vie de l'appareil de 2016 : climat froid plutôt que standard
123+ "A_incr": {"nom": "Climat froid plutôt que standard (fin de vie)", "cout": TP_COUT - TP_COUT_STANDARD,
124+ "subvention": sub_tp - sub_tp_std, "kwh": chauffage * TP_ECONOMIE_INCR, "vie": TP_VIE},
125+ "chauffage_kwh": chauffage, "eco_additive_kwh": eco_tp + eco_iso,
126+ "subvention_tp_standard": sub_tp_std, "cout_tp_standard": TP_COUT_STANDARD,
127+ "surcout_climat_froid": TP_COUT - TP_COUT_STANDARD, "surcout_net_climat_froid": (TP_COUT - TP_COUT_STANDARD) - (sub_tp - sub_tp_std),
128+ }
129+
130+
131+def flux_mesure(m: dict, r: float = TAUX_ACTUALISATION, g: float = CROISSANCE_ENERGIE, vie: int | None = None,
132+ kwh_avant: float = CONSO_KWH, subvention: float | None = None, cout: float | None = None,
133+ kwh: float | None = None) -> dict:
134+ """Flux : t = 0 : −(coût − subvention) ; t = 1..n : économie_t = [F(kWh avant) − F(kWh après)] × (1+g)^t.
135+ Indicateurs : VAN, TRI, délai simple et actualisé, annuité équivalente, coût de l'énergie économisée."""
136+ n = int(vie if vie is not None else m["vie"]); sub = m["subvention"] if subvention is None else subvention
137+ K = m["cout"] if cout is None else cout; dk = m["kwh"] if kwh is None else kwh
138+ eco0 = economie_annuelle(kwh_avant, kwh_avant - dk)
139+ flux = [-(K - sub)] + [eco0 * (1.0 + g) ** t for t in range(1, n + 1)]
140+ van = fin.van(flux, r)
141+ try:
142+ t_ri = fin.tri(flux)
143+ except ValueError:
144+ t_ri = float("nan")
145+ actualises = [cf / (1.0 + r) ** t for t, cf in enumerate(flux)]
146+ crf = fin.facteur_recouvrement(r, n)
147+ va_eco = sum(actualises[1:])
148+ return {"flux": flux, "actualises": actualises, "cumul_actualise": np.cumsum(actualises).tolist(),
149+ "economie_an1": flux[1], "economie_courante": eco0, "cout_net": K - sub, "van": van, "tri": t_ri,
150+ "delai_simple": fin.delai_recuperation(flux), "delai_actualise": fin.delai_recuperation(actualises),
151+ "va_economies": va_eco, "indice_rentabilite": va_eco / (K - sub) if K > sub else float("inf"),
152+ "annuite_cout_net": (K - sub) * crf, "annuite_cout_brut": K * crf,
153+ "cee_net": (K - sub) * crf / dk, "cee_brut": K * crf / dk, # coût de l'énergie économisée, $/kWh
154+ "n": n, "r": r, "g": g}
155+
156+
157+def seuils(m: dict, r: float = TAUX_ACTUALISATION, g: float = CROISSANCE_ENERGIE) -> dict:
158+ """Valeurs d'équilibre (VAN = 0) : croissance du prix de l'énergie, coût net, économie en kWh, taux (= TRI)."""
159+ f0 = flux_mesure(m, r=r, g=g)
160+ cout_net_eq = f0["va_economies"]
161+ # économie en kWh d'équilibre : VAN(kwh) = 0, recherche par bissection (économie linéaire si pas de changement de tranche)
162+ lo, hi = 0.0, CONSO_KWH * 0.9
163+ for _ in range(200):
164+ mid = 0.5 * (lo + hi)
165+ if flux_mesure(m, r=r, g=g, kwh=mid)["van"] < 0: lo = mid
166+ else: hi = mid
167+ kwh_eq = 0.5 * (lo + hi)
168+ # croissance d'équilibre
169+ glo, ghi = -0.5, 0.5
170+ for _ in range(200):
171+ gm = 0.5 * (glo + ghi)
172+ if flux_mesure(m, r=r, g=gm)["van"] < 0: glo = gm
173+ else: ghi = gm
174+ g_eq = 0.5 * (glo + ghi)
175+ return {"cout_net_equilibre": cout_net_eq, "subvention_equilibre": m["cout"] - cout_net_eq, "kwh_equilibre": kwh_eq,
176+ "fraction_chauffage_equilibre": kwh_eq / (CONSO_KWH * PART_CHAUFFAGE), "g_equilibre": g_eq, "tri": f0["tri"]}
177+
178+
179+def grille_van(m: dict, taux=(0.02, 0.03, 0.04, 0.0458, 0.05, 0.06, 0.07, 0.08, 0.10), croissances=(0.0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.06)) -> pd.DataFrame:
180+ return pd.DataFrame([[flux_mesure(m, r=r, g=g)["van"] for g in croissances] for r in taux], index=list(taux), columns=list(croissances))
181+
182+
183+def sensibilite_tornade(m: dict) -> list[dict]:
184+ """Variation de la VAN de la mesure A pour des chocs déclarés sur chaque paramètre (toutes choses égales par ailleurs)."""
185+ base = flux_mesure(m)["van"]
186+ out = []
187+ def add(nom, bas, haut): out.append({"param": nom, "base": base, "bas": bas, "haut": haut})
188+ add("Économie (20 % / 40 % du chauffage)", flux_mesure(m, kwh=CONSO_KWH * PART_CHAUFFAGE * 0.20)["van"], flux_mesure(m, kwh=CONSO_KWH * PART_CHAUFFAGE * 0.40)["van"])
189+ add("Taux d'actualisation (8 % / 2 %)", flux_mesure(m, r=0.08)["van"], flux_mesure(m, r=0.02)["van"])
190+ add("Croissance du prix (0 % / 6 %)", flux_mesure(m, g=0.0)["van"], flux_mesure(m, g=0.06)["van"])
191+ add("Durée de vie (12 / 20 ans)", flux_mesure(m, vie=12)["van"], flux_mesure(m, vie=20)["van"])
192+ add("Coût installé (+20 % / −20 %)", flux_mesure(m, cout=m["cout"] * 1.2)["van"], flux_mesure(m, cout=m["cout"] * 0.8)["van"])
193+ add("Subvention (0 $ / plafond 6 700 $)", flux_mesure(m, subvention=0.0)["van"], flux_mesure(m, subvention=TP_LOGISVERT_MAX)["van"])
194+ add("Consommation (16 000 / 28 000 kWh)", flux_mesure(m, kwh_avant=16000.0, kwh=16000.0 * PART_CHAUFFAGE * TP_ECONOMIE_CHAUFFAGE)["van"],
195+ flux_mesure(m, kwh_avant=28000.0, kwh=28000.0 * PART_CHAUFFAGE * TP_ECONOMIE_CHAUFFAGE)["van"])
196+ return out
197+
198+
199+def sensibilite_duree(m: dict, vies=(10, 12, 15, 18, 20, 25), taux=(0.02, 0.0458, 0.07, 0.10)) -> pd.DataFrame:
200+ return pd.DataFrame([[flux_mesure(m, r=r, vie=v)["van"] for r in taux] for v in vies], index=list(vies), columns=list(taux))
201+
202+
203+# -------------------------------------------------------------------------------------------- contribution à la valeur
204+def lectures_contribution(m: dict, f: dict, valeur_sujet: float) -> dict:
205+ """Quatre lectures de ce que la mesure « vaut » pour le bien : coût, coût net, VA des économies (acheteur rationnel),
206+ prime verte de la littérature appliquée à la valeur du sujet (bornes)."""
207+ primes = {"Europe (méta-analyse)": 0.0232, "Monde (méta-analyse)": 0.0420, "Amérique du Nord (méta-analyse)": 0.0536,
208+ "Pays-Bas, étiquette verte": 0.037, "Angleterre, EPC C vs D": 0.018, "Californie, étiquette verte": 0.05}
209+ return {"cout": m["cout"], "cout_net": f["cout_net"], "va_economies": f["va_economies"], "van": f["van"],
210+ "ratio_va_cout": f["va_economies"] / m["cout"], "ratio_va_cout_net": f["va_economies"] / f["cout_net"],
211+ "primes_dollars": {k: v * valeur_sujet for k, v in primes.items()}, "primes_pct": primes, "valeur_sujet": valeur_sujet}
212+
213+
214+def va_economies_restantes(m: dict, annees_ecoulees: int, r: float = TAUX_ACTUALISATION, g: float = CROISSANCE_ENERGIE) -> float:
215+ """Valeur actuelle, à la date d'une revente future, des économies restantes (ce qu'un acheteur rationnel paierait au plus)."""
216+ f = flux_mesure(m, r=r, g=g)
217+ rest = f["flux"][1 + annees_ecoulees:]
218+ return sum(cf / (1.0 + r) ** (t + 1) for t, cf in enumerate(rest))
219+
220+
221+# -------------------------------------------------------------------------------------------- paires « qualité »
222+def paires_qualite(v: pd.DataFrame, modele: dict, q_haut: int = 4, q_bas: int = 3, tol_s: float = 25.0, tol_age: int = 10,
223+ tol_mois: float = 18.0, tol_dist_km: float = 5.0, date_eval: str = "2026-09-01") -> dict:
224+ """Paires de ventes du cas 1 ne différant (à des tolérances déclarées près) que par la note de qualité (q_haut vs q_bas) :
225+ même secteur, même type, |ΔS| ≤ tol_s m², |Δâge| ≤ tol_age, mêmes garage / sous-sol / piscine, |Δdate| ≤ tol_mois,
226+ distance ≤ tol_dist_km. Le prix du membre « bas » est ramené à la date du membre « haut » par la tendance du modèle
227+ hédonique (croissance mensuelle composée). Chaque vente n'entre que dans une paire (appariement glouton par distance)."""
228+ g_m = modele["croissance_mensuelle"]
229+ hauts = v[v["qualite_1a5"] == q_haut].copy(); bas = v[v["qualite_1a5"] == q_bas].copy()
230+ coords = lambda df: np.radians(df[["lat", "lon"]].values)
231+ utilises = set(); rows = []
232+ for _, h in hauts.iterrows():
233+ cand = bas[(bas["secteur"] == h["secteur"]) & (bas["type"] == h["type"]) & (~bas["id_vente"].isin(utilises))
234+ & ((bas["superficie_habitable_m2"] - h["superficie_habitable_m2"]).abs() <= tol_s)
235+ & ((bas["age"] - h["age"]).abs() <= tol_age) & (bas["garage_places"] == h["garage_places"])
236+ & (bas["sous_sol_fini"] == h["sous_sol_fini"]) & (bas["piscine"] == h["piscine"])]
237+ if cand.empty: continue
238+ dmois = ((cand["date_vente"] - h["date_vente"]).dt.days / 30.4375)
239+ cand = cand[dmois.abs() <= tol_mois]
240+ if cand.empty: continue
241+ # distance haversine approximative (km)
242+ lat1, lon1 = math.radians(h["lat"]), math.radians(h["lon"])
243+ lat2 = np.radians(cand["lat"].values); lon2 = np.radians(cand["lon"].values)
244+ a = np.sin((lat2 - lat1) / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin((lon2 - lon1) / 2) ** 2
245+ dist = 6371.0 * 2 * np.arcsin(np.sqrt(a))
246+ cand = cand.assign(dist_km=dist)
247+ cand = cand[cand["dist_km"] <= tol_dist_km]
248+ if cand.empty: continue
249+ # score de ressemblance : superficie, âge, date, distance normalisés
250+ score = ((cand["superficie_habitable_m2"] - h["superficie_habitable_m2"]).abs() / tol_s + (cand["age"] - h["age"]).abs() / tol_age
251+ + ((cand["date_vente"] - h["date_vente"]).dt.days / 30.4375).abs() / tol_mois + cand["dist_km"] / tol_dist_km)
252+ b = cand.loc[score.idxmin()]
253+ utilises.add(b["id_vente"])
254+ mois = (h["date_vente"] - b["date_vente"]).days / 30.4375
255+ prix_bas_aj = b["prix_vente"] * (1.0 + g_m) ** mois # ramené à la date de la vente « haute »
256+ rows.append({"id_haut": h["id_vente"], "id_bas": b["id_vente"], "secteur": h["secteur"], "prix_haut": h["prix_vente"],
257+ "prix_bas": b["prix_vente"], "prix_bas_ajuste": prix_bas_aj, "mois": mois, "dist_km": float(b["dist_km"]),
258+ "dS": h["superficie_habitable_m2"] - b["superficie_habitable_m2"], "dage": h["age"] - b["age"],
259+ "difference": h["prix_vente"] - prix_bas_aj, "difference_pct": h["prix_vente"] / prix_bas_aj - 1.0})
260+ P = pd.DataFrame(rows)
261+ res = cp.ventes_appariees(P["prix_haut"].tolist(), P["prix_bas_ajuste"].tolist()) if len(P) else {"n": 0, "moyenne": float("nan"), "mediane": float("nan"), "ecart_type": float("nan")}
262+ res["erreur_type"] = res["ecart_type"] / math.sqrt(res["n"]) if res["n"] > 1 else float("nan")
263+ beta_q = float(modele["coef"].loc["qualite", "coef"])
264+ return {"paires": P, "resultat": res, "mediane_pct": float(P["difference_pct"].median()) if len(P) else float("nan"),
265+ "moyenne_pct": float(P["difference_pct"].mean()) if len(P) else float("nan"),
266+ "ecart_type_pct": float(P["difference_pct"].std(ddof=1)) if len(P) > 1 else float("nan"),
267+ "erreur_type_pct": float(P["difference_pct"].std(ddof=1) / math.sqrt(len(P))) if len(P) > 1 else float("nan"),
268+ "ic95_pct": (float(P["difference_pct"].mean() - 1.96 * P["difference_pct"].std(ddof=1) / math.sqrt(len(P))),
269+ float(P["difference_pct"].mean() + 1.96 * P["difference_pct"].std(ddof=1) / math.sqrt(len(P)))) if len(P) > 1 else (float("nan"), float("nan")),
270+ "beta_qualite": beta_q, "effet_hedonique_pct": math.exp(beta_q) - 1.0}
271+
272+
273+def contribution_renovation(paires: dict, valeur_sujet: float, cout: float = HYPOTHESE_COUT_CUISINE_SDB) -> dict:
274+ """Contribution d'un échelon de qualité au sujet (paires et hédonique) rapportée au coût hypothétique d'une rénovation
275+ cuisine + salles de bain qui ferait passer la note de 3 à 4."""
276+ c_paires = paires["mediane_pct"] * valeur_sujet; c_hed = paires["effet_hedonique_pct"] * valeur_sujet
277+ return {"contribution_paires": c_paires, "contribution_hedonique": c_hed, "cout": cout,
278+ "ratio_paires": c_paires / cout, "ratio_hedonique": c_hed / cout, "van_renovation": c_paires - cout}
279+
280+
281+# -------------------------------------------------------------------------------------------- fiche d'état (ch. 11)
282+def composantes_fin_de_vie(seuil: float = 0.90) -> pd.DataFrame:
283+ import ch11_inspection as c11
284+ devis, sujet, cn = c11.charger()
285+ f = c11.fiche(devis, cn)
286+ f = f.copy(); f["ratio"] = f["age_effectif"] / f["vie_utile"]
287+ cols = [c for c in ["composante", "annee", "age", "vie_utile", "etat", "age_effectif", "ratio", "cout_neuf"] if c in f.columns]
288+ return f[(~f["longue_duree"]) & (f["ratio"] >= seuil)][cols].sort_values("ratio", ascending=False).reset_index(drop=True)
289+
290+
291+# -------------------------------------------------------------------------------------------- exercices
292+def exercices() -> dict:
293+ ex = {}
294+ # Ex. 1 : factures à 18 000 et 26 000 kWh ; prix moyen et marginal
295+ ex["ex1"] = {k: facture_tarif_d(k) for k in (18000.0, 26000.0)}
296+ ex["ex1"]["economie_1000_kwh_a_26000"] = economie_annuelle(26000.0, 25000.0)
297+ ex["ex1"]["economie_1000_kwh_a_15000"] = economie_annuelle(15000.0, 14000.0)
298+ # Ex. 2 : LogisVert 24 000 BTU/h standard vs climat froid ; capacité où le plafond mord
299+ ex["ex2"] = {"standard": min(50.0 * 24, 6700.0), "froid": min(120.0 * 24, 6700.0), "capacite_plafond_btu": 6700.0 / 120.0 * 1000.0,
300+ "froid_60000": min(120.0 * 60, 6700.0)}
301+ # Ex. 3 : remplacement des 16 fenêtres (fin de vie 0,717) : coût neuf 22 311 $ (fiche ch. 11), économie 600 kWh, Rénoclimat 300 $/fenêtre max 4 500 $
302+ m_fen = {"nom": "Fenêtres", "cout": 22311.0, "subvention": min(300.0 * 16, 4500.0), "kwh": 600.0, "vie": 30}
303+ ex["ex3"] = {"mesure": m_fen, "flux": flux_mesure(m_fen), "sans_subvention": flux_mesure(m_fen, subvention=0.0)}
304+ # Ex. 4 : coût de l'énergie économisée de l'isolation à 4,58 % et à 8 %
305+ mm = mesures()
306+ ex["ex4"] = {"r458": flux_mesure(mm["B"]), "r8": flux_mesure(mm["B"], r=0.08), "sans_sub": flux_mesure(mm["B"], subvention=0.0)}
307+ # Ex. 5 : primes transposées à 637 953 $
308+ d = cas1.charger(); mod = cas1.hedonique(d["ventes"]); vs = float(cas1.predire(mod, cas1.sujet_df(d["sujet"]))[0])
309+ ex["ex5"] = {"valeur": vs, "europe": 0.0232 * vs, "amerique": 0.0536 * vs, "va_economies_AB": flux_mesure(mm["AB"])["va_economies"],
310+ "va_economies_AB_31": flux_mesure(mm["AB"], vie=31)["va_economies"]}
311+ # Ex. 6 : seuils de la thermopompe
312+ ex["ex6"] = seuils(mm["A"])
313+ ex["ex6"]["tornade"] = sensibilite_tornade(mm["A"])
314+ # Ex. 7 : paires avec tolérances élargies / resserrées
315+ p_base = paires_qualite(d["ventes"], mod)
316+ p_large = paires_qualite(d["ventes"], mod, tol_s=30.0, tol_age=15, tol_mois=24.0, tol_dist_km=99.0)
317+ p_serre = paires_qualite(d["ventes"], mod, tol_s=15.0, tol_age=8, tol_mois=9.0, tol_dist_km=2.0)
318+ ex["ex7"] = {"base": p_base, "large": p_large, "serre": p_serre, "kennedy": math.exp(mod["coef"].loc["qualite", "coef"] - 0.5 * mod["coef"].loc["qualite", "se_hc1"] ** 2) - 1.0}
319+ return ex
320+
321+
322+# -------------------------------------------------------------------------------------------- impression
323+def main() -> dict:
324+ f = lambda x: f"{x:,.2f}".replace(",", " ")
325+ print("=== Facture du sujet au tarif D (hypothèse : 22 000 kWh/an) ===")
326+ F = facture_tarif_d(CONSO_KWH)
327+ for k, v in F.items(): print(f" {k:16s} {v:,.4f}")
328+ print(f" seuil annuel {SEUIL_KWH_JOUR * JOURS:,.0f} kWh ; conso/jour {CONSO_KWH / JOURS:.1f} kWh")
329+ print("\n=== Mesures ===")
330+ mm = mesures()
331+ for k in ("A", "B", "AB", "A_incr"):
332+ m = mm[k]; fl = flux_mesure(m)
333+ print(f"--- {k} {m['nom']} : coût {f(m['cout'])} ; subvention {f(m['subvention'])} ; net {f(fl['cout_net'])} ; "
334+ f"économie {m['kwh']:,.0f} kWh = {f(fl['economie_courante'])} $/an (an 1 : {f(fl['economie_an1'])}) ; vie {m['vie']} ans")
335+ print(f" VAN {f(fl['van'])} ; TRI {fl['tri']*100:.2f} % ; délai simple {fl['delai_simple']} ; actualisé {fl['delai_actualise']} ; "
336+ f"VA éco {f(fl['va_economies'])} ; IR {fl['indice_rentabilite']:.3f} ; annuité nette {f(fl['annuite_cout_net'])} ; "
337+ f"CEE net {fl['cee_net']*100:.2f} ¢/kWh ; brut {fl['cee_brut']*100:.2f} ¢/kWh")
338+ print(f" chauffage {mm['chauffage_kwh']:,.0f} kWh ; économie additive {mm['eco_additive_kwh']:,.0f} vs combinée {mm['AB']['kwh']:,.0f} kWh")
339+ print(f" thermopompe standard : coût {f(mm['cout_tp_standard'])} ; LogisVert {f(mm['subvention_tp_standard'])} ; surcoût climat froid {f(mm['surcout_climat_froid'])} ; net {f(mm['surcout_net_climat_froid'])}")
340+ print("\n=== Seuils d'équilibre (A) ===")
341+ for k, v in seuils(mm["A"]).items(): print(f" {k:30s} {v:,.4f}")
342+ print("\n=== Grille VAN (A) : taux × croissance ===")
343+ print(grille_van(mm["A"]).round(0).to_string())
344+ print("\n=== Grille VAN (B) ===")
345+ print(grille_van(mm["B"]).round(0).to_string())
346+ print("\n=== Sensibilité durée (A) ===")
347+ print(sensibilite_duree(mm["A"]).round(0).to_string())
348+ print("\n=== Tornade (A) ===")
349+ for o in sensibilite_tornade(mm["A"]): print(f" {o['param']:40s} {f(o['bas'])} … {f(o['haut'])}")
350+ d = cas1.charger(); mod = cas1.hedonique(d["ventes"]); vs = float(cas1.predire(mod, cas1.sujet_df(d["sujet"]))[0])
351+ print(f"\n=== Lectures de la contribution (valeur hédonique du sujet {f(vs)}) ===")
352+ for k in ("A", "B", "AB"):
353+ L = lectures_contribution(mm[k], flux_mesure(mm[k]), vs)
354+ print(f" {k}: coût {f(L['cout'])} ; net {f(L['cout_net'])} ; VA éco {f(L['va_economies'])} ; VA/coût {L['ratio_va_cout']:.3f} ; VA/net {L['ratio_va_cout_net']:.3f}")
355+ for k, v in lectures_contribution(mm["A"], flux_mesure(mm["A"]), vs)["primes_dollars"].items(): print(f" prime {k:34s} {f(v)}")
356+ for a in (0, 5, 10): print(f" VA des économies restantes de A après {a} ans : {f(va_economies_restantes(mm['A'], a))}")
357+ print("\n=== Paires qualité 4 vs 3 (cas 1) ===")
358+ P = paires_qualite(d["ventes"], mod)
359+ print(f" n = {P['resultat']['n']} ; différence moyenne {f(P['resultat']['moyenne'])} ; médiane {f(P['resultat']['mediane'])} ; écart-type {f(P['resultat']['ecart_type'])} ; erreur-type {f(P['resultat']['erreur_type'])}")
360+ print(f" en % : moyenne {P['moyenne_pct']*100:.2f} ; médiane {P['mediane_pct']*100:.2f} ; écart-type {P['ecart_type_pct']*100:.2f} ; erreur-type {P['erreur_type_pct']*100:.2f} ; IC95 {P['ic95_pct'][0]*100:.2f}..{P['ic95_pct'][1]*100:.2f} ; hédonique {P['effet_hedonique_pct']*100:.2f} (β = {P['beta_qualite']:.5f})")
361+ print(P["paires"].round(2).to_string())
362+ C = contribution_renovation(P, vs)
363+ for k, v in C.items(): print(f" {k:26s} {v:,.4f}")
364+ print("\n=== Composantes en fin de vie (fiche ch. 11, ratio ≥ 0,90) ===")
365+ print(composantes_fin_de_vie().round(3).to_string())
366+ print("\n=== Exercices ===")
367+ ex = exercices()
368+ for k, v in ex["ex1"].items():
369+ print(f" ex1 {k}: {v if not isinstance(v, dict) else {kk: round(vv, 4) for kk, vv in v.items()}}")
370+ print(f" ex2 {ex['ex2']}")
371+ print(f" ex3 fenêtres : VAN {f(ex['ex3']['flux']['van'])} ; TRI {ex['ex3']['flux']['tri']} ; CEE net {ex['ex3']['flux']['cee_net']*100:.2f} ¢ ; VA éco {f(ex['ex3']['flux']['va_economies'])} ; sans sub VAN {f(ex['ex3']['sans_subvention']['van'])}")
372+ print(f" ex4 CEE iso : 4,58 % net {ex['ex4']['r458']['cee_net']*100:.2f} ¢ brut {ex['ex4']['r458']['cee_brut']*100:.2f} ¢ ; 8 % net {ex['ex4']['r8']['cee_net']*100:.2f} ¢ ; VAN sans sub {f(ex['ex4']['sans_sub']['van'])}")
373+ print(" ex5 " + str({k: round(v, 2) for k, v in ex["ex5"].items()}))
374+ print(f" ex6 g_eq {ex['ex6']['g_equilibre']*100:.2f} % ; coût net eq {f(ex['ex6']['cout_net_equilibre'])} ; kWh eq {ex['ex6']['kwh_equilibre']:,.0f} ({ex['ex6']['fraction_chauffage_equilibre']*100:.1f} % du chauffage)")
375+ for k in ("base", "large", "serre"):
376+ p = ex["ex7"][k]; print(f" ex7 {k}: n {p['resultat']['n']} ; médiane {p['mediane_pct']*100:.2f} % ; moyenne {p['moyenne_pct']*100:.2f} % ; σ {p['ecart_type_pct']*100:.2f} %")
377+ print(f" ex7 Kennedy {ex['ex7']['kennedy']*100:.3f} %")
378+ return {"facture": F, "mesures": mm, "valeur_sujet": vs, "paires": P, "contribution": C, "exercices": ex}
379+
380+
381+if __name__ == "__main__":
382+ main()
added analysis/python/cases/ch46_dossier.py +340 −0
@@ -0,0 +1,340 @@
1+"""Chapitre 46 — Cas 2 : triplex à Québec (Limoilou), dossier complet (DONNÉES SYNTHÉTIQUES, graine 20261031).
2+
3+Tout chiffre cité dans manuscript/chapters/ch46.tex et solutions/ch46.tex provient de ce script. Il s'appuie sur :
4+- traite.cas2 (revenus, RNE, 127 ventes de plex, indicateurs) — partagé avec les ch. 26, 27, 28 et 32 ;
5+- analysis/python/cases/ch32_plex.py (sélection des cinq comparables triplex de Limoilou, régression taux/loyer relatif,
6+ financement résidentiel assuré, coût d'occupation) — importé, jamais recalculé autrement ;
7+- traite.finance (Hypotheque, capitalisation_directe, rne), traite.comparables (ComparableAjuste, poids_inverse_brut,
8+ reconciliation, ajustement_temps_taux), traite.cout (cout_par_superficie_ajuste, depreciation_age_vie, valeur_par_cout).
9+
10+Ce que le chapitre 46 AJOUTE au chapitre 32 : normalisation des taxes aux tarifs 2026 de la Ville de Québec (RNE normalisé),
11+grille d'ajustements explicites en $/logement sur les cinq comparables (régression auxiliaire sur les 127 ventes), indication
12+par le coût (contrôle), droits de mutation de la Ville de Québec, valeur de la faculté de reprise, réconciliation et rapport.
13+
14+Exécution : python3 analysis/python/cases/ch46_dossier.py (depuis la racine du dépôt)
15+"""
16+from __future__ import annotations
17+import sys, pathlib
18+import numpy as np, pandas as pd
19+
20+ROOT = pathlib.Path(__file__).resolve().parents[3]
21+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
22+from traite import cas2, finance as fin, comparables as cmp, cout # noqa: E402
23+import ch32_plex as C # noqa: E402
24+
25+GRAINE = 20261031
26+DATE_EVAL = "2026-09-01"
27+
28+# ----------------------------------------------------------------------------- paramètres déclarés (sources : notes-E, notes-B, notes-C, ch. 21/24)
29+TAUX_TAXE_1A5 = 0.7464 / 100 # Ville de Québec 2026, résidentiel 1 à 5 logements, $/100 $ (R.V.Q. 3492 ; SRC-E-027)
30+TARIF_AQUEDUC_EGOUT = 386.0 # $/logement (idem)
31+TARIF_MATIERES_RESIDUELLES = 195.0 # $/logement (idem)
32+TAUX_TAXE_SCOLAIRE = 0.07899 / 100 # 2026-2027, $/100 $ (SRC-E-026) ; exemption des premiers 25 000 $
33+EXEMPTION_SCOLAIRE = 25_000.0
34+FACTEUR_COMPARATIF_2026 = 1.08 # Ville de Québec, facteur comparatif 2026 (source secondaire, SRC-A-023)
35+# Droits sur les mutations immobilières, Ville de Québec, 2026 (LDMI art. 2 + taux municipaux ; SRC-A-023, partiellement vérifié)
36+TRANCHES_MUTATION_QUEBEC = [(62_900.0, 0.005), (315_000.0, 0.01), (500_000.0, 0.015), (750_000.0, 0.025), (float("inf"), 0.03)]
37+# Coût (contrôle seulement) : coût unitaire du cas 1 au T3 2026 (ch. 21, 2 409 $/m² habitable pour 165 m²), élasticité de taille −0,10,
38+# ratio IPCB « appartements < 5 étages » / « maison individuelle », Montréal, T3 2026 (118,5 / 116,5 ; tableau 18-10-0289-01)
39+COUT_UNITAIRE_CAS1, SUPERFICIE_CAS1, ELASTICITE = 2409.0, 165.0, -0.10
40+IPCB_APPART, IPCB_MAISON = 118.5, 116.5
41+VIE_ECONOMIQUE = 90.0 # hypothèse du dossier : plex de brique de 1948, vie économique totale
42+PHI_ETAT3 = 0.5 # fraction de vie consommée associée à la note d'état 3 (convention ch. 11/24)
43+TERRAIN_PAR_M2 = 900.0 # HYPOTHÈSE DU DOSSIER (non vérifiée) : valeur unitaire du terrain, Limoilou
44+POIDS_46 = {"par_logement_ajuste": 0.40, "mrb": 0.35, "capitalisation": 0.25} # pondération de l'ouvrage pour un triplex de quartier central (ch. 32, sect. 32.4)
45+T_MIN = 2.0 # seuil de significativité (|t|) pour appliquer un coefficient de la régression auxiliaire dans la grille
46+PAS_ARRONDI = 5_000.0
47+ROTATION_QUEBEC = 0.13 # taux de rotation des locataires, RMR de Québec, SCHL oct. 2025 (SRC-E-007)
48+G_MARCHE, G_TAL, R_ACT = 0.035, 0.031, 0.06 # croissance des loyers de marché, pourcentage de base TAL 2026, taux d'actualisation (hypothèses, ch. 26/32)
49+
50+
51+# ----------------------------------------------------------------------------- 46.5 normalisation des taxes et RNE
52+def taxes_2026(valeur_role: float, nb_logements: int) -> dict:
53+ """Taxes municipales (taux 1-5 logements + tarifs par logement) et taxe scolaire (exemption des premiers 25 000 $) sur la valeur au rôle."""
54+ fonciere = valeur_role * TAUX_TAXE_1A5
55+ tarifs = nb_logements * (TARIF_AQUEDUC_EGOUT + TARIF_MATIERES_RESIDUELLES)
56+ scolaire = max(valeur_role - EXEMPTION_SCOLAIRE, 0.0) * TAUX_TAXE_SCOLAIRE
57+ return {"fonciere": fonciere, "tarifs": tarifs, "municipale": fonciere + tarifs, "scolaire": scolaire, "total": fonciere + tarifs + scolaire}
58+
59+
60+def depenses_normalisees_2026(dep: pd.DataFrame, sujet: dict) -> pd.DataFrame:
61+ """Dépenses du dossier avec les taxes recalculées aux taux 2026 (les autres postes sont conservés : polices, contrats, factures)."""
62+ t = taxes_2026(sujet["valeur_role_2025"], sujet["nb_logements"])
63+ d = dep.copy(); d["declare"] = d["montant_annuel"].astype(float); d["montant_annuel"] = d["montant_annuel"].astype(float)
64+ d.loc[d["poste"] == "Taxes municipales", "montant_annuel"] = t["municipale"]
65+ d.loc[d["poste"] == "Taxe scolaire", "montant_annuel"] = t["scolaire"]
66+ return d
67+
68+
69+def base_normalisee(d: dict) -> dict:
70+ """RNE du dossier (taxes 2026) par traite.cas2.rne, et RNE « déclaré » (ch. 32) pour mémoire."""
71+ dep_n = depenses_normalisees_2026(d["depenses"], d["sujet"])
72+ b = cas2.rne(d["loyers"], dep_n)
73+ b["detail"]["declare"] = dep_n["declare"].values
74+ b_decl = cas2.rne(d["loyers"], d["depenses"])
75+ b["rne_declare"] = b_decl["rne"]; b["depenses_declarees"] = b_decl["depenses_totales"]
76+ b["ecart_taxes"] = b_decl["depenses_totales"] - b["depenses_totales"]
77+ b["taxes"] = taxes_2026(d["sujet"]["valeur_role_2025"], d["sujet"]["nb_logements"])
78+ # RNE aux loyers de marché (même structure de dépenses, gestion imputée sur le RBE de marché)
79+ det = b["detail"]; dep_m = float(det.loc[~det["type"].isin(["imputée", "réserve"]), "normalise"].sum()) + cas2.GESTION_IMPUTEE * b["rbe_marche"]
80+ b["rne_marche"] = fin.rne(b["rbe_marche"], dep_m, b["reserve"])
81+ b["loyer_marche_moyen"] = float(d["loyers"]["loyer_marche_estime"].mean()); b["loyer_contrat_moyen"] = float(d["loyers"]["loyer_mensuel"].mean())
82+ b["rho"] = b["rbp_contrat"] / b["rbp_marche"]
83+ return b
84+
85+
86+# ----------------------------------------------------------------------------- 46.6 régression auxiliaire et grille d'ajustements
87+VARS = ["mois", "ln_sup_log", "age", "etat_1a5", "stationnement", "ln_loyer_marche", "loyer_relatif", "triplex", "q_Saint-Sauveur", "q_Saint-Roch", "q_Maizerets"]
88+
89+
90+def _matrice(c: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]:
91+ g = c.copy()
92+ g["ln_sup_log"] = np.log(g["superficie_habitable_m2"] / g["nb_logements"])
93+ g["ln_loyer_marche"] = np.log(g["loyer_mensuel_moyen_marche"])
94+ g["loyer_relatif"] = g["loyer_mensuel_moyen_contrat"] / g["loyer_mensuel_moyen_marche"]
95+ g["triplex"] = (g["nb_logements"] == 3).astype(float)
96+ for q in ("Saint-Sauveur", "Saint-Roch", "Maizerets"): g[f"q_{q}"] = (g["quartier"] == q).astype(float)
97+ X = np.column_stack([np.ones(len(g))] + [g[v].to_numpy(dtype=float) for v in VARS])
98+ y = np.log(g["prix_par_logement"].to_numpy(dtype=float))
99+ return X, y
100+
101+
102+def regression_auxiliaire(c: pd.DataFrame) -> dict:
103+ """OLS : ln(prix par logement) sur l'ancienneté de la vente (mois), ln(m² par logement), l'âge, l'état, le stationnement,
104+ ln(loyer de marché moyen), le loyer relatif, un indicateur triplex et les quartiers (référence Limoilou). Erreurs-types classiques."""
105+ X, y = _matrice(c); n, k = X.shape
106+ beta, *_ = np.linalg.lstsq(X, y, rcond=None); res = y - X @ beta
107+ s2 = float(res @ res) / (n - k); cov = s2 * np.linalg.inv(X.T @ X)
108+ se = np.sqrt(np.diag(cov)); r2 = 1 - float(res @ res) / float(((y - y.mean()) ** 2).sum())
109+ noms = ["const"] + VARS
110+ return {"beta": dict(zip(noms, beta)), "se": dict(zip(noms, se)), "t": dict(zip(noms, beta / se)), "r2": r2, "sigma": float(np.sqrt(s2)), "n": n, "k": k,
111+ "taux_annuel_marche": float(np.exp(-12 * beta[1]) - 1)} # croissance annuelle implicite des prix (mois = ancienneté de la vente)
112+
113+
114+def grille_ajustements(comp5: pd.DataFrame, sujet: dict, base: dict, reg: dict) -> dict:
115+ """Grille d'ajustements en $ par logement, ordre de l'ouvrage : conditions du marché (temps, %) puis attributs (additifs, $) :
116+ superficie par logement, âge, état, stationnement, niveau de loyer de marché, loyer relatif. Signe du point de vue du sujet."""
117+ b = reg["beta"]; n_s = sujet["nb_logements"]
118+ s = {"ln_sup_log": np.log(sujet["superficie_habitable_m2"] / n_s), "age": sujet["age"], "etat_1a5": sujet["etat_1a5"], "stationnement": sujet["stationnement"],
119+ "ln_loyer_marche": np.log(base["loyer_marche_moyen"]), "loyer_relatif": base["rho"]}
120+ g_annuel = reg["taux_annuel_marche"]
121+ comps, lignes = [], []
122+ for _, r in comp5.iterrows():
123+ p = float(r["prix_par_logement"])
124+ a_t = cmp.ajustement_temps_taux(p, g_annuel, float(r["mois"])) # conditions du marché
125+ p_t = p + a_t
126+ x = {"ln_sup_log": np.log(r["superficie_habitable_m2"] / r["nb_logements"]), "age": float(r["age"]), "etat_1a5": float(r["etat_1a5"]),
127+ "stationnement": float(r["stationnement"]), "ln_loyer_marche": np.log(r["loyer_mensuel_moyen_marche"]),
128+ "loyer_relatif": r["loyer_mensuel_moyen_contrat"] / r["loyer_mensuel_moyen_marche"]}
129+ aj = {"temps": a_t}
130+ for v, lib in (("ln_sup_log", "superficie"), ("age", "age"), ("etat_1a5", "etat"), ("stationnement", "stationnement"), ("ln_loyer_marche", "loyer_marche"), ("loyer_relatif", "loyer_relatif")):
131+ # seuls les coefficients significatifs (|t| >= T_MIN) sont appliqués ; le loyer relatif (non significatif) est traité par le taux et le multiplicateur
132+ aj[lib] = p_t * (np.exp(b[v] * (s[v] - x[v])) - 1.0) if abs(reg["t"][v]) >= T_MIN else 0.0
133+ ca = cmp.ComparableAjuste(r["id_vente"], p, aj); comps.append(ca)
134+ lignes.append({"id_vente": r["id_vente"], "date_vente": r["date_vente"].date().isoformat(), "mois": float(r["mois"]), "prix_vente": float(r["prix_vente"]),
135+ "prix_logement": p, **aj, "prix_ajuste": ca.prix_ajuste, "brut_pct": ca.brut_pct, "net_pct": ca.net_pct})
136+ poids = cmp.poids_inverse_brut(comps); rec = cmp.reconciliation(comps, poids)
137+ tab = pd.DataFrame(lignes); tab["poids"] = poids
138+ se_log = rec["ecart_type_pondere"] * float(np.sqrt(sum(w * w for w in poids))) # erreur-type de la moyenne pondérée des prix ajustés
139+ return {"comparables": comps, "tableau": tab, "reconciliation": rec, "poids": poids, "g_annuel": g_annuel,
140+ "valeur_par_logement": rec["valeur_ponderee"], "valeur": rec["valeur_ponderee"] * n_s,
141+ "moyenne_simple": rec["moyenne"] * n_s, "ecart_type_valeur": rec["ecart_type_pondere"] * n_s, "se_valeur": se_log * n_s}
142+
143+
144+# ----------------------------------------------------------------------------- 46.7 revenu
145+def indications_revenu(base: dict, ind: dict, comp5: pd.DataFrame, reg_taux: dict, valeur_comparaison: float, mrb_comp: float) -> dict:
146+ rne = base["rne"]; rbp = base["rbp_contrat"]
147+ taux_ajuste = reg_taux["a"] + reg_taux["b_rho"] * (base["rho"] - 1.0)
148+ taux_comp = float(comp5["taux_global"].median())
149+ out = {"taux_median_127": ind["taux_median"], "taux_q1": ind["taux_q1"], "taux_q3": ind["taux_q3"], "taux_ajuste": taux_ajuste, "taux_comp": taux_comp,
150+ "v_cap_median": fin.capitalisation_directe(rne, ind["taux_median"]), "v_cap_ajuste": fin.capitalisation_directe(rne, taux_ajuste),
151+ "v_cap_comp": fin.capitalisation_directe(rne, taux_comp), "v_cap_q1": fin.capitalisation_directe(rne, ind["taux_q1"]), "v_cap_q3": fin.capitalisation_directe(rne, ind["taux_q3"]),
152+ "v_cap_ajuste_rne_declare": fin.capitalisation_directe(base["rne_declare"], taux_ajuste),
153+ "mrb_comp": mrb_comp, "v_mrb_comp": mrb_comp * rbp, "mrb_127": ind["mrb_median_triplex"], "v_mrb_127": ind["mrb_median_triplex"] * rbp,
154+ "taux_implicite_comparaison": rne / valeur_comparaison, "mrb_implicite_comparaison": valeur_comparaison / rbp,
155+ "taux_implicite_marche": base["rne_marche"] / valeur_comparaison}
156+ # sensibilité : multiplicateur des comparables ramené au loyer relatif du sujet par la pente de la régression ln MRB (ch. 32)
157+ rho_j = comp5["loyer_mensuel_moyen_contrat"] / comp5["loyer_mensuel_moyen_marche"]
158+ mrb_aj = comp5["mrb"] * np.exp(reg_taux["mrb_b_rho"] * (base["rho"] - rho_j))
159+ out["mrb_comp_ajuste_rho"] = float(mrb_aj.median()); out["v_mrb_comp_ajuste_rho"] = out["mrb_comp_ajuste_rho"] * rbp
160+ # lecture « taux implicite » : écart entre le taux ajusté et le taux implicite du prix de comparaison, en points de base
161+ out["ecart_pb"] = (out["taux_ajuste"] - out["taux_implicite_comparaison"]) * 1e4
162+ # ratio de dépenses du sujet vs médiane des ventes : part de l'écart capitalisation/comparaison expliquée par la structure de dépenses
163+ return out
164+
165+
166+# ----------------------------------------------------------------------------- 46.8 coût (contrôle)
167+def indication_cout(sujet: dict, terrain_par_m2: float = TERRAIN_PAR_M2, vie: float = VIE_ECONOMIQUE, phi: float = PHI_ETAT3) -> dict:
168+ cu_taille = cout.cout_par_superficie_ajuste(COUT_UNITAIRE_CAS1, SUPERFICIE_CAS1, sujet["superficie_habitable_m2"], ELASTICITE)
169+ cu = cout.indexer_cout(cu_taille, IPCB_MAISON, IPCB_APPART)
170+ cn = cu * sujet["superficie_habitable_m2"]
171+ a_eff = 0.5 * sujet["age"] + 0.5 * phi * vie # convention ch. 24 (eq. âge effectif) avec la note d'état 3
172+ a_eff = min(a_eff, vie)
173+ dep = cout.depreciation_age_vie(cn, a_eff, vie)
174+ terrain = terrain_par_m2 * sujet["superficie_terrain_m2"]
175+ v = cout.valeur_par_cout(terrain, cn, dep)
176+ return {"cu_taille": cu_taille, "cu": cu, "cout_neuf": cn, "age_effectif": a_eff, "vie": vie, "taux_depreciation": dep / cn, "depreciation": dep,
177+ "terrain": terrain, "contribution_batiment": v["contribution_batiment"], "valeur": v["valeur"]}
178+
179+
180+def sensibilite_cout(sujet: dict) -> pd.DataFrame:
181+ rows = []
182+ for t in (700.0, 900.0, 1100.0):
183+ for vie, phi in ((80.0, 0.5), (90.0, 0.5), (100.0, 0.5), (90.0, 0.35)):
184+ r = indication_cout(sujet, t, vie, phi); rows.append({"terrain_m2": t, "vie": vie, "phi": phi, "age_effectif": r["age_effectif"], "taux_dep": r["taux_depreciation"], "valeur": r["valeur"]})
185+ return pd.DataFrame(rows)
186+
187+
188+def depreciation_extraite(prix_marche: float, sujet: dict) -> dict:
189+ """Lecture inverse : dépréciation que le marché implique si l'on retient le coût neuf et le terrain du dossier."""
190+ r = indication_cout(sujet)
191+ return cout.extraction_depreciation_marche(prix_marche, r["terrain"], r["cout_neuf"], sujet["age"])
192+
193+
194+# ----------------------------------------------------------------------------- 46.9 financement de l'acheteur-occupant
195+def droits_mutation_quebec(base_imposition: float) -> dict:
196+ d, bas, detail = 0.0, 0.0, []
197+ for haut, taux in TRANCHES_MUTATION_QUEBEC:
198+ tranche = max(min(base_imposition, haut) - bas, 0.0); detail.append({"de": bas, "a": haut, "taux": taux, "montant": tranche * taux}); d += tranche * taux
199+ bas = haut
200+ if base_imposition <= haut: break
201+ return {"base": base_imposition, "droits": d, "detail": detail}
202+
203+
204+def financement(prix: float, base: dict, loyers: pd.DataFrame, sujet: dict) -> dict:
205+ mf = C.mise_de_fonds_minimale_assuree(prix)
206+ fi = C.financement_occupant(prix, mf, C.TAUX_ASSURE)
207+ occ = C.cout_occupation(fi, base, loyers, "RC")
208+ h = fin.Hypotheque(fi["pret"], C.TAUX_ASSURE, C.AMORT_MOIS)
209+ base_mut = max(prix, sujet["valeur_role_2025"] * FACTEUR_COMPARATIF_2026)
210+ mut = droits_mutation_quebec(base_mut)
211+ autres = loyers[loyers["logement"] != "RC"]; loyers_bruts = float(autres["loyer_mensuel"].sum())
212+ variantes = []
213+ for lib, mfv, taux, amort in (("assuré, mise minimale, 25 ans", mf, C.TAUX_ASSURE, 300), ("assuré, mise minimale, 30 ans (premier acheteur)", mf, C.TAUX_ASSURE, 360),
214+ ("assuré, mise de 10 %", 0.10 * prix, C.TAUX_ASSURE, 300), ("non assuré, mise de 20 %", 0.20 * prix, C.TAUX_NON_ASSURE, 300),
215+ ("assuré, mise minimale, taux +100 pb", mf, C.TAUX_ASSURE + 0.01, 300), ("assuré, mise minimale, taux +200 pb", mf, C.TAUX_ASSURE + 0.02, 300)):
216+ f = C.financement_occupant(prix, mfv, taux, amort); o = C.cout_occupation(f, base, loyers, "RC")
217+ variantes.append({"scenario": lib, "mise_de_fonds": f["mise_de_fonds"], "rpv": f["rpv"], "pret": f["pret"], "taux": taux, "amort_mois": amort, "paiement_mensuel": f["paiement_mensuel"],
218+ "paiement_mensuel_tam": f["paiement_mensuel_tam"], "cout_net_mensuel": o["cout_net_mensuel"], "cout_net_mensuel_hors_capital": o["cout_net_mensuel_hors_capital"]})
219+ return {**fi, "interets_an1": h.interets_annee(1), "capital_an1": h.capital_annee(1), "occupation": occ, "mutation": mut, "liquidites": mf + mut["droits"],
220+ "loyers_bruts_autres": loyers_bruts, "part_paiement_couverte_brute": loyers_bruts / fi["paiement_mensuel"],
221+ "part_paiement_couverte_nette": occ["loyers_percus_nets"] / 12 / fi["paiement_mensuel"], "variantes": pd.DataFrame(variantes),
222+ "ratio_couverture_investisseur": base["rne"] / fi["service_dette"]}
223+
224+
225+def grille_cout_occupation(prix: float, base: dict, loyers: pd.DataFrame) -> pd.DataFrame:
226+ """Coût économique mensuel d'occupation (hors capital) selon la mise de fonds (part du prix) et le taux hypothécaire."""
227+ rows = []
228+ for part in (None, 0.10, 0.15, 0.20):
229+ for taux in np.arange(0.0312, 0.0713, 0.0025):
230+ mf = C.mise_de_fonds_minimale_assuree(prix) if part is None else part * prix
231+ t = taux if (part is None or part < 0.20) else taux + (C.TAUX_NON_ASSURE - C.TAUX_ASSURE)
232+ f = C.financement_occupant(prix, mf, t, 300); o = C.cout_occupation(f, base, loyers, "RC")
233+ rows.append({"mise": "minimale" if part is None else f"{int(part*100)} %", "taux": taux, "taux_effectif": t, "cout_net_mensuel": o["cout_net_mensuel"], "hors_capital": o["cout_net_mensuel_hors_capital"]})
234+ return pd.DataFrame(rows)
235+
236+
237+# ----------------------------------------------------------------------------- 46.3 valeur de la faculté de reprise (rez-de-chaussée)
238+def va_ecart_rc(loyers: pd.DataFrame, rotation: float = ROTATION_QUEBEC, g: float = G_MARCHE, g_tal: float = G_TAL, r: float = R_ACT, v: float = cas2.TAUX_VACANCE_PERTES) -> dict:
239+ rc = loyers[loyers["logement"] == "RC"].iloc[0]; lc, lm = float(rc["loyer_mensuel"]), float(rc["loyer_marche_estime"])
240+ delta1 = (lm - lc) * 12 * (1 - v)
241+ # écart croissant : marché à g, contrat à g_tal ; valeur actuelle sur 10 ans sans rotation (reprise impossible, locataire reste)
242+ va_10 = sum(((lm * (1 + g) ** (t - 1) - lc * (1 + g_tal) ** (t - 1)) * 12 * (1 - v)) / (1 + r) ** t for t in range(1, 11))
243+ # espérance sous rotation géométrique (ch. 26, éq. va-ecart) avec g = croissance de l'écart ≈ croissance moyenne pondérée
244+ g_ecart = (lm * (1 + g) - lc * (1 + g_tal)) / (lm - lc) - 1
245+ va_rot = delta1 / (r - g_ecart + rotation * (1 + g_ecart))
246+ return {"loyer_contrat": lc, "loyer_marche": lm, "ecart_mensuel": lm - lc, "delta1": delta1, "g_ecart": g_ecart, "va_10ans_sans_rotation": va_10, "va_rotation": va_rot,
247+ "valeur_faculte_reprise": va_10 - va_rot, "date_limite_avis": "2026-12-31", "echeance_bail": "2027-06-30"}
248+
249+
250+# ----------------------------------------------------------------------------- 46.10 réconciliation
251+def reconciliation(v_log: float, v_mrb: float, v_cap: float, base: dict, sujet: dict, sigma_log: float, poids: dict = POIDS_46) -> dict:
252+ ret = {"par_logement_ajuste": v_log, "mrb": v_mrb, "capitalisation": v_cap}
253+ vp = sum(poids[k] * ret[k] for k in ret)
254+ arr = round(vp / PAS_ARRONDI) * PAS_ARRONDI
255+ alt = {"retenue 40/35/25": vp, "45/30/25": 0.45 * v_log + 0.30 * v_mrb + 0.25 * v_cap, "égale": sum(ret.values()) / 3,
256+ "comparaison seule 60/40/0": 0.60 * v_log + 0.40 * v_mrb, "revenu dominant 20/20/60": 0.2 * v_log + 0.2 * v_mrb + 0.6 * v_cap}
257+ fourchette = (min(ret.values()), max(ret.values()))
258+ # fourchette conclue : ± une erreur-type de la moyenne pondérée des prix ajustés (comparaison) autour de la valeur pondérée, arrondie vers l'extérieur
259+ bas = np.floor((vp - sigma_log) / PAS_ARRONDI) * PAS_ARRONDI; haut = np.ceil((vp + sigma_log) / PAS_ARRONDI) * PAS_ARRONDI
260+ contributions = {k: poids[k] * ret[k] for k in ret}
261+ return {"retenues": ret, "poids": poids, "contributions": contributions, "valeur_ponderee": vp, "valeur_finale": arr, "alternatives": alt, "fourchette_indications": fourchette, "se_comparaison": sigma_log,
262+ "fourchette_conclue": (bas, haut), "prix_par_logement": arr / sujet["nb_logements"], "prix_m2": arr / sujet["superficie_habitable_m2"],
263+ "taux_implicite": base["rne"] / arr, "taux_implicite_marche": base["rne_marche"] / arr, "mrb_implicite": arr / base["rbp_contrat"],
264+ "ratio_role": arr / sujet["valeur_role_2025"], "ratio_role_indexe": arr / (sujet["valeur_role_2025"] * FACTEUR_COMPARATIF_2026),
265+ "ecart_ch32": arr - 870_000.0, "ecart_ch32_pct": arr / 870_000.0 - 1}
266+
267+
268+# ----------------------------------------------------------------------------- tout
269+def dossier() -> dict:
270+ r = cas2.tout(); d = {k: r[k] for k in ("ventes", "sujet", "loyers", "depenses")}
271+ base = base_normalisee(d); c = r["comparables"]; ind = r["indicateurs"]
272+ cand = C.selection_comparables(c, d["sujet"], base); comp5 = cand[cand["retenu"]]
273+ reg = regression_auxiliaire(c); gr = grille_ajustements(comp5, d["sujet"], base, reg)
274+ reg_taux = C.regression_taux_loyer_relatif(C.decomposition_taux(c))
275+ rev = indications_revenu(base, ind, comp5, reg_taux, gr["valeur"], float(comp5["mrb"].median()))
276+ rec = reconciliation(gr["valeur"], rev["v_mrb_comp"], rev["v_cap_ajuste"], base, d["sujet"], gr["se_valeur"])
277+ co = indication_cout(d["sujet"]); co_sens = sensibilite_cout(d["sujet"]); dep_ext = depreciation_extraite(rec["valeur_finale"], d["sujet"])
278+ fi = financement(rec["valeur_finale"], base, d["loyers"], d["sujet"]); grille_occ = grille_cout_occupation(rec["valeur_finale"], base, d["loyers"])
279+ rep = va_ecart_rc(d["loyers"])
280+ ch32 = C.indications(C.cas2.rne(d["loyers"], d["depenses"]), d["sujet"], ind, comp5, reg_taux, c)
281+ return {**d, "base": base, "comparables127": c, "indicateurs": ind, "candidats": cand, "comparables5": comp5, "regression": reg, "grille": gr,
282+ "regression_taux": reg_taux, "revenu": rev, "cout": co, "cout_sensibilite": co_sens, "depreciation_extraite": dep_ext, "financement": fi,
283+ "grille_occupation": grille_occ, "reprise": rep, "reconciliation": rec, "ch32": ch32}
284+
285+
286+# ----------------------------------------------------------------------------- exercices
287+def exercices(D: dict | None = None) -> dict:
288+ D = D or dossier(); b = D["base"]; s = D["sujet"]; rec = D["reconciliation"]; ex = {}
289+ # 1. taxes : compte déclaré vs recalcul 2026, effet sur le RNE et sur la capitalisation au taux ajusté
290+ t = b["taxes"]; ex["1"] = {"municipale_2026": t["municipale"], "scolaire_2026": t["scolaire"], "declare": 5900 + 470, "ecart": b["ecart_taxes"], "rne_declare": b["rne_declare"], "rne_2026": b["rne"],
291+ "v_cap_declare": fin.capitalisation_directe(b["rne_declare"], D["revenu"]["taux_ajuste"]), "v_cap_2026": D["revenu"]["v_cap_ajuste"]}
292+ # 2. avis de reprise : calendrier et VA de l'écart
293+ ex["2"] = D["reprise"]
294+ # 3. grille : refaire la cascade de Q0243 et vérifier brut/net
295+ tab = D["grille"]["tableau"]; q = tab[tab["id_vente"] == "Q0243"].iloc[0].to_dict(); ex["3"] = q
296+ # 4. capitalisation : taux médian, Q1, Q3, ajusté, comparables
297+ ex["4"] = {k: D["revenu"][k] for k in ("taux_median_127", "taux_q1", "taux_q3", "taux_ajuste", "taux_comp", "v_cap_median", "v_cap_q1", "v_cap_q3", "v_cap_ajuste", "v_cap_comp")}
298+ # 5. financement : prix final, mise de fonds, paiement, TAM, coût net ; variante 2e étage
299+ f = D["financement"]; occ2 = C.cout_occupation({k: f[k] for k in ("mise_de_fonds", "service_dette", "capital_rembourse_5ans")}, b, D["loyers"], "2e")
300+ ex["5"] = {"prix": f["prix"], "mise": f["mise_de_fonds"], "pret": f["pret"], "paiement": f["paiement_mensuel"], "tam": f["taux_admissible"], "paiement_tam": f["paiement_mensuel_tam"],
301+ "cout_net_rc": f["occupation"]["cout_net_mensuel"], "hors_capital_rc": f["occupation"]["cout_net_mensuel_hors_capital"], "cout_net_2e": occ2["cout_net_mensuel"], "hors_capital_2e": occ2["cout_net_mensuel_hors_capital"],
302+ "droits_mutation": f["mutation"]["droits"], "liquidites": f["liquidites"]}
303+ # 6. pondérations alternatives
304+ ex["6"] = rec["alternatives"]
305+ # 7. coût : sensibilité et dépréciation extraite
306+ ex["7"] = {"cout": D["cout"], "extraite": D["depreciation_extraite"]}
307+ return ex
308+
309+
310+def _d(x): return f"{x:,.2f} $".replace(",", " ")
311+def _p(x, k=2): return f"{100 * x:.{k}f} %"
312+
313+
314+if __name__ == "__main__":
315+ D = dossier(); b = D["base"]; s = D["sujet"]
316+ print("== 46.2/46.5 Sujet, taxes 2026, RNE normalisé (traite.cas2.rne sur dépenses normalisées)")
317+ print(b["taxes"]); print(f"écart taxes déclarées − 2026 : {_d(b['ecart_taxes'])}")
318+ print(b["detail"][["poste", "declare", "normalise", "type"]].to_string(index=False))
319+ print(f"RBP {_d(b['rbp_contrat'])} ; marché {_d(b['rbp_marche'])} ; ρ {b['rho']:.4f} ; RBE {_d(b['rbe'])} ; dépenses {_d(b['depenses_totales'])} (ratio {_p(b['ratio_depenses'],1)}) ; "
320+ f"RNE 2026 {_d(b['rne'])} ; RNE déclaré (ch. 32) {_d(b['rne_declare'])} ; RNE marché {_d(b['rne_marche'])}")
321+ print("\n== 46.6 Régression auxiliaire (127 ventes, ln prix/logement)")
322+ reg = D["regression"]
323+ for k in reg["beta"]: print(f" {k:18s} β = {reg['beta'][k]:+.5f} (e.-t. {reg['se'][k]:.5f}, t = {reg['t'][k]:+.2f})")
324+ print(f" R² {reg['r2']:.3f} ; σ {reg['sigma']:.4f} ; croissance annuelle implicite des prix {_p(reg['taux_annuel_marche'])}")
325+ print("\n== Comparables (ch. 32) et grille d'ajustements ($/logement)")
326+ cols = ["id_vente", "date_vente", "superficie_habitable_m2", "annee_construction", "etat_1a5", "stationnement", "loyer_mensuel_moyen_contrat", "loyer_mensuel_moyen_marche", "loyer_relatif", "prix_vente", "mrb", "taux_global", "prix_par_logement", "mois"]
327+ print(D["comparables5"][cols].assign(date_vente=lambda x: x["date_vente"].dt.date).round(3).to_string(index=False))
328+ print(D["grille"]["tableau"].round(2).to_string(index=False))
329+ g = D["grille"]; print(f"prix/logement pondéré {_d(g['valeur_par_logement'])} → valeur {_d(g['valeur'])} ; moyenne simple {_d(g['moyenne_simple'])} ; é.-t. {_d(g['ecart_type_valeur'])} ; "
330+ f"médiane {g['reconciliation']['mediane']:.0f} ; min {g['reconciliation']['min']:.0f} ; max {g['reconciliation']['max']:.0f} ; étendue {_p(g['reconciliation']['etendue_pct'],1)}")
331+ print("\n== 46.7 Revenu"); print({k: (round(v, 6) if isinstance(v, float) else v) for k, v in D["revenu"].items()})
332+ print("\n== 46.8 Coût (contrôle)"); print({k: round(v, 2) for k, v in D["cout"].items()}); print(D["cout_sensibilite"].round(3).to_string(index=False)); print(D["depreciation_extraite"])
333+ print("\n== 46.10 Réconciliation"); rec = D["reconciliation"]
334+ for k, v in rec.items(): print(f" {k}: {v}")
335+ print("\n== 46.9 Financement au prix final"); f = D["financement"]
336+ print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in f.items() if k not in ("occupation", "mutation", "variantes")})
337+ print({k: round(v, 2) for k, v in f["occupation"].items() if isinstance(v, float)}); print(f["mutation"]); print(f["variantes"].round(2).to_string(index=False))
338+ print("\n== 46.3 Reprise du rez-de-chaussée"); print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in D["reprise"].items()})
339+ print("\n== Exercices"); E = exercices(D)
340+ for k, v in E.items(): print(k, v)
added analysis/python/cases/ch48_portefeuille.py +457 −0
@@ -0,0 +1,457 @@
1+#!/usr/bin/env python3
2+"""Chapitre 48 — Cas 4 : portefeuille multirésidentiel interrégional (DONNÉES SYNTHÉTIQUES, 48 immeubles fictifs, 6 RMR).
3+
4+Dossier complet d'évaluation de portefeuille : sommaire par RMR, distribution des tailles/âges/états, modèle d'évaluation de
5+masse (régression semi-logarithmique du taux global : traite.cas4.effets_regionaux, facteur de Duan, valeur prédite par immeuble,
6+statistiques de ratio IAAO), dynamique des RNE 2016-2026 (croissance, volatilité, choc pandémique, corrélations intra- et
7+inter-régionales), risques (inondation, sol, concentration, dette : échéancier, RCD, renouvellement à +100/+200 pb, capacité de
8+refinancement), agrégation de l'incertitude par Monte-Carlo avec corrélation régionale (copule gaussienne, décomposition de Cholesky,
9+10 000 tirages, graine 20261031 ; démonstration de la diversification : VaR du portefeuille vs somme des VaR), allocation
10+(score rendement/croissance/risque, frontière rendement-risque par RMR), réconciliation et exercices.
11+
12+Les calculs partagés viennent de `traite.cas4` (sommaire, croissance_rne, effets_regionaux), `traite.finance` (hypothèques,
13+RCD, capitalisation) et `traite.hedonique` (MCO HC1, statistiques de ratio) ; ce script n'y ajoute que les calculs propres au
14+dossier et IMPRIME tous les chiffres cités dans manuscript/chapters/ch48.tex.
15+
16+Hypothè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és
19+ 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 + Δ.
21+
22+Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch48_portefeuille.py [--n 10000]
23+"""
24+from __future__ import annotations
25+import sys, pathlib, argparse, math
26+import numpy as np, pandas as pd
27+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
28+from traite import cas4, finance as fin, hedonique as h
29+
30+ROOT = pathlib.Path(__file__).resolve().parents[3]
31+GRAINE = 20261031
32+DATE_EVAL = "2026-06-30"
33+RMR = ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]
34+RMR_REG = ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"] # indicatrices (référence Montréal)
35+
36+# ----------------------------------------------------------------------------- hypothèses propres au chapitre 48
37+AMORT_BASE = 480 # mois (prêts assurés multilogement ; cohérent avec le ch. 33, P45)
38+AMORT_ALT = 300 # sensibilité
39+RCD_MIN = 1.20 # covenant conventionnel (ch. 31, 47)
40+RPV_MAX = 0.75
41+CHOCS_PB = (0, 100, 200)
42+N_MC = 10_000
43+# 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
44+RHO_TAUX = {"global": 0.50, "regional": 0.70}
45+OBLIG_10_ANS = 0.0384 # Banque du Canada, 2026-10-29 (notes-C §4.1) — repère pour la frontière
46+ARRONDI_PORTEFEUILLE = 100_000
47+
48+# ----------------------------------------------------------------------------- utilitaires
49+
50+def _d(x): return f"{x:,.0f}".replace(",", " ") + " $"
51+def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %"
52+
53+def arrondir(x: float, pas: int = ARRONDI_PORTEFEUILLE) -> float:
54+ return round(x / pas) * pas
55+
56+# ----------------------------------------------------------------------------- 48.2 portefeuille
57+
58+def 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+ # distributions
72+ 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 valeur
77+ w_rmr = g["part_valeur"].values; w_imm = (pf["valeur_2026"] / tot["valeur"]).values
78+ 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}
81+
82+# ----------------------------------------------------------------------------- 48.4 modèle d'évaluation de masse
83+
84+def 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 portefeuille
95+ 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
96+ 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}
98+
99+def 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)
102+
103+def 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))
105+
106+def 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"]) * 1e4
113+ t["rpv"] = t["dette"] / t["valeur_2026"]; t["valeur_par_logement"] = t["valeur_2026"] / t["nb_logements"]
114+ return t
115+
116+def 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"] - 1
124+ 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 out
126+
127+# ----------------------------------------------------------------------------- 48.5 dynamique des RNE
128+
129+def 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.0
135+ 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 total
146+ 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) - 1
153+ # corrélations des variations trimestrielles (log) entre immeubles
154+ dl = np.log(piv).diff().dropna()
155+ C = dl.corr(); ids = list(piv.columns); r_of = rmr_de.loc[ids].values
156+ 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}
167+
168+# ----------------------------------------------------------------------------- 48.6 risques et dette
169+
170+def 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"]) * 1e4
177+ 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"]) * 1e4
179+ 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())}
184+
185+def 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 * 12
192+ solde = hyp.solde(mois); amort_res = amort - mois
193+ g = float(croissance.loc[r["id_immeuble"]]) if croissance is not None else 0.0
194+ rne_ech = rne * (1 + g) ** annees
195+ 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_annuel
201+ 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}
227+
228+# ----------------------------------------------------------------------------- 48.7 Monte-Carlo avec corrélation régionale
229+
230+def 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 S
235+
236+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,
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 une
239+ structure à deux facteurs (copule gaussienne, échantillonnée par décomposition de Cholesky). σ_ε,i = volatilité annualisée estimée du RNE de
240+ 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"].values
243+ sig_eta = reg["sigma"] if sigma_taux is None else sigma_taux
244+ corr = dyn["correlations"]
245+ # estimation sur les séries : corrélation inter-RMR moyenne des variations trimestrielles ; la composante régionale additionnelle est
246+ # 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.T
255+ eps = Z_e * vol[None, :]; eta = Z_n * sig_eta
256+ 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 individuelles
263+ 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 RMR
269+ 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).T
275+ 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)))}
280+
281+def 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) * S
284+ 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()))}
285+
286+# ----------------------------------------------------------------------------- 48.8 allocation et arbitrage
287+
288+def 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"].values
292+ 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 risque
295+ 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égionaux
308+ g = cas4.sommaire(pf)["par_rmr"].loc[RMR]
309+ mu = (g["taux_pondere"] + dyn["par_rmr"]["croissance_agregee"]).values
310+ 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()).values
314+ from scipy.optimize import minimize
315+ 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 pts
322+ 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.x
326+ # portefeuille de même rendement attendu que l'actuel, à risque minimal
327+ 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 maximal
330+ 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}
335+
336+# ----------------------------------------------------------------------------- 48.9 réconciliation
337+
338+def 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"]}
346+
347+# ----------------------------------------------------------------------------- exercices
348+
349+def 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, RPV
353+ 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 000
359+ rne = 150_000.0
360+ 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 refinancement
364+ 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.02
367+ 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éalais
373+ 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+ # ex5 : choc de taux généralisé — ρ_taux global 0,90 / régional 0,95
376+ mc5 = simuler_portefeuille(pf, dyn, reg, n=mc["n"], graine=GRAINE, rho_taux={"global": 0.90, "regional": 0.95})
377+ out["ex5"] = {"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+ # ex6 : cession des 3 immeubles en zone inondable
380+ 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["ex6"] = {"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+ # ex7 : croissance de P36 (72 log., Québec, inondable) vs agrégat de Québec ; choc pandémique
389+ cr = dyn["par_immeuble"].set_index("id_immeuble").loc["P36"]
390+ out["ex7"] = {"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 out
393+
394+# ----------------------------------------------------------------------------- dossier complet
395+
396+def 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 D
417+
418+def 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()})
454+
455+if __name__ == "__main__":
456+ ap = argparse.ArgumentParser(); ap.add_argument("--n", type=int, default=N_MC); a = ap.parse_args()
457+ imprimer(dossier(a.n))
added analysis/python/figures/ch37_figures.py +160 −0
Large diff (160 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch41_figures.py +86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
modified analysis/python/figures/ch42_grille.py +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified analysis/python/figures/ch43_carte_riverain.py +2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified analysis/python/figures/ch43_effets_methodes.py +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch44_capitalisation.py +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch44_flux.py +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch44_heatmap_van.py +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch44_prime_verte.py +59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch46_figures.py +125 −0
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch48_portefeuille_figures.py +151 −0
Large diff (151 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch37.py +169 −0
Large diff (169 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch39.py +173 −0
Large diff (173 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch41.py +229 −0
Large diff (229 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch43.py +241 −0
Large diff (241 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch44.py +184 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch46.py +127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch48.py +96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added assets/figures/ch41/ch41-fig-frontiere-option.pdf +0 −0

Binary file not shown.

added assets/figures/ch41/ch41-fig-residuel-densite.pdf +0 −0

Binary file not shown.

added assets/figures/ch41/ch41-fig-surface-prix-cout.pdf +0 −0

Binary file not shown.

modified assets/figures/ch42/ch42-fig-grille-fraction.pdf +0 −0

Binary file not shown.

modified assets/figures/ch43/ch43-fig-carte-riverain.pdf +0 −0

Binary file not shown.

modified assets/figures/ch43/ch43-fig-effets-methodes.pdf +0 −0

Binary file not shown.

added assets/figures/ch44/ch44-fig-capitalisation.pdf +0 −0

Binary file not shown.

added assets/figures/ch44/ch44-fig-flux.pdf +0 −0

Binary file not shown.

added assets/figures/ch44/ch44-fig-heatmap-van.pdf +0 −0

Binary file not shown.

added assets/figures/ch44/ch44-fig-prime-verte.pdf +0 −0

Binary file not shown.

added assets/figures/ch46/ch46-fig-cascade.pdf +0 −0

Binary file not shown.

added assets/figures/ch46/ch46-fig-fourchettes.pdf +0 −0

Binary file not shown.

added assets/figures/ch46/ch46-fig-loyers.pdf +0 −0

Binary file not shown.

added assets/figures/ch46/ch46-fig-mrb.pdf +0 −0

Binary file not shown.

added assets/figures/ch46/ch46-fig-occupation.pdf +0 −0

Binary file not shown.

added assets/figures/ch48/ch48-fig-carte.pdf +0 −0

Binary file not shown.

added assets/figures/ch48/ch48-fig-dette.pdf +0 −0

Binary file not shown.

added assets/figures/ch48/ch48-fig-frontiere.pdf +0 −0

Binary file not shown.

added assets/figures/ch48/ch48-fig-montecarlo.pdf +0 −0

Binary file not shown.

added assets/figures/ch48/ch48-fig-rmr.pdf +0 −0

Binary file not shown.

added assets/figures/ch48/ch48-fig-rne.pdf +0 −0

Binary file not shown.

added assets/figures/ch48/ch48-fig-taux.pdf +0 −0

Binary file not shown.

deleted manuscript/_wrap_ch20.tex +0 −12
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch20.tex +16 −16
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch36.tex +350 −0
Large diff (353 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch38.tex +544 −1
Large diff (546 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch42.tex +297 −1
Large diff (299 lines) is not loaded by default to keep the page fast.Load diff
added references/lib-ch20.bib +53 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
modified references/library.bib +51 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
modified solutions/ch20.tex +29 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
modified solutions/ch36.tex +29 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
modified sources/registry.csv +5 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified verification/calculations.csv +13 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch20-calculations.csv +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch20-claims.csv +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch20-figures.csv +5 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch20-registry.csv +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch20.md +63 −0
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
modified verification/claims.csv +16 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
modified verification/figures.csv +4 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified verification/review_log.md +1 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme