这一篇会把 inventory.py
的代码一段段贴出来读。代码是 beancount
的原文;代码里的中文注释是本文加的。
03 篇讲了"一份持仓"。这一篇讲把很多份持仓装在一起——这才是"账户余额"真正的样子。
你的证券账户此刻持有什么?可能是这样:
现金 1,250.00 USD
苹果股票 10 股,1 月买的,成本 50 USD/股
苹果股票 20 股,6 月买的,成本 80 USD/股
特斯拉股票 5 股,3 月买的,成本 200 USD/股
注意苹果占了两行。它们不能合并成"30 股苹果",因为成本不同——卖的时候要按批次算损益(03 篇讲过)。
所以"账户余额"不是一个数字,是一组持仓。装这组持仓的容器就是
Inventory。
它要能回答的问题包括:这个账户一共有多少美元?加进来一笔钱是新建了一个批次还是加到已有批次上?这个账户的账平不平?
装这些东西最直接的办法是列一个清单,每次找东西就从头翻到尾。beancount 早年就是这么干的,后来改成了字典。
字典(映射)这个概念可以想成一排贴了标签的抽屉:给一个标签,立刻找到对应的抽屉,不用一个个翻。
inventory.py:81:
# FIXME: You should disallow __getitem__, __delitem__ and __setitem__.
# Move the dict inside the container.
# 译:这里应该禁止直接用下标读写,把字典藏到容器内部去。(作者留的待办)
class Inventory(dict[tuple[str, Optional[Cost]], Position]):
"""An Inventory is a set of positions, indexed for efficiency."""
# 一个 Inventory 就是一组持仓,为了查得快而做了索引
#
# 这一行的类型写法拆开来看:
# dict[钥匙的类型, 抽屉里放的东西的类型]
# 钥匙 = (币种, 成本) —— 成本可以是 None
# 抽屉里 = 一个 Position —— 03 篇讲的持仓
钥匙是"币种 + 成本"这一对,这是整个模块最核心的设计。回到上面那个账户,它在容器里长这样:
| 钥匙(币种,成本) | 抽屉里的持仓 |
|---|---|
("USD", None) |
1250.00 USD |
("AAPL", 50 USD / 1月10日) |
10 股 |
("AAPL", 80 USD / 6月3日) |
20 股 |
("TSLA", 200 USD / 3月8日) |
5 股 |
两批苹果的币种一样,但成本不同,所以是两把不同的钥匙、两个抽屉。而现金那一行的成本是
None——03 篇说过,这就是"不带成本"的意思。
模块开头的说明把这一点称作一个"clever
trick"(inventory.py:20-22):未 booking
的持仓把成本留成
None,于是现金和股票能放进同一个容器。不必为现金和持仓各写一套代码。
这是本模块最核心的函数。往容器里加东西时,可能发生四种不同的事,函数得告诉调用方到底发生了哪种。
先看这四种可能(inventory.py:66-76):
class MatchResult(enum.Enum):
"""Result of booking a new lot to an existing inventory."""
CREATED = 1 # 新建了一个批次(原本没有这把钥匙)
REDUCED = 2 # 减少了一个已有批次(卖出)
AUGMENTED = 3 # 增加了一个已有批次(加仓)
IGNORED = 4 # 什么也没做(加了个 0 进来)
然后是函数本体(inventory.py:428-457):
key = (units.currency, cost)
# 拼出钥匙:币种 + 成本
pos = self.get(key, None)
# 拿钥匙去找抽屉。没有这把钥匙就得到 None
if pos is not None:
# 情况一:这个批次已经存在
# Note: In order to augment or reduce, all the fields have to match.
# (注释:要加仓或减仓,成本的每个字段都必须完全一致)
booking = (
MatchResult.REDUCED
if not same_sign(pos.units.number, units.number)
else MatchResult.AUGMENTED
)
# 判断是加仓还是减仓:只看符号。
# 已有 +10 股,来了 -3 股 → 符号相反 → 减仓
# 已有 +10 股,来了 +5 股 → 符号相同 → 加仓
number = pos.units.number + units.number
# 新数量 = 旧数量 + 这次的数量(减仓时这次的数量是负数)
if number == ZERO:
del self[key]
# 加完等于零:直接把这个抽屉扔掉,不留一个"0 股"在里面
else:
self[key] = Position(Amount(number, units.currency), cost)
# 否则:放一个新的持仓进去(注意是"换一个",不是"改一个",
# 因为 03 篇说过 Position 是不可变的)
else:
# 情况二:这把钥匙原本不存在
if units.number == ZERO:
booking = MatchResult.IGNORED
# 加进来的是 0:什么都不做,连抽屉都不建
else:
self[key] = Position(units, cost)
booking = MatchResult.CREATED
# 否则:开一个新抽屉
return pos, booking
# 返回两样东西:改动之前的旧持仓(新建时是 None),和这次发生了什么
三点值得说。
"严格匹配"是它的原则。 函数的说明里写着 "strict lot matching, that is, no partial matches are done"(严格批次匹配,不做部分匹配)。成本里的日期、标签只要有一个字不一样,那就是另一把钥匙、另一个抽屉。至于"用户说要卖 5 股但没说哪一批,该扣哪个抽屉"——那不是这个容器的事,是 09、10 篇的事。容器只管照钥匙存取,不做业务判断。
加仓还是减仓,只看符号。
不看金额大小、不看成本高低,就一句"这两个数同号吗"。这个判断来自 02
篇那个一行的 same_sign。
返回的是改动之前的旧持仓。 不是改完之后的。这个选择是 2016 年定的,理由是调用方通常需要知道"原来是什么样"——比如补差额的代码要看看原来是不是负的。
上面代码里这一句值得单独拿出来说:
if number == ZERO:
del self[key]
卖光了就把这一项彻底删掉,不留一个"0 股苹果"在容器里。
好处是后面所有代码都省事了:问"这个账户空不空",直接看容器里有几项就行,不用一项项过滤掉零;两个容器比是否相等,也不必操心"一个存了 0 股、另一个干脆没这项"算不算相等。
这条规矩和 03 篇那个奇怪的设计正好接上:那里 Position
特意让"数量为零"等于
None。两处合起来是同一个约定——在 beancount
里,"持有 0 股"和"没有这只股票"就是一回事。
模块末尾有个函数专门检查这条规矩(inventory.py:541-554):
def check_invariants(inv: Inventory) -> None:
# 检查这个容器有没有违反两条铁律
lots = set((pos.units.currency, pos.cost) for pos in inv)
assert len(lots) == len(inv), "Invalid inventory: {}".format(inv)
# 铁律一:钥匙不重复。
# 把所有持仓的"币种+成本"收成一个集合(集合会自动去重),
# 如果去重后数量变少了,说明有重复
for pos in inv:
assert pos.units.number != ZERO, "Invalid position size: {}".format(pos)
# 铁律二:不许有数量为零的持仓
这里有个值得一提的发现:这个检查函数在生产代码里一次都没被调用。测试文件本来打算把它挂到每个方法上做全程校验,但挂钩用的函数名(setUp/tearDown)不符合测试框架认的模块级命名约定(应该是
setUpModule/setup_module),所以这套挂钩当前根本不会被触发。36
个测试都按普通方法在跑。
也就是说,这两条铁律目前完全靠 add_amount 里那句
del 自觉维持,没有任何自动检查在兜底。
如果你的账户里只有 10 股苹果,却记了一笔卖出 100 股,会发生什么?
容器照单全收,你会得到 -90 股。它不报错。
这看着像个漏洞,其实是刻意的。summarize.py:700-706
那里的注释解释了原因:
We must allow negative lots at cost, because this may be used to reduce a filtered list of entries (必须允许带成本的负持仓,因为这可能是在对一个被筛过的分录子集做减法)
想象你要看"2026 年下半年"的账。7 月份卖掉的那批股票是 3 月买的,而 3 月那笔买入不在你筛选的范围里。于是这个区间的账算下来就是 -100 股——这不是错误,这是筛选窗口的必然结果。
如果容器在这里报错,所有按时间段出报表的功能都没法用了。
所以拦截被放到了别的层:
insufficient容器只负责准确地记录,判断合不合理是上层的事。 这是一条贯穿 beancount 的分工。
这段代码很少见(inventory.py:140-142):
def __bool__(self):
# Don't define this, be explicit by using is_empty() instead.
# (注释:别定义这个,请明确地用 is_empty() 方法)
raise NotImplementedError("Use explicit is_empty() method instead.")
# 谁要是写 if 某容器: 就直接报错
一般来说,容器类型都会让 if 某容器:
表示"里面有东西吗"。这里却主动禁掉了这个写法,谁用谁报错。
为什么?因为这个容器有两种"空",很容易搞混:
if 某容器:
到底问的是哪一种?读代码的人得停下来想。作者的选择是不给你含糊的机会:想问哪个就写哪个的名字,is_empty()
一目了然。
这跟 01 篇里 Amount 的做法正好相反——那里特意把
if 某金额:
定义成"数值非零"。区别在于金额只有一种合理解释,而容器有两种。只要有歧义,就禁掉简写。
复式记账要求每笔交易借贷相等。但 02
篇讲过,实际算下来常常差一丁点。所以真正的判断是"差额小到可以忽略吗"(inventory.py:161-169):
if isinstance(tolerances, dict):
# 情况一:给的容差是"每个币种各有一个阈值"
for position in self:
tolerance = tolerances.get(position.units.currency, ZERO)
# 查这个币种的阈值。查不到就用 0
# ——意味着没配容差的币种要求分毫不差
if abs(position.units.number) > tolerance:
return False
# 有任何一项超出阈值,就不算小
small = True
else:
# 情况二:给的是一个统一阈值,所有币种一视同仁
small = not any(abs(position.units.number) > tolerances for position in self)
return small
用法是这样的:把一笔交易的每一条腿都加进一个空容器,加完如果各币种都归零(或者小到容差以内),这笔交易就是平的。真正的调用在 12 篇的校验环节。
两个细节:比较用的是严格大于,所以差额正好等于容差时算"小",通过;查不到容差的币种默认阈值是 0,也就是必须分毫不差。
有时候你不关心批次,只想知道"我这些苹果平均成本多少"。average()
干这个(inventory.py:370-384):
total_units = sum(position.units.number for position in positions)
# 这一组的总数量:10 股 + 20 股 = 30 股
# Explicitly skip aggregates when resulting in zero units.
if total_units == ZERO:
continue
# 加起来是零(比如 +10 和 -10)就跳过,不产出这一项
units_amount = Amount(total_units, currency)
if cost_currency:
total_cost = sum(
convert.get_cost(position).number for position in positions
)
# 这一组的总成本:10×50 + 20×80 = 2100
cost_number = (
Decimal("Infinity")
if total_units == ZERO
else (total_cost / total_units)
)
# 平均单价 = 总成本 ÷ 总数量 = 2100 ÷ 30 = 70
# 前面那个"数量为零就是无穷大"的分支其实永远走不到,
# 因为上面已经 continue 掉了 —— 这是两次修改叠出来的死代码
结果是 30 股苹果,平均成本 70 USD。
两个代价要知道:
除法不取整。 2 股 {500 USD} 和
1 股 {520 USD} 合并,得到的平均成本是
506.6666666666666666666666667——27 位小数(02
篇讲过除法会吃满精度)。
批次信息丢了。 合并后标签一律清空,日期取组内最早的那个。这是合并的本意,但意味着这一步不可逆,之后再也追不回原来是哪几批。
None)和带成本的持仓共用一个容器。if 某容器:
直接报错,逼你写清楚是问哪一种"空"。到这里,01 到 04 搭出了 beancount 的数据地基:一个精确的数(02)、带上币种成为一笔钱(01)、带上批次成为一份持仓(03)、装进容器成为账户余额(04)。05 篇讲怎么从这些东西里取出"值"——同一份持仓,在不同的报表里可以有四种不同的值。
以下是本篇的技术版记录,对照源码查阅用。行号均以 commit
97472138 为准。
| 名称 | 位置 | 作用 |
|---|---|---|
ASSERTS_TYPES = False |
inventory.py:62-63 |
门控 add_amount/add_position
的类型断言 |
class MatchResult(enum.Enum) |
:66-76 |
CREATED=1、REDUCED=2、AUGMENTED=3、IGNORED=4 |
class Inventory(dict[tuple[str, Optional[Cost]], Position]) |
:81-535 |
容器本体 |
__init__ / __iter__ /
__lt__ |
:84-106 |
构造、按 value 迭代、排序比较 |
to_string / __str__ /
__repr__ |
:110-130 |
排序后逗号连接,默认加括号 |
is_empty / __bool__ /
__copy__ |
:132-150 |
空判定、禁用真值、浅拷贝 |
is_small / is_mixed /
is_reduced_by |
:152-202 |
容差判定、正负并存判定、减仓判定 |
__neg__ / __abs__ /
__mul__ |
:204-228 |
逐头寸取负、绝对值、乘标量 |
currencies / cost_currencies /
currency_pairs / get_positions |
:234-264 |
集合与列表视图 |
get_only_position /
get_currency_units |
:266-290 |
取唯一头寸、按币种汇总 units |
segregate_units / split |
:294-319 |
按币种拆成多个 Inventory |
reduce / average |
:336-396 |
换算视图、平均成本合并 |
add_amount / add_position /
add_inventory / __add__ /
__iadd__ |
:402-515 |
写入接口 |
from_string |
:517-535 |
测试用迷你解析器;:538 模块级别名 |
check_invariants |
:541-554 |
两条不变量的断言函数 |
dict 基类来自 PR64(commit
8ca4ad22,2018-04-01,Jakob Schnitzer),此前是
list
子类加线性扫描(df60e5d7,2014-11-08,提交说明自承 "I'm not
quite sure that's wise yet")。改成映射后 add_amount
定位一个批次是 self.get(key),key
唯一由字典结构本身保证。__iter__ 改成遍历
values(),所以 for pos in inv 得到
Position 而非 key;实际顺序是插入顺序,docstring 只承诺 "no
guaranteed order"。
__init__ 有两条路径:传入
dict/Inventory 时直接
dict.__init__
复制,不经过任何校验;传入其它可迭代对象时逐个
add_position。前者是
__copy__、__neg__、__abs__、__mul__
的基础。:79-80 的 FIXME 承认对外暴露 dict
的下标操作是待修的设计缺陷;2018-05-05 fda81900
删掉了过渡期的 __getitem__ 兼容层并把 docstring 从
"association list" 改成 "mapping"。类型参数由 2024-12 的
955876a9/8108c924 加上。
历史上 add_amount 短暂做过部分匹配:2016-04-08
fa4430d4 让减仓只要 cost 的 number 与 currency 相同即可(受
CARRY_DATE_AND_BOOK_COST 开关控制),2016-12-05
014750bf 撤销,匹配逻辑归到
booking_full.py。枚举原名 Booking,2020-11-15
2e8291cd 改名 MatchResult 以避开
data.Booking(data.py:55)。
返回修改前 Position 的行为由
fa4430d4(2016-04-08)引入,同一 diff 把 docstring 改成
"the position that that was modified BEFORE it was
modified";014750bf(2016-12-05)合并到主线时由
CHANGES:1611-1612 记载 "Now it returns the position before
being modified, which is more useful"。
def is_reduced_by(self, ramount):
if ramount.number == ZERO:
return False
for position in self:
units = position.units
if ramount.currency == units.currency and not same_sign(
ramount.number, units.number
):
return True
return False
inventory.py:186-202。零直接
False;只比币种与符号,不看
cost;库存里正负并存时任何同币种非零数都返回
True(inventory_test.py:235-239)。它是
booking
阶段判定"减仓还是加仓"的开关:booking_full.py:629-633 用
method is not Booking.NONE and balance is not None and balance.is_reduced_by(units)
进入减仓匹配分支。:613-614
的注释指出没有既有余额就不可能是减仓,所以空账户上直接建负头寸被当作加仓放行。
is_mixed(:171-184)按币种记录第一个头寸的符号,遇到不同符号返回
True,同样不看 cost。全仓库唯一非测试调用点是
booking.py:124,位于
validate_inventory_booking(booking.py:87)内;该函数只有
booking_test.py:103
调用,生产路径没有任何调用,函数头注释写着 "FIXME: This goes away. Maybe
moves to a pedantic plugin."
__bool__ 主动抛错而不是沿用 dict
的"非空为真",报错文字由
4f4aa535(2020-08-30)加上。相等没有重写,走
dict.__eq__:键值对集合相同即相等、与插入顺序无关(inventory_test.py:142-157);579be92d(2018-04-01)删除了
list 时代的 sorted(self) == sorted(other)。值的比较经
Position.__eq__ 到
Amount.__eq__,Decimal 不看 exponent,所以
I("1 USD") == I("1.00 USD")。dict
子类不可哈希,context.py:133 因此对 position
而非库存取 hash。
__lt__(4772b2ef,2018-04-02)比较两个排序后的
Position 列表,排序键是
position.py:239-256 (sortkey);dict 的
> 返回
NotImplemented,inventory_test.py:171,174
靠反射到右操作数的 __lt__ 成立。__copy__
是浅拷贝,2c2b36ce(2018-03-31)把逐个
add_position 改为直接 Inventory(self),前提是
Position 不可变。
to_string(:110-120)先
sorted(self) 再逗号连接,parens=True
默认加括号,:108-109 的 TODO 称括号是 "stupid idea"。
get_only_position(:266-275)空库存隐式返回
None,多于一个头寸显式
raise AssertionError(63d61fde,2018-08-05
引入时写的是 assert len(self) <= 1,现在的显式 raise 在
python -O 下仍然生效);消费者
check_average_cost.py:78、currency_accounts.py:150。
get_currency_units(:277-290)把同币种所有批次的
units.number 相加、忽略成本,币种不存在返回
Amount(ZERO, currency);2016-12-24 4f321863 由
get_units 改名。ops/balance.py:151 用它实现
Balance 断言——只比该币种的 units 总和;pad.py:100
用它算补差额。
def reduce(self, reducer, *args):
inventory = Inventory()
for position in self:
inventory.add_amount(reducer(position, *args))
return inventory
inventory.py:336-350。reducer 接收 Position
返回 Amount,结果经不带 cost 的 add_amount
写入,所以同币种自动合并。:334-335 的 TODO 承认它更像
map。2016-12-24 eb324e34/35ea911d
用 reduce(convert.get_units/get_cost) 替换旧的
units()/cost()。
average(:352-396)按
(units.currency, cost.currency) 分组,成本 currency 为
None
的组产出无成本头寸。total_cost / total_units 是裸
Decimal 除法,不量化,保留上下文默认 28
位有效数字:2 HOOL {500 USD}, 1 HOOL {520 USD} 合并为
3 HOOL {506.6666666666666666666666667 USD}。Decimal("Infinity")
用的是 :46 直接导入的 decimal.Decimal,不经
D();这条保护由
63d61fde(2018-08-05)加入,2020-05-23
e4dc590e(修 #184)在前面加了 continue
之后不再可达。日期取组内最小值(2a5d1c36,2018-08-05),label
置
None。消费者:summarize.py:720-728、check_average_cost.py:77-83、context.py:126-129。
def add_inventory(self, other):
if self.is_empty():
# Optimization for empty inventories; ... We
# can do this because the positions are immutable.
self.update(other)
else:
for position in other.get_positions():
self.add_position(position)
return self
inventory.py:484-501。8ac5050c(2020-05-02)同时做了两件事:加入这条快路径,把类型断言改为受
ASSERTS_TYPES(:63)门控。快路径直接共享
other 的 Position 引用,安全性完全依赖
Position 不可变;这与
CHANGES:1003-1005(PR64)"rely on the fact that Position is
immutable ... This yields an 18% speedup"
是同一条思路。__add__(:503-513)先
__copy__ 再
add_inventory,原对象不变;__iadd__ = add_inventory(:515)返回
self。
position_strs = re.split(
r"([-+]?[0-9,.]+\s+[A-Z]+\s*(?:{[^}]*})?)\s*,?\s*", string
)[1::2]
inventory.py:530-534。用带捕获组的 re.split
取奇数项,注释说明这是为了让花括号内的逗号(日期、标签)不被当分隔符;花括号内容交给
position.from_string。币种只认 [A-Z]+,比
amount.CURRENCY_RE 窄。:538
的模块级别名让测试写成 I = inventory.from_string。
check_invariants(:541-554)的第一条不变量从各
value 派生 (pos.units.currency, pos.cost) 集合、与
len(inv) 比较,能发现多个 value 派生出同一个 lot;它不读取
inv.keys(),不能保证字典 key 与 value
实际一致。非测试代码不调用它。
inventory_test.py:31-38 定义了模块级函数
setUp(module)/tearDown(module),意图用
invariants.instrument_invariants(Inventory, check_invariants, check_invariants)
挂钩。但这套挂钩当前不生效:unittest 的模块级钩子约定名是
setUpModule/tearDownModule,pytest 认的是
setUpModule/setup_module 与
tearDownModule/teardown_module;CI(.github/workflows/tests.yaml:47)跑的是
pytest beancount,裸名
setUp/tearDown 不在这两套命名约定内。36
个测试按普通方法执行。
若改名生效后,invariants.py:52-69 (instrument_invariants)
遍历 klass.__dict__,跳过 _ 开头的名字和非
FunctionType 的对象,把其余方法替换为
invariant_check(:27-49)包装:调用前跑
prefun、调用后跑 postfun,用
reentrant 列表让嵌套调用只在最外层检查。会被挂钩的是 18
个方法;不会被挂钩的有
__init__、__copy__、__neg__、__abs__、__mul__、__iadd__(绑定的是原始函数对象)、from_string(staticmethod
不是 FunctionType)。
| 接口 | 位置 | 行为 |
|---|---|---|
__neg__ / __abs__ /
__mul__(scalar) |
:204-228 |
字典推导保留 key,逐头寸委托 Position
的对应运算;abs 不合并 lot |
currencies() |
:234-240 |
从 key 取 units 币种集合 |
cost_currencies() |
:242-248 |
从 key 取非 None cost
的币种集合;booking_full.py:411-416 用它推断缺失币种 |
currency_pairs() |
:250-256 |
Position.currency_pair()
集合;lifetimes.py:49-51 用它探测商品出现/消失 |
get_positions() |
:258-264 |
list(iter(self)) |
segregate_units(currencies) |
:294-308 |
按给定币种拆分,其余归入 None key;两条
TODO;无生产调用点 |
split() |
:310-319 |
按 units 币种拆成
dict[str, Inventory](ab33b4c8,2020-11-01);唯一生产消费者是
context.py:126 |
| 决策 | 理由 | 证据 |
|---|---|---|
dict 基类、key 为 (currency, cost) |
定位批次 O(1);key 唯一由结构保证;Cost
四字段全参与哈希 |
inventory.py:1-18,81,428-429;8ca4ad22 |
| 严格 key 匹配,不做部分匹配 | 匹配规则属于 booking 层;早年的部分匹配实验已撤回 | :405-407,432;fa4430d4→014750bf |
加减到零立即 del,加零 IGNORED |
让 is_empty、dict.__eq__、len
不需要过滤零值 |
:443-445,451-452,552-554 |
| 允许减成负数 | 过滤后的分录子集必然出现负 lot;拦截由 booking/pad 层做 | summarize.py:700-706;pad.py:182-186 |
| 减仓/加仓只看符号 | same_sign 一行判定;cost 是否匹配已由 key 决定 |
:435-439;number.py:86-95 |
返回修改前的 Position |
调用方需要知道旧状态 | CHANGES:1611-1612 |
__bool__ 抛异常 |
逼调用方写 is_empty(),避免与 dict
真值语义混淆 |
:140-142;4f4aa535 |
__copy__ 与空库存快路径不拷贝
Position |
Position 不可变,共享引用安全;省下的拷贝换来 18%
提速 |
:150,492-497;CHANGES:1003-1005 |
| 类型断言默认关闭 | 大库存聚合循环里断言开销可观 | :62-63,419,471;8ac5050c |
is_small 缺币种默认 ZERO |
未配置容差的币种一律按精确平衡要求 | :163;inventory_test.py:201-203 |
average 用裸 Decimal 除法,无显式
quantize |
结果精度受调用时的 context 控制,源码未注释意图 | :376-390 |
| 不变量只在测试里检查,且当前不生效 | 生产路径靠 add_amount 的删零逻辑维持 |
:541-554;inventory_test.py:31-38;.github/workflows/tests.yaml:47 |
| 现象 | 后果 | 证据 |
|---|---|---|
__init__ 传 dict/Inventory
不校验 |
能装入零仓位,check_invariants 才报 "Invalid position
size" |
inventory.py:91-92 |
__mul__ 乘 ZERO 不删零仓位 |
I("10 USD") * D("0") 得到
(0 USD),len 为 1,违反不变量 |
:228 |
| 对已有头寸加零 | same_sign(x, 0) 对负头寸为
False:负头寸加零返回 REDUCED,正头寸返回
AUGMENTED,数量不变但 Position 对象被重建 |
:435-448;number.py:95 |
key 里的 Cost.number 不看 exponent |
Cost(D("500.00"), ...) 与
Cost(D("500"), ...) 命中同一 key |
:428-429,448 |
add_position 放行 CostSpec 作 key |
Position.cost_types = (Cost, CostSpec);ASSERTS_TYPES
关闭时不查 |
:471-481;position.py:190 |
is_reduced_by 对不存在的币种恒 False |
空账户上的负数按加仓处理,做空不报错 | :196-202;booking_full.py:613-614 |
is_small 空库存恒 True;等于容差算小 |
residual.is_small(...) 对无 posting 的交易通过 |
:161-169 |
get_only_position 空返回 None,多头寸抛
AssertionError |
两种结果的形态不同 | :270-275 |
average 的 Infinity 分支不可达 |
total_units == ZERO 已在 :372-373
continue |
:372-384;e4dc590e |
average 丢弃 label、日期取最小 |
合并后无法追溯原批次 | :385-390 |
to_string 顺序依赖 Position.sortkey |
CURRENCY_ORDER
外的币种按名字长度排,同长度保持插入顺序 |
:120;position.py:129-141,248 |
from_string 币种只认 [A-Z]+ |
"10 X1" 被切成 10 X 加丢弃的
1;小写币种、未匹配尾巴静默丢弃 |
:530-532 |
| 不可哈希 | 不能作 set 元素或 dict key | dict 子类语义 |
未覆盖 dict.copy |
inv.copy() 返回普通 dict 而非
Inventory;只有 copy.copy(inv) 才经
__copy__ |
:81,144-150 |
__lt__ 与 __eq__ 不构成全序 |
Position.__eq__ 比较完整 Cost(含
date、label),sortkey
不含这两个字段:仅 cost.date 不同的两个单头寸 Inventory
互不相等,< 双向都为 False |
:104-106;position.py:223-266 |
__iadd__ 绑定原函数对象 |
测试挂钩后 inv += other 不检查不变量 |
:515;invariants.py:63-68 |
【文档漂移】ASSERTS_TYPES 注释 "Enable this in
tests" |
全仓库没有测试把它设为 True |
:62-63;grep 仅命中 :63,419,471 |
【文档漂移】add_position docstring 说返回 "the position
that was modified" |
实际与 add_amount 一致,返回修改前的对象 |
:467-469 vs :413-417,457 |
【文档漂移】get_positions docstring "A shallow copy of
the list of positions" |
内部并无列表,是新建的 list(iter(self)) |
:261-264 |
【文档漂移】is_mixed 有测试但无生产调用 |
唯一调用点在无调用点的 validate_inventory_booking
内 |
booking.py:86-87,124 |
inventory_test.py 共 36 个测试,两个类
TestInventoryNew(:41)与
TestInventory(:47)。
| 测试 | 行号 | 锁定的行为 |
|---|---|---|
test_from_string ×2 |
42-44, 48-95 | 空串得空库存;多头寸、带日期 cost、# 总价形式 |
test_ctor_empty_len |
104-123 | 同币种两个头寸合并为 1;is_empty 与
len |
test_str |
125-127 | "(100.00 USD, 101.00 CAD)",含括号与
sortkey 顺序 |
test_copy |
129-140 | copy.copy 后修改副本不影响原对象 |
test_op_eq / test_op_lt |
142-175 | 相等与顺序无关;<、>
按排序后的列表比较 |
test_is_small__value / __dict /
__with_default |
177-216 | 标量与字典容差;边界等于算小;缺币种、空字典为
False |
test_is_mixed |
218-226 | 同币种不同 lot 正负并存为 True |
test_is_reduced_by |
228-239 | 零为 False;混合库存下正负都为 True |
test_op_neg / test_op_abs /
test_op_mul |
241-258 | 取负;逐头寸取绝对值;乘标量保留 cost |
test_get_only_position |
260-267 | 多头寸抛 AssertionError;单头寸;空返回
None |
test_get_currency_units |
269-275 | 跨 lot 求和、忽略 cost;不存在币种返回 0 |
test_segregate_units / test_split |
277-304 | 拆分结果;None 兜底 key |
test_units1 / test_units /
test_cost |
306-336 | reduce(get_units)
合并同币种、reduce(get_cost) |
test_average |
338-363 | 无需合并时恒等;加权平均;抵消为零得空库存 |
test_currencies / test_currency_pairs |
365-383 | 集合视图 |
test_add_amount 系列 |
385-495 | 连续加减穿越零到负数;加零 len 为
0;四态顺序;多币种;带 cost 与日期的严格匹配减成负数;返回旧
Position 或 None |
test_add_position / test_op_add /
test_update / test_sum_inventories |
497-527 | add_position;+
不改原对象;test_update 未用 assertIs
锁定返回同一对象;test_sum_inventories
无断言,只是烟雾测试 |
test_reduce |
529-532 | 自定义 reducer |
没有测试覆盖:__bool__ 抛异常、is_small
对空库存、__mul__ 乘零残留零仓位、add_amount
对已有头寸加零、ASSERTS_TYPES=True
时的断言、add_inventory
的空库存快路径、average 的日期最小值与 label
丢弃、from_string
的非法输入、to_string(parens=False)、__iadd__、cost_currencies()、Cost.label
取非 None 值时对 strict lot matching 的影响。
| 日期 | 提交 / 记录 | 变化 |
|---|---|---|
| 2014-02-23 | 7718e667 |
check_invariants 随测试补齐出现 |
| 2014-11-02 | 3620df7e |
update() 改名 add_inventory() |
| 2014-11-08 | df60e5d7 / a440a77c |
Inventory 改为 list 子类;实现
average() |
| 2014-12-08 | efab89d2;CHANGES:4256-4262 |
移除下标运算符 |
| 2015-03-07 | d8a9eb1f |
加入 is_mixed() |
| 2015-04-26 | dc3e9448 |
is_small 支持按币种的字典容差 |
| 2015-09-17 | 4676419d |
切换到基于 units+cost 的新实现 |
| 2015-12-21 | 8faae3f4;CHANGES:2797 |
to_string 加 parens 选项 |
| 2016-04-08 | fa4430d4、cc99c45c |
实验性部分匹配;add_amount 改为返回修改前的
Position |
| 2016-12-05 | 014750bf;CHANGES:1611-1612 |
撤销部分匹配;返回值行为并入主线 |
| 2016-12-24 | eb324e34、35ea911d、4f321863 |
units()/cost() 让位于
reduce(convert.*);get_units 改名 |
| 2018-03-23 | b04cafdd |
删除已弃用方法 |
| 2018-03-31 / 04-01 | 2c2b36ce、8ca4ad22、fd6911ce、579be92d(PR64) |
__copy__ 不再逐条
add_position;list→dict
基类;运算符改字典推导;删除自定义 __eq__ |
| 2018-04-02 | 4772b2ef |
加入 __lt__ |
| 2018-05-05 | fda81900 |
删除过渡期 __getitem__,docstring 改为 mapping
语义 |
| 2018-08-05 | 63d61fde、2a5d1c36 |
加入 get_only_position;average 加
Infinity 保护、日期取最小 |
| 2020-05-02 | 8ac5050c |
类型断言改由 ASSERTS_TYPES 门控;空库存快路径 |
| 2020-05-23 | e4dc590e |
修 #184:average 跳过合计为零的组 |
| 2020-08-30 | 4f4aa535 |
__bool__ 的 NotImplementedError
带上提示文字 |
| 2020-11-01 | ab33b4c8 |
加入 split() |
| 2020-11-15 | 2e8291cd |
枚举 Booking 改名 MatchResult |
| 2024-12-16 / 12-23 | 955876a9、8108c924 |
类型注解 |
Amount(01 篇)是
units 的类型;number.py 提供 ZERO
与 same_sign;position.py 提供
Cost(key
的第二分量)、Position(value)、from_string;convert.py
提供 get_cost 并且是 reduce 的 reducer
来源,convert.py 自身不导入
inventory,无循环;display_context.py 提供
DEFAULT_FORMATTER。data 只在
TYPE_CHECKING 块导入(:59-60)。realization.py:67,409,436,464,677、interpolate.py:87,304,343、summarize.py:690-691、check_average_cost.py:63
以空库存起步逐条写入;booking_full.py:601 用共享的
empty 实例作缺省值。MatchResult:非测试代码仅
implicit_prices.py:76 用
booking != inventory.MatchResult.REDUCED 决定是否为带 cost
的加仓生成 Price。summarize.py:700-707、booking_full.py:613-614、pad.py:182-186。__copy__、add_inventory
快路径、booking_full.py:615-618 的
local_balances 隔离。booking.py:87-136 (validate_inventory_booking)
与 :139 (convert_lot_specs_to_lots) 只有测试调用,它们是
is_mixed 的唯一消费者。beancount/core/inventory.py:1-35 模块
docstring;37-57 导入;59-60 TYPE_CHECKING;62-63
ASSERTS_TYPES;66-76 MatchResult;79-80
FIXME;81-82 类定义;84-98 __init__;100-102
__iter__;104-106 __lt__;108-120
to_string;122-130
__str__/__repr__;132-138
is_empty;140-142 __bool__;144-150
__copy__;152-169 is_small;171-184
is_mixed;186-202 is_reduced_by;204-228
运算符;234-256 集合视图;258-264 get_positions;266-275
get_only_position;277-290
get_currency_units;292-308
segregate_units;310-319 split;336-350
reduce;352-396 average;402-457
add_amount;459-482 add_position;484-501
add_inventory;503-513 __add__;515
__iadd__;517-535 from_string;538
别名;541-554 check_invariants。
beancount/core/inventory_test.py:27-28
P/I 别名;31-38 挂钩;42-532
各测试(行号见上表)。
beancount/utils/invariants.py:1-19 模块
docstring;27-49 invariant_check;52-69
instrument_invariants;72-82
uninstrument_invariants。
其它:number.py:22,86-95;position.py:28-43,129-141,178-199,223-266,286-321,324;convert.py:34,46;data.py:55-68,206-236;interpolate.py:87-93;realization.py:67,409,436-468,640-645,677-682;booking.py:86-136,139;booking_full.py:601-633,640-657,660-670,691;booking_method.py:113-119;context.py:126-133;ops/balance.py:151;ops/pad.py:100,182-186;ops/summarize.py:665-669,677-681,700-707,720-728;ops/validation.py:375-377;ops/lifetimes.py:49-51;plugins/check_average_cost.py:35,58-59,77-83,96;plugins/currency_accounts.py:150;plugins/implicit_prices.py:76;scripts/doctor.py:439-440;.github/workflows/tests.yaml:47;CHANGES:122,281,305-308,836-839,1003-1005,1071-1072,1479-1490,1611-1612,2533-2548,2797,4256-4262。
commit:7718e667、3620df7e、df60e5d7/a440a77c、efab89d2、d8a9eb1f、dc3e9448、4676419d、8faae3f4、fa4430d4/cc99c45c、014750bf、eb324e34/35ea911d/4f321863、b04cafdd、2c2b36ce、8ca4ad22/fd6911ce/579be92d、4772b2ef、fda81900、63d61fde/2a5d1c36、8ac5050c、e4dc590e、4f4aa535、ab33b4c8、2e8291cd、955876a9、8108c924。