# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # mortgage/calc.py : mathématiques hypothécaires canadiennes. # Taux fixes : intérêt composé SEMESTRIELLEMENT, non à l'avance (Loi sur # l'intérêt, art. 6) — jamais la formule américaine (composition mensuelle). # Taux variables : composition mensuelle (convention majoritaire des # prêteurs canadiens). # ----------------------------------------------------------------------------- from __future__ import annotations import math # Fréquences de paiement : nombre de versements par année. FREQUENCIES: dict[str, int] = { "monthly": 12, "semimonthly": 24, "biweekly": 26, "accelerated-biweekly": 26, "weekly": 52, "accelerated-weekly": 52, } # Taux de qualification minimal (test de résistance B-20 / ligne directrice # du BSIF) : max(taux contractuel + 2 points, plancher). STRESS_TEST_FLOOR = 5.25 STRESS_TEST_BUFFER = 2.0 AMORTIZATIONS_YEARS = [10, 15, 20, 25, 30] TERMS_MONTHS = [12, 24, 36, 48, 60, 84, 120] def periodic_rate(annual_pct: float, frequency: str = "monthly", compounding: str = "semi-annual") -> float: """Taux périodique équivalent au taux nominal annuel `annual_pct` (%). compounding="semi-annual" : convention canadienne des prêts fixes — i = (1 + r/2)^(2/f) − 1. "monthly" : prêts variables — i = (1+r/12)^(12/f) − 1. """ if annual_pct < 0: raise ValueError("taux négatif") f = FREQUENCIES[frequency] r = annual_pct / 100.0 if compounding == "monthly": return (1.0 + r / 12.0) ** (12.0 / f) - 1.0 return (1.0 + r / 2.0) ** (2.0 / f) - 1.0 def payment(principal: float, annual_pct: float, amort_years: float, frequency: str = "monthly", compounding: str = "semi-annual") -> float: """Versement périodique (arrondi au cent). Fréquences accélérées : convention canadienne — le versement mensuel divisé par 2 (aux deux semaines) ou par 4 (hebdomadaire), ce qui raccourcit l'amortissement réel. """ if principal <= 0: return 0.0 if frequency in ("accelerated-biweekly", "accelerated-weekly"): m = payment(principal, annual_pct, amort_years, "monthly", compounding) return round(m / (2 if frequency == "accelerated-biweekly" else 4), 2) i = periodic_rate(annual_pct, frequency, compounding) n = round(amort_years * FREQUENCIES[frequency]) if i == 0: return round(principal / n, 2) return round(principal * i / (1.0 - (1.0 + i) ** -n), 2) def schedule(principal: float, annual_pct: float, amort_years: float, frequency: str = "monthly", compounding: str = "semi-annual", pay_amount: float | None = None, max_periods: int | None = None) -> list[dict]: """Tableau d'amortissement complet : [{n, payment, interest, principal, balance}]. Le dernier versement est ajusté au solde exact. Les fréquences accélérées s'éteignent avant l'amortissement contractuel (comportement attendu). `max_periods` borne la simulation (ex. durée du terme).""" if principal <= 0: return [] i = periodic_rate(annual_pct, frequency, compounding) pmt = pay_amount if pay_amount is not None else payment( principal, annual_pct, amort_years, frequency, compounding) if pmt <= 0: return [] hard_cap = round(amort_years * FREQUENCIES[frequency]) + FREQUENCIES[frequency] if frequency.startswith("accelerated"): hard_cap = round(40 * FREQUENCIES[frequency]) # s'éteint plus tôt limit = min(max_periods, hard_cap) if max_periods else hard_cap rows: list[dict] = [] bal = round(principal, 2) n = 0 while bal > 0.005 and n < limit: n += 1 interest = round(bal * i, 2) cap = round(pmt - interest, 2) if cap <= 0 and n > 1: break # paiement insuffisant : ne jamais boucler à l'infini if cap >= bal: # dernier versement ajusté cap = bal row_pay = round(cap + interest, 2) else: row_pay = pmt bal = round(bal - cap, 2) rows.append({"n": n, "payment": row_pay, "interest": interest, "principal": cap, "balance": bal}) return rows def annual_rollup(rows: list[dict], frequency: str = "monthly") -> list[dict]: """Agrège un tableau d'amortissement par année de prêt.""" f = FREQUENCIES[frequency] out: list[dict] = [] for r in rows: year = (r["n"] - 1) // f + 1 if not out or out[-1]["year"] != year: out.append({"year": year, "payment": 0.0, "interest": 0.0, "principal": 0.0, "balance": r["balance"]}) acc = out[-1] acc["payment"] = round(acc["payment"] + r["payment"], 2) acc["interest"] = round(acc["interest"] + r["interest"], 2) acc["principal"] = round(acc["principal"] + r["principal"], 2) acc["balance"] = r["balance"] return out def term_summary(principal: float, annual_pct: float, amort_years: float, term_months: int, frequency: str = "monthly", compounding: str = "semi-annual") -> dict: """Bilan du terme : versement, nombre de versements, capital payé, intérêts payés, solde à l'échéance du terme.""" f = FREQUENCIES[frequency] n_term = round(f * term_months / 12) rows = schedule(principal, annual_pct, amort_years, frequency, compounding, max_periods=n_term) pmt = payment(principal, annual_pct, amort_years, frequency, compounding) interest = round(sum(r["interest"] for r in rows), 2) cap = round(sum(r["principal"] for r in rows), 2) balance = rows[-1]["balance"] if rows else round(principal, 2) payments_per_year = f return { "payment": pmt, "frequency": frequency, "payments_per_year": payments_per_year, "payments_in_term": len(rows), "annual_cost": round(pmt * payments_per_year, 2), "principal_paid": cap, "interest_paid": interest, "balance_end_of_term": balance, "paid_off": balance <= 0.005, } def payoff_years(principal: float, annual_pct: float, amort_years: float, frequency: str, compounding: str = "semi-annual") -> float: """Durée réelle d'extinction (années) — utile pour les fréquences accélérées qui raccourcissent l'amortissement.""" rows = schedule(principal, annual_pct, amort_years, frequency, compounding) if not rows or rows[-1]["balance"] > 0.005: return float(amort_years) return round(len(rows) / FREQUENCIES[frequency], 2) def max_loan(target_payment: float, annual_pct: float, amort_years: float, frequency: str = "monthly", compounding: str = "semi-annual") -> float: """Prêt maximal finançable avec un versement donné (calcul inverse).""" if target_payment <= 0: return 0.0 if frequency in ("accelerated-biweekly", "accelerated-weekly"): # équivalent : versement mensuel = paiement × 2 ou × 4 mult = 2 if frequency == "accelerated-biweekly" else 4 return max_loan(target_payment * mult, annual_pct, amort_years, "monthly", compounding) i = periodic_rate(annual_pct, frequency, compounding) n = round(amort_years * FREQUENCIES[frequency]) if i == 0: return round(target_payment * n, 2) return round(target_payment * (1.0 - (1.0 + i) ** -n) / i, 2) def required_rate(principal: float, target_payment: float, amort_years: float, frequency: str = "monthly", compounding: str = "semi-annual") -> float | None: """Taux annuel (%) tel que le versement du prêt = `target_payment`. Bisection sur [0, 25]. None si même 0 % ne suffit pas.""" if principal <= 0 or target_payment <= 0: return None if payment(principal, 0.0, amort_years, frequency, compounding) > target_payment: return None lo, hi = 0.0, 25.0 if payment(principal, hi, amort_years, frequency, compounding) < target_payment: return hi for _ in range(60): mid = (lo + hi) / 2 if payment(principal, mid, amort_years, frequency, compounding) > target_payment: hi = mid else: lo = mid return round(lo, 2) def qualifying_rate(contract_pct: float) -> float: """Taux de qualification du test de résistance canadien.""" return round(max(contract_pct + STRESS_TEST_BUFFER, STRESS_TEST_FLOOR), 2) def stress_scenarios(principal: float, annual_pct: float, amort_years: float, frequency: str = "monthly", compounding: str = "semi-annual", bumps: tuple = (0.0, 1.0, 2.0, 3.0)) -> list[dict]: """« Et si les taux montent ? » — versement à +0/+1/+2/+3 points.""" return [{ "bump": b, "rate": round(annual_pct + b, 2), "payment": payment(principal, annual_pct + b, amort_years, frequency, compounding), } for b in bumps] def renewal_scenarios(principal: float, annual_pct: float, amort_years: float, term_months: int, frequency: str = "monthly", compounding: str = "semi-annual", bumps: tuple = (-1.0, 0.0, 1.0, 2.0)) -> dict: """Scénario de renouvellement : solde restant à la fin du terme, puis versement recalculé sur l'amortissement résiduel à divers taux.""" summary = term_summary(principal, annual_pct, amort_years, term_months, frequency, compounding) balance = summary["balance_end_of_term"] remaining_years = max(amort_years - term_months / 12.0, 1.0) rows = [] for b in bumps: r = round(annual_pct + b, 2) if r <= 0 or balance <= 0: continue rows.append({"bump": b, "rate": r, "payment": payment(balance, r, remaining_years, frequency, compounding)}) return {"balance_at_renewal": balance, "remaining_amortization_years": round(remaining_years, 1), "scenarios": rows} def gds_tds(gross_annual_income: float, mortgage_payment_monthly: float, property_tax_monthly: float = 0.0, heating_monthly: float = 0.0, condo_fees_monthly: float = 0.0, other_debts_monthly: float = 0.0) -> dict: """Ratios ABD/ATD (GDS/TDS). Convention : 50 % des frais de copropriété. Seuils usuels assurés SCHL : ABD ≤ 39 %, ATD ≤ 44 %. Informatif seulement.""" if gross_annual_income <= 0: return {"gds": None, "tds": None, "gds_ok": None, "tds_ok": None} monthly_income = gross_annual_income / 12.0 housing = (mortgage_payment_monthly + property_tax_monthly + heating_monthly + 0.5 * condo_fees_monthly) gds = round(100.0 * housing / monthly_income, 1) tds = round(100.0 * (housing + other_debts_monthly) / monthly_income, 1) return {"gds": gds, "tds": tds, "gds_ok": gds <= 39.0, "tds_ok": tds <= 44.0, "gds_limit": 39.0, "tds_limit": 44.0}