-
-
Notifications
You must be signed in to change notification settings - Fork 403
holdings: new command showing investment holdings and performance #2683
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 add9b16
feat: holdings: show real holdings with cost basis
simonmichael c7ec9fe
imp: holdings: add --round support
simonmichael 80206fa
feat: holdings: add Price, Value and Gain columns
simonmichael 2d496e4
imp holdings: support -V, -X, --value
simonmichael 6c00585
imp: holdings: use a pager, and support -o, like other reports
simonmichael 4223df5
imp: holdings: support --tree, --depth and --no-elide
simonmichael d5fbc74
imp: holdings: add -S/--sort-amount
simonmichael 61fc393
imp: holdings: add csv and tsv output
simonmichael e2996d4
;doc: lots: mention the holdings command in lot reporting docs
simonmichael 0535914
imp: holdings: add html output
simonmichael a0040f9
;examples: add a sample hledger.css for styling HTML output
simonmichael 2c7173a
imp: holdings: add fods output
simonmichael a56bb8c
imp: holdings: add json output
simonmichael cda59e2
feat: holdings: add Weight, Rgain and XIRR columns, and humanise ages
simonmichael 7938956
imp: holdings: add --drop
simonmichael e374f60
;tools: just holdings-hledger, holdings-examples
simonmichael 8a8c072
imp: holdings: rename the Quantity column to Units
simonmichael e7a24be
doc: roi, lots: examples comparing these reports; interop advice
simonmichael caa0db2
dev: fix missing embedded file declaration
simonmichael cd570eb
imp: holdings: split the Gain column into UGain and UGain%; rename Rgain
simonmichael 3922e67
imp: holdings: percent columns are stylable via the % commodity
simonmichael 7811b45
imp: holdings: show UGain% unsigned, like the other percent columns
simonmichael b3e9bf6
imp: holdings: move the Price column before Cost
simonmichael 8b53bfb
imp: holdings: support --title
simonmichael 15b473a
;examples: rename lot-entries.journal -> lots.journal
simonmichael File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 ?". | ||
|
|
||
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.