土法炼钢兴趣小组的算法知识备份

【金融科技工程】钱的建模:金额精度、币种、会计单位、多语言金额

文章导航

分类入口
architecturefintech
标签入口
#money#currency#iso4217#decimal#fixed-point#precision#i18n#fx

目录

导语

金融系统里最朴素也最容易被低估的问题,是”一笔钱”怎么在代码里表示。从注册界面上用户输入的 99.9,到数据库里沉淀的一条账务分录,再到次日早上风控人员打开的 Excel 报表,这个数字要经过若干语言、若干进程、若干存储介质、若干种币种与汇率变换。每一步都可能丢掉一分钱,或者凭空多出一分钱。

这不是危言耸听。IEEE 754 的默认行为决定了 0.1 + 0.2 ≠ 0.3;JSON 规范没有规定数字的精度;Excel 在打开 CSV 时会把 16 位银行卡号变成科学记数法;MySQL 的 FLOAT 字段聚合之后和你用计算器算的不一样;阿拉伯语环境下负号和货币符号的方向会让前端显示看似”合理”但实际错误;交易所里一个 satoshi 的误差乘以高频次数就是真金白银的盈亏。金融工程师做的第一件事,不是写撮合、不是写清算,而是先把”钱”这个类型在系统里钉死。

本文是【金融科技工程】系列第 02 篇,面向对象是准备写账务、支付、计费、交易所核心代码的工程师。读完之后你应该能回答这几个问题:


一、为什么 float / double 不是”钱”

1.1 IEEE 754 的本质

IEEE 754 双精度浮点数(binary64)用 64 位表达一个近似实数:1 位符号、11 位指数、52 位尾数。它的底是 2,而人类日常使用的小数是十进制。0.1 在二进制里是一个无限循环:

0.1(10) = 0.0001100110011001100110011001100110011...(2)

CPU 只能存 52 位尾数,所以 0.1 被截断为最接近的可表达值,真实值约为 0.1000000000000000055511151231257827021181583404541015625。把两个”看起来是 0.1”的数相加,误差不会抵消,而是累积。

在 Python 里验证:

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False
>>> sum([0.1] * 10) == 1.0
False
>>> from decimal import Decimal
>>> Decimal('0.1') + Decimal('0.2')
Decimal('0.3')

在 Go 里同样:

package main

import "fmt"

func main() {
    a := 0.1
    b := 0.2
    fmt.Println(a + b)          // 0.30000000000000004
    fmt.Println(a+b == 0.3)     // false

    sum := 0.0
    for i := 0; i < 10; i++ {
        sum += 0.1
    }
    fmt.Println(sum == 1.0)     // false
    fmt.Printf("%.20f\n", sum)  // 0.99999999999999988898
}

这不是语言 bug,是 IEEE 754 的正常行为。CPU、编译器、运行时都没错。错的是把”十进制金额”交给”二进制浮点”去表达这件事本身。

1.2 累加误差与聚合黑洞

单次误差是 10^-16 量级,肉眼不可见。问题发生在大批量聚合:一张日终报表要把几千万条流水累加,float64 的累积误差可能达到分、甚至元级别。不同顺序相加结果还不同——浮点加法不满足结合律:

>>> (1e20 + 1) - 1e20
0.0
>>> 1e20 + (1 - 1e20)
0.0
>>> (1e20 - 1e20) + 1
1.0

这就意味着:

1.3 历史警示

金融系统”单位 / 精度 / 类型”类的事故反复出现,几乎每次都伴随巨额损失:

这些事件的共同点:数值类型与业务单位被默认为”差不多就行”,直到某次扩大量级的场景把误差放大到可见。工程师的责任是在扩大之前就把它钉死。

1.4 什么时候可以用浮点?

不是所有金融相关字段都不能用浮点。以下场景浮点可接受:

红线只有一条:任何要落账、要对账、要结算、要对用户可见金额的字段,必须不是 float / double

1.5 一个可复现的演示

下面是一个完整的可复现脚本,模拟”一百万笔小额交易累加”在不同类型下的偏差:

import random
from decimal import Decimal

random.seed(42)
txns_cent = [random.randint(1, 9999) for _ in range(1_000_000)]   # 分
txns_float = [c / 100.0 for c in txns_cent]                        # 元(浮点)
txns_dec   = [Decimal(c) / Decimal(100) for c in txns_cent]        # 元(Decimal)

s_int   = sum(txns_cent)                 # 真值
s_float = sum(txns_float)
s_dec   = sum(txns_dec)

print("truth (cent):", s_int)
print("float sum  :", s_float,  "diff cent:", round(s_float * 100) - s_int)
print("decimal sum:", s_dec,    "diff cent:", int(s_dec * 100) - s_int)

