impl/typescript.ts
2,874 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 RoundingMode } from "./math_round_div.ts"; ← from math.round-div ^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 Rational, addRational, multiplyRational, rational, rationalToInteger } from "./math_rational.ts"; ← from math.rational ^1.0.0 · built alongside by fune
import { type DayCountConvention, dayCountFraction } from "./dates_day_count_fraction.ts"; ← from dates.day-count-fraction ^1.0.0 · built alongside by fune
import { daysBetween } from "./dates_days_between.ts"; ← from dates.days-between ^1.0.0 · built alongside by fune
import { type DailyInterest, type DatedBalance } from "./lending_daily_interest_types.ts";
/**
* Interest on a balance that changes during the period: each balance earns
* (or is charged) balance × rate × year fraction for the days it was held,
* the year fractions come from dates.day-count-fraction, and the pieces are
* added as exact fractions. Only the total is rounded, once, so splitting a
* month into more balance changes never moves the answer by a stray penny.
*/
export function dailyInterest(
balances: readonly DatedBalance[],
fromIso: string,
toIso: string,
annualRateBasisPoints: number,
convention: DayCountConvention,
mode: RoundingMode,
): DailyInterest {
if (!Number.isInteger(annualRateBasisPoints) || annualRateBasisPoints < -100000 || annualRateBasisPoints > 100000) {
throw new RangeError(`annualRateBasisPoints must be between -100000 and 100000, received ${annualRateBasisPoints}`);
}
if (balances.length === 0) {
throw new RangeError("balances must not be empty");
}
const days = daysBetween(fromIso, toIso);
if (days < 0) {
throw new RangeError(`fromIso ${fromIso} must not be after toIso ${toIso}`);
}
const currency = balances[0].balance.currency;
for (let i = 0; i < balances.length; i += 1) {
daysBetween(balances[i].date, balances[i].date);
if (balances[i].balance.currency !== currency) {
throw new RangeError(`currency mismatch: ${currency} and ${balances[i].balance.currency}`);
}
if (i > 0 && balances[i].date <= balances[i - 1].date) {
throw new RangeError("balances must be in strictly ascending date order");
}
}
if (balances[0].date > fromIso) {
throw new RangeError(`the first balance starts on ${balances[0].date}, after fromIso ${fromIso}`);
}
const rate = rational(annualRateBasisPoints, 10000);
let total: Rational = rational(0, 1);
for (let i = 0; i < balances.length; i += 1) {
const start = balances[i].date > fromIso ? balances[i].date : fromIso;
const next = i + 1 < balances.length ? balances[i + 1].date : toIso;
const end = next < toIso ? next : toIso;
if (start >= end) continue;
const fraction = dayCountFraction(start, end, convention);
const amount = multiplyRational(rational(balances[i].balance.minor, 1), rate);
total = addRational(total, multiplyRational(amount, rational(fraction.numerator, fraction.denominator)));
}
return { interest: money(rationalToInteger(total, mode), currency), exact: total, days };
}