# ----------------------------------------------------------------------------- # Immo-Ka — Agrégateur de maisons à vendre (province de Québec) # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # tests/test_mortgage_calc.py : mathématiques hypothécaires canadiennes — # valeurs de référence vérifiables à la main (composition semestrielle). # ----------------------------------------------------------------------------- import unittest from immoka.mortgage import calc class TestPayment(unittest.TestCase): def test_golden_semi_annual(self): # Référence canonique : 100 000 $ @ 6 % / 25 ans, composition # semestrielle (Loi sur l'intérêt) → 639,81 $/mois. self.assertEqual(calc.payment(100_000, 6.0, 25, "monthly"), 639.81) def test_monthly_compounding_differs(self): # La formule américaine (composition mensuelle) donne ~644,30 $ : # si les deux coïncidaient, la convention serait mal appliquée. us = calc.payment(100_000, 6.0, 25, "monthly", compounding="monthly") self.assertAlmostEqual(us, 644.30, delta=0.02) self.assertGreater(us, calc.payment(100_000, 6.0, 25, "monthly")) def test_zero_rate(self): self.assertEqual(calc.payment(120_000, 0.0, 10, "monthly"), 1000.0) def test_accelerated_biweekly_is_half_monthly(self): m = calc.payment(400_000, 5.0, 25, "monthly") self.assertEqual(calc.payment(400_000, 5.0, 25, "accelerated-biweekly"), round(m / 2, 2)) def test_accelerated_weekly_is_quarter_monthly(self): m = calc.payment(400_000, 5.0, 25, "monthly") self.assertEqual(calc.payment(400_000, 5.0, 25, "accelerated-weekly"), round(m / 4, 2)) def test_negative_rate_raises(self): with self.assertRaises(ValueError): calc.periodic_rate(-1.0) class TestSchedule(unittest.TestCase): def test_amortizes_to_zero(self): rows = calc.schedule(300_000, 4.5, 25, "monthly") self.assertEqual(rows[-1]["balance"], 0.0) # 25 ans × 12 : dernier versement ajusté, ±1 période d'arrondi self.assertAlmostEqual(len(rows), 300, delta=1) def test_principal_sums_to_loan(self): rows = calc.schedule(250_000, 5.25, 20, "monthly") self.assertAlmostEqual(sum(r["principal"] for r in rows), 250_000, delta=0.05) def test_accelerated_pays_off_early(self): self.assertLess(calc.payoff_years(300_000, 5.0, 25, "accelerated-biweekly"), 23.0) # ±1 période d'arrondi sur le dernier versement ajusté self.assertAlmostEqual(calc.payoff_years(300_000, 5.0, 25, "monthly"), 25.0, delta=0.1) class TestTermSummary(unittest.TestCase): def test_five_year_term(self): s = calc.term_summary(300_000, 4.5, 25, 60, "monthly") self.assertEqual(s["payments_in_term"], 60) self.assertFalse(s["paid_off"]) self.assertGreater(s["balance_end_of_term"], 0) self.assertLess(s["balance_end_of_term"], 300_000) self.assertAlmostEqual(s["principal_paid"] + s["balance_end_of_term"], 300_000, delta=0.05) self.assertEqual(s["annual_cost"], round(s["payment"] * 12, 2)) class TestInverse(unittest.TestCase): def test_max_loan_inverse_of_payment(self): pmt = calc.payment(350_000, 4.79, 25, "monthly") self.assertAlmostEqual(calc.max_loan(pmt, 4.79, 25, "monthly"), 350_000, delta=5) def test_required_rate_inverse_of_payment(self): pmt = calc.payment(350_000, 4.79, 25, "monthly") self.assertAlmostEqual(calc.required_rate(350_000, pmt, 25, "monthly"), 4.79, delta=0.01) def test_required_rate_none_if_impossible(self): # même 0 % exige > 1000 $/mois pour 350 k$ sur 25 ans self.assertIsNone(calc.required_rate(350_000, 1000, 25, "monthly")) def test_max_loan_accelerated(self): self.assertEqual(calc.max_loan(1000, 5.0, 25, "accelerated-biweekly"), calc.max_loan(2000, 5.0, 25, "monthly")) class TestStress(unittest.TestCase): def test_qualifying_rate(self): self.assertEqual(calc.qualifying_rate(4.19), 6.19) self.assertEqual(calc.qualifying_rate(2.5), 5.25) # plancher self.assertEqual(calc.qualifying_rate(3.25), 5.25) # 3.25+2 = plancher def test_stress_scenarios(self): rows = calc.stress_scenarios(300_000, 4.0, 25) self.assertEqual([r["bump"] for r in rows], [0.0, 1.0, 2.0, 3.0]) self.assertEqual(rows[0]["payment"], calc.payment(300_000, 4.0, 25, "monthly")) pays = [r["payment"] for r in rows] self.assertEqual(pays, sorted(pays)) # monte avec le taux def test_renewal(self): r = calc.renewal_scenarios(300_000, 4.5, 25, 60) self.assertEqual(r["remaining_amortization_years"], 20.0) self.assertGreater(r["balance_at_renewal"], 0) self.assertTrue(all(s["payment"] > 0 for s in r["scenarios"])) class TestGdsTds(unittest.TestCase): def test_ratios(self): # 120 k$/an → 10 000 $/mois ; logement = 2000+300+100+0,5×400 = 2600 r = calc.gds_tds(120_000, 2000, 300, 100, 400, 500) self.assertEqual(r["gds"], 26.0) self.assertEqual(r["tds"], 31.0) self.assertTrue(r["gds_ok"] and r["tds_ok"]) def test_over_limits(self): r = calc.gds_tds(60_000, 2000, 300, 100, 0, 500) self.assertFalse(r["gds_ok"]) self.assertFalse(r["tds_ok"]) def test_no_income(self): r = calc.gds_tds(0, 2000) self.assertIsNone(r["gds"]) self.assertIsNone(r["tds_ok"]) if __name__ == "__main__": unittest.main()