Python 67%
TypeScript 18.2%
CSS 14.4%
1# -----------------------------------------------------------------------------2# Immo-Ka — Agrégateur de maisons à vendre (province de Québec)3# Auteur : Simon-Pierre Boucher — contact@spboucher.ai4# mortgage/calc.py : mathématiques hypothécaires canadiennes.5# Taux fixes : intérêt composé SEMESTRIELLEMENT, non à l'avance (Loi sur6# l'intérêt, art. 6) — jamais la formule américaine (composition mensuelle).7# Taux variables : composition mensuelle (convention majoritaire des8# prêteurs canadiens).9# -----------------------------------------------------------------------------10from __future__ import annotations1112import math1314# Fréquences de paiement : nombre de versements par année.15FREQUENCIES: dict[str, int] = {16 "monthly": 12,17 "semimonthly": 24,18 "biweekly": 26,19 "accelerated-biweekly": 26,20 "weekly": 52,21 "accelerated-weekly": 52,22}2324# Taux de qualification minimal (test de résistance B-20 / ligne directrice25# du BSIF) : max(taux contractuel + 2 points, plancher).26STRESS_TEST_FLOOR = 5.2527STRESS_TEST_BUFFER = 2.02829AMORTIZATIONS_YEARS = [10, 15, 20, 25, 30]30TERMS_MONTHS = [12, 24, 36, 48, 60, 84, 120]313233def periodic_rate(annual_pct: float, frequency: str = "monthly",34 compounding: str = "semi-annual") -> float:35 """Taux périodique équivalent au taux nominal annuel `annual_pct` (%).3637 compounding="semi-annual" : convention canadienne des prêts fixes —38 i = (1 + r/2)^(2/f) − 1. "monthly" : prêts variables — i = (1+r/12)^(12/f) − 1.39 """40 if annual_pct < 0:41 raise ValueError("taux négatif")42 f = FREQUENCIES[frequency]43 r = annual_pct / 100.044 if compounding == "monthly":45 return (1.0 + r / 12.0) ** (12.0 / f) - 1.046 return (1.0 + r / 2.0) ** (2.0 / f) - 1.0474849def payment(principal: float, annual_pct: float, amort_years: float,50 frequency: str = "monthly",51 compounding: str = "semi-annual") -> float:52 """Versement périodique (arrondi au cent).5354 Fréquences accélérées : convention canadienne — le versement mensuel55 divisé par 2 (aux deux semaines) ou par 4 (hebdomadaire), ce qui raccourcit56 l'amortissement réel.57 """58 if principal <= 0:59 return 0.060 if frequency in ("accelerated-biweekly", "accelerated-weekly"):61 m = payment(principal, annual_pct, amort_years, "monthly", compounding)62 return round(m / (2 if frequency == "accelerated-biweekly" else 4), 2)63 i = periodic_rate(annual_pct, frequency, compounding)64 n = round(amort_years * FREQUENCIES[frequency])65 if i == 0:66 return round(principal / n, 2)67 return round(principal * i / (1.0 - (1.0 + i) ** -n), 2)686970def schedule(principal: float, annual_pct: float, amort_years: float,71 frequency: str = "monthly", compounding: str = "semi-annual",72 pay_amount: float | None = None,73 max_periods: int | None = None) -> list[dict]:74 """Tableau d'amortissement complet : [{n, payment, interest, principal,75 balance}]. Le dernier versement est ajusté au solde exact. Les fréquences76 accélérées s'éteignent avant l'amortissement contractuel (comportement77 attendu). `max_periods` borne la simulation (ex. durée du terme)."""78 if principal <= 0:79 return []80 i = periodic_rate(annual_pct, frequency, compounding)81 pmt = pay_amount if pay_amount is not None else payment(82 principal, annual_pct, amort_years, frequency, compounding)83 if pmt <= 0:84 return []85 hard_cap = round(amort_years * FREQUENCIES[frequency]) + FREQUENCIES[frequency]86 if frequency.startswith("accelerated"):87 hard_cap = round(40 * FREQUENCIES[frequency]) # s'éteint plus tôt88 limit = min(max_periods, hard_cap) if max_periods else hard_cap89 rows: list[dict] = []90 bal = round(principal, 2)91 n = 092 while bal > 0.005 and n < limit:93 n += 194 interest = round(bal * i, 2)95 cap = round(pmt - interest, 2)96 if cap <= 0 and n > 1:97 break # paiement insuffisant : ne jamais boucler à l'infini98 if cap >= bal: # dernier versement ajusté99 cap = bal100 row_pay = round(cap + interest, 2)101 else:102 row_pay = pmt103 bal = round(bal - cap, 2)104 rows.append({"n": n, "payment": row_pay, "interest": interest,105 "principal": cap, "balance": bal})106 return rows107108109def annual_rollup(rows: list[dict], frequency: str = "monthly") -> list[dict]:110 """Agrège un tableau d'amortissement par année de prêt."""111 f = FREQUENCIES[frequency]112 out: list[dict] = []113 for r in rows:114 year = (r["n"] - 1) // f + 1115 if not out or out[-1]["year"] != year:116 out.append({"year": year, "payment": 0.0, "interest": 0.0,117 "principal": 0.0, "balance": r["balance"]})118 acc = out[-1]119 acc["payment"] = round(acc["payment"] + r["payment"], 2)120 acc["interest"] = round(acc["interest"] + r["interest"], 2)121 acc["principal"] = round(acc["principal"] + r["principal"], 2)122 acc["balance"] = r["balance"]123 return out124125126def term_summary(principal: float, annual_pct: float, amort_years: float,127 term_months: int, frequency: str = "monthly",128 compounding: str = "semi-annual") -> dict:129 """Bilan du terme : versement, nombre de versements, capital payé,130 intérêts payés, solde à l'échéance du terme."""131 f = FREQUENCIES[frequency]132 n_term = round(f * term_months / 12)133 rows = schedule(principal, annual_pct, amort_years, frequency,134 compounding, max_periods=n_term)135 pmt = payment(principal, annual_pct, amort_years, frequency, compounding)136 interest = round(sum(r["interest"] for r in rows), 2)137 cap = round(sum(r["principal"] for r in rows), 2)138 balance = rows[-1]["balance"] if rows else round(principal, 2)139 payments_per_year = f140 return {141 "payment": pmt,142 "frequency": frequency,143 "payments_per_year": payments_per_year,144 "payments_in_term": len(rows),145 "annual_cost": round(pmt * payments_per_year, 2),146 "principal_paid": cap,147 "interest_paid": interest,148 "balance_end_of_term": balance,149 "paid_off": balance <= 0.005,150 }151152153def payoff_years(principal: float, annual_pct: float, amort_years: float,154 frequency: str, compounding: str = "semi-annual") -> float:155 """Durée réelle d'extinction (années) — utile pour les fréquences156 accélérées qui raccourcissent l'amortissement."""157 rows = schedule(principal, annual_pct, amort_years, frequency, compounding)158 if not rows or rows[-1]["balance"] > 0.005:159 return float(amort_years)160 return round(len(rows) / FREQUENCIES[frequency], 2)161162163def max_loan(target_payment: float, annual_pct: float, amort_years: float,164 frequency: str = "monthly",165 compounding: str = "semi-annual") -> float:166 """Prêt maximal finançable avec un versement donné (calcul inverse)."""167 if target_payment <= 0:168 return 0.0169 if frequency in ("accelerated-biweekly", "accelerated-weekly"):170 # équivalent : versement mensuel = paiement × 2 ou × 4171 mult = 2 if frequency == "accelerated-biweekly" else 4172 return max_loan(target_payment * mult, annual_pct, amort_years,173 "monthly", compounding)174 i = periodic_rate(annual_pct, frequency, compounding)175 n = round(amort_years * FREQUENCIES[frequency])176 if i == 0:177 return round(target_payment * n, 2)178 return round(target_payment * (1.0 - (1.0 + i) ** -n) / i, 2)179180181def required_rate(principal: float, target_payment: float, amort_years: float,182 frequency: str = "monthly",183 compounding: str = "semi-annual") -> float | None:184 """Taux annuel (%) tel que le versement du prêt = `target_payment`.185 Bisection sur [0, 25]. None si même 0 % ne suffit pas."""186 if principal <= 0 or target_payment <= 0:187 return None188 if payment(principal, 0.0, amort_years, frequency, compounding) > target_payment:189 return None190 lo, hi = 0.0, 25.0191 if payment(principal, hi, amort_years, frequency, compounding) < target_payment:192 return hi193 for _ in range(60):194 mid = (lo + hi) / 2195 if payment(principal, mid, amort_years, frequency, compounding) > target_payment:196 hi = mid197 else:198 lo = mid199 return round(lo, 2)200201202def qualifying_rate(contract_pct: float) -> float:203 """Taux de qualification du test de résistance canadien."""204 return round(max(contract_pct + STRESS_TEST_BUFFER, STRESS_TEST_FLOOR), 2)205206207def stress_scenarios(principal: float, annual_pct: float, amort_years: float,208 frequency: str = "monthly",209 compounding: str = "semi-annual",210 bumps: tuple = (0.0, 1.0, 2.0, 3.0)) -> list[dict]:211 """« Et si les taux montent ? » — versement à +0/+1/+2/+3 points."""212 return [{213 "bump": b,214 "rate": round(annual_pct + b, 2),215 "payment": payment(principal, annual_pct + b, amort_years,216 frequency, compounding),217 } for b in bumps]218219220def renewal_scenarios(principal: float, annual_pct: float, amort_years: float,221 term_months: int, frequency: str = "monthly",222 compounding: str = "semi-annual",223 bumps: tuple = (-1.0, 0.0, 1.0, 2.0)) -> dict:224 """Scénario de renouvellement : solde restant à la fin du terme, puis225 versement recalculé sur l'amortissement résiduel à divers taux."""226 summary = term_summary(principal, annual_pct, amort_years, term_months,227 frequency, compounding)228 balance = summary["balance_end_of_term"]229 remaining_years = max(amort_years - term_months / 12.0, 1.0)230 rows = []231 for b in bumps:232 r = round(annual_pct + b, 2)233 if r <= 0 or balance <= 0:234 continue235 rows.append({"bump": b, "rate": r,236 "payment": payment(balance, r, remaining_years,237 frequency, compounding)})238 return {"balance_at_renewal": balance,239 "remaining_amortization_years": round(remaining_years, 1),240 "scenarios": rows}241242243def gds_tds(gross_annual_income: float, mortgage_payment_monthly: float,244 property_tax_monthly: float = 0.0, heating_monthly: float = 0.0,245 condo_fees_monthly: float = 0.0,246 other_debts_monthly: float = 0.0) -> dict:247 """Ratios ABD/ATD (GDS/TDS). Convention : 50 % des frais de copropriété.248 Seuils usuels assurés SCHL : ABD ≤ 39 %, ATD ≤ 44 %. Informatif seulement."""249 if gross_annual_income <= 0:250 return {"gds": None, "tds": None, "gds_ok": None, "tds_ok": None}251 monthly_income = gross_annual_income / 12.0252 housing = (mortgage_payment_monthly + property_tax_monthly +253 heating_monthly + 0.5 * condo_fees_monthly)254 gds = round(100.0 * housing / monthly_income, 1)255 tds = round(100.0 * (housing + other_debts_monthly) / monthly_income, 1)256 return {"gds": gds, "tds": tds, "gds_ok": gds <= 39.0, "tds_ok": tds <= 44.0,257 "gds_limit": 39.0, "tds_limit": 44.0}258