目录

01 · amount.py:Amount,金额 = 数值 + 币种

核对基线 · 范围 · 依赖

核对基线:beancount 仓库 commit 97472138(2026-08-22)。路径相对仓库根目录,引用格式 文件:起-止行 (名称),行号已逐条核对。 本篇范围beancount/core/amount.py(267 行)与 beancount/core/amount_test.py(176 行)。 上游依赖beancount/core/number.pyDZEROMISSING,见 02 篇)、beancount/core/display_context.pyDEFAULT_FORMATTER)。 下游使用者:24 个非测试文件导入本模块;Posting.units/Posting.priceOptional[Amount]beancount/core/data.py:232,234),Balance.amount:201)与 Price.amount:394)是 Amount

这一篇会把 amount.py 的代码一段段贴出来读。代码是 beancount 的原文;代码里的中文注释是本文加的。 少数重复出现的报错文案缩写成 "..." 并标注,其余一字未改。每段代码后面跟一段讲解,说这段在干什么、为什么这么写。

不懂编程也能读下去——先花两分钟看下面五个词。

读之前:五个词

amount.py 里这两种报错方式都用了,而且分得很清楚——后面会看到。

一、这个模块要解决什么问题

账本上写一个 10,这个数字自己是没有意义的:10 块钱?10 股股票?10 欧元?

如果记账软件允许一个孤零零的数字在系统里流动,迟早会有人把 10 美元和 10 人民币加在一起得出 20。这个错误不会报警,它会静静地躺在账本里,直到某天报表对不平,你也查不出是哪一步错的。

amount.py 的对策很直接:不给你写裸数字的机会。这个模块定义了一个叫 Amount(金额)的类型,它永远是"数值 + 币种"两样东西绑在一起,拆不开、造好之后也改不了。整个 beancount 里所有的钱都是这个类型:账本解析出来的每个金额、持仓的数量、账户的余额,全都是它。

它是最底层的一块砖,只有 267 行。

二、金额长什么样:绑死的两个字段

amount.py:40-50

class Amount(NamedTuple("Amount", [("number", Optional[Decimal]), ("currency", str)])):
    # 定义一个叫 Amount 的类,它由两个字段组成:
    #   number   数值,类型是 Decimal(精确小数,见 02 篇)
    #            Optional 的意思是"允许没有"
    #   currency 币种,类型是 str(一串字符,比如 "USD")
    # NamedTuple 是 Python 自带的"带名字的元组",特点是造好之后字段不能改

    __slots__ = ()  # 关掉"往实例上随便挂新东西"的能力

    valid_types_number = (Decimal, type, type(None))
    valid_types_currency = (str, type, type(None))
    # 这两行列出构造时允许传进来的类型,下一段会讲

第一行的信息量最大:币种不是可选的附加说明,它是金额的一半。 没有币种的金额在这套系统里根本造不出来。

__slots__ = () 这行的作用是堵一个后门。Python 默认允许你给任何对象临时挂点东西上去,比如 某金额.备注 = "这笔要报销"。这看着方便,但会让"金额"这个概念慢慢长胖、变成一个谁都能往里塞东西的口袋。加了这一行,这种写法直接报错。

配合 NamedTuple 本身的只读特性,Amount 就成了一个造好即冻结的东西:某金额.number = 20 报错,某金额.currency = "CNY" 报错,挂新属性也报错。

为什么非要冻结?因为同一个金额对象会被很多地方同时持有——持仓记着它、余额表引用它、它还可能是字典的键。如果谁都能就地改它,一个地方改了,所有持有者手里的数都跟着变,而且查不出是谁改的。冻结之后这类问题从根上消失了,代价是每次"修改"都要造新对象——而这个代价换来了别处的收益:因为对象不会被改,Inventory(库存容器,04 篇)就不必为了安全而到处复制数据,当年这一项让它快了 18%。

三、造一个金额时的检查

amount.py:52-61

def __new__(cls, number: Decimal, currency: str) -> Amount:
    # __new__ 是构造函数:每次造一个新的 Amount 都会先走这里

    assert isinstance(number, Amount.valid_types_number), repr(number)
    # 自查:数值必须是上面列的允许类型之一,否则当场崩溃

    assert isinstance(currency, Amount.valid_types_currency), repr(currency)
    # 自查:币种同理

    return super().__new__(cls, number, currency)
    # 检查通过,交给 NamedTuple 真正造出这个对象

值得琢磨的是允许的类型清单里那个 type

valid_types_number = (Decimal, type, type(None))
#                              ^^^^ 这一项的意思是"任何类本身"

为什么数值那一栏允许放一个"类"进去?这是给 MISSING 留的门。

账本里可以故意不写某个数字,让系统自己推算。比如你写"从工资账户转出 500,存进储蓄账户",第二行的金额可以不写——它必然是 500。解析器读到这种留白时,需要一个东西来占位,表示"这里有个数,但现在还不知道"。beancount 用的占位符就叫 MISSING,它被定义成一个类(02 篇会讲为什么是类而不是 None)。

于是就有了这个安排:构造的时候放行,运算的时候拦截。解析器可以造出 Amount(MISSING, "USD") 这种半成品,让它在解析和补全之间流转;但等到真要拿去做加减乘除时,第六节那些函数会用断言把它挡住,而且因为 MISSING 是个类,报错信息里会明明白白印出 MISSING 这个词,一眼看出问题在哪。

代价也在这里:isinstance(x, type)任何类都返回真,所以 Amount(int, "USD") 这种明显的错误也能造出来,构造这一层其实拦不住什么。真正的把关在算术函数里。

四、怎么变成人看得懂的字符串

amount.py:63-87

def to_string(self, dformat: DisplayFormatter = DEFAULT_FORMATTER) -> str:
    # self 就是"这个金额自己"
    # dformat 是格式器,规定小数点后留几位、要不要千分位逗号
    #   不传就用默认的那个

    if isinstance(self.number, Decimal):
        number_fmt = dformat.format(self.number, self.currency)
        # 正常情况:数值是精确小数,交给格式器渲染

    elif self.number is MISSING:
        number_fmt = ""
        # 数值是"待推算"的占位符:渲染成空白,什么都不印

    else:
        number_fmt = str(self.number)
        # 其它情况(比如 None):原样转成字符串

    return "{} {}".format(number_fmt, self.currency)
    # 拼成 "数值 币种",比如 "100.00 USD"

def __str__(self) -> str:
    return self.to_string()
    # 用 print 打印这个金额时,走上面的默认渲染

__repr__ = __str__
# 调试时看到的样子,和打印出来的样子完全一致

这里有一个决定值得注意:金额自己不决定小数点后留几位。

同一本账里,同一个币种的数字写法常常不统一——你可能写 10.510.5010.5000。如果每个金额各印各的,报表那一列就参差不齐。beancount 的做法是先把整本账扫一遍,统计出"美元这个币种在这本账里通常写两位小数",再拿这个统计结果去渲染每一个美元金额(这是 06 篇 display_context.py 的活)。

所以 to_string 要接一个"格式器"参数:它是那次统计的产物。金额是原料,格式器是模具,金额自己不知道该被压成什么形状。

中间那个 MISSING 分支的效果有点意思:数值渲染成空白,但最后一行照样拼上币种,结果是 " USD"——一个前导空格加币种。这正好和账本里"只写币种不写数字"的原文长得一样,打印出来还能再喂回解析器。

最后 __repr__ = __str__ 这行:Python 里一个对象通常有两种展示,一种给人看(100 USD),一种给程序员调试看(Amount(number=Decimal('100'), currency='USD'))。beancount 把两者合成了一个,调试时看到的也是 100 USD。好处是日志和报错读起来不费劲,代价是看不到内部结构。

五、相等、排序、真假

这一节的四个函数都很短,一起看。amount.py:89-122

def __bool__(self) -> bool:
    return self.number != ZERO
    # 当代码里写 if 某金额: 时,问的是"这笔钱非零吗"

def __eq__(self, other):
    # 判断两个金额相不相等
    if other is None:
        return False
        # 和"什么都没有"比,直接不相等
    return (self.number, self.currency) == (other.number, other.currency)
    # 数值和币种两样都一样,才算相等

def __lt__(self, other):
    return sortkey(self) < sortkey(other)
    # 谁排前面:交给下面的 sortkey 决定

def __hash__(self):
    return hash((self.number, self.currency))
    # 算"指纹",让金额能当字典的键、能放进集合
    # 指纹同样由数值和币种共同决定

sortkeyamount.py:160-168,只有一行:

def sortkey(amount: Amount) -> tuple[str, Decimal | None]:
    return (amount.currency, amount.number)
    # 排序时先比币种(字母顺序),币种相同再比数值

三点值得说:

第一,if 某金额: 问的是"非零"而不是"存在"。 元组默认的真假判断是"里面有没有东西",Amount 把它改成了"数值是不是零"。这更贴近记账的直觉:一笔零元的交易,业务上等于没有。

第二,相等和指纹用的是同一组字段,这不是巧合而是必须的。Python 有个规矩:两个东西如果相等,它们的指纹必须也相同,否则字典和集合会出乱子。币种参与指纹计算,所以 100 USD100 CAD 在字典里是两个不同的键——这正是我们要的。

第三,100 USD100.00 USD 是相等的。 因为底层的 Decimal 判断相等时不看小数位数(02 篇细讲)。也就是说,你写了几位小数,不构成这笔钱的身份——它只影响打印出来好不好看,不影响它是不是同一笔钱。

排序规则是"先币种后数值":一堆混币种的金额排出来,会按币种字母序分成几组(CAD、EUR、USD),每组内部再按数值从小到大。报表里同币种的数排在一起,这样才读得下去。

六、加减乘除:为什么是函数,不是运算符

Python 允许你让自定义类型支持 +,写成 金额甲 + 金额乙。beancount 故意没这么做。源码里留了一段大白话注释(amount.py:154-157):

# Note: We don't implement operators on Amount here in favour of the more
# explicit functional style. This should all be LISP anyhow. I like dumb data
# objects with functions instead of objects with methods... alright, this is
# okay.
#
# 译:这里不给 Amount 实现运算符,改用更显式的函数式风格。
#    反正这些本来就该用 LISP 写。比起"带方法的对象",我更喜欢
#    "笨数据 + 函数"这种搭配……好吧,这样就行了。

于是所有运算都是独立的函数。先看加法,amount.py:207-227

def add(amount1: Amount, amount2: Amount) -> Amount:
    # 收两个金额,还回一个新金额

    assert isinstance(amount1.number, Decimal), (
        "Amount1's number is not a Decimal instance: {}".format(amount1.number)
    )
    assert isinstance(amount2.number, Decimal), (
        "Amount2's number is not a Decimal instance: {}".format(amount2.number)
    )
    # 自查两次:两个数值都必须是真正的精确小数
    # 如果谁还是"待推算"的占位符,这里当场崩溃,
    # 崩溃信息里会印出那个值,一眼看出是哪个半成品被误用了

    if amount1.currency != amount2.currency:
        raise ValueError(
            "Unmatching currencies for operation on {} and {}".format(amount1, amount2)
        )
    # 币种不一样就报错退出。注意:不换汇,也不勉强相加

    return Amount(amount1.number + amount2.number, amount1.currency)
    # 走到这说明币种相同:数值相加,币种照抄,造一个全新的金额还回去

这个函数把本篇开头讲的两种报错方式用在了两个不同的地方,分得很清楚:

而更值得看的是它没做的三件事:不换汇(汇率换算是 convert.py 的职责,05 篇)、不返回一个"失败"的空值让你误以为成功、不静默地把两个数加起来。10 美元加 10 人民币在这里是一声报错,而不是 20。

减法(sub)和加法一模一样,只是最后一行的 + 换成 -。乘除法则不同——它们的第二个参数不是金额,是个纯数字。amount.py:171-186

def mul(amount: Amount, number: Decimal) -> Amount:
    # 注意第二个参数是纯数字,不是金额
    assert isinstance(amount.number, Decimal), "..."   # 报错文案略,同 add
    assert isinstance(number, Decimal), "..."          # 报错文案略
    return Amount(amount.number * number, amount.currency)
    # 数值相乘,币种照抄

这个签名本身就是一句设计声明:"10 美元 × 3" 有意义,"10 美元 × 5 欧元" 没有意义。 乘数必须是一个不带币种的纯数(比如份数、税率),所以类型上就不给你传金额的机会。

div(除法)同理,但有个细节:它不对结果做任何取整,10.00 USD 除以 3 得到的是一个 27 位小数的数。要不要取整、按什么精度取,那是 07 篇 interpolate.py 的事。

剩下两个:

def abs(amount: Amount) -> Amount:      # 取绝对值,amount.py:253-264
    assert isinstance(amount.number, Decimal), "..."   # 报错文案略
    return amount if amount.number >= ZERO else Amount(-amount.number, amount.currency)
    # 本来就非负:原样还回去,连新对象都不造(反正它不可变,安全)
    # 是负数:造一个数值取反的新金额

def __neg__(self) -> Amount:            # 取负,amount.py:124-132
    assert isinstance(self.number, Decimal), "..."     # 报错文案略
    return Amount(-self.number, self.currency)

__neg__ 是唯一一个例外——它是运算符重载,所以你可以直接写 -某金额。取负在记账里太常用了(复式记账的每一笔都有正有负),这一个破例是值得的。

七、从字符串造金额:一个只给测试用的小解析器

amount.py:134-151

@staticmethod
def from_string(string: str) -> Amount:
    """... This is a miniature parser used for building tests. ..."""
    # docstring 自己写明:这是个给测试用的迷你解析器

    match = re.match(
        r"\s*([-+]?[0-9.]+)\s+({currency})".format(currency=CURRENCY_RE), string
    )
    # 用正则去套这个字符串,看它是不是"数字 空格 币种"的形状

    if not match:
        raise ValueError("Invalid string for amount: '{}'".format(string))
        # 套不上就报错

    number, currency = match.group(1, 2)
    return Amount(D(number), currency)
    # 套上了:把数字那半交给 D() 转成精确小数(02 篇),拼成金额

模块最后一行给它起了个极短的别名(amount.py:267):

A = from_string = Amount.from_string

所以测试代码里满眼都是 A("100 USD")——这比 Amount(D("100"), "USD") 好写太多。

这里要强调一件事,免得误会:这个函数不是账本的解析路径。 你写在 .beancount 文件里的金额,是由一个 C 语言写的词法器和语法层解析的(08 篇),不走这里。这个小解析器只是为了让几千行测试代码写起来不那么痛苦,所以它相当糙——比如它只检查字符串开头,"100 USD 后面一堆垃圾" 也能匹配成功,多余的部分直接无视。

八、这一篇讲了什么

回头看,267 行代码翻来覆去只在坚持几件事:

  1. 钱必须带币种,从造出来的那一刻起,拆不开。
  2. 造好就不能改,要变化只能造新的。省掉了一整类"谁改了我的数据"的问题,还顺带让下游省掉了防御性拷贝。
  3. 半成品可以存在,但不能参与运算。构造时放行占位符,让解析和补全阶段有周转的余地;算术函数用断言把它们挡在门外。
  4. 跨币种运算一律报错,绝不猜、绝不静默处理。换汇是别的模块的职责。
  5. 该崩的崩,该报错的报错,两种失败分得清清楚楚:程序 bug 用断言,用户数据问题用异常。
  6. 自己不管显示格式,交给统计过整本账的格式器。

这些不是什么高深技巧,就是把"钱这个东西不能含糊"这句话,一条条落实到了类型上。后面 17 篇里的东西——持仓、库存、配平、补全——全都建立在这块砖上。


技术版:结构表、行号、边界行为、演变史(点开)

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

结构一览

名称 位置 作用
CURRENCY_RE amount.py:30-37 币种代码的正则,注释要求与 C 词法器手动保持同步
class Amount(NamedTuple(...)) amount.py:40-151 两字段不可变记录:number: Optional[Decimal]currency: str
Amount.__new__ :52-61 构造时用 assert isinstance 做类型把关
Amount.to_string / __str__ / __repr__ :63-87 渲染成 "<number> <currency>"MISSING 渲染为空
Amount.__bool__ / __eq__ / __lt__ / __hash__ :89-122 真值、相等、排序、哈希
Amount.__neg__ :124-132 取负,唯一被重载的算术运算符
Amount.from_string :134-151 测试用的迷你解析器
sortkey :160-168 排序键 (currency, number),先按币种再按数值
mul / div / add / sub / abs :171-264 顶层函数式运算接口
A = from_string = Amount.from_string :267 别名,测试里大量使用 A("100 USD")

不可变的实现细节

amount_test.py:26-40 (test_mutation) 锁定三种写法都抛 AttributeError,但来源不同:改 currency、改 number 是 namedtuple 字段本身只读的效果,新增属性那条才由 __slots__ = () 保证(没有 __slots__ 的 NamedTuple 子类可以挂新属性)。

不可变是有意的全局决定,不只是 Amount 一处:2016-12-10 的变更把 AmountPostingCostCostSpec 一起改成 NamedTuple 并删掉可变方法 Posting.add()CHANGES:1596-1600)。收益在下游兑现:Inventory 依赖 Position 不可变而免去防御性拷贝,提速 18%(CHANGES:1003-1005)。2024-12-22 的 commit 3a17e762 把中间类 _Amount 去掉,改成直接继承 NamedTuple(...) 表达式,行为不变。

构造断言的放行范围

类型注解写的是 Optional[Decimal]:40),但构造断言额外放行 type,也就是任何"类对象"。这是为 MISSING 留的口子:MISSINGbeancount/core/number.py:28-32 被定义成一个类而不是实例或 Nonecurrency 同样可以是 MISSING:账本写 2 MXN @ 时解析结果是 Amount(MISSING, MISSING)beancount/parser/parser.py:50-51)。amount_test.py:17-24 (test_constructor) 用一个 Dummy 类同时塞进两个字段,注释明说 "This is used when creating incomplete objects"。

代价是断言按 isinstance(x, type) 放行,任何类都能过,例如 Amount(int, "USD") 不会报错;把关不在构造层,而在算术函数。

币种命名规则与词法层同步

# A regular expression to match the name of a currency.
# Note: This is kept in sync with "beancount/parser/lexer.l".
CURRENCY_RE = "|".join(
    [
        r"[A-Z][A-Z0-9\'\.\_\-]*[A-Z0-9]?\b",
        r"/[A-Z0-9\'\.\_\-]*[A-Z](?:[A-Z0-9\'\.\_\-]*[A-Z0-9])?",
    ]
)

amount.py:30-37。规则来自 beancount/parser/lexer.l:166-196 的注释:以大写字母或 / 开头、至少含一个字母、以字母或数字结尾、中间可含 . _ - '。合法例:AAPLVNT.TOTLT_040921C144/6J/NQH21;非法例:/6.3(斜杠开头后面必须含字母)、CAC_(不能以特殊字符结尾)。两侧各自维护一份正则,lexer.l:174amount.py:31 互相提醒 "kept in sync"。

两份正则并不逐字相同。flex 规则 [A-Z][A-Z0-9\'\.\_\-]*[A-Z0-9]lexer.l:203)至少两个字符,单个大写字母在词法层是 CAPITAL token,由语法层判定是 flag 还是币种(lexer.l:176-179);Python 侧的 CURRENCY_RE 把末位写成可选的 [A-Z0-9]?,直接接受单字母,amount_test.py:59-60 注释 "Starting in v3 we will accept single character stock names"。/ 开头的期货代码和单字母币种都是 2021-03-20 的 commit d2d0a35e 引入的语法扩展。

CURRENCY_RE 在本模块只被 from_string 使用(:145-147);构造 Amount 时不校验币种字符串是否符合它,只查类型。

渲染与 DisplayFormatter

默认参数 DEFAULT_FORMATTER 是一个没喂过任何数字的上下文构建出来的(beancount/core/display_context.py:499-500),效果是原样输出:amount_test.py:62-71 (test_tostring)100034.023 USD 默认不加逗号,用 build(commas=True) 的 formatter 才输出 100,034.023 USD

MISSING 分支渲染为空串,末行仍是 "{} {}".format("", currency):77),所以实际输出是带前导空格的 " USD"。这个分支来自两次修复:2018-05-28 commit e3b92243 "Don't fail on rendering a MISSING",2021-02-28 commit 8fa08c87 "printer: Render MISSING as empty in Amount"。numberNone 时走 else 分支,输出 "None USD"

运算接口一览

