"""Chapitre 46 — Cas 2 : triplex à Québec (Limoilou), dossier complet (DONNÉES SYNTHÉTIQUES, graine 20261031). Tout chiffre cité dans manuscript/chapters/ch46.tex et solutions/ch46.tex provient de ce script. Il s'appuie sur : - traite.cas2 (revenus, RNE, 127 ventes de plex, indicateurs) — partagé avec les ch. 26, 27, 28 et 32 ; - analysis/python/cases/ch32_plex.py (sélection des cinq comparables triplex de Limoilou, régression taux/loyer relatif, financement résidentiel assuré, coût d'occupation) — importé, jamais recalculé autrement ; - traite.finance (Hypotheque, capitalisation_directe, rne), traite.comparables (ComparableAjuste, poids_inverse_brut, reconciliation, ajustement_temps_taux), traite.cout (cout_par_superficie_ajuste, depreciation_age_vie, valeur_par_cout). Ce que le chapitre 46 AJOUTE au chapitre 32 : normalisation des taxes aux tarifs 2026 de la Ville de Québec (RNE normalisé), grille d'ajustements explicites en $/logement sur les cinq comparables (régression auxiliaire sur les 127 ventes), indication par le coût (contrôle), droits de mutation de la Ville de Québec, valeur de la faculté de reprise, réconciliation et rapport. Exécution : python3 analysis/python/cases/ch46_dossier.py (depuis la racine du dépôt) """ from __future__ import annotations import sys, pathlib import numpy as np, pandas as pd ROOT = pathlib.Path(__file__).resolve().parents[3] sys.path.insert(0, str(ROOT / "analysis/python")); sys.path.insert(0, str(ROOT / "analysis/python/cases")) from traite import cas2, finance as fin, comparables as cmp, cout # noqa: E402 import ch32_plex as C # noqa: E402 GRAINE = 20261031 DATE_EVAL = "2026-09-01" # ----------------------------------------------------------------------------- paramètres déclarés (sources : notes-E, notes-B, notes-C, ch. 21/24) TAUX_TAXE_1A5 = 0.7464 / 100 # Ville de Québec 2026, résidentiel 1 à 5 logements, $/100 $ (R.V.Q. 3492 ; SRC-E-027) TARIF_AQUEDUC_EGOUT = 386.0 # $/logement (idem) TARIF_MATIERES_RESIDUELLES = 195.0 # $/logement (idem) TAUX_TAXE_SCOLAIRE = 0.07899 / 100 # 2026-2027, $/100 $ (SRC-E-026) ; exemption des premiers 25 000 $ EXEMPTION_SCOLAIRE = 25_000.0 FACTEUR_COMPARATIF_2026 = 1.08 # Ville de Québec, facteur comparatif 2026 (source secondaire, SRC-A-023) # Droits sur les mutations immobilières, Ville de Québec, 2026 (LDMI art. 2 + taux municipaux ; SRC-A-023, partiellement vérifié) TRANCHES_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)] # 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, # ratio IPCB « appartements < 5 étages » / « maison individuelle », Montréal, T3 2026 (118,5 / 116,5 ; tableau 18-10-0289-01) COUT_UNITAIRE_CAS1, SUPERFICIE_CAS1, ELASTICITE = 2409.0, 165.0, -0.10 IPCB_APPART, IPCB_MAISON = 118.5, 116.5 VIE_ECONOMIQUE = 90.0 # hypothèse du dossier : plex de brique de 1948, vie économique totale PHI_ETAT3 = 0.5 # fraction de vie consommée associée à la note d'état 3 (convention ch. 11/24) TERRAIN_PAR_M2 = 900.0 # HYPOTHÈSE DU DOSSIER (non vérifiée) : valeur unitaire du terrain, Limoilou POIDS_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) T_MIN = 2.0 # seuil de significativité (|t|) pour appliquer un coefficient de la régression auxiliaire dans la grille PAS_ARRONDI = 5_000.0 ROTATION_QUEBEC = 0.13 # taux de rotation des locataires, RMR de Québec, SCHL oct. 2025 (SRC-E-007) G_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) # ----------------------------------------------------------------------------- 46.5 normalisation des taxes et RNE def taxes_2026(valeur_role: float, nb_logements: int) -> dict: """Taxes municipales (taux 1-5 logements + tarifs par logement) et taxe scolaire (exemption des premiers 25 000 $) sur la valeur au rôle.""" fonciere = valeur_role * TAUX_TAXE_1A5 tarifs = nb_logements * (TARIF_AQUEDUC_EGOUT + TARIF_MATIERES_RESIDUELLES) scolaire = max(valeur_role - EXEMPTION_SCOLAIRE, 0.0) * TAUX_TAXE_SCOLAIRE return {"fonciere": fonciere, "tarifs": tarifs, "municipale": fonciere + tarifs, "scolaire": scolaire, "total": fonciere + tarifs + scolaire} def depenses_normalisees_2026(dep: pd.DataFrame, sujet: dict) -> pd.DataFrame: """Dépenses du dossier avec les taxes recalculées aux taux 2026 (les autres postes sont conservés : polices, contrats, factures).""" t = taxes_2026(sujet["valeur_role_2025"], sujet["nb_logements"]) d = dep.copy(); d["declare"] = d["montant_annuel"].astype(float); d["montant_annuel"] = d["montant_annuel"].astype(float) d.loc[d["poste"] == "Taxes municipales", "montant_annuel"] = t["municipale"] d.loc[d["poste"] == "Taxe scolaire", "montant_annuel"] = t["scolaire"] return d def base_normalisee(d: dict) -> dict: """RNE du dossier (taxes 2026) par traite.cas2.rne, et RNE « déclaré » (ch. 32) pour mémoire.""" dep_n = depenses_normalisees_2026(d["depenses"], d["sujet"]) b = cas2.rne(d["loyers"], dep_n) b["detail"]["declare"] = dep_n["declare"].values b_decl = cas2.rne(d["loyers"], d["depenses"]) b["rne_declare"] = b_decl["rne"]; b["depenses_declarees"] = b_decl["depenses_totales"] b["ecart_taxes"] = b_decl["depenses_totales"] - b["depenses_totales"] b["taxes"] = taxes_2026(d["sujet"]["valeur_role_2025"], d["sujet"]["nb_logements"]) # RNE aux loyers de marché (même structure de dépenses, gestion imputée sur le RBE de marché) det = b["detail"]; dep_m = float(det.loc[~det["type"].isin(["imputée", "réserve"]), "normalise"].sum()) + cas2.GESTION_IMPUTEE * b["rbe_marche"] b["rne_marche"] = fin.rne(b["rbe_marche"], dep_m, b["reserve"]) b["loyer_marche_moyen"] = float(d["loyers"]["loyer_marche_estime"].mean()); b["loyer_contrat_moyen"] = float(d["loyers"]["loyer_mensuel"].mean()) b["rho"] = b["rbp_contrat"] / b["rbp_marche"] return b # ----------------------------------------------------------------------------- 46.6 régression auxiliaire et grille d'ajustements VARS = ["mois", "ln_sup_log", "age", "etat_1a5", "stationnement", "ln_loyer_marche", "loyer_relatif", "triplex", "q_Saint-Sauveur", "q_Saint-Roch", "q_Maizerets"] def _matrice(c: pd.DataFrame) -> tuple[np.ndarray, np.ndarray]: g = c.copy() g["ln_sup_log"] = np.log(g["superficie_habitable_m2"] / g["nb_logements"]) g["ln_loyer_marche"] = np.log(g["loyer_mensuel_moyen_marche"]) g["loyer_relatif"] = g["loyer_mensuel_moyen_contrat"] / g["loyer_mensuel_moyen_marche"] g["triplex"] = (g["nb_logements"] == 3).astype(float) for q in ("Saint-Sauveur", "Saint-Roch", "Maizerets"): g[f"q_{q}"] = (g["quartier"] == q).astype(float) X = np.column_stack([np.ones(len(g))] + [g[v].to_numpy(dtype=float) for v in VARS]) y = np.log(g["prix_par_logement"].to_numpy(dtype=float)) return X, y def regression_auxiliaire(c: pd.DataFrame) -> dict: """OLS : ln(prix par logement) sur l'ancienneté de la vente (mois), ln(m² par logement), l'âge, l'état, le stationnement, ln(loyer de marché moyen), le loyer relatif, un indicateur triplex et les quartiers (référence Limoilou). Erreurs-types classiques.""" X, y = _matrice(c); n, k = X.shape beta, *_ = np.linalg.lstsq(X, y, rcond=None); res = y - X @ beta s2 = float(res @ res) / (n - k); cov = s2 * np.linalg.inv(X.T @ X) se = np.sqrt(np.diag(cov)); r2 = 1 - float(res @ res) / float(((y - y.mean()) ** 2).sum()) noms = ["const"] + VARS 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, "taux_annuel_marche": float(np.exp(-12 * beta[1]) - 1)} # croissance annuelle implicite des prix (mois = ancienneté de la vente) def grille_ajustements(comp5: pd.DataFrame, sujet: dict, base: dict, reg: dict) -> dict: """Grille d'ajustements en $ par logement, ordre de l'ouvrage : conditions du marché (temps, %) puis attributs (additifs, $) : superficie par logement, âge, état, stationnement, niveau de loyer de marché, loyer relatif. Signe du point de vue du sujet.""" b = reg["beta"]; n_s = sujet["nb_logements"] s = {"ln_sup_log": np.log(sujet["superficie_habitable_m2"] / n_s), "age": sujet["age"], "etat_1a5": sujet["etat_1a5"], "stationnement": sujet["stationnement"], "ln_loyer_marche": np.log(base["loyer_marche_moyen"]), "loyer_relatif": base["rho"]} g_annuel = reg["taux_annuel_marche"] comps, lignes = [], [] for _, r in comp5.iterrows(): p = float(r["prix_par_logement"]) a_t = cmp.ajustement_temps_taux(p, g_annuel, float(r["mois"])) # conditions du marché p_t = p + a_t x = {"ln_sup_log": np.log(r["superficie_habitable_m2"] / r["nb_logements"]), "age": float(r["age"]), "etat_1a5": float(r["etat_1a5"]), "stationnement": float(r["stationnement"]), "ln_loyer_marche": np.log(r["loyer_mensuel_moyen_marche"]), "loyer_relatif": r["loyer_mensuel_moyen_contrat"] / r["loyer_mensuel_moyen_marche"]} aj = {"temps": a_t} for v, lib in (("ln_sup_log", "superficie"), ("age", "age"), ("etat_1a5", "etat"), ("stationnement", "stationnement"), ("ln_loyer_marche", "loyer_marche"), ("loyer_relatif", "loyer_relatif")): # seuls les coefficients significatifs (|t| >= T_MIN) sont appliqués ; le loyer relatif (non significatif) est traité par le taux et le multiplicateur aj[lib] = p_t * (np.exp(b[v] * (s[v] - x[v])) - 1.0) if abs(reg["t"][v]) >= T_MIN else 0.0 ca = cmp.ComparableAjuste(r["id_vente"], p, aj); comps.append(ca) lignes.append({"id_vente": r["id_vente"], "date_vente": r["date_vente"].date().isoformat(), "mois": float(r["mois"]), "prix_vente": float(r["prix_vente"]), "prix_logement": p, **aj, "prix_ajuste": ca.prix_ajuste, "brut_pct": ca.brut_pct, "net_pct": ca.net_pct}) poids = cmp.poids_inverse_brut(comps); rec = cmp.reconciliation(comps, poids) tab = pd.DataFrame(lignes); tab["poids"] = poids 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és return {"comparables": comps, "tableau": tab, "reconciliation": rec, "poids": poids, "g_annuel": g_annuel, "valeur_par_logement": rec["valeur_ponderee"], "valeur": rec["valeur_ponderee"] * n_s, "moyenne_simple": rec["moyenne"] * n_s, "ecart_type_valeur": rec["ecart_type_pondere"] * n_s, "se_valeur": se_log * n_s} # ----------------------------------------------------------------------------- 46.7 revenu def indications_revenu(base: dict, ind: dict, comp5: pd.DataFrame, reg_taux: dict, valeur_comparaison: float, mrb_comp: float) -> dict: rne = base["rne"]; rbp = base["rbp_contrat"] taux_ajuste = reg_taux["a"] + reg_taux["b_rho"] * (base["rho"] - 1.0) taux_comp = float(comp5["taux_global"].median()) 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, "v_cap_median": fin.capitalisation_directe(rne, ind["taux_median"]), "v_cap_ajuste": fin.capitalisation_directe(rne, taux_ajuste), "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"]), "v_cap_ajuste_rne_declare": fin.capitalisation_directe(base["rne_declare"], taux_ajuste), "mrb_comp": mrb_comp, "v_mrb_comp": mrb_comp * rbp, "mrb_127": ind["mrb_median_triplex"], "v_mrb_127": ind["mrb_median_triplex"] * rbp, "taux_implicite_comparaison": rne / valeur_comparaison, "mrb_implicite_comparaison": valeur_comparaison / rbp, "taux_implicite_marche": base["rne_marche"] / valeur_comparaison} # sensibilité : multiplicateur des comparables ramené au loyer relatif du sujet par la pente de la régression ln MRB (ch. 32) rho_j = comp5["loyer_mensuel_moyen_contrat"] / comp5["loyer_mensuel_moyen_marche"] mrb_aj = comp5["mrb"] * np.exp(reg_taux["mrb_b_rho"] * (base["rho"] - rho_j)) out["mrb_comp_ajuste_rho"] = float(mrb_aj.median()); out["v_mrb_comp_ajuste_rho"] = out["mrb_comp_ajuste_rho"] * rbp # lecture « taux implicite » : écart entre le taux ajusté et le taux implicite du prix de comparaison, en points de base out["ecart_pb"] = (out["taux_ajuste"] - out["taux_implicite_comparaison"]) * 1e4 # ratio de dépenses du sujet vs médiane des ventes : part de l'écart capitalisation/comparaison expliquée par la structure de dépenses return out # ----------------------------------------------------------------------------- 46.8 coût (contrôle) def indication_cout(sujet: dict, terrain_par_m2: float = TERRAIN_PAR_M2, vie: float = VIE_ECONOMIQUE, phi: float = PHI_ETAT3) -> dict: cu_taille = cout.cout_par_superficie_ajuste(COUT_UNITAIRE_CAS1, SUPERFICIE_CAS1, sujet["superficie_habitable_m2"], ELASTICITE) cu = cout.indexer_cout(cu_taille, IPCB_MAISON, IPCB_APPART) cn = cu * sujet["superficie_habitable_m2"] a_eff = 0.5 * sujet["age"] + 0.5 * phi * vie # convention ch. 24 (eq. âge effectif) avec la note d'état 3 a_eff = min(a_eff, vie) dep = cout.depreciation_age_vie(cn, a_eff, vie) terrain = terrain_par_m2 * sujet["superficie_terrain_m2"] v = cout.valeur_par_cout(terrain, cn, dep) return {"cu_taille": cu_taille, "cu": cu, "cout_neuf": cn, "age_effectif": a_eff, "vie": vie, "taux_depreciation": dep / cn, "depreciation": dep, "terrain": terrain, "contribution_batiment": v["contribution_batiment"], "valeur": v["valeur"]} def sensibilite_cout(sujet: dict) -> pd.DataFrame: rows = [] for t in (700.0, 900.0, 1100.0): for vie, phi in ((80.0, 0.5), (90.0, 0.5), (100.0, 0.5), (90.0, 0.35)): 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"]}) return pd.DataFrame(rows) def depreciation_extraite(prix_marche: float, sujet: dict) -> dict: """Lecture inverse : dépréciation que le marché implique si l'on retient le coût neuf et le terrain du dossier.""" r = indication_cout(sujet) return cout.extraction_depreciation_marche(prix_marche, r["terrain"], r["cout_neuf"], sujet["age"]) # ----------------------------------------------------------------------------- 46.9 financement de l'acheteur-occupant def droits_mutation_quebec(base_imposition: float) -> dict: d, bas, detail = 0.0, 0.0, [] for haut, taux in TRANCHES_MUTATION_QUEBEC: tranche = max(min(base_imposition, haut) - bas, 0.0); detail.append({"de": bas, "a": haut, "taux": taux, "montant": tranche * taux}); d += tranche * taux bas = haut if base_imposition <= haut: break return {"base": base_imposition, "droits": d, "detail": detail} def financement(prix: float, base: dict, loyers: pd.DataFrame, sujet: dict) -> dict: mf = C.mise_de_fonds_minimale_assuree(prix) fi = C.financement_occupant(prix, mf, C.TAUX_ASSURE) occ = C.cout_occupation(fi, base, loyers, "RC") h = fin.Hypotheque(fi["pret"], C.TAUX_ASSURE, C.AMORT_MOIS) base_mut = max(prix, sujet["valeur_role_2025"] * FACTEUR_COMPARATIF_2026) mut = droits_mutation_quebec(base_mut) autres = loyers[loyers["logement"] != "RC"]; loyers_bruts = float(autres["loyer_mensuel"].sum()) variantes = [] 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), ("assuré, mise de 10 %", 0.10 * prix, C.TAUX_ASSURE, 300), ("non assuré, mise de 20 %", 0.20 * prix, C.TAUX_NON_ASSURE, 300), ("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)): f = C.financement_occupant(prix, mfv, taux, amort); o = C.cout_occupation(f, base, loyers, "RC") 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"], "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"]}) return {**fi, "interets_an1": h.interets_annee(1), "capital_an1": h.capital_annee(1), "occupation": occ, "mutation": mut, "liquidites": mf + mut["droits"], "loyers_bruts_autres": loyers_bruts, "part_paiement_couverte_brute": loyers_bruts / fi["paiement_mensuel"], "part_paiement_couverte_nette": occ["loyers_percus_nets"] / 12 / fi["paiement_mensuel"], "variantes": pd.DataFrame(variantes), "ratio_couverture_investisseur": base["rne"] / fi["service_dette"]} def grille_cout_occupation(prix: float, base: dict, loyers: pd.DataFrame) -> pd.DataFrame: """Coût économique mensuel d'occupation (hors capital) selon la mise de fonds (part du prix) et le taux hypothécaire.""" rows = [] for part in (None, 0.10, 0.15, 0.20): for taux in np.arange(0.0312, 0.0713, 0.0025): mf = C.mise_de_fonds_minimale_assuree(prix) if part is None else part * prix t = taux if (part is None or part < 0.20) else taux + (C.TAUX_NON_ASSURE - C.TAUX_ASSURE) f = C.financement_occupant(prix, mf, t, 300); o = C.cout_occupation(f, base, loyers, "RC") 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"]}) return pd.DataFrame(rows) # ----------------------------------------------------------------------------- 46.3 valeur de la faculté de reprise (rez-de-chaussée) def 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: rc = loyers[loyers["logement"] == "RC"].iloc[0]; lc, lm = float(rc["loyer_mensuel"]), float(rc["loyer_marche_estime"]) delta1 = (lm - lc) * 12 * (1 - v) # écart croissant : marché à g, contrat à g_tal ; valeur actuelle sur 10 ans sans rotation (reprise impossible, locataire reste) 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)) # espérance sous rotation géométrique (ch. 26, éq. va-ecart) avec g = croissance de l'écart ≈ croissance moyenne pondérée g_ecart = (lm * (1 + g) - lc * (1 + g_tal)) / (lm - lc) - 1 va_rot = delta1 / (r - g_ecart + rotation * (1 + g_ecart)) 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, "valeur_faculte_reprise": va_10 - va_rot, "date_limite_avis": "2026-12-31", "echeance_bail": "2027-06-30"} # ----------------------------------------------------------------------------- 46.10 réconciliation def reconciliation(v_log: float, v_mrb: float, v_cap: float, base: dict, sujet: dict, sigma_log: float, poids: dict = POIDS_46) -> dict: ret = {"par_logement_ajuste": v_log, "mrb": v_mrb, "capitalisation": v_cap} vp = sum(poids[k] * ret[k] for k in ret) arr = round(vp / PAS_ARRONDI) * PAS_ARRONDI 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, "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} fourchette = (min(ret.values()), max(ret.values())) # 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érieur bas = np.floor((vp - sigma_log) / PAS_ARRONDI) * PAS_ARRONDI; haut = np.ceil((vp + sigma_log) / PAS_ARRONDI) * PAS_ARRONDI contributions = {k: poids[k] * ret[k] for k in ret} return {"retenues": ret, "poids": poids, "contributions": contributions, "valeur_ponderee": vp, "valeur_finale": arr, "alternatives": alt, "fourchette_indications": fourchette, "se_comparaison": sigma_log, "fourchette_conclue": (bas, haut), "prix_par_logement": arr / sujet["nb_logements"], "prix_m2": arr / sujet["superficie_habitable_m2"], "taux_implicite": base["rne"] / arr, "taux_implicite_marche": base["rne_marche"] / arr, "mrb_implicite": arr / base["rbp_contrat"], "ratio_role": arr / sujet["valeur_role_2025"], "ratio_role_indexe": arr / (sujet["valeur_role_2025"] * FACTEUR_COMPARATIF_2026), "ecart_ch32": arr - 870_000.0, "ecart_ch32_pct": arr / 870_000.0 - 1} # ----------------------------------------------------------------------------- tout def dossier() -> dict: r = cas2.tout(); d = {k: r[k] for k in ("ventes", "sujet", "loyers", "depenses")} base = base_normalisee(d); c = r["comparables"]; ind = r["indicateurs"] cand = C.selection_comparables(c, d["sujet"], base); comp5 = cand[cand["retenu"]] reg = regression_auxiliaire(c); gr = grille_ajustements(comp5, d["sujet"], base, reg) reg_taux = C.regression_taux_loyer_relatif(C.decomposition_taux(c)) rev = indications_revenu(base, ind, comp5, reg_taux, gr["valeur"], float(comp5["mrb"].median())) rec = reconciliation(gr["valeur"], rev["v_mrb_comp"], rev["v_cap_ajuste"], base, d["sujet"], gr["se_valeur"]) co = indication_cout(d["sujet"]); co_sens = sensibilite_cout(d["sujet"]); dep_ext = depreciation_extraite(rec["valeur_finale"], d["sujet"]) fi = financement(rec["valeur_finale"], base, d["loyers"], d["sujet"]); grille_occ = grille_cout_occupation(rec["valeur_finale"], base, d["loyers"]) rep = va_ecart_rc(d["loyers"]) ch32 = C.indications(C.cas2.rne(d["loyers"], d["depenses"]), d["sujet"], ind, comp5, reg_taux, c) return {**d, "base": base, "comparables127": c, "indicateurs": ind, "candidats": cand, "comparables5": comp5, "regression": reg, "grille": gr, "regression_taux": reg_taux, "revenu": rev, "cout": co, "cout_sensibilite": co_sens, "depreciation_extraite": dep_ext, "financement": fi, "grille_occupation": grille_occ, "reprise": rep, "reconciliation": rec, "ch32": ch32} # ----------------------------------------------------------------------------- exercices def exercices(D: dict | None = None) -> dict: D = D or dossier(); b = D["base"]; s = D["sujet"]; rec = D["reconciliation"]; ex = {} # 1. taxes : compte déclaré vs recalcul 2026, effet sur le RNE et sur la capitalisation au taux ajusté 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"], "v_cap_declare": fin.capitalisation_directe(b["rne_declare"], D["revenu"]["taux_ajuste"]), "v_cap_2026": D["revenu"]["v_cap_ajuste"]} # 2. avis de reprise : calendrier et VA de l'écart selon la rotation ex["2"] = {**D["reprise"], "va_rotation_9_4": va_ecart_rc(D["loyers"], rotation=0.094)["va_rotation"], "va_rotation_20": va_ecart_rc(D["loyers"], rotation=0.20)["va_rotation"], "prise_possession_si_acte_2027_01_15": "2028-07-01"} # 3. grille : refaire la cascade de Q0243 et vérifier brut/net tab = D["grille"]["tableau"]; q = tab[tab["id_vente"] == "Q0243"].iloc[0].to_dict(); ex["3"] = q # 4. capitalisation : taux médian, Q1, Q3, ajusté, comparables 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")} d_med = float(D["comparables127"]["ratio_depenses"].median()); rne_ratio = b["rbe"] * (1 - d_med) ex["4"].update({"ratio_median_ventes": d_med, "rne_ratio_ventes": rne_ratio, "v_cap_ratio_ventes": fin.capitalisation_directe(rne_ratio, D["revenu"]["taux_ajuste"]), "taux_implicite_ratio_ventes": rne_ratio / D["grille"]["valeur"], "ecart_pb_restant": (D["revenu"]["taux_ajuste"] - rne_ratio / D["grille"]["valeur"]) * 1e4}) # 5. financement : prix final, mise de fonds, paiement, TAM, coût net ; variante 2e étage 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") 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"], "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"], "droits_mutation": f["mutation"]["droits"], "liquidites": f["liquidites"]} # 5 bis : au prix de la promesse d'achat (895 000 $) fp = financement(895_000.0, b, D["loyers"], s); occ2p = C.cout_occupation({k: fp[k] for k in ("mise_de_fonds", "service_dette", "capital_rembourse_5ans")}, b, D["loyers"], "2e") ex["5"]["promesse"] = {"prix": fp["prix"], "mise": fp["mise_de_fonds"], "pret": fp["pret"], "rpv": fp["rpv"], "paiement": fp["paiement_mensuel"], "service_dette": fp["service_dette"], "paiement_tam": fp["paiement_mensuel_tam"], "droits_mutation": fp["mutation"]["droits"], "liquidites": fp["liquidites"], "capital_moyen_5ans": fp["capital_rembourse_5ans"] / 5, "cout_opportunite": fp["occupation"]["cout_opportunite"], "cout_net_rc": fp["occupation"]["cout_net_mensuel"], "hors_capital_rc": fp["occupation"]["cout_net_mensuel_hors_capital"], "cout_net_2e": occ2p["cout_net_mensuel"], "hors_capital_2e": occ2p["cout_net_mensuel_hors_capital"]} # 6. pondérations alternatives + pondération à l'inverse de la variance ret = rec["retenues"]; sig = {"par_logement_ajuste": rec["se_comparaison"], "mrb": 0.10 * ret["mrb"], "capitalisation": 0.15 * ret["capitalisation"]} w = {k: 1 / sig[k] ** 2 for k in ret}; tot = sum(w.values()); w = {k: v / tot for k, v in w.items()} ex["6"] = {**rec["alternatives"], "poids_inverse_variance": w, "valeur_inverse_variance": sum(w[k] * ret[k] for k in ret), "sigmas": sig} # 7. coût : sensibilité et dépréciation extraite co = D["cout"]; v_final = rec["valeur_finale"] terrain_egal = v_final - co["contribution_batiment"]; dep_egal = 1 - (v_final - co["terrain"]) / co["cout_neuf"] ex["7"] = {"cout": co, "extraite_900": D["depreciation_extraite"], "terrain_pour_egaler": terrain_egal, "terrain_m2_pour_egaler": terrain_egal / s["superficie_terrain_m2"], "taux_dep_pour_egaler": dep_egal, "extraite_1100": cout.extraction_depreciation_marche(v_final, 1100.0 * s["superficie_terrain_m2"], co["cout_neuf"], s["age"])} return ex def _d(x): return f"{x:,.2f} $".replace(",", " ") def _p(x, k=2): return f"{100 * x:.{k}f} %" if __name__ == "__main__": D = dossier(); b = D["base"]; s = D["sujet"] print("== 46.2/46.5 Sujet, taxes 2026, RNE normalisé (traite.cas2.rne sur dépenses normalisées)") print(b["taxes"]); print(f"écart taxes déclarées − 2026 : {_d(b['ecart_taxes'])}") print(b["detail"][["poste", "declare", "normalise", "type"]].to_string(index=False)) 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)}) ; " f"RNE 2026 {_d(b['rne'])} ; RNE déclaré (ch. 32) {_d(b['rne_declare'])} ; RNE marché {_d(b['rne_marche'])}") print("\n== 46.6 Régression auxiliaire (127 ventes, ln prix/logement)") reg = D["regression"] for k in reg["beta"]: print(f" {k:18s} β = {reg['beta'][k]:+.5f} (e.-t. {reg['se'][k]:.5f}, t = {reg['t'][k]:+.2f})") print(f" R² {reg['r2']:.3f} ; σ {reg['sigma']:.4f} ; croissance annuelle implicite des prix {_p(reg['taux_annuel_marche'])}") print("\n== Comparables (ch. 32) et grille d'ajustements ($/logement)") 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"] print(D["comparables5"][cols].assign(date_vente=lambda x: x["date_vente"].dt.date).round(3).to_string(index=False)) print(D["grille"]["tableau"].round(2).to_string(index=False)) 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'])} ; " f"médiane {g['reconciliation']['mediane']:.0f} ; min {g['reconciliation']['min']:.0f} ; max {g['reconciliation']['max']:.0f} ; étendue {_p(g['reconciliation']['etendue_pct'],1)}") print("\n== 46.7 Revenu"); print({k: (round(v, 6) if isinstance(v, float) else v) for k, v in D["revenu"].items()}) 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"]) print("\n== 46.10 Réconciliation"); rec = D["reconciliation"] for k, v in rec.items(): print(f" {k}: {v}") print("\n== 46.9 Financement au prix final"); f = D["financement"] print({k: (round(v, 4) if isinstance(v, float) else v) for k, v in f.items() if k not in ("occupation", "mutation", "variantes")}) 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)) 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()}) print("\n== Exercices"); E = exercices(D) for k, v in E.items(): print(k, v)