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:

  1. Convert every explicit posting to its weight: the amount itself, or - when a cost is present - qty * unit (for @) or sign(qty) * total (for @@) in the cost commodity.
  2. Sum weights per commodity.
  3. No elided posting: every sum must be exactly zero, otherwise transaction does not balance: remaining 3 INR.
  4. One elided posting: replace it with one posting per non-zero commodity, each equal to minus that sum (so one Equity:Opening line 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:

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:

  1. identity (base == quote is 1);
  2. direct or inverse: the latest edge dated on or before date among base->quote and the reciprocal of quote->base; the more recent one wins;
  3. 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:

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:

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:

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:

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:

  1. its id appears as an import tag anywhere in the journal (re-importing the same file), or
  2. 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.