目录

09 · booking.py 与 booking_full.py:批次匹配与缺失数字的补全

核对基线 · 范围 · 依赖

核对基线:beancount 仓库 commit 97472138(2026-08-22)。路径相对仓库根目录,引用格式 文件:起-止行 (名称),行号已逐条核对。 本篇范围beancount/parser/booking.py(224 行)、beancount/parser/booking_full.py(1049 行),及 beancount/parser/booking_test.py(185 行)、beancount/parser/booking_full_test.py(3524 行)。 上游依赖beancount/core/interpolate.pyinfer_tolerancescompute_residualquantize_with_tolerance 与两个 meta 键(07 篇);beancount/core/inventory.py(04 篇);beancount/core/position.pyCost/CostSpec/Position(03 篇);beancount/core/amount.py(01 篇);beancount/core/data.py:55-77 (Booking)beancount/parser/booking_method.py:31-70 (handle_ambiguous_matches)下游使用者:主加载流水线在 beancount/loader.py:605-606 调用 booking.book(),位于解析之后、插件与校验之前;beancount/scripts/example.py:189 内的 parse() 辅助函数也直接调用它,被安装为命令行入口的 bean-examplepyproject.toml:57)经由该函数生成示例账本;beancount/parser/cmptest.py:46 在测试基类里调用同一入口。

1. 模块解决什么问题

解析器交出的是不完整的交易:分录可以只写币种不写数字、可以整行只有账户名(自动过账),成本可以写成 {}{USD}{100.00 # 9.95 USD}。补全它要同时解决两件互相依赖的事:批次匹配(减仓应匹配到哪个库存批次,从而知道成本)与插值(按借贷平衡反推唯一缺失的数字)。

beancount/parser/booking_full.py:1-71 的模块 docstring 用两笔几乎一样的交易说明依赖是双向的。:13-16-10 HOOL {USD}1000 USD-200 USD,两条已知腿的权重和为 1000 + (-200) = 800 USD,取负除以 -10 单位反推出单位成本 80 USDbeancount/parser/booking_full.py:892-905,957-965)——docstring 正文声称由此能 "back out a cost of 100 USD / HOOL",与实际公式算出的结果不符;:23-26 去掉 Income:Gains 的数字后插值失败,只能先按 FIFO 选出最老的 10 股定下成本再回头算收益。两条观察(:32-42):后者远比前者常见;只有减仓能匹配,加仓不能。由此定下顺序(:52-65)——先只对减仓匹配、失败即早退;加仓保持 CostSpec 类型以待插值,若未显式写成本日期则先补成父交易日期(:700-704);再插值,此时才可能补出加仓成本;最后统一转 Cost。末尾(:66-70)承认更完整的做法是在两步间迭代到收敛,作者判断收益太小未实现。

2. 结构一览

名称 位置 作用
booking.BookingError beancount/parser/booking.py:20-23 source/message/entry
booking.book beancount/parser/booking.py:26-54 入口:建记账法字典 → 转调 → 兜底校验
booking.validate_missing_eliminated beancount/parser/booking.py:57-83 扫残留的 MISSING
booking.validate_inventory_booking beancount/parser/booking.py:86-136 死代码,见 3.1
booking.convert_lot_specs_to_lots / convert_spec_to_cost :139-192:195-224 死代码及其转换函数
booking_full.book / _book beancount/parser/booking_full.py:108-127:130-254 主循环,book 丢掉第三个返回值
Refer / get_bucket_currency :287-293:265-284 分录的三个币种引用;桶币种优先级
categorize_by_currency :296-486 按币种分组
replace_currencies :489-528 把推断的币种写回 Posting
has_self_reduction :539-561 自我对冲检测,已关闭
book_reductions :564-715 减仓匹配
compute_cost_number / convert_costspec_to_cost :718-744:747-774 两段重叠的单价计算
MissingType :780-786 UNITS/COST_PER/COST_TOTAL/PRICE
interpolate_group :798-1049 组内补一个数字
四个错误类型 :100-105:257-262:531-536:790-795 SelfReduxErrorCategorizationErrorReductionErrorInterpolationError

