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-upamortisation_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 smalleramortisation_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
| principal | Money | the amount borrowed, greater than zero |
| annual_rate_basis_points | int | nominal annual rate, 0 to 100000 |
| term_months | int | the term; it must hold a whole number of payments |
| payments_per_year | int | 1 to 52 |
| mode | RoundingMode | rounding of the level payment, as lending.loan-payment |
| returns | AmortisationSchedule |
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
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
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.
| Case | Arguments | Expected | |
|---|---|---|---|
| £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
| Case | Arguments | Expected | |
|---|---|---|---|
| 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
| Path | Bytes |
|---|---|
| README.md | 3,116 |
| impl/python.py | 2,823 |
| impl/rust.rs | 3,817 |
| impl/typescript.ts | 2,672 |
| vectors.json | 42,733 |