Phase 3 : PDF pilote (338 p.), rapport de phase, correction page titre
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
48 changed files +2,033 −43
modified
analysis/python/cases/ch03_cycles.py
+50 −26
@@ -145,12 +145,27 @@ def elasticites_4q(p: Parametres4Q) -> dict: | ||
| 145 | 145 | class ParametresCycle: |
| 146 | 146 | p4q: Parametres4Q = Parametres4Q() |
| 147 | 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 | 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) | |
| 151 | − t_choc: int = 8 # trimestre du choc | |
| 152 | − choc_demande: float = 0.10 # hausse permanente de l'ordonnée de demande (+10 %) | |
| 153 | − mult_c1: float = 1.0 # multiplicateur de la pente d'offre c1 (1 = référence ; >1 = offre moins élastique) | |
| 150 | + T: int = 100 # horizon (trimestres) | |
| 151 | + t_choc: int = 8 # trimestre où le choc commence | |
| 152 | + n_choc: int = 12 # durée de montée du choc (trimestres) : la demande croît linéairement puis se stabilise | |
| 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 | 171 | def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame: |
@@ -158,18 +173,19 @@ def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame: | ||
| 158 | 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 | 174 | L'état initial est stationnaire avec v = v* (l'ordonnée a est ajustée pour que D0 = (1 − v*) S0).""" |
| 160 | 175 | p = pc.p4q |
| 161 | − c1 = p.c1 * pc.mult_c1 | |
| 176 | + c1, c0 = pc.c1, pc.c0 | |
| 162 | 177 | b = p.b |
| 163 | 178 | a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0 |
| 164 | 179 | S, R = p.S0, p.R0 |
| 165 | 180 | starts = [p.C0 / 4.0] * (pc.L + 1) # chantiers en cours au régime stationnaire |
| 166 | 181 | rows = [] |
| 167 | 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 | 185 | D = min(S, a - b * R) |
| 170 | 186 | v = 1.0 - D / S |
| 171 | 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 | 189 | rows.append({"t": t, "annee": t / 4.0, "S": S, "D": D, "v": v, "R": R, "P": P, "C": C, "a": a}) |
| 174 | 190 | starts.append(C) |
| 175 | 191 | livraison = starts[-(pc.L + 1)] |
@@ -188,9 +204,9 @@ def etat_stationnaire_cycle(pc: ParametresCycle, apres_choc: bool = True) -> dic | ||
| 188 | 204 | a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0 |
| 189 | 205 | a = a0 * (1.0 + pc.choc_demande) if apres_choc else a0 |
| 190 | 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 | |
| 192 | − S = (a - b * p.i * p.c0) / ((1.0 - pc.v_nat) + b * p.i * c1 * p.delta) | |
| 193 | − P = p.c0 + c1 * p.delta * S | |
| 207 | + c1, c0 = pc.c1, pc.c0 | |
| 208 | + S = (a - b * p.i * c0) / ((1.0 - pc.v_nat) + b * p.i * c1 * p.delta) | |
| 209 | + P = c0 + c1 * p.delta * S | |
| 194 | 210 | R = p.i * P |
| 195 | 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 | 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 | 235 | rows = [] |
| 220 | 236 | for L in delais: |
| 221 | 237 | for m in mult_c1: |
| 222 | 238 | for lam in lams: |
| 223 | 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 | 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 | 303 | return piv |
| 288 | 304 | |
| 289 | 305 | |
| 290 | −def mises_en_chantier_montreal_annuelles() -> pd.Series: | |
| 291 | − """34-10-0143-01 : RMR de Montréal, mensuel, total d'unités, sommes annuelles.""" | |
| 306 | +def mises_en_chantier_montreal_annuelles(geo: str = "Montréal, Québec") -> pd.DataFrame: | |
| 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 | 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 | 312 | and r["Type d'unité"] == "Total d'unités") |
| 295 | 313 | s = pd.Series({r["PÉRIODE DE RÉFÉRENCE"]: float(r["VALEUR"]) for r in rows}) |
| 296 | 314 | s.index = pd.to_datetime(s.index) |
@@ -337,11 +355,13 @@ def taux_effectifs_a4(mois: str = "2026-08-01") -> dict: | ||
| 337 | 355 | d = dict(zip(hdr, rec)) |
| 338 | 356 | # libellés des séries (bloc SERIES du fichier) |
| 339 | 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] | |
| 341 | − non_assure = [k for k, v in labels.items() if "Uninsured" in v and "5 years" in v and "Funds advanced" in v] | |
| 342 | − return {"mois": mois, "assure_fixe_5ans_plus": float(d[assure[0]]) if assure else float("nan"), | |
| 343 | − "non_assure_fixe_5ans_plus": float(d[non_assure[0]]) if non_assure else float("nan"), | |
| 344 | − "labels_assure": [labels[k] for k in assure], "labels_non_assure": [labels[k] for k in non_assure]} | |
| 358 | + desc = {r[0]: (r[1], r[2]) for r in rows[:i] if len(r) >= 3 and r[0].startswith("V1226677")} | |
| 359 | + def _cle(assure: bool): | |
| 360 | + cible = "Residential mortgages, insured" if assure else "Residential mortgages, uninsured" | |
| 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] | |
| 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 | 367 | def ipc_quebec(produit: str = "Ensemble") -> pd.Series: |
@@ -480,22 +500,26 @@ def main() -> None: | ||
| 480 | 500 | print("\n=== 2. Dynamique stock-flux (SYNTHÉTIQUE) ===") |
| 481 | 501 | pc = ParametresCycle() |
| 482 | 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 | 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 | 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): | |
| 486 | − rc = racines_caracteristiques(el["kappa"], p.delta, L) | |
| 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']}") | |
| 507 | + for kap in (el["kappa"], pc.kappa): | |
| 508 | + for L in (1, 2, 3): | |
| 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 | 511 | for k in (0.12, 0.30, 0.60, 1.20): |
| 489 | 512 | c = condition_oscillation_L1(k, p.delta) |
| 490 | 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 | 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 | 517 | print("\n=== 3. Données réelles ===") |
| 495 | 518 | ann = mises_en_chantier_annuelles() |
| 496 | 519 | cols = ["total", "individuelles", "collectifs", "part_individuelles", "part_collectifs", "trimestres"] |
| 497 | 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 | 523 | ch = changements_taux_directeur(); print(ch) |
| 500 | 524 | print(taux_effectifs_a4()) |
| 501 | 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 | 18 | res = [cas.emphyteose(noi, cas.RENTE_EMPH, int(k)) for k in n] |
| 19 | 19 | emph = np.array([r["V_emph"] for r in res]) / 1e6; rentes = np.array([r["V_rentes"] for r in res]) / 1e6 |
| 20 | 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 | 22 | ax.stackplot(n, emph, rentes, reprise, colors=[palette.cat[0], palette.cat[1], palette.cat[2]], alpha=0.85, |
| 23 | 23 | labels=["droit de l'emphytéote (RNE − rente)", "propriétaire : rentes actualisées", "propriétaire : reprise de l'immeuble au terme"]) |
| 24 | 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.)")): | |
| 26 | − ax.axvline(seuil, color=palette.ink2, ls=":", lw=1); ax.text(seuil + 1, 0.12, txt, fontsize=7, color=palette.ink2, va="bottom") | |
| 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(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 | 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 | 29 | fig.tight_layout() |
| 30 | 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 | 11 | def main(): |
| 12 | 12 | setup() |
| 13 | 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 | 15 | blocs = [("Charges courantes (q_i × budget)", rep["par_fraction_courantes"], palette.cat[0]), |
| 16 | 16 | ("Fonds de prévoyance (q_i × A*)", rep["par_fraction_fonds"], palette.cat[1]), |
| 17 | 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 | 13 | A = cas.cotisation_seuil(); regle = 0.005 * cas.VALEUR_RECONSTRUCTION |
| 14 | 14 | t_act, t_regle, t_star = cas.trajectoire(cas.COTISATION_ACTUELLE), cas.trajectoire(regle), cas.trajectoire(A) |
| 15 | 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 | 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 | 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 | 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 | 21 | ax.axhline(0, color=palette.ink2, lw=0.8) |
| 22 | 22 | ax.yaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)) |
| 23 | 23 | ax.set_xlabel("Année"); ax.set_ylabel("Solde du fonds en fin d'année") |
| 24 | 24 | ax.set_xlim(2025.5, 2051.5) |
| 25 | − ax.legend(loc="upper left", 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, | |
| 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=(2043.5, -80000), fontsize=7.5, color=palette.ink2, | |
| 27 | 27 | arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7)) |
| 28 | 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 | 26 | ax.set_xlim(avec.min() - 15000, sans.max() + 60000) |
| 27 | 27 | ax.set_xlabel("Prix de vente (dollars de 2026, données synthétiques)") |
| 28 | 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 | 30 | loc="left", fontsize=8.5) |
| 31 | 31 | ax.legend(loc="lower right", fontsize=7.5) |
| 32 | 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 | 11 | def main(): |
| 12 | 12 | setup() |
| 13 | 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 | 15 | for i, (r, row) in enumerate(s["grille"].items()): |
| 16 | 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=":") | |
| 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) | |
| 19 | − ax.axhline(cas.COTISATION_ACTUELLE, color=palette.cat[3], lw=0.9, ls=":") | |
| 20 | − ax.text(1.52, cas.COTISATION_ACTUELLE + 250, "cotisation actuelle (12 000 $)", fontsize=7.5, color=palette.cat[3]) | |
| 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.axhline(cas.COTISATION_ACTUELLE, color=palette.cat[3], lw=0.9, ls=":", label="cotisation actuelle (12 000 $)") | |
| 19 | + ax.set_xticks([p * 100 for p in s["inflations"]]); ax.set_xticklabels([f"{p*100:.1f}".replace(".", ",") for p in s["inflations"]]) | |
| 21 | 20 | ax.set_xlabel("Hausse annuelle des coûts de construction π (%)"); ax.set_ylabel("Cotisation de seuil A* (an 1)") |
| 22 | 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 | 23 | out = savefig(fig, "ch07/ch07-fig-sensibilite-cotisation.pdf"); print("OK", out) |
| 25 | 24 | |
| 26 | 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
Large diff (162 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/tests/test_ch09.py
+190 −0
Large diff (190 lines) is not loaded by default to keep the page fast.Load diff
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
Large diff (138 lines) is not loaded by default to keep the page fast.Load diff
modified
manuscript/frontmatter/titlepage.tex
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
verification/phase-3-report.md
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/review_log.md
+1 −0
Large diff (5 lines) is not loaded by default to keep the page fast.Load diff