Beancount 设计文档

Martin Blais (blais@furius.ca)

http://furius.ca/beancount/doc/design-doc

一份为开发者提供的指南,帮助理解 Beancount 的内部机制。我希望本文件能提供源代码中主要对象的概览,以便于基于 Beancount 编写脚本和插件,甚至扩展其功能。

引言

不变性

输入的隔离

顺序无关性

所有交易必须平衡

账户具有类型

账户生命周期与 Open 指令

仅支持日期(不支持时间)

元数据用于用户数据

代码库概览

核心数据结构

金额

商品

金额

批次

头寸

库存

账户

标志

分录

关于元组与可变性

摘要

指令

公共属性

交易

标志

交易对手与说明

标签

链接

分录

平衡分录

金额的省略

流式处理

流式处理不变性

加载器与处理顺序

加载器输出

解析器实现

解析的两个阶段:不完整条目

打印器

唯一性与哈希

显示上下文

实现

Web 界面

报表与 Web

客户端 JavaScript

查询接口

设计原则

最小化可配置性

优先使用代码而非 DSL

文件格式还是输入语言?

通过解析器生成器定义语法

未来工作

带标签的字符串

错误清理

类型

结论

引言

本文描述了 Beancount 设计背后的原则,并对其代码库、数据结构、算法、实现和方法论进行了高层次概述。这不是用户手册;如果您仅希望使用 Beancount,请参阅相关的用户手册以及此处提供的其他文档

然而,如果您希望更深入地理解 Beancount 的工作原理,本文将非常有帮助。这对开发者也应具有参考价值。这里记录了许多我未曾 elsewhere 表达的关于 Beancount 的想法。您可能会遇到一些对用户并不重要的随机思考。

通常人们会在编写软件之前撰写设计文档,但我没有这样做。但我认为在此阐明促成此设计的若干理念仍是有价值的。希望有人能从中受益。

不变性

输入隔离

Beancount 应仅接受您提供给其工具的文本文件作为输入。特别是,它不应连接任何外部网络服务,也不应打开任何“全局”文件,即使只是历史价格缓存也不行。这种隔离是刻意为之的。将输入源限制为单一文件,有助于在出现问题时更轻松地调试、复现和隔离问题。这是该工具的一个优良特性。

此外,获取和转换外部数据非常混乱。外部数据格式往往质量低下,且种类繁多,无法全部处理(仅处理最常见的子集也会引发新转换器应置于何处的疑问)。最重要的是,表示数据的问题与获取和转换数据的问题天然分离:Beancount 提供了一个核心,允许您摄入所有交易数据并从中生成报告,其语法正是连接这些外部交易或价格存储库的枢纽。通过这种方式,Beancount 将自身与外部数据源的繁琐细节隔离开来。

顺序无关性

Beancount 保证其输入文件中指令的顺序不会影响计算结果。您可以根据自身便利自由组织和重新排列任何声明,而无需担心软件如何进行计算。这也使得实现多个文件的包含变得极其简单:您只需将文件拼接在一起即可,且可以按任意顺序处理它们。

甚至声明账户或商品的指令也不必在这些账户被使用之前出现。所有指令都会被解析,然后在进行任何计算或验证之前基本被排序(一个细微的细节是,行号被用作排序的次要键,以确保重新排序的稳定性)。

相应地,该语言中所有非指令语法仅限于两类:具有全局影响的设置(“option”、“plugin”)或与语法相关的便利功能(“pushtag”、“poptag”)。

有一个例外:

  • 选项在文件处理过程中可能产生影响,因此应出现在文件开头。目前,Beancount 并不强制执行这一规则,例如不会进行多次解析遍历,但未来应该这样做。

  • 此外,包含文件中的选项目前会被忽略。您应将所有选项放在顶层账本文件中。

所有交易必须平衡

所有交易必须按“权重”平衡。没有任何例外。Beancount 的交易必须保持平衡,这是硬性要求。

特别是,不允许像 Ledger 中的“虚拟分录”这样的例外情况。Beancount 的首个实现曾允许此类分录。结果,我经常用它们来“解决”那些我无法很好建模的问题。当我重写 Beancount 时,我决心将整个输入文件转换为不使用这些虚拟分录,最终我成功做到了,并在此过程中学到了许多新知识。此后,我深信虚拟分录完全不必要,而提供它们只会成为许多用户依赖的拐杖,使他们不愿学习如何用平衡交易来建模资金转移。我至今尚未遇到一个足够令人信服的使用案例1

这确保了任何交易子集的总和都为零。这是一个非常有益的特性,意味着我们可以在任何时间点生成资产负债表。即使我们最终支持结算日期或每条分录的日期,我们也会以不破坏这一不变性的方式拆分交易。

账户具有类型

Beancount 的账户应被限制为以下五种类型之一:资产、负债、收入、支出和权益。其背后的理由是,所有计数问题都可以归类为永久性(资产、负债)或临时性(收入、支出),并且绝大多数账户具有通常的余额符号:正数(资产、支出)或负数(负债、收入)。给定一个待累计的数量,我们总能为其选择这四类标签之一。

强制执行这一规则,使得我们可以实现针对永久性账户(资产负债表)的累计值报表,以及针对时间段变化(利润表)的报表。虽然技术上可以显式地向权益账户记账,但该类别项目通常的用途是记录资产负债表的历史变动(净收益/留存收益或期初余额)。

这些时间与符号的概念超越了传统会计的范畴。缺乏它们会导致关于如何处理此类报表的困难且不必要的问题。我们只需要求您将账户按照此模型进行标记。我承认这需要一些实践和前瞻性思考,但最终结果是构建出一种易于生成常见预期输出的结构。

账户生命周期与开放指令

账户具有生命周期;账户在特定日期开启,并可选择在另一日期关闭。这由“Open”和“Close”指令控制。

所有账户都必须在流中具有对应的 Open 指令。默认情况下,当向尚未在流中见到 Open 指令的账户记账时,系统会抛出错误以强制执行此规则。您可以通过使用“auto_accounts”插件自动生成这些指令,但无论如何,流中的条目始终包含 Open 指令。这是编写脚本时应能依赖的假设。

(最终,类似约束也将应用于商品指令,以确保流中在使用前始终包含商品指令;且它们也应自动生成功能。但目前并非如此 [2015 年 6 月]。)

仅支持日期(不支持时间)

Beancount 不表示时间,仅表示日期。最小时间单位为一天。这是为了简化设计。虽然存在需要时间来消除账户余额歧义的情况,但在实践中这类情况极为罕见,其存在不足以证明引入时间值处理及相应语法的额外复杂性是合理的。

如果您有需要时间的用例,可以使用元数据添加时间信息,或者更可能的是,您应为此编写自定义软件。这超出了 Beancount 的设计范围,是刻意为之。

