一条减记 posting 写成
Assets:Account -5 HOOL {},只说了要卖 5
股,没说卖哪一批。booking_full.book_reductions(beancount/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。
| 名称 | 位置 | 作用 |
|---|---|---|
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 枚举 → 函数的分派表 |
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
为交易撮合追踪加的(50d0bc78,CHANGES:415-420
自陈"This doesn't work yet")。它在
beancount/parser/booking_full.py:675 被接成
matched_postings 后再无任何读取点。
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。
(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-02 与
102.00 USD, 2014-06-01)验证了这一步——cost.date
更早的 2014-06-01 批次胜出。
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
的总量分支更是完全不填这个列表。
# 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。
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-296。return
之后是一段 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_MERGING(beancount/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)。模块顶部的
convert、inventory、flags、Decimal、Cost
五个导入只服务于这段死代码。
触发不要求真正的歧义:beancount/parser/booking_full.py:659-676 (book_reductions)
只在候选数为零时提前 return [], errors 报
No 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.py、beancount/parser/booking_full.py、grammar.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()
返回 cost 为 None 的
Position(beancount/core/inventory.py:352-396
第 391-392 行),随后 average.cost.number 抛
AttributeError。容差 DEFAULT_TOLERANCE = 0.01
经 float 减法再转
Decimal(beancount/plugins/check_average_cost.py:58-59;beancount/core/number.py:64-65),上下界各带二进制展开尾数,区间比标称
±1% 略宽。
持有 −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_xifo(beancount/parser/booking_method.py:222-247)的
remaining 在候选耗尽后仍大于零就把
insufficient 置真,经入口翻译成同一条错误;NONE
在生产路径中被当作增记绕过(4.4 节),AVERAGE
对候选数大于零直接报不支持(4.5 节),二者都不会走到这一步。
| 决策 | 理由 | 证据 |
|---|---|---|
| 七个方法同签名,用字典分派 | 便于第三方新增方法;77844df5 的提交信息写明是为
"exhaustive testing and allowing others to implement new methods more
easily" |
beancount/parser/booking_method.py:376-384;77844df5(2017-01-27) |
insufficient 用布尔位回传而非各自造错误 |
"批次不够扣"的消息只在入口写一次 | beancount/parser/booking_method.py:56-68 |
FIFO/LIFO/HIFO 合并成 _booking_method_xifo |
三者只差排序字段与方向;HIFO 初版是整段复制,一天后被合并 | beancount/parser/booking_method.py:203-226;3e5b4ae0、689048b3 |
| STRICT 保留总量特例 | 候选同向、总量相等时选谁都一样(该前提未被验证) | beancount/parser/booking_method.py:91-97;1c721cfe(2016-10-29) |
| NONE 不做匹配,减记当增记 | 让 NONE 账户可以持有混合 inventory;唯一匹配是否该匹配被显式记为未决 | beancount/parser/booking_method.py:262-272;beancount/parser/booking_full.py:630 |
| AVERAGE 以运行时错误占位而非从枚举移除 | 枚举成员在 beancount/core/data.py、选项校验、C++
重写规划里都还在,删除会破坏选项兼容 |
beancount/parser/booking_method.py:290;beancount/core/data.py:67-68;beancount/parser/options.py:105-118 |
| 匹配筛选与歧义消解分属两个模块 | 筛选依赖 cost spec 与 balance,消解只依赖候选列表,后者才是可替换的策略 | beancount/parser/booking_full.py:637-676 |
| 现象 | 后果 | 证据 |
|---|---|---|
booking_method_NONE 返回三元组,入口按四元组解包 |
若被调用即 ValueError;book_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:675;CHANGES:415-420 |
HIFO 按 cost.number 排序,不看成本币种 |
若调用方直接把不同成本币种的候选交给 HIFO,会按裸数值一起排;但标准
_book
管线中,成本币种未指定且库存成本币种混合时,categorize_by_currency
通常先报 Failed to categorize posting,到不了 HIFO
排序 |
beancount/parser/booking_method.py:200,225;beancount/parser/booking_full.py:401-434;beancount/parser/booking_full_test.py:406-420 |
| FIFO/LIFO 同日期、HIFO 同成本时批次的先后 | 由 Python sorted 的稳定性决定,即候选进入
matches 时的相对顺序;Inventory.__iter__
的文档字符串写明顺序不保证,这一先后关系因此不是受支持的确定行为 |
beancount/parser/booking_method.py:224-226;beancount/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-240;beancount/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-59;beancount/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-652;beancount/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:204;689048b3 |
beancount/parser/booking_method_test.py 只有 16
行:一句模块
docstring、版权、import unittest、from 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.py(git show --numstat 77844df5
只涉及
CHANGES、booking_method.py、booking_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-1432
的 VALID_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.object 加 test_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
只覆盖尺寸判定成功一侧);assert 被 python -O
剥离后非法 method、空 matches
报何种异常;booked_matches
第二返回值;_booking_method_xifo 中 p.cost
为假的排序键分支;check_average_cost
对空头回补不检查方向、无成本子库存触发
AttributeError、float 转 Decimal
的容差尾差(4.5 节)——唯一测试
beancount/plugins/check_average_cost_test.py:10-63
只覆盖普通多头减记。
| 日期 | 提交 / 记录 | 变化 |
|---|---|---|
| 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_matches;CHANGES: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 | 48a311a0、77d50906、2a455c79 |
ruff 格式化、lint、移除 pylint 指令 |
| 2024-12-22 | 85950542、fd6b845f |
类型注解改进、isort |
beancount/parser/booking.py:40-44
建一个 defaultdict,默认值取
options_map["booking_method"](beancount/parser/options.py:640-662
定义,默认 Booking.STRICT),再用每个 Open
指令的 booking
字段覆盖对应账户(beancount/core/data.py:104-115)。beancount/core/data.py:104-108
的注释写明 "In practice, this attribute should be left unspecified
(None) in the vast majority of cases"。beancount/parser/booking_full.py:564-715 (book_reductions)
负责筛候选、调用本模块、把结果写回 local
balance(beancount/parser/booking_full.py:690-691),并在收到错误时
return [], errors 丢弃整组 posting。matches 是
position.Position,返回的是
data.Posting,两者靠
posting._replace(units=..., cost=...)
转换;position.to_string
用于拼错误消息(beancount/parser/booking_method.py:61,63,105)。inventory.Inventory、convert.get_weight、flags.FLAG_MERGING、Decimal、Cost
只在 beancount/parser/booking_method.py:295-373
内被引用。beancount/plugins/check_average_cost.py
针对 Booking.NONE
账户做平均成本近似校验;beancount/parser/booking.py:87-130 (validate_inventory_booking)
对 Booking.NONE 账户跳过"不得转负"的检查。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-691;beancount/parser/booking.py:40-44,87-130;beancount/core/data.py:55-77,104-115;beancount/parser/options.py:105-118,640-662;beancount/core/flags.py:14;beancount/core/inventory.py:100-101,266-275,352-396;beancount/core/position.py:386-388;beancount/core/number.py:64-65;beancount/plugins/check_average_cost.py:1-11,35,58-59,69-83;beancount/plugins/check_average_cost_test.py:10-63;CHANGES:415-420,672-676,1096-1103,1205-1208,1398-1403。
commit:a89e8604(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)。