SPB Git

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

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

98 changed files +5,322 −82

98 changed files
modified analysis/python/cases/ch12_mesurage.py +1 −1
@@ -37,7 +37,7 @@ PLAN = {
37 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 38 "garage_long_m": 6.10, "garage_larg_m": 3.66, # garage simple attaché (20 pi × 12 pi), hors empreinte principale
39 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 188 y = np.log(v["prix_vente"].astype(float)).values; Xb = matrice_base(v)
189 189 kf = KFold(n_splits=n_plis, shuffle=True, random_state=GRAINE)
190 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 194 resultats = []
193 195 specs = {"Équation unique sans segment": ("aucun", v["secteur"].astype(str))}
194 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 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 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 284 # ----------------------------------------------------------------------------- contrôle croisé facultatif
262 285
263 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
Large diff (298 lines) is not loaded by default to keep the page fast.Load diff
modified analysis/python/figures/ch12_figures.py +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified analysis/python/figures/ch14_figures.py +10 −7
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch15_figures.py +132 −0
Large diff (132 lines) is not loaded by default to keep the page fast.Load diff
modified analysis/python/figures/ch22_ecarts.py +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch23_ipcb_divisions.py +38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch23_ipcb_types.py +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch23_sensibilite.py +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch23_structure.py +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch24_composantes.py +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch24_courbes.py +58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch24_decomposition.py +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch24_sensibilite.py +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch25_figures.py +126 −0
Large diff (126 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch26_figures.py +104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch27_figures.py +139 −0
Large diff (139 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch31_levier.py +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch31_service_dette.py +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch31_solde.py +41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/figures/ch31_taux_bdc.py +48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
modified analysis/python/tests/test_ch11.py +8 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch14.py +185 −0
Large diff (185 lines) is not loaded by default to keep the page fast.Load diff
modified analysis/python/tests/test_ch22.py +41 −38
Large diff (151 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch23.py +280 −0
Large diff (280 lines) is not loaded by default to keep the page fast.Load diff
added analysis/python/tests/test_ch26.py +178 −0
Large diff (178 lines) is not loaded by default to keep the page fast.Load diff
modified analysis/python/traite/finance.py +15 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
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.

added data/synthetic/README-brut.md +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added data/synthetic/ch25_terrains_ventes.csv +201 −0
Large diff (201 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch11.tex +24 −11
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch12.tex +9 −9
Large diff (63 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch19.tex +382 −0
Large diff (385 lines) is not loaded by default to keep the page fast.Load diff
added references/lib-ch11.bib +123 −0
Large diff (123 lines) is not loaded by default to keep the page fast.Load diff
added references/lib-ch12.bib +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added references/lib-ch14.bib +114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
modified references/library.bib +268 −13
Large diff (293 lines) is not loaded by default to keep the page fast.Load diff
modified solutions/ch11.tex +61 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
modified solutions/ch12.tex +49 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
modified sources/registry.csv +6 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified tools/BRIEF-CHAPITRE.md +2 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified verification/calculations.csv +23 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch11-calculations.csv +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch11-claims.csv +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch11-figures.csv +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch12-calculations.csv +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch12-claims.csv +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch12-figures.csv +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch12-registry.csv +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch12.md +36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
modified verification/claims.csv +44 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
modified verification/figures.csv +10 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme