TT Lab
开始
学习 学习路径 课程

银行现场的语言

合计每次差一元,却没人能复现

在 TT Lab 中继续学习

一句话总结

不同货币的小数位数不同,如果把这些位数交给浮点数,合计就会悄悄地与账本对不上。存储用整数最小单位,截取到哪一位由策略来定,余数则由分配规则来钉死,这个问题的全部就在这里。

为什么需要它

手续费结算批处理从某一天开始,常常听到“合计差了 3 韩元”。负责人打开表,一行一行地核对,没有哪一行是错的。可是加起来就不一样。让他复现,把同样的输入再放一遍,这次又是对的。这种事持续了两个月左右,最后就形成了一套“重新跑一遍批处理就对了”的运维流程。

原因通常有两处。要么是把金额用 double 存着,要么是各处代码对截取到哪一位的约定各不相同。两者都是每一行只差 1,所以单元测试抓不到。测试数据用的是 12.34 这样的“好数字”,而在好数字上是不会出错的。

再混入多种货币,还会多出一个问题。“小数两位”是美元的情况,不是世界的规则。韩元和日元的小数位数是 0,科威特第纳尔和巴林第纳尔是 3。像黄金(XAU)这样,连小数位数这个概念本身都没有的代码,也在同一张表里。

工作原理

货币代码的维护机构是 SIX,列表原件是 List One XML。每一项的 CcyMnrUnts 就是该货币的小数位数。从发布日期为 2026-01-01 的版本中直接读到的值如下。

KRW 0   JPY 0   CLP 0        (최소단위가 통화 단위 그 자체)
USD 2                        (센트)
KWD 3   BHD 3   TND 3        (필스)
XAU N.A.                     (금. 소수 자리가 숫자가 아니다)

所以处理金额的代码的第一个决定是用整数最小单位来存储。1,234.56 美元存成 123456 和 USD 两个值,只在显示到画面上时才插入小数点。这样,加法和减法中就没有了会产生误差的地方。

会产生误差的地方,只有乘法和除法。乘以费率之后,会出现比最小单位更小的位,必须决定这一位怎么截取。Python 的 decimal 模块 用 quantize() 截取位数,并可以选择 ROUND_HALF_UP、ROUND_HALF_EVEN 这样的模式。默认值是银行家舍入(ROUND_HALF_EVEN),Python 内置的 round() 也是如此。所以,按“0.5 要进位”来写的代码,恰好在一半的情况下给出不同的答案。两者哪个对,技术并不决定。答案是把是哪一种写进文档,并让代码只使用那一种。

存储这一侧也有同样的陷阱。SQLite 的数据类型 附着在值上,而不是列上,往声明为 INTEGER 的格子里放入实数,它会以 REAL 的形式存进去。PostgreSQL 的 numeric 文档明确写道,numeric 计算精确但较慢,double precision 则相反,并且指引说,处理钱的值要用精确的那一种。

最后一块是分配。把 1,000 韩元分给 3 个人,是 333、333、333,还剩 1 韩元。把这 1 韩元丢掉,合计就对不上,给所有人都进一位,又会超过总额。最大余数法是先按向下取整给出商,再把剩下的部分,从余数大的一方开始每人多给 1。这样合计恰好对得上,谁多拿了 1 韩元,也能用规则来解释。

在现场相遇的样子

第一,会为折算顺序而争吵。逐行折算并按韩元单位截取之后再相加的值,和先全部相加之后只折算一次的值,是不同的。两者都不是错误的计算,而是不同的契约。如果账单上每一行都要印出韩元金额,就是前一种,如果只印总额,就是后一种。如果不确定是哪一种,两个系统各写各的,每个月都会出现几韩元的对账项。

第二,在各处自己生成显示格式。画面、账单、对方机构的文件各自加小数点,就会出现日元上被印出小数点的事故。把解析和显示收拢到一个函数里,让金额都从这道门经过,只改货币表这一处就可以了。

第三,舍入策略只存在于代码里。一旦负责人更换,就没有人知道依据了。把它写进策略文件,并把这个值一起写进报告,日后就能说明为什么会出现这个数字。

实际工作中真正重要的事

下一项实验要做什么

亲手做出 600 行的手续费账本和货币表,数一数同样的数据,用实数计算的值与用整数计算的值,有多少行出现分歧。然后用最小单位整数重新加载,与 REAL 副本比较;把解析和显示收拢到一个文件里;数一数两种舍入模式的差别;用最大余数法做出连余数也能对上的分配;记录折算顺序造成的差别;最后用一份报告把它们汇总起来。