Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
eaa0121
feat: holdings: new command showing investment holdings (phase 1, lay…
simonmichael Aug 4, 2026
add9b16
feat: holdings: show real holdings with cost basis
simonmichael Aug 4, 2026
c7ec9fe
imp: holdings: add --round support
simonmichael Aug 4, 2026
80206fa
feat: holdings: add Price, Value and Gain columns
simonmichael Aug 4, 2026
2d496e4
imp holdings: support -V, -X, --value
simonmichael Aug 4, 2026
6c00585
imp: holdings: use a pager, and support -o, like other reports
simonmichael Aug 4, 2026
4223df5
imp: holdings: support --tree, --depth and --no-elide
simonmichael Aug 4, 2026
d5fbc74
imp: holdings: add -S/--sort-amount
simonmichael Aug 4, 2026
61fc393
imp: holdings: add csv and tsv output
simonmichael Aug 4, 2026
e2996d4
;doc: lots: mention the holdings command in lot reporting docs
simonmichael Aug 4, 2026
0535914
imp: holdings: add html output
simonmichael Aug 4, 2026
a0040f9
;examples: add a sample hledger.css for styling HTML output
simonmichael Aug 4, 2026
2c7173a
imp: holdings: add fods output
simonmichael Aug 4, 2026
a56bb8c
imp: holdings: add json output
simonmichael Aug 4, 2026
cda59e2
feat: holdings: add Weight, Rgain and XIRR columns, and humanise ages
simonmichael Aug 4, 2026
7938956
imp: holdings: add --drop
simonmichael Aug 4, 2026
e374f60
;tools: just holdings-hledger, holdings-examples
simonmichael Aug 4, 2026
8a8c072
imp: holdings: rename the Quantity column to Units
simonmichael Aug 4, 2026
e7a24be
doc: roi, lots: examples comparing these reports; interop advice
simonmichael Aug 4, 2026
caa0db2
dev: fix missing embedded file declaration
simonmichael Aug 4, 2026
cd570eb
imp: holdings: split the Gain column into UGain and UGain%; rename Rgain
simonmichael Aug 5, 2026
3922e67
imp: holdings: percent columns are stylable via the % commodity
simonmichael Aug 5, 2026
7811b45
imp: holdings: show UGain% unsigned, like the other percent columns
simonmichael Aug 5, 2026
b3e9bf6
imp: holdings: move the Price column before Cost
simonmichael Aug 5, 2026
8b53bfb
imp: holdings: support --title
simonmichael Aug 5, 2026
15b473a
;examples: rename lot-entries.journal -> lots.journal
simonmichael Aug 5, 2026
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
17 changes: 12 additions & 5 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -1872,14 +1872,21 @@ installcommithook:
@holdings-beancount *ARGS:
bean-report examples/example.beancount holdings {{ ARGS }}

holdings-beancount-examples *ARGS:
# Show a rledger sample holdings report.
@holdings-rledger *ARGS:
rledger report examples/example.beancount holdings {{ ARGS }}

# Show a hledger sample holdings report.
@holdings-hledger *ARGS:
hledger -f examples/lots/lots.journal holdings {{ ARGS }}

# Show several holdings report examples.
holdings-examples:
just holdings-beancount
just holdings-beancount --by root-account
just holdings-beancount --by account
just holdings-beancount --by commodity
just holdings-beancount --by currency

# Show a rledger sample holdings report.
@holdings-rledger *ARGS:
rledger report examples/example.beancount holdings {{ ARGS }}
just holdings-rledger
just holdings-hledger

255 changes: 255 additions & 0 deletions doc/SPEC-holdings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
# holdings command

Specification / design notes for the `holdings` command, which shows a standard
report of investment holdings (lotful assets).

See also
- SPEC-lots.md
- hledger manual: Lot reporting

Status: implemented; see Phases below.

## Goal

A standard, convenient report answering "what investments do I hold right now,
what did they cost, what are they worth, how are they doing ?".
Comment thread
simonmichael marked this conversation as resolved.

Functionally it is like a balancesheet report that is always single-period,
showing one row per holding and several useful attribute columns.
It should reuse the standard balance report machinery, supporting the usual
row-display modes: `--list` (default), `--tree`, `--depth`, `--drop`,
`--alias`, sorting, and the standard output formats (txt, csv, tsv, html,
json...).

## Rows

- Rows are the accounts holding lotful commodities (ie accounts with lot
subaccounts), plus any query arguments to narrow them further.
Cash and other non-lotful accounts don't appear.
- Lot subaccounts follow the standard `--lots` display toggle:
hidden (aggregated into their base account) by default,
shown as rows with `--lots`.
- A totals row is shown at the bottom (disable with `-N/--no-total`).
- The text output's title, "Holdings on DATE", can be customised with
`--title=TEXT` or suppressed with `--title=""`.

## Columns

| Column | Meaning |
|-----------|----------------------------------------------------------------|
| Date | the lot's acquisition date |
| Age | how long the lot has been held, as of the report date |
| Units | number of units held |
| Unit cost | cost basis per unit ("Avg cost" on rows aggregating lots) |
| Price | current market price per unit |
| Cost | total cost basis |
| Value | current market value (Units x Price) |
| Weight | percentage of the portfolio's total value |
| UGain | unrealised gain: Value - Cost |
| UGain% | unrealised gain as a percentage of Cost |
| RGain | realised gain from disposals so far |
| XIRR | annualised internal rate of return, like roi's IRR |

Notes:
- On rows aggregating multiple lots, Date and Age are blank,
and Unit cost shows the average cost (column titled "Avg cost";
it is titled "Unit cost" when `--lots` is in effect).
(Alternatives considered for aggregated Date/Age: oldest lot's date/age,
a date range, quantity-weighted average age.)
- Age is shown in days, or from one year in years with one decimal digit,
eg `44d`, `1.1y`, approximating years as 365 days. (A long/short-term
capital gains indicator could be added later.) The csv/json outputs
keep age as a number of days.
- Weight is each row's value as a percentage of the portfolio's total
value; blank unless all displayed holdings are priced in one commodity.
- RGain sums each dispose posting's proceeds minus the cost basis of the
disposed units, for the lots at or under the row's account. Fully
disposed lots have no row of their own (eg with --lots), but their
realised gains are included in the totals row, which computes RGain
and XIRR from the displayed rows' base accounts - consistent across
display modes. Fully disposed accounts don't appear in the report at
all, so neither do their realised gains.
- XIRR solves for the annualised rate of return implied by the account's
dated cashflows (acquisitions at transacted or basis cost, disposals at
proceeds) plus its current value, like roi's IRR (using ridders,
rate**(days/365.25)); it thus includes realised gains. Blank when
unpriced, when cashflow commodities differ from the value commodity
(eg under -X), or when unsolvable.
The final cashflow is the displayed Value, treated as received at the
report date (even if `--value` priced it at a different date) -
consistent with the Value and UGain columns.
- Rows with no known market price show blank Price, Value and gain columns,
rather than pretending the gain is zero.
- Amounts are displayed normalised to their commodity's display precision
by default (unlike lot names, which can show more precision);
`--round` can select another rounding strategy (default: hard).
- The percent columns (Weight, UGain%, XIRR) are shown with 1 decimal
digit, or with the display style of the "%" commodity if any
(eg by a commodity directive or `-c '0.00 %'`), so their precision and
symbol placement can be customised. The csv/tsv/json outputs keep
bare percent numbers.
- The totals row (shown unless -N) shows only the commodity-independent
columns: Cost, Value, Weight (100%), UGain, UGain%, RGain, XIRR.
- Possible future columns: a long/short-term capital gains indicator.

