目录

10 · booking_method.py:STRICT、FIFO、LIFO、HIFO、NONE 与被禁用的 AVERAGE

核对基线 · 范围 · 依赖

核对基线:beancount 仓库 commit 97472138(作者日期 2026-08-22,提交者日期 2026-08-23)。路径相对仓库根目录,引用格式 文件:起-止行 (名称),行号已逐条核对。 本篇范围beancount/parser/booking_method.py(384 行)、beancount/parser/booking_method_test.py(16 行)、beancount/parser/booking_full_test.py 中的批次匹配测试类。 上游依赖beancount/core/data.pyBooking 枚举与 Meta/Directivebeancount/core/amount.pybeancount/core/position.pyCostto_string)、beancount/core/number.pyZERO);convertinventoryflagsDecimalCost 五个导入只被 if False 死代码使用。 下游使用者:唯一调用点 beancount/parser/booking_full.py:675-676;记账方法映射由 beancount/parser/booking.py:40-44 组装。

1. 模块解决什么问题

一条减记 posting 写成 Assets:Account -5 HOOL {},只说了要卖 5 股,没说卖哪一批。booking_full.book_reductionsbeancount/parser/booking_full.py:564-715)先按 cost spec 的各个分量(units 币种、成本数值、成本币种、日期、label)从账户的 ante-inventory 里筛出候选批次(beancount/parser/booking_full.py:637-657)。筛完可能剩 0 个(直接报 No position matches)、1 个、或多个。多个的时候到底扣哪几批,是一个没有唯一正确答案的问题——它取决于税务口径和用户意图。本模块把这个取舍收拢成七个同签名的函数,由账户上声明的记账方法选一个。

模块不负责按 CostSpec 生成初始候选,那一步在 book_reductions 完成(beancount/parser/booking_full.py:637-657);STRICT_WITH_SIZE(4.2 节)会在候选里再按数量精确相等筛一次,其余方法只负责选择或排序候选。进来的 matches 已保证匹配 cost spec,出去的 booked_reductions 是一组 cost 已经是 Cost(而非 CostSpec)的 posting。

2. 结构一览

名称 位置 作用
AmbiguousMatchError beancount/parser/booking_method.py:23-28 NamedTuple(source, message, entry),本模块唯一的错误类型
handle_ambiguous_matches beancount/parser/booking_method.py:31-70 仓库内唯一调用入口:分派 + 把 insufficient 翻译成错误
booking_method_STRICT beancount/parser/booking_method.py:73-121 歧义即报错,带总量特例
booking_method_STRICT_WITH_SIZE beancount/parser/booking_method.py:124-158 STRICT 之上再按数量补充匹配
booking_method_FIFO / _LIFO / _HIFO beancount/parser/booking_method.py:161-200 三个只转发排序参数的包装函数
_booking_method_xifo beancount/parser/booking_method.py:203-247 三者共用的排序 + 按序消耗实现
booking_method_NONE beancount/parser/booking_method.py:250-274 不匹配,原样返回
booking_method_AVERAGE beancount/parser/booking_method.py:277-373 beancount/parser/booking_method.py:290 无条件报错返回;beancount/parser/booking_method.py:295-373 是被 if False 包住的历史实现
_BOOKING_METHODS beancount/parser/booking_method.py:376-384 Booking 枚举 → 函数的分派表

3. 统一入口 handle_ambiguous_matches

assert isinstance(method, Booking), "Invalid type: {}".format(method)
assert matches, "Internal error: Invalid call with no matches"

# method = globals()['booking_method_{}'.format(method.name)]
method = _BOOKING_METHODS[method]
(booked_reductions, booked_matches, errors, insufficient) = method(
    entry, posting, matches
)

beancount/parser/booking_method.py:48-55。两条断言是前置校验:调用方保证候选非空(空候选已在 beancount/parser/booking_full.py:659-670 提前报 No position matches 并返回)、method 是合法的 Booking 枚举成员。生产路径下只要候选数大于零就会调用本函数(beancount/parser/booking_full.py:659-676),函数名里的 "ambiguous" 不代表候选数一定大于一——单候选同样会走到这里。beancount/parser/booking_method.py:51 保留了一行注释掉的 globals() 反射分派,最终改成显式字典 _BOOKING_METHODS