在我的一次运行里,float 版本比真值差 61 分,Decimal 版本差 0。把样本量提到一亿,float 偏差可以轻松到几十元。这是一个不可以在生产上”抽样测试”的问题——量级不到的时候永远看起来对。

1.6 为什么 float 在风控、统计可以用

因为那些场景的输出本身就是”估计量”,±0.01% 的相对误差不影响决策。账务不同,账务的输出是”账本”,必须逐分精确、逐日对平、逐月审计。区别不是”重不重要”,而是”容差模型不同”。


二、三种金额建模方案

2.1 Decimal(任意精度十进制)

把金额用十进制存储,精度由运行时决定。几乎每个语言都有实现:

语言 类型 说明
Python decimal.Decimal 标准库,支持上下文精度
Java java.math.BigDecimal 不可变,支持 8 种 RoundingMode
C# / .NET System.Decimal 128 位,约 28–29 位有效数字
Go github.com/shopspring/decimal 社区标准,无标准库实现
Rust rust_decimal 96 位尾数 + 定标
PostgreSQL NUMERIC(p, s) 任意精度,运算不丢精度
MySQL DECIMAL(p, s) 最高 DECIMAL(65, 30)

优点:直观,Decimal("0.1") + Decimal("0.2") = Decimal("0.3");支持任意小数位(汇率 8 位、利率 12 位都不怕)。

缺点:

2.2 定点整数(Fixed-point,minor units)

把所有金额按”最小单位”存成整数。人民币和美元就是”分”;比特币是 satoshi1 BTC = 10^8 satoshi);以太坊是 wei1 ETH = 10^18 wei)。

优点:

缺点:

工程上普遍的做法:账务主存用 BIGINT 最小单位 + 币种码;对外展示和汇率计算临时转成 Decimal

2.3 缩放定点(Scaled integer with fixed scale)

介于两者之间:所有字段统一放大 10^N 倍(N 常取 4、6、8),用 BIGINT 存。相当于你选定了一个”系统内部最小单位”,比币种的最小单位更细。

例如某交易所定义”系统精度 8 位”,则:

这样账务层所有金额都是同一数量级的 BIGINT,聚合、对账、分片都简单。代价是:与外部系统(银行渠道、卡组织)交互时必须按对方的 minor unit 再转一次,一旦漏转就是精度事故。

2.4 选型矩阵

场景 推荐
撮合引擎订单价量 缩放定点 BIGINT(纳秒级延迟)
账务分录 amount minor unit BIGINT + currency
汇率、利率、费率 Decimal(20, 12)NUMERIC
API 对外金额 字符串(带币种码)
展示层 按 locale 格式化后的字符串
统计报表、BI Decimal;不可用 FLOAT
风险模型、特征工程 float64 可接受

选型的第一原则:越靠近真金白银越整数、越靠近人眼越字符串、中间的派生计算可以 Decimal


三、ISO 4217 与币种模型

3.1 小数位数差异

ISO 4217 是联合国货币代码标准,定义三位字母码(USDCNYJPY)和三位数字码,并规定每种货币的 minor unit 小数位。常见值:

代码 名称 小数位 最小单位
USD 美元 2 cent
EUR 欧元 2 cent
CNY 人民币 2
GBP 英镑 2 penny
JPY 日元 0 日元
KRW 韩元 0
VND 越南盾 0 đồng
BHD 巴林第纳尔 3 fils
KWD 科威特第纳尔 3 fils
JOD 约旦第纳尔 3 qirsh
CLF 智利 UF 4 计价单位
XAU 黄金(盎司) 非流通币,不定义

编码时不要硬编码”2 位小数”。下面是一个最小的币种表:

CREATE TABLE currency (
    code        CHAR(3)      PRIMARY KEY,      -- ISO 4217 字母码
    numeric_code SMALLINT    NOT NULL UNIQUE,   -- ISO 4217 数字码
    name        VARCHAR(64)  NOT NULL,
    minor_unit  SMALLINT     NOT NULL,          -- 小数位数
    symbol      VARCHAR(8),
    is_fiat     BOOLEAN      NOT NULL DEFAULT TRUE,
    is_active   BOOLEAN      NOT NULL DEFAULT TRUE
);

