本文以美股为例,介绍使用复式簿记软件 Beancount 记录证券投资交易(俗称「炒股」),并计算盈亏的方法。

一、复式簿记与 Beancount 简介

大部分普通人所说的「记账」,是指单式簿记(也称「流水账」),每笔交易只涉及一个账户。单式簿记简单易上手,但出错后不易排查,且在面对复杂交易(如一对多、多对多)时力不从心甚至无能为力。证券投资作为一种复杂交易,必须要用复式簿记才能精确、清晰地计算其盈亏。

三年前,我写过一篇介绍复式簿记和复式簿记软件 Beancount 的博客文章《Beancount —— 命令行复式簿记》(以下简称「安利文」),这篇文章成为了不少中文 Beancount 用户的入坑指引。本文假设你已经阅读过安利文,并对 Beancount 的使用有基本的了解。

二、概念

除了安利文中已经涉及的概念之外,用 Beancount 记录证券投资交易还需要理解一些额外的概念。

成本与价格

在 Beancount 里,证券与一般货币都是通货(commodity)。它们的不同之处在于,我们关心并记录证券的持有成本(held at cost),只有这样,在卖出证券的时候,我们才能正确地计算出这笔交易的盈亏(PnL, Profit and Loss)。

什么是成本(cost)?当你买入一支股票的时候,每股所花的钱即是成本。比如 2017-07-14 某一时刻,Amazon 的股票(AMZN)的市场价在 1000 USD 左右浮动,你买入了 10 股 AMZN,总共花了 10000 USD,那你的这些 AMZN 股票,每一股的 cost 便是 1000 USD。

什么是价格(price)?严格意义上讲,在交易完成前,你无法预知你这笔交易最终会被撮合成什么价格,但对于交易量足够大的股票来讲,它总是离上一次交易的成交价差不了太多。因此,我们通常把某支股票的市场价(market price)称为它的价格。更特殊地,在讨论某支股票的历史行情的时候,通常把它的收盘价(closing price)作为它的价格。比如 2018-08-30 某一时刻,你的 AMZN 股票市场价在 2000 USD 左右浮动(一年上涨 100%?这是真实数据),你卖出了 3 股 AMZN,获得 6000 USD,每股卖出的 price 便是 2000 USD。

在上面的例子中,2017-07-14 你买入 10 AMZN 时,cost 和 price 在那一瞬间是相等的,都是 1000 USD。在那之后,cost 不变,price 有涨有跌;在 2018-08-30 你卖出 3 AMZN 时,cost 依然没变(1000 USD),但是 price 变成了 2000 USD,这中间的差价,便是你的盈亏(PnL)。本例中,你总共获得了 3000 USD 的盈利(profit / capital gains)。

AMZN_YahooFinanceChart

在 Beancount 中,表示 cost 的语法是 {},表示 price 的语法是 @@@。例如,买入 10 股成本为 1000 USD 的 AMZN 记为:

2017-07-14 * "Buy 10 AMZN"
  Assets:Trade:Cash                        -10000.00 USD
  Assets:Trade:Positions                       10 AMZN {1000.00 USD}

以 2000 USD 每股的价格出售 3 股 AMZN 记为:

; 单价
2018-08-30 * "Sell 3 AMZN"
  Assets:Trade:Positions                       -3 AMZN {1000.00 USD} @ 2000.00 USD
  Assets:Trade:Cash                          6000.00 USD
  Income:Trade:PnL                          -3000.00 USD

; 总价
2018-08-30 * "Sell 3 AMZN"
  Assets:Trade:Positions                       -3 AMZN {1000.00 USD} @@ 6000.00 USD
  Assets:Trade:Cash                          6000.00 USD
  Income:Trade:PnL                          -3000.00 USD

为什么要带上 {1000.00 USD}?为什么有个 PnL?为什么交易看起来不平(总和不是零)?下文详解。

库存

如果你还记得三年前的安利文里的桶子之间倒豆子的比喻的话,你会比较容易理解库存(inventory)的概念。把你的股票账户想像成一个桶,买的股票便是放进桶里的豆子。股票桶里的豆子,与其他桶的豆子有一些不同——它们上面贴了标签。你买的那 10 个名为 AMZN 的豆子,每个上面都贴着三个标签「购于 2017-07-14」「成本 1000.00 USD」「备注:无」。在你卖掉 3 个豆子之后,还剩 7 个这样的豆子。然后你在 2019-01-04 又买了 5 个 AMZN 豆子,这 5 个新豆子上的标签是「购于 2019-01-04」「成本 1575.39 USD」「备注:无」。此时,你的股票账户里的一共有 12 个 AMZN 豆子,但是由于它们的标签不同,所以泾渭分明地分成两堆(lot):

7 AMZN {1000.00 USD, 2017-01-14}
5 AMZN {1575.39 USD, 2019-01-04}

注意:对于 held at cost 的 commodity(比如证券)来说,{} 与前面的字母在任何时候都是不可分离的。比如 1 AMZN {...} 代表的是「1 颗带标签的 AMZN 豆子」(held at cost 的 AMZN 股票),不能只写 1 AMZN

