SPB Git

Phase 3 : PDF pilote (338 p.), rapport de phase, correction page titre

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

48 changed files +2,033 −43

48 changed files
Unified Split
modified analysis/python/cases/ch03_cycles.py +50 −26
@@ -145,12 +145,27 @@ def elasticites_4q(p: Parametres4Q) -> dict:
145 class ParametresCycle: 145 class ParametresCycle:
146 p4q: Parametres4Q = Parametres4Q() 146 p4q: Parametres4Q = Parametres4Q()
147 v_nat: float = 0.03 # taux d'inoccupation naturel (SCHL RMR Québec oct. 2025 : 2,4 % ; Montréal 2,9 %) 147 v_nat: float = 0.03 # taux d'inoccupation naturel (SCHL RMR Québec oct. 2025 : 2,4 % ; Montréal 2,9 %)
148− lam: float = 0.5 # vitesse d'ajustement du loyer : ΔR/R = −λ (v − v*) par trimestre 148+ lam: float = 0.75 # vitesse d'ajustement du loyer : ΔR/R = −λ (v − v*) par trimestre
149 L: int = 6 # délai de livraison (trimestres) entre la mise en chantier et l'ajout au stock 149 L: int = 6 # délai de livraison (trimestres) entre la mise en chantier et l'ajout au stock
150− T: int = 80 # horizon (trimestres) 150+ T: int = 100 # horizon (trimestres)
151− t_choc: int = 8 # trimestre du choc 151+ t_choc: int = 8 # trimestre où le choc commence
152− choc_demande: float = 0.10 # hausse permanente de l'ordonnée de demande (+10 %) 152+ n_choc: int = 12 # durée de montée du choc (trimestres) : la demande croît linéairement puis se stabilise
153− mult_c1: float = 1.0 # multiplicateur de la pente d'offre c1 (1 = référence ; >1 = offre moins élastique) 153+ choc_demande: float = 0.06 # hausse permanente de l'ordonnée de demande au terme de la montée (+6 %)
154+ mult_c1: float = 0.25 # multiplicateur de la pente d'offre c1 (0,25 : offre réactive, κ = 0,48 ; 1 : κ = 0,12)
155+
156+ @property
157+ def c1(self) -> float:
158+ return self.p4q.c1 * self.mult_c1
159+
160+ @property
161+ def c0(self) -> float:
162+ """Seuil de coût recalibré pour que l'état initial (S0, R0, P0, C0 = δ S0) soit stationnaire quelle que soit la pente c1."""
163+ return self.p4q.P0 - self.c1 * self.p4q.C0
164+
165+ @property
166+ def kappa(self) -> float:
167+ """Réactivité annuelle de l'offre à l'écart de stock : κ = 1/(b i c1)."""
168+ return 1.0 / (self.p4q.b * self.p4q.i * self.c1)
154 169
155 170
156 def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame: 171 def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame:
@@ -158,18 +173,19 @@ def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame:
158 prix P = R/i ; mises en chantier C = max(0, (P − c0)/c1)/4 ; stock S_{t+1} = (1 − δ/4) S_t + C_{t−L}. 173 prix P = R/i ; mises en chantier C = max(0, (P − c0)/c1)/4 ; stock S_{t+1} = (1 − δ/4) S_t + C_{t−L}.
159 L'état initial est stationnaire avec v = v* (l'ordonnée a est ajustée pour que D0 = (1 − v*) S0).""" 174 L'état initial est stationnaire avec v = v* (l'ordonnée a est ajustée pour que D0 = (1 − v*) S0)."""
160 p = pc.p4q 175 p = pc.p4q
161− c1 = p.c1 * pc.mult_c1 176+ c1, c0 = pc.c1, pc.c0
162 b = p.b 177 b = p.b
163 a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0 178 a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0
164 S, R = p.S0, p.R0 179 S, R = p.S0, p.R0
165 starts = [p.C0 / 4.0] * (pc.L + 1) # chantiers en cours au régime stationnaire 180 starts = [p.C0 / 4.0] * (pc.L + 1) # chantiers en cours au régime stationnaire
166 rows = [] 181 rows = []
167 for t in range(pc.T + 1): 182 for t in range(pc.T + 1):
168− a = a0 * (1.0 + pc.choc_demande) if t >= pc.t_choc else a0 183+ montee = min(1.0, max(0.0, (t - pc.t_choc + 1) / pc.n_choc)) if pc.n_choc > 0 else float(t >= pc.t_choc)
184+ a = a0 * (1.0 + pc.choc_demande * montee)
169 D = min(S, a - b * R) 185 D = min(S, a - b * R)
170 v = 1.0 - D / S 186 v = 1.0 - D / S
171 P = fin.capitalisation_directe(R, p.i) 187 P = fin.capitalisation_directe(R, p.i)
172− C = max(0.0, (P - p.c0) / c1) / 4.0 188+ C = max(0.0, (P - c0) / c1) / 4.0
173 rows.append({"t": t, "annee": t / 4.0, "S": S, "D": D, "v": v, "R": R, "P": P, "C": C, "a": a}) 189 rows.append({"t": t, "annee": t / 4.0, "S": S, "D": D, "v": v, "R": R, "P": P, "C": C, "a": a})
174 starts.append(C) 190 starts.append(C)
175 livraison = starts[-(pc.L + 1)] 191 livraison = starts[-(pc.L + 1)]
@@ -188,9 +204,9 @@ def etat_stationnaire_cycle(pc: ParametresCycle, apres_choc: bool = True) -> dic
188 a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0 204 a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0
189 a = a0 * (1.0 + pc.choc_demande) if apres_choc else a0 205 a = a0 * (1.0 + pc.choc_demande) if apres_choc else a0
190 # (1−v*) S = a − bR ; R = i P ; P = c0 + c1 δ S → (1−v*) S = a − b i (c0 + c1 δ S) 206 # (1−v*) S = a − bR ; R = i P ; P = c0 + c1 δ S → (1−v*) S = a − b i (c0 + c1 δ S)
191− c1 = p.c1 * pc.mult_c1 207+ c1, c0 = pc.c1, pc.c0
192− S = (a - b * p.i * p.c0) / ((1.0 - pc.v_nat) + b * p.i * c1 * p.delta) 208+ S = (a - b * p.i * c0) / ((1.0 - pc.v_nat) + b * p.i * c1 * p.delta)
193− P = p.c0 + c1 * p.delta * S 209+ P = c0 + c1 * p.delta * S
194 R = p.i * P 210 R = p.i * P
195 return {"S": S, "R": R, "P": P, "C": p.delta * S, "v": pc.v_nat} 211 return {"S": S, "R": R, "P": P, "C": p.delta * S, "v": pc.v_nat}
196 212
@@ -215,13 +231,13 @@ def resume_cycle(df: pd.DataFrame, pc: ParametresCycle) -> dict:
215 "amortissement": (post2.max() / R_rel.max()) if periode == periode else float("nan")} 231 "amortissement": (post2.max() / R_rel.max()) if periode == periode else float("nan")}
216 232
217 233
218−def sensibilite_cycle(pc: ParametresCycle, delais=(2, 4, 6, 8, 12), mult_c1=(0.5, 1.0, 2.0), lams=(0.25, 0.5, 1.0)) -> pd.DataFrame: 234+def sensibilite_cycle(pc: ParametresCycle, delais=(2, 4, 6, 8, 12), mult_c1=(0.25, 0.5, 1.0), lams=(0.5, 0.75, 1.5)) -> pd.DataFrame:
219 rows = [] 235 rows = []
220 for L in delais: 236 for L in delais:
221 for m in mult_c1: 237 for m in mult_c1:
222 for lam in lams: 238 for lam in lams:
223 r = resume_cycle(simuler_cycle(replace(pc, L=L, mult_c1=m, lam=lam)), replace(pc, L=L, mult_c1=m, lam=lam)) 239 r = resume_cycle(simuler_cycle(replace(pc, L=L, mult_c1=m, lam=lam)), replace(pc, L=L, mult_c1=m, lam=lam))
224− rows.append({"L": L, "mult_c1": m, "lam": lam, **r}) 240+ rows.append({"L": L, "mult_c1": m, "lam": lam, "kappa": replace(pc, mult_c1=m).kappa, **r})
225 return pd.DataFrame(rows) 241 return pd.DataFrame(rows)
226 242
227 243
@@ -287,10 +303,12 @@ def mises_en_chantier_annuelles(df: pd.DataFrame | None = None) -> pd.DataFrame:
287 return piv 303 return piv
288 304
289 305
290−def mises_en_chantier_montreal_annuelles() -> pd.Series: 306+def mises_en_chantier_montreal_annuelles(geo: str = "Montréal, Québec") -> pd.DataFrame:
291− """34-10-0143-01 : RMR de Montréal, mensuel, total d'unités, sommes annuelles.""" 307+ """34-10-0143-01 : RMR de Montréal (centres de 10 000 hab. et plus), mensuel, total d'unités, sommes annuelles.
308+ La colonne « mois » compte les mois disponibles : dans le fichier du 2026-10-31, juillet à septembre 2025 sont ABSENTS
309+ pour Montréal, de sorte que la somme 2025 (21 138) ne couvre que 9 mois et n'est pas un total annuel."""
292 rows = _lire_statcan(RAW / "statcan_34100143/34100143.csv", 310 rows = _lire_statcan(RAW / "statcan_34100143/34100143.csv",
293− lambda r: r["GÉO"] == "Montréal, Québec" and r["Estimations de logements"] == "Logements mis en chantier" 311+ lambda r: r["GÉO"] == geo and r["Estimations de logements"] == "Logements mis en chantier"
294 and r["Type d'unité"] == "Total d'unités") 312 and r["Type d'unité"] == "Total d'unités")
295 s = pd.Series({r["PÉRIODE DE RÉFÉRENCE"]: float(r["VALEUR"]) for r in rows}) 313 s = pd.Series({r["PÉRIODE DE RÉFÉRENCE"]: float(r["VALEUR"]) for r in rows})
296 s.index = pd.to_datetime(s.index) 314 s.index = pd.to_datetime(s.index)
@@ -337,11 +355,13 @@ def taux_effectifs_a4(mois: str = "2026-08-01") -> dict:
337 d = dict(zip(hdr, rec)) 355 d = dict(zip(hdr, rec))
338 # libellés des séries (bloc SERIES du fichier) 356 # libellés des séries (bloc SERIES du fichier)
339 labels = {r[0]: r[1] for r in rows[:i] if len(r) >= 2 and r[0].startswith("V1226677")} 357 labels = {r[0]: r[1] for r in rows[:i] if len(r) >= 2 and r[0].startswith("V1226677")}
340− assure = [k for k, v in labels.items() if "Insured" in v and "Uninsured" not in v and "5 years" in v and "Funds advanced" in v] 358+ desc = {r[0]: (r[1], r[2]) for r in rows[:i] if len(r) >= 3 and r[0].startswith("V1226677")}
341− non_assure = [k for k, v in labels.items() if "Uninsured" in v and "5 years" in v and "Funds advanced" in v] 359+ def _cle(assure: bool):
342− return {"mois": mois, "assure_fixe_5ans_plus": float(d[assure[0]]) if assure else float("nan"), 360+ cible = "Residential mortgages, insured" if assure else "Residential mortgages, uninsured"
343− "non_assure_fixe_5ans_plus": float(d[non_assure[0]]) if non_assure else float("nan"), 361+ return [k for k, (lab, de) in desc.items() if lab == "Fixed rate, 5 years and over" and de.startswith("Mortgages - Funds advanced") and cible in de]
344− "labels_assure": [labels[k] for k in assure], "labels_non_assure": [labels[k] for k in non_assure]} 362+ ka, kn = _cle(True), _cle(False)
363+ return {"mois": mois, "assure_fixe_5ans_plus": float(d[ka[0]]), "non_assure_fixe_5ans_plus": float(d[kn[0]]),
364+ "serie_assure": ka[0], "serie_non_assure": kn[0]}
345 365
346 366
347 def ipc_quebec(produit: str = "Ensemble") -> pd.Series: 367 def ipc_quebec(produit: str = "Ensemble") -> pd.Series:
@@ -480,22 +500,26 @@ def main() -> None:
480 print("\n=== 2. Dynamique stock-flux (SYNTHÉTIQUE) ===") 500 print("\n=== 2. Dynamique stock-flux (SYNTHÉTIQUE) ===")
481 pc = ParametresCycle() 501 pc = ParametresCycle()
482 df = simuler_cycle(pc); ss = etat_stationnaire_cycle(pc); r = resume_cycle(df, pc) 502 df = simuler_cycle(pc); ss = etat_stationnaire_cycle(pc); r = resume_cycle(df, pc)
503+ print(f"kappa (référence dynamique) = {pc.kappa:.3f} ; c1 = {pc.c1:.1f} ; c0 recalibré = {_f(pc.c0)}")
504+ ss0 = etat_stationnaire_cycle(pc, apres_choc=False); print(f"Stationnaire initial : S={_f(ss0['S'])} R={_f(ss0['R'])} P={_f(ss0['P'])}")
483 print(f"Stationnaire après choc : S={_f(ss['S'])} R={_f(ss['R'])} ({ss['R']/12:.0f} $/mois) P={_f(ss['P'])} C={_f(ss['C'])}/an") 505 print(f"Stationnaire après choc : S={_f(ss['S'])} R={_f(ss['R'])} ({ss['R']/12:.0f} $/mois) P={_f(ss['P'])} C={_f(ss['C'])}/an")
484 print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in r.items()}) 506 print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in r.items()})
485− for L in (1, 2, 4): 507+ for kap in (el["kappa"], pc.kappa):
486− rc = racines_caracteristiques(el["kappa"], p.delta, L) 508+ for L in (1, 2, 3):
487− print(f"Racines (kappa={el['kappa']:.3f}, L={L}) : module={rc['module']:.3f} oscille={rc['oscille']} période={rc['periode']:.1f} ans stable={rc['stable']}") 509+ rc = racines_caracteristiques(kap, p.delta, L)
510+ print(f"Racines (kappa={kap:.3f}, L={L}) : module={rc['module']:.3f} oscille={rc['oscille']} période={rc['periode']:.1f} ans stable={rc['stable']}")
488 for k in (0.12, 0.30, 0.60, 1.20): 511 for k in (0.12, 0.30, 0.60, 1.20):
489 c = condition_oscillation_L1(k, p.delta) 512 c = condition_oscillation_L1(k, p.delta)
490 print(f"L=1, kappa={k}: seuil={c['seuil']:.4f} oscille={c['oscille']} module={c['module']:.3f} période={c['periode']:.2f} stable={c['stable']}") 513 print(f"L=1, kappa={k}: seuil={c['seuil']:.4f} oscille={c['oscille']} module={c['module']:.3f} période={c['periode']:.2f} stable={c['stable']}")
491 sens = sensibilite_cycle(pc) 514 sens = sensibilite_cycle(pc)
492− print(sens[(sens["lam"] == 0.5)][["L", "mult_c1", "R_pointe_pct", "R_creux_pct", "v_max_pct", "C_max_annuel", "periode_trim", "amortissement"]].to_string(index=False)) 515+ print(sens[(sens["lam"] == 0.75)][["L", "mult_c1", "kappa", "R_pointe_pct", "R_creux_pct", "v_max_pct", "C_max_annuel", "periode_trim", "amortissement"]].to_string(index=False))
493 516
494 print("\n=== 3. Données réelles ===") 517 print("\n=== 3. Données réelles ===")
495 ann = mises_en_chantier_annuelles() 518 ann = mises_en_chantier_annuelles()
496 cols = ["total", "individuelles", "collectifs", "part_individuelles", "part_collectifs", "trimestres"] 519 cols = ["total", "individuelles", "collectifs", "part_individuelles", "part_collectifs", "trimestres"]
497 print(ann.loc[[1990, 2000, 2005, 2010, 2015, 2020, 2021, 2022, 2023, 2024, 2025, 2026], cols]) 520 print(ann.loc[[1990, 2000, 2005, 2010, 2015, 2020, 2021, 2022, 2023, 2024, 2025, 2026], cols])
498− mtl = mises_en_chantier_montreal_annuelles(); print("Montréal RMR :", mtl.loc[[2024, 2025, 2026]].to_dict()) 521+ mtl = mises_en_chantier_montreal_annuelles(); print("Montréal RMR (34-10-0143) :", mtl.loc[[2024, 2025, 2026]].to_dict())
522+ qc143 = mises_en_chantier_montreal_annuelles("Québec"); print("Québec centres 10 000+ (34-10-0143) :", qc143.loc[[2024, 2025, 2026]].to_dict())
499 ch = changements_taux_directeur(); print(ch) 523 ch = changements_taux_directeur(); print(ch)
500 print(taux_effectifs_a4()) 524 print(taux_effectifs_a4())
501 cu = serie_cout_usage() 525 cu = serie_cout_usage()
added analysis/python/cases/ch05_rente.py +328 −0
@@ -0,0 +1,328 @@
1+#!/usr/bin/env python3
2+"""Chapitre 5 — Économie urbaine et rente foncière : calculs de l'exemple intégral et des encadrés.
3+
4+Imprime tous les chiffres cités dans le chapitre 5 :
5+ * modèle monocentrique (Alonso–Muth–Mills) simulé avec des PARAMÈTRES PÉDAGOGIQUES DÉCLARÉS (section PARAMS) :
6+ gradient du prix du logement p(d) = p0 (1 - t d / y)^(1/beta), gradient exponentiel approché, rente foncière
7+ r(d) = r0 (p(d)/p0)^(1/alpha), densité, frontière urbaine, courbes d'enchère de von Thünen et d'Alonso ;
8+ * gradient prix/distance observé dans le dossier du cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES, graine 20261031) :
9+ tranches de distance, régressions avec et sans indicatrices de secteur (traite.hedonique.mco), coefficient
10+ `dist_centre` du modèle hédonique de référence (traite.cas1.hedonique) ;
11+ * décomposition interrégionale de la valeur par logement du cas 4 (traite.cas4.sommaire) ;
12+ * valeur résiduelle du terrain et effet de levier ;
13+ * capitalisation de la taxe foncière : formule exacte et approchée, taux effectif, taux 2026 de Québec,
14+ Gatineau et Montréal (sources : notes-B/E du dépôt), grille de sensibilité.
15+
16+Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch05_rente.py
17+Les régressions utilisent traite.hedonique.mco ; les données des cas passent par traite.cas1 et traite.cas4."""
18+from __future__ import annotations
19+import sys, pathlib, math
20+import numpy as np, pandas as pd
21+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
22+from traite import cas1, cas4
23+from traite import hedonique as h
24+
25+ROOT = pathlib.Path(__file__).resolve().parents[3]
26+
27+# ----------------------------------------------------------------------------- paramètres pédagogiques (déclarés)
28+PARAMS = {
29+ "y": 80000.0, # revenu annuel du ménage ($/an)
30+ "t": 700.0, # coût annuel de navettage par kilomètre de distance au centre ($/km/an), temps compris
31+ "beta": 0.30, # part du revenu net de transport consacrée au logement
32+ "alpha": 0.30, # part du terrain dans le coût de production du logement (Cobb-Douglas)
33+ "p0": 260.0, # prix annuel du service de logement au centre ($/m² de plancher/an)
34+ "r0": 80.0, # rente foncière annuelle au centre ($/m² de terrain/an)
35+ "ra": 2.0, # rente alternative (agricole, conversion comprise) à la frontière ($/m²/an)
36+ "i": 0.05, # taux de capitalisation des rentes foncières (annuel)
37+}
38+# courbes d'enchère linéaires de trois usages (illustration d'Alonso) : (rente au centre $/m²/an, pente $/m²/an/km)
39+ALONSO = {"Commerces et bureaux": (300.0, 25.0), "Résidentiel dense": (120.0, 6.0), "Résidentiel pavillonnaire": (60.0, 1.8)}
40+# von Thünen (exercice 5.1) : rendement Y (t/ha), prix p ($/t), coût c ($/t), fret f ($/t/km)
41+THUNEN = {"Maraîchage": dict(Y=20.0, p=300.0, c=250.0, f=3.0), "Grandes cultures": dict(Y=4.0, p=500.0, c=350.0, f=5.0)}
42+
43+# taux de taxe foncière 2026 (par 100 $ d'évaluation) — sources : vqtaux2026 ; gatineaureglement994 ; ledevoirmtl2026 (secondaire) ; taxescolaire2026
44+TAUX_2026 = {
45+ "Québec (1 à 5 logements)": 0.7464, "Québec (6 logements et plus)": 0.7697,
46+ "Gatineau (taux de base)": 0.5224, "Montréal (taxe foncière générale, hors arrondissement)": 0.4631,
47+ "Taxe scolaire 2026-2027 (toute la province)": 0.07899,
48+}
49+FACTEUR_2026 = {"Gatineau": 1.02, "Montréal": 1.00} # facteurs comparatifs 2026 publiés (gatmutations ; mtlmutations2026)
50+
51+
52+# ----------------------------------------------------------------------------- von Thünen et Alonso
53+def rente_thunen(d, Y: float, p: float, c: float, f: float) -> np.ndarray:
54+ """R(d) = Y (p - c) - Y f d (rente par hectare et par an)."""
55+ return Y * (p - c) - Y * f * np.asarray(d, float)
56+
57+def distance_limite_thunen(Y: float, p: float, c: float, f: float) -> float:
58+ """Distance où la rente s'annule : d* = (p - c) / f."""
59+ return (p - c) / f
60+
61+def frontiere_usages(R1: float, t1: float, R2: float, t2: float) -> float:
62+ """Distance où deux courbes d'enchère linéaires R1 - t1 d et R2 - t2 d se croisent."""
63+ return (R1 - R2) / (t1 - t2)
64+
65+def enveloppe_alonso(d, usages: dict = ALONSO, ra: float = PARAMS["ra"]) -> tuple[np.ndarray, list]:
66+ d = np.asarray(d, float)
67+ courbes = {k: R0 - t * d for k, (R0, t) in usages.items()}
68+ env = np.maximum.reduce([*courbes.values(), np.full_like(d, ra)])
69+ return env, courbes
70+
71+
72+# ----------------------------------------------------------------------------- modèle monocentrique
73+def prix_logement(d, P: dict = PARAMS) -> np.ndarray:
74+ """p(d) = p0 (1 - t d / y)^(1/beta) : solution exacte de la condition de Muth avec part budgétaire constante."""
75+ d = np.asarray(d, float)
76+ base = np.clip(1.0 - P["t"] * d / P["y"], 1e-12, None)
77+ return P["p0"] * base ** (1.0 / P["beta"])
78+
79+def gradient_exponentiel(P: dict = PARAMS) -> float:
80+ """Pente logarithmique du prix au centre : gamma = t / (beta y) (approximation exponentielle)."""
81+ return P["t"] / (P["beta"] * P["y"])
82+
83+def prix_exponentiel(d, P: dict = PARAMS) -> np.ndarray:
84+ return P["p0"] * np.exp(-gradient_exponentiel(P) * np.asarray(d, float))
85+
86+def consommation_logement(d, P: dict = PARAMS) -> np.ndarray:
87+ """q(d) = beta (y - t d) / p(d) : surface de plancher consommée par ménage (m²)."""
88+ d = np.asarray(d, float)
89+ return P["beta"] * (P["y"] - P["t"] * d) / prix_logement(d, P)
90+
91+def rente_fonciere(d, P: dict = PARAMS) -> np.ndarray:
92+ """r(d) = r0 (p(d)/p0)^(1/alpha) : profit nul des producteurs de logement (Cobb-Douglas)."""
93+ return P["r0"] * (prix_logement(d, P) / P["p0"]) ** (1.0 / P["alpha"])
94+
95+def intensite_capital(d, P: dict = PARAMS) -> np.ndarray:
96+ """Plancher par m² de terrain, normalisé à 1 au centre : h(d)/h(0) = (p(d)/p0)^((1-alpha)/alpha)."""
97+ return (prix_logement(d, P) / P["p0"]) ** ((1.0 - P["alpha"]) / P["alpha"])
98+
99+def densite(d, P: dict = PARAMS) -> np.ndarray:
100+ """Ménages par m² de terrain, normalisée à 1 au centre : D(d) = h(d)/q(d)."""
101+ d = np.asarray(d, float)
102+ D = intensite_capital(d, P) / consommation_logement(d, P)
103+ return D / (intensite_capital(0.0, P) / consommation_logement(0.0, P))
104+
105+def frontiere_urbaine(P: dict = PARAMS) -> float:
106+ """Distance d_bar où r(d_bar) = ra (solution analytique)."""
107+ ratio_p = (P["ra"] / P["r0"]) ** P["alpha"] # p(d_bar)/p0
108+ return (1.0 - ratio_p ** P["beta"]) * P["y"] / P["t"]
109+
110+def demi_distance(gamma: float) -> float:
111+ """Distance qui divise le prix par deux sous un gradient exponentiel : ln 2 / gamma."""
112+ return math.log(2.0) / gamma
113+
114+def valeur_terrain(rente: float, i: float = PARAMS["i"], g: float = 0.0) -> float:
115+ """Valeur du terrain = rente / (i - g) (rente perpétuelle, croissance g)."""
116+ return rente / (i - g)
117+
118+def decomposition_capozza(ra: float, i: float, cout_conversion: float, rente_d: float, prime_croissance: float) -> dict:
119+ """Capozza–Helsley (1989) : P_L = ra/i + C + (r(d) - ra)/i + prime de croissance."""
120+ comp = {"valeur_agricole": ra / i, "cout_conversion": cout_conversion, "accessibilite": (rente_d - ra) / i, "prime_croissance": prime_croissance}
121+ comp["total"] = sum(comp.values()); return comp
122+
123+
124+# ----------------------------------------------------------------------------- rente résiduelle et levier
125+def valeur_residuelle(V: float, CN: float, depreciation: float) -> float:
126+ """V_L = V - (C_N - D) : valeur du terrain par résidu."""
127+ return V - (CN - depreciation)
128+
129+def levier_residuel(V: float, VL: float) -> float:
130+ """Élasticité de V_L par rapport à V : V / V_L."""
131+ return V / VL
132+
133+def elasticite_rente_prix(alpha: float) -> float:
134+ return 1.0 / alpha
135+
136+
137+# ----------------------------------------------------------------------------- capitalisation fiscale
138+def valeur_avec_taxe(R: float, i: float, tau: float) -> float:
139+ """V = R / (i + tau) : rente nette de taxe capitalisée (taxe proportionnelle à la valeur)."""
140+ return R / (i + tau)
141+
142+def capitalisation_taxe(V0: float, tau0: float, tau1: float, i: float, kappa: float = 1.0) -> dict:
143+ """Effet d'un changement de taux de taxe tau0 -> tau1 sur une valeur V0 établie sous tau0.
144+ exact : V1 = R/(i+tau1) avec R = V0 (i+tau0) ; approché : dV = -kappa * dT / i avec dT = (tau1-tau0) V0."""
145+ R = V0 * (i + tau0)
146+ V1 = R / (i + tau1)
147+ dT = (tau1 - tau0) * V0
148+ return {"R": R, "V1_exact": V1, "dV_exact": V1 - V0, "dT": dT, "dV_approx": -kappa * dT / i,
149+ "dV_exact_kappa": kappa * (V1 - V0), "dV_pct_exact": V1 / V0 - 1}
150+
151+def taux_effectif(tau_role: float, facteur: float) -> float:
152+ """Taux sur la valeur marchande : tau_eff = tau_role / F, où F est le facteur comparatif (V_marché = F × V_rôle)."""
153+ return tau_role / facteur
154+
155+def grille_capitalisation(V0: float, d_taux_pb: list, taux_cap: list, kappa: float = 1.0) -> pd.DataFrame:
156+ """Perte de valeur (en $) pour un écart de taux de taxe (points de base de la valeur) selon le taux de capitalisation."""
157+ rows = []
158+ for pb in d_taux_pb:
159+ row = {"ecart_pb": pb}
160+ for r in taux_cap:
161+ row[f"r={r:.0%}"] = -kappa * (pb / 10000.0) * V0 / r
162+ rows.append(row)
163+ return pd.DataFrame(rows).set_index("ecart_pb")
164+
165+
166+# ----------------------------------------------------------------------------- cas 1 : gradient observé
167+TRANCHES = [0, 2, 4, 6, 8, 10, 12, 20, 35]
168+ETIQUETTES = ["0-2", "2-4", "4-6", "6-8", "8-10", "10-12", "12-20", "20 et plus"]
169+
170+def tranches_distance(v: pd.DataFrame) -> pd.DataFrame:
171+ v = v.copy(); v["prix_m2"] = v["prix_vente"] / v["superficie_habitable_m2"]
172+ v["tranche"] = pd.cut(v["dist_centre_km"], TRANCHES, labels=ETIQUETTES, right=True)
173+ g = v.groupby("tranche", observed=True).agg(n=("prix_vente", "size"), dist_mediane=("dist_centre_km", "median"),
174+ prix_median=("prix_vente", "median"), prix_m2_median=("prix_m2", "median"),
175+ superficie_mediane=("superficie_habitable_m2", "median"),
176+ terrain_median=("superficie_terrain_m2", "median"))
177+ g["secteur_dominant"] = v.groupby("tranche", observed=True)["secteur"].agg(lambda s: s.value_counts().index[0])
178+ return g
179+
180+def gradients_cas1(v: pd.DataFrame, modele: dict | None = None) -> dict:
181+ """Quatre spécifications du gradient prix/distance (coefficients en log par km)."""
182+ y = np.log(v["prix_vente"].astype(float).values)
183+ d = v["dist_centre_km"].astype(float).values
184+ out = {}
185+ # (a) brut : ln(P/S) = a - gamma d
186+ r = h.mco(np.column_stack([np.ones_like(d), d]), np.log(v["prix_vente"] / v["superficie_habitable_m2"]).values)
187+ out["brut_prix_m2"] = {"gamma": -r["beta"][1], "se": r["se_hc1"][1], "r2": r["r2"], "n": r["n"]}
188+ # (b) contrôlé : caractéristiques du modèle de référence sans secteurs, distance linéaire
189+ X = cas1.matrice(v).drop(columns=["dist_centre"] + [f"sect_{s}" for s in cas1.SECTEURS[1:]])
190+ X["dist"] = d
191+ r = h.mco(X.values, y)
192+ out["controle_lineaire"] = {"gamma": -r["beta"][-1], "se": r["se_hc1"][-1], "r2": r["r2"], "n": r["n"]}
193+ # (c) contrôlé, quadratique
194+ Xq = X.copy(); Xq["dist2"] = d ** 2 / 100.0
195+ r = h.mco(Xq.values, y)
196+ out["controle_quadratique"] = {"b1": r["beta"][-2], "b2_100": r["beta"][-1], "se1": r["se_hc1"][-2], "se2": r["se_hc1"][-1], "r2": r["r2"],
197+ "gamma_0": -r["beta"][-2], "gamma_10": -(r["beta"][-2] + 2 * r["beta"][-1] * 10 / 100.0)}
198+ # (d) contrôlé, aire urbaine contiguë (d <= 16 km : Hull, Gatineau, Aylmer)
199+ m = d <= 16.0
200+ r = h.mco(X.values[m], y[m])
201+ out["controle_urbain_16km"] = {"gamma": -r["beta"][-1], "se": r["se_hc1"][-1], "r2": r["r2"], "n": int(m.sum())}
202+ # (e) modèle de référence (secteurs + max(d-3, 0))
203+ modele = modele or cas1.hedonique(v)
204+ c = modele["coef"]
205+ out["reference"] = {"gamma_au_dela_3km": -float(c.loc["dist_centre", "coef"]), "se": float(c.loc["dist_centre", "se_hc1"]),
206+ "t": float(c.loc["dist_centre", "t"]), "r2": modele["r2"],
207+ "secteurs": {s: float(c.loc[f"sect_{s}", "coef"]) for s in cas1.SECTEURS[1:]},
208+ "secteurs_pct": {s: float(c.loc[f"sect_{s}", "effet_pct"]) for s in cas1.SECTEURS[1:]}}
209+ # (f) gradient implicite entre secteurs : médianes par secteur vs distance médiane
210+ gs = v.groupby("secteur").agg(dist=("dist_centre_km", "median"), prix=("prix_vente", "median"), n=("prix_vente", "size"))
211+ out["secteurs_medianes"] = gs
212+ return out
213+
214+def gradient_exact_vs_exponentiel(P: dict = PARAMS) -> pd.DataFrame:
215+ d = np.array([0.0, 5.0, 10.0, 15.0, 20.0, 25.0])
216+ pe, pa = prix_logement(d, P), prix_exponentiel(d, P)
217+ return pd.DataFrame({"d_km": d, "p_exact": pe, "p_exponentiel": pa, "ecart_pct": pa / pe - 1, "q_m2": consommation_logement(d, P),
218+ "rente_fonciere": rente_fonciere(d, P), "densite_relative": densite(d, P)})
219+
220+
221+# ----------------------------------------------------------------------------- cas 4 : décomposition interrégionale
222+def decomposition_rmr(pf: pd.DataFrame, reference: str = "Montréal") -> pd.DataFrame:
223+ """ln(V/n) = ln(RNE/n) - ln(R) par RMR, et écarts à la RMR de référence."""
224+ s = cas4.sommaire(pf)["par_rmr"].copy()
225+ s["rne_par_logement"] = s["rne"] / s["logements"]
226+ s["ln_V_n"] = np.log(s["valeur_par_logement"]); s["ln_RNE_n"] = np.log(s["rne_par_logement"]); s["ln_R"] = np.log(s["taux_pondere"])
227+ ref = s.loc[reference]
228+ s["ecart_ln_V_n"] = s["ln_V_n"] - ref["ln_V_n"]
229+ s["contrib_rne"] = s["ln_RNE_n"] - ref["ln_RNE_n"]
230+ s["contrib_taux"] = -(s["ln_R"] - ref["ln_R"])
231+ s["ratio_valeur"] = s["valeur_par_logement"] / ref["valeur_par_logement"]
232+ return s.sort_values("valeur_par_logement", ascending=False)
233+
234+
235+# ----------------------------------------------------------------------------- exemple intégral (impression)
236+def fmt(x, dec=0):
237+ s = f"{x:,.{dec}f}".replace(",", " ").replace(".", ",")
238+ return s
239+
240+def main():
241+ P = PARAMS
242+ print("=== 5.A Modèle monocentrique simulé (paramètres pédagogiques déclarés) ===")
243+ print({k: v for k, v in P.items()})
244+ g = gradient_exponentiel(P); dbar = frontiere_urbaine(P)
245+ print(f"gradient exponentiel au centre gamma = t/(beta y) = {g:.5f} par km ({g*100:.2f} %/km) ; demi-distance ln2/gamma = {demi_distance(g):.1f} km")
246+ print(f"frontière urbaine d_bar (r = ra) = {dbar:.2f} km ; p(d_bar) = {prix_logement(dbar, P):.2f} $/m²/an ; élasticité rente/prix 1/alpha = {elasticite_rente_prix(P['alpha']):.3f}")
247+ print(f"valeur du terrain au centre r0/i = {fmt(valeur_terrain(P['r0'], P['i']))} $/m² ; à la frontière ra/i = {valeur_terrain(P['ra'], P['i']):.0f} $/m²")
248+ tab = gradient_exact_vs_exponentiel(P)
249+ print(tab.round(3).to_string(index=False))
250+ print(f"q(0) = {consommation_logement(0.0, P):.1f} m² ; q(10) = {consommation_logement(10.0, P):.1f} m² ; q(d_bar) = {consommation_logement(dbar, P):.1f} m²")
251+ print("\n--- Alonso : frontières entre usages (km) ---")
252+ (R1, t1), (R2, t2), (R3, t3) = ALONSO.values()
253+ f12, f23 = frontiere_usages(R1, t1, R2, t2), frontiere_usages(R2, t2, R3, t3)
254+ f3a = (R3 - P["ra"]) / t3
255+ print(f"commerces/dense : {f12:.2f} km ; dense/pavillonnaire : {f23:.2f} km ; pavillonnaire/agricole : {f3a:.2f} km")
256+ print("\n--- von Thünen (exercice 5.1) ---")
257+ for k, q in THUNEN.items():
258+ print(f"{k}: R0 = {q['Y']*(q['p']-q['c']):.0f} $/ha/an ; pente = {q['Y']*q['f']:.0f} $/ha/an/km ; distance limite = {distance_limite_thunen(**q):.2f} km")
259+ a, b = THUNEN.values()
260+ print(f"frontière maraîchage/grandes cultures : {frontiere_usages(a['Y']*(a['p']-a['c']), a['Y']*a['f'], b['Y']*(b['p']-b['c']), b['Y']*b['f']):.2f} km")
261+
262+ print("\n=== 5.B Cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES) : gradient prix/distance ===")
263+ d1 = cas1.charger(); v = d1["ventes"]; m = cas1.hedonique(v)
264+ tr = tranches_distance(v)
265+ print(tr.round(2).to_string())
266+ gr = gradients_cas1(v, m)
267+ for k in ["brut_prix_m2", "controle_lineaire", "controle_urbain_16km"]:
268+ r = gr[k]; print(f"{k}: gamma = {r['gamma']*100:.3f} %/km (e.-t. {r['se']*100:.3f}) ; R² = {r['r2']:.3f} ; n = {r['n']} ; demi-distance = {demi_distance(r['gamma']):.1f} km")
269+ q = gr["controle_quadratique"]
270+ print(f"controle_quadratique: b1 = {q['b1']*100:.3f} %/km (e.-t. {q['se1']*100:.3f}), b2/100 = {q['b2_100']*100:.4f} (e.-t. {q['se2']*100:.4f}) ; gamma(0) = {q['gamma_0']*100:.3f}, gamma(10 km) = {q['gamma_10']*100:.3f} %/km ; R² = {q['r2']:.3f}")
271+ rf = gr["reference"]
272+ print(f"reference (traite.cas1.hedonique) : dist_centre = -{rf['gamma_au_dela_3km']*100:.3f} %/km au-delà de 3 km (e.-t. {rf['se']*100:.3f}, t = {rf['t']:.2f}) ; R² = {rf['r2']:.3f}")
273+ print(" secteurs (réf. Hull), coef. et effet % :", {k: (round(v_, 4), round(rf['secteurs_pct'][k] * 100, 1)) for k, v_ in rf["secteurs"].items()})
274+ print(gr["secteurs_medianes"].round(2).to_string())
275+ gs = gr["secteurs_medianes"]
276+ pente = (math.log(gs.loc["Gatineau", "prix"]) - math.log(gs.loc["Hull", "prix"])) / (gs.loc["Gatineau", "dist"] - gs.loc["Hull", "dist"])
277+ print(f"gradient implicite Hull -> Gatineau (médianes) : {pente*100:.3f} %/km ; Hull -> Buckingham : {(math.log(gs.loc['Buckingham','prix'])-math.log(gs.loc['Hull','prix']))/(gs.loc['Buckingham','dist']-gs.loc['Hull','dist'])*100:.3f} %/km")
278+ print(f"sujet du cas 1 : dist_centre = {d1['sujet']['dist_centre_km']} km ; effet distance du modèle de référence = {max(d1['sujet']['dist_centre_km']-3,0)*rf['gamma_au_dela_3km']*100:.2f} %")
279+ # ce que vaut 1 km de plus pour le sujet selon la spécification contrôlée
280+ V_ind = 551029.0 # indication brute du ch. 16
281+ print(f"à partir de l'indication brute du ch. 16 ({fmt(V_ind)} $), 1 km de plus vaut {fmt(-V_ind*gr['controle_lineaire']['gamma'])} $ (spécification contrôlée) ; 5 km : {fmt(V_ind*(math.exp(-5*gr['controle_lineaire']['gamma'])-1))} $")
282+
283+ print("\n=== 5.C Cas 4 (portefeuille, DONNÉES SYNTHÉTIQUES) : valeur par logement par RMR ===")
284+ d4 = cas4.charger(); dec = decomposition_rmr(d4["portefeuille"])
285+ cols = ["n", "logements", "valeur_par_logement", "rne_par_logement", "taux_pondere", "ratio_valeur", "ecart_ln_V_n", "contrib_rne", "contrib_taux"]
286+ print(dec[cols].round(4).to_string())
287+ for rmr in dec.index:
288+ if rmr != "Montréal":
289+ e = dec.loc[rmr]; part = e["contrib_rne"] / e["ecart_ln_V_n"] if e["ecart_ln_V_n"] != 0 else float("nan")
290+ print(f" {rmr}: écart ln = {e['ecart_ln_V_n']:.3f} dont RNE/log. {e['contrib_rne']:.3f} ({part*100:.0f} %) et taux {e['contrib_taux']:.3f} ({(1-part)*100:.0f} %)")
291+
292+ print("\n=== 5.D Rente résiduelle et levier ===")
293+ CN = 397567.33; dep = 0.30 * CN; V = 551029.0
294+ VL = valeur_residuelle(V, CN, dep)
295+ print(f"C_N (ch. 21) = {fmt(CN,2)} $ ; dépréciation hypothétique 30 % = {fmt(dep,2)} $ ; bâtiment déprécié = {fmt(CN-dep,2)} $")
296+ print(f"V (indication brute ch. 16) = {fmt(V)} $ -> V_L = {fmt(VL)} $ ({VL/V*100:.1f} % de V) ; levier V/V_L = {levier_residuel(V, VL):.2f}")
297+ for dv in (-0.05, 0.05, -0.10):
298+ VL2 = valeur_residuelle(V * (1 + dv), CN, dep)
299+ print(f" V {dv:+.0%} -> V_L = {fmt(VL2)} $ ({VL2/VL-1:+.1%})")
300+ print(f"élasticité de la rente foncière au prix du logement (alpha = {P['alpha']}) : {elasticite_rente_prix(P['alpha']):.2f} ; +10 % de prix -> rente x {1.1**elasticite_rente_prix(P['alpha']):.3f}")
301+ cz = decomposition_capozza(P["ra"], P["i"], 25.0, rente_fonciere(10.0, P), 60.0)
302+ print("Capozza-Helsley à 10 km ($/m²) :", {k: round(v_, 1) for k, v_ in cz.items()})
303+
304+ print("\n=== 5.E Capitalisation de la taxe foncière ===")
305+ for k, t_ in TAUX_2026.items():
306+ print(f" {k}: {t_:.5f} $/100 $ = {t_/100*10000:.1f} pb de la valeur")
307+ V0 = 500000.0; i = 0.05
308+ for pb in (10, 22.4, 50):
309+ c = capitalisation_taxe(V0, 0.0070, 0.0070 + pb / 10000, i)
310+ print(f" écart de {pb} pb sur {fmt(V0)} $ : dT = {fmt(c['dT'])} $/an ; dV approché = {fmt(c['dV_approx'])} $ ; dV exact = {fmt(c['dV_exact'])} $ ({c['dV_pct_exact']*100:.2f} %) ; à 60 % : {fmt(0.6*c['dV_approx'])} $")
311+ ecart_qc_gat = (TAUX_2026["Québec (1 à 5 logements)"] - TAUX_2026["Gatineau (taux de base)"]) / 100
312+ print(f"écart nominal Québec (1-5 log.) - Gatineau (base) = {ecart_qc_gat*10000:.1f} pb ; sur {fmt(V0)} $ : {fmt(ecart_qc_gat*V0)} $/an ; capitalisé à 5 % : {fmt(ecart_qc_gat*V0/i)} $ ; à 60 % : {fmt(0.6*ecart_qc_gat*V0/i)} $")
313+ for ville, F in FACTEUR_2026.items():
314+ key = "Gatineau (taux de base)" if ville == "Gatineau" else "Montréal (taxe foncière générale, hors arrondissement)"
315+ print(f" taux effectif {ville} : {TAUX_2026[key]:.4f}/{F} = {taux_effectif(TAUX_2026[key], F):.4f} $/100 $ de valeur marchande")
316+ print(f" Québec avec un facteur hypothétique de 1,10 : {taux_effectif(TAUX_2026['Québec (1 à 5 logements)'], 1.10):.4f} $/100 $")
317+ sujet_role = d1["sujet"]["valeur_role_2025"]
318+ T_gat = sujet_role * TAUX_2026["Gatineau (taux de base)"] / 100; T_sco = sujet_role * TAUX_2026["Taxe scolaire 2026-2027 (toute la province)"] / 100
319+ print(f"sujet du cas 1 (rôle fictif {fmt(sujet_role)} $) : taxe générale au taux de base Gatineau = {fmt(T_gat)} $ ; scolaire (sans l'exemption de 25 000 $) = {fmt(T_sco)} $")
320+ print(f" écart 6+ vs 1-5 logements à Québec : {(TAUX_2026['Québec (6 logements et plus)']-TAUX_2026['Québec (1 à 5 logements)'])*100:.1f} pb ; sur un immeuble de 2 000 000 $ : {fmt((TAUX_2026['Québec (6 logements et plus)']-TAUX_2026['Québec (1 à 5 logements)'])/100*2e6)} $/an ; capitalisé à 5,5 % : {fmt((TAUX_2026['Québec (6 logements et plus)']-TAUX_2026['Québec (1 à 5 logements)'])/100*2e6/0.055)} $")
321+ G = grille_capitalisation(V0, [5, 10, 25, 50, 100], [0.04, 0.05, 0.06, 0.07])
322+ print("grille (kappa = 1, V0 = 500 000 $) :\n", G.round(0).to_string())
323+ # coût d'usage : part de la taxe
324+ tau_eff = taux_effectif(TAUX_2026["Gatineau (taux de base)"], 1.02) / 100 + TAUX_2026["Taxe scolaire 2026-2027 (toute la province)"] / 100
325+ print(f"taux effectif total Gatineau (base + scolaire) ≈ {tau_eff*100:.4f} $/100 $ ; part de la taxe dans un coût d'usage de (i=4,12 %, tau, delta=1,5 %, g=2 %) : {tau_eff/(0.0412+tau_eff+0.015-0.02)*100:.1f} %")
326+
327+if __name__ == "__main__":
328+ main()
added analysis/python/cases/ch09_ratios.py +345 −0
@@ -0,0 +1,345 @@
1+#!/usr/bin/env python3
2+"""Chapitre 9 — Évaluation foncière municipale et contestation (DONNÉES SYNTHÉTIQUES du cas 1, graine 20261031).
3+
4+Étude de ratios (proportion médiane, facteur comparatif, COD, PRD, PRB, écart type relatif de l'annexe III du
5+Règlement sur le rôle), dossier de contestation du sujet du cas 1 (valeur au rôle fictive 468 000 $) et d'un acheteur
6+récent (vente G0258), compte de taxes 2026 de Gatineau (règlement 994-2025), droits de mutation 2026, échéancier des
7+recours (LFM art. 70, 81, 130, 138.3, 138.4, 138.5), sensibilité et exercices. Imprime tous les chiffres cités.
8+
9+Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch09_ratios.py
10+Bibliothèque : traite.cas1 (modèle hédonique partagé), traite.hedonique.statistiques_ratio, traite.comparables."""
11+from __future__ import annotations
12+import sys, pathlib, datetime as dt
13+import numpy as np, pandas as pd
14+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))
15+from traite import cas1, hedonique as h, comparables as cp
16+
17+ROOT = pathlib.Path(__file__).resolve().parents[3]
18+REF = pd.Timestamp("2024-07-01") # date de référence au marché du rôle fictif (LFM art. 46 al. 1)
19+ROLE_FICTIF = (2026, 2027, 2028) # exercices couverts par un rôle dont la référence est le 1er juillet 2024
20+GRAINE = 20261031
21+
22+# ----------------------------------------------------------------------------- paramètres fiscaux vérifiés (Gatineau 2026)
23+# Règlement 994-2025 (adopté le 18 décembre 2025), par 1 000 $ d'évaluation ; voir data/raw/gatineau_r994_2025/README.md
24+TAUX_GATINEAU_2026 = {
25+ "base": 5.224, # art. 1 (catégorie résiduelle)
26+ "non_residentiel": 29.372, # art. 2
27+ "industriel": 26.120, # art. 3
28+ "terrains_vagues": 20.896, # art. 4
29+ "agricole": 5.090, # art. 5
30+ "six_logements_plus": 6.964, # art. 8
31+ "sto": 1.417, # art. 11 taxe spéciale transport en commun
32+ "dette": 0.830, # art. 12 taxe spéciale service de la dette
33+ "ex_hull": 0.036, # art. 15, 1° (secteur de l'ex-Ville de Hull)
34+ "credit_aqueduc": 0.386, "credit_egout": 0.523, # art. 13-14 (immeubles non raccordés)
35+}
36+COMPENSATION_MATIERES_RESIDUELLES = 178.83 # $ par logement, art. 16
37+TAUX_SCOLAIRE_2026_2027 = 0.07899 / 100 # $ par $ d'évaluation uniformisée ajustée (LIP art. 303.4 ; avis 2026)
38+EXEMPTION_SCOLAIRE = 25_000 # LIP art. 303.1 : tranche exemptée
39+FRAIS_REVISION_GATINEAU = [(500_000, 80.00), (2_000_000, 325.00), (5_000_000, 574.50), (float("inf"), 1_149.25)] # règl. 943-2023
40+FRAIS_REVISION_MONTREAL = [(500_000, 90.00), (2_000_000, 360.00), (5_000_000, 605.00), (float("inf"), 1_215.00)]
41+FRAIS_TAQ_2026 = [(500_000, 93.20), (2_000_000, 372.60), (5_000_000, 621.10), (float("inf"), 1_242.50)] # Tarif J-3, r. 3.2
42+# Droits sur les mutations immobilières 2026 (LDMI art. 2 ; barèmes municipaux)
43+BAREME_MUTATION_QC = [(62_900, 0.005), (315_000, 0.01), (float("inf"), 0.015)]
44+BAREME_MUTATION_MTL = [(62_900, 0.005), (315_000, 0.01), (552_300, 0.015), (1_104_700, 0.02), (2_136_500, 0.025), (3_113_000, 0.035), (float("inf"), 0.04)]
45+BAREME_MUTATION_GAT = [(62_900, 0.005), (315_000, 0.01), (500_000, 0.015), (float("inf"), 0.03)] # à compter du 25 février 2026
46+FACTEUR_GATINEAU_2026 = 1.02 # facteur comparatif publié par la Ville (page « Base d'imposition »)
47+FACTEUR_MONTREAL_2026 = 1.00
48+# Seuils IAAO (Standard on Ratio Studies, 2013, table 1-3 et § 9.2.7)
49+IAAO = {"COD_unifamilial_heterogene": (5.0, 15.0), "COD_unifamilial_homogene": (5.0, 10.0), "PRD": (0.98, 1.03), "PRB": (-0.05, 0.05), "niveau": (0.90, 1.10)}
50+# Règlement sur le rôle (F-2.1, r. 13), art. 13 al. 2 et art. 14 al. 1
51+R13_PROPORTION = (0.95, 1.05)
52+R13_ECART_TYPE_RELATIF = [(0.20, 0.36), (0.40, 0.31), (0.60, 0.25), (0.80, 0.19), (1.01, 0.14)] # (indice de concentration unifamiliale < borne, plafond)
53+
54+
55+# ----------------------------------------------------------------------------- outils
56+def charger() -> dict:
57+ d = cas1.charger()
58+ d["modele"] = cas1.hedonique(d["ventes"])
59+ return d
60+
61+def mois_depuis_ref(dates) -> np.ndarray:
62+ return ((pd.to_datetime(dates) - REF).dt.days / 30.4375).to_numpy()
63+
64+def ratios(v: pd.DataFrame, modele: dict) -> pd.DataFrame:
65+ """Ratios bruts (rôle / prix) et ratios après rajustement des prix aux conditions du marché de la date de référence
66+ (r. 10, art. 11-12) : P_ref = P / (1+g)^mois, g = croissance mensuelle du modèle hédonique partagé."""
67+ g = modele["croissance_mensuelle"]
68+ out = v.copy()
69+ out["mois_ref"] = mois_depuis_ref(out["date_vente"])
70+ out["prix_ref"] = out["prix_vente"] / (1.0 + g) ** out["mois_ref"]
71+ out["r_brut"] = out["valeur_role_2025"] / out["prix_vente"]
72+ out["r_aj"] = out["valeur_role_2025"] / out["prix_ref"]
73+ return out
74+
75+def epuration_r10(r: np.ndarray) -> dict:
76+ """Épuration du r. 10 (art. 15-16) : retrait des ratios hors [médiane − 2σ ; médiane + 2σ], σ sur n − 1."""
77+ r = np.asarray(r, float); med = np.median(r); sd = r.std(ddof=1)
78+ lo, hi = med - 2 * sd, med + 2 * sd
79+ garde = (r >= lo) & (r <= hi)
80+ return {"mediane_initiale": med, "sigma": sd, "borne_basse": lo, "borne_haute": hi, "n": len(r), "n_retires": int((~garde).sum()), "garde": garde,
81+ "proportion_mediane": float(np.median(r[garde]))}
82+
83+def ecart_type_relatif(r: np.ndarray) -> float:
84+ """Annexe III du r. 13 : écart type des ratios (n − 1) divisé par le ratio médian."""
85+ r = np.asarray(r, float)
86+ return float(r.std(ddof=1) / np.median(r))
87+
88+def plafond_r13(indice_concentration: float) -> float:
89+ for borne, plafond in R13_ECART_TYPE_RELATIF:
90+ if indice_concentration < borne:
91+ return plafond
92+ return 0.14
93+
94+def liste_de_base(N: int) -> int:
95+ """r. 10, art. 5 : nombre de ventes de la liste de base = max(30 ; N / (15,5 + 0,001 N))."""
96+ return int(np.ceil(max(30.0, N / (15.5 + 0.001 * N))))
97+
98+def prb_proxy_prix(valeurs, prix) -> dict:
99+ """PRB calculé avec le seul prix (log2 P) comme variable explicative — variante de contrôle (hors norme IAAO)."""
100+ V = np.asarray(valeurs, float); P = np.asarray(prix, float); r = V / P; med = np.median(r)
101+ x = np.log2(P); yv = (r - med) / med
102+ X = np.column_stack([np.ones_like(x), x]); b, *_ = np.linalg.lstsq(X, yv, rcond=None)
103+ res = yv - X @ b; se = np.sqrt((res @ res) / (len(x) - 2) / ((x - x.mean()) ** 2).sum())
104+ return {"PRB": float(b[1]), "PRB_se": float(se)}
105+
106+def frais(valeur_role: float, bareme) -> float:
107+ for borne, montant in bareme:
108+ if valeur_role <= borne:
109+ return montant
110+ return bareme[-1][1]
111+
112+def droits_mutation(base: float, bareme=BAREME_MUTATION_QC) -> float:
113+ """Droit sur les mutations immobilières par tranches (LDMI art. 2) ; base = max(prix, contrepartie, rôle × facteur)."""
114+ total, bas = 0.0, 0.0
115+ for haut, taux in bareme:
116+ if base > bas:
117+ total += (min(base, haut) - bas) * taux
118+ bas = haut
119+ return total
120+
121+def compte_taxes_gatineau(valeur_role: float, facteur: float, taux_voisinage: float, secteur_hull: bool = True, raccorde: bool = True, logements: int = 1) -> dict:
122+ """Compte de taxes d'une résidence (catégorie résiduelle, moins de six logements) selon le règlement 994-2025 et la LIP.
123+ Taxes ad valorem par 1 000 $ : taux de l'unité de voisinage (art. 7) + STO (art. 11) + dette (art. 12) [+ ex-Hull 0,036] ;
124+ crédits aqueduc/égout si non raccordé ; taxe scolaire sur (rôle × facteur − 25 000 $) ; compensation matières résiduelles."""
125+ t = TAUX_GATINEAU_2026
126+ taux_municipal = taux_voisinage + t["sto"] + t["dette"] + (t["ex_hull"] if secteur_hull else 0.0)
127+ if not raccorde:
128+ taux_municipal -= t["credit_aqueduc"] + t["credit_egout"]
129+ municipal = valeur_role * taux_municipal / 1000.0
130+ uniformisee = valeur_role * facteur
131+ scolaire = max(uniformisee - EXEMPTION_SCOLAIRE, 0.0) * TAUX_SCOLAIRE_2026_2027
132+ compensations = COMPENSATION_MATIERES_RESIDUELLES * logements
133+ return {"taux_municipal_pour_1000": taux_municipal, "municipal": municipal, "evaluation_uniformisee": uniformisee, "scolaire": scolaire,
134+ "compensations": compensations, "total": municipal + scolaire + compensations,
135+ "taux_marginal_pour_1000": taux_municipal + facteur * TAUX_SCOLAIRE_2026_2027 * 1000.0}
136+
137+def taux_voisinage_gatineau() -> dict:
138+ """Statistiques des 1 092 taux de l'annexe I du règlement 994-2025 (data/raw/gatineau_r994_2025)."""
139+ a = pd.read_csv(ROOT / "data/raw/gatineau_r994_2025/annexe1_taux_voisinage.csv")
140+ t = a["taux_2026_par_1000"].astype(float)
141+ return {"n": len(t), "min": float(t.min()), "max": float(t.max()), "mediane": float(t.median()), "moyenne": float(t.mean())}
142+
143+
144+# ----------------------------------------------------------------------------- étude de ratios
145+def etude(d: dict) -> dict:
146+ v = ratios(d["ventes"], d["modele"])
147+ brut = h.statistiques_ratio(v["valeur_role_2025"], v["prix_vente"])
148+ aj = h.statistiques_ratio(v["valeur_role_2025"], v["prix_ref"])
149+ ep = epuration_r10(v["r_aj"].to_numpy())
150+ k = v[ep["garde"]]
151+ PM = ep["proportion_mediane"]; F = 1.0 / PM
152+ apres = h.statistiques_ratio(k["valeur_role_2025"], k["prix_ref"])
153+ secteurs = v.groupby("secteur")["r_aj"].agg(mediane="median", n="count").sort_values("n", ascending=False)
154+ types = v.groupby("type")["r_aj"].agg(mediane="median", n="count")
155+ bornes = [0, 350_000, 450_000, 550_000, 700_000, np.inf]
156+ v["tranche"] = pd.cut(v["prix_ref"], bornes, labels=["< 350 k$", "350-450 k$", "450-550 k$", "550-700 k$", "≥ 700 k$"])
157+ tranches = v.groupby("tranche", observed=True)["r_aj"].agg(mediane="median", n="count")
158+ v["trimestre"] = v["date_vente"].dt.to_period("Q").astype(str)
159+ trimestres = v.groupby("trimestre").agg(r_brut=("r_brut", "median"), r_aj=("r_aj", "median"), n=("r_brut", "count"))
160+ annees = v.groupby(v["date_vente"].dt.year).agg(r_brut=("r_brut", "median"), r_aj=("r_aj", "median"), n=("r_brut", "count"))
161+ return {"v": v, "brut": brut, "aj": aj, "epuration": ep, "apres_epuration": apres, "PM": PM, "F": F,
162+ "ecart_type_relatif_brut": ecart_type_relatif(v["r_brut"]), "ecart_type_relatif_aj": ecart_type_relatif(v["r_aj"]),
163+ "ecart_type_relatif_epure": ecart_type_relatif(k["r_aj"]), "plafond_r13": plafond_r13(1.0),
164+ "secteurs": secteurs, "types": types, "tranches": tranches, "trimestres": trimestres, "annees": annees,
165+ "prb_prix_aj": prb_proxy_prix(v["valeur_role_2025"], v["prix_ref"]), "prb_prix_brut": prb_proxy_prix(v["valeur_role_2025"], v["prix_vente"]),
166+ "croissance_mensuelle": d["modele"]["croissance_mensuelle"], "croissance_annuelle": (1 + d["modele"]["croissance_mensuelle"]) ** 12 - 1}
167+
168+
169+# ----------------------------------------------------------------------------- dossier du sujet
170+def comparables_reference(d: dict, k: int = 5, fenetre_mois: float = 12.0, rayon_km: float = 3.0) -> dict:
171+ """Comparables du sujet autour de la date de référence (± 12 mois, art. 46 al. 5), mêmes filtres et même distance d'attributs
172+ que le chapitre 16 (ch16_selection), puis ajustements dérivés du modèle hédonique vers la date de référence."""
173+ import ch16_selection as c16
174+ v = c16.preparer(d["ventes"].copy(), d["sujet"]); s = d["sujet"]
175+ v["mois_ref"] = mois_depuis_ref(v["date_vente"])
176+ # la distance d'attributs du chapitre 16 mesure l'ancienneté par rapport à la date d'évaluation ; ici, par rapport à la date de référence
177+ v["mois"] = v["mois_ref"].abs()
178+ suj = dict(s); suj["mois"] = 0.0; suj["dist_km"] = 0.0
179+ v["distance"] = [cp.distance_attributs(suj, r, c16.ECHELLES, c16.POIDS) for r in v.to_dict("records")]
180+ f = v[(v["mois_ref"].abs() <= fenetre_mois) & (v["dist_km"] <= rayon_km) & (v["type"] == s["type"])
181+ & v["superficie_habitable_m2"].between(0.75 * s["superficie_habitable_m2"], 1.25 * s["superficie_habitable_m2"])].sort_values("distance")
182+ top = f.head(k)
183+ adj = cas1.ajustements_hedoniques(d["modele"], top, s, REF.date().isoformat())
184+ desc = top[["id_vente", "date_vente", "superficie_habitable_m2", "superficie_terrain_m2", "age", "qualite_1a5", "garage_places", "sous_sol_fini", "dist_km", "valeur_role_2025"]].rename(columns={"age": "age_bat"})
185+ adj = adj.merge(desc, on="id_vente")
186+ adj["ratio_comparable"] = adj["valeur_role_2025"] / adj["prix_ajuste"]
187+ return {"candidats": f, "comparables": top, "ajustements": adj, "indication_mediane": float(adj["prix_ajuste"].median()),
188+ "indication_moyenne": float(adj["prix_ajuste"].mean())}
189+
190+def dossier_sujet(d: dict, et: dict) -> dict:
191+ s = d["sujet"]; m = d["modele"]
192+ sd = cas1.sujet_df(s, REF.date().isoformat())
193+ hed = float(cas1.predire(m, sd)[0]); ip = cas1.intervalle_prediction(m, sd).iloc[0]
194+ comps = comparables_reference(d)
195+ # réconciliation : moyenne simple des deux indications (même poids), arrondie au millier
196+ v_reelle = float(np.round((hed + comps["indication_mediane"]) / 2.0, -3))
197+ role = float(s["valeur_role_2025"])
198+ ratio_sujet = role / v_reelle
199+ v_inscrire = v_reelle / et["F"] # art. 147 : valeur réelle ÷ facteur du rôle
200+ tv = taux_voisinage_gatineau()
201+ taxes_role = compte_taxes_gatineau(role, et["F"], tv["mediane"])
202+ taxes_inscrire = compte_taxes_gatineau(v_inscrire, et["F"], tv["mediane"])
203+ return {"hedonique": hed, "ic_bas": float(ip["bas"]), "ic_haut": float(ip["haut"]), "comparables": comps, "valeur_reelle": v_reelle,
204+ "valeur_role": role, "ratio_sujet": ratio_sujet, "ecart_ratio_pct": (ratio_sujet / et["PM"] - 1) * 100,
205+ "valeur_a_inscrire": v_inscrire, "variation_si_conteste": v_inscrire - role, "taxes_role": taxes_role, "taxes_inscrire": taxes_inscrire,
206+ "variation_taxes_annuelle": taxes_inscrire["total"] - taxes_role["total"], "frais_revision": frais(role, FRAIS_REVISION_GATINEAU),
207+ "frais_taq": frais(role, FRAIS_TAQ_2026), "taux_voisinage": tv}
208+
209+def dossier_acheteur(d: dict, et: dict, id_vente: str = "G0258") -> dict:
210+ """Second dossier : l'acquéreur d'une vente du jeu (G0258, Hull) dont le prix, rajusté à la date de référence,
211+ est inférieur à la valeur inscrite."""
212+ v = et["v"]; r = v[v["id_vente"] == id_vente].iloc[0]
213+ prix_ref = float(r["prix_ref"]); role = float(r["valeur_role_2025"])
214+ v_inscrire = prix_ref / et["F"]
215+ tv = et.get("taux_voisinage") or taux_voisinage_gatineau()
216+ t_role = compte_taxes_gatineau(role, et["F"], tv["mediane"]); t_new = compte_taxes_gatineau(v_inscrire, et["F"], tv["mediane"])
217+ gain_annuel = t_role["total"] - t_new["total"]
218+ fr = frais(role, FRAIS_REVISION_GATINEAU)
219+ gain_cycle = gain_annuel * len(ROLE_FICTIF)
220+ # écart de valeur minimal pour couvrir les frais de révision sur un cycle (taux marginal par 1 000 $)
221+ seuil_valeur = fr / (t_role["taux_marginal_pour_1000"] / 1000.0) / len(ROLE_FICTIF)
222+ return {"vente": r, "prix_vente": float(r["prix_vente"]), "date_vente": r["date_vente"], "mois_ref": float(r["mois_ref"]), "prix_ref": prix_ref,
223+ "valeur_role": role, "ratio_brut": float(r["r_brut"]), "ratio_aj": float(r["r_aj"]), "valeur_a_inscrire": v_inscrire, "reduction": role - v_inscrire,
224+ "taxes_role": t_role, "taxes_nouvelle": t_new, "gain_annuel": gain_annuel, "gain_cycle": gain_cycle, "frais_revision": fr,
225+ "frais_taq": frais(role, FRAIS_TAQ_2026), "gain_net_cycle": gain_cycle - fr, "seuil_valeur": seuil_valeur}
226+
227+def sensibilite_acheteur(d: dict, et: dict, da: dict) -> pd.DataFrame:
228+ """Gain net sur le cycle triennal selon la valeur réelle conclue (−10 % à +10 % autour du prix rajusté) et le taux de voisinage
229+ (minimum, médiane, maximum de l'annexe I)."""
230+ tv = taux_voisinage_gatineau(); rows = []
231+ for ecart in np.arange(-0.10, 0.1001, 0.025):
232+ v_reelle = da["prix_ref"] * (1 + ecart); v_insc = v_reelle / et["F"]
233+ row = {"ecart_pct": ecart * 100, "valeur_reelle": v_reelle, "valeur_a_inscrire": v_insc}
234+ for nom, taux in [("min", tv["min"]), ("mediane", tv["mediane"]), ("max", tv["max"])]:
235+ g = (compte_taxes_gatineau(da["valeur_role"], et["F"], taux)["total"] - compte_taxes_gatineau(v_insc, et["F"], taux)["total"]) * len(ROLE_FICTIF)
236+ row[f"gain_net_{nom}"] = g - da["frais_revision"]
237+ rows.append(row)
238+ return pd.DataFrame(rows)
239+
240+def mutations_sujet(ds: dict, et: dict) -> dict:
241+ """Droits de mutation sur une vente hypothétique du sujet au prix de sa valeur réelle à la date de référence :
242+ base = max(prix ; rôle × facteur) (LDMI art. 1.1 et 2 al. 2)."""
243+ prix = ds["valeur_reelle"]; role = ds["valeur_role"]
244+ out = {}
245+ for nom, bareme, F in [("Québec (barème général)", BAREME_MUTATION_QC, et["F"]), ("Montréal 2026", BAREME_MUTATION_MTL, FACTEUR_MONTREAL_2026), ("Gatineau 2026 (dès le 25 février)", BAREME_MUTATION_GAT, et["F"])]:
246+ base = max(prix, role * F)
247+ out[nom] = {"base": base, "droit": droits_mutation(base, bareme), "droit_sur_prix": droits_mutation(prix, bareme)}
248+ # effet du facteur : un prix de 450 000 $ pour le sujet à Gatineau, facteur 1,02 réel vs fictif
249+ out["exemple_facteur"] = {"prix": 450_000.0, "base_F_reel": max(450_000.0, role * FACTEUR_GATINEAU_2026), "base_F_fictif": max(450_000.0, role * et["F"]),
250+ "droit_F_reel": droits_mutation(max(450_000.0, role * FACTEUR_GATINEAU_2026), BAREME_MUTATION_GAT),
251+ "droit_F_fictif": droits_mutation(max(450_000.0, role * et["F"]), BAREME_MUTATION_GAT), "droit_prix": droits_mutation(450_000.0, BAREME_MUTATION_GAT)}
252+ return out
253+
254+def echeancier() -> list[dict]:
255+ """Échéancier légal du rôle fictif 2026-2028 (référence 2024-07-01) et du rôle réel 2027-2029 de Gatineau (référence 2025-07-01)."""
256+ def jalons(ref: dt.date, premier: int, depot: dt.date):
257+ return [
258+ {"jalon": "Date de référence au marché (art. 46 al. 1)", "date": ref},
259+ {"jalon": "Dépôt du rôle (art. 70-71 : 15 août – 15 sept., report possible au 1er nov.)", "date": depot},
260+ {"jalon": "Entrée en vigueur (premier exercice)", "date": dt.date(premier, 1, 1)},
261+ {"jalon": "Expédition de l'avis d'évaluation (art. 81 : avant le 1er mars)", "date": dt.date(premier, 3, 1)},
262+ {"jalon": "Date limite de la demande de révision (art. 130 : avant le 1er mai)", "date": dt.date(premier, 4, 30)},
263+ {"jalon": "Réponse écrite de l'évaluateur (art. 138.3 : au plus tard le 1er sept., report possible au 1er nov. ou au 1er avril suivant)", "date": dt.date(premier, 9, 1)},
264+ {"jalon": "Fin du délai d'entente (art. 138.4 : 30e jour après la réponse du 1er sept.)", "date": dt.date(premier, 10, 1)},
265+ {"jalon": "Date limite du recours au TAQ (art. 138.5 : avant le 31e jour suivant l'expiration du délai d'entente)", "date": dt.date(premier, 10, 31)},
266+ {"jalon": "Fin du cycle triennal (art. 14)", "date": dt.date(premier + 2, 12, 31)},
267+ ]
268+ return {"fictif": jalons(REF.date(), ROLE_FICTIF[0], dt.date(2025, 9, 15)), "gatineau_2027": jalons(dt.date(2025, 7, 1), 2027, dt.date(2026, 10, 27))}
269+
270+
271+# ----------------------------------------------------------------------------- exercices
272+def exercices(et: dict) -> dict:
273+ ex = {}
274+ # ex 1 : facteur comparatif et évaluation uniformisée
275+ pm = 0.92; F = 1 / pm; ex["ex1"] = {"PM": pm, "F": F, "uniformisee_468000": 468_000 * F, "taxe_scolaire": max(468_000 * F - 25_000, 0) * TAUX_SCOLAIRE_2026_2027}
276+ # ex 2 : compte de taxes Gatineau 2026 du sujet (rôle 468 000, voisinage médian, Hull, raccordé) et scénario non raccordé
277+ tv = taux_voisinage_gatineau()
278+ ex["ex2"] = {"raccorde": compte_taxes_gatineau(468_000, 1.02, tv["mediane"]), "non_raccorde": compte_taxes_gatineau(468_000, 1.02, tv["mediane"], raccorde=False),
279+ "six_logements": None}
280+ # ex 3 : petite étude de ratios à la main (8 ventes)
281+ V = np.array([412_000, 455_000, 398_000, 520_000, 610_000, 470_000, 388_000, 545_000.0]); P = np.array([450_000, 480_000, 445_000, 560_000, 690_000, 500_000, 400_000, 600_000.0])
282+ st = h.statistiques_ratio(V, P); ep = epuration_r10(V / P)
283+ ex["ex3"] = {"ratios": V / P, **st, "epuration": ep, "ecart_type_relatif": ecart_type_relatif(V / P)}
284+ # ex 4 : droits de mutation
285+ ex["ex4"] = {"qc_575000": droits_mutation(575_000, BAREME_MUTATION_QC), "gat_575000": droits_mutation(575_000, BAREME_MUTATION_GAT), "mtl_575000": droits_mutation(575_000, BAREME_MUTATION_MTL),
286+ "gat_base_role": max(575_000, 620_000 * 1.02), "gat_droit_base_role": droits_mutation(max(575_000, 620_000 * 1.02), BAREME_MUTATION_GAT)}
287+ # ex 5 : liste de base
288+ ex["ex5"] = {N: liste_de_base(N) for N in [1_000, 10_000, 103_355, 514_614]}
289+ # ex 6 : échéancier — calculé dans la solution à partir de echeancier()
290+ # ex 7 : seuil de rentabilité d'une contestation (voir dossier_acheteur) et régressivité : PRD d'un jeu construit régressif
291+ rng = np.random.default_rng(GRAINE); Vt = np.exp(rng.normal(13.0, 0.35, 400)); Pt = Vt * np.exp(rng.normal(0, 0.08, 400))
292+ A = Vt ** 0.9 * np.exp(0.1 * 13.0) * 0.93 * np.exp(rng.normal(0, 0.06, 400)) # rôle régressif : élasticité 0,9 par rapport à la valeur
293+ ex["ex7"] = h.statistiques_ratio(A, Pt)
294+ return ex
295+
296+
297+# ----------------------------------------------------------------------------- impression
298+def main():
299+ pd.set_option("display.width", 200)
300+ d = charger(); et = etude(d)
301+ print("== Modèle hédonique partagé : croissance mensuelle %.4f %% (%.2f %% par an), σ = %.4f" % (et["croissance_mensuelle"] * 100, et["croissance_annuelle"] * 100, d["modele"]["sigma"]))
302+ print("== Ratios bruts (rôle / prix de vente), n = %d : médiane %.4f ; moyenne %.4f ; pondéré %.4f ; COD %.2f ; PRD %.4f ; PRB %.4f (e.-t. %.4f)" %
303+ (et["brut"]["n"], et["brut"]["ratio_median"], et["brut"]["ratio_moyen"], et["brut"]["ratio_pondere"], et["brut"]["COD"], et["brut"]["PRD"], et["brut"]["PRB"], et["brut"]["PRB_se"]))
304+ print("== Ratios rajustés au 1er juillet 2024 : médiane %.4f ; moyenne %.4f ; pondéré %.4f ; COD %.2f ; PRD %.4f ; PRB %.4f (e.-t. %.4f)" %
305+ (et["aj"]["ratio_median"], et["aj"]["ratio_moyen"], et["aj"]["ratio_pondere"], et["aj"]["COD"], et["aj"]["PRD"], et["aj"]["PRB"], et["aj"]["PRB_se"]))
306+ print("== PRB de contrôle (log2 du seul prix) : brut %.4f (e.-t. %.4f) ; rajusté %.4f (e.-t. %.4f)" % (et["prb_prix_brut"]["PRB"], et["prb_prix_brut"]["PRB_se"], et["prb_prix_aj"]["PRB"], et["prb_prix_aj"]["PRB_se"]))
307+ ep = et["epuration"]
308+ print("== Épuration r. 10 : médiane %.4f, σ %.4f, bornes [%.4f ; %.4f], %d ventes retirées sur %d ; proportion médiane %.4f ; facteur comparatif %.4f" %
309+ (ep["mediane_initiale"], ep["sigma"], ep["borne_basse"], ep["borne_haute"], ep["n_retires"], ep["n"], et["PM"], et["F"]))
310+ print("== Après épuration : COD %.2f ; PRD %.4f ; PRB %.4f" % (et["apres_epuration"]["COD"], et["apres_epuration"]["PRD"], et["apres_epuration"]["PRB"]))
311+ print("== Écart type relatif (annexe III) : brut %.4f ; rajusté %.4f ; épuré %.4f ; plafond r. 13 (100 %% unifamilial) %.2f" % (et["ecart_type_relatif_brut"], et["ecart_type_relatif_aj"], et["ecart_type_relatif_epure"], et["plafond_r13"]))
312+ print("== Par secteur (ratio rajusté) :\n", et["secteurs"].round(4).to_string())
313+ print("== Par type :\n", et["types"].round(4).to_string())
314+ print("== Par tranche de prix rajusté :\n", et["tranches"].round(4).to_string())
315+ print("== Par année de vente (médianes) :\n", et["annees"].round(4).to_string())
316+ print("== Liste de base r. 10 : Gatineau (103 355 unités) → %d ventes ; Montréal (514 614) → %d ; 10 000 → %d ; 1 000 → %d" % (liste_de_base(103_355), liste_de_base(514_614), liste_de_base(10_000), liste_de_base(1_000)))
317+ tv = taux_voisinage_gatineau(); print("== Taux de voisinage Gatineau 2026 (annexe I, n = %d) : min %.3f, médiane %.3f, moyenne %.3f, max %.3f $/1 000 $" % (tv["n"], tv["min"], tv["mediane"], tv["moyenne"], tv["max"]))
318+ ds = dossier_sujet(d, et)
319+ print("== Sujet au 1er juillet 2024 : hédonique %.0f $ (IC 95 %% %.0f – %.0f) ; comparables (médiane %.0f, moyenne %.0f) ; valeur réelle retenue %.0f $" %
320+ (ds["hedonique"], ds["ic_bas"], ds["ic_haut"], ds["comparables"]["indication_mediane"], ds["comparables"]["indication_moyenne"], ds["valeur_reelle"]))
321+ a = ds["comparables"]["ajustements"].copy(); a["date_vente"] = a["date_vente"].dt.date
322+ cols = ["id_vente", "date_vente", "superficie_habitable_m2", "superficie_terrain_m2", "age_bat", "qualite_1a5", "garage_places", "sous_sol_fini", "dist_km", "prix_vente", "conditions_marche", "superficie", "terrain", "age", "qualite", "garage", "sous_sol", "piscine", "prix_ajuste", "ajust_brut_pct", "ajust_net_pct"]
323+ with pd.option_context("display.float_format", lambda x: f"{x:,.2f}" if abs(x) < 10 else f"{x:,.0f}"):
324+ print(a[cols].to_string(index=False))
325+ print("== Ratio du sujet %.4f (écart à la proportion médiane %.1f %%) ; valeur à inscrire (art. 147) %.0f $ ; variation si contestation %.0f $" % (ds["ratio_sujet"], ds["ecart_ratio_pct"], ds["valeur_a_inscrire"], ds["variation_si_conteste"]))
326+ tr = ds["taxes_role"]; print("== Compte de taxes 2026 du sujet (rôle 468 000 $, voisinage médian, Hull) : taux municipal %.3f /1 000 ; municipal %.2f ; uniformisée %.0f ; scolaire %.2f ; compensations %.2f ; total %.2f ; taux marginal %.3f /1 000" %
327+ (tr["taux_municipal_pour_1000"], tr["municipal"], tr["evaluation_uniformisee"], tr["scolaire"], tr["compensations"], tr["total"], tr["taux_marginal_pour_1000"]))
328+ print("== Si la valeur à inscrire était retenue : total %.2f ; variation annuelle %.2f $ ; frais de révision %.2f $ ; frais TAQ %.2f $" % (ds["taxes_inscrire"]["total"], ds["variation_taxes_annuelle"], ds["frais_revision"], ds["frais_taq"]))
329+ da = dossier_acheteur(d, et)
330+ print("== Dossier acheteur %s (%s, %.1f mois après la référence) : prix %.0f ; prix rajusté %.0f ; rôle %.0f ; ratio brut %.4f, rajusté %.4f" % (da["vente"]["id_vente"], da["date_vente"].date(), da["mois_ref"], da["prix_vente"], da["prix_ref"], da["valeur_role"], da["ratio_brut"], da["ratio_aj"]))
331+ print(" valeur à inscrire %.0f (réduction %.0f) ; taxes rôle %.2f → %.2f ; gain annuel %.2f ; gain sur 3 ans %.2f ; frais %.2f ; gain net %.2f ; seuil de valeur %.0f $" %
332+ (da["valeur_a_inscrire"], da["reduction"], da["taxes_role"]["total"], da["taxes_nouvelle"]["total"], da["gain_annuel"], da["gain_cycle"], da["frais_revision"], da["gain_net_cycle"], da["seuil_valeur"]))
333+ print("== Sensibilité (gain net sur le cycle, $) :\n", sensibilite_acheteur(d, et, da).round(0).to_string(index=False))
334+ mu = mutations_sujet(ds, et)
335+ for k_, x in mu.items():
336+ print("== Mutation", k_, {a: round(b, 2) for a, b in x.items()})
337+ for nom, lst in echeancier().items():
338+ print("== Échéancier", nom); [print(" %s : %s" % (j["date"].isoformat(), j["jalon"])) for j in lst]
339+ ex = exercices(et)
340+ print("== Exercices :"); [print(" ", k_, (v_ if not isinstance(v_, dict) else {a: (round(b, 4) if isinstance(b, (int, float, np.floating)) else "…") for a, b in v_.items()})) for k_, v_ in ex.items()]
341+ return {"d": d, "etude": et, "sujet": ds, "acheteur": da, "mutations": mu, "exercices": ex}
342+
343+
344+if __name__ == "__main__":
345+ main()
added analysis/python/figures/ch03_quadrants.py +91 −0
@@ -0,0 +1,91 @@
1+#!/usr/bin/env python3
2+"""Figure ch03 — Modèle des quatre quadrants (DiPasquale–Wheaton) tracé à partir des paramètres pédagogiques du marché type
3+(analysis/python/cases/ch03_cycles.py, SYNTHÉTIQUE). Équilibre de référence (trait plein) et nouvel équilibre après une baisse
4+du taux de capitalisation de 5 % à 4 % (tirets), avec la réaction de court terme (stock fixe) en pointillé.
5+Sortie : assets/figures/ch03/ch03-fig-quatre-quadrants.pdf"""
6+import sys, pathlib
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import numpy as np, matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR
11+import ch03_cycles as cas
12+
13+
14+def main():
15+ setup()
16+ p = cas.Parametres4Q()
17+ base = cas.equilibre_4q(p)
18+ i2 = 0.04
19+ nouv = cas.equilibre_4q(p, i=i2)
20+ ct = cas.court_terme_4q(p, i=i2)
21+ k = 1000.0 # affichage en milliers
22+ c0, c1 = palette.cat[0], palette.cat[1]
23+ fig, ax = plt.subplots(figsize=(LARGEUR, LARGEUR * 0.92))
24+ # bornes
25+ Smax, Pmax, Rmax, Cmax = 150, 420, 25, 3.2
26+ ax.set_xlim(-Pmax, Smax); ax.set_ylim(-Cmax, Rmax)
27+ ax.axhline(0, color=palette.ink, lw=0.9); ax.axvline(0, color=palette.ink, lw=0.9)
28+ ax.grid(False)
29+ for s in ("left", "bottom"):
30+ ax.spines[s].set_visible(False)
31+ ax.set_xticks([]); ax.set_yticks([])
32+ # --- NE : marché de l'espace, D(R) = S → R = (a − S)/b
33+ S = np.linspace(60, 150, 50) * k
34+ ax.plot(S / k, (p.a - S) / p.b / k, color=c0, lw=1.8)
35+ ax.plot(S / k, (1.0 * p.a - S) / p.b / k, color=c0, lw=0, label="_")
36+ # --- NW : marché des actifs, P = R / i (P à gauche, R en haut)
37+ R = np.linspace(0, Rmax, 50) * k
38+ ax.plot(-R / p.i / k, R / k, color=c0, lw=1.8)
39+ ax.plot(-R / i2 / k, R / k, color=c1, lw=1.8, ls="--")
40+ # --- SW : construction, C = (P − c0)/c1 (P à gauche, C en bas)
41+ P = np.linspace(p.c0, Pmax * k, 50)
42+ ax.plot(-P / k, -(P - p.c0) / p.c1 / k, color=c0, lw=1.8)
43+ # --- SE : ajustement du stock, S = C/δ (C en bas, S à droite)
44+ C = np.linspace(0, Cmax, 50) * k
45+ ax.plot(C / p.delta / k, -C / k, color=c0, lw=1.8)
46+
47+ def rectangle(eq, color, ls, lw=1.0):
48+ Sx, Rx, Px, Cx = eq["S"] / k, eq["R"] / k, eq["P"] / k, eq["C"] / k
49+ ax.plot([Sx, Sx], [0, Rx], color=color, lw=lw, ls=ls)
50+ ax.plot([-Px, Sx], [Rx, Rx], color=color, lw=lw, ls=ls)
51+ ax.plot([-Px, -Px], [Rx, -Cx], color=color, lw=lw, ls=ls)
52+ ax.plot([-Px, Sx], [-Cx, -Cx], color=color, lw=lw, ls=ls)
53+ ax.plot([Sx, Sx], [-Cx, 0], color=color, lw=lw, ls=ls)
54+
55+ rectangle(base, c0, "-")
56+ rectangle(nouv, c1, "--")
57+ # court terme : stock fixe, prix bondit à R0/i2
58+ ax.plot([-ct["P"] / k, base["S"] / k], [base["R"] / k, base["R"] / k], color=c1, lw=1.0, ls=":")
59+ ax.plot([-ct["P"] / k, -ct["P"] / k], [base["R"] / k, -ct["C"] / k], color=c1, lw=1.0, ls=":")
60+ ax.plot([-ct["P"] / k, base["S"] / k], [-ct["C"] / k, -ct["C"] / k], color=c1, lw=1.0, ls=":")
61+ # points
62+ ax.plot(base["S"] / k, base["R"] / k, "o", color=c0, ms=4); ax.plot(nouv["S"] / k, nouv["R"] / k, "o", color=c1, ms=4)
63+ ax.plot(-base["P"] / k, base["R"] / k, "o", color=c0, ms=4); ax.plot(-nouv["P"] / k, nouv["R"] / k, "o", color=c1, ms=4)
64+ ax.plot(-ct["P"] / k, base["R"] / k, "o", color=c1, ms=4, mfc="white")
65+ # étiquettes d'axes et de quadrants
66+ ax.text(Smax, 0.6, "Stock $S$ (milliers de log.)", ha="right", va="bottom", fontsize=8)
67+ ax.text(-Pmax, 0.6, "Prix $P$ (k\\$/log.)", ha="left", va="bottom", fontsize=8)
68+ ax.text(1.5, Rmax, "Loyer $R$ (k\\$/log./an)", ha="left", va="top", fontsize=8)
69+ ax.text(1.5, -Cmax, "Construction $C$ (milliers de log./an)", ha="left", va="bottom", fontsize=8)
70+ ax.text(Smax * 0.98, Rmax * 0.93, "Marché de l'espace\n$D(R) = S$", ha="right", va="top", fontsize=8, color=palette.ink2)
71+ ax.text(-Pmax * 0.98, Rmax * 0.93, "Marché des actifs\n$P = R/i$", ha="left", va="top", fontsize=8, color=palette.ink2)
72+ ax.text(-Pmax * 0.98, -Cmax * 0.93, "Construction\n$C = (P - c_0)/c_1$", ha="left", va="bottom", fontsize=8, color=palette.ink2)
73+ ax.text(Smax * 0.98, -Cmax * 0.93, "Ajustement du stock\n$\\Delta S = C - \\delta S = 0$", ha="right", va="bottom", fontsize=8, color=palette.ink2)
74+ # annotations numériques
75+ ax.annotate(f"$S^*$ = {base['S']/k:.0f}", xy=(base["S"] / k, 0), xytext=(base["S"] / k + 2, 1.2), fontsize=7.5, color=c0)
76+ ax.annotate(f"{nouv['S']/k:.1f}", xy=(nouv["S"] / k, 0), xytext=(nouv["S"] / k + 2, 3.4), fontsize=7.5, color=c1)
77+ ax.annotate(f"$R^*$ = {base['R']/k:.1f}", xy=(0, base["R"] / k), xytext=(3, base["R"] / k + 0.6), fontsize=7.5, color=c0)
78+ ax.annotate(f"{nouv['R']/k:.1f}", xy=(0, nouv["R"] / k), xytext=(3, nouv["R"] / k - 1.9), fontsize=7.5, color=c1)
79+ ax.annotate(f"$P^*$ = {base['P']/k:.0f}", xy=(-base["P"] / k, 0), xytext=(-base["P"] / k - 6, 1.2), fontsize=7.5, color=c0, ha="right")
80+ ax.annotate(f"court terme : {ct['P']/k:.0f}", xy=(-ct["P"] / k, 0), xytext=(-ct["P"] / k - 4, -0.9), fontsize=7.5, color=c1, ha="right", va="top")
81+ ax.annotate(f"$C^*$ = {base['C']/k:.1f}", xy=(0, -base["C"] / k), xytext=(3, -base["C"] / k - 0.1), fontsize=7.5, color=c0, va="top")
82+ from matplotlib.lines import Line2D
83+ h = [Line2D([], [], color=c0, lw=1.8, label="équilibre de référence ($i$ = 5 %)"),
84+ Line2D([], [], color=c1, lw=1.8, ls="--", label="nouvel équilibre de long terme ($i$ = 4 %)"),
85+ Line2D([], [], color=c1, lw=1.0, ls=":", label="court terme (stock fixe)")]
86+ ax.legend(handles=h, loc="lower center", bbox_to_anchor=(0.5, -0.06), ncol=3, fontsize=7.5, frameon=False)
87+ out = savefig(fig, "ch03/ch03-fig-quatre-quadrants.pdf"); print("OK", out)
88+
89+
90+if __name__ == "__main__":
91+ main()
added analysis/python/figures/ch04_carte.py +45 −0
@@ -0,0 +1,45 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — carte schématique des six RMR du Québec : position des centres (coordonnées de la Base de données toponymiques du Canada,
3+Ressources naturelles Canada, service geonames, consulté le 2026-10-31), surface des cercles proportionnelle à la population au 1er juillet 2025
4+(Statistique Canada, tableau 17-10-0148-01). Aucun fond de carte ni tuile : schéma en coordonnées géographiques (projection équirectangulaire,
5+facteur cos(47°) sur la longitude). Sortie : assets/figures/ch04/ch04-fig-carte-rmr.pdf"""
6+import sys, pathlib, math
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR
11+import ch04_marches as cas
12+
13+# Coordonnées (lat, lon) des toponymes « CITY » de la BDTC (geogratis.gc.ca/services/geoname, 2026-10-31)
14+CENTRES = {"Montréal": (45.508822, -73.554077), "Québec": (46.813819, -71.207997), "Ottawa-Gatineau (partie du Québec)": (45.427929, -75.710938),
15+ "Sherbrooke": (45.404047, -71.893171), "Trois-Rivières": (46.343077, -72.543383), "Saguenay": (48.428221, -71.062202)}
16+ETIQ = {"Ottawa-Gatineau (partie du Québec)": "Gatineau\n(RMR Ottawa-Gatineau,\npartie du Québec)"}
17+DECAL = {"Montréal": (0.0, -0.42), "Québec": (0.35, 0.18), "Ottawa-Gatineau (partie du Québec)": (0.0, 0.36), "Sherbrooke": (0.0, -0.28),
18+ "Trois-Rivières": (0.0, 0.25), "Saguenay": (0.0, 0.25)}
19+
20+def main():
21+ setup()
22+ pop = cas.population_rmr()[2025]
23+ k = math.cos(math.radians(47.0))
24+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.9))
25+ ref = pop["Montréal"]
26+ for nom, (lat, lon) in CENTRES.items():
27+ x, y = lon * k, lat
28+ r = 1400 * pop[nom] / ref # surface ∝ population
29+ ax.scatter([x], [y], s=max(r, 18), color=palette.cat[0], alpha=0.35, edgecolor=palette.cat[0], lw=1.0, zorder=3)
30+ ax.scatter([x], [y], s=10, color=palette.ink, zorder=4)
31+ dx, dy = DECAL[nom]
32+ ax.text(x + dx, y + dy, f"{ETIQ.get(nom, nom)}\n{pop[nom]:,.0f} hab.".replace(",", " "), ha="center", va="center", fontsize=7.5, color=palette.ink, zorder=5)
33+ # repères : fleuve Saint-Laurent schématisé par la droite reliant Montréal, Trois-Rivières et Québec (segments)
34+ pts = [CENTRES["Montréal"], CENTRES["Trois-Rivières"], CENTRES["Québec"]]
35+ ax.plot([p[1] * k for p in pts], [p[0] for p in pts], color=palette.grid, lw=5, alpha=0.9, zorder=1, solid_capstyle="round")
36+ ax.text((CENTRES["Montréal"][1] * k + CENTRES["Trois-Rivières"][1] * k) / 2 + 0.05, 46.05, "axe du Saint-Laurent (schématique)", fontsize=7, color=palette.ink2, rotation=24)
37+ ax.set_xlim(-75.9 * k - 0.6, -70.6 * k + 0.8); ax.set_ylim(44.7, 49.2)
38+ ax.set_xticks([lon * k for lon in (-76, -75, -74, -73, -72, -71)]); ax.set_xticklabels([f"{abs(l)}° O" for l in (-76, -75, -74, -73, -72, -71)])
39+ ax.set_yticks([45, 46, 47, 48, 49]); ax.set_yticklabels([f"{v}° N" for v in (45, 46, 47, 48, 49)])
40+ ax.set_aspect("equal"); ax.grid(True, lw=0.4)
41+ ax.text(-75.9 * k - 0.45, 49.0, "Surface des cercles proportionnelle à la population au 1er juillet 2025", fontsize=7.5, color=palette.ink2)
42+ out = savefig(fig, "ch04/ch04-fig-carte-rmr.pdf"); print("OK", out)
43+
44+if __name__ == "__main__":
45+ main()
added analysis/python/figures/ch04_ipln.py +33 −0
@@ -0,0 +1,33 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — Indice des prix des logements neufs (IPLN), total maison et terrain, déc. 2016 = 100, janvier 2010 → septembre 2026 :
3+Montréal, Québec, Gatineau (partie du Québec), Sherbrooke, Trois-Rivières, Canada. DONNÉES RÉELLES : Statistique Canada, tableau 18-10-0205-01.
4+Sortie : assets/figures/ch04/ch04-fig-ipln-rmr.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+import pandas as pd, matplotlib.pyplot as plt, matplotlib.dates as mdates
9+from traite.style import setup, palette, savefig, LARGEUR
10+import ch04_marches as cas
11+
12+ORDRE = ["Montréal", "Québec", "Gatineau (partie du Québec)", "Sherbrooke", "Trois-Rivières", "Canada"]
13+
14+def main():
15+ setup()
16+ df = cas.ipln(); df = df[(df["composante"] == "Total") & (df["date"] >= "2010-01-01")]
17+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.6))
18+ for i, geo in enumerate(ORDRE):
19+ s = df[df["geo"] == geo]
20+ ls = "--" if geo == "Canada" else "-"; lw = 2.0 if geo == "Montréal" else 1.4
21+ ax.plot(s["date"], s["indice"], color=palette.cat[i], ls=ls, lw=lw, label=geo.replace(" (partie du Québec)", " (partie QC)"))
22+ dy = {"Canada": 2.3, "Trois-Rivières": -2.0}.get(geo, 0.0)
23+ ax.text(s["date"].iloc[-1] + pd.Timedelta(days=60), s["indice"].iloc[-1] + dy, f"{s['indice'].iloc[-1]:.1f}".replace(".", ","), fontsize=7, va="center", color=palette.ink)
24+ ax.axhline(100, color=palette.ink2, lw=0.6, ls=":"); ax.text(pd.Timestamp("2010-02-01"), 101.5, "déc. 2016 = 100", fontsize=7.5, color=palette.ink2)
25+ ax.axvspan(pd.Timestamp("2020-03-01"), pd.Timestamp("2022-12-31"), color=palette.grid, alpha=0.5, lw=0)
26+ ax.text(pd.Timestamp("2020-04-15"), 72, "mars 2020 –\ndéc. 2022", fontsize=7, color=palette.ink2)
27+ ax.set_ylabel("Indice (déc. 2016 = 100)"); ax.set_xlim(pd.Timestamp("2010-01-01"), pd.Timestamp("2027-06-01")); ax.set_ylim(70, 170)
28+ ax.xaxis.set_major_locator(mdates.YearLocator(2)); ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
29+ ax.legend(loc="upper left", ncol=2, fontsize=7.5)
30+ out = savefig(fig, "ch04/ch04-fig-ipln-rmr.pdf"); print("OK", out)
31+
32+if __name__ == "__main__":
33+ main()
added analysis/python/figures/ch04_mutations.py +29 −0
@@ -0,0 +1,29 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — taux effectif des droits sur les mutations immobilières (droit / base d'imposition) selon la base, barèmes 2026 :
3+barème légal provincial (LDMI art. 2), Montréal, Gatineau (dès le 25 février 2026), Ville de Québec. Calcul de l'auteur (ch04_marches.droits_mutation)
4+à partir des tranches publiées (sources : LégisQuébec, montreal.ca, gatineau.ca, ville.quebec.qc.ca, consultées le 2026-10-31).
5+Sortie : assets/figures/ch04/ch04-fig-mutations.pdf"""
6+import sys, pathlib
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import numpy as np, matplotlib.pyplot as plt
10+from matplotlib.ticker import FuncFormatter
11+from traite.style import setup, palette, savefig, LARGEUR
12+import ch04_marches as cas
13+
14+def main():
15+ setup()
16+ prix = np.linspace(100000, 3500000, 681)
17+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.1))
18+ for i, (nom, bar) in enumerate(cas.BAREMES_2026.items()):
19+ te = np.array([cas.droits_mutation(p, bar) / p for p in prix]) * 100
20+ ax.plot(prix / 1000, te, color=palette.cat[i], lw=1.6 if i else 1.2, ls="--" if i == 0 else "-", label=nom)
21+ for p, lab, y in [(500000, "500 000 $ : médiane unifamiliale,\nprovince, T3 2026", 0.12), (860500, "860 500 $ : médiane plex,\nRMR Montréal, T3 2026", 0.5)]:
22+ ax.axvline(p / 1000, color=palette.ink2, lw=0.6, ls=":"); ax.text(p / 1000 + 25, y, lab, fontsize=6.8, color=palette.ink2)
23+ ax.set_xlabel("Base d'imposition (milliers de dollars)"); ax.set_ylabel("Taux effectif (% de la base)")
24+ ax.xaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:,.0f}".replace(",", " "))); ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.1f}".replace(".", ",")))
25+ ax.set_ylim(0, 3.2); ax.legend(loc="upper left", fontsize=7.5)
26+ out = savefig(fig, "ch04/ch04-fig-mutations.pdf"); print("OK", out)
27+
28+if __name__ == "__main__":
29+ main()
added analysis/python/figures/ch04_parc.py +37 −0
@@ -0,0 +1,37 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — structure du parc de logements : ménages privés selon le type de construction, Québec et Canada (parts), et
3+mode d'occupation par type au Québec. DONNÉES RÉELLES : Statistique Canada, Recensement de 2021, tableau 98-10-0240-01 (WDS, data/raw).
4+Sortie : assets/figures/ch04/ch04-fig-parc-types.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+import numpy as np, matplotlib.pyplot as plt
9+from matplotlib.ticker import FuncFormatter
10+from traite.style import setup, palette, savefig, LARGEUR
11+import ch04_marches as cas
12+
13+def main():
14+ setup()
15+ sp = cas.structure_parc().sort_values("qc_menages", ascending=True)
16+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"width_ratios": [1.15, 1]})
17+ y = np.arange(len(sp)); h = 0.38
18+ ax1.barh(y + h / 2, sp["qc_part"] * 100, h, color=palette.cat[0], label="Québec")
19+ ax1.barh(y - h / 2, sp["ca_part"] * 100, h, color=palette.cat[1], label="Canada")
20+ ax1.set_yticks(y); ax1.set_yticklabels(sp["court"]); ax1.set_xlabel("Part des ménages privés (%)")
21+ ax1.xaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.0f}")); ax1.legend(loc="lower right")
22+ for yi, (q, c) in enumerate(zip(sp["qc_part"], sp["ca_part"])):
23+ ax1.text(q * 100 + 0.6, yi + h / 2, f"{q*100:.1f}".replace(".", ","), va="center", fontsize=7, color=palette.ink)
24+ ax1.set_title("Type de construction", loc="left")
25+ prop = sp["qc_proprietaires"] / sp["qc_menages"] * 100; loc = sp["qc_locataires"] / sp["qc_menages"] * 100
26+ ax2.barh(y, prop, 0.6, color=palette.cat[0], label="Propriétaires")
27+ ax2.barh(y, loc, 0.6, left=prop, color=palette.cat[2], label="Locataires")
28+ ax2.set_yticks(y); ax2.set_yticklabels([]); ax2.set_xlim(0, 100); ax2.set_xlabel("Ménages du type (%), Québec")
29+ ax2.axvline(59.9, color=palette.ink2, lw=0.7, ls=":"); ax2.text(60.8, len(sp) - 0.55, "59,9 % (ensemble)", fontsize=7, color=palette.ink2)
30+ for yi, p in enumerate(prop):
31+ ax2.text(min(p, 93) - 1.5, yi, f"{p:.0f}", va="center", ha="right", fontsize=7, color="white" if p > 12 else palette.ink)
32+ ax2.legend(loc="upper center", bbox_to_anchor=(0.5, -0.16), ncol=2, fontsize=7); ax2.set_title("Mode d'occupation, Québec", loc="left")
33+ fig.tight_layout(w_pad=1.0)
34+ out = savefig(fig, "ch04/ch04-fig-parc-types.pdf"); print("OK", out)
35+
36+if __name__ == "__main__":
37+ main()
added analysis/python/figures/ch04_schl.py +33 −0
@@ -0,0 +1,33 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — marché locatif par RMR : taux d'inoccupation (octobre 2024 et 2025) et loyer moyen des logements de deux chambres (octobre 2025),
3+appartements d'initiative privée de trois logements et plus. DONNÉES RÉELLES (citées avec attribution, conditions SCHL) : SCHL, Enquête sur les
4+logements locatifs, octobre 2025, tableaux 1.1.1 et 1.1.2 (data/raw/schl_rml_2025/rmr-quebec-2025-fr.xlsx).
5+Sortie : assets/figures/ch04/ch04-fig-schl-rmr.pdf"""
6+import sys, pathlib
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import numpy as np, matplotlib.pyplot as plt
10+from matplotlib.ticker import FuncFormatter
11+from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars
12+import ch04_marches as cas
13+
14+def main():
15+ setup()
16+ sc = cas.schl_rmr(); sc.index = [{"Montréal": "Montréal", "Québec": "Québec", "Gatineau (partie du Québec)": "Gatineau\n(partie QC)", "Sherbrooke": "Sher-\nbrooke", "Trois-Rivières": "Trois-\nRivières", "Saguenay": "Sague-\nnay"}[i] for i in sc.index]
17+ x = np.arange(len(sc)); w = 0.38
18+ fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.2))
19+ ax1.bar(x - w / 2, sc["inocc_tous_2024"], w, color=palette.cat[1], label="Octobre 2024")
20+ ax1.bar(x + w / 2, sc["inocc_tous_2025"], w, color=palette.cat[0], label="Octobre 2025")
21+ ax1.axhline(3.0, color=palette.ink2, lw=0.7, ls=":"); ax1.text(-0.45, 3.1, "3 % (seuil de l'art. 11, L.Q. 2024, c. 23)", ha="left", fontsize=6.8, color=palette.ink2)
22+ for xi, v in zip(x + w / 2, sc["inocc_tous_2025"]): ax1.text(xi, v + 0.05, f"{v:.1f}".replace(".", ","), ha="center", fontsize=7)
23+ ax1.set_xticks(x); ax1.set_xticklabels(sc.index, fontsize=6.8); ax1.set_ylabel("Taux d'inoccupation, tous les logements (%)"); ax1.set_ylim(0, 4.6)
24+ ax1.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:.0f}")); ax1.legend(loc="upper left", fontsize=7, bbox_to_anchor=(0.0, 0.97))
25+ ax2.bar(x, sc["loyer_2_chambres_2025"], 0.6, color=palette.cat[0])
26+ for xi, v in zip(x, sc["loyer_2_chambres_2025"]): ax2.text(xi, v + 15, fmt_dollars(v), ha="center", fontsize=7)
27+ ax2.set_xticks(x); ax2.set_xticklabels(sc.index, fontsize=6.8); ax2.set_ylabel("Loyer moyen, deux chambres, octobre 2025 ($/mois)"); ax2.set_ylim(0, 1650)
28+ ax2.yaxis.set_major_formatter(FuncFormatter(lambda v, p: f"{v:,.0f}".replace(",", " ")))
29+ fig.tight_layout(w_pad=1.2)
30+ out = savefig(fig, "ch04/ch04-fig-schl-rmr.pdf"); print("OK", out)
31+
32+if __name__ == "__main__":
33+ main()
added analysis/python/figures/ch04_taux.py +30 −0
@@ -0,0 +1,30 @@
1+#!/usr/bin/env python3
2+"""Figure ch04 — taux d'intérêt 2000-2026 : taux hypothécaire conventionnel 5 ans affiché (V80691335, hebdomadaire, 2000→), rendement de
3+l'obligation du Canada 5 ans (BD.CDN.5YR.DQ.YLD, quotidien, 2000→) et cible du taux du financement à un jour (V39079, disponible à partir
4+d'avril 2009 dans l'extraction Valet). DONNÉES RÉELLES : Banque du Canada, API Valet (data/raw/bdc_valet). Sortie : assets/figures/ch04/ch04-fig-taux-2000-2026.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+import pandas as pd, matplotlib.pyplot as plt, matplotlib.dates as mdates
9+from traite.style import setup, palette, savefig, LARGEUR
10+import ch04_marches as cas
11+
12+def main():
13+ setup()
14+ b = cas.bdc()["series"]
15+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.9))
16+ ax.plot(b["hyp5"].index, b["hyp5"].values, color=palette.cat[0], lw=1.6, label="Taux hypothécaire affiché, 5 ans (conventionnel)")
17+ obl = b["obl5"].resample("W").mean()
18+ ax.plot(obl.index, obl.values, color=palette.cat[1], lw=1.2, label="Obligation du Canada, 5 ans (moyenne hebdomadaire)")
19+ ax.step(b["directeur"].index, b["directeur"].values, color=palette.cat[2], lw=1.4, where="post", label="Taux directeur (cible du financement à un jour, dès 2009)")
20+ for t, lab, y in [("2008-09-15", "crise\nfinancière", 9.3), ("2020-03-11", "pandémie", 9.3), ("2022-03-02", "resserrement\n2022-2023", 9.3)]:
21+ ax.axvline(pd.Timestamp(t), color=palette.ink2, lw=0.6, ls=":"); ax.text(pd.Timestamp(t) + pd.Timedelta(days=40), y, lab, fontsize=7, color=palette.ink2, va="top")
22+ ax.set_ylabel("Taux (% annuel)"); ax.set_ylim(0, 9.6); ax.set_xlim(pd.Timestamp("2000-01-01"), pd.Timestamp("2027-01-01"))
23+ ax.xaxis.set_major_locator(mdates.YearLocator(2)); ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
24+ ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.1), ncol=1, fontsize=7.5)
25+ ax.annotate(f"{b['hyp5'].iloc[-1]:.2f} %".replace(".", ","), xy=(b["hyp5"].index[-1], b["hyp5"].iloc[-1]), xytext=(-40, 10), textcoords="offset points", fontsize=7.5, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
26+ ax.annotate(f"{b['directeur'].iloc[-1]:.2f} %".replace(".", ","), xy=(b["directeur"].index[-1], b["directeur"].iloc[-1]), xytext=(-40, -14), textcoords="offset points", fontsize=7.5, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6))
27+ out = savefig(fig, "ch04/ch04-fig-taux-2000-2026.pdf"); print("OK", out)
28+
29+if __name__ == "__main__":
30+ main()
added analysis/python/figures/ch05_capitalisation.py +33 −0
@@ -0,0 +1,33 @@
1+#!/usr/bin/env python3
2+"""Figure ch05 — sensibilité de la capitalisation d'un écart de taxe foncière : perte de valeur (en % de la valeur) selon
3+l'écart de taux (points de base de la valeur) pour quatre taux de capitalisation et deux degrés de capitalisation.
4+Calculs : ch05_rente.capitalisation_taxe (formule exacte) ; aucune donnée observée.
5+Sortie : assets/figures/ch05/ch05-fig-capitalisation-taxe.pdf"""
6+import sys, pathlib
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import numpy as np
10+import matplotlib.pyplot as plt
11+from traite.style import setup, palette, savefig, LARGEUR
12+import ch05_rente as c5
13+
14+def main():
15+ setup()
16+ pb = np.linspace(0, 100, 101); tau0 = 0.0070
17+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.2))
18+ for k, i in enumerate([0.04, 0.05, 0.06, 0.07]):
19+ exact = np.array([c5.capitalisation_taxe(1.0, tau0, tau0 + p / 1e4, i)["dV_pct_exact"] for p in pb]) * 100
20+ ax.plot(pb, exact, color=palette.cat[k], lw=1.7, label=f"$i$ = {i*100:.0f} %, capitalisation complète (exacte)")
21+ if i == 0.05:
22+ ax.plot(pb, 0.6 * exact, color=palette.cat[k], lw=1.2, ls="--", label="$i$ = 5 %, capitalisation à 60 %")
23+ ax.plot(pb, -pb / 1e4 / i * 100, color=palette.cat[k], lw=0.9, ls=":", label="$i$ = 5 %, approximation $-\\Delta T / i$")
24+ for x, lab in [(10, "10 pb"), (22.4, "écart nominal\nQuébec–Gatineau 2026"), (50, "50 pb")]:
25+ ax.axvline(x, color=palette.grid, lw=0.8, ls="--"); ax.text(x + 1, -0.6, lab, fontsize=6.8, color=palette.ink2, va="top")
26+ ax.set_xlim(0, 100); ax.set_ylim(-25, 0)
27+ ax.set_xlabel("Écart de taux de taxe foncière (points de base de la valeur marchande)")
28+ ax.set_ylabel("Variation de la valeur (%)")
29+ ax.legend(fontsize=6.8, loc="lower left")
30+ out = savefig(fig, "ch05/ch05-fig-capitalisation-taxe.pdf"); print("OK", out)
31+
32+if __name__ == "__main__":
33+ main()
added analysis/python/figures/ch05_cas1_distance.py +37 −0
@@ -0,0 +1,37 @@
1+#!/usr/bin/env python3
2+"""Figure ch05 — prix par mètre carré habitable et distance au centre, cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES, graine 20261031).
3+Nuage des 600 ventes, médianes par tranche de distance et gradient exponentiel ajusté (ln(P/S) sur d).
4+Sortie : assets/figures/ch05/ch05-fig-prix-distance-cas1.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+import numpy as np
9+import matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers
11+from traite import cas1
12+from traite import hedonique as h
13+import ch05_rente as c5
14+
15+def main():
16+ setup()
17+ v = cas1.charger()["ventes"]; v = v.assign(prix_m2=v["prix_vente"] / v["superficie_habitable_m2"])
18+ tr = c5.tranches_distance(v)
19+ d = v["dist_centre_km"].values; r = h.mco(np.column_stack([np.ones_like(d), d]), np.log(v["prix_m2"].values))
20+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.4))
21+ ax.scatter(d, v["prix_m2"], s=9, color=palette.cat[0], alpha=0.35, lw=0, label="Ventes (n = 600)")
22+ ax.plot(tr["dist_mediane"], tr["prix_m2_median"], color=palette.ink, marker="o", ms=5, lw=1.6, label="Médiane par tranche de distance")
23+ xx = np.linspace(0, 33, 200)
24+ ax.plot(xx, np.exp(r["beta"][0] + r["beta"][1] * xx), color=palette.cat[3], lw=1.6, ls="--", label=f"Gradient exponentiel ajusté ({-r['beta'][1]*100:.2f} %/km)")
25+ for sct, xpos in [("Hull", 1.6), ("Gatineau", 7.4), ("Aylmer", 10.9), ("Buckingham et\nMasson-Angers", 28.0)]:
26+ ax.text(xpos, 5150, sct, ha="center", fontsize=7, color=palette.ink2)
27+ s = cas1.charger()["sujet"]
28+ ax.axvline(s["dist_centre_km"], color=palette.ink2, lw=0.8, ls=":")
29+ ax.text(s["dist_centre_km"] + 0.3, 1600, "sujet (2,1 km)", fontsize=7, color=palette.ink2)
30+ ax.set_xlim(-0.5, 33.5); ax.set_ylim(1500, 5400)
31+ ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_milliers))
32+ ax.set_xlabel("Distance au centre de référence (km)"); ax.set_ylabel("Prix par m² habitable ($/m²)")
33+ ax.legend(loc="lower center", bbox_to_anchor=(0.52, 0.0), fontsize=7.5)
34+ out = savefig(fig, "ch05/ch05-fig-prix-distance-cas1.pdf"); print("OK", out)
35+
36+if __name__ == "__main__":
37+ main()
added analysis/python/figures/ch05_gradient.py +49 −0
@@ -0,0 +1,49 @@
1+#!/usr/bin/env python3
2+"""Figure ch05 — gradients du modèle monocentrique simulé (PARAMÈTRES PÉDAGOGIQUES DÉCLARÉS dans ch05_rente.PARAMS).
3+Panneau gauche : courbes d'enchère linéaires de trois usages et leur enveloppe (Alonso) ; panneau droit : prix du
4+logement, rente foncière et densité relative du modèle de Muth–Mills, avec l'approximation exponentielle.
5+Sortie : assets/figures/ch05/ch05-fig-gradient-monocentrique.pdf"""
6+import sys, pathlib
7+ROOT = pathlib.Path(__file__).resolve().parents[3]
8+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
9+import numpy as np
10+import matplotlib.pyplot as plt
11+from traite.style import setup, palette, savefig, LARGEUR
12+import ch05_rente as c5
13+
14+def main():
15+ setup()
16+ P = c5.PARAMS
17+ fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.1), gridspec_kw={"wspace": 0.32})
18+ # --- Alonso
19+ d = np.linspace(0, 36, 400)
20+ env, courbes = c5.enveloppe_alonso(d)
21+ for k, (nom, y) in enumerate(courbes.items()):
22+ a1.plot(d, np.clip(y, 0, None), color=palette.cat[k], lw=1.1, alpha=0.85, label=nom)
23+ a1.plot(d, env, color=palette.ink, lw=2.0, label="Enveloppe (rente de marché)")
24+ a1.axhline(P["ra"], color=palette.ink2, lw=0.8, ls=":")
25+ a1.text(0.5, P["ra"] + 4, "rente agricole", fontsize=7, color=palette.ink2)
26+ (R1, t1), (R2, t2), (R3, t3) = c5.ALONSO.values()
27+ for x in (c5.frontiere_usages(R1, t1, R2, t2), c5.frontiere_usages(R2, t2, R3, t3), (R3 - P["ra"]) / t3):
28+ a1.axvline(x, color=palette.grid, lw=0.8, ls="--")
29+ a1.set_ylim(0, 140); a1.set_xlim(0, 36)
30+ a1.set_xlabel("Distance au centre (km)"); a1.set_ylabel("Rente foncière ($/m² de terrain/an)")
31+ a1.set_title("A. Courbes d'enchère et enveloppe", loc="left", fontsize=8.5)
32+ a1.legend(fontsize=6.8, loc="upper right")
33+ # --- Muth–Mills
34+ dbar = c5.frontiere_urbaine(P)
35+ d2 = np.linspace(0, dbar, 400)
36+ a2.plot(d2, c5.prix_logement(d2) / P["p0"], color=palette.cat[0], lw=1.8, label="Prix du logement $p(d)/p_0$")
37+ a2.plot(d2, c5.prix_exponentiel(d2) / P["p0"], color=palette.cat[0], lw=1.0, ls="--", label="Approximation exponentielle")
38+ a2.plot(d2, c5.rente_fonciere(d2) / P["r0"], color=palette.cat[1], lw=1.8, label="Rente foncière $r(d)/r_0$")
39+ a2.plot(d2, c5.densite(d2), color=palette.cat[2], lw=1.8, label="Densité $D(d)/D_0$")
40+ a2.axvline(dbar, color=palette.ink2, lw=0.8, ls=":")
41+ a2.text(dbar - 0.6, 0.47, f"frontière\nurbaine\n{dbar:.1f} km", fontsize=7, color=palette.ink2, ha="right")
42+ a2.set_xlim(0, dbar + 1); a2.set_ylim(0, 1.05)
43+ a2.set_xlabel("Distance au centre (km)"); a2.set_ylabel("Niveau relatif au centre (= 1)")
44+ a2.set_title("B. Gradients du modèle de Muth–Mills", loc="left", fontsize=8.5)
45+ a2.legend(fontsize=6.8, loc="upper right")
46+ out = savefig(fig, "ch05/ch05-fig-gradient-monocentrique.pdf"); print("OK", out)
47+
48+if __name__ == "__main__":
49+ main()
added analysis/python/figures/ch05_rmr.py +39 −0
@@ -0,0 +1,39 @@
1+#!/usr/bin/env python3
2+"""Figure ch05 — valeur par logement par RMR du portefeuille du cas 4 (DONNÉES SYNTHÉTIQUES, graine 20261031) et
3+décomposition de l'écart logarithmique avec Montréal entre le RNE par logement et le taux de capitalisation.
4+Sortie : assets/figures/ch05/ch05-fig-valeur-logement-rmr.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))
8+import numpy as np
9+import matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars
11+from traite import cas4
12+import ch05_rente as c5
13+
14+def main():
15+ setup()
16+ dec = c5.decomposition_rmr(cas4.charger()["portefeuille"])
17+ fig, (a1, a2) = plt.subplots(1, 2, figsize=(LARGEUR, 3.0), gridspec_kw={"wspace": 0.45, "width_ratios": [1, 1.15]})
18+ y = np.arange(len(dec))[::-1]
19+ a1.barh(y, dec["valeur_par_logement"], color=palette.cat[0], height=0.62)
20+ for yi, (rmr, row) in zip(y, dec.iterrows()):
21+ a1.text(row["valeur_par_logement"] + 3000, yi, f"{fmt_dollars(row['valeur_par_logement'])} ({row['taux_pondere']*100:.2f} %)", va="center", fontsize=7, color=palette.ink)
22+ a1.set_yticks(y); a1.set_yticklabels(dec.index); a1.set_xlim(0, 330000)
23+ a1.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f"{x/1000:.0f} k$"))
24+ a1.set_xlabel("Valeur par logement (taux global pondéré)"); a1.grid(axis="y", visible=False)
25+ a1.set_title("A. Valeur par logement, 2026", loc="left", fontsize=8.5)
26+ sub = dec.drop(index="Montréal"); y2 = np.arange(len(sub))[::-1]
27+ a2.barh(y2, sub["contrib_rne"], color=palette.cat[1], height=0.62, label="Effet du RNE par logement")
28+ a2.barh(y2, sub["contrib_taux"], left=np.where(sub["contrib_rne"] < 0, sub["contrib_rne"], 0), color=palette.cat[2], height=0.62, label="Effet du taux de capitalisation")
29+ for yi, (rmr, row) in zip(y2, sub.iterrows()):
30+ a2.plot(row["ecart_ln_V_n"], yi, marker="D", ms=5, color=palette.ink, mec=palette.surface)
31+ a2.axvline(0, color=palette.ink2, lw=0.8)
32+ a2.set_yticks(y2); a2.set_yticklabels(sub.index); a2.set_xlim(-0.8, 0.15)
33+ a2.set_xlabel("Écart logarithmique avec Montréal"); a2.grid(axis="y", visible=False)
34+ a2.set_title("B. Décomposition de l'écart (losange = total)", loc="left", fontsize=8.5)
35+ a2.legend(fontsize=6.8, loc="upper left")
36+ out = savefig(fig, "ch05/ch05-fig-valeur-logement-rmr.pdf"); print("OK", out)
37+
38+if __name__ == "__main__":
39+ main()
added analysis/python/figures/ch05_surface.py +44 −0
@@ -0,0 +1,44 @@
1+#!/usr/bin/env python3
2+"""Figure ch05 — surface de rente bicentrique simulée (courbes de niveau) : enveloppe de deux surfaces d'enchère
3+exponentielles (centre principal et sous-centre) au-dessus d'une rente agricole ; PARAMÈTRES PÉDAGOGIQUES DÉCLARÉS.
4+Sortie : assets/figures/ch05/ch05-fig-surface-rente.pdf"""
5+import sys, pathlib
6+ROOT = pathlib.Path(__file__).resolve().parents[3]
7+sys.path.insert(0, str(ROOT / "analysis/python"))
8+import numpy as np
9+import matplotlib.pyplot as plt
10+from traite.style import setup, palette, savefig, LARGEUR
11+
12+CENTRES = [ # (x, y, rente au centre $/m²/an, gradient exponentiel 1/km, nom)
13+ (0.0, 0.0, 80.0, 0.085, "Centre principal"),
14+ (13.0, 5.0, 36.0, 0.13, "Sous-centre"),
15+]
16+RA = 2.0
17+
18+def surface(X, Y):
19+ r = np.full_like(X, RA)
20+ for cx, cy, r0, g, _ in CENTRES:
21+ d = np.hypot(X - cx, Y - cy)
22+ r = np.maximum(r, r0 * np.exp(-g * d))
23+ return r
24+
25+def main():
26+ setup()
27+ x = np.linspace(-22, 30, 420); y = np.linspace(-18, 22, 340)
28+ X, Y = np.meshgrid(x, y); R = surface(X, Y)
29+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.9))
30+ niveaux = [2, 4, 6, 10, 15, 20, 30, 40, 55, 70]
31+ cf = ax.contourf(X, Y, R, levels=niveaux + [85], cmap=palette.cmap_seq(), alpha=0.95)
32+ cs = ax.contour(X, Y, R, levels=niveaux, colors=palette.ink, linewidths=0.5, alpha=0.6)
33+ ax.clabel(cs, fmt=lambda v: f"{v:.0f}", fontsize=6.5, colors=palette.ink, inline=True)
34+ for cx, cy, r0, g, nom in CENTRES:
35+ ax.plot(cx, cy, marker="*", ms=9, color=palette.ink, mec=palette.surface)
36+ ax.annotate(f"{nom}\n$r_0$ = {r0:.0f} \\$/m²/an, $\\gamma$ = {g:.3f}/km", (cx, cy), xytext=(8, -26) if cx == 0 else (-118, 14), textcoords="offset points", fontsize=7, color=palette.ink)
37+ cb = fig.colorbar(cf, ax=ax, pad=0.02, shrink=0.9); cb.set_label("Rente foncière ($/m² de terrain/an)")
38+ ax.set_aspect("equal"); ax.set_xlabel("km (est-ouest)"); ax.set_ylabel("km (nord-sud)")
39+ ax.grid(False)
40+ ax.set_title("Enveloppe de deux surfaces d'enchère exponentielles au-dessus de la rente agricole (2 $/m²/an)", loc="left", fontsize=8.5)
41+ out = savefig(fig, "ch05/ch05-fig-surface-rente.pdf"); print("OK", out)
42+
43+if __name__ == "__main__":
44+ main()
modified analysis/python/figures/ch06_emphyteose.py +4 −4
@@ -18,14 +18,14 @@ def main():
18 res = [cas.emphyteose(noi, cas.RENTE_EMPH, int(k)) for k in n] 18 res = [cas.emphyteose(noi, cas.RENTE_EMPH, int(k)) for k in n]
19 emph = np.array([r["V_emph"] for r in res]) / 1e6; rentes = np.array([r["V_rentes"] for r in res]) / 1e6 19 emph = np.array([r["V_emph"] for r in res]) / 1e6; rentes = np.array([r["V_rentes"] for r in res]) / 1e6
20 reprise = np.array([r["V_reprise"] for r in res]) / 1e6; V = res[0]["V_pleine"] / 1e6 20 reprise = np.array([r["V_reprise"] for r in res]) / 1e6; V = res[0]["V_pleine"] / 1e6
21− fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) 21+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.5))
22 ax.stackplot(n, emph, rentes, reprise, colors=[palette.cat[0], palette.cat[1], palette.cat[2]], alpha=0.85, 22 ax.stackplot(n, emph, rentes, reprise, colors=[palette.cat[0], palette.cat[1], palette.cat[2]], alpha=0.85,
23 labels=["droit de l'emphytéote (RNE − rente)", "propriétaire : rentes actualisées", "propriétaire : reprise de l'immeuble au terme"]) 23 labels=["droit de l'emphytéote (RNE − rente)", "propriétaire : rentes actualisées", "propriétaire : reprise de l'immeuble au terme"])
24 ax.axhline(V, color=palette.ink, lw=0.8); ax.text(99, V + 0.03, f"pleine propriété {fmt_dollars(V*1e6)}", ha="right", va="bottom", fontsize=7.5) 24 ax.axhline(V, color=palette.ink, lw=0.8); ax.text(99, V + 0.03, f"pleine propriété {fmt_dollars(V*1e6)}", ha="right", va="bottom", fontsize=7.5)
25− for seuil, txt in ((40, "40 ans : bail assimilé à un transfert\n(D-15.1, art. 1)"), (50, "50 ans : copropriété divise\npossible (art. 1040 C.c.Q.)")): 25+ ax.axvline(40, color=palette.ink2, ls=":", lw=1); ax.text(39, 0.42, "40 ans : bail assimilé\nà un transfert (D-15.1, art. 1)", fontsize=7, color=palette.ink, ha="right", va="bottom")
26− ax.axvline(seuil, color=palette.ink2, ls=":", lw=1); ax.text(seuil + 1, 0.12, txt, fontsize=7, color=palette.ink2, va="bottom") 26+ ax.axvline(50, color=palette.ink2, ls=":", lw=1); ax.text(51, 0.12, "50 ans : copropriété divise\npossible (art. 1040 C.c.Q.)", fontsize=7, color=palette.ink, ha="left", va="bottom")
27 ax.set_xlabel("Durée restante de l'emphytéose (années)"); ax.set_ylabel("Valeur (millions de $)"); ax.set_xlim(1, 100); ax.set_ylim(0, V * 1.12) 27 ax.set_xlabel("Durée restante de l'emphytéose (années)"); ax.set_ylabel("Valeur (millions de $)"); ax.set_xlim(1, 100); ax.set_ylim(0, V * 1.12)
28− ax.legend(loc="upper left", fontsize=7.5, ncol=1, bbox_to_anchor=(0.0, 0.98)); ax.grid(axis="x", visible=False) 28+ ax.legend(loc="upper center", fontsize=7.5, ncol=3, bbox_to_anchor=(0.5, -0.2)); ax.grid(axis="x", visible=False)
29 fig.tight_layout() 29 fig.tight_layout()
30 out = savefig(fig, "ch06/ch06-fig-emphyteose.pdf"); print("OK", out) 30 out = savefig(fig, "ch06/ch06-fig-emphyteose.pdf"); print("OK", out)
31 31
modified analysis/python/figures/ch07_charges.py +1 −1
@@ -11,7 +11,7 @@ import ch07_servitudes_copro as cas
11 def main(): 11 def main():
12 setup() 12 setup()
13 qp = cas.quotes_parts(); A = cas.cotisation_seuil(); rep = cas.repartition_charges(A, qp) 13 qp = cas.quotes_parts(); A = cas.cotisation_seuil(); rep = cas.repartition_charges(A, qp)
14− noms = [f"{n} ({s:.0f} m², q = {q:.4f})" for n, s, q in zip(qp["noms"], qp["superficies"], qp["q"])] 14+ noms = [f"{n} ({s:.0f} m², q = {q:.4f})".replace(".", ",") for n, s, q in zip(qp["noms"], qp["superficies"], qp["q"])]
15 blocs = [("Charges courantes (q_i × budget)", rep["par_fraction_courantes"], palette.cat[0]), 15 blocs = [("Charges courantes (q_i × budget)", rep["par_fraction_courantes"], palette.cat[0]),
16 ("Fonds de prévoyance (q_i × A*)", rep["par_fraction_fonds"], palette.cat[1]), 16 ("Fonds de prévoyance (q_i × A*)", rep["par_fraction_fonds"], palette.cat[1]),
17 ("Fonds d'auto assurance (q_i)", rep["par_fraction_auto"], palette.cat[2]), 17 ("Fonds d'auto assurance (q_i)", rep["par_fraction_auto"], palette.cat[2]),
modified analysis/python/figures/ch07_fonds.py +4 −4
@@ -13,17 +13,17 @@ def main():
13 A = cas.cotisation_seuil(); regle = 0.005 * cas.VALEUR_RECONSTRUCTION 13 A = cas.cotisation_seuil(); regle = 0.005 * cas.VALEUR_RECONSTRUCTION
14 t_act, t_regle, t_star = cas.trajectoire(cas.COTISATION_ACTUELLE), cas.trajectoire(regle), cas.trajectoire(A) 14 t_act, t_regle, t_star = cas.trajectoire(cas.COTISATION_ACTUELLE), cas.trajectoire(regle), cas.trajectoire(A)
15 ans = np.arange(0, cas.HORIZON + 1) + 2026 15 ans = np.arange(0, cas.HORIZON + 1) + 2026
16− fig, ax = plt.subplots(figsize=(LARGEUR, 3.6)) 16+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.8))
17 ax.bar(ans[1:], -t_star["D"][1:], color=palette.grid, width=0.7, label="Réparations majeures prévues (dépenses, indexées à 2,5 %)") 17 ax.bar(ans[1:], -t_star["D"][1:], color=palette.grid, width=0.7, label="Réparations majeures prévues (dépenses, indexées à 2,5 %)")
18 ax.plot(ans, t_act["F"], color=palette.cat[3], lw=1.8, label=f"Cotisation actuelle : {cas.COTISATION_ACTUELLE:,.0f} $ (indexée)".replace(",", " ")) 18 ax.plot(ans, t_act["F"], color=palette.cat[3], lw=1.8, label=f"Cotisation actuelle : {cas.COTISATION_ACTUELLE:,.0f} $ (indexée)".replace(",", " "))
19− ax.plot(ans, t_regle["F"], color=palette.cat[2], lw=1.8, ls="--", label=f"Règle du 0,5 % de la valeur de reconstruction : {regle:,.0f} $".replace(",", " ")) 19+ ax.plot(ans, t_regle["F"], color=palette.cat[2], lw=1.8, ls="--", label=f"Règle du 0@5 % de la valeur de reconstruction : {regle:,.0f} $".replace(",", " ").replace("@", ","))
20 ax.plot(ans, t_star["F"], color=palette.cat[0], lw=2.0, label=f"Cotisation de seuil A* : {A:,.0f} $ (indexée)".replace(",", " ")) 20 ax.plot(ans, t_star["F"], color=palette.cat[0], lw=2.0, label=f"Cotisation de seuil A* : {A:,.0f} $ (indexée)".replace(",", " "))
21 ax.axhline(0, color=palette.ink2, lw=0.8) 21 ax.axhline(0, color=palette.ink2, lw=0.8)
22 ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)) 22 ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars))
23 ax.set_xlabel("Année"); ax.set_ylabel("Solde du fonds en fin d'année") 23 ax.set_xlabel("Année"); ax.set_ylabel("Solde du fonds en fin d'année")
24 ax.set_xlim(2025.5, 2051.5) 24 ax.set_xlim(2025.5, 2051.5)
25− ax.legend(loc="upper left", fontsize=7.2) 25+ ax.legend(loc="upper center", bbox_to_anchor=(0.45, -0.17), ncol=2, fontsize=7.2)
26− ax.annotate("an 14 : fenêtres\n(solde A* = 0)", xy=(2039, 0), xytext=(2041.5, 120000), fontsize=7.5, color=palette.ink2, 26+ ax.annotate("an 14 : fenêtres\n(solde A* = 0)", xy=(2039, 0), xytext=(2043.5, -80000), fontsize=7.5, color=palette.ink2,
27 arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7)) 27 arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7))
28 out = savefig(fig, "ch07/ch07-fig-fonds-prevoyance.pdf"); print("OK", out) 28 out = savefig(fig, "ch07/ch07-fig-fonds-prevoyance.pdf"); print("OK", out)
29 29
modified analysis/python/figures/ch07_paires.py +1 −1
@@ -26,7 +26,7 @@ def main():
26 ax.set_xlim(avec.min() - 15000, sans.max() + 60000) 26 ax.set_xlim(avec.min() - 15000, sans.max() + 60000)
27 ax.set_xlabel("Prix de vente (dollars de 2026, données synthétiques)") 27 ax.set_xlabel("Prix de vente (dollars de 2026, données synthétiques)")
28 ax.grid(axis="y", visible=False) 28 ax.grid(axis="y", visible=False)
29− ax.set_title((f"Différence moyenne {p['moyenne']:,.0f} \\$ (IC 95 % : {p['ic95'][0]:,.0f} à {p['ic95'][1]:,.0f} \\$), soit {p['effet_relatif_moyen']*100:.1f} % du prix").replace(",", " ").replace(".", ","), 29+ ax.set_title((f"Différence moyenne {p['moyenne']:,.0f} \\$ (IC 95 % : {p['ic95'][0]:,.0f} à {p['ic95'][1]:,.0f} \\$)@ soit {p['effet_relatif_moyen']*100:.1f} % du prix").replace(",", " ").replace(".", ",").replace("@", ","),
30 loc="left", fontsize=8.5) 30 loc="left", fontsize=8.5)
31 ax.legend(loc="lower right", fontsize=7.5) 31 ax.legend(loc="lower right", fontsize=7.5)
32 out = savefig(fig, "ch07/ch07-fig-paires-servitude.pdf"); print("OK", out) 32 out = savefig(fig, "ch07/ch07-fig-paires-servitude.pdf"); print("OK", out)
modified analysis/python/figures/ch07_sensibilite.py +5 −6
@@ -11,16 +11,15 @@ import ch07_servitudes_copro as cas
11 def main(): 11 def main():
12 setup() 12 setup()
13 s = cas.sensibilite_cotisation(); regle = 0.005 * cas.VALEUR_RECONSTRUCTION 13 s = cas.sensibilite_cotisation(); regle = 0.005 * cas.VALEUR_RECONSTRUCTION
14− fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) 14+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.4))
15 for i, (r, row) in enumerate(s["grille"].items()): 15 for i, (r, row) in enumerate(s["grille"].items()):
16 ax.plot([p * 100 for p in s["inflations"]], row, marker="o", color=palette.cat[i], label=f"rendement du fonds r = {r*100:.0f} %") 16 ax.plot([p * 100 for p in s["inflations"]], row, marker="o", color=palette.cat[i], label=f"rendement du fonds r = {r*100:.0f} %")
17− ax.axhline(regle, color=palette.ink2, lw=0.9, ls=":") 17+ ax.axhline(regle, color=palette.ink2, lw=0.9, ls=":", label="règle du 0,5 % de la valeur de reconstruction (21 000 $)")
18− ax.text(1.52, regle + 250, "règle du 0,5 % de la valeur de reconstruction (21 000 $)", fontsize=7.5, color=palette.ink2) 18+ ax.axhline(cas.COTISATION_ACTUELLE, color=palette.cat[3], lw=0.9, ls=":", label="cotisation actuelle (12 000 $)")
19− ax.axhline(cas.COTISATION_ACTUELLE, color=palette.cat[3], lw=0.9, ls=":") 19+ ax.set_xticks([p * 100 for p in s["inflations"]]); ax.set_xticklabels([f"{p*100:.1f}".replace(".", ",") for p in s["inflations"]])
20− ax.text(1.52, cas.COTISATION_ACTUELLE + 250, "cotisation actuelle (12 000 $)", fontsize=7.5, color=palette.cat[3])
21 ax.set_xlabel("Hausse annuelle des coûts de construction π (%)"); ax.set_ylabel("Cotisation de seuil A* (an 1)") 20 ax.set_xlabel("Hausse annuelle des coûts de construction π (%)"); ax.set_ylabel("Cotisation de seuil A* (an 1)")
22 ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)); ax.set_ylim(10000, 26000) 21 ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)); ax.set_ylim(10000, 26000)
23− ax.legend(loc="lower right", fontsize=7.5) 22+ ax.legend(loc="upper center", bbox_to_anchor=(0.45, -0.2), ncol=2, fontsize=7.5)
24 out = savefig(fig, "ch07/ch07-fig-sensibilite-cotisation.pdf"); print("OK", out) 23 out = savefig(fig, "ch07/ch07-fig-sensibilite-cotisation.pdf"); print("OK", out)
25 24
26 if __name__ == "__main__": 25 if __name__ == "__main__":
added analysis/python/figures/ch09_ratios.py +95 −0
@@ -0,0 +1,95 @@
1+#!/usr/bin/env python3
2+"""Figures du chapitre 9 (DONNÉES SYNTHÉTIQUES du cas 1, graine 20261031) :
3+ ch09-fig-ratios-distribution.pdf — distribution des ratios rôle/prix, bruts et rajustés à la date de référence, bornes d'épuration du r. 10
4+ ch09-fig-ratios-temps.pdf — ratio médian par trimestre de vente : dérive des ratios bruts, stabilité après rajustement
5+ ch09-fig-prb.pdf — nuage (r − médiane)/médiane contre log2 de la valeur proxy et droite du PRB
6+ ch09-fig-sensibilite-contestation.pdf — gain net d'une contestation sur le cycle triennal selon la valeur réelle conclue et le taux de voisinage
7+Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch09_ratios.py"""
8+from __future__ import annotations
9+import sys, pathlib
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+import numpy as np, pandas as pd
13+import matplotlib.pyplot as plt
14+from matplotlib.ticker import FuncFormatter, PercentFormatter
15+from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars
16+import ch09_ratios as cas
17+
18+
19+def fig_distribution(et):
20+ v = et["v"]; ep = et["epuration"]
21+ bins = np.arange(0.64, 1.24, 0.02)
22+ fig, axes = plt.subplots(1, 2, figsize=(LARGEUR, 2.9), sharey=True)
23+ for ax, col, titre, c in [(axes[0], "r_brut", "Ratios bruts : rôle / prix de vente", palette.cat[0]), (axes[1], "r_aj", "Ratios rajustés au 1er juillet 2024", palette.cat[1])]:
24+ r = v[col]; ax.hist(r, bins=bins, color=c, alpha=0.85, edgecolor=palette.surface, linewidth=0.4)
25+ med = r.median(); ax.axvline(med, color=palette.ink, lw=1.2)
26+ ax.text(med + 0.01, ax.get_ylim()[1] * 0.93, f"médiane {med:.3f}".replace(".", ","), fontsize=8, color=palette.ink)
27+ ax.set_title(titre, loc="left", fontsize=8.5); ax.set_xlabel("Ratio valeur au rôle / prix")
28+ ax.set_xlim(0.64, 1.24)
29+ axes[0].set_ylabel("Nombre de ventes")
30+ ax = axes[1]
31+ for b in (ep["borne_basse"], ep["borne_haute"]):
32+ ax.axvline(b, color=palette.cat[3], lw=1.0, ls="--")
33+ ax.text(ep["borne_haute"] + 0.01, ax.get_ylim()[1] * 0.55, f"médiane ± 2σ\n{ep['n_retires']} ventes\nretirées", fontsize=7.5, color=palette.cat[3])
34+ for ax in axes:
35+ ax.axvspan(0.90, 1.10, color=palette.grid, alpha=0.35, lw=0, zorder=0)
36+ axes[0].text(0.905, axes[0].get_ylim()[1] * 0.05, "niveau IAAO 0,90–1,10", fontsize=7, color=palette.ink2)
37+ fig.suptitle(f"COD {et['brut']['COD']:.1f} → {et['aj']['COD']:.1f} ; PRD {et['brut']['PRD']:.3f} → {et['aj']['PRD']:.3f} ; proportion médiane après épuration {et['PM']:.3f}".replace(".", ","),
38+ fontsize=8, color=palette.ink2, x=0.02, ha="left", y=1.02)
39+ print("OK", savefig(fig, "ch09/ch09-fig-ratios-distribution.pdf"))
40+
41+
42+def fig_temps(et):
43+ t = et["trimestres"].copy(); t.index = pd.PeriodIndex(t.index, freq="Q").to_timestamp()
44+ fig, ax = plt.subplots(figsize=(LARGEUR, 2.8))
45+ ax.plot(t.index, t["r_brut"], marker="o", ms=3.5, color=palette.cat[0], label="Ratio médian brut (rôle / prix)")
46+ ax.plot(t.index, t["r_aj"], marker="s", ms=3.5, color=palette.cat[1], label="Ratio médian après rajustement au 1er juillet 2024")
47+ ax.axhline(et["PM"], color=palette.ink2, lw=0.9, ls=":")
48+ ax.text(t.index[0], et["PM"] + 0.004, f"proportion médiane {et['PM']:.3f}".replace(".", ","), fontsize=7.5, color=palette.ink2)
49+ ax.axvline(cas.REF, color=palette.cat[3], lw=0.9, ls="--"); ax.text(cas.REF, 0.995, " date de référence", fontsize=7.5, color=palette.cat[3], va="top")
50+ ax.set_ylim(0.80, 1.0); ax.set_ylabel("Ratio médian du trimestre"); ax.set_xlabel("Trimestre de la vente")
51+ ax.legend(loc="lower left", fontsize=7.5)
52+ print("OK", savefig(fig, "ch09/ch09-fig-ratios-temps.pdf"))
53+
54+
55+def fig_prb(et):
56+ v = et["v"]; V = v["valeur_role_2025"].to_numpy(); P = v["prix_ref"].to_numpy(); r = V / P; med = np.median(r)
57+ proxy = 0.5 * (V / med + P); x = np.log2(proxy); y = (r - med) / med
58+ b = np.polyfit(x, y, 1)
59+ fig, ax = plt.subplots(figsize=(LARGEUR, 3.0))
60+ ax.scatter(x, y, s=9, color=palette.cat[1], alpha=0.55, lw=0, label="Ventes (ratios rajustés)")
61+ xs = np.linspace(x.min(), x.max(), 50); ax.plot(xs, np.polyval(b, xs), color=palette.ink, lw=1.4, label=f"Droite du PRB : pente {et['aj']['PRB']:.3f} (e.-t. {et['aj']['PRB_se']:.3f})".replace(".", ","))
62+ ax.axhspan(-0.05, 0.05, color=palette.grid, alpha=0.5, lw=0, zorder=0)
63+ ax.text(x.min(), 0.052, "zone IAAO : pente attendue entre −0,05 et +0,05", fontsize=7.5, color=palette.ink2)
64+ ax.axhline(0, color=palette.ink2, lw=0.6)
65+ ax.set_xlabel("log₂ de la valeur proxy ((rôle / médiane + prix rajusté) / 2)"); ax.set_ylabel("(ratio − médiane) / médiane")
66+ sec = ax.secondary_xaxis("top", functions=(lambda z: 2 ** z, lambda z: np.log2(np.maximum(z, 1))))
67+ sec.set_xlabel("Valeur proxy ($)", fontsize=8); sec.xaxis.set_major_formatter(FuncFormatter(fmt_dollars)); sec.tick_params(labelsize=7)
68+ ax.set_ylim(-0.3, 0.3); ax.yaxis.set_major_formatter(PercentFormatter(1.0, decimals=0))
69+ ax.legend(loc="lower right", fontsize=7.5)
70+ print("OK", savefig(fig, "ch09/ch09-fig-prb.pdf"))
71+
72+
73+def fig_sensibilite(d, et, da):
74+ s = cas.sensibilite_acheteur(d, et, da); tv = cas.taux_voisinage_gatineau()
75+ fig, ax = plt.subplots(figsize=(LARGEUR, 2.9))
76+ for col, lab, c in [("gain_net_min", f"taux de voisinage minimal ({tv['min']:.3f} $/1 000 $)", palette.cat[2]), ("gain_net_mediane", f"taux médian ({tv['mediane']:.3f} $/1 000 $)", palette.cat[0]), ("gain_net_max", f"taux maximal ({tv['max']:.3f} $/1 000 $)", palette.cat[1])]:
77+ ax.plot(s["valeur_reelle"], s[col], marker="o", ms=3, color=c, label=lab.replace(".", ","))
78+ ax.axhline(0, color=palette.ink2, lw=0.8)
79+ ax.axvline(da["prix_ref"], color=palette.cat[3], ls="--", lw=0.9); ax.text(da["prix_ref"] + 1500, ax.get_ylim()[1] * 0.92, "prix rajusté\nde la vente", fontsize=7.5, color=palette.cat[3])
80+ ax.axvline(da["valeur_role"] - da["seuil_valeur"], color=palette.ink2, ls=":", lw=0.9)
81+ ax.set_xlabel("Valeur réelle conclue à la date de référence ($)"); ax.set_ylabel("Gain net sur trois exercices ($)")
82+ ax.xaxis.set_major_formatter(FuncFormatter(fmt_dollars)); ax.yaxis.set_major_formatter(FuncFormatter(fmt_dollars))
83+ ax.set_title(f"Valeur inscrite {fmt_dollars(da['valeur_role'])} ; frais de révision {da['frais_revision']:.0f} $ ; facteur comparatif {et['F']:.4f}".replace(".", ","), loc="left", fontsize=8.5)
84+ ax.legend(loc="upper right", fontsize=7.5)
85+ print("OK", savefig(fig, "ch09/ch09-fig-sensibilite-contestation.pdf"))
86+
87+
88+def main():
89+ setup()
90+ d = cas.charger(); et = cas.etude(d); da = cas.dossier_acheteur(d, et)
91+ fig_distribution(et); fig_temps(et); fig_prb(et); fig_sensibilite(d, et, da)
92+
93+
94+if __name__ == "__main__":
95+ main()
added analysis/python/tests/test_ch07.py +186 −0
@@ -0,0 +1,186 @@
1+"""Tests du chapitre 7 — servitude par ventes appariées, quotes-parts et charges de copropriété, fonds de prévoyance, exercices.
2+Tolérances : 1 $ sur les montants cités arrondis au dollar ; 1e-9 sur les identités comptables ; 0,0001 sur les proportions."""
3+import sys, pathlib, math
4+import numpy as np, pytest
5+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
6+import ch07_servitudes_copro as cas
7+from traite import comparables as cp
8+from traite import finance as fin
9+
10+
11+@pytest.fixture(scope="module")
12+def res():
13+ return cas.main()
14+
15+
16+class TestServitude:
17+ def test_paires_citees(self, res):
18+ p = res["paires"]
19+ assert p["n"] == 12
20+ assert p["moyenne"] == pytest.approx(-20333, abs=1)
21+ assert p["mediane"] == pytest.approx(-19000, abs=1)
22+ assert p["ecart_type"] == pytest.approx(10026, abs=1)
23+ assert p["se"] == pytest.approx(2894, abs=1)
24+ assert p["t"] == pytest.approx(-7.03, abs=0.01)
25+ assert p["ic95"][0] == pytest.approx(-26703, abs=1) and p["ic95"][1] == pytest.approx(-13963, abs=1)
26+ assert p["effet_relatif_moyen"] == pytest.approx(-0.0451, abs=0.0001)
27+ assert p["effet_relatif_ratio"] == pytest.approx(-0.0446, abs=0.0001)
28+ assert p["effet_semilog"] == pytest.approx(-0.0454, abs=0.0001)
29+ assert p["sans"].mean() == pytest.approx(455792, abs=1)
30+ assert all(a < s for a, s in zip(p["avec"], p["sans"]))
31+
32+ def test_identites_paires(self, res):
33+ p = res["paires"]
34+ # la moyenne des différences est la différence des moyennes ; l'écart-type est celui de l'échantillon (n−1)
35+ assert p["moyenne"] == pytest.approx(p["avec"].mean() - p["sans"].mean(), abs=1e-9)
36+ assert p["ecart_type"] == pytest.approx(float(np.std(p["diffs"], ddof=1)), abs=1e-9)
37+ assert p["se"] == pytest.approx(p["ecart_type"] / math.sqrt(12), abs=1e-9)
38+ # cohérence avec traite.comparables
39+ va = cp.ventes_appariees(list(p["avec"]), list(p["sans"]))
40+ assert va["moyenne"] == pytest.approx(p["moyenne"]) and va["mediane"] == pytest.approx(p["mediane"])
41+
42+ def test_modele_bande(self, res):
43+ b = res["bande"]
44+ assert b["superficie_bande_m2"] == 120 and b["valeur_bande"] == 21600 and b["effet"] == 10800
45+ assert b["part_terrain"] == pytest.approx(120 / 700)
46+
47+
48+class TestCopropriete:
49+ def test_quotes_parts(self, res):
50+ qp = res["qp"]
51+ assert list(qp["q_dixmilliemes"]) == [935, 1352, 974, 1408, 1196, 1644, 1186, 1305]
52+ assert int(qp["q_dixmilliemes"].sum()) == 10000 and qp["q"].sum() == pytest.approx(1.0, abs=1e-12)
53+ assert qp["superficies"].sum() == 710
54+ assert qp["valeur_relative"].sum() == pytest.approx(716.91, abs=0.01)
55+ # méthode déclarée : F6 = 110 × 1,04 × 1,03
56+ assert qp["valeur_relative"][5] == pytest.approx(110 * 1.04 * 1.03)
57+
58+ def test_repartition(self, res):
59+ rep = res["rep"]; qp = res["qp"]
60+ assert rep["courantes"] == 40000 and rep["auto_assurance"] == 5000
61+ assert rep["general"] == pytest.approx(40000 + res["A_star"] + 5000)
62+ assert rep["par_fraction_general"].sum() == pytest.approx(rep["general"], abs=1e-6)
63+ assert rep["par_fraction_restreint"].sum() == pytest.approx(3200) and int(rep["n_stationnements"]) == 4
64+ assert rep["par_fraction_total"].sum() == pytest.approx(rep["total"], abs=1e-6)
65+ assert rep["total"] == pytest.approx(70091, abs=1)
66+ # chiffres cités : F4 et F6
67+ assert rep["par_fraction_total"][3] == pytest.approx(10218, abs=1) and rep["par_fraction_total"][5] == pytest.approx(11797, abs=1)
68+ assert rep["par_fraction_total"][0] == pytest.approx(6254, abs=1)
69+ assert rep["par_fraction_restreint"][3] == 800 and rep["par_fraction_restreint"][0] == 0
70+ assert rep["par_fraction_fonds"][3] == pytest.approx(qp["q"][3] * res["A_star"])
71+
72+ def test_auto_assurance(self):
73+ assert cas.contribution_auto_assurance(10000, 0) == 5000
74+ assert cas.contribution_auto_assurance(10000, 7000) == 3000
75+ assert cas.contribution_auto_assurance(10000, 12000) == 0
76+
77+
78+class TestFondsPrevoyance:
79+ def test_depenses(self, res):
80+ D = res["tr_star"]["D"]
81+ assert sum(c for _, c, a in cas.COMPOSANTES for _ in a) == 479000
82+ assert D.sum() == pytest.approx(651959, abs=1)
83+ assert D[14] == pytest.approx(140000 * 1.025 ** 14, abs=1e-6) and D[14] == pytest.approx(197816, abs=1)
84+ assert D[7] == pytest.approx(89151, abs=1)
85+
86+ def test_cotisation_seuil(self, res):
87+ A = res["A_star"]
88+ assert A == pytest.approx(21891, abs=1)
89+ tr = res["tr_star"]
90+ assert tr["min"] == pytest.approx(0.0, abs=0.01) and tr["annee_min"] == 14
91+ assert tr["F"][-1] == pytest.approx(239241, abs=1)
92+ assert tr["A"][-1] == pytest.approx(A * 1.025 ** 24, abs=1e-6)
93+ # identité de récurrence F_t = F_{t-1}(1+r) + A_t − D_t
94+ for t in range(1, cas.HORIZON + 1):
95+ assert tr["F"][t] == pytest.approx(tr["F"][t - 1] * 1.02 + tr["A"][t] - tr["D"][t], abs=1e-6)
96+ assert res["A_floor"] == pytest.approx(23228, abs=1)
97+
98+ def test_scenarios(self, res):
99+ act, regle = res["tr_act"], res["tr_regle"]
100+ assert res["regle_0_5"] == 21000
101+ assert act["premiere_annee_negative"] == 13 and act["min"] == pytest.approx(-233300, abs=1) and act["annee_min"] == 22
102+ assert act["F"][-1] == pytest.approx(-182783, abs=1)
103+ assert regle["premiere_annee_negative"] == 14 and regle["min"] == pytest.approx(-16660, abs=1)
104+
105+ def test_toiture_sff(self, res):
106+ fc = res["toiture"]
107+ assert fc["cout_indexe"] == pytest.approx(89151, abs=1)
108+ assert fc["sff"] == pytest.approx(fin.facteur_fonds_amortissement(0.02, 7)) and fc["sff"] == pytest.approx(0.13451, abs=1e-5)
109+ assert fc["versement"] == pytest.approx(11992, abs=1)
110+ # le versement accumule exactement le coût indexé en 7 ans à 2 %
111+ acc = sum(fc["versement"] * 1.02 ** (7 - k) for k in range(1, 8))
112+ assert acc == pytest.approx(fc["cout_indexe"], abs=1e-6)
113+
114+ def test_sensibilite(self, res):
115+ s = res["sens"]
116+ g = s["grille"]
117+ assert g[0.02][2] == pytest.approx(res["A_star"], abs=1)
118+ assert g[0.01][0] == pytest.approx(21683, abs=1) and g[0.01][4] == pytest.approx(24442, abs=1)
119+ assert g[0.03][0] == pytest.approx(19342, abs=1) and g[0.03][4] == pytest.approx(22099, abs=1)
120+ # monotonie : A* croît avec π et décroît avec r
121+ for r in s["rendements"]:
122+ assert all(np.diff(g[r]) > 0)
123+ for j in range(len(s["inflations"])):
124+ assert g[0.01][j] > g[0.02][j] > g[0.03][j]
125+
126+ def test_incidence_valeur(self, res):
127+ inc = res["incidence"]; qp = res["qp"]
128+ assert inc["solde_requis"] == pytest.approx(235908, abs=1) and inc["deficit"] == pytest.approx(150908, abs=1)
129+ assert inc["ajustement_par_fraction"][3] == pytest.approx(-21248, abs=1)
130+ assert inc["ajustement_par_fraction"][5] == pytest.approx(-24809, abs=1)
131+ assert inc["ajustement_par_fraction"].sum() == pytest.approx(-inc["deficit"], abs=1e-6)
132+ assert cas.trajectoire(cas.COTISATION_ACTUELLE, inc["solde_requis"])["min"] == pytest.approx(0.0, abs=0.01)
133+
134+
135+class TestExercices:
136+ def test_ex1_tarifs(self):
137+ # 1 fiche + 6 actes + 1 plan à 1 $, état certifié 16 $ + 16 $ pour 1 réquisition, copie certifiée 42 $
138+ assert 8 * 1 + (16 + 16) + 42 == 82
139+
140+ def test_ex2_quotes_parts(self):
141+ s = np.array([65.0, 80.0, 95.0, 120.0]); q = s / s.sum()
142+ assert q.sum() == pytest.approx(1) and q[3] == pytest.approx(1 / 3)
143+ charges = q * 54000
144+ assert charges[0] == pytest.approx(9750) and charges[3] == pytest.approx(18000)
145+ # variante avec coefficient d'étage 1,05 pour la fraction de 120 m²
146+ v = s * np.array([1, 1, 1, 1.05]); q2 = v / v.sum()
147+ assert q2[3] == pytest.approx(126 / 366, abs=1e-9) and (q2 * 54000)[3] == pytest.approx(18590, abs=1)
148+
149+ def test_ex3_paires(self):
150+ sans = np.array([398000, 412500, 430000, 445000, 462000, 489500]); avec = np.array([384000, 395500, 416000, 423000, 448000, 466500])
151+ va = cp.ventes_appariees(list(avec), list(sans))
152+ assert va["moyenne"] == pytest.approx(-17333, abs=1) and va["mediane"] == pytest.approx(-15500, abs=1)
153+ assert va["ecart_type"] == pytest.approx(4179, abs=1)
154+ se = va["ecart_type"] / math.sqrt(6); t = 2.571
155+ assert se == pytest.approx(1706, abs=1)
156+ assert va["moyenne"] - t * se == pytest.approx(-21720, abs=2) and va["moyenne"] + t * se == pytest.approx(-12947, abs=2)
157+ assert va["moyenne"] / sans.mean() == pytest.approx(-0.0394, abs=0.0001)
158+
159+ def test_ex4_bande(self):
160+ effet = 4 * 35 * 165 * 0.6
161+ assert effet == pytest.approx(13860)
162+ assert effet / 20000 == pytest.approx(0.693, abs=0.001)
163+
164+ def test_ex5_toiture(self):
165+ cT = 75000 * 1.025 ** 7; sff = fin.facteur_fonds_amortissement(0.02, 7)
166+ assert cT == pytest.approx(89151, abs=1) and cT * sff == pytest.approx(11992, abs=1)
167+ # sans indexation, versement 10 088 $ ; déficit à l'an 7
168+ a0 = 75000 * sff
169+ assert a0 == pytest.approx(10088, abs=1)
170+ assert cT - 75000 == pytest.approx(14151, abs=1)
171+ # variante r = 3 %
172+ assert cT * fin.facteur_fonds_amortissement(0.03, 7) == pytest.approx(11635, abs=1)
173+
174+ def test_ex6_script(self):
175+ # l'exercice demande : A* avec r = 3 % et π = 3 % ; A* avec plancher 25 000 $ ; solde initial requis
176+ assert cas.cotisation_seuil(inflation=0.03, rendement=0.03) == pytest.approx(21409, abs=1)
177+ assert cas.cotisation_seuil(plancher=25000) == pytest.approx(23228, abs=1)
178+ assert cas.cotisation_seuil(solde0=150000) == pytest.approx(17304, abs=1)
179+ assert cas.cotisation_seuil(indexer=False) == pytest.approx(25625, abs=1)
180+
181+ def test_ex7_indivision(self):
182+ V = 1050000.0
183+ part = V / 3
184+ assert part == pytest.approx(350000)
185+ assert part * (1 - 0.08) == pytest.approx(322000) and part * (1 - 0.15) == pytest.approx(297500)
186+ assert 350000 - 322000 == 28000 and 350000 - 297500 == 52500
added analysis/python/tests/test_ch08.py +162 −0
@@ -0,0 +1,162 @@
1+"""Tests du chapitre 8 — zonage et urbanisme (DONNÉES SYNTHÉTIQUES, graine 20261031).
2+Tolérances : 0,5 $ sur les montants cités arrondis au dollar ; 0,05 point de pourcentage sur les pourcentages cités avec deux
3+décimales ; 1e-9 sur les identités algébriques. Les valeurs « citées » sont celles de manuscript/chapters/ch08.tex et de
4+solutions/ch08.tex."""
5+import sys, pathlib, math
6+import numpy as np, pytest
7+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
8+import ch08_zonage as cas
9+from traite import comparables as cp
10+
11+TOL = 0.5
12+
13+
14+@pytest.fixture(scope="module")
15+def res():
16+ return cas.main(ecrire=False)
17+
18+
19+class TestDonneesSynthetiques:
20+ def test_structure(self, res):
21+ lots, ventes = res["lots"], res["ventes"]
22+ assert len(lots) == 400 and lots["zone_initiale"].value_counts().to_dict() == {"H1": 200, "C": 60, "H2": 60, "H3": 60, "P": 20}
23+ assert int(lots["rezone"].sum()) == 36 and set(lots.loc[lots["rezone"], "zone_apres_T0"]) == {"H2"}
24+ assert len(ventes) == 482 and int((ventes["zone_initiale"] == "H1").sum()) == 305 and int(ventes["traite_post"].sum()) == 29
25+ assert (ventes["prix_vente"] % 500 == 0).all()
26+
27+ def test_reproductibilite(self):
28+ l1, v1 = cas.charger_ou_generer(ecrire=False); l2, v2 = cas.charger_ou_generer(ecrire=False)
29+ assert v1.equals(v2) and l1.equals(l2)
30+
31+ def test_prime_injectee(self):
32+ assert cas.prime_rezonage(20) == pytest.approx(0.03) and cas.prime_rezonage(60) == pytest.approx(0.09) and cas.prime_rezonage(10) == pytest.approx(0.03)
33+
34+
35+class TestPaires:
36+ def test_resume_cite(self, res):
37+ rp = res["resume_paires"]
38+ assert rp["n"] == 29
39+ assert rp["moyenne"] == pytest.approx(32285, abs=TOL) and rp["mediane"] == pytest.approx(19944, abs=TOL)
40+ assert rp["ecart_type"] == pytest.approx(51335, abs=TOL) and rp["erreur_type_moyenne"] == pytest.approx(9533, abs=TOL)
41+ assert rp["moyenne_pct"] * 100 == pytest.approx(9.29, abs=0.05) and rp["mediane_pct"] * 100 == pytest.approx(6.28, abs=0.05)
42+ assert rp["ic95_pct"][0] * 100 == pytest.approx(4.32, abs=0.05) and rp["ic95_pct"][1] * 100 == pytest.approx(14.26, abs=0.05)
43+ assert rp["n_jeunes"] == 16 and rp["n_vieux"] == 13
44+ assert rp["moyenne_pct_jeunes"] * 100 == pytest.approx(4.85, abs=0.05) and rp["moyenne_pct_vieux"] * 100 == pytest.approx(14.75, abs=0.05)
45+ assert res["prime_theorique_moyenne"] * 100 == pytest.approx(6.15, abs=0.05)
46+
47+ def test_identites_paires(self, res):
48+ p = res["paires"]
49+ assert (p["difference"] - (p["prix_traite"] - p["prix_temoin_ajuste"])).abs().max() < 1e-6
50+ assert (p["prix_temoin_ajuste"] - (p["prix_temoin"] + p["ajust_temps"]) * p["facteur_physique"]).abs().max() < 1e-6
51+ assert p["id_temoin"].is_unique and p["id_traite"].is_unique
52+ r = cp.ventes_appariees(p["prix_traite"].tolist(), p["prix_temoin_ajuste"].tolist())
53+ assert r["moyenne"] == pytest.approx(p["difference"].mean(), rel=1e-12)
54+ assert r["ecart_type"] / math.sqrt(r["n"]) == pytest.approx(res["resume_paires"]["erreur_type_moyenne"], rel=1e-12)
55+
56+ def test_ajustement_temps_coherent(self, res):
57+ p = res["paires"].iloc[0]
58+ from datetime import date
59+ mois = (date.fromisoformat(p["date_traite"]) - date.fromisoformat(p["date_temoin"])).days / 30.4375
60+ assert p["ajust_temps"] == pytest.approx(cp.ajustement_temps_taux(p["prix_temoin"], cas.TAUX_MARCHE, mois), rel=1e-12)
61+
62+
63+class TestDiD:
64+ def test_valeurs_citees(self, res):
65+ d = res["did"]
66+ assert d["n_obs"] == 305 and d["effectifs"] == {(False, False): 134, (False, True): 112, (True, False): 30, (True, True): 29}
67+ assert d["delta_2x2"] == pytest.approx(0.1061, abs=5e-5) and d["delta_2x2_pct"] * 100 == pytest.approx(11.19, abs=0.05)
68+ assert d["delta_reg"] == pytest.approx(0.0657, abs=5e-5) and d["se_delta"] == pytest.approx(0.0208, abs=5e-5)
69+ assert d["delta_reg_pct"] * 100 == pytest.approx(6.79, abs=0.05) and d["sigma_resid"] == pytest.approx(0.0709, abs=5e-5)
70+ assert d["coef"]["ln_hab"] == pytest.approx(0.4765, abs=5e-5) and d["coef"]["tendance"] == pytest.approx(0.0308, abs=5e-5)
71+
72+ def test_identite_2x2(self, res):
73+ d = res["did"]; m = d["moyennes_lnP"]
74+ assert d["delta_2x2"] == pytest.approx((m[(True, True)] - m[(True, False)]) - (m[(False, True)] - m[(False, False)]), rel=1e-12)
75+ # la régression retrouve, à l'erreur-type près, la prime moyenne injectée
76+ assert abs(d["delta_reg"] - math.log(1 + res["prime_theorique_moyenne"])) < 2 * d["se_delta"]
77+
78+
79+class TestOutils:
80+ def test_grille(self, res):
81+ g = res["grille"]
82+ assert g["lot_conforme"] and g["logements_par_densite"] == 4 and g["logements_admissibles"] == 4 and g["aire_plancher_max_m2"] == pytest.approx(360.0)
83+ g2 = cas.densite_admissible(430.0, 150.0, 4, 0.6, 450.0)
84+ assert not g2["lot_conforme"] and g2["logements_admissibles"] == 2
85+
86+ def test_redeveloppement(self, res):
87+ rd = res["redev"]
88+ assert rd["gdv"] == pytest.approx(2088000) and rd["couts_directs"] == pytest.approx(1044000) and rd["couts_indirects"] == pytest.approx(125280)
89+ assert rd["marge"] == pytest.approx(313200) and rd["financement"] == pytest.approx(62640) and rd["reste_avant_parcs"] == pytest.approx(512880)
90+ assert rd["valeur_residuelle"] == pytest.approx(466254.55, abs=0.01) and rd["contribution_parcs"] == pytest.approx(46625.45, abs=0.01)
91+ assert rd["valeur_residuelle"] + rd["contribution_parcs"] == pytest.approx(rd["reste_avant_parcs"], rel=1e-12)
92+ assert res["prime_option"] == pytest.approx(86254.55, abs=0.01) and res["prime_option_pct"] * 100 == pytest.approx(22.7, abs=0.05)
93+
94+ def test_grille_attendue(self, res):
95+ G = res["grille_attendue"]
96+ assert G.shape == (4, 4)
97+ assert G[2, 2] == pytest.approx(55462, abs=TOL) and G[3, 0] == pytest.approx(82998, abs=TOL) and G[0, 3] == pytest.approx(17118, abs=TOL)
98+ assert G[3, 0] == pytest.approx(res["prime_option"] * 1.08 ** -0.5, rel=1e-9)
99+ assert res["exemple_p75_T2"] == pytest.approx(427462, abs=TOL)
100+ assert res["exemple_p75_T2"] == pytest.approx(380000 + G[2, 2] - 8000, abs=TOL)
101+
102+ def test_droits_acquis(self, res):
103+ assert res["noi_duplex"] == pytest.approx(22620) and res["noi_maison"] == pytest.approx(19320) and res["delta_noi"] == pytest.approx(3300)
104+ attendu = {0.0: 60000.0, 0.01: 50769.23, 0.02: 44000.0, 0.05: 31428.57}
105+ for lam, v in attendu.items():
106+ assert res["v_delta"][lam] == pytest.approx(v, abs=0.01)
107+ assert res["v_delta_T"][0.0] == pytest.approx(44830, abs=TOL) and res["v_delta_T"][0.02] == pytest.approx(37252, abs=TOL)
108+ assert res["v_droits_acquis_lam02"] == pytest.approx(474000)
109+ # horizon infini = limite de l'horizon fini
110+ assert cas.valeur_surplus_horizon(3300, 0.055, 0.02, 1e6) == pytest.approx(cas.valeur_surplus_derogatoire(3300, 0.055, 0.02), rel=1e-9)
111+
112+ def test_parcs_et_agricole(self, res):
113+ assert res["parcs"]["terrain_max_m2"] == pytest.approx(240) and res["parcs"]["somme_max"] == pytest.approx(96000)
114+ assert res["v_agricole"] == pytest.approx(75000) and res["v_si_autorise"] == pytest.approx(157500) and res["v_attendue_agricole"] == pytest.approx(90796, abs=TOL)
115+
116+
117+class TestExercices:
118+ """Chiffres des solutions (solutions/ch08.tex)."""
119+
120+ def test_ex1_grille(self):
121+ g = cas.densite_admissible(540.0, 150.0, 4, 0.60, 450.0)
122+ assert g["lot_conforme"] and g["logements_admissibles"] == 3 and g["aire_plancher_max_m2"] == pytest.approx(324.0)
123+
124+ def test_ex2_parcs(self):
125+ c = cas.contribution_parcs(1250000.0, 3100.0)
126+ assert c["terrain_max_m2"] == pytest.approx(310) and c["somme_max"] == pytest.approx(125000)
127+
128+ def test_ex3_droits_acquis(self):
129+ d_noi = 3 * 12 * 1250 * (1 - 0.38) - 12 * 2600 * (1 - 0.30)
130+ assert d_noi == pytest.approx(6060.0)
131+ assert cas.valeur_surplus_derogatoire(d_noi, 0.06, 0.0) == pytest.approx(101000, abs=TOL)
132+ assert cas.valeur_surplus_derogatoire(d_noi, 0.06, 0.03) == pytest.approx(67333.33, abs=0.01)
133+ assert cas.valeur_surplus_horizon(d_noi, 0.06, 0.03, 20) == pytest.approx(56203, abs=TOL)
134+
135+ def test_ex4_did(self):
136+ m = {(False, False): 12.70, (False, True): 12.76, (True, False): 12.72, (True, True): 12.83}
137+ delta = (m[(True, True)] - m[(True, False)]) - (m[(False, True)] - m[(False, False)])
138+ assert delta == pytest.approx(0.05, abs=1e-9) and (math.exp(delta) - 1) * 100 == pytest.approx(5.13, abs=0.005)
139+
140+ def test_ex5_agricole(self):
141+ v_agr = 8 * 14000.0; v_aut = 110000.0 + 7.5 * 14000.0
142+ assert v_agr == pytest.approx(112000) and v_aut == pytest.approx(215000)
143+ assert cas.valeur_attendue_zone_agricole(v_agr, v_aut, 0.25, 2.0, 0.06, 6000.0) == pytest.approx(125837, abs=TOL)
144+ assert cas.valeur_attendue_zone_agricole(v_agr, v_aut, 0.50, 2.0, 0.06, 6000.0) == pytest.approx(145675, abs=TOL)
145+
146+ def test_ex6_option(self):
147+ rd = cas.valeur_residuelle_redeveloppement(3, 95.0, 5600.0, 2950.0, 0.12, 28000.0, 0.15, 0.03, 0.10, 0.0)
148+ assert rd["gdv"] == pytest.approx(1596000) and rd["valeur_residuelle"] == pytest.approx(308254.55, abs=0.01)
149+ rd2 = cas.valeur_residuelle_redeveloppement(3, 95.0, 5600.0, 2950.0, 0.12, 28000.0, 0.15, 0.03, 0.10, 45000.0)
150+ assert rd2["valeur_residuelle"] == pytest.approx(267345.45, abs=0.01)
151+ assert rd["valeur_residuelle"] - rd2["valeur_residuelle"] == pytest.approx(45000 / 1.10, rel=1e-9)
152+ assert cas.prime_option(350000.0, rd["valeur_residuelle"]) == 0.0
153+ assert cas.prime_option(280000.0, rd["valeur_residuelle"]) == pytest.approx(28254.55, abs=0.01)
154+
155+ def test_ex7_derivation(self):
156+ # V_Δ(T) → ΔRNE/(R+λ) ; et sensibilité : dV/dλ = −V/(R+λ)
157+ dn, R, lam = 3300.0, 0.055, 0.02
158+ v = cas.valeur_surplus_derogatoire(dn, R, lam)
159+ h = 1e-6
160+ deriv = (cas.valeur_surplus_derogatoire(dn, R, lam + h) - cas.valeur_surplus_derogatoire(dn, R, lam - h)) / (2 * h)
161+ assert deriv == pytest.approx(-v / (R + lam), rel=1e-6)
162+ assert cas.valeur_surplus_derogatoire(dn, R, lam + 0.002) == pytest.approx(42857.14, abs=0.01)
added analysis/python/tests/test_ch09.py +190 −0
@@ -0,0 +1,190 @@
1+"""Tests du chapitre 9 — étude de ratios (COD, PRD, PRB, proportion médiane, facteur comparatif), dossiers de contestation,
2+compte de taxes de Gatineau 2026, droits de mutation, échéancier et exercices.
3+Tolérances : identités 1e-9 ; statistiques citées à 5e-4 (ratios) ou 0,05 (COD) ; montants cités au dollar près ou 0,5 $ ; valeurs arrondies au millier exactes."""
4+import sys, pathlib, datetime as dt
5+import numpy as np, pandas as pd, pytest
6+sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases"))
7+import ch09_ratios as cas
8+from traite import hedonique as h
9+
10+
11+@pytest.fixture(scope="module")
12+def data():
13+ d = cas.charger(); et = cas.etude(d)
14+ return {"d": d, "et": et, "ds": cas.dossier_sujet(d, et), "da": cas.dossier_acheteur(d, et), "ex": cas.exercices(et)}
15+
16+
17+class TestIdentitesRatio:
18+ def test_ratios_uniformes(self):
19+ """Rôle exactement proportionnel au prix : COD = 0, PRD = 1, PRB = 0, médiane = proportion."""
20+ P = np.linspace(200_000, 900_000, 50); st = h.statistiques_ratio(0.93 * P, P)
21+ assert st["COD"] == pytest.approx(0.0, abs=1e-9) and st["PRD"] == pytest.approx(1.0, abs=1e-9)
22+ assert st["PRB"] == pytest.approx(0.0, abs=1e-9) and st["ratio_median"] == pytest.approx(0.93, abs=1e-12)
23+
24+ def test_definitions(self, data):
25+ """COD = 100 · moyenne|r − méd| / méd ; PRD = moyenne(r) / (ΣV/ΣP) ; facteur = 1 / proportion médiane."""
26+ v = data["et"]["v"]; r = (v["valeur_role_2025"] / v["prix_vente"]).to_numpy(); med = np.median(r)
27+ st = data["et"]["brut"]
28+ assert st["COD"] == pytest.approx(100 * np.mean(np.abs(r - med)) / med, rel=1e-12)
29+ assert st["PRD"] == pytest.approx(r.mean() / (v["valeur_role_2025"].sum() / v["prix_vente"].sum()), rel=1e-12)
30+ assert data["et"]["F"] * data["et"]["PM"] == pytest.approx(1.0, abs=1e-12)
31+
32+ def test_regressivite_prd(self):
33+ """Un rôle d'élasticité 0,9 par rapport à la valeur est régressif : PRD > 1,03 et PRB < 0."""
34+ rng = np.random.default_rng(1); V = np.exp(rng.normal(13, 0.4, 2000)); P = V * np.exp(rng.normal(0, 0.05, 2000)); A = 0.93 * V ** 0.9 * np.exp(0.1 * 13)
35+ st = h.statistiques_ratio(A, P)
36+ assert st["PRD"] > 1.03 and st["PRB"] < -0.05
37+
38+ def test_biais_du_proxy(self):
39+ """Lorsque l'erreur du rôle est la seule source de dispersion des ratios, le proxy IAAO induit un PRB positif ; le proxy « prix seul » ne l'induit pas."""
40+ rng = np.random.default_rng(2); P = np.exp(rng.normal(13, 0.3, 5000)); A = 0.93 * P * np.exp(rng.normal(0, 0.07, 5000))
41+ st = h.statistiques_ratio(A, P); alt = cas.prb_proxy_prix(A, P)
42+ assert st["PRB"] > 0.03 and abs(alt["PRB"]) < 0.01
43+
44+ def test_epuration(self):
45+ r = np.array([0.9, 0.92, 0.95, 0.88, 0.91, 1.6, 0.93, 0.5])
46+ ep = cas.epuration_r10(r)
47+ med = np.median(r); sd = r.std(ddof=1)
48+ assert ep["borne_basse"] == pytest.approx(med - 2 * sd) and ep["borne_haute"] == pytest.approx(med + 2 * sd)
49+ assert ep["n_retires"] == int(((r < med - 2 * sd) | (r > med + 2 * sd)).sum())
50+
51+ def test_liste_de_base(self):
52+ assert cas.liste_de_base(100) == 30
53+ assert cas.liste_de_base(103_355) == int(np.ceil(103_355 / (15.5 + 103.355)))
54+ assert cas.liste_de_base(103_355) == 870 and cas.liste_de_base(514_614) == 971
55+
56+ def test_plafond_r13(self):
57+ assert cas.plafond_r13(0.10) == 0.36 and cas.plafond_r13(0.30) == 0.31 and cas.plafond_r13(0.50) == 0.25 and cas.plafond_r13(0.70) == 0.19 and cas.plafond_r13(0.95) == 0.14
58+
59+
60+class TestEtude:
61+ def test_valeurs_citees_brut(self, data):
62+ st = data["et"]["brut"]
63+ assert st["n"] == 600
64+ assert st["ratio_median"] == pytest.approx(0.9136, abs=5e-4) and st["COD"] == pytest.approx(6.90, abs=0.05)
65+ assert st["PRD"] == pytest.approx(1.0015, abs=5e-4) and st["PRB"] == pytest.approx(0.0327, abs=5e-4)
66+
67+ def test_valeurs_citees_rajuste(self, data):
68+ et = data["et"]; st = et["aj"]
69+ assert et["croissance_annuelle"] == pytest.approx(0.0446, abs=5e-4)
70+ assert st["ratio_median"] == pytest.approx(0.9245, abs=5e-4) and st["COD"] == pytest.approx(6.12, abs=0.05)
71+ assert st["PRD"] == pytest.approx(0.9988, abs=5e-4) and st["PRB"] == pytest.approx(0.0640, abs=5e-4)
72+ assert st["COD"] < et["brut"]["COD"]
73+
74+ def test_epuration_et_facteur(self, data):
75+ et = data["et"]; ep = et["epuration"]
76+ assert ep["n_retires"] == 29 and ep["borne_basse"] == pytest.approx(0.7830, abs=5e-4) and ep["borne_haute"] == pytest.approx(1.0661, abs=5e-4)
77+ assert et["PM"] == pytest.approx(0.9244, abs=5e-4) and et["F"] == pytest.approx(1.0818, abs=5e-4)
78+ assert et["apres_epuration"]["COD"] == pytest.approx(5.48, abs=0.05)
79+ assert et["ecart_type_relatif_epure"] == pytest.approx(0.0660, abs=5e-4) and et["ecart_type_relatif_epure"] < et["plafond_r13"]
80+ assert cas.R13_PROPORTION[0] > et["PM"] # le rôle fictif est sous le plancher de 95 % du r. 13
81+
82+ def test_sous_groupes(self, data):
83+ et = data["et"]
84+ assert et["secteurs"]["n"].sum() == 600 and et["secteurs"]["mediane"].between(0.915, 0.935).all()
85+ assert et["tranches"]["mediane"].between(0.915, 0.935).all()
86+ assert et["annees"].loc[2023, "r_brut"] == pytest.approx(0.9538, abs=5e-4) and et["annees"].loc[2026, "r_brut"] == pytest.approx(0.8692, abs=5e-4)
87+
88+ def test_taux_voisinage(self):
89+ tv = cas.taux_voisinage_gatineau()
90+ assert tv["n"] == 1092 and tv["min"] == pytest.approx(1.613) and tv["max"] == pytest.approx(7.544) and tv["mediane"] == pytest.approx(5.232, abs=5e-4)
91+
92+
93+class TestTaxes:
94+ def test_compte_sujet(self, data):
95+ t = data["ds"]["taxes_role"]
96+ assert t["taux_municipal_pour_1000"] == pytest.approx(5.232 + 1.417 + 0.830 + 0.036, abs=1e-9)
97+ assert t["municipal"] == pytest.approx(468_000 * t["taux_municipal_pour_1000"] / 1000, rel=1e-12)
98+ assert t["evaluation_uniformisee"] == pytest.approx(468_000 * data["et"]["F"], rel=1e-12)
99+ assert t["scolaire"] == pytest.approx((t["evaluation_uniformisee"] - 25_000) * 0.0007899, rel=1e-12)
100+ assert t["total"] == pytest.approx(4076.01, abs=0.5)
101+ assert t["taux_marginal_pour_1000"] == pytest.approx(8.370, abs=5e-4)
102+
103+ def test_credits_non_raccorde(self):
104+ a = cas.compte_taxes_gatineau(468_000, 1.02, 5.232); b = cas.compte_taxes_gatineau(468_000, 1.02, 5.232, raccorde=False)
105+ assert a["municipal"] - b["municipal"] == pytest.approx(468_000 * (0.386 + 0.523) / 1000, rel=1e-12)
106+
107+ def test_droits_mutation(self):
108+ assert cas.droits_mutation(500_000, cas.BAREME_MUTATION_QC) == pytest.approx(0.005 * 62_900 + 0.01 * (315_000 - 62_900) + 0.015 * (500_000 - 315_000), rel=1e-12)
109+ assert cas.droits_mutation(575_000, cas.BAREME_MUTATION_QC) == pytest.approx(6735.50, abs=0.01)
110+ assert cas.droits_mutation(575_000, cas.BAREME_MUTATION_GAT) == pytest.approx(7860.50, abs=0.01)
111+ assert cas.droits_mutation(575_000, cas.BAREME_MUTATION_MTL) == pytest.approx(6849.00, abs=0.01)
112+ assert cas.droits_mutation(4_000, cas.BAREME_MUTATION_QC) == pytest.approx(20.0)
113+
114+ def test_frais(self):
115+ assert cas.frais(468_000, cas.FRAIS_REVISION_GATINEAU) == 80.0 and cas.frais(592_100, cas.FRAIS_REVISION_GATINEAU) == 325.0
116+ assert cas.frais(468_000, cas.FRAIS_TAQ_2026) == 93.20 and cas.frais(6_000_000, cas.FRAIS_TAQ_2026) == 1242.50
117+ for b_mun, b_taq in zip(cas.FRAIS_REVISION_GATINEAU, cas.FRAIS_TAQ_2026):
118+ assert b_mun[1] <= b_taq[1] # LFM art. 263.2 al. 2 : plafond = tarif du TAQ
119+
120+
121+class TestDossiers:
122+ def test_sujet(self, data):
123+ ds = data["ds"]; et = data["et"]
124+ assert ds["hedonique"] == pytest.approx(580_375, abs=1) and ds["comparables"]["indication_mediane"] == pytest.approx(565_414, abs=1)
125+ assert ds["valeur_reelle"] == 573_000
126+ assert ds["ratio_sujet"] == pytest.approx(468_000 / 573_000, rel=1e-12) and ds["ratio_sujet"] == pytest.approx(0.8168, abs=5e-4)
127+ assert ds["valeur_a_inscrire"] * et["F"] == pytest.approx(ds["valeur_reelle"], rel=1e-12)
128+ assert ds["valeur_a_inscrire"] == pytest.approx(529_681, abs=1) and ds["variation_si_conteste"] > 0
129+ assert ds["variation_taxes_annuelle"] == pytest.approx(516.24, abs=0.5)
130+ assert len(ds["comparables"]["ajustements"]) == 5
131+ assert list(ds["comparables"]["ajustements"]["id_vente"]) == ["G0264", "G0334", "G0404", "G0388", "G0188"]
132+ a = ds["comparables"]["ajustements"]
133+ groupes = ["conditions_marche", "localisation", "superficie", "terrain", "age", "qualite", "garage", "sous_sol", "piscine", "type"]
134+ assert np.allclose(a["prix_vente"] + a[groupes].sum(axis=1), a["prix_ajuste"])
135+
136+ def test_acheteur(self, data):
137+ da = data["da"]; et = data["et"]
138+ assert da["vente"]["id_vente"] == "G0258" and da["date_vente"] == pd.Timestamp("2024-07-04")
139+ assert da["prix_vente"] == 509_500 and da["valeur_role"] == 592_100
140+ assert da["prix_ref"] == pytest.approx(509_317, abs=1) and da["ratio_aj"] == pytest.approx(1.1625, abs=5e-4)
141+ assert da["valeur_a_inscrire"] == pytest.approx(470_813, abs=1) and da["reduction"] == pytest.approx(121_287, abs=1)
142+ assert da["gain_annuel"] == pytest.approx(1015.11, abs=0.5) and da["gain_cycle"] == pytest.approx(3 * da["gain_annuel"], rel=1e-12)
143+ assert da["frais_revision"] == 325.0 and da["gain_net_cycle"] == pytest.approx(2720.34, abs=0.5)
144+ assert da["seuil_valeur"] == pytest.approx(12_944, abs=1)
145+ # gain annuel = réduction × taux marginal (identité)
146+ assert da["gain_annuel"] == pytest.approx(da["reduction"] * da["taxes_role"]["taux_marginal_pour_1000"] / 1000, rel=1e-9)
147+
148+ def test_sensibilite(self, data):
149+ s = cas.sensibilite_acheteur(data["d"], data["et"], data["da"])
150+ assert len(s) == 9 and (s["gain_net_min"] > 0).all()
151+ assert s["gain_net_mediane"].is_monotonic_decreasing
152+ ligne0 = s.iloc[(s["ecart_pct"].abs()).idxmin()]
153+ assert ligne0["gain_net_mediane"] == pytest.approx(data["da"]["gain_net_cycle"], abs=0.5)
154+
155+ def test_mutations(self, data):
156+ mu = cas.mutations_sujet(data["ds"], data["et"])
157+ assert mu["Gatineau 2026 (dès le 25 février)"]["droit"] == pytest.approx(7830.50, abs=0.01)
158+ ex = mu["exemple_facteur"]
159+ assert ex["base_F_reel"] == pytest.approx(468_000 * 1.02) and ex["droit_F_reel"] == pytest.approx(5270.90, abs=0.01) and ex["droit_prix"] == pytest.approx(4860.50, abs=0.01)
160+
161+ def test_echeancier(self):
162+ e = cas.echeancier()["fictif"]; dates = {j["jalon"][:20]: j["date"] for j in e}
163+ assert e[0]["date"] == dt.date(2024, 7, 1) and e[2]["date"] == dt.date(2026, 1, 1)
164+ assert e[4]["date"] == dt.date(2026, 4, 30) and e[5]["date"] == dt.date(2026, 9, 1) and e[7]["date"] == dt.date(2026, 10, 31)
165+ assert (e[6]["date"] - e[5]["date"]).days == 30 and (e[7]["date"] - e[6]["date"]).days == 30
166+ g = cas.echeancier()["gatineau_2027"]; assert g[0]["date"] == dt.date(2025, 7, 1) and g[4]["date"] == dt.date(2027, 4, 30)
167+
168+
169+class TestExercices:
170+ def test_ex1(self, data):
171+ ex = data["ex"]["ex1"]
172+ assert ex["F"] == pytest.approx(1.0870, abs=5e-5) and ex["uniformisee_468000"] == pytest.approx(508_695.65, abs=0.01) and ex["taxe_scolaire"] == pytest.approx(382.07, abs=0.01)
173+
174+ def test_ex2(self, data):
175+ ex = data["ex"]["ex2"]
176+ assert ex["raccorde"]["total"] == pytest.approx(468_000 * 7.515 / 1000 + (468_000 * 1.02 - 25_000) * 0.0007899 + 178.83, abs=0.01)
177+ assert ex["raccorde"]["total"] == pytest.approx(4053.15, abs=0.01) and ex["non_raccorde"]["total"] == pytest.approx(3627.74, abs=0.01)
178+
179+ def test_ex3(self, data):
180+ ex = data["ex"]["ex3"]
181+ assert ex["ratio_median"] == pytest.approx(0.9221, abs=5e-4) and ex["COD"] == pytest.approx(2.50, abs=0.05) and ex["PRD"] == pytest.approx(1.0031, abs=5e-4)
182+ assert ex["epuration"]["n_retires"] == 0 and ex["ecart_type_relatif"] == pytest.approx(0.0311, abs=5e-4)
183+
184+ def test_ex4(self, data):
185+ ex = data["ex"]["ex4"]
186+ assert ex["gat_base_role"] == pytest.approx(632_400) and ex["gat_droit_base_role"] == pytest.approx(9582.50, abs=0.01)
187+
188+ def test_ex7(self, data):
189+ ex = data["ex"]["ex7"]
190+ assert ex["PRD"] > 1.0 and ex["PRB"] < -0.05
added assets/figures/ch04/ch04-fig-carte-rmr.pdf +0 −0