各方法返回四元组,handle_ambiguous_matches 对外只返回三元组:insufficient 这一位不外传,而是在 beancount/parser/booking_method.py:56-68 就地拼成 Not enough lots to reduce "<posting>": <候选列表> 追加进 errors。也就是说"批次不够扣"这条错误消息在七个方法里只写一次。

第二个返回值 booked_matches 是 2019-03-16 为交易撮合追踪加的(50d0bc78CHANGES:415-420 自陈"This doesn't work yet")。它在 beancount/parser/booking_full.py:675 被接成 matched_postings 后再无任何读取点。

4. 各记账方法

4.1 STRICT:歧义即失败,但总量对上就全扣

if len(matches) > 1:
    sum_matches = sum(p.units.number for p in matches)
    if sum_matches == -posting.units.number:
        booked_reductions.extend(
            posting._replace(units=-match.units, cost=match.cost) for match in matches
        )
    else:
        errors.append(AmbiguousMatchError(... 'Ambiguous matches for "{}": {}' ...))
else:
    match = matches[0]
    sign = -1 if posting.units.number < ZERO else 1
    number = min(abs(match.units.number), abs(posting.units.number))
    match_units = Amount(number * sign, match.units.currency)
    booked_reductions.append(posting._replace(units=match_units, cost=match.cost))
    booked_matches.append(match)
    insufficient = match_units.number != posting.units.number

beancount/parser/booking_method.py:90-119。总量特例(beancount/parser/booking_method.py:91-97)来自 2016-10-29 的 1c721cfe:在候选均为与减记方向相反的同向批次这一前提下,数量之和恰好等于要扣减的量意味着必须耗尽全部候选,选谁都一样,于是全部扣掉。这一分支本身不验证该前提——候选生成阶段(beancount/parser/booking_full.py:637-657)不检查持仓符号,_booking_method_xifo 才有的同号跳过(beancount/parser/booking_method.py:231-232)这里没有,混合 inventory 时总量相等不再意味着选法无差别。这一分支不填 booked_matches,也不设 insufficient

单候选分支不做符号一致性检查,直接取两边绝对值的较小者再乘上 posting 自己的符号;批次不够时仍然把部分扣减放进 booked_reductions,靠 insufficient 让上层报错,而 beancount/parser/booking_full.py:678-680 见到错误就丢弃整组 posting。

4.2 STRICT_WITH_SIZE:按数量补充匹配

(booked_reductions, booked_matches, errors, insufficient) = booking_method_STRICT(...)
if errors and len(matches) > 1:
    number = -posting.units.number
    matching_units = [match for match in matches if number == match.units.number]
    if matching_units:
        matching_units.sort(key=lambda match: match.cost.date)
        match = matching_units[0]
        booked_reductions.append(posting._replace(units=-match.units, cost=match.cost))
        booked_matches.append(match)
        insufficient = False
        errors = []

beancount/parser/booking_method.py:139-156。先原样跑 STRICT,只在它报错且候选多于一个时补充判定:筛出数量与请求数量精确相等的候选(可能不止一个),按 cost.date 排序取最早的一个,然后清空 errors。数量必须精确相等(不是"够扣"就行),没有精确相等的候选时补充判定不生效,仍保留 STRICT 的错误。beancount/core/data.py:59-62 的枚举注释同样表述为 "if a lot matches the size exactly, accept the oldest";beancount/parser/booking_full_test.py:3105-3120 (test_strict_with_size_multiple) 用两个同尺寸候选(101.00 USD, 2014-06-02102.00 USD, 2014-06-01)验证了这一步——cost.date 更早的 2014-06-01 批次胜出。

4.3 FIFO / LIFO / HIFO:一份实现,三组排序参数

def booking_method_FIFO(entry, posting, matches):
    return _booking_method_xifo(entry, posting, matches, "date", False)
def booking_method_LIFO(entry, posting, matches):
    return _booking_method_xifo(entry, posting, matches, "date", True)
def booking_method_HIFO(entry, posting, matches):
    return _booking_method_xifo(entry, posting, matches, "number", True)

