# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # mortgage/cmhc.py : assurance prêt hypothécaire (SCHL / Sagen / Canada # Guaranty). Règles et barèmes isolés ici pour être mis à jour facilement. # Barème standard en vigueur (2026) — primes en % du prêt selon le # rapport prêt-valeur (RPV). # ----------------------------------------------------------------------------- from __future__ import annotations # (RPV maximal, prime en % du prêt) PREMIUM_TABLE: list[tuple[float, float]] = [ (0.65, 0.0060), (0.75, 0.0170), (0.80, 0.0240), (0.85, 0.0280), (0.90, 0.0310), (0.95, 0.0400), ] # Surprime pour amortissement assuré de 30 ans (premier acheteur / neuf, # admissible depuis déc. 2024). SURCHARGE_30Y = 0.0020 # Prix maximal admissible à l'assurance (porté de 1 M$ à 1,5 M$ en déc. 2024). MAX_INSURABLE_PRICE = 1_500_000 # Amortissement maximal assuré (30 ans seulement premier acheteur / neuf). MAX_INSURED_AMORTIZATION = 25 MAX_INSURED_AMORTIZATION_FTHB = 30 # Taxe de vente du Québec sur la prime (payable comptant à la clôture, # jamais ajoutée au prêt). QC_TAX_ON_PREMIUM = 0.09975 def min_down_payment(price: float) -> float: """Mise de fonds minimale légale au Canada. 5 % de la première tranche de 500 000 $, 10 % de l'excédent jusqu'à 1,5 M$ ; 20 % à compter de 1,5 M$.""" if price <= 0: return 0.0 if price >= MAX_INSURABLE_PRICE: return round(0.20 * price, 2) return round(0.05 * min(price, 500_000) + 0.10 * max(0.0, price - 500_000), 2) def premium_rate(ltv: float, amort_years: int = 25) -> float | None: """Taux de prime selon le RPV. None si RPV > 95 % (non assurable).""" if ltv <= 0: return 0.0 for cap, rate in PREMIUM_TABLE: if ltv <= cap + 1e-9: extra = SURCHARGE_30Y if amort_years > 25 else 0.0 return rate + extra return None def insurance_quote(price: float, down_payment: float, amort_years: int = 25) -> dict: """Décomposition complète de l'assurance prêt hypothécaire. Retourne : required (bool), eligible (bool), premium, premium_rate, loan_before, total_mortgage, qc_tax (payable comptant), ltv, issues[]. Ne devine rien : si le scénario est inadmissible, le dit explicitement. """ issues: list[str] = [] if price <= 0 or down_payment < 0 or down_payment >= price: return {"required": False, "eligible": False, "premium": 0.0, "premium_rate": 0.0, "loan_before": max(price - down_payment, 0.0), "total_mortgage": max(price - down_payment, 0.0), "qc_tax": 0.0, "ltv": None, "issues": ["paramètres invalides"]} loan = round(price - down_payment, 2) ltv = loan / price required = ltv > 0.80 + 1e-9 if not required: return {"required": False, "eligible": True, "premium": 0.0, "premium_rate": 0.0, "loan_before": loan, "total_mortgage": loan, "qc_tax": 0.0, "ltv": round(ltv * 100, 2), "issues": issues} # Prêt assuré : vérifier l'admissibilité. eligible = True if price >= MAX_INSURABLE_PRICE: eligible = False issues.append("prix ≥ 1,5 M$ : assurance non disponible — mise de " "fonds de 20 % requise") if down_payment < min_down_payment(price) - 0.01: eligible = False issues.append("mise de fonds sous le minimum légal " f"({min_down_payment(price):,.0f} $)".replace(",", " ")) if amort_years > MAX_INSURED_AMORTIZATION_FTHB: eligible = False issues.append("amortissement > 30 ans impossible pour un prêt assuré") elif amort_years > MAX_INSURED_AMORTIZATION: issues.append("30 ans assuré : réservé premier acheteur ou " "construction neuve (surprime de 0,20 %)") rate = premium_rate(ltv, amort_years) if eligible else None if rate is None and eligible: eligible = False issues.append("rapport prêt-valeur > 95 % : non assurable") premium = round(loan * rate, 2) if (eligible and rate) else 0.0 return { "required": True, "eligible": eligible, "premium": premium, "premium_rate": round((rate or 0.0) * 100, 2), "loan_before": loan, "total_mortgage": round(loan + premium, 2), "qc_tax": round(premium * QC_TAX_ON_PREMIUM, 2), "ltv": round(ltv * 100, 2), "issues": issues, } def allowed_amortizations(price: float, down_payment: float) -> list[int]: """Amortissements permis pour un scénario donné (25 ans max si assuré, 30 ans si mise de fonds ≥ 20 % — ou premier acheteur/neuf assuré).""" if price <= 0: return [10, 15, 20, 25, 30] ltv = (price - down_payment) / price if ltv > 0.80: return [10, 15, 20, 25, 30] # 30 = cas particulier (signalé par issues) return [10, 15, 20, 25, 30]