目录

04 · inventory.py:Inventory 库存容器

核对基线 · 范围 · 依赖

核对基线:beancount 仓库 commit 97472138(2026-08-22)。路径相对仓库根目录,引用格式 文件:起-止行 (名称),行号已逐条核对。 本篇范围beancount/core/inventory.py(554 行)、beancount/core/inventory_test.py(538 行)、beancount/utils/invariants.py(82 行)。 上游依赖beancount/core/amount.pyAmount,01 篇)、beancount/core/number.pyZEROsame_sign,02 篇)、beancount/core/position.pyCostPositionfrom_string,03 篇)、beancount/core/convert.pyget_cost)、beancount/core/display_context.pyDEFAULT_FORMATTER)。 下游使用者:17 个非测试文件导入本模块。realization.py:67,409 用它做账户余额与 running balance;booking_full.py:601-633 用它做批次匹配;interpolate.py:87-93 用它累加交易残差;ops/pad.py:100,182 用它校验与补差。

这一篇会把 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 股——这不是错误,这是筛选窗口的必然结果

如果容器在这里报错,所有按时间段出报表的功能都没法用了。

所以拦截被放到了别的层:

容器只负责准确地记录,判断合不合理是上层的事。 这是一条贯穿 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 篇讲过除法会吃满精度)。

批次信息丢了。 合并后标签一律清空,日期取组内最早的那个。这是合并的本意,但意味着这一步不可逆,之后再也追不回原来是哪几批。

九、这一篇讲了什么

  1. 账户余额不是一个数字,是一组持仓。 同一只股票的不同批次必须分开存。
  2. 用"币种 + 成本"当钥匙,让现金(成本为 None)和带成本的持仓共用一个容器。
  3. 加东西进来有四种结果:新建、加仓、减仓、忽略。加仓还是减仓只看符号。
  4. 减到零就删掉整项,容器里永远没有"0 股"。这让"空不空"的判断变得干净。
  5. 允许减成负数,因为按时间段筛选账目时负持仓是必然结果。合不合理由上层判断,容器只负责准确记录。
  6. 有歧义的简写一律禁掉if 某容器: 直接报错,逼你写清楚是问哪一种"空"。
  7. 容器不做业务判断:不挑批次、不换汇、不拦超卖。它只是一个索引正确、规矩明确的存取结构。

到这里,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=1REDUCED=2AUGMENTED=3IGNORED=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 基类的由来

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 的历史

历史上 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.Bookingdata.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"。

is_reduced_by 与 is_mixed

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;库存里正负并存时任何同币种非零数都返回 Trueinventory_test.py:235-239)。它是 booking 阶段判定"减仓还是加仓"的开关:booking_full.py:629-633method 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_bookingbooking.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> 返回 NotImplementedinventory_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 / get_currency_units

get_only_position:266-275)空库存隐式返回 None,多于一个头寸显式 raise AssertionError63d61fde,2018-08-05 引入时写的是 assert len(self) <= 1,现在的显式 raise 在 python -O 下仍然生效);消费者 check_average_cost.py:78currency_accounts.py:150

get_currency_units:277-290)把同币种所有批次的 units.number 相加、忽略成本,币种不存在返回 Amount(ZERO, currency);2016-12-24 4f321863get_units 改名。ops/balance.py:151 用它实现 Balance 断言——只比该币种的 units 总和;pad.py:100 用它算补差额。

reduce 与 average

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/35ea911dreduce(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-728check_average_cost.py:77-83context.py:126-129

add_inventory 的空库存快路径

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-5018ac5050c(2020-05-02)同时做了两件事:加入这条快路径,把类型断言改为受 ASSERTS_TYPES:63)门控。快路径直接共享 otherPosition 引用,安全性完全依赖 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

