Functional Weave
Code in Rust

lending.apr@1.0.1

impl/typescript.ts

4,461 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 { type Money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { FIXED_SCALE, powFixed } from "./math_fractional_power.ts";  ← from math.fractional-power ^1.0.0 · built alongside by fune
import { type AprResult, type CreditFlow } from "./lending_apr_types.ts";

const UNITS = [1, 2, 4, 12, 52, 365];

/** 10^10: the APR is settled to ten decimal places of the rate before the disclosure rounding. */
const SETTLE = 10000000000n;

/**
 * Net cash flow per period (repayments minus advances), in period order,
 * after checking every flow.
 */
function netFlows(advances: readonly CreditFlow[], repayments: readonly CreditFlow[]): [number, bigint][] {
  if (advances.length === 0) throw new RangeError("advances must not be empty");
  if (repayments.length === 0) throw new RangeError("repayments must not be empty");
  const currency = advances[0].amount.currency;
  const net = new Map<number, bigint>();
  let earliest = Number.POSITIVE_INFINITY;
  const add = (flow: CreditFlow, sign: bigint) => {
    const amount: Money = flow.amount;
    if (amount.currency !== currency) throw new RangeError(`currency mismatch: ${currency} and ${amount.currency}`);
    if (!Number.isInteger(amount.minor) || amount.minor <= 0) {
      throw new RangeError(`every amount must be greater than zero, received ${amount.minor}`);
    }
    if (!Number.isInteger(flow.period) || flow.period < 0 || flow.period > 36500) {
      throw new RangeError(`periods must be between 0 and 36500, received ${flow.period}`);
    }
    net.set(flow.period, (net.get(flow.period) ?? 0n) + sign * BigInt(amount.minor));
  };
  for (const flow of advances) {
    add(flow, -1n);
    earliest = Math.min(earliest, flow.period);
  }
  for (const flow of repayments) add(flow, 1n);
  if (earliest !== 0) {
    throw new RangeError("time is measured from the first drawdown: the earliest advance must be at period 0");
  }
  return [...net.entries()].filter(([, amount]) => amount !== 0n).sort((a, b) => a[0] - b[0]);
}

/**
 * The APR by the total charge for credit equation (FCA Handbook CONC
 * App 1.2.6R): the rate X at which the drawdowns, discounted to the first
 * drawdown at (1 + X)^-t, equal the repayments discounted the same way, with
 * t in years. Solved by bisection on the per-period discount factor
 * v = (1 + X)^(-1/periodsPerYear) in 18-place fixed point, which only needs
 * whole powers of v, then X = v^-periodsPerYear − 1, settled to ten decimal
 * places and rounded to one decimal place of a percent as App 1.2.6(3)(f)
 * requires.
 */
export function apr(advances: readonly CreditFlow[], repayments: readonly CreditFlow[], periodsPerYear: number): AprResult {
  if (!UNITS.includes(periodsPerYear)) {
    throw new RangeError(`periodsPerYear must be 1, 2, 4, 12, 52 or 365, received ${periodsPerYear}`);
  }
  const flows = netFlows(advances, repayments);
  // One change of sign, advances then repayments, is what makes the root unique.
  let seenPositive = false;
  for (const [, amount] of flows) {
    if (amount > 0n) seenPositive = true;
    else if (seenPositive) {
      throw new RangeError("cash flows must be advances first and repayments after: the APR would not be unique");
    }
  }
  if (flows.length === 0 || flows[0][1] > 0n) {
    throw new RangeError("cash flows must be advances first and repayments after: the APR would not be unique");
  }
  const total = flows.reduce((sum, [, amount]) => sum + amount, 0n);
  if (total < 0n) {
    throw new RangeError("the repayments total less than the credit: the APR would be negative");
  }
  let rate = 0n;
  if (total > 0n) {
    const value = (v: bigint) => flows.reduce((sum, [period, amount]) => sum + amount * powFixed(v, period), 0n);
    let lo = 0n;
    let hi = FIXED_SCALE;
    while (hi - lo > 1n) {
      const mid = (lo + hi) / 2n;
      if (value(mid) >= 0n) hi = mid;
      else lo = mid;
    }
    const growth = powFixed(hi, periodsPerYear);
    if (growth === 0n) throw new RangeError("the APR is too large to compute");
    rate = (FIXED_SCALE * FIXED_SCALE) / growth - FIXED_SCALE;
    if (rate < 0n) rate = 0n;
  }
  // Settle the solver's last-digit noise, then round as the rule says.
  const settled = (2n * rate * SETTLE + FIXED_SCALE) / (2n * FIXED_SCALE);
  const tenths = Number((2n * settled * 1000n + SETTLE) / (2n * SETTLE));
  const precise = Number((2n * settled * 10000n + SETTLE) / (2n * SETTLE));
  return {
    basisPoints: tenths * 10,
    display: `${Math.floor(tenths / 10)}.${tenths % 10}%`,
    preciseBasisPoints: precise,
  };
}