Ch. 12 livré et validé (compilation isolée, tests, fusion des registres)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
98 changed files +5,322 −82
modified
analysis/python/cases/ch12_mesurage.py
+1 −1
@@ -37,7 +37,7 @@ PLAN = { | |||||
| 37 | "sous_sol_plancher_sous_sol_m": 1.90, # plancher du sous-sol à 1,90 m sous le niveau moyen du sol (> 0,4 m : pas un étage MEFQ) | 37 | "sous_sol_plancher_sous_sol_m": 1.90, # plancher du sous-sol à 1,90 m sous le niveau moyen du sol (> 0,4 m : pas un étage MEFQ) | ||
| 38 | "garage_long_m": 6.10, "garage_larg_m": 3.66, # garage simple attaché (20 pi × 12 pi), hors empreinte principale | 38 | "garage_long_m": 6.10, "garage_larg_m": 3.66, # garage simple attaché (20 pi × 12 pi), hors empreinte principale | ||
| 39 | "lot_facade_m": 15.24, "lot_prof_m": 40.68, # lot rectangulaire 50 pi × 133,5 pi ≈ 620 m² | 39 | "lot_facade_m": 15.24, "lot_prof_m": 40.68, # lot rectangulaire 50 pi × 133,5 pi ≈ 620 m² | ||
| 40 | − "marge_avant_m": 7.50, "marge_lat_gauche_m": 2.00, "marge_lat_droite_m": 1.50, | 40 | + "marge_avant_m": 7.50, "marge_lat_gauche_m": 2.00, "marge_lat_droite_m": 1.66, # 15,24 − 7,92 − 3,66 − 2,00 | ||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | 43 | ||||
modified
analysis/python/cases/ch15_segmentation.py
+3 −1
@@ -188,7 +188,9 @@ def validation_croisee(v: pd.DataFrame, segments: dict, n_plis: int = 10) -> pd. | |||||
| 188 | y = np.log(v["prix_vente"].astype(float)).values; Xb = matrice_base(v) | 188 | y = np.log(v["prix_vente"].astype(float)).values; Xb = matrice_base(v) | ||
| 189 | kf = KFold(n_splits=n_plis, shuffle=True, random_state=GRAINE) | 189 | kf = KFold(n_splits=n_plis, shuffle=True, random_state=GRAINE) | ||
| 190 | def ajuster_predire(Xtr, ytr, Xte): | 190 | def ajuster_predire(Xtr, ytr, Xte): | ||
| 191 | − b = h.mco(Xtr, ytr)["beta"]; return Xte @ b | 191 | + with np.errstate(invalid="ignore"): # petits segments : variance nulle d'un régresseur → erreurs-types non définies (non utilisées ici) | ||
| 192 | + b = h.mco(Xtr, ytr)["beta"] | ||||
| 193 | + return Xte @ b | ||||
| 192 | resultats = [] | 194 | resultats = [] | ||
| 193 | specs = {"Équation unique sans segment": ("aucun", v["secteur"].astype(str))} | 195 | specs = {"Équation unique sans segment": ("aucun", v["secteur"].astype(str))} | ||
| 194 | for nom, lab in segments.items(): | 196 | for nom, lab in segments.items(): | ||
modified
analysis/python/cases/ch19_spatial.py
+23 −0
@@ -258,6 +258,29 @@ def sensibilite(b: dict) -> pd.DataFrame: | |||||
| 258 | "rho": sar["rho"], "se_rho": sar["se_rho"], "aic_sar": sar["aic"], "lambda": sem["lambda"], "se_lambda": sem["se_lambda"], "aic_sem": sem["aic"]}) | 258 | "rho": sar["rho"], "se_rho": sar["se_rho"], "aic_sar": sar["aic"], "lambda": sem["lambda"], "se_lambda": sem["se_lambda"], "aic_sem": sem["aic"]}) | ||
| 259 | return pd.DataFrame(rows) | 259 | return pd.DataFrame(rows) | ||
| 260 | 260 | ||||
| 261 | + | ||||
| 262 | +# ----------------------------------------------------------------------------- exercice 19.7 | ||||
| 263 | + | ||||
| 264 | +def exercice_7(b: dict | None = None) -> dict: | ||||
| 265 | + """SEM sans secteur avec 4, 8 et 16 voisins : λ, espérance conditionnelle du résidu au sujet et indication ; | ||||
| 266 | + matrice spatio-temporelle avec fenêtre de 24 mois : ventes sans voisine antérieure et ρ_st.""" | ||||
| 267 | + b = b or charger_base(); y, Xr, xy, xs, n = b["y"], b["Xr"].values, b["xy"], b["xs_r"], len(b["y"]) | ||||
| 268 | + duan = lambda res: float(np.mean(np.exp(res))) | ||||
| 269 | + rows = [] | ||||
| 270 | + for k in [4, 8, 16]: | ||||
| 271 | + W = h.matrice_poids_knn(xy, k); sem = h.sem_mv(y, Xr, W) | ||||
| 272 | + xy_aug = np.vstack([xy, b["cs"]]); Wa = np.zeros((n + 1, n + 1)); Wa[:n, :n] = W | ||||
| 273 | + Wa[n, :n] = h.matrice_poids_knn(xy_aug, k)[n, :n]; Wa[n] /= Wa[n].sum() | ||||
| 274 | + u_s = float(h.sem_esperance_conditionnelle(sem["lambda"], Wa, sem["residus"], [n])[0]) | ||||
| 275 | + rows.append({"k": k, "lambda": sem["lambda"], "u_sujet": u_s, "indication": float(np.exp(xs @ sem["beta"] + u_s) * duan(sem["innovations"]))}) | ||||
| 276 | + sem_k = pd.DataFrame(rows) | ||||
| 277 | + out = {"sem_par_k": sem_k} | ||||
| 278 | + for fen in [12, 24]: | ||||
| 279 | + Wst = h.matrice_poids_spatio_temporelle(xy, b["mois"], PORTEE_KM, fen); ok = Wst.sum(1) > 0 | ||||
| 280 | + Z = np.column_stack([(Wst @ y)[ok], Xr[ok]]); r = h.mco(Z, y[ok]) | ||||
| 281 | + out[f"st_{fen}"] = {"sans_voisin": int((~ok).sum()), "voisins_moyen": float((Wst[ok] > 0).sum(1).mean()), "rho_st": float(r["beta"][0]), "se": float(r["se_hc1"][0])} | ||||
| 282 | + return out | ||||
| 283 | + | ||||
| 261 | # ----------------------------------------------------------------------------- contrôle croisé facultatif | 284 | # ----------------------------------------------------------------------------- contrôle croisé facultatif | ||
| 262 | 285 | ||||
| 263 | def controle_croise(b: dict, W: np.ndarray, g: dict) -> dict: | 286 | def controle_croise(b: dict, W: np.ndarray, g: dict) -> dict: | ||
added
analysis/python/cases/ch24_depreciation.py
+343 −0
@@ -0,0 +1,343 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 24 — dépréciation et désuétudes du sujet du cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES). | ||||
| 3 | + | ||||
| 4 | +Imprime tous les chiffres cités dans le chapitre 24 : | ||||
| 5 | + * âges (chronologique, effectif selon la convention du ch. 11), vie économique et vie restante ; | ||||
| 6 | + * dépréciation physique : âge-vie simple (âge chronologique / âge effectif), âge-vie modifiée, ventilée par composantes | ||||
| 7 | + (fiche d'état et travaux du ch. 11, traite.cout.depreciation_ventilee) ; | ||||
| 8 | + * désuétude fonctionnelle : carence curable du sujet (salle d'eau), exemples incurables (perte de loyer × MRB du cas 2 ; | ||||
| 9 | + perte de RNE capitalisée) ; désuétude externe (ventes appariées, capitalisation) ; | ||||
| 10 | + * extraction de marché sur les 600 ventes du cas 1 : coût neuf par vente (élasticité de taille du ch. 21), prix ramenés à la | ||||
| 11 | + date d'évaluation (tendance du modèle hédonique), valeur de terrain par secteur extraite des ventes récentes, dépréciation | ||||
| 12 | + par vente, profil en fonction de l'âge (médianes par tranche, ajustements linéaire et quadratique) ; | ||||
| 13 | + * profils comparés : âge-vie linéaire, extraction, courbe hédonique (coefficients âge et âge² de traite.cas1.hedonique) | ||||
| 14 | + et profil « vrai » du générateur (data/synthetic/README.md), convertis en dépréciation du bâtiment ; | ||||
| 15 | + * valeur par le coût du sujet (traite.cout.valeur_par_cout) selon deux lectures de la dépréciation, et sensibilité. | ||||
| 16 | + | ||||
| 17 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch24_depreciation.py | ||||
| 18 | +Toutes les formules de dépréciation proviennent de analysis/python/traite/cout.py (aucune réécriture).""" | ||||
| 19 | +from __future__ import annotations | ||||
| 20 | +import sys, pathlib | ||||
| 21 | +import numpy as np, pandas as pd | ||||
| 22 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 23 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) | ||||
| 24 | +from traite import cout as ct, cas1 | ||||
| 25 | +from traite import hedonique as h | ||||
| 26 | +import ch21_cout_neuf as c21 | ||||
| 27 | +import ch11_inspection as c11 | ||||
| 28 | + | ||||
| 29 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 30 | + | ||||
| 31 | +# --- conventions et hypothèses déclarées --------------------------------------------------------------------- | ||||
| 32 | +VIE_ECONOMIQUE = c11.VIE_ECONOMIQUE # 60 ans (convention des ch. 11 et 24, hypothèse pédagogique) | ||||
| 33 | +AGE_CHRONO = 28 # 1998 → 2026 | ||||
| 34 | +DATE_EVAL = "2026-09-01" | ||||
| 35 | +ELASTICITE_TAILLE = -0.10 # ch. 21 : coût unitaire ∝ (S/S_réf)^ε | ||||
| 36 | +FACTEUR_QUALITE = 0.05 # coût neuf ± 5 % par classe de qualité (hypothèse pédagogique) | ||||
| 37 | +AGE_MAX_RECENT = 10 # ventes « récentes » servant à extraire la valeur du terrain | ||||
| 38 | +S_REF = 165.0 | ||||
| 39 | +# Carence fonctionnelle curable du sujet (constat FICTIF du ch. 24) : aucune salle d'eau au rez-de-chaussée | ||||
| 40 | +SALLE_EAU_AJOUT = 16000.0 # soumission fictive : ajout a posteriori, installé (hypothèse pédagogique) | ||||
| 41 | +SALLE_EAU_POSTES = {"Plomberie brute et finition": 2.0, "Vanités et accessoires (2 sdb)": 0.5, "Portes intérieures et moulures": 1.0, | ||||
| 42 | + "Gypse et tirage de joints": 12.0, "Peinture intérieure": 12.0, "Planchers céramique": 3.0} # quantités si incluse à neuf | ||||
| 43 | +# Exemples illustratifs (hypothèses pédagogiques, hors sujet) | ||||
| 44 | +TRIPLEX_PERTE_LOYER_MOIS = 75.0 # $/mois, logement sans entrée laveuse-sécheuse | ||||
| 45 | +RNE_PERTE_ANNUELLE = 6000.0 # $/an | ||||
| 46 | +TAUX_CAP_ILLUSTRATIF = 0.045 # milieu de la fourchette CBRE T3 2026 Montréal low-rise B (4,25-4,75 %), calibration.json SRC-E-045 | ||||
| 47 | +PART_BATIMENT_ILLUSTRATIVE = 0.70 | ||||
| 48 | +PAIRES_SANS, PAIRES_AVEC, PAIRES_PART_BAT = 520000.0, 490000.0, 0.60 | ||||
| 49 | + | ||||
| 50 | + | ||||
| 51 | +def charger() -> dict: | ||||
| 52 | + devis, sujet, cn = c11.charger() | ||||
| 53 | + d = cas1.charger() | ||||
| 54 | + m = cas1.hedonique(d["ventes"]) | ||||
| 55 | + fiche = c11.fiche(devis, cn) | ||||
| 56 | + trav = c11.travaux(devis, cn) | ||||
| 57 | + return {"devis": devis, "sujet": sujet, "cn": cn, "ventes": d["ventes"], "modele": m, "fiche": fiche, "travaux": trav} | ||||
| 58 | + | ||||
| 59 | + | ||||
| 60 | +# --------------------------------------------------------------------------------------------- âges et âge-vie | ||||
| 61 | + | ||||
| 62 | +def ages_sujet(fiche: pd.DataFrame) -> dict: | ||||
| 63 | + s = fiche[fiche["longue_duree"]].iloc[0] | ||||
| 64 | + pond = float((fiche["cout_neuf"] * fiche["ratio"]).sum() / fiche["cout_neuf"].sum()) | ||||
| 65 | + return {"chronologique": AGE_CHRONO, "effectif_structure": float(s.age_effectif), "vie_economique": VIE_ECONOMIQUE, | ||||
| 66 | + "vie_restante": ct.vie_restante(VIE_ECONOMIQUE, float(s.age_effectif)), | ||||
| 67 | + "ratio_pondere": pond, "age_effectif_pondere": pond * VIE_ECONOMIQUE, "indice_etat": c11.indice_etat(fiche)} | ||||
| 68 | + | ||||
| 69 | + | ||||
| 70 | +def variantes_physique(cn: dict, fiche: pd.DataFrame, trav: pd.DataFrame) -> dict: | ||||
| 71 | + """Quatre lectures de la dépréciation physique du sujet (toutes sur C_N = 397 567,33 $).""" | ||||
| 72 | + a_eff = ages_sujet(fiche)["effectif_structure"] | ||||
| 73 | + curable = float(trav["cout"].sum()) | ||||
| 74 | + simple_chrono = ct.depreciation_age_vie(cn["total"], AGE_CHRONO, VIE_ECONOMIQUE) | ||||
| 75 | + simple_eff = ct.depreciation_age_vie(cn["total"], a_eff, VIE_ECONOMIQUE) | ||||
| 76 | + modifiee = ct.depreciation_age_vie_modifiee(cn["total"], curable, a_eff, VIE_ECONOMIQUE) | ||||
| 77 | + ventilee = c11.depreciation(fiche, trav, cn) | ||||
| 78 | + return {"age_vie_chrono": simple_chrono, "age_vie_effectif": simple_eff, "modifiee": modifiee, "ventilee": ventilee, | ||||
| 79 | + "curable": curable, "age_effectif": a_eff} | ||||
| 80 | + | ||||
| 81 | + | ||||
| 82 | +# ------------------------------------------------------------------------------------------ désuétudes | ||||
| 83 | + | ||||
| 84 | +def carence_salle_eau(devis: pd.DataFrame, cn: dict) -> dict: | ||||
| 85 | + """Désuétude fonctionnelle curable (carence) : coût d'ajout a posteriori − coût de l'élément s'il avait été inclus à neuf. | ||||
| 86 | + Le coût « à neuf » est reconstitué à partir des prix unitaires du devis (quantités déclarées), majoré comme le coût neuf.""" | ||||
| 87 | + g = c11.facteur_majoration(cn) | ||||
| 88 | + direct = 0.0 | ||||
| 89 | + for poste, q in SALLE_EAU_POSTES.items(): | ||||
| 90 | + pu = float(devis.loc[devis["poste"] == poste, "prix_unitaire"].iloc[0]) | ||||
| 91 | + direct += pu * q | ||||
| 92 | + cout_si_neuf = direct * g | ||||
| 93 | + d = ct.desuetude_fonctionnelle_curable(SALLE_EAU_AJOUT, cout_si_neuf, 0.0) | ||||
| 94 | + return {"cout_si_neuf_direct": direct, "cout_si_neuf": cout_si_neuf, "cout_ajout": SALLE_EAU_AJOUT, "desuetude": d} | ||||
| 95 | + | ||||
| 96 | + | ||||
| 97 | +def exemples_incurables() -> dict: | ||||
| 98 | + from traite import cas2 | ||||
| 99 | + ind = cas2.indicateurs_marche(cas2.comparables_plex(cas2.charger()["ventes"], cas2.charger()["sujet"])) | ||||
| 100 | + mrb_annuel = ind["mrb_median_triplex"] | ||||
| 101 | + loyer = ct.desuetude_incurable_par_loyer(TRIPLEX_PERTE_LOYER_MOIS, mrb_annuel * 12) | ||||
| 102 | + rne = ct.desuetude_incurable_par_rne(RNE_PERTE_ANNUELLE, TAUX_CAP_ILLUSTRATIF, PART_BATIMENT_ILLUSTRATIVE) | ||||
| 103 | + rne_total = ct.desuetude_incurable_par_rne(RNE_PERTE_ANNUELLE, TAUX_CAP_ILLUSTRATIF, 1.0) | ||||
| 104 | + paires = ct.desuetude_externe_ventes_appariees(PAIRES_SANS, PAIRES_AVEC, PAIRES_PART_BAT) | ||||
| 105 | + return {"mrb_annuel": mrb_annuel, "mrb_mensuel": mrb_annuel * 12, "perte_loyer_capitalisee": loyer, | ||||
| 106 | + "perte_rne_capitalisee_totale": rne_total, "perte_rne_batiment": rne, "paires": paires} | ||||
| 107 | + | ||||
| 108 | + | ||||
| 109 | +# ------------------------------------------------------------------------------------------ extraction de marché | ||||
| 110 | + | ||||
| 111 | +def cout_neuf_ventes(v: pd.DataFrame, cn_total: float, elasticite: float = ELASTICITE_TAILLE, fq: float = FACTEUR_QUALITE) -> pd.Series: | ||||
| 112 | + """Coût neuf de chaque vente, transposé du sujet : C_N × (S/165)^(1+ε) × (1 + fq·(qualité − 3)).""" | ||||
| 113 | + unit = ct.cout_par_superficie_ajuste(cn_total / S_REF, S_REF, v["superficie_habitable_m2"].astype(float), elasticite) | ||||
| 114 | + return unit * v["superficie_habitable_m2"].astype(float) * (1.0 + fq * (v["qualite_1a5"].astype(float) - 3.0)) | ||||
| 115 | + | ||||
| 116 | + | ||||
| 117 | +def prix_a_la_date(v: pd.DataFrame, modele: dict, date_eval: str = DATE_EVAL) -> pd.Series: | ||||
| 118 | + """Prix ramenés à la date d'évaluation par la tendance mensuelle du modèle hédonique (ch. 17-18).""" | ||||
| 119 | + g = modele["croissance_mensuelle"] | ||||
| 120 | + mois = (pd.Timestamp(date_eval) - pd.to_datetime(v["date_vente"])).dt.days / 30.4375 | ||||
| 121 | + return v["prix_vente"].astype(float) * (1.0 + g) ** mois | ||||
| 122 | + | ||||
| 123 | + | ||||
| 124 | +def valeur_terrain_par_secteur(v: pd.DataFrame, p_adj: pd.Series, cn_i: pd.Series, age_max: int = AGE_MAX_RECENT) -> pd.DataFrame: | ||||
| 125 | + """Valeur du terrain par secteur, extraite des ventes récentes : V_L = P_adj − C_N (1 − âge/L) (extraction, ch. 25).""" | ||||
| 126 | + r = v[v["age"] <= age_max].copy() | ||||
| 127 | + r["p_adj"] = p_adj[r.index]; r["cn"] = cn_i[r.index] | ||||
| 128 | + r["dep"] = [ct.depreciation_age_vie(c, a, VIE_ECONOMIQUE) for c, a in zip(r["cn"], r["age"])] | ||||
| 129 | + r["vl"] = r["p_adj"] - (r["cn"] - r["dep"]) | ||||
| 130 | + r["part"] = r["vl"] / r["p_adj"] | ||||
| 131 | + g = r.groupby("secteur").agg(n=("vl", "size"), vl_mediane=("vl", "median"), vl_moyenne=("vl", "mean"), vl_ecart_type=("vl", "std"), | ||||
| 132 | + part_mediane=("part", "median"), terrain_m2_median=("superficie_terrain_m2", "median")) | ||||
| 133 | + g["vl_par_m2"] = g["vl_mediane"] / g["terrain_m2_median"] | ||||
| 134 | + return g | ||||
| 135 | + | ||||
| 136 | + | ||||
| 137 | +def extraction(v: pd.DataFrame, modele: dict, cn_total: float, elasticite: float = ELASTICITE_TAILLE, fq: float = FACTEUR_QUALITE, | ||||
| 138 | + age_max: int = AGE_MAX_RECENT) -> tuple[pd.DataFrame, pd.DataFrame]: | ||||
| 139 | + """Dépréciation totale extraite de chaque vente : D = C_N − (P_adj − V_L) (traite.cout.extraction_depreciation_marche).""" | ||||
| 140 | + w = v.copy() | ||||
| 141 | + w["cn"] = cout_neuf_ventes(w, cn_total, elasticite, fq) | ||||
| 142 | + w["p_adj"] = prix_a_la_date(w, modele) | ||||
| 143 | + vl = valeur_terrain_par_secteur(w, w["p_adj"], w["cn"], age_max) | ||||
| 144 | + w["vl"] = w["secteur"].map(vl["vl_mediane"]) | ||||
| 145 | + ext = [ct.extraction_depreciation_marche(p, l, c, a) for p, l, c, a in zip(w["p_adj"], w["vl"], w["cn"], w["age"])] | ||||
| 146 | + w["contribution_batiment"] = [e["contribution_batiment"] for e in ext] | ||||
| 147 | + w["D"] = [e["depreciation"] for e in ext] | ||||
| 148 | + w["taux"] = [e["taux"] for e in ext] | ||||
| 149 | + w["taux_annuel"] = [e.get("taux_annuel", np.nan) for e in ext] | ||||
| 150 | + return w, vl | ||||
| 151 | + | ||||
| 152 | + | ||||
| 153 | +def ajuster_profil(w: pd.DataFrame) -> dict: | ||||
| 154 | + """Ajustements du taux de dépréciation extrait sur l'âge : linéaire (constante), quadratique, linéaire par l'origine.""" | ||||
| 155 | + a = w["age"].astype(float).values; y = w["taux"].values; n = len(w) | ||||
| 156 | + lin = h.mco(np.column_stack([np.ones(n), a]), y) | ||||
| 157 | + quad = h.mco(np.column_stack([np.ones(n), a, a ** 2]), y) | ||||
| 158 | + orig = h.mco(a.reshape(-1, 1), y) | ||||
| 159 | + return {"lineaire": lin, "quadratique": quad, "origine": orig, | ||||
| 160 | + "taux_annuel_origine": float(orig["beta"][0]), "vie_implicite": 1.0 / float(orig["beta"][0])} | ||||
| 161 | + | ||||
| 162 | + | ||||
| 163 | +def taux_quadratique(quad: dict, age) -> np.ndarray: | ||||
| 164 | + b = quad["beta"]; age = np.asarray(age, dtype=float) | ||||
| 165 | + return b[0] + b[1] * age + b[2] * age ** 2 | ||||
| 166 | + | ||||
| 167 | + | ||||
| 168 | +def tranches(w: pd.DataFrame, bornes=(0, 10, 20, 30, 40, 50, 60, 100)) -> pd.DataFrame: | ||||
| 169 | + g = w.groupby(pd.cut(w["age"], bornes), observed=True)["taux"].agg(["size", "median", "mean", "std"]) | ||||
| 170 | + g.index = [f"{int(i.left) + (1 if i.left else 0)}-{int(i.right)}" for i in g.index] | ||||
| 171 | + return g | ||||
| 172 | + | ||||
| 173 | + | ||||
| 174 | +# ------------------------------------------------------------------------------------------ profils comparés | ||||
| 175 | + | ||||
| 176 | +def prix_neuf_sujet(modele: dict, sujet: dict) -> float: | ||||
| 177 | + """Prédiction hédonique du sujet s'il était neuf (âge 0), même date, correction de Duan.""" | ||||
| 178 | + s = dict(sujet); s["age"] = 0; s["annee_construction"] = 2026 | ||||
| 179 | + return float(cas1.predire(modele, cas1.sujet_df(s))[0]) | ||||
| 180 | + | ||||
| 181 | + | ||||
| 182 | +def profil_total_hedonique(modele: dict, age) -> np.ndarray: | ||||
| 183 | + """Baisse du prix TOTAL avec l'âge selon le modèle : 1 − exp(β₁ âge + β₂ âge²).""" | ||||
| 184 | + b1 = float(modele["coef"].loc["age", "coef"]); b2 = float(modele["coef"].loc["age2_100", "coef"]) / 100.0 | ||||
| 185 | + age = np.asarray(age, dtype=float) | ||||
| 186 | + return 1.0 - np.exp(b1 * age + b2 * age ** 2) | ||||
| 187 | + | ||||
| 188 | + | ||||
| 189 | +def profil_total_generateur(age) -> np.ndarray: | ||||
| 190 | + """Loi de génération du cas 1 (data/synthetic/README.md) : −0,0055·âge + 0,00003·âge².""" | ||||
| 191 | + age = np.asarray(age, dtype=float) | ||||
| 192 | + return 1.0 - np.exp(-0.0055 * age + 0.00003 * age ** 2) | ||||
| 193 | + | ||||
| 194 | + | ||||
| 195 | +def total_vers_batiment(d_total, alpha: float) -> np.ndarray: | ||||
| 196 | + """Conversion : si le terrain vaut une part α du prix neuf et ne se déprécie pas, d_B = d_total / (1 − α).""" | ||||
| 197 | + return np.asarray(d_total, dtype=float) / (1.0 - alpha) | ||||
| 198 | + | ||||
| 199 | + | ||||
| 200 | +def profils(modele: dict, sujet: dict, vl: float, quad: dict, ages=np.arange(0, 81)) -> dict: | ||||
| 201 | + p0 = prix_neuf_sujet(modele, sujet) | ||||
| 202 | + alpha = vl / p0 | ||||
| 203 | + return {"ages": ages, "prix_neuf": p0, "alpha": alpha, | ||||
| 204 | + "age_vie": np.minimum(ages / VIE_ECONOMIQUE, 1.0), | ||||
| 205 | + "extraction": taux_quadratique(quad, ages), | ||||
| 206 | + "hedonique_total": profil_total_hedonique(modele, ages), "generateur_total": profil_total_generateur(ages), | ||||
| 207 | + "hedonique_bat": total_vers_batiment(profil_total_hedonique(modele, ages), alpha), | ||||
| 208 | + "generateur_bat": total_vers_batiment(profil_total_generateur(ages), alpha)} | ||||
| 209 | + | ||||
| 210 | + | ||||
| 211 | +# ------------------------------------------------------------------------------------------ valeur par le coût | ||||
| 212 | + | ||||
| 213 | +def valeur_terrain_retenue(vl: pd.DataFrame, secteur: str = "Hull") -> dict: | ||||
| 214 | + med = float(vl.loc[secteur, "vl_mediane"]) | ||||
| 215 | + return {"mediane": med, "retenue": round(med / 1000.0) * 1000.0, "n": int(vl.loc[secteur, "n"]), "part": float(vl.loc[secteur, "part_mediane"])} | ||||
| 216 | + | ||||
| 217 | + | ||||
| 218 | +def valeur_sujet(cn: dict, vl_retenue: float, quad: dict, fonc: float, variantes: dict) -> dict: | ||||
| 219 | + """Deux lectures : (A) dépréciation physique ventilée (ch. 11) + désuétude fonctionnelle ; (B) dépréciation extraite du marché | ||||
| 220 | + à l'âge chronologique + désuétude fonctionnelle spécifique au sujet.""" | ||||
| 221 | + t28 = float(taux_quadratique(quad, AGE_CHRONO)) | ||||
| 222 | + d_ext = t28 * cn["total"] | ||||
| 223 | + A = ct.valeur_par_cout(vl_retenue, cn["total"], variantes["ventilee"]["total"], fonc, 0.0) | ||||
| 224 | + B = ct.valeur_par_cout(vl_retenue, cn["total"], d_ext, fonc, 0.0) | ||||
| 225 | + return {"taux_extrait_28": t28, "d_extraite": d_ext, "A_ventilee": A, "B_extraction": B, | ||||
| 226 | + "ecart": B["valeur"] - A["valeur"], "valeur_retenue": B["valeur"], "valeur_arrondie": round(B["valeur"] / 5000.0) * 5000.0} | ||||
| 227 | + | ||||
| 228 | + | ||||
| 229 | +# ------------------------------------------------------------------------------------------ sensibilité | ||||
| 230 | + | ||||
| 231 | +def grille_age_vie(cn: dict, curable: float, vies=(50, 60, 70, 80, 100), ages_eff=(20, 25, 29, 33, 37)) -> pd.DataFrame: | ||||
| 232 | + """Taux de dépréciation (âge-vie modifiée, curable fixé) pour chaque couple (vie économique, âge effectif).""" | ||||
| 233 | + rows = {} | ||||
| 234 | + for L in vies: | ||||
| 235 | + rows[L] = {a: ct.depreciation_age_vie_modifiee(cn["total"], curable, a, L)["total"] / cn["total"] for a in ages_eff} | ||||
| 236 | + return pd.DataFrame(rows).T | ||||
| 237 | + | ||||
| 238 | + | ||||
| 239 | +def sensibilite_extraction(v: pd.DataFrame, modele: dict, cn_total: float) -> pd.DataFrame: | ||||
| 240 | + """Taux extrait à 28 ans et valeur du terrain (Hull) selon les choix de l'extraction.""" | ||||
| 241 | + rows = [] | ||||
| 242 | + for nom, kw in [("cas de base", {}), ("ventes récentes ≤ 5 ans", {"age_max": 5}), ("ventes récentes ≤ 15 ans", {"age_max": 15}), | ||||
| 243 | + ("élasticité −0,05", {"elasticite": -0.05}), ("élasticité −0,15", {"elasticite": -0.15}), | ||||
| 244 | + ("qualité ±0 %", {"fq": 0.0}), ("qualité ±10 %", {"fq": 0.10})]: | ||||
| 245 | + w, vl = extraction(v, modele, cn_total, **kw) | ||||
| 246 | + q = ajuster_profil(w)["quadratique"] | ||||
| 247 | + rows.append({"variante": nom, "vl_hull": float(vl.loc["Hull", "vl_mediane"]), "taux_28": float(taux_quadratique(q, AGE_CHRONO)), | ||||
| 248 | + "taux_annuel": ajuster_profil(w)["taux_annuel_origine"]}) | ||||
| 249 | + return pd.DataFrame(rows) | ||||
| 250 | + | ||||
| 251 | + | ||||
| 252 | +def tornade(cn: dict, vl: float, d_ext: float, fonc: float, variantes: dict, sens_ext: pd.DataFrame) -> list[dict]: | ||||
| 253 | + base = ct.valeur_par_cout(vl, cn["total"], d_ext, fonc)["valeur"] | ||||
| 254 | + out = [] | ||||
| 255 | + def add(param, bas, haut, etiquette): | ||||
| 256 | + out.append({"param": param, "bas": bas, "haut": haut, "base": base, "etiquette": etiquette}) | ||||
| 257 | + add("Valeur du terrain ±15 %", ct.valeur_par_cout(vl * 0.85, cn["total"], d_ext, fonc)["valeur"], ct.valeur_par_cout(vl * 1.15, cn["total"], d_ext, fonc)["valeur"], f"{vl*0.85:,.0f} → {vl*1.15:,.0f} $".replace(",", " ")) | ||||
| 258 | + t = sens_ext["taux_28"] | ||||
| 259 | + add("Choix d'extraction (taux à 28 ans)", ct.valeur_par_cout(vl, cn["total"], t.max() * cn["total"], fonc)["valeur"], ct.valeur_par_cout(vl, cn["total"], t.min() * cn["total"], fonc)["valeur"], f"{t.max()*100:.1f} % → {t.min()*100:.1f} %") | ||||
| 260 | + add("Coût neuf ±8,6 % (date des prix)", ct.valeur_par_cout(vl, cn["total"] / 1.086, d_ext / 1.086, fonc)["valeur"], ct.valeur_par_cout(vl, cn["total"] * 1.086, d_ext * 1.086, fonc)["valeur"], "indice ×0,921 → ×1,086") | ||||
| 261 | + add("Désuétude fonctionnelle 0 → 2×", ct.valeur_par_cout(vl, cn["total"], d_ext, 2 * fonc)["valeur"], ct.valeur_par_cout(vl, cn["total"], d_ext, 0.0)["valeur"], f"{2*fonc:,.0f} → 0 $".replace(",", " ")) | ||||
| 262 | + add("Lecture ventilée (ch. 11) au lieu de l'extraction", ct.valeur_par_cout(vl, cn["total"], variantes["ventilee"]["total"], fonc)["valeur"], base, f"{variantes['ventilee']['total']/cn['total']*100:.1f} % → {d_ext/cn['total']*100:.1f} %") | ||||
| 263 | + return out | ||||
| 264 | + | ||||
| 265 | + | ||||
| 266 | +# ------------------------------------------------------------------------------------------ main | ||||
| 267 | + | ||||
| 268 | +def main() -> dict: | ||||
| 269 | + d = charger() | ||||
| 270 | + cn, fiche, trav, v, m, sujet, devis = d["cn"], d["fiche"], d["travaux"], d["ventes"], d["modele"], d["sujet"], d["devis"] | ||||
| 271 | + f2 = lambda x: f"{x:,.2f}".replace(",", " "); f0 = lambda x: f"{x:,.0f}".replace(",", " ") | ||||
| 272 | + print("== DONNÉES SYNTHÉTIQUES — sujet cas 1 :", sujet["type"], sujet["annee_construction"], sujet["superficie_habitable_m2"], "m² hab.,", sujet["secteur"], "; évaluation", DATE_EVAL) | ||||
| 273 | + print(f"== Coût neuf C_N (ch. 21) = {f2(cn['total'])} $") | ||||
| 274 | + ag = ages_sujet(fiche) | ||||
| 275 | + print(f"== Âges : chronologique {ag['chronologique']} ans ; effectif de la structure {ag['effectif_structure']:.1f} ans (convention ch. 11, λ = {c11.LAMBDA}) ; " | ||||
| 276 | + f"vie économique {ag['vie_economique']:.0f} ans ; vie restante {ag['vie_restante']:.1f} ans ; indice d'état {ag['indice_etat']:.3f} ; " | ||||
| 277 | + f"âge effectif pondéré par les coûts {ag['age_effectif_pondere']:.1f} ans") | ||||
| 278 | + var = variantes_physique(cn, fiche, trav) | ||||
| 279 | + print("== Dépréciation physique — quatre lectures :") | ||||
| 280 | + print(f" âge-vie simple, âge chronologique 28/60 : {f2(var['age_vie_chrono'])} $ ({var['age_vie_chrono']/cn['total']*100:.2f} %)") | ||||
| 281 | + print(f" âge-vie simple, âge effectif {var['age_effectif']:.0f}/60 : {f2(var['age_vie_effectif'])} $ ({var['age_vie_effectif']/cn['total']*100:.2f} %)") | ||||
| 282 | + mo = var["modifiee"] | ||||
| 283 | + print(f" âge-vie modifiée : curable {f2(mo['curable'])} + incurable {f2(mo['incurable'])} = {f2(mo['total'])} $ ({mo['total']/cn['total']*100:.2f} %)") | ||||
| 284 | + ve = var["ventilee"] | ||||
| 285 | + print(f" ventilée (ch. 11) : curable {f2(ve['curable'])} + courte durée {f2(sum(ve['courte_duree'].values()))} + longue durée {f2(ve['longue_duree'])} " | ||||
| 286 | + f"(base {f2(ve['base_longue'])} × {ve['age_effectif_structure']:.0f}/60) = {f2(ve['total'])} $ ({ve['taux']*100:.2f} %)") | ||||
| 287 | + courtes = fiche[(~fiche["longue_duree"]) & (fiche["composante"] != "Entrée asphaltée")] | ||||
| 288 | + print(f" composantes de courte durée : {len(courtes)} ; coût neuf {f2(courtes['cout_neuf'].sum())} $ ; dépréciation {f2(sum(ve['courte_duree'].values()))} $ " | ||||
| 289 | + f"({sum(ve['courte_duree'].values())/courtes['cout_neuf'].sum()*100:.1f} % de leur coût neuf)") | ||||
| 290 | + fin = [(k, x, fiche.loc[fiche['composante'] == k, 'cout_neuf'].iloc[0]) for k, x in ve["courte_duree"].items()] | ||||
| 291 | + for k, x, c in sorted(fin, key=lambda t: -t[1]): | ||||
| 292 | + print(f" {k:58s} coût neuf {f2(c):>11s} $ dépréciation {f2(x):>11s} $ ({x/c*100:5.1f} %)") | ||||
| 293 | + se = carence_salle_eau(devis, cn) | ||||
| 294 | + print("== Désuétude fonctionnelle curable du sujet (carence : salle d'eau au rez-de-chaussée, constat fictif) :") | ||||
| 295 | + print(f" coût si incluse à neuf : direct {f2(se['cout_si_neuf_direct'])} $ → majoré {f2(se['cout_si_neuf'])} $ ; ajout a posteriori {f2(se['cout_ajout'])} $ ; désuétude = {f2(se['desuetude'])} $") | ||||
| 296 | + ex = exemples_incurables() | ||||
| 297 | + print("== Exemples incurables (hypothèses pédagogiques) :") | ||||
| 298 | + print(f" triplex cas 2 : perte {TRIPLEX_PERTE_LOYER_MOIS:.0f} $/mois × MRB mensuel {ex['mrb_mensuel']:.2f} (= 12 × {ex['mrb_annuel']:.2f}) = {f2(ex['perte_loyer_capitalisee'])} $") | ||||
| 299 | + print(f" perte de RNE {f0(RNE_PERTE_ANNUELLE)} $/an / {TAUX_CAP_ILLUSTRATIF*100:.2f} % = {f2(ex['perte_rne_capitalisee_totale'])} $ ; part bâtiment {PART_BATIMENT_ILLUSTRATIVE:.0%} → {f2(ex['perte_rne_batiment'])} $") | ||||
| 300 | + print(f" paires : ({f0(PAIRES_SANS)} − {f0(PAIRES_AVEC)}) × {PAIRES_PART_BAT:.0%} = {f2(ex['paires'])} $") | ||||
| 301 | + w, vl = extraction(v, m, cn["total"]) | ||||
| 302 | + print(f"== Extraction de marché sur {len(w)} ventes : tendance {m['croissance_mensuelle']*100:.3f} %/mois ; C_N par vente = C_N × (S/165)^(1{ELASTICITE_TAILLE:+.2f}) × (1 ± {FACTEUR_QUALITE:.0%}/classe)") | ||||
| 303 | + print(f" Valeur du terrain par secteur (ventes ≤ {AGE_MAX_RECENT} ans) :") | ||||
| 304 | + print(vl.round(3).to_string()) | ||||
| 305 | + vlr = valeur_terrain_retenue(vl) | ||||
| 306 | + print(f" → Hull : médiane {f2(vlr['mediane'])} $ sur {vlr['n']} ventes (part médiane du prix {vlr['part']*100:.1f} %) ; VALEUR DE TERRAIN RETENUE V_L = {f0(vlr['retenue'])} $ ({vlr['retenue']/620:.0f} $/m² pour 620 m²)") | ||||
| 307 | + tr = tranches(w) | ||||
| 308 | + print(" Taux de dépréciation extrait par tranche d'âge :"); print(tr.round(3).to_string()) | ||||
| 309 | + aj = ajuster_profil(w) | ||||
| 310 | + q = aj["quadratique"]; l = aj["lineaire"] | ||||
| 311 | + print(f" linéaire : taux = {l['beta'][0]:.4f} + {l['beta'][1]:.5f}·âge (se {l['se_hc1'][1]:.5f}), R² {l['r2']:.3f}") | ||||
| 312 | + print(f" quadratique : taux = {q['beta'][0]:.4f} + {q['beta'][1]:.5f}·âge {q['beta'][2]:+.7f}·âge² (se {q['se_hc1'][0]:.4f}, {q['se_hc1'][1]:.5f}, {q['se_hc1'][2]:.7f}), R² {q['r2']:.3f}") | ||||
| 313 | + print(f" par l'origine : {aj['taux_annuel_origine']*100:.3f} % de C_N par année → vie économique linéaire implicite {aj['vie_implicite']:.0f} ans") | ||||
| 314 | + for a in (10, 28, 40, 60): | ||||
| 315 | + print(f" taux quadratique à {a:2d} ans : {float(taux_quadratique(q, a))*100:.2f} %") | ||||
| 316 | + pr = profils(m, sujet, vlr["retenue"], q) | ||||
| 317 | + print(f"== Profils : prix hédonique du sujet neuf {f0(pr['prix_neuf'])} $ ; part du terrain α = {pr['alpha']:.3f}") | ||||
| 318 | + print(" âge | âge-vie | extraction | hédonique total → bâtiment | générateur total → bâtiment") | ||||
| 319 | + for a in (10, 20, 28, 40, 60, 80): | ||||
| 320 | + i = int(a) | ||||
| 321 | + print(f" {a:3d} | {pr['age_vie'][i]*100:6.1f} | {pr['extraction'][i]*100:7.1f} | {pr['hedonique_total'][i]*100:6.1f} → {pr['hedonique_bat'][i]*100:6.1f} | {pr['generateur_total'][i]*100:6.1f} → {pr['generateur_bat'][i]*100:6.1f}") | ||||
| 322 | + vs = valeur_sujet(cn, vlr["retenue"], q, se["desuetude"], var) | ||||
| 323 | + print("== Valeur par le coût du sujet (traite.cout.valeur_par_cout) :") | ||||
| 324 | + A, B = vs["A_ventilee"], vs["B_extraction"] | ||||
| 325 | + print(f" (A) ventilée : V_L {f0(vlr['retenue'])} + C_N {f2(cn['total'])} − D_phys {f2(ve['total'])} − D_fonc {f2(se['desuetude'])} = {f2(A['valeur'])} $ (dépréciation totale {A['taux_depreciation']*100:.2f} %)") | ||||
| 326 | + print(f" (B) extraction : D_extraite(28) = {vs['taux_extrait_28']*100:.2f} % × C_N = {f2(vs['d_extraite'])} $ ; V = {f0(vlr['retenue'])} + {f2(cn['total'])} − {f2(vs['d_extraite'])} − {f2(se['desuetude'])} = {f2(B['valeur'])} $ " | ||||
| 327 | + f"(dépréciation totale {f2(B['depreciation_totale'])} $ = {B['taux_depreciation']*100:.2f} % ; contribution du bâtiment {f2(B['contribution_batiment'])} $)") | ||||
| 328 | + print(f" écart B − A = {f2(vs['ecart'])} $ ; VALEUR RETENUE PAR LE COÛT {f2(vs['valeur_retenue'])} $, arrondie {f0(vs['valeur_arrondie'])} $") | ||||
| 329 | + gr = grille_age_vie(cn, var["curable"]) | ||||
| 330 | + print("== Sensibilité âge-vie modifiée (taux en %) : vie économique (lignes) × âge effectif (colonnes)"); print((gr * 100).round(1).to_string()) | ||||
| 331 | + sx = sensibilite_extraction(v, m, cn["total"]) | ||||
| 332 | + print("== Sensibilité de l'extraction :"); print(sx.assign(taux_28=sx.taux_28 * 100, taux_annuel=sx.taux_annuel * 100).round(2).to_string()) | ||||
| 333 | + to = tornade(cn, vlr["retenue"], vs["d_extraite"], se["desuetude"], var, sx) | ||||
| 334 | + print("== Tornade (valeur par le coût) :") | ||||
| 335 | + for o in to: | ||||
| 336 | + print(f" {o['param']:48s} {o['etiquette']:28s} bas {f0(o['bas'])} haut {f0(o['haut'])} (base {f0(o['base'])}) ; étendue {(o['haut']-o['bas'])/o['base']*100:.1f} %") | ||||
| 337 | + return {"cn": cn, "fiche": fiche, "travaux": trav, "ventes": v, "modele": m, "sujet": sujet, "devis": devis, "ages": ag, "variantes": var, | ||||
| 338 | + "salle_eau": se, "exemples": ex, "extraction": w, "vl": vl, "vl_retenue": vlr, "tranches": tr, "ajustements": aj, "profils": pr, | ||||
| 339 | + "valeur": vs, "grille": gr, "sens_extraction": sx, "tornade": to} | ||||
| 340 | + | ||||
| 341 | + | ||||
| 342 | +if __name__ == "__main__": | ||||
| 343 | + main() | ||||
added
analysis/python/cases/ch25_terrain.py
+364 −0
@@ -0,0 +1,364 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 25 — Évaluation du terrain (DONNÉES SYNTHÉTIQUES, graine 20261031). | ||||
| 3 | + | ||||
| 4 | +Imprime tous les chiffres cités dans le chapitre 25 : | ||||
| 5 | + * jeu synthétique de ventes de terrains vacants « Gatineau » (data/synthetic/ch25_terrains_ventes.csv) : lois déclarées ; | ||||
| 6 | + * modèle hédonique des terrains (traite.hedonique.mco) : élasticité de superficie, façade, services, topographie, forme, densité ; | ||||
| 7 | + * comparaison directe : sélection de cinq terrains comparables pour le sujet du cas 1 et grille d'ajustements ; | ||||
| 8 | + * allocation : part du terrain dans les ventes du cas 1 (secteur Hull) ; prix implicite marginal du modèle hédonique (traite.cas1) ; | ||||
| 9 | + * extraction : prix − coût déprécié du bâtiment sur les cinq comparables du chapitre 16 (coût neuf du chapitre 21) ; | ||||
| 10 | + * technique résiduelle du terrain sur le cas 3 (RNE de traite.cas3 ; taux déclarés) et sensibilité ; | ||||
| 11 | + * lotissement (développement actualisé) d'un site de 10 000 m² en 12 terrains (traite.finance.van) ; | ||||
| 12 | + * capitalisation d'une rente foncière (emphytéose) ; | ||||
| 13 | + * synthèse des indications et valeur de terrain retenue pour le sujet du cas 1 (reprise par les ch. 20, 24, 45). | ||||
| 14 | + | ||||
| 15 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch25_terrain.py | ||||
| 16 | +Toutes les fonctions financières proviennent de analysis/python/traite (aucune réécriture).""" | ||||
| 17 | +from __future__ import annotations | ||||
| 18 | +import sys, json, pathlib | ||||
| 19 | +import numpy as np, pandas as pd | ||||
| 20 | + | ||||
| 21 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 22 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | ||||
| 23 | +from traite import hedonique as hd, comparables as cp, finance as fin, cout as ct, cas1, cas3 | ||||
| 24 | + | ||||
| 25 | +D = ROOT / "data/synthetic" | ||||
| 26 | +CSV_TERRAINS = D / "ch25_terrains_ventes.csv" | ||||
| 27 | +GRAINE = 20261031 | ||||
| 28 | +REF_DATE = pd.Timestamp("2024-07-01") | ||||
| 29 | +DATE_EVAL = pd.Timestamp("2026-09-01") | ||||
| 30 | + | ||||
| 31 | +# ----------------------------------------------------------------------------- 1. jeu synthétique de ventes de terrains | ||||
| 32 | +# Lois déclarées (hypothèses pédagogiques ; voir data/synthetic/README.md, section « Chapitre 25 ») | ||||
| 33 | +P_REF = 225_000.0 # prix du terrain de référence : 560 m², façade 18 m, desservi, plat, régulier, 1 logement, Hull, 2024-07-01 | ||||
| 34 | +ELAST_T = 0.50 # élasticité du prix du terrain à la superficie (< 1 : valeur unitaire décroissante) | ||||
| 35 | +ELAST_F = 0.08 # élasticité à la façade (à superficie donnée) | ||||
| 36 | +B_NON_DESSERVI = -0.30 # ni aqueduc ni égout (puits + installation septique à la charge de l'acheteur) | ||||
| 37 | +B_PENTE = -0.06 # pente marquée | ||||
| 38 | +B_ROC = -0.12 # roc affleurant ou remblai important | ||||
| 39 | +B_IRREG = -0.05 # forme irrégulière (triangle, drapeau, trapèze prononcé) | ||||
| 40 | +B_LOGEMENT = 0.12 # par logement constructible supplémentaire (zonage H2/H3) | ||||
| 41 | +TENDANCE_MOIS = 0.0036 # même tendance que le coefficient « mois » du modèle hédonique du cas 1 (≈ 4,4 %/an) | ||||
| 42 | +SIGMA = 0.10 | ||||
| 43 | +MEDIANES_APCIQ = {"Hull": 475_000, "Gatineau": 464_900, "Aylmer": 530_000, "Buckingham": 395_000, "Masson-Angers": 395_000 * 0.98} | ||||
| 44 | +PRIME_SECTEUR = {k: float(np.log(v / MEDIANES_APCIQ["Hull"])) for k, v in MEDIANES_APCIQ.items()} | ||||
| 45 | +CENTROIDES = {"Hull": (45.4296, -75.7450), "Gatineau": (45.4769, -75.6650), "Aylmer": (45.3936, -75.8450), | ||||
| 46 | + "Buckingham": (45.5899, -75.4200), "Masson-Angers": (45.5468, -75.4150)} | ||||
| 47 | +N_PAR_SECTEUR = {"Hull": 70, "Gatineau": 60, "Aylmer": 40, "Buckingham": 15, "Masson-Angers": 15} | ||||
| 48 | + | ||||
| 49 | +def generer_terrains(graine: int = GRAINE) -> pd.DataFrame: | ||||
| 50 | + """Ventes synthétiques de terrains vacants résidentiels (2023-01 → 2026-08). Aucun terrain réel.""" | ||||
| 51 | + rng = np.random.default_rng(graine) | ||||
| 52 | + rows = [] | ||||
| 53 | + k = 0 | ||||
| 54 | + for sct, n in N_PAR_SECTEUR.items(): | ||||
| 55 | + periurbain = sct in ("Buckingham", "Masson-Angers") | ||||
| 56 | + for _ in range(n): | ||||
| 57 | + k += 1 | ||||
| 58 | + desservi = int(rng.random() < (0.30 if periurbain else 0.85)) | ||||
| 59 | + if desservi: | ||||
| 60 | + T = float(np.clip(np.exp(rng.normal(np.log(600), 0.28)), 320, 2500)) | ||||
| 61 | + else: | ||||
| 62 | + T = float(np.clip(np.exp(rng.normal(np.log(2200), 0.45)), 900, 9000)) | ||||
| 63 | + F = float(np.clip(0.60 * np.sqrt(T) * np.exp(rng.normal(0, 0.14)), 10, 90)) | ||||
| 64 | + topo = rng.choice(["plat", "pente", "roc"], p=[0.70, 0.20, 0.10]) | ||||
| 65 | + forme = rng.choice(["régulier", "irrégulier"], p=[0.80, 0.20]) | ||||
| 66 | + logements = int(rng.choice([1, 2, 3], p=[0.82, 0.12, 0.06])) if desservi and not periurbain else 1 | ||||
| 67 | + jours = int(rng.integers(0, (pd.Timestamp("2026-08-31") - pd.Timestamp("2023-01-01")).days + 1)) | ||||
| 68 | + date = pd.Timestamp("2023-01-01") + pd.Timedelta(days=jours) | ||||
| 69 | + mois = (date - REF_DATE).days / 30.4375 | ||||
| 70 | + lat0, lon0 = CENTROIDES[sct] | ||||
| 71 | + lat = lat0 + rng.normal(0, 0.012); lon = lon0 + rng.normal(0, 0.017) | ||||
| 72 | + lnp = (np.log(P_REF) + ELAST_T * np.log(T / 560) + ELAST_F * np.log(F / 18) + B_NON_DESSERVI * (1 - desservi) | ||||
| 73 | + + B_PENTE * (topo == "pente") + B_ROC * (topo == "roc") + B_IRREG * (forme == "irrégulier") | ||||
| 74 | + + B_LOGEMENT * (logements - 1) + PRIME_SECTEUR[sct] + TENDANCE_MOIS * mois + rng.normal(0, SIGMA)) | ||||
| 75 | + prix = float(np.round(np.exp(lnp) / 500) * 500) | ||||
| 76 | + rows.append({"id_vente": f"T{k:03d}", "date_vente": date.strftime("%Y-%m-%d"), "secteur": sct, "superficie_m2": round(T, 1), | ||||
| 77 | + "facade_m": round(F, 2), "desservi": desservi, "topographie": topo, "forme": forme, "logements_constructibles": logements, | ||||
| 78 | + "lat": round(lat, 5), "lon": round(lon, 5), "prix_vente": prix}) | ||||
| 79 | + df = pd.DataFrame(rows) | ||||
| 80 | + df["prix_m2"] = df["prix_vente"] / df["superficie_m2"] | ||||
| 81 | + return df | ||||
| 82 | + | ||||
| 83 | +def charger_terrains(regenerer: bool = False) -> pd.DataFrame: | ||||
| 84 | + if regenerer or not CSV_TERRAINS.exists(): | ||||
| 85 | + df = generer_terrains(); df.to_csv(CSV_TERRAINS, index=False) | ||||
| 86 | + df = pd.read_csv(CSV_TERRAINS, parse_dates=["date_vente"]) | ||||
| 87 | + return df | ||||
| 88 | + | ||||
| 89 | +# ----------------------------------------------------------------------------- 2. modèle hédonique des terrains | ||||
| 90 | +SECTEURS = ["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"] | ||||
| 91 | + | ||||
| 92 | +def matrice_terrains(df: pd.DataFrame) -> pd.DataFrame: | ||||
| 93 | + X = pd.DataFrame(index=df.index) | ||||
| 94 | + X["const"] = 1.0 | ||||
| 95 | + X["ln_T"] = np.log(df["superficie_m2"] / 560.0) | ||||
| 96 | + X["ln_F"] = np.log(df["facade_m"] / 18.0) | ||||
| 97 | + X["non_desservi"] = 1.0 - df["desservi"].astype(float) | ||||
| 98 | + X["pente"] = (df["topographie"] == "pente").astype(float) | ||||
| 99 | + X["roc"] = (df["topographie"] == "roc").astype(float) | ||||
| 100 | + X["irregulier"] = (df["forme"] == "irrégulier").astype(float) | ||||
| 101 | + X["logements_sup"] = df["logements_constructibles"].astype(float) - 1.0 | ||||
| 102 | + for s in SECTEURS[1:]: | ||||
| 103 | + X[f"sect_{s}"] = (df["secteur"] == s).astype(float) | ||||
| 104 | + X["mois"] = ((pd.to_datetime(df["date_vente"]) - REF_DATE).dt.days / 30.4375).astype(float) | ||||
| 105 | + return X | ||||
| 106 | + | ||||
| 107 | +def hedonique_terrains(df: pd.DataFrame) -> dict: | ||||
| 108 | + X = matrice_terrains(df); y = np.log(df["prix_vente"].astype(float)) | ||||
| 109 | + r = hd.mco(X.values, y.values) | ||||
| 110 | + coef = pd.DataFrame({"coef": r["beta"], "se_hc1": r["se_hc1"]}, index=X.columns) | ||||
| 111 | + coef["t"] = coef["coef"] / coef["se_hc1"]; coef["effet_pct"] = np.exp(coef["coef"]) - 1 | ||||
| 112 | + return {"coef": coef, "beta": pd.Series(r["beta"], index=X.columns), "r2": r["r2"], "sigma": float(np.sqrt(r["sigma2"])), "n": r["n"], | ||||
| 113 | + "residus": r["residus"], "X": X, "duan": float(np.mean(np.exp(r["residus"])))} | ||||
| 114 | + | ||||
| 115 | +def sujet_terrain() -> dict: | ||||
| 116 | + """Le terrain du sujet du cas 1, réputé vacant : 620 m², Hull, desservi, plat, régulier, zonage unifamilial ; façade posée à 20 m (hypothèse).""" | ||||
| 117 | + s = cas1.charger()["sujet"] | ||||
| 118 | + return {"id_vente": "SUJET", "secteur": s["secteur"], "superficie_m2": float(s["superficie_terrain_m2"]), "facade_m": 20.0, "desservi": 1, | ||||
| 119 | + "topographie": "plat", "forme": "régulier", "logements_constructibles": 1, "lat": s["lat"], "lon": s["lon"], "date_vente": DATE_EVAL} | ||||
| 120 | + | ||||
| 121 | +def predire_terrain(modele: dict, terrain: dict | pd.DataFrame, duan: bool = True) -> float: | ||||
| 122 | + df = pd.DataFrame([terrain]) if isinstance(terrain, dict) else terrain | ||||
| 123 | + X = matrice_terrains(df)[modele["X"].columns] | ||||
| 124 | + return float(np.exp(X.values @ modele["beta"].values)[0] * (modele["duan"] if duan else 1.0)) | ||||
| 125 | + | ||||
| 126 | +# ----------------------------------------------------------------------------- 3. comparaison directe | ||||
| 127 | +ECHELLES = {"superficie_m2": 100.0, "facade_m": 4.0, "dist_km": 1.5, "mois_anc": 6.0} | ||||
| 128 | +POIDS = {"superficie_m2": 2.0, "facade_m": 0.5, "dist_km": 1.5, "mois_anc": 1.0} | ||||
| 129 | + | ||||
| 130 | +def selection_terrains(df: pd.DataFrame, suj: dict, k: int = 5, mois_max: float = 24, dist_max: float = 4.0) -> dict: | ||||
| 131 | + v = df.copy() | ||||
| 132 | + v["mois_anc"] = (DATE_EVAL - v["date_vente"]).dt.days / 30.4375 | ||||
| 133 | + v["dist_km"] = np.sqrt(((v["lat"] - suj["lat"]) * 111) ** 2 + ((v["lon"] - suj["lon"]) * 78) ** 2) | ||||
| 134 | + etapes = {"total": len(v)} | ||||
| 135 | + f = v[v["mois_anc"].between(0, mois_max)]; etapes["fenetre"] = len(f) | ||||
| 136 | + f = f[f["dist_km"] <= dist_max]; etapes["rayon"] = len(f) | ||||
| 137 | + f = f[(f["desservi"] == 1) & (f["logements_constructibles"] == 1)]; etapes["desservi_unifamilial"] = len(f) | ||||
| 138 | + f = f[f["superficie_m2"].between(0.6 * suj["superficie_m2"], 1.4 * suj["superficie_m2"])]; etapes["superficie"] = len(f) | ||||
| 139 | + s0 = dict(suj); s0["mois_anc"] = 0.0; s0["dist_km"] = 0.0 | ||||
| 140 | + f = f.assign(distance=[cp.distance_attributs(s0, r, ECHELLES, POIDS) for r in f.to_dict("records")]).sort_values("distance") | ||||
| 141 | + return {"etapes": etapes, "candidats": f, "comparables": f.head(k), "ventes": v} | ||||
| 142 | + | ||||
| 143 | +GROUPES = {"conditions_marche": ["mois"], "localisation": [f"sect_{s}" for s in SECTEURS[1:]], "superficie": ["ln_T"], "facade": ["ln_F"], | ||||
| 144 | + "services": ["non_desservi"], "topographie": ["pente", "roc"], "forme": ["irregulier"], "densite": ["logements_sup"]} | ||||
| 145 | + | ||||
| 146 | +def grille_terrains(modele: dict, comps: pd.DataFrame, suj: dict) -> pd.DataFrame: | ||||
| 147 | + """Ajustements en cascade dérivés du modèle hédonique des terrains (même logique que traite.cas1.ajustements_hedoniques) : | ||||
| 148 | + A_k = base × [exp(β_k (x_s − x_c)) − 1], conditions du marché d'abord, puis localisation, puis caractéristiques physiques.""" | ||||
| 149 | + Xs = matrice_terrains(pd.DataFrame([suj])).iloc[0]; Xc = matrice_terrains(comps); beta = modele["beta"] | ||||
| 150 | + rows = [] | ||||
| 151 | + for i, c in comps.iterrows(): | ||||
| 152 | + base = float(c["prix_vente"]); row = {"id_vente": c["id_vente"], "prix_vente": base} | ||||
| 153 | + for g, cols in GROUPES.items(): | ||||
| 154 | + dlog = float(sum(beta[col] * (Xs[col] - Xc.loc[i, col]) for col in cols)) | ||||
| 155 | + adj = base * (np.exp(dlog) - 1.0); row[g] = adj; base += adj | ||||
| 156 | + row["prix_ajuste"] = base | ||||
| 157 | + row["ajust_brut_pct"] = sum(abs(row[g]) for g in GROUPES) / row["prix_vente"] | ||||
| 158 | + row["ajust_net_pct"] = (base - row["prix_vente"]) / row["prix_vente"] | ||||
| 159 | + rows.append(row) | ||||
| 160 | + return pd.DataFrame(rows) | ||||
| 161 | + | ||||
| 162 | +def reconcilier(grille: pd.DataFrame) -> dict: | ||||
| 163 | + objs = [cp.ComparableAjuste(r["id_vente"], r["prix_vente"], {g: r[g] for g in GROUPES}) for _, r in grille.iterrows()] | ||||
| 164 | + w = cp.poids_inverse_brut(objs) | ||||
| 165 | + return {**cp.reconciliation(objs, w), "objets": objs} | ||||
| 166 | + | ||||
| 167 | +def comparaison(df: pd.DataFrame | None = None) -> dict: | ||||
| 168 | + df = charger_terrains() if df is None else df | ||||
| 169 | + m = hedonique_terrains(df); suj = sujet_terrain(); sel = selection_terrains(df, suj) | ||||
| 170 | + g = grille_terrains(m, sel["comparables"], suj); rec = reconcilier(g) | ||||
| 171 | + pred = predire_terrain(m, suj) | ||||
| 172 | + return {"terrains": df, "modele": m, "sujet": suj, "selection": sel, "grille": g, "reconciliation": rec, "prediction_hedonique": pred, | ||||
| 173 | + "valeur_m2": rec["valeur_ponderee"] / suj["superficie_m2"]} | ||||
| 174 | + | ||||
| 175 | +# ----------------------------------------------------------------------------- 4. allocation et prix implicite marginal (cas 1) | ||||
| 176 | +C_N_SUJET = 397_567.33 # coût neuf du sujet du cas 1 (ch. 21, analysis/python/cases/ch21_cout_neuf.py) | ||||
| 177 | +ELAST_COUT_TOTAL = 0.90 # élasticité du coût total à la superficie (ch. 21 : ε = −0,10 sur le coût unitaire) | ||||
| 178 | +QUALITE_COUT = 0.10 # ±10 % de coût neuf par classe de qualité (hypothèse pédagogique) | ||||
| 179 | +VIE_ECONOMIQUE = 60.0 # variante « âge-vie » de l'extraction (convention déclarée ; le ch. 24 discute la vie économique) | ||||
| 180 | + | ||||
| 181 | +def cout_neuf_comparable(S: float, qualite: float) -> float: | ||||
| 182 | + """Coût neuf d'un comparable transposé depuis le sujet : C_N(S, q) = C_N,sujet × (S/165)^0,90 × (1 + 0,10 (q − 3)).""" | ||||
| 183 | + return C_N_SUJET * (S / 165.0) ** ELAST_COUT_TOTAL * (1 + QUALITE_COUT * (qualite - 3)) | ||||
| 184 | + | ||||
| 185 | +def facteur_age(modele_cas1: dict, age) -> np.ndarray: | ||||
| 186 | + """Rapport prix à l'âge a / prix neuf selon le profil d'âge du modèle hédonique du cas 1 : exp(β_age a + β_age2 a²/100).""" | ||||
| 187 | + b = modele_cas1["coef"]["coef"] | ||||
| 188 | + a = np.asarray(age, float) | ||||
| 189 | + return np.exp(b["age"] * a + b["age2_100"] * a ** 2 / 100.0) | ||||
| 190 | + | ||||
| 191 | +def extraction_cas1(cas: dict | None = None) -> dict: | ||||
| 192 | + """Extraction sur les cinq comparables du chapitre 16 : V_L = P − C_N(1 − δ), sous deux conventions de dépréciation : | ||||
| 193 | + (a) âge-vie linéaire (vie 60 ans) ; (b) dépréciation implicite du marché (profil d'âge hédonique) : V_L = P_neuf − C_N.""" | ||||
| 194 | + cas = cas or cas1.tout() | ||||
| 195 | + comps = cas["selection"]["comparables"].copy(); m = cas["modele"] | ||||
| 196 | + comps["C_N"] = cout_neuf_comparable(comps["superficie_habitable_m2"], comps["qualite_1a5"]) | ||||
| 197 | + comps["delta_agevie"] = comps["age"] / VIE_ECONOMIQUE | ||||
| 198 | + comps["V_L_agevie"] = comps["prix_vente"] - comps["C_N"] * (1 - comps["delta_agevie"]) | ||||
| 199 | + f = facteur_age(m, comps["age"]); comps["P_neuf"] = comps["prix_vente"] / f | ||||
| 200 | + comps["delta_marche"] = (comps["P_neuf"] - comps["prix_vente"]) / comps["C_N"] | ||||
| 201 | + comps["V_L_marche"] = comps["P_neuf"] - comps["C_N"] | ||||
| 202 | + for k in ("agevie", "marche"): | ||||
| 203 | + comps[f"V_L_{k}_m2"] = comps[f"V_L_{k}"] / comps["superficie_terrain_m2"] | ||||
| 204 | + # transposition au terrain du sujet (620 m²) avec l'élasticité de superficie du marché des terrains | ||||
| 205 | + comps[f"V_L_{k}_sujet"] = comps[f"V_L_{k}"] * (620.0 / comps["superficie_terrain_m2"]) ** ELAST_T | ||||
| 206 | + # Hull, toutes ventes : part du terrain par extraction (dépréciation de marché) | ||||
| 207 | + v = cas["ventes"]; h = v[v["secteur"] == "Hull"].copy() | ||||
| 208 | + h["C_N"] = cout_neuf_comparable(h["superficie_habitable_m2"], h["qualite_1a5"]) | ||||
| 209 | + h["V_L"] = h["prix_vente"] / facteur_age(m, h["age"]) - h["C_N"]; h["part"] = h["V_L"] / h["prix_vente"]; h["V_L_m2"] = h["V_L"] / h["superficie_terrain_m2"] | ||||
| 210 | + return {"comparables": comps, "hull": h, "part_hull_mediane": float(h["part"].median()), "part_hull_q1": float(h["part"].quantile(0.25)), | ||||
| 211 | + "part_hull_q3": float(h["part"].quantile(0.75)), "V_L_hull_mediane": float(h["V_L"].median()), | ||||
| 212 | + "indication_agevie": float(comps["V_L_agevie_sujet"].median()), "indication_marche": float(comps["V_L_marche_sujet"].median())} | ||||
| 213 | + | ||||
| 214 | +def allocation_cas1(cas: dict | None = None, extr: dict | None = None) -> dict: | ||||
| 215 | + cas = cas or cas1.tout(); extr = extr or extraction_cas1(cas) | ||||
| 216 | + a = extr["part_hull_mediane"] | ||||
| 217 | + P_grille = float(cas["ajustements"]["prix_ajuste"].mean()) # moyenne des prix ajustés des 5 comparables (ch. 17) | ||||
| 218 | + P_hed = float(cas["prediction_hedonique"]) # prédiction hédonique du sujet (ch. 18) | ||||
| 219 | + b = cas["modele"]["coef"]["coef"]; T = 620.0 | ||||
| 220 | + return {"ratio": a, "sur_grille": a * P_grille, "sur_hedonique": a * P_hed, "P_grille": P_grille, "P_hed": P_hed, | ||||
| 221 | + "beta_ln_T": float(b["ln_T"]), "prix_marginal_m2": float(b["ln_T"] * P_hed / T), "contribution_marginale": float(b["ln_T"] * P_hed), | ||||
| 222 | + "sujet_age_factor": float(facteur_age(cas["modele"], 28)), "delta_marche_sujet": float((P_hed / facteur_age(cas["modele"], 28) - P_hed) / C_N_SUJET)} | ||||
| 223 | + | ||||
| 224 | +# ----------------------------------------------------------------------------- 5. technique résiduelle du terrain (cas 3) | ||||
| 225 | +RES = {"cout_unitaire_neuf": 2500.0, "superficie_brute_m2": 950.0, "taux_depreciation": 0.52, "R_L": 0.0375, "n_restante": 40, "terrain_m2": 740.0} | ||||
| 226 | + | ||||
| 227 | +def residuelle_cas3(params: dict | None = None, t3: dict | None = None) -> dict: | ||||
| 228 | + p = {**RES, **(params or {})}; t3 = t3 or cas3.tout() | ||||
| 229 | + noi = float(t3["base"]["rne"]); R = float(t3["taux"]["mediane"]); V_cap = float(t3["valeur_capitalisation"]) | ||||
| 230 | + C_N = p["cout_unitaire_neuf"] * p["superficie_brute_m2"]; C_B = C_N * (1 - p["taux_depreciation"]) | ||||
| 231 | + R_B = p["R_L"] + 1.0 / p["n_restante"] | ||||
| 232 | + V_L = (noi - R_B * C_B) / p["R_L"] | ||||
| 233 | + R_L_implicite = (noi - R_B * C_B) / (V_cap - C_B) if V_cap > C_B else float("nan") | ||||
| 234 | + return {"noi": noi, "R": R, "V_cap": V_cap, "C_N": C_N, "C_B": C_B, "R_L": p["R_L"], "R_B": R_B, "revenu_batiment": R_B * C_B, | ||||
| 235 | + "revenu_terrain": noi - R_B * C_B, "V_L": V_L, "V_L_m2": V_L / p["terrain_m2"], "somme": V_L + C_B, "ecart_vs_cap": (V_L + C_B) / V_cap - 1, | ||||
| 236 | + "R_L_implicite": R_L_implicite, "part_terrain": V_L / (V_L + C_B), "params": p} | ||||
| 237 | + | ||||
| 238 | +def sensibilite_residuelle(t3: dict | None = None, R_Ls=(0.0325, 0.035, 0.0375, 0.04, 0.0425), ns=(30, 40, 50), deltas=(0.44, 0.52, 0.60)) -> pd.DataFrame: | ||||
| 239 | + t3 = t3 or cas3.tout(); rows = [] | ||||
| 240 | + for d in deltas: | ||||
| 241 | + for n in ns: | ||||
| 242 | + for rl in R_Ls: | ||||
| 243 | + r = residuelle_cas3({"taux_depreciation": d, "n_restante": n, "R_L": rl}, t3) | ||||
| 244 | + rows.append({"taux_depreciation": d, "C_B": r["C_B"], "n_restante": n, "R_L": rl, "R_B": r["R_B"], "V_L": r["V_L"], "V_L_m2": r["V_L_m2"]}) | ||||
| 245 | + return pd.DataFrame(rows) | ||||
| 246 | + | ||||
| 247 | +# ----------------------------------------------------------------------------- 6. lotissement (développement actualisé) | ||||
| 248 | +LOT = {"site_m2": 10_000.0, "n_lots": 12, "lot_m2": 625.0, "rue_m2": 1500.0, "parc_m2": 1000.0, "facade_m": 20.0, | ||||
| 249 | + "infrastructure_par_lot": 65_000.0, "honoraires_permis": 90_000.0, "taxes_annuelles": 12_000.0, "frais_vente_pct": 0.04, | ||||
| 250 | + "profit_pct": 0.15, "lots_par_an": 4, "taux": 0.10, "croissance_prix": 0.0} | ||||
| 251 | + | ||||
| 252 | +def lotissement(modele_terrains: dict, params: dict | None = None) -> dict: | ||||
| 253 | + """Valeur d'un site brut par la méthode du lotissement actualisé (subdivision development method). | ||||
| 254 | + Produit des ventes de lots finis au prix prédit par le modèle hédonique des terrains à la date d'évaluation, moins les coûts | ||||
| 255 | + de viabilisation, d'honoraires, de portage, de vente et le profit du promoteur ; flux de fin d'année actualisés au taux déclaré.""" | ||||
| 256 | + p = {**LOT, **(params or {})} | ||||
| 257 | + assert abs(p["n_lots"] * p["lot_m2"] + p["rue_m2"] + p["parc_m2"] - p["site_m2"]) < 1e-6 | ||||
| 258 | + lot = {"secteur": "Hull", "superficie_m2": p["lot_m2"], "facade_m": p["facade_m"], "desservi": 1, "topographie": "plat", "forme": "régulier", | ||||
| 259 | + "logements_constructibles": 1, "date_vente": DATE_EVAL} | ||||
| 260 | + prix_lot = predire_terrain(modele_terrains, lot) | ||||
| 261 | + n_ans = int(np.ceil(p["n_lots"] / p["lots_par_an"])) | ||||
| 262 | + flux, detail = [], [] | ||||
| 263 | + # t = 0 : honoraires, permis, études | ||||
| 264 | + f0 = -p["honoraires_permis"]; flux.append(f0); detail.append({"annee": 0, "lots_vendus": 0, "revenus": 0.0, "infrastructure": 0.0, "honoraires": p["honoraires_permis"], | ||||
| 265 | + "taxes": 0.0, "frais_vente": 0.0, "profit": 0.0, "flux": f0}) | ||||
| 266 | + restants = p["n_lots"] | ||||
| 267 | + for t in range(1, n_ans + 1): | ||||
| 268 | + vendus = min(p["lots_par_an"], restants); restants -= vendus | ||||
| 269 | + rev = vendus * prix_lot * (1 + p["croissance_prix"]) ** (t - 1) | ||||
| 270 | + infra = p["infrastructure_par_lot"] * p["n_lots"] if t == 1 else 0.0 | ||||
| 271 | + taxes = p["taxes_annuelles"] * (restants + vendus) / p["n_lots"] | ||||
| 272 | + fv = p["frais_vente_pct"] * rev; prof = p["profit_pct"] * rev | ||||
| 273 | + f = rev - infra - taxes - fv - prof | ||||
| 274 | + flux.append(f); detail.append({"annee": t, "lots_vendus": vendus, "revenus": rev, "infrastructure": infra, "honoraires": 0.0, "taxes": taxes, | ||||
| 275 | + "frais_vente": fv, "profit": prof, "flux": f}) | ||||
| 276 | + V = fin.van(flux, p["taux"]) | ||||
| 277 | + statique = sum(d["flux"] for d in detail) | ||||
| 278 | + return {"prix_lot": prix_lot, "produit_brut": prix_lot * p["n_lots"], "flux": flux, "detail": pd.DataFrame(detail), "valeur_actualisee": V, | ||||
| 279 | + "valeur_statique": statique, "valeur_m2": V / p["site_m2"], "valeur_statique_m2": statique / p["site_m2"], "n_ans": n_ans, | ||||
| 280 | + "ratio_terrain_brut_sur_produit": V / (prix_lot * p["n_lots"]), "params": p} | ||||
| 281 | + | ||||
| 282 | +def sensibilite_lotissement(modele_terrains: dict) -> dict: | ||||
| 283 | + base = lotissement(modele_terrains)["valeur_actualisee"] | ||||
| 284 | + out = {"base": base} | ||||
| 285 | + out["absorption"] = {k: lotissement(modele_terrains, {"lots_par_an": k})["valeur_actualisee"] for k in (2, 3, 4, 6, 12)} | ||||
| 286 | + out["taux"] = {r: lotissement(modele_terrains, {"taux": r})["valeur_actualisee"] for r in (0.08, 0.10, 0.12, 0.15)} | ||||
| 287 | + out["prix"] = {} | ||||
| 288 | + for d in (-0.10, 0.0, 0.10): | ||||
| 289 | + m2 = dict(modele_terrains); m2["duan"] = modele_terrains["duan"] * (1 + d) | ||||
| 290 | + out["prix"][d] = lotissement(m2)["valeur_actualisee"] | ||||
| 291 | + out["infrastructure"] = {c: lotissement(modele_terrains, {"infrastructure_par_lot": c})["valeur_actualisee"] for c in (50_000, 65_000, 80_000)} | ||||
| 292 | + out["profit"] = {c: lotissement(modele_terrains, {"profit_pct": c})["valeur_actualisee"] for c in (0.10, 0.15, 0.20)} | ||||
| 293 | + return out | ||||
| 294 | + | ||||
| 295 | +# ----------------------------------------------------------------------------- 7. capitalisation de la rente foncière | ||||
| 296 | +def rente_fonciere(rente: float = 11_000.0, R_L: float = 0.05, g: float = 0.02, n: int = 40, valeur_reversion: float = 300_000.0) -> dict: | ||||
| 297 | + """Terrain loué (emphytéose ou bail foncier) : V_L = rente/R_L (rente constante perpétuelle) ; V_L = rente_1/(R_L − g) (Gordon) ; | ||||
| 298 | + bail de n ans à rente croissante + réversion actualisée.""" | ||||
| 299 | + perp = rente / R_L | ||||
| 300 | + gordon = rente / fin.taux_gordon(R_L, g) | ||||
| 301 | + va_rentes = sum(rente * (1 + g) ** (t - 1) / (1 + R_L) ** t for t in range(1, n + 1)) | ||||
| 302 | + va_rev = valeur_reversion / (1 + R_L) ** n | ||||
| 303 | + return {"perpetuelle": perp, "gordon": gordon, "bail_n": va_rentes + va_rev, "va_rentes": va_rentes, "va_reversion": va_rev, "n": n} | ||||
| 304 | + | ||||
| 305 | +# ----------------------------------------------------------------------------- 8. synthèse et valeur retenue | ||||
| 306 | +def synthese() -> dict: | ||||
| 307 | + df = charger_terrains(); comp = comparaison(df); cas = cas1.tout(); extr = extraction_cas1(cas); allo = allocation_cas1(cas, extr) | ||||
| 308 | + t3 = cas3.tout(); res = residuelle_cas3(t3=t3); lot = lotissement(comp["modele"]); rf = rente_fonciere() | ||||
| 309 | + retenue = round(comp["reconciliation"]["valeur_ponderee"] / 5000) * 5000 | ||||
| 310 | + return {"comparaison": comp, "cas1": cas, "extraction": extr, "allocation": allo, "cas3": t3, "residuelle": res, "lotissement": lot, | ||||
| 311 | + "rente": rf, "valeur_retenue": retenue, "part_retenue_grille": retenue / allo["P_grille"], "part_retenue_hed": retenue / allo["P_hed"]} | ||||
| 312 | + | ||||
| 313 | +def _d(x): return f"{x:,.0f} $".replace(",", " ") | ||||
| 314 | + | ||||
| 315 | +def main() -> dict: | ||||
| 316 | + S = synthese(); c = S["comparaison"]; m = c["modele"]; df = c["terrains"] | ||||
| 317 | + print("=== 1. Jeu synthétique de terrains (ch25_terrains_ventes.csv, graine 20261031) ===") | ||||
| 318 | + print(f"n = {len(df)} ; desservis = {int(df.desservi.sum())} ; période {df.date_vente.min().date()} → {df.date_vente.max().date()}") | ||||
| 319 | + print(df.groupby("secteur").agg(n=("prix_vente", "size"), T_med=("superficie_m2", "median"), prix_med=("prix_vente", "median"), prix_m2_med=("prix_m2", "median")).round(0)) | ||||
| 320 | + print(df.groupby("desservi")[["superficie_m2", "prix_vente", "prix_m2"]].median().round(0)) | ||||
| 321 | + print("\n=== 2. Modèle hédonique des terrains ===") | ||||
| 322 | + print(m["coef"].round(4)); print(f"R² = {m['r2']:.3f} ; σ = {m['sigma']:.3f} ; Duan = {m['duan']:.4f}") | ||||
| 323 | + print("\n=== 3. Comparaison directe — sujet 620 m², Hull ===") | ||||
| 324 | + print("Entonnoir :", c["selection"]["etapes"]) | ||||
| 325 | + comps = c["selection"]["comparables"] | ||||
| 326 | + print(comps[["id_vente", "date_vente", "secteur", "superficie_m2", "facade_m", "topographie", "forme", "dist_km", "mois_anc", "prix_vente", "prix_m2", "distance"]].round(2).to_string()) | ||||
| 327 | + print(c["grille"].round(0).to_string()) | ||||
| 328 | + r = c["reconciliation"] | ||||
| 329 | + print(f"Poids (inverse du brut) : {np.round(r['poids'], 3)} ; valeur pondérée {_d(r['valeur_ponderee'])} ; moyenne {_d(r['moyenne'])} ; médiane {_d(r['mediane'])} ; " | ||||
| 330 | + f"min {_d(r['min'])} ; max {_d(r['max'])} ; étendue {r['etendue_pct']*100:.1f} % ; {_d(c['valeur_m2'])}/m²") | ||||
| 331 | + print(f"Prédiction hédonique directe du terrain du sujet : {_d(c['prediction_hedonique'])}") | ||||
| 332 | + print("\n=== 4. Allocation et prix implicite marginal (cas 1) ===") | ||||
| 333 | + a = S["allocation"]; e = S["extraction"] | ||||
| 334 | + print(f"Part du terrain, ventes de Hull (extraction, dépréciation de marché) : médiane {a['ratio']*100:.1f} % (Q1 {e['part_hull_q1']*100:.1f} %, Q3 {e['part_hull_q3']*100:.1f} %) ; V_L médian Hull {_d(e['V_L_hull_mediane'])}") | ||||
| 335 | + print(f"Allocation sur la grille du ch. 17 ({_d(a['P_grille'])}) : {_d(a['sur_grille'])} ; sur la prédiction hédonique ({_d(a['P_hed'])}) : {_d(a['sur_hedonique'])}") | ||||
| 336 | + print(f"β(ln T) = {a['beta_ln_T']:.4f} → prix implicite marginal {a['prix_marginal_m2']:.1f} $/m² ; contribution marginale β × P = {_d(a['contribution_marginale'])}") | ||||
| 337 | + print(f"Facteur d'âge du sujet (28 ans) : {a['sujet_age_factor']:.4f} → dépréciation implicite {a['delta_marche_sujet']*100:.1f} % de C_N") | ||||
| 338 | + print("\n=== 5. Extraction sur les 5 comparables du ch. 16 ===") | ||||
| 339 | + cols = ["id_vente", "prix_vente", "superficie_habitable_m2", "superficie_terrain_m2", "age", "qualite_1a5", "C_N", "delta_agevie", "V_L_agevie", "V_L_agevie_m2", | ||||
| 340 | + "P_neuf", "delta_marche", "V_L_marche", "V_L_marche_m2", "V_L_agevie_sujet", "V_L_marche_sujet"] | ||||
| 341 | + print(e["comparables"][cols].round(3).to_string()) | ||||
| 342 | + print(f"Indication (médiane transposée à 620 m²) : âge-vie 60 ans {_d(e['indication_agevie'])} ; dépréciation de marché {_d(e['indication_marche'])}") | ||||
| 343 | + print("\n=== 6. Technique résiduelle du terrain (cas 3) ===") | ||||
| 344 | + rs = S["residuelle"] | ||||
| 345 | + print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in rs.items() if k != "params"}) | ||||
| 346 | + sens = sensibilite_residuelle(S["cas3"]); print(sens.pivot_table(index=["taux_depreciation", "n_restante"], columns="R_L", values="V_L").round(0)) | ||||
| 347 | + print("\n=== 7. Lotissement actualisé ===") | ||||
| 348 | + L = S["lotissement"]; print(f"Prix d'un lot fini (625 m², Hull, {DATE_EVAL.date()}) : {_d(L['prix_lot'])} ; produit brut {_d(L['produit_brut'])}") | ||||
| 349 | + print(L["detail"].round(0).to_string()) | ||||
| 350 | + print(f"Valeur actualisée du site brut : {_d(L['valeur_actualisee'])} ({L['valeur_m2']:.1f} $/m²) ; statique {_d(L['valeur_statique'])} ({L['valeur_statique_m2']:.1f} $/m²) ; " | ||||
| 351 | + f"ratio terrain brut / produit {L['ratio_terrain_brut_sur_produit']*100:.1f} %") | ||||
| 352 | + sl = sensibilite_lotissement(m) | ||||
| 353 | + for k, v in sl.items(): | ||||
| 354 | + if k != "base": print(k, {kk: round(vv) for kk, vv in v.items()}) | ||||
| 355 | + print("\n=== 8. Rente foncière ===") | ||||
| 356 | + print({k: round(v, 0) if isinstance(v, float) else v for k, v in S["rente"].items()}) | ||||
| 357 | + print("\n=== 9. Synthèse — sujet du cas 1 ===") | ||||
| 358 | + print(f"Comparaison (terrains) {_d(r['valeur_ponderee'])} ; extraction (marché) {_d(e['indication_marche'])} ; extraction (âge-vie 60) {_d(e['indication_agevie'])} ; " | ||||
| 359 | + f"allocation (grille) {_d(a['sur_grille'])} ; allocation (hédonique) {_d(a['sur_hedonique'])}") | ||||
| 360 | + print(f"VALEUR DU TERRAIN RETENUE : {_d(S['valeur_retenue'])} ({S['valeur_retenue']/620:.0f} $/m²) ; part de la grille {S['part_retenue_grille']*100:.1f} % ; part de la prédiction hédonique {S['part_retenue_hed']*100:.1f} %") | ||||
| 361 | + return S | ||||
| 362 | + | ||||
| 363 | +if __name__ == "__main__": | ||||
| 364 | + main() | ||||
added
analysis/python/cases/ch26_etat_locatif.py
+318 −0
@@ -0,0 +1,318 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 26 — Baux et loyers : calculs de l'exemple intégral (cas 3, 12 logements à Montréal ; cas 2, triplex à Québec ; | ||||
| 3 | +DONNÉES SYNTHÉTIQUES) et des exercices. | ||||
| 4 | + | ||||
| 5 | +Tous les chiffres cités dans manuscript/chapters/ch26.tex et solutions/ch26.tex sortent d'ici. Les revenus du cas 3 et du | ||||
| 6 | +cas 2 viennent des modules partagés traite.cas3 / traite.cas2 (fonctions `charger`, `revenus`, `rne`, `taux_extraits`, | ||||
| 7 | +`indicateurs_marche`) afin de coïncider avec les chapitres 2, 27-31 et 46-47 ; les facteurs d'actualisation viennent de | ||||
| 8 | +traite.finance. La formule fermée de la valeur actuelle de l'écart contractuel/marché sous rotation géométrique est celle | ||||
| 9 | +du chapitre 2 (éq. « rotation-va ») ; elle est appliquée ici logement par logement. | ||||
| 10 | + | ||||
| 11 | +Hypothèses de travail propres au chapitre (déclarées comme telles dans le texte, pas des statistiques) : | ||||
| 12 | + - profil mensuel de consommation d'électricité d'un 4½ chauffé à l'électricité, 12 000 kWh/an (PROFIL_KWH) ; | ||||
| 13 | + - loyer « chauffé-éclairé » d'un comparable fictif : 1 450 $/mois (COMPARABLE_CHAUFFE) ; | ||||
| 14 | + - structure du tarif D d'Hydro-Québec en vigueur le 1er avril 2026 (source : hydrotarifs2026, section 2.5). | ||||
| 15 | + | ||||
| 16 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch26_etat_locatif.py | ||||
| 17 | +""" | ||||
| 18 | +from __future__ import annotations | ||||
| 19 | +import sys, pathlib, datetime as dt | ||||
| 20 | +import numpy as np, pandas as pd | ||||
| 21 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 22 | +from traite import cas3, cas2, finance as fin | ||||
| 23 | + | ||||
| 24 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 25 | +DATE_EVAL = dt.date(2026, 9, 1) | ||||
| 26 | + | ||||
| 27 | +# ----------------------------------------------------------------------------- tarif D (Hydro-Québec, 1er avril 2026) | ||||
| 28 | +TARIF_D = {"redevance_par_jour": 0.46154, "prix_tranche1": 0.07065, "seuil_kwh_par_jour": 40.0, "prix_tranche2": 0.11142} | ||||
| 29 | +# Hypothèse de travail (pas une statistique) : consommation mensuelle d'un 4½ de 70 m² chauffé à l'électricité, 12 000 kWh/an. | ||||
| 30 | +MOIS = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."] | ||||
| 31 | +JOURS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] | ||||
| 32 | +PROFIL_KWH = [1900, 1700, 1400, 1000, 700, 550, 500, 500, 600, 850, 1150, 1150] | ||||
| 33 | +COMPARABLE_CHAUFFE = 1450.0 # $/mois, loyer « chauffé-éclairé » d'un comparable fictif (hypothèse pédagogique) | ||||
| 34 | +GESTION = 0.04 # convention du cas 3 : gestion imputée à 4 % du RBE | ||||
| 35 | + | ||||
| 36 | + | ||||
| 37 | +# ----------------------------------------------------------------------------- état locatif et indicateurs | ||||
| 38 | +def tableau_etat(etat: pd.DataFrame, date_eval: dt.date = DATE_EVAL) -> pd.DataFrame: | ||||
| 39 | + """État locatif enrichi : ancienneté (années), écarts mensuel/annuel, ratio contrat/marché, mois avant l'échéance.""" | ||||
| 40 | + t = etat.copy() | ||||
| 41 | + t["anciennete"] = date_eval.year - t["locataire_depuis"].astype(int) | ||||
| 42 | + t["ecart_mensuel"] = t["loyer_mensuel_marche"] - t["loyer_mensuel_contrat"] | ||||
| 43 | + t["ecart_annuel"] = 12 * t["ecart_mensuel"] | ||||
| 44 | + t["ecart_pct"] = t["ecart_mensuel"] / t["loyer_mensuel_marche"] | ||||
| 45 | + t["ratio"] = t["loyer_mensuel_contrat"] / t["loyer_mensuel_marche"] | ||||
| 46 | + ech = pd.to_datetime(t["echeance_bail"]).dt.date | ||||
| 47 | + t["mois_echeance"] = [round((e - date_eval).days / 30.4375, 1) for e in ech] | ||||
| 48 | + t["loyer_m2_contrat"] = t["loyer_mensuel_contrat"] / t["superficie_m2"] | ||||
| 49 | + t["loyer_m2_marche"] = t["loyer_mensuel_marche"] / t["superficie_m2"] | ||||
| 50 | + return t | ||||
| 51 | + | ||||
| 52 | + | ||||
| 53 | +def indicateurs(etat: pd.DataFrame, hyp: dict, seuils=(0.05, 0.10, 0.20)) -> dict: | ||||
| 54 | + """Indicateurs synthétiques de l'état locatif (éq. 26.1 à 26.4 du chapitre).""" | ||||
| 55 | + t = tableau_etat(etat) | ||||
| 56 | + r = cas3.revenus(etat, hyp) # RBP contrat / marché / écart (module partagé) | ||||
| 57 | + lc, lm = t["loyer_mensuel_contrat"].astype(float), t["loyer_mensuel_marche"].astype(float) | ||||
| 58 | + out = {**r, "n": len(t), | ||||
| 59 | + "ecart_pondere": float((lm - lc).sum() / lm.sum()), # δ̄ = Σ(Lm − Lc) / Σ Lm | ||||
| 60 | + "ratio_pondere": float(lc.sum() / lm.sum()), | ||||
| 61 | + "ratio_moyen_simple": float(t["ratio"].mean()), | ||||
| 62 | + "ecart_mensuel_moyen": float((lm - lc).mean()), | ||||
| 63 | + "ecart_mensuel_median": float((lm - lc).median()), | ||||
| 64 | + "anciennete_moyenne": float(t["anciennete"].mean()), "anciennete_mediane": float(t["anciennete"].median()), | ||||
| 65 | + "anciennete_max": int(t["anciennete"].max()), | ||||
| 66 | + "loyer_moyen_contrat": float(lc.mean()), "loyer_moyen_marche": float(lm.mean()), | ||||
| 67 | + "loyer_m2_contrat_moyen": float(lc.sum() / t["superficie_m2"].sum()), "loyer_m2_marche_moyen": float(lm.sum() / t["superficie_m2"].sum()), | ||||
| 68 | + "part_ecart_top3": float((lm - lc).sort_values(ascending=False).head(3).sum() / (lm - lc).sum()), | ||||
| 69 | + "nb_stationnements": int(t["stationnement"].sum()), "tableau": t} | ||||
| 70 | + for s in seuils: | ||||
| 71 | + out[f"part_sous_marche_{int(s*100)}"] = float((t["ratio"] < 1 - s).mean()) | ||||
| 72 | + out[f"nb_sous_marche_{int(s*100)}"] = int((t["ratio"] < 1 - s).sum()) | ||||
| 73 | + return out | ||||
| 74 | + | ||||
| 75 | + | ||||
| 76 | +# ----------------------------------------------------------------------------- rotation, survie et valeur de l'écart | ||||
| 77 | +def survie(rho: float, t: int | np.ndarray) -> float | np.ndarray: | ||||
| 78 | + """Probabilité qu'un locataire en place le soit encore après t années (départs géométriques, taux ρ).""" | ||||
| 79 | + return (1 - rho) ** t | ||||
| 80 | + | ||||
| 81 | + | ||||
| 82 | +def esperance_duree(rho: float) -> float: | ||||
| 83 | + """Durée espérée de l'occupation restante, E[n] = Σ t ρ(1−ρ)^(t−1) = 1/ρ (années ; départ compté en fin d'année).""" | ||||
| 84 | + if not 0 < rho <= 1: | ||||
| 85 | + raise ValueError("ρ doit être dans ]0, 1]") | ||||
| 86 | + return 1.0 / rho | ||||
| 87 | + | ||||
| 88 | + | ||||
| 89 | +def va_ecart(delta1: float, r: float, g: float, rho: float) -> float: | ||||
| 90 | + """VA espérée de l'écart contractuel/marché d'un logement : Δ₁ reçu avec certitude l'année 1, puis l'écart croît à g et | ||||
| 91 | + survit avec probabilité (1−ρ) chaque année → Δ₁ / (r − g + ρ(1+g)) (formule fermée du chapitre 2, éq. rotation-va). | ||||
| 92 | + Condition de convergence : (1−ρ)(1+g) < 1+r.""" | ||||
| 93 | + if (1 - rho) * (1 + g) >= 1 + r: | ||||
| 94 | + raise ValueError("série divergente : (1−ρ)(1+g) ≥ 1+r") | ||||
| 95 | + return delta1 / (r - g + rho * (1 + g)) | ||||
| 96 | + | ||||
| 97 | + | ||||
| 98 | +def va_ecart_serie(delta1: float, r: float, g: float, rho: float, T: int = 400) -> float: | ||||
| 99 | + """Même valeur par sommation explicite (vérification numérique de la formule fermée).""" | ||||
| 100 | + t = np.arange(1, T + 1) | ||||
| 101 | + return float(np.sum(delta1 * ((1 - rho) * (1 + g)) ** (t - 1) / (1 + r) ** t)) | ||||
| 102 | + | ||||
| 103 | + | ||||
| 104 | +def va_annuite(delta: float, r: float, n: float) -> float: | ||||
| 105 | + """Annuité d'un écart constant pendant n années (éq. « annuite » du chapitre 2) ; n peut être non entier.""" | ||||
| 106 | + return delta * (1 - (1 + r) ** (-n)) / r | ||||
| 107 | + | ||||
| 108 | + | ||||
| 109 | +def va_ecart_logements(etat: pd.DataFrame, hyp: dict, rhos=(0.085, 0.094, 0.15), r: float | None = None, g: float | None = None) -> pd.DataFrame: | ||||
| 110 | + """VA de l'écart par logement, nette de l'inoccupation (convention du chapitre 2 : Δ₁ = 12 × écart mensuel × (1 − v)).""" | ||||
| 111 | + t = tableau_etat(etat); r = hyp["taux_actualisation"] if r is None else r; g = hyp["croissance_loyers_marche"] if g is None else g | ||||
| 112 | + v = hyp["taux_inoccupation_pertes"] | ||||
| 113 | + out = t[["logement", "pieces", "anciennete", "ecart_mensuel", "ecart_annuel"]].copy() | ||||
| 114 | + out["delta1_net"] = out["ecart_annuel"] * (1 - v) | ||||
| 115 | + for rho in rhos: | ||||
| 116 | + out[f"va_rho_{rho:.3f}"] = [va_ecart(d, r, g, rho) for d in out["delta1_net"]] | ||||
| 117 | + out["va_perpetuel"] = out["delta1_net"] / (r - g) | ||||
| 118 | + return out | ||||
| 119 | + | ||||
| 120 | + | ||||
| 121 | +def sensibilite_va(etat: pd.DataFrame, hyp: dict, rhos=(0.05, 0.085, 0.094, 0.15, 0.25), rs=(0.0625, 0.0725, 0.0825), gs=(0.025, 0.035, 0.045)) -> dict: | ||||
| 122 | + """VA agrégée de l'écart (12 logements) selon ρ × r (g du cas) et ρ × g (r du cas).""" | ||||
| 123 | + t = tableau_etat(etat); v = hyp["taux_inoccupation_pertes"]; d1 = float(t["ecart_annuel"].sum() * (1 - v)) | ||||
| 124 | + g0, r0 = hyp["croissance_loyers_marche"], hyp["taux_actualisation"] | ||||
| 125 | + tab_r = pd.DataFrame({f"r={r:.4f}": [va_ecart(d1, r, g0, rho) for rho in rhos] for r in rs}, index=[f"rho={rho:.3f}" for rho in rhos]) | ||||
| 126 | + tab_g = pd.DataFrame({f"g={g:.3f}": [va_ecart(d1, r0, g, rho) for rho in rhos] for g in gs}, index=[f"rho={rho:.3f}" for rho in rhos]) | ||||
| 127 | + return {"delta1_net": d1, "par_taux": tab_r, "par_croissance": tab_g, "sans_rotation": d1 / (r0 - g0)} | ||||
| 128 | + | ||||
| 129 | + | ||||
| 130 | +# ----------------------------------------------------------------------------- loyer économique | ||||
| 131 | +def loyer_economique(base: dict, R: float, V: float, n_logements: int, m: float = GESTION) -> dict: | ||||
| 132 | + """Loyer économique (loyer de faisabilité) : RBP requis pour que le RNE après réserve rémunère la valeur V au taux R, | ||||
| 133 | + compte tenu des dépenses hors gestion DE_hg, de la réserve RR, des autres revenus A, de l'inoccupation v et d'une | ||||
| 134 | + gestion à m × RBE : RNE* = R·V ; RBE* = (RNE* + DE_hg + RR)/(1 − m) ; RBP* = (RBE* − A)/(1 − v).""" | ||||
| 135 | + det = base["detail"]; v = base["taux_vacance_pertes"]; A = base["autres_revenus"] | ||||
| 136 | + gestion = float(det.loc[det["poste"].str.startswith("Gestion"), "normalise"].sum()) | ||||
| 137 | + de_hg = base["depenses_hors_reserve"] - gestion; RR = base["reserve"] | ||||
| 138 | + rne_req = R * V; rbe_req = (rne_req + de_hg + RR) / (1 - m); rbp_req = (rbe_req - A) / (1 - v) | ||||
| 139 | + return {"R": R, "V": V, "rne_requis": rne_req, "rbe_requis": rbe_req, "rbp_requis": rbp_req, "de_hors_gestion": de_hg, "reserve": RR, | ||||
| 140 | + "loyer_economique_mensuel": rbp_req / (12 * n_logements), "loyer_contrat_mensuel": base["rbp_contrat"] / (12 * n_logements), | ||||
| 141 | + "loyer_marche_mensuel": base["rbp_marche"] / (12 * n_logements)} | ||||
| 142 | + | ||||
| 143 | + | ||||
| 144 | +# ----------------------------------------------------------------------------- normalisation : électricité (tarif D) | ||||
| 145 | +def cout_electricite_mensuel(kwh: float, jours: int, tarif: dict = TARIF_D) -> dict: | ||||
| 146 | + """Coût mensuel hors taxes de vente selon la structure du tarif D : redevance × jours + 1re tranche (≤ 40 kWh/jour) + 2e tranche.""" | ||||
| 147 | + seuil = tarif["seuil_kwh_par_jour"] * jours | ||||
| 148 | + t1 = min(kwh, seuil); t2 = max(kwh - seuil, 0.0) | ||||
| 149 | + red = tarif["redevance_par_jour"] * jours | ||||
| 150 | + return {"kwh": kwh, "jours": jours, "redevance": red, "tranche1_kwh": t1, "tranche2_kwh": t2, | ||||
| 151 | + "cout_tranche1": t1 * tarif["prix_tranche1"], "cout_tranche2": t2 * tarif["prix_tranche2"], | ||||
| 152 | + "total": red + t1 * tarif["prix_tranche1"] + t2 * tarif["prix_tranche2"]} | ||||
| 153 | + | ||||
| 154 | + | ||||
| 155 | +def normalisation_electricite(profil=PROFIL_KWH, jours=JOURS, loyer_chauffe: float = COMPARABLE_CHAUFFE, tarif: dict = TARIF_D) -> dict: | ||||
| 156 | + rows = [{"mois": m, **cout_electricite_mensuel(k, j, tarif)} for m, k, j in zip(MOIS, profil, jours)] | ||||
| 157 | + tab = pd.DataFrame(rows) | ||||
| 158 | + annuel = float(tab["total"].sum()); mensuel = annuel / 12 | ||||
| 159 | + return {"tableau": tab, "kwh_annuel": float(sum(profil)), "cout_annuel": annuel, "cout_mensuel_moyen": mensuel, | ||||
| 160 | + "cout_moyen_kwh": annuel / sum(profil), "part_redevance": float(tab["redevance"].sum() / annuel), | ||||
| 161 | + "kwh_tranche2": float(tab["tranche2_kwh"].sum()), "mois_depassement": int((tab["tranche2_kwh"] > 0).sum()), | ||||
| 162 | + "loyer_chauffe": loyer_chauffe, "loyer_nu": loyer_chauffe - mensuel, | ||||
| 163 | + "cout_mensuel_indexe_2027": mensuel * 1.03} # hausse domestique 3,0 % (Régie, D-2026-033) appliquée à toute la facture, approximation | ||||
| 164 | + | ||||
| 165 | + | ||||
| 166 | +# ----------------------------------------------------------------------------- calendrier des avis | ||||
| 167 | +def _moins_mois(d: dt.date, k: int) -> dt.date: | ||||
| 168 | + """Date d, moins k mois (même quantième, borné à la fin du mois).""" | ||||
| 169 | + y, m = d.year, d.month - k | ||||
| 170 | + while m <= 0: | ||||
| 171 | + m += 12; y -= 1 | ||||
| 172 | + while m > 12: | ||||
| 173 | + m -= 12; y += 1 | ||||
| 174 | + import calendar | ||||
| 175 | + return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1])) | ||||
| 176 | + | ||||
| 177 | + | ||||
| 178 | +def calendrier(echeance: dt.date = dt.date(2027, 6, 30), duree_mois: int = 12) -> dict: | ||||
| 179 | + """Fenêtres légales (C.c.Q.) pour un bail à durée fixe se terminant à `echeance` : | ||||
| 180 | + avis de modification 3 à 6 mois avant le terme (art. 1942 ; 1 à 2 mois si bail < 12 mois) ; réponse du locataire dans le | ||||
| 181 | + mois (1945) ; demande de fixation dans le mois du refus (1947) ; avis de reprise/éviction 6 mois avant (1960 ; 1 mois si | ||||
| 182 | + bail ≤ 6 mois) ; nouveau locataire : 10 jours (1950) après la conclusion du bail suivant.""" | ||||
| 183 | + if duree_mois >= 12: | ||||
| 184 | + a_min, a_max = 3, 6 | ||||
| 185 | + else: | ||||
| 186 | + a_min, a_max = 1, 2 | ||||
| 187 | + debut_fenetre, fin_fenetre = _moins_mois(echeance, a_max), _moins_mois(echeance, a_min) | ||||
| 188 | + reprise = _moins_mois(echeance, 6 if duree_mois > 6 else 1) | ||||
| 189 | + reponse_si_fin = _moins_mois(echeance, a_min - 1) # avis reçu au dernier jour permis → réponse dans le mois | ||||
| 190 | + demande_tal = _moins_mois(echeance, a_min - 2) # refus au dernier jour → demande dans le mois | ||||
| 191 | + debut_suivant = echeance + dt.timedelta(days=1) | ||||
| 192 | + return {"echeance": echeance, "avis_modification_debut": debut_fenetre, "avis_modification_fin": fin_fenetre, | ||||
| 193 | + "reponse_locataire_fin_si_avis_tardif": reponse_si_fin, "demande_fixation_fin_si_refus_tardif": demande_tal, | ||||
| 194 | + "avis_reprise_eviction_fin": reprise, "debut_bail_suivant": debut_suivant, | ||||
| 195 | + "nouveau_locataire_fixation_10j": debut_suivant + dt.timedelta(days=10), "nouveau_locataire_sans_avis_2mois": _moins_mois(debut_suivant, -2)} | ||||
| 196 | + | ||||
| 197 | + | ||||
| 198 | +# ----------------------------------------------------------------------------- loyer maximal des cinq ans (art. 1955) | ||||
| 199 | +def croissance_max_1955(loyer_initial: float, loyer_maximal: float, annees: int = 5) -> float: | ||||
| 200 | + """Taux annuel composé maximal compatible avec le loyer maximal indiqué au bail (art. 1955 C.c.Q., 2024, c. 2).""" | ||||
| 201 | + return (loyer_maximal / loyer_initial) ** (1 / annees) - 1 | ||||
| 202 | + | ||||
| 203 | + | ||||
| 204 | +# ----------------------------------------------------------------------------- bail commercial : loyer net équivalent | ||||
| 205 | +def loyer_net_equivalent(loyer_brut_annuel: float, depenses_recuperables: float, superficie_m2: float) -> dict: | ||||
| 206 | + net = loyer_brut_annuel - depenses_recuperables | ||||
| 207 | + return {"loyer_brut": loyer_brut_annuel, "recuperables": depenses_recuperables, "loyer_net": net, | ||||
| 208 | + "brut_m2": loyer_brut_annuel / superficie_m2, "net_m2": net / superficie_m2} | ||||
| 209 | + | ||||
| 210 | + | ||||
| 211 | +# ----------------------------------------------------------------------------- cas 2 (triplex) | ||||
| 212 | +def cas2_etat() -> dict: | ||||
| 213 | + d = cas2.charger(); l = d["loyers"].copy(); r = cas2.revenus(l) | ||||
| 214 | + l["anciennete"] = DATE_EVAL.year - l["locataire_depuis"].astype(int) | ||||
| 215 | + l["ecart_mensuel"] = l["loyer_marche_estime"] - l["loyer_mensuel"]; l["ratio"] = l["loyer_mensuel"] / l["loyer_marche_estime"] | ||||
| 216 | + return {**r, "tableau": l, "ecart_pondere": float(l["ecart_mensuel"].sum() / l["loyer_marche_estime"].sum()), | ||||
| 217 | + "anciennete_moyenne": float(l["anciennete"].mean()), "nb_sous_marche_10": int((l["ratio"] < 0.9).sum()), "sujet": d["sujet"], "data": d} | ||||
| 218 | + | ||||
| 219 | + | ||||
| 220 | +# ----------------------------------------------------------------------------- tout | ||||
| 221 | +def calculer() -> dict: | ||||
| 222 | + d = cas3.charger(); etat, dep, hyp, sujet = d["etat"], d["dep"], d["hyp"], d["sujet"] | ||||
| 223 | + ind = indicateurs(etat, hyp); base = cas3.rne(etat, dep, hyp); tx = cas3.taux_extraits(d["comps"]) | ||||
| 224 | + R = tx["mediane"]; V_cap = fin.capitalisation_directe(base["rne"], R) | ||||
| 225 | + econ_role = loyer_economique(base, R, hyp["valeur_role_2026"], sujet["nb_logements"]) | ||||
| 226 | + econ_cap = loyer_economique(base, R, V_cap, sujet["nb_logements"]) | ||||
| 227 | + va = va_ecart_logements(etat, hyp); sens = sensibilite_va(etat, hyp) | ||||
| 228 | + r, g, rho = hyp["taux_actualisation"], hyp["croissance_loyers_marche"], hyp["rotation_annuelle"] | ||||
| 229 | + va_agregee_cas = va_ecart(sens["delta1_net"], r, g, rho) # = 102 188 $ (chapitre 2, ρ = 15 %) | ||||
| 230 | + norm = normalisation_electricite(); cal = calendrier() | ||||
| 231 | + return {"etat": etat, "hyp": hyp, "sujet": sujet, "ind": ind, "base": base, "R": R, "V_cap": V_cap, | ||||
| 232 | + "econ_role": econ_role, "econ_cap": econ_cap, "va": va, "sens": sens, "va_agregee_cas": va_agregee_cas, | ||||
| 233 | + "esperance_duree": {rho_: esperance_duree(rho_) for rho_ in (0.085, 0.094, 0.15, 0.13)}, | ||||
| 234 | + "norm": norm, "cal": cal, "cas2": cas2_etat()} | ||||
| 235 | + | ||||
| 236 | + | ||||
| 237 | +# ----------------------------------------------------------------------------- exercices | ||||
| 238 | +def exercices(res: dict | None = None) -> dict: | ||||
| 239 | + res = res or calculer(); hyp = res["hyp"]; ex = {} | ||||
| 240 | + # 26.1 — délais : bail 12 mois échéant le 30 juin 2027 et bail de 6 mois échéant le 31 décembre 2026 | ||||
| 241 | + ex["1"] = {"bail12": calendrier(dt.date(2027, 6, 30), 12), "bail6": calendrier(dt.date(2026, 12, 31), 6)} | ||||
| 242 | + # 26.2 — indicateurs d'un état locatif fictif de six logements (énoncé) | ||||
| 243 | + e2 = pd.DataFrame({"logement": ["1", "2", "3", "4", "5", "6"], "loyer_mensuel_contrat": [850, 925, 1180, 1240, 1410, 1495], | ||||
| 244 | + "loyer_mensuel_marche": [1100, 1100, 1300, 1300, 1550, 1550], "locataire_depuis": [2012, 2016, 2021, 2025, 2019, 2024], | ||||
| 245 | + "superficie_m2": [52, 52, 70, 70, 92, 92], "echeance_bail": ["2027-06-30"] * 6, "chauffage_inclus": [0] * 6, "stationnement": [1, 0, 1, 0, 1, 0]}) | ||||
| 246 | + hyp2 = {**hyp, "autres_revenus_annuels": 0.0, "taux_inoccupation_pertes": 0.03} | ||||
| 247 | + ex["2"] = indicateurs(e2, hyp2) | ||||
| 248 | + # 26.3 — normalisation : comparable chauffé-éclairé 1 450 $, profil de consommation donné ; variante 1 500 $ et 14 000 kWh (profil × 14/12) | ||||
| 249 | + ex["3"] = {"base": normalisation_electricite(), "variante": normalisation_electricite(profil=[k * 14000 / 12000 for k in PROFIL_KWH], loyer_chauffe=1500.0)} | ||||
| 250 | + # 26.4 — VA de l'écart du logement 103 (319 $/mois) : ρ = 9,4 % vs 15 % ; annuité sur E[n] = 1/ρ années ; perpétuel | ||||
| 251 | + d103 = 12 * 319 * (1 - hyp["taux_inoccupation_pertes"]); r, g = hyp["taux_actualisation"], hyp["croissance_loyers_marche"] | ||||
| 252 | + ex["4"] = {"delta1_net": d103, "va_094": va_ecart(d103, r, g, 0.094), "va_150": va_ecart(d103, r, g, 0.15), | ||||
| 253 | + "E_n_094": esperance_duree(0.094), "E_n_150": esperance_duree(0.15), | ||||
| 254 | + "annuite_094": va_annuite(d103, r, esperance_duree(0.094)), "annuite_150": va_annuite(d103, r, esperance_duree(0.15)), | ||||
| 255 | + "perpetuel": d103 / (r - g), "serie_094": va_ecart_serie(d103, r, g, 0.094)} | ||||
| 256 | + # 26.5 — loyer économique du triplex (cas 2) sur la valeur au rôle 495 000 $ et sur la valeur par capitalisation | ||||
| 257 | + c2 = res["cas2"]["data"]; base2 = cas2.rne(c2["loyers"], c2["depenses"]); comp2 = cas2.comparables_plex(c2["ventes"], c2["sujet"]); ind2 = cas2.indicateurs_marche(comp2) | ||||
| 258 | + R2 = ind2["taux_median"]; V2_role = c2["sujet"]["valeur_role_2025"]; V2_cap = fin.capitalisation_directe(base2["rne"], R2) | ||||
| 259 | + det2 = base2["detail"]; gestion2 = float(det2.loc[det2["type"] == "imputée", "normalise"].sum()); de_hg2 = base2["depenses_hors_reserve"] - gestion2 | ||||
| 260 | + def econ2(V): | ||||
| 261 | + rne_req = R2 * V; rbe_req = (rne_req + de_hg2 + base2["reserve"]) / (1 - cas2.GESTION_IMPUTEE); rbp_req = rbe_req / (1 - cas2.TAUX_VACANCE_PERTES) | ||||
| 262 | + return {"V": V, "rne_requis": rne_req, "rbe_requis": rbe_req, "rbp_requis": rbp_req, "loyer_mensuel_moyen": rbp_req / 36} | ||||
| 263 | + ex["5"] = {"R": R2, "base": base2, "de_hors_gestion": de_hg2, "role": econ2(V2_role), "cap": econ2(V2_cap), "V_cap": V2_cap, | ||||
| 264 | + "rbp_contrat": base2["rbp_contrat"], "rbp_marche": base2["rbp_marche"]} | ||||
| 265 | + # 26.6 — immeuble neuf (art. 1955) : loyer 1 600 $, loyer maximal 1 850 $ indiqué au bail ; trois trajectoires sur cinq ans : | ||||
| 266 | + # (a) indexation au taux de base 3,1 % (sans clause : fixation possible), (b) saut au loyer maximal dès la 2e année (borne haute permise | ||||
| 267 | + # par la clause), (c) croissance régulière au taux g_max ; VA des loyers des années 1 à 5 au taux r du cas (7,25 %). | ||||
| 268 | + L0, Lmax, gtal = 1600.0, 1850.0, 0.031 | ||||
| 269 | + gmax = croissance_max_1955(L0, Lmax) | ||||
| 270 | + traj_tal = [L0 * (1 + gtal) ** t for t in range(0, 5)] | ||||
| 271 | + traj_saut = [L0] + [Lmax] * 4 | ||||
| 272 | + traj_reg = [L0 * (1 + gmax) ** t for t in range(0, 5)] | ||||
| 273 | + va = lambda traj: sum(12 * traj[t - 1] / (1 + r) ** t for t in range(1, 6)) | ||||
| 274 | + ex["6"] = {"g_max": gmax, "traj_tal": traj_tal, "traj_saut": traj_saut, "traj_reg": traj_reg, "va_tal": va(traj_tal), "va_saut": va(traj_saut), "va_reg": va(traj_reg), | ||||
| 275 | + "loyer_an5_tal": traj_tal[4], "depasse_tal": traj_tal[4] > Lmax, "annee_depassement": next((t + 1 for t, L in enumerate(traj_tal) if L > Lmax), None), | ||||
| 276 | + "loyer_an6_tal": L0 * (1 + gtal) ** 5} | ||||
| 277 | + # 26.7 — bail commercial au rez-de-chaussée : brut 36 000 $/an, récupérables 9 500 $, 110 m², 5 ans fixes, option 5 ans ; VA à 7,25 % | ||||
| 278 | + lne = loyer_net_equivalent(36000.0, 9500.0, 110.0) | ||||
| 279 | + va_com = fin.van([0.0] + [lne["loyer_net"]] * 5, r) | ||||
| 280 | + loyer_res = 1292.0 * 12 * (1 - hyp["taux_inoccupation_pertes"]) # logement de marché moyen du cas 3 (1 292 $) indexé à g | ||||
| 281 | + va_res = sum(loyer_res * (1 + g) ** (t - 1) / (1 + r) ** t for t in range(1, 6)) | ||||
| 282 | + ex["7"] = {**lne, "va_net_5ans": va_com, "va_residentiel_5ans": va_res, "ratio_va": va_com / va_res, "loyer_res_an1_net": loyer_res} | ||||
| 283 | + return ex | ||||
| 284 | + | ||||
| 285 | + | ||||
| 286 | +def _f(x): return f"{x:,.0f}".replace(",", " ") | ||||
| 287 | + | ||||
| 288 | + | ||||
| 289 | +if __name__ == "__main__": | ||||
| 290 | + res = calculer(); ind = res["ind"]; t = ind["tableau"] | ||||
| 291 | + print("=== Cas 3 — état locatif (données synthétiques)") | ||||
| 292 | + print(t[["logement", "pieces", "superficie_m2", "loyer_mensuel_contrat", "loyer_mensuel_marche", "anciennete", "ecart_mensuel", "ecart_pct", "ratio", "echeance_bail", "mois_echeance"]].to_string(index=False)) | ||||
| 293 | + for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "ecart_pondere", "ratio_pondere", "ratio_moyen_simple", "ecart_mensuel_moyen", "ecart_mensuel_median", | ||||
| 294 | + "anciennete_moyenne", "anciennete_mediane", "anciennete_max", "part_sous_marche_5", "part_sous_marche_10", "part_sous_marche_20", | ||||
| 295 | + "nb_sous_marche_5", "nb_sous_marche_10", "nb_sous_marche_20", "loyer_moyen_contrat", "loyer_moyen_marche", "loyer_m2_contrat_moyen", "loyer_m2_marche_moyen", "part_ecart_top3"]: | ||||
| 296 | + print(f"{k:28s} {ind[k]:.4f}" if isinstance(ind[k], float) else f"{k:28s} {ind[k]}") | ||||
| 297 | + print("\n=== Loyer économique (R médian = %.4f %%)" % (100 * res["R"])) | ||||
| 298 | + for lab, e in (("sur valeur au rôle 2 200 000 $", res["econ_role"]), ("sur valeur par capitalisation", res["econ_cap"])): | ||||
| 299 | + print(lab, {k: round(v, 2) for k, v in e.items()}) | ||||
| 300 | + print("\n=== VA de l'écart par logement (r = %.4f, g = %.3f ; nette de l'inoccupation)" % (res["hyp"]["taux_actualisation"], res["hyp"]["croissance_loyers_marche"])) | ||||
| 301 | + print(res["va"].round(0).to_string(index=False)); print("totaux :", res["va"].drop(columns=["logement", "pieces"]).sum().round(0).to_dict()) | ||||
| 302 | + print("VA agrégée ρ = 15 % (chapitre 2) :", round(res["va_agregee_cas"], 0), "; sans rotation :", round(res["sens"]["sans_rotation"], 0)) | ||||
| 303 | + print("Durées espérées 1/ρ :", {k: round(v, 2) for k, v in res["esperance_duree"].items()}) | ||||
| 304 | + print("\n=== Sensibilité (VA agrégée)"); print(res["sens"]["par_taux"].round(0)); print(res["sens"]["par_croissance"].round(0)) | ||||
| 305 | + n = res["norm"]; print("\n=== Normalisation électricité (tarif D, hypothèse 12 000 kWh/an)") | ||||
| 306 | + print(n["tableau"].round(2).to_string(index=False)) | ||||
| 307 | + print({k: round(v, 2) for k, v in n.items() if k != "tableau"}) | ||||
| 308 | + print("\n=== Calendrier (bail 2026-07-01 → 2027-06-30)"); print(res["cal"]) | ||||
| 309 | + c2 = res["cas2"]; print("\n=== Cas 2 — triplex"); print(c2["tableau"].to_string(index=False)) | ||||
| 310 | + print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in c2.items() if k not in ("tableau", "sujet", "data")}) | ||||
| 311 | + print("\n=== Exercices"); ex = exercices(res) | ||||
| 312 | + for k, v in ex.items(): | ||||
| 313 | + print(f"--- {k}") | ||||
| 314 | + if isinstance(v, dict): | ||||
| 315 | + for kk, vv in v.items(): | ||||
| 316 | + if isinstance(vv, (int, float)): print(f" {kk}: {vv:.4f}") | ||||
| 317 | + elif isinstance(vv, dict): print(f" {kk}: {{" + ", ".join(f"{a}: {round(b, 4) if isinstance(b, float) else b}" for a, b in vv.items() if not isinstance(b, (pd.DataFrame, dict))) + "}") | ||||
| 318 | + elif isinstance(vv, list): print(f" {kk}: {[round(x, 2) for x in vv]}") | ||||
added
analysis/python/cases/ch27_revenus.py
+356 −0
@@ -0,0 +1,356 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 27 — Revenus potentiels, vacance et pertes : calculs du cas 3 (12 logements, Rosemont) et du cas 2 (triplex, | ||||
| 3 | +Limoilou) — DONNÉES SYNTHÉTIQUES — et lecture des tableaux publics de la SCHL (Enquête sur les logements locatifs, | ||||
| 4 | +octobre 2025, data/raw/schl_rml_2025/) — DONNÉES RÉELLES citées avec attribution. | ||||
| 5 | + | ||||
| 6 | +Tous les chiffres cités dans manuscript/chapters/ch27.tex et solutions/ch27.tex sortent d'ici. Les revenus des cas | ||||
| 7 | +viennent de traite.cas3.revenus / traite.cas2.revenus et la projection des loyers de traite.cas3.projection_dcf, pour | ||||
| 8 | +coïncider avec les chapitres 29, 30, 32, 46 et 47 ; les formules viennent de traite.finance (aucune n'est réécrite). | ||||
| 9 | + | ||||
| 10 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch27_revenus.py | ||||
| 11 | +""" | ||||
| 12 | +from __future__ import annotations | ||||
| 13 | +import copy, math, pathlib, sys | ||||
| 14 | +import numpy as np, pandas as pd | ||||
| 15 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 16 | +from traite import cas2, cas3, finance as fin | ||||
| 17 | + | ||||
| 18 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 19 | +RAW = ROOT / "data/raw/schl_rml_2025" | ||||
| 20 | +SIZES = ["3 à 5 unités", "6 à 19 unités", "20 à 49 unités", "50 à 99 unités", "100 unités et +", "Tous les immeubles"] | ||||
| 21 | +TYPES = ["Studio", "1 chambre", "2 chambres", "3 chambres +", "Tous"] | ||||
| 22 | +RMR_FICHIER = {"Montréal": "rmr-montreal-2025-fr.xlsx", "Québec": "rmr-quebec-2025-fr.xlsx", "Gatineau (partie QC)": "rmr-gatineau-2025-fr.xlsx", | ||||
| 23 | + "Sherbrooke": "rmr-sherbrooke-2025-fr.xlsx", "Trois-Rivières": "rmr-trois-rivieres-2025-fr.xlsx", "Saguenay": "rmr-saguenay-2025-fr.xlsx"} | ||||
| 24 | +# Libellés de la RMR dans les fichiers (tableaux par centre du fichier Québec ; tableaux par zone des autres fichiers) | ||||
| 25 | +RMR_LIBELLE_QC = {"Montréal": "Montréal (RMR)", "Québec": "Québec (RMR)", "Gatineau (partie QC)": "Ottawa-Gatineau (RMR) (partie QC)", | ||||
| 26 | + "Sherbrooke": "Sherbrooke (RMR)", "Trois-Rivières": "Trois-Rivières (RMR)", "Saguenay": "Saguenay (RMR)"} | ||||
| 27 | + | ||||
| 28 | + | ||||
| 29 | +# ----------------------------------------------------------------------------- lecture des tableaux SCHL (réels) | ||||
| 30 | +def _valeurs(tokens) -> list[float]: | ||||
| 31 | + """Convertit une ligne SCHL en nombres : « 2,9 » → 2.9 ; « ** » (supprimé) → NaN ; lettres de qualité a-d et flèches ignorées.""" | ||||
| 32 | + out = [] | ||||
| 33 | + for t in tokens: | ||||
| 34 | + s = str(t).strip() | ||||
| 35 | + if s == "**": | ||||
| 36 | + out.append(float("nan")); continue | ||||
| 37 | + if s in {"a", "b", "c", "d", "↑", "↓", "-", "–", "O", "N"}: | ||||
| 38 | + continue | ||||
| 39 | + try: | ||||
| 40 | + out.append(float(s.replace(",", ".").replace(" ", "").replace(" ", ""))) | ||||
| 41 | + except ValueError: | ||||
| 42 | + continue | ||||
| 43 | + return out | ||||
| 44 | + | ||||
| 45 | + | ||||
| 46 | +def _lignes(fichier: str, tableau: str) -> list[tuple[str, list[float]]]: | ||||
| 47 | + import openpyxl | ||||
| 48 | + wb = openpyxl.load_workbook(RAW / fichier, read_only=True, data_only=True) | ||||
| 49 | + ws = wb[tableau] | ||||
| 50 | + rows = [] | ||||
| 51 | + for r in ws.iter_rows(values_only=True): | ||||
| 52 | + cells = [c for c in r if c is not None] | ||||
| 53 | + if not cells: | ||||
| 54 | + continue | ||||
| 55 | + rows.append((str(cells[0]).strip(), _valeurs(cells[1:]))) | ||||
| 56 | + return rows | ||||
| 57 | + | ||||
| 58 | + | ||||
| 59 | +def _bloc(rows, debut: str, libelles: list[str]) -> dict[str, list[float]]: | ||||
| 60 | + """Extrait, dans le bloc commençant par la ligne `debut`, les lignes dont le libellé figure dans `libelles`.""" | ||||
| 61 | + out, actif = {}, False | ||||
| 62 | + for lab, vals in rows: | ||||
| 63 | + if lab == debut: | ||||
| 64 | + actif = True; continue | ||||
| 65 | + if actif and lab in libelles and len(vals) >= 2: | ||||
| 66 | + out[lab] = vals | ||||
| 67 | + if actif and lab in libelles and lab == libelles[-1]: | ||||
| 68 | + break | ||||
| 69 | + return out | ||||
| 70 | + | ||||
| 71 | + | ||||
| 72 | +def schl_inoccupation_rmr() -> pd.DataFrame: | ||||
| 73 | + """Taux d'inoccupation (%), tous les appartements, oct. 2024 et oct. 2025, par RMR (tableau 1.1.1 du fichier Québec, par centre).""" | ||||
| 74 | + rows = _lignes("rmr-quebec-2025-fr.xlsx", "Tableau 1.1.1") | ||||
| 75 | + rec = [] | ||||
| 76 | + for rmr, lab in RMR_LIBELLE_QC.items(): | ||||
| 77 | + v = [vals for l, vals in rows if l == lab][0] | ||||
| 78 | + # 5 types × (2024, 2025) = 10 valeurs ; « Tous » = les deux dernières | ||||
| 79 | + rec.append({"rmr": rmr, "inocc_2024": v[-2], "inocc_2025": v[-1], "inocc_2ch_2025": v[5]}) | ||||
| 80 | + return pd.DataFrame(rec).set_index("rmr") | ||||
| 81 | + | ||||
| 82 | + | ||||
| 83 | +def schl_rotation_rmr() -> pd.DataFrame: | ||||
| 84 | + """Taux de rotation (%), tous les appartements, oct. 2024 et 2025, par RMR (tableau 1.1.6 du fichier Québec).""" | ||||
| 85 | + rows = _lignes("rmr-quebec-2025-fr.xlsx", "Tableau 1.1.6") | ||||
| 86 | + rec = [] | ||||
| 87 | + for rmr, lab in RMR_LIBELLE_QC.items(): | ||||
| 88 | + v = [vals for l, vals in rows if l == lab][0] | ||||
| 89 | + rec.append({"rmr": rmr, "rotation_2024": v[-2], "rotation_2025": v[-1]}) | ||||
| 90 | + return pd.DataFrame(rec).set_index("rmr") | ||||
| 91 | + | ||||
| 92 | + | ||||
| 93 | +def schl_taille_montreal() -> pd.DataFrame: | ||||
| 94 | + """Inoccupation (tous les appartements, %) et rotation (%) selon la taille de l'immeuble, oct. 2025 : RMR de Montréal et île de Montréal | ||||
| 95 | + (tableaux 1.3.1 et 1.3.4 du fichier Montréal).""" | ||||
| 96 | + inoc = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.3.1"); rot = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.3.4") | ||||
| 97 | + rec = [] | ||||
| 98 | + for zone, debut in (("Île de Montréal", "Île de Montréal (zones 1-18)"), ("RMR de Montréal", "Montréal (RMR)")): | ||||
| 99 | + bi = _bloc(inoc, debut, SIZES); br = _bloc(rot, debut, SIZES) | ||||
| 100 | + for s in SIZES: | ||||
| 101 | + vi = bi.get(s, []); vr = br.get(s, []) | ||||
| 102 | + rec.append({"zone": zone, "taille": s, "inocc_2024": vi[-2] if len(vi) >= 2 else np.nan, "inocc_2025": vi[-1] if vi else np.nan, | ||||
| 103 | + "rotation_2024": vr[-2] if len(vr) >= 2 else np.nan, "rotation_2025": vr[-1] if vr else np.nan}) | ||||
| 104 | + return pd.DataFrame(rec) | ||||
| 105 | + | ||||
| 106 | + | ||||
| 107 | +def schl_quartiles_montreal() -> pd.DataFrame: | ||||
| 108 | + """Inoccupation (%) selon le quartile de loyer, tous les appartements, RMR de Montréal, oct. 2024 et 2025 (tableau 1.4).""" | ||||
| 109 | + rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.4") | ||||
| 110 | + b = _bloc(rows, "Montréal (RMR)", ["Q1", "Q2", "Q3", "Q4"]) | ||||
| 111 | + return pd.DataFrame([{"quartile": q, "inocc_2024": b[q][-2], "inocc_2025": b[q][-1]} for q in ["Q1", "Q2", "Q3", "Q4"]]).set_index("quartile") | ||||
| 112 | + | ||||
| 113 | + | ||||
| 114 | +def schl_inoccupes_occupes_ile() -> pd.DataFrame: | ||||
| 115 | + """Loyer moyen ($) des logements inoccupés et occupés, île de Montréal, oct. 2025, par taille de logement (tableau 1.1.9).""" | ||||
| 116 | + rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.1.9") | ||||
| 117 | + v = [vals for l, vals in rows if l.startswith("Île-de-Montréal")][0] | ||||
| 118 | + rec = [{"type": t, "inoccupes": v[2 * i], "occupes": v[2 * i + 1], "ecart_pct": (v[2 * i] / v[2 * i + 1] - 1) * 100} for i, t in enumerate(TYPES)] | ||||
| 119 | + return pd.DataFrame(rec).set_index("type") | ||||
| 120 | + | ||||
| 121 | + | ||||
| 122 | +def schl_annee_construction_ile() -> pd.DataFrame: | ||||
| 123 | + """Inoccupation (%) selon l'année de construction, tous les appartements, île de Montréal, oct. 2025 (tableau 1.2.1).""" | ||||
| 124 | + rows = _lignes("rmr-montreal-2025-fr.xlsx", "Tableau 1.2.1") | ||||
| 125 | + libs = ["Avant 1940", "1940 à 1959", "1960 à 1974", "1975 à 1989", "1990 à 2004", "2005 à 2014", "2015 ou après", "Tous les immeubles"] | ||||
| 126 | + b = _bloc(rows, "Île de Montréal (zones 1-18)", libs) | ||||
| 127 | + return pd.DataFrame([{"periode": k, "inocc_2025": b[k][-1]} for k in libs if k in b]).set_index("periode") | ||||
| 128 | + | ||||
| 129 | + | ||||
| 130 | +def ipc_loyer_quebec() -> dict: | ||||
| 131 | + """IPC « Loyer », Québec, non désaisonnalisé (Statistique Canada 18-10-0004-01, 2002 = 100) : variation sept. 2026 / sept. 2025 | ||||
| 132 | + et croissance annuelle moyenne sur 10 ans (sept. 2016 → sept. 2026).""" | ||||
| 133 | + p = ROOT / "data/raw/statcan_18100004/18100004.csv" | ||||
| 134 | + import csv | ||||
| 135 | + vals = {} | ||||
| 136 | + with p.open(encoding="utf-8-sig") as f: | ||||
| 137 | + for row in csv.reader(f, delimiter=";"): | ||||
| 138 | + if len(row) > 10 and row[1] == "Québec" and row[3] == "Loyer" and row[0] >= "2015-01": | ||||
| 139 | + try: | ||||
| 140 | + vals[row[0]] = float(row[10]) | ||||
| 141 | + except ValueError: | ||||
| 142 | + pass | ||||
| 143 | + s = pd.Series(vals).sort_index() | ||||
| 144 | + return {"serie": s, "sept2026": s["2026-09"], "sept2025": s["2025-09"], "var_12m": s["2026-09"] / s["2025-09"] - 1, | ||||
| 145 | + "cagr_10ans": (s["2026-09"] / s["2016-09"]) ** (1 / 10) - 1, "cagr_5ans": (s["2026-09"] / s["2021-09"]) ** (1 / 5) - 1} | ||||
| 146 | + | ||||
| 147 | + | ||||
| 148 | +# ----------------------------------------------------------------------------- cas 3 et cas 2 (synthétiques) | ||||
| 149 | +def cascade_cas3() -> dict: | ||||
| 150 | + d = cas3.charger(); etat, hyp = d["etat"], d["hyp"] | ||||
| 151 | + r = cas3.revenus(etat, hyp) | ||||
| 152 | + n = len(etat); sup = float(etat["superficie_m2"].sum()) | ||||
| 153 | + v = r["taux_vacance_pertes"] | ||||
| 154 | + r.update({ | ||||
| 155 | + "n_logements": n, "superficie_m2": sup, "loyer_mensuel_contrat": float(etat["loyer_mensuel_contrat"].sum()), | ||||
| 156 | + "loyer_mensuel_marche": float(etat["loyer_mensuel_marche"].sum()), | ||||
| 157 | + "deficit_loyer": r["ecart_marche"], "deficit_pct": r["ecart_marche"] / r["rbp_marche"], | ||||
| 158 | + "rbp_contrat_sur_marche": r["rbp_contrat"] / r["rbp_marche"], | ||||
| 159 | + "loyers_effectifs": r["rbp_contrat"] * (1 - v), | ||||
| 160 | + "taux_occupation_economique": r["rbp_contrat"] * (1 - v) / r["rbp_marche"], # loyers encaissés / loyers de marché | ||||
| 161 | + "rbe_sur_rbp": r["rbe"] / r["rbp_contrat"], "autres_sur_rbp": r["autres_revenus"] / r["rbp_contrat"], | ||||
| 162 | + "autres_par_logement_mois": r["autres_revenus"] / n / 12, | ||||
| 163 | + "rbe_par_logement": r["rbe"] / n, "rbe_par_m2": r["rbe"] / sup, "rbp_par_m2": r["rbp_contrat"] / sup, | ||||
| 164 | + "rbe_marche": fin.revenu_brut_effectif(r["rbp_marche"], v, r["autres_revenus"]), | ||||
| 165 | + # variantes de convention (même v) : v appliqué au RBP + autres revenus ; autres revenus à 50 % vacants ; v sur RBP de marché | ||||
| 166 | + "rbe_variante_v_sur_tout": (r["rbp_contrat"] + r["autres_revenus"]) * (1 - v), | ||||
| 167 | + "rbe_variante_v_sur_marche": r["rbp_marche"] * (1 - v) + r["autres_revenus"], | ||||
| 168 | + }) | ||||
| 169 | + r["ecart_variante_v_sur_tout"] = r["rbe_variante_v_sur_tout"] - r["rbe"] | ||||
| 170 | + # décomposition de v (hypothèse pédagogique déclarée, cohérente avec le ch. 47) : frictionnel = rotation × durée ; reste = créances + structurel | ||||
| 171 | + rot, duree = hyp["rotation_annuelle"], 1.0 | ||||
| 172 | + r["v_frictionnel_dossier"] = fin.taux_vacance_frictionnelle(rot, duree) | ||||
| 173 | + r["v_residuel_dossier"] = v - r["v_frictionnel_dossier"] | ||||
| 174 | + r["etat"] = etat; r["hyp"] = hyp; r["sujet"] = d["sujet"] | ||||
| 175 | + return r | ||||
| 176 | + | ||||
| 177 | + | ||||
| 178 | +def par_type_cas3(etat: pd.DataFrame) -> pd.DataFrame: | ||||
| 179 | + g = etat.groupby("pieces").agg(n=("logement", "count"), superficie=("superficie_m2", "sum"), contrat=("loyer_mensuel_contrat", "sum"), | ||||
| 180 | + marche=("loyer_mensuel_marche", "sum"), anciennete=("locataire_depuis", lambda s: float((2026 - s).mean()))) | ||||
| 181 | + g["contrat_moyen"] = g["contrat"] / g["n"]; g["marche_moyen"] = g["marche"] / g["n"] | ||||
| 182 | + g["ecart"] = g["marche"] - g["contrat"]; g["ecart_pct"] = g["ecart"] / g["marche"] * 100 | ||||
| 183 | + g["rbp_contrat"] = g["contrat"] * 12; g["rbp_marche"] = g["marche"] * 12 | ||||
| 184 | + tot = g.sum(numeric_only=True); tot["contrat_moyen"] = tot["contrat"] / tot["n"]; tot["marche_moyen"] = tot["marche"] / tot["n"] | ||||
| 185 | + tot["ecart_pct"] = tot["ecart"] / tot["marche"] * 100; tot["anciennete"] = float((2026 - etat["locataire_depuis"]).mean()) | ||||
| 186 | + g.loc["Total"] = tot | ||||
| 187 | + return g | ||||
| 188 | + | ||||
| 189 | + | ||||
| 190 | +def cascade_cas2() -> dict: | ||||
| 191 | + c = cas2.charger(); loyers = c["loyers"] | ||||
| 192 | + r = cas2.revenus(loyers) | ||||
| 193 | + v = r["taux_vacance_pertes"] | ||||
| 194 | + r.update({"n_logements": len(loyers), "superficie_m2": float(loyers["superficie_m2"].sum()), "vacance_pertes": r["rbp_contrat"] * v, | ||||
| 195 | + "deficit_pct": r["ecart_marche"] / r["rbp_marche"], "taux_occupation_economique": r["rbp_contrat"] * (1 - v) / r["rbp_marche"], | ||||
| 196 | + "rbe_par_logement": r["rbe"] / len(loyers), "autres_revenus": 0.0, | ||||
| 197 | + # décomposition déclarée du 3 % du dossier : inoccupation SCHL Québec 2,4 % + créances ≈ 0,6 % | ||||
| 198 | + "v_inoccupation_schl": 0.024, "v_creances": v - 0.024, | ||||
| 199 | + # occupant : le logement du propriétaire (RC, hypothèse du ch. 46) n'a pas de risque d'inoccupation marchand → RBE si l'on | ||||
| 200 | + # n'applique v qu'aux deux logements loués (variante pédagogique) | ||||
| 201 | + "loyers": loyers}) | ||||
| 202 | + loue = loyers[loyers["logement"] != "RC"] | ||||
| 203 | + r["rbe_variante_occupant"] = float(loyers.loc[loyers["logement"] == "RC", "loyer_mensuel"].sum() * 12) + fin.revenu_brut_effectif(float(loue["loyer_mensuel"].sum() * 12), v) | ||||
| 204 | + return r | ||||
| 205 | + | ||||
| 206 | + | ||||
| 207 | +# ----------------------------------------------------------------------------- modèle d'inoccupation frictionnelle et grilles | ||||
| 208 | +def grille_frictionnelle(rotations=(0.05, 0.094, 0.13, 0.15, 0.20, 0.25), durees=(0.5, 1.0, 1.5, 2.0, 3.0)) -> pd.DataFrame: | ||||
| 209 | + g = pd.DataFrame({d: [fin.taux_vacance_frictionnelle(r, d) * 100 for r in rotations] for d in durees}, index=[r * 100 for r in rotations]) | ||||
| 210 | + g.index.name = "rotation_pct"; g.columns.name = "duree_mois" | ||||
| 211 | + return g | ||||
| 212 | + | ||||
| 213 | + | ||||
| 214 | +def sensibilite_v(base: dict, taux=(0.02, 0.029, 0.039, 0.05, 0.06, 0.08)) -> pd.DataFrame: | ||||
| 215 | + """RBE, RNE normalisé et valeur par capitalisation (taux médian du cas 3) pour différents v ; dépenses recalculées par traite.cas3.rne | ||||
| 216 | + (la gestion imputée à 4 % du RBE varie avec v).""" | ||||
| 217 | + d = cas3.charger(); R = cas3.taux_extraits(d["comps"])["mediane"] | ||||
| 218 | + rows = [] | ||||
| 219 | + for v in taux: | ||||
| 220 | + h = copy.deepcopy(d["hyp"]); h["taux_inoccupation_pertes"] = v | ||||
| 221 | + b = cas3.rne(d["etat"], d["dep"], h) | ||||
| 222 | + rows.append({"v": v, "vacance_pertes": b["vacance_pertes"], "rbe": b["rbe"], "rne": b["rne"], "valeur": fin.capitalisation_directe(b["rne"], R)}) | ||||
| 223 | + s = pd.DataFrame(rows) | ||||
| 224 | + h0 = copy.deepcopy(d["hyp"]); b0 = cas3.rne(d["etat"], d["dep"], h0) # référence = v du dossier, même si absent de la grille | ||||
| 225 | + ref = {"rbe": b0["rbe"], "valeur": fin.capitalisation_directe(b0["rne"], R)} | ||||
| 226 | + s["d_rbe"] = s["rbe"] - ref["rbe"]; s["d_valeur"] = s["valeur"] - ref["valeur"]; s["d_valeur_pct"] = s["valeur"] / ref["valeur"] - 1 | ||||
| 227 | + s.attrs["R"] = R | ||||
| 228 | + # dérivées analytiques : ∂RBE/∂v = −RBP ; ∂RNE/∂v = −RBP(1−0,04) ; ∂V/∂v = ∂RNE/∂v / R | ||||
| 229 | + s.attrs["dRBE_dv_pt"] = -base["rbp_contrat"] / 100 | ||||
| 230 | + s.attrs["dRNE_dv_pt"] = -base["rbp_contrat"] * (1 - 0.04) / 100 | ||||
| 231 | + s.attrs["dV_dv_pt"] = -base["rbp_contrat"] * (1 - 0.04) / 100 / R | ||||
| 232 | + return s | ||||
| 233 | + | ||||
| 234 | + | ||||
| 235 | +# ----------------------------------------------------------------------------- trajectoires des loyers (cas 3) | ||||
| 236 | +def trajectoires_cas3() -> dict: | ||||
| 237 | + """Trois trajectoires du RBP sur l'horizon du DCF : (i) indexation TAL seule (rotation 0) ; (ii) base du dossier (TAL 3,1 % + rotation 15 % | ||||
| 238 | + relouée au marché, marché +3,5 %) ; (iii) RBP au marché (indice) ; variante (iv) rotation SCHL 9,4 %.""" | ||||
| 239 | + d = cas3.charger(); etat, dep, hyp = d["etat"], d["dep"], d["hyp"] | ||||
| 240 | + def proj(**chg): | ||||
| 241 | + h = copy.deepcopy(hyp); h.update(chg) | ||||
| 242 | + return cas3.projection_dcf(etat, dep, h) | ||||
| 243 | + base = proj(); tal = proj(rotation_annuelle=0.0); rot_schl = proj(rotation_annuelle=0.094) | ||||
| 244 | + rot_schl_marche_schl = proj(rotation_annuelle=0.094, croissance_loyers_marche=0.033) # marché au rythme de l'IPC-loyer récent | ||||
| 245 | + t = pd.DataFrame({"annee": base["annee"], "exercice": base["exercice"], "tal_seul": tal["rbp"], "base_rot15": base["rbp"], | ||||
| 246 | + "rot_schl_094": rot_schl["rbp"], "marche": base["rbp_marche"], "rot094_marche033": rot_schl_marche_schl["rbp"], | ||||
| 247 | + "rbe_tal_seul": tal["rbe"], "rbe_base": base["rbe"], "rbe_rot094": rot_schl["rbe"], "rbe_marche_plein": base["rbp_marche"] * (1 - hyp["taux_inoccupation_pertes"]) + hyp["autres_revenus_annuels"] * (1 + hyp["croissance_depenses"]) ** (base["annee"] - 1)}) | ||||
| 248 | + t["ecart_base_pct"] = (1 - t["base_rot15"] / t["marche"]) * 100 | ||||
| 249 | + t["ecart_tal_pct"] = (1 - t["tal_seul"] / t["marche"]) * 100 | ||||
| 250 | + t["ecart_rot094_pct"] = (1 - t["rot_schl_094"] / t["marche"]) * 100 | ||||
| 251 | + T = hyp["horizon_ans"] | ||||
| 252 | + res = {"table": t, "hyp": hyp, "T": T, | ||||
| 253 | + "cagr": {k: (t.loc[t["annee"] == T, k].iloc[0] / t.loc[t["annee"] == 1, k].iloc[0]) ** (1 / (T - 1)) - 1 for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]}, | ||||
| 254 | + "rbp_T": {k: float(t.loc[t["annee"] == T, k].iloc[0]) for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]}, | ||||
| 255 | + "somme_rbp_1_T": {k: float(t.loc[t["annee"] <= T, k].sum()) for k in ["tal_seul", "base_rot15", "rot_schl_094", "marche"]}} | ||||
| 256 | + # valeur DCF associée à chaque trajectoire (pour mémoire : sensibilité de la valeur à la trajectoire ; ch. 30/34 approfondissent) | ||||
| 257 | + res["valeur_dcf"] = {"tal_seul": cas3.valeur_dcf(tal, hyp)["valeur"], "base_rot15": cas3.valeur_dcf(base, hyp)["valeur"], | ||||
| 258 | + "rot_schl_094": cas3.valeur_dcf(rot_schl, hyp)["valeur"]} | ||||
| 259 | + return res | ||||
| 260 | + | ||||
| 261 | + | ||||
| 262 | +def mini_projection(loyers: list[float], marche: float, g_c: float, g_m: float, k_par_an: int, annees: int) -> pd.DataFrame: | ||||
| 263 | + """Petit modèle de trajectoire (exercice 27.6) : chaque année, les loyers reconduits croissent de g_c, le marché de g_m, et les k logements les | ||||
| 264 | + plus sous le marché sont reloués au marché. Année 1 = loyers en place. Reproduit la logique de traite.cas3.projection_dcf à petite échelle.""" | ||||
| 265 | + L = np.array(loyers, dtype=float); M = float(marche); rows = [] | ||||
| 266 | + for t in range(1, annees + 1): | ||||
| 267 | + if t > 1: | ||||
| 268 | + L = L * (1 + g_c); M = M * (1 + g_m) | ||||
| 269 | + idx = np.argsort(L / M)[:k_par_an]; L[idx] = M | ||||
| 270 | + rows.append({"annee": t, "loyers": L.copy(), "marche": M, "rbp": L.sum() * 12, "rbp_marche": M * len(L) * 12}) | ||||
| 271 | + return pd.DataFrame(rows) | ||||
| 272 | + | ||||
| 273 | + | ||||
| 274 | +# ----------------------------------------------------------------------------- exercices (solutions/ch27.tex) | ||||
| 275 | +def exercices(base3: dict, base2: dict) -> dict: | ||||
| 276 | + ex = {} | ||||
| 277 | + # 27.1 — RBP contractuel, au marché, déficit ; six logements | ||||
| 278 | + L = [895, 910, 1040, 1150, 1225, 1310]; M = [1100, 1100, 1150, 1250, 1300, 1350] | ||||
| 279 | + ex["1"] = {"loyers": L, "marche": M, "rbp_c": sum(L) * 12, "rbp_m": sum(M) * 12, "deficit": (sum(M) - sum(L)) * 12, | ||||
| 280 | + "deficit_pct": 1 - sum(L) / sum(M), "ecarts_pct": [1 - l / m for l, m in zip(L, M)]} | ||||
| 281 | + # 27.2 — RBE selon trois conventions ; v = 4 %, autres 4 800 $ | ||||
| 282 | + rbp = ex["1"]["rbp_c"]; v = 0.04; autres = 4800.0 | ||||
| 283 | + ex["2"] = {"rbp": rbp, "v": v, "autres": autres, "rbe_livre": fin.revenu_brut_effectif(rbp, v, autres), "rbe_v_sur_tout": (rbp + autres) * (1 - v), | ||||
| 284 | + "rbe_autres_demi": rbp * (1 - v) + autres * (1 - v / 2), "vacance": rbp * v} | ||||
| 285 | + ex["2"]["ecart_tout"] = ex["2"]["rbe_v_sur_tout"] - ex["2"]["rbe_livre"] | ||||
| 286 | + # 27.3 — inoccupation frictionnelle : Québec rotation 13 %, durées 1 et 1,5 mois ; créances 0,5 % ; comparer à SCHL 2,4 % | ||||
| 287 | + ex["3"] = {"rot": 0.13, "vf_1": fin.taux_vacance_frictionnelle(0.13, 1.0), "vf_15": fin.taux_vacance_frictionnelle(0.13, 1.5), | ||||
| 288 | + "creances": 0.005, "schl": 0.024} | ||||
| 289 | + ex["3"]["v_1"] = ex["3"]["vf_1"] + 0.005; ex["3"]["v_15"] = ex["3"]["vf_15"] + 0.005 | ||||
| 290 | + ex["3"]["duree_implicite_schl"] = 0.024 * 12 / 0.13 # durée (mois) qui reproduirait 2,4 % avec ρ = 13 % | ||||
| 291 | + # 27.4 — concession : un mois gratuit sur 12 à 1 300 $ ; équivalent en v ; deux mois sur 24 | ||||
| 292 | + ex["4"] = {"L": 1300.0, "L_eff_1_12": fin.loyer_effectif_concession(1300, 1, 12), "L_eff_2_24": fin.loyer_effectif_concession(1300, 2, 24), | ||||
| 293 | + "v_equiv_1_12": 1 / 12, "v_equiv_2_24": 2 / 24, "double_compte": 1300 * 12 * (1 - 1 / 12) * (1 - 1 / 12)} | ||||
| 294 | + ex["4"]["rbp_annuel"] = 1300 * 12; ex["4"]["rbe_correct"] = 1300 * 12 * (1 - 1 / 12) | ||||
| 295 | + # 27.5 — triplex (cas 2) : cascade, RBE marché, occupation économique, variante occupant | ||||
| 296 | + ex["5"] = {k: base2[k] for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "taux_vacance_pertes", "vacance_pertes", "rbe", "rbe_marche", "taux_occupation_economique", "rbe_variante_occupant", "rbe_par_logement"]} | ||||
| 297 | + # 27.6 — mini-projection : 4 logements | ||||
| 298 | + mp = mini_projection([900, 950, 1000, 1100], 1200, 0.031, 0.035, 1, 4) | ||||
| 299 | + ex["6"] = {"table": mp, "rbp": mp["rbp"].tolist(), "rbp_marche": mp["rbp_marche"].tolist(), "ecart_pct": (1 - mp["rbp"] / mp["rbp_marche"]).tolist(), | ||||
| 300 | + "tal_seul": [sum([900, 950, 1000, 1100]) * 12 * 1.031 ** (t - 1) for t in range(1, 5)]} | ||||
| 301 | + # 27.7 — valeur et v : cas 3 à v = 2,9 % et 5,9 % ; R = médiane ; via traite.cas3.rne | ||||
| 302 | + s = sensibilite_v(base3, taux=(0.029, 0.039, 0.059)) | ||||
| 303 | + ex["7"] = {"table": s, "R": s.attrs["R"], "dV_dv_pt": s.attrs["dV_dv_pt"], "dRBE_dv_pt": s.attrs["dRBE_dv_pt"], | ||||
| 304 | + "V_029": float(s.loc[np.isclose(s["v"], 0.029), "valeur"].iloc[0]), "V_039": float(s.loc[np.isclose(s["v"], 0.039), "valeur"].iloc[0]), | ||||
| 305 | + "V_059": float(s.loc[np.isclose(s["v"], 0.059), "valeur"].iloc[0]), | ||||
| 306 | + "rne_029": float(s.loc[np.isclose(s["v"], 0.029), "rne"].iloc[0]), "rne_059": float(s.loc[np.isclose(s["v"], 0.059), "rne"].iloc[0])} | ||||
| 307 | + ex["7"]["approx_lineaire_2pts"] = 2 * ex["7"]["dV_dv_pt"] | ||||
| 308 | + # 27.8 — lecture SCHL : quartiles, loyers inoccupés/occupés, implication pour un immeuble au-dessus du marché | ||||
| 309 | + q = schl_quartiles_montreal(); io = schl_inoccupes_occupes_ile() | ||||
| 310 | + ex["8"] = {"quartiles": q, "inoccupes_occupes": io, "ratio_tous": io.loc["Tous", "inoccupes"] / io.loc["Tous", "occupes"], | ||||
| 311 | + "ratio_2ch": io.loc["2 chambres", "inoccupes"] / io.loc["2 chambres", "occupes"], "q4_sur_q1": q.loc["Q4", "inocc_2025"] / q.loc["Q1", "inocc_2025"]} | ||||
| 312 | + return ex | ||||
| 313 | + | ||||
| 314 | + | ||||
| 315 | +def calculer() -> dict: | ||||
| 316 | + base3 = cascade_cas3(); base2 = cascade_cas2() | ||||
| 317 | + res = {"cas3": base3, "cas2": base2, "par_type": par_type_cas3(base3["etat"]), | ||||
| 318 | + "schl_rmr": schl_inoccupation_rmr().join(schl_rotation_rmr()), "schl_taille": schl_taille_montreal(), | ||||
| 319 | + "schl_quartiles": schl_quartiles_montreal(), "schl_inocc_occ": schl_inoccupes_occupes_ile(), "schl_annee": schl_annee_construction_ile(), | ||||
| 320 | + "ipc_loyer": ipc_loyer_quebec(), "grille_frictionnelle": grille_frictionnelle(), "sensibilite_v": sensibilite_v(base3), | ||||
| 321 | + "trajectoires": trajectoires_cas3()} | ||||
| 322 | + res["exercices"] = exercices(base3, base2) | ||||
| 323 | + return res | ||||
| 324 | + | ||||
| 325 | + | ||||
| 326 | +def _fmt(x, d=2): | ||||
| 327 | + return f"{x:,.{d}f}".replace(",", " ").replace(".", ",") | ||||
| 328 | + | ||||
| 329 | + | ||||
| 330 | +if __name__ == "__main__": | ||||
| 331 | + r = calculer(); c3 = r["cas3"]; c2 = r["cas2"] | ||||
| 332 | + print("=== Cas 3 — cascade RBP → RBE (données synthétiques)") | ||||
| 333 | + for k in ["loyer_mensuel_contrat", "loyer_mensuel_marche", "rbp_contrat", "rbp_marche", "deficit_loyer", "deficit_pct", "taux_vacance_pertes", "vacance_pertes", | ||||
| 334 | + "loyers_effectifs", "autres_revenus", "autres_par_logement_mois", "rbe", "rbe_sur_rbp", "rbe_par_logement", "rbe_par_m2", "rbp_par_m2", | ||||
| 335 | + "taux_occupation_economique", "rbe_marche", "rbe_variante_v_sur_tout", "ecart_variante_v_sur_tout", "rbe_variante_v_sur_marche", | ||||
| 336 | + "v_frictionnel_dossier", "v_residuel_dossier"]: | ||||
| 337 | + print(f" {k:32s} {_fmt(c3[k], 4 if abs(c3[k]) < 2 else 2)}") | ||||
| 338 | + print(r["par_type"].round(2).to_string()) | ||||
| 339 | + print("=== Cas 2 — triplex") | ||||
| 340 | + for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "deficit_pct", "taux_vacance_pertes", "vacance_pertes", "rbe", "rbe_marche", "taux_occupation_economique", "rbe_variante_occupant", "rbe_par_logement"]: | ||||
| 341 | + print(f" {k:32s} {_fmt(c2[k], 4 if abs(c2[k]) < 2 else 2)}") | ||||
| 342 | + print("=== SCHL oct. 2025 (réel) — inoccupation et rotation par RMR"); print(r["schl_rmr"].to_string()) | ||||
| 343 | + print("=== SCHL — taille d'immeuble (Montréal)"); print(r["schl_taille"].to_string()) | ||||
| 344 | + print("=== SCHL — quartiles RMR Montréal"); print(r["schl_quartiles"].to_string()) | ||||
| 345 | + print("=== SCHL — loyers inoccupés / occupés, île"); print(r["schl_inocc_occ"].round(1).to_string()) | ||||
| 346 | + print("=== SCHL — année de construction, île"); print(r["schl_annee"].to_string()) | ||||
| 347 | + ipc = r["ipc_loyer"]; print(f"=== IPC loyer Québec : sept. 2026 {ipc['sept2026']} / sept. 2025 {ipc['sept2025']} → {ipc['var_12m']*100:.2f} % ; TCAC 10 ans {ipc['cagr_10ans']*100:.2f} % ; 5 ans {ipc['cagr_5ans']*100:.2f} %") | ||||
| 348 | + print("=== Grille frictionnelle (v_f en %)"); print(r["grille_frictionnelle"].round(2).to_string()) | ||||
| 349 | + s = r["sensibilite_v"]; print("=== Sensibilité à v (R = %.4f %%)" % (s.attrs["R"] * 100)); print(s.round(2).to_string()) | ||||
| 350 | + print(f" dRBE/dv (par point) {s.attrs['dRBE_dv_pt']:.2f} ; dRNE/dv {s.attrs['dRNE_dv_pt']:.2f} ; dV/dv {s.attrs['dV_dv_pt']:.2f}") | ||||
| 351 | + t = r["trajectoires"]; print("=== Trajectoires RBP"); print(t["table"].round(2).to_string()); print("TCAC", {k: round(v * 100, 3) for k, v in t["cagr"].items()}) | ||||
| 352 | + print("RBP_T", {k: round(v, 2) for k, v in t["rbp_T"].items()}); print("Somme 1..T", {k: round(v, 2) for k, v in t["somme_rbp_1_T"].items()}); print("V_DCF", {k: round(v, 2) for k, v in t["valeur_dcf"].items()}) | ||||
| 353 | + print("=== Exercices") | ||||
| 354 | + for k, v in r["exercices"].items(): | ||||
| 355 | + print(k, {kk: (vv if not isinstance(vv, (pd.DataFrame, pd.Series)) else "<table>") for kk, vv in v.items()}) | ||||
| 356 | + print(r["exercices"]["6"]["table"].to_string()); print(r["exercices"]["7"]["table"].round(2).to_string()) | ||||
added
analysis/python/cases/ch31_financement.py
+330 −0
@@ -0,0 +1,330 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 31 — Financement, levier, RCD et RPV : calculs du chapitre (cas 3, 12 logements, Montréal, | ||||
| 3 | +DONNÉES SYNTHÉTIQUES, sauf les taux de la Banque du Canada cités en repère). | ||||
| 4 | +Tous les chiffres de manuscript/chapters/ch31.tex et solutions/ch31.tex proviennent de ce script, qui n'appelle que la | ||||
| 5 | +bibliothèque commune (traite.finance, traite.cas3) et les fonctions du chapitre 30 (cases.ch30_dcf) pour les flux. | ||||
| 6 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch31_financement.py [--latex] | ||||
| 7 | +Les fonctions sont importées par analysis/python/figures/ch31_*.py et analysis/python/tests/test_ch31.py. | ||||
| 8 | + | ||||
| 9 | +Aides propres au chapitre (candidates à une promotion dans traite.finance après harmonisation) : | ||||
| 10 | + prime_aph(rpv, amortissement_mois) — barème SCHL immeubles collectifs (locatif ordinaire / APH Select) | ||||
| 11 | + mise_de_fonds_minimale(prix) — règle fédérale 5 % / 10 % (plafond 1,5 M$) en vigueur le 15 déc. 2024 | ||||
| 12 | + renouvellement(...) — recalcul exact du paiement sur le solde au terme, à un nouveau taux | ||||
| 13 | + cout_effectif_dette(...) — TRI annuel des flux de la dette (coût effectif k) | ||||
| 14 | +""" | ||||
| 15 | +from __future__ import annotations | ||||
| 16 | +import sys, pathlib | ||||
| 17 | +import numpy as np, pandas as pd | ||||
| 18 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 19 | +from traite import cas3, finance as fin | ||||
| 20 | +from cases import ch30_dcf as c30 | ||||
| 21 | + | ||||
| 22 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 23 | + | ||||
| 24 | +# Repères publics (sources/notes-C.md §4, consultés le 2026-10-31) — valeurs citées dans le texte, non des intrants du cas 3 | ||||
| 25 | +REPERES = { | ||||
| 26 | + "taux_directeur": 0.0250, # BdC, 28 octobre 2026 | ||||
| 27 | + "gdc_5ans": 0.0358, "gdc_10ans": 0.0384, # BdC Valet, 2026-10-29 | ||||
| 28 | + "affiche_5ans": 0.0619, # V80691335, semaine du 2026-10-28 | ||||
| 29 | + "assure_5ans_plus": 0.0412, # A4_RATES_MORTGAGES, V122667780, août 2026 | ||||
| 30 | + "non_assure_5ans_plus": 0.0458, # A4_RATES_MORTGAGES, V122667786, août 2026 | ||||
| 31 | + "mqr_plancher": 0.0525, "mqr_marge": 0.02, # BSIF, page modifiée le 2026-01-29 | ||||
| 32 | + "lti_multiple": 4.5, # BSIF, limites LTI | ||||
| 33 | + "plafond_assurable": 1_500_000.0, "seuil_mdf": 500_000.0, # Finances Canada, 15 décembre 2024 | ||||
| 34 | +} | ||||
| 35 | + | ||||
| 36 | +# ----------------------------------------------------------------------------- aides propres au chapitre | ||||
| 37 | + | ||||
| 38 | +def prime_aph(rpv: float, amortissement_mois: int) -> float: | ||||
| 39 | + """Prime d'assurance SCHL (immeubles collectifs, locatif ordinaire et APH Select, tous buts sauf construction), | ||||
| 40 | + en proportion du prêt : barème par tranche de RPV + surprime de 0,25 % par tranche de 5 ans d'amortissement au-delà | ||||
| 41 | + de 25 ans (sources/notes-C.md §4.4, PDF « Droits et primes » consulté le 2026-10-31).""" | ||||
| 42 | + if rpv <= 0.65: base = 0.0260 | ||||
| 43 | + elif rpv <= 0.70: base = 0.0285 | ||||
| 44 | + elif rpv <= 0.75: base = 0.0335 | ||||
| 45 | + elif rpv <= 0.80: base = 0.0435 | ||||
| 46 | + elif rpv <= 0.85: base = 0.0535 | ||||
| 47 | + elif rpv <= 0.90: base = 0.0590 | ||||
| 48 | + else: base = 0.0615 | ||||
| 49 | + annees = amortissement_mois / 12 | ||||
| 50 | + tranches = max(0, int(np.ceil((annees - 25) / 5 - 1e-9))) | ||||
| 51 | + return base + 0.0025 * tranches | ||||
| 52 | + | ||||
| 53 | +def mise_de_fonds_minimale(prix: float) -> float: | ||||
| 54 | + """Mise de fonds minimale d'un prêt assuré (résidentiel 1-4 logements, propriétaire-occupant) : 5 % jusqu'à 500 000 $, | ||||
| 55 | + 10 % de la tranche entre 500 000 $ et 1,5 M$ ; au-delà de 1,5 M$ le prêt n'est pas assurable (20 % exigés en pratique | ||||
| 56 | + par la règle RPV ≤ 80 % de B-20). Règles fédérales en vigueur le 15 décembre 2024 (notes-C.md §4.3).""" | ||||
| 57 | + if prix > REPERES["plafond_assurable"]: | ||||
| 58 | + return 0.20 * prix | ||||
| 59 | + s = REPERES["seuil_mdf"] | ||||
| 60 | + return 0.05 * min(prix, s) + 0.10 * max(prix - s, 0.0) | ||||
| 61 | + | ||||
| 62 | +def cout_effectif_dette(hyp: fin.Hypotheque, annees: int) -> dict: | ||||
| 63 | + """Coût effectif annuel de la dette : TRI des flux annuels de l'emprunteur (+M_0 ; −SD … ; −SD − solde) sur `annees`, | ||||
| 64 | + comparé au taux effectif annuel (1 + i/2)^2 − 1 (paiements mensuels agrégés en fin d'année ⇒ TRI annuel < effectif).""" | ||||
| 65 | + sd = hyp.service_dette_annuel; solde = hyp.solde(annees * 12) | ||||
| 66 | + flux_emprunteur = [hyp.capital] + [-sd] * annees | ||||
| 67 | + flux_emprunteur[-1] -= solde | ||||
| 68 | + flux_preteur = [-x for x in flux_emprunteur] | ||||
| 69 | + return {"k_annuel": fin.tri(flux_preteur), "taux_effectif": fin.taux_effectif_annuel(hyp.taux_nominal, hyp.composition), | ||||
| 70 | + "flux_emprunteur": flux_emprunteur, "solde_fin": solde} | ||||
| 71 | + | ||||
| 72 | +# ----------------------------------------------------------------------------- cœur | ||||
| 73 | + | ||||
| 74 | +def base() -> dict: | ||||
| 75 | + """Tout le cas 3 (via traite.cas3.tout) + flux après dette du chapitre 30.""" | ||||
| 76 | + r = c30.base() | ||||
| 77 | + r["levier30"] = c30.flux_levier(r) | ||||
| 78 | + return r | ||||
| 79 | + | ||||
| 80 | +def conventions(capital: float, i: float = 0.0412, n: int = 480) -> pd.DataFrame: | ||||
| 81 | + """Comparaison des conventions canadienne (composition semestrielle) et américaine (mensuelle) sur un même prêt.""" | ||||
| 82 | + rows = [] | ||||
| 83 | + for nom, comp in (("Canada (semestrielle)", 2), ("États-Unis (mensuelle)", 12)): | ||||
| 84 | + h = fin.Hypotheque(capital, i, n, composition=comp) | ||||
| 85 | + rows.append({"convention": nom, "composition": comp, "taux_mensuel": h.taux_periodique, "taux_effectif": fin.taux_effectif_annuel(i, comp), | ||||
| 86 | + "paiement": h.paiement, "service_dette": h.service_dette_annuel, "constante": h.constante_hypothecaire, | ||||
| 87 | + "solde_60": h.solde(60), "solde_120": h.solde(120), "interets_an1": h.interets_annee(1), "capital_an1": h.capital_annee(1)}) | ||||
| 88 | + df = pd.DataFrame(rows) | ||||
| 89 | + return df | ||||
| 90 | + | ||||
| 91 | +def tableau_extraits(h: fin.Hypotheque, periodes=(1, 2, 3, 12, 13, 60, 61, 120, 121, 240, 360, 479, 480)) -> pd.DataFrame: | ||||
| 92 | + tab = pd.DataFrame(h.tableau()) | ||||
| 93 | + out = tab[tab["periode"].isin(periodes)].copy() | ||||
| 94 | + out["part_interet"] = out["interet"] / out["paiement"] | ||||
| 95 | + return out.reset_index(drop=True) | ||||
| 96 | + | ||||
| 97 | +def decomposition_annuelle(h: fin.Hypotheque) -> pd.DataFrame: | ||||
| 98 | + tab = pd.DataFrame(h.tableau()); tab["annee"] = (tab["periode"] - 1) // 12 + 1 | ||||
| 99 | + g = tab.groupby("annee").agg(interet=("interet", "sum"), capital=("capital", "sum"), paiement=("paiement", "sum"), solde_fin=("solde", "last")).reset_index() | ||||
| 100 | + g["solde_debut"] = [h.capital] + g["solde_fin"].tolist()[:-1] | ||||
| 101 | + return g | ||||
| 102 | + | ||||
| 103 | +def financement(r: dict) -> dict: | ||||
| 104 | + """Dimensionnement du prêt du cas 3 (traite.cas3.financement) + indicateurs dérivés (ratios, point mort, coût effectif).""" | ||||
| 105 | + f = r["financement"]; h = f["hypotheque"]; b = r["base"]; hyp = r["hyp"]; T = hyp["horizon_ans"] | ||||
| 106 | + V = r["dcf"]["valeur"]; noi1 = b["rne"] | ||||
| 107 | + cout = cout_effectif_dette(h, T) | ||||
| 108 | + return {**{k: v for k, v in f.items() if k != "hypotheque"}, "hypotheque": h, "valeur": V, "rne_1": noi1, | ||||
| 109 | + "paiement": h.paiement, "taux_effectif": fin.taux_effectif_annuel(h.taux_nominal, 2), "taux_mensuel_us": h.taux_nominal / 12, | ||||
| 110 | + "interets_an1": h.interets_annee(1), "capital_an1": h.capital_annee(1), "solde_60": h.solde(60), "solde_120": h.solde(120), | ||||
| 111 | + "rpv_max_par_rcd_sur_V": noi1 / (hyp["financement"]["rcd_min"] * h.constante_hypothecaire) / V, | ||||
| 112 | + "rpv_max_par_rcd_sur_Vcap": r["taux"]["mediane"] / (hyp["financement"]["rcd_min"] * h.constante_hypothecaire), | ||||
| 113 | + "R_dcf": noi1 / V, "R_median": r["taux"]["mediane"], | ||||
| 114 | + "point_mort": fin.ratio_point_mort(b["depenses_totales"], h.service_dette_annuel, b["rbp_contrat"]), | ||||
| 115 | + "point_mort_hors_reserve": fin.ratio_point_mort(b["depenses_hors_reserve"], h.service_dette_annuel, b["rbp_contrat"]), | ||||
| 116 | + "flux_apres_dette_1": noi1 - h.service_dette_annuel, "cash_on_cash_1": (noi1 - h.service_dette_annuel) / f["mise_de_fonds"], | ||||
| 117 | + "dette_sur_fp": f["pret"] / f["mise_de_fonds"], "k_annuel": cout["k_annuel"], "tri_fp": r["levier30"]["tri"], "tri_sans_levier": hyp["taux_actualisation"]} | ||||
| 118 | + | ||||
| 119 | +def levier_courant(r: dict) -> dict: | ||||
| 120 | + """Vérification de R_E = (R − M f)/(1 − M) = R + (R − f) D/E sur la base DCF et sur la base capitalisation directe.""" | ||||
| 121 | + fi = financement(r); f = fi["constante"]; M = fi["rpv"] | ||||
| 122 | + R = fi["R_dcf"]; RE_formule = (R - M * f) / (1 - M); RE_alt = R + (R - f) * fi["dette_sur_fp"] | ||||
| 123 | + # sur la valeur par capitalisation directe (prix = V_cap, même prêt) : | ||||
| 124 | + Vc = r["valeur_capitalisation"]; Mc = fi["pret"] / Vc; Rc = fi["rne_1"] / Vc | ||||
| 125 | + return {"R": R, "f": f, "M": M, "D_sur_E": fi["dette_sur_fp"], "RE_formule": RE_formule, "RE_alt": RE_alt, "RE_direct": fi["cash_on_cash_1"], | ||||
| 126 | + "R_cap": Rc, "M_cap": Mc, "RE_cap": (Rc - Mc * f) / (1 - Mc), "levier_courant": "négatif" if R < f else "positif"} | ||||
| 127 | + | ||||
| 128 | +def levier_par_rpv(r: dict, rpvs=(0.0, 0.25, 0.50, 0.60, None, 0.70, 0.75, 0.80, 0.85)) -> pd.DataFrame: | ||||
| 129 | + """Effet du ratio prêt-valeur sur la mise de fonds, le service de la dette, le RCD, le rendement courant et le TRI des fonds | ||||
| 130 | + propres (achat à la valeur DCF, flux du cas 3, même taux et même amortissement ; None = RPV du prêt retenu).""" | ||||
| 131 | + d = r["dcf"]; hyp = r["hyp"]; fi = r["financement"]; V = d["valeur"]; T = hyp["horizon_ans"] | ||||
| 132 | + noi1 = r["base"]["rne"]; cf = d["flux"] | ||||
| 133 | + rows = [] | ||||
| 134 | + for M in rpvs: | ||||
| 135 | + Mv = fi["rpv"] if M is None else M | ||||
| 136 | + pret = Mv * V | ||||
| 137 | + h = fin.Hypotheque(pret, hyp["financement"]["taux"], hyp["financement"]["amortissement_mois"]) | ||||
| 138 | + res = fin.rendement_fonds_propres_levier(cf, V, h, d["vt_nette"]) | ||||
| 139 | + sd = h.service_dette_annuel | ||||
| 140 | + rows.append({"rpv": Mv, "pret": pret, "mise_de_fonds": V - pret, "service_dette": sd, "rcd_1": (noi1 / sd) if sd > 0 else np.inf, | ||||
| 141 | + "flux_apres_dette_1": noi1 - sd, "cash_on_cash_1": (noi1 - sd) / (V - pret), "tri_fp": res["tri"], | ||||
| 142 | + "flux_min": min(res["flux"][1:-1]), "retenu": M is None}) | ||||
| 143 | + return pd.DataFrame(rows) | ||||
| 144 | + | ||||
| 145 | +def tri_fp_courbe(r: dict, rpvs=np.linspace(0.0, 0.85, 35)) -> pd.DataFrame: | ||||
| 146 | + return levier_par_rpv(r, tuple(float(x) for x in rpvs)) | ||||
| 147 | + | ||||
| 148 | +def pret_maximal(r: dict) -> dict: | ||||
| 149 | + fi = r["financement"]; hyp = r["hyp"]["financement"] | ||||
| 150 | + return {"pret_rpv": fi["pret_rpv"], "pret_rcd": fi["pret_rcd"], "pret": fi["pret"], "contrainte": fi["contrainte"], | ||||
| 151 | + "sd_max": r["base"]["rne"] / hyp["rcd_min"], "f": fi["constante"]} | ||||
| 152 | + | ||||
| 153 | +def grille_pret_max(r: dict, taux=(0.0312, 0.0412, 0.0512, 0.0612), amort=(300, 360, 480), rcd=(1.10, 1.20, 1.30)) -> pd.DataFrame: | ||||
| 154 | + """Prêt maximal par le RCD selon le taux, l'amortissement et le RCD minimal, en dollars et en % de la valeur DCF.""" | ||||
| 155 | + noi1 = r["base"]["rne"]; V = r["dcf"]["valeur"]; rows = [] | ||||
| 156 | + for i in taux: | ||||
| 157 | + for n in amort: | ||||
| 158 | + for q in rcd: | ||||
| 159 | + p = fin.pret_max_par_rcd(noi1, q, i, n) | ||||
| 160 | + rows.append({"taux": i, "amortissement_mois": n, "rcd_min": q, "pret_max": p, "rpv_implicite": p / V, | ||||
| 161 | + "constante": fin.Hypotheque(1.0, i, n).constante_hypothecaire}) | ||||
| 162 | + return pd.DataFrame(rows) | ||||
| 163 | + | ||||
| 164 | +def aph_select(r: dict, rcd_min: float = 1.10, rpv_max: float = 0.85, amorts=(480, 540, 600)) -> pd.DataFrame: | ||||
| 165 | + """Variante APH Select (50 points : RPV 85 %, RCD 1,10 ; 70 points : 45 ans ; 100 points : 50 ans) sur la valeur DCF, au | ||||
| 166 | + même taux de 4,12 % ; prime SCHL selon le barème (prime_aph), capitalisée ou non au prêt (ici affichée séparément).""" | ||||
| 167 | + noi1 = r["base"]["rne"]; V = r["dcf"]["valeur"]; i = r["hyp"]["financement"]["taux"]; rows = [] | ||||
| 168 | + fi = r["financement"] | ||||
| 169 | + rows.append({"formule": "Conventionnel 40 ans (RPV 75 %, RCD 1,20)", "amortissement_mois": 480, "pret_rpv": fi["pret_rpv"], "pret_rcd": fi["pret_rcd"], "pret": fi["pret"], | ||||
| 170 | + "rpv": fi["pret"] / V, "service_dette": fi["service_dette"], "mise_de_fonds": V - fi["pret"], "prime_pct": np.nan, "prime": np.nan, "rcd_1": noi1 / fi["service_dette"]}) | ||||
| 171 | + for n in amorts: | ||||
| 172 | + p_rpv = fin.pret_max_par_rpv(V, rpv_max); p_rcd = fin.pret_max_par_rcd(noi1, rcd_min, i, n); p = min(p_rpv, p_rcd) | ||||
| 173 | + h = fin.Hypotheque(p, i, n); pr = prime_aph(p / V, n) | ||||
| 174 | + rows.append({"formule": f"APH Select {n // 12} ans (RPV {rpv_max:.0%}, RCD {rcd_min:.2f})".replace("%", " %"), "amortissement_mois": n, "pret_rpv": p_rpv, "pret_rcd": p_rcd, "pret": p, | ||||
| 175 | + "rpv": p / V, "service_dette": h.service_dette_annuel, "mise_de_fonds": V - p, "prime_pct": pr, "prime": pr * p, "rcd_1": noi1 / h.service_dette_annuel}) | ||||
| 176 | + return pd.DataFrame(rows) | ||||
| 177 | + | ||||
| 178 | +# ----------------------------------------------------------------------------- B-20, MQR, LTI, mise de fonds | ||||
| 179 | + | ||||
| 180 | +def regles_residentielles() -> dict: | ||||
| 181 | + """Illustrations chiffrées des règles fédérales (résidentiel 1-4 logements).""" | ||||
| 182 | + mqr_na = fin.taux_admissible_minimal(REPERES["non_assure_5ans_plus"]); mqr_as = fin.taux_admissible_minimal(REPERES["assure_5ans_plus"]) | ||||
| 183 | + mqr_bas = fin.taux_admissible_minimal(0.0300) | ||||
| 184 | + prix = (400_000.0, 650_000.0, 1_000_000.0, 1_500_000.0, 1_600_000.0) | ||||
| 185 | + mdf = pd.DataFrame({"prix": prix, "mise_de_fonds": [mise_de_fonds_minimale(p) for p in prix]}) | ||||
| 186 | + mdf["pct"] = mdf["mise_de_fonds"] / mdf["prix"]; mdf["pret"] = mdf["prix"] - mdf["mise_de_fonds"]; mdf["rpv"] = mdf["pret"] / mdf["prix"] | ||||
| 187 | + # capacité d'emprunt d'un ménage : paiement admissible au MQR vs au taux contractuel (ménage fictif, 25 ans) | ||||
| 188 | + revenu = 120_000.0; part_dette = 0.39 # ratio ABD indicatif, hypothèse pédagogique (non une règle citée) | ||||
| 189 | + paiement_mensuel = revenu * part_dette / 12 * 0.85 # 85 % du service de la dette admissible va à l'hypothèque (reste : taxes, chauffage) — hypothèse | ||||
| 190 | + def capacite(i, n=300): | ||||
| 191 | + f = fin.Hypotheque(1.0, i, n).paiement | ||||
| 192 | + return paiement_mensuel / f | ||||
| 193 | + cap = {"revenu": revenu, "paiement_mensuel": paiement_mensuel, "pret_au_contrat": capacite(REPERES["non_assure_5ans_plus"]), "pret_au_mqr": capacite(mqr_na), | ||||
| 194 | + "pret_lti": REPERES["lti_multiple"] * revenu} | ||||
| 195 | + cap["reduction_mqr"] = 1 - cap["pret_au_mqr"] / cap["pret_au_contrat"] | ||||
| 196 | + return {"mqr_non_assure": mqr_na, "mqr_assure": mqr_as, "mqr_taux_bas": mqr_bas, "mise_de_fonds": mdf, "capacite": cap} | ||||
| 197 | + | ||||
| 198 | +def stress_rcd(r: dict, marges=(0.0, 0.01, 0.02, 0.03)) -> pd.DataFrame: | ||||
| 199 | + """Test de résistance « à la B-20 » appliqué au RCD du cas 3 : service de la dette du prêt retenu recalculé au taux contractuel | ||||
| 200 | + + marge (même amortissement), RCD sur le RNE de l'année 1, et prêt maximal si le prêteur qualifiait au taux majoré.""" | ||||
| 201 | + fi = r["financement"]; hyp = r["hyp"]["financement"]; noi1 = r["base"]["rne"]; V = r["dcf"]["valeur"]; rows = [] | ||||
| 202 | + for m in marges: | ||||
| 203 | + i = hyp["taux"] + m | ||||
| 204 | + h = fin.Hypotheque(fi["pret"], i, hyp["amortissement_mois"]) | ||||
| 205 | + pmax = fin.pret_max_par_rcd(noi1, hyp["rcd_min"], i, hyp["amortissement_mois"]) | ||||
| 206 | + rows.append({"marge": m, "taux": i, "service_dette": h.service_dette_annuel, "rcd_1": noi1 / h.service_dette_annuel, | ||||
| 207 | + "pret_max_qualifie": pmax, "rpv_qualifie": pmax / V, "constante": h.constante_hypothecaire}) | ||||
| 208 | + return pd.DataFrame(rows) | ||||
| 209 | + | ||||
| 210 | +# ----------------------------------------------------------------------------- renouvellement au terme | ||||
| 211 | + | ||||
| 212 | +def renouvellement(r: dict, deltas=(0.0, 0.01, 0.02, 0.03), terme_ans: int | None = None) -> dict: | ||||
| 213 | + """Renouvellement exact à l'échéance du terme : solde après `terme` années, nouveau paiement sur l'amortissement résiduel | ||||
| 214 | + au taux initial + delta ; service de la dette, RCD des années suivantes, flux des fonds propres et TRI (achat à la valeur | ||||
| 215 | + DCF, projection de base du cas 3). Comparaison avec l'approximation du chapitre 30 (taux majoré dès l'origine).""" | ||||
| 216 | + d = r["dcf"]; hyp = r["hyp"]; fi = r["financement"]; h0 = fi["hypotheque"]; T = hyp["horizon_ans"] | ||||
| 217 | + terme = terme_ans or hyp["financement"]["terme_ans"]; n0 = hyp["financement"]["amortissement_mois"] | ||||
| 218 | + V = d["valeur"]; mise = V - h0.capital; cf = list(d["flux"]); rne = r["projection"].loc[r["projection"]["annee"] <= T, "rne"].tolist() | ||||
| 219 | + solde_terme = h0.solde(terme * 12); n_res = n0 - terme * 12 | ||||
| 220 | + sd0 = h0.service_dette_annuel | ||||
| 221 | + rows = []; detail = {} | ||||
| 222 | + for dlt in deltas: | ||||
| 223 | + i2 = h0.taux_nominal + dlt | ||||
| 224 | + h2 = fin.Hypotheque(solde_terme, i2, n_res) | ||||
| 225 | + sd2 = h2.service_dette_annuel | ||||
| 226 | + sd = [sd0] * terme + [sd2] * (T - terme) | ||||
| 227 | + solde_T = h2.solde((T - terme) * 12) | ||||
| 228 | + flux = [-mise] + [cf[t] - sd[t] for t in range(T)] | ||||
| 229 | + flux[-1] += d["vt_nette"] - solde_T | ||||
| 230 | + tri = fin.tri(flux) | ||||
| 231 | + rcd = [rne[t] / sd[t] for t in range(T)] | ||||
| 232 | + # approximation du ch. 30 : même prêt, taux majoré dès l'origine | ||||
| 233 | + h_approx = fin.Hypotheque(h0.capital, i2, n0) | ||||
| 234 | + approx = fin.rendement_fonds_propres_levier(cf, V, h_approx, d["vt_nette"]) | ||||
| 235 | + rows.append({"delta": dlt, "taux_renouvele": i2, "solde_terme": solde_terme, "paiement_2": h2.paiement, "service_dette_2": sd2, | ||||
| 236 | + "hausse_sd": sd2 - sd0, "hausse_sd_pct": sd2 / sd0 - 1, "rcd_an_terme_plus_1": rcd[terme], "rcd_min_apres": min(rcd[terme:]), | ||||
| 237 | + "rcd_an_T": rcd[-1], "flux_apres_dette_an_terme_plus_1": cf[terme] - sd2, "solde_T": solde_T, "tri_fp": tri, | ||||
| 238 | + "tri_fp_approx_ch30": approx["tri"], "sd_approx_ch30": h_approx.service_dette_annuel, "interets_an_terme_plus_1": h2.interets_annee(1)}) | ||||
| 239 | + detail[dlt] = {"flux": flux, "rcd": rcd, "sd": sd, "hypotheque_2": h2} | ||||
| 240 | + tab = pd.DataFrame(rows) | ||||
| 241 | + # seuil : hausse de taux qui ramène le RCD de l'année terme+1 à 1,20 et à 1,00 | ||||
| 242 | + def rcd_apres(dlt): | ||||
| 243 | + h2 = fin.Hypotheque(solde_terme, h0.taux_nominal + dlt, n_res); return rne[terme] / h2.service_dette_annuel | ||||
| 244 | + def bissection(cible, lo=0.0, hi=0.20): | ||||
| 245 | + for _ in range(200): | ||||
| 246 | + mid = 0.5 * (lo + hi) | ||||
| 247 | + if rcd_apres(mid) > cible: lo = mid | ||||
| 248 | + else: hi = mid | ||||
| 249 | + return 0.5 * (lo + hi) | ||||
| 250 | + return {"tableau": tab, "detail": detail, "solde_terme": solde_terme, "n_residuel": n_res, "terme": terme, | ||||
| 251 | + "delta_rcd_120": bissection(1.20), "delta_rcd_100": bissection(1.00), "taux_rcd_120": h0.taux_nominal + bissection(1.20), "taux_rcd_100": h0.taux_nominal + bissection(1.00)} | ||||
| 252 | + | ||||
| 253 | +def sensibilite_service_dette(r: dict, taux=np.arange(0.03, 0.0801, 0.0025), amorts=(300, 360, 480)) -> pd.DataFrame: | ||||
| 254 | + """Service de la dette et RCD (RNE_1) du prêt retenu selon le taux et l'amortissement (pour la figure et le tableau).""" | ||||
| 255 | + fi = r["financement"]; noi1 = r["base"]["rne"]; rows = [] | ||||
| 256 | + for n in amorts: | ||||
| 257 | + for i in taux: | ||||
| 258 | + h = fin.Hypotheque(fi["pret"], float(i), n) | ||||
| 259 | + rows.append({"amortissement_mois": n, "taux": float(i), "service_dette": h.service_dette_annuel, "rcd_1": noi1 / h.service_dette_annuel, "constante": h.constante_hypothecaire}) | ||||
| 260 | + return pd.DataFrame(rows) | ||||
| 261 | + | ||||
| 262 | +def risque_levier(r: dict, sigma_actif: float = 0.08) -> dict: | ||||
| 263 | + """Illustration : écart-type du rendement des fonds propres = σ_actif / (1 − M) (dette sans risque, une période).""" | ||||
| 264 | + fi = r["financement"]; M = fi["rpv"] | ||||
| 265 | + return {"sigma_actif": sigma_actif, "M": M, "sigma_fp": sigma_actif / (1 - M), "multiplicateur": 1 / (1 - M), | ||||
| 266 | + "grille": pd.DataFrame({"rpv": [0, 0.25, 0.5, M, 0.75, 0.85], "sigma_fp": [sigma_actif / (1 - m) for m in [0, 0.25, 0.5, M, 0.75, 0.85]]})} | ||||
| 267 | + | ||||
| 268 | +# ----------------------------------------------------------------------------- exercices | ||||
| 269 | + | ||||
| 270 | +def exercices() -> dict: | ||||
| 271 | + r = base(); e = {} | ||||
| 272 | + # ex 1 : convention canadienne vs américaine, 850 000 $, 4,58 %, 300 mois | ||||
| 273 | + e["ex1"] = conventions(850_000.0, 0.0458, 300).set_index("convention").to_dict("index") | ||||
| 274 | + # ex 2 : terme vs amortissement : prêt 1 000 000 $, 4,12 %, 480 mois ; solde à 60 et 120 ; intérêts an 1 ; part du capital remboursée au terme | ||||
| 275 | + h = fin.Hypotheque(1_000_000.0, 0.0412, 480) | ||||
| 276 | + e["ex2"] = {"paiement": h.paiement, "sd": h.service_dette_annuel, "solde_60": h.solde(60), "solde_120": h.solde(120), "interets_an1": h.interets_annee(1), | ||||
| 277 | + "capital_an1": h.capital_annee(1), "part_remboursee_60": 1 - h.solde(60) / h.capital, "interets_totaux": h.paiement * 480 - h.capital, | ||||
| 278 | + "h25": fin.Hypotheque(1_000_000.0, 0.0412, 300)} | ||||
| 279 | + e["ex2"]["interets_totaux_25"] = e["ex2"]["h25"].paiement * 300 - 1_000_000.0; e["ex2"]["sd_25"] = e["ex2"]["h25"].service_dette_annuel | ||||
| 280 | + # ex 3 : prêt maximal : RNE 120 000 $, valeur 2 600 000 $, RPV 75 %, RCD 1,25, 4,58 %, 360 mois ; puis APH 1,10 / 85 % / 480 mois | ||||
| 281 | + e["ex3"] = {"pret_rpv": fin.pret_max_par_rpv(2_600_000, 0.75), "pret_rcd": fin.pret_max_par_rcd(120_000, 1.25, 0.0458, 360), | ||||
| 282 | + "f": fin.Hypotheque(1.0, 0.0458, 360).constante_hypothecaire, "pret_rcd_aph": fin.pret_max_par_rcd(120_000, 1.10, 0.0412, 480), | ||||
| 283 | + "pret_rpv_aph": fin.pret_max_par_rpv(2_600_000, 0.85), "f_aph": fin.Hypotheque(1.0, 0.0412, 480).constante_hypothecaire} | ||||
| 284 | + e["ex3"]["pret"] = min(e["ex3"]["pret_rpv"], e["ex3"]["pret_rcd"]); e["ex3"]["pret_aph"] = min(e["ex3"]["pret_rpv_aph"], e["ex3"]["pret_rcd_aph"]) | ||||
| 285 | + e["ex3"]["prime_aph"] = prime_aph(e["ex3"]["pret_aph"] / 2_600_000, 480); e["ex3"]["rpv_aph"] = e["ex3"]["pret_aph"] / 2_600_000 | ||||
| 286 | + # ex 4 : levier courant : R = 5,2 %, f = 6,0 % et 4,5 %, M = 0,70 | ||||
| 287 | + e["ex4"] = {M: {f: (0.052 - M * f) / (1 - M) for f in (0.060, 0.045)} for M in (0.0, 0.5, 0.7, 0.8)} | ||||
| 288 | + # ex 5 : MQR et mise de fonds : prix 720 000 $, taux 4,58 % | ||||
| 289 | + e["ex5"] = {"mdf": mise_de_fonds_minimale(720_000), "mqr": fin.taux_admissible_minimal(0.0458), "mqr_bas": fin.taux_admissible_minimal(0.0300), | ||||
| 290 | + "paiement_contrat": fin.Hypotheque(720_000 - mise_de_fonds_minimale(720_000), 0.0458, 300).paiement, | ||||
| 291 | + "paiement_mqr": fin.Hypotheque(720_000 - mise_de_fonds_minimale(720_000), fin.taux_admissible_minimal(0.0458), 300).paiement, | ||||
| 292 | + "lti_revenu_requis": (720_000 - mise_de_fonds_minimale(720_000)) / 4.5} | ||||
| 293 | + # ex 6 : renouvellement du cas 3 à +150 pb | ||||
| 294 | + e["ex6"] = renouvellement(r, deltas=(0.015,))["tableau"].iloc[0].to_dict() | ||||
| 295 | + # ex 7 : levier et TRI par RPV (cas 3) : 50 % et 85 % | ||||
| 296 | + e["ex7"] = levier_par_rpv(r, (0.50, 0.85)).to_dict("records") | ||||
| 297 | + e["ex7_k"] = financement(r)["k_annuel"] | ||||
| 298 | + return e | ||||
| 299 | + | ||||
| 300 | +# ----------------------------------------------------------------------------- sortie | ||||
| 301 | + | ||||
| 302 | +def _d(x): return f"{x:,.0f}".replace(",", " ") + " $" | ||||
| 303 | +def _p(x, d=2): return f"{x*100:.{d}f}".replace(".", ",") + " %" | ||||
| 304 | + | ||||
| 305 | +def main(): | ||||
| 306 | + r = base(); fi = financement(r) | ||||
| 307 | + print("=== Cas 3 — financement (traite.cas3.financement) ===") | ||||
| 308 | + for k in ("valeur", "rne_1", "pret_rpv", "pret_rcd", "pret", "contrainte", "rpv", "mise_de_fonds", "paiement", "service_dette", "constante", "taux_mensuel", "taux_mensuel_us", "taux_effectif", | ||||
| 309 | + "interets_an1", "capital_an1", "solde_60", "solde_120", "rcd", "flux_apres_dette_1", "cash_on_cash_1", "dette_sur_fp", "point_mort", "point_mort_hors_reserve", | ||||
| 310 | + "rpv_max_par_rcd_sur_V", "rpv_max_par_rcd_sur_Vcap", "R_dcf", "R_median", "k_annuel", "tri_fp", "tri_sans_levier"): | ||||
| 311 | + v = fi[k]; print(f"{k:28s} {v if not isinstance(v, float) else (round(v, 6) if abs(v) < 10 else round(v, 2))}") | ||||
| 312 | + print("\n=== Conventions (prêt du cas 3) ===\n", conventions(fi["pret"]).T) | ||||
| 313 | + print("\n=== Tableau d'amortissement — extraits ===\n", tableau_extraits(fi["hypotheque"]).round(2).to_string()) | ||||
| 314 | + dec = decomposition_annuelle(fi["hypotheque"]); print("\n=== Décomposition annuelle (années 1, 5, 10, 20, 30, 40) ===\n", dec[dec["annee"].isin([1, 5, 10, 20, 30, 40])].round(0).to_string()) | ||||
| 315 | + print("\n=== Levier courant ===\n", levier_courant(r)) | ||||
| 316 | + print("\n=== Levier par RPV ===\n", levier_par_rpv(r).round(4).to_string()) | ||||
| 317 | + print("\n=== Prêt maximal ===\n", pret_maximal(r)) | ||||
| 318 | + print("\n=== Grille prêt max (RCD) ===\n", grille_pret_max(r).round(4).to_string()) | ||||
| 319 | + print("\n=== APH Select ===\n", aph_select(r).round(4).to_string()) | ||||
| 320 | + rr = regles_residentielles(); print("\n=== Règles résidentielles ===\n", {k: v for k, v in rr.items() if k != "mise_de_fonds"}, "\n", rr["mise_de_fonds"].round(4).to_string()) | ||||
| 321 | + print("\n=== Stress RCD (taux + marge) ===\n", stress_rcd(r).round(4).to_string()) | ||||
| 322 | + ren = renouvellement(r); print("\n=== Renouvellement au terme ===\n", ren["tableau"].round(4).T.to_string(), "\nseuils :", {k: ren[k] for k in ("solde_terme", "n_residuel", "delta_rcd_120", "delta_rcd_100", "taux_rcd_120", "taux_rcd_100")}) | ||||
| 323 | + print("\n=== Risque ===\n", risque_levier(r)["grille"].round(4).to_string()) | ||||
| 324 | + s = sensibilite_service_dette(r, taux=np.array([0.03, 0.04, 0.0412, 0.05, 0.06, 0.07, 0.08])); print("\n=== Service de la dette vs taux ===\n", s.round(4).to_string()) | ||||
| 325 | + print("\n=== Exercices ===") | ||||
| 326 | + e = exercices() | ||||
| 327 | + for k, v in e.items(): print(k, v) | ||||
| 328 | + | ||||
| 329 | +if __name__ == "__main__": | ||||
| 330 | + main() | ||||
added
analysis/python/cases/ch32_plex.py
+298 −0
modified
analysis/python/figures/ch12_figures.py
+1 −1
modified
analysis/python/figures/ch14_figures.py
+10 −7
added
analysis/python/figures/ch15_figures.py
+132 −0
modified
analysis/python/figures/ch22_ecarts.py
+1 −1
added
analysis/python/figures/ch23_ipcb_divisions.py
+38 −0
added
analysis/python/figures/ch23_ipcb_types.py
+44 −0
added
analysis/python/figures/ch23_sensibilite.py
+40 −0
added
analysis/python/figures/ch23_structure.py
+35 −0
added
analysis/python/figures/ch24_composantes.py
+40 −0
added
analysis/python/figures/ch24_courbes.py
+58 −0
added
analysis/python/figures/ch24_decomposition.py
+60 −0
added
analysis/python/figures/ch24_sensibilite.py
+47 −0
added
analysis/python/figures/ch25_figures.py
+126 −0
added
analysis/python/figures/ch26_figures.py
+104 −0
added
analysis/python/figures/ch27_figures.py
+139 −0
added
analysis/python/figures/ch31_levier.py
+37 −0
added
analysis/python/figures/ch31_service_dette.py
+36 −0
added
analysis/python/figures/ch31_solde.py
+41 −0
added
analysis/python/figures/ch31_taux_bdc.py
+48 −0
modified
analysis/python/tests/test_ch11.py
+8 −0
added
analysis/python/tests/test_ch14.py
+185 −0
modified
analysis/python/tests/test_ch22.py
+41 −38
added
analysis/python/tests/test_ch23.py
+280 −0
added
analysis/python/tests/test_ch26.py
+178 −0
modified
analysis/python/traite/finance.py
+15 −0
modified
assets/figures/ch12/ch12-fig-attenuation.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch12/ch12-fig-conventions-sujet.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch12/ch12-fig-erreur-valeur.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch14/ch14-fig-avant-apres.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch14/ch14-fig-geocodage.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch14/ch14-fig-levier-cook.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch14/ch14-fig-scores-robustes.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch14/ch14-fig-sensibilite-seuils.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch15/ch15-fig-carte-segments.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch15/ch15-fig-coefficients-secteurs.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch15/ch15-fig-dendrogramme.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch15/ch15-fig-loyers-rosemont.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch15/ch15-fig-mois-inventaire.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch15/ch15-fig-silhouette.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch22/ch22-fig-ecarts-metre.pdf
+0 −0
Binary file not shown.
modified
assets/figures/ch22/ch22-fig-repartition-corps.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch23/ch23-fig-ipcb-divisions.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch23/ch23-fig-ipcb-types.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch23/ch23-fig-sensibilite.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch23/ch23-fig-structure-couts.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch24/ch24-fig-composantes.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch24/ch24-fig-courbes.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch24/ch24-fig-decomposition.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch24/ch24-fig-sensibilite.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch25/ch25-fig-carte-terrains.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch25/ch25-fig-lotissement.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch25/ch25-fig-residuelle-sensibilite.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch25/ch25-fig-valeur-unitaire-superficie.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch26/ch26-fig-anciennete.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch26/ch26-fig-contrat-marche.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch26/ch26-fig-normalisation.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch26/ch26-fig-va-ecart.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch27/ch27-fig-cascade.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch27/ch27-fig-inoccupation-rmr.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch27/ch27-fig-sensibilite.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch27/ch27-fig-trajectoires.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch31/ch31-fig-levier.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch31/ch31-fig-service-dette.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch31/ch31-fig-solde.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch31/ch31-fig-taux-bdc.pdf
+0 −0
Binary file not shown.