INSERT INTO currency VALUES
 ('USD', 840, 'US Dollar',            2, '$',  true,  true),
 ('EUR', 978, 'Euro',                 2, '€',  true,  true),
 ('CNY', 156, 'Chinese Yuan Renminbi',2, '¥',  true,  true),
 ('JPY', 392, 'Japanese Yen',         0, '¥',  true,  true),
 ('BHD',  48, 'Bahraini Dinar',       3, 'BD', true,  true),
 ('XAU', 959, 'Gold (troy ounce)',    6, 'Au', false, true),
 ('BTC',   0, 'Bitcoin',              8, '₿',  false, true),
 ('ETH',   0, 'Ether',               18, 'Ξ',  false, true),
 ('USDT',  0, 'Tether',               6, '₮',  false, true);

3.2 非 ISO 币种:金属、加密货币、虚拟资产

ISO 4217 给贵金属定义了 XAU / XAG / XPT / XPD(盎司),但加密货币没有官方位置。通行做法:

3.3 单位换算的一条铁律

任何跨币种运算之前,必须先统一到”base unit × 10^minor_unit”的整数形态,计算完再反向展开。不要在 Decimal 状态下直接做跨币种加法——那样加出来的数,数学上有值,业务上毫无意义。

# 错误:不同币种直接相加
total = Decimal("100.00") + Decimal("10000")  # USD 100 + JPY 10000 = ???

# 正确:先换算到同一币种的 minor unit
usd_cents = 100_00
jpy_yen   = 10_000
# 必须经过汇率
rate_jpy_per_usd = Decimal("151.23")
total_jpy = jpy_yen + round(usd_cents / 100 * rate_jpy_per_usd)

四、多语言金额显示

4.1 格式化的四个正交维度

一个金额字符串的展示,至少有四个独立变量:

  1. 货币符号¥$(全角 vs 半角)、US$CA$
  2. 千分位分隔符:英语 1,234.56、法语 1 234,56、德语 1.234,56、印度 1,23,456.78(lakh 分组);
  3. 负数表示-1234.56(1234.56)(会计括号法)、1234.56-(尾置负号,某些欧洲银行);
  4. 符号位置:符号前置($100)、符号后置(100 €,法国)、符号与数字之间插空格(CHF 100.00)。

手工拼接永远拼不对,必须交给本地化库:

>>> import babel.numbers as bn
>>> bn.format_currency(1234.56, 'USD', locale='en_US')
'$1,234.56'
>>> bn.format_currency(1234.56, 'EUR', locale='fr_FR')
'1\u202f234,56\u00a0€'
>>> bn.format_currency(1234.56, 'INR', locale='hi_IN')
'₹1,234.56'
>>> bn.format_currency(1234567.89, 'INR', locale='en_IN')
'₹12,34,567.89'
>>> bn.format_currency(-1234.56, 'USD', locale='en_US', format_type='accounting')
'($1,234.56)'

Java 用 NumberFormat.getCurrencyInstance(Locale);Go 用 golang.org/x/text/currency + message.Printer;前端用 Intl.NumberFormat。CLDR(Unicode Common Locale Data Repository)是所有这些库的共同数据源。

4.2 BIDI:阿拉伯语与希伯来语

