From eaa01212d6e3199fc3db19789b95a9e59733904f Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 05:29:11 +0100 Subject: [PATCH 01/26] feat: holdings: new command showing investment holdings (phase 1, layout mockup) Adds a skeleton holdings command which shows a mockup of the planned layout with sample data, rendered with the standard table machinery. --lots and --tree select layout variants. Design notes and mockups are in the new doc/SPEC-holdings.md. AI usage: Claude Fable 5, ~42k output tokens --- doc/SPEC-holdings.md | 154 ++++++++++++++++++++++ hledger/Hledger/Cli/Commands.hs | 4 + hledger/Hledger/Cli/Commands/Holdings.hs | 102 ++++++++++++++ hledger/Hledger/Cli/Commands/Holdings.md | 20 +++ hledger/Hledger/Cli/Commands/Holdings.txt | 18 +++ hledger/Hledger/Cli/Commands/commands.m4 | 1 + hledger/hledger.cabal | 1 + hledger/hledger.m4.md | 1 + 8 files changed, 301 insertions(+) create mode 100644 doc/SPEC-holdings.md create mode 100644 hledger/Hledger/Cli/Commands/Holdings.hs create mode 100644 hledger/Hledger/Cli/Commands/Holdings.md create mode 100644 hledger/Hledger/Cli/Commands/Holdings.txt diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md new file mode 100644 index 00000000000..1eaaef63617 --- /dev/null +++ b/doc/SPEC-holdings.md @@ -0,0 +1,154 @@ +# 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: phase 1, layout mockup. + +## 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`). + +## Columns + +| Column | Meaning | +|-----------|----------------------------------------------------------------| +| Date | the lot's acquisition date | +| Age | how long the lot has been held, as of the report date | +| Quantity | number of units held | +| Unit cost | cost basis per unit ("Avg cost" on rows aggregating lots) | +| Cost | total cost basis | +| Price | current market price per unit | +| Value | current market value (Quantity x Price) | +| Gain | unrealised gain: Value - Cost, absolute and percent | + +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, eg `75d`. (Could later be humanised, eg `2m14d` or + `1y3m`, and/or a long/short-term capital gains indicator could be added.) +- Rows with no known market price show blank Price, Value and Gain, + rather than pretending the gain is zero. +- The totals row shows only the commodity-independent columns: + Cost, Value, Gain. +- Possible future columns: portfolio weight %, realised gain, XIRR. + +## Valuation + +- The report date is the report end date (today by default, or set with `-e`). +- Prices are market prices at the report date, from P directives and/or + inferred from transaction costs, using the standard `--value` infrastructure. + Value/Gain columns behave like `--value=end`. + +## Layout mockups + +Scenario: two AAPL buys in assets:broker:stocks, one MSFT buy in +assets:broker:funds, a FIFO sale of 5 AAPL, 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 Quantity Avg cost Cost Price Value Gain +======================++======================================================================== + assets:broker:funds || 2026-02-15 44d 5 MSFT $400.00 $2000 $410 $2050 $50 (+2.5%) + assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 $230 (+27.1%) +----------------------++------------------------------------------------------------------------ + || $2850 $3130 $280 (+9.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.) + +With `--lots` (lot subaccounts become rows; Avg cost becomes exact Unit cost): + +``` +$ hledger holdings --lots +Holdings on 2026-03-31 + + || Date Age Quantity Unit cost Cost Price Value Gain +========================================++========================================================================= + assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) + assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 $110 (+44.0%) + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 $120 (+20.0%) +----------------------------------------++------------------------------------------------------------------------- + || $2850 $3130 $280 (+9.8%) +``` + +With `--lots --tree` (parent rows aggregate; multi-commodity cells go +multi-line as in bal): + +``` +$ hledger holdings --lots --tree +Holdings on 2026-03-31 + + || Date Age Quantity Unit cost Cost Price Value Gain +==========================++========================================================================= + assets || 15 AAPL $2850 $3130 $280 (+9.8%) + || 5 MSFT + broker || 15 AAPL $2850 $3130 $280 (+9.8%) + || 5 MSFT + funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) + {2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) + stocks || 15 AAPL $56.67 $850 $72 $1080 $230 (+27.1%) + {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 $110 (+44.0%) + {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 $120 (+20.0%) +``` + +With `--depth 2` (aggregation up the tree; per-unit and per-lot columns blank +where meaningless): + +``` +$ hledger holdings --depth 2 +Holdings on 2026-03-31 + + || Quantity Cost Value Gain +================++===================================== + assets:broker || 15 AAPL $2850 $3130 $280 (+9.8%) + || 5 MSFT +``` + +## Implementation notes + +- The table is a single-period MultiBalanceReport-shaped table: rows from the + standard account-tree display machinery, but columns are per-row attributes + instead of periods. Rendering via Text.Tabular.AsciiWide as in Balance.hs. +- Cell data comes from lot state (as computed by journalCalculateLots) plus + market prices. + +## Phases + +1. Layout mockup: skeleton `holdings` command printing the sample layout above. (current) +2. Real single-attribute report: rows from the journal's lotful accounts, with + Quantity and Cost columns. +3. Valuation columns: Price, Value, Gain. +4. Date/Age columns, `--lots`/`--tree`/`--depth` behavior, totals row. +5. Output formats, sorting, extra columns. 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..6e370839c12 --- /dev/null +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -0,0 +1,102 @@ +{-| + +The @holdings@ command shows a report of investment holdings (lot-tracked assets). + +Currently it shows a mockup of the planned layout, with sample data. +See doc/SPEC-holdings.md. + +-} + +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} + +module Hledger.Cli.Commands.Holdings ( + holdingsmode + ,holdings +) where + +import Data.Default (def) +import Data.Text (Text) +import Data.Text.Lazy.IO qualified as TL + +import Hledger +import Hledger.Cli.CliOptions +import Text.Tabular.AsciiWide + +-- | Command line options for this command. +holdingsmode = hledgerCommandMode + $(embedFileRelative "Hledger/Cli/Commands/Holdings.txt") + (flattreeflags True) + cligeneralflagsgroups1 + hiddenflags + ([], Just $ argsFlag "[QUERY]") + +-- | Show the holdings report. +-- Phase 1: shows a hardcoded mockup of the planned layout, ignoring the journal. +-- The --lots and --tree flags select the corresponding layout variant. +holdings :: CliOpts -> Journal -> IO () +holdings CliOpts{rawopts_=rawopts, reportspec_=ReportSpec{_rsReportOpts=ropts}} _j = do + putStrLn "Holdings on 2026-03-31 (mockup with sample data)" + putStrLn "" + TL.putStrLn $ renderTable + def{tableBorders=False} + (textCell TopLeft) + (textCell TopRight) + (textCell TopRight) + tbl + where + tbl | lots && tree = treeMockup + | lots = lotsMockup + | otherwise = defaultMockup + where + lots = boolopt "lots" rawopts + tree = accountlistmode_ ropts == ALTree + +-- | Build a mockup holdings table from column headings, rows of +-- (account name, cells), and a possible totals row. +mockupTable :: [Text] -> [(Text, [Text])] -> Maybe [Text] -> Table Text Text Text +mockupTable colheadings rows mtotalrow = maybe maintbl addtotal mtotalrow + where + maintbl = Table + (Group NoLine $ map (Header . fst) rows) + (Group NoLine $ map Header colheadings) + (map snd rows) + addtotal totalrow = concatTables SingleLine maintbl $ + Table (Group NoLine [Header ""]) (Header []) [totalrow] + +-- Sample data: two AAPL buys in assets:broker:stocks, one MSFT buy in +-- assets:broker:funds, a FIFO sale of 5 AAPL, and market prices +-- (AAPL $72, MSFT $410) on the report date 2026-03-31. + +-- | Default layout: list mode, lot subaccounts hidden. +defaultMockup :: Table Text Text Text +defaultMockup = mockupTable + ["Date", "Age", "Quantity", "Avg cost", "Cost", "Price", "Value", "Gain"] + [ ("assets:broker:funds", ["2026-02-15", "44d", "5 MSFT", "$400.00", "$2000", "$410", "$2050", "$50 (+2.5%)"]) + , ("assets:broker:stocks", ["", "", "15 AAPL", "$56.67", "$850", "$72", "$1080", "$230 (+27.1%)"]) + ] + (Just ["", "", "", "", "$2850", "", "$3130", "$280 (+9.8%)"]) + +-- | With --lots: lot subaccounts become rows. +lotsMockup :: Table Text Text Text +lotsMockup = mockupTable + ["Date", "Age", "Quantity", "Unit cost", "Cost", "Price", "Value", "Gain"] + [ ("assets:broker:funds:{2026-02-15, $400}", ["2026-02-15", "44d", "5 MSFT", "$400", "$2000", "$410", "$2050", "$50 (+2.5%)"]) + , ("assets:broker:stocks:{2026-01-15, $50}", ["2026-01-15", "75d", "5 AAPL", "$50", "$250", "$72", "$360", "$110 (+44.0%)"]) + , ("assets:broker:stocks:{2026-02-01, $60}", ["2026-02-01", "58d", "10 AAPL", "$60", "$600", "$72", "$720", "$120 (+20.0%)"]) + ] + (Just ["", "", "", "", "$2850", "", "$3130", "$280 (+9.8%)"]) + +-- | With --lots --tree: parent rows aggregate their subaccounts. +treeMockup :: Table Text Text Text +treeMockup = mockupTable + ["Date", "Age", "Quantity", "Unit cost", "Cost", "Price", "Value", "Gain"] + [ ("assets", ["", "", "15 AAPL\n5 MSFT", "", "$2850", "", "$3130", "$280 (+9.8%)"]) + , (" broker", ["", "", "15 AAPL\n5 MSFT", "", "$2850", "", "$3130", "$280 (+9.8%)"]) + , (" funds", ["2026-02-15", "44d", "5 MSFT", "$400", "$2000", "$410", "$2050", "$50 (+2.5%)"]) + , (" {2026-02-15, $400}", ["2026-02-15", "44d", "5 MSFT", "$400", "$2000", "$410", "$2050", "$50 (+2.5%)"]) + , (" stocks", ["", "", "15 AAPL", "$56.67", "$850", "$72", "$1080", "$230 (+27.1%)"]) + , (" {2026-01-15, $50}", ["2026-01-15", "75d", "5 AAPL", "$50", "$250", "$72", "$360", "$110 (+44.0%)"]) + , (" {2026-02-01, $60}", ["2026-02-01", "58d", "10 AAPL", "$60", "$600", "$72", "$720", "$120 (+20.0%)"]) + ] + Nothing diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md new file mode 100644 index 00000000000..d2f5ee4cbe4 --- /dev/null +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -0,0 +1,20 @@ +## 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. +``` + +This command is a work in progress; currently it shows a mockup of the +planned layout, with sample data. + +It will show the assets held in lot-tracked accounts (see [Lots](#lots)): +one row per account (or per lot, with `--lots`), and columns showing +each holding's acquisition date, age, quantity, cost basis, +current market price, market value, and unrealised gain. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt new file mode 100644 index 00000000000..e164a13ac9a --- /dev/null +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -0,0 +1,18 @@ +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. + +This command is a work in progress; currently it shows a mockup of the +planned layout, with sample data. + +It will show the assets held in lot-tracked accounts (see Lots): one row +per account (or per lot, with --lots), and columns showing each +holding's acquisition date, age, quantity, cost basis, current market +price, market value, and unrealised gain. 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.cabal b/hledger/hledger.cabal index cd931903791..0449e9c41f7 100644 --- a/hledger/hledger.cabal +++ b/hledger/hledger.cabal @@ -127,6 +127,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..c6bb5e5e564 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -7724,6 +7724,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)** From add9b168077bed2d9724141d18008d5073936713 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 06:31:20 +0100 Subject: [PATCH 02/26] feat: holdings: show real holdings with cost basis The holdings command now shows real data in list mode: the assets held in lot-tracked accounts as of the report end date, one row per account, or one row per lot with --lots. Columns: Date and Age (when the row's lots share one date), Quantity, Unit/Avg cost, and Cost (total cost basis), with a totals row (disable with -N). Amounts are displayed normalised to their commodity's display precision. Query arguments are supported; --tree is not yet. Rows come from a single-period end-balances multiBalanceReport; per-lot quantities are summed from lot subaccount postings, and each lot's cost basis is parsed from its subaccount name. The command receives the journal with lot detail uncollapsed regardless of --lots, aggregating lots itself. AI usage: Claude Fable 5, ~80k output tokens --- doc/SPEC-holdings.md | 31 +++- hledger/Hledger/Cli/Commands/Holdings.hs | 204 ++++++++++++++-------- hledger/Hledger/Cli/Commands/Holdings.md | 19 +- hledger/Hledger/Cli/Commands/Holdings.txt | 18 +- hledger/Hledger/Cli/Utils.hs | 1 + hledger/test/holdings.test | 141 +++++++++++++++ 6 files changed, 325 insertions(+), 89 deletions(-) create mode 100644 hledger/test/holdings.test diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 1eaaef63617..04e4fca428f 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -54,6 +54,8 @@ Notes: `1y3m`, and/or a long/short-term capital gains indicator could be added.) - Rows with no known market price show blank Price, Value and Gain, rather than pretending the gain is zero. +- Amounts are displayed normalised to their commodity's display precision + (unlike lot names, which can show more precision). - The totals row shows only the commodity-independent columns: Cost, Value, Gain. - Possible future columns: portfolio weight %, realised gain, XIRR. @@ -138,17 +140,28 @@ Holdings on 2026-03-31 ## Implementation notes -- The table is a single-period MultiBalanceReport-shaped table: rows from the - standard account-tree display machinery, but columns are per-row attributes - instead of periods. Rendering via Text.Tabular.AsciiWide as in Balance.hs. -- Cell data comes from lot state (as computed by journalCalculateLots) plus - market prices. +- 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 quantities 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. (current) -2. Real single-attribute report: rows from the journal's lotful accounts, with - Quantity and Cost columns. +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, Quantity, 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. -4. Date/Age columns, `--lots`/`--tree`/`--depth` behavior, totals row. +4. `--tree`/`--depth` behavior. 5. Output formats, sorting, extra columns. diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 6e370839c12..c098afaaa07 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -2,8 +2,10 @@ The @holdings@ command shows a report of investment holdings (lot-tracked assets). -Currently it shows a mockup of the planned layout, with sample data. -See doc/SPEC-holdings.md. +Work in progress; see doc/SPEC-holdings.md. +Currently it shows the non-valuation columns (Date, Age, Quantity, Unit/Avg +cost, Cost) in list mode, with lot subaccounts aggregated by default or shown +as rows with --lots. -} @@ -16,8 +18,13 @@ module Hledger.Cli.Commands.Holdings ( ) where import Data.Default (def) -import Data.Text (Text) +import Data.List.Extra (nubSort) +import Data.Map.Strict qualified as M +import Data.Maybe (isJust) +import Data.Text qualified as T import Data.Text.Lazy.IO qualified as TL +import Data.Time.Calendar (addDays, diffDays) +import System.Console.CmdArgs.Explicit (flagNone) import Hledger import Hledger.Cli.CliOptions @@ -26,77 +33,138 @@ import Text.Tabular.AsciiWide -- | Command line options for this command. holdingsmode = hledgerCommandMode $(embedFileRelative "Hledger/Cli/Commands/Holdings.txt") - (flattreeflags True) + (flattreeflags True ++ + [flagNone ["no-total","N"] (setboolopt "no-total") "omit the final total row"]) cligeneralflagsgroups1 hiddenflags ([], Just $ argsFlag "[QUERY]") --- | Show the holdings report. --- Phase 1: shows a hardcoded mockup of the planned layout, ignoring the journal. --- The --lots and --tree flags select the corresponding layout variant. +-- | 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 CliOpts{rawopts_=rawopts, reportspec_=ReportSpec{_rsReportOpts=ropts}} _j = do - putStrLn "Holdings on 2026-03-31 (mockup with sample data)" - putStrLn "" - TL.putStrLn $ renderTable - def{tableBorders=False} - (textCell TopLeft) - (textCell TopRight) - (textCell TopRight) - tbl +holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsReportOpts=ropts}} j = do + if accountlistmode_ ropts == ALTree then error' "holdings: --tree is not yet supported" + else do + putStrLn $ "Holdings on " ++ T.unpack (showDate reportdate) + putStrLn "" + if null rows + then putStrLn "(no holdings)" + else TL.putStr $ renderTable + def{tableBorders=False} + (textCell TopLeft) + (textCell TopRight) + (textCell TopRight) + tbl where - tbl | lots && tree = treeMockup - | lots = lotsMockup - | otherwise = defaultMockup + showlots = boolopt "lots" rawopts + + -- 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 quantity held in each lot subaccount, from its postings. + -- Keyed by account and commodity, so amounts in different commodities + -- (not expected in a lot subaccount, but possible) don't merge wrongly. + -- Postings are restricted by the report query's non-date terms and its + -- end date (but not its begin date; holdings are cumulative). + lotmap :: M.Map (AccountName, CommoditySymbol) Amount + lotmap = M.fromListWith (+) + [ ((paccount p, acommodity a), amountStripCost a) + | p <- journalPostings j + , isJust $ lotSubaccountName $ paccount p + , endq `matchesPosting` p + , a <- amountsRaw $ pamount p + ] where - lots = boolopt "lots" rawopts - tree = accountlistmode_ ropts == ALTree + endq = And [filterQuery (not . queryIsDateOrDate2) q + ,Date $ DateSpan Nothing (Exact <$> mend)] --- | Build a mockup holdings table from column headings, rows of --- (account name, cells), and a possible totals row. -mockupTable :: [Text] -> [(Text, [Text])] -> Maybe [Text] -> Table Text Text Text -mockupTable colheadings rows mtotalrow = maybe maintbl addtotal mtotalrow - where - maintbl = Table - (Group NoLine $ map (Header . fst) rows) + -- 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. + styles = journalCommodityStylesWith HardRounding j + + -- 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. + mbr = multiBalanceReport rspec' j' + where + rspec' = rspec{_rsReportOpts=ropts{balanceaccum_=Historical, interval_=NoInterval}} + j' = if showlots then j else journalCollapseLotDetail j + rows = filter keeprow $ prRows mbr + where + keeprow r + | showlots = isJust $ lotSubaccountName $ prrFullName r + | otherwise = not $ null $ lotsUnder $ prrFullName r + + tbl = maybe id addtotalrow mtotalrow $ Table + (Group NoLine $ map (Header . renderacct) rows) (Group NoLine $ map Header colheadings) - (map snd rows) - addtotal totalrow = concatTables SingleLine maintbl $ - Table (Group NoLine [Header ""]) (Header []) [totalrow] - --- Sample data: two AAPL buys in assets:broker:stocks, one MSFT buy in --- assets:broker:funds, a FIFO sale of 5 AAPL, and market prices --- (AAPL $72, MSFT $410) on the report date 2026-03-31. - --- | Default layout: list mode, lot subaccounts hidden. -defaultMockup :: Table Text Text Text -defaultMockup = mockupTable - ["Date", "Age", "Quantity", "Avg cost", "Cost", "Price", "Value", "Gain"] - [ ("assets:broker:funds", ["2026-02-15", "44d", "5 MSFT", "$400.00", "$2000", "$410", "$2050", "$50 (+2.5%)"]) - , ("assets:broker:stocks", ["", "", "15 AAPL", "$56.67", "$850", "$72", "$1080", "$230 (+27.1%)"]) - ] - (Just ["", "", "", "", "$2850", "", "$3130", "$280 (+9.8%)"]) - --- | With --lots: lot subaccounts become rows. -lotsMockup :: Table Text Text Text -lotsMockup = mockupTable - ["Date", "Age", "Quantity", "Unit cost", "Cost", "Price", "Value", "Gain"] - [ ("assets:broker:funds:{2026-02-15, $400}", ["2026-02-15", "44d", "5 MSFT", "$400", "$2000", "$410", "$2050", "$50 (+2.5%)"]) - , ("assets:broker:stocks:{2026-01-15, $50}", ["2026-01-15", "75d", "5 AAPL", "$50", "$250", "$72", "$360", "$110 (+44.0%)"]) - , ("assets:broker:stocks:{2026-02-01, $60}", ["2026-02-01", "58d", "10 AAPL", "$60", "$600", "$72", "$720", "$120 (+20.0%)"]) - ] - (Just ["", "", "", "", "$2850", "", "$3130", "$280 (+9.8%)"]) - --- | With --lots --tree: parent rows aggregate their subaccounts. -treeMockup :: Table Text Text Text -treeMockup = mockupTable - ["Date", "Age", "Quantity", "Unit cost", "Cost", "Price", "Value", "Gain"] - [ ("assets", ["", "", "15 AAPL\n5 MSFT", "", "$2850", "", "$3130", "$280 (+9.8%)"]) - , (" broker", ["", "", "15 AAPL\n5 MSFT", "", "$2850", "", "$3130", "$280 (+9.8%)"]) - , (" funds", ["2026-02-15", "44d", "5 MSFT", "$400", "$2000", "$410", "$2050", "$50 (+2.5%)"]) - , (" {2026-02-15, $400}", ["2026-02-15", "44d", "5 MSFT", "$400", "$2000", "$410", "$2050", "$50 (+2.5%)"]) - , (" stocks", ["", "", "15 AAPL", "$56.67", "$850", "$72", "$1080", "$230 (+27.1%)"]) - , (" {2026-01-15, $50}", ["2026-01-15", "75d", "5 AAPL", "$50", "$250", "$72", "$360", "$110 (+44.0%)"]) - , (" {2026-02-01, $60}", ["2026-02-01", "58d", "10 AAPL", "$60", "$600", "$72", "$720", "$120 (+20.0%)"]) - ] - Nothing + (map rowcells rows) + where + addtotalrow totalrow tbl' = concatTables SingleLine tbl' $ + Table (Group NoLine [Header ""]) (Header []) [totalrow] + colheadings = ["Date", "Age", "Quantity", if showlots then "Unit cost" else "Avg cost", "Cost"] + renderacct r = T.replicate (prrIndent r * 2) " " <> prrDisplayName r + + rowLotCosts r = [multiplyAmount (aquantity a) c + | (a, mcb) <- lotsUnder $ prrFullName r, Just c <- [cbCost =<< mcb]] + + rowcells r = [datecell, agecell, qtycell, unitcostcell, costcell] + where + rowlots = lotsUnder $ prrFullName r + 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, T.pack (show $ diffDays reportdate dt) <> "d") + _ -> ("", "") + qtycell = T.pack $ showMixedAmountWith oneLineNoCostFmt $ styleAmounts styles $ prrTotal r + costs = rowLotCosts r + costcell = showamts costs + unitcostcell = case (rowlots, costs) of + ([(_, mcb)], _) -> maybe "" (T.pack . showAmountWith noCostFmt) (cbCost =<< mcb) + (_, _:_) | [totcost] <- amounts (mixed costs) + , [totqty] <- amounts (mixed $ map fst rowlots) + , not $ amountLooksZero totqty + -> T.pack $ showAmountWith noCostFmt $ avgcost totqty totcost + _ -> "" + -- An average cost: total cost / total quantity, 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 + + -- Grand totals row: just the Cost column for now. + mtotalrow + | no_total_ ropts || length rows < 2 = Nothing + | otherwise = Just ["", "", "", "", showamts $ concatMap rowLotCosts rows] + + showamts = T.pack . showMixedAmountWith oneLineNoCostFmt . mixed diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index d2f5ee4cbe4..1343a1b9c06 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -9,12 +9,19 @@ Flags: except where the account is depth-clipped. -t --tree list/tree mode: show accounts as a tree. Amounts include subaccount amounts. + -N --no-total omit the final total row ``` -This command is a work in progress; currently it shows a mockup of the -planned layout, with sample data. +This command is a work in progress. -It will show the assets held in lot-tracked accounts (see [Lots](#lots)): -one row per account (or per lot, with `--lots`), and columns showing -each holding's acquisition date, age, quantity, cost basis, -current market price, market value, and unrealised gain. +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`. Columns show each holding's acquisition date and age +(when the row's lots share a single date), the quantity held, +the unit cost (or average cost, on rows aggregating multiple lots), +and the total cost basis. +Amounts are displayed with their commodity's display precision +(unlike lot names, which can show more precision). + +Not yet implemented: market price, market value and unrealised gain +columns; tree mode; output formats other than text. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index e164a13ac9a..1a86b085632 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -8,11 +8,17 @@ Flags: except where the account is depth-clipped. -t --tree list/tree mode: show accounts as a tree. Amounts include subaccount amounts. + -N --no-total omit the final total row -This command is a work in progress; currently it shows a mockup of the -planned layout, with sample data. +This command is a work in progress. -It will show the assets held in lot-tracked accounts (see Lots): one row -per account (or per lot, with --lots), and columns showing each -holding's acquisition date, age, quantity, cost basis, current market -price, market value, and unrealised gain. +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. +Columns show each holding's acquisition date and age (when the row's +lots share a single date), the quantity held, the unit cost (or average +cost, on rows aggregating multiple lots), and the total cost basis. +Amounts are displayed with their commodity's display precision (unlike +lot names, which can show more precision). + +Not yet implemented: market price, market value and unrealised gain +columns; tree mode; output formats other than text. 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/test/holdings.test b/hledger/test/holdings.test new file mode 100644 index 00000000000..6431c81e9d4 --- /dev/null +++ b/hledger/test/holdings.test @@ -0,0 +1,141 @@ +# * Tests for the holdings command. + +# Sample scenario: two AAPL buys, one MSFT buy, a FIFO sale of 5 AAPL. +< +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 Quantity Avg cost Cost +======================++============================================ + assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 + assets:broker:stocks || 15 AAPL $56.67 $850 +----------------------++-------------------------------------------- + || $2850 + +# ** 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 Quantity Unit cost Cost +========================================++============================================= + 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 + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 +----------------------------------------++--------------------------------------------- + || $2850 + +# ** 3. A query can restrict the report; a single row shows no totals row. +$ hledger -f- holdings -e 2026-04-01 cur:AAPL +Holdings on 2026-03-31 + + || Date Age Quantity Avg cost Cost +======================++===================================== + assets:broker:stocks || 15 AAPL $56.67 $850 + +# ** 4. Tree mode is not yet supported. +$ hledger -f- holdings --tree +>2 +hledger: Error: holdings: --tree is not yet supported +>=1 + +# ** 5. 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) + +# ** 6. 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 Quantity Unit cost Cost +==========================================++=================================================== + 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 + +# ** 7. Same, aggregated: Avg cost also uses the display precision. +$ hledger -f- holdings -e 2026-04-01 +Holdings on 2026-03-31 + + || Date Age Quantity Avg cost Cost +=============++=================================================== + assets:fund || 16.993 FUND $1859.59 $31600.00 + assets:gold || 2026-03-01 30d 1.5 GOLD $2000.22 $3000.33 +-------------++--------------------------------------------------- + || $34600.33 + +# ** 8. 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 Quantity Unit cost Cost +==========================++============================================== + 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 From c7ec9fefc6a37f2511f52c165c17d87f1717a6b7 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 06:32:19 +0100 Subject: [PATCH 03/26] imp: holdings: add --round support Like print's --round, but defaulting to hard, since holdings is a display report; --round=none/soft/all select the other rounding strategies. An invalid value is reported before any output. Until now, --round was supported only by some print-like commands; I mistakenly thought it was needed here also. But it's working now and might be useful, so let's keep it. AI usage: Claude Fable 5, ~15k output tokens --- doc/SPEC-holdings.md | 3 ++- hledger/Hledger/Cli/Commands/Holdings.hs | 26 +++++++++++++++++------ hledger/Hledger/Cli/Commands/Holdings.md | 10 ++++++++- hledger/Hledger/Cli/Commands/Holdings.txt | 10 ++++++++- hledger/test/holdings.test | 12 +++++++++++ 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 04e4fca428f..d8125ac4b17 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -55,7 +55,8 @@ Notes: - Rows with no known market price show blank Price, Value and Gain, rather than pretending the gain is zero. - Amounts are displayed normalised to their commodity's display precision - (unlike lot names, which can show more precision). + by default (unlike lot names, which can show more precision); + `--round` can select another rounding strategy (default: hard). - The totals row shows only the commodity-independent columns: Cost, Value, Gain. - Possible future columns: portfolio weight %, realised gain, XIRR. diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index c098afaaa07..b008bd6232e 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -18,23 +18,33 @@ module Hledger.Cli.Commands.Holdings ( ) where import Data.Default (def) -import Data.List.Extra (nubSort) +import Data.List.Extra (intercalate, nubSort) import Data.Map.Strict qualified as M -import Data.Maybe (isJust) +import Data.Maybe (fromMaybe, isJust) import Data.Text qualified as T import Data.Text.Lazy.IO qualified as TL import Data.Time.Calendar (addDays, diffDays) -import System.Console.CmdArgs.Explicit (flagNone) +import System.Console.CmdArgs.Explicit (flagNone, flagReq) import Hledger import Hledger.Cli.CliOptions +import Hledger.Cli.Commands.Print (roundFromRawOpts) import Text.Tabular.AsciiWide -- | Command line options for this command. holdingsmode = hledgerCommandMode $(embedFileRelative "Hledger/Cli/Commands/Holdings.txt") (flattreeflags True ++ - [flagNone ["no-total","N"] (setboolopt "no-total") "omit the final total row"]) + [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" + ]]) cligeneralflagsgroups1 hiddenflags ([], Just $ argsFlag "[QUERY]") @@ -48,7 +58,7 @@ holdingsmode = hledgerCommandMode holdings :: CliOpts -> Journal -> IO () holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsReportOpts=ropts}} j = do if accountlistmode_ ropts == ALTree then error' "holdings: --tree is not yet supported" - else do + else rounding `seq` do -- validate the --round value before any output putStrLn $ "Holdings on " ++ T.unpack (showDate reportdate) putStrLn "" if null rows @@ -97,8 +107,10 @@ holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsR Just cb{cbCost = styleAmounts styles <$> cbCost cb} where parseAmt = either (const Nothing) Just . parseamount - -- Amounts are displayed normalised to their commodity's display precision. - styles = journalCommodityStylesWith HardRounding j + -- 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 -- The lots held at or under the given account, excluding empty ones. lotsUnder :: AccountName -> [(Amount, Maybe CostBasis)] diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 1343a1b9c06..ba718cca6c3 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -10,6 +10,13 @@ Flags: -t --tree list/tree mode: show accounts as a tree. Amounts include subaccount amounts. -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 ``` This command is a work in progress. @@ -21,7 +28,8 @@ with `--lots`. Columns show each holding's acquisition date and age the unit cost (or average cost, on rows aggregating multiple lots), and the total cost basis. Amounts are displayed with their commodity's display precision -(unlike lot names, which can show more precision). +(unlike lot names, which can show more precision); +`--round` can select another rounding strategy. Not yet implemented: market price, market value and unrealised gain columns; tree mode; output formats other than text. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index 1a86b085632..5668527c5b4 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -9,6 +9,13 @@ Flags: -t --tree list/tree mode: show accounts as a tree. Amounts include subaccount amounts. -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 This command is a work in progress. @@ -18,7 +25,8 @@ Columns show each holding's acquisition date and age (when the row's lots share a single date), the quantity held, the unit cost (or average cost, on rows aggregating multiple lots), and the total cost basis. Amounts are displayed with their commodity's display precision (unlike -lot names, which can show more precision). +lot names, which can show more precision); --round can select another +rounding strategy. Not yet implemented: market price, market value and unrealised gain columns; tree mode; output formats other than text. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index 6431c81e9d4..eedac7fb4c8 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -139,3 +139,15 @@ Holdings on 2026-01-31 assets:{2026-01-02, $12} || 2026-01-02 29d 5.000 AAAA $12 $60 --------------------------++---------------------------------------------- || $72 + +# ** 9. --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 Quantity Unit cost Cost +==========================++============================================== + 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 From 80206fae6908cfcb6e6d46d2af4bc01ac52e5499 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 06:50:53 +0100 Subject: [PATCH 04/26] feat: holdings: add Price, Value and Gain columns Holdings are valued at the report date using the standard price oracle: market prices come from P directives, and from transaction costs with --infer-market-prices. Each holding is valued in its cost commodity when possible, so Gain (absolute and percent) is meaningful; holdings with no market price show blank Price, Value and Gain, as does the totals row unless all rows are priced. The general -B/-V/-X/--value flags are ignored; holdings does its own valuation. AI usage: Claude Fable 5, ~30k output tokens --- doc/SPEC-holdings.md | 16 ++- hledger/Hledger/Cli/Commands/Holdings.hs | 70 ++++++++-- hledger/Hledger/Cli/Commands/Holdings.md | 13 +- hledger/Hledger/Cli/Commands/Holdings.txt | 18 ++- hledger/test/holdings.test | 158 ++++++++++++++++------ 5 files changed, 212 insertions(+), 63 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index d8125ac4b17..4a7d1352d15 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -64,9 +64,16 @@ Notes: ## Valuation - The report date is the report end date (today by default, or set with `-e`). -- Prices are market prices at the report date, from P directives and/or - inferred from transaction costs, using the standard `--value` infrastructure. - Value/Gain columns behave like `--value=end`. +- 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 Gain = Value - Cost is meaningful); otherwise in the default valuation + commodity. +- Gain (absolute and percent) is shown when the value and cost are in a + single common commodity. +- The general `-B`/`-V`/`-X`/`--value` flags are ignored (holdings does its + own valuation, and quantities stay quantities). Later, `--value=end,COMM` + could perhaps select the valuation commodity. ## Layout mockups @@ -163,6 +170,7 @@ Holdings on 2026-03-31 2. Real report in list mode: rows from the journal's lotful accounts, with Date, Age, Quantity, 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. +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. 5. Output formats, sorting, extra columns. diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index b008bd6232e..1c3fb67215c 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -3,9 +3,9 @@ The @holdings@ command shows a report of investment holdings (lot-tracked assets). Work in progress; see doc/SPEC-holdings.md. -Currently it shows the non-valuation columns (Date, Age, Quantity, Unit/Avg -cost, Cost) in list mode, with lot subaccounts aggregated by default or shown -as rows with --lots. +Currently it shows the Date, Age, Quantity, Unit/Avg cost, Cost, Price, +Value and Gain columns in list mode, with lot subaccounts aggregated by +default or shown as rows with --lots. -} @@ -20,11 +20,12 @@ module Hledger.Cli.Commands.Holdings ( import Data.Default (def) import Data.List.Extra (intercalate, nubSort) import Data.Map.Strict qualified as M -import Data.Maybe (fromMaybe, isJust) +import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text qualified as T import Data.Text.Lazy.IO qualified as TL import Data.Time.Calendar (addDays, diffDays) import System.Console.CmdArgs.Explicit (flagNone, flagReq) +import Text.Printf (printf) import Hledger import Hledger.Cli.CliOptions @@ -112,6 +113,38 @@ holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsR rounding = fromMaybe HardRounding $ roundFromRawOpts rawopts styles = journalCommodityStylesWith rounding j + priceoracle = journalPriceOracle (infer_prices_ ropts) j + + -- Value a row's quantities at the report date, in the cost commodity + -- when known: Just (price amounts, total value) if all of the row's + -- commodities have a market price, otherwise Nothing. + rowValuation :: PeriodicReportRow DisplayName MixedAmount -> Maybe ([Amount], MixedAmount) + rowValuation r = do + pvs <- mapM lookup1 qas + Just (map fst pvs, mixed (map snd pvs)) + where + -- strip costs so each commodity appears as one amount + qas = filter (not . amountLooksZero) $ amounts $ mixedAmountStripCosts $ prrTotal r + mto = listToMaybe [acommodity c | (_, mcb) <- lotsUnder (prrFullName r), Just c <- [cbCost =<< mcb]] + lookup1 qa = do + (pcomm, rate) <- priceoracle (reportdate, acommodity qa, mto) + let mkamt n = styleAmounts styles $ amountSetFullPrecisionUpTo Nothing + nullamt{acommodity=pcomm, aquantity=n} + Just (mkamt rate, mkamt (rate * aquantity qa)) + + -- Render a gain (and percent gain) from single-commodity value and + -- cost amounts, if their commodities match. + showgain :: [Amount] -> [Amount] -> 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 = printf " (%+.1f%%)" (realToFrac (100 * gain / aquantity c) :: Double) + | otherwise = "" + showgain _ _ = "" + -- The lots held at or under the given account, excluding empty ones. lotsUnder :: AccountName -> [(Amount, Maybe CostBasis)] lotsUnder acct = @@ -124,9 +157,12 @@ holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsR -- 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 quantities should stay quantities. mbr = multiBalanceReport rspec' j' where - rspec' = rspec{_rsReportOpts=ropts{balanceaccum_=Historical, interval_=NoInterval}} + rspec' = rspec{_rsReportOpts=ropts{balanceaccum_=Historical, interval_=NoInterval + ,conversionop_=Just NoConversionOp, value_=Nothing}} j' = if showlots then j else journalCollapseLotDetail j rows = filter keeprow $ prRows mbr where @@ -141,14 +177,21 @@ holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsR where addtotalrow totalrow tbl' = concatTables SingleLine tbl' $ Table (Group NoLine [Header ""]) (Header []) [totalrow] - colheadings = ["Date", "Age", "Quantity", if showlots then "Unit cost" else "Avg cost", "Cost"] + colheadings = ["Date", "Age", "Quantity", if showlots then "Unit cost" else "Avg cost", "Cost", "Price", "Value", "Gain"] renderacct r = T.replicate (prrIndent r * 2) " " <> prrDisplayName r rowLotCosts r = [multiplyAmount (aquantity a) c | (a, mcb) <- lotsUnder $ prrFullName r, Just c <- [cbCost =<< mcb]] - rowcells r = [datecell, agecell, qtycell, unitcostcell, costcell] + rowcells r = [datecell, agecell, qtycell, unitcostcell, costcell, pricecell, valuecell, gaincell] where + (pricecell, valuecell, gaincell) = case rowValuation r of + Nothing -> ("", "", "") + Just (prices, val) -> + ( T.intercalate "\n" $ map (T.pack . showAmountWith noCostFmt) prices + , T.pack $ showMixedAmountWith oneLineNoCostFmt val + , showgain (amounts val) (amounts $ mixed costs) + ) rowlots = lotsUnder $ prrFullName r dates = nubSort [cbDate =<< mcb | (_, mcb) <- rowlots] -- Date and Age are shown when the row's lots all have the same date. @@ -174,9 +217,18 @@ holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsR pdiv = case asprecision (astyle avg) of Precision n -> n; _ -> defaultMaxDisplayPrecision pstyle = case asprecision (astyle costa) of Precision n -> n; _ -> 2 - -- Grand totals row: just the Cost column for now. + -- Grand totals row: the Cost, Value and Gain columns. + -- Value and Gain are blank unless all rows have a market price. mtotalrow | no_total_ ropts || length rows < 2 = Nothing - | otherwise = Just ["", "", "", "", showamts $ concatMap rowLotCosts rows] + | otherwise = Just ["", "", "", "", showamts totcosts, "", totvaluecell, totgaincell] + where + totcosts = concatMap rowLotCosts rows + mrowvals = map rowValuation rows + (totvaluecell, totgaincell) = case sequence mrowvals of + Nothing -> ("", "") + Just rowvals -> ( T.pack $ showMixedAmountWith oneLineNoCostFmt totvalue + , showgain (amounts totvalue) (amounts $ mixed totcosts)) + where totvalue = mixed $ concatMap (amounts . snd) rowvals showamts = T.pack . showMixedAmountWith oneLineNoCostFmt . mixed diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index ba718cca6c3..340f8d2300c 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -26,10 +26,17 @@ as of the report end date: one row per account, or one row per lot with `--lots`. Columns show each holding's acquisition date and age (when the row's lots share a single date), the quantity held, the unit cost (or average cost, on rows aggregating multiple lots), -and the total cost basis. +the total cost basis, the current market price, the market value, +and the unrealised gain (absolute and percent). + +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. +The general `-B`/`-V`/`-X`/`--value` flags are ignored. Amounts are displayed with their commodity's display precision (unlike lot names, which can show more precision); `--round` can select another rounding strategy. -Not yet implemented: market price, market value and unrealised gain -columns; tree mode; output formats other than text. +Not yet implemented: tree mode; output formats other than text. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index 5668527c5b4..b72e0d87a9e 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -23,10 +23,16 @@ 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. Columns show each holding's acquisition date and age (when the row's lots share a single date), the quantity held, the unit cost (or average -cost, on rows aggregating multiple lots), and the total cost basis. -Amounts are displayed with their commodity's display precision (unlike -lot names, which can show more precision); --round can select another -rounding strategy. +cost, on rows aggregating multiple lots), the total cost basis, the +current market price, the market value, and the unrealised gain +(absolute and percent). -Not yet implemented: market price, market value and unrealised gain -columns; tree mode; output formats other than text. +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. The +general -B/-V/-X/--value flags are ignored. Amounts are displayed with +their commodity's display precision (unlike lot names, which can show +more precision); --round can select another rounding strategy. + +Not yet implemented: tree mode; output formats other than text. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index eedac7fb4c8..1f05b3ee191 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -1,6 +1,7 @@ # * 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: @@ -31,32 +32,32 @@ commodity MSFT ; lots: $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost -======================++============================================ - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 - assets:broker:stocks || 15 AAPL $56.67 $850 -----------------------++-------------------------------------------- - || $2850 + || Date Age Quantity Avg cost Cost Price Value Gain +======================++================================================================ + assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 + assets:broker:stocks || 15 AAPL $56.67 $850 +----------------------++---------------------------------------------------------------- + || $2850 # ** 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 Quantity Unit cost Cost -========================================++============================================= - 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 - assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 -----------------------------------------++--------------------------------------------- - || $2850 + || Date Age Quantity Unit cost Cost Price Value Gain +========================================++================================================================= + 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 + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 +----------------------------------------++----------------------------------------------------------------- + || $2850 # ** 3. A query can restrict the report; a single row shows no totals row. $ hledger -f- holdings -e 2026-04-01 cur:AAPL Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost -======================++===================================== - assets:broker:stocks || 15 AAPL $56.67 $850 + || Date Age Quantity Avg cost Cost Price Value Gain +======================++========================================================= + assets:broker:stocks || 15 AAPL $56.67 $850 # ** 4. Tree mode is not yet supported. $ hledger -f- holdings --tree @@ -98,24 +99,24 @@ commodity GOLD ; lots: $ hledger -f- holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Quantity Unit cost Cost -==========================================++=================================================== - 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 + || Date Age Quantity Unit cost Cost Price Value Gain +==========================================++======================================================================= + 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 # ** 7. Same, aggregated: Avg cost also uses the display precision. $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost -=============++=================================================== - assets:fund || 16.993 FUND $1859.59 $31600.00 - assets:gold || 2026-03-01 30d 1.5 GOLD $2000.22 $3000.33 --------------++--------------------------------------------------- - || $34600.33 + || Date Age Quantity Avg cost Cost Price Value Gain +=============++======================================================================= + assets:fund || 16.993 FUND $1859.59 $31600.00 + assets:gold || 2026-03-01 30d 1.5 GOLD $2000.22 $3000.33 +-------------++----------------------------------------------------------------------- + || $34600.33 # ** 8. Quantities are also normalised to the commodity's display precision # (here 3 decimals, inferred from the first purchase). @@ -133,21 +134,96 @@ commodity AAAA ; lots: $ hledger -f- holdings -e 2026-02-01 --lots Holdings on 2026-01-31 - || Date Age Quantity Unit cost Cost -==========================++============================================== - 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 + || Date Age Quantity Unit cost Cost Price Value Gain +==========================++================================================================== + 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 # ** 9. --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 Quantity Unit cost Cost -==========================++============================================== - 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 + || Date Age Quantity Unit cost Cost Price Value Gain +==========================++================================================================== + 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 + +# ** 10. 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 Quantity Avg cost Cost Price Value Gain +======================++========================================================================= + assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) + assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 $230 (+27.1%) +----------------------++------------------------------------------------------------------------- + || $2850 $3130 $280 (+9.8%) + +# ** 11. Same with --lots: per-lot values and gains. +$ hledger -f- holdings -e 2026-04-01 --lots +Holdings on 2026-03-31 + + || Date Age Quantity Unit cost Cost Price Value Gain +========================================++========================================================================== + assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) + assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 $110 (+44.0%) + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 $120 (+20.0%) +----------------------------------------++-------------------------------------------------------------------------- + || $2850 $3130 $280 (+9.8%) + +# ** 12. 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 Quantity Unit cost Cost Price Value Gain +==========================++========================================================================= + assets:{2026-01-01, $10} || 2026-01-01 30d 1.234 AAAA $10 $12 $12 $15 $2 (+20.0%) + assets:{2026-01-02, $12} || 2026-01-02 29d 5.000 AAAA $12 $60 $12 $60 $0 (+0.0%) +--------------------------++------------------------------------------------------------------------- + || $72 $75 $2 (+3.4%) From 2d496e4f327fd93081e03b326483401523ee028f Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 07:00:56 +0100 Subject: [PATCH 05/26] imp holdings: support -V, -X, --value These select the valuation commodity and/or valuation date for the Price and Value columns; the cost columns (Cost, Unit/Avg cost, and the cost side of Gain) are also converted to the valuation commodity at the valuation date, so percent gain is unaffected by currency conversion. Converted amounts are normalised to the commodity's display precision, like the rest of the report. Costs with no market price to the valuation commodity are left unconverted, leaving Gain blank. --value=then is rejected (holdings is a snapshot report), and -B/--cost has no effect. Also expose amountValueAtDate from Hledger.Data.Valuation. AI usage: Claude Fable 5, ~23k output tokens --- doc/SPEC-holdings.md | 11 +++-- hledger-lib/Hledger/Data/Valuation.hs | 1 + hledger/Hledger/Cli/Commands/Holdings.hs | 53 ++++++++++++++++++++--- hledger/Hledger/Cli/Commands/Holdings.md | 7 ++- hledger/Hledger/Cli/Commands/Holdings.txt | 13 ++++-- hledger/test/holdings.test | 37 ++++++++++++++++ 6 files changed, 107 insertions(+), 15 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 4a7d1352d15..0d156fcc7c8 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -71,9 +71,14 @@ Notes: commodity. - Gain (absolute and percent) is shown when the value and cost are in a single common commodity. -- The general `-B`/`-V`/`-X`/`--value` flags are ignored (holdings does its - own valuation, and quantities stay quantities). Later, `--value=end,COMM` - could perhaps select the valuation 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 Gain) 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 Gain blank). +- `--value=then` is not supported (holdings is a snapshot report). +- `-B/--cost` has no effect; quantities always stay quantities. ## Layout mockups 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/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 1c3fb67215c..77f4c60d045 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -17,6 +17,7 @@ module Hledger.Cli.Commands.Holdings ( ,holdings ) where +import Control.Applicative ((<|>)) import Data.Default (def) import Data.List.Extra (intercalate, nubSort) import Data.Map.Strict qualified as M @@ -59,6 +60,8 @@ holdingsmode = hledgerCommandMode holdings :: CliOpts -> Journal -> IO () holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsReportOpts=ropts}} j = do if accountlistmode_ ropts == ALTree then error' "holdings: --tree is not yet supported" + else if (case mvalue of Just (AtThen _) -> True; _ -> False) + then error' "holdings: --value=then is not supported" else rounding `seq` do -- validate the --round value before any output putStrLn $ "Holdings on " ++ T.unpack (showDate reportdate) putStrLn "" @@ -115,9 +118,22 @@ holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsR priceoracle = journalPriceOracle (infer_prices_ ropts) j - -- Value a row's quantities at the report date, in the cost commodity - -- when known: Just (price amounts, total value) if all of the row's - -- commodities have a market price, otherwise Nothing. + -- 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 quantities 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 @@ -125,13 +141,36 @@ holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsR where -- strip costs so each commodity appears as one amount qas = filter (not . amountLooksZero) $ amounts $ mixedAmountStripCosts $ prrTotal r - mto = listToMaybe [acommodity c | (_, mcb) <- lotsUnder (prrFullName r), Just c <- [cbCost =<< mcb]] + mto = case mvalue of + Nothing -> listToMaybe [acommodity c | (_, mcb) <- lotsUnder (prrFullName r), Just c <- [cbCost =<< mcb]] + Just _ -> mtargetcomm lookup1 qa = do - (pcomm, rate) <- priceoracle (reportdate, acommodity qa, mto) + (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 a row's cost amounts for display, so that the Cost, + -- Unit/Avg cost and Gain columns follow the valuation commodity when + -- -V/-X/--value is in effect: convert to the requested commodity, or + -- to the commodity the row's value came out in, at the valuation date. + -- Costs already in the target commodity, or with no target or no + -- market price, are left unchanged. + rowCostValuer :: PeriodicReportRow DisplayName MixedAmount -> Amount -> Amount + rowCostValuer r = case mvalue of + Nothing -> id + Just _ -> case mtargetcomm <|> mrowvaluecomm 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 + where + mrowvaluecomm = case rowValuation r of + Just (_, val) | [v] <- amounts val -> Just $ acommodity v + _ -> Nothing + -- Render a gain (and percent gain) from single-commodity value and -- cost amounts, if their commodities match. showgain :: [Amount] -> [Amount] -> T.Text @@ -180,7 +219,7 @@ holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsR colheadings = ["Date", "Age", "Quantity", if showlots then "Unit cost" else "Avg cost", "Cost", "Price", "Value", "Gain"] renderacct r = T.replicate (prrIndent r * 2) " " <> prrDisplayName r - rowLotCosts r = [multiplyAmount (aquantity a) c + rowLotCosts r = [rowCostValuer r $ multiplyAmount (aquantity a) c | (a, mcb) <- lotsUnder $ prrFullName r, Just c <- [cbCost =<< mcb]] rowcells r = [datecell, agecell, qtycell, unitcostcell, costcell, pricecell, valuecell, gaincell] @@ -202,7 +241,7 @@ holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsR costs = rowLotCosts r costcell = showamts costs unitcostcell = case (rowlots, costs) of - ([(_, mcb)], _) -> maybe "" (T.pack . showAmountWith noCostFmt) (cbCost =<< mcb) + ([(_, mcb)], _) -> maybe "" (T.pack . showAmountWith noCostFmt . rowCostValuer r) (cbCost =<< mcb) (_, _:_) | [totcost] <- amounts (mixed costs) , [totqty] <- amounts (mixed $ map fst rowlots) , not $ amountLooksZero totqty diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 340f8d2300c..e818fde5c9b 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -34,7 +34,12 @@ Market prices at the report date come from `--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. -The general `-B`/`-V`/`-X`/`--value` flags are ignored. + +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. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index b72e0d87a9e..b1099f6fa42 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -30,9 +30,14 @@ current market price, the market value, and the unrealised gain 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. The -general -B/-V/-X/--value flags are ignored. Amounts are displayed with -their commodity's display precision (unlike lot names, which can show -more precision); --round can select another rounding strategy. +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. Not yet implemented: tree mode; output formats other than text. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index 1f05b3ee191..8df2e7e98f3 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -227,3 +227,40 @@ Holdings on 2026-01-31 assets:{2026-01-02, $12} || 2026-01-02 29d 5.000 AAAA $12 $60 $12 $60 $0 (+0.0%) --------------------------++------------------------------------------------------------------------- || $72 $75 $2 (+3.4%) + + +# ** 13. -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 Quantity Avg cost Cost Price Value Gain +===============++================================================================================= + assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €656.86 €200.71 (+44.0%) + +# ** 14. -V values in the default valuation commodity. +$ hledger -f- holdings -e 2026-04-01 -V +Holdings on 2026-03-31 + + || Date Age Quantity Avg cost Cost Price Value Gain +===============++======================================================================== + assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 $220 (+44.0%) + +# ** 15. --value=then is not supported. +$ hledger -f- holdings --value=then +>2 +hledger: Error: holdings: --value=then is not supported +>=1 From 6c0058564a3774bb79ae9a75a8b090c09d4818b8 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 07:04:12 +0100 Subject: [PATCH 06/26] imp: holdings: use a pager, and support -o, like other reports AI usage: Claude Fable 5, ~6k output tokens --- hledger/Hledger/Cli/Commands/Holdings.hs | 30 ++++++++++++----------- hledger/Hledger/Cli/Commands/Holdings.md | 2 ++ hledger/Hledger/Cli/Commands/Holdings.txt | 2 ++ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 77f4c60d045..d0ee6f50412 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -23,7 +23,7 @@ import Data.List.Extra (intercalate, nubSort) import Data.Map.Strict qualified as M import Data.Maybe (fromMaybe, isJust, listToMaybe) import Data.Text qualified as T -import Data.Text.Lazy.IO qualified as TL +import Data.Text.Lazy qualified as TL import Data.Time.Calendar (addDays, diffDays) import System.Console.CmdArgs.Explicit (flagNone, flagReq) import Text.Printf (printf) @@ -31,6 +31,7 @@ import Text.Printf (printf) import Hledger import Hledger.Cli.CliOptions import Hledger.Cli.Commands.Print (roundFromRawOpts) +import Hledger.Cli.Utils (writeOutputLazyText) import Text.Tabular.AsciiWide -- | Command line options for this command. @@ -46,7 +47,8 @@ holdingsmode = hledgerCommandMode ," to match precision" ,"hard - round amounts to precision (default)" ,"all - also round cost amounts to precision" - ]]) + ] + ,outputFileFlag]) cligeneralflagsgroups1 hiddenflags ([], Just $ argsFlag "[QUERY]") @@ -58,21 +60,21 @@ holdingsmode = hledgerCommandMode -- synthetic postings) uncollapsed, regardless of --lots -- (see maybeCollapseLotDetail); it aggregates lots itself. holdings :: CliOpts -> Journal -> IO () -holdings CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsReportOpts=ropts}} j = do +holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _rsReportOpts=ropts}} j = do if accountlistmode_ ropts == ALTree then error' "holdings: --tree is not yet supported" else if (case mvalue of Just (AtThen _) -> True; _ -> False) then error' "holdings: --value=then is not supported" - else rounding `seq` do -- validate the --round value before any output - putStrLn $ "Holdings on " ++ T.unpack (showDate reportdate) - putStrLn "" - if null rows - then putStrLn "(no holdings)" - else TL.putStr $ renderTable - def{tableBorders=False} - (textCell TopLeft) - (textCell TopRight) - (textCell TopRight) - tbl + else rounding `seq` -- validate the --round value before any output + writeOutputLazyText opts $ + "Holdings on " <> TL.fromStrict (showDate reportdate) <> "\n\n" <> + if null rows + then "(no holdings)\n" + else renderTable + def{tableBorders=False} + (textCell TopLeft) + (textCell TopRight) + (textCell TopRight) + tbl where showlots = boolopt "lots" rawopts diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index e818fde5c9b..42048fb7df3 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -17,6 +17,8 @@ Flags: to match precision hard - round amounts to precision (default) all - also round cost amounts to precision + -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. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index b1099f6fa42..30283bdc652 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -16,6 +16,8 @@ Flags: to match precision hard - round amounts to precision (default) all - also round cost amounts to precision + -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. From 4223df5c95ae8975ec4b6cae8469a73b29d4e054 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 07:12:51 +0100 Subject: [PATCH 07/26] imp: holdings: support --tree, --depth and --no-elide In tree mode, parent account rows aggregate the lots beneath them, showing only lot-tracked commodities (not eg cash), with one line per commodity in the Quantity and Price columns; --no-elide shows boring parent accounts (and single lots) as separate rows. --depth clips and aggregates the displayed rows as usual, while the lots beneath still count. Totals are summed over the topmost displayed rows only, avoiding double counting, and the totals row is omitted when there is a single top-level row. Also, in list mode, rows whose lots all appear in a deeper displayed row are no longer shown (previously a base account posted to directly could duplicate its lot subaccount rows). AI usage: Claude Fable 5, ~21k output tokens --- doc/SPEC-holdings.md | 4 +- hledger/Hledger/Cli/Commands/Holdings.hs | 53 ++++++++++++----- hledger/Hledger/Cli/Commands/Holdings.md | 8 ++- hledger/Hledger/Cli/Commands/Holdings.txt | 13 +++-- hledger/test/holdings.test | 69 +++++++++++++++++------ 5 files changed, 108 insertions(+), 39 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 0d156fcc7c8..831e78fafb4 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -177,5 +177,7 @@ Holdings on 2026-03-31 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. +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. Output formats, sorting, extra columns. diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index d0ee6f50412..3b1dc3635dd 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -38,7 +38,8 @@ import Text.Tabular.AsciiWide holdingsmode = hledgerCommandMode $(embedFileRelative "Hledger/Cli/Commands/Holdings.txt") (flattreeflags True ++ - [flagNone ["no-total","N"] (setboolopt "no-total") "omit the final total row" + [flagNone ["no-elide"] (setboolopt "no-elide") "in tree mode, don't squash boring parent accounts" + ,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 ?" @@ -61,8 +62,7 @@ holdingsmode = hledgerCommandMode -- (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 accountlistmode_ ropts == ALTree then error' "holdings: --tree is not yet supported" - else if (case mvalue of Just (AtThen _) -> True; _ -> False) + 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 $ @@ -77,6 +77,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, tbl where 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. @@ -97,7 +98,10 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, , a <- amountsRaw $ pamount p ] where - endq = And [filterQuery (not . queryIsDateOrDate2) q + -- the query without its date terms (holdings are cumulative to the + -- end date, added below) 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)] -- A lot subaccount's cost basis, parsed from its name @@ -141,8 +145,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, pvs <- mapM lookup1 qas Just (map fst pvs, mixed (map snd pvs)) where - -- strip costs so each commodity appears as one amount - qas = filter (not . amountLooksZero) $ amounts $ mixedAmountStripCosts $ prrTotal r + qas = rowQtyAmounts r mto = case mvalue of Nothing -> listToMaybe [acommodity c | (_, mcb) <- lotsUnder (prrFullName r), Just c <- [cbCost =<< mcb]] Just _ -> mtargetcomm @@ -186,6 +189,16 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, | otherwise = "" showgain _ _ = "" + -- A row's quantities 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. + rowQtyAmounts :: PeriodicReportRow DisplayName MixedAmount -> [Amount] + rowQtyAmounts 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 = @@ -205,11 +218,20 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, rspec' = rspec{_rsReportOpts=ropts{balanceaccum_=Historical, interval_=NoInterval ,conversionop_=Just NoConversionOp, value_=Nothing}} j' = if showlots then j else journalCollapseLotDetail j - rows = filter keeprow $ prRows mbr + -- 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 - keeprow r - | showlots = isJust $ lotSubaccountName $ prrFullName r - | otherwise = not $ null $ lotsUnder $ prrFullName r + 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 ] tbl = maybe id addtotalrow mtotalrow $ Table (Group NoLine $ map (Header . renderacct) rows) @@ -239,7 +261,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, (datecell, agecell) = case dates of [Just dt] -> (showDate dt, T.pack (show $ diffDays reportdate dt) <> "d") _ -> ("", "") - qtycell = T.pack $ showMixedAmountWith oneLineNoCostFmt $ styleAmounts styles $ prrTotal r + qtycell = T.intercalate "\n" $ map (T.pack . showAmountWith noCostFmt . styleAmounts styles) $ rowQtyAmounts r costs = rowLotCosts r costcell = showamts costs unitcostcell = case (rowlots, costs) of @@ -258,14 +280,15 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, pdiv = case asprecision (astyle avg) of Precision n -> n; _ -> defaultMaxDisplayPrecision pstyle = case asprecision (astyle costa) of Precision n -> n; _ -> 2 - -- Grand totals row: the Cost, Value and Gain columns. + -- Grand totals row: the Cost, Value and Gain columns, summed over the + -- topmost displayed rows (which include everything below them). -- Value and Gain are blank unless all rows have a market price. mtotalrow - | no_total_ ropts || length rows < 2 = Nothing + | no_total_ ropts || length toprows < 2 = Nothing | otherwise = Just ["", "", "", "", showamts totcosts, "", totvaluecell, totgaincell] where - totcosts = concatMap rowLotCosts rows - mrowvals = map rowValuation rows + totcosts = concatMap rowLotCosts toprows + mrowvals = map rowValuation toprows (totvaluecell, totgaincell) = case sequence mrowvals of Nothing -> ("", "") Just rowvals -> ( T.pack $ showMixedAmountWith oneLineNoCostFmt totvalue diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 42048fb7df3..4cf6e15c728 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -9,6 +9,7 @@ Flags: 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 -N --no-total omit the final total row --round=TYPE how much rounding or padding should be done when displaying amounts ? @@ -25,7 +26,10 @@ 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`. Columns show each holding's acquisition date and age +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. +Columns show each holding's acquisition date and age (when the row's lots share a single date), the quantity held, the unit cost (or average cost, on rows aggregating multiple lots), the total cost basis, the current market price, the market value, @@ -46,4 +50,4 @@ Amounts are displayed with their commodity's display precision (unlike lot names, which can show more precision); `--round` can select another rounding strategy. -Not yet implemented: tree mode; output formats other than text. +Not yet implemented: output formats other than text. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index 30283bdc652..39a23c0eb55 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -8,6 +8,7 @@ Flags: 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 -N --no-total omit the final total row --round=TYPE how much rounding or padding should be done when displaying amounts ? @@ -23,10 +24,12 @@ 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. -Columns show each holding's acquisition date and age (when the row's -lots share a single date), the quantity held, the unit cost (or average -cost, on rows aggregating multiple lots), the total cost basis, the -current market price, the market value, and the unrealised gain +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. Columns show each holding's acquisition date and age (when the +row's lots share a single date), the quantity held, the unit cost (or +average cost, on rows aggregating multiple lots), the total cost basis, +the current market price, the market value, and the unrealised gain (absolute and percent). Market prices at the report date come from P directives, and from @@ -42,4 +45,4 @@ Amounts are displayed with their commodity's display precision (unlike lot names, which can show more precision); --round can select another rounding strategy. -Not yet implemented: tree mode; output formats other than text. +Not yet implemented: output formats other than text. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index 8df2e7e98f3..aef2925848f 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -59,13 +59,50 @@ Holdings on 2026-03-31 ======================++========================================================= assets:broker:stocks || 15 AAPL $56.67 $850 -# ** 4. Tree mode is not yet supported. -$ hledger -f- holdings --tree ->2 -hledger: Error: holdings: --tree is not yet supported ->=1 +# ** 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 Quantity Unit cost Cost Price Value Gain +============================++================================================================= + assets:broker || 15 AAPL $2850 + || 5 MSFT + funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 + stocks || 15 AAPL $56.67 $850 + {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 + {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 + +# ** 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 Quantity Unit cost Cost Price Value Gain +==========================++================================================================= + assets || 15 AAPL $2850 + || 5 MSFT + broker || 15 AAPL $2850 + || 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 + {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 + {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 + +# ** 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 Quantity Avg cost Cost Price Value Gain +===============++========================================================== + assets:broker || 15 AAPL $2850 + || 5 MSFT -# ** 5. A journal with no lots reports no holdings. +# ** 7. A journal with no lots reports no holdings. < 2026-01-01 opening assets:cash $100 @@ -76,7 +113,7 @@ Holdings on 2026-01-01 (no holdings) -# ** 6. Amounts are shown with their commodity's display precision. +# ** 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). @@ -107,7 +144,7 @@ Holdings on 2026-03-31 ------------------------------------------++----------------------------------------------------------------------- || $34600.33 -# ** 7. Same, aggregated: Avg cost also uses the display precision. +# ** 9. Same, aggregated: Avg cost also uses the display precision. $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 @@ -118,7 +155,7 @@ Holdings on 2026-03-31 -------------++----------------------------------------------------------------------- || $34600.33 -# ** 8. Quantities are also normalised to the commodity's display precision +# ** 10. Quantities are also normalised to the commodity's display precision # (here 3 decimals, inferred from the first purchase). < commodity AAAA ; lots: @@ -141,7 +178,7 @@ Holdings on 2026-01-31 --------------------------++------------------------------------------------------------------ || $72 -# ** 9. --round can select another rounding strategy, eg none shows +# ** 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 @@ -153,7 +190,7 @@ Holdings on 2026-01-31 --------------------------++------------------------------------------------------------------ || $72 -# ** 10. With market prices (P directives), the Price, Value and Gain +# ** 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). < @@ -193,7 +230,7 @@ Holdings on 2026-03-31 ----------------------++------------------------------------------------------------------------- || $2850 $3130 $280 (+9.8%) -# ** 11. Same with --lots: per-lot values and gains. +# ** 13. Same with --lots: per-lot values and gains. $ hledger -f- holdings -e 2026-04-01 --lots Holdings on 2026-03-31 @@ -205,7 +242,7 @@ Holdings on 2026-03-31 ----------------------------------------++-------------------------------------------------------------------------- || $2850 $3130 $280 (+9.8%) -# ** 12. With --infer-market-prices, transaction costs also provide +# ** 14. With --infer-market-prices, transaction costs also provide # market prices, as usual. < commodity AAAA ; lots: @@ -229,7 +266,7 @@ Holdings on 2026-01-31 || $72 $75 $2 (+3.4%) -# ** 13. -X/--value=end,COMM value holdings in the given commodity: +# ** 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. @@ -251,7 +288,7 @@ Holdings on 2026-03-31 ===============++================================================================================= assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €656.86 €200.71 (+44.0%) -# ** 14. -V values in the default valuation commodity. +# ** 16. -V values in the default valuation commodity. $ hledger -f- holdings -e 2026-04-01 -V Holdings on 2026-03-31 @@ -259,7 +296,7 @@ Holdings on 2026-03-31 ===============++======================================================================== assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 $220 (+44.0%) -# ** 15. --value=then is not supported. +# ** 17. --value=then is not supported. $ hledger -f- holdings --value=then >2 hledger: Error: holdings: --value=then is not supported From d5fbc7400796c4eaa089ade39520fad099b219c9 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 07:28:05 +0100 Subject: [PATCH 08/26] imp: holdings: add -S/--sort-amount Sorts rows by market value (or by cost, when unpriced), largest first. In tree mode each level is sorted, keeping subtrees together. AI usage: Claude Fable 5, ~12k output tokens --- doc/SPEC-holdings.md | 11 ++++++++- hledger/Hledger/Cli/Commands/Holdings.hs | 30 +++++++++++++++++++---- hledger/Hledger/Cli/Commands/Holdings.md | 4 +++ hledger/Hledger/Cli/Commands/Holdings.txt | 13 ++++++---- hledger/test/holdings.test | 27 ++++++++++++++++++++ 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 831e78fafb4..d1940a0fb82 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -180,4 +180,13 @@ Holdings on 2026-03-31 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. Output formats, sorting, extra columns. +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 row per commodity, Gain and Gain% as separate + columns. (HTML/FODS/JSON possibly later, via the standard output-format + machinery.) +7. Extra columns, on demand: portfolio weight %, humanised Age (eg 2m14d) + or a long/short-term indicator, realised gain, XIRR. +8. Docs integration: mention holdings in the manual's lot reporting + sections; maybe a demo. diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 3b1dc3635dd..7fbc3ee7fb3 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -19,9 +19,10 @@ module Hledger.Cli.Commands.Holdings ( import Control.Applicative ((<|>)) import Data.Default (def) -import Data.List.Extra (intercalate, nubSort) +import Data.List.Extra (intercalate, nubSort, sortOn) import Data.Map.Strict qualified as M -import Data.Maybe (fromMaybe, isJust, listToMaybe) +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 (addDays, diffDays) @@ -39,6 +40,7 @@ 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" + ,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" @@ -216,7 +218,8 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, mbr = multiBalanceReport rspec' j' where rspec' = rspec{_rsReportOpts=ropts{balanceaccum_=Historical, interval_=NoInterval - ,conversionop_=Just NoConversionOp, value_=Nothing}} + ,conversionop_=Just NoConversionOp, value_=Nothing + ,sort_amount_=False}} -- -S sorts by value/cost below, not by quantities 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 @@ -233,10 +236,27 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, 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 mtotalrow $ Table - (Group NoLine $ map (Header . renderacct) rows) + (Group NoLine $ map (Header . renderacct) sortedrows) (Group NoLine $ map Header colheadings) - (map rowcells rows) + (map rowcells sortedrows) where addtotalrow totalrow tbl' = concatTables SingleLine tbl' $ Table (Group NoLine [Header ""]) (Header []) [totalrow] diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 4cf6e15c728..b5b3fc155f5 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -10,6 +10,8 @@ Flags: -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 + -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 ? @@ -29,6 +31,8 @@ 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. Columns show each holding's acquisition date and age (when the row's lots share a single date), the quantity held, the unit cost (or average cost, on rows aggregating multiple lots), diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index 39a23c0eb55..e908153e96e 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -9,6 +9,8 @@ Flags: -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 + -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 ? @@ -26,11 +28,12 @@ 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. Columns show each holding's acquisition date and age (when the -row's lots share a single date), the quantity held, the unit cost (or -average cost, on rows aggregating multiple lots), the total cost basis, -the current market price, the market value, and the unrealised gain -(absolute and percent). +as usual. With -S/--sort-amount, rows are sorted by market value (or by +cost, when unpriced), largest first. Columns show each holding's +acquisition date and age (when the row's lots share a single date), the +quantity held, the unit cost (or average cost, on rows aggregating +multiple lots), the total cost basis, the current market price, the +market value, and the unrealised gain (absolute and percent). Market prices at the report date come from P directives, and from transaction costs with --infer-market-prices, as usual; holdings are diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index aef2925848f..f1727532ba5 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -301,3 +301,30 @@ $ 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 Quantity Avg cost Cost Price Value Gain +===============++========================================================================= + assets:msoft || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) + assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 $220 (+44.0%) +---------------++------------------------------------------------------------------------- + || $2500 $2770 $270 (+10.8%) From 61fc3937b7ce4dcfab0b235ff77b4a667bf60fbe Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 07:41:28 +0100 Subject: [PATCH 09/26] imp: holdings: add csv and tsv output One record per displayed row and commodity, with machine-friendlier fields: full account names, age in days, bare quantity and gain percent numbers, and gain and gain percent as separate fields. Amounts have no digit group marks; as in other commands' CSV output, the decimal mark follows the commodity's display style. No totals records. (In tree mode, parent account records repeat the data of their subaccounts.) AI usage: Claude Fable 5, ~21k output tokens --- doc/SPEC-holdings.md | 6 +- hledger/Hledger/Cli/Commands/Holdings.hs | 80 ++++++++++++++++++++--- hledger/Hledger/Cli/Commands/Holdings.md | 13 +++- hledger/Hledger/Cli/Commands/Holdings.txt | 12 +++- hledger/test/holdings.test | 24 +++++++ 5 files changed, 121 insertions(+), 14 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index d1940a0fb82..ce4d62caf2e 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -183,8 +183,10 @@ Holdings on 2026-03-31 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 row per commodity, Gain and Gain% as separate - columns. (HTML/FODS/JSON possibly later, via the standard output-format +6. CSV/TSV output: one record per row and commodity, with full account + names, age in days, bare quantity and gain percent numbers, Gain and + Gain% as separate fields, and no totals records. (done) + (HTML/FODS/JSON possibly later, via the standard output-format machinery.) 7. Extra columns, on demand: portfolio weight %, humanised Age (eg 2m14d) or a long/short-term indicator, realised gain, XIRR. diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 7fbc3ee7fb3..980e18d4a38 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -32,7 +32,8 @@ import Text.Printf (printf) import Hledger import Hledger.Cli.CliOptions import Hledger.Cli.Commands.Print (roundFromRawOpts) -import Hledger.Cli.Utils (writeOutputLazyText) +import Hledger.Cli.Utils (unsupportedOutputFormatError, writeOutputLazyText) +import Hledger.Write.Csv (CSV, printCSV, printTSV) import Text.Tabular.AsciiWide -- | Command line options for this command. @@ -51,6 +52,7 @@ holdingsmode = hledgerCommandMode ,"hard - round amounts to precision (default)" ,"all - also round cost amounts to precision" ] + ,outputFormatFlag ["txt","csv","tsv"] ,outputFileFlag]) cligeneralflagsgroups1 hiddenflags @@ -67,7 +69,13 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, 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 $ + writeOutputLazyText opts $ case outputFormatFromOpts opts of + "txt" -> txtoutput + "csv" -> printCSV csvoutput + "tsv" -> printTSV csvoutput + fmt -> error' $ unsupportedOutputFormatError fmt + where + txtoutput = "Holdings on " <> TL.fromStrict (showDate reportdate) <> "\n\n" <> if null rows then "(no holdings)\n" @@ -77,7 +85,6 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, (textCell TopRight) (textCell TopRight) tbl - where showlots = boolopt "lots" rawopts tree = accountlistmode_ ropts == ALTree @@ -291,14 +298,58 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, , not $ amountLooksZero totqty -> T.pack $ showAmountWith noCostFmt $ avgcost totqty totcost _ -> "" - -- An average cost: total cost / total quantity, 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 + + -- CSV/TSV output: one record per displayed row and commodity, with + -- machine-friendlier fields: full account names, age in days, bare + -- quantity and gain percent numbers, and gain and gain percent as + -- separate fields. No totals records. + csvoutput :: CSV + csvoutput = + ["account","commodity","date","age","quantity","unitcost","cost","price","value","gain","gainpct"] + : concatMap rowrecords sortedrows + where + rowrecords r = map rec $ rowQtyAmounts r 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 + acct = prrFullName r + rec qa = [acct, c, dtstr, agestr, qtystr, ucoststr, coststr, pricestr, valstr, gainstr, pctstr] + 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] + (dtstr, agestr) = case dates of + [Just dt] -> (showDate dt, T.pack $ show $ diffDays reportdate dt) + _ -> ("", "") + qtystr = T.pack $ showAmountWith machineFmt{displayCommodity=False} $ styleAmounts styles qa + ccosts = [rowCostValuer r $ multiplyAmount (aquantity a) cb | (a, mcb) <- clots, Just cb <- [cbCost =<< mcb]] + coststr = showamts' ccosts + ucoststr = case (clots, ccosts) of + ([(_, mcb)], _) -> maybe "" (showamt . rowCostValuer r) (cbCost =<< mcb) + (_, _:_) | [totcost] <- amounts (mixed ccosts) + , not $ amountLooksZero qa + -> showamt $ avgcost qa totcost + _ -> "" + mto = case mvalue of + Nothing -> listToMaybe [acommodity cb | (_, mcb) <- clots, Just cb <- [cbCost =<< mcb]] + Just _ -> mtargetcomm + (pricestr, valstr, gainstr, pctstr) = + case priceoracle (valuationdate, c, mto) of + Nothing -> ("", "", "", "") + Just (pcomm, rate) -> (showamt price, showamt val, gainstr', pctstr') + where + mkamt n = styleAmounts styles $ amountSetFullPrecisionUpTo Nothing + nullamt{acommodity=pcomm, aquantity=n} + price = mkamt rate + val = mkamt (rate * aquantity qa) + (gainstr', pctstr') = case amounts (mixed ccosts) of + [costamt] | acommodity costamt == pcomm -> + ( showamt $ mkamt gainq + , if aquantity costamt /= 0 + then T.pack $ printf "%.1f" (realToFrac (100 * gainq / aquantity costamt) :: Double) + else "" ) + where gainq = aquantity val - aquantity costamt + _ -> ("", "") -- Grand totals row: the Cost, Value and Gain columns, summed over the -- topmost displayed rows (which include everything below them). @@ -316,3 +367,12 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, where totvalue = mixed $ concatMap (amounts . snd) rowvals showamts = T.pack . showMixedAmountWith oneLineNoCostFmt . mixed + + -- An average cost: total cost / total quantity, 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 index b5b3fc155f5..3cfbd548e44 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -20,6 +20,8 @@ Flags: 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. -o --output-file=FILE write output to FILE. A file extension matching one of the above formats selects that format. ``` @@ -54,4 +56,13 @@ Amounts are displayed with their commodity's display precision (unlike lot names, which can show more precision); `--round` can select another rounding strategy. -Not yet implemented: output formats other than text. +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 quantity 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.) + +Not yet implemented: html, fods or json output. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index e908153e96e..4e189490227 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -19,6 +19,8 @@ Flags: 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. -o --output-file=FILE write output to FILE. A file extension matching one of the above formats selects that format. @@ -48,4 +50,12 @@ Amounts are displayed with their commodity's display precision (unlike lot names, which can show more precision); --round can select another rounding strategy. -Not yet implemented: output formats other than text. +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 +quantity 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.) + +Not yet implemented: html, fods or json output. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index f1727532ba5..4fed1a1f072 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -328,3 +328,27 @@ Holdings on 2026-03-31 assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 $220 (+44.0%) ---------------++------------------------------------------------------------------------- || $2500 $2770 $270 (+10.8%) + +# ** 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","quantity","unitcost","cost","price","value","gain","gainpct" +"assets:apples","AAPL","2026-01-15","75","10","$50","$500","$72","$720","$220","44.0" +"assets:msoft","MSFT","2026-02-15","44","5","$400","$2000","$410","$2050","$50","2.5" + +# ** 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","quantity","unitcost","cost","price","value","gain","gainpct" +"assets:stocks","AAAA","2026-01-15","75","1000,5","2,25 €","2251,12 €","3,10 €","3101,55 €","850,42 €","37.8" From e2996d497a958889ef695683719488f1bfd46ebe Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 07:53:10 +0100 Subject: [PATCH 10/26] ;doc: lots: mention the holdings command in lot reporting docs Add a holdings example to the First lots example and Lot reporting example sections, and a cross-reference in Lot subaccounts. AI usage: Claude Fable 5, ~10k output tokens --- doc/SPEC-holdings.md | 3 ++- hledger/hledger.m4.md | 31 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index ce4d62caf2e..036716817a7 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -191,4 +191,5 @@ Holdings on 2026-03-31 7. Extra columns, on demand: portfolio weight %, humanised Age (eg 2m14d) or a long/short-term indicator, realised gain, XIRR. 8. Docs integration: mention holdings in the manual's lot reporting - sections; maybe a demo. + sections (First lots example, Lot subaccounts, Lot reporting example). + (done) diff --git a/hledger/hledger.m4.md b/hledger/hledger.m4.md index c6bb5e5e564..439c51f02f0 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -7138,6 +7138,19 @@ $ 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 - +quantities, cost basis, current value and unrealised gain: + +```cli +$ hledger holdings -e 2026-04-01 +Holdings on 2026-03-31 + + || Date Age Quantity Avg cost Cost Price Value Gain +===============++======================================================================== + assets:stocks || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 $110 (+44.0%) +``` + Or use `print -a` to see maximum detail on how hledger has analysed your entries - lot subaccounts, inferred annotations, and posting types: @@ -7199,7 +7212,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 @@ -7641,6 +7655,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 Quantity Unit cost Cost Price Value Gain +=================================++================================================================ + assets:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 + assets:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 +---------------------------------++---------------------------------------------------------------- + || $850 +``` + 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). From 05359141a478e03ed642c07336cdb4e63a3742a4 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 08:16:00 +0100 Subject: [PATCH 11/26] imp: holdings: add html output An HTML table like the text table, but with single-line cells, rendered with the standard spreadsheet-cell machinery. Amount cells are right-aligned (headings are not). For styling, each cell has a css class naming its column (account, date, age, quantity, unitcost, cost, price, value, gain; 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. Tree-mode account names are indented with no-break spaces. AI usage: Claude Fable 5, ~43k output tokens --- doc/SPEC-holdings.md | 7 +- hledger/Hledger/Cli/Commands/Holdings.hs | 104 ++++++++++++++++------ hledger/Hledger/Cli/Commands/Holdings.md | 11 ++- hledger/Hledger/Cli/Commands/Holdings.txt | 11 ++- hledger/test/holdings.test | 5 ++ 5 files changed, 107 insertions(+), 31 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 036716817a7..e011dc86416 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -186,8 +186,11 @@ Holdings on 2026-03-31 6. CSV/TSV output: one record per row and commodity, with full account names, age in days, bare quantity and gain percent numbers, Gain and Gain% as separate fields, and no totals records. (done) - (HTML/FODS/JSON possibly later, via the standard output-format - machinery.) + 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/JSON possibly later.) 7. Extra columns, on demand: portfolio weight %, humanised Age (eg 2m14d) or a long/short-term indicator, realised gain, XIRR. 8. Docs integration: mention holdings in the manual's lot reporting diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 980e18d4a38..dcbe72b2df2 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -19,7 +19,7 @@ module Hledger.Cli.Commands.Holdings ( import Control.Applicative ((<|>)) import Data.Default (def) -import Data.List.Extra (intercalate, nubSort, sortOn) +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(..)) @@ -31,9 +31,14 @@ 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.Spreadsheet (addHeaderBorders, headerCell) +import Hledger.Write.Spreadsheet qualified as Ods +import Lucid qualified as L import Text.Tabular.AsciiWide -- | Command line options for this command. @@ -52,7 +57,7 @@ holdingsmode = hledgerCommandMode ,"hard - round amounts to precision (default)" ,"all - also round cost amounts to precision" ] - ,outputFormatFlag ["txt","csv","tsv"] + ,outputFormatFlag ["txt","csv","tsv","html"] ,outputFileFlag]) cligeneralflagsgroups1 hiddenflags @@ -70,10 +75,11 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, 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 - fmt -> error' $ unsupportedOutputFormatError fmt + "txt" -> txtoutput + "csv" -> printCSV csvoutput + "tsv" -> printTSV csvoutput + "html" -> (<>"\n") $ htmlAsLazyText $ styledTableHtml htmltable + fmt -> error' $ unsupportedOutputFormatError fmt where txtoutput = "Holdings on " <> TL.fromStrict (showDate reportdate) <> "\n\n" <> @@ -260,7 +266,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, Nothing -> sumq $ mixed $ rowLotCosts r where sumq = sum . map aquantity . amounts - tbl = maybe id addtotalrow mtotalrow $ Table + tbl = maybe id addtotalrow (map (T.intercalate ", ") <$> mtotalrowparts) $ Table (Group NoLine $ map (Header . renderacct) sortedrows) (Group NoLine $ map Header colheadings) (map rowcells sortedrows) @@ -273,13 +279,21 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, rowLotCosts r = [rowCostValuer r $ multiplyAmount (aquantity a) c | (a, mcb) <- lotsUnder $ prrFullName r, Just c <- [cbCost =<< mcb]] - rowcells r = [datecell, agecell, qtycell, unitcostcell, costcell, pricecell, valuecell, gaincell] + -- The text table's cells: each cell's parts joined, + -- multi-line in Quantity 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], qtyparts, [unitcostcell], costparts, priceparts, valueparts, [gaincell]] where - (pricecell, valuecell, gaincell) = case rowValuation r of - Nothing -> ("", "", "") + (priceparts, valueparts, gaincell) = case rowValuation r of + Nothing -> ([], [], "") Just (prices, val) -> - ( T.intercalate "\n" $ map (T.pack . showAmountWith noCostFmt) prices - , T.pack $ showMixedAmountWith oneLineNoCostFmt val + ( map showamt prices + , map showamt $ amounts val , showgain (amounts val) (amounts $ mixed costs) ) rowlots = lotsUnder $ prrFullName r @@ -288,16 +302,54 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, (datecell, agecell) = case dates of [Just dt] -> (showDate dt, T.pack (show $ diffDays reportdate dt) <> "d") _ -> ("", "") - qtycell = T.intercalate "\n" $ map (T.pack . showAmountWith noCostFmt . styleAmounts styles) $ rowQtyAmounts r + qtyparts = map (showamt . styleAmounts styles) $ rowQtyAmounts r costs = rowLotCosts r - costcell = showamts costs + costparts = map showamt $ amounts $ mixed costs unitcostcell = case (rowlots, costs) of - ([(_, mcb)], _) -> maybe "" (T.pack . showAmountWith noCostFmt . rowCostValuer r) (cbCost =<< mcb) + ([(_, mcb)], _) -> maybe "" (showamt . rowCostValuer r) (cbCost =<< mcb) (_, _:_) | [totcost] <- amounts (mixed costs) , [totqty] <- amounts (mixed $ map fst rowlots) , not $ amountLooksZero totqty - -> T.pack $ showAmountWith noCostFmt $ avgcost totqty totcost + -> showamt $ avgcost totqty totcost _ -> "" + showamt = T.pack . showAmountWith noCostFmt + + -- The html output's table: like the text table, but with single-line + -- cells, an Account column heading, and a Total: row heading. + htmltable :: [[Ods.Cell Ods.NumLines Html]] + htmltable = + addHeaderBorders (zipWith hcell colclasses ("Account" : colheadings)) + : [ zipWith3 bodycell [0..] colclasses + (toHtml (acctcell r) : zipWith partsHtml amountcols (rowCellParts r)) + | r <- sortedrows ] + ++ maybe [] (\tot -> addTotalBorders + [zipWith3 totalcell [0..] colclasses + (toHtml ("Total:"::T.Text) : zipWith partsHtml amountcols tot) :: [Ods.Cell () Html]]) + mtotalrowparts + where + -- per-column css classes, so the cells can be styled + colclasses = ["account","date","age","quantity","unitcost","cost","price","value","gain"] + -- which of the other columns' cell parts are amounts + amountcols = [False, False, True, True, True, True, True, True] + hcell :: Ods.Lines border => T.Text -> T.Text -> Ods.Cell border Html + hcell cls t = toHtml <$> (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 :: Ods.Lines border => Int -> T.Text -> Html -> Ods.Cell border Html + 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 + partsHtml :: Bool -> [T.Text] -> Html + 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 -- CSV/TSV output: one record per displayed row and commodity, with -- machine-friendlier fields: full account names, age in days, bare @@ -351,22 +403,24 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, where gainq = aquantity val - aquantity costamt _ -> ("", "") - -- Grand totals row: the Cost, Value and Gain columns, summed over the - -- topmost displayed rows (which include everything below them). + -- 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 Gain are blank unless all rows have a market price. - mtotalrow + mtotalrowparts :: Maybe [[T.Text]] + mtotalrowparts | no_total_ ropts || length toprows < 2 = Nothing - | otherwise = Just ["", "", "", "", showamts totcosts, "", totvaluecell, totgaincell] + | otherwise = Just [[], [], [], [], costparts, [], valueparts, [gaincell]] where totcosts = concatMap rowLotCosts toprows + costparts = map showamt $ amounts $ mixed totcosts mrowvals = map rowValuation toprows - (totvaluecell, totgaincell) = case sequence mrowvals of - Nothing -> ("", "") - Just rowvals -> ( T.pack $ showMixedAmountWith oneLineNoCostFmt totvalue + (valueparts, gaincell) = case sequence mrowvals of + Nothing -> ([], "") + Just rowvals -> ( map showamt $ amounts totvalue , showgain (amounts totvalue) (amounts $ mixed totcosts)) where totvalue = mixed $ concatMap (amounts . snd) rowvals - - showamts = T.pack . showMixedAmountWith oneLineNoCostFmt . mixed + showamt = T.pack . showAmountWith noCostFmt -- An average cost: total cost / total quantity, showing significant -- decimal digits up to the cost commodity's display precision diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 3cfbd548e44..a10ba39cd52 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -21,7 +21,7 @@ Flags: 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. + txt, csv, tsv, html. -o --output-file=FILE write output to FILE. A file extension matching one of the above formats selects that format. ``` @@ -65,4 +65,11 @@ CSV output, the decimal mark follows the commodity's display style. (Note in tree mode, parent account records repeat the data of their subaccounts.) -Not yet implemented: html, fods or json output. +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`, `quantity`, `unitcost`, +`cost`, `price`, `value`, `gain`; 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). + +Not yet implemented: fods or json output. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index 4e189490227..49da7bc0e50 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -20,7 +20,7 @@ Flags: 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. + txt, csv, tsv, html. -o --output-file=FILE write output to FILE. A file extension matching one of the above formats selects that format. @@ -58,4 +58,11 @@ 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.) -Not yet implemented: html, fods or json output. +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, quantity, unitcost, cost, price, value, +gain; 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). + +Not yet implemented: fods or json output. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index 4fed1a1f072..bb5b5c3f5f8 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -352,3 +352,8 @@ P 2026-03-31 AAAA 3,10 € $ hledger -f- holdings -e 2026-04-01 -O csv "account","commodity","date","age","quantity","unitcost","cost","price","value","gain","gainpct" "assets:stocks","AAAA","2026-01-15","75","1000,5","2,25 €","2251,12 €","3,10 €","3101,55 €","850,42 €","37.8" + +# ** 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>/ From a0040f957e7aff0b260e4a2a627fd1c79b8fdd4f Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 08:20:06 +0100 Subject: [PATCH 12/26] ;examples: add a sample hledger.css for styling HTML output It prevents wrapping within dates and commodity amounts, useful eg for the holdings report. Kept at the top level for convenience, since HTML reports look for hledger.css in the current directory. AI usage: Claude Fable 5, ~5k output tokens --- hledger.css | 6 ++++++ hledger/hledger.m4.md | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 hledger.css 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.m4.md b/hledger/hledger.m4.md index 439c51f02f0..ea64ec74eb1 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -823,7 +823,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 From 2c7173a6848a8910453eda9561dcf4d472d25406 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 08:24:06 +0100 Subject: [PATCH 13/26] imp: holdings: add fods output A flat opendocument spreadsheet readable by LibreOffice etc., with the same single-line cells as the html output; both are now built from a shared spreadsheet-cell table. AI usage: Claude Fable 5, ~10k output tokens --- doc/SPEC-holdings.md | 3 +- hledger/Hledger/Cli/Commands/Holdings.hs | 41 ++++++++++++++--------- hledger/Hledger/Cli/Commands/Holdings.md | 7 ++-- hledger/Hledger/Cli/Commands/Holdings.txt | 7 ++-- hledger/test/holdings.test | 4 +++ 5 files changed, 42 insertions(+), 20 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index e011dc86416..6fbcdbb5480 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -190,7 +190,8 @@ Holdings on 2026-03-31 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/JSON possibly later.) + FODS output: the same single-line cells, via printFods. (done) + (JSON possibly later.) 7. Extra columns, on demand: portfolio weight %, humanised Age (eg 2m14d) or a long/short-term indicator, realised gain, XIRR. 8. Docs integration: mention holdings in the manual's lot reporting diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index dcbe72b2df2..4147a2fcb5d 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -10,6 +10,7 @@ default or shown as rows with --lots. -} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TemplateHaskell #-} module Hledger.Cli.Commands.Holdings ( @@ -36,9 +37,11 @@ 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 System.IO qualified as IO import Text.Tabular.AsciiWide -- | Command line options for this command. @@ -57,7 +60,7 @@ holdingsmode = hledgerCommandMode ,"hard - round amounts to precision (default)" ,"all - also round cost amounts to precision" ] - ,outputFormatFlag ["txt","csv","tsv","html"] + ,outputFormatFlag ["txt","csv","tsv","html","fods"] ,outputFileFlag]) cligeneralflagsgroups1 hiddenflags @@ -79,6 +82,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, "csv" -> printCSV csvoutput "tsv" -> printTSV csvoutput "html" -> (<>"\n") $ htmlAsLazyText $ styledTableHtml htmltable + "fods" -> printFods IO.localeEncoding $ M.singleton "Holdings" ((1,0), fodstable) fmt -> error' $ unsupportedOutputFormatError fmt where txtoutput = @@ -314,43 +318,50 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _ -> "" showamt = T.pack . showAmountWith noCostFmt - -- The html output's table: like the text table, but with single-line - -- cells, an Account column heading, and a Total: row heading. - htmltable :: [[Ods.Cell Ods.NumLines Html]] - htmltable = + -- 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 - (toHtml (acctcell r) : zipWith partsHtml amountcols (rowCellParts r)) + (plain (acctcell r) : zipWith parts amountcols (rowCellParts r)) | r <- sortedrows ] ++ maybe [] (\tot -> addTotalBorders [zipWith3 totalcell [0..] colclasses - (toHtml ("Total:"::T.Text) : zipWith partsHtml amountcols tot) :: [Ods.Cell () Html]]) + (plain "Total:" : zipWith parts amountcols tot) :: [Ods.Cell () content]]) mtotalrowparts where - -- per-column css classes, so the cells can be styled + -- per-column css classes, so the html cells can be styled colclasses = ["account","date","age","quantity","unitcost","cost","price","value","gain"] -- which of the other columns' cell parts are amounts amountcols = [False, False, True, True, True, True, True, True] - hcell :: Ods.Lines border => T.Text -> T.Text -> Ods.Cell border Html - hcell cls t = toHtml <$> (headerCell t){Ods.cellClass = Ods.Class cls} + 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 :: 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 :: Ods.Lines border => Int -> T.Text -> Html -> Ods.Cell border Html 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 - partsHtml :: Bool -> [T.Text] -> Html + + -- 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)) + -- CSV/TSV output: one record per displayed row and commodity, with -- machine-friendlier fields: full account names, age in days, bare -- quantity and gain percent numbers, and gain and gain percent as diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index a10ba39cd52..8436aaac6b4 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -21,7 +21,7 @@ Flags: 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. + txt, csv, tsv, html, fods. -o --output-file=FILE write output to FILE. A file extension matching one of the above formats selects that format. ``` @@ -72,4 +72,7 @@ naming its column (`account`, `date`, `age`, `quantity`, `unitcost`, `coltotal`), and each commodity amount is enclosed in a span with class `amount` (eg allowing wrapping within amounts to be prevented). -Not yet implemented: fods or json output. +With `-O fods`, a spreadsheet document readable by LibreOffice etc. +is produced, with the same single-line cells as the html output. + +Not yet implemented: json output. diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index 49da7bc0e50..0f81d7f52ae 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -20,7 +20,7 @@ Flags: 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. + txt, csv, tsv, html, fods. -o --output-file=FILE write output to FILE. A file extension matching one of the above formats selects that format. @@ -65,4 +65,7 @@ gain; 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). -Not yet implemented: fods or json output. +With -O fods, a spreadsheet document readable by LibreOffice etc. is +produced, with the same single-line cells as the html output. + +Not yet implemented: json output. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index bb5b5c3f5f8..9cdbd7d5a4d 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -357,3 +357,7 @@ $ hledger -f- holdings -e 2026-04-01 -O csv # 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"/ From a56bb8c3c5b4a76cea3dc0be40e4d404a4dc40a2 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 08:32:15 +0100 Subject: [PATCH 14/26] imp: holdings: add json output A JSON array of holding objects, one per displayed row and commodity, with the same fields as the CSV output: money amounts as machine-format display strings, dates/ages/quantities/gain percents typed (quantities and gain percents in hledger's usual JSON number encoding), and missing values null. The csv/tsv/json outputs now all render from a shared Holding record. Also add holdings to the manual's output format table. AI usage: Claude Fable 5, ~17k output tokens --- doc/SPEC-holdings.md | 4 +- hledger/Hledger/Cli/Commands/Holdings.hs | 119 +++++++++++++++++----- hledger/Hledger/Cli/Commands/Holdings.md | 7 +- hledger/Hledger/Cli/Commands/Holdings.txt | 6 +- hledger/hledger.m4.md | 1 + hledger/test/holdings.test | 4 + 6 files changed, 109 insertions(+), 32 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 6fbcdbb5480..94e6e2e904f 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -191,7 +191,9 @@ Holdings on 2026-03-31 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 possibly later.) + JSON output: an array of holding objects with the CSV fields; + quantities and gain percents use hledger's usual JSON number + encoding, missing values are null. (done) 7. Extra columns, on demand: portfolio weight %, humanised Age (eg 2m14d) or a long/short-term indicator, realised gain, XIRR. 8. Docs integration: mention holdings in the manual's lot reporting diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 4147a2fcb5d..5853d0ebdca 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -19,6 +19,8 @@ module Hledger.Cli.Commands.Holdings ( ) where import Control.Applicative ((<|>)) +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 @@ -26,7 +28,7 @@ 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 (addDays, diffDays) +import Data.Time.Calendar (Day, addDays, diffDays) import System.Console.CmdArgs.Explicit (flagNone, flagReq) import Text.Printf (printf) @@ -60,12 +62,60 @@ holdingsmode = hledgerCommandMode ,"hard - round amounts to precision (default)" ,"all - also round cost amounts to precision" ] - ,outputFormatFlag ["txt","csv","tsv","html","fods"] + ,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, quantities 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 + ,hQuantity :: Amount -- ^ quantity held, styled + ,hUnitCost :: Maybe T.Text -- ^ unit or average cost + ,hCost :: T.Text -- ^ total cost basis + ,hPrice :: Maybe T.Text -- ^ market price at the valuation date + ,hValue :: Maybe T.Text -- ^ market value + ,hGain :: Maybe T.Text -- ^ unrealised gain + ,hGainPct :: Maybe Quantity -- ^ unrealised gain percent, rounded to 1 decimal + } + +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} (hQuantity h) + ,fromMaybe "" (hUnitCost h) + ,hCost h + ,fromMaybe "" (hPrice h) + ,fromMaybe "" (hValue h) + ,fromMaybe "" (hGain h) + ,maybe "" (T.pack . show) (hGainPct h) + ] + +holdingJson :: Holding -> Value +holdingJson h = object + ["account" .= hAccount h + ,"commodity" .= hCommodity h + ,"date" .= hDate h + ,"age" .= hAge h + ,"quantity" .= aquantity (hQuantity h) + ,"unitcost" .= hUnitCost h + ,"cost" .= hCost h + ,"price" .= hPrice h + ,"value" .= hValue h + ,"gain" .= hGain h + ,"gainpct" .= hGainPct h + ] + -- | 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). -- @@ -83,6 +133,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, "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 = @@ -362,57 +413,71 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, fodstable :: [[Ods.Cell Ods.NumLines T.Text]] fodstable = spreadsheetWith id (\_ -> T.intercalate ", " . filter (not . T.null)) - -- CSV/TSV output: one record per displayed row and commodity, with - -- machine-friendlier fields: full account names, age in days, bare - -- quantity and gain percent numbers, and gain and gain percent as - -- separate fields. No totals records. - csvoutput :: CSV - csvoutput = - ["account","commodity","date","age","quantity","unitcost","cost","price","value","gain","gainpct"] - : concatMap rowrecords sortedrows + -- Machine-readable records, one per displayed row and commodity, + -- for the csv/tsv/json output: with full account names, age in days, + -- bare quantity and gain percent numbers, and gain and gain percent + -- separate. No totals records. + holdingrecords :: [Holding] + holdingrecords = concatMap rowrecords sortedrows where rowrecords r = map rec $ rowQtyAmounts r where acct = prrFullName r - rec qa = [acct, c, dtstr, agestr, qtystr, ucoststr, coststr, pricestr, valstr, gainstr, pctstr] + rec qa = Holding + { hAccount = acct + , hCommodity = c + , hDate = mdate + , hAge = diffDays reportdate <$> mdate + , hQuantity = styleAmounts styles qa + , hUnitCost = mucoststr + , hCost = coststr + , hPrice = mpricestr + , hValue = mvalstr + , hGain = mgainstr + , hGainPct = mpct + } 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] - (dtstr, agestr) = case dates of - [Just dt] -> (showDate dt, T.pack $ show $ diffDays reportdate dt) - _ -> ("", "") - qtystr = T.pack $ showAmountWith machineFmt{displayCommodity=False} $ styleAmounts styles qa + 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 - ucoststr = case (clots, ccosts) of - ([(_, mcb)], _) -> maybe "" (showamt . rowCostValuer r) (cbCost =<< mcb) + mucoststr = case (clots, ccosts) of + ([(_, mcb)], _) -> showamt . rowCostValuer r <$> (cbCost =<< mcb) (_, _:_) | [totcost] <- amounts (mixed ccosts) , not $ amountLooksZero qa - -> showamt $ avgcost qa totcost - _ -> "" + -> Just $ showamt $ avgcost qa totcost + _ -> Nothing mto = case mvalue of Nothing -> listToMaybe [acommodity cb | (_, mcb) <- clots, Just cb <- [cbCost =<< mcb]] Just _ -> mtargetcomm - (pricestr, valstr, gainstr, pctstr) = + (mpricestr, mvalstr, mgainstr, mpct) = case priceoracle (valuationdate, c, mto) of - Nothing -> ("", "", "", "") - Just (pcomm, rate) -> (showamt price, showamt val, gainstr', pctstr') + Nothing -> (Nothing, Nothing, Nothing, Nothing) + Just (pcomm, rate) -> (Just $ showamt price, Just $ showamt val, mgainstr', mpct') where mkamt n = styleAmounts styles $ amountSetFullPrecisionUpTo Nothing nullamt{acommodity=pcomm, aquantity=n} price = mkamt rate val = mkamt (rate * aquantity qa) - (gainstr', pctstr') = case amounts (mixed ccosts) of + (mgainstr', mpct') = case amounts (mixed ccosts) of [costamt] | acommodity costamt == pcomm -> - ( showamt $ mkamt gainq + ( Just $ showamt $ mkamt gainq , if aquantity costamt /= 0 - then T.pack $ printf "%.1f" (realToFrac (100 * gainq / aquantity costamt) :: Double) - else "" ) + 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","quantity","unitcost","cost","price","value","gain","gainpct"] + : map holdingCsv holdingrecords -- Grand totals row (as cell parts, like rowCellParts): the Cost, -- Value and Gain columns, summed over the topmost displayed rows diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 8436aaac6b4..fd08ba60bbb 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -21,7 +21,7 @@ Flags: 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. + 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. ``` @@ -75,4 +75,7 @@ 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. -Not yet implemented: json output. +With `-O json`, a JSON array of holding objects is produced, with the +same fields as the CSV output; quantities 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 index 0f81d7f52ae..cbaf6e8c503 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -20,7 +20,7 @@ Flags: 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. + 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. @@ -68,4 +68,6 @@ 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. -Not yet implemented: json output. +With -O json, a JSON array of holding objects is produced, with the same +fields as the CSV output; quantities and gain percents are JSON number +objects as in other commands' JSON output, and missing values are null. diff --git a/hledger/hledger.m4.md b/hledger/hledger.m4.md index ea64ec74eb1..4eafed4a1ba 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 | diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index 9cdbd7d5a4d..0be7e5cd4a9 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -361,3 +361,7 @@ $ hledger -f- holdings -e 2026-04-01 -O html # ** 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 €"/ From cda59e2a79417ba65c439dda5bd901cb46653c6b Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 12:07:59 +0100 Subject: [PATCH 15/26] feat: holdings: add Weight, Rgain and XIRR columns, and humanise ages Weight is each row's percentage of the portfolio's total value (blank unless all displayed holdings are priced in one commodity). Rgain is the realised gain from disposals so far: each dispose posting's proceeds minus the cost basis of the disposed units, for the lots at or under the row's account. XIRR is the annualised internal rate of return implied by the account's dated cashflows and current value, calculated like roi's IRR (the solver setup is duplicated for now, with cross-referencing comments); it includes realised gains. The totals row is now always shown (unless -N), and computes Rgain and XIRR at account level, from the displayed rows' base accounts: so they include fully disposed lots, which have no row of their own (eg with --lots). Fully disposed accounts still don't appear in the report, so neither do their realised gains. Ages are now shown compactly: in days or in years with one decimal digit, eg 44d or 6.2y. The csv/tsv/json outputs gain weight, rgain and xirr fields, keeping age numeric. Also update examples/lots/lot-entries.journal so a plain `hledger holdings` demonstrates all the columns: add a market price, and shift the story dates one year into the past. Also explain the inferred equity:unrealised-gain posting. And record two decisions in SPEC-holdings.md: future-dated postings stay included by default (a today-default report end date was tried and rolled back), and XIRR's final cashflow is the displayed Value at the report date. AI usage: Claude Fable 5, ~120k output tokens --- doc/SPEC-holdings.md | 60 ++++- examples/lots/lot-entries.journal | 50 +++-- hledger/Hledger/Cli/Commands/Holdings.hs | 238 ++++++++++++++++---- hledger/Hledger/Cli/Commands/Holdings.md | 14 +- hledger/Hledger/Cli/Commands/Holdings.txt | 16 +- hledger/Hledger/Cli/Commands/Roi.hs | 3 + hledger/hledger.m4.md | 18 +- hledger/test/holdings.test | 258 +++++++++++++--------- 8 files changed, 470 insertions(+), 187 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 94e6e2e904f..8ae335469d6 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -42,7 +42,10 @@ json...). | Cost | total cost basis | | Price | current market price per unit | | Value | current market value (Quantity x Price) | +| Weight | percentage of the portfolio's total value | | Gain | unrealised gain: Value - Cost, absolute and percent | +| 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, @@ -50,20 +53,38 @@ Notes: 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, eg `75d`. (Could later be humanised, eg `2m14d` or - `1y3m`, and/or a long/short-term capital gains indicator could be added.) +- 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 Gain columns. - Rows with no known market price show blank Price, Value and Gain, 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 totals row shows only the commodity-independent columns: - Cost, Value, Gain. -- Possible future columns: portfolio weight %, realised gain, XIRR. +- The totals row (shown unless -N) shows only the commodity-independent + columns: Cost, Value, Weight (100%), Gain, Rgain, XIRR. +- Possible future columns: a long/short-term capital gains indicator. ## Valuation - -- The report date is the report end date (today by default, or set with `-e`). - 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 @@ -194,8 +215,29 @@ Holdings on 2026-03-31 JSON output: an array of holding objects with the CSV fields; quantities and gain percents use hledger's usual JSON number encoding, missing values are null. (done) -7. Extra columns, on demand: portfolio weight %, humanised Age (eg 2m14d) - or a long/short-term indicator, realised gain, XIRR. +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 quantities +(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/lot-entries.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/examples/lots/lot-entries.journal b/examples/lots/lot-entries.journal index 49bb8633905..eb9a51e9b8e 100644 --- a/examples/lots/lot-entries.journal +++ b/examples/lots/lot-entries.journal @@ -23,13 +23,13 @@ account revenue:gains ; type: G ; 1a. Buy with no fee. ; {$50} records the per-unit cost basis. -2026-01-01 buy shares +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. -2026-02-01 buy shares, $ fee +2025-02-01 buy shares, $ fee assets:cash -$610 expenses:fees $10 assets:broker 10 ETSY {$60} @@ -37,13 +37,13 @@ account revenue:gains ; type: G ; 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 +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. -2026-04-01 buy shares, both fees +2025-04-01 buy shares, both fees assets:cash -$810 expenses:fees $10 expenses:fees 0.1 ETSY @ $80 @@ -55,14 +55,14 @@ account revenue:gains ; type: G ; 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 +; 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 ({2026-02-01, $60}). -2026-06-01 transfer, $ fee +; 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 @@ -70,8 +70,8 @@ account revenue:gains ; type: G ; 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 +; 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 @@ -83,19 +83,22 @@ account revenue:gains ; type: G ; 3a. Sell with no fee, at a gain. ; {$50} selects the lot; @ $90 is the selling price. ; The gain posting balances the difference between cost and sale price. -2026-08-01 sell at a gain, using specific identification +; 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. -2026-09-01 sell 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. -2026-10-01 sell with $ fee +2025-10-01 sell with $ fee assets:broker -4.8 ETSY {$70} @ $90 assets:cash $422 expenses:fees $10 @@ -103,7 +106,7 @@ account revenue:gains ; type: G ; 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 +2025-10-15 sell with stock fee assets:broker -5 ETSY {$80} @ $90 expenses:fees 0.1 ETSY {$80} @ $90 assets:cash $441 @@ -112,7 +115,7 @@ account revenue:gains ; type: G ; 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 +2025-11-01 sell using auto selection and inferred gain amount assets:broker -4.9 ETSY {} @ $90 assets:cash $441 revenue:gains $-50 @@ -120,7 +123,7 @@ account revenue:gains ; type: G ; 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 +2025-12-01 sell, with inferred gain posting assets:broker2 -4.9 ETSY {} @ $90 assets:cash $441 ; both gains postings are inferred here @@ -133,19 +136,28 @@ account revenue:gains ; type: G ; 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 +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. -2027-03-01 charitable donation +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. -2027-04-01 pay contractor +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 lot-entries.journal holdings +P 2026-05-01 ETSY $95 diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 5853d0ebdca..4550122b320 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -19,6 +19,7 @@ module Hledger.Cli.Commands.Holdings ( ) where import Control.Applicative ((<|>)) +import Control.Monad (guard) import Data.Aeson (Value, object, (.=)) import Data.Decimal (roundTo) import Data.Default (def) @@ -43,6 +44,7 @@ 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 @@ -82,8 +84,11 @@ data Holding = Holding { ,hCost :: T.Text -- ^ total cost basis ,hPrice :: Maybe T.Text -- ^ market price at the valuation date ,hValue :: Maybe T.Text -- ^ market value + ,hWeight :: Maybe Quantity -- ^ percentage of the portfolio's value, rounded to 1 decimal ,hGain :: Maybe T.Text -- ^ unrealised gain ,hGainPct :: 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] @@ -97,8 +102,11 @@ holdingCsv h = ,hCost h ,fromMaybe "" (hPrice h) ,fromMaybe "" (hValue h) + ,maybe "" (T.pack . show) (hWeight h) ,fromMaybe "" (hGain h) ,maybe "" (T.pack . show) (hGainPct h) + ,fromMaybe "" (hRgain h) + ,maybe "" (T.pack . printf "%.1f") (hXirr h) ] holdingJson :: Holding -> Value @@ -112,10 +120,29 @@ holdingJson h = object ,"cost" .= hCost h ,"price" .= hPrice h ,"value" .= hValue h + ,"weight" .= hWeight h ,"gain" .= hGain h ,"gainpct" .= hGainPct 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 a (rounded) percentage: eg 64.3%. +showpct :: Quantity -> T.Text +showpct p = T.pack (show p) <> "%" + +-- | Show an XIRR percentage with 1 decimal place: eg 12.3%. +showxirr :: Double -> T.Text +showxirr x = T.pack $ printf "%.1f%%" x + -- | 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). -- @@ -154,25 +181,72 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, mend = queryEndDate False q reportdate = maybe (_rsDay rspec) (addDays (-1)) mend - -- The quantity held in each lot subaccount, from its postings. - -- Keyed by account and commodity, so amounts in different commodities - -- (not expected in a lot subaccount, but possible) don't merge wrongly. - -- Postings are restricted by the report query's non-date terms and its - -- end date (but not its begin date; holdings are cumulative). - lotmap :: M.Map (AccountName, CommoditySymbol) Amount - lotmap = M.fromListWith (+) - [ ((paccount p, acommodity a), amountStripCost a) + -- 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 ] - where - -- the query without its date terms (holdings are cumulative to the - -- end date, added below) 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 quantity 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). @@ -225,27 +299,80 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, nullamt{acommodity=pcomm, aquantity=n} Just (mkamt rate, mkamt (rate * aquantity qa)) - -- How to convert a row's cost amounts for display, so that the Cost, - -- Unit/Avg cost and Gain columns follow the valuation commodity when - -- -V/-X/--value is in effect: convert to the requested commodity, or - -- to the commodity the row's value came out in, at the valuation date. - -- Costs already in the target commodity, or with no target or no - -- market price, are left unchanged. - rowCostValuer :: PeriodicReportRow DisplayName MixedAmount -> Amount -> Amount - rowCostValuer r = case mvalue of + -- How to convert cost amounts (Cost, Unit/Avg cost, Rgain, and the + -- cost side of Gain) 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 <|> mrowvaluecomm of + 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. Rounded to 1 decimal place. + 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 $ roundTo 1 $ 100 * aquantity v / aquantity t + -- Render a gain (and percent gain) from single-commodity value and -- cost amounts, if their commodities match. showgain :: [Amount] -> [Amount] -> T.Text @@ -328,7 +455,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, where addtotalrow totalrow tbl' = concatTables SingleLine tbl' $ Table (Group NoLine [Header ""]) (Header []) [totalrow] - colheadings = ["Date", "Age", "Quantity", if showlots then "Unit cost" else "Avg cost", "Cost", "Price", "Value", "Gain"] + colheadings = ["Date", "Age", "Quantity", if showlots then "Unit cost" else "Avg cost", "Cost", "Price", "Value", "Weight", "Gain", "Rgain", "XIRR"] renderacct r = T.replicate (prrIndent r * 2) " " <> prrDisplayName r rowLotCosts r = [rowCostValuer r $ multiplyAmount (aquantity a) c @@ -337,25 +464,34 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, -- The text table's cells: each cell's parts joined, -- multi-line in Quantity and Price, one-line elsewhere. rowcells = zipWith T.intercalate cellseps . rowCellParts - cellseps = [", ", ", ", "\n", ", ", ", ", "\n", ", ", ", "] + 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], qtyparts, [unitcostcell], costparts, priceparts, valueparts, [gaincell]] + rowCellParts r = [[datecell], [agecell], qtyparts, [unitcostcell], costparts, priceparts, valueparts, [weightcell], [gaincell], rgainparts, [xirrcell]] where - (priceparts, valueparts, gaincell) = case rowValuation r of - Nothing -> ([], [], "") + acct = prrFullName r + (priceparts, valueparts, gaincell, 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 ) - rowlots = lotsUnder $ prrFullName r + 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, T.pack (show $ diffDays reportdate dt) <> "d") + [Just dt] -> (showDate dt, showage $ diffDays reportdate dt) _ -> ("", "") qtyparts = map (showamt . styleAmounts styles) $ rowQtyAmounts r costs = rowLotCosts r @@ -386,9 +522,9 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, mtotalrowparts where -- per-column css classes, so the html cells can be styled - colclasses = ["account","date","age","quantity","unitcost","cost","price","value","gain"] + colclasses = ["account","date","age","quantity","unitcost","cost","price","value","weight","gain","rgain","xirr"] -- which of the other columns' cell parts are amounts - amountcols = [False, False, True, True, True, True, True, True] + amountcols = [False, False, True, True, True, True, True, False, True, 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 @@ -433,8 +569,11 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, , hCost = coststr , hPrice = mpricestr , hValue = mvalstr + , hWeight = mweight , hGain = mgainstr , hGainPct = mpct + , hRgain = mrgainstr + , hXirr = mxirr } where c = acommodity qa @@ -456,10 +595,15 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, mto = case mvalue of Nothing -> listToMaybe [acommodity cb | (_, mcb) <- clots, Just cb <- [cbCost =<< mcb]] Just _ -> mtargetcomm - (mpricestr, mvalstr, mgainstr, mpct) = + 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) - Just (pcomm, rate) -> (Just $ showamt price, Just $ showamt val, mgainstr', mpct') + Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing) + Just (pcomm, rate) -> (Just $ showamt price, Just $ showamt val, mgainstr', mpct' + ,weightPct $ mixedAmount val + ,xirrOf (flowsUnder acct (Just c)) val) where mkamt n = styleAmounts styles $ amountSetFullPrecisionUpTo Nothing nullamt{acommodity=pcomm, aquantity=n} @@ -476,7 +620,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, csvoutput :: CSV csvoutput = - ["account","commodity","date","age","quantity","unitcost","cost","price","value","gain","gainpct"] + ["account","commodity","date","age","quantity","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr"] : map holdingCsv holdingrecords -- Grand totals row (as cell parts, like rowCellParts): the Cost, @@ -485,17 +629,23 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, -- Value and Gain are blank unless all rows have a market price. mtotalrowparts :: Maybe [[T.Text]] mtotalrowparts - | no_total_ ropts || length toprows < 2 = Nothing - | otherwise = Just [[], [], [], [], costparts, [], valueparts, [gaincell]] + | no_total_ ropts = Nothing + | otherwise = Just [[], [], [], [], costparts, [], valueparts, [weightcell], [gaincell], rgainparts, [xirrcell]] where totcosts = concatMap rowLotCosts toprows costparts = map showamt $ amounts $ mixed totcosts - mrowvals = map rowValuation toprows - (valueparts, gaincell) = case sequence mrowvals of - Nothing -> ([], "") - Just rowvals -> ( map showamt $ amounts totvalue - , showgain (amounts totvalue) (amounts $ mixed totcosts)) - where totvalue = mixed $ concatMap (amounts . snd) rowvals + (valueparts, weightcell, gaincell) = 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 quantity, showing significant diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index fd08ba60bbb..09560d7411e 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -36,10 +36,20 @@ aggregates the displayed rows as usual. With `-S/--sort-amount`, rows are sorted by market value (or by cost, when unpriced), largest first. Columns show each holding's acquisition date and age -(when the row's lots share a single date), the quantity held, +(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 quantity held, the unit cost (or average cost, on rows aggregating multiple lots), the total cost basis, the current market price, the market value, -and the unrealised gain (absolute and percent). +the percentage of the portfolio's total value (Weight), +the unrealised gain (absolute and percent), +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 diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index cbaf6e8c503..f4d2af0069e 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -32,10 +32,18 @@ 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. Columns show each holding's -acquisition date and age (when the row's lots share a single date), the -quantity held, the unit cost (or average cost, on rows aggregating -multiple lots), the total cost basis, the current market price, the -market value, and the unrealised gain (absolute and percent). +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 quantity held, the +unit cost (or average cost, on rows aggregating multiple lots), the +total cost basis, the current market price, the market value, the +percentage of the portfolio's total value (Weight), the unrealised gain +(absolute and percent), 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 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.m4.md b/hledger/hledger.m4.md index 4eafed4a1ba..d9ea3191aae 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -7148,9 +7148,11 @@ quantities, cost basis, current value and unrealised gain: $ hledger holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Gain -===============++======================================================================== - assets:stocks || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 $110 (+44.0%) + || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR +===============++================================================================================================ + assets:stocks || 2026-01-15 75d 5 AAPL $50 $250 $72 $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 - @@ -7664,12 +7666,12 @@ unrealised gain): $ hledger holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Quantity Unit cost Cost Price Value Gain -=================================++================================================================ - assets:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 + || Date Age Quantity Unit cost Cost Price Value Weight Gain 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 +---------------------------------++------------------------------------------------------------------------------------- + || $850 $100 ``` and `print -x --lots` shows the inferred lot subaccounts and gain postings. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index 0be7e5cd4a9..49a8aa128da 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -32,32 +32,34 @@ commodity MSFT ; lots: $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Gain -======================++================================================================ - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 - assets:broker:stocks || 15 AAPL $56.67 $850 -----------------------++---------------------------------------------------------------- - || $2850 + || Date Age Quantity Avg cost Cost Price Value Weight Gain 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 Quantity Unit cost Cost Price Value Gain -========================================++================================================================= - 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 - assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 -----------------------------------------++----------------------------------------------------------------- - || $2850 + || Date Age Quantity Unit cost Cost Price Value Weight Gain 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; a single row shows no totals row. +# ** 3. A query can restrict the report. $ hledger -f- holdings -e 2026-04-01 cur:AAPL Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Gain -======================++========================================================= - assets:broker:stocks || 15 AAPL $56.67 $850 + || Date Age Quantity Avg cost Cost Price Value Weight Gain 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 @@ -66,41 +68,47 @@ Holdings on 2026-03-31 $ hledger -f- holdings -e 2026-04-01 --lots --tree Holdings on 2026-03-31 - || Date Age Quantity Unit cost Cost Price Value Gain -============================++================================================================= - assets:broker || 15 AAPL $2850 - || 5 MSFT - funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 - stocks || 15 AAPL $56.67 $850 - {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 - {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 + || Date Age Quantity Unit cost Cost Price Value Weight Gain 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 Quantity Unit cost Cost Price Value Gain -==========================++================================================================= - assets || 15 AAPL $2850 - || 5 MSFT - broker || 15 AAPL $2850 - || 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 - {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 - {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 + || Date Age Quantity Unit cost Cost Price Value Weight Gain 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 Quantity Avg cost Cost Price Value Gain -===============++========================================================== - assets:broker || 15 AAPL $2850 - || 5 MSFT + || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR +===============++=============================================================================== + assets:broker || 15 AAPL $2850 $100 + || 5 MSFT +---------------++------------------------------------------------------------------------------- + || $2850 $100 # ** 7. A journal with no lots reports no holdings. < @@ -136,24 +144,24 @@ commodity GOLD ; lots: $ hledger -f- holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Quantity Unit cost Cost Price Value Gain -==========================================++======================================================================= - 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 + || Date Age Quantity Unit cost Cost Price Value Weight Gain 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 Quantity Avg cost Cost Price Value Gain -=============++======================================================================= - assets:fund || 16.993 FUND $1859.59 $31600.00 - assets:gold || 2026-03-01 30d 1.5 GOLD $2000.22 $3000.33 --------------++----------------------------------------------------------------------- - || $34600.33 + || Date Age Quantity Avg cost Cost Price Value Weight Gain 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). @@ -171,24 +179,24 @@ commodity AAAA ; lots: $ hledger -f- holdings -e 2026-02-01 --lots Holdings on 2026-01-31 - || Date Age Quantity Unit cost Cost Price Value Gain -==========================++================================================================== - 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 + || Date Age Quantity Unit cost Cost Price Value Weight Gain 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 Quantity Unit cost Cost Price Value Gain -==========================++================================================================== - 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 + || Date Age Quantity Unit cost Cost Price Value Weight Gain 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 @@ -223,24 +231,24 @@ P 2026-03-31 MSFT $410 $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Gain -======================++========================================================================= - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) - assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 $230 (+27.1%) -----------------------++------------------------------------------------------------------------- - || $2850 $3130 $280 (+9.8%) + || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR +======================++================================================================================================ + assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 (+2.5%) 22.7% + assets:broker:stocks || 15 AAPL $56.67 $850 $72 $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 Quantity Unit cost Cost Price Value Gain -========================================++========================================================================== - assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) - assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 $110 (+44.0%) - assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 $120 (+20.0%) -----------------------------------------++-------------------------------------------------------------------------- - || $2850 $3130 $280 (+9.8%) + || Date Age Quantity Unit cost Cost Price Value Weight Gain Rgain XIRR +========================================++================================================================================================= + assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 (+2.5%) 22.7% + assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 (+44.0%) $100 759.2% + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $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. @@ -258,12 +266,12 @@ commodity AAAA ; lots: $ hledger -f- holdings -e 2026-02-01 --lots --infer-market-prices Holdings on 2026-01-31 - || Date Age Quantity Unit cost Cost Price Value Gain -==========================++========================================================================= - assets:{2026-01-01, $10} || 2026-01-01 30d 1.234 AAAA $10 $12 $12 $15 $2 (+20.0%) - assets:{2026-01-02, $12} || 2026-01-02 29d 5.000 AAAA $12 $60 $12 $60 $0 (+0.0%) ---------------------------++------------------------------------------------------------------------- - || $72 $75 $2 (+3.4%) + || Date Age Quantity Unit cost Cost Price Value Weight Gain 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 $60 $12 $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: @@ -284,17 +292,21 @@ P 2026-03-31 $ €0.9123 $ hledger -f- holdings -e 2026-04-01 -X € Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Gain -===============++================================================================================= - assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €656.86 €200.71 (+44.0%) + || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR +===============++====================================================================================================== + assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €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 Quantity Avg cost Cost Price Value Gain -===============++======================================================================== - assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 $220 (+44.0%) + || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR +===============++=============================================================================================== + assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $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 @@ -322,20 +334,20 @@ P 2026-03-31 MSFT $410 $ hledger -f- holdings -e 2026-04-01 -S Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Gain -===============++========================================================================= - assets:msoft || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) - assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 $220 (+44.0%) ----------------++------------------------------------------------------------------------- - || $2500 $2770 $270 (+10.8%) + || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR +===============++================================================================================================ + assets:msoft || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 74.0% $50 (+2.5%) 22.7% + assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $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","quantity","unitcost","cost","price","value","gain","gainpct" -"assets:apples","AAPL","2026-01-15","75","10","$50","$500","$72","$720","$220","44.0" -"assets:msoft","MSFT","2026-02-15","44","5","$400","$2000","$410","$2050","$50","2.5" +"account","commodity","date","age","quantity","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr" +"assets:apples","AAPL","2026-01-15","75","10","$50","$500","$72","$720","26.0","$220","44.0","","490.5" +"assets:msoft","MSFT","2026-02-15","44","5","$400","$2000","$410","$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. @@ -350,8 +362,8 @@ commodity 1.000,00 € P 2026-03-31 AAAA 3,10 € $ hledger -f- holdings -e 2026-04-01 -O csv -"account","commodity","date","age","quantity","unitcost","cost","price","value","gain","gainpct" -"assets:stocks","AAAA","2026-01-15","75","1000,5","2,25 €","2251,12 €","3,10 €","3101,55 €","850,42 €","37.8" +"account","commodity","date","age","quantity","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr" +"assets:stocks","AAAA","2026-01-15","75","1000,5","2,25 €","2251,12 €","3,10 €","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. @@ -365,3 +377,47 @@ $ hledger -f- holdings -e 2026-04-01 -O fods # ** 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 Quantity Avg cost Cost Price Value Weight Gain 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 Quantity Unit cost Cost Price Value Weight Gain Rgain XIRR +=================================++===================================================================================== + assets:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 +---------------------------------++------------------------------------------------------------------------------------- + || $600 $200 From 7938956e54b19eeadd18afac246910bea5b54178 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 10:38:51 +0100 Subject: [PATCH 16/26] imp: holdings: add --drop AI usage: Claude Fable 5, ~5k output tokens --- hledger/Hledger/Cli/Commands/Holdings.hs | 1 + hledger/Hledger/Cli/Commands/Holdings.md | 1 + hledger/Hledger/Cli/Commands/Holdings.txt | 1 + hledger/test/holdings.test | 10 ++++++++++ 4 files changed, 13 insertions(+) diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 4550122b320..14576045f3f 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -53,6 +53,7 @@ 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" $ diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 09560d7411e..344e39a78e9 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -10,6 +10,7 @@ Flags: -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 diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index f4d2af0069e..fd3bc081959 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -9,6 +9,7 @@ Flags: -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 diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index 49a8aa128da..29381aeb2a2 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -421,3 +421,13 @@ Holdings on 2026-03-31 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 Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR +========++==================================================================================== + stocks || 2026-02-01 58d 10 AAPL $60 $600 $200 +--------++------------------------------------------------------------------------------------ + || $600 $200 From e374f60db90ad87d1b53d2e1c19e468013e2b18d Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 12:30:17 +0100 Subject: [PATCH 17/26] ;tools: just holdings-hledger, holdings-examples --- Justfile | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Justfile b/Justfile index b20ee6f7a00..0dfcf6b990b 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/lot-entries.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 From 8a8c072d9139c9b68c7256ca4f7c7d9f83ffef00 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 12:34:22 +0100 Subject: [PATCH 18/26] imp: holdings: rename the Quantity column to Units "Quantity" elsewhere in hledger means the number part of an amount, while this column shows a full amount like "10 AAPL". Units avoids the clash and matches fund statement terminology. Renamed consistently in the display heading, the csv/tsv/json field names, the html css class, code, docs and tests. AI usage: Claude Fable 5, ~21k output tokens --- doc/SPEC-holdings.md | 24 +-- hledger/Hledger/Cli/Commands/Holdings.hs | 46 ++--- hledger/Hledger/Cli/Commands/Holdings.md | 8 +- hledger/Hledger/Cli/Commands/Holdings.txt | 28 +-- hledger/hledger.m4.md | 24 +-- hledger/test/holdings.test | 198 +++++++++++----------- 6 files changed, 164 insertions(+), 164 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 8ae335469d6..209dba6ba9c 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -37,11 +37,11 @@ json...). |-----------|----------------------------------------------------------------| | Date | the lot's acquisition date | | Age | how long the lot has been held, as of the report date | -| Quantity | number of units held | +| Units | number of units held | | Unit cost | cost basis per unit ("Avg cost" on rows aggregating lots) | | Cost | total cost basis | | Price | current market price per unit | -| Value | current market value (Quantity x Price) | +| Value | current market value (Units x Price) | | Weight | percentage of the portfolio's total value | | Gain | unrealised gain: Value - Cost, absolute and percent | | Rgain | realised gain from disposals so far | @@ -99,7 +99,7 @@ Notes: currency conversion. Costs with no market price to the valuation commodity are left unconverted (making Gain blank). - `--value=then` is not supported (holdings is a snapshot report). -- `-B/--cost` has no effect; quantities always stay quantities. +- `-B/--cost` has no effect; units always stay units. ## Layout mockups @@ -113,7 +113,7 @@ Default (list mode, lot subaccounts hidden): $ hledger holdings Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Gain + || Date Age Units Avg cost Cost Price Value Gain ======================++======================================================================== assets:broker:funds || 2026-02-15 44d 5 MSFT $400.00 $2000 $410 $2050 $50 (+2.5%) assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 $230 (+27.1%) @@ -130,7 +130,7 @@ With `--lots` (lot subaccounts become rows; Avg cost becomes exact Unit cost): $ hledger holdings --lots Holdings on 2026-03-31 - || Date Age Quantity Unit cost Cost Price Value Gain + || Date Age Units Unit cost Cost Price Value Gain ========================================++========================================================================= assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 $110 (+44.0%) @@ -146,7 +146,7 @@ multi-line as in bal): $ hledger holdings --lots --tree Holdings on 2026-03-31 - || Date Age Quantity Unit cost Cost Price Value Gain + || Date Age Units Unit cost Cost Price Value Gain ==========================++========================================================================= assets || 15 AAPL $2850 $3130 $280 (+9.8%) || 5 MSFT @@ -166,7 +166,7 @@ where meaningless): $ hledger holdings --depth 2 Holdings on 2026-03-31 - || Quantity Cost Value Gain + || Units Cost Value Gain ================++===================================== assets:broker || 15 AAPL $2850 $3130 $280 (+9.8%) || 5 MSFT @@ -184,7 +184,7 @@ Holdings on 2026-03-31 (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 quantities are summed from the lot subaccounts' postings +- 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. @@ -194,7 +194,7 @@ Holdings on 2026-03-31 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, Quantity, Unit/Avg cost and Cost columns; --lots; totals row; + 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) @@ -205,7 +205,7 @@ Holdings on 2026-03-31 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 quantity and gain percent numbers, Gain and + 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 @@ -213,7 +213,7 @@ Holdings on 2026-03-31 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; - quantities and gain percents use hledger's usual JSON number + 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 @@ -227,7 +227,7 @@ Holdings on 2026-03-31 ### Future-dated postings Without an explicit report end date (eg set by -e), -holdings includes future-dated postings in its quantities +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, diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 14576045f3f..5f871bcf89a 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -3,7 +3,7 @@ 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, Quantity, Unit/Avg cost, Cost, Price, +Currently it shows the Date, Age, Units, Unit/Avg cost, Cost, Price, Value and Gain columns in list mode, with lot subaccounts aggregated by default or shown as rows with --lots. @@ -74,13 +74,13 @@ holdingsmode = hledgerCommandMode -- | 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, quantities and gain percents are typed. +-- 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 - ,hQuantity :: Amount -- ^ quantity held, styled + ,hUnits :: Amount -- ^ units held, styled ,hUnitCost :: Maybe T.Text -- ^ unit or average cost ,hCost :: T.Text -- ^ total cost basis ,hPrice :: Maybe T.Text -- ^ market price at the valuation date @@ -98,7 +98,7 @@ holdingCsv h = ,hCommodity h ,maybe "" showDate (hDate h) ,maybe "" (T.pack . show) (hAge h) - ,T.pack $ showAmountWith machineFmt{displayCommodity=False} (hQuantity h) + ,T.pack $ showAmountWith machineFmt{displayCommodity=False} (hUnits h) ,fromMaybe "" (hUnitCost h) ,hCost h ,fromMaybe "" (hPrice h) @@ -116,7 +116,7 @@ holdingJson h = object ,"commodity" .= hCommodity h ,"date" .= hDate h ,"age" .= hAge h - ,"quantity" .= aquantity (hQuantity h) + ,"units" .= aquantity (hUnits h) ,"unitcost" .= hUnitCost h ,"cost" .= hCost h ,"price" .= hPrice h @@ -201,7 +201,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, , a <- amountsRaw $ pamount p ] - -- The quantity held in each lot subaccount. + -- The units held in each lot subaccount. lotmap :: M.Map (AccountName, CommoditySymbol) Amount lotmap = M.fromListWith (+) [(k, amountStripCost a) | (k, (_, a)) <- lotpostings] @@ -280,7 +280,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, Just (AtDate d mc) -> (d, mc) Just (AtThen mc) -> (reportdate, mc) -- not supported, rejected above - -- Value a row's quantities at the valuation date: Just (price amounts, + -- 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 @@ -290,7 +290,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, pvs <- mapM lookup1 qas Just (map fst pvs, mixed (map snd pvs)) where - qas = rowQtyAmounts r + qas = rowUnitAmounts r mto = case mvalue of Nothing -> listToMaybe [acommodity c | (_, mcb) <- lotsUnder (prrFullName r), Just c <- [cbCost =<< mcb]] Just _ -> mtargetcomm @@ -387,12 +387,12 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, | otherwise = "" showgain _ _ = "" - -- A row's quantities of lot-tracked commodities: its balance restricted + -- 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. - rowQtyAmounts :: PeriodicReportRow DisplayName MixedAmount -> [Amount] - rowQtyAmounts r = + 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] @@ -410,12 +410,12 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, -- 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 quantities should stay quantities. + -- 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 quantities + ,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 @@ -456,21 +456,21 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, where addtotalrow totalrow tbl' = concatTables SingleLine tbl' $ Table (Group NoLine [Header ""]) (Header []) [totalrow] - colheadings = ["Date", "Age", "Quantity", if showlots then "Unit cost" else "Avg cost", "Cost", "Price", "Value", "Weight", "Gain", "Rgain", "XIRR"] + colheadings = ["Date", "Age", "Units", if showlots then "Unit cost" else "Avg cost", "Cost", "Price", "Value", "Weight", "Gain", "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 Quantity and Price, one-line elsewhere. + -- 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], qtyparts, [unitcostcell], costparts, priceparts, valueparts, [weightcell], [gaincell], rgainparts, [xirrcell]] + rowCellParts r = [[datecell], [agecell], unitparts, [unitcostcell], costparts, priceparts, valueparts, [weightcell], [gaincell], rgainparts, [xirrcell]] where acct = prrFullName r (priceparts, valueparts, gaincell, weightcell) = case rowValuation r of @@ -494,7 +494,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, (datecell, agecell) = case dates of [Just dt] -> (showDate dt, showage $ diffDays reportdate dt) _ -> ("", "") - qtyparts = map (showamt . styleAmounts styles) $ rowQtyAmounts r + unitparts = map (showamt . styleAmounts styles) $ rowUnitAmounts r costs = rowLotCosts r costparts = map showamt $ amounts $ mixed costs unitcostcell = case (rowlots, costs) of @@ -523,7 +523,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, mtotalrowparts where -- per-column css classes, so the html cells can be styled - colclasses = ["account","date","age","quantity","unitcost","cost","price","value","weight","gain","rgain","xirr"] + colclasses = ["account","date","age","units","unitcost","cost","price","value","weight","gain","rgain","xirr"] -- which of the other columns' cell parts are amounts amountcols = [False, False, True, True, True, True, True, False, True, True, False] hcell cls t = plain <$> (headerCell t){Ods.cellClass = Ods.Class cls} @@ -552,12 +552,12 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, -- Machine-readable records, one per displayed row and commodity, -- for the csv/tsv/json output: with full account names, age in days, - -- bare quantity and gain percent numbers, and gain and gain percent + -- 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 $ rowQtyAmounts r + rowrecords r = map rec $ rowUnitAmounts r where acct = prrFullName r rec qa = Holding @@ -565,7 +565,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, , hCommodity = c , hDate = mdate , hAge = diffDays reportdate <$> mdate - , hQuantity = styleAmounts styles qa + , hUnits = styleAmounts styles qa , hUnitCost = mucoststr , hCost = coststr , hPrice = mpricestr @@ -621,7 +621,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, csvoutput :: CSV csvoutput = - ["account","commodity","date","age","quantity","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr"] + ["account","commodity","date","age","units","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr"] : map holdingCsv holdingrecords -- Grand totals row (as cell parts, like rowCellParts): the Cost, @@ -649,7 +649,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, showxirr <$> xirrOf (concatMap (\b -> flowsUnder b Nothing) topbases) tv showamt = T.pack . showAmountWith noCostFmt - -- An average cost: total cost / total quantity, showing significant + -- 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 diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 344e39a78e9..0823df4c6ca 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -40,7 +40,7 @@ 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 quantity held, +the units held, the unit cost (or average cost, on rows aggregating multiple lots), the total cost basis, the current market price, the market value, the percentage of the portfolio's total value (Weight), @@ -69,7 +69,7 @@ Amounts are displayed with their commodity's display precision 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 quantity and gain percent numbers, gain and gain percent as +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. @@ -78,7 +78,7 @@ 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`, `quantity`, `unitcost`, +naming its column (`account`, `date`, `age`, `units`, `unitcost`, `cost`, `price`, `value`, `gain`; 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). @@ -87,6 +87,6 @@ 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; quantities and gain percents are +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 index fd3bc081959..693f492919b 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -35,16 +35,16 @@ as usual. With -S/--sort-amount, rows are sorted by market value (or by cost, when unpriced), largest first. 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 quantity held, the -unit cost (or average cost, on rows aggregating multiple lots), the -total cost basis, the current market price, the market value, the -percentage of the portfolio's total value (Weight), the unrealised gain -(absolute and percent), 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). +44d or 1.1y, approximating years as 365 days), the units held, the unit +cost (or average cost, on rows aggregating multiple lots), the total +cost basis, the current market price, the market value, the percentage +of the portfolio's total value (Weight), the unrealised gain (absolute +and percent), 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 @@ -61,7 +61,7 @@ rounding strategy. 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 -quantity and gain percent numbers, gain and gain percent as separate +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 @@ -69,8 +69,8 @@ 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, quantity, unitcost, cost, price, value, -gain; totals row cells also have coltotal), and each commodity amount is +column (account, date, age, units, unitcost, cost, price, value, gain; +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). @@ -78,5 +78,5 @@ 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; quantities and gain percents are JSON number +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.m4.md b/hledger/hledger.m4.md index d9ea3191aae..57d0b8d7bc8 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -7142,17 +7142,17 @@ $ hledger bal assets:stocks --lots -N 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 - -quantities, cost basis, current value and unrealised gain: +units held, cost basis, current value and unrealised gain: ```cli $ hledger holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR -===============++================================================================================================ - assets:stocks || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 100.0% $110 (+44.0%) $100 1865.3% ----------------++------------------------------------------------------------------------------------------------ - || $250 $360 100.0% $110 (+44.0%) $100 1865.3% + || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR +===============++============================================================================================== + assets:stocks || 2026-01-15 75d 5 AAPL $50 $250 $72 $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 - @@ -7666,12 +7666,12 @@ unrealised gain): $ hledger holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Quantity Unit cost Cost Price Value Weight Gain 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 + || Date Age Units Unit cost Cost Price Value Weight Gain 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. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index 29381aeb2a2..e9202950d9f 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -32,34 +32,34 @@ commodity MSFT ; lots: $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR -======================++===================================================================================== - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 - assets:broker:stocks || 15 AAPL $56.67 $850 $100 -----------------------++------------------------------------------------------------------------------------- - || $2850 $100 + || Date Age Units Avg cost Cost Price Value Weight Gain 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 Quantity Unit cost Cost Price Value Weight Gain 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 + || Date Age Units Unit cost Cost Price Value Weight Gain 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 Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR -======================++============================================================================== - assets:broker:stocks || 15 AAPL $56.67 $850 $100 -----------------------++------------------------------------------------------------------------------ - || $850 $100 + || Date Age Units Avg cost Cost Price Value Weight Gain 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 @@ -68,47 +68,47 @@ Holdings on 2026-03-31 $ hledger -f- holdings -e 2026-04-01 --lots --tree Holdings on 2026-03-31 - || Date Age Quantity Unit cost Cost Price Value Weight Gain 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 + || Date Age Units Unit cost Cost Price Value Weight Gain 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 Quantity Unit cost Cost Price Value Weight Gain 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 + || Date Age Units Unit cost Cost Price Value Weight Gain 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 Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR -===============++=============================================================================== - assets:broker || 15 AAPL $2850 $100 - || 5 MSFT ----------------++------------------------------------------------------------------------------- - || $2850 $100 + || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR +===============++============================================================================== + assets:broker || 15 AAPL $2850 $100 + || 5 MSFT +---------------++------------------------------------------------------------------------------ + || $2850 $100 # ** 7. A journal with no lots reports no holdings. < @@ -144,7 +144,7 @@ commodity GOLD ; lots: $ hledger -f- holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Quantity Unit cost Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Unit cost Cost Price Value Weight Gain 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 @@ -156,7 +156,7 @@ Holdings on 2026-03-31 $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR =============++============================================================================================ assets:fund || 16.993 FUND $1859.59 $31600.00 assets:gold || 2026-03-01 30d 1.5 GOLD $2000.22 $3000.33 @@ -179,7 +179,7 @@ commodity AAAA ; lots: $ hledger -f- holdings -e 2026-02-01 --lots Holdings on 2026-01-31 - || Date Age Quantity Unit cost Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Unit cost Cost Price Value Weight Gain 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 @@ -191,7 +191,7 @@ Holdings on 2026-01-31 $ hledger -f- holdings -e 2026-02-01 --lots --round=none Holdings on 2026-01-31 - || Date Age Quantity Unit cost Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Unit cost Cost Price Value Weight Gain 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 @@ -231,24 +231,24 @@ P 2026-03-31 MSFT $410 $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR -======================++================================================================================================ - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 (+2.5%) 22.7% - assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 (+27.1%) $100 419.4% -----------------------++------------------------------------------------------------------------------------------------ - || $2850 $3130 100.0% $280 (+9.8%) $100 137.8% + || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR +======================++=============================================================================================== + assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 (+2.5%) 22.7% + assets:broker:stocks || 15 AAPL $56.67 $850 $72 $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 Quantity Unit cost Cost Price Value Weight Gain Rgain XIRR -========================================++================================================================================================= - assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 (+2.5%) 22.7% - assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 (+44.0%) $100 759.2% - assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 (+20.0%) 215.2% -----------------------------------------++------------------------------------------------------------------------------------------------- - || $2850 $3130 100.0% $280 (+9.8%) $100 137.8% + || Date Age Units Unit cost Cost Price Value Weight Gain Rgain XIRR +========================================++================================================================================================ + assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 (+2.5%) 22.7% + assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 (+44.0%) $100 759.2% + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $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. @@ -266,7 +266,7 @@ commodity AAAA ; lots: $ hledger -f- holdings -e 2026-02-01 --lots --infer-market-prices Holdings on 2026-01-31 - || Date Age Quantity Unit cost Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Unit cost Cost Price Value Weight Gain 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 $60 $12 $60 80.2% $0 (+0.0%) 0.0% @@ -292,21 +292,21 @@ P 2026-03-31 $ €0.9123 $ hledger -f- holdings -e 2026-04-01 -X € Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR -===============++====================================================================================================== - assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €656.86 100.0% €200.71 (+44.0%) ----------------++------------------------------------------------------------------------------------------------------ - || €456.15 €656.86 100.0% €200.71 (+44.0%) + || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR +===============++===================================================================================================== + assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €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 Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR -===============++=============================================================================================== - assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 100.0% $220 (+44.0%) 490.5% ----------------++----------------------------------------------------------------------------------------------- - || $500 $720 100.0% $220 (+44.0%) 490.5% + || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR +===============++============================================================================================== + assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $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 @@ -334,18 +334,18 @@ P 2026-03-31 MSFT $410 $ hledger -f- holdings -e 2026-04-01 -S Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR -===============++================================================================================================ - assets:msoft || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 74.0% $50 (+2.5%) 22.7% - assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 26.0% $220 (+44.0%) 490.5% ----------------++------------------------------------------------------------------------------------------------ - || $2500 $2770 100.0% $270 (+10.8%) 110.4% + || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR +===============++=============================================================================================== + assets:msoft || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 74.0% $50 (+2.5%) 22.7% + assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $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","quantity","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr" +"account","commodity","date","age","units","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr" "assets:apples","AAPL","2026-01-15","75","10","$50","$500","$72","$720","26.0","$220","44.0","","490.5" "assets:msoft","MSFT","2026-02-15","44","5","$400","$2000","$410","$2050","74.0","$50","2.5","","22.7" @@ -362,13 +362,13 @@ commodity 1.000,00 € P 2026-03-31 AAAA 3,10 € $ hledger -f- holdings -e 2026-04-01 -O csv -"account","commodity","date","age","quantity","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr" +"account","commodity","date","age","units","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr" "assets:stocks","AAAA","2026-01-15","75","1000,5","2,25 €","2251,12 €","3,10 €","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>/ +> /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 @@ -389,11 +389,11 @@ commodity AAPL ; lots: $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR -========++===================================================================================== - assets || 2020-01-15 6.2y 10 AAPL $50 $500 ---------++------------------------------------------------------------------------------------- - || $500 + || Date Age Units Avg cost Cost Price Value Weight Gain 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 @@ -416,18 +416,18 @@ commodity AAPL ; lots: $ hledger -f- holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Quantity Unit cost Cost Price Value Weight Gain Rgain XIRR -=================================++===================================================================================== - assets:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 ----------------------------------++------------------------------------------------------------------------------------- - || $600 $200 + || Date Age Units Unit cost Cost Price Value Weight Gain 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 Quantity Avg cost Cost Price Value Weight Gain Rgain XIRR -========++==================================================================================== - stocks || 2026-02-01 58d 10 AAPL $60 $600 $200 ---------++------------------------------------------------------------------------------------ - || $600 $200 + || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR +========++=================================================================================== + stocks || 2026-02-01 58d 10 AAPL $60 $600 $200 +--------++----------------------------------------------------------------------------------- + || $600 $200 From e7a24be2384cb772ab4847b0f6e8bea518a6eb37 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 17:56:29 +0100 Subject: [PATCH 19/26] doc: roi, lots: examples comparing these reports; interop advice - Document in Lot reporting > Recording gains and Commands > roi that on lots journals, roi's --pnl should match both generated gain accounts (eg --pnl 'revenues:gain|equity:unrealised-gain'), so that the unrealised-gain postings aren't counted as cash flows. - Add examples/lots/irr.journal, a minimal roi vs holdings comparison, and append detailed comparison notes to it and lot-entries.journal. - Roi.txt regenerated; also picks up some pending list reformatting. AI usage: Claude Fable 5, ~30k output tokens --- examples/lots/irr.journal | 65 +++++++++++++++++++ examples/lots/lot-entries.journal | 96 ++++++++++++++++++++++++++++ hledger/Hledger/Cli/Commands/Roi.md | 9 +++ hledger/Hledger/Cli/Commands/Roi.txt | 72 ++++++++++++--------- hledger/hledger.m4.md | 12 ++++ 5 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 examples/lots/irr.journal diff --git a/examples/lots/irr.journal b/examples/lots/irr.journal new file mode 100644 index 00000000000..fe151d1c812 --- /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 Cost Price Value Weight Gain Rgain XIRR +===============++============================================================================================== + assets:stocks || 2026-01-15 76d 5 AAPL $50 $250 $72 $360 100.0% $110 (+44.0%) $100 1809.1% + +1. roi's PnL $210 = holdings' Gain $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 index eb9a51e9b8e..b4360e4cb6c 100644 --- a/examples/lots/lot-entries.journal +++ b/examples/lots/lot-entries.journal @@ -161,3 +161,99 @@ account revenue:gains ; type: G ; market value and unrealised gain. Eg try: ; hledger -f lot-entries.journal holdings P 2026-05-01 ETSY $95 + + +comment + +############################################################################### +Comparing roi and holdings with this journal. + +$ hledger -f lot-entries.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 lot-entries.journal holdings -e 2026-04-01 --infer-market-prices +Holdings on 2026-03-31 + + || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR +================++=============================================================================================== + assets:broker || 2025-04-01 364d 0.1 ETSY $80 $8 $100 $10 2.0% $2 (+25.0%) $196 17.9% + assets:broker2 || 2026-01-01 89d 5.0 ETSY $90 $450 $100 $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 Gain $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/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.m4.md b/hledger/hledger.m4.md index 57d0b8d7bc8..dbd6b31d9ff 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -7584,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) From caa0db23d7b69d2d0f2d8a00a413a10e112bf469 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Tue, 4 Aug 2026 18:17:31 +0100 Subject: [PATCH 20/26] dev: fix missing embedded file declaration --- hledger/hledger.cabal | 1 + hledger/package.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/hledger/hledger.cabal b/hledger/hledger.cabal index 0449e9c41f7..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 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 From cd570ebe94c9362194ed6ed8bbb07efd851a9a9b Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Wed, 5 Aug 2026 06:36:59 +0100 Subject: [PATCH 21/26] imp: holdings: split the Gain column into UGain and UGain%; rename Rgain In the table outputs, the combined "$110 (+44.0%)" gain cell is now two columns, UGain and UGain%, and Rgain is renamed to RGain. The html css classes are now ugain, ugainpct, rgain, and for consistency the csv/tsv/json field names gain and gainpct are renamed to ugain and ugainpct. Also regenerate the spec's layout mockups as real outputs showing all current columns, and update the docs and examples. AI usage: Claude Fable 5, ~35k output tokens --- doc/SPEC-holdings.md | 97 ++++----- examples/lots/irr.journal | 6 +- examples/lots/lot-entries.journal | 26 +-- hledger/Hledger/Cli/Commands/Holdings.hs | 64 +++--- hledger/Hledger/Cli/Commands/Holdings.md | 11 +- hledger/Hledger/Cli/Commands/Holdings.txt | 24 +-- hledger/hledger.m4.md | 16 +- hledger/test/holdings.test | 232 +++++++++++----------- 8 files changed, 241 insertions(+), 235 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 209dba6ba9c..eaba060d22a 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -7,7 +7,7 @@ See also - SPEC-lots.md - hledger manual: Lot reporting -Status: phase 1, layout mockup. +Status: implemented; see Phases below. ## Goal @@ -43,8 +43,9 @@ json...). | Price | current market price per unit | | Value | current market value (Units x Price) | | Weight | percentage of the portfolio's total value | -| Gain | unrealised gain: Value - Cost, absolute and percent | -| Rgain | realised gain from disposals so far | +| 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: @@ -59,10 +60,10 @@ Notes: 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 +- 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 + 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. @@ -74,37 +75,37 @@ Notes: (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 Gain columns. -- Rows with no known market price show blank Price, Value and Gain, + 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 totals row (shown unless -N) shows only the commodity-independent - columns: Cost, Value, Weight (100%), Gain, Rgain, XIRR. + 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 Gain = Value - Cost is meaningful); otherwise in the default valuation + (so UGain = Value - Cost is meaningful); otherwise in the default valuation commodity. -- Gain (absolute and percent) is shown when the value and cost are in a +- 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 Gain) are then also converted to the valuation + 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 Gain blank). + 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 mockups +## Layout examples Scenario: two AAPL buys in assets:broker:stocks, one MSFT buy in -assets:broker:funds, a FIFO sale of 5 AAPL, and P directives +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): @@ -113,16 +114,18 @@ Default (list mode, lot subaccounts hidden): $ hledger holdings Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Gain -======================++======================================================================== - assets:broker:funds || 2026-02-15 44d 5 MSFT $400.00 $2000 $410 $2050 $50 (+2.5%) - assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 $230 (+27.1%) -----------------------++------------------------------------------------------------------------ - || $2850 $3130 $280 (+9.8%) + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR +======================++=============================================================================================== + assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 +2.5% 22.7% + assets:broker:stocks || 15 AAPL $56.67 $850 $72 $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.) +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): @@ -130,46 +133,48 @@ 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 Cost Price Value Gain -========================================++========================================================================= - assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) - assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 $110 (+44.0%) - assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 $120 (+20.0%) -----------------------------------------++------------------------------------------------------------------------- - || $2850 $3130 $280 (+9.8%) + || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR +========================================++================================================================================================ + assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 +2.5% 22.7% + assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 +44.0% $100 759.2% + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $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): +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 Cost Price Value Gain -==========================++========================================================================= - assets || 15 AAPL $2850 $3130 $280 (+9.8%) - || 5 MSFT - broker || 15 AAPL $2850 $3130 $280 (+9.8%) - || 5 MSFT - funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) - {2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 $50 (+2.5%) - stocks || 15 AAPL $56.67 $850 $72 $1080 $230 (+27.1%) - {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 $110 (+44.0%) - {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 $120 (+20.0%) + || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR +==============================++================================================================================================ + assets || 15 AAPL $2850 $72 $3130 100.0% $280 +9.8% $100 137.8% + || 5 MSFT $410 + broker || 15 AAPL $2850 $72 $3130 100.0% $280 +9.8% $100 137.8% + || 5 MSFT $410 + funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 +2.5% 22.7% + stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 +27.1% $100 419.4% + {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 +44.0% $100 759.2% + {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $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; per-unit and per-lot columns blank -where meaningless): +With `--depth 2` (aggregation up the tree): ``` $ hledger holdings --depth 2 Holdings on 2026-03-31 - || Units Cost Value Gain -================++===================================== - assets:broker || 15 AAPL $2850 $3130 $280 (+9.8%) - || 5 MSFT + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR +===============++========================================================================================= + assets:broker || 15 AAPL $2850 $72 $3130 100.0% $280 +9.8% $100 137.8% + || 5 MSFT $410 +---------------++----------------------------------------------------------------------------------------- + || $2850 $3130 100.0% $280 +9.8% $100 137.8% ``` ## Implementation notes diff --git a/examples/lots/irr.journal b/examples/lots/irr.journal index fe151d1c812..f1cced7f37e 100644 --- a/examples/lots/irr.journal +++ b/examples/lots/irr.journal @@ -28,11 +28,11 @@ $ hledger -f examples/lots/irr.journal roi --inv assets:stocks --pnl 'revenues:g $ hledger -f examples/lots/irr.journal holdings -N -e 2026-04-02 Holdings on 2026-04-01 - || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ===============++============================================================================================== - assets:stocks || 2026-01-15 76d 5 AAPL $50 $250 $72 $360 100.0% $110 (+44.0%) $100 1809.1% + assets:stocks || 2026-01-15 76d 5 AAPL $50 $250 $72 $360 100.0% $110 +44.0% $100 1809.1% -1. roi's PnL $210 = holdings' Gain $110 + Rgain $100. +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 diff --git a/examples/lots/lot-entries.journal b/examples/lots/lot-entries.journal index b4360e4cb6c..99361ee5f3d 100644 --- a/examples/lots/lot-entries.journal +++ b/examples/lots/lot-entries.journal @@ -178,21 +178,21 @@ $ hledger -f lot-entries.journal roi --inv assets:broker --pnl 'revenue:gains|eq $ hledger -f lot-entries.journal holdings -e 2026-04-01 --infer-market-prices Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR -================++=============================================================================================== - assets:broker || 2025-04-01 364d 0.1 ETSY $80 $8 $100 $10 2.0% $2 (+25.0%) $196 17.9% - assets:broker2 || 2026-01-01 89d 5.0 ETSY $90 $450 $100 $500 98.0% $50 (+11.1%) $498 144.2% -----------------++----------------------------------------------------------------------------------------------- - || $458 $510 100.0% $52 (+11.4%) $694 47.8% + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR +================++================================================================================================ + assets:broker || 2025-04-01 364d 0.1 ETSY $80 $8 $100 $10 2.0% $2 +25.0% $196 17.9% + assets:broker2 || 2026-01-01 89d 5.0 ETSY $90 $450 $100 $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 Gain $52 + Rgain $694 = $746 PnL $755 (~$9 apart) -Return XIRR 47.8% IRR 48.55% (0.75pp apart) +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, @@ -232,11 +232,11 @@ 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: +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 +"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 diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 5f871bcf89a..6194bb4991f 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -4,8 +4,8 @@ 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, Cost, Price, -Value and Gain columns in list mode, with lot subaccounts aggregated by -default or shown as rows with --lots. +Value, Weight, UGain, UGain%, RGain and XIRR columns, with lot +subaccounts aggregated by default or shown as rows with --lots. -} @@ -86,8 +86,8 @@ data Holding = Holding { ,hPrice :: Maybe T.Text -- ^ market price at the valuation date ,hValue :: Maybe T.Text -- ^ market value ,hWeight :: Maybe Quantity -- ^ percentage of the portfolio's value, rounded to 1 decimal - ,hGain :: Maybe T.Text -- ^ unrealised gain - ,hGainPct :: Maybe Quantity -- ^ unrealised gain percent, 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 } @@ -104,8 +104,8 @@ holdingCsv h = ,fromMaybe "" (hPrice h) ,fromMaybe "" (hValue h) ,maybe "" (T.pack . show) (hWeight h) - ,fromMaybe "" (hGain h) - ,maybe "" (T.pack . show) (hGainPct h) + ,fromMaybe "" (hUgain h) + ,maybe "" (T.pack . show) (hUgainPct h) ,fromMaybe "" (hRgain h) ,maybe "" (T.pack . printf "%.1f") (hXirr h) ] @@ -122,8 +122,8 @@ holdingJson h = object ,"price" .= hPrice h ,"value" .= hValue h ,"weight" .= hWeight h - ,"gain" .= hGain h - ,"gainpct" .= hGainPct h + ,"ugain" .= hUgain h + ,"ugainpct" .= hUgainPct h ,"rgain" .= hRgain h ,"xirr" .= hXirr h ] @@ -300,8 +300,8 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, 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 Gain) for display when -V/-X/--value is in effect: + -- 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 @@ -357,7 +357,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, _ -> Nothing -- The distinct base accounts of the displayed rows (excluding any - -- contained in another). Account-level totals (Rgain, XIRR) are + -- 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] @@ -374,18 +374,18 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, guard $ acommodity v == acommodity t && aquantity t /= 0 Just $ roundTo 1 $ 100 * aquantity v / aquantity t - -- Render a gain (and percent gain) from single-commodity value and - -- cost amounts, if their commodities match. - showgain :: [Amount] -> [Amount] -> T.Text + -- 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 + (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 = printf " (%+.1f%%)" (realToFrac (100 * gain / aquantity c) :: Double) + pct | aquantity c /= 0 = T.pack $ printf "%+.1f%%" (realToFrac (100 * gain / aquantity c) :: Double) | otherwise = "" - showgain _ _ = "" + 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 @@ -456,7 +456,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, 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", "Cost", "Price", "Value", "Weight", "Gain", "Rgain", "XIRR"] + colheadings = ["Date", "Age", "Units", if showlots then "Unit cost" else "Avg cost", "Cost", "Price", "Value", "Weight", "UGain", "UGain%", "RGain", "XIRR"] renderacct r = T.replicate (prrIndent r * 2) " " <> prrDisplayName r rowLotCosts r = [rowCostValuer r $ multiplyAmount (aquantity a) c @@ -465,16 +465,16 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, -- 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", ", ", ", ", ", ", ", ", ", "] + 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], costparts, priceparts, valueparts, [weightcell], [gaincell], rgainparts, [xirrcell]] + rowCellParts r = [[datecell], [agecell], unitparts, [unitcostcell], costparts, priceparts, valueparts, [weightcell], [ugaincell], [ugainpctcell], rgainparts, [xirrcell]] where acct = prrFullName r - (priceparts, valueparts, gaincell, weightcell) = case rowValuation r of - Nothing -> ([], [], "", "") + (priceparts, valueparts, (ugaincell, ugainpctcell), weightcell) = case rowValuation r of + Nothing -> ([], [], ("", ""), "") Just (prices, val) -> ( map showamt prices , map showamt $ amounts val @@ -523,9 +523,9 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, mtotalrowparts where -- per-column css classes, so the html cells can be styled - colclasses = ["account","date","age","units","unitcost","cost","price","value","weight","gain","rgain","xirr"] + colclasses = ["account","date","age","units","unitcost","cost","price","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, True, False] + 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 @@ -571,8 +571,8 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, , hPrice = mpricestr , hValue = mvalstr , hWeight = mweight - , hGain = mgainstr - , hGainPct = mpct + , hUgain = mgainstr + , hUgainPct = mpct , hRgain = mrgainstr , hXirr = mxirr } @@ -621,22 +621,22 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, csvoutput :: CSV csvoutput = - ["account","commodity","date","age","units","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr"] + ["account","commodity","date","age","units","unitcost","cost","price","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 + -- Value and gain columns, summed over the topmost displayed rows -- (which include everything below them). - -- Value and Gain are blank unless all rows have a market price. + -- 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], [gaincell], rgainparts, [xirrcell]] + | otherwise = Just [[], [], [], [], costparts, [], valueparts, [weightcell], [ugaincell], [ugainpctcell], rgainparts, [xirrcell]] where totcosts = concatMap rowLotCosts toprows costparts = map showamt $ amounts $ mixed totcosts - (valueparts, weightcell, gaincell) = case mportfoliovalue of - Nothing -> ([], "", "") + (valueparts, weightcell, (ugaincell, ugainpctcell)) = case mportfoliovalue of + Nothing -> ([], "", ("", "")) Just totvalue -> ( map showamt $ amounts totvalue , maybe "" showpct $ weightPct totvalue , showgain (amounts totvalue) (amounts $ mixed totcosts)) diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 0823df4c6ca..f0ba545381d 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -44,19 +44,19 @@ the units held, the unit cost (or average cost, on rows aggregating multiple lots), the total cost basis, the current market price, the market value, the percentage of the portfolio's total value (Weight), -the unrealised gain (absolute and percent), -the realised gain from disposals so far (Rgain), +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 +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. +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 @@ -79,7 +79,8 @@ 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`, -`cost`, `price`, `value`, `gain`; totals row cells also have +`cost`, `price`, `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). diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index 693f492919b..b1933d53549 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -38,18 +38,18 @@ 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 total cost basis, the current market price, the market value, the percentage -of the portfolio's total value (Weight), the unrealised gain (absolute -and percent), 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). +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. +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 @@ -69,10 +69,10 @@ 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, cost, price, value, gain; -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). +column (account, date, age, units, unitcost, cost, price, 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. diff --git a/hledger/hledger.m4.md b/hledger/hledger.m4.md index dbd6b31d9ff..dafdefb196a 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -7148,11 +7148,11 @@ units held, cost basis, current value and unrealised gain: $ hledger holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ===============++============================================================================================== - assets:stocks || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 100.0% $110 (+44.0%) $100 1865.3% + assets:stocks || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 100.0% $110 +44.0% $100 1865.3% ---------------++---------------------------------------------------------------------------------------------- - || $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 - @@ -7678,12 +7678,12 @@ unrealised gain): $ hledger holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Units Unit cost Cost Price Value Weight Gain Rgain XIRR -=================================++==================================================================================== - assets:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $100 + || Date Age Units Unit cost Cost Price 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 +---------------------------------++--------------------------------------------------------------------------------------------- + || $850 $100 ``` and `print -x --lots` shows the inferred lot subaccounts and gain postings. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index e9202950d9f..e9c34a597a3 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -32,34 +32,34 @@ commodity MSFT ; lots: $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR -======================++==================================================================================== - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 - assets:broker:stocks || 15 AAPL $56.67 $850 $100 -----------------------++------------------------------------------------------------------------------------ - || $2850 $100 + || Date Age Units Avg cost Cost Price 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 Cost Price Value Weight Gain 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 + || Date Age Units Unit cost Cost Price 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 Cost Price Value Weight Gain Rgain XIRR -======================++============================================================================= - assets:broker:stocks || 15 AAPL $56.67 $850 $100 -----------------------++----------------------------------------------------------------------------- - || $850 $100 + || Date Age Units Avg cost Cost Price 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 @@ -68,47 +68,47 @@ Holdings on 2026-03-31 $ hledger -f- holdings -e 2026-04-01 --lots --tree Holdings on 2026-03-31 - || Date Age Units Unit cost Cost Price Value Weight Gain 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 + || Date Age Units Unit cost Cost Price 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 Cost Price Value Weight Gain 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 + || Date Age Units Unit cost Cost Price 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 Cost Price Value Weight Gain Rgain XIRR -===============++============================================================================== - assets:broker || 15 AAPL $2850 $100 - || 5 MSFT ----------------++------------------------------------------------------------------------------ - || $2850 $100 + || Date Age Units Avg cost Cost Price 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. < @@ -144,24 +144,24 @@ commodity GOLD ; lots: $ hledger -f- holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Units Unit cost Cost Price Value Weight Gain 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 + || Date Age Units Unit cost Cost Price 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 Cost Price Value Weight Gain 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 + || Date Age Units Avg cost Cost Price 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). @@ -179,24 +179,24 @@ commodity AAAA ; lots: $ hledger -f- holdings -e 2026-02-01 --lots Holdings on 2026-01-31 - || Date Age Units Unit cost Cost Price Value Weight Gain 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 + || Date Age Units Unit cost Cost Price 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 Cost Price Value Weight Gain 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 + || Date Age Units Unit cost Cost Price 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 @@ -231,24 +231,24 @@ P 2026-03-31 MSFT $410 $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ======================++=============================================================================================== - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 (+2.5%) 22.7% - assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 (+27.1%) $100 419.4% + assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 +2.5% 22.7% + assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 +27.1% $100 419.4% ----------------------++----------------------------------------------------------------------------------------------- - || $2850 $3130 100.0% $280 (+9.8%) $100 137.8% + || $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 Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR ========================================++================================================================================================ - assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 (+2.5%) 22.7% - assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 (+44.0%) $100 759.2% - assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 (+20.0%) 215.2% + assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 +2.5% 22.7% + assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 +44.0% $100 759.2% + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 +20.0% 215.2% ----------------------------------------++------------------------------------------------------------------------------------------------ - || $2850 $3130 100.0% $280 (+9.8%) $100 137.8% + || $2850 $3130 100.0% $280 +9.8% $100 137.8% # ** 14. With --infer-market-prices, transaction costs also provide # market prices, as usual. @@ -266,12 +266,12 @@ commodity AAAA ; lots: $ hledger -f- holdings -e 2026-02-01 --lots --infer-market-prices Holdings on 2026-01-31 - || Date Age Units Unit cost Cost Price Value Weight Gain 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 $60 $12 $60 80.2% $0 (+0.0%) 0.0% ---------------------------++------------------------------------------------------------------------------------------------ - || $72 $75 100.0% $2 (+3.4%) 52.2% + || Date Age Units Unit cost Cost Price 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 $60 $12 $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: @@ -292,21 +292,21 @@ P 2026-03-31 $ €0.9123 $ hledger -f- holdings -e 2026-04-01 -X € Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR -===============++===================================================================================================== - assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €656.86 100.0% €200.71 (+44.0%) ----------------++----------------------------------------------------------------------------------------------------- - || €456.15 €656.86 100.0% €200.71 (+44.0%) + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR +===============++==================================================================================================== + assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €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 Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ===============++============================================================================================== - assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 100.0% $220 (+44.0%) 490.5% + assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 100.0% $220 +44.0% 490.5% ---------------++---------------------------------------------------------------------------------------------- - || $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 @@ -334,18 +334,18 @@ P 2026-03-31 MSFT $410 $ hledger -f- holdings -e 2026-04-01 -S Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ===============++=============================================================================================== - assets:msoft || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 74.0% $50 (+2.5%) 22.7% - assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 26.0% $220 (+44.0%) 490.5% + assets:msoft || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 74.0% $50 +2.5% 22.7% + assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 26.0% $220 +44.0% 490.5% ---------------++----------------------------------------------------------------------------------------------- - || $2500 $2770 100.0% $270 (+10.8%) 110.4% + || $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","cost","price","value","weight","gain","gainpct","rgain","xirr" +"account","commodity","date","age","units","unitcost","cost","price","value","weight","ugain","ugainpct","rgain","xirr" "assets:apples","AAPL","2026-01-15","75","10","$50","$500","$72","$720","26.0","$220","44.0","","490.5" "assets:msoft","MSFT","2026-02-15","44","5","$400","$2000","$410","$2050","74.0","$50","2.5","","22.7" @@ -362,7 +362,7 @@ commodity 1.000,00 € P 2026-03-31 AAAA 3,10 € $ hledger -f- holdings -e 2026-04-01 -O csv -"account","commodity","date","age","units","unitcost","cost","price","value","weight","gain","gainpct","rgain","xirr" +"account","commodity","date","age","units","unitcost","cost","price","value","weight","ugain","ugainpct","rgain","xirr" "assets:stocks","AAAA","2026-01-15","75","1000,5","2,25 €","2251,12 €","3,10 €","3101,55 €","100.0","850,42 €","37.8","","376.2" # ** 21. HTML output: an HTML table like the text table, with single-line @@ -389,11 +389,11 @@ commodity AAPL ; lots: $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight Gain Rgain XIRR -========++==================================================================================== - assets || 2020-01-15 6.2y 10 AAPL $50 $500 ---------++------------------------------------------------------------------------------------ - || $500 + || Date Age Units Avg cost Cost Price 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 @@ -416,18 +416,18 @@ commodity AAPL ; lots: $ hledger -f- holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Units Unit cost Cost Price Value Weight Gain Rgain XIRR -=================================++==================================================================================== - assets:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 ----------------------------------++------------------------------------------------------------------------------------ - || $600 $200 + || Date Age Units Unit cost Cost Price 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 Cost Price Value Weight Gain Rgain XIRR -========++=================================================================================== - stocks || 2026-02-01 58d 10 AAPL $60 $600 $200 ---------++----------------------------------------------------------------------------------- - || $600 $200 + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR +========++============================================================================================ + stocks || 2026-02-01 58d 10 AAPL $60 $600 $200 +--------++-------------------------------------------------------------------------------------------- + || $600 $200 From 3922e67e2f245bc05312bbcaced3bc7fb01c5005 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Wed, 5 Aug 2026 07:15:49 +0100 Subject: [PATCH 22/26] imp: holdings: percent columns are stylable via the % commodity The Weight, UGain% and XIRR columns are now rendered as "%" commodity amounts, so their precision, symbol placement and spacing can be customised by declaring a display style for "%" - with a commodity directive, with -c '0.00 %', or inferred from % amounts in the journal. With no % style, a default is used: one decimal digit and the % sign on the right with no space, as before. The csv/tsv/json outputs keep bare percent numbers. AI usage: Claude Fable 5, ~38k output tokens --- doc/SPEC-holdings.md | 5 +++ hledger/Hledger/Cli/Commands/Holdings.hs | 38 ++++++++++++------ hledger/Hledger/Cli/Commands/Holdings.md | 3 ++ hledger/Hledger/Cli/Commands/Holdings.txt | 4 +- hledger/test/holdings.test | 47 +++++++++++++++++++++++ 5 files changed, 84 insertions(+), 13 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index eaba060d22a..89269848d0b 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -81,6 +81,11 @@ Notes: - 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. diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 6194bb4991f..c7c746c6f4f 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -136,14 +136,6 @@ showage d | d >= 365 = T.pack (show (roundTo 1 (fromIntegral d / 365))) <> "y" | otherwise = T.pack (show d) <> "d" --- | Show a (rounded) percentage: eg 64.3%. -showpct :: Quantity -> T.Text -showpct p = T.pack (show p) <> "%" - --- | Show an XIRR percentage with 1 decimal place: eg 12.3%. -showxirr :: Double -> T.Text -showxirr x = T.pack $ printf "%.1f%%" x - -- | 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). -- @@ -365,14 +357,36 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, 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. Rounded to 1 decimal place. + -- 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 $ roundTo 1 $ 100 * aquantity v / aquantity t + 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} + + -- Like showpct, but always signed: eg +44.0%. + showsignedpct :: Quantity -> T.Text + showsignedpct p = (if p >= 0 then "+" else "") <> showpct 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. @@ -383,7 +397,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, gain = aquantity v - aquantity c gainamt = styleAmounts styles $ amountSetFullPrecisionUpTo Nothing nullamt{acommodity=acommodity v, aquantity=gain} - pct | aquantity c /= 0 = T.pack $ printf "%+.1f%%" (realToFrac (100 * gain / aquantity c) :: Double) + pct | aquantity c /= 0 = showsignedpct $ 100 * gain / aquantity c | otherwise = "" showgain _ _ = ("", "") @@ -603,7 +617,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, case priceoracle (valuationdate, c, mto) of Nothing -> (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing) Just (pcomm, rate) -> (Just $ showamt price, Just $ showamt val, mgainstr', mpct' - ,weightPct $ mixedAmount val + ,roundTo 1 <$> weightPct (mixedAmount val) ,xirrOf (flowsUnder acct (Just c)) val) where mkamt n = styleAmounts styles $ amountSetFullPrecisionUpTo Nothing diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index f0ba545381d..9edc4925998 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -66,6 +66,9 @@ 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, diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index b1933d53549..a9f5c5e2486 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -57,7 +57,9 @@ 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. +rounding strategy. The percent columns (Weight, UGain%, XIRR) are shown +with one decimal digit, or with the display style of the % commodity if +any (eg by a commodity directive or -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 diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index e9c34a597a3..3e8d6f68799 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -431,3 +431,50 @@ Holdings on 2026-03-31 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 Cost Price Value Weight UGain UGain% RGain XIRR +========++==================================================================================================== + assets || 2026-01-15 75d 5 AAPL $50 $250 $72 $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 Cost Price Value Weight UGain UGain% RGain XIRR +========++================================================================================================= + assets || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 100.0 % $220 +44.0 % 490.5 % +--------++------------------------------------------------------------------------------------------------- + || $500 $720 100.0 % $220 +44.0 % 490.5 % From 7811b451601f6901f27eb4a8e559be52d133eee7 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Wed, 5 Aug 2026 07:24:39 +0100 Subject: [PATCH 23/26] imp: holdings: show UGain% unsigned, like the other percent columns AI usage: Claude Fable 5, ~5k output tokens --- doc/SPEC-holdings.md | 32 +++++++------- examples/lots/irr.journal | 2 +- examples/lots/lot-entries.journal | 6 +-- hledger/Hledger/Cli/Commands/Holdings.hs | 6 +-- hledger/hledger.m4.md | 4 +- hledger/test/holdings.test | 54 ++++++++++++------------ 6 files changed, 50 insertions(+), 54 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 89269848d0b..f6212ec0b9c 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -121,10 +121,10 @@ Holdings on 2026-03-31 || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ======================++=============================================================================================== - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 +2.5% 22.7% - assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 +27.1% $100 419.4% + assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 2.5% 22.7% + assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 27.1% $100 419.4% ----------------------++----------------------------------------------------------------------------------------------- - || $2850 $3130 100.0% $280 +9.8% $100 137.8% + || $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 @@ -140,11 +140,11 @@ Holdings on 2026-03-31 || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR ========================================++================================================================================================ - assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 +2.5% 22.7% - assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 +44.0% $100 759.2% - assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 +20.0% 215.2% + assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 2.5% 22.7% + assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 44.0% $100 759.2% + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 20.0% 215.2% ----------------------------------------++------------------------------------------------------------------------------------------------ - || $2850 $3130 100.0% $280 +9.8% $100 137.8% + || $2850 $3130 100.0% $280 9.8% $100 137.8% ``` With `--lots --tree` (parent rows aggregate; multi-commodity cells go @@ -156,16 +156,16 @@ Holdings on 2026-03-31 || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR ==============================++================================================================================================ - assets || 15 AAPL $2850 $72 $3130 100.0% $280 +9.8% $100 137.8% + assets || 15 AAPL $2850 $72 $3130 100.0% $280 9.8% $100 137.8% || 5 MSFT $410 - broker || 15 AAPL $2850 $72 $3130 100.0% $280 +9.8% $100 137.8% + broker || 15 AAPL $2850 $72 $3130 100.0% $280 9.8% $100 137.8% || 5 MSFT $410 - funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 +2.5% 22.7% - stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 +27.1% $100 419.4% - {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 +44.0% $100 759.2% - {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 +20.0% 215.2% + funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 2.5% 22.7% + stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 27.1% $100 419.4% + {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 44.0% $100 759.2% + {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 20.0% 215.2% ------------------------------++------------------------------------------------------------------------------------------------ - || $2850 $3130 100.0% $280 +9.8% $100 137.8% + || $2850 $3130 100.0% $280 9.8% $100 137.8% ``` With `--depth 2` (aggregation up the tree): @@ -176,10 +176,10 @@ Holdings on 2026-03-31 || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ===============++========================================================================================= - assets:broker || 15 AAPL $2850 $72 $3130 100.0% $280 +9.8% $100 137.8% + assets:broker || 15 AAPL $2850 $72 $3130 100.0% $280 9.8% $100 137.8% || 5 MSFT $410 ---------------++----------------------------------------------------------------------------------------- - || $2850 $3130 100.0% $280 +9.8% $100 137.8% + || $2850 $3130 100.0% $280 9.8% $100 137.8% ``` ## Implementation notes diff --git a/examples/lots/irr.journal b/examples/lots/irr.journal index f1cced7f37e..92dbb74ffdf 100644 --- a/examples/lots/irr.journal +++ b/examples/lots/irr.journal @@ -30,7 +30,7 @@ Holdings on 2026-04-01 || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ===============++============================================================================================== - assets:stocks || 2026-01-15 76d 5 AAPL $50 $250 $72 $360 100.0% $110 +44.0% $100 1809.1% + assets:stocks || 2026-01-15 76d 5 AAPL $50 $250 $72 $360 100.0% $110 44.0% $100 1809.1% 1. roi's PnL $210 = holdings' UGain $110 + RGain $100. diff --git a/examples/lots/lot-entries.journal b/examples/lots/lot-entries.journal index 99361ee5f3d..db2f639c83f 100644 --- a/examples/lots/lot-entries.journal +++ b/examples/lots/lot-entries.journal @@ -180,10 +180,10 @@ Holdings on 2026-03-31 || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ================++================================================================================================ - assets:broker || 2025-04-01 364d 0.1 ETSY $80 $8 $100 $10 2.0% $2 +25.0% $196 17.9% - assets:broker2 || 2026-01-01 89d 5.0 ETSY $90 $450 $100 $500 98.0% $50 +11.1% $498 144.2% + assets:broker || 2025-04-01 364d 0.1 ETSY $80 $8 $100 $10 2.0% $2 25.0% $196 17.9% + assets:broker2 || 2026-01-01 89d 5.0 ETSY $90 $450 $100 $500 98.0% $50 11.1% $498 144.2% ----------------++------------------------------------------------------------------------------------------------ - || $458 $510 100.0% $52 +11.4% $694 47.8% + || $458 $510 100.0% $52 11.4% $694 47.8% These agree as closely as the two reports can on this journal: diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index c7c746c6f4f..870577492ea 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -380,10 +380,6 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, T.pack $ showAmountWith noCostFmt{displayZeroCommodity=True} $ styleAmounts pctstyles nullamt{acommodity="%", aquantity=p} - -- Like showpct, but always signed: eg +44.0%. - showsignedpct :: Quantity -> T.Text - showsignedpct p = (if p >= 0 then "+" else "") <> showpct p - -- Show an XIRR percentage, like showpct: eg 12.3%. showxirr :: Double -> T.Text showxirr = showpct . realToFrac @@ -397,7 +393,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, gain = aquantity v - aquantity c gainamt = styleAmounts styles $ amountSetFullPrecisionUpTo Nothing nullamt{acommodity=acommodity v, aquantity=gain} - pct | aquantity c /= 0 = showsignedpct $ 100 * gain / aquantity c + pct | aquantity c /= 0 = showpct $ 100 * gain / aquantity c | otherwise = "" showgain _ _ = ("", "") diff --git a/hledger/hledger.m4.md b/hledger/hledger.m4.md index dafdefb196a..417bb3ceefa 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -7150,9 +7150,9 @@ Holdings on 2026-03-31 || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ===============++============================================================================================== - assets:stocks || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 100.0% $110 +44.0% $100 1865.3% + assets:stocks || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 100.0% $110 44.0% $100 1865.3% ---------------++---------------------------------------------------------------------------------------------- - || $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 - diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index 3e8d6f68799..bd8f49f85c5 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -233,10 +233,10 @@ Holdings on 2026-03-31 || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ======================++=============================================================================================== - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 +2.5% 22.7% - assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 +27.1% $100 419.4% + assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 2.5% 22.7% + assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 27.1% $100 419.4% ----------------------++----------------------------------------------------------------------------------------------- - || $2850 $3130 100.0% $280 +9.8% $100 137.8% + || $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 @@ -244,11 +244,11 @@ Holdings on 2026-03-31 || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR ========================================++================================================================================================ - assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 +2.5% 22.7% - assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 +44.0% $100 759.2% - assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 +20.0% 215.2% + assets:broker:funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 2.5% 22.7% + assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 44.0% $100 759.2% + assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 20.0% 215.2% ----------------------------------------++------------------------------------------------------------------------------------------------ - || $2850 $3130 100.0% $280 +9.8% $100 137.8% + || $2850 $3130 100.0% $280 9.8% $100 137.8% # ** 14. With --infer-market-prices, transaction costs also provide # market prices, as usual. @@ -268,10 +268,10 @@ Holdings on 2026-01-31 || Date Age Units Unit cost Cost Price 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 $60 $12 $60 80.2% $0 +0.0% 0.0% + 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 $60 $12 $60 80.2% $0 0.0% 0.0% --------------------------++-------------------------------------------------------------------------------------------------- - || $72 $75 100.0% $2 +3.4% 52.2% + || $72 $75 100.0% $2 3.4% 52.2% # ** 15. -X/--value=end,COMM value holdings in the given commodity: @@ -294,9 +294,9 @@ Holdings on 2026-03-31 || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ===============++==================================================================================================== - assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €656.86 100.0% €200.71 +44.0% + assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €656.86 100.0% €200.71 44.0% ---------------++---------------------------------------------------------------------------------------------------- - || €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 @@ -304,9 +304,9 @@ Holdings on 2026-03-31 || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ===============++============================================================================================== - assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 100.0% $220 +44.0% 490.5% + assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 100.0% $220 44.0% 490.5% ---------------++---------------------------------------------------------------------------------------------- - || $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 @@ -336,10 +336,10 @@ Holdings on 2026-03-31 || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR ===============++=============================================================================================== - assets:msoft || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 74.0% $50 +2.5% 22.7% - assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 26.0% $220 +44.0% 490.5% + assets:msoft || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 74.0% $50 2.5% 22.7% + assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 26.0% $220 44.0% 490.5% ---------------++----------------------------------------------------------------------------------------------- - || $2500 $2770 100.0% $270 +10.8% 110.4% + || $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 @@ -450,11 +450,11 @@ 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 Cost Price Value Weight UGain UGain% RGain XIRR -========++==================================================================================================== - assets || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 100.00 % $110 +44.00 % $100 1865.32 % ---------++---------------------------------------------------------------------------------------------------- - || $250 $360 100.00 % $110 +44.00 % $100 1865.32 % + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR +========++=================================================================================================== + assets || 2026-01-15 75d 5 AAPL $50 $250 $72 $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). @@ -473,8 +473,8 @@ P 2026-03-31 AAPL $72 $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR -========++================================================================================================= - assets || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 100.0 % $220 +44.0 % 490.5 % ---------++------------------------------------------------------------------------------------------------- - || $500 $720 100.0 % $220 +44.0 % 490.5 % + || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR +========++================================================================================================ + assets || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 100.0 % $220 44.0 % 490.5 % +--------++------------------------------------------------------------------------------------------------ + || $500 $720 100.0 % $220 44.0 % 490.5 % From b3e9bf62ae0a9c5e278ae35bc077d4c30accfc78 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Wed, 5 Aug 2026 07:34:05 +0100 Subject: [PATCH 24/26] imp: holdings: move the Price column before Cost This groups the per-unit columns (Unit/Avg cost, Price) and the total columns (Cost, Value), making the basis-vs-market comparisons easier to scan. The csv/tsv/json field order follows suit. AI usage: Claude Fable 5, ~10k output tokens --- doc/SPEC-holdings.md | 48 +++--- examples/lots/irr.journal | 4 +- examples/lots/lot-entries.journal | 8 +- hledger/Hledger/Cli/Commands/Holdings.hs | 20 +-- hledger/Hledger/Cli/Commands/Holdings.md | 4 +- hledger/Hledger/Cli/Commands/Holdings.txt | 12 +- hledger/hledger.m4.md | 14 +- hledger/test/holdings.test | 182 +++++++++++----------- 8 files changed, 146 insertions(+), 146 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index f6212ec0b9c..62396509ebe 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -39,8 +39,8 @@ json...). | 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) | -| Cost | total cost basis | | 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 | @@ -119,12 +119,12 @@ Default (list mode, lot subaccounts hidden): $ hledger holdings Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ======================++=============================================================================================== - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 2.5% 22.7% - assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 27.1% $100 419.4% + 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% + || $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 @@ -138,13 +138,13 @@ 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 Cost Price Value Weight UGain UGain% RGain XIRR + || 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 $410 $2050 65.5% $50 2.5% 22.7% - assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 44.0% $100 759.2% - assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 20.0% 215.2% + 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% + || $2850 $3130 100.0% $280 9.8% $100 137.8% ``` With `--lots --tree` (parent rows aggregate; multi-commodity cells go @@ -154,18 +154,18 @@ 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 Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR ==============================++================================================================================================ - assets || 15 AAPL $2850 $72 $3130 100.0% $280 9.8% $100 137.8% - || 5 MSFT $410 - broker || 15 AAPL $2850 $72 $3130 100.0% $280 9.8% $100 137.8% - || 5 MSFT $410 - funds:{2026-02-15, $400} || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 2.5% 22.7% - stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 27.1% $100 419.4% - {2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 44.0% $100 759.2% - {2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 20.0% 215.2% + 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% + || $2850 $3130 100.0% $280 9.8% $100 137.8% ``` With `--depth 2` (aggregation up the tree): @@ -174,12 +174,12 @@ With `--depth 2` (aggregation up the tree): $ hledger holdings --depth 2 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ===============++========================================================================================= - assets:broker || 15 AAPL $2850 $72 $3130 100.0% $280 9.8% $100 137.8% - || 5 MSFT $410 + 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% + || $2850 $3130 100.0% $280 9.8% $100 137.8% ``` ## Implementation notes diff --git a/examples/lots/irr.journal b/examples/lots/irr.journal index 92dbb74ffdf..260470c687a 100644 --- a/examples/lots/irr.journal +++ b/examples/lots/irr.journal @@ -28,9 +28,9 @@ $ hledger -f examples/lots/irr.journal roi --inv assets:stocks --pnl 'revenues:g $ hledger -f examples/lots/irr.journal holdings -N -e 2026-04-02 Holdings on 2026-04-01 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ===============++============================================================================================== - assets:stocks || 2026-01-15 76d 5 AAPL $50 $250 $72 $360 100.0% $110 44.0% $100 1809.1% + 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. diff --git a/examples/lots/lot-entries.journal b/examples/lots/lot-entries.journal index db2f639c83f..983bd4d59ac 100644 --- a/examples/lots/lot-entries.journal +++ b/examples/lots/lot-entries.journal @@ -178,12 +178,12 @@ $ hledger -f lot-entries.journal roi --inv assets:broker --pnl 'revenue:gains|eq $ hledger -f lot-entries.journal holdings -e 2026-04-01 --infer-market-prices Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ================++================================================================================================ - assets:broker || 2025-04-01 364d 0.1 ETSY $80 $8 $100 $10 2.0% $2 25.0% $196 17.9% - assets:broker2 || 2026-01-01 89d 5.0 ETSY $90 $450 $100 $500 98.0% $50 11.1% $498 144.2% + 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% + || $458 $510 100.0% $52 11.4% $694 47.8% These agree as closely as the two reports can on this journal: diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 870577492ea..37b70938c3d 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -3,7 +3,7 @@ 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, Cost, Price, +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. @@ -82,8 +82,8 @@ data Holding = Holding { ,hAge :: Maybe Integer -- ^ days held at the report date ,hUnits :: Amount -- ^ units held, styled ,hUnitCost :: Maybe T.Text -- ^ unit or average cost - ,hCost :: T.Text -- ^ total cost basis ,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 @@ -100,8 +100,8 @@ holdingCsv h = ,maybe "" (T.pack . show) (hAge h) ,T.pack $ showAmountWith machineFmt{displayCommodity=False} (hUnits h) ,fromMaybe "" (hUnitCost h) - ,hCost h ,fromMaybe "" (hPrice h) + ,hCost h ,fromMaybe "" (hValue h) ,maybe "" (T.pack . show) (hWeight h) ,fromMaybe "" (hUgain h) @@ -118,8 +118,8 @@ holdingJson h = object ,"age" .= hAge h ,"units" .= aquantity (hUnits h) ,"unitcost" .= hUnitCost h - ,"cost" .= hCost h ,"price" .= hPrice h + ,"cost" .= hCost h ,"value" .= hValue h ,"weight" .= hWeight h ,"ugain" .= hUgain h @@ -466,7 +466,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, 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", "Cost", "Price", "Value", "Weight", "UGain", "UGain%", "RGain", "XIRR"] + 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 @@ -475,12 +475,12 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, -- 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", ", ", ", ", ", ", ", ", ", ", ", "] + 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], costparts, priceparts, valueparts, [weightcell], [ugaincell], [ugainpctcell], rgainparts, [xirrcell]] + 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 @@ -533,7 +533,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, mtotalrowparts where -- per-column css classes, so the html cells can be styled - colclasses = ["account","date","age","units","unitcost","cost","price","value","weight","ugain","ugainpct","rgain","xirr"] + 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} @@ -631,7 +631,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, csvoutput :: CSV csvoutput = - ["account","commodity","date","age","units","unitcost","cost","price","value","weight","ugain","ugainpct","rgain","xirr"] + ["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, @@ -641,7 +641,7 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, mtotalrowparts :: Maybe [[T.Text]] mtotalrowparts | no_total_ ropts = Nothing - | otherwise = Just [[], [], [], [], costparts, [], valueparts, [weightcell], [ugaincell], [ugainpctcell], rgainparts, [xirrcell]] + | otherwise = Just [[], [], [], [], [], costparts, valueparts, [weightcell], [ugaincell], [ugainpctcell], rgainparts, [xirrcell]] where totcosts = concatMap rowLotCosts toprows costparts = map showamt $ amounts $ mixed totcosts diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 9edc4925998..725553aae77 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -42,7 +42,7 @@ 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 total cost basis, the current market price, the market value, +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), @@ -82,7 +82,7 @@ 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`, -`cost`, `price`, `value`, `weight`, `ugain`, `ugainpct`, `rgain`, +`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). diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index a9f5c5e2486..f6d09c11c97 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -36,9 +36,9 @@ cost, when unpriced), largest first. 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 total -cost basis, the current market price, the market value, the percentage -of the portfolio's total value (Weight), the unrealised gain and gain +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 @@ -58,8 +58,8 @@ 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 of the % commodity if -any (eg by a commodity directive or -c '0.00 %'). +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 @@ -71,7 +71,7 @@ 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, cost, price, value, weight, +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). diff --git a/hledger/hledger.m4.md b/hledger/hledger.m4.md index 417bb3ceefa..d43ea03a6d4 100644 --- a/hledger/hledger.m4.md +++ b/hledger/hledger.m4.md @@ -7148,11 +7148,11 @@ units held, cost basis, current value and unrealised gain: $ hledger holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ===============++============================================================================================== - assets:stocks || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 100.0% $110 44.0% $100 1865.3% + 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% + || $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 - @@ -7678,12 +7678,12 @@ unrealised gain): $ hledger holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR + || 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 + 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 + || $850 $100 ``` and `print -x --lots` shows the inferred lot subaccounts and gain postings. diff --git a/hledger/test/holdings.test b/hledger/test/holdings.test index bd8f49f85c5..9c44b50fffe 100644 --- a/hledger/test/holdings.test +++ b/hledger/test/holdings.test @@ -32,34 +32,34 @@ commodity MSFT ; lots: $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || 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 + assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 + assets:broker:stocks || 15 AAPL $56.67 $850 $100 ----------------------++--------------------------------------------------------------------------------------------- - || $2850 $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 Cost Price Value Weight UGain UGain% RGain XIRR + || 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 + 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 + || $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 Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ======================++====================================================================================== - assets:broker:stocks || 15 AAPL $56.67 $850 $100 + assets:broker:stocks || 15 AAPL $56.67 $850 $100 ----------------------++-------------------------------------------------------------------------------------- - || $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 @@ -68,47 +68,47 @@ Holdings on 2026-03-31 $ hledger -f- holdings -e 2026-04-01 --lots --tree Holdings on 2026-03-31 - || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR ============================++============================================================================================== - assets:broker || 15 AAPL $2850 $100 + 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 + 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 + || $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 Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Unit cost Price Cost Value Weight UGain UGain% RGain XIRR ==========================++============================================================================================== - assets || 15 AAPL $2850 $100 + assets || 15 AAPL $2850 $100 || 5 MSFT - broker || 15 AAPL $2850 $100 + 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 + 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 + || $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 Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ===============++======================================================================================= - assets:broker || 15 AAPL $2850 $100 + assets:broker || 15 AAPL $2850 $100 || 5 MSFT ---------------++--------------------------------------------------------------------------------------- - || $2850 $100 + || $2850 $100 # ** 7. A journal with no lots reports no holdings. < @@ -144,24 +144,24 @@ commodity GOLD ; lots: $ hledger -f- holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR + || 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 + 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 + || $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 Cost Price Value Weight UGain UGain% RGain XIRR + || 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 + assets:fund || 16.993 FUND $1859.59 $31600.00 + assets:gold || 2026-03-01 30d 1.5 GOLD $2000.22 $3000.33 -------------++----------------------------------------------------------------------------------------------------- - || $34600.33 + || $34600.33 # ** 10. Quantities are also normalised to the commodity's display precision # (here 3 decimals, inferred from the first purchase). @@ -179,24 +179,24 @@ commodity AAAA ; lots: $ hledger -f- holdings -e 2026-02-01 --lots Holdings on 2026-01-31 - || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR + || 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 + 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 + || $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 Cost Price Value Weight UGain UGain% RGain XIRR + || 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 + 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 + || $72 # ** 12. With market prices (P directives), the Price, Value and Gain # columns show each holding's market price at the report date, its @@ -231,24 +231,24 @@ P 2026-03-31 MSFT $410 $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ======================++=============================================================================================== - assets:broker:funds || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 65.5% $50 2.5% 22.7% - assets:broker:stocks || 15 AAPL $56.67 $850 $72 $1080 34.5% $230 27.1% $100 419.4% + 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% + || $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 Cost Price Value Weight UGain UGain% RGain XIRR + || 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 $410 $2050 65.5% $50 2.5% 22.7% - assets:broker:stocks:{2026-01-15, $50} || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 11.5% $110 44.0% $100 759.2% - assets:broker:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 $72 $720 23.0% $120 20.0% 215.2% + 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% + || $2850 $3130 100.0% $280 9.8% $100 137.8% # ** 14. With --infer-market-prices, transaction costs also provide # market prices, as usual. @@ -266,12 +266,12 @@ commodity AAAA ; lots: $ hledger -f- holdings -e 2026-02-01 --lots --infer-market-prices Holdings on 2026-01-31 - || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR + || 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 $60 $12 $60 80.2% $0 0.0% 0.0% + 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% + || $72 $75 100.0% $2 3.4% 52.2% # ** 15. -X/--value=end,COMM value holdings in the given commodity: @@ -292,21 +292,21 @@ P 2026-03-31 $ €0.9123 $ hledger -f- holdings -e 2026-04-01 -X € Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ===============++==================================================================================================== - assets:stocks || 2026-01-15 75d 10 AAPL €45.62 €456.15 €65.69 €656.86 100.0% €200.71 44.0% + 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% + || €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 Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ===============++============================================================================================== - assets:stocks || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 100.0% $220 44.0% 490.5% + 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% + || $500 $720 100.0% $220 44.0% 490.5% # ** 17. --value=then is not supported. $ hledger -f- holdings --value=then @@ -334,20 +334,20 @@ P 2026-03-31 MSFT $410 $ hledger -f- holdings -e 2026-04-01 -S Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ===============++=============================================================================================== - assets:msoft || 2026-02-15 44d 5 MSFT $400 $2000 $410 $2050 74.0% $50 2.5% 22.7% - assets:apples || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 26.0% $220 44.0% 490.5% + 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% + || $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","cost","price","value","weight","ugain","ugainpct","rgain","xirr" -"assets:apples","AAPL","2026-01-15","75","10","$50","$500","$72","$720","26.0","$220","44.0","","490.5" -"assets:msoft","MSFT","2026-02-15","44","5","$400","$2000","$410","$2050","74.0","$50","2.5","","22.7" +"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. @@ -362,8 +362,8 @@ commodity 1.000,00 € P 2026-03-31 AAAA 3,10 € $ hledger -f- holdings -e 2026-04-01 -O csv -"account","commodity","date","age","units","unitcost","cost","price","value","weight","ugain","ugainpct","rgain","xirr" -"assets:stocks","AAAA","2026-01-15","75","1000,5","2,25 €","2251,12 €","3,10 €","3101,55 €","100.0","850,42 €","37.8","","376.2" +"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. @@ -389,11 +389,11 @@ commodity AAPL ; lots: $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ========++============================================================================================= - assets || 2020-01-15 6.2y 10 AAPL $50 $500 + assets || 2020-01-15 6.2y 10 AAPL $50 $500 --------++--------------------------------------------------------------------------------------------- - || $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 @@ -416,21 +416,21 @@ commodity AAPL ; lots: $ hledger -f- holdings -e 2026-04-01 --lots Holdings on 2026-03-31 - || Date Age Units Unit cost Cost Price Value Weight UGain UGain% RGain XIRR + || 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 + assets:stocks:{2026-02-01, $60} || 2026-02-01 58d 10 AAPL $60 $600 ---------------------------------++--------------------------------------------------------------------------------------------- - || $600 $200 + || $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 Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ========++============================================================================================ - stocks || 2026-02-01 58d 10 AAPL $60 $600 $200 + stocks || 2026-02-01 58d 10 AAPL $60 $600 $200 --------++-------------------------------------------------------------------------------------------- - || $600 $200 + || $600 $200 # ** 27. A display style declared for the "%" commodity (eg with -c) # styles the percent columns: Weight, UGain% and XIRR. @@ -450,11 +450,11 @@ 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 Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ========++=================================================================================================== - assets || 2026-01-15 75d 5 AAPL $50 $250 $72 $360 100.00 % $110 44.00 % $100 1865.32 % + 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 % + || $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). @@ -473,8 +473,8 @@ P 2026-03-31 AAPL $72 $ hledger -f- holdings -e 2026-04-01 Holdings on 2026-03-31 - || Date Age Units Avg cost Cost Price Value Weight UGain UGain% RGain XIRR + || Date Age Units Avg cost Price Cost Value Weight UGain UGain% RGain XIRR ========++================================================================================================ - assets || 2026-01-15 75d 10 AAPL $50 $500 $72 $720 100.0 % $220 44.0 % 490.5 % + 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 % + || $500 $720 100.0 % $220 44.0 % 490.5 % From 8b53bfb74634660411e57a49d10525bb04f872f4 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Wed, 5 Aug 2026 09:10:09 +0100 Subject: [PATCH 25/26] imp: holdings: support --title The report title ("Holdings on DATE") can now be customised with --title=TEXT or suppressed with --title="", like other reports. AI usage: Claude Fable 5, ~5k output tokens --- doc/SPEC-holdings.md | 2 ++ hledger/Hledger/Cli/Commands/Holdings.hs | 6 ++++- hledger/Hledger/Cli/Commands/Holdings.md | 2 ++ hledger/Hledger/Cli/Commands/Holdings.txt | 28 ++++++++++++----------- 4 files changed, 24 insertions(+), 14 deletions(-) diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index 62396509ebe..a4d621786a3 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -30,6 +30,8 @@ json...). 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 diff --git a/hledger/Hledger/Cli/Commands/Holdings.hs b/hledger/Hledger/Cli/Commands/Holdings.hs index 37b70938c3d..37a468254b7 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.hs +++ b/hledger/Hledger/Cli/Commands/Holdings.hs @@ -157,7 +157,11 @@ holdings opts@CliOpts{rawopts_=rawopts, reportspec_=rspec@ReportSpec{_rsQuery=q, fmt -> error' $ unsupportedOutputFormatError fmt where txtoutput = - "Holdings on " <> TL.fromStrict (showDate reportdate) <> "\n\n" <> + -- 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 diff --git a/hledger/Hledger/Cli/Commands/Holdings.md b/hledger/Hledger/Cli/Commands/Holdings.md index 725553aae77..4aed7a089a3 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.md +++ b/hledger/Hledger/Cli/Commands/Holdings.md @@ -36,6 +36,8 @@ 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`, diff --git a/hledger/Hledger/Cli/Commands/Holdings.txt b/hledger/Hledger/Cli/Commands/Holdings.txt index f6d09c11c97..5d6473b98f0 100644 --- a/hledger/Hledger/Cli/Commands/Holdings.txt +++ b/hledger/Hledger/Cli/Commands/Holdings.txt @@ -32,19 +32,21 @@ 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. 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). +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 From 15b473af2322dbdea8337f42aafd944673dfb430 Mon Sep 17 00:00:00 2001 From: Simon Michael Date: Wed, 5 Aug 2026 13:48:39 +0100 Subject: [PATCH 26/26] ;examples: rename lot-entries.journal -> lots.journal --- Justfile | 2 +- doc/SPEC-holdings.md | 2 +- doc/SPEC-lots.md | 4 ++-- examples/lots/{lot-entries.journal => lots.journal} | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) rename examples/lots/{lot-entries.journal => lots.journal} (96%) diff --git a/Justfile b/Justfile index 0dfcf6b990b..63a5ea89f25 100644 --- a/Justfile +++ b/Justfile @@ -1878,7 +1878,7 @@ installcommithook: # Show a hledger sample holdings report. @holdings-hledger *ARGS: - hledger -f examples/lots/lot-entries.journal holdings {{ ARGS }} + hledger -f examples/lots/lots.journal holdings {{ ARGS }} # Show several holdings report examples. holdings-examples: diff --git a/doc/SPEC-holdings.md b/doc/SPEC-holdings.md index a4d621786a3..494293dc73c 100644 --- a/doc/SPEC-holdings.md +++ b/doc/SPEC-holdings.md @@ -250,6 +250,6 @@ 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/lot-entries.journal needing -e to show prices) +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/lot-entries.journal b/examples/lots/lots.journal similarity index 96% rename from examples/lots/lot-entries.journal rename to examples/lots/lots.journal index 983bd4d59ac..cbfb66e5ec9 100644 --- a/examples/lots/lot-entries.journal +++ b/examples/lots/lots.journal @@ -4,8 +4,8 @@ ; 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] +; 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.) @@ -159,7 +159,7 @@ account revenue:gains ; type: G ; Recording an occasional market price allows reports to show current ; market value and unrealised gain. Eg try: -; hledger -f lot-entries.journal holdings +; hledger -f lots.journal holdings P 2026-05-01 ETSY $95 @@ -168,14 +168,14 @@ comment ############################################################################### Comparing roi and holdings with this journal. -$ hledger -f lot-entries.journal roi --inv assets:broker --pnl 'revenue:gains|equity:unrealised-gain' -e 2026-03-31 --value=end,$ --infer-market-prices +$ hledger -f lots.journal roi --inv assets:broker --pnl 'revenue:gains|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 lot-entries.journal holdings -e 2026-04-01 --infer-market-prices +$ 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