在 Beancount 中导入外部数据

Martin Blais,2016 年 3 月

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

本文档针对 Beancount v2;Beancount v3 正在开发中,其构建和安装系统完全不同。有关 v3 的导入说明,请参阅此文档(Beangulp)。

引言

导入过程

自动化网络下载

典型下载

从 PDF 文件中提取数据

工具

调用方式

配置

从输入文件配置

编写导入器

对导入器进行回归测试

生成测试输入

逐步改进

运行测试

缓存数据

内存缓存

磁盘缓存

组织你的文件

示例导入器

清理工作

自动分类

清理交易对手

未来工作

相关讨论帖

历史备注

引言

本手册介绍 Beancount 中用于帮助您自动化导入外部交易数据到 Beancount 输入文件,并管理从金融机构网站下载的文档的库和工具。

导入过程

人们常常好奇我们是如何做到的,因此让我坦诚而详细地描述一下我们在这里所做的事情。

这项任务的核心是将一个人所有账户中发生的交易记录转录到一个单一的文本文件中:Beancount 输入文件。将所有交易集中到一个系统中,是我们生成全面的财富和支出报告所必需的。有些人称之为“对账”。

我们可以手动从纸质对账单中逐条输入所有交易记录。然而,如今大多数金融机构都提供网站,允许你以多种数据格式下载历史交易记录,并可解析这些格式以输出 Beancount 语法。

从这些文档导入交易涉及以下步骤:

  • 手动审核交易的正确性,甚至检查是否存在欺诈行为;

  • 合并新交易与从其他账户导入的先前交易。例如,从银行账户支付信用卡账单的交易,通常会分别从银行账户和信用卡账户导入。你必须手动将对应的交易合并在一起1

  • 为支出交易分配正确的类别

  • 整理你的文件,将生成的指令移动到文件中的正确位置。

  • 核对余额,可通过视觉检查或插入 Balance 指令来断言新交易插入后账户的最终余额应为何值。

如果我的导入器没有缺陷,更新我大部分活跃账户通常只需 30 至 60 分钟。不活跃的账户则每季度更新一次,或在我有空时更新。我通常在周六上午进行,大约每月两次,有时每周一次。如果你维护了一个结构良好、包含大量断言的输入文件,不匹配很容易被发现,整个过程轻松愉快,完成后生成更新的资产负债表令人满足(我通常会重新导出到 Google Finance 投资组合)。

自动化网络下载

我并未自动化文件下载过程,Beancount 也未提供连接网络并获取文件的工具。由于网络协议种类繁多,难以对此问题做出有意义的贡献2。考虑到当今安全网站的特性以及其背后复杂的 JavaScript 实现,实现这一功能将是一场噩梦。网络爬虫可能过于复杂,难以成为可行的解决方案。

手动登录各个网站,使用我的用户名和密码,点击正确的按钮生成所需的下载文件。这些文件会被导入器自动识别,而提取交易并将文档归档到结构良好的目录层次中,则通过本文所述的工具实现自动化。

虽然我没有脚本化获取过程,但我认为在某些网站上是可以实现的。这项工作留给你自行实现,仅当你认为值得投入时间时再进行。

然而,如今得益于开放银行项目,我们拥有了统一的 API,能够快速可靠地从多个银行账户下载交易记录。

手动下载

典型下载

以下是涉及的典型文件类型的说明;这描述了我的使用场景以及我已实现的操作,可以帮助你大致了解其中涉及的内容。

  • 信用卡和银行通常能提供质量较好的 OFX 或 CSV 格式的历史对账单下载,但我需要手动对这些交易的另一方进行分类,并合并部分交易。

  • 投资账户能提供高质量的可处理对账单,购买交易的提取已完全自动化,但我需要手动编辑销售交易,以关联正确的成本基础。一些提供特殊产品(如 P2P 借贷)的机构仅提供 PDF 文件,这些文件需手动转换。

  • 工资单和股权归属事件通常仅以 PDF 形式提供,我不会尝试自动提取数据;而是手动抄录,保持输入格式高度规范,且条目顺序与对账单上一致,这使处理更简便。

  • 现金交易:我必须手动录入。我仅将非食品类支出作为独立交易直接记账;至于食品支出,我大约每六个月会统计一次钱包余额,并为每个月插入一笔汇总交易,将现金账户余额转入食品支出以保持平衡。采用这种方式后,手动输入的交易量会意外地很少,可能每周只需录入几笔(具体取决于生活方式,对我而言这种方式很有效)。当我外出时,我会先在手机上的 Google Keep 中记录这些交易,待积累到一定数量后再统一抄录。

