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,
};
}