From 639344e061b9659b6f72c831bb744ecf7d46dc46 Mon Sep 17 00:00:00 2001 From: Rohan Babaria <107670599+Nitroxium18@users.noreply.github.com> Date: Tue, 14 Oct 2025 04:59:17 +0530 Subject: [PATCH 1/7] Add amortization calculator to financial folder --- financial/amortization.py | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 financial/amortization.py diff --git a/financial/amortization.py b/financial/amortization.py new file mode 100644 index 000000000000..43b5f5de1715 --- /dev/null +++ b/financial/amortization.py @@ -0,0 +1,60 @@ +def level_payment(principal, annual_rate_pct, years, payments_per_year=12): + if principal <= 0: + raise ValueError("principal must be > 0") + if years <= 0 or payments_per_year <= 0: + raise ValueError("years and payments_per_year must be > 0") + r = (annual_rate_pct / 100.0) / payments_per_year + n = years * payments_per_year + if r == 0: + return principal / n + factor = (1 + r) ** n + return principal * (r * factor) / (factor - 1) + + +def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=12, print_annual_summary=False, eps=1e-9): + pmt = level_payment(principal, annual_rate_pct, years, payments_per_year) + r = (annual_rate_pct / 100.0) / payments_per_year + n = years * payments_per_year + + balance = float(principal) + schedule = [] + + if print_annual_summary: + print(f"{'Year':<6}{'Months Pd':<12}{'Tenure Left':<13}{'Payment/Period':<16}{'Outstanding':<14}") + + for period in range(1, n + 1): + interest = balance * r + principal_component = pmt - interest + + # shortpay on the last period if the scheduled principal would overshoot + if principal_component > balance - eps: + principal_component = balance + payment_made = interest + principal_component + else: + payment_made = pmt + + if principal_component < 0 and principal_component > -eps: # clamp tiny negatives + principal_component = 0.0 + + balance = max(0.0, balance - principal_component) + schedule.append([period, payment_made, interest, principal_component, balance]) + + # streamline for all time periods (monthly/quarterly/biweekly/weekly) + months_elapsed = int(round((period * 12) / payments_per_year)) + + if print_annual_summary and (months_elapsed % 12 == 0 or balance <= eps): + tenure_left_periods = n - period + print(f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left_periods:<13}{pmt:<16.2f}{balance:<14.2f}") + + if balance <= eps: + break + + # normalize any tiny residual + if schedule and schedule[-1][4] <= eps: + schedule[-1][4] = 0.0 + + return round(pmt, 4), schedule + + +pmt, sched = amortization_schedule(10000, 5.5, 15, payments_per_year=12, print_annual_summary=True) +print(pmt) \ No newline at end of file From df4838f4d12ec49337e660f5fa3d1b10c4828ec5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 00:07:41 +0000 Subject: [PATCH 2/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- financial/amortization.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/financial/amortization.py b/financial/amortization.py index 43b5f5de1715..3e190979ba57 100644 --- a/financial/amortization.py +++ b/financial/amortization.py @@ -11,7 +11,14 @@ def level_payment(principal, annual_rate_pct, years, payments_per_year=12): return principal * (r * factor) / (factor - 1) -def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=12, print_annual_summary=False, eps=1e-9): +def amortization_schedule( + principal, + annual_rate_pct, + years, + payments_per_year=12, + print_annual_summary=False, + eps=1e-9, +): pmt = level_payment(principal, annual_rate_pct, years, payments_per_year) r = (annual_rate_pct / 100.0) / payments_per_year n = years * payments_per_year @@ -20,7 +27,9 @@ def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=1 schedule = [] if print_annual_summary: - print(f"{'Year':<6}{'Months Pd':<12}{'Tenure Left':<13}{'Payment/Period':<16}{'Outstanding':<14}") + print( + f"{'Year':<6}{'Months Pd':<12}{'Tenure Left':<13}{'Payment/Period':<16}{'Outstanding':<14}" + ) for period in range(1, n + 1): interest = balance * r @@ -33,7 +42,9 @@ def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=1 else: payment_made = pmt - if principal_component < 0 and principal_component > -eps: # clamp tiny negatives + if ( + principal_component < 0 and principal_component > -eps + ): # clamp tiny negatives principal_component = 0.0 balance = max(0.0, balance - principal_component) @@ -44,7 +55,9 @@ def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=1 if print_annual_summary and (months_elapsed % 12 == 0 or balance <= eps): tenure_left_periods = n - period - print(f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left_periods:<13}{pmt:<16.2f}{balance:<14.2f}") + print( + f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left_periods:<13}{pmt:<16.2f}{balance:<14.2f}" + ) if balance <= eps: break @@ -56,5 +69,7 @@ def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=1 return round(pmt, 4), schedule -pmt, sched = amortization_schedule(10000, 5.5, 15, payments_per_year=12, print_annual_summary=True) -print(pmt) \ No newline at end of file +pmt, sched = amortization_schedule( + 10000, 5.5, 15, payments_per_year=12, print_annual_summary=True +) +print(pmt) From 38cf066e1fe32651ab233820cf766dd829dc3ca3 Mon Sep 17 00:00:00 2001 From: Rohan Babaria <107670599+Nitroxium18@users.noreply.github.com> Date: Tue, 14 Oct 2025 06:20:15 +0530 Subject: [PATCH 3/7] Shortened amortization.py --- financial/amortization.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/financial/amortization.py b/financial/amortization.py index 3e190979ba57..e7ab97e3cac2 100644 --- a/financial/amortization.py +++ b/financial/amortization.py @@ -1,3 +1,8 @@ +""" +Wiki: https://en.wikipedia.org/wiki/Amortization_calculator +""" + + def level_payment(principal, annual_rate_pct, years, payments_per_year=12): if principal <= 0: raise ValueError("principal must be > 0") @@ -11,14 +16,7 @@ def level_payment(principal, annual_rate_pct, years, payments_per_year=12): return principal * (r * factor) / (factor - 1) -def amortization_schedule( - principal, - annual_rate_pct, - years, - payments_per_year=12, - print_annual_summary=False, - eps=1e-9, -): +def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=12, print_annual_summary=False, eps=1e-9): pmt = level_payment(principal, annual_rate_pct, years, payments_per_year) r = (annual_rate_pct / 100.0) / payments_per_year n = years * payments_per_year @@ -28,9 +26,13 @@ def amortization_schedule( if print_annual_summary: print( - f"{'Year':<6}{'Months Pd':<12}{'Tenure Left':<13}{'Payment/Period':<16}{'Outstanding':<14}" + ( + f"{'Year':<6}{'Months Pd':<12}{'Tenure Left':<13}" + f"{'Payment/Period':<16}{'Outstanding':<14}" + ) ) + for period in range(1, n + 1): interest = balance * r principal_component = pmt - interest @@ -42,22 +44,23 @@ def amortization_schedule( else: payment_made = pmt - if ( - principal_component < 0 and principal_component > -eps - ): # clamp tiny negatives + if principal_component < 0 and principal_component > -eps: # clamp tiny negatives principal_component = 0.0 balance = max(0.0, balance - principal_component) schedule.append([period, payment_made, interest, principal_component, balance]) # streamline for all time periods (monthly/quarterly/biweekly/weekly) - months_elapsed = int(round((period * 12) / payments_per_year)) + months_elapsed = (round((period * 12) / payments_per_year)) if print_annual_summary and (months_elapsed % 12 == 0 or balance <= eps): tenure_left_periods = n - period print( - f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left_periods:<13}{pmt:<16.2f}{balance:<14.2f}" + ( + f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left_periods:<13}" + f"{pmt:<16.2f}{balance:<14.2f}" ) + ) if balance <= eps: break @@ -69,7 +72,5 @@ def amortization_schedule( return round(pmt, 4), schedule -pmt, sched = amortization_schedule( - 10000, 5.5, 15, payments_per_year=12, print_annual_summary=True -) +pmt, sched = amortization_schedule(10000, 5.5, 15, payments_per_year=12, print_annual_summary=True) print(pmt) From 57993483048cec930f44d838ce3998cb4e46d8a0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 14 Oct 2025 00:50:36 +0000 Subject: [PATCH 4/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- financial/amortization.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/financial/amortization.py b/financial/amortization.py index e7ab97e3cac2..34e82a0fa9c8 100644 --- a/financial/amortization.py +++ b/financial/amortization.py @@ -16,7 +16,14 @@ def level_payment(principal, annual_rate_pct, years, payments_per_year=12): return principal * (r * factor) / (factor - 1) -def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=12, print_annual_summary=False, eps=1e-9): +def amortization_schedule( + principal, + annual_rate_pct, + years, + payments_per_year=12, + print_annual_summary=False, + eps=1e-9, +): pmt = level_payment(principal, annual_rate_pct, years, payments_per_year) r = (annual_rate_pct / 100.0) / payments_per_year n = years * payments_per_year @@ -32,7 +39,6 @@ def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=1 ) ) - for period in range(1, n + 1): interest = balance * r principal_component = pmt - interest @@ -44,23 +50,25 @@ def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=1 else: payment_made = pmt - if principal_component < 0 and principal_component > -eps: # clamp tiny negatives + if ( + principal_component < 0 and principal_component > -eps + ): # clamp tiny negatives principal_component = 0.0 balance = max(0.0, balance - principal_component) schedule.append([period, payment_made, interest, principal_component, balance]) # streamline for all time periods (monthly/quarterly/biweekly/weekly) - months_elapsed = (round((period * 12) / payments_per_year)) + months_elapsed = round((period * 12) / payments_per_year) if print_annual_summary and (months_elapsed % 12 == 0 or balance <= eps): tenure_left_periods = n - period print( - ( - f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left_periods:<13}" - f"{pmt:<16.2f}{balance:<14.2f}" + ( + f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left_periods:<13}" + f"{pmt:<16.2f}{balance:<14.2f}" + ) ) - ) if balance <= eps: break @@ -72,5 +80,7 @@ def amortization_schedule(principal, annual_rate_pct, years, payments_per_year=1 return round(pmt, 4), schedule -pmt, sched = amortization_schedule(10000, 5.5, 15, payments_per_year=12, print_annual_summary=True) +pmt, sched = amortization_schedule( + 10000, 5.5, 15, payments_per_year=12, print_annual_summary=True +) print(pmt) From dd75579300e8cd56fac3d76f99fcd4a98283b2d4 Mon Sep 17 00:00:00 2001 From: Rohan Babaria <107670599+Nitroxium18@users.noreply.github.com> Date: Tue, 14 Oct 2025 06:41:23 +0530 Subject: [PATCH 5/7] Updated amortization.py Removed redundant code & shortened print --- financial/amortization.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/financial/amortization.py b/financial/amortization.py index 34e82a0fa9c8..ea9ff41a0117 100644 --- a/financial/amortization.py +++ b/financial/amortization.py @@ -78,9 +78,3 @@ def amortization_schedule( schedule[-1][4] = 0.0 return round(pmt, 4), schedule - - -pmt, sched = amortization_schedule( - 10000, 5.5, 15, payments_per_year=12, print_annual_summary=True -) -print(pmt) From 48e556ea6924e98d95c5ac488f0a4fec0066128e Mon Sep 17 00:00:00 2001 From: Rohan Babaria <107670599+Nitroxium18@users.noreply.github.com> Date: Tue, 14 Oct 2025 01:44:56 +0000 Subject: [PATCH 6/7] Improve amortization module with type annotations and documentation --- financial/amortization.py | 118 +++++++++++++++++++++++++++++++------- 1 file changed, 98 insertions(+), 20 deletions(-) diff --git a/financial/amortization.py b/financial/amortization.py index ea9ff41a0117..b1612c1967b0 100644 --- a/financial/amortization.py +++ b/financial/amortization.py @@ -1,35 +1,106 @@ +"""Amortization (level payment) utilities. + +Computes the level annuity payment and a per-period amortization schedule. +Pure Python, no dependencies. + +References +---------- +https://en.wikipedia.org/wiki/Amortization_calculator +https://en.wikipedia.org/wiki/Amortization_schedule """ -Wiki: https://en.wikipedia.org/wiki/Amortization_calculator -""" -def level_payment(principal, annual_rate_pct, years, payments_per_year=12): +def level_payment( + principal: float, + annual_rate_pct: float, + years: int, + payments_per_year: int = 12, +) -> float: + """Return the fixed payment for a fully amortizing loan. + + Parameters + ---------- + principal : float + Initial loan amount (> 0). + annual_rate_pct : float + Annual percentage rate, e.g., 6.0 for 6%. + years : int + Loan term in years (> 0). + payments_per_year : int, default 12 + Number of payments per year (> 0). + + Returns + ------- + float + The level payment per period. + + Examples + -------- + >>> round(level_payment(10_000, 6.0, 15, 12), 4) + 84.3857 + >>> round(level_payment(12_000, 0.0, 2, 12), 2) + 500.0 + """ if principal <= 0: raise ValueError("principal must be > 0") if years <= 0 or payments_per_year <= 0: raise ValueError("years and payments_per_year must be > 0") + r = (annual_rate_pct / 100.0) / payments_per_year n = years * payments_per_year if r == 0: return principal / n + factor = (1 + r) ** n return principal * (r * factor) / (factor - 1) def amortization_schedule( - principal, - annual_rate_pct, - years, - payments_per_year=12, - print_annual_summary=False, - eps=1e-9, -): + principal: float, + annual_rate_pct: float, + years: int, + payments_per_year: int = 12, + print_annual_summary: bool = False, + eps: float = 1e-9, +) -> tuple[float, list[list[float]]]: + """Generate a fully amortizing schedule. + + Each row is: [period, payment, interest, principal, balance] + + Parameters + ---------- + principal : float + Initial loan amount. + annual_rate_pct : float + Annual percentage rate (APR), e.g., 5.5 for 5.5%. + years : int + Loan term in years. + payments_per_year : int, default 12 + Payments per year (e.g., 12 monthly, 4 quarterly, 26 biweekly). + print_annual_summary : bool, default False + If True, prints a one-line summary every 12 months. + eps : float, default 1e-9 + Tolerance for floating-point comparisons. + + Returns + ------- + (float, list[list[float]]) + Payment per period, and the amortization schedule. + + Examples + -------- + >>> pmt, sched = amortization_schedule(10_000, 6.0, 15, 12) + >>> round(pmt, 4) + 84.3857 + >>> round(sched[-1][4], 6) # ending balance ~ 0 + 0.0 + """ pmt = level_payment(principal, annual_rate_pct, years, payments_per_year) r = (annual_rate_pct / 100.0) / payments_per_year n = years * payments_per_year balance = float(principal) - schedule = [] + schedule: list[list[float]] = [] if print_annual_summary: print( @@ -43,29 +114,36 @@ def amortization_schedule( interest = balance * r principal_component = pmt - interest - # shortpay on the last period if the scheduled principal would overshoot + # Short-pay on the last period if the scheduled principal would over-shoot if principal_component > balance - eps: principal_component = balance payment_made = interest + principal_component else: payment_made = pmt - if ( - principal_component < 0 and principal_component > -eps - ): # clamp tiny negatives + # Clamp tiny negatives from FP noise + if 0 > principal_component > -eps: principal_component = 0.0 balance = max(0.0, balance - principal_component) - schedule.append([period, payment_made, interest, principal_component, balance]) + schedule.append( + [ + float(period), + float(payment_made), + float(interest), + float(principal_component), + float(balance), + ] + ) - # streamline for all time periods (monthly/quarterly/biweekly/weekly) + # Works for any cadence (monthly/quarterly/biweekly/weekly) months_elapsed = round((period * 12) / payments_per_year) if print_annual_summary and (months_elapsed % 12 == 0 or balance <= eps): - tenure_left_periods = n - period + tenure_left = n - period print( ( - f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left_periods:<13}" + f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left:<13}" f"{pmt:<16.2f}{balance:<14.2f}" ) ) @@ -73,7 +151,7 @@ def amortization_schedule( if balance <= eps: break - # normalize any tiny residual + # Normalize final tiny residual to exact zero for cleanliness if schedule and schedule[-1][4] <= eps: schedule[-1][4] = 0.0 From 743a527f3be1b4bcd550411a360f593536e90c33 Mon Sep 17 00:00:00 2001 From: Rohan Babaria <107670599+Nitroxium18@users.noreply.github.com> Date: Tue, 14 Oct 2025 01:49:11 +0000 Subject: [PATCH 7/7] Fix Ruff UP034 extraneous parentheses --- financial/amortization.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/financial/amortization.py b/financial/amortization.py index b1612c1967b0..8d11972549cf 100644 --- a/financial/amortization.py +++ b/financial/amortization.py @@ -104,10 +104,8 @@ def amortization_schedule( if print_annual_summary: print( - ( - f"{'Year':<6}{'Months Pd':<12}{'Tenure Left':<13}" - f"{'Payment/Period':<16}{'Outstanding':<14}" - ) + f"{'Year':<6}{'Months Pd':<12}{'Tenure Left':<13}" + f"{'Payment/Period':<16}{'Outstanding':<14}" ) for period in range(1, n + 1): @@ -142,10 +140,8 @@ def amortization_schedule( if print_annual_summary and (months_elapsed % 12 == 0 or balance <= eps): tenure_left = n - period print( - ( - f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left:<13}" - f"{pmt:<16.2f}{balance:<14.2f}" - ) + f"{months_elapsed // 12:<6}{months_elapsed:<12}{tenure_left:<13}" + f"{pmt:<16.2f}{balance:<14.2f}" ) if balance <= eps: