"""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 avec traçabilité (valeur, source, provisoire ou non).""" def __init__(self, path=OUT / "calibration.json"): self.raw = json.loads(path.read_text()) if path.exists() else {} self.journal: list[dict] = [] def get(self, key: str, defaut, unite: str = "", note: str = ""): item = self.raw.get(key) if isinstance(item, dict) and item.get("valeur") is not None: self.journal.append({"parametre": key, "valeur": item["valeur"], "unite": item.get("unite", unite), "source_id": item.get("source_id", "?"), "periode": item.get("periode", ""), "statut": "calibre"}) return item["valeur"] self.journal.append({"parametre": key, "valeur": defaut, "unite": unite, "source_id": "NON_VERIFIE", "periode": "", "statut": "provisoire", "note": note}) return defaut 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_median_unifamiliale_rmr_gatineau", 520_000, "$", "prix médian unifamiliale RMR Gatineau (APCIQ)") g = cal.get("croissance_annuelle_prix_gatineau", 0.07, "proportion", "variation annuelle du prix médian") secteurs = ["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"] prime_secteur = {"Hull": 0.00, "Gatineau": -0.05, "Aylmer": 0.08, "Buckingham": -0.15, "Masson-Angers": -0.18} # hypothèse pédagogique 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), ] cible = cal.get("cout_construction_unifamiliale_par_m2_quebec", 2_900, "$/m² habitable", "coût neuf typique unifamiliale (tout compris)") 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("prix_median_plex_rmr_quebec", 560_000, "$", "prix médian plex 2-5 log. RMR Québec (APCIQ)") loyer_2ch = cal.get("loyer_moyen_2ch_rmr_quebec", 1_200, "$/mois", "loyer moyen 2 ch. RMR Québec (SCHL)") inocc = cal.get("taux_inoccupation_rmr_quebec", 0.01, "proportion", "taux d'inoccupation RMR Québec (SCHL)") g = cal.get("croissance_annuelle_prix_quebec", 0.10, "proportion", "variation annuelle du prix médian plex Québec") 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, 25) ecart = -0.02 * anciennete_moy # ~2 % 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("loyer_moyen_2ch_rmr_montreal", 1_250, "$/mois", "loyer moyen 2 ch. RMR Montréal (SCHL)") loyer_1ch = cal.get("loyer_moyen_1ch_rmr_montreal", 1_050, "$/mois", "loyer moyen 1 ch. RMR Montréal (SCHL)") loyer_3ch = cal.get("loyer_moyen_3ch_rmr_montreal", 1_550, "$/mois", "loyer moyen 3 ch.+ RMR Montréal (SCHL)") inocc = cal.get("taux_inoccupation_rmr_montreal", 0.02, "proportion", "taux d'inoccupation RMR Montréal (SCHL)") cap = cal.get("taux_capitalisation_multires_montreal", 0.0475, "proportion", "taux de capitalisation multirésidentiel Montréal (enquête publique)") taux_hyp = cal.get("taux_hypothecaire_multires_assure", 0.042, "proportion", "taux assuré SCHL 5 ans (indicatif)") taux_taxe = cal.get("taux_taxe_fonciere_montreal_residentiel_2026", 0.0065, "$/$ d'évaluation", "taux global résidentiel Montréal") taux_scolaire = cal.get("taux_taxe_scolaire_2025_2026", 0.0009152, "$/$ d'évaluation", "taux unique de la taxe scolaire 2025-2026") pct_tal = cal.get("tal_pourcentage_base_2026", 0.039, "proportion", "pourcentage de base TAL 2026 (logements non chauffés)") # é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.025) ** anc * (1 if anc else 1.0) 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_650_000 dep = { "Taxes municipales": valeur_role * taux_taxe, "Taxe scolaire": valeur_role * taux_scolaire, "Assurances": 14_800, "É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: 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)), "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")), "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), "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), "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), "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)} 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.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 (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). - 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. - 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). - 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}