Methodology · Open Math
Compound Interest
We simulate compound growth period by period rather than relying on a single closed-form equation, so contributions, withdrawals, escalation, and annuity timing are all handled by one tested function.
Authoritative reference
Our implementation is validated against U.S. SEC — Investor.gov Compound Interest Calculator.
Reference test cases
These exact cases are asserted in our unit test suite, so a regression would fail the build.
| Input | Expected |
|---|---|
| $10,000 at 7%, compounded monthly, 10 years, no contributions | $20,096.61 |
| $0 + $100/mo at 7%, compounded monthly, 10 years | $17,308.48 |
Source code
This is the exact, unmodified source that powers the Compound Interest Calculator.
Compound interest engine (compound.ts)
// src/lib/finance/compound.ts
//
// Period-by-period compound interest simulation. Single source of truth for every
// calculator that models compound interest with optional contributions, withdrawals,
// escalation, and choice of annuity timing.
//
// Authoritative reference: Investor.gov compound interest calculator
// https://www.investor.gov/financial-tools-calculators/calculators/compound-interest-calculator
import type { CompoundOptions, CompoundResult, YearRow } from './types.js';
/** Hard cap on balance to short-circuit Infinity in degenerate inputs. */
const MAX_BALANCE = 1e15;
/** Sanity ceiling on years to bound the simulation cost. */
const MAX_YEARS = 200;
/**
* Simulate compound interest period by period.
*
* The simulation iterates {@link CompoundOptions.frequency} times per year.
* Each period applies one period of interest growth and one period's worth of
* net cash flow (contributions minus withdrawals). With {@link Timing} = 'begin'
* cash flow is added before interest is applied (annuity-due); with 'end' it's
* added after (ordinary annuity).
*
* Year-end interest is computed as `endBalance - startBalance - netAnnualCashFlow`,
* which is exact regardless of timing, escalation, or withdrawal pattern.
*
* @throws Error if inputs are invalid (negative principal, non-positive frequency, etc.)
*/
export function compound(opts: CompoundOptions): CompoundResult {
const {
principal,
rate,
years,
frequency = 12,
contribution = 0,
contribFreq = 12,
withdrawal = 0,
withdrawalFreq = 12,
timing = 'end',
escalationPct = 0,
escalationFixed = 0,
} = opts;
// ---- Validation ---------------------------------------------------------
if (!Number.isFinite(principal) || principal < 0) {
throw new Error(`compound: principal must be a non-negative number, got ${principal}`);
}
if (!Number.isFinite(rate) || rate < 0) {
throw new Error(`compound: rate must be a non-negative number, got ${rate}`);
}
if (!Number.isInteger(years) || years < 1 || years > MAX_YEARS) {
throw new Error(`compound: years must be an integer in [1, ${MAX_YEARS}], got ${years}`);
}
if (!Number.isFinite(frequency) || frequency < 1) {
throw new Error(`compound: frequency must be >= 1, got ${frequency}`);
}
if (!Number.isFinite(contribution) || contribution < 0) {
throw new Error(`compound: contribution must be non-negative, got ${contribution}`);
}
if (!Number.isFinite(withdrawal) || withdrawal < 0) {
throw new Error(`compound: withdrawal must be non-negative, got ${withdrawal}`);
}
// ---- Simulation ---------------------------------------------------------
const yearly: YearRow[] = [];
let balance = principal;
let totalContributions = principal;
let totalWithdrawals = 0;
let totalInterest = 0;
let annualContribution = contribution * contribFreq;
const annualWithdrawal = withdrawal * withdrawalFreq;
for (let y = 1; y <= years; y++) {
// Apply escalation to contributions starting in year 2.
if (y > 1) {
if (escalationPct > 0) annualContribution *= (1 + escalationPct);
if (escalationFixed > 0) annualContribution += escalationFixed;
}
const startBalance = balance;
const netAnnual = annualContribution - annualWithdrawal;
const netPerPeriod = netAnnual / frequency;
// Accumulate the interest actually credited each period rather than deriving
// it as (endBalance − startBalance − netAnnual). The derived form is only
// correct when no period is floored at zero or clamped at MAX_BALANCE; once
// a withdrawal drains the account, the derived value reports phantom interest
// (it cannot tell the difference between interest and the truncated cash flow).
// Tracking the credited interest directly keeps `interest` accurate in those
// edge cases. The balance arithmetic below is byte-for-byte identical to the
// previous implementation, so finalBalance and the existing tests are unchanged.
let yearInterest = 0;
for (let p = 0; p < frequency; p++) {
let beforeGrowth: number;
if (timing === 'begin') {
balance += netPerPeriod;
beforeGrowth = balance;
balance *= 1 + rate / frequency;
} else {
beforeGrowth = balance;
balance *= 1 + rate / frequency;
balance += netPerPeriod;
}
// Interest credited this period is the growth from applying the rate.
yearInterest += balance - beforeGrowth - (timing === 'begin' ? 0 : netPerPeriod);
// Floor at zero — a withdrawal can never push the account below zero. Any
// interest credited before the account hit zero stays counted (it was real).
if (balance < 0) balance = 0;
// Defensive: bail out of pathological inputs without producing NaN/Infinity.
if (!Number.isFinite(balance) || balance > MAX_BALANCE) balance = MAX_BALANCE;
}
totalContributions += annualContribution;
totalWithdrawals += annualWithdrawal;
totalInterest += yearInterest;
yearly.push({
year: y,
startBalance,
contributions: annualContribution,
withdrawals: annualWithdrawal,
interest: yearInterest,
endBalance: balance,
});
}
return {
finalBalance: balance,
totalContributions,
totalWithdrawals,
totalInterest,
yearly,
};
}
/**
* Closed-form lump-sum compound: A = P(1 + r/n)^(nt).
* Useful for quick scenarios where there are no contributions.
*/
export function compoundLumpSum(principal: number, rate: number, years: number, frequency = 12): number {
if (rate === 0) return principal;
return principal * Math.pow(1 + rate / frequency, frequency * years);
}
/**
* Closed-form continuous compound: A = P × e^(rt).
*/
export function compoundContinuous(principal: number, rate: number, years: number): number {
return principal * Math.exp(rate * years);
}
/**
* Exact doubling time (years) for a fixed rate and compounding frequency:
* (1 + r/n)^(n·t) = 2 => t = ln(2) / (n · ln(1 + r/n))
*
* Returns Infinity when rate is 0.
*/
export function doublingTime(rate: number, frequency = 12): number {
if (rate <= 0) return Infinity;
return Math.log(2) / (frequency * Math.log(1 + rate / frequency));
}