Ch. 47 livré (50 p., dossier complet, Monte-Carlo) ; renvois normalisés
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
60 changed files +5,653 −58
added
analysis/python/cases/ch02_droits.py
+176 −0
@@ -0,0 +1,176 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 2 — Droits immobiliers et bases de valeur : calculs de l'exemple intégral sur le cas 3 (12 logements, Montréal, | ||||
| 3 | +DONNÉES SYNTHÉTIQUES). Valeur du droit du locateur (baux en place) vs valeur de la pleine propriété libre de baux (loyers de | ||||
| 4 | +marché) ; valeur des baux pour les locataires ; sensibilité à la rotation et au taux d'actualisation ; chiffres des exercices. | ||||
| 5 | +Toutes les grandeurs du cas 3 proviennent de `traite.cas3` (module partagé) ; les formules financières de `traite.finance`. | ||||
| 6 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch02_droits.py""" | ||||
| 7 | +from __future__ import annotations | ||||
| 8 | +import sys, copy, pathlib | ||||
| 9 | +import numpy as np, pandas as pd | ||||
| 10 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 11 | +from traite import cas3, finance as fin | ||||
| 12 | + | ||||
| 13 | +ROTATIONS = [0.0, 1 / 12, 2 / 12, 3 / 12, 6 / 12] # 0, 1, 2, 3, 6 logements reloués par an (le module arrondit ρ × 12 à l'entier) | ||||
| 14 | +TAUX = [0.0625, 0.0725, 0.0825] | ||||
| 15 | + | ||||
| 16 | + | ||||
| 17 | +# ----------------------------------------------------------------------------- écart contractuel / marché | ||||
| 18 | + | ||||
| 19 | +def etat_marche(etat: pd.DataFrame) -> pd.DataFrame: | ||||
| 20 | + """État locatif hypothétique « libre de baux » : chaque logement est reloué au loyer de marché dès l'année 1.""" | ||||
| 21 | + e = etat.copy() | ||||
| 22 | + e["loyer_mensuel_contrat"] = e["loyer_mensuel_marche"].astype(float) | ||||
| 23 | + return e | ||||
| 24 | + | ||||
| 25 | + | ||||
| 26 | +def ecart_par_logement(etat: pd.DataFrame, annee_eval: int = 2026) -> pd.DataFrame: | ||||
| 27 | + e = etat.copy() | ||||
| 28 | + e["ecart_mensuel"] = e["loyer_mensuel_marche"] - e["loyer_mensuel_contrat"] | ||||
| 29 | + e["ecart_annuel"] = 12 * e["ecart_mensuel"] | ||||
| 30 | + e["ratio_contrat_marche"] = e["loyer_mensuel_contrat"] / e["loyer_mensuel_marche"] | ||||
| 31 | + e["anciennete"] = annee_eval - e["locataire_depuis"] | ||||
| 32 | + return e[["logement", "pieces", "locataire_depuis", "anciennete", "loyer_mensuel_contrat", "loyer_mensuel_marche", | ||||
| 33 | + "ecart_mensuel", "ecart_annuel", "ratio_contrat_marche"]] | ||||
| 34 | + | ||||
| 35 | + | ||||
| 36 | +# ----------------------------------------------------------------------------- formules fermées (équations du chapitre) | ||||
| 37 | + | ||||
| 38 | +def valeur_bail_locataire(ecart_annuel: float, n: int, r: float) -> float: | ||||
| 39 | + """Valeur du bail pour le locataire = Σ_{t=1}^{n} (L_marché − L_contrat)/(1+r)^t, écart constant (éq. annuité).""" | ||||
| 40 | + return ecart_annuel * fin.facteur_va_annuite(r, n) | ||||
| 41 | + | ||||
| 42 | + | ||||
| 43 | +def valeur_droit_temporaire_et_reversion(revenu_annuel: float, n: int, r: float, valeur_reversion: float) -> dict: | ||||
| 44 | + """Décomposition d'un droit temporaire (usufruit, bail au locateur) et de la réversion (nue-propriété).""" | ||||
| 45 | + va_droit = revenu_annuel * fin.facteur_va_annuite(r, n) | ||||
| 46 | + va_rev = valeur_reversion * fin.facteur_va(r, n) | ||||
| 47 | + return {"droit_temporaire": va_droit, "reversion": va_rev, "total": va_droit + va_rev} | ||||
| 48 | + | ||||
| 49 | + | ||||
| 50 | +def usufruit_nue_propriete(revenu_annee1: float, g: float, r: float, n: int) -> dict: | ||||
| 51 | + """Usufruit de n ans sur un bien dont le revenu net croît au taux g : VA de l'annuité croissante ; nue-propriété = réversion | ||||
| 52 | + V_n = R_{n+1}/(r−g) actualisée ; la somme redonne la pleine propriété V_0 = R_1/(r−g) (identité de Gordon).""" | ||||
| 53 | + flux = [revenu_annee1 * (1 + g) ** (t - 1) for t in range(1, n + 1)] | ||||
| 54 | + va_usufruit = fin.van([0.0] + flux, r) | ||||
| 55 | + v_pleine = revenu_annee1 / (r - g) | ||||
| 56 | + v_n = revenu_annee1 * (1 + g) ** n / (r - g) | ||||
| 57 | + va_nue = v_n * fin.facteur_va(r, n) | ||||
| 58 | + return {"V_pleine": v_pleine, "usufruit": va_usufruit, "nue_propriete": va_nue, "somme": va_usufruit + va_nue, "V_n": v_n, | ||||
| 59 | + "part_usufruit": va_usufruit / v_pleine} | ||||
| 60 | + | ||||
| 61 | + | ||||
| 62 | +def va_ecart_rotation(ecart_net_annee1: float, r: float, g: float, rho: float, T: int | None = None) -> float: | ||||
| 63 | + """Valeur actuelle de l'écart contractuel/marché sous rotation ρ : écart_t = Δ_1 [(1−ρ)(1+g)]^{t−1}. | ||||
| 64 | + Perpétuité (T=None) : Δ_1 / (r − g + ρ(1+g)) ; sinon somme finie.""" | ||||
| 65 | + q = (1 - rho) * (1 + g) / (1 + r) | ||||
| 66 | + if T is None: | ||||
| 67 | + if q >= 1: | ||||
| 68 | + raise ValueError("série divergente : (1−ρ)(1+g) ≥ 1+r") | ||||
| 69 | + return ecart_net_annee1 / (1 + r) / (1 - q) | ||||
| 70 | + return ecart_net_annee1 / (1 + r) * (1 - q ** T) / (1 - q) if abs(1 - q) > 1e-14 else ecart_net_annee1 * T / (1 + r) | ||||
| 71 | + | ||||
| 72 | + | ||||
| 73 | +# ----------------------------------------------------------------------------- décomposition par le module partagé | ||||
| 74 | + | ||||
| 75 | +def decomposition(d: dict, rotation: float | None = None, taux: float | None = None) -> dict: | ||||
| 76 | + """V_pleine (libre de baux, loyers de marché) − V_locateur (baux en place) = valeur agrégée des baux pour les locataires. | ||||
| 77 | + Les deux valeurs sont des DCF de `traite.cas3` avec les mêmes hypothèses (dépenses, CAPEX, taux terminal, coûts de vente).""" | ||||
| 78 | + hyp = copy.deepcopy(d["hyp"]) | ||||
| 79 | + if rotation is not None: | ||||
| 80 | + hyp["rotation_annuelle"] = rotation | ||||
| 81 | + if taux is not None: | ||||
| 82 | + hyp["taux_actualisation"] = taux | ||||
| 83 | + proj_c = cas3.projection_dcf(d["etat"], d["dep"], hyp) | ||||
| 84 | + proj_m = cas3.projection_dcf(etat_marche(d["etat"]), d["dep"], hyp) | ||||
| 85 | + v_c = cas3.valeur_dcf(proj_c, hyp); v_m = cas3.valeur_dcf(proj_m, hyp) | ||||
| 86 | + rne_c = cas3.rne(d["etat"], d["dep"], hyp); rne_m = cas3.rne(etat_marche(d["etat"]), d["dep"], hyp) | ||||
| 87 | + return {"rotation": hyp["rotation_annuelle"], "taux": hyp["taux_actualisation"], "proj_contrat": proj_c, "proj_marche": proj_m, | ||||
| 88 | + "V_locateur": v_c["valeur"], "V_pleine": v_m["valeur"], "V_locataires": v_m["valeur"] - v_c["valeur"], | ||||
| 89 | + "part_locataires": (v_m["valeur"] - v_c["valeur"]) / v_m["valeur"], | ||||
| 90 | + "va_flux_contrat": v_c["va_flux"], "va_flux_marche": v_m["va_flux"], "va_vt_contrat": v_c["va_terminale"], "va_vt_marche": v_m["va_terminale"], | ||||
| 91 | + "rne1_contrat": rne_c["rne"], "rne1_marche": rne_m["rne"], "rbe_contrat": rne_c["rbe"], "rbe_marche": rne_m["rbe"]} | ||||
| 92 | + | ||||
| 93 | + | ||||
| 94 | +def capitalisation_naive(dec: dict, taux_cap: float) -> dict: | ||||
| 95 | + """Erreur classique : capitaliser l'écart de RNE de l'année 1 à perpétuité (comme si les baux ne se renouvelaient jamais au marché).""" | ||||
| 96 | + v_c = fin.capitalisation_directe(dec["rne1_contrat"], taux_cap); v_m = fin.capitalisation_directe(dec["rne1_marche"], taux_cap) | ||||
| 97 | + return {"Vcap_contrat": v_c, "Vcap_marche": v_m, "ecart_capitalise": v_m - v_c} | ||||
| 98 | + | ||||
| 99 | + | ||||
| 100 | +def sensibilite(d: dict) -> pd.DataFrame: | ||||
| 101 | + rows = [] | ||||
| 102 | + for rho in ROTATIONS: | ||||
| 103 | + for r in TAUX: | ||||
| 104 | + dec = decomposition(d, rotation=rho, taux=r) | ||||
| 105 | + rows.append({"rotation": rho, "taux": r, "V_locateur": dec["V_locateur"], "V_pleine": dec["V_pleine"], | ||||
| 106 | + "V_locataires": dec["V_locataires"], "part": dec["part_locataires"]}) | ||||
| 107 | + return pd.DataFrame(rows) | ||||
| 108 | + | ||||
| 109 | + | ||||
| 110 | +# ----------------------------------------------------------------------------- exercices | ||||
| 111 | + | ||||
| 112 | +def exercices(d: dict, base: dict) -> dict: | ||||
| 113 | + r = d["hyp"]["taux_actualisation"] | ||||
| 114 | + e = ecart_par_logement(d["etat"]) | ||||
| 115 | + l103 = e.loc[e["logement"] == 103].iloc[0] | ||||
| 116 | + ex3 = {"ecart_mensuel": float(l103["ecart_mensuel"]), "ecart_annuel": float(l103["ecart_annuel"]), | ||||
| 117 | + "va_5ans": valeur_bail_locataire(float(l103["ecart_annuel"]), 5, r), "va_10ans": valeur_bail_locataire(float(l103["ecart_annuel"]), 10, r), | ||||
| 118 | + "va_perpetuite": float(l103["ecart_annuel"]) / r} | ||||
| 119 | + # ex 4 : usufruit 15 ans sur un immeuble de 600 000 $ ; revenu net 30 000 $ croissant à 1 %/an ; r = 6 % (V = R_1/(r−g) = 600 000 $) | ||||
| 120 | + ex4 = usufruit_nue_propriete(30000.0, 0.01, 0.06, 15) | ||||
| 121 | + # ex 5 : valeur au rôle fictive du cas 1, 468 000 $ au 2024-07-01 ; indice +4,43 %/an ; date d'évaluation 2026-09-01 (26 mois) | ||||
| 122 | + ex5 = {"role": 468000.0, "mois": 26, "indice": 1.0443 ** (26 / 12), "valeur_indexee": 468000.0 * 1.0443 ** (26 / 12)} | ||||
| 123 | + # ex 6 : part des locataires selon la rotation (taux de base) — repris de la sensibilité | ||||
| 124 | + # ex 7 : hypothèse extraordinaire : décontamination 150 000 $ + stigmate 5 % sur la valeur DCF du locateur | ||||
| 125 | + v = base["V_locateur"] | ||||
| 126 | + ex7 = {"V_hyp": v, "couts": 150000.0, "stigmate": 0.05, "V_sans_hyp": (v - 150000.0) * 0.95, "incidence": v - (v - 150000.0) * 0.95} | ||||
| 127 | + return {"ex3": ex3, "ex4": ex4, "ex5": ex5, "ex7": ex7} | ||||
| 128 | + | ||||
| 129 | + | ||||
| 130 | +# ----------------------------------------------------------------------------- principal | ||||
| 131 | + | ||||
| 132 | +def main(verbose: bool = True) -> dict: | ||||
| 133 | + d = cas3.charger() | ||||
| 134 | + rev = cas3.revenus(d["etat"], d["hyp"]) | ||||
| 135 | + e = ecart_par_logement(d["etat"]) | ||||
| 136 | + base = decomposition(d) | ||||
| 137 | + cap = capitalisation_naive(base, cas3.taux_extraits(d["comps"])["mediane"]) | ||||
| 138 | + hyp = d["hyp"]; r = hyp["taux_actualisation"]; g = hyp["croissance_loyers_marche"]; rho = hyp["rotation_annuelle"]; v = hyp["taux_inoccupation_pertes"] | ||||
| 139 | + ecart_net1 = rev["ecart_marche"] * (1 - v) | ||||
| 140 | + ferme = {"ecart_net_annee1": ecart_net1, | ||||
| 141 | + "perpetuite_rotation": va_ecart_rotation(ecart_net1, r, g, rho), | ||||
| 142 | + "perpetuite_sans_rotation": va_ecart_rotation(ecart_net1, r, g, 0.0), | ||||
| 143 | + "horizon10_rotation": va_ecart_rotation(ecart_net1, r, g, rho, T=10), | ||||
| 144 | + "annuite_5ans": valeur_bail_locataire(ecart_net1, 5, r), "annuite_10ans": valeur_bail_locataire(ecart_net1, 10, r)} | ||||
| 145 | + sens = sensibilite(d) | ||||
| 146 | + ex = exercices(d, base) | ||||
| 147 | + res = {"d": d, "revenus": rev, "ecarts": e, "base": base, "cap": cap, "ferme": ferme, "sensibilite": sens, "exercices": ex} | ||||
| 148 | + if verbose: | ||||
| 149 | + pd.set_option("display.width", 200); pd.set_option("display.max_columns", 20) | ||||
| 150 | + print("=== Cas 3 — écart contractuel / marché (données synthétiques) ===") | ||||
| 151 | + print(f"RBP contractuel {rev['rbp_contrat']:,.0f} $ ; RBP au marché {rev['rbp_marche']:,.0f} $ ; écart {rev['ecart_marche']:,.0f} $/an " | ||||
| 152 | + f"({rev['ecart_marche']/rev['rbp_marche']*100:.1f} % du marché) ; écart net de vacance (3,9 %) {ecart_net1:,.0f} $") | ||||
| 153 | + print(e.to_string(index=False)) | ||||
| 154 | + print(f"ratio contrat/marché : moyenne {e['ratio_contrat_marche'].mean():.3f}, min {e['ratio_contrat_marche'].min():.3f} (log. {int(e.loc[e['ratio_contrat_marche'].idxmin(),'logement'])}), max {e['ratio_contrat_marche'].max():.3f}") | ||||
| 155 | + print("\n=== Décomposition DCF (hypothèses du cas 3 : r = 7,25 %, rotation 15 %, g_c 3,1 %, g_m 3,5 %, R_T 5 %, c_v 2,5 %) ===") | ||||
| 156 | + for k in ["rne1_contrat", "rne1_marche", "rbe_contrat", "rbe_marche", "va_flux_contrat", "va_flux_marche", "va_vt_contrat", "va_vt_marche", "V_locateur", "V_pleine", "V_locataires"]: | ||||
| 157 | + print(f"{k:18s} {base[k]:>14,.2f} $") | ||||
| 158 | + print(f"part des locataires : {base['part_locataires']*100:.2f} %") | ||||
| 159 | + print("\nProjection contractuelle vs marché (RBP) :") | ||||
| 160 | + pc, pm = base["proj_contrat"], base["proj_marche"] | ||||
| 161 | + print(pd.DataFrame({"annee": pc["annee"], "rbp_contrat": pc["rbp"], "rbp_marche_scen": pm["rbp"], "ecart_rbp": pm["rbp"] - pc["rbp"], | ||||
| 162 | + "rne_contrat": pc["rne"], "rne_marche": pm["rne"], "ecart_rne": pm["rne"] - pc["rne"]}).round(0).to_string(index=False)) | ||||
| 163 | + print("\n=== Capitalisation naïve de l'écart (taux médian extrait) ===") | ||||
| 164 | + print({k: round(x, 0) for k, x in cap.items()}) | ||||
| 165 | + print("\n=== Formules fermées (écart net année 1, g = 3,5 %) ===") | ||||
| 166 | + print({k: round(x, 0) for k, x in ferme.items()}) | ||||
| 167 | + print("\n=== Sensibilité rotation × taux (V_locataires et part) ===") | ||||
| 168 | + print(sens.assign(logements_an=(sens["rotation"] * 12).round(0), V_locataires=sens["V_locataires"].round(0), part=(sens["part"] * 100).round(2), V_locateur=sens["V_locateur"].round(0), V_pleine=sens["V_pleine"].round(0)).to_string(index=False)) | ||||
| 169 | + print("\n=== Exercices ===") | ||||
| 170 | + for k, x in ex.items(): | ||||
| 171 | + print(k, {kk: (round(vv, 4) if isinstance(vv, float) else vv) for kk, vv in x.items()}) | ||||
| 172 | + return res | ||||
| 173 | + | ||||
| 174 | + | ||||
| 175 | +if __name__ == "__main__": | ||||
| 176 | + main() | ||||
added
analysis/python/cases/ch03_cycles.py
+513 −0
@@ -0,0 +1,513 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 3 — Offre, demande et cycles : calculs de l'exemple intégral et des tableaux. | ||||
| 3 | + | ||||
| 4 | +Trois blocs : | ||||
| 5 | +1. Modèle des quatre quadrants (DiPasquale–Wheaton) : équilibre stationnaire en forme fermée, statique comparative | ||||
| 6 | + (taux de capitalisation, demande, coût de construction). PARAMÈTRES PÉDAGOGIQUES (marché type, données synthétiques). | ||||
| 7 | +2. Dynamique stock-flux trimestrielle avec délai de livraison et inoccupation naturelle : simulation d'un cycle | ||||
| 8 | + après un choc de demande, racines caractéristiques du modèle linéaire (condition d'oscillation), sensibilité. | ||||
| 9 | +3. Données RÉELLES lues dans data/raw : mises en chantier au Québec (Statistique Canada 34-10-0135-01 et 34-10-0143-01), | ||||
| 10 | + taux de la Banque du Canada (Valet : V39079, BD.CDN.5YR.DQ.YLD, V80691335, groupe A4), IPC Québec (18-10-0004-01), | ||||
| 11 | + IPLN Québec (18-10-0205-01) ; coût d'usage du logement (Poterba) et ratio prix/loyer implicite ; mois d'inventaire | ||||
| 12 | + (chiffres APCIQ T3 2026 cités dans sources/notes-E.md). | ||||
| 13 | + | ||||
| 14 | +Exécuter depuis la racine : python3 analysis/python/cases/ch03_cycles.py (imprime tous les chiffres cités au chapitre 3). | ||||
| 15 | +Testé par analysis/python/tests/test_ch03.py. | ||||
| 16 | +""" | ||||
| 17 | +from __future__ import annotations | ||||
| 18 | +import csv, math, pathlib, sys | ||||
| 19 | +from dataclasses import dataclass, replace | ||||
| 20 | +import numpy as np | ||||
| 21 | +import pandas as pd | ||||
| 22 | + | ||||
| 23 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 24 | +sys.path.insert(0, str(ROOT / "analysis/python")) | ||||
| 25 | +from traite import finance as fin # noqa: E402 (P = R / i : capitalisation directe de la bibliothèque) | ||||
| 26 | + | ||||
| 27 | +RAW = ROOT / "data/raw" | ||||
| 28 | +GRAINE = 20261031 | ||||
| 29 | + | ||||
| 30 | +# ============================================================================================================= | ||||
| 31 | +# 1. Quatre quadrants — paramètres pédagogiques d'un marché type (SYNTHÉTIQUE) | ||||
| 32 | +# ============================================================================================================= | ||||
| 33 | + | ||||
| 34 | +@dataclass(frozen=True) | ||||
| 35 | +class Parametres4Q: | ||||
| 36 | + """Marché type : S en logements, R en $/logement/an, P en $/logement, C en logements/an. | ||||
| 37 | + Calibration pédagogique (ordres de grandeur) : loyer 1 200 $/mois ≈ loyer moyen SCHL RMR de Québec oct. 2025 | ||||
| 38 | + (1 227 $, tous logements) ; taux de capitalisation 5 % ≈ fourchette CBRE Québec T3 2026 (4,25-6,00 %) ; | ||||
| 39 | + élasticité-prix de la demande d'espace −0,5 et taux de retrait δ = 1 %/an : hypothèses déclarées (non estimées).""" | ||||
| 40 | + S0: float = 100_000.0 # stock initial (logements) | ||||
| 41 | + R0: float = 14_400.0 # loyer annuel d'équilibre initial ($/logement/an) = 1 200 $/mois | ||||
| 42 | + eta: float = -0.5 # élasticité-prix de la demande d'espace au point initial | ||||
| 43 | + i: float = 0.05 # taux de capitalisation (marché des actifs) | ||||
| 44 | + delta: float = 0.01 # taux de retrait du stock (démolitions, conversions), par an | ||||
| 45 | + c0: float = 240_000.0 # seuil de coût : prix en deçà duquel rien ne se construit ($/logement) | ||||
| 46 | + | ||||
| 47 | + @property | ||||
| 48 | + def b(self) -> float: | ||||
| 49 | + """Pente de la demande D = a − bR ; b = −η S0 / R0.""" | ||||
| 50 | + return -self.eta * self.S0 / self.R0 | ||||
| 51 | + | ||||
| 52 | + @property | ||||
| 53 | + def a(self) -> float: | ||||
| 54 | + return self.S0 + self.b * self.R0 | ||||
| 55 | + | ||||
| 56 | + @property | ||||
| 57 | + def C0(self) -> float: | ||||
| 58 | + return self.delta * self.S0 | ||||
| 59 | + | ||||
| 60 | + @property | ||||
| 61 | + def P0(self) -> float: | ||||
| 62 | + return fin.capitalisation_directe(self.R0, self.i) | ||||
| 63 | + | ||||
| 64 | + @property | ||||
| 65 | + def c1(self) -> float: | ||||
| 66 | + """Pente de l'offre de construction C = (P − c0)/c1, telle que C0 = δ S0 au prix initial.""" | ||||
| 67 | + return (self.P0 - self.c0) / self.C0 | ||||
| 68 | + | ||||
| 69 | + | ||||
| 70 | +def equilibre_4q(p: Parametres4Q, a: float | None = None, i: float | None = None, c0: float | None = None) -> dict: | ||||
| 71 | + """Équilibre stationnaire des quatre quadrants en forme fermée : | ||||
| 72 | + S* = (a − b i c0) / (1 + b i c1 δ) ; R* = (a − S*)/b ; P* = R*/i ; C* = δ S*.""" | ||||
| 73 | + a = p.a if a is None else a | ||||
| 74 | + i = p.i if i is None else i | ||||
| 75 | + c0 = p.c0 if c0 is None else c0 | ||||
| 76 | + b, c1, d = p.b, p.c1, p.delta | ||||
| 77 | + S = (a - b * i * c0) / (1.0 + b * i * c1 * d) | ||||
| 78 | + R = (a - S) / b | ||||
| 79 | + P = fin.capitalisation_directe(R, i) | ||||
| 80 | + C = d * S | ||||
| 81 | + assert abs(C - (P - c0) / c1) < 1e-6 * max(1.0, C) | ||||
| 82 | + return {"S": S, "R": R, "P": P, "C": C, "a": a, "i": i, "c0": c0} | ||||
| 83 | + | ||||
| 84 | + | ||||
| 85 | +def equilibre_4q_iteratif(p: Parametres4Q, a: float | None = None, i: float | None = None, c0: float | None = None, | ||||
| 86 | + n_iter: int = 2000) -> dict: | ||||
| 87 | + """Même équilibre par itération de point fixe S ← C(P(R(S)))/δ (vérification numérique de la forme fermée).""" | ||||
| 88 | + a = p.a if a is None else a | ||||
| 89 | + i = p.i if i is None else i | ||||
| 90 | + c0 = p.c0 if c0 is None else c0 | ||||
| 91 | + S = p.S0 | ||||
| 92 | + for _ in range(n_iter): | ||||
| 93 | + R = (a - S) / p.b | ||||
| 94 | + P = fin.capitalisation_directe(R, i) | ||||
| 95 | + C = max(0.0, (P - c0) / p.c1) | ||||
| 96 | + S = 0.5 * S + 0.5 * C / p.delta # relaxation pour garantir la convergence | ||||
| 97 | + R = (a - S) / p.b | ||||
| 98 | + return {"S": S, "R": R, "P": fin.capitalisation_directe(R, i), "C": p.delta * S} | ||||
| 99 | + | ||||
| 100 | + | ||||
| 101 | +def court_terme_4q(p: Parametres4Q, a: float | None = None, i: float | None = None) -> dict: | ||||
| 102 | + """Court terme : stock fixe à S0 ; le loyer et le prix absorbent le choc.""" | ||||
| 103 | + a = p.a if a is None else a | ||||
| 104 | + i = p.i if i is None else i | ||||
| 105 | + R = (a - p.S0) / p.b | ||||
| 106 | + P = fin.capitalisation_directe(R, i) | ||||
| 107 | + return {"S": p.S0, "R": R, "P": P, "C": max(0.0, (P - p.c0) / p.c1)} | ||||
| 108 | + | ||||
| 109 | + | ||||
| 110 | +def statique_comparative(p: Parametres4Q) -> pd.DataFrame: | ||||
| 111 | + """Trois chocs : baisse du taux de capitalisation à 4 %, hausse de la demande de 10 %, hausse du coût de 10 %.""" | ||||
| 112 | + base = equilibre_4q(p) | ||||
| 113 | + chocs = { | ||||
| 114 | + "Référence": dict(), | ||||
| 115 | + "Taux i : 5 % → 4 %": dict(i=0.04), | ||||
| 116 | + "Demande a : +10 %": dict(a=1.10 * p.a), | ||||
| 117 | + "Coût c0 : +10 %": dict(c0=1.10 * p.c0), | ||||
| 118 | + } | ||||
| 119 | + rows = [] | ||||
| 120 | + for nom, kw in chocs.items(): | ||||
| 121 | + lt = equilibre_4q(p, **kw) | ||||
| 122 | + ct = court_terme_4q(p, a=kw.get("a"), i=kw.get("i")) if "c0" not in kw else {"R": base["R"], "P": base["P"]} | ||||
| 123 | + rows.append({"choc": nom, "R_ct": ct["R"], "P_ct": ct["P"], "S_lt": lt["S"], "R_lt": lt["R"], "P_lt": lt["P"], "C_lt": lt["C"], | ||||
| 124 | + "dS_pct": 100 * (lt["S"] / base["S"] - 1), "dR_ct_pct": 100 * (ct["R"] / base["R"] - 1), | ||||
| 125 | + "dR_lt_pct": 100 * (lt["R"] / base["R"] - 1), "dP_ct_pct": 100 * (ct["P"] / base["P"] - 1), | ||||
| 126 | + "dP_lt_pct": 100 * (lt["P"] / base["P"] - 1)}) | ||||
| 127 | + return pd.DataFrame(rows).set_index("choc") | ||||
| 128 | + | ||||
| 129 | + | ||||
| 130 | +def elasticites_4q(p: Parametres4Q) -> dict: | ||||
| 131 | + """Élasticités au point initial : construction/prix (court terme) et stock stationnaire/demande (long terme).""" | ||||
| 132 | + eps_C = (1.0 / p.c1) * (p.P0 / p.C0) # (dC/dP)(P/C) | ||||
| 133 | + kappa = 1.0 / (p.b * p.i * p.c1) # réactivité de l'offre à l'écart de stock (par an) | ||||
| 134 | + base = equilibre_4q(p) | ||||
| 135 | + plus = equilibre_4q(p, a=1.01 * p.a) | ||||
| 136 | + eps_S_a = (plus["S"] / base["S"] - 1) / 0.01 # élasticité du stock stationnaire à l'ordonnée de demande | ||||
| 137 | + return {"eps_C_P": eps_C, "kappa": kappa, "eps_S_a": eps_S_a, "eta": p.eta} | ||||
| 138 | + | ||||
| 139 | + | ||||
| 140 | +# ============================================================================================================= | ||||
| 141 | +# 2. Dynamique stock-flux (SYNTHÉTIQUE) — simulation trimestrielle et racines caractéristiques | ||||
| 142 | +# ============================================================================================================= | ||||
| 143 | + | ||||
| 144 | +@dataclass(frozen=True) | ||||
| 145 | +class ParametresCycle: | ||||
| 146 | + p4q: Parametres4Q = Parametres4Q() | ||||
| 147 | + v_nat: float = 0.03 # taux d'inoccupation naturel (SCHL RMR Québec oct. 2025 : 2,4 % ; Montréal 2,9 %) | ||||
| 148 | + lam: float = 0.5 # vitesse d'ajustement du loyer : ΔR/R = −λ (v − v*) par trimestre | ||||
| 149 | + L: int = 6 # délai de livraison (trimestres) entre la mise en chantier et l'ajout au stock | ||||
| 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) | ||||
| 154 | + | ||||
| 155 | + | ||||
| 156 | +def simuler_cycle(pc: ParametresCycle) -> pd.DataFrame: | ||||
| 157 | + """Trimestriel. Demande occupée D = a − bR ; inoccupation v = 1 − D/S ; loyer R_{t+1} = R_t[1 − λ(v_t − v*)] ; | ||||
| 158 | + prix P = R/i ; mises en chantier C = max(0, (P − c0)/c1)/4 ; stock S_{t+1} = (1 − δ/4) S_t + C_{t−L}. | ||||
| 159 | + L'état initial est stationnaire avec v = v* (l'ordonnée a est ajustée pour que D0 = (1 − v*) S0).""" | ||||
| 160 | + p = pc.p4q | ||||
| 161 | + c1 = p.c1 * pc.mult_c1 | ||||
| 162 | + b = p.b | ||||
| 163 | + a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0 | ||||
| 164 | + S, R = p.S0, p.R0 | ||||
| 165 | + starts = [p.C0 / 4.0] * (pc.L + 1) # chantiers en cours au régime stationnaire | ||||
| 166 | + rows = [] | ||||
| 167 | + for t in range(pc.T + 1): | ||||
| 168 | + a = a0 * (1.0 + pc.choc_demande) if t >= pc.t_choc else a0 | ||||
| 169 | + D = min(S, a - b * R) | ||||
| 170 | + v = 1.0 - D / S | ||||
| 171 | + P = fin.capitalisation_directe(R, p.i) | ||||
| 172 | + C = max(0.0, (P - p.c0) / c1) / 4.0 | ||||
| 173 | + rows.append({"t": t, "annee": t / 4.0, "S": S, "D": D, "v": v, "R": R, "P": P, "C": C, "a": a}) | ||||
| 174 | + starts.append(C) | ||||
| 175 | + livraison = starts[-(pc.L + 1)] | ||||
| 176 | + S = (1.0 - p.delta / 4.0) * S + livraison | ||||
| 177 | + R = R * (1.0 - pc.lam * (v - pc.v_nat)) | ||||
| 178 | + df = pd.DataFrame(rows) | ||||
| 179 | + df["R_mois"] = df["R"] / 12.0 | ||||
| 180 | + df["C_annuel"] = df["C"] * 4.0 | ||||
| 181 | + return df | ||||
| 182 | + | ||||
| 183 | + | ||||
| 184 | +def etat_stationnaire_cycle(pc: ParametresCycle, apres_choc: bool = True) -> dict: | ||||
| 185 | + """État stationnaire du modèle dynamique (avec inoccupation naturelle) : v = v*, D = (1−v*)S, C = δS/4.""" | ||||
| 186 | + p = pc.p4q | ||||
| 187 | + b = p.b | ||||
| 188 | + a0 = (1.0 - pc.v_nat) * p.S0 + b * p.R0 | ||||
| 189 | + a = a0 * (1.0 + pc.choc_demande) if apres_choc else a0 | ||||
| 190 | + # (1−v*) S = a − bR ; R = i P ; P = c0 + c1 δ S → (1−v*) S = a − b i (c0 + c1 δ S) | ||||
| 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 | ||||
| 194 | + R = p.i * P | ||||
| 195 | + return {"S": S, "R": R, "P": P, "C": p.delta * S, "v": pc.v_nat} | ||||
| 196 | + | ||||
| 197 | + | ||||
| 198 | +def resume_cycle(df: pd.DataFrame, pc: ParametresCycle) -> dict: | ||||
| 199 | + """Mesures du cycle : pointe et creux de loyer (écart au stationnaire final), pointe d'inoccupation, période.""" | ||||
| 200 | + ss = etat_stationnaire_cycle(pc) | ||||
| 201 | + apres = df[df["t"] >= pc.t_choc] | ||||
| 202 | + R_rel = apres["R"] / ss["R"] - 1.0 | ||||
| 203 | + i_max = int(R_rel.idxmax()) | ||||
| 204 | + # creux après la première pointe | ||||
| 205 | + post = R_rel.loc[i_max:] | ||||
| 206 | + i_min = int(post.idxmin()) | ||||
| 207 | + # seconde pointe (pour la période) : maximum après le creux | ||||
| 208 | + post2 = R_rel.loc[i_min:] | ||||
| 209 | + i_max2 = int(post2.idxmax()) if len(post2) > 1 else i_max | ||||
| 210 | + periode = (i_max2 - i_max) if i_max2 > i_max and post2.max() > 0 else float("nan") | ||||
| 211 | + return {"R_pointe_pct": 100 * R_rel.max(), "t_pointe": df.loc[i_max, "t"] - pc.t_choc, "R_creux_pct": 100 * post.min(), | ||||
| 212 | + "t_creux": df.loc[i_min, "t"] - pc.t_choc, "v_max_pct": 100 * apres["v"].max(), "v_min_pct": 100 * apres["v"].min(), | ||||
| 213 | + "C_max_annuel": apres["C_annuel"].max(), "periode_trim": periode, | ||||
| 214 | + "S_final_ecart_pct": 100 * (df["S"].iloc[-1] / ss["S"] - 1), "v_final_pct": 100 * df["v"].iloc[-1], | ||||
| 215 | + "amortissement": (post2.max() / R_rel.max()) if periode == periode else float("nan")} | ||||
| 216 | + | ||||
| 217 | + | ||||
| 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: | ||||
| 219 | + rows = [] | ||||
| 220 | + for L in delais: | ||||
| 221 | + for m in mult_c1: | ||||
| 222 | + for lam in lams: | ||||
| 223 | + r = resume_cycle(simuler_cycle(replace(pc, L=L, mult_c1=m, lam=lam)), replace(pc, L=L, mult_c1=m, lam=lam)) | ||||
| 224 | + rows.append({"L": L, "mult_c1": m, "lam": lam, **r}) | ||||
| 225 | + return pd.DataFrame(rows) | ||||
| 226 | + | ||||
| 227 | + | ||||
| 228 | +def racines_caracteristiques(kappa: float, delta: float, L: int = 1) -> dict: | ||||
| 229 | + """Modèle linéaire annuel S_{t+1} = (1−δ) S_t + κ (a − S_{t−L}) + cte, κ = 1/(b i c1). | ||||
| 230 | + Polynôme : r^{L+1} − (1−δ) r^L + κ = 0. Retourne le module maximal, la nature des racines et la période.""" | ||||
| 231 | + coeffs = np.zeros(L + 2) | ||||
| 232 | + coeffs[0] = 1.0 | ||||
| 233 | + coeffs[1] = -(1.0 - delta) | ||||
| 234 | + coeffs[-1] = kappa | ||||
| 235 | + r = np.roots(coeffs) | ||||
| 236 | + dom = r[np.argmax(np.abs(r))] | ||||
| 237 | + complexe = bool(abs(dom.imag) > 1e-9) | ||||
| 238 | + periode = (2 * math.pi / abs(np.angle(dom))) if complexe else float("inf") | ||||
| 239 | + return {"racines": r, "module": float(abs(dom)), "oscille": complexe, "periode": periode, "stable": bool(abs(dom) < 1.0)} | ||||
| 240 | + | ||||
| 241 | + | ||||
| 242 | +def condition_oscillation_L1(kappa: float, delta: float) -> dict: | ||||
| 243 | + """Cas L = 1 en forme fermée : r² − (1−δ) r + κ = 0 ; racines complexes ssi κ > (1−δ)²/4 ; module √κ ; | ||||
| 244 | + période 2π / arccos((1−δ)/(2√κ)).""" | ||||
| 245 | + seuil = (1.0 - delta) ** 2 / 4.0 | ||||
| 246 | + if kappa > seuil: | ||||
| 247 | + return {"seuil": seuil, "oscille": True, "module": math.sqrt(kappa), "periode": 2 * math.pi / math.acos((1 - delta) / (2 * math.sqrt(kappa))), | ||||
| 248 | + "stable": kappa < 1.0} | ||||
| 249 | + disc = math.sqrt((1 - delta) ** 2 - 4 * kappa) | ||||
| 250 | + r1, r2 = ((1 - delta) + disc) / 2, ((1 - delta) - disc) / 2 | ||||
| 251 | + return {"seuil": seuil, "oscille": False, "module": max(abs(r1), abs(r2)), "periode": float("inf"), "stable": max(abs(r1), abs(r2)) < 1.0} | ||||
| 252 | + | ||||
| 253 | + | ||||
| 254 | +# ============================================================================================================= | ||||
| 255 | +# 3. Données RÉELLES | ||||
| 256 | +# ============================================================================================================= | ||||
| 257 | + | ||||
| 258 | +def _lire_statcan(fichier: pathlib.Path, filtre) -> list[dict]: | ||||
| 259 | + with open(fichier, encoding="utf-8-sig") as f: | ||||
| 260 | + return [r for r in csv.DictReader(f, delimiter=";") if filtre(r) and r["VALEUR"]] | ||||
| 261 | + | ||||
| 262 | + | ||||
| 263 | +def mises_en_chantier_quebec() -> pd.DataFrame: | ||||
| 264 | + """34-10-0135-01 : Québec, toutes régions, trimestriel, non désaisonnalisé, mises en chantier par type d'unité. | ||||
| 265 | + Périodes étiquetées par le premier mois du trimestre (1948-01 = T1 1948).""" | ||||
| 266 | + rows = _lire_statcan(RAW / "statcan_34100135/34100135.csv", | ||||
| 267 | + lambda r: r["GÉO"] == "Québec" and r["Désaisonnalisation"] == "Non désaisonnalisés" | ||||
| 268 | + and r["Estimations de logement"] == "Logements mis en chantier") | ||||
| 269 | + df = pd.DataFrame([{"periode": r["PÉRIODE DE RÉFÉRENCE"], "type": r["Type d'unité"], "unites": float(r["VALEUR"]), "vecteur": r["VECTEUR"]} for r in rows]) | ||||
| 270 | + df["annee"] = df["periode"].str[:4].astype(int) | ||||
| 271 | + df["trimestre"] = df["periode"].str[5:7].map({"01": 1, "04": 2, "07": 3, "10": 4}) | ||||
| 272 | + df["date"] = pd.PeriodIndex(df["annee"].astype(str) + "Q" + df["trimestre"].astype(str), freq="Q").to_timestamp() | ||||
| 273 | + return df.sort_values(["type", "date"]).reset_index(drop=True) | ||||
| 274 | + | ||||
| 275 | + | ||||
| 276 | +def mises_en_chantier_annuelles(df: pd.DataFrame | None = None) -> pd.DataFrame: | ||||
| 277 | + """Sommes annuelles (années complètes à 4 trimestres) par type ; part des maisons individuelles et des collectifs.""" | ||||
| 278 | + df = mises_en_chantier_quebec() if df is None else df | ||||
| 279 | + piv = df.pivot_table(index="annee", columns="type", values="unites", aggfunc="sum") | ||||
| 280 | + n_trim = df[df["type"] == "Total d'unités"].groupby("annee")["trimestre"].count() | ||||
| 281 | + piv["trimestres"] = n_trim | ||||
| 282 | + piv = piv.rename(columns={"Total d'unités": "total", "Maisons individuelles": "individuelles", "Logements collectifs": "collectifs", | ||||
| 283 | + "Appartements et autres logements": "appartements", "Logements jumelés": "jumeles", "Logements en bande": "bande"}) | ||||
| 284 | + piv["part_individuelles"] = piv["individuelles"] / piv["total"] | ||||
| 285 | + piv["part_collectifs"] = piv["collectifs"] / piv["total"] | ||||
| 286 | + piv["part_appartements"] = piv["appartements"] / piv["total"] | ||||
| 287 | + return piv | ||||
| 288 | + | ||||
| 289 | + | ||||
| 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.""" | ||||
| 292 | + 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" | ||||
| 294 | + and r["Type d'unité"] == "Total d'unités") | ||||
| 295 | + s = pd.Series({r["PÉRIODE DE RÉFÉRENCE"]: float(r["VALEUR"]) for r in rows}) | ||||
| 296 | + s.index = pd.to_datetime(s.index) | ||||
| 297 | + ann = s.groupby(s.index.year).sum() | ||||
| 298 | + mois = s.groupby(s.index.year).count() | ||||
| 299 | + return pd.DataFrame({"total": ann, "mois": mois}) | ||||
| 300 | + | ||||
| 301 | + | ||||
| 302 | +def _valet(nom: str) -> pd.Series: | ||||
| 303 | + rows = list(csv.reader(open(RAW / "bdc_valet" / f"{nom}.csv", encoding="utf-8-sig"))) | ||||
| 304 | + i = next(k for k, r in enumerate(rows) if r and r[0] == "date") | ||||
| 305 | + s = pd.Series({r[0]: float(r[1]) for r in rows[i + 1:] if len(r) > 1 and r[1]}) | ||||
| 306 | + s.index = pd.to_datetime(s.index) | ||||
| 307 | + return s.sort_index() | ||||
| 308 | + | ||||
| 309 | + | ||||
| 310 | +def taux_bdc_mensuels() -> pd.DataFrame: | ||||
| 311 | + """Moyennes mensuelles : taux hypothécaire affiché 5 ans (V80691335), obligation GdC 5 ans et 10 ans, taux directeur (V39079, depuis 2009 dans Valet).""" | ||||
| 312 | + out = pd.DataFrame({ | ||||
| 313 | + "hyp5": _valet("V80691335").resample("MS").mean(), | ||||
| 314 | + "gdc5": _valet("BD.CDN.5YR.DQ.YLD").resample("MS").mean(), | ||||
| 315 | + "gdc10": _valet("BD.CDN.10YR.DQ.YLD").resample("MS").mean(), | ||||
| 316 | + "directeur": _valet("V39079").resample("MS").mean(), | ||||
| 317 | + }) | ||||
| 318 | + return out | ||||
| 319 | + | ||||
| 320 | + | ||||
| 321 | +def changements_taux_directeur(depuis: str = "2020-01-01") -> pd.DataFrame: | ||||
| 322 | + """Dates d'effet (Valet, lendemain de l'annonce) et niveaux du taux cible du financement à un jour (V39079).""" | ||||
| 323 | + s = _valet("V39079") | ||||
| 324 | + ch = s[s.diff().fillna(0) != 0] | ||||
| 325 | + ch = ch[ch.index >= depuis] | ||||
| 326 | + prev = s.shift(1).reindex(ch.index) | ||||
| 327 | + return pd.DataFrame({"avant": prev.values, "apres": ch.values, "variation_pb": ((ch - prev) * 100).round().astype(int).values}, index=ch.index) | ||||
| 328 | + | ||||
| 329 | + | ||||
| 330 | +def taux_effectifs_a4(mois: str = "2026-08-01") -> dict: | ||||
| 331 | + """Groupe A4 (fonds avancés) : V122667783 = assuré, fixe 5 ans et plus ; V122667789 = non assuré, fixe 5 ans et plus | ||||
| 332 | + (correspondance d'après les métadonnées du fichier ; valeurs d'août 2026 citées dans notes-C/E : 4,12 % et 4,58 %).""" | ||||
| 333 | + rows = list(csv.reader(open(RAW / "bdc_valet/A4_RATES_MORTGAGES.csv", encoding="utf-8-sig"))) | ||||
| 334 | + i = next(k for k, r in enumerate(rows) if r and r[0] == "date") | ||||
| 335 | + hdr = rows[i] | ||||
| 336 | + rec = next(r for r in rows[i + 1:] if r and r[0] == mois) | ||||
| 337 | + d = dict(zip(hdr, rec)) | ||||
| 338 | + # libellés des séries (bloc SERIES du fichier) | ||||
| 339 | + labels = {r[0]: r[1] for r in rows[:i] if len(r) >= 2 and r[0].startswith("V1226677")} | ||||
| 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]} | ||||
| 345 | + | ||||
| 346 | + | ||||
| 347 | +def ipc_quebec(produit: str = "Ensemble") -> pd.Series: | ||||
| 348 | + """18-10-0004-01, Québec, non désaisonnalisé, 2002 = 100 (Ensemble : v41691783 ; Loyer : v41691818).""" | ||||
| 349 | + rows = _lire_statcan(RAW / "statcan_18100004/18100004.csv", lambda r: r["GÉO"] == "Québec" and r["Produits et groupes de produits"] == produit) | ||||
| 350 | + s = pd.Series({r["PÉRIODE DE RÉFÉRENCE"] + "-01": float(r["VALEUR"]) for r in rows}) | ||||
| 351 | + s.index = pd.to_datetime(s.index) | ||||
| 352 | + return s.sort_index() | ||||
| 353 | + | ||||
| 354 | + | ||||
| 355 | +def ipln_quebec(geo: str = "Québec", composante: str = "Total (maison et terrain)") -> pd.Series: | ||||
| 356 | + rows = _lire_statcan(RAW / "statcan_18100205/18100205.csv", lambda r: r["GÉO"] == geo and r["Indices des prix des logements neufs"] == composante) | ||||
| 357 | + s = pd.Series({r["PÉRIODE DE RÉFÉRENCE"] + "-01": float(r["VALEUR"]) for r in rows}) | ||||
| 358 | + s.index = pd.to_datetime(s.index) | ||||
| 359 | + return s.sort_index() | ||||
| 360 | + | ||||
| 361 | + | ||||
| 362 | +# --- coût d'usage ------------------------------------------------------------------------------------------- | ||||
| 363 | + | ||||
| 364 | +TAUX_TAXE_QUEBEC_2026 = 0.007464 + 0.0007899 # Ville de Québec 1-5 log. (0,7464 $/100 $) + taxe scolaire 2026-2027 (0,07899 $/100 $) | ||||
| 365 | +DELTA_ENTRETIEN_RISQUE = 0.025 # dépréciation + entretien + prime de risque : HYPOTHÈSE PÉDAGOGIQUE déclarée | ||||
| 366 | + | ||||
| 367 | + | ||||
| 368 | +def cout_usage(P: float, i: float, tau: float, delta: float, g: float) -> float: | ||||
| 369 | + """u = P (i + τ + δ − g) : coût annuel d'occupation d'un logement de prix P (Poterba 1984).""" | ||||
| 370 | + return P * (i + tau + delta - g) | ||||
| 371 | + | ||||
| 372 | + | ||||
| 373 | +def taux_cout_usage(i: float, tau: float, delta: float, g: float) -> float: | ||||
| 374 | + return i + tau + delta - g | ||||
| 375 | + | ||||
| 376 | + | ||||
| 377 | +def ratio_prix_loyer(i: float, tau: float, delta: float, g: float) -> float: | ||||
| 378 | + """À l'équilibre R = u → P/R = 1/(i + τ + δ − g) (multiple du loyer annuel).""" | ||||
| 379 | + u = taux_cout_usage(i, tau, delta, g) | ||||
| 380 | + if u <= 0: | ||||
| 381 | + return float("inf") | ||||
| 382 | + return 1.0 / u | ||||
| 383 | + | ||||
| 384 | + | ||||
| 385 | +def serie_cout_usage(tau: float = TAUX_TAXE_QUEBEC_2026, delta: float = DELTA_ENTRETIEN_RISQUE, debut: str = "2001-01-01") -> pd.DataFrame: | ||||
| 386 | + """Série mensuelle réelle : i = taux hypothécaire affiché 5 ans ; π = inflation IPC Québec sur 12 mois ; | ||||
| 387 | + g_ipc = π (anticipation = inflation courante) ; g_ipln = croissance annualisée de l'IPLN Québec sur 5 ans ; | ||||
| 388 | + u et P/R pour chaque hypothèse. τ et δ constants (déclarés).""" | ||||
| 389 | + bdc = taux_bdc_mensuels() | ||||
| 390 | + ipc = ipc_quebec() | ||||
| 391 | + pi = (ipc / ipc.shift(12) - 1.0) * 100 | ||||
| 392 | + ipln = ipln_quebec() | ||||
| 393 | + g_ipln = ((ipln / ipln.shift(60)) ** (1 / 5) - 1.0) * 100 | ||||
| 394 | + df = pd.DataFrame({"hyp5": bdc["hyp5"], "gdc5": bdc["gdc5"], "directeur": bdc["directeur"], "pi": pi, "g_ipln": g_ipln}).loc[debut:] | ||||
| 395 | + df = df.dropna(subset=["hyp5", "pi"]) | ||||
| 396 | + df["reel"] = df["hyp5"] - df["pi"] | ||||
| 397 | + df["u_ipc"] = df["hyp5"] + 100 * (tau + delta) - df["pi"] | ||||
| 398 | + df["u_ipln"] = df["hyp5"] + 100 * (tau + delta) - df["g_ipln"] | ||||
| 399 | + df["pr_ipc"] = 100.0 / df["u_ipc"] | ||||
| 400 | + df["pr_ipln"] = (100.0 / df["u_ipln"]).where(df["u_ipln"] > 0) | ||||
| 401 | + return df | ||||
| 402 | + | ||||
| 403 | + | ||||
| 404 | +DATES_TABLEAU_COUT_USAGE = ["2001-01-01", "2008-01-01", "2012-01-01", "2020-07-01", "2023-10-01", "2026-09-01"] | ||||
| 405 | + | ||||
| 406 | + | ||||
| 407 | +def exemple_cout_usage(prix: float = 463_500.0, i_affiche: float = 0.0619, i_effectif: float = 0.0458, pi: float = 0.031, | ||||
| 408 | + g_ipln: float = 0.013, tau: float = TAUX_TAXE_QUEBEC_2026, delta: float = DELTA_ENTRETIEN_RISQUE) -> dict: | ||||
| 409 | + """Exemple intégral : unifamiliale au prix médian APCIQ RMR de Québec T3 2026 (463 500 $) ; taux affiché 5 ans 6,19 % | ||||
| 410 | + (BdC 2026-10-28) ; taux effectif non assuré fixe ≥ 5 ans 4,58 % (A4, août 2026) ; inflation IPC Québec 3,1 % ; | ||||
| 411 | + IPLN Québec +1,3 % (sept. 2026). Semi-élasticité : d ln(P/R) / di = −1/u.""" | ||||
| 412 | + out = {} | ||||
| 413 | + for nom, i in (("affiche", i_affiche), ("effectif", i_effectif)): | ||||
| 414 | + for gnom, g in (("ipc", pi), ("ipln", g_ipln)): | ||||
| 415 | + u = taux_cout_usage(i, tau, delta, g) | ||||
| 416 | + out[f"u_{nom}_{gnom}"] = u | ||||
| 417 | + out[f"cout_annuel_{nom}_{gnom}"] = cout_usage(prix, i, tau, delta, g) | ||||
| 418 | + out[f"cout_mensuel_{nom}_{gnom}"] = cout_usage(prix, i, tau, delta, g) / 12 | ||||
| 419 | + out[f"pr_{nom}_{gnom}"] = ratio_prix_loyer(i, tau, delta, g) | ||||
| 420 | + # sensibilité : +100 pb sur le taux effectif, anticipation = IPC | ||||
| 421 | + u0 = taux_cout_usage(i_effectif, tau, delta, pi) | ||||
| 422 | + u1 = taux_cout_usage(i_effectif + 0.01, tau, delta, pi) | ||||
| 423 | + out["pr_effectif_ipc_plus100pb"] = 1 / u1 | ||||
| 424 | + out["var_pr_plus100pb_pct"] = 100 * ((1 / u1) / (1 / u0) - 1) | ||||
| 425 | + out["semi_elasticite"] = -1 / u0 | ||||
| 426 | + # loyer de référence SCHL (RMR Québec oct. 2025, appartements 3 ch. et plus) pour comparaison | ||||
| 427 | + out["loyer_schl_3ch_quebec"] = 1475.0 | ||||
| 428 | + out["loyer_schl_2ch_quebec"] = 1277.0 | ||||
| 429 | + out["prix_implicite_loyer_3ch_effectif_ipc"] = 1475.0 * 12 / u0 | ||||
| 430 | + return out | ||||
| 431 | + | ||||
| 432 | + | ||||
| 433 | +# --- indicateurs d'alerte (APCIQ T3 2026, chiffres cités dans sources/notes-E.md) -------------------------------- | ||||
| 434 | + | ||||
| 435 | +APCIQ_T3_2026 = { | ||||
| 436 | + # territoire : (ventes T3 2026, inscriptions en vigueur, médiane unifamiliale) | ||||
| 437 | + "Province": (20_935, 42_408, 500_000), | ||||
| 438 | + "RMR Montréal": (9_265, 20_350, 645_500), | ||||
| 439 | + "RMR Québec": (2_161, 2_204, 463_500), | ||||
| 440 | + "RMR Gatineau": (1_044, 2_256, 480_000), | ||||
| 441 | + "RMR Sherbrooke": (569, 825, 490_750), | ||||
| 442 | + "RMR Trois-Rivières": (372, 447, 395_000), | ||||
| 443 | + "RMR Saguenay": (371, 498, 365_500), | ||||
| 444 | +} | ||||
| 445 | + | ||||
| 446 | + | ||||
| 447 | +def mois_inventaire(inscriptions: float, ventes_trimestre: float) -> float: | ||||
| 448 | + """Mois d'inventaire = inscriptions en vigueur / ventes mensuelles moyennes du trimestre.""" | ||||
| 449 | + return inscriptions / (ventes_trimestre / 3.0) | ||||
| 450 | + | ||||
| 451 | + | ||||
| 452 | +def indicateurs_apciq() -> pd.DataFrame: | ||||
| 453 | + rows = [] | ||||
| 454 | + for terr, (v, insc, med) in APCIQ_T3_2026.items(): | ||||
| 455 | + rows.append({"territoire": terr, "ventes_t3": v, "inscriptions": insc, "ventes_mensuelles": v / 3.0, | ||||
| 456 | + "mois_inventaire": mois_inventaire(insc, v), "mediane_unifamiliale": med}) | ||||
| 457 | + return pd.DataFrame(rows).set_index("territoire") | ||||
| 458 | + | ||||
| 459 | + | ||||
| 460 | +# ============================================================================================================= | ||||
| 461 | +# Impression | ||||
| 462 | +# ============================================================================================================= | ||||
| 463 | + | ||||
| 464 | +def _f(x, d=0): | ||||
| 465 | + return f"{x:,.{d}f}".replace(",", " ") | ||||
| 466 | + | ||||
| 467 | + | ||||
| 468 | +def main() -> None: | ||||
| 469 | + p = Parametres4Q() | ||||
| 470 | + print("=== 1. Quatre quadrants (marché type, SYNTHÉTIQUE) ===") | ||||
| 471 | + print(f"b = {p.b:.4f} log./$ ; a = {_f(p.a)} ; P0 = {_f(p.P0)} $ ; C0 = {_f(p.C0)} ; c1 = {p.c1:.2f} $ par (log./an)") | ||||
| 472 | + eq = equilibre_4q(p); it = equilibre_4q_iteratif(p) | ||||
| 473 | + print(f"Équilibre fermé : S*={_f(eq['S'])} R*={_f(eq['R'])} P*={_f(eq['P'])} C*={_f(eq['C'])} | itératif S={_f(it['S'],1)}") | ||||
| 474 | + el = elasticites_4q(p) | ||||
| 475 | + print(f"Élasticités : construction/prix = {el['eps_C_P']:.2f} ; kappa = {el['kappa']:.4f} ; stock stationnaire/demande = {el['eps_S_a']:.3f}") | ||||
| 476 | + sc = statique_comparative(p) | ||||
| 477 | + pd.set_option("display.width", 200); pd.set_option("display.float_format", lambda x: f"{x:,.2f}") | ||||
| 478 | + print(sc[["R_ct", "P_ct", "S_lt", "R_lt", "P_lt", "C_lt", "dS_pct", "dR_ct_pct", "dR_lt_pct", "dP_ct_pct", "dP_lt_pct"]]) | ||||
| 479 | + | ||||
| 480 | + print("\n=== 2. Dynamique stock-flux (SYNTHÉTIQUE) ===") | ||||
| 481 | + pc = ParametresCycle() | ||||
| 482 | + df = simuler_cycle(pc); ss = etat_stationnaire_cycle(pc); r = resume_cycle(df, pc) | ||||
| 483 | + print(f"Stationnaire après choc : S={_f(ss['S'])} R={_f(ss['R'])} ({ss['R']/12:.0f} $/mois) P={_f(ss['P'])} C={_f(ss['C'])}/an") | ||||
| 484 | + 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']}") | ||||
| 488 | + for k in (0.12, 0.30, 0.60, 1.20): | ||||
| 489 | + c = condition_oscillation_L1(k, p.delta) | ||||
| 490 | + print(f"L=1, kappa={k}: seuil={c['seuil']:.4f} oscille={c['oscille']} module={c['module']:.3f} période={c['periode']:.2f} stable={c['stable']}") | ||||
| 491 | + 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)) | ||||
| 493 | + | ||||
| 494 | + print("\n=== 3. Données réelles ===") | ||||
| 495 | + ann = mises_en_chantier_annuelles() | ||||
| 496 | + cols = ["total", "individuelles", "collectifs", "part_individuelles", "part_collectifs", "trimestres"] | ||||
| 497 | + print(ann.loc[[1990, 2000, 2005, 2010, 2015, 2020, 2021, 2022, 2023, 2024, 2025, 2026], cols]) | ||||
| 498 | + mtl = mises_en_chantier_montreal_annuelles(); print("Montréal RMR :", mtl.loc[[2024, 2025, 2026]].to_dict()) | ||||
| 499 | + ch = changements_taux_directeur(); print(ch) | ||||
| 500 | + print(taux_effectifs_a4()) | ||||
| 501 | + cu = serie_cout_usage() | ||||
| 502 | + print(cu.loc[DATES_TABLEAU_COUT_USAGE, ["hyp5", "gdc5", "pi", "g_ipln", "reel", "u_ipc", "pr_ipc", "u_ipln", "pr_ipln"]]) | ||||
| 503 | + print("Dernier mois :", cu.index[-1].date(), cu.iloc[-1][["hyp5", "pi", "u_ipc", "pr_ipc"]].to_dict()) | ||||
| 504 | + print("IPC Québec sept. 2026 :", ipc_quebec().iloc[-1], "; a/a =", round((ipc_quebec().iloc[-1] / ipc_quebec().iloc[-13] - 1) * 100, 2)) | ||||
| 505 | + print("IPLN Québec sept. 2026 :", ipln_quebec().iloc[-1], "; a/a =", round((ipln_quebec().iloc[-1] / ipln_quebec().iloc[-13] - 1) * 100, 2)) | ||||
| 506 | + ex = exemple_cout_usage() | ||||
| 507 | + for k, v in ex.items(): | ||||
| 508 | + print(f" {k}: {v:,.4f}" if isinstance(v, float) else f" {k}: {v}") | ||||
| 509 | + print(indicateurs_apciq()) | ||||
| 510 | + | ||||
| 511 | + | ||||
| 512 | +if __name__ == "__main__": | ||||
| 513 | + main() | ||||
added
analysis/python/cases/ch04_marches.py
+333 −0
@@ -0,0 +1,333 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 4 — Marchés résidentiels québécois : lecture des DONNÉES RÉELLES de data/raw et calculs cités dans le texte. | ||||
| 3 | + | ||||
| 4 | +Imprime tous les chiffres du chapitre 4 : | ||||
| 5 | + * structure du parc (Recensement 2021, tableau 98-10-0240-01, WDS) : ménages par type de construction et mode | ||||
| 6 | + d'occupation, Québec et Canada ; parts, taux de propriété par type, indice de dissimilarité Québec/Canada ; | ||||
| 7 | + * ménages et locataires par RMR (98-10-0231-01) ; population des RMR 2020-2025 (17-10-0148-01) et TCAC ; | ||||
| 8 | + * Indice des prix des logements neufs (18-10-0205-01, déc. 2016 = 100) : niveaux de septembre 2026, variations, | ||||
| 9 | + composantes maison/terrain, épisode 2020-2022 ; | ||||
| 10 | + * marché locatif SCHL (Enquête sur les logements locatifs, octobre 2025, tableaux 1.1.1, 1.1.2, 1.1.3, 1.1.5 du | ||||
| 11 | + fichier rmr-quebec-2025-fr.xlsx) : inoccupation, loyers, univers, variation estimative vs variation brute ; | ||||
| 12 | + * indicateurs APCIQ T3 2026 (chiffres cités, calibration.json) : mois d'inventaire, rapports de prix, MRB implicite ; | ||||
| 13 | + * Banque du Canada (API Valet) : taux directeur, taux hypothécaire affiché 5 ans, obligation 5 ans, 2000-2026 ; | ||||
| 14 | + * droits sur les mutations immobilières 2026 (province, Montréal, Gatineau, Québec) ; sensibilité du prix soutenable | ||||
| 15 | + au taux hypothécaire (traite.finance.Hypotheque, composition semestrielle). | ||||
| 16 | + | ||||
| 17 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch04_marches.py | ||||
| 18 | +Aucune donnée synthétique n'est utilisée dans ce chapitre. Licences : voir data/raw/SOURCES.md.""" | ||||
| 19 | +from __future__ import annotations | ||||
| 20 | +import sys, json, csv, pathlib, math | ||||
| 21 | +import numpy as np, pandas as pd | ||||
| 22 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 23 | +from traite import finance as fin | ||||
| 24 | + | ||||
| 25 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 26 | +RAW = ROOT / "data/raw" | ||||
| 27 | +CAL = json.loads((ROOT / "data/synthetic/calibration.json").read_text(encoding="utf-8")) | ||||
| 28 | + | ||||
| 29 | +# ----------------------------------------------------------------------------- Recensement 2021 : types et mode d'occupation | ||||
| 30 | +# Membres de la dimension « Type de construction résidentielle » du tableau 98-10-0240-01 (métadonnées WDS, getCubeMetadata, 2026-10-31) | ||||
| 31 | +TYPES_240 = {2: "Maison individuelle non attenante", 9: "Maison jumelée", 8: "Maison en rangée", 5: "Appartement ou plain-pied dans un duplex", | ||||
| 32 | + 6: "Appartement, immeuble de moins de cinq étages", 3: "Appartement, immeuble de cinq étages ou plus", | ||||
| 33 | + 7: "Autre maison individuelle attenante", 10: "Logement mobile"} | ||||
| 34 | +TYPES_COURTS = {2: "Maison individuelle", 9: "Jumelée", 8: "En rangée", 5: "Duplex", 6: "Appartement < 5 étages", | ||||
| 35 | + 3: "Appartement ≥ 5 étages", 7: "Autre attenante", 10: "Mobile"} | ||||
| 36 | +MODES_240 = {1: "total", 2: "proprietaire", 3: "locataire"} | ||||
| 37 | +GEO_240 = {1: "Canada", 884: "Québec"} | ||||
| 38 | + | ||||
| 39 | +def recensement_types() -> pd.DataFrame: | ||||
| 40 | + """Ménages privés selon le type de construction et le mode d'occupation (Canada, Québec), Recensement 2021.""" | ||||
| 41 | + d = json.loads((RAW / "statcan_recensement2021/98100240_canada_quebec_type_construction_mode_occupation.json").read_text(encoding="utf-8")) | ||||
| 42 | + rows = [] | ||||
| 43 | + for e in d: | ||||
| 44 | + o = e["object"]; c = [int(x) for x in o["coordinate"].split(".")] | ||||
| 45 | + geo, typ, mode = c[0], c[4], c[5] | ||||
| 46 | + rows.append({"geo": GEO_240[geo], "type_id": typ, "mode": MODES_240[mode], "menages": o["vectorDataPoint"][0]["value"]}) | ||||
| 47 | + df = pd.DataFrame(rows).pivot_table(index=["geo", "type_id"], columns="mode", values="menages").reset_index() | ||||
| 48 | + df["type"] = df["type_id"].map(lambda k: TYPES_240.get(k, "Total" if k == 1 else "Autre logement attenant (agrégat)")) | ||||
| 49 | + df["court"] = df["type_id"].map(lambda k: TYPES_COURTS.get(k, "Total" if k == 1 else "Attenant (agrégat)")) | ||||
| 50 | + tot = df[df["type_id"] == 1].set_index("geo")["total"] | ||||
| 51 | + df["part"] = df.apply(lambda r: r["total"] / tot[r["geo"]], axis=1) | ||||
| 52 | + df["taux_propriete"] = df["proprietaire"] / df["total"] | ||||
| 53 | + return df | ||||
| 54 | + | ||||
| 55 | +def structure_parc() -> pd.DataFrame: | ||||
| 56 | + """Huit types détaillés (sans l'agrégat 4 ni le total), Québec et Canada côte à côte.""" | ||||
| 57 | + df = recensement_types() | ||||
| 58 | + d = df[df["type_id"].isin(TYPES_240)].copy() | ||||
| 59 | + q = d[d["geo"] == "Québec"].set_index("type_id"); c = d[d["geo"] == "Canada"].set_index("type_id") | ||||
| 60 | + out = pd.DataFrame({"type": q["type"], "court": q["court"], "qc_menages": q["total"], "qc_part": q["part"], "qc_proprietaires": q["proprietaire"], | ||||
| 61 | + "qc_locataires": q["locataire"], "qc_taux_propriete": q["taux_propriete"], "ca_menages": c["total"], "ca_part": c["part"], | ||||
| 62 | + "ca_taux_propriete": c["taux_propriete"]}) | ||||
| 63 | + return out.sort_values("qc_menages", ascending=False) | ||||
| 64 | + | ||||
| 65 | +def indice_dissimilarite(parts_a: np.ndarray, parts_b: np.ndarray) -> float: | ||||
| 66 | + """D = ½ Σ |s_i^A − s_i^B| : part des ménages à « reclasser » pour que les deux structures coïncident.""" | ||||
| 67 | + return 0.5 * float(np.abs(np.asarray(parts_a) - np.asarray(parts_b)).sum()) | ||||
| 68 | + | ||||
| 69 | +def menages_rmr() -> pd.DataFrame: | ||||
| 70 | + """98-10-0231-01 : ménages propriétaires / locataires, Canada, Québec, RMR Montréal, Québec, Ottawa-Gatineau (ensemble).""" | ||||
| 71 | + geo = {1: "Canada", 24: "Québec (province)", 35: "RMR Montréal", 36: "RMR Québec", 81: "RMR Ottawa-Gatineau (ensemble)"} | ||||
| 72 | + d = json.loads((RAW / "statcan_recensement2021/98100231_mode_occupation_canada_quebec_rmr.json").read_text(encoding="utf-8")) | ||||
| 73 | + rows = {} | ||||
| 74 | + for e in d: | ||||
| 75 | + o = e["object"]; c = [int(x) for x in o["coordinate"].split(".")] | ||||
| 76 | + rows.setdefault(geo[c[0]], {})[MODES_240[c[6]]] = o["vectorDataPoint"][0]["value"] | ||||
| 77 | + df = pd.DataFrame(rows).T | ||||
| 78 | + df["part_locataires"] = df["locataire"] / df["total"] | ||||
| 79 | + return df | ||||
| 80 | + | ||||
| 81 | +# ----------------------------------------------------------------------------- Population des RMR (17-10-0148-01) | ||||
| 82 | +RMR_148 = {14: "Montréal", 10: "Québec", 17: "Ottawa-Gatineau (partie du Québec)", 11: "Sherbrooke", 12: "Trois-Rivières", 9: "Saguenay", | ||||
| 83 | + 13: "Drummondville", 191: "Ensemble des RMR du Québec"} | ||||
| 84 | + | ||||
| 85 | +def population_rmr() -> pd.DataFrame: | ||||
| 86 | + d = json.loads((RAW / "statcan_17100148/17100148_rmr_quebec_total_latest6.json").read_text(encoding="utf-8")) | ||||
| 87 | + rows = {} | ||||
| 88 | + for e in d: | ||||
| 89 | + o = e["object"]; k = int(o["coordinate"].split(".")[0]) | ||||
| 90 | + rows[RMR_148[k]] = {int(p["refPerRaw"][:4]): p["value"] for p in o["vectorDataPoint"]} | ||||
| 91 | + df = pd.DataFrame(rows).T.sort_index(axis=1) | ||||
| 92 | + df["var_2024_2025"] = df[2025] / df[2024] - 1 | ||||
| 93 | + df["tcac_2020_2025"] = (df[2025] / df[2020]) ** (1 / 5) - 1 | ||||
| 94 | + return df | ||||
| 95 | + | ||||
| 96 | +# ----------------------------------------------------------------------------- IPLN (18-10-0205-01) | ||||
| 97 | +IPLN_GEO = {"Canada": "Canada", "Québec": "Québec (province)", "Montréal, Québec": "Montréal", "Québec, Québec": "Québec", | ||||
| 98 | + "Ottawa-Gatineau, partie Québec, Ontario/Québec": "Gatineau (partie du Québec)", "Sherbrooke, Québec": "Sherbrooke", | ||||
| 99 | + "Trois-Rivières, Québec": "Trois-Rivières"} | ||||
| 100 | + | ||||
| 101 | +def ipln() -> pd.DataFrame: | ||||
| 102 | + """Séries mensuelles (déc. 2016 = 100) : colonnes date, geo, composante (Total / Maison / Terrain), indice.""" | ||||
| 103 | + rows = [] | ||||
| 104 | + with open(RAW / "statcan_18100205/18100205.csv", encoding="utf-8-sig") as f: | ||||
| 105 | + r = csv.reader(f, delimiter=";"); next(r) | ||||
| 106 | + for row in r: | ||||
| 107 | + if row[1] in IPLN_GEO and row[10] not in ("", ".."): | ||||
| 108 | + comp = {"Total (maison et terrain)": "Total", "Maison seulement": "Maison", "Terrain seulement": "Terrain"}[row[3]] | ||||
| 109 | + rows.append({"date": pd.Timestamp(row[0] + "-01"), "geo": IPLN_GEO[row[1]], "composante": comp, "indice": float(row[10]), "vecteur": row[8]}) | ||||
| 110 | + return pd.DataFrame(rows).sort_values(["geo", "composante", "date"]).reset_index(drop=True) | ||||
| 111 | + | ||||
| 112 | +def ipln_stats(df: pd.DataFrame) -> pd.DataFrame: | ||||
| 113 | + """Par géographie : dernier mois, niveau total/maison/terrain, variation 12 mois, variation depuis déc. 2016, épisode janv. 2020 → déc. 2022, sommet.""" | ||||
| 114 | + out = [] | ||||
| 115 | + for geo, g in df.groupby("geo"): | ||||
| 116 | + t = g[g["composante"] == "Total"].set_index("date")["indice"] | ||||
| 117 | + last = t.index.max() | ||||
| 118 | + rec = {"geo": geo, "dernier_mois": last.strftime("%Y-%m"), "total": t[last], "var_12_mois": t[last] / t[last - pd.DateOffset(years=1)] - 1, | ||||
| 119 | + "var_depuis_2016_12": t[last] / t[pd.Timestamp("2016-12-01")] - 1, | ||||
| 120 | + "var_2020_01_2022_12": t[pd.Timestamp("2022-12-01")] / t[pd.Timestamp("2020-01-01")] - 1, | ||||
| 121 | + "sommet": t.idxmax().strftime("%Y-%m"), "sommet_val": t.max(), "ecart_sommet": t[last] / t.max() - 1, | ||||
| 122 | + "var_2000_2016": t[pd.Timestamp("2016-12-01")] / t[pd.Timestamp("2000-01-01")] - 1 if pd.Timestamp("2000-01-01") in t.index else np.nan} | ||||
| 123 | + for comp in ("Maison", "Terrain"): | ||||
| 124 | + s = g[g["composante"] == comp].set_index("date")["indice"] | ||||
| 125 | + rec[comp.lower()] = s.get(last, np.nan) | ||||
| 126 | + rec[f"{comp.lower()}_var_depuis_2016_12"] = s.get(last, np.nan) / s.get(pd.Timestamp("2016-12-01"), np.nan) - 1 | ||||
| 127 | + out.append(rec) | ||||
| 128 | + return pd.DataFrame(out).set_index("geo") | ||||
| 129 | + | ||||
| 130 | +# ----------------------------------------------------------------------------- SCHL, Enquête sur les logements locatifs (octobre 2025) | ||||
| 131 | +RMR_SCHL = {"Montréal (RMR)": "Montréal", "Québec (RMR)": "Québec", "Ottawa-Gatineau (RMR) (partie Québec)": "Gatineau (partie du Québec)", | ||||
| 132 | + "Sherbrooke (RMR)": "Sherbrooke", "Trois-Rivières (RMR)": "Trois-Rivières", "Saguenay (RMR)": "Saguenay"} | ||||
| 133 | +_SKIP = {"a", "b", "c", "d", "↑", "↓", "-", "n.d."} | ||||
| 134 | +TYPES_SCHL = ["studio", "1_chambre", "2_chambres", "3_chambres_plus", "tous"] | ||||
| 135 | + | ||||
| 136 | +def _val(tok): | ||||
| 137 | + if tok is None: return None | ||||
| 138 | + s = str(tok).strip().replace(" ", " ") | ||||
| 139 | + if s in ("**", "++", ""): return np.nan # ** : donnée supprimée ; ++ : variation non calculable | ||||
| 140 | + try: return float(s.replace(" ", "").replace(",", ".")) | ||||
| 141 | + except ValueError: return None | ||||
| 142 | + | ||||
| 143 | +def _lire_tableau(ws) -> dict: | ||||
| 144 | + """Pour chaque centre : 10 valeurs (5 types × [oct. 2024, oct. 2025]) nettoyées des lettres de qualité et des flèches.""" | ||||
| 145 | + out = {} | ||||
| 146 | + for row in ws.iter_rows(values_only=True): | ||||
| 147 | + cells = [c for c in row if c is not None] | ||||
| 148 | + if not cells: continue | ||||
| 149 | + lab = str(cells[0]).strip() | ||||
| 150 | + for k, name in RMR_SCHL.items(): | ||||
| 151 | + if lab.startswith(k.split(" (RMR)")[0]) and "(RMR)" in lab: | ||||
| 152 | + vals = [] | ||||
| 153 | + for tok in cells[1:]: | ||||
| 154 | + s = str(tok).strip() | ||||
| 155 | + if s in _SKIP: continue | ||||
| 156 | + v = _val(tok) | ||||
| 157 | + if v is None: continue | ||||
| 158 | + vals.append(v) | ||||
| 159 | + if len(vals) == 10: | ||||
| 160 | + out[name] = vals | ||||
| 161 | + return out | ||||
| 162 | + | ||||
| 163 | +def schl_rmr() -> pd.DataFrame: | ||||
| 164 | + """Inoccupation (%), loyer moyen ($), univers (logements) et variation estimative (%) par RMR, octobre 2024 et 2025 (fichier Québec, tous les centres).""" | ||||
| 165 | + import openpyxl | ||||
| 166 | + wb = openpyxl.load_workbook(RAW / "schl_rml_2025/rmr-quebec-2025-fr.xlsx", read_only=True, data_only=True) | ||||
| 167 | + t111, t112, t113, t115 = (_lire_tableau(wb[f"Tableau 1.1.{i}"]) for i in (1, 2, 3, 5)) | ||||
| 168 | + rows = [] | ||||
| 169 | + for name in RMR_SCHL.values(): | ||||
| 170 | + rec = {"rmr": name} | ||||
| 171 | + for j, typ in enumerate(TYPES_SCHL): | ||||
| 172 | + rec[f"inocc_{typ}_2024"], rec[f"inocc_{typ}_2025"] = t111[name][2 * j], t111[name][2 * j + 1] | ||||
| 173 | + rec[f"loyer_{typ}_2024"], rec[f"loyer_{typ}_2025"] = t112[name][2 * j], t112[name][2 * j + 1] | ||||
| 174 | + rec[f"univers_{typ}_2024"], rec[f"univers_{typ}_2025"] = t113[name][2 * j], t113[name][2 * j + 1] | ||||
| 175 | + rec[f"varest_{typ}_2024"], rec[f"varest_{typ}_2025"] = t115[name][2 * j], t115[name][2 * j + 1] | ||||
| 176 | + rec["var_brute_2_chambres"] = rec["loyer_2_chambres_2025"] / rec["loyer_2_chambres_2024"] - 1 | ||||
| 177 | + rec["var_brute_tous"] = rec["loyer_tous_2025"] / rec["loyer_tous_2024"] - 1 | ||||
| 178 | + rows.append(rec) | ||||
| 179 | + return pd.DataFrame(rows).set_index("rmr") | ||||
| 180 | + | ||||
| 181 | +# ----------------------------------------------------------------------------- APCIQ T3 2026 (chiffres cités avec attribution, calibration.json) | ||||
| 182 | +def apciq_t3_2026() -> pd.DataFrame: | ||||
| 183 | + """Médianes et volumes T3 2026 (APCIQ par le système Centris) tels que notés dans calibration.json ; mois d'inventaire = inscriptions / (ventes trimestrielles / 3).""" | ||||
| 184 | + p = CAL["prix_apciq"] | ||||
| 185 | + rows = { | ||||
| 186 | + "Province": dict(unifamiliale=p["province"]["unifamiliale_prix_median"]["valeur"], copropriete=p["province"]["copropriete_prix_median"]["valeur"], | ||||
| 187 | + plex=p["province"]["plex_prix_median"]["valeur"], ventes=p["province"]["ventes_residentielles"]["valeur"], inscriptions=p["province"]["inscriptions_en_vigueur"]["valeur"]), | ||||
| 188 | + "Montréal": dict(unifamiliale=645500, copropriete=435000, plex=860500, ventes=9265, inscriptions=20350), | ||||
| 189 | + "Québec": dict(unifamiliale=463500, copropriete=334900, plex=600000, ventes=2161, inscriptions=2204), | ||||
| 190 | + "Gatineau": dict(unifamiliale=480000, copropriete=297450, plex=617500, ventes=1044, inscriptions=2256), | ||||
| 191 | + "Sherbrooke": dict(unifamiliale=490750, copropriete=327000, plex=544500, ventes=569, inscriptions=825), | ||||
| 192 | + "Trois-Rivières": dict(unifamiliale=395000, copropriete=287000, plex=420000, ventes=372, inscriptions=447), | ||||
| 193 | + "Saguenay": dict(unifamiliale=365500, copropriete=np.nan, plex=373500, ventes=371, inscriptions=498), | ||||
| 194 | + } | ||||
| 195 | + for k in ("Montréal", "Québec", "Gatineau", "Sherbrooke"): # cohérence avec calibration.json quand la RMR y figure | ||||
| 196 | + key = {"Montréal": "rmr_montreal", "Québec": "rmr_quebec", "Gatineau": "rmr_gatineau", "Sherbrooke": "rmr_sherbrooke"}[k] | ||||
| 197 | + assert p[key]["unifamiliale_prix_median"]["valeur"] == rows[k]["unifamiliale"], k | ||||
| 198 | + df = pd.DataFrame(rows).T | ||||
| 199 | + df["mois_inventaire"] = df["inscriptions"] / (df["ventes"] / 3) | ||||
| 200 | + df["plex_sur_unifamiliale"] = df["plex"] / df["unifamiliale"] | ||||
| 201 | + df["copro_sur_unifamiliale"] = df["copropriete"] / df["unifamiliale"] | ||||
| 202 | + return df | ||||
| 203 | + | ||||
| 204 | +def mrb_implicite(prix_plex: float, loyer_2ch: float, logements: int = 3) -> float: | ||||
| 205 | + """Multiplicateur du revenu brut implicite d'un plex de `logements` logements loués au loyer moyen 2 chambres de la RMR (ordre de grandeur).""" | ||||
| 206 | + return fin.multiplicateur_revenu_brut(prix_plex, 12 * logements * loyer_2ch) | ||||
| 207 | + | ||||
| 208 | +# ----------------------------------------------------------------------------- Banque du Canada (Valet) | ||||
| 209 | +def _valet(fichier: str) -> pd.Series: | ||||
| 210 | + rows = [] | ||||
| 211 | + with open(RAW / "bdc_valet" / fichier, encoding="utf-8-sig") as f: | ||||
| 212 | + started = False | ||||
| 213 | + for line in f: | ||||
| 214 | + line = line.strip() | ||||
| 215 | + if line.startswith('"OBSERVATIONS"'): started = True; continue | ||||
| 216 | + if not started or not line or line.startswith('"date"'): continue | ||||
| 217 | + parts = [x.strip('"') for x in line.split(",")] | ||||
| 218 | + if len(parts) >= 2 and parts[1] not in ("", "na"): | ||||
| 219 | + rows.append((pd.Timestamp(parts[0]), float(parts[1]))) | ||||
| 220 | + return pd.Series(dict(rows)).sort_index() | ||||
| 221 | + | ||||
| 222 | +def bdc() -> dict: | ||||
| 223 | + s = {"directeur": _valet("V39079.csv"), "hyp5": _valet("V80691335.csv"), "obl5": _valet("BD.CDN.5YR.DQ.YLD.csv"), "obl10": _valet("BD.CDN.10YR.DQ.YLD.csv")} | ||||
| 224 | + d = s["directeur"] | ||||
| 225 | + chg = d[d.diff() != 0].dropna() | ||||
| 226 | + stats = {"directeur_dernier": (d.index[-1].date().isoformat(), d.iloc[-1]), "directeur_min": (d.idxmin().date().isoformat(), d.min()), | ||||
| 227 | + "directeur_max_2000": (d.idxmax().date().isoformat(), d.max()), | ||||
| 228 | + "directeur_2022_03": d[d.index >= "2022-03-01"].iloc[0], "directeur_2023_07_12": d[d.index >= "2023-07-13"].iloc[0], | ||||
| 229 | + "hyp5_dernier": (s["hyp5"].index[-1].date().isoformat(), s["hyp5"].iloc[-1]), "hyp5_min": (s["hyp5"].idxmin().date().isoformat(), s["hyp5"].min()), | ||||
| 230 | + "hyp5_max_2000": (s["hyp5"].idxmax().date().isoformat(), s["hyp5"].max()), | ||||
| 231 | + "obl5_dernier": (s["obl5"].index[-1].date().isoformat(), s["obl5"].iloc[-1]), "obl10_dernier": (s["obl10"].index[-1].date().isoformat(), s["obl10"].iloc[-1]), | ||||
| 232 | + "changements_2022_2026": [(t.date().isoformat(), v) for t, v in chg[chg.index >= "2022-01-01"].items()]} | ||||
| 233 | + return {"series": s, "stats": stats} | ||||
| 234 | + | ||||
| 235 | +# ----------------------------------------------------------------------------- Droits sur les mutations immobilières 2026 | ||||
| 236 | +BAREMES_2026 = { | ||||
| 237 | + # (borne supérieure de la tranche, taux) ; None = au-delà. Sources : LDMI art. 2 (province) ; montreal.ca ; gatineau.ca (dès le 25 février 2026) ; ville.quebec.qc.ca | ||||
| 238 | + "Province (barème légal)": [(62900, 0.005), (315000, 0.01), (None, 0.015)], | ||||
| 239 | + "Montréal": [(62900, 0.005), (315000, 0.01), (552300, 0.015), (1104700, 0.02), (2136500, 0.025), (3113000, 0.035), (None, 0.04)], | ||||
| 240 | + "Gatineau (dès le 25 février 2026)": [(62900, 0.005), (315000, 0.01), (500000, 0.015), (None, 0.03)], | ||||
| 241 | + "Québec (ville)": [(62900, 0.005), (315000, 0.01), (500000, 0.015), (750000, 0.025), (None, 0.03)], | ||||
| 242 | +} | ||||
| 243 | + | ||||
| 244 | +def droits_mutation(base: float, bareme: list[tuple]) -> float: | ||||
| 245 | + """Droit progressif par tranches : Σ_k τ_k × (min(B, b_k) − b_{k−1})⁺.""" | ||||
| 246 | + total, prev = 0.0, 0.0 | ||||
| 247 | + for borne, taux in bareme: | ||||
| 248 | + haut = base if borne is None else min(base, borne) | ||||
| 249 | + if haut > prev: total += taux * (haut - prev) | ||||
| 250 | + prev = borne if borne is not None else base | ||||
| 251 | + if borne is not None and base <= borne: break | ||||
| 252 | + return total | ||||
| 253 | + | ||||
| 254 | +def tableau_mutations(prix: list[float]) -> pd.DataFrame: | ||||
| 255 | + rows = [] | ||||
| 256 | + for nom, bar in BAREMES_2026.items(): | ||||
| 257 | + for p in prix: | ||||
| 258 | + d = droits_mutation(p, bar) | ||||
| 259 | + rows.append({"bareme": nom, "prix": p, "droit": d, "taux_effectif": d / p}) | ||||
| 260 | + return pd.DataFrame(rows) | ||||
| 261 | + | ||||
| 262 | +# ----------------------------------------------------------------------------- Taux et prix soutenable | ||||
| 263 | +def paiement_mensuel(capital: float, taux: float, amort_ans: int = 25) -> float: | ||||
| 264 | + return fin.Hypotheque(capital, taux, amort_ans * 12).paiement | ||||
| 265 | + | ||||
| 266 | +def prix_soutenable(paiement: float, taux: float, mise_de_fonds: float = 0.20, amort_ans: int = 25) -> float: | ||||
| 267 | + """Prix tel que le paiement mensuel d'un prêt de (1 − m) × prix, au taux donné (composition semestrielle), égale `paiement`.""" | ||||
| 268 | + i = fin.taux_mensuel_canadien(taux) | ||||
| 269 | + capital = paiement * fin.facteur_va_annuite(i, amort_ans * 12) | ||||
| 270 | + return capital / (1 - mise_de_fonds) | ||||
| 271 | + | ||||
| 272 | +def sensibilite_taux(prix_ref: float = 500000.0, taux_ref: float = 0.0412, taux: tuple = (0.03, 0.035, 0.0412, 0.045, 0.05, 0.055, 0.0619, 0.07)) -> pd.DataFrame: | ||||
| 273 | + """Prix médian provincial T3 2026 (500 000 $), mise de fonds 20 %, 25 ans : paiement au taux de référence (assuré fixe ≥ 5 ans, août 2026 : 4,12 %), | ||||
| 274 | + puis prix soutenable à paiement constant pour chaque taux.""" | ||||
| 275 | + cap = prix_ref * 0.8 | ||||
| 276 | + pmt_ref = paiement_mensuel(cap, taux_ref) | ||||
| 277 | + rows = [] | ||||
| 278 | + for t in taux: | ||||
| 279 | + rows.append({"taux": t, "paiement_pour_prix_ref": paiement_mensuel(cap, t), "prix_soutenable": prix_soutenable(pmt_ref, t), | ||||
| 280 | + "variation_prix": prix_soutenable(pmt_ref, t) / prix_ref - 1}) | ||||
| 281 | + return pd.DataFrame(rows), pmt_ref | ||||
| 282 | + | ||||
| 283 | +# ----------------------------------------------------------------------------- Ratio prix / loyer annuel | ||||
| 284 | +def ratio_prix_loyer(prix: float, loyer_mensuel: float) -> float: | ||||
| 285 | + return prix / (12 * loyer_mensuel) | ||||
| 286 | + | ||||
| 287 | +# ----------------------------------------------------------------------------- Impression | ||||
| 288 | +def fmt(x, d=0): return f"{x:,.{d}f}".replace(",", " ") | ||||
| 289 | + | ||||
| 290 | +def main(verbose: bool = True) -> dict: | ||||
| 291 | + res = {} | ||||
| 292 | + sp = structure_parc(); res["parc"] = sp | ||||
| 293 | + rt = recensement_types(); res["types"] = rt | ||||
| 294 | + tot = rt[rt["type_id"] == 1].set_index("geo") | ||||
| 295 | + D = indice_dissimilarite(sp["qc_part"].values, sp["ca_part"].values); res["dissimilarite"] = D | ||||
| 296 | + mr = menages_rmr(); res["menages_rmr"] = mr | ||||
| 297 | + pop = population_rmr(); res["population"] = pop | ||||
| 298 | + ip = ipln(); st = ipln_stats(ip); res["ipln"] = ip; res["ipln_stats"] = st | ||||
| 299 | + sc = schl_rmr(); res["schl"] = sc | ||||
| 300 | + ap = apciq_t3_2026(); res["apciq"] = ap | ||||
| 301 | + b = bdc(); res["bdc"] = b | ||||
| 302 | + mut = tableau_mutations([500000, 645500, 860500, 1500000]); res["mutations"] = mut | ||||
| 303 | + sens, pmt_ref = sensibilite_taux(); res["sensibilite"] = sens; res["paiement_ref"] = pmt_ref | ||||
| 304 | + # MRB et prix/loyer implicites | ||||
| 305 | + mrb = {r: mrb_implicite(ap.loc[r, "plex"], sc.loc[sc.index[sc.index.str.startswith(r.split(" ")[0])][0], "loyer_2_chambres_2025"]) | ||||
| 306 | + for r in ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]} | ||||
| 307 | + res["mrb"] = mrb | ||||
| 308 | + if not verbose: return res | ||||
| 309 | + print("=== Recensement 2021 (98-10-0240-01) — ménages privés") | ||||
| 310 | + for g in ("Québec", "Canada"): | ||||
| 311 | + print(f" {g} : total {fmt(tot.loc[g,'total'])} ; propriétaires {fmt(tot.loc[g,'proprietaire'])} ({tot.loc[g,'taux_propriete']*100:.1f} %) ; locataires {fmt(tot.loc[g,'locataire'])}") | ||||
| 312 | + print(sp[["court", "qc_menages", "qc_part", "qc_taux_propriete", "ca_part", "ca_taux_propriete"]].to_string(float_format=lambda v: f"{v:.4f}")) | ||||
| 313 | + print(f" Indice de dissimilarité Québec/Canada (8 types) : {D:.4f}") | ||||
| 314 | + agg = rt[(rt["type_id"] == 4)].set_index("geo"); print(f" Agrégat « autre logement attenant » : Québec {fmt(agg.loc['Québec','total'])} ({agg.loc['Québec','part']*100:.1f} %), Canada {agg.loc['Canada','part']*100:.1f} %") | ||||
| 315 | + plex_like = sp.loc[[5, 6], "qc_menages"].sum(); print(f" Duplex + appartements < 5 étages, Québec : {fmt(plex_like)} ménages = {plex_like/tot.loc['Québec','total']*100:.1f} %") | ||||
| 316 | + print("\n=== Ménages par RMR (98-10-0231-01)"); print(mr.to_string(float_format=lambda v: f"{v:.4f}")) | ||||
| 317 | + print("\n=== Population des RMR au 1er juillet (17-10-0148-01)"); print(pop.to_string(float_format=lambda v: f"{v:.4f}")) | ||||
| 318 | + print("\n=== IPLN (18-10-0205-01, déc. 2016 = 100)"); print(st.to_string(float_format=lambda v: f"{v:.4f}")) | ||||
| 319 | + print("\n=== SCHL — ELL octobre 2025 (fichier rmr-quebec-2025-fr.xlsx, tableaux 1.1.1/1.1.2/1.1.3/1.1.5)") | ||||
| 320 | + cols = ["inocc_tous_2024", "inocc_tous_2025", "inocc_2_chambres_2025", "loyer_2_chambres_2024", "loyer_2_chambres_2025", "loyer_tous_2025", "univers_tous_2025", | ||||
| 321 | + "varest_2_chambres_2025", "varest_tous_2025", "var_brute_2_chambres", "var_brute_tous"] | ||||
| 322 | + print(sc[cols].to_string(float_format=lambda v: f"{v:.4f}")) | ||||
| 323 | + print("\n=== APCIQ T3 2026 (chiffres cités) et indicateurs dérivés"); print(ap.to_string(float_format=lambda v: f"{v:.3f}")) | ||||
| 324 | + print(" MRB implicite (plex 2-5 log. ↔ 3 logements au loyer moyen 2 ch. SCHL oct. 2025) :", {k: round(v, 2) for k, v in mrb.items()}) | ||||
| 325 | + print(f" Ratio prix/loyer annuel, Montréal (copropriété 435 000 $ / loyer copropriété locative 2 ch. 1 826 $) : {ratio_prix_loyer(435000, 1826):.1f}") | ||||
| 326 | + print("\n=== Banque du Canada (Valet)") | ||||
| 327 | + for k, v in b["stats"].items(): print(f" {k} : {v}") | ||||
| 328 | + print("\n=== Droits sur les mutations immobilières 2026"); print(mut.to_string(float_format=lambda v: f"{v:.4f}")) | ||||
| 329 | + print(f"\n=== Sensibilité au taux : paiement de référence {pmt_ref:.2f} $/mois (prêt 400 000 $, 4,12 %, 25 ans)"); print(sens.to_string(float_format=lambda v: f"{v:.4f}")) | ||||
| 330 | + return res | ||||
| 331 | + | ||||
| 332 | +if __name__ == "__main__": | ||||
| 333 | + main() | ||||
added
analysis/python/cases/ch06_demembrements.py
+280 −0
@@ -0,0 +1,280 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 6 — Code civil et droit immobilier : effets des modalités et démembrements du droit de propriété sur la valeur. | ||||
| 3 | + | ||||
| 4 | +Imprime tous les chiffres cités dans le chapitre 6 (DONNÉES SYNTHÉTIQUES et hypothèses pédagogiques déclarées) : | ||||
| 5 | + * table de mortalité SYNTHÉTIQUE (loi de Gompertz–Makeham à paramètres déclarés ; ce n'est PAS une table de | ||||
| 6 | + Statistique Canada) : probabilités de survie et espérances de vie ; | ||||
| 7 | + * usufruit à terme fixe et usufruit viager (art. 1120, 1123, 1162 C.c.Q.) sur la maison du cas 1 (Gatineau) : | ||||
| 8 | + valeur de l'usufruit U, de la nue-propriété NP, identité U + NP = V, rente viagère équivalente (art. 1171) ; | ||||
| 9 | + * emphytéose (art. 1195, 1197, 1208-1210) sur un immeuble locatif dont le RNE est celui du cas 3 (Montréal) : | ||||
| 10 | + valeur du droit de l'emphytéote et du droit du propriétaire selon la durée restante ; seuils de 40 ans (D-15.1, art. 1) | ||||
| 11 | + et de 50 ans (art. 1040 C.c.Q.) ; | ||||
| 12 | + * part indivise (art. 1010, 1015) avec décote déclarée ; servitude de non-construction (art. 1177) par l'élasticité | ||||
| 13 | + prix-terrain du modèle hédonique du cas 1 ; rang hypothécaire (art. 2660, 2945) ; droits sur les mutations 2026 | ||||
| 14 | + (D-15.1, art. 2) ; quantum d'un vice caché (art. 1726-1728) ; budget de recherche au registre foncier. | ||||
| 15 | + | ||||
| 16 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch06_demembrements.py | ||||
| 17 | +Les fonctions d'actualisation proviennent de analysis/python/traite/finance.py (aucune réécriture) ; les valeurs des cas | ||||
| 18 | +proviennent de traite.cas1 et traite.cas3.""" | ||||
| 19 | +from __future__ import annotations | ||||
| 20 | +import sys, math, pathlib | ||||
| 21 | +import numpy as np, pandas as pd | ||||
| 22 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 23 | +from traite import finance as fin | ||||
| 24 | + | ||||
| 25 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | ||||
| 26 | + | ||||
| 27 | +# ----------------------------------------------------------------------------- hypothèses déclarées | ||||
| 28 | +# Table de mortalité SYNTHÉTIQUE : force de mortalité de Gompertz–Makeham mu(x) = A + B c^x. | ||||
| 29 | +# Paramètres pédagogiques choisis pour une espérance de vie à 65 ans de l'ordre de vingt ans (voir table_mortalite()) ; | ||||
| 30 | +# ils ne reproduisent aucune table officielle. En pratique, utiliser les tables complètes de mortalité de Statistique | ||||
| 31 | +# Canada (tableau 13-10-0114-01) ou les tables de l'Institut canadien des actuaires, selon la fin de l'évaluation. | ||||
| 32 | +GM_A, GM_B, GM_C = 0.0004, 0.000025, 1.102 | ||||
| 33 | +OMEGA = 111 # âge limite de la table synthétique (survie nulle au-delà) | ||||
| 34 | + | ||||
| 35 | +R_ACTU = 0.05 # taux d'actualisation des flux d'une résidence (hypothèse pédagogique) | ||||
| 36 | +G_LOYER = 0.015 # croissance annuelle de la valeur locative nette (hypothèse pédagogique) | ||||
| 37 | +V_ARRONDI = 638_000.0 # valeur de la maison du cas 1 (prédiction hédonique 637 953 $ arrondie au millier) | ||||
| 38 | + | ||||
| 39 | +# Emphytéose : immeuble dont le RNE est celui du cas 3 ; monde de Gordon r - g = taux global | ||||
| 40 | +R_EMPH, G_EMPH = 0.07, 0.025 # r = taux d'actualisation du cas 3 (hypothèses DCF) ; g déclaré | ||||
| 41 | +RENTE_EMPH = 30_000.0 # rente emphytéotique annuelle fixe (hypothèse pédagogique) | ||||
| 42 | +N_EMPH = 35 # durée restante de référence (années) | ||||
| 43 | + | ||||
| 44 | +# Droits sur les mutations immobilières, tranches 2026 (RLRQ, c. D-15.1, art. 2 ; notes-A § 2.3) | ||||
| 45 | +TRANCHES_2026 = [(62_900.0, 0.005), (315_000.0, 0.010), (float("inf"), 0.015)] | ||||
| 46 | + | ||||
| 47 | +# Registre foncier — grille tarifaire en vigueur à compter du 1er avril 2025 (notes-A § 3.1) | ||||
| 48 | +TARIFS_REGISTRE = {"consultation_document": 1.0, "inscription": 142.0, "radiation": 162.0, | ||||
| 49 | + "etat_certifie": 16.0, "copie_non_certifiee": 26.0, "copie_certifiee": 42.0} | ||||
| 50 | + | ||||
| 51 | + | ||||
| 52 | +# ----------------------------------------------------------------------------- mortalité synthétique | ||||
| 53 | + | ||||
| 54 | +def mu(x: float) -> float: | ||||
| 55 | + """Force de mortalité synthétique (Gompertz–Makeham).""" | ||||
| 56 | + return GM_A + GM_B * GM_C ** x | ||||
| 57 | + | ||||
| 58 | + | ||||
| 59 | +def survie(x: float, t: float) -> float: | ||||
| 60 | + """Probabilité tp_x qu'une personne d'âge x soit vivante à l'âge x + t (intégrale exacte de mu).""" | ||||
| 61 | + if t <= 0: | ||||
| 62 | + return 1.0 | ||||
| 63 | + if x + t >= OMEGA: | ||||
| 64 | + return 0.0 | ||||
| 65 | + integ = GM_A * t + GM_B * GM_C ** x * (GM_C ** t - 1.0) / math.log(GM_C) | ||||
| 66 | + return math.exp(-integ) | ||||
| 67 | + | ||||
| 68 | + | ||||
| 69 | +def esperance_vie(x: float) -> float: | ||||
| 70 | + """Espérance de vie abrégée (somme des tp_x, correction d'un demi-an).""" | ||||
| 71 | + return sum(survie(x, t) for t in range(1, OMEGA - int(x) + 1)) + 0.5 | ||||
| 72 | + | ||||
| 73 | + | ||||
| 74 | +def table_mortalite(ages=(55, 60, 65, 70, 75, 80, 85)) -> pd.DataFrame: | ||||
| 75 | + rows = [{"age": a, "esperance_vie": esperance_vie(a), "p10": survie(a, 10), "p20": survie(a, 20), "p30": survie(a, 30)} for a in ages] | ||||
| 76 | + return pd.DataFrame(rows).set_index("age") | ||||
| 77 | + | ||||
| 78 | + | ||||
| 79 | +# ----------------------------------------------------------------------------- valeur de la pleine propriété | ||||
| 80 | + | ||||
| 81 | +def valeur_cas1() -> dict: | ||||
| 82 | + """Prédiction hédonique du sujet du cas 1 (traite.cas1) et valeur arrondie retenue dans le chapitre.""" | ||||
| 83 | + from traite import cas1 | ||||
| 84 | + t = cas1.tout() | ||||
| 85 | + return {"prediction_hedonique": float(t["prediction_hedonique"]), "V": V_ARRONDI, | ||||
| 86 | + "terrain_m2": t["sujet"]["superficie_terrain_m2"], "elasticite_terrain": float(t["modele"]["coef"].loc["ln_T", "coef"])} | ||||
| 87 | + | ||||
| 88 | + | ||||
| 89 | +def loyer_net(V: float, r: float = R_ACTU, g: float = G_LOYER) -> float: | ||||
| 90 | + """Valeur locative nette annuelle cohérente avec V dans un monde de Gordon : L = (r - g) V.""" | ||||
| 91 | + return (r - g) * V | ||||
| 92 | + | ||||
| 93 | + | ||||
| 94 | +# ----------------------------------------------------------------------------- usufruit | ||||
| 95 | + | ||||
| 96 | +def usufruit_terme(V: float, n: int, r: float = R_ACTU, g: float = G_LOYER) -> dict: | ||||
| 97 | + """Usufruit à terme fixe de n ans : U = somme actualisée des fruits L(1+g)^(t-1), NP = V - U = V((1+g)/(1+r))^n.""" | ||||
| 98 | + L = loyer_net(V, r, g) | ||||
| 99 | + flux = [L * (1 + g) ** (t - 1) for t in range(1, n + 1)] | ||||
| 100 | + U = fin.van([0.0] + flux, r) | ||||
| 101 | + NP = V - U | ||||
| 102 | + return {"n": n, "L": L, "U": U, "NP": NP, "part_NP": NP / V, "NP_formule": V * ((1 + g) / (1 + r)) ** n} | ||||
| 103 | + | ||||
| 104 | + | ||||
| 105 | +def usufruit_viager(V: float, x: int, r: float = R_ACTU, g: float = G_LOYER) -> dict: | ||||
| 106 | + """Usufruit viager d'un usufruitier d'âge x (convention : le revenu de l'année t revient à l'usufruitier s'il est vivant au | ||||
| 107 | + début de l'année, probabilité (t-1)p_x ; la nue-propriété reçoit la pleine propriété à la fin de l'année du décès).""" | ||||
| 108 | + L = loyer_net(V, r, g) | ||||
| 109 | + U = 0.0; NP_direct = 0.0; esp = 0.0 | ||||
| 110 | + for t in range(1, OMEGA - x + 1): | ||||
| 111 | + p_debut, p_fin = survie(x, t - 1), survie(x, t) | ||||
| 112 | + v = fin.facteur_va(r, t) | ||||
| 113 | + U += v * p_debut * L * (1 + g) ** (t - 1) | ||||
| 114 | + NP_direct += v * (p_debut - p_fin) * V * (1 + g) ** t | ||||
| 115 | + esp += p_fin | ||||
| 116 | + return {"age": x, "L": L, "U": U, "NP": V - U, "NP_direct": NP_direct, "part_NP": (V - U) / V, | ||||
| 117 | + "esperance_vie": esp + 0.5, "annuite_viagere": U / L} | ||||
| 118 | + | ||||
| 119 | + | ||||
| 120 | +def rente_viagere_equivalente(V: float, x: int, r: float = R_ACTU, g: float = G_LOYER) -> float: | ||||
| 121 | + """Rente annuelle constante, versée tant que l'usufruitier vit, de même valeur actuelle que l'usufruit (art. 1171).""" | ||||
| 122 | + u = usufruit_viager(V, x, r, g) | ||||
| 123 | + a_x = sum(fin.facteur_va(r, t) * survie(x, t - 1) for t in range(1, OMEGA - x + 1)) | ||||
| 124 | + return u["U"] / a_x | ||||
| 125 | + | ||||
| 126 | + | ||||
| 127 | +def grille_usufruit(V: float, ages=(55, 60, 65, 70, 75, 80, 85), taux=(0.04, 0.05, 0.06), g: float = G_LOYER) -> pd.DataFrame: | ||||
| 128 | + """Part de la nue-propriété (NP/V) selon l'âge de l'usufruitier et le taux d'actualisation.""" | ||||
| 129 | + return pd.DataFrame({f"r={r:.2f}": [usufruit_viager(V, a, r, g)["part_NP"] for a in ages] for r in taux}, index=list(ages)) | ||||
| 130 | + | ||||
| 131 | + | ||||
| 132 | +# ----------------------------------------------------------------------------- emphytéose | ||||
| 133 | + | ||||
| 134 | +def emphyteose(noi_1: float, rente: float, n: int, r: float = R_EMPH, g: float = G_EMPH, r_prop: float | None = None) -> dict: | ||||
| 135 | + """Droits de l'emphytéote et du propriétaire pour une durée restante de n ans. | ||||
| 136 | + Monde de Gordon : V_pleine = NOI_1/(r-g) ; NOI_t = NOI_1 (1+g)^(t-1) ; reprise à la fin de l'année n à V_n = V_pleine (1+g)^n. | ||||
| 137 | + Si r_prop est donné, les flux du propriétaire (rente + reprise) sont actualisés à ce taux (sinon à r : la somme vaut V_pleine).""" | ||||
| 138 | + V = noi_1 / (r - g) | ||||
| 139 | + rp = r if r_prop is None else r_prop | ||||
| 140 | + flux_emph = [noi_1 * (1 + g) ** (t - 1) - rente for t in range(1, n + 1)] | ||||
| 141 | + V_emph = fin.van([0.0] + flux_emph, r) | ||||
| 142 | + V_rentes = rente * fin.facteur_va_annuite(rp, n) | ||||
| 143 | + V_reprise = V * (1 + g) ** n * fin.facteur_va(rp, n) | ||||
| 144 | + V_prop = V_rentes + V_reprise | ||||
| 145 | + return {"n": n, "V_pleine": V, "V_emph": V_emph, "V_rentes": V_rentes, "V_reprise": V_reprise, "V_prop": V_prop, | ||||
| 146 | + "somme": V_emph + V_prop, "part_emph": V_emph / V, "rente": rente, "noi_1": noi_1} | ||||
| 147 | + | ||||
| 148 | + | ||||
| 149 | +def grille_emphyteose(noi_1: float, rente: float, durees=(60, 50, 45, 40, 35, 30, 20, 10, 5), **kw) -> pd.DataFrame: | ||||
| 150 | + return pd.DataFrame([emphyteose(noi_1, rente, n, **kw) for n in durees]).set_index("n") | ||||
| 151 | + | ||||
| 152 | + | ||||
| 153 | +def rne_cas3() -> float: | ||||
| 154 | + """RNE normalisé de première année du cas 3 (traite.cas3), seul chiffre du cas repris ici.""" | ||||
| 155 | + from traite import cas3 | ||||
| 156 | + return float(cas3.tout()["base"]["rne"]) | ||||
| 157 | + | ||||
| 158 | + | ||||
| 159 | +# ----------------------------------------------------------------------------- indivision, servitude, hypothèque, mutations, vice | ||||
| 160 | + | ||||
| 161 | +def part_indivise(V: float, quote_part: float, decote: float) -> dict: | ||||
| 162 | + """Valeur d'une part indivise : quote-part de la valeur du tout, diminuée d'une décote d'illiquidité déclarée.""" | ||||
| 163 | + brute = quote_part * V | ||||
| 164 | + return {"quote_part": quote_part, "valeur_proportionnelle": brute, "decote": decote, "valeur_part": brute * (1 - decote)} | ||||
| 165 | + | ||||
| 166 | + | ||||
| 167 | +def servitude_terrain(V: float, terrain_m2: float, grevee_m2: float, elasticite: float) -> dict: | ||||
| 168 | + """Effet d'une servitude qui soustrait grevee_m2 à l'usage du terrain, par l'élasticité prix-terrain du modèle hédonique : | ||||
| 169 | + V_avec = V ((T - T_g)/T)^beta ; approximation déclarée (la servitude n'est pas une réduction de lot).""" | ||||
| 170 | + ratio = (terrain_m2 - grevee_m2) / terrain_m2 | ||||
| 171 | + V_avec = V * ratio ** elasticite | ||||
| 172 | + return {"terrain_m2": terrain_m2, "grevee_m2": grevee_m2, "elasticite": elasticite, "V_sans": V, "V_avec": V_avec, | ||||
| 173 | + "delta": V - V_avec, "delta_pct": 1 - V_avec / V} | ||||
| 174 | + | ||||
| 175 | + | ||||
| 176 | +def rang_hypothecaire(produit_net: float, creances: list[float]) -> list[dict]: | ||||
| 177 | + """Distribution du produit net d'une vente sous contrôle de justice selon le rang (art. 2660, 2945).""" | ||||
| 178 | + reste = produit_net; out = [] | ||||
| 179 | + for i, c in enumerate(creances, 1): | ||||
| 180 | + paye = min(c, max(reste, 0.0)); reste -= paye | ||||
| 181 | + out.append({"rang": i, "creance": c, "recouvre": paye, "perte": c - paye, "taux_recouvrement": paye / c if c else 1.0}) | ||||
| 182 | + out.append({"rang": "débiteur", "creance": 0.0, "recouvre": max(reste, 0.0), "perte": 0.0, "taux_recouvrement": float("nan")}) | ||||
| 183 | + return out | ||||
| 184 | + | ||||
| 185 | + | ||||
| 186 | +def droit_mutation(base: float, tranches=TRANCHES_2026, taux_municipal: list[tuple[float, float]] | None = None) -> dict: | ||||
| 187 | + """Droit sur les mutations immobilières (D-15.1, art. 2) : taux par tranche sur la base d'imposition. | ||||
| 188 | + taux_municipal : liste optionnelle de (seuil_inferieur, taux) remplaçant 1,5 % au-dessus de 500 000 $ (facultatif, max 3 %).""" | ||||
| 189 | + detail = []; prec = 0.0; total = 0.0 | ||||
| 190 | + for plafond, taux in tranches: | ||||
| 191 | + if base <= prec: | ||||
| 192 | + break | ||||
| 193 | + assiette = min(base, plafond) - prec | ||||
| 194 | + montant = assiette * taux | ||||
| 195 | + detail.append({"de": prec, "a": min(base, plafond), "taux": taux, "montant": montant}); total += montant; prec = plafond | ||||
| 196 | + supplement = 0.0 | ||||
| 197 | + if taux_municipal: | ||||
| 198 | + for seuil, taux in taux_municipal: | ||||
| 199 | + if base > seuil: | ||||
| 200 | + borne_sup = min([s for s, _ in taux_municipal if s > seuil] + [float("inf")]) | ||||
| 201 | + supplement += (min(base, borne_sup) - seuil) * (taux - 0.015) | ||||
| 202 | + return {"base": base, "detail": detail, "droit_base": total, "supplement_municipal": supplement, "total": total + supplement, | ||||
| 203 | + "taux_effectif": (total + supplement) / base} | ||||
| 204 | + | ||||
| 205 | + | ||||
| 206 | +def quantum_vice(prix: float, cout_reparation: float, plus_value: float, prejudice_autre: float = 0.0, vendeur_connaissait: bool = False) -> dict: | ||||
| 207 | + """Mesure usuelle du préjudice d'un vice caché : coût des réparations diminué de la plus-value qu'elles procurent | ||||
| 208 | + (réduction de prix, art. 1726-1727) ; dommages-intérêts additionnels seulement si le vendeur connaissait le vice (art. 1728).""" | ||||
| 209 | + reduction = cout_reparation - plus_value | ||||
| 210 | + dommages = prejudice_autre if vendeur_connaissait else 0.0 | ||||
| 211 | + return {"prix": prix, "reduction_prix": reduction, "dommages": dommages, "total": reduction + dommages, | ||||
| 212 | + "prix_ajuste": prix - reduction, "part_prix": reduction / prix} | ||||
| 213 | + | ||||
| 214 | + | ||||
| 215 | +def budget_registre(nb_comparables: int, documents_par_vente: int = 3) -> dict: | ||||
| 216 | + """Budget de consultation du registre foncier pour vérifier des ventes comparables (1 $ par document).""" | ||||
| 217 | + n_doc = nb_comparables * documents_par_vente | ||||
| 218 | + return {"documents": n_doc, "cout": n_doc * TARIFS_REGISTRE["consultation_document"]} | ||||
| 219 | + | ||||
| 220 | + | ||||
| 221 | +# ----------------------------------------------------------------------------- impression | ||||
| 222 | + | ||||
| 223 | +def _d(x: float) -> str: | ||||
| 224 | + return f"{x:,.0f} $".replace(",", " ") | ||||
| 225 | + | ||||
| 226 | + | ||||
| 227 | +def main() -> None: | ||||
| 228 | + print("=== Chapitre 6 — démembrements et modalités (DONNÉES SYNTHÉTIQUES / hypothèses déclarées) ===\n") | ||||
| 229 | + tm = table_mortalite() | ||||
| 230 | + print("Table de mortalité SYNTHÉTIQUE (Gompertz–Makeham A=%.4f B=%.6f c=%.3f) :" % (GM_A, GM_B, GM_C)) | ||||
| 231 | + print(tm.round(3).to_string(), "\n") | ||||
| 232 | + | ||||
| 233 | + c1 = valeur_cas1() | ||||
| 234 | + V = c1["V"] | ||||
| 235 | + print(f"Cas 1 : prédiction hédonique {c1['prediction_hedonique']:.2f} $ → V retenu {_d(V)} ; L = (r-g)V = {_d(loyer_net(V))} / an " | ||||
| 236 | + f"({loyer_net(V)/12:.0f} $/mois) avec r = {R_ACTU:.1%}, g = {G_LOYER:.1%}\n") | ||||
| 237 | + | ||||
| 238 | + for n in (10, 20, 30): | ||||
| 239 | + u = usufruit_terme(V, n) | ||||
| 240 | + print(f"Usufruit à terme {n} ans : U = {_d(u['U'])} ; NP = {_d(u['NP'])} ({u['part_NP']:.1%}) ; vérif. NP formule {_d(u['NP_formule'])}") | ||||
| 241 | + print() | ||||
| 242 | + for x in (55, 65, 75, 85): | ||||
| 243 | + u = usufruit_viager(V, x) | ||||
| 244 | + print(f"Usufruit viager, {x} ans : e_x = {u['esperance_vie']:.1f} ; U = {_d(u['U'])} ; NP = {_d(u['NP'])} ({u['part_NP']:.1%}) ; " | ||||
| 245 | + f"NP direct = {_d(u['NP_direct'])} ; annuité viagère {u['annuite_viagere']:.3f} ; rente équiv. {_d(rente_viagere_equivalente(V, x))}/an") | ||||
| 246 | + print("\nGrille NP/V (âge × r) :"); print(grille_usufruit(V).round(3).to_string(), "\n") | ||||
| 247 | + | ||||
| 248 | + noi = rne_cas3() | ||||
| 249 | + e = emphyteose(noi, RENTE_EMPH, N_EMPH) | ||||
| 250 | + print(f"Emphytéose : RNE_1 cas 3 = {noi:.2f} $ ; r = {R_EMPH:.1%}, g = {G_EMPH:.1%} → V pleine = {_d(e['V_pleine'])} ; rente {_d(RENTE_EMPH)}/an ; n = {N_EMPH}") | ||||
| 251 | + print(f" droit de l'emphytéote {_d(e['V_emph'])} ({e['part_emph']:.1%}) ; propriétaire : rentes {_d(e['V_rentes'])} + reprise {_d(e['V_reprise'])} = {_d(e['V_prop'])} ; somme {_d(e['somme'])}") | ||||
| 252 | + e2 = emphyteose(noi, RENTE_EMPH, N_EMPH, r_prop=0.055) | ||||
| 253 | + print(f" avec r_prop = 5,5 % : propriétaire {_d(e2['V_prop'])} ; somme {_d(e2['somme'])} (> V pleine de {_d(e2['somme']-e2['V_pleine'])})") | ||||
| 254 | + print("\nGrille emphytéose selon la durée restante :") | ||||
| 255 | + ge = grille_emphyteose(noi, RENTE_EMPH)[["V_emph", "V_rentes", "V_reprise", "V_prop", "part_emph"]].copy() | ||||
| 256 | + ge["part_emph"] = (ge["part_emph"] * 100).round(1); ge[["V_emph", "V_rentes", "V_reprise", "V_prop"]] = ge[["V_emph", "V_rentes", "V_reprise", "V_prop"]].round(0) | ||||
| 257 | + print(ge.to_string(), "\n") | ||||
| 258 | + | ||||
| 259 | + for q, d in ((0.5, 0.0), (0.5, 0.10), (0.5, 0.20), (1 / 3, 0.15)): | ||||
| 260 | + p = part_indivise(V, q, d) | ||||
| 261 | + print(f"Part indivise {q:.3f} décote {d:.0%} : {_d(p['valeur_proportionnelle'])} → {_d(p['valeur_part'])}") | ||||
| 262 | + s = servitude_terrain(V, c1["terrain_m2"], 120, c1["elasticite_terrain"]) | ||||
| 263 | + print(f"\nServitude de non-construction sur 120 m² de {c1['terrain_m2']} m² (élasticité {s['elasticite']:.4f}) : V_avec = {_d(s['V_avec'])} ; ΔV = {_d(s['delta'])} ({s['delta_pct']:.2%})") | ||||
| 264 | + print("\nRang hypothécaire, produit net 560 000 $, créances 455 000 / 90 000 / 40 000 :") | ||||
| 265 | + for r in rang_hypothecaire(560_000, [455_000, 90_000, 40_000]): | ||||
| 266 | + print(" ", r) | ||||
| 267 | + m = droit_mutation(V) | ||||
| 268 | + print(f"\nDroit de mutation 2026 sur {_d(V)} : " + " ; ".join(f"{t['taux']:.1%} sur {_d(t['a']-t['de'])} = {t['montant']:.2f} $" for t in m["detail"]) + | ||||
| 269 | + f" → {m['total']:.2f} $ ({m['taux_effectif']:.3%})") | ||||
| 270 | + m2 = droit_mutation(750_000, taux_municipal=[(500_000, 0.02)]) | ||||
| 271 | + print(f"Droit de mutation 2026 sur 750 000 $ avec taux municipal 2 % au-delà de 500 000 $ : base {m2['droit_base']:.2f} + {m2['supplement_municipal']:.2f} = {m2['total']:.2f} $") | ||||
| 272 | + qv = quantum_vice(520_000, 46_000, 9_000, 6_500, True) | ||||
| 273 | + print(f"\nVice caché : prix 520 000 $, réparations 46 000 $, plus-value 9 000 $ → réduction {_d(qv['reduction_prix'])} ({qv['part_prix']:.1%}) ; " | ||||
| 274 | + f"si vendeur connaissait : + {_d(qv['dommages'])} = {_d(qv['total'])}") | ||||
| 275 | + b = budget_registre(5) | ||||
| 276 | + print(f"\nRegistre foncier : 5 comparables × 3 documents = {b['documents']} documents → {b['cout']:.0f} $ (1 $/document)") | ||||
| 277 | + | ||||
| 278 | + | ||||
| 279 | +if __name__ == "__main__": | ||||
| 280 | + main() | ||||
added
analysis/python/cases/ch07_servitudes_copro.py
+280 −0
@@ -0,0 +1,280 @@ | |||||
| 1 | +#!/usr/bin/env python3 | ||||
| 2 | +"""Chapitre 7 — Cadastre, titres, servitudes et copropriété : exemples chiffrés (DONNÉES SYNTHÉTIQUES, graine 20261031). | ||||
| 3 | + | ||||
| 4 | +Trois blocs, tous générés dans ce script (aucun fichier externe, aucune statistique de marché) : | ||||
| 5 | + A. Effet d'une servitude de passage mesuré par ventes appariées (traite.comparables.ventes_appariees) ; | ||||
| 6 | + comparaison avec le « modèle de la bande » (perte d'utilité d'une bande de terrain grevée). | ||||
| 7 | + B. Copropriété divise de huit fractions : valeur relative (art. 1041, 1053 C.c.Q.), quotes-parts, répartition | ||||
| 8 | + des charges communes générales et des charges des parties communes à usage restreint (art. 1064 C.c.Q.). | ||||
| 9 | + C. Fonds de prévoyance (art. 1071 C.c.Q. ; CCQ, r. 8.01, art. 3 et 7-9) : plan de réparations majeures sur 25 ans, | ||||
| 10 | + cotisation annuelle indexée nécessaire (méthode du seuil), comparaison avec la règle du 0,5 % de la valeur de | ||||
| 11 | + reconstruction et avec la cotisation actuelle, déficit de capitalisation et incidence sur la valeur d'une fraction. | ||||
| 12 | + | ||||
| 13 | +Toutes les fonctions financières proviennent de analysis/python/traite/ (finance.py, comparables.py). | ||||
| 14 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch07_servitudes_copro.py | ||||
| 15 | +""" | ||||
| 16 | +from __future__ import annotations | ||||
| 17 | +import sys, math, pathlib | ||||
| 18 | +import numpy as np | ||||
| 19 | +from scipy import stats | ||||
| 20 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | ||||
| 21 | +from traite import comparables as cp | ||||
| 22 | +from traite import finance as fin | ||||
| 23 | + | ||||
| 24 | +SEED = 20261031 | ||||
| 25 | + | ||||
| 26 | +# ----------------------------------------------------------------------------- A. servitude par paires | ||||
| 27 | +N_PAIRES = 12 | ||||
| 28 | +PRIX_MEDIAN_SANS = 445_000.0 # $ ; niveau pédagogique (bungalow de banlieue fictif, terrain 700 m²) | ||||
| 29 | +SIGMA_PAIRE = 0.08 # dispersion log des prix « sans servitude » entre paires | ||||
| 30 | +DELTA_SERVITUDE = 0.045 # effet « vrai » de la servitude de passage (proportion du prix) — hypothèse pédagogique | ||||
| 31 | +SIGMA_BRUIT = 0.025 # bruit de transaction résiduel au sein d'une paire | ||||
| 32 | +TERRAIN_M2 = 700.0 | ||||
| 33 | +BANDE_LARGEUR_M, BANDE_LONGUEUR_M = 3.0, 40.0 # bande de passage grevée | ||||
| 34 | +PRIX_TERRAIN_M2 = 180.0 # $/m² de terrain, prix unitaire pédagogique du secteur fictif | ||||
| 35 | +PERTE_UTILITE = 0.50 # proportion de l'utilité de la bande perdue par le fonds servant (passage non exclusif) | ||||
| 36 | + | ||||
| 37 | + | ||||
| 38 | +def paires_servitude(n: int = N_PAIRES, seed: int = SEED) -> dict: | ||||
| 39 | + """Génère n paires (sans, avec servitude de passage) et extrait l'effet par ventes appariées.""" | ||||
| 40 | + rng = np.random.default_rng(seed) | ||||
| 41 | + sans = np.exp(np.log(PRIX_MEDIAN_SANS) + rng.normal(0.0, SIGMA_PAIRE, n)) | ||||
| 42 | + avec = sans * (1.0 - DELTA_SERVITUDE) * np.exp(rng.normal(0.0, SIGMA_BRUIT, n)) | ||||
| 43 | + sans = np.round(sans / 500.0) * 500.0 | ||||
| 44 | + avec = np.round(avec / 500.0) * 500.0 | ||||
| 45 | + va = cp.ventes_appariees(list(avec), list(sans)) # différences = avec − sans (négatives) | ||||
| 46 | + diffs = np.array(va["differences"]) | ||||
| 47 | + se = va["ecart_type"] / math.sqrt(n) | ||||
| 48 | + t_crit = float(stats.t.ppf(0.975, n - 1)) | ||||
| 49 | + ratios = avec / sans | ||||
| 50 | + beta = float(np.mean(np.log(ratios))) | ||||
| 51 | + return { | ||||
| 52 | + "n": n, "sans": sans, "avec": avec, "diffs": diffs, | ||||
| 53 | + "moyenne": va["moyenne"], "mediane": va["mediane"], "ecart_type": va["ecart_type"], "se": se, | ||||
| 54 | + "t": va["moyenne"] / se, "t_crit": t_crit, | ||||
| 55 | + "ic95": (va["moyenne"] - t_crit * se, va["moyenne"] + t_crit * se), | ||||
| 56 | + "effet_relatif_moyen": float(np.mean(ratios - 1.0)), # moyenne des (avec/sans − 1) | ||||
| 57 | + "effet_relatif_ratio": float(avec.mean() / sans.mean() - 1.0), # rapport des moyennes | ||||
| 58 | + "beta_log": beta, "effet_semilog": cp.ajustement_depuis_coefficient_log(beta), | ||||
| 59 | + "ic95_relatif": ((va["moyenne"] - t_crit * se) / sans.mean(), (va["moyenne"] + t_crit * se) / sans.mean()), | ||||
| 60 | + } | ||||
| 61 | + | ||||
| 62 | + | ||||
| 63 | +def modele_bande(prix_terrain_m2: float = PRIX_TERRAIN_M2, largeur: float = BANDE_LARGEUR_M, | ||||
| 64 | + longueur: float = BANDE_LONGUEUR_M, perte: float = PERTE_UTILITE) -> dict: | ||||
| 65 | + """Effet d'une servitude = superficie grevée × prix unitaire du terrain × proportion d'utilité perdue.""" | ||||
| 66 | + s = largeur * longueur | ||||
| 67 | + return {"superficie_bande_m2": s, "valeur_bande": s * prix_terrain_m2, "effet": s * prix_terrain_m2 * perte, | ||||
| 68 | + "part_terrain": s / TERRAIN_M2} | ||||
| 69 | + | ||||
| 70 | + | ||||
| 71 | +# ----------------------------------------------------------------------------- B. copropriété : quotes-parts et charges | ||||
| 72 | +FRACTIONS = [ # (nom, superficie privative m², étage, orientation, stationnement intérieur à usage restreint) | ||||
| 73 | + ("F1", 72.0, "RDC", "nord", False), ("F2", 98.0, "RDC", "sud", True), | ||||
| 74 | + ("F3", 72.0, "2e", "nord", False), ("F4", 98.0, "2e", "sud", True), | ||||
| 75 | + ("F5", 85.0, "3e", "nord", False), ("F6", 110.0, "3e", "sud", True), | ||||
| 76 | + ("F7", 85.0, "2e", "est", True), ("F8", 90.0, "3e", "ouest", False), | ||||
| 77 | +] | ||||
| 78 | +COEF_ETAGE = {"RDC": 0.96, "2e": 1.00, "3e": 1.04} # méthode déclarée dans l'acte constitutif (fictif) | ||||
| 79 | +COEF_ORIENTATION = {"nord": 0.97, "est": 1.00, "ouest": 1.00, "sud": 1.03} | ||||
| 80 | +BASE_QUOTE = 10_000 # quotes-parts exprimées en dix-millièmes | ||||
| 81 | + | ||||
| 82 | +CHARGES_COURANTES = { # budget annuel des charges communes générales, hors fonds (dollars de 2026, fictif) | ||||
| 83 | + "Assurance de l'immeuble": 12_000.0, "Entretien et réparations courantes": 8_500.0, | ||||
| 84 | + "Déneigement et entretien paysager": 5_200.0, "Énergie des parties communes": 4_800.0, | ||||
| 85 | + "Gestion et honoraires professionnels": 7_200.0, "Administration et frais divers": 2_300.0, | ||||
| 86 | +} | ||||
| 87 | +FRANCHISE_MAX = 10_000.0 # plus haute franchise de la police du syndicat (dégâts d'eau) — fictif | ||||
| 88 | +FONDS_AUTO_ASSURANCE_SOLDE = 0.0 # fonds d'auto assurance non capitalisé → contribution = moitié de la franchise (r. 4.1, art. 2) | ||||
| 89 | +ENTRETIEN_STATIONNEMENTS = 3_200.0 # entretien courant des 4 stationnements intérieurs (partie commune à usage restreint) | ||||
| 90 | + | ||||
| 91 | + | ||||
| 92 | +def quotes_parts(fractions=FRACTIONS) -> dict: | ||||
| 93 | + """Valeur relative v_i = S_i × c_étage × c_orientation ; quote-part q_i = v_i / Σ v, en dix-millièmes (somme exacte = 10 000).""" | ||||
| 94 | + v = np.array([s * COEF_ETAGE[e] * COEF_ORIENTATION[o] for _, s, e, o, _ in fractions]) | ||||
| 95 | + q = v / v.sum() | ||||
| 96 | + brut = q * BASE_QUOTE | ||||
| 97 | + ent = np.floor(brut).astype(int) | ||||
| 98 | + reste = BASE_QUOTE - ent.sum() | ||||
| 99 | + ordre = np.argsort(-(brut - ent)) # plus forts restes | ||||
| 100 | + ent[ordre[:reste]] += 1 | ||||
| 101 | + return {"noms": [f[0] for f in fractions], "superficies": np.array([f[1] for f in fractions]), | ||||
| 102 | + "valeur_relative": v, "q": ent / BASE_QUOTE, "q_dixmilliemes": ent, "q_brut": q, | ||||
| 103 | + "stationnement": np.array([f[4] for f in fractions])} | ||||
| 104 | + | ||||
| 105 | + | ||||
| 106 | +def contribution_auto_assurance(franchise_max: float = FRANCHISE_MAX, solde: float = FONDS_AUTO_ASSURANCE_SOLDE) -> float: | ||||
| 107 | + """CCQ, r. 4.1, art. 2 : moitié de la plus haute franchise si le fonds est capitalisé à moins de la moitié ; différence | ||||
| 108 | + entre la franchise et le solde si entre la moitié et la franchise ; nulle au-delà (franchises séisme/inondation exclues).""" | ||||
| 109 | + if solde <= franchise_max / 2: | ||||
| 110 | + return franchise_max / 2 | ||||
| 111 | + if solde < franchise_max: | ||||
| 112 | + return franchise_max - solde | ||||
| 113 | + return 0.0 | ||||
| 114 | + | ||||
| 115 | + | ||||
| 116 | +def repartition_charges(cotisation_fonds_prevoyance: float, qp: dict | None = None) -> dict: | ||||
| 117 | + """Charges annuelles de chaque fraction : (courantes + fonds de prévoyance + fonds d'auto assurance) × q_i | ||||
| 118 | + + part égale de l'entretien des stationnements pour leurs seuls usagers (art. 1064 C.c.Q.).""" | ||||
| 119 | + qp = qp or quotes_parts() | ||||
| 120 | + courantes = sum(CHARGES_COURANTES.values()) | ||||
| 121 | + auto = contribution_auto_assurance() | ||||
| 122 | + general = courantes + cotisation_fonds_prevoyance + auto | ||||
| 123 | + n_stat = int(qp["stationnement"].sum()) | ||||
| 124 | + part_stat = np.where(qp["stationnement"], ENTRETIEN_STATIONNEMENTS / n_stat, 0.0) | ||||
| 125 | + q = qp["q"] | ||||
| 126 | + return {"courantes": courantes, "fonds_prevoyance": cotisation_fonds_prevoyance, "auto_assurance": auto, "general": general, | ||||
| 127 | + "par_fraction_courantes": q * courantes, "par_fraction_fonds": q * cotisation_fonds_prevoyance, | ||||
| 128 | + "par_fraction_auto": q * auto, "par_fraction_general": q * general, "par_fraction_restreint": part_stat, | ||||
| 129 | + "par_fraction_total": q * general + part_stat, "total": general + ENTRETIEN_STATIONNEMENTS, "n_stationnements": n_stat} | ||||
| 130 | + | ||||
| 131 | + | ||||
| 132 | +# ----------------------------------------------------------------------------- C. fonds de prévoyance | ||||
| 133 | +VALEUR_RECONSTRUCTION = 4_200_000.0 # $ ; montant d'assurance (art. 1073 C.c.Q.), fixé par un évaluateur agréé (r. 4.1, art. 3) — fictif | ||||
| 134 | +HORIZON = 25 # années (CCQ, r. 8.01, art. 3 : « minimalement les 25 prochaines années ») | ||||
| 135 | +INFLATION = 0.025 # hausse annuelle des coûts de construction retenue dans l'étude (hypothèse pédagogique) | ||||
| 136 | +RENDEMENT = 0.020 # rendement des placements à capital garanti (art. 1071 : « placé … à capital garanti ») | ||||
| 137 | +SOLDE_INITIAL = 85_000.0 # solde du fonds de prévoyance au début de l'année 1 (2026) — fictif | ||||
| 138 | +COTISATION_ACTUELLE = 12_000.0 # cotisation annuelle votée jusqu'ici (fictif) — insuffisante, on le verra | ||||
| 139 | +COMPOSANTES = [ # (composante, coût en dollars de 2026, années prévues 1..25) | ||||
| 140 | + ("Peinture et éclairage des corridors", 18_000.0, (4, 13, 22)), | ||||
| 141 | + ("Maçonnerie et balcons", 55_000.0, (5,)), | ||||
| 142 | + ("Toiture (membrane)", 75_000.0, (7,)), | ||||
| 143 | + ("Pavage et drainage du stationnement", 28_000.0, (9, 21)), | ||||
| 144 | + ("Chauffage et ventilation des parties communes", 42_000.0, (12,)), | ||||
| 145 | + ("Fenêtres et portes-fenêtres", 140_000.0, (14,)), | ||||
| 146 | + ("Portes de garage et équipements du stationnement", 22_000.0, (16,)), | ||||
| 147 | + ("Entrée d'eau et drains", 35_000.0, (19,)), | ||||
| 148 | +] | ||||
| 149 | + | ||||
| 150 | + | ||||
| 151 | +def depenses_prevues(composantes=COMPOSANTES, horizon: int = HORIZON, inflation: float = INFLATION) -> np.ndarray: | ||||
| 152 | + """D_t, t = 1..horizon : coûts indexés C_0 (1+π)^t des réparations majeures prévues l'année t.""" | ||||
| 153 | + d = np.zeros(horizon + 1) | ||||
| 154 | + for _, c0, annees in composantes: | ||||
| 155 | + for t in annees: | ||||
| 156 | + if 1 <= t <= horizon: | ||||
| 157 | + d[t] += c0 * (1.0 + inflation) ** t | ||||
| 158 | + return d | ||||
| 159 | + | ||||
| 160 | + | ||||
| 161 | +def trajectoire(cotisation_an1: float, solde0: float = SOLDE_INITIAL, rendement: float = RENDEMENT, inflation: float = INFLATION, | ||||
| 162 | + horizon: int = HORIZON, composantes=COMPOSANTES, indexer: bool = True) -> dict: | ||||
| 163 | + """F_t = F_{t-1}(1+r) + A_t − D_t, avec A_t = A_1 (1+π)^{t−1} si indexée (convention de l'étude), flux en fin d'année.""" | ||||
| 164 | + d = depenses_prevues(composantes, horizon, inflation) | ||||
| 165 | + F = np.zeros(horizon + 1); F[0] = solde0 | ||||
| 166 | + A = np.zeros(horizon + 1) | ||||
| 167 | + for t in range(1, horizon + 1): | ||||
| 168 | + A[t] = cotisation_an1 * ((1.0 + inflation) ** (t - 1) if indexer else 1.0) | ||||
| 169 | + F[t] = F[t - 1] * (1.0 + rendement) + A[t] - d[t] | ||||
| 170 | + return {"F": F, "A": A, "D": d, "min": float(F[1:].min()), "annee_min": int(np.argmin(F[1:]) + 1), | ||||
| 171 | + "premiere_annee_negative": next((t for t in range(1, horizon + 1) if F[t] < 0), None)} | ||||
| 172 | + | ||||
| 173 | + | ||||
| 174 | +def cotisation_seuil(plancher: float = 0.0, solde0: float = SOLDE_INITIAL, rendement: float = RENDEMENT, inflation: float = INFLATION, | ||||
| 175 | + horizon: int = HORIZON, composantes=COMPOSANTES, indexer: bool = True) -> float: | ||||
| 176 | + """Méthode du seuil : plus petite cotisation A_1 (indexée) telle que le solde ne descende jamais sous `plancher`.""" | ||||
| 177 | + lo, hi = 0.0, 500_000.0 | ||||
| 178 | + for _ in range(200): | ||||
| 179 | + mid = 0.5 * (lo + hi) | ||||
| 180 | + if trajectoire(mid, solde0, rendement, inflation, horizon, composantes, indexer)["min"] >= plancher: | ||||
| 181 | + hi = mid | ||||
| 182 | + else: | ||||
| 183 | + lo = mid | ||||
| 184 | + return hi | ||||
| 185 | + | ||||
| 186 | + | ||||
| 187 | +def solde_initial_requis(cotisation: float = COTISATION_ACTUELLE, plancher: float = 0.0, **kw) -> float: | ||||
| 188 | + """Solde d'ouverture qui rendrait la cotisation actuelle suffisante (sert à mesurer le déficit de capitalisation).""" | ||||
| 189 | + lo, hi = 0.0, 5_000_000.0 | ||||
| 190 | + for _ in range(200): | ||||
| 191 | + mid = 0.5 * (lo + hi) | ||||
| 192 | + if trajectoire(cotisation, mid, **kw)["min"] >= plancher: | ||||
| 193 | + hi = mid | ||||
| 194 | + else: | ||||
| 195 | + lo = mid | ||||
| 196 | + return hi | ||||
| 197 | + | ||||
| 198 | + | ||||
| 199 | +def fonds_composante_unique(cout_actuel: float, annees: int, inflation: float = INFLATION, rendement: float = RENDEMENT) -> dict: | ||||
| 200 | + """Vérification par le facteur de fonds d'amortissement : versement constant qui accumule C_0 (1+π)^T en T ans au taux r.""" | ||||
| 201 | + cT = cout_actuel * (1.0 + inflation) ** annees | ||||
| 202 | + sff = fin.facteur_fonds_amortissement(rendement, annees) | ||||
| 203 | + return {"cout_indexe": cT, "sff": sff, "versement": cT * sff} | ||||
| 204 | + | ||||
| 205 | + | ||||
| 206 | +def sensibilite_cotisation(inflations=(0.015, 0.020, 0.025, 0.030, 0.035), rendements=(0.010, 0.020, 0.030), plancher: float = 0.0) -> dict: | ||||
| 207 | + grille = {r: [cotisation_seuil(plancher, inflation=p, rendement=r) for p in inflations] for r in rendements} | ||||
| 208 | + return {"inflations": list(inflations), "rendements": list(rendements), "grille": grille} | ||||
| 209 | + | ||||
| 210 | + | ||||
| 211 | +def incidence_valeur(qp: dict | None = None, cotisation: float = COTISATION_ACTUELLE) -> dict: | ||||
| 212 | + """Déficit de capitalisation D_0 = solde requis − solde réel ; ajustement de la fraction i = −q_i × D_0 (hypothèse : capitalisation 1 pour 1).""" | ||||
| 213 | + qp = qp or quotes_parts() | ||||
| 214 | + requis = solde_initial_requis(cotisation) | ||||
| 215 | + deficit = requis - SOLDE_INITIAL | ||||
| 216 | + return {"solde_requis": requis, "deficit": deficit, "ajustement_par_fraction": -qp["q"] * deficit} | ||||
| 217 | + | ||||
| 218 | + | ||||
| 219 | +# ----------------------------------------------------------------------------- impression | ||||
| 220 | +def fmt(x): return f"{x:,.0f}".replace(",", " ") | ||||
| 221 | + | ||||
| 222 | + | ||||
| 223 | +def main() -> dict: | ||||
| 224 | + out = {} | ||||
| 225 | + # A | ||||
| 226 | + p = paires_servitude(); out["paires"] = p | ||||
| 227 | + print("== A. Servitude de passage — ventes appariées (n = %d, graine %d)" % (p["n"], SEED)) | ||||
| 228 | + for i, (s, a, d) in enumerate(zip(p["sans"], p["avec"], p["diffs"]), 1): | ||||
| 229 | + print(f" paire {i:2d} : sans {fmt(s):>9} $ avec {fmt(a):>9} $ diff {d:>9,.0f} $ ratio {a/s-1:+.3%}") | ||||
| 230 | + print(f" moyenne {p['moyenne']:,.0f} $ ; médiane {p['mediane']:,.0f} $ ; écart-type {p['ecart_type']:,.0f} $ ; erreur-type {p['se']:,.0f} $") | ||||
| 231 | + print(f" t = {p['t']:.2f} (t crit. {p['t_crit']:.3f}) ; IC 95 % [{p['ic95'][0]:,.0f} ; {p['ic95'][1]:,.0f}] $") | ||||
| 232 | + print(f" effet relatif : moyenne des ratios {p['effet_relatif_moyen']:.4%} ; rapport des moyennes {p['effet_relatif_ratio']:.4%} ; semi-log exp(β)−1 = {p['effet_semilog']:.4%} (β = {p['beta_log']:.5f})") | ||||
| 233 | + print(f" IC 95 % relatif [{p['ic95_relatif'][0]:.2%} ; {p['ic95_relatif'][1]:.2%}] ; moyenne des prix sans {p['sans'].mean():,.0f} $") | ||||
| 234 | + b = modele_bande(); out["bande"] = b | ||||
| 235 | + print(f" modèle de la bande : {b['superficie_bande_m2']:.0f} m² × {PRIX_TERRAIN_M2:.0f} $/m² = {b['valeur_bande']:,.0f} $ × {PERTE_UTILITE:.0%} = {b['effet']:,.0f} $ ({b['part_terrain']:.1%} du terrain)") | ||||
| 236 | + # B | ||||
| 237 | + qp = quotes_parts(); out["qp"] = qp | ||||
| 238 | + print("\n== B. Copropriété divise — valeur relative et quotes-parts (dix-millièmes)") | ||||
| 239 | + for n, s, v, q in zip(qp["noms"], qp["superficies"], qp["valeur_relative"], qp["q_dixmilliemes"]): | ||||
| 240 | + print(f" {n} : {s:5.0f} m² v = {v:8.2f} q = {q:5d}/10 000") | ||||
| 241 | + print(f" Σ v = {qp['valeur_relative'].sum():.2f} ; Σ q = {qp['q_dixmilliemes'].sum()} ; Σ S = {qp['superficies'].sum():.0f} m²") | ||||
| 242 | + # C | ||||
| 243 | + A_star = cotisation_seuil(); out["A_star"] = A_star | ||||
| 244 | + A_floor = cotisation_seuil(plancher=25_000.0); out["A_floor"] = A_floor | ||||
| 245 | + regle = 0.005 * VALEUR_RECONSTRUCTION; out["regle_0_5"] = regle | ||||
| 246 | + tr_act = trajectoire(COTISATION_ACTUELLE); tr_star = trajectoire(A_star); tr_regle = trajectoire(regle) | ||||
| 247 | + out.update({"tr_act": tr_act, "tr_star": tr_star, "tr_regle": tr_regle}) | ||||
| 248 | + D = tr_star["D"] | ||||
| 249 | + print("\n== C. Fonds de prévoyance — plan sur %d ans, π = %.1f %%, r = %.1f %%, F0 = %s $" % (HORIZON, INFLATION * 100, RENDEMENT * 100, fmt(SOLDE_INITIAL))) | ||||
| 250 | + tot0 = 0.0 | ||||
| 251 | + for nom, c0, annees in COMPOSANTES: | ||||
| 252 | + for t in annees: | ||||
| 253 | + print(f" an {t:2d} ({2026 + t - 1}) : {nom:48s} {fmt(c0):>8} $ → indexé {fmt(c0 * (1 + INFLATION) ** t):>8} $") | ||||
| 254 | + tot0 += c0 | ||||
| 255 | + print(f" total des dépenses prévues : {fmt(tot0)} $ (dollars de 2026) ; {fmt(D.sum())} $ (dollars courants)") | ||||
| 256 | + print(f" cotisation actuelle {fmt(COTISATION_ACTUELLE)} $ : solde minimal {tr_act['min']:,.0f} $ (an {tr_act['annee_min']}), premier solde négatif an {tr_act['premiere_annee_negative']} ; solde an 25 {tr_act['F'][-1]:,.0f} $") | ||||
| 257 | + print(f" règle 0,5 % × {fmt(VALEUR_RECONSTRUCTION)} = {fmt(regle)} $ : solde minimal {tr_regle['min']:,.0f} $ (an {tr_regle['annee_min']}), premier négatif an {tr_regle['premiere_annee_negative']}") | ||||
| 258 | + print(f" cotisation de seuil A* (plancher 0) = {A_star:,.0f} $ (an 1, indexée à {INFLATION:.1%}) ; solde minimal {tr_star['min']:,.2f} $ an {tr_star['annee_min']} ; solde an 25 {tr_star['F'][-1]:,.0f} $ ; an 25 cotisation {tr_star['A'][-1]:,.0f} $") | ||||
| 259 | + print(f" cotisation de seuil avec plancher 25 000 $ = {A_floor:,.0f} $") | ||||
| 260 | + fc = fonds_composante_unique(75_000.0, 7); out["toiture"] = fc | ||||
| 261 | + print(f" vérification toiture seule : {fmt(75000)} $ × 1,025^7 = {fc['cout_indexe']:,.0f} $ ; SFF(2 %, 7) = {fc['sff']:.5f} ; versement {fc['versement']:,.0f} $") | ||||
| 262 | + rep = repartition_charges(A_star, qp); out["rep"] = rep | ||||
| 263 | + print(f"\n== B'. Répartition des charges avec A* : courantes {fmt(rep['courantes'])} $ + fonds {fmt(rep['fonds_prevoyance'])} $ + auto-assurance {fmt(rep['auto_assurance'])} $ = {fmt(rep['general'])} $ ; stationnements {fmt(ENTRETIEN_STATIONNEMENTS)} $ / {rep['n_stationnements']}") | ||||
| 264 | + for i, n in enumerate(qp["noms"]): | ||||
| 265 | + print(f" {n} : q {qp['q'][i]:.4f} courantes {rep['par_fraction_courantes'][i]:8,.0f} fonds {rep['par_fraction_fonds'][i]:8,.0f} auto {rep['par_fraction_auto'][i]:6,.0f} restreint {rep['par_fraction_restreint'][i]:6,.0f} total {rep['par_fraction_total'][i]:8,.0f} $/an = {rep['par_fraction_total'][i]/12:6,.0f} $/mois") | ||||
| 266 | + print(f" total {rep['total']:,.0f} $ ; contrôle Σ = {rep['par_fraction_total'].sum():,.2f} $") | ||||
| 267 | + sens = sensibilite_cotisation(); out["sens"] = sens | ||||
| 268 | + print("\n== Sensibilité de A* (plancher 0) : lignes r, colonnes π") | ||||
| 269 | + print(" " + " ".join(f"{p:.1%}" for p in sens["inflations"])) | ||||
| 270 | + for r, row in sens["grille"].items(): | ||||
| 271 | + print(f" r={r:.1%} " + " ".join(f"{a:7,.0f}" for a in row)) | ||||
| 272 | + inc = incidence_valeur(qp); out["incidence"] = inc | ||||
| 273 | + print(f"\n== Incidence sur la valeur : solde requis avec {fmt(COTISATION_ACTUELLE)} $ = {inc['solde_requis']:,.0f} $ ; déficit {inc['deficit']:,.0f} $") | ||||
| 274 | + for n, a in zip(qp["noms"], inc["ajustement_par_fraction"]): | ||||
| 275 | + print(f" {n} : ajustement {a:,.0f} $") | ||||
| 276 | + return out | ||||
| 277 | + | ||||
| 278 | + | ||||
| 279 | +if __name__ == "__main__": | ||||
| 280 | + main() | ||||
added
analysis/python/cases/ch08_zonage.py
+351 −0
Large diff (351 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/cases/ch10_controle_rapport.py
+215 −0
Large diff (215 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/cases/ch10_matrice_normes.py
+90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch01_figures.py
+127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch02_decomposition.py
+42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch02_ecart_logements.py
+42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch06_emphyteose.py
+34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch06_usufruit.py
+39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch07_charges.py
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch07_fonds.py
+31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch07_paires.py
+35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch07_sensibilite.py
+27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch08_carte_zones.py
+42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch08_option_sensibilite.py
+39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch08_rezonage_paires.py
+46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch10_figures.py
+124 −0
Large diff (124 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/tests/test_ch01.py
+169 −0
Large diff (169 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/tests/test_ch10.py
+94 −0
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added
assets/figures/ch01/ch01-fig-prix-valeur.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch01/ch01-fig-recherche.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch01/ch01-fig-residus.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch01/ch01-fig-rosen.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch01/ch01-fig-sensibilite-sigma.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch02/ch02-fig-decomposition-droits.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch02/ch02-fig-ecart-logements.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch06/ch06-fig-emphyteose.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch06/ch06-fig-usufruit.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch07/ch07-fig-fonds-prevoyance.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch07/ch07-fig-paires-servitude.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch07/ch07-fig-repartition-charges.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch07/ch07-fig-sensibilite-cotisation.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch08/ch08-fig-carte-zones.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch08/ch08-fig-option-sensibilite.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch08/ch08-fig-rezonage-paires.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch10/ch10-fig-chronologie-cadres.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch10/ch10-fig-grille-controle.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch10/ch10-fig-matrice-normes.pdf
+0 −0
Binary file not shown.
added
data/raw/gatineau_r994_2025/README.md
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
data/raw/gatineau_r994_2025/annexe1_taux_voisinage.csv
+1093 −0
Large diff (1,093 lines) is not loaded by default to keep the page fast.Load diff
modified
data/synthetic/README.md
+3 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added
data/synthetic/ch08_zonage_lots.csv
+401 −0
Large diff (401 lines) is not loaded by default to keep the page fast.Load diff
added
data/synthetic/ch08_zonage_ventes.csv
+483 −0
Large diff (483 lines) is not loaded by default to keep the page fast.Load diff
modified
manuscript/chapters/ch10.tex
+220 −1
Large diff (222 lines) is not loaded by default to keep the page fast.Load diff
modified
manuscript/chapters/ch47.tex
+67 −56
Large diff (365 lines) is not loaded by default to keep the page fast.Load diff
added
references/lib-ch47.bib
+8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
solutions/ch47.tex
+30 −1
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
modified
sources/registry.csv
+1 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/calculations.csv
+26 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch47-calculations.csv
+27 −0
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch47-claims.csv
+38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch47-figures.csv
+8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch47-registry.csv
+2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch47.md
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/claims.csv
+37 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/figures.csv
+7 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff