库存如何工作

Martin Blais,2016 年 12 月

http://furius.ca/beancount/doc/booking

本文解释了我们如何累积商品并
将销售(减少)与累积的库存内容进行匹配。

引言

除了能够跟踪和列出每个账户的分录(该操作生成一份日记账)之外,Beancount 最常见且最有用的操作之一是汇总任意一组分录的头寸。这些聚合是 Beancount 工作的核心,并通过一个名为“库存”的对象实现。本文将解释这一聚合过程的工作原理。如果您要追踪投资,理解以下内容是必要的。

匹配与记账方式

为了获得整体概览,让我们通过一些简单示例来了解各种记账功能。这将让您一次性掌握所有主要概念。

简单记账——免费

考虑对某个账户进行一系列简单的记账,例如:

2016-04-24 * "Deposit check"
  Assets:Bank:Checking      221.23 USD
  …

2016-04-29 * "ATM Withdrawal"
  Assets:Bank:Checking     -100.00 USD
  …

2016-04-29 * "Debit card payment"
  Assets:Bank:Checking      -45.67 USD
  …

支票账户的初始余额为空。第一笔交易后,余额为 221.23 美元;第二笔交易后,余额变为 121.23 美元;第三笔交易最终将余额调整为 75.56 美元。这看起来非常自然:数字直接相加即可。

这一点可能显而易见,但请注意,数字的符号(正负)也是允许变化的(可以变为负数)。

多种商品

一个账户的余额可以包含多种商品。这相当于一个从商品到其单位数量的映射。例如:

2016-07-24 * "Dinner before leaving NYC"
  Expenses:Restaurants     34.58 USD
  …

2016-07-26 * "Food with friends after landing"
  Expenses:Restaurants     62.11 CAD
  …

经过这两笔交易后,餐饮账户包含 34.58 美元和 62.11 加元。其余额被称为混合商品。自然地,记账仅影响其对应的货币。例如,以下交易:

2016-07-27 * "Brunch"
  Expenses:Restaurants     23.91 CAD
  …

会使该账户余额变为 34.58 美元和 86.02 加元。其中美元的数量并未改变。

请注意,账户可以包含任意数量的商品,持有成本的商品也是如此,我们稍后会介绍。虽然系统支持这种灵活性,但我建议您创建足够多的账户,以确保每个账户只包含一种商品;这可以通过“onecommodity”插件来强制执行。

成本基础

当我们考虑追踪具有成本基础的投资时,情况会变得稍微复杂一些。Beancount 允许您为特定购入批次关联一个成本基础和一个可选标签。考虑以下对投资账户的两笔购买:

2015-04-01 * "Buy some shares of Hooli in April"
  Assets:Invest            25 HOOL {23.00 USD, "first-lot"}
  …

2015-05-01 * "Buy some more shares of Hooli in May"
  Assets:Invest            35 HOOL {27.00 USD}
  …

现在,投资账户的余额包含:

          units ccy   cost  cost-ccy lot-date    label
             25 HOOL {23.00 USD,     2015-04-01, "first-lot"}
             35 HOOL {27.00 USD,     2015-05-01, None}

这两个批次并未合并,它们在余额中仍是两个独立的持仓。只有当商品及其所有成本属性完全匹配时,系统才会合并批次并调整单位数量。(实践中,很少会出现两笔增加操作具有完全相同的成本和日期属性。)

请注意,Beancount 自动为每个批次关联了购入日期;如果您希望,可以通过添加类似可选标签的日期来覆盖它(这在进行成本基础调整时非常有用)。

用于增加账户余额的记账称为增加项

减少

但如何从余额中移除商品呢?

您可以通过卖出部分持仓来减少现有批次。这通过向账户记入一个减少项来实现,例如:

2015-05-15 * "Sell some shares"
  Assets:Invest           -12 HOOL {23.00 USD}
  …

为明确起见,使这笔记账成为减少项的原因,仅仅是其符号(-)与该商品当前余额符号(+25)相反。此记账告诉 Beancount:查找所有成本基础为 23.00 美元的批次,并从中移除 12 个单位。最终余额为:

          units ccy   cost  cost-ccy lot-date    label
             13 HOOL {23.00 USD,     2015-04-01, "first-lot"}
             35 HOOL {27.00 USD,     2015-05-01, None}

