diff --git a/Justfile b/Justfile index b20ee6f7a00..63a5ea89f25 100644 --- a/Justfile +++ b/Justfile @@ -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 diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md new file mode 100644 index 00000000000..494293dc73c --- /dev/null +++ b/doc/SPEC-holdings.md @@ -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. diff --git a/doc/SPEC-lots.md b/doc/SPEC-lots.md index 497c9796f12..b4e1102ef9f 100644 --- a/doc/SPEC-lots.md +++ b/doc/SPEC-lots.md @@ -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 -- +- - - @@ -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: +A larger collection of example entries: ### Disposal diff --git a/examples/lots/irr.journal b/examples/lots/irr.journal new file mode 100644 index 00000000000..260470c687a --- /dev/null +++ b/examples/lots/irr.journal @@ -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. + diff --git a/examples/lots/lot-entries.journal b/examples/lots/lot-entries.journal deleted file mode 100644 index 49bb8633905..00000000000 --- a/examples/lots/lot-entries.journal +++ /dev/null @@ -1,151 +0,0 @@ -; * Example lot-tracking entries for hledger --lots. -; -; This file demonstrates common journal entry patterns for acquiring, -; transferring, and disposing of lot-tracked commodities (stocks, cryptocurrency, etc.). -; Use it as a starting point or reference for your own entries. -; -; To try it: hledger -f lots-entries.journal print --lots [--verbose-tags] -; hledger -f lots-entries.journal bal --lots [-M] -; -; This journal is written so that both hledger1 and hledger2 accept it. -; (Only hledger2 will calculate lot movements and capital gains correctly.) - -; Declare ETSY as lotful, so that we don't have to always write cost basis annotations. -commodity ETSY ; lots: - -; Declare this account as Gain type, so that lots mode will check/infer it, -; and so that it doesn't disturb non-lots-mode transaction balancing. -account revenue:gains ; type: G - -; ============================================================================ -; ** 1. ACQUIRE - buying or receiving a lot-tracked commodity -; ============================================================================ - -; 1a. Buy with no fee. -; {$50} records the per-unit cost basis. -2026-01-01 buy shares - assets:cash -$500 - assets:broker 10 ETSY {$50} - -; 1b. Buy with a fee in the base currency ($). -; The fee is a separate expense; the cost basis remains $60/share. -2026-02-01 buy shares, $ fee - assets:cash -$610 - expenses:fees $10 - assets:broker 10 ETSY {$60} - -; 1c. Buy with a fee in the acquired commodity. -; The broker takes 0.1 ETSY as a fee; you receive 9.9 ETSY. -; The fee posting needs @ so the lot system knows it's a disposal, not a transfer. -2026-03-01 buy shares, stock fee - assets:cash -$700 - expenses:fees 0.1 ETSY @ $70 - assets:broker 9.9 ETSY @ $70 - -; 1d. Buy with fees in both commodities. -2026-04-01 buy shares, both fees - assets:cash -$810 - expenses:fees $10 - expenses:fees 0.1 ETSY @ $80 - assets:broker 9.9 ETSY @ $80 - -; ============================================================================ -; ** 2. TRANSFER - moving lots between accounts, preserving cost basis -; ============================================================================ - -; 2a. Transfer with no fee. -; No transacted price or cost basis mentioned - the lots move with their original cost basis intact. -; By default, lots are reduced FIFO, oldest lot first ({2026-01-01, $50}). -2026-05-01 transfer to another broker - assets:broker -10 ETSY - assets:broker2 10 ETSY - -; 2b. Transfer with a fee in the base currency. -; FIFO selects the next oldest lot ({2026-02-01, $60}). -2026-06-01 transfer, $ fee - assets:broker -10 ETSY - assets:broker2 10 ETSY - assets:cash -$5 - expenses:fees $5 - -; 2c. Transfer with a fee in the transferred commodity. -; The fee (0.1 ETSY) is the difference between the from and to amounts. -; FIFO selects from the {2026-03-01, $70} lot (9.9 available). -2026-07-01 transfer, stock fee - assets:broker -5 ETSY - expenses:fees 0.1 ETSY - assets:broker2 4.9 ETSY - -; ============================================================================ -; ** 3. DISPOSE - selling or spending a lot-tracked commodity -; ============================================================================ - -; 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. -2026-08-01 sell at a gain, using specific identification - assets:broker2 -10 ETSY {$50} @ $90 - assets:cash $900 - revenue:gains -$400 - -; 3b. Sell with no fee, at a loss. -2026-09-01 sell at a loss - assets:broker2 -10 ETSY {$60} @ $55 - assets:cash $550 - revenue:gains $50 - -; 3c. Sell with a $ fee. -2026-10-01 sell with $ fee - assets:broker -4.8 ETSY {$70} @ $90 - assets:cash $422 - expenses:fees $10 - revenue:gains -$96 - -; 3d. Sell with a stock fee. -; You sell 5 from the {$80} lot; the broker keeps 0.1 ETSY as a fee. -2026-10-15 sell with stock fee - assets:broker -5 ETSY {$80} @ $90 - expenses:fees 0.1 ETSY {$80} @ $90 - assets:cash $441 - revenue:gains -$49 - -; 3e. Sell using commodity's/account's reduction method — -; {} chooses the most appropriate available lot(s) (FIFO by default). -; The gain posting's amount is inferred. -2026-11-01 sell using auto selection and inferred gain amount - 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 revenue:gains. -2026-12-01 sell, with inferred gain posting - assets:broker2 -4.9 ETSY {} @ $90 - assets:cash $441 - ; both gains postings are inferred here - -; ============================================================================ -; ** 4. REVENUE & EXPENSES - lotful commodities in income/expense accounts -; ============================================================================ -; Revenue and expense accounts don't get lot subaccounts; -; only the asset account tracks the lot. - -; 4a. Receive stock as income (e.g. stock compensation or dividend). -; A new lot is acquired; the income is recorded in $. -2027-01-01 stock compensation - revenue:salary -$900 - assets:broker2 10 ETSY {$90} - -; 4b. Donate stock to charity. -; A disposal at market price; the difference from cost basis is recorded as a gain/loss. -2027-03-01 charitable donation - assets:broker2 -5 ETSY {$90} @ $100 - expenses:donations $500 - revenue:gains -$50 - -; 4c. Pay a contractor in stock. Similar to the above. -2027-04-01 pay contractor - assets:broker2 -5 ETSY {$90} @ $100 - expenses:contractors $500 - revenue:gains -$50 diff --git a/examples/lots/lots.journal b/examples/lots/lots.journal new file mode 100644 index 00000000000..cbfb66e5ec9 --- /dev/null +++ b/examples/lots/lots.journal @@ -0,0 +1,259 @@ +; * Example lot-tracking entries for hledger --lots. +; +; This file demonstrates common journal entry patterns for acquiring, +; transferring, and disposing of lot-tracked commodities (stocks, cryptocurrency, etc.). +; Use it as a starting point or reference for your own entries. +; +; To try it: hledger -f lots.journal print --lots [--verbose-tags] +; hledger -f lots.journal bal --lots [-M] +; +; This journal is written so that both hledger1 and hledger2 accept it. +; (Only hledger2 will calculate lot movements and capital gains correctly.) + +; Declare ETSY as lotful, so that we don't have to always write cost basis annotations. +commodity ETSY ; lots: + +; Declare this account as Gain type, so that lots mode will check/infer it, +; and so that it doesn't disturb non-lots-mode transaction balancing. +account revenue:gains ; type: G + +; ============================================================================ +; ** 1. ACQUIRE - buying or receiving a lot-tracked commodity +; ============================================================================ + +; 1a. Buy with no fee. +; {$50} records the per-unit cost basis. +2025-01-01 buy shares + assets:cash -$500 + assets:broker 10 ETSY {$50} + +; 1b. Buy with a fee in the base currency ($). +; The fee is a separate expense; the cost basis remains $60/share. +2025-02-01 buy shares, $ fee + assets:cash -$610 + expenses:fees $10 + assets:broker 10 ETSY {$60} + +; 1c. Buy with a fee in the acquired commodity. +; The broker takes 0.1 ETSY as a fee; you receive 9.9 ETSY. +; The fee posting needs @ so the lot system knows it's a disposal, not a transfer. +2025-03-01 buy shares, stock fee + assets:cash -$700 + expenses:fees 0.1 ETSY @ $70 + assets:broker 9.9 ETSY @ $70 + +; 1d. Buy with fees in both commodities. +2025-04-01 buy shares, both fees + assets:cash -$810 + expenses:fees $10 + expenses:fees 0.1 ETSY @ $80 + assets:broker 9.9 ETSY @ $80 + +; ============================================================================ +; ** 2. TRANSFER - moving lots between accounts, preserving cost basis +; ============================================================================ + +; 2a. Transfer with no fee. +; No transacted price or cost basis mentioned - the lots move with their original cost basis intact. +; By default, lots are reduced FIFO, oldest lot first ({2025-01-01, $50}). +2025-05-01 transfer to another broker + assets:broker -10 ETSY + assets:broker2 10 ETSY + +; 2b. Transfer with a fee in the base currency. +; FIFO selects the next oldest lot ({2025-02-01, $60}). +2025-06-01 transfer, $ fee + assets:broker -10 ETSY + assets:broker2 10 ETSY + assets:cash -$5 + expenses:fees $5 + +; 2c. Transfer with a fee in the transferred commodity. +; The fee (0.1 ETSY) is the difference between the from and to amounts. +; FIFO selects from the {2025-03-01, $70} lot (9.9 available). +2025-07-01 transfer, stock fee + assets:broker -5 ETSY + expenses:fees 0.1 ETSY + assets:broker2 4.9 ETSY + +; ============================================================================ +; ** 3. DISPOSE - selling or spending a lot-tracked commodity +; ============================================================================ + +; 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. +2025-08-01 sell at a gain, using specific identification + assets:broker2 -10 ETSY {$50} @ $90 + assets:cash $900 + revenue:gains -$400 + +; 3b. Sell with no fee, at a loss. +2025-09-01 sell at a loss + assets:broker2 -10 ETSY {$60} @ $55 + assets:cash $550 + revenue:gains $50 + +; 3c. Sell with a $ fee. +2025-10-01 sell with $ fee + assets:broker -4.8 ETSY {$70} @ $90 + assets:cash $422 + expenses:fees $10 + revenue:gains -$96 + +; 3d. Sell with a stock fee. +; You sell 5 from the {$80} lot; the broker keeps 0.1 ETSY as a fee. +2025-10-15 sell with stock fee + assets:broker -5 ETSY {$80} @ $90 + expenses:fees 0.1 ETSY {$80} @ $90 + assets:cash $441 + revenue:gains -$49 + +; 3e. Sell using commodity's/account's reduction method — +; {} chooses the most appropriate available lot(s) (FIFO by default). +; The gain posting's amount is inferred. +2025-11-01 sell using auto selection and inferred gain amount + 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 revenue:gains. +2025-12-01 sell, with inferred gain posting + assets:broker2 -4.9 ETSY {} @ $90 + assets:cash $441 + ; both gains postings are inferred here + +; ============================================================================ +; ** 4. REVENUE & EXPENSES - lotful commodities in income/expense accounts +; ============================================================================ +; Revenue and expense accounts don't get lot subaccounts; +; only the asset account tracks the lot. + +; 4a. Receive stock as income (e.g. stock compensation or dividend). +; A new lot is acquired; the income is recorded in $. +2026-01-01 stock compensation + revenue:salary -$900 + assets:broker2 10 ETSY {$90} + +; 4b. Donate stock to charity. +; A disposal at market price; the difference from cost basis is recorded as a gain/loss. +2026-03-01 charitable donation + assets:broker2 -5 ETSY {$90} @ $100 + expenses:donations $500 + revenue:gains -$50 + +; 4c. Pay a contractor in stock. Similar to the above. +2026-04-01 pay contractor + assets:broker2 -5 ETSY {$90} @ $100 + expenses:contractors $500 + revenue:gains -$50 + +; ============================================================================ +; ** 5. MARKET PRICES +; ============================================================================ + +; Recording an occasional market price allows reports to show current +; market value and unrealised gain. Eg try: +; hledger -f lots.journal holdings +P 2026-05-01 ETSY $95 + + +comment + +############################################################################### +Comparing roi and holdings with this journal. + +$ hledger -f lots.journal roi --inv assets:broker --pnl 'revenue:gains|equity:unrealised-gain' -e 2026-03-31 --value=end,$ --infer-market-prices ++---++------------+------------++---------------+----------+-------------+------++--------++------------+----------+ +| || Begin | End || Value (begin) | Cashflow | Value (end) | PnL || IRR || TWR/period | TWR/year | ++===++============+============++===============+==========+=============+======++========++============+==========+ +| 1 || 2025-01-01 | 2026-03-30 || 0 | $-245 | $510 | $755 || 48.55% || 7.15% | 5.71% | ++---++------------+------------++---------------+----------+-------------+------++--------++------------+----------+ + +$ hledger -f lots.journal holdings -e 2026-04-01 --infer-market-prices +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +================++================================================================================================ + assets:broker || 2025-04-01 364d 0.1 ETSY $80 $100 $8 $10 2.0% $2 25.0% $196 17.9% + assets:broker2 || 2026-01-01 89d 5.0 ETSY $90 $100 $450 $500 98.0% $50 11.1% $498 144.2% +----------------++------------------------------------------------------------------------------------------------ + || $458 $510 100.0% $52 11.4% $694 47.8% + + +These agree as closely as the two reports can on this journal: + +Measure holdings (totals row) roi +----------- ------------------------------ --------------------- +Value $510 $510 (exact match) +Total gain UGain $52 + RGain $694 = $746 PnL $755 (~$9 apart) +Return XIRR 47.8% IRR 48.55% (0.75pp apart) + +1. -e 2026-04-01 (holdings) and -e 2026-03-31 (roi): the one-day +offset makes both reports value and discount to the same date, +2026-03-31 (before the 2026-04-01 transaction). It's also chosen so +both broker accounts are still open - after 2026-04-01, broker2 is +fully disposed and drops out of holdings, so its $547 of realised +gains would vanish from holdings' totals while staying in roi's +PnL. That coverage gap is the biggest divergence and can't be +bridged by flags, only avoided by the date. + +2. --pnl 'revenue:gains|equity:unrealised-gain': keeps the lots +system's generated ugain postings out of roi's external cashflows. + +3. --infer-market-prices: there's no P directive before 2026-05-01, +so both reports infer the price from the latest transaction cost +($100), giving the identical $510 valuation. + +4. --inv assets:broker matches both broker accounts, so the +inter-broker lot transfers are internal for roi, just as holdings' +totals cancel them (transfer out and in at the same basis). + +5. The remaining ~$9 PnL gap is the stock fees: roi values the +0.1 ETSY amounts that left the investment as fees at the end price +($100 each), while the actual transaction-time values were +$70-90. That difference in flows also accounts for the remaining +0.75pp of IRR spread. + +############################################################################# +Additional notes, from another pair of reports. + +The headline numbers differ for explainable reasons: + +1. roi needs to know about the gain accounts. Naively, roi --inv +assets:broker --pnl revenue:gains counts the generated +equity:unrealised-gain postings as external cashflows, giving +nonsense (PnL $1496). Adding the ugain account (--pnl +'revenue:gains|equity:unrealised-gain') fixes it: PnL $752, IRR +48.3%. This seems worth documenting in the lots or roi docs. + +2. Coverage differs by design. Holdings' totals show RGain $196: +only assets:broker, because assets:broker2 is fully disposed and has +no row (documented behavior). The journal's total realised gain is +$743; roi includes closed positions, hence its much larger PnL. So +"holdings RGain" and "roi PnL" answer different questions on +journals with closed accounts. + +3. roi's residual ~$7 discrepancy (PnL $752 vs $745 = $743 realised ++ $2 unrealised) comes from --value=end valuing the ETSY cashflows +that left the investment (the 0.1 ETSY stock fees, etc.) at the end +price $95 instead of their transaction prices - a known +roi/--value=end characteristic, not a lots bug. + +4. Broker-only roi is not comparable (IRR 155.7% vs holdings XIRR +17.8%): the lot transfers to broker2 become external flows for roi, +valued at $95 though transferred at $50-70 cost basis in +2025. Holdings values transfers at basis on their dates, which is +arguably right for this purpose. + +One more caveat: even with the --pnl workaround, roi's TWR looks +distorted on lots journals (the clean case 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. That would need a closer look if TWR on lots journals +matters. diff --git a/hledger-lib/Hledger/Data/Valuation.hs b/hledger-lib/Hledger/Data/Valuation.hs index 1598bebc87a..ea4736a5483 100644 --- a/hledger-lib/Hledger/Data/Valuation.hs +++ b/hledger-lib/Hledger/Data/Valuation.hs @@ -20,6 +20,7 @@ module Hledger.Data.Valuation ( ,mixedAmountToCost ,mixedAmountApplyValuation ,mixedAmountValueAtDate + ,amountValueAtDate ,mixedAmountApplyGain ,mixedAmountGainAtDate ,marketPriceReverse diff --git a/hledger.css b/hledger.css new file mode 100644 index 00000000000..7dc53281aed --- /dev/null +++ b/hledger.css @@ -0,0 +1,6 @@ +/* hledger.css - a sample style sheet for hledger's HTML output. + HTML reports (eg from bal or holdings) are styled by a hledger.css + file in the same directory, if present. */ + +/* Don't wrap within dates or commodity amounts (eg in the holdings report). */ +td.date, span.amount { white-space: nowrap; } diff --git a/hledger/Hledger/Cli/Commands.hs b/hledger/Hledger/Cli/Commands.hs index 56bfe133832..6e4328e719f 100644 --- a/hledger/Hledger/Cli/Commands.hs +++ b/hledger/Hledger/Cli/Commands.hs @@ -41,6 +41,7 @@ module Hledger.Cli.Commands ( ,module Hledger.Cli.Commands.Diff ,module Hledger.Cli.Commands.Get ,module Hledger.Cli.Commands.Help + ,module Hledger.Cli.Commands.Holdings ,module Hledger.Cli.Commands.Import ,module Hledger.Cli.Commands.Incomestatement ,module Hledger.Cli.Commands.Notes @@ -92,6 +93,7 @@ import Hledger.Cli.Commands.Diff import Hledger.Cli.Commands.Files import Hledger.Cli.Commands.Get import Hledger.Cli.Commands.Help +import Hledger.Cli.Commands.Holdings import Hledger.Cli.Commands.Import import Hledger.Cli.Commands.Incomestatement import Hledger.Cli.Commands.Notes @@ -132,6 +134,7 @@ builtinCommands = [ ,(filesmode , files) ,(getmode , getcmd) ,(helpmode , help') + ,(holdingsmode , holdings) ,(importmode , importcmd) ,(incomestatementmode , incomestatement) ,(notesmode , notes) @@ -268,6 +271,7 @@ commandsList progversion builtin othercmds cmdaliases = -----------------------------------------80------------------------------------- ,bold' "ADVANCED REPORTS" ," balance (bal) show balance changes, end balances, gains, budgets.." + ," holdings show investment holdings" ,"+lots show a commodity's lots" -- hledger-lots ," roi show return on investments" ,"" diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs new file mode 100644 index 00000000000..37a468254b7 --- /dev/null +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -0,0 +1,673 @@ +{-| + +The @holdings@ command shows a report of investment holdings (lot-tracked assets). + +Work in progress; see doc/SPEC-holdings.md. +Currently it shows the Date, Age, Units, Unit/Avg cost, Price, Cost, +Value, Weight, UGain, UGain%, RGain and XIRR columns, with lot +subaccounts aggregated by default or shown as rows with --lots. + +-} + +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TemplateHaskell #-} + +module Hledger.Cli.Commands.Holdings ( + holdingsmode + ,holdings +) where + +import Control.Applicative ((<|>)) +import Control.Monad (guard) +import Data.Aeson (Value, object, (.=)) +import Data.Decimal (roundTo) +import Data.Default (def) +import Data.List.Extra (intercalate, intersperse, nubSort, sortOn) +import Data.Map.Strict qualified as M +import Data.Maybe (fromMaybe, isJust, listToMaybe, mapMaybe) +import Data.Ord (Down(..)) +import Data.Text qualified as T +import Data.Text.Lazy qualified as TL +import Data.Time.Calendar (Day, addDays, diffDays) +import System.Console.CmdArgs.Explicit (flagNone, flagReq) +import Text.Printf (printf) + +import Hledger +import Hledger.Cli.CliOptions +import Hledger.Cli.Commands.Balance (addTotalBorders) +import Hledger.Cli.Commands.Print (roundFromRawOpts) +import Hledger.Cli.Utils (unsupportedOutputFormatError, writeOutputLazyText) +import Hledger.Write.Csv (CSV, printCSV, printTSV) +import Hledger.Write.Html (Html, htmlAsLazyText, styledTableHtml, toHtml) +import Hledger.Write.Ods (printFods) +import Hledger.Write.Spreadsheet (addHeaderBorders, headerCell) +import Hledger.Write.Spreadsheet qualified as Ods +import Lucid qualified as L +import Numeric.RootFinding (RiddersParam(..), Root(..), Tolerance(..), ridders) +import System.IO qualified as IO +import Text.Tabular.AsciiWide + +-- | Command line options for this command. +holdingsmode = hledgerCommandMode + $(embedFileRelative "Hledger/Cli/Commands/Holdings.txt") + (flattreeflags True ++ + [flagNone ["no-elide"] (setboolopt "no-elide") "in tree mode, don't squash boring parent accounts" + ,flagReq ["drop"] (\s opts -> Right $ setopt "drop" s opts) "N" "in list mode, omit N leading account name parts" + ,flagNone ["sort-amount","S"] (setboolopt "sort-amount") "sort by value (or cost) instead of account name, largest first" + ,flagNone ["no-total","N"] (setboolopt "no-total") "omit the final total row" + ,flagReq ["round"] (\s opts -> Right $ setopt "round" s opts) "TYPE" $ + intercalate "\n" + ["how much rounding or padding should be done when displaying amounts ?" + ,"none - show original decimal digits" + ,"soft - just add or remove decimal zeros" + ," to match precision" + ,"hard - round amounts to precision (default)" + ,"all - also round cost amounts to precision" + ] + ,outputFormatFlag ["txt","csv","tsv","html","fods","json"] + ,outputFileFlag]) + cligeneralflagsgroups1 + hiddenflags + ([], Just $ argsFlag "[QUERY]") + +-- | One holding: a displayed report row and commodity, +-- as machine-readable data for the csv/tsv/json output. +-- Money amounts are display strings (in machine format: no digit group +-- marks); dates, ages, units and gain percents are typed. +data Holding = Holding { + hAccount :: AccountName + ,hCommodity :: CommoditySymbol + ,hDate :: Maybe Day -- ^ acquisition date, when the lots share one + ,hAge :: Maybe Integer -- ^ days held at the report date + ,hUnits :: Amount -- ^ units held, styled + ,hUnitCost :: Maybe T.Text -- ^ unit or average cost + ,hPrice :: Maybe T.Text -- ^ market price at the valuation date + ,hCost :: T.Text -- ^ total cost basis + ,hValue :: Maybe T.Text -- ^ market value + ,hWeight :: Maybe Quantity -- ^ percentage of the portfolio's value, rounded to 1 decimal + ,hUgain :: Maybe T.Text -- ^ unrealised gain + ,hUgainPct :: Maybe Quantity -- ^ unrealised gain percent, rounded to 1 decimal + ,hRgain :: Maybe T.Text -- ^ realised gain, from disposals so far + ,hXirr :: Maybe Double -- ^ annualised internal rate of return percent + } + +holdingCsv :: Holding -> [T.Text] +holdingCsv h = + [hAccount h + ,hCommodity h + ,maybe "" showDate (hDate h) + ,maybe "" (T.pack . show) (hAge h) + ,T.pack $ showAmountWith machineFmt{displayCommodity=False} (hUnits h) + ,fromMaybe "" (hUnitCost h) + ,fromMaybe "" (hPrice h) + ,hCost h + ,fromMaybe "" (hValue h) + ,maybe "" (T.pack . show) (hWeight h) + ,fromMaybe "" (hUgain h) + ,maybe "" (T.pack . show) (hUgainPct h) + ,fromMaybe "" (hRgain h) + ,maybe "" (T.pack . printf "%.1f") (hXirr h) + ] + +holdingJson :: Holding -> Value +holdingJson h = object + ["account" .= hAccount h + ,"commodity" .= hCommodity h + ,"date" .= hDate h + ,"age" .= hAge h + ,"units" .= aquantity (hUnits h) + ,"unitcost" .= hUnitCost h + ,"price" .= hPrice h + ,"cost" .= hCost h + ,"value" .= hValue h + ,"weight" .= hWeight h + ,"ugain" .= hUgain h + ,"ugainpct" .= hUgainPct h + ,"rgain" .= hRgain h + ,"xirr" .= hXirr h + ] + +-- | Show an age in days compactly: in days, or if a year or more, +-- in years with one decimal digit (approximating years as 365 days): +-- eg 44d, 1.1y. +showage :: Integer -> T.Text +showage d + | d >= 365 = T.pack (show (roundTo 1 (fromIntegral d / 365))) <> "y" + | otherwise = T.pack (show d) <> "d" + +-- | Show the holdings report: the assets held in lot-tracked accounts +-- as of the report end date, one row per account (or per lot, with --lots). +-- +-- This command receives the journal with lot detail (lot subaccounts and +-- synthetic postings) uncollapsed, regardless of --lots +-- (see maybeCollapseLotDetail); it aggregates lots itself. +holdings :: CliOpts -> Journal -> IO () +holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsReportOpts=ropts}} j = do + if (case mvalue of Just (AtThen _) -> True; _ -> False) + then error' "holdings: --value=then is not supported" + else rounding `seq` -- validate the --round value before any output + writeOutputLazyText opts $ case outputFormatFromOpts opts of + "txt" -> txtoutput + "csv" -> printCSV csvoutput + "tsv" -> printTSV csvoutput + "html" -> (<>"\n") $ htmlAsLazyText $ styledTableHtml htmltable + "fods" -> printFods IO.localeEncoding $ M.singleton "Holdings" ((1,0), fodstable) + "json" -> (<>"\n") $ toJsonText $ map holdingJson holdingrecords + fmt -> error' $ unsupportedOutputFormatError fmt + where + txtoutput = + -- The default title can be customised or suppressed with --title. + (case effectiveTitle ropts ("Holdings on " <> showDate reportdate) of + "" -> "" + t -> TL.fromStrict t <> "\n\n") + <> + if null rows + then "(no holdings)\n" + else renderTable + def{tableBorders=False} + (textCell TopLeft) + (textCell TopRight) + (textCell TopRight) + tbl + showlots = boolopt "lots" rawopts + tree = accountlistmode_ ropts == ALTree + + -- The date this report shows holdings at: the day before the (exclusive) + -- report end date if specified, otherwise today. + mend = queryEndDate False q + reportdate = maybe (_rsDay rspec) (addDays (-1)) mend + + -- The query used to select lot subaccount postings: the report query + -- without its date terms (holdings are cumulative to the end date, + -- added here) and depth terms (--depth only clips the displayed rows; + -- the lots beneath still count). + endq = And [filterQuery (\x -> not $ queryIsDateOrDate2 x || queryIsDepth x) q + ,Date $ DateSpan Nothing (Exact <$> mend)] + + -- The postings contributing to each lot subaccount, keyed by account + -- and commodity (so amounts in different commodities, not expected in + -- a lot subaccount but possible, don't merge wrongly). + lotpostings :: [((AccountName, CommoditySymbol), (Day, Amount))] + lotpostings = + [ ((paccount p, acommodity a), (postingDate p, a)) + | p <- journalPostings j + , isJust $ lotSubaccountName $ paccount p + , endq `matchesPosting` p + , a <- amountsRaw $ pamount p + ] + + -- The units held in each lot subaccount. + lotmap :: M.Map (AccountName, CommoditySymbol) Amount + lotmap = M.fromListWith (+) [(k, amountStripCost a) | (k, (_, a)) <- lotpostings] + + -- Each lot subaccount's cashflows in the cost commodity, for XIRR: + -- each posting's transacted cost if any (so, proceeds when disposing), + -- otherwise its cost basis value. Negative = money invested. + flowmap :: M.Map (AccountName, CommoditySymbol) [(Day, Amount)] + flowmap = M.fromListWith (++) + [ (k, [(d, negate flowamt)]) + | (k, (d, a)) <- lotpostings + , Just flowamt <- [case acost a of + Just _ -> Just $ amountCost a + Nothing -> multiplyAmount (aquantity a) <$> (cbCost =<< acostbasis a)] + ] + + -- Each lot subaccount's realised gains, in the cost commodity: + -- for each dispose posting (negative, with a transacted price and a + -- cost basis), the proceeds minus the cost basis of the disposed units. + rgainmap :: M.Map (AccountName, CommoditySymbol) Amount + rgainmap = M.fromListWith (+) + [ (k, proceeds - basis) + | (k, (_, a)) <- lotpostings + , aquantity a < 0 + , isJust $ acost a + , let proceeds = negate $ amountCost a + , Just ub <- [cbCost =<< acostbasis a] + , let basis = multiplyAmount (negate $ aquantity a) ub + , acommodity proceeds == acommodity basis + ] + + -- The values in a map whose keys are at or under the given account + -- (and in the given held commodity, if specified). + underIn :: M.Map (AccountName, CommoditySymbol) v -> AccountName -> Maybe CommoditySymbol -> [v] + underIn m acct mc = + [ v | ((sub, c), v) <- M.toAscList m + , acct == sub || acct `isAccountNamePrefixOf` sub + , maybe True (== c) mc + ] + + -- The cashflows of the lots at or under an account, optionally of one held commodity. + flowsUnder :: AccountName -> Maybe CommoditySymbol -> [(Day, Amount)] + flowsUnder acct mc = concat $ underIn flowmap acct mc + + -- The realised gains of the lots at or under an account, optionally of one held commodity. + rgainsUnder :: AccountName -> Maybe CommoditySymbol -> [Amount] + rgainsUnder = underIn rgainmap + + -- A lot subaccount's cost basis, parsed from its name + -- (which by construction contains the acquisition date and unit cost). + -- The cost gets its commodity's display style, including display + -- precision (lot names can have more precision, eg from inferred + -- per-unit costs), so derived amounts (Unit cost, Cost) are displayed + -- with the standard display precision. + lotBasis :: AccountName -> Maybe CostBasis + lotBasis acct = do + name <- lotSubaccountName acct + cb <- either (const Nothing) Just $ parseLotName parseAmt name + Just cb{cbCost = styleAmounts styles <$> cbCost cb} + where parseAmt = either (const Nothing) Just . parseamount + + -- Amounts are displayed normalised to their commodity's display + -- precision by default; --round can choose another rounding strategy. + rounding = fromMaybe HardRounding $ roundFromRawOpts rawopts + styles = journalCommodityStylesWith rounding j + + priceoracle = journalPriceOracle (infer_prices_ ropts) j + + -- The valuation strategy requested with -V/-X/--value, if any. + -- It selects the valuation date and/or the valuation commodity; + -- --value=then is rejected above. + mvalue = value_ ropts + (valuationdate, mtargetcomm) = case mvalue of + Nothing -> (reportdate, Nothing) + Just (AtEnd mc) -> (reportdate, mc) + Just (AtNow mc) -> (_rsDay rspec, mc) + Just (AtDate d mc) -> (d, mc) + Just (AtThen mc) -> (reportdate, mc) -- not supported, rejected above + + -- Value a row's units at the valuation date: Just (price amounts, + -- total value) if all of the row's commodities have a market price, + -- otherwise Nothing. Without -V/-X/--value, each holding is valued in + -- its cost commodity when known; with them, in the requested or + -- default valuation commodity. + rowValuation :: PeriodicReportRow DisplayName MixedAmount -> Maybe ([Amount], MixedAmount) + rowValuation r = do + pvs <- mapM lookup1 qas + Just (map fst pvs, mixed (map snd pvs)) + where + qas = rowUnitAmounts r + mto = case mvalue of + Nothing -> listToMaybe [acommodity c | (_, mcb) <- lotsUnder (prrFullName r), Just c <- [cbCost =<< mcb]] + Just _ -> mtargetcomm + lookup1 qa = do + (pcomm, rate) <- priceoracle (valuationdate, acommodity qa, mto) + let mkamt n = styleAmounts styles $ amountSetFullPrecisionUpTo Nothing + nullamt{acommodity=pcomm, aquantity=n} + Just (mkamt rate, mkamt (rate * aquantity qa)) + + -- How to convert cost amounts (Cost, Unit/Avg cost, RGain, and the + -- cost side of UGain) for display when -V/-X/--value is in effect: + -- convert to the requested commodity, or to the given fallback + -- commodity (the row's or portfolio's value commodity), at the + -- valuation date. Costs already in the target commodity, or with no + -- target or no market price, are left unchanged. + costValuerTo :: Maybe CommoditySymbol -> Amount -> Amount + costValuerTo mfallback = case mvalue of + Nothing -> id + Just _ -> case mtargetcomm <|> mfallback of + Nothing -> id + -- styleAmounts is reapplied after conversion, since + -- amountValueAtDate leaves full precision displayed + Just tc -> \a -> if acommodity a == tc then a + else styleAmounts styles $ + amountValueAtDate priceoracle styles (Just tc) valuationdate a + + rowCostValuer :: PeriodicReportRow DisplayName MixedAmount -> Amount -> Amount + rowCostValuer r = costValuerTo mrowvaluecomm + where + mrowvaluecomm = case rowValuation r of + Just (_, val) | [v] <- amounts val -> Just $ acommodity v + _ -> Nothing + + -- The annualised internal rate of return implied by dated cashflows + -- (negative = money invested) up to the report date, as a percentage, + -- calculated like roi's IRR. Nothing if it can not be solved. + -- Note: this duplicates the solver setup and rate convention of + -- Roi.hs's solveIRR/interestSum (not exported); keep them in sync, + -- or extract a shared helper. + xirrPct :: [(Day, Quantity)] -> Maybe Double + xirrPct cf = + case ridders (RiddersParam 100 (AbsTol 0.00001)) (0.000000000001, 10000) npv of + Root rate -> Just $ (rate - 1) * 100 + _ -> Nothing + where + npv rate = sum [realToFrac n * rate ** (fromIntegral (diffDays reportdate t) / 365.25) | (t, n) <- cf] + + -- XIRR from cashflows plus a final value amount at the report date, + -- when they are all in one commodity. + xirrOf :: [(Day, Amount)] -> Amount -> Maybe Double + xirrOf flows finalv = do + guard $ not $ null flows + guard $ all ((== acommodity finalv) . acommodity . snd) flows + xirrPct $ (reportdate, aquantity finalv) : [(d, aquantity a) | (d, a) <- flows] + + -- The total value of the displayed holdings, when all are priced; + -- and its commodity, when it has just one. + mportfoliovalue :: Maybe MixedAmount + mportfoliovalue = do + rowvals <- traverse rowValuation toprows + Just $ mixed $ concatMap (amounts . snd) rowvals + mportvaluecomm = case amounts <$> mportfoliovalue of + Just [v] -> Just $ acommodity v + _ -> Nothing + + -- The distinct base accounts of the displayed rows (excluding any + -- contained in another). Account-level totals (RGain, XIRR) are + -- computed from these, so that they include fully disposed lots, + -- which have no displayed row of their own (eg with --lots). + topbases :: [AccountName] + topbases = [ b | b <- bases, not $ any (`isAccountNamePrefixOf` b) bases ] + where bases = nubSort $ map (lotBaseAccount . prrFullName) toprows + + -- A value's percentage of the portfolio's total value, when both are + -- single amounts in the same commodity. + weightPct :: MixedAmount -> Maybe Quantity + weightPct val = do + tot <- mportfoliovalue + [t] <- Just $ amounts tot + [v] <- Just $ amounts val + guard $ acommodity v == acommodity t && aquantity t /= 0 + Just $ 100 * aquantity v / aquantity t + + -- The commodity display styles, plus a default style for the "%" + -- commodity if none is declared or inferred: one decimal digit, + -- and the % sign on the right with no space. + pctstyles = M.union styles $ M.singleton "%" $ + amountstyle{ascommodityside=R, asprecision=Precision 1, asrounding=HardRounding} + + -- Show a percentage (for the Weight, UGain% and XIRR columns) as a + -- "%" commodity amount, using the display style of "%" (eg from a + -- commodity directive or -c) or the default above: eg 64.3%. + showpct :: Quantity -> T.Text + showpct p = + T.pack $ showAmountWith noCostFmt{displayZeroCommodity=True} $ + styleAmounts pctstyles nullamt{acommodity="%", aquantity=p} + + -- Show an XIRR percentage, like showpct: eg 12.3%. + showxirr :: Double -> T.Text + showxirr = showpct . realToFrac + + -- Render a gain amount and percent gain, as separate texts, from + -- single-commodity value and cost amounts, if their commodities match. + showgain :: [Amount] -> [Amount] -> (T.Text, T.Text) + showgain [v] [c] | acommodity v == acommodity c = + (T.pack $ showAmountWith noCostFmt{displayZeroCommodity=True} gainamt, pct) + where + gain = aquantity v - aquantity c + gainamt = styleAmounts styles $ amountSetFullPrecisionUpTo Nothing + nullamt{acommodity=acommodity v, aquantity=gain} + pct | aquantity c /= 0 = showpct $ 100 * gain / aquantity c + | otherwise = "" + showgain _ _ = ("", "") + + -- A row's units of lot-tracked commodities: its balance restricted + -- to the commodities of the lots at or beneath it. This excludes other + -- commodities (eg cash) from parent account rows in tree mode, and + -- strips costs so each commodity appears as one amount. + rowUnitAmounts :: PeriodicReportRow DisplayName MixedAmount -> [Amount] + rowUnitAmounts r = + filter (\a -> acommodity a `elem` lotcomms && not (amountLooksZero a)) $ + amounts $ mixedAmountStripCosts $ prrTotal r + where lotcomms = [acommodity a | (a, _) <- lotsUnder $ prrFullName r] + + -- The lots held at or under the given account, excluding empty ones. + lotsUnder :: AccountName -> [(Amount, Maybe CostBasis)] + lotsUnder acct = + [ (a, lotBasis sub) | ((sub, _), a) <- M.toAscList lotmap + , acct == sub || acct `isAccountNamePrefixOf` sub + , not $ amountLooksZero a + ] + + -- Report rows come from a single-period, end-balances multiBalanceReport: + -- on the lot-detailed journal with --lots (rows are lot subaccounts), + -- on the collapsed journal otherwise (rows are the base accounts). + -- Non-holding accounts are filtered out. + -- Cost conversion and valuation (-B/-V/--value) are disabled: + -- holdings does its own valuation, and units should stay units. + mbr = multiBalanceReport rspec' j' + where + rspec' = rspec{_rsReportOpts=ropts{balanceaccum_=Historical, interval_=NoInterval + ,conversionop_=Just NoConversionOp, value_=Nothing + ,sort_amount_=False}} -- -S sorts by value/cost below, not by units + j' = if showlots then j else journalCollapseLotDetail j + -- Rows to display: those with lots at or beneath them. In list mode, + -- also drop rows whose lots all appear in a deeper displayed row + -- (eg a base account posted to directly, when its lot subaccounts + -- are shown); in tree mode such parent rows are wanted. + rows = filter keeprow candidates + where + candidates = filter (not . null . lotsUnder . prrFullName) $ prRows mbr + keeprow r = tree || + not (any (\r2 -> prrFullName r `isAccountNamePrefixOf` prrFullName r2) candidates) + + -- The topmost displayed rows: those not contained in another displayed + -- row. Totals are computed from these, to avoid double counting. + toprows = [ r | r <- rows + , not $ any (\r2 -> prrFullName r2 `isAccountNamePrefixOf` prrFullName r) rows ] + + -- The display order: by account name, or with -S by each row's Value + -- (falling back to Cost), largest first. Sorting compares the keys + -- along each row's chain of displayed ancestors, so in tree mode each + -- level is sorted and subtrees stay together. Ties (and rows mixing + -- commodities, which are summed crudely) keep the account name order. + sortedrows + | not $ sort_amount_ ropts = rows + | otherwise = sortOn keypath rows + where + keymap = M.fromList [(prrFullName r, Down $ rowSortKey r) | r <- rows] + keypath r = mapMaybe (`M.lookup` keymap) $ reverse (parentAccountNames a) ++ [a] + where a = prrFullName r + rowSortKey r = case rowValuation r of + Just (_, val) -> sumq val + Nothing -> sumq $ mixed $ rowLotCosts r + where sumq = sum . map aquantity . amounts + + tbl = maybe id addtotalrow (map (T.intercalate ", ") <$> mtotalrowparts) $ Table + (Group NoLine $ map (Header . renderacct) sortedrows) + (Group NoLine $ map Header colheadings) + (map rowcells sortedrows) + where + addtotalrow totalrow tbl' = concatTables SingleLine tbl' $ + Table (Group NoLine [Header ""]) (Header []) [totalrow] + colheadings = ["Date", "Age", "Units", if showlots then "Unit cost" else "Avg cost", "Price", "Cost", "Value", "Weight", "UGain", "UGain%", "RGain", "XIRR"] + renderacct r = T.replicate (prrIndent r * 2) " " <> prrDisplayName r + + rowLotCosts r = [rowCostValuer r $ multiplyAmount (aquantity a) c + | (a, mcb) <- lotsUnder $ prrFullName r, Just c <- [cbCost =<< mcb]] + + -- The text table's cells: each cell's parts joined, + -- multi-line in Units and Price, one-line elsewhere. + rowcells = zipWith T.intercalate cellseps . rowCellParts + cellseps = [", ", ", ", "\n", ", ", "\n", ", ", ", ", ", ", ", ", ", ", ", ", ", "] + + -- A row's cells, each as a list of parts: + -- one part per commodity amount in the amount cells, at most one part elsewhere. + rowCellParts :: PeriodicReportRow DisplayName MixedAmount -> [[T.Text]] + rowCellParts r = [[datecell], [agecell], unitparts, [unitcostcell], priceparts, costparts, valueparts, [weightcell], [ugaincell], [ugainpctcell], rgainparts, [xirrcell]] + where + acct = prrFullName r + (priceparts, valueparts, (ugaincell, ugainpctcell), weightcell) = case rowValuation r of + Nothing -> ([], [], ("", ""), "") + Just (prices, val) -> + ( map showamt prices + , map showamt $ amounts val + , showgain (amounts val) (amounts $ mixed costs) + , maybe "" showpct $ weightPct val + ) + rgainparts = case map (rowCostValuer r) $ rgainsUnder acct Nothing of + [] -> [] + rs -> map showamt $ amounts $ mixed rs + xirrcell = fromMaybe "" $ do + (_, val) <- rowValuation r + [v] <- Just $ amounts val + showxirr <$> xirrOf (flowsUnder acct Nothing) v + rowlots = lotsUnder acct + dates = nubSort [cbDate =<< mcb | (_, mcb) <- rowlots] + -- Date and Age are shown when the row's lots all have the same date. + (datecell, agecell) = case dates of + [Just dt] -> (showDate dt, showage $ diffDays reportdate dt) + _ -> ("", "") + unitparts = map (showamt . styleAmounts styles) $ rowUnitAmounts r + costs = rowLotCosts r + costparts = map showamt $ amounts $ mixed costs + unitcostcell = case (rowlots, costs) of + ([(_, mcb)], _) -> maybe "" (showamt . rowCostValuer r) (cbCost =<< mcb) + (_, _:_) | [totcost] <- amounts (mixed costs) + , [totqty] <- amounts (mixed $ map fst rowlots) + , not $ amountLooksZero totqty + -> showamt $ avgcost totqty totcost + _ -> "" + showamt = T.pack . showAmountWith noCostFmt + + -- Spreadsheet-shaped tables for the html and fods output: like the + -- text table, but with single-line cells, an Account column heading, + -- and a Total: row heading. Parameterised on how to convert plain + -- text, and a cell's list of (possibly amount) parts, to content. + spreadsheetWith :: forall content. (T.Text -> content) -> (Bool -> [T.Text] -> content) + -> [[Ods.Cell Ods.NumLines content]] + spreadsheetWith plain parts = + addHeaderBorders (zipWith hcell colclasses ("Account" : colheadings)) + : [ zipWith3 bodycell [0..] colclasses + (plain (acctcell r) : zipWith parts amountcols (rowCellParts r)) + | r <- sortedrows ] + ++ maybe [] (\tot -> addTotalBorders + [zipWith3 totalcell [0..] colclasses + (plain "Total:" : zipWith parts amountcols tot) :: [Ods.Cell () content]]) + mtotalrowparts + where + -- per-column css classes, so the html cells can be styled + colclasses = ["account","date","age","units","unitcost","price","cost","value","weight","ugain","ugainpct","rgain","xirr"] + -- which of the other columns' cell parts are amounts + amountcols = [False, False, True, True, True, True, True, False, True, False, True, False] + hcell cls t = plain <$> (headerCell t){Ods.cellClass = Ods.Class cls} + -- body cells are right-aligned, except the first two columns + -- (Account and Date); headings are unaffected + bodycell :: Ods.Lines border => Int -> T.Text -> content' -> Ods.Cell border content' + bodycell i cls t = (Ods.defaultCell t) + {Ods.cellType = if i < 2 then Ods.TypeString else Ods.TypeMixedAmount + ,Ods.cellClass = Ods.Class cls} + totalcell i cls = bodycell i (cls <> " coltotal") + -- indent tree-mode account names with no-break spaces + acctcell r = T.replicate (prrIndent r * 2) "\160" <> prrDisplayName r + + -- Each commodity amount gets its own span with an "amount" class, + -- so eg wrapping within amounts can be prevented with css. + htmltable :: [[Ods.Cell Ods.NumLines Html]] + htmltable = spreadsheetWith toHtml partsHtml + where + partsHtml isamount parts = + mconcat $ intersperse (toHtml (", "::T.Text)) $ + map (\p -> if isamount then L.span_ [L.class_ "amount"] (toHtml p) else toHtml p) $ + filter (not . T.null) parts + + fodstable :: [[Ods.Cell Ods.NumLines T.Text]] + fodstable = spreadsheetWith id (\_ -> T.intercalate ", " . filter (not . T.null)) + + -- Machine-readable records, one per displayed row and commodity, + -- for the csv/tsv/json output: with full account names, age in days, + -- bare units and gain percent numbers, and gain and gain percent + -- separate. No totals records. + holdingrecords :: [Holding] + holdingrecords = concatMap rowrecords sortedrows + where + rowrecords r = map rec $ rowUnitAmounts r + where + acct = prrFullName r + rec qa = Holding + { hAccount = acct + , hCommodity = c + , hDate = mdate + , hAge = diffDays reportdate <$> mdate + , hUnits = styleAmounts styles qa + , hUnitCost = mucoststr + , hCost = coststr + , hPrice = mpricestr + , hValue = mvalstr + , hWeight = mweight + , hUgain = mgainstr + , hUgainPct = mpct + , hRgain = mrgainstr + , hXirr = mxirr + } + where + c = acommodity qa + showamt = T.pack . showAmountWith machineFmt + showamts' = T.pack . showMixedAmountWith machineFmt . mixed + clots = filter ((==c) . acommodity . fst) $ lotsUnder acct + dates = nubSort [cbDate =<< mcb | (_, mcb) <- clots] + mdate = case dates of + [Just dt] -> Just dt + _ -> Nothing + ccosts = [rowCostValuer r $ multiplyAmount (aquantity a) cb | (a, mcb) <- clots, Just cb <- [cbCost =<< mcb]] + coststr = showamts' ccosts + mucoststr = case (clots, ccosts) of + ([(_, mcb)], _) -> showamt . rowCostValuer r <$> (cbCost =<< mcb) + (_, _:_) | [totcost] <- amounts (mixed ccosts) + , not $ amountLooksZero qa + -> Just $ showamt $ avgcost qa totcost + _ -> Nothing + mto = case mvalue of + Nothing -> listToMaybe [acommodity cb | (_, mcb) <- clots, Just cb <- [cbCost =<< mcb]] + Just _ -> mtargetcomm + mrgainstr = case map (rowCostValuer r) $ rgainsUnder acct (Just c) of + [] -> Nothing + rs -> Just $ showamts' rs + (mpricestr, mvalstr, mgainstr, mpct, mweight, mxirr) = + case priceoracle (valuationdate, c, mto) of + Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing) + Just (pcomm, rate) -> (Just $ showamt price, Just $ showamt val, mgainstr', mpct' + ,roundTo 1 <$> weightPct (mixedAmount val) + ,xirrOf (flowsUnder acct (Just c)) val) + where + mkamt n = styleAmounts styles $ amountSetFullPrecisionUpTo Nothing + nullamt{acommodity=pcomm, aquantity=n} + price = mkamt rate + val = mkamt (rate * aquantity qa) + (mgainstr', mpct') = case amounts (mixed ccosts) of + [costamt] | acommodity costamt == pcomm -> + ( Just $ showamt $ mkamt gainq + , if aquantity costamt /= 0 + then Just $ roundTo 1 $ 100 * gainq / aquantity costamt + else Nothing ) + where gainq = aquantity val - aquantity costamt + _ -> (Nothing, Nothing) + + csvoutput :: CSV + csvoutput = + ["account","commodity","date","age","units","unitcost","price","cost","value","weight","ugain","ugainpct","rgain","xirr"] + : map holdingCsv holdingrecords + + -- Grand totals row (as cell parts, like rowCellParts): the Cost, + -- Value and gain columns, summed over the topmost displayed rows + -- (which include everything below them). + -- Value and gains are blank unless all rows have a market price. + mtotalrowparts :: Maybe [[T.Text]] + mtotalrowparts + | no_total_ ropts = Nothing + | otherwise = Just [[], [], [], [], [], costparts, valueparts, [weightcell], [ugaincell], [ugainpctcell], rgainparts, [xirrcell]] + where + totcosts = concatMap rowLotCosts toprows + costparts = map showamt $ amounts $ mixed totcosts + (valueparts, weightcell, (ugaincell, ugainpctcell)) = case mportfoliovalue of + Nothing -> ([], "", ("", "")) + Just totvalue -> ( map showamt $ amounts totvalue + , maybe "" showpct $ weightPct totvalue + , showgain (amounts totvalue) (amounts $ mixed totcosts)) + rgainparts = case map (costValuerTo mportvaluecomm) $ concatMap (\b -> rgainsUnder b Nothing) topbases of + [] -> [] + rs -> map showamt $ amounts $ mixed rs + xirrcell = fromMaybe "" $ do + totvalue <- mportfoliovalue + [tv] <- Just $ amounts totvalue + showxirr <$> xirrOf (concatMap (\b -> flowsUnder b Nothing) topbases) tv + showamt = T.pack . showAmountWith noCostFmt + + -- An average cost: total cost / total units, showing significant + -- decimal digits up to the cost commodity's display precision + -- (at least 2), without trailing zeros. + avgcost qtya costa = amountSetPrecision (Precision (min pdiv (max 2 pstyle))) avg + where + avg = divideAmountAndUpdatePrecision (aquantity qtya) costa + pdiv = case asprecision (astyle avg) of Precision n -> n; _ -> defaultMaxDisplayPrecision + pstyle = case asprecision (astyle costa) of Precision n -> n; _ -> 2 diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md new file mode 100644 index 00000000000..4aed7a089a3 --- /dev/null +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -0,0 +1,98 @@ +## holdings + +Show a report of investment holdings (lot-tracked assets). + +```flags +Flags: + -l --flat list/tree mode: show accounts as a flat list + (default). Amounts exclude subaccount amounts, + except where the account is depth-clipped. + -t --tree list/tree mode: show accounts as a tree. Amounts + include subaccount amounts. + --no-elide in tree mode, don't squash boring parent accounts + --drop=N in list mode, omit N leading account name parts + -S --sort-amount sort by value (or cost) instead of account name, + largest first + -N --no-total omit the final total row + --round=TYPE how much rounding or padding should be done when + displaying amounts ? + none - show original decimal digits + soft - just add or remove decimal zeros + to match precision + hard - round amounts to precision (default) + all - also round cost amounts to precision + -O --output-format=FMT select the output format. Supported formats: + txt, csv, tsv, html, fods, json. + -o --output-file=FILE write output to FILE. A file extension matching + one of the above formats selects that format. +``` + +This command is a work in progress. + +It shows the assets held in lot-tracked accounts (see [Lots](#lots)) +as of the report end date: one row per account, or one row per lot +with `--lots`. With `--tree`, accounts are shown as a tree, with +parent rows aggregating the lots beneath them; `--depth` limits and +aggregates the displayed rows as usual. +With `-S/--sort-amount`, rows are sorted by market value (or by cost, +when unpriced), largest first. +The report title ("Holdings on DATE") can be customised with +`--title=TEXT`, or suppressed with `--title=""`. +Columns show each holding's acquisition date and age +(when the row's lots share a single date; ages are shown in days, or +from one year in years with one decimal digit, eg `44d` or `1.1y`, +approximating years as 365 days), +the units held, +the unit cost (or average cost, on rows aggregating multiple lots), +the current market price, the total cost basis, the market value, +the percentage of the portfolio's total value (Weight), +the unrealised gain and gain percent (UGain, UGain%), +the realised gain from disposals so far (RGain), +and the annualised internal rate of return (XIRR, calculated from the +account's dated cashflows and current value, like roi's IRR; +it includes realised gains). +In the totals row, RGain and XIRR are account-level: they also include +fully disposed lots, which have no row of their own (eg with `--lots`). + +Market prices at the report date come from +[P directives](#p-directives), and from transaction costs with +`--infer-market-prices`, as usual; holdings are valued in their cost +commodity when possible. When a holding has no market price, +its Price, Value and gain columns are left blank. + +With `-V`, `-X COMM` or `--value` ([Valuation](#valuation)), holdings +are valued in the default or given valuation commodity instead, and the +cost columns are also converted to it (at the valuation date, so percent +gain is unaffected). `--value=then` is not supported, and `-B/--cost` +has no effect. +Amounts are displayed with their commodity's display precision +(unlike lot names, which can show more precision); +`--round` can select another rounding strategy. +The percent columns (Weight, UGain%, XIRR) are shown with one decimal +digit, or with the display style configured for the `%` commodity +(eg by `-c '0.00 %'`). + +With `-O csv` or `-O tsv`, machine-readable output is produced instead: +one record per row and commodity, with full account names, age in days, +bare units and gain percent numbers, gain and gain percent as +separate fields, and no totals records. +Amounts are shown without digit group marks; as in other commands' +CSV output, the decimal mark follows the commodity's display style. +(Note in tree mode, parent account records repeat the data of their +subaccounts.) + +With `-O html`, an HTML table is produced: like the text table, +but with single-line cells. For styling, each cell has a css class +naming its column (`account`, `date`, `age`, `units`, `unitcost`, +`price`, `cost`, `value`, `weight`, `ugain`, `ugainpct`, `rgain`, +`xirr`; totals row cells also have +`coltotal`), and each commodity amount is enclosed in a span with +class `amount` (eg allowing wrapping within amounts to be prevented). + +With `-O fods`, a spreadsheet document readable by LibreOffice etc. +is produced, with the same single-line cells as the html output. + +With `-O json`, a JSON array of holding objects is produced, with the +same fields as the CSV output; units and gain percents are +JSON number objects as in other commands' JSON output, and missing +values are null. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt new file mode 100644 index 00000000000..5d6473b98f0 --- /dev/null +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -0,0 +1,86 @@ +holdings + +Show a report of investment holdings (lot-tracked assets). + +Flags: + -l --flat list/tree mode: show accounts as a flat list + (default). Amounts exclude subaccount amounts, + except where the account is depth-clipped. + -t --tree list/tree mode: show accounts as a tree. Amounts + include subaccount amounts. + --no-elide in tree mode, don't squash boring parent accounts + --drop=N in list mode, omit N leading account name parts + -S --sort-amount sort by value (or cost) instead of account name, + largest first + -N --no-total omit the final total row + --round=TYPE how much rounding or padding should be done when + displaying amounts ? + none - show original decimal digits + soft - just add or remove decimal zeros + to match precision + hard - round amounts to precision (default) + all - also round cost amounts to precision + -O --output-format=FMT select the output format. Supported formats: + txt, csv, tsv, html, fods, json. + -o --output-file=FILE write output to FILE. A file extension matching + one of the above formats selects that format. + +This command is a work in progress. + +It shows the assets held in lot-tracked accounts (see Lots) as of the +report end date: one row per account, or one row per lot with --lots. +With --tree, accounts are shown as a tree, with parent rows aggregating +the lots beneath them; --depth limits and aggregates the displayed rows +as usual. With -S/--sort-amount, rows are sorted by market value (or by +cost, when unpriced), largest first. The report title ("Holdings on +DATE") can be customised with --title=TEXT, or suppressed with +--title="". Columns show each holding's acquisition date and age (when +the row's lots share a single date; ages are shown in days, or from one +year in years with one decimal digit, eg 44d or 1.1y, approximating +years as 365 days), the units held, the unit cost (or average cost, on +rows aggregating multiple lots), the current market price, the total +cost basis, the market value, the percentage of the portfolio's total +value (Weight), the unrealised gain and gain percent (UGain, UGain%), +the realised gain from disposals so far (RGain), and the annualised +internal rate of return (XIRR, calculated from the account's dated +cashflows and current value, like roi's IRR; it includes realised +gains). In the totals row, RGain and XIRR are account-level: they also +include fully disposed lots, which have no row of their own (eg with +--lots). + +Market prices at the report date come from P directives, and from +transaction costs with --infer-market-prices, as usual; holdings are +valued in their cost commodity when possible. When a holding has no +market price, its Price, Value and gain columns are left blank. + +With -V, -X COMM or --value (Valuation), holdings are valued in the +default or given valuation commodity instead, and the cost columns are +also converted to it (at the valuation date, so percent gain is +unaffected). --value=then is not supported, and -B/--cost has no effect. +Amounts are displayed with their commodity's display precision (unlike +lot names, which can show more precision); --round can select another +rounding strategy. The percent columns (Weight, UGain%, XIRR) are shown +with one decimal digit, or with the display style configured for the % +commodity (eg by -c '0.00 %'). + +With -O csv or -O tsv, machine-readable output is produced instead: one +record per row and commodity, with full account names, age in days, bare +units and gain percent numbers, gain and gain percent as separate +fields, and no totals records. Amounts are shown without digit group +marks; as in other commands' CSV output, the decimal mark follows the +commodity's display style. (Note in tree mode, parent account records +repeat the data of their subaccounts.) + +With -O html, an HTML table is produced: like the text table, but with +single-line cells. For styling, each cell has a css class naming its +column (account, date, age, units, unitcost, price, cost, value, weight, +ugain, ugainpct, rgain, xirr; totals row cells also have coltotal), and +each commodity amount is enclosed in a span with class amount (eg +allowing wrapping within amounts to be prevented). + +With -O fods, a spreadsheet document readable by LibreOffice etc. is +produced, with the same single-line cells as the html output. + +With -O json, a JSON array of holding objects is produced, with the same +fields as the CSV output; units and gain percents are JSON number +objects as in other commands' JSON output, and missing values are null. diff --git a/hledger/Hledger/Cli/Commands/Roi.hs b/hledger/Hledger/Cli/Commands/Roi.hs index 2bfa609933b..2c7813ac59b 100644 --- a/hledger/Hledger/Cli/Commands/Roi.hs +++ b/hledger/Hledger/Cli/Commands/Roi.hs @@ -279,6 +279,9 @@ interestSum :: Day -> CashFlow -> Double -> Double interestSum referenceDay cf rate = sum $ map go cf where go (t,m) = realToFrac (unMix m) * rate ** (fromIntegral (referenceDay `diffDays` t) / 365.25) +-- Note: Holdings.hs's xirrPct duplicates this solver setup and +-- interestSum's rate convention; keep them in sync, or extract a +-- shared helper. solveIRR :: (Double -> Double) -> String -> String -> Double solveIRR npv errNotBracketed errSearchFailed = case ridders (RiddersParam 100 (AbsTol 0.00001)) diff --git a/hledger/Hledger/Cli/Commands/Roi.md b/hledger/Hledger/Cli/Commands/Roi.md index 7a2c01f6f42..2018f3068b5 100644 --- a/hledger/Hledger/Cli/Commands/Roi.md +++ b/hledger/Hledger/Cli/Commands/Roi.md @@ -124,6 +124,15 @@ postings in the example below would be classifed as: snake oil $50 ; investment posting ``` +### Using roi with lots + +If your journal records [lots](#lots), 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. ### IRR and TWR explained diff --git a/hledger/Hledger/Cli/Commands/Roi.txt b/hledger/Hledger/Cli/Commands/Roi.txt index 3bd2a32f28e..bdbe9c11360 100644 --- a/hledger/Hledger/Cli/Commands/Roi.txt +++ b/hledger/Hledger/Cli/Commands/Roi.txt @@ -30,22 +30,22 @@ Price directives will be taken into account if you supply appropriate Note, in some cases this report can fail, for these reasons: -- Error (NotBracketed): No solution for Internal Rate of Return (IRR). - Possible causes: IRR is huge (>1000000%), balance of investment - becomes negative at some point in time. -- Error (SearchFailed): Failed to find solution for Internal Rate of - Return (IRR). Either search does not converge to a solution, or - converges too slowly. -- Error (NotBracketed): Cannot compute window IRR. Possible cause: - investment balance becomes negative within that window. -- Error (SearchFailed): Window IRR search did not converge. +- Error (NotBracketed): No solution for Internal Rate of Return (IRR). + Possible causes: IRR is huge (>1000000%), balance of investment + becomes negative at some point in time. +- Error (SearchFailed): Failed to find solution for Internal Rate of + Return (IRR). Either search does not converge to a solution, or + converges too slowly. +- Error (NotBracketed): Cannot compute window IRR. Possible cause: + investment balance becomes negative within that window. +- Error (SearchFailed): Window IRR search did not converge. Examples: -- Using roi to compute total return of investment in stocks: - https://github.com/simonmichael/hledger/blob/main/examples/investing/roi-unrealised.ledger +- Using roi to compute total return of investment in stocks: + https://github.com/simonmichael/hledger/blob/main/examples/investing/roi-unrealised.ledger -- Cookbook > Return on Investment: https://hledger.org/roi.html +- Cookbook > Return on Investment: https://hledger.org/roi.html Spaces and special characters in --inv and --pnl @@ -73,23 +73,23 @@ sorted into two categories: "cash flow" and "profit and loss", as ROI needs to know which part of the investment value is your contributions and which is due to the return on investment. -- "Cash flow" is depositing or withdrawing money, buying or selling - assets, or otherwise converting between your investment commodity - and any other commodity. Example: +- "Cash flow" is depositing or withdrawing money, buying or selling + assets, or otherwise converting between your investment commodity and + any other commodity. Example: - 2019-01-01 Investing in Snake Oil - assets:cash -$100 - investment:snake oil + 2019-01-01 Investing in Snake Oil + assets:cash -$100 + investment:snake oil - 2020-01-01 Selling my Snake Oil - assets:cash $10 - investment:snake oil = 0 + 2020-01-01 Selling my Snake Oil + assets:cash $10 + investment:snake oil = 0 -- "Profit and loss" is change in the value of your investment: +- "Profit and loss" is change in the value of your investment: - 2019-06-01 Snake Oil falls in value - investment:snake oil = $57 - equity:unrealized profit or loss + 2019-06-01 Snake Oil falls in value + investment:snake oil = $57 + equity:unrealized profit or loss All non-investment postings are assumed to be "cash flow", unless they match --pnl query. Changes in value of your investment due to "profit @@ -111,6 +111,16 @@ in the example below would be classifed as: cash -$100 ; cash flow posting snake oil $50 ; investment posting +Using roi with lots + +If your journal records lots, 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. + IRR and TWR explained "ROI" stands for "return on investment". Traditionally this was computed @@ -180,9 +190,9 @@ removes the distortion caused by investor cash flows. References: -- Explanation of rate of return -- Explanation of IRR -- Explanation of TWR -- IRR vs TWR -- Examples of computing IRR and TWR and discussion of the limitations - of both metrics +- Explanation of rate of return +- Explanation of IRR +- Explanation of TWR +- IRR vs TWR +- Examples of computing IRR and TWR and discussion of the limitations of + both metrics diff --git a/hledger/Hledger/Cli/Commands/commands.m4 b/hledger/Hledger/Cli/Commands/commands.m4 index fb36a3f1f9e..39f97cff060 100644 --- a/hledger/Hledger/Cli/Commands/commands.m4 +++ b/hledger/Hledger/Cli/Commands/commands.m4 @@ -55,6 +55,7 @@ _command_({{Incomestatement}}) # Advanced report commands _command_({{Balance}}) +_command_({{Holdings}}) _command_({{Roi}}) # Chart commands diff --git a/hledger/Hledger/Cli/Utils.hs b/hledger/Hledger/Cli/Utils.hs index 9e8c26285bc..42e581ff115 100644 --- a/hledger/Hledger/Cli/Utils.hs +++ b/hledger/Hledger/Cli/Utils.hs @@ -130,6 +130,7 @@ maybeCollapseLotDetail :: CliOpts -> Journal -> Journal maybeCollapseLotDetail opts | boolopt "lots" rawopts = id | boolopt "ignore-lots" rawopts = id + | command_ opts == "holdings" = id -- holdings always needs lot detail; it aggregates lots itself | otherwise = journalCollapseLotDetail where rawopts = rawopts_ opts diff --git a/hledger/hledger.cabal b/hledger/hledger.cabal index cd931903791..7546d3c89b9 100644 --- a/hledger/hledger.cabal +++ b/hledger/hledger.cabal @@ -77,6 +77,7 @@ extra-source-files: Hledger/Cli/Commands/Files.txt Hledger/Cli/Commands/Get.txt Hledger/Cli/Commands/Help.txt + Hledger/Cli/Commands/Holdings.txt Hledger/Cli/Commands/Import.txt Hledger/Cli/Commands/Incomestatement.txt Hledger/Cli/Commands/Notes.txt @@ -127,6 +128,7 @@ library Hledger.Cli.Commands.Files Hledger.Cli.Commands.Get Hledger.Cli.Commands.Help + Hledger.Cli.Commands.Holdings Hledger.Cli.Commands.Import Hledger.Cli.Commands.Incomestatement Hledger.Cli.Commands.Notes diff --git a/hledger/hledger.m4.md b/hledger/hledger.m4.md index aab2c31773b..d43ea03a6d4 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -700,6 +700,7 @@ Here are those commands and the formats currently supported: | balancesheet | Y | Y | Y | Y | | | | Y | | balancesheetequity | Y | Y | Y | Y | | | | Y | | cashflow | Y | Y | Y | Y | | | | Y | +| holdings | Y | Y | Y | Y | | | | Y | | incomestatement | Y | Y | Y | Y | | | | Y | | print | Y | Y | Y | Y | Y | Y | Y | Y | | register | Y | Y | Y | Y | | | | Y | @@ -823,7 +824,8 @@ You can prevent this by setting your preferred options in the `HLEDGER_LESS` var ˜ ### HTML output˜˜ -HTML output can be styled by an optional `hledger.css` file in the same directory. +HTML output can be styled by an optional `hledger.css` file in the same directory +(there is a sample in the hledger source repository). HTML output will be a HTML fragment, not a complete HTML document. Like other hledger output, for non-ascii characters it will use the system locale's text encoding @@ -7138,6 +7140,21 @@ $ hledger bal assets:stocks --lots -N 5 AAPL assets:stocks:{2026-01-15, $50} ``` +And if you also record a market price, eg with a `P 2026-03-31 AAPL $72` directive, +the [holdings](#holdings) command shows an overview of your investment holdings - +units held, cost basis, current value and unrealised gain: + +```cli +$ hledger holdings -e 2026-04-01 +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +===============++============================================================================================== + assets:stocks || 2026-01-15 75d 5 AAPL $50 $72 $250 $360 100.0% $110 44.0% $100 1865.3% +---------------++---------------------------------------------------------------------------------------------- + || $250 $360 100.0% $110 44.0% $100 1865.3% +``` + Or use `print -a` to see maximum detail on how hledger has analysed your entries - lot subaccounts, inferred annotations, and posting types: @@ -7199,7 +7216,8 @@ so it will infer cost basis annotations automatically, and you won't need to wri Internally, hledger tracks each lot in a subaccount, named like the cost basis. You don't need to write these subaccounts in the journal; hledger infers them automatically. They are hidden from reports by default, since there can be many lots. -To show them, just add the `--lots` flag to any report. Eg: +To show them, just add the `--lots` flag to any report +(the [holdings](#holdings) command is designed for viewing them). Eg: ```journal 2026-01-15 buy @@ -7566,6 +7584,18 @@ account equity:unrealised-gain ; type:U equity:unrealised-gain $10 ``` +### 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: + +```cli +$ hledger roi --inv assets:stocks --pnl 'revenues:gain|equity:unrealised-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. + ## Lot postings and balance assertions On a dispose or transfer posting without an explicit lot subaccount, a [balance assertion](#balance-assertions) @@ -7641,6 +7671,21 @@ $ hledger bal assets:stocks --lots -N 10 AAPL assets:stocks:{2026-02-01, $60} ``` +[holdings](#holdings) shows a fuller overview of them, with acquisition dates +and cost basis (and, when market prices are recorded, current value and +unrealised gain): +``` +$ hledger holdings -e 2026-04-01 --lots +Holdings on 2026-03-31 + + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR +=================================++============================================================================================= + assets:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $100 + assets:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 +---------------------------------++--------------------------------------------------------------------------------------------- + || $850 $100 +``` + and `print -x --lots` shows the inferred lot subaccounts and gain postings. 5 shares were acquired at $50 and sold at $70 = 5 × ($70 - $50) = $100 gain (remember that revenue amounts appear negative). @@ -7724,6 +7769,7 @@ You can list all of a command's options by running `hledger CMD -h`. **[Advanced report commands](#advanced-report-commands)** - [balance](#balance) (bal) - show balance changes, end balances, budgets, gains.. +- [holdings](#holdings) - show investment holdings - [roi](#roi) - show return on investments **[Chart commands](#chart-commands)** diff --git a/hledger/package.yaml b/hledger/package.yaml index 2f83dd8ba4d..bf911f48aca 100644 --- a/hledger/package.yaml +++ b/hledger/package.yaml @@ -75,6 +75,7 @@ extra-source-files: - Hledger/Cli/Commands/Files.txt - Hledger/Cli/Commands/Get.txt - Hledger/Cli/Commands/Help.txt +- Hledger/Cli/Commands/Holdings.txt - Hledger/Cli/Commands/Import.txt - Hledger/Cli/Commands/Incomestatement.txt - Hledger/Cli/Commands/Notes.txt diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test new file mode 100644 index 00000000000..9c44b50fffe --- /dev/null +++ b/hledger/test/holdings.test @@ -0,0 +1,480 @@ +# * Tests for the holdings command. + +# Sample scenario: two AAPL buys, one MSFT buy, a FIFO sale of 5 AAPL. +# No market prices, so the Price, Value and Gain columns are blank. +< +commodity AAPL ; lots: +commodity MSFT ; lots: + +2026-01-01 opening + assets:broker:cash $10000 + equity:start + +2026-01-15 buy + assets:broker:stocks 10 AAPL @ $50 + assets:broker:cash + +2026-02-01 buy + assets:broker:stocks 10 AAPL @ $60 + assets:broker:cash + +2026-02-15 buy + assets:broker:funds 5 MSFT @ $400 + assets:broker:cash + +2026-03-01 sell + assets:broker:stocks -5 AAPL {} @ $70 + assets:broker:cash + +# ** 1. Default: one row per account holding lots, lot subaccounts aggregated. +# Date and Age are shown only when a row's lots all have the same date. +# Cost is the total cost basis; Avg cost is Cost / Quantity. +$ hledger -f- holdings -e 2026-04-01 +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 $2000 + assets:broker:stocks || 15 AAPL $56.67 $850 $100 +----------------------++--------------------------------------------------------------------------------------------- + || $2850 $100 + +# ** 2. With --lots: one row per lot subaccount, with exact unit costs. +$ hledger -f- holdings -e 2026-04-01 --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 $2000 + assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $100 + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 +----------------------------------------++---------------------------------------------------------------------------------------------- + || $2850 $100 + +# ** 3. A query can restrict the report. +$ hledger -f- holdings -e 2026-04-01 cur:AAPL +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +======================++====================================================================================== + assets:broker:stocks || 15 AAPL $56.67 $850 $100 +----------------------++-------------------------------------------------------------------------------------- + || $850 $100 + +# ** 4. Tree mode: parent account rows aggregate the lots beneath them +# (and show only lot-tracked commodities, not eg cash). Lot subaccounts +# appear as leaves with --lots. No totals row when there is a single +# top-level row. +$ hledger -f- holdings -e 2026-04-01 --lots --tree +Holdings on 2026-03-31 + + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR +============================++============================================================================================== + assets:broker || 15 AAPL $2850 $100 + || 5 MSFT + funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 + stocks || 15 AAPL $56.67 $850 $100 + {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $100 + {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 +----------------------------++---------------------------------------------------------------------------------------------- + || $2850 $100 + +# ** 5. --no-elide shows boring parent accounts (and single lots) +# as separate rows. +$ hledger -f- holdings -e 2026-04-01 --lots --tree --no-elide +Holdings on 2026-03-31 + + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR +==========================++============================================================================================== + assets || 15 AAPL $2850 $100 + || 5 MSFT + broker || 15 AAPL $2850 $100 + || 5 MSFT + funds || 2026-02-15 44d 5 MSFT $400 $2000 + {2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 + stocks || 15 AAPL $56.67 $850 $100 + {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $100 + {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 +--------------------------++---------------------------------------------------------------------------------------------- + || $2850 $100 + +# ** 6. --depth clips and aggregates the displayed rows; the lots +# beneath still count. +$ hledger -f- holdings -e 2026-04-01 --depth 2 +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +===============++======================================================================================= + assets:broker || 15 AAPL $2850 $100 + || 5 MSFT +---------------++--------------------------------------------------------------------------------------- + || $2850 $100 + +# ** 7. A journal with no lots reports no holdings. +< +2026-01-01 opening + assets:cash $100 + equity:start + +$ hledger -f- holdings -e 2026-01-02 +Holdings on 2026-01-01 + +(no holdings) + +# ** 8. Amounts are shown with their commodity's display precision. +# Inferred per-unit costs in lot names can have up to 8 decimals; +# Unit cost, Avg cost and Cost are still displayed with the commodity's +# standard display precision (here, 2 digits). +< +commodity FUND ; lots: +commodity GOLD ; lots: + +2026-01-01 buy fund units + assets:fund 9.216 FUND @@ $16600.00 + assets:cash + +2026-02-01 buy more + assets:fund 7.777 FUND @@ $15000.00 + assets:cash + +2026-03-01 buy gold + assets:gold 1.5 GOLD @@ $3000.33 + assets:cash + +$ hledger -f- holdings -e 2026-04-01 --lots +Holdings on 2026-03-31 + + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR +==========================================++===================================================================================================== + assets:fund:{2026-01-01, $1801.21527778} || 2026-01-01 89d 9.216 FUND $1801.22 $16600.00 + assets:fund:{2026-02-01, $1928.76430500} || 2026-02-01 58d 7.777 FUND $1928.76 $15000.00 + assets:gold:{2026-03-01, $2000.22} || 2026-03-01 30d 1.5 GOLD $2000.22 $3000.33 +------------------------------------------++----------------------------------------------------------------------------------------------------- + || $34600.33 + +# ** 9. Same, aggregated: Avg cost also uses the display precision. +$ hledger -f- holdings -e 2026-04-01 +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +=============++===================================================================================================== + assets:fund || 16.993 FUND $1859.59 $31600.00 + assets:gold || 2026-03-01 30d 1.5 GOLD $2000.22 $3000.33 +-------------++----------------------------------------------------------------------------------------------------- + || $34600.33 + +# ** 10. Quantities are also normalised to the commodity's display precision +# (here 3 decimals, inferred from the first purchase). +< +commodity AAAA ; lots: + +2026-01-01 buy + assets 1.234 AAAA @ $10 + assets:cash + +2026-01-02 buy + assets 5 AAAA @ $12 + assets:cash + +$ hledger -f- holdings -e 2026-02-01 --lots +Holdings on 2026-01-31 + + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR +==========================++================================================================================================ + assets:{2026-01-01, $10} || 2026-01-01 30d 1.234 AAAA $10 $12 + assets:{2026-01-02, $12} || 2026-01-02 29d 5.000 AAAA $12 $60 +--------------------------++------------------------------------------------------------------------------------------------ + || $72 + +# ** 11. --round can select another rounding strategy, eg none shows +# amounts with their original precision. +$ hledger -f- holdings -e 2026-02-01 --lots --round=none +Holdings on 2026-01-31 + + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR +==========================++================================================================================================ + assets:{2026-01-01, $10} || 2026-01-01 30d 1.234 AAAA $10 $12 + assets:{2026-01-02, $12} || 2026-01-02 29d 5 AAAA $12 $60 +--------------------------++------------------------------------------------------------------------------------------------ + || $72 + +# ** 12. With market prices (P directives), the Price, Value and Gain +# columns show each holding's market price at the report date, its +# market value, and its unrealised gain (absolute and percent). +< +commodity AAPL ; lots: +commodity MSFT ; lots: + +2026-01-01 opening + assets:broker:cash $10000 + equity:start + +2026-01-15 buy + assets:broker:stocks 10 AAPL @ $50 + assets:broker:cash + +2026-02-01 buy + assets:broker:stocks 10 AAPL @ $60 + assets:broker:cash + +2026-02-15 buy + assets:broker:funds 5 MSFT @ $400 + assets:broker:cash + +2026-03-01 sell + assets:broker:stocks -5 AAPL {} @ $70 + assets:broker:cash + +P 2026-03-31 AAPL $72 +P 2026-03-31 MSFT $410 + +$ hledger -f- holdings -e 2026-04-01 +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% + +# ** 13. Same with --lots: per-lot values and gains. +$ hledger -f- holdings -e 2026-04-01 --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% + +# ** 14. With --infer-market-prices, transaction costs also provide +# market prices, as usual. +< +commodity AAAA ; lots: + +2026-01-01 buy + assets 1.234 AAAA @ $10 + assets:cash + +2026-01-02 buy + assets 5 AAAA @ $12 + assets:cash + +$ hledger -f- holdings -e 2026-02-01 --lots --infer-market-prices +Holdings on 2026-01-31 + + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR +==========================++================================================================================================== + assets:{2026-01-01, $10} || 2026-01-01 30d 1.234 AAAA $10 $12 $12 $15 19.8% $2 20.0% 820.5% + assets:{2026-01-02, $12} || 2026-01-02 29d 5.000 AAAA $12 $12 $60 $60 80.2% $0 0.0% 0.0% +--------------------------++-------------------------------------------------------------------------------------------------- + || $72 $75 100.0% $2 3.4% 52.2% + + +# ** 15. -X/--value=end,COMM value holdings in the given commodity: +# the Price and Value columns, and also the Cost, Unit/Avg cost and Gain +# columns (converted at the valuation date, so percent gain is unchanged). +# Converted amounts are also normalised to the commodity's display precision. +< +commodity AAPL ; lots: +commodity €1.00 + +2026-01-15 buy + assets:stocks 10 AAPL @ $50 + assets:cash + +P 2026-03-31 AAPL $72 +P 2026-03-31 $ €0.9123 + +$ hledger -f- holdings -e 2026-04-01 -X € +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +===============++==================================================================================================== + assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €65.69 €456.15 €656.86 100.0% €200.71 44.0% +---------------++---------------------------------------------------------------------------------------------------- + || €456.15 €656.86 100.0% €200.71 44.0% + +# ** 16. -V values in the default valuation commodity. +$ hledger -f- holdings -e 2026-04-01 -V +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +===============++============================================================================================== + assets:stocks || 2026-01-15 75d 10 AAPL $50 $72 $500 $720 100.0% $220 44.0% 490.5% +---------------++---------------------------------------------------------------------------------------------- + || $500 $720 100.0% $220 44.0% 490.5% + +# ** 17. --value=then is not supported. +$ hledger -f- holdings --value=then +>2 +hledger: Error: holdings: --value=then is not supported +>=1 + +# ** 18. -S/--sort-amount sorts rows by value (or cost when unpriced), +# largest first. +< +commodity AAPL ; lots: +commodity MSFT ; lots: + +2026-01-15 buy + assets:apples 10 AAPL @ $50 + assets:cash + +2026-02-15 buy + assets:msoft 5 MSFT @ $400 + assets:cash + +P 2026-03-31 AAPL $72 +P 2026-03-31 MSFT $410 + +$ hledger -f- holdings -e 2026-04-01 -S +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +===============++=============================================================================================== + assets:msoft || 2026-02-15 44d 5 MSFT $400 $410 $2000 $2050 74.0% $50 2.5% 22.7% + assets:apples || 2026-01-15 75d 10 AAPL $50 $72 $500 $720 26.0% $220 44.0% 490.5% +---------------++----------------------------------------------------------------------------------------------- + || $2500 $2770 100.0% $270 10.8% 110.4% + +# ** 19. CSV output: one record per row and commodity, with full account +# names, age in days, bare quantity and gain percent numbers, and gain +# and gain percent as separate fields. (TSV is the same, tab-separated.) +$ hledger -f- holdings -e 2026-04-01 -O csv +"account","commodity","date","age","units","unitcost","price","cost","value","weight","ugain","ugainpct","rgain","xirr" +"assets:apples","AAPL","2026-01-15","75","10","$50","$72","$500","$720","26.0","$220","44.0","","490.5" +"assets:msoft","MSFT","2026-02-15","44","5","$400","$410","$2000","$2050","74.0","$50","2.5","","22.7" + +# ** 20. CSV/TSV amounts have no digit group marks; the decimal mark +# follows the commodity's display style, as in other commands' CSV output. +< +commodity AAAA ; lots: +commodity 1.000,00 € + +2026-01-15 buy + assets:stocks 1000,5 AAAA @ 2,25 € + assets:cash + +P 2026-03-31 AAAA 3,10 € + +$ hledger -f- holdings -e 2026-04-01 -O csv +"account","commodity","date","age","units","unitcost","price","cost","value","weight","ugain","ugainpct","rgain","xirr" +"assets:stocks","AAAA","2026-01-15","75","1000,5","2,25 €","3,10 €","2251,12 €","3101,55 €","100.0","850,42 €","37.8","","376.2" + +# ** 21. HTML output: an HTML table like the text table, with single-line +# cells and a totals row. +$ hledger -f- holdings -e 2026-04-01 -O html +> /assets:stocks<\/td>2026-01-15<\/td>75d<\/td>1000,5 AAAA<\/span><\/td>/ + +# ** 22. FODS output: a flat opendocument spreadsheet. +$ hledger -f- holdings -e 2026-04-01 -O fods +> /office:mimetype="application\/vnd\.oasis\.opendocument\.spreadsheet"/ + +# ** 23. JSON output: an array of holding objects with the CSV fields. +$ hledger -f- holdings -e 2026-04-01 -O json +> /"unitcost": "2,25 €"/ + +# ** 24. Ages of a year or more are shown in years with one decimal digit. +< +commodity AAPL ; lots: + +2020-01-15 buy + assets 10 AAPL @ $50 + assets:cash + +$ hledger -f- holdings -e 2026-04-01 +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +========++============================================================================================= + assets || 2020-01-15 6.2y 10 AAPL $50 $500 +--------++--------------------------------------------------------------------------------------------- + || $500 + +# ** 25. Realised gains of fully disposed lots have no row of their own +# with --lots, but are included in the totals row, which shows +# account-level realised gain (and XIRR), consistent with other modes. +< +commodity AAPL ; lots: + +2026-01-15 buy low + assets:stocks 10 AAPL @ $50 + assets:cash + +2026-02-01 buy high + assets:stocks 10 AAPL @ $60 + assets:cash + +2026-03-01 sell the first lot entirely + assets:stocks -10 AAPL {2026-01-15} @ $70 + assets:cash + +$ hledger -f- holdings -e 2026-04-01 --lots +Holdings on 2026-03-31 + + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR +=================================++============================================================================================= + assets:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 +---------------------------------++--------------------------------------------------------------------------------------------- + || $600 $200 + +# ** 26. --drop omits leading account name parts in list mode. +$ hledger -f- holdings -e 2026-04-01 --drop 1 +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +========++============================================================================================ + stocks || 2026-02-01 58d 10 AAPL $60 $600 $200 +--------++-------------------------------------------------------------------------------------------- + || $600 $200 + +# ** 27. A display style declared for the "%" commodity (eg with -c) +# styles the percent columns: Weight, UGain% and XIRR. +< +commodity AAPL ; lots: + +2026-01-15 buy + assets 10 AAPL @ $50 + assets:cash + +2026-02-01 sell some + assets -5 AAPL @ $70 + assets:cash + +P 2026-03-31 AAPL $72 + +$ hledger -f- holdings -e 2026-04-01 -c '1.00 %' +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +========++=================================================================================================== + assets || 2026-01-15 75d 5 AAPL $50 $72 $250 $360 100.00 % $110 44.00 % $100 1865.32 % +--------++--------------------------------------------------------------------------------------------------- + || $250 $360 100.00 % $110 44.00 % $100 1865.32 % + +# ** 28. A % style inferred from journal % amounts also styles the +# percent columns (here, adding a space before the sign). +< +commodity AAPL ; lots: + +2026-01-10 some unrelated percent amount + (misc:rate) 5.0 % + +2026-01-15 buy + assets 10 AAPL @ $50 + assets:cash + +P 2026-03-31 AAPL $72 + +$ hledger -f- holdings -e 2026-04-01 +Holdings on 2026-03-31 + + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR +========++================================================================================================ + assets || 2026-01-15 75d 10 AAPL $50 $72 $500 $720 100.0 % $220 44.0 % 490.5 % +--------++------------------------------------------------------------------------------------------------ + || $500 $720 100.0 % $220 44.0 % 490.5 %