当你往股票桶里增加这些带标签的豆子时,Beancount 都会比较豆子的种类,以及它的三个标签(成本、日期、备注),只有种类和三个标签完全一致的情况下,这些豆子才会被认为是完全一模一样的豆子,被归在一堆里。一般地,在 Beancount 里,所有的 commodity 其实都有标签的,只是对于一般货币来说,我们不会加 {} 记号,因此它们的标签都是空的,自然符合「种类及三个标签完全一致」的条件,被归成同一堆了。

当你往股票桶里取出这些带标签的豆子时,你需要让 Beancount 知道取哪些。比如上面,一共 12 颗 AMZN 豆子,从中取出 4 颗,那么是哪 4 颗呢?你同样需要通过标签来让 Beancount 知道你要从哪堆豆子里取。写成 -4 AMZN {1000.00 USD} 或是 -4 AMZN {2017-01-14} 都将匹配到 7 颗的那堆,写 -4 AMZN {1575.39 USD} 或是 -4 AMZN {2019-01-04} 则是匹配到 5 颗的那堆。如果你在买入的时候还加了备注的话(如 7 AMZN {1000.00 USD, "foo bar"}),卖出的时候还可以用备注来匹配这一堆。

如果你要取出 8 颗怎么办?两堆中的任何一堆都是不够取的,这时候你可以取两次(7 + 1,或是其他你喜欢的组合),可这样也太麻烦了。而且就算是取 4 颗的情况,你还得先看了一下自己库存里的标签。有没有更自动化的方法?有。对于 held at cost 的证券,Beancount 提供了 5 种簿记方法:

  • STRICT:每次取豆子时,必须要明确匹配到某个 lot,不允许模糊匹配(例外是 {},代表全部取出)。这也是默认的模式。
  • FIFO:即先进先出(First-In, First-Out),每次取豆子时自动从最老的豆子开始取,直到取光。
  • LIFO:即后进先出(Last-In, First-Out),与 FIFO 相反,最新的豆子优先被取出。
  • AVERAGE:每次重新计算 cost basis。该方法暂未实现。
  • NONE:完全不合并,允许不同符号的 lot 同时存在。

比如你可以改用 FIFO 簿记方法:

1970-01-01 open Assets:Trade:Positions "FIFO"

在使用 FIFO 簿记方法之后,你只需要写 -4 AMZN {},Beancount 就会自动取出最老的 4 颗豆子。

为什么要搞不同的簿记方法?因为按不同的簿记方法,你的 PnL 计算结果是不同的。还是刚才 12 颗 AMZN 豆子的情况。假设今天 AMZN 的价格是 1300 USD,你希望卖出 4 颗豆子。如果你选择按 FIFO 卖出标签为 {1000.00 USD, 2017-01-14} 的豆子,那么你的 PnL 是盈利 1200.00 USD;如果你选择按 LIFO 卖出标签为 {1575.39 USD, 2019-01-04} 的豆子,那么你的 PnL 是亏损 1101.56 USD。同样一笔交易,按不同的簿记方法,一个是盈利,一个是亏损!

谁会来关心你的 PnL 呢?除了你自己,当然是税务局了。比如 IRS 就是用 FIFO 的方式来计算 PnL 并据此征税的。对于投资者来说,盈利的话要缴税,亏损的话可以抵税;长期持有的股票卖出时所得税率低,短期持有的股票卖出时所得税率高——只有使用规范的 PnL 计算方法,才能算出正确的税务数据。

同质与异质

上面的例子皆是做多(long),那如果是做空(short)呢?Beancount 当然也是支持的。Beancount 在将 lot 放入 inventory 的时候,会判断 lot 与 inventory 是同质(homogeneous)还是异质(heterogeneous)的。在多仓情况下,使用 + 符号是增加仓位(buy to open),使用 - 符号是减少仓位(sell to close);在空仓情况下,使用 - 符号才是增加仓位(sell to open),使用 + 符号则是减少仓位(buy to cover)。只要记住:lot 的符号和已有 inventory 一样,就是增加仓位(无论 long 或 short),否则就是减少仓位。

三、实践

普通交易

现在再来看最开始买 10 AMZN 又卖 3 AMZN 的两笔交易,就显得清晰明了了。上文的写法是最完整的写法,完整地写出了交易的每一个部件。我们实际记录的时候,并不需要写这么完全,因为 Beancount 会根据已有条件自动补全。那两笔交易完全可以写成这样:

1970-01-01 open Assets:Trade:Positions "FIFO"

2017-07-14 * "Buy 10 AMZN"
  Assets:Trade:Cash
  Assets:Trade:Positions                       10 AMZN {1000.00 USD}

2018-08-30 * "Sell 3 AMZN"
  Assets:Trade:Positions                       -3 AMZN {}
  Assets:Trade:Cash                          6000.00 USD
  Income:Trade:PnL