请注意,第一个批次减少了 12 个单位。我们无需指定批次的所有属性,只需指定成本即可。我们也可以等效地使用日期来指定要减少的批次:

2015-05-15 * "Sell some shares"
  Assets:Invest           -12 HOOL {2015-04-01}
  …

或使用标签:

2015-05-15 * "Sell some shares"
  Assets:Invest           -12 HOOL {"first-lot"}
  …

或这些属性的任意组合。系统会将任何属性组合与余额内容匹配,以确定要减少的批次。事实上,如果余额中仅有一个批次,您甚至可以这样减少它:

2015-05-15 * "Sell some shares"
  Assets:Invest           -12 HOOL {}
  …

模糊匹配

但如果多个批次都匹配减仓操作,会发生什么情况?例如,假设之前的库存包含两个批次,如果你这样写出售指令:

2015-05-15 * "Sell some shares"
  Assets:Invest           -12 HOOL {}
  …

Beancount 无法确定哪个批次需要被减仓。我们遇到了一个模糊匹配。

部分模糊匹配也是可能的。例如,如果你有如下库存需要减仓:

          units ccy   cost  cost-ccy lot-date    label
             25 HOOL {23.00 USD,     2015-04-01, None}
             30 HOOL {25.00 USD,     2015-04-01, None}
             35 HOOL {27.00 USD,     2015-05-01, None}

而你尝试这样减仓:

2015-05-15 * "Sell some shares"
  Assets:Invest           -12 HOOL {2015-04-01}
  …

前两个批次会被选为匹配项。

严格记账

Beancount 如何处理模糊匹配?默认情况下,它会报错。

更准确地说,Beancount 会调用记账方法,并根据该方法的设置来处理模糊匹配。默认的记账方法是“STRICT”(严格),它会直接放弃并报错,提示你需要细化输入以消除库存减仓的歧义。

先进先出与后进先出记账

还有其他记账方法可供选择。可以通过选项进行配置,如下所示:

option "booking_method" "FIFO"

FIFO”(先进先出)方法会自动选择最旧的匹配批次,直至达到请求的减仓数量。例如,基于我们之前的库存:

          units ccy   cost  cost-ccy lot-date    label
             25 HOOL {23.00 USD,     2015-04-01, "first-lot"}
             35 HOOL {27.00 USD,     2015-05-01, None}

尝试减仓 28 股,如下所示:

2015-05-15 * "Sell some shares"
  Assets:Invest           -28 HOOL {}
  …

将匹配两个批次,完全减掉第一个批次至零单位,并从第二个批次中移除剩余的 3 单位,最终得到如下库存:

          units ccy   cost  cost-ccy lot-date    label
             32 HOOL {27.00 USD,     2015-05-01, None}

“LIFO”(后进先出)方法类似,但它优先消耗最新(最晚)的批次,逆向追溯时间以移除所需数量。

按账户设置记账方法

你不必让所有账户都遵循相同的记账方法;上一节中的选项仅设置所有账户的默认方法。若要为特定账户覆盖记账方法,可以在该账户的 Open 指令中使用可选字符串选项,如下所示:

2014-01-01 open Assets:Invest    "FIFO"

这允许你对不同账户采用不同的记账解析方式。

完全匹配

严格记账有一个例外:如果整个库存恰好被完全减仓(即减仓数量等于总单位数),则显然所有匹配批次都应被选中,即使在“STRICT”模式下也被视为无歧义。例如,在“STRICT”模式下,以下减仓操作会清空之前的库存而不报错,因为恰好有 25 + 35 股匹配:

2015-05-15 * "Sell all my shares"
  Assets:Invest           -60 HOOL {}
  …

平均记账

由政府激励计划创建的退休账户(例如美国的 401k 计划或加拿大的 RRSP)通常由税前资金构成。对于这类账户,经纪商通常忽略成本基础的计算,因为税收将在资金从账户中取出时才征收。这些账户通常被完全投资,因此费用通常以投资组合中的股份形式扣除,按费用支付当日的价格计算。这使得追踪单个批次的成本基础变得困难。

