#!/usr/bin/env python3 """Chapitre 7 — Cadastre, titres, servitudes et copropriété : exemples chiffrés (DONNÉES SYNTHÉTIQUES, graine 20261031). Trois blocs, tous générés dans ce script (aucun fichier externe, aucune statistique de marché) : A. Effet d'une servitude de passage mesuré par ventes appariées (traite.comparables.ventes_appariees) ; comparaison avec le « modèle de la bande » (perte d'utilité d'une bande de terrain grevée). B. Copropriété divise de huit fractions : valeur relative (art. 1041, 1053 C.c.Q.), quotes-parts, répartition des charges communes générales et des charges des parties communes à usage restreint (art. 1064 C.c.Q.). 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, cotisation annuelle indexée nécessaire (méthode du seuil), comparaison avec la règle du 0,5 % de la valeur de reconstruction et avec la cotisation actuelle, déficit de capitalisation et incidence sur la valeur d'une fraction. Toutes les fonctions financières proviennent de analysis/python/traite/ (finance.py, comparables.py). Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch07_servitudes_copro.py """ from __future__ import annotations import sys, math, pathlib import numpy as np from scipy import stats sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) from traite import comparables as cp from traite import finance as fin SEED = 20261031 # ----------------------------------------------------------------------------- A. servitude par paires N_PAIRES = 12 PRIX_MEDIAN_SANS = 445_000.0 # $ ; niveau pédagogique (bungalow de banlieue fictif, terrain 700 m²) SIGMA_PAIRE = 0.08 # dispersion log des prix « sans servitude » entre paires DELTA_SERVITUDE = 0.045 # effet « vrai » de la servitude de passage (proportion du prix) — hypothèse pédagogique SIGMA_BRUIT = 0.025 # bruit de transaction résiduel au sein d'une paire TERRAIN_M2 = 700.0 BANDE_LARGEUR_M, BANDE_LONGUEUR_M = 3.0, 40.0 # bande de passage grevée PRIX_TERRAIN_M2 = 180.0 # $/m² de terrain, prix unitaire pédagogique du secteur fictif PERTE_UTILITE = 0.50 # proportion de l'utilité de la bande perdue par le fonds servant (passage non exclusif) def paires_servitude(n: int = N_PAIRES, seed: int = SEED) -> dict: """Génère n paires (sans, avec servitude de passage) et extrait l'effet par ventes appariées.""" rng = np.random.default_rng(seed) sans = np.exp(np.log(PRIX_MEDIAN_SANS) + rng.normal(0.0, SIGMA_PAIRE, n)) avec = sans * (1.0 - DELTA_SERVITUDE) * np.exp(rng.normal(0.0, SIGMA_BRUIT, n)) sans = np.round(sans / 500.0) * 500.0 avec = np.round(avec / 500.0) * 500.0 va = cp.ventes_appariees(list(avec), list(sans)) # différences = avec − sans (négatives) diffs = np.array(va["differences"]) se = va["ecart_type"] / math.sqrt(n) t_crit = float(stats.t.ppf(0.975, n - 1)) ratios = avec / sans beta = float(np.mean(np.log(ratios))) return { "n": n, "sans": sans, "avec": avec, "diffs": diffs, "moyenne": va["moyenne"], "mediane": va["mediane"], "ecart_type": va["ecart_type"], "se": se, "t": va["moyenne"] / se, "t_crit": t_crit, "ic95": (va["moyenne"] - t_crit * se, va["moyenne"] + t_crit * se), "effet_relatif_moyen": float(np.mean(ratios - 1.0)), # moyenne des (avec/sans − 1) "effet_relatif_ratio": float(avec.mean() / sans.mean() - 1.0), # rapport des moyennes "beta_log": beta, "effet_semilog": cp.ajustement_depuis_coefficient_log(beta), "ic95_relatif": ((va["moyenne"] - t_crit * se) / sans.mean(), (va["moyenne"] + t_crit * se) / sans.mean()), } def modele_bande(prix_terrain_m2: float = PRIX_TERRAIN_M2, largeur: float = BANDE_LARGEUR_M, longueur: float = BANDE_LONGUEUR_M, perte: float = PERTE_UTILITE) -> dict: """Effet d'une servitude = superficie grevée × prix unitaire du terrain × proportion d'utilité perdue.""" s = largeur * longueur return {"superficie_bande_m2": s, "valeur_bande": s * prix_terrain_m2, "effet": s * prix_terrain_m2 * perte, "part_terrain": s / TERRAIN_M2} # ----------------------------------------------------------------------------- B. copropriété : quotes-parts et charges FRACTIONS = [ # (nom, superficie privative m², étage, orientation, stationnement intérieur à usage restreint) ("F1", 72.0, "RDC", "nord", False), ("F2", 98.0, "RDC", "sud", True), ("F3", 72.0, "2e", "nord", False), ("F4", 98.0, "2e", "sud", True), ("F5", 85.0, "3e", "nord", False), ("F6", 110.0, "3e", "sud", True), ("F7", 85.0, "2e", "est", True), ("F8", 90.0, "3e", "ouest", False), ] COEF_ETAGE = {"RDC": 0.96, "2e": 1.00, "3e": 1.04} # méthode déclarée dans l'acte constitutif (fictif) COEF_ORIENTATION = {"nord": 0.97, "est": 1.00, "ouest": 1.00, "sud": 1.03} BASE_QUOTE = 10_000 # quotes-parts exprimées en dix-millièmes CHARGES_COURANTES = { # budget annuel des charges communes générales, hors fonds (dollars de 2026, fictif) "Assurance de l'immeuble": 12_000.0, "Entretien et réparations courantes": 8_500.0, "Déneigement et entretien paysager": 5_200.0, "Énergie des parties communes": 4_800.0, "Gestion et honoraires professionnels": 7_200.0, "Administration et frais divers": 2_300.0, } FRANCHISE_MAX = 10_000.0 # plus haute franchise de la police du syndicat (dégâts d'eau) — fictif FONDS_AUTO_ASSURANCE_SOLDE = 0.0 # fonds d'auto assurance non capitalisé → contribution = moitié de la franchise (r. 4.1, art. 2) ENTRETIEN_STATIONNEMENTS = 3_200.0 # entretien courant des 4 stationnements intérieurs (partie commune à usage restreint) def quotes_parts(fractions=FRACTIONS) -> dict: """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).""" v = np.array([s * COEF_ETAGE[e] * COEF_ORIENTATION[o] for _, s, e, o, _ in fractions]) q = v / v.sum() brut = q * BASE_QUOTE ent = np.floor(brut).astype(int) reste = BASE_QUOTE - ent.sum() ordre = np.argsort(-(brut - ent)) # plus forts restes ent[ordre[:reste]] += 1 return {"noms": [f[0] for f in fractions], "superficies": np.array([f[1] for f in fractions]), "valeur_relative": v, "q": ent / BASE_QUOTE, "q_dixmilliemes": ent, "q_brut": q, "stationnement": np.array([f[4] for f in fractions])} def contribution_auto_assurance(franchise_max: float = FRANCHISE_MAX, solde: float = FONDS_AUTO_ASSURANCE_SOLDE) -> float: """CCQ, r. 4.1, art. 2 : moitié de la plus haute franchise si le fonds est capitalisé à moins de la moitié ; différence entre la franchise et le solde si entre la moitié et la franchise ; nulle au-delà (franchises séisme/inondation exclues).""" if solde <= franchise_max / 2: return franchise_max / 2 if solde < franchise_max: return franchise_max - solde return 0.0 def repartition_charges(cotisation_fonds_prevoyance: float, qp: dict | None = None) -> dict: """Charges annuelles de chaque fraction : (courantes + fonds de prévoyance + fonds d'auto assurance) × q_i + part égale de l'entretien des stationnements pour leurs seuls usagers (art. 1064 C.c.Q.).""" qp = qp or quotes_parts() courantes = sum(CHARGES_COURANTES.values()) auto = contribution_auto_assurance() general = courantes + cotisation_fonds_prevoyance + auto n_stat = int(qp["stationnement"].sum()) part_stat = np.where(qp["stationnement"], ENTRETIEN_STATIONNEMENTS / n_stat, 0.0) q = qp["q"] return {"courantes": courantes, "fonds_prevoyance": cotisation_fonds_prevoyance, "auto_assurance": auto, "general": general, "par_fraction_courantes": q * courantes, "par_fraction_fonds": q * cotisation_fonds_prevoyance, "par_fraction_auto": q * auto, "par_fraction_general": q * general, "par_fraction_restreint": part_stat, "par_fraction_total": q * general + part_stat, "total": general + ENTRETIEN_STATIONNEMENTS, "n_stationnements": n_stat} # ----------------------------------------------------------------------------- C. fonds de prévoyance VALEUR_RECONSTRUCTION = 4_200_000.0 # $ ; montant d'assurance (art. 1073 C.c.Q.), fixé par un évaluateur agréé (r. 4.1, art. 3) — fictif HORIZON = 25 # années (CCQ, r. 8.01, art. 3 : « minimalement les 25 prochaines années ») INFLATION = 0.025 # hausse annuelle des coûts de construction retenue dans l'étude (hypothèse pédagogique) RENDEMENT = 0.020 # rendement des placements à capital garanti (art. 1071 : « placé … à capital garanti ») SOLDE_INITIAL = 85_000.0 # solde du fonds de prévoyance au début de l'année 1 (2026) — fictif COTISATION_ACTUELLE = 12_000.0 # cotisation annuelle votée jusqu'ici (fictif) — insuffisante, on le verra COMPOSANTES = [ # (composante, coût en dollars de 2026, années prévues 1..25) ("Peinture et éclairage des corridors", 18_000.0, (4, 13, 22)), ("Maçonnerie et balcons", 55_000.0, (5,)), ("Toiture (membrane)", 75_000.0, (7,)), ("Pavage et drainage du stationnement", 28_000.0, (9, 21)), ("Chauffage et ventilation des parties communes", 42_000.0, (12,)), ("Fenêtres et portes-fenêtres", 140_000.0, (14,)), ("Portes de garage et équipements du stationnement", 22_000.0, (16,)), ("Entrée d'eau et drains", 35_000.0, (19,)), ] def depenses_prevues(composantes=COMPOSANTES, horizon: int = HORIZON, inflation: float = INFLATION) -> np.ndarray: """D_t, t = 1..horizon : coûts indexés C_0 (1+π)^t des réparations majeures prévues l'année t.""" d = np.zeros(horizon + 1) for _, c0, annees in composantes: for t in annees: if 1 <= t <= horizon: d[t] += c0 * (1.0 + inflation) ** t return d def trajectoire(cotisation_an1: float, solde0: float = SOLDE_INITIAL, rendement: float = RENDEMENT, inflation: float = INFLATION, horizon: int = HORIZON, composantes=COMPOSANTES, indexer: bool = True) -> dict: """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.""" d = depenses_prevues(composantes, horizon, inflation) F = np.zeros(horizon + 1); F[0] = solde0 A = np.zeros(horizon + 1) for t in range(1, horizon + 1): A[t] = cotisation_an1 * ((1.0 + inflation) ** (t - 1) if indexer else 1.0) F[t] = F[t - 1] * (1.0 + rendement) + A[t] - d[t] return {"F": F, "A": A, "D": d, "min": float(F[1:].min()), "annee_min": int(np.argmin(F[1:]) + 1), "premiere_annee_negative": next((t for t in range(1, horizon + 1) if F[t] < 0), None)} def cotisation_seuil(plancher: float = 0.0, solde0: float = SOLDE_INITIAL, rendement: float = RENDEMENT, inflation: float = INFLATION, horizon: int = HORIZON, composantes=COMPOSANTES, indexer: bool = True) -> float: """Méthode du seuil : plus petite cotisation A_1 (indexée) telle que le solde ne descende jamais sous `plancher`.""" lo, hi = 0.0, 500_000.0 for _ in range(200): mid = 0.5 * (lo + hi) if trajectoire(mid, solde0, rendement, inflation, horizon, composantes, indexer)["min"] >= plancher: hi = mid else: lo = mid return hi def solde_initial_requis(cotisation: float = COTISATION_ACTUELLE, plancher: float = 0.0, **kw) -> float: """Solde d'ouverture qui rendrait la cotisation actuelle suffisante (sert à mesurer le déficit de capitalisation).""" lo, hi = 0.0, 5_000_000.0 for _ in range(200): mid = 0.5 * (lo + hi) if trajectoire(cotisation, mid, **kw)["min"] >= plancher: hi = mid else: lo = mid return hi def fonds_composante_unique(cout_actuel: float, annees: int, inflation: float = INFLATION, rendement: float = RENDEMENT) -> dict: """Vérification par le facteur de fonds d'amortissement : versement constant qui accumule C_0 (1+π)^T en T ans au taux r.""" cT = cout_actuel * (1.0 + inflation) ** annees sff = fin.facteur_fonds_amortissement(rendement, annees) return {"cout_indexe": cT, "sff": sff, "versement": cT * sff} def 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: grille = {r: [cotisation_seuil(plancher, inflation=p, rendement=r) for p in inflations] for r in rendements} return {"inflations": list(inflations), "rendements": list(rendements), "grille": grille} def incidence_valeur(qp: dict | None = None, cotisation: float = COTISATION_ACTUELLE) -> dict: """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).""" qp = qp or quotes_parts() requis = solde_initial_requis(cotisation) deficit = requis - SOLDE_INITIAL return {"solde_requis": requis, "deficit": deficit, "ajustement_par_fraction": -qp["q"] * deficit} # ----------------------------------------------------------------------------- impression def fmt(x): return f"{x:,.0f}".replace(",", " ") def main() -> dict: out = {} # A p = paires_servitude(); out["paires"] = p print("== A. Servitude de passage — ventes appariées (n = %d, graine %d)" % (p["n"], SEED)) for i, (s, a, d) in enumerate(zip(p["sans"], p["avec"], p["diffs"]), 1): print(f" paire {i:2d} : sans {fmt(s):>9} $ avec {fmt(a):>9} $ diff {d:>9,.0f} $ ratio {a/s-1:+.3%}") print(f" moyenne {p['moyenne']:,.0f} $ ; médiane {p['mediane']:,.0f} $ ; écart-type {p['ecart_type']:,.0f} $ ; erreur-type {p['se']:,.0f} $") print(f" t = {p['t']:.2f} (t crit. {p['t_crit']:.3f}) ; IC 95 % [{p['ic95'][0]:,.0f} ; {p['ic95'][1]:,.0f}] $") 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})") print(f" IC 95 % relatif [{p['ic95_relatif'][0]:.2%} ; {p['ic95_relatif'][1]:.2%}] ; moyenne des prix sans {p['sans'].mean():,.0f} $") b = modele_bande(); out["bande"] = b 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)") # B qp = quotes_parts(); out["qp"] = qp print("\n== B. Copropriété divise — valeur relative et quotes-parts (dix-millièmes)") for n, s, v, q in zip(qp["noms"], qp["superficies"], qp["valeur_relative"], qp["q_dixmilliemes"]): print(f" {n} : {s:5.0f} m² v = {v:8.2f} q = {q:5d}/10 000") print(f" Σ v = {qp['valeur_relative'].sum():.2f} ; Σ q = {qp['q_dixmilliemes'].sum()} ; Σ S = {qp['superficies'].sum():.0f} m²") # C A_star = cotisation_seuil(); out["A_star"] = A_star A_floor = cotisation_seuil(plancher=25_000.0); out["A_floor"] = A_floor regle = 0.005 * VALEUR_RECONSTRUCTION; out["regle_0_5"] = regle tr_act = trajectoire(COTISATION_ACTUELLE); tr_star = trajectoire(A_star); tr_regle = trajectoire(regle) out.update({"tr_act": tr_act, "tr_star": tr_star, "tr_regle": tr_regle}) D = tr_star["D"] print("\n== C. Fonds de prévoyance — plan sur %d ans, π = %.1f %%, r = %.1f %%, F0 = %s $" % (HORIZON, INFLATION * 100, RENDEMENT * 100, fmt(SOLDE_INITIAL))) tot0 = 0.0 for nom, c0, annees in COMPOSANTES: for t in annees: print(f" an {t:2d} ({2026 + t - 1}) : {nom:48s} {fmt(c0):>8} $ → indexé {fmt(c0 * (1 + INFLATION) ** t):>8} $") tot0 += c0 print(f" total des dépenses prévues : {fmt(tot0)} $ (dollars de 2026) ; {fmt(D.sum())} $ (dollars courants)") 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} $") 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']}") 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} $") print(f" cotisation de seuil avec plancher 25 000 $ = {A_floor:,.0f} $") fc = fonds_composante_unique(75_000.0, 7); out["toiture"] = fc 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} $") rep = repartition_charges(A_star, qp); out["rep"] = rep 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']}") for i, n in enumerate(qp["noms"]): 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") print(f" total {rep['total']:,.0f} $ ; contrôle Σ = {rep['par_fraction_total'].sum():,.2f} $") sens = sensibilite_cotisation(); out["sens"] = sens print("\n== Sensibilité de A* (plancher 0) : lignes r, colonnes π") print(" " + " ".join(f"{p:.1%}" for p in sens["inflations"])) for r, row in sens["grille"].items(): print(f" r={r:.1%} " + " ".join(f"{a:7,.0f}" for a in row)) inc = incidence_valeur(qp); out["incidence"] = inc print(f"\n== Incidence sur la valeur : solde requis avec {fmt(COTISATION_ACTUELLE)} $ = {inc['solde_requis']:,.0f} $ ; déficit {inc['deficit']:,.0f} $") for n, a in zip(qp["noms"], inc["ajustement_par_fraction"]): print(f" {n} : ajustement {a:,.0f} $") return out if __name__ == "__main__": main()