SPB Git
33.5 KB · 369 lines python
1"""Génération des quatre dossiers de cas SYNTHÉTIQUES du traité.23Tout est déterministe (graine 20261031). Les paramètres de calibration proviennent de data/synthetic/calibration.json4(valeurs publiques sourcées, voir sources/registry.csv) ; en l'absence d'une valeur, un défaut PROVISOIRE est utilisé et5consigné comme NON_VERIFIE dans data/synthetic/README.md. Les relations (élasticités, primes, lois de tirage) sont des6HYPOTHÈSES PÉDAGOGIQUES déclarées dans le README — elles ne décrivent aucun bien ni marché réel."""7from __future__ import annotations8import json, math, pathlib, datetime9import numpy as np10import pandas as pd1112ROOT = pathlib.Path(__file__).resolve().parents[3]13OUT = ROOT / "data/synthetic"14SEED = 2026103115DATE_EVAL = datetime.date(2026, 9, 1)  # date d'évaluation commune des quatre cas1617class Calibration:18    """Accès aux paramètres de data/synthetic/calibration.json avec traçabilité.19    get(chemin, defaut, unite, note, sub=None, f=None) : chemin pointé vers une feuille {valeur, source_id, …} ;20    sub = clé dans une valeur-dictionnaire ; f = transformation (ex. % → proportion), consignée dans le journal."""21    def __init__(self, path=OUT / "calibration.json"):22        self.raw = json.loads(path.read_text()) if path.exists() else {}23        self.journal: list[dict] = []24    def _leaf(self, chemin: str):25        node = self.raw26        for part in chemin.split("."):27            if not isinstance(node, dict) or part not in node: return None28            node = node[part]29        return node if isinstance(node, dict) and "valeur" in node else None30    def get(self, chemin: str, defaut, unite: str = "", note: str = "", sub: str | None = None, f=None, transformation: str = ""):31        leaf = self._leaf(chemin)32        val = None33        if leaf is not None and leaf.get("valeur") is not None:34            val = leaf["valeur"]35            if sub is not None:36                val = val.get(sub) if isinstance(val, dict) else None37        if val is not None:38            out = f(val) if f else val39            self.journal.append({"parametre": chemin + (f"#{sub}" if sub else ""), "valeur": out, "valeur_source": val, "unite": unite or leaf.get("unite", ""),40                                 "source_id": leaf.get("source_id", "?"), "periode": leaf.get("periode", ""), "statut": "calibre", "note": transformation or note})41            return out42        self.journal.append({"parametre": chemin + (f"#{sub}" if sub else ""), "valeur": defaut, "valeur_source": None, "unite": unite, "source_id": "NON_VERIFIE",43                             "periode": "", "statut": "provisoire", "note": note})44        return defaut4546PCT = lambda x: x / 100.047PI2_VERS_M2 = lambda x: x * 10.76394849def _dates(rng, n, debut=datetime.date(2023, 1, 1), fin=datetime.date(2026, 8, 31)):50    span = (fin - debut).days51    d = np.sort(rng.integers(0, span + 1, n))52    return [debut + datetime.timedelta(days=int(x)) for x in d]5354def _mois_depuis(dates, ref=datetime.date(2023, 1, 1)):55    return np.array([(d.year - ref.year) * 12 + (d.month - ref.month) + (d.day - 1) / 30.0 for d in dates])5657# ============================================================================ CAS 158def cas1_gatineau(cal: Calibration, rng: np.random.Generator) -> dict:59    n = 60060    med = cal.get("prix_apciq.rmr_gatineau.unifamiliale_prix_median_par_secteur", 475_000, "$", "prix médian unifamiliale secteur Hull (APCIQ T3 2026)", sub="Hull")61    var2 = cal.get("cas_gatineau.role_2027_2029_unifamiliale_mediane", 9.06, "%", "variation de la médiane unifamiliale au rôle sur 2 ans", sub="variation_pct")62    g = (1 + var2 / 100) ** 0.5 - 1   # croissance annuelle composée (2023-07 → 2025-07), calcul de l'auteur63    secteurs = ["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"]64    med_sect = {k: cal.get("prix_apciq.rmr_gatineau.unifamiliale_prix_median_par_secteur", d, "$", f"médiane {k}", sub=k)65                for k, d in [("Aylmer", 530_000), ("Gatineau", 464_900), ("Buckingham/Masson-Angers", 395_000)]}66    prime_secteur = {"Hull": 0.0, "Gatineau": math.log(med_sect["Gatineau"] / med), "Aylmer": math.log(med_sect["Aylmer"] / med),67                     "Buckingham": math.log(med_sect["Buckingham/Masson-Angers"] / med), "Masson-Angers": math.log(med_sect["Buckingham/Masson-Angers"] / med) - 0.02}  # écarts de médianes APCIQ (log) ; Masson-Angers −2 pts : hypothèse68    p_sect = [0.30, 0.32, 0.20, 0.10, 0.08]69    sect = rng.choice(secteurs, n, p=p_sect)70    dates = _dates(rng, n)71    mois = _mois_depuis(dates)72    S = np.exp(rng.normal(np.log(140), 0.25, n)).round(0)                   # superficie habitable m²73    T = np.where(np.isin(sect, ["Buckingham", "Masson-Angers"]), np.exp(rng.normal(np.log(1200), 0.45, n)), np.exp(rng.normal(np.log(560), 0.30, n))).round(0)74    annee = np.clip(rng.normal(1985, 20, n).round(0), 1920, 2025).astype(int)75    age = 2026 - annee76    qualite = np.clip(rng.normal(3, 0.8, n).round(0), 1, 5).astype(int)77    garage = rng.choice([0, 1, 2], n, p=[0.45, 0.45, 0.10])78    sous_sol_fini = rng.binomial(1, 0.6, n)79    piscine = rng.binomial(1, 0.15, n)80    chambres = np.clip((S / 45 + rng.normal(0, 0.6, n)).round(0), 1, 6).astype(int)81    sdb = np.clip((S / 80 + rng.normal(0, 0.5, n)).round(0), 1, 4).astype(int)82    type_bat = np.where(rng.random(n) < 0.45, "plain-pied", "à étages")83    # centre de chaque secteur (lat, lon approximatifs du centre urbain, jitter ± ~2-4 km : coordonnées synthétiques)84    centres = {"Hull": (45.428, -75.714), "Gatineau": (45.477, -75.650), "Aylmer": (45.395, -75.843), "Buckingham": (45.588, -75.418), "Masson-Angers": (45.545, -75.420)}85    lat = np.array([centres[s][0] for s in sect]) + rng.normal(0, 0.012, n)86    lon = np.array([centres[s][1] for s in sect]) + rng.normal(0, 0.020, n)87    dist_centre_km = np.sqrt(((lat - 45.428) * 111) ** 2 + ((lon + 75.714) * 78) ** 2)88    # indice de marché : croissance g/an composée, référence = 1er juillet 2024 (date de référence du rôle fictif)89    ref_mois = _mois_depuis([datetime.date(2024, 7, 1)])[0]90    ln_index = np.log1p(g) * (mois - ref_mois) / 1291    lnP = (np.log(med) + 0.55 * np.log(S / 140) + 0.10 * np.log(T / 560) - 0.0055 * age + 0.000030 * age ** 292           + 0.06 * (qualite - 3) + 0.035 * garage + 0.03 * sous_sol_fini + 0.02 * piscine93           + np.array([prime_secteur[s] for s in sect]) - 0.006 * np.clip(dist_centre_km - 3, 0, None)94           + 0.02 * (type_bat == "à étages") + ln_index + rng.normal(0, 0.085, n))95    lnP += np.log(med) - np.median(lnP - ln_index)   # normalisation : médiane à la date de référence = prix médian calibré96    prix = (np.exp(lnP) / 500).round(0) * 50097    # valeur au rôle 2025-2027 fictif : valeur à la date de référence (2024-07-01) × ratio ~ N(0.93, 0.07)98    lnP_ref = lnP - ln_index99    role = (np.exp(lnP_ref) * np.clip(rng.normal(0.93, 0.07, n), 0.7, 1.2) / 100).round(0) * 100100    ventes = pd.DataFrame({101        "id_vente": [f"G{idx+1:04d}" for idx in range(n)], "date_vente": [d.isoformat() for d in dates], "secteur": sect,102        "type": type_bat, "superficie_habitable_m2": S, "superficie_terrain_m2": T, "annee_construction": annee, "age": age,103        "qualite_1a5": qualite, "garage_places": garage, "sous_sol_fini": sous_sol_fini, "piscine": piscine,104        "chambres": chambres, "salles_de_bain": sdb, "lat": lat.round(5), "lon": lon.round(5), "dist_centre_km": dist_centre_km.round(2),105        "valeur_role_2025": role, "prix_vente": prix})106    sujet = {"adresse_fictive": "123, rue des Érables (fictive), secteur Hull, Gatineau", "secteur": "Hull", "type": "à étages",107             "superficie_habitable_m2": 165, "superficie_terrain_m2": 620, "annee_construction": 1998, "age": 28, "qualite_1a5": 3,108             "garage_places": 1, "sous_sol_fini": 1, "piscine": 0, "chambres": 4, "salles_de_bain": 2, "lat": 45.4215, "lon": -75.7395,109             "dist_centre_km": 2.1, "valeur_role_2025": 468_000, "date_evaluation": DATE_EVAL.isoformat(),110             "note": "DONNÉES SYNTHÉTIQUES — propriété fictive construite pour le traité"}111    # devis par composantes (38 postes) : quantités cohérentes avec 165 m² habitables sur 2 étages, empreinte ~ 95 m²112    postes = [113        ("Excavation et remblai", "m³", 420, 28), ("Fondations béton (semelles, murs)", "m³", 38, 520), ("Dalle de sous-sol", "m²", 95, 95),114        ("Drain français et imperméabilisation", "m lin.", 42, 120), ("Charpente murs et planchers", "m²", 330, 165), ("Fermes de toit", "m²", 110, 95),115        ("Couverture bardeaux d'asphalte", "m²", 125, 85), ("Revêtement extérieur (brique 40 % / fibrociment 60 %)", "m²", 240, 165),116        ("Isolation et pare-vapeur", "m²", 420, 32), ("Fenêtres (PVC double vitrage)", "unité", 16, 1450), ("Portes extérieures", "unité", 3, 1900),117        ("Porte de garage", "unité", 1, 2600), ("Gypse et tirage de joints", "m²", 620, 38), ("Peinture intérieure", "m²", 620, 14),118        ("Planchers bois franc", "m²", 95, 120), ("Planchers céramique", "m²", 35, 135), ("Planchers flottants (sous-sol)", "m²", 70, 55),119        ("Escaliers", "unité", 2, 4800), ("Portes intérieures et moulures", "unité", 14, 650), ("Armoires de cuisine et comptoirs", "forfait", 1, 24000),120        ("Vanités et accessoires (2 sdb)", "forfait", 1, 7500), ("Plomberie brute et finition", "appareil", 14, 1650), ("Chauffe-eau", "unité", 1, 1600),121        ("Électricité (entrée 200 A, filage, appareils)", "forfait", 1, 19500), ("Ventilation (VRC) et conduits", "forfait", 1, 7800),122        ("Thermopompe centrale et plinthes", "forfait", 1, 14500), ("Éclairage et finition électrique", "forfait", 1, 4200),123        ("Gouttières et solins", "m lin.", 48, 45), ("Balcon et perron", "m²", 14, 650), ("Entrée asphaltée", "m²", 55, 65),124        ("Aménagement paysager de base", "forfait", 1, 6500), ("Raccordements aqueduc/égout", "forfait", 1, 9500), ("Nettoyage et conteneurs", "forfait", 1, 3800),125        ("Permis et études (arpentage, sol)", "forfait", 1, 6200), ("Supervision de chantier (salaires)", "semaine", 26, 1900),126        ("Assurances chantier et cautionnement", "forfait", 1, 5200), ("Plans et honoraires professionnels", "forfait", 1, 14000), ("Financement intérimaire", "forfait", 1, 11800),127    ]128    alt_min = cal.get("couts_construction.altus_cout_unifamiliale_montreal_pi2", 155, "$/pi²", "Altus 2026 Montréal unifamiliale, borne basse", sub="min")129    alt_max = cal.get("couts_construction.altus_cout_unifamiliale_montreal_pi2", 215, "$/pi²", "Altus 2026 Montréal unifamiliale, borne haute", sub="max")130    cible = 0.5 * (alt_min + alt_max) * 10.7639 * 1.10   # milieu de fourchette Altus converti en $/m², +10 % pour sous-sol fini et aménagements (hypothèse)131    couts = pd.DataFrame(postes, columns=["poste", "unite", "quantite", "prix_unitaire"])132    couts["indirect"] = couts["poste"].isin(["Permis et études (arpentage, sol)", "Supervision de chantier (salaires)", "Assurances chantier et cautionnement", "Plans et honoraires professionnels", "Financement intérimaire"])133    brut = (couts["quantite"] * couts["prix_unitaire"]).sum()134    echelle = cible * 165 / brut135    couts["prix_unitaire"] = (couts["prix_unitaire"] * echelle).round(2)136    couts["montant"] = (couts["quantite"] * couts["prix_unitaire"]).round(2)137    return {"ventes": ventes, "sujet": sujet, "couts": couts}138139# ============================================================================ CAS 2140def cas2_quebec(cal: Calibration, rng: np.random.Generator) -> dict:141    n = 250142    med_plex = cal.get("cas_quebec.plex_prix_median_limoilou", 647_500, "$", "prix médian plex secteur Basse-Ville–Limoilou (APCIQ T3 2026)")143    loyer_2ch = cal.get("loyers_schl.rmr_quebec.loyer_moyen", 1_277, "$/mois", "loyer moyen 2 ch. RMR Québec (SCHL oct. 2025)", sub="2_chambres")144    inocc = cal.get("loyers_schl.rmr_quebec.taux_inoccupation", 0.024, "proportion", "taux d'inoccupation RMR Québec (SCHL oct. 2025)", sub="tous", f=PCT, transformation="% → proportion")145    var3 = cal.get("cas_quebec.role_2025_2027_variation_moyenne_totale", 23.5, "%", "variation moyenne du rôle de Québec sur 3 ans")146    g = (1 + var3 / 100) ** (1 / 3) - 1   # croissance annuelle composée, calcul de l'auteur147    quartiers = ["Limoilou", "Saint-Sauveur", "Saint-Roch", "Montcalm", "Maizerets"]148    prime = {"Limoilou": 0.0, "Saint-Sauveur": -0.06, "Saint-Roch": 0.02, "Montcalm": 0.15, "Maizerets": -0.09}149    q = rng.choice(quartiers, n, p=[0.35, 0.2, 0.15, 0.1, 0.2])150    nb_log = rng.choice([2, 3], n, p=[0.45, 0.55])151    dates = _dates(rng, n); mois = _mois_depuis(dates)152    annee = np.clip(rng.normal(1945, 18, n).round(0), 1900, 2015).astype(int); age = 2026 - annee153    etat = np.clip(rng.normal(3, 0.8, n).round(0), 1, 5).astype(int)154    sup_hab = (nb_log * np.exp(rng.normal(np.log(85), 0.15, n))).round(0)155    stationnement = rng.binomial(1, 0.5, n)156    # loyers : par logement ~ loyer_2ch × facteur taille × écart contractuel (sous le marché selon l'ancienneté des baux)157    loyer_marche_unit = loyer_2ch * np.exp(rng.normal(0, 0.12, n)) * (1 + 0.05 * (etat - 3)) * np.exp(np.array([prime[x] for x in q]) * 0.5)158    anciennete_moy = np.clip(rng.exponential(5, n), 0, 20)159    ecart = -0.015 * anciennete_moy                          # ~1,5 % par année d'ancienneté sous le marché (hypothèse)160    loyer_contrat_unit = loyer_marche_unit * (1 + ecart)161    rbp = loyer_contrat_unit * nb_log * 12162    ratio_dep = np.clip(rng.normal(0.36, 0.05, n), 0.25, 0.5)163    rne = rbp * (1 - inocc) * (1 - ratio_dep)164    ref_mois = _mois_depuis([datetime.date(2024, 7, 1)])[0]165    ln_index = np.log1p(g) * (mois - ref_mois) / 12166    lnP = (np.log(med_plex) + 0.65 * np.log(sup_hab / (2.55 * 85)) + 0.12 * (nb_log - 2.55) - 0.004 * (age - 80) + 0.05 * (etat - 3)167           + np.array([prime[x] for x in q]) + 0.03 * stationnement + 0.25 * np.log(np.clip(loyer_marche_unit / loyer_2ch, 0.5, 2))168           + ln_index + rng.normal(0, 0.09, n))169    lnP += np.log(med_plex) - np.median(lnP - ln_index)   # normalisation à la médiane calibrée (date de référence)170    prix = (np.exp(lnP) / 1000).round(0) * 1000171    ventes = pd.DataFrame({"id_vente": [f"Q{i+1:04d}" for i in range(n)], "date_vente": [d.isoformat() for d in dates], "quartier": q,172        "nb_logements": nb_log, "superficie_habitable_m2": sup_hab, "annee_construction": annee, "age": age, "etat_1a5": etat,173        "stationnement": stationnement, "loyer_mensuel_moyen_contrat": loyer_contrat_unit.round(0), "loyer_mensuel_moyen_marche": loyer_marche_unit.round(0),174        "revenu_brut_potentiel": rbp.round(0), "ratio_depenses": ratio_dep.round(3), "rne": rne.round(0), "prix_vente": prix})175    ventes["mrb"] = (ventes["prix_vente"] / ventes["revenu_brut_potentiel"]).round(2)176    ventes["taux_global"] = (ventes["rne"] / ventes["prix_vente"]).round(4)177    ventes["prix_par_logement"] = (ventes["prix_vente"] / ventes["nb_logements"]).round(0)178    loyers = pd.DataFrame([179        {"logement": "RC", "pieces": "5½", "chambres": 2, "superficie_m2": 92, "loyer_mensuel": round(loyer_2ch * 0.82), "debut_bail": "2019-07-01", "chauffage_inclus": 0, "locataire_depuis": 2019},180        {"logement": "2e", "pieces": "5½", "chambres": 2, "superficie_m2": 92, "loyer_mensuel": round(loyer_2ch * 0.93), "debut_bail": "2022-07-01", "chauffage_inclus": 0, "locataire_depuis": 2022},181        {"logement": "3e", "pieces": "4½", "chambres": 1, "superficie_m2": 78, "loyer_mensuel": round(loyer_2ch * 0.98), "debut_bail": "2025-07-01", "chauffage_inclus": 0, "locataire_depuis": 2025},182    ])183    loyers["loyer_marche_estime"] = [round(loyer_2ch * 1.12), round(loyer_2ch * 1.12), round(loyer_2ch * 0.98)]184    sujet = {"adresse_fictive": "456, 3e Avenue (fictive), Limoilou, Québec", "quartier": "Limoilou", "nb_logements": 3, "annee_construction": 1948, "age": 78,185             "etat_1a5": 3, "superficie_habitable_m2": 262, "superficie_terrain_m2": 310, "stationnement": 1, "valeur_role_2025": 495_000,186             "date_evaluation": DATE_EVAL.isoformat(), "note": "DONNÉES SYNTHÉTIQUES — immeuble fictif"}187    depenses = pd.DataFrame([188        {"poste": "Taxes municipales", "montant_annuel": 5_900, "type": "fixe"}, {"poste": "Taxe scolaire", "montant_annuel": 470, "type": "fixe"},189        {"poste": "Assurances", "montant_annuel": 3_400, "type": "fixe"}, {"poste": "Électricité (aires communes)", "montant_annuel": 650, "type": "variable"},190        {"poste": "Déneigement et entretien extérieur", "montant_annuel": 1_500, "type": "variable"}, {"poste": "Entretien et réparations", "montant_annuel": 3_200, "type": "variable"},191        {"poste": "Gestion (propriétaire-occupant : imputée 4 %)", "montant_annuel": 0, "type": "imputée"}, {"poste": "Réserve de remplacement", "montant_annuel": 1_800, "type": "réserve"}])192    return {"ventes": ventes, "sujet": sujet, "loyers": loyers, "depenses": depenses}193194# ============================================================================ CAS 3195def cas3_montreal(cal: Calibration, rng: np.random.Generator) -> dict:196    loyer_2ch = cal.get("cas_montreal.loyers_rosemont", 1_348, "$/mois", "loyer moyen 2 ch. zone Rosemont (SCHL oct. 2025)", sub="2_chambres")197    loyer_1ch = cal.get("cas_montreal.loyers_rosemont", 1_055, "$/mois", "loyer moyen 1 ch. zone Rosemont (SCHL oct. 2025)", sub="1_chambre")198    loyer_3ch = cal.get("cas_montreal.loyers_rosemont", 1_750, "$/mois", "loyer moyen 3 ch.+ zone Rosemont (SCHL oct. 2025)", sub="3_chambres_plus")199    inocc = cal.get("cas_montreal.taux_inoccupation_rmr", 0.029, "proportion", "taux d'inoccupation RMR Montréal (SCHL oct. 2025)", sub="tous", f=PCT, transformation="% → proportion")200    cap_b = cal.get("cas_montreal.cap_rate_multiresidentiel", [4.25, 4.75], "%", "CBRE T3 2026 Montréal low-rise B (fourchette)", sub="low_rise_B")201    cap = 0.5 * (cap_b[0] + cap_b[1]) / 100   # milieu de fourchette, calcul de l'auteur202    taux_hyp = cal.get("taux.taux_hypothecaire_assure_fixe_5_ans_et_plus", 4.12, "%", "taux assuré fixe 5 ans et plus, fonds avancés (BdC, août 2026)", f=PCT, transformation="% → proportion")203    taux_taxe = cal.get("cas_montreal.taux_taxe_fonciere_generale_residentielle_2026", 0.4631, "$/100 $", "taxe foncière générale résidentielle Montréal 2026 (taux 6 logements et plus non vérifié)", f=lambda x: x / 100, transformation="$/100 $ → proportion")204    taux_scolaire = cal.get("taux.taxe_scolaire_2026_2027", 0.07899, "$/100 $", "taxe scolaire 2026-2027", f=lambda x: x / 100, transformation="$/100 $ → proportion")205    pct_tal = cal.get("ratios_exploitation_multilogement.tal_pourcentages_2026", 3.1, "%", "pourcentage de base TAL 2026", sub="taux_de_base_loyer", f=PCT, transformation="% → proportion")206    # état locatif : 12 logements, bâtiment 1962, 3 étages, Rosemont207    mix = [("3½", 1, loyer_1ch)] * 4 + [("4½", 2, loyer_2ch)] * 6 + [("5½", 3, loyer_3ch)] * 2208    rows = []209    for i, (p, ch, lm) in enumerate(mix, 1):210        anc = int(rng.choice([0, 1, 2, 3, 5, 8, 12, 18], p=[0.1, 0.15, 0.15, 0.15, 0.15, 0.12, 0.1, 0.08]))211        marche = lm * float(np.exp(rng.normal(0, 0.05)))212        contrat = marche * (1 - 0.02) ** anc213        rows.append({"logement": f"{100 + i}" if i <= 4 else (f"{200 + i - 4}" if i <= 8 else f"{300 + i - 8}"), "pieces": p, "chambres": ch,214                     "superficie_m2": {1: 52, 2: 72, 3: 95}[ch] + int(rng.integers(-4, 5)), "loyer_mensuel_contrat": round(contrat), "loyer_mensuel_marche": round(marche),215                     "locataire_depuis": 2026 - anc, "echeance_bail": "2027-06-30", "chauffage_inclus": 0, "stationnement": int(rng.random() < 0.5)})216    etat_locatif = pd.DataFrame(rows)217    rbp = etat_locatif["loyer_mensuel_contrat"].sum() * 12218    autres = 12 * (6 * 60 + 12 * 35)  # stationnement (6 places × 60 $), buanderie (35 $/log.)219    valeur_role = 2_200_000220    dep = {221        "Taxes municipales": valeur_role * taux_taxe, "Taxe scolaire": valeur_role * taux_scolaire, "Assurances": 11_500,222        "Électricité (aires communes)": 3_900, "Chauffage (aires communes, gaz)": 2_600, "Eau et égouts (taxe)": 0,223        "Entretien et réparations": 0.065 * rbp, "Conciergerie": 9_600, "Déneigement et paysagement": 6_200, "Gestion (4 % du RBE)": None,224        "Frais juridiques et comptables": 2_400, "Publicité et location": 1_200, "Réserve de remplacement (par logement)": 12 * 650}225    hist = []226    for an, infl in [(2023, 0.0), (2024, 0.045), (2025, 0.03)]:227        for poste, m in dep.items():228            if m is None: continue229            base = m * (1 + infl) ** (an - 2023) * float(np.exp(rng.normal(0, 0.04))) if m else 0230            hist.append({"annee": an, "poste": poste, "montant": round(base)})231    depenses = pd.DataFrame(hist)232    # comparables : 40 ventes 6-30 logements, 2024-2026, Rosemont/Villeray/Plateau/Hochelaga/Ahuntsic233    n = 40234    quart = rng.choice(["Rosemont", "Villeray", "Plateau", "Hochelaga", "Ahuntsic"], n, p=[0.3, 0.25, 0.15, 0.15, 0.15])235    prime = {"Rosemont": 0.0, "Villeray": -0.01, "Plateau": 0.06, "Hochelaga": -0.07, "Ahuntsic": -0.03}236    nlog = rng.integers(6, 31, n)237    dates = _dates(rng, n, datetime.date(2024, 9, 1), datetime.date(2026, 8, 31))238    annee = np.clip(rng.normal(1960, 15, n).round(0), 1900, 2010).astype(int)239    etat = np.clip(rng.normal(3, 0.7, n).round(0), 1, 5).astype(int)240    loyer_moy = loyer_2ch * np.exp(rng.normal(-0.05, 0.1, n)) * (1 + 0.04 * (etat - 3))241    rbp_c = loyer_moy * nlog * 12242    ratio = np.clip(rng.normal(0.40, 0.04, n), 0.3, 0.52)243    rne_c = rbp_c * (1 - inocc) * (1 - ratio)244    taux_c = cap * np.exp(rng.normal(0, 0.07, n)) * (1 - 0.03 * (etat - 3)) * (1 - 0.004 * (nlog - 12)) * np.exp(-np.array([prime[x] for x in quart]) * 0.5)245    prix_c = (rne_c / taux_c / 1000).round(0) * 1000246    comps = pd.DataFrame({"id_vente": [f"M{i+1:03d}" for i in range(n)], "date_vente": [d.isoformat() for d in dates], "quartier": quart, "nb_logements": nlog,247        "annee_construction": annee, "etat_1a5": etat, "loyer_mensuel_moyen": loyer_moy.round(0), "revenu_brut_potentiel": rbp_c.round(0),248        "ratio_depenses": ratio.round(3), "rne": rne_c.round(0), "prix_vente": prix_c})249    comps["taux_global"] = (comps["rne"] / comps["prix_vente"]).round(4)250    comps["prix_par_logement"] = (comps["prix_vente"] / comps["nb_logements"]).round(0)251    comps["mrb"] = (comps["prix_vente"] / comps["revenu_brut_potentiel"]).round(2)252    hypotheses = {"date_evaluation": DATE_EVAL.isoformat(), "horizon_ans": 10, "taux_inoccupation_pertes": inocc + 0.01,253                  "croissance_loyers_contrat": pct_tal, "croissance_loyers_marche": 0.035, "rotation_annuelle": 0.15,254                  "croissance_depenses": 0.03, "taux_actualisation": 0.0725, "taux_terminal": cap + 0.005, "couts_vente": 0.025,255                  "capex": {"2027": 85_000, "2029": 40_000, "2032": 120_000}, "financement": {"rpv": 0.75, "taux": taux_hyp, "amortissement_mois": 480, "terme_ans": 5, "rcd_min": 1.20},256                  "autres_revenus_annuels": autres, "valeur_role_2026": valeur_role, "taux_taxe_fonciere": taux_taxe, "taux_taxe_scolaire": taux_scolaire,257                  "note": "HYPOTHÈSES SYNTHÉTIQUES — voir data/synthetic/README.md pour la provenance de chaque paramètre"}258    sujet = {"adresse_fictive": "789, rue Fictive, Rosemont–La Petite-Patrie, Montréal", "nb_logements": 12, "annee_construction": 1962, "etages": 3,259             "superficie_terrain_m2": 740, "superficie_locative_m2": int(etat_locatif["superficie_m2"].sum()), "stationnements": 6, "etat_1a5": 3,260             "valeur_role_2026": valeur_role, "date_evaluation": DATE_EVAL.isoformat(), "note": "DONNÉES SYNTHÉTIQUES — immeuble fictif"}261    return {"etat_locatif": etat_locatif, "depenses": depenses, "comparables": comps, "hypotheses": hypotheses, "sujet": sujet}262263# ============================================================================ CAS 4264def cas4_portefeuille(cal: Calibration, rng: np.random.Generator) -> dict:265    def L(r, d): return cal.get(f"loyers_schl.rmr_{r}.loyer_moyen", d, "$/mois", f"loyer moyen 2 ch. RMR {r} (SCHL oct. 2025)", sub="2_chambres")266    def I(r, d): return cal.get(f"loyers_schl.rmr_{r}.taux_inoccupation", d, "proportion", f"inoccupation RMR {r} (SCHL oct. 2025)", sub="tous", f=PCT, transformation="% → proportion")267    cap_mtl = cal.get("ratios_exploitation_multilogement.cap_rate_cbre_montreal_t3_2026", [4.25, 4.75], "%", "CBRE Montréal low-rise B", sub="low_rise_B"); cap_mtl = 0.5 * sum(cap_mtl) / 100268    cap_qc = cal.get("ratios_exploitation_multilogement.cap_rate_cbre_quebec_city_t3_2026", [5.0, 6.0], "%", "CBRE Québec low-rise B", sub="low_rise_B"); cap_qc = 0.5 * sum(cap_qc) / 100269    cap_autres = cal.get("cas_portefeuille_6_regions.cap_rate_autres_rmr", None, "%", "taux de capitalisation hors Montréal/Québec (non publié)")270    # hypothèse pédagogique pour les RMR sans enquête publique : prime sur le taux de Québec (taille/liquidité)271    primes = {"Gatineau": 0.0, "Sherbrooke": 0.0025, "Trois-Rivières": 0.0075, "Saguenay": 0.0100}272    cap_x = lambda r: (cap_autres / 100) if cap_autres else cap_qc + primes[r]273    rmr = {"Montréal": (45.52, -73.60, L("montreal", 1_346), I("montreal", 2.9), cap_mtl),274           "Québec": (46.81, -71.22, L("quebec", 1_277), I("quebec", 2.4), cap_qc),275           "Gatineau": (45.47, -75.70, L("gatineau", 1_460), I("gatineau", 3.8), cap_x("Gatineau")),276           "Sherbrooke": (45.40, -71.89, L("sherbrooke", 1_062), I("sherbrooke", 2.7), cap_x("Sherbrooke")),277           "Trois-Rivières": (46.35, -72.55, L("trois_rivieres", 1_016), I("trois_rivieres", 2.7), cap_x("Trois-Rivières")),278           "Saguenay": (48.43, -71.07, L("saguenay", 985), I("saguenay", 1.3), cap_x("Saguenay"))}279    n = 48280    villes = rng.choice(list(rmr), n, p=[0.35, 0.2, 0.15, 0.1, 0.1, 0.1])281    rows = []282    for i, v in enumerate(villes, 1):283        lat0, lon0, l2, inocc, capv = rmr[v]284        nlog = int(rng.choice([6, 8, 12, 16, 24, 36, 48, 72], p=[0.2, 0.2, 0.2, 0.15, 0.1, 0.08, 0.05, 0.02]))285        annee = int(np.clip(rng.normal(1972, 18), 1920, 2018)); etat = int(np.clip(round(rng.normal(3, 0.8)), 1, 5))286        loyer = l2 * float(np.exp(rng.normal(-0.03, 0.1))) * (1 + 0.04 * (etat - 3))287        rbp = loyer * nlog * 12; ratio = float(np.clip(rng.normal(0.40, 0.04), 0.3, 0.52)); rne = rbp * (1 - inocc - 0.005) * (1 - ratio)288        zone_inond = int(rng.random() < 0.08); sol = int(rng.random() < 0.05)289        taux = capv * float(np.exp(rng.normal(0, 0.06))) * (1 - 0.03 * (etat - 3)) * (1 + 0.05 * zone_inond) * (1 + 0.03 * sol)290        valeur = rne / taux291        dette = valeur * float(np.clip(rng.normal(0.6, 0.12), 0.2, 0.8))292        rows.append({"id_immeuble": f"P{i:02d}", "rmr": v, "lat": round(lat0 + rng.normal(0, 0.04), 5), "lon": round(lon0 + rng.normal(0, 0.05), 5), "nb_logements": nlog,293                     "annee_construction": annee, "age": 2026 - annee, "etat_1a5": etat, "loyer_mensuel_moyen": round(loyer), "revenu_brut_potentiel": round(rbp),294                     "taux_inoccupation_marche": inocc, "ratio_depenses": round(ratio, 3), "rne_2026": round(rne), "zone_inondable": zone_inond, "contamination_sol": sol,295                     "taux_global": round(taux, 4), "valeur_2026": round(valeur / 1000) * 1000, "dette": round(dette / 1000) * 1000,296                     "taux_dette": round(float(rng.normal(0.045, 0.006)), 4), "echeance_dette": int(rng.integers(2027, 2032))})297    pf = pd.DataFrame(rows)298    # séries trimestrielles de RNE 2016T1-2026T2 : tendance régionale commune + choc idiosyncrasique299    trims = pd.period_range("2016Q1", "2026Q2", freq="Q")300    g_reg = {"Montréal": 0.035, "Québec": 0.03, "Gatineau": 0.04, "Sherbrooke": 0.03, "Trois-Rivières": 0.025, "Saguenay": 0.02}301    ser = []302    for _, r in pf.iterrows():303        t = np.arange(len(trims)); g = g_reg[r["rmr"]]304        base = r["rne_2026"] / (1 + g) ** ((len(trims) - 1) / 4)305        chocs = np.cumsum(rng.normal(0, 0.015, len(trims)))306        pandemie = np.where((trims >= "2020Q2") & (trims <= "2021Q2"), -0.03, 0)307        x = base * (1 + g) ** (t / 4) * np.exp(chocs - chocs[-1] + pandemie) / 4308        for tt, val in zip(trims, x):309            ser.append({"id_immeuble": r["id_immeuble"], "trimestre": str(tt), "rne_trimestriel": round(val)})310    series = pd.DataFrame(ser)311    return {"portefeuille": pf, "series": series}312313# ============================================================================ écriture314def ecrire_tout() -> dict:315    OUT.mkdir(parents=True, exist_ok=True)316    cal = Calibration()317    rng = np.random.default_rng(SEED)318    c1 = cas1_gatineau(cal, rng); c2 = cas2_quebec(cal, rng); c3 = cas3_montreal(cal, rng); c4 = cas4_portefeuille(cal, rng)319    c1["ventes"].to_csv(OUT / "cas1_gatineau_ventes.csv", index=False); (OUT / "cas1_gatineau_sujet.json").write_text(json.dumps(c1["sujet"], ensure_ascii=False, indent=1))320    c1["couts"].to_csv(OUT / "cas1_gatineau_couts.csv", index=False)321    c2["ventes"].to_csv(OUT / "cas2_quebec_ventes_plex.csv", index=False); (OUT / "cas2_quebec_sujet.json").write_text(json.dumps(c2["sujet"], ensure_ascii=False, indent=1))322    c2["loyers"].to_csv(OUT / "cas2_quebec_loyers.csv", index=False); c2["depenses"].to_csv(OUT / "cas2_quebec_depenses.csv", index=False)323    c3["etat_locatif"].to_csv(OUT / "cas3_montreal_etat_locatif.csv", index=False); c3["depenses"].to_csv(OUT / "cas3_montreal_depenses.csv", index=False)324    c3["comparables"].to_csv(OUT / "cas3_montreal_comparables.csv", index=False)325    (OUT / "cas3_montreal_hypotheses_dcf.json").write_text(json.dumps(c3["hypotheses"], ensure_ascii=False, indent=1)); (OUT / "cas3_montreal_sujet.json").write_text(json.dumps(c3["sujet"], ensure_ascii=False, indent=1))326    c4["portefeuille"].to_csv(OUT / "cas4_portefeuille.csv", index=False); c4["series"].to_csv(OUT / "cas4_portefeuille_series.csv", index=False)327    # journal de calibration dédoublonné328    seen = {}; 329    for j in cal.journal: seen.setdefault(j["parametre"], j)330    jour = pd.DataFrame(list(seen.values()))331    jour["valeur"] = jour["valeur"].map(lambda x: round(x, 6) if isinstance(x, float) else x)332    jour.to_csv(OUT / "calibration_journal.csv", index=False)333    n_cal = int((jour["statut"] == "calibre").sum()); n_prov = int((jour["statut"] == "provisoire").sum())334    readme = f"""# Données SYNTHÉTIQUES du traité — générées le {datetime.date.today().isoformat()} (graine {SEED})335336**Aucun fichier de ce dossier ne décrit une propriété, une transaction, un locataire ou un immeuble réel.** Les jeux sont produits par337`analysis/python/traite/synth.py` (`python3 -m analysis.python.cases.generate_all`). Les ordres de grandeur (prix médians, loyers, taux338d'inoccupation, taux de capitalisation, taux de taxe, coûts unitaires) sont calibrés sur des publications publiques consignées dans339`calibration.json` et `calibration_journal.csv` ({n_cal} paramètres calibrés avec source, {n_prov} paramètres PROVISOIRES marqués NON_VERIFIE).340Les relations entre variables (élasticités, primes de secteur, décote d'ancienneté des baux, lois de tirage) sont des hypothèses pédagogiques341énoncées ci-dessous ; elles sont vraisemblables mais ne résultent pas d'une estimation sur données réelles.342343## Dossiers344| Fichier | Lignes | Contenu |345|---|---|---|346| cas1_gatineau_ventes.csv | {len(c1['ventes'])} | ventes unifamiliales 2023-01 → 2026-08, 5 secteurs, attributs, coordonnées synthétiques, valeur au rôle fictive |347| cas1_gatineau_sujet.json | 1 | sujet : cottage 1998, 165 m², Hull |348| cas1_gatineau_couts.csv | {len(c1['couts'])} | devis par composantes (quantités × prix unitaires), postes directs/indirects |349| cas2_quebec_ventes_plex.csv | {len(c2['ventes'])} | ventes de 2-3 logements, 5 quartiers, loyers, RNE, MRB, taux |350| cas2_quebec_sujet.json / _loyers.csv / _depenses.csv | 1 / 3 / 8 | triplex 1948 Limoilou : 3 baux, dépenses |351| cas3_montreal_etat_locatif.csv | 12 | état locatif (contrat vs marché, ancienneté) |352| cas3_montreal_depenses.csv | {len(c3['depenses'])} | historique 2023-2025 par poste |353| cas3_montreal_comparables.csv | {len(c3['comparables'])} | ventes 6-30 logements 2024-09 → 2026-08, RNE, taux |354| cas3_montreal_hypotheses_dcf.json | 1 | hypothèses DCF, financement, CAPEX |355| cas4_portefeuille.csv | {len(c4['portefeuille'])} | 48 immeubles, 6 RMR, risques, dette |356| cas4_portefeuille_series.csv | {len(c4['series'])} | RNE trimestriel 2016T1 → 2026T2 |357358## Lois de génération (hypothèses pédagogiques)359- Cas 1 : ln P = ln(médiane) + 0,55 ln(S/140) + 0,10 ln(T/560) − 0,0055·âge + 0,00003·âge² + 0,06·(qualité−3) + 0,035·garage + 0,03·sous-sol + 0,02·piscine + prime de secteur = log du rapport des médianes APCIQ T3 2026 par secteur à celle de Hull (Masson-Angers : Buckingham/Masson-Angers − 2 points, hypothèse) − 0,006·max(dist−3, 0) + 0,02·étages + indice temporel (croissance annuelle composée, référence 2024-07-01) + ε, σ = 0,085 ; prix arrondis à 500 $ ; valeur au rôle = valeur à la date de référence × N(0,93 ; 0,07).360- Cas 2 : loyer de marché par logement = loyer 2 ch. SCHL × N(0 ; 0,12) × (1 + 0,05(état−3)) × ½ prime de quartier ; loyer contractuel = marché × (1 − 0,015 × ancienneté moyenne, plafonnée à 20 ans) ; ratio de dépenses N(0,36 ; 0,05) ; ln P fonction de la superficie, du nombre de logements, de l'âge, de l'état, du quartier, du stationnement, du niveau de loyer et du temps, σ = 0,09.361- Cas 3 : 12 baux (4 × 3½, 6 × 4½, 2 × 5½), loyers contractuels = marché × 0,98^ancienneté ; dépenses par poste avec inflation 2024 +4,5 %, 2025 +3 % ; comparables : taux global = taux de référence × exp(N(0 ; 0,07)) × (1 − 0,03(état−3)) × (1 − 0,004(n−12)) × exp(−½ prime).362- Cas 4 : 48 immeubles ; taux global régional × état × (1 + 5 % si zone inondable) × (1 + 3 % si contamination) ; séries de RNE : croissance régionale + marche aléatoire (σ 1,5 %/trim.) + choc pandémique −3 % (2020T2-2021T2).363364## Paramètres et provenance365{jour.to_markdown(index=False)}366"""367    (OUT / "README.md").write_text(readme)368    return {"cas1": c1, "cas2": c2, "cas3": c3, "cas4": c4, "journal": jour}369
Files · 2eb2bf4e67f754808bae5090a9e53eab37149ddb
Menu

Theme