#!/usr/bin/env python3 """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. Imprime tous les chiffres du chapitre 4 : * structure du parc (Recensement 2021, tableau 98-10-0240-01, WDS) : ménages par type de construction et mode d'occupation, Québec et Canada ; parts, taux de propriété par type, indice de dissimilarité Québec/Canada ; * ménages et locataires par RMR (98-10-0231-01) ; population des RMR 2020-2025 (17-10-0148-01) et TCAC ; * Indice des prix des logements neufs (18-10-0205-01, déc. 2016 = 100) : niveaux de septembre 2026, variations, composantes maison/terrain, épisode 2020-2022 ; * 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 fichier rmr-quebec-2025-fr.xlsx) : inoccupation, loyers, univers, variation estimative vs variation brute ; * indicateurs APCIQ T3 2026 (chiffres cités, calibration.json) : mois d'inventaire, rapports de prix, MRB implicite ; * Banque du Canada (API Valet) : taux directeur, taux hypothécaire affiché 5 ans, obligation 5 ans, 2000-2026 ; * droits sur les mutations immobilières 2026 (province, Montréal, Gatineau, Québec) ; sensibilité du prix soutenable au taux hypothécaire (traite.finance.Hypotheque, composition semestrielle). Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch04_marches.py Aucune donnée synthétique n'est utilisée dans ce chapitre. Licences : voir data/raw/SOURCES.md.""" from __future__ import annotations import sys, json, csv, pathlib, math import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import finance as fin ROOT = pathlib.Path(__file__).resolve().parents[3] RAW = ROOT / "data/raw" CAL = json.loads((ROOT / "data/synthetic/calibration.json").read_text(encoding="utf-8")) # ----------------------------------------------------------------------------- Recensement 2021 : types et mode d'occupation # Membres de la dimension « Type de construction résidentielle » du tableau 98-10-0240-01 (métadonnées WDS, getCubeMetadata, 2026-10-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", 6: "Appartement, immeuble de moins de cinq étages", 3: "Appartement, immeuble de cinq étages ou plus", 7: "Autre maison individuelle attenante", 10: "Logement mobile"} TYPES_COURTS = {2: "Maison individuelle", 9: "Jumelée", 8: "En rangée", 5: "Duplex", 6: "Appartement < 5 étages", 3: "Appartement ≥ 5 étages", 7: "Autre attenante", 10: "Mobile"} MODES_240 = {1: "total", 2: "proprietaire", 3: "locataire"} GEO_240 = {1: "Canada", 884: "Québec"} def recensement_types() -> pd.DataFrame: """Ménages privés selon le type de construction et le mode d'occupation (Canada, Québec), Recensement 2021.""" d = json.loads((RAW / "statcan_recensement2021/98100240_canada_quebec_type_construction_mode_occupation.json").read_text(encoding="utf-8")) rows = [] for e in d: o = e["object"]; c = [int(x) for x in o["coordinate"].split(".")] geo, typ, mode = c[0], c[4], c[5] rows.append({"geo": GEO_240[geo], "type_id": typ, "mode": MODES_240[mode], "menages": o["vectorDataPoint"][0]["value"]}) df = pd.DataFrame(rows).pivot_table(index=["geo", "type_id"], columns="mode", values="menages").reset_index() df["type"] = df["type_id"].map(lambda k: TYPES_240.get(k, "Total" if k == 1 else "Autre logement attenant (agrégat)")) df["court"] = df["type_id"].map(lambda k: TYPES_COURTS.get(k, "Total" if k == 1 else "Attenant (agrégat)")) tot = df[df["type_id"] == 1].set_index("geo")["total"] df["part"] = df.apply(lambda r: r["total"] / tot[r["geo"]], axis=1) df["taux_propriete"] = df["proprietaire"] / df["total"] return df def structure_parc() -> pd.DataFrame: """Huit types détaillés (sans l'agrégat 4 ni le total), Québec et Canada côte à côte.""" df = recensement_types() d = df[df["type_id"].isin(TYPES_240)].copy() q = d[d["geo"] == "Québec"].set_index("type_id"); c = d[d["geo"] == "Canada"].set_index("type_id") out = pd.DataFrame({"type": q["type"], "court": q["court"], "qc_menages": q["total"], "qc_part": q["part"], "qc_proprietaires": q["proprietaire"], "qc_locataires": q["locataire"], "qc_taux_propriete": q["taux_propriete"], "ca_menages": c["total"], "ca_part": c["part"], "ca_taux_propriete": c["taux_propriete"]}) return out.sort_values("qc_menages", ascending=False) def indice_dissimilarite(parts_a: np.ndarray, parts_b: np.ndarray) -> float: """D = ½ Σ |s_i^A − s_i^B| : part des ménages à « reclasser » pour que les deux structures coïncident.""" return 0.5 * float(np.abs(np.asarray(parts_a) - np.asarray(parts_b)).sum()) def menages_rmr() -> pd.DataFrame: """98-10-0231-01 : ménages propriétaires / locataires, Canada, Québec, RMR Montréal, Québec, Ottawa-Gatineau (ensemble).""" geo = {1: "Canada", 24: "Québec (province)", 35: "RMR Montréal", 36: "RMR Québec", 81: "RMR Ottawa-Gatineau (ensemble)"} d = json.loads((RAW / "statcan_recensement2021/98100231_mode_occupation_canada_quebec_rmr.json").read_text(encoding="utf-8")) rows = {} for e in d: o = e["object"]; c = [int(x) for x in o["coordinate"].split(".")] rows.setdefault(geo[c[0]], {})[MODES_240[c[6]]] = o["vectorDataPoint"][0]["value"] df = pd.DataFrame(rows).T df["part_locataires"] = df["locataire"] / df["total"] return df # ----------------------------------------------------------------------------- Population des RMR (17-10-0148-01) RMR_148 = {14: "Montréal", 10: "Québec", 17: "Ottawa-Gatineau (partie du Québec)", 11: "Sherbrooke", 12: "Trois-Rivières", 9: "Saguenay", 13: "Drummondville", 191: "Ensemble des RMR du Québec"} def population_rmr() -> pd.DataFrame: d = json.loads((RAW / "statcan_17100148/17100148_rmr_quebec_total_latest6.json").read_text(encoding="utf-8")) rows = {} for e in d: o = e["object"]; k = int(o["coordinate"].split(".")[0]) rows[RMR_148[k]] = {int(p["refPerRaw"][:4]): p["value"] for p in o["vectorDataPoint"]} df = pd.DataFrame(rows).T.sort_index(axis=1) df["var_2024_2025"] = df[2025] / df[2024] - 1 df["tcac_2020_2025"] = (df[2025] / df[2020]) ** (1 / 5) - 1 return df # ----------------------------------------------------------------------------- IPLN (18-10-0205-01) IPLN_GEO = {"Canada": "Canada", "Québec": "Québec (province)", "Montréal, Québec": "Montréal", "Québec, Québec": "Québec", "Ottawa-Gatineau, partie Québec, Ontario/Québec": "Gatineau (partie du Québec)", "Sherbrooke, Québec": "Sherbrooke", "Trois-Rivières, Québec": "Trois-Rivières"} def ipln() -> pd.DataFrame: """Séries mensuelles (déc. 2016 = 100) : colonnes date, geo, composante (Total / Maison / Terrain), indice.""" rows = [] with open(RAW / "statcan_18100205/18100205.csv", encoding="utf-8-sig") as f: r = csv.reader(f, delimiter=";"); next(r) for row in r: if row[1] in IPLN_GEO and row[10] not in ("", ".."): comp = {"Total (maison et terrain)": "Total", "Maison seulement": "Maison", "Terrain seulement": "Terrain"}[row[3]] rows.append({"date": pd.Timestamp(row[0] + "-01"), "geo": IPLN_GEO[row[1]], "composante": comp, "indice": float(row[10]), "vecteur": row[8]}) return pd.DataFrame(rows).sort_values(["geo", "composante", "date"]).reset_index(drop=True) def ipln_stats(df: pd.DataFrame) -> pd.DataFrame: """Par géographie : dernier mois, niveau total/maison/terrain, variation 12 mois, variation depuis déc. 2016, épisode janv. 2020 → déc. 2022, sommet.""" out = [] for geo, g in df.groupby("geo"): t = g[g["composante"] == "Total"].set_index("date")["indice"] last = t.index.max() rec = {"geo": geo, "dernier_mois": last.strftime("%Y-%m"), "total": t[last], "var_12_mois": t[last] / t[last - pd.DateOffset(years=1)] - 1, "var_depuis_2016_12": t[last] / t[pd.Timestamp("2016-12-01")] - 1, "var_2020_01_2022_12": t[pd.Timestamp("2022-12-01")] / t[pd.Timestamp("2020-01-01")] - 1, "sommet": t.idxmax().strftime("%Y-%m"), "sommet_val": t.max(), "ecart_sommet": t[last] / t.max() - 1, "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} for comp in ("Maison", "Terrain"): s = g[g["composante"] == comp].set_index("date")["indice"] rec[comp.lower()] = s.get(last, np.nan) rec[f"{comp.lower()}_var_depuis_2016_12"] = s.get(last, np.nan) / s.get(pd.Timestamp("2016-12-01"), np.nan) - 1 out.append(rec) return pd.DataFrame(out).set_index("geo") # ----------------------------------------------------------------------------- SCHL, Enquête sur les logements locatifs (octobre 2025) RMR_SCHL = {"Montréal (RMR)": "Montréal", "Québec (RMR)": "Québec", "Ottawa-Gatineau (RMR) (partie Québec)": "Gatineau (partie du Québec)", "Sherbrooke (RMR)": "Sherbrooke", "Trois-Rivières (RMR)": "Trois-Rivières", "Saguenay (RMR)": "Saguenay"} _SKIP = {"a", "b", "c", "d", "↑", "↓", "-", "n.d."} TYPES_SCHL = ["studio", "1_chambre", "2_chambres", "3_chambres_plus", "tous"] def _val(tok): if tok is None: return None s = str(tok).strip().replace(" ", " ") if s in ("**", "++", ""): return np.nan # ** : donnée supprimée ; ++ : variation non calculable try: return float(s.replace(" ", "").replace(",", ".")) except ValueError: return None def _lire_tableau(ws) -> dict: """Pour chaque centre : 10 valeurs (5 types × [oct. 2024, oct. 2025]) nettoyées des lettres de qualité et des flèches.""" out = {} for row in ws.iter_rows(values_only=True): cells = [c for c in row if c is not None] if not cells: continue lab = str(cells[0]).strip() for k, name in RMR_SCHL.items(): if lab.startswith(k.split(" (RMR)")[0]) and "(RMR)" in lab: vals = [] for tok in cells[1:]: s = str(tok).strip() if s in _SKIP: continue v = _val(tok) if v is None: continue vals.append(v) if len(vals) == 10: out[name] = vals return out def schl_rmr() -> pd.DataFrame: """Inoccupation (%), loyer moyen ($), univers (logements) et variation estimative (%) par RMR, octobre 2024 et 2025 (fichier Québec, tous les centres).""" import openpyxl wb = openpyxl.load_workbook(RAW / "schl_rml_2025/rmr-quebec-2025-fr.xlsx", read_only=True, data_only=True) t111, t112, t113, t115 = (_lire_tableau(wb[f"Tableau 1.1.{i}"]) for i in (1, 2, 3, 5)) rows = [] for name in RMR_SCHL.values(): rec = {"rmr": name} for j, typ in enumerate(TYPES_SCHL): rec[f"inocc_{typ}_2024"], rec[f"inocc_{typ}_2025"] = t111[name][2 * j], t111[name][2 * j + 1] rec[f"loyer_{typ}_2024"], rec[f"loyer_{typ}_2025"] = t112[name][2 * j], t112[name][2 * j + 1] rec[f"univers_{typ}_2024"], rec[f"univers_{typ}_2025"] = t113[name][2 * j], t113[name][2 * j + 1] rec[f"varest_{typ}_2024"], rec[f"varest_{typ}_2025"] = t115[name][2 * j], t115[name][2 * j + 1] rec["var_brute_2_chambres"] = rec["loyer_2_chambres_2025"] / rec["loyer_2_chambres_2024"] - 1 rec["var_brute_tous"] = rec["loyer_tous_2025"] / rec["loyer_tous_2024"] - 1 rows.append(rec) return pd.DataFrame(rows).set_index("rmr") # ----------------------------------------------------------------------------- APCIQ T3 2026 (chiffres cités avec attribution, calibration.json) def apciq_t3_2026() -> pd.DataFrame: """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).""" p = CAL["prix_apciq"] rows = { "Province": dict(unifamiliale=p["province"]["unifamiliale_prix_median"]["valeur"], copropriete=p["province"]["copropriete_prix_median"]["valeur"], plex=p["province"]["plex_prix_median"]["valeur"], ventes=p["province"]["ventes_residentielles"]["valeur"], inscriptions=p["province"]["inscriptions_en_vigueur"]["valeur"]), "Montréal": dict(unifamiliale=645500, copropriete=435000, plex=860500, ventes=9265, inscriptions=20350), "Québec": dict(unifamiliale=463500, copropriete=334900, plex=600000, ventes=2161, inscriptions=2204), "Gatineau": dict(unifamiliale=480000, copropriete=297450, plex=617500, ventes=1044, inscriptions=2256), "Sherbrooke": dict(unifamiliale=490750, copropriete=327000, plex=544500, ventes=569, inscriptions=825), "Trois-Rivières": dict(unifamiliale=395000, copropriete=287000, plex=420000, ventes=372, inscriptions=447), "Saguenay": dict(unifamiliale=365500, copropriete=np.nan, plex=373500, ventes=371, inscriptions=498), } for k in ("Montréal", "Québec", "Gatineau", "Sherbrooke"): # cohérence avec calibration.json quand la RMR y figure key = {"Montréal": "rmr_montreal", "Québec": "rmr_quebec", "Gatineau": "rmr_gatineau", "Sherbrooke": "rmr_sherbrooke"}[k] assert p[key]["unifamiliale_prix_median"]["valeur"] == rows[k]["unifamiliale"], k df = pd.DataFrame(rows).T df["mois_inventaire"] = df["inscriptions"] / (df["ventes"] / 3) df["plex_sur_unifamiliale"] = df["plex"] / df["unifamiliale"] df["copro_sur_unifamiliale"] = df["copropriete"] / df["unifamiliale"] return df def mrb_implicite(prix_plex: float, loyer_2ch: float, logements: int = 3) -> float: """Multiplicateur du revenu brut implicite d'un plex de `logements` logements loués au loyer moyen 2 chambres de la RMR (ordre de grandeur).""" return fin.multiplicateur_revenu_brut(prix_plex, 12 * logements * loyer_2ch) # ----------------------------------------------------------------------------- Banque du Canada (Valet) def _valet(fichier: str) -> pd.Series: rows = [] with open(RAW / "bdc_valet" / fichier, encoding="utf-8-sig") as f: started = False for line in f: line = line.strip() if line.startswith('"OBSERVATIONS"'): started = True; continue if not started or not line or line.startswith('"date"'): continue parts = [x.strip('"') for x in line.split(",")] if len(parts) >= 2 and parts[1] not in ("", "na"): rows.append((pd.Timestamp(parts[0]), float(parts[1]))) return pd.Series(dict(rows)).sort_index() def bdc() -> dict: 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")} d = s["directeur"] chg = d[d.diff() != 0].dropna() stats = {"directeur_dernier": (d.index[-1].date().isoformat(), d.iloc[-1]), "directeur_min": (d.idxmin().date().isoformat(), d.min()), "directeur_max_2000": (d.idxmax().date().isoformat(), d.max()), "directeur_2022_03": d[d.index >= "2022-03-01"].iloc[0], "directeur_2023_07_12": d[d.index >= "2023-07-13"].iloc[0], "hyp5_dernier": (s["hyp5"].index[-1].date().isoformat(), s["hyp5"].iloc[-1]), "hyp5_min": (s["hyp5"].idxmin().date().isoformat(), s["hyp5"].min()), "hyp5_max_2000": (s["hyp5"].idxmax().date().isoformat(), s["hyp5"].max()), "obl5_dernier": (s["obl5"].index[-1].date().isoformat(), s["obl5"].iloc[-1]), "obl10_dernier": (s["obl10"].index[-1].date().isoformat(), s["obl10"].iloc[-1]), "changements_2022_2026": [(t.date().isoformat(), v) for t, v in chg[chg.index >= "2022-01-01"].items()]} return {"series": s, "stats": stats} # ----------------------------------------------------------------------------- Droits sur les mutations immobilières 2026 BAREMES_2026 = { # (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 "Province (barème légal)": [(62900, 0.005), (315000, 0.01), (None, 0.015)], "Montréal": [(62900, 0.005), (315000, 0.01), (552300, 0.015), (1104700, 0.02), (2136500, 0.025), (3113000, 0.035), (None, 0.04)], "Gatineau (dès le 25 février 2026)": [(62900, 0.005), (315000, 0.01), (500000, 0.015), (None, 0.03)], "Québec (ville)": [(62900, 0.005), (315000, 0.01), (500000, 0.015), (750000, 0.025), (None, 0.03)], } def droits_mutation(base: float, bareme: list[tuple]) -> float: """Droit progressif par tranches : Σ_k τ_k × (min(B, b_k) − b_{k−1})⁺.""" total, prev = 0.0, 0.0 for borne, taux in bareme: haut = base if borne is None else min(base, borne) if haut > prev: total += taux * (haut - prev) prev = borne if borne is not None else base if borne is not None and base <= borne: break return total def tableau_mutations(prix: list[float]) -> pd.DataFrame: rows = [] for nom, bar in BAREMES_2026.items(): for p in prix: d = droits_mutation(p, bar) rows.append({"bareme": nom, "prix": p, "droit": d, "taux_effectif": d / p}) return pd.DataFrame(rows) # ----------------------------------------------------------------------------- Taux et prix soutenable def paiement_mensuel(capital: float, taux: float, amort_ans: int = 25) -> float: return fin.Hypotheque(capital, taux, amort_ans * 12).paiement def prix_soutenable(paiement: float, taux: float, mise_de_fonds: float = 0.20, amort_ans: int = 25) -> float: """Prix tel que le paiement mensuel d'un prêt de (1 − m) × prix, au taux donné (composition semestrielle), égale `paiement`.""" i = fin.taux_mensuel_canadien(taux) capital = paiement * fin.facteur_va_annuite(i, amort_ans * 12) return capital / (1 - mise_de_fonds) 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: """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 %), puis prix soutenable à paiement constant pour chaque taux.""" cap = prix_ref * 0.8 pmt_ref = paiement_mensuel(cap, taux_ref) rows = [] for t in taux: rows.append({"taux": t, "paiement_pour_prix_ref": paiement_mensuel(cap, t), "prix_soutenable": prix_soutenable(pmt_ref, t), "variation_prix": prix_soutenable(pmt_ref, t) / prix_ref - 1}) return pd.DataFrame(rows), pmt_ref # ----------------------------------------------------------------------------- Ratio prix / loyer annuel def ratio_prix_loyer(prix: float, loyer_mensuel: float) -> float: return prix / (12 * loyer_mensuel) # ----------------------------------------------------------------------------- Impression def fmt(x, d=0): return f"{x:,.{d}f}".replace(",", " ") def main(verbose: bool = True) -> dict: res = {} sp = structure_parc(); res["parc"] = sp rt = recensement_types(); res["types"] = rt tot = rt[rt["type_id"] == 1].set_index("geo") D = indice_dissimilarite(sp["qc_part"].values, sp["ca_part"].values); res["dissimilarite"] = D mr = menages_rmr(); res["menages_rmr"] = mr pop = population_rmr(); res["population"] = pop ip = ipln(); st = ipln_stats(ip); res["ipln"] = ip; res["ipln_stats"] = st sc = schl_rmr(); res["schl"] = sc ap = apciq_t3_2026(); res["apciq"] = ap b = bdc(); res["bdc"] = b mut = tableau_mutations([500000, 645500, 860500, 1500000]); res["mutations"] = mut sens, pmt_ref = sensibilite_taux(); res["sensibilite"] = sens; res["paiement_ref"] = pmt_ref # MRB et prix/loyer implicites mrb = {r: mrb_implicite(ap.loc[r, "plex"], sc.loc[sc.index[sc.index.str.startswith(r.split(" ")[0])][0], "loyer_2_chambres_2025"]) for r in ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]} res["mrb"] = mrb if not verbose: return res print("=== Recensement 2021 (98-10-0240-01) — ménages privés") for g in ("Québec", "Canada"): 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'])}") print(sp[["court", "qc_menages", "qc_part", "qc_taux_propriete", "ca_part", "ca_taux_propriete"]].to_string(float_format=lambda v: f"{v:.4f}")) print(f" Indice de dissimilarité Québec/Canada (8 types) : {D:.4f}") 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} %") 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} %") print("\n=== Ménages par RMR (98-10-0231-01)"); print(mr.to_string(float_format=lambda v: f"{v:.4f}")) print("\n=== Population des RMR au 1er juillet (17-10-0148-01)"); print(pop.to_string(float_format=lambda v: f"{v:.4f}")) popqc = CAL["demographie_structure"]["population_quebec_2025"]["valeur"] print(f" Part des RMR dans la population du Québec 2025p ({fmt(popqc)}, ISQ) : {pop.loc['Ensemble des RMR du Québec', 2025]/popqc*100:.1f} % ; " f"six RMR du chapitre : {pop.loc[[k for k in RMR_148.values() if k not in ('Drummondville','Ensemble des RMR du Québec')], 2025].sum()/popqc*100:.1f} %") print("\n=== IPLN (18-10-0205-01, déc. 2016 = 100)"); print(st.to_string(float_format=lambda v: f"{v:.4f}")) 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)") 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", "varest_2_chambres_2025", "varest_tous_2025", "var_brute_2_chambres", "var_brute_tous"] print(sc[cols].to_string(float_format=lambda v: f"{v:.4f}")) print("\n=== APCIQ T3 2026 (chiffres cités) et indicateurs dérivés"); print(ap.to_string(float_format=lambda v: f"{v:.3f}")) 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()}) 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}") print("\n=== Banque du Canada (Valet)") for k, v in b["stats"].items(): print(f" {k} : {v}") print("\n=== Droits sur les mutations immobilières 2026"); print(mut.to_string(float_format=lambda v: f"{v:.4f}")) 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}")) return res # ----------------------------------------------------------------------------- Exercices (solutions/ch04.tex) def exercices() -> dict: """Chiffres des solutions des exercices 4.1 à 4.6 (l'exercice 4.7 est rédactionnel).""" sp = structure_parc(); rt = recensement_types(); tot = rt[rt["type_id"] == 1].set_index("geo") ex = {} # 4.1 : types attenants et appartements = tous sauf maison individuelle (2) et mobile (10) sel = [k for k in sp.index if k not in (2, 10)] ex["ex1"] = {"qc_part": sp.loc[sel, "qc_menages"].sum() / tot.loc["Québec", "total"], "qc_taux_prop": sp.loc[sel, "qc_proprietaires"].sum() / sp.loc[sel, "qc_menages"].sum(), "ca_part": sp.loc[sel, "ca_menages"].sum() / tot.loc["Canada", "total"], "ca_taux_prop": (sp.loc[sel, "ca_taux_propriete"] * sp.loc[sel, "ca_menages"]).sum() / sp.loc[sel, "ca_menages"].sum(), "qc_menages": sp.loc[sel, "qc_menages"].sum(), "qc_prop": sp.loc[sel, "qc_proprietaires"].sum()} # 4.2 ex["ex2"] = {"sherbrooke": 825 / (569 / 3), "trois_rivieres": 447 / (372 / 3), "sherbrooke_plus30": 825 * 1.3 / (569 / 3)} # 4.3 n_rest, n_new, l0, g, l_new = 19500, 1500, 1000.0, 0.06, 1700.0 l_2025 = (n_rest * l0 * (1 + g) + n_new * l_new) / (n_rest + n_new) ex["ex3"] = {"loyer_2025": l_2025, "var_brute": l_2025 / l0 - 1, "var_meme": g, "univers_2025": n_rest + n_new} # 4.4 bar = BAREMES_2026["Gatineau (dès le 25 février 2026)"] for prix in (620000, 560000): base = max(prix, 590000 * 1.02) ex[f"ex4_{prix}"] = {"base": base, "droit": droits_mutation(base, bar), "taux_eff_prix": droits_mutation(base, bar) / prix, "taux_eff_base": droits_mutation(base, bar) / base} # 4.5 g5 = (903607 / 840396) ** 0.2 - 1 ex["ex5"] = {"tcac": g5, "pop_2035": 903607 * (1 + g5) ** 10, "tcac_isq": 1.16 ** (1 / 26) - 1, "pop_2051_isq": 903607 * 1.16, "pop_2051_tendance": 903607 * (1 + g5) ** 26} # 4.6 def pmax(taux, pmt=2400.0, mdf=90000.0): return pmt * fin.facteur_va_annuite(fin.taux_mensuel_canadien(taux), 300) + mdf def elast(taux, h=0.001): """Élasticité ∂ln P*/∂ln i par différences finies centrées (h = 10 pb) ; le paiement de référence n'influe pas sur le résultat.""" return (math.log(prix_soutenable(2130.18, taux + h)) - math.log(prix_soutenable(2130.18, taux - h))) / (math.log(taux + h) - math.log(taux - h)) def semi(taux, h=0.001): """Semi-élasticité ∂ln P*/∂i (par unité de taux ; ÷100 pour « par point de pourcentage »).""" return (math.log(prix_soutenable(2130.18, taux + h)) - math.log(prix_soutenable(2130.18, taux - h))) / (2 * h) ex["ex6"] = {"prix_412": pmax(0.0412), "prix_619": pmax(0.0619), "capital_412": pmax(0.0412) - 90000, "capital_619": pmax(0.0619) - 90000, "elast_3": elast(0.03), "elast_6": elast(0.06), "semi_3_par_point": semi(0.03) / 100, "semi_6_par_point": semi(0.06) / 100, "pente_3_35_par_point": math.log(prix_soutenable(2130.18, 0.035) / prix_soutenable(2130.18, 0.03)) / 0.5, "pente_619_7_par_point": math.log(prix_soutenable(2130.18, 0.07) / prix_soutenable(2130.18, 0.0619)) / 0.81} return ex def imprimer_exercices(): ex = exercices() print("\n=== Exercices") for k, v in ex.items(): print(f" {k} :", {a: (round(b, 6) if isinstance(b, float) else b) for a, b in v.items()}) if __name__ == "__main__": main(); imprimer_exercices()