spb/traite-evaluation-qc
Public
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