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.
pub fn compound_interest(principal: &Money, annual_rate_basis_points: i64, periods_per_year: i64, years: i64) -> 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
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompoundInterestResult {
pub principal: Money,
pub interest: Money,
pub total: Money,
pub periods: i64,
}
Your code names it in one line, in the file that uses it
fune!(finance.compound-interest@^1); // then call compound_interest(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::math_round_div::round_div; ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_add::subtract_money; ← from money.add ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, 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.
const MAX_PERIODS: i64 = 120000;
/// 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).powi(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.
///
/// # Panics
/// Panics if `periods_per_year` is below 1, `years` is negative, or the period
/// count exceeds the limit.
pub fn compound_interest(
principal: &Money,
annual_rate_basis_points: i64,
periods_per_year: i64,
years: i64,
) -> CompoundInterestResult {
if periods_per_year < 1 {
panic!(
"periods_per_year must be at least 1, received {}",
periods_per_year
);
}
if years < 0 {
panic!("years must not be negative, received {}", years);
}
let periods = periods_per_year * years;
if periods > MAX_PERIODS {
panic!(
"{} periods is beyond the {} period limit",
periods, MAX_PERIODS
);
}
let mut balance = money(principal.minor, &principal.currency);
for _ in 0..periods {
let accrued = round_div(
balance.minor * annual_rate_basis_points,
10000 * periods_per_year,
"half-up",
);
balance = money(balance.minor + accrued, &balance.currency);
}
CompoundInterestResult {
principal: money(principal.minor, &principal.currency),
interest: subtract_money(&balance, principal),
total: balance,
periods,
}
}
pub fn compound_interest_to_value(result: &CompoundInterestResult) -> Value {
Value::obj(vec![
("principal", money_to_value(&result.principal)),
("interest", money_to_value(&result.interest)),
("total", money_to_value(&result.total)),
("periods", Value::Int(result.periods)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
// Refuse what the typed signature cannot hold, with the wording TypeScript
// and Python use, rather than let the conversion below quietly change it.
if let Value::Float(f) = args[1] {
if f.fract() != 0.0 {
panic!("annualRateBasisPoints must be an integer, received {}", f);
}
}
if let Value::Float(f) = args[2] {
if f.fract() != 0.0 {
panic!("periodsPerYear must be at least 1, received {}", f);
}
}
if let Value::Float(f) = args[3] {
if f.fract() != 0.0 {
panic!("years must not be negative, received {}", f);
}
}
compound_interest_to_value(&compound_interest(
&money_from_value(&args[0]),
args[1].as_i64(),
args[2].as_i64(),
args[3].as_i64(),
))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. 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 Rust implementation. Install it without the registry with fune add ./finance.compound-interest-1.0.0-rust.fune, or fetch it from a terminal with fune pull finance.compound-interest@1.0.0:rust.
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 |