Beancount 的库存系统是一个强大的功能,用于追踪随时间买入和卖出的资产,例如股票、共同基金或外币。它可以精确追踪成本基础,这对于计算资本收益和理解投资组合表现至关重要。本教程涵盖在账本中管理库存的核心机制。
核心概念
库存管理的核心在于追踪持仓。一个"持仓"就是账户中持有的某一商品的某个数量。Beancount 区分两种基本类型的持仓。
仓位类型
-
简单持仓(无成本):这是标准的余额过账。它表示某一商品的数量,不附带任何获取成本。它适用于现金或简单的余额断言。
Assets:Bank:Checking 100.00 USD -
带成本基础的持仓:这类持仓不仅包括单位数量和商品,还包括获取该商品时的成本。这是库存追踪的基础。成本在花括号
{}中指定。Assets:Invest:VTSAX 10 VTSAX {100.00 USD, "lot-1"}在这个例子中,我们持有 10 个单位的
VTSAX。每个单位的获取成本为 $100.00 USD。这一批特定的股份被称为一个"批次"。
库存操作
你可以对库存执行两种主要操作:
-
增加(添加到库存):当你买入某一商品时,你增加了库存。你创建一个具有特定单位数量和成本基础的新批次。
2024-01-15 * "Buy shares" Assets:Invest:STOCK 50 STOCK {25.00 USD, "lot-1"} Assets:Bank:Checking -1250.00 USD这里,我们以每单位 $25.00 USD 的成本买入 50 个单位的
STOCK。这在Assets:Invest:STOCK账户中创建了一个批次。 -
减少(从库存中移除):当你卖出某一商品时,你减少了库存。你必须指定要从哪个批次卖出。这通过在花括号中提供匹配信息来完成。
2024-01-20 * "Sell shares" Assets:Invest:STOCK -25 STOCK {25.00 USD} Assets:Bank:Checking 625.00 USD在这笔交易中,我们从以每单位 $25.00 USD 买入的批次中卖出 25 个单位的
STOCK。
记账方法
当你减少库存时,如果有多个批次匹配这次减少,Beancount 需要一条规则来决定从哪个具体批次中扣减。这条规则称为"记账方法"。你可以用 option 为整个文件设置默认值,也可以在 open 指令上为某个账户指定它自己的方法。
Beancount 3.2.3 接受七个方法名称:STRICT(默认)、STRICT_WITH_SIZE、NONE、FIFO、LIFO、HIFO 和 AVERAGE。其中六个已实现;AVERAGE 可以解析,但一旦需要记账一次减少就会报错,如下面的 AVERAGE 部分所示。
1. STRICT(默认)
STRICT 方法是默认且最安全的记账方法。它强制要求明确且无歧义的匹配。
2024-01-01 open Assets:Invest:STOCK "STRICT"- 要求精确的批次匹配:减少过账的成本指定符(
{...})必须标识出单个批次——通过成本、获取日期、标签或它们的任意组合。 - 匹配有歧义时报错:如果指定符匹配到多个批次,Beancount 会抛出
AmbiguousMatchError,而不是猜测。 - 例外:如果一次减少正好移除指定符所匹配的单位总数,则允许使用空的指定符(
{}),并且这次减少会分摊到这些批次上。
下面这个账本持有两个批次,并通过指定其成本卖出其中一个,这是无歧义的:
1970-01-01 commodity STK
1970-01-01 open Assets:Broker:Strict STK "STRICT"
1970-01-01 open Assets:Broker:Cash USD
1970-01-01 open Income:Gains USD
2024-01-10 * "Buy the first lot"
Assets:Broker:Strict 10 STK {100.00 USD}
Assets:Broker:Cash -1000.00 USD
2024-02-10 * "Buy the second lot"
Assets:Broker:Strict 10 STK {120.00 USD}
Assets:Broker:Cash -1200.00 USD
; The cost identifies exactly one lot, so STRICT is satisfied.
2024-06-01 * "Sell the $120.00 lot"
Assets:Broker:Strict -10 STK {120.00 USD} @ 150.00 USD
Assets:Broker:Cash 1500.00 USD
Income:Gains它加载时无错误,将 $300.00 的收益记入 Income:Gains,并在账户中留下 10 STK {100.00 USD}。
把最后一个过账替换为空指定符,同一个文件就会失败:
; Rejected under STRICT: "-10 STK {}" matches both lots.
2024-06-01 * "Sell 10 shares"
Assets:Broker:Strict -10 STK {} @ 150.00 USD
Assets:Broker:Cash 1500.00 USD
Income:GainsBeancount 会报告 Ambiguous matches for "-10 STK {}" 并列出候选批次。不过,卖出整个持仓是可以的,因为没有任何东西可供选择:
; Allowed under STRICT: -20 STK is the entire holding, so the empty
; specifier is split across both lots.
2024-06-01 * "Close the position"
Assets:Broker:Strict -20 STK {} @ 150.00 USD
Assets:Broker:Cash 3000.00 USD
Income:Gains这记入 $800.00 的收益——$3,000.00 的收益额对 $1,000.00 + $1,200.00 的基础——并让账户清空。这是 STRICT 本身的性质,不是你必须切换到 STRICT_WITH_SIZE 才能实现的东西。
2. FIFO(先进先出)
FIFO 方法自动将减少记账到最早可用的批次上。
2024-01-01 open Assets:Invest:STOCK "FIFO"- 自动解决:它通过选择最早的匹配批次来解决歧义。
- 按时间顺序匹配:你假设卖出的是你持有最久的资产。当你没有指定批次时,多个税务机关将其视为默认做法。
3. LIFO(后进先出)
LIFO 方法与 FIFO 相反。它将减少记账到最新可用的批次上。
2024-01-01 open Assets:Invest:STOCK "LIFO"- 逆时间顺序:它选择最近获取的匹配批次。
- 最新,而非最贵:LIFO 仅按获取日期选择。当价格一直在上涨时,它恰好会卖出成本最高的股份,但如果你最新的批次是最便宜的——下面的例子正是为了展示这一点——LIFO 会实现最大的收益,而不是最小的。总是卖出最贵股份的方法是
HIFO,接下来介绍。
4. HIFO(最高进,先出)
HIFO 方法将减少记账到最贵的可用批次上,无论其日期如何。
2024-01-01 open Assets:Invest:STOCK "HIFO"- 按成本排序匹配:它选择成本基础最高的匹配批次。
- 最小的已实现收益:对于给定的卖出价,卖出成本最高的股份会实现最小的收益(或最大的亏损)。你是否可以使用它属于司法辖区问题——例如在美国,选择批次本身就要求在卖出时进行特定标识——所以应把该方法视为一种记账机制,并单独确认税务选择。
5. 比较 FIFO、LIFO 和 HIFO 在相同批次上的表现
这三种方法只有在最早、最新和最贵的批次是三个不同批次时才有所不同。下面这个账本正是这样安排的——批次 A 是最早的,批次 C 是最新的,中间批次 B 是最贵的——然后从三个仅记账方法不同的账户各卖出 10 股:
1970-01-01 commodity STK
1970-01-01 open Assets:Broker:Fifo STK "FIFO"
1970-01-01 open Assets:Broker:Lifo STK "LIFO"
1970-01-01 open Assets:Broker:Hifo STK "HIFO"
1970-01-01 open Assets:Broker:Cash USD
1970-01-01 open Income:Gains USD
; Lot A - the oldest, at $100.00 per share
2024-01-10 * "Buy lot A"
Assets:Broker:Fifo 10 STK {100.00 USD}
Assets:Broker:Lifo 10 STK {100.00 USD}
Assets:Broker:Hifo 10 STK {100.00 USD}
Assets:Broker:Cash -3000.00 USD
; Lot B - the most expensive, at $120.00 per share
2024-02-10 * "Buy lot B"
Assets:Broker:Fifo 10 STK {120.00 USD}
Assets:Broker:Lifo 10 STK {120.00 USD}
Assets:Broker:Hifo 10 STK {120.00 USD}
Assets:Broker:Cash -3600.00 USD
; Lot C - the newest, at $90.00 per share
2024-03-10 * "Buy lot C"
Assets:Broker:Fifo 10 STK {90.00 USD}
Assets:Broker:Lifo 10 STK {90.00 USD}
Assets:Broker:Hifo 10 STK {90.00 USD}
Assets:Broker:Cash -2700.00 USD
; Sell 10 shares out of each account at $150.00 and let each
; account's booking method choose which lot leaves.
2024-06-01 * "Sell 10 shares from each account"
Assets:Broker:Fifo -10 STK {} @ 150.00 USD
Assets:Broker:Lifo -10 STK {} @ 150.00 USD
Assets:Broker:Hifo -10 STK {} @ 150.00 USD
Assets:Broker:Cash 4500.00 USD
Income:Gains它加载时零错误,总共记入 $1,400.00 的收益,分摊如下:
| 账户 | 方法 | 记账批次 | 成本基础 | 已实现收益 | 剩余批次 |
|---|---|---|---|---|---|
Assets:Broker:Fifo | FIFO | 批次 A,2024-01-10 | $100.00 | $500.00 | 10 @ $120.00、10 @ $90.00 |
Assets:Broker:Lifo | LIFO | 批次 C,2024-03-10 | $90.00 | $600.00 | 10 @ $100.00、10 @ $120.00 |
Assets:Broker:Hifo | HIFO | 批次 B,2024-02-10 | $120.00 | $300.00 | 10 @ $100.00、10 @ $90.00 |
LIFO 那一行值得细看:它实现了三者中最大的收益,因为最新的批次同时也是最便宜的。
6. STRICT_WITH_SIZE
STRICT_WITH_SIZE 是 STRICT 加上一个额外的决胜规则:当多个批次匹配,但其中恰好有一个批次正好持有你要移除的单位数量时,就选择那个批次。
1970-01-01 commodity STK
1970-01-01 open Assets:Broker:Sized STK "STRICT_WITH_SIZE"
1970-01-01 open Assets:Broker:Cash USD
1970-01-01 open Income:Gains USD
2024-01-10 * "Buy 10 shares"
Assets:Broker:Sized 10 STK {100.00 USD}
Assets:Broker:Cash -1000.00 USD
2024-02-10 * "Buy 7 shares"
Assets:Broker:Sized 7 STK {120.00 USD}
Assets:Broker:Cash -840.00 USD
; Only one lot holds exactly 7 units, so the empty specifier resolves.
2024-06-01 * "Sell 7 shares"
Assets:Broker:Sized -7 STK {} @ 150.00 USD
Assets:Broker:Cash 1050.00 USD
Income:Gains这记入相对于 $120.00 批次的 $210.00 收益。把 open 行上的 "STRICT" 换成相同内容的文件会失败,报错 Ambiguous matches for "-7 STK {}"。
7. AVERAGE(接受但未实现)
AVERAGE 是一个有效名称——option "booking_method" "AVERAGE" 和 open … "AVERAGE" 都能解析——但 Beancount 3.2.3 背后没有实现。这里的一切在卖出之前都能加载:
1970-01-01 commodity STK
1970-01-01 open Assets:Broker:Avg STK "AVERAGE"
1970-01-01 open Assets:Broker:Cash USD
1970-01-01 open Income:Gains USD
2024-01-10 * "Buy 10 shares at $10.00"
Assets:Broker:Avg 10 STK {10.00 USD}
Assets:Broker:Cash -100.00 USD
2024-02-10 * "Buy 10 more at $8.00"
Assets:Broker:Avg 10 STK {8.00 USD}
Assets:Broker:Cash -80.00 USD
; An average-cost engine would book this at $9.00 per share. This one refuses.
2024-06-01 * "Sell 5 shares"
Assets:Broker:Avg -5 STK {}
Assets:Broker:Cash 45.00 USD
Income:Gains当这次减少需要记账的那一刻,加载器会停下并报:
AVERAGE method is not supported不要围绕它来规划账本。如果你今天想要平均成本行为,把持仓放在 NONE 账户中并自己计算平均值,或者追踪每个批次并接受批次级别的收益。
8. NONE
NONE 方法完全禁用批次匹配。
2024-01-01 open Assets:Invest:STOCK "NONE"- 无批次匹配:Beancount 不会尝试将减少与增加相匹配。
- 允许混合符号:这允许一个账户同时持有同一商品的正余额和负余额。这种行为类似于 Ledger CLI 工具处理商品的方式。
批次规格
一个"批次"是在特定时间和价格获取的某一商品的特定区块。当你创建或减少一个持仓时,你可以详细指定其批次属性。
完整规格
当增加库存(买入)时,你可以为一个批次指定最多三个属性,用逗号分隔写在单对花括号内:
Assets:Invest:STOCK 10 STOCK {100.00 USD, 2024-01-15, "lot-identifier"}100.00 USD—— 成本基础,以每单位表示。2024-01-15—— 获取日期。当你省略它时,Beancount 会从交易日期填入,这就是为什么上面的错误消息会在每个批次上显示日期。"lot-identifier"—— 一个可选的字符串标签。
虽然三者都是可选的,但至少提供成本基础是标准做法。花括号必须保持在一行内,而账本中的注释以 ; 开头,绝不是 #。
匹配方法
当减少库存(卖出)时,你使用相同的语法来指定要从哪个(些)批次卖出。
-
按成本匹配:这是最常见的方法。
Assets:Invest:STOCK -5 STOCK {100.00 USD} -
按日期匹配:如果成本相同,你可以用获取日期来区分。
Assets:Invest:STOCK -5 STOCK {2024-01-15} -
按标签匹配:标签提供了一种万无一失的批次标识方式。
Assets:Invest:STOCK -5 STOCK {"lot-identifier"} -
把批次交给记账方法:一组空花括号
{}不指定任何批次,所以由账户的记账方法选择。在FIFO、LIFO或HIFO下,那就是最早、最新或最贵的匹配批次;在默认的STRICT下,除非这次减少正好清空所匹配的批次,否则会是一个AmbiguousMatchError。Assets:Invest:STOCK -5 STOCK {}
价格处理
理解成本基础({})和价格(@)之间的区别至关重要。它们服务于不同的目的,不能互换。
价格与成本
{cost}:定义资产的获取成本。它是库存批次本身的一部分,用于记账减少额和计算资本收益。@ price:一个注释,记录交易时的市场价格。它用于货币换算或注明特定日期的市场价值。
以下是三种场景:
-
价格注释(换算):使用
@从一种货币换算为另一种货币。Assets:Forex 1000 USD @ 0.85 EUR -
成本基础(获取):买入资产时使用
{}来确立其成本。Assets:Invest 10 STOCK {100.00 USD} -
两者兼有(带价格记录的卖出):卖出资产时,使用
{}标识要卖出的批次,使用@记录卖出价格。这允许自动计算资本收益。Assets:Invest -10 STOCK {100.00 USD} @ 105.00 USD这笔条目从成本为每个 $100.00 的批次中卖出 10 个
STOCK,卖出价为每个 $105.00。
独立的 price 指令为市场估值提供参考数据。实时价格可以为托管账本中受支持的资产维护这些指令。刷新会让你的批次、记账方法、获取成本和记录的卖出收益保持不变。
价格使用规则
- 价格注释(
@)不会影响记账哪个批次。批次匹配完全由成本基础({})和账户的记账方法处理。 @符号仅用于:
- 货币换算。
- 在交易时记录资产的市场价值。
- 为资本收益计算提供卖出价格。
配置
你可以全局配置记账方法,也可以按账户配置。
全局记账方法
你可以使用 option 指令为整个 Beancount 文件设置默认记账方法。
option "booking_method" "STRICT"接受的值是 "STRICT"(当你什么都不设置时的默认值)、"STRICT_WITH_SIZE"、"NONE"、"FIFO"、"LIFO"、"HIFO" 和 "AVERAGE"。任何其他字符串都会在加载时被拒绝,报错 Error for option 'booking_method'。"AVERAGE" 在这里和 open 上都被接受,但在其下记账一次减少会失败,如上面的 AVERAGE 部分所示。
按账户覆盖
对不同账户使用不同方法通常很有用。例如,你可能希望对退休账户使用 FIFO,但对应税经纪账户使用 STRICT,以确保你卖出的是特定的税务批次。你可以在打开账户时设置记账方法。
2024-01-01 open Assets:Retirement:401K "FIFO"
2024-01-01 open Assets:Taxable:Stock "STRICT"最佳实践
-
库存组织:为了保持账本整洁简单,强烈建议为你持有的每个独特商品使用单独的账户,并在其
open指令上将每个账户约束到该商品。; GOOD: separate accounts by commodity, each constrained to one 2024-01-01 open Assets:Invest:VTSAX VTSAX 2024-01-01 open Assets:Invest:VFIAX VFIAX避免在同一个账户中混合不同的股票或基金,因为这会使库存管理复杂化。
open上的商品列表会让 Beancount 拒绝一个误放的过账,而不是静默地混合两个库存。 -
批次管理:
-
为批次使用有意义的标签,尤其是对于税收亏损收割或员工股票授予等特定交易。
Assets:Invest:STOCK 10 STOCK {100.00 USD, "tax-loss-harvest-2024"} -
用注释记录你的交易。这会让你的账本日后更易阅读和理解。
Assets:Invest:STOCK -10 STOCK {100.00 USD} @ 110.00 USD ; Gain: 10%
- 调试:如果你遇到错误或意外行为,Beancount 提供了检查库存状态的工具。
-
检查库存状态:使用
bea doctor context main.beancount 42检查第 42 行的交易,包括其过账和受影响的账户余额。将文件名和行号替换为你想检查的交易。将
<LINENO>替换为某笔交易之后的行号,以查看其效果。 -
验证批次匹配:
bea check工具会校验你的整个文件。它会捕获任何记账错误,例如STRICT模式下的模糊批次匹配。