元数据用于用户数据

默认情况下,Beancount 不会对元数据字段及其值做任何假设。元数据专供用户和插件私有使用。Beancount 核心不会读取元数据,也不会根据元数据改变其处理逻辑。然而,插件可以定义影响其输出的特殊元数据值。

尽管如此,Beancount 处理过程中会生成一些特殊的元数据字段:

  • filename:字符串,表示交易读取自的文件名(或创建该交易的插件)

  • lineno:整数,表示交易所在文件的行号。

  • tolerances:一个字典,键为货币,值为 Decimal 类型,表示平衡交易时使用的容差值。

  • residual(在记账项上):布尔值,若为真,表示该记账项是自动添加以吸收舍入误差的。

未来可能会增加更多此类字段,但无论如何,核心不应读取任何元数据并据此改变其行为。(我或许应该创建一个集中注册表或位置,以便统一存放所有特殊值。)

代码库概览

所有源代码均位于名为“beancount”的 Python 包中。它由多个具有明确职责的包组成,这些包之间的依赖关系被严格约束。

Beancount 源代码包及其相互之间的近似依赖关系。

在底部,我们有一个beancount.core包,其中包含用于在内存中表示交易和其他指令的数据结构。该包包含账户、金额、批次、头寸和库存的代码,还包含处理指令流的常用例程。

再往上一层,beancount.parser包包含解析 Beancount 语言的代码,以及一个能够根据指令流生成相应文本的打印器。理论上,应该能够在文本和数据结构之间相互转换,并且无损地完成这一过程。

在主包的根目录下,有一个beancount.loader模块,负责协调将文件加载到内存所需的所有操作。这是编写 Beancount 脚本的主要入口点,总是从加载指令流开始。它会调用解析器,运行一些处理代码,导入并按顺序执行插件,以生成最终的指令流,用于生成报告。

beancount.plugins包包含各种插件的实现。每个插件模块彼此独立。请查阅源代码中的文档字符串,了解每个插件的具体功能。其中一些是某些想法的原型。

还有一个beancount.ops包,其中包含一些高级处理代码,以及一些默认始终运行的代码(例如使用 Pad 指令进行填充),这些代码也以插件形式实现。该包需要稍作调整,以明确其角色。

在此之上,报告代码会调用上述包中的模块。共有四个包包含报告代码,分别对应 Beancount 的报告工具:

  • beancount.reports:该包包含专门用于生成不同类型输出的代码(由 bean-report 调用)。通常,我倾向于避免定义自定义例程来生成所需输出,而是使用 SQL 查询工具来表达分组和聚合;但我认为仍有必要保留部分此类功能。报告通过一个具有统一接口的类层次结构定义,您应该能够对其进行扩展。每个报告支持若干输出格式中的一部分。

  • beancount.query:该包包含查询语言和交互式 shell(由 bean-query 调用)的实现,允许您使用类似 SQL 的 DSL 对头寸进行分组和聚合。这本质上是在内存中的 Posting 对象表上执行处理,并定义了针对 Amount、Position 和 Inventory 对象的函数。

  • beancount.web:该包包含默认 Web 界面(由 bean-web 调用)的所有源代码。这是一个简单的Bottle应用程序,将 beancount.reports 中的许多报告以 HTML 格式提供,运行在您的本地机器上。Web 界面提供了对数据的简单视图和访问。(它仍有很大改进空间,远非完美。)

  • beancount.projects:该包包含一些我想要分享和分发的特定应用自定义脚本。在可能的情况下,我力求将这些脚本整合到报告中。没有直接调用这些脚本的命令;您应使用“python3 -m beancount.projects.<name>”来运行它们。

存在一个名为 beancount.scripts 的包,其中包含了位于 bin 目录 下所有脚本的“主程序”。这些脚本是简单的启动器,用于导入 beancount.scripts 下对应的文件。这样我们可以将所有源代码集中存放在一个目录中,便于运行代码检查工具(如 linter)和其他代码健康检查工具——所有代码都在一处。

此外,还有一个名为 beancount.utils 的包,用于存放通用的、可重用的实用工具代码。还有一个相对不重要的包 beancount.docs,其中包含作者用于生成和维护本文档的代码(连接 Google Drive 的代码)。

这些包之间的依赖关系由一个 自定义脚本 来强制执行。

核心数据结构

本节描述了用于构建指令的基本数据结构。在可能的情况下,我将以概念性的方式描述这些数据结构。

(对 Ledger 用户的说明):本节介绍了 Beancount 的一些术语;如果您希望将其与 Ledger 中的概念和术语进行对比,请参阅 此处

数字

数字 使用 decimal 对象表示,这非常适合会计用途。会计系统中绝大多数数字本质上都是十进制数,而二进制表示会引入舍入误差,导致显示和精度方面的问题。有理数可以避免这些问题,但它们无法保留用户输入时所期望的有限精度。我们必须显式处理 容差

因此,所有数字都应使用 Python 的 “decimal.Decimal” 对象。幸运的是,Python 3.x 已原生支持 decimal 类型的 C 实现(位于标准库中;此前需要安装外部的 “cdecimal” 包,现已集成到 C/Python 解释器中)。

decimal 对象的默认构造函数不支持我们希望允许的一些输入语法,例如整数部分包含逗号(如 “278,401.35 USD”)或从空字符串初始化。这些是需要处理的重要情况。因此,我提供了一个专门的构造函数来支持这些输入: beancount.core.number.D()。这是创建 decimal 对象的唯一推荐方法:

from beancount.core.number import D
…
number = D("2,402.21")

我倾向于单独导入 “D” 符号(而非使用 number.D)。所有与数字相关的代码都位于 beancount.core.number 下。

还定义了一些数字常量: ZEROONEHALF。请使用这些常量,而不是显式构造数字(如 D("1")),这样更便于通过 grep 查找此类初始化。

商品

商品货币(我在代码和文档中交替使用这两个名称)是一个表示账户中可存储的某种“事物”的字符串。在实现中,它被表示为 Python 的 str 对象(没有专门用于货币操作的模块)。这些字符串只能包含大写字母、数字和某些特殊字符(参见词法分析器代码)。

Beancount 不预定义任何货币名称或类别——所有货币都是平等的。真的,它对美元、欧元、日元或其他任何货币都没有特殊认知。源代码中唯一提及这些货币的地方是在测试中。它不支持使用“$”或其他货币符号的语法;我理解有些用户可能希望使用这种语法,但为了保持解析器的简洁和一致性,我选择不提供这一选项。

此外,Beancount 不区分代表“货币”的商品与其他商品(如股票、小时等)。系统中所有商品均被同等对待。它也没有“本币”2的概念;它是一个真正的多币种系统。

货币无需在输入文件格式中显式定义;您只需在文件中直接使用它们,解析器便会通过其唯一语法识别它们(词法分析器会识别并输出货币标记)。不过,您也可以使用 Commodity 指令声明某些货币。这仅用于为每个商品创建一个实体,以便用户可以附加元数据,部分报表和插件能够查找并使用这些元数据。

账户

账户本质上是与记账项关联的“容器”名称,以简单字符串(Python 的 str 对象)表示。账户由词法分析器检测和标记,其名称必须至少包含两个由冒号(":")分隔的单词。

账户没有对应的对象类型;我们仅通过其唯一的名称字符串(类似于 Python 中的文件名)来引用它们。当需要账户特定属性时,我们可以从条目流中提取 Open 指令,并找到与目标账户对应的那一条。

类似于 Python 的 os.path 模块,beancount.core.account 中提供了一些用于操作账户名称的函数,其命名方式与 os.path 模块中的函数类似。

账户名称的第一个组成部分仅限于以下五种类型之一:

  • 资产

  • 负债

  • 权益

  • 收入

  • 支出

输入语法中账户类型的名称可通过某些 "option" 指令自定义,因此您可以将这些名称改为其他语言,甚至将 "Income" 重命名为 "Revenue"(如果您更喜欢后者)。beancount.core.account_types 模块提供了一些辅助工具来处理这些自定义。

请注意,账户名称集合构成一个隐式层次结构。例如,以下名称:

Assets:US:TDBank:Checking
Assets:US:TDBank:Savings

隐式地定义了一个树形结构,其中父节点为 "Assets"、"US"、"TDBank",两个叶节点为 "Checking" 和 "Savings"。该隐式树在处理过程中不会被显式构建,但有一个 Python 模块可轻松实现此功能(参见 beancount.core.realization),并生成树的线性化表示。

标记

“标记”是一个单字符字符串,可用于关联交易和记账项,以表明它们是否被假定为正确(“已对账”)或被标记为可疑。交易实例中常用的标记是字符“*”。在记账项中,通常留空(设为 None)。

金额

金额 是一个数字与其关联货币的组合,概念上为:

Amount = (Number, Currency)

Amount 实例用于表示特定货币的数量(即“单位”)以及记账项的价格。

beancount.core.amount 中定义了一个简单的类似元组的对象类。存在一些函数可直接对 Amount 实例执行简单的数学运算。您还可以使用 amount.from_string() 创建 Amount 实例,例如:

value = amount.from_string("201.32 USD")

Cost

Cost 对象表示某类商品特定批次的成本基础。从概念上讲,它由以下部分组成:

Cost = (Number, Currency, Date, Label)

数字和货币指的是成本本身,而非商品本身。例如,如果您以每股 56.78 美元的价格购买了 40 股 AAPL 股票,则 数字 为 “56.78” 小数,货币 为 “USD”。例如:

Cost(Decimal("56.78"), "USD", date(2012, 3, 5), "lot15")

日期 是相应批次的购入日期(一个 datetime.date 对象)。当记账项增加库存时,系统会自动将交易日期附加到 Cost 对象上;或者,如果输入语法提供了显式的日期覆盖,则也会附加该日期。

标签 可以是任意字符串,用于方便用户引用特定批次或区分相似的批次。

在 Cost 对象中,数字、货币和日期属性始终被设置。如果标签未设置,则其值为 “None”。

CostSpec

在输入语法中,我们允许用户仅提供最少的信息,以区分记账前库存中包含的各个批次。所提供的数据会筛选出唯一匹配的批次,或缩小到一个子集,随后由自动化记账算法(例如“先进先出”)进行处理。

此外,我们允许用户提供单位成本和/或总成本。这种便利性有助于让 Beancount 根据总收入自动计算单位成本。

CostSpec = (Number-per-unit, Number-total, Currency, Date, Label, Merge)

由于任何输入元素都可能被省略,因此 CostSpec 的任何属性都可能为 None。如果缺少某个必需的数字,系统将为其设置特殊值 “MISSING”。

Merge 属性用于记录用户请求:在应用交易前合并所有输入批次,以及在交易后合并它们。这是所有发往使用 “AVERAGE” 记账方法账户的交易所采用的方法。

Position

Position 表示以特定成本持有的某种商品的单位数量,它仅由以下部分组成:

Position = (Units, Cost)

UnitsAmount 的实例,CostCost 的实例,若商品未按成本持有,则为 null 值。库存包含一系列 Position 实例。详见其在 beancount.core.position中的定义

Posting

每个 Transaction 指令由多个 Posting 组成(我通常在代码和邮件列表中非正式地称其为交易的“分录”)。每个 Posting 关联一个账户、一个 Position,以及可选的价格和标记:

Posting = (Account, Units, Cost-or-CostSpec, Price, Flag, Metadata)

如您所见,一个Posting嵌入了其Position实例3Units是一个Amount,而“cost”属性指向一个CostCostSpec实例(解析器输出的Posting实例包含CostSpec属性,该属性在记账过程中被解析为Cost实例;详情请参见库存如何工作)。

PriceAmount的实例或空值。它用于声明货币兑换以平衡交易,或记录按成本持有头寸的当前价格。它是输入中出现在“@”符号旁边的金额。

Posting 上的标记相对较少;用户通常只需标记整个交易,而非特定的 Posting。标记通常保持为 None;若已设置,则为单个字符的字符串。

Posting 类型定义在beancount.core.data中,与其他所有指令类型一同定义。

库存

Inventory 是账户在不同商品批次中余额的容器。它本质上是一个包含Position实例的列表,并为其定义了适当的操作。从概念上讲,您可以将其视为具有唯一键的映射:

Inventory = [Position1, Position2, Position3, … , PositionN]

通常,位置的(Units.Currency, Cost)组合在列表中保持唯一,类似于映射的键。对于具有相同货币和成本值的位置,系统会通过累加其Units.Number将其合并为单个位置。简单位置与按成本持有的位置混合存储在列表中。

Inventory 是 Beancount 实现中最重要且最常使用的对象之一,因为它用于在一段时间内汇总一个或多个账户的余额。同时,库存减少算法也在此处应用,该机制的痕迹也可见于此。库存如何工作文档详细说明了这一过程。

为了测试,您可以使用inventory.from_string()创建已初始化的 Inventory 实例。所有库存代码均位于beancount.core.inventory中。

关于元组与可变性

尽管该程序使用的是默认不会让可变性变得“困难”的语言,但我设计软件时尽量避免在大多数地方使用可变性。Python 提供了一个“collections.namedtuple”工厂,用于创建其属性无法被覆盖的新记录类型。然而……这仅部分成立:不可变元组中的可变属性仍可被修改。Python 并未提供非常强大的机制来强制执行这一特性。

