spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 7 — Cadastre, titres, servitudes et copropriété : exemples chiffrés (DONNÉES SYNTHÉTIQUES, graine 20261031).34Trois blocs, tous générés dans ce script (aucun fichier externe, aucune statistique de marché) :5 A. Effet d'une servitude de passage mesuré par ventes appariées (traite.comparables.ventes_appariees) ;6 comparaison avec le « modèle de la bande » (perte d'utilité d'une bande de terrain grevée).7 B. Copropriété divise de huit fractions : valeur relative (art. 1041, 1053 C.c.Q.), quotes-parts, répartition8 des charges communes générales et des charges des parties communes à usage restreint (art. 1064 C.c.Q.).9 C. Fonds de prévoyance (art. 1071 C.c.Q. ; CCQ, r. 8.01, art. 3 et 7-9) : plan de réparations majeures sur 25 ans,10 cotisation annuelle indexée nécessaire (méthode du seuil), comparaison avec la règle du 0,5 % de la valeur de11 reconstruction et avec la cotisation actuelle, déficit de capitalisation et incidence sur la valeur d'une fraction.1213Toutes les fonctions financières proviennent de analysis/python/traite/ (finance.py, comparables.py).14Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch07_servitudes_copro.py15"""16from __future__ import annotations17import sys, math, pathlib18import numpy as np19from scipy import stats20sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1]))21from traite import comparables as cp22from traite import finance as fin2324SEED = 202610312526# ----------------------------------------------------------------------------- A. servitude par paires27N_PAIRES = 1228PRIX_MEDIAN_SANS = 445_000.0 # $ ; niveau pédagogique (bungalow de banlieue fictif, terrain 700 m²)29SIGMA_PAIRE = 0.08 # dispersion log des prix « sans servitude » entre paires30DELTA_SERVITUDE = 0.045 # effet « vrai » de la servitude de passage (proportion du prix) — hypothèse pédagogique31SIGMA_BRUIT = 0.025 # bruit de transaction résiduel au sein d'une paire32TERRAIN_M2 = 700.033BANDE_LARGEUR_M, BANDE_LONGUEUR_M = 3.0, 40.0 # bande de passage grevée34PRIX_TERRAIN_M2 = 180.0 # $/m² de terrain, prix unitaire pédagogique du secteur fictif35PERTE_UTILITE = 0.50 # proportion de l'utilité de la bande perdue par le fonds servant (passage non exclusif)363738def paires_servitude(n: int = N_PAIRES, seed: int = SEED) -> dict:39 """Génère n paires (sans, avec servitude de passage) et extrait l'effet par ventes appariées."""40 rng = np.random.default_rng(seed)41 sans = np.exp(np.log(PRIX_MEDIAN_SANS) + rng.normal(0.0, SIGMA_PAIRE, n))42 avec = sans * (1.0 - DELTA_SERVITUDE) * np.exp(rng.normal(0.0, SIGMA_BRUIT, n))43 sans = np.round(sans / 500.0) * 500.044 avec = np.round(avec / 500.0) * 500.045 va = cp.ventes_appariees(list(avec), list(sans)) # différences = avec − sans (négatives)46 diffs = np.array(va["differences"])47 se = va["ecart_type"] / math.sqrt(n)48 t_crit = float(stats.t.ppf(0.975, n - 1))49 ratios = avec / sans50 beta = float(np.mean(np.log(ratios)))51 return {52 "n": n, "sans": sans, "avec": avec, "diffs": diffs,53 "moyenne": va["moyenne"], "mediane": va["mediane"], "ecart_type": va["ecart_type"], "se": se,54 "t": va["moyenne"] / se, "t_crit": t_crit,55 "ic95": (va["moyenne"] - t_crit * se, va["moyenne"] + t_crit * se),56 "effet_relatif_moyen": float(np.mean(ratios - 1.0)), # moyenne des (avec/sans − 1)57 "effet_relatif_ratio": float(avec.mean() / sans.mean() - 1.0), # rapport des moyennes58 "beta_log": beta, "effet_semilog": cp.ajustement_depuis_coefficient_log(beta),59 "ic95_relatif": ((va["moyenne"] - t_crit * se) / sans.mean(), (va["moyenne"] + t_crit * se) / sans.mean()),60 }616263def modele_bande(prix_terrain_m2: float = PRIX_TERRAIN_M2, largeur: float = BANDE_LARGEUR_M,64 longueur: float = BANDE_LONGUEUR_M, perte: float = PERTE_UTILITE) -> dict:65 """Effet d'une servitude = superficie grevée × prix unitaire du terrain × proportion d'utilité perdue."""66 s = largeur * longueur67 return {"superficie_bande_m2": s, "valeur_bande": s * prix_terrain_m2, "effet": s * prix_terrain_m2 * perte,68 "part_terrain": s / TERRAIN_M2}697071# ----------------------------------------------------------------------------- B. copropriété : quotes-parts et charges72FRACTIONS = [ # (nom, superficie privative m², étage, orientation, stationnement intérieur à usage restreint)73 ("F1", 72.0, "RDC", "nord", False), ("F2", 98.0, "RDC", "sud", True),74 ("F3", 72.0, "2e", "nord", False), ("F4", 98.0, "2e", "sud", True),75 ("F5", 85.0, "3e", "nord", False), ("F6", 110.0, "3e", "sud", True),76 ("F7", 85.0, "2e", "est", True), ("F8", 90.0, "3e", "ouest", False),77]78COEF_ETAGE = {"RDC": 0.96, "2e": 1.00, "3e": 1.04} # méthode déclarée dans l'acte constitutif (fictif)79COEF_ORIENTATION = {"nord": 0.97, "est": 1.00, "ouest": 1.00, "sud": 1.03}80BASE_QUOTE = 10_000 # quotes-parts exprimées en dix-millièmes8182CHARGES_COURANTES = { # budget annuel des charges communes générales, hors fonds (dollars de 2026, fictif)83 "Assurance de l'immeuble": 12_000.0, "Entretien et réparations courantes": 8_500.0,84 "Déneigement et entretien paysager": 5_200.0, "Énergie des parties communes": 4_800.0,85 "Gestion et honoraires professionnels": 7_200.0, "Administration et frais divers": 2_300.0,86}87FRANCHISE_MAX = 10_000.0 # plus haute franchise de la police du syndicat (dégâts d'eau) — fictif88FONDS_AUTO_ASSURANCE_SOLDE = 0.0 # fonds d'auto assurance non capitalisé → contribution = moitié de la franchise (r. 4.1, art. 2)89ENTRETIEN_STATIONNEMENTS = 3_200.0 # entretien courant des 4 stationnements intérieurs (partie commune à usage restreint)909192def quotes_parts(fractions=FRACTIONS) -> dict:93 """Valeur relative v_i = S_i × c_étage × c_orientation ; quote-part q_i = v_i / Σ v, en dix-millièmes (somme exacte = 10 000)."""94 v = np.array([s * COEF_ETAGE[e] * COEF_ORIENTATION[o] for _, s, e, o, _ in fractions])95 q = v / v.sum()96 brut = q * BASE_QUOTE97 ent = np.floor(brut).astype(int)98 reste = BASE_QUOTE - ent.sum()99 ordre = np.argsort(-(brut - ent)) # plus forts restes100 ent[ordre[:reste]] += 1101 return {"noms": [f[0] for f in fractions], "superficies": np.array([f[1] for f in fractions]),102 "valeur_relative": v, "q": ent / BASE_QUOTE, "q_dixmilliemes": ent, "q_brut": q,103 "stationnement": np.array([f[4] for f in fractions])}104105106def contribution_auto_assurance(franchise_max: float = FRANCHISE_MAX, solde: float = FONDS_AUTO_ASSURANCE_SOLDE) -> float:107 """CCQ, r. 4.1, art. 2 : moitié de la plus haute franchise si le fonds est capitalisé à moins de la moitié ; différence108 entre la franchise et le solde si entre la moitié et la franchise ; nulle au-delà (franchises séisme/inondation exclues)."""109 if solde <= franchise_max / 2:110 return franchise_max / 2111 if solde < franchise_max:112 return franchise_max - solde113 return 0.0114115116def repartition_charges(cotisation_fonds_prevoyance: float, qp: dict | None = None) -> dict:117 """Charges annuelles de chaque fraction : (courantes + fonds de prévoyance + fonds d'auto assurance) × q_i118 + part égale de l'entretien des stationnements pour leurs seuls usagers (art. 1064 C.c.Q.)."""119 qp = qp or quotes_parts()120 courantes = sum(CHARGES_COURANTES.values())121 auto = contribution_auto_assurance()122 general = courantes + cotisation_fonds_prevoyance + auto123 n_stat = int(qp["stationnement"].sum())124 part_stat = np.where(qp["stationnement"], ENTRETIEN_STATIONNEMENTS / n_stat, 0.0)125 q = qp["q"]126 return {"courantes": courantes, "fonds_prevoyance": cotisation_fonds_prevoyance, "auto_assurance": auto, "general": general,127 "par_fraction_courantes": q * courantes, "par_fraction_fonds": q * cotisation_fonds_prevoyance,128 "par_fraction_auto": q * auto, "par_fraction_general": q * general, "par_fraction_restreint": part_stat,129 "par_fraction_total": q * general + part_stat, "total": general + ENTRETIEN_STATIONNEMENTS, "n_stationnements": n_stat}130131132# ----------------------------------------------------------------------------- C. fonds de prévoyance133VALEUR_RECONSTRUCTION = 4_200_000.0 # $ ; montant d'assurance (art. 1073 C.c.Q.), fixé par un évaluateur agréé (r. 4.1, art. 3) — fictif134HORIZON = 25 # années (CCQ, r. 8.01, art. 3 : « minimalement les 25 prochaines années »)135INFLATION = 0.025 # hausse annuelle des coûts de construction retenue dans l'étude (hypothèse pédagogique)136RENDEMENT = 0.020 # rendement des placements à capital garanti (art. 1071 : « placé … à capital garanti »)137SOLDE_INITIAL = 85_000.0 # solde du fonds de prévoyance au début de l'année 1 (2026) — fictif138COTISATION_ACTUELLE = 12_000.0 # cotisation annuelle votée jusqu'ici (fictif) — insuffisante, on le verra139COMPOSANTES = [ # (composante, coût en dollars de 2026, années prévues 1..25)140 ("Peinture et éclairage des corridors", 18_000.0, (4, 13, 22)),141 ("Maçonnerie et balcons", 55_000.0, (5,)),142 ("Toiture (membrane)", 75_000.0, (7,)),143 ("Pavage et drainage du stationnement", 28_000.0, (9, 21)),144 ("Chauffage et ventilation des parties communes", 42_000.0, (12,)),145 ("Fenêtres et portes-fenêtres", 140_000.0, (14,)),146 ("Portes de garage et équipements du stationnement", 22_000.0, (16,)),147 ("Entrée d'eau et drains", 35_000.0, (19,)),148]149150151def depenses_prevues(composantes=COMPOSANTES, horizon: int = HORIZON, inflation: float = INFLATION) -> np.ndarray:152 """D_t, t = 1..horizon : coûts indexés C_0 (1+π)^t des réparations majeures prévues l'année t."""153 d = np.zeros(horizon + 1)154 for _, c0, annees in composantes:155 for t in annees:156 if 1 <= t <= horizon:157 d[t] += c0 * (1.0 + inflation) ** t158 return d159160161def trajectoire(cotisation_an1: float, solde0: float = SOLDE_INITIAL, rendement: float = RENDEMENT, inflation: float = INFLATION,162 horizon: int = HORIZON, composantes=COMPOSANTES, indexer: bool = True) -> dict:163 """F_t = F_{t-1}(1+r) + A_t − D_t, avec A_t = A_1 (1+π)^{t−1} si indexée (convention de l'étude), flux en fin d'année."""164 d = depenses_prevues(composantes, horizon, inflation)165 F = np.zeros(horizon + 1); F[0] = solde0166 A = np.zeros(horizon + 1)167 for t in range(1, horizon + 1):168 A[t] = cotisation_an1 * ((1.0 + inflation) ** (t - 1) if indexer else 1.0)169 F[t] = F[t - 1] * (1.0 + rendement) + A[t] - d[t]170 return {"F": F, "A": A, "D": d, "min": float(F[1:].min()), "annee_min": int(np.argmin(F[1:]) + 1),171 "premiere_annee_negative": next((t for t in range(1, horizon + 1) if F[t] < 0), None)}172173174def cotisation_seuil(plancher: float = 0.0, solde0: float = SOLDE_INITIAL, rendement: float = RENDEMENT, inflation: float = INFLATION,175 horizon: int = HORIZON, composantes=COMPOSANTES, indexer: bool = True) -> float:176 """Méthode du seuil : plus petite cotisation A_1 (indexée) telle que le solde ne descende jamais sous `plancher`."""177 lo, hi = 0.0, 500_000.0178 for _ in range(200):179 mid = 0.5 * (lo + hi)180 if trajectoire(mid, solde0, rendement, inflation, horizon, composantes, indexer)["min"] >= plancher:181 hi = mid182 else:183 lo = mid184 return hi185186187def solde_initial_requis(cotisation: float = COTISATION_ACTUELLE, plancher: float = 0.0, **kw) -> float:188 """Solde d'ouverture qui rendrait la cotisation actuelle suffisante (sert à mesurer le déficit de capitalisation)."""189 lo, hi = 0.0, 5_000_000.0190 for _ in range(200):191 mid = 0.5 * (lo + hi)192 if trajectoire(cotisation, mid, **kw)["min"] >= plancher:193 hi = mid194 else:195 lo = mid196 return hi197198199def fonds_composante_unique(cout_actuel: float, annees: int, inflation: float = INFLATION, rendement: float = RENDEMENT) -> dict:200 """Vérification par le facteur de fonds d'amortissement : versement constant qui accumule C_0 (1+π)^T en T ans au taux r."""201 cT = cout_actuel * (1.0 + inflation) ** annees202 sff = fin.facteur_fonds_amortissement(rendement, annees)203 return {"cout_indexe": cT, "sff": sff, "versement": cT * sff}204205206def sensibilite_cotisation(inflations=(0.015, 0.020, 0.025, 0.030, 0.035), rendements=(0.010, 0.020, 0.030), plancher: float = 0.0) -> dict:207 grille = {r: [cotisation_seuil(plancher, inflation=p, rendement=r) for p in inflations] for r in rendements}208 return {"inflations": list(inflations), "rendements": list(rendements), "grille": grille}209210211def incidence_valeur(qp: dict | None = None, cotisation: float = COTISATION_ACTUELLE) -> dict:212 """Déficit de capitalisation D_0 = solde requis − solde réel ; ajustement de la fraction i = −q_i × D_0 (hypothèse : capitalisation 1 pour 1)."""213 qp = qp or quotes_parts()214 requis = solde_initial_requis(cotisation)215 deficit = requis - SOLDE_INITIAL216 return {"solde_requis": requis, "deficit": deficit, "ajustement_par_fraction": -qp["q"] * deficit}217218219# ----------------------------------------------------------------------------- impression220def fmt(x): return f"{x:,.0f}".replace(",", " ")221222223def main() -> dict:224 out = {}225 # A226 p = paires_servitude(); out["paires"] = p227 print("== A. Servitude de passage — ventes appariées (n = %d, graine %d)" % (p["n"], SEED))228 for i, (s, a, d) in enumerate(zip(p["sans"], p["avec"], p["diffs"]), 1):229 print(f" paire {i:2d} : sans {fmt(s):>9} $ avec {fmt(a):>9} $ diff {d:>9,.0f} $ ratio {a/s-1:+.3%}")230 print(f" moyenne {p['moyenne']:,.0f} $ ; médiane {p['mediane']:,.0f} $ ; écart-type {p['ecart_type']:,.0f} $ ; erreur-type {p['se']:,.0f} $")231 print(f" t = {p['t']:.2f} (t crit. {p['t_crit']:.3f}) ; IC 95 % [{p['ic95'][0]:,.0f} ; {p['ic95'][1]:,.0f}] $")232 print(f" effet relatif : moyenne des ratios {p['effet_relatif_moyen']:.4%} ; rapport des moyennes {p['effet_relatif_ratio']:.4%} ; semi-log exp(β)−1 = {p['effet_semilog']:.4%} (β = {p['beta_log']:.5f})")233 print(f" IC 95 % relatif [{p['ic95_relatif'][0]:.2%} ; {p['ic95_relatif'][1]:.2%}] ; moyenne des prix sans {p['sans'].mean():,.0f} $")234 b = modele_bande(); out["bande"] = b235 print(f" modèle de la bande : {b['superficie_bande_m2']:.0f} m² × {PRIX_TERRAIN_M2:.0f} $/m² = {b['valeur_bande']:,.0f} $ × {PERTE_UTILITE:.0%} = {b['effet']:,.0f} $ ({b['part_terrain']:.1%} du terrain)")236 # B237 qp = quotes_parts(); out["qp"] = qp238 print("\n== B. Copropriété divise — valeur relative et quotes-parts (dix-millièmes)")239 for n, s, v, q in zip(qp["noms"], qp["superficies"], qp["valeur_relative"], qp["q_dixmilliemes"]):240 print(f" {n} : {s:5.0f} m² v = {v:8.2f} q = {q:5d}/10 000")241 print(f" Σ v = {qp['valeur_relative'].sum():.2f} ; Σ q = {qp['q_dixmilliemes'].sum()} ; Σ S = {qp['superficies'].sum():.0f} m²")242 # C243 A_star = cotisation_seuil(); out["A_star"] = A_star244 A_floor = cotisation_seuil(plancher=25_000.0); out["A_floor"] = A_floor245 regle = 0.005 * VALEUR_RECONSTRUCTION; out["regle_0_5"] = regle246 tr_act = trajectoire(COTISATION_ACTUELLE); tr_star = trajectoire(A_star); tr_regle = trajectoire(regle)247 out.update({"tr_act": tr_act, "tr_star": tr_star, "tr_regle": tr_regle})248 D = tr_star["D"]249 print("\n== C. Fonds de prévoyance — plan sur %d ans, π = %.1f %%, r = %.1f %%, F0 = %s $" % (HORIZON, INFLATION * 100, RENDEMENT * 100, fmt(SOLDE_INITIAL)))250 tot0 = 0.0251 for nom, c0, annees in COMPOSANTES:252 for t in annees:253 print(f" an {t:2d} ({2026 + t - 1}) : {nom:48s} {fmt(c0):>8} $ → indexé {fmt(c0 * (1 + INFLATION) ** t):>8} $")254 tot0 += c0255 print(f" total des dépenses prévues : {fmt(tot0)} $ (dollars de 2026) ; {fmt(D.sum())} $ (dollars courants)")256 print(f" cotisation actuelle {fmt(COTISATION_ACTUELLE)} $ : solde minimal {tr_act['min']:,.0f} $ (an {tr_act['annee_min']}), premier solde négatif an {tr_act['premiere_annee_negative']} ; solde an 25 {tr_act['F'][-1]:,.0f} $")257 print(f" règle 0,5 % × {fmt(VALEUR_RECONSTRUCTION)} = {fmt(regle)} $ : solde minimal {tr_regle['min']:,.0f} $ (an {tr_regle['annee_min']}), premier négatif an {tr_regle['premiere_annee_negative']}")258 print(f" cotisation de seuil A* (plancher 0) = {A_star:,.0f} $ (an 1, indexée à {INFLATION:.1%}) ; solde minimal {tr_star['min']:,.2f} $ an {tr_star['annee_min']} ; solde an 25 {tr_star['F'][-1]:,.0f} $ ; an 25 cotisation {tr_star['A'][-1]:,.0f} $")259 print(f" cotisation de seuil avec plancher 25 000 $ = {A_floor:,.0f} $")260 fc = fonds_composante_unique(75_000.0, 7); out["toiture"] = fc261 print(f" vérification toiture seule : {fmt(75000)} $ × 1,025^7 = {fc['cout_indexe']:,.0f} $ ; SFF(2 %, 7) = {fc['sff']:.5f} ; versement {fc['versement']:,.0f} $")262 rep = repartition_charges(A_star, qp); out["rep"] = rep263 print(f"\n== B'. Répartition des charges avec A* : courantes {fmt(rep['courantes'])} $ + fonds {fmt(rep['fonds_prevoyance'])} $ + auto-assurance {fmt(rep['auto_assurance'])} $ = {fmt(rep['general'])} $ ; stationnements {fmt(ENTRETIEN_STATIONNEMENTS)} $ / {rep['n_stationnements']}")264 for i, n in enumerate(qp["noms"]):265 print(f" {n} : q {qp['q'][i]:.4f} courantes {rep['par_fraction_courantes'][i]:8,.0f} fonds {rep['par_fraction_fonds'][i]:8,.0f} auto {rep['par_fraction_auto'][i]:6,.0f} restreint {rep['par_fraction_restreint'][i]:6,.0f} total {rep['par_fraction_total'][i]:8,.0f} $/an = {rep['par_fraction_total'][i]/12:6,.0f} $/mois")266 print(f" total {rep['total']:,.0f} $ ; contrôle Σ = {rep['par_fraction_total'].sum():,.2f} $")267 sens = sensibilite_cotisation(); out["sens"] = sens268 print("\n== Sensibilité de A* (plancher 0) : lignes r, colonnes π")269 print(" " + " ".join(f"{p:.1%}" for p in sens["inflations"]))270 for r, row in sens["grille"].items():271 print(f" r={r:.1%} " + " ".join(f"{a:7,.0f}" for a in row))272 inc = incidence_valeur(qp); out["incidence"] = inc273 print(f"\n== Incidence sur la valeur : solde requis avec {fmt(COTISATION_ACTUELLE)} $ = {inc['solde_requis']:,.0f} $ ; déficit {inc['deficit']:,.0f} $")274 for n, a in zip(qp["noms"], inc["ajustement_par_fraction"]):275 print(f" {n} : ajustement {a:,.0f} $")276 return out277278279if __name__ == "__main__":280 main()281