SPB Git

Ch. 11 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 a58cc97 Browse files

48 changed files +2,252 −35

48 changed files
added analysis/python/cases/ch28_depenses.py +313 −0
@@ -0,0 +1,313 @@
1 +#!/usr/bin/env python3
2 +"""Chapitre 28 — Dépenses, réserves, CAPEX et RNE : calculs du cas 3 (12 logements, Rosemont) et du cas 2 (triplex, Limoilou).
3 +DONNÉES SYNTHÉTIQUES (graine 20261031).
4 +
5 +Tous les chiffres cités dans manuscript/chapters/ch28.tex et solutions/ch28.tex sortent d'ici. Les calculs partagés
6 +(revenus, dépenses normalisées, RNE) viennent de traite.cas3 et traite.cas2, pour coïncider avec les chapitres 29, 30,
7 +32, 46 et 47 ; les formules financières viennent de traite.finance. Aucune formule partagée n'est réécrite ; les
8 +calculs propres au chapitre (classement, normalisation alternative, équivalence réserve/CAPEX, sensibilités) sont ici.
9 +
10 +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch28_depenses.py
11 +"""
12 +from __future__ import annotations
13 +import sys, pathlib
14 +import numpy as np, pandas as pd
15 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
16 +from traite import cas3, cas2, finance as fin
17 +
18 +ROOT = pathlib.Path(__file__).resolve().parents[3]
19 +
20 +# Taux global médian extrait au chapitre 29 (traite.cas3.taux_extraits ; revenu net APRÈS réserve). Repris ici pour les
21 +# démonstrations de cohérence ; recalculé par calculer() et vérifié par le test.
22 +GROUPES = { # regroupement des postes pour les figures et les ratios de référence
23 + "Taxes municipales": "Taxes foncières", "Taxe scolaire": "Taxes foncières", "Assurances": "Assurances",
24 + "Électricité (aires communes)": "Énergie des aires communes", "Chauffage (aires communes, gaz)": "Énergie des aires communes",
25 + "Eau et égouts (taxe)": "Taxes foncières", "Entretien et réparations": "Entretien et réparations",
26 + "Conciergerie": "Conciergerie et services", "Déneigement et paysagement": "Conciergerie et services",
27 + "Déneigement et entretien extérieur": "Conciergerie et services",
28 + "Frais juridiques et comptables": "Honoraires et publicité", "Publicité et location": "Honoraires et publicité",
29 + "Gestion (4 % du RBE)": "Gestion", "Gestion (propriétaire-occupant : imputée 4 %)": "Gestion",
30 + "Réserve de remplacement (par logement)": "Réserve de remplacement", "Réserve de remplacement": "Réserve de remplacement",
31 +}
32 +ORDRE_GROUPES = ["Taxes foncières", "Assurances", "Énergie des aires communes", "Entretien et réparations",
33 + "Conciergerie et services", "Honoraires et publicité", "Gestion", "Réserve de remplacement"]
34 +
35 +# Composantes à vie courte couvertes par la réserve — HYPOTHÈSES PÉDAGOGIQUES déclarées (coût unitaire par logement, durée
36 +# de vie en années). Elles servent uniquement à illustrer les deux méthodes de calcul d'une réserve (linéaire et fonds
37 +# d'amortissement) ; elles ne proviennent d'aucun barème publié.
38 +COMPOSANTES_RESERVE = [
39 + ("Cuisinière et réfrigérateur", 2200.0, 12),
40 + ("Chauffe-eau", 1100.0, 10),
41 + ("Revêtements de sol", 3600.0, 20),
42 + ("Peinture et finition intérieure", 1500.0, 7),
43 +]
44 +
45 +
46 +def _groupes(detail: pd.DataFrame, col: str = "normalise") -> pd.Series:
47 + g = detail.assign(groupe=detail["poste"].map(GROUPES)).groupby("groupe")[col].sum()
48 + return g.reindex(ORDRE_GROUPES).fillna(0.0)
49 +
50 +
51 +def historique_cas3(dep: pd.DataFrame) -> pd.DataFrame:
52 + """Pivot 2023-2025 par poste, avec variation totale 2025/2023 et taux de croissance annuel composé."""
53 + piv = dep.pivot(index="poste", columns="annee", values="montant").astype(float)
54 + ordre = ["Taxes municipales", "Taxe scolaire", "Assurances", "Électricité (aires communes)", "Chauffage (aires communes, gaz)",
55 + "Eau et égouts (taxe)", "Entretien et réparations", "Conciergerie", "Déneigement et paysagement",
56 + "Frais juridiques et comptables", "Publicité et location", "Réserve de remplacement (par logement)"]
57 + piv = piv.reindex(ordre)
58 + piv.loc["Total"] = piv.sum()
59 + with np.errstate(divide="ignore", invalid="ignore"):
60 + piv["var_2025_2023"] = piv[2025] / piv[2023] - 1
61 + piv["tcac"] = (piv[2025] / piv[2023]) ** 0.5 - 1
62 + return piv
63 +
64 +
65 +def normalisation_alternative(dep: pd.DataFrame, g: float = 0.03, annee_cible: int = 2026) -> pd.DataFrame:
66 + """Pour les postes indexés : (a) dernière année × (1+g) [règle du livre] ; (b) moyenne des trois exercices, chacun
67 + ramené en dollars de l'année cible au taux g (moyenne indexée). Permet de comparer les deux conventions."""
68 + piv = dep.pivot(index="poste", columns="annee", values="montant").astype(float)
69 + rows = []
70 + for poste, r in piv.iterrows():
71 + indexes = {an: r[an] * (1 + g) ** (annee_cible - an) for an in (2023, 2024, 2025)}
72 + rows.append({"poste": poste, "derniere_indexee": indexes[2025], "moyenne_indexee": float(np.mean(list(indexes.values()))),
73 + "min_indexe": min(indexes.values()), "max_indexe": max(indexes.values())})
74 + out = pd.DataFrame(rows).set_index("poste")
75 + out["ecart"] = out["moyenne_indexee"] - out["derniere_indexee"]
76 + return out
77 +
78 +
79 +def reserve_par_composantes(composantes=COMPOSANTES_RESERVE, nb_logements: int = 12, taux: float = 0.03) -> dict:
80 + """Réserve annuelle par la méthode linéaire (coût/durée) et par le fonds d'amortissement (coût × SFF au taux donné)."""
81 + rows = []
82 + for nom, cout, vie in composantes:
83 + lin = cout / vie
84 + sff = cout * fin.facteur_fonds_amortissement(taux, vie)
85 + rows.append({"composante": nom, "cout_par_logement": cout, "duree_vie": vie, "lineaire_par_logement": lin, "fonds_par_logement": sff,
86 + "lineaire_immeuble": lin * nb_logements, "fonds_immeuble": sff * nb_logements})
87 + df = pd.DataFrame(rows)
88 + return {"detail": df, "lineaire_par_logement": float(df["lineaire_par_logement"].sum()), "fonds_par_logement": float(df["fonds_par_logement"].sum()),
89 + "lineaire_immeuble": float(df["lineaire_immeuble"].sum()), "fonds_immeuble": float(df["fonds_immeuble"].sum()), "taux": taux}
90 +
91 +
92 +def demonstration_reserve(noi_apres: float, reserve: float, R_apres: float) -> dict:
93 + """Cohérence réserve/taux : V cohérent ; taux « avant réserve » équivalent R' = R + RR/V ; valeur erronée si R' est
94 + appliqué au revenu après réserve ; écart exact ΔV = RR/R' ; erreur relative −RR/(RNE+RR)."""
95 + V = fin.capitalisation_directe(noi_apres, R_apres)
96 + R_avant = fin.taux_global_extrait(noi_apres + reserve, V)
97 + V_err = fin.capitalisation_directe(noi_apres, R_avant)
98 + V_err_inverse = fin.capitalisation_directe(noi_apres + reserve, R_apres) # taux après réserve sur revenu avant réserve
99 + return {"V": V, "R_apres": R_apres, "R_avant": R_avant, "ecart_taux_pb": (R_avant - R_apres) * 1e4, "V_err": V_err,
100 + "delta_V": V - V_err, "delta_V_formule": reserve / R_avant, "erreur_relative": V_err / V - 1,
101 + "erreur_relative_formule": -reserve / (noi_apres + reserve), "approx_reserve_sur_R": reserve / R_apres,
102 + "V_err_inverse": V_err_inverse, "erreur_relative_inverse": V_err_inverse / V - 1}
103 +
104 +
105 +def equivalence_capex(hyp: dict, r: float | None = None, T: int | None = None) -> dict:
106 + """Les CAPEX programmés du dossier (flux actualisés) convertis en charge annuelle équivalente sur l'horizon : montre
107 + ce qu'une réserve « pour travaux lourds » représenterait si on la comptait en plus des CAPEX (double comptage)."""
108 + r = hyp["taux_actualisation"] if r is None else r; T = hyp["horizon_ans"] if T is None else T
109 + an0 = int(hyp["date_evaluation"][:4])
110 + flux = [0.0] * (T + 1)
111 + for an, m in hyp["capex"].items():
112 + t = int(an) - an0 + 1
113 + if 1 <= t <= T: flux[t] = float(m)
114 + va = fin.van(flux, r) # flux[0] = 0 à t = 0
115 + annuite = va * fin.facteur_recouvrement(r, T)
116 + total = float(sum(hyp["capex"].values()))
117 + return {"capex": {int(k): float(v) for k, v in hyp["capex"].items()}, "total": total, "va": va, "annuite_equivalente": annuite,
118 + "moyenne_simple": total / T, "r": r, "T": T}
119 +
120 +
121 +def classement(detail: pd.DataFrame, rbe: float, nb_log: int, surface_m2: float) -> dict:
122 + d = detail.copy()
123 + fixes = float(d.loc[d["type"] == "fixe", "normalise"].sum()); variables = float(d.loc[d["type"] == "variable", "normalise"].sum())
124 + reserve = float(d.loc[d["type"] == "réserve", "normalise"].sum()); total = fixes + variables + reserve
125 + gestion = float(d.loc[d["poste"].str.startswith("Gestion"), "normalise"].sum())
126 + return {"fixes": fixes, "variables": variables, "variables_hors_gestion": variables - gestion, "gestion": gestion, "reserve": reserve, "total": total,
127 + "part_fixes": fixes / total, "part_variables": variables / total, "part_reserve": reserve / total,
128 + "fixes_pct_rbe": fixes / rbe, "variables_pct_rbe": variables / rbe, "reserve_pct_rbe": reserve / rbe, "total_pct_rbe": total / rbe,
129 + "total_par_logement": total / nb_log, "total_par_m2": total / surface_m2, "groupes": _groupes(d),
130 + "groupes_pct_rbe": _groupes(d) / rbe, "groupes_par_logement": _groupes(d) / nb_log}
131 +
132 +
133 +def rbe_fonction_loyer(loyer_moyen: float, nb_log: int, v: float, autres: float) -> float:
134 + return fin.revenu_brut_effectif(loyer_moyen * nb_log * 12, v, autres)
135 +
136 +
137 +def ratio_iso_depenses(loyer_moyen, base: dict, hyp: dict, nb_log: int = 12, gestion: float = 0.04):
138 + """Ratio de dépenses du sujet si son loyer moyen était L (dépenses hors gestion et réserve inchangées, gestion en % du RBE)."""
139 + L = np.asarray(loyer_moyen, dtype=float)
140 + rbe = rbe_fonction_loyer(L, nb_log, hyp["taux_inoccupation_pertes"], hyp["autres_revenus_annuels"])
141 + de_hors_gestion = base["depenses_hors_reserve"] - gestion * base["rbe"]
142 + return (de_hors_gestion + gestion * rbe + base["reserve"]) / rbe
143 +
144 +
145 +def sensibilites(base: dict, hyp: dict, R: float, nb_log: int = 12) -> dict:
146 + """Sensibilité du RNE (et de V = RNE/R à taux inchangé) à la réserve par logement, au taux de taxe et au taux de gestion."""
147 + rbe = base["rbe"]; de_hors_res = base["depenses_hors_reserve"]; res0 = base["reserve"]; noi0 = base["rne"]; V0 = noi0 / R
148 + role = hyp["valeur_role_2026"]; taxe0 = role * hyp["taux_taxe_fonciere"]; gestion0 = 0.04 * rbe
149 + # réserve par logement
150 + rows_r = []
151 + for rpl in [0, 100, 300, 500, 675.25, 900, 1200]:
152 + res = rpl * nb_log; noi = fin.rne(rbe, de_hors_res, res)
153 + rows_r.append({"reserve_par_logement": rpl, "reserve": res, "rne": noi, "V": noi / R, "dV_pct": noi / noi0 - 1, "ratio": (de_hors_res + res) / rbe})
154 + # taux de taxe municipale ($/100 $), rôle inchangé
155 + rows_t = []
156 + scen = [("Montréal, taxe générale 2026 (base)", hyp["taux_taxe_fonciere"]),
157 + ("Compte 2025 indexé de 5,3 % (Rosemont)", 10631 * 1.053 / role),
158 + ("Gatineau, six logements ou plus (6,964 $/1 000 $)", 0.006964),
159 + ("Québec, six logements et plus (0,7697 $/100 $)", 0.007697),
160 + ("Taux + 10 points de base", hyp["taux_taxe_fonciere"] + 0.001)]
161 + for nom, tau in scen:
162 + taxe = role * tau; noi = fin.rne(rbe, de_hors_res - taxe0 + taxe, res0)
163 + rows_t.append({"scenario": nom, "taux_par_100": tau * 100, "taxe": taxe, "delta_taxe": taxe - taxe0, "rne": noi, "V": noi / R, "dV_pct": noi / noi0 - 1})
164 + # taux de gestion
165 + rows_g = []
166 + for m in [0.0, 0.03, 0.04, 0.05, 0.06, 0.10]:
167 + noi = fin.rne(rbe, de_hors_res - gestion0 + m * rbe, res0)
168 + rows_g.append({"gestion_pct": m, "gestion": m * rbe, "rne": noi, "V": noi / R, "dV_pct": noi / noi0 - 1})
169 + # dérivée : un dollar de dépense = 1/R dollars de valeur
170 + return {"reserve": pd.DataFrame(rows_r), "taxe": pd.DataFrame(rows_t), "gestion": pd.DataFrame(rows_g), "dV_dDE": -1.0 / R, "V0": V0,
171 + "elasticite_rne_depenses": -(de_hors_res + res0) / noi0}
172 +
173 +
174 +def cas2_detail() -> dict:
175 + d = cas2.charger(); b = cas2.rne(d["loyers"], d["depenses"]); s = d["sujet"]
176 + det = b["detail"].copy(); det["type_livre"] = det["type"].replace({"imputée": "variable"})
177 + det_cl = pd.DataFrame({"poste": det["poste"], "normalise": det["normalise"], "type": det["type_livre"]})
178 + cl = classement(det_cl, b["rbe"], s["nb_logements"], s["superficie_habitable_m2"])
179 + # vérification des taxes de Québec 2026 : taux 1-5 logements + tarifs par logement ; taxe scolaire sur (rôle − 25 000 $)
180 + role = s["valeur_role_2025"]; n = s["nb_logements"]
181 + taxe_gen = role * 0.007464; tarifs = n * (386 + 195); taxe_mun_recalc = taxe_gen + tarifs
182 + scol_recalc = max(role - 25000, 0) * 0.0007899
183 + ind = cas2.indicateurs_marche(cas2.comparables_plex(d["ventes"], s))
184 + return {**d, "base": b, "indicateurs": ind, "classement": cl, "detail": det, "rne_par_logement": b["rne"] / n, "rne_par_m2": b["rne"] / s["superficie_habitable_m2"],
185 + "taxes_quebec_recalc": {"taxe_generale": taxe_gen, "tarifs": tarifs, "total": taxe_mun_recalc, "declaree": 5900.0, "ecart": 5900.0 - taxe_mun_recalc,
186 + "scolaire_recalc": scol_recalc, "scolaire_declaree": 470.0},
187 + "gestion_imputee": float(det.loc[det["type"] == "imputée", "normalise"].sum()), "reserve_par_logement": b["reserve"] / n}
188 +
189 +
190 +def calculer() -> dict:
191 + d = cas3.charger(); etat, dep, hyp, sujet, comps = d["etat"], d["dep"], d["hyp"], d["sujet"], d["comps"]
192 + base = cas3.rne(etat, dep, hyp); tx = cas3.taux_extraits(comps); R = tx["mediane"]
193 + nb = sujet["nb_logements"]; surf = sujet["superficie_locative_m2"]
194 + hist = historique_cas3(dep); alt = normalisation_alternative(dep, hyp["croissance_depenses"])
195 + cl = classement(base["detail"], base["rbe"], nb, surf)
196 + det = base["detail"].copy(); det["regle"] = det["poste"].map(lambda p: "rôle × taux" if p.startswith("Tax") else ("maintenue" if p.startswith("Réserve") else ("% du RBE" if p.startswith("Gestion") else "× 1,03")))
197 + det["pct_rbe"] = det["normalise"] / base["rbe"]; det["par_logement"] = det["normalise"] / nb
198 + demo = demonstration_reserve(base["rne"], base["reserve"], R)
199 + res_comp = reserve_par_composantes(nb_logements=nb, taux=hyp["croissance_depenses"])
200 + eq_capex = equivalence_capex(hyp)
201 + sens = sensibilites(base, hyp, R, nb)
202 + # revenu net et ratio si les loyers étaient au marché (même convention que l'exercice 29.7)
203 + rbe_m = fin.revenu_brut_effectif(base["rbp_marche"], hyp["taux_inoccupation_pertes"], hyp["autres_revenus_annuels"])
204 + de_m = base["depenses_hors_reserve"] - 0.04 * base["rbe"] + 0.04 * rbe_m
205 + noi_m = fin.rne(rbe_m, de_m, base["reserve"])
206 + marche = {"rbe": rbe_m, "depenses_hors_reserve": de_m, "rne": noi_m, "ratio": (de_m + base["reserve"]) / rbe_m,
207 + "loyer_moyen": base["rbp_marche"] / 12 / nb, "loyer_moyen_contrat": base["rbp_contrat"] / 12 / nb}
208 + comps_stats = {"n": len(comps), "ratio_median": float(comps["ratio_depenses"].median()), "ratio_q1": float(comps["ratio_depenses"].quantile(0.25)),
209 + "ratio_q3": float(comps["ratio_depenses"].quantile(0.75)), "ratio_min": float(comps["ratio_depenses"].min()), "ratio_max": float(comps["ratio_depenses"].max()),
210 + "corr_ratio_loyer": float(comps[["ratio_depenses", "loyer_mensuel_moyen"]].corr().iloc[0, 1]),
211 + "loyer_median": float(comps["loyer_mensuel_moyen"].median())}
212 + # revenu net historique 2025 « tel que déclaré » (sans gestion, sans indexation) pour l'erreur du RNE historique
213 + dep_2025 = float(hist.loc["Total", 2025]); noi_hist = base["rbe"] - dep_2025
214 + return {**d, "base": base, "R": R, "taux": tx, "nb_logements": nb, "surface_m2": surf, "historique": hist, "alternative": alt, "classement": cl, "detail": det,
215 + "demo_reserve": demo, "reserve_composantes": res_comp, "equivalence_capex": eq_capex, "sensibilites": sens, "marche": marche, "comps_stats": comps_stats,
216 + "rne_par_logement": base["rne"] / nb, "rne_par_m2": base["rne"] / surf, "rne_pct_rbe": base["rne"] / base["rbe"],
217 + "depenses_2025_declarees": dep_2025, "rne_historique_2025": noi_hist, "ecart_normalisation": base["depenses_totales"] - dep_2025,
218 + "V_historique": noi_hist / R, "V": base["rne"] / R, "cas2": cas2_detail()}
219 +
220 +
221 +def exercices(res: dict) -> dict:
222 + """Chiffres des solutions (solutions/ch28.tex)."""
223 + base, hyp, R = res["base"], res["hyp"], res["R"]
224 + ex = {}
225 + # 28.1 — classement et ratios d'un huit logements (données de l'énoncé)
226 + rbe1 = 8 * 1250 * 12 * (1 - 0.04) + 3600
227 + postes1 = {"Taxes municipales": 1450000 * 0.004631, "Taxe scolaire": 1450000 * 0.0007899, "Assurances": 7400.0, "Électricité des aires communes": 2100.0,
228 + "Entretien et réparations": 6800.0, "Déneigement et paysagement": 3900.0, "Gestion (5 % du RBE)": 0.05 * rbe1, "Réserve (600 $/log.)": 8 * 600.0}
229 + fixes1 = postes1["Taxes municipales"] + postes1["Taxe scolaire"] + postes1["Assurances"]; res1 = postes1["Réserve (600 $/log.)"]
230 + var1 = sum(postes1.values()) - fixes1 - res1
231 + ex["ex1"] = {"rbe": rbe1, "postes": postes1, "fixes": fixes1, "variables": var1, "reserve": res1, "total": fixes1 + var1 + res1,
232 + "rne": rbe1 - fixes1 - var1 - res1, "ratio": (fixes1 + var1 + res1) / rbe1, "rne_par_logement": (rbe1 - fixes1 - var1 - res1) / 8,
233 + "part_fixes": fixes1 / (fixes1 + var1 + res1)}
234 + # 28.2 — normalisation : dernière année indexée vs moyenne indexée pour trois postes du cas 3
235 + alt = res["alternative"]
236 + ex["ex2"] = {p: {"derniere": alt.loc[p, "derniere_indexee"], "moyenne": alt.loc[p, "moyenne_indexee"], "ecart": alt.loc[p, "ecart"]}
237 + for p in ["Assurances", "Entretien et réparations", "Conciergerie"]}
238 + ex["ex2"]["ecart_total"] = sum(v["ecart"] for k, v in ex["ex2"].items() if isinstance(v, dict))
239 + ex["ex2"]["dV"] = ex["ex2"]["ecart_total"] / R
240 + # 28.3 — taxes : rôle × taux vs compte indexé ; effet sur RNE et V
241 + role = hyp["valeur_role_2026"]; taxe_base = role * hyp["taux_taxe_fonciere"]; taxe_idx = 10631 * 1.053
242 + ex["ex3"] = {"taxe_base": taxe_base, "taxe_indexee": taxe_idx, "ecart": taxe_idx - taxe_base, "rne_alt": base["rne"] - (taxe_idx - taxe_base),
243 + "V_base": base["rne"] / R, "V_alt": (base["rne"] - (taxe_idx - taxe_base)) / R, "dV": -(taxe_idx - taxe_base) / R,
244 + "scolaire_sans_exemption": role * hyp["taux_taxe_scolaire"], "scolaire_avec_exemption": (role - 25000) * hyp["taux_taxe_scolaire"],
245 + "ecart_exemption": 25000 * hyp["taux_taxe_scolaire"]}
246 + # 28.4 — réserve : linéaire vs fonds d'amortissement à 3 % et à 5 %
247 + rc3 = reserve_par_composantes(taux=0.03); rc5 = reserve_par_composantes(taux=0.05)
248 + ex["ex4"] = {"lineaire": rc3["lineaire_par_logement"], "fonds_3": rc3["fonds_par_logement"], "fonds_5": rc5["fonds_par_logement"],
249 + "lineaire_immeuble": rc3["lineaire_immeuble"], "fonds_3_immeuble": rc3["fonds_immeuble"], "convention": base["reserve"] / 12,
250 + "detail3": rc3["detail"]}
251 + # 28.5 — cohérence réserve/taux sur un comparable de 20 logements
252 + noi_c, rr_c, P_c = 182000.0, 20 * 675.0, 4100000.0
253 + R_c_apres = noi_c / P_c; R_c_avant = (noi_c + rr_c) / P_c
254 + ex["ex5"] = {"R_apres": R_c_apres, "R_avant": R_c_avant, "ecart_pb": (R_c_avant - R_c_apres) * 1e4,
255 + "V_coherent": base["rne"] / R_c_apres, "V_err": base["rne"] / R_c_avant, "erreur_rel": (base["rne"] / R_c_avant) / (base["rne"] / R_c_apres) - 1,
256 + "delta_V": base["rne"] / R_c_apres - base["rne"] / R_c_avant, "delta_formule_sujet": base["reserve"] / R_c_avant,
257 + "ratio_reserve_comparable": rr_c / (noi_c + rr_c), "ratio_reserve_sujet": base["reserve"] / (base["rne"] + base["reserve"]),
258 + "V_avant_sur_avant": (base["rne"] + base["reserve"]) / R_c_avant}
259 + # 28.6 — gestion imputée et propriétaire-occupant (cas 2)
260 + c2 = res["cas2"]; b2 = c2["base"]
261 + noi_sans_gestion = b2["rne"] + c2["gestion_imputee"]
262 + ex["ex6"] = {"gestion": c2["gestion_imputee"], "rne_avec": b2["rne"], "rne_sans": noi_sans_gestion, "ratio_avec": b2["ratio_depenses"],
263 + "ratio_sans": (b2["depenses_totales"] - c2["gestion_imputee"]) / b2["rbe"], "gestion_5": 0.05 * b2["rbe"], "rne_5": b2["rne"] - 0.01 * b2["rbe"],
264 + "taux_median_plex": c2["indicateurs"]["taux_median"], "V_avec": b2["rne"] / c2["indicateurs"]["taux_median"],
265 + "V_sans": noi_sans_gestion / c2["indicateurs"]["taux_median"], "surevaluation": noi_sans_gestion / b2["rne"] - 1}
266 + # 28.7 — CAPEX vs réserve : annuité équivalente et double comptage
267 + eq = res["equivalence_capex"]
268 + ex["ex7"] = {**eq, "rne_double": base["rne"] - eq["annuite_equivalente"], "V_double": (base["rne"] - eq["annuite_equivalente"]) / R,
269 + "erreur_rel_double": -eq["annuite_equivalente"] / base["rne"], "va_a_R": None}
270 + # valeur actuelle de la réserve perpétuelle vs CAPEX perpétuels : composante de 2 200 $/log. tous les 12 ans à 3 %
271 + C = 2200.0 * 12; N = 12; r = 0.03
272 + ex["ex7"]["va_capex_perpetuel"] = C / ((1 + r) ** N - 1); ex["ex7"]["reserve_equiv"] = C * fin.facteur_fonds_amortissement(r, N)
273 + ex["ex7"]["va_reserve_perpetuelle"] = ex["ex7"]["reserve_equiv"] / r
274 + return ex
275 +
276 +
277 +def _f(x): return f"{x:,.2f}".replace(",", " ").replace(".", ",")
278 +
279 +
280 +if __name__ == "__main__":
281 + pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30)
282 + res = calculer(); b = res["base"]
283 + print("=== Cas 3 — historique 2023-2025 ($)"); print(res["historique"].round(3).to_string())
284 + print("\n=== Cas 3 — dépenses normalisées (première année)"); print(res["detail"].round(2).to_string())
285 + print(f"\nRBE {_f(b['rbe'])} | DE hors réserve {_f(b['depenses_hors_reserve'])} | réserve {_f(b['reserve'])} | total {_f(b['depenses_totales'])} | ratio {b['ratio_depenses']*100:.2f} %")
286 + print(f"RNE {_f(b['rne'])} | par logement {_f(res['rne_par_logement'])} | par m² {_f(res['rne_par_m2'])} | RNE/RBE {res['rne_pct_rbe']*100:.1f} %")
287 + cl = res["classement"]
288 + print(f"Fixes {_f(cl['fixes'])} ({cl['part_fixes']*100:.1f} %) | variables {_f(cl['variables'])} ({cl['part_variables']*100:.1f} %) | réserve {_f(cl['reserve'])} ({cl['part_reserve']*100:.1f} %)")
289 + print(f"Dépenses par logement {_f(cl['total_par_logement'])} | par m² {_f(cl['total_par_m2'])}")
290 + print("Groupes (% du RBE) :"); print((cl["groupes_pct_rbe"] * 100).round(2).to_string())
291 + print(f"\nDépenses déclarées 2025 {_f(res['depenses_2025_declarees'])} ; écart de normalisation {_f(res['ecart_normalisation'])} ; RNE historique {_f(res['rne_historique_2025'])} ; V hist {_f(res['V_historique'])} vs V {_f(res['V'])}")
292 + print("\n=== Normalisation alternative (moyenne indexée vs dernière indexée)"); print(res["alternative"].round(2).to_string())
293 + print("\n=== Démonstration réserve / taux"); print({k: round(v, 6) for k, v in res["demo_reserve"].items()})
294 + print("\n=== Réserve par composantes (hypothèses pédagogiques)"); print(res["reserve_composantes"]["detail"].round(2).to_string())
295 + print({k: round(v, 2) for k, v in res["reserve_composantes"].items() if k != "detail"})
296 + print("\n=== CAPEX programmés → annuité équivalente"); print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in res["equivalence_capex"].items()})
297 + print("\n=== Loyers au marché"); print({k: round(v, 4) for k, v in res["marche"].items()})
298 + print("\n=== Comparables : ratios"); print({k: round(v, 4) for k, v in res["comps_stats"].items()})
299 + s = res["sensibilites"]
300 + print("\n=== Sensibilité réserve"); print(s["reserve"].round(4).to_string())
301 + print("\n=== Sensibilité taxes"); print(s["taxe"].round(4).to_string())
302 + print("\n=== Sensibilité gestion"); print(s["gestion"].round(4).to_string())
303 + print(f"dV/dDE = {s['dV_dDE']:.3f} ; élasticité RNE/dépenses = {s['elasticite_rne_depenses']:.3f}")
304 + c2 = res["cas2"]
305 + print("\n=== Cas 2"); print(c2["detail"].round(2).to_string())
306 + print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in c2["base"].items() if k != "detail"})
307 + print("classement :", {k: (round(v, 4) if isinstance(v, float) else None) for k, v in c2["classement"].items() if isinstance(v, float)})
308 + print("taxes Québec recalculées :", {k: round(v, 2) for k, v in c2["taxes_quebec_recalc"].items()})
309 + print(f"RNE/log {_f(c2['rne_par_logement'])} ; RNE/m² {_f(c2['rne_par_m2'])} ; réserve/log {_f(c2['reserve_par_logement'])}")
310 + print("\n=== Exercices")
311 + ex = exercices(res)
312 + for k, v in ex.items():
313 + print(k, {kk: (round(vv, 4) if isinstance(vv, float) else ("<df>" if isinstance(vv, pd.DataFrame) else vv)) for kk, vv in v.items()} if isinstance(v, dict) else v)
added analysis/python/cases/ch33_six_logements.py +331 −0
@@ -0,0 +1,331 @@
1 +#!/usr/bin/env python3
2 +"""Chapitre 33 — Immeubles de six logements et plus : tous les chiffres cités dans manuscript/chapters/ch33.tex
3 +et solutions/ch33.tex (DONNÉES SYNTHÉTIQUES des cas 3 et 4, plus des données RÉELLES de la SCHL lues dans data/raw).
4 +
5 +Contenu :
6 + - schl_ile_montreal() : lecture des tableaux 1.3.1/1.3.2/1.3.4 (taille d'immeuble) et 1.2.1/1.2.2 (année de
7 + construction) du classeur SCHL « rmr-montreal-2025-fr.xlsx » (Enquête sur les logements locatifs, octobre 2025),
8 + bloc « Île de Montréal (zones 1-18) » — données réelles, citées avec attribution (conditions d'utilisation SCHL).
9 + - sommaire_cas4() : portefeuille du cas 4 par RMR (traite.cas4.sommaire) + statistiques de taux et de taille.
10 + - effet_taille() : effet de la taille sur le taux global dans le cas 3 (régression linéaire de traite.cas3) et dans
11 + le cas 4 (régression semi-log de traite.cas4.effets_regionaux), et taux médians par classe de taille.
12 + - unites_cas3() : analyse par logement, par m², par chambre et multiplicateurs pour le sujet du cas 3.
13 + - p45() : évaluation sommaire par capitalisation d'un immeuble de 36 logements du cas 4 (P45, Gatineau) avec
14 + plusieurs taux candidats (Montréal cas 3, Gatineau cas 4, régression cas 4) pour isoler l'effet de RMR et de taille.
15 + - sensibilite_p45() : grilles RMR × état et courbe taille → taux prédit et valeur.
16 + - exercices() : chiffres des solutions.
17 +
18 +Aucune formule financière n'est réécrite : traite.finance, traite.cas3, traite.cas4, traite.hedonique.
19 +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch33_six_logements.py
20 +"""
21 +from __future__ import annotations
22 +import sys, pathlib
23 +import numpy as np, pandas as pd
24 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
25 +from traite import cas3, cas4, finance as fin, hedonique as h
26 +
27 +ROOT = pathlib.Path(__file__).resolve().parents[3]
28 +SCHL_XLSX = ROOT / "data/raw/schl_rml_2025/rmr-montreal-2025-fr.xlsx"
29 +
30 +# ----------------------------------------------------------------------------- constantes citées (sources : notes-B, notes-E, ch. 47)
31 +V_FINALE_CH47 = 2_060_000 # valeur marchande finale du cas 3 au ch. 47 (réconciliation 45/35/20) — vérifiée par test contre ch47_dossier
32 +TAXE_QUEBEC_1A5 = 0.7464 / 100 # Ville de Québec 2026, résidentiel 1 à 5 logements, $ par 100 $ (R.V.Q. 3492)
33 +TAXE_QUEBEC_6PLUS = 0.7697 / 100 # Ville de Québec 2026, résidentiel 6 logements et plus
34 +TARIF_QUEBEC_EAU = 386.0 # $ par logement (aqueduc-égout) ; + 129 $ par chambre (non utilisé ici)
35 +TARIF_QUEBEC_MATIERES = 195.0 # $ par logement (matières résiduelles)
36 +IPCB_MTL_BASSE_HAUTEUR_T3_2026 = 0.092 # IPCB Montréal, immeuble < 5 étages, variation annuelle T3 2026 (StatCan 18-10-0289-01)
37 +IPCB_MTL_TOUR_T3_2026 = 0.079 # idem, tour ≥ 5 étages
38 +ID_36 = "P45" # immeuble de 36 logements retenu dans le cas 4 (Gatineau, 1954, état 2)
39 +RMR_LISTE = ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]
40 +
41 +
42 +# ----------------------------------------------------------------------------- SCHL (données réelles)
43 +
44 +def _lire_bloc(ws, titre_bloc: str, n_groupes: int, largeur_groupe: int, labels_arret=("Tous les immeubles",)) -> pd.DataFrame:
45 + """Lit les lignes du bloc `titre_bloc` d'une feuille SCHL. Chaque ligne = [libellé, g1..gk] ; chaque groupe de
46 + `largeur_groupe` cellules = (oct. 2024, qualité, oct. 2025, qualité[, flèche]). Retourne, par ligne, les valeurs
47 + d'octobre 2025 des groupes 2 chambres (3e) et tous (5e), avec leur lettre de qualité."""
48 + rows = list(ws.iter_rows(values_only=True))
49 + out, dans_bloc = [], False
50 + for r in rows:
51 + lab = r[0]
52 + if isinstance(lab, str) and lab.startswith(titre_bloc):
53 + dans_bloc = True; continue
54 + if not dans_bloc or lab is None:
55 + continue
56 + def val(g, off):
57 + v = r[1 + g * largeur_groupe + off]
58 + if v is None or str(v).strip() in ("**", "-", ""):
59 + return np.nan
60 + return float(str(v).replace(" ", "").replace(" ", "").replace(",", "."))
61 + def qual(g, off):
62 + q = r[1 + g * largeur_groupe + off]
63 + return str(q).strip() if isinstance(q, str) else ""
64 + out.append({"categorie": str(lab).strip(), "deux_ch_2025": val(2, 2), "deux_ch_qual": qual(2, 3),
65 + "tous_2025": val(4, 2), "tous_qual": qual(4, 3), "tous_2024": val(4, 0)})
66 + if str(lab).strip() in labels_arret:
67 + break
68 + return pd.DataFrame(out)
69 +
70 +
71 +def schl_ile_montreal() -> dict:
72 + """Tableaux SCHL (octobre 2025), île de Montréal : inoccupation, loyer moyen et rotation par taille d'immeuble ;
73 + inoccupation et loyer moyen par année de construction. Valeurs « tous les appartements » et « 2 chambres »."""
74 + import openpyxl
75 + wb = openpyxl.load_workbook(SCHL_XLSX, read_only=True, data_only=True)
76 + bloc = "Île de Montréal"
77 + taille_inocc = _lire_bloc(wb["Tableau 1.3.1"], bloc, 5, 5)
78 + taille_loyer = _lire_bloc(wb["Tableau 1.3.2"], bloc, 5, 4)
79 + taille_rot = _lire_bloc(wb["Tableau 1.3.4"], bloc, 5, 5)
80 + annee_inocc = _lire_bloc(wb["Tableau 1.2.1"], bloc, 5, 5)
81 + annee_loyer = _lire_bloc(wb["Tableau 1.2.2"], bloc, 5, 4)
82 + taille = taille_inocc[["categorie"]].copy()
83 + taille["inoccupation_tous"] = taille_inocc["tous_2025"].values
84 + taille["inoccupation_2024"] = taille_inocc["tous_2024"].values
85 + taille["loyer_tous"] = taille_loyer.set_index("categorie").loc[taille["categorie"], "tous_2025"].values
86 + taille["loyer_2ch"] = taille_loyer.set_index("categorie").loc[taille["categorie"], "deux_ch_2025"].values
87 + taille["rotation_tous"] = taille_rot.set_index("categorie").loc[taille["categorie"], "tous_2025"].values
88 + annee = annee_inocc[["categorie"]].copy()
89 + annee["inoccupation_tous"] = annee_inocc["tous_2025"].values
90 + annee["loyer_tous"] = annee_loyer.set_index("categorie").loc[annee["categorie"], "tous_2025"].values
91 + annee["loyer_2ch"] = annee_loyer.set_index("categorie").loc[annee["categorie"], "deux_ch_2025"].values
92 + annee = annee[annee["categorie"] != "Inconnue"].reset_index(drop=True)
93 + return {"taille": taille, "annee": annee}
94 +
95 +
96 +# ----------------------------------------------------------------------------- cas 4 : sommaire et effets
97 +
98 +def sommaire_cas4() -> dict:
99 + d = cas4.charger(); pf = d["portefeuille"]
100 + s = cas4.sommaire(pf)
101 + g = s["par_rmr"].copy()
102 + stats = pf.groupby("rmr").agg(taux_median=("taux_global", "median"), taux_min=("taux_global", "min"), taux_max=("taux_global", "max"),
103 + logements_moyen=("nb_logements", "mean"), loyer_moyen=("loyer_mensuel_moyen", "mean"),
104 + inocc_marche=("taux_inoccupation_marche", "first"), age_moyen=("age", "mean"))
105 + g = g.join(stats)
106 + g["rne_par_logement"] = g["rne"] / g["logements"]
107 + g = g.loc[RMR_LISTE]
108 + pf = pf.assign(valeur_par_logement=pf["valeur_2026"] / pf["nb_logements"], rne_par_logement=pf["rne_2026"] / pf["nb_logements"],
109 + mrb=pf["valeur_2026"] / pf["revenu_brut_potentiel"], rpv=pf["dette"] / pf["valeur_2026"])
110 + return {"par_rmr": g, "total": s["total"], "portefeuille": pf, "series": d["series"]}
111 +
112 +
113 +def effet_taille(pf: pd.DataFrame) -> dict:
114 + """Effet de la taille sur le taux global : (i) cas 3, pente linéaire par logement (traite.cas3.taux_extraits) ;
115 + (ii) cas 4, élasticité semi-log (traite.cas4.effets_regionaux) ; (iii) taux médians par classe de taille (cas 4)."""
116 + t3 = cas3.tout()
117 + reg3 = t3["taux"]["regression"]
118 + eff4 = cas4.effets_regionaux(pf)
119 + b_ln = float(eff4.loc["ln_logements", "coef"]); se_ln = float(eff4.loc["ln_logements", "se_hc1"])
120 + classes = pd.cut(pf["nb_logements"], bins=[5, 8, 16, 24, 48, 72], labels=["6–8", "12–16", "24", "36–48", "72"])
121 + par_classe = pf.groupby(classes, observed=True).agg(n=("id_immeuble", "count"), taux_median=("taux_global", "median"),
122 + valeur_par_logement=("valeur_2026", lambda v: float(v.sum()) / float(pf.loc[v.index, "nb_logements"].sum())))
123 + # effet, en points de base, de passer de 12 à 36 logements, toutes choses égales par ailleurs, à un taux de 5,5 % (cas 4) et à la médiane du cas 3
124 + r_ref4 = float(np.exp(eff4.loc["const", "coef"])) # Montréal, état 3, 12 logements, 50 ans, sans risque
125 + d_pb_cas4 = (np.exp(b_ln * np.log(36 / 12)) - 1) * r_ref4 * 1e4
126 + d_pb_cas3 = reg3["taille"] * (36 - 12) * 1e4
127 + return {"cas3_pente_pb_par_logement": reg3["taille"] * 1e4, "cas3_constante": reg3["constante"], "cas3_mediane": t3["taux"]["mediane"],
128 + "cas3_n": t3["taux"]["n"], "cas3_comparables": t3["taux"]["comparables"], "cas3_valeur_cap": t3["valeur_capitalisation"],
129 + "cas3_rne": t3["base"]["rne"], "cas3_base": t3["base"], "cas3_etat": t3["etat"], "cas3_sujet": t3["sujet"], "cas3_dcf": t3["dcf"],
130 + "cas4_coef_ln": b_ln, "cas4_se_ln": se_ln, "cas4_effet_doublement_pct": float(np.exp(b_ln * np.log(2)) - 1),
131 + "cas4_effets": eff4, "cas4_r_ref": r_ref4, "d_pb_12_36_cas4": float(d_pb_cas4), "d_pb_12_36_cas3": float(d_pb_cas3), "par_classe": par_classe}
132 +
133 +
134 +# ----------------------------------------------------------------------------- unités d'analyse (cas 3)
135 +
136 +def unites_cas3(et: dict, valeur: float = V_FINALE_CH47) -> dict:
137 + etat, sujet, base = et["cas3_etat"], et["cas3_sujet"], et["cas3_base"]
138 + n = int(sujet["nb_logements"]); m2 = float(sujet["superficie_locative_m2"])
139 + chambres = int(etat["chambres"].sum())
140 + rbp = base["rbp_contrat"]; rne = base["rne"]
141 + return {"valeur": valeur, "n": n, "m2": m2, "chambres": chambres, "par_logement": valeur / n, "par_m2": valeur / m2, "par_chambre": valeur / chambres,
142 + "m2_par_logement": m2 / n, "chambres_par_logement": chambres / n, "mrb": fin.multiplicateur_revenu_brut(valeur, rbp),
143 + "mrb_marche": fin.multiplicateur_revenu_brut(valeur, base["rbp_marche"]), "taux_implicite": fin.taux_global_extrait(rne, valeur),
144 + "rne_par_logement": rne / n, "loyer_moyen": float(etat["loyer_mensuel_contrat"].mean()), "loyer_moyen_m2": float((etat["loyer_mensuel_contrat"].sum()) / m2),
145 + "valeur_cap": et["cas3_valeur_cap"], "valeur_cap_par_logement": et["cas3_valeur_cap"] / n}
146 +
147 +
148 +# ----------------------------------------------------------------------------- P45 : 36 logements à Gatineau
149 +
150 +def _X_ligne(rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0) -> np.ndarray:
151 + x = [1.0, etat - 3, np.log(n / 12), inond, sol, (age - 50) / 10]
152 + x += [1.0 if rmr == r else 0.0 for r in ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]]
153 + return np.array(x)
154 +
155 +
156 +def _regression_cas4(pf: pd.DataFrame) -> dict:
157 + """Même spécification que traite.cas4.effets_regionaux, mais on conserve les résidus pour le facteur de Duan."""
158 + X = pd.DataFrame({"const": 1.0, "etat": pf["etat_1a5"] - 3, "ln_logements": np.log(pf["nb_logements"] / 12), "inondable": pf["zone_inondable"],
159 + "sol": pf["contamination_sol"], "age": (pf["age"] - 50) / 10})
160 + for r in ["Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]:
161 + X[f"rmr_{r}"] = (pf["rmr"] == r).astype(float)
162 + res = h.mco(X.values, np.log(pf["taux_global"].values))
163 + res["duan"] = float(np.mean(np.exp(res["residus"])))
164 + res["colonnes"] = list(X.columns)
165 + return res
166 +
167 +
168 +def taux_predit(reg: dict, rmr: str, etat: int, n: int, age: float, inond: int = 0, sol: int = 0, duan: bool = True) -> float:
169 + x = _X_ligne(rmr, etat, n, age, inond, sol)
170 + return float(np.exp(x @ reg["beta"]) * (reg["duan"] if duan else 1.0))
171 +
172 +
173 +def p45(som: dict, et: dict, amortissement_mois: int = 300) -> dict:
174 + pf = som["portefeuille"]; row = pf.set_index("id_immeuble").loc[ID_36]
175 + reg = _regression_cas4(pf)
176 + rne = float(row["rne_2026"]); n = int(row["nb_logements"]); rmr = str(row["rmr"]); etat = int(row["etat_1a5"]); age = float(row["age"])
177 + g = som["par_rmr"]
178 + taux = {
179 + "montreal_cas3_mediane": et["cas3_mediane"], # erreur : taux d'un autre marché
180 + "gatineau_pondere": float(g.loc[rmr, "taux_pondere"]), # RNE/valeur agrégés, Gatineau (cas 4)
181 + "gatineau_median": float(g.loc[rmr, "taux_median"]), # médiane des 8 immeubles de Gatineau
182 + "regression_sujet": taux_predit(reg, rmr, etat, n, age), # régression semi-log, caractéristiques de P45
183 + "regression_si_montreal": taux_predit(reg, "Montréal", etat, n, age), # même immeuble à Montréal (effet RMR)
184 + "regression_si_12_logements": taux_predit(reg, rmr, etat, 12, age), # même immeuble à 12 logements (effet taille)
185 + "regression_si_etat_3": taux_predit(reg, rmr, 3, n, age), # même immeuble en état moyen
186 + "fichier": float(row["taux_global"]), # taux inscrit au dossier synthétique
187 + }
188 + valeurs = {k: fin.capitalisation_directe(rne, R) for k, R in taux.items()}
189 + par_log = {k: v / n for k, v in valeurs.items()}
190 + hyp = fin.Hypotheque(float(row["dette"]), float(row["taux_dette"]), amortissement_mois)
191 + fin_ = {"dette": float(row["dette"]), "taux_dette": float(row["taux_dette"]), "echeance": int(row["echeance_dette"]), "service_dette": hyp.service_dette_annuel,
192 + "rcd": fin.rcd(rne, hyp.service_dette_annuel), "rpv_fichier": float(row["dette"]) / float(row["valeur_2026"]),
193 + "rpv_regression": float(row["dette"]) / valeurs["regression_sujet"], "constante": hyp.constante_hypothecaire}
194 + # décomposition de l'écart Montréal cas 3 → régression sujet (en points de base, cumul multiplicatif)
195 + r_mtl12 = taux_predit(reg, "Montréal", etat, 12, age)
196 + decomposition = {"regression_montreal_12_log_etat2_age72": r_mtl12,
197 + "effet_taille_pb": (taux_predit(reg, "Montréal", etat, n, age) - r_mtl12) * 1e4,
198 + "effet_rmr_pb": (taux["regression_sujet"] - taux_predit(reg, "Montréal", etat, n, age)) * 1e4}
199 + return {"id": ID_36, "rmr": rmr, "n": n, "annee": int(row["annee_construction"]), "age": age, "etat": etat, "loyer_moyen": float(row["loyer_mensuel_moyen"]),
200 + "rbp": float(row["revenu_brut_potentiel"]), "ratio_depenses": float(row["ratio_depenses"]), "rne": rne, "rne_par_logement": rne / n,
201 + "inocc_marche": float(row["taux_inoccupation_marche"]), "valeur_fichier": float(row["valeur_2026"]), "valeur_fichier_par_logement": float(row["valeur_2026"]) / n,
202 + "mrb_fichier": float(row["valeur_2026"]) / float(row["revenu_brut_potentiel"]), "taux": taux, "valeurs": valeurs, "par_logement": par_log,
203 + "regression": reg, "financement": fin_, "decomposition": decomposition,
204 + "ecart_montreal_vs_sujet": valeurs["montreal_cas3_mediane"] / valeurs["regression_sujet"] - 1,
205 + "gatineau_valeur_par_logement": float(g.loc[rmr, "valeur_par_logement"]), "montreal_valeur_par_logement": float(g.loc["Montréal", "valeur_par_logement"])}
206 +
207 +
208 +# ----------------------------------------------------------------------------- sensibilité
209 +
210 +def sensibilite_p45(p: dict) -> dict:
211 + reg, n, age, rne = p["regression"], p["n"], p["age"], p["rne"]
212 + grille = pd.DataFrame({r: [taux_predit(reg, r, e, n, age) for e in range(1, 6)] for r in RMR_LISTE}, index=[f"état {e}" for e in range(1, 6)])
213 + grille_valeur_log = grille.map(lambda R: rne / R / n)
214 + tailles = [6, 12, 24, 36, 48, 72]
215 + courbe = pd.DataFrame({"n": tailles, "taux_gatineau": [taux_predit(reg, "Gatineau", p["etat"], k, age) for k in tailles],
216 + "taux_montreal": [taux_predit(reg, "Montréal", p["etat"], k, age) for k in tailles]})
217 + courbe["valeur_par_logement_gatineau"] = rne / n / courbe["taux_gatineau"] # RNE par logement constant, taille variable
218 + R0 = p["taux"]["regression_sujet"]
219 + pb = pd.DataFrame({"delta_pb": [-50, -25, 0, 25, 50]})
220 + pb["taux"] = R0 + pb["delta_pb"] / 1e4; pb["valeur"] = rne / pb["taux"]; pb["variation_pct"] = pb["valeur"] / (rne / R0) - 1
221 + # élasticité exacte : dV/V = -dR/R
222 + return {"grille_taux": grille, "grille_valeur_par_logement": grille_valeur_log, "courbe_taille": courbe, "taux_pb": pb,
223 + "elasticite": -1.0, "variation_25pb_pct": float(R0 / (R0 + 0.0025) - 1)}
224 +
225 +
226 +# ----------------------------------------------------------------------------- exercices
227 +
228 +def exercices(som: dict, et: dict, u: dict, p: dict) -> dict:
229 + ex = {}
230 + # ex1 — unités de comparaison : un 16 logements fictif (données de l'énoncé) évalué par les trois unités du cas 3
231 + n16, m2_16, ch16 = 16, 1040.0, 26
232 + ex["ex1"] = {"par_logement": u["par_logement"] * n16, "par_m2": u["par_m2"] * m2_16, "par_chambre": u["par_chambre"] * ch16,
233 + "m2_par_logement_sujet": u["m2_par_logement"], "m2_par_logement_16": m2_16 / n16, "chambres_par_logement_16": ch16 / n16}
234 + ex["ex1"]["ecart_max_pct"] = max(ex["ex1"]["par_logement"], ex["ex1"]["par_m2"], ex["ex1"]["par_chambre"]) / min(ex["ex1"]["par_logement"], ex["ex1"]["par_m2"], ex["ex1"]["par_chambre"]) - 1
235 + # ex2 — taxes de la Ville de Québec : 5 logements (1-5) vs 6 logements (6+) pour une même valeur au rôle
236 + role = 1_800_000
237 + taxe5 = role * TAXE_QUEBEC_1A5 + 5 * (TARIF_QUEBEC_EAU + TARIF_QUEBEC_MATIERES)
238 + taxe6 = role * TAXE_QUEBEC_6PLUS + 6 * (TARIF_QUEBEC_EAU + TARIF_QUEBEC_MATIERES)
239 + ex["ex2"] = {"role": role, "taxe_5": taxe5, "taxe_6": taxe6, "ecart_taux_seul": role * (TAXE_QUEBEC_6PLUS - TAXE_QUEBEC_1A5),
240 + "ecart_total": taxe6 - taxe5, "effet_valeur_a_5pct": role * (TAXE_QUEBEC_6PLUS - TAXE_QUEBEC_1A5) / 0.055}
241 + # ex3 — effet de taille (cas 3) : 36 logements au lieu de 12
242 + R12 = et["cas3_constante"]; R36 = et["cas3_constante"] + (et["cas3_pente_pb_par_logement"] / 1e4) * (36 - 12)
243 + rne_log = et["cas3_rne"] / 12
244 + ex["ex3"] = {"R12": R12, "R36": R36, "ecart_pb": (R36 - R12) * 1e4, "valeur_par_logement_12": rne_log / R12, "valeur_par_logement_36": rne_log / R36,
245 + "prime_pct": rne_log / R36 / (rne_log / R12) - 1, "cas4_doublement_pct": et["cas4_effet_doublement_pct"]}
246 + # ex4 — erreur de marché : P45 au taux montréalais
247 + ex["ex4"] = {"valeur_montreal": p["valeurs"]["montreal_cas3_mediane"], "valeur_regression": p["valeurs"]["regression_sujet"],
248 + "ecart": p["valeurs"]["montreal_cas3_mediane"] - p["valeurs"]["regression_sujet"], "ecart_pct": p["ecart_montreal_vs_sujet"],
249 + "valeur_gatineau_pondere": p["valeurs"]["gatineau_pondere"], "effet_rmr_pb": p["decomposition"]["effet_rmr_pb"], "effet_taille_pb": p["decomposition"]["effet_taille_pb"]}
250 + # ex5 — diligence : 4 baux surévalués de 120 $/mois dans l'état locatif de P45 ; vacance 3,8 %, gestion 4 % du RBE, R = régression
251 + delta_loyer = 4 * 120 * 12
252 + R = p["taux"]["regression_sujet"]; v = p["inocc_marche"]
253 + delta_rne = delta_loyer * (1 - v) * (1 - 0.04)
254 + ex["ex5"] = {"delta_rbp": delta_loyer, "delta_rne": delta_rne, "delta_valeur": delta_rne / R, "delta_valeur_pct": delta_rne / R / p["valeurs"]["regression_sujet"],
255 + "R": R, "v": v}
256 + # ex6 — régression : 24 logements, état 3, 45 ans, dans chaque RMR ; IC à 95 % approximatif sur l'effet RMR (HC1)
257 + reg = p["regression"]; cols = reg["colonnes"]
258 + ex6 = {}
259 + for r in RMR_LISTE:
260 + R = taux_predit(reg, r, 3, 24, 45)
261 + if r == "Montréal":
262 + lo = hi = None
263 + else:
264 + j = cols.index(f"rmr_{r}"); b, s = reg["beta"][j], reg["se_hc1"][j]
265 + lo, hi = np.exp(b - 1.96 * s) - 1, np.exp(b + 1.96 * s) - 1
266 + ex6[r] = {"taux": R, "effet_pct": (np.exp(reg["beta"][cols.index(f"rmr_{r}")]) - 1) if r != "Montréal" else 0.0, "ic_lo": lo, "ic_hi": hi}
267 + ex["ex6"] = {"par_rmr": ex6, "r2": reg["r2"], "n": reg["n"], "duan": reg["duan"]}
268 + # ex7 — immeuble neuf : coût indexé vs revenu (données de l'énoncé : coût 2025 hors terrain 6 400 000 $, terrain 1 100 000 $, 24 logements,
269 + # loyer 1 975 $/mois, inoccupation 5,6 % (SCHL, île, immeubles 2015+), dépenses 32 % du RBE, taux neuf CBRE Montréal 4,50-4,75 %)
270 + cout_2025, terrain, n_neuf, loyer_neuf, v_neuf, ratio_neuf = 6_400_000, 1_100_000, 24, 1975, 0.056, 0.32
271 + cout_2026 = cout_2025 * (1 + IPCB_MTL_BASSE_HAUTEUR_T3_2026)
272 + rbp = n_neuf * loyer_neuf * 12; rbe = fin.revenu_brut_effectif(rbp, v_neuf); rne_neuf = fin.rne(rbe, ratio_neuf * rbe)
273 + ex["ex7"] = {"cout_2026": cout_2026, "cout_total": cout_2026 + terrain, "cout_par_logement": (cout_2026 + terrain) / n_neuf, "rbp": rbp, "rbe": rbe, "rne": rne_neuf,
274 + "V_450": fin.capitalisation_directe(rne_neuf, 0.045), "V_475": fin.capitalisation_directe(rne_neuf, 0.0475),
275 + "taux_implicite_cout": rne_neuf / (cout_2026 + terrain), "valeur_assurable_hors_terrain": cout_2026}
276 + return ex
277 +
278 +
279 +# ----------------------------------------------------------------------------- assemblage et impression
280 +
281 +def calculer() -> dict:
282 + som = sommaire_cas4(); et = effet_taille(som["portefeuille"]); u = unites_cas3(et); p = p45(som, et); s = sensibilite_p45(p)
283 + schl = schl_ile_montreal()
284 + return {"sommaire": som, "effet_taille": et, "unites": u, "p45": p, "sensibilite": s, "schl": schl, "exercices": exercices(som, et, u, p)}
285 +
286 +
287 +def _d(x): return f"{x:,.0f}".replace(",", " ") + " $"
288 +def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %"
289 +
290 +
291 +def imprimer(R: dict) -> None:
292 + pd.set_option("display.width", 200); pd.set_option("display.max_columns", 20)
293 + print("=== SCHL, île de Montréal, octobre 2025 (données réelles) ===")
294 + print(R["schl"]["taille"].to_string(index=False)); print(R["schl"]["annee"].to_string(index=False))
295 + g = R["sommaire"]["par_rmr"]; t = R["sommaire"]["total"]
296 + print("\n=== Cas 4 : sommaire par RMR ===")
297 + print(g[["n", "logements", "valeur", "rne", "taux_pondere", "taux_median", "taux_min", "taux_max", "rpv", "valeur_par_logement", "rne_par_logement", "logements_moyen", "loyer_moyen"]].to_string())
298 + print(f"Total : {t['n']} immeubles, {t['logements']} logements, valeur {_d(t['valeur'])}, RNE {_d(t['rne'])}, taux pondéré {_p(t['taux_pondere'])}, RPV {_p(t['rpv'], 1)}, valeur/logement {_d(t['valeur']/t['logements'])}")
299 + et = R["effet_taille"]
300 + print("\n=== Effet de taille ===")
301 + print(f"Cas 3 : pente {et['cas3_pente_pb_par_logement']:.2f} pb/logement ; constante {_p(et['cas3_constante'], 3)} ; médiane {_p(et['cas3_mediane'], 3)} ; 12→36 : {et['d_pb_12_36_cas3']:.1f} pb")
302 + print(f"Cas 4 : coef ln(n/12) = {et['cas4_coef_ln']:.4f} (ET HC1 {et['cas4_se_ln']:.4f}) ; doublement → {_p(et['cas4_effet_doublement_pct'])} du taux ; 12→36 à R réf. {_p(et['cas4_r_ref'], 2)} : {et['d_pb_12_36_cas4']:.1f} pb")
303 + print(et["par_classe"].to_string()); print(et["cas4_effets"].to_string())
304 + u = R["unites"]
305 + print("\n=== Unités (cas 3, valeur finale ch. 47) ===")
306 + print(f"V = {_d(u['valeur'])} ; {u['n']} log., {u['m2']:.0f} m², {u['chambres']} chambres → {_d(u['par_logement'])}/log., {_d(u['par_m2'])}/m², {_d(u['par_chambre'])}/chambre ; "
307 + f"{u['m2_par_logement']:.1f} m²/log. ; {u['chambres_par_logement']:.2f} ch./log. ; MRB contrat {u['mrb']:.2f}, marché {u['mrb_marche']:.2f} ; taux implicite {_p(u['taux_implicite'])} ; "
308 + f"RNE/log. {_d(u['rne_par_logement'])} ; loyer moyen {_d(u['loyer_moyen'])}/mois ; loyer {u['loyer_moyen_m2']:.2f} $/m²/mois ; V_cap {_d(u['valeur_cap'])} ({_d(u['valeur_cap_par_logement'])}/log.)")
309 + p = R["p45"]
310 + print(f"\n=== {p['id']} : {p['n']} logements, {p['rmr']}, {p['annee']} ({p['age']:.0f} ans), état {p['etat']} ===")
311 + print(f"Loyer moyen {_d(p['loyer_moyen'])}/mois ; RBP {_d(p['rbp'])} ; ratio dépenses {_p(p['ratio_depenses'], 1)} ; inoccupation de marché {_p(p['inocc_marche'], 1)} ; RNE {_d(p['rne'])} ({_d(p['rne_par_logement'])}/log.)")
312 + print(f"Valeur au dossier {_d(p['valeur_fichier'])} ({_d(p['valeur_fichier_par_logement'])}/log., MRB {p['mrb_fichier']:.2f}, taux {_p(p['taux']['fichier'])})")
313 + for k in p["taux"]:
314 + print(f" {k:30s} R = {_p(p['taux'][k], 3)} V = {_d(p['valeurs'][k])} ({_d(p['par_logement'][k])}/log.)")
315 + d = p["decomposition"]
316 + print(f"Décomposition : Montréal 12 log. état 2 âge 72 → {_p(d['regression_montreal_12_log_etat2_age72'], 3)} ; effet taille {d['effet_taille_pb']:+.1f} pb ; effet RMR {d['effet_rmr_pb']:+.1f} pb")
317 + print(f"Écart Montréal cas 3 vs régression : {_p(p['ecart_montreal_vs_sujet'], 1)} ; valeur/log. Gatineau cas 4 {_d(p['gatineau_valeur_par_logement'])}, Montréal {_d(p['montreal_valeur_par_logement'])}")
318 + f = p["financement"]
319 + print(f"Dette {_d(f['dette'])} à {_p(f['taux_dette'])} (échéance {f['echeance']}) ; service (300 mois, hyp.) {_d(f['service_dette'])} ; RCD {f['rcd']:.2f} ; RPV dossier {_p(f['rpv_fichier'], 1)} ; RPV sur V régression {_p(f['rpv_regression'], 1)} ; constante {_p(f['constante'])}")
320 + reg = p["regression"]; print(f"Régression cas 4 : n = {reg['n']}, R² = {reg['r2']:.3f}, facteur de Duan {reg['duan']:.4f}")
321 + s = R["sensibilite"]
322 + print("\n=== Sensibilité P45 ===")
323 + print((s["grille_taux"] * 100).round(2).to_string()); print(s["grille_valeur_par_logement"].round(0).to_string()); print(s["courbe_taille"].to_string(index=False)); print(s["taux_pb"].to_string(index=False))
324 + print(f"Variation de la valeur pour +25 pb : {_p(s['variation_25pb_pct'])}")
325 + print("\n=== Exercices ===")
326 + for k, v in R["exercices"].items():
327 + print(k, {kk: (round(vv, 4) if isinstance(vv, float) else vv) for kk, vv in v.items()} if k != "ex6" else {r: {kk: (round(vv, 4) if isinstance(vv, float) else vv) for kk, vv in d.items()} for r, d in v["par_rmr"].items()} | {"r2": round(v["r2"], 3), "duan": round(v["duan"], 4)})
328 +
329 +
330 +if __name__ == "__main__":
331 + imprimer(calculer())
modified analysis/python/figures/ch25_figures.py +17 −16
@@ -52,7 +52,8 @@ def fig_carte(S):
52 52 th = np.linspace(0, 2 * np.pi, 200)
53 53 ax.plot(suj["lon"] + 4 / 78 * np.cos(th), suj["lat"] + 4 / 111 * np.sin(th), color=palette.cat[3], lw=0.8, ls=":")
54 54 for sct, (la, lo) in cas.CENTROIDES.items():
55 − ax.text(lo, la + 0.030, sct, ha="center", fontsize=8, color=palette.ink, fontweight="semibold")
55 + dy = -0.036 if sct == "Masson-Angers" else 0.030
56 + ax.text(lo, la + dy, sct, ha="center", fontsize=8, color=palette.ink, fontweight="semibold")
56 57 ax.set_xlabel("Longitude (coordonnées fictives)"); ax.set_ylabel("Latitude (coordonnées fictives)")
57 58 ax.set_aspect(111 / 78); ax.legend(loc="lower right", fontsize=7.5)
58 59 ax.set_xlim(-75.92, -75.33); ax.set_ylim(45.34, 45.64)
@@ -61,7 +62,7 @@ def fig_carte(S):
61 62
62 63 def fig_lotissement(S):
63 64 L = S["lotissement"]; det = L["detail"]; p = L["params"]; m = S["comparaison"]["modele"]
64 − fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.4), gridspec_kw={"width_ratios": [1.1, 1]})
65 + fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.6), gridspec_kw={"width_ratios": [1.0, 1.0], "wspace": 0.95})
65 66 yrs = det["annee"].values
66 67 couts = -(det["infrastructure"] + det["honoraires"] + det["taxes"] + det["frais_vente"] + det["profit"]).values
67 68 a1.bar(yrs, det["revenus"].values, color=palette.cat[0], width=0.6, label="Produit des ventes de lots")
@@ -69,24 +70,24 @@ def fig_lotissement(S):
69 70 r = p["taux"]; cum = np.cumsum([f / (1 + r) ** t for t, f in zip(yrs, L["flux"])])
70 71 a1.plot(yrs, cum, color=palette.cat[3], marker="o", lw=1.6, label="Valeur actualisée cumulée (10 %)")
71 72 for t, v in zip(yrs, cum):
72 − a1.annotate(fmt_dollars(v), (t, v), xytext=(0, 6), textcoords="offset points", ha="center", fontsize=7, color=palette.ink)
73 + a1.annotate(fmt_milliers(v / 1000), (t, v), xytext=(0, 6), textcoords="offset points", ha="center", fontsize=7, color=palette.ink)
73 74 a1.axhline(0, color=palette.ink2, lw=0.6)
74 − a1.set_xticks(yrs); a1.set_xticklabels([f"Année {t}" for t in yrs]); a1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p_: fmt_milliers(x / 1000)))
75 + a1.set_xticks(yrs); a1.set_xticklabels([str(t) for t in yrs]); a1.set_xlabel("Année (flux de fin d'année)"); a1.set_ylim(-1_250_000, 1_450_000); a1.yaxis.set_major_formatter(plt.FuncFormatter(lambda x, p_: fmt_milliers(x / 1000)))
75 76 a1.set_ylabel("Milliers de dollars"); a1.set_title("(a) Flux du lotissement de 12 lots", loc="left", fontsize=9)
76 − a1.legend(fontsize=7, loc="upper left")
77 + a1.legend(fontsize=6.5, loc="lower left", handlelength=1.2)
77 78 sl = cas.sensibilite_lotissement(m); base = sl["base"]
78 − items = [("Absorption : 2 à 6 lots/an", sl["absorption"][2], sl["absorption"][6]),
79 − ("Prix des lots : −10 % / +10 %", sl["prix"][-0.10], sl["prix"][0.10]),
80 − ("Infrastructures : 80 000 / 50 000 $ par lot", sl["infrastructure"][80_000], sl["infrastructure"][50_000]),
81 − ("Profit du promoteur : 20 % / 10 %", sl["profit"][0.20], sl["profit"][0.10]),
82 − ("Taux d'actualisation : 15 % / 8 %", sl["taux"][0.15], sl["taux"][0.08])]
79 + items = [("Absorption\n2 → 6 lots/an", sl["absorption"][2], sl["absorption"][6]),
80 + ("Prix des lots\n−10 % → +10 %", sl["prix"][-0.10], sl["prix"][0.10]),
81 + ("Infrastructures\n80 000 → 50 000 $/lot", sl["infrastructure"][80_000], sl["infrastructure"][50_000]),
82 + ("Profit du promoteur\n20 % → 10 %", sl["profit"][0.20], sl["profit"][0.10]),
83 + ("Taux d'actualisation\n15 % → 8 %", sl["taux"][0.15], sl["taux"][0.08])]
83 84 items.sort(key=lambda x: abs(x[2] - x[1]), reverse=True)
84 85 for k, (lab, lo, hi) in enumerate(items):
85 86 a2.barh(k, lo - base, left=base, color=palette.cat[1], height=0.55)
86 87 a2.barh(k, hi - base, left=base, color=palette.cat[0], height=0.55)
87 88 a2.text(min(lo, hi) - 8000, k, fmt_milliers((min(lo, hi)) / 1000), va="center", ha="right", fontsize=7, color=palette.ink)
88 89 a2.text(max(lo, hi) + 8000, k, fmt_milliers((max(lo, hi)) / 1000), va="center", ha="left", fontsize=7, color=palette.ink)
89 − a2.set_yticks(range(len(items))); a2.set_yticklabels([i[0] for i in items], fontsize=7.5)
90 + a2.set_yticks(range(len(items))); a2.set_yticklabels([i[0] for i in items], fontsize=7)
90 91 a2.axvline(base, color=palette.ink, lw=0.8)
91 92 a2.set_xlim(base - 420_000, base + 420_000); a2.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p_: fmt_milliers(x / 1000)))
92 93 a2.set_xlabel("Valeur du site brut (milliers de dollars)"); a2.set_title(f"(b) Sensibilité autour de {fmt_dollars(base)}", loc="left", fontsize=9)
@@ -101,17 +102,17 @@ def fig_residuelle(S):
101 102 for d, col, ls in [(0.44, palette.cat[1], "--"), (0.52, palette.cat[0], "-"), (0.60, palette.cat[2], "-.")]:
102 103 VL = [cas.residuelle_cas3({"taux_depreciation": d, "R_L": r}, t3)["V_L"] for r in RL]
103 104 CB = cas.residuelle_cas3({"taux_depreciation": d}, t3)["C_B"]
104 − ax.plot(RL * 100, VL, color=col, ls=ls, lw=1.6, label=f"Dépréciation {d*100:.0f} % — $C_B$ = {fmt_dollars(CB)}")
105 + ax.plot(RL * 100, VL, color=col, ls=ls, lw=1.6, label=f"Dépréciation {d*100:.0f} % : bâtiment déprécié {fmt_dollars(CB)}")
105 106 ax.axhline(0, color=palette.ink2, lw=0.6)
106 107 ax.scatter([rs["R_L"] * 100], [rs["V_L"]], s=60, color=palette.cat[3], zorder=5, edgecolor=palette.ink, linewidths=0.5)
107 − ax.annotate(f"Cas de base : $R_L$ = 3,75 %, n = 40 ans\n$V_L$ = {fmt_dollars(rs['V_L'])}", (rs["R_L"] * 100, rs["V_L"]), xytext=(4.2, 900_000), fontsize=8,
108 + ax.annotate(f"Cas de base : taux du terrain 3,75 %, 40 ans restants\nvaleur résiduelle {fmt_dollars(rs['V_L'])}", (rs["R_L"] * 100, rs["V_L"]), xytext=(4.05, 1_000_000), fontsize=8,
108 109 arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
109 110 ext = rs["V_cap"] - rs["C_B"]
110 111 ax.axhline(ext, color=palette.ink2, lw=0.8, ls=":")
111 − ax.text(3.02, ext + 25_000, f"Par extraction sur la capitalisation directe : $V$ − $C_B$ = {fmt_dollars(ext)}", fontsize=7.5, color=palette.ink2)
112 − ax.set_xlabel("Taux de rendement du terrain $R_L$ (%)"); ax.set_ylabel("Valeur résiduelle du terrain $V_L$ ($)")
112 + ax.text(3.02, ext + 25_000, f"Extraction sur la valeur capitalisée (V − bâtiment déprécié) : {fmt_dollars(ext)}", fontsize=7.5, color=palette.ink2)
113 + ax.set_xlabel("Taux de rendement du terrain $R_L$ (%)"); ax.set_ylabel("Valeur résiduelle du terrain (dollars)")
113 114 ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)); ax.set_ylim(-300_000, 1_300_000)
114 − ax.legend(loc="upper right", fontsize=7.5)
115 + ax.legend(loc="lower left", fontsize=7.5)
115 116 savefig(fig, "ch25/ch25-fig-residuelle-sensibilite.pdf")
116 117
117 118
modified analysis/python/figures/ch26_figures.py +5 −6
@@ -40,13 +40,13 @@ def fig_anciennete(res):
40 40 fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.1), gridspec_kw={"width_ratios": [1.15, 1]})
41 41 # (a) distribution de l'ancienneté (part des logements) et courbes de survie (1 − ρ)^t — même échelle de proportions, pas de double axe
42 42 anc = t["anciennete"].values; bins = np.arange(0, 21, 2)
43 − a1.hist(anc, bins=bins, weights=np.full(len(anc), 1 / len(anc)), color=palette.cat[0], alpha=0.85, edgecolor=palette.surface, zorder=3, label="part des 12 logements (ancienneté observée)")
43 + a1.hist(anc, bins=bins, weights=np.full(len(anc), 1 / len(anc)), color=palette.cat[0], alpha=0.85, edgecolor=palette.surface, zorder=3, label="part des 12 logements")
44 44 tt = np.linspace(0, 20, 200)
45 − for rho, c, ls, lab in ((0.085, palette.cat[2], "-", "survie $(1-\\rho)^t$, ρ = 8,5 % (SCHL, 6–19 log., Montréal 2025)"), (0.094, palette.cat[3], "--", "survie, ρ = 9,4 % (SCHL, RMR Montréal 2025)"), (0.15, palette.cat[1], ":", "survie, ρ = 15 % (hypothèse du cas 3)")):
45 + for rho, c, ls, lab in ((0.085, palette.cat[2], "-", "survie $(1-\\rho)^t$, ρ = 8,5 % (SCHL, 6–19 log.)"), (0.094, palette.cat[3], "--", "survie, ρ = 9,4 % (SCHL, RMR Montréal)"), (0.15, palette.cat[1], ":", "survie, ρ = 15 % (cas 3)")):
46 46 a1.plot(tt, survie(rho, tt), color=c, linestyle=ls, linewidth=1.4, label=lab)
47 47 a1.set_xlabel("Ancienneté du locataire (années)"); a1.set_ylabel("Proportion"); a1.set_ylim(0, 1.05); a1.set_xlim(0, 20)
48 48 a1.set_title("(a) Ancienneté observée et survie théorique", loc="left")
49 − a1.legend(loc="upper center", bbox_to_anchor=(0.5, -0.24), fontsize=6.6, ncol=1)
49 + a1.legend(loc="upper left", bbox_to_anchor=(0.0, -0.20), fontsize=6.3, ncol=2, frameon=False)
50 50 # (b) écart mensuel selon l'ancienneté, par type
51 51 marq = {"3½": "o", "4½": "s", "5½": "^"}
52 52 for i, (p, m) in enumerate(marq.items()):
@@ -57,9 +57,8 @@ def fig_anciennete(res):
57 57 a2.yaxis.set_major_formatter(dollars); a2.set_xlabel("Ancienneté du locataire (années)"); a2.set_ylabel("Écart marché − contrat ($/mois)")
58 58 a2.set_title("(b) Écart mensuel selon l'ancienneté", loc="left"); a2.legend(title="Type", loc="upper left", fontsize=7)
59 59 a2.set_xlim(0, 20); a2.set_ylim(0, 360)
60 − a2.legend(title="Type", loc="upper left", fontsize=7, bbox_to_anchor=(0.0, -0.24), ncol=3)
61 − fig.text(0.99, -0.30, SYNTH, ha="right", va="bottom", fontsize=7, color=palette.ink2)
62 − fig.tight_layout()
60 + a2.text(1.0, -0.36, SYNTH, transform=a2.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
61 + fig.subplots_adjust(wspace=0.38)
63 62 return savefig(fig, "ch26/ch26-fig-anciennete.pdf")
64 63
65 64
modified analysis/python/figures/ch27_figures.py +9 −9
@@ -50,9 +50,9 @@ def fig_inoccupation_rmr(res):
50 50
51 51 def fig_cascade(res):
52 52 r = res["cas3"]
53 − etapes = [("RBP aux loyers\nde marché", r["rbp_marche"], "total"), ("Déficit de loyer\n(baux en place)", -r["deficit_loyer"], "delta"),
54 − ("RBP\ncontractuel", r["rbp_contrat"], "total"), ("Inoccupation et\nmauvaises créances", -r["vacance_pertes"], "delta"),
55 − ("Loyers\neffectifs", r["loyers_effectifs"], "total"), ("Autres\nrevenus", r["autres_revenus"], "delta"), ("Revenu brut\neffectif (RBE)", r["rbe"], "total")]
53 + etapes = [("RBP au\nmarché", r["rbp_marche"], "total"), ("Déficit\nde loyer", -r["deficit_loyer"], "delta"),
54 + ("RBP\ncontractuel", r["rbp_contrat"], "total"), ("Inoccupation\net créances", -r["vacance_pertes"], "delta"),
55 + ("Loyers\neffectifs", r["loyers_effectifs"], "total"), ("Autres\nrevenus", r["autres_revenus"], "delta"), ("RBE", r["rbe"], "total")]
56 56 fig, ax = plt.subplots(figsize=(LARGEUR, 3.5))
57 57 cum = 0.0
58 58 for i, (lab, val, kind) in enumerate(etapes):
@@ -79,7 +79,7 @@ def fig_cascade(res):
79 79 def fig_trajectoires(res):
80 80 t = res["trajectoires"]["table"]; h = res["trajectoires"]["hyp"]; T = res["trajectoires"]["T"]
81 81 t = t[t["annee"] <= T]
82 − fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))
82 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.9))
83 83 x = t["exercice"].values
84 84 ax.fill_between(x, t["tal_seul"] / 1000, t["marche"] / 1000, color=palette.grid, alpha=0.8, label="Écart entre l'indexation seule et le marché")
85 85 ax.plot(x, t["marche"] / 1000, color=palette.cat[1], marker="s", ms=3.2, label=f"RBP au loyer de marché (indice : +{h['croissance_loyers_marche']*100:.1f} %/an)".replace(".", ","))
@@ -90,9 +90,9 @@ def fig_trajectoires(res):
90 90 ax.text(x[-1] + 0.15, t[k].iloc[-1] / 1000 + dy / 10, fmt_milliers(t[k].iloc[-1]) + " $", fontsize=7, color=palette.ink, va="center")
91 91 ax.set_xticks(x); ax.set_xlabel("Exercice (1er septembre → 31 août)"); ax.set_ylabel("Revenu brut potentiel (milliers de $)")
92 92 ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v)))
93 − ax.set_xlim(x[0] - 0.3, x[-1] + 1.4); ax.legend(loc="lower right", fontsize=7)
93 + ax.set_xlim(x[0] - 0.3, x[-1] + 1.4); ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.17), ncol=2, fontsize=6.8)
94 94 ax.set_title("Cas 3 : quatre trajectoires du revenu brut potentiel sur dix ans", loc="left")
95 − ax.text(0.99, -0.22, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
95 + ax.text(0.99, -0.40, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
96 96 return savefig(fig, "ch27/ch27-fig-trajectoires.pdf")
97 97
98 98
@@ -100,16 +100,16 @@ def fig_sensibilite(res):
100 100 s = res["schl_rmr"]; base = res["cas3"]
101 101 fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.2))
102 102 d = np.linspace(0, 3, 61)
103 − rots = [(0.05, "ρ = 5 %"), (0.094, "ρ = 9,4 % (Montréal 2025)"), (0.13, "ρ = 13 % (Québec 2025)"), (0.162, "ρ = 16,2 % (Gatineau 2025)"), (0.25, "ρ = 25 %")]
103 + rots = [(0.05, "ρ = 5 %"), (0.094, "ρ = 9,4 % (Montréal 2025)"), (0.13, "ρ = 13 % (Québec 2025)"), (0.162, "ρ = 16,2 % (Gatineau 2025)"), (0.20, "ρ = 20 %")]
104 104 for i, (rho, lab) in enumerate(rots):
105 105 a.plot(d, [fin.taux_vacance_frictionnelle(rho, x) * 100 for x in d], color=palette.cat[i], label=lab)
106 106 a.axhline(s.loc["Montréal", "inocc_2025"], color=palette.ink2, lw=0.7, ls=":")
107 − a.text(0.05, s.loc["Montréal", "inocc_2025"] + 0.08, "inoccupation SCHL, RMR de Montréal, oct. 2025 : 2,9 %", fontsize=6.8, color=palette.ink2)
107 + a.text(0.05, s.loc["Montréal", "inocc_2025"] - 0.12, "SCHL, RMR de Montréal, oct. 2025 : 2,9 %", fontsize=6.3, color=palette.ink2, va="top")
108 108 a.scatter([1.0], [base["v_frictionnel_dossier"] * 100], color=palette.ink, marker="*", s=70, zorder=4)
109 109 a.annotate("dossier du cas 3 :\nρ = 15 %, d = 1 mois\n→ 1,25 %", (1.0, base["v_frictionnel_dossier"] * 100), xytext=(1.25, 0.35), fontsize=6.8,
110 110 arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
111 111 a.set_xlabel("Durée moyenne de vacance entre deux baux, d (mois)"); a.set_ylabel("Inoccupation frictionnelle v_f = ρ·d/12 (%)")
112 − a.set_ylim(0, 6.5); a.yaxis.set_major_formatter(pct1); a.legend(fontsize=6.5, loc="upper left")
112 + a.set_ylim(0, 5.2); a.yaxis.set_major_formatter(pct1); a.legend(fontsize=6.5, loc="upper left")
113 113 a.set_title("(a) Rotation, durée de vacance et inoccupation", loc="left", fontsize=8.5)
114 114 sv = c.sensibilite_v(base, taux=tuple(sorted(set(np.round(np.arange(0.01, 0.0801, 0.0025), 4)) | {0.029, 0.039, 0.059})))
115 115 b.plot(sv["v"] * 100, sv["valeur"] / 1000, color=palette.cat[0])
added analysis/python/figures/ch28_figures.py +144 −0
@@ -0,0 +1,144 @@
1 +#!/usr/bin/env python3
2 +"""Chapitre 28 — figures (5) : cascade RBP → RNE (cas 3) ; structure des dépenses en % du RBE (cas 3 vs cas 2) ;
3 +évolution 2023-2025 des groupes de dépenses (indice 2023 = 100) et montant normalisé 2026 ; ratio de dépenses vs loyer
4 +moyen (40 comparables du cas 3, courbe iso-dépenses du sujet) ; sensibilité du RNE (réserve par logement, taux de taxe,
5 +gestion). DONNÉES SYNTHÉTIQUES (graine 20261031). Idempotent ; écrit dans assets/figures/ch28/ch28-fig-<slug>.pdf.
6 +Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch28_figures.py"""
7 +from __future__ import annotations
8 +import sys, pathlib
9 +import numpy as np, pandas as pd
10 +import matplotlib.pyplot as plt
11 +from matplotlib.ticker import FuncFormatter
12 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
13 +from traite.style import setup, palette, savefig, LARGEUR, DEMI, fmt_dollars
14 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
15 +from ch28_depenses import calculer, ratio_iso_depenses, GROUPES, ORDRE_GROUPES
16 +
17 +pct0 = FuncFormatter(lambda x, p: f"{x*100:.0f} %")
18 +pct1 = FuncFormatter(lambda x, p: f"{x*100:.1f}".replace(".", ",") + " %")
19 +kdol = FuncFormatter(lambda x, p: f"{x/1000:,.0f}".replace(",", " ") + " k$")
20 +NOTE = "DONNÉES SYNTHÉTIQUES"
21 +
22 +
23 +def fig_cascade(res):
24 + b = res["base"]; cl = res["classement"]
25 + etapes = [("Revenu brut\npotentiel", b["rbp_contrat"], "total"), ("Inoccupation\net pertes", -b["vacance_pertes"], "neg"), ("Autres\nrevenus", b["autres_revenus"], "pos"),
26 + ("Revenu brut\neffectif", b["rbe"], "total"), ("Dépenses\nfixes", -cl["fixes"], "neg"), ("Dépenses\nvariables", -cl["variables_hors_gestion"], "neg"),
27 + ("Gestion\nimputée", -cl["gestion"], "neg"), ("Réserve de\nremplacement", -cl["reserve"], "neg"), ("Revenu net\nd'exploitation", b["rne"], "total")]
28 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))
29 + cum = 0.0
30 + for i, (nom, v, k) in enumerate(etapes):
31 + if k == "total":
32 + ax.bar(i, v, color=palette.cat[0], width=0.7); cum = v; top = v
33 + else:
34 + bas = cum + v if v < 0 else cum
35 + ax.bar(i, abs(v), bottom=bas, color=palette.cat[3] if v < 0 else palette.cat[2], width=0.7); cum += v; top = max(bas + abs(v), bas)
36 + ax.text(i, top + 2500, fmt_dollars(abs(v)) if k != "total" else fmt_dollars(v), ha="center", va="bottom", fontsize=7.2, color=palette.ink)
37 + if i < len(etapes) - 1:
38 + ax.plot([i + 0.35, i + 0.65], [cum, cum], color=palette.ink2, lw=0.6, ls=":")
39 + ax.set_xticks(range(len(etapes))); ax.set_xticklabels([e[0] for e in etapes], fontsize=7.2)
40 + ax.yaxis.set_major_formatter(kdol); ax.set_ylabel("Dollars par année (première année)"); ax.set_ylim(0, 195000); ax.grid(axis="x", visible=False)
41 + ax.set_title("Cas 3 : du revenu brut potentiel au revenu net d'exploitation normalisé", loc="left")
42 + ax.text(0.99, -0.28, NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
43 + return savefig(fig, "ch28/ch28-fig-cascade.pdf")
44 +
45 +
46 +def fig_structure(res):
47 + g3 = res["classement"]["groupes_pct_rbe"]; g2 = res["cas2"]["classement"]["groupes_pct_rbe"]
48 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.4))
49 + y = np.arange(len(ORDRE_GROUPES)); h = 0.38
50 + ax.barh(y + h / 2, g3.values, height=h, color=palette.cat[0], label="Cas 3 — 12 logements, Montréal (ratio total 46,6 %)")
51 + ax.barh(y - h / 2, g2.values, height=h, color=palette.cat[1], label="Cas 2 — triplex, Québec (ratio total 45,7 %)")
52 + for yy, v in zip(y + h / 2, g3.values): ax.text(v + 0.003, yy, f"{v*100:.1f} %".replace(".", ","), va="center", fontsize=7.2)
53 + for yy, v in zip(y - h / 2, g2.values): ax.text(v + 0.003, yy, f"{v*100:.1f} %".replace(".", ","), va="center", fontsize=7.2)
54 + ax.set_yticks(y); ax.set_yticklabels(ORDRE_GROUPES); ax.invert_yaxis(); ax.xaxis.set_major_formatter(pct0); ax.set_xlim(0, 0.29)
55 + ax.set_xlabel("Dépense normalisée en pourcentage du revenu brut effectif"); ax.grid(axis="y", visible=False)
56 + ax.legend(loc="lower right", fontsize=7.5)
57 + ax.set_title("Structure des dépenses normalisées, par groupe de postes", loc="left")
58 + ax.text(0.99, -0.22, NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
59 + return savefig(fig, "ch28/ch28-fig-structure.pdf")
60 +
61 +
62 +def fig_evolution(res):
63 + dep = res["dep"].copy(); dep["groupe"] = dep["poste"].map(GROUPES)
64 + g = dep.groupby(["groupe", "annee"])["montant"].sum().unstack("annee").reindex(ORDRE_GROUPES).dropna(how="all")
65 + g = g[g[2023] > 0]
66 + det = res["detail"].assign(groupe=res["detail"]["poste"].map(GROUPES)).groupby("groupe")["normalise"].sum()
67 + fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.2), gridspec_kw={"width_ratios": [1.15, 1]})
68 + ax = axes[0]
69 + for i, (grp, r) in enumerate(g.iterrows()):
70 + idx = r / r[2023] * 100
71 + ax.plot([2023, 2024, 2025], idx.values, marker="o", color=palette.cat[i % 6], lw=1.4, ms=3.5, ls="-" if i < 6 else "--")
72 + ax.text(2025.07, idx[2025], grp, fontsize=6.8, va="center", color=palette.ink)
73 + tot = res["historique"].loc["Total", [2023, 2024, 2025]]; ax.plot([2023, 2024, 2025], (tot / tot[2023] * 100).values, color=palette.ink, lw=2.2, marker="s", ms=4)
74 + ax.text(2025.07, tot[2025] / tot[2023] * 100, "Total", fontsize=7.2, va="center", fontweight="bold")
75 + ax.set_xticks([2023, 2024, 2025]); ax.set_xlim(2022.8, 2026.1); ax.set_ylabel("Indice (2023 = 100)"); ax.set_title("Historique déclaré, indice par groupe", loc="left", fontsize=8.5)
76 + ax = axes[1]
77 + grp_order = [x for x in ORDRE_GROUPES if x in g.index or x in det.index]
78 + h25 = pd.Series({k: g.loc[k, 2025] if k in g.index else 0.0 for k in grp_order}); hn = det.reindex(grp_order).fillna(0.0)
79 + y = np.arange(len(grp_order)); w = 0.38
80 + ax.barh(y + w / 2, h25.values, height=w, color=palette.cat[4], label="Déclaré 2025")
81 + ax.barh(y - w / 2, hn.values, height=w, color=palette.cat[0], label="Normalisé (première année)")
82 + ax.set_yticks(y); ax.set_yticklabels(grp_order, fontsize=7); ax.invert_yaxis(); ax.xaxis.set_major_formatter(kdol); ax.grid(axis="y", visible=False)
83 + ax.legend(loc="lower right", fontsize=7); ax.set_title("2025 déclaré et montant normalisé", loc="left", fontsize=8.5)
84 + fig.text(0.99, -0.02, NOTE, ha="right", va="bottom", fontsize=7, color=palette.ink2)
85 + fig.tight_layout(w_pad=1.5)
86 + return savefig(fig, "ch28/ch28-fig-evolution.pdf")
87 +
88 +
89 +def fig_ratio_loyer(res):
90 + c = res["comps"]; b = res["base"]; hyp = res["hyp"]; m = res["marche"]
91 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))
92 + ax.scatter(c["loyer_mensuel_moyen"], c["ratio_depenses"], s=26 + 1.6 * c["nb_logements"], color=palette.cat[0], alpha=0.75, edgecolor=palette.surface, lw=0.6,
93 + label="Ventes comparables (taille du point = nombre de logements)")
94 + L = np.linspace(950, 1650, 200); ax.plot(L, ratio_iso_depenses(L, b, hyp), color=palette.cat[3], lw=1.6, label="Sujet : ratio si le loyer moyen était L (dépenses inchangées)")
95 + ax.scatter([m["loyer_moyen_contrat"]], [b["ratio_depenses"]], marker="D", s=70, color=palette.cat[3], edgecolor=palette.ink, lw=1.2, zorder=5)
96 + ax.scatter([m["loyer_moyen"]], [m["ratio"]], marker="D", s=70, facecolor="white", edgecolor=palette.cat[3], lw=1.4, zorder=5)
97 + ax.annotate("sujet, loyers contractuels\n1 150 $ ; 46,6 %", (m["loyer_moyen_contrat"], b["ratio_depenses"]), xytext=(985, 0.50), fontsize=7.3, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7))
98 + ax.annotate("sujet, loyers de marché\n1 292 $ ; 42,1 %", (m["loyer_moyen"], m["ratio"]), xytext=(1380, 0.47), fontsize=7.3, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7))
99 + med = res["comps_stats"]["ratio_median"]; ax.axhline(med, color=palette.ink2, lw=0.8, ls=":"); ax.text(1640, med + 0.004, f"médiane des comparables {med*100:.1f} %".replace(".", ","), ha="right", fontsize=7)
100 + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:,.0f} $".replace(",", " "))); ax.yaxis.set_major_formatter(pct0)
101 + ax.set_xlabel("Loyer mensuel moyen par logement"); ax.set_ylabel("Dépenses (réserve comprise) / revenu brut effectif"); ax.set_xlim(950, 1650); ax.set_ylim(0.28, 0.56)
102 + ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.2), ncol=1, fontsize=7.5)
103 + ax.set_title("Ratio de dépenses et niveau des loyers : quarante ventes et le sujet du cas 3", loc="left")
104 + ax.text(0.99, -0.42, NOTE, transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2)
105 + return savefig(fig, "ch28/ch28-fig-ratio-loyer.pdf")
106 +
107 +
108 +def fig_sensibilite(res):
109 + s = res["sensibilites"]; noi0 = res["base"]["rne"]
110 + fig, axes = plt.subplots(1, 3, figsize=(LARGEUR, 2.9), sharey=True)
111 + # réserve
112 + ax = axes[0]; r = s["reserve"]
113 + ax.plot(r["reserve_par_logement"], r["rne"], color=palette.cat[0], marker="o", ms=3.5)
114 + ax.axvline(675.25, color=palette.ink2, lw=0.7, ls=":"); ax.set_xlabel("Réserve ($ par logement par an)"); ax.set_title("Réserve de remplacement", loc="left", fontsize=8.5)
115 + ax.text(690, noi0 + 300, "675 $ (dossier)", fontsize=7)
116 + # taxes : taux continu
117 + ax = axes[1]; taux = np.linspace(0.0040, 0.0080, 50); role = res["hyp"]["valeur_role_2026"]; taxe0 = role * res["hyp"]["taux_taxe_fonciere"]
118 + ax.plot(taux * 100, noi0 - (role * taux - taxe0), color=palette.cat[1])
119 + for _, row in s["taxe"].iterrows():
120 + if row["taux_par_100"] <= 0.80: ax.scatter([row["taux_par_100"]], [row["rne"]], color=palette.cat[1], s=22, zorder=4)
121 + ax.axvline(0.4631, color=palette.ink2, lw=0.7, ls=":"); ax.set_xlabel("Taxe municipale ($ par 100 $ du rôle)"); ax.set_title("Taux de taxe (rôle inchangé)", loc="left", fontsize=8.5)
122 + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.2f}".replace(".", ",")))
123 + ax.text(0.48, noi0 + 300, "Montréal 0,4631", fontsize=7); ax.text(0.70, noi0 - 6200, "Gatineau 6+\n0,6964", fontsize=6.8); ax.text(0.715, noi0 - 9500, "Québec 6+\n0,7697", fontsize=6.8, ha="left")
124 + # gestion
125 + ax = axes[2]; g = s["gestion"]
126 + ax.plot(g["gestion_pct"], g["rne"], color=palette.cat[2], marker="o", ms=3.5)
127 + ax.axvline(0.04, color=palette.ink2, lw=0.7, ls=":"); ax.set_xlabel("Gestion imputée (% du RBE)"); ax.set_title("Taux de gestion", loc="left", fontsize=8.5)
128 + ax.xaxis.set_major_formatter(pct0); ax.text(0.045, noi0 + 300, "4 % (dossier)", fontsize=7)
129 + for ax in axes:
130 + ax.axhline(noi0, color=palette.ink2, lw=0.7, ls=":"); ax.yaxis.set_major_formatter(kdol)
131 + axes[0].set_ylabel("RNE normalisé ($/an)")
132 + fig.text(0.99, -0.04, NOTE, ha="right", va="bottom", fontsize=7, color=palette.ink2)
133 + fig.tight_layout(w_pad=1.2)
134 + return savefig(fig, "ch28/ch28-fig-sensibilite.pdf")
135 +
136 +
137 +def main():
138 + setup(); res = calculer()
139 + outs = [fig_cascade(res), fig_structure(res), fig_evolution(res), fig_ratio_loyer(res), fig_sensibilite(res)]
140 + for o in outs: print("écrit :", o)
141 +
142 +
143 +if __name__ == "__main__":
144 + main()
added analysis/python/figures/ch32_figures.py +128 −0
@@ -0,0 +1,128 @@
1 +"""Figures du chapitre 32 — Duplex à quintuplex (données SYNTHÉTIQUES du cas 2, sauf la figure des médianes APCIQ, citée).
2 +Produit : assets/figures/ch32/ch32-fig-{mrb-taille-quartier,taux-loyer-relatif,prix-logement-quartier,plex-apciq-rmr,sensibilite-occupant}.pdf
3 +Exécution : python3 analysis/python/figures/ch32_figures.py (depuis la racine)."""
4 +from __future__ import annotations
5 +import sys, pathlib
6 +import numpy as np
7 +import matplotlib.pyplot as plt
8 +from matplotlib.ticker import FuncFormatter
9 +
10 +ROOT = pathlib.Path(__file__).resolve().parents[3]
11 +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
12 +from traite.style import setup, palette, savefig, LARGEUR, DEMI, fmt_dollars, fmt_milliers # noqa: E402
13 +import ch32_plex as C # noqa: E402
14 +
15 +QUARTIERS = ["Limoilou", "Saint-Roch", "Saint-Sauveur", "Maizerets"]
16 +
17 +
18 +def _boxstyle(bp, color):
19 + for k in ("boxes", "whiskers", "caps", "medians"):
20 + for a in bp[k]: a.set_color(color); a.set_linewidth(1.2)
21 + for a in bp["medians"]: a.set_color(palette.ink); a.set_linewidth(1.6)
22 + for a in bp["fliers"]: a.set(marker="o", markersize=3, markerfacecolor="none", markeredgecolor=color, alpha=0.7)
23 +
24 +
25 +def fig_mrb(R):
26 + c = R["comparables"]
27 + fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.0), gridspec_kw={"width_ratios": [1, 2]})
28 + ax = axes[0]
29 + data = [c.loc[c["nb_logements"] == n, "mrb"] for n in (2, 3)]
30 + bp = ax.boxplot(data, widths=0.55, patch_artist=False); _boxstyle(bp, palette.cat[0])
31 + ax.set_xticks([1, 2]); ax.set_xticklabels([f"Duplex\n(n = {len(data[0])})", f"Triplex\n(n = {len(data[1])})"])
32 + ax.set_ylabel("Multiplicateur du revenu brut (MRB)"); ax.set_title("Selon la taille", loc="left")
33 + for i, d in enumerate(data, 1):
34 + ax.text(i + 0.33, d.median(), f"{d.median():.1f}".replace(".", ","), va="center", fontsize=8, color=palette.ink)
35 + ax = axes[1]
36 + data = [c.loc[c["quartier"] == q, "mrb"] for q in QUARTIERS]
37 + bp = ax.boxplot(data, widths=0.55); _boxstyle(bp, palette.cat[1])
38 + ax.set_xticks(range(1, 5)); ax.set_xticklabels([f"{q}\n(n = {len(d)})" for q, d in zip(QUARTIERS, data)])
39 + ax.set_title("Selon le quartier (2 et 3 logements confondus)", loc="left")
40 + for i, d in enumerate(data, 1):
41 + ax.text(i + 0.33, d.median(), f"{d.median():.1f}".replace(".", ","), va="center", fontsize=8, color=palette.ink)
42 + ymin = min(c["mrb"]) - 1; ymax = max(c["mrb"]) + 1
43 + for ax in axes: ax.set_ylim(ymin, ymax); ax.grid(axis="x", visible=False)
44 + fig.tight_layout(); savefig(fig, "ch32/ch32-fig-mrb-taille-quartier.pdf")
45 +
46 +
47 +def fig_taux_rho(R):
48 + g = R["decomposition"]; reg = R["regression"]; I = R["indications"]
49 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.3))
50 + for n, col, lab in ((2, palette.cat[0], "Duplex"), (3, palette.cat[1], "Triplex")):
51 + s = g[g["nb_logements"] == n]
52 + ax.scatter(s["loyer_relatif"], 100 * s["taux_global"], s=16, color=col, alpha=0.75, label=lab, edgecolors="none")
53 + xs = np.linspace(g["loyer_relatif"].min() - 0.01, 1.01, 50)
54 + ax.plot(xs, 100 * (reg["a"] + reg["b_rho"] * (xs - 1)), color=palette.ink, linewidth=1.4, label="Régression $R = a + b(\\rho - 1)$")
55 + ax.axhline(100 * R["indicateurs"]["taux_median"], color=palette.ink2, linestyle=":", linewidth=1)
56 + ax.text(xs[0], 100 * R["indicateurs"]["taux_median"] + 0.04, "médiane des 127 ventes", fontsize=7.5, color=palette.ink2)
57 + ax.scatter([I["rho_sujet"]], [100 * I["taux_ajuste"]], marker="*", s=140, color=palette.cat[2], zorder=5, label="Sujet : $\\rho$ = 0,848, taux ajusté", edgecolors=palette.ink, linewidths=0.5)
58 + ax.set_xlabel("Loyer contractuel moyen / loyer de marché estimé ($\\rho$)"); ax.set_ylabel("Taux global extrait (%)")
59 + ax.yaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.1f}".replace(".", ",")))
60 + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.2f}".replace(".", ",")))
61 + ax.legend(loc="upper left", fontsize=7.5, ncol=2); fig.tight_layout()
62 + savefig(fig, "ch32/ch32-fig-taux-loyer-relatif.pdf")
63 +
64 +
65 +def fig_prix_logement(R):
66 + c = R["comparables"]; comp5 = R["comparables5"]; I = R["indications"]
67 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.2))
68 + data = [c.loc[c["quartier"] == q, "prix_par_logement"] / 1000 for q in QUARTIERS]
69 + bp = ax.boxplot(data, widths=0.55); _boxstyle(bp, palette.cat[0])
70 + rng = np.random.default_rng(20261031)
71 + for i, q in enumerate(QUARTIERS, 1):
72 + s = c[c["quartier"] == q]
73 + ax.scatter(i + rng.uniform(-0.18, 0.18, len(s)), s["prix_par_logement"] / 1000, s=9, color=palette.cat[0], alpha=0.35, edgecolors="none")
74 + ax.scatter(np.ones(len(comp5)) + 0.0, comp5["prix_par_logement"] / 1000, marker="D", s=30, color=palette.cat[2], zorder=5, label="Cinq comparables retenus (triplex, Limoilou)", edgecolors=palette.ink, linewidths=0.4)
75 + ax.axhline(I["prix_log_final"] / 1000, color=palette.ink, linestyle="--", linewidth=1.1)
76 + ax.text(4.45, I["prix_log_final"] / 1000 + 4, "valeur pondérée\ndu sujet, par logement", fontsize=7.5, ha="right", color=palette.ink)
77 + ax.set_xticks(range(1, 5)); ax.set_xticklabels([f"{q}\n(n = {len(d)})" for q, d in zip(QUARTIERS, data)])
78 + ax.set_ylabel("Prix par logement (milliers de $)"); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers))
79 + ax.grid(axis="x", visible=False); ax.legend(loc="upper right", fontsize=7.5); fig.tight_layout()
80 + savefig(fig, "ch32/ch32-fig-prix-logement-quartier.pdf")
81 +
82 +
83 +# Médianes APCIQ (Centris) des plex de 2 à 5 logements, T3 2026 — chiffres cités (communiqué du 14 octobre 2026 et baromètres), sources/notes-E.md
84 +APCIQ_PLEX_T3_2026 = [
85 + ("Province de Québec", 699_000), ("RMR de Montréal", 860_500), ("Île de Montréal", 900_000), ("RMR de Québec", 600_000),
86 + ("Agglomération de Québec", 618_500), ("Secteur La Cité–Basse-Ville et Limoilou", 647_500), ("RMR de Gatineau", 617_500),
87 + ("RMR de Sherbrooke", 544_500), ("RMR de Trois-Rivières", 420_000), ("RMR de Saguenay", 373_500)]
88 +
89 +
90 +def fig_apciq():
91 + fig, ax = plt.subplots(figsize=(LARGEUR, 3.1))
92 + labels = [l for l, _ in APCIQ_PLEX_T3_2026][::-1]; vals = [v for _, v in APCIQ_PLEX_T3_2026][::-1]
93 + cols = [palette.cat[1] if ("Québec" in l and "Province" not in l) or "Limoilou" in l else palette.cat[0] for l in labels]
94 + ax.barh(labels, vals, color=cols, height=0.65)
95 + for i, v in enumerate(vals): ax.text(v + 8000, i, fmt_dollars(v), va="center", fontsize=7.5, color=palette.ink)
96 + ax.set_xlim(0, 1_050_000); ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: fmt_milliers(x / 1000) + " k$"))
97 + ax.set_xlabel("Prix médian des plex (2 à 5 logements), 3ᵉ trimestre 2026"); ax.grid(axis="y", visible=False); fig.tight_layout()
98 + savefig(fig, "ch32/ch32-fig-plex-apciq-rmr.pdf")
99 +
100 +
101 +def fig_sensibilite(R):
102 + S = R["sensibilite"]; base = R["base"]; I = R["indications"]; loyers = R["loyers"]
103 + fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 3.1))
104 + ax = axes[0]; L = S["loyers"]
105 + ax.plot(L["rho"], L["v_mrb"] / 1000, marker="o", color=palette.cat[0], label="MRB des comparables × RBP")
106 + ax.plot(L["rho"], L["v_cap_taux_median"] / 1000, marker="s", color=palette.cat[1], label="Capitalisation, taux médian fixe")
107 + ax.plot(L["rho"], L["v_cap_ajuste"] / 1000, marker="^", color=palette.cat[2], label="Capitalisation, taux ajusté à $\\rho$")
108 + ax.axvline(I["rho_sujet"], color=palette.ink2, linestyle=":", linewidth=1); ax.text(I["rho_sujet"] + 0.004, L["v_mrb"].max() / 1000, "sujet", fontsize=7.5, color=palette.ink2, va="top")
109 + ax.set_xlabel("Loyers contractuels / loyers de marché ($\\rho$)"); ax.set_ylabel("Indication de valeur (milliers de $)")
110 + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.2f}".replace(".", ","))); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers))
111 + ax.legend(fontsize=7, loc="upper left"); ax.set_title("Loyers sous le marché", loc="left")
112 + ax = axes[1]
113 + prix = I["valeur_arrondie"]; taux = np.arange(0.0312, 0.0713, 0.0025)
114 + for mf, col, lab in ((C.mise_de_fonds_minimale_assuree(prix), palette.cat[0], "Mise de fonds minimale assurée (7,1 %)"), (0.20 * prix, palette.cat[1], "Mise de fonds de 20 %")):
115 + y = [C.cout_occupation(C.financement_occupant(prix, mf, t), base, loyers, "RC")["cout_net_mensuel_hors_capital"] for t in taux]
116 + ax.plot(100 * taux, y, color=col, label=lab)
117 + ax.axhline(1430, color=palette.ink2, linestyle="--", linewidth=1); ax.text(100 * taux[0] + 0.05, 1430 + 40, "loyer de marché du logement occupé (1 430 $)", fontsize=7.5, color=palette.ink2)
118 + ax.axvline(100 * C.TAUX_ASSURE, color=palette.ink2, linestyle=":", linewidth=1); ax.text(100 * C.TAUX_ASSURE + 0.05, 3000, "4,12 %\n(assuré,\naoût 2026)", fontsize=7, color=palette.ink2)
119 + ax.set_xlabel("Taux hypothécaire (%)"); ax.set_ylabel("Coût net mensuel d'occupation, hors capital ($)")
120 + ax.xaxis.set_major_formatter(FuncFormatter(lambda x, p: f"{x:.1f}".replace(".", ","))); ax.yaxis.set_major_formatter(FuncFormatter(fmt_milliers))
121 + ax.legend(fontsize=7, loc="upper left"); ax.set_title("Financement de l'acheteur-occupant", loc="left")
122 + fig.tight_layout(); savefig(fig, "ch32/ch32-fig-sensibilite-occupant.pdf")
123 +
124 +
125 +if __name__ == "__main__":
126 + setup(); R = C.calculer()
127 + fig_mrb(R); fig_taux_rho(R); fig_prix_logement(R); fig_apciq(); fig_sensibilite(R)
128 + print("figures ch32 écrites dans", ROOT / "assets/figures/ch32")
modified analysis/python/tests/test_ch19.py +11 −0
@@ -234,3 +234,14 @@ def test_solutions_exercices():
234 234 co = np.array([[1, 0], [0, 2], [-3, 0.0]]); z = np.array([0.08, 0.05, -0.01]); p = {"pepite": 0.0045, "palier_partiel": 0.009, "portee": 5.0}
235 235 k = h.krigeage_ordinaire(co, z, np.array([[0, 0.0]]), p, None)
236 236 assert k["prediction"][0] == pytest.approx(0.0489, abs=0.0001) and k["variance"][0] == pytest.approx(0.00869, abs=0.00001)
237 + # ex. 19.1 (suite) : signes alternés → I = −1 exactement sur cette configuration
238 + e2 = np.array([0.10, -0.10, 0.10, -0.10, 0.10]); assert h.moran_i(e2, W)["I"] == pytest.approx(-1.0, abs=1e-9)
239 + # ex. 19.6 : poids du krigeage vs inverse de la distance
240 + idw = (1 / np.array([1, 2, 3.0])); idw /= idw.sum(); assert idw[2] == pytest.approx(0.182, abs=0.001) and (idw * z).sum() == pytest.approx(0.0555, abs=0.0001)
241 +
242 +def test_solution_exercice_7(res):
243 + r = c19.exercice_7(res["base"]); s = r["sem_par_k"].set_index("k")
244 + assert s.loc[4, "lambda"] == pytest.approx(0.5066, abs=0.0005) and s.loc[16, "lambda"] == pytest.approx(0.7855, abs=0.0005)
245 + assert s.loc[4, "u_sujet"] == pytest.approx(-0.0189, abs=0.0005) and s.loc[16, "u_sujet"] == pytest.approx(-0.0554, abs=0.0005)
246 + assert list(s["indication"].round(0)) == [656724, 633352, 628672]
247 + assert r["st_24"]["sans_voisin"] == 16 and r["st_24"]["rho_st"] == pytest.approx(0.4149, abs=0.0005) and r["st_12"]["rho_st"] == pytest.approx(0.3696, abs=0.0005)
added analysis/python/tests/test_ch24.py +211 −0
@@ -0,0 +1,211 @@
1 +"""Tests du chapitre 24 — dépréciation et désuétudes du sujet du cas 1, extraction de marché, profils et exercices.
2 +Tolérances : 0,01 $ sur les montants recalculés ; 0,5 $ sur les montants arrondis cités ; 5e-4 sur les taux cités en % à deux
3 +décimales ; 1e-9 sur les identités."""
4 +import sys, pathlib
5 +import numpy as np, pytest
6 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
7 +import ch24_depreciation as cas
8 +from traite import cout as ct
9 +
10 +
11 +@pytest.fixture(scope="module")
12 +def data():
13 + d = cas.charger()
14 + var = cas.variantes_physique(d["cn"], d["fiche"], d["travaux"])
15 + se = cas.carence_salle_eau(d["devis"], d["cn"])
16 + w, vl = cas.extraction(d["ventes"], d["modele"], d["cn"]["total"])
17 + aj = cas.ajuster_profil(w); vlr = cas.valeur_terrain_retenue(vl)
18 + pr = cas.profils(d["modele"], d["sujet"], vlr["retenue"], aj["quadratique"])
19 + vs = cas.valeur_sujet(d["cn"], vlr["retenue"], aj["quadratique"], se["desuetude"], var)
20 + return {**d, "var": var, "se": se, "w": w, "vl": vl, "aj": aj, "vlr": vlr, "pr": pr, "vs": vs, "ages": cas.ages_sujet(d["fiche"])}
21 +
22 +
23 +class TestAges:
24 + def test_ages_cites(self, data):
25 + a = data["ages"]
26 + assert a["chronologique"] == 28 and a["effectif_structure"] == pytest.approx(29.0, abs=1e-9)
27 + assert a["vie_restante"] == pytest.approx(31.0, abs=1e-9) and a["vie_economique"] == 60
28 + assert a["indice_etat"] == pytest.approx(2.809, abs=5e-4) and a["age_effectif_pondere"] == pytest.approx(32.8, abs=0.05)
29 +
30 + def test_convention_age_effectif(self):
31 + # a_eff = (1 − λ) a + λ φ(c) L : structure 28 ans, état 3 (φ = 0,5), L = 60 → 0,5·28 + 0,5·30 = 29
32 + import ch11_inspection as c11
33 + assert c11.age_effectif(28, 60, 3, 0.5) == 29.0 and c11.age_effectif(28, 60, 1, 0.5) == 15.5 and c11.age_effectif(28, 60, 5, 0.5) == 44.0
34 + assert ct.vie_restante(60, 29) == 31.0 and ct.vie_restante(60, 75) == 0.0
35 +
36 +
37 +class TestPhysique:
38 + def test_quatre_lectures(self, data):
39 + v = data["var"]; cn = data["cn"]["total"]
40 + assert cn == pytest.approx(397567.33, abs=0.01)
41 + assert v["age_vie_chrono"] == pytest.approx(185531.42, abs=0.01) and v["age_vie_chrono"] / cn == pytest.approx(0.4667, abs=5e-4)
42 + assert v["age_vie_effectif"] == pytest.approx(192157.55, abs=0.01)
43 + assert v["modifiee"]["curable"] == pytest.approx(10479.98, abs=0.01) and v["modifiee"]["incurable"] == pytest.approx(187092.22, abs=0.01)
44 + assert v["modifiee"]["total"] == pytest.approx(197572.20, abs=0.01) and v["modifiee"]["total"] / cn == pytest.approx(0.4970, abs=5e-4)
45 + ve = v["ventilee"]
46 + assert sum(ve["courte_duree"].values()) == pytest.approx(130584.55, abs=0.01) and ve["longue_duree"] == pytest.approx(80025.35, abs=0.01)
47 + assert ve["base_longue"] == pytest.approx(165569.69, abs=0.01) and ve["total"] == pytest.approx(221089.88, abs=0.01)
48 + assert ve["taux"] == pytest.approx(0.5561, abs=5e-4) and len(ve["courte_duree"]) == 18
49 +
50 + def test_identites_ventilee(self, data):
51 + v = data["var"]; ve = v["ventilee"]; f = data["fiche"]; cn = data["cn"]["total"]
52 + courtes = f[(~f["longue_duree"]) & (f["composante"] != "Entrée asphaltée")]
53 + assert courtes["cout_neuf"].sum() == pytest.approx(221517.66, abs=0.01)
54 + # base longue = C_N − curable − Σ coût neuf courte durée
55 + assert ve["base_longue"] == pytest.approx(cn - ve["curable"] - courtes["cout_neuf"].sum(), abs=1e-6)
56 + assert ve["total"] == pytest.approx(ve["curable"] + sum(ve["courte_duree"].values()) + ve["longue_duree"], abs=1e-9)
57 + # proposition : D_vent − D_simple = Cur (1 − r_S) + Σ C_k (r_k − r_S)
58 + rS = 29.0 / 60.0
59 + ecart = ve["curable"] * (1 - rS) + sum(ve["courte_duree"].values()) - courtes["cout_neuf"].sum() * rS
60 + assert ve["total"] - v["age_vie_effectif"] == pytest.approx(ecart, abs=1e-6) and ecart == pytest.approx(28932.33, abs=0.01)
61 + # chaque composante : dépréciation = coût neuf × min(a_eff / L, 1)
62 + for r in courtes.itertuples():
63 + assert ve["courte_duree"][r.composante] == pytest.approx(r.cout_neuf * min(r.age_effectif / r.vie_utile, 1.0), abs=1e-6)
64 + assert ve["courte_duree"]["Ventilateur récupérateur de chaleur (VRC)"] == pytest.approx(7501.27, abs=0.01)
65 + assert ve["courte_duree"]["Revêtement extérieur (brique et fibrociment)"] == pytest.approx(18089.57, abs=0.01)
66 +
67 +
68 +class TestDesuetudes:
69 + def test_salle_eau(self, data):
70 + se = data["se"]
71 + assert se["cout_si_neuf_direct"] == pytest.approx(6309.76, abs=0.01) and se["cout_si_neuf"] == pytest.approx(8394.69, abs=0.01)
72 + assert se["desuetude"] == pytest.approx(7605.31, abs=0.01) and se["desuetude"] == pytest.approx(16000 - se["cout_si_neuf"], abs=1e-9)
73 +
74 + def test_exemples(self):
75 + ex = cas.exemples_incurables()
76 + assert ex["mrb_annuel"] == pytest.approx(19.82, abs=1e-9) and ex["perte_loyer_capitalisee"] == pytest.approx(17838.00, abs=0.01)
77 + assert ex["perte_rne_capitalisee_totale"] == pytest.approx(133333.33, abs=0.01) and ex["perte_rne_batiment"] == pytest.approx(93333.33, abs=0.01)
78 + assert ex["paires"] == pytest.approx(18000.0, abs=1e-9)
79 +
80 +
81 +class TestExtraction:
82 + def test_valeur_terrain(self, data):
83 + vl, vlr = data["vl"], data["vlr"]
84 + assert int(vl.loc["Hull", "n"]) == 13 and vl.loc["Hull", "vl_mediane"] == pytest.approx(276953.99, abs=0.01)
85 + assert vl.loc["Hull", "part_mediane"] == pytest.approx(0.505, abs=5e-4) and vlr["retenue"] == 277000.0
86 + assert vlr["retenue"] / 620 == pytest.approx(447, abs=0.5)
87 + assert vl.loc["Aylmer", "vl_mediane"] == pytest.approx(321579, abs=1) and vl.loc["Gatineau", "vl_mediane"] == pytest.approx(270089, abs=1)
88 + assert vl.loc["Buckingham", "vl_mediane"] == pytest.approx(182678, abs=1) and vl.loc["Masson-Angers", "vl_mediane"] == pytest.approx(175657, abs=1)
89 + assert int(vl["n"].sum()) == 33
90 +
91 + def test_identite_extraction(self, data):
92 + w = data["w"]
93 + assert len(w) == 600
94 + assert np.allclose(w["D"], w["cn"] - (w["p_adj"] - w["vl"]), atol=1e-6) and np.allclose(w["taux"], w["D"] / w["cn"], atol=1e-12)
95 + assert data["modele"]["croissance_mensuelle"] == pytest.approx(0.00364, abs=5e-6)
96 +
97 + def test_tranches_et_ajustements(self, data):
98 + tr = cas.tranches(data["w"])
99 + assert list(tr["size"]) == [33, 47, 76, 115, 124, 101, 104]
100 + assert tr.loc["0-10", "median"] == pytest.approx(0.038, abs=5e-4) and tr.loc["21-30", "median"] == pytest.approx(0.192, abs=5e-4)
101 + assert tr.loc["61-100", "median"] == pytest.approx(0.403, abs=5e-4)
102 + aj = data["aj"]; q = aj["quadratique"]["beta"]; l = aj["lineaire"]["beta"]
103 + assert l[0] == pytest.approx(0.0878, abs=5e-5) and l[1] == pytest.approx(0.00447, abs=5e-6) and aj["lineaire"]["r2"] == pytest.approx(0.228, abs=5e-4)
104 + assert q[0] == pytest.approx(0.0107, abs=5e-5) and q[1] == pytest.approx(0.00891, abs=5e-6) and q[2] == pytest.approx(-0.0000514, abs=5e-8)
105 + assert aj["quadratique"]["r2"] == pytest.approx(0.250, abs=5e-4)
106 + assert aj["taux_annuel_origine"] == pytest.approx(0.00619, abs=5e-6) and aj["vie_implicite"] == pytest.approx(162, abs=0.5)
107 + assert float(cas.taux_quadratique(aj["quadratique"], 28)) == pytest.approx(0.2200, abs=5e-5)
108 + assert float(cas.taux_quadratique(aj["quadratique"], 60)) == pytest.approx(0.3605, abs=5e-5)
109 +
110 +
111 +class TestProfils:
112 + def test_profils_cites(self, data):
113 + pr = data["pr"]
114 + assert pr["prix_neuf"] == pytest.approx(720727, abs=1) and pr["alpha"] == pytest.approx(0.384, abs=5e-4)
115 + assert pr["hedonique_total"][28] == pytest.approx(0.1148, abs=5e-5) and pr["generateur_total"][28] == pytest.approx(0.1223, abs=5e-5)
116 + assert pr["hedonique_bat"][28] == pytest.approx(0.1865, abs=5e-4) and pr["generateur_bat"][28] == pytest.approx(0.1986, abs=5e-4)
117 + assert pr["age_vie"][28] == pytest.approx(28 / 60, abs=1e-12) and pr["age_vie"][60] == 1.0
118 + # conversion : d_B (1 − α) = d_total
119 + assert np.allclose(pr["hedonique_bat"] * (1 - pr["alpha"]), pr["hedonique_total"])
120 + # profils croissants sur 0-80 ans
121 + assert np.all(np.diff(pr["extraction"]) > 0) and np.all(np.diff(pr["hedonique_bat"]) > 0)
122 +
123 + def test_generateur(self):
124 + assert cas.profil_total_generateur(28) == pytest.approx(1 - np.exp(-0.0055 * 28 + 0.00003 * 784), abs=1e-12)
125 +
126 +
127 +class TestValeur:
128 + def test_deux_lectures(self, data):
129 + vs = data["vs"]; A, B = vs["A_ventilee"], vs["B_extraction"]
130 + assert vs["d_extraite"] == pytest.approx(87461.05, abs=0.01)
131 + assert A["valeur"] == pytest.approx(445872.14, abs=0.01) and A["taux_depreciation"] == pytest.approx(0.5752, abs=5e-4)
132 + assert B["valeur"] == pytest.approx(579500.97, abs=0.01) and B["depreciation_totale"] == pytest.approx(95066.36, abs=0.01)
133 + assert B["taux_depreciation"] == pytest.approx(0.2391, abs=5e-4) and B["contribution_batiment"] == pytest.approx(302500.97, abs=0.01)
134 + assert vs["ecart"] == pytest.approx(133628.83, abs=0.01) and vs["valeur_arrondie"] == 580000.0
135 + # identité V = V_L + C_N − D_phys − D_fonc − D_ext
136 + assert B["valeur"] == pytest.approx(277000 + 397567.33 - 87461.05 - 7605.31, abs=0.02)
137 +
138 + def test_ordre_externe(self, data):
139 + cn = data["cn"]["total"]; vs = data["vs"]
140 + # une désuétude externe de 10 % appliquée sur C_N plutôt que sur la base restante surestime la déduction
141 + avant = 0.10 * cn; apres = 0.10 * vs["B_extraction"]["contribution_batiment"]
142 + assert avant == pytest.approx(39756.73, abs=0.01) and apres == pytest.approx(30250.10, abs=0.01) and avant - apres == pytest.approx(9506.64, abs=0.01)
143 +
144 +
145 +class TestSensibilite:
146 + def test_grille(self, data):
147 + g = cas.grille_age_vie(data["cn"], data["var"]["curable"])
148 + assert g.loc[60, 29] == pytest.approx(0.4970, abs=5e-4) and g.loc[50, 37] == pytest.approx(0.747, abs=5e-4) and g.loc[100, 20] == pytest.approx(0.221, abs=5e-4)
149 + assert g.loc[80, 29] == pytest.approx(0.379, abs=5e-4)
150 +
151 + def test_extraction_variantes(self, data):
152 + sx = cas.sensibilite_extraction(data["ventes"], data["modele"], data["cn"]["total"]).set_index("variante")
153 + assert sx.loc["cas de base", "taux_28"] == pytest.approx(0.2200, abs=5e-5)
154 + assert sx.loc["ventes récentes ≤ 5 ans", "taux_28"] == pytest.approx(0.1968, abs=5e-5) and sx.loc["ventes récentes ≤ 15 ans", "taux_28"] == pytest.approx(0.2720, abs=5e-5)
155 + assert sx.loc["ventes récentes ≤ 15 ans", "vl_hull"] == pytest.approx(303775, abs=1)
156 + assert sx["taux_28"].min() == pytest.approx(0.1968, abs=5e-5) and sx["taux_28"].max() == pytest.approx(0.2720, abs=5e-5)
157 +
158 + def test_tornade(self, data):
159 + sx = cas.sensibilite_extraction(data["ventes"], data["modele"], data["cn"]["total"])
160 + t = {o["param"]: o for o in cas.tornade(data["cn"], data["vlr"]["retenue"], data["vs"]["d_extraite"], data["se"]["desuetude"], data["var"], sx)}
161 + assert t["Valeur du terrain ±15 %"]["bas"] == pytest.approx(537951, abs=1) and t["Valeur du terrain ±15 %"]["haut"] == pytest.approx(621051, abs=1)
162 + assert t["Choix d'extraction (taux à 28 ans)"]["bas"] == pytest.approx(558807, abs=1) and t["Choix d'extraction (taux à 28 ans)"]["haut"] == pytest.approx(588716, abs=1)
163 + assert t["Coût neuf ±8,6 % (date des prix)"]["bas"] == pytest.approx(554944, abs=1) and t["Coût neuf ±8,6 % (date des prix)"]["haut"] == pytest.approx(606170, abs=1)
164 + assert t["Lecture ventilée (ch. 11) au lieu de l'extraction"]["bas"] == pytest.approx(445872, abs=1)
165 +
166 +
167 +class TestExercices:
168 + def test_ex1(self):
169 + assert ct.depreciation_age_vie(320000, 15, 60) == 80000 and ct.vie_restante(60, 15) == 45
170 + m = ct.depreciation_age_vie_modifiee(320000, 9000, 15, 60)
171 + assert m["incurable"] == 77750 and m["total"] == 86750 and m["total"] / 320000 == pytest.approx(0.2711, abs=5e-5)
172 + assert ct.depreciation_age_vie_modifiee(320000, 9000, 20, 60)["total"] == pytest.approx(112666.67, abs=0.01)
173 +
174 + def test_ex2(self):
175 + comps = [ct.Composante("toiture", 9000, 12, 20), ct.Composante("fenêtres", 18000, 10, 30), ct.Composante("chauffe-eau", 1500, 8, 12)]
176 + v = ct.depreciation_ventilee(320000, 9000, comps, 15, 60)
177 + assert v["courte_duree"] == {"toiture": 5400, "fenêtres": 6000, "chauffe-eau": 1000} and v["base_longue"] == 282500
178 + assert v["longue_duree"] == 70625 and v["total"] == 92025 and v["total"] / 320000 == pytest.approx(0.2876, abs=5e-5)
179 + assert v["total"] - 86750 == 5275
180 +
181 + def test_ex3(self):
182 + assert ct.desuetude_fonctionnelle_curable(16000, 8394.69, 0) == pytest.approx(7605.31, abs=1e-9)
183 + assert 22000 * 0.6 + 3000 == 16200 # base de reproduction : solde non déprécié + coût d'enlèvement
184 +
185 + def test_ex4(self):
186 + assert ct.desuetude_incurable_par_loyer(75, 19.82 * 12) == pytest.approx(17838, abs=1e-9)
187 + assert ct.desuetude_incurable_par_rne(6000, 0.045, 0.7) == pytest.approx(93333.33, abs=0.01)
188 + assert ct.desuetude_incurable_par_rne(6000, 0.055, 0.7) == pytest.approx(76363.64, abs=0.01)
189 +
190 + def test_ex5(self):
191 + ventes = [(610000, 260000, 420000, 12), (545000, 250000, 390000, 25), (480000, 240000, 380000, 41), (520000, 255000, 410000, 33)]
192 + e = [ct.extraction_depreciation_marche(p, l, c, a) for p, l, c, a in ventes]
193 + assert [x["depreciation"] for x in e] == [70000, 95000, 140000, 145000]
194 + ta = [x["taux_annuel"] for x in e]
195 + assert ta[0] == pytest.approx(0.01389, abs=5e-6) and ta[2] == pytest.approx(0.00899, abs=5e-6)
196 + m = float(np.mean(ta))
197 + assert m == pytest.approx(0.01083, abs=5e-6) and 1 / m == pytest.approx(92.3, abs=0.05) and 30 * m * 400000 == pytest.approx(130006, abs=1)
198 + m2 = float(np.mean([ct.extraction_depreciation_marche(p, l + 20000, c, a)["taux_annuel"] for p, l, c, a in ventes]))
199 + assert m2 == pytest.approx(0.01303, abs=5e-6) and 30 * m2 * 400000 == pytest.approx(156350, abs=1)
200 +
201 + def test_ex6(self):
202 + b1, b2, al = -0.005097, 0.00002642, 0.384
203 + t28 = 1 - np.exp(b1 * 28 + b2 * 784)
204 + assert t28 == pytest.approx(0.1149, abs=5e-5) and t28 / (1 - al) == pytest.approx(0.1865, abs=5e-5)
205 + assert (1 - np.exp(b1 * 50 + b2 * 2500)) / (1 - al) == pytest.approx(0.2793, abs=5e-5)
206 + assert -(b1 + 2 * b2 * 28) == pytest.approx(0.003617, abs=5e-7) and -b1 / (2 * b2) == pytest.approx(96.5, abs=0.05)
207 + assert 1 - t28 / (28 / 60) == pytest.approx(0.754, abs=5e-4)
208 +
209 + def test_ex7(self):
210 + assert 6130.62 + 15989.85 == pytest.approx(22120.47, abs=0.01)
211 + assert 0.10 * 397567.33 - 0.10 * 302500.97 == pytest.approx(9506.64, abs=0.01)
added analysis/python/tests/test_ch27.py +183 −0
@@ -0,0 +1,183 @@
1 +"""Tests du chapitre 27 — revenus potentiels, vacance et pertes (cas 3 et cas 2, données synthétiques ; tableaux SCHL réels).
2 +Identités (RBE, cascade, modèle frictionnel, concessions, trajectoires) + valeurs citées dans manuscript/chapters/ch27.tex et
3 +solutions/ch27.tex (tolérances déclarées : 0,01 $ sur les montants, 1e-6 sur les taux, 0,05 point sur les lectures SCHL)."""
4 +import sys, pathlib
5 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
6 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
7 +import numpy as np, pytest
8 +from traite import cas2, cas3, finance as fin
9 +import ch27_revenus as c
10 +
11 +ROOT = pathlib.Path(__file__).resolve().parents[3]
12 +
13 +
14 +@pytest.fixture(scope="module")
15 +def res():
16 + return c.calculer()
17 +
18 +
19 +# ----------------------------------------------------------------------------- bibliothèque : fonctions ajoutées pour le chapitre
20 +def test_vacance_frictionnelle_identites():
21 + assert fin.taux_vacance_frictionnelle(0.15, 1.0) == pytest.approx(0.0125)
22 + assert fin.taux_vacance_frictionnelle(0.094, 12.0) == pytest.approx(0.094) # une année entière de vacance = la rotation
23 + assert fin.taux_vacance_frictionnelle(0.0, 3.0) == 0.0 and fin.taux_vacance_frictionnelle(0.2, 0.0) == 0.0
24 + # linéarité : doubler la durée ou la rotation double la provision
25 + assert fin.taux_vacance_frictionnelle(0.1, 2.0) == pytest.approx(2 * fin.taux_vacance_frictionnelle(0.1, 1.0)) == pytest.approx(2 * fin.taux_vacance_frictionnelle(0.05, 2.0))
26 + with pytest.raises(ValueError):
27 + fin.taux_vacance_frictionnelle(-0.1, 1.0)
28 +
29 +
30 +def test_loyer_effectif_concession():
31 + assert fin.loyer_effectif_concession(1300, 1, 12) == pytest.approx(1300 * 11 / 12)
32 + assert fin.loyer_effectif_concession(1300, 0, 12) == 1300 and fin.loyer_effectif_concession(1300, 12, 12) == 0
33 + # un mois sur douze ≡ deux mois sur vingt-quatre
34 + assert fin.loyer_effectif_concession(1300, 1, 12) == pytest.approx(fin.loyer_effectif_concession(1300, 2, 24))
35 + with pytest.raises(ValueError):
36 + fin.loyer_effectif_concession(1300, 13, 12)
37 +
38 +
39 +# ----------------------------------------------------------------------------- cas 3 : identités et valeurs citées
40 +def test_cascade_cas3_identites(res):
41 + r = res["cas3"]
42 + assert r["rbp_contrat"] == pytest.approx(12 * r["etat"]["loyer_mensuel_contrat"].sum())
43 + assert r["rbp_marche"] == pytest.approx(12 * r["etat"]["loyer_mensuel_marche"].sum())
44 + assert r["rbe"] == pytest.approx(fin.revenu_brut_effectif(r["rbp_contrat"], r["taux_vacance_pertes"], r["autres_revenus"]))
45 + assert r["rbe"] == pytest.approx(r["loyers_effectifs"] + r["autres_revenus"])
46 + assert r["loyers_effectifs"] == pytest.approx(r["rbp_contrat"] - r["vacance_pertes"])
47 + assert r["rbp_marche"] - r["deficit_loyer"] == pytest.approx(r["rbp_contrat"])
48 + assert r["taux_occupation_economique"] == pytest.approx((1 - r["taux_vacance_pertes"]) * r["rbp_contrat_sur_marche"])
49 + # cohérence avec le module partagé (ch. 29, 30, 47)
50 + t = cas3.tout()
51 + assert r["rbe"] == pytest.approx(t["base"]["rbe"], rel=1e-12) and r["rbp_contrat"] == pytest.approx(t["base"]["rbp_contrat"], rel=1e-12)
52 +
53 +
54 +def test_valeurs_citees_cas3(res):
55 + r = res["cas3"]
56 + assert r["loyer_mensuel_contrat"] == 13795 and r["loyer_mensuel_marche"] == 15503
57 + assert r["rbp_contrat"] == pytest.approx(165_540, abs=0.01) and r["rbp_marche"] == pytest.approx(186_036, abs=0.01)
58 + assert r["deficit_loyer"] == pytest.approx(20_496, abs=0.01) and r["deficit_pct"] == pytest.approx(0.1102, abs=5e-5)
59 + assert r["vacance_pertes"] == pytest.approx(6_456.06, abs=0.01) and r["loyers_effectifs"] == pytest.approx(159_083.94, abs=0.01)
60 + assert r["autres_revenus"] == pytest.approx(9_360, abs=0.01) and r["autres_par_logement_mois"] == pytest.approx(65, abs=0.01)
61 + assert r["rbe"] == pytest.approx(168_443.94, abs=0.01) and r["rbe_sur_rbp"] == pytest.approx(1.0175, abs=5e-5)
62 + assert r["rbe_par_logement"] == pytest.approx(14_037.00, abs=0.01) and r["rbe_par_m2"] == pytest.approx(204.17, abs=0.01)
63 + assert r["taux_occupation_economique"] == pytest.approx(0.8551, abs=5e-5)
64 + assert r["rbe_marche"] == pytest.approx(188_140.60, abs=0.01)
65 + assert r["rbe_variante_v_sur_tout"] == pytest.approx(168_078.90, abs=0.01) and r["ecart_variante_v_sur_tout"] == pytest.approx(-365.04, abs=0.01)
66 + assert r["v_frictionnel_dossier"] == pytest.approx(0.0125, abs=1e-9) and r["v_residuel_dossier"] == pytest.approx(0.0265, abs=1e-9)
67 +
68 +
69 +def test_par_type_cas3(res):
70 + p = res["par_type"]
71 + assert p.loc["3½", "n"] == 4 and p.loc["4½", "n"] == 6 and p.loc["5½", "n"] == 2
72 + assert p.loc["3½", "ecart_pct"] == pytest.approx(15.76, abs=0.01) and p.loc["4½", "ecart_pct"] == pytest.approx(11.07, abs=0.01)
73 + assert p.loc["5½", "ecart_pct"] == pytest.approx(4.93, abs=0.01) and p.loc["Total", "ecart_pct"] == pytest.approx(11.02, abs=0.01)
74 + assert p.loc["3½", "anciennete"] == pytest.approx(9.0) and p.loc["Total", "anciennete"] == pytest.approx(6.33, abs=0.01)
75 + assert p.loc["Total", "rbp_contrat"] == pytest.approx(165_540) and p.loc["Total", "rbp_marche"] == pytest.approx(186_036)
76 +
77 +
78 +# ----------------------------------------------------------------------------- cas 2
79 +def test_cascade_cas2(res):
80 + r = res["cas2"]
81 + t = cas2.tout()
82 + assert r["rbe"] == pytest.approx(t["base"]["rbe"], rel=1e-12)
83 + assert r["rbp_contrat"] == pytest.approx(41_832) and r["rbp_marche"] == pytest.approx(49_332) and r["ecart_marche"] == pytest.approx(7_500)
84 + assert r["deficit_pct"] == pytest.approx(0.1520, abs=5e-5) and r["vacance_pertes"] == pytest.approx(1_254.96, abs=0.01)
85 + assert r["rbe"] == pytest.approx(40_577.04, abs=0.01) and r["rbe_marche"] == pytest.approx(47_852.04, abs=0.01)
86 + assert r["taux_occupation_economique"] == pytest.approx(0.8225, abs=5e-5) and r["rbe_variante_occupant"] == pytest.approx(40_953.96, abs=0.01)
87 + assert r["rbe_par_logement"] == pytest.approx(13_525.68, abs=0.01)
88 +
89 +
90 +# ----------------------------------------------------------------------------- SCHL (réel) : lectures des tableaux
91 +def test_schl_lectures(res):
92 + s = res["schl_rmr"]
93 + attendu = {"Montréal": (2.1, 2.9, 11.1, 9.4), "Québec": (0.9, 2.4, 13.9, 13.0), "Gatineau (partie QC)": (1.9, 3.8, 17.3, 16.2),
94 + "Sherbrooke": (1.4, 2.7, 10.4, 12.0), "Trois-Rivières": (0.9, 2.7, 10.2, 10.1), "Saguenay": (1.6, 1.3, 13.3, 12.6)}
95 + for k, (i24, i25, r24, r25) in attendu.items():
96 + assert (s.loc[k, "inocc_2024"], s.loc[k, "inocc_2025"], s.loc[k, "rotation_2024"], s.loc[k, "rotation_2025"]) == pytest.approx((i24, i25, r24, r25), abs=0.05)
97 + t = res["schl_taille"].set_index(["zone", "taille"])
98 + assert t.loc[("Île de Montréal", "6 à 19 unités"), "inocc_2025"] == pytest.approx(2.2) and t.loc[("Île de Montréal", "3 à 5 unités"), "inocc_2025"] == pytest.approx(2.3)
99 + assert t.loc[("Île de Montréal", "100 unités et +"), "inocc_2025"] == pytest.approx(5.1) and t.loc[("Île de Montréal", "Tous les immeubles"), "inocc_2025"] == pytest.approx(3.1)
100 + assert t.loc[("RMR de Montréal", "6 à 19 unités"), "rotation_2025"] == pytest.approx(8.5) and t.loc[("RMR de Montréal", "100 unités et +"), "rotation_2025"] == pytest.approx(14.2)
101 + assert t.loc[("RMR de Montréal", "Tous les immeubles"), "inocc_2025"] == pytest.approx(2.9)
102 + q = res["schl_quartiles"]
103 + assert (q.loc["Q1", "inocc_2025"], q.loc["Q4", "inocc_2025"]) == pytest.approx((1.3, 4.9))
104 + io = res["schl_inocc_occ"]
105 + assert (io.loc["Tous", "inoccupes"], io.loc["Tous", "occupes"]) == (1573, 1275) and io.loc["Tous", "ecart_pct"] == pytest.approx(23.4, abs=0.05)
106 + assert (io.loc["2 chambres", "inoccupes"], io.loc["2 chambres", "occupes"]) == (1815, 1349)
107 + a = res["schl_annee"]
108 + assert a.loc["2015 ou après", "inocc_2025"] == pytest.approx(5.6) and a.loc["Avant 1940", "inocc_2025"] == pytest.approx(2.2)
109 +
110 +
111 +def test_ipc_loyer(res):
112 + i = res["ipc_loyer"]
113 + assert (i["sept2026"], i["sept2025"]) == pytest.approx((174.2, 168.6))
114 + assert i["var_12m"] == pytest.approx(0.0332, abs=5e-5) and i["cagr_10ans"] == pytest.approx(0.0402, abs=5e-5)
115 +
116 +
117 +# ----------------------------------------------------------------------------- modèle frictionnel, sensibilité, trajectoires
118 +def test_grille_et_sensibilite(res):
119 + g = res["grille_frictionnelle"]
120 + assert g.loc[9.4, 1.0] == pytest.approx(0.78, abs=0.005) and g.loc[13.0, 2.0] == pytest.approx(2.17, abs=0.005) and g.loc[15.0, 1.0] == pytest.approx(1.25)
121 + s = res["sensibilite_v"]; b = res["cas3"]
122 + assert s.attrs["R"] == pytest.approx(0.04415, abs=1e-6)
123 + assert s.attrs["dRBE_dv_pt"] == pytest.approx(-1_655.40, abs=0.01) and s.attrs["dRNE_dv_pt"] == pytest.approx(-1_589.18, abs=0.01)
124 + assert s.attrs["dV_dv_pt"] == pytest.approx(-35_995.11, abs=0.01)
125 + row = s.set_index("v")
126 + assert row.loc[0.039, "valeur"] == pytest.approx(2_037_502.89, abs=0.01) # = ch. 29
127 + assert row.loc[0.029, "valeur"] == pytest.approx(2_073_497.99, abs=0.01) and row.loc[0.08, "valeur"] == pytest.approx(1_889_922.94, abs=0.01)
128 + assert row.loc[0.02, "rbe"] == pytest.approx(171_589.20, abs=0.01) and row.loc[0.06, "rne"] == pytest.approx(86_618.47, abs=0.01)
129 + # linéarité exacte en v : V(v) = V(0,039) + (v − 0,039)·100·dV/dv
130 + for v in (0.02, 0.05, 0.08):
131 + assert row.loc[v, "valeur"] == pytest.approx(row.loc[0.039, "valeur"] + (v - 0.039) * 100 * s.attrs["dV_dv_pt"], abs=0.01)
132 +
133 +
134 +def test_trajectoires(res):
135 + tr = res["trajectoires"]; t = tr["table"].set_index("annee"); h = tr["hyp"]
136 + assert t.loc[1, "base_rot15"] == pytest.approx(165_540) and t.loc[1, "marche"] == pytest.approx(186_036)
137 + # indexation seule = RBP × 1,031^(t−1)
138 + for k in range(1, 12):
139 + assert t.loc[k, "tal_seul"] == pytest.approx(165_540 * 1.031 ** (k - 1), rel=1e-9)
140 + # ordre des trajectoires : TAL seul ≤ rotation 9,4 % ≤ rotation 15 % ≤ marché
141 + assert ((t["tal_seul"] <= t["rot_schl_094"] + 1e-6) & (t["rot_schl_094"] <= t["base_rot15"] + 1e-6) & (t["base_rot15"] <= t["marche"] + 1e-6)).all()
142 + # base = traite.cas3.projection_dcf (ch. 30/47)
143 + p = cas3.tout()["projection"]
144 + assert np.allclose(t["base_rot15"].values, p["rbp"].values) and np.allclose(t["marche"].values, p["rbp_marche"].values)
145 + assert tr["rbp_T"]["tal_seul"] == pytest.approx(217_886.81, abs=0.01) and tr["rbp_T"]["base_rot15"] == pytest.approx(251_101.39, abs=0.01)
146 + assert tr["rbp_T"]["rot_schl_094"] == pytest.approx(247_211.89, abs=0.01) and tr["rbp_T"]["marche"] == pytest.approx(253_547.97, abs=0.01)
147 + assert tr["cagr"]["base_rot15"] == pytest.approx(0.04738, abs=5e-6) and tr["cagr"]["rot_schl_094"] == pytest.approx(0.04557, abs=5e-6)
148 + assert t.loc[10, "ecart_base_pct"] == pytest.approx(0.96, abs=0.005) and t.loc[10, "ecart_tal_pct"] == pytest.approx(14.06, abs=0.005)
149 + assert t.loc[10, "ecart_rot094_pct"] == pytest.approx(2.50, abs=0.005) and t.loc[2, "base_rot15"] == pytest.approx(177_389.21, abs=0.01)
150 + assert tr["valeur_dcf"]["base_rot15"] == pytest.approx(2_208_565.91, abs=0.01) # = ch. 30/47
151 + assert tr["valeur_dcf"]["tal_seul"] == pytest.approx(1_756_414.23, abs=0.01) and tr["valeur_dcf"]["rot_schl_094"] == pytest.approx(2_140_916.43, abs=0.01)
152 + assert tr["somme_rbp_1_T"]["base_rot15"] == pytest.approx(2_113_053.32, abs=0.01)
153 +
154 +
155 +def test_mini_projection():
156 + mp = c.mini_projection([900, 950, 1000, 1100], 1200, 0.031, 0.035, 1, 4)
157 + assert mp.loc[0, "rbp"] == pytest.approx(47_400) and mp.loc[0, "rbp_marche"] == pytest.approx(57_600)
158 + assert mp.loc[1, "rbp"] == pytest.approx(52_638.60, abs=0.01) # 900 → 1 242 ; autres × 1,031
159 + assert mp.loc[2, "rbp"] == pytest.approx(57_578.28, abs=0.01) and mp.loc[3, "rbp"] == pytest.approx(62_177.79, abs=0.01)
160 + assert (mp["rbp"] <= mp["rbp_marche"] + 1e-9).all()
161 +
162 +
163 +# ----------------------------------------------------------------------------- exercices (solutions/ch27.tex)
164 +def test_exercices(res):
165 + ex = res["exercices"]
166 + assert ex["1"]["rbp_c"] == 78_360 and ex["1"]["rbp_m"] == 87_000 and ex["1"]["deficit"] == 8_640 and ex["1"]["deficit_pct"] == pytest.approx(0.0993, abs=5e-5)
167 + assert ex["2"]["rbe_livre"] == pytest.approx(80_025.60, abs=0.01) and ex["2"]["rbe_v_sur_tout"] == pytest.approx(79_833.60, abs=0.01)
168 + assert ex["2"]["rbe_autres_demi"] == pytest.approx(79_929.60, abs=0.01) and ex["2"]["vacance"] == pytest.approx(3_134.40, abs=0.01)
169 + assert ex["3"]["vf_1"] == pytest.approx(0.010833, abs=1e-6) and ex["3"]["vf_15"] == pytest.approx(0.01625, abs=1e-9)
170 + assert ex["3"]["v_1"] == pytest.approx(0.015833, abs=1e-6) and ex["3"]["v_15"] == pytest.approx(0.02125, abs=1e-9) and ex["3"]["duree_implicite_schl"] == pytest.approx(2.215, abs=5e-4)
171 + assert ex["4"]["L_eff_1_12"] == pytest.approx(1_191.67, abs=0.01) and ex["4"]["rbe_correct"] == pytest.approx(14_300) and ex["4"]["double_compte"] == pytest.approx(13_108.33, abs=0.01)
172 + assert ex["5"]["rbe"] == pytest.approx(40_577.04, abs=0.01) and ex["5"]["rbe_variante_occupant"] == pytest.approx(40_953.96, abs=0.01)
173 + assert ex["6"]["rbp"] == pytest.approx([47_400, 52_638.60, 57_578.28, 62_177.79], abs=0.01)
174 + assert ex["6"]["tal_seul"][3] == pytest.approx(51_946.27, abs=0.01) and ex["6"]["ecart_pct"][3] == pytest.approx(0.0264, abs=5e-5)
175 + assert ex["7"]["V_029"] == pytest.approx(2_073_497.99, abs=0.01) and ex["7"]["V_059"] == pytest.approx(1_965_512.67, abs=0.01)
176 + assert ex["7"]["rne_029"] == pytest.approx(91_544.94, abs=0.01) and ex["7"]["rne_059"] == pytest.approx(86_777.38, abs=0.01)
177 + assert ex["7"]["approx_lineaire_2pts"] == pytest.approx(ex["7"]["V_059"] - ex["7"]["V_039"], abs=0.01) # linéaire en v
178 + assert ex["8"]["ratio_tous"] == pytest.approx(1.2337, abs=5e-5) and ex["8"]["ratio_2ch"] == pytest.approx(1.3454, abs=5e-5) and ex["8"]["q4_sur_q1"] == pytest.approx(3.77, abs=0.005)
179 +
180 +
181 +def test_figures_presentes():
182 + for slug in ("inoccupation-rmr", "cascade", "trajectoires", "sensibilite"):
183 + assert (ROOT / f"assets/figures/ch27/ch27-fig-{slug}.pdf").exists(), slug
added analysis/python/tests/test_ch32.py +132 −0
@@ -0,0 +1,132 @@
1 +"""Tests du chapitre 32 — Duplex à quintuplex : identités, cohérence avec traite.cas2 (partagé avec les ch. 26, 27, 46),
2 +valeurs citées dans le texte (tolérances déclarées), règles de financement vérifiées, exercices, présence des figures."""
3 +import pathlib, sys
4 +import numpy as np, pytest
5 +
6 +ROOT = pathlib.Path(__file__).resolve().parents[3]
7 +sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8 +import ch32_plex as C # noqa: E402
9 +from traite import cas2, finance as fin # noqa: E402
10 +
11 +
12 +@pytest.fixture(scope="module")
13 +def R():
14 + return C.calculer()
15 +
16 +
17 +# ----------------------------------------------------------------------------- cohérence avec le module partagé
18 +def test_coherence_cas2_partage(R):
19 + r = cas2.tout(); b = R["base"]
20 + assert b["rbp_contrat"] == pytest.approx(41832.0) and b["rbp_marche"] == pytest.approx(49332.0)
21 + assert b["rne"] == pytest.approx(22033.9584, abs=0.01) and b["ratio_depenses"] == pytest.approx(0.457, abs=0.001)
22 + assert R["indicateurs"] == r["indicateurs"]
23 + assert R["indications"]["v_mrb_127"] == pytest.approx(r["valeur_mrb"], abs=0.01)
24 + assert R["indications"]["v_cap_127"] == pytest.approx(r["valeur_capitalisation"], abs=0.01)
25 + assert R["indications"]["v_log_127"] == pytest.approx(r["valeur_par_logement"], abs=0.01)
26 +
27 +
28 +def test_valeurs_citees_indicateurs(R):
29 + ind = R["indicateurs"]
30 + assert ind["n"] == 127 and ind["n_triplex"] == 67
31 + assert ind["mrb_median_triplex"] == pytest.approx(19.82, abs=0.005) and ind["taux_median"] == pytest.approx(0.0317, abs=5e-5)
32 + assert ind["prix_par_logement_triplex"] == pytest.approx(281000, abs=1)
33 + assert R["indications"]["v_mrb_127"] == pytest.approx(829110.24, abs=1)
34 + assert R["indications"]["v_cap_127"] == pytest.approx(695077.55, abs=1)
35 + assert R["indications"]["v_log_127"] == pytest.approx(843000, abs=1)
36 + pt = R["par_taille"]; assert pt.loc[2, "n"] == 60 and pt.loc[3, "n"] == 67
37 + assert pt.loc[3, "prix_median"] == pytest.approx(843000, abs=1) and pt.loc[2, "prix_median"] == pytest.approx(583500, abs=1)
38 +
39 +
40 +# ----------------------------------------------------------------------------- identités
41 +def test_identite_mrb_taux(R):
42 + """R = (1 − v)(1 − d)/MRB pour chaque vente du jeu (v = 2,4 %, d = ratio de la vente), aux arrondis du fichier près."""
43 + g = R["decomposition"]
44 + lhs = g["taux_global"]; rhs = (1 - 0.024) * (1 - g["ratio_depenses"]) / g["mrb"]
45 + assert np.max(np.abs(lhs - rhs)) < 2e-4
46 +
47 +
48 +def test_decomposition_rho(R):
49 + """R_c = ρ R_m lorsque les dépenses sont proportionnelles au revenu (jeu synthétique)."""
50 + g = R["decomposition"]
51 + assert np.max(np.abs(g["taux_contrat_reconstitue"] - g["taux_global"])) < 2e-4
52 + assert g["taux_marche"].median() > g["taux_global"].median()
53 + assert R["regression"]["b_rho"] > 0 and 0.03 < R["regression"]["a"] < 0.04
54 +
55 +
56 +def test_valeurs_citees_regression_et_indications(R):
57 + reg = R["regression"]; I = R["indications"]
58 + assert reg["a"] == pytest.approx(0.0345, abs=1e-4) and reg["b_rho"] == pytest.approx(0.0284, abs=1e-4)
59 + assert I["rho_sujet"] == pytest.approx(0.848, abs=5e-4)
60 + assert I["taux_ajuste"] == pytest.approx(0.0302, abs=1e-4)
61 + assert I["v_cap_ajuste"] == pytest.approx(729549, abs=1)
62 + assert I["v_log_comp"] == pytest.approx(918000, abs=1) and I["v_mrb_comp"] == pytest.approx(922396, abs=1)
63 + assert I["v_cap_comp"] == pytest.approx(741884, abs=1)
64 + assert I["valeur_ponderee"] == pytest.approx(872426, abs=1) and I["valeur_arrondie"] == 870000
65 + assert I["valeur_ponderee"] == pytest.approx(sum(C.POIDS_INDICATIONS[k] * v for k, v in I["retenues"].items()))
66 + assert abs(sum(C.POIDS_INDICATIONS.values()) - 1) < 1e-12
67 + assert I["v_cap_ratio_ventes"] == pytest.approx(863880, abs=1) and I["v_log_127_indexe"] == pytest.approx(900926, abs=1)
68 +
69 +
70 +def test_comparables_selection(R):
71 + comp5 = R["comparables5"]
72 + assert len(comp5) == 5 and set(comp5["id_vente"]) == {"Q0187", "Q0222", "Q0243", "Q0207", "Q0235"}
73 + assert (comp5["quartier"] == "Limoilou").all() and (comp5["nb_logements"] == 3).all()
74 + assert comp5["distance"].is_monotonic_increasing and len(R["candidats"]) == 28
75 + assert comp5["prix_par_logement"].median() == pytest.approx(306000, abs=1) and comp5["mrb"].median() == pytest.approx(22.05, abs=0.005)
76 +
77 +
78 +def test_prime_occupant(R):
79 + p = R["prime"]; I = R["indications"]
80 + assert p["v_investisseur_contrat"] + p["v_rattrapage"] + p["prime"] == pytest.approx(I["valeur_ponderee"])
81 + assert p["v_investisseur_contrat"] == pytest.approx(440679.17, abs=0.01) and p["prime_pct"] == pytest.approx(0.335, abs=0.001)
82 +
83 +
84 +# ----------------------------------------------------------------------------- financement (règles vérifiées, notes-C §4.3, §4.2)
85 +def test_mise_de_fonds_minimale():
86 + assert C.mise_de_fonds_minimale_assuree(450_000) == pytest.approx(22_500)
87 + assert C.mise_de_fonds_minimale_assuree(843_000) == pytest.approx(59_300)
88 + assert C.mise_de_fonds_minimale_assuree(870_000) == pytest.approx(62_000)
89 + assert C.mise_de_fonds_minimale_assuree(1_500_000) == pytest.approx(125_000)
90 + assert np.isnan(C.mise_de_fonds_minimale_assuree(1_600_000))
91 +
92 +
93 +def test_financement_occupant(R):
94 + f = R["financement"]
95 + assert f["pret"] == pytest.approx(808_000) and f["rpv"] == pytest.approx(0.9287, abs=1e-4)
96 + assert f["paiement_mensuel"] == pytest.approx(4302.97, abs=0.01) and f["service_dette"] == pytest.approx(51635.62, abs=0.01)
97 + # composition semestrielle : taux mensuel équivalent (1 + 0,0412/2)^(1/6) − 1
98 + i_m = (1 + 0.0412 / 2) ** (1 / 6) - 1
99 + assert f["paiement_mensuel"] == pytest.approx(808_000 * i_m / (1 - (1 + i_m) ** -300), abs=0.01)
100 + assert f["taux_admissible"] == pytest.approx(0.0612) and f["paiement_mensuel_tam"] == pytest.approx(5227.41, abs=0.01)
101 + h = fin.Hypotheque(f["pret"], f["taux"], f["amort_mois"]); assert h.solde(300) == pytest.approx(0.0, abs=1e-6)
102 + o = R["occupation"]
103 + assert o["cout_net_mensuel"] == pytest.approx(3532.11, abs=0.01) and o["cout_net_mensuel_hors_capital"] == pytest.approx(1813.08, abs=0.01)
104 + assert o["cout_net_annuel"] == pytest.approx(o["sortie_brute"] - o["loyers_percus_nets"])
105 +
106 +
107 +def test_sensibilite(R):
108 + S = R["sensibilite"]; L = S["loyers"]
109 + assert L["v_cap_ajuste"].is_monotonic_increasing and L["v_mrb"].is_monotonic_increasing
110 + assert L.loc[L["rho"] == 1.0, "rne"].item() == pytest.approx(R["rne_marche"], abs=0.01)
111 + F = S["financement"]
112 + assert F["cout_net_mensuel"].iloc[4] > F["cout_net_mensuel"].iloc[0] # +100 pb coûte plus cher
113 + assert F["service_dette"].iloc[1] < F["service_dette"].iloc[0] # 30 ans < 25 ans
114 + T = S["taux"]; assert all(T["valeur"] == pytest.approx(R["base"]["rne"] / T["taux"]))
115 +
116 +
117 +# ----------------------------------------------------------------------------- exercices (solutions/ch32.tex)
118 +def test_exercices():
119 + E = C.exercices()
120 + assert E["1"]["mrb"] == pytest.approx(18.75) and E["1"]["taux"] == pytest.approx(E["1"]["identite"])
121 + assert E["1"]["rne"] == pytest.approx(18763.68)
122 + assert E["2"]["843000.0"]["mise"] == pytest.approx(59300) and E["2"]["450000.0"]["mise"] == pytest.approx(22500)
123 + e3 = E["3"]; assert e3["R_m"] - e3["R_c"] == pytest.approx(e3["terme"]) and e3["ecart_pb"] == pytest.approx(105.36, abs=0.01)
124 + assert E["4"]["2e"]["cout_net_mensuel"] > E["4"]["RC"]["cout_net_mensuel"]
125 + assert E["6"]["ponderations"]["comparaison seule (50/50/0)"] == pytest.approx(920197.8, abs=0.1)
126 + assert E["6"]["ponderations"]["revenu dominant (20/20/60)"] == pytest.approx(805809, abs=1)
127 + assert E["7"]["va_1an"] == pytest.approx(4335.85, abs=0.01) and E["7"]["va_10ans"] == pytest.approx(40791.47, abs=0.01)
128 +
129 +
130 +def test_figures_presentes():
131 + for slug in ("mrb-taille-quartier", "taux-loyer-relatif", "prix-logement-quartier", "plex-apciq-rmr", "sensibilite-occupant"):
132 + assert (ROOT / f"assets/figures/ch32/ch32-fig-{slug}.pdf").exists(), slug
modified assets/figures/ch25/ch25-fig-carte-terrains.pdf +0 −0

