SPB Git
34.4 KB · 418 lines python
1#!/usr/bin/env python32"""Chapitre 47 — Cas 3 : immeuble de 12 logements à Montréal (DONNÉES SYNTHÉTIQUES, immeuble fictif).34Dossier complet : revenus, dépenses normalisées, RNE, extraction du taux global, capitalisation directe, DCF 10 ans,5comparaison par unité (prix par logement, MRB), financement (RPV / RCD, APH Select), scénarios de taux au renouvellement,6Monte-Carlo (10 000 tirages, graine 20261031), tornade, réconciliation, droits de mutation, exercices.78Les calculs de base viennent de `traite.cas3` (partagés avec les chapitres 26-31, 33, 34, 40) ; ce script n'y ajoute que les9calculs propres au dossier. Il IMPRIME tous les chiffres cités dans manuscript/chapters/ch47.tex.1011Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch47_dossier.py [--n 10000]12"""13from __future__ import annotations14import sys, pathlib, argparse, math15import numpy as np, pandas as pd16sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))17from traite import cas3, finance as fin, comparables as cp1819ROOT = pathlib.Path(__file__).resolve().parents[3]20GRAINE = 2026103121DATE_EVAL = pd.Timestamp("2026-09-01")2223# ----------------------------------------------------------------------------- hypothèses propres au chapitre 4724# Comparaison par unité (ajustements déclarés ; voir §47.10 du chapitre)25AJ_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)26AJ_TEMPS_ANNUEL = 0.0             # aucun ajustement temporel (effet non significatif sur l'échantillon ; marché stable)27ECHELLES_SEL = {"nb_logements": 6.0, "annee_construction": 15.0, "mois": 12.0, "etat_1a5": 1.0}28POIDS_SEL = {"nb_logements": 2.0, "annee_construction": 1.0, "mois": 1.0, "etat_1a5": 1.0}29K_COMPARABLES = 103031# Réconciliation (pondérations raisonnées, §47.13)32POIDS_RECONCILIATION = {"capitalisation": 0.45, "dcf": 0.35, "comparaison_unite": 0.20}33ARRONDI_VALEUR = 10_0003435# Droits sur les mutations immobilières, Ville de Montréal, 2026 (bornes supérieures des tranches, taux)36TRANCHES_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)]3738# Monte-Carlo : lois déclarées (§47.12)39MC_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 CAPEX47}48R_T_PLANCHER = 0.0354950# ----------------------------------------------------------------------------- utilitaires5152def 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.055    for haut, taux in tranches:56        if base <= bas: break57        droit += (min(base, haut) - bas) * taux58        bas = haut59    return droit6061def arrondir(x: float, pas: int = ARRONDI_VALEUR) -> float:62    return round(x / pas) * pas6364# ----------------------------------------------------------------------------- comparaison par unité6566def 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.437570    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)}7980def 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.437583    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équentiel93        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 sujet95        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}113114# ----------------------------------------------------------------------------- financement et scénarios de taux115116def 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_annuel123        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]) - sd125        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)127128def 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"] = col138    return pd.DataFrame(out)139140def 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 / valeur147        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.0590148        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)152153# ----------------------------------------------------------------------------- Monte-Carlo154155def 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_c160    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) + autres164    de0 = de0_hors_gestion + 0.04 * rbe1                      # gestion imputée = 4 % du RBE de première année165    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) * 12174        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 - res0177        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] - capex181    return {"rne": rne, "capex": capex, "flux": flux, "rbp": rbp}182183def 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) ** T190    return {"valeur": va_flux + va_vt, "va_flux": va_flux, "va_terminale": va_vt, "vt_nette": vt_nette}191192def 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 x205206def 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ésiduel216    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_annuel218    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 valeur229    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"] = rangs234    return res235236def 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 stats240    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)259260# ----------------------------------------------------------------------------- réconciliation261262def reconciliation(r: dict, cu: dict) -> dict:263    ind = {"capitalisation": r["valeur_capitalisation"], "dcf": r["dcf"]["valeur"], "comparaison_unite": cu["indication"]}264    w = POIDS_RECONCILIATION265    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"]}272273def 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"]}281282def 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_sans292    return f293294# ----------------------------------------------------------------------------- exercices295296def 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éral305    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"] = rot314        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 fonds317    V = rec["valeur_finale"]; f5 = dict(hyp["financement"]); f5["taux"] = 0.0512318    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, prime322    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 graine325    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] / sd330    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 out333334# ----------------------------------------------------------------------------- dossier complet335336def 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}345346def _d(x): return f"{x:,.0f}".replace(",", " ") + " $"347def _p(x, d=2): return f"{100*x:.{d}f}".replace(".", ",") + " %"348349def 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()})414415if __name__ == "__main__":416    ap = argparse.ArgumentParser(); ap.add_argument("--n", type=int, default=10_000); a = ap.parse_args()417    imprimer(dossier(a.n))418
Files · 8dfc6ca7769188d087f58c82e02a063bfba99ae6
Menu

Theme