ADR-0007: AVERAGE booking realizes a single weighted-average pool
Status
Accepted
Context
Rustledger supports an AVERAGE booking method on accounts (open ... "AVERAGE") — a weighted-average-cost (WAC) model where all lots of a commodity share one running cost. Python Beancount notably does not implement AVERAGE (it errors with "AVERAGE method is not supported"), so there is no upstream oracle.
The original implementation was broken in two ways, found by dogfooding:
- A partial sale across multiple lots returned every matched lot from
reduce_average, which the booking consumer (book.rs) expanded into one reduction posting per lot — silently emptying the whole position and booking a garbage capital gain. - The account balance showed each purchase lot separately (
10 {150} 10 {170} -5 {160}) instead of a single pool.
We surveyed how WAC is actually modeled — hledger SPEC-lots, Beancount PR #591 ("AVERAGE booking ≡ {*} lot merging"), GnuCash "scrub by average cost", ERPNext / Odoo (AVCO), QuickBooks Desktop. They are unanimous: AVERAGE is a single running weighted-average pool, recomputed on each acquisition; a sale draws COGS at the current average and leaves the average unchanged. Keeping separate lots is the FIFO / specific-identification model, not average.
Two design risks that make AVERAGE "rare in PTA" were investigated and found not to apply to rustledger:
- Rolling-state balance assertions. Editing a past transaction shifts the running average for every later one, which would make cost-bearing balance assertions history-dependent. Rustledger's
balanceassertions are quantity-only (they ignore the{cost}annotation today), so there is no rolling-state interaction to manage. - Decimal precision. Repeating-decimal averages (e.g.
2450/15 = 163.333…) compute exactly underrust_decimal(28 digits); gains were already correct to full precision.
Decision
Model an AVERAGE account as one weighted-average pool, realized at the balance, while the journal keeps the real per-lot purchase costs (matching hledger: the pool is rewritten, the journal is not).
Inventory::reduce_averagereturns a single synthetic matched lot of the reduced quantity at the average cost, and keeps the average cost on the remainder.Inventory::merge_averagecollapses all cost-bearing lots of a currency into one weighted-average lot (Σ(units·cost) / Σ units); cost-less (cash) positions and net-zero currencies are handled cleanly.- The query realizes an AVERAGE account as a single pool: the query
AccountInfocarries the account's booking method, andsum(position)over a group whose postings all belong to one AVERAGE account callsmerge_average. FIFO / LIFO / STRICT / HIFO / NONE accounts are unaffected.
Consequences
- The realized balance of an AVERAGE account is a single
N COMM {avg}lot, as every other WAC implementation produces. Capital gains use the running average. - The journal (
JOURNAL, the raw postings) still shows the true acquisition costs, preserving the audit trail. Only aggregated balances merge. - The realization merge is applied in the per-account aggregation path. A bare
sum(position)that spans multiple accounts is left unmerged (merging across accounts is ill-defined); this is a documented seam, not a correctness issue. - Because
balanceassertions are quantity-only, AVERAGE introduces no rolling-state assertion problems. If cost-aware balance assertions are added later, their interaction with the rolling average must be revisited.
Amendment: one pool per side (#2393)
"All lots of a commodity share one running cost" holds while an account is on one side. An account can hold a long and a short in the same commodity, and pooling both sides was wrong: holding 5 at 102 and a short of 2 at 101, a sale of 2 was booked at 102.67 instead of 102, the short vanished into the merged remainder, and a cover of the short at 95 reported a loss of 8 instead of a gain of 6. The surveyed systems model a single position, so they are silent on this; the rule follows from the other booking methods and {*}, which all match only lots of the opposite sign.
So an AVERAGE reduction pools the SIDE it takes from, the lots whose sign is opposite the reduction's, and leaves the other side unchanged. A single-sided account, the case this ADR was written for, is unaffected.
merge_average (the sum(position) realization above) is not changed. It merges a sum of postings, where every sale is already a negative lot at the pool's cost, so sign cannot separate sales from shorts; on a mixed account that view is still wrong, tracked in #2394.
Prior art
- hledger
SPEC-lots— single running pool; pool cost rewritten on acquisition. - Beancount PR #591 / issue #213 — AVERAGE ≡ automatic
{*}lot merging. - GnuCash average-cost scrub; ERPNext Moving Average; Odoo AVCO; QuickBooks Desktop average costing.