Ledgerhub 设计文档
Martin Blais,2014 年 2 月
http://furius.ca/beancount/doc/ledgerhub-design-doc
请注意,本文档是 LedgerHub 的原始设计文档。LedgerHub 正在回归 Beancount。详情请参阅此事后分析文档 [blais, 2015-12]。
动机
目前存在多个开源项目,能够从文本文件输入生成复式记账交易。这些复式记账项目包括Beancount、Ledger、HLedger、Abandon,它们都是独立实现的相似目标:从文本文件中构建复式记账交易的内存表示,并生成各种报表,如资产负债表、损益表、日记账等。每个实现都探索了略有不同的功能集,但本质上都通过读取自定义声明性语言格式的输入文件来工作,这种语言专为人类编写而设计,其语法也以此为目标。尽管这些语言略有差异,但它们所定义的底层数据结构却相当相似。
定期更新日记文件的一个关键环节,是将现实世界账户的交易明细以一致的数据格式复制到单一输入文件中。这本质上是一个翻译步骤,旨在将多个机构账户的交易明细统一到一个系统中。各大银行和信用卡公司通常提供可下载的交易数据,格式为 Quicken 或 Microsoft Money(OFX),许多机构还提供自定义的 CSV 文件包含交易明细。此外,许多机构还会定期提供可下载的 PDF 形式对账单,这些对账单可与用户的账簿账户关联。
这些外部数据格式的转换过程在一定程度上可以自动化。这些不同的文件可以被转换为输出文本,然后由用户进行调整,以整合到复式记账软件所接受的输入文件格式中。目前已有多个项目在这一领域取得进展:Ledger-autosync 旨在自动从 OFX 服务器获取交易记录,并将其转换为适用于 Ledger 和 HLedger 的格式;Reckon 可将 CSV 文件转换为 Ledger 格式。Beancount 包含一段代码,能够自动识别下载文件对应的账户,提取交易明细,并将文件自动归档到与记账簿科目表结构一致的目录层级中。这段代码应独立于 Beancount 之外运行。Ledger 也提供了一个 "convert" 命令,尝试实现类似功能;此外,还存在一个名为 CSV2Ledger 的 Perl 脚本,可用于转换 CSV 文件。HLedger 曾经也提供了一个 convert 命令,可依据单独文件中定义的可选转换提示将 CSV 文件转换为标准格式;如今,HLedger 在输入文件为 CSV 时直接进行实时转换(即 CSV 已被视为第一类输入格式)。
用于获取和转换外部数据文件的程序不必绑定于单一系统。此外,这类代码通常较为繁琐,若能吸引大量贡献者共同参与,将极大受益于共享通用解析器,这些解析器可针对他们当前或未来可能使用的各类机构进行优化。作为 Beancount 的作者,我已决定将 Beancount 的导入与归档源代码从其主项目中分离出来,并与 Beancount 主体代码解耦,以便他人能够参与贡献,目标是提供与项目无关的通用功能。本文档描述了该项目的目标与设计。
目标与阶段
本新项目应以与项目无关的方式解决以下方面:
-
获取:通过直接连接数据源,自动化 获取 外部数据文件。可利用如 ofxclient 等外部工具和库处理 OFX 数据源;在可能的情况下,也可使用网页爬取技术获取可下载文件。本阶段的输出是一组下载至目录中的、特定机构的文件。
需注意,此处的“获取”不仅限于交易数据;我们还将支持获取 价格 数据。可由此生成一组(日期,价格)条目。我们可能需要支持一种中间格式,用于表达持仓列表(并提供相应的 ledgerhub-Ledger/Beancount/HLedger 接口支持以获取该数据)。 -
识别:根据文件名及其内容,自动 推测 该文件所属的机构及账户配置,并最好能从中提取出日期或对账单日期。该功能也应支持 PDF 文件。本阶段的输出是将每个输入文件与特定的提取器及配置(例如特定账户名称)建立关联。
-
提取:尽可能解析每个文件,并从中提取生成复式记账数据结构所需的各项信息,存入某种通用的数据结构中,例如由字符串和数字组成的字典,且与底层项目期望的输出格式无关。如果可能,应将生成该笔交易的原始文本片段原样附加到输出数据结构中。本阶段的输出是一个数据结构,例如符合特定格式的 Python 字典列表。
-
转换:利用来自过往交易历史(记录在账本中)的信息,通过简单的学习算法,程序应对上一步提取的交易信息进行转换。最常见的应用场景是为仅有一条分录的交易自动添加分类分录。例如,信用卡对账单通常仅包含信用卡账户余额的变化,而所有交易需手动关联至特定的支出账户。此阶段可部分自动化该过程。
-
渲染:将内部交易数据结构转换为特定复式记账项目实现的语法格式,以及所需的特定语法变体(例如货币格式、逗号与小数点作为小数分隔符、本地化日期输入格式)。此步骤输出可直接插入所选记账软件输入文件的文本内容。
-
归档:清理已下载文件的文件名,并将其移动至与识别出的账户相对应的结构化目录层次中。此过程可复用识别阶段得出的关联信息。
除渲染阶段外,其他所有阶段均应独立于特定项目实现,以便适用于所有记账软件。然而,渲染代码应进行专门化处理,导入源代码,并尽可能为每个项目的输出文本添加其特有的功能。
必要时,应提供接口以从各记账软件的输入文件中获取特定数据集,从而屏蔽通用代码对各项目具体实现细节的依赖。例如,分类转换步骤需要利用部分交易数据(如摘要字段、部分金额、账户名称和日期)来训练其算法。每个项目应提供一种方式,以统一格式从其输入文件中提取所需数据。
各阶段详情
获取
默认情况下,用户应能通过点击操作访问其金融机构的网站,并将文档下载至~/Downloads 目录。包含若干文件的目录应作为识别阶段的合理默认输入。该目录允许包含其他/垃圾文件,识别步骤应能自动跳过这些文件。
需实现一个可自动获取数据的模块。理想情况下,不应依赖外部工具。提取的数据也应保存至某个 Downloads 目录中。
此功能属于 ledger-autosync 项目的范畴。我们或许应在本阶段协调输入/输出接口,甚至集成调用其部分库代码。作者指出,从 OFX 服务器获取数据相当简单,但起止日期需进行处理和过滤。
自动获取支持会因机构所在位置而有很大差异。有些地方支持良好,有些则较弱。请使用ofxhome.com的数据进行配置。
获取价格
对于价格获取,市面上有许多库可供选择。最初我们将把 Beancount 的 bean-prices 移植到 ledgerhub 中。
识别
识别阶段包括运行一个驱动程序,该程序
-
在目录层次结构中搜索文件(通常是您的 ~/Downloads 文件夹)
-
如有必要,将文件转换为某种文本/ASCII 格式,以便能够匹配正则表达式(即使输出较为混乱,例如将 PDF 文件转换为 ASCII)。这对 PDF 文件效果良好:尽管我们通常无法从中提取交易数据,但通常可以相当可靠地识别其所属账户,并几乎总能提取对账日期。
-
将一组正则表达式与 ASCII 化后的内容进行匹配。如果所有正则表达式均匹配成功,则将该配置与文件名关联。
请注意,同一文件可能关联多个配置,因为某些文件包含多个部分,每个部分可能需要不同的导入器来提取其数据(例如,OFX 银行和 OFX 信用卡数据可能混合在同一个文件中,部分机构正是如此)。
此过程的最终结果是,将每个文件名与配置文件中实例化的特定导入器对象相关联。这些导入器对象通过一组必需的账户名称,将关联的下载文件转换为类似 Ledger 的语法。以下是两个导入器的示例配置:
from ledgerhub.sources.rbc import rbcinvesting, rbcpdf
CONFIG = [
...
(('FileType: application/vnd.ms-excel', r'Filename: .*Activity-123456789-', ),
rbcinvesting.Importer({
'FILE' : 'Assets:CA:RBC-Investing:Taxable',
'cash' : 'Assets:CA:RBC-Investing:Taxable:Cash',
'positions' : 'Assets:CA:RBC-Investing:Taxable',
'interest' : 'Income:CA:RBC-Investing:Taxable:Interest',
'dividend' : 'Income:CA:RBC-Investing:Taxable:Dividends',
'fees' : 'Expenses:Financial:Fees',
'commission' : 'Expenses:Financial:Commissions',
'transfer' : 'Assets:CA:RBC:Checking',
})),
(('FileType: application/pdf',
'Filename:.*/123456789-\d\d\d\d[A-Z][a-z][a-z]\d\d-\d\d\d\d[A-Z][a-z][a-z]\d\d.pdf'),
rbcpdf.Importer({
'FILE': 'Assets:CA:RBC-Investing:RRSP',
})),
该配置由一个列表组成,每个可能的导入器对应一个二元组:1) 一组正则表达式,这些表达式必须全部匹配文件的“匹配文本”(即文件内容的文本化版本);2) 一个已配置特定账户集合的导入器对象,用于生成交易。每个导入器都需要一组特定的输出账户,以创建其交易和分录。驱动程序仅需文件名以及这些(正则表达式,导入器)对的列表,即可完成全部工作。
文本化过程是将下载的二进制格式文件简单且不完美地转换为可应用正则表达式匹配的文本形式。对于 OFX 文件或 CSV 文件,无需进行文本化转换,可直接对文件内容进行匹配;对于 Excel/XLS 文件,则需将其转换为 CSV 格式,以便后续搜索;对于 PDF 文件,则会尝试多种不同的 PDF 转文本工具,直到其中一个成功(这些工具 notoriously 不可靠,因此必须尝试多种方案)。请注意,这种转换后的“匹配文本”仅临时创建,仅用于识别目的;导入器仍将使用原始二进制文件执行其工作。
目前尚不明确是否能将正则表达式标准化,以避免让用户手动配置。实际上,我发现通常有必要,或至少非常方便,在导入配置中加入账户 ID。诚然,为每种可能的下载文件配置规则可能很繁琐,需要用户查看每个文件的内容进行一些猜测,但与试图标准化这一过程相比,这种方法在实践中更为可靠,很可能是因为区分特定用户的所有文件比区分所有文件类型要容易得多。在正则表达式中使用账户 ID 是实现这一目标的简便方法,且效果良好。这还提供了一个清晰的位置,用于将账户列表与特定导入器关联,而这一点无论如何都需要用户输入。
提取
一旦建立关联,我们将对每个文件运行导入器。会生成某种数据结构。每个导入器执行其特定功能——这正是那些复杂技巧发挥作用的地方。理想情况下,我们应构建一个通用工具库,以帮助解析相似类型的文件。
尽管每个导入器模块应基本独立,但仍可设想一些通用功能,例如,如何处理单一投资账户中持有的多种股票,这些配置可独立于每个导入器进行(例如,首选方法是为该账户创建一个子账户,以股票代码命名,或其他方式)。
备注 [AMaffei]:如果希望将各个步骤作为独立的类 UNIX 工具运行,或使用常规文本处理工具处理中间文件,此过程可输出一种通用且定义明确的 CSV 文件格式。
转换
某些转换应独立于导入器。特别是,自动分类不完整的交易并不依赖于哪个导入器生成了该交易。我希望尽可能保持此步骤的通用性,以便未来可在此处插入其他增强步骤。目前,我仅能想到以下用途:
-
自动分类仅含单笔收支的交易
-
检测重复交易:导入的文件常包含已在账本中的交易;这些交易应被忽略或标记为重复。实际上,这并不像听起来那么简单,因为仅通过日期+描述的直接比较会失败:若同一交易来自两个输入文件,总有一方会被合并到另一方,有时日期甚至略有差异。因此需要一定程度的模糊匹配。
-
收款人姓名标准化:导入的收款人姓名通常被截断或包含一些无关词汇,例如“LLC”、城市名称和/或数字代码。可能需要自动清理这些内容。
此步骤涉及一个引导阶段,我们将从实际要导入交易的账本中提取一些数据。我们将实现一个通用接口,使每种账本语言的实现都能提供用于训练的相关数据。
此处的输出数据格式应与输入相同,以便我们可以选择性地跳过此阶段。
渲染
驱动程序应选择一个输出渲染器。在此阶段,我们将提取的数据结构转换为您所使用的特定账本实现格式。每个渲染器实现都应可以自由导入其特定实现的模块,但我们需谨慎限制这些导入依赖仅限于这些模块,以确保运行代码时仅需一种账本实现。
每个渲染器可在此处定义其渲染样式选项,因为每种语言都有其特殊性。
[AMaffei] 此外,应能提供一个通用渲染器,使用 printf 风格的格式字符串输出任意所需格式。
归档
导入器应能查看文件的文本化内容,并提取文件/对账单日期。这很有用,因为我们可以通过在文件名前添加对账单日期来重命名文件,而我们下载对账单或交易文件的日期通常与文件生成日期不同。如果无法从文件中提取日期,则回退到文件的最后修改时间。
应提供一个目标目录,我们将每个文件移动到与其关联的账户目录中。例如,一个这样的文件:
~/Downloads/ofx32755.qbo
应被移动到目录
.../Assets/US/RBC/Checking/2013-11-27.ofx32755.qbo
如果通过识别步骤将其与 Assets:US:RBC:Checking 账户的导入器关联。为此,所有导入器都应关联一个必需的“归档”账户。
据我所知,目前只有 Beancount 实现了此功能,但我相信这种组织和保存导入文件的便捷机制对其他人也会很有用。给定一组目录,Beancount 会自动找到这些文件,并利用文件名中的日期,在日记网页中生成指向这些文件的链接,并在用户点击链接时显示其内容。即使没有此功能,它也可用于维护文档缓存(我将我的文档保存在版本库中,并同步到外部驱动器进行备份)。
实现细节
关于初始实现的说明:
-
本项目的实现将使用 Python3。为什么选择 Python?
-
导入和提取的性能基本无关紧要,动态语言非常适合此类任务。
-
在动态语言中解析效果极佳,有大量可用的库。
-
Python3 现已广泛分布,目前所有所需的解析库都普遍支持它。
-
-
所有模块都应进行测试,包括使用示例输入进行测试。如果您要添加新模块,则需要为其提供一个匿名化的示例文件。我们必须建立一个自动化测试套件,因为以往的经验表明,这类代码对新输入和意外输入非常脆弱和不稳定。编写容易,但也很容易出错。
- 为了测试无法匿名化的二进制文件,我们将提供一种从匹配文本而非原始二进制 PDF 语句进行测试的功能。这些文件通常无法提取内容,仅用于标识和归档(例如,PDF 对账单,我们几乎无法从中提取任何有意义的数据,除了可能的对账日期)。
-
应提供一种快速方式,无需任何配置,即可用特定下载文件测试特定导入器,即使输出的账户名称略有异常也没关系。
-
必须为导入器的操作提供清晰易读的追踪信息,包括调试/详细模式选项。
-
我们提供一个单一函数作为您自定义导入脚本的入口。您的配置即脚本,脚本即配置。您只需在末尾调用该函数。我们还将提供一个脚本,用于通过文件名获取其属性,以满足希望采用更传统调用方式的用户。
-
我们应保持类型简单,但日期使用标准的 datetime 类型,数字使用 decimal.Decimal,货币/商品使用字符串。
这显然基于我在 Beancount 中当前的导入器代码。我非常欢迎关于本项目的新想法和建议,合作将备受欢迎。支持的导入器越多越好。
导入器接口
每个导入器都应实现为继承自以下类的类:
class ImporterBase:
"所有源导入器的基类/接口。"
# 一个将必需配置变量映射到其文档字符串的字典。
# 此处声明导入器所需提供的选项列表及其含义。
REQUIRED_CONFIG = {}
def __init__(self, config):
"""创建一个导入器。
大多数具体实现可直接使用此方法,无需重写。
参数:
config:一个配置账户字典,必须与
REQUIRED_CONFIG 的值匹配。
"""
# 配置值的字典,可公开访问。
assert isinstance(config, dict)
self.config = config
# 检查配置是否仅包含必需的配置值。
if not verify_config(self, config, self.REQUIRED_CONFIG):
raise ValueError("无效配置 {},需要 {}".format(
config, self.REQUIRED_CONFIG))
def get_filing_account(self):
"""返回用于移动输入文件的账户。
返回:
对应此导入器的账户名称。
"""
return self.config['FILE']
def import_file(self, filename):
"""尝试导入一个文件。
参数:
filename:要导入的文件名。
返回:
从文件中提取的新导入条目列表。
"""
raise NotImplementedError
def import_date(self, filename, text_contents):
"""尝试获取与给定文件对应的日期。
参数:
filename:要从中提取日期的文件名
text_contents:文件内容的 ASCII 文本版本,
无论其原始格式为何。
返回:
如果成功,返回一个日期对象;否则返回 None。
"""
raise NotImplementedError
对于每个导入器,应提供详细说明,指导用户如何在机构网站上找到并下载原始输入文件,以便在添加此导入器时能够正确找到下载链接(部分机构提供多种下载格式)。此外,还需提供输入文件支持的一行描述,以便我们在运行时显示支持的文件类型列表。
参考资料
其他旨在将账户数据导入 Ledger 的项目列于此处。
-
Reckon
-
OFXmate(ledger-autosync 的图形界面)
-
csv2ledger(似乎缺乏活跃的维护者)
更新(2015 年 11 月):本设计文档已实现,项目正移回 Beancount。详情请见此处。