正确处理方式是将每种商品的所有批次的单位数量和成本基础分别处理。例如,以下两笔交易:

2016-07-28 * "Buy some shares of retirement fund"
  Assets:Invest       45.0045 VBMPX {11.11 USD}
  …

2016-10-12 * "Buy some shares of retirement fund"
  Assets:Invest       54.5951 VBMPX {10.99 USD}
  …

应产生一个单一批次,包含总单位数和平均成本:

          units ccy    cost    cost-ccy lot-date    label
        99.5996 VBMPX {11.0442 USD,     2016-07-28, None}

此账户中收取的费用可能如下所示:

2016-12-30 * "Quarterly management fee"
  Assets:Invest       -1.4154 VBMPX {10.59 USD}
  Expenses:Fees

即使存在负单位,数量和成本也会分别汇总:

          units ccy    cost    cost-ccy lot-date    label
        98.1842 VBMPX {11.0508 USD,     2016-07-28, None}
此功能尚未在 Beancount 中支持;其实现较为复杂,未来将在一个小版本中推出。

无记账

然而,目前处理非应税账户的另一种方法是:直接禁用记账。有一种名为“NONE”的记账方法,采用非常宽松的策略,无条件接受任何新仓位。新仓位总是无条件追加到库存中。对上一节中的交易使用此策略,将得到如下库存:

          units ccy    cost  cost-ccy lot-date    label
        45.0045 VBMPX {11.11 USD,     2016-07-28, None}
        54.5951 VBMPX {10.99 USD,     2016-10-12, None}
        -1.4154 VBMPX {10.59 USD,     2016-12-30, None}

请注意,所得库存中混合了正负符号;通常这是不允许的,但在此退化记账方法下被允许。需要注意的是,在此方法下,唯一有意义的数值是单位的总计平均成本。各个仓位实际上并非真正的仓位,它们仅表示对该账户的所有分录列表。

注意:如果您熟悉 Ledger,这正是它默认且唯一支持的记账方法。

摘要

总结一下,我们在本指南中介绍了以下内容。增加操作永远不会出现问题;它们总是向现有库存添加一个新仓位。另一方面,减少操作可能导致以下几种结果:

  • 单一匹配。 仅有一个仓位与减少操作匹配;该仓位被减少。

  • 完全匹配。 请求减少的单位总数与匹配仓位的单位总数相等;这些仓位被完全消除。

  • 无匹配。 没有任何仓位与减少分录匹配;将抛出错误。

  • 模糊匹配。 库存中有多个仓位与减少分录匹配;此时将调用记账方法来处理这种情况

为处理最后一种情况,有几种记账方法可供选择:

  • STRICT。 抛出错误。

  • FIFO。 从最旧(最早)的仓位中选取单位,直至完成减少。

  • LIFO。 从最新(最晚)的仓位中选取单位,直至完成减少。

  • AVERAGE。 每次减少后,受影响商品的所有单位将合并,并重新计算其平均成本。

  • NONE。 禁用记账;减少的仓位直接添加到库存中。这将导致库存中出现混合符号,只有单位总数和总成本基础是合理的数值。

Beancount 为所有账户设定了默认记账方法,可通过选项覆盖:

option "booking_method" "FIFO"

记账方法的默认值为“STRICT”。我建议您保持此默认设置,并仅对特定账户覆盖记账方法。

可通过在账户的 Open 指令中添加字符串来为每个账户指定该方法:

2016-05-01 open Assets:Vanguard:RGAGX  "AVERAGE"

价格的用途

简短的回答是:价格既不被使用,也不影响记账算法。然而,讨论它们在此上下文中的作用仍有必要,因为用户通常会对其含义感到困惑。

价格有两种用途:在不同商品之间进行转换,以及为减少的仓位标记其销售价格,以便记录并可选地平衡收益。

商品兑换

兑换用于将一种货币兑换为另一种货币。其形式如下:

2016-04-24 * "Deposit check"
  Assets:Bank:Checking      220.00 USD @ 1.3 CAD
  Income:Payment           -286.00 CAD

