Source code for mortgagemath._payment
"""Periodic payment calculation."""
import decimal
from decimal import Decimal, localcontext
from mortgagemath._types import (
Compounding,
DayCount,
LoanParams,
PaymentFrequency,
PaymentRounding,
)
_ONE = Decimal("1")
_TWO = Decimal("2")
_HUNDRED = Decimal("100")
_TWO_HUNDRED = Decimal("200")
_ROUNDING_MAP = {
PaymentRounding.ROUND_UP: decimal.ROUND_UP,
PaymentRounding.ROUND_DOWN: decimal.ROUND_DOWN,
PaymentRounding.ROUND_HALF_UP: decimal.ROUND_HALF_UP,
PaymentRounding.ROUND_HALF_EVEN: decimal.ROUND_HALF_EVEN,
}
def _periodic_rate_for(
annual_rate: Decimal,
compounding: Compounding,
payment_frequency: PaymentFrequency,
) -> Decimal:
"""Periodic rate for an arbitrary annual rate + compounding + cadence.
The same math as :func:`_periodic_rate` but parameterized so that
ARM rate-change recasts can derive the new periodic rate without
constructing a synthetic ``LoanParams``.
"""
ppy = Decimal(payment_frequency.payments_per_year)
if compounding == Compounding.MONTHLY:
# Preserves v0.2.x exactness when ppy=12: annual / (100 * 12) = annual / 1200.
return annual_rate / (_HUNDRED * ppy)
if compounding == Compounding.SEMI_ANNUAL:
# j_2 quoted as percent → semi-annual rate fraction is annual / 200.
# Periodic rate solves (1+r)^ppy = (1 + j_2/200)^2.
with localcontext() as ctx:
ctx.prec = 50
half_period = _ONE + annual_rate / _TWO_HUNDRED
return half_period ** (_TWO / ppy) - _ONE
if compounding == Compounding.ANNUAL:
# Annual rate is quoted as effective annual.
with localcontext() as ctx:
ctx.prec = 50
return (_ONE + annual_rate / _HUNDRED) ** (_ONE / ppy) - _ONE
raise ValueError(f"unsupported compounding: {compounding}") # pragma: no cover
def _periodic_rate(loan: LoanParams) -> Decimal:
"""Compute the per-period interest rate as a Decimal fraction.
Returns the rate per payment period (not per year). For monthly
compounding with monthly payments this is exactly ``annual_rate / 1200``,
matching all v0.2.x behavior. For semi-annual compounding (the
Canadian *Interest Act* §6 convention) this derives the equivalent
periodic rate via ``(1 + j_2/200)**(2/payments_per_year) - 1``.
"""
return _periodic_rate_for(loan.annual_rate, loan.compounding, loan.payment_frequency)
[docs]
def periodic_payment(loan: LoanParams) -> Decimal:
"""Calculate the periodic principal-and-interest payment for a loan.
Uses the standard annuity formula with Decimal arithmetic throughout::
r = periodic interest rate (per payment period)
n = number of payments over the amortization period
payment = P * r * (1+r)^n / ((1+r)^n - 1)
The result is rounded according to ``loan.payment_rounding``.
The same closed-form formula applies to both 30/360 and Actual/360
loans: the day-count convention determines how the schedule accrues
interest each period, not how the level periodic payment is computed.
Validated against the Fannie Mae Multifamily Selling and Servicing
Guide §1103 worked example ($25M / 5.5% / 30yr → DSC 6.8134680%
→ monthly P&I $141,947.25).
For non-monthly compounding (Canadian *Interest Act* §6
semi-annual quoting; or annual compounding) the periodic rate is
derived from the quoted annual rate; see :func:`_periodic_rate`.
Args:
loan: Loan parameters.
Returns:
Periodic P&I payment rounded to the loan's currency unit.
Raises:
ValueError: If principal, term_months, or annual_rate is not
positive, or the day_count is unsupported.
"""
# term_months and amortization_period_months invariants are
# enforced by LoanParams.__post_init__ (v0.6.1); this function
# checks only the value-level invariants the constructor cannot
# express in the dataclass.
if loan.principal <= 0:
raise ValueError(f"principal must be positive, got {loan.principal}")
if loan.annual_rate < 0:
raise ValueError(f"annual_rate must be non-negative, got {loan.annual_rate}")
# When the user has pinned the payment, return it directly.
if loan.payment_override is not None:
return loan.payment_override
if loan.day_count not in (DayCount.THIRTY_360, DayCount.ACTUAL_360): # pragma: no cover
raise ValueError(f"unsupported day_count: {loan.day_count}")
rounding = _ROUNDING_MAP[loan.payment_rounding]
r = _periodic_rate(loan)
n = loan._amort_payments
unit = loan.currency_unit
if loan.interest_only_months > 0:
# Initial payment is interest-only.
return (loan.principal * r).quantize(unit, rounding=rounding)
if r == 0:
# For zero-interest loans, the annuity formula is undefined.
# Simple division: principal / total payments.
return (loan.principal / n).quantize(unit, rounding=rounding)
with localcontext() as ctx:
ctx.prec = 50
factor = (_ONE + r) ** n
payment = loan.principal * r * factor / (factor - _ONE)
return payment.quantize(unit, rounding=rounding)
# Permanent alias preserved from v0.2.x. ``monthly_payment`` is exactly
# ``periodic_payment`` — the historical name reflected the library's
# original monthly-only scope; the rename in v0.3.0 acknowledges that
# the function returns the per-period payment for whatever frequency
# the loan is configured with.
monthly_payment = periodic_payment