3. book() 的外层与主循环

3.1 booking.py:记账法字典、兜底校验与三段死代码

booking_methods = collections.defaultdict(lambda: options_map["booking_method"])
for entry in incomplete_entries:
    if isinstance(entry, data.Open) and entry.booking:
        booking_methods[entry.account] = entry.booking

beancount/parser/booking.py:41-44。账户的记账法来自 open 指令的 booking 字段,缺省落到全局选项 booking_methodbeancount/parser/options.py:640-660,默认 STRICT)。调用方传入 defaultdict,使未在 open 里显式配置 booking 的账户自动继承全局记账法;beancount/parser/booking_full.py:628,555 直接下标 methods[account],换普通 dict 须预先塞满所有会被访问的账户键,否则抛 KeyErrorvalidate_missing_eliminated:57-83)在其后再扫一遍,units 的两个字段或 cost 的四个字段还留着 MISSING 就报 "Transaction has incomplete elements"(:67-80);此检查假定 costNone 时必是 Cost——.numberCost 独有字段,CostSpecbeancount/core/position.py:28-66)只有 number_per/number_total,3.2 节说明何时会有 CostSpec 残留触发 AttributeError。另有三个函数无生产调用点,只被测试引用:

book() 收尾调 validate_missing_eliminatedbeancount/parser/booking.py:57-83)扫残留的 MISSING:逐条 posting 判 MISSING in (units.number, units.currency),以及 cost is not None and MISSING in (cost.number, cost.currency, cost.date, cost.label):72-77)。它只按 Cost 的字段名取值——CostSpec 没有 number 字段(它是 number_per/number_totalbeancount/core/position.py:45-66),所以这个兜底只对已转成 Cost 的结果安全。interpolate_group 在"带成本的分录不能推价格"这条错误路径上原样返回仍带 CostSpec 的 postings(beancount/parser/booking_full.py:986-995),若 units 已完整、流程继续走到这里,访问 cost.number 会抛 AttributeError 而不是记一条错误。

3.2 _book 的主循环:两级错误退出

beancount/parser/booking_full.py:161-254。对每条 Transaction:分类(:164)→ 回填币种(:168)→ 算容差(:171-179)→ 逐币种组做匹配(:215-217)与插值(:229-231)→ 装回交易并写 __tolerances__:238-240)→ 用最终结果更新余额(:248-250)。

两处 continue 层级不同,后果差别很大::164-167 那处在 for entry in entries 一层,跳过末尾的 new_entries.append(entry):252),分类失败丢弃整条交易:220-222 那处在币种组循环里,匹配失败只丢该组分录,交易保留但可能少腿。插值错误不 continue:233-235),主循环无条件 repl_postings.extend(inter_postings),但去留取决于 interpolate_group 的返回值:缺口数超过一个时函数把 out_postings 置空(:874),该币种组全部分录从结果消失;总价无法反推单价、或带成本分录无法推价格这两个具体错误分支则直接返回原始 postings:924,997),未补全、仍含 CostSpec/MISSING 的分录原样并入 repl_postings。两支后果不同::924units.number 仍是 MISSINGvalidate_missing_eliminatedor 短路,只报预期错误;:997 支("Cannot infer price for postings with units held at cost")units 已完整、cost 仍是 CostSpec,触发前述 AttributeError

:184-186 提醒组内 postings 是 entry.postings 的子集且可能含复制出的自动过账,"Never use entry.postings going forward";但 :171,175 算容差用的正是全集——容差整笔交易一份,不分组。:242-250 不复用 book_reductions 的局部余额而重新聚合,注释说这是一致性自检。:194-200 是被 if False: 关掉的自我对冲检查:同账户同币种既加仓又减仓时正确顺序是先减后加,has_self_reduction:539-561)能检出,但整块调用禁用,注释指向 http://furius.ca/beancount/doc/self-reductions:188)。

3.3 双容差:算两份,默认只用宽的

tolerances_max = interpolate.infer_tolerances(entry.postings, options_map, mode="max")
if options_map["use_precise_interpolation"]:
    tolerances_interp = interpolate.infer_tolerances(entry.postings, options_map, mode="min")