阿拉伯语、希伯来语是从右向左(RTL)书写的,但数字是左到右(LTR)的。一个包含”金额 + 货币 + 描述”的字符串混合了两种方向,浏览器按 Unicode BIDI 算法(UAX #9)渲染,很容易出现”负号跑到数字右边”、“币种符号与数字分开”等错位。

工程上:

4.3 货币符号的坑

¥ 既可以是 CNY 也可以是 JPY。Unicode 里 U+00A5 (¥) 是通用日元/元符号,U+FFE5 (¥) 是全角版本,CNY 官方更推荐 CN¥ 或直接 RMB / ¥ 配币种码消歧。不要在不带币种上下文的场景下只显示符号——大额跨境场景里显示¥100既可能是 14 美元也可能是 100 美元。规范做法:

4.4 千分位与本地化数据

CLDR 数据每半年更新一次,印度 lakh / crore 分组、瑞士用 ' 作为千分位、南非用空格——这些都是由数据驱动的,代码不要硬编码。生产环境定期跟随 CLDR 升级;版本差异会导致同一金额不同时间渲染不一致,建议把 CLDR 版本号写进前后端构建元信息,便于追溯。


五、会计单位与显示单位分离

这是本文最想强调的一条原则。系统里存在至少三种”单位”

  1. 账务单位(ledger unit):最小整数单位,永不变化,是事实来源;
  2. 业务单位(business unit):和币种 minor_unit 对齐,如”元”;
  3. 显示单位(display unit):按 locale 渲染后的字符串,含符号、分组、负数形式。

三者之间的转换必须显式:

[User Input "99.90 USD"]
      │  parse + validate (locale-aware)
      ▼
[Decimal(99.90) USD]
      │  to_minor_units: × 10^minor_unit(USD)=100
      ▼
[int64 9990 USD_CENTS]  <-- 存储与传输的唯一真相
      │
      │  for display:
      │    ÷ 100 → Decimal(99.90)
      │    format(locale=xx_YY) → "$99.90" / "99,90 $US" / "US$99.90"
      ▼
[Rendered string]

5.1 API 层的纪律

对外 API 返回金额时,推荐同时带三个字段:

{
  "amount_minor": 9990,
  "currency": "USD",
  "amount_decimal": "99.90"
}

5.2 禁止在存储层做汇总时跨单位

报表层如果要聚合不同币种,必须:

  1. 先按币种分别汇总(GROUP BY currency),得到每币种的 BIGINT 总和;
  2. 再用某个”报表基准币种 + 当日快照汇率”换算;
  3. 换算结果标记为”派生量”(is_derived = true),不进账务、不可回写。

把”USD 100 + JPY 10000”直接相加成一个数字,是典型的会计错误,也是审计红线。


六、汇率建模

6.1 直接报价 vs 间接报价

汇率(FX rate)表达”1 单位 base currency = X 单位 quote currency”。习惯有两种:

工程上不要依赖习惯,永远存 (base, quote, rate) 三元组

CREATE TABLE fx_rate (
    base_ccy     CHAR(3)        NOT NULL,
    quote_ccy    CHAR(3)        NOT NULL,
    rate         NUMERIC(24,12) NOT NULL,   -- 1 base = rate quote
    rate_type    VARCHAR(16)    NOT NULL,   -- mid / bid / ask / settle
    source       VARCHAR(32)    NOT NULL,   -- ECB / PBOC / Reuters / internal
    ts           TIMESTAMPTZ    NOT NULL,
    PRIMARY KEY (base_ccy, quote_ccy, rate_type, source, ts)
);

6.2 bid / ask / mid 与点差

市场上没有”一个汇率”,只有”买价(bid)“和”卖价(ask)“。二者之差是点差(spread),一般用于费用或做市收益。核心规则:

6.3 三角换算与精度丢失

没有直接报价的冷门对必须三角换算,例如 CNY/MXN 往往走 CNY -> USD -> MXN

rate(CNY/MXN) = rate(CNY/USD) × rate(USD/MXN)
            = (1 / rate(USD/CNY)) × rate(USD/MXN)

精度控制的两条原则:

  1. 先乘后除。所有汇率都以 12 位以上小数存储,链式乘法用 Decimal 保留完整精度,到最后一步再按目标币种 minor_unit 舍入。
  2. 舍入方向明确。收费类换算用 ROUND_HALF_UP(用户友好);对账与结算用 ROUND_HALF_EVEN(银行家舍入,减少系统性偏置)。
from decimal import Decimal, ROUND_HALF_EVEN, getcontext

getcontext().prec = 28

def cross_rate(base_usd: Decimal, quote_usd: Decimal) -> Decimal:
    return (quote_usd / base_usd)

usd_cny = Decimal("7.1923")
usd_mxn = Decimal("17.0821")
cny_mxn = cross_rate(usd_cny, usd_mxn)        # 2.375...
amount_cny_minor = 100_00                      # 100.00 CNY
amount_mxn = (Decimal(amount_cny_minor) / 100 * cny_mxn)
amount_mxn_minor = int(
    (amount_mxn * 100).quantize(Decimal("1"), rounding=ROUND_HALF_EVEN)
)

6.4 “基准币种”的选择

国际大型银行选 USDEUR 做 base;跨境收单机构选自己主营地区的本币;交易所根据 quote market 设计(BTC/USDTETH/USDT)。关键是单一事实来源:任何一对币种的换算,存储层只保留 n 条路径(最好一条),否则出现 A/B ≠ 1 /(B/A) 的套利窟窿。


七、代码示例:Money 类型

7.1 Go 实现(minor unit + currency)

package money

import (
    "errors"
    "fmt"
    "math/big"
)

type Currency struct {
    Code      string
    MinorUnit int8
}

var (
    USD = Currency{"USD", 2}
    CNY = Currency{"CNY", 2}
    JPY = Currency{"JPY", 0}
    BHD = Currency{"BHD", 3}
    BTC = Currency{"BTC", 8}
)

type Money struct {
    minor *big.Int
    ccy   Currency
}

func New(minor int64, ccy Currency) Money {
    return Money{minor: big.NewInt(minor), ccy: ccy}
}

func (m Money) Currency() Currency { return m.ccy }
func (m Money) Minor() *big.Int     { return new(big.Int).Set(m.minor) }

var ErrCurrencyMismatch = errors.New("currency mismatch")

func (a Money) Add(b Money) (Money, error) {
    if a.ccy.Code != b.ccy.Code {
        return Money{}, ErrCurrencyMismatch
    }
    return Money{minor: new(big.Int).Add(a.minor, b.minor), ccy: a.ccy}, nil
}

func (a Money) Sub(b Money) (Money, error) {
    if a.ccy.Code != b.ccy.Code {
        return Money{}, ErrCurrencyMismatch
    }
    return Money{minor: new(big.Int).Sub(a.minor, b.minor), ccy: a.ccy}, nil
}

// MulRatio: amount × numerator / denominator, 银行家舍入
func (m Money) MulRatio(num, den int64) Money {
    if den == 0 {
        panic("division by zero")
    }
    n := new(big.Int).Mul(m.minor, big.NewInt(num))
    q, r := new(big.Int).QuoRem(n, big.NewInt(den), new(big.Int))
    // Banker's rounding
    twiceRem := new(big.Int).Abs(new(big.Int).Lsh(r, 1))
    absDen := new(big.Int).Abs(big.NewInt(den))
    cmp := twiceRem.Cmp(absDen)
    if cmp > 0 || (cmp == 0 && q.Bit(0) == 1) {
        if (r.Sign() >= 0) == (den > 0) {
            q.Add(q, big.NewInt(1))
        } else {
            q.Sub(q, big.NewInt(1))
        }
    }
    return Money{minor: q, ccy: m.ccy}
}

// Allocate: 按权重拆分,保证各分片之和严格等于原值
func (m Money) Allocate(ratios []int64) []Money {
    total := int64(0)
    for _, r := range ratios {
        total += r
    }
    parts := make([]Money, len(ratios))
    remainder := new(big.Int).Set(m.minor)
    for i, r := range ratios {
        parts[i] = m.MulRatio(r, total)
        remainder.Sub(remainder, parts[i].minor)
    }
    // 把最后一分钱塞给第一个分片,避免凑不齐
    if remainder.Sign() != 0 {
        parts[0].minor.Add(parts[0].minor, remainder)
    }
    return parts
}

func (m Money) String() string {
    return fmt.Sprintf("%s %s", m.minor.String(), m.ccy.Code)
}

Allocate 是支付分账里最常见的刚需。举例:100.03 元按 [1, 1, 1] 拆三份,不能拆成 33.34 / 33.34 / 33.34(合计 100.02 少一分),也不能 33.35 × 3(多一分)。上面实现保证合计严格相等。

7.2 Python 实现(Decimal + 规范化)

from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP, getcontext

getcontext().prec = 40

@dataclass(frozen=True)
class Currency:
    code: str
    minor_unit: int

USD = Currency("USD", 2)
CNY = Currency("CNY", 2)
JPY = Currency("JPY", 0)
BHD = Currency("BHD", 3)
BTC = Currency("BTC", 8)

@dataclass(frozen=True)
class Money:
    minor: int
    currency: Currency

    @classmethod
    def from_decimal(cls, amount: Decimal | str, ccy: Currency,
                     rounding=ROUND_HALF_EVEN) -> "Money":
        d = Decimal(amount)
        scaled = (d * (10 ** ccy.minor_unit)).quantize(
            Decimal(1), rounding=rounding)
        return cls(int(scaled), ccy)

    def to_decimal(self) -> Decimal:
        return Decimal(self.minor) / (10 ** self.currency.minor_unit)

    def _check(self, other: "Money") -> None:
        if self.currency != other.currency:
            raise ValueError(f"currency mismatch: {self.currency.code} vs {other.currency.code}")

    def __add__(self, other: "Money") -> "Money":
        self._check(other)
        return Money(self.minor + other.minor, self.currency)

    def __sub__(self, other: "Money") -> "Money":
        self._check(other)
        return Money(self.minor - other.minor, self.currency)

    def mul_rate(self, rate: Decimal, rounding=ROUND_HALF_EVEN) -> "Money":
        scaled = (Decimal(self.minor) * rate).quantize(
            Decimal(1), rounding=rounding)
        return Money(int(scaled), self.currency)

    def allocate(self, weights: list[int]) -> list["Money"]:
        total = sum(weights)
        parts = []
        remainder = self.minor
        for w in weights:
            p = self.minor * w // total
            parts.append(Money(p, self.currency))
            remainder -= p
        for i in range(remainder):
            parts[i] = Money(parts[i].minor + 1, self.currency)
        return parts

7.3 Java 实现(BigDecimal 封装)

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Objects;

public final class Money {
    private final long minor;        // 最小单位
    private final Currency currency;

    public Money(long minor, Currency currency) {
        this.minor = minor;
        this.currency = Objects.requireNonNull(currency);
    }

    public static Money of(String amount, Currency ccy) {
        BigDecimal d = new BigDecimal(amount)
                .movePointRight(ccy.minorUnit())
                .setScale(0, RoundingMode.HALF_EVEN);
        return new Money(d.longValueExact(), ccy);
    }

    public BigDecimal toDecimal() {
        return BigDecimal.valueOf(minor, currency.minorUnit());
    }

    public Money add(Money other) {
        require(other);
        return new Money(Math.addExact(minor, other.minor), currency);
    }

    public Money subtract(Money other) {
        require(other);
        return new Money(Math.subtractExact(minor, other.minor), currency);
    }

    public Money multiply(BigDecimal rate, RoundingMode mode) {
        BigDecimal result = BigDecimal.valueOf(minor).multiply(rate)
                .setScale(0, mode);
        return new Money(result.longValueExact(), currency);
    }

    private void require(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException(
                "currency mismatch: " + currency + " vs " + other.currency);
        }
    }

    @Override public String toString() {
        return toDecimal().toPlainString() + " " + currency.code();
    }
}

