目录

02 · number.py:Decimal、统一入口 D() 与 MISSING 哨兵

核对基线 · 范围 · 依赖

核对基线:beancount 仓库 commit 97472138(2026-08-22)。路径相对仓库根目录,引用格式 文件:起-止行 (名称),行号已逐条用 sed -n / grep -n 核对;Python 语义事实在 3.14.6 上实测。 本篇范围beancount/core/number.py(184 行)与 beancount/core/number_test.py(160 行);账本词法层旁证 beancount/parser/lexer.lbeancount/parser/tokens.cbeancount/parser/grammar.y上游依赖:只有标准库 redecimalnumber.py:18-19),没有任何 beancount 内部依赖,是 core 里最底层的模块。 下游使用者beancount/ 包内二十余个非测试文件导入本模块(另有 experiments/docs/intro-doc/svg.py);amount.pyD/ZERO/MISSING(见 01 篇),解析器与 booking 全流程依赖 MISSING

这一篇会把 number.py 的代码一段段贴出来读。代码是 beancount 的原文;代码里的中文注释是本文加的。 每段代码后面跟一段讲解。

01 篇讲的是"钱必须带币种",这一篇讲的是更底下的一层:那个数本身该怎么存。整个模块只有 184 行,但它挡住的是记账软件最经典的一类事故。

(01 篇开头解释过的五个词——类、函数、不可变、断言、异常——这里不再重复。本篇会新用到"常量"和"哨兵",用到时会说。)

一、0.1 + 0.2 为什么不等于 0.3

先看一个可以自己动手验证的现象。打开任何一台电脑上的 Python,输入:

>>> 0.1 + 0.2
0.30000000000000004

这不是 Python 的 bug,几乎所有编程语言都这样,Excel 和 JavaScript 也一样。原因是计算机内部用二进制存小数,而 0.1 在二进制里是个无限循环小数——就像十进制写不出精确的 1/3 一样,二进制也写不出精确的 0.1。存进去的其实是一个非常接近 0.1 但不等于 0.1 的数。

平时这没什么关系,误差在小数点后十几位。但记账不一样:

这类数字有个名字叫浮点数(floating-point)。number.py 的 docstring 里对它的态度是一句不留余地的话(number.py:46):

never use floating-point in an accounting system (记账系统里绝对不要用浮点数)

二、替代方案:把数字当十进制存

Python 标准库里有个类型叫 Decimal,它不用二进制存小数,而是老老实实记下"这个数由哪几个十进制数字组成、小数点在哪"。

number.py 的第 19 行就是整个模块的地基:

from decimal import Decimal
# 从标准库把 Decimal 这个类型取过来。
# beancount 里所有的钱,底层都是它,没有例外

代价是慢一点,好处是你写多少就是多少

>>> Decimal("0.1") + Decimal("0.2")
Decimal('0.3')

这里有个细节要留心,后面好几篇都会用到:Decimal记住你写了几位小数Decimal("0.10")Decimal("0.1") 在数值上相等,但它们不是同一个东西——前者记得自己有两位小数。

这个"多余"的信息在 beancount 里是有用的原料:你在账本里写 10.50 而不是 10.5,系统会理解成"这笔账你是按分记的",并据此推断出对账时该用多大的容差(07 篇)、报表里该保留几位小数(06 篇)。你写了几位小数,被当成一种意图来读。

也正因为如此,beancount 从不对存下来的数调用 normalize()(一个把 100.00 压成 1E+2 的函数)——那会把这份意图抹掉。

三、D():数字进系统的一道门

数字进入系统的形态五花八门:可能是字符串 "1,234.50"(带千分位逗号)、可能是空的、可能已经是 Decimal 了。D() 把这些情况在一个地方收口。

number.py:41-71

def D(strord: Decimal | str | None = None) -> Decimal:
    # 名字只有一个字母,因为它被调用的次数太多了
    # 参数可以是 Decimal、字符串或者什么都不传

    try:
        # 下面整段被 try 包住:无论出什么错,都在最后统一处理

        if strord is None or strord == "":
            return Decimal()
            # 什么都没传,或者传了个空字符串 → 返回 0
            # 这是为导入对账单这类场景准备的:表格里的空格子就当 0

        elif isinstance(strord, str):
            return Decimal(_CLEAN_NUMBER_RE.sub("", strord))
            # 是字符串:先把里面所有的逗号和空格删掉,再交给 Decimal
            # 所以 "1,234.50" 变成 "1234.50","- 122.34" 变成 "-122.34"

        elif isinstance(strord, Decimal):
            return strord
            # 已经是 Decimal 了:原样还回去,不做多余的事

        elif isinstance(strord, (int, float)):
            return Decimal(strord)
            # 是整数或浮点数:直接转
            # 注意这一行——上一节刚说过绝不能用浮点数,这里却接受它

        else:
            assert strord is None, "Invalid value to convert: {}".format(strord)
            # 其它类型:崩溃

    except Exception as exc:
        raise ValueError(
            "Impossible to create Decimal instance from {!s}: {}".format(strord, exc)
        ) from exc
        # 上面任何一步出错,都统一包装成一个 ValueError 抛出去,
        # 报错信息里带上原始输入,方便定位是哪个数据有问题

