提前还贷,缩短年限和降低月供其实是一样的

为了搞清楚每个月那 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
      );
    }
  }
}

这里特意保留了 everyNyearly 两种模式。

「每 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 期」和「每年」为什么不能简单当成一回事
  • 组合贷怎么拆开计算
  • 新功能怎么保证不会破坏旧逻辑

这些东西如果只看公式,很难提前想到。

所以最后这个项目里,我觉得最有价值的部分反而不是那条等额本息公式,而是把一张银行还款表真正拆成了一套可以运行、可以测试、也可以自己修改参数的程序。

如果你想体验,欢迎微信搜索【推雪房贷计算器】,或扫描小程序码:

相关推荐
2501_928996221 小时前
Agent 开发 API 选型:硅碳相变下 Function Calling 兼容性与多模型路由拆解
前端
invicinble1 小时前
做数字产品的核心内容--数据的设计与展示
大数据·前端
LabVIEW开发1 小时前
LabVIEW运行时动态修改控件标题
前端·labview·labview知识·labview功能·labview程序
晴天162 小时前
ES6+ 核心语法
前端·es6·状态模式
API快乐传递者2 小时前
淘宝海外商品详情接口实战指南:从全球开放平台到跨境铺货的全链路方案
java·前端·数据库
gyratesky2 小时前
支持独立部署的地图方案
前端·gis
a1117762 小时前
图片转3D模型 img2threejs 开源
前端·开源
老王以为2 小时前
走进AI Agent第三篇:让 Agent 记住你
前端·人工智能·机器学习
全栈技术负责人2 小时前
大模型流式输出核心技术:智能 Markdown 渲染引擎方案
前端·javascript·vue.js