spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 2 — Droits immobiliers et bases de valeur : calculs de l'exemple intégral sur le cas 3 (12 logements, Montréal,3DONNÉES SYNTHÉTIQUES). Valeur du droit du locateur (baux en place) vs valeur de la pleine propriété libre de baux (loyers de4marché) ; valeur des baux pour les locataires ; sensibilité à la rotation et au taux d'actualisation ; chiffres des exercices.5Toutes les grandeurs du cas 3 proviennent de `traite.cas3` (module partagé) ; les formules financières de `traite.finance`.6Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch02_droits.py"""7from __future__ import annotations8import sys, copy, pathlib9import numpy as np, pandas as pd10sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))11from traite import cas3, finance as fin1213ROTATIONS = [0.0, 1 / 12, 2 / 12, 3 / 12, 6 / 12] # 0, 1, 2, 3, 6 logements reloués par an (le module arrondit ρ × 12 à l'entier)14TAUX = [0.0625, 0.0725, 0.0825]151617# ----------------------------------------------------------------------------- écart contractuel / marché1819def etat_marche(etat: pd.DataFrame) -> pd.DataFrame:20 """État locatif hypothétique « libre de baux » : chaque logement est reloué au loyer de marché dès l'année 1."""21 e = etat.copy()22 e["loyer_mensuel_contrat"] = e["loyer_mensuel_marche"].astype(float)23 return e242526def ecart_par_logement(etat: pd.DataFrame, annee_eval: int = 2026) -> pd.DataFrame:27 e = etat.copy()28 e["ecart_mensuel"] = e["loyer_mensuel_marche"] - e["loyer_mensuel_contrat"]29 e["ecart_annuel"] = 12 * e["ecart_mensuel"]30 e["ratio_contrat_marche"] = e["loyer_mensuel_contrat"] / e["loyer_mensuel_marche"]31 e["anciennete"] = annee_eval - e["locataire_depuis"]32 return e[["logement", "pieces", "locataire_depuis", "anciennete", "loyer_mensuel_contrat", "loyer_mensuel_marche",33 "ecart_mensuel", "ecart_annuel", "ratio_contrat_marche"]]343536# ----------------------------------------------------------------------------- formules fermées (équations du chapitre)3738def valeur_bail_locataire(ecart_annuel: float, n: int, r: float) -> float:39 """Valeur du bail pour le locataire = Σ_{t=1}^{n} (L_marché − L_contrat)/(1+r)^t, écart constant (éq. annuité)."""40 return ecart_annuel * fin.facteur_va_annuite(r, n)414243def valeur_droit_temporaire_et_reversion(revenu_annuel: float, n: int, r: float, valeur_reversion: float) -> dict:44 """Décomposition d'un droit temporaire (usufruit, bail au locateur) et de la réversion (nue-propriété)."""45 va_droit = revenu_annuel * fin.facteur_va_annuite(r, n)46 va_rev = valeur_reversion * fin.facteur_va(r, n)47 return {"droit_temporaire": va_droit, "reversion": va_rev, "total": va_droit + va_rev}484950def usufruit_nue_propriete(revenu_annee1: float, g: float, r: float, n: int) -> dict:51 """Usufruit de n ans sur un bien dont le revenu net croît au taux g : VA de l'annuité croissante ; nue-propriété = réversion52 V_n = R_{n+1}/(r−g) actualisée ; la somme redonne la pleine propriété V_0 = R_1/(r−g) (identité de Gordon)."""53 flux = [revenu_annee1 * (1 + g) ** (t - 1) for t in range(1, n + 1)]54 va_usufruit = fin.van([0.0] + flux, r)55 v_pleine = revenu_annee1 / (r - g)56 v_n = revenu_annee1 * (1 + g) ** n / (r - g)57 va_nue = v_n * fin.facteur_va(r, n)58 return {"V_pleine": v_pleine, "usufruit": va_usufruit, "nue_propriete": va_nue, "somme": va_usufruit + va_nue, "V_n": v_n,59 "part_usufruit": va_usufruit / v_pleine}606162def va_ecart_rotation(ecart_net_annee1: float, r: float, g: float, rho: float, T: int | None = None) -> float:63 """Valeur actuelle de l'écart contractuel/marché sous rotation ρ : écart_t = Δ_1 [(1−ρ)(1+g)]^{t−1}.64 Perpétuité (T=None) : Δ_1 / (r − g + ρ(1+g)) ; sinon somme finie."""65 q = (1 - rho) * (1 + g) / (1 + r)66 if T is None:67 if q >= 1:68 raise ValueError("série divergente : (1−ρ)(1+g) ≥ 1+r")69 return ecart_net_annee1 / (1 + r) / (1 - q)70 return ecart_net_annee1 / (1 + r) * (1 - q ** T) / (1 - q) if abs(1 - q) > 1e-14 else ecart_net_annee1 * T / (1 + r)717273# ----------------------------------------------------------------------------- décomposition par le module partagé7475def decomposition(d: dict, rotation: float | None = None, taux: float | None = None) -> dict:76 """V_pleine (libre de baux, loyers de marché) − V_locateur (baux en place) = valeur agrégée des baux pour les locataires.77 Les deux valeurs sont des DCF de `traite.cas3` avec les mêmes hypothèses (dépenses, CAPEX, taux terminal, coûts de vente)."""78 hyp = copy.deepcopy(d["hyp"])79 if rotation is not None:80 hyp["rotation_annuelle"] = rotation81 if taux is not None:82 hyp["taux_actualisation"] = taux83 proj_c = cas3.projection_dcf(d["etat"], d["dep"], hyp)84 proj_m = cas3.projection_dcf(etat_marche(d["etat"]), d["dep"], hyp)85 v_c = cas3.valeur_dcf(proj_c, hyp); v_m = cas3.valeur_dcf(proj_m, hyp)86 rne_c = cas3.rne(d["etat"], d["dep"], hyp); rne_m = cas3.rne(etat_marche(d["etat"]), d["dep"], hyp)87 return {"rotation": hyp["rotation_annuelle"], "taux": hyp["taux_actualisation"], "proj_contrat": proj_c, "proj_marche": proj_m,88 "V_locateur": v_c["valeur"], "V_pleine": v_m["valeur"], "V_locataires": v_m["valeur"] - v_c["valeur"],89 "part_locataires": (v_m["valeur"] - v_c["valeur"]) / v_m["valeur"],90 "va_flux_contrat": v_c["va_flux"], "va_flux_marche": v_m["va_flux"], "va_vt_contrat": v_c["va_terminale"], "va_vt_marche": v_m["va_terminale"],91 "rne1_contrat": rne_c["rne"], "rne1_marche": rne_m["rne"], "rbe_contrat": rne_c["rbe"], "rbe_marche": rne_m["rbe"]}929394def capitalisation_naive(dec: dict, taux_cap: float) -> dict:95 """Erreur classique : capitaliser l'écart de RNE de l'année 1 à perpétuité (comme si les baux ne se renouvelaient jamais au marché)."""96 v_c = fin.capitalisation_directe(dec["rne1_contrat"], taux_cap); v_m = fin.capitalisation_directe(dec["rne1_marche"], taux_cap)97 return {"Vcap_contrat": v_c, "Vcap_marche": v_m, "ecart_capitalise": v_m - v_c}9899100def sensibilite(d: dict) -> pd.DataFrame:101 rows = []102 for rho in ROTATIONS:103 for r in TAUX:104 dec = decomposition(d, rotation=rho, taux=r)105 rows.append({"rotation": rho, "taux": r, "V_locateur": dec["V_locateur"], "V_pleine": dec["V_pleine"],106 "V_locataires": dec["V_locataires"], "part": dec["part_locataires"]})107 return pd.DataFrame(rows)108109110# ----------------------------------------------------------------------------- exercices111112def exercices(d: dict, base: dict) -> dict:113 r = d["hyp"]["taux_actualisation"]114 e = ecart_par_logement(d["etat"])115 l103 = e.loc[e["logement"] == 103].iloc[0]116 ex3 = {"ecart_mensuel": float(l103["ecart_mensuel"]), "ecart_annuel": float(l103["ecart_annuel"]),117 "va_5ans": valeur_bail_locataire(float(l103["ecart_annuel"]), 5, r), "va_10ans": valeur_bail_locataire(float(l103["ecart_annuel"]), 10, r),118 "va_perpetuite": float(l103["ecart_annuel"]) / r}119 # ex 4 : usufruit 15 ans sur un immeuble de 600 000 $ ; revenu net 30 000 $ croissant à 1 %/an ; r = 6 % (V = R_1/(r−g) = 600 000 $)120 ex4 = usufruit_nue_propriete(30000.0, 0.01, 0.06, 15)121 # ex 5 : valeur au rôle fictive du cas 1, 468 000 $ au 2024-07-01 ; indice +4,43 %/an ; date d'évaluation 2026-09-01 (26 mois)122 ex5 = {"role": 468000.0, "mois": 26, "indice": 1.0443 ** (26 / 12), "valeur_indexee": 468000.0 * 1.0443 ** (26 / 12)}123 # ex 6 : part des locataires selon la rotation (taux de base) — repris de la sensibilité124 # ex 7 : hypothèse extraordinaire : décontamination 150 000 $ + stigmate 5 % sur la valeur DCF du locateur125 v = base["V_locateur"]126 ex7 = {"V_hyp": v, "couts": 150000.0, "stigmate": 0.05, "V_sans_hyp": (v - 150000.0) * 0.95, "incidence": v - (v - 150000.0) * 0.95}127 return {"ex3": ex3, "ex4": ex4, "ex5": ex5, "ex7": ex7}128129130# ----------------------------------------------------------------------------- principal131132def main(verbose: bool = True) -> dict:133 d = cas3.charger()134 rev = cas3.revenus(d["etat"], d["hyp"])135 e = ecart_par_logement(d["etat"])136 base = decomposition(d)137 cap = capitalisation_naive(base, cas3.taux_extraits(d["comps"])["mediane"])138 hyp = d["hyp"]; r = hyp["taux_actualisation"]; g = hyp["croissance_loyers_marche"]; rho = hyp["rotation_annuelle"]; v = hyp["taux_inoccupation_pertes"]139 ecart_net1 = rev["ecart_marche"] * (1 - v)140 ferme = {"ecart_net_annee1": ecart_net1,141 "perpetuite_rotation": va_ecart_rotation(ecart_net1, r, g, rho),142 "perpetuite_sans_rotation": va_ecart_rotation(ecart_net1, r, g, 0.0),143 "horizon10_rotation": va_ecart_rotation(ecart_net1, r, g, rho, T=10),144 "annuite_5ans": valeur_bail_locataire(ecart_net1, 5, r), "annuite_10ans": valeur_bail_locataire(ecart_net1, 10, r)}145 sens = sensibilite(d)146 ex = exercices(d, base)147 res = {"d": d, "revenus": rev, "ecarts": e, "base": base, "cap": cap, "ferme": ferme, "sensibilite": sens, "exercices": ex}148 if verbose:149 pd.set_option("display.width", 200); pd.set_option("display.max_columns", 20)150 print("=== Cas 3 — écart contractuel / marché (données synthétiques) ===")151 print(f"RBP contractuel {rev['rbp_contrat']:,.0f} $ ; RBP au marché {rev['rbp_marche']:,.0f} $ ; écart {rev['ecart_marche']:,.0f} $/an "152 f"({rev['ecart_marche']/rev['rbp_marche']*100:.1f} % du marché) ; écart net de vacance (3,9 %) {ecart_net1:,.0f} $")153 print(e.to_string(index=False))154 print(f"ratio contrat/marché : moyenne {e['ratio_contrat_marche'].mean():.3f}, min {e['ratio_contrat_marche'].min():.3f} (log. {int(e.loc[e['ratio_contrat_marche'].idxmin(),'logement'])}), max {e['ratio_contrat_marche'].max():.3f}")155 print("\n=== Décomposition DCF (hypothèses du cas 3 : r = 7,25 %, rotation 15 %, g_c 3,1 %, g_m 3,5 %, R_T 5 %, c_v 2,5 %) ===")156 for k in ["rne1_contrat", "rne1_marche", "rbe_contrat", "rbe_marche", "va_flux_contrat", "va_flux_marche", "va_vt_contrat", "va_vt_marche", "V_locateur", "V_pleine", "V_locataires"]:157 print(f"{k:18s} {base[k]:>14,.2f} $")158 print(f"part des locataires : {base['part_locataires']*100:.2f} %")159 print("\nProjection contractuelle vs marché (RBP) :")160 pc, pm = base["proj_contrat"], base["proj_marche"]161 print(pd.DataFrame({"annee": pc["annee"], "rbp_contrat": pc["rbp"], "rbp_marche_scen": pm["rbp"], "ecart_rbp": pm["rbp"] - pc["rbp"],162 "rne_contrat": pc["rne"], "rne_marche": pm["rne"], "ecart_rne": pm["rne"] - pc["rne"]}).round(0).to_string(index=False))163 print("\n=== Capitalisation naïve de l'écart (taux médian extrait) ===")164 print({k: round(x, 0) for k, x in cap.items()})165 print("\n=== Formules fermées (écart net année 1, g = 3,5 %) ===")166 print({k: round(x, 0) for k, x in ferme.items()})167 print("\n=== Sensibilité rotation × taux (V_locataires et part) ===")168 print(sens.assign(logements_an=(sens["rotation"] * 12).round(0), V_locataires=sens["V_locataires"].round(0), part=(sens["part"] * 100).round(2), V_locateur=sens["V_locateur"].round(0), V_pleine=sens["V_pleine"].round(0)).to_string(index=False))169 print("\n=== Exercices ===")170 for k, x in ex.items():171 print(k, {kk: (round(vv, 4) if isinstance(vv, float) else vv) for kk, vv in x.items()})172 return res173174175if __name__ == "__main__":176 main()177