beancount/parser/booking_method.py:172,186,200。差异被压缩成 Cost 上的排序字段名和是否逆序两个参数。共用实现:

sign = -1 if posting.units.number < ZERO else 1
remaining = abs(posting.units.number)
for match in sorted(
    matches, key=lambda p: p.cost and getattr(p.cost, sortattr), reverse=reverse_order
):
    if remaining <= ZERO:
        break
    # If the inventory somehow ended up with mixed lots, skip this one.
    if match.units.number * sign > ZERO:
        continue
    size = min(abs(match.units.number), remaining)
    booked_reductions.append(
        posting._replace(units=Amount(size * sign, match.units.currency), cost=match.cost)
    )
    booked_matches.append(match)
    remaining -= size
insufficient = remaining > ZERO

beancount/parser/booking_method.py:222-245。三点:排序只看单一字段,同值批次的先后由 Python sorted 的稳定性决定,即候选在 matches 列表里的原始相对顺序——而 beancount/core/inventory.py:100-101 (Inventory.__iter__) 的文档字符串明确写着 "there is no guaranteed order",因此 FIFO/LIFO 同日期、HIFO 同成本时具体哪个批次胜出不是受支持的确定行为;beancount/parser/booking_method.py:231-232 的跳过分支处理混合 inventory——减记方向与候选批次同号(例如做空持仓里混进一笔多头批次)时该批次被跳过,不参与本次匹配;候选耗尽后 remaining 仍大于零就置 insufficient,由入口翻成 Not enough lots to reduce

getattr(p.cost, sortattr) 前的 p.cost and 是空成本保护;在 book_reductions 当前的生产调用路径中不会触发——beancount/parser/booking_full.py:643-644 已经把 cost is None 的持仓筛掉了,但 _booking_method_xifo 本身不保证 matches[*].cost 非空,直接调用时这条保护仍是可到达行为。三处 booked_matches.append(match)beancount/parser/booking_method.py:118,154,241,分别在 STRICT 单候选分支、STRICT_WITH_SIZE、_booking_method_xifo 里)存的都是候选在 ante-inventory 里的原始 Position:即便 size 小于该批次全部数量,追加的仍是未削减的完整对象,STRICT 的总量分支更是完全不填这个列表。

4.4 NONE:不匹配,也不因唯一而匹配

# This never needs to match against any existing positions... we
# disregard the matches, there's never any error. Note that this never
# gets called in practice, we want to treat NONE postings as augmentations.
# ...
# Note that it's an interesting question whether a reduction on an
# account with NONE method which happens to match a single position
# ought to be matched against it. We don't allow it for now.
return [posting], [], False

beancount/parser/booking_method.py:262-274。三点事实:其一,beancount/parser/booking_full.py:628-633 的条件是 method is not Booking.NONE and balance is not None and balance.is_reduced_by(units),NONE 账户上的减记走 else 分支当增记处理,在 book_reductions 当前的生产调用路径中不会触发本函数;但本函数未加下划线前缀,直接调用它、或以 method=Booking.NONE 直接调用 handle_ambiguous_matches,仍是可达路径。其二,返回值是三元组,而入口按四元组解包(beancount/parser/booking_method.py:53-55),走到这条路径会得到解包 ValueError;其三,注释明确记下了"即使唯一匹配也不匹配"是一个有意的当前选择。

后果是 NONE 账户会积出正负并存的混合 inventory:beancount/parser/booking_full_test.py:2263-2326 四个用例(test_ambiguous__NONE__notmatching_{nonmixed,mixed}{1,2})锁定这一点——不匹配任何候选的 posting(正负数各两例、原 inventory 非混合/混合各两例)原样追加进 ex-inventory,都不报错。真正验证减记 posting 保留原 CostSpec#reduced 行的 S 标志)的是另一组用例:beancount/parser/booking_full_test.py:1872-1916 (test_reduce__ambiguous__none, test_reduce__ambiguous__none__from_mixed),两例都经 book_reductions 走完整路径,而非直接调用 booking_method_NONE

4.5 AVERAGE:2016 年实现过基础场景,同年被禁用,现在无条件报错

errors = [AmbiguousMatchError(entry.meta, "AVERAGE method is not supported", entry)]
return booked_reductions, booked_matches, errors, False

# FIXME: Future implementation here.

if False:
    # DISABLED - This is the code for AVERAGE, which is currently disabled.

beancount/parser/booking_method.py:290-296return 之后是一段 76 行的历史实现(beancount/parser/booking_method.py:295-373):把所有候选按 units 和 weight 各汇总进一个 Inventory,两者都必须收敛成单一持仓,否则报 Cannot merge positions in multiple currencies;显式给出成本的减记报 Explicit cost reductions aren't supported yet;否则生成一组带 flags.FLAG_MERGINGbeancount/core/flags.py:14,值 "M")的抵消 posting、一条合并后的平均成本 posting,最后才是真正的减记。合并批次的日期取 matches[0].cost.date 并留了 ## FIXME: Select which one, oldest or latest.beancount/parser/booking_method.py:357-358)。模块顶部的 convertinventoryflagsDecimalCost 五个导入只服务于这段死代码。

触发不要求真正的歧义beancount/parser/booking_full.py:659-676 (book_reductions) 只在候选数为零时提前 return [], errorsNo position matches;候选数大于零就无条件调用 handle_ambiguous_matches,AVERAGE 分支不检查候选数量,直接返回上面那条报错。因此只找到一个候选的普通减记同样会报 "AVERAGE method is not supported",不需要真的存在多批次歧义。

选项层与运行时的两层语义差beancount/parser/options.py:105-118 (options_validate_booking_method) 的校验是 data.Booking[value]AVERAGE 是合法枚举成员(beancount/core/data.py:67-68),所以 option "booking_method" "AVERAGE" 解析期完全通过,beancount/parser/booking_full_test.py:1352-1357 正是这样断言的;选项文档(beancount/parser/options.py:642-652)只举了 STRICT、FIFO、NONE 三个例子,没提 AVERAGE 会在运行时报错。

曾经实现过又被禁用a89e8604(2016-05-09,当时路径 src/python/beancount/parser/booking_full.py)的提交信息自陈 "implemented the basic case of average cost booking";5f60b500(2016-08-25)"Disabled AVERAGE booking method in the main branch" 随后将其禁用。基线版本里可见的报错分支与 76 行 if False 历史实现,是这段被禁用代码留下的状态,不是从未实现过。

外部实现未进主线8c0d9d25(2020-11-22,Ben Blount,"Implement AVERAGE booking and lot merging via {*}")实现了 AVERAGE 与 {*} 合并语法,改动 beancount/parser/booking_method.pybeancount/parser/booking_full.pygrammar.py 等 5 个文件。git merge-base --is-ancestor 8c0d9d25 HEAD 判定为否;合并 PR #591 的 19d67a3d 同样不在祖先链,两者只存在于 origin/average_booking_rollback 分支。

替代校验beancount/plugins/check_average_cost.py 是 AVERAGE 缺席期间的手工近似(CHANGES:672-676,2018-08-05)。它只处理 Open.booking == Booking.NONE 的账户(beancount/plugins/check_average_cost.py:69),且只按 posting 自身 units.number < ZERO 判断(beancount/plugins/check_average_cost.py:77),不看余额方向,正数 posting 回补空头持仓的减记不会被检查到。校验按 (账户, units 币种, cost 币种或 None) 分桶维护历史平均成本(beancount/plugins/check_average_cost.py:70-76):桶内已有非零无成本余额时,balance.average().get_only_position() 返回 costNonePositionbeancount/core/inventory.py:352-396 第 391-392 行),随后 average.cost.numberAttributeError。容差 DEFAULT_TOLERANCE = 0.01 经 float 减法再转 Decimalbeancount/plugins/check_average_cost.py:58-59beancount/core/number.py:64-65),上下界各带二进制展开尾数,区间比标称 ±1% 略宽。

5. 跨零点:进入批次匹配,止步于零点

持有 −1 手买入 2 手(或持有 10 卖 13)需要把一条腿拆成"减记 1 + 增记 1"两条。beancount/parser/booking_full.py:672-674 留了 TODO(blais) 和追踪标记 {d3cbd78f1029}beancount/parser/booking_full_test.py:2706-2708 用同一个标记把整个 TestBookCrossover 类 skip 掉,理由写作 "Crossing is not supported yet. Handle this in the v3 C++ rewrite."。该类只有一个 FIFO 用例,描述了期望产出:ante 是 -1 HOOL {110.00 USD, 2015-10-02},apply 是 2 HOOL {112.00 USD},期望 booked 出两条腿、ex-inventory 只剩 1 HOOL {112.00 USD, 2015-02-22}