从 PDF 文件中提取数据

我在将 PDF 文件中的数据转换为可用格式方面已取得一定进展,这是一项常见需求,但尚未完成;事实证明,在一般情况下完全自动化地从 PDF 中提取表格并不容易。我有一些接近可用的代码,将在适当时机发布。除此之外,我找到的最佳开源解决方案是名为TabulaPDF的工具,但你仍需手动识别页面上数据表格的位置;你或许可通过其姊妹项目tabula-java实现部分数据抓取的自动化。

尽管如此,我通常能成功通过导入器在转换为纯文本的 PDF 对账单中进行文本搜索,以识别其所属机构并提取文档签发日期。

此外,还有多种用于从 PDF 文档中提取文本的工具,例如PDFMinerLibreOfficexpdf库、poppler3等……但它们都无法在所有输入文档上稳定工作;你很可能需要安装多种工具,并根据不同输入文件选择不同的工具。因此,我并未在 Beancount 中强制依赖任何 PDF 转换工具。你应该测试哪些工具适用于你的具体文档,并在导入器实现中调用这些工具。

通过开放银行聚合器自动下载

开源会计软件 Firefly III 已经集成了部分免费的开放银行 API。有关更多信息,请访问 Firefly III 文档。一个可能感兴趣的开放银行聚合器示例是 GoCardless。GoCardless 支持欧盟和英国众多符合 PSD2 标准的银行,并且 免费使用

在 Beancount 生态系统中,tarioch/beancounttools 项目包含一个用于 Nordigen(即 GoCardless 银行 API 的前身)的导入器。该工具可与 bean-extract 配合使用。

  1. 只需创建并配置一个 GoCardless 账户

  2. 配置 tarioch/beancounttools 的 Nordigen 导入器:在 my-smart.import 中添加 nordigen 导入器,并将 nordigen.yaml 放入 Downloads/ 目录中

  3. 运行:`bean-extract -e database.beancount my-smart.import Download/ > new.beancount`。

这将自动为您导入数据。

工具

提供了三个 Beancount 工具来协调导入的三个阶段:

  1. bean-identify:给定一个杂乱的已下载文件列表(例如在 ~/Downloads 中),自动识别哪些已配置的导入器能够处理这些文件并输出结果。此工具用于调试,以确认您的配置是否正确地为每个下载文件关联了合适的导入器;

  2. bean-extract:尽可能从每个文件中提取交易记录和对账日期。这将生成一些 Beancount 输入文本,供您移入您的输入文件中;

  3. bean-file:将已下载的文件归档到与会计科目表结构相对应的目录层次中,以便存档,例如保存在个人 git 仓库中。文件名将被清理,文件会被移动,并在每个文件前添加适当的对账日期,以便 Beancount 可生成相应的 Document 指令。

调用

所有工具都接受相同的输入参数:

bean-<tool> <config> <downloads-dir>

例如,

bean-extract blais.config ~/Downloads

归档工具接受一个额外选项,允许用户指定文件移动的目标位置,例如:

bean-file -o ~/accounting/documents blais.config ~/Downloads

其默认行为是将文件移动到配置文件所在的同一目录。

配置

前面介绍的工具负责协调整个流程,但它们本身并不直接解析各个下载文件的具体内容。它们会调用导入器对象的方法。您必须提供一个此类导入器的列表;该列表即为导入过程的配置(没有它,这些工具将无法执行任何有用的操作)。

对于每个找到的文件,每个导入器都会被调用以判断其是否能够处理该文件。如果导入器认为可以处理,则可调用相应方法生成交易列表、提取日期,或生成清理后的下载文件名。

配置应为一个 Python3 模块,您需在其中实例化导入器,并将列表赋值给模块级别的 "CONFIG" 变量,如下所示:

