Functional Weave
Code in Python

lending.loan-payment Unreviewed

Level repayment for a loan (annuity formula), computed exactly and rounded once, the way you choose.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 23 tests, run in TypeScript, Python and Rust.

Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified consumer-credit compliance specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

Not professional advice. This capability calculates lending figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a consumer-credit compliance specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

The level (annuity) payment that repays a loan in equal instalments: what a mortgage, car loan or personal loan quote shows as "monthly payment".

## The formula

For example

  • loan_payment(£100,000.00, 5%, 300, 12, half-up) → £584.59 £100,000 mortgage at 5% over 25 years is £584.59 a month
  • loan_payment(£100,000.00, 5%, 300, 12, up) → £584.60 the same mortgage rounded up so it never under-repays
  • loan_payment(£1,000.00, 12%, 12, 12, half-up) → £88.85 £1,000 at 12% over a year

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

def loan_payment(principal: Money, annual_rate_basis_points: int, term_months: int, payments_per_year: int, mode: RoundingMode) -> Money
principalMoneythe amount borrowed, greater than zero
annual_rate_basis_pointsintnominal annual rate, 0 to 100000; 525 = 5.25%
term_monthsintthe term; it must hold a whole number of payments
payments_per_yearint1 to 52: 12 monthly, 4 quarterly, 26 fortnightly, 52 weekly
modeRoundingModehow the exact payment becomes whole minor units; "up" never under-repays
returnsMoneythe payment per period, in the principal's currency

Your code names it in one line, in the file that uses it

from fune.lending.loan_payment import loan_payment  # lending.loan-payment@^1
impl/python.py · 77 lines · open · 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)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add lending.loan-payment
Download for Python lending.loan-payment-1.0.1-python.fune · 15,229 bytes sha256 0fc6692cd1823f2effc6a0a2177eef42eee4389079ab77d9ff51eae23b09420a

The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./lending.loan-payment-1.0.1-python.fune, or fetch it from a terminal with fune pull lending.loan-payment@1.0.1:python.

The whole function, every language, is one file too: lending.loan-payment-1.0.1.fune, 22,812 bytes, sha256 ae3adb6e388662e058f53225156c2198c46abb159e47c6967cb931c48a9670a1. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

# fune: before lending.loan-payment

after — your function gets the result and the arguments, and returns the final result.

# fune: after lending.loan-payment

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

# fune: replace math.big-integer in lending.loan-payment
# fune: replace math.round-div in lending.loan-payment
# fune: replace money.amount in lending.loan-payment

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show lending.loan-payment --steps.

# fune: step lending.loan-payment after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
£100,000 mortgage at 5% over 25 years is £584.59 a month £100,000.00, 5%, 300, 12, half-up → £584.59
the same mortgage rounded up so it never under-repays £100,000.00, 5%, 300, 12, up → £584.60
£1,000 at 12% over a year £1,000.00, 12%, 12, 12, half-up → £88.85
rounding down the same loan £1,000.00, 12%, 12, 12, down → £88.84
zero rate is the principal divided equally £1,000.00, 0%, 12, 12, half-up → £83.33
zero rate rounded up £1,000.00, 0%, 12, 12, up → £83.34
£150.00 over a year at 0% divides exactly £150.00, 0%, 12, 12, half-up → £12.50
an exact half penny (£1.50 over 12 months is 12.5p) rounds away from zero under half-up £1.50, 0%, 12, 12, half-up → £0.13
half-even sends the exact half to the even penny £1.50, 0%, 12, 12, half-even → £0.12
£25,000 car loan at 3.99% over five years £25,000.00, 3.99%, 60, 12, half-up → £460.30
Show the other 13 tests
CaseArgumentsExpected
weekly payments over two years at 6.5% £10,000.00, 6.5%, 24, 52, half-up → £102.60
quarterly payments over three years at 8% £5,000.00, 8%, 36, 4, half-up → £472.80
a single annual payment is principal plus a year's interest £1,000.00, 10%, 12, 1, half-up → £1,100.00
fortnightly over three years £3,000.00, 7%, 36, 26, half-up → £42.69
forty years at 4.25% in euros €350,000.00, 4.25%, 480, 12, half-up → €1,517.67
a very high rate: 1000% over 12 months £500.00, 1000%, 12, 12, half-up → £416.96
a zero principal is refused £0.00, 5%, 12, 12, half-up → error: principal must be greater than zero
a negative rate is refused £1,000.00, -0.01%, 12, 12, half-up → error: annualRateBasisPoints must be between 0 and 100000
payments per year above 52 are refused £1,000.00, 5%, 12, 365, half-up → error: paymentsPerYear must be between 1 and 52
a zero term is refused £1,000.00, 5%, 0, 12, half-up → error: termMonths must be at least 1
a term that is not a whole number of payments £1,000.00, 5%, 13, 4, half-up → error: is not a whole number of payments
more payments than the limit £1,000.00, 5%, 1,200, 52, half-up → error: payment limit
an unknown rounding mode £1,000.00, 5%, 12, 12, nearest → error: unknown rounding mode

More from the author

With a nominal annual rate split equally between the periods, r = rate / paymentsPerYear, and n payments:

payment = P × r / (1 − (1 + r)^−n)

That is the textbook present-value-of-an-annuity formula, the same one as a spreadsheet's PMT. Here it is never evaluated in floating point. The rate is b basis points, so r = b / D with D = 10000 × paymentsPerYear, and the formula is exactly the fraction

P × b × (D + b)^n / ( D × ((D + b)^n − D^n) )

Both sides are integers. For a 25-year monthly mortgage (D + b)^n is a number of over 1,500 digits, far past what `math.rational` holds (its parts must stay within 2^53 so JavaScript numbers stay exact), so the fraction is built with arbitrary-size integers: `bigint` in TypeScript, `int` in Python and `math.big-integer` in Rust. It is then rounded exactly once, with the `math.round-div` mode you pass. A zero rate is P / n, rounded the same way.

## Rounding

- `half-up` gives the nearest penny, what most quotes show: £100,000 at 5% over 25 years is £584.59. - `up` never under-repays: every payment is at most a penny high, and the final payment (see lending.amortisation-schedule) comes out slightly smaller. - `down` or `half-even` are available where a contract says so.

The mode applies to the exact value, so there is no double rounding: a payment of exactly x.5 pence rounds by the mode's tie rule, anything else to the nearer penny.

## What it does not do

- The rate is nominal and divided equally (12% a year is 1% a month). It is not an APR or AER; lending.apr and banking.savings-aer convert. - Every period is treated as equal. Interest charged daily, or a first period of odd length, changes the payment slightly; build the schedule with lending.daily-interest when that matters. - No fees are added, and the payment is not a regulated APR disclosure.

## Limits

The rate is 0 to 100000 basis points, payments per year 1 to 52, and the term must hold a whole number of payments (13 months cannot be paid quarterly), at most 3000 of them. The principal must be positive and, for TypeScript to stay exact, below 2^51 minor units.

The module also exports `paymentCount` (the argument checks and n) and `roundWide` (round a large positive fraction with a round-div mode), which the other lending capabilities reuse.

## Before you rely on this

**Not professional advice.** This capability calculates lending figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed above, and have a consumer-credit compliance specialist review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.

**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified consumer-credit compliance specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

1.0.1 marks it unreviewed. The code and the tests are unchanged.

Files

PathBytes
README.md3,634
impl/python.py3,427
impl/rust.rs3,920
impl/typescript.ts3,440
vectors.json5,096