#!/usr/bin/env python3 """Chapitre 26 — Baux et loyers : calculs de l'exemple intégral (cas 3, 12 logements à Montréal ; cas 2, triplex à Québec ; DONNÉES SYNTHÉTIQUES) et des exercices. Tous les chiffres cités dans manuscript/chapters/ch26.tex et solutions/ch26.tex sortent d'ici. Les revenus du cas 3 et du cas 2 viennent des modules partagés traite.cas3 / traite.cas2 (fonctions `charger`, `revenus`, `rne`, `taux_extraits`, `indicateurs_marche`) afin de coïncider avec les chapitres 2, 27-31 et 46-47 ; les facteurs d'actualisation viennent de traite.finance. La formule fermée de la valeur actuelle de l'écart contractuel/marché sous rotation géométrique est celle du chapitre 2 (éq. « rotation-va ») ; elle est appliquée ici logement par logement. Hypothèses de travail propres au chapitre (déclarées comme telles dans le texte, pas des statistiques) : - profil mensuel de consommation d'électricité d'un 4½ chauffé à l'électricité, 12 000 kWh/an (PROFIL_KWH) ; - loyer « chauffé-éclairé » d'un comparable fictif : 1 450 $/mois (COMPARABLE_CHAUFFE) ; - structure du tarif D d'Hydro-Québec en vigueur le 1er avril 2026 (source : hydrotarifs2026, section 2.5). Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch26_etat_locatif.py """ from __future__ import annotations import sys, pathlib, datetime as dt import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import cas3, cas2, finance as fin ROOT = pathlib.Path(__file__).resolve().parents[3] DATE_EVAL = dt.date(2026, 9, 1) # ----------------------------------------------------------------------------- tarif D (Hydro-Québec, 1er avril 2026) TARIF_D = {"redevance_par_jour": 0.46154, "prix_tranche1": 0.07065, "seuil_kwh_par_jour": 40.0, "prix_tranche2": 0.11142} # Hypothèse de travail (pas une statistique) : consommation mensuelle d'un 4½ de 70 m² chauffé à l'électricité, 12 000 kWh/an. MOIS = ["janv.", "févr.", "mars", "avr.", "mai", "juin", "juil.", "août", "sept.", "oct.", "nov.", "déc."] JOURS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] PROFIL_KWH = [1900, 1700, 1400, 1000, 700, 550, 500, 500, 600, 850, 1150, 1150] COMPARABLE_CHAUFFE = 1450.0 # $/mois, loyer « chauffé-éclairé » d'un comparable fictif (hypothèse pédagogique) GESTION = 0.04 # convention du cas 3 : gestion imputée à 4 % du RBE # ----------------------------------------------------------------------------- état locatif et indicateurs def tableau_etat(etat: pd.DataFrame, date_eval: dt.date = DATE_EVAL) -> pd.DataFrame: """État locatif enrichi : ancienneté (années), écarts mensuel/annuel, ratio contrat/marché, mois avant l'échéance.""" t = etat.copy() t["anciennete"] = date_eval.year - t["locataire_depuis"].astype(int) t["ecart_mensuel"] = t["loyer_mensuel_marche"] - t["loyer_mensuel_contrat"] t["ecart_annuel"] = 12 * t["ecart_mensuel"] t["ecart_pct"] = t["ecart_mensuel"] / t["loyer_mensuel_marche"] t["ratio"] = t["loyer_mensuel_contrat"] / t["loyer_mensuel_marche"] ech = pd.to_datetime(t["echeance_bail"]).dt.date t["mois_echeance"] = [round((e - date_eval).days / 30.4375, 1) for e in ech] t["loyer_m2_contrat"] = t["loyer_mensuel_contrat"] / t["superficie_m2"] t["loyer_m2_marche"] = t["loyer_mensuel_marche"] / t["superficie_m2"] return t def indicateurs(etat: pd.DataFrame, hyp: dict, seuils=(0.05, 0.10, 0.20)) -> dict: """Indicateurs synthétiques de l'état locatif (éq. 26.1 à 26.4 du chapitre).""" t = tableau_etat(etat) r = cas3.revenus(etat, hyp) # RBP contrat / marché / écart (module partagé) lc, lm = t["loyer_mensuel_contrat"].astype(float), t["loyer_mensuel_marche"].astype(float) out = {**r, "n": len(t), "ecart_pondere": float((lm - lc).sum() / lm.sum()), # δ̄ = Σ(Lm − Lc) / Σ Lm "ratio_pondere": float(lc.sum() / lm.sum()), "ratio_moyen_simple": float(t["ratio"].mean()), "ecart_mensuel_moyen": float((lm - lc).mean()), "ecart_mensuel_median": float((lm - lc).median()), "anciennete_moyenne": float(t["anciennete"].mean()), "anciennete_mediane": float(t["anciennete"].median()), "anciennete_max": int(t["anciennete"].max()), "loyer_moyen_contrat": float(lc.mean()), "loyer_moyen_marche": float(lm.mean()), "loyer_m2_contrat_moyen": float(lc.sum() / t["superficie_m2"].sum()), "loyer_m2_marche_moyen": float(lm.sum() / t["superficie_m2"].sum()), "part_ecart_top3": float((lm - lc).sort_values(ascending=False).head(3).sum() / (lm - lc).sum()), "nb_stationnements": int(t["stationnement"].sum()), "tableau": t} for s in seuils: out[f"part_sous_marche_{int(s*100)}"] = float((t["ratio"] < 1 - s).mean()) out[f"nb_sous_marche_{int(s*100)}"] = int((t["ratio"] < 1 - s).sum()) return out # ----------------------------------------------------------------------------- rotation, survie et valeur de l'écart def survie(rho: float, t: int | np.ndarray) -> float | np.ndarray: """Probabilité qu'un locataire en place le soit encore après t années (départs géométriques, taux ρ).""" return (1 - rho) ** t def esperance_duree(rho: float) -> float: """Durée espérée de l'occupation restante, E[n] = Σ t ρ(1−ρ)^(t−1) = 1/ρ (années ; départ compté en fin d'année).""" if not 0 < rho <= 1: raise ValueError("ρ doit être dans ]0, 1]") return 1.0 / rho def va_ecart(delta1: float, r: float, g: float, rho: float) -> float: """VA espérée de l'écart contractuel/marché d'un logement : Δ₁ reçu avec certitude l'année 1, puis l'écart croît à g et survit avec probabilité (1−ρ) chaque année → Δ₁ / (r − g + ρ(1+g)) (formule fermée du chapitre 2, éq. rotation-va). Condition de convergence : (1−ρ)(1+g) < 1+r.""" if (1 - rho) * (1 + g) >= 1 + r: raise ValueError("série divergente : (1−ρ)(1+g) ≥ 1+r") return delta1 / (r - g + rho * (1 + g)) def va_ecart_serie(delta1: float, r: float, g: float, rho: float, T: int = 400) -> float: """Même valeur par sommation explicite (vérification numérique de la formule fermée).""" t = np.arange(1, T + 1) return float(np.sum(delta1 * ((1 - rho) * (1 + g)) ** (t - 1) / (1 + r) ** t)) def va_annuite(delta: float, r: float, n: float) -> float: """Annuité d'un écart constant pendant n années (éq. « annuite » du chapitre 2) ; n peut être non entier.""" return delta * (1 - (1 + r) ** (-n)) / r def va_ecart_logements(etat: pd.DataFrame, hyp: dict, rhos=(0.085, 0.094, 0.15), r: float | None = None, g: float | None = None) -> pd.DataFrame: """VA de l'écart par logement, nette de l'inoccupation (convention du chapitre 2 : Δ₁ = 12 × écart mensuel × (1 − v)).""" t = tableau_etat(etat); r = hyp["taux_actualisation"] if r is None else r; g = hyp["croissance_loyers_marche"] if g is None else g v = hyp["taux_inoccupation_pertes"] out = t[["logement", "pieces", "anciennete", "ecart_mensuel", "ecart_annuel"]].copy() out["delta1_net"] = out["ecart_annuel"] * (1 - v) for rho in rhos: out[f"va_rho_{rho:.3f}"] = [va_ecart(d, r, g, rho) for d in out["delta1_net"]] out["va_perpetuel"] = out["delta1_net"] / (r - g) return out def sensibilite_va(etat: pd.DataFrame, hyp: dict, rhos=(0.05, 0.085, 0.094, 0.15, 0.25), rs=(0.0625, 0.0725, 0.0825), gs=(0.025, 0.035, 0.045)) -> dict: """VA agrégée de l'écart (12 logements) selon ρ × r (g du cas) et ρ × g (r du cas).""" t = tableau_etat(etat); v = hyp["taux_inoccupation_pertes"]; d1 = float(t["ecart_annuel"].sum() * (1 - v)) g0, r0 = hyp["croissance_loyers_marche"], hyp["taux_actualisation"] tab_r = pd.DataFrame({f"r={r:.4f}": [va_ecart(d1, r, g0, rho) for rho in rhos] for r in rs}, index=[f"rho={rho:.3f}" for rho in rhos]) tab_g = pd.DataFrame({f"g={g:.3f}": [va_ecart(d1, r0, g, rho) for rho in rhos] for g in gs}, index=[f"rho={rho:.3f}" for rho in rhos]) return {"delta1_net": d1, "par_taux": tab_r, "par_croissance": tab_g, "sans_rotation": d1 / (r0 - g0)} # ----------------------------------------------------------------------------- loyer économique def loyer_economique(base: dict, R: float, V: float, n_logements: int, m: float = GESTION) -> dict: """Loyer économique (loyer de faisabilité) : RBP requis pour que le RNE après réserve rémunère la valeur V au taux R, compte tenu des dépenses hors gestion DE_hg, de la réserve RR, des autres revenus A, de l'inoccupation v et d'une gestion à m × RBE : RNE* = R·V ; RBE* = (RNE* + DE_hg + RR)/(1 − m) ; RBP* = (RBE* − A)/(1 − v).""" det = base["detail"]; v = base["taux_vacance_pertes"]; A = base["autres_revenus"] gestion = float(det.loc[det["poste"].str.startswith("Gestion"), "normalise"].sum()) de_hg = base["depenses_hors_reserve"] - gestion; RR = base["reserve"] rne_req = R * V; rbe_req = (rne_req + de_hg + RR) / (1 - m); rbp_req = (rbe_req - A) / (1 - v) return {"R": R, "V": V, "rne_requis": rne_req, "rbe_requis": rbe_req, "rbp_requis": rbp_req, "de_hors_gestion": de_hg, "reserve": RR, "loyer_economique_mensuel": rbp_req / (12 * n_logements), "loyer_contrat_mensuel": base["rbp_contrat"] / (12 * n_logements), "loyer_marche_mensuel": base["rbp_marche"] / (12 * n_logements)} # ----------------------------------------------------------------------------- normalisation : électricité (tarif D) def cout_electricite_mensuel(kwh: float, jours: int, tarif: dict = TARIF_D) -> dict: """Coût mensuel hors taxes de vente selon la structure du tarif D : redevance × jours + 1re tranche (≤ 40 kWh/jour) + 2e tranche.""" seuil = tarif["seuil_kwh_par_jour"] * jours t1 = min(kwh, seuil); t2 = max(kwh - seuil, 0.0) red = tarif["redevance_par_jour"] * jours return {"kwh": kwh, "jours": jours, "redevance": red, "tranche1_kwh": t1, "tranche2_kwh": t2, "cout_tranche1": t1 * tarif["prix_tranche1"], "cout_tranche2": t2 * tarif["prix_tranche2"], "total": red + t1 * tarif["prix_tranche1"] + t2 * tarif["prix_tranche2"]} def normalisation_electricite(profil=PROFIL_KWH, jours=JOURS, loyer_chauffe: float = COMPARABLE_CHAUFFE, tarif: dict = TARIF_D) -> dict: rows = [{"mois": m, **cout_electricite_mensuel(k, j, tarif)} for m, k, j in zip(MOIS, profil, jours)] tab = pd.DataFrame(rows) annuel = float(tab["total"].sum()); mensuel = annuel / 12 return {"tableau": tab, "kwh_annuel": float(sum(profil)), "cout_annuel": annuel, "cout_mensuel_moyen": mensuel, "cout_moyen_kwh": annuel / sum(profil), "part_redevance": float(tab["redevance"].sum() / annuel), "kwh_tranche2": float(tab["tranche2_kwh"].sum()), "mois_depassement": int((tab["tranche2_kwh"] > 0).sum()), "loyer_chauffe": loyer_chauffe, "loyer_nu": loyer_chauffe - mensuel, "cout_mensuel_indexe_2027": mensuel * 1.03} # hausse domestique 3,0 % (Régie, D-2026-033) appliquée à toute la facture, approximation # ----------------------------------------------------------------------------- calendrier des avis def _moins_mois(d: dt.date, k: int) -> dt.date: """Date d, moins k mois (même quantième, borné à la fin du mois).""" y, m = d.year, d.month - k while m <= 0: m += 12; y -= 1 while m > 12: m -= 12; y += 1 import calendar return dt.date(y, m, min(d.day, calendar.monthrange(y, m)[1])) def calendrier(echeance: dt.date = dt.date(2027, 6, 30), duree_mois: int = 12) -> dict: """Fenêtres légales (C.c.Q.) pour un bail à durée fixe se terminant à `echeance` : avis de modification 3 à 6 mois avant le terme (art. 1942 ; 1 à 2 mois si bail < 12 mois) ; réponse du locataire dans le mois (1945) ; demande de fixation dans le mois du refus (1947) ; avis de reprise/éviction 6 mois avant (1960 ; 1 mois si bail ≤ 6 mois) ; nouveau locataire : 10 jours (1950) après la conclusion du bail suivant.""" if duree_mois >= 12: a_min, a_max = 3, 6 else: a_min, a_max = 1, 2 debut_fenetre, fin_fenetre = _moins_mois(echeance, a_max), _moins_mois(echeance, a_min) reprise = _moins_mois(echeance, 6 if duree_mois > 6 else 1) reponse_si_fin = _moins_mois(echeance, a_min - 1) # avis reçu au dernier jour permis → réponse dans le mois demande_tal = _moins_mois(echeance, a_min - 2) # refus au dernier jour → demande dans le mois debut_suivant = echeance + dt.timedelta(days=1) return {"echeance": echeance, "avis_modification_debut": debut_fenetre, "avis_modification_fin": fin_fenetre, "reponse_locataire_fin_si_avis_tardif": reponse_si_fin, "demande_fixation_fin_si_refus_tardif": demande_tal, "avis_reprise_eviction_fin": reprise, "debut_bail_suivant": debut_suivant, "nouveau_locataire_fixation_10j": debut_suivant + dt.timedelta(days=10), "nouveau_locataire_sans_avis_2mois": _moins_mois(debut_suivant, -2)} # ----------------------------------------------------------------------------- loyer maximal des cinq ans (art. 1955) def croissance_max_1955(loyer_initial: float, loyer_maximal: float, annees: int = 5) -> float: """Taux annuel composé maximal compatible avec le loyer maximal indiqué au bail (art. 1955 C.c.Q., 2024, c. 2).""" return (loyer_maximal / loyer_initial) ** (1 / annees) - 1 # ----------------------------------------------------------------------------- bail commercial : loyer net équivalent def loyer_net_equivalent(loyer_brut_annuel: float, depenses_recuperables: float, superficie_m2: float) -> dict: net = loyer_brut_annuel - depenses_recuperables return {"loyer_brut": loyer_brut_annuel, "recuperables": depenses_recuperables, "loyer_net": net, "brut_m2": loyer_brut_annuel / superficie_m2, "net_m2": net / superficie_m2} # ----------------------------------------------------------------------------- cas 2 (triplex) def cas2_etat() -> dict: d = cas2.charger(); l = d["loyers"].copy(); r = cas2.revenus(l) l["anciennete"] = DATE_EVAL.year - l["locataire_depuis"].astype(int) l["ecart_mensuel"] = l["loyer_marche_estime"] - l["loyer_mensuel"]; l["ratio"] = l["loyer_mensuel"] / l["loyer_marche_estime"] return {**r, "tableau": l, "ecart_pondere": float(l["ecart_mensuel"].sum() / l["loyer_marche_estime"].sum()), "anciennete_moyenne": float(l["anciennete"].mean()), "nb_sous_marche_10": int((l["ratio"] < 0.9).sum()), "sujet": d["sujet"], "data": d} # ----------------------------------------------------------------------------- tout def calculer() -> dict: d = cas3.charger(); etat, dep, hyp, sujet = d["etat"], d["dep"], d["hyp"], d["sujet"] ind = indicateurs(etat, hyp); base = cas3.rne(etat, dep, hyp); tx = cas3.taux_extraits(d["comps"]) R = tx["mediane"]; V_cap = fin.capitalisation_directe(base["rne"], R) econ_role = loyer_economique(base, R, hyp["valeur_role_2026"], sujet["nb_logements"]) econ_cap = loyer_economique(base, R, V_cap, sujet["nb_logements"]) va = va_ecart_logements(etat, hyp); sens = sensibilite_va(etat, hyp) r, g, rho = hyp["taux_actualisation"], hyp["croissance_loyers_marche"], hyp["rotation_annuelle"] va_agregee_cas = va_ecart(sens["delta1_net"], r, g, rho) # = 102 188 $ (chapitre 2, ρ = 15 %) norm = normalisation_electricite(); cal = calendrier() return {"etat": etat, "hyp": hyp, "sujet": sujet, "ind": ind, "base": base, "R": R, "V_cap": V_cap, "econ_role": econ_role, "econ_cap": econ_cap, "va": va, "sens": sens, "va_agregee_cas": va_agregee_cas, "esperance_duree": {rho_: esperance_duree(rho_) for rho_ in (0.085, 0.094, 0.15, 0.13)}, "norm": norm, "cal": cal, "cas2": cas2_etat()} # ----------------------------------------------------------------------------- exercices def exercices(res: dict | None = None) -> dict: res = res or calculer(); hyp = res["hyp"]; ex = {} # 26.1 — délais : bail 12 mois échéant le 30 juin 2027 et bail de 6 mois échéant le 31 décembre 2026 ex["1"] = {"bail12": calendrier(dt.date(2027, 6, 30), 12), "bail6": calendrier(dt.date(2026, 12, 31), 6)} # 26.2 — indicateurs d'un état locatif fictif de six logements (énoncé) e2 = pd.DataFrame({"logement": ["1", "2", "3", "4", "5", "6"], "loyer_mensuel_contrat": [850, 925, 1180, 1240, 1410, 1495], "loyer_mensuel_marche": [1100, 1100, 1300, 1300, 1550, 1550], "locataire_depuis": [2012, 2016, 2021, 2025, 2019, 2024], "superficie_m2": [52, 52, 70, 70, 92, 92], "echeance_bail": ["2027-06-30"] * 6, "chauffage_inclus": [0] * 6, "stationnement": [1, 0, 1, 0, 1, 0]}) hyp2 = {**hyp, "autres_revenus_annuels": 0.0, "taux_inoccupation_pertes": 0.03} ex["2"] = indicateurs(e2, hyp2) # 26.3 — normalisation : comparable chauffé-éclairé 1 450 $, profil de consommation donné ; variante 1 500 $ et 14 000 kWh (profil × 14/12) ex["3"] = {"base": normalisation_electricite(), "variante": normalisation_electricite(profil=[k * 14000 / 12000 for k in PROFIL_KWH], loyer_chauffe=1500.0)} # 26.4 — VA de l'écart du logement 103 (319 $/mois) : ρ = 9,4 % vs 15 % ; annuité sur E[n] = 1/ρ années ; perpétuel d103 = 12 * 319 * (1 - hyp["taux_inoccupation_pertes"]); r, g = hyp["taux_actualisation"], hyp["croissance_loyers_marche"] ex["4"] = {"delta1_net": d103, "va_094": va_ecart(d103, r, g, 0.094), "va_150": va_ecart(d103, r, g, 0.15), "E_n_094": esperance_duree(0.094), "E_n_150": esperance_duree(0.15), "annuite_094": va_annuite(d103, r, esperance_duree(0.094)), "annuite_150": va_annuite(d103, r, esperance_duree(0.15)), "perpetuel": d103 / (r - g), "serie_094": va_ecart_serie(d103, r, g, 0.094)} # 26.5 — loyer économique du triplex (cas 2) sur la valeur au rôle 495 000 $ et sur la valeur par capitalisation c2 = res["cas2"]["data"]; base2 = cas2.rne(c2["loyers"], c2["depenses"]); comp2 = cas2.comparables_plex(c2["ventes"], c2["sujet"]); ind2 = cas2.indicateurs_marche(comp2) R2 = ind2["taux_median"]; V2_role = c2["sujet"]["valeur_role_2025"]; V2_cap = fin.capitalisation_directe(base2["rne"], R2) det2 = base2["detail"]; gestion2 = float(det2.loc[det2["type"] == "imputée", "normalise"].sum()); de_hg2 = base2["depenses_hors_reserve"] - gestion2 def econ2(V): rne_req = R2 * V; rbe_req = (rne_req + de_hg2 + base2["reserve"]) / (1 - cas2.GESTION_IMPUTEE); rbp_req = rbe_req / (1 - cas2.TAUX_VACANCE_PERTES) return {"V": V, "rne_requis": rne_req, "rbe_requis": rbe_req, "rbp_requis": rbp_req, "loyer_mensuel_moyen": rbp_req / 36} ex["5"] = {"R": R2, "base": base2, "de_hors_gestion": de_hg2, "role": econ2(V2_role), "cap": econ2(V2_cap), "V_cap": V2_cap, "rbp_contrat": base2["rbp_contrat"], "rbp_marche": base2["rbp_marche"]} # 26.6 — immeuble neuf (art. 1955) : loyer 1 600 $, loyer maximal 1 850 $ indiqué au bail ; trois trajectoires sur cinq ans : # (a) indexation au taux de base 3,1 % (sans clause : fixation possible), (b) saut au loyer maximal dès la 2e année (borne haute permise # par la clause), (c) croissance régulière au taux g_max ; VA des loyers des années 1 à 5 au taux r du cas (7,25 %). L0, Lmax, gtal = 1600.0, 1850.0, 0.031 gmax = croissance_max_1955(L0, Lmax) traj_tal = [L0 * (1 + gtal) ** t for t in range(0, 5)] traj_saut = [L0] + [Lmax] * 4 traj_reg = [L0 * (1 + gmax) ** t for t in range(0, 5)] va = lambda traj: sum(12 * traj[t - 1] / (1 + r) ** t for t in range(1, 6)) ex["6"] = {"g_max": gmax, "traj_tal": traj_tal, "traj_saut": traj_saut, "traj_reg": traj_reg, "va_tal": va(traj_tal), "va_saut": va(traj_saut), "va_reg": va(traj_reg), "loyer_an5_tal": traj_tal[4], "depasse_tal": traj_tal[4] > Lmax, "annee_depassement": next((t + 1 for t, L in enumerate(traj_tal) if L > Lmax), None), "loyer_an6_tal": L0 * (1 + gtal) ** 5} # 26.7 — bail commercial au rez-de-chaussée : brut 36 000 $/an, récupérables 9 500 $, 110 m², 5 ans fixes, option 5 ans ; VA à 7,25 % lne = loyer_net_equivalent(36000.0, 9500.0, 110.0) va_com = fin.van([0.0] + [lne["loyer_net"]] * 5, r) loyer_res = 1292.0 * 12 * (1 - hyp["taux_inoccupation_pertes"]) # logement de marché moyen du cas 3 (1 292 $) indexé à g va_res = sum(loyer_res * (1 + g) ** (t - 1) / (1 + r) ** t for t in range(1, 6)) ex["7"] = {**lne, "va_net_5ans": va_com, "va_residentiel_5ans": va_res, "ratio_va": va_com / va_res, "loyer_res_an1_net": loyer_res} return ex def _f(x): return f"{x:,.0f}".replace(",", " ") if __name__ == "__main__": res = calculer(); ind = res["ind"]; t = ind["tableau"] print("=== Cas 3 — état locatif (données synthétiques)") print(t[["logement", "pieces", "superficie_m2", "loyer_mensuel_contrat", "loyer_mensuel_marche", "anciennete", "ecart_mensuel", "ecart_pct", "ratio", "echeance_bail", "mois_echeance"]].to_string(index=False)) for k in ["rbp_contrat", "rbp_marche", "ecart_marche", "ecart_pondere", "ratio_pondere", "ratio_moyen_simple", "ecart_mensuel_moyen", "ecart_mensuel_median", "anciennete_moyenne", "anciennete_mediane", "anciennete_max", "part_sous_marche_5", "part_sous_marche_10", "part_sous_marche_20", "nb_sous_marche_5", "nb_sous_marche_10", "nb_sous_marche_20", "loyer_moyen_contrat", "loyer_moyen_marche", "loyer_m2_contrat_moyen", "loyer_m2_marche_moyen", "part_ecart_top3"]: print(f"{k:28s} {ind[k]:.4f}" if isinstance(ind[k], float) else f"{k:28s} {ind[k]}") print("\n=== Loyer économique (R médian = %.4f %%)" % (100 * res["R"])) for lab, e in (("sur valeur au rôle 2 200 000 $", res["econ_role"]), ("sur valeur par capitalisation", res["econ_cap"])): print(lab, {k: round(v, 2) for k, v in e.items()}) print("\n=== VA de l'écart par logement (r = %.4f, g = %.3f ; nette de l'inoccupation)" % (res["hyp"]["taux_actualisation"], res["hyp"]["croissance_loyers_marche"])) print(res["va"].round(0).to_string(index=False)); print("totaux :", res["va"].drop(columns=["logement", "pieces"]).sum().round(0).to_dict()) print("VA agrégée ρ = 15 % (chapitre 2) :", round(res["va_agregee_cas"], 0), "; sans rotation :", round(res["sens"]["sans_rotation"], 0)) print("Durées espérées 1/ρ :", {k: round(v, 2) for k, v in res["esperance_duree"].items()}) print("\n=== Sensibilité (VA agrégée)"); print(res["sens"]["par_taux"].round(0)); print(res["sens"]["par_croissance"].round(0)) n = res["norm"]; print("\n=== Normalisation électricité (tarif D, hypothèse 12 000 kWh/an)") print(n["tableau"].round(2).to_string(index=False)) print({k: round(v, 2) for k, v in n.items() if k != "tableau"}) print("\n=== Calendrier (bail 2026-07-01 → 2027-06-30)"); print(res["cal"]) c2 = res["cas2"]; print("\n=== Cas 2 — triplex"); print(c2["tableau"].to_string(index=False)) print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in c2.items() if k not in ("tableau", "sujet", "data")}) print("\n=== Exercices"); ex = exercices(res) for k, v in ex.items(): print(f"--- {k}") if isinstance(v, dict): for kk, vv in v.items(): if isinstance(vv, (int, float)): print(f" {kk}: {vv:.4f}") elif isinstance(vv, dict): print(f" {kk}: {{" + ", ".join(f"{a}: {round(b, 4) if isinstance(b, float) else b}" for a, b in vv.items() if not isinstance(b, (pd.DataFrame, dict))) + "}") elif isinstance(vv, list): print(f" {kk}: {[round(x, 2) for x in vv]}")