这一篇会把 amount.py
的代码一段段贴出来读。代码是 beancount
的原文;代码里的中文注释是本文加的。
少数重复出现的报错文案缩写成 "..."
并标注,其余一字未改。每段代码后面跟一段讲解,说这段在干什么、为什么这么写。
不懂编程也能读下去——先花两分钟看下面五个词。
add(甲, 乙)
就是把甲和乙加起来。assert):程序的自我检查。assert 某条件
的意思是"我认为这个条件一定成立,不成立就立刻崩溃"。它防的是程序员自己写错,不是防用户。raise):报错并停下,把错误往上层传。和断言的区别在于:异常是预料之中的错误(用户数据有问题),断言是"这种情况根本不该出现"。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.5、10.50、10.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))
# 算"指纹",让金额能当字典的键、能放进集合
# 指纹同样由数值和币种共同决定
sortkey 在
amount.py:160-168,只有一行:
def sortkey(amount: Amount) -> tuple[str, Decimal | None]:
return (amount.currency, amount.number)
# 排序时先比币种(字母顺序),币种相同再比数值
三点值得说:
第一,if 某金额:
问的是"非零"而不是"存在"。
元组默认的真假判断是"里面有没有东西",Amount
把它改成了"数值是不是零"。这更贴近记账的直觉:一笔零元的交易,业务上等于没有。
第二,相等和指纹用的是同一组字段,这不是巧合而是必须的。Python
有个规矩:两个东西如果相等,它们的指纹必须也相同,否则字典和集合会出乱子。币种参与指纹计算,所以
100 USD 和 100 CAD
在字典里是两个不同的键——这正是我们要的。
第三,100 USD 和 100.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 行代码翻来覆去只在坚持几件事:
这些不是什么高深技巧,就是把"钱这个东西不能含糊"这句话,一条条落实到了类型上。后面 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 的变更把
Amount、Posting、Cost、CostSpec
一起改成 NamedTuple 并删掉可变方法
Posting.add()(CHANGES:1596-1600)。收益在下游兑现:Inventory
依赖 Position 不可变而免去防御性拷贝,提速
18%(CHANGES:1003-1005)。2024-12-22 的 commit
3a17e762 把中间类 _Amount 去掉,改成直接继承
NamedTuple(...) 表达式,行为不变。
类型注解写的是
Optional[Decimal](:40),但构造断言额外放行
type,也就是任何"类对象"。这是为 MISSING
留的口子:MISSING 在
beancount/core/number.py:28-32 被定义成一个类而不是实例或
None。currency 同样可以是
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 的注释:以大写字母或
/ 开头、至少含一个字母、以字母或数字结尾、中间可含
. _ - '。合法例:AAPL、V、NT.TO、TLT_040921C144、/6J、/NQH21;非法例:/6.3(斜杠开头后面必须含字母)、CAC_(不能以特殊字符结尾)。两侧各自维护一份正则,lexer.l:174
与 amount.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"。number 是 None 时走 else
分支,输出 "None USD"。
| 函数 | 位置 | 签名 | 约束 |
|---|---|---|---|
mul(amount, number) |
:171-186 |
Amount × Decimal → Amount | 两个参数都 assert 为 Decimal,标量不能是
Amount |
div(amount, number) |
:189-204 |
Amount ÷ Decimal → Amount | 同上;结果不做量化,Decimal 除法带满上下文精度(默认 28
位有效数字) |
add(a1, a2) |
:207-227 |
Amount + Amount → Amount | 两边 number 都 assert 为
Decimal;币种不同
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 | 唯一的运算符重载,assert 为 Decimal |
核心代码里的调用点:beancount/ops/balance.py:154、beancount/ops/pad.py:101(amount.sub
算余额差额)、beancount/plugins/sellgains.py:122(amount.mul)。
| 决策 | 理由 | 证据 |
|---|---|---|
| 数值与币种绑成一个不可变值对象 | 作为 Position.units、字典
key、集合元素被多处共享,原地修改会污染所有持有者;不可变换来免拷贝的性能 |
amount.py:47;amount_test.py:26-40;CHANGES:1596-1600,1003-1005 |
| 不重载算术运算符,用顶层函数 | 作者偏好"哑数据 + 函数",参数类型更显式(乘数是 Decimal 标量而非 Amount) | amount.py:154-157 |
add/sub 币种不同即抛异常 |
静默相加会掩盖借贷不平衡;换算是 convert
模块的职责 |
amount.py:223-226,246-249;amount_test.py:158-159,166-167 |
构造断言放行类对象(为 MISSING) |
解析器输出的不完整对象需要占位;MISSING
是类,报错可见 |
amount.py:49;number.py:28-32;amount_test.py:17-24 |
渲染委托给 DisplayFormatter |
小数位和对齐由整本账的统计决定,不是单个金额能知道的 | amount.py:63-72;display_context.py:499-500 |
| 币种规则在 Python 与 C 两侧各维护一份 | 词法器是 flex/C,Python 侧测试与 from_string
需要同一规则;无法共享定义只能靠注释同步 |
amount.py:31;lexer.l:174 |
__eq__ 与 __hash__ 用同一组字段 |
Python 契约:相等的对象哈希必须相等;币种参与哈希才能让
100 USD 与 100 CAD 成为不同 key |
amount.py:96-122;amount_test.py:81-87 |
__bool__ 定义为"非零" |
让 if amount: 表达"有金额",而不是元组默认的"非空" |
amount.py:89-94 |
| 现象 | 后果 | 证据 |
|---|---|---|
构造断言用 isinstance(x, type),任何类都能过 |
Amount(int, "USD") 不报错;类型把关实际在算术函数 |
amount.py:49,59;amount_test.py:17-24 |
构造用 assert 做类型检查 |
python -O 下断言被剥离,构造层不再校验类型 |
amount.py:59-60 |
number 为 MISSING 时
bool(amount) 为 True |
bool(amount) 为 True 不代表 number 是
Decimal;beancount 判断完整性用
is MISSING(beancount/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_string 的 None 分支输出
"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-115;NamedTuple 继承
tuple 的比较方法 |
number 为 MISSING 的 Amount 参与排序 |
sortkey 元组里第二项是类对象,与 Decimal
比较抛 TypeError |
amount.py:115,168 |
【文档漂移】__lt__ docstring 说用于 Position
排序键 |
Position.sortkey 自己拼元组,不经过
Amount.__lt__;amount.sortkey
在核心代码里的实际调用点是 realization.py:648 |
amount.py:109;position.py:239-256 |
【文档漂移】__new__ 参数注解
number: Decimal、字段注解
Optional[Decimal] |
都窄于实际放行的
(Decimal, type, NoneType);类型检查器看不到
MISSING 这条路径 |
amount.py:40,52,49 |
amount_test.py 共 14 个测试,一个类
TestAmount(amount_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_string 的 MISSING/None
分支、mul/div 传入非 Decimal
的断言、__neg__ 对 MISSING
的断言。这些行为只由代码本身定义。
| 日期 | 提交 / 记录 | 变化 |
|---|---|---|
| 2016-12-10 | CHANGES:1596-1600 |
Amount、Posting、Cost、CostSpec
全部改为不可变 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(...) |
number.py 提供
D(from_string
用)、ZERO(__bool__、abs)、MISSING(to_string
分支);display_context.py 提供
DEFAULT_FORMATTER(运行时导入,amount.py:21)。DisplayFormatter
只用作类型注解,放在 TYPE_CHECKING
块里(amount.py:26-27),配合
from __future__ import annotations(:10)惰性求值;这个块由
2024-12-16 commit 955876a9
补类型注解时引入。display_context.py 不引用
amount,两者之间没有循环导入。beancount/parser/grammar.py:475 (Builder.amount)
构造;:899,921 在处理负价格和 @@
总价时重新构造 Amount。Posting.units、Posting.price(Optional[Amount],data.py:232,234)、Balance.amount(:201)、Price.amount(:394)、Position.units(position.py:178,192-194,构造时
assert isinstance(units, Amount))。Inventory.get_currency_units
返回
Amount(inventory.py:284-290);余额校验与 pad
用
amount.sub(ops/balance.py:154、ops/pad.py:101);realization.py:648
用 amount.sortkey 排序。beancount/core/convert.py
负责按价格表换算,换算后才能 add。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-256;beancount/core/data.py:201,232,234,394;beancount/core/inventory.py:284-290;beancount/core/realization.py:648;beancount/ops/balance.py:154;beancount/ops/pad.py:101;beancount/plugins/sellgains.py:122;beancount/parser/grammar.py:475,899,921;beancount/parser/lexer.l:166-196,174,176-179,203-208;beancount/parser/parser.py:50-51;CHANGES:1003-1005,1596-1600。
commit:e3b92243(2018-05-28)、092a099d(2020-06-10)、8fa08c87(2021-02-28)、d2d0a35e(2021-03-20)、cd819968(2022-04-10)、3a17e762(2024-12-22)。