#!/usr/bin/env python3
from myimporters.bank import acmebank
from myimporters.bank import chase
…

CONFIG = [
  acmebank.Importer(),
  chase.Importer(),
  …
]

当然,由于您正在编写 Python 脚本,可以自由插入任何其他代码。唯一的要求是,这个 "CONFIG" 变量必须指向一个符合导入器协议(下一节将描述)的对象列表。它们的顺序无关紧要。

特别是,最好以尽可能通用的方式编写导入器,并使用输入文件中使用的特定账户名称对其进行参数化。这有助于使代码与具体账户解耦,并迫使你定义逻辑账户,我发现这有助于提高清晰度。

或者也可以不这样做……最终,这些导入器代码存在于你自己的私人空间中,而不是 Beancount 的一部分。如果你愿意,完全可以将它们保持得杂乱无章且无法共享。

从输入文件中配置

一个我尚未测试过的有趣想法是:使用你的 Beancount 输入文件来推断导入器的配置。如果你想尝试并编写一些代码,可以通过使用 API 的 beancount.loader.load_file() 函数,从导入配置的 Python 配置中加载你的输入文件。

编写一个导入器

每个导入器都必须遵循特定的协议,并至少实现其中的部分方法。该协议的完整细节最好在源代码本身中查看:importer.py。上述工具会负责查找下载的文件,并调用你的导入器对象的相应方法。

以下是需要或可能希望实现的方法的简要摘要:

  • name():此方法为每个导入器实例提供一个唯一标识符。能够用唯一名称引用你的导入器非常方便;例如,识别过程会打印出该名称。

  • identify():此方法仅在该导入器能够处理给定文件时返回 True。你必须实现此方法,所有工具都会调用它来确定(文件,导入器)对的列表。

  • extract():此方法用于尝试从文件内容中提取一些 Beancount 指令。它必须通过实例化 beancount.core.data 中定义的对象来创建指令,并返回它们。

  • file_account():此方法返回与此导入器关联的根账户。下载的文件将由归档脚本移动到此账户下。

  • file_date():如果可以从对账单内容中提取日期,请在此处返回。这对于带日期的 PDF 对账单很有用……通常可以通过正则表达式从转换为文本的 PDF 中提取日期。这允许归档脚本使用相关日期,而不是默认的文件下载日期。

  • file_name():通常无需重命名下载的文件。银行生成的文件通常都有唯一名称,当你多次下载时,浏览器会因名称冲突而自动重命名它们。此函数用于让导入器提供一个“友好”的文件名,以便归档时使用。

因此,基本上你需要在 PYTHONPATH 的某个位置(任意你喜欢的地方,最好是私有位置)创建一个模块,并实现一个类似如下所示的类:

from beancount.ingest import importer

class Importer(importer.ImporterProtocol):

    def identify(self, file):
        …

    # Override other methods…

通常我会将导入器模块文件放在为每个导入器专门创建的目录中,以便将示例输入文件都放在该目录下用于回归测试。

对导入器进行回归测试

随着时间的推移,我发现回归测试对于保持导入器代码正常运行至关重要。导入器通常针对没有官方规范的文件格式编写,且经常出现意外情况。例如,我曾遇到一些 XML 文件中含有未转义的"&"字符,这需要为该银行专门编写一个修复程序4。我还见过一家折扣经纪公司将其日期格式在 MM/DD/YY 和 DD/MM/YY 之间切换;因此,现在的导入器必须能够处理这两种格式。于是你做了必要的调整,但最终却发现其他地方又出问题了——这并不理想。而且出问题的时间特别烦人:通常是在你试图更新账本时才发生,而这时你还有其他事情要处理。

测试这些导入器最简单、最懒惰也最有效的方法是使用一些真实的数据文件,并将导入器从中提取的内容与预期输出进行比较。为了使导入器至少具备一定的可靠性,你必须能够对多个真实输入重复执行提取操作。由于这些输入不可预测且定义模糊,因此不可能为所有可能的情况编写详尽的测试。实际上,我每几个月就必须对某些导入器进行至少一次修复,而通过这一流程,每次仅需耗费约半小时:我将导致失败的新下载文件添加到导入器目录中,然后在本地运行它作为测试来修复代码。同时,我还会对目录中所有先前下载的测试输入(包括旧文件和新文件)运行测试,以确保导入器在旧文件上仍能按预期工作。