无论如何,函数式编程更多地不在于所用语言本身,而在于我们构建在其结构中的保证。支持强保证的语言有助于强制执行这一点。但即使仅通过一套约定,只要我们能基本避免可变性,就能实现一个基本函数式的程序,从而避开因不可预测的修改而产生的大部分陷阱,使代码更易于维护。对修改发生位置毫无顾忌的程序最难维护。通过以函数式方法避免大部分可变性,我们也就避免了大部分此类问题。

  • Beancount 中使用的大多数数据结构都是namedtuples,其属性的修改被有意设计为不便进行。

  • 大部分代码会尽量避免突变,除非是局部状态(函数内部)或在我们能够轻松理解的狭窄范围内。在可能发生突变的地方,按照惯例,我默认避免使用突变。当我们确实需要突变时,我会尽量记录其影响。

  • 我避免创建会修改其内部状态的类,除非少数情况(例如网络应用程序)。我更倾向于使用函数而非对象,在定义类时,我也尽量避免继承。

这些特性尤其适用于所有小型对象:Amount、Lot、Position、Posting 对象,以及来自 beancount.core.data 的所有指令类型。

另一方面,Inventory 对象几乎总是用作累加器,并且确实允许修改其内部状态(若要避免这一点,需要使用特殊的持久化数据结构)。你必须谨慎处理对 Inventory 对象的共享访问……以及如果确实修改它们时,更要小心。

最后,加载器生成的是一系列指令,它们都是简单的 namedtuple 对象。这些列表构成了应用程序的主要状态。我刻意避免将它们封装在某个特殊容器中,而是显式地在各处传递。与其构建一个庞大的“应用”对象,我已精简了所有冗余,你的所有状态仅由两部分表示:一个按时间排序的指令列表,可用于流式处理;以及一个只读的常量选项值列表。我认为这更简单。

我将自己能够对中等规模的 Python 代码库进行广泛修改的能力归功于这些原则的采用。我非常希望拥有类型以防范另一类潜在错误,我计划尝试使用 Python 3.5 即将推出的 typing 模块

摘要

下图说明了这些对象之间的关系,从 Posting 开始。

例如,要获取一笔记账的单位数量,你可以使用

posting.units.number

对于成本货币:

posting.cost.currency

你可以在 Python 中打印这些元组以了解其结构。

先前的设计

为了保持历史记录,如果你回溯到仓库的早期版本,记账项的结构会更深、更复杂。新设计反映了更扁平、更简洁的版本。以下是旧设计的原貌:

指令

加载 Beancount 输入文件的主要输出是一系列 指令。在代码库和文档中,我也交替使用 条目 这一术语。指令有多种类型:

  • 交易

  • 余额与填充

  • 开户与关户

  • 商品

  • 备注

  • 事件

  • 价格

  • 文档

在典型的 Beancount 输入文件中,绝大多数条目是交易和一些余额断言,还可能包含一些开户和关户条目。其余所有类型都是可选的。

由于这些指令与选项值映射共同构成了整个应用程序状态,你应该能够将这些条目传递给函数,以生成报告。系统正是围绕这一理念构建的:通过处理指令流,提取所有内容并生成报告,这些报告本质上是对附加在指令流上的值进行不同形式的过滤与聚合。

公共属性

以下属性适用于所有指令:

  • 日期。 一个 datetime.date 对象。这非常有用且一致。对于交易(Transaction),这是其发生的时间;对于 Open 或 Close 指令,这是账户开立或关闭的日期;对于 Pad 指令,这是插入交易的日期;对于 Note 或 Document,这是将注释插入该账户交易流的日期;对于 Event,这是其发生的时间。对于 Commodity 指令(本质上是为商品相关元数据提供悬挂点),日期意义不大;我选择将这些指令的日期设为商品创建的当天。
  • 元数据。 所有指令都具有元数据属性(一个 Python 字典对象)。元数据的目的是允许用户附加任何所需的辅助数据,并在脚本或查询中使用它们。Posting 实例也具有元数据属性。

交易

Transaction 指令是 Beancount 的核心,也是输入文件中最常见的指令,值得特别关注。记账系统的作用是通过多种方式组织与交易关联的 Posting。其他所有类型的条目在我们的系统中均处于辅助角色。

交易具有以下额外字段:

标记

单字符标志通常用于替代“txn”关键字(Transaction 是唯一允许不输入关键字的指令)。我曾考虑略微修改语法定义,以允许既不输入标志也不输入关键字,因为我最终希望支持这种用法。目前,必须提供标志或关键字之一。标志属性可以设为 None。

交易对手与说明

说明字段是用户提供的交易描述,例如“与 Mary-Ann 共进晚餐”。您可以在该字符串中添加任何信息,它会在日记报告中显示。通常,它用于补充无法自动导入的上下文信息,例如“从储蓄账户转账支付汽车维修费用”。

付款方名称是可选的,用于描述交易对象,例如“全食超市”或“壳牌”。

请注意,我希望能够生成与特定付款方相关的所有交易报告,因此最好使用一致的付款方名称。但问题是,手动维护这种一致性有时过于繁琐,需要更好的工具或更宽松的名称匹配机制才能实现这一目标。

输入语法也允许仅提供一个字符串。默认情况下,该字符串将作为说明内容,但实践中有时仅提供付款方名称会更方便。目前,若只想输入付款方名称,必须附加一个空的说明字符串。这一点将来应重新审视。

标签

标签是一组字符串,可用于对交易进行分组(若无标签,可设为 None)。然后可以生成该交易子集的视图,包括所有常规报告(资产负债表、损益表、日记等)。您可以为各种目的标记交易;以下是一些示例:

  • 一次特定旅行中的所有交易可被打上标签,以便日后汇总旅行支出。这些交易通常发生在相近的日期,因此存在一种便捷语法,可自动为文件中一起声明的多个交易打上标签。

  • 与某个特定项目相关的交易。我曾参加过一个在线课程,并将所有相关支出都打上了标签。我现在用这种方式追踪我移民过程中某个阶段(例如绿卡)的所有相关支出。

  • 声明一组支出将在未来由雇主(或你自己的公司)偿还。

  • 与地点之间迁移相关的支出。

我常用的标签名称通常类似于 #trip-israel-2012#conference-siggraph#course-cfa

通常,当添加子账户无法满足需求时,标签就很有用。这种情况常出现在一组相关支出属于不同类型,因而不能归入单一账户时。

在查询工具提供适当支持的前提下,标签最终可被元数据取代——我一直在考虑将标签转换为带有布尔值 True 的元数据键,以消除不必要的复杂性。

链接是一组唯一的字符串或 None,在实际使用中,大多数交易的链接通常是空的。它们与标签的区别仅在于用途。