对于买入交易,我们只要记录买入 10 AMZN,每股 1000 USD 就已足够,Beancount 会自动算出这需要 10000 USD 的 Cash;对于卖出交易,我们只要记录卖出 3 AMZN,获得 6000 USD 现金也就足够了——至于卖哪 3 股、成本是多少、卖出时的价格是多少、盈亏是多少,都不用写,因为 Beancount 会根据已有条件自动算出来。最后你在 Fava 里看到的 Beancount 补全之后的交易,就是和本文最开始的完整体是一样的。

在实际交易中,许多劵商收取交易佣金,这时候你还需要增加一条类似 Expenses:Trade:Commissions 的 posting,记录所收取的佣金。Beancount 会自动把佣金从 Cash 里或是 PnL 里扣除。

分红与赠股

对于现金形式的分红,没啥特别的。比如每季度都分红的微软

2018-03-09 * "DIVIDEND: MICROSOFT CORP"
  Income:Trade:Dividend
  Assets:Trade:Cash                             3.36 USD

对于股票形式的分红或赠股(较少,我没遇到过),你需要在劵商的结单上找到加塞到你的股票账户里的新股票的 market price,作为新股票的 cost。这里以 2017-06-05 新浪(SINA)赠送微博(WB)为例:

2017-06-05 * "Spin off: 1 WB for 10 SINA"
  Income:Trade:Dividend
  Assets:Trade:Positions                        2 WB {71.94 USD}

拆股与合股

由于公司结构调整,有些股票会被拆分或合并。对用户来说就是减少了一种股票,增加了另一种股票;两种股票的符号可能是一致的,但是成本通常是不一致的。比如前段时间戴尔搞了一个大新闻,把自己的 Class V Common Stock 以 1.8066 的比例置换成 Class C Common Stock,完成「重新上市」。对投资者来说,就是原本每一股 DVMT 股票,被换成 1.8066 股 DELL 股票(向下取整)。我当时持有 5 股 DVMT,于是换到了 9 股 DELL:

2019-01-02 * "CLASS V COMMON STOCK STOCK MERGER @ 1.8066, CLASS C COMMON STOCK SHRS RECEIVED THRU MERGER"
  Assets:Trade:Positions                       -5 DVMT {} @ 80.00 USD
  Assets:Trade:Positions                        9 DELL {48.59 USD}
  Income:Trade:PnL

在这次行动中:

  • 那个 @ 80.00 USD 只是我为了记录 DVMT 退市的时候的收盘价,其实它完全不参与算式平衡;
  • 48.59 USD 是劵商结单上提供的 cost,通常与前一天的收盘价是一致的。

虽然 @ 80.00 USD 不参与算式平衡,但是可以用来验算 PnL。Beancount 自带这么一个插件用来做这个事情:

plugin "beancount.plugins.sellgains"

留给读者的测验:已知这次公司行动使我盈利 49.81 USD,求原有 DVMT 股票的(平均)成本。

价格数据库与浮动盈亏

Beancount 支持维护一个价格数据库。Beancount 的核心功能不会用到这些数据,但是有些插件会,比如计算浮动盈亏(Unrealized PnL)。我写了一个简单的脚本,可以从 Beancount 文件读取你所有的持仓,然后从 IEX 的 API 抓取这些股票的过去一年里每天的收盘价并输出到屏幕。我现在工作流是,有个专门的文件用来保存价格数据库,每月整理账本的时候做一次更新,然后在 Vim 里 :sort u 做一次去重。

有了价格数据库之后,就可以开启 Beancount 自带的插件,计算 Unrealized PnL:

plugin "beancount.plugins.unrealized" "Unrealized"

2019-10-10 更新:在 Fava 中默认不显示 Unrealized PnL 产生的交易,可点击「X」按钮显示出来,也可通过选项来更改默认显示的交易类型。如:

1970-01-01 custom "fava-option" "journal-show-transaction" "pending cleared other"

四、参考资料

五、尾声

有读者在看过三年前的那篇安利文之后表示我对 cost 和 price 没有涉及到。本文算是讲解完全了吧。

据说 2019 年开始,中国的股市开始又一轮的复苏,想必会吸引许多新的投资者加入战场,希望本文所介绍的记账方法对他们带来帮助。

本文地址: https://wzyboy.im/post/1317.html


读者来信

2019-10-10 读者 LIGHT Liu <l...@gmail.com> 来信询问为什么在 Fava 中看不到 unrealized 插件产生的交易并寄来了示例账本。经调查,我发现原因是 Fava 默认不显示状态为 Pending 和 Cleared 以外的交易。在 Fava 选项中可以使用 journal-show-transaction 改变这一默认行为。我一直都开着 Other 类型,所以原文漏掉了这一点,现已补充至原文。感谢读者的指出。

2023-08-03 读者 Fang-Pen Lin <f...@launchplatform.com> 来信推荐了其创办 Beancount 托管平台 BeanHub。该平台提供了灵活的自定义表单功能,可以让用户方便地在浏览器中录入数据。对于有随手记账习惯的用户来说,可能无法随时随地直接使用文本编辑器进行录入。BeanHub 作为托管平台,免去了用户自己安装维护 Beancount 的麻烦,更可提供网页界面,方便在手机等移动设备上进行记录。


欢迎留下评论。评论前,请先阅读《隐私声明》。