beancount.ingest.regression 提供了一些自动化这一过程的支持。我们希望有一个例程,能够列出导入器的包目录,识别用于测试的输入文件,并生成一组单元测试,将导入器方法的输出与紧邻测试文件放置的“预期文件”内容进行比较。

例如,给定一个包含导入器实现和两个示例输入文件的包:

/home/joe/importers/acmebank/__init__.py   <- code goes here
/home/joe/importers/acmebank/sample1.csv
/home/joe/importers/acmebank/sample2.csv

你可以将以下代码放入 Python 模块(__init__.py 文件)中:

from beancount.ingest import regression
…
def test():
    importer = Importer(...)
    yield from regression.compare_sample_files(importer)

如果您的导入器重写了extract()file_date()方法,这将自动生成四个由pytest自动运行的单元测试:

  1. 一个调用sample1.csvextract()方法、将提取的条目打印为字符串,并将该字符串与sample1.csv.extract文件内容进行比较的测试。

  2. 一个调用sample1.csvfile_date()方法,并将返回的日期与sample1.csv.file_date文件中记录的日期进行比较的测试。

  3. 一个与(1)类似但针对sample2.csv的测试。

  4. 一个与(2)类似但针对sample2.csv的测试。

生成测试输入

最初,包含预期输出的文件并不存在。当缺少预期输出文件时,回归测试会自动根据提取结果生成这些文件。这将产生以下文件列表:

/home/joe/importers/acmebank/__init__.py   <- code goes here
/home/joe/importers/acmebank/sample1.csv
/home/joe/importers/acmebank/sample1.csv.extract
/home/joe/importers/acmebank/sample1.csv.file_date
/home/joe/importers/acmebank/sample2.csv
/home/joe/importers/acmebank/sample2.csv.extract
/home/joe/importers/acmebank/sample2.csv.file_date

您应检查预期输出文件的内容,以视觉方式确认它们准确反映了下载文件的内容。

如果再次运行测试时这些文件已存在,测试将使用这些预期输出文件作为输入。如果将来内容发生变化,测试将失败并生成错误。(如果你愿意,现在就可以手动编辑其中一个文件并插入一些意外数据来测试这一点。)

当你修改源代码时,可以随时重新运行测试,以确保它仍能正确处理这些旧文件。当新下载的文件失败时,重复上述过程:将该文件复制到该目录中,修复导入器,运行它,并检查预期文件。就这样5

逐步改进

有时我会改进导入器,使其即使在旧文件上也能生成更多或更好的输出,导致所有旧测试都失败。处理这种情况的一个好方法是将所有这些文件纳入源代码控制,本地删除所有预期文件,运行测试以重新生成新的文件,然后与最近一次提交进行差异对比,确认更改符合预期。

缓存数据

某些二进制文件的数据转换可能代价高昂且缓慢。例如,将 PDF 文件转换为文本通常就是如此6。这尤其令人困扰,因为在处理下载数据的过程中,我们通常需要多次运行这些工具——如果一切顺利,至少运行两次:一次提取,一次归档;如果出现问题,次数会更多。因此,我们希望缓存这些转换,以避免例如耗时 40 秒的 PDF 转文本操作被重复执行。

Beancount 旨在为下载文件的转换提供两个级别的缓存:

  1. 内存缓存转换结果,以便多个导入器请求相同转换时仅执行一次;

  2. 磁盘缓存转换结果,以便多次调用工具时可重复使用。

内存缓存

内存缓存的工作方式如下:你的方法接收一个代表特定文件的包装对象,并调用该包装对象的 convert() 方法,传入一个转换器可调用函数。

class MyImporter(ImporterProtocol):
    ...
    def extract(self, file):
        text = file.convert(slow_convert_pdf_to_text)
        match = re.search(..., text)

该转换会自动被缓存:如果两个导入器或两个不同方法对同一文件使用相同的转换器,转换仅执行一次。这是一种简单处理内存中冗余转换的方式。请务必始终通过 .convert() 方法调用,并共享转换器函数以利用此功能。

磁盘缓存

