Ch. 16 rédigé (modèle) : texte, schéma, 4 figures, solutions, tests ; cleveref sans article ; compilation isolée avec étiquettes inter-chapitres
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
54 changed files +3,024 −37
modified
CONVENTIONS.md
+1 −0
@@ -41,6 +41,7 @@ Cible : **9 000 à 14 000 mots** par chapitre (prose), 3-6 figures, 4-8 tableaux | ||
| 41 | 41 | - Sigles français : RNE (NOI), RCD (DSCR), RPV (LTV), TRI (IRR), VAN (NPV), REB/RBE (revenu brut effectif), RBP (revenu brut potentiel), PHMU (plus haute et meilleure utilisation), MRB (multiplicateur du revenu brut), taux global d'actualisation (TGA) = taux de capitalisation global. |
| 42 | 42 | - Distinguer toujours : **prix** (observé), **valeur marchande** (estimation), **valeur au rôle** (fiscale, date de référence), **valeur d'investissement** (propre à un investisseur), **coût**. Ne jamais écrire « la valeur est de… » pour un prix observé. |
| 43 | 43 | - Distinguer **exigence professionnelle** (norme OEAQ/IVS/CUSPAP), **droit applicable** (loi, règlement, jurisprudence), **pratique courante** et **modèle expérimental**. Formules : « la norme exige », « la loi prévoit », « il est d'usage », « on peut modéliser ». |
| 44 | +- **Renvois** : `\cref{}` produit le nom SANS article (« chapitre 17 », « figure 16.2 », « tableau 16.4 », « équation (16.3) », « exercice 16.5 ») ; l'auteur écrit l'article et la préposition : « le \cref{ch:17} », « au \cref{ch:17} », « à la \cref{fig:16:nuage} », « l'\cref{eq:16:local} », « du \cref{tab:16:grille} ». En début de phrase, `\Cref{}` (majuscule, sans article) ou « Le \cref{} ». | |
| 44 | 45 | - Toute affirmation juridique cite l'article (ex. « art. 1955 C.c.Q. ») et la source bibliographique, avec date de consultation si c'est une page web ; toute statistique cite l'organisme, le tableau et la période. |
| 45 | 46 | - **Rien d'inventé.** Pas de référence, de loi, de norme, de chiffre « plausible ». En cas de doute : écrire la chose comme non vérifiée dans `limites` et l'inscrire dans `verification/claims.csv` avec `statut=non_verifie`. |
| 46 | 47 | |
added
analysis/python/cases/ch21_cout_neuf.py
+230 −0
@@ -0,0 +1,230 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Chapitre 21 — coût neuf (remplacement) du sujet du cas 1 (Gatineau, DONNÉES SYNTHÉTIQUES) et indexation IPCB (données réelles). | |
| 3 | + | |
| 4 | +Imprime tous les chiffres cités dans le chapitre 21 : | |
| 5 | + * devis par composantes (data/synthetic/cas1_gatineau_couts.csv, 38 postes) regroupé par corps de métier ; | |
| 6 | + * coûts directs, indirects, incitatif de l'entrepreneur (traite.cout.cout_neuf), coût neuf total, $/m² et $/pi² ; | |
| 7 | + * comparaison unitaire avec la fourchette Altus 2026 (155-215 $/pi², Montréal, unifamiliale) ; | |
| 8 | + * indexation par ratio d'indices IPCB Montréal (Statistique Canada 18-10-0289-01, données réelles dans data/raw) ; | |
| 9 | + * ajustement de taille (élasticité) ; | |
| 10 | + * analyse de sensibilité (taux d'indirects, incitatif, indice, élasticité). | |
| 11 | + | |
| 12 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch21_cout_neuf.py | |
| 13 | +Toutes les fonctions financières proviennent de analysis/python/traite/cout.py (aucune réécriture).""" | |
| 14 | +from __future__ import annotations | |
| 15 | +import sys, json, csv, pathlib | |
| 16 | +import numpy as np, pandas as pd | |
| 17 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 18 | +from traite import cout as ct | |
| 19 | + | |
| 20 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 21 | +D = ROOT / "data/synthetic" | |
| 22 | +RAW = ROOT / "data/raw/statcan_18100289/18100289.csv" | |
| 23 | + | |
| 24 | +PI2_PAR_M2 = 10.7639 | |
| 25 | +TAUX_INCITATIF = 0.10 # hypothèse pédagogique (voir texte, §21.6) ; assiette : directs + indirects | |
| 26 | +ALTUS_MIN, ALTUS_MAX = 155.0, 215.0 # $/pi², Altus Canadian Cost Guide 2026, Montréal, unifamiliale (tel que relayé) | |
| 27 | + | |
| 28 | +# Regroupement des 38 postes du devis en sept corps de métier (convention du chapitre) | |
| 29 | +CORPS = { | |
| 30 | + "Excavation et remblai": "Fondations et site", "Fondations béton (semelles, murs)": "Fondations et site", | |
| 31 | + "Dalle de sous-sol": "Fondations et site", "Drain français et imperméabilisation": "Fondations et site", | |
| 32 | + "Raccordements aqueduc/égout": "Fondations et site", | |
| 33 | + "Charpente murs et planchers": "Structure et charpente", "Fermes de toit": "Structure et charpente", | |
| 34 | + "Escaliers": "Structure et charpente", | |
| 35 | + "Couverture bardeaux d'asphalte": "Enveloppe", "Revêtement extérieur (brique 40 % / fibrociment 60 %)": "Enveloppe", | |
| 36 | + "Isolation et pare-vapeur": "Enveloppe", "Fenêtres (PVC double vitrage)": "Enveloppe", "Portes extérieures": "Enveloppe", | |
| 37 | + "Porte de garage": "Enveloppe", "Gouttières et solins": "Enveloppe", | |
| 38 | + "Gypse et tirage de joints": "Finition intérieure", "Peinture intérieure": "Finition intérieure", | |
| 39 | + "Planchers bois franc": "Finition intérieure", "Planchers céramique": "Finition intérieure", | |
| 40 | + "Planchers flottants (sous-sol)": "Finition intérieure", "Portes intérieures et moulures": "Finition intérieure", | |
| 41 | + "Armoires de cuisine et comptoirs": "Finition intérieure", "Vanités et accessoires (2 sdb)": "Finition intérieure", | |
| 42 | + "Plomberie brute et finition": "Mécanique et électricité", "Chauffe-eau": "Mécanique et électricité", | |
| 43 | + "Électricité (entrée 200 A, filage, appareils)": "Mécanique et électricité", "Ventilation (VRC) et conduits": "Mécanique et électricité", | |
| 44 | + "Thermopompe centrale et plinthes": "Mécanique et électricité", "Éclairage et finition électrique": "Mécanique et électricité", | |
| 45 | + "Balcon et perron": "Aménagements extérieurs", "Entrée asphaltée": "Aménagements extérieurs", | |
| 46 | + "Aménagement paysager de base": "Aménagements extérieurs", "Nettoyage et conteneurs": "Aménagements extérieurs", | |
| 47 | + "Permis et études (arpentage, sol)": "Coûts indirects", "Supervision de chantier (salaires)": "Coûts indirects", | |
| 48 | + "Assurances chantier et cautionnement": "Coûts indirects", "Plans et honoraires professionnels": "Coûts indirects", | |
| 49 | + "Financement intérimaire": "Coûts indirects", | |
| 50 | +} | |
| 51 | +ORDRE_CORPS = ["Fondations et site", "Structure et charpente", "Enveloppe", "Finition intérieure", | |
| 52 | + "Mécanique et électricité", "Aménagements extérieurs", "Coûts indirects"] | |
| 53 | + | |
| 54 | + | |
| 55 | +def charger() -> tuple[pd.DataFrame, dict]: | |
| 56 | + devis = pd.read_csv(D / "cas1_gatineau_couts.csv") | |
| 57 | + devis["corps"] = devis["poste"].map(CORPS) | |
| 58 | + assert devis["corps"].notna().all(), "poste sans corps de métier" | |
| 59 | + sujet = json.loads((D / "cas1_gatineau_sujet.json").read_text()) | |
| 60 | + return devis, sujet | |
| 61 | + | |
| 62 | + | |
| 63 | +def regrouper(devis: pd.DataFrame) -> pd.DataFrame: | |
| 64 | + g = devis.groupby("corps", sort=False)["montant"].agg(["sum", "count"]).reindex(ORDRE_CORPS) | |
| 65 | + g.columns = ["montant", "postes"] | |
| 66 | + g["part_direct_indirect"] = g["montant"] / g["montant"].sum() | |
| 67 | + return g | |
| 68 | + | |
| 69 | + | |
| 70 | +def cout_neuf_sujet(devis: pd.DataFrame, taux_incitatif: float = TAUX_INCITATIF, base: str = "direct+indirect") -> dict: | |
| 71 | + """Coût neuf du sujet : les indirects viennent du devis (5 postes), l'incitatif est appliqué par traite.cout.cout_neuf.""" | |
| 72 | + direct = float(devis.loc[~devis["indirect"], "montant"].sum()) | |
| 73 | + indirects = float(devis.loc[devis["indirect"], "montant"].sum()) | |
| 74 | + taux_ind = indirects / direct | |
| 75 | + cn = ct.cout_neuf(direct, taux_ind, taux_incitatif, base) | |
| 76 | + cn["taux_indirects"] = taux_ind | |
| 77 | + cn["taux_incitatif"] = taux_incitatif | |
| 78 | + return cn | |
| 79 | + | |
| 80 | + | |
| 81 | +def superficies(sujet: dict, devis: pd.DataFrame) -> dict: | |
| 82 | + """Superficie habitable (JSON du sujet) et aire brute des étages (hypothèse : empreinte = aire de la dalle de sous-sol × 2 étages).""" | |
| 83 | + hab = float(sujet["superficie_habitable_m2"]) | |
| 84 | + empreinte = float(devis.loc[devis["poste"] == "Dalle de sous-sol", "quantite"].iloc[0]) | |
| 85 | + brute = 2.0 * empreinte # cottage à deux étages hors sol (type « à étages ») | |
| 86 | + return {"habitable_m2": hab, "habitable_pi2": hab * PI2_PAR_M2, "empreinte_m2": empreinte, | |
| 87 | + "brute_etages_m2": brute, "brute_etages_pi2": brute * PI2_PAR_M2} | |
| 88 | + | |
| 89 | + | |
| 90 | +# ----------------------------------------------------------------------------- IPCB (données réelles) | |
| 91 | + | |
| 92 | +def ipcb_montreal(types: tuple[str, ...] = ("Bâtiments résidentiels [621]", "Maison individuelle [621112]", | |
| 93 | + "Immeuble à hauteur restreinte (moins de cinq étages) [62111]", | |
| 94 | + "Tour d'habitations (cinq étages ou plus) [621111]", | |
| 95 | + "Bâtiments non résidentiels [622]")) -> pd.DataFrame: | |
| 96 | + """Séries trimestrielles IPCB (2023 = 100), Montréal, agrégat des divisions, lues dans le CSV brut de Statistique Canada.""" | |
| 97 | + rows = [] | |
| 98 | + with open(RAW, encoding="utf-8-sig") as f: | |
| 99 | + for r in csv.DictReader(f, delimiter=";"): | |
| 100 | + if r["GÉO"] == "Montréal, Québec" and r["Division"] == "Agrégat des divisions" and r["Type d'immeuble"] in types and r["VALEUR"]: | |
| 101 | + rows.append({"periode": r["PÉRIODE DE RÉFÉRENCE"], "type": r["Type d'immeuble"], "indice": float(r["VALEUR"]), "vecteur": r["VECTEUR"]}) | |
| 102 | + df = pd.DataFrame(rows) | |
| 103 | + 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() | |
| 104 | + return df.sort_values(["type", "date"]).reset_index(drop=True) | |
| 105 | + | |
| 106 | + | |
| 107 | +def indice(df: pd.DataFrame, type_: str, periode: str) -> float: | |
| 108 | + v = df[(df["type"] == type_) & (df["periode"] == periode)]["indice"] | |
| 109 | + if v.empty: | |
| 110 | + raise KeyError(f"{type_} {periode} absent") | |
| 111 | + return float(v.iloc[0]) | |
| 112 | + | |
| 113 | + | |
| 114 | +# ----------------------------------------------------------------------------- sensibilité | |
| 115 | + | |
| 116 | +def grille_sensibilite(direct: float, taux_ind: np.ndarray, taux_inc: np.ndarray) -> np.ndarray: | |
| 117 | + """Coût neuf (assiette directs + indirects) pour chaque couple (taux d'indirects, taux d'incitatif).""" | |
| 118 | + return np.array([[ct.cout_neuf(direct, ti, tc)["total"] for tc in taux_inc] for ti in taux_ind]) | |
| 119 | + | |
| 120 | + | |
| 121 | +def tornade(direct: float, taux_ind0: float, taux_inc0: float, ratio_idx: tuple[float, float], elast: tuple[float, float], | |
| 122 | + d_ind: float = 0.05, d_inc: float = 0.05) -> list[dict]: | |
| 123 | + """Variation du coût neuf quand un seul paramètre bouge (bas / haut), les autres restant à leur valeur centrale.""" | |
| 124 | + base = ct.cout_neuf(direct, taux_ind0, taux_inc0)["total"] | |
| 125 | + out = [ | |
| 126 | + {"param": "Taux d'indirects", "bas": ct.cout_neuf(direct, taux_ind0 - d_ind, taux_inc0)["total"], "haut": ct.cout_neuf(direct, taux_ind0 + d_ind, taux_inc0)["total"], | |
| 127 | + "etiquette": f"{(taux_ind0-d_ind)*100:.1f} % → {(taux_ind0+d_ind)*100:.1f} %"}, | |
| 128 | + {"param": "Taux d'incitatif", "bas": ct.cout_neuf(direct, taux_ind0, taux_inc0 - d_inc)["total"], "haut": ct.cout_neuf(direct, taux_ind0, taux_inc0 + d_inc)["total"], | |
| 129 | + "etiquette": f"{(taux_inc0-d_inc)*100:.0f} % → {(taux_inc0+d_inc)*100:.0f} %"}, | |
| 130 | + {"param": "Indice de coûts (date)", "bas": ct.indexer_cout(base, 1.0, ratio_idx[0]), "haut": ct.indexer_cout(base, 1.0, ratio_idx[1]), | |
| 131 | + "etiquette": f"×{ratio_idx[0]:.3f} → ×{ratio_idx[1]:.3f}"}, | |
| 132 | + {"param": "Élasticité de taille", "bas": base * ct.cout_par_superficie_ajuste(1.0, 140.0, 165.0, elast[0]) / ct.cout_par_superficie_ajuste(1.0, 140.0, 165.0, -0.10), | |
| 133 | + "haut": base * ct.cout_par_superficie_ajuste(1.0, 140.0, 165.0, elast[1]) / ct.cout_par_superficie_ajuste(1.0, 140.0, 165.0, -0.10), | |
| 134 | + "etiquette": f"{elast[0]:.2f} → {elast[1]:.2f}"}, | |
| 135 | + ] | |
| 136 | + for o in out: | |
| 137 | + o["base"] = base | |
| 138 | + return out | |
| 139 | + | |
| 140 | + | |
| 141 | +def main() -> dict: | |
| 142 | + devis, sujet = charger() | |
| 143 | + g = regrouper(devis) | |
| 144 | + cn = cout_neuf_sujet(devis) | |
| 145 | + sup = superficies(sujet, devis) | |
| 146 | + f = lambda x: f"{x:,.2f}".replace(",", " ") | |
| 147 | + print("== DONNÉES SYNTHÉTIQUES — sujet cas 1 :", sujet["type"], sujet["annee_construction"], sujet["superficie_habitable_m2"], "m² hab.,", sujet["secteur"]) | |
| 148 | + print(f"== Devis : {len(devis)} postes ; {int((~devis['indirect']).sum())} directs, {int(devis['indirect'].sum())} indirects") | |
| 149 | + print("== Regroupement par corps de métier ($) :") | |
| 150 | + for k, r in g.iterrows(): | |
| 151 | + print(f" {k:28s} {int(r['postes']):2d} postes {f(r['montant']):>14s} {r['part_direct_indirect']*100:5.1f} %") | |
| 152 | + print(f"== Coûts directs D = {f(cn['direct'])} $") | |
| 153 | + print(f"== Coûts indirects I = {f(cn['indirects'])} $ (I/D = {cn['taux_indirects']*100:.2f} % ; I/(D+I) = {cn['indirects']/(cn['direct']+cn['indirects'])*100:.2f} %)") | |
| 154 | + print(f"== Sous-total D + I = {f(cn['direct']+cn['indirects'])} $") | |
| 155 | + print(f"== Incitatif {TAUX_INCITATIF*100:.0f} % (D+I) = {f(cn['incitatif'])} $") | |
| 156 | + print(f"== COÛT NEUF C_N = {f(cn['total'])} $ (arrondi {round(cn['total']/1000)*1000:,.0f} $)".replace(",", " ")) | |
| 157 | + print(f" part directs {cn['direct']/cn['total']*100:.1f} %, indirects {cn['indirects']/cn['total']*100:.1f} %, incitatif {cn['incitatif']/cn['total']*100:.1f} %") | |
| 158 | + alt = cout_neuf_sujet(devis, TAUX_INCITATIF, "direct") | |
| 159 | + print(f"== Variante assiette 'direct' : incitatif {f(alt['incitatif'])} $, C_N {f(alt['total'])} $ (écart {f(cn['total']-alt['total'])} $)") | |
| 160 | + print(f"== Superficies : habitable {sup['habitable_m2']:.0f} m² = {sup['habitable_pi2']:.0f} pi² ; empreinte {sup['empreinte_m2']:.0f} m² ; aire brute des étages {sup['brute_etages_m2']:.0f} m² = {sup['brute_etages_pi2']:.0f} pi²") | |
| 161 | + um_hab = cn["total"] / sup["habitable_m2"]; um_brut = cn["total"] / sup["brute_etages_m2"] | |
| 162 | + print(f"== C_N / m² habitable = {um_hab:,.0f} $/m² = {um_hab/PI2_PAR_M2:,.1f} $/pi²".replace(",", " ")) | |
| 163 | + print(f"== C_N / m² brut = {um_brut:,.0f} $/m² = {um_brut/PI2_PAR_M2:,.1f} $/pi²".replace(",", " ")) | |
| 164 | + di = cn["direct"] + cn["indirects"] | |
| 165 | + print(f"== (D+I) / m² habitable = {di/sup['habitable_m2']:,.0f} $/m² = {di/sup['habitable_m2']/PI2_PAR_M2:,.1f} $/pi² ; (D+I)/m² brut = {di/sup['brute_etages_m2']:,.0f} $/m² = {di/sup['brute_etages_m2']/PI2_PAR_M2:,.1f} $/pi²".replace(",", " ")) | |
| 166 | + # comparaison unitaire Altus | |
| 167 | + alt_mid = 0.5 * (ALTUS_MIN + ALTUS_MAX) | |
| 168 | + print(f"== Comparaison unitaire Altus 2026 ({ALTUS_MIN:.0f}-{ALTUS_MAX:.0f} $/pi², milieu {alt_mid:.0f}) :") | |
| 169 | + for nom, s_pi2 in [("aire brute des étages", sup["brute_etages_pi2"]), ("superficie habitable", sup["habitable_pi2"])]: | |
| 170 | + print(f" sur {nom} ({s_pi2:.0f} pi²) : {ALTUS_MIN*s_pi2:,.0f} – {ALTUS_MAX*s_pi2:,.0f} $, milieu {alt_mid*s_pi2:,.0f} $".replace(",", " ")) | |
| 171 | + print(f" milieu Altus en $/m² : {alt_mid*PI2_PAR_M2:,.0f} $/m²".replace(",", " ")) | |
| 172 | + # indexation IPCB | |
| 173 | + ipcb = ipcb_montreal() | |
| 174 | + res, mi = "Bâtiments résidentiels [621]", "Maison individuelle [621112]" | |
| 175 | + print("== IPCB Montréal (2023 = 100), données réelles Statistique Canada 18-10-0289-01 :") | |
| 176 | + for t in ipcb["type"].unique(): | |
| 177 | + s = ipcb[ipcb["type"] == t] | |
| 178 | + print(f" {t:60s} {len(s)} trimestres, {s['periode'].iloc[0]} → {s['periode'].iloc[-1]} ; premier {s['indice'].iloc[0]}, dernier {s['indice'].iloc[-1]} ({s['vecteur'].iloc[0]})") | |
| 179 | + pts = {p: indice(ipcb, mi, p) for p in ["2017-01", "2019-07", "2021-07", "2023-07", "2025-07", "2026-04", "2026-07"]} | |
| 180 | + print(" Maison individuelle :", pts) | |
| 181 | + ptr = {p: indice(ipcb, res, p) for p in ["2017-01", "2019-07", "2021-07", "2023-07", "2025-07", "2026-07"]} | |
| 182 | + print(" Bâtiments résidentiels :", ptr) | |
| 183 | + var_an = indice(ipcb, res, "2026-07") / indice(ipcb, res, "2025-07") - 1 | |
| 184 | + var_an_mi = indice(ipcb, mi, "2026-07") / indice(ipcb, mi, "2025-07") - 1 | |
| 185 | + print(f" Variation annuelle T3 2025 → T3 2026 : résidentiel {var_an*100:.2f} %, maison individuelle {var_an_mi*100:.2f} %") | |
| 186 | + r_2017 = indice(ipcb, mi, "2026-07") / indice(ipcb, mi, "2017-01") | |
| 187 | + r_2019 = indice(ipcb, mi, "2026-07") / indice(ipcb, mi, "2019-07") | |
| 188 | + r_2021 = indice(ipcb, mi, "2026-07") / indice(ipcb, mi, "2021-07") | |
| 189 | + print(f" Ratios maison individuelle : T3 2026 / T1 2017 = {r_2017:.4f} ; / T3 2019 = {r_2019:.4f} ; / T3 2021 = {r_2021:.4f}") | |
| 190 | + # taux annuel composé 2017T1→2026T3 (9,5 ans) | |
| 191 | + n_ans = (2026 + 6 / 12) - (2017 + 0 / 12) | |
| 192 | + print(f" Croissance annuelle composée T1 2017 → T3 2026 ({n_ans:.1f} ans) : {(r_2017**(1/n_ans)-1)*100:.2f} %/an") | |
| 193 | + # exemple d'indexation : coût du sujet exprimé en dollars du T3 2019 et du T3 2021 (rétropolation) | |
| 194 | + c_2019 = ct.indexer_cout(cn["total"], indice(ipcb, mi, "2026-07"), indice(ipcb, mi, "2019-07")) | |
| 195 | + c_2021 = ct.indexer_cout(cn["total"], indice(ipcb, mi, "2026-07"), indice(ipcb, mi, "2021-07")) | |
| 196 | + print(f" C_N du sujet en dollars du T3 2019 : {c_2019:,.0f} $ ; du T3 2021 : {c_2021:,.0f} $".replace(",", " ")) | |
| 197 | + # exemple direct : un devis de 2019T3 de 290 000 $ porté au T3 2026 | |
| 198 | + c0 = 290_000.0 | |
| 199 | + print(f" Exemple indexation : {c0:,.0f} $ (T3 2019) × {indice(ipcb, mi, '2026-07')}/{indice(ipcb, mi, '2019-07')} = {ct.indexer_cout(c0, indice(ipcb, mi, '2019-07'), indice(ipcb, mi, '2026-07')):,.0f} $".replace(",", " ")) | |
| 200 | + # erreur de série : non résidentiel et tours comme mauvais substituts | |
| 201 | + for t in ["Bâtiments non résidentiels [622]", "Tour d'habitations (cinq étages ou plus) [621111]", "Immeuble à hauteur restreinte (moins de cinq étages) [62111]"]: | |
| 202 | + r = indice(ipcb, t, "2026-07") / indice(ipcb, t, "2019-07") | |
| 203 | + print(f" Ratio T3 2026 / T3 2019 avec {t} : {r:.4f} (écart vs maison individuelle {(r/r_2019-1)*100:+.1f} %)") | |
| 204 | + # ajustement de taille | |
| 205 | + print("== Ajustement de taille (élasticité) :") | |
| 206 | + for e in [-0.05, -0.10, -0.15, -0.20]: | |
| 207 | + u = ct.cout_par_superficie_ajuste(um_hab, 165.0, 140.0, e) # coût unitaire d'une maison de 140 m² déduit du sujet | |
| 208 | + u2 = ct.cout_par_superficie_ajuste(um_hab, 165.0, 200.0, e) | |
| 209 | + print(f" e = {e:+.2f} : 140 m² → {u:,.0f} $/m² ({u*140:,.0f} $) ; 200 m² → {u2:,.0f} $/m² ({u2*200:,.0f} $)".replace(",", " ")) | |
| 210 | + # exemple du texte : coût unitaire de référence 2 300 $/m² observé sur 200 m², appliqué à 165 m² | |
| 211 | + u_ref = 2300.0 | |
| 212 | + for e in [0.0, -0.10]: | |
| 213 | + u = ct.cout_par_superficie_ajuste(u_ref, 200.0, 165.0, e) | |
| 214 | + print(f" Référence {u_ref:.0f} $/m² (200 m²) → 165 m² avec e = {e:+.2f} : {u:,.1f} $/m², coût {u*165:,.0f} $".replace(",", " ")) | |
| 215 | + # sensibilité | |
| 216 | + print("== Sensibilité (assiette directs + indirects) :") | |
| 217 | + ti = np.array([0.12, 0.15, 0.18, 0.2095, 0.24, 0.27]); tc = np.array([0.05, 0.08, 0.10, 0.12, 0.15]) | |
| 218 | + G = grille_sensibilite(cn["direct"], ti, tc) | |
| 219 | + print(" indirects \\ incitatif", [f"{x*100:.0f} %" for x in tc]) | |
| 220 | + for i, x in enumerate(ti): | |
| 221 | + print(f" {x*100:5.1f} % ", [f"{v:,.0f}".replace(",", " ") for v in G[i]]) | |
| 222 | + print(f" Étendue de la grille : {G.min():,.0f} – {G.max():,.0f} $ ({(G.max()/G.min()-1)*100:.1f} %)".replace(",", " ")) | |
| 223 | + torn = tornade(cn["direct"], cn["taux_indirects"], TAUX_INCITATIF, (1 / (1 + var_an_mi), 1 + var_an_mi), (-0.05, -0.20)) | |
| 224 | + for o in torn: | |
| 225 | + print(f" {o['param']:24s} {o['etiquette']:22s} bas {o['bas']:,.0f} haut {o['haut']:,.0f} (base {o['base']:,.0f}) ; étendue {(o['haut']-o['bas'])/o['base']*100:+.1f} %".replace(",", " ")) | |
| 226 | + return {"devis": devis, "sujet": sujet, "groupes": g, "cout_neuf": cn, "superficies": sup, "ipcb": ipcb, "grille": (ti, tc, G), "tornade": torn} | |
| 227 | + | |
| 228 | + | |
| 229 | +if __name__ == "__main__": | |
| 230 | + main() | |
added
analysis/python/cases/ch29_capitalisation.py
+227 −0
@@ -0,0 +1,227 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Chapitre 29 — Capitalisation directe : calculs du cas 3 (12 logements, Rosemont ; DONNÉES SYNTHÉTIQUES). | |
| 3 | + | |
| 4 | +Tous les chiffres cités dans manuscript/chapters/ch29.tex et solutions/ch29.tex sortent d'ici. Les calculs partagés | |
| 5 | +(RNE normalisé, taux extraits, valeur par capitalisation) viennent de traite.cas3 afin de coïncider avec les | |
| 6 | +chapitres 30 et 47 ; les formules financières viennent de traite.finance (aucune n'est réécrite ici). | |
| 7 | + | |
| 8 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch29_capitalisation.py | |
| 9 | +""" | |
| 10 | +from __future__ import annotations | |
| 11 | +import sys, pathlib | |
| 12 | +import numpy as np, pandas as pd | |
| 13 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 14 | +from traite import cas3, finance as fin | |
| 15 | + | |
| 16 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 17 | + | |
| 18 | +# Barème 2026 des droits sur les mutations immobilières, Ville de Montréal (sources/notes-B.md §, clé mtlmutations2026) : | |
| 19 | +# bornes supérieures des tranches ($) et taux marginaux. Facteur comparatif 2026 = 1,00 (LDMI art. 1.1 ; rôle 2026-2028). | |
| 20 | +MUTATIONS_MTL_2026 = [(62_900, 0.005), (315_000, 0.01), (552_300, 0.015), (1_104_700, 0.02), (2_136_500, 0.025), (3_113_000, 0.035), (float("inf"), 0.04)] | |
| 21 | +FACTEUR_COMPARATIF_MTL_2026 = 1.00 | |
| 22 | + | |
| 23 | + | |
| 24 | +def droits_mutation(base: float, bareme=MUTATIONS_MTL_2026) -> float: | |
| 25 | + """Droits progressifs par tranches (LDMI art. 2 ; barème municipal).""" | |
| 26 | + total, bas = 0.0, 0.0 | |
| 27 | + for haut, taux in bareme: | |
| 28 | + if base <= bas: | |
| 29 | + break | |
| 30 | + total += (min(base, haut) - bas) * taux | |
| 31 | + bas = haut | |
| 32 | + return total | |
| 33 | + | |
| 34 | + | |
| 35 | +def comparables_proches(c: pd.DataFrame, sujet: dict, k: int = 10, date_eval="2026-09-01") -> pd.DataFrame: | |
| 36 | + """Les k ventes les plus proches du sujet : taille, état, âge, quartier, ancienneté de la vente (distance pédagogique).""" | |
| 37 | + c = c.copy() | |
| 38 | + mois = (pd.Timestamp(date_eval) - c["date_vente"]).dt.days / 30.4375 | |
| 39 | + c["mois"] = mois | |
| 40 | + c["distance"] = (abs(c["nb_logements"] - sujet["nb_logements"]) / 6 + abs(c["etat_1a5"] - sujet["etat_1a5"]) | |
| 41 | + + abs(c["annee_construction"] - sujet["annee_construction"]) / 20 + 0.5 * (c["quartier"] != "Rosemont") + mois / 24) | |
| 42 | + return c.sort_values("distance").head(k) | |
| 43 | + | |
| 44 | + | |
| 45 | +def calculer() -> dict: | |
| 46 | + d = cas3.charger() | |
| 47 | + etat, dep, hyp, sujet, comps = d["etat"], d["dep"], d["hyp"], d["sujet"], d["comps"] | |
| 48 | + base = cas3.rne(etat, dep, hyp) | |
| 49 | + tx = cas3.taux_extraits(comps) | |
| 50 | + c = tx["comparables"] | |
| 51 | + R = tx["mediane"] | |
| 52 | + V = fin.capitalisation_directe(base["rne"], R) | |
| 53 | + proches = comparables_proches(c, sujet) | |
| 54 | + # régression : résidus | |
| 55 | + X = np.column_stack([np.ones(len(c)), c["etat_1a5"] - 3, c["nb_logements"] - 12]) | |
| 56 | + beta = np.array([tx["regression"]["constante"], tx["regression"]["etat"], tx["regression"]["taille"]]) | |
| 57 | + resid = c["taux_global"].values - X @ beta | |
| 58 | + reg = {**tx["regression"], "sigma_resid": float(resid.std(ddof=3)), "sigma_brut": float(c["taux_global"].std(ddof=1)), | |
| 59 | + "r2": float(1 - resid.var(ddof=0) / c["taux_global"].var(ddof=0)), "prevision_sujet": float(beta[0])} | |
| 60 | + V_reg = fin.capitalisation_directe(base["rne"], reg["prevision_sujet"]) | |
| 61 | + | |
| 62 | + # --- construction du taux ------------------------------------------------------------------------------------- | |
| 63 | + f_ = hyp["financement"] | |
| 64 | + h1 = fin.Hypotheque(1.0, f_["taux"], f_["amortissement_mois"]) # prêt unitaire : constante hypothécaire | |
| 65 | + f = h1.constante_hypothecaire | |
| 66 | + M = f_["rpv"] | |
| 67 | + re_implicite = (R - M * f) / (1 - M) # rendement courant des fonds propres impliqué par le marché | |
| 68 | + grille_re = {re: fin.taux_bande_investissement(M, f, re) for re in (0.04, 0.05, 0.06, 0.07, 0.08)} | |
| 69 | + R_rcd = fin.taux_par_rcd(f_["rcd_min"], M, f) | |
| 70 | + V_rcd = fin.capitalisation_directe(base["rne"], R_rcd) | |
| 71 | + r = hyp["taux_actualisation"]; Rt = hyp["taux_terminal"] | |
| 72 | + g_terminal = r - Rt # croissance impliquée par le taux terminal du cas 3 | |
| 73 | + gordon = {"r": r, "g_terminal": g_terminal, "R_g_terminal": fin.taux_gordon(r, g_terminal), | |
| 74 | + "g_tal": hyp["croissance_loyers_contrat"], "R_g_tal": fin.taux_gordon(r, hyp["croissance_loyers_contrat"]), | |
| 75 | + "g_implicite_marche": r - R} | |
| 76 | + n = hyp["horizon_ans"] | |
| 77 | + P = 1 - h1.solde(12 * n) # part du capital remboursée en n ans | |
| 78 | + sff = fin.facteur_fonds_amortissement(r, n) | |
| 79 | + delta = (1 + g_terminal) ** n - 1 # appréciation cohérente avec g_terminal | |
| 80 | + ellwood = {"Y": r, "M": M, "f": f, "P": P, "n": n, "SFF": sff, "delta": delta, | |
| 81 | + "R_delta0": fin.taux_ellwood(r, M, f, P, n, 0.0), "R_delta": fin.taux_ellwood(r, M, f, P, n, delta), | |
| 82 | + "R_akerson": fin.taux_akerson(r, M, f, P, n, delta), "R_sans_dette": fin.taux_ellwood(r, 0.0, f, P, n, delta), | |
| 83 | + "R_sans_dette_delta0": fin.taux_ellwood(r, 0.0, f, P, n, 0.0), | |
| 84 | + "composantes": {"M f": M * f, "(1-M) Y": (1 - M) * r, "-M P SFF": -M * P * sff, "-delta SFF": -delta * sff}} | |
| 85 | + ellwood["V_delta"] = fin.capitalisation_directe(base["rne"], ellwood["R_delta"]) | |
| 86 | + # lecture inverse : appréciation Δ cohérente avec le taux de marché (Y, M, f fixés) et rendement Y cohérent avec Δ fixé | |
| 87 | + ellwood["delta_implicite"] = (ellwood["R_delta0"] - R) / sff | |
| 88 | + lo, hi = 0.02, 0.30 | |
| 89 | + for _ in range(200): | |
| 90 | + mid = 0.5 * (lo + hi); Pm = 1 - h1.solde(12 * n) | |
| 91 | + if fin.taux_ellwood(mid, M, f, Pm, n, delta) < R: lo = mid | |
| 92 | + else: hi = mid | |
| 93 | + ellwood["Y_implicite"] = 0.5 * (lo + hi) | |
| 94 | + ellwood["composantes_marche"] = {"M f": M * f, "(1-M) Y": (1 - M) * r, "-M P SFF": -M * P * sff, "-delta SFF": -ellwood["delta_implicite"] * sff} | |
| 95 | + | |
| 96 | + # --- multiplicateurs ------------------------------------------------------------------------------------------- | |
| 97 | + mrb = {"mediane": float(c["mrb"].median()), "moyenne": float(c["mrb"].mean()), "min": float(c["mrb"].min()), "max": float(c["mrb"].max()), | |
| 98 | + "cv": float(c["mrb"].std(ddof=1) / c["mrb"].mean()), "cv_taux": float(c["taux_global"].std(ddof=1) / c["taux_global"].mean())} | |
| 99 | + mrb["V_contrat"] = mrb["mediane"] * base["rbp_contrat"]; mrb["V_marche"] = mrb["mediane"] * base["rbp_marche"] | |
| 100 | + mrb["mrn"] = 1 / R; mrb["mrb_sujet_implicite"] = V / base["rbp_contrat"] | |
| 101 | + mrb["ratio_dep_median_comps"] = float(c["ratio_depenses"].median()); mrb["ratio_dep_sujet_rbe"] = base["depenses_totales"] / base["rbe"] | |
| 102 | + | |
| 103 | + # --- cohérence RNE–taux ---------------------------------------------------------------------------------------- | |
| 104 | + taux_taxes = hyp["taux_taxe_fonciere"] + hyp["taux_taxe_scolaire"] | |
| 105 | + taxes_role = hyp["valeur_role_2026"] * taux_taxes | |
| 106 | + taxes_valeur = V * taux_taxes | |
| 107 | + coherence = {"taxes_role": taxes_role, "taxes_sur_valeur": taxes_valeur, "ecart_taxes": taxes_role - taxes_valeur, | |
| 108 | + "effet_valeur": (taxes_role - taxes_valeur) / R, "valeur_role": hyp["valeur_role_2026"], "ratio_valeur_role": V / hyp["valeur_role_2026"], | |
| 109 | + "reserve": base["reserve"], "reserve_par_logement": base["reserve"] / len(etat), | |
| 110 | + "R_avant_reserve_equiv": (base["rne"] + base["reserve"]) / V, "gestion": 0.04 * base["rbe"], | |
| 111 | + "V_si_taux_avant_reserve_sur_rne_apres": fin.capitalisation_directe(base["rne"], (base["rne"] + base["reserve"]) / V)} | |
| 112 | + # loyers de marché capitalisés à tort (baux en place) | |
| 113 | + rbe_m = fin.revenu_brut_effectif(base["rbp_marche"], base["taux_vacance_pertes"], base["autres_revenus"]) | |
| 114 | + de_m = base["depenses_hors_reserve"] - 0.04 * base["rbe"] + 0.04 * rbe_m | |
| 115 | + rne_m = fin.rne(rbe_m, de_m, base["reserve"]) | |
| 116 | + coherence["rne_marche"] = rne_m; coherence["V_marche"] = fin.capitalisation_directe(rne_m, R); coherence["surevaluation_marche"] = coherence["V_marche"] / V - 1 | |
| 117 | + # droits de mutation (encadré Québec) | |
| 118 | + base_droits = max(V, hyp["valeur_role_2026"] * FACTEUR_COMPARATIF_MTL_2026) | |
| 119 | + mut = {"base": base_droits, "droits": droits_mutation(base_droits), "droits_sur_prix": droits_mutation(V)} | |
| 120 | + mut["pct_prix"] = mut["droits"] / V | |
| 121 | + | |
| 122 | + # --- sensibilité ----------------------------------------------------------------------------------------------- | |
| 123 | + grille_R = np.round(np.arange(0.0375, 0.0526, 0.0025), 4) | |
| 124 | + sens = pd.DataFrame({"R": grille_R}) | |
| 125 | + for k, m in (("rne_m5", 0.95), ("rne", 1.0), ("rne_p5", 1.05)): | |
| 126 | + sens[k] = [fin.capitalisation_directe(base["rne"] * m, x) for x in grille_R] | |
| 127 | + sens["var_vs_base"] = sens["rne"] / V - 1 | |
| 128 | + derive = {"dV_dR": -V / R, "dV_25pb": -V / R * 0.0025, "V_plus25": fin.capitalisation_directe(base["rne"], R + 0.0025), | |
| 129 | + "V_moins25": fin.capitalisation_directe(base["rne"], R - 0.0025), "elasticite_R": -1.0, "elasticite_rne": 1.0} | |
| 130 | + derive["var_plus25_exacte"] = derive["V_plus25"] / V - 1; derive["var_plus25_lineaire"] = -0.0025 / R | |
| 131 | + | |
| 132 | + return {"base": base, "taux": tx, "R": R, "V": V, "V_arrondie": round(V / 5000) * 5000, "proches": proches, "mediane_proches": float(proches["taux_global"].median()), | |
| 133 | + "V_proches": fin.capitalisation_directe(base["rne"], float(proches["taux_global"].median())), "V_q1": fin.capitalisation_directe(base["rne"], tx["q3"]), "V_q3": fin.capitalisation_directe(base["rne"], tx["q1"]), | |
| 134 | + "regression": reg, "V_reg": V_reg, "f": f, "M": M, "re_implicite": re_implicite, "grille_re": grille_re, "R_rcd": R_rcd, "V_rcd": V_rcd, | |
| 135 | + "gordon": gordon, "ellwood": ellwood, "mrb": mrb, "coherence": coherence, "mutations": mut, "sensibilite": sens, "derive": derive, | |
| 136 | + "hyp": hyp, "sujet": sujet, "comps": comps} | |
| 137 | + | |
| 138 | + | |
| 139 | +def exercices(res: dict | None = None) -> dict: | |
| 140 | + """Chiffres des exercices 29.1 à 29.8 (énoncés dans ch29.tex, solutions dans solutions/ch29.tex).""" | |
| 141 | + res = res or calculer(); c = res["comps"].set_index("id_vente"); b = res["base"] | |
| 142 | + ex = {} | |
| 143 | + # 29.1 — capitalisation simple : RNE de 61 200 $ (énoncé), R = 4,75 % ; +25 pb | |
| 144 | + ex["1"] = {"rne": 61_200.0, "R": 0.0475, "V": fin.capitalisation_directe(61_200.0, 0.0475), "V_plus25": fin.capitalisation_directe(61_200.0, 0.05)} | |
| 145 | + ex["1"]["var"] = ex["1"]["V_plus25"] / ex["1"]["V"] - 1; ex["1"]["lineaire"] = -0.0025 / 0.0475 | |
| 146 | + # 29.2 — extraction sur cinq ventes du fichier cas 3 | |
| 147 | + ids = ["M008", "M011", "M022", "M034", "M040"]; s = c.loc[ids] | |
| 148 | + taux = s["rne"] / s["prix_vente"] | |
| 149 | + ex["2"] = {"ids": ids, "taux": taux.to_dict(), "mediane": float(taux.median()), "moyenne": float(taux.mean()), | |
| 150 | + "ponderee": float((s["rne"].sum()) / s["prix_vente"].sum()), "V_mediane": fin.capitalisation_directe(b["rne"], float(taux.median()))} | |
| 151 | + # 29.3 — convention de la réserve : un comparable de 18 logements (énoncé) ; réserve 675 $/logement | |
| 152 | + rne_apres, reserve, prix = 150_000.0, 18 * 675.0, 3_400_000.0 | |
| 153 | + R_apres = fin.taux_global_extrait(rne_apres, prix); R_avant = fin.taux_global_extrait(rne_apres + reserve, prix) | |
| 154 | + ex["3"] = {"R_apres": R_apres, "R_avant": R_avant, "V_correct": fin.capitalisation_directe(b["rne"], R_apres), | |
| 155 | + "V_errone": fin.capitalisation_directe(b["rne"], R_avant), "V_coherent_avant": fin.capitalisation_directe(b["rne"] + b["reserve"], R_avant)} | |
| 156 | + ex["3"]["erreur"] = ex["3"]["V_errone"] / ex["3"]["V_correct"] - 1 | |
| 157 | + # 29.4 — bande d'investissement et RCD (R_E = 6 % et RCD = 1,25 : hypothèses de l'énoncé) | |
| 158 | + f = res["f"]; M = res["M"] | |
| 159 | + ex["4"] = {"f": f, "R_bande": fin.taux_bande_investissement(M, f, 0.06), "R_rcd": fin.taux_par_rcd(1.25, M, f), | |
| 160 | + "V_bande": fin.capitalisation_directe(b["rne"], fin.taux_bande_investissement(M, f, 0.06)), | |
| 161 | + "V_rcd": fin.capitalisation_directe(b["rne"], fin.taux_par_rcd(1.25, M, f)), "re_implicite": res["re_implicite"]} | |
| 162 | + # 29.5 — Gordon | |
| 163 | + g = res["gordon"] | |
| 164 | + ex["5"] = {"R_225": g["R_g_terminal"], "R_310": g["R_g_tal"], "g_implicite": g["g_implicite_marche"], | |
| 165 | + "V_225": fin.capitalisation_directe(b["rne"], g["R_g_terminal"]), "V_310": fin.capitalisation_directe(b["rne"], g["R_g_tal"]), | |
| 166 | + "R_g4": fin.taux_gordon(g["r"], 0.04), "V_g4": fin.capitalisation_directe(b["rne"], fin.taux_gordon(g["r"], 0.04))} | |
| 167 | + # 29.6 — Ellwood / Akerson (Δ = 25 % sur 10 ans, énoncé) | |
| 168 | + e = res["ellwood"] | |
| 169 | + ex["6"] = {"P": e["P"], "SFF": e["SFF"], "R_ellwood": fin.taux_ellwood(e["Y"], e["M"], e["f"], e["P"], e["n"], 0.25), | |
| 170 | + "R_akerson": fin.taux_akerson(e["Y"], e["M"], e["f"], e["P"], e["n"], 0.25), "R_sans_dette": fin.taux_ellwood(e["Y"], 0.0, e["f"], e["P"], e["n"], 0.25), | |
| 171 | + "R_delta0": e["R_delta0"], "g_equiv": 1.25 ** (1 / e["n"]) - 1} | |
| 172 | + ex["6"]["R_gordon_equiv"] = fin.taux_gordon(e["Y"], ex["6"]["g_equiv"]); ex["6"]["V"] = fin.capitalisation_directe(b["rne"], ex["6"]["R_ellwood"]) | |
| 173 | + # 29.7 — loyers de marché vs baux en place ; MRB | |
| 174 | + co = res["coherence"]; m = res["mrb"] | |
| 175 | + ex["7"] = {"rne_marche": co["rne_marche"], "V_marche": co["V_marche"], "surevaluation": co["surevaluation_marche"], "V_contrat": res["V"], | |
| 176 | + "V_mrb_contrat": m["V_contrat"], "V_mrb_marche": m["V_marche"], "ecart_rbp": b["ecart_marche"], "v": b["taux_vacance_pertes"], | |
| 177 | + "ecart_rne": co["rne_marche"] - b["rne"], "V_mrb_vs_cap": m["V_contrat"] / res["V"] - 1} | |
| 178 | + # 29.8 — régression et sensibilité croisée | |
| 179 | + r = res["regression"] | |
| 180 | + R24 = r["constante"] + r["etat"] * (2 - 3) + r["taille"] * (24 - 12) | |
| 181 | + ex["8"] = {"R_24_etat2": R24, "R_6_etat4": r["constante"] + r["etat"] * (4 - 3) + r["taille"] * (6 - 12), "sigma_resid": r["sigma_resid"], | |
| 182 | + "V_pire": fin.capitalisation_directe(b["rne"] * 0.95, res["R"] + 0.0025), "V_meilleur": fin.capitalisation_directe(b["rne"] * 1.05, res["R"] - 0.0025)} | |
| 183 | + ex["8"]["var_pire"] = ex["8"]["V_pire"] / res["V"] - 1; ex["8"]["var_meilleur"] = ex["8"]["V_meilleur"] / res["V"] - 1 | |
| 184 | + ex["8"]["approx_pire"] = -0.05 - 0.0025 / res["R"]; ex["8"]["approx_meilleur"] = 0.05 + 0.0025 / res["R"] | |
| 185 | + return ex | |
| 186 | + | |
| 187 | + | |
| 188 | +def _p(x, dec=0): | |
| 189 | + return f"{x:,.{dec}f}".replace(",", " ") | |
| 190 | + | |
| 191 | + | |
| 192 | +def main(): | |
| 193 | + res = calculer(); b = res["base"]; tx = res["taux"] | |
| 194 | + pd.set_option("display.width", 220); pd.set_option("display.max_columns", 30) | |
| 195 | + print("=== Cas 3 (synthétique) — revenus et RNE normalisé (traite.cas3) ===") | |
| 196 | + for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "vacance_pertes", "autres_revenus", "rbe", "depenses_hors_reserve", "reserve", "depenses_totales", "rne", "rne_par_logement"]: | |
| 197 | + print(f" {k:24s} {_p(b[k], 2)}") | |
| 198 | + print(f" ratio dépenses/RBE {b['ratio_depenses']:.4f}") | |
| 199 | + print(b["detail"].round(2).to_string(index=False)) | |
| 200 | + print("\n=== Extraction du taux (40 ventes, 6-30 logements, 24 mois) ===") | |
| 201 | + for k in ["n", "mediane", "moyenne", "ponderee_rne", "q1", "q3", "min", "max"]: | |
| 202 | + print(f" {k:14s} {tx[k]}") | |
| 203 | + print(" régression :", {k: round(v, 6) for k, v in res["regression"].items()}) | |
| 204 | + print(f" V (RNE / médiane) = {_p(b['rne'], 2)} / {res['R']:.5f} = {_p(res['V'], 2)} (arrondie {_p(res['V_arrondie'])}) ; V au taux de régression = {_p(res['V_reg'])}") | |
| 205 | + print(f" 10 comparables les plus proches (médiane {res['mediane_proches']:.5f}) :") | |
| 206 | + cols = ["id_vente", "date_vente", "quartier", "nb_logements", "annee_construction", "etat_1a5", "rne", "prix_vente", "taux_global", "mrb", "distance"] | |
| 207 | + pr = res["proches"][cols].copy(); pr["date_vente"] = pr["date_vente"].dt.date; print(pr.round(3).to_string(index=False)) | |
| 208 | + print("\n=== Construction du taux ===") | |
| 209 | + print(f" constante hypothécaire f (4,12 %, 480 mois, composition semestrielle) = {res['f']:.6f} ; M = {res['M']}") | |
| 210 | + print(f" bande d'investissement : R_E impliqué par R = {res['R']:.5f} → {res['re_implicite']:.5f} ; grille R(R_E) = {{ {', '.join(f'{k:.2f}: {v:.5f}' for k, v in res['grille_re'].items())} }}") | |
| 211 | + print(f" formule RCD : R = 1,20 × 0,75 × f = {res['R_rcd']:.5f} → V = {_p(res['V_rcd'])}") | |
| 212 | + print(" Gordon :", {k: round(v, 5) for k, v in res["gordon"].items()}) | |
| 213 | + e = res["ellwood"]; print(" Ellwood/Akerson :", {k: (round(v, 6) if isinstance(v, float) else v) for k, v in e.items() if not k.startswith("composantes")}) | |
| 214 | + print(" composantes (Δ du cas) :", {k: round(v, 6) for k, v in e["composantes"].items()}); print(" composantes (Δ implicite) :", {k: round(v, 6) for k, v in e["composantes_marche"].items()}) | |
| 215 | + print(f" V (médiane des 10 proches {res['mediane_proches']:.5f}) = {_p(res['V_proches'])} ; V(Q3 des taux) = {_p(res['V_q1'])} ; V(Q1 des taux) = {_p(res['V_q3'])}") | |
| 216 | + print("\n=== Multiplicateurs ===", {k: round(v, 4) for k, v in res["mrb"].items()}) | |
| 217 | + print("\n=== Cohérence RNE–taux ===", {k: round(v, 4) for k, v in res["coherence"].items()}) | |
| 218 | + print("=== Droits de mutation Montréal 2026 ===", {k: round(v, 4) for k, v in res["mutations"].items()}) | |
| 219 | + print("\n=== Sensibilité ===") | |
| 220 | + print(res["sensibilite"].round(4).to_string(index=False)); print(" dérivée :", {k: round(v, 6) for k, v in res["derive"].items()}) | |
| 221 | + print("\n=== Exercices ===") | |
| 222 | + for k, v in exercices(res).items(): | |
| 223 | + print(f" 29.{k} :", {kk: (round(vv, 6) if isinstance(vv, float) else vv) for kk, vv in v.items()}) | |
| 224 | + | |
| 225 | + | |
| 226 | +if __name__ == "__main__": | |
| 227 | + main() | |
added
analysis/python/cases/ch30_dcf.py
+297 −0
@@ -0,0 +1,297 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Chapitre 30 — Flux actualisés et valeur terminale : exemple intégral du cas 3 (12 logements, Montréal, | |
| 3 | +DONNÉES SYNTHÉTIQUES). Tous les chiffres cités dans manuscript/chapters/ch30.tex et solutions/ch30.tex | |
| 4 | +proviennent de ce script, qui n'appelle que la bibliothèque commune (traite.cas3, traite.finance). | |
| 5 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch30_dcf.py | |
| 6 | +Les fonctions sont importées par les scripts de figures (analysis/python/figures/ch30_*.py) et par | |
| 7 | +analysis/python/tests/test_ch30.py.""" | |
| 8 | +from __future__ import annotations | |
| 9 | +import sys, copy, pathlib | |
| 10 | +import numpy as np, pandas as pd | |
| 11 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 12 | +from traite import cas3, finance as fin | |
| 13 | + | |
| 14 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 15 | + | |
| 16 | +# ----------------------------------------------------------------------------- cœur du cas 3 | |
| 17 | + | |
| 18 | +def base() -> dict: | |
| 19 | + """Tout le cas 3 (RNE normalisé, taux extraits, projection 11 ans, DCF, capitalisation directe, financement).""" | |
| 20 | + return cas3.tout() | |
| 21 | + | |
| 22 | +def tableau_dcf(r: dict) -> pd.DataFrame: | |
| 23 | + """Tableau DCF complet : projection + facteur d'actualisation + valeur actuelle + VA cumulée (années 1..T, puis T+1).""" | |
| 24 | + h = r["hyp"]; T = h["horizon_ans"]; taux = h["taux_actualisation"] | |
| 25 | + p = r["projection"].copy() | |
| 26 | + p["facteur"] = [fin.facteur_va(taux, int(t)) if t <= T else np.nan for t in p["annee"]] | |
| 27 | + p["va"] = p["flux_avant_dette"] * p["facteur"] | |
| 28 | + p["va_cumulee"] = p["va"].cumsum() | |
| 29 | + return p | |
| 30 | + | |
| 31 | +def dcf_sans_levier(r: dict) -> dict: | |
| 32 | + """Flux de l'investisseur non endetté qui paie V_0 : TRI, VAN au taux d'actualisation, délai de récupération, MIRR.""" | |
| 33 | + d = r["dcf"]; h = r["hyp"] | |
| 34 | + flux = [-d["valeur"]] + list(d["flux"]); flux[-1] += d["vt_nette"] | |
| 35 | + taux_r = h["taux_actualisation"] | |
| 36 | + return {"flux": flux, "tri": fin.tri(flux), "van_au_taux": fin.van(flux, taux_r), | |
| 37 | + "delai_recuperation": fin.delai_recuperation(flux), | |
| 38 | + "mirr": fin.tri_modifie(flux, taux_r, taux_r)} | |
| 39 | + | |
| 40 | +def taux_implicites(r: dict) -> dict: | |
| 41 | + """Lecture croisée DCF / capitalisation directe : taux implicite du DCF, croissance implicite, TRI si l'on paie la | |
| 42 | + valeur par capitalisation directe, croissance annualisée du RNE sur l'horizon.""" | |
| 43 | + d = r["dcf"]; h = r["hyp"]; p = r["projection"]; T = h["horizon_ans"] | |
| 44 | + rne1 = float(p.loc[p["annee"] == 1, "rne"].iloc[0]); rneT1 = float(p.loc[p["annee"] == T + 1, "rne"].iloc[0]) | |
| 45 | + R_med = r["taux"]["mediane"]; taux_r = h["taux_actualisation"]; Rt = h["taux_terminal"] | |
| 46 | + V_cap = r["valeur_capitalisation"] | |
| 47 | + flux_cap = [-V_cap] + list(d["flux"]); flux_cap[-1] += d["vt_nette"] | |
| 48 | + return {"R_implicite_dcf": rne1 / d["valeur"], "g_implicite_gordon_R_med": taux_r - R_med, "g_implicite_terminal": taux_r - Rt, | |
| 49 | + "croissance_rne_annualisee": (rneT1 / rne1) ** (1 / T) - 1, "croissance_rbp_annualisee": (float(p.loc[p["annee"] == T + 1, "rbp"].iloc[0]) / float(p.loc[p["annee"] == 1, "rbp"].iloc[0])) ** (1 / T) - 1, | |
| 50 | + "tri_si_paye_V_cap": fin.tri(flux_cap), "ecart_dcf_cap": d["valeur"] - V_cap, "ecart_dcf_cap_pct": d["valeur"] / V_cap - 1, | |
| 51 | + "V_gordon_g_marche": fin.capitalisation_directe(rne1, fin.taux_gordon(taux_r, h["croissance_loyers_marche"])), | |
| 52 | + "V_cap_au_taux_terminal": fin.capitalisation_directe(rne1, Rt)} | |
| 53 | + | |
| 54 | +def flux_levier(r: dict) -> dict: | |
| 55 | + """Flux après service de la dette (financement de traite.cas3.financement) et TRI des fonds propres avec levier. | |
| 56 | + Les CAPEX sont supportés par les fonds propres : la série passée à rendement_fonds_propres_levier est le flux avant dette (RNE − CAPEX).""" | |
| 57 | + d = r["dcf"]; f = r["financement"]; h = r["hyp"]; T = h["horizon_ans"] | |
| 58 | + hyp = f["hypotheque"] | |
| 59 | + res = fin.rendement_fonds_propres_levier(d["flux"], d["valeur"], hyp, d["vt_nette"]) | |
| 60 | + apres_dette = fin.flux_apres_financement(d["flux"], hyp) | |
| 61 | + rne = r["projection"].loc[r["projection"]["annee"] <= T, "rne"].tolist() | |
| 62 | + tab = pd.DataFrame({"annee": range(1, T + 1), "rne": rne, "capex": [c for c in (r["projection"].loc[r["projection"]["annee"] <= T, "capex"])], | |
| 63 | + "flux_avant_dette": d["flux"], "service_dette": [hyp.service_dette_annuel] * T, "flux_apres_dette": apres_dette, | |
| 64 | + "rcd": [x / hyp.service_dette_annuel for x in rne]}) | |
| 65 | + tab["flux_fonds_propres"] = res["flux"][1:] | |
| 66 | + signes = int(sum(1 for a, b in zip(res["flux"][:-1], res["flux"][1:]) if a * b < 0)) | |
| 67 | + mirr = fin.tri_modifie(res["flux"], h["financement"]["taux"], h["taux_actualisation"]) | |
| 68 | + return {**res, "tableau": tab, "service_dette": hyp.service_dette_annuel, "changements_signe": signes, "mirr": mirr, | |
| 69 | + "produit_net_revente": d["vt_nette"] - res["solde_final"], "cash_on_cash_1": fin.rendement_cash_on_cash(apres_dette[0], res["mise_de_fonds"]), | |
| 70 | + "rcd_1": fin.rcd(rne[0], hyp.service_dette_annuel), "rcd_min_horizon": min(tab["rcd"]), "taux_effectif_annuel_hyp": fin.taux_effectif_annuel(h["financement"]["taux"], 2), | |
| 71 | + "taux_mensuel": hyp.taux_periodique, "taux_mensuel_si_composition_mensuelle": h["financement"]["taux"] / 12, | |
| 72 | + "levier": "positif" if res["tri"] > r["hyp"]["taux_actualisation"] else "négatif"} | |
| 73 | + | |
| 74 | +# ----------------------------------------------------------------------------- sensibilité | |
| 75 | + | |
| 76 | +def valeur_avec(hyp_mod: dict, r: dict) -> dict: | |
| 77 | + """Recalcule projection + DCF avec un dictionnaire d'hypothèses modifié (les données d'état locatif/dépenses sont inchangées).""" | |
| 78 | + proj = cas3.projection_dcf(r["etat"], r["dep"], hyp_mod) | |
| 79 | + return cas3.valeur_dcf(proj, hyp_mod) | {"projection": proj} | |
| 80 | + | |
| 81 | +def modifier(hyp: dict, **kw) -> dict: | |
| 82 | + h = copy.deepcopy(hyp) | |
| 83 | + for k, v in kw.items(): | |
| 84 | + if k == "capex_facteur": | |
| 85 | + h["capex"] = {a: m * v for a, m in h["capex"].items()} | |
| 86 | + else: | |
| 87 | + h[k] = v | |
| 88 | + return h | |
| 89 | + | |
| 90 | +def sensibilite_taux_terminal(r: dict, grille=(0.0425, 0.045, 0.0475, 0.05, 0.0525, 0.055, 0.0575)) -> pd.DataFrame: | |
| 91 | + rows = [] | |
| 92 | + for Rt in grille: | |
| 93 | + v = valeur_avec(modifier(r["hyp"], taux_terminal=Rt), r) | |
| 94 | + rows.append({"taux_terminal": Rt, "vt_brute": v["vt_brute"], "vt_nette": v["vt_nette"], "va_terminale": v["va_terminale"], "valeur": v["valeur"], | |
| 95 | + "part_terminale": v["part_terminale"], "ecart_pct_base": v["valeur"] / r["dcf"]["valeur"] - 1}) | |
| 96 | + return pd.DataFrame(rows) | |
| 97 | + | |
| 98 | +def surface_r_Rt(r: dict, taux=(0.0625, 0.065, 0.0675, 0.07, 0.0725, 0.075, 0.0775, 0.08, 0.0825), Rt=(0.0425, 0.045, 0.0475, 0.05, 0.0525, 0.055, 0.0575)) -> pd.DataFrame: | |
| 99 | + """Surface V(r, R_T) ; lignes = r, colonnes = R_T.""" | |
| 100 | + M = np.zeros((len(taux), len(Rt))) | |
| 101 | + for i, ri in enumerate(taux): | |
| 102 | + for j, Rj in enumerate(Rt): | |
| 103 | + M[i, j] = valeur_avec(modifier(r["hyp"], taux_actualisation=ri, taux_terminal=Rj), r)["valeur"] | |
| 104 | + return pd.DataFrame(M, index=list(taux), columns=list(Rt)) | |
| 105 | + | |
| 106 | +def surface_r_g(r: dict, taux=(0.0625, 0.065, 0.0675, 0.07, 0.0725, 0.075, 0.0775, 0.08, 0.0825), g=(0.02, 0.025, 0.03, 0.035, 0.04, 0.045, 0.05)) -> pd.DataFrame: | |
| 107 | + """Surface V(r, g) où g est la croissance des loyers de marché (R_T inchangé).""" | |
| 108 | + M = np.zeros((len(taux), len(g))) | |
| 109 | + for i, ri in enumerate(taux): | |
| 110 | + for j, gj in enumerate(g): | |
| 111 | + M[i, j] = valeur_avec(modifier(r["hyp"], taux_actualisation=ri, croissance_loyers_marche=gj), r)["valeur"] | |
| 112 | + return pd.DataFrame(M, index=list(taux), columns=list(g)) | |
| 113 | + | |
| 114 | +def tornade(r: dict) -> pd.DataFrame: | |
| 115 | + """Variation de la valeur pour un choc bas/haut de chaque intrant, les autres étant à leur valeur de base.""" | |
| 116 | + h = r["hyp"]; V0 = r["dcf"]["valeur"] | |
| 117 | + chocs = [ | |
| 118 | + ("Taux d'actualisation r (−50 / +50 pb)", {"taux_actualisation": h["taux_actualisation"] - 0.005}, {"taux_actualisation": h["taux_actualisation"] + 0.005}), | |
| 119 | + ("Taux terminal R_T (−25 / +25 pb)", {"taux_terminal": h["taux_terminal"] - 0.0025}, {"taux_terminal": h["taux_terminal"] + 0.0025}), | |
| 120 | + ("Croissance des loyers de marché (2,5 / 4,5 %)", {"croissance_loyers_marche": 0.025}, {"croissance_loyers_marche": 0.045}), | |
| 121 | + ("Croissance des loyers contractuels (2,0 / 4,0 %)", {"croissance_loyers_contrat": 0.020}, {"croissance_loyers_contrat": 0.040}), | |
| 122 | + ("Rotation annuelle (5 / 25 %)", {"rotation_annuelle": 0.05}, {"rotation_annuelle": 0.25}), | |
| 123 | + ("Inoccupation et pertes (2,9 / 4,9 %)", {"taux_inoccupation_pertes": 0.029}, {"taux_inoccupation_pertes": 0.049}), | |
| 124 | + ("CAPEX (−25 / +25 %)", {"capex_facteur": 0.75}, {"capex_facteur": 1.25}), | |
| 125 | + ("Croissance des dépenses (2,0 / 4,0 %)", {"croissance_depenses": 0.020}, {"croissance_depenses": 0.040}), | |
| 126 | + ("Coûts de vente (1,5 / 3,5 %)", {"couts_vente": 0.015}, {"couts_vente": 0.035}), | |
| 127 | + ] | |
| 128 | + rows = [] | |
| 129 | + for nom, bas, haut in chocs: | |
| 130 | + vb = valeur_avec(modifier(h, **bas), r)["valeur"]; vh = valeur_avec(modifier(h, **haut), r)["valeur"] | |
| 131 | + rows.append({"intrant": nom, "valeur_bas": vb, "valeur_haut": vh, "delta_bas": vb - V0, "delta_haut": vh - V0, "amplitude": abs(vh - vb)}) | |
| 132 | + return pd.DataFrame(rows).sort_values("amplitude", ascending=False).reset_index(drop=True) | |
| 133 | + | |
| 134 | +def scenarios(r: dict) -> pd.DataFrame: | |
| 135 | + """Trois scénarios narratifs : base ; encadrement serré des loyers ; hausse des taux. | |
| 136 | + Deux lectures : (i) la valeur que conclurait l'évaluateur sous le scénario ; (ii) le sort de l'investisseur qui a payé la | |
| 137 | + valeur de base et financé avec le prêt de base (TRI sans levier et TRI des fonds propres réalisés si le scénario se matérialise). | |
| 138 | + Dans « Hausse des taux », le prêt de base est supposé renouvelé au taux initial + 100 pb dès l'origine (approximation du renouvellement, cf. ch. 31).""" | |
| 139 | + h = r["hyp"]; V_base = r["dcf"]["valeur"]; f_base = r["financement"] | |
| 140 | + defs = { | |
| 141 | + "Base": ({}, 0.0), | |
| 142 | + "Encadrement serré": ({"croissance_loyers_contrat": 0.020, "rotation_annuelle": 0.08, "croissance_loyers_marche": 0.030}, 0.0), | |
| 143 | + "Hausse des taux": ({"taux_actualisation": h["taux_actualisation"] + 0.0075, "taux_terminal": h["taux_terminal"] + 0.005}, 0.01), | |
| 144 | + } | |
| 145 | + rows = [] | |
| 146 | + for nom, (kw, choc_taux_hyp) in defs.items(): | |
| 147 | + hm = modifier(h, **kw); v = valeur_avec(hm, r); p = v["projection"] | |
| 148 | + hyp_pret = fin.Hypotheque(f_base["pret"], h["financement"]["taux"] + choc_taux_hyp, h["financement"]["amortissement_mois"]) | |
| 149 | + flux_sl = [-V_base] + list(v["flux"]); flux_sl[-1] += v["vt_nette"] | |
| 150 | + lev = fin.rendement_fonds_propres_levier(v["flux"], V_base, hyp_pret, v["vt_nette"]) | |
| 151 | + rows.append({"scenario": nom, "r": hm["taux_actualisation"], "R_T": hm["taux_terminal"], "g_contrat": hm["croissance_loyers_contrat"], "g_marche": hm["croissance_loyers_marche"], | |
| 152 | + "rotation": hm["rotation_annuelle"], "taux_hyp": hyp_pret.taux_nominal, "rne_1": float(p.loc[p["annee"] == 1, "rne"].iloc[0]), | |
| 153 | + "rne_T1": v["rne_T1"], "vt_nette": v["vt_nette"], "valeur": v["valeur"], "part_terminale": v["part_terminale"], | |
| 154 | + "ecart_pct_base": v["valeur"] / V_base - 1, "service_dette": hyp_pret.service_dette_annuel, "rcd_min": min(x / hyp_pret.service_dette_annuel for x in p.loc[p["annee"] <= hm["horizon_ans"], "rne"]), | |
| 155 | + "tri_sans_levier_prix_base": fin.tri(flux_sl), "tri_fonds_propres_prix_base": lev["tri"], "flux_fp_min": min(lev["flux"][1:-1])}) | |
| 156 | + return pd.DataFrame(rows) | |
| 157 | + | |
| 158 | +# ----------------------------------------------------------------------------- vérification de la proposition DCF = capitalisation directe (Gordon) | |
| 159 | + | |
| 160 | +def verification_gordon(noi1: float = 100_000.0, r: float = 0.0725, g: float = 0.035, T: int = 10, couts_vente: float = 0.0) -> dict: | |
| 161 | + """Sous croissance constante g et R_T = r − g, le DCF à horizon T redonne exactement NOI_1/(r − g), quel que soit T.""" | |
| 162 | + noi = fin.projeter_croissance(noi1, g, T + 1) | |
| 163 | + res = fin.dcf(noi[:T], r, noi_suivant=noi[T], taux_terminal=fin.taux_gordon(r, g), couts_vente=couts_vente) | |
| 164 | + return {"V_dcf": res.valeur, "V_gordon": fin.capitalisation_directe(noi1, fin.taux_gordon(r, g)), "part_terminale": res.va_terminale / res.valeur, "T": T} | |
| 165 | + | |
| 166 | +# ----------------------------------------------------------------------------- exercices (solutions/ch30.tex) | |
| 167 | + | |
| 168 | +def exercices() -> dict: | |
| 169 | + out = {} | |
| 170 | + # 30.1 Gordon | |
| 171 | + out["ex1"] = {"R": fin.taux_gordon(0.0725, 0.035), "V": fin.capitalisation_directe(100_000, fin.taux_gordon(0.0725, 0.035)), "g_implicite": 0.0725 - 0.04415, "r_implicite": 0.04415 + 0.035} | |
| 172 | + # 30.2 valeur terminale | |
| 173 | + vtb, vtn = fin.valeur_terminale(159_658.38, 0.05, 0.025) | |
| 174 | + out["ex2"] = {"vt_brute": vtb, "vt_nette": vtn, "va_10": vtn * fin.facteur_va(0.0725, 10), "va_11_erronee": vtn * fin.facteur_va(0.0725, 11), "ecart": vtn * fin.facteur_va(0.0725, 10) - vtn * fin.facteur_va(0.0725, 11), | |
| 175 | + "vt_nette_55": fin.valeur_terminale(159_658.38, 0.055, 0.025)[1]} | |
| 176 | + # 30.3 DCF 5 ans | |
| 177 | + flux5 = [62_000, 64_000, 20_000, 68_000, 70_000]; noi6 = 72_000 | |
| 178 | + res5 = fin.dcf(flux5, 0.07, noi_suivant=noi6, taux_terminal=0.05, couts_vente=0.03) | |
| 179 | + prix = 1_300_000 | |
| 180 | + f5 = [-prix] + flux5; f5[-1] += res5.valeur_terminale_nette | |
| 181 | + out["ex3"] = {"va_flux": res5.va_flux, "vt_brute": res5.valeur_terminale_brute, "vt_nette": res5.valeur_terminale_nette, "va_vt": res5.va_terminale, "valeur": res5.valeur, | |
| 182 | + "van_prix": fin.van(f5, 0.07), "tri_prix": fin.tri(f5), "part_terminale": res5.va_terminale / res5.valeur} | |
| 183 | + # 30.4 taux hypothécaire canadien | |
| 184 | + i = 0.0412 | |
| 185 | + out["ex4"] = {"i_m_can": fin.taux_mensuel_canadien(i), "i_m_us": i / 12, "eff_can": fin.taux_effectif_annuel(i, 2), "eff_us": fin.taux_effectif_annuel(i, 12), | |
| 186 | + "paiement_can": fin.Hypotheque(1_000_000, i, 300).paiement, "paiement_us": fin.Hypotheque(1_000_000, i, 300, composition=12).paiement} | |
| 187 | + out["ex4"]["ecart_annuel"] = (out["ex4"]["paiement_us"] - out["ex4"]["paiement_can"]) * 12 | |
| 188 | + # 30.5 cohérence Gordon | |
| 189 | + out["ex5"] = {T: verification_gordon(90_000, 0.0725, 0.035, T) for T in (5, 10, 20)} | |
| 190 | + out["ex5_cv"] = verification_gordon(90_000, 0.0725, 0.035, 10, couts_vente=0.025) | |
| 191 | + # 30.6 variante du cas 3 | |
| 192 | + r = base() | |
| 193 | + v6 = valeur_avec(modifier(r["hyp"], taux_actualisation=0.0775, taux_terminal=0.055), r) | |
| 194 | + out["ex6"] = {"valeur": v6["valeur"], "va_flux": v6["va_flux"], "va_terminale": v6["va_terminale"], "vt_nette": v6["vt_nette"], "part_terminale": v6["part_terminale"], "ecart_pct": v6["valeur"] / r["dcf"]["valeur"] - 1, | |
| 195 | + "R_implicite": float(v6["projection"].loc[0, "rne"]) / v6["valeur"]} | |
| 196 | + # 30.7 TRI avec levier (données du cas 3) | |
| 197 | + lev = flux_levier(r) | |
| 198 | + out["ex7"] = {"flux": lev["flux"], "tri": lev["tri"], "mirr": lev["mirr"], "changements_signe": lev["changements_signe"], "mise": lev["mise_de_fonds"], "solde_final": lev["solde_final"], | |
| 199 | + "van_8": fin.van(lev["flux"], 0.08), "van_12": fin.van(lev["flux"], 0.12)} | |
| 200 | + # 30.8 écart DCF / capitalisation directe | |
| 201 | + out["ex8"] = taux_implicites(r) | |
| 202 | + return out | |
| 203 | + | |
| 204 | + | |
| 205 | +# ----------------------------------------------------------------------------- corps de tableaux LaTeX (collés dans ch30.tex) | |
| 206 | + | |
| 207 | +def _n(x, dec=0): | |
| 208 | + """Nombre brut pour une colonne S de siunitx (point décimal, pas de séparateur).""" | |
| 209 | + return f"{x:.{dec}f}" | |
| 210 | + | |
| 211 | +def latex_tables(): | |
| 212 | + r = base(); h = r["hyp"]; T = h["horizon_ans"]; t = tableau_dcf(r); d = r["dcf"] | |
| 213 | + print("% --- tab:30:projection (revenus et dépenses, années 1-11)") | |
| 214 | + for _, x in t.iterrows(): | |
| 215 | + print(f"{int(x.annee)} & {int(x.exercice)} & {_n(x.rbp)} & {_n(x.rbp_marche)} & {_n(x.rbe)} & {_n(x.depenses)} & {_n(x.reserve)} & {_n(x.rne)} \\\\") | |
| 216 | + print("% --- tab:30:dcf (flux, facteur, VA)") | |
| 217 | + for _, x in t[t.annee <= T].iterrows(): | |
| 218 | + print(f"{int(x.annee)} & {_n(x.rne)} & {_n(x.capex)} & {_n(x.flux_avant_dette)} & {_n(x.facteur, 6)} & {_n(x.va)} & {_n(x.va_cumulee)} \\\\") | |
| 219 | + print("% --- tab:30:levier") | |
| 220 | + lv = flux_levier(r) | |
| 221 | + for _, x in lv["tableau"].iterrows(): | |
| 222 | + print(f"{int(x.annee)} & {_n(x.rne)} & {_n(x.flux_avant_dette)} & {_n(x.service_dette)} & {_n(x.flux_apres_dette)} & {_n(x.rcd, 2)} & {_n(x.flux_fonds_propres)} \\\\") | |
| 223 | + print("% --- tab:30:sens-rt") | |
| 224 | + for _, x in sensibilite_taux_terminal(r).iterrows(): | |
| 225 | + print(f"{_n(x.taux_terminal*100, 2)} & {_n(x.vt_brute)} & {_n(x.vt_nette)} & {_n(x.va_terminale)} & {_n(x.valeur)} & {_n(x.part_terminale*100, 1)} & {_n(x.ecart_pct_base*100, 1)} \\\\") | |
| 226 | + print("% --- tab:30:tornade") | |
| 227 | + for _, x in tornade(r).iterrows(): | |
| 228 | + print(f"{x.intrant} & {_n(x.valeur_bas)} & {_n(x.valeur_haut)} & {_n(x.delta_bas)} & {_n(x.delta_haut)} & {_n(x.amplitude)} \\\\") | |
| 229 | + print("% --- tab:30:scenarios (colonnes = scénarios)") | |
| 230 | + s = scenarios(r).set_index("scenario") | |
| 231 | + lignes = [("Taux d'actualisation $r$ (\\%)", "r", 2, 100), ("Taux terminal $R_T$ (\\%)", "R_T", 2, 100), ("Croissance des loyers contractuels (\\%)", "g_contrat", 1, 100), | |
| 232 | + ("Croissance des loyers de marché (\\%)", "g_marche", 1, 100), ("Rotation annuelle (\\%)", "rotation", 0, 100), ("Taux hypothécaire (\\%)", "taux_hyp", 2, 100), | |
| 233 | + ("$\\NOI_{11}$ (\\$)", "rne_T1", 0, 1), ("Valeur terminale nette (\\$)", "vt_nette", 0, 1), ("Valeur $V_0$ (\\$)", "valeur", 0, 1), ("Écart à la base (\\%)", "ecart_pct_base", 1, 100), | |
| 234 | + ("Part de la valeur terminale (\\%)", "part_terminale", 1, 100), ("Service de la dette (\\$)", "service_dette", 0, 1), ("RCD minimal sur l'horizon", "rcd_min", 2, 1), | |
| 235 | + ("TRI sans levier si payé \\dollars{2208566} (\\%)", "tri_sans_levier_prix_base", 2, 100), ("TRI des fonds propres si payé \\dollars{2208566} (\\%)", "tri_fonds_propres_prix_base", 2, 100), ("Flux des fonds propres le plus bas (\\$)", "flux_fp_min", 0, 1)] | |
| 236 | + for lab, col, dec, mult in lignes: | |
| 237 | + print(lab + " & " + " & ".join(_n(s.loc[sc, col] * mult, dec) for sc in ["Base", "Encadrement serré", "Hausse des taux"]) + " \\\\") | |
| 238 | + print("% --- surface V(r,R_T) en milliers") | |
| 239 | + S = surface_r_Rt(r) | |
| 240 | + for ri, row in S.iterrows(): | |
| 241 | + print(_n(ri*100, 2) + " & " + " & ".join(_n(v/1000, 0) for v in row.values) + " \\\\") | |
| 242 | + | |
| 243 | +# ----------------------------------------------------------------------------- impression | |
| 244 | + | |
| 245 | +def _d(x): return f"{x:,.0f}".replace(",", " ") + " $" | |
| 246 | +def _p(x, d=2): return f"{x*100:.{d}f}".replace(".", ",") + " %" | |
| 247 | + | |
| 248 | +def main(): | |
| 249 | + pd.set_option("display.width", 250); pd.set_option("display.max_columns", 40); pd.set_option("display.float_format", lambda x: f"{x:,.4f}") | |
| 250 | + r = base(); h = r["hyp"]; b = r["base"]; tx = r["taux"]; d = r["dcf"] | |
| 251 | + print("=" * 100); print("CHAPITRE 30 — CAS 3 (DONNÉES SYNTHÉTIQUES) : FLUX ACTUALISÉS"); print("=" * 100) | |
| 252 | + print("\n== Hypothèses (data/synthetic/cas3_montreal_hypotheses_dcf.json)") | |
| 253 | + for k in ["date_evaluation", "horizon_ans", "taux_inoccupation_pertes", "croissance_loyers_contrat", "croissance_loyers_marche", "rotation_annuelle", "croissance_depenses", "taux_actualisation", "taux_terminal", "couts_vente", "capex", "autres_revenus_annuels"]: | |
| 254 | + print(f" {k:32s} {h[k]}") | |
| 255 | + print(f" financement : {h['financement']}") | |
| 256 | + print("\n== Année 1 (RNE normalisé, cf. ch. 27-28)") | |
| 257 | + print(f" RBP contractuel {_d(b['rbp_contrat'])} ; RBP marché {_d(b['rbp_marche'])} ; écart {_d(b['ecart_marche'])} ({_p(b['ecart_marche']/b['rbp_marche'],1)} sous le marché)") | |
| 258 | + print(f" Inoccupation et pertes {_p(b['taux_vacance_pertes'],1)} = {_d(b['vacance_pertes'])} ; autres revenus {_d(b['autres_revenus'])} ; RBE {_d(b['rbe'])}") | |
| 259 | + print(f" Dépenses hors réserve {_d(b['depenses_hors_reserve'])} ; réserve {_d(b['reserve'])} ; RNE {_d(b['rne'])} ({_d(b['rne_par_logement'])}/log.) ; ratio dépenses {_p(b['ratio_depenses'],1)}") | |
| 260 | + print("\n== Taux extraits des 40 comparables (ch. 29)") | |
| 261 | + print(f" n={tx['n']} ; médiane {_p(tx['mediane'],3)} ; moyenne {_p(tx['moyenne'],3)} ; pondérée RNE {_p(tx['ponderee_rne'],3)} ; Q1 {_p(tx['q1'],3)} ; Q3 {_p(tx['q3'],3)} ; min {_p(tx['min'],2)} ; max {_p(tx['max'],2)}") | |
| 262 | + print(f" Valeur par capitalisation directe au taux médian : {_d(r['valeur_capitalisation'])}") | |
| 263 | + print("\n== Tableau DCF complet (années 1-10 + année 11 pour la VT)") | |
| 264 | + t = tableau_dcf(r) | |
| 265 | + print(t[["annee", "exercice", "rbp", "rbp_marche", "rbe", "depenses", "reserve", "rne", "capex", "flux_avant_dette", "facteur", "va", "va_cumulee"]].to_string(index=False)) | |
| 266 | + print("\n== Valeur terminale et valeur") | |
| 267 | + print(f" RNE_11 {_d(d['rne_T1'])} ; R_T {_p(h['taux_terminal'])} ; VT brute {_d(d['vt_brute'])} ; coûts de vente {_p(h['couts_vente'],1)} = {_d(d['vt_brute']-d['vt_nette'])} ; VT nette {_d(d['vt_nette'])}") | |
| 268 | + print(f" Facteur (1+r)^-10 = {fin.facteur_va(h['taux_actualisation'], 10):.6f} ; VA de la VT {_d(d['va_terminale'])} ; VA des flux {_d(d['va_flux'])}") | |
| 269 | + print(f" VALEUR DCF = {_d(d['valeur'])} ; part de la VT {_p(d['part_terminale'],1)} ; par logement {_d(d['valeur']/12)}") | |
| 270 | + ti = taux_implicites(r) | |
| 271 | + print("\n== Lecture croisée DCF / capitalisation directe") | |
| 272 | + for k, v in ti.items(): print(f" {k:32s} {(_p(v,3) if abs(v) < 1 else _d(v))}") | |
| 273 | + sl = dcf_sans_levier(r) | |
| 274 | + print("\n== Investisseur non endetté payant V_0") | |
| 275 | + print(f" TRI {_p(sl['tri'],4)} (= r par construction) ; VAN(r) {sl['van_au_taux']:.6f} $ ; délai de récupération {sl['delai_recuperation']:.2f} ans ; MIRR(r,r) {_p(sl['mirr'],4)}") | |
| 276 | + lv = flux_levier(r); f = r["financement"] | |
| 277 | + print("\n== Financement (traite.cas3.financement) et flux après dette") | |
| 278 | + print(f" Prêt max RPV 75 % {_d(f['pret_rpv'])} ; prêt max RCD 1,20 {_d(f['pret_rcd'])} ; prêt retenu {_d(f['pret'])} (contrainte {f['contrainte']}) ; RPV effectif {_p(f['rpv'])}") | |
| 279 | + print(f" Taux nominal {_p(h['financement']['taux'])} comp. semestrielle → mensuel {_p(lv['taux_mensuel'],5)} (vs {_p(lv['taux_mensuel_si_composition_mensuelle'],5)} si mensuelle) ; effectif annuel {_p(lv['taux_effectif_annuel_hyp'],4)}") | |
| 280 | + print(f" Paiement mensuel {_d(f['hypotheque'].paiement)} ; service de la dette {_d(lv['service_dette'])} ; constante {_p(f['constante'],3)} ; RCD année 1 {lv['rcd_1']:.3f} ; RCD min sur l'horizon {lv['rcd_min_horizon']:.3f}") | |
| 281 | + print(f" Mise de fonds {_d(lv['mise_de_fonds'])} ; solde après 120 paiements {_d(lv['solde_final'])} ; produit net de revente {_d(lv['produit_net_revente'])}") | |
| 282 | + print(lv["tableau"].to_string(index=False)) | |
| 283 | + print(f" Flux des fonds propres : {[round(x) for x in lv['flux']]}") | |
| 284 | + print(f" Changements de signe {lv['changements_signe']} ; TRI fonds propres {_p(lv['tri'],3)} ; MIRR (fin. {_p(h['financement']['taux'])}, réinv. {_p(h['taux_actualisation'])}) {_p(lv['mirr'],3)} ; cash-on-cash an 1 {_p(lv['cash_on_cash_1'],2)} ; levier {lv['levier']}") | |
| 285 | + print("\n== Sensibilité au taux terminal") | |
| 286 | + print(sensibilite_taux_terminal(r).to_string(index=False)) | |
| 287 | + print("\n== Surface V(r, R_T)"); print(surface_r_Rt(r).round(0).to_string()) | |
| 288 | + print("\n== Surface V(r, g_marché)"); print(surface_r_g(r).round(0).to_string()) | |
| 289 | + print("\n== Tornade"); print(tornade(r).to_string(index=False)) | |
| 290 | + print("\n== Scénarios"); print(scenarios(r).to_string(index=False)) | |
| 291 | + print("\n== Vérification de la proposition (croissance constante, R_T = r − g)") | |
| 292 | + for T in (5, 10, 30): print(f" T={T:2d} : {verification_gordon(T=T)}") | |
| 293 | + print("\n== Exercices") | |
| 294 | + for k, v in exercices().items(): print(f" {k}: {v}") | |
| 295 | + | |
| 296 | +if __name__ == "__main__": | |
| 297 | + latex_tables() if "--latex" in sys.argv else main() | |
added
analysis/python/cases/ch47_dossier.py
+417 −0
@@ -0,0 +1,417 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Chapitre 47 — Cas 3 : immeuble de 12 logements à Montréal (DONNÉES SYNTHÉTIQUES, immeuble fictif). | |
| 3 | + | |
| 4 | +Dossier complet : revenus, dépenses normalisées, RNE, extraction du taux global, capitalisation directe, DCF 10 ans, | |
| 5 | +comparaison par unité (prix par logement, MRB), financement (RPV / RCD, APH Select), scénarios de taux au renouvellement, | |
| 6 | +Monte-Carlo (10 000 tirages, graine 20261031), tornade, réconciliation, droits de mutation, exercices. | |
| 7 | + | |
| 8 | +Les calculs de base viennent de `traite.cas3` (partagés avec les chapitres 26-31, 33, 34, 40) ; ce script n'y ajoute que les | |
| 9 | +calculs propres au dossier. Il IMPRIME tous les chiffres cités dans manuscript/chapters/ch47.tex. | |
| 10 | + | |
| 11 | +Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch47_dossier.py [--n 10000] | |
| 12 | +""" | |
| 13 | +from __future__ import annotations | |
| 14 | +import sys, pathlib, argparse, math | |
| 15 | +import numpy as np, pandas as pd | |
| 16 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 17 | +from traite import cas3, finance as fin, comparables as cp | |
| 18 | + | |
| 19 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 20 | +GRAINE = 20261031 | |
| 21 | +DATE_EVAL = pd.Timestamp("2026-09-01") | |
| 22 | + | |
| 23 | +# ----------------------------------------------------------------------------- hypothèses propres au chapitre 47 | |
| 24 | +# Comparaison par unité (ajustements déclarés ; voir §47.10 du chapitre) | |
| 25 | +AJ_ETAT_PAR_POINT = 0.03 # +3 % du prix par point d'état (hypothèse pédagogique, cohérente avec la loi de génération du jeu) | |
| 26 | +AJ_TEMPS_ANNUEL = 0.0 # aucun ajustement temporel (effet non significatif sur l'échantillon ; marché stable) | |
| 27 | +ECHELLES_SEL = {"nb_logements": 6.0, "annee_construction": 15.0, "mois": 12.0, "etat_1a5": 1.0} | |
| 28 | +POIDS_SEL = {"nb_logements": 2.0, "annee_construction": 1.0, "mois": 1.0, "etat_1a5": 1.0} | |
| 29 | +K_COMPARABLES = 10 | |
| 30 | + | |
| 31 | +# Réconciliation (pondérations raisonnées, §47.13) | |
| 32 | +POIDS_RECONCILIATION = {"capitalisation": 0.45, "dcf": 0.35, "comparaison_unite": 0.20} | |
| 33 | +ARRONDI_VALEUR = 10_000 | |
| 34 | + | |
| 35 | +# Droits sur les mutations immobilières, Ville de Montréal, 2026 (bornes supérieures des tranches, taux) | |
| 36 | +TRANCHES_MUTATION_MTL_2026 = [(62_900, 0.005), (315_000, 0.01), (552_300, 0.015), (1_104_700, 0.02), (2_136_500, 0.025), (3_113_000, 0.035), (math.inf, 0.04)] | |
| 37 | + | |
| 38 | +# Monte-Carlo : lois déclarées (§47.12) | |
| 39 | +MC_LOIS = { | |
| 40 | + "inoccupation_pertes": ("lognormale", {"mediane": 0.039, "sigma_log": 0.30}), | |
| 41 | + "croissance_loyers_marche": ("normale", {"mu": 0.035, "sigma": 0.010}), | |
| 42 | + "rotation_annuelle": ("triangulaire", {"min": 0.08, "mode": 0.15, "max": 0.25}), | |
| 43 | + "croissance_depenses": ("normale", {"mu": 0.030, "sigma": 0.0075}), | |
| 44 | + "taux_actualisation": ("normale", {"mu": 0.0725, "sigma": 0.005}), | |
| 45 | + "ecart_terminal": ("normale", {"mu": 0.0225, "sigma": 0.004}), # R_T = r − écart ; plancher R_T ≥ 3,5 % | |
| 46 | + "facteur_capex": ("lognormale", {"mediane": 1.0, "sigma_log": 0.20}), # un facteur indépendant par événement CAPEX | |
| 47 | +} | |
| 48 | +R_T_PLANCHER = 0.035 | |
| 49 | + | |
| 50 | +# ----------------------------------------------------------------------------- utilitaires | |
| 51 | + | |
| 52 | +def droits_mutation_montreal(base: float, tranches=TRANCHES_MUTATION_MTL_2026) -> float: | |
| 53 | + """Droit sur les mutations immobilières (barème progressif par tranches, Ville de Montréal 2026).""" | |
| 54 | + droit, bas = 0.0, 0.0 | |
| 55 | + for haut, taux in tranches: | |
| 56 | + if base <= bas: break | |
| 57 | + droit += (min(base, haut) - bas) * taux | |
| 58 | + bas = haut | |
| 59 | + return droit | |
| 60 | + | |
| 61 | +def arrondir(x: float, pas: int = ARRONDI_VALEUR) -> float: | |
| 62 | + return round(x / pas) * pas | |
| 63 | + | |
| 64 | +# ----------------------------------------------------------------------------- comparaison par unité | |
| 65 | + | |
| 66 | +def ols_prix_par_logement(comps: pd.DataFrame) -> dict: | |
| 67 | + """Régression auxiliaire ln(prix par logement) sur état, taille, niveau de loyer, temps et quartier (extraction d'ajustements).""" | |
| 68 | + c = comps.copy() | |
| 69 | + c["mois"] = (DATE_EVAL - c["date_vente"]).dt.days / 30.4375 | |
| 70 | + Q = pd.get_dummies(c["quartier"]).astype(float).drop(columns=["Rosemont"]) | |
| 71 | + X = np.column_stack([np.ones(len(c)), c["etat_1a5"] - 3, c["nb_logements"] - 12, np.log(c["loyer_mensuel_moyen"] / 1150.0), c["mois"] / 12.0, Q.values]) | |
| 72 | + noms = ["constante", "etat", "taille", "ln_loyer", "annees"] + list(Q.columns) | |
| 73 | + y = np.log(c["prix_par_logement"].values) | |
| 74 | + b, *_ = np.linalg.lstsq(X, y, rcond=None) | |
| 75 | + e = y - X @ b; k = X.shape[1]; s2 = float(e @ e) / (len(c) - k) | |
| 76 | + se = np.sqrt(np.diag(s2 * np.linalg.inv(X.T @ X))) | |
| 77 | + r2 = 1 - float(e @ e) / float(((y - y.mean()) ** 2).sum()) | |
| 78 | + return {"coef": dict(zip(noms, b)), "se": dict(zip(noms, se)), "r2": r2, "sigma": math.sqrt(s2), "n": len(c)} | |
| 79 | + | |
| 80 | +def comparaison_par_unite(d: dict, base: dict) -> dict: | |
| 81 | + comps = d["comps"].copy(); sujet = d["sujet"] | |
| 82 | + comps["mois"] = (DATE_EVAL - comps["date_vente"]).dt.days / 30.4375 | |
| 83 | + ols = ols_prix_par_logement(comps) | |
| 84 | + cf = ols["coef"] | |
| 85 | + suj = {"nb_logements": sujet["nb_logements"], "annee_construction": sujet["annee_construction"], "mois": 0.0, "etat_1a5": sujet["etat_1a5"]} | |
| 86 | + comps["distance"] = [cp.distance_attributs(suj, r, ECHELLES_SEL, POIDS_SEL) for r in comps.to_dict("records")] | |
| 87 | + sel = comps.sort_values("distance").head(K_COMPARABLES).copy() | |
| 88 | + loyer_sujet = base["rbp_contrat"] / (12 * sujet["nb_logements"]) | |
| 89 | + objets = []; lignes = [] | |
| 90 | + for r in sel.to_dict("records"): | |
| 91 | + p = r["prix_par_logement"] | |
| 92 | + # ajustement de transaction (marché/temps) en pourcentage, séquentiel | |
| 93 | + base_tx = cp.prix_ajuste_multiplicatif(p, [(1 + AJ_TEMPS_ANNUEL) ** (r["mois"] / 12.0) - 1]) | |
| 94 | + # ajustements de propriété en dollars par logement, signe du point de vue du sujet | |
| 95 | + a_loc = base_tx * (math.exp(-cf.get(r["quartier"], 0.0)) - 1) # sujet à Rosemont (référence) | |
| 96 | + a_taille = base_tx * (math.exp(cf["taille"] * (sujet["nb_logements"] - r["nb_logements"])) - 1) | |
| 97 | + a_etat = base_tx * AJ_ETAT_PAR_POINT * (sujet["etat_1a5"] - r["etat_1a5"]) | |
| 98 | + a_loyer = base_tx * ((loyer_sujet / r["loyer_mensuel_moyen"]) ** cf["ln_loyer"] - 1) | |
| 99 | + aj = {"aj_localisation": a_loc, "aj_taille": a_taille, "aj_etat": a_etat, "aj_loyer": a_loyer} | |
| 100 | + o = cp.ComparableAjuste(r["id_vente"], base_tx, aj); objets.append(o) | |
| 101 | + lignes.append({"id": r["id_vente"], "date": r["date_vente"].date().isoformat(), "quartier": r["quartier"], "n": r["nb_logements"], "annee": r["annee_construction"], | |
| 102 | + "etat": r["etat_1a5"], "loyer_moyen": r["loyer_mensuel_moyen"], "prix": r["prix_vente"], "prix_par_logement": p, "mrb": r["mrb"], "taux": r["taux_global"], | |
| 103 | + "distance": r["distance"], **aj, "prix_ajuste": o.prix_ajuste, "brut_pct": o.brut_pct, "net_pct": o.net_pct}) | |
| 104 | + grille = pd.DataFrame(lignes) | |
| 105 | + w = [float(x) for x in cp.poids_inverse_brut(objets)] | |
| 106 | + rec = {k: (float(v) if isinstance(v, (int, float, np.floating)) else v) for k, v in cp.reconciliation(objets, w).items()} | |
| 107 | + indication = float(rec["valeur_ponderee"] * sujet["nb_logements"]) | |
| 108 | + mrb_med = float(sel["mrb"].median()); mrb_q1 = float(sel["mrb"].quantile(0.25)); mrb_q3 = float(sel["mrb"].quantile(0.75)) | |
| 109 | + indication_mrb = mrb_med * base["rbp_contrat"] | |
| 110 | + return {"ols": ols, "grille": grille, "poids": w, "reconciliation": rec, "prix_par_logement_retenu": rec["valeur_ponderee"], "indication": indication, | |
| 111 | + "loyer_moyen_sujet": loyer_sujet, "mrb_mediane": mrb_med, "mrb_q1": mrb_q1, "mrb_q3": mrb_q3, "indication_mrb": indication_mrb, | |
| 112 | + "mrb_sujet_cap": None, "selection": sel} | |
| 113 | + | |
| 114 | +# ----------------------------------------------------------------------------- financement et scénarios de taux | |
| 115 | + | |
| 116 | +def scenarios_renouvellement(r: dict, hausses_pb=(0, 100, 200, 300)) -> pd.DataFrame: | |
| 117 | + """Au terme de 5 ans, le solde est refinancé au taux initial + hausse, sur l'amortissement résiduel (420 mois).""" | |
| 118 | + f = r["financement"]; h = f["hypotheque"]; p = r["projection"]; terme = r["hyp"]["financement"]["terme_ans"] | |
| 119 | + solde = h.solde(terme * 12); rows = [] | |
| 120 | + for pb in hausses_pb: | |
| 121 | + t = h.taux_nominal + pb / 10_000; h2 = fin.Hypotheque(solde, t, h.amortissement_mois - terme * 12) | |
| 122 | + sd = h2.service_dette_annuel | |
| 123 | + rcd = {f"rcd_{a}": float(p.loc[p["annee"] == a, "rne"].iloc[0]) / sd for a in range(terme + 1, r["hyp"]["horizon_ans"] + 1)} | |
| 124 | + flux6 = float(p.loc[p["annee"] == terme + 1, "rne"].iloc[0]) - sd | |
| 125 | + rows.append({"hausse_pb": pb, "taux": t, "solde_renouvele": solde, "service_dette": sd, "paiement_mensuel": h2.paiement, "flux_apres_dette_an6": flux6, **rcd}) | |
| 126 | + return pd.DataFrame(rows) | |
| 127 | + | |
| 128 | +def rcd_par_annee(r: dict, sc: pd.DataFrame) -> pd.DataFrame: | |
| 129 | + """RCD par année 1-10 : années 1-5 au taux initial, années 6-10 selon le scénario de renouvellement.""" | |
| 130 | + p = r["projection"]; f = r["financement"]; terme = r["hyp"]["financement"]["terme_ans"] | |
| 131 | + out = {"annee": list(range(1, r["hyp"]["horizon_ans"] + 1))} | |
| 132 | + for _, s in sc.iterrows(): | |
| 133 | + col = [] | |
| 134 | + for a in out["annee"]: | |
| 135 | + sd = f["service_dette"] if a <= terme else s["service_dette"] | |
| 136 | + col.append(float(p.loc[p["annee"] == a, "rne"].iloc[0]) / sd) | |
| 137 | + out[f"+{int(s['hausse_pb'])} pb"] = col | |
| 138 | + return pd.DataFrame(out) | |
| 139 | + | |
| 140 | +def aph_select(r: dict, valeur: float, rpv_max=0.85, rcd_min=1.10, amortissements=(480, 540, 600), prime_pct=None) -> pd.DataFrame: | |
| 141 | + """Variante APH Select (SCHL) : RPV 85 % (50 points, existant), RCD 1,10, amortissement 40/45/50 ans, taux assuré 4,12 %.""" | |
| 142 | + f = r["hyp"]["financement"]; noi = r["base"]["rne"]; rows = [] | |
| 143 | + for am in amortissements: | |
| 144 | + p_rpv = fin.pret_max_par_rpv(valeur, rpv_max); p_rcd = fin.pret_max_par_rcd(noi, rcd_min, f["taux"], am) | |
| 145 | + pret = min(p_rpv, p_rcd); h = fin.Hypotheque(pret, f["taux"], am) | |
| 146 | + rpv = pret / valeur | |
| 147 | + prime = 0.0260 if rpv <= 0.65 else 0.0285 if rpv <= 0.70 else 0.0335 if rpv <= 0.75 else 0.0435 if rpv <= 0.80 else 0.0535 if rpv <= 0.85 else 0.0590 | |
| 148 | + surprime = 0.0025 * max(0, (am / 12 - 25) / 5) | |
| 149 | + rows.append({"amortissement_ans": am // 12, "pret_rpv": p_rpv, "pret_rcd": p_rcd, "pret": pret, "contrainte": "RPV" if p_rpv <= p_rcd else "RCD", "rpv": rpv, | |
| 150 | + "service_dette": h.service_dette_annuel, "rcd": noi / h.service_dette_annuel, "mise_de_fonds": valeur - pret, "prime_pct": prime + surprime, "prime": (prime + surprime) * pret}) | |
| 151 | + return pd.DataFrame(rows) | |
| 152 | + | |
| 153 | +# ----------------------------------------------------------------------------- Monte-Carlo | |
| 154 | + | |
| 155 | +def projeter_vectorise(etat: pd.DataFrame, hyp: dict, base: dict, v: np.ndarray, g_m: np.ndarray, rot: np.ndarray, g_d: np.ndarray, fac_capex: np.ndarray, g_c: float | None = None) -> dict: | |
| 156 | + """Projection vectorisée (N tirages) reproduisant exactement `cas3.projection_dcf` pour les paramètres de base. | |
| 157 | + fac_capex : tableau (N, nb_événements CAPEX) de facteurs multiplicatifs.""" | |
| 158 | + T = hyp["horizon_ans"]; N = len(v); n_log = len(etat) | |
| 159 | + g_c = hyp["croissance_loyers_contrat"] if g_c is None else g_c | |
| 160 | + loyers = np.tile(etat["loyer_mensuel_contrat"].astype(float).values, (N, 1)); marche = np.tile(etat["loyer_mensuel_marche"].astype(float).values, (N, 1)) | |
| 161 | + autres = hyp["autres_revenus_annuels"]; res0 = base["reserve"] | |
| 162 | + de0_hors_gestion = base["depenses_hors_reserve"] - 0.04 * base["rbe"] | |
| 163 | + rbe1 = loyers.sum(1) * 12 * (1 - v) + autres | |
| 164 | + de0 = de0_hors_gestion + 0.04 * rbe1 # gestion imputée = 4 % du RBE de première année | |
| 165 | + k = np.round(rot * n_log).astype(int) | |
| 166 | + annee0 = int(hyp["date_evaluation"][:4]); evenements = sorted(hyp["capex"].items()) | |
| 167 | + rne = np.zeros((N, T + 1)); capex = np.zeros((N, T)); rbp = np.zeros((N, T + 1)) | |
| 168 | + for t in range(1, T + 2): | |
| 169 | + if t > 1: | |
| 170 | + loyers = loyers * (1 + g_c); marche = marche * (1 + g_m[:, None]) | |
| 171 | + rang = np.argsort(np.argsort(loyers / marche, axis=1), axis=1) | |
| 172 | + loyers = np.where(rang < k[:, None], marche, loyers) | |
| 173 | + rbp[:, t - 1] = loyers.sum(1) * 12 | |
| 174 | + rbe = rbp[:, t - 1] * (1 - v) + autres * (1 + g_d) ** (t - 1) | |
| 175 | + de = de0 * (1 + g_d) ** (t - 1) | |
| 176 | + rne[:, t - 1] = rbe - de - res0 | |
| 177 | + if t <= T: | |
| 178 | + for j, (an, montant) in enumerate(evenements): | |
| 179 | + if int(an) == annee0 + t - 1: capex[:, t - 1] += montant * fac_capex[:, j] | |
| 180 | + flux = rne[:, :T] - capex | |
| 181 | + return {"rne": rne, "capex": capex, "flux": flux, "rbp": rbp} | |
| 182 | + | |
| 183 | +def valeur_vectorisee(proj: dict, r: np.ndarray, R_T: np.ndarray, cv: float) -> dict: | |
| 184 | + T = proj["flux"].shape[1] | |
| 185 | + t = np.arange(1, T + 1) | |
| 186 | + disc = (1 + r[:, None]) ** (-t) | |
| 187 | + va_flux = (proj["flux"] * disc).sum(1) | |
| 188 | + vt_nette = proj["rne"][:, T] / R_T * (1 - cv) | |
| 189 | + va_vt = vt_nette / (1 + r) ** T | |
| 190 | + return {"valeur": va_flux + va_vt, "va_flux": va_flux, "va_terminale": va_vt, "vt_nette": vt_nette} | |
| 191 | + | |
| 192 | +def tirer(n: int, graine: int, nb_capex: int, lois=MC_LOIS) -> dict: | |
| 193 | + rng = np.random.default_rng(graine) | |
| 194 | + def tir(nom, taille): | |
| 195 | + typ, p = lois[nom] | |
| 196 | + if typ == "lognormale": return np.exp(np.log(p["mediane"]) + p["sigma_log"] * rng.standard_normal(taille)) | |
| 197 | + if typ == "normale": return p["mu"] + p["sigma"] * rng.standard_normal(taille) | |
| 198 | + if typ == "triangulaire": return rng.triangular(p["min"], p["mode"], p["max"], taille) | |
| 199 | + raise ValueError(typ) | |
| 200 | + x = {"inoccupation_pertes": np.clip(tir("inoccupation_pertes", n), 0.0, 0.30), "croissance_loyers_marche": tir("croissance_loyers_marche", n), | |
| 201 | + "rotation_annuelle": tir("rotation_annuelle", n), "croissance_depenses": tir("croissance_depenses", n), "taux_actualisation": np.clip(tir("taux_actualisation", n), 0.04, None), | |
| 202 | + "ecart_terminal": tir("ecart_terminal", n), "facteur_capex": tir("facteur_capex", (n, nb_capex))} | |
| 203 | + x["taux_terminal"] = np.maximum(x["taux_actualisation"] - x["ecart_terminal"], R_T_PLANCHER) | |
| 204 | + return x | |
| 205 | + | |
| 206 | +def monte_carlo(r: dict, n: int = 10_000, graine: int = GRAINE) -> dict: | |
| 207 | + etat, hyp, base = r["etat"], r["hyp"], r["base"] | |
| 208 | + x = tirer(n, graine, len(hyp["capex"])) | |
| 209 | + proj = projeter_vectorise(etat, hyp, base, x["inoccupation_pertes"], x["croissance_loyers_marche"], x["rotation_annuelle"], x["croissance_depenses"], x["facteur_capex"]) | |
| 210 | + val = valeur_vectorisee(proj, x["taux_actualisation"], x["taux_terminal"], hyp["couts_vente"]) | |
| 211 | + V = val["valeur"] | |
| 212 | + sd = r["financement"]["service_dette"]; terme = hyp["financement"]["terme_ans"] | |
| 213 | + rcd = proj["rne"] / sd # RCD par année au prêt de base (service de la dette constant sur le terme) | |
| 214 | + rcd_min5 = rcd[:, :terme].min(1) | |
| 215 | + # renouvellement à l'année 6 : taux initial + 200 pb sur le solde résiduel | |
| 216 | + h = r["financement"]["hypotheque"]; h6 = fin.Hypotheque(h.solde(terme * 12), h.taux_nominal + 0.02, h.amortissement_mois - terme * 12) | |
| 217 | + rcd6_choc = proj["rne"][:, terme] / h6.service_dette_annuel | |
| 218 | + q = lambda a, p: float(np.quantile(a, p)) | |
| 219 | + res = {"n": n, "graine": graine, "tirages": x, "valeur": V, "rne": proj["rne"], "rcd": rcd, | |
| 220 | + "stats": {"moyenne": float(V.mean()), "ecart_type": float(V.std(ddof=1)), "cv": float(V.std(ddof=1) / V.mean()), "mediane": q(V, 0.5), | |
| 221 | + "q05": q(V, 0.05), "q25": q(V, 0.25), "q75": q(V, 0.75), "q95": q(V, 0.95), "min": float(V.min()), "max": float(V.max())}, | |
| 222 | + "probabilites": {"V_sous_cap": float((V < r["valeur_capitalisation"]).mean()), "V_sous_2M": float((V < 2_000_000).mean()), "V_sous_role": float((V < hyp["valeur_role_2026"]).mean()), | |
| 223 | + "rcd1_sous_1.20": float((rcd[:, 0] < 1.20).mean()), "rcd1_sous_1.00": float((rcd[:, 0] < 1.00).mean()), | |
| 224 | + "rcd_min5_sous_1.20": float((rcd_min5 < 1.20).mean()), "rcd_min5_sous_1.00": float((rcd_min5 < 1.00).mean()), | |
| 225 | + "rcd6_choc200_sous_1.20": float((rcd6_choc < 1.20).mean()), "rcd6_choc200_sous_1.00": float((rcd6_choc < 1.00).mean())}, | |
| 226 | + "rcd1_q05": q(rcd[:, 0], 0.05), "rcd_min5_q05": q(rcd_min5, 0.05), "rcd6_choc200_q05": q(rcd6_choc, 0.05), "rcd6_choc200_mediane": q(rcd6_choc, 0.5), | |
| 227 | + "sd_renouvele_200pb": h6.service_dette_annuel} | |
| 228 | + # corrélations (sensibilité globale) : coefficient de corrélation de rang entre chaque intrant et la valeur | |
| 229 | + rangs = {} | |
| 230 | + for nom in ["inoccupation_pertes", "croissance_loyers_marche", "rotation_annuelle", "croissance_depenses", "taux_actualisation", "taux_terminal"]: | |
| 231 | + rangs[nom] = float(pd.Series(x[nom]).corr(pd.Series(V), method="spearman")) | |
| 232 | + rangs["facteur_capex_moyen"] = float(pd.Series(x["facteur_capex"].mean(1)).corr(pd.Series(V), method="spearman")) | |
| 233 | + res["correlations_rang"] = rangs | |
| 234 | + return res | |
| 235 | + | |
| 236 | +def tornade(r: dict) -> pd.DataFrame: | |
| 237 | + """Sensibilité un-à-la-fois : chaque intrant à son quantile 10 % et 90 % (autres intrants à la base), valeur DCF.""" | |
| 238 | + etat, hyp, base = r["etat"], r["hyp"], r["base"] | |
| 239 | + from scipy import stats | |
| 240 | + def q(nom, p): | |
| 241 | + typ, par = MC_LOIS[nom] | |
| 242 | + if typ == "lognormale": return float(np.exp(np.log(par["mediane"]) + par["sigma_log"] * stats.norm.ppf(p))) | |
| 243 | + if typ == "normale": return float(par["mu"] + par["sigma"] * stats.norm.ppf(p)) | |
| 244 | + if typ == "triangulaire": return float(stats.triang.ppf(p, c=(par["mode"] - par["min"]) / (par["max"] - par["min"]), loc=par["min"], scale=par["max"] - par["min"])) | |
| 245 | + basepar = {"inoccupation_pertes": hyp["taux_inoccupation_pertes"], "croissance_loyers_marche": hyp["croissance_loyers_marche"], "rotation_annuelle": hyp["rotation_annuelle"], | |
| 246 | + "croissance_depenses": hyp["croissance_depenses"], "taux_actualisation": hyp["taux_actualisation"], "ecart_terminal": hyp["taux_actualisation"] - hyp["taux_terminal"], "facteur_capex": 1.0} | |
| 247 | + def valeur(par): | |
| 248 | + one = lambda k: np.array([par[k]]) | |
| 249 | + fac = np.full((1, len(hyp["capex"])), par["facteur_capex"]) | |
| 250 | + proj = projeter_vectorise(etat, hyp, base, one("inoccupation_pertes"), one("croissance_loyers_marche"), one("rotation_annuelle"), one("croissance_depenses"), fac) | |
| 251 | + R_T = max(par["taux_actualisation"] - par["ecart_terminal"], R_T_PLANCHER) | |
| 252 | + return float(valeur_vectorisee(proj, one("taux_actualisation"), np.array([R_T]), hyp["couts_vente"])["valeur"][0]) | |
| 253 | + V0 = valeur(basepar); rows = [] | |
| 254 | + for nom in basepar: | |
| 255 | + lo = dict(basepar); hi = dict(basepar); lo[nom] = q(nom, 0.10); hi[nom] = q(nom, 0.90) | |
| 256 | + rows.append({"intrant": nom, "bas": lo[nom], "haut": hi[nom], "V_bas": valeur(lo), "V_haut": valeur(hi), "V_base": V0}) | |
| 257 | + t = pd.DataFrame(rows); t["amplitude"] = (t["V_haut"] - t["V_bas"]).abs() | |
| 258 | + return t.sort_values("amplitude", ascending=False).reset_index(drop=True) | |
| 259 | + | |
| 260 | +# ----------------------------------------------------------------------------- réconciliation | |
| 261 | + | |
| 262 | +def reconciliation(r: dict, cu: dict) -> dict: | |
| 263 | + ind = {"capitalisation": r["valeur_capitalisation"], "dcf": r["dcf"]["valeur"], "comparaison_unite": cu["indication"]} | |
| 264 | + w = POIDS_RECONCILIATION | |
| 265 | + vp = sum(w[k] * ind[k] for k in ind) | |
| 266 | + V = arrondir(vp) | |
| 267 | + hyp = r["hyp"]; sujet = r["sujet"]; base = r["base"] | |
| 268 | + return {"indications": ind, "mrb_indication": cu["indication_mrb"], "poids": w, "valeur_ponderee": vp, "valeur_finale": V, | |
| 269 | + "fourchette": (min(ind.values()), max(ind.values())), "ratio_valeur_role": V / hyp["valeur_role_2026"], "prix_par_logement": V / sujet["nb_logements"], | |
| 270 | + "mrb_contrat": V / base["rbp_contrat"], "mrb_marche": V / base["rbp_marche"], "taux_implicite": base["rne"] / V, "droits_mutation": droits_mutation_montreal(V), | |
| 271 | + "prix_par_m2_locatif": V / sujet["superficie_locative_m2"]} | |
| 272 | + | |
| 273 | +def valeur_libre_de_baux(r: dict) -> dict: | |
| 274 | + """RNE et valeur par capitalisation si tous les logements étaient loués au loyer de marché (hypothèse du rôle, art. 45.1 LFM).""" | |
| 275 | + b, hyp, dep = r["base"], r["hyp"], r["dep"] | |
| 276 | + rbe_m = fin.revenu_brut_effectif(b["rbp_marche"], hyp["taux_inoccupation_pertes"], hyp["autres_revenus_annuels"]) | |
| 277 | + dn = cas3.depenses_normalisees(dep, hyp, rbe_m) | |
| 278 | + de = float(dn.loc[dn["type"] != "réserve", "normalise"].sum()); res = float(dn.loc[dn["type"] == "réserve", "normalise"].sum()) | |
| 279 | + rne_m = fin.rne(rbe_m, de, res) | |
| 280 | + return {"rbe": rbe_m, "depenses": de + res, "rne": rne_m, "valeur": fin.capitalisation_directe(rne_m, r["taux"]["mediane"]), "ecart_rne": rne_m - b["rne"]} | |
| 281 | + | |
| 282 | +def financement_final(r: dict, V: float) -> dict: | |
| 283 | + f = cas3.financement(V, r["base"]["rne"], r["hyp"]) | |
| 284 | + p = r["projection"]; flux_ad = p.loc[p["annee"] <= r["hyp"]["horizon_ans"], "flux_avant_dette"].tolist() | |
| 285 | + lev = fin.rendement_fonds_propres_levier(flux_ad, V, f["hypotheque"], r["dcf"]["vt_nette"]) | |
| 286 | + f["tri_fonds_propres"] = lev["tri"]; f["flux_fonds_propres"] = lev["flux"]; f["solde_final"] = lev["solde_final"] | |
| 287 | + f["cash_on_cash_1"] = fin.rendement_cash_on_cash(flux_ad[0] - f["service_dette"], f["mise_de_fonds"]) | |
| 288 | + f["point_mort"] = fin.ratio_point_mort(r["base"]["depenses_totales"], f["service_dette"], r["base"]["rbp_contrat"]) | |
| 289 | + f["point_mort_hors_reserve"] = fin.ratio_point_mort(r["base"]["depenses_hors_reserve"], f["service_dette"], r["base"]["rbp_contrat"]) | |
| 290 | + tri_sans = fin.tri([-V] + flux_ad[:-1] + [flux_ad[-1] + r["dcf"]["vt_nette"]]) | |
| 291 | + f["tri_sans_levier"] = tri_sans | |
| 292 | + return f | |
| 293 | + | |
| 294 | +# ----------------------------------------------------------------------------- exercices | |
| 295 | + | |
| 296 | +def exercices(r: dict, cu: dict, rec: dict) -> dict: | |
| 297 | + """Solutions chiffrées des exercices du chapitre 47 (solutions/ch47.tex).""" | |
| 298 | + b = r["base"]; hyp = r["hyp"]; tx = r["taux"]; out = {} | |
| 299 | + # Ex. 1 : vacance économique 5 % au lieu de 3,9 % | |
| 300 | + rbe5 = fin.revenu_brut_effectif(b["rbp_contrat"], 0.05, b["autres_revenus"]); dn5 = cas3.depenses_normalisees(r["dep"], hyp, rbe5) | |
| 301 | + de5 = float(dn5.loc[dn5["type"] != "réserve", "normalise"].sum()); res5 = float(dn5.loc[dn5["type"] == "réserve", "normalise"].sum()) | |
| 302 | + rne5 = fin.rne(rbe5, de5, res5) | |
| 303 | + out["ex1"] = {"rbe": rbe5, "depenses_hors_reserve": de5, "rne": rne5, "ecart_rne": rne5 - b["rne"], "valeur": fin.capitalisation_directe(rne5, tx["mediane"]), "ecart_valeur": fin.capitalisation_directe(rne5, tx["mediane"]) - r["valeur_capitalisation"]} | |
| 304 | + # Ex. 2 : taxes municipales = historique 2025 × 1,053 (hausse Rosemont 2026) au lieu du recalcul au taux général | |
| 305 | + taxes_alt = 10631 * 1.053; taxes_norm = hyp["valeur_role_2026"] * hyp["taux_taxe_fonciere"] | |
| 306 | + rne2 = b["rne"] - (taxes_alt - taxes_norm) | |
| 307 | + out["ex2"] = {"taxes_alt": taxes_alt, "taxes_norm": taxes_norm, "ecart": taxes_alt - taxes_norm, "rne": rne2, "valeur": fin.capitalisation_directe(rne2, tx["mediane"]), "ecart_valeur": fin.capitalisation_directe(rne2, tx["mediane"]) - r["valeur_capitalisation"]} | |
| 308 | + # Ex. 3 : fourchette Q1-Q3 du taux et taux par régression (état 3, 12 logements) | |
| 309 | + out["ex3"] = {"V_q1": fin.capitalisation_directe(b["rne"], tx["q1"]), "V_q3": fin.capitalisation_directe(b["rne"], tx["q3"]), "taux_regression": tx["regression"]["constante"], | |
| 310 | + "V_regression": fin.capitalisation_directe(b["rne"], tx["regression"]["constante"]), "V_ponderee": fin.capitalisation_directe(b["rne"], tx["ponderee_rne"])} | |
| 311 | + # Ex. 4 : rotation 10 % (au lieu de 15 %) et 25 % | |
| 312 | + for nom, rot in [("rot10", 0.10), ("rot25", 0.25)]: | |
| 313 | + h2 = dict(hyp); h2["rotation_annuelle"] = rot | |
| 314 | + p2 = cas3.projection_dcf(r["etat"], r["dep"], h2); v2 = cas3.valeur_dcf(p2, h2) | |
| 315 | + out[f"ex4_{nom}"] = {"valeur": v2["valeur"], "rne_T1": v2["rne_T1"], "ecart": v2["valeur"] - r["dcf"]["valeur"], "rbp_an10": float(p2.loc[p2["annee"] == 10, "rbp"].iloc[0])} | |
| 316 | + # Ex. 5 : taux 5,12 % (+100 pb) : prêt max RCD 1,20, prêt RPV 75 % de la valeur finale, RCD, mise de fonds | |
| 317 | + V = rec["valeur_finale"]; f5 = dict(hyp["financement"]); f5["taux"] = 0.0512 | |
| 318 | + h5 = dict(hyp); h5["financement"] = f5; fi5 = cas3.financement(V, b["rne"], h5) | |
| 319 | + out["ex5"] = {k: fi5[k] for k in ["pret_rpv", "pret_rcd", "pret", "contrainte", "service_dette", "rcd", "rpv", "mise_de_fonds", "constante"]} | |
| 320 | + out["ex5"]["ecart_pret"] = fi5["pret"] - cas3.financement(V, b["rne"], hyp)["pret"] | |
| 321 | + # Ex. 6 : APH Select 50 points (RPV 85 %, RCD 1,10, 40 ans) sur la valeur finale, prime | |
| 322 | + aph = aph_select(r, V, amortissements=(480,)); out["ex6"] = aph.iloc[0].to_dict() | |
| 323 | + out["ex6"]["pret_conventionnel"] = cas3.financement(V, b["rne"], hyp)["pret"] | |
| 324 | + # Ex. 7 : Monte-Carlo avec inoccupation médiane 6 % (marché détendu) — 10 000 tirages, même graine | |
| 325 | + lois = dict(MC_LOIS); lois["inoccupation_pertes"] = ("lognormale", {"mediane": 0.06, "sigma_log": 0.30}) | |
| 326 | + x = tirer(10_000, GRAINE, len(hyp["capex"]), lois) | |
| 327 | + proj = projeter_vectorise(r["etat"], hyp, b, x["inoccupation_pertes"], x["croissance_loyers_marche"], x["rotation_annuelle"], x["croissance_depenses"], x["facteur_capex"]) | |
| 328 | + val = valeur_vectorisee(proj, x["taux_actualisation"], x["taux_terminal"], hyp["couts_vente"])["valeur"] | |
| 329 | + sd = r["financement"]["service_dette"]; rcd1 = proj["rne"][:, 0] / sd | |
| 330 | + out["ex7"] = {"mediane": float(np.median(val)), "q05": float(np.quantile(val, 0.05)), "q95": float(np.quantile(val, 0.95)), "p_rcd1_sous_1.20": float((rcd1 < 1.2).mean()), "p_rcd1_sous_1.00": float((rcd1 < 1.0).mean()), | |
| 331 | + "rcd1_mediane": float(np.median(rcd1))} | |
| 332 | + return out | |
| 333 | + | |
| 334 | +# ----------------------------------------------------------------------------- dossier complet | |
| 335 | + | |
| 336 | +def dossier(n_mc: int = 10_000, graine: int = GRAINE) -> dict: | |
| 337 | + r = cas3.tout() | |
| 338 | + cu = comparaison_par_unite(r, r["base"]) | |
| 339 | + sc = scenarios_renouvellement(r); rcd_an = rcd_par_annee(r, sc) | |
| 340 | + mc = monte_carlo(r, n_mc, graine); torn = tornade(r) | |
| 341 | + rec = reconciliation(r, cu); fi = financement_final(r, rec["valeur_finale"]); fi_dcf = financement_final(r, r["dcf"]["valeur"]) | |
| 342 | + aph = aph_select(r, rec["valeur_finale"]) | |
| 343 | + return {**r, "comparaison_unite": cu, "libre_de_baux": valeur_libre_de_baux(r), "scenarios": sc, "rcd_par_annee": rcd_an, "mc": mc, "tornade": torn, "reconciliation": rec, | |
| 344 | + "financement_final": fi, "financement_dcf": fi_dcf, "aph": aph} | |
| 345 | + | |
| 346 | +def _d(x): return f"{x:,.0f}".replace(",", " ") + " $" | |
| 347 | +def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %" | |
| 348 | + | |
| 349 | +def imprimer(D: dict) -> None: | |
| 350 | + pd.set_option("display.width", 220); pd.set_option("display.max_columns", 40); pd.set_option("display.float_format", lambda v: f"{v:,.4f}") | |
| 351 | + b, hyp, tx, p = D["base"], D["hyp"], D["taux"], D["projection"] | |
| 352 | + print("=" * 100 + "\nCH. 47 — CAS 3 : 12 LOGEMENTS, ROSEMONT (DONNÉES SYNTHÉTIQUES, immeuble fictif) — date d'évaluation", hyp["date_evaluation"]) | |
| 353 | + print("\n--- 47.4-47.5 État locatif et revenus ---") | |
| 354 | + e = D["etat"].copy(); e["ecart"] = e["loyer_mensuel_marche"] - e["loyer_mensuel_contrat"]; e["ecart_pct"] = e["ecart"] / e["loyer_mensuel_marche"]; e["anciennete"] = 2026 - e["locataire_depuis"] | |
| 355 | + print(e[["logement", "pieces", "superficie_m2", "loyer_mensuel_contrat", "loyer_mensuel_marche", "ecart", "ecart_pct", "locataire_depuis", "anciennete", "stationnement"]].to_string(index=False)) | |
| 356 | + print("loyer mensuel total contrat", _d(e["loyer_mensuel_contrat"].sum()), "marché", _d(e["loyer_mensuel_marche"].sum()), "écart moyen", _p(e["ecart"].sum() / e["loyer_mensuel_marche"].sum())) | |
| 357 | + print("écart moyen par logement (mois)", _d(e["ecart"].mean()), "ancienneté moyenne", f"{e['anciennete'].mean():.1f} ans", "loyer/m² contrat", f"{(e.loyer_mensuel_contrat/e.superficie_m2).mean():.2f}") | |
| 358 | + for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "autres_revenus", "vacance_pertes", "rbe"]: print(f"{k:22s} {_d(b[k])}") | |
| 359 | + print("taux vacance/pertes", _p(b["taux_vacance_pertes"], 1), "écart au marché", _p(b["ecart_marche"] / b["rbp_marche"], 1), "autres revenus / logement / mois", f"{b['autres_revenus']/144:.0f} $") | |
| 360 | + print("\n--- 47.6 Dépenses (historique 2023-2025 et normalisation) ---") | |
| 361 | + hist = D["dep"].pivot(index="poste", columns="annee", values="montant"); print(hist.to_string()); print("totaux", hist.sum().to_dict()) | |
| 362 | + print(b["detail"].to_string(index=False)) | |
| 363 | + print("dépenses hors réserve", _d(b["depenses_hors_reserve"]), "réserve", _d(b["reserve"]), f"({b['reserve']/12:.2f} $/logement)", "total", _d(b["depenses_totales"]), "ratio/RBE", _p(b["ratio_depenses"], 1), | |
| 364 | + "ratio/RBP", _p(b["depenses_totales"] / b["rbp_contrat"], 1), "dépenses/logement", _d(b["depenses_totales"] / 12)) | |
| 365 | + print("taxes recalculées", _d(hyp["valeur_role_2026"] * hyp["taux_taxe_fonciere"]), "scolaire", _d(hyp["valeur_role_2026"] * hyp["taux_taxe_scolaire"]), "gestion 4 %", _d(0.04 * b["rbe"])) | |
| 366 | + print("\n--- 47.7 RNE normalisé ---"); print("RNE", f"{b['rne']:.2f}", "par logement", f"{b['rne_par_logement']:.2f}", "RNE/RBE", _p(b["rne"] / b["rbe"], 1)) | |
| 367 | + print("\n--- 47.8 Taux extraits (n = %d) ---" % tx["n"]) | |
| 368 | + for k in ["mediane", "moyenne", "ponderee_rne", "q1", "q3", "min", "max"]: print(f"{k:14s} {_p(tx[k], 3)}") | |
| 369 | + print("régression", {k: round(v, 6) for k, v in tx["regression"].items()}, "→ taux prédit état 3 / 12 log. =", _p(tx["regression"]["constante"], 3)) | |
| 370 | + c = tx["comparables"]; print("écart-type", _p(c["taux_global"].std(ddof=1), 3), "par quartier (médiane) :", c.groupby("quartier")["taux_global"].median().round(5).to_dict(), "par état :", c.groupby("etat_1a5")["taux_global"].median().round(5).to_dict()) | |
| 371 | + print("tranche 6-14 logements : n =", int(c["nb_logements"].between(6, 14).sum()), "médiane", _p(c.loc[c["nb_logements"].between(6, 14), "taux_global"].median(), 3), "; 12 derniers mois : n =", int((c["date_vente"] >= DATE_EVAL - pd.DateOffset(months=12)).sum()), "médiane", _p(c.loc[c["date_vente"] >= DATE_EVAL - pd.DateOffset(months=12), "taux_global"].median(), 3)) | |
| 372 | + print("Valeur par capitalisation (RNE_1 / médiane)", f"{D['valeur_capitalisation']:.2f}", "→ par logement", _d(D["valeur_capitalisation"] / 12)) | |
| 373 | + print("sensibilité ±25 pb :", _d(b["rne"] / (tx["mediane"] - 0.0025)), "/", _d(b["rne"] / (tx["mediane"] + 0.0025)), "; Q1/Q3 :", _d(b["rne"] / tx["q1"]), "/", _d(b["rne"] / tx["q3"])) | |
| 374 | + print("\n--- 47.9 DCF 10 ans ---"); print(p.to_string(index=False)) | |
| 375 | + d = D["dcf"] | |
| 376 | + for k in ["valeur", "va_flux", "va_terminale", "vt_brute", "vt_nette", "rne_T1", "part_terminale"]: print(f"{k:16s} {d[k]:.4f}") | |
| 377 | + print("croissance RNE an 1→10 (TCAC)", _p((p.loc[9, "rne"] / p.loc[0, "rne"]) ** (1 / 9) - 1), "RBP an 10 / marché an 10", _p(p.loc[9, "rbp"] / p.loc[9, "rbp_marche"], 1), "CAPEX total", _d(p["capex"].sum())) | |
| 378 | + print("taux implicite V_dcf : RNE_1/V =", _p(b["rne"] / d["valeur"], 3), "; TRI non levier (achat à V_dcf) =", _p(D["financement_dcf"]["tri_sans_levier"], 3)) | |
| 379 | + print("écart DCF − capitalisation", _d(d["valeur"] - D["valeur_capitalisation"]), _p(d["valeur"] / D["valeur_capitalisation"] - 1, 1)) | |
| 380 | + print("\n--- 47.10 Comparaison par unité ---") | |
| 381 | + cu = D["comparaison_unite"]; o = cu["ols"] | |
| 382 | + print("OLS ln(prix/logement) : n =", o["n"], "R² =", round(o["r2"], 3), "σ =", round(o["sigma"], 4)) | |
| 383 | + for k in o["coef"]: print(f" {k:12s} {o['coef'][k]:+.4f} (é.-t. {o['se'][k]:.4f})") | |
| 384 | + print("loyer moyen sujet (contrat)", f"{cu['loyer_moyen_sujet']:.2f}") | |
| 385 | + print(cu["grille"].to_string(index=False)); print("poids", [round(w, 3) for w in cu["poids"]]) | |
| 386 | + rc = cu["reconciliation"]; print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in rc.items() if k != "poids"}) | |
| 387 | + print("prix/logement retenu", f"{cu['prix_par_logement_retenu']:.2f}", "→ indication", f"{cu['indication']:.2f}") | |
| 388 | + print("MRB sélection : médiane", cu["mrb_mediane"], "Q1", cu["mrb_q1"], "Q3", cu["mrb_q3"], "→ indication MRB × RBP contrat", f"{cu['indication_mrb']:.2f}") | |
| 389 | + print("\n--- 47.11 Financement (base : valeur DCF, comme au ch. 31) ---") | |
| 390 | + f = D["financement"] | |
| 391 | + for k in ["pret_rpv", "pret_rcd", "pret", "contrainte", "service_dette", "rcd", "rpv", "mise_de_fonds", "constante", "taux_mensuel"]: print(f"{k:14s} {f[k]}") | |
| 392 | + print("paiement mensuel", f"{f['hypotheque'].paiement:.2f}", "solde après 60 mois", f"{f['hypotheque'].solde(60):.2f}", "après 120", f"{f['hypotheque'].solde(120):.2f}") | |
| 393 | + fd = D["financement_dcf"]; print("TRI fonds propres (achat à V_dcf)", _p(fd["tri_fonds_propres"], 2), "TRI sans levier", _p(fd["tri_sans_levier"], 2), "cash-on-cash an 1", _p(fd["cash_on_cash_1"], 2), "point mort", _p(fd["point_mort"], 1), "hors réserve", _p(fd["point_mort_hors_reserve"], 1)) | |
| 394 | + print("flux fonds propres", [round(x) for x in fd["flux_fonds_propres"]]) | |
| 395 | + print("levier : constante", _p(f["constante"], 3), "vs taux de capitalisation", _p(tx["mediane"], 3), "→ levier", "négatif" if f["constante"] > tx["mediane"] else "positif", "; intérêts an 1", _d(f["hypotheque"].interets_annee(1)), "capital an 1", _d(f["hypotheque"].capital_annee(1))) | |
| 396 | + print("\nScénarios de renouvellement (an 6) :"); print(D["scenarios"].to_string(index=False)); print(D["rcd_par_annee"].to_string(index=False)) | |
| 397 | + print("\nAPH Select (valeur finale) :"); print(D["aph"].to_string(index=False)) | |
| 398 | + print("\n--- 47.12 Monte-Carlo ---") | |
| 399 | + mc = D["mc"]; print("n =", mc["n"], "graine", mc["graine"]); print({k: round(v, 2) for k, v in mc["stats"].items()}); print({k: round(v, 4) for k, v in mc["probabilites"].items()}) | |
| 400 | + print("RCD an 1 q05", round(mc["rcd1_q05"], 3), "RCD min 1-5 q05", round(mc["rcd_min5_q05"], 3), "RCD an 6 (+200 pb) médiane", round(mc["rcd6_choc200_mediane"], 3), "q05", round(mc["rcd6_choc200_q05"], 3), "SD renouvelé +200 pb", _d(mc["sd_renouvele_200pb"])) | |
| 401 | + print("corrélations de rang", {k: round(v, 3) for k, v in mc["correlations_rang"].items()}) | |
| 402 | + for nom in ["inoccupation_pertes", "croissance_loyers_marche", "rotation_annuelle", "croissance_depenses", "taux_actualisation", "taux_terminal"]: | |
| 403 | + x = mc["tirages"][nom]; print(f" {nom:26s} moyenne {x.mean():.4f} q05 {np.quantile(x,0.05):.4f} q95 {np.quantile(x,0.95):.4f}") | |
| 404 | + print("tornade :"); print(D["tornade"].to_string(index=False)) | |
| 405 | + print("\n--- 47.13 Réconciliation ---") | |
| 406 | + print("valeur libre de baux (loyers de marché, taux médian) :", {k: round(v, 2) for k, v in D["libre_de_baux"].items()}, "→ ratio rôle/valeur libre", round(hyp["valeur_role_2026"] / D["libre_de_baux"]["valeur"], 4)) | |
| 407 | + rec = D["reconciliation"] | |
| 408 | + for k, v in rec.items(): print(f"{k:20s} {v}") | |
| 409 | + ff = D["financement_final"] | |
| 410 | + print("financement à la valeur finale :", {k: (round(v, 4) if isinstance(v, float) else v) for k, v in ff.items() if k in ["pret_rpv", "pret_rcd", "pret", "contrainte", "service_dette", "rcd", "rpv", "mise_de_fonds", "tri_fonds_propres", "tri_sans_levier", "cash_on_cash_1", "point_mort"]}) | |
| 411 | + print("coût d'acquisition : droits de mutation", _d(rec["droits_mutation"]), "; mise de fonds + droits", _d(ff["mise_de_fonds"] + rec["droits_mutation"])) | |
| 412 | + print("\n--- Exercices ---") | |
| 413 | + for k, v in exercices(D, D["comparaison_unite"], rec).items(): print(k, {kk: (round(vv, 4) if isinstance(vv, float) else vv) for kk, vv in v.items()}) | |
| 414 | + | |
| 415 | +if __name__ == "__main__": | |
| 416 | + ap = argparse.ArgumentParser(); ap.add_argument("--n", type=int, default=10_000); a = ap.parse_args() | |
| 417 | + imprimer(dossier(a.n)) | |
added
analysis/python/figures/ch21_decomposition.py
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Figure ch21 — décomposition du coût neuf du sujet du cas 1 par corps de métier (DONNÉES SYNTHÉTIQUES, graine 20261031). | |
| 3 | +Sortie : assets/figures/ch21/ch21-fig-decomposition-corps.pdf""" | |
| 4 | +import sys, pathlib | |
| 5 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 6 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 7 | +import matplotlib.pyplot as plt | |
| 8 | +from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars | |
| 9 | +import ch21_cout_neuf as cas | |
| 10 | + | |
| 11 | +def main(): | |
| 12 | + setup() | |
| 13 | + devis, sujet = cas.charger(); g = cas.regrouper(devis); cn = cas.cout_neuf_sujet(devis) | |
| 14 | + noms = list(g.index) + ["Incitatif de l'entrepreneur (10 %)"] | |
| 15 | + vals = list(g["montant"]) + [cn["incitatif"]] | |
| 16 | + couleurs = [palette.cat[0]] * 6 + [palette.cat[1], palette.cat[3]] | |
| 17 | + ordre = sorted(range(len(vals)), key=lambda i: vals[i]) | |
| 18 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.4)) | |
| 19 | + y = range(len(ordre)) | |
| 20 | + ax.barh(list(y), [vals[i] for i in ordre], color=[couleurs[i] for i in ordre], height=0.68) | |
| 21 | + ax.set_yticks(list(y)); ax.set_yticklabels([noms[i] for i in ordre]) | |
| 22 | + tot = cn["total"] | |
| 23 | + for k, i in enumerate(ordre): | |
| 24 | + ax.text(vals[i] + 1200, k, f"{fmt_dollars(vals[i])} ({vals[i]/tot*100:.1f} %)", va="center", ha="left", fontsize=8, color=palette.ink) | |
| 25 | + ax.set_xlim(0, max(vals) * 1.42) | |
| 26 | + ax.xaxis.set_major_formatter(plt.FuncFormatter(fmt_dollars)) | |
| 27 | + ax.set_xlabel("Montant (dollars de 2026)") | |
| 28 | + ax.grid(axis="y", visible=False) | |
| 29 | + ax.set_title(f"Coût neuf total : {fmt_dollars(tot)} — directs {cn['direct']/tot*100:.1f} %, indirects {cn['indirects']/tot*100:.1f} %, incitatif {cn['incitatif']/tot*100:.1f} %", | |
| 30 | + loc="left", fontsize=8.5, color=palette.ink) | |
| 31 | + from matplotlib.patches import Patch | |
| 32 | + ax.legend(handles=[Patch(color=palette.cat[0], label="Coûts directs (six corps de métier)"), Patch(color=palette.cat[1], label="Coûts indirects (cinq postes)"), | |
| 33 | + Patch(color=palette.cat[3], label="Incitatif de l'entrepreneur")], loc="upper center", bbox_to_anchor=(0.42, -0.16), ncol=3, fontsize=7.5) | |
| 34 | + out = savefig(fig, "ch21/ch21-fig-decomposition-corps.pdf"); print("OK", out) | |
| 35 | + | |
| 36 | +if __name__ == "__main__": | |
| 37 | + main() | |
added
analysis/python/figures/ch21_ipcb.py
+36 −0
@@ -0,0 +1,36 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Figure ch21 — IPCB Montréal (Statistique Canada, tableau 18-10-0289-01, base 2023 = 100), données RÉELLES lues dans data/raw. | |
| 3 | +Séries résidentielles depuis le début de la série (T1 2017) jusqu'au T3 2026 ; non résidentiel en comparaison. | |
| 4 | +Sortie : assets/figures/ch21/ch21-fig-ipcb-montreal.pdf""" | |
| 5 | +import sys, pathlib | |
| 6 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 7 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 8 | +import matplotlib.pyplot as plt, matplotlib.dates as mdates | |
| 9 | +from traite.style import setup, palette, savefig, LARGEUR | |
| 10 | +import ch21_cout_neuf as cas | |
| 11 | + | |
| 12 | +ETIQ = {"Bâtiments résidentiels [621]": "Bâtiments résidentiels", "Maison individuelle [621112]": "Maison individuelle", | |
| 13 | + "Immeuble à hauteur restreinte (moins de cinq étages) [62111]": "Immeuble < 5 étages", | |
| 14 | + "Tour d'habitations (cinq étages ou plus) [621111]": "Tour d'habitation (≥ 5 étages)", "Bâtiments non résidentiels [622]": "Non résidentiel"} | |
| 15 | + | |
| 16 | +def main(): | |
| 17 | + setup() | |
| 18 | + df = cas.ipcb_montreal(); df = df[df["date"] >= "2017-01-01"] | |
| 19 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.5)) | |
| 20 | + for i, (t, lab) in enumerate(ETIQ.items()): | |
| 21 | + s = df[df["type"] == t] | |
| 22 | + lw = 2.2 if t.startswith("Maison") else 1.4 | |
| 23 | + ls = "--" if t.startswith("Bâtiments non") else "-" | |
| 24 | + ax.plot(s["date"], s["indice"], color=palette.cat[i], lw=lw, ls=ls, label=lab) | |
| 25 | + ax.axhline(100, color=palette.ink2, lw=0.6, ls=":"); ax.text(df["date"].min(), 101, "2023 = 100", fontsize=7.5, color=palette.ink2) | |
| 26 | + ax.set_ylabel("Indice (2023 = 100)"); ax.set_xlim(df["date"].min(), df["date"].max() + __import__("pandas").Timedelta(days=120)) | |
| 27 | + ax.legend(loc="upper left", bbox_to_anchor=(0.0, 0.98), fontsize=7.5) | |
| 28 | + mi = df[df["type"] == "Maison individuelle [621112]"] | |
| 29 | + ax.annotate(f"T3 2026 : {mi['indice'].iloc[-1]:.1f}", xy=(mi["date"].iloc[-1], mi["indice"].iloc[-1]), xytext=(-70, 14), textcoords="offset points", fontsize=7.5, | |
| 30 | + arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.6), color=palette.ink) | |
| 31 | + ax.xaxis.set_major_locator(mdates.YearLocator()); ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y")) | |
| 32 | + ax.set_ylim(60, 125) | |
| 33 | + out = savefig(fig, "ch21/ch21-fig-ipcb-montreal.pdf"); print("OK", out) | |
| 34 | + | |
| 35 | +if __name__ == "__main__": | |
| 36 | + main() | |
added
analysis/python/figures/ch21_sensibilite.py
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Figure ch21 — sensibilité du coût neuf du sujet du cas 1 (DONNÉES SYNTHÉTIQUES) : (a) grille taux d'indirects × taux d'incitatif ; | |
| 3 | +(b) tornade (indirects, incitatif, indice de coûts). Sortie : assets/figures/ch21/ch21-fig-sensibilite.pdf""" | |
| 4 | +import sys, pathlib | |
| 5 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 6 | +sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) | |
| 7 | +import numpy as np, matplotlib.pyplot as plt | |
| 8 | +from traite.style import setup, palette, savefig, LARGEUR, fmt_dollars | |
| 9 | +import ch21_cout_neuf as cas | |
| 10 | + | |
| 11 | +def main(): | |
| 12 | + setup() | |
| 13 | + devis, sujet = cas.charger(); cn = cas.cout_neuf_sujet(devis) | |
| 14 | + ti = np.array([0.12, 0.15, 0.18, cn["taux_indirects"], 0.24, 0.27]); tc = np.array([0.05, 0.08, 0.10, 0.12, 0.15]) | |
| 15 | + G = cas.grille_sensibilite(cn["direct"], ti, tc) | |
| 16 | + ipcb = cas.ipcb_montreal(); mi = "Maison individuelle [621112]" | |
| 17 | + var = cas.indice(ipcb, mi, "2026-07") / cas.indice(ipcb, mi, "2025-07") - 1 | |
| 18 | + torn = cas.tornade(cn["direct"], cn["taux_indirects"], cas.TAUX_INCITATIF, (1 / (1 + var), 1 + var), (-0.05, -0.20))[:3] | |
| 19 | + fig, (a, b) = plt.subplots(1, 2, figsize=(LARGEUR, 3.3), gridspec_kw={"width_ratios": [1.15, 1]}) | |
| 20 | + im = a.imshow(G / 1000, cmap=palette.cmap_seq(), aspect="auto", origin="lower") | |
| 21 | + a.set_xticks(range(len(tc))); a.set_xticklabels([f"{x*100:.0f} %" for x in tc]); a.set_yticks(range(len(ti))); a.set_yticklabels([f"{x*100:.1f} %" for x in ti]) | |
| 22 | + a.set_xlabel("Taux d'incitatif (sur directs + indirects)"); a.set_ylabel("Taux d'indirects (sur directs)"); a.grid(False) | |
| 23 | + for i in range(len(ti)): | |
| 24 | + for j in range(len(tc)): | |
| 25 | + a.text(j, i, f"{G[i, j]/1000:.0f}", ha="center", va="center", fontsize=7.5, color="white" if G[i, j] > G.mean() else palette.ink) | |
| 26 | + a.set_title("(a) Coût neuf (milliers de $)", loc="left") | |
| 27 | + base = torn[0]["base"] | |
| 28 | + for k, o in enumerate(torn): | |
| 29 | + lo, hi = min(o["bas"], o["haut"]), max(o["bas"], o["haut"]) | |
| 30 | + b.barh(k, lo - base, left=base, color=palette.cat[0], height=0.55); b.barh(k, hi - base, left=base, color=palette.cat[1], height=0.55) | |
| 31 | + b.text(lo - 1500, k, fmt_dollars(lo), ha="right", va="center", fontsize=7.2); b.text(hi + 1500, k, fmt_dollars(hi), ha="left", va="center", fontsize=7.2) | |
| 32 | + b.set_yticks(range(len(torn))); b.set_yticklabels([f"{o['param']}\n({o['etiquette']})" for o in torn], fontsize=7.5) | |
| 33 | + b.axvline(base, color=palette.ink, lw=0.8); b.set_xlim(base - 62000, base + 62000) | |
| 34 | + b.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f"{x/1000:.0f} k$")); b.grid(axis="y", visible=False) | |
| 35 | + b.set_title("(b) Tornade autour de " + fmt_dollars(base), loc="left") | |
| 36 | + fig.tight_layout(w_pad=2.0) | |
| 37 | + out = savefig(fig, "ch21/ch21-fig-sensibilite.pdf"); print("OK", out) | |
| 38 | + | |
| 39 | +if __name__ == "__main__": | |
| 40 | + main() | |
added
analysis/python/figures/ch29_figures.py
+146 −0
@@ -0,0 +1,146 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Chapitre 29 — figures (5) : taux extraits (histogramme), taux vs état/taille (nuage), surface V(RNE, R) (carte de | |
| 3 | +chaleur), décomposition du taux (barres), rendements des obligations du GdC et taux hypothécaire affiché (série réelle, | |
| 4 | +Banque du Canada). Idempotent ; écrit dans assets/figures/ch29/ch29-fig-<slug>.pdf. | |
| 5 | +Usage (racine) : PYTHONPATH=analysis/python python3 analysis/python/figures/ch29_figures.py""" | |
| 6 | +from __future__ import annotations | |
| 7 | +import sys, pathlib | |
| 8 | +import numpy as np, pandas as pd | |
| 9 | +import matplotlib.pyplot as plt | |
| 10 | +from matplotlib.ticker import FuncFormatter, PercentFormatter | |
| 11 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 12 | +from traite.style import setup, palette, savefig, LARGEUR, DEMI, fmt_dollars | |
| 13 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "cases")) | |
| 14 | +from ch29_capitalisation import calculer | |
| 15 | + | |
| 16 | +ROOT = pathlib.Path(__file__).resolve().parents[3] | |
| 17 | +RAW = ROOT / "data/raw/bdc_valet" | |
| 18 | +pct = FuncFormatter(lambda x, p: f"{x*100:.2f}".replace(".", ",") + " %") | |
| 19 | +pct1 = FuncFormatter(lambda x, p: f"{x*100:.1f}".replace(".", ",") + " %") | |
| 20 | + | |
| 21 | + | |
| 22 | +def fig_taux_extraits(res): | |
| 23 | + c = res["taux"]["comparables"]; R = res["R"] | |
| 24 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.0)) | |
| 25 | + bins = np.arange(0.0365, 0.0566, 0.0025) | |
| 26 | + ax.hist(c["taux_global"], bins=bins, color=palette.cat[0], alpha=0.85, edgecolor=palette.surface, linewidth=0.8) | |
| 27 | + ymax = ax.get_ylim()[1] * 1.12; ax.set_ylim(0, ymax) | |
| 28 | + for x, lab, ls, h, ha in ((res["taux"]["q1"], "Q1", ":", 0.97, "right"), (R, "médiane", "-", 0.88, "right"), (res["taux"]["q3"], "Q3", ":", 0.97, "left")): | |
| 29 | + ax.axvline(x, color=palette.ink, linestyle=ls, linewidth=1.1) | |
| 30 | + ax.text(x + (0.0002 if ha == "left" else -0.0002), ymax * h, f"{lab} {x*100:.2f} %".replace(".", ","), ha=ha, va="top", fontsize=8) | |
| 31 | + xr = res["regression"]["prevision_sujet"] | |
| 32 | + ax.axvline(xr, color=palette.cat[1], linestyle="--", linewidth=1.1) | |
| 33 | + ax.text(xr + 0.0002, ymax * 0.70, f"régression (état 3, 12 log.)\n{xr*100:.2f} %".replace(".", ","), color=palette.ink, fontsize=7.5, ha="left", va="top") | |
| 34 | + ax.xaxis.set_major_formatter(pct) | |
| 35 | + ax.set_xlabel("Taux global extrait (RNE après réserve / prix)"); ax.set_ylabel("Nombre de ventes") | |
| 36 | + ax.set_title(f"Distribution des taux extraits — {len(c)} ventes synthétiques, 6 à 30 logements, 2024-09 à 2026-08", loc="left") | |
| 37 | + ax.text(0.99, -0.30, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) | |
| 38 | + return savefig(fig, "ch29/ch29-fig-taux-extraits.pdf") | |
| 39 | + | |
| 40 | + | |
| 41 | +def fig_taux_etat_taille(res): | |
| 42 | + c = res["taux"]["comparables"]; reg = res["regression"] | |
| 43 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) | |
| 44 | + marqueurs = {2: "s", 3: "o", 4: "^"} | |
| 45 | + for i, et in enumerate([2, 3, 4]): | |
| 46 | + s = c[c["etat_1a5"] == et] | |
| 47 | + ax.scatter(s["nb_logements"], s["taux_global"], color=palette.cat[i], marker=marqueurs[et], s=28, label=f"état {et} (n = {len(s)})", zorder=3) | |
| 48 | + xs = np.array([6, 30]); ax.plot(xs, reg["constante"] + reg["etat"] * (et - 3) + reg["taille"] * (xs - 12), color=palette.cat[i], linewidth=1.0, alpha=0.8) | |
| 49 | + ax.scatter([12], [reg["prevision_sujet"]], marker="*", s=120, color=palette.ink, zorder=4) | |
| 50 | + ax.annotate("sujet (12 log., état 3)\nprévision de la régression", (12, reg["prevision_sujet"]), xytext=(16.5, 0.0535), fontsize=7.5, | |
| 51 | + arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7)) | |
| 52 | + ax.yaxis.set_major_formatter(pct); ax.set_xlabel("Nombre de logements"); ax.set_ylabel("Taux global extrait") | |
| 53 | + ax.set_title("Taux extraits selon la taille et l'état : droites de la régression $R = a + b_1(\\mathrm{état}-3) + b_2(n-12)$", loc="left") | |
| 54 | + ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.22), ncol=3) | |
| 55 | + ax.text(0.99, -0.42, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) | |
| 56 | + return savefig(fig, "ch29/ch29-fig-taux-etat-taille.pdf") | |
| 57 | + | |
| 58 | + | |
| 59 | +def fig_surface(res): | |
| 60 | + rne0 = res["base"]["rne"]; R0 = res["R"] | |
| 61 | + Rs = np.array(sorted(set(np.round(np.arange(0.0375, 0.0526, 0.0025), 4)) | {round(R0, 5)})) | |
| 62 | + mult = np.array([0.90, 0.95, 1.00, 1.05, 1.10]) | |
| 63 | + V = np.array([[rne0 * m / r for r in Rs] for m in mult]) | |
| 64 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.4)) | |
| 65 | + im = ax.imshow(V / 1e6, cmap=palette.cmap_seq(), aspect="auto", origin="lower") | |
| 66 | + ax.set_xticks(range(len(Rs))); ax.set_xticklabels([f"{r*100:.2f}".replace(".", ",") for r in Rs]) | |
| 67 | + ax.set_yticks(range(len(mult))); ax.set_yticklabels([f"{m*100-100:+.0f} %\n{rne0*m/1000:,.1f} k$".replace(",", " ").replace(".", ",") for m in mult]) | |
| 68 | + for i in range(len(mult)): | |
| 69 | + for j in range(len(Rs)): | |
| 70 | + v = V[i, j]; coul = "white" if v / 1e6 > np.percentile(V / 1e6, 55) else palette.ink | |
| 71 | + ax.text(j, i, f"{v/1e6:.2f}".replace(".", ","), ha="center", va="center", fontsize=7.5, color=coul) | |
| 72 | + j0 = int(np.argmin(abs(Rs - R0))); ax.add_patch(plt.Rectangle((j0 - 0.5, 2 - 0.5), 1, 1, fill=False, edgecolor=palette.cat[1], linewidth=1.6)) | |
| 73 | + ax.set_xlabel("Taux global $R$ (%)"); ax.set_ylabel("RNE normalisé (écart au cas de base ; k$)") | |
| 74 | + rne_txt = f"{rne0:,.0f}".replace(",", " "); r_txt = f"{R0*100:.3f}".replace(".", ",") | |
| 75 | + ax.set_title(f"Valeur indiquée $V = RNE/R$ (M\\$) ; cadre : cas de base (RNE {rne_txt} \\$, R = {r_txt} %)", loc="left") | |
| 76 | + ax.grid(False) | |
| 77 | + cb = fig.colorbar(im, ax=ax, pad=0.02); cb.set_label("V (M$)") | |
| 78 | + ax.text(0.99, -0.28, "DONNÉES SYNTHÉTIQUES", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) | |
| 79 | + return savefig(fig, "ch29/ch29-fig-surface-v.pdf") | |
| 80 | + | |
| 81 | + | |
| 82 | +def fig_bande(res): | |
| 83 | + e = res["ellwood"]; M, f, R = res["M"], res["f"], res["R"] | |
| 84 | + constructions = [ | |
| 85 | + ("Bande\nd'investissement\n(R_E impliqué)", [("M·f", M * f), ("(1−M)·R_E", (1 - M) * res["re_implicite"])]), | |
| 86 | + ("Couverture\nde la dette\n(RCD 1,20)", [("RCD·M·f", res["R_rcd"])]), | |
| 87 | + ("Akerson\nΔ implicite 7,7 %\n(Y = 7,25 %)", [("M·f", e["composantes_marche"]["M f"]), ("(1−M)·Y", e["composantes_marche"]["(1-M) Y"]), | |
| 88 | + ("−M·P·SFF", e["composantes_marche"]["-M P SFF"]), ("−Δ·SFF", e["composantes_marche"]["-delta SFF"])]), | |
| 89 | + ("Akerson\nΔ = 24,9 %\n(Y = 7,25 %)", [("M·f", e["composantes"]["M f"]), ("(1−M)·Y", e["composantes"]["(1-M) Y"]), | |
| 90 | + ("−M·P·SFF", e["composantes"]["-M P SFF"]), ("−Δ·SFF", e["composantes"]["-delta SFF"])]), | |
| 91 | + ] | |
| 92 | + coul = {"M·f": palette.cat[0], "(1−M)·R_E": palette.cat[2], "RCD·M·f": palette.cat[4], "(1−M)·Y": palette.cat[2], "−M·P·SFF": palette.cat[1], "−Δ·SFF": palette.cat[3]} | |
| 93 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.9)) | |
| 94 | + vus = set() | |
| 95 | + for k, (nom, comps) in enumerate(constructions): | |
| 96 | + pos, neg = 0.0, 0.0 | |
| 97 | + for lab, v in comps: | |
| 98 | + if v >= 0: | |
| 99 | + ax.bar(k, v, bottom=pos, color=coul[lab], width=0.6, label=lab if lab not in vus else None); pos += v | |
| 100 | + else: | |
| 101 | + # composante négative dessinée en retrait depuis le sommet des composantes positives | |
| 102 | + ax.bar(k + 0.0, v, bottom=pos + neg, color=coul[lab], width=0.6, alpha=0.9, hatch="//", edgecolor=palette.surface, label=lab if lab not in vus else None); neg += v | |
| 103 | + vus.add(lab) | |
| 104 | + total = pos + neg | |
| 105 | + ax.plot([k - 0.38, k + 0.38], [total, total], color=palette.ink, linewidth=1.4) | |
| 106 | + ax.text(k, max(pos, total) + 0.0012, f"R = {total*100:.2f} %".replace(".", ","), ha="center", va="bottom", fontsize=8, fontweight="bold") | |
| 107 | + ax.axhline(R, color=palette.ink2, linestyle="--", linewidth=0.9, label=f"médiane du marché ({R*100:.2f} %)".replace(".", ",")) | |
| 108 | + ax.set_xticks(range(len(constructions))); ax.set_xticklabels([c[0] for c in constructions], fontsize=7.5) | |
| 109 | + ax.yaxis.set_major_formatter(pct1); ax.set_ylabel("Taux global $R$"); ax.set_xlim(-0.5, 3.5); ax.set_ylim(0, 0.066) | |
| 110 | + ax.set_title("Décomposition du taux global selon la méthode de construction (cas 3 ; M = 75 %, f = 5,08 %)", loc="left") | |
| 111 | + ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.30), ncol=4, fontsize=7.5) | |
| 112 | + ax.text(0.99, -0.46, "DONNÉES SYNTHÉTIQUES (paramètres du cas 3)", transform=ax.transAxes, ha="right", va="bottom", fontsize=7, color=palette.ink2) | |
| 113 | + return savefig(fig, "ch29/ch29-fig-bande-investissement.pdf") | |
| 114 | + | |
| 115 | + | |
| 116 | +def _valet(nom: str) -> pd.Series: | |
| 117 | + df = pd.read_csv(RAW / f"{nom}.csv", skiprows=8, header=None, names=["date", "v"]) | |
| 118 | + df["date"] = pd.to_datetime(df["date"], format="%Y-%m-%d", errors="coerce"); df["v"] = pd.to_numeric(df["v"], errors="coerce") | |
| 119 | + return df.dropna().set_index("date")["v"] / 100 | |
| 120 | + | |
| 121 | + | |
| 122 | +def fig_gdc(): | |
| 123 | + s5, s10, hyp = _valet("BD.CDN.5YR.DQ.YLD"), _valet("BD.CDN.10YR.DQ.YLD"), _valet("V80691335") | |
| 124 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) | |
| 125 | + ax.plot(s10.resample("MS").mean(), color=palette.cat[0], label="Obligation du GdC 10 ans (moyenne mensuelle)") | |
| 126 | + ax.plot(s5.resample("MS").mean(), color=palette.cat[1], label="Obligation du GdC 5 ans (moyenne mensuelle)") | |
| 127 | + ax.plot(hyp.resample("MS").mean(), color=palette.cat[2], label="Taux hypothécaire affiché 5 ans, banques (conventionnel)") | |
| 128 | + for s, dy in ((s10, 5), (s5, -5), (hyp, 0)): | |
| 129 | + ax.annotate(f"{s.iloc[-1]*100:.2f} %".replace(".", ","), (s.index[-1], s.iloc[-1]), xytext=(4, dy), textcoords="offset points", fontsize=7.5, va="center", color=palette.ink) | |
| 130 | + ax.yaxis.set_major_formatter(pct1); ax.set_ylabel("Taux annuel"); ax.set_xlabel("") | |
| 131 | + ax.set_title("Taux sans risque et coût de la dette au Canada, 2001-2026 : la base des primes de risque du taux global", loc="left") | |
| 132 | + ax.legend(loc="upper right", fontsize=7.5); ax.set_xlim(pd.Timestamp("2001-01-01"), pd.Timestamp("2027-06-01")) | |
| 133 | + return savefig(fig, "ch29/ch29-fig-taux-gdc.pdf"), {"gdc5": float(s5.iloc[-1]), "gdc10": float(s10.iloc[-1]), "hyp": float(hyp.iloc[-1]), "date5": str(s5.index[-1].date()), "datehyp": str(hyp.index[-1].date()), | |
| 134 | + "gdc10_moy_2001_2026": float(s10.mean()), "gdc10_min": float(s10.min()), "gdc10_min_date": str(s10.idxmin().date())} | |
| 135 | + | |
| 136 | + | |
| 137 | +def main(): | |
| 138 | + setup(); res = calculer() | |
| 139 | + outs = [fig_taux_extraits(res), fig_taux_etat_taille(res), fig_surface(res), fig_bande(res)] | |
| 140 | + o, info = fig_gdc(); outs.append(o) | |
| 141 | + for p in outs: print("OK", p.relative_to(ROOT)) | |
| 142 | + print("Banque du Canada (dernières observations) :", info) | |
| 143 | + | |
| 144 | + | |
| 145 | +if __name__ == "__main__": | |
| 146 | + main() | |
added
analysis/python/figures/ch30_flux.py
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Ch. 30, figure « flux » : RNE, CAPEX et flux net avant dette (barres) du cas 3 (DONNÉES SYNTHÉTIQUES), puis valeur | |
| 3 | +actuelle cumulée des flux et apport de la valeur terminale. Sortie : assets/figures/ch30/ch30-fig-flux.pdf.""" | |
| 4 | +from __future__ import annotations | |
| 5 | +import sys, pathlib | |
| 6 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 7 | +import numpy as np, matplotlib.pyplot as plt | |
| 8 | +from matplotlib.ticker import FuncFormatter | |
| 9 | +from traite.style import setup, palette, savefig, LARGEUR, fmt_milliers | |
| 10 | +from cases import ch30_dcf as c | |
| 11 | + | |
| 12 | +def main(): | |
| 13 | + setup() | |
| 14 | + r = c.base(); t = c.tableau_dcf(r); h = r["hyp"]; T = h["horizon_ans"]; d = r["dcf"] | |
| 15 | + tt = t[t["annee"] <= T] | |
| 16 | + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(LARGEUR, 4.6), sharex=True, gridspec_kw={"height_ratios": [1.15, 1], "hspace": 0.12}) | |
| 17 | + x = tt["annee"].values; w = 0.27 | |
| 18 | + ax1.bar(x - w, tt["rne"] / 1000, w, color=palette.cat[0], label="RNE") | |
| 19 | + ax1.bar(x, -tt["capex"] / 1000, w, color=palette.cat[1], label="CAPEX (sortie)") | |
| 20 | + ax1.bar(x + w, tt["flux_avant_dette"] / 1000, w, color=palette.cat[2], label="Flux net avant dette") | |
| 21 | + ax1.axhline(0, color=palette.ink2, lw=0.8) | |
| 22 | + ax1.set_ylabel("Milliers de dollars") | |
| 23 | + ax1.legend(loc="upper left", ncol=3) | |
| 24 | + # bas : VA cumulée | |
| 25 | + ax2.plot(x, tt["va_cumulee"] / 1000, marker="o", color=palette.cat[0], label="VA cumulée des flux") | |
| 26 | + ax2.vlines(T, tt["va_cumulee"].iloc[-1] / 1000, d["valeur"] / 1000, color=palette.cat[3], lw=3, label="VA de la valeur terminale") | |
| 27 | + ax2.scatter([T], [d["valeur"] / 1000], color=palette.cat[3], zorder=5) | |
| 28 | + ax2.axhline(d["valeur"] / 1000, color=palette.ink2, lw=0.8, ls="--") | |
| 29 | + ax2.annotate(f"$V_0$ = {fmt_milliers(d['valeur'])} \\$", xy=(1.2, d["valeur"] / 1000), va="bottom", fontsize=8, color=palette.ink) | |
| 30 | + ax2.annotate(f"flux : {fmt_milliers(d['va_flux'])} \\$ ({d['va_flux']/d['valeur']*100:.0f} %)", xy=(T - 0.1, tt["va_cumulee"].iloc[-1] / 1000 - 40), ha="right", va="top", fontsize=8) | |
| 31 | + ax2.annotate(f"VT : {fmt_milliers(d['va_terminale'])} \\$ ({d['part_terminale']*100:.0f} %)", xy=(T - 0.1, (tt["va_cumulee"].iloc[-1] + d["valeur"]) / 2000), ha="right", va="center", fontsize=8) | |
| 32 | + ax2.set_ylabel("Milliers de dollars"); ax2.set_xlabel("Année de projection") | |
| 33 | + ax2.set_xticks(x); ax2.set_ylim(0, d["valeur"] / 1000 * 1.12) | |
| 34 | + ax2.legend(loc="center left") | |
| 35 | + for ax in (ax1, ax2): ax.yaxis.set_major_formatter(FuncFormatter(lambda v, p: fmt_milliers(v))) | |
| 36 | + savefig(fig, "ch30/ch30-fig-flux.pdf") | |
| 37 | + | |
| 38 | +if __name__ == "__main__": | |
| 39 | + main() | |
added
analysis/python/figures/ch30_gdc.py
+37 −0
@@ -0,0 +1,37 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Ch. 30, figure « gdc » (DONNÉES RÉELLES, Banque du Canada, API Valet, téléchargées le 2026-10-31) : rendements des | |
| 3 | +obligations de référence du gouvernement du Canada à 5 et 10 ans et taux hypothécaire conventionnel 5 ans affiché, | |
| 4 | +moyennes mensuelles 2006-2026. Sortie : ch30-fig-gdc.pdf.""" | |
| 5 | +from __future__ import annotations | |
| 6 | +import sys, pathlib, io | |
| 7 | +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) | |
| 8 | +import pandas as pd, matplotlib.pyplot as plt | |
| 9 | +from traite.style import setup, palette, savefig, LARGEUR, ROOT | |
| 10 | + | |
| 11 | +RAW = ROOT / "data/raw/bdc_valet" | |
| 12 | + | |
| 13 | +def lire(nom: str) -> pd.Series: | |
| 14 | + txt = (RAW / f"{nom}.csv").read_text(encoding="utf-8-sig") | |
| 15 | + obs = txt[txt.index('"OBSERVATIONS"'):].split("\n", 1)[1] | |
| 16 | + df = pd.read_csv(io.StringIO(obs)); df.columns = ["date", "val"] | |
| 17 | + df["date"] = pd.to_datetime(df["date"]); df["val"] = pd.to_numeric(df["val"], errors="coerce") | |
| 18 | + return df.dropna().set_index("date")["val"] | |
| 19 | + | |
| 20 | +def main(): | |
| 21 | + setup() | |
| 22 | + s10 = lire("BD.CDN.10YR.DQ.YLD"); s5 = lire("BD.CDN.5YR.DQ.YLD"); hyp = lire("V80691335") | |
| 23 | + deb = "2006-01-01" | |
| 24 | + m10 = s10[deb:].resample("MS").mean(); m5 = s5[deb:].resample("MS").mean(); mh = hyp[deb:].resample("MS").mean() | |
| 25 | + fig, ax = plt.subplots(figsize=(LARGEUR, 3.2)) | |
| 26 | + ax.plot(mh.index, mh.values, color=palette.cat[1], lw=1.4, label="Taux hypothécaire conventionnel 5 ans affiché (V80691335)") | |
| 27 | + ax.plot(m10.index, m10.values, color=palette.cat[0], lw=1.6, label="Obligation GdC 10 ans (BD.CDN.10YR.DQ.YLD)") | |
| 28 | + ax.plot(m5.index, m5.values, color=palette.cat[2], lw=1.2, label="Obligation GdC 5 ans (BD.CDN.5YR.DQ.YLD)") | |
| 29 | + last10 = s10.iloc[-1]; lasth = hyp.iloc[-1] | |
| 30 | + ax.annotate(f"{last10:.2f} % (29 oct. 2026)".replace(".", ","), xy=(s10.index[-1], last10), xytext=(pd.Timestamp("2022-03-01"), 1.0), fontsize=8, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7)) | |
| 31 | + ax.annotate(f"{lasth:.2f} %".replace(".", ","), xy=(hyp.index[-1], lasth), xytext=(pd.Timestamp("2024-09-01"), 7.6), fontsize=8, arrowprops=dict(arrowstyle="-", color=palette.ink2, lw=0.7)) | |
| 32 | + ax.set_ylabel("Taux (%)"); ax.set_ylim(0, 9) | |
| 33 | + ax.legend(loc="upper right", fontsize=7.5) | |
| 34 | + savefig(fig, "ch30/ch30-fig-gdc.pdf") | |
| 35 | + | |
| 36 | +if __name__ == "__main__": | |
| 37 | + main() | |
added
analysis/python/figures/ch30_loyers.py
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch30_surface.py
+34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch30_tornade.py
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/figures/ch47_dossier_figures.py
+117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/tests/test_ch16.py
+59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/tests/test_ch29.py
+196 −0
Large diff (196 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/tests/test_ch30.py
+143 −0
Large diff (143 lines) is not loaded by default to keep the page fast.Load diff
added
analysis/python/tests/test_ch47.py
+117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added
assets/figures/ch21/ch21-fig-decomposition-corps.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch21/ch21-fig-ipcb-montreal.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch21/ch21-fig-sensibilite.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch29/ch29-fig-bande-investissement.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch29/ch29-fig-surface-v.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch29/ch29-fig-taux-etat-taille.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch29/ch29-fig-taux-extraits.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch29/ch29-fig-taux-gdc.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch30/ch30-fig-flux.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch30/ch30-fig-gdc.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch30/ch30-fig-loyers.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch30/ch30-fig-surface.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch30/ch30-fig-tornade.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch47/ch47-fig-depenses.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch47/ch47-fig-etat-locatif.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch47/ch47-fig-flux-dcf.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch47/ch47-fig-montecarlo.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch47/ch47-fig-rcd-scenarios.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch47/ch47-fig-taux-extraits.pdf
+0 −0
Binary file not shown.
added
assets/figures/ch47/ch47-fig-tornade.pdf
+0 −0
Binary file not shown.
modified
manuscript/chapters/ch16.tex
+447 −1
Large diff (449 lines) is not loaded by default to keep the page fast.Load diff
modified
manuscript/chapters/ch21.tex
+176 −1
Large diff (178 lines) is not loaded by default to keep the page fast.Load diff
modified
solutions/ch16.tex
+35 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
modified
styles/traite-qc.sty
+32 −32
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
modified
tools/compile_chapter.sh
+2 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added
tools/make_labels.py
+17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
modified
tools/merge_sources.py
+42 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/calculations.csv
+1 −1
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch16-calculations.csv
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch16-claims.csv
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch16-figures.csv
+6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch16-registry.csv
+1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added
verification/chapters/ch16.md
+7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/claims.csv
+1 −1
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
modified
verification/figures.csv
+1 −1
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff