Python 基础体系 · 第 45/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python Decimal 与 Fraction:精度、舍入、上下文和金融计算

在 Python 中,“精度”不是一个单一问题。至少要区分三件事:

  1. 表示精度:一个数能否被数据类型准确表示;
  2. 运算精度:多个准确或近似的数参与运算后,结果保留多少信息;
  3. 业务精度:结果最终允许保留几位小数、采用什么舍入规则,以及何时舍入。

float 主要使用二进制浮点数;Decimal 使用十进制浮点模型,并提供可配置的精度、舍入和异常信号;Fraction 使用整数分子与整数分母表示有理数。三者解决的问题不同,不能简单地说“Decimal 比 float 精确”或“Fraction 永远最好”。正确的选择取决于数据的来源、运算过程和结果约束。(docs.python.org)


一、先区分三个数值模型

1. float:二进制浮点近似

Python 的 float 通常对应 IEEE 754 双精度二进制浮点数。它用二进制小数保存数值,因此某些十进制小数无法有限表示。

例如:

print(0.1 + 0.2)
print((0.1 + 0.2) == 0.3)

典型输出为:

0.30000000000000004
False

这不是 Python 的加法错误,而是表示模型的结果。二进制中,0.10.2 都只能表示为附近的二进制数;加法是在这些近似值上进行的。Python 文档也明确指出,1.12.2 等十进制数通常不能被二进制浮点精确表示。(docs.python.org)

float 适合:

  • 科学计算中的近似结果;
  • 图形、物理模拟;
  • 对误差有明确容忍范围的计算;
  • 需要硬件浮点性能的场景。

它不适合直接承担“金额必须严格相等”的业务不变量。

2. Decimal:十进制浮点与定点计算

Decimal 的内部形式可以抽象为:

x=(1)s×C×10ex = (-1)^s \times C \times 10^e

其中:

  • ss 是符号,通常为 01
  • CC 是由十进制数字组成的系数;
  • ee 是十进制指数。

例如:

from decimal import Decimal

x = Decimal("123.4500")
print(x.as_tuple())

输出类似:

DecimalTuple(sign=0, digits=(1, 2, 3, 4, 5, 0, 0), exponent=-4)

这里的值为:

1234500×104=123.45001234500 \times 10^{-4} = 123.4500

末尾的两个 0 不改变数值,但保留了输入的有效位信息。因此:

from decimal import Decimal

print(Decimal("1.30") + Decimal("1.20"))
print(Decimal("1.3") * Decimal("1.2"))
print(Decimal("1.30") * Decimal("1.20"))

输出:

2.50
1.56
1.5600

这正是金额计算中常见的“数值相等”和“表示精度”同时存在的情况。Decimal 对象本身不可变,且可以表示有限数、正负无穷、静默 NaN、信号 NaN,以及带符号的正零和负零。(docs.python.org)

3. Fraction:精确有理数

Fraction 表示:

x=pqx = \frac{p}{q}

其中 ppqq 是整数,且分母 q0q \ne 0。Python 会自动约分,并保证分母为正。

from fractions import Fraction

a = Fraction(16, -10)
b = Fraction(6, 8)

print(a)
print(b)
print(a + b)

输出:

-8/5
3/4
-17/20

Fraction(16, -10) 实际保存的是:

1610=85\frac{-16}{10} = \frac{-8}{5}

因为两个整数的四则运算结果仍然是有理数,所以 Fraction 在其表示范围内可以避免舍入:

from fractions import Fraction

result = Fraction(1, 3) + Fraction(1, 6)
print(result)
print(float(result))

输出:

1/2
0.5

但“精确”不意味着“适合所有场景”。分母可能持续膨胀,最终导致对象变大、运算变慢;而金融系统通常还需要金额小数位、舍入方向、税务规则和格式化,这些不是 Fraction 的核心抽象。(docs.python.org)


二、Decimal 的精确输入:字符串和浮点数完全不同

1. 字符串输入表达十进制意图

金额应优先从字符串构造:

from decimal import Decimal

price = Decimal("19.99")
tax_rate = Decimal("0.13")

tax = price * tax_rate
print(tax)

输出:

2.5987

Decimal("19.99") 保存的是十进制数 19.99,而不是某个先前已经近似过的二进制浮点数。

2. 从 float 构造会保留浮点的真实二进制值

下面两种写法含义不同:

from decimal import Decimal

print(Decimal("0.1"))
print(Decimal(0.1))

典型输出:

0.1
0.1000000000000000055511151231257827021181583404541015625

Decimal(0.1) 并不是把源代码中的字符 "0.1" 重新解析成十进制,而是把 float 中已经存在的二进制近似值无损转换为十进制表示。Decimal.from_float(0.1) 也具有同样语义。(docs.python.org)

因此,以下代码不能修复已经发生的浮点误差:

from decimal import Decimal

bad = Decimal(0.1) + Decimal(0.2)
good = Decimal("0.1") + Decimal("0.2")

print(bad)
print(good)

应输出类似:

0.300000000000000016653345669433337482
0.3

如果输入已经是 float,可以明确选择一种语义:

from decimal import Decimal

binary_value = Decimal.from_float(0.1)
decimal_intent = Decimal(str(0.1))

print(binary_value)
print(decimal_intent)

Decimal(str(value)) 表示“采用浮点数的十进制显示结果”;Decimal.from_float(value) 表示“保留浮点数的实际二进制值”。二者都不是万能的清洗方法,关键在于你要保留哪一种语义。

Python 3.14 新增了 Decimal.from_number()。它只接受 intfloatDecimal,不接受字符串,因此适合在 API 类型边界上限制输入类别;它不会把 float 自动变成十进制字面量。(docs.python.org)

from decimal import Decimal

print(Decimal.from_number(10))
print(Decimal.from_number(Decimal("3.14")))
print(Decimal.from_number(0.1))

三、Decimal 的精度不是构造时精度

这是理解 Decimal 的关键。

from decimal import Decimal, getcontext

getcontext().prec = 5

x = Decimal("3.1415926535")
print(x)

y = x + Decimal("0")
print(y)

输出类似:

3.1415926535
3.1416

构造 x 时,当前上下文的 prec=5 不会截断字符串中的数字;上下文精度主要作用于运算结果。加入 Decimal("0") 后,发生了一次运算,结果才按照当前上下文舍入。(docs.python.org)

可以把运算理解为:

r=roundC(f(x1,x2,))r = \operatorname{round}_{C}(f(x_1, x_2, \ldots))

其中:

  • ff 是精确输入上的数学运算;
  • CC 是当前 Context
  • roundC\operatorname{round}_{C} 表示依据上下文进行舍入、指数检查和信号处理。

但这并不等于所有复杂表达式都天然获得无限精度。每个运算步骤都可能受到当前上下文影响。例如:

from decimal import Decimal, localcontext

a = Decimal("1.23456")
b = Decimal("7.89012")

with localcontext(prec=5):
    first = a * b
    result = first + Decimal("0.00001")

print(first)
print(result)

乘法结果先在 prec=5 下舍入,后续加法使用的已经是舍入后的 first。提高上下文精度,只能减少中间舍入,不能恢复已经丢失的信息。


四、上下文 Context:Decimal 运算的隐含环境

Decimal上下文是控制十进制运算的环境。它至少包含:

  • prec:有效数字精度;
  • rounding:舍入模式;
  • EminEmax:指数范围;
  • flags:已经发生过的信号;
  • traps:哪些信号应转化为异常。

默认上下文通常为 prec=28ROUND_HALF_EVEN,并对 OverflowDivisionByZeroInvalidOperation 启用陷阱。当前上下文可以通过 getcontext() 获取,通过 setcontext() 替换。(docs.python.org)

from decimal import getcontext

ctx = getcontext()

print(ctx.prec)
print(ctx.rounding)
print(ctx.Emin, ctx.Emax)
print(ctx.traps)
print(ctx.flags)

1. prec 控制有效数字,不是小数位数

from decimal import Decimal, localcontext

with localcontext(prec=4):
    print(Decimal("123.4567") + Decimal("0"))
    print(Decimal("0.1234567") + Decimal("0"))

输出类似:

123.5
0.1235

prec=4 表示结果保留四位有效数字:

  • 123.4567123.5
  • 0.12345670.1235

它不是“保留四位小数”。如果业务要求金额保留两位小数,应使用 quantize(),而不是把 prec 当作小数位配置。

2. localcontext() 用于局部改变状态

直接修改全局当前上下文容易让调用者之间互相影响:

from decimal import Decimal, getcontext

getcontext().prec = 6
value = Decimal(1) / Decimal(7)

# 后续代码仍然受到 prec=6 的影响

更安全的方式是:

from decimal import Decimal, localcontext

with localcontext(prec=50):
    high_precision = Decimal(1) / Decimal(7)

print(high_precision)

退出 with 后,之前的上下文会恢复。localcontext() 会复制进入代码块时的上下文,并在退出时恢复原状态;Python 3.11 起还支持通过关键字参数直接设置上下文属性。(docs.python.org)

from decimal import Decimal, localcontext, ROUND_HALF_UP

with localcontext(prec=12, rounding=ROUND_HALF_UP):
    result = Decimal("1") / Decimal("6")

print(result)

上下文是有状态的。对于多线程、异步请求或库代码,不应假设调用者会保持默认上下文,也不应在每个请求中无约束地修改共享配置。库函数更适合使用 localcontext(),并在边界处显式规定精度、舍入和异常策略。


五、舍入模式:同一个数字可以得到不同业务结果

Python decimal 提供多种舍入模式,包括:

  • ROUND_DOWN:向零舍入;
  • ROUND_UP:远离零舍入;
  • ROUND_FLOOR:向负无穷舍入;
  • ROUND_CEILING:向正无穷舍入;
  • ROUND_HALF_UP:最接近值,正好一半时远离零;
  • ROUND_HALF_DOWN:最接近值,正好一半时向零;
  • ROUND_HALF_EVEN:最接近值,正好一半时取偶数;
  • ROUND_05UP:特殊的 0/5 舍入规则。(docs.python.org)

1. ROUND_HALF_EVENROUND_HALF_UP

from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP

x = Decimal("2.5")
y = Decimal("3.5")

print(x.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN))
print(y.quantize(Decimal("1"), rounding=ROUND_HALF_EVEN))

print(x.quantize(Decimal("1"), rounding=ROUND_HALF_UP))
print(y.quantize(Decimal("1"), rounding=ROUND_HALF_UP))

输出:

2
4
3
4

ROUND_HALF_EVEN 在恰好一半时选择偶数,适合减少大量重复舍入时的统计偏差;ROUND_HALF_UP 更接近许多人工财务规则中“5 入”的直觉。但究竟使用哪一个,必须由业务规则、会计制度或外部协议决定,不能因为“看起来更符合常识”就自行选择。

2. 负数揭示了“向下”和“向零”的区别

from decimal import Decimal, ROUND_DOWN, ROUND_FLOOR, ROUND_UP

x = Decimal("-1.21")

print(x.quantize(Decimal("0.1"), rounding=ROUND_DOWN))
print(x.quantize(Decimal("0.1"), rounding=ROUND_FLOOR))
print(x.quantize(Decimal("0.1"), rounding=ROUND_UP))

输出:

-1.2
-1.3
-1.3

因为:

  • ROUND_DOWN 是向零靠近;
  • ROUND_FLOOR 是向负无穷靠近;
  • ROUND_UP 是远离零。

“向下取整”在中文业务描述中经常含糊,必须明确它是“向零”还是“向负无穷”。


六、quantize():金融金额的固定小数位工具

quantize() 的作用不是简单格式化,而是把数值舍入到指定的指数。

from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal("0.01")

amount = Decimal("7.325")
rounded = amount.quantize(CENT, rounding=ROUND_HALF_UP)

print(rounded)

输出:

7.33

这里:

Decimal("0.01")

表示指数为 -2,所以结果固定为两位小数。

from decimal import Decimal, ROUND_HALF_UP

for value in ["7.324", "7.325", "7.326", "-7.325"]:
    result = Decimal(value).quantize(
        Decimal("0.01"),
        rounding=ROUND_HALF_UP,
    )
    print(value, "->", result)

输出:

7.324 -> 7.32
7.325 -> 7.33
7.326 -> 7.33
-7.325 -> -7.33

1. 量化不是显示格式化

from decimal import Decimal

value = Decimal("1.2300")

print(format(value, ".2f"))
print(value.quantize(Decimal("0.01")))
print(value)

输出:

1.23
1.23
1.2300

format() 产生字符串,主要影响展示;quantize() 产生新的 Decimal,改变结果的指数和舍入后的数值。原对象仍不可变。

2. 加法与乘法并不自动保持金额小数位

from decimal import Decimal

a = Decimal("102.72")
b = Decimal("3.17")

print(a + b)
print(a * 42)
print(a * b)
print(b / a)

输出:

105.89
4314.24
325.6224
0.030859...

加法以及乘以整数通常保留原有的固定小数位;非整数乘法和除法会产生新的小数位,若业务规定结果必须是两位小数,就必须显式量化。官方文档也给出了对乘法和除法追加 quantize() 的模式。(docs.python.org)

from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal("0.01")

def money(value: Decimal) -> Decimal:
    return value.quantize(CENT, rounding=ROUND_HALF_UP)

def money_mul(x: Decimal, y: Decimal) -> Decimal:
    return money(x * y)

def money_div(x: Decimal, y: Decimal) -> Decimal:
    return money(x / y)

print(money_mul(Decimal("102.72"), Decimal("3.17")))
print(money_div(Decimal("3.17"), Decimal("102.72")))

输出:

325.62
0.03

函数的真正作用是把“业务金额边界”固定下来,而不是让所有内部中间结果都立即舍入。


七、舍入时机:逐行舍入与最后舍入不是同一件事

考虑三笔金额:

from decimal import Decimal, ROUND_HALF_UP

CENT = Decimal("0.01")
items = [Decimal("0.005"), Decimal("0.005"), Decimal("0.005")]

line_total = sum(
    item.quantize(CENT, rounding=ROUND_HALF_UP)
    for item in items
)

final_total = sum(items).quantize(CENT, rounding=ROUND_HALF_UP)

print(line_total)
print(final_total)

输出:

0.03
0.02

推导过程如下:

逐行舍入:

0.0050.010.005 \to 0.01

因此:

0.01+0.01+0.01=0.030.01 + 0.01 + 0.01 = 0.03

最后舍入:

0.005+0.005+0.005=0.0150.005 + 0.005 + 0.005 = 0.015

再进行:

0.0150.020.015 \to 0.02

两种结果都可能合理,取决于业务规则:

  • 发票按每个明细行计税:可能需要逐行舍入;
  • 数学上先合计再计算:可能需要最后舍入;
  • 平台需要“明细合计必须等于总计”:还需要定义差额分摊规则。

因此,Decimal 只能准确执行指定的规则,不能替业务决定规则。


八、避免双重舍入:中间过程提高精度,边界统一量化

双重舍入是指一个结果先被舍入到较高位数,再被舍入到较低位数。它可能与直接舍入到目标位数不同。

例如:

from decimal import Decimal, ROUND_HALF_UP

value = Decimal("1.2451")

first = value.quantize(Decimal("0.001"), rounding=ROUND_HALF_UP)
second = first.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
direct = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)

print(first)
print(second)
print(direct)

输出:

1.245
1.25
1.25

这个例子结果相同,但不能据此认为双重舍入总是安全。中间步骤可能改变临界位,尤其是在不同舍入模式、负数或分步计算中。

更稳妥的原则是:

  1. 输入先解析为正确的数值模型;
  2. 中间计算保留足够的有效数字;
  3. 只有到明确的业务边界才 quantize()
  4. 输出、入库或对账前再验证目标尺度。

如果必须进行高精度中间计算,可以使用局部上下文:

from decimal import Decimal, localcontext, ROUND_HALF_UP

CENT = Decimal("0.01")

with localcontext(prec=40, rounding=ROUND_HALF_UP):
    subtotal = Decimal("19.99") * Decimal("1.075")
    total = subtotal.quantize(CENT)

print(total)

这里 prec=40 减少中间计算的有效数字损失,quantize(CENT) 明确规定最终金额尺度。


九、上下文信号:区分“舍入了”和“结果不精确”

Decimal 的信号用于记录特殊计算条件。常见信号包括:

  • Inexact:结果不是精确值;
  • Rounded:结果发生了舍入或丢弃了数字;
  • DivisionByZero:除以零;
  • InvalidOperation:无效运算;
  • OverflowUnderflowSubnormal
  • FloatOperation:发生了受监控的浮点混用。(docs.python.org)

每个信号都有:

  • 一个 flag,用于记录是否发生;
  • 一个 trap,用于决定是否直接抛出异常。

flag 是粘性的:一旦被设置,会一直保留,直到显式清除。因此监控一段计算前,应先调用 clear_flags()。(docs.python.org)

from decimal import Decimal, ExtendedContext, getcontext

ctx = getcontext()
ctx.clear_flags()

result = Decimal("1") / Decimal("7")

print(result)
print(ctx.flags)

典型结果中会出现:

Inexact
Rounded

1 / 7 的十进制展开是无限循环小数,所以:

  • Rounded 表示超出当前精度的位被舍弃;
  • Inexact 表示被舍弃的位中存在非零数字。

1. 用 trap 把“不允许的舍入”变成异常

如果输入金额必须最多两位小数,可以利用 Inexact trap 做校验:

from decimal import Decimal, Context, Inexact

CENT = Decimal("0.01")
validation_context = Context(traps=[Inexact])

def validate_money_scale(value: Decimal) -> Decimal:
    return value.quantize(CENT, context=validation_context)

print(validate_money_scale(Decimal("12.34")))

try:
    validate_money_scale(Decimal("12.345"))
except Inexact:
    print("输入超过两位小数")

输出:

12.34
输入超过两位小数

这与“直接舍入”不同:

Decimal("12.345").quantize(Decimal("0.01"))

会得到 12.3412.35,而校验逻辑的目标是拒绝非法输入,而不是替调用者修改输入。


十、FloatOperation:阻止不明确的浮点混用

Decimal 不允许一般算术中直接混合 float

from decimal import Decimal

try:
    print(Decimal("1.1") + 0.2)
except TypeError as exc:
    print(type(exc).__name__, exc)

但比较操作存在特殊规则:

from decimal import Decimal

print(Decimal("1.1") == 1.1)

即使比较结果是 True,也不能因此认为两个对象的内部表示相同。更严格的程序可以打开 FloatOperation trap:

from decimal import Decimal, FloatOperation, localcontext

with localcontext() as ctx:
    ctx.traps[FloatOperation] = True

    try:
        Decimal(0.1)
    except FloatOperation:
        print("禁止从 float 构造 Decimal")

这适合在金融核心模块的测试或开发环境中发现隐式转换。生产环境是否启用,应结合已有数据边界和异常处理方式决定。(docs.python.org)


十一、Decimal.fma():避免乘加之间的中间舍入

Python 3.14 的 Decimal 提供了 fma()

x.fma(y, z)

它计算:

x×y+zx \times y + z

但不会先对中间乘积 x×yx \times y 舍入。(docs.python.org)

from decimal import Decimal, localcontext

x = Decimal("1.234567")
y = Decimal("9.876543")
z = Decimal("0.000001")

with localcontext(prec=8):
    normal = x * y + z
    fused = x.fma(y, z)

print(normal)
print(fused)

两者在简单例子中可能相同,但在低精度、高位数或临界舍入场景中可能不同。fma() 的价值是减少一次中间舍入,适合需要把线性表达式视为一个整体计算的场景。

它并不意味着结果自动满足金额小数位;最终仍然需要:

amount = x.fma(y, z).quantize(Decimal("0.01"))

十二、Fraction 的精确性与构造陷阱

1. 从整数和字符串构造

from fractions import Fraction

print(Fraction(3, 7))
print(Fraction("3/7"))
print(Fraction("0.125"))
print(Fraction("7e-6"))

输出:

3/7
3/7
1/8
7/1000000

从十进制字符串构造时,Fraction 会把有限十进制准确转换成有理数。例如:

0.125=1251000=180.125 = \frac{125}{1000} = \frac{1}{8}

2. 从 Decimal 构造通常可以保持十进制值

from decimal import Decimal
from fractions import Fraction

print(Fraction(Decimal("1.1")))

输出:

11/10

这是一种精确转换,因为有限十进制数本身就是有理数。(docs.python.org)

3. 从 float 构造会保留浮点实际值

from fractions import Fraction

print(Fraction(1.1))
print(Fraction("1.1"))

典型输出:

2476979795053773/2251799813685248
11/10

Fraction(1.1) 表示的是 float 中实际保存的二进制数,不是人们通常写下的十进制 1.1。如果业务含义是十进制输入,应使用:

Fraction("1.1")

而不是:

Fraction(1.1)

4. limit_denominator() 是近似,不是还原真相

from fractions import Fraction

f = Fraction("3.1415926535897932")

print(f.limit_denominator(1000))

输出:

355/113

limit_denominator(1000) 会寻找分母不超过 1000 的最接近有理数。它可用于把测量值或浮点值近似为简单分数,但结果是“受约束的近似”,不代表原始数据本来就是 355/113。(docs.python.org)


十三、什么时候选 Decimal,什么时候选 Fraction

适合 Decimal 的问题

Decimal 适合数值本身具有十进制业务含义的问题:

  • 金额;
  • 税率;
  • 利率;
  • 价格;
  • 汇率;
  • 需要固定小数位的计费结果;
  • 需要指定舍入模式的报表。

