SPB Git forge

spb/house-ka

Public
18commits 1branches 0releases
1.9 MBsize
maindefault branch
19 days agolast push
Python 67% TypeScript 18.2% CSS 14.4%
11.1 KB · 258 lines python
Raw Blame History
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