Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)
Python 47.5%
HTML 27.9%
TypeScript 15.5%
CSS 7.2%
JavaScript 2%
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