SPB Git
18.2 KB · 281 lines python
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
Files · ac7e5213a2af3676c22c1500a18fcfe86d2041bc
Menu

Theme