Design notes and algorithms
ledgerline is a pipeline of small, independently testable stages. Nothing in the pipeline uses floats;
every quantity is a decimal.Decimal with a 50-digit context, so sums and price conversions are exact and
only the final display step rounds (half-up, to the commodity's declared precision).
text --parser--> Journal (typed AST) --booking--> balanced, date-sorted Transactions
|
query ---------> Entries (one per matching posting)
prices (PriceDB) -> Valuer
|
aggregate by account x period --> Report --> table | csv | json
1. Parsing and the error model
parser.py is line oriented. The first column decides what a line is: blank, comment (;/#), digit (transaction),
~ (periodic) or a directive word. Indented lines are consumed as the notes/postings of the preceding header.
Errors are never raised mid-parse. Each problem becomes a LedgerError(message, file, line, col, length, text, hint)
and parsing resynchronises on the next top-level line, so one check run reports every syntax problem. The
renderer prints the offending line and underlines exactly the bad token:
error: misplaced thousands separator in '1,00.50'
--> examples/broken-syntax.journal:13:20
|
13 | Expenses:Food 1,00.50 INR
| ^^^^^^^
= hint: use 1,000 (western) or 1,00,000 (indian) grouping, or no commas
Columns are tracked by carrying the absolute offset of every substring through the amount/cost/assertion splitting
(Parser.amounts), so the caret is right even for a bad cost on a line that also has an assertion.
Booking errors (unbalanced, strict-mode, assertions) are a second phase: they need a fully parsed journal and
reuse the same LedgerError with the line numbers recorded on every AST node. If phase one fails, phase two is not
attempted (its errors would be noise).
The parser also keeps, per file, an ordered list of items (raw comment/blank/directive lines and parsed
transactions). That is what lets fmt rewrite a file without losing comments.
2. Balancing (booking.py)
Each transaction splits its postings into three groups: real, [balanced virtual] and (unbalanced virtual).
The first two are resolved independently:
- Convert every explicit posting to its weight: the amount itself, or - when a cost is present -
qty * unit(for@) orsign(qty) * total(for@@) in the cost commodity. - Sum weights per commodity.
- No elided posting: every sum must be exactly zero, otherwise
transaction does not balance: remaining 3 INR. - One elided posting: replace it with one posting per non-zero commodity, each equal to minus that sum
(so one
Equity:Openingline can absorb INR and USD). Two elided postings is an error pointing at the second.
Worked example:
2026-01-01 Opening
Assets:Bank 5,00,000 INR
Assets:Broker:Cash 3,000 USD
Equity:Opening
Weights: 500000 INR, 3000 USD. The elided posting becomes Equity:Opening -500000 INR and
Equity:Opening -3000 USD. With a cost, 10 AAPL @ 180.00 USD against Assets:Broker:Cash (elided) yields
-1800.00 USD; the AAPL posting still contributes 10 AAPL to the account balance - the cost only participates in
balancing and in the price database.
Assertions in date order
After balancing, every posting of every transaction is flattened to (effective date, file order, posting index)
where the effective date is the posting's date: tag or the transaction date. The list is sorted on that key and swept
once while maintaining balance[account][commodity]. At a posting with = X, the running balance is compared to X.
2026-01-05 Check <- appears first in the file
A 0 X = 15 X
2026-01-01 First
A 10 X
B
2026-01-03 Second
A 5 X
B
Sorted order is First, Second, Check: the balance is 10, then 15, and the assertion passes. Because of the sweep, an assertion between two transactions sees exactly the postings dated before it, however the file is arranged. (Assertions are skipped when balancing failed, as the amounts they depend on are wrong.)
3. Prices and valuation (prices.py, reports.Valuer)
The PriceDB holds directed edges (base, quote) -> [(date, order, rate)] from two sources:
Pdirectives,- implicit prices: every posting with a cost contributes
rate = unit(for@) ortotal / |qty|(for@@) on the transaction (or posting) date.
On the same date an explicit P wins over an implicit price because it has a later order.
rate(base, quote, date) tries, in order:
- identity (
base == quoteis 1); - direct or inverse: the latest edge dated on or before
dateamongbase->quoteand the reciprocal ofquote->base; the more recent one wins; - one-hop chain through any intermediate commodity
m:rate(base,m) * rate(m,quote), where each leg is a direct/inverse lookup. If several intermediates work, the one whose older leg is freshest is chosen.
Chains of three or more legs are deliberately not searched: they hide errors, and a missing direct price is almost always something the user wants to know about. When no rate exists the amount is left in its own commodity.
Worked example (P lines: AAPL 200 USD and USD 80 INR, both 2026-01-01):
AAPL -> INR = 200 * 80 = 16000 via USD, and INR -> AAPL = 1/16000.
Valuation modes
--value INR converts amounts. When the price is looked up is --value-at:
end(default): sum the raw balance per commodity first, then convert at the end of the report period (each column of a--monthlyreport at its own month end). With no-e, the single-column report date is the latest date in the journal (transactions or prices).then: convert each posting at its own date, then sum (historical cost-of-living style reports).
Example with P 2026-01-01 USD 79, P 2026-01-31 USD 80, P 2026-02-28 USD 90, 100 USD received on 01-15 and
50 USD on 02-20 (150 USD total):
| Command | Result |
|---|---|
bal Assets -e 2026-02 --value INR |
only the January posting is in range, priced at 01-31: 100 x 80 = 8,000 |
bal Assets --value INR |
150 x 90 = 13,500 (latest price, 02-28) |
bal Assets --value INR --value-at then |
100 x 79 + 50 x 80 = 11,900 |
-B/--cost replaces each posting's amount with its weight (purchase cost) before any aggregation.
4. The report engine (reports.py)
A query produces Entry rows (date, account, commodity, quantity). aggregate() buckets entries per account into
Period columns:
- flow reports (
bal,is,cf): entries withperiod.start <= date < period.end; - cumulative reports (
bs,bal -H): entries withdate < period.end.
Period columns are the natural calendar month/quarter/year (clipped only by an explicit -b/-e), so the
valuation date of a column is always the real period end. Flow reports with several columns get a Total column,
computed by aggregating the whole range as one period (so --value converts the total at the range end instead of
summing converted columns).
bal renders either a tree (each row is the inclusive subtotal of its subtree; a parent is hidden only when it and
all of its descendants are zero) or flat names; --depth N collapses deeper accounts into their depth-N ancestor.
Income statement and balance sheet
is: Revenues (Income:*, sign flipped to read positive), Expenses, and Net income = revenues - expenses per column.
bs is cumulative and adds three derived rows so that the accounting equation always visibly holds:
- Current earnings = -(sum of Income and Expense balances to date): profit not yet closed into equity.
- Total equity = equity accounts + current earnings.
- Unrealised gains / commodity differences: whatever remains of
assets - liabilities - total equity. At cost (-B) it is zero. Under--valueit is the gain from marking holdings to market, e.g. buying10 ABC @ 100 USDand pricing ABC at 120 USD gives a 200 USD row, andNet worth == Total equityagain.
Cash flow
Cash accounts are those matching --cash (default ^assets:.*(bank|cash|checking|savings|wallet)). For every
transaction that touches cash, each non-cash posting is a counterparty; its cash effect is -weight. It is filed
under:
- Operating: Income and Expense accounts,
- Investing: other Assets,
- Financing: Liabilities and Equity (credit-card settlement shows here because the spend itself never hit cash),
- Exchange: whatever is left over once counterparties are subtracted. A pure currency exchange between two cash
accounts has no counterparty; its cash deltas (
+10 USD,-800 INR) land inExchange:Currency exchange.
The residual rule makes the statement reconcile: Net cash flow always equals the change in cash, and the report
prints Cash at start / Cash at end computed independently from the cash accounts so you can see that.
Worked example: salary 5,000 into the bank and rent 2,000 out of it in January, with rent budgeted: Operating =
+5,000 - 2,000, Net cash flow = 3,000, and with a 10,000 opening balance (Financing, from Equity:Opening) the
closing cash is 13,000.
5. Budget
For each periodic transaction, occurrences() enumerates dates start + k * interval (month arithmetic keeps the
anchor day, clamping to the last day of shorter months: monthly from the 31st gives 31 Jan, 28 Feb, 31 Mar, ...).
Each occurrence adds the transaction's explicitly-amounted postings to the budget of the period containing it, honouring
to. Rows are the budgeted accounts; actual for a row is every posting on that account or any sub-account, so a
budget on Expenses:Food is compared with Expenses:Food:Groceries + Expenses:Food:Dining.
% used = actual / budget; 100% or more is red, 80% or more yellow. The Total row sums only budget rows that have
no budgeted ancestor, so nothing is double counted.
6. CSV import and de-duplication (importer.py)
rules.toml maps columns (date_column, date_format, description_column, and either a signed amount_column
or debit_column/credit_column) and holds ordered [[rule]] blocks: a regex on the narration, the counter account,
and optional payee, status, tags. The first matching rule wins; unmatched rows go to default_account with
status ! so they stand out in stats and reg status:!. Sign handling: the amount is always from the statement
account's point of view (positive = balance increases); invert = true flips it for statements where charges are
positive.
Each row gets an id sha1(date | amount | narration | n)[:12] where n is the occurrence index among rows with
identical date/amount/narration, so two identical CHAI 20.00 rows on one day are both kept but never imported twice.
The id is written into the transaction as the tag import:<id>. A row is skipped when:
- its id appears as an
importtag anywhere in the journal (re-importing the same file), or - the journal has a transaction on that date with a posting to the statement account of the same amount and a description equal to the narration (a row you already typed by hand). Each hand-entered transaction cancels at most one CSV row.
7. Formatter idempotency
fmt renders from the AST, never by patching text, so the output is a pure function of the parsed journal:
widths are computed per transaction, amounts come from Journal.show(amount, exact=True), and the commodity format
used is max(declared precision, the amount's own scale). Because inference takes the maximum precision seen and
formatting only ever pads up to it, a second run sees the same maxima and produces identical bytes. The test-suite
checks format(format(x)) == format(x), that parsing the output yields the same transactions, and that the shipped
examples/ pass fmt --check.