它同时提供十进制表示、上下文精度、舍入模式、量化和异常信号,因此适合表达“值是多少”以及“结果如何被接受”。

适合 Fraction 的问题

Fraction 适合数学意义上的有理数:

  • 分数运算;
  • 配方比例;
  • 音乐节拍和周期;
  • 精确的概率表达;
  • 有理函数;
  • 需要延迟舍入的算法;
  • 需要验证两个有理数是否严格相等。

例如比例计算:

from fractions import Fraction

part_a = Fraction(2, 5)
part_b = Fraction(1, 3)

total = part_a + part_b
share = part_a / total

print(total)
print(share)

输出:

11/15
6/11

如果最终要展示为百分比,再在输出边界转换:

from decimal import Decimal

percentage = (
    Decimal(share.numerator) / Decimal(share.denominator)
).quantize(Decimal("0.01"))

print(percentage)

这里的转换过程明确分为两步:

  1. Fraction 保持比例的精确性;
  2. Decimal 按十进制小数位生成展示结果。

十四、DecimalFraction 不能直接混合运算

下面的代码通常会失败:

from decimal import Decimal
from fractions import Fraction

try:
    print(Decimal("1.2") + Fraction(1, 5))
except TypeError as exc:
    print(type(exc).__name__, exc)

两种类型的核心语义不同:

  • Decimal 的结果受上下文精度和舍入模式影响;
  • Fraction 需要保持有理数精确性。

不要依靠隐式转换,应在边界处明确转换。例如,将 Fraction 转为 Decimal

from decimal import Decimal, localcontext
from fractions import Fraction

value = Fraction(1, 7)

with localcontext(prec=30):
    decimal_value = Decimal(value.numerator) / Decimal(value.denominator)

print(decimal_value)

这里的结果不再是精确的有理数,而是当前上下文下的十进制近似。转换发生时,应明确接受这个语义变化。

将有限 Decimal 转为 Fraction 则可以精确:

from decimal import Decimal
from fractions import Fraction

value = Decimal("12.3400")
fraction = Fraction(value)

print(fraction)

输出:

617/50

Fraction 表示数值时会约分,因此不会保留 Decimal("12.3400") 的小数位展示信息。


十五、一个完整的金额计算示例

下面实现一个简单订单计算:

  • 单价和数量来自字符串或整数;
  • 折扣率、税率使用 Decimal
  • 中间过程保留较高精度;
  • 最终金额按两位小数、ROUND_HALF_UP 舍入;
  • 输入金额超过两位小数时拒绝,而不是静默修改。
from decimal import (
    Context,
    Decimal,
    Inexact,
    ROUND_HALF_UP,
    localcontext,
)

CENT = Decimal("0.01")

scale_context = Context(traps=[Inexact])


def parse_money(text: str) -> Decimal:
    value = Decimal(text)
    # 只验证,不在此处悄悄舍入
    return value.quantize(CENT, context=scale_context)


def calculate_order(
    unit_price: str,
    quantity: int,
    discount_rate: str,
    tax_rate: str,
) -> dict[str, Decimal]:
    price = parse_money(unit_price)
    discount = Decimal(discount_rate)
    tax = Decimal(tax_rate)

    if quantity < 0:
        raise ValueError("quantity cannot be negative")
    if not Decimal("0") <= discount <= Decimal("1"):
        raise ValueError("discount_rate must be between 0 and 1")
    if not Decimal("0") <= tax <= Decimal("1"):
        raise ValueError("tax_rate must be between 0 and 1")

    with localcontext(prec=40, rounding=ROUND_HALF_UP):
        subtotal = price * quantity
        discount_amount = subtotal * discount
        taxable = subtotal - discount_amount
        tax_amount = taxable * tax
        total = taxable + tax_amount

        return {
            "subtotal": subtotal.quantize(CENT),
            "discount": discount_amount.quantize(CENT),
            "taxable": taxable.quantize(CENT),
            "tax": tax_amount.quantize(CENT),
            "total": total.quantize(CENT),
        }


result = calculate_order(
    unit_price="19.99",
    quantity=3,
    discount_rate="0.10",
    tax_rate="0.13",
)

for name, value in result.items():
    print(f"{name}: {value}")

典型输出:

subtotal: 59.97
discount: 6.00
taxable: 53.97
tax: 7.02
total: 60.99

