Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion doc/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 49 additions & 78 deletions doc/PLAN-ugain.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 15 additions & 15 deletions doc/SPEC-finalising.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand Down
Loading