"""Cas 3 — immeuble de 12 logements à Montréal (données synthétiques) : calculs PARTAGÉS par les chapitres 26-31, 33, 34, 40 et 47. Toute valeur citée dans ces chapitres provient d'ici (ou d'un script qui appelle ces fonctions), pour que les chiffres se répondent.""" from __future__ import annotations import json, pathlib import numpy as np, pandas as pd from . import finance as fin ROOT = pathlib.Path(__file__).resolve().parents[3] D = ROOT / "data/synthetic" def charger() -> dict: return {"etat": pd.read_csv(D / "cas3_montreal_etat_locatif.csv"), "dep": pd.read_csv(D / "cas3_montreal_depenses.csv"), "comps": pd.read_csv(D / "cas3_montreal_comparables.csv", parse_dates=["date_vente"]), "hyp": json.loads((D / "cas3_montreal_hypotheses_dcf.json").read_text()), "sujet": json.loads((D / "cas3_montreal_sujet.json").read_text())} def revenus(etat: pd.DataFrame, hyp: dict) -> dict: """RBP contractuel, RBP au marché, autres revenus, RBE (convention : vacance/pertes appliquée au RBP seulement).""" rbp_c = float(etat["loyer_mensuel_contrat"].sum() * 12); rbp_m = float(etat["loyer_mensuel_marche"].sum() * 12) autres = float(hyp["autres_revenus_annuels"]); v = float(hyp["taux_inoccupation_pertes"]) rbe = fin.revenu_brut_effectif(rbp_c, v, autres) return {"rbp_contrat": rbp_c, "rbp_marche": rbp_m, "ecart_marche": rbp_m - rbp_c, "autres_revenus": autres, "taux_vacance_pertes": v, "vacance_pertes": rbp_c * v, "rbe": rbe} def depenses_normalisees(dep: pd.DataFrame, hyp: dict, rbe: float, annee_base: int = 2025, inflation: float | None = None) -> pd.DataFrame: """Normalisation : dernière année observée indexée d'un an (dépenses +g), taxes recalculées sur la valeur au rôle × taux, gestion = 4 % du RBE (imputée, même si propriétaire gère), réserve = montant historique 2025 conservé tel quel (≈ 675 $/logement), non indexée (convention du livre).""" g = hyp["croissance_depenses"] if inflation is None else inflation d = dep[dep["annee"] == annee_base].set_index("poste")["montant"].astype(float) norm = {} for poste, m in d.items(): if poste.startswith("Taxes municipales"): norm[poste] = hyp["valeur_role_2026"] * hyp["taux_taxe_fonciere"] elif poste.startswith("Taxe scolaire"): norm[poste] = hyp["valeur_role_2026"] * hyp["taux_taxe_scolaire"] elif poste.startswith("Réserve"): norm[poste] = m # fixée par convention, non indexée else: norm[poste] = m * (1 + g) norm["Gestion (4 % du RBE)"] = 0.04 * rbe out = pd.DataFrame({"poste": list(norm), "historique_2025": [d.get(k, np.nan) for k in norm], "normalise": list(norm.values())}) out["type"] = ["réserve" if k.startswith("Réserve") else ("fixe" if any(k.startswith(x) for x in ["Taxes", "Taxe", "Assurances"]) else "variable") for k in norm] return out def rne(etat, dep, hyp) -> dict: r = revenus(etat, hyp); dn = depenses_normalisees(dep, hyp, r["rbe"]) de_hors_reserve = float(dn.loc[dn["type"] != "réserve", "normalise"].sum()); reserve = float(dn.loc[dn["type"] == "réserve", "normalise"].sum()) noi = fin.rne(r["rbe"], de_hors_reserve, reserve) return {**r, "depenses_hors_reserve": de_hors_reserve, "reserve": reserve, "depenses_totales": de_hors_reserve + reserve, "rne": noi, "ratio_depenses": (de_hors_reserve + reserve) / r["rbe"], "rne_par_logement": noi / len(etat), "detail": dn} def taux_extraits(comps: pd.DataFrame, n_min: int = 6, n_max: int = 30, fenetre_mois: int = 24, date_eval="2026-09-01") -> dict: """Extraction du taux global : médiane, moyenne, moyenne pondérée par le RNE, et régression simple sur l'état et la taille.""" c = comps.copy() c = c[(c["nb_logements"].between(n_min, n_max)) & (c["date_vente"] >= pd.Timestamp(date_eval) - pd.DateOffset(months=fenetre_mois))] w = c["rne"] / c["rne"].sum() X = np.column_stack([np.ones(len(c)), c["etat_1a5"] - 3, c["nb_logements"] - 12]) beta, *_ = np.linalg.lstsq(X, c["taux_global"], rcond=None) return {"n": len(c), "mediane": float(c["taux_global"].median()), "moyenne": float(c["taux_global"].mean()), "ponderee_rne": float((w * c["taux_global"]).sum()), "q1": float(c["taux_global"].quantile(0.25)), "q3": float(c["taux_global"].quantile(0.75)), "min": float(c["taux_global"].min()), "max": float(c["taux_global"].max()), "regression": {"constante": float(beta[0]), "etat": float(beta[1]), "taille": float(beta[2])}, "comparables": c} def projection_dcf(etat, dep, hyp, horizon: int | None = None) -> pd.DataFrame: """Projection annuelle : loyers contractuels indexés au taux TAL ; chaque année une fraction `rotation_annuelle` des logements est relouée au loyer de marché (qui croît à `croissance_loyers_marche`) ; dépenses +g ; CAPEX aux années prévues.""" T = horizon or hyp["horizon_ans"] base = rne(etat, dep, hyp) loyers = etat["loyer_mensuel_contrat"].astype(float).values.copy(); marche = etat["loyer_mensuel_marche"].astype(float).values.copy() rot = hyp["rotation_annuelle"]; g_c = hyp["croissance_loyers_contrat"]; g_m = hyp["croissance_loyers_marche"]; g_d = hyp["croissance_depenses"] v = hyp["taux_inoccupation_pertes"]; autres = hyp["autres_revenus_annuels"] de0 = base["depenses_hors_reserve"]; res0 = base["reserve"] rows = []; annee0 = int(hyp["date_evaluation"][:4]) rng = np.random.default_rng(3) # ordre déterministe de relocation : les plus sous le marché d'abord for t in range(1, T + 2): if t > 1: loyers *= (1 + g_c); marche *= (1 + g_m) k = int(round(rot * len(loyers))) idx = np.argsort(loyers / marche)[:k] # logements les plus sous-évalués se libèrent en premier (hypothèse) loyers[idx] = marche[idx] rbp = loyers.sum() * 12; rbe = fin.revenu_brut_effectif(rbp, v, autres * (1 + g_d) ** (t - 1)) de = de0 * (1 + g_d) ** (t - 1); res = res0 noi = rbe - de - res capex = float(hyp["capex"].get(str(annee0 + t - 1), 0.0)) if t <= T else 0.0 rows.append({"annee": t, "exercice": annee0 + t - 1, "rbp": rbp, "rbp_marche": marche.sum() * 12, "rbe": rbe, "depenses": de, "reserve": res, "rne": noi, "capex": capex, "flux_avant_dette": noi - capex}) return pd.DataFrame(rows) def valeur_dcf(proj: pd.DataFrame, hyp: dict) -> dict: T = hyp["horizon_ans"]; r = hyp["taux_actualisation"]; Rt = hyp["taux_terminal"]; cv = hyp["couts_vente"] flux = proj.loc[proj["annee"] <= T, "flux_avant_dette"].tolist(); noi_T1 = float(proj.loc[proj["annee"] == T + 1, "rne"].iloc[0]) res = fin.dcf(flux, r, noi_suivant=noi_T1, taux_terminal=Rt, couts_vente=cv) return {"valeur": res.valeur, "va_flux": res.va_flux, "va_terminale": res.va_terminale, "vt_brute": res.valeur_terminale_brute, "vt_nette": res.valeur_terminale_nette, "rne_T1": noi_T1, "part_terminale": res.va_terminale / res.valeur, "flux": flux} def financement(valeur: float, noi_1: float, hyp: dict) -> dict: f = hyp["financement"] pret_rpv = fin.pret_max_par_rpv(valeur, f["rpv"]); pret_rcd = fin.pret_max_par_rcd(noi_1, f["rcd_min"], f["taux"], f["amortissement_mois"]) pret = min(pret_rpv, pret_rcd); h = fin.Hypotheque(pret, f["taux"], f["amortissement_mois"]) return {"pret_rpv": pret_rpv, "pret_rcd": pret_rcd, "pret": pret, "contrainte": "RPV" if pret_rpv <= pret_rcd else "RCD", "hypotheque": h, "service_dette": h.service_dette_annuel, "rcd": fin.rcd(noi_1, h.service_dette_annuel), "rpv": pret / valeur, "mise_de_fonds": valeur - pret, "constante": h.constante_hypothecaire, "taux_mensuel": h.taux_periodique} def tout() -> dict: d = charger(); base = rne(d["etat"], d["dep"], d["hyp"]); tx = taux_extraits(d["comps"]); proj = projection_dcf(d["etat"], d["dep"], d["hyp"]) V_cap = fin.capitalisation_directe(float(proj.loc[proj["annee"] == 1, "rne"].iloc[0]), tx["mediane"]) V_dcf = valeur_dcf(proj, d["hyp"]); fi = financement(V_dcf["valeur"], base["rne"], d["hyp"]) return {**d, "base": base, "taux": tx, "projection": proj, "valeur_capitalisation": V_cap, "dcf": V_dcf, "financement": fi}