目前,Beancount 仅实现了(1)。磁盘缓存将在后续版本中实现。请跟踪此 工单 以获取状态更新。

组织你的文件

本文档中描述的工具在指定以下内容时非常灵活:

  • 导入配置:提供导入器对象列表的 Python 配置文件;

  • 导入器实现:实现各个导入器及其回归测试文件的 Python 模块;

  • 下载目录:存放下载文件的目录;

  • 归档目录:下载的文件将被存入的目录。

你可以从任意位置指定这些路径。尽管如此,许多人常询问如何组织文件,因此我在 beancount/examples/ingest/office 下提供了一个模板示例,并在此进行说明。

我建议你按照以下结构创建一个 Git 或 Mercurial7 源代码控制仓库:

office
├── documents
│   ├── Assets
│   ├── Liabilities
│   ├── Income
│   └── Expenses
├── importers
│   ├── __init__.py
│   └── … 
│       ├── __init__.py
│       ├── sample-download-1.csv
│       ├── sample-download-1.extract
│       ├── sample-download-1.file_date
│       └── sample-download-1.file_name
├── personal.beancount
└── personal.import

根目录 "office" 就是你的仓库。它包含你的账本文件("personal.beancount")、导入配置("personal.import")、自定义导入器源代码("importers/")以及文档历史("documents/"),这些文档应按 bean 文件妥善组织。你始终从这个根目录运行命令。

将文档与导入器源代码存储在同一仓库中的一个优势是,你可以直接将回归测试链接到 documents/ 目录下的某些文件。

你可以通过运行 identify 来检查你的配置:

bean-identify example.import ~/Downloads

如果一切正常,你可以立即从下载的文件中提取交易记录:

bean-extract -e example.beancount example.import ~/Downloads > tmp.beancount

然后打开 tmp.beancount,将其内容移动到你的 personal.beancount 文件中。

完成后,你可以像这样将下载的文件归档保存:

bean-file example.import ~/Downloads -o documents

如果我的导入器正常工作,我通常甚至不会打开这些文件。你可以在执行前使用 --dry-run 选项测试目标路径。

要运行自定义导入器的回归测试,请使用以下命令:

pytest -v importers

我个人在根目录下有一个 Makefile,其中包含这些目标以简化我的工作。请注意,你需要安装 "pytest"(一个测试运行器),它通常打包为 "python3-pytest" 或 "pytest"。

示例导入器

除了上述文档外,我还为一个虚构的投资账户创建了一个虚构的 CSV 文件格式的示例导入器。请参见 此目录

还有一个使用外部工具(PDFMiner2)将 PDF 文件转换为文本以识别并提取对账日期的导入器示例。请参见 此目录

Beancount 还自带了一些非常基础的通用导入器。请参见 此目录

  • 有一个简单的 OFX 导入器,我长期使用且效果良好。虽然它非常简单,但我已使用多年,足以从大多数信用卡账户中提取信息。

  • 此外,还有一些混合类(mixin classes)可供你集成到导入器实现中,以使其更便捷;这些是来自 LedgerHub 项目的遗留物——你其实并不需要使用它们,但在迁移到该系统时可能有所帮助。

最终,我计划在这个框架中构建并提供一个通用的 CSV 文件解析器,以及一个 QIF 文件解析器,以便用户从 Quicken 迁移到 Beancount。(我需要示例输入来完成这项工作;如果你愿意分享你的文件,我可以利用它来构建,因为我没有实际的输入,也不使用 Quicken。)将来也希望能构建一个从 GnuCash 转换的工具,它也将放在这里。

清理

自动分类

首次使用用户经常问到的一个问题和常见想法是:"如何为仅有一方的已导入交易自动分配类别?" 例如,从信用卡账户导入交易通常只提供一条记录,如下所示:

2016-03-18 * "UNION MARKET"
  Liabilities:US:CreditCard    -12.99 USD

你需要手动添加一笔支出记录,如下所示:

2016-03-18 * "UNION MARKET"
  Liabilities:US:CreditCard    -12.99 USD
  Expenses:Food:Grocery

人们常常觉得这样做很耗时。

