"""Mathématiques financières de l'évaluation immobilière (conventions déclarées). Conventions : - Les taux annuels sont des proportions (0.05 = 5 %). - Hypothèques canadiennes à taux fixe : intérêt composé SEMESTRIELLEMENT, non à l'avance (Loi sur l'intérêt, L.R.C. 1985, c. I-15, art. 6 : le taux doit être exprimé en taux annuel calculé semestriellement ou annuellement). Le taux périodique mensuel équivalent est i_m = (1 + i/2)^(1/6) - 1. - Flux en FIN de période sauf indication contraire ; t = 1 … T ; V_0 à t = 0. """ from __future__ import annotations from dataclasses import dataclass from typing import Iterable, Sequence import math # ----------------------------------------------------------------------------- taux def taux_periodique(i_nominal: float, m_composition: int, m_paiements: int) -> float: """Taux périodique équivalent à un taux nominal i composé m_composition fois/an, pour m_paiements paiements/an. i_p = (1 + i/m_c)^(m_c/m_p) - 1.""" return (1.0 + i_nominal / m_composition) ** (m_composition / m_paiements) - 1.0 def taux_mensuel_canadien(i_nominal: float) -> float: """Taux mensuel d'une hypothèque canadienne (composition semestrielle).""" return taux_periodique(i_nominal, 2, 12) def taux_effectif_annuel(i_nominal: float, m: int) -> float: return (1.0 + i_nominal / m) ** m - 1.0 def taux_nominal_depuis_effectif(i_eff: float, m: int) -> float: return m * ((1.0 + i_eff) ** (1.0 / m) - 1.0) # ----------------------------------------------------------------------------- facteurs def facteur_va(r: float, n: int) -> float: """Facteur de valeur actuelle d'un dollar : 1/(1+r)^n.""" return (1.0 + r) ** (-n) def facteur_va_annuite(r: float, n: int) -> float: """Valeur actuelle d'une annuité de 1 $ en fin de période pendant n périodes.""" if abs(r) < 1e-14: return float(n) return (1.0 - (1.0 + r) ** (-n)) / r def facteur_recouvrement(r: float, n: int) -> float: """Facteur de recouvrement du capital (annuité équivalente à 1 $ présent) = 1/facteur_va_annuite.""" return 1.0 / facteur_va_annuite(r, n) def facteur_fonds_amortissement(r: float, n: int) -> float: """Sinking fund factor : versement périodique qui accumule 1 $ après n périodes.""" if abs(r) < 1e-14: return 1.0 / n return r / ((1.0 + r) ** n - 1.0) def facteur_vf(r: float, n: int) -> float: return (1.0 + r) ** n # ----------------------------------------------------------------------------- VAN / TRI def van(flux: Sequence[float], r: float, t0: int = 0) -> float: """Valeur actuelle nette : flux[k] survient à t = t0 + k (par défaut flux[0] à t = 0).""" return sum(cf / (1.0 + r) ** (t0 + k) for k, cf in enumerate(flux)) def tri(flux: Sequence[float], borne_inf: float = -0.99, borne_sup: float = 10.0, tol: float = 1e-10, max_iter: int = 500) -> float: """Taux de rendement interne par bissection (robuste) puis raffinement de Newton. Exige un changement de signe de la VAN sur [borne_inf, borne_sup].""" f = lambda r: van(flux, r) lo, hi = borne_inf, borne_sup flo, fhi = f(lo), f(hi) if flo * fhi > 0: raise ValueError("Pas de changement de signe de la VAN : TRI non défini sur l'intervalle") for _ in range(max_iter): mid = 0.5 * (lo + hi) fm = f(mid) if abs(fm) < tol or (hi - lo) < tol: break if flo * fm < 0: hi, fhi = mid, fm else: lo, flo = mid, fm r = 0.5 * (lo + hi) for _ in range(20): # Newton d = sum(-k * cf / (1.0 + r) ** (k + 1) for k, cf in enumerate(flux)) if abs(d) < 1e-14: break r_new = r - f(r) / d if not (borne_inf < r_new < borne_sup): break if abs(r_new - r) < 1e-14: r = r_new break r = r_new return r def tri_modifie(flux: Sequence[float], taux_financement: float, taux_reinvestissement: float) -> float: """TRI modifié (MIRR) : flux négatifs actualisés au taux de financement, positifs capitalisés au taux de réinvestissement.""" n = len(flux) - 1 va_neg = sum(cf / (1 + taux_financement) ** k for k, cf in enumerate(flux) if cf < 0) vf_pos = sum(cf * (1 + taux_reinvestissement) ** (n - k) for k, cf in enumerate(flux) if cf > 0) if va_neg == 0 or vf_pos == 0: raise ValueError("MIRR exige des flux négatifs et positifs") return (vf_pos / -va_neg) ** (1.0 / n) - 1.0 def delai_recuperation(flux: Sequence[float]) -> float | None: """Délai de récupération simple (interpolation linéaire), None si jamais récupéré.""" cum = 0.0 for k, cf in enumerate(flux): prev = cum cum += cf if k > 0 and prev < 0 <= cum: return (k - 1) + (-prev / cf if cf else 0.0) return None # ----------------------------------------------------------------------------- hypothèques @dataclass(frozen=True) class Hypotheque: capital: float taux_nominal: float # annuel, composé semestriellement (Canada) amortissement_mois: int composition: int = 2 # 2 = semestriel (Canada) ; 12 = mensuel (É.-U.) paiements_par_an: int = 12 @property def taux_periodique(self) -> float: return taux_periodique(self.taux_nominal, self.composition, self.paiements_par_an) @property def paiement(self) -> float: """Paiement périodique constant (fin de période).""" return self.capital * facteur_recouvrement(self.taux_periodique, self.amortissement_mois) @property def service_dette_annuel(self) -> float: return self.paiement * self.paiements_par_an @property def constante_hypothecaire(self) -> float: """f = service de la dette annuel / capital (mortgage constant).""" return self.service_dette_annuel / self.capital def solde(self, apres_paiements: int) -> float: """Solde du capital après k paiements (formule prospective).""" i, n = self.taux_periodique, self.amortissement_mois k = apres_paiements if i == 0: return self.capital * (1 - k / n) return self.capital * ((1 + i) ** n - (1 + i) ** k) / ((1 + i) ** n - 1) def tableau(self, nb_paiements: int | None = None) -> list[dict]: """Tableau d'amortissement : période, paiement, intérêt, capital, solde.""" n = nb_paiements or self.amortissement_mois i, pmt = self.taux_periodique, self.paiement solde = self.capital rows = [] for k in range(1, n + 1): interet = solde * i cap = pmt - interet solde = solde - cap if k == self.amortissement_mois: cap += solde; solde = 0.0 # absorbe l'arrondi final rows.append({"periode": k, "paiement": pmt, "interet": interet, "capital": cap, "solde": max(solde, 0.0)}) return rows def interets_annee(self, annee: int) -> float: rows = self.tableau() return sum(r["interet"] for r in rows[(annee - 1) * 12: annee * 12]) def capital_annee(self, annee: int) -> float: rows = self.tableau() return sum(r["capital"] for r in rows[(annee - 1) * 12: annee * 12]) def pret_max_par_rcd(noi: float, rcd_min: float, taux_nominal: float, amortissement_mois: int, composition: int = 2) -> float: """Prêt maximal tel que RCD = RNE / service de la dette >= rcd_min.""" sd_max = noi / rcd_min f = Hypotheque(1.0, taux_nominal, amortissement_mois, composition).constante_hypothecaire return sd_max / f def pret_max_par_rpv(valeur: float, rpv_max: float) -> float: return valeur * rpv_max def rcd(noi: float, service_dette: float) -> float: """Ratio de couverture de la dette (DSCR).""" return noi / service_dette def rpv(pret: float, valeur: float) -> float: """Ratio prêt-valeur (LTV).""" return pret / valeur def taux_admissible_minimal(taux_contrat: float, plancher: float = 0.0525, marge: float = 0.02) -> float: """Taux admissible minimal (test de résistance BSIF B-20 / assuré) : max(taux + marge, plancher). Paramètres à vérifier (sources/notes-C.md).""" return max(taux_contrat + marge, plancher) # ----------------------------------------------------------------------------- financement intérimaire (construction) def interets_intermediaires(pret: float, taux: float, duree_ans: float, mode: str = "continu", capitaliser: bool = False, mise_de_fonds: float = 0.0, cout_total: float | None = None) -> dict: """Intérêts intérimaires d'un prêt de construction tiré de façon UNIFORME sur la durée du chantier. Convention : le prêt `pret` (L) est déboursé linéairement entre t0 et T (années) ; le taux `taux` (i) est un taux annuel simple appliqué au solde. Si `mise_de_fonds` (Eq) > 0 et `cout_total` (C) est fourni, la mise de fonds finance les premiers déboursés et le prêt ne commence qu'à t0 = T × Eq / C (sinon t0 = 0). mode = 'continu' : I = L · i · (T − t0) / 2 (solde moyen = L/2) mode = 'mensuel' : n = 12 (T − t0) mois, tirage de L/n en début de mois, intérêt (i/12) sur le solde ; sans capitalisation I = L · i · (T − t0)/2 · (n + 1)/n ; avec `capitaliser=True`, les intérêts mensuels sont ajoutés au solde (prêt à intérêts capitalisés). Retourne un dict : interets, duree_effective_ans, t0, solde_moyen, n_mois, solde_final.""" if pret < 0 or taux < 0 or duree_ans <= 0: raise ValueError("pret >= 0, taux >= 0, duree_ans > 0") t0 = 0.0 if mise_de_fonds > 0: if cout_total is None or cout_total <= 0: raise ValueError("cout_total requis si mise_de_fonds > 0") t0 = duree_ans * min(mise_de_fonds / cout_total, 1.0) T = duree_ans - t0 if T <= 0: return {"interets": 0.0, "duree_effective_ans": 0.0, "t0": t0, "solde_moyen": 0.0, "n_mois": 0, "solde_final": pret} if mode == "continu": interets = pret * taux * T / 2.0 return {"interets": interets, "duree_effective_ans": T, "t0": t0, "solde_moyen": pret / 2.0, "n_mois": None, "solde_final": pret} if mode != "mensuel": raise ValueError("mode ∈ {'continu', 'mensuel'}") n = max(int(round(12 * T)), 1) tirage, im = pret / n, taux / 12.0 solde, interets, cumul = 0.0, 0.0, 0.0 for _ in range(n): solde += tirage it = solde * im interets += it cumul += solde if capitaliser: solde += it return {"interets": interets, "duree_effective_ans": T, "t0": t0, "solde_moyen": cumul / n, "n_mois": n, "solde_final": solde} def taux_duree_implicites(interets: float, pret: float) -> float: """Produit i × T (taux annuel × durée en années) impliqué par un montant d'intérêts intérimaires sous tirage uniforme : I = L i T / 2 ⇒ i T = 2 I / L.""" if pret <= 0: raise ValueError("pret > 0") return 2.0 * interets / pret def marge_sur_prix(marge_sur_cout: float) -> float: """Convertit une marge exprimée sur le coût (m_c = E / C) en marge sur le prix (m_p = E / (C + E)) : m_p = m_c / (1 + m_c).""" return marge_sur_cout / (1.0 + marge_sur_cout) def marge_sur_cout(marge_sur_prix_: float) -> float: """Inverse : m_c = m_p / (1 − m_p).""" if marge_sur_prix_ >= 1: raise ValueError("marge sur prix < 1") return marge_sur_prix_ / (1.0 - marge_sur_prix_) # ----------------------------------------------------------------------------- revenus def revenu_brut_effectif(rbp: float, taux_vacance_pertes: float, autres_revenus: float = 0.0) -> float: """RBE = RBP × (1 − v) + autres revenus.""" return rbp * (1.0 - taux_vacance_pertes) + autres_revenus def rne(rbe: float, depenses_exploitation: float, reserves: float = 0.0) -> float: """RNE = RBE − dépenses d'exploitation − réserves (si la convention retenue les inclut).""" return rbe - depenses_exploitation - reserves def ratio_depenses(depenses: float, rbe: float) -> float: return depenses / rbe def multiplicateur_revenu_brut(prix: float, revenu_brut: float) -> float: return prix / revenu_brut # ----------------------------------------------------------------------------- capitalisation def capitalisation_directe(noi_1: float, taux: float) -> float: """V = RNE_1 / R.""" if taux <= 0: raise ValueError("taux de capitalisation doit être > 0") return noi_1 / taux def taux_global_extrait(noi_1: float, prix: float) -> float: return noi_1 / prix def taux_bande_investissement(rpv: float, constante_hyp: float, rendement_fonds_propres: float) -> float: """R = M·f + (1 − M)·R_E (band of investment : pondération dette/fonds propres).""" return rpv * constante_hyp + (1.0 - rpv) * rendement_fonds_propres def taux_par_rcd(rcd_cible: float, rpv: float, constante_hyp: float) -> float: """Formule de couverture de la dette : R = RCD × M × f.""" return rcd_cible * rpv * constante_hyp def taux_gordon(r: float, g: float) -> float: """R = r − g (croissance constante perpétuelle du RNE, r > g).""" if r <= g: raise ValueError("r doit excéder g") return r - g def taux_ellwood(y_e: float, rpv: float, constante_hyp: float, part_remboursee: float, n: int, variation_valeur: float) -> float: """Taux global d'Ellwood (sans financement de classe J) : R = Y_E − M·(Y_E + P·SFF − f) − Δ·SFF, SFF au taux Y_E sur n ans, P = part du capital remboursée, Δ = variation relative de la valeur.""" sff = facteur_fonds_amortissement(y_e, n) return y_e - rpv * (y_e + part_remboursee * sff - constante_hyp) - variation_valeur * sff def taux_akerson(y_e: float, rpv: float, constante_hyp: float, part_remboursee: float, n: int, variation_valeur: float) -> float: """Formulation d'Akerson (équivalente à Ellwood) : R = M·f + (1−M)·Y_E − M·P·SFF − Δ·SFF.""" sff = facteur_fonds_amortissement(y_e, n) return rpv * constante_hyp + (1 - rpv) * y_e - rpv * part_remboursee * sff - variation_valeur * sff # ----------------------------------------------------------------------------- DCF @dataclass class ResultatDCF: valeur: float va_flux: float va_terminale: float valeur_terminale_brute: float valeur_terminale_nette: float flux: list[float] def valeur_terminale(noi_suivant: float, taux_terminal: float, couts_vente: float = 0.0) -> tuple[float, float]: """(VT brute, VT nette) : VT = RNE_{T+1}/R_T ; nette = brute × (1 − c_v).""" brute = capitalisation_directe(noi_suivant, taux_terminal) return brute, brute * (1.0 - couts_vente) def dcf(flux: Sequence[float], taux_actualisation: float, noi_suivant: float | None = None, taux_terminal: float | None = None, couts_vente: float = 0.0, valeur_terminale_nette: float | None = None) -> ResultatDCF: """V_0 = Σ_{t=1}^{T} CF_t/(1+r)^t + VT_nette/(1+r)^T. flux[0] est le flux de l'année 1. Fournir soit (noi_suivant, taux_terminal) soit valeur_terminale_nette.""" T = len(flux) va_flux = sum(cf / (1 + taux_actualisation) ** (t + 1) for t, cf in enumerate(flux)) if valeur_terminale_nette is None: if noi_suivant is None or taux_terminal is None: raise ValueError("fournir noi_suivant et taux_terminal, ou valeur_terminale_nette") vt_b, vt_n = valeur_terminale(noi_suivant, taux_terminal, couts_vente) else: vt_n = valeur_terminale_nette; vt_b = vt_n / (1 - couts_vente) if couts_vente < 1 else vt_n va_vt = vt_n / (1 + taux_actualisation) ** T return ResultatDCF(va_flux + va_vt, va_flux, va_vt, vt_b, vt_n, list(flux)) def projeter_croissance(valeur_initiale: float, g: float | Sequence[float], n: int) -> list[float]: """Série de n valeurs à partir de valeur_initiale (année 1) avec croissance g (scalaire ou par année).""" gs = [g] * (n - 1) if isinstance(g, (int, float)) else list(g) out = [valeur_initiale] for k in range(n - 1): out.append(out[-1] * (1 + gs[k])) return out def flux_apres_financement(noi: Sequence[float], hyp: Hypotheque) -> list[float]: """Flux de trésorerie avant impôt pour l'investisseur : RNE_t − service de la dette.""" sd = hyp.service_dette_annuel return [x - sd for x in noi] def rendement_fonds_propres_levier(noi: Sequence[float], prix: float, hyp: Hypotheque, valeur_revente_nette: float) -> dict: """TRI des fonds propres avec levier : mise de fonds à t=0, flux après dette, produit net de revente moins solde.""" T = len(noi) mise = prix - hyp.capital flux = [-mise] + flux_apres_financement(noi, hyp) flux[-1] += valeur_revente_nette - hyp.solde(T * 12) return {"flux": flux, "tri": tri(flux), "mise_de_fonds": mise, "solde_final": hyp.solde(T * 12)} def rendement_cash_on_cash(flux_annee1: float, mise_de_fonds: float) -> float: return flux_annee1 / mise_de_fonds def ratio_point_mort(depenses: float, service_dette: float, rbp: float) -> float: """(DE + SD) / RBP : taux d'occupation économique minimal.""" return (depenses + service_dette) / rbp