Skip to content
Go back

从 0.1 + 0.2 到 FIFO 成本:投资系统里的金额精度问题

很多 JavaScript 开发者都见过这个例子:

0.1 + 0.2 === 0.3;// false0.1 + 0.2;// 0.30000000000000004

这个问题很经典,也很容易被当成一个语言笑话。

但我在做 StockTracker 的收益核算时,越来越觉得它不是一个只存在于面试题里的问题。只要系统里有手续费、费率、每股成本、分红摊薄和 FIFO 成本匹配,它就会从一个看起来无害的小数误差,变成一条会进入业务链路的计算偏差。

这篇想写的不是“JavaScript 为什么又出错了”,而是另一个更实际的问题:当一个系统真的要算投资账时,应该怎样对待数字。

JavaScript number 表达的不是十进制小数

JavaScript 里的 number 使用 IEEE 754 双精度浮点数。

它不是专门为金额设计的十进制类型,而是一种二进制浮点表示。很多十进制小数无法用有限的二进制小数精确表示。

这个问题可以类比成十进制里的 1 / 3

1 / 3 = 0.333333333333...

十进制无法用有限位数精确表示三分之一,只能保存一个近似值。

同样,二进制也无法用有限位数精确表示很多十进制小数。比如 0.10.2 在进入计算机之后,就已经不是我们纸面上看到的精确十进制值,而是最接近它们的二进制近似值。

所以:

0.1 + 0.2;

并不是两个精确的十进制小数相加,而是两个二进制近似值相加。结果再转回十进制显示,就可能变成:

0.30000000000000004

这不是 JavaScript 算错了。

更准确地说,是 JavaScript 准确执行了二进制浮点规则,但金融业务通常需要的是十进制语义。

toFixed 只能修展示,不能修账本

很多场景里,number 完全够用。

页面宽高、滚动距离、动画进度、图表坐标、百分比位置,这些都允许细微误差。用户不会关心一个元素实际宽度是 199.9999999997px 还是 200px

但金额系统不一样。

比如 A 股佣金:

10000 * 0.0003;// 2.9999999999999996

业务上,这应该是:

10000 元 * 0.03% = 3 元

如果只是展示,当然可以:

(10000 * 0.0003).toFixed(2);// "3.00"

但展示修好了,不代表计算链路修好了。

toFixed 本质上是格式化 API。它返回字符串,适合把数字显示成用户能读的样子,但它不是金额计算模型。

金融计算里真正重要的问题不是“最后保留两位”,而是:

哪类值应该保留几位?哪一步应该舍入?哪些中间值不能太早舍入?

如果这些口径不明确,代码里就会出现很多看似合理的 toFixed(2)。短期能把界面数字修漂亮,长期会让计算链路变得不可解释。

误差是怎么被投资核算放大的

投资工具里的金额误差经常不是一次性出现的,而是被一层一层传下去。

先看最简单的手续费。

成交金额:10000 元佣金率:0.03%最低佣金:5 元

这里系统要计算:

佣金 = max(成交金额 * 佣金率, 最低佣金)

如果底层乘法已经产生浮点近似值,后面再参与最低佣金比较、买入净成本、卖出到账和收益汇总,误差就不再只是一个显示问题。

更典型的是每股成本。

比如买入 3 股,总成本 36.5 元:

每股成本 = 36.5 / 3 = 12.166666...

如果系统立刻把每股成本保留两位:

12.17

后来卖出 2 股时,成本基础会变成:

12.17 * 2 = 24.34

但如果中间保留更高精度,成本基础更接近:

12.166667 * 2 = 24.333334

最后再按金额规则保留两位,得到的是:

24.33

两者只差 0.01 元,看起来很小。但这类差异一旦进入大量交易、分红摊薄、部分卖出和组合汇总,就会变得很难解释。

投资工具最怕的不是差一分钱本身,而是用户问“为什么和券商不一样”时,系统无法说清楚自己的口径。

FIFO 成本队列会继续放大问题

StockTracker 的卖出盈亏采用 FIFO。

买入时,系统会把买入批次放进成本队列:

第 1 批:100 股,每股成本 10.05第 2 批:100 股,每股成本 12.05

卖出时,从最早的批次开始匹配:

卖出 150 股  -> 匹配第 1 批 100 股  -> 匹配第 2 批 50 股

成本基础不是简单的“当前平均成本 * 卖出数量”,而是按批次计算:

成本基础 = 第 1 批成本 + 第 2 批部分成本

这意味着每个买入批次的成本精度都会影响卖出盈亏。

如果买入净成本里包含手续费,单股成本就更容易出现除不尽:

买入金额:36 元手续费:0.5 元买入数量:3 股净成本:36.5 元每股成本:12.166666...

卖出 2 股时:

成本基础 = 12.166666... * 2

如果前面用 number 加上随手 toFixed,这个成本基础就会随着代码路径变化而变化。更糟糕的是,这类变化不一定能在界面上立刻暴露,但会在交易明细、汇总收益和 AI 分析上下文里慢慢扩散。

分红和清仓会让这个问题再复杂一层。

现金分红会优先摊低仍持有批次的成本;清仓后重新买入,又必须隔离旧一轮持仓里的分红和成本。也就是说,成本队列里保存的不只是“买入价格”,而是一组会被后续交易继续消费的账本状态。

