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 avec traçabilité (valeur, source, provisoire ou non)."""19 def __init__(self, path=OUT / "calibration.json"):20 self.raw = json.loads(path.read_text()) if path.exists() else {}21 self.journal: list[dict] = []22 def get(self, key: str, defaut, unite: str = "", note: str = ""):23 item = self.raw.get(key)24 if isinstance(item, dict) and item.get("valeur") is not None:25 self.journal.append({"parametre": key, "valeur": item["valeur"], "unite": item.get("unite", unite),26 "source_id": item.get("source_id", "?"), "periode": item.get("periode", ""), "statut": "calibre"})27 return item["valeur"]28 self.journal.append({"parametre": key, "valeur": defaut, "unite": unite, "source_id": "NON_VERIFIE", "periode": "", "statut": "provisoire", "note": note})29 return defaut3031def _dates(rng, n, debut=datetime.date(2023, 1, 1), fin=datetime.date(2026, 8, 31)):32 span = (fin - debut).days33 d = np.sort(rng.integers(0, span + 1, n))34 return [debut + datetime.timedelta(days=int(x)) for x in d]3536def _mois_depuis(dates, ref=datetime.date(2023, 1, 1)):37 return np.array([(d.year - ref.year) * 12 + (d.month - ref.month) + (d.day - 1) / 30.0 for d in dates])3839# ============================================================================ CAS 140def cas1_gatineau(cal: Calibration, rng: np.random.Generator) -> dict:41 n = 60042 med = cal.get("prix_median_unifamiliale_rmr_gatineau", 520_000, "$", "prix médian unifamiliale RMR Gatineau (APCIQ)")43 g = cal.get("croissance_annuelle_prix_gatineau", 0.07, "proportion", "variation annuelle du prix médian")44 secteurs = ["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"]45 prime_secteur = {"Hull": 0.00, "Gatineau": -0.05, "Aylmer": 0.08, "Buckingham": -0.15, "Masson-Angers": -0.18} # hypothèse pédagogique46 p_sect = [0.30, 0.32, 0.20, 0.10, 0.08]47 sect = rng.choice(secteurs, n, p=p_sect)48 dates = _dates(rng, n)49 mois = _mois_depuis(dates)50 S = np.exp(rng.normal(np.log(140), 0.25, n)).round(0) # superficie habitable m²51 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)52 annee = np.clip(rng.normal(1985, 20, n).round(0), 1920, 2025).astype(int)53 age = 2026 - annee54 qualite = np.clip(rng.normal(3, 0.8, n).round(0), 1, 5).astype(int)55 garage = rng.choice([0, 1, 2], n, p=[0.45, 0.45, 0.10])56 sous_sol_fini = rng.binomial(1, 0.6, n)57 piscine = rng.binomial(1, 0.15, n)58 chambres = np.clip((S / 45 + rng.normal(0, 0.6, n)).round(0), 1, 6).astype(int)59 sdb = np.clip((S / 80 + rng.normal(0, 0.5, n)).round(0), 1, 4).astype(int)60 type_bat = np.where(rng.random(n) < 0.45, "plain-pied", "à étages")61 # centre de chaque secteur (lat, lon approximatifs du centre urbain, jitter ± ~2-4 km : coordonnées synthétiques)62 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)}63 lat = np.array([centres[s][0] for s in sect]) + rng.normal(0, 0.012, n)64 lon = np.array([centres[s][1] for s in sect]) + rng.normal(0, 0.020, n)65 dist_centre_km = np.sqrt(((lat - 45.428) * 111) ** 2 + ((lon + 75.714) * 78) ** 2)66 # indice de marché : croissance g/an composée, référence = 1er juillet 2024 (date de référence du rôle fictif)67 ref_mois = _mois_depuis([datetime.date(2024, 7, 1)])[0]68 ln_index = np.log1p(g) * (mois - ref_mois) / 1269 lnP = (np.log(med) + 0.55 * np.log(S / 140) + 0.10 * np.log(T / 560) - 0.0055 * age + 0.000030 * age ** 270 + 0.06 * (qualite - 3) + 0.035 * garage + 0.03 * sous_sol_fini + 0.02 * piscine71 + np.array([prime_secteur[s] for s in sect]) - 0.006 * np.clip(dist_centre_km - 3, 0, None)72 + 0.02 * (type_bat == "à étages") + ln_index + rng.normal(0, 0.085, n))73 lnP += np.log(med) - np.median(lnP - ln_index) # normalisation : médiane à la date de référence = prix médian calibré74 prix = (np.exp(lnP) / 500).round(0) * 50075 # valeur au rôle 2025-2027 fictif : valeur à la date de référence (2024-07-01) × ratio ~ N(0.93, 0.07)76 lnP_ref = lnP - ln_index77 role = (np.exp(lnP_ref) * np.clip(rng.normal(0.93, 0.07, n), 0.7, 1.2) / 100).round(0) * 10078 ventes = pd.DataFrame({79 "id_vente": [f"G{idx+1:04d}" for idx in range(n)], "date_vente": [d.isoformat() for d in dates], "secteur": sect,80 "type": type_bat, "superficie_habitable_m2": S, "superficie_terrain_m2": T, "annee_construction": annee, "age": age,81 "qualite_1a5": qualite, "garage_places": garage, "sous_sol_fini": sous_sol_fini, "piscine": piscine,82 "chambres": chambres, "salles_de_bain": sdb, "lat": lat.round(5), "lon": lon.round(5), "dist_centre_km": dist_centre_km.round(2),83 "valeur_role_2025": role, "prix_vente": prix})84 sujet = {"adresse_fictive": "123, rue des Érables (fictive), secteur Hull, Gatineau", "secteur": "Hull", "type": "à étages",85 "superficie_habitable_m2": 165, "superficie_terrain_m2": 620, "annee_construction": 1998, "age": 28, "qualite_1a5": 3,86 "garage_places": 1, "sous_sol_fini": 1, "piscine": 0, "chambres": 4, "salles_de_bain": 2, "lat": 45.4215, "lon": -75.7395,87 "dist_centre_km": 2.1, "valeur_role_2025": 468_000, "date_evaluation": DATE_EVAL.isoformat(),88 "note": "DONNÉES SYNTHÉTIQUES — propriété fictive construite pour le traité"}89 # devis par composantes (38 postes) : quantités cohérentes avec 165 m² habitables sur 2 étages, empreinte ~ 95 m²90 postes = [91 ("Excavation et remblai", "m³", 420, 28), ("Fondations béton (semelles, murs)", "m³", 38, 520), ("Dalle de sous-sol", "m²", 95, 95),92 ("Drain français et imperméabilisation", "m lin.", 42, 120), ("Charpente murs et planchers", "m²", 330, 165), ("Fermes de toit", "m²", 110, 95),93 ("Couverture bardeaux d'asphalte", "m²", 125, 85), ("Revêtement extérieur (brique 40 % / fibrociment 60 %)", "m²", 240, 165),94 ("Isolation et pare-vapeur", "m²", 420, 32), ("Fenêtres (PVC double vitrage)", "unité", 16, 1450), ("Portes extérieures", "unité", 3, 1900),95 ("Porte de garage", "unité", 1, 2600), ("Gypse et tirage de joints", "m²", 620, 38), ("Peinture intérieure", "m²", 620, 14),96 ("Planchers bois franc", "m²", 95, 120), ("Planchers céramique", "m²", 35, 135), ("Planchers flottants (sous-sol)", "m²", 70, 55),97 ("Escaliers", "unité", 2, 4800), ("Portes intérieures et moulures", "unité", 14, 650), ("Armoires de cuisine et comptoirs", "forfait", 1, 24000),98 ("Vanités et accessoires (2 sdb)", "forfait", 1, 7500), ("Plomberie brute et finition", "appareil", 14, 1650), ("Chauffe-eau", "unité", 1, 1600),99 ("Électricité (entrée 200 A, filage, appareils)", "forfait", 1, 19500), ("Ventilation (VRC) et conduits", "forfait", 1, 7800),100 ("Thermopompe centrale et plinthes", "forfait", 1, 14500), ("Éclairage et finition électrique", "forfait", 1, 4200),101 ("Gouttières et solins", "m lin.", 48, 45), ("Balcon et perron", "m²", 14, 650), ("Entrée asphaltée", "m²", 55, 65),102 ("Aménagement paysager de base", "forfait", 1, 6500), ("Raccordements aqueduc/égout", "forfait", 1, 9500), ("Nettoyage et conteneurs", "forfait", 1, 3800),103 ("Permis et études (arpentage, sol)", "forfait", 1, 6200), ("Supervision de chantier (salaires)", "semaine", 26, 1900),104 ("Assurances chantier et cautionnement", "forfait", 1, 5200), ("Plans et honoraires professionnels", "forfait", 1, 14000), ("Financement intérimaire", "forfait", 1, 11800),105 ]106 cible = cal.get("cout_construction_unifamiliale_par_m2_quebec", 2_900, "$/m² habitable", "coût neuf typique unifamiliale (tout compris)")107 couts = pd.DataFrame(postes, columns=["poste", "unite", "quantite", "prix_unitaire"])108 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"])109 brut = (couts["quantite"] * couts["prix_unitaire"]).sum()110 echelle = cible * 165 / brut111 couts["prix_unitaire"] = (couts["prix_unitaire"] * echelle).round(2)112 couts["montant"] = (couts["quantite"] * couts["prix_unitaire"]).round(2)113 return {"ventes": ventes, "sujet": sujet, "couts": couts}114115# ============================================================================ CAS 2116def cas2_quebec(cal: Calibration, rng: np.random.Generator) -> dict:117 n = 250118 med_plex = cal.get("prix_median_plex_rmr_quebec", 560_000, "$", "prix médian plex 2-5 log. RMR Québec (APCIQ)")119 loyer_2ch = cal.get("loyer_moyen_2ch_rmr_quebec", 1_200, "$/mois", "loyer moyen 2 ch. RMR Québec (SCHL)")120 inocc = cal.get("taux_inoccupation_rmr_quebec", 0.01, "proportion", "taux d'inoccupation RMR Québec (SCHL)")121 g = cal.get("croissance_annuelle_prix_quebec", 0.10, "proportion", "variation annuelle du prix médian plex Québec")122 quartiers = ["Limoilou", "Saint-Sauveur", "Saint-Roch", "Montcalm", "Maizerets"]123 prime = {"Limoilou": 0.0, "Saint-Sauveur": -0.06, "Saint-Roch": 0.02, "Montcalm": 0.15, "Maizerets": -0.09}124 q = rng.choice(quartiers, n, p=[0.35, 0.2, 0.15, 0.1, 0.2])125 nb_log = rng.choice([2, 3], n, p=[0.45, 0.55])126 dates = _dates(rng, n); mois = _mois_depuis(dates)127 annee = np.clip(rng.normal(1945, 18, n).round(0), 1900, 2015).astype(int); age = 2026 - annee128 etat = np.clip(rng.normal(3, 0.8, n).round(0), 1, 5).astype(int)129 sup_hab = (nb_log * np.exp(rng.normal(np.log(85), 0.15, n))).round(0)130 stationnement = rng.binomial(1, 0.5, n)131 # loyers : par logement ~ loyer_2ch × facteur taille × écart contractuel (sous le marché selon l'ancienneté des baux)132 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)133 anciennete_moy = np.clip(rng.exponential(5, n), 0, 25)134 ecart = -0.02 * anciennete_moy # ~2 % par année d'ancienneté sous le marché (hypothèse)135 loyer_contrat_unit = loyer_marche_unit * (1 + ecart)136 rbp = loyer_contrat_unit * nb_log * 12137 ratio_dep = np.clip(rng.normal(0.36, 0.05, n), 0.25, 0.5)138 rne = rbp * (1 - inocc) * (1 - ratio_dep)139 ref_mois = _mois_depuis([datetime.date(2024, 7, 1)])[0]140 ln_index = np.log1p(g) * (mois - ref_mois) / 12141 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)142 + 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))143 + ln_index + rng.normal(0, 0.09, n))144 lnP += np.log(med_plex) - np.median(lnP - ln_index) # normalisation à la médiane calibrée (date de référence)145 prix = (np.exp(lnP) / 1000).round(0) * 1000146 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,147 "nb_logements": nb_log, "superficie_habitable_m2": sup_hab, "annee_construction": annee, "age": age, "etat_1a5": etat,148 "stationnement": stationnement, "loyer_mensuel_moyen_contrat": loyer_contrat_unit.round(0), "loyer_mensuel_moyen_marche": loyer_marche_unit.round(0),149 "revenu_brut_potentiel": rbp.round(0), "ratio_depenses": ratio_dep.round(3), "rne": rne.round(0), "prix_vente": prix})150 ventes["mrb"] = (ventes["prix_vente"] / ventes["revenu_brut_potentiel"]).round(2)151 ventes["taux_global"] = (ventes["rne"] / ventes["prix_vente"]).round(4)152 ventes["prix_par_logement"] = (ventes["prix_vente"] / ventes["nb_logements"]).round(0)153 loyers = pd.DataFrame([154 {"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},155 {"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},156 {"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},157 ])158 loyers["loyer_marche_estime"] = [round(loyer_2ch * 1.12), round(loyer_2ch * 1.12), round(loyer_2ch * 0.98)]159 sujet = {"adresse_fictive": "456, 3e Avenue (fictive), Limoilou, Québec", "quartier": "Limoilou", "nb_logements": 3, "annee_construction": 1948, "age": 78,160 "etat_1a5": 3, "superficie_habitable_m2": 262, "superficie_terrain_m2": 310, "stationnement": 1, "valeur_role_2025": 495_000,161 "date_evaluation": DATE_EVAL.isoformat(), "note": "DONNÉES SYNTHÉTIQUES — immeuble fictif"}162 depenses = pd.DataFrame([163 {"poste": "Taxes municipales", "montant_annuel": 5_900, "type": "fixe"}, {"poste": "Taxe scolaire", "montant_annuel": 470, "type": "fixe"},164 {"poste": "Assurances", "montant_annuel": 3_400, "type": "fixe"}, {"poste": "Électricité (aires communes)", "montant_annuel": 650, "type": "variable"},165 {"poste": "Déneigement et entretien extérieur", "montant_annuel": 1_500, "type": "variable"}, {"poste": "Entretien et réparations", "montant_annuel": 3_200, "type": "variable"},166 {"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"}])167 return {"ventes": ventes, "sujet": sujet, "loyers": loyers, "depenses": depenses}168169# ============================================================================ CAS 3170def cas3_montreal(cal: Calibration, rng: np.random.Generator) -> dict:171 loyer_2ch = cal.get("loyer_moyen_2ch_rmr_montreal", 1_250, "$/mois", "loyer moyen 2 ch. RMR Montréal (SCHL)")172 loyer_1ch = cal.get("loyer_moyen_1ch_rmr_montreal", 1_050, "$/mois", "loyer moyen 1 ch. RMR Montréal (SCHL)")173 loyer_3ch = cal.get("loyer_moyen_3ch_rmr_montreal", 1_550, "$/mois", "loyer moyen 3 ch.+ RMR Montréal (SCHL)")174 inocc = cal.get("taux_inoccupation_rmr_montreal", 0.02, "proportion", "taux d'inoccupation RMR Montréal (SCHL)")175 cap = cal.get("taux_capitalisation_multires_montreal", 0.0475, "proportion", "taux de capitalisation multirésidentiel Montréal (enquête publique)")176 taux_hyp = cal.get("taux_hypothecaire_multires_assure", 0.042, "proportion", "taux assuré SCHL 5 ans (indicatif)")177 taux_taxe = cal.get("taux_taxe_fonciere_montreal_residentiel_2026", 0.0065, "$/$ d'évaluation", "taux global résidentiel Montréal")178 taux_scolaire = cal.get("taux_taxe_scolaire_2025_2026", 0.0009152, "$/$ d'évaluation", "taux unique de la taxe scolaire 2025-2026")179 pct_tal = cal.get("tal_pourcentage_base_2026", 0.039, "proportion", "pourcentage de base TAL 2026 (logements non chauffés)")180 # état locatif : 12 logements, bâtiment 1962, 3 étages, Rosemont181 mix = [("3½", 1, loyer_1ch)] * 4 + [("4½", 2, loyer_2ch)] * 6 + [("5½", 3, loyer_3ch)] * 2182 rows = []183 for i, (p, ch, lm) in enumerate(mix, 1):184 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]))185 marche = lm * float(np.exp(rng.normal(0, 0.05)))186 contrat = marche * (1 - 0.025) ** anc * (1 if anc else 1.0)187 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,188 "superficie_m2": {1: 52, 2: 72, 3: 95}[ch] + int(rng.integers(-4, 5)), "loyer_mensuel_contrat": round(contrat), "loyer_mensuel_marche": round(marche),189 "locataire_depuis": 2026 - anc, "echeance_bail": "2027-06-30", "chauffage_inclus": 0, "stationnement": int(rng.random() < 0.5)})190 etat_locatif = pd.DataFrame(rows)191 rbp = etat_locatif["loyer_mensuel_contrat"].sum() * 12192 autres = 12 * (6 * 60 + 12 * 35) # stationnement (6 places × 60 $), buanderie (35 $/log.)193 valeur_role = 2_650_000194 dep = {195 "Taxes municipales": valeur_role * taux_taxe, "Taxe scolaire": valeur_role * taux_scolaire, "Assurances": 14_800,196 "Électricité (aires communes)": 3_900, "Chauffage (aires communes, gaz)": 2_600, "Eau et égouts (taxe)": 0,197 "Entretien et réparations": 0.065 * rbp, "Conciergerie": 9_600, "Déneigement et paysagement": 6_200, "Gestion (4 % du RBE)": None,198 "Frais juridiques et comptables": 2_400, "Publicité et location": 1_200, "Réserve de remplacement (par logement)": 12 * 650}199 hist = []200 for an, infl in [(2023, 0.0), (2024, 0.045), (2025, 0.03)]:201 for poste, m in dep.items():202 if m is None: continue203 base = m * (1 + infl) ** (an - 2023) * float(np.exp(rng.normal(0, 0.04))) if m else 0204 hist.append({"annee": an, "poste": poste, "montant": round(base)})205 depenses = pd.DataFrame(hist)206 # comparables : 40 ventes 6-30 logements, 2024-2026, Rosemont/Villeray/Plateau/Hochelaga/Ahuntsic207 n = 40208 quart = rng.choice(["Rosemont", "Villeray", "Plateau", "Hochelaga", "Ahuntsic"], n, p=[0.3, 0.25, 0.15, 0.15, 0.15])209 prime = {"Rosemont": 0.0, "Villeray": -0.01, "Plateau": 0.06, "Hochelaga": -0.07, "Ahuntsic": -0.03}210 nlog = rng.integers(6, 31, n)211 dates = _dates(rng, n, datetime.date(2024, 9, 1), datetime.date(2026, 8, 31))212 annee = np.clip(rng.normal(1960, 15, n).round(0), 1900, 2010).astype(int)213 etat = np.clip(rng.normal(3, 0.7, n).round(0), 1, 5).astype(int)214 loyer_moy = loyer_2ch * np.exp(rng.normal(-0.05, 0.1, n)) * (1 + 0.04 * (etat - 3))215 rbp_c = loyer_moy * nlog * 12216 ratio = np.clip(rng.normal(0.40, 0.04, n), 0.3, 0.52)217 rne_c = rbp_c * (1 - inocc) * (1 - ratio)218 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)219 prix_c = (rne_c / taux_c / 1000).round(0) * 1000220 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,221 "annee_construction": annee, "etat_1a5": etat, "loyer_mensuel_moyen": loyer_moy.round(0), "revenu_brut_potentiel": rbp_c.round(0),222 "ratio_depenses": ratio.round(3), "rne": rne_c.round(0), "prix_vente": prix_c})223 comps["taux_global"] = (comps["rne"] / comps["prix_vente"]).round(4)224 comps["prix_par_logement"] = (comps["prix_vente"] / comps["nb_logements"]).round(0)225 comps["mrb"] = (comps["prix_vente"] / comps["revenu_brut_potentiel"]).round(2)226 hypotheses = {"date_evaluation": DATE_EVAL.isoformat(), "horizon_ans": 10, "taux_inoccupation_pertes": inocc + 0.01,227 "croissance_loyers_contrat": pct_tal, "croissance_loyers_marche": 0.035, "rotation_annuelle": 0.15,228 "croissance_depenses": 0.03, "taux_actualisation": 0.0725, "taux_terminal": cap + 0.005, "couts_vente": 0.025,229 "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},230 "autres_revenus_annuels": autres, "valeur_role_2026": valeur_role, "taux_taxe_fonciere": taux_taxe, "taux_taxe_scolaire": taux_scolaire,231 "note": "HYPOTHÈSES SYNTHÉTIQUES — voir data/synthetic/README.md pour la provenance de chaque paramètre"}232 sujet = {"adresse_fictive": "789, rue Fictive, Rosemont–La Petite-Patrie, Montréal", "nb_logements": 12, "annee_construction": 1962, "etages": 3,233 "superficie_terrain_m2": 740, "superficie_locative_m2": int(etat_locatif["superficie_m2"].sum()), "stationnements": 6, "etat_1a5": 3,234 "valeur_role_2026": valeur_role, "date_evaluation": DATE_EVAL.isoformat(), "note": "DONNÉES SYNTHÉTIQUES — immeuble fictif"}235 return {"etat_locatif": etat_locatif, "depenses": depenses, "comparables": comps, "hypotheses": hypotheses, "sujet": sujet}236237# ============================================================================ CAS 4238def cas4_portefeuille(cal: Calibration, rng: np.random.Generator) -> dict:239 rmr = {"Montréal": (45.52, -73.60, cal.get("loyer_moyen_2ch_rmr_montreal", 1_250), cal.get("taux_inoccupation_rmr_montreal", 0.02), cal.get("taux_capitalisation_multires_montreal", 0.0475)),240 "Québec": (46.81, -71.22, cal.get("loyer_moyen_2ch_rmr_quebec", 1_200), cal.get("taux_inoccupation_rmr_quebec", 0.01), cal.get("taux_capitalisation_multires_quebec", 0.0525, "proportion", "taux de cap. multirés. Québec")),241 "Gatineau": (45.47, -75.70, cal.get("loyer_moyen_2ch_rmr_gatineau", 1_450, "$/mois", "loyer moyen 2 ch. RMR Gatineau (SCHL)"), cal.get("taux_inoccupation_rmr_gatineau", 0.015, "proportion", "inocc. Gatineau"), 0.05),242 "Sherbrooke": (45.40, -71.89, cal.get("loyer_moyen_2ch_rmr_sherbrooke", 1_100, "$/mois", "loyer moyen 2 ch. Sherbrooke"), cal.get("taux_inoccupation_rmr_sherbrooke", 0.01, "proportion", "inocc. Sherbrooke"), 0.055),243 "Trois-Rivières": (46.35, -72.55, cal.get("loyer_moyen_2ch_rmr_trois_rivieres", 1_000, "$/mois", "loyer moyen 2 ch. Trois-Rivières"), cal.get("taux_inoccupation_rmr_trois_rivieres", 0.01, "proportion", "inocc. Trois-Rivières"), 0.06),244 "Saguenay": (48.43, -71.07, cal.get("loyer_moyen_2ch_rmr_saguenay", 950, "$/mois", "loyer moyen 2 ch. Saguenay"), cal.get("taux_inoccupation_rmr_saguenay", 0.01, "proportion", "inocc. Saguenay"), 0.0625)}245 n = 48246 villes = rng.choice(list(rmr), n, p=[0.35, 0.2, 0.15, 0.1, 0.1, 0.1])247 rows = []248 for i, v in enumerate(villes, 1):249 lat0, lon0, l2, inocc, capv = rmr[v]250 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]))251 annee = int(np.clip(rng.normal(1972, 18), 1920, 2018)); etat = int(np.clip(round(rng.normal(3, 0.8)), 1, 5))252 loyer = l2 * float(np.exp(rng.normal(-0.03, 0.1))) * (1 + 0.04 * (etat - 3))253 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)254 zone_inond = int(rng.random() < 0.08); sol = int(rng.random() < 0.05)255 taux = capv * float(np.exp(rng.normal(0, 0.06))) * (1 - 0.03 * (etat - 3)) * (1 + 0.05 * zone_inond) * (1 + 0.03 * sol)256 valeur = rne / taux257 dette = valeur * float(np.clip(rng.normal(0.6, 0.12), 0.2, 0.8))258 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,259 "annee_construction": annee, "age": 2026 - annee, "etat_1a5": etat, "loyer_mensuel_moyen": round(loyer), "revenu_brut_potentiel": round(rbp),260 "taux_inoccupation_marche": inocc, "ratio_depenses": round(ratio, 3), "rne_2026": round(rne), "zone_inondable": zone_inond, "contamination_sol": sol,261 "taux_global": round(taux, 4), "valeur_2026": round(valeur / 1000) * 1000, "dette": round(dette / 1000) * 1000,262 "taux_dette": round(float(rng.normal(0.045, 0.006)), 4), "echeance_dette": int(rng.integers(2027, 2032))})263 pf = pd.DataFrame(rows)264 # séries trimestrielles de RNE 2016T1-2026T2 : tendance régionale commune + choc idiosyncrasique265 trims = pd.period_range("2016Q1", "2026Q2", freq="Q")266 g_reg = {"Montréal": 0.035, "Québec": 0.03, "Gatineau": 0.04, "Sherbrooke": 0.03, "Trois-Rivières": 0.025, "Saguenay": 0.02}267 ser = []268 for _, r in pf.iterrows():269 t = np.arange(len(trims)); g = g_reg[r["rmr"]]270 base = r["rne_2026"] / (1 + g) ** ((len(trims) - 1) / 4)271 chocs = np.cumsum(rng.normal(0, 0.015, len(trims)))272 pandemie = np.where((trims >= "2020Q2") & (trims <= "2021Q2"), -0.03, 0)273 x = base * (1 + g) ** (t / 4) * np.exp(chocs - chocs[-1] + pandemie) / 4274 for tt, val in zip(trims, x):275 ser.append({"id_immeuble": r["id_immeuble"], "trimestre": str(tt), "rne_trimestriel": round(val)})276 series = pd.DataFrame(ser)277 return {"portefeuille": pf, "series": series}278279# ============================================================================ écriture280def ecrire_tout() -> dict:281 OUT.mkdir(parents=True, exist_ok=True)282 cal = Calibration()283 rng = np.random.default_rng(SEED)284 c1 = cas1_gatineau(cal, rng); c2 = cas2_quebec(cal, rng); c3 = cas3_montreal(cal, rng); c4 = cas4_portefeuille(cal, rng)285 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))286 c1["couts"].to_csv(OUT / "cas1_gatineau_couts.csv", index=False)287 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))288 c2["loyers"].to_csv(OUT / "cas2_quebec_loyers.csv", index=False); c2["depenses"].to_csv(OUT / "cas2_quebec_depenses.csv", index=False)289 c3["etat_locatif"].to_csv(OUT / "cas3_montreal_etat_locatif.csv", index=False); c3["depenses"].to_csv(OUT / "cas3_montreal_depenses.csv", index=False)290 c3["comparables"].to_csv(OUT / "cas3_montreal_comparables.csv", index=False)291 (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))292 c4["portefeuille"].to_csv(OUT / "cas4_portefeuille.csv", index=False); c4["series"].to_csv(OUT / "cas4_portefeuille_series.csv", index=False)293 # journal de calibration dédoublonné294 seen = {}; 295 for j in cal.journal: seen.setdefault(j["parametre"], j)296 jour = pd.DataFrame(list(seen.values()))297 jour.to_csv(OUT / "calibration_journal.csv", index=False)298 n_cal = int((jour["statut"] == "calibre").sum()); n_prov = int((jour["statut"] == "provisoire").sum())299 readme = f"""# Données SYNTHÉTIQUES du traité — générées le {datetime.date.today().isoformat()} (graine {SEED})300301**Aucun fichier de ce dossier ne décrit une propriété, une transaction, un locataire ou un immeuble réel.** Les jeux sont produits par302`analysis/python/traite/synth.py` (`python3 -m analysis.python.cases.generate_all`). Les ordres de grandeur (prix médians, loyers, taux303d'inoccupation, taux de capitalisation, taux de taxe, coûts unitaires) sont calibrés sur des publications publiques consignées dans304`calibration.json` et `calibration_journal.csv` ({n_cal} paramètres calibrés avec source, {n_prov} paramètres PROVISOIRES marqués NON_VERIFIE).305Les relations entre variables (élasticités, primes de secteur, décote d'ancienneté des baux, lois de tirage) sont des hypothèses pédagogiques306énoncées ci-dessous ; elles sont vraisemblables mais ne résultent pas d'une estimation sur données réelles.307308## Dossiers309| Fichier | Lignes | Contenu |310|---|---|---|311| 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 |312| cas1_gatineau_sujet.json | 1 | sujet : cottage 1998, 165 m², Hull |313| cas1_gatineau_couts.csv | {len(c1['couts'])} | devis par composantes (quantités × prix unitaires), postes directs/indirects |314| cas2_quebec_ventes_plex.csv | {len(c2['ventes'])} | ventes de 2-3 logements, 5 quartiers, loyers, RNE, MRB, taux |315| cas2_quebec_sujet.json / _loyers.csv / _depenses.csv | 1 / 3 / 8 | triplex 1948 Limoilou : 3 baux, dépenses |316| cas3_montreal_etat_locatif.csv | 12 | état locatif (contrat vs marché, ancienneté) |317| cas3_montreal_depenses.csv | {len(c3['depenses'])} | historique 2023-2025 par poste |318| cas3_montreal_comparables.csv | {len(c3['comparables'])} | ventes 6-30 logements 2024-09 → 2026-08, RNE, taux |319| cas3_montreal_hypotheses_dcf.json | 1 | hypothèses DCF, financement, CAPEX |320| cas4_portefeuille.csv | {len(c4['portefeuille'])} | 48 immeubles, 6 RMR, risques, dette |321| cas4_portefeuille_series.csv | {len(c4['series'])} | RNE trimestriel 2016T1 → 2026T2 |322323## Lois de génération (hypothèses pédagogiques)324- 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 (Hull 0, Gatineau −5 %, Aylmer +8 %, Buckingham −15 %, Masson-Angers −18 %) − 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).325- 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,02 × ancienneté moyenne) ; 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.326- Cas 3 : 12 baux (4 × 3½, 6 × 4½, 2 × 5½), loyers contractuels = marché × 0,975^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).327- 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).328329## Paramètres et provenance330{jour.to_markdown(index=False)}331"""332 (OUT / "README.md").write_text(readme)333 return {"cas1": c1, "cas2": c2, "cas3": c3, "cas4": c4, "journal": jour}334