删逗号那一行用到的工具在 number.py:38

_CLEAN_NUMBER_RE = re.compile("[, ]")
# 一条规则:匹配"逗号或空格"。上面用它把这两种字符全删掉

这个函数值得注意的地方有三处。

它很宽容,而且是故意的。 docstring 说明了它的用途:给"从文件里导入金额"用(number.py:44)。你从银行下载的对账单可能把空金额留成空格、把一千写成 1,000——D() 一律接住。宽容的边界也写清楚了:逗号一律当千分位删掉,所以法国式的写法(用逗号当小数点)在这里会被理解错D("1 000,5") 得到的是 10005 而不是 1000.5。docstring 直说了不支持这种写法。

它对浮点数的态度是矛盾的。 上面第 64-65 行接受 float,而同一个函数的 docstring 却写着"绝不要用浮点数"。传进去会发生什么:

>>> D(3.30)
Decimal('3.29999999999999982236431605997495353221893310546875')

那串尾巴就是第一节讲的二进制误差,它被原封不动地记进了账本。更麻烦的是打印出来看不见——'{:.2f}' 格式化之后它显示为 3.30,一切正常,直到某天对账差了一分钱。测试里也没有任何一个 float 的用例。

最后那个 assert 有点怪。 它写的是 assert strord is None,但能走到这一行的前提恰恰是 strord 不是 None(第一个分支已经把 None 拦掉了)。所以这个断言必定失败——它其实是被当成"到这里就该崩"用的。副作用是:Python 有个优化开关(-O)会把所有断言删掉,开着它跑的话,这个分支什么都不返回,等于悄悄返回了一个"空",反而比崩溃更糟。

四、但账本里的数字根本不走这道门

这是本篇最容易被误会的一点:D() 看着像"所有数字的总入口",但你写在 .beancount 文件里的金额不经过它。

账本文件由一套 C 语言写的解析器处理(08 篇细讲),数字在 C 那一层就直接构造成 Decimal 了。于是同一个字符串,从这两条路进来,结果可能完全不同:

你写的东西 D() 进(导入工具) 从账本文件进(C 解析器)
1,234.50 接受 接受
12,34(逗号分组错了) 接受,得到 1234 拒绝
1e3(科学计数法) 接受,得到 1000 拒绝
1_000(下划线分隔) 接受 拒绝
NaNinf(非数字、无穷大) 接受 拒绝
123(全角数字) 接受 拒绝
""(空) 返回 0 不适用

左边那列是给程序员写的导入脚本用的,宽松是为了好用;右边那列是用户手写的账本,严格是为了不让垃圾数据混进来。

两条路的严格程度差这么多,是因为它们防的不是同一件事。 账本是你亲手写的、要长期保存的原始数据,一个字符都不能含糊;导入脚本处理的是别人(银行、券商)给的格式,你只能尽量适应。

五、MISSING:一个什么都不做的类

账本允许你故意不写某个数字,让系统自己推算。比如一笔转账,你写了转出 500,转入那行的金额可以留空——它必然也是 500。

系统读到这种留白时,需要一个东西来占位。这个占位符叫 MISSINGnumber.py:28-32):

# A constant used to make incomplete data, e.g. missing numbers in the cost spec
# to be filled in automatically. We define this as a class so that it appears in
# errors that would occur from attempts to access incomplete data.
#
# 译:一个用来标记"数据不完整"的常量,比如成本说明里等着自动填的数字。
#    我们把它定义成一个类,这样在误用这类不完整数据而出错时,
#    它的名字会出现在报错信息里。

class MISSING:
    pass
    # pass 的意思是"这个类里什么都没有"
    # 它不需要有任何内容,存在本身就是它的全部作用

这七行是本篇最值得琢磨的设计。

为什么不用 None None 是 Python 里表示"空"的通用值。但在 beancount 里,"空"有两种完全不同的含义:

这两件事如果都用 None 表示,后面的补全环节就分不清"不用管"和"该我算了"。所以 beancount 让它们各占一个值。

为什么定义成一个类,而不是一个普通的值? 上面那段注释自己回答了:为了让它在报错时显出名字来。如果 MISSING 是个普通对象,误把它当数字用时,报错信息只会说"某个类型不支持相加";而它是个类的话,01 篇里那些断言(assert isinstance(number, Decimal), repr(number))打印出来就带着 MISSING 这个词,一眼看出是哪个半成品被误用了。

这个占位符的寿命很短。 它只活在"解析完成"到"补全完成"这一小段里。解析器的说明文件写死了一条规矩(beancount/parser/parser.py:25-28):加载完成的交易里绝不允许还残留 MISSING

所以整个系统里有三种"零和空",各管各的:

意思 举例
MISSING 用户留空,等系统推算 转账第二行不写金额
None 这个成分根本不存在 这笔交易没有价格信息
ZERO 明确的数字 0 手续费就是 0 元

六、四个常量

number.py:22-25

ZERO = Decimal()      # 0
HALF = Decimal("0.5") # 0.5
ONE = Decimal("1")    # 1
TEN = Decimal("10")   # 10

"常量"就是给一个固定的值起个名字,各处直接用这个名字,不必每次重新造。

有意思的是它们的实际使用情况很不平均:

留着一个没人用的常量不算错,但这类"定义了就忘了"的痕迹,在一个十年的项目里很常见。

七、两个小工具

number.py:74-95,两个都只有一行:

def round_to(number: Decimal, increment: Decimal) -> Decimal:
    """Round a number *down* to a particular increment."""
    # docstring 说:把数字向下取整到某个增量

    return int(number / increment) * increment
    # 除一下、取整、再乘回去。
    # 比如把 135.12345 按 0.01 取整:除得 13512.345,取整得 13512,乘回 135.12

def same_sign(number1: Decimal, number2: Decimal) -> bool:
    # 判断两个数是不是同号
    return (number1 >= 0) == (number2 >= 0)
    # 两边各自问一句"你是不是非负数",两个答案一样就算同号
    # 注意 0 被归进"非负"这一边

round_to 这里有个文档和代码对不上的地方:docstring 说的是"向下取整"(round down),但代码用的 int()向零截断。正数没区别,负数就分道扬镳了:-135.12345 向下取整应该是 -135.13(更小),而实际得到的是 -135.12(更接近零)。测试把后者钉死了,所以真实行为是截断,文档那句话不准。

same_sign 的用处在 04 篇:往一个账户里记一笔钱时,得先判断这是加仓还是减仓——看新来的数和已有的数是不是同号就知道了。

模块里还有另外三个函数(auto_quantize 一族),作用是给那些从浮点数转来、带一串垃圾尾数的数字反推出"本来应该是几位小数"。它们在所有非测试代码里零调用,只有测试文件在用。细节在文末技术版。

八、这一篇讲了什么

  1. 绝不用浮点数。 二进制存不下 0.1,误差会累积、会静默。记账系统里的数一律是 Decimal
  2. 小数位数是有意义的信息。 你写 10.50 还是 10.5,系统当成"你想记到分还是记到角"来读,并据此推断容差和显示精度。所以存下来的数从不做 normalize()
  3. 数字进系统有两条路,严格程度不同。 账本文件走 C 解析器,卡得很死;导入脚本走 D(),宽容得多——因为它们防的不是同一件事。
  4. "没有"要分成两种。 MISSING(该有个数,等推算)和 None(根本没这回事)不能混。分不清,补全环节就没法工作。
  5. 占位符要能在报错里显形。MISSING 做成一个类,就是为了误用时报错信息里能看见它的名字。

这些和 01 篇合起来,就是 beancount 对"一笔钱"的完整定义:一个精确的十进制数,带着币种,带着你写下的小数位数,而且如果暂时还不知道是多少,它有一个会自报家门的占位符。


技术版:接受/拒绝对照、Decimal 语义实测、边界行为、演变史(点开)

以下是本篇的技术版记录,对照源码查阅用。行号均以 commit 97472138 为准。

结构一览

名称 位置 作用
模块 docstring number.py:1-11 两条使用约定:从 beancount.core.amountDecimal(已脱节);优先用 D()
ZERO / HALF / ONE / TEN :22-25 常量;ONE.scaleb(expo) 是容差算子,TEN**exponent 是量化算子
class MISSING :28-32 哨兵,定义成类而不是实例或 None
NUMBER_RE :36 r"[+-]?\s*[0-9,]*(?:\.[0-9]*)?",供别处拼正则用,D() 自己不用它
_CLEAN_NUMBER_RE :38 re.compile("[, ]")D() 用它删掉所有逗号和空格
D :41-71 统一构造入口,任何失败包装成 ValueError
round_to :74-83 按增量取整,实现是向零截断
same_sign :86-95 两数是否同号,0 归为非负
auto_quantized_exponent :98-120 按阈值反推小数位
auto_quantize :123-148 用上面反推的指数做 quantize + normalize
num_fractional_digits :151-159 返回 -exponent
infer_quantum_from_list :162-184 一组数上取最大小数位,返回 -exponent