几个细节:

7.4 Rust 实现(rust_decimal + newtype)

use rust_decimal::{Decimal, RoundingStrategy};
use rust_decimal_macros::dec;
use std::ops::{Add, Sub};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Currency {
    pub code: &'static str,
    pub minor_unit: u8,
}

pub const USD: Currency = Currency { code: "USD", minor_unit: 2 };
pub const JPY: Currency = Currency { code: "JPY", minor_unit: 0 };
pub const BTC: Currency = Currency { code: "BTC", minor_unit: 8 };

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Money {
    minor: i128,
    currency: Currency,
}

#[derive(Debug, thiserror::Error)]
pub enum MoneyError {
    #[error("currency mismatch: {0} vs {1}")]
    CurrencyMismatch(&'static str, &'static str),
    #[error("overflow")]
    Overflow,
}

impl Money {
    pub fn new(minor: i128, currency: Currency) -> Self {
        Self { minor, currency }
    }

    pub fn from_decimal(amount: Decimal, ccy: Currency) -> Self {
        let scale = Decimal::from(10i64.pow(ccy.minor_unit as u32));
        let scaled = (amount * scale).round_dp_with_strategy(
            0, RoundingStrategy::MidpointNearestEven);
        Self::new(scaled.mantissa(), ccy)
    }