函数 位置 签名 约束
mul(amount, number) :171-186 Amount × Decimal → Amount 两个参数都 assertDecimal,标量不能是 Amount
div(amount, number) :189-204 Amount ÷ Decimal → Amount 同上;结果不做量化,Decimal 除法带满上下文精度(默认 28 位有效数字)
add(a1, a2) :207-227 Amount + Amount → Amount 两边 numberassertDecimal;币种不同 raise ValueError("Unmatching currencies for operation on {} and {}")
sub(a1, a2) :230-250 Amount − Amount → Amount add
abs(amount) :253-264 Amount → Amount 非负时返回原对象本身,负数时新建;与内置 abs 同名
Amount.__neg__ :124-132 −Amount 唯一的运算符重载,assertDecimal

核心代码里的调用点:beancount/ops/balance.py:154beancount/ops/pad.py:101amount.sub 算余额差额)、beancount/plugins/sellgains.py:122amount.mul)。

设计决策与理由

决策 理由 证据
数值与币种绑成一个不可变值对象 作为 Position.units、字典 key、集合元素被多处共享,原地修改会污染所有持有者;不可变换来免拷贝的性能 amount.py:47amount_test.py:26-40CHANGES:1596-1600,1003-1005
不重载算术运算符,用顶层函数 作者偏好"哑数据 + 函数",参数类型更显式(乘数是 Decimal 标量而非 Amount) amount.py:154-157
add/sub 币种不同即抛异常 静默相加会掩盖借贷不平衡;换算是 convert 模块的职责 amount.py:223-226,246-249amount_test.py:158-159,166-167
构造断言放行类对象(为 MISSING 解析器输出的不完整对象需要占位;MISSING 是类,报错可见 amount.py:49number.py:28-32amount_test.py:17-24
渲染委托给 DisplayFormatter 小数位和对齐由整本账的统计决定,不是单个金额能知道的 amount.py:63-72display_context.py:499-500
币种规则在 Python 与 C 两侧各维护一份 词法器是 flex/C,Python 侧测试与 from_string 需要同一规则;无法共享定义只能靠注释同步 amount.py:31lexer.l:174
__eq____hash__ 用同一组字段 Python 契约:相等的对象哈希必须相等;币种参与哈希才能让 100 USD100 CAD 成为不同 key amount.py:96-122amount_test.py:81-87
__bool__ 定义为"非零" if amount: 表达"有金额",而不是元组默认的"非空" amount.py:89-94

行为细节与边界

现象 后果 证据
构造断言用 isinstance(x, type),任何类都能过 Amount(int, "USD") 不报错;类型把关实际在算术函数 amount.py:49,59amount_test.py:17-24
构造用 assert 做类型检查 python -O 下断言被剥离,构造层不再校验类型 amount.py:59-60
numberMISSINGbool(amount)True bool(amount) 为 True 不代表 numberDecimal;beancount 判断完整性用 is MISSINGbeancount/parser/booking_full.py:830,852 amount.py:94
Decimal 相等不看 exponent Amount(D("100"), "USD") == Amount(D("100.00"), "USD"),哈希也相等;小数位数不构成身份 amount.py:106,122;Python Decimal 语义
__eq__ 对非 Amount 对象直接取属性 与元组、字符串比较抛 AttributeError,不是返回 False amount.py:104-106
to_stringNone 分支输出 "None USD" None 在 beancount 里表示该字段未提供(beancount/parser/parser.py:44-45),正常渲染路径不会走到这个分支 amount.py:75-76
div 不量化 div(A("10.00 USD"), D("3")) 的 number 带 27 位小数 amount.py:204
abs 非负时返回同一对象 amount.abs(a) is a 可能为 True,依赖不可变才安全 amount.py:264
模块级 abs 与内置函数同名 amount.py 内或 from beancount.core.amount import * 后,abs(某个 Decimal) 会走 Amount 版本,因 Decimal 没有 .number 属性而抛 AttributeError amount.py:253,261-263
from_string 只锚定开头 尾部多余内容被忽略;只用于测试所以未处理 amount.py:145-147
只重写了 __lt__>/>=/<= 走元组默认比较 元组比较先比 number 再比 currency,与 __lt__ 的先币种后数值相反:Amount(D("3"), "EUR") < Amount(D("1"), "USD")True,而 Amount(D("1"), "USD") > Amount(D("3"), "EUR")False amount.py:108-115NamedTuple 继承 tuple 的比较方法
numberMISSING 的 Amount 参与排序 sortkey 元组里第二项是类对象,与 Decimal 比较抛 TypeError amount.py:115,168
【文档漂移】__lt__ docstring 说用于 Position 排序键 Position.sortkey 自己拼元组,不经过 Amount.__lt__amount.sortkey 在核心代码里的实际调用点是 realization.py:648 amount.py:109position.py:239-256
【文档漂移】__new__ 参数注解 number: Decimal、字段注解 Optional[Decimal] 都窄于实际放行的 (Decimal, type, NoneType);类型检查器看不到 MISSING 这条路径 amount.py:40,52,49

测试锁定了什么

amount_test.py 共 14 个测试,一个类 TestAmountamount_test.py:12):

