from typing import List from .lending_amortisation_schedule_types import AmortisationRow, AmortisationSchedule from .lending_loan_payment import loan_payment, payment_count, round_wide from .math_round_div import RoundingMode from .money_amount import Money, money def period_interest(balance: int, annual_rate_basis_points: int, payments_per_year: int) -> int: """Interest for one period on a positive balance: balance * b / D, rounded half-up to the minor unit, which is what gets posted to the account.""" if balance <= 0 or annual_rate_basis_points == 0: return 0 product = balance * annual_rate_basis_points # Rust holds the product in an i64; refuse it everywhere rather than in one language. if product > 9223372036854775807: raise ValueError("balance too large for exact interest") return round_wide(product, 10000 * payments_per_year, "half-up") def amortisation_schedule( principal: Money, annual_rate_basis_points: int, term_months: int, payments_per_year: int, mode: RoundingMode, ) -> AmortisationSchedule: """The schedule a lender's system produces: each period's interest is computed on the opening balance and rounded to a whole minor unit, the level payment repays interest first and principal with the rest, and the last payment is whatever clears the balance, so it ends at exactly zero. """ payment = loan_payment(principal, annual_rate_basis_points, term_months, payments_per_year, mode) n = payment_count(annual_rate_basis_points, term_months, payments_per_year) currency = principal.currency rows: List[AmortisationRow] = [] balance = principal.minor total_interest = 0 total_paid = 0 period = 1 while period <= n and balance > 0: interest = period_interest(balance, annual_rate_basis_points, payments_per_year) # The last period, or one where the level payment would overshoot (a # rounded-up payment can clear the loan a period early), pays exactly # what is owed. if period == n or balance + interest <= payment.minor: due = balance + interest else: due = payment.minor repaid = due - interest balance -= repaid total_interest += interest total_paid += due rows.append( AmortisationRow( period=period, payment=money(due, currency), interest=money(interest, currency), principal=money(repaid, currency), balance=money(balance, currency), ) ) period += 1 return AmortisationSchedule( payment=payment, rows=rows, total_interest=money(total_interest, currency), total_paid=money(total_paid, currency), )