Binary file not shown.

added assets/figures/ch04/ch04-fig-ipln-rmr.pdf +0 −0

Binary file not shown.

added assets/figures/ch04/ch04-fig-mutations.pdf +0 −0

Binary file not shown.

added assets/figures/ch04/ch04-fig-parc-types.pdf +0 −0

Binary file not shown.

added assets/figures/ch04/ch04-fig-schl-rmr.pdf +0 −0

Binary file not shown.

added assets/figures/ch04/ch04-fig-taux-2000-2026.pdf +0 −0

Binary file not shown.

added assets/figures/ch05/ch05-fig-capitalisation-taxe.pdf +0 −0

Binary file not shown.

added assets/figures/ch05/ch05-fig-gradient-monocentrique.pdf +0 −0

Binary file not shown.

added assets/figures/ch05/ch05-fig-prix-distance-cas1.pdf +0 −0

Binary file not shown.

added assets/figures/ch05/ch05-fig-surface-rente.pdf +0 −0

Binary file not shown.

added assets/figures/ch05/ch05-fig-valeur-logement-rmr.pdf +0 −0

Binary file not shown.

modified assets/figures/ch06/ch06-fig-emphyteose.pdf +0 −0

Binary file not shown.

modified assets/figures/ch07/ch07-fig-fonds-prevoyance.pdf +0 −0

