Beancount 中的价格

Martin Blais,2015 年 12 月

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

引言

根据定义,处理 Beancount 文件仅限于文件本身的内容。特别是,商品的最新价格从不自动从互联网获取。这是有意为之,以确保任何报告的运行都是确定性的,并且可以离线执行。不会有任何意外。

然而,为了计算资产的市场价值,我们需要访问价格信息。为此,Beancount 提供了一个价格指令(Price directive),可用于通过在输入文件中内联插入这些价格点来填充其内存中的价格数据库:

2015-11-20 price ITOT 95.46 USD
2015-11-20 price LQD 115.63 USD
2015-11-21 price USD 1.33495 CAD

当然,你可以手动查找在线价格并自行编写这些指令。但对于公开交易的资产,你可以通过调用一段代码来自动化这一过程,该代码会下载价格并为你生成相应的指令。

Beancount 自带了一些工具来帮助你完成这一任务。本文档将介绍这些工具。

问题

在维护 Beancount 文件的背景下,我们需要解决一些特定需求。

首先,我们不能期望用户总是及时更新输入文件。这意味着我们必须能够获取最新的价格,以及历史价格。Beancount 提供了一个接口来提供这些价格,并实现了若干数据源(从 Yahoo! Finance 和 Google Finance 获取)。

其次,我们不希望为当前不相关的持仓存储过多价格。文件中持有的资产列表会随时间变化。理想情况下,我们只希望在资产被持有期间包含其对应的价格。Beancount 的价格维护工具能够自动判断在特定日期需要获取哪些商品的价格。

第三,如果某个价格已经存在于输入文件中,我们就不希望再次获取。这些工具能够检测到这种情况并跳过重复的价格。此外,还内置了缓存机制,避免冗余的网络请求。

最后,虽然我们提供了一些基本的价格源实现,但无法为所有可能的在线来源和网站提供相应的代码。这个问题类似于从各种机构导入和提取数据的问题。为解决此问题,我们提供了一种可扩展性机制:你可以通过编写一个 Python 模块来实现自己的价格获取器,并在输入文件中通过指定该模块的名称作为某商品的数据源来引用它。

“bean-price” 工具

Beancount 随附了一个名为 “bean-price” 的命令行工具,整合了上述所有功能。默认情况下,该脚本接受一组 Beancount 输入文件名,并获取计算当前账户持仓最新市场价值所需的价格:

bean-price /home/joe/finances/joe.beancount

还可以提供一个要运行的特定价格获取任务列表,例如:

bean-price -e USD:google/TSE:XUS CAD:mysources.morningstar/RBF1005

这些任务将并发执行,因此速度相当快。

源字符串

这些“任务源字符串”的通用格式为:

<quote-currency>:<module>/[^]<symbol>

例如:

USD:beancount.prices.sources.google/NASDAQ:AAPL

“报价货币”是指商品的报价货币。例如,苹果公司的股票以美元报价。

“模块”是包含一个可实例化 Source 类的 Python 模块名称,该类可连接到数据源以提取价格数据。这些模块会自动导入,并在其中实例化 Source 类,以从其支持的特定在线源获取价格。这允许您编写自己的获取代码,而无需修改本脚本。您的代码可以放在 Python PYTHONPATH 的任意位置,您无需修改 Beancount 本身即可使其正常工作。

“符号”是一个传递给价格获取器以查找货币的字符串。例如,苹果股票在纳斯达克交易,Google Finance 源中的对应符号为“NASDAQ:AAPL”。其他价格源可能使用不同的符号体系,例如某些源可能需要资产的 CUSIP 编号。

系统提供了默认的价格源实现;我们提供了适用于 Yahoo! Finance 和 Google Finance 的获取器,覆盖了大量常见的公共投资类型(如股票和部分共同基金)。为方便起见,模块名称会首先在 "beancount.prices.sources" 包中搜索,这些实现就位于该包中。因此,例如,要使用提供的 Yahoo! Finance 数据获取器时,您无需写完整的 "beancount.prices.sources.yahoo/AAPL",而只需使用 "yahoo/AAPL" 即可。

备用源

实际上,在线获取价格经常失败。数据源通常仅支持有限数量的资产,即使如此,对不同资产的支持也可能存在差异。例如,Google Finance 支持股票的历史价格,但不返回货币工具的历史价格(这些限制可能更多与它们和上游数据提供商之间的合同安排有关,而非技术限制)。

为此,源字符串可以提供多个数据源,用逗号分隔。例如:

USD:google/CURRENCY:GBPUSD,yahoo/GBPUSD

系统将依次尝试每个源,如果某个源未能返回有效价格,则尝试下一个源作为备用。我们希望至少有一个指定的源能够成功。

反向价格