现行代码不会把这类输入当增记处理:beancount/core/inventory.py:186-202 (Inventory.is_reduced_by) 只要同币种存在符号相反的 position 就返回真,跨零的增记数量与既有反向持仓符号相反,因此仍判定为"减记",被 beancount/parser/booking_full.py:628-633 交给 handle_ambiguous_matches。以 STRICT 为例,beancount/parser/booking_full_test.py:1781-1804 (test_reduce__sign_change_simple) 直接给出结果:持有 10 手申请减记 13 手时报 Not enough lots to reduce,ex-inventory 原样保留 10 手不变,注释写明 "It does not make sense to carry over the cost basis into negative units territory"。当前实际执行批次消耗的 STRICT、STRICT_WITH_SIZE、FIFO、LIFO、HIFO 都不会自动生成越过零点后的增记腿——_booking_method_xifobeancount/parser/booking_method.py:222-247)的 remaining 在候选耗尽后仍大于零就把 insufficient 置真,经入口翻译成同一条错误;NONE 在生产路径中被当作增记绕过(4.4 节),AVERAGE 对候选数大于零直接报不支持(4.5 节),二者都不会走到这一步。

6. 设计决策与理由

决策 理由 证据
七个方法同签名,用字典分派 便于第三方新增方法;77844df5 的提交信息写明是为 "exhaustive testing and allowing others to implement new methods more easily" beancount/parser/booking_method.py:376-38477844df5(2017-01-27)
insufficient 用布尔位回传而非各自造错误 "批次不够扣"的消息只在入口写一次 beancount/parser/booking_method.py:56-68
FIFO/LIFO/HIFO 合并成 _booking_method_xifo 三者只差排序字段与方向;HIFO 初版是整段复制,一天后被合并 beancount/parser/booking_method.py:203-2263e5b4ae0689048b3
STRICT 保留总量特例 候选同向、总量相等时选谁都一样(该前提未被验证) beancount/parser/booking_method.py:91-971c721cfe(2016-10-29)
NONE 不做匹配,减记当增记 让 NONE 账户可以持有混合 inventory;唯一匹配是否该匹配被显式记为未决 beancount/parser/booking_method.py:262-272beancount/parser/booking_full.py:630
AVERAGE 以运行时错误占位而非从枚举移除 枚举成员在 beancount/core/data.py、选项校验、C++ 重写规划里都还在,删除会破坏选项兼容 beancount/parser/booking_method.py:290beancount/core/data.py:67-68beancount/parser/options.py:105-118
匹配筛选与歧义消解分属两个模块 筛选依赖 cost spec 与 balance,消解只依赖候选列表,后者才是可替换的策略 beancount/parser/booking_full.py:637-676

7. 行为细节与边界

现象 后果 证据
booking_method_NONE 返回三元组,入口按四元组解包 若被调用即 ValueErrorbook_reductions 当前生产路径靠 beancount/parser/booking_full.py:630 的条件短路避开,但函数本身可被直接调用触发 beancount/parser/booking_method.py:53-55,274
STRICT 总量分支不填 booked_matches 同一方法两条分支的第二返回值语义不一致;因该值无消费者而不显现 beancount/parser/booking_method.py:95-97,118
booked_matches 存的是候选的原始 Position 不是按 size/match_units 削减后的量;booked_matches 本身不能表示本次实际消耗量,实际消耗量只体现在对应的 booked_reductions beancount/parser/booking_method.py:118,154,241
booked_matches 全链路无读取点 撮合追踪停留在半成品 beancount/parser/booking_full.py:675CHANGES:415-420
HIFO 按 cost.number 排序,不看成本币种 若调用方直接把不同成本币种的候选交给 HIFO,会按裸数值一起排;但标准 _book 管线中,成本币种未指定且库存成本币种混合时,categorize_by_currency 通常先报 Failed to categorize posting,到不了 HIFO 排序 beancount/parser/booking_method.py:200,225beancount/parser/booking_full.py:401-434beancount/parser/booking_full_test.py:406-420
FIFO/LIFO 同日期、HIFO 同成本时批次的先后 由 Python sorted 的稳定性决定,即候选进入 matches 时的相对顺序;Inventory.__iter__ 的文档字符串写明顺序不保证,这一先后关系因此不是受支持的确定行为 beancount/parser/booking_method.py:224-226beancount/core/inventory.py:100-101
STRICT 单候选分支不检查符号 减记方向与候选批次同号时也会被扣,没有 xifo 里那条 continue beancount/parser/booking_method.py:111-119,231-232
STRICT_WITH_SIZE 要求数量精确相等 候选里有"够扣但不等量"的批次时不触发补充判定,仍报 Ambiguous matches beancount/parser/booking_method.py:146-148
STRICT_WITH_SIZE 只在 errors 非空且 len(matches) > 1 时生效 总量特例先命中时不会进入 beancount/parser/booking_method.py:145
报错后部分扣减仍在 booked_reductions 上层见错即 return [], errors 丢弃整组,故不影响余额 beancount/parser/booking_method.py:117,236-240beancount/parser/booking_full.py:678-680
两条 assert 做前置校验 python -O 剥离断言后,可哈希但未知的 method 值通常报 KeyError,不可哈希的 method(如 list)报 TypeError;空 matches 的结果取决于被分派到的具体方法,不统一退化成同一种异常 beancount/parser/booking_method.py:35,48-49
check_average_cost 的容差经 float→D() 上下界带二进制展开尾数,区间比标称 ±1% 略宽(详见 4.5 节) beancount/plugins/check_average_cost.py:35,58-59beancount/core/number.py:64-65
check_average_cost 只按 posting 自身符号判断减记 不看余额方向,正数 posting 回补空头持仓的减记不会被检查;对应无成本子库存非空时还会触发 AttributeError(详见 4.5 节) beancount/plugins/check_average_cost.py:70-83
【文档漂移】选项说明未提 AVERAGE 不可用 解析期接受、运行期报错 beancount/parser/options.py:642-652beancount/parser/booking_method.py:290
【文档漂移】booking_method_NONE 的 Returns 段写 (booked_reductions, booked_matches, insufficient) errors 一项,与其余六个方法的四元组约定不符——但恰好如实描述了它真正返回的三元组 beancount/parser/booking_method.py:259
【文档漂移】_booking_method_xifo 的 docstring 写 "FIFO and LIFO booking method implementations" 合并 HIFO 后未更新 beancount/parser/booking_method.py:204689048b3

8. 测试锁定了什么

beancount/parser/booking_method_test.py 只有 16 行:一句模块 docstring、版权、import unittestfrom beancount.parser import booking_method as bm # noqa: F401__main__ 块,没有任何测试方法。docstring 自陈 "these should be already covered by the tests in booking_full_test, but we may want to add more tests here"(beancount/parser/booking_method_test.py:3-5)。它曾在 2017-01-26(8a129461)随模块拆分建出 2873 行,第二天 77844df5 从中删掉 2870 行、只留下这份占位文件;该提交没有改动 beancount/parser/booking_full_test.pygit show --numstat 77844df5 只涉及 CHANGESbooking_method.pybooking_method_test.py 三个文件),相关覆盖此前就一直在 beancount/parser/booking_full_test.py 里。

覆盖方式说明beancount/parser/booking_full_test.py 3524 行,先用 grep -n '^class\|def test' 取类与用例索引,再按类名逐段读取相关区段(beancount/parser/booking_full_test.py:1413-1670,1766-2057,2232-2547,2550-2740,3087-3121),未逐行通读全文。

该文件用 beancount 语法本身当测试 DSL(beancount/parser/booking_full_test.py:1413-1419 (_BookingTestBase) 自述 "This reuses Beancount's input syntax to create a DSL"):测试体是一段账本文本写在 docstring 里,@parser.parse_doc 解析成 directive,靠 tag 区分角色(beancount/parser/booking_full_test.py:1424-1432VALID_TAGS)——#ante 提供期初 inventory,#apply 是被测的减记(多条则逐条各跑一遍),#booked 断言最终 posting 与错误,#ex 断言期末 inventory,#ambi-matches / #ambi-resolved 断言 handle_ambiguous_matches 的入参与返回,#reduced 断言 book_reductions 的返回,#print 只做调试打印。错误用 posting 上的 error: 元数据写正则(beancount/parser/booking_full_test.py:1611-1625)。中间调用靠 mock.patch.objecttest_utils.record 录制(beancount/parser/booking_full_test.py:1540-1546)。

测试类 位置 / 用例数 锁定的行为
TestParseBookingOptions beancount/parser/booking_full_test.py:1344-1365,3 option "booking_method" 解析为枚举;AVERAGE 解析成功;非法值报 1 个错并退回 STRICT
TestBookReductions beancount/parser/booking_full_test.py:1766-2056,15 无成本减记、无匹配、唯一匹配、STRICT 歧义(4 种 cost spec 写法都报 Ambiguous matches)、NONE 产出混合 inventory、多条减记连续消耗、FIFO/HIFO 多腿、总量特例不报错、跨币种候选被排除
TestBookAmbiguous beancount/parser/booking_full_test.py:2232-2392,9 2 个 FIFO 精确成本匹配用例(test_ambiguous__NONE__matching_existing1/2,方法名带 NONE 字样但装饰器是 @book_test(Booking.FIFO));4 个 NONE 用例锁定不匹配 posting 原样追加进非混合/混合 inventory;3 个 STRICT 用例覆盖无匹配报 No position matches、超量报 Not enough lots to reduce、混合 inventory 下报 No position matches
TestBookAmbiguousFIFO beancount/parser/booking_full_test.py:2394-2548,8 零数量减记、消耗第一批的一部分/全部、消耗前两批、消耗前三批(部分与恰好各一例)、超量报错
TestBookAmbiguousLIFO beancount/parser/booking_full_test.py:2550-2704,8 与 FIFO 逐条对称,方向相反
TestBookCrossover beancount/parser/booking_full_test.py:2706-2735,1 整类 @unittest.skip,见第 5 节
_TestBookAmbiguousAVERAGE beancount/parser/booking_full_test.py:2737-3033,15 整类 @unittest.skip("Booking.AVERAGE is disabled.");类名下划线前缀不影响 unittest 默认 loader 的收集(实测 loadTestsFromModule 对下划线开头的 TestCase 子类仍会收集),真正让全部用例跳过的是类级装饰器;内部另有两条方法自带 @unittest.skip("FIXME enable this when supporting explicit cost reductions")beancount/parser/booking_full_test.py:2939,2964
TestStrictWithSize beancount/parser/booking_full_test.py:3087-3120,2 单个同尺寸候选被自动选中;两个同尺寸候选取 cost.date 最早的一个

没有测试覆盖的行为:booking_method_NONE 函数本身(生产路径不会触发,返回值无任何断言);booking_method_AVERAGE 的报错分支(_TestBookAmbiguousAVERAGE 断言的是历史实现,且整类被跳过);HIFO 在成本币种不同的候选间排序;FIFO/LIFO 同日期、HIFO 同成本数值时批次的并列顺序(唯一启用的 HIFO 用例 beancount/parser/booking_full_test.py:1960-1982 只用了三个不同成本);STRICT 总量特例在空头方向的表现;STRICT 单候选分支在减记方向与候选同号时的行为(混合 inventory 用例 beancount/parser/booking_full_test.py:2370-2391 在进入策略前即因无候选报错);STRICT_WITH_SIZE 无精确等量候选、或总量特例先命中时的两条分支(beancount/parser/booking_full_test.py:3087-3120 只覆盖尺寸判定成功一侧);assertpython -O 剥离后非法 method、空 matches 报何种异常;booked_matches 第二返回值;_booking_method_xifop.cost 为假的排序键分支;check_average_cost 对空头回补不检查方向、无成本子库存触发 AttributeError、float 转 Decimal 的容差尾差(4.5 节)——唯一测试 beancount/plugins/check_average_cost_test.py:10-63 只覆盖普通多头减记。

9. 演变史

