spb/traite-evaluation-qc
Public
1"""Chapitre 46 — Cas 2 : triplex à Québec (Limoilou), dossier complet (DONNÉES SYNTHÉTIQUES, graine 20261031).23Tout chiffre cité dans manuscript/chapters/ch46.tex et solutions/ch46.tex provient de ce script. Il s'appuie sur :4- traite.cas2 (revenus, RNE, 127 ventes de plex, indicateurs) — partagé avec les ch. 26, 27, 28 et 32 ;5- analysis/python/cases/ch32_plex.py (sélection des cinq comparables triplex de Limoilou, régression taux/loyer relatif,6 financement résidentiel assuré, coût d'occupation) — importé, jamais recalculé autrement ;7- traite.finance (Hypotheque, capitalisation_directe, rne), traite.comparables (ComparableAjuste, poids_inverse_brut,8 reconciliation, ajustement_temps_taux), traite.cout (cout_par_superficie_ajuste, depreciation_age_vie, valeur_par_cout).910Ce que le chapitre 46 AJOUTE au chapitre 32 : normalisation des taxes aux tarifs 2026 de la Ville de Québec (RNE normalisé),11grille d'ajustements explicites en $/logement sur les cinq comparables (régression auxiliaire sur les 127 ventes), indication12par le coût (contrôle), droits de mutation de la Ville de Québec, valeur de la faculté de reprise, réconciliation et rapport.1314Exécution : python3 analysis/python/cases/ch46_dossier.py (depuis la racine du dépôt)15"""16from __future__ import annotations17import sys, pathlib18import numpy as np, pandas as pd1920ROOT = pathlib.Path(__file__).resolve().parents[3]21sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases"))22from traite import cas2, finance as fin, comparables as cmp, cout # noqa: E40223import ch32_plex as C # noqa: E4022425GRAINE = 2026103126DATE_EVAL = "2026-09-01"2728# ----------------------------------------------------------------------------- paramètres déclarés (sources : notes-E, notes-B, notes-C, ch. 21/24)29TAUX_TAXE_1A5 = 0.7464 / 100 # Ville de Québec 2026, résidentiel 1 à 5 logements, $/100 $ (R.V.Q. 3492 ; SRC-E-027)30TARIF_AQUEDUC_EGOUT = 386.0 # $/logement (idem)31TARIF_MATIERES_RESIDUELLES = 195.0 # $/logement (idem)32TAUX_TAXE_SCOLAIRE = 0.07899 / 100 # 2026-2027, $/100 $ (SRC-E-026) ; exemption des premiers 25 000 $33EXEMPTION_SCOLAIRE = 25_000.034FACTEUR_COMPARATIF_2026 = 1.08 # Ville de Québec, facteur comparatif 2026 (source secondaire, SRC-A-023)35# Droits sur les mutations immobilières, Ville de Québec, 2026 (LDMI art. 2 + taux municipaux ; SRC-A-023, partiellement vérifié)36TRANCHES_MUTATION_QUEBEC = [(62_900.0, 0.005), (315_000.0, 0.01), (500_000.0, 0.015), (750_000.0, 0.025), (float("inf"), 0.03)]37# Coût (contrôle seulement) : coût unitaire du cas 1 au T3 2026 (ch. 21, 2 409 $/m² habitable pour 165 m²), élasticité de taille −0,10,38# ratio IPCB « appartements < 5 étages » / « maison individuelle », Montréal, T3 2026 (118,5 / 116,5 ; tableau 18-10-0289-01)39COUT_UNITAIRE_CAS1, SUPERFICIE_CAS1, ELASTICITE = 2409.0, 165.0, -0.1040IPCB_APPART, IPCB_MAISON = 118.5, 116.541VIE_ECONOMIQUE = 90.0 # hypothèse du dossier : plex de brique de 1948, vie économique totale42PHI_ETAT3 = 0.5 # fraction de vie consommée associée à la note d'état 3 (convention ch. 11/24)43TERRAIN_PAR_M2 = 900.0 # HYPOTHÈSE DU DOSSIER (non vérifiée) : valeur unitaire du terrain, Limoilou44POIDS_46 = {"par_logement_ajuste": 0.40, "mrb": 0.35, "capitalisation": 0.25} # pondération de l'ouvrage pour un triplex de quartier central (ch. 32, sect. 32.4)45T_MIN = 2.0 # seuil de significativité (|t|) pour appliquer un coefficient de la régression auxiliaire dans la grille46PAS_ARRONDI = 5_000.047ROTATION_QUEBEC = 0.13 # taux de rotation des locataires, RMR de Québec, SCHL oct. 2025 (SRC-E-007)48G_MARCHE, G_TAL, R_ACT = 0.035, 0.031, 0.06 # croissance des loyers de marché, pourcentage de base TAL 2026, taux d'actualisation (hypothèses, ch. 26/32)495051# ----------------------------------------------------------------------------- 46.5 normalisation des taxes et RNE52def taxes_2026(valeur_role: float, nb_logements: int) -> dict:53 """Taxes municipales (taux 1-5 logements + tarifs par logement) et taxe scolaire (exemption des premiers 25 000 $) sur la valeur au rôle."""54 fonciere = valeur_role * TAUX_TAXE_1A555 tarifs = nb_logements * (TARIF_AQUEDUC_EGOUT + TARIF_MATIERES_RESIDUELLES)56 scolaire = max(valeur_role - EXEMPTION_SCOLAIRE, 0.0) * TAUX_TAXE_SCOLAIRE57 return {"fonciere": fonciere, "tarifs": tarifs, "municipale": fonciere + tarifs, "scolaire": scolaire, "total": fonciere + tarifs + scolaire}585960def depenses_normalisees_2026(dep: pd.DataFrame, sujet: dict) -> pd.DataFrame:61 """Dépenses du dossier avec les taxes recalculées aux taux 2026 (les autres postes sont conservés : polices, contrats, factures)."""62 t = taxes_2026(sujet["valeur_role_2025"], sujet["nb_logements"])63 d = dep.copy(); d["declare"] = d["montant_annuel"].astype(float); d["montant_annuel"] = d["montant_annuel"].astype(float)64 d.loc[d["poste"] == "Taxes municipales", "montant_annuel"] = t["municipale"]65 d.loc[d["poste"] == "Taxe scolaire", "montant_annuel"] = t["scolaire"]66 return d676869def base_normalisee(d: dict) -> dict:70 """RNE du dossier (taxes 2026) par traite.cas2.rne, et RNE « déclaré » (ch. 32) pour mémoire."""71 dep_n = depenses_normalisees_2026(d["depenses"], d["sujet"])72 b = cas2.rne(d["loyers"], dep_n)73 b["detail"]["declare"] = dep_n["declare"].values74 b_decl = cas2.rne(d["loyers"], d["depenses"])75 b["rne_declare"] = b_decl["rne"]; b["depenses_declarees"] = b_decl["depenses_totales"]76 b["ecart_taxes"] = b_decl["depenses_totales"] - b["depenses_totales"]77 b["taxes"] = taxes_2026(d["sujet"]["valeur_role_2025"], d["sujet"]["nb_logements"])78 # RNE aux loyers de marché (même structure de dépenses, gestion imputée sur le RBE de marché)79 det = b["detail"]; dep_m = float(det.loc[~det["type"].isin(["imputée", "réserve"]), "normalise"].sum()) + cas2.GESTION_IMPUTEE * b["rbe_marche"]80 b["rne_marche"] = fin.rne(b["rbe_marche"], dep_m, b["reserve"])81 b["loyer_marche_moyen"] = float(d["loyers"]["loyer_marche_estime"].mean()); b["loyer_contrat_moyen"] = float(d["loyers"]["loyer_mensuel"].mean())82 b["rho"] = b["rbp_contrat"] / b["rbp_marche"]83 return b848586# ----------------------------------------------------------------------------- 46.6 régression auxiliaire et grille d'ajustements87VARS = ["mois", "ln_sup_log", "age", "etat_1a5", "stationnement", "ln_loyer_marche", "loyer_relatif", "triplex", "q_Saint-Sauveur", "q_Saint-Roch", "q_Maizerets"]888990def _matrice(c: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]:91 g = c.copy()92 g["ln_sup_log"] = np.log(g["superficie_habitable_m2"] / g["nb_logements"])93 g["ln_loyer_marche"] = np.log(g["loyer_mensuel_moyen_marche"])94 g["loyer_relatif"] = g["loyer_mensuel_moyen_contrat"] / g["loyer_mensuel_moyen_marche"]95 g["triplex"] = (g["nb_logements"] == 3).astype(float)96 for q in ("Saint-Sauveur", "Saint-Roch", "Maizerets"): g[f"q_{q}"] = (g["quartier"] == q).astype(float)97 X = np.column_stack([np.ones(len(g))] + [g[v].to_numpy(dtype=float) for v in VARS])98 y = np.log(g["prix_par_logement"].to_numpy(dtype=float))99 return X, y100101102def regression_auxiliaire(c: pd.DataFrame) -> dict:103 """OLS : ln(prix par logement) sur l'ancienneté de la vente (mois), ln(m² par logement), l'âge, l'état, le stationnement,104 ln(loyer de marché moyen), le loyer relatif, un indicateur triplex et les quartiers (référence Limoilou). Erreurs-types classiques."""105 X, y = _matrice(c); n, k = X.shape106 beta, *_ = np.linalg.lstsq(X, y, rcond=None); res = y - X @ beta107 s2 = float(res @ res) / (n - k); cov = s2 * np.linalg.inv(X.T @ X)108 se = np.sqrt(np.diag(cov)); r2 = 1 - float(res @ res) / float(((y - y.mean()) ** 2).sum())109 noms = ["const"] + VARS110 return {"beta": dict(zip(noms, beta)), "se": dict(zip(noms, se)), "t": dict(zip(noms, beta / se)), "r2": r2, "sigma": float(np.sqrt(s2)), "n": n, "k": k,111 "taux_annuel_marche": float(np.exp(-12 * beta[1]) - 1)} # croissance annuelle implicite des prix (mois = ancienneté de la vente)112113114def grille_ajustements(comp5: pd.DataFrame, sujet: dict, base: dict, reg: dict) -> dict:115 """Grille d'ajustements en $ par logement, ordre de l'ouvrage : conditions du marché (temps, %) puis attributs (additifs, $) :116 superficie par logement, âge, état, stationnement, niveau de loyer de marché, loyer relatif. Signe du point de vue du sujet."""117 b = reg["beta"]; n_s = sujet["nb_logements"]118 s = {"ln_sup_log": np.log(sujet["superficie_habitable_m2"] / n_s), "age": sujet["age"], "etat_1a5": sujet["etat_1a5"], "stationnement": sujet["stationnement"],119 "ln_loyer_marche": np.log(base["loyer_marche_moyen"]), "loyer_relatif": base["rho"]}120 g_annuel = reg["taux_annuel_marche"]121 comps, lignes = [], []122 for _, r in comp5.iterrows():123 p = float(r["prix_par_logement"])124 a_t = cmp.ajustement_temps_taux(p, g_annuel, float(r["mois"])) # conditions du marché125 p_t = p + a_t126 x = {"ln_sup_log": np.log(r["superficie_habitable_m2"] / r["nb_logements"]), "age": float(r["age"]), "etat_1a5": float(r["etat_1a5"]),127 "stationnement": float(r["stationnement"]), "ln_loyer_marche": np.log(r["loyer_mensuel_moyen_marche"]),128 "loyer_relatif": r["loyer_mensuel_moyen_contrat"] / r["loyer_mensuel_moyen_marche"]}129 aj = {"temps": a_t}130 for v, lib in (("ln_sup_log", "superficie"), ("age", "age"), ("etat_1a5", "etat"), ("stationnement", "stationnement"), ("ln_loyer_marche", "loyer_marche"), ("loyer_relatif", "loyer_relatif")):131 # seuls les coefficients significatifs (|t| >= T_MIN) sont appliqués ; le loyer relatif (non significatif) est traité par le taux et le multiplicateur132 aj[lib] = p_t * (np.exp(b[v] * (s[v] - x[v])) - 1.0) if abs(reg["t"][v]) >= T_MIN else 0.0133 ca = cmp.ComparableAjuste(r["id_vente"], p, aj); comps.append(ca)134 lignes.append({"id_vente": r["id_vente"], "date_vente": r["date_vente"].date().isoformat(), "mois": float(r["mois"]), "prix_vente": float(r["prix_vente"]),135 "prix_logement": p, **aj, "prix_ajuste": ca.prix_ajuste, "brut_pct": ca.brut_pct, "net_pct": ca.net_pct})136 poids = cmp.poids_inverse_brut(comps); rec = cmp.reconciliation(comps, poids)137 tab = pd.DataFrame(lignes); tab["poids"] = poids138 se_log = rec["ecart_type_pondere"] * float(np.sqrt(sum(w * w for w in poids))) # erreur-type de la moyenne pondérée des prix ajustés139 return {"comparables": comps, "tableau": tab, "reconciliation": rec, "poids": poids, "g_annuel": g_annuel,140 "valeur_par_logement": rec["valeur_ponderee"], "valeur": rec["valeur_ponderee"] * n_s,141 "moyenne_simple": rec["moyenne"] * n_s, "ecart_type_valeur": rec["ecart_type_pondere"] * n_s, "se_valeur": se_log * n_s}142143144# ----------------------------------------------------------------------------- 46.7 revenu145def indications_revenu(base: dict, ind: dict, comp5: pd.DataFrame, reg_taux: dict, valeur_comparaison: float, mrb_comp: float) -> dict:146 rne = base["rne"]; rbp = base["rbp_contrat"]147 taux_ajuste = reg_taux["a"] + reg_taux["b_rho"] * (base["rho"] - 1.0)148 taux_comp = float(comp5["taux_global"].median())149 out = {"taux_median_127": ind["taux_median"], "taux_q1": ind["taux_q1"], "taux_q3": ind["taux_q3"], "taux_ajuste": taux_ajuste, "taux_comp": taux_comp,150 "v_cap_median": fin.capitalisation_directe(rne, ind["taux_median"]), "v_cap_ajuste": fin.capitalisation_directe(rne, taux_ajuste),151 "v_cap_comp": fin.capitalisation_directe(rne, taux_comp), "v_cap_q1": fin.capitalisation_directe(rne, ind["taux_q1"]), "v_cap_q3": fin.capitalisation_directe(rne, ind["taux_q3"]),152 "v_cap_ajuste_rne_declare": fin.capitalisation_directe(base["rne_declare"], taux_ajuste),153 "mrb_comp": mrb_comp, "v_mrb_comp": mrb_comp * rbp, "mrb_127": ind["mrb_median_triplex"], "v_mrb_127": ind["mrb_median_triplex"] * rbp,154 "taux_implicite_comparaison": rne / valeur_comparaison, "mrb_implicite_comparaison": valeur_comparaison / rbp,155 "taux_implicite_marche": base["rne_marche"] / valeur_comparaison}156 # sensibilité : multiplicateur des comparables ramené au loyer relatif du sujet par la pente de la régression ln MRB (ch. 32)157 rho_j = comp5["loyer_mensuel_moyen_contrat"] / comp5["loyer_mensuel_moyen_marche"]158 mrb_aj = comp5["mrb"] * np.exp(reg_taux["mrb_b_rho"] * (base["rho"] - rho_j))159 out["mrb_comp_ajuste_rho"] = float(mrb_aj.median()); out["v_mrb_comp_ajuste_rho"] = out["mrb_comp_ajuste_rho"] * rbp160 # lecture « taux implicite » : écart entre le taux ajusté et le taux implicite du prix de comparaison, en points de base161 out["ecart_pb"] = (out["taux_ajuste"] - out["taux_implicite_comparaison"]) * 1e4162 # ratio de dépenses du sujet vs médiane des ventes : part de l'écart capitalisation/comparaison expliquée par la structure de dépenses163 return out164165166# ----------------------------------------------------------------------------- 46.8 coût (contrôle)167def indication_cout(sujet: dict, terrain_par_m2: float = TERRAIN_PAR_M2, vie: float = VIE_ECONOMIQUE, phi: float = PHI_ETAT3) -> dict:168 cu_taille = cout.cout_par_superficie_ajuste(COUT_UNITAIRE_CAS1, SUPERFICIE_CAS1, sujet["superficie_habitable_m2"], ELASTICITE)169 cu = cout.indexer_cout(cu_taille, IPCB_MAISON, IPCB_APPART)170 cn = cu * sujet["superficie_habitable_m2"]171 a_eff = 0.5 * sujet["age"] + 0.5 * phi * vie # convention ch. 24 (eq. âge effectif) avec la note d'état 3172 a_eff = min(a_eff, vie)173 dep = cout.depreciation_age_vie(cn, a_eff, vie)174 terrain = terrain_par_m2 * sujet["superficie_terrain_m2"]175 v = cout.valeur_par_cout(terrain, cn, dep)176 return {"cu_taille": cu_taille, "cu": cu, "cout_neuf": cn, "age_effectif": a_eff, "vie": vie, "taux_depreciation": dep / cn, "depreciation": dep,177 "terrain": terrain, "contribution_batiment": v["contribution_batiment"], "valeur": v["valeur"]}178179180def sensibilite_cout(sujet: dict) -> pd.DataFrame:181 rows = []182 for t in (700.0, 900.0, 1100.0):183 for vie, phi in ((80.0, 0.5), (90.0, 0.5), (100.0, 0.5), (90.0, 0.35)):184 r = indication_cout(sujet, t, vie, phi); rows.append({"terrain_m2": t, "vie": vie, "phi": phi, "age_effectif": r["age_effectif"], "taux_dep": r["taux_depreciation"], "valeur": r["valeur"]})185 return pd.DataFrame(rows)186187188def depreciation_extraite(prix_marche: float, sujet: dict) -> dict:189 """Lecture inverse : dépréciation que le marché implique si l'on retient le coût neuf et le terrain du dossier."""190 r = indication_cout(sujet)191 return cout.extraction_depreciation_marche(prix_marche, r["terrain"], r["cout_neuf"], sujet["age"])192193194# ----------------------------------------------------------------------------- 46.9 financement de l'acheteur-occupant195def droits_mutation_quebec(base_imposition: float) -> dict:196 d, bas, detail = 0.0, 0.0, []197 for haut, taux in TRANCHES_MUTATION_QUEBEC:198 tranche = max(min(base_imposition, haut) - bas, 0.0); detail.append({"de": bas, "a": haut, "taux": taux, "montant": tranche * taux}); d += tranche * taux199 bas = haut200 if base_imposition <= haut: break201 return {"base": base_imposition, "droits": d, "detail": detail}202203204def financement(prix: float, base: dict, loyers: pd.DataFrame, sujet: dict) -> dict:205 mf = C.mise_de_fonds_minimale_assuree(prix)206 fi = C.financement_occupant(prix, mf, C.TAUX_ASSURE)207 occ = C.cout_occupation(fi, base, loyers, "RC")208 h = fin.Hypotheque(fi["pret"], C.TAUX_ASSURE, C.AMORT_MOIS)209 base_mut = max(prix, sujet["valeur_role_2025"] * FACTEUR_COMPARATIF_2026)210 mut = droits_mutation_quebec(base_mut)211 autres = loyers[loyers["logement"] != "RC"]; loyers_bruts = float(autres["loyer_mensuel"].sum())212 variantes = []213 for lib, mfv, taux, amort in (("assuré, mise minimale, 25 ans", mf, C.TAUX_ASSURE, 300), ("assuré, mise minimale, 30 ans (premier acheteur)", mf, C.TAUX_ASSURE, 360),214 ("assuré, mise de 10 %", 0.10 * prix, C.TAUX_ASSURE, 300), ("non assuré, mise de 20 %", 0.20 * prix, C.TAUX_NON_ASSURE, 300),215 ("assuré, mise minimale, taux +100 pb", mf, C.TAUX_ASSURE + 0.01, 300), ("assuré, mise minimale, taux +200 pb", mf, C.TAUX_ASSURE + 0.02, 300)):216 f = C.financement_occupant(prix, mfv, taux, amort); o = C.cout_occupation(f, base, loyers, "RC")217 variantes.append({"scenario": lib, "mise_de_fonds": f["mise_de_fonds"], "rpv": f["rpv"], "pret": f["pret"], "taux": taux, "amort_mois": amort, "paiement_mensuel": f["paiement_mensuel"],218 "paiement_mensuel_tam": f["paiement_mensuel_tam"], "cout_net_mensuel": o["cout_net_mensuel"], "cout_net_mensuel_hors_capital": o["cout_net_mensuel_hors_capital"]})219 return {**fi, "interets_an1": h.interets_annee(1), "capital_an1": h.capital_annee(1), "occupation": occ, "mutation": mut, "liquidites": mf + mut["droits"],220 "loyers_bruts_autres": loyers_bruts, "part_paiement_couverte_brute": loyers_bruts / fi["paiement_mensuel"],221 "part_paiement_couverte_nette": occ["loyers_percus_nets"] / 12 / fi["paiement_mensuel"], "variantes": pd.DataFrame(variantes),222 "ratio_couverture_investisseur": base["rne"] / fi["service_dette"]}223224225def grille_cout_occupation(prix: float, base: dict, loyers: pd.DataFrame) -> pd.DataFrame:226 """Coût économique mensuel d'occupation (hors capital) selon la mise de fonds (part du prix) et le taux hypothécaire."""227 rows = []228 for part in (None, 0.10, 0.15, 0.20):229 for taux in np.arange(0.0312, 0.0713, 0.0025):230 mf = C.mise_de_fonds_minimale_assuree(prix) if part is None else part * prix231 t = taux if (part is None or part < 0.20) else taux + (C.TAUX_NON_ASSURE - C.TAUX_ASSURE)232 f = C.financement_occupant(prix, mf, t, 300); o = C.cout_occupation(f, base, loyers, "RC")233 rows.append({"mise": "minimale" if part is None else f"{int(part*100)} %", "taux": taux, "taux_effectif": t, "cout_net_mensuel": o["cout_net_mensuel"], "hors_capital": o["cout_net_mensuel_hors_capital"]})234 return pd.DataFrame(rows)235236237# ----------------------------------------------------------------------------- 46.3 valeur de la faculté de reprise (rez-de-chaussée)238def va_ecart_rc(loyers: pd.DataFrame, rotation: float = ROTATION_QUEBEC, g: float = G_MARCHE, g_tal: float = G_TAL, r: float = R_ACT, v: float = cas2.TAUX_VACANCE_PERTES) -> dict:239 rc = loyers[loyers["logement"] == "RC"].iloc[0]; lc, lm = float(rc["loyer_mensuel"]), float(rc["loyer_marche_estime"])240 delta1 = (lm - lc) * 12 * (1 - v)241 # écart croissant : marché à g, contrat à g_tal ; valeur actuelle sur 10 ans sans rotation (reprise impossible, locataire reste)242 va_10 = sum(((lm * (1 + g) ** (t - 1) - lc * (1 + g_tal) ** (t - 1)) * 12 * (1 - v)) / (1 + r) ** t for t in range(1, 11))243 # espérance sous rotation géométrique (ch. 26, éq. va-ecart) avec g = croissance de l'écart ≈ croissance moyenne pondérée244 g_ecart = (lm * (1 + g) - lc * (1 + g_tal)) / (lm - lc) - 1245 va_rot = delta1 / (r - g_ecart + rotation * (1 + g_ecart))246 return {"loyer_contrat": lc, "loyer_marche": lm, "ecart_mensuel": lm - lc, "delta1": delta1, "g_ecart": g_ecart, "va_10ans_sans_rotation": va_10, "va_rotation": va_rot,247 "valeur_faculte_reprise": va_10 - va_rot, "date_limite_avis": "2026-12-31", "echeance_bail": "2027-06-30"}248249250# ----------------------------------------------------------------------------- 46.10 réconciliation251def reconciliation(v_log: float, v_mrb: float, v_cap: float, base: dict, sujet: dict, sigma_log: float, poids: dict = POIDS_46) -> dict:252 ret = {"par_logement_ajuste": v_log, "mrb": v_mrb, "capitalisation": v_cap}253 vp = sum(poids[k] * ret[k] for k in ret)254 arr = round(vp / PAS_ARRONDI) * PAS_ARRONDI255 alt = {"retenue 40/35/25": vp, "45/30/25": 0.45 * v_log + 0.30 * v_mrb + 0.25 * v_cap, "égale": sum(ret.values()) / 3,256 "comparaison seule 60/40/0": 0.60 * v_log + 0.40 * v_mrb, "revenu dominant 20/20/60": 0.2 * v_log + 0.2 * v_mrb + 0.6 * v_cap}257 fourchette = (min(ret.values()), max(ret.values()))258 # fourchette conclue : ± une erreur-type de la moyenne pondérée des prix ajustés (comparaison) autour de la valeur pondérée, arrondie vers l'extérieur259 bas = np.floor((vp - sigma_log) / PAS_ARRONDI) * PAS_ARRONDI; haut = np.ceil((vp + sigma_log) / PAS_ARRONDI) * PAS_ARRONDI260 contributions = {k: poids[k] * ret[k] for k in ret}261 return {"retenues": ret, "poids": poids, "contributions": contributions, "valeur_ponderee": vp, "valeur_finale": arr, "alternatives": alt, "fourchette_indications": fourchette, "se_comparaison": sigma_log,262 "fourchette_conclue": (bas, haut), "prix_par_logement": arr / sujet["nb_logements"], "prix_m2": arr / sujet["superficie_habitable_m2"],263 "taux_implicite": base["rne"] / arr, "taux_implicite_marche": base["rne_marche"] / arr, "mrb_implicite": arr / base["rbp_contrat"],264 "ratio_role": arr / sujet["valeur_role_2025"], "ratio_role_indexe": arr / (sujet["valeur_role_2025"] * FACTEUR_COMPARATIF_2026),265 "ecart_ch32": arr - 870_000.0, "ecart_ch32_pct": arr / 870_000.0 - 1}266267268# ----------------------------------------------------------------------------- tout269def dossier() -> dict:270 r = cas2.tout(); d = {k: r[k] for k in ("ventes", "sujet", "loyers", "depenses")}271 base = base_normalisee(d); c = r["comparables"]; ind = r["indicateurs"]272 cand = C.selection_comparables(c, d["sujet"], base); comp5 = cand[cand["retenu"]]273 reg = regression_auxiliaire(c); gr = grille_ajustements(comp5, d["sujet"], base, reg)274 reg_taux = C.regression_taux_loyer_relatif(C.decomposition_taux(c))275 rev = indications_revenu(base, ind, comp5, reg_taux, gr["valeur"], float(comp5["mrb"].median()))276 rec = reconciliation(gr["valeur"], rev["v_mrb_comp"], rev["v_cap_ajuste"], base, d["sujet"], gr["se_valeur"])277 co = indication_cout(d["sujet"]); co_sens = sensibilite_cout(d["sujet"]); dep_ext = depreciation_extraite(rec["valeur_finale"], d["sujet"])278 fi = financement(rec["valeur_finale"], base, d["loyers"], d["sujet"]); grille_occ = grille_cout_occupation(rec["valeur_finale"], base, d["loyers"])279 rep = va_ecart_rc(d["loyers"])280 ch32 = C.indications(C.cas2.rne(d["loyers"], d["depenses"]), d["sujet"], ind, comp5, reg_taux, c)281 return {**d, "base": base, "comparables127": c, "indicateurs": ind, "candidats": cand, "comparables5": comp5, "regression": reg, "grille": gr,282 "regression_taux": reg_taux, "revenu": rev, "cout": co, "cout_sensibilite": co_sens, "depreciation_extraite": dep_ext, "financement": fi,283 "grille_occupation": grille_occ, "reprise": rep, "reconciliation": rec, "ch32": ch32}284285286# ----------------------------------------------------------------------------- exercices287def exercices(D: dict | None = None) -> dict:288 D = D or dossier(); b = D["base"]; s = D["sujet"]; rec = D["reconciliation"]; ex = {}289 # 1. taxes : compte déclaré vs recalcul 2026, effet sur le RNE et sur la capitalisation au taux ajusté290 t = b["taxes"]; ex["1"] = {"municipale_2026": t["municipale"], "scolaire_2026": t["scolaire"], "declare": 5900 + 470, "ecart": b["ecart_taxes"], "rne_declare": b["rne_declare"], "rne_2026": b["rne"],291 "v_cap_declare": fin.capitalisation_directe(b["rne_declare"], D["revenu"]["taux_ajuste"]), "v_cap_2026": D["revenu"]["v_cap_ajuste"]}292 # 2. avis de reprise : calendrier et VA de l'écart293 ex["2"] = D["reprise"]294 # 3. grille : refaire la cascade de Q0243 et vérifier brut/net295 tab = D["grille"]["tableau"]; q = tab[tab["id_vente"] == "Q0243"].iloc[0].to_dict(); ex["3"] = q296 # 4. capitalisation : taux médian, Q1, Q3, ajusté, comparables297 ex["4"] = {k: D["revenu"][k] for k in ("taux_median_127", "taux_q1", "taux_q3", "taux_ajuste", "taux_comp", "v_cap_median", "v_cap_q1", "v_cap_q3", "v_cap_ajuste", "v_cap_comp")}298 # 5. financement : prix final, mise de fonds, paiement, TAM, coût net ; variante 2e étage299 f = D["financement"]; occ2 = C.cout_occupation({k: f[k] for k in ("mise_de_fonds", "service_dette", "capital_rembourse_5ans")}, b, D["loyers"], "2e")300 ex["5"] = {"prix": f["prix"], "mise": f["mise_de_fonds"], "pret": f["pret"], "paiement": f["paiement_mensuel"], "tam": f["taux_admissible"], "paiement_tam": f["paiement_mensuel_tam"],301 "cout_net_rc": f["occupation"]["cout_net_mensuel"], "hors_capital_rc": f["occupation"]["cout_net_mensuel_hors_capital"], "cout_net_2e": occ2["cout_net_mensuel"], "hors_capital_2e": occ2["cout_net_mensuel_hors_capital"],302 "droits_mutation": f["mutation"]["droits"], "liquidites": f["liquidites"]}303 # 6. pondérations alternatives304 ex["6"] = rec["alternatives"]305 # 7. coût : sensibilité et dépréciation extraite306 ex["7"] = {"cout": D["cout"], "extraite": D["depreciation_extraite"]}307 return ex308309310def _d(x): return f"{x:,.2f} $".replace(",", " ")311def _p(x, k=2): return f"{100 * x:.{k}f} %"312313314if __name__ == "__main__":315 D = dossier(); b = D["base"]; s = D["sujet"]316 print("== 46.2/46.5 Sujet, taxes 2026, RNE normalisé (traite.cas2.rne sur dépenses normalisées)")317 print(b["taxes"]); print(f"écart taxes déclarées − 2026 : {_d(b['ecart_taxes'])}")318 print(b["detail"][["poste", "declare", "normalise", "type"]].to_string(index=False))319 print(f"RBP {_d(b['rbp_contrat'])} ; marché {_d(b['rbp_marche'])} ; ρ {b['rho']:.4f} ; RBE {_d(b['rbe'])} ; dépenses {_d(b['depenses_totales'])} (ratio {_p(b['ratio_depenses'],1)}) ; "320 f"RNE 2026 {_d(b['rne'])} ; RNE déclaré (ch. 32) {_d(b['rne_declare'])} ; RNE marché {_d(b['rne_marche'])}")321 print("\n== 46.6 Régression auxiliaire (127 ventes, ln prix/logement)")322 reg = D["regression"]323 for k in reg["beta"]: print(f" {k:18s} β = {reg['beta'][k]:+.5f} (e.-t. {reg['se'][k]:.5f}, t = {reg['t'][k]:+.2f})")324 print(f" R² {reg['r2']:.3f} ; σ {reg['sigma']:.4f} ; croissance annuelle implicite des prix {_p(reg['taux_annuel_marche'])}")325 print("\n== Comparables (ch. 32) et grille d'ajustements ($/logement)")326 cols = ["id_vente", "date_vente", "superficie_habitable_m2", "annee_construction", "etat_1a5", "stationnement", "loyer_mensuel_moyen_contrat", "loyer_mensuel_moyen_marche", "loyer_relatif", "prix_vente", "mrb", "taux_global", "prix_par_logement", "mois"]327 print(D["comparables5"][cols].assign(date_vente=lambda x: x["date_vente"].dt.date).round(3).to_string(index=False))328 print(D["grille"]["tableau"].round(2).to_string(index=False))329 g = D["grille"]; print(f"prix/logement pondéré {_d(g['valeur_par_logement'])} → valeur {_d(g['valeur'])} ; moyenne simple {_d(g['moyenne_simple'])} ; é.-t. {_d(g['ecart_type_valeur'])} ; "330 f"médiane {g['reconciliation']['mediane']:.0f} ; min {g['reconciliation']['min']:.0f} ; max {g['reconciliation']['max']:.0f} ; étendue {_p(g['reconciliation']['etendue_pct'],1)}")331 print("\n== 46.7 Revenu"); print({k: (round(v, 6) if isinstance(v, float) else v) for k, v in D["revenu"].items()})332 print("\n== 46.8 Coût (contrôle)"); print({k: round(v, 2) for k, v in D["cout"].items()}); print(D["cout_sensibilite"].round(3).to_string(index=False)); print(D["depreciation_extraite"])333 print("\n== 46.10 Réconciliation"); rec = D["reconciliation"]334 for k, v in rec.items(): print(f" {k}: {v}")335 print("\n== 46.9 Financement au prix final"); f = D["financement"]336 print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in f.items() if k not in ("occupation", "mutation", "variantes")})337 print({k: round(v, 2) for k, v in f["occupation"].items() if isinstance(v, float)}); print(f["mutation"]); print(f["variantes"].round(2).to_string(index=False))338 print("\n== 46.3 Reprise du rez-de-chaussée"); print({k: (round(v, 2) if isinstance(v, float) else v) for k, v in D["reprise"].items()})339 print("\n== Exercices"); E = exercices(D)340 for k, v in E.items(): print(k, v)341