测试 行号 锁定的行为
test_constructor 13-24 接受千分位逗号字符串经 D();两个字段可以是任意类对象
test_mutation 26-40 改已有属性、新增属性都抛 AttributeError
test_fromstring 42-60 与直接构造相等;8 位小数 BTC;前后空白;缺数字或缺币种抛 ValueError;单字母币种
test_tostring 62-71 默认渲染不加逗号;build(commas=True) 加逗号
test_comparisons 73-79 同值相等、不同值不等
test_hash 81-87 可作 dict key / set 元素;同数值不同币种是两个 key
test_sort__explicit / test_sort__natural 89-133 key=sortkey 与自然排序结果一致:先币种后数值
test_neg 135-143 正、负、零取负
test_mult / test_div 145-151 乘除 Decimal 标量
test_add / test_sub 153-167 同币种正常;跨币种抛 ValueError
test_abs 169-172 正、零、负

没有测试覆盖的行为:__bool__(含 MISSING 情形)、__eq__None 与非 Amount 对象、to_stringMISSING/None 分支、mul/div 传入非 Decimal 的断言、__neg__MISSING 的断言。这些行为只由代码本身定义。

演变史

日期 提交 / 记录 变化
2016-12-10 CHANGES:1596-1600 AmountPostingCostCostSpec 全部改为不可变 NamedTuple,删除 Posting.add()
2018-04-01 CHANGES:1003-1005(PR64) Inventory 依赖 Position 不可变省掉拷贝,提速 18%
2018-05-28 e3b92243 渲染 MISSING 不再报错
2020-06-10 092a099d 全仓库直接 from decimal import Decimal,不再经 beancount.core.number 间接导入
2021-02-28 8fa08c87 MISSING 渲染为空串
2021-03-20 d2d0a35e 币种语法扩展:单字母币种、/ 开头的期货代码
2022-04-10 cd819968 删除模块末尾的 NULL_AMOUNT = Amount(ZERO, ''),修 #662
2024-12-22 3a17e762 去掉中间类 _Amount,直接继承 NamedTuple(...)

与其他模块的关系

参考索引

beancount/core/amount.py:1-8 模块 docstring;21-24 导入;26-27 TYPE_CHECKING 导入;30-37 CURRENCY_RE;40 类定义与字段注解;47 __slots__;49-50 允许类型;52-61 __new__;63-77 to_string;79-87 __str__/__repr__;89-94 __bool__;96-106 __eq__;108-115 __lt__;117-122 __hash__;124-132 __neg__;134-151 from_string;154-157 函数式风格注释;160-168 sortkey;171-186 mul;189-204 div;207-227 add;230-250 sub;253-264 abs;267 别名。

beancount/core/amount_test.py:13-24、26-40、42-60、62-71、73-79、81-87、89-110、112-133、135-143、145-147、149-151、153-159、161-167、169-172。

其它beancount/core/number.py:28-32 MISSING;beancount/core/display_context.py:499-500 DEFAULT_FORMATTER;beancount/core/position.py:178,192-194,239-256beancount/core/data.py:201,232,234,394beancount/core/inventory.py:284-290beancount/core/realization.py:648beancount/ops/balance.py:154beancount/ops/pad.py:101beancount/plugins/sellgains.py:122beancount/parser/grammar.py:475,899,921beancount/parser/lexer.l:166-196,174,176-179,203-208beancount/parser/parser.py:50-51CHANGES:1003-1005,1596-1600

commite3b92243(2018-05-28)、092a099d(2020-06-10)、8fa08c87(2021-02-28)、d2d0a35e(2021-03-20)、cd819968(2022-04-10)、3a17e762(2024-12-22)。