## Valuation
- Prices are market prices at the report date, from P directives, and from
transaction costs with `--infer-market-prices`, looked up with the standard
price oracle. Each holding is valued in its cost commodity when possible
(so UGain = Value - Cost is meaningful); otherwise in the default valuation
commodity.
- UGain and UGain% are shown when the value and cost are in a
single common commodity.
- `-V`/`-X COMM`/`--value=end|now|DATE[,COMM]` select the valuation
commodity and/or valuation date. The cost columns (Cost, Unit/Avg cost,
and the cost side of UGain) are then also converted to the valuation
commodity at the valuation date, so percent gain is unaffected by
currency conversion. Costs with no market price to the valuation
commodity are left unconverted (making UGain blank).
- `--value=then` is not supported (holdings is a snapshot report).
- `-B/--cost` has no effect; units always stay units.

## Layout examples

Scenario: two AAPL buys in assets:broker:stocks, one MSFT buy in
assets:broker:funds, a FIFO sale of 5 AAPL at $70, and P directives
(AAPL $72, MSFT $410) on the report date 2026-03-31.

Default (list mode, lot subaccounts hidden):

```
$ hledger holdings
Holdings on 2026-03-31

|| Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR
======================++===============================================================================================
assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $410 $2000 $2050 65.5% $50 2.5% 22.7%
assets:broker:stocks || 15 AAPL $56.67 $72 $850 $1080 34.5% $230 27.1% $100 419.4%
----------------------++-----------------------------------------------------------------------------------------------
|| $2850 $3130 100.0% $280 9.8% $100 137.8%
```

