Functional Weave
Code in TypeScript

finance.compound-interest@1.0.0

impl/typescript.ts

2,408 bytes · the TypeScript implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { roundDiv } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { subtractMoney } from "./money_add.ts";  ← from money.add ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { type CompoundInterestResult } from "./finance_compound_interest_types.ts";

/**
 * 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 = 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)^(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 periodsPerYear 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.
 */
export function compoundInterest(
  principal: Money,
  annualRateBasisPoints: number,
  periodsPerYear: number,
  years: number,
): CompoundInterestResult {
  if (!Number.isInteger(annualRateBasisPoints)) {
    throw new TypeError(`annualRateBasisPoints must be an integer, received ${annualRateBasisPoints}`);
  }
  if (!Number.isInteger(periodsPerYear) || periodsPerYear < 1) {
    throw new RangeError(`periodsPerYear must be at least 1, received ${periodsPerYear}`);
  }
  if (!Number.isInteger(years) || years < 0) {
    throw new RangeError(`years must not be negative, received ${years}`);
  }

  const periods = periodsPerYear * years;
  if (periods > MAX_PERIODS) {
    throw new RangeError(`${periods} periods is beyond the ${MAX_PERIODS} period limit`);
  }

  let balance = money(principal.minor, principal.currency);
  for (let period = 0; period < periods; period += 1) {
    const accrued = roundDiv(balance.minor * annualRateBasisPoints, 10000 * periodsPerYear, "half-up");
    balance = money(balance.minor + accrued, balance.currency);
  }

  return {
    principal: money(principal.minor, principal.currency),
    interest: subtractMoney(balance, principal),
    total: balance,
    periods,
  };
}