#!/usr/bin/env python3 """Chapitre 29 — Capitalisation directe : calculs du cas 3 (12 logements, Rosemont ; DONNÉES SYNTHÉTIQUES). Tous les chiffres cités dans manuscript/chapters/ch29.tex et solutions/ch29.tex sortent d'ici. Les calculs partagés (RNE normalisé, taux extraits, valeur par capitalisation) viennent de traite.cas3 afin de coïncider avec les chapitres 30 et 47 ; les formules financières viennent de traite.finance (aucune n'est réécrite ici). Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch29_capitalisation.py """ from __future__ import annotations import sys, pathlib import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import cas3, finance as fin ROOT = pathlib.Path(__file__).resolve().parents[3] # Barème 2026 des droits sur les mutations immobilières, Ville de Montréal (sources/notes-B.md §, clé mtlmutations2026) : # bornes supérieures des tranches ($) et taux marginaux. Facteur comparatif 2026 = 1,00 (LDMI art. 1.1 ; rôle 2026-2028). MUTATIONS_MTL_2026 = [(62_900, 0.005), (315_000, 0.01), (552_300, 0.015), (1_104_700, 0.02), (2_136_500, 0.025), (3_113_000, 0.035), (float("inf"), 0.04)] FACTEUR_COMPARATIF_MTL_2026 = 1.00 def droits_mutation(base: float, bareme=MUTATIONS_MTL_2026) -> float: """Droits progressifs par tranches (LDMI art. 2 ; barème municipal).""" total, bas = 0.0, 0.0 for haut, taux in bareme: if base <= bas: break total += (min(base, haut) - bas) * taux bas = haut return total def comparables_proches(c: pd.DataFrame, sujet: dict, k: int = 10, date_eval="2026-09-01") -> pd.DataFrame: """Les k ventes les plus proches du sujet : taille, état, âge, quartier, ancienneté de la vente (distance pédagogique).""" c = c.copy() mois = (pd.Timestamp(date_eval) - c["date_vente"]).dt.days / 30.4375 c["mois"] = mois c["distance"] = (abs(c["nb_logements"] - sujet["nb_logements"]) / 6 + abs(c["etat_1a5"] - sujet["etat_1a5"]) + abs(c["annee_construction"] - sujet["annee_construction"]) / 20 + 0.5 * (c["quartier"] != "Rosemont") + mois / 24) return c.sort_values("distance").head(k) def calculer() -> dict: d = cas3.charger() etat, dep, hyp, sujet, comps = d["etat"], d["dep"], d["hyp"], d["sujet"], d["comps"] base = cas3.rne(etat, dep, hyp) tx = cas3.taux_extraits(comps) c = tx["comparables"] R = tx["mediane"] V = fin.capitalisation_directe(base["rne"], R) proches = comparables_proches(c, sujet) # régression : résidus X = np.column_stack([np.ones(len(c)), c["etat_1a5"] - 3, c["nb_logements"] - 12]) beta = np.array([tx["regression"]["constante"], tx["regression"]["etat"], tx["regression"]["taille"]]) resid = c["taux_global"].values - X @ beta reg = {**tx["regression"], "sigma_resid": float(resid.std(ddof=3)), "sigma_brut": float(c["taux_global"].std(ddof=1)), "r2": float(1 - resid.var(ddof=0) / c["taux_global"].var(ddof=0)), "prevision_sujet": float(beta[0])} V_reg = fin.capitalisation_directe(base["rne"], reg["prevision_sujet"]) # --- construction du taux ------------------------------------------------------------------------------------- f_ = hyp["financement"] h1 = fin.Hypotheque(1.0, f_["taux"], f_["amortissement_mois"]) # prêt unitaire : constante hypothécaire f = h1.constante_hypothecaire M = f_["rpv"] re_implicite = (R - M * f) / (1 - M) # rendement courant des fonds propres impliqué par le marché grille_re = {re: fin.taux_bande_investissement(M, f, re) for re in (0.04, 0.05, 0.06, 0.07, 0.08)} R_rcd = fin.taux_par_rcd(f_["rcd_min"], M, f) V_rcd = fin.capitalisation_directe(base["rne"], R_rcd) r = hyp["taux_actualisation"]; Rt = hyp["taux_terminal"] g_terminal = r - Rt # croissance impliquée par le taux terminal du cas 3 gordon = {"r": r, "g_terminal": g_terminal, "R_g_terminal": fin.taux_gordon(r, g_terminal), "g_tal": hyp["croissance_loyers_contrat"], "R_g_tal": fin.taux_gordon(r, hyp["croissance_loyers_contrat"]), "g_implicite_marche": r - R} n = hyp["horizon_ans"] P = 1 - h1.solde(12 * n) # part du capital remboursée en n ans sff = fin.facteur_fonds_amortissement(r, n) delta = (1 + g_terminal) ** n - 1 # appréciation cohérente avec g_terminal ellwood = {"Y": r, "M": M, "f": f, "P": P, "n": n, "SFF": sff, "delta": delta, "R_delta0": fin.taux_ellwood(r, M, f, P, n, 0.0), "R_delta": fin.taux_ellwood(r, M, f, P, n, delta), "R_akerson": fin.taux_akerson(r, M, f, P, n, delta), "R_sans_dette": fin.taux_ellwood(r, 0.0, f, P, n, delta), "R_sans_dette_delta0": fin.taux_ellwood(r, 0.0, f, P, n, 0.0), "composantes": {"M f": M * f, "(1-M) Y": (1 - M) * r, "-M P SFF": -M * P * sff, "-delta SFF": -delta * sff}} ellwood["V_delta"] = fin.capitalisation_directe(base["rne"], ellwood["R_delta"]) # lecture inverse : appréciation Δ cohérente avec le taux de marché (Y, M, f fixés) et rendement Y cohérent avec Δ fixé ellwood["delta_implicite"] = (ellwood["R_delta0"] - R) / sff lo, hi = 0.02, 0.30 for _ in range(200): mid = 0.5 * (lo + hi); Pm = 1 - h1.solde(12 * n) if fin.taux_ellwood(mid, M, f, Pm, n, delta) < R: lo = mid else: hi = mid ellwood["Y_implicite"] = 0.5 * (lo + hi) ellwood["composantes_marche"] = {"M f": M * f, "(1-M) Y": (1 - M) * r, "-M P SFF": -M * P * sff, "-delta SFF": -ellwood["delta_implicite"] * sff} # --- multiplicateurs ------------------------------------------------------------------------------------------- mrb = {"mediane": float(c["mrb"].median()), "moyenne": float(c["mrb"].mean()), "min": float(c["mrb"].min()), "max": float(c["mrb"].max()), "cv": float(c["mrb"].std(ddof=1) / c["mrb"].mean()), "cv_taux": float(c["taux_global"].std(ddof=1) / c["taux_global"].mean())} mrb["V_contrat"] = mrb["mediane"] * base["rbp_contrat"]; mrb["V_marche"] = mrb["mediane"] * base["rbp_marche"] mrb["mrn"] = 1 / R; mrb["mrb_sujet_implicite"] = V / base["rbp_contrat"] mrb["ratio_dep_median_comps"] = float(c["ratio_depenses"].median()); mrb["ratio_dep_sujet_rbe"] = base["depenses_totales"] / base["rbe"] # --- cohérence RNE–taux ---------------------------------------------------------------------------------------- taux_taxes = hyp["taux_taxe_fonciere"] + hyp["taux_taxe_scolaire"] taxes_role = hyp["valeur_role_2026"] * taux_taxes taxes_valeur = V * taux_taxes coherence = {"taxes_role": taxes_role, "taxes_sur_valeur": taxes_valeur, "ecart_taxes": taxes_role - taxes_valeur, "effet_valeur": (taxes_role - taxes_valeur) / R, "valeur_role": hyp["valeur_role_2026"], "ratio_valeur_role": V / hyp["valeur_role_2026"], "reserve": base["reserve"], "reserve_par_logement": base["reserve"] / len(etat), "R_avant_reserve_equiv": (base["rne"] + base["reserve"]) / V, "gestion": 0.04 * base["rbe"], "V_si_taux_avant_reserve_sur_rne_apres": fin.capitalisation_directe(base["rne"], (base["rne"] + base["reserve"]) / V)} # loyers de marché capitalisés à tort (baux en place) rbe_m = fin.revenu_brut_effectif(base["rbp_marche"], base["taux_vacance_pertes"], base["autres_revenus"]) de_m = base["depenses_hors_reserve"] - 0.04 * base["rbe"] + 0.04 * rbe_m rne_m = fin.rne(rbe_m, de_m, base["reserve"]) coherence["rne_marche"] = rne_m; coherence["V_marche"] = fin.capitalisation_directe(rne_m, R); coherence["surevaluation_marche"] = coherence["V_marche"] / V - 1 # droits de mutation (encadré Québec) base_droits = max(V, hyp["valeur_role_2026"] * FACTEUR_COMPARATIF_MTL_2026) mut = {"base": base_droits, "droits": droits_mutation(base_droits), "droits_sur_prix": droits_mutation(V)} mut["pct_prix"] = mut["droits"] / V # --- sensibilité ----------------------------------------------------------------------------------------------- grille_R = np.round(np.arange(0.0375, 0.0526, 0.0025), 4) sens = pd.DataFrame({"R": grille_R}) for k, m in (("rne_m5", 0.95), ("rne", 1.0), ("rne_p5", 1.05)): sens[k] = [fin.capitalisation_directe(base["rne"] * m, x) for x in grille_R] sens["var_vs_base"] = sens["rne"] / V - 1 derive = {"dV_dR": -V / R, "dV_25pb": -V / R * 0.0025, "V_plus25": fin.capitalisation_directe(base["rne"], R + 0.0025), "V_moins25": fin.capitalisation_directe(base["rne"], R - 0.0025), "elasticite_R": -1.0, "elasticite_rne": 1.0} derive["var_plus25_exacte"] = derive["V_plus25"] / V - 1; derive["var_plus25_lineaire"] = -0.0025 / R return {"base": base, "taux": tx, "R": R, "V": V, "V_arrondie": round(V / 5000) * 5000, "proches": proches, "mediane_proches": float(proches["taux_global"].median()), "V_proches": fin.capitalisation_directe(base["rne"], float(proches["taux_global"].median())), "V_q1": fin.capitalisation_directe(base["rne"], tx["q3"]), "V_q3": fin.capitalisation_directe(base["rne"], tx["q1"]), "regression": reg, "V_reg": V_reg, "f": f, "M": M, "re_implicite": re_implicite, "grille_re": grille_re, "R_rcd": R_rcd, "V_rcd": V_rcd, "gordon": gordon, "ellwood": ellwood, "mrb": mrb, "coherence": coherence, "mutations": mut, "sensibilite": sens, "derive": derive, "hyp": hyp, "sujet": sujet, "comps": comps} def exercices(res: dict | None = None) -> dict: """Chiffres des exercices 29.1 à 29.8 (énoncés dans ch29.tex, solutions dans solutions/ch29.tex).""" res = res or calculer(); c = res["comps"].set_index("id_vente"); b = res["base"] ex = {} # 29.1 — capitalisation simple : RNE de 61 200 $ (énoncé), R = 4,75 % ; +25 pb ex["1"] = {"rne": 61_200.0, "R": 0.0475, "V": fin.capitalisation_directe(61_200.0, 0.0475), "V_plus25": fin.capitalisation_directe(61_200.0, 0.05)} ex["1"]["var"] = ex["1"]["V_plus25"] / ex["1"]["V"] - 1; ex["1"]["lineaire"] = -0.0025 / 0.0475 # 29.2 — extraction sur cinq ventes du fichier cas 3 ids = ["M008", "M011", "M022", "M034", "M040"]; s = c.loc[ids] taux = s["rne"] / s["prix_vente"] ex["2"] = {"ids": ids, "taux": taux.to_dict(), "mediane": float(taux.median()), "moyenne": float(taux.mean()), "ponderee": float((s["rne"].sum()) / s["prix_vente"].sum()), "V_mediane": fin.capitalisation_directe(b["rne"], float(taux.median()))} # 29.3 — convention de la réserve : un comparable de 18 logements (énoncé) ; réserve 675 $/logement rne_apres, reserve, prix = 150_000.0, 18 * 675.0, 3_400_000.0 R_apres = fin.taux_global_extrait(rne_apres, prix); R_avant = fin.taux_global_extrait(rne_apres + reserve, prix) ex["3"] = {"R_apres": R_apres, "R_avant": R_avant, "V_correct": fin.capitalisation_directe(b["rne"], R_apres), "V_errone": fin.capitalisation_directe(b["rne"], R_avant), "V_coherent_avant": fin.capitalisation_directe(b["rne"] + b["reserve"], R_avant)} ex["3"]["erreur"] = ex["3"]["V_errone"] / ex["3"]["V_correct"] - 1 # 29.4 — bande d'investissement et RCD (R_E = 6 % et RCD = 1,25 : hypothèses de l'énoncé) f = res["f"]; M = res["M"] ex["4"] = {"f": f, "R_bande": fin.taux_bande_investissement(M, f, 0.06), "R_rcd": fin.taux_par_rcd(1.25, M, f), "V_bande": fin.capitalisation_directe(b["rne"], fin.taux_bande_investissement(M, f, 0.06)), "V_rcd": fin.capitalisation_directe(b["rne"], fin.taux_par_rcd(1.25, M, f)), "re_implicite": res["re_implicite"]} # 29.5 — Gordon g = res["gordon"] ex["5"] = {"R_225": g["R_g_terminal"], "R_310": g["R_g_tal"], "g_implicite": g["g_implicite_marche"], "V_225": fin.capitalisation_directe(b["rne"], g["R_g_terminal"]), "V_310": fin.capitalisation_directe(b["rne"], g["R_g_tal"]), "R_g4": fin.taux_gordon(g["r"], 0.04), "V_g4": fin.capitalisation_directe(b["rne"], fin.taux_gordon(g["r"], 0.04))} # 29.6 — Ellwood / Akerson (Δ = 25 % sur 10 ans, énoncé) e = res["ellwood"] ex["6"] = {"P": e["P"], "SFF": e["SFF"], "R_ellwood": fin.taux_ellwood(e["Y"], e["M"], e["f"], e["P"], e["n"], 0.25), "R_akerson": fin.taux_akerson(e["Y"], e["M"], e["f"], e["P"], e["n"], 0.25), "R_sans_dette": fin.taux_ellwood(e["Y"], 0.0, e["f"], e["P"], e["n"], 0.25), "R_delta0": e["R_delta0"], "g_equiv": 1.25 ** (1 / e["n"]) - 1} ex["6"]["R_gordon_equiv"] = fin.taux_gordon(e["Y"], ex["6"]["g_equiv"]); ex["6"]["V"] = fin.capitalisation_directe(b["rne"], ex["6"]["R_ellwood"]) # 29.7 — loyers de marché vs baux en place ; MRB co = res["coherence"]; m = res["mrb"] ex["7"] = {"rne_marche": co["rne_marche"], "V_marche": co["V_marche"], "surevaluation": co["surevaluation_marche"], "V_contrat": res["V"], "V_mrb_contrat": m["V_contrat"], "V_mrb_marche": m["V_marche"], "ecart_rbp": b["ecart_marche"], "v": b["taux_vacance_pertes"], "ecart_rne": co["rne_marche"] - b["rne"], "V_mrb_vs_cap": m["V_contrat"] / res["V"] - 1} # 29.8 — régression et sensibilité croisée r = res["regression"] R24 = r["constante"] + r["etat"] * (2 - 3) + r["taille"] * (24 - 12) ex["8"] = {"R_24_etat2": R24, "R_6_etat4": r["constante"] + r["etat"] * (4 - 3) + r["taille"] * (6 - 12), "sigma_resid": r["sigma_resid"], "V_pire": fin.capitalisation_directe(b["rne"] * 0.95, res["R"] + 0.0025), "V_meilleur": fin.capitalisation_directe(b["rne"] * 1.05, res["R"] - 0.0025)} ex["8"]["var_pire"] = ex["8"]["V_pire"] / res["V"] - 1; ex["8"]["var_meilleur"] = ex["8"]["V_meilleur"] / res["V"] - 1 ex["8"]["approx_pire"] = -0.05 - 0.0025 / res["R"]; ex["8"]["approx_meilleur"] = 0.05 + 0.0025 / res["R"] return ex def _p(x, dec=0): return f"{x:,.{dec}f}".replace(",", " ") def main(): res = calculer(); b = res["base"]; tx = res["taux"] pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30) print("=== Cas 3 (synthétique) — revenus et RNE normalisé (traite.cas3) ===") for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "vacance_pertes", "autres_revenus", "rbe", "depenses_hors_reserve", "reserve", "depenses_totales", "rne", "rne_par_logement"]: print(f" {k:24s} {_p(b[k], 2)}") print(f" ratio dépenses/RBE {b['ratio_depenses']:.4f}") print(b["detail"].round(2).to_string(index=False)) print("\n=== Extraction du taux (40 ventes, 6-30 logements, 24 mois) ===") for k in ["n", "mediane", "moyenne", "ponderee_rne", "q1", "q3", "min", "max"]: print(f" {k:14s} {tx[k]}") print(" régression :", {k: round(v, 6) for k, v in res["regression"].items()}) print(f" V (RNE / médiane) = {_p(b['rne'], 2)} / {res['R']:.5f} = {_p(res['V'], 2)} (arrondie {_p(res['V_arrondie'])}) ; V au taux de régression = {_p(res['V_reg'])}") print(f" 10 comparables les plus proches (médiane {res['mediane_proches']:.5f}) :") cols = ["id_vente", "date_vente", "quartier", "nb_logements", "annee_construction", "etat_1a5", "rne", "prix_vente", "taux_global", "mrb", "distance"] pr = res["proches"][cols].copy(); pr["date_vente"] = pr["date_vente"].dt.date; print(pr.round(3).to_string(index=False)) print("\n=== Construction du taux ===") print(f" constante hypothécaire f (4,12 %, 480 mois, composition semestrielle) = {res['f']:.6f} ; M = {res['M']}") print(f" bande d'investissement : R_E impliqué par R = {res['R']:.5f} → {res['re_implicite']:.5f} ; grille R(R_E) = {{ {', '.join(f'{k:.2f}: {v:.5f}' for k, v in res['grille_re'].items())} }}") print(f" formule RCD : R = 1,20 × 0,75 × f = {res['R_rcd']:.5f} → V = {_p(res['V_rcd'])}") print(" Gordon :", {k: round(v, 5) for k, v in res["gordon"].items()}) e = res["ellwood"]; print(" Ellwood/Akerson :", {k: (round(v, 6) if isinstance(v, float) else v) for k, v in e.items() if not k.startswith("composantes")}) print(" composantes (Δ du cas) :", {k: round(v, 6) for k, v in e["composantes"].items()}); print(" composantes (Δ implicite) :", {k: round(v, 6) for k, v in e["composantes_marche"].items()}) print(f" V (médiane des 10 proches {res['mediane_proches']:.5f}) = {_p(res['V_proches'])} ; V(Q3 des taux) = {_p(res['V_q1'])} ; V(Q1 des taux) = {_p(res['V_q3'])}") print("\n=== Multiplicateurs ===", {k: round(v, 4) for k, v in res["mrb"].items()}) print("\n=== Cohérence RNE–taux ===", {k: round(v, 4) for k, v in res["coherence"].items()}) print("=== Droits de mutation Montréal 2026 ===", {k: round(v, 4) for k, v in res["mutations"].items()}) print("\n=== Sensibilité ===") print(res["sensibilite"].round(4).to_string(index=False)); print(" dérivée :", {k: round(v, 6) for k, v in res["derive"].items()}) print("\n=== Exercices ===") for k, v in exercices(res).items(): print(f" 29.{k} :", {kk: (round(vv, 6) if isinstance(vv, float) else vv) for kk, vv in v.items()}) if __name__ == "__main__": main()