Binary file not shown.

modified assets/figures/ch25/ch25-fig-lotissement.pdf +0 −0

Binary file not shown.

modified assets/figures/ch25/ch25-fig-residuelle-sensibilite.pdf +0 −0

Binary file not shown.

modified assets/figures/ch25/ch25-fig-valeur-unitaire-superficie.pdf +0 −0

Binary file not shown.

modified assets/figures/ch26/ch26-fig-anciennete.pdf +0 −0

Binary file not shown.

modified assets/figures/ch26/ch26-fig-contrat-marche.pdf +0 −0

Binary file not shown.

modified assets/figures/ch26/ch26-fig-normalisation.pdf +0 −0

Binary file not shown.

modified assets/figures/ch26/ch26-fig-va-ecart.pdf +0 −0

Binary file not shown.

modified assets/figures/ch27/ch27-fig-cascade.pdf +0 −0

Binary file not shown.

modified assets/figures/ch27/ch27-fig-inoccupation-rmr.pdf +0 −0

Binary file not shown.

modified assets/figures/ch27/ch27-fig-sensibilite.pdf +0 −0

Binary file not shown.

modified assets/figures/ch27/ch27-fig-trajectoires.pdf +0 −0

Binary file not shown.

added assets/figures/ch28/ch28-fig-cascade.pdf +0 −0

