Ch. 20 livré et validé (compilation isolée, tests, fusion des registres)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
69 changed files +5,128 −47
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