spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 23 — coûts directs et indirects : structure complète du coût neuf du sujet du cas 1 (DONNÉES SYNTHÉTIQUES),3financement intérimaire, taxes de vente, incitatif, multilogement (cas 3) et conjoncture IPCB (données RÉELLES).45Réutilise les fonctions du chapitre 21 (analysis/python/cases/ch21_cout_neuf.py : devis, regroupement, coût neuf, IPCB)6et la bibliothèque traite (cout.cout_neuf, finance.interets_intermediaires, finance.marge_sur_prix) ; aucune formule financière7n'est réécrite ici. Imprime tous les chiffres cités dans le chapitre 23 ; testé par analysis/python/tests/test_ch23.py.89Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch23_structure_couts.py1011Paramètres documentés (data/raw et sources/notes-*.md) et hypothèses pédagogiques (déclarées comme telles dans le texte) :12 * taux d'intérêt intérimaire de référence : taux hypothécaire affiché 5 ans, prêt conventionnel, banques à charte,13 6,19 % au 2026-10-28 (Banque du Canada, série V80691335, data/raw/bdc_valet/V80691335.csv) — lu dans le fichier brut ;14 un prêt de construction est en pratique tarifé autrement (taux variable + prime), ce taux sert de référence documentée ;15 * TPS 5 % et TVQ 9,975 % (ARC, page « Calculatrice de la TPS/TVH et taux », modifiée le 2025-04-01) ;16 * remboursement TPS habitation neuve : 36 % de la TPS, max. 6 300 $, plein jusqu'à 350 000 $, nul à 450 000 $ et plus17 (ARC, guide RC4028 Rév. 10/2026) ; remboursement premiers acheteurs : 100 % de la TPS jusqu'à 1 M$, nul à 1,5 M$,18 travaux commencés le 20 mars 2025 ou après ; remboursement TVQ : 50 %, max. 9 975 $, réduit au-delà de 200 000 $,19 nul à 300 000 $ (Revenu Québec, page citée par moteur de recherche ; page elle-même inaccessible, HTTP 403) ;20 * hypothèses : durée de chantier 26 semaines (= supervision du devis) ; valeur de terrain 180 000 $ (exercice 21.7, en21 attente du ch. 25) ; part de la main-d'œuvre dans les coûts directs λ ∈ [0,35 ; 0,50] ; multilogement : efficacité22 locative 85 %, durée 12 mois, prêt 75 %."""23from __future__ import annotations24import sys, csv, pathlib25import numpy as np, pandas as pd26sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))27sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))28from traite import cout as ct, finance as fin29import ch21_cout_neuf as c213031ROOT = pathlib.Path(__file__).resolve().parents[3]32RAW_IPCB = ROOT / "data/raw/statcan_18100289/18100289.csv"33RAW_BDC_AFFICHE = ROOT / "data/raw/bdc_valet/V80691335.csv"34RAW_BDC_A4 = ROOT / "data/raw/bdc_valet/A4_RATES_MORTGAGES.csv"3536PI2_PAR_M2 = c21.PI2_PAR_M237TAUX_INCITATIF = c21.TAUX_INCITATIF # 10 % sur D + I (convention de l'ouvrage, hypothèse pédagogique du ch. 21)38TPS, TVQ = 0.05, 0.09975 # ARC, page « Calculatrice de la TPS/TVH et taux » (2025-04-01)39REMB_TPS = {"taux": 0.36, "max": 6300.0, "plein": 350000.0, "nul": 450000.0} # RC4028 Rév. 10/202640REMB_TPS_PREMIER = {"plein": 1_000_000.0, "nul": 1_500_000.0, "debut_travaux": "2025-03-20"} # RC4028 Rév. 10/202641REMB_TVQ = {"taux": 0.50, "max": 9975.0, "plein": 200000.0, "nul": 300000.0} # Revenu Québec (partiellement vérifié)42VALEUR_TERRAIN_HYP = 180000.0 # hypothèse (exercice 21.7) ; le ch. 25 établit la valeur du terrain43DUREE_CHANTIER_ANS = 26 / 52 # 26 semaines de supervision dans le devis44POSTE_FIN = "Financement intérimaire"4546# Nature dominante de chaque poste du devis (convention du chapitre : matériaux / main-d'œuvre / mixte / équipement / services)47NATURE = {48 "Excavation et remblai": "équipement", "Fondations béton (semelles, murs)": "mixte", "Dalle de sous-sol": "mixte",49 "Drain français et imperméabilisation": "mixte", "Raccordements aqueduc/égout": "services", "Charpente murs et planchers": "mixte",50 "Fermes de toit": "matériaux", "Escaliers": "mixte", "Couverture bardeaux d'asphalte": "mixte",51 "Revêtement extérieur (brique 40 % / fibrociment 60 %)": "mixte", "Isolation et pare-vapeur": "mixte",52 "Fenêtres (PVC double vitrage)": "matériaux", "Portes extérieures": "matériaux", "Porte de garage": "matériaux",53 "Gouttières et solins": "mixte", "Gypse et tirage de joints": "main-d'œuvre", "Peinture intérieure": "main-d'œuvre",54 "Planchers bois franc": "mixte", "Planchers céramique": "mixte", "Planchers flottants (sous-sol)": "mixte",55 "Portes intérieures et moulures": "mixte", "Armoires de cuisine et comptoirs": "matériaux", "Vanités et accessoires (2 sdb)": "matériaux",56 "Plomberie brute et finition": "mixte", "Chauffe-eau": "matériaux", "Électricité (entrée 200 A, filage, appareils)": "mixte",57 "Ventilation (VRC) et conduits": "mixte", "Thermopompe centrale et plinthes": "matériaux", "Éclairage et finition électrique": "mixte",58 "Balcon et perron": "mixte", "Entrée asphaltée": "mixte", "Aménagement paysager de base": "mixte", "Nettoyage et conteneurs": "services",59 "Permis et études (arpentage, sol)": "indirect", "Supervision de chantier (salaires)": "indirect",60 "Assurances chantier et cautionnement": "indirect", "Plans et honoraires professionnels": "indirect", "Financement intérimaire": "indirect",61}626364# ----------------------------------------------------------------------------- structure complète du devis (cas 1)6566def structure(devis: pd.DataFrame, cn: dict) -> pd.DataFrame:67 """Tableau complet des 38 postes : corps, nature, montant, part des directs (ou des indirects), part de C_N."""68 d = devis.copy()69 d["nature"] = d["poste"].map(NATURE)70 assert d["nature"].notna().all()71 D, I, CN = cn["direct"], cn["indirects"], cn["total"]72 d["part_bloc"] = np.where(d["indirect"], d["montant"] / I, d["montant"] / D)73 d["part_D"] = d["montant"] / D74 d["part_CN"] = d["montant"] / CN75 d["ordre_corps"] = d["corps"].map({k: i for i, k in enumerate(c21.ORDRE_CORPS)})76 return d.sort_values(["ordre_corps", "montant"], ascending=[True, False]).reset_index(drop=True)777879def ratios_indirects(devis: pd.DataFrame, cn: dict) -> pd.DataFrame:80 """Les cinq postes indirects : montant, % des directs, % de D+I, % de C_N."""81 ind = devis[devis["indirect"]].copy()82 D, I, CN = cn["direct"], cn["indirects"], cn["total"]83 ind["pct_D"] = ind["montant"] / D84 ind["pct_DI"] = ind["montant"] / (D + I)85 ind["pct_CN"] = ind["montant"] / CN86 return ind[["poste", "montant", "pct_D", "pct_DI", "pct_CN"]].sort_values("montant", ascending=False).reset_index(drop=True)878889def parts_nature(d: pd.DataFrame) -> pd.Series:90 """Montants des coûts directs par nature dominante (convention du chapitre)."""91 return d[~d["indirect"]].groupby("nature")["montant"].sum().sort_values(ascending=False)929394# ----------------------------------------------------------------------------- données Banque du Canada (réelles)9596def taux_affiche_5ans(date: str = "2026-10-28") -> float:97 """Taux hypothécaire affiché 5 ans (prêt conventionnel), % → proportion, lu dans le CSV Valet brut."""98 with open(RAW_BDC_AFFICHE, encoding="utf-8-sig") as f:99 rows = [r for r in csv.reader(f) if len(r) == 2 and r[0][:4].isdigit()]100 d = {r[0]: float(r[1]) for r in rows}101 return d[date] / 100.0102103104def taux_fonds_avances(date: str = "2026-08-01") -> dict:105 """Taux effectifs des fonds avancés (groupe A4), août 2026 : non assuré fixe ≥ 5 ans (V122667786), non assuré total (V122667781),106 assuré total (V122667775), non assuré variable (V122667782)."""107 with open(RAW_BDC_A4, encoding="utf-8-sig") as f:108 lines = list(csv.reader(f))109 hdr = next(i for i, r in enumerate(lines) if r and r[0] == "date")110 cols = lines[hdr]111 row = next(r for r in lines[hdr + 1:] if r and r[0] == date)112 g = lambda v: float(row[cols.index(v)]) / 100.0113 return {"non_assure_fixe_5ans_plus": g("V122667786"), "non_assure_total": g("V122667781"),114 "assure_total": g("V122667775"), "non_assure_variable": g("V122667782")}115116117# ----------------------------------------------------------------------------- financement intérimaire118119def financement_cas1(devis: pd.DataFrame, cn: dict, taux: float, duree_ans: float = DUREE_CHANTIER_ANS) -> dict:120 """Vérifie le forfait « Financement intérimaire » du devis par la formule L·i·T/2 (prêt = 100 % des coûts hors financement)."""121 forfait = float(devis.loc[devis["poste"] == POSTE_FIN, "montant"].iloc[0])122 base = cn["direct"] + cn["indirects"] - forfait # coûts directs + indirects hors financement123 cont = fin.interets_intermediaires(base, taux, duree_ans, "continu")124 mens = fin.interets_intermediaires(base, taux, duree_ans, "mensuel")125 cap = fin.interets_intermediaires(base, taux, duree_ans, "mensuel", capitaliser=True)126 iT = fin.taux_duree_implicites(forfait, base)127 return {"forfait": forfait, "base_hors_financement": base, "interets_continu": cont["interets"], "interets_mensuel": mens["interets"],128 "interets_capitalise": cap["interets"], "iT_implicite": iT, "duree_implicite_mois": iT / taux * 12,129 "taux_implicite_6mois": iT / duree_ans, "taux": taux, "duree_ans": duree_ans}130131132def cout_neuf_avec_financement(devis: pd.DataFrame, cn: dict, interets: float, taux_incitatif: float = TAUX_INCITATIF) -> dict:133 """Coût neuf recalculé en remplaçant le forfait de financement du devis par un montant d'intérêts calculé."""134 forfait = float(devis.loc[devis["poste"] == POSTE_FIN, "montant"].iloc[0])135 I = cn["indirects"] - forfait + interets136 taux_ind = I / cn["direct"]137 out = ct.cout_neuf(cn["direct"], taux_ind, taux_incitatif)138 out["taux_indirects"] = taux_ind139 out["interets"] = interets140 return out141142143def grille_financement(base: float, durees_mois: list[int], taux: list[float]) -> np.ndarray:144 """Intérêts intérimaires (tirage uniforme, continu) pour chaque couple (durée, taux)."""145 return np.array([[fin.interets_intermediaires(base, i, m / 12, "continu")["interets"] for i in taux] for m in durees_mois])146147148# ----------------------------------------------------------------------------- taxes de vente et remboursements149150def remboursement_tps(jvm: float, premier_acheteur: bool = False, travaux_apres_20mars2025: bool = True) -> dict:151 """Remboursement fédéral TPS/TVH pour habitation neuve (RC4028 Rév. 10/2026).152 Remboursement général : 36 % de la TPS, maximum 6 300 $, plein jusqu'à une JVM de 350 000 $, nul à 450 000 $ et plus ;153 la règle de réduction entre 350 001 et 449 999 $ n'est pas écrite explicitement dans le guide (il renvoie au formulaire) :154 une réduction linéaire est appliquée ici et SIGNALÉE comme interprétation. Premiers acheteurs (travaux commencés le155 20 mars 2025 ou après) : 100 % de la TPS jusqu'à 1 M$, réduit graduellement, nul à 1,5 M$ et plus (interpolation linéaire)."""156 tps = jvm * TPS157 general = min(REMB_TPS["taux"] * tps, REMB_TPS["max"])158 if jvm > REMB_TPS["plein"]:159 general *= max((REMB_TPS["nul"] - jvm) / (REMB_TPS["nul"] - REMB_TPS["plein"]), 0.0)160 premier = 0.0161 if premier_acheteur and travaux_apres_20mars2025:162 premier = tps if jvm <= REMB_TPS_PREMIER["plein"] else tps * max((REMB_TPS_PREMIER["nul"] - jvm) / (REMB_TPS_PREMIER["nul"] - REMB_TPS_PREMIER["plein"]), 0.0)163 return {"tps": tps, "remboursement_general": general, "remboursement_premier_acheteur": premier,164 "remboursement": max(general, premier), "interpolation_appliquee": REMB_TPS["plein"] < jvm < REMB_TPS["nul"]}165166167def remboursement_tvq(jvm: float) -> dict:168 """Remboursement TVQ habitation neuve (paramètres Revenu Québec, partiellement vérifiés) : 50 % de la TVQ, max. 9 975 $,169 réduit au-delà de 200 000 $, nul à 300 000 $ et plus (réduction linéaire = interprétation)."""170 tvq = jvm * TVQ171 r = min(REMB_TVQ["taux"] * tvq, REMB_TVQ["max"])172 if jvm > REMB_TVQ["plein"]:173 r *= max((REMB_TVQ["nul"] - jvm) / (REMB_TVQ["nul"] - REMB_TVQ["plein"]), 0.0)174 return {"tvq": tvq, "remboursement": r}175176177def taxes_profils(cn_total: float, valeur_terrain: float = VALEUR_TERRAIN_HYP) -> pd.DataFrame:178 """Coût toutes taxes comprises d'une habitation neuve (bâtiment + terrain) selon le profil de l'acheteur :179 occupant (non premier acheteur), premier acheteur (travaux après le 20 mars 2025), investisseur inscrit (taxes récupérées)."""180 jvm = cn_total + valeur_terrain181 rows = []182 for profil, premier, inscrit in [("Occupant (acheteur subséquent)", False, False), ("Premier acheteur (travaux ≥ 2025-03-20)", True, False), ("Investisseur inscrit aux taxes", False, True)]:183 rt, rq = remboursement_tps(jvm, premier), remboursement_tvq(jvm)184 tps_nette = 0.0 if inscrit else rt["tps"] - rt["remboursement"]185 tvq_nette = 0.0 if inscrit else rq["tvq"] - rq["remboursement"]186 rows.append({"profil": profil, "prix_avant_taxes": jvm, "tps": rt["tps"], "tvq": rq["tvq"], "remb_tps": rt["remboursement"],187 "remb_tvq": rq["remboursement"], "taxes_nettes": tps_nette + tvq_nette, "cout_ttc": jvm + tps_nette + tvq_nette,188 "taux_effectif": (tps_nette + tvq_nette) / jvm})189 return pd.DataFrame(rows)190191192# ----------------------------------------------------------------------------- incitatif193194def incitatif_rendement_implicite(incitatif: float, valeur_terrain: float, couts_di: float, duree_ans: float) -> float:195 """Rendement annuel implicite du capital engagé : ρ = E / [T (V_L + (D+I)/2)] (terrain payé au départ, travaux tirés uniformément)."""196 return incitatif / (duree_ans * (valeur_terrain + couts_di / 2.0))197198199def effet_hausse_salariale(direct: float, part_mo: float, hausse: float) -> float:200 """ΔD = λ · Δw/w · D : effet d'une hausse salariale sur les coûts directs pour une part λ de main-d'œuvre (hypothèse déclarée)."""201 return direct * part_mo * hausse202203204# ----------------------------------------------------------------------------- multilogement (cas 3, hypothèses déclarées)205206def multilogement_cas3(devis: pd.DataFrame, cn: dict, sup: dict, ipcb: pd.DataFrame, taux: float, duree_ans: float = 1.0,207 efficacite: float = 0.85, part_pret: float = 0.75, periode: str = "2026-07") -> dict:208 """Coût neuf INDICATIF d'un immeuble de 12 logements (cas 3 : 825 m² locatifs, 3 étages) par transposition du devis du cas 1 :209 coût direct unitaire = D / aire brute du cas 1 × (IPCB immeuble < 5 étages / IPCB maison individuelle, Montréal, même trimestre) ;210 aire brute = superficie locative / efficacité ; indirects hors financement aux ratios du devis du cas 1 ;211 financement : prêt = part_pret × (D + I hors financement), mise de fonds d'abord, durée `duree_ans`, taux documenté ;212 incitatif 10 % sur D + I. Tout chiffre produit est une HYPOTHÈSE pédagogique (aucun barème multilogement public n'existe)."""213 import json214 sujet3 = json.loads((ROOT / "data/synthetic/cas3_montreal_sujet.json").read_text())215 s_loc = float(sujet3["superficie_locative_m2"]); n_log = int(sujet3["nb_logements"])216 s_brute = s_loc / efficacite217 r_type = c21.indice(ipcb, "Immeuble à hauteur restreinte (moins de cinq étages) [62111]", periode) / c21.indice(ipcb, "Maison individuelle [621112]", periode)218 cu_direct_cas1 = cn["direct"] / sup["brute_etages_m2"]219 cu_direct = cu_direct_cas1 * r_type220 D = cu_direct * s_brute221 ind = devis[devis["indirect"] & (devis["poste"] != POSTE_FIN)]222 ratios = (ind.set_index("poste")["montant"] / cn["direct"]).to_dict()223 I_hors_fin = {k: v * D for k, v in ratios.items()}224 base = D + sum(I_hors_fin.values())225 pret = part_pret * base226 f = fin.interets_intermediaires(pret, taux, duree_ans, "continu", mise_de_fonds=base - pret, cout_total=base)227 I = sum(I_hors_fin.values()) + f["interets"]228 res = ct.cout_neuf(D, I / D, TAUX_INCITATIF)229 return {"n_logements": n_log, "s_locative": s_loc, "s_brute": s_brute, "ratio_ipcb": r_type, "cu_direct_cas1": cu_direct_cas1, "cu_direct": cu_direct,230 "direct": D, "indirects_detail": I_hors_fin, "financement": f, "pret": pret, "indirects": I, "taux_indirects": I / D,231 "incitatif": res["incitatif"], "total": res["total"], "par_logement": res["total"] / n_log, "par_m2_locatif": res["total"] / s_loc,232 "par_m2_brut": res["total"] / s_brute, "duree_ans": duree_ans, "taux": taux}233234235# ----------------------------------------------------------------------------- IPCB : types, régions, divisions (réelles)236237TYPES_RES = ("Bâtiments résidentiels [621]", "Maison individuelle [621112]", "Maison en rangée [621112]",238 "Immeuble à hauteur restreinte (moins de cinq étages) [62111]", "Tour d'habitations (cinq étages ou plus) [621111]",239 "Immeubles d'appartements", "Bâtiments non résidentiels [622]")240GEOS = ("Montréal, Québec", "Québec, Québec", "Ottawa–Gatineau, partie ontarienne, Ontario/Québec", "Composite de quinze régions métropolitaines de recensement")241242243def ipcb_long(geos: tuple[str, ...] = GEOS, types: tuple[str, ...] = TYPES_RES, divisions: tuple[str, ...] | None = ("Agrégat des divisions",)) -> pd.DataFrame:244 """Lecture du CSV brut 18-10-0289-01 (2023 = 100) : géographie × type × division × trimestre."""245 rows = []246 with open(RAW_IPCB, encoding="utf-8-sig") as f:247 for r in csv.DictReader(f, delimiter=";"):248 if r["GÉO"] in geos and r["Type d'immeuble"] in types and (divisions is None or r["Division"] in divisions) and r["VALEUR"]:249 rows.append({"geo": r["GÉO"], "type": r["Type d'immeuble"], "division": r["Division"], "periode": r["PÉRIODE DE RÉFÉRENCE"],250 "indice": float(r["VALEUR"]), "vecteur": r["VECTEUR"]})251 df = pd.DataFrame(rows)252 df["date"] = pd.PeriodIndex(df["periode"].str.replace("-01", "Q1").str.replace("-04", "Q2").str.replace("-07", "Q3").str.replace("-10", "Q4"), freq="Q").to_timestamp()253 return df.sort_values(["geo", "type", "division", "date"]).reset_index(drop=True)254255256def val(df: pd.DataFrame, geo: str, type_: str, periode: str, division: str = "Agrégat des divisions") -> float:257 v = df[(df["geo"] == geo) & (df["type"] == type_) & (df["division"] == division) & (df["periode"] == periode)]["indice"]258 if v.empty:259 raise KeyError((geo, type_, periode, division))260 return float(v.iloc[0])261262263def variations_annuelles(df: pd.DataFrame) -> pd.DataFrame:264 """Variation sur quatre trimestres (a/a) de chaque série."""265 out = df.copy()266 out["var_aa"] = out.groupby(["geo", "type", "division"])["indice"].pct_change(4)267 return out268269270def tableau_types_regions(df: pd.DataFrame) -> pd.DataFrame:271 """T3 2025, T3 2026, variation a/a et variation depuis le T1 2020 (ou début de série) pour les séries résidentielles par région et type."""272 rows = []273 for geo in GEOS:274 for t in TYPES_RES:275 s = df[(df["geo"] == geo) & (df["type"] == t) & (df["division"] == "Agrégat des divisions")]276 if s.empty:277 continue278 try:279 v25, v26 = val(df, geo, t, "2025-07"), val(df, geo, t, "2026-07")280 except KeyError:281 continue282 debut = s.iloc[0]283 v20 = val(df, geo, t, "2020-01") if (s["periode"] == "2020-01").any() else None284 rows.append({"geo": geo, "type": t, "debut": debut["periode"], "t3_2025": v25, "t3_2026": v26, "var_aa": v26 / v25 - 1,285 "var_2020": (v26 / v20 - 1) if v20 else np.nan, "depuis_debut": v26 / debut["indice"] - 1})286 return pd.DataFrame(rows)287288289def divisions_maison(df_div: pd.DataFrame, type_: str = "Maison individuelle [621112]", geo: str = "Montréal, Québec",290 p0: str = "2020-01", p1: str = "2022-10", p2: str = "2025-07", p3: str = "2026-07") -> pd.DataFrame:291 """Variation par division (indices de division, SANS pondération : ce ne sont pas des contributions) sur deux périodes."""292 rows = []293 for d in sorted(df_div[(df_div["geo"] == geo) & (df_div["type"] == type_)]["division"].unique()):294 try:295 a, b, c, e = (val(df_div, geo, type_, p, d) for p in (p0, p1, p2, p3))296 except KeyError:297 continue298 rows.append({"division": d, p0: a, p1: b, p2: c, p3: e, "var_2020_2022": b / a - 1, "var_aa": e / c - 1})299 return pd.DataFrame(rows).sort_values("var_2020_2022", ascending=False).reset_index(drop=True)300301302# ----------------------------------------------------------------------------- sensibilité303304def tornade(devis: pd.DataFrame, cn: dict, taux: float, part_mo: float = 0.40) -> list[dict]:305 """Variation du coût neuf du cas 1 quand un seul paramètre bouge (les autres restant au cas de base = devis) :306 durée du chantier (6 → 18 mois), taux intérimaire (4 → 9 %), indirects hors financement (−5 → +5 points de D),307 hausse salariale (0 → 8 % avec λ = part_mo), incitatif (5 → 15 %)."""308 base = cn["total"]309 forfait = float(devis.loc[devis["poste"] == POSTE_FIN, "montant"].iloc[0])310 base_hf = cn["direct"] + cn["indirects"] - forfait311 def cn_fin(m, i): return cout_neuf_avec_financement(devis, cn, fin.interets_intermediaires(base_hf, i, m / 12)["interets"])["total"]312 I_hf = cn["indirects"] - forfait313 def cn_ind(delta): return ct.cout_neuf(cn["direct"], (I_hf + delta * cn["direct"] + forfait) / cn["direct"], TAUX_INCITATIF)["total"]314 def cn_sal(h):315 D2 = cn["direct"] + effet_hausse_salariale(cn["direct"], part_mo, h)316 return ct.cout_neuf(D2, cn["indirects"] / D2, TAUX_INCITATIF)["total"]317 out = [318 {"param": "Durée du chantier (taux 6,19 %)", "bas": cn_fin(6, taux), "haut": cn_fin(18, taux), "etiquette": "6 → 18 mois"},319 {"param": "Taux intérimaire (12 mois)", "bas": cn_fin(12, 0.04), "haut": cn_fin(12, 0.09), "etiquette": "4 % → 9 %"},320 {"param": "Indirects hors financement", "bas": cn_ind(-0.05), "haut": cn_ind(0.05), "etiquette": "−5 → +5 points de D"},321 {"param": f"Hausse salariale (λ = {part_mo:.0%})", "bas": cn_sal(0.0), "haut": cn_sal(0.08), "etiquette": "0 → +8 %"},322 {"param": "Taux d'incitatif", "bas": ct.cout_neuf(cn["direct"], cn["taux_indirects"], 0.05)["total"], "haut": ct.cout_neuf(cn["direct"], cn["taux_indirects"], 0.15)["total"], "etiquette": "5 % → 15 %"},323 ]324 for o in out:325 o["base"] = base326 return out327328329# ----------------------------------------------------------------------------- impression330331def main() -> dict:332 f = lambda x: f"{x:,.2f}".replace(",", " ")333 g = lambda x: f"{x:,.0f}".replace(",", " ")334 devis, sujet = c21.charger()335 cn = c21.cout_neuf_sujet(devis)336 sup = c21.superficies(sujet, devis)337 st = structure(devis, cn)338 ri = ratios_indirects(devis, cn)339 print("== DONNÉES SYNTHÉTIQUES — devis du cas 1 (38 postes), chiffres du ch. 21 réutilisés :")340 print(f" D = {f(cn['direct'])} $ ; I = {f(cn['indirects'])} $ ({cn['taux_indirects']*100:.2f} % de D) ; E = {f(cn['incitatif'])} $ ; C_N = {f(cn['total'])} $")341 print("== Structure complète (poste, corps, nature, montant, part du bloc, part de C_N) :")342 for _, r in st.iterrows():343 print(f" {r['poste'][:52]:52s} {r['corps'][:24]:24s} {r['nature']:12s} {f(r['montant']):>12s} {r['part_bloc']*100:6.2f} % {r['part_CN']*100:6.2f} %")344 pn = parts_nature(st)345 print("== Coûts directs par nature dominante :", {k: f"{g(v)} $ ({v/cn['direct']*100:.1f} %)" for k, v in pn.items()})346 print("== Ratios des cinq postes indirects (montant ; % D ; % D+I ; % C_N) :")347 for _, r in ri.iterrows():348 print(f" {r['poste']:42s} {f(r['montant']):>12s} {r['pct_D']*100:5.2f} {r['pct_DI']*100:5.2f} {r['pct_CN']*100:5.2f}")349 print(f" total indirects : {ri['pct_D'].sum()*100:.2f} % de D ; {ri['pct_DI'].sum()*100:.2f} % de D+I ; {ri['pct_CN'].sum()*100:.2f} % de C_N")350 print(f" hors supervision : {(ri['pct_D'].sum() - ri.loc[ri['poste']=='Supervision de chantier (salaires)', 'pct_D'].iloc[0])*100:.2f} % de D")351 # Banque du Canada352 i_aff = taux_affiche_5ans(); fa = taux_fonds_avances()353 print(f"== Banque du Canada : taux affiché 5 ans conventionnel {i_aff*100:.2f} % (2026-10-28) ; fonds avancés août 2026 : non assuré fixe ≥ 5 ans {fa['non_assure_fixe_5ans_plus']*100:.2f} %, non assuré total {fa['non_assure_total']*100:.2f} %, variable non assuré {fa['non_assure_variable']*100:.2f} %, assuré total {fa['assure_total']*100:.2f} %")354 # financement355 fc = financement_cas1(devis, cn, i_aff)356 print(f"== Financement intérimaire : forfait du devis {f(fc['forfait'])} $ ; base hors financement {f(fc['base_hors_financement'])} $")357 print(f" formule continue L·i·T/2 (L = 100 % base, i = {i_aff*100:.2f} %, T = 26 sem.) : {f(fc['interets_continu'])} $ ; mensuelle : {f(fc['interets_mensuel'])} $ ; capitalisée : {f(fc['interets_capitalise'])} $")358 print(f" produit i·T impliqué par le forfait : {fc['iT_implicite']:.5f} → durée {fc['duree_implicite_mois']:.1f} mois à {i_aff*100:.2f} %, ou taux {fc['taux_implicite_6mois']*100:.2f} % sur 6 mois")359 base_hf = fc["base_hors_financement"]360 eq = fin.interets_intermediaires(0.75 * base_hf, i_aff, 0.75, "continu", mise_de_fonds=0.25 * base_hf, cout_total=base_hf)361 print(f" variante mise de fonds d'abord (prêt 75 %, T = 9 mois) : t0 = {eq['t0']*12:.2f} mois, intérêts {f(eq['interets'])} $ (vs prêt 100 % sur 9 mois : {f(fin.interets_intermediaires(base_hf, i_aff, 0.75)['interets'])} $)")362 durees = [6, 9, 12, 18]; taux_g = [0.04, 0.05, round(i_aff, 4), 0.07, 0.09]363 G = grille_financement(base_hf, durees, taux_g)364 print(" grille intérêts (durée mois \\ taux) :", [f"{t*100:.2f} %" for t in taux_g])365 for k, m in enumerate(durees):366 cns = [cout_neuf_avec_financement(devis, cn, G[k, j])["total"] for j in range(len(taux_g))]367 print(f" {m:2d} mois : ", [g(v) for v in G[k]], " → C_N :", [g(v) for v in cns])368 cn_6 = cout_neuf_avec_financement(devis, cn, fc["interets_continu"])369 print(f" C_N avec intérêts calculés (6 mois, {i_aff*100:.2f} %) : {f(cn_6['total'])} $ (écart vs devis {f(cn_6['total']-cn['total'])} $)")370 # taxes371 tp = taxes_profils(cn["total"])372 print(f"== Taxes de vente (TPS {TPS*100:.0f} %, TVQ {TVQ*100:.3f} %) sur bâtiment + terrain hypothétique {g(VALEUR_TERRAIN_HYP)} $ :")373 for _, r in tp.iterrows():374 print(f" {r['profil']:42s} prix {g(r['prix_avant_taxes'])} ; TPS {g(r['tps'])} ; TVQ {g(r['tvq'])} ; remb. TPS {g(r['remb_tps'])} ; remb. TVQ {g(r['remb_tvq'])} ; taxes nettes {g(r['taxes_nettes'])} ({r['taux_effectif']*100:.2f} %) ; TTC {g(r['cout_ttc'])}")375 print(f" taxes sur le seul C_N : TPS {g(cn['total']*TPS)} $, TVQ {g(cn['total']*TVQ)} $, total {g(cn['total']*(TPS+TVQ))} $ ({(TPS+TVQ)*100:.3f} %)")376 ex = remboursement_tps(400000.0); print(f" exemple RC4028 : JVM 400 000 $ → TPS {g(ex['tps'])} $, remboursement général {f(ex['remboursement_general'])} $ (interpolation linéaire signalée) ; JVM 340 000 $ → {f(remboursement_tps(340000.0)['remboursement_general'])} $ ; TVQ JVM 250 000 $ → {f(remboursement_tvq(250000.0)['remboursement'])} $")377 # incitatif378 rho = incitatif_rendement_implicite(cn["incitatif"], VALEUR_TERRAIN_HYP, cn["direct"] + cn["indirects"], 0.75)379 print(f"== Incitatif : marge sur coût 10 % = marge sur prix {fin.marge_sur_prix(0.10)*100:.2f} % ; rendement implicite du capital (V_L {g(VALEUR_TERRAIN_HYP)} $, T = 9 mois) : {rho*100:.1f} %/an ; sur 6 mois : {incitatif_rendement_implicite(cn['incitatif'], VALEUR_TERRAIN_HYP, cn['direct']+cn['indirects'], 0.5)*100:.1f} %/an")380 print(f" parcs (LAU 117.4, ≤ 10 % de la valeur du site) : si applicable, au plus {g(0.10*VALEUR_TERRAIN_HYP)} $ pour un site de {g(VALEUR_TERRAIN_HYP)} $")381 # salaires382 for lam in (0.35, 0.40, 0.50):383 dD = effet_hausse_salariale(cn["direct"], lam, 0.08)384 print(f" hausse salariale 8 % avec λ = {lam:.2f} : ΔD = {g(dD)} $ ({dD/cn['direct']*100:.2f} % de D) → ΔC_N = {g(dD*(1+cn['taux_indirects'])*1.10)} $")385 # IPCB386 df = ipcb_long()387 tt = tableau_types_regions(df)388 print("== IPCB (18-10-0289-01, 2023 = 100) par région et type : T3 2025, T3 2026, a/a, depuis T1 2020, depuis début :")389 for _, r in tt.iterrows():390 v20 = f"{r['var_2020']*100:+.1f} %" if not np.isnan(r["var_2020"]) else " n.d."391 print(f" {r['geo'][:28]:28s} {r['type'][:40]:40s} {r['debut']} {r['t3_2025']:6.1f} {r['t3_2026']:6.1f} {r['var_aa']*100:+5.1f} % {v20} {r['depuis_debut']*100:+6.1f} %")392 va = variations_annuelles(df)393 mi = va[(va["geo"] == "Montréal, Québec") & (va["type"] == "Maison individuelle [621112]")]394 pic = mi.loc[mi["var_aa"].idxmax()]395 print(f" pic de variation a/a, maison individuelle Montréal : {pic['var_aa']*100:.1f} % au {pic['periode']} ; minimum : {mi['var_aa'].min()*100:.1f} % au {mi.loc[mi['var_aa'].idxmin(), 'periode']}")396 df_div = ipcb_long(("Montréal, Québec",), ("Maison individuelle [621112]", "Immeuble à hauteur restreinte (moins de cinq étages) [62111]"), None)397 dv = divisions_maison(df_div)398 print("== Divisions, maison individuelle Montréal : T1 2020, T4 2022, T3 2025, T3 2026, var 2020→2022, var a/a :")399 for _, r in dv.iterrows():400 print(f" {r['division']:48s} {r['2020-01']:6.1f} {r['2022-10']:6.1f} {r['2025-07']:6.1f} {r['2026-07']:6.1f} {r['var_2020_2022']*100:+6.1f} % {r['var_aa']*100:+5.1f} %")401 dv_lr = divisions_maison(df_div, "Immeuble à hauteur restreinte (moins de cinq étages) [62111]")402 print("== Divisions, immeuble < 5 étages Montréal (var 2020→2022, var a/a) :", {r["division"][:20]: (f"{r['var_2020_2022']*100:+.1f}", f"{r['var_aa']*100:+.1f}") for _, r in dv_lr.iterrows() if r["division"] in ("Agrégat des divisions", "Béton", "Bois, plastiques et composites", "Chauffage, ventilation et conditionnement d’air")})403 # multilogement404 ipcb21 = c21.ipcb_montreal()405 ml = multilogement_cas3(devis, cn, sup, ipcb21, i_aff)406 print(f"== Multilogement cas 3 (HYPOTHÈSES) : {ml['n_logements']} log., {ml['s_locative']:.0f} m² locatifs → aire brute {ml['s_brute']:.1f} m² (efficacité 85 %) ; ratio IPCB <5 ét./maison {ml['ratio_ipcb']:.4f}")407 print(f" coût direct unitaire cas 1 {f(ml['cu_direct_cas1'])} $/m² brut → {f(ml['cu_direct'])} $/m² ; D = {f(ml['direct'])} $")408 for k, v in ml["indirects_detail"].items():409 print(f" {k:42s} {f(v):>12s} $")410 print(f" financement : prêt {f(ml['pret'])} $ (75 %), t0 = {ml['financement']['t0']*12:.1f} mois, durée effective {ml['financement']['duree_effective_ans']*12:.1f} mois, intérêts {f(ml['financement']['interets'])} $")411 print(f" I = {f(ml['indirects'])} $ ({ml['taux_indirects']*100:.2f} % de D) ; E = {f(ml['incitatif'])} $ ; C_N = {f(ml['total'])} $ ; {g(ml['par_logement'])} $/logement ; {g(ml['par_m2_locatif'])} $/m² locatif ; {g(ml['par_m2_brut'])} $/m² brut")412 ml18 = multilogement_cas3(devis, cn, sup, ipcb21, 0.07, duree_ans=1.5)413 print(f" variante 18 mois à 7 % : intérêts {f(ml18['financement']['interets'])} $, C_N {f(ml18['total'])} $ ({g(ml18['par_logement'])} $/log.)")414 # tornade415 torn = tornade(devis, cn, i_aff)416 print("== Tornade (C_N du cas 1) :")417 for o in torn:418 print(f" {o['param']:34s} {o['etiquette']:20s} bas {g(o['bas'])} haut {g(o['haut'])} (base {g(o['base'])}) ; étendue {(o['haut']-o['bas'])/o['base']*100:+.1f} %")419 return {"devis": devis, "sujet": sujet, "cn": cn, "sup": sup, "structure": st, "ratios": ri, "nature": pn, "taux_affiche": i_aff, "fonds_avances": fa,420 "financement": fc, "grille": (durees, taux_g, G), "taxes": tp, "ipcb": df, "types_regions": tt, "divisions": dv, "divisions_lr": dv_lr,421 "multilogement": ml, "tornade": torn}422423424if __name__ == "__main__":425 main()426