链接用于将一组相关的交易串联起来,并有工具可列出、导航和平衡这些被链接的交易子集。它们是交易引用其他交易的一种方式,但不用于汇总。

示例包括:交易账户中的交易 ID(这些账户通常为“相关”或“其他”交易提供 ID);与相关更正相关的账户条目,例如在电话沟通后撤销的费用可链接到几周前的原始无效费用;房屋的买卖及其相关购置费用。

与标签不同,链接的字符串通常是由导入器生成的唯一数字。系统不会为链接生成视图;仅能生成特定链接交易的日记账,且渲染的交易应附带一个可点击的“链接”图标,用于查看所有相关交易。

分录

一组分录附加在交易对象上。从技术上讲,这个列表是可变的,但实践中我们尽量不修改它。每个分录只能属于单个交易。

有时同一交易的不同分录会在各自账户中于不同日期结算,因此我们最终可能允许分录拥有自己的日期,以覆盖交易的默认日期,用于文档记录;在最简单的版本中,我们强制所有交易即时发生,这易于理解,且在实践中并未造成显著问题。最终我们可能会通过隐式将具有多个日期的交易转换为多个交易,并使用某种过渡账户在日期间暂存未结余额来实现此功能。详情请参见相关提案

平衡分录

复式记账法的基本原则在每个交易条目中均得到强制执行:所有分录的总和必须为零。本节描述了我们实现这一原则的具体方式,这是整个系统能够良好平衡的核心逻辑。这也是本系统中少数几个计算超越简单过滤和聚合的环节之一。

正如我们之前所见,分录与

  • 一个仓位相关,仓位是某一批次(其本身可能有也可能没有成本)的单位数量

  • 可选的转换价格

鉴于此,我们如何平衡一笔交易?

需要明确一些术语:以本示例条目为例:

Assets:Investment:HOOL    -50 HOOL {700 USD} @ 920 USD

单位。 单位数量即持仓数量,例如“50”。

货币。 该笔持仓的商品,即“HOOL”。

成本。 此持仓的成本为单位数量乘以每单位的成本货币金额,即 50 × 700 = 35000 美元。

(总)价格。 每单位的价格为附带的价格金额,即 920 美元。持仓的总价格为其单位数量乘以转换价格,本例中为 50 × 920 = 46000 美元。

权重。 用于平衡交易中每条条目的金额:

  1. 如果条目附有成本,则计算该持仓的成本(无论是否附有价格);

  2. 如果条目无成本但有转换价格,则将持仓转换为其总价格;

  3. 最后,如果条目既无成本也无转换价格,则直接使用持仓的单位数量。

平衡过程非常简单:首先将每条条目的持仓转换为其权重,然后按货币种类对这些金额进行分组,最终每种货币的总和需接近零,即在允许的小幅容差范围内(由输入数据和选项共同推断)。

以下示例属于情况(2),包含一个价格转换和一个既无成本也无价格的常规条目(情况 3):

2013-07-22 * "Wired money to foreign account"
  Assets:Investment:HOOL     -35350 CAD @ 1.01 USD        ;; -35000 USD (2)
  Assets:Investment:Cash      35000 USD                   ;;  35000 USD (3)
                                                          ;;------------------
                                                          ;;      0 USD

在下一个示例中,第一条条目有成本,第二条条目则没有:

2013-07-22 * "Bought some investment"
  Assets:Investment:HOOL     50 HOOL {700 USD}            ;;  35000 USD (1)
  Assets:Investment:Cash    -35000 USD                    ;; -35000 USD (3)
                                                          ;;------------------
                                                          ;;      0 USD

这是一个更复杂的示例,同时包含价格和成本;此处的价格在平衡计算中被忽略,仅用于向内部价格数据库录入数据点:

2013-07-22 * "Sold some investment"
  Assets:Investment:HOOL    -50 HOOL {700 USD} @ 920 USD  ;; -35000 USD (1)
  Assets:Investment:Cash     46000 USD                    ;;  46000 USD (3)
  Income:CapitalGains       -11000 USD                    ;; -11000 USD (3)
                                                          ;;------------------
                                                          ;;      0 USD

最后,这是一个包含多种商品的示例:

     2013-07-05 * "COMPANY INC PAYROLL"
       Assets:US:TD:Checking                            4585.38 USD
       Income:US:Company:GroupTermLife                   -25.38 USD
       Income:US:Company:Salary                        -5000.00 USD
       Assets:US:Vanguard:Cash                           540.00 USD
       Assets:US:Federal:IRAContrib                     -540.00 IRAUSD
       Expenses:Taxes:US:Federal:IRAContrib              540.00 IRAUSD
       Assets:US:Company:Vacation                          4.62 VACHR
       Income:US:Company:Vacation                         -4.62 VACHR

此处有三组权重需要平衡:

  • 美元:(4485.38) + (-25.38) + (-5000) + (540) ~= 0 美元

  • IRA 美元:(540.00) + (-540.00) ~= 0 IRA 美元

  • VACHR:(4.62) + (-4.62) ~= 0 VACHR

金额省略

交易最多允许一条条目被省略并自动设定;若某金额被省略,则其余所有条目的最终余额将归于该省略条目。

结合库存记账提案,应扩展以支持更多省略情形。一个有趣的想法是允许省略的条目至少指定其使用的货币,从而允许用户省略多条条目,例如:

     2013-07-05 * "COMPANY INC PAYROLL"
       Assets:US:TD:Checking                                    USD
       Income:US:Company:GroupTermLife                   -25.38 USD
       Income:US:Company:Salary                        -5000.00 USD
       Assets:US:Vanguard:Cash                           540.00 USD
       Assets:US:Federal:IRAContrib                     -540.00 IRAUSD
       Expenses:Taxes:US:Federal:IRAContrib                     IRAUSD
       Assets:US:Company:Vacation                          4.62 VACHR
       Income:US:Company:Vacation                               VACHR

流式处理

将所有状态表示为单一指令流的一个重要副产品是,Beancount 中的大多数操作都可以通过简单的函数实现,这些函数接受指令列表作为输入,并输出修改后的指令列表。

例如,

  • “填充”指令通过处理指令流、累积余额实现,当余额断言失败时,在“填充”指令后插入一条新的填充交易。

  • 汇总操作(如打开账簿、关闭账簿、将净收益转入权益)均以这种方式实现,作为对流列表的功能性操作。例如,在特定日期打开账簿,是通过将该日期之前的所有余额相加,并用新交易替换原有交易,这些新交易从权益账户中提取最终余额。关闭账簿仅涉及将损益类账户的余额转移至权益账户,并将交易列表截断至该日期。这种方法非常优雅——报告代码无需知晓汇总操作是否已发生。

  • 带有成本的条目中的价格通常被忽略。"隐含价格"选项通过一个插件实现,该插件在处理操作流时,自动为此类条目插入原本平凡的价格指令。

  • 许多类型的验证也以这种方式实现;sellgains 插件验证非收入类余额是否与忽略成本的条目转换价格一致。此插件不插入任何新指令,但可能生成新的错误。

