Skip to content

Commit 143a563

Browse files
committed
commit
1 parent 6144891 commit 143a563

6 files changed

Lines changed: 266 additions & 14 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package category implements the "holding category" command group.
6+
package category
7+
8+
import (
9+
"buf.build/go/app/appcmd"
10+
"buf.build/go/app/appext"
11+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/category/categorylist"
12+
)
13+
14+
// NewCommand returns a new category command group.
15+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
16+
return &appcmd.Command{
17+
Use: name,
18+
Short: "Display holdings by category",
19+
SubCommands: []*appcmd.Command{
20+
categorylist.NewCommand("list", builder),
21+
},
22+
}
23+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package categorylist implements the "holding category list" command.
6+
package categorylist
7+
8+
import (
9+
"context"
10+
"os"
11+
12+
"buf.build/go/app/appcmd"
13+
"buf.build/go/app/appext"
14+
"github.com/bufdev/ibctl/cmd/ibctl/internal/ibctlcmd"
15+
"github.com/bufdev/ibctl/internal/ibctl/ibctlconfig"
16+
"github.com/bufdev/ibctl/internal/ibctl/ibctlfxrates"
17+
"github.com/bufdev/ibctl/internal/ibctl/ibctlholdings"
18+
"github.com/bufdev/ibctl/internal/ibctl/ibctlmerge"
19+
"github.com/bufdev/ibctl/internal/ibctl/ibctlpath"
20+
"github.com/bufdev/ibctl/internal/pkg/cliio"
21+
"github.com/spf13/pflag"
22+
)
23+
24+
// formatFlagName is the flag name for the output format.
25+
const formatFlagName = "format"
26+
27+
// downloadFlagName is the flag name for downloading fresh data before displaying.
28+
const downloadFlagName = "download"
29+
30+
// NewCommand returns a new category list command.
31+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
32+
flags := newFlags()
33+
return &appcmd.Command{
34+
Use: name,
35+
Short: "List holdings aggregated by category",
36+
Args: appcmd.NoArgs,
37+
Run: builder.NewRunFunc(
38+
func(ctx context.Context, container appext.Container) error {
39+
return run(ctx, container, flags)
40+
},
41+
),
42+
BindFlags: flags.Bind,
43+
}
44+
}
45+
46+
type flags struct {
47+
// Dir is the base directory containing ibctl.yaml and data subdirectories.
48+
Dir string
49+
// Format is the output format (table, csv, json).
50+
Format string
51+
// Download fetches fresh data before displaying.
52+
Download bool
53+
}
54+
55+
func newFlags() *flags {
56+
return &flags{}
57+
}
58+
59+
// Bind registers the flag definitions with the given flag set.
60+
func (f *flags) Bind(flagSet *pflag.FlagSet) {
61+
flagSet.StringVar(&f.Dir, ibctlcmd.DirFlagName, ".", "The ibctl directory containing ibctl.yaml")
62+
flagSet.StringVar(&f.Format, formatFlagName, "table", "Output format (table, csv, json)")
63+
flagSet.BoolVar(&f.Download, downloadFlagName, false, "Download fresh data before displaying")
64+
}
65+
66+
func run(ctx context.Context, container appext.Container, flags *flags) error {
67+
format, err := cliio.ParseFormat(flags.Format)
68+
if err != nil {
69+
return appcmd.NewInvalidArgumentError(err.Error())
70+
}
71+
// Read and validate the configuration file from the base directory.
72+
config, err := ibctlconfig.ReadConfig(flags.Dir)
73+
if err != nil {
74+
return err
75+
}
76+
// Download fresh data if --download is set.
77+
if flags.Download {
78+
downloader, err := ibctlcmd.NewDownloader(container, flags.Dir)
79+
if err != nil {
80+
return err
81+
}
82+
if err := downloader.Download(ctx); err != nil {
83+
return err
84+
}
85+
}
86+
// Merge trade data from all sources.
87+
mergedData, err := ibctlmerge.Merge(
88+
ibctlpath.DataAccountsDirPath(config.DirPath),
89+
ibctlpath.CacheAccountsDirPath(config.DirPath),
90+
ibctlpath.ActivityStatementsDirPath(config.DirPath),
91+
ibctlpath.SeedDirPath(config.DirPath),
92+
config.AccountAliases,
93+
)
94+
if err != nil {
95+
return err
96+
}
97+
// Load FX rates for USD conversion.
98+
fxStore := ibctlfxrates.NewStore(ibctlpath.CacheFXDirPath(config.DirPath))
99+
// Compute holdings via FIFO from all trade data.
100+
result, err := ibctlholdings.GetHoldingsOverview(mergedData.Trades, mergedData.Positions, mergedData.CashPositions, config, fxStore)
101+
if err != nil {
102+
return err
103+
}
104+
// Aggregate holdings by category.
105+
categories := ibctlholdings.GetCategoryList(result.Holdings)
106+
// Write output in the requested format.
107+
writer := os.Stdout
108+
switch format {
109+
case cliio.FormatTable:
110+
headers := ibctlholdings.CategoryListHeaders()
111+
rows := make([][]string, 0, len(categories))
112+
for _, c := range categories {
113+
rows = append(rows, ibctlholdings.CategoryOverviewToTableRow(c))
114+
}
115+
return cliio.WriteTable(writer, headers, rows)
116+
case cliio.FormatCSV:
117+
headers := ibctlholdings.CategoryListHeaders()
118+
records := make([][]string, 0, len(categories)+1)
119+
records = append(records, headers)
120+
for _, c := range categories {
121+
records = append(records, ibctlholdings.CategoryOverviewToRow(c))
122+
}
123+
return cliio.WriteCSVRecords(writer, records)
124+
case cliio.FormatJSON:
125+
return cliio.WriteJSON(writer, categories...)
126+
default:
127+
return appcmd.NewInvalidArgumentErrorf("unsupported format: %s", format)
128+
}
129+
}