为了记账目的,将该笔交易记入支票账户的库存时,带有价格的分录与不带价格的分录处理方式完全相同:支票账户仅接收 220.00 单位的美元存款,并与“USD”商品的持仓进行匹配。价格仅用于验证交易是否平衡并确保复式记账规则得到遵守(220.00 x 1.3 加元 + -286.00 加元 = 0.00)。除此之外,价格在修改库存内容时被完全忽略。换句话说,在分录应用到账户库存后,价格即被遗忘,库存余额不会保留任何关于该笔兑换存款的记录。

价格 vs. 成本基础

有人可能会疑惑,如果存在成本基础说明,如以下情况,价格是如何使用的:

2015-05-15 * "Sell some shares"
  Assets:Invest:HOOL           -12 HOOL {23.00 USD} @ 24.70 USD
  Assets:Invest:Cash        296.40 USD
  Income:Invest:Gains

答案常常令许多用户感到意外:当存在成本基础时,平衡算法不会使用价格;成本基础才是用于平衡分录的数值。这一特性非常有用,因为它允许我们自动计算资本收益。在上例中,平衡算法会计算 -12 x 23.00 + 296.40 = -20.40 美元,即资本收益(24.70 - 23.00)* 12,并将该值分配给最后一笔分录。

通常,销售利润的计算方法是将收益(即现金存款)与已售持仓的成本基础进行加权对比,这足以确定收益差额。此外,如果一条增加型分录恰好带有价格标注,该价格同样会被忽略。

价格仅作为您记录的注释。它仍附着在分录对象上,若您希望以某种方式使用它,随时可以通过编写 Python 代码实现。目前已有两个插件利用了这一注释:

  • beancount.plugins.implicit_prices:此插件提取分录上附带的价格,自动生成并插入对应的 Price 指令,以填充全局价格数据库。

  • beancount.plugins.sellgains:此插件实现了一项额外的平衡检查:它使用价格计算预期收益,并将其与交易中所有其他分录(排除任何收益账户分录)进行对比。在我们的示例中,它会验证(-12 x 24.70 + 296.40)= 0。这提供了另一种验证输入正确性的方法。

有关此主题的更多详情,请参阅使用 Beancount 进行交易文档。

交易

买入某种资产并随后卖出的行为,我们称之为“交易”。在 Beancount 中,只有具有成本基础的资产才被视为交易对象。由于库存减少是在记账过程中与累计库存内容匹配完成的,因此交易应在这一阶段被识别和记录。

截至当前(2016 年 12 月),交易记录功能尚未实现。

此前已测试过一些原型,我相信在不久的将来添加该功能将非常容易。相关内容将在此处更新,请持续关注。

交易的实现方式是:在交易流中,记账过程为增加和减少的分录分别插入带有唯一 UUID 的匹配元数据。我们将提供函数和报表,能够轻松提取每条减少分录对应的配对分录,并以不同方式过滤它们。最终,用户应能将所有交易提取为一张表格,包含买入价、卖出价以及其他费用。

调试记账问题

如果您在记账过程中因匹配流程遇到困难,可以使用工具查看在应用交易前后某账户的详细库存内容。为此,您可以使用 bean-doctor 命令。调用该程序时,需提供接近您要选择的交易的文件名和行号,例如:

bean-doctor context <filename> <line-no>

生成的输出将显示所有受影响账户在交易应用前的库存列表,包括成本基础、购入日期和可选标签的完整渲染内容。请注意,某些失败通常会通过丢弃无效交易的影响来处理(但绝不会静默处理)。

在 Emacs 或 VI 中,将光标置于交易附近并调用相应命令,是最简便的调用方式,因为它会自动插入行号。

附录

本文档的其余部分将深入探讨更多技术细节。您完全可以忽略这部分内容,它并非理解 Beancount 工作原理所必需。仅当您对细节感兴趣时才需阅读。

数据表示

了解库存对象中持仓的表示方式很有帮助。持仓本质上是某种商品的若干单位,附带一些关于其购入的可选信息:

  • 成本。 每单位的购入成本(即“成本基础”)。

  • 日期。 单位被购入的日期。

  • 标签。 用户指定的标签,可用于引用该批持仓。