else:
    tolerances_interp = tolerances_max

beancount/parser/booking_full.py:170-179。容差承担两个冲突职责:校验"这笔平不平"要宽容(取最大值容纳外部数据的舍入误差),补数字要精确(取最小值以匹配用户写下的最细精度)。2026-04-28 commit ec057e11 修的就是二者混用,随它提交、次日被 573f04bc 删除的 rounding_fix_explanation.mdgit show ec057e11:rounding_fix_explanation.md 可取回)给的例子:4.8 EUR 推出容差 0.05、2.97 EUR 推出 0.005,取最大值后补出的 7.77 被量化成 7.8,账面多出 0.03 EUR 悬空;修法是插值用 min、__tolerances__ 仍写 max。四天后 commit 5704a861 改为默认关闭并加 use_precise_interpolation 选项(beancount/parser/options.py:700-717,默认 False),理由是向后兼容。beancount/parser/booking_full_test.py:3481-3520 用同一输入锁定两条路径:打开得 -7.77,默认得 -7.8,两种情况 __tolerances__["EUR"] 都是 0.05

4. categorize_by_currency:五级推断

beancount/parser/booking_full.py:296-486。把分录按"跟谁配平"的币种分组,之后匹配与插值都在组内独立进行。桶币种优先级由 get_bucket_currency:265-284)定死:成本 > 价格 > units,最后一条还要求成本与价格都为 None。推断分五步,前一步定不下来才轮到后一步:

  1. 成本与价格币种互补:356-362):只写了一个时把已知的复制给 MISSING 的那个,即 docstring(:306-308)说的"两者必须一致"。
  2. 显式已知直接归桶:364-374):并用 sortdict 记下该币种首次出现的下标供末尾排序(:485),保证输出顺序可重复。
  3. 唯一未知腿对唯一已知桶:381-399):条件是 len(unknown) == 1 and len(groups) == 1;无成本无价格则推 units 币种,否则推成本/价格币种。
  4. 查账户 ante-inventory:401-434):units 币种缺失看 balance.currencies(),成本/价格币种缺失看 balance.cost_currencies()恰好一个才采用;仍定不下来报 CategorizationError("Failed to categorize posting N"):430-434,下标从 1 开始,源自 fe6ec215/#249)。
  5. 回填残留的 units 币种:436-448):靠成本或价格入桶但 units 仍 MISSING 的,再用账户余额的唯一币种补一次。

自动过账(units is MISSING 且无价格币种,:366-368)单独装桶,最后复制进每个已存在的币种组:462-465)——一条空行在 USD、CAD 两组各出现一次,各补各的数。超过一条报 "You may not have more than one auto-posting per currency" 并只留第一条(:451-461)。收尾(:467-483)对仍 MISSING 的币种各报一条 "Could not resolve X currency"。

5. replace_currencies:把推断结果写回

beancount/parser/booking_full.py:489-528。逐组按原始下标排序(:506)取 posting 写回币种。units 整个是 MISSING(自动过账)时造 Amount(MISSING, refer.units_currency):509-510),数字留空等插值;否则只替换 currency is MISSING 的字段,并用 replace 标志位保证无字段需改时返回原对象:512-525)。自动过账在多个组各复制一次,同一条原始分录产出多个 Posting,这正是 :184-186 警告的由来。

6. book_reductions:筛候选批次,匹配不上整组作废

beancount/parser/booking_full.py:564-715。对 balances 只读(:609-611),自己维护浅拷贝 local_balances:616-619),使同一交易内多条减仓不重复吃掉同一批持仓(:685-691)。判定减仓要三条同时成立(:629-633):记账法不是 Booking.NONE、账户有余额、balance.is_reduced_by(units)。不带成本或 units 数字缺失的分录原样通过(:622-624)——后半条是 commit 686d5c3f(2017-09-30)补的,此前 HOOL {115.00 USD} 这种省略数量的写法会进匹配逻辑并失败,回归测试见 beancount/parser/booking_full_test.py:2046-2056

