"""Chapitre 32 — Duplex à quintuplex : calculs de l'exemple intégral (cas 2, triplex de Limoilou, données SYNTHÉTIQUES). Tout chiffre cité dans manuscript/chapters/ch32.tex et solutions/ch32.tex provient de ce script, qui s'appuie sur les fonctions partagées de traite.cas2 (revenus, RNE, ventes de plex), traite.finance (hypothèque canadienne, capitalisation) et traite.comparables (distance d'attributs). Aucune formule financière n'est réécrite ici. Exécution : python3 analysis/python/cases/ch32_plex.py (depuis la racine du dépôt) """ from __future__ import annotations import sys, pathlib import numpy as np, pandas as pd ROOT = pathlib.Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT / "analysis/python")) from traite import cas2, finance as fin, comparables as cmp # noqa: E402 # ----------------------------------------------------------------------------- paramètres déclarés du chapitre TAUX_ASSURE = 0.0412 # BdC, fonds avancés, prêts assurés à taux fixe de 5 ans et plus, août 2026 (SRC-E-012) TAUX_NON_ASSURE = 0.0458 # idem, prêts non assurés AMORT_MOIS = 300 # 25 ans (30 ans possibles pour un premier acheteur ou une construction neuve, Finances Canada 2024) PLANCHER_MQR, MARGE_MQR = 0.0525, 0.02 # BSIF, taux admissible minimal (prêts non assurés) : max(contrat + 2 %, 5,25 %) SEUIL_5PCT, PLAFOND_ASSURABLE = 500_000.0, 1_500_000.0 # Finances Canada, en vigueur le 15 décembre 2024 TAUX_SANS_RISQUE = 0.0358 # obligation du GdC 5 ans, 2026-10-29 (coût d'opportunité de la mise de fonds) R_INVESTISSEUR_REF = 0.05 # taux global « d'investisseur » illustratif : borne basse CBRE Québec, basse hauteur B, T3 2026 (5,00-6,00 %) POIDS_INDICATIONS = {"par_logement": 0.40, "mrb": 0.35, "capitalisation": 0.25} # pondération raisonnée (sect. 32.4) ECHELLES = {"superficie_habitable_m2": 30.0, "age": 15.0, "etat_1a5": 1.0, "stationnement": 1.0, "loyer_relatif": 0.05, "mois": 6.0} POIDS_DIST = {"superficie_habitable_m2": 1.5, "age": 1.0, "etat_1a5": 1.0, "stationnement": 0.5, "loyer_relatif": 1.5, "mois": 1.0} K_COMPARABLES = 5 # ----------------------------------------------------------------------------- règles de financement (vérifiées, notes-C) def mise_de_fonds_minimale_assuree(prix: float) -> float: """Mise de fonds minimale d'un prêt assuré (Finances Canada, 15 déc. 2024) : 5 % jusqu'à 500 000 $, 10 % de la tranche entre 500 000 $ et 1,5 M$ ; au-delà de 1,5 M$ le prêt n'est pas assurable (retourne NaN).""" if prix > PLAFOND_ASSURABLE: return float("nan") return 0.05 * min(prix, SEUIL_5PCT) + 0.10 * max(prix - SEUIL_5PCT, 0.0) def financement_occupant(prix: float, mise_de_fonds: float, taux: float, amort_mois: int = AMORT_MOIS) -> dict: """Prêt, paiement mensuel (composition semestrielle), service annuel, constante, paiement au taux admissible minimal.""" pret = prix - mise_de_fonds h = fin.Hypotheque(pret, taux, amort_mois) tam = fin.taux_admissible_minimal(taux, PLANCHER_MQR, MARGE_MQR) h_tam = fin.Hypotheque(pret, tam, amort_mois) return {"prix": prix, "mise_de_fonds": mise_de_fonds, "rpv": fin.rpv(pret, prix), "pret": pret, "taux": taux, "amort_mois": amort_mois, "paiement_mensuel": h.paiement, "service_dette": h.service_dette_annuel, "constante": h.constante_hypothecaire, "taux_admissible": tam, "paiement_mensuel_tam": h_tam.paiement, "solde_60": h.solde(60), "capital_rembourse_5ans": pret - h.solde(60)} def cout_occupation(fi: dict, base: dict, loyers: pd.DataFrame, logement_occupe: str, taux_opportunite: float = TAUX_SANS_RISQUE) -> dict: """Coût net annuel et mensuel d'occupation pour un acheteur-occupant qui reprend `logement_occupe` : service de la dette + dépenses d'exploitation hors gestion imputée (il gère lui-même) + coût d'opportunité de la mise de fonds − loyers des autres logements (après inoccupation et pertes), comparé au loyer de marché du logement occupé.""" d = base["detail"] dep_hors_gestion = float(d.loc[d["type"] != "imputée", "normalise"].sum()) autres = loyers[loyers["logement"] != logement_occupe] loyers_percus = float(autres["loyer_mensuel"].sum() * 12) * (1 - cas2.TAUX_VACANCE_PERTES) occ = loyers[loyers["logement"] == logement_occupe].iloc[0] opp = fi["mise_de_fonds"] * taux_opportunite brut = fi["service_dette"] + dep_hors_gestion + opp net = brut - loyers_percus net_hors_capital = net - fi["capital_rembourse_5ans"] / 5.0 return {"logement": logement_occupe, "loyer_marche_logement": float(occ["loyer_marche_estime"]), "loyer_contrat_logement": float(occ["loyer_mensuel"]), "depenses_hors_gestion": dep_hors_gestion, "cout_opportunite": opp, "loyers_percus_nets": loyers_percus, "sortie_brute": brut, "cout_net_annuel": net, "cout_net_mensuel": net / 12, "cout_net_mensuel_hors_capital": net_hors_capital / 12, "capital_annuel_moyen_5ans": fi["capital_rembourse_5ans"] / 5.0} # ----------------------------------------------------------------------------- indicateurs de marché et décomposition du taux def indicateurs_par_groupe(c: pd.DataFrame, cle: str) -> pd.DataFrame: g = c.assign(prix_m2=c["prix_vente"] / c["superficie_habitable_m2"], loyer_relatif=c["loyer_mensuel_moyen_contrat"] / c["loyer_mensuel_moyen_marche"]) out = g.groupby(cle).agg(n=("prix_vente", "size"), prix_median=("prix_vente", "median"), prix_par_logement=("prix_par_logement", "median"), prix_m2=("prix_m2", "median"), mrb=("mrb", "median"), taux=("taux_global", "median"), loyer_relatif=("loyer_relatif", "median"), ratio_depenses=("ratio_depenses", "median")) return out def decomposition_taux(c: pd.DataFrame, inocc: float = 0.024) -> pd.DataFrame: """Pour chaque vente : loyer relatif ρ = contrat/marché, taux extrait au contrat R_c = RNE_c/P, taux « au marché » R_m = RBP_m (1 − v)(1 − d)/P avec le ratio de dépenses d de la vente, MRB au marché, et identité R_c = ρ R_m (dépenses proportionnelles).""" g = c.copy() g["loyer_relatif"] = g["loyer_mensuel_moyen_contrat"] / g["loyer_mensuel_moyen_marche"] g["rbp_marche"] = g["loyer_mensuel_moyen_marche"] * g["nb_logements"] * 12 g["rne_marche"] = g["rbp_marche"] * (1 - inocc) * (1 - g["ratio_depenses"]) g["taux_marche"] = g["rne_marche"] / g["prix_vente"] g["mrb_marche"] = g["prix_vente"] / g["rbp_marche"] g["taux_contrat_reconstitue"] = g["loyer_relatif"] * g["taux_marche"] return g def regression_taux_loyer_relatif(g: pd.DataFrame) -> dict: """OLS : taux_global = a + b (ρ − 1) + ε, puis variante avec l'état (centré sur 3) et le nombre de logements (centré sur 3).""" y = g["taux_global"].to_numpy() x1 = np.column_stack([np.ones(len(g)), g["loyer_relatif"] - 1.0]) b1, *_ = np.linalg.lstsq(x1, y, rcond=None) res1 = y - x1 @ b1 x2 = np.column_stack([np.ones(len(g)), g["loyer_relatif"] - 1.0, g["etat_1a5"] - 3, g["nb_logements"] - 3]) b2, *_ = np.linalg.lstsq(x2, y, rcond=None) res2 = y - x2 @ b2 sst = ((y - y.mean()) ** 2).sum() # MRB (log) sur ρ ym = np.log(g["mrb"].to_numpy()) bm, *_ = np.linalg.lstsq(x1, ym, rcond=None) resm = ym - x1 @ bm return {"a": b1[0], "b_rho": b1[1], "r2": 1 - (res1 ** 2).sum() / sst, "sigma": float(np.sqrt((res1 ** 2).sum() / (len(y) - 2))), "a2": b2[0], "b2_rho": b2[1], "b2_etat": b2[2], "b2_nb": b2[3], "r2_2": 1 - (res2 ** 2).sum() / sst, "mrb_a": bm[0], "mrb_b_rho": bm[1], "mrb_r2": 1 - (resm ** 2).sum() / ((ym - ym.mean()) ** 2).sum(), "rho_median": float(g["loyer_relatif"].median()), "n": len(g)} def prime_occupant(prix: float, rne_contrat: float, rne_marche: float, r_inv: float = R_INVESTISSEUR_REF) -> dict: """Décomposition pédagogique P = RNE_c/R* + (RNE_m − RNE_c)/R* + π : valeur d'investisseur aux loyers en place, valeur du rattrapage (si les loyers étaient au marché), et résidu π attribué à la demande d'occupants (prime d'occupation).""" v_inv = fin.capitalisation_directe(rne_contrat, r_inv); v_m = fin.capitalisation_directe(rne_marche, r_inv) return {"r_inv": r_inv, "v_investisseur_contrat": v_inv, "v_rattrapage": v_m - v_inv, "v_investisseur_marche": v_m, "prime": prix - v_m, "prime_pct": (prix - v_m) / prix, "taux_implicite": rne_contrat / prix} # ----------------------------------------------------------------------------- sélection des comparables triplex de Limoilou def selection_comparables(c: pd.DataFrame, sujet: dict, base: dict, k: int = K_COMPARABLES, quartier: str = "Limoilou") -> pd.DataFrame: cand = c[(c["nb_logements"] == sujet["nb_logements"]) & (c["quartier"] == quartier)].copy() cand["loyer_relatif"] = cand["loyer_mensuel_moyen_contrat"] / cand["loyer_mensuel_moyen_marche"] cand["prix_m2"] = cand["prix_vente"] / cand["superficie_habitable_m2"] s = {"superficie_habitable_m2": sujet["superficie_habitable_m2"], "age": sujet["age"], "etat_1a5": sujet["etat_1a5"], "stationnement": sujet["stationnement"], "loyer_relatif": base["rbp_contrat"] / base["rbp_marche"], "mois": 0.0} cand["distance"] = [cmp.distance_attributs(s, {kk: float(row[kk]) for kk in ECHELLES}, ECHELLES, POIDS_DIST) for _, row in cand.iterrows()] cand = cand.sort_values("distance") cand["retenu"] = False; cand.iloc[:k, cand.columns.get_loc("retenu")] = True return cand G_ROLE_ANNUEL = (1 + 0.235) ** (1 / 3) - 1 # croissance annuelle composée implicite du rôle 2025-2027 de Québec (+23,5 % en 3 ans), hypothèse du jeu def indications(base: dict, sujet: dict, ind: dict, comp5: pd.DataFrame, reg: dict, c: pd.DataFrame | None = None) -> dict: rbp, rne, n = base["rbp_contrat"], base["rne"], sujet["nb_logements"] rho_s = base["rbp_contrat"] / base["rbp_marche"] taux_ajuste = reg["a"] + reg["b_rho"] * (rho_s - 1.0) mrb_ajuste = float(np.exp(reg["mrb_a"] + reg["mrb_b_rho"] * (rho_s - 1.0))) out = { "rho_sujet": rho_s, # échantillon des 127 ventes (traite.cas2.tout) "v_mrb_127": ind["mrb_median_triplex"] * rbp, "v_cap_127": fin.capitalisation_directe(rne, ind["taux_median"]), "v_log_127": ind["prix_par_logement_triplex"] * n, "v_m2_127": ind["prix_m2_median"] * sujet["superficie_habitable_m2"], # ajustements par régression sur le loyer relatif "taux_ajuste": taux_ajuste, "v_cap_ajuste": fin.capitalisation_directe(rne, taux_ajuste), "mrb_ajuste": mrb_ajuste, "v_mrb_ajuste": mrb_ajuste * rbp, # cinq comparables triplex de Limoilou "mrb_comp": float(comp5["mrb"].median()), "taux_comp": float(comp5["taux_global"].median()), "log_comp": float(comp5["prix_par_logement"].median()), "m2_comp": float(comp5["prix_m2"].median()), } # explication de l'écart capitalisation vs comparaison : (i) structure de dépenses du sujet vs ratio médian des ventes ; # (ii) ancienneté médiane des 127 ventes et indexation au rythme du rôle (+23,5 % en 3 ans, hypothèse du jeu synthétique) out["v_mrb_comp"] = out["mrb_comp"] * rbp; out["v_cap_comp"] = fin.capitalisation_directe(rne, out["taux_comp"]) out["v_log_comp"] = out["log_comp"] * n; out["v_m2_comp"] = out["m2_comp"] * sujet["superficie_habitable_m2"] # indications retenues pour la pondération : comparables (par logement), MRB des comparables, capitalisation ajustée au loyer relatif if c is not None: d_med = float(c["ratio_depenses"].median()); mois_med = float(c["mois"].median()) rbe = base["rbe"]; rne_ratio_ventes = rbe * (1 - d_med) out.update({"ratio_dep_median_ventes": d_med, "rne_au_ratio_des_ventes": rne_ratio_ventes, "v_cap_ratio_ventes": fin.capitalisation_directe(rne_ratio_ventes, taux_ajuste), "mois_median_127": mois_med, "g_role_annuel": G_ROLE_ANNUEL, "v_log_127_indexe": out["v_log_127"] * (1 + G_ROLE_ANNUEL) ** (mois_med / 12), "mois_median_comp": float(comp5["mois"].median())}) retenues = {"par_logement": out["v_log_comp"], "mrb": out["v_mrb_comp"], "capitalisation": out["v_cap_ajuste"]} out["retenues"] = retenues out["valeur_ponderee"] = sum(POIDS_INDICATIONS[kk] * v for kk, v in retenues.items()) out["valeur_arrondie"] = round(out["valeur_ponderee"] / 5000) * 5000 out["etendue_pct"] = (max(retenues.values()) - min(retenues.values())) / out["valeur_ponderee"] out["taux_implicite_final"] = rne / out["valeur_ponderee"]; out["mrb_implicite_final"] = out["valeur_ponderee"] / rbp out["prix_log_final"] = out["valeur_ponderee"] / n out["ecart_role"] = out["valeur_ponderee"] / sujet["valeur_role_2025"] - 1 return out # ----------------------------------------------------------------------------- sensibilité def sensibilite(base: dict, sujet: dict, ind: dict, reg: dict, indic: dict, loyers: pd.DataFrame) -> dict: rbp_c, rbp_m, rne_c = base["rbp_contrat"], base["rbp_marche"], base["rne"] d = base["detail"]; dep_fixes = float(d.loc[~d["type"].isin(["imputée"]), "normalise"].sum()) rows = [] for rho in (0.85, 0.90, 0.95, 1.00): rbp = rbp_m * rho; rbe = fin.revenu_brut_effectif(rbp, cas2.TAUX_VACANCE_PERTES) rne = fin.rne(rbe, dep_fixes - base["reserve"] + cas2.GESTION_IMPUTEE * rbe, base["reserve"]) taux = reg["a"] + reg["b_rho"] * (rho - 1.0) rows.append({"rho": rho, "rbp": rbp, "rne": rne, "taux_ajuste": taux, "v_cap_ajuste": fin.capitalisation_directe(rne, taux), "v_cap_taux_median": fin.capitalisation_directe(rne, ind["taux_median"]), "v_mrb": indic["mrb_comp"] * rbp}) loyers_tab = pd.DataFrame(rows) taux_tab = pd.DataFrame([{"taux": t, "valeur": fin.capitalisation_directe(rne_c, t)} for t in np.arange(0.0250, 0.0401, 0.0025)]) prix = indic["valeur_arrondie"] fin_rows = [] for libelle, mf, taux, amort in (("assuré, mise de fonds minimale, 25 ans", mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE, 300), ("assuré, mise de fonds minimale, 30 ans (premier acheteur)", mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE, 360), ("assuré, mise de fonds de 10 %", 0.10 * prix, TAUX_ASSURE, 300), ("non assuré, mise de fonds de 20 %", 0.20 * prix, TAUX_NON_ASSURE, 300), ("assuré, mise de fonds minimale, taux +100 pb", mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE + 0.01, 300), ("assuré, mise de fonds minimale, taux +200 pb", mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE + 0.02, 300)): f = financement_occupant(prix, mf, taux, amort); co = cout_occupation(f, base, loyers, "RC") fin_rows.append({"scenario": libelle, **{kk: f[kk] for kk in ("mise_de_fonds", "rpv", "pret", "taux", "amort_mois", "paiement_mensuel", "service_dette", "paiement_mensuel_tam")}, "cout_net_mensuel": co["cout_net_mensuel"], "cout_net_mensuel_hors_capital": co["cout_net_mensuel_hors_capital"]}) return {"loyers": loyers_tab, "taux": taux_tab, "financement": pd.DataFrame(fin_rows)} # ----------------------------------------------------------------------------- exercices (solutions/ch32.tex) def exercices() -> dict: ex = {} # 32.1 — duplex : MRB, RNE, taux, identité R = (1 − v)(1 − d)/MRB P, rbp, v, d = 585_000.0, 31_200.0, 0.03, 0.38 rbe = fin.revenu_brut_effectif(rbp, v); rne = rbe * (1 - d) ex["1"] = {"prix": P, "rbp": rbp, "rbe": rbe, "rne": rne, "mrb": fin.multiplicateur_revenu_brut(P, rbp), "taux": fin.taux_global_extrait(rne, P), "identite": (1 - v) * (1 - d) / fin.multiplicateur_revenu_brut(P, rbp), "prix_par_logement": P / 2} # 32.2 — mise de fonds minimale assurée pour trois prix ; prêt ; au-delà du plafond ex["2"] = {str(p): {"mise": mise_de_fonds_minimale_assuree(p), "pret": p - mise_de_fonds_minimale_assuree(p) if p <= PLAFOND_ASSURABLE else float("nan"), "mise_pct": mise_de_fonds_minimale_assuree(p) / p} for p in (450_000.0, 843_000.0, 1_200_000.0, 1_600_000.0)} ex["2"]["mf20_1600000"] = 0.20 * 1_600_000 # 32.3 — décomposition du taux : R_c = R_m − (1 − ρ)(1 − v)/MRB_m (dépenses fixes) et R_c = ρ R_m (dépenses proportionnelles) P3, lm, lc, n3, v3, dfix = 717_000.0, 1_430.0, 1_215.0, 3, 0.024, 17_500.0 rbp_m, rbp_c = lm * n3 * 12, lc * n3 * 12; rho = rbp_c / rbp_m rne_m, rne_c = rbp_m * (1 - v3) - dfix, rbp_c * (1 - v3) - dfix ex["3"] = {"rho": rho, "rbp_m": rbp_m, "rbp_c": rbp_c, "rne_m": rne_m, "rne_c": rne_c, "R_m": rne_m / P3, "R_c": rne_c / P3, "mrb_m": P3 / rbp_m, "terme": (1 - rho) * (1 - v3) / (P3 / rbp_m), "R_c_prop": rho * rne_m / P3, "ecart_pb": (rne_m - rne_c) / P3 * 1e4} # 32.4 — coût d'occupation si l'acheteur reprend le 2e plutôt que le RC (cas 2, prix retenu, financement assuré minimal) r = cas2.tout(); base, loyers, sujet = r["base"], r["loyers"], r["sujet"] c = r["comparables"]; comp = selection_comparables(c, sujet, base); comp5 = comp[comp["retenu"]] g = decomposition_taux(c); reg = regression_taux_loyer_relatif(g); indic = indications(base, sujet, r["indicateurs"], comp5, reg, c) prix = indic["valeur_arrondie"] f = financement_occupant(prix, mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE) ex["4"] = {"prix": prix, "financement": f, "RC": cout_occupation(f, base, loyers, "RC"), "2e": cout_occupation(f, base, loyers, "2e")} # 32.5 — régression taux ~ ρ sur les 127 ventes et application au sujet ex["5"] = {"reg": reg, "taux_ajuste": indic["taux_ajuste"], "v_cap_ajuste": indic["v_cap_ajuste"], "v_cap_127": indic["v_cap_127"], "taux_a_rho_1": reg["a"], "rho_sujet": indic["rho_sujet"], "taux_median": r["indicateurs"]["taux_median"]} # 32.6 — pondérations alternatives ret = indic["retenues"] alt = {"retenue (40/35/25)": POIDS_INDICATIONS, "égale (1/3 chacune)": {k: 1 / 3 for k in ret}, "revenu dominant (20/20/60)": {"par_logement": 0.2, "mrb": 0.2, "capitalisation": 0.6}, "comparaison seule (50/50/0)": {"par_logement": 0.5, "mrb": 0.5, "capitalisation": 0.0}} ex["6"] = {"indications": ret, "ponderations": {lib: sum(w[k] * ret[k] for k in ret) for lib, w in alt.items()}} # 32.7 — valeur actualisée de l'écart de loyer du RC selon la date de reprise (r = 6 %, croissance TAL 3,1 %, marché 3,5 %) lc0, lm0, g_tal, g_m, rr = 1_047.0, 1_430.0, 0.031, 0.035, 0.06 def va_ecart(annees_avant_reprise: int, horizon: int = 10) -> float: va = 0.0 for t in range(1, horizon + 1): if t <= annees_avant_reprise: ecart = (lm0 * (1 + g_m) ** (t - 1) - lc0 * (1 + g_tal) ** (t - 1)) * 12 va += ecart / (1 + rr) ** t return va ex["7"] = {"va_1an": va_ecart(1), "va_3ans": va_ecart(3), "va_10ans": va_ecart(10), "ecart_annee1": (lm0 - lc0) * 12} return ex # ----------------------------------------------------------------------------- tout def calculer() -> dict: r = cas2.tout() base, sujet, loyers, ind, c = r["base"], r["sujet"], r["loyers"], r["indicateurs"], r["comparables"] par_taille = indicateurs_par_groupe(c, "nb_logements"); par_quartier = indicateurs_par_groupe(c, "quartier") g = decomposition_taux(c); reg = regression_taux_loyer_relatif(g) comp = selection_comparables(c, sujet, base); comp5 = comp[comp["retenu"]] indic = indications(base, sujet, ind, comp5, reg, c) rbe_m = base["rbe_marche"]; d = base["detail"] dep_m = float(d.loc[~d["type"].isin(["imputée", "réserve"]), "normalise"].sum()) + cas2.GESTION_IMPUTEE * rbe_m rne_marche = fin.rne(rbe_m, dep_m, base["reserve"]) prime = prime_occupant(indic["valeur_ponderee"], base["rne"], rne_marche) prix = indic["valeur_arrondie"] fi = financement_occupant(prix, mise_de_fonds_minimale_assuree(prix), TAUX_ASSURE) occ = cout_occupation(fi, base, loyers, "RC") sens = sensibilite(base, sujet, ind, reg, indic, loyers) return {**r, "par_taille": par_taille, "par_quartier": par_quartier, "decomposition": g, "regression": reg, "candidats": comp, "comparables5": comp5, "indications": indic, "rne_marche": rne_marche, "prime": prime, "financement": fi, "occupation": occ, "sensibilite": sens} def _d(x): return f"{x:,.0f} $".replace(",", " ") def _p(x, k=2): return f"{100 * x:.{k}f} %" if __name__ == "__main__": R = calculer(); b = R["base"]; ind = R["indicateurs"]; I = R["indications"]; reg = R["regression"] print("== Cas 2 — revenus et RNE (traite.cas2)") print(f"RBP contrat {_d(b['rbp_contrat'])} ; RBP marché {_d(b['rbp_marche'])} ; écart {_d(b['ecart_marche'])} ({_p(b['ecart_marche']/b['rbp_marche'],1)} du marché) ; ρ sujet = {I['rho_sujet']:.4f}") print(f"RBE {b['rbe']:.2f} ; dépenses hors réserve {b['depenses_hors_reserve']:.2f} ; réserve {b['reserve']:.0f} ; RNE {b['rne']:.4f} ; ratio {_p(b['ratio_depenses'],1)} ; RNE aux loyers de marché {R['rne_marche']:.2f}") print(R["base"]["detail"][["poste", "montant_annuel", "normalise"]].to_string(index=False)) print("\n== 127 ventes de 2-3 logements (4 quartiers, 24 mois)") print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in ind.items()}) print(R["par_taille"].round(4).to_string()); print(R["par_quartier"].round(4).to_string()) g = R["decomposition"] print(f"ρ médian {g['loyer_relatif'].median():.3f} ; taux contrat médian {_p(g['taux_global'].median())} ; taux au marché médian {_p(g['taux_marche'].median())} ; MRB marché médian {g['mrb_marche'].median():.2f}") print(f"Régression taux = a + b(ρ−1) : a = {_p(reg['a'],3)}, b = {reg['b_rho']:.4f} (R² {reg['r2']:.3f}, σ {_p(reg['sigma'],3)}) ; avec état et taille : a = {_p(reg['a2'],3)}, b_ρ = {reg['b2_rho']:.4f}, b_état = {reg['b2_etat']:.5f}, b_n = {reg['b2_nb']:.5f}, R² {reg['r2_2']:.3f}") print(f"ln MRB = {reg['mrb_a']:.4f} + {reg['mrb_b_rho']:.4f}(ρ−1), R² {reg['mrb_r2']:.3f} → MRB à ρ=1 : {np.exp(reg['mrb_a']):.2f}") print("\n== Comparables triplex Limoilou (distance d'attributs)") cols = ["id_vente", "date_vente", "superficie_habitable_m2", "annee_construction", "etat_1a5", "stationnement", "loyer_mensuel_moyen_contrat", "loyer_mensuel_moyen_marche", "loyer_relatif", "revenu_brut_potentiel", "prix_vente", "mrb", "taux_global", "prix_par_logement", "prix_m2", "mois", "distance", "retenu"] print(f"candidats : {len(R['candidats'])}"); print(R["candidats"][cols].assign(date_vente=lambda x: x["date_vente"].dt.date).head(8).round(3).to_string(index=False)) print("\n== Indications") for k, v in I.items(): if k != "retenues": print(f"{k:22s} {v:,.4f}" if isinstance(v, float) else f"{k:22s} {v}") print("retenues", {k: round(v) for k, v in I["retenues"].items()}, "poids", POIDS_INDICATIONS) print("\n== Prime d'occupation (R* = 5 %)"); print({k: round(v, 4) for k, v in R["prime"].items()}) print("\n== Financement de l'acheteur-occupant (prix arrondi)"); print({k: round(v, 4) for k, v in R["financement"].items()}) print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in R["occupation"].items()}) print("\n== Sensibilité"); S = R["sensibilite"] print(S["loyers"].round(4).to_string(index=False)); print(S["taux"].round(2).to_string(index=False)); print(S["financement"].round(2).to_string(index=False)) print("\n== Exercices"); E = exercices() for k, v in E.items(): print(k, v if k not in ("4", "5") else {kk: (vv if not isinstance(vv, dict) else {a: (round(bb, 4) if isinstance(bb, float) else bb) for a, bb in vv.items()}) for kk, vv in v.items()})