这些只是部分示例。我旨在使大多数操作都以此方式工作。这种设计已被证明既优雅,又出人意料地灵活,并催生了 Beancount 中可用的插件系统;你可能会惊讶地发现,我最初并未打算提供插件系统……它是在不断提炼抽象、努力避免程序中状态的过程中自然涌现出来的。

我正在考虑通过引入一个新的 "Error" 指令,将错误嵌入到指令流中,该指令可由流处理函数插入。目前尚不确定这是否会简化任何内容,这只是一个初步的想法 [2015 年 7 月]。

流不变量

指令流提供了一些你可以依赖的保证:

  • 所有指令均按日期排序。由于许多日期包含多个指令,为确保稳定的排序顺序,文件中的行号被用作次要排序键。

  • 所有账户的使用均在流中由对应的 Open 条目先行声明。如果输入文件未提供 Open 条目,系统将自动合成一个。

  • 所有 Balance 指令均优先于同一天的其他任何指令。这是为了确保余额断言的处理简单直接,并强调其语义发生在当天开始时。

加载与处理顺序

从输入文件中加载条目列表是该项目的核心。理解这一过程对于理解 Beancount 的工作原理至关重要。请参见下图。

它包括:

  1. 一个解析步骤,读取所有输入文件并生成一组可能不完整的指令流。

  2. 由内置和用户指定插件对这条指令流进行处理。此步骤可能生成新的错误对象。

  3. 一个最终验证步骤,确保插件未破坏任何不变量。

beancount.loader 模块协调此处理过程。在代码和文档中,我始终谨慎区分 "解析" 和 "加载" 这两个术语。 这两个概念是不同的。"解析" 仅是 "加载" 的一部分。

用户指定的插件将按照输入文件中提供的顺序执行。

原始模式。 一些用户希望更精确地控制哪些内置插件被运行以及它们的执行顺序,因此启用“原始”选项将禁用所有内置插件的自动加载(如果您希望使用这些插件,必须通过显式的“plugin”指令手动指定)。我不确定这是否真的有用,但它允许在实验时对加载过程进行更严格的控制。

加载器输出

解析器和加载器生成三个列表:

  • entries:来自 beancount.core.data 的指令元组列表。这是应主要由 Transaction 和 Balance 实例组成的数据流。尽可能地,所有数据转换都通过向此列表中添加或删除条目来完成。在代码和文档中,我交替使用“entries”或“directives”来指代它们。

  • errors:错误对象列表。在 Beancount 中,我并不使用异常来报告错误;相反,所有函数都会生成错误对象,并在最合适的时机显示它们。(这些错误对象应有一个公共基类型,但目前的约定是它们都提供 source(文件名、行号)、message 和 entry 属性。)

  • options_map:文件中提供的选项以及解析过程中派生出的选项字典。尽管这是一个可变对象,但一旦由解析器生成,我们就不再修改它。

解析器实现

Beancount 解析器是 C 语言和 Python 3 代码的混合体。这就是为什么您首先需要进行编译的原因。该解析器使用 flex 分词器和 Bison 解析器生成器实现,主要出于性能考虑。

我选择 C 语言和这些老旧的 GNU 工具,是因为它们具有良好的可移植性,能在任何地方运行。虽然存在更先进的解析器生成工具,但它们要么要求用户安装更多编译工具,要么依赖于小众语言。C 语言是一个绝佳的通用基础。我希望 Beancount 能在任何地方运行,并且易于安装。这不仅是为了方便他人,也是为了我自己——因为未来我还会从事其他项目,依赖越少,维护老旧软件就越轻松。这只是未雨绸缪。(这也是为什么我选择 Python 而非我如今更自然倾向的其他语言(如 Clojure、Go、ML);我需要 Beancount 能稳定运行……当我记账时,我没有时间调试问题。Python 对变更非常谨慎,它将长期存在。)

有一个用 flex 编写的词法分析器文件 lexer.l 和一个用 Bison 编写的语法规则 grammar.y。这些工具用于生成对应的 C 源代码(lexer.h/.c 和 grammar.h/.c)。这些文件,连同一些手动编写的 C 代码(定义了模块的接口函数 parser.h/.c),被编译成一个扩展模块(_parser.so)。

最终,我们或许可以考虑在 setup.py 中添加一个小型依赖规则,以自动调用 flex 和 Bison;但目前为了最小化安装负担,我将生成的源代码(lexer.h/c 和 grammar.h/c)直接提交到代码仓库中。

Python 与 C 代码之间的交互方式如下:

  • 您导入 beancount.parser.parser,并调用 parse_file() 或 parse_string()。这会使用 grammar.py 中的代码创建一个 Builder 对象,该对象本质上提供了用于处理语法规则的 Python 回调函数。

  • parser.py 调用扩展模块中的 C 函数,并传入 Builder 对象。该 C 代码设置 flex 以读取输入文件或字符串,然后通过调用生成的 C 函数 “yyparse()” 将控制权交给解析器。

  • yyparse() 是解析器,它通过连续调用 “yylex()” 从词法分析器的 C 代码中获取输入标记,并尝试归约规则。

  • 当成功归约一条规则或一个标记时,C 代码会调用您提供的 Builder 对象上的回调方法。解析器的规则之间通过传递 “PyObject*” 实例进行通信,因此这部分代码必须小心正确处理引用计数。这使我能够在规则归约时运行 Python 代码,从而既轻松自定义解析器行为,又保持了 C 语言处理语法规则的高性能。

请注意,grammar.py 中的 Builder 继承自 lexer.py 中定义的类似 Builder 类,因此词法分析器识别的标记会调用 lexer.py 文件中定义的方法来创建它们,而解析器规则则相应地调用 grammar.py 的 Builder 中的方法。这种隔离不仅结构清晰,还允许您仅使用词法分析器对文件进行分词(无需解析),以测试分词器;如果您好奇其工作原理,可以查看相关测试用例。

我刚刚想到了这种设计,尚未在其他地方见过。我尝试后发现,与使用 PLY 相比,这种更快的解析器带来了显著的性能提升,因此我坚持采用它。我很喜欢这种模式,它很好地平衡了解析器生成器的灵活性与使用 Python 处理规则的能力。最终,我希望将一些最重要的回调函数迁移到 C 代码中,以大幅提升性能(目前我尚未进行任何实际的性能优化)。