候选批次的筛选是六道 continue 过滤(:639-657):

for position in balance:
    if units.currency and position.units.currency != units.currency: continue
    if position.cost is None: continue
    if cost_number is not None and position.cost.number != cost_number: continue
    if isinstance(costspec.currency, str) and position.cost.currency != costspec.currency: continue
    if costspec.date and position.cost.date != costspec.date: continue
    if costspec.label and position.cost.label != costspec.label: continue

即币种、必须带成本、单价、成本币种、日期、标签。cost_numbercompute_cost_number 先算(:637),算不出来(含 MISSING 或两数皆无)返回 None,该道跳过。CostSpec 写了什么就筛什么:{} 全不筛,{2016-01-15} 只按日期筛。

匹配为空时(:660-670)报 ReductionError('No position matches ...')return [], errors,注释写 "This is irreconcilable, remove these postings"——整组作废,不做部分生效;候选非空但无法消歧或数量不够时,handle_ambiguous_matches:675-677)的错误同样触发 return [], errors:678-680)。加仓分支(:692-713)只做一件事:CostSpec 没写日期就填交易日期(:702-704),成本仍是 CostSpec。紧接的注释代码(:706-711)本想给新批次插入唯一标签以支持交易跟踪,FIXME 说它会造成不该有的歧义匹配错误。

7. CostSpec → Cost:两段重复的除法

if number_total is not None:
    cost_total = number_total
    units_number = abs(units.number)
    if number_per is not None:
        cost_total += number_per * units_number
    unit_cost = cost_total / units_number

beancount/parser/booking_full.py:732-739 (compute_cost_number)convert_costspec_to_cost:762-771)几乎逐行重复,差别两处:判断 number_per 的哨兵一个比 None、一个比 MISSING;前者在 number_per is None 时返回 None,后者直接把它当单价。:777 挂着 # FIXME: Refactor compute_cost_number() and convert_costspec_to_cost().

两处的 abs() 来自 2020-09-07 commit 2f14cb5d(修 #364):此前保留符号、只在最后除法里取绝对值,-23 MSFT {106.935 # 6.90 USD} 这类空头带总费用的写法算成 (6.90 + 106.935 × (-23)) / 23,单价为负;修法是把 abs() 提前到累加之前,正确结果 107.235。回归测试两条:beancount/parser/booking_full_test.py:1238-1265 走完整流程,:1334-1340 直接断言 compute_cost_number(CostSpec(3, 6, ...), -12 HOOL) == 3.5

两处除法都不量化Decimal 除法按上下文精度(默认 28 位有效数字)展开,除不尽时留长尾小数,而整个 Cost 元组是 Inventory 的 key(beancount/core/inventory.py:428key = (units.currency, cost)),长尾数字原样成为那批持仓的身份。后续减仓若按四舍五入的单价书写,第三道过滤就匹配不上。

8. interpolate_group:一组最多补一个数字

beancount/parser/booking_full.py:798-1049。先扫一遍收集缺口(:823-853):units.number is MISSINGUNITScost 还是 CostSpecnumber_per/number_totalMISSING 分别记 COST_PER/COST_TOTAL,已是 Cost 的则断言其 numberDecimal:845-850,注释说明减仓成本已在匹配阶段定好,无必要插值);price.number is MISSINGPRICE

三个分支(:858-874):零个缺口就把 CostSpec 全转 Cost 返回;两个及以上报 "Too many missing numbers for currency group '{}'" 并返回空列表;恰好一个才计算。目标值来自 compute_residual:893-895)对其余分录求权重和取负(:905),其余为空时 weight = ZERO:907-909)。

缺口 公式 位置 附加规则
UNITS 有成本 (weight − number_total) / number_per;有价格 weight / price;都没有 weight :911-947 唯一调 quantize_with_tolerance 的分支
COST_PER (weight − number_total) / units.number :957-971 units.number == ZERO 时置 new_posting = None
COST_TOTAL weight − number_per × units.number :973-984 无除法
PRICE abs(weight / units.number) :986-1006 cost is not None 时报 "Cannot infer price for postings with units held at cost"

三条要点:

补出的分录打上 __automatic__:1013-1016)并转 Cost:1020)。出口先断言不再有 CostSpec:1025),再检查带成本的分录:units.number == ZERO 报 "Amount is zero"、cost.number < ZERO 报 "Cost is negative"(:1029-1047);紧邻的注释(:1032-1035)写 "we don't allow either a cost value of zero",但实现并未检查 cost.number == ZERO——零成本实际被接受,注释与代码已经漂移,beancount/parser/booking_test.py:47-55 (test_cost_zero) 直接断言 {0.00 USD} 不产生错误。posting.cost.number is not None 这道前置判断是 commit 76365107 补的(导入器直接构造分录时 Cost.number 可能为 None)。第三个返回值 interpolatednew_posting is not None:1049),所以"删掉多余分录"报告 False

9. 设计决策与理由

决策 理由 证据
先做减仓匹配,加仓不动 匹配只对减仓有定义;加仓成本可能要靠插值 beancount/parser/booking_full.py:38-42,52-58,692-713
不在匹配与插值间迭代到收敛 需要迭代的实际用例极少 beancount/parser/booking_full.py:66-70
按币种分组后各组独立处理 一组一个未知数才有唯一解 beancount/parser/booking_full.py:296-302,863-874
桶币种取成本 > 价格 > units 带成本或价格的腿,权重记在该币种上 beancount/parser/booking_full.py:265-284
匹配失败整组作废 作者注释 "This is irreconcilable, remove these postings" beancount/parser/booking_full.py:660-680 (book_reductions)
book_reductions 不改 balances 无副作用,用局部拷贝承接组内累积 beancount/parser/booking_full.py:609-619
加仓 CostSpec 只补日期不转 Cost 转早了分不清单位成本与总成本哪个待插 beancount/parser/booking_full.py:56-58,694-704
只有 UNITS 分支调用容差量化 提交记录只说明这是给 auto-posting 加的量化,未说明为何只挑这一分支 beancount/parser/booking_full.py:944-947;commit bbdb744b
精确插值默认关闭 会改变已有账本补出的数字 beancount/parser/options.py:700-717
带成本时禁止再推价格 get_weight() 存在 Cost 时直接忽略 price,价格不参与权重,无法由残差反推 beancount/core/convert.py:84-96beancount/parser/booking_full.py:986-997
价格可为零,成本也可为零,只有负成本报错 实现只判 units.number == ZEROcost.number < ZERO,未检查 cost.number == ZERO beancount/parser/booking_full.py:1029-1047beancount/parser/booking_test.py:47-55

10. 行为细节与边界

现象 后果 证据
分类失败的 continue 在外层循环 整条交易被剔除,不只是丢分录 beancount/parser/booking_full.py:164-167 vs beancount/parser/booking_full.py:252
匹配失败的 continue 在内层循环 交易保留但少了该组全部分录,可能不平 beancount/parser/booking_full.py:220-222
插值错误的去留按返回值分流,不统一处理 缺口数 >1:整组消失(out_postings=[]);两个具体错误分支:未补全分录原样并入 beancount/parser/booking_full.py:233-235,874,924,997beancount/parser/booking.py:57-83
methodsdefaultdict 是调用方选择而非硬性要求 beancount/parser/booking_full.py:628,555 直接下标取值;换普通 dict 须预填全部会访问的账户键,否则 KeyError——无直接测试覆盖,属源码可推出的边界 beancount/parser/booking.py:41-44beancount/parser/booking_full.py:555,628
六处 assert X.currency == weight_currency,非四处 三处比 cost.currency、两处比 price.currency、一处比 units.currency;不对抛 AssertionError 中断加载 beancount/parser/booking_full.py:926-928,933-935,939-941,960-962,976-978,1000-1002
自动过账权重为零被静默删除 多写的空行不报错直接消失 beancount/parser/booking_full.py:949-955,1021-1022beancount/parser/booking_full_test.py:1079-1101,1103-1129
COST_PER 遇零数量同样静默删除 该分录整条消失,无错误 beancount/parser/booking_full.py:963-971
COST_PER/COST_TOTAL/PRICE 不量化 长尾小数进入 Cost,而 Cost 是库存 key 的一部分 beancount/parser/booking_full.py:964,979,1003
兜底校验只认 Cost 的字段名 validate_missing_eliminatedcost.number,而 CostSpec 只有 number_per/number_total;"带成本不能推价格"的错误路径原样返回 CostSpec,走到这里会抛 AttributeError beancount/parser/booking.py:72-77beancount/parser/booking_full.py:986-995beancount/core/position.py:45-66
【文档漂移】validate_inventory_booking 说禁止持仓为负 实现检查 is_mixed(),纯负库存不报错 beancount/parser/booking.py:88-93 vs beancount/parser/booking.py:124beancount/parser/booking_test.py:117-128
【文档漂移】convert_lot_specs_to_lots 自称"仅为过渡" SIMPLE 已删,只剩测试引用 beancount/parser/booking.py:144-148CHANGES:1098-1102
自我对冲检查被 if False: 关闭 不参与生产路径,却仍有 9 个测试 beancount/parser/booking_full.py:194-200beancount/parser/booking_full_test.py:2058-2158
容差按整笔交易算,不分组 beancount/parser/booking_full.py:171,175 传全集而非分组子集 beancount/parser/booking_full.py:171-177 vs beancount/parser/booking_full.py:184-186
成本币种与价格币种都显式但彼此不同 不验证二者相等,只取成本币种入桶 beancount/parser/booking_full.py:265-284,356-374,467-483
减仓/加仓由 ante-inventory 中同币种持仓的符号决定,非数量正负本身 空账户里的负数量不算减仓,走增仓分支生成新空头 beancount/core/inventory.py:186-202beancount/parser/booking_full.py:629-633beancount/parser/booking_full_test.py:1687-1719
Booking.NONEbook_reductions 判定条件里即被排除 即使有唯一精确匹配的既有批次也不匹配,直接进增仓分支 beancount/parser/booking_full.py:628-704beancount/parser/booking_method.py:250-274
仅一条自动过账且没有任何已知币种组 复制循环零迭代,交易保留但 postings 为空,无错误 beancount/parser/booking_full.py:183,252,366-368,450-465,485-486
总成本转换与 PRICE 推断的除法无零除保护 units 时除法先于末尾 "Amount is zero" 检查抛出 Decimal 除零异常 beancount/parser/booking_full.py:762-771,986-1006,1027-1047

11. 测试锁定了什么

beancount/parser/booking_test.py 有 10 个 test_* 方法:TestInvalidAmountsErrors:25-67)4 个,锁定四种组合——不带成本的 0 MSFT 不报错,0 MSFT {200.00 USD} 报 "Amount is zero",{0.00 USD} 不报错,{-200.00 USD} 报 "Cost is negative";TestBookingValidation:70-181)6 个,覆盖的全是死函数 validate_inventory_booking

beancount/parser/booking_full_test.py 3524 行、21 个类共 138 个测试方法。本篇按类名与 docstring 归纳覆盖范围,只逐行读了与论点直接相关的段落(:53-93:366-423:594-720:1079-1266:1413-1480:1985-2057:2706-2740:3123-3175:3481-3520),其余按索引取样:

测试类 行号 / 数量 锁定的行为
TestAllInterpolationCombinations :53-93,2 穷举解析器对缺失币种/数量/成本/价格字段组合的语法接受情况;只调 parser.parse_string(),不覆盖 booking、匹配或插值结果
TestCategorizeCurrencyGroup :101-420,12 五级推断各分支;多自动过账报错;靠余额消歧;混合成本币种归桶失败
TestReplaceCurrenciesInGroup :423-575,2 自动过账复制进每组;各字段的币种回填
TestInterpolateCurrencyGroup :594-1266,17 四种 MissingType 的公式;两个缺口报错;三种多余自动过账;容差量化
TestComputeCostNumber :1268-1341,8 单价计算八种输入/边界,含 2f14cb5d 的负数量回归
TestParseBookingOptions :1344-1365,3 booking_method 选项的解析与非法值
_BookingTestBase 系列 :1413-3121 账本语法搭的 DSL:#ante 给前置库存、#apply 给待匹配分录,#booked/#reduced/#ex/#ambi-matches/#ambi-resolved 断言各阶段结果(:1439-1477
TestBookAugmentations / TestBookReductions :1671-2056,6 + 15 加仓不匹配;六道过滤;无匹配、歧义、数量不足报错;省略数量的回归
TestHasSelfReductions :2058-2158,9 已被 if False: 关闭的函数
TestBookReductionsSelf :2160-2230,4 同交易内先加仓后减仓的场景;4 个测试仅第 1 个(:2161-2176)实际执行,其余 3 个标 @unittest.skip 跳过
TestBookAmbiguous* :2232-2704,9 + 8 + 8 NONE/STRICT/FIFO/LIFO 的消歧结果
TestBookCrossover(不构成当前覆盖) :2706-2735,1 整类 @unittest.skip("Crossing is not supported yet…{d3cbd78f1029}"):2706-2708),与 beancount/parser/booking_full.py:672-674 的 TODO 同标记——保存的是单腿穿越零线拆一减一加的期望行为,不是已锁定的当前行为
_TestBookAmbiguousAVERAGE :2738-3033,15 unittest 正常收集该类,但整类被 @unittest.skip("Booking.AVERAGE is disabled."):2737)标记跳过;下划线前缀不影响收集
TestBasicBooking / TestStrictWithSize :3035-3082,3 + :3084-3121,2 前者锁定同日同成本归并、不同日/成本各留批次;后者锁定 STRICT_WITH_SIZE 按数量匹配单批与多批
TestBookingApi / TestBook :3123-3478,1 + 11 Booking 每个取值跑 bf.book;匹配整体行为,后者挂 # FIXME: TODO - Rewrite these tests
TestInterpolationRounding :3481-3520,2 双容差的开与关

未覆盖,按程度分三类。全无调用initial_balances_book() 直接赋给 balances 并原地修改,不做复制,beancount/parser/booking_full.py:154-158,249,传普通 dict 遇新账户会在 :249KeyError)、六处 assert X.currency == weight_currency 的失败路径。执行到但去留未断言:"分类失败丢整条交易" 与 "匹配失败只丢一组" 均被场景测试触发(beancount/parser/booking_full_test.py:1548-1559,1723-1739,1807-1826),但 assertPostings 在有 book_errors 时被跳过(:1557-1559),不验证具体去留。集成执行但无针对性断言validate_missing_eliminatedbooking.book() 集成测试(beancount/parser/booking_test.py:25-67)执行过,但无测试直接构造残留 MISSING 断言其自身错误分支。

12. 演变史