Binary file not shown.

modified assets/figures/ch07/ch07-fig-paires-servitude.pdf +0 −0

Binary file not shown.

modified assets/figures/ch07/ch07-fig-repartition-charges.pdf +0 −0

Binary file not shown.

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

Binary file not shown.

added assets/figures/ch09/ch09-fig-prb.pdf +0 −0

Binary file not shown.

added assets/figures/ch09/ch09-fig-ratios-distribution.pdf +0 −0

Binary file not shown.

added assets/figures/ch09/ch09-fig-ratios-temps.pdf +0 −0

Binary file not shown.

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

Binary file not shown.

modified manuscript/chapters/ch10.tex +135 −0
@@ -219,3 +219,138 @@ Une hypothèse extraordinaire bien rédigée comporte quatre éléments : l'éno
219 \subsection{Inspection et signature} 219 \subsection{Inspection et signature}
220 220
221 Deux règles coercitives closent la norme 2. La règle 2.4 pose que « Tout immeuble évalué doit faire l'objet d'une inspection, à moins de circonstances exceptionnelles lesquelles seront expliquées au rapport » ; chaque signataire atteste avoir personnellement inspecté l'immeuble ou, à défaut, le mentionne explicitement ; l'inspection doit au minimum permettre de décrire l'immeuble, de fonder la conclusion sur l'usage le meilleur et le plus profitable et de soutenir les comparaisons \parencite[règle 2.4]{oeaqNormes}. Le \cref{ch:11} traite de l'inspection comme technique ; ici, il suffit de noter que la norme en fait une obligation dont l'omission doit être expliquée, et non une option tarifaire. Les évaluations « sur dossier » que certains prêteurs demandent pour des biens standard ne sont conformes que si le rapport dit clairement que l'immeuble n'a pas été inspecté, pourquoi, et quelles hypothèses en découlent. La règle 2.5 porte sur la signature : elle peut être manuscrite ou électronique selon un procédé reconnu par le secrétaire de l'Ordre, et le signataire « doit accepter l'entière responsabilité du contenu et des conclusions du rapport » \parencite[règle 2.5]{oeaqNormes}. Cette phrase a une portée considérable pour les cabinets où des stagiaires ou des techniciens préparent les rapports : la signature n'est pas une formalité de validation, elle transfère au signataire la responsabilité de chaque chiffre et de chaque phrase, ce que l'article 33 du Code de déontologie confirme en obligeant l'évaluateur à superviser et réviser le travail qu'il délègue \parencite[art.~33]{deontOEAQ}. 221 Deux règles coercitives closent la norme 2. La règle 2.4 pose que « Tout immeuble évalué doit faire l'objet d'une inspection, à moins de circonstances exceptionnelles lesquelles seront expliquées au rapport » ; chaque signataire atteste avoir personnellement inspecté l'immeuble ou, à défaut, le mentionne explicitement ; l'inspection doit au minimum permettre de décrire l'immeuble, de fonder la conclusion sur l'usage le meilleur et le plus profitable et de soutenir les comparaisons \parencite[règle 2.4]{oeaqNormes}. Le \cref{ch:11} traite de l'inspection comme technique ; ici, il suffit de noter que la norme en fait une obligation dont l'omission doit être expliquée, et non une option tarifaire. Les évaluations « sur dossier » que certains prêteurs demandent pour des biens standard ne sont conformes que si le rapport dit clairement que l'immeuble n'a pas été inspecté, pourquoi, et quelles hypothèses en découlent. La règle 2.5 porte sur la signature : elle peut être manuscrite ou électronique selon un procédé reconnu par le secrétaire de l'Ordre, et le signataire « doit accepter l'entière responsabilité du contenu et des conclusions du rapport » \parencite[règle 2.5]{oeaqNormes}. Cette phrase a une portée considérable pour les cabinets où des stagiaires ou des techniciens préparent les rapports : la signature n'est pas une formalité de validation, elle transfère au signataire la responsabilité de chaque chiffre et de chaque phrase, ce que l'article 33 du Code de déontologie confirme en obligeant l'évaluateur à superviser et réviser le travail qu'il délègue \parencite[art.~33]{deontOEAQ}.
222+
223+\section{Déontologie : indépendance, compétence, conflits d'intérêts}\label{sec:10:deonto}
224+
225+\subsection{Les fondements}
226+
227+Le Code de déontologie des évaluateurs agréés, règlement adopté en vertu du Code des professions et dont le texte à jour au 1\textsuperscript{er} juillet 2026 a été consulté pour cet ouvrage \parencite{deontOEAQ}, donne aux normes de pratique une portée disciplinaire : l'article 4 oblige l'évaluateur à exercer « conformément aux normes de pratique de la profession », de sorte qu'un manquement aux NPP est aussi un manquement déontologique \parencite[art.~4]{deontOEAQ}. L'article 5 pose l'obligation de compétence : l'évaluateur tient compte des limites de ses aptitudes et n'entreprend pas un travail « pour lequel il n'est pas suffisamment qualifié sans obtenir l'assistance nécessaire » \parencite[art.~5]{deontOEAQ}. La règle vaut pour le résidentiel comme pour le multirésidentiel complexe : un évaluateur dont la pratique est faite de maisons unifamiliales et qui accepte d'évaluer un immeuble de cent logements avec baux commerciaux au rez-de-chaussée doit s'adjoindre la compétence qui lui manque, et le dire. L'article 14 établit la primauté de l'intérêt du client : l'évaluateur subordonne son intérêt personnel et celui de l'organisation qui l'emploie à celui de son client \parencite[art.~14]{deontOEAQ}. C'est le fondement du désintéressement, que les articles suivants déclinent.
228+
229+\subsection{Indépendance et conflits d'intérêts}
230+
231+L'article 17 est la clé de voûte : « L'évaluateur agréé doit sauvegarder en tout temps son indépendance professionnelle », ce qui implique d'ignorer toute intervention d'un tiers dans l'exercice de son jugement et d'éviter d'évaluer un bien dans lequel lui-même ou son organisation a un intérêt direct ou indirect \parencite[art.~17]{deontOEAQ}. L'article 18 régit le \motcle{double rôle} : celui qui exerce aussi une autre activité, par exemple le courtage ou la gestion immobilière, doit divulguer par écrit ce double rôle et son mode de rémunération, et obtenir du client qu'aucun acte posé dans l'autre rôle ne soit tenu pour une opinion motivée de valeur \parencite[art.~18]{deontOEAQ}. L'article 19 vise le \motcle{conflit d'intérêts}, et il contient une règle propre à l'évaluation municipale : l'évaluateur qui confectionne ou tient à jour le rôle d'une municipalité se place en conflit lorsqu'il accepte d'exécuter pour un tiers une expertise concernant un immeuble situé sur le territoire de cette municipalité ; lorsque les clients consentent à ce qu'il agisse malgré un conflit, la divulgation doit être écrite et mentionnée au rapport \parencite[art.~19]{deontOEAQ}. L'article 20 pose que l'évaluateur n'agit généralement, dans la même affaire, que pour une partie, sauf lorsqu'il intervient comme arbitre ou amiable compositeur avec l'information des parties ; l'article 21 interdit de recevoir ou de verser tout avantage, commission ou ristourne lié à l'exercice, ce qui exclut les ententes de référencement rémunérées avec des courtiers ou des prêteurs \parencite[art.~20--21]{deontOEAQ}.
232+
233+Ces règles répondent à un risque documenté par la recherche. \textcite{lentzWang1998} ont passé en revue les enjeux de l'évaluation résidentielle dans le processus de prêt hypothécaire et montré comment la pression des prêteurs, des courtiers et des emprunteurs, qui ont tous intérêt à ce que la transaction se conclue, s'exerce sur l'évaluateur dont les honoraires dépendent du volume de mandats reçus. La réponse réglementaire canadienne est double. Du côté de l'évaluateur, le Code de déontologie interdit la dépendance ; du côté du prêteur, la ligne directrice B-20 du Bureau du surintendant des institutions financières exige, à son principe 4, des évaluations « realistic, substantiated and supportable », produites par des évaluateurs désignés ou agréés et indépendants du processus de crédit \parencite{osfib20}. L'indépendance est ainsi protégée des deux côtés de la relation.
234+
235+\subsection{Honoraires}
236+
237+L'article 24.1 interdit les \motcle{honoraires conditionnels}, c'est-à-dire les honoraires « dont le montant dépend des résultats », sous réserve de trois exceptions limitées à des services de consultation : la vérification de l'inscription au rôle d'évaluation, la négociation d'indemnités d'expropriation et la vérification des frais d'exploitation d'un bail ; même dans ces cas, les honoraires conditionnels sont interdits lorsque l'évaluateur témoigne devant un tribunal \parencite[art.~24.1--24.3]{deontOEAQ}. La règle, modifiée par la loi de 2025 réformant le système professionnel, a une conséquence nette : un rapport d'évaluation ne peut jamais être rémunéré en fonction de la valeur conclue, de l'obtention du prêt ou du succès d'une contestation. Le raisonnement est le même que pour l'indépendance : une rémunération liée au résultat crée un intérêt dans le résultat, et un intérêt dans le résultat est incompatible avec une opinion neutre et objective, celle-là même que l'attestation de la règle 2.3 fait déclarer.
238+
239+\subsection{Rapport, signature, supervision et secret}
240+
241+L'article 34 oblige l'évaluateur à « signer et indiquer sa qualité d'évaluateur agréé sur tout rapport ou autre document produit dans l'exercice de sa profession » \parencite[art.~34]{deontOEAQ}. L'article 41 exige que le rapport soit conforme aux normes de pratique et fasse état « de la méthodologie utilisée ainsi que de l'étendue des recherches » \parencite[art.~41]{deontOEAQ} ; c'est l'obligation déontologique de transparence méthodologique, pendant de l'élément 8 de la règle 2.3. L'article 33 rend l'évaluateur responsable de tout travail exécuté par d'autres sous sa direction et lui impose de former, de superviser et de réviser \parencite[art.~33]{deontOEAQ}. L'article 51 protège le \motcle{secret professionnel} : l'évaluateur ne peut utiliser les renseignements confidentiels au préjudice de son client ni révéler qu'une personne a fait appel à ses services sans autorisation écrite \parencite[art.~51]{deontOEAQ}. Cette dernière règle a une portée que l'on sous-estime à l'heure des bases de données : les loyers, les états financiers et les prix négociés recueillis dans un mandat ne peuvent pas alimenter librement la banque de comparables du cabinet ; leur réutilisation exige que la source ne soit pas identifiable ou que le client y ait consenti, question que le \cref{ch:13} reprend sous l'angle des données.
242+
243+\begin{table}[htbp]
244+\caption{Situations déontologiques typiques et conduite prescrite par le Code de déontologie des évaluateurs agréés}\label{tab:10:deonto}
245+\centering
246+\small
247+\begin{tabularx}{\textwidth}{YYl}
248+\toprule
249+Situation & Conduite prescrite (paraphrase) & Articles \\
250+\midrule
251+Le prêteur suggère la valeur « nécessaire » pour que le prêt soit approuvé & Ignorer l'intervention ; conclure selon son seul jugement ; documenter la demande au dossier & 17 \\
252+L'évaluateur est aussi courtier et vend l'immeuble qu'on lui demande d'évaluer & Refuser, ou divulguer par écrit le double rôle et la rémunération et obtenir le consentement ; aucun acte de courtage ne vaut opinion de valeur & 17, 18 \\
253+L'évaluateur tient le rôle d'une municipalité et un contribuable lui demande une contre-expertise sur ce territoire & Conflit d'intérêts ; divulgation écrite, consentement des clients, mention au rapport, ou refus & 19 \\
254+Un client propose des honoraires égaux à un pourcentage de la réduction de taxes obtenue & Interdit pour un rapport d'évaluation ; permis seulement pour la consultation sur l'inscription au rôle, jamais devant un tribunal & 24.1--24.3 \\
255+Un courtier offre une rétribution pour chaque dossier référé & Refuser tout avantage, commission ou ristourne & 21 \\
256+Un stagiaire a rédigé le rapport ; l'évaluateur le signe sans le relire & Superviser et réviser ; la signature engage l'entière responsabilité du contenu & 33 ; NPP 2.5 \\
257+Le client demande d'ajouter une clause excluant toute responsabilité & Refuser ; la responsabilité civile ne peut être exclue ni limitée par contrat & 32 \\
258+Un confrère demande les loyers recueillis dans un mandat antérieur pour un autre immeuble & Secret professionnel ; ne pas divulguer sans autorisation ou anonymisation & 51 \\
259+\bottomrule
260+\end{tabularx}
261+\tabsource{synthèse de l'auteur d'après le Code de déontologie des évaluateurs agréés, texte à jour au 1\textsuperscript{er} juillet 2026 \parencite{deontOEAQ} et la règle 2.5 des NPP \parencite{oeaqNormes}. Les situations sont des cas types rédigés pour l'ouvrage.}
262+\end{table}
263+
264+\subsection{Les biais de jugement et l'éthique de l'estimation}
265+
266+La déontologie protège l'évaluateur des pressions extérieures ; elle ne le protège pas de lui-même. La recherche comportementale sur l'évaluation, dont \textcite{diazHansz2002} dressent le bilan, a établi que les évaluateurs sont sujets aux mêmes biais cognitifs que les autres décideurs, et en particulier au \motcle{biais d'ancrage}. \textcite{northcraftNeale1987} ont montré, dans une expérience célèbre, que le prix demandé pour une maison influence l'estimation d'agents immobiliers expérimentés autant que celle d'étudiants, et que les professionnels nient cette influence. \textcite{diaz1990} a observé que le processus réel de décision des évaluateurs s'écarte du processus normatif enseigné, et \textcite{diaz1997} a montré que la connaissance d'une estimation antérieure ancre l'estimation nouvelle. On peut modéliser l'ancrage de façon élémentaire : si $V$ est la valeur que l'évaluateur conclurait sans information parasite et $a$ une ancre (prix demandé, estimation antérieure, montant du prêt souhaité), l'estimation produite s'écrit
267+\begin{equation}\label{eq:10:ancrage}
268+\E[\hat V \mid a] = V + \lambda\,(a - V), \qquad 0 \leq \lambda \leq 1,
269+\end{equation}
270+où $\lambda$ mesure le poids de l'ancre ; $\lambda = 0$ correspond à l'indépendance parfaite et $\lambda = 1$ à l'abdication du jugement. Ce modèle n'est pas une norme, c'est une description ; mais il éclaire le sens des règles. Lorsque la norme exige que l'évaluateur définisse le but, la date et l'étendue du travail \emph{avant} de conclure, lorsque le Code de déontologie lui interdit de recevoir des instructions sur le résultat, lorsque le \cref{ch:16} recommande de fixer les critères de sélection des comparables avant de regarder les prix, il s'agit chaque fois de réduire $\lambda$ en retardant le moment où l'ancre est connue ou en retirant à l'ancre sa pertinence. L'éthique de l'estimation ne se réduit donc pas à l'honnêteté ; elle inclut une discipline de méthode qui protège le jugement contre ses propres raccourcis. Le \cref{ch:20} traite des conséquences de ces biais sur la réconciliation et l'incertitude.
271+
272+\section{Normes internationales et canadiennes : convergences et écarts}\label{sec:10:international}
273+
274+\subsection{Les International Valuation Standards (IVS 2025)}
275+
276+Les \motcle{International Valuation Standards} (IVS) sont publiées par l'International Valuation Standards Council (IVSC), organisme privé établi à Londres. L'édition en vigueur au 31 octobre 2026 est celle publiée le 31 janvier 2024, avec effet le 31 janvier 2025 pour les évaluations réalisées à compter de cette date \parencite[IVS 100, para.~50.01]{ivs2025}. Aucune édition postérieure n'est en vigueur ; l'IVSC a publié en janvier 2026 un projet d'exposition (\emph{exposure draft}) d'une édition qui prendrait effet le 31 janvier 2028, document soumis à consultation et sans valeur normative \parencite{ivs2028ed}. L'édition 2025 comprend des normes générales, IVS 100 (cadre), 101 (étendue du travail), 102 (bases de valeur), 103 (approches), 104 (données et intrants), 105 (modèles) et 106 (documentation et rapport), et des normes d'actifs dont IVS 400 (droits immobiliers) et 410 (immeubles en développement) \parencite{ivs2025}. Deux précisions de numérotation s'imposent, parce que de nombreux textes en circulation reproduisent celle de l'édition 2022 : les bases de valeur sont à l'annexe d'IVS 102 et non d'IVS 104, et les approches sont à IVS 103 et non à IVS 105. La définition de la valeur marchande, à l'annexe A10.01 d'IVS 102, parle du montant estimé auquel un actif « should exchange on the valuation date between a willing buyer and a willing seller in an arm's-length transaction » après une commercialisation adéquate et entre parties informées, prudentes et non contraintes \parencite[IVS 102, A10.01]{ivs2025} ; le \cref{ch:2} compare cette formulation à celle des NPP. Les approches de marché, de revenu et de coût d'IVS 103 correspondent aux méthodes de comparaison, du revenu et du coût des NPP \parencite[IVS 103]{ivs2025}.
277+
278+L'apport propre des IVS 2025 pour le présent ouvrage tient à deux normes récentes, IVS 104 et IVS 105, qui traitent explicitement des données et des modèles d'évaluation. Elles consacrent l'idée que la qualité d'une évaluation dépend de la qualité des intrants et de la documentation du modèle employé, y compris lorsqu'il s'agit d'un modèle statistique ou automatisé, et elles rejoignent ainsi les préoccupations des \cref{ch:14,ch:18,ch:37}. La \cref{sec:10:redaction} en tire des règles de divulgation.
279+
280+\subsection{Le CUSPAP 2026 de l'Institut canadien des évaluateurs}
281+
282+Les \motcle{Canadian Uniform Standards of Professional Appraisal Practice} (CUSPAP) sont les normes de l'Institut canadien des évaluateurs, association nationale qui délivre les désignations AACI et CRA. L'édition 2026 est entrée en vigueur le 1\textsuperscript{er} avril 2026 et s'applique à tout mandat achevé à compter de cette date ; elle remplace l'édition 2024, en vigueur depuis le 1\textsuperscript{er} janvier 2024 \parencite{cuspap2026,cuspap2024}. Elle comprend une section de définitions et huit normes, chacune formée de règles et de commentaires : éthique, rapport, évaluation immobilière, examen, consultation, étude de fonds de prévoyance, machinerie et équipement, évaluation de masse ; son annexe 20 énumère les changements depuis 2024, et son introduction la déclare conforme aux IVS \parencite[s.~1--2]{cuspap2026}. Sa définition de la valeur marchande, « The most probable price, as of a specified date, in cash, or in terms equivalent to cash, or in other precisely revealed terms », renvoie en note à la quatrième édition canadienne de \emph{The Appraisal of Real Estate} \parencite[s.~3]{cuspap2026}. Au Québec, l'évaluateur agréé qui détient aussi une désignation de l'ICE est tenu aux deux corpus ; en cas de divergence, les NPP s'imposent à lui en vertu de l'article 4 du Code de déontologie, et le CUSPAP en vertu de son adhésion à l'Institut. Le CUSPAP est plus explicite que les NPP sur quelques points qui méritent d'être importés dans la pratique : la terminologie des hypothèses examinée plus haut, et l'obligation, pour toute opinion de valeur marchande, de fournir une analyse du délai d'exposition raisonnable (\emph{exposure time}), notion rétrospective distincte du délai de commercialisation (\emph{marketing time}), prospectif \parencite[s.~3 ; règle 8.2]{cuspap2026}.
283+
284+\subsection{L'USPAP 2024 : une vérification partielle}
285+
286+Les \motcle{Uniform Standards of Professional Appraisal Practice} (USPAP) de The Appraisal Foundation sont la référence américaine. Selon les sources consultées, l'édition 2024 est entrée en vigueur le 1\textsuperscript{er} janvier 2024 sans date d'expiration, l'Appraisal Standards Board ayant abandonné le cycle biennal au profit de mises à jour « au besoin » ; aucune édition 2026 n'existe, les documents de 2026 étant un manuel de référence et le cours de mise à jour \parencite{uspap2024,isauspap2026}. Le lecteur doit savoir que cette information est \emph{partiellement vérifiée} : le site de The Appraisal Foundation était inaccessible lors de la vérification des sources, le 31 octobre 2026, et l'état de l'édition repose sur des sources secondaires ; la structure interne de l'USPAP (dix normes et règles d'éthique, de tenue des dossiers, de compétence, d'étendue du travail et d'exception juridictionnelle) n'a pas été revérifiée sur le document lui-même. L'ouvrage cite donc l'USPAP pour situer la pratique québécoise, non pour en exposer le contenu, et la \cref{sec:10:limites} le rappelle.
287+
288+\subsection{Le Red Book de la RICS et les normes de l'IAAO}
289+
290+Le \emph{RICS Valuation --- Global Standards}, dit \motcle{Red Book}, de la Royal Institution of Chartered Surveyors, a été publié en décembre 2024 et est entré en vigueur le 31 janvier 2025, à la même date que les IVS qu'il incorpore ; il comprend des normes professionnelles (PS 1 et PS 2) et six normes techniques, VPS 1 à VPS 6, portant sur les conditions du mandat, les bases de valeur, les approches et méthodes, les inspections et dossiers, les modèles d'évaluation et les rapports ; une édition « Red Book 2028 » est annoncée à la suite de la révision des IVS \parencite{rics2025}. Le Red Book intéresse l'évaluateur québécois dans les mandats pour des investisseurs internationaux ou aux fins des états financiers en IFRS, et sa norme VPS 5 sur les modèles d'évaluation est l'un des textes les plus développés sur la documentation des modèles statistiques.
291+
292+Les normes de l'\motcle{International Association of Assessing Officers} (IAAO) relèvent d'un autre registre : elles ne régissent pas l'évaluateur individuel mais la qualité des évaluations de masse et des études de ratios, et elles servent de référence aux organismes d'évaluation foncière, y compris au Québec à travers les indicateurs du \cref{ch:9}. Le \emph{Standard on Ratio Studies}, approuvé en avril 2013, fixe des plages de coefficient de dispersion de 5,0 à 10,0 pour l'unifamilial des secteurs récents ou homogènes et de 5,0 à 15,0 pour les secteurs anciens ou hétérogènes, un différentiel lié au prix (PRD) de 0,98 à 1,03 et un PRB de $-0{,}05$ à $0{,}05$ \parencite[table 1-3 ; §~9.2]{iaaoratio2013} ; sa page officielle indiquait, en octobre 2026, qu'une révision est en cours de finalisation. Le \emph{Standard on Mass Appraisal of Real Property} a été révisé en 2025 et remplace l'édition de 2017 \parencite{iaaomass2025} ; le \emph{Standard on Automated Valuation Models}, révisé en 2018, renvoie au premier pour les seuils \parencite{iaaoavm2018}. Ces normes sont mobilisées aux \cref{ch:9,ch:18,ch:37}.
293+
294+\subsection{Tableau comparatif et chronologie}
295+
296+Le \cref{tab:10:cadres} compare les cinq cadres sur les dimensions vérifiées : éditeur, édition en vigueur, date d'effet, structure, statut de la vérification et portée pour l'évaluateur québécois. La \cref{fig:10:chronologie} en donne la chronologie documentaire. Trois observations se dégagent. D'abord, les cadres convergent : le CUSPAP se déclare conforme aux IVS, le Red Book les incorpore, et les NPP, bien qu'antérieures dans leur rédaction de fond, partagent avec eux la même définition fonctionnelle de la valeur marchande, la même exigence de divulgation des hypothèses et la même structure rapport--attestation. Ensuite, les rythmes de révision diffèrent : les IVS et le Red Book suivent un cycle de trois ans environ, le CUSPAP un cycle de deux ans, l'USPAP a abandonné son cycle, et les NPP sont révisées norme par norme, de sorte que leur « édition » est un assemblage de textes de dates différentes. Enfin, les écarts portent moins sur le fond que sur le degré d'explicitation : les cadres récents (IVS 104-105, Red Book VPS 5, CUSPAP) disent davantage sur les données, les modèles et le délai d'exposition que les NPP, dont la modernisation annoncée pourrait combler cette distance.
297+
298+\begin{table}[htbp]
299+\caption{Comparaison des cadres normatifs (état au 31 octobre 2026)}\label{tab:10:cadres}
300+\centering
301+\footnotesize
302+\begin{tabularx}{\textwidth}{lYYYYY}
303+\toprule
304+ & NPP (OEAQ) & CUSPAP 2026 (ICE) & IVS 2025 (IVSC) & USPAP 2024 (TAF) & Red Book (RICS) \\
305+\midrule
306+Nature & Normes d'un ordre professionnel (Code des professions) ; portée disciplinaire & Normes d'une association professionnelle nationale & Normes internationales d'application volontaire, adoptées par renvoi & Normes américaines, rendues obligatoires par les lois des États et les organismes fédéraux & Normes d'une association professionnelle internationale \\
307+Édition en vigueur & « En vigueur depuis le 1\textsuperscript{er} février 2024 » ; textes de fond 1998--2016 ; PDF du 24 mars 2025 & Édition 2026, en vigueur le 1\textsuperscript{er} avril 2026 & Publiées le 31 janvier 2024, en vigueur le 31 janvier 2025 & Édition 2024, en vigueur le 1\textsuperscript{er} janvier 2024, sans date d'expiration & Publié en déc. 2024, en vigueur le 31 janvier 2025 \\
308+Structure & 22 normes par paires acte/communication ; annexes A--F & Définitions + 8 normes (règles et commentaires) ; annexe 20 & IVS 100--106 (générales) ; 200--500 (actifs) ; bases de valeur en annexe d'IVS 102 & 10 normes et règles (éthique, dossiers, compétence, étendue du travail) & PS 1--2 ; VPS 1--6 \\
309+Valeur marchande & « prix sincère le plus probable » (Intro-11) & « most probable price » (s.~3) & « estimated amount … should exchange » (IVS 102, A10.01) & non revérifié & renvoie aux IVS \\
310+Hypothèses & Hypothèses extraordinaires et réserves ; incidence sur la valeur (2.1 c), 2.3 él.~7) & Extraordinary assumption, hypothetical condition, extraordinary limiting condition & Hypothèses et hypothèses spéciales (IVS 101) & non revérifié & VPS 1 (conditions du mandat) \\
311+Rapport & 12 éléments (2.3) ; 3 formes ; attestation ; inspection (2.4) ; signature (2.5) & Reporting Standard ; analyse du délai d'exposition (8.2) & IVS 106 Documentation and Reporting & Standard 2 (non revérifié) & VPS 6 Valuation reports \\
312+Prochaine édition & Modernisation annoncée (rapport annuel 2024-2025) & Cycle biennal (2028 probable) & Projet en vigueur le 31 janvier 2028 (exposure draft, janv. 2026) & Mises à jour au besoin & « Red Book 2028 » annoncé \\
313+Vérification & Vérifiée (PDF officiel) & Vérifiée (PDF officiel) & Vérifiée (PDF officiel) & \textbf{Partielle} (site inaccessible ; sources secondaires) & Vérifiée (page RICS ; PDF non téléchargé) \\
314+\bottomrule
315+\end{tabularx}
316+\tabsource{synthèse de l'auteur d'après \textcite{oeaqNormes,oeaqra2025,cuspap2026,ivs2025,ivs2028ed,uspap2024,isauspap2026,rics2025}, sources consultées le 31 octobre 2026 ; registre \texttt{sources/registry-C.csv}.}
317+\end{table}
318+
319+\begin{figure}[htbp]
320+\centering
321+\includegraphics[width=\textwidth]{ch10/ch10-fig-chronologie-cadres.pdf}
322+\caption{Chronologie documentaire des cadres normatifs : date d'entrée en vigueur (point plein), date de publication lorsqu'elle est documentée (point creux), fin de validité (barre) et projet (pointillé). Au 31 octobre 2026, huit des dix documents sont en vigueur ou servent de référence ; le CUSPAP 2024 est remplacé et les IVS 2028 ne sont qu'un projet. L'astérisque signale la vérification partielle de l'USPAP.}\label{fig:10:chronologie}
323+\figsource{dates tirées de \texttt{sources/registry-C.csv} (consultation du 31 octobre 2026) ; script \texttt{analysis/python/figures/ch10\_figures.py}. Pour l'IAAO 2025 et le projet IVS 2028, seuls l'année ou le mois sont documentés ; les points sont placés au début de la période.}
324+\end{figure}
325+
326+\subsection{Ce que la matrice des dispositions révèle}
327+
328+Le dépôt de cet ouvrage recense dans un fichier, \texttt{sources/normes-C.csv}, chacune des dispositions normatives, déontologiques, réglementaires et prudentielles vérifiées pour le domaine des normes et de la finance, avec sa référence précise, sa version, sa date de consultation et les chapitres qui la mobilisent. Ce fichier n'est pas un objet d'étude en soi, mais ses statistiques descriptives, calculées par le script \texttt{analysis/python/cases/ch10\_matrice\_normes.py}, disent quelque chose de la structure du cadre normatif tel que ce traité le mobilise. Le \cref{tab:10:matrice} et la \cref{fig:10:matrice} les résument. Sur 100 dispositions réparties en 17 domaines, 43 proviennent de l'OEAQ et du Code des professions, 27 des cadres hors Québec (IVS, CUSPAP, USPAP, RICS, IAAO), 20 des autorités financières et prudentielles et 10 des diffuseurs de données de marché. Le présent chapitre est visé par 51 dispositions, plus que tout autre, devant le \cref{ch:31} (15) et le \cref{ch:20} (14) ; chaque disposition vise en moyenne 1,70 chapitre, 51 \% en visent plus d'un, et aucune n'en vise plus de quatre. La lecture de ces chiffres est simple : les règles professionnelles forment un socle concentré dans ce chapitre, tandis que les règles de finance et de données se dispersent dans les chapitres techniques ; et la moitié des dispositions servent à plusieurs endroits, ce qui justifie qu'elles soient tenues dans un registre unique plutôt que recopiées de chapitre en chapitre.
329+
330+\begin{table}[htbp]
331+\caption{Statistiques descriptives de la matrice des dispositions du domaine C (\texttt{sources/normes-C.csv}, 100 dispositions vérifiées le 31 octobre 2026)}\label{tab:10:matrice}
332+\centering
333+\small
334+\begin{tabular}{lS[table-format=3.0]S[table-format=2.0]}
335+\toprule
336+Famille de sources & {Dispositions} & {dont visant le ch.~10} \\
337+\midrule
338+OEAQ --- normes de pratique (NPP) & 15 & 12 \\
339+OEAQ --- déontologie (C-26, r.~123) & 14 & 14 \\
340+OEAQ --- règlements et Code des professions & 14 & 14 \\
341+Cadres hors Québec (IVS, CUSPAP, USPAP, RICS, IAAO) & 27 & 10 \\
342+Finance et prudentiel (Banque du Canada, BSIF, Finances Canada, SCHL) & 20 & 1 \\
343+Données de marché (Statistique Canada, APCIQ, JLR, Teranet) & 10 & 0 \\
344+\midrule
345+Total & 100 & 51 \\
346+\bottomrule
347+\end{tabular}
348+\tabsource{\texttt{analysis/python/cases/ch10\_matrice\_normes.py} ; regroupement des 17 domaines en six familles par l'auteur.}
349+\end{table}
350+
351+\begin{figure}[htbp]
352+\centering
353+\includegraphics[width=\textwidth]{ch10/ch10-fig-matrice-normes.pdf}
354+\caption{La matrice des dispositions du domaine C : à gauche, répartition des 100 dispositions par famille de sources (les familles OEAQ en couleur) ; à droite, les dix chapitres les plus visés, une disposition pouvant viser plusieurs chapitres. Le chapitre 10 concentre les règles professionnelles ; les règles financières et de données se dispersent dans les chapitres techniques.}\label{fig:10:matrice}
355+\figsource{\texttt{sources/normes-C.csv} (consultation du 31 octobre 2026) ; script \texttt{analysis/python/figures/ch10\_figures.py}.}
356+\end{figure}
modified manuscript/frontmatter/titlepage.tex +1 −1
@@ -2,7 +2,7 @@
2 \begin{tikzpicture}[remember picture, overlay] 2 \begin{tikzpicture}[remember picture, overlay]
3 \fill[brandPrimary] (current page.north west) rectangle ([yshift=-120mm]current page.north east); 3 \fill[brandPrimary] (current page.north west) rectangle ([yshift=-120mm]current page.north east);
4 \fill[brandSecondary] ([yshift=-120mm]current page.north west) rectangle ([yshift=-123mm]current page.north east); 4 \fill[brandSecondary] ([yshift=-120mm]current page.north west) rectangle ([yshift=-123mm]current page.north east);
5− \node[anchor=north west, text=white, font=\sffamily\bfseries\fontsize{30}{36}\selectfont, text width=130mm, align=left, execute at begin node=\hyphenpenalty=10000\exhyphenpenalty=10000\relax] 5+ \node[anchor=north west, text=white, font=\sffamily\bfseries\fontsize{30}{36}\selectfont, text width=130mm, align=left, execute at begin node={\hyphenpenalty=10000 \exhyphenpenalty=10000 \relax}]
6 at ([xshift=22mm, yshift=-40mm]current page.north west) {\livreTitre}; 6 at ([xshift=22mm, yshift=-40mm]current page.north west) {\livreTitre};
7 \node[anchor=north west, text=brandSecondary, font=\sffamily\fontsize{16}{20}\selectfont, text width=130mm] 7 \node[anchor=north west, text=brandSecondary, font=\sffamily\fontsize{16}{20}\selectfont, text width=130mm]
8 at ([xshift=22mm, yshift=-92mm]current page.north west) {\livreSousTitre}; 8 at ([xshift=22mm, yshift=-92mm]current page.north west) {\livreSousTitre};
added verification/phase-3-report.md +25 −0
@@ -0,0 +1,25 @@
1+# Phase 3 — Prototype réel (2026-10-31)
2+
3+## Artefacts
4+- Chapitres entièrement rédigés : 16 (orchestrateur, 28 p.), 21 (35 p.), 29 (35 p.), 30 (37 p.), 47 (50 p., dossier complet avec Monte-Carlo) ; 74422 mots de prose au total pour ces cinq chapitres ; solutions complètes ; chaque chiffre recalculé par un script (`analysis/python/cases/ch{16,21,29,30,47}_*.py`) et vérifié par des tests (125 tests verts au total, dont test_cas1/2/3/4).
5+- Modules partagés `traite/cas1.py` (sélection ch. 16, modèle hédonique de référence R² 0,853, ajustements dérivés, prédiction 637 953 $ ± IC), `cas2.py`, `cas3.py` (RNE 89 956 $, taux médian 4,415 %, V_cap 2 037 503 $, V_DCF 2 208 566 $, prêt 1 475 959 $ limité par le RCD), `cas4.py` : les chapitres 29, 30 et 47 publient des chiffres identiques.
6+- **PDF pilote** `build/main.pdf` : 338 pages (5 chapitres rédigés + 43 chapitres en production marqués `stubchapter` + annexes), compilation LuaLaTeX + biber + makeindex sans erreur fatale ; références non définies restantes : labels internes du ch. 10 en cours de rédaction.
7+- Classeurs Excel (`spreadsheets/`) à formules vivantes : grille cas 1, DCF/financement cas 3, calculateur hypothécaire.
8+- Outils : `tools/compile_chapter.sh` (compilation isolée avec étiquettes inter-chapitres), `tools/make_labels.py`, `tools/merge_sources.py` (fusion des registres par chapitre), `tools/check_manuscript.py`, `tools/inventaire.py`, `tools/BRIEF-CHAPITRE.md`, `tools/BRIEFS-PHASE4.md`.
9+
10+## Vérification des chiffres du pilote
11+- Ch. 16 : entonnoir 600→236→40→27→16→5 ; indication brute 551 029 $ ; sensibilité 525 768–608 014 $ (48 combinaisons) — `test_ch16.py` (4 tests).
12+- Ch. 21 : D = 298 826 $, I = 62 599 $ (20,95 %), incitatif 10 % = 36 142 $, C_N = 397 567 $ (2 409 $/m²) ; IPCB Montréal réel 66,1 (2017T1) → 116,5 (2026T3) — `test_ch21.py` (21 tests).
13+- Ch. 29/30/47 : identités VAN(TRI)=0, VT = RNE₁₁/R_T, Ellwood ≡ Akerson, DCF ≡ Gordon sous croissance constante ; TRI fonds propres 11,22 % ; MC : médiane 2 191 608 $, IC 90 % [1 735 194 ; 2 876 902], P(RCD₁ < 1,20) = 49,4 % ; valeur finale ch. 47 : 2 060 000 $ — `test_ch29/30/47.py` (38 tests).
14+
15+## Erreurs rencontrées et corrigées
16+- `\cref` avec article dans les noms → doublons (« du le chapitre ») : convention changée (noms sans article), documentée dans CONVENTIONS §3, rédacteurs avertis.
17+- Compilation isolée : labels inter-chapitres indéfinis → `tools/make_labels.py` (format exact des `.aux` d'hyperref/cleveref) ; `\newlabel` interdit hors préambule → `\@newl@bel` dans le préambule.
18+- Page titre : option TikZ `execute at begin node` non protégée par accolades → erreur « Missing number » dans le livre complet (pas dans les chapitres isolés) → corrigée.
19+- biber universel cassé (macOS 27) → shim arm64 (phase 0) ; `%` non échappés dans des notes bib → échappés.
20+
21+## Points non vérifiés (reportés dans verification/claims.csv)
22+Taux de taxe « 6 logements et plus » et taxe d'arrondissement de Montréal 2026 ; TAL 2027 ; APH Select (cellules 70/100 points) ; fourchette Altus relayée ; Akerson 1970 non vérifié à la page ; paramètres TVQ de l'habitation neuve ; seuils d'ajustement brut/net (pratique non normée) ; taux d'actualisation 7,25 % du cas 3 = hypothèse déclarée (aucune enquête publique québécoise).
23+
24+## Prochaines actions
25+Phase 4 en cours : 15 rédacteurs lancés (ch. 1-11, 17-19) ; lots suivants au fil des livraisons (12-15, 20, 22-28, 31-46, 48, annexes). Puis phase 5 (glossaire, index, harmonisation des renvois et des chiffres), phase 6 (QA : `make test`, inspection visuelle, revue des citations), phase 7 (livraison).
modified verification/review_log.md +1 −0
@@ -4,3 +4,4 @@
4 |---|---|---|---| 4 |---|---|---|---|
5 | 2026-10-31 | 0 | Création du dépôt, audit de l'environnement, style `traite-qc.sty`, Makefile, outils de vérification | Claude (orchestrateur) | 5 | 2026-10-31 | 0 | Création du dépôt, audit de l'environnement, style `traite-qc.sty`, Makefile, outils de vérification | Claude (orchestrateur) |
6 | 2026-10-31 | 1 | Cinq lots de sources vérifiés et fusionnés (registre, matrice des normes, bibliographie 422 clés, calibration 204 paramètres, données brutes 315 Mo) ; générateur synthétique recalibré | Claude + 5 agents | 6 | 2026-10-31 | 1 | Cinq lots de sources vérifiés et fusionnés (registre, matrice des normes, bibliographie 422 clés, calibration 204 paramètres, données brutes 315 Mo) ; générateur synthétique recalibré | Claude + 5 agents |
7+| 2026-10-31 | 3 | Prototype : ch. 16, 21, 29, 30, 47 rédigés et testés ; PDF pilote 338 p. ; modules cas1-4 | Claude + 4 agents |
7 8
Menu

Theme