一、为什么浮点精度是金融系统 P0 风险
2021 年某跨境支付公司因为浮点累计误差,结算日批量给商户打款时少了 0.01 美元/笔,2 万商户总共少打 200美元——这种金额不大,但财务对账时每天都会出现几十笔不平,被审计盯了三个月。真正的灾难是另一个案例:某交易所清结算引擎用 double 计算 USDT 价格,0.1% 误差在千万级订单上放大成 30 万美元的真实亏损。
这不是 bug,是 IEEE 754 双精度浮点数的本质。任何用 float 做金额运算的系统,最终都会撞上 0.01 误差。今天我讲清楚为什么、怎么办。
二、IEEE 754 双精度底层
Python 的 float 是 C 语言的 double,即 IEEE 754 binary64,由三部分组成:
- 1 位符号
- 11 位指数(指数偏移1023)
- 52 位尾数
总共 64 位。关键问题:十进制小数0.1 转二进制是无限循环小数(类似 1/3 = 0.3333…),所以只能近似存储:
1 2 3 4 5
| >>>0.1 .hex() '0x1.999999999999ap-4' >>> (0.1).as_integer_ratio() (3602879701896397, 36028797018963968)
|
所以 0.1 + 0.2 的真相是「两个近似值相加得到另一个近似值」,结果不是精确的 0.3:
1 2 3 4
| >>> 0.1 + 0.2 0.30000000000000004 >>> 0.1 + 0.2 == 0.3 False
|
三、Python float 的极限
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| import sys print(sys.float_info)
四个常见精度工具对比:
- **float**:原生类型,速度最快,~15 位精度,**不能用于金额** - **decimal.Decimal**:十进制浮点,可设精度,金融场景首选 - **fractions.Fraction**:精确有理数,速度慢,适合验证算法 - **numpy.float64**:底层还是 IEEE 754,只是批量运算方便
Python 默认 `round()` 使用银行家舍入,又称"四舍六入五成双"。它和传统"四舍五入"的差别在遇到 5 时:5 前面是奇数就进位(5→6),前面是偶数就舍去(5→4)。
```python
cases = [ (0.5, 0), (1.5, 0), (2.5, 0), (3.5, 0), (4.5, 0), ] for v, n in cases: print(f"round({v}, {n}) = {round(v, n)}")
|
为什么金融系统偏爱银行家舍入?因为大量数据汇总时,”5 全部进位”会导致系统性偏大,长期下来金额虚高。银行家舍入统计上无偏,5 的半数进半数舍,长期累加期望为零。
五、实战 1:货币运算
金融场景的标准做法是:所有金额用 Decimal 存储,运算前设置上下文精度,输出前 quantize 到 2 位小数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| from decimal import Decimal, getcontext, ROUND_HALF_EVEN
getcontext().prec = 28
price = Decimal('19.99') qty = Decimal('3') total = price * qty
total_fmt = total.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN) print(total_fmt)
a = Decimal('0.1') b = Decimal('0.2') print(a + b)
|
为什么 Decimal('0.1')正确而 Decimal(0.1) 不正确?因为后者先经过 float 转换,已经被 IEEE 754 污染过。字符串是 Decimal 的唯一安全入口。
六、实战 2:阶梯电价分摊
国内阶梯电价把月用电量切成三档,每档单价不同。我见过一个 bug:累计电费时每档单独 round 到分,最后一档舍入误差没补回,导致用户总电费少0.01 元/户,百万用户累计少 1 万元。正确做法:分档计算用 Decimal 但不 round,最后一次汇总时统一 round:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| from decimal import Decimal, ROUND_HALF_EVEN
def calc_tiered_electricity(kwh: Decimal) -> Decimal: """阶梯电价:0~200 档 0.53 元,200~400 档 0.58 元,400+ 档 0.83 元""" tiers = [ (200, Decimal('0.53')), (200, Decimal('0.58')), (None, Decimal('0.83')), ] total = Decimal('0') remaining = kwh for quota, price in tiers: if quota is None: used = remaining else: used = min(remaining, quota) total += used * price remaining -= used if remaining <= 0: break return total.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN)
print(calc_tiered_electricity(Decimal('350'))) print(calc_tiered_electricity(Decimal('500')))
|
关键技巧:分档内部用 Decimal 精确运算,只在最终返回前 quantize 一次。
七、实战 3:复利计算
金融工程里复利最容易出错,因为小数幂的浮点误差会随期数指数放大。10 万本金、月利率 0.5%、复利 12 个月:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| from decimal import Decimal, getcontext getcontext().prec = 28
principal = Decimal('100000') rate = Decimal('0.005') months = 12
amount = principal * (1 + rate) ** months print(amount.quantize(Decimal('0.01')))
amount_f = 100000 * (1 + 0.005) ** 12 print(round(amount_f, 2))
|
更复杂的”每月定投 + 月复利”:
1 2 3 4 5 6 7 8 9 10 11
| def monthly_invest(principal: Decimal, monthly: Decimal, rate: Decimal, months: int) -> Decimal: """每月初定投 monthly 元,月利率 rate,共 months 月""" amount = principal for _ in range(months): amount = (amount + monthly) * (1 + rate) return amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_EVEN)
print(monthly_invest(Decimal('0'), Decimal('10000'), Decimal('0.005'), 12))
|
八、踩坑记录
踩坑 1:JSON 序列化丢失精度
Python json.dumps(Decimal('0.1')) 会抛 TypeError,需要自定义 encoder:
1 2 3 4 5 6 7 8 9 10 11 12
| import json from decimal import Decimal
class DecimalEncoder(json.JSONEncoder): def default(self, o): if isinstance(o, Decimal): return str(o) return super().default(o)
json.dumps({'amount': Decimal('19.99')}, cls=DecimalEncoder)
|
踩坑 2:ORM 字段类型
SQLAlchemy / Django ORM 默认 Float 映射 Python float。金额字段必须显式声明:
1 2 3 4
| from sqlalchemy import Numeric amount = Column(Numeric(precision=18, scale=8)) amount = models.DecimalField(max_digits=18, decimal_places=8)
|
踩坑 3:链上 USDT 18 位精度
以太坊 USDT 智能合约用 uint256 表示最小单位(1 USDT = 10^6),不做 18 位小数,但跨链桥接和 DEX 聚合时经常出现 wei/ether 单位混乱。永远用字符串或 Decimal 接收链上金额,乘以 10^decimals 转整数后再做运算。
九、小结:decimal vs float 选型决策树
1 2 3 4 5
| 是否涉及金钱 / 累计求和? ├─ 是 → Decimal│ └─ 输出前 quantize + ROUND_HALF_EVEN └─ 否 → 是否需要精确有理数? ├─ 是 → Fraction └─ 否 → float OK(科学计算、ML、3D 渲染)
|
关键准则:
- 金额永远用
Decimal(str(value)),绝不用 float初始化
- 运算前
getcontext().prec = 28,输出前 quantize(Decimal('0.01'), ROUND_HALF_EVEN)
- 中间计算不要 round,只在边界 round
- ORM 字段用 Numeric(18, 8) 而非 Float
- JSON 序列化自定义 encoder,金额永远是字符串
- 跨语言、跨链金额单位转换:先转最小单位整数(wei、satoshi、分),再运算
金融系统不怕复杂逻辑,怕的就是 0.01。把 Decimal 用对,把银行家舍入搞懂,你的支付系统至少能少90% 的对账不平。