"""Génération des quatre dossiers de cas SYNTHÉTIQUES du traité. Tout est déterministe (graine 20261031). Les paramètres de calibration proviennent de data/synthetic/calibration.json (valeurs publiques sourcées, voir sources/registry.csv) ; en l'absence d'une valeur, un défaut PROVISOIRE est utilisé et consigné comme NON_VERIFIE dans data/synthetic/README.md. Les relations (élasticités, primes, lois de tirage) sont des HYPOTHÈSES PÉDAGOGIQUES déclarées dans le README — elles ne décrivent aucun bien ni marché réel.""" from __future__ import annotations import json, math, pathlib, datetime import numpy as np import pandas as pd ROOT = pathlib.Path(__file__).resolve().parents[3] OUT = ROOT / "data/synthetic" SEED = 20261031 DATE_EVAL = datetime.date(2026, 9, 1) # date d'évaluation commune des quatre cas class Calibration: """Accès aux paramètres de data/synthetic/calibration.json avec traçabilité. get(chemin, defaut, unite, note, sub=None, f=None) : chemin pointé vers une feuille {valeur, source_id, …} ; sub = clé dans une valeur-dictionnaire ; f = transformation (ex. % → proportion), consignée dans le journal.""" def __init__(self, path=OUT / "calibration.json"): self.raw = json.loads(path.read_text()) if path.exists() else {} self.journal: list[dict] = [] def _leaf(self, chemin: str): node = self.raw for part in chemin.split("."): if not isinstance(node, dict) or part not in node: return None node = node[part] return node if isinstance(node, dict) and "valeur" in node else None def get(self, chemin: str, defaut, unite: str = "", note: str = "", sub: str | None = None, f=None, transformation: str = ""): leaf = self._leaf(chemin) val = None if leaf is not None and leaf.get("valeur") is not None: val = leaf["valeur"] if sub is not None: val = val.get(sub) if isinstance(val, dict) else None if val is not None: out = f(val) if f else val self.journal.append({"parametre": chemin + (f"#{sub}" if sub else ""), "valeur": out, "valeur_source": val, "unite": unite or leaf.get("unite", ""), "source_id": leaf.get("source_id", "?"), "periode": leaf.get("periode", ""), "statut": "calibre", "note": transformation or note}) return out self.journal.append({"parametre": chemin + (f"#{sub}" if sub else ""), "valeur": defaut, "valeur_source": None, "unite": unite, "source_id": "NON_VERIFIE", "periode": "", "statut": "provisoire", "note": note}) return defaut PCT = lambda x: x / 100.0 PI2_VERS_M2 = lambda x: x * 10.7639 def _dates(rng, n, debut=datetime.date(2023, 1, 1), fin=datetime.date(2026, 8, 31)): span = (fin - debut).days d = np.sort(rng.integers(0, span + 1, n)) return [debut + datetime.timedelta(days=int(x)) for x in d] def _mois_depuis(dates, ref=datetime.date(2023, 1, 1)): return np.array([(d.year - ref.year) * 12 + (d.month - ref.month) + (d.day - 1) / 30.0 for d in dates]) # ============================================================================ CAS 1 def cas1_gatineau(cal: Calibration, rng: np.random.Generator) -> dict: n = 600 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") 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") g = (1 + var2 / 100) ** 0.5 - 1 # croissance annuelle composée (2023-07 → 2025-07), calcul de l'auteur secteurs = ["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"] med_sect = {k: cal.get("prix_apciq.rmr_gatineau.unifamiliale_prix_median_par_secteur", d, "$", f"médiane {k}", sub=k) for k, d in [("Aylmer", 530_000), ("Gatineau", 464_900), ("Buckingham/Masson-Angers", 395_000)]} prime_secteur = {"Hull": 0.0, "Gatineau": math.log(med_sect["Gatineau"] / med), "Aylmer": math.log(med_sect["Aylmer"] / med), "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èse p_sect = [0.30, 0.32, 0.20, 0.10, 0.08] sect = rng.choice(secteurs, n, p=p_sect) dates = _dates(rng, n) mois = _mois_depuis(dates) S = np.exp(rng.normal(np.log(140), 0.25, n)).round(0) # superficie habitable m² 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) annee = np.clip(rng.normal(1985, 20, n).round(0), 1920, 2025).astype(int) age = 2026 - annee qualite = np.clip(rng.normal(3, 0.8, n).round(0), 1, 5).astype(int) garage = rng.choice([0, 1, 2], n, p=[0.45, 0.45, 0.10]) sous_sol_fini = rng.binomial(1, 0.6, n) piscine = rng.binomial(1, 0.15, n) chambres = np.clip((S / 45 + rng.normal(0, 0.6, n)).round(0), 1, 6).astype(int) sdb = np.clip((S / 80 + rng.normal(0, 0.5, n)).round(0), 1, 4).astype(int) type_bat = np.where(rng.random(n) < 0.45, "plain-pied", "à étages") # centre de chaque secteur (lat, lon approximatifs du centre urbain, jitter ± ~2-4 km : coordonnées synthétiques) 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)} lat = np.array([centres[s][0] for s in sect]) + rng.normal(0, 0.012, n) lon = np.array([centres[s][1] for s in sect]) + rng.normal(0, 0.020, n) dist_centre_km = np.sqrt(((lat - 45.428) * 111) ** 2 + ((lon + 75.714) * 78) ** 2) # indice de marché : croissance g/an composée, référence = 1er juillet 2024 (date de référence du rôle fictif) ref_mois = _mois_depuis([datetime.date(2024, 7, 1)])[0] ln_index = np.log1p(g) * (mois - ref_mois) / 12 lnP = (np.log(med) + 0.55 * np.log(S / 140) + 0.10 * np.log(T / 560) - 0.0055 * age + 0.000030 * age ** 2 + 0.06 * (qualite - 3) + 0.035 * garage + 0.03 * sous_sol_fini + 0.02 * piscine + np.array([prime_secteur[s] for s in sect]) - 0.006 * np.clip(dist_centre_km - 3, 0, None) + 0.02 * (type_bat == "à étages") + ln_index + rng.normal(0, 0.085, n)) lnP += np.log(med) - np.median(lnP - ln_index) # normalisation : médiane à la date de référence = prix médian calibré prix = (np.exp(lnP) / 500).round(0) * 500 # valeur au rôle 2025-2027 fictif : valeur à la date de référence (2024-07-01) × ratio ~ N(0.93, 0.07) lnP_ref = lnP - ln_index role = (np.exp(lnP_ref) * np.clip(rng.normal(0.93, 0.07, n), 0.7, 1.2) / 100).round(0) * 100 ventes = pd.DataFrame({ "id_vente": [f"G{idx+1:04d}" for idx in range(n)], "date_vente": [d.isoformat() for d in dates], "secteur": sect, "type": type_bat, "superficie_habitable_m2": S, "superficie_terrain_m2": T, "annee_construction": annee, "age": age, "qualite_1a5": qualite, "garage_places": garage, "sous_sol_fini": sous_sol_fini, "piscine": piscine, "chambres": chambres, "salles_de_bain": sdb, "lat": lat.round(5), "lon": lon.round(5), "dist_centre_km": dist_centre_km.round(2), "valeur_role_2025": role, "prix_vente": prix}) sujet = {"adresse_fictive": "123, rue des Érables (fictive), secteur Hull, Gatineau", "secteur": "Hull", "type": "à étages", "superficie_habitable_m2": 165, "superficie_terrain_m2": 620, "annee_construction": 1998, "age": 28, "qualite_1a5": 3, "garage_places": 1, "sous_sol_fini": 1, "piscine": 0, "chambres": 4, "salles_de_bain": 2, "lat": 45.4215, "lon": -75.7395, "dist_centre_km": 2.1, "valeur_role_2025": 468_000, "date_evaluation": DATE_EVAL.isoformat(), "note": "DONNÉES SYNTHÉTIQUES — propriété fictive construite pour le traité"} # devis par composantes (38 postes) : quantités cohérentes avec 165 m² habitables sur 2 étages, empreinte ~ 95 m² postes = [ ("Excavation et remblai", "m³", 420, 28), ("Fondations béton (semelles, murs)", "m³", 38, 520), ("Dalle de sous-sol", "m²", 95, 95), ("Drain français et imperméabilisation", "m lin.", 42, 120), ("Charpente murs et planchers", "m²", 330, 165), ("Fermes de toit", "m²", 110, 95), ("Couverture bardeaux d'asphalte", "m²", 125, 85), ("Revêtement extérieur (brique 40 % / fibrociment 60 %)", "m²", 240, 165), ("Isolation et pare-vapeur", "m²", 420, 32), ("Fenêtres (PVC double vitrage)", "unité", 16, 1450), ("Portes extérieures", "unité", 3, 1900), ("Porte de garage", "unité", 1, 2600), ("Gypse et tirage de joints", "m²", 620, 38), ("Peinture intérieure", "m²", 620, 14), ("Planchers bois franc", "m²", 95, 120), ("Planchers céramique", "m²", 35, 135), ("Planchers flottants (sous-sol)", "m²", 70, 55), ("Escaliers", "unité", 2, 4800), ("Portes intérieures et moulures", "unité", 14, 650), ("Armoires de cuisine et comptoirs", "forfait", 1, 24000), ("Vanités et accessoires (2 sdb)", "forfait", 1, 7500), ("Plomberie brute et finition", "appareil", 14, 1650), ("Chauffe-eau", "unité", 1, 1600), ("Électricité (entrée 200 A, filage, appareils)", "forfait", 1, 19500), ("Ventilation (VRC) et conduits", "forfait", 1, 7800), ("Thermopompe centrale et plinthes", "forfait", 1, 14500), ("Éclairage et finition électrique", "forfait", 1, 4200), ("Gouttières et solins", "m lin.", 48, 45), ("Balcon et perron", "m²", 14, 650), ("Entrée asphaltée", "m²", 55, 65), ("Aménagement paysager de base", "forfait", 1, 6500), ("Raccordements aqueduc/égout", "forfait", 1, 9500), ("Nettoyage et conteneurs", "forfait", 1, 3800), ("Permis et études (arpentage, sol)", "forfait", 1, 6200), ("Supervision de chantier (salaires)", "semaine", 26, 1900), ("Assurances chantier et cautionnement", "forfait", 1, 5200), ("Plans et honoraires professionnels", "forfait", 1, 14000), ("Financement intérimaire", "forfait", 1, 11800), ] alt_min = cal.get("couts_construction.altus_cout_unifamiliale_montreal_pi2", 155, "$/pi²", "Altus 2026 Montréal unifamiliale, borne basse", sub="min") alt_max = cal.get("couts_construction.altus_cout_unifamiliale_montreal_pi2", 215, "$/pi²", "Altus 2026 Montréal unifamiliale, borne haute", sub="max") 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) couts = pd.DataFrame(postes, columns=["poste", "unite", "quantite", "prix_unitaire"]) 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"]) brut = (couts["quantite"] * couts["prix_unitaire"]).sum() echelle = cible * 165 / brut couts["prix_unitaire"] = (couts["prix_unitaire"] * echelle).round(2) couts["montant"] = (couts["quantite"] * couts["prix_unitaire"]).round(2) return {"ventes": ventes, "sujet": sujet, "couts": couts} # ============================================================================ CAS 2 def cas2_quebec(cal: Calibration, rng: np.random.Generator) -> dict: n = 250 med_plex = cal.get("cas_quebec.plex_prix_median_limoilou", 647_500, "$", "prix médian plex secteur Basse-Ville–Limoilou (APCIQ T3 2026)") 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") 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") var3 = cal.get("cas_quebec.role_2025_2027_variation_moyenne_totale", 23.5, "%", "variation moyenne du rôle de Québec sur 3 ans") g = (1 + var3 / 100) ** (1 / 3) - 1 # croissance annuelle composée, calcul de l'auteur quartiers = ["Limoilou", "Saint-Sauveur", "Saint-Roch", "Montcalm", "Maizerets"] prime = {"Limoilou": 0.0, "Saint-Sauveur": -0.06, "Saint-Roch": 0.02, "Montcalm": 0.15, "Maizerets": -0.09} q = rng.choice(quartiers, n, p=[0.35, 0.2, 0.15, 0.1, 0.2]) nb_log = rng.choice([2, 3], n, p=[0.45, 0.55]) dates = _dates(rng, n); mois = _mois_depuis(dates) annee = np.clip(rng.normal(1945, 18, n).round(0), 1900, 2015).astype(int); age = 2026 - annee etat = np.clip(rng.normal(3, 0.8, n).round(0), 1, 5).astype(int) sup_hab = (nb_log * np.exp(rng.normal(np.log(85), 0.15, n))).round(0) stationnement = rng.binomial(1, 0.5, n) # loyers : par logement ~ loyer_2ch × facteur taille × écart contractuel (sous le marché selon l'ancienneté des baux) 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) anciennete_moy = np.clip(rng.exponential(5, n), 0, 20) ecart = -0.015 * anciennete_moy # ~1,5 % par année d'ancienneté sous le marché (hypothèse) loyer_contrat_unit = loyer_marche_unit * (1 + ecart) rbp = loyer_contrat_unit * nb_log * 12 ratio_dep = np.clip(rng.normal(0.36, 0.05, n), 0.25, 0.5) rne = rbp * (1 - inocc) * (1 - ratio_dep) ref_mois = _mois_depuis([datetime.date(2024, 7, 1)])[0] ln_index = np.log1p(g) * (mois - ref_mois) / 12 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) + 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)) + ln_index + rng.normal(0, 0.09, n)) lnP += np.log(med_plex) - np.median(lnP - ln_index) # normalisation à la médiane calibrée (date de référence) prix = (np.exp(lnP) / 1000).round(0) * 1000 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, "nb_logements": nb_log, "superficie_habitable_m2": sup_hab, "annee_construction": annee, "age": age, "etat_1a5": etat, "stationnement": stationnement, "loyer_mensuel_moyen_contrat": loyer_contrat_unit.round(0), "loyer_mensuel_moyen_marche": loyer_marche_unit.round(0), "revenu_brut_potentiel": rbp.round(0), "ratio_depenses": ratio_dep.round(3), "rne": rne.round(0), "prix_vente": prix}) ventes["mrb"] = (ventes["prix_vente"] / ventes["revenu_brut_potentiel"]).round(2) ventes["taux_global"] = (ventes["rne"] / ventes["prix_vente"]).round(4) ventes["prix_par_logement"] = (ventes["prix_vente"] / ventes["nb_logements"]).round(0) loyers = pd.DataFrame([ {"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}, {"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}, {"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}, ]) loyers["loyer_marche_estime"] = [round(loyer_2ch * 1.12), round(loyer_2ch * 1.12), round(loyer_2ch * 0.98)] sujet = {"adresse_fictive": "456, 3e Avenue (fictive), Limoilou, Québec", "quartier": "Limoilou", "nb_logements": 3, "annee_construction": 1948, "age": 78, "etat_1a5": 3, "superficie_habitable_m2": 262, "superficie_terrain_m2": 310, "stationnement": 1, "valeur_role_2025": 495_000, "date_evaluation": DATE_EVAL.isoformat(), "note": "DONNÉES SYNTHÉTIQUES — immeuble fictif"} depenses = pd.DataFrame([ {"poste": "Taxes municipales", "montant_annuel": 5_900, "type": "fixe"}, {"poste": "Taxe scolaire", "montant_annuel": 470, "type": "fixe"}, {"poste": "Assurances", "montant_annuel": 3_400, "type": "fixe"}, {"poste": "Électricité (aires communes)", "montant_annuel": 650, "type": "variable"}, {"poste": "Déneigement et entretien extérieur", "montant_annuel": 1_500, "type": "variable"}, {"poste": "Entretien et réparations", "montant_annuel": 3_200, "type": "variable"}, {"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"}]) return {"ventes": ventes, "sujet": sujet, "loyers": loyers, "depenses": depenses} # ============================================================================ CAS 3 def cas3_montreal(cal: Calibration, rng: np.random.Generator) -> dict: loyer_2ch = cal.get("cas_montreal.loyers_rosemont", 1_348, "$/mois", "loyer moyen 2 ch. zone Rosemont (SCHL oct. 2025)", sub="2_chambres") loyer_1ch = cal.get("cas_montreal.loyers_rosemont", 1_055, "$/mois", "loyer moyen 1 ch. zone Rosemont (SCHL oct. 2025)", sub="1_chambre") loyer_3ch = cal.get("cas_montreal.loyers_rosemont", 1_750, "$/mois", "loyer moyen 3 ch.+ zone Rosemont (SCHL oct. 2025)", sub="3_chambres_plus") 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") 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") cap = 0.5 * (cap_b[0] + cap_b[1]) / 100 # milieu de fourchette, calcul de l'auteur 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") 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") taux_scolaire = cal.get("taux.taxe_scolaire_2026_2027", 0.07899, "$/100 $", "taxe scolaire 2026-2027", f=lambda x: x / 100, transformation="$/100 $ → proportion") 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") # état locatif : 12 logements, bâtiment 1962, 3 étages, Rosemont mix = [("3½", 1, loyer_1ch)] * 4 + [("4½", 2, loyer_2ch)] * 6 + [("5½", 3, loyer_3ch)] * 2 rows = [] for i, (p, ch, lm) in enumerate(mix, 1): 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])) marche = lm * float(np.exp(rng.normal(0, 0.05))) contrat = marche * (1 - 0.02) ** anc 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, "superficie_m2": {1: 52, 2: 72, 3: 95}[ch] + int(rng.integers(-4, 5)), "loyer_mensuel_contrat": round(contrat), "loyer_mensuel_marche": round(marche), "locataire_depuis": 2026 - anc, "echeance_bail": "2027-06-30", "chauffage_inclus": 0, "stationnement": int(rng.random() < 0.5)}) etat_locatif = pd.DataFrame(rows) rbp = etat_locatif["loyer_mensuel_contrat"].sum() * 12 autres = 12 * (6 * 60 + 12 * 35) # stationnement (6 places × 60 $), buanderie (35 $/log.) valeur_role = 2_200_000 dep = { "Taxes municipales": valeur_role * taux_taxe, "Taxe scolaire": valeur_role * taux_scolaire, "Assurances": 11_500, "Électricité (aires communes)": 3_900, "Chauffage (aires communes, gaz)": 2_600, "Eau et égouts (taxe)": 0, "Entretien et réparations": 0.065 * rbp, "Conciergerie": 9_600, "Déneigement et paysagement": 6_200, "Gestion (4 % du RBE)": None, "Frais juridiques et comptables": 2_400, "Publicité et location": 1_200, "Réserve de remplacement (par logement)": 12 * 650} hist = [] for an, infl in [(2023, 0.0), (2024, 0.045), (2025, 0.03)]: for poste, m in dep.items(): if m is None: continue base = m * (1 + infl) ** (an - 2023) * float(np.exp(rng.normal(0, 0.04))) if m else 0 hist.append({"annee": an, "poste": poste, "montant": round(base)}) depenses = pd.DataFrame(hist) # comparables : 40 ventes 6-30 logements, 2024-2026, Rosemont/Villeray/Plateau/Hochelaga/Ahuntsic n = 40 quart = rng.choice(["Rosemont", "Villeray", "Plateau", "Hochelaga", "Ahuntsic"], n, p=[0.3, 0.25, 0.15, 0.15, 0.15]) prime = {"Rosemont": 0.0, "Villeray": -0.01, "Plateau": 0.06, "Hochelaga": -0.07, "Ahuntsic": -0.03} nlog = rng.integers(6, 31, n) dates = _dates(rng, n, datetime.date(2024, 9, 1), datetime.date(2026, 8, 31)) annee = np.clip(rng.normal(1960, 15, n).round(0), 1900, 2010).astype(int) etat = np.clip(rng.normal(3, 0.7, n).round(0), 1, 5).astype(int) loyer_moy = loyer_2ch * np.exp(rng.normal(-0.05, 0.1, n)) * (1 + 0.04 * (etat - 3)) rbp_c = loyer_moy * nlog * 12 ratio = np.clip(rng.normal(0.40, 0.04, n), 0.3, 0.52) rne_c = rbp_c * (1 - inocc) * (1 - ratio) 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) prix_c = (rne_c / taux_c / 1000).round(0) * 1000 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, "annee_construction": annee, "etat_1a5": etat, "loyer_mensuel_moyen": loyer_moy.round(0), "revenu_brut_potentiel": rbp_c.round(0), "ratio_depenses": ratio.round(3), "rne": rne_c.round(0), "prix_vente": prix_c}) comps["taux_global"] = (comps["rne"] / comps["prix_vente"]).round(4) comps["prix_par_logement"] = (comps["prix_vente"] / comps["nb_logements"]).round(0) comps["mrb"] = (comps["prix_vente"] / comps["revenu_brut_potentiel"]).round(2) hypotheses = {"date_evaluation": DATE_EVAL.isoformat(), "horizon_ans": 10, "taux_inoccupation_pertes": inocc + 0.01, "croissance_loyers_contrat": pct_tal, "croissance_loyers_marche": 0.035, "rotation_annuelle": 0.15, "croissance_depenses": 0.03, "taux_actualisation": 0.0725, "taux_terminal": cap + 0.005, "couts_vente": 0.025, "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}, "autres_revenus_annuels": autres, "valeur_role_2026": valeur_role, "taux_taxe_fonciere": taux_taxe, "taux_taxe_scolaire": taux_scolaire, "note": "HYPOTHÈSES SYNTHÉTIQUES — voir data/synthetic/README.md pour la provenance de chaque paramètre"} sujet = {"adresse_fictive": "789, rue Fictive, Rosemont–La Petite-Patrie, Montréal", "nb_logements": 12, "annee_construction": 1962, "etages": 3, "superficie_terrain_m2": 740, "superficie_locative_m2": int(etat_locatif["superficie_m2"].sum()), "stationnements": 6, "etat_1a5": 3, "valeur_role_2026": valeur_role, "date_evaluation": DATE_EVAL.isoformat(), "note": "DONNÉES SYNTHÉTIQUES — immeuble fictif"} return {"etat_locatif": etat_locatif, "depenses": depenses, "comparables": comps, "hypotheses": hypotheses, "sujet": sujet} # ============================================================================ CAS 4 def cas4_portefeuille(cal: Calibration, rng: np.random.Generator) -> dict: 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") 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") 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) / 100 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) / 100 cap_autres = cal.get("cas_portefeuille_6_regions.cap_rate_autres_rmr", None, "%", "taux de capitalisation hors Montréal/Québec (non publié)") # hypothèse pédagogique pour les RMR sans enquête publique : prime sur le taux de Québec (taille/liquidité) primes = {"Gatineau": 0.0, "Sherbrooke": 0.0025, "Trois-Rivières": 0.0075, "Saguenay": 0.0100} cap_x = lambda r: (cap_autres / 100) if cap_autres else cap_qc + primes[r] rmr = {"Montréal": (45.52, -73.60, L("montreal", 1_346), I("montreal", 2.9), cap_mtl), "Québec": (46.81, -71.22, L("quebec", 1_277), I("quebec", 2.4), cap_qc), "Gatineau": (45.47, -75.70, L("gatineau", 1_460), I("gatineau", 3.8), cap_x("Gatineau")), "Sherbrooke": (45.40, -71.89, L("sherbrooke", 1_062), I("sherbrooke", 2.7), cap_x("Sherbrooke")), "Trois-Rivières": (46.35, -72.55, L("trois_rivieres", 1_016), I("trois_rivieres", 2.7), cap_x("Trois-Rivières")), "Saguenay": (48.43, -71.07, L("saguenay", 985), I("saguenay", 1.3), cap_x("Saguenay"))} n = 48 villes = rng.choice(list(rmr), n, p=[0.35, 0.2, 0.15, 0.1, 0.1, 0.1]) rows = [] for i, v in enumerate(villes, 1): lat0, lon0, l2, inocc, capv = rmr[v] 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])) annee = int(np.clip(rng.normal(1972, 18), 1920, 2018)); etat = int(np.clip(round(rng.normal(3, 0.8)), 1, 5)) loyer = l2 * float(np.exp(rng.normal(-0.03, 0.1))) * (1 + 0.04 * (etat - 3)) 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) zone_inond = int(rng.random() < 0.08); sol = int(rng.random() < 0.05) taux = capv * float(np.exp(rng.normal(0, 0.06))) * (1 - 0.03 * (etat - 3)) * (1 + 0.05 * zone_inond) * (1 + 0.03 * sol) valeur = rne / taux dette = valeur * float(np.clip(rng.normal(0.6, 0.12), 0.2, 0.8)) 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, "annee_construction": annee, "age": 2026 - annee, "etat_1a5": etat, "loyer_mensuel_moyen": round(loyer), "revenu_brut_potentiel": round(rbp), "taux_inoccupation_marche": inocc, "ratio_depenses": round(ratio, 3), "rne_2026": round(rne), "zone_inondable": zone_inond, "contamination_sol": sol, "taux_global": round(taux, 4), "valeur_2026": round(valeur / 1000) * 1000, "dette": round(dette / 1000) * 1000, "taux_dette": round(float(rng.normal(0.045, 0.006)), 4), "echeance_dette": int(rng.integers(2027, 2032))}) pf = pd.DataFrame(rows) # séries trimestrielles de RNE 2016T1-2026T2 : tendance régionale commune + choc idiosyncrasique trims = pd.period_range("2016Q1", "2026Q2", freq="Q") g_reg = {"Montréal": 0.035, "Québec": 0.03, "Gatineau": 0.04, "Sherbrooke": 0.03, "Trois-Rivières": 0.025, "Saguenay": 0.02} ser = [] for _, r in pf.iterrows(): t = np.arange(len(trims)); g = g_reg[r["rmr"]] base = r["rne_2026"] / (1 + g) ** ((len(trims) - 1) / 4) chocs = np.cumsum(rng.normal(0, 0.015, len(trims))) pandemie = np.where((trims >= "2020Q2") & (trims <= "2021Q2"), -0.03, 0) x = base * (1 + g) ** (t / 4) * np.exp(chocs - chocs[-1] + pandemie) / 4 for tt, val in zip(trims, x): ser.append({"id_immeuble": r["id_immeuble"], "trimestre": str(tt), "rne_trimestriel": round(val)}) series = pd.DataFrame(ser) return {"portefeuille": pf, "series": series} # ============================================================================ écriture def ecrire_tout() -> dict: OUT.mkdir(parents=True, exist_ok=True) cal = Calibration() rng = np.random.default_rng(SEED) c1 = cas1_gatineau(cal, rng); c2 = cas2_quebec(cal, rng); c3 = cas3_montreal(cal, rng); c4 = cas4_portefeuille(cal, rng) 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)) c1["couts"].to_csv(OUT / "cas1_gatineau_couts.csv", index=False) 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)) c2["loyers"].to_csv(OUT / "cas2_quebec_loyers.csv", index=False); c2["depenses"].to_csv(OUT / "cas2_quebec_depenses.csv", index=False) c3["etat_locatif"].to_csv(OUT / "cas3_montreal_etat_locatif.csv", index=False); c3["depenses"].to_csv(OUT / "cas3_montreal_depenses.csv", index=False) c3["comparables"].to_csv(OUT / "cas3_montreal_comparables.csv", index=False) (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)) c4["portefeuille"].to_csv(OUT / "cas4_portefeuille.csv", index=False); c4["series"].to_csv(OUT / "cas4_portefeuille_series.csv", index=False) # journal de calibration dédoublonné seen = {}; for j in cal.journal: seen.setdefault(j["parametre"], j) jour = pd.DataFrame(list(seen.values())) jour["valeur"] = jour["valeur"].map(lambda x: round(x, 6) if isinstance(x, float) else x) jour.to_csv(OUT / "calibration_journal.csv", index=False) n_cal = int((jour["statut"] == "calibre").sum()); n_prov = int((jour["statut"] == "provisoire").sum()) readme = f"""# Données SYNTHÉTIQUES du traité — générées le {datetime.date.today().isoformat()} (graine {SEED}) **Aucun fichier de ce dossier ne décrit une propriété, une transaction, un locataire ou un immeuble réel.** Les jeux sont produits par `analysis/python/traite/synth.py` (`python3 -m analysis.python.cases.generate_all`). Les ordres de grandeur (prix médians, loyers, taux d'inoccupation, taux de capitalisation, taux de taxe, coûts unitaires) sont calibrés sur des publications publiques consignées dans `calibration.json` et `calibration_journal.csv` ({n_cal} paramètres calibrés avec source, {n_prov} paramètres PROVISOIRES marqués NON_VERIFIE). Les relations entre variables (élasticités, primes de secteur, décote d'ancienneté des baux, lois de tirage) sont des hypothèses pédagogiques énoncées ci-dessous ; elles sont vraisemblables mais ne résultent pas d'une estimation sur données réelles. ## Dossiers | Fichier | Lignes | Contenu | |---|---|---| | 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 | | cas1_gatineau_sujet.json | 1 | sujet : cottage 1998, 165 m², Hull | | cas1_gatineau_couts.csv | {len(c1['couts'])} | devis par composantes (quantités × prix unitaires), postes directs/indirects | | cas2_quebec_ventes_plex.csv | {len(c2['ventes'])} | ventes de 2-3 logements, 5 quartiers, loyers, RNE, MRB, taux | | cas2_quebec_sujet.json / _loyers.csv / _depenses.csv | 1 / 3 / 8 | triplex 1948 Limoilou : 3 baux, dépenses | | cas3_montreal_etat_locatif.csv | 12 | état locatif (contrat vs marché, ancienneté) | | cas3_montreal_depenses.csv | {len(c3['depenses'])} | historique 2023-2025 par poste | | cas3_montreal_comparables.csv | {len(c3['comparables'])} | ventes 6-30 logements 2024-09 → 2026-08, RNE, taux | | cas3_montreal_hypotheses_dcf.json | 1 | hypothèses DCF, financement, CAPEX | | cas4_portefeuille.csv | {len(c4['portefeuille'])} | 48 immeubles, 6 RMR, risques, dette | | cas4_portefeuille_series.csv | {len(c4['series'])} | RNE trimestriel 2016T1 → 2026T2 | ## Lois de génération (hypothèses pédagogiques) - 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). - 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. - 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). - 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). ## Paramètres et provenance {jour.to_markdown(index=False)} """ (OUT / "README.md").write_text(readme) return {"cas1": c1, "cas2": c2, "cas3": c3, "cas4": c4, "journal": jour}