Beancount 精度与公差
Martin Blais,2015 年 5 月,2025 年 5 月更新
http://furius.ca/beancount/doc/tolerances
本文档描述了 Beancount 如何处理交易余额检查和余额断言中数字的有限精度问题,并记录了在自动推断数字时可能发生的舍入行为。
引言
Beancount 将所有数字以十进制形式存储,即您在输入文件中输入的数字在计算机内存中被精确表示。我们不使用浮点数。其原因已在其他地方说明,本质上是为了能够进行精确求和,避免任何舍入误差。
在 Beancount 中,有四个领域涉及这些十进制数字的小数位数。本节将讨论这些方面:
-
公差:在执行余额检查计算时,我们必须考虑数字存储的精度,例如美元的 1 分。由于金融机构以有限精度存储货币,这意味着余额中会自然产生舍入。我们必须正确处理这种舍入,以便允许合理的舍入误差,同时检测超出此范围的错误所导致的不平衡。
-
插值:Beancount 语法允许用户省略某些数字,以便自动填充以方便使用。在计算这些缺失数字时,我们希望使用该货币的惯用精度,以确保显示的数字具有自然的数值,与机构自身计算的结果一致。
-
显示:在显示数字时,每种货币都有其惯用的小数位数来表示金额。如果我们显示的数字是除法运算的结果,则需要进行舍入,以避免向用户展示大量无关紧要的数字(这是不可取的)。
-
计算:在执行计算时,十进制数不像浮点表示那样受硬件实现的天然限制。我们为产生大量小数位的计算(如除法运算)指定其精度。
本文档将逐一讨论这些问题,Beancount 如何自动推断良好的默认值,以及如何通过配置来控制其使用的精度和公差。
定义
"精度":在 Beancount 的语境中,"精度" 一词曾被其原始作者略微误用。在本文中,我们将 "精度" 定义为:(a) 表示十进制数所需的小数位数,例如 12.356 的精度为 "3";或 (b) 该精度对应的最小数值示例,例如在配置中,精度为 "3" 可输入为 "0.01"。
"容差" 是一个直接使用的数值,例如 "0.0005"。它不受下面提到的 "容差倍数" 影响(你可以理解为 "容差倍数" 调整的是一个 "精度" 数值)。
容差:余额校验中的不精确性
Beancount 会自动强制要求输入文件中交易的分录金额总和为零。为了以现实的方式验证这一点,Beancount 必须容忍一定程度的不精确性。这是因为 Beancount 允许你 复现现实世界中的账户交易行为,而在现实世界中,机构出于实际原因会对金额进行四舍五入。
以下是一个示例:考虑一笔在两种不同货币(美元和欧元)之间进行的转账交易:
2015-05-01 * "Transfer from secret Swiss bank account"
Assets:CH:SBS:Checking -9000.00 CHF
Assets:US:BofA:Checking 9643.82 USD @ 0.93324 CHF
在此示例中,使用的汇率为 0.93324 美元/瑞士法郎,即每美元兑换 0.93324 瑞士法郎。该汇率由银行提供,精确到小数点后五位。将 9000.00 瑞士法郎按此汇率精确换算为美元,结果为 9643.82152501... 美元。同样,使用该汇率将美元换算为瑞士法郎也会产生不精确的结果:9643.82 × 0.93324 = 8999.9985768…。
以下是另一种可能发生此类舍入的情况:一笔涉及共同基金 fractional 股份的交易:
2013-04-03 * "Buy Mutual Fund - Price as of date based on closing price"
Assets:US:Vanguard:RGAGX 10.22626 RGAGX {37.61 USD}
Assets:US:Vanguard:Cash -384.61 USD
在此交易中同样发生了舍入:不仅基金的净资产值被四舍五入到最接近的美分($37.61),基金份额数量也被四舍五入,并由 Vanguard 以固定的小数位数(10.22626 个 VPMBX 单位)记录。整个交易的余额需要容忍一定程度的不精确性,无论你是通过计算股份价值(10.22626 × $37.61 = $384.6096386)还是通过从目标投入金额反推股份数量($384.61 / $37.61 = 10.2262696091)来验证。
从 Beancount 的角度来看,上述两个示例都是平衡交易。显然,如果我们希望将外部账户的交易准确地表示并复现到输入文件中,余额校验算法就必须允许一定的容差。
默认容差的确定方式
Beancount 会尝试从输入数据中 自动推导 每笔交易的容差,且对每笔交易 独立计算1。让我们再次检查上一个示例:
2013-04-03 * "Buy Mutual Fund - Price as of date based on closing price"
Assets:US:Vanguard:RGAGX 10.22626 RGAGX {37.61 USD}
Assets:US:Vanguard:Cash -384.61 USD
在此交易中,Beancount 将推导出:
-
RGAGX 的容差为 5 位小数,即 0.000005 RGAGX,
-
USD 的容差为 2 位小数,即 0.005 USD。
请注意,所使用的容差是 用户提供的最后一位精度数字的分数(通常是其一半)。这一数值完全从输入中推断得出,无需任何全局容差声明。同时请注意,容差是 针对每种货币分别计算 的。
请注意,尽管我们为 RGAGX 的单位推导了容差,但在本交易的平衡校验中并未使用它,因为第一条分录的“权重”是以 USD 表示的(10.22626 × 37.61 = 384.6096386 USD)。
那么这里发生了什么?各分录的权重被计算如下:
-
第一条分录:384.6096386 USD
-
第二条分录:-384.61 USD
这些金额按货币汇总(本交易的权重中仅有美元单位),结果得到一个残差值为 -0.0003614 美元。该值与美元单位的容差进行比较:|-0.0003614| < 0.005,因此该交易平衡。
价格与成本
为推断所使用的容差,交易中各条目声明的价格和成本金额将被忽略。这是因为这些金额通常比条目的基础金额具有更高的精度——有时这种额外精度是使交易平衡所必需的。这些值不应用于设定整个交易的容差。
例如,在以下交易中:
1999-09-30 * "Vest ESPP - Bought at discount: 18.5980 USD"
Assets:US:Schwab:ESPP 54 HOOL {21.8800 USD}
Income:CA:ESPP:PayContrib -1467.84 CAD @ 0.6842 USD
Income:CA:ESPP:Discount -259.03 CAD @ 0.6842 USD
此处唯一推断出的容差是加元的 0.005。(54 HOOL 在此情况下不产生任何容差,因为它是整数;下一节将解释这一点)。USD 没有从第一条目的成本(21.8800 美元)或其余条目的价格(0.6842 美元)中推断出任何容差。
整数金额
对于输入中的整数金额,容差不会被推断为 0.5,即以下情况应无法平衡:
2013-04-03 * "Buy Mutual Fund - Price as of date based on closing price"
Assets:US:Vanguard:RGAGX 10.21005 RGAGX {37.61 USD}
Assets:US:Vanguard:Cash -384 USD
换句话说,整数金额不会为其货币的容差确定贡献有效数字位数。
默认情况下,无小数部分的金额所使用的容差为零。因此,在此示例中,由于我们无法推断美元数值的精度(请记住,成本被忽略),该交易将无法平衡,因为其残差非零(|-0.0003614| > 0)。
您可以自定义每种货币以及所有货币的默认容差值(详见下文配置部分如何设置)。
(这种对整数金额的处理方式意味着,仅通过输入数字所能指定的最大精度为货币单位的 0.05;例如,输入数字如 10.72,其精度为 0.1,再乘以容差乘数 0.5,得到 0.05。另一方面,设置默认容差允许指定任意数值。)
解决歧义
一种罕见的情况是,对同一种货币输入了多个不同的精度。此时,将采用所推断输入容差中最大(最粗略)的一个。
例如,如果我们希望将收入追踪到分以上,可以这样写:
1999-08-20 * "Sell"
Assets:US:BRS:ESPP -81 HOOL {26.3125 USD}
Assets:US:BRS:Cash 2141.36 USD
Expenses:Financial:Fees 0.08 USD
Income:CA:ESPP:PnL -10.125 USD
本例中,我们拥有的美元金额为 2141.36、0.08 和 -10.125,分别推断出容差为 0.005 或 0.0005。我们选择最粗略的容差:该交易允许 0.005 美元的不精确性。
默认容差配置
当交易中的数字不足以在本地推断容差时,我们将回退到某个默认容差值。如前所述,这种情况可能因 (a) 与所需货币相关的数字为整数,或 (b) 输入中完全缺少足够的数字而发生。
默认情况下,所有货币的默认容差均为零。可通过如下选项进行设置:
option "inferred_tolerance_default" "*:0.001"
可以通过向选项提供相应的货币,进一步为每种涉及的货币细化默认容差,如下所示:
option "inferred_tolerance_default" "USD:0.003"
如果提供了货币特定的容差,则将优先使用该值而非全局值。
此选项的一般形式为:
option "inferred_tolerance_default" "<currency>:<tolerance>"
明确说明:此选项仅在无法推断容差时使用。如果你的交易中存在过大的舍入误差,且交易数字本身能推断出某个容差值,则此设置将被忽略(例如,试图通过设置更大的值来解决此问题将无效)。如需放宽容差,请参见下一节中的“tolerance_multiplier”。
(注意:我曾考虑为商品指令专门添加一个元数据字段来实现此功能,但这会违背元数据仅用于用户和插件的不变性原则,因此我目前仍保持原状。)
容差乘数
我们之前已说明,当未显式提供容差值时,它会从交易条目中的数字推断得出。默认情况下,这些数字中最小位数会被除以二,以获得容差值,因为我们假设所复现交易的机构会进行舍入,因此误差不应超过一半。
但现实中,你可能会发现舍入误差有时会超过此值。为此,我们提供了一个选项来设置推断容差的乘数:
option "tolerance_multiplier" "0.6"
此值将覆盖默认乘数0.5。在本例中,对于仅包含如 24.45 CHF 这类数值的交易,CHF 的推断容差为 ±0.012 CHF。
注意:我认为你几乎无需更改此值(我自己也一直使用默认值),但为以防万一,我们仍提供了此选项。
从成本推断容差
还有一个功能可扩展从交易中推断的最大容差,以包含由以成本持有或按价格转换的条目所推断的成本货币值。这些条目可通过将单位的最小位数乘以成本或价格值,再取其一半,从而推断出容差值。
例如,若某条目附有金额 "2.345 RGAGX {45.00 USD}",则意味着容差为 0.001 × 45.00 / 2 = 0.045 USD。所有以成本持有或由价格转换的条目所可能产生的舍入误差总和将被计算,并将所得容差添加到用于确定该商品应使用容差的候选列表中(我们采用所有推断容差中的最大值)。
你可按如下方式启用此功能:
option "infer_tolerance_from_cost" "TRUE"
启用此标志只会使容差可能变宽,绝不会变小。
余额断言与填充
在其他一些地方也需要进行近似比较。余额断言也会比较两个数值:
2015-05-08 balance Assets:Investments:RGAGX 4.271 RGAGX
此断言表示该账户的累计余额为 4.271 单位 RGAGX,误差范围为 ±0.001 RGAGX。因此,累计值在 4.270 RGAGX 至 4.272 RGAGX 之间均视为符合断言。
容差会自动推断为余额断言中数字的最小位数单位。若需更宽松的断言,可声明如下:
2015-05-08 balance Assets:Investments:RGAGX 4.27 RGAGX
此断言将接受 4.26 RGAGX 至 4.28 RGAGX 之间的数值。
请注意,推断出的公差也会受到上述讨论的公差乘数的扩大影响。
触发填充的公差
填充指令会自动插入交易,以使账户余额与后续的余额断言保持一致。只有当余额与预期值不同时,才会触发插入操作,且此操作的公差行为与余额断言完全相同。
余额断言中的显式公差
Beancount 支持指定显式的公差值,例如:
2015-05-08 balance Assets:Investments:RGAGX 4.271 ~ 0.01 RGAGX
此功能的引入是因为在 Vanguard 投资账户中观察到一些异常现象:四舍五入似乎遵循奇怪的规则,导致余额不匹配。
迁移现有输入
从交易数字中推断公差值通常足以使现有文件无需修改即可正常工作。在使用本文所述方法处理旧文件时,可能会出现新的错误,但这些错误要么指向之前未检测到的输入错误,要么可以通过简单地增加适当位数来修复。
作为例证,迁移作者的大型输入文件是一个相对顺利的过程,耗时不到一小时。
为了便于过渡,您可能希望将所有货币的默认公差更改为 Beancount 之前使用的值,如下所示:
option "inferred_tolerance_default" "*:0.005"
我建议您先从这里开始,修复文件中的所有错误,然后再移除此设置并修复其余错误。这将使您更容易适应这种新行为。
以下是一个修复新错误的示例:将来自“整数金额”部分的这个新失败交易:
2013-04-03 * "Buy Mutual Fund - Price as of date based on closing price"
Assets:US:Vanguard:RGAGX 10.21005 RGAGX {37.61 USD}
Assets:US:Vanguard:Cash -384 USD
通过插入零来提供一个本地推断的值,如下所示:
2013-04-03 * "Buy Mutual Fund - Price as of date based on closing price"
Assets:US:Vanguard:RGAGX 10.21005 RGAGX {37.61 USD}
Assets:US:Vanguard:Cash -384.00 USD
即可消除余额检查的警告。
插值:用整数填补空白
Beancount 允许用户省略某些数字,由系统自动填充,即插值。例如,此处的利润值留空,由系统自动填充:
2013-04-03 * "Buy Mutual Fund - Price as of date based on closing price"
Assets:US:Vanguard:RGAGX -10.22626 RGAGX {37.61 USD}
Assets:US:Vanguard:Cash 645.61 USD
Income:US:Vanguard:Profit
虽然 Beancount 可以计算出精确的差额,但实际中,经纪商通常会以两位小数精度计算利润/亏损,即 $216.00,即使未四舍五入的数值为 $261.0003614。Beancount 会在特定精度下对数字进行四舍五入,而该精度也取决于金额所代表的货币。因此,我们会在此处插入“216.00 USD”。
推断数字的精度
Beancount 能够推断输入中缺失的某些数字。例如,此交易中的第二笔分录会由 Beancount 自动进行“插值”:
2014-05-06 * "Buy mutual fund"
Assets:Investments:RGXGX 4.27 RGAGX {53.21 USD}
Assets:Investments:Cash
根据第一笔分录计算得出的插入金额为 -227.2067 USD。您可能会问:这个数值是以何种精度插入的?是完整保留 227.2067 USD,还是四舍五入到分,例如 227.21 USD?
这取决于为该货币推断出的公差。在本例中,无法推断出任何公差(除成本金额外,未提供其他 USD 金额,而成本金额在推断公差时会被忽略),因此我们必须使用默认公差。
如果输入文件中未覆盖默认容差(因此容差为零),则将使用完整精度;不会进行四舍五入。这将产生以下交易:
2014-05-06 * "Buy mutual fund"
Assets:Investments:RGXGX 4.27 RGAGX {53.21 USD}
Assets:Investments:Cash -227.2067 USD
请注意,如果可以从该交易中的其他数字推断出容差,则会使用该容差进行四舍五入,例如在本例中,由于“佣金”项中的 9.95 美元,现金项被四舍五入到两位小数:
2014-05-06 * "Buy mutual fund"
Assets:Investments:RGXGX 4.27 RGAGX {53.21 USD}
Expenses:Commissions 9.95 USD
Assets:Investments:Cash -237.16 USD
然而,如果无法推断出容差,且美元的默认容差设置为 0.001,则该数字将在插入前被量化为 0.001,即 227.207 美元将被存储为:
option "default_tolerance" "USD:0.001"
2014-05-06 * "Buy mutual fund"
Assets:Investments:RGXGX 4.27 RGAGX {53.21 USD}
Assets:Investments:Cash -227.207 USD
最后,如果您启用了四舍五入误差累积功能,则该条目的金额将反映正确的残差,考虑了自动插入的四舍五入金额:
option "default_tolerance" "USD:0.01"
option "account_rounding" "Equity:RoundingError"
2014-05-06 * "Buy mutual fund"
Assets:Investments:RGXGX 4.27 RGAGX {53.21 USD}
Assets:Investments:Cash -227.207 USD
Equity:RoundingError 0.0003 USD
保存四舍五入误差
如前所述,交易不必完全平衡,允许存在轻微的不精确。但这让一些人感到困扰。如果您希望跟踪和衡量容差所允许的残差金额,Beancount 提供了一个选项,可自动插入条目,使每笔交易完全平衡。
您可以通过以下方式启用此功能:
option "account_rounding" "Equity:RoundingError"
这将告诉 Beancount 插入条目,以补偿四舍五入误差,并计入“Equity:RoundingError”账户。例如,启用该功能后,以下交易:
2013-02-23 * "Buying something"
Assets:Invest 1.245 RGAGX {43.23 USD}
Assets:Cash -53.82 USD
将被自动转换为:
2013-02-23 * "Buying something"
Assets:Invest 1.245 RGAGX {43.23 USD}
Assets:Cash -53.82 USD
Equity:RoundingError -0.00135 USD
您可以验证此交易已完全平衡。如果交易本身已完全平衡(大多数交易都是如此),则不会插入任何条目。请注意,以这种方式插入的四舍五入误差不会像其他插值数字那样被四舍五入。
最后,如果您要求所有账户必须显式声明,则应记得在文件中的适当日期声明四舍五入账户,如下所示:
2000-01-01 open Equity:RoundingError
显示:用于显示的四舍五入数字
在显示数字时,看到一个由除法运算产生的、带有异常多小数位的数字3是非常不合理的。例如,成本金额显示为“134.2374736427639678237437 美元”对读者毫无意义,额外的小数位数值极小且无实际意义;我们更希望展示“134.24 美元”。因此,必须将数字四舍五入至特定精度。
用于渲染这些数字的小数位数取决于以下几个因素:
-
数字所代表的货币。 美元通常使用 1 分($0.01)的分辨率和两位精度。日元通常使用整数(1 日元是一个很小的单位)。共同基金股份通常至少使用三位小数,因为一股可能代表相对较大的金额。汇率可能使用多达五位精度(例如 USD/JPY)。
-
数字的上下文。 当表示另一种商品单个单位的成本时,有时我们希望该成本使用的精度与该数值总金额的常用精度不同。
截至 2025 年,新增了一个配置选项,允许您通过示例指定特定货币的预期精度,如下所示:
option "display_precision" "USD:0.01"
option "display_precision" "AUD:0.01"
计算:有限精度下的运算
最后,当对十进制数进行计算时,必须进行四舍五入。Beancount 中的所有数字在内部均以十进制形式表示。
对于没有学习过计算机科学的人来说,这可能是一个不熟悉但非常重要的主题。绝大多数情况下,计算机使用浮点数,这些是实数的二进制近似值。由于这些表示法可用的位数有限,四舍五入会自然发生。为了表示和计算十进制数,我们使用了一种软件实现,它不受硬件限制,因此能够以任意精度表示计算结果(但代价是——速度较慢)。
对于十进制数,某些运算会产生大量小数位,我们必须决定将结果四舍五入到何种精度(否则我们将花费大量空间来表示极小的数字)。
产生大量小数位的计算会以 28 位小数的精度执行(这是 Python 的 IEEE 十进制实现上下文中的默认精度)。这已经足够充分,因为上述方法很少会让这类数字在整个系统中传播:容差机制使我们能够精确记录用户声明的金额,只有自动推导的价格和成本才可能产生超出实际需求的精度,从而可能渗入系统其余部分的聚合计算中。
有关此问题的更深入讨论,请参阅十进制定点与浮点运算和每位计算机科学家应了解的浮点运算文档。
参考资料
导致此实现的原始提案可在此处找到。特别是,该提案强调了其他系统为解决此问题所做的尝试。此外,在专门讨论此主题的邮件列表中也有一些相关讨论。
请注意,长期以来,Beancount 对所有货币使用固定的容差值 0.005。一旦本文件所述方法被实现,该固定值即被移除。
此外,对于 Balance 和 Pad 指令,过去默认设置了一个容差选项,值为任何单位的 0.015。随着本文件所述变更的合并,该选项已被弃用。
历史备注
以下是截至 2016 年 3 月 Beancount 中数字显示状态的概览,来自邮件列表:
首先,必须理解这些数字在内存中的表示方式。它们使用十进制表示法,不仅能准确表示十进制数(与二进制浮点数的近似表示相对),还包含特定的精度。也就是说,数字 2.00 与 2.0 和 2.000 的表示方式不同。这些数字“记住”了它们所表示的精度。这一点很重要。当我提到将数字以“自然精度”显示时,我指的是它们实际表示的精度,例如,2.0 显示为 "2.0",2.000 显示为 "2.000"。
接着,有两个截然不同的主题:(1)容差,和(2)精度。
-
"容差" 是用于确定交易平衡时可接受的不精确程度的值。这在验证阶段使用,以决定允许多大程度的宽松。它不应影响数字的显示方式。
-
"精确度"这个说法或许有点用词不当:我指的是数字应显示多少位小数。
曾经,在账本引擎尚未完成时,Beancount 中这些概念并未明确定义,我也未能一致地处理这些问题。后来,我逐渐明确了需要做什么,于是创建了一个名为 "DisplayContext" 的类,用于为每种货币设置合适的数字显示精度(每种货币通常有其最常用的显示精度,例如 USD 为两位、MXN 为一位、JPY 为零位,而在报表中我们通常可以安全地将实际数值四舍五入到该精度)。因此,该 DisplayContext 实例会在解析器中自动实例化,为了避免用户必须手动设置这些值——为了让 Beancount 默认 "正确行事"——它能够累积所遇到的数字,并从输入中推断出最常用和最大的小数位数,然后将其作为默认的数字显示位数。最常用的格式/位数用于显示数量,而观察到的最大位数则用于显示成本和价格。此外,该类还具备将数字对齐小数点以及在千位插入逗号的功能。它将格式控制与数字本身分离。
大多数渲染数字的代码都使用 DisplayContext(通过 to_string() 方法)将数字转换为字符串,例如网页界面和显式文本报表。但并非全部如此……这里有一些历史原因……SQL shell 使用了一些旧的专用代码来渲染数字,而我从未费心将其迁移到 DisplayContext 类。对此有一个待办事项。它迟早需要被迁移,但我至今仍未处理,因为我对 SQL 查询引擎有更大的计划——计划彻底重写它并加入诸多改进,因此我打算届时一并完成。如果你还记得,SQL 查询引擎原本只是一个原型,虽然它能正常工作,但缺乏充分的单元测试覆盖。我当初的目标是通过实际使用来发现哪些功能有用,然后再开发一个更好、更完善的 v2 版本。
现在,关于 PRINT 命令……它并不打算作为报表工具。打印器的目的是准确输出交易内容的原始表示形式。为此,它必须以数字的"自然"精度渲染,以确保在重新读入时能解析为完全相同的数字(即使包含尾随零)。因此,PRINT 命令刻意不使用从输入文件推导出的 DisplayContext 实例进行渲染。我本可以更改这一点,但那样会破坏往返一致性:使用显示上下文进行格式化所导致的四舍五入,可能会使交易余额不再平衡。
如你所见,这并非一个显而易见的话题……希望以上说明能帮助你理解 Beancount 在渲染数字精度时的行为。
注意:"default_tolerances" 已于近期更名为 "inferred_tolerance_default",因为原名称过于笼统且容易引起混淆。旧名称仍可使用,但会发出警告。
我刚从你的评论和一些代码搜索中注意到,"render_commas" 选项已不再使用。我不确定这是如何发生的,但我将立即修复它,并将从输入文件推导出的 DisplayContext 的默认值设为启用。
我应该也将 SQL shell 的渲染改为无论未来计划如何都使用显示上下文,以确保其与其他部分的渲染保持一致。我不确定这个周末能否完成,但我会上一个工单,点此。
希望这能帮到你。如果以上内容不够清晰,欢迎随时提问。如果这些内容不够明显,我深表歉意……这里涉及的历史相当复杂,代码量也很大。我应该重新审查选项的命名,我认为所有容差选项都包含“tolerance”一词,但目前尚无用于覆盖渲染行为的选项;当我添加这些选项时,它们也应使用统一的命名。
截至 2025 年 5 月,容差和精度的配置设置正在审查中。这长期以来一直是一个痛点。