目录

03 · position.py:Position、Cost 与 CostSpec

核对基线 · 范围 · 依赖

核对基线:beancount 仓库 commit 97472138(2026-08-22)。路径相对仓库根目录,引用格式 文件:起-止行 (名称),行号已逐条核对。 本篇范围beancount/core/position.py(418 行)与 beancount/core/position_test.py(352 行)。 上游依赖beancount/core/amount.pyAmountCURRENCY_REabsmul,见 01 篇)、beancount/core/number.pyDZERONUMBER_RE,见 02 篇)、beancount/core/display_context.pyDEFAULT_FORMATTER)。 下游使用者:12 个非测试文件导入本模块;Posting.costOptional[Union[Cost, CostSpec]]beancount/core/data.py:233),Inventorydict[tuple[str, Optional[Cost]], Position]beancount/core/inventory.py:81),beancount/api.py:65-67CostCostSpecPosition 导出为公开 API。

这一篇会把 position.py 的代码一段段贴出来读。代码是 beancount 的原文;代码里的中文注释是本文加的。

01 篇讲"一笔钱",02 篇讲"那个数怎么存"。这一篇往上走一层:一份持有的资产。它比一笔钱复杂,因为股票和外币不只有数量,还有"从哪来"。

一、为什么记股票不能只记数量

假设你分三次买了同一只股票:

时间 买入 单价
1 月 100 股 50 元
6 月 100 股 80 元
11 月 100 股 60 元

现在你卖掉 100 股,卖价 90 元。你赚了多少?

这个问题没有唯一答案,取决于你卖的是哪一批:

三个答案都对,取决于你按哪种规则算——而这直接决定你要交多少税。所以记账系统必须记住每一批的来历,不能把 300 股混成一坨。

这一"批"在会计上叫 lot(批次)。一个批次要记四样东西:多少钱一股、什么币种、哪天买的、有没有贴标签。

position.py 就是干这个的。它定义三个东西:

二、Cost:一批货的身份证

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,用来少交税)——这些规则的实现全靠 datenumber 排序(10 篇细讲)。

不过注释里那句"总是有效日期",实际上只对走完完整流程的数据成立。这个类自己不做任何检查:Cost("74.00", "CAD", None, None) 这种日期为空、单价还是个字符串的东西照样能造出来(测试里就有)。约束靠的是流程,不是类型。

三、CostSpec:你写下的匹配条件

现在看用户实际会怎么写账本。卖股票时你可能写:

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 年留到现在。语法先放出去、功能没跟上,作者后来自己也承认这个语法不该那么早开放。

四、为什么非要两个类型

CostCostSpec 长得很像,为什么不用一个?

因为它们是同一个东西的两个阶段

你写的账本
    ↓  解析
CostSpec(可能到处是空白:「卖 100 股,哪批不知道」)
    ↓  booking 阶段:去库存里找匹配的批次、把缺的数字算出来
Cost(每个字段都有确切的值:「卖的是 1 月那批,单价 50 USD」)
    ↓
后面所有的报表、统计、校验

好处是下游代码不必再操心"这个字段会不会是空的"。一旦过了 booking 那道关,拿到的一定是 Cost,四个字段都在。系统里有一条硬性检查(booking_full.py:1025),确认这一步之后再没有 CostSpec 残留。

这是 beancount 里反复出现的一个模式:用不同的类型区分"半成品"和"成品",而不是用同一个类型加一堆"这个字段填了吗"的判断。01 篇的 MISSING 占位符、02 篇的三态区分,都是同一件事的不同侧面。

五、Position:数量 + 从哪批来

有了批次身份,持仓就好定义了。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 的规矩:

这是一个为了实用而牺牲严谨的取舍,用的时候得知道。

七、排序:为什么美元排最前

报表要把持仓列出来,得有个顺序。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)
    # 排序时依次比:币种座次 → 成本数值 → 成本币种 → 数量

设计意图是"常用法币排前面,报表好看"。但注释和代码在这里对不上:注释说其余币种按字母序,实际代码用的是名字的长度。所以 ABAC 这两个币种算出来的座次完全一样,谁前谁后就看后面三项,以及排序本身的稳定性。这是个无害但确实存在的小瑕疵。

八、单花括号和双花括号

账本里成本有两种写法,意思不同:

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 块成本)。数量会变,来历不会变。批次身份是这批货的"出身",任何数量上的运算都不该动它。

十、这一篇讲了什么

  1. 持有资产不能只记数量,还得记"哪一批"——否则卖出时算不出赚了多少,也报不了税。
  2. 一个批次的身份 = 单价 + 币种 + 日期 + 标签,四个字段全等才是同一批。
  3. 半成品和成品用两个类型区分CostSpec 是你写下的匹配条件(可以留白),Cost 是补全后的确切身份。过了 booking 那道关,下游再也见不到空字段。
  4. 现金和持仓共用一个容器,靠 cost 是不是 None 区分。这让上层不必写两套代码。
  5. 数量为零 = 没有这个持仓,相等判断特意做成这样,代价是不太守 Python 的相等规矩。
  6. 算术只动数量,批次身份原样带过——出身不会因为数量变了而改变。

到这里,"一笔钱"(01)、"那个数"(02)、"一份持仓"(03)都有了。04 篇把很多份持仓装进一个容器,那才是账户余额真正的样子。


技术版:结构表、行号、边界行为、演变史(点开)

以下是本篇的技术版记录,对照源码查阅用。行号均以 commit 97472138 为准。

结构一览

名称 位置 作用
class Cost(NamedTuple) position.py:28-42 四字段:numbercurrencydatelabel
class CostSpec(NamedTuple) position.py:45-66 六字段:number_pernumber_totalcurrencydatelabelmerge,任一可留白
cost_to_str position.py:69-114 CostCostSpec 渲染成花括号内的文本
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 渲染 PositionPosting,决定单双花括号
class Position(NamedTuple(...)) position.py:178-414 两字段:units: Amountcost: Optional[Cost]
Position.__new__ position.py:192-199 断言 unitsAmountcostNone/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 只作用于 unitscost 原样带过
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 的日期约束

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 直接填 Noneposition.py:410),所以"总是有效日期"只对经过 booking 的数据成立。

CostInventory 字典 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.numbercost.currencycost.datecost.label,规格里留白的字段跳过。

CostSpec 各字段的三态

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.dategrammar.py:540-543
label 解析器不产生(parser.py:66-69 未写 写了标签即为 strgrammar.py:568-574
merge 解析器不产生,grammar.py:608-609 归为 False {*} 写法为 True

merge 字段没有任何处理逻辑:grammar.py:551-557True 的同时追加 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 未实现。

两个类型的分工与 #364

parser.py:1-28 的模块 docstring 定义了流程:解析器产出的 Posting.costCostSpec,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)各写一遍"总价加单价乘数量再除以数量",:777FIXME 承认重复。插值后的 assert all(not isinstance(posting.cost, CostSpec) ...):1025)是类型切换完成的硬检查。

#364 修复(2020-09-07,commit 2f14cb5dCHANGES: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_strposition.py:69-114)按类型分支,各段用 ", " 连接(:114):

to_string:155-175)拼 units 与花括号:总价写法用 {{...}},其余用 {...}:168-174)。参数 pos 只要求有 .units.costprinter.py:288 直接传 Posting。双花括号分支来自 2026-05-12 commit de20355a

Position 的 cost 类型放行范围

字段注解与 __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_positionASSERTS_TYPES(默认 Falseinventory.py:63)打开时才拒绝 CostSpec:475)。

相等与哈希的契约问题

重写 __eq__ 会让 Python 把 __hash__ 置为 None,所以 position.py:201-207 显式定义 hash((units, cost))__ne__ 未重写,落到元组默认实现后再退回身份比较,pos != NoneTrue,与 pos == None 同时成立。

