Skip to content

lots: balance disposals at cost basis (historical cost accounting) - #2744

Merged
simonmichael merged 4 commits into
mainfrom
lots-basis-balancing
Sep 23, 2026
Merged

simonmichael merged 4 commits into
mainfrom
lots-basis-balancing

Conversation

@simonmichael

Copy link
Copy Markdown
Member

Lot disposals now balance at cost basis, with a single realised gain posting; the generated equity:unrealised-gain counter posting is gone. This is the historical cost accounting convention (what hledger 1 users have always done), and it keeps the accounting equation balanced through disposals: bse nets to zero after a full sell-off, which it did not before (#2731). Unrealised gains are not posted, but can still be reported from market prices (holdings or bal --gain).

Three commits:

  1. Balance disposals at cost basis; drop the unrealised-gain posting. The balancer sets aside gain postings (tagged _ptype:gain before balancing, by account type or heuristically), which is equivalent to basis balancing since q*B + q*(T-B) = q*T and works before lot matching has determined B; the gain amount is generated or checked after lot matching. Inferred amounts are unchanged. Tagging also runs inside the balancer (so hledger add/web and balance-assignment entries work) and before --auto's preliminary balancing. This reinstates the February approach (76696ca/24412e6e9) that 80b320a had replaced. Breaking: user-written unrealised-gain counter postings (the former "style 4") now leave the entry unbalanced; _ptype:rgain/_ptype:ugain become _ptype:gain; a gain posting in a lot transfer is rejected.

  2. print shows inferred cost basis; amountless gain postings are filled in. Plain print and -x show lot postings with the basis annotation lot processing inferred (10 AAPL {$50} @ $50, -5 AAPL {2026-02-01, $50} @ $70, {} for a multi-lot disposal), so print output is self-describing and re-reads without the commodity directive under the default method; --export and rewrite keep entries as written. A gain posting written without an amount is filled in with the calculated gain (at most one per entry; previously an error).

  3. -B converts lot postings at cost basis; add --value=transacted. -B/--value=cost converts amounts with a cost basis to that basis, else to transacted cost as before, so cost reports agree with balancing (bse -B balances, a sold-out lot account shows 0, --gain is value minus basis); non-lot amounts are unaffected. --value=transacted keeps the transacted-cost view (proceeds, net cash invested). The conversion is applied before lot detail is collapsed so multi-lot disposals stay exact. Breaking for lot users' -B output.

Docs: manual (Gain postings, Recording gains, roi note, first lots example, posting types table, migration sections, Reporting at cost, --value), SPEC-lots, SPEC-finalising, SPEC-print, SPEC-special-postings, DECISIONS, PLAN-ugain (reframed as an optional future revaluation layer), Roi.md, Check.md, Print.md; examples lots.journal and irr.journal. Tests: expectations regenerated after audit (only removed ugain lines, retagged gain postings, and added annotations), plus 20 new tests in lots-dispose.test and lots-errors.test.

Follow-ups not included: hledger-ui's B toggle converts the already-collapsed journal, so multi-lot disposals there use transacted cost; the style-2 heuristic still can't separate an undeclared and undetected gain account from a cash fee (documented, declare it in that case).

…posting

Disposal entries now get a single generated (or user-written, checked)
realised gain posting, and balance at cost basis: the disposed units
count as quantity x basis, and the gain posting accounts for the
difference from the transacted proceeds. The equity:unrealised-gain
counter posting is no longer generated. This is historical cost
accounting, as hledger 1 users have always recorded gains; unrealised
gains are not posted but can still be reported from market prices.

Previously disposals balanced at transacted cost, using an
unrealised-gain posting intended as the reclassifying half of a
mark-to-market scheme whose revaluation postings were never
implemented. Without them it left a permanent phantom balance in
equity:unrealised-gain, so bse did not balance even after all holdings
were sold (#2731).

Implementation: the balancer sets aside gain postings (tagged
_ptype:gain before balancing, by account type or heuristically),
which is equivalent to basis balancing since q*B + q*(T-B) = q*T, and
works before lot matching has determined B; the gain amount is
generated or verified after lot matching. Inferred amounts are
unchanged. The tagging also runs inside the balancer, so hledger add
and hledger-web handle explicit gain postings, and runs before --auto's
preliminary balancing (which previously failed with an explicit gain
posting). A gain posting in a non-disposal (eg a lot transfer) is now
rejected. User-written unrealised-gain counter postings (the former
"style 4") now leave the entry unbalanced. The _ptype:rgain and
_ptype:ugain tags are replaced by _ptype:gain. The UnrealisedGain
account type remains, but nothing generates postings to it.

This reinstates the approach of 76696ca and 24412e6 (2026-02),
where Gain-typed postings were excluded from balancing and a separate
check validated disposals at cost basis; 80b320a (2026-04) replaced
that with the counter posting, to avoid the balancing exception. The
exception is now explained as basis balancing (see above), scoped by a
tag set before balancing rather than by account type, and the separate
check is the gain check after lot matching.

-B/--value=cost still converts at transacted cost; valuing lot postings
at basis there is a possible follow-up, as is showing inferred basis
annotations in print output so disposals re-read standalone.

AI usage: Fable 5.1, ~170k output tokens
…re filled in

print (and print -x) now always show lot postings with an
explicit cost basis annotation: `10 AAPL {$50} @ $50` on an acquire,
`-5 AAPL {2026-02-01, $50} @ $70` on a single-lot disposal or
transfer, `{}` on a disposal from several lots. Since disposals
balance at cost basis, plain print output (which does not show
directives) would otherwise no longer be re-readable; now it is
self-describing, at least under the default FIFO cost basis method.
print --lots is unchanged (the lot subaccount carries the basis), and
print --export and rewrite keep entries as written, since --export
reproduces the declarations.

Also, the restriction on amountless gain postings is dropped;
a gain posting written without an amount is now filled in with
the calculated gain (less any other written gain amounts), like an
inferred gain posting but with the user's choice of account; at most
one per entry. Gain postings are tagged inside the balancer once
amounts are known, so this also works when the disposal is only
recognisable after a balance assignment is resolved (#2686, previously
an error). With --ignore-lots there is no lot matching, so the
balancer infers the amount as before.

AI usage: Fable 5.1, ~60k output tokens
…cted

-B/--value=cost now converts an amount to its cost basis when it has one
(lot postings, after lot processing), otherwise to its transacted cost as
before. So for lot accounts, cost reports agree with how disposals now
balance: a disposal converts to what the disposed units cost rather than
what they sold for, an account's cost balance is the cost of the units
still held (zero once all are sold), bse -B balances, and --gain is value
minus cost basis. Non-lot amounts are unaffected.

The previous behaviour remains available as --value=transacted, which
converts at transacted cost only, showing proceeds and net cash invested.

Since collapsing lot detail (when --lots is off) merges a multi-lot
disposal's per-lot fragments into one amount with an unspecified basis,
the -B conversion is applied before that, in journalTransform. Also,
print -B now shows lot postings converted, which it previously did not.

AI usage: Fable 5.1, ~35k output tokens
@simonmichael simonmichael added investing Related to investments, lots, capital gains, etc. journal The journal file format, and its features. needs-testing To unblock: needs more developer testing or general usage labels Sep 22, 2026
The heuristic that detects undeclared gain postings in disposal-shaped
entries accepted any candidate whose siblings left a multi-commodity
residual, on the grounds that cost inference would resolve it. That
also matched a purchase with a cash fee and a small fee paid in the lot
commodity (a common exchange entry shape), tagging the fee as the gain
and then rejecting the entry because the "gain" didn't match.

Now the multi-commodity case must look like an unpriced sale: the lot
commodity net sold, one other commodity net received. A net purchase
with a cash fee is left alone and balances normally.

AI usage: Fable 5.1, ~43k output tokens
@simonmichael

simonmichael commented Sep 22, 2026 •

Copy link
Copy Markdown
Member Author

Migration tips:

  • remove any unrealised-gain postings, which will now cause your disposals to be unbalanced.
  • an equity:unrealised-gain (or type:U) account declaration is no longer needed. (I have left the U/UnrealisedGain account type existing, though it's now unused.)

@simonmichael simonmichael removed the needs-testing To unblock: needs more developer testing or general usage label Sep 23, 2026
@simonmichael

Copy link
Copy Markdown
Member Author

Tested on my journals, seems to be working fine.

@simonmichael
simonmichael merged commit c443f43 into main Sep 23, 2026
4 checks passed
@simonmichael
simonmichael deleted the lots-basis-balancing branch September 23, 2026 02:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Assignees

Couldn't load assignees.