from_string

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_moduletearDownModule/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_stringstaticmethod 不是 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-4298ca4ad22
严格 key 匹配,不做部分匹配 匹配规则属于 booking 层;早年的部分匹配实验已撤回 :405-407,432fa4430d4014750bf
加减到零立即 del,加零 IGNORED is_emptydict.__eq__len 不需要过滤零值 :443-445,451-452,552-554
允许减成负数 过滤后的分录子集必然出现负 lot;拦截由 booking/pad 层做 summarize.py:700-706pad.py:182-186
减仓/加仓只看符号 same_sign 一行判定;cost 是否匹配已由 key 决定 :435-439number.py:86-95
返回修改前的 Position 调用方需要知道旧状态 CHANGES:1611-1612
__bool__ 抛异常 逼调用方写 is_empty(),避免与 dict 真值语义混淆 :140-1424f4aa535
__copy__ 与空库存快路径不拷贝 Position Position 不可变,共享引用安全;省下的拷贝换来 18% 提速 :150,492-497CHANGES:1003-1005
类型断言默认关闭 大库存聚合循环里断言开销可观 :62-63,419,4718ac5050c
is_small 缺币种默认 ZERO 未配置容差的币种一律按精确平衡要求 :163inventory_test.py:201-203
average 用裸 Decimal 除法,无显式 quantize 结果精度受调用时的 context 控制,源码未注释意图 :376-390
不变量只在测试里检查,且当前不生效 生产路径靠 add_amount 的删零逻辑维持 :541-554inventory_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-448number.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-481position.py:190
is_reduced_by 对不存在的币种恒 False 空账户上的负数按加仓处理,做空不报错 :196-202booking_full.py:613-614
is_small 空库存恒 True;等于容差算小 residual.is_small(...) 对无 posting 的交易通过 :161-169
get_only_position 空返回 None,多头寸抛 AssertionError 两种结果的形态不同 :270-275
averageInfinity 分支不可达 total_units == ZERO 已在 :372-373 continue :372-384e4dc590e
average 丢弃 label、日期取最小 合并后无法追溯原批次 :385-390
to_string 顺序依赖 Position.sortkey CURRENCY_ORDER 外的币种按名字长度排,同长度保持插入顺序 :120position.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(含 datelabel),sortkey 不含这两个字段:仅 cost.date 不同的两个单头寸 Inventory 互不相等,< 双向都为 False :104-106position.py:223-266
__iadd__ 绑定原函数对象 测试挂钩后 inv += other 不检查不变量 :515invariants.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_emptylen
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 与日期的严格匹配减成负数;返回旧 PositionNone
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 efab89d2CHANGES:4256-4262 移除下标运算符
2015-03-07 d8a9eb1f 加入 is_mixed()
2015-04-26 dc3e9448 is_small 支持按币种的字典容差
2015-09-17 4676419d 切换到基于 units+cost 的新实现
2015-12-21 8faae3f4CHANGES:2797 to_stringparens 选项
2016-04-08 fa4430d4cc99c45c 实验性部分匹配;add_amount 改为返回修改前的 Position
2016-12-05 014750bfCHANGES:1611-1612 撤销部分匹配;返回值行为并入主线
2016-12-24 eb324e3435ea911d4f321863 units()/cost() 让位于 reduce(convert.*)get_units 改名
2018-03-23 b04cafdd 删除已弃用方法
2018-03-31 / 04-01 2c2b36ce8ca4ad22fd6911ce579be92d(PR64) __copy__ 不再逐条 add_positionlistdict 基类;运算符改字典推导;删除自定义 __eq__
2018-04-02 4772b2ef 加入 __lt__
2018-05-05 fda81900 删除过渡期 __getitem__,docstring 改为 mapping 语义
2018-08-05 63d61fde2a5d1c36 加入 get_only_positionaverageInfinity 保护、日期取最小
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 955876a98108c924 类型注解

与其他模块的关系

参考索引

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-95position.py:28-43,129-141,178-199,223-266,286-321,324convert.py:34,46data.py:55-68,206-236interpolate.py:87-93realization.py:67,409,436-468,640-645,677-682booking.py:86-136,139booking_full.py:601-633,640-657,660-670,691booking_method.py:113-119context.py:126-133ops/balance.py:151ops/pad.py:100,182-186ops/summarize.py:665-669,677-681,700-707,720-728ops/validation.py:375-377ops/lifetimes.py:49-51plugins/check_average_cost.py:35,58-59,77-83,96plugins/currency_accounts.py:150plugins/implicit_prices.py:76scripts/doctor.py:439-440.github/workflows/tests.yaml:47CHANGES:122,281,305-308,836-839,1003-1005,1071-1072,1479-1490,1611-1612,2533-2548,2797,4256-4262

commit7718e6673620df7edf60e5d7/a440a77cefab89d2d8a9eb1fdc3e94484676419d8faae3f4fa4430d4/cc99c45c014750bfeb324e34/35ea911d/4f321863b04cafdd2c2b36ce8ca4ad22/fd6911ce/579be92d4772b2effda8190063d61fde/2a5d1c368ac5050ce4dc590e4f4aa535ab33b4c82e8291cd955876a98108c924