我们通常将这些持仓对象称为“批次”或“头寸”。其结构示意如下:

持仓对象的结构。

有两种不同类型的持仓,详见以下章节:

  • 简单持仓。 这类持仓没有成本基础,“成本”属性被设为 null 值(Python 中为“None”)。

  • 按成本持有持仓。 这类持仓具有关联的成本基础和购入详情。

库存仅仅是这些持仓的累加,以列表形式表示。我们有时用交易前库存指代交易分录应用前的库存内容,用交易后库存指代应用后的结果库存。

分录是一个比持仓更广泛的对象:除了单位和成本外,它还包含关联的账户和可选的价格属性。若存在,价格的类型与单位相同,它代表输入中交易的一个头寸。分录隐含持仓,这些持仓会被添加到库存中。我们可以说一个持仓被记入某个账户。

有关 Beancount 内部数据结构的更多细节,请参阅设计文档,其中对此主题有更深入的阐述。

为什么记账并不简单

当考虑如何追踪不同批次的成本基础时,减记过程的复杂性便显现出来。为说明其工作原理,我们来看一个简单的例子,该例子将在下面各节中重复使用:

  • 4 月 1 日以每股 23 美元的价格买入 25 股。

  • 5 月 1 日以每股 27 美元的价格买入 35 股。

  • 5 月 15 日卖出 30 股,当时股价为每股 26 美元。

目前我们忽略佣金,因为它们不会增加额外的复杂性。在 Beancount 中,这一情景将被记录如下:

2015-04-01 * "Buy some shares of Hooli in April"
  Assets:Invest:HOOL            25 HOOL {23.00 USD}
  Assets:Invest:Cash       -575.00 USD

2015-05-01 * "Buy some more shares of Hooli in May"
  Assets:Invest:HOOL            35 HOOL {27.00 USD}
  Assets:Invest:Cash       -945.00 USD

2015-05-15 * "Sell 30 shares"
  Assets:Invest:HOOL           -30 HOOL {...} @ 26.00 USD
  Assets:Invest:Cash        780.00 USD
  Income:Invest:HOOL:Gains

现在,整个问题的核心在于哪一批股票被选中出售。我已将此输入渲染为红色省略号(“…”)。用户在该位置填写的任何内容都将用于确定我们希望使用的批次。

我们选择作为售出的批次将决定收益金额,因为收益是这些股票成本基础的函数。这就是为什么这一点至关重要。

增加与减记

最重要的观察是,输入中存在两种看似相似但处理方式截然不同的批次指定方式。

当我们买入时,如上述第一笔交易,{...} 成本基础语法为 Beancount 提供了有关批次的信息:

2015-05-01 * "Buy some more shares of Hooli in May"
  Assets:Invest:HOOL            35 HOOL {27.00 USD}
  Assets:Invest:Cash       -945.00 USD

我们将此称为对库存的“增加”,因为它只是向库存中添加一个新头寸。您提供的成本基础将与该头寸绑定,并在库存中长期保留。此外,您还可以为增加型批次提供其他一些数据。让我们看看成本规范中可提供的所有数据:

  • 成本基础。 包括单位成本和总成本数值——二者合并为一个单位成本数值——以及货币。
  • 购入日期。 每个批次都有一个购入日期。默认情况下,其父交易的日期将自动作为其购入日期。您可以通过提供日期来覆盖此日期。这在处理股票分割或洗售交易时非常有用,以便保留被替换股份的原始购入日期,我们稍后会看到这一点。

  • 标签。 您可以为其提供一个唯一标签,以便在日后出售部分或全部该批次时更方便地引用它。

  • 合并。 一个标志(标志位),指示该批次应被合并(这将在后续实现平均成本记账时有用)。

对于增加型过账,这些信息必须提供或自动推断。它们可以按任意顺序提供:

2015-05-01 * "Buy some more shares of Hooli in May"
  Assets:Invest:HOOL            35 HOOL {2015-04-25, 27.00 USD, "hooli-123"}
  Assets:Invest:Cash       -945.00 USD

如果您省略日期,则使用交易的日期。如果您省略成本,则其余过账项必须填写完整,以便从其他项中推断出成本金额。由于标签是可选的,未指定的标签字段将保留为 null 值。

