SPB Git

Ch. 47 livré (50 p., dossier complet, Monte-Carlo) ; renvois normalisés

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

60 changed files +5,653 −58

60 changed files
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
Menu

Theme