D() 的类型契约

类型注解(:41)未列出 int/float,但 :64-65 接受它们(bool 作为 int 子类一并放行)。其它类型走 :66-67assert strord is NoneAssertionError 连同 InvalidOperation:68-71 包成 ValueError——这个"只捕一种异常"的契约在 python -O 下失效(assert 被剥离,静默返回 None)。测试 number_test.py:33-41 (test_D) 覆盖 Decimal 透传、逗号、"- 122.34"(负号后带空格,靠删空格正则通过)、空串、None,没有 float 用例,也没有非法字符串用例。

账本文件里的金额/价格/成本不经过 D()(option 指令里的数字仍走 D()):beancount/parser/lexer.l:282 的 NUMBER 正则先决定哪些字符串能成为数字 token,beancount/parser/tokens.c:55-67 (pydecimal_from_cstring) 再用 validate_decimal_number:9-53)校验千分位分组,最后经 beancount/parser/decimal.h:26 (PyDec_FromCString) 调用 Decimal(str)。正负号是独立 token(lexer.l:220-221),由 grammar.y:338-346 (number_expr) 作一元运算处理,所以账本里 - 122.34 合法而 1e3 不合法。

D() 与账本词法层的完整对照

输入形态 beancount D() beancount 账本(lexer + tokens.c)
纯数字、前导零 0071234567 接受 接受
符号紧贴数字 +5-0.5 接受 接受(符号是独立 token,lexer.l:220-221
符号与数字间有空格 - 122.34 接受(number_test.py:39,靠删空格) 接受(一元运算,grammar.y:338-346
首尾空白 " 5 " 接受 不适用
尾随换行 "5\n" 接受(Decimal() 自身剥离首尾空白) 不适用
千分位逗号:首段 1-3 位,其后每段恰 3 位,小数部分无逗号 1,234.501,234,567.891 接受(删逗号) 接受(tokens.c:19-26,33-39,46-48
逗号分组错误 12,341,00.5 接受(得 1234100.5 拒绝(-EINVAL
小数点一侧无数字 .55. 接受 .5 拒绝、5. 接受((\.[0-9]*)?
下划线、全角/Unicode 数字 1_000123 接受 拒绝(不匹配 NUMBER)
指数、NaN、Infinity 1e3NaNinf 接受 拒绝(不匹配 NUMBER)
空串 "" 返回 0 不适用

tokens.c 不是一个可以照抄的校验器::28-31 只有 isdigit 才把字符拷进输出缓冲,没有 else 分支,未知字符被静默跳过——单独拿它校验 "1e3" 会得到 "13""-1.5" 会得到 "1.5"。它能工作是因为 lexer 正则已经在前面把关。

常量的实际消费者

ZERO 在非测试代码里有几十处引用(判零、初始化累加器);ONE 有两类消费者——ONE.scaleb(expo) * multiplier 生成容差(beancount/core/interpolate.py:188beancount/ops/balance.py:41),以及取汇率倒数 ONE / ratebeancount/core/prices.py:112,131,240:333,372ONE 作同币种汇率);TEN**exponent 只用于 auto_quantizenumber.py:144);HALF 在非测试代码里零调用。

MISSING 的产生点与判定点

MISSING 是类对象,isinstance(MISSING, type) 为 True,因此能通过 beancount/core/amount.py:49valid_types_number = (Decimal, type, type(None))。产生点在 C 语法层:beancount/parser/grammar.y:446-450INDENT optflag account eol,无金额 posting 的 units 直接传 MISSING_OBJ)、:636-642 (maybe_number):644-650 (maybe_currency)%empty 分支;Python 侧 beancount/parser/grammar.py:518-519cost_comp_list 为空时直接返回 CostSpec(MISSING, None, MISSING, None, None, False),这是裸 {} 的路径;:583-587 处理的是 cost_comp_list 非空但没有 CompoundAmount 的成本说明(只写了日期或标签),同样填入 MISSING 数字与币种。

auto_quantize 一族

给由 float 转来、带垃圾尾数的 Decimal(如 20.899999618530273)反推小数位并量化:number.py:98-120 (auto_quantized_exponent):123-148 (auto_quantize):162-184 (infer_quantum_from_list)。非测试代码零调用,只有 number_test.py:68-156 在用;docstring :174-175 说接受 float,实际传裸 float 在 :107number.normalize() 处抛 AttributeError

num_fractional_digits:151-159)返回 -number.as_tuple().exponentdisplay_context.py 没有调用它,而是内联同一算式(display_context.py:204-205)。

设计决策与理由

决策 理由 没选的替代方案 证据
单一入口 D(),但只是 "Prefer" 输入形态(None/空串/逗号)在一处收口 各调用点自行 Decimal(x) number.py:8-9(模块 docstring);:44-49 (D);核心代码仍有裸调 display_context.py:319inventory.py:381
MISSING 是类,不是 None 或实例 误当数字运算立刻 TypeErrorNone 会静默传递 Noneobject() 单例 number.py:28-32beancount/parser/parser.py:3-8
账本数字由 C 层直接 Decimal(str),不走 D() 词法器与语法器本身是 flex/bison 生成的 C 代码,数字在 C 层就地构造;千分位与字符集校验也在 C 层完成,比 D() 词法层产字符串,Python 侧 D() lexer.l:282tokens.c:9-53,55-67decimal.h:26
插值结果只在"容差像用户手写"时才量化 自动推断的容差位数不可信,量化会伪造精度 一律量化 / 一律不量化 interpolate.py:46-58 (is_tolerance_user_specified):392-393
显示量化在 localcontext 内把 prec 抬到 小数位 + 12 修 issue #584:推断出的极端精度 + 大整数导致 quantize 超出默认 28 位 全局改 getcontext().prec commit 4c1e87bb(2020-11-25,+9);commit c9132bba(2025-12-21,改 +12 并加 NOTE);display_context.py:321-331
从不设置舍入模式 无记录;代码里没有任何显式设置,沿用 decimal 默认 显式 ROUND_HALF_UP 非测试代码 grep ROUND_|rounding= 无命中

坑与边界

现象 后果 beancount 的处理 证据
D(float) 是裸 Decimal(float)D(3.30) = 3.29999999999999982236431605997495353221893310546875 二进制展开进账本;D(1 - 0.01)0.98999999999999999111… 作为容差下界 无;docstring 说禁 float 但代码接受;测试无 float 用例 number.py:64-65beancount/plugins/check_average_cost.py:35,58-59number_test.py:33-41
D(True) = Decimal(1) bool 经 int 分支混入金额(boolint 子类) number.py:64-65
python -OD(object()) 返回 None 类型错不再抛 ValueError,None 流入下游 number.py:66-67(assert 被 -O 剥离)
全仓库从未设置舍入模式 quantize'{:.2f}'、除法均沿用调用时的 decimal context;未被调用方修改时结果是 ROUND_HALF_EVEN2.345→2.340.125→0.12),但这不是这些操作自身的保证 interpolate.py:393display_context.py:331,372,405,451grammar.py:918-919
除法结果带满 prec 位小数 10.00 / 3 → 27 位小数,exponent -27,进入容差推断/显示统计即为极端精度 display_context.quantize 抬 prec 兜底 grammar.py:918-919position.py:368display_context.py:321-331
D() 删除所有空格和逗号 D("1 000,5") = 10005;欧式小数逗号被当千分位 docstring 明说不支持法式逗号;C 层另有校验 number.py:38,46-48,61tokens.c:19-26
D("NaN")D("inf")D("1e3") 均被接受 非有限值或科学计数法进入金额 无(账本文件靠 lexer 正则拦住) number.py:60-61lexer.l:282
tokens.c 对未知字符静默跳过 把它当校验器照抄,"1e3" 会得 "13""-1.5""1.5" 依赖 lexer 正则在前面把关 tokens.c:28-31
非有限值不只来自字符串 除法在 trap 关闭时也能产出 NaN/Infinity;核心代码里还有裸写的 Decimal("Infinity") inventory.py:381 (Inventory.average)
round_to docstring "round down",实现 int() 向零截断 负数方向与 floor 不同:-135.12345 → -135.12,不是 -135.13 测试锁定截断行为 number.py:75,83number_test.py:46-47
same_sign 把 0 归为非负 same_sign(正, 0) True,same_sign(负, 0) False 测试锁定 number.py:95number_test.py:60-65
num_fractional_digits(Decimal("1E+2")) = -2 位数为负 number.py:159
quantize_with_tolerance 判断的是 quantum = (tolerance * 2).normalize() 是否"像手写",不是 tolerance 本身;guard 只限有效数字个数 MAX_TOLERANCE_DIGITS = 5 实际允许最多 4 位有效数字;quantum = 1E-40 能通过 guard,quantize 照样抛 InvalidOperation 作者 TODO 承认魔数 2 应改为 tolerance_multiplier 倒数 interpolate.py:35,58,377-381,392
负零:-1 * 0.00(-0.001).quantize(0.01)-0.00 str() 输出 -0.00== 0 为 True 拦不住 无(非测试代码 grep 无 is_signed/copy_abs 实测;见下方 (i)
str(Decimal("0.00000001")) = '1E-8' adjusted exponent < -6 时 str() 自动切科学计数法 有推断小数位时格式串是 f 型,无小数位时 :403 退回通用格式、同样输出 1E-8 display_context.py:372,403,405,451
【文档漂移】(1) number.py:5-6 要求从 beancount.core.amount 导入 Decimal,但 amount.py:16 只是顺带 from decimal import Decimalnumber.py:19 自己也直接从 decimal 导入;(2) parser.py:3-8 说缺失值是 'NA':25-28 才说是 MISSING;(3) display_context.py:322-323 注释 "1 billion"(对应 +9)而代码是 +12;(4) MISSING 注释称"会出现在错误里",裸算术 MISSING + 1TypeError 只显示 'type',名字只在带 repr() 的断言里可见;(5) infer_quantum_from_list docstring 说接受 float 实则抛 AttributeError 按注释找代码会找不到 number.py:5-6,19,28-30,107,174-175amount.py:16,59parser.py:3-8,25-28display_context.py:322-330

Decimal 语义实测

验证环境 Python 3.14.6,默认 getcontext().prec == 28

(a) 构造精度只由输入决定,context.prec 只影响运算。

>>> with localcontext() as c:
...     c.prec = 2
...     print(Decimal("0.1122334455"), Decimal("0.1122334455") + 0)
...
0.1122334455 0.11

beancount 测试 number_test.py:16-26 (test_formatting) 引用了官方文档同一句话。

(b) 0.10 == 0.1 为 True,但 exponent 不同;normalize() 会丢掉它。

>>> Decimal("0.10") == Decimal("0.1"), Decimal("0.10").as_tuple().exponent, Decimal("0.1").as_tuple().exponent
(True, -2, -1)
>>> Decimal("100.00").normalize()
Decimal('1E+2')

exponent 的消费点:interpolate.py:185-188(交易容差 = ONE.scaleb(expo) * tolerance_multiplier,multiplier 默认 0.5,:130-131)、ops/balance.py:34-41(Balance 容差)、display_context.py:204-205(显示精度众数统计)。

(c) quantize 超出 precInvalidOperation,不是截断。

>>> with localcontext() as c:
...     c.prec = 4
...     Decimal("12345.678").quantize(Decimal("0.01"))
...
decimal.InvalidOperation: [<class 'decimal.InvalidOperation'>]

beancount 唯一的防御是 display_context.py:321-331localcontextinterpolate.py:383-391 只在注释里引用了这条规则,其 guard 防不住。

(d) 默认舍入 ROUND_HALF_EVEN;beancount 从未改动;str.format 不接受 rounding 参数。

>>> getcontext().rounding, Decimal("2.345").quantize(Decimal("0.01")), Decimal("0.125").quantize(Decimal("0.01")), "{:.2f}".format(Decimal("2.345"))
('ROUND_HALF_EVEN', Decimal('2.34'), Decimal('0.12'), '2.34')
>>> Decimal("2.345").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
Decimal('2.35')
>>> "{:.2f}".format(Decimal("2.345"), rounding=ROUND_HALF_UP)   # 关键字被静默忽略
'2.34'
>>> with localcontext(rounding=ROUND_HALF_UP): "{:.2f}".format(Decimal("2.345"))
'2.35'

(e) Decimal(float) 原样保留二进制展开,且 '{:.2f}' 会把它藏起来。

>>> Decimal(3.30)
Decimal('3.29999999999999982236431605997495353221893310546875')
>>> Decimal(3.30).as_tuple().exponent, Decimal(3.30) == Decimal("3.30"), "{:.2f}".format(Decimal(3.30))
(-50, False, '3.30')
>>> Decimal(str(3.30)), Decimal(True)
(Decimal('3.3'), Decimal('1'))

exponent 为 -50 意味着按 (b) 推断出的容差是 ONE.scaleb(-50) * 0.5 = 5E-51,等价于无容差。

(f) D("") 在 beancount 返回新对象,与 ZERO 相等但不同一。

>>> D("") == ZERO, D("") is ZERO
(True, False)

(g) Decimalfloat 可以比较(按精确值),但不能做算术。

>>> Decimal("0.1") < 0.1, Decimal("0.5") == 0.5, Decimal("0.1") == 0.1
(True, True, False)
>>> Decimal("0.1") + 0.1
TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'

beancount 依赖前者:number.py:111-114 (auto_quantized_exponent) 直接拿 float 阈值与 Decimal 比较。

(h) 除法吃满 prec,并受舍入模式影响。

>>> Decimal("10.00") / Decimal("3"), (Decimal("10.00") / Decimal("3")).as_tuple().exponent
(Decimal('3.333333333333333333333333333'), -27)

对应 grammar.py:918-919@@ 总价折单价)、position.py:368

(i) 负零:乘负数与舍入都会产生,加零可消除,== 看不出。

>>> Decimal("-1") * Decimal("0.00"), Decimal("-0.001").quantize(Decimal("0.01")), -Decimal("0.00")
(Decimal('-0.00'), Decimal('-0.00'), Decimal('0.00'))
>>> Decimal("-0.00") == 0, Decimal("-0.00").is_signed(), Decimal("-0.00").copy_abs(), Decimal("-0.00") + 0
(True, True, Decimal('0.00'), Decimal('0.00'))

(j) str() 在 adjusted exponent < -6 时切科学计数法,format(…, "f") 不会。

>>> str(Decimal("0.00000001")), format(Decimal("0.00000001"), "f"), str(Decimal("0E-8"))
('1E-8', '0.00000001', '0E-8')

(k) Python Decimal() 默认接受的写法,以及 \d 的全角陷阱。

>>> Decimal("1_000"), Decimal("123.45"), Decimal(".5"), Decimal("5."), Decimal(" 5 ")
(Decimal('1000'), Decimal('123.45'), Decimal('0.5'), Decimal('5'), Decimal('5'))
>>> bool(re.match(r"\d", "1")), bool(re.match(r"[0-9]", "1"))
(True, False)

MISSING / None / ZERO 三态

语义 典型来源 在哪里被区分
MISSING(类对象) 用户留空、要求系统推算 grammar.y:446-450(无金额 posting → units 为 MISSING_OBJ);:636-642 (maybe_number):644-650 (maybe_currency)@ 后空 → Amount(MISSING, MISSING){100 # USD}number_total=MISSING);grammar.py:518-519(裸 {}CostSpec(MISSING, None, MISSING, None, None, False))与 :583-587;docstring 例子 parser.py:32-33,37-38,50-51,73-74 booking_full.py:830-837,852-853 (interpolate_group)is MISSING 收集待插值项;:730 (compute_cost_number) if MISSING in (number_per, number_total): return None:348-354,366 (categorize_by_currency) 同时判 is not MISSING and is not Nonegrammar.py:177 (_dcupdate) 跳过 MISSING 币种
None 该成分在输入里根本不存在,不需要推算 parser.py:44-45(无 @price = None);:78-79{100 USD}number_total=None booking_full.py:509units is MISSING or units is None 才补币种);amount.py:49 允许 type(None)
ZERO 明确的数值 0 position.py:342-364 (from_string)(工具解析器,非账本语法:成本表达式里 per_number/total_number 匹配为空串时置 ZERO position.py:117-123 (is_total_cost_spec)number_per == ZERO and isinstance(number_total, Decimal) 判"纯总价写法";position_test.py:231-239 对照 CostSpec(ZERO, …) 打印 {{202.46 USD}}CostSpec(MISSING, …) 打印 {# 202.46 USD}

parser.py:1-108 的 docstring 共 12 组 INPUT 例子,第 27-28 行明确 "MISSING should never appear in completed, loaded transaction postings"。两条不变量支撑这套三态:完成态不得残留 MISSING,以及判缺失只用 is MISSING、不用真值判断或 == None(因为 None 在同一个字段上有另一个含义,MISSING 又不是假值)。

测试锁定了什么

number_test.py 160 行、3 个类共 11 个测试,其中 6 个属于 TestInferQuantization

测试 行号 锁定的行为
TestDecimalPrecision.test_formatting 17-26 prec=2localcontextD("0.1122334455")str() 仍是全部位数
TestToDecimal.test_ZERO 30-31 ZERO == Decimal("0")
test_D 33-41 Decimal 透传、纯数字串、千分位逗号、"- 122.34" 靠删空格通过、D("")D(None) 都等于 Decimal()
test_round_to 43-52 增量 0.0110、正负各两例;-135.12345-135.12987 都得 -135.12,把"向零截断"钉死
test_same_sign 54-65 四种符号组合;ZERO 与正数同号、与 ZERO 同号,与负数不同号
test_infer_quantization_none 74-96 10 个由 float 直接转来的 Decimal 推不出量化,期望 exponent -21
test_infer_quantization_one 98-101 整数加千分之一量级扰动的一组数,期望 0
test_infer_quantization_normal 103-107 exp 取 2/3/5,TEN**exp 分别得 0.01/0.001/0.00001
test_infer_quantization_under 109-129 11 个 30 位小数的价格串,结果是 1e-29——算法在这组输入上没能收敛
test_infer_quantization_under3 131-139 只调用不断言,防崩溃的回归用例
test_auto_quantize 141-156 1135.109998 在阈值 0.01 与 0.02 下都得 1135.111135.102399 在 0.01 下得 1135.1024、在 0.001 下原样返回

setUp:69-72)用 random.uniform 造 100 个随机数,所以 test_infer_quantization_one/_normal 每次跑的输入都不同。

没有测试覆盖的行为D(float)D(True)D(object())(含 python -O 下静默返回 None)、任何非法字符串、D(int);常量只测了 ZEROMISSINGNUMBER_REnum_fractional_digitsauto_quantized_exponent 在本测试文件里完全没有出现。

演变史

日期 提交 / 记录 变化
2015-07-16 b6b132ae 加入 NUMBER_RE 常量;_CLEAN_NUMBER_RE 允许符号与数字之间有空格,D("- 122.34") 因此合法
2015-12-12 90a31c76 检测到 Python 自带的 decimal 不是 C 实现时输出警告
2016-01-10 b3246233CHANGES:2726-2740 修 issue #96:内置 decimal 是纯 Python 实现时,改用显式安装的 cdecimal
2016-10-30 CHANGES:1829-1831 删掉 amount.py 里的旧兼容代码,记载 D() 此前已从 amount 迁到 number
2019-04-27 8f53d9ee 澄清 D() 的注释
2020-06-10 efd7339d 删除 cdecimal 回退:最低支持版本升到 Python 3.5
2020-06-10 092a099d 全仓库改为直接 from decimal import Decimal;提交信息说此前的间接层"并没有按预期工作"
2020-12-28 7ddfde03 新增 infer_quantum_from_list
2020-12-28 47bfa2fa 加固并修正 auto_quantize 系列
2024-11-09 a9fd82de 补类型注解并加 py.typed 标记
2024-06-16 48a311a02a455c79 全仓库 ruff 格式化

模块 docstring 里"从 beancount.core.amount 导入 Decimal"这条建议是 D() 迁移前的遗留,092a099d 之后已无实际约束力。

与其他模块的关系

参考索引

commit:基线 97472138(2026-08-22);4c1e87bb(2020-11-25,Fixed #584);c9132bba(2025-12-21,改为 +12 并加 NOTE)。

beancount/core/number.py:1-11 模块 docstring;5-6 漂移句;8-9 Prefer D();19 from decimal import Decimal;22-25 常量;28-32 MISSING;36 NUMBER_RE;38 _CLEAN_NUMBER_RE;41-71 D();44 importer 用途;46 "never use floating-point";58-65 各分支;66-67 assert 分支;68-71 ValueError 包装;74-83 round_to;86-95 same_sign;98-120 auto_quantized_exponent;107 normalize();111-114 float 阈值与 Decimal 比较;123-148 auto_quantize;144 TEN**exponent;151-159 num_fractional_digits;162-184 infer_quantum_from_list;174-175 float 声明。

beancount/core/number_test.py:16-26 test_formatting;30-31 test_ZERO;33-41 test_D;43-52 test_round_to;54-65 test_same_sign;68-156 TestInferQuantization。

beancount/core/amount.py:16 import;40 Optional[Decimal];49 valid_types_number;59 构造 assert;71-76 to_string;135-151 from_string。

beancount/core/interpolate.py:30 MAXIMUM_TOLERANCE;35 MAX_TOLERANCE_DIGITS;46-58 is_tolerance_user_specified;97 infer_tolerances;130-131 multiplier 默认 0.5;185-188 exponent → 容差;364-394 quantize_with_tolerance。

beancount/core/display_context.py:201-205 小数位统计;298-331 quantize;319 裸 Decimal(1);321-331 localcontext;330 prec + 12;372、403、405、451 格式串。

beancount/parser/lexer.l:220-221 PLUS/MINUS;282 NUMBER 正则。tokens.c:9-53 validate_decimal_number;19-26 逗号规则;28-31 仅 isdigit 拷贝;55-67 pydecimal_from_cstring。decimal.h:26 PyDec_FromCString。grammar.y:338-346 number_expr;446-450 无金额 posting;636-642 maybe_number;644-650 maybe_currency。

beancount/parser/parser.py:1-108 docstring;3-8 'NA' 漂移;25-28 MISSING 不变量。grammar.py:177 _dcupdate;519、587 CostSpec(MISSING, …);918-919 总价折单价除法。options.py:70、89、538、553。

其它convert.py:100-101prices.py:112,131,240,333,372position.py:117-123,342-364,368ops/balance.py:20-41plugins/check_average_cost.py:35,58-59inventory.py:186-198,381,402-437scripts/example.py:836,842,1039,1584data.py:557,589,591projects/export.py:162