finance.compound-interest
Compound interest accrued period by period in minor units, the way a statement is built.
1.0.0 · published 2026-10-03 by charlie · Anterra
Pinned by 18 tests, run in TypeScript, Python and Rust.
What it does
The interest is accrued one period at a time and rounded to a whole minor unit at every step, because that is what the ledger behind a statement actually does: each period posts a real, whole-penny entry and the next period earns interest on that posted balance.
A single principal * (1 + r/n)^(n*y) at the end is a different number. 1000.00 at a nominal 12% compounded monthly for a year accrues 1126.84 here and 1126.83 by pow(); 2500.00 at 3.75% monthly over five years differs by three pence. Neither difference is large and neither can be argued away at a reconciliation, because the statement is the authority and the statement was built by accumulating rounded postings.
For example
compound_interest(£1,000.00, 5%, 1, 1)→ principal £1,000.00, interest £50.00, total £1,050.00, periods 1 5 percent on 1000.00 for one year compounded annuallycompound_interest(£1,000.00, 5%, 1, 3)→ principal £1,000.00, interest £157.63, total £1,157.63, periods 3 three annual periods, the third of which accrues half a penny and rounds upcompound_interest(£1,000.00, 5%, 2, 1)→ principal £1,000.00, interest £50.63, total £1,050.63, periods 2 semi-annual 5 percent: the second period lands on exactly half a penny
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 compound_interest(principal: Money, annual_rate_basis_points: int, periods_per_year: int, years: int) -> CompoundInterestResult
| principal | Money | the opening balance; negative for a debt |
| annual_rate_basis_points | int | nominal annual rate, 450 = 4.5%; may be negative |
| periods_per_year | int | 12 monthly, 4 quarterly, 1 annually |
| years | int | whole years; 0 is a valid no-op |
| returns | CompoundInterestResult |
The type it declares, generated into your project
@dataclass(frozen=True)
class CompoundInterestResult:
principal: Money
interest: Money
total: Money
periods: int
Your code names it in one line, in the file that uses it
from fune.finance.compound_interest import compound_interest # finance.compound-interest@^1
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
from .finance_compound_interest_types import CompoundInterestResult
from .math_round_div import round_div ← from math.round-div ^1.0.0 · built alongside by fune
from .money_add import subtract_money ← from money.add ^1.0.0 · built alongside by fune
from .money_amount import Money, money ← from money.amount ^1.0.0 · built alongside by fune
#: A thousand years of daily compounding is already past the point where this
#: is modelling anything; beyond it a caller has passed the wrong unit and is
#: asking for a loop that will not finish.
MAX_PERIODS = 120000
def compound_interest(
principal: Money,
annual_rate_basis_points: int,
periods_per_year: int,
years: int,
) -> CompoundInterestResult:
"""Compound interest accrued period by period, in whole minor units.
The loop is the point. A statement is built by posting a real, rounded entry
each period and letting the next period earn on the posted balance, so that
is what happens here. ``principal * (1 + r/n) ** (n*y)`` evaluated once at
the end gives a slightly different answer and cannot be reconciled against
the statement it disagrees with.
The division by ``periods_per_year`` stays inside the same rounding step as
the interest itself: a nominal 4.5% compounded monthly is 37.5 basis points
per period, and rounding that rate to 37 or 38 first would distort the
result far more than rounding the accrued pennies ever does.
"""
if isinstance(annual_rate_basis_points, bool) or not isinstance(annual_rate_basis_points, int):
raise TypeError(
"annual_rate_basis_points must be an integer, received %r" % (annual_rate_basis_points,)
)
if isinstance(periods_per_year, bool) or not isinstance(periods_per_year, int) or periods_per_year < 1:
raise ValueError("periods_per_year must be at least 1, received %r" % (periods_per_year,))
if isinstance(years, bool) or not isinstance(years, int) or years < 0:
raise ValueError("years must not be negative, received %r" % (years,))
periods = periods_per_year * years
if periods > MAX_PERIODS:
raise ValueError("%d periods is beyond the %d period limit" % (periods, MAX_PERIODS))
balance = money(principal.minor, principal.currency)
for _ in range(periods):
accrued = round_div(balance.minor * annual_rate_basis_points, 10000 * periods_per_year, "half-up")
balance = money(balance.minor + accrued, balance.currency)
return CompoundInterestResult(
principal=money(principal.minor, principal.currency),
interest=subtract_money(balance, principal),
total=balance,
periods=periods,
)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 finance.compound-interest
The manifest, vectors and README with only the Python implementation. Install it without the registry with fune add ./finance.compound-interest-1.0.0-python.fune, or fetch it from a terminal with fune pull finance.compound-interest@1.0.0:python.
The whole function, every language, is one file too: finance.compound-interest-1.0.0.fune, 18,344 bytes, sha256 c8221eaf973c1ba217232fe89724375fcc44c021a6b864410841c45dd98ab1b0. 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 finance.compound-interest
after — your function gets the result and the arguments, and returns the final result.
# fune: after finance.compound-interest
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.round-div in finance.compound-interest
# fune: replace money.add in finance.compound-interest
# fune: replace money.amount in finance.compound-interest
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 finance.compound-interest --steps.
# fune: step finance.compound-interest 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 | |
|---|---|---|---|
| 5 percent on 1000.00 for one year compounded annually | £1,000.00, 5%, 1, 1 | → | principal £1,000.00, interest £50.00, total £1,050.00, periods 1 |
| three annual periods, the third of which accrues half a penny and rounds up | £1,000.00, 5%, 1, 3 | → | principal £1,000.00, interest £157.63, total £1,157.63, periods 3 |
| semi-annual 5 percent: the second period lands on exactly half a penny | £1,000.00, 5%, 2, 1 | → | principal £1,000.00, interest £50.63, total £1,050.63, periods 2 |
| a nominal 4.5 percent monthly: 37.5 basis points a period, never rounded as a rate | £1,000.00, 4.5%, 12, 1 | → | principal £1,000.00, interest £45.94, total £1,045.94, periods 12 |
| nominal 12 percent monthly is 1126.84, a penny above what a single pow() returns | £1,000.00, 12%, 12, 1 | → | principal £1,000.00, interest £126.84, total £1,126.84, periods 12 |
| 2500.00 at 3.75 percent monthly for five years: three pence above pow(), and the statement wins | £2,500.00, 3.75%, 12, 5 | → | principal £2,500.00, interest £514.72, total £3,014.72, periods 60 |
| quarterly compounding over two years | £1,000.00, 5%, 4, 2 | → | principal £1,000.00, interest £104.49, total £1,104.49, periods 8 |
| zero years accrues nothing and returns the principal untouched | £1,000.00, 5%, 1, 0 | → | principal £1,000.00, interest £0.00, total £1,000.00, periods 0 |
| a zero rate still runs the periods and still accrues nothing | £1,000.00, 0%, 12, 1 | → | principal £1,000.00, interest £0.00, total £1,000.00, periods 12 |
| a balance of one penny never earns a second one, ten years running | £0.01, 5%, 1, 10 | → | principal £0.01, interest £0.00, total £0.01, periods 10 |
Show the other 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| ten pence at 5 percent rounds a half penny up every year and reaches fifteen | £0.10, 5%, 1, 5 | → | principal £0.10, interest £0.05, total £0.15, periods 5 |
| 100 percent a year doubles three pence to twenty-four | £0.03, 100%, 1, 3 | → | principal £0.03, interest £0.21, total £0.24, periods 3 |
| an overdraft accrues the mirror image of a savings balance | -£1,000.00, 12%, 12, 1 | → | principal -£1,000.00, interest -£126.84, total -£1,126.84, periods 12 |
| a negative rate shrinks the balance rather than erroring | £1,000.00, -0.5%, 4, 1 | → | principal £1,000.00, interest -£5.00, total £995.00, periods 4 |
| zero periods per year is an error, not annual compounding | £1,000.00, 5%, 0, 1 | → | error: must be at least 1 |
| negative years is an error, not discounting | £1,000.00, 5%, 12, -1 | → | error: years must not be negative |
| a fractional rate is an error: basis points are integers | £1,000.00, 0.045%, 12, 1 | → | error: must be an integer |
| an absurd period count is a wrong unit, not a long wait | £1,000.00, 5%, 365, 400 | → | error: period limit |
More from the author
The period rate is the nominal annual rate divided by periodsPerYear, and that division stays inside the same integer division that rounds the period's interest. Rounding the RATE first would be much worse: a nominal 4.5% compounded monthly is 37.5 basis points per period, and forcing that to 37 or 38 basis points moves the answer far more than any penny of accrual rounding. Where the period rate is a whole number of basis points this is exactly money.apply-rate's arithmetic.
The rate is nominal, not AER/APY. 1200 basis points compounded monthly is a nominal 12%, which is an effective 12.68%. Do not pass an AER here and expect it back.
Negative rates and negative principals both work and round symmetrically away from zero, so an overdraft accrues the mirror image of a savings balance.
Files
| Path | Bytes |
|---|---|
| README.md | 1,507 |
| impl/python.py | 2,525 |
| impl/rust.rs | 3,578 |
| impl/typescript.ts | 2,408 |
| vectors.json | 5,202 |