Skip to main content

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.

InputExpected
$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));
}