计算过程为:

subtotal=19.99×3=59.97\text{subtotal} = 19.99 \times 3 = 59.97

discount=59.97×0.10=5.9976.00\text{discount} = 59.97 \times 0.10 = 5.997 \to 6.00

taxable=59.975.997=53.973\text{taxable} = 59.97 - 5.997 = 53.973

tax=53.973×0.13=7.016497.02\text{tax} = 53.973 \times 0.13 = 7.01649 \to 7.02

total=53.973+7.01649=60.9894960.99\text{total} = 53.973 + 7.01649 = 60.98949 \to 60.99

这个示例中有一个必须由业务确认的细节:折扣金额是否在计算应税金额前舍入。如果规则要求每个中间金额都是两位小数,则应在 discount_amount 计算后立即 quantize();如果规则要求最后统一舍入,则应保留更多中间位数。代码不能替代规则。


十六、金融系统中的金额表示:小数金额、最小货币单位和 Decimal

有些系统不把金额保存为 Decimal,而是保存为最小货币单位的整数,例如:

amount_cents = 1999

它表示 19.99。这种方案的优点是:

  • 加减法是整数运算;
  • 数据库约束容易表达;
  • JSON 序列化简单;
  • 不存在二进制浮点误差。

但它只适合小数位固定且明确的金额。对于税率、汇率、利率或不同货币的小数位,仍然需要 Decimal

两种表示可以在边界处转换:

from decimal import Decimal

cents = 1999
amount = Decimal(cents) / Decimal(100)

print(amount)

反向转换时必须先规定舍入策略:

from decimal import Decimal, ROUND_HALF_UP

amount = Decimal("19.995")
cents = int(
    amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
    * 100
)

print(cents)

输出:

2000

不能直接使用:

int(amount * 100)

因为这会把“金额如何舍入”隐含为向零截断。


十七、JSON、CSV 和 TOML 的精度边界

1. JSON

JSON 本身只有通用的数字语法,并没有 Python DecimalFraction 这样的类型标记。直接序列化自定义数值对象通常会失败:

import json
from decimal import Decimal

try:
    print(json.dumps({"amount": Decimal("19.99")}))
except TypeError as exc:
    print(type(exc).__name__, exc)

常见边界方案有两种:

import json
from decimal import Decimal

payload = {
    "amount": "19.99",
    "tax": "2.60",
}

text = json.dumps(payload)
data = json.loads(text, parse_float=Decimal)

print(text)
print(Decimal(data["amount"]))

金额使用字符串的优点是避免下游系统把它当成二进制浮点;如果系统协议规定金额为最小单位整数,也可以传:

{"amount_minor": 1999}

不要让一端传 "19.99"、另一端传 19.99,再由不同语言自行推断精度规则。

2. CSV

CSV 的字段本质上是文本:

from decimal import Decimal

row = ["1001", "19.99", "3"]
price = Decimal(row[1])
quantity = int(row[2])

total = price * quantity
print(total)

解析时应显式指定类型;不要先使用 float 再转 Decimal

3. TOML

TOML 支持数字字面量,但跨语言读取时仍应确认目标库对十进制浮点、指数和特殊值的处理。若配置项参与金额计算,读取后应在应用边界显式转换并验证尺度:

from decimal import Decimal

raw_tax_rate = "0.13"
tax_rate = Decimal(raw_tax_rate)

if not Decimal("0") <= tax_rate <= Decimal("1"):
    raise ValueError("invalid tax rate")

配置文件只是传输格式,不应被视为自动提供了业务精度保证。


十八、特殊值:NaN、无穷大和负零

Decimal 支持特殊值:

from decimal import Decimal

values = [
    Decimal("NaN"),
    Decimal("Infinity"),
    Decimal("-Infinity"),
    Decimal("-0"),
]

for value in values:
    print(value, value.is_nan(), value.is_finite(), value.is_signed())

金融金额通常不应允许 NaN 和无穷大进入核心计算。应在输入边界检查:

from decimal import Decimal

def require_finite(value: Decimal) -> Decimal:
    if not value.is_finite():
        raise ValueError(f"non-finite decimal: {value}")
    return value

-0 在数值比较中通常等于 0,但它仍然携带负号:

from decimal import Decimal

x = Decimal("-0")

print(x == 0)
print(x.is_zero())
print(x.is_signed())

输出:

True
True
True