日期 提交 / 记录 变化
2015-07-23 700ad2bc booking 逻辑从 parser.grammar 移出,独立成 parser.booking
2015-09-05 db9d7f14 simple 与 full(新)两套 booking 算法代码分拆
2015-09-06 836c914a Lot/LotSpec 迁移为 Cost/CostSpec 数据结构,全仓库跟改
2016-02-06 CHANGES:2613-2622 CHANGES 集中记录该结构迁移:解析器改输出 CostSpecPosting.position 拆平为 .units/.cost
2016-06-29 80c939ce 修价格插值汇率取反
2016-08-25 5f60b500 主干禁用 AVERAGE
2016-10-14 bbdb744bcc82c533 自动过账按容差量化;加 __tolerances__
2016-10-23 666518e2(21:28)→4581d78e(21:37) 前者加负成本检查;后者标题写 "zero cost",实加的却是零 units 检查("Amount is zero"),零成本仍不报错
2016-10-30 CHANGES:1710-1719 FULL 成为默认记账算法
2017-04-30 859f341e 布局由 src/python/beancount/… 改为 beancount/…,此前 commit 用旧路径
2017-09-30 686d5c3f 省略 units 数字时减仓匹配不再失败(CHANGES:1254
2018-03-13 e30335ef41b8ef510da6a23c 删 SIMPLE 与 booking_algorithmconvert_* 成死代码
2018-03-22 fe6ec215 错误信息里的分录序号从 0 改为 1(#249)
2020-09-07 2f14cb5d 修 #364:空头 {per # total} 算出负单价
2021-01-09 76365107 负成本检查前先判 Cost.number is not None
2021-02-28 0abd49e4 加入被 skip 的 TestBookCrossover 为 v3 留文档
2021-03-13 2578ee2c9ef7d0a7 book() 增加 initial_balances 并修其 bug
2026-04-28 ec057e11 双容差:插值用 min、元数据写 max
2026-04-29 573f04bc 删除该说明文件
2026-05-02 5704a861 use_precise_interpolation,双容差默认关闭

13. 与其他模块的关系

14. 参考索引

beancount/parser/booking.py:1-3 docstring;20-23 BookingError;26-54 book(41-44、47-49、52);57-83 validate_missing_eliminated(72-76);86-136 validate_inventory_booking(86、88-96、119-120、124-134);139-192 convert_lot_specs_to_lots(144-148、178-186);195-224 convert_spec_to_cost(210-221)。

beancount/parser/booking_full.py:1-71 docstring(13-16、23-26、32-42、52-65、66-70);100-105 SelfReduxError;108-127 book;130-254 _book(154-159、164-168、170-179、183-186、194-200、215-222、229-235、238-240、242-250、252);257-262 CategorizationError;265-284 get_bucket_currency;287-293 Refer;296-486 categorize_by_currency(304-321、342-364、356-362、366-368、381-399、401-434、436-448、450-465、467-483、485-486);489-528 replace_currencies(506、509-510、512-525);531-536 ReductionError;539-561 has_self_reduction;564-715 book_reductions(596-619、622-624、629-633、637-657、660-670、672-674、675-680、685-691、700-704、706-711);718-744 compute_cost_number(732-739);747-774 convert_costspec_to_cost(762-771);777 FIXME;780-786 MissingType;790-795 InterpolationError;798-1049 interpolate_group(812-817、823-853、845-850、858-874、883-909、911-955、914-924、944-947、957-971、973-984、986-1006、1012-1022、1025、1029-1047、1049)。

beancount/parser/booking_test.py:19-23、25-67、70-116、117-128、130-142、144-161、163-181。

beancount/parser/booking_full_test.py:39-50、53-93、101-420、423-575、578-591、594-655、659-1266、1079-1129、1173-1236、1238-1266、1268-1341、1344-1365、1413-1477、1548-1559、1568、1599、1651、1671-1764、1687-1719、1766-2056、2046-2056、2058-2158、2160-2230、2232-2392、2394-2548、2550-2704、2706-2735、2737-3033、3035-3085、3087-3121、3123-3149、3137、3152-3478、3481-3520。

其它beancount/core/data.py:55-77beancount/core/inventory.py:186-202,428,459beancount/core/interpolate.py:72-94,97,236,242,364-394beancount/core/convert.py:84-96beancount/core/position.py:28-66beancount/parser/booking_method.py:31-70,250-274,277-296,376-384beancount/parser/grammar.py:519,586-611,590-606beancount/parser/options.py:640-660,700-717beancount/parser/cmptest.py:40-47,62-68beancount/loader.py:605-606beancount/scripts/example.py:172-190pyproject.toml:57CHANGES:1098-1102,1254,1710-1719,2613-2622

commit700ad2bc(2015-07-23)、db9d7f14(2015-09-05)、836c914a(2015-09-06)、80c939ce(2016-06-29)、5f60b500(2016-08-25)、bbdb744b/cc82c533(2016-10-14)、666518e2/4581d78e(2016-10-23)、859f341e(2017-04-30)、686d5c3f(2017-09-30)、e30335ef/41b8ef51/0da6a23c(2018-03-13)、fe6ec215(2018-03-22)、2f14cb5d(2020-09-07)、76365107(2021-01-09)、0abd49e4(2021-02-28)、2578ee2c/9ef7d0a7(2021-03-13)、ec057e11(2026-04-28)、573f04bc(2026-04-29)、5704a861(2026-05-02)。