为了搞清楚每个月那 4546 块钱去哪了,我写了个还款计划模拟器
起因是一张对不上的表
签完贷款合同,银行给了一张还款计划表。
一百万,30 年,利率 3.6%,等额本息,月供 4546.51 元。
第一行写着:
本期利息:3000.00
本期本金:1546.51
我盯着看了一会儿。
每个月还 4546 块,真正还到本金里的只有 1546 块。按这个月供算下来:
4546.51 × 360 = 1,636,743.6
也就是 63.67 万利息。
这个数字用房贷计算器一按就有。但我更想知道的是另外几件事:
- 第三年年初提前还 5 万,能少多少利息?
- 如果每年都还 5 万呢?
- 选择「减少月供」之后,把省下来的月供继续拿去提前还,会怎么样?
网上的房贷计算器大多适合算一次性提前还款,复杂一点的情况就不太好用了。银行客户经理给我的回答也很直接:
到时候来柜台我们给您算。
那就自己写一个。
这篇只讲这个小程序里的计算内核,主要是 src/core/loan.ts。UI 和小程序部分不展开。
先把等额本息算明白
等额本息的公式:
scss
月供 = P · r · (1+r)^n / ((1+r)^n − 1)
其中:
P:贷款本金r:月利率n:剩余期数
代码其实很简单:
arduino
export function equalPaymentOf(
principal: number,
monthlyRate: number,
months: number
): number {
if (months <= 0) return principal;
if (monthlyRate === 0) return principal / months;
const pow = Math.pow(1 + monthlyRate, months);
return (principal * monthlyRate * pow) / (pow - 1);
}
monthlyRate === 0 这个分支不能省。
公式在利率为 0 时分母也是 0,如果直接算会得到 NaN。而用户编辑输入框的时候,利率暂时为空,程序里很可能就是 0。
这种边界不处理,后面所有计算都会跟着变成 NaN。
至于公式为什么是这样,可以从现金流倒过来看:
scss
P = M/(1+r) + M/(1+r)² + ... + M/(1+r)ⁿ
贷款本金等于未来每期还款折现后的总和,右边是等比数列,求和之后反解 M,就是上面的公式。
第一个月为什么利息正好是 3000?
因为本金还是 100 万,月利率是 3.6% / 12 = 0.3%:
erlang
1,000,000 × 0.3% = 3,000
随着本金逐渐下降,利息也跟着下降。
等额本金则简单一些,每期归还固定本金,利息按照剩余本金计算:
ini
monthlyPrincipal = part.principal / totalMonths;
interestFull = balance * r;
100 万、30 年的情况下,第一期:
ini
1000000 / 360 + 3000 = 5777.78
所以等额本金前期月供会明显高一些,但总利息更少。
为什么要逐月模拟
如果只是计算总利息,一行代码就够了:
月供 × 期数 − 本金
但提前还款一加进来,这种算法就不够用了。
提前还一笔之后,剩余本金变了,后面的利息都要重新计算。
如果选择「减少月供」,后面的月供也会发生变化。
如果是组合贷,商贷和公积金甚至是两套不同的参数。
所以内核最后采用了一个很朴素的办法:
一个月一个月往前算。
ini
for (let period = 1; period <= totalMonths && balance > 1e-6; period++) {
const date = monthAt(startDate, period);
const interestFull = balance * r;
let principalFull: number;
if (isEP) {
principalFull = monthlyPayment - interestFull;
if (principalFull >= balance - 1e-6) {
principalFull = balance;
}
} else {
principalFull = monthlyPrincipal;
if (principalFull >= balance - 1e-6) {
principalFull = balance;
}
}
balance -= principalFull;
// ...
}
这里的 1e-6 是处理浮点误差。
理论上最后一期还完之后余额应该正好是 0,但实际计算可能得到:
2.3283064365386963e-10
如果条件写成:
balance > 0
就可能多跑一期,最后多出一行 0.00。
日期不要用 Date 算
这里还踩过一个 JavaScript 的坑。
最开始用 Date.setMonth() 推月份,结果从 1 月 31 日加一个月,可能直接跑到 3 月。
原因是目标月份没有 31 日,JavaScript 会继续溢出。
但房贷计算其实根本不关心具体日期,只需要「哪年哪月」。
所以直接把年月转换成一个整数:
typescript
export function monthAt(start: string, k: number): MonthDate {
const [y, m] = start.split("-").map(Number);
const idx = y * 12 + (m - 1) + (k - 1);
return {
year: Math.floor(idx / 12),
month: (idx % 12) + 1,
};
}
这样没有时区,也没有日期溢出的问题。
最麻烦的是那几分钱
模拟器跑通以后,我拿结果和银行的还款计划表逐行比较。
前面基本都一样,后面开始出现几分钱的差异。
到最后一期,累计差了一块多。
金额不大,但对于一张还款表来说,这个问题不能接受。
因为用户把本金这一列加起来,应该得到最开始的贷款本金。
问题出在取整。
最开始的实现是每个月算完以后就把余额保留两位小数:
ini
balance = round2(balance);
看起来很合理,钱本来就是按分计算。
但问题是,这个被取整后的余额会进入下一期利息计算。
每个月的半分钱误差都会带到下一期,360 期以后就可能变成块钱级别。
后来把「计算精度」和「显示精度」分开:
ini
balance -= principalFull;
const interest = round2(interestFull);
const payment = round2(interestFull + principalFull);
const principal = round2(payment - interest);
余额内部始终保持全精度,只有写入展示数据时才取两位小数。
还有一个细节:
ini
const principal = round2(payment - interest);
而不是:
ini
const principal = round2(principalFull);
因为如果本金和利息分别取整,它们相加后可能和显示出来的月供差 1 分。
从月供减掉利息,可以保证:
本金 + 利息 = 月供
最后一期做一次轧差
即使每一期都单独取整,所有展示值加起来仍然可能差几分钱。
所以最后一期再做一次校正:
ini
const last = items[items.length - 1];
const paidBefore = items
.slice(0, -1)
.reduce((a, i) => a + i.principal + i.prepay, 0);
const fixedPrincipal = round2(part.principal - paidBefore);
if (Math.abs(fixedPrincipal - last.principal) < items.length * 0.01 + 0.5) {
last.principal = fixedPrincipal;
last.payment = round2(fixedPrincipal + last.interest);
}
逻辑很简单:
前面所有期已经还掉多少本金,最后一期就还剩多少。
不过这里不能无条件修正。
所以加了一个上限:
matlab
items.length * 0.01 + 0.5
如果差额明显超过正常的取整误差,就不应该偷偷塞到最后一期。
之前改代码时就遇到过一次类似的问题:余额扣减漏了一步,最后差了六千多。如果没有这个限制,错误可能直接被「最后一期轧差」盖掉。
这种保护对调试很有用。
汇总值也按照用户看到的数字计算
总利息没有直接套公式,而是把明细逐行加起来:
css
const totalInterest = round2(
items.reduce((a, i) => round2(a + i.interest), 0)
);
const totalPayment = round2(
items.reduce((a, i) => round2(a + i.payment + i.prepay), 0)
);
这里看起来有点啰嗦。
为什么不直接:
css
round2(items.reduce((a, i) => a + i.interest, 0))
因为用户看到的是一行一行、精确到分的数字。
如果程序内部最后才统一取整,可能出现:
程序汇总:123456.78
用户手算:123456.79
所以汇总也按照展示出来的数字逐行计算。
提前还款:缩短期限和减少月供
提前还款主要有两种方式:
- 缩短期限
- 减少月供
代码里其实是两条完全不同的路径。
首先扣掉提前还款:
ini
balance -= prepayAmt;
如果选择减少月供,就用新的余额和剩余期数重新计算:
ini
if (balance > 1e-6 && afterPrepay === "reducePayment") {
const remaining = totalMonths - period;
if (remaining > 0) {
if (isEP) {
monthlyPayment = equalPaymentOf(
balance,
r,
remaining
);
} else {
monthlyPrincipal = balance / remaining;
}
}
}
而缩短期限不需要重新计算月供。
月供保持不变,余额会更快归零,循环也就更早结束。
这两个方案到底差多少,直接模拟一次就知道。
比如:
100 万、30 年,第 12 期一次性提前还 10 万:
- 缩短期限:总期限减少 60 多个月,总利息少 20 多万
- 减少月供:期限不变,月供下降,总利息少 11 万左右
具体数字会随贷款参数变化,但差异的原因很简单:
利息同时受本金和时间影响。
缩短期限既减少本金占用,又减少计息时间;减少月供主要减少的是本金。
那为什么还要提供减少月供?
因为现金流。
少还几百块,是下个月就能感受到的变化;省下来的十几万利息,要很多年以后才真正体现出来。
所以程序把两种结果并排展示,不替用户做选择。
提前还款规则
现实中的提前还款通常不是「某一天突然还一笔」。
更常见的是:
- 第三年还 5 万
- 每隔半年还 2 万
- 每年 1 月还 5 万
所以我把提前还款抽成了规则:
sql
function ruleTriggers(
rule: PrepayRule,
period: number,
date: MonthDate,
start: string
): boolean {
switch (rule.mode) {
case "once":
return period === rule.startPeriod;
case "everyN": {
const interval = Math.max(1, rule.intervalPeriods);
return (
period >= rule.startPeriod &&
(period - rule.startPeriod) % interval === 0
);
}
case "yearly": {
const [sy] = start.split("-").map(Number);
const firstYear = sy + (rule.startYear - 1);
return (
date.month === rule.month &&
date.year >= firstYear
);
}
}
}
这里特意保留了 everyN 和 yearly 两种模式。
「每 12 期」和「每年」看起来一样,但其实不完全一样。
假设第一期是 2026 年 3 月:
- 每 12 期:下一次是 2027 年 3 月
- 每年 1 月:可能对应第 11、23、35 期
让用户自己计算这个偏移量没什么意义。
另外:
javascript
Math.max(1, intervalPeriods)
也是为了防止间隔输入 0。
否则 % 0 得到 NaN,规则不会触发,而且不会直接报错。
这种问题最难发现,因为用户只会觉得:
我明明设置了,为什么没生效?
多条规则可以在同一期同时触发,金额累加,但不能超过当期剩余本金:
ini
for (const rule of rules) {
if (!rule.enabled || rule.target !== target) continue;
if (!ruleTriggers(rule, period, date, startDate)) continue;
let amt = rule.amount;
if (amt <= 0) continue;
if (prepayAmt + amt > balanceBeforePrepay) {
amt = balanceBeforePrepay - prepayAmt;
}
if (amt <= 0) continue;
prepayAmt += amt;
}
这里有个细节:
balanceBeforePrepay 是本期正常还款之后、所有提前还款之前的余额。
多条规则叠加时,都应该和同一个余额快照比较。
如果直接拿实时变化的 balance 去判断,前一条规则扣掉的钱会影响后一条规则的计算。
滚雪球:把省下来的月供继续还进去
这个功能是我自己比较想要的。
比如选择「减少月供」之后,月供从 4546 降到 4092。
如果这 454 块直接进入日常消费,实际上就很难感受到它的价值。
那不如继续拿去还本金。
于是加了一个「滚雪球」:
月供降多少,就继续把这部分钱拿去提前还款。
有两种模式。
第一种,每个月直接追加:
ini
const currentPayment = isEP
? monthlyPayment
: monthlyPrincipal + balance * r;
const saving = Math.max(
0,
round2(initialPayment - currentPayment)
);
if (snowball.mode === "autoAdd") {
snowballAdd = Math.min(saving, balance);
}
第二种先攒起来:
ini
if (snowball.mode === "accumulate") {
reserve = round2(reserve + saving);
const thr = snowball.threshold || 0;
if (thr > 0 && reserve >= thr) {
const n = Math.floor(reserve / thr);
snowballAdd = round2(n * thr);
reserve = round2(reserve - snowballAdd);
}
}
第二种更接近现实。
很多银行对提前还款有次数或者最低金额限制,不可能每个月为了 400 多块跑一次。
比如设置 1 万元的阈值,月供省下来的钱就先存起来,达到 1 万以后再还。
这里用:
arduino
Math.floor(reserve / thr)
而不是简单减一次阈值,是为了处理一次跨过多个阈值的情况。
剩余的钱继续留在 reserve 里。
为什么滚雪球只在减少月供时启用
代码里直接限制:
ini
const sbEnabled =
afterPrepay === "reducePayment" &&
snowball &&
snowball.mode !== "none";
因为缩短期限模式下月供没有下降。
既然:
ini
saving === 0
就没有滚雪球的「燃料」。
如果用户想在缩短期限的基础上多还,直接把提前还款规则里的金额调大就行。
这个限制还有一个好处:UI 可以根据当前模式直接禁用滚雪球选项,不会让用户开了一个实际上没有任何效果的功能。
给月供设一个目标
还有一种实际需求:
月供降到 3000 以内就不继续还了。
如果一直减少月供,理论上可以一直往下降,但这不一定是用户想要的。
所以增加了一个目标值:
ini
const stopPrepay =
afterPrepay === "reducePayment" &&
typeof reducePaymentTarget === "number" &&
reducePaymentTarget > 0 &&
currentPayment <= reducePaymentTarget;
一旦当前月供达到目标,本期后面的提前还款就全部停止,包括规则触发和滚雪球追加。
结果页会在月供曲线上标出达到目标的时间。
这个结果比单纯告诉用户「总利息少了多少」更直观。
组合贷:两笔贷款分开算
组合贷本质上就是两笔贷款:
- 商业贷款
- 公积金贷款
两笔贷款的利率、还款方式甚至提前还款策略都可能不同。
所以没有把它们硬塞进一个计算器,而是分别模拟:
arduino
for (const { key, part } of targets) {
const withPrepay = simulatePart(
part,
config.startDate,
rules,
config.afterPrepay,
key,
...
);
const baseline = simulatePart(
part,
config.startDate,
[],
config.afterPrepay,
key,
...
);
}
simulatePart() 根本不需要知道组合贷存在。
它只负责算一笔贷款。
规则通过 target 指定作用于哪一笔。
最后再按月份合并。
因为两笔贷款可能不会同时结束,所以短的一笔后面相当于补 0:
ini
const len = Math.max(
0,
...partResults.map((p) => p.items.length)
);
for (let i = 0; i < len; i++) {
for (const part of partResults) {
const item = part.items[i];
if (item) {
// 累加
}
}
}
滚雪球放到组合贷里,还有一个问题:
省下来的钱应该还哪一笔?
这里直接选择利率更高的贷款。
比如商贷 3.6%,公积金 2.85%,同样提前还 1 万,优先减少 3.6% 那笔贷款的利息。
利率相同时则优先商贷。
这个没有做成用户可配置项,因为在单纯比较利息的情况下,利率高的那笔就是更优先的选择。
「省了多少」必须有基线
「省了 23 万利息」这句话本身没有意义。
必须先说清楚:
和什么相比?
所以每笔贷款实际上会算两遍:
ini
const withPrepay = simulatePart(
part,
startDate,
rules,
afterPrepay,
key,
...
);
const baseline = simulatePart(
part,
startDate,
[],
afterPrepay,
key,
...
);
一遍按照用户设置的提前还款方案计算。
另一遍完全不提前还款。
然后:
ini
interestSaved =
baselineInterest - totalInterest;
monthsSaved =
baselineMonths - months;
这样结果页就可以直接比较:
- 原计划总利息
- 当前方案总利息
- 节省利息
- 提前还款后减少的期数
再配一张剩余本金曲线,差异会比较直观。
计算 360 期的循环成本很低,多算一次没有什么问题。
怎么确认计算是对的
计算内核是整个项目里唯一有测试的部分。
src/core/ 不依赖 Vue 和 uni API,所以可以直接在 Node 环境里跑。
测试没有大量硬编码 360 期的结果。
因为那样相当于再写一份答案去验证原来的答案,维护起来也麻烦。
主要测试的是一些必须成立的性质,也就是不变量。
比如本金守恒:
scss
it("本金合计(含提前还款)等于贷款额", () => {
expect(sumPrinciple(res)).toBeCloseTo(1_000_000, 1);
});
以及月供:
scss
it("月供逐期相等,尾差在最后一个月修正", () => {
for (const it of res.items.slice(0, 359)) {
expect(
Math.abs(it.payment - res.firstPayment)
).toBeLessThanOrEqual(0.01);
}
});
提前还款也测极端情况:
scss
it("提前还款不超过剩余本金(大额一次性)", () => {
const res = simulatePart(
PART,
START,
[mkRule({ amount: 5_000_000 })],
"shortenTerm",
"commercial"
);
expect(res.months).toBe(12);
expect(res.items[11].remainPrincipal).toBe(0);
});
其中本金守恒是最重要的一条。
余额推进时少扣一次、多扣一次,最后都会暴露出来。
还加了一条回归测试,保证新功能关闭时不会影响原来的行为:
scss
it("无 snowball 配置时与旧行为一致", () => {
const legacyRes = simulatePart(
PART,
START,
[mkRule()],
"reducePayment",
"commercial",
4_500
);
const newRes = simulatePart(
PART,
START,
[mkRule()],
"reducePayment",
"commercial",
4_500,
{ mode: "none" }
);
expect(legacyRes.totalPrepay)
.toBe(newRes.totalPrepay);
expect(legacyRes.totalInterest)
.toBe(newRes.totalInterest);
});
唯一一处比较具体的数值测试,是用常见房贷计算器的结果作为范围基准:
scss
it("100万 3.6% 30年 月供约 4546.5", () => {
const payment = equalPaymentOf(
1_000_000,
0.003,
360
);
expect(payment).toBeGreaterThan(4540);
expect(payment).toBeLessThan(4550);
});
这里没有直接写死一个小数,是因为不同计算器在最后一位的取舍可能存在差异。
写完之后
模拟器跑起来以后,我把手上的几个方案都试了一遍。
最大的一个变化,是我以前觉得提前还款最重要的是「还多少」,后来发现其实更重要的是什么时候还。
同样是 10 万块,第 12 期还和第 60 期还,省下来的利息可以差很多。
原因其实就是那几个字:
余额 × 时间。
但真正把两条剩余本金曲线放在一起以后,才有比较直观的感觉。
另外一个收获,是那张银行给我的还款计划表突然没那么神秘了。
它其实就是一组输入经过一套规则之后得到的输出:
markdown
本金
利率
期限
还款方式
提前还款规则
↓
逐月模拟
↓
每期本金 / 利息 / 月供 / 余额
↓
总利息 / 节省利息 / 减少期数
等额本息本身并不复杂,公式就是一个等比数列。
真正花时间的,反而是那些公式之外的东西:
- 浮点数到底什么时候取整
- 最后一期怎么处理尾差
- 日期为什么不能随便用
Date - 多条提前还款规则怎么叠加
- 「每 12 期」和「每年」为什么不能简单当成一回事
- 组合贷怎么拆开计算
- 新功能怎么保证不会破坏旧逻辑
这些东西如果只看公式,很难提前想到。
所以最后这个项目里,我觉得最有价值的部分反而不是那条等额本息公式,而是把一张银行还款表真正拆成了一套可以运行、可以测试、也可以自己修改参数的程序。
如果你想体验,欢迎微信搜索【推雪房贷计算器】,或扫描小程序码:
