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 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 93 """Trois rééchantillonnages : (A) les 16 candidats avec remise puis sélection des 5 plus proches (modèle fixe) ;
94 94 (B) les 600 ventes avec remise, réestimation du modèle, grille des 5 comparables fixes et prédiction du sujet ;
95 95 (C) les deux à la fois (même réplication b pour la corrélation) ; variante paramétrique β* ~ N(β̂, V_HC1)."""
96 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 98 g0 = grille_rapide(b["beta"], b["Xs"], b["Xc16"], b["P16"], b["G"])
99 99 P_aj16, brut16 = g0["P_aj"], g0["brut"]
100 100 # (A) candidats
@@ -335,7 +335,9 @@ def exercices(b: dict, bs: dict, co: dict, cb: dict) -> dict:
335 335 w2 = np.array([0.30, 0.55, 0.15]); w1 = np.array([POIDS_RAISONNES["comparaison"], POIDS_RAISONNES["hedonique"], POIDS_RAISONNES["cout"]])
336 336 V = np.array([b["V_comp"], b["V_hed"], co["valeur"]]); V16v = np.array([V16, b["V_hed"], co["valeur"]])
337 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 341 "V_k16_raisonnee": float(w1 @ V16v), "arrondi_k16": round(float(w1 @ V16v) / a) * a,
340 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 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 236 out["ex6"] = {"att": att, "moyenne_post": float(np.mean([att[2], att[3]]))}
237 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 257 # ----------------------------------------------------------------------------- orchestration
240 258
241 259 def main(verbose: bool = True) -> dict:
@@ -252,7 +270,7 @@ def main(verbose: bool = True) -> dict:
252 270 res["rdd"] = discontinuite()
253 271 res["sens"] = sensibilite(se); res["temoins"] = groupes_temoins(se)
254 272 res["labo"] = laboratoire()
255 − res["ex"] = exercices()
273 + res["ex"] = exercices(); res["ex7"] = exercice_7(se)
256 274 _CACHE["res"] = res
257 275 if verbose: imprimer(res)
258 276 return res
@@ -279,6 +297,7 @@ def imprimer(res: dict) -> None:
279 297 print(sv["event_study"][["coef", "se_grappes"]].round(4).T)
280 298 print("Témoins :\n", res["temoins"].round(4))
281 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 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 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 17 from traite import cas1
18 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 21 ROOT = pathlib.Path(__file__).resolve().parents[3]
22 22 GRAINE = 20261031
23 23 N_ARBRES_FORET = 500
@@ -219,7 +219,7 @@ def importance_permutation(Xh: pd.DataFrame, Xm: pd.DataFrame, y: np.ndarray, pl
219 219 for nom, ctor in (("Gradient boosting", _boosting), ("Forêt aléatoire", _foret)):
220 220 gain = np.zeros(Xm.shape[1]); base_err = 0.0
221 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 223 for j in range(Xm.shape[1]):
224 224 acc = 0.0
225 225 for _ in range(repetitions):
@@ -309,16 +309,15 @@ def exercices(v: pd.DataFrame, comp: dict, secteurs: dict, scen: dict, sh: dict)
309 309 for _ in range(3): F = F + nu * (yv - F); traj.append(F)
310 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 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 313 import itertools
314 314 joueurs = ["S", "Q", "L"]; phi = {}
315 315 for j in joueurs:
316 316 tot = 0.0
317 317 for perm in itertools.permutations(joueurs):
318 − avant = tuple(sorted(perm[:perm.index(j)])); apres = tuple(sorted(avant + (j,)))
319 − tot += vS[apres] - vS[avant]
318 + avant = frozenset(perm[:perm.index(j)]); tot += vS[avant | {j}] - vS[avant]
320 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 321 # 37.5 : dix prédictions d'un AVM fictif
323 322 prix = np.array([312000, 358500, 401000, 445000, 489500, 532000, 598000, 655000, 742000, 895000], float)
324 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 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 396 if __name__ == "__main__":
352 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 356 return {"valeurs": V, "valeur_portefeuille": VP, "rne": rne, "rcd": rcd, "service_dette": sd, "stats": stats_,
357 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:
360 − """Trois structures : indépendance (0, 0), corrélation régionale (0,30 ; 0,60), corrélation parfaite (1, 1)."""
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). kw → simuler_portefeuille
361 + (p. ex. croissance=None, sigma_g_defaut=0.10 pour un choc de revenu de type récession)."""
361 362 rows = []
362 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 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 366 return pd.DataFrame(rows)
366 367
@@ -417,6 +418,7 @@ def tout(rapide: bool = False) -> dict:
417 418 D["lhs"] = comparer_lhs(r, n=1000, k=k_rep)
418 419 c4 = cas4.charger(); cr = cas4.croissance_rne(c4["series"])
419 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 422 D["cas4"] = c4; D["cas4_croissance"] = cr
421 423 D["exercices"] = exercices(r, mc)
422 424 return D
@@ -456,6 +458,7 @@ def imprimer(D: dict) -> None:
456 458 print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in D["portefeuille"]["stats"].items()})
457 459 print(D["portefeuille"]["par_rmr"].to_string(index=False))
458 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 462 print("\n=== Exercices")
460 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 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 177 # ----------------------------------------------------------------------------- D. conversion locatif → copropriété divise
156 178 # L'immeuble fictif de huit fractions, vu AVANT conversion comme un immeuble locatif de 8 logements (hypothèses déclarées).
157 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 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 322 print(f" RNE hors cotisation au fonds ({fmt(cl['fonds'])} $) = {fmt(cl['noi_sans_reserve'])} $ → taux {cl['taux_implicite_sans_reserve']:.2%}")
301 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 328 fb = faisabilite(); out["faisabilite"] = fb
303 329 b = fb["bloc"]
304 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