Binary file not shown.

added assets/figures/ch28/ch28-fig-evolution.pdf +0 −0

Binary file not shown.

added assets/figures/ch28/ch28-fig-ratio-loyer.pdf +0 −0

Binary file not shown.

added assets/figures/ch28/ch28-fig-sensibilite.pdf +0 −0

Binary file not shown.

added assets/figures/ch28/ch28-fig-structure.pdf +0 −0

Binary file not shown.

added assets/figures/ch32/ch32-fig-mrb-taille-quartier.pdf +0 −0

Binary file not shown.

added assets/figures/ch32/ch32-fig-plex-apciq-rmr.pdf +0 −0

Binary file not shown.

added assets/figures/ch32/ch32-fig-prix-logement-quartier.pdf +0 −0

Binary file not shown.

added assets/figures/ch32/ch32-fig-sensibilite-occupant.pdf +0 −0

Binary file not shown.

added assets/figures/ch32/ch32-fig-taux-loyer-relatif.pdf +0 −0

Binary file not shown.

modified data/synthetic/README.md +3 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added manuscript/_wrap_ch19.tex +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch11.tex +2 −2
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch13.tex +587 −1
Large diff (589 lines) is not loaded by default to keep the page fast.Load diff
modified manuscript/chapters/ch19.tex +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added references/lib-ch19.bib +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified solutions/ch19.tex +40 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
modified verification/calculations.csv +14 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch11.md +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch19-calculations.csv +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch19-claims.csv +11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch19-figures.csv +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added verification/chapters/ch19-registry.csv +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
modified verification/claims.csv +10 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
modified verification/figures.csv +8 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme