spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""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.34Imprime 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 mode6 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 du11 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 soutenable15 au taux hypothécaire (traite.finance.Hypotheque, composition semestrielle).1617Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch04_marches.py18Aucune donnée synthétique n'est utilisée dans ce chapitre. Licences : voir data/raw/SOURCES.md."""19from __future__ import annotations20import sys, json, csv, pathlib, math21import numpy as np, pandas as pd22sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))23from traite import finance as fin2425ROOT = pathlib.Path(__file__).resolve().parents[3]26RAW = ROOT / "data/raw"27CAL = json.loads((ROOT / "data/synthetic/calibration.json").read_text(encoding="utf-8"))2829# ----------------------------------------------------------------------------- Recensement 2021 : types et mode d'occupation30# Membres de la dimension « Type de construction résidentielle » du tableau 98-10-0240-01 (métadonnées WDS, getCubeMetadata, 2026-10-31)31TYPES_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"}34TYPES_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"}36MODES_240 = {1: "total", 2: "proprietaire", 3: "locataire"}37GEO_240 = {1: "Canada", 884: "Québec"}3839def 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 df5455def 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)6465def 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())6869def 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).T78 df["part_locataires"] = df["locataire"] / df["total"]79 return df8081# ----------------------------------------------------------------------------- Population des RMR (17-10-0148-01)82RMR_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"}8485def 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] - 193 df["tcac_2020_2025"] = (df[2025] / df[2020]) ** (1 / 5) - 194 return df9596# ----------------------------------------------------------------------------- IPLN (18-10-0205-01)97IPLN_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"}100101def 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)111112def 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) - 1127 out.append(rec)128 return pd.DataFrame(out).set_index("geo")129130# ----------------------------------------------------------------------------- SCHL, Enquête sur les logements locatifs (octobre 2025)131RMR_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."}134TYPES_SCHL = ["studio", "1_chambre", "2_chambres", "3_chambres_plus", "tous"]135136def _val(tok):137 if tok is None: return None138 s = str(tok).strip().replace(" ", " ")139 if s in ("**", "++", ""): return np.nan # ** : donnée supprimée ; ++ : variation non calculable140 try: return float(s.replace(" ", "").replace(",", "."))141 except ValueError: return None142143def _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: continue149 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: continue156 v = _val(tok)157 if v is None: continue158 vals.append(v)159 if len(vals) == 10:160 out[name] = vals161 return out162163def 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 openpyxl166 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"] - 1177 rec["var_brute_tous"] = rec["loyer_tous_2025"] / rec["loyer_tous_2024"] - 1178 rows.append(rec)179 return pd.DataFrame(rows).set_index("rmr")180181# ----------------------------------------------------------------------------- APCIQ T3 2026 (chiffres cités avec attribution, calibration.json)182def 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 figure196 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"], k198 df = pd.DataFrame(rows).T199 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 df203204def 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)207208# ----------------------------------------------------------------------------- Banque du Canada (Valet)209def _valet(fichier: str) -> pd.Series:210 rows = []211 with open(RAW / "bdc_valet" / fichier, encoding="utf-8-sig") as f:212 started = False213 for line in f:214 line = line.strip()215 if line.startswith('"OBSERVATIONS"'): started = True; continue216 if not started or not line or line.startswith('"date"'): continue217 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()221222def 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}234235# ----------------------------------------------------------------------------- Droits sur les mutations immobilières 2026236BAREMES_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.ca238 "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}243244def 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.0247 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 base251 if borne is not None and base <= borne: break252 return total253254def 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)261262# ----------------------------------------------------------------------------- Taux et prix soutenable263def paiement_mensuel(capital: float, taux: float, amort_ans: int = 25) -> float:264 return fin.Hypotheque(capital, taux, amort_ans * 12).paiement265266def 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)271272def 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.8276 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_ref282283# ----------------------------------------------------------------------------- Ratio prix / loyer annuel284def ratio_prix_loyer(prix: float, loyer_mensuel: float) -> float:285 return prix / (12 * loyer_mensuel)286287# ----------------------------------------------------------------------------- Impression288def fmt(x, d=0): return f"{x:,.{d}f}".replace(",", " ")289290def main(verbose: bool = True) -> dict:291 res = {}292 sp = structure_parc(); res["parc"] = sp293 rt = recensement_types(); res["types"] = rt294 tot = rt[rt["type_id"] == 1].set_index("geo")295 D = indice_dissimilarite(sp["qc_part"].values, sp["ca_part"].values); res["dissimilarite"] = D296 mr = menages_rmr(); res["menages_rmr"] = mr297 pop = population_rmr(); res["population"] = pop298 ip = ipln(); st = ipln_stats(ip); res["ipln"] = ip; res["ipln_stats"] = st299 sc = schl_rmr(); res["schl"] = sc300 ap = apciq_t3_2026(); res["apciq"] = ap301 b = bdc(); res["bdc"] = b302 mut = tableau_mutations([500000, 645500, 860500, 1500000]); res["mutations"] = mut303 sens, pmt_ref = sensibilite_taux(); res["sensibilite"] = sens; res["paiement_ref"] = pmt_ref304 # MRB et prix/loyer implicites305 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"] = mrb308 if not verbose: return res309 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 res331332if __name__ == "__main__":333 main()334