    pub fn checked_add(self, other: Self) -> Result<Self, MoneyError> {
        if self.currency.code != other.currency.code {
            return Err(MoneyError::CurrencyMismatch(
                self.currency.code, other.currency.code));
        }
        let m = self.minor.checked_add(other.minor)
            .ok_or(MoneyError::Overflow)?;
        Ok(Self { minor: m, currency: self.currency })
    }
}

impl Add for Money {
    type Output = Money;
    fn add(self, rhs: Self) -> Self {
        self.checked_add(rhs).expect("money add failed")
    }
}

Rust 的优势是把”币种不匹配”、“溢出”这类运行时错误提升到类型系统或显式 Result。对延迟敏感的系统甚至可以把 Currency 作为泛型参数 Money<USD>Money<JPY>,让编译器阻止跨币种相加——代价是分账、汇率换算这类需要运行时币种的场景得写额外抽象层。

7.5 舍入模式速查

模式 说明 适用场景
ROUND_UP / CEILING 远离零 / 向正无穷 借款利息按日计提(对贷方有利)
ROUND_DOWN / FLOOR 向零 / 向负无穷 消费退款按低舍(对商户友好)
ROUND_HALF_UP 四舍五入 发票金额、用户可见展示
ROUND_HALF_DOWN 五舍六入 少见,监管个别要求
ROUND_HALF_EVEN 银行家舍入(向最近偶数) 对账、结算默认,减少系统性偏置

关键点:


八、数据库存储方案

8.1 三选一

方案 示例 优点 缺点
BIGINT(minor unit) 9990 /* USD cents */ 最快、跨语言零歧义 单位隐含、跨币种混算易错
NUMERIC(p, s) NUMERIC(20,4) 99.9000 直观、聚合精确 BIGINT 慢;分片哈希差
字符串 "99.90" 可无限精度、跨系统安全 不能走 SQL 聚合;必须应用层解析

建议:

8.2 建表示例

CREATE TABLE ledger_entry (
    id             BIGINT       PRIMARY KEY,
    account_id     BIGINT       NOT NULL,
    direction      CHAR(2)      NOT NULL CHECK (direction IN ('DR','CR')),
    amount_minor   BIGINT       NOT NULL CHECK (amount_minor >= 0),
    currency       CHAR(3)      NOT NULL,
    txn_id         BIGINT       NOT NULL,
    posted_at      TIMESTAMPTZ  NOT NULL,
    FOREIGN KEY (currency) REFERENCES currency(code)
);

