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