(assets:broker:funds holds a single lot, so its Date/Age are shown even though
lots are hidden; assets:broker:stocks aggregates two lots, so they are blank.
funds has no RGain because nothing was disposed from it; stocks' $100 realised
gain appears on its row and in the totals.)

With `--lots` (lot subaccounts become rows; Avg cost becomes exact Unit cost):

```
$ hledger holdings --lots
Holdings on 2026-03-31

|| Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR
========================================++================================================================================================
assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $410 $2000 $2050 65.5% $50 2.5% 22.7%
assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $72 $250 $360 11.5% $110 44.0% $100 759.2%
assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $72 $600 $720 23.0% $120 20.0% 215.2%
----------------------------------------++------------------------------------------------------------------------------------------------
|| $2850 $3130 100.0% $280 9.8% $100 137.8%
```

With `--lots --tree` (parent rows aggregate; multi-commodity cells go
multi-line as in bal; boring parents are squashed as usual):

```
$ hledger holdings --lots --tree
Holdings on 2026-03-31

|| Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR
==============================++================================================================================================
assets || 15 AAPL $72 $2850 $3130 100.0% $280 9.8% $100 137.8%
|| 5 MSFT $410
broker || 15 AAPL $72 $2850 $3130 100.0% $280 9.8% $100 137.8%
|| 5 MSFT $410
funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $410 $2000 $2050 65.5% $50 2.5% 22.7%
stocks || 15 AAPL $56.67 $72 $850 $1080 34.5% $230 27.1% $100 419.4%
{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $72 $250 $360 11.5% $110 44.0% $100 759.2%
{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $72 $600 $720 23.0% $120 20.0% 215.2%
------------------------------++------------------------------------------------------------------------------------------------
|| $2850 $3130 100.0% $280 9.8% $100 137.8%
```

With `--depth 2` (aggregation up the tree):

```
$ hledger holdings --depth 2
Holdings on 2026-03-31

|| Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR
===============++=========================================================================================
assets:broker || 15 AAPL $72 $2850 $3130 100.0% $280 9.8% $100 137.8%
|| 5 MSFT $410
---------------++-----------------------------------------------------------------------------------------
|| $2850 $3130 100.0% $280 9.8% $100 137.8%
```

## Implementation notes

- The holdings command receives the journal with lot detail uncollapsed,
regardless of --lots (see maybeCollapseLotDetail in Hledger.Cli.Utils);
it aggregates lots itself.
- Rows come from a single-period, end-balances (Historical) multiBalanceReport:
run on the lot-detailed journal with --lots (rows are lot subaccounts),
or on the collapsed journal otherwise (rows are the base accounts).
Rows without lots beneath them are filtered out.
(balanceReport was considered but it is just a thin projection of
multiBalanceReport; MBR keeps the row structure, totals and valuation
machinery we need.)
- Per-lot units are summed from the lot subaccounts' postings
(amount arithmetic discards cost basis, so balances alone don't suffice).
Each lot's cost basis is parsed back from the lot subaccount name, which by
construction contains the acquisition date and unit cost.
- Rendering via Text.Tabular.AsciiWide as in Balance.hs.

## Phases

1. Layout mockup: skeleton `holdings` command printing the sample layout above. (done)
2. Real report in list mode: rows from the journal's lotful accounts, with
Date, Age, Units, Unit/Avg cost and Cost columns; --lots; totals row;
functional tests (hledger/test/holdings.test). --tree errors out. (done)
3. Valuation columns: Price, Value, Gain, with market prices from the
standard price oracle; blank when no market price is known. (done)
4. `--tree`/`--depth` behavior: parent rows aggregate the lots beneath
them, showing only lot-tracked commodities; totals sum only the topmost
displayed rows, avoiding double counting. (done)
5. Sorting: `-S/--sort-amount` sorts rows by Value (falling back to Cost),
largest first; tree mode sorts each subtree level, keeping subtrees
together. (done)
6. CSV/TSV output: one record per row and commodity, with full account
names, age in days, bare units and gain percent numbers, Gain and
Gain% as separate fields, and no totals records. (done)
HTML output: like the text table, but with single-line cells, via the
spreadsheet-cell machinery. Amount cells are right-aligned; each cell
has a css class naming its column (plus coltotal on the totals row),
and each commodity amount is in a span with class "amount". (done)
FODS output: the same single-line cells, via printFods. (done)
JSON output: an array of holding objects with the CSV fields;
units and gain percents use hledger's usual JSON number
encoding, missing values are null. (done)
7. Extra columns: Weight (portfolio %), humanised Age (eg 1.1y),
Rgain (realised gain), XIRR; also added to the csv/tsv/json outputs
(with age still numeric there). (done)
8. Docs integration: mention holdings in the manual's lot reporting
sections (First lots example, Lot subaccounts, Lot reporting example).
(done)

## Open questions

### Future-dated postings

Without an explicit report end date (eg set by -e),
holdings includes future-dated postings in its units
(like other hledger reports), but prices are computed as of the
valuation date, and ages as of the report end date (both are today by default).
This means future positions are typically valued/aged as of today,
future-dated lots will show a negative age, and future P directives are invisible.

An alternative was tried (2026-08-04) and rolled back: defaulting the
report end date to today (treating a missing end date as today+1 in the
lot posting query, the internal multiBalanceReport query, and the report
date), like hledger-ui's hiding of future transactions. It was judged
not worth the inconsistency with other reports, for now; the motivating
example (examples/lots/lots.journal needing -e to show prices)
was fixed instead by moving its story dates into the past. Could be
revisited if future-dated journals prove troublesome in practice.
4 changes: 2 additions & 2 deletions doc/SPEC-lots.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Here is the current specification for lots functionality, most of which has been
See also
- hledger manual: Cost basis
- hledger manual: Lot reporting
- <https://github.com/simonmichael/hledger/blob/main/examples/lots/lot-entries.journal>
- <https://github.com/simonmichael/hledger/blob/main/examples/lots/lots.journal>
- <https://joyful.com/hledger+lot+tracking>
- <https://github.com/simonmichael/hledger/issues/1015>

Expand Down Expand Up @@ -714,7 +714,7 @@ Display behaviour:

For end-to-end walkthroughs, see the user manual's "First lots example" and "Lot reporting example" sections.

A larger collection of example entries: <https://github.com/simonmichael/hledger/blob/main/examples/lots/lot-entries.journal>
A larger collection of example entries: <https://github.com/simonmichael/hledger/blob/main/examples/lots/lots.journal>

### Disposal

Expand Down
65 changes: 65 additions & 0 deletions examples/lots/irr.journal
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# A minimal example for comparing roi and holdings.

commodity AAPL ; lots:

2026-01-15 buy
assets:stocks 10 AAPL @ $50
assets:cash -$500

2026-02-01 sell some at a gain
assets:stocks -5 AAPL @ $70
assets:cash $350

P 2026-03-31 AAPL $72


comment

#############################################################################
Comparing roi and holdings

$ hledger -f examples/lots/irr.journal roi --inv assets:stocks --pnl 'revenues:gain|equity:unrealised-gain' -e 2026-04-01 --value=end,$
+---++------------+------------++---------------+----------+-------------+------++----------++------------+----------+
| || Begin | End || Value (begin) | Cashflow | Value (end) | PnL || IRR || TWR/period | TWR/year |
+===++============+============++===============+==========+=============+======++==========++============+==========+
| 1 || 2026-01-15 | 2026-03-31 || 0 | $150 | $360 | $210 || 1809.07% || -1.39% | -6.50% |
+---++------------+------------++---------------+----------+-------------+------++----------++------------+----------+

$ hledger -f examples/lots/irr.journal holdings -N -e 2026-04-02
Holdings on 2026-04-01

|| Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR
===============++==============================================================================================
assets:stocks || 2026-01-15 76d 5 AAPL $50 $72 $250 $360 100.0% $110 44.0% $100 1809.1%

1. roi's PnL $210 = holdings' UGain $110 + RGain $100.

Both reach it from the same facts: $500 was invested, $350 came back
from the sale (roi's net Cashflow of $150), and the remaining 5 AAPL
are worth $360 at the P price. roi computes PnL as value change
minus net cashflow ($360 − $0 − $150); holdings splits the same
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,$
with -e 2026-04-01 puts roi's valuation on the same date and price
as holdings'.

2. holdings XIRR 1809.1% = roi IRR 1809.07% (holdings rounds to one decimal).


holdings' -e 2026-04-02 moves the report date to 2026-04-01, which
is exactly roi's discounting reference (its exclusive span end)
under roi -e 2026-04-01. Both measure each cashflow's distance from
2026-04-01 with the terminal $360 at distance zero, so they solve
the identical equation. The valuation is unaffected because the
price lookup still finds the P 2026-03-31 price of $72 from either
date.

If both commands are given the same -e DATE, holdings anchors at
DATE−1 (the report date it displays in the title) while roi anchors
at DATE (its exclusive end), with the terminal value at exponent
zero in both.

Loading