spb/traite-evaluation-qc
Public
1#!/usr/bin/env python32"""Chapitre 25 — Évaluation du terrain (DONNÉES SYNTHÉTIQUES, graine 20261031).34Imprime tous les chiffres cités dans le chapitre 25 :5 * jeu synthétique de ventes de terrains vacants « Gatineau » (data/synthetic/ch25_terrains_ventes.csv) : lois déclarées ;6 * modèle hédonique des terrains (traite.hedonique.mco) : élasticité de superficie, façade, services, topographie, forme, densité ;7 * comparaison directe : sélection de cinq terrains comparables pour le sujet du cas 1 et grille d'ajustements ;8 * allocation : part du terrain dans les ventes du cas 1 (secteur Hull) ; prix implicite marginal du modèle hédonique (traite.cas1) ;9 * extraction : prix − coût déprécié du bâtiment sur les cinq comparables du chapitre 16 (coût neuf du chapitre 21) ;10 * technique résiduelle du terrain sur le cas 3 (RNE de traite.cas3 ; taux déclarés) et sensibilité ;11 * lotissement (développement actualisé) d'un site de 10 000 m² en 12 terrains (traite.finance.van) ;12 * capitalisation d'une rente foncière (emphytéose) ;13 * rapprochement avec la valeur extraite par le ch. 24 (ch24_depreciation.py) et valeur de terrain retenue pour le sujet du cas 1 (ch. 20, 45).1415Usage (racine du dépôt) : PYTHONPATH=analysis/python python3 analysis/python/cases/ch25_terrain.py16Toutes les fonctions financières proviennent de analysis/python/traite (aucune réécriture)."""17from __future__ import annotations18import sys, json, pathlib19import numpy as np, pandas as pd2021ROOT = pathlib.Path(__file__).resolve().parents[3]22sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))23from traite import hedonique as hd, comparables as cp, finance as fin, cout as ct, cas1, cas32425D = ROOT / "data/synthetic"26CSV_TERRAINS = D / "ch25_terrains_ventes.csv"27GRAINE = 2026103128REF_DATE = pd.Timestamp("2024-07-01")29DATE_EVAL = pd.Timestamp("2026-09-01")3031# ----------------------------------------------------------------------------- 1. jeu synthétique de ventes de terrains32# Lois déclarées (hypothèses pédagogiques ; voir data/synthetic/README.md, section « Chapitre 25 »)33P_REF = 225_000.0 # prix du terrain de référence : 560 m², façade 18 m, desservi, plat, régulier, 1 logement, Hull, 2024-07-0134ELAST_T = 0.50 # élasticité du prix du terrain à la superficie (< 1 : valeur unitaire décroissante)35ELAST_F = 0.08 # élasticité à la façade (à superficie donnée)36B_NON_DESSERVI = -0.30 # ni aqueduc ni égout (puits + installation septique à la charge de l'acheteur)37B_PENTE = -0.06 # pente marquée38B_ROC = -0.12 # roc affleurant ou remblai important39B_IRREG = -0.05 # forme irrégulière (triangle, drapeau, trapèze prononcé)40B_LOGEMENT = 0.12 # par logement constructible supplémentaire (zonage H2/H3)41TENDANCE_MOIS = 0.0036 # même tendance que le coefficient « mois » du modèle hédonique du cas 1 (≈ 4,4 %/an)42SIGMA = 0.1043MEDIANES_APCIQ = {"Hull": 475_000, "Gatineau": 464_900, "Aylmer": 530_000, "Buckingham": 395_000, "Masson-Angers": 395_000 * 0.98}44PRIME_SECTEUR = {k: float(np.log(v / MEDIANES_APCIQ["Hull"])) for k, v in MEDIANES_APCIQ.items()}45CENTROIDES = {"Hull": (45.4296, -75.7450), "Gatineau": (45.4769, -75.6650), "Aylmer": (45.3936, -75.8450),46 "Buckingham": (45.5899, -75.4200), "Masson-Angers": (45.5468, -75.4150)}47N_PAR_SECTEUR = {"Hull": 70, "Gatineau": 60, "Aylmer": 40, "Buckingham": 15, "Masson-Angers": 15}4849def generer_terrains(graine: int = GRAINE) -> pd.DataFrame:50 """Ventes synthétiques de terrains vacants résidentiels (2023-01 → 2026-08). Aucun terrain réel."""51 rng = np.random.default_rng(graine)52 rows = []53 k = 054 for sct, n in N_PAR_SECTEUR.items():55 periurbain = sct in ("Buckingham", "Masson-Angers")56 for _ in range(n):57 k += 158 desservi = int(rng.random() < (0.30 if periurbain else 0.85))59 if desservi:60 T = float(np.clip(np.exp(rng.normal(np.log(600), 0.28)), 320, 2500))61 else:62 T = float(np.clip(np.exp(rng.normal(np.log(2200), 0.45)), 900, 9000))63 F = float(np.clip(0.60 * np.sqrt(T) * np.exp(rng.normal(0, 0.14)), 10, 90))64 topo = rng.choice(["plat", "pente", "roc"], p=[0.70, 0.20, 0.10])65 forme = rng.choice(["régulier", "irrégulier"], p=[0.80, 0.20])66 logements = int(rng.choice([1, 2, 3], p=[0.82, 0.12, 0.06])) if desservi and not periurbain else 167 jours = int(rng.integers(0, (pd.Timestamp("2026-08-31") - pd.Timestamp("2023-01-01")).days + 1))68 date = pd.Timestamp("2023-01-01") + pd.Timedelta(days=jours)69 mois = (date - REF_DATE).days / 30.437570 lat0, lon0 = CENTROIDES[sct]71 lat = lat0 + rng.normal(0, 0.012); lon = lon0 + rng.normal(0, 0.017)72 lnp = (np.log(P_REF) + ELAST_T * np.log(T / 560) + ELAST_F * np.log(F / 18) + B_NON_DESSERVI * (1 - desservi)73 + B_PENTE * (topo == "pente") + B_ROC * (topo == "roc") + B_IRREG * (forme == "irrégulier")74 + B_LOGEMENT * (logements - 1) + PRIME_SECTEUR[sct] + TENDANCE_MOIS * mois + rng.normal(0, SIGMA))75 prix = float(np.round(np.exp(lnp) / 500) * 500)76 rows.append({"id_vente": f"T{k:03d}", "date_vente": date.strftime("%Y-%m-%d"), "secteur": sct, "superficie_m2": round(T, 1),77 "facade_m": round(F, 2), "desservi": desservi, "topographie": topo, "forme": forme, "logements_constructibles": logements,78 "lat": round(lat, 5), "lon": round(lon, 5), "prix_vente": prix})79 df = pd.DataFrame(rows)80 df["prix_m2"] = df["prix_vente"] / df["superficie_m2"]81 return df8283def charger_terrains(regenerer: bool = False) -> pd.DataFrame:84 if regenerer or not CSV_TERRAINS.exists():85 df = generer_terrains(); df.to_csv(CSV_TERRAINS, index=False)86 df = pd.read_csv(CSV_TERRAINS, parse_dates=["date_vente"])87 return df8889# ----------------------------------------------------------------------------- 2. modèle hédonique des terrains90SECTEURS = ["Hull", "Gatineau", "Aylmer", "Buckingham", "Masson-Angers"]9192def matrice_terrains(df: pd.DataFrame) -> pd.DataFrame:93 X = pd.DataFrame(index=df.index)94 X["const"] = 1.095 X["ln_T"] = np.log(df["superficie_m2"] / 560.0)96 X["ln_F"] = np.log(df["facade_m"] / 18.0)97 X["non_desservi"] = 1.0 - df["desservi"].astype(float)98 X["pente"] = (df["topographie"] == "pente").astype(float)99 X["roc"] = (df["topographie"] == "roc").astype(float)100 X["irregulier"] = (df["forme"] == "irrégulier").astype(float)101 X["logements_sup"] = df["logements_constructibles"].astype(float) - 1.0102 for s in SECTEURS[1:]:103 X[f"sect_{s}"] = (df["secteur"] == s).astype(float)104 X["mois"] = ((pd.to_datetime(df["date_vente"]) - REF_DATE).dt.days / 30.4375).astype(float)105 return X106107def hedonique_terrains(df: pd.DataFrame) -> dict:108 X = matrice_terrains(df); y = np.log(df["prix_vente"].astype(float))109 r = hd.mco(X.values, y.values)110 coef = pd.DataFrame({"coef": r["beta"], "se_hc1": r["se_hc1"]}, index=X.columns)111 coef["t"] = coef["coef"] / coef["se_hc1"]; coef["effet_pct"] = np.exp(coef["coef"]) - 1112 return {"coef": coef, "beta": pd.Series(r["beta"], index=X.columns), "r2": r["r2"], "sigma": float(np.sqrt(r["sigma2"])), "n": r["n"],113 "residus": r["residus"], "X": X, "duan": float(np.mean(np.exp(r["residus"])))}114115def sujet_terrain() -> dict:116 """Le terrain du sujet du cas 1, réputé vacant : 620 m², Hull, desservi, plat, régulier, zonage unifamilial ; façade posée à 20 m (hypothèse)."""117 s = cas1.charger()["sujet"]118 return {"id_vente": "SUJET", "secteur": s["secteur"], "superficie_m2": float(s["superficie_terrain_m2"]), "facade_m": 20.0, "desservi": 1,119 "topographie": "plat", "forme": "régulier", "logements_constructibles": 1, "lat": s["lat"], "lon": s["lon"], "date_vente": DATE_EVAL}120121def predire_terrain(modele: dict, terrain: dict | pd.DataFrame, duan: bool = True) -> float:122 df = pd.DataFrame([terrain]) if isinstance(terrain, dict) else terrain123 X = matrice_terrains(df)[modele["X"].columns]124 return float(np.exp(X.values @ modele["beta"].values)[0] * (modele["duan"] if duan else 1.0))125126# ----------------------------------------------------------------------------- 3. comparaison directe127ECHELLES = {"superficie_m2": 100.0, "facade_m": 4.0, "dist_km": 1.5, "mois_anc": 6.0}128POIDS = {"superficie_m2": 2.0, "facade_m": 0.5, "dist_km": 1.5, "mois_anc": 1.0}129130def selection_terrains(df: pd.DataFrame, suj: dict, k: int = 5, mois_max: float = 24, dist_max: float = 4.0) -> dict:131 v = df.copy()132 v["mois_anc"] = (DATE_EVAL - v["date_vente"]).dt.days / 30.4375133 v["dist_km"] = np.sqrt(((v["lat"] - suj["lat"]) * 111) ** 2 + ((v["lon"] - suj["lon"]) * 78) ** 2)134 etapes = {"total": len(v)}135 f = v[v["mois_anc"].between(0, mois_max)]; etapes["fenetre"] = len(f)136 f = f[f["dist_km"] <= dist_max]; etapes["rayon"] = len(f)137 f = f[(f["desservi"] == 1) & (f["logements_constructibles"] == 1)]; etapes["desservi_unifamilial"] = len(f)138 f = f[f["superficie_m2"].between(0.6 * suj["superficie_m2"], 1.4 * suj["superficie_m2"])]; etapes["superficie"] = len(f)139 s0 = dict(suj); s0["mois_anc"] = 0.0; s0["dist_km"] = 0.0140 f = f.assign(distance=[cp.distance_attributs(s0, r, ECHELLES, POIDS) for r in f.to_dict("records")]).sort_values("distance")141 return {"etapes": etapes, "candidats": f, "comparables": f.head(k), "ventes": v}142143GROUPES = {"conditions_marche": ["mois"], "localisation": [f"sect_{s}" for s in SECTEURS[1:]], "superficie": ["ln_T"], "facade": ["ln_F"],144 "services": ["non_desservi"], "topographie": ["pente", "roc"], "forme": ["irregulier"], "densite": ["logements_sup"]}145146def grille_terrains(modele: dict, comps: pd.DataFrame, suj: dict) -> pd.DataFrame:147 """Ajustements en cascade dérivés du modèle hédonique des terrains (même logique que traite.cas1.ajustements_hedoniques) :148 A_k = base × [exp(β_k (x_s − x_c)) − 1], conditions du marché d'abord, puis localisation, puis caractéristiques physiques."""149 Xs = matrice_terrains(pd.DataFrame([suj])).iloc[0]; Xc = matrice_terrains(comps); beta = modele["beta"]150 rows = []151 for i, c in comps.iterrows():152 base = float(c["prix_vente"]); row = {"id_vente": c["id_vente"], "prix_vente": base}153 for g, cols in GROUPES.items():154 dlog = float(sum(beta[col] * (Xs[col] - Xc.loc[i, col]) for col in cols))155 adj = base * (np.exp(dlog) - 1.0); row[g] = adj; base += adj156 row["prix_ajuste"] = base157 row["ajust_brut_pct"] = sum(abs(row[g]) for g in GROUPES) / row["prix_vente"]158 row["ajust_net_pct"] = (base - row["prix_vente"]) / row["prix_vente"]159 rows.append(row)160 return pd.DataFrame(rows)161162def reconcilier(grille: pd.DataFrame) -> dict:163 objs = [cp.ComparableAjuste(r["id_vente"], r["prix_vente"], {g: r[g] for g in GROUPES}) for _, r in grille.iterrows()]164 w = cp.poids_inverse_brut(objs)165 return {**cp.reconciliation(objs, w), "objets": objs}166167def comparaison(df: pd.DataFrame | None = None) -> dict:168 df = charger_terrains() if df is None else df169 m = hedonique_terrains(df); suj = sujet_terrain(); sel = selection_terrains(df, suj)170 g = grille_terrains(m, sel["comparables"], suj); rec = reconcilier(g)171 pred = predire_terrain(m, suj)172 return {"terrains": df, "modele": m, "sujet": suj, "selection": sel, "grille": g, "reconciliation": rec, "prediction_hedonique": pred,173 "valeur_m2": rec["valeur_ponderee"] / suj["superficie_m2"]}174175# ----------------------------------------------------------------------------- 4. allocation et prix implicite marginal (cas 1)176C_N_SUJET = 397_567.33 # coût neuf du sujet du cas 1 (ch. 21, analysis/python/cases/ch21_cout_neuf.py)177ELAST_COUT_TOTAL = 0.90 # élasticité du coût total à la superficie (ch. 21 : ε = −0,10 sur le coût unitaire)178QUALITE_COUT = 0.10 # ±10 % de coût neuf par classe de qualité (hypothèse pédagogique)179VIE_ECONOMIQUE = 60.0 # variante « âge-vie » de l'extraction (convention déclarée ; le ch. 24 discute la vie économique)180181def cout_neuf_comparable(S: float, qualite: float) -> float:182 """Coût neuf d'un comparable transposé depuis le sujet : C_N(S, q) = C_N,sujet × (S/165)^0,90 × (1 + 0,10 (q − 3))."""183 return C_N_SUJET * (S / 165.0) ** ELAST_COUT_TOTAL * (1 + QUALITE_COUT * (qualite - 3))184185def facteur_age(modele_cas1: dict, age) -> np.ndarray:186 """Rapport prix à l'âge a / prix neuf selon le profil d'âge du modèle hédonique du cas 1 : exp(β_age a + β_age2 a²/100)."""187 b = modele_cas1["coef"]["coef"]188 a = np.asarray(age, float)189 return np.exp(b["age"] * a + b["age2_100"] * a ** 2 / 100.0)190191def extraction_cas1(cas: dict | None = None) -> dict:192 """Extraction sur les cinq comparables du chapitre 16 : V_L = P − C_N(1 − δ), sous deux conventions de dépréciation :193 (a) âge-vie linéaire (vie 60 ans) ; (b) dépréciation implicite du marché (profil d'âge hédonique) : V_L = P_neuf − C_N."""194 cas = cas or cas1.tout()195 comps = cas["selection"]["comparables"].copy(); m = cas["modele"]196 comps["C_N"] = cout_neuf_comparable(comps["superficie_habitable_m2"], comps["qualite_1a5"])197 comps["delta_agevie"] = comps["age"] / VIE_ECONOMIQUE198 comps["V_L_agevie"] = comps["prix_vente"] - comps["C_N"] * (1 - comps["delta_agevie"])199 f = facteur_age(m, comps["age"]); comps["P_neuf"] = comps["prix_vente"] / f200 comps["delta_marche"] = (comps["P_neuf"] - comps["prix_vente"]) / comps["C_N"]201 comps["V_L_marche"] = comps["P_neuf"] - comps["C_N"]202 for k in ("agevie", "marche"):203 comps[f"V_L_{k}_m2"] = comps[f"V_L_{k}"] / comps["superficie_terrain_m2"]204 # transposition au terrain du sujet (620 m²) avec l'élasticité de superficie du marché des terrains205 comps[f"V_L_{k}_sujet"] = comps[f"V_L_{k}"] * (620.0 / comps["superficie_terrain_m2"]) ** ELAST_T206 # Hull, toutes ventes : part du terrain par extraction (dépréciation de marché)207 v = cas["ventes"]; h = v[v["secteur"] == "Hull"].copy()208 h["C_N"] = cout_neuf_comparable(h["superficie_habitable_m2"], h["qualite_1a5"])209 h["V_L"] = h["prix_vente"] / facteur_age(m, h["age"]) - h["C_N"]; h["part"] = h["V_L"] / h["prix_vente"]; h["V_L_m2"] = h["V_L"] / h["superficie_terrain_m2"]210 return {"comparables": comps, "hull": h, "part_hull_mediane": float(h["part"].median()), "part_hull_q1": float(h["part"].quantile(0.25)),211 "part_hull_q3": float(h["part"].quantile(0.75)), "V_L_hull_mediane": float(h["V_L"].median()),212 "indication_agevie": float(comps["V_L_agevie_sujet"].median()), "indication_marche": float(comps["V_L_marche_sujet"].median())}213214def allocation_cas1(cas: dict | None = None, extr: dict | None = None) -> dict:215 cas = cas or cas1.tout(); extr = extr or extraction_cas1(cas)216 a = extr["part_hull_mediane"]217 P_grille = float(cas["ajustements"]["prix_ajuste"].mean()) # moyenne des prix ajustés des 5 comparables (ch. 17)218 P_hed = float(cas["prediction_hedonique"]) # prédiction hédonique du sujet (ch. 18)219 b = cas["modele"]["coef"]["coef"]; T = 620.0220 return {"ratio": a, "sur_grille": a * P_grille, "sur_hedonique": a * P_hed, "P_grille": P_grille, "P_hed": P_hed,221 "beta_ln_T": float(b["ln_T"]), "prix_marginal_m2": float(b["ln_T"] * P_hed / T), "contribution_marginale": float(b["ln_T"] * P_hed),222 "sujet_age_factor": float(facteur_age(cas["modele"], 28)), "delta_marche_sujet": float((P_hed / facteur_age(cas["modele"], 28) - P_hed) / C_N_SUJET)}223224# ----------------------------------------------------------------------------- 5. technique résiduelle du terrain (cas 3)225RES = {"cout_unitaire_neuf": 2500.0, "superficie_brute_m2": 950.0, "taux_depreciation": 0.52, "R_L": 0.0375, "n_restante": 40, "terrain_m2": 740.0}226227def residuelle_cas3(params: dict | None = None, t3: dict | None = None) -> dict:228 p = {**RES, **(params or {})}; t3 = t3 or cas3.tout()229 noi = float(t3["base"]["rne"]); R = float(t3["taux"]["mediane"]); V_cap = float(t3["valeur_capitalisation"])230 C_N = p["cout_unitaire_neuf"] * p["superficie_brute_m2"]; C_B = C_N * (1 - p["taux_depreciation"])231 R_B = p["R_L"] + 1.0 / p["n_restante"]232 V_L = (noi - R_B * C_B) / p["R_L"]233 R_L_implicite = (noi - R_B * C_B) / (V_cap - C_B) if V_cap > C_B else float("nan")234 return {"noi": noi, "R": R, "V_cap": V_cap, "C_N": C_N, "C_B": C_B, "R_L": p["R_L"], "R_B": R_B, "revenu_batiment": R_B * C_B,235 "revenu_terrain": noi - R_B * C_B, "V_L": V_L, "V_L_m2": V_L / p["terrain_m2"], "somme": V_L + C_B, "ecart_vs_cap": (V_L + C_B) / V_cap - 1,236 "R_L_implicite": R_L_implicite, "part_terrain": V_L / (V_L + C_B), "params": p}237238def sensibilite_residuelle(t3: dict | None = None, R_Ls=(0.0325, 0.035, 0.0375, 0.04, 0.0425), ns=(30, 40, 50), deltas=(0.44, 0.52, 0.60)) -> pd.DataFrame:239 t3 = t3 or cas3.tout(); rows = []240 for d in deltas:241 for n in ns:242 for rl in R_Ls:243 r = residuelle_cas3({"taux_depreciation": d, "n_restante": n, "R_L": rl}, t3)244 rows.append({"taux_depreciation": d, "C_B": r["C_B"], "n_restante": n, "R_L": rl, "R_B": r["R_B"], "V_L": r["V_L"], "V_L_m2": r["V_L_m2"]})245 return pd.DataFrame(rows)246247# ----------------------------------------------------------------------------- 6. lotissement (développement actualisé)248LOT = {"site_m2": 10_000.0, "n_lots": 12, "lot_m2": 625.0, "rue_m2": 1500.0, "parc_m2": 1000.0, "facade_m": 20.0,249 "infrastructure_par_lot": 65_000.0, "honoraires_permis": 90_000.0, "taxes_annuelles": 12_000.0, "frais_vente_pct": 0.04,250 "profit_pct": 0.15, "lots_par_an": 4, "taux": 0.10, "croissance_prix": 0.0}251252def lotissement(modele_terrains: dict, params: dict | None = None) -> dict:253 """Valeur d'un site brut par la méthode du lotissement actualisé (subdivision development method).254 Produit des ventes de lots finis au prix prédit par le modèle hédonique des terrains à la date d'évaluation, moins les coûts255 de viabilisation, d'honoraires, de portage, de vente et le profit du promoteur ; flux de fin d'année actualisés au taux déclaré."""256 p = {**LOT, **(params or {})}257 assert abs(p["n_lots"] * p["lot_m2"] + p["rue_m2"] + p["parc_m2"] - p["site_m2"]) < 1e-6258 lot = {"secteur": "Hull", "superficie_m2": p["lot_m2"], "facade_m": p["facade_m"], "desservi": 1, "topographie": "plat", "forme": "régulier",259 "logements_constructibles": 1, "date_vente": DATE_EVAL}260 prix_lot = predire_terrain(modele_terrains, lot)261 n_ans = int(np.ceil(p["n_lots"] / p["lots_par_an"]))262 flux, detail = [], []263 # t = 0 : honoraires, permis, études264 f0 = -p["honoraires_permis"]; flux.append(f0); detail.append({"annee": 0, "lots_vendus": 0, "revenus": 0.0, "infrastructure": 0.0, "honoraires": p["honoraires_permis"],265 "taxes": 0.0, "frais_vente": 0.0, "profit": 0.0, "flux": f0})266 restants = p["n_lots"]267 for t in range(1, n_ans + 1):268 vendus = min(p["lots_par_an"], restants); restants -= vendus269 rev = vendus * prix_lot * (1 + p["croissance_prix"]) ** (t - 1)270 infra = p["infrastructure_par_lot"] * p["n_lots"] if t == 1 else 0.0271 taxes = p["taxes_annuelles"] * (restants + vendus) / p["n_lots"]272 fv = p["frais_vente_pct"] * rev; prof = p["profit_pct"] * rev273 f = rev - infra - taxes - fv - prof274 flux.append(f); detail.append({"annee": t, "lots_vendus": vendus, "revenus": rev, "infrastructure": infra, "honoraires": 0.0, "taxes": taxes,275 "frais_vente": fv, "profit": prof, "flux": f})276 V = fin.van(flux, p["taux"])277 statique = sum(d["flux"] for d in detail)278 return {"prix_lot": prix_lot, "produit_brut": prix_lot * p["n_lots"], "flux": flux, "detail": pd.DataFrame(detail), "valeur_actualisee": V,279 "valeur_statique": statique, "valeur_m2": V / p["site_m2"], "valeur_statique_m2": statique / p["site_m2"], "n_ans": n_ans,280 "ratio_terrain_brut_sur_produit": V / (prix_lot * p["n_lots"]), "params": p}281282def sensibilite_lotissement(modele_terrains: dict) -> dict:283 base = lotissement(modele_terrains)["valeur_actualisee"]284 out = {"base": base}285 out["absorption"] = {k: lotissement(modele_terrains, {"lots_par_an": k})["valeur_actualisee"] for k in (2, 3, 4, 6, 12)}286 out["taux"] = {r: lotissement(modele_terrains, {"taux": r})["valeur_actualisee"] for r in (0.08, 0.10, 0.12, 0.15)}287 out["prix"] = {}288 for d in (-0.10, 0.0, 0.10):289 m2 = dict(modele_terrains); m2["duan"] = modele_terrains["duan"] * (1 + d)290 out["prix"][d] = lotissement(m2)["valeur_actualisee"]291 out["infrastructure"] = {c: lotissement(modele_terrains, {"infrastructure_par_lot": c})["valeur_actualisee"] for c in (50_000, 65_000, 80_000)}292 out["profit"] = {c: lotissement(modele_terrains, {"profit_pct": c})["valeur_actualisee"] for c in (0.10, 0.15, 0.20)}293 return out294295# ----------------------------------------------------------------------------- 7. capitalisation de la rente foncière296def rente_fonciere(rente: float = 11_000.0, R_L: float = 0.05, g: float = 0.02, n: int = 40, valeur_reversion: float = 300_000.0) -> dict:297 """Terrain loué (emphytéose ou bail foncier) : V_L = rente/R_L (rente constante perpétuelle) ; V_L = rente_1/(R_L − g) (Gordon) ;298 bail de n ans à rente croissante + réversion actualisée."""299 perp = rente / R_L300 gordon = rente / fin.taux_gordon(R_L, g)301 va_rentes = sum(rente * (1 + g) ** (t - 1) / (1 + R_L) ** t for t in range(1, n + 1))302 va_rev = valeur_reversion / (1 + R_L) ** n303 return {"perpetuelle": perp, "gordon": gordon, "bail_n": va_rentes + va_rev, "va_rentes": va_rentes, "va_reversion": va_rev, "n": n}304305# ----------------------------------------------------------------------------- 8. rapprochement avec le ch. 24, synthèse et valeur retenue306def extraction_recente_ch24(cas: dict | None = None) -> dict:307 """Valeur du terrain extraite par le ch. 24 (analysis/python/cases/ch24_depreciation.py) : médiane de V_L = P_adj − C_N(1 − âge/60)308 sur les ventes récentes (≤ 10 ans) du secteur Hull, prix ramenés à la date d'évaluation. Appel direct du module du ch. 24 (cohérence)."""309 import ch24_depreciation as c24310 cas = cas or cas1.tout()311 w, vl = c24.extraction(cas["ventes"], cas["modele"], C_N_SUJET)312 r = c24.valeur_terrain_retenue(vl, "Hull")313 return {"retenue": float(r["retenue"]), "mediane": float(r["mediane"]), "n": int(r["n"]), "part": float(r["part"]),314 "vl_par_m2": float(vl.loc["Hull", "vl_par_m2"]), "table": vl}315316def synthese() -> dict:317 df = charger_terrains(); comp = comparaison(df); cas = cas1.tout(); extr = extraction_cas1(cas); allo = allocation_cas1(cas, extr)318 t3 = cas3.tout(); res = residuelle_cas3(t3=t3); lot = lotissement(comp["modele"]); rf = rente_fonciere(); c24 = extraction_recente_ch24(cas)319 V_comp = comp["reconciliation"]["valeur_ponderee"]; se_comp = comp["reconciliation"]["ecart_type_pondere"]320 indications = {"comparaison": V_comp, "extraction_ch24": c24["retenue"], "allocation_grille": allo["sur_grille"]}321 mediane_trois = float(np.median(list(indications.values())))322 # Valeur retenue : celle du ch. 24 (277 000 $), confirmée si elle se situe à moins de deux écarts-types pondérés de la comparaison directe323 retenue = c24["retenue"] if abs(c24["retenue"] - V_comp) <= 2 * se_comp else round(V_comp / 1000) * 1000324 rapproch = {"ecart_comp_pct": V_comp / retenue - 1, "ecart_alloc_pct": allo["sur_grille"] / retenue - 1, "ecart_extr_marche_pct": extr["indication_marche"] / retenue - 1,325 "ecart_hed_pct": comp["prediction_hedonique"] / retenue - 1, "z_comp": (c24["retenue"] - V_comp) / se_comp, "mediane_trois": mediane_trois,326 "confirmee": retenue == c24["retenue"]}327 return {"comparaison": comp, "cas1": cas, "extraction": extr, "allocation": allo, "cas3": t3, "residuelle": res, "lotissement": lot, "rente": rf,328 "ch24": c24, "indications": indications, "rapprochement": rapproch, "valeur_retenue": retenue,329 "part_retenue_grille": retenue / allo["P_grille"], "part_retenue_hed": retenue / allo["P_hed"]}330331def _d(x): return f"{x:,.0f} $".replace(",", " ")332333def main() -> dict:334 S = synthese(); c = S["comparaison"]; m = c["modele"]; df = c["terrains"]335 print("=== 1. Jeu synthétique de terrains (ch25_terrains_ventes.csv, graine 20261031) ===")336 print(f"n = {len(df)} ; desservis = {int(df.desservi.sum())} ; période {df.date_vente.min().date()} → {df.date_vente.max().date()}")337 print(df.groupby("secteur").agg(n=("prix_vente", "size"), T_med=("superficie_m2", "median"), prix_med=("prix_vente", "median"), prix_m2_med=("prix_m2", "median")).round(0))338 print(df.groupby("desservi")[["superficie_m2", "prix_vente", "prix_m2"]].median().round(0))339 print("\n=== 2. Modèle hédonique des terrains ===")340 print(m["coef"].round(4)); print(f"R² = {m['r2']:.3f} ; σ = {m['sigma']:.3f} ; Duan = {m['duan']:.4f}")341 print("\n=== 3. Comparaison directe — sujet 620 m², Hull ===")342 print("Entonnoir :", c["selection"]["etapes"])343 comps = c["selection"]["comparables"]344 print(comps[["id_vente", "date_vente", "secteur", "superficie_m2", "facade_m", "topographie", "forme", "dist_km", "mois_anc", "prix_vente", "prix_m2", "distance"]].round(2).to_string())345 print(c["grille"].round(0).to_string())346 r = c["reconciliation"]347 print(f"Poids (inverse du brut) : {np.round(r['poids'], 3)} ; valeur pondérée {_d(r['valeur_ponderee'])} ; moyenne {_d(r['moyenne'])} ; médiane {_d(r['mediane'])} ; "348 f"min {_d(r['min'])} ; max {_d(r['max'])} ; étendue {r['etendue_pct']*100:.1f} % ; {_d(c['valeur_m2'])}/m²")349 print(f"Prédiction hédonique directe du terrain du sujet : {_d(c['prediction_hedonique'])}")350 print("\n=== 4. Allocation et prix implicite marginal (cas 1) ===")351 a = S["allocation"]; e = S["extraction"]352 print(f"Part du terrain, ventes de Hull (extraction, dépréciation de marché) : médiane {a['ratio']*100:.1f} % (Q1 {e['part_hull_q1']*100:.1f} %, Q3 {e['part_hull_q3']*100:.1f} %) ; V_L médian Hull {_d(e['V_L_hull_mediane'])}")353 print(f"Allocation sur la grille du ch. 17 ({_d(a['P_grille'])}) : {_d(a['sur_grille'])} ; sur la prédiction hédonique ({_d(a['P_hed'])}) : {_d(a['sur_hedonique'])}")354 print(f"β(ln T) = {a['beta_ln_T']:.4f} → prix implicite marginal {a['prix_marginal_m2']:.1f} $/m² ; contribution marginale β × P = {_d(a['contribution_marginale'])}")355 print(f"Facteur d'âge du sujet (28 ans) : {a['sujet_age_factor']:.4f} → dépréciation implicite {a['delta_marche_sujet']*100:.1f} % de C_N")356 print("\n=== 5. Extraction sur les 5 comparables du ch. 16 ===")357 cols = ["id_vente", "prix_vente", "superficie_habitable_m2", "superficie_terrain_m2", "age", "qualite_1a5", "C_N", "delta_agevie", "V_L_agevie", "V_L_agevie_m2",358 "P_neuf", "delta_marche", "V_L_marche", "V_L_marche_m2", "V_L_agevie_sujet", "V_L_marche_sujet"]359 print(e["comparables"][cols].round(3).to_string())360 print(f"Indication (médiane transposée à 620 m²) : âge-vie 60 ans {_d(e['indication_agevie'])} ; dépréciation de marché {_d(e['indication_marche'])}")361 print("\n=== 6. Technique résiduelle du terrain (cas 3) ===")362 rs = S["residuelle"]363 print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in rs.items() if k != "params"})364 sens = sensibilite_residuelle(S["cas3"]); print(sens.pivot_table(index=["taux_depreciation", "n_restante"], columns="R_L", values="V_L").round(0))365 print("\n=== 7. Lotissement actualisé ===")366 L = S["lotissement"]; print(f"Prix d'un lot fini (625 m², Hull, {DATE_EVAL.date()}) : {_d(L['prix_lot'])} ; produit brut {_d(L['produit_brut'])}")367 print(L["detail"].round(0).to_string())368 print(f"Valeur actualisée du site brut : {_d(L['valeur_actualisee'])} ({L['valeur_m2']:.1f} $/m²) ; statique {_d(L['valeur_statique'])} ({L['valeur_statique_m2']:.1f} $/m²) ; "369 f"ratio terrain brut / produit {L['ratio_terrain_brut_sur_produit']*100:.1f} %")370 sl = sensibilite_lotissement(m)371 for k, v in sl.items():372 if k != "base": print(k, {kk: round(vv) for kk, vv in v.items()})373 print("\n=== 8. Rente foncière ===")374 print({k: round(v, 0) if isinstance(v, float) else v for k, v in S["rente"].items()})375 print("\n=== 9. Rapprochement avec le ch. 24 et synthèse — sujet du cas 1 ===")376 c24 = S["ch24"]; rp = S["rapprochement"]377 print(f"Ch. 24 (extraction, {c24['n']} ventes récentes de Hull, âge-vie 60) : médiane {_d(c24['mediane'])} → retenue {_d(c24['retenue'])} ; part médiane {c24['part']*100:.1f} % ; {c24['vl_par_m2']:.0f} $/m² (terrain médian)")378 print(f"Comparaison (terrains) {_d(r['valeur_ponderee'])} ; extraction (marché) {_d(e['indication_marche'])} ; extraction (âge-vie 60) {_d(e['indication_agevie'])} ; "379 f"allocation (grille) {_d(a['sur_grille'])} ; allocation (hédonique) {_d(a['sur_hedonique'])}")380 print(f"Écarts à la valeur du ch. 24 : comparaison {rp['ecart_comp_pct']*100:+.1f} % ({rp['z_comp']:.2f} écart-type pondéré) ; allocation {rp['ecart_alloc_pct']*100:+.1f} % ; "381 f"extraction marché {rp['ecart_extr_marche_pct']*100:+.1f} % ; hédonique terrains {rp['ecart_hed_pct']*100:+.1f} % ; médiane des trois indications {_d(rp['mediane_trois'])} ; confirmée : {rp['confirmee']}")382 print(f"VALEUR DU TERRAIN RETENUE : {_d(S['valeur_retenue'])} ({S['valeur_retenue']/620:.0f} $/m²) ; part de la grille {S['part_retenue_grille']*100:.1f} % ; part de la prédiction hédonique {S['part_retenue_hed']*100:.1f} %")383 return S384385if __name__ == "__main__":386 main()387