有时,仅能获取某工具的反向价格。这种情况在货币中很常见。例如,加元兑美元的价格由 USD/CAD 市场提供,该市场给出的是 1 美元兑换多少加元(即反向)。要使用此数据,您可以在工具名称前添加 "^",以指示工具计算获取价格的倒数:

USD:google/^CURRENCY:USDCAD

如果源价格需要被反向,如上所述,其精度可能与原始获取值不同。例如,若 USD/CAD 的价格为 1.32759,则上述指令为“CAD”工具定价时将输出:

2015-10-28 price CAD  0.753244601119 USD

默认情况下,反向汇率的舍入方式与其它价格指令对数字的舍入方式相同。

如您所知,Beancount 的内存价格数据库是双向工作的(所有汇率的倒数都会自动存储)。因此,如果您希望输出的价格条目交换货币顺序,而不是直接取汇率的倒数,可以使用 --swap-inverted 选项。在前面的加元价格示例中,它将输出如下内容:

2015-10-28 price USD   1.32759 CAD

日期

默认情况下,系统会提取资产的最新价格。您可以使用选项来获取指定历史日期的价格:

bean-price --date=2015-02-03 …

如果您使用输入文件指定要获取价格的列表,该工具将自动识别在该时间点账本中持有的资产,并仅获取这些资产的历史价格。

缓存

价格会自动缓存(如果是当前且最新的价格,缓存时间仅约半小时)。这在连续多次运行脚本进行故障排查时非常方便。

您可以使用选项禁用缓存:

bean-price --no-cache

您还可以指示脚本在获取价格前清除缓存:

bean-price --clear-cache

来自 Beancount 输入文件的价格

通常,您使用 Beancount 输入文件来指定要获取价格的货币列表。为此,您应在输入文件中为每个需要获取价格的货币添加 Commodity 指令,如下所示:

2007-07-20 commodity VEA

  price: "USD:google/NYSEARCA:VEA"

"price" 元数据应包含一个价格源字符串列表。例如,一只股票产品可能如下所示:

2007-07-20 commodity CAD

  price: "USD:google/CURRENCY:USDCAD,yahoo/USDCAD"

而一种货币可能需要转换为多种目标货币:

1990-01-01 commodity GBP

  price: "USD:yahoo/GBPUSD CAD:yahoo/GBPCAD CHF:yahoo/GBPCHF"

提取哪些资产

从 Beancount 输入文件中计算需要价格的资产列表有多种方式:

  1. Commodity 指令。 文件中所有包含 "price" 元数据的 Commodity 指令列表。对于每个此类持仓,系统将查阅该指令的 "price" 元数据字段,以确定从何处获取价格。

  2. 按成本持有资产的货币。 在特定日期所见的所有按成本持有的持仓的价格。由于这些持仓是按成本记录的,我们可以假定其对应货币存在随时间变化的价格。

  3. 已转换的货币。 过去曾从其他货币转换而来的持仓价格(例如,将某种货币的现金转换为另一种货币)。

默认情况下,要获取的票据列表仅包含上述三个列表的交集。这是因为本脚本最常见的用途是为特定日期获取缺失的价格,且仅获取必需的价格。

非活跃货币。 您可以使用 "--inactive" 选项,获取第 (1) 类中的全部价格,而不考虑第 (2) 和 (3) 类中确定的资产持仓。

未声明的货币。 默认情况下,没有对应 "Commodity" 指令的货币将被忽略。如需包含输入文件中出现的所有货币,请使用 "--undeclared" 选项。

覆盖。 默认情况下,已存在于文件中的相同价格指令将被排除,因为价格已存在。您可以使用 "--clobber" 忽略现有价格指令,避免过滤掉已获取的内容。

最后,您可以使用 “--all” 来包含未激活和未声明的商品,并允许覆盖现有商品。除非用于测试,否则您可能不希望使用此选项。

如果您想进行故障排查并打印出已看到的商品列表,请使用两次 “--verbose” 选项,即 “-vv”。您也可以仅使用 “--dry-run” 选项打印出待获取的价格列表,该选项不会实际获取缺失的价格。

结论

编写您自己的脚本

如果此工具定义的工作流程不符合您的需求,而您希望编写自己的脚本,则无需从头开始;您应该能够复用现有的价格获取代码来实现。我计划在 experiments 目录中提供一些此类脚本的示例。例如,给定一个现有文件,您可能希望在每个星期五获取所有资产的价格,以补全缺失的价格历史。另一个例子是获取正确计算投资回报所需的全部价格指令,尤其是在存在资金存入和分配的情况下。

贡献

如果此工作流程非常适合您的需求,并且您希望为 Beancount 贡献一个价格源获取器,请联系邮件列表。我愿意纳入经过仔细单元测试并长期使用过的非常通用的获取器。