SPB Git

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>
Simon-Pierre Boucher committed (Oct 31, 2026) parent 1d42af1 Browse files

54 changed files +3,024 −37

54 changed files
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 - 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. 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 - 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é. 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 - 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 ». 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 - 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 - 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 - **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 - **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
Menu

Theme