SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
5.8 KB · 137 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# tests/test_mortgage_calc.py : mathématiques hypothécaires canadiennes —5#   valeurs de référence vérifiables à la main (composition semestrielle).6# -----------------------------------------------------------------------------7import unittest89from immoka.mortgage import calc101112class TestPayment(unittest.TestCase):13    def test_golden_semi_annual(self):14        # Référence canonique : 100 000 $ @ 6 % / 25 ans, composition15        # semestrielle (Loi sur l'intérêt) → 639,81 $/mois.16        self.assertEqual(calc.payment(100_000, 6.0, 25, "monthly"), 639.81)1718    def test_monthly_compounding_differs(self):19        # La formule américaine (composition mensuelle) donne ~644,30 $ :20        # si les deux coïncidaient, la convention serait mal appliquée.21        us = calc.payment(100_000, 6.0, 25, "monthly", compounding="monthly")22        self.assertAlmostEqual(us, 644.30, delta=0.02)23        self.assertGreater(us, calc.payment(100_000, 6.0, 25, "monthly"))2425    def test_zero_rate(self):26        self.assertEqual(calc.payment(120_000, 0.0, 10, "monthly"), 1000.0)2728    def test_accelerated_biweekly_is_half_monthly(self):29        m = calc.payment(400_000, 5.0, 25, "monthly")30        self.assertEqual(calc.payment(400_000, 5.0, 25, "accelerated-biweekly"),31                         round(m / 2, 2))3233    def test_accelerated_weekly_is_quarter_monthly(self):34        m = calc.payment(400_000, 5.0, 25, "monthly")35        self.assertEqual(calc.payment(400_000, 5.0, 25, "accelerated-weekly"),36                         round(m / 4, 2))3738    def test_negative_rate_raises(self):39        with self.assertRaises(ValueError):40            calc.periodic_rate(-1.0)414243class TestSchedule(unittest.TestCase):44    def test_amortizes_to_zero(self):45        rows = calc.schedule(300_000, 4.5, 25, "monthly")46        self.assertEqual(rows[-1]["balance"], 0.0)47        # 25 ans × 12 : dernier versement ajusté, ±1 période d'arrondi48        self.assertAlmostEqual(len(rows), 300, delta=1)4950    def test_principal_sums_to_loan(self):51        rows = calc.schedule(250_000, 5.25, 20, "monthly")52        self.assertAlmostEqual(sum(r["principal"] for r in rows), 250_000,53                               delta=0.05)5455    def test_accelerated_pays_off_early(self):56        self.assertLess(calc.payoff_years(300_000, 5.0, 25,57                                          "accelerated-biweekly"), 23.0)58        # ±1 période d'arrondi sur le dernier versement ajusté59        self.assertAlmostEqual(calc.payoff_years(300_000, 5.0, 25, "monthly"),60                               25.0, delta=0.1)616263class TestTermSummary(unittest.TestCase):64    def test_five_year_term(self):65        s = calc.term_summary(300_000, 4.5, 25, 60, "monthly")66        self.assertEqual(s["payments_in_term"], 60)67        self.assertFalse(s["paid_off"])68        self.assertGreater(s["balance_end_of_term"], 0)69        self.assertLess(s["balance_end_of_term"], 300_000)70        self.assertAlmostEqual(s["principal_paid"] + s["balance_end_of_term"],71                               300_000, delta=0.05)72        self.assertEqual(s["annual_cost"], round(s["payment"] * 12, 2))737475class TestInverse(unittest.TestCase):76    def test_max_loan_inverse_of_payment(self):77        pmt = calc.payment(350_000, 4.79, 25, "monthly")78        self.assertAlmostEqual(calc.max_loan(pmt, 4.79, 25, "monthly"),79                               350_000, delta=5)8081    def test_required_rate_inverse_of_payment(self):82        pmt = calc.payment(350_000, 4.79, 25, "monthly")83        self.assertAlmostEqual(calc.required_rate(350_000, pmt, 25, "monthly"),84                               4.79, delta=0.01)8586    def test_required_rate_none_if_impossible(self):87        # même 0 % exige > 1000 $/mois pour 350 k$ sur 25 ans88        self.assertIsNone(calc.required_rate(350_000, 1000, 25, "monthly"))8990    def test_max_loan_accelerated(self):91        self.assertEqual(calc.max_loan(1000, 5.0, 25, "accelerated-biweekly"),92                         calc.max_loan(2000, 5.0, 25, "monthly"))939495class TestStress(unittest.TestCase):96    def test_qualifying_rate(self):97        self.assertEqual(calc.qualifying_rate(4.19), 6.19)98        self.assertEqual(calc.qualifying_rate(2.5), 5.25)   # plancher99        self.assertEqual(calc.qualifying_rate(3.25), 5.25)  # 3.25+2 = plancher100101    def test_stress_scenarios(self):102        rows = calc.stress_scenarios(300_000, 4.0, 25)103        self.assertEqual([r["bump"] for r in rows], [0.0, 1.0, 2.0, 3.0])104        self.assertEqual(rows[0]["payment"],105                         calc.payment(300_000, 4.0, 25, "monthly"))106        pays = [r["payment"] for r in rows]107        self.assertEqual(pays, sorted(pays))  # monte avec le taux108109    def test_renewal(self):110        r = calc.renewal_scenarios(300_000, 4.5, 25, 60)111        self.assertEqual(r["remaining_amortization_years"], 20.0)112        self.assertGreater(r["balance_at_renewal"], 0)113        self.assertTrue(all(s["payment"] > 0 for s in r["scenarios"]))114115116class TestGdsTds(unittest.TestCase):117    def test_ratios(self):118        # 120 k$/an → 10 000 $/mois ; logement = 2000+300+100+0,5×400 = 2600119        r = calc.gds_tds(120_000, 2000, 300, 100, 400, 500)120        self.assertEqual(r["gds"], 26.0)121        self.assertEqual(r["tds"], 31.0)122        self.assertTrue(r["gds_ok"] and r["tds_ok"])123124    def test_over_limits(self):125        r = calc.gds_tds(60_000, 2000, 300, 100, 0, 500)126        self.assertFalse(r["gds_ok"])127        self.assertFalse(r["tds_ok"])128129    def test_no_income(self):130        r = calc.gds_tds(0, 2000)131        self.assertIsNone(r["gds"])132        self.assertIsNone(r["tds_ok"])133134135if __name__ == "__main__":136    unittest.main()137