所以我后来更倾向于把收益核算看成一个账本问题,而不是一个展示问题。

账本需要的是固定口径,而不是看起来差不多。

真正要设计的是精度口径

StockTracker 里,我最后把金额计算封装在一个很薄的 money.ts 里。

它没有让业务代码到处直接使用精度库,而是提供少量基础函数:

export function add(a: number, b: number): numberexport function sub(a: number, b: number): numberexport function mul(a: number, b: number): numberexport function div(a: number, b: number): numberexport function roundMoney(value: number): numberexport function calcCommission(...)

这里有一个重要区分:

const MONEY_DP = 2;const COST_DP = 6;

最终金额通常保留 2 位,比如手续费、税费、到账金额、已实现盈亏。

但每股成本这类中间值会保留更高精度。因为每股成本不是最终展示金额,它还会继续参与 FIFO 成本匹配、摊薄成本和后续卖出盈亏计算。

这件事听起来细,但它决定了系统的口径是否稳定。

金额展示:可以是 2 位中间成本:不应该过早压成 2 位最终盈亏:再按金额规则舍入

一个投资核算系统如果把所有数字都当成“展示金额”,很快就会遇到解释不清的问题。

可选方案不只有 big.js

解决这个问题,不一定只有一种方案。

最容易开始的是继续用 number,然后在关键节点加 toFixed。这个方案适合很轻的展示场景,但不适合作为投资账本的底座。它没有真正解决十进制语义问题,也没有强制业务代码区分中间精度和最终展示精度。

支付系统里常见的做法是用整数最小单位,比如把 10.23 元 存成 1023 分。这个方案很稳定,适合订单、账单和支付。但投资系统不只有金额,还有股票价格、成交数量、佣金费率、每股成本、分红摊薄、加密资产数量和多币种换算。如果还要处理 36.5 / 3 这种每股成本,除法仍然会产生精度和舍入问题。

也可以用 BigInt + fixed scale 自己实现固定精度:

const SCALE = 1_000_000n;const price = 12_166_667n; // 表示 12.166667

这个方案没有第三方依赖,整数计算非常明确。但你需要自己处理不同字段的 scale、除法后的舍入、金额格式化、资产精度和 TypeScript 类型隔离。如果封装得不好,amountScale2costScale6 很容易被误加在一起。

currency.jsDinero.js 更偏 Money 建模。前者适合轻量金额计算,后者适合把金额和币种绑定起来,做多币种格式化和安全比较。它们能启发资产金额模型,但不能直接替代股票价格、成交数量、费率和 FIFO 成本队列里的十进制计算。

decimal.jsbignumber.jsbig.js 更接近这类问题的核心:提供十进制任意精度计算。decimal.js 功能更完整,适合复杂数学计算和精度配置;bignumber.js 支持十进制和非十进制的任意精度计算;big.js 更小,能力更克制,主要就是加减乘除、比较和舍入。

StockTracker 当前的需求其实很明确:

不需要复杂数学函数,也不需要非十进制计算。

所以我最后选了 big.js

它不是最强的方案,但足够贴合当前问题。

精度库应该藏在基础层

引入 big.js 之后,我不希望业务代码里到处出现:

new Big(...)

原因很简单:业务层应该表达业务含义,而不是暴露底层数值实现。

比如计算佣金,业务代码关心的是:

佣金 = max(成交金额 * 佣金率, 最低佣金)

而不是每个调用点都手动写一遍 Big 运算。

所以更好的方式是把精度库收进基础层:

export function calcCommission(  totalAmount: number,  rate: number,  minCommission: number): number {  const raw = mul(totalAmount, rate);  return roundMoney(gt(raw, minCommission) ? raw : minCommission);}

业务层调用的是 calcCommission,而不是 Big

这样做有几个好处:

金融系统里的数值精度,不应该靠每个调用点自觉。

它应该变成基础设施。

测试要覆盖真实会发生的数字

精度问题很容易在正常整数 case 里被掩盖。

如果测试只写:

买入 100 股,每股 10 元卖出 50 股,每股 12 元

很多错误都不会暴露。

更有价值的测试,反而是这些看起来不整齐的数字:

10000 * 0.000336.5 / 312.166667 * 2含最低佣金含分红摊薄清仓后重新建仓

因为真实投资记录里,大多数数字都不会刚好整齐。

手续费、税费、分红、港股结算费、基金份额、加密资产数量,都可能制造小数。

如果测试没有覆盖这些场景,系统就很容易在看起来“数学正确”的情况下,做出业务上难解释的结果。

最后,重要的不是 big.js 本身

我选择 big.js,不是因为它能神奇地解决所有金融问题。

它只是让十进制加减乘除更可靠。

真正重要的是另外几件事:

0.1 + 0.2 !== 0.3 只是入口。

它提醒我们:计算机里的数字,不一定等于业务世界里的数字。

在投资系统里,这个差异不能只靠最后一层 UI 修掉。它必须进入账本设计、成本队列、手续费规则和测试用例里。

否则 AI 分析写得再漂亮,图表画得再精致,底下那本账也可能从一开始就偏了。


Share this post on: