这一篇会把 position.py
的代码一段段贴出来读。代码是 beancount
的原文;代码里的中文注释是本文加的。
01 篇讲"一笔钱",02 篇讲"那个数怎么存"。这一篇往上走一层:一份持有的资产。它比一笔钱复杂,因为股票和外币不只有数量,还有"从哪来"。
假设你分三次买了同一只股票:
| 时间 | 买入 | 单价 |
|---|---|---|
| 1 月 | 100 股 | 50 元 |
| 6 月 | 100 股 | 80 元 |
| 11 月 | 100 股 | 60 元 |
现在你卖掉 100 股,卖价 90 元。你赚了多少?
这个问题没有唯一答案,取决于你卖的是哪一批:
三个答案都对,取决于你按哪种规则算——而这直接决定你要交多少税。所以记账系统必须记住每一批的来历,不能把 300 股混成一坨。
这一"批"在会计上叫 lot(批次)。一个批次要记四样东西:多少钱一股、什么币种、哪天买的、有没有贴标签。
position.py 就是干这个的。它定义三个东西:
Cost ——
一个批次的完整身份(单价、币种、日期、标签)CostSpec ——
你在账本里写下的匹配条件,允许留白Position —— 数量 + 它属于哪个批次position.py:28-42:
class Cost(NamedTuple):
"""A variant of Amount that also includes a date and a label."""
# docstring 说:Amount 的一个变体,额外带上日期和标签
number: Decimal
# 单价(每一股/每一单位多少钱)
currency: str
# 单价的币种
# For the date that the lot was created at.
# There should always be a valid date.
date: datetime.date
# 这批货是哪天建仓的。注释说"这里应该总是一个有效日期"
label: Optional[str]
# 这批货的标签,可以没有(Optional 表示允许是"没有")
四个字段,没有别的。这四样合起来就是一个批次的身份——两个
Cost 只要四个字段全一样,系统就认为它们是同一批货。
日期为什么重要?因为选批次的规则要用它排序。你可以让系统"总是先卖最早买的那批"(这叫
FIFO,先进先出),也可以"总是先卖最贵的那批"(HIFO,用来少交税)——这些规则的实现全靠
date 和 number 排序(10 篇细讲)。
不过注释里那句"总是有效日期",实际上只对走完完整流程的数据成立。这个类自己不做任何检查:Cost("74.00", "CAD", None, None)
这种日期为空、单价还是个字符串的东西照样能造出来(测试里就有)。约束靠的是流程,不是类型。
现在看用户实际会怎么写账本。卖股票时你可能写:
2026-03-15 * "卖出"
Assets:Stocks -100 AAPL {50.00 USD} ; 明说卖 50 块买的那批
Assets:Stocks -100 AAPL {2026-01-10} ; 或者只说日期,让系统去找
Assets:Stocks -100 AAPL {} ; 或者什么都不说
这三种写法里,花括号内的东西都不是"批次身份",而是筛选条件。它可以只写一部分,甚至完全留空。
所以需要另一个类型来装它。position.py:45-66:
class CostSpec(NamedTuple):
# 一个"不完整的 Cost",装的是用户在输入里写下的全部信息,
# 用来定位到某个具体批次。任何字段都可以留白。
number_per: Optional[Decimal]
# 单价,可以不写
number_total: Optional[Decimal]
# 总价,可以不写(写了总价,系统自己除出单价)
currency: Optional[str]
# 币种,可以不写
date: Optional[datetime.date]
# 日期,可以不写
label: Optional[str]
# 标签,可以不写
merge: Optional[bool]
# 要不要把这个币种的所有批次合并成一个平均成本
比 Cost 多了两个字段:
number_total(总价)
是为了让你可以写"这 100 股一共花了 5000
块"而不必自己算单价。系统会替你除。
merge(合并)
是个有故事的字段:语法上你可以写 {*}
表示"别管批次了,全部按平均成本算"。但这个功能从来没实现过。你真写了
{*},解析器会一边把这个字段设成真、一边报错说"成本合并尚不支持"。这段代码从
2018
年留到现在。语法先放出去、功能没跟上,作者后来自己也承认这个语法不该那么早开放。
Cost 和 CostSpec
长得很像,为什么不用一个?
因为它们是同一个东西的两个阶段:
你写的账本
↓ 解析
CostSpec(可能到处是空白:「卖 100 股,哪批不知道」)
↓ booking 阶段:去库存里找匹配的批次、把缺的数字算出来
Cost(每个字段都有确切的值:「卖的是 1 月那批,单价 50 USD」)
↓
后面所有的报表、统计、校验
好处是下游代码不必再操心"这个字段会不会是空的"。一旦过了
booking 那道关,拿到的一定是
Cost,四个字段都在。系统里有一条硬性检查(booking_full.py:1025),确认这一步之后再没有
CostSpec 残留。
这是 beancount
里反复出现的一个模式:用不同的类型区分"半成品"和"成品",而不是用同一个类型加一堆"这个字段填了吗"的判断。01
篇的 MISSING 占位符、02
篇的三态区分,都是同一件事的不同侧面。
有了批次身份,持仓就好定义了。position.py:178-199:
class Position(NamedTuple("Position", [("units", Amount), ("cost", Optional[Cost])])):
# 两个字段:
# units 数量,是一个 Amount(01 篇讲的"数值 + 币种")
# cost 这批货的成本身份,可以是 None
__slots__ = () # 同 01 篇:堵住往实例上挂新属性
cost_types = (Cost, CostSpec)
# 构造时允许的 cost 类型:Cost 或 CostSpec 都行
def __new__(cls, units: Amount, cost: Cost | None = None):
assert isinstance(units, Amount), "..." # 报错文案略
# 数量必须是 Amount,不能是裸数字
assert cost is None or isinstance(cost, Position.cost_types), "..."
# 成本要么是 None(现金那种不带成本的),要么是上面两种类型之一
return super().__new__(cls, units, cost)
cost 可以是
None,这一点很关键。它让同一个容器能装两种东西:
100.00 USD → cost 是 None,就是一百块现金
10 AAPL {50.00 USD, 2026-01-10} → cost 有值,是 1 月买的十股苹果
你钱包里的现金和你的股票,在系统里是同一种数据结构。这个设计让 04 篇的库存容器可以一视同仁地处理它们,不必分成"现金账"和"持仓账"两套代码。
这里也有一处名实不符:字段注解写的是
Optional[Cost](只允许 Cost),但上面那行
cost_types 运行时其实放行 CostSpec。原因是
booking 期间要拿 CostSpec 临时凑成 Position
做中转。注解是 2024 年收窄的,运行时没跟着改。
Position
的相等判断有个特别的地方(position.py:223-237):
def __eq__(self, other):
return (
self.units.number == ZERO
# 如果拿来比的是 None,那就问一句:我的数量是零吗?
# 是零 → 算相等
if other is None
else (self.units == other.units and self.cost == other.cost)
# 拿来比的是另一个持仓:数量和批次都一样才算相等
)
某持仓 == None
通常应该是假的,这里却在数量为零时返回真。为什么?
因为在 beancount 的库存容器里,"持有 0 股苹果"和"根本没有苹果这一项"是一回事。数量归零时容器会直接把这一项删掉(04 篇)。既然业务上等价,比较时也让它们等价,省得每个调用点都写"要么是 None 要么数量为零"。
代价是这个定义不太守 Python 的规矩:
hash(零持仓) 不等于
hash(None);None,彼此却不相等(币种不同);某持仓 == None 和 某持仓 != None
会同时为真——因为只重写了"相等",没重写"不等"。这是一个为了实用而牺牲严谨的取舍,用的时候得知道。
报表要把持仓列出来,得有个顺序。position.py:129-141
先给几个币种排了座次:
CURRENCY_ORDER = {
"USD": 0, # 美元排第一
"EUR": 1, # 欧元第二
"JPY": 2,
"CAD": 3,
"GBP": 4,
"AUD": 5,
"NZD": 6,
"CHF": 7,
# All the rest in alphabetical order...
# 注释说:剩下的按字母顺序……(但代码其实没按字母排,见下)
}
NCURRENCIES = len(CURRENCY_ORDER) # 上面这张表有几项,答案是 8
然后是排序规则(position.py:239-256):
def sortkey(self):
currency = self.units.currency
order_units = CURRENCY_ORDER.get(currency, NCURRENCIES + len(currency))
# 查表:表里有就用表里的座次(0-7);
# 表里没有(比如 AAPL)就用 8 + 币种名的长度
if self.cost is not None:
cost_number = self.cost.number
cost_currency = self.cost.currency
else:
cost_number = ZERO
cost_currency = ""
# 没有成本的(现金):用 0 和空字符串占位,
# 这样它才能和有成本的持仓放在一起比
return (order_units, cost_number, cost_currency, self.units.number)
# 排序时依次比:币种座次 → 成本数值 → 成本币种 → 数量
设计意图是"常用法币排前面,报表好看"。但注释和代码在这里对不上:注释说其余币种按字母序,实际代码用的是名字的长度。所以
AB 和 AC
这两个币种算出来的座次完全一样,谁前谁后就看后面三项,以及排序本身的稳定性。这是个无害但确实存在的小瑕疵。
账本里成本有两种写法,意思不同:
10 AAPL {50.00 USD} 单花括号:每股 50 块
10 AAPL {{500.00 USD}} 双花括号:一共 500 块(系统自己除出每股 50)
系统内部只存单价,不存"你当初写的是哪种"。那打印出来的时候怎么办?如果把双花括号的写法打印成单花括号,数字含义就从"总价"变成了"单价"——再读回去就错得离谱。
beancount
的办法是给总价写法留了一个特征(position.py:117-123):
def is_total_cost_spec(cost):
"""Return true if the cost spec came from total-cost-only syntax."""
# 判断这个成本说明是不是来自"只写总价"的写法
return (
isinstance(cost, CostSpec)
# 得是还没 booking 的 CostSpec
and cost.number_per == ZERO
# 单价那一栏恰好是 0 —— 这就是标记
and isinstance(cost.number_total, Decimal)
# 而且总价那一栏确实有个数
)
解析器读到 {{500 USD}} 时,会把单价设成 0、总价设成
500。"单价是 0
而总价有值"这个组合在正常数据里不会出现,于是它成了"这是总价写法"的暗号。打印时认出这个暗号,就还原成双花括号。
这个修复是 2026 年 5 月才加的(commit
de20355a)。在那之前,{{2000 USD}} 打印出来是
{2000 USD},存下来再读一遍,两千块的总价就变成了两千块的单价。
顺带一提:这个还原只对没走完 booking
的数据有效。一旦转成了
Cost,原始写法就丢了,只能输出归一化的单价——提交说明里也承认了这一点。
position.py:286-313,三个运算:
def get_negative(self):
return Position(-self.units, self.cost)
# 取负:数量取反,批次身份原样带过
__neg__ = get_negative
# 让 -某持仓 这种写法也能用
def __abs__(self):
return Position(amount_abs(self.units), self.cost)
# 取绝对值:同样只动数量
def __mul__(self, scalar):
return Position(amount_mul(self.units, scalar), self.cost)
# 乘一个倍数:还是只动数量
三个函数长得几乎一样,共同点是第二个参数
self.cost 原封不动。
这是对的:把 10 股变成 -10 股,它们仍然是同一批货(1 月买的、50 块成本)。数量会变,来历不会变。批次身份是这批货的"出身",任何数量上的运算都不该动它。
CostSpec
是你写下的匹配条件(可以留白),Cost
是补全后的确切身份。过了 booking 那道关,下游再也见不到空字段。cost 是不是
None 区分。这让上层不必写两套代码。到这里,"一笔钱"(01)、"那个数"(02)、"一份持仓"(03)都有了。04 篇把很多份持仓装进一个容器,那才是账户余额真正的样子。
以下是本篇的技术版记录,对照源码查阅用。行号均以 commit
97472138 为准。
| 名称 | 位置 | 作用 |
|---|---|---|
class Cost(NamedTuple) |
position.py:28-42 |
四字段:number、currency、date、label |
class CostSpec(NamedTuple) |
position.py:45-66 |
六字段:number_per、number_total、currency、date、label、merge,任一可留白 |
cost_to_str |
position.py:69-114 |
把 Cost 或 CostSpec
渲染成花括号内的文本 |
is_total_cost_spec |
position.py:117-123 |
判定 CostSpec 是否来自 {{总价}} 写法 |
CURRENCY_ORDER / NCURRENCIES |
position.py:129-141 |
八种主要币种的排序优先级表及其长度 |
get_position |
position.py:144-152 |
从 Posting 取 (units, cost) 建
Position;全仓库无调用点 |
to_string |
position.py:155-175 |
渲染 Position 或
Posting,决定单双花括号 |
class Position(NamedTuple(...)) |
position.py:178-414 |
两字段:units: Amount、cost: Optional[Cost] |
Position.__new__ |
position.py:192-199 |
断言 units 是 Amount,cost 是
None/Cost/CostSpec |
__hash__ / __eq__ / sortkey /
__lt__ |
position.py:201-207,223-266 |
哈希、相等(零仓位等于 None)、四元组排序键 |
__copy__ / currency_pair |
position.py:268-284 |
拷贝;(units 币种, cost 币种或 None) |
get_negative=__neg__ /
__abs__ / __mul__ |
position.py:286-313 |
只作用于 units,cost 原样带过 |
is_negative_at_cost |
position.py:315-321 |
units.number < ZERO and cost is not None |
from_string |
position.py:323-394 |
测试用的迷你解析器 |
from_amounts |
position.py:396-414 |
便捷构造器;pad.py:134 是其生产调用点;别名在
:417-418 |
Cost 没有
__new__,不做任何类型检查,Cost("74.00", "CAD", None, None)
能构造(position_test.py:250)。date 注解为非
Optional,注释要求"总是有效日期",需求来自
booking:STRICT_WITH_SIZE 在多个同量候选中按
match.cost.date
取最早(booking_method.py:149),FIFO 经
_booking_method_xifo(..., "date", ...)
按日期升序排序(:172),LIFO
用同一函数反向排序(:186),HIFO 按 "number"
反向排序(:200),公共排序调用在
:224-225。日期由 booking_full.py:702-704
在规格未写日期时补交易日期;data.py:593 (create_simple_posting_with_cost)
填 entry.date,是 2025-01-23 commit 994c8a70
修 #934 时替换掉占位值 date(1, 1, 1)
的结果。from_amounts 直接填
None(position.py:410),所以"总是有效日期"只对经过
booking 的数据成立。
Cost 是 Inventory 字典 key
(units.currency, cost)
的一项(inventory.py:81),它的默认元组相等就是批次身份:四个字段全比,Decimal
不看 exponent,Cost(D("100"), "USD", d, None) 与
Cost(D("100.00"), "USD", d, None)
相等且哈希相同。减仓匹配同样逐字段比对:booking_full.py:646-656
依次比
cost.number、cost.currency、cost.date、cost.label,规格里留白的字段跳过。
docstring(position.py:46-52)说留白字段取"NA",实际值有三态,由
beancount/parser/grammar.py:503-611 (Builder.cost_spec) 与
grammar.y:636-642 (maybe_number) 决定:
| 字段 | MISSING |
None |
其它 |
|---|---|---|---|
number_per |
有花括号但没写单价,等 booking
推算:{}(grammar.py:519)、{2015-09-21}(:586-587)、{USD}、{# 9.95 USD}(grammar.y:636-642) |
解析器不产生,仅程序构造(position_test.py:110,157) |
ZERO 专指 {{总价}}
写法(grammar.py:590-607) |
number_total |
{45.23 # USD}:要求推算总价(parser.py:73-74) |
没写总价(parser.py:78-79) |
Decimal |
currency |
{}、{2015-09-21}:币种待定(grammar.py:519,586) |
解析器不产生 | str |
date |
解析器不产生(parser.py:66-69) |
未写 | 写了日期即为
datetime.date(grammar.py:540-543) |
label |
解析器不产生(parser.py:66-69) |
未写 | 写了标签即为
str(grammar.py:568-574) |
merge |
— | 解析器不产生,grammar.py:608-609 归为
False |
{*} 写法为 True |
merge
字段没有任何处理逻辑:grammar.py:551-557 置
True 的同时追加
ParserError("Cost merging is not supported yet")(CHANGES:1205-1208,2018-01-23);全仓库非测试代码读取
.merge 的只有 cost_to_str 打印
*(position.py:111)和
booking.py:207
的元组解包(解包后未使用)。Booking.AVERAGE
枚举值存在(data.py:67)但 booking 未实现。
parser.py:1-28 的模块 docstring 定义了流程:解析器产出的
Posting.cost 是 CostSpec,booking
阶段"找匹配批次"与"插值缺失数字"同时进行,完成后把 CostSpec
换成 Cost。分工在 booking_full.py:54-64:减仓
posting 先 booking 并转成 Cost,加仓 posting 保留
CostSpec
以便对总价/单价插值,最后统一转换。转换点有三处:booking_full.py:861、:887、:1020;转换函数
convert_costspec_to_cost(:747-774)与匹配用的
compute_cost_number(:718-744)各写一遍"总价加单价乘数量再除以数量",:777
的 FIXME 承认重复。插值后的
assert all(not isinstance(posting.cost, CostSpec) ...)(:1025)是类型切换完成的硬检查。
#364 修复(2020-09-07,commit
2f14cb5d,CHANGES:168-172)改的正是这两段除法:修复前
cost_total += number_per * units.number
用带符号数量、再除以 abs(units.number),空头
-23 MSFT {106.935 # 6.90 USD} 得到负单价;修复后两处都先取
abs(:736-739、:765-769)。回归测试
booking_full_test.py:1238-1265 (test_negative_units)
锁定结果 -23 MSFT {107.235 USD}。
cost_to_str(position.py:69-114)按类型分支,各段用
", " 连接(:114):
Cost:number 是 Decimal 时借
Amount.to_string 渲染(:82-83),否则只要
currency 是 str
就单独输出币种(:84-85,2026-01-18 commit
d3b07397 修 #744);detail=True 再追加
date.isoformat() 与带引号的
label(:86-90)。CostSpec:整个金额段的进入条件是"两个数字至少一个是
Decimal"(:93);总价写法只输出
number_total(:95-96),否则先单价、再
#
加总价(:98-102),最后是币种(:103-104);detail
段多一个 merge 为真时的
*(:111-112)。to_string(:155-175)拼 units
与花括号:总价写法用 {{...}},其余用
{...}(:168-174)。参数 pos
只要求有 .units 与
.cost,printer.py:288 直接传
Posting。双花括号分支来自 2026-05-12 commit
de20355a。
字段注解与 __new__ 注解只写 Cost,运行时
cost_types 仍放行 CostSpec:2024-12-24 commit
d6136fdb"position: disallow
CostSpec"只收窄了注解,提交说明称 booking 期间 CostSpec
挂在 Posting 上即可。实际
booking_full.py:950,966,981 三处用 CostSpec
构造 Position 作为插值中转,interpolate.py:209
对 posting 的 cost 断言
isinstance(cost, CostSpec)。Inventory.add_position
在 ASSERTS_TYPES(默认
False,inventory.py:63)打开时才拒绝
CostSpec(:475)。
重写 __eq__ 会让 Python 把 __hash__ 置为
None,所以 position.py:201-207 显式定义
hash((units, cost))。__ne__
未重写,落到元组默认实现后再退回身份比较,pos != None 为
True,与 pos == None 同时成立。
这个相等定义不满足 Python 的相等-哈希契约,也不传递:零数量
Position 与 None 相等,但
hash(Position(...)) 通常不等于
hash(None);Position(Amount(ZERO, "CAD"), None)
与 Position(Amount(ZERO, "USD"), None) 都和
None
相等,彼此却不相等(:201-207,223-237)。
7da47782(2013-06-23)把 Position
拆成独立文件时,sortkey 与 __copy__ 已在,但
CURRENCY_ORDER 当时定义在 data.py:41、由本模块
import 进来,sortkey 还是「币种优先级 +
数量」的二元组(优先级回退值硬编码
10)。常量定义与四元组排序键是
6039771f(2015-12-13)换成 (units, cost)
实现时才定型的。
Position 只重写了 __lt__,没有重写
__le__、__gt__、__ge__;这三者仍是
NamedTuple 继承自 tuple
的字段字典序比较,可能与 sortkey()
的结论相反。__new__ 允许 cost 是
CostSpec,而 sortkey() 无条件读
self.cost.number——CostSpec 没有
number 字段,这种 Position 调用
sortkey() 抛
AttributeError;Amount(D("123.45"), None)
同理在 len(currency) 处抛 TypeError。
def __copy__(self):
"""Shallow copy, except for the lot, which can be shared. This is important for
performance reasons; a lot of time is spent here during balancing."""
# Note: We use Decimal() for efficiency.
return Position(copy.copy(self.units), copy.copy(self.cost))
position.py:268-276。docstring 说 lot 共享,实现却对
units 与 cost 各调一次
copy.copy;NamedTuple 子类经
__reduce_ex__
重建,结果是三个新对象,没有引用被共享。生产代码不调用
copy.copy(position):booking_full.py:618
拷的是
Inventory,Inventory.__copy__(inventory.py:144-150)只重建字典。真正的性能收益来自不可变(CHANGES:1003-1005)。
__abs__ 是 2017-09-06 commit 3ba3324e 为
SQL shell 的 ABS() 添加,Inventory.__abs__ 与
__mul__
逐项调用它们(inventory.py:212-228)。is_negative_at_cost(:315-321)的唯一生产调用点在
pad.py:181-186:pad_balance.add_position(diff_position)
返回的 pos
是修改前的旧持仓(inventory.py:413-417),不是加上差额后的结果;diff_position
由 from_amounts 单参调用产生,cost 恒为
None,因此 pos.cost is not None
恒假,这个分支在当前库存结构下不会检测到更新后的带成本负仓位。
from_string(position.py:323-394)用
NUMBER_RE 与 CURRENCY_RE
拼正则并锚定行尾(:335-338),花括号内容按
[,/]
切分(:352),每段依次尝试复合金额(:355-370)、日期(:373-376)、标签(:379-382)、*(:385-387,抛
ValueError)。复合金额就地折算成单价:total = number * per_number + total_number; per_number = total / number(:367-368),乘除都用带符号的
number。
booking.py:214-218 (convert_spec_to_cost)
是第三种形状:乘带符号、除 abs(units_num),这正是 #364
修复前 booking_full 的写法。对
-23 MSFT {106.935 # 6.90 USD}
三条路径各得不同结果:from_string 得
+106.635,convert_spec_to_cost 得
-106.635(负单价,#364 的症状),现行 booking 得
+107.235。convert_spec_to_cost
无生产路径调用;唯一调用它的
convert_lot_specs_to_lots(booking.py:170)自身也没有非测试调用点。
from_string 与正式解析器还有两处差异:单花括号空成本
1 USD {} 因分组捕获到空字符串、被
if match.group(3): 判假,直接得到
cost=None(:350-352,391-392),而正式解析器会产生带
MISSING 字段的 CostSpec;双花括号总价写法
1 USD {{2 USD}} 不匹配这段正则,抛
ValueError。
from_amounts(:396-414)判断用
if cost_amount,即 Amount.__bool__
的"数值非零"(amount.py:89-94);pad.py:134
是它唯一的生产调用点,且只传一个参数。
| 决策 | 理由 | 证据 |
|---|---|---|
Cost 与 CostSpec 分成两个类型 |
解析结果允许留白,booking
完成后下游不再判断字段是否缺失;MISSING
不得出现在加载完成的分录里 |
parser.py:1-28;booking_full.py:54-64,1025 |
完成 booking 后 Cost.date 按设计应为有效日期 |
FIFO/LIFO/STRICT_WITH_SIZE 按日期排序选批次;注解非
Optional,但构造器不做运行时检查 |
position.py:37-39,396-414;booking_method.py:149,172,186,224-225 |
{{总价}} 存成 number_per=ZERO
而非另加字段 |
复用六字段结构;ZERO 与
MISSING、None 互斥,足以区分三种写法 |
grammar.py:590-607;position.py:117-123 |
未 booking 的 {{总价}} 打印保留双花括号语义 |
避免再解析后总价被误读成单价;已 booking 的 Cost
仍归一化输出单价 |
de20355a;position.py:117-123,155-175 |
零仓位与 None 相等 |
Inventory 数量归零即删
key(inventory.py:443-445),二者语义一致 |
position.py:223-237;position_test.py:255-262 |
| 排序键先主要币种、再成本、最后数量 | 报表把常用法币排前面 | position.py:126-141,239-256 |
三个 NamedTuple 全部不可变 |
Inventory 免拷贝;字典 key 只接受可哈希对象 |
CHANGES:1597-1598,1003-1005 |
运算只作用于 units |
取负、取绝对值、缩放都不改变批次身份 | position.py:286-313 |
merge 字段保留但报错 |
语法先于 AVERAGE 实现放出,作者自述"不该这么早开放语法" | CHANGES:1205-1208;grammar.py:551-557 |
| 现象 | 后果 | 证据 |
|---|---|---|
Cost 无类型检查 |
任何值都能进四个字段;sortkey 遇到
number=None 的 Cost 在比较时抛
TypeError |
position.py:28-42;position_test.py:250-251 |
Position.__new__ 用 assert |
python -O 下不再校验类型 |
position.py:193-198 |
__eq__ 对非 Position 直接取属性 |
pos == 5 抛 AttributeError |
position.py:236 |
pos == None 与 pos != None 可同时为真 |
__ne__ 未重写 |
position.py:223-237 |
| 未知币种排序只看长度 | 同长度币种无稳定字母序 | position.py:248 |
CostSpec 只有币种时打印为空 |
{USD} 输入打印成
{}(金额段进入条件不满足),Cost 分支则输出
{USD} |
position.py:84-85,92-114,155-175 |
number_total=MISSING 不打印 |
{45.23 # USD} 打印成
{45.23 USD},插值请求丢失 |
position.py:100-102 |
number_per=None 与 MISSING 同样打印
# 总价 |
两者在渲染层不可区分 | position_test.py:110-111,236-239 |
number_per=ZERO 且无总价 |
打印 {0 USD},不是总价写法 |
position.py:92-105,117-123 |
cost_to_str 对非
Cost/CostSpec 对象不报错 |
直接返回空字符串;python -O 关闭断言后非法
cost 被静默渲染成 {} |
position.py:69-114,192-199 |
from_amounts 用 Amount.__bool__ |
成本 Amount 数值为零时得到无成本持仓 |
position.py:409-413;amount.py:94 |
from_string 按 [,/] 切分 |
仍接受 2016-10-30 已废弃的 /
分隔;带千分位逗号的成本数字被切开后报 ValueError |
position.py:352 |
from_string 复合成本用带符号数量 |
-23 MSFT {106.935 # 6.90 USD} 得
106.635,与 booking 的 107.235
不同;数量为零时 DivisionByZero |
position.py:363-368 |
from_string 标签正则 "([^"]+)*" |
{""} 匹配成功,label 为
None |
position.py:379-382 |
【文档漂移】__copy__ docstring 说 lot 共享 |
实现拷贝全部字段 | position.py:268-276 |
| 【文档漂移】"We use Decimal() for efficiency" | 两处注释下方都没有 Decimal() 调用 |
position.py:275,292 |
【文档漂移】CURRENCY_ORDER 注释"All the rest in
alphabetical order" |
键不含币种名 | position.py:138,248 |
【文档漂移】Position.cost 注解
Optional[Cost] |
运行时与 booking 都使用 CostSpec |
position.py:178,190,192 |
【文档漂移】CostSpec docstring 说留白取"NA" |
实际是 MISSING 与 None 两种 |
position.py:46-52;parser.py:5-7 |
【文档漂移】parser.py:104 例子
{2015-09-21} 给出 merge=True |
grammar.py:608-609 默认 False |
parser.py:103-104 |
| 测试 | 行号 | 锁定的行为 |
|---|---|---|
TestCost.test_cost_to_str__detail/__simple |
31-72 | Cost 六种字段组合的渲染;detail=False
去掉日期与标签;仅币种时输出 USD |
TestCostSpec.test_cost_to_str__detail/__simple |
78-170 | #
复合、ZERO+总价、None+总价、仅标签、*
标记;仅币种输出空串 |
test_from_string__* |
174-217 | 空串报错、空白容忍、成本/日期/标签/复合成本、缺币种报错 |
test_str / test_to_string /
_no_detail |
219-229 | str() 与 to_string()
一致;detail=False |
test_to_string__total_cost_spec /
__missing_per_unit_cost_spec |
231-239 | {{202.46 USD}} 与 {# 202.46 USD} |
test_from_amounts / test_constructors |
241-253 | 成本展开;Cost 接受字符串数值、Amount
字段可为 None |
test_compare_zero_to_none |
255-262 | 零仓位双向等于 None;不同币种零仓位不等 |
test_neg / test_abs /
test_mul |
264-280 | 只变 units,cost 不变 |
test_eq_and_sortkey / __bycost |
282-313 | USD < CAD < 未知币种;同币种按成本数值;64 次随机打乱结果稳定 |
test_copy |
315-320 | 拷贝后 units、cost 相等 |
test_quantities / test_negative |
322-335 | 经 convert.get_cost 折算成本 |
test_is_negative_at_cost /
test_currency_pair |
337-348 | 符号判定;币种对 |
没有直接测试:__hash__(生产调用点在
parser/context.py:133)、__eq__ 对非
Position
对象、get_position、from_string 的
/ 分隔与负数量复合成本、__new__
的两条断言、from_amounts
传零成本、Cost(MISSING, ...) 的 cost_to_str
处理。cost 为 CostSpec 或
units.currency 为 None
时的排序崩溃路径也没有测试。
| 日期 | 提交 / 记录 | 变化 |
|---|---|---|
| 2013-06-23 | 7da47782 |
Position 拆成独立文件,sortkey(二元组)与
__copy__ 已在 |
| 2014-07-10 | 23bac991 |
加 from_amounts |
| 2014-08-31 | 9b6f2870 |
is_negative_at_cost 出现 |
| 2015-12-13 | 6039771f |
旧 (number, lot) 实现删除,换成
(units, cost);sortkey 变成四元组 |
| 2016-02-06 | CHANGES:2532-2556 |
Lot 类删除,Cost 与 CostSpec
登场,Posting 扁平化 |
| 2016-04-24 | f9326957 |
渲染零成本的 bug:判断改为
isinstance(..., Decimal) |
| 2016-06-05 | 563ce010 |
注释修正:Cost 不含 merge |
| 2016-10-30 | CHANGES:1788-1802 |
{{...}} 语法完整支持,/ 分隔符废弃 |
| 2016-12-10 | 5130ea5a、bef8a472;CHANGES:1597-1598 |
三个类型改 NamedTuple,删除可变方法
add() |
| 2017-01-14 | 2752f115、3c368354 |
弃用 get_cost()/at_cost(),改用
convert.get_cost |
| 2017-09-06 | 3ba3324e |
加 __abs__ |
| 2018-01-02 | 065eb189 |
cost_to_str 对只有总价的 CostSpec
不再输出空串 |
| 2018-01-23 | CHANGES:1205-1208 |
{*} 语法报错"not supported yet" |
| 2018-03-23 / 04-01 | b04cafdd、b4b73920 |
删除弃用方法与残留的 set_units() |
| 2020-09-07 | 2f14cb5d;CHANGES:168-172 |
#364:空头 {单价 # 总价} 算出负单价 |
| 2024-12-22 | ee6212d8、3a17e762 |
Cost/CostSpec 改 class
写法;Position 去掉中间类 |
| 2024-12-24 | d6136fdb |
Position.cost 注解收窄为
Optional[Cost] |
| 2025-01-23 | 994c8a70 |
#934:create_simple_posting_with_cost 用
entry.date 替换 date(1,1,1) |
| 2026-01-18 | d3b07397 |
#744:Cost 只有币种时输出币种 |
| 2026-05-12 | de20355a |
加 is_total_cost_spec,{{总价}}
打印保留双花括号 |
amount.py 提供
Amount、CURRENCY_RE、abs/mul;number.py
提供
ZERO、D、NUMBER_RE;display_context.py
提供
DEFAULT_FORMATTER(position.py:18-25)。本模块不导入
MISSING,对 MISSING 的所有处理都靠
isinstance(..., Decimal) 间接完成。grammar.py:503-611 构造
CostSpec;booking_full.py:747-774 与
cmptest.py:91-113 把它转成
Cost;inventory.py:448,454 与
booking_full.py:950-981 构造 Position。Posting.cost(data.py:233,:641
断言三种类型);Inventory 以 (currency, cost)
为 key、Position
为值(inventory.py:81,425-454)。convert.get_cost/get_weight
只在 cost 是 Cost 且 number 是
Decimal
时乘算(convert.py:55-58,86-90);printer.py:288-294
同样条件;interpolate.py:202-215 对 CostSpec
的两个数字分别算容差;booking_method.py:61,103,328 用
position.to_string 拼错误信息。booking_full.py:637-654 用
compute_cost_number 把 CostSpec
折成单价后逐字段比对 Inventory 里的
Cost;:1027-1047
转换完成后拒绝零数量与负成本。beancount/core/position.py:1-4 模块
docstring;11-25 导入;28-42 Cost;45-66
CostSpec;69-114 cost_to_str;117-123
is_total_cost_spec;126-141
CURRENCY_ORDER/NCURRENCIES;144-152
get_position;155-175 to_string;178
类定义;187 __slots__;190 cost_types;192-199
__new__;201-207 __hash__;209-221
渲染方法;223-237 __eq__;239-256
sortkey;258-266 __lt__;268-276
__copy__;278-284 currency_pair;286-295
get_negative/__neg__;297-303
__abs__;305-313 __mul__;315-321
is_negative_at_cost;323-394
from_string;396-414 from_amounts;417-418
别名。
beancount/core/position_test.py:28-73
TestCost;75-171 TestCostSpec;173-349
TestPosition。
其它:beancount/parser/grammar.py:91-103,106,503-611;grammar.y:636-642,652-667;grammar_test.py:1173-1179;parser.py:1-108;booking_full.py:54-64,618,637-654,702-704,718-777,861,887,950-981,1020,1030-1047;booking_full_test.py:1238-1265;booking_method.py:61,103,149,172,186,200,224-225,328;booking.py:139,195-224;cmptest.py:91-113;printer.py:288-293;data.py:67,233,593,641;inventory.py:63,81,106,120,144-150,212-228,256,425-454,475;convert.py:55-58,86-90;interpolate.py:200-214;amount.py:89-94;ops/pad.py:134,182-186;api.py:65-67;CHANGES:168-172,1003-1005,1205-1208,1597-1598,1788-1802,2532-2556。
commit:7da47782、23bac991、9b6f2870、6039771f、f9326957、563ce010、5130ea5a/bef8a472、2752f115/3c368354、3ba3324e、065eb189、b04cafdd、b4b73920、2f14cb5d、ee6212d8/3a17e762、d6136fdb、994c8a70、d3b07397、de20355a。