#!/usr/bin/env python3 """Chapitre 47 — Cas 3 : immeuble de 12 logements à Montréal (DONNÉES SYNTHÉTIQUES, immeuble fictif). Dossier complet : revenus, dépenses normalisées, RNE, extraction du taux global, capitalisation directe, DCF 10 ans, comparaison par unité (prix par logement, MRB), financement (RPV / RCD, APH Select), scénarios de taux au renouvellement, Monte-Carlo (10 000 tirages, graine 20261031), tornade, réconciliation, droits de mutation, exercices. 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 calculs propres au dossier. Il IMPRIME tous les chiffres cités dans manuscript/chapters/ch47.tex. Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch47_dossier.py [--n 10000] """ from __future__ import annotations import sys, pathlib, argparse, math import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import cas3, finance as fin, comparables as cp ROOT = pathlib.Path(__file__).resolve().parents[3] GRAINE = 20261031 DATE_EVAL = pd.Timestamp("2026-09-01") # ----------------------------------------------------------------------------- hypothèses propres au chapitre 47 # Comparaison par unité (ajustements déclarés ; voir §47.10 du chapitre) 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) AJ_TEMPS_ANNUEL = 0.0 # aucun ajustement temporel (effet non significatif sur l'échantillon ; marché stable) ECHELLES_SEL = {"nb_logements": 6.0, "annee_construction": 15.0, "mois": 12.0, "etat_1a5": 1.0} POIDS_SEL = {"nb_logements": 2.0, "annee_construction": 1.0, "mois": 1.0, "etat_1a5": 1.0} K_COMPARABLES = 10 # Réconciliation (pondérations raisonnées, §47.13) POIDS_RECONCILIATION = {"capitalisation": 0.45, "dcf": 0.35, "comparaison_unite": 0.20} ARRONDI_VALEUR = 10_000 # Droits sur les mutations immobilières, Ville de Montréal, 2026 (bornes supérieures des tranches, taux) 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)] # Monte-Carlo : lois déclarées (§47.12) MC_LOIS = { "inoccupation_pertes": ("lognormale", {"mediane": 0.039, "sigma_log": 0.30}), "croissance_loyers_marche": ("normale", {"mu": 0.035, "sigma": 0.010}), "rotation_annuelle": ("triangulaire", {"min": 0.08, "mode": 0.15, "max": 0.25}), "croissance_depenses": ("normale", {"mu": 0.030, "sigma": 0.0075}), "taux_actualisation": ("normale", {"mu": 0.0725, "sigma": 0.005}), "ecart_terminal": ("normale", {"mu": 0.0225, "sigma": 0.004}), # R_T = r − écart ; plancher R_T ≥ 3,5 % "facteur_capex": ("lognormale", {"mediane": 1.0, "sigma_log": 0.20}), # un facteur indépendant par événement CAPEX } R_T_PLANCHER = 0.035 # ----------------------------------------------------------------------------- utilitaires def droits_mutation_montreal(base: float, tranches=TRANCHES_MUTATION_MTL_2026) -> float: """Droit sur les mutations immobilières (barème progressif par tranches, Ville de Montréal 2026).""" droit, bas = 0.0, 0.0 for haut, taux in tranches: if base <= bas: break droit += (min(base, haut) - bas) * taux bas = haut return droit def arrondir(x: float, pas: int = ARRONDI_VALEUR) -> float: return round(x / pas) * pas # ----------------------------------------------------------------------------- comparaison par unité def ols_prix_par_logement(comps: pd.DataFrame) -> dict: """Régression auxiliaire ln(prix par logement) sur état, taille, niveau de loyer, temps et quartier (extraction d'ajustements).""" c = comps.copy() c["mois"] = (DATE_EVAL - c["date_vente"]).dt.days / 30.4375 Q = pd.get_dummies(c["quartier"]).astype(float).drop(columns=["Rosemont"]) 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]) noms = ["constante", "etat", "taille", "ln_loyer", "annees"] + list(Q.columns) y = np.log(c["prix_par_logement"].values) b, *_ = np.linalg.lstsq(X, y, rcond=None) e = y - X @ b; k = X.shape[1]; s2 = float(e @ e) / (len(c) - k) se = np.sqrt(np.diag(s2 * np.linalg.inv(X.T @ X))) r2 = 1 - float(e @ e) / float(((y - y.mean()) ** 2).sum()) return {"coef": dict(zip(noms, b)), "se": dict(zip(noms, se)), "r2": r2, "sigma": math.sqrt(s2), "n": len(c)} def comparaison_par_unite(d: dict, base: dict) -> dict: comps = d["comps"].copy(); sujet = d["sujet"] comps["mois"] = (DATE_EVAL - comps["date_vente"]).dt.days / 30.4375 ols = ols_prix_par_logement(comps) cf = ols["coef"] suj = {"nb_logements": sujet["nb_logements"], "annee_construction": sujet["annee_construction"], "mois": 0.0, "etat_1a5": sujet["etat_1a5"]} comps["distance"] = [cp.distance_attributs(suj, r, ECHELLES_SEL, POIDS_SEL) for r in comps.to_dict("records")] sel = comps.sort_values("distance").head(K_COMPARABLES).copy() loyer_sujet = base["rbp_contrat"] / (12 * sujet["nb_logements"]) objets = []; lignes = [] for r in sel.to_dict("records"): p = r["prix_par_logement"] # ajustement de transaction (marché/temps) en pourcentage, séquentiel base_tx = cp.prix_ajuste_multiplicatif(p, [(1 + AJ_TEMPS_ANNUEL) ** (r["mois"] / 12.0) - 1]) # ajustements de propriété en dollars par logement, signe du point de vue du sujet a_loc = base_tx * (math.exp(-cf.get(r["quartier"], 0.0)) - 1) # sujet à Rosemont (référence) a_taille = base_tx * (math.exp(cf["taille"] * (sujet["nb_logements"] - r["nb_logements"])) - 1) a_etat = base_tx * AJ_ETAT_PAR_POINT * (sujet["etat_1a5"] - r["etat_1a5"]) a_loyer = base_tx * ((loyer_sujet / r["loyer_mensuel_moyen"]) ** cf["ln_loyer"] - 1) aj = {"aj_localisation": a_loc, "aj_taille": a_taille, "aj_etat": a_etat, "aj_loyer": a_loyer} o = cp.ComparableAjuste(r["id_vente"], base_tx, aj); objets.append(o) lignes.append({"id": r["id_vente"], "date": r["date_vente"].date().isoformat(), "quartier": r["quartier"], "n": r["nb_logements"], "annee": r["annee_construction"], "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"], "distance": r["distance"], **aj, "prix_ajuste": o.prix_ajuste, "brut_pct": o.brut_pct, "net_pct": o.net_pct}) grille = pd.DataFrame(lignes) w = [float(x) for x in cp.poids_inverse_brut(objets)] rec = {k: (float(v) if isinstance(v, (int, float, np.floating)) else v) for k, v in cp.reconciliation(objets, w).items()} indication = float(rec["valeur_ponderee"] * sujet["nb_logements"]) mrb_med = float(sel["mrb"].median()); mrb_q1 = float(sel["mrb"].quantile(0.25)); mrb_q3 = float(sel["mrb"].quantile(0.75)) indication_mrb = mrb_med * base["rbp_contrat"] return {"ols": ols, "grille": grille, "poids": w, "reconciliation": rec, "prix_par_logement_retenu": rec["valeur_ponderee"], "indication": indication, "loyer_moyen_sujet": loyer_sujet, "mrb_mediane": mrb_med, "mrb_q1": mrb_q1, "mrb_q3": mrb_q3, "indication_mrb": indication_mrb, "mrb_sujet_cap": None, "selection": sel} # ----------------------------------------------------------------------------- financement et scénarios de taux def scenarios_renouvellement(r: dict, hausses_pb=(0, 100, 200, 300)) -> pd.DataFrame: """Au terme de 5 ans, le solde est refinancé au taux initial + hausse, sur l'amortissement résiduel (420 mois).""" f = r["financement"]; h = f["hypotheque"]; p = r["projection"]; terme = r["hyp"]["financement"]["terme_ans"] solde = h.solde(terme * 12); rows = [] for pb in hausses_pb: t = h.taux_nominal + pb / 10_000; h2 = fin.Hypotheque(solde, t, h.amortissement_mois - terme * 12) sd = h2.service_dette_annuel 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)} flux6 = float(p.loc[p["annee"] == terme + 1, "rne"].iloc[0]) - sd rows.append({"hausse_pb": pb, "taux": t, "solde_renouvele": solde, "service_dette": sd, "paiement_mensuel": h2.paiement, "flux_apres_dette_an6": flux6, **rcd}) return pd.DataFrame(rows) def rcd_par_annee(r: dict, sc: pd.DataFrame) -> pd.DataFrame: """RCD par année 1-10 : années 1-5 au taux initial, années 6-10 selon le scénario de renouvellement.""" p = r["projection"]; f = r["financement"]; terme = r["hyp"]["financement"]["terme_ans"] out = {"annee": list(range(1, r["hyp"]["horizon_ans"] + 1))} for _, s in sc.iterrows(): col = [] for a in out["annee"]: sd = f["service_dette"] if a <= terme else s["service_dette"] col.append(float(p.loc[p["annee"] == a, "rne"].iloc[0]) / sd) out[f"+{int(s['hausse_pb'])} pb"] = col return pd.DataFrame(out) def aph_select(r: dict, valeur: float, rpv_max=0.85, rcd_min=1.10, amortissements=(480, 540, 600), prime_pct=None) -> pd.DataFrame: """Variante APH Select (SCHL) : RPV 85 % (50 points, existant), RCD 1,10, amortissement 40/45/50 ans, taux assuré 4,12 %.""" f = r["hyp"]["financement"]; noi = r["base"]["rne"]; rows = [] for am in amortissements: p_rpv = fin.pret_max_par_rpv(valeur, rpv_max); p_rcd = fin.pret_max_par_rcd(noi, rcd_min, f["taux"], am) pret = min(p_rpv, p_rcd); h = fin.Hypotheque(pret, f["taux"], am) rpv = pret / valeur 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 surprime = 0.0025 * max(0, (am / 12 - 25) / 5) 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, "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}) return pd.DataFrame(rows) # ----------------------------------------------------------------------------- Monte-Carlo 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: """Projection vectorisée (N tirages) reproduisant exactement `cas3.projection_dcf` pour les paramètres de base. fac_capex : tableau (N, nb_événements CAPEX) de facteurs multiplicatifs.""" T = hyp["horizon_ans"]; N = len(v); n_log = len(etat) g_c = hyp["croissance_loyers_contrat"] if g_c is None else g_c loyers = np.tile(etat["loyer_mensuel_contrat"].astype(float).values, (N, 1)); marche = np.tile(etat["loyer_mensuel_marche"].astype(float).values, (N, 1)) autres = hyp["autres_revenus_annuels"]; res0 = base["reserve"] de0_hors_gestion = base["depenses_hors_reserve"] - 0.04 * base["rbe"] rbe1 = loyers.sum(1) * 12 * (1 - v) + autres de0 = de0_hors_gestion + 0.04 * rbe1 # gestion imputée = 4 % du RBE de première année k = np.round(rot * n_log).astype(int) annee0 = int(hyp["date_evaluation"][:4]); evenements = sorted(hyp["capex"].items()) rne = np.zeros((N, T + 1)); capex = np.zeros((N, T)); rbp = np.zeros((N, T + 1)) for t in range(1, T + 2): if t > 1: loyers = loyers * (1 + g_c); marche = marche * (1 + g_m[:, None]) rang = np.argsort(np.argsort(loyers / marche, axis=1), axis=1) loyers = np.where(rang < k[:, None], marche, loyers) rbp[:, t - 1] = loyers.sum(1) * 12 rbe = rbp[:, t - 1] * (1 - v) + autres * (1 + g_d) ** (t - 1) de = de0 * (1 + g_d) ** (t - 1) rne[:, t - 1] = rbe - de - res0 if t <= T: for j, (an, montant) in enumerate(evenements): if int(an) == annee0 + t - 1: capex[:, t - 1] += montant * fac_capex[:, j] flux = rne[:, :T] - capex return {"rne": rne, "capex": capex, "flux": flux, "rbp": rbp} def valeur_vectorisee(proj: dict, r: np.ndarray, R_T: np.ndarray, cv: float) -> dict: T = proj["flux"].shape[1] t = np.arange(1, T + 1) disc = (1 + r[:, None]) ** (-t) va_flux = (proj["flux"] * disc).sum(1) vt_nette = proj["rne"][:, T] / R_T * (1 - cv) va_vt = vt_nette / (1 + r) ** T return {"valeur": va_flux + va_vt, "va_flux": va_flux, "va_terminale": va_vt, "vt_nette": vt_nette} def tirer(n: int, graine: int, nb_capex: int, lois=MC_LOIS) -> dict: rng = np.random.default_rng(graine) def tir(nom, taille): typ, p = lois[nom] if typ == "lognormale": return np.exp(np.log(p["mediane"]) + p["sigma_log"] * rng.standard_normal(taille)) if typ == "normale": return p["mu"] + p["sigma"] * rng.standard_normal(taille) if typ == "triangulaire": return rng.triangular(p["min"], p["mode"], p["max"], taille) raise ValueError(typ) x = {"inoccupation_pertes": np.clip(tir("inoccupation_pertes", n), 0.0, 0.30), "croissance_loyers_marche": tir("croissance_loyers_marche", n), "rotation_annuelle": tir("rotation_annuelle", n), "croissance_depenses": tir("croissance_depenses", n), "taux_actualisation": np.clip(tir("taux_actualisation", n), 0.04, None), "ecart_terminal": tir("ecart_terminal", n), "facteur_capex": tir("facteur_capex", (n, nb_capex))} x["taux_terminal"] = np.maximum(x["taux_actualisation"] - x["ecart_terminal"], R_T_PLANCHER) return x def monte_carlo(r: dict, n: int = 10_000, graine: int = GRAINE) -> dict: etat, hyp, base = r["etat"], r["hyp"], r["base"] x = tirer(n, graine, len(hyp["capex"])) proj = projeter_vectorise(etat, hyp, base, x["inoccupation_pertes"], x["croissance_loyers_marche"], x["rotation_annuelle"], x["croissance_depenses"], x["facteur_capex"]) val = valeur_vectorisee(proj, x["taux_actualisation"], x["taux_terminal"], hyp["couts_vente"]) V = val["valeur"] sd = r["financement"]["service_dette"]; terme = hyp["financement"]["terme_ans"] rcd = proj["rne"] / sd # RCD par année au prêt de base (service de la dette constant sur le terme) rcd_min5 = rcd[:, :terme].min(1) # renouvellement à l'année 6 : taux initial + 200 pb sur le solde résiduel h = r["financement"]["hypotheque"]; h6 = fin.Hypotheque(h.solde(terme * 12), h.taux_nominal + 0.02, h.amortissement_mois - terme * 12) rcd6_choc = proj["rne"][:, terme] / h6.service_dette_annuel q = lambda a, p: float(np.quantile(a, p)) res = {"n": n, "graine": graine, "tirages": x, "valeur": V, "rne": proj["rne"], "rcd": rcd, "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), "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())}, "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()), "rcd1_sous_1.20": float((rcd[:, 0] < 1.20).mean()), "rcd1_sous_1.00": float((rcd[:, 0] < 1.00).mean()), "rcd_min5_sous_1.20": float((rcd_min5 < 1.20).mean()), "rcd_min5_sous_1.00": float((rcd_min5 < 1.00).mean()), "rcd6_choc200_sous_1.20": float((rcd6_choc < 1.20).mean()), "rcd6_choc200_sous_1.00": float((rcd6_choc < 1.00).mean())}, "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), "sd_renouvele_200pb": h6.service_dette_annuel} # corrélations (sensibilité globale) : coefficient de corrélation de rang entre chaque intrant et la valeur rangs = {} for nom in ["inoccupation_pertes", "croissance_loyers_marche", "rotation_annuelle", "croissance_depenses", "taux_actualisation", "taux_terminal"]: rangs[nom] = float(pd.Series(x[nom]).corr(pd.Series(V), method="spearman")) rangs["facteur_capex_moyen"] = float(pd.Series(x["facteur_capex"].mean(1)).corr(pd.Series(V), method="spearman")) res["correlations_rang"] = rangs return res def tornade(r: dict) -> pd.DataFrame: """Sensibilité un-à-la-fois : chaque intrant à son quantile 10 % et 90 % (autres intrants à la base), valeur DCF.""" etat, hyp, base = r["etat"], r["hyp"], r["base"] from scipy import stats def q(nom, p): typ, par = MC_LOIS[nom] if typ == "lognormale": return float(np.exp(np.log(par["mediane"]) + par["sigma_log"] * stats.norm.ppf(p))) if typ == "normale": return float(par["mu"] + par["sigma"] * stats.norm.ppf(p)) 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"])) basepar = {"inoccupation_pertes": hyp["taux_inoccupation_pertes"], "croissance_loyers_marche": hyp["croissance_loyers_marche"], "rotation_annuelle": hyp["rotation_annuelle"], "croissance_depenses": hyp["croissance_depenses"], "taux_actualisation": hyp["taux_actualisation"], "ecart_terminal": hyp["taux_actualisation"] - hyp["taux_terminal"], "facteur_capex": 1.0} def valeur(par): one = lambda k: np.array([par[k]]) fac = np.full((1, len(hyp["capex"])), par["facteur_capex"]) proj = projeter_vectorise(etat, hyp, base, one("inoccupation_pertes"), one("croissance_loyers_marche"), one("rotation_annuelle"), one("croissance_depenses"), fac) R_T = max(par["taux_actualisation"] - par["ecart_terminal"], R_T_PLANCHER) return float(valeur_vectorisee(proj, one("taux_actualisation"), np.array([R_T]), hyp["couts_vente"])["valeur"][0]) V0 = valeur(basepar); rows = [] for nom in basepar: lo = dict(basepar); hi = dict(basepar); lo[nom] = q(nom, 0.10); hi[nom] = q(nom, 0.90) rows.append({"intrant": nom, "bas": lo[nom], "haut": hi[nom], "V_bas": valeur(lo), "V_haut": valeur(hi), "V_base": V0}) t = pd.DataFrame(rows); t["amplitude"] = (t["V_haut"] - t["V_bas"]).abs() return t.sort_values("amplitude", ascending=False).reset_index(drop=True) # ----------------------------------------------------------------------------- réconciliation def reconciliation(r: dict, cu: dict) -> dict: ind = {"capitalisation": r["valeur_capitalisation"], "dcf": r["dcf"]["valeur"], "comparaison_unite": cu["indication"]} w = POIDS_RECONCILIATION vp = sum(w[k] * ind[k] for k in ind) V = arrondir(vp) hyp = r["hyp"]; sujet = r["sujet"]; base = r["base"] return {"indications": ind, "mrb_indication": cu["indication_mrb"], "poids": w, "valeur_ponderee": vp, "valeur_finale": V, "fourchette": (min(ind.values()), max(ind.values())), "ratio_valeur_role": V / hyp["valeur_role_2026"], "prix_par_logement": V / sujet["nb_logements"], "mrb_contrat": V / base["rbp_contrat"], "mrb_marche": V / base["rbp_marche"], "taux_implicite": base["rne"] / V, "droits_mutation": droits_mutation_montreal(V), "prix_par_m2_locatif": V / sujet["superficie_locative_m2"]} def valeur_libre_de_baux(r: dict) -> dict: """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).""" b, hyp, dep = r["base"], r["hyp"], r["dep"] rbe_m = fin.revenu_brut_effectif(b["rbp_marche"], hyp["taux_inoccupation_pertes"], hyp["autres_revenus_annuels"]) dn = cas3.depenses_normalisees(dep, hyp, rbe_m) de = float(dn.loc[dn["type"] != "réserve", "normalise"].sum()); res = float(dn.loc[dn["type"] == "réserve", "normalise"].sum()) rne_m = fin.rne(rbe_m, de, res) 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"]} def financement_final(r: dict, V: float) -> dict: f = cas3.financement(V, r["base"]["rne"], r["hyp"]) p = r["projection"]; flux_ad = p.loc[p["annee"] <= r["hyp"]["horizon_ans"], "flux_avant_dette"].tolist() lev = fin.rendement_fonds_propres_levier(flux_ad, V, f["hypotheque"], r["dcf"]["vt_nette"]) f["tri_fonds_propres"] = lev["tri"]; f["flux_fonds_propres"] = lev["flux"]; f["solde_final"] = lev["solde_final"] f["cash_on_cash_1"] = fin.rendement_cash_on_cash(flux_ad[0] - f["service_dette"], f["mise_de_fonds"]) f["point_mort"] = fin.ratio_point_mort(r["base"]["depenses_totales"], f["service_dette"], r["base"]["rbp_contrat"]) f["point_mort_hors_reserve"] = fin.ratio_point_mort(r["base"]["depenses_hors_reserve"], f["service_dette"], r["base"]["rbp_contrat"]) tri_sans = fin.tri([-V] + flux_ad[:-1] + [flux_ad[-1] + r["dcf"]["vt_nette"]]) f["tri_sans_levier"] = tri_sans return f # ----------------------------------------------------------------------------- exercices def exercices(r: dict, cu: dict, rec: dict) -> dict: """Solutions chiffrées des exercices du chapitre 47 (solutions/ch47.tex).""" b = r["base"]; hyp = r["hyp"]; tx = r["taux"]; out = {} # Ex. 1 : vacance économique 5 % au lieu de 3,9 % rbe5 = fin.revenu_brut_effectif(b["rbp_contrat"], 0.05, b["autres_revenus"]); dn5 = cas3.depenses_normalisees(r["dep"], hyp, rbe5) de5 = float(dn5.loc[dn5["type"] != "réserve", "normalise"].sum()); res5 = float(dn5.loc[dn5["type"] == "réserve", "normalise"].sum()) rne5 = fin.rne(rbe5, de5, res5) 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"]} # Ex. 2 : taxes municipales = historique 2025 × 1,053 (hausse Rosemont 2026) au lieu du recalcul au taux général taxes_alt = 10631 * 1.053; taxes_norm = hyp["valeur_role_2026"] * hyp["taux_taxe_fonciere"] rne2 = b["rne"] - (taxes_alt - taxes_norm) 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"]} # Ex. 3 : fourchette Q1-Q3 du taux et taux par régression (état 3, 12 logements) 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"], "V_regression": fin.capitalisation_directe(b["rne"], tx["regression"]["constante"]), "V_ponderee": fin.capitalisation_directe(b["rne"], tx["ponderee_rne"])} # Ex. 4 : rotation 10 % (au lieu de 15 %) et 25 % for nom, rot in [("rot10", 0.10), ("rot25", 0.25)]: h2 = dict(hyp); h2["rotation_annuelle"] = rot p2 = cas3.projection_dcf(r["etat"], r["dep"], h2); v2 = cas3.valeur_dcf(p2, h2) 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])} # 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 V = rec["valeur_finale"]; f5 = dict(hyp["financement"]); f5["taux"] = 0.0512 h5 = dict(hyp); h5["financement"] = f5; fi5 = cas3.financement(V, b["rne"], h5) out["ex5"] = {k: fi5[k] for k in ["pret_rpv", "pret_rcd", "pret", "contrainte", "service_dette", "rcd", "rpv", "mise_de_fonds", "constante"]} out["ex5"]["ecart_pret"] = fi5["pret"] - cas3.financement(V, b["rne"], hyp)["pret"] # Ex. 6 : APH Select 50 points (RPV 85 %, RCD 1,10, 40 ans) sur la valeur finale, prime aph = aph_select(r, V, amortissements=(480,)); out["ex6"] = aph.iloc[0].to_dict() out["ex6"]["pret_conventionnel"] = cas3.financement(V, b["rne"], hyp)["pret"] # Ex. 7 : Monte-Carlo avec inoccupation médiane 6 % (marché détendu) — 10 000 tirages, même graine lois = dict(MC_LOIS); lois["inoccupation_pertes"] = ("lognormale", {"mediane": 0.06, "sigma_log": 0.30}) x = tirer(10_000, GRAINE, len(hyp["capex"]), lois) proj = projeter_vectorise(r["etat"], hyp, b, x["inoccupation_pertes"], x["croissance_loyers_marche"], x["rotation_annuelle"], x["croissance_depenses"], x["facteur_capex"]) val = valeur_vectorisee(proj, x["taux_actualisation"], x["taux_terminal"], hyp["couts_vente"])["valeur"] sd = r["financement"]["service_dette"]; rcd1 = proj["rne"][:, 0] / sd 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()), "rcd1_mediane": float(np.median(rcd1))} return out # ----------------------------------------------------------------------------- dossier complet def dossier(n_mc: int = 10_000, graine: int = GRAINE) -> dict: r = cas3.tout() cu = comparaison_par_unite(r, r["base"]) sc = scenarios_renouvellement(r); rcd_an = rcd_par_annee(r, sc) mc = monte_carlo(r, n_mc, graine); torn = tornade(r) rec = reconciliation(r, cu); fi = financement_final(r, rec["valeur_finale"]); fi_dcf = financement_final(r, r["dcf"]["valeur"]) aph = aph_select(r, rec["valeur_finale"]) 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, "financement_final": fi, "financement_dcf": fi_dcf, "aph": aph} def _d(x): return f"{x:,.0f}".replace(",", " ") + " $" def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %" def imprimer(D: dict) -> None: pd.set_option("display.width", 220); pd.set_option("display.max_columns", 40); pd.set_option("display.float_format", lambda v: f"{v:,.4f}") b, hyp, tx, p = D["base"], D["hyp"], D["taux"], D["projection"] print("=" * 100 + "\nCH. 47 — CAS 3 : 12 LOGEMENTS, ROSEMONT (DONNÉES SYNTHÉTIQUES, immeuble fictif) — date d'évaluation", hyp["date_evaluation"]) print("\n--- 47.4-47.5 État locatif et revenus ---") 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"] print(e[["logement", "pieces", "superficie_m2", "loyer_mensuel_contrat", "loyer_mensuel_marche", "ecart", "ecart_pct", "locataire_depuis", "anciennete", "stationnement"]].to_string(index=False)) 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())) 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}") for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "autres_revenus", "vacance_pertes", "rbe"]: print(f"{k:22s} {_d(b[k])}") 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} $") print("\n--- 47.6 Dépenses (historique 2023-2025 et normalisation) ---") hist = D["dep"].pivot(index="poste", columns="annee", values="montant"); print(hist.to_string()); print("totaux", hist.sum().to_dict()) print(b["detail"].to_string(index=False)) 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), "ratio/RBP", _p(b["depenses_totales"] / b["rbp_contrat"], 1), "dépenses/logement", _d(b["depenses_totales"] / 12)) 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"])) 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)) print("\n--- 47.8 Taux extraits (n = %d) ---" % tx["n"]) for k in ["mediane", "moyenne", "ponderee_rne", "q1", "q3", "min", "max"]: print(f"{k:14s} {_p(tx[k], 3)}") 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)) 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()) 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)) print("Valeur par capitalisation (RNE_1 / médiane)", f"{D['valeur_capitalisation']:.2f}", "→ par logement", _d(D["valeur_capitalisation"] / 12)) 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"])) print("\n--- 47.9 DCF 10 ans ---"); print(p.to_string(index=False)) d = D["dcf"] for k in ["valeur", "va_flux", "va_terminale", "vt_brute", "vt_nette", "rne_T1", "part_terminale"]: print(f"{k:16s} {d[k]:.4f}") 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())) 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)) print("écart DCF − capitalisation", _d(d["valeur"] - D["valeur_capitalisation"]), _p(d["valeur"] / D["valeur_capitalisation"] - 1, 1)) print("\n--- 47.10 Comparaison par unité ---") cu = D["comparaison_unite"]; o = cu["ols"] print("OLS ln(prix/logement) : n =", o["n"], "R² =", round(o["r2"], 3), "σ =", round(o["sigma"], 4)) for k in o["coef"]: print(f" {k:12s} {o['coef'][k]:+.4f} (é.-t. {o['se'][k]:.4f})") print("loyer moyen sujet (contrat)", f"{cu['loyer_moyen_sujet']:.2f}") print(cu["grille"].to_string(index=False)); print("poids", [round(w, 3) for w in cu["poids"]]) rc = cu["reconciliation"]; print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in rc.items() if k != "poids"}) print("prix/logement retenu", f"{cu['prix_par_logement_retenu']:.2f}", "→ indication", f"{cu['indication']:.2f}") 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}") print("\n--- 47.11 Financement (base : valeur DCF, comme au ch. 31) ---") f = D["financement"] 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]}") 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}") 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)) print("flux fonds propres", [round(x) for x in fd["flux_fonds_propres"]]) 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))) print("\nScénarios de renouvellement (an 6) :"); print(D["scenarios"].to_string(index=False)); print(D["rcd_par_annee"].to_string(index=False)) print("\nAPH Select (valeur finale) :"); print(D["aph"].to_string(index=False)) print("\n--- 47.12 Monte-Carlo ---") 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()}) 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"])) print("corrélations de rang", {k: round(v, 3) for k, v in mc["correlations_rang"].items()}) for nom in ["inoccupation_pertes", "croissance_loyers_marche", "rotation_annuelle", "croissance_depenses", "taux_actualisation", "taux_terminal"]: 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}") print("tornade :"); print(D["tornade"].to_string(index=False)) print("\n--- 47.13 Réconciliation ---") 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)) rec = D["reconciliation"] for k, v in rec.items(): print(f"{k:20s} {v}") ff = D["financement_final"] 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"]}) print("coût d'acquisition : droits de mutation", _d(rec["droits_mutation"]), "; mise de fonds + droits", _d(ff["mise_de_fonds"] + rec["droits_mutation"])) print("\n--- Exercices ---") 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()}) if __name__ == "__main__": ap = argparse.ArgumentParser(); ap.add_argument("--n", type=int, default=10_000); a = ap.parse_args() imprimer(dossier(a.n))