到目前为止,这种设计运行良好,但存在一个问题:flex 词法分析器存在一些固有的限制,已证明具有挑战性。特别是,为了通过缩进识别交易条目,我不得不对行首的空白字符进行分词。此外,输入中的单个字符应被解析为标志,但目前仅支持一小部分字符。我计划最终编写一个具有前瞻能力的自定义词法分析器,以更好地解决这些问题(这很容易实现)。

解析的两个阶段:不完整的条目

目前,解析器生成的交易可能平衡,也可能不平衡。在插件运行之后的验证阶段会执行余额断言。这种自由度允许用户输入不完整的条目列表,并由相应插件自动完成数据录入并插入补全的条目。

然而,交易中的条目始终是完整的对象,所有预期属性均已设置。例如,输入语法中省略了金额的条目,在离开解析器时,其金额字段已被填充。

当我们实现库存记账提案时,将不得不放宽这一特性,因为我们希望支持省略那些值依赖于先前交易累积状态的数字。一个明显的例子是这样的条目

2015-04-03 * "Sell stock"
  Assets:Investments:AAPL     -10 AAPL {}
  ...

如果在 4 月 3 日开始时,账户中恰好持有 10 单位的 AAPL 股票(无论是一个批次还是多个批次),则应成功执行。这里没有歧义:它明确要求“卖出所有 AAPL”。然而,这种明确性假设我们已经计算了该账户截至 4 月 3 日的库存余额……

因此,解析器需要分为两个阶段:

  1. 一个简单的解析步骤,生成可能缺少数量的不完整条目

  2. 一个独立的插值步骤,该步骤以每个账户的库存余额作为输入。第二步将调用记账算法(例如 FIFO)。

请参见上方的图表作为参考。一旦实现,其余部分应保持不变。

打印器

与解析器位于同一包中的是一个打印器。该打印器将所有处理 Beancount“语言”功能的代码隔离在beancount.parser包中:解析器将输入语法转换为数据结构,而打印器则执行相反的操作。此包之外的任何代码都不应关心 Beancount 语法。

在某个时刻,我决定确保打印器能够与解析器实现双向转换,即:给定加载器生成的一组条目,你应该能够将它们转换为文本输入,再重新解析回来,最终得到的条目集合应完全一致(将重新解析的输入再次输出为文本时,应生成与原始文本完全相同的结果),例如:

请注意,反过来不一定成立:读取输入文件并通过加载器处理时,可能会合成大量条目(得益于插件),因此将其重新打印出来可能不会生成与原始输入文件完全相同的文件(即使忽略顺序和空白字符的差异也是如此)。

这是一个很好的特性。除此之外,它使我们能够轻松使用解析器创建具有预期输出的测试用例。虽然已有测试试图保护这一特性,但最近的一些更改部分破坏了它:

  • 元数据的双向转换尚未得到充分测试。特别是,某些元数据字段需要被忽略(例如文件名和行号)。

  • 某些指令包含派生数据,例如平衡指令包含一个“diff_amount”字段,用于在断言余额失败时记录差值。这有助于更轻松地报告错误。我可能需要在某处移除这些例外,因为它是唯一一个这样的设计(我可以将其替换为插入的“Error”指令)。

这可能需要在未来用更完整的测试进一步完善(目前的测试已经相当接近了)。

唯一性与哈希

为了能够快速比较指令,我们支持对所有指令进行唯一哈希,即:从每个指令中应能生成一个简短且唯一的标识符。我们可以使用这些标识符来执行集合包含/排除/比较测试,以支持单元测试。我们提供了一个基础测试用例类,其中包含利用此功能的断言方法。该特性在我们的测试套件中被广泛使用。

该特性还用于检测和移除重复项。此功能是可选的,由beancount.plugins.noduplicates插件启用。

请注意,当前指令的哈希计算不包含用户元数据

显示上下文

数字以不同的小数位数输入:

500    -> 0 fractional digits
520.23 -> 2 fractional digits
1.2357 -> 4 fractional digits
31.462 -> 3 fractional digits

通常,用于同一种货币的数字会以不同位数的小数形式输入。鉴于输入的这种差异,一个随之而来的问题是:如何在报告中一致地呈现这些数字

请考虑以下几点:

  • 我们希望用户无需手动指定默认渲染时使用的小数位数。

  • 数字的呈现方式可能因上下文而异:大多数情况下,我们希望使用最常见的小数位数来呈现数字(必要时进行四舍五入),但在呈现汇率时,我们希望使用曾见过的最大小数位数。

  • 所使用的位数通常取决于其代表的商品。例如,美元通常以两位小数呈现;像 RGAGX 这样的共同基金单位,您的经纪商可能以三位小数记录;日元通常以整数形式指定。

  • 我们需要在文本报告中对具有不同小数位数的数字进行对齐,使其按小数点或最右端数字对齐,并可能在之后显示货币符号。

为解决这一棘手问题,我构建了一种累加器,用于记录从输入中看到的所有数字,并统计每种货币所观察到的精度统计信息。我称此为DisplayContext。通过该对象,可以请求构建一个 DisplayFormatter 对象,用于以特定方式呈现数字。

在代码中,我使用变量名 dcontextdformat 来指代这些对象。解析器在解析过程中会自动创建一个 DisplayContext 对象,并将所有遇到的数字传入其中。该对象可通过加载器生成的 options_map 获取。

实现

通常需要计算一组过滤后交易的最终余额,并在分层账户结构中报告这些余额。请参见下图。

为了创建分层呈现,我提供了一种称为“realization”的过程。一个 realization 是一个节点树,每个节点包含:

  • 账户名称,

  • 该账户的交易条目列表,

  • 该账户的最终余额,

  • 子账户名称到子节点的映射。

这些节点的类型为“RealAccount”,它也是一个包含其子节点的字典。代码库中所有 RealAccount 类型的变量按约定均以“real_”为前缀。realization 模块提供了遍历这些余额树并生成分层报告的函数,所有报告例程均使用这些函数。

例如,以下是一段将账户余额树输出到控制台的代码:

import sys
from beancount import loader
from beancount.core import realization

entries, errors, options_map = loader.load_file("filename.beancount")
real_root = realization.realize(entries)
realization.dump_balances(real_root, file=sys.stdout)

Web 界面

在引入 SQL 语法用于过滤和聚合交易条目之前,Beancount 唯一提供的报告是 bean-web 提供的 Web 界面。因此,bean-web 已发展到足以满足大多数用途和通用报告的需求。

报告与 Web

bean-web 的一个重要特性是,它应该只是一个薄薄的分发外壳,用于展示由 beancount.reports 层生成的报表。它曾经包含报表渲染代码本身,但后来我开始将所有报表代码提取到一个独立的包中,以便生成其他格式的报表,例如文本报表和 CSV 输出。这一工作基本已完成,但截至此时(2015 年 7 月),仍有一些报表仅支持 HTML 输出。这就是原因。

客户端 JavaScript

