SPB Git
29.1 KB · 417 lines python
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    mc = mises_en_chantier(); res["mises_en_chantier"] = mc; res["mc_stats"] = stats_mises_en_chantier(mc)305    # MRB et prix/loyer implicites306    mrb = {r: mrb_implicite(ap.loc[r, "plex"], sc.loc[sc.index[sc.index.str.startswith(r.split(" ")[0])][0], "loyer_2_chambres_2025"])307           for r in ["Montréal", "Québec", "Gatineau", "Sherbrooke", "Trois-Rivières", "Saguenay"]}308    res["mrb"] = mrb309    if not verbose: return res310    print("=== Recensement 2021 (98-10-0240-01) — ménages privés")311    for g in ("Québec", "Canada"):312        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'])}")313    print(sp[["court", "qc_menages", "qc_part", "qc_taux_propriete", "ca_part", "ca_taux_propriete"]].to_string(float_format=lambda v: f"{v:.4f}"))314    print(f"  Indice de dissimilarité Québec/Canada (8 types) : {D:.4f}")315    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} %")316    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} %")317    print("\n=== Ménages par RMR (98-10-0231-01)"); print(mr.to_string(float_format=lambda v: f"{v:.4f}"))318    print("\n=== Population des RMR au 1er juillet (17-10-0148-01)"); print(pop.to_string(float_format=lambda v: f"{v:.4f}"))319    popqc = CAL["demographie_structure"]["population_quebec_2025"]["valeur"]320    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} % ; "321          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} %")322    print("\n=== IPLN (18-10-0205-01, déc. 2016 = 100)"); print(st.to_string(float_format=lambda v: f"{v:.4f}"))323    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)")324    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",325            "varest_2_chambres_2025", "varest_tous_2025", "var_brute_2_chambres", "var_brute_tous"]326    print(sc[cols].to_string(float_format=lambda v: f"{v:.4f}"))327    print("\n=== APCIQ T3 2026 (chiffres cités) et indicateurs dérivés"); print(ap.to_string(float_format=lambda v: f"{v:.3f}"))328    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()})329    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}")330    print("\n=== Mises en chantier, Québec, toutes les régions (34-10-0135-01), années complètes")331    print(mc.loc[[2000, 2005, 2008, 2010, 2015, 2019, 2020, 2021, 2022, 2023, 2024, 2025], ["total", "individuelles", "jumeles", "en_bande", "appartements", "part_appartements"]].to_string(float_format=lambda v: f"{v:.4f}"))332    for k, v in res["mc_stats"].items(): print(f"  {k} : {v}")333    print("\n=== Banque du Canada (Valet)")334    for k, v in b["stats"].items(): print(f"  {k} : {v}")335    print("\n=== Droits sur les mutations immobilières 2026"); print(mut.to_string(float_format=lambda v: f"{v:.4f}"))336    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}"))337    return res338339340341# ----------------------------------------------------------------------------- Mises en chantier (34-10-0135-01, SCHL via Statistique Canada)342def mises_en_chantier(geo: str = "Québec") -> pd.DataFrame:343    """Logements mis en chantier, toutes les régions, trimestriel non désaisonnalisé, sommés par année civile : total, maisons individuelles,344    appartements et autres logements, jumelés, en bande ; part des appartements. Années complètes seulement (quatre trimestres)."""345    rows = []346    with open(RAW / "statcan_34100135/34100135.csv", encoding="utf-8-sig") as f:347        r = csv.reader(f, delimiter=";"); next(r)348        for row in r:349            if row[1] == geo and row[3] == "Logements mis en chantier" and row[5] == "Non désaisonnalisés" and row[12] not in ("", ".."):350                rows.append({"periode": row[0], "annee": int(row[0][:4]), "type": row[4], "unites": float(row[12])})351    df = pd.DataFrame(rows)352    n = df[df["type"] == "Total d'unités"].groupby("annee")["periode"].count()353    complets = n[n == 4].index354    piv = df[df["annee"].isin(complets)].pivot_table(index="annee", columns="type", values="unites", aggfunc="sum")355    piv = piv.rename(columns={"Total d'unités": "total", "Maisons individuelles": "individuelles", "Appartements et autres logements": "appartements",356                              "Logements jumelés": "jumeles", "Logements en bande": "en_bande"})357    piv["part_appartements"] = piv["appartements"] / piv["total"]358    piv["part_individuelles"] = piv["individuelles"] / piv["total"]359    return piv360361def stats_mises_en_chantier(piv: pd.DataFrame) -> dict:362    return {"derniere_annee": int(piv.index.max()), "total_2025": piv.loc[2025, "total"], "part_app_2025": piv.loc[2025, "part_appartements"],363            "part_app_2000": piv.loc[2000, "part_appartements"], "max_total": (int(piv["total"].idxmax()), piv["total"].max()),364            "min_total_2000": (int(piv.loc[2000:, "total"].idxmin()), piv.loc[2000:, "total"].min()),365            "moyenne_2000_2019": piv.loc[2000:2019, "total"].mean(), "moyenne_2020_2025": piv.loc[2020:2025, "total"].mean(),366            "individuelles_2000": piv.loc[2000, "individuelles"], "individuelles_2025": piv.loc[2025, "individuelles"]}367368# ----------------------------------------------------------------------------- Exercices (solutions/ch04.tex)369def exercices() -> dict:370    """Chiffres des solutions des exercices 4.1 à 4.6 (l'exercice 4.7 est rédactionnel)."""371    sp = structure_parc(); rt = recensement_types(); tot = rt[rt["type_id"] == 1].set_index("geo")372    ex = {}373    # 4.1 : types attenants et appartements = tous sauf maison individuelle (2) et mobile (10)374    sel = [k for k in sp.index if k not in (2, 10)]375    ex["ex1"] = {"qc_part": sp.loc[sel, "qc_menages"].sum() / tot.loc["Québec", "total"],376                 "qc_taux_prop": sp.loc[sel, "qc_proprietaires"].sum() / sp.loc[sel, "qc_menages"].sum(),377                 "ca_part": sp.loc[sel, "ca_menages"].sum() / tot.loc["Canada", "total"],378                 "ca_taux_prop": (sp.loc[sel, "ca_taux_propriete"] * sp.loc[sel, "ca_menages"]).sum() / sp.loc[sel, "ca_menages"].sum(),379                 "qc_menages": sp.loc[sel, "qc_menages"].sum(), "qc_prop": sp.loc[sel, "qc_proprietaires"].sum()}380    # 4.2381    ex["ex2"] = {"sherbrooke": 825 / (569 / 3), "trois_rivieres": 447 / (372 / 3), "sherbrooke_plus30": 825 * 1.3 / (569 / 3)}382    # 4.3383    n_rest, n_new, l0, g, l_new = 19500, 1500, 1000.0, 0.06, 1700.0384    l_2025 = (n_rest * l0 * (1 + g) + n_new * l_new) / (n_rest + n_new)385    ex["ex3"] = {"loyer_2025": l_2025, "var_brute": l_2025 / l0 - 1, "var_meme": g, "univers_2025": n_rest + n_new}386    # 4.4387    bar = BAREMES_2026["Gatineau (dès le 25 février 2026)"]388    for prix in (620000, 560000):389        base = max(prix, 590000 * 1.02)390        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}391    # 4.5392    g5 = (903607 / 840396) ** 0.2 - 1393    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}394    # 4.6395    def pmax(taux, pmt=2400.0, mdf=90000.0):396        return pmt * fin.facteur_va_annuite(fin.taux_mensuel_canadien(taux), 300) + mdf397    def elast(taux, h=0.001):398        """É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."""399        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))400    def semi(taux, h=0.001):401        """Semi-élasticité ∂ln P*/∂i (par unité de taux ; ÷100 pour « par point de pourcentage »)."""402        return (math.log(prix_soutenable(2130.18, taux + h)) - math.log(prix_soutenable(2130.18, taux - h))) / (2 * h)403    ex["ex6"] = {"prix_412": pmax(0.0412), "prix_619": pmax(0.0619), "capital_412": pmax(0.0412) - 90000, "capital_619": pmax(0.0619) - 90000,404                 "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,405                 "pente_3_35_par_point": math.log(prix_soutenable(2130.18, 0.035) / prix_soutenable(2130.18, 0.03)) / 0.5,406                 "pente_619_7_par_point": math.log(prix_soutenable(2130.18, 0.07) / prix_soutenable(2130.18, 0.0619)) / 0.81}407    return ex408409def imprimer_exercices():410    ex = exercices()411    print("\n=== Exercices")412    for k, v in ex.items():413        print(f"  {k} :", {a: (round(b, 6) if isinstance(b, float) else b) for a, b in v.items()})414415if __name__ == "__main__":416    main(); imprimer_exercices()417
Files · 2a26d365cc23544c8343a62de5e56cfc4c06cb46
Menu

Theme