用 TypeScript 实现贷款 IRR:从还款现金流计算年化利率
贷款计算器里,一个容易写错的功能是“年化利率”。
假设借款 10,000 元,分 12 个月偿还,每月还 900 元。不考虑其他费用,总还款为 10,800 元。
直接计算:
(10800 - 10000) / 10000 = 8%
这只能得到总利息与初始本金的比例,不能直接当作这笔分期贷款的年化利率。因为借款人在逐月归还本金,并没有完整占用 10,000 元一整年。
如果放款时还扣了手续费,计算结果又会不同。
本文用一个具体例子,说明如何整理还款现金流,再用 TypeScript 求出月度 IRR 和复利口径的年化利率。
一、先确定计算口径
“真实年化率”是常见说法,但在程序界面和计算结果里,最好明确标注口径,例如:
- 月度 IRR;
- 月度 IRR × 12,线性年化口径;
(1 + 月度 IRR)^12 - 1,复利年化口径。
这几项不能混着展示。
中国人民银行公告〔2021〕第3号指出,贷款成本应包括利息及与贷款直接相关的各类费用;年化利率可以采用复利或单利方法计算,采用单利方法的应说明是单利。公告附件也提供了内部收益率法的计算示例。来源:中国人民银行
本文计算的是:根据输入的借款和还款现金流,先求月度 IRR,再换算为复利年化利率。是否遗漏费用,会直接影响结果。
二、把借款拆成现金流
以下是一组假设数据:
| 项目 | 金额或安排 |
|---|---|
| 合同本金 | 10,000 元 |
| 放款时扣除的手续费 | 200 元 |
| 实际到账 | 9,800 元 |
| 还款期数 | 12 期 |
| 每期还款 | 900 元 |
| 首期还款时间 | 放款后一个月末 |
从借款人视角看,收到的钱是正数,支付的钱是负数:
第 0 期:+9800
第 1 期:-900
第 2 期:-900
……
第 12 期:-900
这里有两个细节。
第一,初始现金流用的是实际到账的 9,800 元,而不是合同本金 10,000 元。
第二,200 元手续费已经从初始现金流里扣除,后续不能再加一次,否则会重复计费。如果还有每月收取的贷款相关费用,应计入对应月份的还款金额。
三、IRR 求解的是什么
设实际到账金额为 P,第 t 个月的还款金额为 Aₜ,月度 IRR 为 r,需要求解:
P = A₁ / (1+r) + A₂ / (1+r)² + … + Aₙ / (1+r)ⁿ
也就是寻找一个月利率,让所有未来还款折算到放款时点后的现值,等于实际到账金额。
求出 r 后:
线性年化 = r × 12
复利年化 = (1 + r)¹² - 1
这里的复利换算是统一年化口径,不表示合同一定按“利滚利”收款。
四、用二分法实现
对于“一次到账、之后只还款”的现金流,随着折现率提高,还款现值会下降,可以用二分法求解。
下面的函数限定为:
- 每期代表一个月,首期在一个月末;
- 只有一次初始到账,后续还款金额不能为负;
- 总还款不少于实际到账,即只处理非负利率;
- 最多支持 1,200 期,并设置求解边界。
它不是适用于任意投资现金流的通用 IRR 函数。
/**
* 返回月度 IRR,例如 0.015 表示月度 1.5%。
* payments 为每个月末支付的金额,使用正数。
*/
function monthlyIrr(
netReceived: number,
payments: readonly number[]
): number {
if (!Number.isFinite(netReceived) || netReceived <= 0) {
throw new Error("实际到账金额必须大于 0");
}
if (payments.length === 0 || payments.length > 1200) {
throw new Error("还款期数必须在 1~1200 之间");
}
let total = 0;
for (const amount of payments) {
if (!Number.isFinite(amount) || amount < 0) {
throw new Error("还款金额必须是非负有限数");
}
total += amount;
}
if (!Number.isFinite(total)) {
throw new Error("金额超出计算范围");
}
if (total < netReceived) {
throw new Error("此示例不支持负利率");
}
if (total === netReceived) return 0;
// 未来还款现值减去实际到账金额。
const residual = (rate: number): number => {
let pv = 0;
let discount = 1;
for (const amount of payments) {
discount /= 1 + rate;
pv += amount * discount;
}
return pv - netReceived;
};
let low = 0;
let high = 1;
// 扩大上界,直到覆盖根;上界设置硬限制。
while (residual(high) > 0 && high < 1048576) {
high *= 2;
}
if (residual(high) > 0) {
throw new Error("利率超出求解范围");
}
for (let i = 0; i < 160; i++) {
const mid = (low + high) / 2;
if (residual(mid) > 0) {
low = mid;
} else {
high = mid;
}
}
return (low + high) / 2;
}
二分法每轮都要遍历一次还款数组。固定迭代次数后,计算量随期数线性增长,适合这种规模较小的计算器功能。
五、运行前面的例子
const payments = Array<number>(12).fill(900);
const monthlyRate = monthlyIrr(9800, payments);
const linearAnnualRate = monthlyRate * 12;
const effectiveAnnualRate = (1 + monthlyRate) ** 12 - 1;
console.log(`月度 IRR:${
(monthlyRate * 100).toFixed(4)}%`);
console.log(`线性年化:${
(linearAnnualRate * 100).toFixed(4)}%`);
console.log(`复利年化:${
(effectiveAnnualRate * 100).toFixed(4)}%`);
这组数据的计算结果为:
月度 IRR:1.5274%
线性年化:18.3292%
复利年化:19.9502%
为什么和最开始的 8% 差这么多?
8% 没有考虑本金逐月归还,也没有计入放款时扣除的 200 元手续费。IRR 则同时考虑了实际到账金额、每次还款金额和还款时点。
因此,不能只看“总共多还了多少钱”,还要看“这笔钱实际用了多久”。
六、给计算结果做交叉检查
除了检查显示出来的百分比,还可以把求得的利率代回原方程:
const presentValue = payments.reduce(
(sum, amount, index) =>
sum + amount / (1 + monthlyRate) ** (index + 1),
0
);
console.log(presentValue.toFixed(2)); // 9800.00
console.assert(
Math.abs(presentValue - 9800) < 0.000001,
"现值校验未通过"
);
console.assert(
monthlyIrr(1200, Array<number>(12).fill(100)) === 0,
"零利率测试未通过"
);
console.assert(
Math.abs(monthlyIrr(1000, [1100]) - 0.1) < 1e-10,
"单期测试未通过"
);
单期测试很直观:到账 1,000 元,一个月后还 1,100 元,对应月度利率就是 10%。
计算过程中保留精度,最后展示时再调用 toFixed()。不要每轮迭代都四舍五入。
七、接入实际业务时的边界
不规则日期不能直接套用月度 IRR。
如果首期只有 15 天,或中途提前还款,就不再符合这里的等月间隔假设。需要使用按实际日期折现的模型,并明确一年按多少天计算。
还款数组必须包含完整现金流。
到账时收取的费用调整初始净到账;以后收取的费用放到实际支付的期次。缺少一笔费用,结果就只代表已输入的部分成本。
金额处理和利率求解分开。
业务账单可以使用整数分或十进制定点运算管理金额。将实际应付金额确定到分后,再送入求解函数。number 适合这里的数值求解,但不应承担所有账务精度处理。
不要用这个函数处理反复借还的现金流。
多次提款、退款或正负现金流多次交替,不满足本文的单调性前提,可能出现多个 IRR 或无解,需要另外设计。
结语
做贷款年化计算器,难点不只是把公式写进代码,更在于把钱的流向和时间安排放对。
先整理实际到账与每期支出,再求月度 IRR,最后明确年化口径,计算结果才方便核对和比较。结果也不能单独用于判断收费是否合法。
相关工具入口:第三方贷款计算器。这是我维护的网站;本文代码是独立教学示例,使用时应按具体还款安排核对输入和计算口径。