Functional Weave
Code in Python

lending.amortisation-schedule Unreviewed

Repayment schedule for a level-payment loan: interest, principal and balance per period, ending at exactly zero.

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

Pinned by 13 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 repayment table for a level-payment loan: for every period, the payment, the interest, the part that repays the loan, and the balance left. It is the table a mortgage offer or a loan statement shows, built the way the lender's ledger builds it.

## How each row is made

For example

  • amortisation_schedule(£1,000.00, 12%, 12, 12, half-up) → payment £88.85, rows ×12, total interest £66.19, total paid £1,066.19 £1,000 at 12% over 12 months, half-up
  • amortisation_schedule(£2,000.00, 9.99%, 12, 12, up) → payment £175.83, rows ×12, total interest £109.85, total paid £2,109.85 rounded up, the final payment is smaller
  • amortisation_schedule(£1,000.00, 12%, 12, 12, down) → payment £88.84, rows ×12, total interest £66.19, total paid £1,066.19 rounded down, the final payment is larger

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 amortisation_schedule(principal: Money, annual_rate_basis_points: int, term_months: int, payments_per_year: int, mode: RoundingMode) -> AmortisationSchedule
principalMoneythe amount borrowed, greater than zero
annual_rate_basis_pointsintnominal annual rate, 0 to 100000
term_monthsintthe term; it must hold a whole number of payments
payments_per_yearint1 to 52
modeRoundingModerounding of the level payment, as lending.loan-payment
returnsAmortisationSchedule

The types it declares, generated into your project

@dataclass(frozen=True)
class AmortisationRow:
    """One period of the schedule."""

    #: 1 for the first payment
    period: int
    #: the level payment, or the adjusted final one
    payment: Money
    #: interest for the period on the opening balance
    interest: Money
    #: the part of the payment that repays the loan
    principal: Money
    #: the balance after this payment
    balance: Money

@dataclass(frozen=True)
class AmortisationSchedule:
    """The level payment, every period, and the totals."""

    payment: Money
    rows: List[AmortisationRow]
    total_interest: Money
    total_paid: Money

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

from fune.lending.amortisation_schedule import amortisation_schedule  # lending.amortisation-schedule@^1
impl/python.py · 69 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 typing import List

from .lending_amortisation_schedule_types import AmortisationRow, AmortisationSchedule
from .lending_loan_payment import loan_payment, payment_count, round_wide  ← from lending.loan-payment ^1.0.0 · built alongside by fune
from .math_round_div import RoundingMode  ← 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


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),
    )

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.amortisation-schedule
Download for Python lending.amortisation-schedule-1.0.1-python.fune · 59,331 bytes sha256 e256dddab54c78ab0dce138d5fab85a4bafccf3b019cc3b4e918e9dd236e7371

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

The whole function, every language, is one file too: lending.amortisation-schedule-1.0.1.fune, 66,045 bytes, sha256 d20048fdaec868479307700ac9f444968a01d572c7be5809edaf48096ef9f96c. 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.amortisation-schedule

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

# fune: after lending.amortisation-schedule

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 lending.loan-payment in lending.amortisation-schedule
# fune: replace math.round-div in lending.amortisation-schedule
# fune: replace money.amount in lending.amortisation-schedule

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.amortisation-schedule --steps.

# fune: step lending.amortisation-schedule 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
£1,000 at 12% over 12 months, half-up £1,000.00, 12%, 12, 12, half-up → payment £88.85, rows ×12, total interest £66.19, total paid £1,066.19
rounded up, the final payment is smaller £2,000.00, 9.99%, 12, 12, up → payment £175.83, rows ×12, total interest £109.85, total paid £2,109.85
rounded down, the final payment is larger £1,000.00, 12%, 12, 12, down → payment £88.84, rows ×12, total interest £66.19, total paid £1,066.19
£5,000 at 6% over 6 months £5,000.00, 6%, 6, 12, half-up → payment £847.98, rows ×6, total interest £87.87, total paid £5,087.87
interest-free: the pennies left over go on the last payment £1,000.00, 0%, 12, 12, down → payment £83.33, rows ×12, total interest £0.00, total paid £1,000.00
rounded up, a tiny interest-free loan clears three payments early £0.25, 0%, 12, 12, up → payment £0.03, rows ×9, total interest £0.00, total paid £0.25
a single annual payment £1,000.00, 10%, 12, 1, half-up → payment £1,100.00, rows ×1, total interest £100.00, total paid £1,100.00
quarterly over two years at 8% £10,000.00, 8%, 24, 4, half-up → payment £1,365.10, rows ×8, total interest £920.80, total paid £10,920.80
a small loan where rounding dominates £10.00, 19.99%, 12, 12, half-up → payment £0.93, rows ×12, total interest £1.10, total paid £11.10
dollars, monthly for three years at 7.5% $15,000.00, 7.5%, 36, 12, half-up → payment $466.59, rows ×36, total interest $1,797.36, total paid $16,797.36
Show the other 3 tests
CaseArgumentsExpected
a zero principal is refused £0.00, 5%, 12, 12, half-up → error: principal must be greater than zero
a term that is not a whole number of payments £1,000.00, 5%, 7, 4, half-up → error: is not a whole number of payments
a negative rate is refused £1,000.00, -0.5%, 12, 12, half-up → error: annualRateBasisPoints must be between 0 and 100000

More from the author

1. The level payment comes from lending.loan-payment, rounded with the mode you pass. 2. Each period's interest is the opening balance × rate / paymentsPerYear, rounded half-up to a whole minor unit: that is the amount actually posted. 3. The payment pays that interest first; the rest reduces the balance. 4. The final payment is not the level payment. It is whatever is owed: the remaining balance plus that period's interest. So the balance ends at exactly zero, never at -3p or +2p.

The final payment is where every penny of rounding collects. With the payment rounded half-up it is within a few pence of the others; rounded `up` it is a little smaller, rounded `down` a little larger. If a rounded-up payment would clear the loan before the term ends (possible only for tiny loans, such as 25p over 12 months), the schedule stops at the payment that clears it, so it can have fewer rows than the term has payments. It never has a row with a payment of zero.

The totals are the sums of the posted rows: totalPaid = principal + totalInterest, exactly.

## What it does not do

The periods are equal and the rate is nominal and fixed, as in lending.loan-payment. It does not model daily interest, payment holidays, rate changes, fees or overpayments (lending.overpayment-effect covers a one-off overpayment).

## Limits

The same arguments as lending.loan-payment, so at most 3000 rows. A balance whose interest product (balance × basis points) would exceed 2^63 - 1 is an error ("balance too large for exact interest") in every language.

The module also exports `periodInterest(balance, annualRateBasisPoints, paymentsPerYear)`, the rounding rule of step 2.

## 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,116
impl/python.py2,823
impl/rust.rs3,817
impl/typescript.ts2,672
vectors.json42,733