Repository Public Research preview
1#!/usr/bin/env python32# -*- coding: utf-8 -*-3"""4verification_calculs.py — moteur de calcul et de vérification des trois séances refondues5(IMM1003 S08, IMM1003 S09, IMM1033 S08), UQO, automne 2026.67Rôle : SOURCE UNIQUE des chiffres utilisés dans les diapositives, les scripts, les exercices et les corrigés.8 1. Recalcule les cas pédagogiques (bungalow de Gatineau, atelier à cinq comparables, six logements de Gatineau).9 2. Vérifie les calculs des decks originaux (sommes, pourcentages, bases, arrondis, net/brut, intérêts, profit…).10 3. Produit `resultats.json` (chiffres) et `RESULTATS_CALCULS.md` (tableaux lisibles), plus la liste des corrections.1112Conventions d'arrondi : montants au dollar (ROUND_HALF_UP) à chaque ligne affichée ; pourcentages à 0,1 %.13Usage : python3 verification_calculs.py [--json chemin] [--md chemin]14"""15from __future__ import annotations16import json, sys, math, datetime as dt17from decimal import Decimal, ROUND_HALF_UP18from pathlib import Path1920HERE = Path(__file__).resolve().parent2122def r0(x: float) -> int:23 return int(Decimal(str(x)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))2425def r1(x: float) -> float:26 return float(Decimal(str(x)).quantize(Decimal("0.1"), rounding=ROUND_HALF_UP))2728def r2(x: float) -> float:29 return float(Decimal(str(x)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))3031def arrondi(x: float, pas: int = 5000) -> int:32 return int(pas * Decimal(str(x / pas)).quantize(Decimal("1"), rounding=ROUND_HALF_UP))3334def mois_ecoules(vente: dt.date, evaluation: dt.date) -> int:35 return (evaluation.year - vente.year) * 12 + (evaluation.month - vente.month)3637CHECKS: list[dict] = [] # vérifications des decks originaux38def check(doc: str, libelle: str, attendu, obtenu, tol: float = 0.5, note: str = ""):39 ok = abs(float(attendu) - float(obtenu)) <= tol40 CHECKS.append({"document": doc, "element": libelle, "valeur_deck": attendu, "valeur_recalculee": obtenu,41 "exact": ok, "note": note})42 return ok4344# =============================================================================45# 1. CATALOGUE D'AJUSTEMENTS DU COURS (IMM1003, séances 8 et 9) — convention pédagogique46# Aligné sur les conventions de la firme du TP2 officiel (temps 0,17 %/mois ; aire 150 $/pi² ; lot 6 $/pi² au-delà47# de 1 000 pi² d'écart ; garage 30 000 $ ; sous-sol aménagé 20 000 $ ; salle de bains 12 000 $ ; piscine 25 000 $).48# =============================================================================49CATALOGUE = {50 "temps_pct_par_mois": 0.17, # +0,17 %/mois ≈ +2 %/an (APCIQ, unifamiliale RMR de Gatineau, 2026) — prorata simple51 "aire_par_pi2": 150, # valeur contributive marginale de l'aire habitable ($/pi² d'écart)52 "lot_par_pi2": 6, # $/pi² d'écart de terrain53 "lot_seuil_pi2": 1000, # aucun ajustement si |écart| < 1 000 pi²54 "garage_simple_vs_aucun": 30000,55 "garage_double_vs_simple": 15000,56 "soussol_fini_vs_non_fini": 20000,57 "soussol_fini_vs_partiel": 10000, # donc partiel vs non fini = 10 000 $58 "sdb_complete": 12000,59 "salle_eau": 6000,60 "chambre": 10000,61 "age_par_an": 2000,62 "etat": {"très bon": -8000, "bon": 0, "moyen": 12000}, # ajustement si le COMPARABLE est dans cet état et le sujet « bon »63 "localisation_pct": 5.0, # ± 5 % du prix ajusté transactionnel (analyse par paires)64 "piscine_creusee": 25000,65}66GARAGE_RANG = {"aucun": 0, "simple": 1, "double": 2}67SOUSSOL_RANG = {"non fini": 0, "partiel": 1, "fini": 2}6869def valeur_garage(niveau_sujet: str, niveau_comp: str) -> int:70 """Ajustement au comparable pour le garage (comparable → sujet)."""71 def v(n): # valeur cumulée d'un niveau par rapport à « aucun »72 return {0: 0, 1: CATALOGUE["garage_simple_vs_aucun"],73 2: CATALOGUE["garage_simple_vs_aucun"] + CATALOGUE["garage_double_vs_simple"]}[GARAGE_RANG[n]]74 return v(niveau_sujet) - v(niveau_comp)7576def valeur_soussol(sujet: str, comp: str) -> int:77 def v(n):78 return {0: 0, 1: CATALOGUE["soussol_fini_vs_non_fini"] - CATALOGUE["soussol_fini_vs_partiel"],79 2: CATALOGUE["soussol_fini_vs_non_fini"]}[SOUSSOL_RANG[n]]80 return v(sujet) - v(comp)8182def valeur_sdb(sujet: tuple[int, int], comp: tuple[int, int]) -> int:83 """(complètes, salles d'eau)"""84 return (sujet[0] - comp[0]) * CATALOGUE["sdb_complete"] + (sujet[1] - comp[1]) * CATALOGUE["salle_eau"]8586def grille(sujet: dict, comp: dict, date_eval: dt.date) -> dict:87 """Applique la séquence du cours : 5 ajustements transactionnels en séquence, puis 3 ajustements de propriété additifs.88 Signe : on ajuste le COMPARABLE vers le SUJET (comparable supérieur → négatif)."""89 rows = []90 prix = comp["prix"]91 cour = prix92 # --- transactionnels (séquence) ---93 droits = comp.get("droits", 0); cour += droits; rows.append(("1. Droits de propriété", droits, cour))94 fin = -comp.get("financement_avantage", 0); cour += fin; rows.append(("2. Conditions de financement", fin, cour))95 cond = comp.get("conditions_vente_pct", 0.0); m_cond = r0(cour * cond / 100); cour += m_cond96 rows.append((f"3. Conditions de vente ({cond:+.1f} %)" if cond else "3. Conditions de vente", m_cond, cour))97 dep = comp.get("depenses_apres_achat", 0); cour += dep; rows.append(("4. Dépenses après achat", dep, cour))98 mois = mois_ecoules(comp["date"], date_eval)99 t = r0(cour * CATALOGUE["temps_pct_par_mois"] / 100 * mois); cour += t100 rows.append((f"5. Temps ({mois} mois × 0,17 %)", t, cour))101 prix_trans = cour102 # --- propriété (additifs sur le prix ajusté transactionnel) ---103 prop = []104 loc = comp.get("localisation", "similaire")105 loc_pct = {"similaire": 0.0, "inférieure": CATALOGUE["localisation_pct"], "supérieure": -CATALOGUE["localisation_pct"]}[loc]106 prop.append((f"6. Localisation ({loc}, {loc_pct:+.0f} %)" if loc_pct else "6. Localisation (similaire)", r0(prix_trans * loc_pct / 100)))107 d_aire = comp["aire"] - sujet["aire"]108 prop.append((f"7a. Aire habitable ({d_aire:+d} pi² × 150 $)", -d_aire * CATALOGUE["aire_par_pi2"]))109 d_lot = comp["lot"] - sujet["lot"]110 lot_adj = -d_lot * CATALOGUE["lot_par_pi2"] if abs(d_lot) >= CATALOGUE["lot_seuil_pi2"] else 0111 prop.append((f"7b. Terrain ({d_lot:+d} pi²{'' if abs(d_lot) >= CATALOGUE['lot_seuil_pi2'] else ', < 1 000'})", lot_adj))112 d_age = comp["annee"] - sujet["annee"] # >0 : comparable plus récent → négatif113 prop.append((f"7c. Âge ({d_age:+d} an{'s' if abs(d_age) > 1 else ''} × 2 000 $)", -d_age * CATALOGUE["age_par_an"]))114 prop.append((f"7d. État ({comp['etat']})", CATALOGUE["etat"][comp["etat"]] - CATALOGUE["etat"][sujet["etat"]]))115 d_ch = comp["chambres"] - sujet["chambres"]116 prop.append((f"7e. Chambres ({d_ch:+d})", -d_ch * CATALOGUE["chambre"]))117 prop.append((f"7f. Salles de bains ({comp['sdb'][0]} c. + {comp['sdb'][1]} s.e. vs {sujet['sdb'][0]} + {sujet['sdb'][1]})", valeur_sdb(sujet["sdb"], comp["sdb"])))118 prop.append((f"7g. Sous-sol ({comp['soussol']} vs {sujet['soussol']})", valeur_soussol(sujet["soussol"], comp["soussol"])))119 prop.append((f"7h. Garage ({comp['garage']} vs {sujet['garage']})", valeur_garage(sujet["garage"], comp["garage"])))120 pisc = (int(sujet.get("piscine", False)) - int(comp.get("piscine", False))) * CATALOGUE["piscine_creusee"]121 if sujet.get("piscine") or comp.get("piscine"):122 prop.append(("7i. Piscine creusée", pisc))123 nonimm = -comp.get("non_immobilier", 0)124 prop.append(("8. Composantes non immobilières", nonimm))125 total_prop = sum(v for _, v in prop)126 indication = prix_trans + total_prop127 ajustements = [v for _, v, _ in rows] + [v for _, v in prop]128 net = sum(ajustements); brut = sum(abs(v) for v in ajustements)129 return {130 "nom": comp["nom"], "prix": prix, "mois": mois, "transactionnels": rows, "prix_ajuste_transactionnel": prix_trans,131 "propriete": prop, "total_propriete": total_prop, "indication": indication,132 "net": net, "net_pct": r1(net / prix * 100), "brut": brut, "brut_pct": r1(brut / prix * 100),133 "seuil_net_ok": abs(net) / prix <= 0.15, "seuil_brut_ok": brut / prix <= 0.25,134 "prix_pi2": r0(prix / comp["aire"]), "indication_pi2": r0(indication / sujet["aire"]),135 }136137def reconcilier(grilles: list[dict], poids: dict[str, float]) -> dict:138 assert abs(sum(poids.values()) - 100) < 1e-9, "les poids doivent totaliser 100"139 vp = sum(g["indication"] * poids[g["nom"]] / 100 for g in grilles)140 vals = [g["indication"] for g in grilles]141 moy = sum(vals) / len(vals)142 med = sorted(vals)[len(vals) // 2] if len(vals) % 2 else (sorted(vals)[len(vals)//2 - 1] + sorted(vals)[len(vals)//2]) / 2143 return {"poids": poids, "valeur_ponderee": r0(vp), "valeur_conclue": arrondi(vp), "moyenne_simple": r0(moy),144 "moyenne_arrondie": arrondi(moy), "mediane": r0(med), "min": min(vals), "max": max(vals),145 "etendue_pct": r1((max(vals) - min(vals)) / vp * 100)}146147# =============================================================================148# 2. IMM1003 S08 — ÉTUDE DE CAS : bungalow, Gatineau (secteur Hull), date d'évaluation 1er novembre 2026149# =============================================================================150DATE_EVAL = dt.date(2026, 11, 1)151SUJET_S8 = {"nom": "Sujet", "type": "bungalow", "annee": 2008, "aire": 1200, "lot": 6000, "chambres": 3, "sdb": (1, 1),152 "soussol": "partiel", "garage": "simple", "etat": "bon", "localisation": "Hull"}153COMPS_S8 = [154 {"nom": "C1", "prix": 435000, "date": dt.date(2026, 7, 1), "localisation": "similaire", "annee": 2007, "aire": 1250,155 "lot": 6200, "chambres": 3, "sdb": (1, 1), "soussol": "fini", "garage": "simple", "etat": "bon"},156 {"nom": "C2", "prix": 415000, "date": dt.date(2026, 5, 1), "localisation": "similaire", "annee": 2010, "aire": 1150,157 "lot": 5500, "chambres": 3, "sdb": (1, 0), "soussol": "non fini", "garage": "aucun", "etat": "bon", "non_immobilier": 4000},158 {"nom": "C3", "prix": 460000, "date": dt.date(2026, 8, 1), "localisation": "supérieure", "annee": 2005, "aire": 1350,159 "lot": 7000, "chambres": 4, "sdb": (2, 0), "soussol": "fini", "garage": "double", "etat": "très bon"},160 {"nom": "C4", "prix": 425000, "date": dt.date(2026, 3, 1), "localisation": "similaire", "annee": 2008, "aire": 1200,161 "lot": 6000, "chambres": 3, "sdb": (1, 1), "soussol": "partiel", "garage": "simple", "etat": "moyen"},162]163POIDS_S8 = {"C1": 40, "C4": 35, "C2": 20, "C3": 5}164165# Exercice « 4 parmi 8 » (bloc 2) : candidates à filtrer — scores de similarité166CANDIDATS_S8 = [167 # nom, secteur, type, date, prix, aire, remarque, décision attendue168 ("A", "Hull", "bungalow", "juil. 2026", 435000, 1250, "vente libre, vérifiée", "retenir (= C1)"),169 ("B", "Hull", "bungalow", "mai 2026", 415000, 1150, "inclut électroménagers 4 000 $", "retenir avec ajustement (= C2)"),170 ("C", "Hull", "bungalow", "août 2026", 460000, 1350, "secteur supérieur, 4 ch., garage double", "retenir, poids faible (= C3)"),171 ("D", "Hull", "bungalow", "mars 2026", 425000, 1200, "état moyen", "retenir (= C4)"),172 ("E", "Hull", "bungalow", "sept. 2026", 380000, 1180, "vente entre parents (lien de dépendance)", "écarter : non quantifiable"),173 ("F", "Aylmer", "bungalow", "juin 2026", 505000, 1210, "quartier différent, prix supérieur", "écarter : localisation non comparable (ou poids très faible)"),174 ("G", "Hull", "2 étages", "juil. 2026", 489000, 1700, "type différent", "écarter : type et taille"),175 ("H", "Hull", "bungalow", "oct. 2024", 398000, 1220, "vente il y a 25 mois", "écarter : trop ancienne (ajustement de temps dominant)"),176]177178# =============================================================================179# 3. IMM1003 S09 — ATELIER : unifamiliale 2 étages, Gatineau (Hull), cinq comparables, 1er novembre 2026180# =============================================================================181SUJET_S9 = {"nom": "Sujet", "type": "2 étages", "annee": 2005, "aire": 1500, "lot": 5500, "chambres": 3, "sdb": (2, 0),182 "soussol": "fini", "garage": "double", "etat": "bon", "localisation": "Hull", "role_2025": 385000,183 "role_terrain": 95000, "role_batiment": 290000}184185def equivalence_comptant(solde: float, taux_contrat: float, taux_marche: float, annees: int) -> dict:186 """Solde de prix de vente à intérêts seulement, capital remboursé à l'échéance : avantage = solde − VA(paiements au taux du marché)."""187 i_c, i_m = taux_contrat / 100, taux_marche / 100188 interets = solde * i_c189 facteur_annuite = (1 - (1 + i_m) ** -annees) / i_m190 va = interets * facteur_annuite + solde / (1 + i_m) ** annees191 return {"solde": solde, "taux_contrat": taux_contrat, "taux_marche": taux_marche, "annees": annees,192 "interets_annuels": r0(interets), "facteur_annuite": r2(r2(facteur_annuite)), "va_capital": r0(solde / (1 + i_m) ** annees),193 "va_interets": r0(interets * facteur_annuite), "valeur_actuelle": r0(va), "avantage": r0(solde - va)}194195FIN_C1 = equivalence_comptant(100000, 2.0, 5.0, 3) # avantage ≈ 8 170 $196197COMPS_S9 = [198 {"nom": "C1", "prix": 410000, "date": dt.date(2026, 2, 1), "localisation": "similaire", "annee": 2003, "aire": 1580,199 "lot": 5000, "chambres": 3, "sdb": (2, 0), "soussol": "non fini", "garage": "simple", "etat": "bon",200 "financement_avantage": FIN_C1["avantage"], "particularite": "solde de prix de vente 100 000 $ à 2 % (3 ans, intérêts seulement) ; marché 5 %"},201 {"nom": "C2", "prix": 445000, "date": dt.date(2026, 7, 1), "localisation": "similaire", "annee": 2006, "aire": 1480,202 "lot": 5200, "chambres": 3, "sdb": (1, 0), "soussol": "fini", "garage": "double", "etat": "très bon"},203 {"nom": "C3", "prix": 425000, "date": dt.date(2026, 4, 1), "localisation": "similaire", "annee": 2005, "aire": 1520,204 "lot": 6000, "chambres": 3, "sdb": (2, 0), "soussol": "fini", "garage": "double", "etat": "bon",205 "depenses_apres_achat": 15000, "particularite": "toiture à refaire connue de l'acheteur : 15 000 $"},206 {"nom": "C4", "prix": 460000, "date": dt.date(2026, 8, 1), "localisation": "supérieure", "annee": 2008, "aire": 1650,207 "lot": 7000, "chambres": 4, "sdb": (2, 0), "soussol": "fini", "garage": "double", "etat": "très bon",208 "particularite": "secteur Aylmer (paire de ventes : +5 % vs Hull)"},209 {"nom": "C5", "prix": 438000, "date": dt.date(2026, 5, 1), "localisation": "similaire", "annee": 2004, "aire": 1500,210 "lot": 5500, "chambres": 3, "sdb": (2, 0), "soussol": "partiel", "garage": "simple", "etat": "moyen",211 "non_immobilier": 6000, "particularite": "inclut électroménagers et spa : 6 000 $"},212]213POIDS_S9 = {"C2": 30, "C3": 30, "C1": 20, "C5": 15, "C4": 5}214POIDS_S9_SANS_C4 = {"C2": 32, "C3": 32, "C1": 21, "C5": 15, "C4": 0}215216# Démonstrations S9 ----------------------------------------------------------217PAIRES = {218 "garage_double_vs_simple": {"A (double)": 452000, "B (simple)": 437000},219 "localisation_aylmer_vs_hull": {"Aylmer": 472000, "Hull": 449000},220 "soussol_amenage": {"aménagé": 441000, "non aménagé": 421000},221}222REVENTE = {"prix_2024": 410000, "date_2024": "1er nov. 2024", "prix_2026": 426600, "date_2026": "1er nov. 2026", "mois": 24}223CAPITALISATION = {"loyer_mensuel_garage": 150, "taux": 0.07}224# Régression (démonstration) : 12 ventes de bungalows à Hull, prix vs aire habitable (données pédagogiques fixes)225REG_AIRES = [1050, 1100, 1120, 1150, 1180, 1200, 1220, 1250, 1280, 1300, 1350, 1400]226REG_RESID = [-9000, 6500, -4000, 11000, -7500, 2000, 9500, -11000, 4000, -3000, 7000, -5500]227REG_PRIX = [r0(250000 + 150 * a + e) for a, e in zip(REG_AIRES, REG_RESID)]228229def ols(x: list[float], y: list[float]) -> dict:230 n = len(x); mx = sum(x) / n; my = sum(y) / n231 sxx = sum((xi - mx) ** 2 for xi in x); sxy = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y))232 b1 = sxy / sxx; b0 = my - b1 * mx233 yhat = [b0 + b1 * xi for xi in x]234 sse = sum((yi - yh) ** 2 for yi, yh in zip(y, yhat)); sst = sum((yi - my) ** 2 for yi in y)235 se_b1 = math.sqrt(sse / (n - 2) / sxx)236 return {"n": n, "beta0": r0(b0), "beta1": r2(b1), "r2": r2(1 - sse / sst), "se_beta1": r2(se_b1),237 "t_beta1": r2(b1 / se_b1), "ecart_type_residus": r0(math.sqrt(sse / (n - 2)))}238239# Exemple A (fiabilité, V1 à V5) conservé de la version originale (exact)240EXEMPLE_A = [("V1", 456000, 5, 7.9, 11.8, "8 mois", "Moyen"), ("V2", 448000, 3, 4.2, 6.5, "3 mois", "Fort"),241 ("V3", 462000, 6, 12.1, 18.4, "10 mois", "Faible"), ("V4", 455000, 4, 5.8, 9.1, "5 mois", "Fort"),242 ("V5", 470000, 7, 16.3, 22.7, "14 mois", "Écarté")]243244# =============================================================================245# 4. IMM1033 S08 — CHAÎNE DU COÛT NEUF (convention du cours = TP2) et six logements de Gatineau (Hull)246# =============================================================================247PARAM_1033 = {248 "cg_pct": 8.0, "fg_pct": 7.0, "profit_eg_pct": 8.0, "contingences_pct": 5.0,249 "permis_par_1000": 4.70, "permis_min_par_logement": 335, # Gatineau, règl. 501-2005, art. 104 (TP2)250 "gcr_par_unite": 2656.68, "gcr_max_logements_vises": 5, # B-1.1, r. 8, art. 2 : unifamiliale, duplex à quintuplex, copropriété ≤ 4 parties privatives superposées (vérifié 2026-11-02)251 "taux_directeur": 2.50, "taux_preferentiel": 4.70, "prime_construction": 2.05, # BdC 28 oct. 2026 (vérifié) ; préférentiel 4,70 % depuis le 29 oct. 2026 (vérifié) ; prime = approximation (aucune source bancaire publique)252 "honoraires_pct_directs_cours": 4.0, "assurance_pct_dc": 0.8, "profit_entrepreneurial_pct": 11.0,253 "tps": 5.0, "tvq": 9.975,254}255SIX_LOGEMENTS = {256 "nom": "Immeuble de six logements, secteur Hull (Gatineau)", "logements": 6, "programme": "3 × 4½ et 3 × 5½",257 "aire_hors_sol_pi2": 5400, "aire_sous_sol_pi2": 1800, "stationnements": 8, "duree_mois": 14, "juridique_forfait": 12000,258 "terrain_valeur": 210000,259 "directs_nus": [ # divisions (prix nus composites RSMeans 2026 T4, localisés Gatineau — hypothèse pédagogique calibrée sur le TP2)260 ("Fondations et structure (béton, ossature bois)", 170000), ("Enveloppe (brique 70 %, revêtement, fenêtres, portes)", 145000),261 ("Toiture plate à membrane", 42000), ("Plomberie, chauffage (plinthes), VRC", 120000), ("Électricité (6 entrées)", 65000),262 ("Finitions intérieures (cuisines, salles de bains, planchers, gypse, peinture)", 175000),263 ("Équipements (buanderie commune, rangements)", 20000), ("Site : excavation, branchements, 8 stationnements, aménagement", 48000),264 ],265}266# Marché (hypothèse pédagogique) : ventes de six logements neufs à Gatineau pour l'extraction du profit267MARCHE_6LOG = [("M1", 1560000, 215000), ("M2", 1510000, 205000), ("M3", 1595000, 225000), ("M4", 1465000, 200000)] # prix, terrain268269def chaine_cout(cas: dict, p: dict, gcr_applicable: bool | None = None, duree: int | None = None, i_pct: float | None = None) -> dict:270 nus = sum(v for _, v in cas["directs_nus"])271 cg = r0(nus * p["cg_pct"] / 100); cum1 = nus + cg272 fg = r0(cum1 * p["fg_pct"] / 100); cum2 = cum1 + fg273 peg = r0(cum2 * p["profit_eg_pct"] / 100); directs_cours = cum2 + peg274 conting = r0(nus * p["contingences_pct"] / 100); dc = directs_cours + conting275 permis_prop = r0(dc / 1000 * p["permis_par_1000"]); permis_min = p["permis_min_par_logement"] * cas["logements"]276 permis = max(permis_prop, permis_min)277 if gcr_applicable is None:278 gcr_applicable = cas["logements"] <= p["gcr_max_logements_vises"]279 gcr = r0(p["gcr_par_unite"] * cas["logements"]) if gcr_applicable else 0280 T = duree if duree is not None else cas["duree_mois"]281 i = (i_pct if i_pct is not None else p["taux_preferentiel"] + p["prime_construction"])282 interets = r0(dc * i / 100 * T / 12 * 0.5)283 honoraires = r0(directs_cours * p["honoraires_pct_directs_cours"] / 100)284 assurance = r0(dc * p["assurance_pct_dc"] / 100)285 juridique = cas["juridique_forfait"]286 indirects = permis + gcr + interets + honoraires + assurance + juridique287 assiette = dc + indirects288 profit = r0(assiette * p["profit_entrepreneurial_pct"] / 100)289 total = assiette + profit290 return {291 "directs_nus": nus, "cg": cg, "apres_cg": cum1, "fg": fg, "apres_fg": cum2, "profit_eg": peg, "directs_cours": directs_cours,292 "contingences": conting, "directs_plus_contingences": dc, "majoration_cascade_pct": r2((directs_cours / nus - 1) * 100),293 "permis_proportionnel": permis_prop, "permis_minimum": permis_min, "permis": permis,294 "gcr_applicable": gcr_applicable, "gcr": gcr, "taux_pret": i, "duree_mois": T, "interets_intercalaires": interets,295 "honoraires": honoraires, "assurance": assurance, "juridique": juridique, "indirects": indirects,296 "ratio_indirects_directs_cours_pct": r1(indirects / directs_cours * 100), "assiette_profit": assiette,297 "profit_entrepreneurial": profit, "cout_total": total,298 "cout_pi2_hors_sol": r2(total / cas["aire_hors_sol_pi2"]), "cout_pi2_brut": r2(total / (cas["aire_hors_sol_pi2"] + cas["aire_sous_sol_pi2"])),299 "cout_par_logement": r0(total / cas["logements"]), "redevances_developpement": 0, # Gatineau règl. 961-2024 : 3 986,39 $/logement en zone 1 (Aylmer) seulement ; Hull = 0300 "profit_sur_cout_pct": p["profit_entrepreneurial_pct"], "marge_sur_prix_pct": r2(p["profit_entrepreneurial_pct"] / (100 + p["profit_entrepreneurial_pct"]) * 100),301 "part_indirects_du_total_pct": r1(indirects / total * 100), "part_profit_du_total_pct": r1(profit / total * 100),302 }303304def interets_par_periode(montant: float, i_pct: float, T: int, profil: str = "lineaire") -> dict:305 """Méthode par période : I = Σ B_t · i · Δt, décaissements mensuels selon un profil (linéaire ou courbe en S 3t²−2t³),306 intérêts du mois calculés sur le solde en début de mois (décaissement en fin de mois), capitalisés au solde."""307 i_m = i_pct / 100 / 12308 cum = lambda t: t / T if profil == "lineaire" else 3 * (t / T) ** 2 - 2 * (t / T) ** 3309 solde = 0.0; total_int = 0.0; lignes = []310 for t in range(1, T + 1):311 debours = montant * (cum(t) - cum(t - 1))312 interet = solde * i_m313 solde += debours + interet314 total_int += interet315 lignes.append({"mois": t, "decaissement": r0(debours), "cumul_decaisse": r0(montant * cum(t)), "interet": r0(interet), "solde_fin": r0(solde)})316 return {"profil": profil, "montant": montant, "taux": i_pct, "duree": T, "interets_total": r0(total_int), "lignes": lignes,317 "solde_moyen": r0(sum(l["solde_fin"] for l in lignes) / T)}318319def taxes_habitation_neuve(prix: float, p: dict, premier_acheteur: bool = False, tvq_rembours: bool = True) -> dict:320 """TPS/TVQ pour une habitation neuve achetée par un occupant (règles générales 2026 ; remboursement TPS 36 % ≤ 350 000 $,321 dégressif jusqu'à 450 000 $ ; remboursement TVQ 50 % max 9 975 $ ≤ 200 000 $, dégressif jusqu'à 300 000 $).322 Le remboursement « premiers acheteurs » (jusqu'à 100 % de la TPS ≤ 1 M$, dégressif à 1,5 M$) est calculé à part : statut à confirmer."""323 tps = prix * p["tps"] / 100; tvq = prix * p["tvq"] / 100324 if prix <= 350000: remb_tps = min(tps * 0.36, 6300)325 elif prix < 450000: remb_tps = 6300 * (450000 - prix) / 100000326 else: remb_tps = 0.0327 if prix <= 200000: remb_tvq = min(tvq * 0.50, 9975)328 elif prix < 300000: remb_tvq = 9975 * (300000 - prix) / 100000329 else: remb_tvq = 0.0330 if not tvq_rembours: remb_tvq = 0.0331 remb_tps_pa = 0.0332 if premier_acheteur:333 remb_tps_pa = tps if prix <= 1000000 else (tps * (1500000 - prix) / 500000 if prix < 1500000 else 0.0)334 remb_tps_eff = max(remb_tps, remb_tps_pa)335 net = tps + tvq - remb_tps_eff - remb_tvq336 return {"prix": prix, "tps": r2(tps), "tvq": r2(tvq), "taxes_brutes": r2(tps + tvq), "remb_tps_general": r2(remb_tps),337 "remb_tps_premier_acheteur": r2(remb_tps_pa), "remb_tps_retenu": r2(remb_tps_eff), "remb_tvq": r2(remb_tvq),338 "taxes_nettes": r2(net), "taxes_nettes_pct": r2(net / prix * 100)}339340# =============================================================================341# 5. VÉRIFICATION DES DECKS ORIGINAUX342# =============================================================================343def verifier_originaux():344 d = "IMM1003 S08 (original)"345 # grille originale à 5 %/an, prorata mensuel, 15 sept. 2026346 for nom, prix, mois, att in [("C1", 435000, 2, 3600), ("C2", 415000, 4, 6900), ("C3", 460000, 1, 1900), ("C4", 425000, 6, 10600)]:347 check(d, f"temps {nom} (5 %/an)", att, r0(prix * 0.05 * mois / 12 / 100) * 100, tol=100, note=f"exact à 5 %/an, arrondi à la centaine dans le deck (valeur exacte {r0(prix * 0.05 * mois / 12)} $) ; taux non sourcé (APCIQ 2026 ≈ 2 %/an)")348 check(d, "net propriété C1", -14000, sum([0, -5000, -2000, -2000, -5000, 0, 0, 0]))349 check(d, "net propriété C2", 60000, sum([10000, 5000, 5000, 3000, 12000, 20000, 5000, 0]))350 check(d, "net propriété C3", -92000, sum([-15000, -15000, -10000, -8000, -10000, -18000, -8000, -8000]))351 check(d, "net propriété C4", 13000, sum([0, 0, 0, 10000, 3000, 0, 0, 0]))352 check(d, "prix ajusté final C2", 481900, 415000 + 6900 + 60000)353 check(d, "moyenne des 4 indications", 431250, (424600 + 481900 + 369900 + 448600) / 4)354 check(d, "ampleur C3 brut %", 20.4, r1((1900 + 92000) / 460000 * 100), tol=0.05)355 check(d, "ampleur C1 net %", -2.4, r1((3600 - 14000) / 435000 * 100), tol=0.05)356 check(d, "financement : reprise 100 000 $ à 2 % vs 5 % (3 ans) — deck ≈ 10 000 $", 10000, FIN_C1["avantage"], tol=500,357 note="montant posé sans calcul ; équivalence au comptant : 8 170 $ (3 ans) ou 12 990 $ (5 ans)")358 check(d, "financement (5 ans)", 10000, equivalence_comptant(100000, 2, 5, 5)["avantage"], tol=500, note="idem, 5 ans")359 d = "IMM1003 S09 (original)"360 check(d, "temps C1 : 410 000 × 5 % × 8/12", 13667, 410000 * 0.05 * 8 / 12, tol=1)361 check(d, "indication C1", 456167, 410000 + 13667 + 32500)362 check(d, "net C1 % (sans le temps)", 7.9, r1(32500 / 410000 * 100), tol=0.05, note="convention différente de S08 (qui inclut le temps)")363 check(d, "brut C1 % (sans le temps)", 11.8, r1(48500 / 410000 * 100), tol=0.05)364 check(d, "temps C2 : 445 000 × 5 % × 3/12", 5563, 445000 * 0.05 * 3 / 12, tol=1)365 check(d, "indication C2", 454663, 445000 + 5562.5 + 4100, tol=1)366 check(d, "temps C3", 10625, 425000 * 0.05 * 6 / 12, tol=1); check(d, "indication C3", 430125, 435625 - 5500)367 check(d, "temps C4", 3833, 460000 * 0.05 * 2 / 12, tol=1); check(d, "localisation C4 −5 %", -23192, -0.05 * 463833, tol=1)368 check(d, "indication C4", 389141, 463833 - 74692); check(d, "net C4 %", 16.2, r1(74692 / 460000 * 100), tol=0.05)369 check(d, "temps C5", 9125, 438000 * 0.05 * 5 / 12, tol=1); check(d, "indication C5", 484125, 447125 + 37000)370 check(d, "quiz net 350 000 $", 3.7, r1(13000 / 350000 * 100), tol=0.05); check(d, "quiz brut", 12.3, r1(43000 / 350000 * 100), tol=0.05)371 check(d, "capitalisation garage 150 × 12 / 0,07", 25700, 150 * 12 / 0.07, tol=20)372 check(d, "financement : hypothèque 400 000 $ assumable 2 % vs 5 % — deck 12 000 $", 12000, equivalence_comptant(400000, 2, 5, 5)["avantage"], tol=1000,373 note="NON ÉTAYÉ : montant posé sans base (solde, durée) ; sur 400 000 $ et 5 ans l'équivalence au comptant donne ≈ 51 954 $ ; S08 dit ≈ 10 000 $ pour 100 000 $")374 d = "IMM1033 S08 (original)"375 check(d, "exemple rapide : total 660 000 $", 660000, 500000 * 1.2 * 1.1); check(d, "honoraires 8 logements", 225500, 96000 + 24000 + 36000 + 18000 + 3500 + 48000)376 check(d, "honoraires / directs 8 logements %", 18.8, r1(225500 / 1200000 * 100), tol=0.05, note="exact mais invraisemblable (convention du cours : 4 %)")377 check(d, "intercalaires 800 000 × 7 % × 10/12 × ½", 23333, 800000 * 0.07 * 10 / 12 * 0.5, tol=1)378 check(d, "TPS 350 000 $", 17500, 350000 * 0.05); check(d, "TVQ 350 000 $", 34913, 350000 * 0.09975, tol=1)379 tx = taxes_habitation_neuve(350000, PARAM_1033)380 check(d, "remboursement TVQ à 350 000 $ (deck : 9 975 $)", 9975, tx["remb_tvq"], tol=1, note="ERREUR : nul au-delà de 300 000 $")381 check(d, "taxes nettes 350 000 $ (deck : 36 138 $)", 36138, tx["taxes_nettes"], tol=1, note="corrigé : 46 112,50 $ (13,2 %)")382 check(d, "assurances/légal 6 logements : 35 500 $", 35500, 9000 + 3000 + 4500 + 4000 + 15000, note="dont GCR 15 000 $ pour un 6 logements : plan non applicable (à confirmer)")383 check(d, "extraction du profit 15,7 %", 15.7, r1(170000 / 1080000 * 100), tol=0.05)384 ind = [19425, 5550, 6937.5, 2500, 6475, 2081.25, 2775, 2220, 2500, 2000, 4162.5]385 check(d, "exemple résidentiel : indirects 56 627 $", 56627, sum(ind), tol=1); check(d, "coût total 367 540 $", 367540, (277500 + sum(ind)) * 1.10, tol=2, note="exact (arrondi des lignes)")386 check(d, "coût total $/pi² 245", 245, 367540 / 1500, tol=0.5); check(d, "permis exemple résidentiel (10 $/1 000 $) 2 775 $", 2775, 277500 / 1000 * 10,387 note="hypothèse 10 $/1 000 $ ; tarif de Gatineau : 4,70 $/1 000 $, min. 335 $/logement → 1 304 $")388 check(d, "commercial : indirects 205 375 $", 205375, 775000 * 0.265, tol=1); check(d, "commercial : total 1 098 020 $", 1098020, 980375 * 1.12, tol=1)389 check(d, "6 logements : directs 692 200 $", 692200, (28 + 22 + 24 + 12 + 32) * 5400 + 35000 + 20000)390 check(d, "6 logements : 128 $/pi²", 128, 692200 / 5400, tol=0.5)391 check(d, "taux directeur 2,25 % (15 juillet 2026)", 2.25, PARAM_1033["taux_directeur"], tol=0.001, note="périmé : 2,50 % depuis le 28 oct. 2026 (vérifié, Banque du Canada) ; préférentiel 4,70 %")392393# =============================================================================394# 6. EXÉCUTION395# =============================================================================396def main(argv):397 out_json = HERE / "resultats.json"; out_md = HERE / "RESULTATS_CALCULS.md"398 if "--json" in argv: out_json = Path(argv[argv.index("--json") + 1])399 if "--md" in argv: out_md = Path(argv[argv.index("--md") + 1])400 verifier_originaux()401 R: dict = {"date_generation": dt.date.today().isoformat(), "catalogue": CATALOGUE, "parametres_1033": PARAM_1033}402 # ---- IMM1003 S08403 g8 = [grille(SUJET_S8, c, DATE_EVAL) for c in COMPS_S8]404 rec8 = reconcilier(g8, POIDS_S8)405 R["imm1003_s08"] = {"date_evaluation": DATE_EVAL.isoformat(), "sujet": SUJET_S8, "comparables": COMPS_S8, "grilles": g8, "reconciliation": rec8,406 "candidats_4_parmi_8": CANDIDATS_S8,407 "exemples_criteres": {408 "financement": FIN_C1, "financement_5_ans": equivalence_comptant(100000, 2, 5, 5),409 "depenses_apres_achat": {"prix": 410000, "toiture": 15000, "equivalent": 425000},410 "temps": {"prix": 400000, "mois": 12, "taux_mois": 0.17, "ajustement": r0(400000 * 0.0017 * 12), "compose_2pct": r0(400000 * 0.02)},411 "non_immobilier": {"prix": 440000, "electros": 8000, "mobilier": 2000, "net": 430000},412 "terrain": {"sujet": 5500, "comp": 7000, "ecart": 1500, "ajust": -1500 * 6},413 "servitude": {"prix": 430000, "perte_pct": 3, "ajust": r0(430000 * 0.03)},414 "conditions_vente_succession": {"prix": 405000, "ecart_pct": 4, "ajust": r0(405000 * 0.04)},415 }}416 for c in COMPS_S8: c["date"] = c["date"].isoformat()417 # ---- IMM1003 S09418 g9 = [grille(SUJET_S9, c, DATE_EVAL) for c in COMPS_S9]419 rec9 = reconcilier(g9, POIDS_S9); rec9_sans_c4 = reconcilier(g9, POIDS_S9_SANS_C4)420 # sensibilités421 sens = {}422 base_g = CATALOGUE["garage_double_vs_simple"]423 for gv in (10000, 15000, 20000):424 CATALOGUE["garage_double_vs_simple"] = gv425 gg = [grille(SUJET_S9, {**c, "date": c["date"]}, DATE_EVAL) for c in COMPS_S9]426 sens[f"garage_double_vs_simple_{gv}"] = reconcilier(gg, POIDS_S9)["valeur_ponderee"]427 CATALOGUE["garage_double_vs_simple"] = base_g428 base_t = CATALOGUE["temps_pct_par_mois"]429 for tv in (-0.25, 0.0, 0.17, 0.34):430 CATALOGUE["temps_pct_par_mois"] = tv431 gg = [grille(SUJET_S9, c, DATE_EVAL) for c in COMPS_S9]432 sens[f"temps_{tv}"] = reconcilier(gg, POIDS_S9)["valeur_ponderee"]433 CATALOGUE["temps_pct_par_mois"] = base_t434 base_l = CATALOGUE["localisation_pct"]435 for lv in (3.0, 5.0, 8.0):436 CATALOGUE["localisation_pct"] = lv437 gg = [grille(SUJET_S9, c, DATE_EVAL) for c in COMPS_S9]438 sens[f"localisation_{lv}"] = reconcilier(gg, POIDS_S9)["valeur_ponderee"]439 CATALOGUE["localisation_pct"] = base_l440 reg = ols(REG_AIRES, REG_PRIX)441 paires = {k: {"ecart": max(v.values()) - min(v.values()), "ecart_pct": r1((max(v.values()) - min(v.values())) / min(v.values()) * 100), **v} for k, v in PAIRES.items()}442 rev = {**REVENTE, "variation_pct": r2((REVENTE["prix_2026"] / REVENTE["prix_2024"] - 1) * 100),443 "taux_annuel_compose_pct": r2(((REVENTE["prix_2026"] / REVENTE["prix_2024"]) ** (12 / REVENTE["mois"]) - 1) * 100),444 "taux_mensuel_simple_pct": r2((REVENTE["prix_2026"] / REVENTE["prix_2024"] - 1) * 100 / REVENTE["mois"])}445 aberrant = {"indications": [g["indication"] for g in g9], "mediane": rec9["mediane"],446 "ecarts_pct": {g["nom"]: r1((g["indication"] - rec9["mediane"]) / rec9["mediane"] * 100) for g in g9}, "seuil_pct": 10}447 R["imm1003_s09"] = {"date_evaluation": DATE_EVAL.isoformat(), "sujet": SUJET_S9, "comparables": COMPS_S9, "grilles": g9,448 "reconciliation": rec9, "reconciliation_sans_C4": rec9_sans_c4, "sensibilite": sens, "regression": {"aires": REG_AIRES, "prix": REG_PRIX, **reg},449 "paires": paires, "revente": rev, "capitalisation": {**CAPITALISATION, "valeur": r0(150 * 12 / 0.07)},450 "exemple_A": EXEMPLE_A, "valeurs_aberrantes": aberrant, "equivalence_comptant_C1": FIN_C1,451 "exemple_pct_vs_dollar": {"prix": 420000, "ajust_pct": 3, "ajust_dollar": r0(420000 * 0.03)},452 "exemple_ordre": {"prix": 400000, "financement": -10000, "temps_pct": 2, "sequence": r0((400000 - 10000) * 1.02),453 "additif": r0(400000 * 1.02 - 10000), "ecart": r0((400000 - 10000) * 1.02 - (400000 * 1.02 - 10000))}}454 for c in COMPS_S9: c["date"] = c["date"].isoformat()455 # ---- IMM1033 S08456 ch = chaine_cout(SIX_LOGEMENTS, PARAM_1033)457 ch_gcr = chaine_cout(SIX_LOGEMENTS, PARAM_1033, gcr_applicable=True)458 dc = ch["directs_plus_contingences"]; i = ch["taux_pret"]; T = ch["duree_mois"]459 per_lin = interets_par_periode(dc, i, T, "lineaire"); per_s = interets_par_periode(dc, i, T, "courbe_en_S")460 sens_i = {f"{iv}": chaine_cout(SIX_LOGEMENTS, PARAM_1033, i_pct=iv) for iv in (5.75, 6.75, 7.75, 8.75)}461 sens_T = {f"{tv}": chaine_cout(SIX_LOGEMENTS, PARAM_1033, duree=tv) for tv in (10, 14, 18, 20, 24)}462 retard = chaine_cout(SIX_LOGEMENTS, PARAM_1033, duree=T + 6)463 # unités et marché464 assiette = ch["assiette_profit"]465 marche = []466 for nom, prix, terr in MARCHE_6LOG:467 bat = prix - terr468 marche.append({"vente": nom, "prix": prix, "terrain": terr, "batiment_implicite": bat, "profit_extrait_pct": r1((bat - assiette) / assiette * 100),469 "prix_par_logement": r0(prix / 6)})470 bats = sorted(m["batiment_implicite"] for m in marche); med_bat = (bats[1] + bats[2]) / 2471 extraction = {"ventes": marche, "batiment_implicite_median": r0(med_bat), "assiette": assiette,472 "profit_extrait_median_pct": r1((med_bat - assiette) / assiette * 100), "ecart_cout_total_vs_marche_pct": r1((ch["cout_total"] - med_bat) / med_bat * 100)}473 taxes = {"occupant_350000": taxes_habitation_neuve(350000, PARAM_1033), "occupant_280000": taxes_habitation_neuve(280000, PARAM_1033),474 "occupant_premier_acheteur_650000": taxes_habitation_neuve(650000, PARAM_1033, premier_acheteur=True),475 "occupant_premier_acheteur_350000": taxes_habitation_neuve(350000, PARAM_1033, premier_acheteur=True),476 "locatif_6_logements": {"base_jvm_estimee": ch["cout_total"] + SIX_LOGEMENTS["terrain_valeur"], "tps": r2((ch["cout_total"] + SIX_LOGEMENTS["terrain_valeur"]) * 0.05),477 "tvq": r2((ch["cout_total"] + SIX_LOGEMENTS["terrain_valeur"]) * 0.09975),478 "remb_tps_pct": 100, "tps_nette": 0.0, "remb_tvq": 0.0,479 "note": "autocotisation à l'achèvement (JVM) ; remboursement bonifié de 100 % de la TPS (C-56 : locatif ≥ 4 logements, chantier commencé du 14 sept. 2023 au 31 déc. 2030) ; Québec non harmonisé : remboursement TVQ locatif 36 % max 7 182 $ par logement, nul si JVM du logement ≥ 225 000 $ → 0 $ ici (base estimée ÷ 6 ≈ 242 800 $/logement)"}}480 honoraires_methodes = {"pourcentage": {"base": ch["directs_cours"], "taux": 4.0, "montant": ch["honoraires"]},481 "detail_convention": {"architecte_pct": 2.5, "ingenieurs_pct": 1.5, "architecte": r0(ch["directs_cours"] * 0.025), "ingenieurs": r0(ch["directs_cours"] * 0.015)},482 "horaire": {"heures": 260, "taux_horaire": 145, "montant": 260 * 145},483 "forfait": {"montant": 38500}}484 profit_marge = {"cout": assiette, "profit_11_sur_cout": ch["profit_entrepreneurial"], "prix_equivalent": ch["cout_total"],485 "marge_sur_prix_pct": ch["marge_sur_prix_pct"], "profit_pour_marge_11_sur_prix": r0(assiette * 0.11 / 0.89),486 "taux_sur_cout_equivalent_marge_11_pct": r2(0.11 / 0.89 * 100)}487 R["imm1033_s08"] = {"cas": SIX_LOGEMENTS, "chaine": ch, "chaine_si_gcr_applicable": ch_gcr, "interets_methode_simplifiee": ch["interets_intercalaires"],488 "interets_par_periode_lineaire": per_lin, "interets_par_periode_courbe_S": per_s,489 "sensibilite_taux": {k: {"taux": v["taux_pret"], "interets": v["interets_intercalaires"], "cout_total": v["cout_total"]} for k, v in sens_i.items()},490 "sensibilite_duree": {k: {"duree": v["duree_mois"], "interets": v["interets_intercalaires"], "cout_total": v["cout_total"]} for k, v in sens_T.items()},491 "retard_6_mois": {"interets": retard["interets_intercalaires"], "surcout_interets": retard["interets_intercalaires"] - ch["interets_intercalaires"],492 "cout_total": retard["cout_total"], "surcout_total": retard["cout_total"] - ch["cout_total"]},493 "extraction_marche": extraction, "taxes": taxes, "honoraires_methodes": honoraires_methodes, "profit_vs_marge": profit_marge,494 "exemples_simples": {"intercalaires_800000": {"C": 800000, "i": 7.0, "T_mois": 10, "I": r0(800000 * 0.07 * 10 / 12 * 0.5)},495 "cascade_vs_somme": {"nus": 100000, "cascade": r0(100000 * 1.08 * 1.07 * 1.08), "somme_23pct": 123000},496 "permis_exemples": {"travaux_277500": max(r0(277500 / 1000 * 4.70), 335), "travaux_800000_4_log": max(r0(800000 / 1000 * 4.70), 4 * 335),497 "petits_travaux_40000_1_log": max(r0(40000 / 1000 * 4.70), 335)},498 "extraction_originale": {"prix": 1450000, "terrain": 200000, "directs": 900000, "indirects": 180000, "profit": 170000, "pct": r1(170000 / 1080000 * 100)}}}499 R["verifications_originaux"] = CHECKS500 out_json.write_text(json.dumps(R, ensure_ascii=False, indent=1, default=str), encoding="utf-8")501 out_md.write_text(rapport_md(R), encoding="utf-8")502 nb_err = sum(1 for c in CHECKS if not c["exact"])503 print(f"OK — {out_json.name}, {out_md.name} ; vérifications des decks originaux : {len(CHECKS)} contrôles, {nb_err} écart(s)/erreur(s) documenté(s)")504 print(f"S08 bungalow : {[ (g['nom'], g['indication']) for g in g8 ]} → pondérée {rec8['valeur_ponderee']} → {rec8['valeur_conclue']} $ (moyenne {rec8['moyenne_simple']})")505 print(f"S09 atelier : {[ (g['nom'], g['indication']) for g in g9 ]} → pondérée {rec9['valeur_ponderee']} → {rec9['valeur_conclue']} $ (moyenne {rec9['moyenne_simple']})")506 print(f"IMM1033 six logements : directs nus {ch['directs_nus']} → directs du cours {ch['directs_cours']} → D+C {dc} → indirects {ch['indirects']} → total {ch['cout_total']} $ ({ch['cout_pi2_hors_sol']} $/pi²) ; I½ = {ch['interets_intercalaires']} vs période lin. {per_lin['interets_total']} / S {per_s['interets_total']}")507508def fmt(x) -> str:509 if isinstance(x, bool): return "oui" if x else "non"510 if isinstance(x, int): return f"{x:,}".replace(",", " ")511 if isinstance(x, float): return f"{x:,.2f}".replace(",", " ").replace(".", ",")512 return str(x)513514def rapport_md(R: dict) -> str:515 L = ["# Résultats de calcul — séances refondues (généré par verification_calculs.py)", "", f"Généré le {R['date_generation']}. Toute valeur citée dans les diapositives, scripts, exercices et corrigés doit provenir de ce fichier.", ""]516 L += ["## Catalogue d'ajustements du cours (IMM1003 S8–S9)", "", "| Élément | Valeur |", "|---|---|"]517 for k, v in R["catalogue"].items(): L.append(f"| {k} | {fmt(v) if not isinstance(v, dict) else v} |")518 for key, titre in (("imm1003_s08", "IMM1003 S08 — Étude de cas : bungalow à Hull (évaluation au 1er novembre 2026)"), ("imm1003_s09", "IMM1003 S09 — Atelier : unifamiliale 2 étages à Hull, cinq comparables (1er novembre 2026)")):519 S = R[key]; L += ["", f"## {titre}", "", f"Sujet : {S['sujet']}", ""]520 for g in S["grilles"]:521 L += [f"### {g['nom']} — prix de vente {fmt(g['prix'])} $ ({g['mois']} mois)", "", "| Étape | Ajustement ($) | Prix courant ($) |", "|---|---:|---:|"]522 for lib, v, cur in g["transactionnels"]: L.append(f"| {lib} | {f'{v:+,}'.replace(',', ' ')} | {fmt(cur)} |")523 L.append(f"| **Prix ajusté transactionnel** | | **{fmt(g['prix_ajuste_transactionnel'])}** |")524 for lib, v in g["propriete"]: L.append(f"| {lib} | {f'{v:+,}'.replace(',', ' ')} | |")525 L += [f"| **Total propriété** | **{f'{g['total_propriete']:+,}'.replace(',', ' ')}** | |", f"| **Indication de valeur** | | **{fmt(g['indication'])}** |",526 "", f"Net : {f'{g['net']:+,}'.replace(',', ' ')} $ ({g['net_pct']} %) — Brut : {fmt(g['brut'])} $ ({g['brut_pct']} %) — seuils net ≤ 15 % : {fmt(g['seuil_net_ok'])}, brut ≤ 25 % : {fmt(g['seuil_brut_ok'])}", ""]527 rec = S["reconciliation"]528 L += ["### Réconciliation", "", f"Poids : {rec['poids']} → valeur pondérée {fmt(rec['valeur_ponderee'])} $ → **valeur conclue {fmt(rec['valeur_conclue'])} $** (arrondi 5 000 $). Moyenne simple {fmt(rec['moyenne_simple'])} $ (→ {fmt(rec['moyenne_arrondie'])}), médiane {fmt(rec['mediane'])} $, étendue {fmt(rec['min'])}–{fmt(rec['max'])} $ ({rec['etendue_pct']} % de la valeur pondérée).", ""]529 if key == "imm1003_s09":530 L += [f"Sans C4 (poids {S['reconciliation_sans_C4']['poids']}) : {fmt(S['reconciliation_sans_C4']['valeur_ponderee'])} $ → {fmt(S['reconciliation_sans_C4']['valeur_conclue'])} $.", "",531 "Sensibilité (valeur pondérée) : " + ", ".join(f"{k} → {fmt(v)} $" for k, v in S["sensibilite"].items()), "",532 f"Régression prix–aire (n = {S['regression']['n']}) : β0 = {fmt(S['regression']['beta0'])}, β1 = {S['regression']['beta1']} $/pi², R² = {S['regression']['r2']}, t = {S['regression']['t_beta1']}, σ résidus = {fmt(S['regression']['ecart_type_residus'])} $.",533 f"Paires : {S['paires']}", f"Revente : {S['revente']}", f"Équivalence au comptant C1 : {S['equivalence_comptant_C1']}", f"Valeurs aberrantes : {S['valeurs_aberrantes']}", f"Ordre séquentiel vs additif : {S['exemple_ordre']}", ""]534 else:535 L += [f"Exemples des critères : {S['exemples_criteres']}", ""]536 S = R["imm1033_s08"]; ch = S["chaine"]537 L += ["", "## IMM1033 S08 — Six logements, secteur Hull (chaîne du cours = TP2)", "", "| Poste | Montant ($) |", "|---|---:|"]538 for k in ("directs_nus", "cg", "apres_cg", "fg", "apres_fg", "profit_eg", "directs_cours", "contingences", "directs_plus_contingences", "permis_proportionnel", "permis_minimum", "permis", "gcr", "interets_intercalaires", "honoraires", "assurance", "juridique", "indirects", "assiette_profit", "profit_entrepreneurial", "cout_total"):539 L.append(f"| {k} | {fmt(ch[k])} |")540 L += ["", f"Taux du prêt {ch['taux_pret']} % ; durée {ch['duree_mois']} mois ; majoration en cascade {ch['majoration_cascade_pct']} % ; ratio indirects/directs du cours {ch['ratio_indirects_directs_cours_pct']} % ; {ch['cout_pi2_hors_sol']} $/pi² hors sol ; {ch['cout_pi2_brut']} $/pi² brut (avec sous-sol) ; {fmt(ch['cout_par_logement'])} $/logement ; marge sur prix {ch['marge_sur_prix_pct']} % ; GCR applicable : {fmt(ch['gcr_applicable'])} (si applicable : total {fmt(S['chaine_si_gcr_applicable']['cout_total'])} $).", "",541 f"Intérêts : simplifiée ½ = {fmt(S['interets_methode_simplifiee'])} $ ; par période linéaire = {fmt(S['interets_par_periode_lineaire']['interets_total'])} $ ; courbe en S = {fmt(S['interets_par_periode_courbe_S']['interets_total'])} $.", "",542 "Sensibilité taux : " + "; ".join(f"{v['taux']} % → I {fmt(v['interets'])} $, total {fmt(v['cout_total'])} $" for v in S["sensibilite_taux"].values()), "",543 "Sensibilité durée : " + "; ".join(f"{v['duree']} mois → I {fmt(v['interets'])} $, total {fmt(v['cout_total'])} $" for v in S["sensibilite_duree"].values()), "",544 f"Retard de 6 mois : {S['retard_6_mois']}", "", f"Extraction marché : {S['extraction_marche']}", "", f"Taxes : {S['taxes']}", "", f"Honoraires : {S['honoraires_methodes']}", "", f"Profit vs marge : {S['profit_vs_marge']}", "", f"Exemples simples : {S['exemples_simples']}", ""]545 L += ["", "## Calendrier mensuel (courbe en S) — six logements", "", "| Mois | Décaissement | Cumul | Intérêt | Solde fin |", "|---:|---:|---:|---:|---:|"]546 for l in S["interets_par_periode_courbe_S"]["lignes"]: L.append(f"| {l['mois']} | {fmt(l['decaissement'])} | {fmt(l['cumul_decaisse'])} | {fmt(l['interet'])} | {fmt(l['solde_fin'])} |")547 L += ["", "## Vérification des decks originaux", "", "| Document | Élément | Deck | Recalculé | Exact | Note |", "|---|---|---:|---:|:-:|---|"]548 for c in R["verifications_originaux"]: L.append(f"| {c['document']} | {c['element']} | {fmt(c['valeur_deck'])} | {fmt(c['valeur_recalculee'])} | {'✓' if c['exact'] else '✗'} | {c['note']} |")549 return "\n".join(L) + "\n"550551if __name__ == "__main__":552 main(sys.argv[1:])553