diff --git a/doc/DECISIONS.md b/doc/DECISIONS.md index a6cac6df033..f55a8df82c6 100644 --- a/doc/DECISIONS.md +++ b/doc/DECISIONS.md @@ -42,12 +42,38 @@ To distinguish transacted costs (@) from cost basis ({}). ### Compute realised gain from the disposal postings only -The synthetic `rgain`/`ugain` pair is sized from `Σ aquantity × (B − T)` +The generated gain posting is sized from `Σ aquantity × (B − T)` over non-acquire postings with both basis and transacted cost — not from the entry's full cost-basis residual. This isolates real capital gain from acquire-side bookkeeping mistakes (eg a typo'd `{B}` or a fee being double-counted into basis). +### Disposals balance at cost basis (historical cost accounting) + +2026-09, #2731. Disposal entries get a single realised gain posting, and are +understood to balance at cost basis: the disposed units count as `q × B`, +the gain posting supplies `q × (T − B)`, and the proceeds are `q × T`. +Previously an `equity:unrealised-gain` counter posting was also generated so +that disposals balanced at transacted cost; but with no revaluation postings +ever crediting that account, it accumulated a phantom balance equal to minus +the cumulative realised gains, so `bse` failed to balance even after +everything was sold. The alternative, mark-to-market accounting (keeping the +counter posting and generating revaluation postings as prices change), is +legitimate but more complex, and historical cost is what hledger 1 users +already do. Implementation: the balancer sets aside postings tagged +`_ptype:gain` (equivalent to basis balancing, since `q×B + q×(T−B) = q×T`, +and checkable before lot matching); the gain amount is verified after lot +matching. `-B`/`--value=cost` converts lot postings at cost basis too, so +cost reports agree with balancing (`bse -B` balances; a sold-out lot account +shows 0); `--value=transacted` gives the transacted-cost view (proceeds). +Revaluation postings could be added later as an optional layer. +(This reinstates the approach of 76696caec/24412e6e9 (2026-02), which +80b320acc (2026-04) had replaced with the counter posting to avoid a +balancing exception; the exception is now explained as basis balancing, +keyed on a tag set before balancing, and amountless gain postings are +allowed again. Plain `print` shows lot postings with their inferred basis +annotations so its output re-reads standalone under the default method.) + ### Don't enforce basis = transacted cost in acquisitions by default Acquires with `{B} @ T` where `B ≠ T`, are accepted by default, for better compatibility diff --git a/doc/PLAN-ugain.md b/doc/PLAN-ugain.md index 66d25d75972..a2132f64bad 100644 --- a/doc/PLAN-ugain.md +++ b/doc/PLAN-ugain.md @@ -1,90 +1,61 @@ -# Plan: continuous unrealised-gain tracking via generated postings +# Plan: optional unrealised-gain (revaluation) postings ## Background -Today `equity:unrealised-gain` only receives a single posting at disposal -time, sized at the disposal gain. Conceptually it should accumulate -continuously: each market-price change should generate a synthetic -revaluation transaction, posting `Dr asset (revaluation) / Cr ugain`, -so the ugain account's history is inspectable in `print`, `register`, and -`bal` like any other account. Disposal then becomes a clean reclassification: -the existing `rgain`+`ugain` pair shape stays, but the ugain side is sized -from the disposed lot's accumulated revaluation balance rather than computed -on the spot. +hledger records gains by the historical cost convention (see DECISIONS.md, +"Disposals balance at cost basis"): a disposal gets one realised gain +posting, and unrealised gains are not posted, only reported from market +prices (`holdings`, `--gain`, `-V` vs `-B`). -This was the trade-off discussed in commit -[80b320acc](https://github.com/hledgerorg/hledger/commit/80b320acc): -that commit chose not to generate revaluation postings, paying the cost of -a less inspectable ugain in exchange for less synthetic noise. -The disposal-only-gain rework (see that commit, and "Compute realised gain -from the disposal postings only" in DECISIONS.md) is forward-compatible with continuous ugain tracking -— the synthetic `rgain`+`ugain` pair shape we kept is exactly what -disposal-time reclassification would produce. +Earlier (until 2026-09) each disposal also received an +`equity:unrealised-gain` counter posting, intended as the second half of a +mark-to-market scheme in which revaluation postings (`Dr asset / +Cr equity:unrealised-gain`) would accrue unrealised gain as prices move, +and disposal would recycle the disposed lot's accumulated gain to realised. +The revaluation half was never implemented (commit 80b320acc chose not to +generate revaluation postings, to avoid synthetic noise), leaving the +counter posting as a plug that broke the accounting equation (#2731). -## Open design questions - -1. **What's the asset-side posting representation?** - - Pure `$` mixed into a commodity-tracked account? - - Parallel `assets:revaluation:*` account? - - Extension of the lot data model with a market-value field? - -2. **When are revaluations triggered?** - - Each `P` directive? - - Each transaction whose `@`/`@@` price differs from the lot's last-known price? - - Explicit valuation dates? - - Period boundaries (month-end, year-end)? - - On-demand at report time only? +## The optional layer -3. **How does disposal-time reclassification scale with accumulated ugain?** - - Partial disposals — what fraction of the lot's accumulated ugain transfers to rgain? - - Interaction with FIFO/LIFO/HIFO/AVERAGE selection. - - Lot-level vs. commodity-level vs. account-level revaluation tracking. - -## What this would look like end-to-end (sketch) +Revaluation postings could still be offered, as an opt-in on top of +historical cost, giving a ledger trail of unrealised gains (inspectable in +`register`, attributable to periods in `is`/`bse`): ```journal -2026-01-01 buy - assets:broker 100 AAPL {$50} - assets:cash -$5000 - -P 2026-02-01 AAPL $60 ; → synthetic revaluation transaction generated: - ; Dr assets:broker:{...} $1000 ; +$1000 over basis - ; Cr equity:unrealised-gain -$1000 - -P 2026-03-01 AAPL $70 ; → another synthetic revaluation: - ; Dr assets:broker:{...} $1000 - ; Cr equity:unrealised-gain -$1000 - -2026-04-01 sell - assets:broker -100 AAPL {$50} @ $70 - assets:cash $7000 - ; equity:unrealised-gain currently shows -$2000. - ; Disposal-time reclassification posts: - ; Cr revenues:gain -$2000 - ; Dr equity:unrealised-gain $2000 - ; ugain returns to $0 for this lot. +2026-03-01 revalue AAPL at $70 ; generated from a P directive + equity:unrealised-gain $-200 ; 10 AAPL x ($70 - $50) + assets:stocks:revaluation $200 ; or the lot subaccount itself + +2026-03-01 sell some + assets:stocks -5 AAPL {$50} @ $70 ; balances at basis: -$250 + assets:cash $350 + revenues:gain $-100 ; realised gain + equity:unrealised-gain $100 ; reverse the disposed lot's accumulated revaluation... + assets:stocks:revaluation $-100 ; ...and its asset-side write-up ``` -Each step is consistent at transacted-cost balance and produces a real -posting trail visible in `print`/`register`/`bal`. - -## Why this is parked +Note the two conventions: crediting an equity reserve and recycling it at +disposal (through other comprehensive income), versus crediting +`revenues:unrealised gain` so it hits the income statement each period +(fair value through profit or loss, no recycling). The U account type +suits the former. -Each open question above has multiple plausible answers with different -trade-offs around storage, UX, and scaling. Picking one without a real -prototype risks locking in a direction we'd regret. The current pair -shape works correctly for one-shot disposal-time gain recognition, which -covers the common reporting need; users who want continuous unrealised -tracking can use `bal -V` (market-value report) which derives the same -information at report time without persisting it. - -Worth revisiting when: -- Users start asking for `register equity:unrealised-gain` to show the - history of their paper gains, not just realisations. -- A jurisdiction-specific tax workflow makes period-end revaluation - posting mandatory rather than optional. -- The disposal-time reclassification math turns out to need more state - than `aquantity × (B − T)` per lot (eg cost-basis adjustments from - corporate actions that retroactively change the gain). +## Open design questions -Until then: out of scope. +1. Asset-side representation: `$` posted into the lot subaccount (makes + `bal` show "5 AAPL, $200"), a parallel `assets:...:revaluation` account, + or a market-value field in the lot model. +2. Trigger: each `P` directive, each transaction with a differing price, + period boundaries, or on demand at report time. +3. Partial disposals and non-FIFO methods: which fraction of a lot's + accumulated revaluation to reverse. +4. `-B`/cost reports: with revaluations in asset accounts, "cost" reports + would show market carrying value unless revaluation accounts are + excluded. + +## Status + +Parked. The common need (realised gain at disposal, unrealised gain as a +report) is covered without it. Revisit if users want a posting history of +paper gains, or a tax workflow requires period-end revaluation entries. diff --git a/doc/SPEC-finalising.md b/doc/SPEC-finalising.md index 9381ee5ffba..d2d277abf04 100644 --- a/doc/SPEC-finalising.md +++ b/doc/SPEC-finalising.md @@ -55,17 +55,17 @@ journalFinalise -- Pre-balancing cost/equity tagging 8. journalTagCostsAndEquityAndMaybeInferCosts(1st) -- tag conversion equity postings + redundant costs (helps balancer ignore them) - -- Generate auto postings - 9. journalAddAutoPostings -- if --auto, do transaction balancing (preliminary) to infer some missing amounts/costs, - -- then apply auto posting rules. Calls journalBalanceTransactions. - - -- Lot cost basis and transacted cost inference (before balancing; always run, + -- Lot cost basis and transacted cost inference, gain posting tagging (before balancing; always run, -- so lot entries balance the same with or without --ignore-lots; -- lenient with --ignore-lots: their errors are skipped, leaving the affected postings unchanged) - 10. journalInferBasisFromAccountNames -- if account name has a {…} lot subaccount, parse cost basis from it - 11. journalInferPostingsTransactedCost -- infer cost from cost basis of acquire postings - 12. journalAddGainOrUGainPosting -- if only one of rgain/ugain is written, add the other - -- (pre-balancer, so the ordinary balancer accepts the paired disposal) + 9. journalInferBasisFromAccountNames -- if account name has a {…} lot subaccount, parse cost basis from it + 10. journalInferPostingsTransactedCost -- infer cost from cost basis of acquire postings + 11. journalTagGainPostings -- in disposals, tag user-written gain postings _ptype:gain, + -- so the balancer sets them aside (disposals balance at cost basis) + + -- Generate auto postings + 12. journalAddAutoPostings -- if --auto, do transaction balancing (preliminary) to infer some missing amounts/costs, + -- then apply auto posting rules. Calls journalBalanceTransactions. -- Transaction balancing (main) 13. journalBalanceTransactionsAndDeferAssertions @@ -98,7 +98,7 @@ journalFinalise -- infer cost basis for bare disposals, normalize transacted cost 25. journalCheckAcquireBasis -- gated separately on `hledger check basis` (not on checklots); -- error if any acquire posting has cost basis ≠ transacted cost - 26. journalAddOrCheckGainPostings -- for disposals with no gain postings, add the rgain+ugain pair + 26. journalAddOrCheckGainPostings -- for disposals with no gain posting, add the gain posting -- sized at the disposal gain; otherwise check any user-written -- gain amount against the disposal gain 27. journalStripBalancerCopiedBases -- always: remove balancer-copied basis annotations, @@ -127,9 +127,9 @@ An arrow A → B means "A must run before B". - **journalTagCostsAndEquityAndMaybeInferCosts(1st) → journalBalanceTransactions** The balancer needs to know which costs are redundant (equity-paired) to ignore them. -- **journalClassifyLotPostings → journalInferPostingsTransactedCost** - Transacted cost inference skips `transfer-to` postings (which have no selling price), - so it needs the `_ptype` tag to be present. +- **journalTagGainPostings → journalAddAutoPostings** + Auto postings do a preliminary balancing pass, which must set aside any + user-written gain postings just as the main pass does. - **journalInferPostingsTransactedCost → journalBalanceTransactions** The balancer needs transacted costs to correctly infer missing amounts @@ -150,7 +150,7 @@ An arrow A → B means "A must run before B". - **journalCheckAcquireBasis → journalAddOrCheckGainPostings** When an acquire posting has B ≠ T, we want the structural error to surface - before any gain-pair-specific diagnostic (the gain pair's amount would be + before any gain-specific diagnostic (the gain amount would be meaningless given the imbalance). ### Design decisions @@ -214,7 +214,7 @@ Several steps only run with specific flags: | journalTagCostsAndEquity (2nd) | `--infer-costs` | | journalInferEquityFromCosts | `--infer-equity` | | journalInferBasisFromAccountNames | always; lenient (skips its errors) with `--ignore-lots`/`-I`, unless restored by `--strict` or `hledger check lots` | -| journalAddGainOrUGainPosting | always; lenient, as above | +| journalTagGainPostings | always; lenient, as above | | journalClassifyLotPostings | default; skipped by `--ignore-lots`/`-I`; restored by `--strict` or `hledger check lots` | | journalCheckLotsTagValues | same | | journalCheckLotsMethodCoherence | same | diff --git a/doc/SPEC-lots.md b/doc/SPEC-lots.md index 38434bd8ef7..e3e390051c5 100644 --- a/doc/SPEC-lots.md +++ b/doc/SPEC-lots.md @@ -46,7 +46,7 @@ without introducing new ones. It splits the lot pipeline in two: the same as with lots enabled: `journalInferBasisFromAccountNames` (in lenient mode: an invalid or conflicting lot subaccount name leaves the posting unchanged, an ordinary subaccount, instead of erroring), - `journalInferPostingsTransactedCost`, `journalAddGainOrUGainPosting` (in + `journalInferPostingsTransactedCost`, `journalTagGainPostings` (in lenient mode: the amountless-gain-posting error is skipped), and `journalStripBalancerCopiedBases`. The transaction balancer also stays lot-aware (`lotful_commodities_`/`account_lots_tags_` are populated as @@ -332,9 +332,9 @@ directly without a redundant `{}` annotation on the amount. After inferring cost basis, we identify and classify lot postings. A `_ptype` tag is added to each classified posting to record its type: -`acquire`, `dispose`, `transfer-from`, `transfer-to`, or `gain` (the last -applies to user-written postings on Gain-type accounts; `rgain` and -`ugain`, used on inferred postings, come later). +`acquire`, `dispose`, `transfer-from`, or `transfer-to`. (Realised gain +postings are tagged `gain` separately: user-written ones before balancing by +`transactionTagGainPostings`, generated ones by `journalAddOrCheckGainPostings`.) (`journalClassifyLotPostings` → `transactionClassifyLotPostings`) @@ -630,9 +630,28 @@ So a lot transaction can be broadly classified as "acquire", "transfer", or "dis ## Transaction balancing -All transactions, including disposals, are balanced by the ordinary -transaction-balancing rule — sum postings at transacted cost (ignoring cost -basis), sum must be zero, infer at most one missing amount per commodity. +All transactions are balanced by the ordinary transaction-balancing rule — +sum postings at transacted cost (ignoring cost basis), sum must be zero, +infer at most one missing amount per commodity — with one refinement for +disposals: their realised gain postings (tagged `_ptype:gain`, see +"Disposal transactions" below) are set aside, contributing nothing to the +sum or to inferred amounts. This makes a disposal balance at cost basis: +since `q×B + q×(T−B) = q×T`, "the non-gain postings sum to zero at +transacted cost" is the same statement as "all postings, gain included, +sum to zero with the disposed units valued at basis"; and it can be +checked before lot matching has determined B. The gain amount itself is +checked after lot matching (`journalAddOrCheckGainPostings`), which closes +the loop. (Historical cost accounting: unrealised gains are not posted.) + +Cost reports follow the same convention: `-B`/`--value=cost` converts an +amount with a cost basis to that basis (`amountCostBasis`), else to its +transacted cost, so a disposal converts to what the units cost and cost +reports balance; `--value=transacted` (`ToTransactedCost`) converts at +transacted cost only. Since collapsing lot detail (when `--lots` is off) +merges a multi-lot disposal's fragments into one amount with an unspecified +basis, the -B conversion is applied before collapsing, in +`journalTransform` (`maybeConvertToCostBasis`); the later per-report +conversion is then a no-op for those amounts. When the balancer infers a conversion cost between two commodities, and exactly one of them has classified lot postings — or, failing that, is @@ -666,35 +685,35 @@ a cost basis `B` and a transacted cost `T`, contribute `aquantity × (B − T)`. ### Gain postings -Conceptually, each disposal transaction has a balanced pair of postings, -representing the disposal's capital gain or loss: +Each disposal transaction has a **gain** posting (its realised gain), usually +to a Gain-type account (default `revenues:gain`), carrying the negated gain. +There is no counter posting: the disposal balances at cost basis (see +"Transaction balancing" above), the disposed units leaving at their basis +and the gain making up the difference from the proceeds. (Until 2026-09 a +balancing `equity:unrealised-gain` posting was also generated, so that +disposals balanced at transacted cost; it was dropped, see #2731 and +DECISIONS.md, because without revaluation postings it left a permanent +phantom equity balance and broke the accounting equation.) -- a **realised gain** posting (rgain), often to a Gain-type account (default `revenues:gain`) -- an **unrealised gain** posting (ugain) with opposite sign, usually to an UnrealisedGain-type account - (default `equity:unrealised-gain`). +### Gain account type -The two sum to zero so the transaction balancer accepts the disposal. -They reclassify the unrealised gain accumulated since acquisition, as realised gain. - -### Gain/UnrealisedGain account types - -The Gain and UnrealisedGain account types can be declared explicitly via `type:` tags: +The Gain account type can be declared explicitly via a `type:` tag: ``` -account revenues:gain ; type: G -account equity:unrealised-gain ; type: U +account revenues:gain ; type: G ``` -They are also inferred from conventional English account names +It is also inferred from conventional English account names (see the regex table under [Account types](https://hledger.org/hledger.html#account-types) in the user manual). -For example `revenues:gain`, `income:capital-gains`, `equity:unrealised-gain`, -and `equity:unrealized gains` are all detected automatically. +For example `revenues:gain` and `income:capital-gains` are detected automatically. +(The UnrealisedGain/U type, eg `equity:unrealised-gain`, still exists for +users' own accounts, but hledger generates no postings to it.) -Declaring and using these account types is not strictly required, -but they can improve error checking in disposals, -they help select an account for inferred gain postings, -and they facilitate more precise querying. +Declaring and using this account type is not strictly required, +but it can improve error checking in disposals, +it selects the account for inferred gain postings, +and it facilitates more precise querying. ### Disposal journal entries @@ -703,23 +722,23 @@ Disposal transactions can be written in any of these styles. The user manual's Styles are listed in the same order as the manual, from implicit to explicit. -1. **No gain postings.** +1. **No gain posting.** After lot matching, hledger computes the disposal gain - and infers realised gain and unrealised gain postings for the transaction + and infers a realised gain posting for the transaction (`journalAddOrCheckGainPostings`). The inferred amounts are rounded to the entry's local precision for the gain commodity (or if that is 0, and the gain has non-zero cents, decimal precision 2. See "Gain precision" below). -2. **Only rgain written, not using a type:G account.** - hledger identifies rgain posting(s) heuristically: one or more postings +2. **Only the gain posting written, not using a type:G account.** + hledger identifies gain posting(s) heuristically: one or more postings whose account type is not Asset, Liability, or Equity (or a subtype of these), which have not been classified as a lot movement by the lot classifier, - and whose non-gain siblings sum to zero (or have a multi-commodity imbalance). - Also if the lot classifier added a `_ptype:gain` tag, that indicates a gain posting - (though currently we don't expect that without a type:G account). - When gain postings are detected, hledger tags them with `_ptype:rgain`, - and infers a single balancing ugain posting to the default UnrealisedGain account. + and whose non-gain siblings sum to zero (or, in an unpriced sale, to a lot commodity + net sold plus one other commodity net received, which cost inference will resolve; + a net purchase with a cash fee is not treated as a disposal with a gain). + When gain postings are detected, hledger tags them with `_ptype:gain` + (`transactionTagGainPostings`, before balancing), and the balancer sets them aside. After lot matching, the transaction's gain amount is checked against the calculated gain at the entry's local precision; sub-last-place-unit differences are tolerated (see "Gain precision" below), but larger discrepancies @@ -734,17 +753,22 @@ Styles are listed in the same order as the manual, from implicit to explicit. classifier: they neither make an entry look like a disposal nor count in the residual sums. -3. **Only rgain written, using a type:G account.** - hledger identifies the rgain posting by the type:G account, - and infers a balancing ugain posting. The gain amount must be written explicitly, and is checked. +3. **Only the gain posting written, using a type:G account.** + hledger identifies the gain posting by the type:G account and tags it, + as above. The gain amount is checked; or it may be omitted (at most one + amountless gain posting per entry), in which case the balancer leaves the + posting alone and `journalAddOrCheckGainPostings` fills in the calculated + gain (less any other written gain amounts) after lot matching. In lenient + (--ignore-lots) mode there is no lot matching, so amountless Gain-typed + postings are not tagged and the balancer infers them as usual. -4. **rgain and ugain postings written, identified by their type:G and U accounts.** - All is explicit (including the gain amounts). No inference is needed; hledger checks the gain amount. +(A fourth style, with an explicit `equity:unrealised-gain` counter posting, +was supported until 2026-09; such entries now fail to balance.) ### Gain precision -Inferred gain amounts (cases 1, 2, 3) and the gain-validation -comparison (cases 2-4) operate at the **entry's local precision** for +Inferred gain amounts (case 1, and amountless gain postings in case 3) and the gain-validation +comparison (cases 2-3) operate at the **entry's local precision** for the gain commodity (ie, the maximum precision seen among the posting amounts in that commodity). @@ -868,10 +892,14 @@ Pre-balancing: elided cash amount balances at cost. Transfer destinations are recognised by shape and skipped: an explicit negative same-commodity same-quantity counterpart, or an equity posting with no cost-basis amounts (equity transfer). -3. **journalAddGainOrUGainPosting** — if the user has written an explicit rgain or ugain - posting without its counterpart, add the matching balancing posting (pre-balancer, - so the ordinary balancer accepts the paired transaction). Disposal transactions - are recognised by shape: a negative lotful or cost-basis amount. +3. **journalTagGainPostings** — in disposal transactions (recognised by shape: + a negative lotful or cost-basis amount), tag user-written realised gain + postings `_ptype:gain`, so the balancer sets them aside; error on an + amountless one (unless lenient). Runs before auto postings, whose + preliminary balancing needs the tags too. The per-transaction + `transactionTagGainPostings` also runs inside the balancer + (`balanceTransactionHelperMaybeSplittingLotFees`), for callers which + balance single entries, like `hledger add`. Balancing (`journalBalanceTransactions`): infers balance-assignment and elided amounts. For lots journals it uses `balanceTransactionHelperMaybeSplittingLotFees`: @@ -913,9 +941,10 @@ Post-balancing: not on `checklots`. Errors if any acquire posting has cost basis differing from its transacted cost (per-unit). Default mode skips this check; see [DECISIONS.md](DECISIONS.md) for the rationale. -9. **journalAddOrCheckGainPostings** — for disposals with no gain postings yet, add - the rgain + ugain pair sized at the disposal gain. Also validates that any - user-written gain amount matches the disposal gain. +9. **journalAddOrCheckGainPostings** — for disposals with no gain posting yet, add + the gain posting sized at the disposal gain. Also validates that any + user-written (tagged) gain amount matches the disposal gain — including in a + non-disposal, eg a transfer, where the gain is zero. The gated stages raise errors when the journal contains lot-related content that can't be resolved (missing lot cost, ambiguous selectors, dispose before acquire, @@ -928,7 +957,9 @@ The `--lots` flag is a display toggle consumed in the report-loading layer strips lot subaccount suffixes from account names, drops synthetic `_lot-parent-assertion` postings, and merges runs of `_lotsplit-posting` fragments (per-lot dispose/transfer splits sharing the same `poriginal`) back to a single -posting carrying the user's original amount. Posting amounts on other postings +posting (with an unspecified `{}` basis when several lots were involved). +Lot-inferred cost basis annotations are kept on the amounts, so that `print` +can show them (see below). Posting amounts on other postings are left alone; `print` relies on `transactionWithMostlyOriginalPostings` to revert to `poriginal` when displaying non-explicit output. @@ -993,10 +1024,10 @@ classify and balance as if the user had written: expenses:fees 0.000399 ETH @ $1,992.36 ``` -(with the rgain and ugain postings then generated as usual; note an -elided gain posting must not be written - disposals reject amountless -gain postings with an error, whether their amounts are explicit or -inferred by balancing). +(with the gain posting then generated as usual, or an amountless gain +posting filled in - this works here too, although the disposal is only +recognisable once the balance assignment has been resolved, because gain +postings are also tagged inside the balancer, after that point). The original user posting is preserved via `poriginal` on the transfer portion (p1), and the dispose portion (p2) is tagged `_feesplit-posting`. As a result: @@ -1033,7 +1064,13 @@ each fragment's `poriginal` points at the user's unmodified original posting. Display behaviour: - Plain `print` collapses the fragments via `journalCollapseLotDetail` and - reverts to `poriginal`, so the user sees their single original posting. + reverts to `poriginal`, so the user sees their single original posting - but + with the cost basis annotation inferred by lot processing added when they + wrote none (`{$50}` on an acquire, `{2026-01-15, $50}` on a single-lot + disposal or transfer, `{}` on a multi-lot disposal), so the printed entry + is self-describing and re-reads without the commodity's `lots:` declaration + under the default method. (With `--lots`, the subaccount name carries the + basis, so amounts are shown without it.) - `print --lots` keeps the fragments visible and renders each with its lot subaccount and per-lot quantity, but with the user's original cost basis annotations (achieved by `transactionWithMostlyOriginalPostings` scaling @@ -1099,12 +1136,12 @@ or: Explanation: 1. The missing @ price (or missing cash amount) is inferred by the ordinary - transaction balancer so the postings balance at transacted cost. + transaction balancer so the non-gain postings balance at transacted cost + (any user-written gain posting having been set aside). 2. 15 AAPL are reduced from one or more existing lots, selected by `assets:stock`'s / `AAPL`'s / default (FIFO) reduction method. 3. `journalAddOrCheckGainPostings` computes the disposal gain (`aquantity × (B − T)` summed over non-acquire postings whose amounts carry both basis and transacted cost) and adds a realised-gain posting - (rgain) and a matching unrealised-gain posting (ugain) with the opposite - sign. The pair sums to zero, so the disposal stays balanced under the - ordinary transacted-cost rule. + or checks the user-written one. The entry now balances at cost + basis. diff --git a/doc/SPEC-print.md b/doc/SPEC-print.md index 0f5a0dcbadd..40f9c042c27 100644 --- a/doc/SPEC-print.md +++ b/doc/SPEC-print.md @@ -8,6 +8,15 @@ Notes on some of print's behaviour. By default, print tries to show each entry as it is written in the journal file, except for alignment. And it shows entries in date-then-parse order. +Two additions for lot entries: the gain posting inferred for a disposal is +shown, and lot postings are shown with the cost basis annotation inferred by +lot processing when the user wrote none (eg `10 AAPL {$50} @ $50`, +`-5 AAPL {2026-01-15, $50} @ $70`, or `{}` for a disposal from several +lots). So lot entries are self-describing: the output can be re-read without +the commodity's `lots:` declaration, under the default cost basis method. +(With `--lots`, the lot subaccount name carries the basis instead; and +`--export` does not add them, since it reproduces the directives and aims +to keep entries as written.) Comment lines immediately preceding an entry (with no blank line between) are part of it (`tprecedingcomment`) and are shown before it, verbatim. diff --git a/doc/SPEC-special-postings.md b/doc/SPEC-special-postings.md index feaf297d562..075706238fc 100644 --- a/doc/SPEC-special-postings.md +++ b/doc/SPEC-special-postings.md @@ -56,9 +56,7 @@ For each cost amount, two conversion postings are generated under \ | _ptype:dispose | journalClassifyLotPostings | Lot disposal | | _ptype:transfer-from | journalClassifyLotPostings | Lot transfer source | | _ptype:transfer-to | journalClassifyLotPostings | Lot transfer destination | -| _ptype:gain | journalClassifyLotPostings | User-written posting to a Gain-type account | -| _ptype:rgain | journalAddGainOrUGainPosting / journalAddOrCheckGainPostings | Inferred realised-gain posting | -| _ptype:ugain | journalAddGainOrUGainPosting / journalAddOrCheckGainPostings | Inferred unrealised-gain counter posting | +| _ptype:gain | transactionTagGainPostings / journalAddOrCheckGainPostings | Realised-gain posting in a disposal (user-written, or generated); set aside by the balancer | | _cost-posting | journalTagCostsAndEquityAndMaybeInferCosts / journalInferEquityFromCosts | Has (or could have) cost matching conversion postings | | _conversion-posting | journalTagCostsAndEquityAndMaybeInferCosts / journalInferEquityFromCosts | Equity conversion posting | -| _generated-posting | journalInferEquityFromCosts / journalAddGainOrUGainPosting / journalAddOrCheckGainPostings | Machine-generated posting | +| _generated-posting | journalInferEquityFromCosts / journalAddOrCheckGainPostings | Machine-generated posting | diff --git a/examples/lots/irr.journal b/examples/lots/irr.journal index 102be7d7eb1..f84e3c5bdc4 100644 --- a/examples/lots/irr.journal +++ b/examples/lots/irr.journal @@ -20,7 +20,7 @@ Comparing roi and holdings (For a realistic comparison, with transfers, fees and closed positions, see lots.journal.) -$ hledger -f examples/lots/irr.journal roi --inv assets:stocks --pnl 'revenues:gain|equity:unrealised-gain' -e 2026-04-01 --value=end,$ +$ hledger -f examples/lots/irr.journal roi --inv assets:stocks --pnl revenues:gain -e 2026-04-01 --value=end,$ +---++------------+------------++---------------+----------+-------------+------++----------++------------+----------+ | || Begin | End || Value (begin) | Cashflow | Value (end) | PnL || IRR || TWR/period | TWR/year | +===++============+============++===============+==========+=============+======++==========++============+==========+ @@ -44,8 +44,8 @@ total into unrealised ($360 value − $250 cost basis = $110) plus realised (5 sold × ($70 − $50) = $100). The two invocation details that make the comparison fair: roi's ---pnl must include equity:unrealised-gain (else the generated ugain -postings are miscounted as external cashflows), and --value=end,$ +--pnl must include revenues:gain (else the generated gain postings +are miscounted as external cashflows), and --value=end,$ with -e 2026-04-01 puts roi's valuation on the same date and price as holdings'. diff --git a/examples/lots/lots.journal b/examples/lots/lots.journal index 1ea37a95dc2..9e43c01e7d0 100644 --- a/examples/lots/lots.journal +++ b/examples/lots/lots.journal @@ -82,10 +82,8 @@ account revenue:gains ; type: G ; 3a. Sell with no fee, at a gain. ; {$50} selects the lot; @ $90 is the selling price. -; The gain posting balances the difference between cost and sale price. -; hledger also infers a balancing equity:unrealised-gain posting (visible -; with print --lots), reclassifying the gain accumulated while holding -; the lot from unrealised to realised; see the manual's Recording gains. +; The gain posting balances the difference between cost and sale price: +; the disposal balances at cost basis. See the manual's Gain postings. 2025-08-01 sell at a gain, using specific identification assets:broker2 -10 ETSY {$50} @ $90 assets:cash $900 @@ -119,7 +117,6 @@ account revenue:gains ; type: G assets:broker -4.9 ETSY {} @ $90 assets:cash $441 revenue:gains $-50 - ; a balancing unrealised-gain posting is inferred here ; 3f. Sell without writing a gain posting — it is added automatically, ; using the first account of Gain type, otherwise revenues:gain. @@ -170,7 +167,7 @@ Comparing roi and holdings with this journal. (Some notes below generated by claude fable.) For a minimal comparison that reconciles exactly, see irr.journal. -$ hledger -f lots.journal roi --inv assets:broker --pnl 'revenue:gains|equity:unrealised-gain' -e 2026-03-31 --value=end,$ --infer-market-prices +$ hledger -f lots.journal roi --inv assets:broker --pnl revenue:gains -e 2026-03-31 --value=end,$ --infer-market-prices +---++------------+------------++---------------+----------+-------------+------++--------++------------+----------+ | || Begin | End || Value (begin) | Cashflow | Value (end) | PnL || IRR || TWR/period | TWR/year | +===++============+============++===============+==========+=============+======++========++============+==========+ @@ -208,10 +205,9 @@ journals with closed positions, "holdings RGain" and "roi PnL" answer different questions: holdings reports on what you still hold, roi on the whole investment's history. -2. --pnl 'revenue:gains|equity:unrealised-gain': keeps the lots -system's generated ugain postings out of roi's external cashflows. -With --pnl revenue:gains alone, they are counted as external -cashflows, giving nonsense: PnL $1448, IRR 102.87%. +2. --pnl revenue:gains: counts the realised gain postings as profit +rather than as external cashflows in and out of the investment +(which would distort the result). 3. --infer-market-prices: there's no P directive before 2026-05-01, so both reports infer the price from the latest transaction cost @@ -236,6 +232,6 @@ characteristic, not a lots bug. One more caveat: even with the right --pnl, roi's TWR can look distorted on lots journals (eg on irr.journal's clean two-entry -case it shows TWR -1.39% despite a +$210 gain), likely because the -balanced rgain/ugain pair nets to zero PnL at disposal time, -confusing TWR's subperiod attribution. +case it shows TWR -1.39% despite a +$210 gain); the cause is not +yet understood (it is not the gain postings: the figure is the same +with no gain postings involved). diff --git a/hledger-lib/Hledger/Data/AccountName.hs b/hledger-lib/Hledger/Data/AccountName.hs index 01dc31225fd..2c616dfb516 100644 --- a/hledger-lib/Hledger/Data/AccountName.hs +++ b/hledger-lib/Hledger/Data/AccountName.hs @@ -25,7 +25,6 @@ module Hledger.Data.AccountName ( ,accountNameType ,defaultBaseConversionAccount ,defaultGainAccount - ,defaultUnrealisedGainAccount ,assetAccountRegex ,cashAccountRegex ,liabilityAccountRegex @@ -95,14 +94,10 @@ acctsep = T.pack [acctsepchar] -- when no other account of type V/Conversion has been declared. defaultBaseConversionAccount = "equity:conversion" --- The default account name for inferred realised gain (rgain) postings, +-- The default account name for inferred gain postings, -- when no other account of type G/Gain has been declared. defaultGainAccount = "revenues:gain" --- The default account name for inferred unrealised gain (ugain) postings, --- when no other account of type U/UnrealisedGain has been declared. -defaultUnrealisedGainAccount = "equity:unrealised-gain" - -- | Regular expressions matching common English top-level account names, -- used as a fallback when account types are not declared. assetAccountRegex = toRegexCI' "^assets?(:|$)" diff --git a/hledger-lib/Hledger/Data/Amount.hs b/hledger-lib/Hledger/Data/Amount.hs index 1b36dfcd8f1..03126089a0a 100644 --- a/hledger-lib/Hledger/Data/Amount.hs +++ b/hledger-lib/Hledger/Data/Amount.hs @@ -64,6 +64,7 @@ module Hledger.Data.Amount ( (@@), amountWithCommodity, amountCost, + amountCostBasis, amountIsZero, amountLooksZero, amountSetQuantity, @@ -140,6 +141,7 @@ module Hledger.Data.Amount ( mixedAmountStripCosts, -- ** arithmetic mixedAmountCost, + mixedAmountCostBasis, maNegate, maPlus, maMinus, @@ -379,6 +381,16 @@ amountCost a@Amount{aquantity=q, acost=mp} = Just (UnitCost p@Amount{aquantity=pq}) -> p{aquantity=pq * q} Just (TotalCost p@Amount{aquantity=pq}) -> p{aquantity=pq} +-- | Convert an Amount to its cost basis when it has a cost basis annotation +-- with a cost (as lot postings do after lot processing), otherwise to its +-- transacted cost as amountCost does. This is what -B/--value=cost reports: +-- for a lot disposal it gives what the disposed units cost, not the proceeds. +amountCostBasis :: Amount -> Amount +amountCostBasis a@Amount{aquantity=q, acostbasis=mcb} = + case mcb >>= cbCost of + Just b@Amount{aquantity=bq} -> b{aquantity=bq * q} + Nothing -> amountCost a + -- | Strip all costs from an Amount amountStripCost :: Amount -> Amount amountStripCost a = a{acost=Nothing} @@ -1185,6 +1197,13 @@ mixedAmountCost (Mixed ma) = foldl' (\m a -> maAddAmount m (amountCost a)) (Mixed noCosts) withCosts where (noCosts, withCosts) = M.partition (isNothing . acost) ma +-- | Convert all component amounts to cost basis (or else transacted cost) +-- where possible (see amountCostBasis). +mixedAmountCostBasis :: MixedAmount -> MixedAmount +mixedAmountCostBasis (Mixed ma) = + foldl' (\m a -> maAddAmount m (amountCostBasis a)) (Mixed noCosts) withCosts + where (noCosts, withCosts) = M.partition (\a -> isNothing (acost a) && isNothing (acostbasis a >>= cbCost)) ma + -- -- | MixedAmount derived Eq instance in Types.hs doesn't know that we -- -- want $0 = EUR0 = 0. Yet we don't want to drag all this code over there. -- -- For now, use this when cross-commodity zero equality is important. diff --git a/hledger-lib/Hledger/Data/Balancing.hs b/hledger-lib/Hledger/Data/Balancing.hs index 9ee3bb3510b..4832e29e2ad 100644 --- a/hledger-lib/Hledger/Data/Balancing.hs +++ b/hledger-lib/Hledger/Data/Balancing.hs @@ -57,7 +57,7 @@ import Hledger.Data.Types import Hledger.Data.AccountName (accountNameType, isAccountNamePrefixOf) import Hledger.Data.Amount import Hledger.Data.Journal -import Hledger.Data.Lots (lotBaseAccount, transactionAutoSplitFeeOutflows) +import Hledger.Data.Lots (isGainPosting, lotBaseAccount, transactionAutoSplitFeeOutflows, transactionTagGainPostings) import Hledger.Data.Posting import Hledger.Data.Transaction import Hledger.Data.Errors @@ -120,9 +120,13 @@ transactionCheckBalanced BalancingOpts{commodity_styles_=_mglobalstyles, txn_bal -- convert a posting's amount to cost, -- unless it has been marked as a redundant cost (equivalent to some nearby equity conversion postings), - -- in which case ignore it. + -- in which case ignore it; + -- or it is a disposal's realised gain posting, which is set aside: a disposal + -- balances at cost basis, which is equivalent to its non-gain postings balancing + -- at transacted cost (see Hledger.Data.Lots.transactionTagGainPostings). postingBalancingAmount p | costPostingTagName `elem` map fst (ptags p) = mixedAmountStripCosts $ pamount p + | isGainPosting p = nullmixedamt | otherwise = mixedAmountCost $ pamount p lookszero = case txn_balancing_ of @@ -134,7 +138,9 @@ transactionCheckBalanced BalancingOpts{commodity_styles_=_mglobalstyles, txn_bal -- check that the sum looks like zero (rsumcost, bvsumcost) = (foldMap postingBalancingAmount rps, foldMap postingBalancingAmount bvps) - (rsumamts, bvsumamts) = (map postingBalancingAmount rps, map postingBalancingAmount bvps) + (rsumamts, bvsumamts) = (map postingBalancingAmount (nongain rps), map postingBalancingAmount (nongain bvps)) + nongain = filter (not . isGainPosting) + gainnote ps = if any isGainPosting ps then " (excluding gain postings)" else "" (rsumok, bvsumok) = (lookszero rsumcost, lookszero bvsumcost) (rsumokold, bvsumokold) = (lookszeroatglobaldisplayprecision rsumcost, lookszeroatglobaldisplayprecision bvsumcost) @@ -158,7 +164,7 @@ transactionCheckBalanced BalancingOpts{commodity_styles_=_mglobalstyles, txn_bal | not rsignsok = "The real postings all have the same sign." | otherwise = "The real postings' sum should be 0 but is " ++ showamt rsumcost - ++ "\n " ++ intercalate " + " (map showamt rsumamts) ++ " = " ++ showamt rsumcost + ++ "\n " ++ intercalate " + " (map showamt rsumamts) ++ " = " ++ showamt rsumcost ++ gainnote rps ++ (if rsumokold then oldbalancingmsg else "") ++ lotmismatchmsg rps bvmsg @@ -166,7 +172,7 @@ transactionCheckBalanced BalancingOpts{commodity_styles_=_mglobalstyles, txn_bal | not bvsignsok = "The balanced virtual postings all have the same sign." | otherwise = "The balanced virtual postings' sum should be 0 but is: " ++ showamt bvsumcost - ++ "\n " ++ intercalate " + " (map showamt bvsumamts) ++ " = " ++ showamt bvsumcost + ++ "\n " ++ intercalate " + " (map showamt bvsumamts) ++ " = " ++ showamt bvsumcost ++ gainnote bvps ++ (if bvsumokold then oldbalancingmsg else "") ++ lotmismatchmsg bvps -- When a balancing conversion cost was not inferred because of a @@ -227,7 +233,7 @@ balanceSingleTransaction bopts = fmap fst . balanceTransactionHelper bopts -- error message if both fail). Lot classification runs after balancing and -- will classify the split postings (#2686, #2692). balanceTransactionHelperMaybeSplittingLotFees :: BalancingOpts -> Transaction -> Either String (Transaction, [(AccountName, MixedAmount)]) -balanceTransactionHelperMaybeSplittingLotFees bopts t +balanceTransactionHelperMaybeSplittingLotFees bopts t0 | S.null lotfulcomms = balanceTransactionHelper bopts t -- not a lots journal | length (tpostings t2) == length (tpostings t) = balanceTransactionHelper bopts t -- no split applied | otherwise = case balanceTransactionHelper bopts t2 of @@ -235,6 +241,11 @@ balanceTransactionHelperMaybeSplittingLotFees bopts t Left _ -> balanceTransactionHelper bopts t where lotfulcomms = lotful_commodities_ bopts + -- Tag any user-written gain postings in a disposal first, so the balancer + -- sets them aside. (journalFinalise does this too, but callers balancing a + -- single entry, like hledger add, rely on it happening here.) + t = transactionTagGainPostings (not $ lenient_lots_ bopts) (verbose_balancing_tags_ bopts) (accountNameType (account_types_ bopts)) (`S.member` lotfulcomms) + (accountUsesNoLotsWith (account_lots_tags_ bopts)) t0 t2 = transactionAutoSplitFeeOutflows (verbose_balancing_tags_ bopts) (accountNameType (account_types_ bopts)) (`S.member` lotfulcomms) (accountUsesNoLotsWith (account_lots_tags_ bopts)) t @@ -320,13 +331,17 @@ transactionInferBalancingAmount styles _atypes t@Transaction{tpostings=ps} ) where lbl = lbl_ "transactionInferBalancingAmount" - (amountfulrealps, amountlessrealps) = partition hasAmount (realPostings t) + -- Gain postings in a disposal are set aside (see transactionCheckBalanced): + -- they don't contribute to the sum, and an amountless one is not inferred + -- here (journalAddOrCheckGainPostings fills it in after lot matching). + (amountfulrealps, amountlessrealps) = partition hasAmount $ filter (not . isGainPosting) (realPostings t) realsum = maSum $ map (mixedAmountCost . pamount) amountfulrealps -- & dbg9With (lbl "real balancing amount".showMixedAmountOneLine) - (amountfulbvps, amountlessbvps) = partition hasAmount (balancedVirtualPostings t) + (amountfulbvps, amountlessbvps) = partition hasAmount $ filter (not . isGainPosting) (balancedVirtualPostings t) bvsum = maSum $ map (mixedAmountCost . pamount) amountfulbvps inferamount :: Posting -> (Posting, Maybe MixedAmount) + inferamount p | isGainPosting p = (p, Nothing) inferamount p = let minferredamt = case preal p of @@ -449,7 +464,7 @@ costInferrerFor :: Bool -> S.Set CommoditySymbol -> Transaction -> PostingRealne costInferrerFor lenientlots lotfulcomms t pt = maybe id infercost inferFromAndTo where lbl = lbl_ "costInferrerFor" - postings = filter (\p -> preal p == pt) $ tpostings t + postings = filter (\p -> preal p == pt && not (isGainPosting p)) $ tpostings t -- gain postings are set aside, as in transactionCheckBalanced pcommodities = map acommodity $ concatMap (amounts . pamount) postings sumamounts = amounts $ sumPostings postings -- amounts normalises to one amount per commodity & price diff --git a/hledger-lib/Hledger/Data/Journal.hs b/hledger-lib/Hledger/Data/Journal.hs index f281addb27d..29dc3be2e59 100644 --- a/hledger-lib/Hledger/Data/Journal.hs +++ b/hledger-lib/Hledger/Data/Journal.hs @@ -130,9 +130,6 @@ module Hledger.Data.Journal ( defaultGainAccount, journalBaseGainAccount, journalGainAccounts, - defaultUnrealisedGainAccount, - journalBaseUnrealisedGainAccount, - journalUnrealisedGainAccounts, -- * Misc nulljournal, journalConcat, @@ -984,7 +981,7 @@ journalBaseConversionAccount = headDef defaultBaseConversionAccount . journalCon journalConversionAccounts :: Journal -> [AccountName] journalConversionAccounts = M.keys . M.filter (==Conversion) . jaccounttypes --- | The account name to use for inferred realised-gain (rgain) postings. +-- | The account name to use for inferred gain postings. -- This is the alphabetically first account declared with type G/Gain, -- or otherwise the defaultGainAccount (revenues:gain). journalBaseGainAccount :: Journal -> AccountName @@ -994,16 +991,6 @@ journalBaseGainAccount = headDef defaultGainAccount . journalGainAccounts journalGainAccounts :: Journal -> [AccountName] journalGainAccounts = sort . M.keys . M.filter (==Gain) . jaccounttypes --- | The account name to use for inferred unrealised-gain (ugain) postings. --- This is the alphabetically first account declared with type U/UnrealisedGain, --- or otherwise the defaultUnrealisedGainAccount (equity:unrealised-gain). -journalBaseUnrealisedGainAccount :: Journal -> AccountName -journalBaseUnrealisedGainAccount = headDef defaultUnrealisedGainAccount . journalUnrealisedGainAccounts - --- | All the accounts in this journal which are declared as U/UnrealisedGain type. -journalUnrealisedGainAccounts :: Journal -> [AccountName] -journalUnrealisedGainAccounts = sort . M.keys . M.filter (==UnrealisedGain) . jaccounttypes - -- Various kinds of filtering on journals. We do it differently depending -- on the command. diff --git a/hledger-lib/Hledger/Data/Lots.hs b/hledger-lib/Hledger/Data/Lots.hs index af9538b10bf..a220dbaeca0 100644 --- a/hledger-lib/Hledger/Data/Lots.hs +++ b/hledger-lib/Hledger/Data/Lots.hs @@ -99,8 +99,10 @@ module Hledger.Data.Lots ( journalCheckAcquireBasis, journalCheckLotsMethodCoherence, journalCollapseLotDetail, - journalAddGainOrUGainPosting, + journalTagGainPostings, + transactionTagGainPostings, journalAddOrCheckGainPostings, + isGainPosting, lotBaseAccount, lotSubaccountName, mergeCostBasis, @@ -131,7 +133,7 @@ import Hledger.Data.AccountName (accountNameType, parentAccountNames) import Hledger.Data.AccountType (isAssetType, isEquityType, isLiabilityType) import Hledger.Data.Amount (AmountFormat(..), amountRoundedQuantity, amountSetPrecisionMin, amountSetQuantity, amountsRaw, divideAmountAndUpdatePrecision, isNegativeAmount, maNegate, maSum, mapMixedAmount, mixedAmount, mixedAmountCost, mixedAmountIsZero, mixedAmountLooksZero, nullmixedamt, noCostFmt, oneLineNoCostFmt, showAmountWith, showAmountsDistinctly, showMixedAmountOneLine, showMixedAmountsDistinctly) import Hledger.Data.Errors (makeAccountTagErrorExcerpt, makeCommodityTagErrorExcerpt, makePostingErrorExcerptByIndex, makeTransactionErrorExcerpt, transactionFindPostingIndex) -import Hledger.Data.Journal (journalAccountType, journalAccountUsesNoLots, journalBaseGainAccount, journalBaseUnrealisedGainAccount, journalCommodityLotsMethod, journalCommodityStylesWith, journalCommodityUsesLots, journalInheritedAccountTags, journalMapPostings, journalMapTransactions, journalPostings, journalTieTransactions, parseReductionMethod) +import Hledger.Data.Journal (journalAccountType, journalAccountUsesNoLots, journalBaseGainAccount, journalCommodityLotsMethod, journalCommodityStylesWith, journalCommodityUsesLots, journalInheritedAccountTags, journalMapPostings, journalMapTransactions, journalPostings, journalTieTransactions, parseReductionMethod) import Hledger.Data.Posting (generatedPostingTagName, hasAmount, isReal, isVirtual, lotParentAssertionTagName, lotsplitPostingTagName, nullposting, originalPosting, postingAddHiddenAndMaybeVisibleTag, postingHasTag, postingStripCosts, feesplitPostingTagName) import Hledger.Data.Transaction (transactionCommodityStyles, txnTieKnot) import Hledger.Data.Types @@ -613,7 +615,7 @@ transactionAutoSplitFeeOutflows verbosetags lookupAccountType commodityIsLotful -- transactionClassifyLotPostings :: Bool -> (AccountName -> Maybe AccountType) -> (CommoditySymbol -> Bool) -> (AccountName -> Bool) -> Transaction -> Transaction transactionClassifyLotPostings verbosetags lookupAccountType commodityIsLotful accountUsesNoLots t@Transaction{tpostings=ps} - | not (any hasLotRelevantAmount ps) && not (any isGainAcct ps) + | not (any hasLotRelevantAmount ps) = lotDbg t "no lot-relevant amounts, skipping" t | otherwise = lotDbg t "classifying" $ t{tpostings=zipWith classifyAt [0..] ps} where @@ -805,13 +807,10 @@ transactionClassifyLotPostings verbosetags lookupAccountType commodityIsLotful a nq == pq && any (/= acct) (if isNeg then paccts else naccts) _ -> False - isGainAcct :: Posting -> Bool - isGainAcct p = lookupAccountType (paccount p) == Just Gain - classifyAt :: Int -> Posting -> Posting classifyAt i p | not (isReal p) = p -- skip virtual (parenthesised) postings - | isClassifiedPosting p = p -- skip postings already carrying a ptype tag (eg rgain/ugain postings added by journalAddGainOrUGainPosting) + | isClassifiedPosting p = p -- skip postings already carrying a ptype tag (eg gain postings tagged by transactionTagGainPostings) -- A balancer-copied basis posting is normally left unclassified, -- except when it can be read as the elided destination or source of -- a lot transfer (see isMirroredTransferCandidate). @@ -832,9 +831,7 @@ transactionClassifyLotPostings verbosetags lookupAccountType commodityIsLotful a | otherwise = case dbg5 ("classifyLotPostings: classifyPosting " ++ show (paccount p) ++ " result") $ shouldClassify p of Just classification -> addTag classification p - Nothing - | isGainAcct p -> addTag "gain" p - | otherwise -> p + Nothing -> p where addTag cls = postingAddHiddenAndMaybeVisibleTag True verbosetags (toHiddenTag ("ptype", cls)) -- Check if posting should be classified and return the classification: @@ -1031,142 +1028,143 @@ journalCalculateLots verbosetags j -- Disposal gain postings --- | Make a generated gain posting with the given account, amount, and _ptype --- tag value (\"rgain\" or \"ugain\"). Also tags with @_generated-posting@ so --- print can reveal it under @--verbose-tags@. -mkGeneratedGainPosting :: Bool -> AccountName -> MixedAmount -> Text -> Posting -mkGeneratedGainPosting verbosetags acc amt ptypeTag = - postingAddHiddenAndMaybeVisibleTag True verbosetags (toHiddenTag ("ptype", ptypeTag)) +-- | Make a generated realised-gain posting with the given account and amount, +-- tagged @_ptype:gain@ and @_generated-posting@ (the latter visible as +-- @generated-posting:@ in @print --verbose-tags@). +mkGeneratedGainPosting :: Bool -> AccountName -> MixedAmount -> Posting +mkGeneratedGainPosting verbosetags acc amt = + tagGain verbosetags $ postingAddHiddenAndMaybeVisibleTag False verbosetags (generatedPostingTagName, "") $ nullposting{paccount = acc, pamount = amt} --- | For each disposal transaction with a user-written realised-gain (rgain) --- posting that lacks its matching ugain posting, append a generated ugain --- posting on the default UnrealisedGain account so the entry balances at --- transacted cost without any special exception. +-- | Tag a posting as a gain posting (@_ptype:gain@). +tagGain :: Bool -> Posting -> Posting +tagGain verbosetags = postingAddHiddenAndMaybeVisibleTag True verbosetags (toHiddenTag ("ptype", "gain")) + +-- | Does this posting look like a lot disposal (or transfer source) ? +-- True if it has a negative amount with a cost basis annotation, or in a +-- lotful commodity (unless the account opts out with lots: NONE). +-- A shape check, usable before lot classification has run. +postingHasDisposeShape :: (CommoditySymbol -> Bool) -> (AccountName -> Bool) -> Posting -> Bool +postingHasDisposeShape commodityIsLotful accountUsesNoLots p = + any (\a -> isNegativeAmount a + && (isJust (acostbasis a) + || (commodityIsLotful (acommodity a) + && not (accountUsesNoLots (lotBaseAccount (paccount p)))))) + (amountsRaw (pamount p)) + +-- | In a disposal transaction, tag the user-written realised gain +-- postings with @_ptype:gain@, so that the transaction balancer sets them +-- aside (see 'Hledger.Data.Balancing.transactionCheckBalanced'). -- --- The user's rgain posting is detected in either of two ways: +-- A disposal balances at cost basis: the dispose posting counts as its +-- quantity times its basis, and the gain posting accounts for the +-- difference from the transacted proceeds. The basis is not known until +-- lot matching, which runs after balancing; but since +-- @q*B + q*(T-B) == q*T@, balancing the non-gain postings at transacted +-- cost is equivalent, and that is what the balancer does. Whether the gain +-- amount is right is checked afterwards by 'journalAddOrCheckGainPostings'. -- --- 1. By account type — the posting's account is declared with @type:G@. (And --- symmetrically for explicitly-written ugain on a @type:U@ account, with --- its corresponding rgain posting inserted on the default Gain account.) +-- Disposal transactions are recognised by shape, since classification has +-- not run yet: a real posting with a negative lotful or cost-basis amount +-- ('postingHasDisposeShape'; this also matches transfer sources, where a +-- gain posting is later rejected by journalAddOrCheckGainPostings, the +-- gain being zero). Gain postings are recognised in one of two ways: -- --- 2. By transacted-cost residual — when no Gain/UnrealisedGain-typed account --- is involved, if the entry's postings sum to a non-zero single-commodity --- residual at transacted cost (with @ prices applied), that residual is --- treated as the user's rgain amount, and a balancing ugain posting is --- appended on the default UnrealisedGain account. This lets users write a --- gain posting on any account (without declaring it as @type:G@) and have --- hledger infer the balancing ugain posting automatically. +-- 1. By account type: real postings on a Gain-typed account. These may be +-- amountless (unless the first argument is false, in lenient --ignore-lots +-- mode, where there'll be no lot matching to fill the amount in): the +-- balancer leaves such a posting alone and 'journalAddOrCheckGainPostings' +-- sets its amount to the calculated gain. -- --- Runs BEFORE transaction balancing. Whether the user-written gain amount is --- correct is checked later by 'journalAddOrCheckGainPostings' after lot --- matching. Disposals with no detectable rgain are left alone; if there's a --- disposal gain, the rgain+ugain pair is added by 'journalAddOrCheckGainPostings'. +-- 2. Heuristically, when there is no Gain-typed posting: real, amountful +-- postings whose account type is not Asset, Liability or Equity (or a +-- subtype), and which don't carry a lotful or cost-basis amount; accepted +-- only if all postings have amounts and the remaining postings sum to +-- zero at transacted cost (a well-formed disposal), or to an unpriced +-- sale: a lot commodity net sold plus one other commodity net received +-- (which balancing cost inference will resolve). A net purchase with a +-- cash fee posting is not mistaken for a disposal with a gain. +-- This lets a gain posting be written on any account without declaring +-- it type:G, for simple entries. -- --- With a true first argument (lenient mode, used by --ignore-lots), the --- amountless-gain-posting error is skipped, leaving that transaction --- unchanged for the balancer to handle. -journalAddGainOrUGainPosting :: Bool -> Bool -> Journal -> Either String Journal -journalAddGainOrUGainPosting lenient verbosetags j = do - txns' <- mapM infer (jtxns j) - Right j{jtxns = txns'} +-- Virtual (parenthesised) postings are ignored throughout, as the balancer +-- and the lot machinery ignore them. A transaction which already has a +-- tagged gain posting is returned unchanged, so this is safe to run +-- repeatedly: it runs as a journalFinalise step, and again inside the +-- balancer for callers which balance single entries (hledger add, hledger-web). +transactionTagGainPostings + :: Bool -- ^ also tag amountless Gain-typed postings (not in lenient mode) + -> Bool -- ^ also add visible tags + -> (AccountName -> Maybe AccountType) -- ^ account type lookup + -> (CommoditySymbol -> Bool) -- ^ is this a lotful commodity ? + -> (AccountName -> Bool) -- ^ does this account use lots: NONE ? + -> Transaction -> Transaction +transactionTagGainPostings tagamountless verbosetags lookupAccountType commodityIsLotful accountUsesNoLots t + | any isGainPosting realps = t + | not (any hasDisposeShape realps) = t + | any isGainTyped realps = tagPostings isGainTyped + | heuristicOk = tagPostings isCandidate + | otherwise = t where - atypes = jaccounttypes j - ugainAccount = journalBaseUnrealisedGainAccount j - isRgain p = accountNameType atypes (paccount p) == Just Gain - isUgain p = accountNameType atypes (paccount p) == Just UnrealisedGain - - -- This runs before transaction balancing, so lot classification hasn't - -- happened yet; disposal transactions are recognised by shape instead of - -- by ptype tags: any posting with a negative lotful or cost-basis amount. - -- (This is broader than "classified dispose" - it also matches transfer - -- sources - but the case analysis below is a no-op for those.) - hasDisposeShape p = - any (\a -> isNegativeAmount a - && (isJust (acostbasis a) - || (journalCommodityUsesLots j (acommodity a) - -- lots: NONE accounts' bare postings aren't disposals - && not (journalAccountUsesNoLots j (lotBaseAccount (paccount p)))))) - (amountsRaw (pamount p)) - amountIsLotfulOrHasBasis a = - isJust (acostbasis a) - || journalCommodityUsesLots j (acommodity a) - postingHasLotfulOrBasisAmount p = any amountIsLotfulOrHasBasis (amountsRaw (pamount p)) - - -- Match the four cases in SPEC-lots.md "Gain inference": - -- 1. rgain on type:G + ugain on type:U → no inference - -- 2. no rgain or ugain → defer to journalAddOrCheckGainPostings - -- 3. rgain on type:G alone → infer balancing ugain posting - -- 4. rgain on no type:G account → detect rgain postings heuristically and infer ugain posting - -- Only real postings are considered, here and in tryResidual: virtual - -- postings don't affect balancing and are ignored by the lot machinery, - -- so they must neither look like disposals nor count in the residual. - infer t - | not (any hasDisposeShape ps) = Right t - | any isRgain ps && any isUgain ps = Right t -- 1 - | any isRgain ps = addCounter t (filter isRgain ps) ugainAccount "ugain" -- 3 - | otherwise = tryResidual t -- 2 or 4 - where ps = filter isReal (tpostings t) - - addCounter t existing missingAcc ptypeTag - | any (not . hasAmount) existing = - if lenient then Right t else Left (amountlessErr t) - | otherwise = - let balancingP = mkGeneratedGainPosting verbosetags missingAcc (maNegate $ foldMap pamount existing) ptypeTag - in Right $ txnTieKnot t{tpostings = tpostings t ++ [balancingP]} - - -- When no posting is on a declared G/U account, try to identify the - -- user-written rgain posting(s); and infer a balancing ugain posting - -- to the default UnrealisedGain account. - -- Candidate rgain postings (there can be more than one in an entry) - -- have an account type that's not Asset/Liability/Equity (or any subtype), - -- and have not been classified as a lot movement by the lot classifier. - -- Also, they are accepted as rgain postings - -- only when the remaining, non-candidate postings sum to zero - -- (indicating that the transaction is balanced without them) - -- or to a multi-commodity amount (which we leave the transaction balancer to deal with). - tryResidual t - | any (not . hasAmount) realps = Right t -- let balancer handle - | null rgainCandidates = Right t -- no candidate: defer - | not shouldInsert = Right t -- imbalance isn't pure gain: defer - | otherwise = Right $ txnTieKnot t{tpostings = ps' ++ [ugainP]} - where - realps = filter isReal (tpostings t) - (rgainCandidates, nonCandidates) = partition isRgainCandidate realps - nonCandResidual = foldMap (mixedAmountCost . pamount) nonCandidates - nonCandCommodities = filter (not . isZeroAmount) (amountsRaw nonCandResidual) - shouldInsert = - mixedAmountIsZero nonCandResidual -- well-formed disposal: net zero - || length nonCandCommodities >= 2 -- multi-commodity: cost inference will resolve - isZeroAmount a = aquantity a == 0 - ps' = map tagCandidate (tpostings t) - tagCandidate p - | isReal p && isRgainCandidate p = postingAddHiddenAndMaybeVisibleTag True verbosetags (toHiddenTag ("ptype", "rgain")) p - | otherwise = p - sumCand = foldMap pamount rgainCandidates - ugainP = mkGeneratedGainPosting verbosetags ugainAccount (maNegate sumCand) "ugain" - - -- An rgain candidate is a posting whose account type is not Asset, - -- Liability, or Equity (or any subtype thereof — Cash, Conversion, - -- UnrealisedGain) and which is not itself a lot movement - recognised by - -- shape (no lotful or cost-basis amounts), since classification hasn't - -- run yet. (Gain-typed accounts need no special case here: any of those - -- would have been handled as case 3 before reaching this.) - isRgainCandidate p = hasAmount p && notALE p && not (postingHasLotfulOrBasisAmount p) + realps = filter isReal (tpostings t) + hasDisposeShape = postingHasDisposeShape commodityIsLotful accountUsesNoLots + isGainTyped p = (tagamountless || hasAmount p) && lookupAccountType (paccount p) == Just Gain + hasLotfulOrBasisAmount p = + any (\a -> isJust (acostbasis a) || commodityIsLotful (acommodity a)) (amountsRaw (pamount p)) + tagPostings isgain = txnTieKnot t{tpostings = map tag (tpostings t)} + where tag p = if isReal p && isgain p then tagGain verbosetags p else p + + -- Heuristic gain candidates, and whether the other postings balance without them. + isCandidate p = hasAmount p && notALE p && not (hasLotfulOrBasisAmount p) where - notALE q = case accountNameType atypes (paccount q) of - Just t -> not (isAssetType t || isLiabilityType t || isEquityType t) + notALE q = case lookupAccountType (paccount q) of + Just ty -> not (isAssetType ty || isLiabilityType ty || isEquityType ty) Nothing -> True - --- | Error for a disposal transaction containing a gain or unrealised-gain --- posting with no (or no longer any) amount. + heuristicOk = + all hasAmount realps -- with an elided amount, leave it to the balancer + && not (null candidates) + && (mixedAmountIsZero residual -- well-formed disposal: net zero + || isPricelessSale nonzeroresidual) -- unpriced sale: cost inference will resolve + where + (candidates, noncandidates) = partition isCandidate realps + residual = foldMap (mixedAmountCost . pamount) noncandidates + nonzeroresidual = filter ((/= 0) . aquantity) (amountsRaw residual) + -- The residual is a lot commodity net sold and one other commodity + -- net received. (A net purchase with a cash fee has the opposite + -- signs; there the fee is not a gain.) + isPricelessSale as = case partition (isLotCommodity . acommodity) as of + ([sold], [proceeds]) -> isNegativeAmount sold && not (isNegativeAmount proceeds) + _ -> False + isLotCommodity c = commodityIsLotful c || c `elem` disposecommodities + disposecommodities = + [acommodity a | p <- realps, hasDisposeShape p, a <- amountsRaw (pamount p)] + +-- | Apply 'transactionTagGainPostings' to each transaction. In lenient +-- (--ignore-lots) mode, amountless gain postings are left untagged, for the +-- balancer to infer like any other posting. +journalTagGainPostings :: Bool -> Bool -> Journal -> Either String Journal +journalTagGainPostings lenient verbosetags j = + Right $ journalMapTransactions + (transactionTagGainPostings (not lenient) verbosetags (journalAccountType j) (journalCommodityUsesLots j) (journalAccountUsesNoLots j)) + j + +-- | Error for a disposal with a gain posting whose amount was inferred by +-- the balancer because the disposal could only be recognised afterwards +-- (eg its amounts came from a balance assignment). amountlessErr :: Transaction -> String amountlessErr t = txnErrPrefix t - ++ "This disposal has an amountless gain or unrealised-gain posting.\n" + ++ "This disposal has an amountless gain posting, which can't be inferred here.\n" ++ "Write the amount explicitly, or omit the posting entirely\n" - ++ "(hledger will then infer both rgain and ugain from the cost basis)." + ++ "(hledger will then infer the realised gain from the cost basis)." + +-- | Error for a disposal with more than one amountless gain posting. +multipleAmountlessErr :: Transaction -> String +multipleAmountlessErr t = + txnErrPrefix t + ++ "This disposal has more than one amountless gain posting.\n" + ++ "At most one gain posting can have its amount inferred." -- | Check that no acquire posting has a disagreement between its cost basis and transacted cost. -- @@ -1210,17 +1208,16 @@ journalCheckAcquireBasis j = mapM_ checkTxn (jtxns j) >> Right j (basisStr, transactedStr) = showAmountsDistinctly oneLineNoCostFmt{displayZeroCommodity=True} basis transacted --- | For each disposal transaction with a transacted price, add a pair of balanced --- postings recognising the realised capital gain: +-- | For each disposal transaction with a transacted price, add a realised-gain +-- posting to a 'Gain'-type account (default @revenues:gain@) with +-- the negated gain amount; or if the transaction already has gain posting(s) +-- (user-written; see 'transactionTagGainPostings'), check that their sum +-- matches the calculated gain. -- --- * a realised-gain posting ('rgain') to a 'Gain'-type account (default --- @revenues:gain@) with the negated gain amount; --- * an unrealised-gain posting ('ugain') to an 'UnrealisedGain'-type account --- (default @equity:unrealised-gain@) with the gain amount, reclassifying the --- accumulated (conceptual) unrealised gain as realised. --- --- The two postings sum to zero, so the ordinary transaction balancing rule at --- transacted cost is satisfied without any special exception. +-- With the gain posting present, the disposal balances at cost basis, and +-- no counter posting is needed: hledger records gains by the historical cost +-- convention, in which unrealised gains are not posted (though they can be +-- reported from market prices). -- -- The gain amount is the disposal gain: for each dispose posting amount with -- both a cost basis and a transacted cost, contribute @aquantity * (B - T)@ @@ -1229,72 +1226,78 @@ journalCheckAcquireBasis j = mapM_ checkTxn (jtxns j) >> Right j -- contribution is @-5 * ($50 - $70) = $100@. -- -- Runs after 'journalCalculateLots' so that cost basis is populated on dispose --- postings. Both generated postings carry the @_generated-posting@ hidden tag, +-- postings. The generated posting carries the @_generated-posting@ hidden tag, -- visible as @generated-posting:@ in @print --verbose-tags@. journalAddOrCheckGainPostings :: Bool -> Journal -> Either String Journal journalAddOrCheckGainPostings verbosetags j = do - txns' <- mapM addPair (jtxns j) + txns' <- mapM addOrCheck (jtxns j) Right j{jtxns = txns'} where atypes = jaccounttypes j - rgainAccount = journalBaseGainAccount j - ugainAccount = journalBaseUnrealisedGainAccount j - - -- rgain/ugain postings are identified either by account type - -- (declared or inferred from name), or by the _ptype:rgain / _ptype:ugain - -- hidden tag that journalAddGainOrUGainPosting (and this function, for its - -- own output) attaches to generated postings. - isRgain p = accountNameType atypes (paccount p) == Just Gain - || ("_ptype", "rgain") `elem` ptags p - isUgain p = accountNameType atypes (paccount p) == Just UnrealisedGain - || ("_ptype", "ugain") `elem` ptags p + gainAccount = journalBaseGainAccount j + + -- Within a disposal, gain postings are identified by the _ptype:gain + -- hidden tag (added by transactionTagGainPostings), or by account type + -- (declared or inferred from name). Only real postings count, as in + -- transactionTagGainPostings and the balancer. + isGainTyped p = isReal p && accountNameType atypes (paccount p) == Just Gain + isGain p = isReal p && (isGainPosting p || isGainTyped p) disposeHasPrice p = isDisposePosting p && any (isJust . acost) (amountsRaw (pamount p)) - addPair t - | not (any isDisposePosting (tpostings t)) = Right t - | not (any disposeHasPrice (tpostings t)) = Right t - | any isAmountlessGain ps = Left (amountlessErr t) - | any isRgain ps || any isUgain ps = validatePair t -- already paired - | otherwise = Right (addNewPair t) + addOrCheck t + | any isGainPosting ps = fillOrCheck t -- tagged gain posting(s), possibly in a non-disposal (then the gain is zero) + | not (any disposeHasPrice ps) = Right t + | any isAmountlessGain ps = Left (amountlessErr t) + | any isGainTyped ps = checkGain t + | otherwise = Right (addGain t) where ps = tpostings t - -- A gain posting the user left amountless. Usually rejected before - -- balancing (journalAddGainOrUGainPosting), but when the disposal was - -- only detectable after balancing (eg its amounts came from a balance - -- assignment), the elided posting has an inferred amount by now, so - -- check the original posting (#2686). - isAmountlessGain p = (isRgain p || isUgain p) - && not (hasAmount (originalPosting p)) - - addNewPair t + -- An untagged gain posting the user left amountless: the disposal was + -- not recognisable when the balancer tagged gain postings, so it has + -- inferred an amount for it by now, which can't be corrected; check + -- the original posting. (Rare: balance assignments are resolved before + -- tagging, so the #2686 shape no longer gets here.) + isAmountlessGain p = isGainTyped p && not (hasAmount (originalPosting p)) + + -- With tagged gain postings: if exactly one is amountless, set its amount + -- to the calculated gain (less any other written gain amounts); otherwise + -- check the written amount(s). + fillOrCheck t = case filter (\p -> isGain p && not (hasAmount p)) (tpostings t) of + [] -> checkGain t + [_] -> Right (fillGain t) + _ -> Left (multipleAmountlessErr t) + + fillGain t = txnTieKnot t{tpostings = map fill (tpostings t)} + where + ps = tpostings t + gain = foldMap postingDisposalGain ps + written = foldMap pamount (filter (\p -> isGain p && hasAmount p) ps) + amt = setLocalGainPrecision t (maNegate gain <> maNegate written) + fill p + | isGain p && not (hasAmount p) = p{pamount = amt, poriginal = Just (originalPosting p)} + | otherwise = p + + addGain t | mixedAmountIsZero gain = t - | otherwise = txnTieKnot t{tpostings = tpostings t ++ [rgainP, ugainP]} + | otherwise = txnTieKnot t{tpostings = tpostings t ++ [gainP]} where gain = foldMap postingDisposalGain (tpostings t) - rgainP = mkGeneratedGainPosting verbosetags rgainAccount (setLocalGainPrecision t $ maNegate gain) "rgain" - ugainP = mkGeneratedGainPosting verbosetags ugainAccount (setLocalGainPrecision t gain) "ugain" + gainP = mkGeneratedGainPosting verbosetags gainAccount (setLocalGainPrecision t $ maNegate gain) - -- When a disposal already has rgain/ugain postings (user-written on - -- declared G/U accounts, or completed by journalAddGainOrUGainPosting via - -- residual-based detection), check that the gain amount matches the - -- calculated disposal gain. - -- - -- We validate using the ugain side because under residual-based detection - -- the user-written rgain may be on an undeclared account (though we now - -- tag those candidates _ptype:rgain). The auto-inserted ugain posting - -- always carries _ptype:ugain. By construction, ugainSum == disposal gain - -- when the user's rgain was correct. - validatePair t = + -- Check that the user-written gain amount(s) sum to the calculated + -- disposal gain (negated). The gain is zero when there is no priced + -- disposal, eg a gain posting mistakenly written in a lot transfer. + checkGain t = let ps = tpostings t gain = foldMap postingDisposalGain ps - ugainSum = foldMap pamount (filter isUgain ps) - -- ugain_sum should equal +gain. Tolerate sub-ULP noise at the + writtenGain = foldMap pamount (filter isGain ps) + -- writtenGain should equal -gain. Tolerate sub-ULP noise at the -- precision chosen by setLocalGainPrecision, matching how the -- balancer tolerates balancing imprecision. - diff = setLocalGainPrecision t (ugainSum <> maNegate gain) + diff = setLocalGainPrecision t (writtenGain <> gain) in if mixedAmountLooksZero diff then Right t - else Left (mismatchErr t gain ugainSum) + else Left (mismatchErr t gain writtenGain) -- Set each component amount's display precision to the entry's local -- precision for that commodity. @@ -1317,7 +1320,7 @@ journalAddOrCheckGainPostings verbosetags j = do then a{astyle = s{asprecision = Precision 0}} else a{astyle = s{asprecision = Precision 2}} - mismatchErr t gain ugainSum = + mismatchErr t gain writtenGain = txnErrPrefix t ++ "This disposal's realised gain amount is wrong.\n" ++ " written: " ++ writtenStr ++ "\n" @@ -1325,7 +1328,7 @@ journalAddOrCheckGainPostings verbosetags j = do where (writtenStr, calculatedStr) = showMixedAmountsDistinctly oneLineNoCostFmt{displayZeroCommodity=True} - (maNegate ugainSum) (maNegate gain) + writtenGain (maNegate gain) -- | The realised-capital-gain contribution from a single posting. -- @@ -1373,16 +1376,20 @@ journalAddOrCheckGainPostings verbosetags j = do -- carriers added when splitting a posting into lot subaccounts); -- * strips any lot subaccount (the trailing @{...}@ component) from each remaining -- posting's account name; --- * strips any lot-inferred cost basis (@acostbasis@) from each remaining posting's --- @pamount@. Cost basis the user wrote explicitly is preserved — only acostbasis --- that was absent on the posting's @poriginal@ (ie added by lot processing) is --- stripped. +-- * merges per-lot split fragments back into one posting (see mergeLotSplits). +-- +-- Lot-inferred cost basis annotations (@acostbasis@) are kept on the amounts: +-- 'print' shows them, so that lot entries are self-describing and can be +-- re-read without the commodity's @lots:@ declaration (under the default +-- method). A merged multi-lot posting gets an unspecified basis (@{}@), +-- meaning "lots selected by the account's method". -- -- Postings tagged @_feesplit-posting@ (synthetic fee fragments from auto-split) are -- retained so transactions stay balanced in reports like 'print'. -- -- 'print' relies on its existing 'transactionWithMostlyOriginalPostings' logic to --- revert pamount to 'poriginal' when displaying non-explicit output. +-- revert pamount to 'poriginal' when displaying non-explicit output (carrying +-- the inferred basis over). -- -- Journals with no lot content are returned unchanged. journalCollapseLotDetail :: Journal -> Journal @@ -1402,7 +1409,6 @@ journalCollapseLotDetail j | postingHasTag lotParentAssertionTagName p = Nothing | otherwise = Just p { paccount = newAcct - , pamount = mapMixedAmount stripInferredBasis (pamount p) -- An assertion originally written on the base account (eg by CSV -- balanceN rules) targets the parent, which is what this collapsed -- posting shows again; keep it, so writing out the collapsed view @@ -1420,14 +1426,6 @@ journalCollapseLotDetail j newAcct = lotBaseAccount (paccount p) -- The account the user actually wrote (before lot processing). originalAcct = paccount (originalPosting p) - -- Preserve acostbasis the user wrote explicitly on poriginal; strip any - -- acostbasis added later by lot processing. - origHasBasis = case poriginal p of - Just op -> any (isJust . acostbasis) (amountsRaw (pamount op)) - Nothing -> True - stripInferredBasis a - | origHasBasis = a - | otherwise = a{acostbasis = Nothing} -- Merge consecutive lotsplit-tagged postings that share the same original -- (poriginal) into one. The survivor's pamount is taken from its @@ -1450,7 +1448,14 @@ journalCollapseLotDetail j -- lose the amount entirely) or when a fee portion was split -- off (using the original would double-count the retained -- feesplit posting's amount). (#2692) - survivor = (untagLotsplit p){pamount = maSum (map pamount (p:run))} + -- Summing several fragments drops their (differing) cost + -- bases; show an unspecified basis instead, meaning lots + -- selected by method, which re-reads equivalently under + -- the default method. + merged = maSum (map pamount (p:run)) + hadBasis = any (any (isJust . acostbasis) . amountsRaw . pamount) (p:run) + survivor = (untagLotsplit p){pamount = if null run || not hadBasis then merged else mapMixedAmount unspecifiedBasis merged} + unspecifiedBasis a = a{acostbasis = Just (CostBasis Nothing Nothing Nothing)} in survivor : go rest | otherwise = p : go ps untagLotsplit p = p{ptags = filter ((/= lotsplitPostingTagName) . fst) (ptags p)} @@ -1507,7 +1512,9 @@ isTransferFromPosting p = ("_ptype", "transfer-from") `elem` ptags p isTransferToPosting :: Posting -> Bool isTransferToPosting p = ("_ptype", "transfer-to") `elem` ptags p --- | Check if a posting is a gain posting (has _ptype:gain tag). +-- | Check if a posting is a gain posting (has _ptype:gain tag): +-- user-written and tagged by transactionTagGainPostings, or generated by +-- journalAddOrCheckGainPostings. isGainPosting :: Posting -> Bool isGainPosting p = ("_ptype", "gain") `elem` ptags p diff --git a/hledger-lib/Hledger/Data/Posting.hs b/hledger-lib/Hledger/Data/Posting.hs index 658efe84cb9..a87117fea9d 100644 --- a/hledger-lib/Hledger/Data/Posting.hs +++ b/hledger-lib/Hledger/Data/Posting.hs @@ -820,13 +820,18 @@ postingApplyValuation :: PriceOracle -> M.Map CommoditySymbol AmountStyle -> Day postingApplyValuation priceoracle styles periodlast today v p = postingTransformAmount (mixedAmountApplyValuation priceoracle styles periodlast today (postingDate p) v) p --- | Maybe convert this 'Posting's amount to cost. +-- | Maybe convert this 'Posting's amount to cost: to cost basis where known, +-- else transacted cost (ToCost); or to transacted cost (ToTransactedCost). postingToCost :: ConversionOp -> Posting -> Maybe Posting -postingToCost NoConversionOp p = Just p -postingToCost ToCost p +postingToCost NoConversionOp p = Just p +postingToCost ToCost p = postingToCostWith mixedAmountCostBasis p +postingToCost ToTransactedCost p = postingToCostWith mixedAmountCost p + +postingToCostWith :: (MixedAmount -> MixedAmount) -> Posting -> Maybe Posting +postingToCostWith tocost p -- If this is an equity conversion posting with an associated cost nearby, ignore it | conversionPostingTagName `elem` map fst (ptags p) && nocosts = Nothing - | otherwise = Just $ postingTransformAmount mixedAmountCost p + | otherwise = Just $ postingTransformAmount tocost p where nocosts = (not . any (isJust . acost) . amountsRaw) $ pamount p diff --git a/hledger-lib/Hledger/Data/Valuation.hs b/hledger-lib/Hledger/Data/Valuation.hs index ea4736a5483..c811dc2a73d 100644 --- a/hledger-lib/Hledger/Data/Valuation.hs +++ b/hledger-lib/Hledger/Data/Valuation.hs @@ -56,10 +56,12 @@ import Data.Word (Word8) ------------------------------------------------------------------------------ -- Types --- | Which operation to perform on conversion transactions. --- (There was also an "infer equity postings" operation, but that is now done +-- | Which cost conversion to perform on amounts, if any: +-- to cost basis where known, otherwise transacted cost (-B/--value=cost); +-- or to transacted cost always (--value=transacted). +-- (There was also an "infer equity postings" operation, but that is now done -- earlier, in journal finalisation.) -data ConversionOp = NoConversionOp | ToCost +data ConversionOp = NoConversionOp | ToCost | ToTransactedCost deriving (Show,Eq) -- | What kind of value conversion should be done on amounts ? @@ -228,8 +230,9 @@ mixedAmountApplyValuation priceoracle styles periodlast today postingdate v = -- | Convert an Amount to its cost if requested, and style it appropriately. amountToCost :: M.Map CommoditySymbol AmountStyle -> ConversionOp -> Amount -> Amount -amountToCost styles ToCost = styleAmounts styles . amountCost -amountToCost _ NoConversionOp = id +amountToCost styles ToCost = styleAmounts styles . amountCostBasis +amountToCost styles ToTransactedCost = styleAmounts styles . amountCost +amountToCost _ NoConversionOp = id -- | Apply a specified valuation to this amount, using the provided -- price oracle, and reference dates. Also fix up its display style @@ -308,13 +311,14 @@ amountValueAtDate priceoracle styles mto d a = -- between the valued amount and the value of the cost basis (see -- mixedAmountApplyValuation). -- --- Note: the "cost basis" subtracted here is the sum of every contributing --- posting's transacted cost, regardless of direction. When a disposal posting --- records its sale price with @ (rather than carrying lot cost via {cost}), +-- Note: the cost subtracted here is the sum of every contributing posting's +-- cost basis where known (lot postings), else transacted cost, regardless of +-- direction. For a non-lot disposal posting recording its sale price with @, -- the negative quantity flips its cost contribution from acquisition cost to -- negated sale proceeds, so the result accumulates realised gain alongside -- any unrealised gain on remaining units. In other words, with bare @ this --- reports total gain since inception. +-- reports total gain since inception; with lot tracking, the unrealised gain +-- on the remaining units. -- -- If the commodity we are valuing in is not the same as the commodity of the -- cost, this will value the cost at the same date as the primary amount. This @@ -322,7 +326,7 @@ amountValueAtDate priceoracle styles mto d a = -- posting date. If so, let us know and we can change this behaviour. mixedAmountApplyGain :: PriceOracle -> M.Map CommoditySymbol AmountStyle -> Day -> Day -> Day -> ValuationType -> MixedAmount -> MixedAmount mixedAmountApplyGain priceoracle styles periodlast today postingdate v ma = - mixedAmountApplyValuation priceoracle styles periodlast today postingdate v $ ma `maMinus` mixedAmountCost ma + mixedAmountApplyValuation priceoracle styles periodlast today postingdate v $ ma `maMinus` mixedAmountCostBasis ma -- | Calculate the gain of each component amount, that is the -- difference between the valued amount and the value of the cost basis. @@ -333,7 +337,7 @@ mixedAmountApplyGain priceoracle styles periodlast today postingdate v ma = -- posting date. If so, let us know and we can change this behaviour. mixedAmountGainAtDate :: PriceOracle -> M.Map CommoditySymbol AmountStyle -> Maybe CommoditySymbol -> Day -> MixedAmount -> MixedAmount mixedAmountGainAtDate priceoracle styles mto d ma = - mixedAmountValueAtDate priceoracle styles mto d $ ma `maMinus` mixedAmountCost ma + mixedAmountValueAtDate priceoracle styles mto d $ ma `maMinus` mixedAmountCostBasis ma ------------------------------------------------------------------------------ -- Market price lookup diff --git a/hledger-lib/Hledger/Read/Common.hs b/hledger-lib/Hledger/Read/Common.hs index 104b464c3dc..a8dbdb5177a 100644 --- a/hledger-lib/Hledger/Read/Common.hs +++ b/hledger-lib/Hledger/Read/Common.hs @@ -395,6 +395,16 @@ journalFinalise iopts@InputOpts{auto_,balancingopts_,ignore_lots_,infer_costs_,i -- Pre-balancing cost/equity tagging >>= journalTagCostsAndEquityAndMaybeInferCosts verbose_tags_ False -- tag equity conversion postings and redundant costs, to help the transaction balancer ignore them + -- Lot cost basis and transacted cost inference, and gain posting tagging + -- These enrichment stages always run, so lot entries balance the same + -- with or without --ignore-lots; with --ignore-lots (unless overridden + -- by --strict or `hledger check lots`) they are lenient, skipping their + -- errors and leaving the affected postings/transactions unchanged. + -- They run before auto postings, whose preliminary balancing needs them too. + >>= journalInferBasisFromAccountNames lenientlots -- infer cost basis from lot subaccount names (validating them, unless lenient) + <&> journalInferPostingsTransactedCost -- in acquire-shaped postings, infer a transacted cost from cost basis + >>= journalTagGainPostings lenientlots verbose_tags_ -- in disposals, tag user-written gain postings so the balancer sets them aside + -- Auto postings >>= (if auto_ && not (null $ jtxnmodifiers pj) then journalAddAutoPostings verbose_tags_ _ioDay -- add auto postings if enabled; does preliminary transaction balancing @@ -404,15 +414,6 @@ journalFinalise iopts@InputOpts{auto_,balancingopts_,ignore_lots_,infer_costs_,i ,verbose_balancing_tags_ = verbose_tags_} else pure) - -- Lot cost basis and transacted cost inference - -- These enrichment stages always run, so lot entries balance the same - -- with or without --ignore-lots; with --ignore-lots (unless overridden - -- by --strict or `hledger check lots`) they are lenient, skipping their - -- errors and leaving the affected postings/transactions unchanged. - >>= journalInferBasisFromAccountNames lenientlots -- infer cost basis from lot subaccount names (validating them, unless lenient) - <&> journalInferPostingsTransactedCost -- in acquire-shaped postings, infer a transacted cost from cost basis - >>= journalAddGainOrUGainPosting lenientlots verbose_tags_ -- if user wrote an explicit rgain or ugain posting alone, add its counter - -- Transaction balancing >>= (\j -> if checkordereddates then journalCheckOrdereddates j $> j else Right j) -- maybe check that journal entries are in date order >>= (\j -> do @@ -453,7 +454,7 @@ journalFinalise iopts@InputOpts{auto_,balancingopts_,ignore_lots_,infer_costs_,i >>= (if checklots then journalCheckLotsMethodCoherence else pure) -- reject a global (*ALL) method mixed with other methods for one commodity >>= (if checklots then journalCalculateLots verbose_tags_ else pure) -- evaluate lot selectors, calculate lot balances, add lot subaccounts >>= (if checkbasis then journalCheckAcquireBasis else pure) -- if `hledger check basis`, error on any acquire with cost basis ≠ transacted cost - >>= (if checklots then journalAddOrCheckGainPostings verbose_tags_ else pure) -- in disposal transactions, add the realised-gain + unrealised-gain posting pair + >>= (if checklots then journalAddOrCheckGainPostings verbose_tags_ else pure) -- in disposal transactions, add the realised-gain posting, or check a user-written one <&> journalStripBalancerCopiedBases -- remove balancer-copied basis annotations, kept until now as classification evidence -- Now report any balance assertion failure detected above. diff --git a/hledger-lib/Hledger/Reports/ReportOptions.hs b/hledger-lib/Hledger/Reports/ReportOptions.hs index 7945d6e7b86..ff34b858c47 100644 --- a/hledger-lib/Hledger/Reports/ReportOptions.hs +++ b/hledger-lib/Hledger/Reports/ReportOptions.hs @@ -582,13 +582,13 @@ valuationTypeFromRawOpts rawopts = case (balancecalcopt rawopts, directval) of | n == "value" = valueopt v | otherwise = Nothing valueopt v - | t `elem` ["cost","c"] = AtEnd . Just <$> mc -- keep supporting --value=cost,COMM for now + | t `elem` ["cost","c","transacted"] = AtEnd . Just <$> mc -- keep supporting --value=cost,COMM for now | t `elem` ["then" ,"t"] = Just $ AtThen mc | t `elem` ["end" ,"e"] = Just $ AtEnd mc | t `elem` ["now" ,"n"] = Just $ AtNow mc | otherwise = case parsedate t of Just d -> Just $ AtDate d mc - Nothing -> usageError $ "could not parse \""++t++"\" as valuation type, should be: then|end|now|t|e|n|YYYY-MM-DD" + Nothing -> usageError $ "could not parse \""++t++"\" as valuation type, should be: cost|transacted|then|end|now|c|t|e|n|YYYY-MM-DD" where -- parse --value's value: TYPE[,COMM] (t,c') = break (==',') v @@ -609,6 +609,7 @@ conversionOpFromRawOpts rawopts conversionopfromrawopt (n,v) -- option name, value | n == "B" = Just ToCost | n == "value", takeWhile (/=',') v `elem` ["cost", "c"] = Just ToCost -- keep supporting --value=cost for now + | n == "value", takeWhile (/=',') v == "transacted" = Just ToTransactedCost | otherwise = Nothing -- | Parse the depth arguments. This can be either a flat depth that applies to diff --git a/hledger-ui/Hledger/UI/UIState.hs b/hledger-ui/Hledger/UI/UIState.hs index 7511a911795..91632e085a0 100644 --- a/hledger-ui/Hledger/UI/UIState.hs +++ b/hledger-ui/Hledger/UI/UIState.hs @@ -179,9 +179,10 @@ toggleEmpty = over empty__ not toggleConversionOp :: UIState -> UIState toggleConversionOp ui = (over value valOff) (over conversionop toggleCostMode ui) where - toggleCostMode Nothing = Just ToCost - toggleCostMode (Just NoConversionOp) = Just ToCost - toggleCostMode (Just ToCost) = Just NoConversionOp + toggleCostMode Nothing = Just ToCost + toggleCostMode (Just NoConversionOp) = Just ToCost + toggleCostMode (Just ToCost) = Just NoConversionOp + toggleCostMode (Just ToTransactedCost) = Just NoConversionOp valOff _ = Nothing -- | Toggle between showing primary amounts or values (using valuation specified at startup, or a default). diff --git a/hledger/Hledger/Cli/CliOptions.hs b/hledger/Hledger/Cli/CliOptions.hs index 9a91f8c42ba..e205f8299c3 100644 --- a/hledger/Hledger/Cli/CliOptions.hs +++ b/hledger/Hledger/Cli/CliOptions.hs @@ -235,7 +235,7 @@ reportflags = [ ,flagReq ["depth"] (\s opts -> Right $ setopt "depth" s opts) "DEPTHEXP" "if a number (or -NUM): show only top NUM levels of accounts. If REGEXP=NUM, only apply limiting to accounts matching the regular expression." -- valuation - ,flagNone ["B","cost"] (setboolopt "B") "convert amounts to their cost/sale amount (@/@@)" + ,flagNone ["B","cost"] (setboolopt "B") "convert amounts to their cost basis ({}), or else their cost/sale amount (@/@@)" -- ^ no "valuation mode:" prefix for this one, it's not mutually exclusive ,flagNone ["V","market"] (setboolopt "V") (unwords @@ -254,6 +254,8 @@ reportflags = [ ,"'end': value at period end(s)" ,"'now': value today" ,"YYYY-MM-DD: value on given date" + ,"'cost': cost basis, or else transacted cost (same as -B)" + ,"'transacted': transacted cost/sale amount (@/@@) only" ]) -- display diff --git a/hledger/Hledger/Cli/Commands/Balance.hs b/hledger/Hledger/Cli/Commands/Balance.hs index 9f5a2e23636..287148f52f7 100644 --- a/hledger/Hledger/Cli/Commands/Balance.hs +++ b/hledger/Hledger/Cli/Commands/Balance.hs @@ -333,7 +333,7 @@ balancemode = hledgerCommandMode ,flagNone ["valuechange"] (setboolopt "valuechange") (calcprefix ++ "show total change of value of period-end historical balances (caused by deposits, withdrawals, market price fluctuations)") ,flagNone ["gain"] (setboolopt "gain") - (calcprefix ++ "show capital gain/loss (historical balance value minus the net of postings' transacted costs; equals unrealised gain if nothing has been disposed of)") + (calcprefix ++ "show capital gain/loss (historical balance value minus cost basis, or transacted cost where there is none)") -- XXX --budget[=DESCPAT], --forecast[=PERIODEXP], could be more consistent ,flagOpt "" ["budget"] (\s opts -> Right $ setopt "budget" s opts) "DESCPAT" (unlines @@ -1320,8 +1320,9 @@ budgetReportAsTable ropts@ReportOpts{..} (PeriodicReport spans items totrow) = _ -> Nothing where costedAmounts = case conversionop_ of - Just ToCost -> amounts . mixedAmountCost - _ -> amounts . mixedAmountStripCosts -- strip any lingering cost info that would prevent unification + Just ToCost -> amounts . mixedAmountCostBasis + Just ToTransactedCost -> amounts . mixedAmountCost + _ -> amounts . mixedAmountStripCosts -- strip any lingering cost info that would prevent unification -- | Like percentage, but accept multicommodity actual and budget amounts, -- and extract the specified commodity from both. diff --git a/hledger/Hledger/Cli/Commands/Check.md b/hledger/Hledger/Cli/Commands/Check.md index 9129090fb65..d5077253cbf 100644 --- a/hledger/Hledger/Cli/Commands/Check.md +++ b/hledger/Hledger/Cli/Commands/Check.md @@ -47,7 +47,7 @@ These important checks are performed by default, by almost all hledger commands: - **lots** - all [lot](#lot-reporting) entries are valid. Checks lot posting classifications, lot movements, and that any user-written - realised or unrealised gain amounts on a disposal match the calculated gain. + realised gain amount on a disposal matches the calculated gain. This check can be disabled by `-I` or `--ignore-lots`. If you put it in your config file, you can override that with `-s`/`--strict` or `hledger check lots`. diff --git a/hledger/Hledger/Cli/Commands/Print.hs b/hledger/Hledger/Cli/Commands/Print.hs index 056b27ec66d..faa235d99e7 100644 --- a/hledger/Hledger/Cli/Commands/Print.hs +++ b/hledger/Hledger/Cli/Commands/Print.hs @@ -46,7 +46,7 @@ import Hledger.Cli.CliOptions import Hledger.Cli.Utils import Hledger.Cli.Anchor (setAccountAnchor) import System.IO qualified as IO -import Data.Maybe (isJust) +import Data.Maybe (isJust, isNothing) printmode = hledgerCommandMode $(embedFileRelative "Hledger/Cli/Commands/Print.txt") @@ -139,7 +139,7 @@ print' opts@CliOpts{rawopts_=rawopts} j = do let -- lbl = lbl_ "print'" hasPtype t p = ("_ptype", t) `elem` ptags p - isGeneratedGainPosting p = hasPtype "rgain" p || hasPtype "ugain" p + isGeneratedGainPosting p = hasPtype "gain" p setFullPrecisionExceptGain p | isGeneratedGainPosting p = p | otherwise = postingTransformAmount mixedAmountSetFullPrecision p @@ -192,7 +192,7 @@ printEntries opts@CliOpts{rawopts_=rawopts, reportspec_=rspec} j = in beancountDirectives j' ts' <> "\n" <> journalItemsAsText beancountItemRenderer (jitems j') ts' | otherwise = error' "print --export supports only the txt, ledger and beancount output formats" -- PARTIAL: where - exportWith renderer = journalItemsAsText (withoutAppliedRules renderer) (jitems j) . styleAmounts styles . map maybeoriginalamounts + exportWith renderer = journalItemsAsText (withoutAppliedRules renderer) (jitems j) . styleAmounts styles . map (maybeoriginalamountsWith False) withoutAppliedRules renderer = renderer{irDirective = \txt -> if isAppliedRule txt then Nothing else irDirective renderer txt} isAppliedRule txt = (forecasting && "~" `T.isPrefixOf` txt) || (autoposting && "=" `T.isPrefixOf` txt) forecasting = isJust $ forecast_ $ inputopts_ opts @@ -217,7 +217,11 @@ printEntries opts@CliOpts{rawopts_=rawopts, reportspec_=rspec} j = styleAmounts styles | otherwise = error' $ unsupportedOutputFormatError fmt -- PARTIAL: - maybeoriginalamounts + -- For plain print, lot postings also get the cost basis annotation inferred + -- by lot processing, making the output self-describing; not for --export, + -- which reproduces the journal's directives instead and keeps entries as written. + maybeoriginalamounts = maybeoriginalamountsWith True + maybeoriginalamountsWith showinferredbasis -- Use the fully inferred and amount-styled/rounded transaction in the following situations: -- with -x/--explicit: | boolopt "explicit" (rawopts_ opts) = id @@ -225,6 +229,7 @@ printEntries opts@CliOpts{rawopts_=rawopts, reportspec_=rspec} j = | opts ^. infer_costs = id -- with -B/-V/-X/--value ("because of #551, and because of print -V valuing only one posting when there's an implicit txn price.") | has (value . _Just) opts = id + | isJust (conversionop_ $ _rsReportOpts rspec) = id -- For transactions containing priced auto-split postings (from lot transfer -- auto-split), keep the explicit form: reverting to the original would drop -- the priced dispose fragment while keeping its generated gain postings, @@ -235,7 +240,7 @@ printEntries opts@CliOpts{rawopts_=rawopts, reportspec_=rspec} j = | otherwise = \t -> if any keptFeesplit (tpostings t) then t - else transactionWithMostlyOriginalPostings t + else transactionWithMostlyOriginalPostings showinferredbasis t where hasTag name p = name `elem` map fst (ptags p) keptFeesplit p = hasTag feesplitPostingTagName p @@ -244,8 +249,8 @@ printEntries opts@CliOpts{rawopts_=rawopts, reportspec_=rspec} j = -- Like maybeoriginalamounts, but also keeps the inferred amount for -- balance assignment postings (which had no explicit amount). -- Beancount requires all amounts to be explicit. - fillBalanceAssignments t = (maybeoriginalamounts t) - { tpostings = zipWith fillIfBalAssign (tpostings t) (tpostings $ maybeoriginalamounts t) } + fillBalanceAssignments t = (maybeoriginalamountsWith False t) + { tpostings = zipWith fillIfBalAssign (tpostings t) (tpostings $ maybeoriginalamountsWith False t) } where fillIfBalAssign inferred reverted | isJust (pbalanceassertion orig) && isMissingMixedAmount (pamount orig) = reverted { pamount = pamount inferred } @@ -260,9 +265,11 @@ printEntries opts@CliOpts{rawopts_=rawopts, reportspec_=rspec} j = -- are retained, keeping their fragment 'pamount' rather than reverting to 'poriginal' — -- so 'print --lots' shows the per-lot detail, while 'journalCollapseLotDetail' has -- already merged them down to one posting when --lots is off. +-- With a true first argument, lot postings whose original amount had no cost basis +-- annotation get the one inferred by lot processing (see withInferredBasis below). -- This is mainly for showing transactions with the amounts in their original journal format. -transactionWithMostlyOriginalPostings :: Transaction -> Transaction -transactionWithMostlyOriginalPostings t = +transactionWithMostlyOriginalPostings :: Bool -> Transaction -> Transaction +transactionWithMostlyOriginalPostings showinferredbasis t = transactionMapPostings postingMostlyOriginal t{tpostings = filter (not . hasTag feesplitPostingTagName) (tpostings t)} where @@ -295,8 +302,19 @@ transactionWithMostlyOriginalPostings t = -- show the fragment's current amount: several sibling fragments -- can't re-infer their amounts on re-reading (#2692). | hasTag lotsplitPostingTagName p = - if hasAmount orig then scaleToFragment (pamount orig) (pamount p) else pamount p - | otherwise = pamount orig + withInferredBasis $ if hasAmount orig then scaleToFragment (pamount orig) (pamount p) else pamount p + | otherwise = withInferredBasis (pamount orig) + -- Show the cost basis annotation inferred by lot processing, when the + -- user didn't write one, so lot entries are self-describing (they can + -- be re-read without the commodity's lots: declaration, under the + -- default method). Not needed with --lots, where the lot subaccount + -- name already carries it. + withInferredBasis ma + | not showinferredbasis = ma + | isJust (lotSubaccountName (paccount p)) = ma + | otherwise = case (amountsRaw ma, amountsRaw (pamount p)) of + ([oa], [ca]) | isNothing (acostbasis oa), Just cb <- acostbasis ca -> mixedAmount oa{acostbasis = Just cb} + _ -> ma scaleToFragment origAmt curAmt = case (amountsRaw origAmt, amountsRaw curAmt) of ([oa], [ca]) -> mixedAmount oa{aquantity = aquantity ca} _ -> curAmt diff --git a/hledger/Hledger/Cli/Commands/Print.md b/hledger/Hledger/Cli/Commands/Print.md index 7ff75265243..7c207ecc72f 100644 --- a/hledger/Hledger/Cli/Commands/Print.md +++ b/hledger/Hledger/Cli/Commands/Print.md @@ -161,7 +161,11 @@ But here are some things which can cause print's output to become unparseable: (and same-day postings) could be disrupted. Also, printing a subset of journal entries can disrupt validation of balance assertions or lot entries. -And print does not reproduce directives (unless `--export` is used); this too can break lot entries. +And print does not reproduce directives (unless `--export` is used). +Lot entries are printed with their inferred cost basis annotations, so they can still be read +without the commodity's `lots:` declaration when the default cost basis method is used; +but with other methods, or `lots: NONE` accounts, the missing declarations will change how they are read. +(`--export` output keeps lot entries as written, since it reproduces the declarations.) To suppress errors from these, we often use the `-I` flag (short for `--ignore-assertions --ignore-lots`. So any time you are reading from standard input with `-f-`, consider adding `-I` also. diff --git a/hledger/Hledger/Cli/Commands/Rewrite.hs b/hledger/Hledger/Cli/Commands/Rewrite.hs index 0d2f244ce5c..c88d6d0ae42 100644 --- a/hledger/Hledger/Cli/Commands/Rewrite.hs +++ b/hledger/Hledger/Cli/Commands/Rewrite.hs @@ -92,7 +92,7 @@ diffOutput postinglayout j j' = do unless (null unpatchable) . hPutStr stderr $ unpatchableWarning unpatchable T.putStr . foldMap fileDiff $ M.toAscList editsbyfile where - changed = [(transactionWithMostlyOriginalPostings t, transactionWithMostlyOriginalPostings t') + changed = [(transactionWithMostlyOriginalPostings False t, transactionWithMostlyOriginalPostings False t') | (t, t') <- zip (jtxns j) (jtxns j'), t /= t'] -- collected per file, in no particular order; fileDiff sorts them (edits, unpatchable) = partitionEithers $ map txnEdit changed diff --git a/hledger/Hledger/Cli/Commands/Roi.md b/hledger/Hledger/Cli/Commands/Roi.md index f020afee6a5..7613c897fc3 100644 --- a/hledger/Hledger/Cli/Commands/Roi.md +++ b/hledger/Hledger/Cli/Commands/Roi.md @@ -127,12 +127,9 @@ postings in the example below would be classifed as: ### Using roi with lots If your journal records [lots](#lot-reporting), each disposal transaction has a -balanced pair of gain postings, by default to `revenues:gain` (the -realised gain) and `equity:unrealised-gain` (reclassifying the -accumulated unrealised gain). Make sure `--pnl` matches both accounts, eg -`--pnl 'revenues:gain|equity:unrealised-gain'`; otherwise the -unrealised-gain postings are counted as cash flows in and out of the -investment, distorting the report. +realised gain posting, by default to `revenues:gain`. Make sure `--pnl` +matches that account, eg `--pnl revenues:gain`, so realised gains are +counted as profit rather than as cash flows out of the investment. ### IRR and TWR explained diff --git a/hledger/Hledger/Cli/Utils.hs b/hledger/Hledger/Cli/Utils.hs index 7b8fb56deb3..d4be56faaf6 100644 --- a/hledger/Hledger/Cli/Utils.hs +++ b/hledger/Hledger/Cli/Utils.hs @@ -146,8 +146,19 @@ journalTransform opts = maybePivot opts <&> maybeWarnAboutAnon opts <&> maybeObfuscate opts + <&> maybeConvertToCostBasis opts <&> maybeCollapseLotDetail opts +-- | With -B/--value=cost, convert amounts to cost basis now, before lot +-- detail is collapsed: collapsing merges a multi-lot disposal's per-lot +-- fragments (which may have different bases) into one amount with an +-- unspecified basis, after which the basis values would be lost. The later +-- per-report cost conversion is then a no-op for these amounts. +maybeConvertToCostBasis :: CliOpts -> Journal -> Journal +maybeConvertToCostBasis opts + | conversionop_ (_rsReportOpts $ reportspec_ opts) == Just ToCost = journalToCost ToCost + | otherwise = id + -- | Collapse lot-tracking detail (strip lot subaccounts, drop synthetic lot-processing -- postings) so reports show the user's original form. Skipped when --lots is set, -- or when --ignore-lots/-I is set (in which case leaf-@{...}@ accounts are diff --git a/hledger/hledger.m4.md b/hledger/hledger.m4.md index e8ba689be9e..7da62eb5614 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -5473,6 +5473,12 @@ Now when you add the `-B`/`--cost` flag to reports ("B" is from Ledger's -B/--ba any amounts which have been annotated with costs will be converted to their cost's commodity (in the report output). Ie they will be displayed "at cost" or "at sale price". +For [lot](#lot-reporting) postings, which carry a cost basis, `-B` converts to the cost basis instead: +a disposal shows what the disposed units cost, not what they sold for, +consistent with how such entries balance, so eg a lot account's cost balance is the +cost of the units still held (and zero once they are all sold), and `bse -B` balances. +To see transacted amounts (proceeds) instead, use `--value=transacted`. + Some things to note: - Costs are attached to specific posting amounts in specific transactions, and once recorded they do not change. @@ -5831,13 +5837,15 @@ Amounts for which no valuation commodity can be found are not converted. `-V` and `-X` are special cases of the more general `--value` option: - --value=TYPE[,COMM] TYPE is then, end, now or YYYY-MM-DD. + --value=TYPE[,COMM] TYPE is then, end, now, YYYY-MM-DD, cost or transacted. COMM is an optional commodity symbol. Shows amounts converted to: - default valuation commodity (or COMM) using market prices at posting dates - default valuation commodity (or COMM) using market prices at period end(s) - default valuation commodity (or COMM) using current market prices - default valuation commodity (or COMM) using market prices at some date + - cost basis, or else transacted cost (like -B) + - transacted cost only The TYPE part selects cost or value and valuation date: @@ -5858,6 +5866,15 @@ The TYPE part selects cost or value and valuation date: : Convert amounts to their value in the default valuation commodity using market prices on this date. +`--value=cost` +: Convert amounts to their cost basis where they have one (lot postings), + otherwise to their transacted cost. Same as `-B`/`--cost`; see [Reporting at cost](#reporting-at-cost). + +`--value=transacted` +: Convert amounts to their transacted cost or sale amount (`@`/`@@`), + ignoring any cost basis. This is `-B`'s behaviour for non-lot postings, + and shows proceeds rather than cost for lot disposals. + To select a different valuation commodity, add the optional `,COMM` part: a comma, then the target commodity's symbol. Eg: **`--value=now,EUR`**. hledger will do its best to convert amounts to this commodity, deducing @@ -6157,22 +6174,23 @@ Lot tracking is activated for AAPL by the `lots` tag, so hledger will - keep track of each lot that's acquired (and optionally show them in reports) - dispose of lots in the right order (FIFO by default) -- calculate the resulting capital gain, and add the gain postings if missing +- calculate the resulting capital gain, and add the gain posting if missing - check for many kinds of error (such as selling more than you have). -`print` shows the inferred gain postings: +`print` shows the inferred cost basis annotations and gain posting +(so its output can be re-read even without the `commodity` directive, +if the default cost basis method is used): ```cli $ hledger print 2026-01-15 buy assets:cash $-500 - assets:stocks 10 AAPL @ $50 + assets:stocks 10 AAPL {$50} @ $50 2026-02-01 sell some - assets:stocks -5 AAPL @ $70 + assets:stocks -5 AAPL {2026-01-15, $50} @ $70 assets:cash $350 revenues:gain $-100 - equity:unrealised-gain $100 ``` @@ -6188,7 +6206,6 @@ $ hledger print --lots assets:stocks:{2026-01-15, $50} -5 AAPL @ $70 assets:cash $350 revenues:gain $-100 - equity:unrealised-gain $100 ``` @@ -6203,8 +6220,7 @@ $ hledger print -a 2026-02-01 sell some assets:stocks:{2026-01-15, $50} -5 AAPL {2026-01-15, $50} @ $70 ; ptype: dispose assets:cash $350 - revenues:gain $-100 ; ptype: rgain, generated-posting: - equity:unrealised-gain $100 ; ptype: ugain, generated-posting: + revenues:gain $-100 ; ptype: gain, generated-posting: ``` @@ -6522,8 +6538,8 @@ so the remaining units' basis is unchanged, but no gain or loss is recognised, and the receiving posting holds unpriced commodity units. To have the gain calculated, give the receiving posting a transacted price. -When the gain postings are inferred (not written explicitly), -hledger sizes them from the disposal side only: +When the gain posting is inferred (not written explicitly), +hledger sizes it from the disposal side only: the sum of `quantity × (B − T)` over the entry's dispose postings. Acquire postings in the same entry don't contribute to the inferred gain (any acquire-side `B ≠ T` would be rejected at load time anyway, see [Acquire](#acquire) above). @@ -6710,71 +6726,77 @@ either of two ways: ## Gain postings -Each disposal transaction will have a balanced pair of postings recording the capital gain (or loss): - -- **realised gain** (rgain) on a Gain-type account - the gain/loss, negated -- **unrealised gain** (ugain) on an UnrealisedGain-type account - same amount, opposite sign - -Conceptually, the market value of a lot fluctuates from acquisition onward, accumulating unrealised gain or loss. -At disposal, that accumulated unrealised gain is reclassified as realised gain. - -Gain postings can be left implicit (and hledger will infer them); -or they can be written explicitly in the journal. +Each disposal transaction has a **gain posting**, usually on a Gain-type account, +recording the capital gain (or loss) as revenue: the gain, negated. + +A disposal balances at cost basis, not at transacted cost: +the disposed units count as their quantity times their cost basis (what they cost you), +the proceeds are what you received, and the gain posting accounts for the difference. +Eg selling 5 AAPL bought at $50 for $70 each: `-5 AAPL {$50} @ $70` counts as -$250, `$350` is received, +and `revenues:gain $-100` balances the entry. +(This is the historical cost accounting convention, and is how hledger 1 users have recorded gains. +Unrealised gains are not recorded as postings, but can be reported from market prices, +eg with [holdings](#holdings) or [`--gain`](#valuation-type). +Consequently the accounting equation stays balanced through disposals.) + +The gain posting can be left implicit (and hledger will infer it); +or it can be written explicitly in the journal. [Recording gains](#recording-gains) below shows examples of both. -In inferred gain postings, the gain amount will be rounded to the entry's local precision for the gain commodity +In an inferred gain posting, the gain amount will be rounded to the entry's local precision for the gain commodity (or if the local precision is zero, two decimal digits will be shown - except when both of those digits are zero). More decimals can be seen by increasing the display precision (eg `hledger print --round=soft -c '$1.0000'`). -In explicit gain postings, the amount must also be written. -(Usually hledger fills in missing amounts, but not in gain postings.) +An explicit gain posting can also be written without an amount, +in which case hledger fills in the calculated gain (like Style 1, but with your choice of account). +At most one gain posting per entry can be left amountless. -### Gain and UnrealisedGain accounts +### Gain accounts -The **`Gain`** (**`G`**) and **`UnrealisedGain`** (**`U`**) [account types](#account-types) -are subtypes of Revenue and Equity respectively. -Declaring an account with the G type (and optionally one with the U type) serves three purposes: +The **`Gain`** (**`G`**) [account type](#account-types) is a subtype of Revenue. +Declaring an account with the G type serves three purposes: 1. **Recognising gain postings** - explicit gain postings which use the G account will be detected reliably by account type, and error messages may be clearer. 2. **Customising account names** - - inferred gain postings will use the first-declared G and U accounts, - rather than the defaults (`revenues:gain` and `equity:unrealised-gain`). -3. **Categorising** - when reporting, you can match on the G (or U) account type specifically. + inferred gain postings will use the first-declared G account, + rather than the default (`revenues:gain`). +3. **Categorising** - when reporting, you can match on the G account type specifically. -G and U are inferred from conventional English account names -(eg `revenues:gain`, `income:capital-gains`, `equity:unrealised-gain`, `equity:unrealized gains`; +G is inferred from conventional English account names +(eg `revenues:gain`, `income:capital-gains`; see the regex table under [Account types](#account-types)). -You can also declare them explicitly: +You can also declare it explicitly: ```journal account revenues:capital gain ; type: G -account equity:unrealised gain ; type: U ``` +(There is also an **`UnrealisedGain`** (**`U`**) account type, a subtype of Equity, +for accounts like `equity:unrealised-gain`; hledger does not currently generate postings to these.) + Why do we post both gains and losses to a revenue account ? It's more convenient than using separate revenue and expense accounts, and the sign keeps things correct. ## Recording gains -In the journal, you can write disposal transactions in four main styles, +In the journal, you can write disposal transactions in three main styles, with different tradeoffs between brevity, error checking, and robustness. From least to most explicit, they are: -- **Style 1:** Don't write gain postings (let hledger infer them) -- **Style 2:** Write the rgain posting, using a non-G account (undeclared gain posting) -- **Style 3:** Write the rgain posting, using a G account (declared gain posting, more reliable) -- **Style 4:** Write both rgain and ugain postings, using G/U accounts (most explicit) +- **Style 1:** Don't write a gain posting (let hledger infer it) +- **Style 2:** Write the gain posting, using a non-G account (undeclared gain posting) +- **Style 3:** Write the gain posting, using a G account (declared gain posting, more reliable) Style 1 just calculates the gain. -Styles 2-4 calculate the gain and compare it with what you wrote, potentially catching more errors. +Styles 2 and 3 calculate the gain and compare it with what you wrote, potentially catching more errors. **TLDR:** use Style 1 for brevity and to avoid most problems, or Style 3 for best error checking while still being concise. -### Style 1: No gain postings +### Style 1: No gain posting ```journal 2026-02-01 sell @@ -6782,7 +6804,7 @@ or Style 3 for best error checking while still being concise. assets:cash $60 ``` -### Style 2: rgain posting, non-G account +### Style 2: gain posting, non-G account ```journal 2026-02-01 sell @@ -6805,18 +6827,39 @@ Another example, this time with multiple gain postings: hledger identifies these "undeclared" gain postings by their characteristics: postings whose account type is not Asset, Liability, or Equity (or a subtype); and which have not been classified as lot postings (acquire, transfer, dispose); -and without which the remaining postings in the entry balance to zero. +and without which the remaining postings in the entry balance to zero +(or, when the sale has no price, form a plain sale: the lot commodity +net sold, one other commodity net received). -So above, the `gains` postings are detected, their amounts are checked against the calculated value, -and an unrealised gain posting is added to balance them. +So above, the `gains` postings are detected, set aside when balancing the entry +(which then balances at cost basis, as described above), +and their amounts are checked against the calculated gain. Any other imbalance - eg a typo in non-gain postings — is reported as a standard balance error, not silently absorbed as gain. -This heuristic detection of gain postings can fail -(eg when the entry has an undeclared gain posting and also a fee posting like `expenses:fees`). -You can check it with `hledger print -a`. -It is just a convenience so that you don't have to always declare a type G account, for simple entries. +This heuristic detection of gain postings can fail when the entry has other +non-asset postings too, such as a fee. Here both `expenses:fees` and `gains` +look like gain candidates, the remaining postings don't balance without them, +so neither is detected, and the entry is reported as unbalanced: + +```journal +2026-02-01 sell, with a fee + assets:stocks -1 AAPL {$50} @ $60 + assets:cash $59 + expenses:fees $1 + gains $-10 +``` + +In such cases, declare the gain account's type (Style 3 below): + +```journal +account gains ; type:G +``` + +You can check the detection with `hledger print -a`. +This heuristic exists as a convenience for simple entries; +declaring the gain account's type is the more reliable method. -### Style 3: rgain posting, G account +### Style 3: gain posting, G account ```journal account revenues:gain ; type:G @@ -6827,30 +6870,20 @@ account revenues:gain ; type:G revenues:gain $-10 ``` -### Style 4: rgain and ugain postings, G and U accounts - -```journal -account revenues:gain ; type:G -account equity:unrealised-gain ; type:U - -2026-02-01 sell - assets:stocks -1 AAPL {$50} @ $60 - assets:cash $60 - revenues:gain $-10 - equity:unrealised-gain $10 -``` +(Earlier hledger 2 previews also generated an `equity:unrealised-gain` counter posting in each disposal, +so that disposals balanced at transacted cost. This is no longer done, and such postings, +if written explicitly, will now leave the entry unbalanced; remove them.) ### Gain postings and the roi command When using the [roi](#roi) command with a journal that records lots, -make sure `--pnl` matches both gain accounts, eg: +make sure `--pnl` matches the gain account, eg: ```cli -$ hledger roi --inv assets:stocks --pnl 'revenues:gain|equity:unrealised-gain' +$ hledger roi --inv assets:stocks --pnl revenues:gain ``` -Otherwise the unrealised-gain postings added to each disposal (see above) -are counted as cash flows in and out of the investment, distorting the report. +so that realised gains are counted as profit rather than as cash flows out of the investment. ## Lot postings and balance assertions @@ -6944,7 +6977,7 @@ Holdings on 2026-03-31 || $850 $100 ``` -and `print -x --lots` shows the inferred lot subaccounts and gain postings. +and `print -x --lots` shows the inferred lot subaccounts and gain posting. 5 shares were acquired at $50 and sold at $70 = 5 × ($70 - $50) = $100 gain (remember that revenue amounts appear negative). ``` @@ -6953,7 +6986,6 @@ $ hledger print desc:sell -x --lots assets:stocks:{2026-01-15, $50} -5 AAPL {2026-01-15, $50} @ $70 assets:cash $350 revenues:gain $-100 - equity:unrealised-gain $100 ``` And as shown in [First lots example](#first-lots-example), `print -a` also shows the lot posting classifications. @@ -7137,9 +7169,7 @@ To also add visible tags, for troubleshooting, use `print`'s `--verbose-tags` or | `ptype:dispose` | Negative postings with lot annotations, or in a lotful commodity, with no matching counterposting | Selects and reduces existing lots | | `ptype:transfer-from` | The negative posting of a pair of counterpostings, at least one with lot annotation or a lotful commodity; or a negative lot posting with an equity counterpart (equity transfer) | Moves lots between accounts, preserving cost basis | | `ptype:transfer-to` | The positive posting of a transfer pair; or a positive lot posting with an equity counterpart (equity transfer, e.g. opening balances) | As above | -| `ptype:gain` | A user-written posting to a `Gain`-type account | Marks the user's explicit realised gain posting in a disposal | -| `ptype:rgain` | A generated realised-gain posting on a `Gain`-type account | Marks hledger-inferred realised capital gain/loss in a disposal | -| `ptype:ugain` | A generated unrealised-gain posting on an `UnrealisedGain`-type account | Marks the balancing posting so the disposal sums to zero at transacted cost | +| `ptype:gain` | In a disposal: a user-written posting to a `Gain`-type account (or, failing that, a non-asset/liability/equity posting without which the entry balances); or the realised-gain posting hledger generates | Records the realised capital gain/loss; set aside by the transaction balancer, so the disposal balances at cost basis | # PART 5: MORE ABOUT THE COMMAND LINE @@ -7908,8 +7938,8 @@ Things that can need attention: Rename such accounts, or use `-I` or `--ignore-lots`. - **Explicit gain postings** in disposal entries are checked against the calculated gain, and a mismatch is an error. - Also, accounts with conventional names like `revenues:gain` or `equity:unrealised-gain` - are now given the Gain and UnrealisedGain [account types](#account-types), + Also, accounts with conventional names like `revenues:gain` + are now given the Gain [account type](#account-types), and inferred gain postings will use them. See [Gain postings](#gain-postings). - `-I` or `--ignore-lots` skips lot tracking, gain calculation and lot checks, @@ -7935,11 +7965,11 @@ Journals written for hledger 2 are mostly readable by hledger 1, with these cave So you may need to write the transacted cost too: `10 AAA {$50} @ $50`. (We recommend this anyway; see [Cost basis annotations](#cost-basis-annotations).) - hledger 1 rejects `type: U` account declarations (`type: G` is fine). - (hledger 2 doesn't need them, unless you want to customise the account used for unrealised gain.) -- hledger 1 does not infer gain postings. - A disposal entry with only a realised gain posting written (`revenues:gain $-50`) + (hledger 2 doesn't need them either.) +- hledger 1 does not infer gain postings, and balances disposals at transacted cost, + so a disposal entry with a realised gain posting written (`revenues:gain $-50`) is unbalanced in hledger 1. - Write the unrealised gain posting too (`equity:unrealised-gain $50`), or write neither. + Omit the gain posting instead (hledger 2 infers it; hledger 1 records no gain). See [Recording gains](#recording-gains). - `print --lots` output includes lot subaccount names like `assets:stocks:{2026-01-01, $50}`; hledger 1 reads these as ordinary subaccounts. @@ -7951,9 +7981,9 @@ Journals written for hledger 2 are mostly readable by hledger 1, with these cave To keep the same journal working in both hledger 1 and hledger 2: - write acquisitions with both cost basis and cost, with the same amount in each: `10 AAA {$50} @ $50` -- write disposals with both gain postings, or with neither - (then hledger 2 infers them, and hledger 1 shows no gain) -- name gain accounts conventionally (`revenues:gain`, `equity:unrealised-gain`) rather than declaring `type: U` +- write disposals without a gain posting + (then hledger 2 infers it, and hledger 1 shows no gain) +- don't declare `type: U` accounts (hledger 1 rejects them, hledger 2 doesn't need them) - avoid account names ending in `{...}` - after changes, check the journal with both versions, eg `hledger check` and `hledger1 check` diff --git a/hledger/test/check-lots.test b/hledger/test/check-lots.test index de1ba038feb..524c7b825c2 100644 --- a/hledger/test/check-lots.test +++ b/hledger/test/check-lots.test @@ -110,7 +110,7 @@ $ hledger -f - check lots # ** 11. --ignore-lots disables lot checking but not basic lot inference, # so entries which balance with lots enabled still balance: here the -# unrealised gain counterpart posting is still generated. +# user-written gain posting is still set aside by the balancer. < 2026-01-15 buy assets:checking $-500 @@ -130,7 +130,6 @@ $ hledger -f - print -I assets:stocks -5 AAPL {$50} @ $55 assets:cash $275 revenues:gains $-25 - equity:unrealised-gain $25 >= diff --git a/hledger/test/journal/infer-costbasis.test b/hledger/test/journal/infer-costbasis.test index 63e27fd67c6..36f11a0d882 100644 --- a/hledger/test/journal/infer-costbasis.test +++ b/hledger/test/journal/infer-costbasis.test @@ -54,7 +54,7 @@ commodity AAPL ; lots: $ hledger -f - print --verbose-tags 2024-01-01 buy stock - assets:brokerage 10 AAPL @@ $1000 ; ptype: acquire + assets:brokerage 10 AAPL {$100} @@ $1000 ; ptype: acquire assets:cash >= diff --git a/hledger/test/journal/lots-acquire-entries.test b/hledger/test/journal/lots-acquire-entries.test index 3f161dfdc91..60e0aac7b8c 100644 --- a/hledger/test/journal/lots-acquire-entries.test +++ b/hledger/test/journal/lots-acquire-entries.test @@ -245,8 +245,8 @@ $ hledger -f- print --lots --verbose-tags >= # *** 18. An acquire entry with an unbalanced virtual posting in the lotful -# commodity: virtual postings don't trigger gain inference (which would -# add a spurious ugain posting, unbalancing the entry). The real posting +# commodity: virtual postings don't trigger gain detection (which would +# set a posting aside, unbalancing the entry). The real posting # is a normal acquire, and the entry balances within display precision. < commodity $0.00 diff --git a/hledger/test/journal/lots-acquire.test b/hledger/test/journal/lots-acquire.test index a65159e6ebc..f85c031bc9a 100644 --- a/hledger/test/journal/lots-acquire.test +++ b/hledger/test/journal/lots-acquire.test @@ -161,7 +161,7 @@ commodity AAPL ; lots: $ hledger -f- print --verbose-tags 2026-01-15 buy - assets:stocks 10 AAPL @ $50 ; ptype: acquire + assets:stocks 10 AAPL {$50} @ $50 ; ptype: acquire assets:cash $-500 >= @@ -187,7 +187,7 @@ commodity AAPL ; lots: $ hledger -f- print --verbose-tags 2026-01-15 buy - assets:stocks 10 AAPL ; ptype: acquire + assets:stocks 10 AAPL {$50} ; ptype: acquire assets:cash $-500 >= @@ -231,7 +231,7 @@ commodity AAPL ; lots: $ hledger -f- print --verbose-tags date:2026-02-01 2026-02-01 transfer assets:old -10 AAPL {$50} ; ptype: transfer-from - assets:new 10 AAPL ; ptype: transfer-to + assets:new 10 AAPL {2026-01-15, $50} ; ptype: transfer-to >= @@ -249,8 +249,8 @@ commodity AAPL ; lots: $ hledger -f- print --verbose-tags date:2026-02-01 2026-02-01 transfer - assets:old -10 AAPL ; ptype: transfer-from - assets:new 10 AAPL ; ptype: transfer-to + assets:old -10 AAPL {2026-01-15, $50} ; ptype: transfer-from + assets:new 10 AAPL {2026-01-15, $50} ; ptype: transfer-to >= diff --git a/hledger/test/journal/lots-dispose-entries.test b/hledger/test/journal/lots-dispose-entries.test index 9d0b1715e6a..5f3b013b257 100644 --- a/hledger/test/journal/lots-dispose-entries.test +++ b/hledger/test/journal/lots-dispose-entries.test @@ -20,7 +20,6 @@ $ hledger -f- print desc:sell --lots --verbose-tags equity:opening:{2026-01-01, $50} -5 AAPL {$50} @ $55 ; ptype: dispose assets:cash $275 revenue:gains $-25 ; ptype: gain - equity:unrealised-gain $25 ; ptype: ugain, generated-posting: >= @@ -40,7 +39,6 @@ $ hledger -f- print desc:sell --lots --verbose-tags assets:broker:{2026-01-01, $50} -5 AAPL {$50} @ $55 ; ptype: dispose assets:cash $275 revenue:gains $-25 ; ptype: gain - equity:unrealised-gain $25 ; ptype: ugain, generated-posting: >= @@ -63,7 +61,6 @@ $ hledger -f- print desc:sell --lots --verbose-tags assets:broker:{2026-01-01, $50} -5 AAPL ; ptype: dispose assets:cash $275 revenue:gains $-25 ; ptype: gain - equity:unrealised-gain $25 ; ptype: ugain, generated-posting: >= @@ -84,7 +81,6 @@ $ hledger -f- print desc:sell --lots --verbose-tags assets:broker:{2026-01-01, $50} -5 AAPL @ $55 ; ptype: dispose assets:cash $275 revenue:gains $-25 ; ptype: gain - equity:unrealised-gain $25 ; ptype: ugain, generated-posting: >= @@ -105,7 +101,6 @@ $ hledger -f- print desc:sell --lots --verbose-tags assets:broker:{2026-01-01, $50} -5 AAPL {$50} ; ptype: dispose assets:cash $275 revenue:gains $-25 ; ptype: gain - equity:unrealised-gain $25 ; ptype: ugain, generated-posting: >= @@ -126,7 +121,6 @@ $ hledger -f- print desc:sell --lots --verbose-tags assets:broker:{2026-01-01, $50} -5 AAPL {} @ $55 ; ptype: dispose assets:cash $275 revenue:gains $-25 ; ptype: gain - equity:unrealised-gain $25 ; ptype: ugain, generated-posting: >= @@ -150,7 +144,6 @@ $ hledger -f- print desc:sell --lots --verbose-tags assets:broker:{2026-01-01, $50} -5 AAPL {$50} @ $55 ; ptype: dispose assets:cash $275 capital gains $-25 ; ptype: gain - equity:unrealised-gain $25 ; ptype: ugain, generated-posting: >= @@ -171,7 +164,6 @@ $ hledger -f- print desc:sell --lots --verbose-tags assets:broker:{2026-01-01, $50} -5 AAPL {$50} @@ $275 ; ptype: dispose assets:cash $275 revenue:gains $-25 ; ptype: gain - equity:unrealised-gain $25 ; ptype: ugain, generated-posting: >= @@ -227,8 +219,7 @@ $ hledger -f- print desc:sell --lots --verbose-tags 2026-02-01 sell assets:broker:{2026-01-01, $50} -5 AAPL {$50} @ $55 ; ptype: dispose assets:cash $275 - revenues:gain $-25 ; ptype: rgain, generated-posting: - equity:unrealised-gain $25 ; ptype: ugain, generated-posting: + revenues:gain $-25 ; ptype: gain, generated-posting: >= @@ -250,7 +241,7 @@ $ hledger -f- print desc:sell --lots >= # *** 13. dispose with an explicit gain posting to an undeclared account. -# The imbalance is assumed to be rgain; a corresponding ugain posting is inferred. +# The imbalance is assumed to be the gain, and set aside by the balancer. < commodity AAPL ; lots: @@ -268,7 +259,6 @@ $ hledger -f- print desc:sell --lots --verbose-tags assets:broker:{2026-01-01, $50} -5 AAPL {$50} @ $55 ; ptype: dispose assets:cash $275 revenues:gain $-25 ; ptype: gain - equity:unrealised-gain $25 ; ptype: ugain, generated-posting: >= @@ -307,7 +297,7 @@ $ hledger -f- print desc:sell --lots >2 /unbalanced/ >=1 -# *** 16. dispose with typo'd cash plus correct rgain on undeclared account → balance error. +# *** 16. dispose with typo'd cash plus correct gain posting on undeclared account → balance error. # The non-candidate postings' sum is single-commodity non-zero, so no gain posting is detected, # and the balancer reports the imbalance. < @@ -327,7 +317,7 @@ $ hledger -f- print desc:sell --lots >=1 # *** 17. multi-lot dispose with per-lot gains postings. -# All gain postings are detected and a single balancing ugain posting is inferred. +# All gain postings are detected and set aside by the balancer. < commodity AAPL ; lots: @@ -353,7 +343,6 @@ $ hledger -f- print desc:sell --lots --verbose-tags assets:cash $480 revenues:gain $-50 ; ptype: gain, gain on lot1 revenues:gain $-15 ; ptype: gain, gain on lot2 - equity:unrealised-gain $65 ; ptype: ugain, generated-posting: >= @@ -361,7 +350,7 @@ $ hledger -f- print desc:sell --lots --verbose-tags # hiding sub-cent noise from B-T arithmetic. Here 9.216 × 1801.215277778 # = 16600.000000002, so the calculated gain is Rs2803.240000002... but the # entry's local Rs precision is 2 dp (from Rs19403.24), so the inferred -# rgain/ugain are saved at 2 dp. +# gain is saved at 2 dp. < 2017-04-09 buy assets:fund 9.216 FUND {Rs1801.215277778} @@ -376,11 +365,10 @@ $ hledger -f- print desc:sell --lots assets:savings Rs19403.24 assets:fund:{2017-04-09, Rs1801.215277778} -9.216 FUND {Rs1801.215277778} @@ Rs19403.24 revenues:gain Rs-2803.24 - equity:unrealised-gain Rs2803.24 >= -# *** 19. A user-written rgain that differs from the calculated gain by more +# *** 19. A user-written gain that differs from the calculated gain by more # than half a ULP at the entry's local precision is rejected, and the error # message shows the actual amounts at full precision (so any real # discrepancy is visible). @@ -404,7 +392,7 @@ hledger: Error: -:7: | assets:fund -9.216 FUND {Rs1801.215277778} @@ Rs19403.24 | income:capital Rs-2803.30 -Postings were read as: unclassified, dispose, gain; and generated: ugain. +Postings were read as: unclassified, dispose, gain. This disposal's realised gain amount is wrong. written: Rs-2803.30 calculated: Rs-2803.239999997952 diff --git a/hledger/test/journal/lots-dispose.test b/hledger/test/journal/lots-dispose.test index 31e86c216aa..3534bbfcc90 100644 --- a/hledger/test/journal/lots-dispose.test +++ b/hledger/test/journal/lots-dispose.test @@ -149,7 +149,6 @@ $ hledger -f- print --lots assets:stocks:{2026-01-15, $50} -5 AAPL {2026-01-15, $50} @ $60 assets:cash $300 revenue:gains $-50 - equity:unrealised-gain $50 >= @@ -206,7 +205,6 @@ $ hledger -f- print --lots -x desc:sell assets:stocks:{2026-02-01, $55} -5 AAPL {2026-02-01, $55} @ $60 assets:cash $900 revenues:gain $-125 - equity:unrealised-gain $125 >= @@ -231,7 +229,6 @@ $ hledger -f- print --lots -x desc:sell assets:stocks:{2026-02-01, $55} -5 AAPL {2026-02-01, $55} @ $60 assets:cash $900 revenues:gain $-125 - equity:unrealised-gain $125 >= @@ -253,10 +250,9 @@ commodity AAPL ; lots: $ hledger -f- print desc:sell 2026-03-01 sell - assets:stocks -15 AAPL @ $60 + assets:stocks -15 AAPL {} @ $60 assets:cash revenues:gain $-125 - equity:unrealised-gain $125 >= @@ -264,10 +260,9 @@ $ hledger -f- print desc:sell # stripped because the user didn't write one), but fills in inferred amounts. $ hledger -f- print -x desc:sell 2026-03-01 sell - assets:stocks -15 AAPL @ $60 + assets:stocks -15 AAPL {} @ $60 assets:cash $900 revenues:gain $-125 - equity:unrealised-gain $125 >= @@ -279,7 +274,6 @@ $ hledger -f- print --lots desc:sell assets:stocks:{2026-02-01, $55} -5 AAPL @ $60 assets:cash revenues:gain $-125 - equity:unrealised-gain $125 >= @@ -322,7 +316,6 @@ $ hledger -f- print --lots -x desc:sell assets:stocks 0 AAPL =* 0 AAPL assets:cash $1400 revenues:gain $-300 - equity:unrealised-gain $300 >= @@ -413,7 +406,6 @@ $ hledger -f- print --lots -x desc:sell assets:stocks:{2026-01-15, $50} -5 AAPL {2026-01-15, $50} @ $60 assets:cash $300 revenues:gain $-50 - equity:unrealised-gain $50 >= @@ -518,7 +510,6 @@ commodity AAPL ; lots: $ hledger -f- bal --lots -N $-359 assets:cash 5.0 AAPL assets:stocks:{2026-01-15, $80} - $49 equity:unrealised-gain 0.1 AAPL expenses:fees $-49 revenue:gains @@ -547,7 +538,6 @@ $ hledger -f- print -x --lots desc:transfer assets:cold wallet:{2026-03-06, $1,980.78} 0.999601 ETH {2026-03-06, $1,980.78} expenses:fees:trading 0.000399 ETH @ $1,992.36 revenues:gain $0 - equity:unrealised-gain $0 >= @@ -579,7 +569,6 @@ $ hledger -f- print --lots --infer-equity equity:conversion:$-AAPL:AAPL 10 AAPL equity:conversion:$-AAPL:$ $-600 income:capital $-100 - equity:unrealised-gain $100 >= @@ -618,7 +607,7 @@ $ hledger -f- print --lots >=1 # ** 34. An imprecise per-unit cost basis produces sub-Rs0.005 noise in -# calculated gain. The user-written rgain matches at the entry's local +# calculated gain. The user-written gain matches at the entry's local # Rs precision (2 dp), so it is accepted and the entry is balanced. # 9.216 × 1801.215277778 = 16600.000000002, off by 0.000000002 from the # Rs16600.00 paid; this rounds away at the entry's 2 dp tolerance. @@ -643,7 +632,6 @@ $ hledger -f- print --lots assets:savings Rs19403.24 assets:fund:{2017-04-09, Rs1801.215277778} -9.216 FUND {Rs1801.215277778} @@ Rs19403.24 income:capital Rs-2803.24 - equity:unrealised-gain Rs2803.24 >= @@ -720,8 +708,7 @@ $ hledger -f- print -a date:2026-02 2026-02-01 sell all assets:broker1:{2026-01-01, $4.34782609} -2.3 ABC {2026-01-01, $4.34782609} @@ $25 = 0 ABC ; ptype: dispose assets:cash $25 - revenues:gain $-15 ; ptype: rgain, generated-posting: - equity:unrealised-gain $15 ; ptype: ugain, generated-posting: + revenues:gain $-15 ; ptype: gain, generated-posting: >= @@ -749,7 +736,6 @@ $ hledger -f- print --lots -x assets:broker1:{2021-03-08, $3.33333333} -3 ABC {2021-03-08, $3.33333333} @ $4 assets:cash $12 revenues:gain $-2 - equity:unrealised-gain $2 >= @@ -817,3 +803,460 @@ $ hledger -f- bal --lots -N 40 AAA assets:broker:{2022-02-01, "0002", $0} $-100 assets:cash -20 AAA equity:bonus + +# ** 42. Disposals balance at cost basis, with a single realised-gain posting +# and no unrealised-gain counterpart. So the accounting equation holds: after +# selling everything and closing revenue to equity, the books balance. #2731 +< +commodity AAPL ; lots: + +2026-01-01 opening + equity:opening $-500 + assets:cash $500 + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -10 AAPL @ $70 + assets:cash $700 + +2026-03-01 retain + revenues:gain $200 + equity:retained $-200 +$ hledger -f- bal + $700 assets:cash + $-500 equity:opening + $-200 equity:retained +-------------------- + 0 + +# ** 43. With an explicit gain posting, a missing cash amount is inferred +# from the proceeds, as if the gain posting were not there. +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash + revenues:gain $-100 +$ hledger -f- print -x +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL {$50} @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL {2026-02-01, $50} @ $70 + assets:cash $350 + revenues:gain $-100 + +>= + +# ** 44. Likewise a missing transacted price is inferred from the proceeds. +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL + assets:cash $350 + revenues:gain $-100 +$ hledger -f- print -x +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL {$50} @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL {2026-02-01, $50} @@ $350 + assets:cash $350 + revenues:gain $-100 + +>= + +# ** 45. A gain posting in a lot transfer (which has no gain) is an error. +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 transfer + assets:stocks -5 AAPL + assets:broker 5 AAPL + revenues:gain $-100 +$ hledger -f- print +>2 /realised gain amount is wrong. + written: \$-100 + calculated: 0/ +>=1 + +# ** 46. An old-style unrealised-gain counter posting is no longer expected, +# and unbalances the entry. +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash $350 + revenues:gain $-100 + equity:unrealised-gain $100 +$ hledger -f- print +>2 /sum should be 0 but is \$100 + \$-350 \+ \$350 \+ \$100 = \$100 \(excluding gain postings\)/ +>=1 + +# ** 47. But an unrealised-gain posting with its own asset-side counterpart +# (eg reversing a manual revaluation) is an ordinary balanced pair. +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash $350 + revenues:gain $-100 + assets:stocks:revaluation $-100 + equity:unrealised-gain $100 +$ hledger -f- bal -N + $-150 assets:cash + 5 AAPL assets:stocks + $-100 assets:stocks:revaluation + $100 equity:unrealised-gain + $-100 revenues:gain + +# ** 48. An explicit gain posting also works with --auto (whose preliminary +# balancing runs early). +< +commodity AAPL ; lots: + += assets:cash + (budget) *0.1 + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash $350 + revenues:gain $-100 +$ hledger -f- print --auto +2026-02-01 buy + assets:cash $-500 + (budget) $-50 + assets:stocks 10 AAPL {$50} @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL {2026-02-01, $50} @ $70 + assets:cash $350 + (budget) $35 + revenues:gain $-100 + +>= + +# ** 49. And with --ignore-lots, an entry with an explicit gain posting still loads +# (the gain amount is not checked). +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash $350 + revenues:gain $-99 +$ hledger -f- print --ignore-lots +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash $350 + revenues:gain $-99 + +>= + +# ** 50. print shows the cost basis annotations inferred by lot processing, +# so its output is self-describing: the entries below (as printed from a +# journal with a `commodity AAPL ; lots:` declaration) are read the same way +# without that declaration. A disposal from several lots shows `{}`. +< +2026-01-01 buy + assets:cash $-500 + assets:stocks 10 AAPL {$50} @ $50 + +2026-01-02 buy + assets:cash $-600 + assets:stocks 10 AAPL {$60} @ $60 + +2026-02-01 sell + assets:stocks -5 AAPL {2026-01-01, $50} @ $70 + assets:cash $350 + revenues:gain $-100 + +2026-03-01 sell more + assets:stocks -10 AAPL {} @ $70 + assets:cash $700 + revenues:gain $-150 +$ hledger -f- bal --lots -N + $-50 assets:cash + 5 AAPL assets:stocks:{2026-01-02, $60} + $-250 revenues:gain + +# ** 51. And a multi-lot disposal is printed with the unspecified basis `{}`. +< +commodity AAPL ; lots: + +2026-01-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-01-02 buy + assets:cash $-600 + assets:stocks 10 AAPL @ $60 + +2026-02-01 sell + assets:stocks -15 AAPL @ $70 + assets:cash +$ hledger -f- print desc:sell +2026-02-01 sell + assets:stocks -15 AAPL {} @ $70 + assets:cash + revenues:gain $-250 + +>= + +# ** 52. A gain posting written without an amount is filled in with the +# calculated gain (and shown as written by plain print, explicitly by -x). +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash $350 + revenues:gain +$ hledger -f- print -x desc:sell +2026-03-01 sell + assets:stocks -5 AAPL {2026-02-01, $50} @ $70 + assets:cash $350 + revenues:gain $-100 + +>= + +# ** 53. Both an amountless gain posting and an elided amount are inferred. +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash + revenues:gain +$ hledger -f- print -x desc:sell +2026-03-01 sell + assets:stocks -5 AAPL {2026-02-01, $50} @ $70 + assets:cash $350 + revenues:gain $-100 + +>= + +# ** 54. With several gain postings, one may be amountless; it receives the +# remainder of the calculated gain. +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash $350 + revenues:gain $-60 + revenues:gain:rest +$ hledger -f- print -x desc:sell +2026-03-01 sell + assets:stocks -5 AAPL {2026-02-01, $50} @ $70 + assets:cash $350 + revenues:gain $-60 + revenues:gain:rest $-40 + +>= + +# ** 55. But more than one amountless gain posting is an error. +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash $350 + revenues:gain + revenues:gain:other +$ hledger -f- print +>2 /more than one amountless gain posting/ +>=1 + +# ** 56. print --export reproduces the declarations, so it keeps lot entries +# as written, without the inferred annotations. +< +commodity AAPL ; lots: + +2026-01-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-02-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash +$ hledger -f- print --export +commodity AAPL ; lots: + +2026-01-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-02-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash + revenues:gain $-100 + +>= + +# ** 57. -B converts lot postings at cost basis: after a partial sale the +# stock account's cost balance is the cost of the units still held. +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash $350 +$ hledger -f- bal -B -N assets:stocks + $250 assets:stocks + +# ** 58. --value=transacted shows the transacted amounts (net cash invested) instead. +$ hledger -f- bal --value=transacted -N assets:stocks + $150 assets:stocks + +# ** 59. So cost reports balance, like the entries: after selling everything +# and closing revenue to equity, bal -B sums to zero and shows no stock cost. +< +commodity AAPL ; lots: + +2026-01-01 opening + equity:opening $-500 + assets:cash $500 + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -10 AAPL @ $70 + assets:cash $700 + +2026-03-01 retain + revenues:gain $200 + equity:retained $-200 +$ hledger -f- bal -B + $700 assets:cash + $-500 equity:opening + $-200 equity:retained +-------------------- + 0 + +# ** 60. Also for a disposal from several lots, whose per-lot fragments are +# merged in non---lots reports (the conversion happens before merging). +< +commodity AAPL ; lots: + +2026-01-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-01-02 buy + assets:cash $-600 + assets:stocks 10 AAPL @ $60 + +2026-02-01 sell + assets:stocks -15 AAPL @ $70 + assets:cash +$ hledger -f- reg -B assets:stocks +2026-01-01 buy assets:stocks $500 $500 +2026-01-02 buy assets:stocks $600 $1100 +2026-02-01 sell assets:stocks $-800 $300 + +# ** 61. print -B shows lot postings converted too. +< +commodity AAPL ; lots: + +2026-02-01 buy + assets:cash $-500 + assets:stocks 10 AAPL @ $50 + +2026-03-01 sell + assets:stocks -5 AAPL @ $70 + assets:cash $350 +$ hledger -f- print -B desc:sell +2026-03-01 sell + assets:stocks $-250 + assets:cash $350 + revenues:gain $-100 + +>= + +# ** 62. A purchase with a cash fee, and a fee paid in the lot commodity, +# is not mistaken for a disposal with a gain posting: the entry's net +# shape is a purchase (lot commodity received, cash paid), so the cash +# fee posting is not tagged as a gain and the entry loads normally. +< +commodity A ; lots: + +2026-01-01 buy A, paying fees in $ and A + assets:cc 10 A + assets:cc -0.1 A + expenses:fees 0.1 A + expenses:fees $2 + assets:cash $-102 +$ hledger -f- print -x --verbose-tags +2026-01-01 buy A, paying fees in $ and A + assets:cc 10 A {$10} @@ $100 ; ptype: acquire + assets:cc -0.1 A {2026-01-01, $10} ; ptype: dispose + expenses:fees 0.1 A + expenses:fees $2 + assets:cash $-102 + +>= diff --git a/hledger/test/journal/lots-errors.test b/hledger/test/journal/lots-errors.test index f77e565e8a8..0defa405422 100644 --- a/hledger/test/journal/lots-errors.test +++ b/hledger/test/journal/lots-errors.test @@ -180,9 +180,10 @@ Possible fixes: add a cost basis ({$X}), a price (@ $X), or check the account type declaration. >=1 -# ** 13. An amountless gain posting in a disposal is rejected with the same -# clear error when the disposal's amounts are inferred by balancing (eg from -# a balance assignment) as when they are explicit (#2686). +# ** 13. An amountless gain posting is filled in with the calculated gain, +# also when the disposal is only recognisable after a balance assignment +# has been resolved (#2686): gain postings are set aside inside the balancer, +# once amounts are known. < commodity A ; lots: 2026-01-15 buy @@ -195,19 +196,16 @@ commodity A ; lots: expenses:fees 0.05 A @ $150 income:gains -$ hledger -f- bal assets --lots 2>&1 -hledger: Error: -:6: -6 | 2026-02-17 transfer with priced fee - | assets:x1 = 0 A - | assets:x2 0.95 A - | expenses:fees 0.05 A @ $150 - | income:gains - -Postings were read as: transfer-from, transfer-to, unclassified, gain; and generated: dispose, balance-assertion. -This disposal has an amountless gain or unrealised-gain posting. -Write the amount explicitly, or omit the posting entirely -(hledger will then infer both rgain and ugain from the cost basis). ->=1 +$ hledger -f- print -x --lots desc:transfer +2026-02-17 transfer with priced fee + assets:x1:{2026-01-15, $100} -0.95 A {2026-01-15, $100} + assets:x1:{2026-01-15, $100} -0.05 A {2026-01-15, $100} @ $150 + assets:x1 0 A =* 0 A + assets:x2:{2026-01-15, $100} 0.95 A {2026-01-15, $100} + expenses:fees 0.05 A @ $150 + income:gains $-2.50 + +>= # ** 14. Referencing a lot that doesn't exist (here, a label the lot doesn't # have) reports the non-matching selector and lists the account's available diff --git a/hledger/test/journal/lots-methods.test b/hledger/test/journal/lots-methods.test index f2ddbaa7f04..af3b5609ea9 100644 --- a/hledger/test/journal/lots-methods.test +++ b/hledger/test/journal/lots-methods.test @@ -419,8 +419,7 @@ $ hledger -f- bal assets:broker --lots -N # ** 20. AVERAGE: two acquisitions form a $50 pool; two disposals consume it. # The {date, $50} cost annotation on each disposal posting, and the inferred -# revenues:gain / equity:unrealised-gain postings, all reflect the shared -# pool cost. Acquires keep the user's literal per-acquisition cost. +# revenues:gain postings, all reflect the shared pool cost. Acquires keep the user's literal per-acquisition cost. < commodity AAPL ; lots: AVERAGE 2026-05-01 buy lot 1 @@ -452,13 +451,11 @@ $ hledger -f- print --lots -x assets:stocks:{2026-05-01} -10 AAPL {2026-05-01, $50} @ $100 assets:cash $1000 revenues:gain $-500 - equity:unrealised-gain $500 2026-05-04 second sale assets:stocks:{2026-05-02} -10 AAPL {2026-05-02, $50} @ $100 assets:cash $1000 revenues:gain $-500 - equity:unrealised-gain $500 >= @@ -495,13 +492,11 @@ $ hledger -f- print --lots -x assets:broker1:{2026-01-15} -10 AAPL {2026-01-15, $60} @ $100 assets:cash $1000 revenues:gain $-400 - equity:unrealised-gain $400 2026-04-01 sell from broker2 assets:broker2:{2026-02-01} -10 AAPL {2026-02-01, $60} @ $100 assets:cash $1000 revenues:gain $-400 - equity:unrealised-gain $400 >= @@ -537,7 +532,6 @@ $ hledger -f- print --lots -x assets:stocks:{2026-01-15} -5 AAPL {2026-01-15, $40} @ $50 assets:cash $250 revenues:gain $-50 - equity:unrealised-gain $50 2026-03-01 buy 2 assets:stocks:{2026-03-01} 10 AAPL {$70} @ $70 @@ -547,7 +541,6 @@ $ hledger -f- print --lots -x assets:stocks:{2026-01-15} -5 AAPL {2026-01-15, $60} @ $80 assets:cash $400 revenues:gain $-100 - equity:unrealised-gain $100 >= @@ -580,10 +573,9 @@ $ hledger -f- bal --lots 5 A assets:avg:{2026-01-01} 10 A assets:avg:{2026-02-01} $-150 assets:cash - $75 equity:unrealised-gain $-75 revenues:gain -------------------- - $-150 + $-225 15 A # ** 24. A transfer out of an AVERAGE pool carries the pooled cost ($20) @@ -616,10 +608,9 @@ account assets:avg ; lots: AVERAGE $ hledger -f- bal --lots 10 A assets:avg:{2026-02-01} $100 assets:cash - $300 equity:unrealised-gain $-300 revenues:gain -------------------- - $100 + $-200 10 A # ** 25. print --lots -x of a transfer into an AVERAGE account: both @@ -708,10 +699,9 @@ $ hledger -f- bal --lots 10 AAPL assets:broker2:{2026-02-01} $350 assets:cash $-1200 assets:checking - $50 equity:unrealised-gain $-50 revenues:gain -------------------- - $-850 + $-900 15 AAPL # ** 28. A global (*ALL) method mixed with a different method for the same diff --git a/hledger/test/journal/lots-transfer.test b/hledger/test/journal/lots-transfer.test index b4218b1a412..908284bbf2a 100644 --- a/hledger/test/journal/lots-transfer.test +++ b/hledger/test/journal/lots-transfer.test @@ -71,7 +71,7 @@ $ hledger -f- print --verbose-tags 2026-02-01 transfer assets:broker1 -10 AAPL {$50} ; ptype: transfer-from - assets:broker2 10 AAPL ; ptype: transfer-to + assets:broker2 10 AAPL {2026-01-15, $50} ; ptype: transfer-to >= @@ -93,7 +93,7 @@ $ hledger -f- print --verbose-tags 2026-02-01 transfer assets:broker1 -10 AAPL {$50} ; ptype: transfer-from - assets:broker2 10 AAPL ; ptype: transfer-to + assets:broker2 10 AAPL {2026-01-15, $50} ; ptype: transfer-to >= @@ -211,9 +211,9 @@ commodity A ; lots: $ hledger -f- print --verbose-tags date:2026-02-17 2026-02-17 reclassify with fee - assets:cc -1 A ; ptype: transfer-from - assets:cc 1 A ; ptype: transfer-to - assets:cc -0.02 A ; ptype: dispose + assets:cc -1 A {2026-01-15, $100} ; ptype: transfer-from + assets:cc 1 A {2026-01-15, $100} ; ptype: transfer-to + assets:cc -0.02 A {2026-01-15, $100} ; ptype: dispose expenses:fees $2 >= @@ -467,7 +467,6 @@ $ hledger -f- print --lots -x desc:transfer expenses:fees 0.05 A @ $150 assets:x2:{2026-01-15, $100} 0.95 A {2026-01-15, $100} revenues:gain $-2.50 - equity:unrealised-gain $2.50 >= @@ -489,12 +488,11 @@ commodity A ; lots: $ hledger -f- print desc:transfer 2026-02-17 transfer with priced fee - assets:x1 -0.95 A - assets:x1 -0.05 A @ $150 + assets:x1 -0.95 A {2026-01-15, $100} + assets:x1 -0.05 A {2026-01-15, $100} @ $150 expenses:fees 0.05 A @ $150 - assets:x2 0.95 A + assets:x2 0.95 A {2026-01-15, $100} revenues:gain $-2.50 - equity:unrealised-gain $2.50 >= @@ -518,7 +516,6 @@ $ hledger -f- print --lots desc:transfer expenses:fees 0.05 A @ $150 assets:x2:{2026-01-15, $100} 0.95 A {2026-01-15, $100} revenues:gain $-2.50 - equity:unrealised-gain $2.50 >= @@ -545,7 +542,6 @@ $ hledger -f- print -x --lots desc:transfer expenses:fees 0.05 A @ $150 assets:x2:{2026-01-15, $100} 0.95 A {2026-01-15, $100} revenues:gain $-2.50 - equity:unrealised-gain $2.50 >= @@ -588,9 +584,9 @@ commodity A ; lots: $ hledger -f- print desc:transfer 2026-02-17 transfer with fee - assets:x1 -1 A + assets:x1 -1 A {2026-01-15, $100} expenses:fees 0.05 A - assets:x2 0.95 A + assets:x2 0.95 A {2026-01-15, $100} >= @@ -616,7 +612,6 @@ $ hledger -f- print --lots -x desc:transfer expenses:fees 0.05 A @ $150 assets:x2:{2026-01-15, $100} 0.95 A {2026-01-15, $100} revenues:gain $-2.50 - equity:unrealised-gain $2.50 >= @@ -668,7 +663,6 @@ $ hledger -f- print --lots -x desc:transfer assets:x2:{2026-01-15, $100} 0.95 A {2026-01-15, $100} expenses:fees 0.05 A @ $150 revenues:gain $-2.50 - equity:unrealised-gain $2.50 >= @@ -800,20 +794,20 @@ commodity ABC ; lots: $ hledger -f- print -x 2020-03-08 one assets:cash $-10 - assets:broker1 2 ABC @@ $10 + assets:broker1 2 ABC {$5} @@ $10 2020-03-08 two assets:cash $-10 - assets:broker1 3 ABC @@ $10 + assets:broker1 3 ABC {$3.33333333} @@ $10 2020-04-04 three - assets:broker1 -5 ABC - assets:transfers 5 ABC + assets:broker1 -5 ABC {} + assets:transfers 5 ABC {} 2020-04-05 four - assets:transfers -4.9 ABC - assets:transfers -0.1 ABC - assets:broker2 4.9 ABC + assets:transfers -4.9 ABC {} + assets:transfers -0.1 ABC {2020-03-08, "0001", $5} + assets:broker2 4.9 ABC {} expenses:fees 0.1 ABC >= @@ -1038,8 +1032,7 @@ $ hledger -f- print -a date:2020-04 assets:broker2:{2020-03-08, $2} 4.9 ABC {2020-03-08, $2} ; ptype: transfer-to assets:broker1:{2020-03-08, $2} -0.1 ABC {2020-03-08, $2} @@ $1 ; ptype: dispose expenses:fees $1 - revenues:gain $-0.80 ; ptype: rgain, generated-posting: - equity:unrealised-gain $0.80 ; ptype: ugain, generated-posting: + revenues:gain $-0.80 ; ptype: gain, generated-posting: >= @@ -1065,8 +1058,7 @@ $ hledger -f- print -a date:2020-04 assets:broker2:{2020-03-08, $2} 4.9 ABC {2020-03-08, $2} ; ptype: transfer-to assets:broker1:{2020-03-08, $2} -0.1 ABC {2020-03-08, $2} @@ $1 ; ptype: dispose expenses:fees $1 - revenues:gain $-0.80 ; ptype: rgain, generated-posting: - equity:unrealised-gain $0.80 ; ptype: ugain, generated-posting: + revenues:gain $-0.80 ; ptype: gain, generated-posting: >=