#!/usr/bin/env python3 """Chapitre 2 — Droits immobiliers et bases de valeur : calculs de l'exemple intégral sur le cas 3 (12 logements, Montréal, DONNÉES SYNTHÉTIQUES). Valeur du droit du locateur (baux en place) vs valeur de la pleine propriété libre de baux (loyers de marché) ; valeur des baux pour les locataires ; sensibilité à la rotation et au taux d'actualisation ; chiffres des exercices. Toutes les grandeurs du cas 3 proviennent de `traite.cas3` (module partagé) ; les formules financières de `traite.finance`. Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch02_droits.py""" from __future__ import annotations import sys, copy, pathlib import numpy as np, pandas as pd sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import cas3, finance as fin ROTATIONS = [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) TAUX = [0.0625, 0.0725, 0.0825] # ----------------------------------------------------------------------------- écart contractuel / marché def etat_marche(etat: pd.DataFrame) -> pd.DataFrame: """État locatif hypothétique « libre de baux » : chaque logement est reloué au loyer de marché dès l'année 1.""" e = etat.copy() e["loyer_mensuel_contrat"] = e["loyer_mensuel_marche"].astype(float) return e def ecart_par_logement(etat: pd.DataFrame, annee_eval: int = 2026) -> pd.DataFrame: e = etat.copy() e["ecart_mensuel"] = e["loyer_mensuel_marche"] - e["loyer_mensuel_contrat"] e["ecart_annuel"] = 12 * e["ecart_mensuel"] e["ratio_contrat_marche"] = e["loyer_mensuel_contrat"] / e["loyer_mensuel_marche"] e["anciennete"] = annee_eval - e["locataire_depuis"] return e[["logement", "pieces", "locataire_depuis", "anciennete", "loyer_mensuel_contrat", "loyer_mensuel_marche", "ecart_mensuel", "ecart_annuel", "ratio_contrat_marche"]] # ----------------------------------------------------------------------------- formules fermées (équations du chapitre) def valeur_bail_locataire(ecart_annuel: float, n: int, r: float) -> float: """Valeur du bail pour le locataire = Σ_{t=1}^{n} (L_marché − L_contrat)/(1+r)^t, écart constant (éq. annuité).""" return ecart_annuel * fin.facteur_va_annuite(r, n) def valeur_droit_temporaire_et_reversion(revenu_annuel: float, n: int, r: float, valeur_reversion: float) -> dict: """Décomposition d'un droit temporaire (usufruit, bail au locateur) et de la réversion (nue-propriété).""" va_droit = revenu_annuel * fin.facteur_va_annuite(r, n) va_rev = valeur_reversion * fin.facteur_va(r, n) return {"droit_temporaire": va_droit, "reversion": va_rev, "total": va_droit + va_rev} def usufruit_nue_propriete(revenu_annee1: float, g: float, r: float, n: int) -> dict: """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éversion 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).""" flux = [revenu_annee1 * (1 + g) ** (t - 1) for t in range(1, n + 1)] va_usufruit = fin.van([0.0] + flux, r) v_pleine = revenu_annee1 / (r - g) v_n = revenu_annee1 * (1 + g) ** n / (r - g) va_nue = v_n * fin.facteur_va(r, n) return {"V_pleine": v_pleine, "usufruit": va_usufruit, "nue_propriete": va_nue, "somme": va_usufruit + va_nue, "V_n": v_n, "part_usufruit": va_usufruit / v_pleine} def va_ecart_rotation(ecart_net_annee1: float, r: float, g: float, rho: float, T: int | None = None) -> float: """Valeur actuelle de l'écart contractuel/marché sous rotation ρ : écart_t = Δ_1 [(1−ρ)(1+g)]^{t−1}. Perpétuité (T=None) : Δ_1 / (r − g + ρ(1+g)) ; sinon somme finie.""" q = (1 - rho) * (1 + g) / (1 + r) if T is None: if q >= 1: raise ValueError("série divergente : (1−ρ)(1+g) ≥ 1+r") return ecart_net_annee1 / (1 + r) / (1 - q) return ecart_net_annee1 / (1 + r) * (1 - q ** T) / (1 - q) if abs(1 - q) > 1e-14 else ecart_net_annee1 * T / (1 + r) # ----------------------------------------------------------------------------- décomposition par le module partagé def decomposition(d: dict, rotation: float | None = None, taux: float | None = None) -> dict: """V_pleine (libre de baux, loyers de marché) − V_locateur (baux en place) = valeur agrégée des baux pour les locataires. Les deux valeurs sont des DCF de `traite.cas3` avec les mêmes hypothèses (dépenses, CAPEX, taux terminal, coûts de vente).""" hyp = copy.deepcopy(d["hyp"]) if rotation is not None: hyp["rotation_annuelle"] = rotation if taux is not None: hyp["taux_actualisation"] = taux proj_c = cas3.projection_dcf(d["etat"], d["dep"], hyp) proj_m = cas3.projection_dcf(etat_marche(d["etat"]), d["dep"], hyp) v_c = cas3.valeur_dcf(proj_c, hyp); v_m = cas3.valeur_dcf(proj_m, hyp) rne_c = cas3.rne(d["etat"], d["dep"], hyp); rne_m = cas3.rne(etat_marche(d["etat"]), d["dep"], hyp) return {"rotation": hyp["rotation_annuelle"], "taux": hyp["taux_actualisation"], "proj_contrat": proj_c, "proj_marche": proj_m, "V_locateur": v_c["valeur"], "V_pleine": v_m["valeur"], "V_locataires": v_m["valeur"] - v_c["valeur"], "part_locataires": (v_m["valeur"] - v_c["valeur"]) / v_m["valeur"], "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"], "rne1_contrat": rne_c["rne"], "rne1_marche": rne_m["rne"], "rbe_contrat": rne_c["rbe"], "rbe_marche": rne_m["rbe"]} def capitalisation_naive(dec: dict, taux_cap: float) -> dict: """Erreur classique : capitaliser l'écart de RNE de l'année 1 à perpétuité (comme si les baux ne se renouvelaient jamais au marché).""" v_c = fin.capitalisation_directe(dec["rne1_contrat"], taux_cap); v_m = fin.capitalisation_directe(dec["rne1_marche"], taux_cap) return {"Vcap_contrat": v_c, "Vcap_marche": v_m, "ecart_capitalise": v_m - v_c} def sensibilite(d: dict) -> pd.DataFrame: rows = [] for rho in ROTATIONS: for r in TAUX: dec = decomposition(d, rotation=rho, taux=r) rows.append({"rotation": rho, "taux": r, "V_locateur": dec["V_locateur"], "V_pleine": dec["V_pleine"], "V_locataires": dec["V_locataires"], "part": dec["part_locataires"]}) return pd.DataFrame(rows) # ----------------------------------------------------------------------------- exercices def exercices(d: dict, base: dict) -> dict: r = d["hyp"]["taux_actualisation"] e = ecart_par_logement(d["etat"]) l103 = e.loc[e["logement"] == 103].iloc[0] ex3 = {"ecart_mensuel": float(l103["ecart_mensuel"]), "ecart_annuel": float(l103["ecart_annuel"]), "va_5ans": valeur_bail_locataire(float(l103["ecart_annuel"]), 5, r), "va_10ans": valeur_bail_locataire(float(l103["ecart_annuel"]), 10, r), "va_perpetuite": float(l103["ecart_annuel"]) / r} # 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 $) ex4 = usufruit_nue_propriete(30000.0, 0.01, 0.06, 15) # 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) ex5 = {"role": 468000.0, "mois": 26, "indice": 1.0443 ** (26 / 12), "valeur_indexee": 468000.0 * 1.0443 ** (26 / 12)} # ex 6 : part des locataires selon la rotation (taux de base) — repris de la sensibilité # ex 7 : hypothèse extraordinaire : décontamination 150 000 $ + stigmate 5 % sur la valeur DCF du locateur v = base["V_locateur"] 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} return {"ex3": ex3, "ex4": ex4, "ex5": ex5, "ex7": ex7} # ----------------------------------------------------------------------------- principal def main(verbose: bool = True) -> dict: d = cas3.charger() rev = cas3.revenus(d["etat"], d["hyp"]) e = ecart_par_logement(d["etat"]) base = decomposition(d) cap = capitalisation_naive(base, cas3.taux_extraits(d["comps"])["mediane"]) hyp = d["hyp"]; r = hyp["taux_actualisation"]; g = hyp["croissance_loyers_marche"]; rho = hyp["rotation_annuelle"]; v = hyp["taux_inoccupation_pertes"] ecart_net1 = rev["ecart_marche"] * (1 - v) ferme = {"ecart_net_annee1": ecart_net1, "perpetuite_rotation": va_ecart_rotation(ecart_net1, r, g, rho), "perpetuite_sans_rotation": va_ecart_rotation(ecart_net1, r, g, 0.0), "horizon10_rotation": va_ecart_rotation(ecart_net1, r, g, rho, T=10), "annuite_5ans": valeur_bail_locataire(ecart_net1, 5, r), "annuite_10ans": valeur_bail_locataire(ecart_net1, 10, r)} sens = sensibilite(d) ex = exercices(d, base) res = {"d": d, "revenus": rev, "ecarts": e, "base": base, "cap": cap, "ferme": ferme, "sensibilite": sens, "exercices": ex} if verbose: pd.set_option("display.width", 200); pd.set_option("display.max_columns", 20) print("=== Cas 3 — écart contractuel / marché (données synthétiques) ===") print(f"RBP contractuel {rev['rbp_contrat']:,.0f} $ ; RBP au marché {rev['rbp_marche']:,.0f} $ ; écart {rev['ecart_marche']:,.0f} $/an " f"({rev['ecart_marche']/rev['rbp_marche']*100:.1f} % du marché) ; écart net de vacance (3,9 %) {ecart_net1:,.0f} $") print(e.to_string(index=False)) 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}") 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 %) ===") 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"]: print(f"{k:18s} {base[k]:>14,.2f} $") print(f"part des locataires : {base['part_locataires']*100:.2f} %") print("\nProjection contractuelle vs marché (RBP) :") pc, pm = base["proj_contrat"], base["proj_marche"] print(pd.DataFrame({"annee": pc["annee"], "rbp_contrat": pc["rbp"], "rbp_marche_scen": pm["rbp"], "ecart_rbp": pm["rbp"] - pc["rbp"], "rne_contrat": pc["rne"], "rne_marche": pm["rne"], "ecart_rne": pm["rne"] - pc["rne"]}).round(0).to_string(index=False)) print("\n=== Capitalisation naïve de l'écart (taux médian extrait) ===") print({k: round(x, 0) for k, x in cap.items()}) print("\n=== Formules fermées (écart net année 1, g = 3,5 %) ===") print({k: round(x, 0) for k, x in ferme.items()}) print("\n=== Sensibilité rotation × taux (V_locataires et part) ===") 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)) print("\n=== Exercices ===") for k, x in ex.items(): print(k, {kk: (round(vv, 4) if isinstance(vv, float) else vv) for kk, vv in x.items()}) return res if __name__ == "__main__": main()