SPB Git
31.4 KB · 426 lines python
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
Files · 3f09fc5fc3793c822f5612b9fe13f99a59fa3187
Menu

Theme