这是 Beancount 中的库和工具的用户手册,可以帮助您将外部交易数据自动导入到您的 Beancount 输入文件中,并管理您从金融机构网站下载的文件。
人们经常想知道我们是怎么做的,所以让我在这里直截了当地详细描述一下我们要做的事情。
目前的任务的本质是将一个人的整个账户集中发生的交易转录到一个单一的文本文件:Beancount 输入文件。将整个交易集录入到一个系统中,是我们需要做的事情,以便生成关于一个人的财富和支出的综合报告。有人把这叫做 “对账”。
我们可以把纸质报表上的所有交易都用手抄录下来,只需把它们打进去就可以了。但是现在大多数金融机构都有一个网站,你可以通过下载到一些数据格式的历史交易报表来解析输出 Beancount 语法。
从这些单据中导入交易,需要:
- 手动审查交易的正确性甚至是欺诈行为。
- 将新的交易与之前从其他账户导入的交易进行合并。例如,从银行账户支付信用卡的款项,通常会从银行和信用卡账户导入。您必须手动将相应的交易合并到一起。
- 为支出交易分配正确的类别
- 通过将产生的指令移动到文件中的正确位置来组织你的文件。
- 核查余额,要么直观地核实余额,要么插入一个余额指令,说明新的交易后的最终账户余额应该是多少。
如果我的导入器正常工作,没有 bug 的话,这个过程需要 30-60 分钟来更新我的大部分活跃账户。不太活跃的账户每季度更新一次,或者在我觉得合适的时候更新。我倾向于在周六早上做这个,也许每个月两次,有时每周一次。如果你保持一个有条理的输入文件,有大量的断言,错误很容易被发现,这是一个愉快而简单的过程,而且在你完成后,生成一个更新的资产负债表是很有成就感的(我通常会重新导出到 Google Finance 的投资组合中)。
文件的下载并不是我自动完成的,Beancount 也没有提供任何工具来连接到网络并获取文件。外面的协议种类太多,根本无法对这个问题做出有意义的贡献。考虑到当今安全网站的性质和用于实现这些网站的 JavaScript 城堡,这将是一场噩梦。网络抓取很可能是一个值得、可行的解决方案。
我用我的用户名和密码手动登录到各个网站,然后点击右边的按钮,生成我需要的下载文件。这些文件会被导入器自动识别,并通过本文中描述的工具自动提取事务并将文件归档到有条理的目录层次结构中。 虽然我没有脚本化提取,但我认为在某些网站上是可以这样做的。这些工作就留给你在你认为值得的地方去实现了。
下面是对所涉及的典型文件类型的描述;这描述了我的用例和我所做的事情。这应该能让你对所涉及的内容有一个定性的认识。
- 信用卡和银行提供了质量相当好的 OFX 或 CSV 文件格式的历史对账单下载,但我需要对这些交易的另一面进行手动分类,并将一些交易合并到一起。
- 投资账户为我提供了高质量的可处理的报表,提取购买交易是完全自动化的,但我需要手动编辑销售交易,以关联正确的成本基础。有些机构的专业产品(如 P2P 网贷)只提供 PDF 文件,而这些文件都是人工翻译的。
- 工资单存根和归属事件通常只提供 PDF 格式,我懒得尝试自动提取数据;我手动抄录这些数据,保持输入非常有规律,并按照报表上的顺序排列。这样做会更容易些。
- 现金交易。我必须手动输入这些数据。我只把非食品类的支出直接记为单笔交易,对于食品类的支出,可能每半年我都会算一次钱包余额,然后插入一个汇总交易,每个月从食品现金账户中扣掉一部分,以使其保持平衡。如果你这样做,你最终需要手动输入的交易量会出奇地少,也许每周只需要输入几笔(这取决于生活方式的选择,这对我来说很管用)。当我出差的时候,我只是在手机上的 Google Keep 里记下这些,最后在积累后再抄录下来。
我已经在转换 PDF 文件中的数据方面取得了一些进展,这是一个普遍的需求,但还不完整;事实证明,在一般情况下,从 PDF 中完全自动提取表格并不容易。我有一些接近可以工作的代码,当时机成熟的时候会发布。另一方面,我找到的最好的 FOSS 解决方案是一个叫 TabulaPDF 的工具,但你仍然需要手动识别数据表在页面上的位置;你也许可以通过它的姐妹项目 tabula-java 自动提取一些。
尽管如此,我的导入器通常能成功地将 PDF 转换为难看的文本,以识别出它们是为哪个机构服务的,并提取出文件的发布日期。
最后,有许多不同的工具能从 PDF 文档中提取文本,如 PDFMiner、LibreOffice、xpdf 库、poppler 库等等..........但没有一个能在所有输入文档上一致地工作;你很可能最终会安装许多工具,并依赖不同的工具来处理不同的输入文件。出于这个原因,我并不要求在 Beancount 内部依赖 PDF 转换工具。你应该测试哪些工具在你的特定文档上有效,然后从你的导入器实现中调用这些工具。
提供了三个 Beancount 工具来协调导入的三个阶段:
- bean-identify: 给出一个杂乱的下载文件列表(例如在 ~/Downloads 中),自动识别你配置的导入器里的哪些文件能够处理并打印出来。这是用于调试和确定你的配置是否为每个下载的文件正确地关联了一个合适的导入器。
- bean-extract: 如果可能的话,从每个文件中提取交易和日期。这将产生一些 Beancount 输入文本并转移到你的输入文件中。
- bean-file: 将下载的文件归档到一个目录层次结构中,镜像账目表,以便保存,例如在个人的 git repo 中。清理文件名,移动文件并在每个文件上预留适当的声明日期,这样 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 config 中加载你的输入文件。
每个导入器必须遵守特定的协议,并至少实现其中的一些方法。这个协议的全部细节最好在源代码中找到:importer.py。上面的工具将负责查找下载并在你的 importer 对象上调用适当的方法。
以下是你需要或可能想要实现的方法的简要总结:
- name(): 这个方法为每个导入器实例提供了一个唯一的 id。可以方便的用一个唯一的名字来引用你的导入器,例如,它被标识过程打印出来。
- Identify(): 这个方法只是返回 true,如果这个导入器可以处理给定的文件,则返回 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 文件中的一些未删减的”&”字符,只需要对该银行进行自定义修复。我还目睹过一家折扣券商在 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)
如果你的 importer 覆盖了 extract()
和 file_date()
方法,这将生成四个单元测试,由 nosetests 自动运行:
- 一个在 sample1.csv 上调用 extract() 的测试,将提取的条目打印成一个字符串,并将这个字符串与 sample1.csv.extract.excel 中的内容进行比较
- 一个在 sample1.csv 上调用
file_date()
并将日期与sample1.csv.file_date
文件中的日期进行比较的测试。 - 一个类似于(1)的测试,但是在 samplex2.csv 上的测试。
- 一个类似于(2)的测试,但是在 samplex2.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
你应该检查预期输出文件的内容,以确定它们代表了下载文件的内容。
如果你在这些文件存在的情况下再次运行测试,预期输出文件将被用作测试的输入。如果以后的内容不同,测试将失败,并且会产生一个错误。(如果你想的话,你可以通过手动编辑并在其中一个文件中插入一些意想不到的数据来测试这个问题)。
当你编辑你的源代码时,你可以随时重新运行测试,以确保它在那些旧文件上仍然有效。当一个新下载的文件失败时,你会重复上面的过程。你在那个目录下复制一个文件,修复导入器,运行它,检查预期的文件。就这样。
有时我对导入器进行了改进,导致即使是旧的文件也会产生更多或更好的输出,这样一来,所有的旧测试都会失败。处理这种情况的好方法是将所有这些文件都放在源码控制下,在本地删除所有预期的文件,然后运行测试再生新的文件,然后对照最近的提交进行 diff,检查更改是否符合预期。
有些二进制文件的数据转换可能会很费时间,而且速度很慢。这通常是将 PDF 文件转换为文本的情况。
这是特别痛苦的,因为在提取我们下载的数据的过程中,我们通常要多次运行工具–如果一切正常工作,至少要运行两次:一次提取,两次转换为文件–如果有问题,通常要运行很多次。出于这个原因,我们希望这些转换进行缓存,这样就可以避免痛苦的 40 秒 PDF 到文本的转换,比如说,不需要运行两次。
Beancount 旨在为下载文件的转换提供两级缓存:
- 转换的内存内缓存,以便多个请求相同转换的导入商只运行一次
- 一个磁盘上的转换缓存,这样可以重复使用多个工具的调用。
内存内缓存的工作原理是这样的。你的方法接收一个给定文件的 wrapper 对象,并调用 wrapper 的 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 或 Mercurial 仓库:
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/”)和你的历史文档(”document/”),这些文件应该按 bean-file 组织好。你总是在这个根目录下运行命令。
将你的文档和你的 importers 源代码存储在同一个仓库中的一个好处是,你可以将你的回归测试链接到 documents/目录下的一些文件。
你可以通过运行 identif
来检查你的配置。
bean-identify example.import ~/Downloads
如果成功,你可以一次性从下载的文件中提取交易事务。
bean-extract -e example.beancount example.import ~/Downloads > tmp.beancount
然后打开 tmp.beancount 并将其内容移动到个人的个人 .beancount 文件中。
完成后,你可以像这样把下载的文件藏起来,以备后人使用。
bean-file example.import ~/Downloads -o documents
如果我的导入器正常工作,我通常都不会去打开那些文件。你可以使用 –dry-run 选项来测试移动目的地,然后再进行测试。
pytest -v importers
要运行自定义导入器的回归测试,请使用以下命令。
就我个人而言,我在根目录下有一个 Makefile,里面有这些目标,方便我的生活。注意,你必须安装 “pytest”,这是一个测试运行程序;它通常被打包成 “python3-pytest “或 “pytest”。
除了上面的文档,我还编了一个导入器例子,用于虚构投资账户的 CSV 文件格式。请看这个目录。
还有一个导入器的例子,它使用一个外部工具(PDFMiner2)将一个 PDF 文件转换为文本来识别它,并从中提取报表日期。请看这个目录。
Beancount 还自带了一些非常基本的通用导入器。请看这个目录。
- 有一个简单的 OFX 导入器对我来说已经工作了很长时间。虽然它很简单,但我已经用了好几年了,它足以从大多数信用卡账户中提取信息。
- 还有一些混合类,你可以混入到你的导入器实现中,使其更方便;这些都是 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 目前没有提供自动分类交易的机制。你可以在你的导入器代码中构建这个机制。我想为用户提供一个钩子,让用户注册一个完成函数,这个函数可以在所有的导入器中运行,你可以将代码钩入其中。
下载中能找到的付费者,一般都是难看的名字:
- 它们有时是商家的法定名称,由于各种原因,往往不能反映出你去的地方的街道名称。比如,我最近在纽约一家叫 “幸运蜜蜂 “的餐厅吃饭,从 ofx 文件中看到的备忘录是 “KING BEE”。
- 这些名字有时候是缩写,或者含有一些粗体字。在前面的例子中,实际的备忘录是 “KING BEE NEW YO”,其中 “NEW YO “是一个被截断的位置字符串。
- 不同数据源之间的丑化量是不一致的。
如果能够在导入时通过翻译收款人名来规范化就好了。我认为你可以使用一些简单的规则将正则表达式映射到用户提供的名称,就可以做到大部分。真的没有什么好的自动化方法来获取收款人的 “干净的名字”。
Beancount 还没有提供一个让你这样做的钩子。最终会有的。你也可以建立一个插件,在加载分类账时重命名这些账户。我也会建立这个插件–这很简单,而且输出效果会更好。
除了强化已有的东西之外,我还想增加一些东西:
- 一个通用的、可配置的 CSV 导入器,你可以实例化。我计划用这个来玩一下,然后建立一个嗅探器,可以自动找出每个列的作用。
- 一个钩子,允许你注册一个回调,用于后处理交易的回调,可以在所有的导入器中工作。
曾经有一个第一次实现本文描述的过程。这个项目叫 LedgerHub,在 2016 年 2 月已经退役了,重写后的代码被集成到这个beancount.ingest库中,并将其集成到 Beancount 本身的代码中。
最初的项目是想包括各种导入器的实现,以便与其他人分享,但这种分享并不是很成功,所以重写后只包括了构建自己的导入器和调用这些导入器的脚手架,并且只包含了数量非常有限的导入器实现示例。
关于 LedgerHub 的文档被保留了下来,可以帮助你了解 Beancount 的导入器支持的起源和设计选择。您可以在这里找到它们:
- 原创设计
- 原始说明及最终状态(本文件旧版)。
- 分析项目被终止的原因 (项目总结)