Building an Interest Calculation Engine
A technical deep dive into implementing a precise and configurable interest calculation engine in TypeScript, covering accrual methods, day count conventions, compounding strategies, and edge case handling.
Interest calculation is the financial heartbeat of any lending platform. Despite its apparent simplicity---rate times principal times time---the devil is in the details. Different day count conventions, accrual methods, compounding frequencies, and rounding strategies can produce materially different results. A production-grade interest calculation engine must handle all of these variations while maintaining the precision that financial regulations and accounting standards demand.
This article explores the design and implementation of a configurable interest calculation engine in TypeScript, covering the mathematical foundations, common day count conventions, accrual methods, and the edge cases that trip up naive implementations.
Day Count Conventions
The first question in any interest calculation is deceptively simple: how many days are in a year? The answer depends on the day count convention specified in the loan agreement. Different conventions produce different interest amounts for the same nominal rate.
type DayCountConvention =
| "actual_365"
| "actual_360"
| "actual_actual"
| "thirty_360"
| "thirty_360_european";
class DayCountCalculator {
getDayCountFraction(
startDate: Date,
endDate: Date,
convention: DayCountConvention
): number {
switch (convention) {
case "actual_365":
return this.actualDays(startDate, endDate) / 365;
case "actual_360":
return this.actualDays(startDate, endDate) / 360;
case "actual_actual":
return this.actualActual(startDate, endDate);
case "thirty_360":
return this.thirty360US(startDate, endDate);
case "thirty_360_european":
return this.thirty360European(startDate, endDate);
}
}
private actualDays(start: Date, end: Date): number {
const msPerDay = 24 * 60 * 60 * 1000;
return Math.round((end.getTime() - start.getTime()) / msPerDay);
}
private actualActual(start: Date, end: Date): number {
const startYear = start.getFullYear();
const endYear = end.getFullYear();
if (startYear === endYear) {
const daysInYear = this.isLeapYear(startYear) ? 366 : 365;
return this.actualDays(start, end) / daysInYear;
}
// Prorate across years
let fraction = 0;
const endOfStartYear = new Date(startYear, 11, 31);
const daysInStartYear = this.isLeapYear(startYear) ? 366 : 365;
fraction += this.actualDays(start, endOfStartYear) / daysInStartYear;
for (let year = startYear + 1; year < endYear; year++) {
fraction += 1;
}
const startOfEndYear = new Date(endYear, 0, 1);
const daysInEndYear = this.isLeapYear(endYear) ? 366 : 365;
fraction += this.actualDays(startOfEndYear, end) / daysInEndYear;
return fraction;
}
private thirty360US(start: Date, end: Date): number {
let d1 = start.getDate();
let d2 = end.getDate();
const m1 = start.getMonth() + 1;
const m2 = end.getMonth() + 1;
const y1 = start.getFullYear();
const y2 = end.getFullYear();
if (d1 === 31) d1 = 30;
if (d2 === 31 && d1 >= 30) d2 = 30;
return (360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1)) / 360;
}
private thirty360European(start: Date, end: Date): number {
let d1 = Math.min(start.getDate(), 30);
let d2 = Math.min(end.getDate(), 30);
const m1 = start.getMonth() + 1;
const m2 = end.getMonth() + 1;
const y1 = start.getFullYear();
const y2 = end.getFullYear();
return (360 * (y2 - y1) + 30 * (m2 - m1) + (d2 - d1)) / 360;
}
private isLeapYear(year: number): boolean {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
}The actual/360 convention, common in commercial lending, produces slightly more interest than actual/365 because the same number of actual days is divided by a smaller denominator. This distinction matters enormously at scale---across a portfolio of millions of dollars, the difference amounts to significant revenue.
The Core Interest Calculation Engine
With day count conventions handled, the calculation engine can compute interest for any period using the appropriate method.
interface InterestCalculationConfig {
dayCountConvention: DayCountConvention;
compoundingFrequency: CompoundingFrequency;
accrualMethod: AccrualMethod;
roundingMode: RoundingMode;
roundingPrecision: number;
}
type CompoundingFrequency =
| "simple"
| "daily"
| "monthly"
| "quarterly"
| "annually";
type AccrualMethod = "daily" | "monthly" | "period";
type RoundingMode = "half_up" | "half_even" | "floor" | "ceiling";
interface InterestResult {
principal: number;
rate: number;
startDate: Date;
endDate: Date;
dayCountFraction: number;
interestAmount: number;
config: InterestCalculationConfig;
}
class InterestCalculationEngine {
constructor(
private readonly dayCount: DayCountCalculator,
private readonly config: InterestCalculationConfig
) {}
calculate(
principal: number,
annualRate: number,
startDate: Date,
endDate: Date
): InterestResult {
const dayCountFraction = this.dayCount.getDayCountFraction(
startDate,
endDate,
this.config.dayCountConvention
);
let interestAmount: number;
switch (this.config.compoundingFrequency) {
case "simple":
interestAmount = principal * annualRate * dayCountFraction;
break;
case "daily":
interestAmount = this.compoundDaily(
principal,
annualRate,
startDate,
endDate
);
break;
case "monthly":
interestAmount = this.compoundPeriodically(
principal,
annualRate,
dayCountFraction,
12
);
break;
case "quarterly":
interestAmount = this.compoundPeriodically(
principal,
annualRate,
dayCountFraction,
4
);
break;
case "annually":
interestAmount = this.compoundPeriodically(
principal,
annualRate,
dayCountFraction,
1
);
break;
}
interestAmount = this.round(interestAmount);
return {
principal,
rate: annualRate,
startDate,
endDate,
dayCountFraction,
interestAmount,
config: this.config,
};
}
private compoundDaily(
principal: number,
annualRate: number,
startDate: Date,
endDate: Date
): number {
const days = Math.round(
(endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)
);
const dailyRate = annualRate / 365;
const compoundedAmount = principal * Math.pow(1 + dailyRate, days);
return compoundedAmount - principal;
}
private compoundPeriodically(
principal: number,
annualRate: number,
yearFraction: number,
periodsPerYear: number
): number {
const periodicRate = annualRate / periodsPerYear;
const totalPeriods = yearFraction * periodsPerYear;
const compoundedAmount =
principal * Math.pow(1 + periodicRate, totalPeriods);
return compoundedAmount - principal;
}
private round(value: number): number {
const factor = Math.pow(10, this.config.roundingPrecision);
switch (this.config.roundingMode) {
case "half_up":
return Math.round(value * factor) / factor;
case "half_even":
return this.bankersRound(value, factor);
case "floor":
return Math.floor(value * factor) / factor;
case "ceiling":
return Math.ceil(value * factor) / factor;
}
}
private bankersRound(value: number, factor: number): number {
const shifted = value * factor;
const truncated = Math.trunc(shifted);
const remainder = Math.abs(shifted - truncated);
if (Math.abs(remainder - 0.5) < Number.EPSILON) {
// Round to even
return (truncated % 2 === 0 ? truncated : truncated + 1) / factor;
}
return Math.round(shifted) / factor;
}
}The banker's rounding mode (half_even) is particularly important in financial systems. Standard "round half up" introduces a systematic bias that, over millions of calculations, produces a measurable drift. Banker's rounding eliminates this bias by rounding to the nearest even number when the value is exactly halfway.
Daily Accrual Engine
Most lending platforms accrue interest daily, even if payments are collected monthly. The daily accrual engine runs as a scheduled batch job that calculates and records each day's interest for every active loan.
interface AccrualRecord {
id: string;
loanAccountId: string;
accrualDate: Date;
principal: number;
dailyRate: number;
accruedAmount: number;
cumulativeAccrued: number;
}
class DailyAccrualEngine {
constructor(
private readonly calculator: InterestCalculationEngine,
private readonly accrualRepo: AccrualRepository,
private readonly loanRepo: LoanAccountRepository
) {}
async runDailyAccrual(accrualDate: Date): Promise<AccrualSummary> {
const activeLoans = await this.loanRepo.findByStatus(["active", "current", "delinquent"]);
let totalAccrued = 0;
let loansProcessed = 0;
let errors = 0;
for (const loan of activeLoans) {
try {
const accrual = await this.accrueForLoan(loan, accrualDate);
totalAccrued += accrual.accruedAmount;
loansProcessed++;
} catch (error) {
errors++;
console.error(
`Accrual failed for loan ${loan.id}: ${error}`
);
}
}
return {
accrualDate,
loansProcessed,
errors,
totalAccrued: Math.round(totalAccrued * 100) / 100,
};
}
private async accrueForLoan(
loan: LoanAccount,
accrualDate: Date
): Promise<AccrualRecord> {
const previousDay = new Date(accrualDate);
previousDay.setDate(previousDay.getDate() - 1);
// Check for duplicate accrual
const existing = await this.accrualRepo.findByLoanAndDate(
loan.id,
accrualDate
);
if (existing) {
throw new Error(`Accrual already exists for loan ${loan.id} on ${accrualDate}`);
}
const result = this.calculator.calculate(
loan.outstandingPrincipal,
loan.interestRate,
previousDay,
accrualDate
);
const previousAccruals = await this.accrualRepo.getCumulativeAccrued(loan.id);
const record: AccrualRecord = {
id: crypto.randomUUID(),
loanAccountId: loan.id,
accrualDate,
principal: loan.outstandingPrincipal,
dailyRate: loan.interestRate / 365,
accruedAmount: result.interestAmount,
cumulativeAccrued: previousAccruals + result.interestAmount,
};
await this.accrualRepo.save(record);
// Update loan's accrued interest
loan.accruedInterest += result.interestAmount;
await this.loanRepo.save(loan);
return record;
}
}
interface AccrualSummary {
accrualDate: Date;
loansProcessed: number;
errors: number;
totalAccrued: number;
}A practical tip: always check for duplicate accrual records before processing. If the daily batch fails partway through and is restarted, the idempotency check prevents double-counting interest. This is a critical correctness concern---double accrual means overcharging borrowers.
Handling Variable and Promotional Rates
Many lending products feature variable interest rates that adjust periodically based on a benchmark index, or promotional rates that apply for a limited time before reverting to a standard rate.
interface RateSchedule {
loanAccountId: string;
segments: RateSegment[];
}
interface RateSegment {
effectiveDate: Date;
expirationDate?: Date;
baseRate: number;
margin: number;
effectiveRate: number;
rateType: "fixed" | "variable" | "promotional";
indexName?: string;
floor?: number;
ceiling?: number;
}
class VariableRateEngine {
constructor(
private readonly indexProvider: BenchmarkIndexProvider,
private readonly rateScheduleRepo: RateScheduleRepository
) {}
async adjustRates(adjustmentDate: Date): Promise<RateAdjustmentResult[]> {
const variableLoans = await this.rateScheduleRepo.findVariableRate();
const results: RateAdjustmentResult[] = [];
for (const schedule of variableLoans) {
const activeSegment = schedule.segments.find(
(s) =>
s.effectiveDate <= adjustmentDate &&
(!s.expirationDate || s.expirationDate > adjustmentDate) &&
s.rateType === "variable"
);
if (!activeSegment || !activeSegment.indexName) continue;
const currentIndex = await this.indexProvider.getCurrentRate(
activeSegment.indexName
);
let newRate = currentIndex + activeSegment.margin;
// Apply floor and ceiling
if (activeSegment.floor !== undefined) {
newRate = Math.max(newRate, activeSegment.floor);
}
if (activeSegment.ceiling !== undefined) {
newRate = Math.min(newRate, activeSegment.ceiling);
}
const oldRate = activeSegment.effectiveRate;
activeSegment.effectiveRate = Math.round(newRate * 10000) / 10000;
results.push({
loanAccountId: schedule.loanAccountId,
oldRate,
newRate: activeSegment.effectiveRate,
indexValue: currentIndex,
margin: activeSegment.margin,
adjustmentDate,
});
await this.rateScheduleRepo.save(schedule);
}
return results;
}
}
interface RateAdjustmentResult {
loanAccountId: string;
oldRate: number;
newRate: number;
indexValue: number;
margin: number;
adjustmentDate: Date;
}Rate floors and ceilings protect both lender and borrower. A floor ensures that the lender always earns a minimum return even if the benchmark index drops to zero. A ceiling caps the borrower's exposure to rising rates. Both are typically specified in the loan agreement and must be enforced precisely by the calculation engine.
APR Calculation
The Annual Percentage Rate is a disclosure requirement under TILA that captures the total cost of borrowing, including fees, expressed as an annualized rate. APR calculation requires finding the internal rate of return of the loan's cash flow schedule.
class APRCalculator {
calculate(
loanAmount: number,
totalFees: number,
monthlyPayment: number,
termMonths: number
): number {
const netProceeds = loanAmount - totalFees;
// Newton-Raphson method to find the monthly rate
let guess = 0.005; // Start with 0.5% monthly
const tolerance = 0.00000001;
const maxIterations = 100;
for (let i = 0; i < maxIterations; i++) {
const { pv, dpv } = this.presentValueAndDerivative(
monthlyPayment,
termMonths,
guess
);
const diff = pv - netProceeds;
if (Math.abs(diff) < tolerance) break;
guess = guess - diff / dpv;
}
return Math.round(guess * 12 * 10000) / 10000; // Annualize and round
}
private presentValueAndDerivative(
payment: number,
periods: number,
rate: number
): { pv: number; dpv: number } {
let pv = 0;
let dpv = 0;
for (let t = 1; t <= periods; t++) {
const discountFactor = Math.pow(1 + rate, -t);
pv += payment * discountFactor;
dpv += -t * payment * Math.pow(1 + rate, -(t + 1));
}
return { pv, dpv };
}
}The Newton-Raphson iterative method converges quickly for APR calculations and is the industry-standard approach. The tolerance should be set tight enough that the resulting APR is accurate to at least four decimal places, which satisfies TILA's disclosure requirements.
Conclusion
An interest calculation engine is one of those components where correctness is non-negotiable. Errors in interest calculation directly affect borrower charges, lender revenue, and regulatory compliance. The engine must support multiple day count conventions to serve different product types, implement precise rounding strategies to avoid systematic bias, run daily accrual reliably with idempotency guarantees, handle variable and promotional rates with floor and ceiling enforcement, and compute APR accurately for disclosure purposes.
TypeScript provides the structure needed to make these configurations explicit and type-safe. By parameterizing the engine with day count conventions, compounding frequencies, accrual methods, and rounding modes, the same core calculation logic can serve personal loans, commercial credit lines, mortgages, and any other lending product without modification. The investment in getting this component right pays dividends across the entire platform.
Related Articles
Designing APIs for Lending Platforms
A comprehensive guide to designing robust, secure, and developer-friendly APIs for lending platforms, covering RESTful resource modeling, webhook architectures, idempotency, versioning, and partner integration patterns in TypeScript.
Risk Management in Lending: Architecture and Strategy
A strategic guide to building a comprehensive risk management framework for lending platforms, covering credit risk, portfolio management, stress testing, concentration limits, and loss forecasting.
Digital Lending Trends: What's Next for Fintech
A business-focused analysis of the trends shaping digital lending, including embedded finance, alternative data, real-time decisioning, open banking, and the evolution of lending-as-a-service platforms.