#!/usr/bin/env python3 """Chapitre 23 — coûts directs et indirects : structure complète du coût neuf du sujet du cas 1 (DONNÉES SYNTHÉTIQUES), financement intérimaire, taxes de vente, incitatif, multilogement (cas 3) et conjoncture IPCB (données RÉELLES). Réutilise les fonctions du chapitre 21 (analysis/python/cases/ch21_cout_neuf.py : devis, regroupement, coût neuf, IPCB) et la bibliothèque traite (cout.cout_neuf, finance.interets_intermediaires, finance.marge_sur_prix) ; aucune formule financière n'est réécrite ici. Imprime tous les chiffres cités dans le chapitre 23 ; testé par analysis/python/tests/test_ch23.py. Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch23_structure_couts.py Paramètres documentés (data/raw et sources/notes-*.md) et hypothèses pédagogiques (déclarées comme telles dans le texte) : * taux d'intérêt intérimaire de référence : taux hypothécaire affiché 5 ans, prêt conventionnel, banques à charte, 6,19 % au 2026-10-28 (Banque du Canada, série V80691335, data/raw/bdc_valet/V80691335.csv) — lu dans le fichier brut ; un prêt de construction est en pratique tarifé autrement (taux variable + prime), ce taux sert de référence documentée ; * TPS 5 % et TVQ 9,975 % (ARC, page « Calculatrice de la TPS/TVH et taux », modifiée le 2025-04-01) ; * remboursement TPS habitation neuve : 36 % de la TPS, max. 6 300 $, plein jusqu'à 350 000 $, nul à 450 000 $ et plus (ARC, guide RC4028 Rév. 10/2026) ; remboursement premiers acheteurs : 100 % de la TPS jusqu'à 1 M$, nul à 1,5 M$, travaux commencés le 20 mars 2025 ou après ; remboursement TVQ : 50 %, max. 9 975 $, réduit au-delà de 200 000 $, nul à 300 000 $ (Revenu Québec, page citée par moteur de recherche ; page elle-même inaccessible, HTTP 403) ; * hypothèses : durée de chantier 26 semaines (= supervision du devis) ; valeur de terrain 180 000 $ (exercice 21.7, en attente du ch. 25) ; part de la main-d'œuvre dans les coûts directs λ ∈ [0,35 ; 0,50] ; multilogement : efficacité locative 85 %, durée 12 mois, prêt 75 %.""" from __future__ import annotations import sys, csv, pathlib import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) from traite import cout as ct, finance as fin import ch21_cout_neuf as c21 ROOT = pathlib.Path(__file__).resolve().parents[3] RAW_IPCB = ROOT / "data/raw/statcan_18100289/18100289.csv" RAW_BDC_AFFICHE = ROOT / "data/raw/bdc_valet/V80691335.csv" RAW_BDC_A4 = ROOT / "data/raw/bdc_valet/A4_RATES_MORTGAGES.csv" PI2_PAR_M2 = c21.PI2_PAR_M2 TAUX_INCITATIF = c21.TAUX_INCITATIF # 10 % sur D + I (convention de l'ouvrage, hypothèse pédagogique du ch. 21) TPS, TVQ = 0.05, 0.09975 # ARC, page « Calculatrice de la TPS/TVH et taux » (2025-04-01) REMB_TPS = {"taux": 0.36, "max": 6300.0, "plein": 350000.0, "nul": 450000.0} # RC4028 Rév. 10/2026 REMB_TPS_PREMIER = {"plein": 1_000_000.0, "nul": 1_500_000.0, "debut_travaux": "2025-03-20"} # RC4028 Rév. 10/2026 REMB_TVQ = {"taux": 0.50, "max": 9975.0, "plein": 200000.0, "nul": 300000.0} # Revenu Québec (partiellement vérifié) VALEUR_TERRAIN_HYP = 180000.0 # hypothèse (exercice 21.7) ; le ch. 25 établit la valeur du terrain DUREE_CHANTIER_ANS = 26 / 52 # 26 semaines de supervision dans le devis POSTE_FIN = "Financement intérimaire" # Nature dominante de chaque poste du devis (convention du chapitre : matériaux / main-d'œuvre / mixte / équipement / services) NATURE = { "Excavation et remblai": "équipement", "Fondations béton (semelles, murs)": "mixte", "Dalle de sous-sol": "mixte", "Drain français et imperméabilisation": "mixte", "Raccordements aqueduc/égout": "services", "Charpente murs et planchers": "mixte", "Fermes de toit": "matériaux", "Escaliers": "mixte", "Couverture bardeaux d'asphalte": "mixte", "Revêtement extérieur (brique 40 % / fibrociment 60 %)": "mixte", "Isolation et pare-vapeur": "mixte", "Fenêtres (PVC double vitrage)": "matériaux", "Portes extérieures": "matériaux", "Porte de garage": "matériaux", "Gouttières et solins": "mixte", "Gypse et tirage de joints": "main-d'œuvre", "Peinture intérieure": "main-d'œuvre", "Planchers bois franc": "mixte", "Planchers céramique": "mixte", "Planchers flottants (sous-sol)": "mixte", "Portes intérieures et moulures": "mixte", "Armoires de cuisine et comptoirs": "matériaux", "Vanités et accessoires (2 sdb)": "matériaux", "Plomberie brute et finition": "mixte", "Chauffe-eau": "matériaux", "Électricité (entrée 200 A, filage, appareils)": "mixte", "Ventilation (VRC) et conduits": "mixte", "Thermopompe centrale et plinthes": "matériaux", "Éclairage et finition électrique": "mixte", "Balcon et perron": "mixte", "Entrée asphaltée": "mixte", "Aménagement paysager de base": "mixte", "Nettoyage et conteneurs": "services", "Permis et études (arpentage, sol)": "indirect", "Supervision de chantier (salaires)": "indirect", "Assurances chantier et cautionnement": "indirect", "Plans et honoraires professionnels": "indirect", "Financement intérimaire": "indirect", } # ----------------------------------------------------------------------------- structure complète du devis (cas 1) def structure(devis: pd.DataFrame, cn: dict) -> pd.DataFrame: """Tableau complet des 38 postes : corps, nature, montant, part des directs (ou des indirects), part de C_N.""" d = devis.copy() d["nature"] = d["poste"].map(NATURE) assert d["nature"].notna().all() D, I, CN = cn["direct"], cn["indirects"], cn["total"] d["part_bloc"] = np.where(d["indirect"], d["montant"] / I, d["montant"] / D) d["part_D"] = d["montant"] / D d["part_CN"] = d["montant"] / CN d["ordre_corps"] = d["corps"].map({k: i for i, k in enumerate(c21.ORDRE_CORPS)}) return d.sort_values(["ordre_corps", "montant"], ascending=[True, False]).reset_index(drop=True) def ratios_indirects(devis: pd.DataFrame, cn: dict) -> pd.DataFrame: """Les cinq postes indirects : montant, % des directs, % de D+I, % de C_N.""" ind = devis[devis["indirect"]].copy() D, I, CN = cn["direct"], cn["indirects"], cn["total"] ind["pct_D"] = ind["montant"] / D ind["pct_DI"] = ind["montant"] / (D + I) ind["pct_CN"] = ind["montant"] / CN return ind[["poste", "montant", "pct_D", "pct_DI", "pct_CN"]].sort_values("montant", ascending=False).reset_index(drop=True) def parts_nature(d: pd.DataFrame) -> pd.Series: """Montants des coûts directs par nature dominante (convention du chapitre).""" return d[~d["indirect"]].groupby("nature")["montant"].sum().sort_values(ascending=False) # ----------------------------------------------------------------------------- données Banque du Canada (réelles) def taux_affiche_5ans(date: str = "2026-10-28") -> float: """Taux hypothécaire affiché 5 ans (prêt conventionnel), % → proportion, lu dans le CSV Valet brut.""" with open(RAW_BDC_AFFICHE, encoding="utf-8-sig") as f: rows = [r for r in csv.reader(f) if len(r) == 2 and r[0][:4].isdigit()] d = {r[0]: float(r[1]) for r in rows} return d[date] / 100.0 def taux_fonds_avances(date: str = "2026-08-01") -> dict: """Taux effectifs des fonds avancés (groupe A4), août 2026 : non assuré fixe ≥ 5 ans (V122667786), non assuré total (V122667781), assuré total (V122667775), non assuré variable (V122667782).""" with open(RAW_BDC_A4, encoding="utf-8-sig") as f: lines = list(csv.reader(f)) hdr = next(i for i, r in enumerate(lines) if r and r[0] == "date") cols = lines[hdr] row = next(r for r in lines[hdr + 1:] if r and r[0] == date) g = lambda v: float(row[cols.index(v)]) / 100.0 return {"non_assure_fixe_5ans_plus": g("V122667786"), "non_assure_total": g("V122667781"), "assure_total": g("V122667775"), "non_assure_variable": g("V122667782")} # ----------------------------------------------------------------------------- financement intérimaire def financement_cas1(devis: pd.DataFrame, cn: dict, taux: float, duree_ans: float = DUREE_CHANTIER_ANS) -> dict: """Vérifie le forfait « Financement intérimaire » du devis par la formule L·i·T/2 (prêt = 100 % des coûts hors financement).""" forfait = float(devis.loc[devis["poste"] == POSTE_FIN, "montant"].iloc[0]) base = cn["direct"] + cn["indirects"] - forfait # coûts directs + indirects hors financement cont = fin.interets_intermediaires(base, taux, duree_ans, "continu") mens = fin.interets_intermediaires(base, taux, duree_ans, "mensuel") cap = fin.interets_intermediaires(base, taux, duree_ans, "mensuel", capitaliser=True) iT = fin.taux_duree_implicites(forfait, base) return {"forfait": forfait, "base_hors_financement": base, "interets_continu": cont["interets"], "interets_mensuel": mens["interets"], "interets_capitalise": cap["interets"], "iT_implicite": iT, "duree_implicite_mois": iT / taux * 12, "taux_implicite_6mois": iT / duree_ans, "taux": taux, "duree_ans": duree_ans} def cout_neuf_avec_financement(devis: pd.DataFrame, cn: dict, interets: float, taux_incitatif: float = TAUX_INCITATIF) -> dict: """Coût neuf recalculé en remplaçant le forfait de financement du devis par un montant d'intérêts calculé.""" forfait = float(devis.loc[devis["poste"] == POSTE_FIN, "montant"].iloc[0]) I = cn["indirects"] - forfait + interets taux_ind = I / cn["direct"] out = ct.cout_neuf(cn["direct"], taux_ind, taux_incitatif) out["taux_indirects"] = taux_ind out["interets"] = interets return out def grille_financement(base: float, durees_mois: list[int], taux: list[float]) -> np.ndarray: """Intérêts intérimaires (tirage uniforme, continu) pour chaque couple (durée, taux).""" return np.array([[fin.interets_intermediaires(base, i, m / 12, "continu")["interets"] for i in taux] for m in durees_mois]) # ----------------------------------------------------------------------------- taxes de vente et remboursements def remboursement_tps(jvm: float, premier_acheteur: bool = False, travaux_apres_20mars2025: bool = True) -> dict: """Remboursement fédéral TPS/TVH pour habitation neuve (RC4028 Rév. 10/2026). Remboursement général : 36 % de la TPS, maximum 6 300 $, plein jusqu'à une JVM de 350 000 $, nul à 450 000 $ et plus ; la règle de réduction entre 350 001 et 449 999 $ n'est pas écrite explicitement dans le guide (il renvoie au formulaire) : une réduction linéaire est appliquée ici et SIGNALÉE comme interprétation. Premiers acheteurs (travaux commencés le 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).""" tps = jvm * TPS general = min(REMB_TPS["taux"] * tps, REMB_TPS["max"]) if jvm > REMB_TPS["plein"]: general *= max((REMB_TPS["nul"] - jvm) / (REMB_TPS["nul"] - REMB_TPS["plein"]), 0.0) premier = 0.0 if premier_acheteur and travaux_apres_20mars2025: 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) return {"tps": tps, "remboursement_general": general, "remboursement_premier_acheteur": premier, "remboursement": max(general, premier), "interpolation_appliquee": REMB_TPS["plein"] < jvm < REMB_TPS["nul"]} def remboursement_tvq(jvm: float) -> dict: """Remboursement TVQ habitation neuve (paramètres Revenu Québec, partiellement vérifiés) : 50 % de la TVQ, max. 9 975 $, réduit au-delà de 200 000 $, nul à 300 000 $ et plus (réduction linéaire = interprétation).""" tvq = jvm * TVQ r = min(REMB_TVQ["taux"] * tvq, REMB_TVQ["max"]) if jvm > REMB_TVQ["plein"]: r *= max((REMB_TVQ["nul"] - jvm) / (REMB_TVQ["nul"] - REMB_TVQ["plein"]), 0.0) return {"tvq": tvq, "remboursement": r} def taxes_profils(cn_total: float, valeur_terrain: float = VALEUR_TERRAIN_HYP) -> pd.DataFrame: """Coût toutes taxes comprises d'une habitation neuve (bâtiment + terrain) selon le profil de l'acheteur : occupant (non premier acheteur), premier acheteur (travaux après le 20 mars 2025), investisseur inscrit (taxes récupérées).""" jvm = cn_total + valeur_terrain rows = [] 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)]: rt, rq = remboursement_tps(jvm, premier), remboursement_tvq(jvm) tps_nette = 0.0 if inscrit else rt["tps"] - rt["remboursement"] tvq_nette = 0.0 if inscrit else rq["tvq"] - rq["remboursement"] rows.append({"profil": profil, "prix_avant_taxes": jvm, "tps": rt["tps"], "tvq": rq["tvq"], "remb_tps": rt["remboursement"], "remb_tvq": rq["remboursement"], "taxes_nettes": tps_nette + tvq_nette, "cout_ttc": jvm + tps_nette + tvq_nette, "taux_effectif": (tps_nette + tvq_nette) / jvm}) return pd.DataFrame(rows) # ----------------------------------------------------------------------------- incitatif def incitatif_rendement_implicite(incitatif: float, valeur_terrain: float, couts_di: float, duree_ans: float) -> float: """Rendement annuel implicite du capital engagé : ρ = E / [T (V_L + (D+I)/2)] (terrain payé au départ, travaux tirés uniformément).""" return incitatif / (duree_ans * (valeur_terrain + couts_di / 2.0)) def effet_hausse_salariale(direct: float, part_mo: float, hausse: float) -> float: """Δ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).""" return direct * part_mo * hausse # ----------------------------------------------------------------------------- multilogement (cas 3, hypothèses déclarées) def multilogement_cas3(devis: pd.DataFrame, cn: dict, sup: dict, ipcb: pd.DataFrame, taux: float, duree_ans: float = 1.0, efficacite: float = 0.85, part_pret: float = 0.75, periode: str = "2026-07") -> dict: """Coût neuf INDICATIF d'un immeuble de 12 logements (cas 3 : 825 m² locatifs, 3 étages) par transposition du devis du cas 1 : coût direct unitaire = D / aire brute du cas 1 × (IPCB immeuble < 5 étages / IPCB maison individuelle, Montréal, même trimestre) ; aire brute = superficie locative / efficacité ; indirects hors financement aux ratios du devis du cas 1 ; financement : prêt = part_pret × (D + I hors financement), mise de fonds d'abord, durée `duree_ans`, taux documenté ; incitatif 10 % sur D + I. Tout chiffre produit est une HYPOTHÈSE pédagogique (aucun barème multilogement public n'existe).""" import json sujet3 = json.loads((ROOT / "data/synthetic/cas3_montreal_sujet.json").read_text()) s_loc = float(sujet3["superficie_locative_m2"]); n_log = int(sujet3["nb_logements"]) s_brute = s_loc / efficacite r_type = c21.indice(ipcb, "Immeuble à hauteur restreinte (moins de cinq étages) [62111]", periode) / c21.indice(ipcb, "Maison individuelle [621112]", periode) cu_direct_cas1 = cn["direct"] / sup["brute_etages_m2"] cu_direct = cu_direct_cas1 * r_type D = cu_direct * s_brute ind = devis[devis["indirect"] & (devis["poste"] != POSTE_FIN)] ratios = (ind.set_index("poste")["montant"] / cn["direct"]).to_dict() I_hors_fin = {k: v * D for k, v in ratios.items()} base = D + sum(I_hors_fin.values()) pret = part_pret * base f = fin.interets_intermediaires(pret, taux, duree_ans, "continu", mise_de_fonds=base - pret, cout_total=base) I = sum(I_hors_fin.values()) + f["interets"] res = ct.cout_neuf(D, I / D, TAUX_INCITATIF) 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, "direct": D, "indirects_detail": I_hors_fin, "financement": f, "pret": pret, "indirects": I, "taux_indirects": I / D, "incitatif": res["incitatif"], "total": res["total"], "par_logement": res["total"] / n_log, "par_m2_locatif": res["total"] / s_loc, "par_m2_brut": res["total"] / s_brute, "duree_ans": duree_ans, "taux": taux} # ----------------------------------------------------------------------------- IPCB : types, régions, divisions (réelles) TYPES_RES = ("Bâtiments résidentiels [621]", "Maison individuelle [621112]", "Maison en rangée [621112]", "Immeuble à hauteur restreinte (moins de cinq étages) [62111]", "Tour d'habitations (cinq étages ou plus) [621111]", "Immeubles d'appartements", "Bâtiments non résidentiels [622]") GEOS = ("Montréal, Québec", "Québec, Québec", "Ottawa–Gatineau, partie ontarienne, Ontario/Québec", "Composite de quinze régions métropolitaines de recensement") def ipcb_long(geos: tuple[str, ...] = GEOS, types: tuple[str, ...] = TYPES_RES, divisions: tuple[str, ...] | None = ("Agrégat des divisions",)) -> pd.DataFrame: """Lecture du CSV brut 18-10-0289-01 (2023 = 100) : géographie × type × division × trimestre.""" rows = [] with open(RAW_IPCB, encoding="utf-8-sig") as f: for r in csv.DictReader(f, delimiter=";"): 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"]: rows.append({"geo": r["GÉO"], "type": r["Type d'immeuble"], "division": r["Division"], "periode": r["PÉRIODE DE RÉFÉRENCE"], "indice": float(r["VALEUR"]), "vecteur": r["VECTEUR"]}) df = pd.DataFrame(rows) 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() return df.sort_values(["geo", "type", "division", "date"]).reset_index(drop=True) def val(df: pd.DataFrame, geo: str, type_: str, periode: str, division: str = "Agrégat des divisions") -> float: v = df[(df["geo"] == geo) & (df["type"] == type_) & (df["division"] == division) & (df["periode"] == periode)]["indice"] if v.empty: raise KeyError((geo, type_, periode, division)) return float(v.iloc[0]) def variations_annuelles(df: pd.DataFrame) -> pd.DataFrame: """Variation sur quatre trimestres (a/a) de chaque série.""" out = df.copy() out["var_aa"] = out.groupby(["geo", "type", "division"])["indice"].pct_change(4) return out def tableau_types_regions(df: pd.DataFrame) -> pd.DataFrame: """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.""" rows = [] for geo in GEOS: for t in TYPES_RES: s = df[(df["geo"] == geo) & (df["type"] == t) & (df["division"] == "Agrégat des divisions")] if s.empty: continue try: v25, v26 = val(df, geo, t, "2025-07"), val(df, geo, t, "2026-07") except KeyError: continue debut = s.iloc[0] v20 = val(df, geo, t, "2020-01") if (s["periode"] == "2020-01").any() else None rows.append({"geo": geo, "type": t, "debut": debut["periode"], "t3_2025": v25, "t3_2026": v26, "var_aa": v26 / v25 - 1, "var_2020": (v26 / v20 - 1) if v20 else np.nan, "depuis_debut": v26 / debut["indice"] - 1}) return pd.DataFrame(rows) def divisions_maison(df_div: pd.DataFrame, type_: str = "Maison individuelle [621112]", geo: str = "Montréal, Québec", p0: str = "2020-01", p1: str = "2022-10", p2: str = "2025-07", p3: str = "2026-07") -> pd.DataFrame: """Variation par division (indices de division, SANS pondération : ce ne sont pas des contributions) sur deux périodes.""" rows = [] for d in sorted(df_div[(df_div["geo"] == geo) & (df_div["type"] == type_)]["division"].unique()): try: a, b, c, e = (val(df_div, geo, type_, p, d) for p in (p0, p1, p2, p3)) except KeyError: continue rows.append({"division": d, p0: a, p1: b, p2: c, p3: e, "var_2020_2022": b / a - 1, "var_aa": e / c - 1}) return pd.DataFrame(rows).sort_values("var_2020_2022", ascending=False).reset_index(drop=True) # ----------------------------------------------------------------------------- sensibilité def tornade(devis: pd.DataFrame, cn: dict, taux: float, part_mo: float = 0.40) -> list[dict]: """Variation du coût neuf du cas 1 quand un seul paramètre bouge (les autres restant au cas de base = devis) : durée du chantier (6 → 18 mois), taux intérimaire (4 → 9 %), indirects hors financement (−5 → +5 points de D), hausse salariale (0 → 8 % avec λ = part_mo), incitatif (5 → 15 %).""" base = cn["total"] forfait = float(devis.loc[devis["poste"] == POSTE_FIN, "montant"].iloc[0]) base_hf = cn["direct"] + cn["indirects"] - forfait def cn_fin(m, i): return cout_neuf_avec_financement(devis, cn, fin.interets_intermediaires(base_hf, i, m / 12)["interets"])["total"] I_hf = cn["indirects"] - forfait def cn_ind(delta): return ct.cout_neuf(cn["direct"], (I_hf + delta * cn["direct"] + forfait) / cn["direct"], TAUX_INCITATIF)["total"] def cn_sal(h): D2 = cn["direct"] + effet_hausse_salariale(cn["direct"], part_mo, h) return ct.cout_neuf(D2, cn["indirects"] / D2, TAUX_INCITATIF)["total"] out = [ {"param": "Durée du chantier (taux 6,19 %)", "bas": cn_fin(6, taux), "haut": cn_fin(18, taux), "etiquette": "6 → 18 mois"}, {"param": "Taux intérimaire (12 mois)", "bas": cn_fin(12, 0.04), "haut": cn_fin(12, 0.09), "etiquette": "4 % → 9 %"}, {"param": "Indirects hors financement", "bas": cn_ind(-0.05), "haut": cn_ind(0.05), "etiquette": "−5 → +5 points de D"}, {"param": f"Hausse salariale (λ = {part_mo:.0%})", "bas": cn_sal(0.0), "haut": cn_sal(0.08), "etiquette": "0 → +8 %"}, {"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 %"}, ] for o in out: o["base"] = base return out # ----------------------------------------------------------------------------- impression def main() -> dict: f = lambda x: f"{x:,.2f}".replace(",", " ") g = lambda x: f"{x:,.0f}".replace(",", " ") devis, sujet = c21.charger() cn = c21.cout_neuf_sujet(devis) sup = c21.superficies(sujet, devis) st = structure(devis, cn) ri = ratios_indirects(devis, cn) print("== DONNÉES SYNTHÉTIQUES — devis du cas 1 (38 postes), chiffres du ch. 21 réutilisés :") 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'])} $") print("== Structure complète (poste, corps, nature, montant, part du bloc, part de C_N) :") for _, r in st.iterrows(): 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} %") pn = parts_nature(st) print("== Coûts directs par nature dominante :", {k: f"{g(v)} $ ({v/cn['direct']*100:.1f} %)" for k, v in pn.items()}) print("== Ratios des cinq postes indirects (montant ; % D ; % D+I ; % C_N) :") for _, r in ri.iterrows(): 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}") 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") print(f" hors supervision : {(ri['pct_D'].sum() - ri.loc[ri['poste']=='Supervision de chantier (salaires)', 'pct_D'].iloc[0])*100:.2f} % de D") # Banque du Canada i_aff = taux_affiche_5ans(); fa = taux_fonds_avances() 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} %") # financement fc = financement_cas1(devis, cn, i_aff) print(f"== Financement intérimaire : forfait du devis {f(fc['forfait'])} $ ; base hors financement {f(fc['base_hors_financement'])} $") 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'])} $") 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") base_hf = fc["base_hors_financement"] eq = fin.interets_intermediaires(0.75 * base_hf, i_aff, 0.75, "continu", mise_de_fonds=0.25 * base_hf, cout_total=base_hf) 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'])} $)") durees = [6, 9, 12, 18]; taux_g = [0.04, 0.05, round(i_aff, 4), 0.07, 0.09] G = grille_financement(base_hf, durees, taux_g) print(" grille intérêts (durée mois \\ taux) :", [f"{t*100:.2f} %" for t in taux_g]) for k, m in enumerate(durees): cns = [cout_neuf_avec_financement(devis, cn, G[k, j])["total"] for j in range(len(taux_g))] print(f" {m:2d} mois : ", [g(v) for v in G[k]], " → C_N :", [g(v) for v in cns]) cn_6 = cout_neuf_avec_financement(devis, cn, fc["interets_continu"]) 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'])} $)") # taxes tp = taxes_profils(cn["total"]) print(f"== Taxes de vente (TPS {TPS*100:.0f} %, TVQ {TVQ*100:.3f} %) sur bâtiment + terrain hypothétique {g(VALEUR_TERRAIN_HYP)} $ :") for _, r in tp.iterrows(): 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'])}") 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} %)") 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'])} $") # incitatif rho = incitatif_rendement_implicite(cn["incitatif"], VALEUR_TERRAIN_HYP, cn["direct"] + cn["indirects"], 0.75) 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") 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)} $") # salaires for lam in (0.35, 0.40, 0.50): dD = effet_hausse_salariale(cn["direct"], lam, 0.08) 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)} $") # IPCB df = ipcb_long() tt = tableau_types_regions(df) 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 :") for _, r in tt.iterrows(): v20 = f"{r['var_2020']*100:+.1f} %" if not np.isnan(r["var_2020"]) else " n.d." 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} %") va = variations_annuelles(df) mi = va[(va["geo"] == "Montréal, Québec") & (va["type"] == "Maison individuelle [621112]")] pic = mi.loc[mi["var_aa"].idxmax()] 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']}") df_div = ipcb_long(("Montréal, Québec",), ("Maison individuelle [621112]", "Immeuble à hauteur restreinte (moins de cinq étages) [62111]"), None) dv = divisions_maison(df_div) print("== Divisions, maison individuelle Montréal : T1 2020, T4 2022, T3 2025, T3 2026, var 2020→2022, var a/a :") for _, r in dv.iterrows(): 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} %") dv_lr = divisions_maison(df_div, "Immeuble à hauteur restreinte (moins de cinq étages) [62111]") 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")}) # multilogement ipcb21 = c21.ipcb_montreal() ml = multilogement_cas3(devis, cn, sup, ipcb21, i_aff) 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}") print(f" coût direct unitaire cas 1 {f(ml['cu_direct_cas1'])} $/m² brut → {f(ml['cu_direct'])} $/m² ; D = {f(ml['direct'])} $") for k, v in ml["indirects_detail"].items(): print(f" {k:42s} {f(v):>12s} $") 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'])} $") 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") ml18 = multilogement_cas3(devis, cn, sup, ipcb21, 0.07, duree_ans=1.5) print(f" variante 18 mois à 7 % : intérêts {f(ml18['financement']['interets'])} $, C_N {f(ml18['total'])} $ ({g(ml18['par_logement'])} $/log.)") # tornade torn = tornade(devis, cn, i_aff) print("== Tornade (C_N du cas 1) :") for o in torn: 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} %") return {"devis": devis, "sujet": sujet, "cn": cn, "sup": sup, "structure": st, "ratios": ri, "nature": pn, "taux_affiche": i_aff, "fonds_avances": fa, "financement": fc, "grille": (durees, taux_g, G), "taxes": tp, "ipcb": df, "types_regions": tt, "divisions": dv, "divisions_lr": dv_lr, "multilogement": ml, "tornade": torn} if __name__ == "__main__": main()