1+ """Amortization (level payment) utilities.
2+
3+ Computes the level annuity payment and a per-period amortization schedule.
4+ Pure Python, no dependencies.
5+
6+ References
7+ ----------
8+ https://en.wikipedia.org/wiki/Amortization_calculator
9+ https://en.wikipedia.org/wiki/Amortization_schedule
110"""
2- Wiki: https://en.wikipedia.org/wiki/Amortization_calculator
3- """
411
512
6- def level_payment (principal , annual_rate_pct , years , payments_per_year = 12 ):
13+ def level_payment (
14+ principal : float ,
15+ annual_rate_pct : float ,
16+ years : int ,
17+ payments_per_year : int = 12 ,
18+ ) -> float :
19+ """Return the fixed payment for a fully amortizing loan.
20+
21+ Parameters
22+ ----------
23+ principal : float
24+ Initial loan amount (> 0).
25+ annual_rate_pct : float
26+ Annual percentage rate, e.g., 6.0 for 6%.
27+ years : int
28+ Loan term in years (> 0).
29+ payments_per_year : int, default 12
30+ Number of payments per year (> 0).
31+
32+ Returns
33+ -------
34+ float
35+ The level payment per period.
36+
37+ Examples
38+ --------
39+ >>> round(level_payment(10_000, 6.0, 15, 12), 4)
40+ 84.3857
41+ >>> round(level_payment(12_000, 0.0, 2, 12), 2)
42+ 500.0
43+ """
744 if principal <= 0 :
845 raise ValueError ("principal must be > 0" )
946 if years <= 0 or payments_per_year <= 0 :
1047 raise ValueError ("years and payments_per_year must be > 0" )
48+
1149 r = (annual_rate_pct / 100.0 ) / payments_per_year
1250 n = years * payments_per_year
1351 if r == 0 :
1452 return principal / n
53+
1554 factor = (1 + r ) ** n
1655 return principal * (r * factor ) / (factor - 1 )
1756
1857
1958def amortization_schedule (
20- principal ,
21- annual_rate_pct ,
22- years ,
23- payments_per_year = 12 ,
24- print_annual_summary = False ,
25- eps = 1e-9 ,
26- ):
59+ principal : float ,
60+ annual_rate_pct : float ,
61+ years : int ,
62+ payments_per_year : int = 12 ,
63+ print_annual_summary : bool = False ,
64+ eps : float = 1e-9 ,
65+ ) -> tuple [float , list [list [float ]]]:
66+ """Generate a fully amortizing schedule.
67+
68+ Each row is: [period, payment, interest, principal, balance]
69+
70+ Parameters
71+ ----------
72+ principal : float
73+ Initial loan amount.
74+ annual_rate_pct : float
75+ Annual percentage rate (APR), e.g., 5.5 for 5.5%.
76+ years : int
77+ Loan term in years.
78+ payments_per_year : int, default 12
79+ Payments per year (e.g., 12 monthly, 4 quarterly, 26 biweekly).
80+ print_annual_summary : bool, default False
81+ If True, prints a one-line summary every 12 months.
82+ eps : float, default 1e-9
83+ Tolerance for floating-point comparisons.
84+
85+ Returns
86+ -------
87+ (float, list[list[float]])
88+ Payment per period, and the amortization schedule.
89+
90+ Examples
91+ --------
92+ >>> pmt, sched = amortization_schedule(10_000, 6.0, 15, 12)
93+ >>> round(pmt, 4)
94+ 84.3857
95+ >>> round(sched[-1][4], 6) # ending balance ~ 0
96+ 0.0
97+ """
2798 pmt = level_payment (principal , annual_rate_pct , years , payments_per_year )
2899 r = (annual_rate_pct / 100.0 ) / payments_per_year
29100 n = years * payments_per_year
30101
31102 balance = float (principal )
32- schedule = []
103+ schedule : list [ list [ float ]] = []
33104
34105 if print_annual_summary :
35106 print (
@@ -43,37 +114,44 @@ def amortization_schedule(
43114 interest = balance * r
44115 principal_component = pmt - interest
45116
46- # shortpay on the last period if the scheduled principal would overshoot
117+ # Short-pay on the last period if the scheduled principal would over-shoot
47118 if principal_component > balance - eps :
48119 principal_component = balance
49120 payment_made = interest + principal_component
50121 else :
51122 payment_made = pmt
52123
53- if (
54- principal_component < 0 and principal_component > - eps
55- ): # clamp tiny negatives
124+ # Clamp tiny negatives from FP noise
125+ if 0 > principal_component > - eps :
56126 principal_component = 0.0
57127
58128 balance = max (0.0 , balance - principal_component )
59- schedule .append ([period , payment_made , interest , principal_component , balance ])
129+ schedule .append (
130+ [
131+ float (period ),
132+ float (payment_made ),
133+ float (interest ),
134+ float (principal_component ),
135+ float (balance ),
136+ ]
137+ )
60138
61- # streamline for all time periods (monthly/quarterly/biweekly/weekly)
139+ # Works for any cadence (monthly/quarterly/biweekly/weekly)
62140 months_elapsed = round ((period * 12 ) / payments_per_year )
63141
64142 if print_annual_summary and (months_elapsed % 12 == 0 or balance <= eps ):
65- tenure_left_periods = n - period
143+ tenure_left = n - period
66144 print (
67145 (
68- f"{ months_elapsed // 12 :<6} { months_elapsed :<12} { tenure_left_periods :<13} "
146+ f"{ months_elapsed // 12 :<6} { months_elapsed :<12} { tenure_left :<13} "
69147 f"{ pmt :<16.2f} { balance :<14.2f} "
70148 )
71149 )
72150
73151 if balance <= eps :
74152 break
75153
76- # normalize any tiny residual
154+ # Normalize final tiny residual to exact zero for cleanliness
77155 if schedule and schedule [- 1 ][4 ] <= eps :
78156 schedule [- 1 ][4 ] = 0.0
79157
0 commit comments