这一篇会把 convert.py
的代码一段段贴出来读。代码是 beancount
的原文;代码里的中文注释是本文加的。
前四篇搭好了数据结构。这一篇回答一个看起来简单的问题:一份持仓值多少钱?
答案是:看你问的是哪种"值"。
你手上有 10 股苹果,1 月买的,当时 523.45 美元一股。现在市价 600 美元。这份持仓"值多少"?
| 问法 | 答案 | 什么时候用 |
|---|---|---|
| 有多少 | 10 股 | 想知道持有量 |
| 当初花了多少 | 5234.50 美元 | 算成本、看投入 |
| 记账时用哪个数配平 | 5234.50 美元 | 检查这笔交易平不平 |
| 现在值多少 | 6000 美元 | 出资产报表、算浮盈 |
四个答案都对,用在不同地方。convert.py
把这四种取法收敛成四个函数,一共 232 行:
get_units → 有多少 (10 HOOL)
get_cost → 花了多少 (成本 × 数量)
get_weight → 配平用哪个 (见第四节)
get_value → 现在值多少 (要查价格表)
为什么要专门写一个模块? 因为如果不统一,每个需要"取值"的地方都会自己写一遍判断:有成本吗?有价格吗?该用哪个?这种判断散落在几十个调用点里,改一次规则就要改几十处,而且一定会有地方漏改。收敛成四个函数之后,"什么叫成本""什么叫权重"只有一处定义。
四个函数的签名长得一模一样:第一个参数是持仓,返回一个金额。这样它们可以直接交给
04 篇讲的
Inventory.reduce,把整个账户的持仓一次性换算成另一种口径:
cost_inv = inv.reduce(convert.get_cost) # 整个账户按成本算
value_inv = inv.reduce(convert.get_value, price_map, date) # 按市值算
convert.py:34-43:
def get_units(pos):
assert isinstance(pos, Position) or type(pos).__name__ == "Posting"
# 检查传进来的确实是持仓或者分录腿(这个古怪的写法第八节讲)
return pos.units
# 原样返回数量,什么都不做
一个什么都不做的函数为什么要存在?
因为它要跟另外三个长得一样。有了它,"按数量汇总"和"按成本汇总"就是同一段代码换个参数的事:
reducer = get_cost if at_cost else get_units # 报表代码里真实的写法
inv.reduce(reducer)
如果没有 get_units,这里就得写成 if-else
两条分支。为了让接口整齐而写一个恒等函数,是划算的。
convert.py:46-60:
def get_cost(pos):
assert isinstance(pos, Position) or type(pos).__name__ == "Posting"
cost = pos.cost
return (
Amount(cost.number * pos.units.number, cost.currency)
# 有成本:单价 × 数量,用成本的币种
# 10 股 × 523.45 USD = 5234.50 USD
if (isinstance(cost, Cost) and isinstance(cost.number, Decimal))
# 条件有两层:
# 1. cost 得是 Cost(成品),不能是 CostSpec(03 篇讲的半成品)
# 2. 单价得是真正的数,不能是 MISSING 那种占位符
else pos.units
# 条件不满足:原样返回数量,不报错
)
注意最后那个
else:取不到成本时不报错,而是把数量原样还回去。
一百块现金没有成本可言,get_cost
返回的就是一百块现金本身。这样把整个账户 reduce
一遍时,现金和股票能混在一起算,不必先分类。
这个"取不到就返回原值"的做法在本模块反复出现,第六节会讲它的代价。
这是本模块最重要的函数,因为它定义了复式记账里"这笔账平不平"到底在比什么。
源码的 docstring
里直接给了一张对照表(convert.py:74-77):
Assets:Account 5234.50 USD -> 5234.50 USD
Assets:Account 3877.41 EUR @ 1.35 USD -> 5234.50 USD
Assets:Account 10 HOOL {523.45 USD} -> 5234.50 USD
Assets:Account 10 HOOL {523.45 USD} @ 545.60 CAD -> 5234.50 USD
左边是四种写法,右边是这条腿拿去配平时用的金额。四种写法算出来都是 5234.50 美元,所以它们在配平这件事上是等价的。
逐行看:
第四行是关键。代码怎么做到的(convert.py:88-104):
units = pos.units
cost = pos.cost
# It the object has a cost, use that as the weight, to balance.
# (注释:有成本就用成本作为配平金额)
if isinstance(cost, Cost) and isinstance(cost.number, Decimal):
weight = Amount(cost.number * pos.units.number, cost.currency)
# 第一优先:有成本,用成本。这里直接 return 之外的路都不走了,
# 所以第四行那个 @ 545.60 CAD 根本没机会参与
else:
# Otherwise use the postings.
weight = units
# 没成本:先假定用数量本身(对应第一行)
# Unless there is a price available; use that if present.
if not isinstance(pos, Position):
# 只有分录腿才可能带价格,纯持仓没有这个字段
price = pos.price
if price is not None:
# Note: Here we could assert that price.currency == units.currency.
if price.number is MISSING or units.number is MISSING:
converted_number = MISSING
# 两个乘数只要有一个还是占位符,结果也是占位符。
# 不能直接相乘——那会崩溃
else:
converted_number = price.number * units.number
# 正常情况:价格 × 数量(对应第二行)
weight = Amount(converted_number, price.currency)
return weight
成本优先于价格,这是一条业务决定,不是技术细节。它的含义是:你买入时实际付出的代价,才是这笔交易需要配平的东西;后来标注的市价只是备注。
第四行那种写法(既有成本又有价格)在真实账本里很常见:你买了美股,成本记美元,同时想记一下当天的加元价格供以后查。系统必须明确规定用哪个,否则同一笔交易在不同实现里可能一个平一个不平。
那段 MISSING 传播的代码有个来历:2017 年修的一个
bug。打印器要显示一条还没补全的分录时,会来问它的配平金额;如果这时直接做乘法,占位符参与运算会当场崩溃。所以这里显式地判断一次——币种保住,数值留空。
前三个函数都只看持仓自己。第四个不行——"现在值多少"得去查行情表(convert.py:140-156):
value_currency = (
(isinstance(cost, Cost) and cost.currency)
# 第一优先:用成本的币种。
# 你按美元成本买的股票,市值也用美元报
or (hasattr(pos, "price") and pos.price and pos.price.currency)
# 第二优先:用分录上标注的价格的币种
or None
# 都没有:不知道该换成什么币种
)
if isinstance(value_currency, str):
# 推断出了目标币种,去查行情表
base_quote = (units.currency, value_currency)
price_date, price_number = prices.get_price(price_map, base_quote, date)
# 查"苹果股票对美元"在这一天的价格
if output_date_prices is not None:
output_date_prices.append((price_date, price_number))
# 调用方可以传一个列表进来,收集查到了哪些价格
if price_number is not None:
return Amount(units.number * price_number, value_currency)
# 查到了:数量 × 市价
# We failed to infer a conversion rate; return the units.
return units
# 查不到:原样返回数量
有一处很容易看漏:它只从持仓自身推断目标币种,不接受你指定。
后果是这样的:如果一份持仓既没有成本也没有价格(比如就是 100 加元现金),那么即使行情表里有加元兑美元的汇率,它也不换——因为它不知道你想换成什么。想指定币种,得用下一节的函数。
还有一个更微妙的:这里判断成本是否可用,只看"是不是
Cost",没有像 get_cost
那样再要求单价是个真正的数。所以一个单价还是占位符的成本,在这里照样能提供币种去查价。三个函数对"成本算不算数"的判断并不一致。
上面几个函数都有同一个行为:取不到值就把输入原样还回去,不报错、不返回空。
docstring 明确说了这是刻意的(convert.py:127-132):
...This is designed so that you could reduce an inventory with this and not lose any information silently in case of failure to convert (possibly due to an empty price map). Compare the returned currency to that of the input position if you need to check for success.
译:这样设计,是为了让你能用它对整个库存做换算,而不会因为某一次换算失败(比如价格表是空的)就悄悄丢掉信息。如果需要判断是否成功,比较返回的币种和输入的币种。
理由是实际的:把一百个持仓 reduce
成市值时,只要有一只股票缺行情,如果抛异常,整次换算就全废了。返回原值至少能让其余九十九个算出来。
代价是失败被藏了起来。一份 2 MSFT {0.01 USD}
在查不到行情时,结果是 2 MSFT——数量还在,但成本信息在
reduce
的过程中丢了,而且报表上不会有任何提示说这一项没换算成功。你得自己比较返回的币种才知道。
顺带一提,早年这里有过一层兜底:查不到行情就用成本价当市值。2017 年删掉了,删除时留下的理由是:行情表应该是汇率的唯一来源。宁可换算失败,也不拿成本价冒充市价——那会让报表上的"市值"变成一个混合了两种含义的数。
如果你就是想把一切都换成加元看,用
convert_amount(convert.py:210-232):
base_quote = (amt.currency, target_currency)
_, rate = prices.get_price(price_map, base_quote, date)
# 第一步:直接查"源币种 → 目标币种"
if rate is not None:
return Amount(amt.number * rate, target_currency)
# 查到了,直接乘
elif via:
# 没查到,但调用方给了可以借道的中间币种
assert isinstance(via, (tuple, list))
for implied_currency in via:
if implied_currency == target_currency:
continue
# 中间币种就是目标币种?那没意义,跳过
base_quote1 = (amt.currency, implied_currency)
_, rate1 = prices.get_price(price_map, base_quote1, date)
if rate1 is not None:
# 第一跳成功:源 → 中间
base_quote2 = (implied_currency, target_currency)
_, rate2 = prices.get_price(price_map, base_quote2, date)
if rate2 is not None:
# 第二跳也成功:中间 → 目标
return Amount(amt.number * rate1 * rate2, target_currency)
# 两段汇率相乘
return amt
# 都失败:原样返回
"借道"解决的是这种情况:你想知道苹果股票值多少加元,但行情表里只有"苹果对美元"和"美元对加元"。那就先换成美元再换成加元,两段汇率相乘。
最多只借一次道。 代码里没有任何搜索更长路径的逻辑——不会去试"苹果→美元→欧元→加元"。这是个有意的收敛:路径一长,结果就依赖于"走哪条路",而且每多一跳就多一层舍入误差。两跳已经覆盖了绝大多数真实需求。
回头说那句在四个函数开头都出现的断言:
assert isinstance(pos, Position) or type(pos).__name__ == "Posting"
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# 按类的"名字"是不是叫 Posting 来判断
正常写法应该是
isinstance(pos, Posting),直接检查类型。这里为什么要拿名字的字符串去比?
因为模块开头写了这段话(convert.py:13-16):
# This module equivalently converts Position and Posting instances. Note that
# we're specifically avoiding to create an import dependency on
# beancount.core.data in order to keep this module isolatable, but it works on
# postings due to duck-typing.
#
# 译:这个模块对 Position 和 Posting 一视同仁。注意我们刻意避免依赖
# beancount.core.data,为的是让本模块可以被单独加载;它对 posting
# 也能工作,靠的是鸭子类型。
Posting 定义在 data.py 里,而
data.py 是更上层的模块。如果这里
import data,底层模块就反过来依赖了上层模块——这类依赖多了,模块之间就会缠成一团,任何一个都没法单独拿出来用。
所以作者选择不导入。代价就是那句字符串比较。
"鸭子类型"是这类做法的名字,来自一句英文谚语:像鸭子一样走路、像鸭子一样叫,那就当它是鸭子。这个模块只读
units、cost、price
三个属性——任何有这三个属性的东西它都能处理,管你到底是什么类。
这是个务实的取舍:用一点类型检查的严谨,换模块之间的干净。看得出作者知道自己在做什么——那句注释就是解释这件事的。
到这里 01-05 就齐了:精确的数(02)→ 带币种的钱(01)→ 带批次的持仓(03)→ 装进容器的余额(04)→ 从中取出各种口径的值(05)。这是 beancount 的整个数据地基,后面的解析、补全、校验、报表全都建在上面。
以下是本篇的技术版记录,对照源码查阅用。行号均以 commit
97472138 为准。
| 名称 | 位置 | 作用 |
|---|---|---|
| 模块 docstring | convert.py:1-20 |
四种口径的一句话定义;Inventory.reduce 用法;不依赖
data.py 的说明;get_* 与
convert_* 两族命名 |
get_units(pos) |
:34-43 |
原样返回 pos.units |
get_cost(pos) |
:46-60 |
cost.number × units.number,以成本币种计;无成本或成本数值非
Decimal 时退回 units |
get_weight(pos) |
:63-106 |
配平口径:有成本用成本,否则有价格用价格,否则
units;docstring 含四行对照表 |
get_value(pos, price_map, date=None, output_date_prices=None) |
:109-156 |
市值口径:目标币种从 cost/price
推断,数值从 price_map 查;查不到返回
units |
convert_position(pos, target_currency, price_map, date=None) |
:162-187 |
把持仓换算到指定币种,借道成本/价格币种 |
convert_amount(amt, target_currency, price_map, date=None, via=None) |
:190-232 |
把 Amount 换算到指定币种,via
列出可借道的中间币种;查不到返回原 amt |
四个 get_*
函数开头都有同一句断言(:42,54,84,135);两个
convert_* 函数没有。get_value 的
output_date_prices 参数会被原地
append,所以它不是纯函数(:109)。
Position 只有 units、cost
两个字段(position.py:178),Posting 多出
account、price、flag、meta(data.py:231-236)。本模块只读
units、cost、price
三个属性,因此不需要 Posting
的类型定义;data.py 是更上层的模块(导入
account、amount、number、position,data.py:21-25)。
区分"是不是 Posting"有两种写法并存:get_weight 用
not isinstance(pos, Position) 后直接取
pos.price(:96-97),get_value 与
convert_position 用
hasattr(pos, "price")(:142,182)。Position
声明了
__slots__ = ()(position.py:187),hasattr(position, "price")
恒为 False。
get_cost 的条件有两层:cost 是
Cost 而非 CostSpec(解析产物的成本是
CostSpec,grammar.py:519,611;booking 后才变成
Cost),且 cost.number 是 Decimal
而非 MISSING。两层任一不满足都退回
units,不报错。units.number
不在检查范围内:units.number 为 MISSING 而
cost.number 为 Decimal
时,Decimal × type 抛 TypeError。
get_units 是恒等函数,存在的意义是作为
Inventory.reduce 的 reducer
与其它三个口径并列(inventory.py:29-33,realization.py:642-644
按 at_cost 开关在 get_cost 与
get_units 之间二选一)。
判定顺序在 :88-104:
cost 是 Cost 且 cost.number
是 Decimal →
Amount(cost.number × units.number, cost.currency)(:89-90)。第四行里
@ 545.60 CAD 被完全忽略。weight = units(:93)。Position 且 price is not None →
Amount(price.number × units.number, price.currency)(:96-104)。这张表和判定逻辑原本在
interpolate.get_posting_weight,2017-01-14 的 commit
f30c2e46 迁入本模块并把旧函数改成带
warnings.warn
的转发。旧函数定义和调用现已删除;get_posting_weight
这个名称仅在 CHANGES:1502
的弃用记录里保留。迁移时删掉了旧实现里
assert posting.units.currency != price.currency
这条断言,换成注释 "Here we could assert that price.currency ==
units.currency"(:99)——注释里的方向与被删断言相反。
test_old_test(convert_test.py:108-124)按四行表逐一锁定:105.50 USD
→ 105.50 USD;@ 0.90 CAD →
94.95 CAD;{0.80 EUR} →
84.40 EUR;同时带 {0.80 EUR} 与
@ 2.00 CAD → 仍是 84.40 EUR(注释 "the price
should be ignored")。
价格分支对 MISSING
做显式传播(:100-104):两个乘数任一是
MISSING,结果就是
Amount(MISSING, price.currency)。这段来自 2017-07-02 的
commit 39c68dda "Fixed minor bug with rendering postings
with missing
numbers":解析阶段的分录数值可能未填,打印器要渲染其权重(printer.py:294)。
成本分支没有对称的处理:cost.number 是
MISSING 时该分支被
isinstance(cost.number, Decimal)
跳过(:89)。跳过之后走向哪里取决于对象类型:Position(或无价格的
Posting)直接得到
weight = units(:93);带价格的
Posting 则继续进入价格分支,按价格分支自身的规则计算或传播
MISSING,并不是一律退回
units。units.number 是 MISSING
而成本分支被采用时则在 :90 抛
TypeError。printer.py:290-294
在调用前额外守卫,把解析产物(CostSpec)排除在权重渲染之外。
get_value 不接受目标币种参数:币种来自成本(优先)或
posting 的价格,只取币种不取数值。判定成本是否可用只看
isinstance(cost, Cost)(:141),不像
get_cost/get_weight 那样还要求
cost.number 是
Decimal。test_value__currency_from_price(convert_test.py:257-260)里
posting 写的是 @ 520.00 USD,结果却是按价格表的
530.00 算出的
53000.00 USD。持仓既无成本也无价格时不做任何换算,即使价格表里有该币种的汇率:test_other_currency(:300-305)的
100 CAD 在 USD/CAD
有报价的情况下原样返回。
prices.get_price(prices.py:347-382)的日期语义:date=None
走 get_latest_price 取最后一条;给定日期用
bisect_right 取"不晚于该日"的最后一条;早于首条报价返回
(None, None);base 与 quote 相同返回
(None, ONE);键不存在时先试反向键(prices.py:283-291),再失败返回
(None, None)。
output_date_prices 参数由 2020-08-30 的 commit
8457b80c 加入:只要推断出字符串形式的
value_currency、实际调用了
get_price,无论查价成功与否都会
append(失败时是
(None, None));但币种推断失败时函数在到达
get_price
之前就返回,列表不会留下记录。仓库内没有调用者传这个参数。
CHANGES:1515-1518
记录了这个契约:prices.convert_amount 被删除时 "a failure
doesn't return None anymore, but an unmodified amount",同期
query_env 里的换算函数从"发出警告"改为"保留
units"(CHANGES:1531-1532)。这一版之前的
get_value
还有一层"价格表查不到就用成本/价格里的数值"的回退,2017-01-15 的 commit
5288892f 删掉了它,一并删除的 FIXME 注释写明理由:"the
price database ought to be the single source of rates for
conversions"。
test_stock_not_found(convert_test.py:335-340)锁定这条契约的库存级效果:2 MSFT {0.01 USD}
经 reduce(convert.get_value, ...) 后得到
2 MSFT——units 被保留,但因为
reduce 只把返回的 Amount 交给
add_amount(inventory.py:349),成本信息不在结果里。
convert_position(:162-187)只是把持仓的成本币种或价格币种包成单元素元组
via=(value_currency,) 后转调
convert_amount。via
里等于目标币种的项被跳过(:221-222),最多两跳,不做更长路径搜索。
日期语义与 get_value
相同,test_convert_amount_with_date(convert_test.py:214-233)用三条
USD/CAD 报价锁定七个日期:None 与
2015-01-01 取 1.30;2014-12-31 与
2014-01-01 取 1.25;2013-12-31 与
2013-01-01 取 1.20;2012-12-31
早于首条,返回原 100 USD。
test_convert_position__miss_and_success_on_implieds(:189-193)锁定借道:100 HOOL {514.00 USD}
换到 CAD,价格表只有 HOOL/USD = 530.00 与
USD/CAD = 1.2,结果 63600.00 CAD。其余四个
miss
用例(:159-187)覆盖借道的每一种失败。
:159-161 有一条
TODO(blais):convert_position
未来要返回换算前后的差额,以便记入未实现损益账户;doctor.py:439-443
目前是在调用方用 reduce(get_cost) 与
reduce(get_value) 相减得到这个差额。
| 决策 | 理由 | 证据 |
|---|---|---|
四种口径统一成以 pos 为首参数、返回 Amount
的函数 |
与 Inventory.reduce(reducer, *args)
组合,一套遍历逻辑复用;Inventory.units()/cost()、Position.at_cost()
等专用方法随之弃用 |
convert.py:8-11;inventory.py:336-350;CHANGES:1486-1495 |
不导入 data.py,靠鸭子类型接受
Posting |
保持模块可独立加载(docstring 原话 "isolatable") | convert.py:13-16,42;data.py:21-25 |
get_weight 成本优先于价格 |
持有成本的腿以成本配平,价格只作记录;四行表定义交易语义 | convert.py:74-77,88-104;convert_test.py:122-124 |
价格分支显式传播 MISSING |
解析阶段渲染未填数值的分录权重不能抛异常 | convert.py:100-104;commit 39c68dda |
查不到汇率返回原 units/amt 而非
None 或异常 |
作为 reducer 时避免一次缺价使整次库存换算失败 | convert.py:126-132,203-205;CHANGES:1515-1518 |
get_value 不接受目标币种,只从
cost/price 推断 |
"值多少钱"的币种由持仓自身决定;任意币种换算是
convert_* 的职责 |
convert.py:112-117,140-144 |
| 删除"价格表查不到就用成本数值"的回退 | 价格表是换算汇率的唯一来源 | commit 5288892f 删除的 FIXME 注释 |
via 只尝试一个中间币种 |
转换路径最多两段汇率相乘 | convert.py:220-229 |
prices 从 ops 迁到 core |
本模块在 core 层,不能依赖 ops |
commit 5a818e47;CHANGES:1509-1510 |
| 现象 | 后果 | 证据 |
|---|---|---|
get_cost/get_weight 成本分支不检查
units.number |
units.number 为 MISSING 且成本完整时抛
TypeError;只有 get_weight 价格分支传播
MISSING |
convert.py:57,90,100-103 |
get_value/convert_amount(及其委托的
convert_position)不传播 MISSING |
一旦查到汇率,数值为 MISSING 类会在乘法处抛
TypeError |
:152-153,212-214,225-229 |
| 成本有效性判定不对称 | get_cost/get_weight 只在
cost.number 是 Decimal
时采用成本;get_value/convert_position 只要
cost 是 Cost 就取
cost.currency |
:56-59,89-90,140-143,180-183 |
@@ 总价在语法层被除成单价且不量化,权重回乘 |
-3 EUR @@ 10 USD 存为单价 3.333…(28
位),get_weight 得
-9.999999999999999999999999999 USD,与原总价差
1E-27;issue #109 至今未处理 |
grammar.py:903-921;convert.py:103;TODO:4836-4841 |
反向汇率是 ONE / rate,不量化 |
价格表只有 USD/CAD = 1.20
时,100 CAD → USD 得
83.33333333333333333333333333 USD |
prices.py:130-132,283-291;convert.py:214 |
价格表只收 Price 指令 |
分录里的 @ 与 {} 不进价格表;不开
implicit_prices 插件时,账本里写过 @ 的品种在
get_value 里仍查不到 |
prices.py:84;plugins/implicit_prices.py |
成本币种等于 units 币种时 get_price 返回
ONE |
100 USD {1 USD} 配空价格表得
100 USD;docstring
的"比较币种判断成功"在此情形下无法区分 |
:146-153;prices.py:332-333,371-372 |
convert_position 在无成本无价格时
via=(None,) |
元组非空,进入循环:(amt.currency, None) 返回
ONE,(None, target) 查不到,最终返回原
units;多两次查表,结果不变 |
:180-187,220-229;prices.py:332-333 |
convert_amount 目标币种等于源币种 |
get_price 返回
ONE,走"直接换算成功"分支,返回新构造的等值
Amount,不是原对象 |
:211-214;convert_test.py:209-212 |
output_date_prices 失败时也 append |
列表里出现 (None, None);目前仓库内无调用者 |
:150-151 |
reduce(get_value) 失败保留 units
但丢成本 |
2 MSFT {0.01 USD} 变成无成本的 2 MSFT |
convert_test.py:335-340;inventory.py:349 |
Position 上 get_weight
永远不走价格分支 |
isinstance(pos, Position) 为真直接跳过 |
:96 |
【文档漂移】:99 注释说可断言
price.currency == units.currency |
被它替换的旧断言要求 !=(四行表第二行
EUR @ USD);注释方向相反 |
:99;commit f30c2e46 |
【文档漂移】convert_position docstring 只说借道 "its
cost currency" |
代码同样取 posting
的价格币种;test_convert_position__currency_from_price 用
@ 99999 USD 的 posting 借道 USD 算出
63600.00 CAD,价格数值被忽略 |
:165-167,180-184;convert_test.py:262-266 |
【文档漂移】模块 docstring 说 get_* 换算到 "their price
currency" |
get_units 不换算,get_cost 用成本币种 |
:18 |
convert_test.py 定义 31
个测试方法,三个类:TestPositionConversions(:47,20
个)、TestPostingConversions(:236,继承前者并新增
4 个,实际运行 24
个)、TestMarketValue(:269,7
个)。子类只重写 _pos(:239-241)把
Position 换成
Posting,让同一批用例在两种对象上各跑一遍;父类的
_pos 断言 price 为空(:52)。
| 测试 | 行号 | 锁定的行为 |
|---|---|---|
test_units |
59-64 | 有无成本都原样返回 units |
test_cost__empty / __not_empty /
__missing |
70-85 | 无成本返回 units;100 HOOL {514.00 USD} →
51400.00 USD;Cost(MISSING, ...) 返回
units |
test_weight__no_cost / __with_cost /
__with_cost_missing |
91-106 | 与 get_cost 三例同构 |
test_old_test |
108-124 | 四行表的四种组合;有成本时忽略价格由 :122-124 断言 |
test_value__no_currency /
__currency_from_cost |
139-147 | 无成本无价格不换算;有成本时用价格表数值,空表退回
units |
test_convert_position__* 六例 |
153-193 | 直接命中;目标币种等于成本币种且无报价;第二跳缺;第一跳缺;两跳都缺;借道成功
63600.00 CAD |
test_convert_amount__fail / __success /
__noop |
199-212 | 空表原样返回;127.00 USD → 152.40 CAD;同币种返回等值 |
test_convert_amount_with_date |
214-233 | 七个日期对应三条报价与"早于首条" |
test_weight_with_cost_and_price |
243-249 | 名称与构造不符:Cost 的第三个位置参数是
date 而非 Posting.price,实际构造出的
Posting.price 是 None;本测试与
test_weight__with_cost 同构 |
test_weight_with_only_price |
251-255 | 仅 Posting:只有价格用价格分支 |
test_value__currency_from_price /
test_convert_position__currency_from_price |
257-266 | 仅 Posting:价格只供币种,数值来自价格表 |
TestMarketValue 七例 |
293-340 | 经 Inventory.reduce 的库存级市值;MSFT
查不到保留 2 MSFT |
没有测试覆盖的行为:get_weight 价格分支的
MISSING 传播(由 printer
路径间接使用);units.number 为 MISSING 时的
TypeError;output_date_prices;via
含多个币种或含 None;convert_amount 传非
tuple/list 的 via 时的断言;get_* 对非
Position/Posting 对象的断言;@@
总价回乘的精度差。
间接覆盖:date
非空且价格键不存在的分支(prices.py:374-382 的
KeyError 捕获)由 test_stock_not_found
间接覆盖,prices.py 自身没有针对该分支的直接单元测试。
| 日期 | 提交 / 记录 | 变化 |
|---|---|---|
| 2016-12-19 | 7f2573ae |
新建 conversions.py(当时仓库布局带
src/python/ 前缀),四个 get_*
函数首次统一接口;get_value 带成本回退 |
| 2016-12-24 | 193807c3 / 5a818e47 /
ba44a951 |
改名 convert;ops.prices 迁到
core.prices;加 Inventory.reduce 测试 |
| 2017-01-14 | f30c2e46 |
弃用 interpolate.get_posting_weight,四行表迁入
get_weight docstring |
| 2017-01-14 | 7c59a119 |
get_value 补上 fallthrough 分支;docstring
写入静默失败契约 |
| 2017-01-15 | 5288892f |
convert_position/convert_amount
现行实现(via 借道);删除成本回退 |
| 2017-01-15 | 722d09fa /
df2cf311;CHANGES:1471-1532 |
删除 prices.convert_amount
等,全部改用本模块;失败返回原金额 |
| 2017-07-02 | 39c68dda |
价格分支传播 MISSING |
| 2020-06-10 | 092a099d |
直接 from decimal import Decimal |
| 2020-08-30 | 8457b80c |
get_value 增加 output_date_prices |
| 2020-11-01 | 37e8129b |
get_value docstring 补充"posting
带价格时用价格币种" |
| 2021-01-31 | 7a8decf3 |
convert_position 上方加 TODO(blais) |
| 2025-01-23 | 994c8a70 |
测试改动:成本日期改取 entry.date |
prices.py 提供
get_price(:347-382)与
PriceMap、build_price_map;amount.py
提供 Amount;number.py 提供
MISSING;position.py 提供 Cost 与
Position,isinstance(cost, Cost) 用来把
CostSpec 排除在外。interpolate.compute_residual(interpolate.py:72-94)对每条
posting 取 get_weight 累加进 Inventory,跳过带
__residual__
元数据的自动腿;validation.py:375-384 用残差是否
is_small(tolerances) 判定 "Transaction does not
balance";booking_full.py:893-909
用同一残差反推缺失腿,:930,936,942 三处按
cost/price/units 做
get_weight 的逆运算;booking_method.py:317-321
合并多批次匹配时用 get_weight 汇总成本。Inventory.reduce(inventory.py:336-350);Inventory.average(:376-379)用
get_cost
求总成本;realization.py:642-644、context.py:186-188、summarize.py:545,655、currency_accounts.py:124、doctor.py:439-441,453
各取一种口径。printer.py:286-294 渲染 posting
权重(注释 "we render weights at maximum precision, for
debugging")。sellgains.py:127 用
get_weight 汇总非成本腿的收入。api.py:30-35
再导出六个函数。beancount/core/convert.py:1-20 模块
docstring;13-16 鸭子类型说明;18-19 两族命名;25-31 导入;34-43
get_units;46-60 get_cost;63-106
get_weight(74-77 四行表;88-90 成本分支;91-93
units;95-104 价格分支;99 注释;100-103
MISSING 传播);109-156 get_value(112-117
目标币种说明;123-125 output_date_prices;126-132
失败契约;140-144 币种推断;148-153 查表);159-161
TODO(blais);162-187 convert_position;190-232
convert_amount。
beancount/core/convert_test.py:23-37
build_price_map_util;40-44
create_some_test_transaction;47-53 类与
_pos;59-124 units/cost/weight;130-137 两张价格表;139-147
value;153-193 convert_position;199-233
convert_amount;236-266
TestPostingConversions;269-340
TestMarketValue。
其它:prices.py:46,60,84,112,130-132,247-262,265-291,312-344,347-382;interpolate.py:11,72-94,239,281;ops/validation.py:375-384;booking_full.py:887,893-909,930,936,942,945-947;booking_method.py:317-321;grammar.py:519,611,903-921;printer.py:286-294;inventory.py:29-33,336-350,376-379;data.py:206-236;position.py:28-42,45-66,178-199;realization.py:642-644;context.py:186-188;ops/summarize.py:545,655;plugins/currency_accounts.py:124;plugins/sellgains.py:127;scripts/doctor.py:439-443,453;api.py:30-35;TODO:4836-4841;CHANGES:1471-1532。
commit:7f2573ae、193807c3、5a818e47、ba44a951、f30c2e46、7c59a119、5288892f、722d09fa、df2cf311、39c68dda、092a099d、8457b80c、37e8129b、7a8decf3、994c8a70。