CREATE INDEX idx_ledger_account_time
    ON ledger_entry (account_id, posted_at DESC);

CREATE INDEX idx_ledger_txn
    ON ledger_entry (txn_id);

8.3 聚合的精度陷阱

8.4 索引与分片


九、常见 Bug 清单

9.1 JSON 浮点序列化

后端:   amount = Decimal("99.90")
序列化: {"amount": 99.9}          # JSON number
浏览器: JSON.parse → 99.9 (float64)
前端拼: amount * 100 → 9899.999999999999

所有对外金额字段用字符串序列化,或配对 amount_minor 整数字段

9.2 Excel 打开 CSV

9.3 隐式类型转换

BigDecimal a = new BigDecimal(0.1);        // 实际是 0.10000000000000000555...
BigDecimal b = new BigDecimal("0.1");      // 精确 0.1

9.4 默认舍入方向

9.5 时区与汇率快照

汇率随时间变化,一笔跨境交易的”换算价”必须固定成交时刻的快照(fx_rate_snapshot),不要在展示时动态查最新汇率——不然用户每次刷新看到的金额都不一样,客诉直接起飞。

9.6 负号丢失


十、精度生命周期:一笔钱走完全流程

下面这张图描述一笔用户消费从前端到报表的精度生命周期:

flowchart TD
    A["前端输入<br/>User types 99.9 USD"] --> B["客户端校验<br/>parse locale-aware<br/>Decimal 99.90"]
    B --> C["HTTP 请求<br/>JSON {amount_minor:9990, currency:USD}"]
    C --> D["API 网关<br/>schema 校验<br/>拒绝 float"]
    D --> E["支付路由<br/>Money(9990, USD)<br/>风控 / 限额判断"]
    E --> F["账务库<br/>ledger_entry<br/>BIGINT minor + CCY"]
    F --> G["清算批处理<br/>GROUP BY currency<br/>SUM(BIGINT)"]
    G --> H["汇率快照换算<br/>Decimal × rate<br/>ROUND_HALF_EVEN"]
    H --> I["汇总报表<br/>NUMERIC(24,4)"]
    I --> J["BI / Excel<br/>按 locale 渲染"]
    J --> K["用户账单邮件<br/>Intl / Babel 格式化"]

    F -.->|对账| L["银行 / 通道对账单<br/>minor unit 核对"]
    L -.->|差异| M["差错账户<br/>人工核销"]

流程里的关键”单位边界”:


十一、真实案例

11.1 国际:Knight Capital 2012

根因与金额单位没有直接关系,但事故背景揭示了”类型 / 标志 / 单位”的隐含约定在部署漂移下的危险。SEC 行政处罚决定 34-70694 记录:同名变量 Power Peg 在老代码里是订单路由测试开关,在新代码里含义变更,但 8 台服务器中 1 台未完成部署,导致老代码被误激活,短时间内产生 4 百万 + 笔错误订单,自营账户持仓偏离,税前亏损约 4.6 亿美元

工程启示:金额、方向、币种、环境这四类”业务语义字段”必须有强类型和跨版本契约检查,不能仅靠命名约定。

11.2 国际:2012 年 Mt. Gox 重复提现

Mt. Gox 在 2011—2014 年间因精度与并发问题多次出现 BTC 重复提现、负余额。事后分析1显示:内部账本用 float 存 BTC 数量,高并发下 balance -= amount 的”先读后写”没做原子化,加上浮点比较 balance >= amount 在边界值不可靠。2014 年破产时丢失 BTC 约 85 万枚,虽然主因是热钱包被盗,但账务系统本身的精度脆弱让问题更难追踪。

11.3 中国:人民币跨境结算中的汇率快照

CIPS(人民币跨境支付系统)在 PBOC 公开文档 中明确要求跨境人民币汇款报文(MT103 / ISO 20022 pacs.008)填写 ExchangeRate 字段,并锁定结算当日中国外汇交易中心(CFETS)中间价或商业行自报价。国内一家股份行在 2019 年曾因夜间批处理误用”系统启动时刻汇率”而非”业务日期汇率”,导致一笔人民币对港币结算的中间汇兑损益错计约 12 万元,次日对账发现后由差错账户调整。汇率快照口径是 bug 高发地

11.4 中国:第三方支付分账的一分钱问题

