Functional Weave
Code in Rust

lending.loan-payment@1.0.1

impl/python.py

3,427 bytes · the Python implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

from .math_round_div import RoundingMode, round_div  ← from math.round-div ^1.0.0 · built alongside by fune
from .money_amount import Money, money  ← from money.amount ^1.0.0 · built alongside by fune

#: A hundred years of monthly payments, sixty of weekly ones.
MAX_PAYMENTS = 3000


def _is_int(value: object) -> bool:
    return isinstance(value, int) and not isinstance(value, bool)


def payment_count(annual_rate_basis_points: int, term_months: int, payments_per_year: int) -> int:
    """The number of payments a term holds, after checking the arguments every
    lending capability shares."""
    if not _is_int(annual_rate_basis_points) or annual_rate_basis_points < 0 or annual_rate_basis_points > 100000:
        raise ValueError(
            "annualRateBasisPoints must be between 0 and 100000, received %r" % (annual_rate_basis_points,)
        )
    if not _is_int(payments_per_year) or payments_per_year < 1 or payments_per_year > 52:
        raise ValueError("paymentsPerYear must be between 1 and 52, received %r" % (payments_per_year,))
    if not _is_int(term_months) or term_months < 1:
        raise ValueError("termMonths must be at least 1, received %r" % (term_months,))
    if (term_months * payments_per_year) % 12 != 0:
        raise ValueError(
            "a term of %d months is not a whole number of payments at %d a year" % (term_months, payments_per_year)
        )
    n = term_months * payments_per_year // 12
    if n > MAX_PAYMENTS:
        raise ValueError("%d payments is more than the %d payment limit" % (n, MAX_PAYMENTS))
    return n


def round_wide(numerator: int, denominator: int, mode: RoundingMode) -> int:
    """Round the exact quotient numerator / denominator (both positive) with a
    math.round-div mode, however large they are.

    Only the integer part and where the remainder sits against one half
    matter, so the decision is handed to round_div as a small fraction with the
    same integer part and the same side of the half: q, q + 1/4, q + 1/2 or
    q + 3/4.
    """
    q, remainder = divmod(numerator, denominator)
    twice = remainder * 2
    if twice == 0:
        return round_div(q, 1, mode)
    if twice == denominator:
        return round_div(2 * q + 1, 2, mode)
    return round_div(4 * q + (1 if twice < denominator else 3), 4, mode)


def loan_payment(
    principal: Money,
    annual_rate_basis_points: int,
    term_months: int,
    payments_per_year: int,
    mode: RoundingMode,
) -> Money:
    """The level payment that repays ``principal`` over the term at a nominal
    annual rate divided equally between the periods:

        payment = P * r / (1 - (1 + r)^-n),   r = rate / payments_per_year

    With r = b / D (b basis points, D = 10000 * payments_per_year) this is the
    exact fraction P * b * (D + b)^n / (D * ((D + b)^n - D^n)), evaluated in
    integers of whatever size it takes and rounded once. A zero rate is P / n.
    """
    if not _is_int(principal.minor) or principal.minor <= 0:
        raise ValueError("principal must be greater than zero, received %r" % (principal.minor,))
    n = payment_count(annual_rate_basis_points, term_months, payments_per_year)
    if annual_rate_basis_points == 0:
        return money(round_div(principal.minor, n, mode), principal.currency)
    b = annual_rate_basis_points
    d = 10000 * payments_per_year
    grown = (d + b) ** n
    numerator = principal.minor * b * grown
    denominator = d * (grown - d ** n)
    return money(round_wide(numerator, denominator, mode), principal.currency)