我希望最终在 Web 界面中加入更多的客户端脚本。然而,我认为在一段时间内我无法着手这项工作,至少要等到所有核心变更提案完成之后(例如,库存记账改进、结算拆分与合并等)。

如果您希望为 Beancount 做出贡献,改进 bean-web 或创建您自己的可视化图表将是一个绝佳的途径。

查询接口

当前的查询接口是一个原型,尚未经过与代码库其余部分同等程度的测试和优化。我一直在对其进行实验,并对改进 SQL 语言及其可生成的输出类型有许多想法。我认为它目前大约完成了 70%。

不过,它确实可以正常工作,尽管某些报表的指定方式略显笨拙,但它能产生有用的结果。

未来某个时候,它将被彻底重写。当我进行重写时,我会暂时保留当前的实现,以免现有脚本直接失效;我会推出一个 v2 版本的壳层。

设计原则

最小化可配置性

首先,Beancount 应尽可能少地提供选项。其次,命令行程序不应包含任何影响输入文件处理和语义的选项(影响输出或与脚本本身相关的选项是可以接受的)。

目标是避免功能膨胀。少量选项可以防止它们以复杂的方式相互作用,并减少诸多疑问。在本项目中,我没有提供大量自定义选项,而是倾向于默认提供最佳行为,即使这意味着功能更少。这是苹果的设计理念。我通常不倾向于在自己的项目中采用这种做法,但这次我特意选择了它。在这个过程中,我学到的是:即使功能更少,依然可以实现最初设定的大部分目标。

过多的命令行选项会使程序难以使用。例如,我认为 Ledger 就深受其害。我无法记住那些不常使用的选项,因此我更倾向于不使用它们。影响语义的选项应存在于文件本身中(也应尽量减少)。运行进程时提供的选项,仅应影响该进程本身。

较少的选项也使软件更容易重构。软件库演化的最大障碍是代码间复杂的相互作用。确保一组明确的不变量,将使日后拆分或重组功能变得容易得多。

因此,默认情况下,我会抵制那些非通用或对大多数用户无效的更改。另一方面,如果大规模更改能够很好地通用化且几乎不需要配置,那么它们更有可能被实现。

优先使用代码而非 DSL

Beancount 提供了一种简单的语法、相应的解析器和打印器,以及一组非常基础的数据记录类型,使用户能够轻松编写脚本来处理自己的数据,并利用 Python 环境中丰富的库。如果内置的查询工具不足以满足需求,我希望用户能够极其轻松地基于 Beancount 构建自己所需的功能。

这也意味着 Beancount 提供的工具无需支持每个人可能想要的所有功能。Beancount 的优势应在于其表示的一致性与简洁性,而非报告功能的丰富性。创建新的报表类型应当容易,而修改内部实现则应当困难。(当然,它提供了一套不断演进的查询工具,足以满足大多数用户的需求。)

相比之下,Ledger 系统的实现提供了以领域特定语言(DSL)定义的“表达式”来调用高级操作,这些表达式要么通过命令行提供,要么直接写在输入文件中。对用户而言,这种表达式语言是又一门需要学习的内容,而且它还可能带来限制,需要不断扩展和改进…… 我更倾向于尽可能避免使用 DSL,而改用 Python 中广为人知的语义,尽管这并非总是合理。

文件格式还是输入语言?

有人可能会疑惑,Beancount 的输入语法应被视为一种计算机语言,还仅仅是一种数据格式。我们试图解决的问题本质上是:如何让人类轻松创建从交易到分录再到账户与头寸的数据结构。

数据文件格式与简单的声明式计算机语言之间有何区别?

一个区别在于文件的预期编写者:是人类,还是计算机?Beancount 的输入文件至少应能由人类手动编辑或快速浏览。尽管我们正试图通过导入代码自动化这一过程——至少是其中令人不快的部分——但我们仍希望确保审查所有新增的交易,以保证其正确性。如果文件的预期编写者是人类,那么可以将此类输入归入计算机语言的范畴(大多数数据格式是为计算机编写而设计的)。

我们也可以通过其语义的表达能力来判断。Beancount 的语言并非设计用于执行计算,也就是说,你无法在其中定义新的“Beancount 函数”。但它具备数据类型。从这一点来看,它更像是一种数据格式。

这只是一个值得思考的问题,尤其是在添加新语义的背景下。

通过解析器生成器定义语法

其语言的语法应兼容常用的解析器生成工具。应能通过一个简单的词法分析器和某个语法输入文件来解析该语言。

Beancount 的原始语法是模仿 Ledger 的语法设计的。然而,为了尽量减少标记量,该语法难以用常规工具解析。通过对语法进行简单修改,例如为字符串、账户和商品添加专用标记,Beancount v2 的重写版本得以使用 flex/bison 语法分析器生成器。

这带来了重要优势:

  • 它使得对语法进行增量修改变得非常容易。使用没有明确定义语法的自定义解析器,往往很难快速原型化语法变更。

  • 它使得在其他编程语言中实现解析器变得更加容易。我最终希望能够在其他语言中解析 Beancount 输入文件。由于数据结构足够简单,应该很容易在其他语言中重新实现核心部分。

  • 编写和维护的代码量也大大减少。

(最终我计划创建一种文件格式,能够从单一输入生成多种语言的解析器。但这将在所有主要功能实现之后再进行。)

未来工作

带标签的字符串

目前,账户名称、标签、链接和商品均以简单的 Python 字符串表示。我尽量保持设计简洁。未来我计划对这些类型分别创建字符串的专用子类,但在此之前需要评估其性能影响。目前我还不完全确定这样能带来哪些具体功能上的好处。

错误信息清理

我正在考虑移除独立的“错误”列表,将其整合为自动产生的“Error”条目流。这样做的优点是错误可以与其他条目一起渲染输出,但意味着每次需要打印或查找错误时都必须处理整个条目列表(考虑到错误输出频率极低,这可能并非大问题)。

结论

本文档将持续演进。如果您对内部实现有任何疑问,请在邮件列表中提出。

参考资料

Beancount 的设计并未受以下文档启发,但您也可能对它们感兴趣:


  1. 我正在考虑引入一种非常受限的非平衡记账形式,使其在保持交易整体平衡的前提下,额外的记账项无法与常规平衡记账项交互(或求和)。

  2. 存在一个名为 operating_currency 的选项,但它仅用于为生成报表提供良好默认值,从不参与交易处理。它用于告知报表代码哪些商品应单独分列显示余额,例如。

  3. 在 Beancount 的早期版本中,Posting 曾拥有一个 'position' 属性并将其组合。我更喜欢这种扁平化设计,因为许多函数既适用于 Position 也适用于 Posting。可以将 Posting 视为“派生”自 Position,尽管从技术上讲它并非真正的继承。