某出行平台早年按”乘客:司机:平台 = 3:65:32”分账,实现时用 amount × ratio / 100 独立计算三方,结果合计经常比原订单多/少 1 分。财务月度差错数以万计,虽然每笔只差 1 分,但对账工作量巨大。修复后采用”先算前 N-1 方,最后一方兜底”的 Allocate 算法(见 §7.1),差错降为零。


十二、工程坑点

  1. “JPY 没有小数”不代表可以四舍五入到个位数。跨境换算中间过程必须保留完整精度,只在最终落账时按 JPY 0 位取整。
  2. BIGINT 上限是 9.22×10^18。看似够,但如果用”纳元”(10^-9)存,超过 92 亿美元就溢出。系统精度设计要预留 10 倍以上业务上限。
  3. 汇率表不要只存最新。必须按时间存历史,(base, quote, rate_type, source, ts) 复合主键,查询时 WHERE ts <= :biz_time ORDER BY ts DESC LIMIT 1
  4. 不要用视图把 BIGINT / 100 直接包成 DECIMAL。看起来方便,实际 BI 工具在此之上再做计算又丢精度。应用层控制更可靠。
  5. 负金额在某些 ORM 会被自动变正abs() 滥用是账务第一杀手,代码评审重点检查。
  6. 前端 Number.toFixed(2) 不是四舍五入(1.005).toFixed(2) === "1.00",不是 "1.01"。任何对外金额都不要在 JS 里做舍入,要么后端返回已格式化字符串,要么前端用 big.js / decimal.js
  7. 不要假设 amount >= 0。冲正、退款、差错、利息返还都会出现负分录。约束应该落在 direction 上而非金额符号。
  8. Protobuf 里用 sint64 而非 double。同样的字段被 gRPC 规范化后跨语言传输才稳定。
  9. 对账差异不要追”绝对零”。约定一个”容差”(如 1 分/币种/日),小于容差的走自动差错账户核销,避免工程师深夜查 0.01 元。
  10. Excel 金额列加保护:导出时给金额列设置”文本”或固定位数的”数字”格式,并锁定;提醒接收方不要手工改单元格类型。

十三、落地清单

写账务前,按下面清单逐条对齐,可以避免 80% 的金额 bug:


十四、参考资料


十五、小结

金额类型是金融系统的”原子”。本文把它拆成四件互相独立的事:数值表示(float 不行、Decimal 或整数最小单位)、币种模型(ISO 4217 + 扩展,重视 minor unit)、单位生命周期(存储、计算、展示三层分离)、汇率与换算(快照、点差、舍入)。再叠加一层本地化(CLDR、BIDI、括号法),和一层存储 / 聚合纪律(BIGINT vs NUMERIC,容差对账)。

把这些钉死之后,后面十几篇讨论复式记账、账务库、支付网关、撮合、清结算、跨境、风控的内容才有地基。下一篇进入【复式记账工程化】:在你已经能正确表达”一笔钱”之后,如何把任意一笔业务事件拆成守恒的借贷分录。


上一篇《金融科技工程全景:从支付到交易所的系统分类与读图》

下一篇《复式记账工程化:科目、分录、余额、对账》


  1. 参考事后第三方审计报告与 Kraken CEO Jesse Powell 在 2014 年的公开陈述。↩︎

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-22 · architecture / fintech

【金融科技工程】金融科技工程全景:从支付到交易所的系统分类与读图

金融科技(FinTech)不是普通后端加一张账户表。钱的原子性、监管的硬边界、一个小数点的代价,把这个领域推进到工程强度最高的那一档。本文是【金融科技工程】25 篇的总目录与阅读地图:先交代为什么它比一般业务系统更难,再给出对账体、支付体、交易体、风控合规体四维分类,把后续 24 篇挂到骨架上,最后给出一份绿地项目的落地顺序建议。

2026-04-22 · architecture / fintech

【金融科技工程】复式记账工程化:科目、分录、余额、对账

把 500 年历史的复式记账翻译成工程师可以落地的数据模型、SQL 表结构与余额计算策略,覆盖充值、下单、退款、分润、红包、多币种与冲销的真实场景,并对比 TigerBeetle、beancount、Ledger CLI、Square LedgerDB、Stripe Ledger 等开源与工业实现。

2026-04-22 · architecture / fintech

【金融科技工程】账务数据库设计:TiDB/OceanBase/Postgres 下的分片、索引、热点账户

账务(Ledger)数据库是金融系统最硬的那块骨头。本文从 RPO/RTO 目标出发,对比 PostgreSQL、MySQL、OceanBase、TiDB、CockroachDB、Oracle、TigerBeetle 等主流选型,讲分片维度、热点账户拆解、索引设计、冷热归档、MVCC 并发控制与审计合规,辅以蚂蚁、Stripe、PayPal、Square 的真实演进路径。


By .