Skip to content

Commit 48e556e

Browse files
committed
Improve amortization module with type annotations and documentation
1 parent dd75579 commit 48e556e

File tree

1 file changed

+98
-20
lines changed

1 file changed

+98
-20
lines changed

financial/amortization.py

Lines changed: 98 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,106 @@
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

1958
def 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

Comments
 (0)