日期 提交 / 记录 变化
2016-05-09 a89e8604 实现 average cost booking 的基础场景(当时路径为 src/python/beancount/parser/booking_full.py
2016-08-25 5f60b500 Disabled AVERAGE booking method in the main branch
2016-10-29 1c721cfe STRICT 增加总量特例:候选之和等于请求量时全部匹配
2017-01-26 8a129461 beancount/parser/booking_full.py 拆出 beancount/parser/booking_method.py(194 行),同时建 beancount/parser/booking_method_test.py(2873 行);CHANGES:1398-1400
2017-01-27 77844df5 拆成一方法一函数以便逐一测试,beancount/parser/booking_method_test.py 删掉 2870 行(未改动 booking_full_test.py);提交作者日期 2017-01-27,CHANGES:1401-1403 这条记录归在上一条 2017-01-26 的日期标题下
2017-04-30 859f341e 仓库布局 src/python/beancount/...beancount/...
2018-08-05 CHANGES:672-676 新增 check_average_cost 插件,作为 AVERAGE 实现前的近似校验
2019-03-16 50d0bc78 为交易撮合追踪加第二返回值 booked_matchesCHANGES:415-420 自陈未完成
2020-06-10 092a099d 直接 from decimal import Decimal,不再经 beancount.core.number 间接导入
2020-11-22 8c0d9d25 外部贡献者实现 AVERAGE 与 {*} 合并语法;仅存在于 origin/average_booking_rollback,不在 HEAD 祖先链
2021-02-20 0d46c7cd 新增 STRICT_WITH_SIZE 方法与对应枚举成员
2022-06-22 3e5b4ae0 新增 HIFO(外部贡献),整段复制 xifo 循环体,含一行 print(match.cost) 调试残留
2022-06-23 689048b3 合并为 _booking_method_xifo(sortattr, reverse_order),删掉 print
2024-06-16 48a311a077d509062a455c79 ruff 格式化、lint、移除 pylint 指令
2024-12-22 85950542fd6b845f 类型注解改进、isort

10. 与其他模块的关系

11. 参考索引

beancount/parser/booking_method.py:1-3 模块 docstring;8-20 导入;23-28 AmbiguousMatchError;31-70 handle_ambiguous_matches;73-121 STRICT;124-158 STRICT_WITH_SIZE;161-172 FIFO;175-186 LIFO;189-200 HIFO;203-247 _booking_method_xifo;250-274 NONE;277-291 AVERAGE 报错;293 FIXME;295-373 if False 历史实现;376-384 _BOOKING_METHODS

beancount/parser/booking_method_test.py:1-6 docstring;11-13 导入;15-16 __main__

beancount/parser/booking_full_test.py:1344-1365;1413-1670(1424-1432 tag 集合,1436-1490 DSL 说明,1540-1546 record 打桩,1583-1609 中间调用断言,1611-1624 错误断言,1626-1668 posting 比较);1766-2056;2232-2392;2394-2548;2550-2704;2706-2735;2737-3033;3087-3120。

其它beancount/parser/booking_full.py:555,564-715,628-633,637-657,659-670,672-674,675-676,678-680,690-691beancount/parser/booking.py:40-44,87-130beancount/core/data.py:55-77,104-115beancount/parser/options.py:105-118,640-662beancount/core/flags.py:14beancount/core/inventory.py:100-101,266-275,352-396beancount/core/position.py:386-388beancount/core/number.py:64-65beancount/plugins/check_average_cost.py:1-11,35,58-59,69-83beancount/plugins/check_average_cost_test.py:10-63CHANGES:415-420,672-676,1096-1103,1205-1208,1398-1403

commita89e8604(2016-05-09)、5f60b500(2016-08-25)、1c721cfe(2016-10-29)、8a129461(2017-01-26)、77844df5(2017-01-27)、859f341e(2017-04-30)、50d0bc78(2019-03-16)、092a099d(2020-06-10)、8c0d9d25(2020-11-22)、19d67a3d(2021-02-14,二者均只在 origin/average_booking_rollback 分支)、0d46c7cd(2021-02-20)、3e5b4ae0(2022-06-22)、689048b3(2022-06-23)、48a311a0/77d50906/2a455c79(2024-06-16)、85950542/fd6b845f(2024-12-22)。