lots: balance disposals at cost basis (historical cost accounting) - #2744
Merged
Merged
Conversation
…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
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
Member
Author
|
Migration tips:
|
Member
Author
|
Tested on my journals, seems to be working fine. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lot disposals now balance at cost basis, with a single realised gain posting; the generated
equity:unrealised-gaincounter 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:bsenets 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 (holdingsorbal --gain).Three commits:
Balance disposals at cost basis; drop the unrealised-gain posting. The balancer sets aside gain postings (tagged
_ptype:gainbefore balancing, by account type or heuristically), which is equivalent to basis balancing sinceq*B + q*(T-B) = q*Tand 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 (sohledger 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:ugainbecome_ptype:gain; a gain posting in a lot transfer is rejected.print shows inferred cost basis; amountless gain postings are filled in. Plain
printand-xshow 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 thecommoditydirective under the default method;--exportandrewritekeep 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).-B converts lot postings at cost basis; add --value=transacted.
-B/--value=costconverts amounts with a cost basis to that basis, else to transacted cost as before, so cost reports agree with balancing (bse -Bbalances, a sold-out lot account shows 0,--gainis value minus basis); non-lot amounts are unaffected.--value=transactedkeeps 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'-Boutput.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
Btoggle 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).