我的标准回答是:虽然拥有自动功能会很有趣,但如果你的文本编辑器已正确配置了账户名称补全功能,手动完成这项工作其实非常轻松,根本不需要自动化。通过自动化节省的时间其实微乎其微。而且我个人喜欢逐笔检查每条交易,确认其内容,有时还会添加备注(例如,和谁一起吃饭,亚马逊这笔收费是买什么的等),而正是在这些时候我进行分类。

这最终可以通过让用户提供一些简单规则,或利用过往交易历史训练一个简单的分类器来解决。

目前 Beancount 并未提供自动分类交易的机制。你可以在导入代码中自行实现这一功能。我计划提供一个钩子,让用户注册一个完成函数,该函数可在所有导入器中运行,从而让你挂载这段代码。

清理交易对手方名称

下载文件中出现的交易对手方名称通常都很丑陋:

  • 它们有时是企业的法定名称,但由于各种原因,往往无法反映你实际前往的店铺街道名称。例如,我最近在纽约一家名为 "Lucky Bee" 的餐厅用餐,OFX 文件中的备注却是 "KING BEE"。

  • 这些名称有时会被缩写或包含杂乱信息。在上例中,实际备注是 "KING BEE NEW YO",其中 "NEW YO" 是被截断的位置字符串。

  • 不同数据源的杂乱程度并不一致。

如果能在导入时通过规则将交易对手方名称标准化,那将非常理想。我认为你可以通过用户提供的正则表达式到名称的简单映射规则来完成大部分工作。目前并没有很好的自动化方法来获取交易对手方的"规范名称"。

Beancount 目前尚未提供允许你执行此操作的钩子,但将来会加入。你也可以编写一个插件,在加载账本时重命名这些账户。我也会实现这个功能——这很容易,而且能显著提升输出的整洁度。

未来工作

除了巩固现有功能外,我真正希望添加的一些功能:

  • 一个通用的、可配置的 CSV 导入器,你可以实例化它。我计划先尝试一下,并构建一个智能探测器,能够自动识别每一列的用途。

  • 一个钩子,允许你注册一个跨所有导入器的交易后处理回调函数。

历史备注

曾经有一个对本文所述过程的初始实现,该项目名为 LedgerHub,并已于 2016 年 2 月被停用,随后被重写并整合到 Beancount 本身中,成为这个 beancount.ingest 库。原始项目本意是包含多种导入器的实现以便与他人共享,但这种共享并未取得太大成功,因此重写版本仅保留了构建您自己的导入器并调用它们的框架,以及极少的示例导入器实现。

关于 LedgerHub 的文档已被保留,可以帮助您了解 Beancount 导入器支持的起源和设计选择。您可以在以下位置找到这些文档:


  1. 输入此类交易本质上存在三种概念模式:(1) 用户手动编写单笔交易;(2) 用户将两方作为一笔转账交易输入;(3) 两笔独立交易自动合并为一笔交易。这些模式彼此互为对立。这个故事的特殊之处在于,同一笔交易在各个账户中的记账日期往往不同。Beancount 目前(2016 年 3 月)不支持单笔交易的多个记账日期,但有关实现这些输入模式的讨论正在进行中。更多详情请参见 此文档

  2. 在自由软件领域,最接近通用下载器的是用于 OFX 文件的 ofxclient;在商业领域,Yodlee 提供了一项连接众多金融机构的服务。

  3. poppler 中的 'pdftotext' 工具提供了有用的 '-layout' 标志,可输出不破坏表格结构的文本文件,在典型的“每行一笔交易”场景中非常有用。

  4. 在向他们发送了几封详细邮件后,既未收到回复,也未看到下载文件有任何改进,我已放弃让他们修复此问题。

  5. 正如您所见,这一过程部分解释了为何我不共享我的导入器代码。要让它们持续运行,需要存储过多的个人数据。

  6. 我不太明白为什么,因为打开它们进行查看几乎是瞬时的,但几乎所有将其转换为其他格式的工具都慢得多。

  7. 我个人更喜欢 Mercurial,因为它的命令和输出更清晰,且可扩展性强;但 Git 存储模型的一个优势是,在其中移动文件无需额外成本(不会额外存储副本)。而在 Mercurial 仓库中移动文件会占用一些存储空间。如果您重命名账户或更改文件组织方式,最终可能复制大量大文件。