这个相等定义不满足 Python 的相等-哈希契约,也不传递:零数量 PositionNone 相等,但 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__ 允许 costCostSpec,而 sortkey() 无条件读 self.cost.number——CostSpec 没有 number 字段,这种 Position 调用 sortkey()AttributeErrorAmount(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 共享,实现却对 unitscost 各调一次 copy.copyNamedTuple 子类经 __reduce_ex__ 重建,结果是三个新对象,没有引用被共享。生产代码不调用 copy.copy(position)booking_full.py:618 拷的是 InventoryInventory.__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-186pad_balance.add_position(diff_position) 返回的 pos 是修改前的旧持仓(inventory.py:413-417),不是加上差额后的结果;diff_positionfrom_amounts 单参调用产生,cost 恒为 None,因此 pos.cost is not None 恒假,这个分支在当前库存结构下不会检测到更新后的带成本负仓位。

from_string 的三种除法

from_stringposition.py:323-394)用 NUMBER_RECURRENCY_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.635convert_spec_to_cost-106.635(负单价,#364 的症状),现行 booking 得 +107.235convert_spec_to_cost 无生产路径调用;唯一调用它的 convert_lot_specs_to_lotsbooking.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 是它唯一的生产调用点,且只传一个参数。

设计决策与理由

决策 理由 证据
CostCostSpec 分成两个类型 解析结果允许留白,booking 完成后下游不再判断字段是否缺失;MISSING 不得出现在加载完成的分录里 parser.py:1-28booking_full.py:54-64,1025
完成 booking 后 Cost.date 按设计应为有效日期 FIFO/LIFO/STRICT_WITH_SIZE 按日期排序选批次;注解非 Optional,但构造器不做运行时检查 position.py:37-39,396-414booking_method.py:149,172,186,224-225
{{总价}} 存成 number_per=ZERO 而非另加字段 复用六字段结构;ZEROMISSINGNone 互斥,足以区分三种写法 grammar.py:590-607position.py:117-123
未 booking 的 {{总价}} 打印保留双花括号语义 避免再解析后总价被误读成单价;已 booking 的 Cost 仍归一化输出单价 de20355aposition.py:117-123,155-175
零仓位与 None 相等 Inventory 数量归零即删 key(inventory.py:443-445),二者语义一致 position.py:223-237position_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-1208grammar.py:551-557

行为细节与边界

现象 后果 证据
Cost 无类型检查 任何值都能进四个字段;sortkey 遇到 number=NoneCost 在比较时抛 TypeError position.py:28-42position_test.py:250-251
Position.__new__assert python -O 下不再校验类型 position.py:193-198
__eq__ 对非 Position 直接取属性 pos == 5AttributeError position.py:236
pos == Nonepos != 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=NoneMISSING 同样打印 # 总价 两者在渲染层不可区分 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_amountsAmount.__bool__ 成本 Amount 数值为零时得到无成本持仓 position.py:409-413amount.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 标签正则 "([^"]+)*" {""} 匹配成功,labelNone 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" 实际是 MISSINGNone 两种 position.py:46-52parser.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 只变 unitscost 不变
test_eq_and_sortkey / __bycost 282-313 USD < CAD < 未知币种;同币种按成本数值;64 次随机打乱结果稳定
test_copy 315-320 拷贝后 unitscost 相等
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_positionfrom_string/ 分隔与负数量复合成本、__new__ 的两条断言、from_amounts 传零成本、Cost(MISSING, ...)cost_to_str 处理。costCostSpecunits.currencyNone 时的排序崩溃路径也没有测试。

演变史

日期 提交 / 记录 变化
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 类删除,CostCostSpec 登场,Posting 扁平化
2016-04-24 f9326957 渲染零成本的 bug:判断改为 isinstance(..., Decimal)
2016-06-05 563ce010 注释修正:Cost 不含 merge
2016-10-30 CHANGES:1788-1802 {{...}} 语法完整支持,/ 分隔符废弃
2016-12-10 5130ea5abef8a472CHANGES:1597-1598 三个类型改 NamedTuple,删除可变方法 add()
2017-01-14 2752f1153c368354 弃用 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 b04cafddb4b73920 删除弃用方法与残留的 set_units()
2020-09-07 2f14cb5dCHANGES:168-172 #364:空头 {单价 # 总价} 算出负单价
2024-12-22 ee6212d83a17e762 Cost/CostSpec 改 class 写法;Position 去掉中间类
2024-12-24 d6136fdb Position.cost 注解收窄为 Optional[Cost]
2025-01-23 994c8a70 #934:create_simple_posting_with_costentry.date 替换 date(1,1,1)
2026-01-18 d3b07397 #744:Cost 只有币种时输出币种
2026-05-12 de20355a is_total_cost_spec{{总价}} 打印保留双花括号

与其他模块的关系

参考索引

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-611grammar.y:636-642,652-667grammar_test.py:1173-1179parser.py:1-108booking_full.py:54-64,618,637-654,702-704,718-777,861,887,950-981,1020,1030-1047booking_full_test.py:1238-1265booking_method.py:61,103,149,172,186,200,224-225,328booking.py:139,195-224cmptest.py:91-113printer.py:288-293data.py:67,233,593,641inventory.py:63,81,106,120,144-150,212-228,256,425-454,475convert.py:55-58,86-90interpolate.py:200-214amount.py:89-94ops/pad.py:134,182-186api.py:65-67CHANGES:168-172,1003-1005,1205-1208,1597-1598,1788-1802,2532-2556

commit7da4778223bac9919b6f28706039771ff9326957563ce0105130ea5a/bef8a4722752f115/3c3683543ba3324e065eb189b04cafddb4b739202f14cb5dee6212d8/3a17e762d6136fdb994c8a70d3b07397de20355a