如果金额系统要求统一展示零,应在格式化或规范化边界处理负零,而不是假设所有零的内部表示完全相同。


十九、诊断失败结果:从异常、flags 和输入链路定位问题

遇到金额不一致时,应按数据流排查:

外部文本/JSON/CSV
        |
        v
类型解析
        |
        v
Decimal 或 Fraction
        |
        v
上下文与运算
        |
        v
quantize / 最小单位转换
        |
        v
数据库、接口或展示

重点检查以下路径:

  1. 是否从 float 构造了 Decimal
  2. 是否在某个函数中修改了当前上下文;
  3. 是否使用了错误的舍入模式;
  4. 是否在明细级别和总计级别采用了不同舍入时机;
  5. 是否把 format() 的字符串误当成数值;
  6. 是否把 Decimal 转成 float 后又重新计算;
  7. 是否忽略了 InexactRounded 等 flags;
  8. 是否把 Fraction 转换成 Decimal 时使用了不足的上下文精度。

对于需要严格失败的计算,可以创建专用上下文:

from decimal import (
    Context,
    Decimal,
    DivisionByZero,
    Inexact,
    InvalidOperation,
    Overflow,
)

strict = Context(
    prec=28,
    traps=[
        DivisionByZero,
        InvalidOperation,
        Overflow,
        Inexact,
    ],
)

try:
    result = strict.divide(Decimal("1"), Decimal("7"))
except Inexact:
    print("结果需要舍入,严格模式拒绝继续")

严格模式适合验证、对账和测试;宽松模式适合允许计算继续、随后检查 flags 的批处理。二者都比“完全忽略计算状态”更容易诊断。


二十、常见误解与对应修正

误解一:用了 Decimal 就不需要舍入

错误。Decimal 可以准确表示很多十进制输入,但除法、非整数乘法和有限精度上下文仍然可能产生无限或超出精度的结果。Decimal("1") / Decimal("3") 仍需要舍入。

误解二:prec=2 表示保留两位小数

错误。prec 表示有效数字位数,不是固定小数位。固定两位小数应使用:

value.quantize(Decimal("0.01"))

误解三:Decimal(float_value) 可以修复浮点误差

错误。它会精确转换 float 已保存的二进制值。若源数据的业务意图是十进制,应从字符串构造。

误解四:Fraction 结果永远适合金融系统

错误。Fraction 能精确表示有理数,却不会自动表达金额尺度、舍入制度、货币单位或报表格式。它更适合把数学过程保持为精确有理数。

误解五:格式化成两位小数就完成了金额计算

错误。格式化只改变输出文本;金额计算还需要规定中间精度、舍入模式、舍入时机和存储格式。

误解六:两个 Decimal 数值相等,就代表它们的业务表示完全相同

from decimal import Decimal

a = Decimal("12.0")
b = Decimal("12.00")

print(a == b)
print(a.compare_total(b))

数值比较中二者相等,但 compare_total() 比较的是抽象表示,可能认为它们不同。数值相等适合计算判断;表示相等适合需要保留尺度或编码形式的场景。(docs.python.org)


二十一、选择依据可以归纳为一个问题

选择数值类型时,不要先问“哪个最精确”,而应依次问:

  1. 输入的真实语义是什么:二进制测量值、十进制金额,还是数学分数?
  2. 中间结果是否必须严格保持有理数?
  3. 业务是否规定固定小数位?
  4. 舍入是在每个明细、每个阶段,还是最终输出时发生?
  5. 结果是否允许 NaN、无穷大或隐式近似?
  6. 数据跨 JSON、CSV、数据库或其他语言时,协议如何表达精度?
  7. 出现非精确结果时,是记录 flag、继续执行,还是抛出异常?

一般而言:

  • 科学和工程近似计算优先考虑 float
  • 金额、税率、利率和十进制业务规则优先考虑 Decimal
  • 需要保持有理数精确性的数学过程优先考虑 Fraction
  • 固定货币最小单位且规则简单的存储可以使用整数;
  • 跨系统传输应明确使用十进制字符串或最小单位整数,而不是让接收方猜测。

Decimal 的核心不是“永远不产生误差”,而是让十进制表示、精度、舍入和异常处理成为可配置、可验证的程序状态。Fraction 的核心不是“替代 Decimal”,而是把有理数运算延迟到最后一步再近似。理解表示模型、上下文状态和业务舍入边界,才是避免金融计算错误的基础。


系列导航与关联阅读

官方资料

本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。