您可能会疑惑,为何允许覆盖增加型交易的日期?这是因为当进行成本基础调整时,保留原始过账的购入日期非常有用:您先移除该过账,然后用原始购入日期和新的成本基础重新添加它。

现在,当我们出售这些股票时,我们会将这笔交易称为“减少”交易,即“减少”。请注意,‘增加’和‘减少’这两个术语是我为设计 Beancount 处理库存的方式而提出的术语,并非通用的会计术语。

这被称为‘减少’,因为我们正在从截至交易日期累计的库存中移除股票。例如,如果我们从这 35 股中卖出 30 股,输入可能如下所示:

2015-05-15 * "Sell 30 shares"
  Assets:Invest:HOOL           -30 HOOL {27.00 USD} @ 26.00 USD
  Assets:Invest:Cash        780.00 USD
  Income:Invest:HOOL:Gains

虽然输入格式与‘增加’交易相同,但 Beancount 对其处理方式截然不同:它会查看账户库存在应用交易前的状态,并找出所有与您提供的批次数据匹配的持仓。然后,它会使用匹配批次的详细信息作为‘减少’交易的成本基础信息。

在此示例中,它将简单匹配所有成本基础为 $27.00 的持仓。该示例在出售前的库存仅包含一个 35 股、成本为 $27.00 的批次,因此只有一个匹配的持仓,该批次减少 30 股后剩余 5 股。我们稍后会看到,当有多个批次匹配指定条件时会发生什么情况。

请注意,您还可以提供其他批次信息的子集进行匹配,例如仅提供标签:

2015-05-15 * "Sell 30 shares"
  Assets:Invest:HOOL           -30 HOOL {"hooli-123"} @ 26.00 USD
  Assets:Invest:Cash        780.00 USD
  Income:Invest:HOOL:Gains

这也是识别您希望减少的特定批次的有效方式。如果您在此提供了日期,它也仅用于与库存内容匹配,以区分不同日期购入的批次,而不会将日期附加到任何地方。此外,如果库存中只有一个批次,您甚至可以仅提供一个空的成本基础规范,例如:“{}”。下面的“记账方法”部分将详细说明当匹配结果存在歧义时会发生什么。

总结如下:

  • 当您向账户库存中添加项目(增加)时,您提供的信息将用于创建一个新的批次,并附加到该批次上。

  • 当您从账户库存中移除项目(减少)时,您提供的信息将用于筛选库存内容,以选择要减少的批次,并填充所选批次的信息。

同质与混合库存

到目前为止,在示例和文档中的绝大多数示例中,‘增加’意味着添加正数量的股票。但在 Beancount 中,许多账户通常具有负余额,例如负债账户。人们可能会问:持有按成本计价的负数量商品是否合理?

答案是肯定的。这对应于‘做空’头寸。大多数人不太可能进行做空交易,但 Beancount 的库存系统支持这种操作。我们定义‘增加’是相对于特定商品现有批次余额而言的。例如,如果账户库存包含以下持仓:

                                25 HOOL {23.00 USD, 2016-04-01}
                                35 HOOL {27.00 USD, 2016-05-01}

那么‘增加’意味着正数量的股票。另一方面,如果账户仅包含做空头寸,例如:

                               -20 HOOL {23.00 USD, 2016-04-15}
                               -10 HOOL {27.00 USD, 2016-05-15}

那么‘增加’意味着负数量的股票,而‘减少’则通过匹配正数量的股票来实现。

上述两种库存在 HOOL 单位上是同质的,即所有持仓的符号相同。在我们接下来将看到的大多数记账方法中,Beancount 禁止创建非同质(即‘混合’)库存。但‘NONE’方法允许这样做。一个混合库存可能包含如下内容:

                                25 HOOL {23.00 USD, 2016-04-01}
                               -20 HOOL {23.00 USD, 2016-04-15}

正如你可能直观感受到的,"增强"或"减少"的概念仅适用于同质的库存。

原始提案

如果你对促成此实现的设计文档感兴趣,可以在此查看。我希望最终的实现既足够简单又具有通用性。