cmd/ibctl/internal/command/holding/holding.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package holding
88
import (
99
"buf.build/go/app/appcmd"
1010
"buf.build/go/app/appext"
11+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/category"
1112
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/holdinglist"
1213
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/lot"
1314
)
@@ -18,6 +19,7 @@ func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
1819
Use: name,
1920
Short: "Display holding information",
2021
SubCommands: []*appcmd.Command{
22+
category.NewCommand("category", builder),
2123
holdinglist.NewCommand("list", builder),
2224
lot.NewCommand("lot", builder),
2325
},

cmd/ibctl/internal/command/holding/holdinglist/holdinglist.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ import (
2626
// formatFlagName is the flag name for the output format.
2727
const formatFlagName = "format"
2828

29-
// cachedFlagName is the flag name for skipping download and using cached data only.
30-
const cachedFlagName = "cached"
29+
// downloadFlagName is the flag name for downloading fresh data before displaying.
30+
const downloadFlagName = "download"
3131

3232
// NewCommand returns a new holdings overview command.
3333
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
@@ -50,8 +50,8 @@ type flags struct {
5050
Dir string
5151
// Format is the output format (table, csv, json).
5252
Format string
53-
// Cached skips downloading and uses only cached data.
54-
Cached bool
53+
// Download fetches fresh data before displaying.
54+
Download bool
5555
}
5656

5757
func newFlags() *flags {
@@ -62,7 +62,7 @@ func newFlags() *flags {
6262
func (f *flags) Bind(flagSet *pflag.FlagSet) {
6363
flagSet.StringVar(&f.Dir, ibctlcmd.DirFlagName, ".", "The ibctl directory containing ibctl.yaml")
6464
flagSet.StringVar(&f.Format, formatFlagName, "table", "Output format (table, csv, json)")
65-
flagSet.BoolVar(&f.Cached, cachedFlagName, false, "Skip downloading and use only cached data")
65+
flagSet.BoolVar(&f.Download, downloadFlagName, false, "Download fresh data before displaying")
6666
}
6767

6868
func run(ctx context.Context, container appext.Container, flags *flags) error {
@@ -75,8 +75,8 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
7575
if err != nil {
7676
return err
7777
}
78-
// Download fresh data unless --cached is set.
79-
if !flags.Cached {
78+
// Download fresh data if --download is set.
79+
if flags.Download {
8080
downloader, err := ibctlcmd.NewDownloader(container, flags.Dir)
8181
if err != nil {
8282
return err

cmd/ibctl/internal/command/holding/lot/lotlist/lotlist.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ import (
2424
// formatFlagName is the flag name for the output format.
2525
const formatFlagName = "format"
2626

27-
// cachedFlagName is the flag name for skipping download and using cached data only.
28-
const cachedFlagName = "cached"
27+
// downloadFlagName is the flag name for downloading fresh data before displaying.
28+
const downloadFlagName = "download"
2929

3030
// NewCommand returns a new lot list command.
3131
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
@@ -48,8 +48,8 @@ type flags struct {
4848
Dir string
4949
// Format is the output format (table, csv, json).
5050
Format string
51-
// Cached skips downloading and uses only cached data.
52-
Cached bool
51+
// Download fetches fresh data before displaying.
52+
Download bool
5353
}
5454

5555
func newFlags() *flags {
@@ -60,7 +60,7 @@ func newFlags() *flags {
6060
func (f *flags) Bind(flagSet *pflag.FlagSet) {
6161
flagSet.StringVar(&f.Dir, ibctlcmd.DirFlagName, ".", "The ibctl directory containing ibctl.yaml")
6262
flagSet.StringVar(&f.Format, formatFlagName, "table", "Output format (table, csv, json)")
63-
flagSet.BoolVar(&f.Cached, cachedFlagName, false, "Skip downloading and use only cached data")
63+
flagSet.BoolVar(&f.Download, downloadFlagName, false, "Download fresh data before displaying")
6464
}
6565

6666
func run(ctx context.Context, container appext.Container, flags *flags, symbol string) error {
@@ -73,8 +73,8 @@ func run(ctx context.Context, container appext.Container, flags *flags, symbol s
7373
if err != nil {
7474
return err
7575
}
76-
// Download fresh data unless --cached is set.
77-
if !flags.Cached {
76+
// Download fresh data if --download is set.
77+
if flags.Download {
7878
downloader, err := ibctlcmd.NewDownloader(container, flags.Dir)
7979
if err != nil {
8080
return err

internal/ibctl/ibctlholdings/ibctlholdings.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,104 @@ func ComputeLotTotals(lots []*LotOverview) *LotTotals {
261261
}
262262
}
263263

264+
// CategoryOverview represents holdings aggregated by category.
265+
type CategoryOverview struct {
266+
// Category is the asset category (e.g., "EQUITY", "FIXED_INCOME", "CASH").
267+
Category string `json:"category"`
268+
// MarketValueUSD is the total market value in USD.
269+
MarketValueUSD string `json:"market_value_usd"`
270+
// NetLiqPct is the percentage of total portfolio value (e.g., "45.23%").
271+
NetLiqPct string `json:"net_liq_pct"`
272+
// UnrealizedPnLUSD is the total unrealized P&L in USD.
273+
UnrealizedPnLUSD string `json:"unrealized_pnl_usd"`
274+
// STCGUSD is the total short-term P&L in USD.
275+
STCGUSD string `json:"stcg_usd"`
276+
// LTCGUSD is the total long-term P&L in USD.
277+
LTCGUSD string `json:"ltcg_usd"`
278+
}
279+
280+
// CategoryListHeaders returns the column headers for category list output.
281+
func CategoryListHeaders() []string {
282+
return []string{"CATEGORY", "MKT VAL USD", "NET LIQ %", "UNRLZD P&L USD", "STCG USD", "LTCG USD"}
283+
}
284+
285+
// CategoryOverviewToRow converts a CategoryOverview to a string slice for CSV output.
286+
func CategoryOverviewToRow(c *CategoryOverview) []string {
287+
return []string{
288+
c.Category,
289+
c.MarketValueUSD,
290+
c.NetLiqPct,
291+
c.UnrealizedPnLUSD,
292+
c.STCGUSD,
293+
c.LTCGUSD,
294+
}
295+
}
296+
297+
// CategoryOverviewToTableRow converts a CategoryOverview to a string slice for table display.
298+
func CategoryOverviewToTableRow(c *CategoryOverview) []string {
299+
return []string{
300+
c.Category,
301+
cliio.FormatUSD(c.MarketValueUSD),
302+
c.NetLiqPct,
303+
cliio.FormatUSD(c.UnrealizedPnLUSD),
304+
cliio.FormatUSD(c.STCGUSD),
305+
cliio.FormatUSD(c.LTCGUSD),
306+
}
307+
}
308+
309+
// GetCategoryList aggregates holdings by category from a HoldingsResult.
310+
func GetCategoryList(holdings []*HoldingOverview) []*CategoryOverview {
311+
// Accumulate per-category totals in micros.
312+
type categoryData struct {
313+
mktValMicros int64
314+
pnlMicros int64
315+
stcgMicros int64
316+
ltcgMicros int64
317+
}
318+
dataMap := make(map[string]*categoryData)
319+
var totalMktValMicros int64
320+
for _, h := range holdings {
321+
cat := h.Category
322+
if cat == "" {
323+
cat = "UNCATEGORIZED"
324+
}
325+
data, ok := dataMap[cat]
326+
if !ok {
327+
data = &categoryData{}
328+
dataMap[cat] = data
329+
}
330+
mktVal := mathpb.ParseMicros(h.MarketValueUSD)
331+
data.mktValMicros += mktVal
332+
data.pnlMicros += mathpb.ParseMicros(h.UnrealizedPnLUSD)
333+
data.stcgMicros += mathpb.ParseMicros(h.STCGUSD)
334+
data.ltcgMicros += mathpb.ParseMicros(h.LTCGUSD)
335+
totalMktValMicros += mktVal
336+
}
337+
// Build category overview entries with net liq percentage.
338+
var categories []*CategoryOverview
339+
for cat, data := range dataMap {
340+
// Compute net liq percentage: mkt val / total mkt val * 100.
341+
var pctStr string
342+
if totalMktValMicros != 0 {
343+
pct := float64(data.mktValMicros) / float64(totalMktValMicros) * 100
344+
pctStr = fmt.Sprintf("%.2f%%", pct)
345+
}
346+
categories = append(categories, &CategoryOverview{
347+
Category: cat,
348+
MarketValueUSD: moneypb.MoneyValueToString(moneypb.MoneyFromMicros("USD", data.mktValMicros)),
349+
NetLiqPct: pctStr,
350+
UnrealizedPnLUSD: moneypb.MoneyValueToString(moneypb.MoneyFromMicros("USD", data.pnlMicros)),
351+
STCGUSD: moneypb.MoneyValueToString(moneypb.MoneyFromMicros("USD", data.stcgMicros)),
352+
LTCGUSD: moneypb.MoneyValueToString(moneypb.MoneyFromMicros("USD", data.ltcgMicros)),
353+
})
354+
}
355+
// Sort by category name for deterministic output.
356+
sort.Slice(categories, func(i, j int) bool {
357+
return categories[i].Category < categories[j].Category
358+
})
359+
return categories
360+
}
361+
264362
// GetLotList returns the individual tax lots for a given symbol.
265363
func GetLotList(
266364
symbol string,

0 commit comments

Comments
 (0)