Skip to content

Commit 66046ab

Browse files
committed
commit
1 parent 2b83212 commit 66046ab

File tree

30 files changed

+1518
-338
lines changed

30 files changed

+1518
-338
lines changed

README.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
11
# ibctl
22

3-
## TODO:
4-
5-
- other manual things other than just cash adjustments, and a list of them
6-
73
A CLI tool for analyzing Interactive Brokers (IBKR) holdings and trades. Downloads data via the IBKR Flex Query API, computes FIFO tax lots, and displays holdings with prices, positions, and USD conversions. Supports multiple IBKR accounts.
84

95
## Prerequisites
@@ -118,17 +114,31 @@ accounts:
118114
rrsp: "U1234567"
119115
holdco: "U2345678"
120116
individual: "U3456789"
121-
symbols:
122-
- name: AAPL
117+
categorization:
118+
- symbol: AAPL
123119
category: EQUITY
124120
type: STOCK
125121
sector: TECH
126122
geo: US
123+
cash:
124+
CAD: "-5000.00"
125+
additions:
126+
- account_alias: wealthsimple
127+
symbol: VFV.TO
128+
currency_code: CAD
129+
trade_date: "20240315"
130+
trade_price: "102.50"
131+
trade_side: buy
132+
quantity: "100"
133+
last_price: "115.30"
134+
tax_exempt: true
127135
```
128136
129137
- `flex_query_id` — your IBKR Flex Query ID (required)
130138
- `accounts` — maps user-chosen aliases to IBKR account IDs (required). Account numbers are confidential — only aliases appear in output and directory names.
131-
- `symbols` — optional classification metadata for holdings display (category, type, sector, geo)
139+
- `categorization` — optional classification metadata for holdings display (category, type, sector, geo)
140+
- `cash` — optional manual cash adjustments by currency code (applied to cash positions in holdings display)
141+
- `additions` — optional manually added trades from non-IBKR brokers. Addition accounts must not match IBKR account aliases. Trade dates use YYYYMMDD format. When `tax_exempt` is true, lots show P&L=0 and are excluded from tax calculations.
132142

133143
## Usage
134144

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ package categorylist
88
import (
99
"context"
1010
"os"
11+
"strings"
1112

1213
"buf.build/go/app/appcmd"
1314
"buf.build/go/app/appext"
@@ -21,11 +22,14 @@ import (
2122
"github.com/spf13/pflag"
2223
)
2324

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"
25+
const (
26+
// formatFlagName is the flag name for the output format.
27+
formatFlagName = "format"
28+
// downloadFlagName is the flag name for downloading fresh data before displaying.
29+
downloadFlagName = "download"
30+
// geoFlagName is the flag name for filtering by geo.
31+
geoFlagName = "geo"
32+
)
2933

3034
// NewCommand returns a new category list command.
3135
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
@@ -50,6 +54,8 @@ type flags struct {
5054
Format string
5155
// Download fetches fresh data before displaying.
5256
Download bool
57+
// Geo filters to only include holdings matching this geographic classification.
58+
Geo string
5359
}
5460

5561
func newFlags() *flags {
@@ -61,6 +67,7 @@ func (f *flags) Bind(flagSet *pflag.FlagSet) {
6167
flagSet.StringVar(&f.Dir, ibctlcmd.DirFlagName, ".", "The ibctl directory containing ibctl.yaml")
6268
flagSet.StringVar(&f.Format, formatFlagName, "table", "Output format (table, csv, json)")
6369
flagSet.BoolVar(&f.Download, downloadFlagName, false, "Download fresh data before displaying")
70+
flagSet.StringVar(&f.Geo, geoFlagName, "", "Filter by geo (e.g., US, INTL)")
6471
}
6572

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

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"buf.build/go/app/appcmd"
1010
"buf.build/go/app/appext"
1111
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/category"
12+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/geo"
1213
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/holdinglist"
1314
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/holdingvalue"
1415
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/lot"
@@ -21,6 +22,7 @@ func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
2122
Short: "Display holding information",
2223
SubCommands: []*appcmd.Command{
2324
category.NewCommand("category", builder),
25+
geo.NewCommand("geo", builder),
2426
holdinglist.NewCommand("list", builder),
2527
lot.NewCommand("lot", builder),
2628
holdingvalue.NewCommand("value", builder),

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
9393
ibctlpath.ActivityStatementsDirPath(config.DirPath),
9494
ibctlpath.SeedDirPath(config.DirPath),
9595
config.AccountAliases,
96+
config.Additions,
9697
)
9798
if err != nil {
9899
return err
@@ -144,7 +145,7 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
144145
totalsRow := make([]string, len(headers))
145146
totalsRow[0] = "TOTAL"
146147
totalsRow[6] = totals.MarketValueUSD
147-
totalsRow[7] = totals.UnrealizedPnLUSD
148+
totalsRow[7] = totals.PnLUSD
148149
totalsRow[8] = totals.STCGUSD
149150
totalsRow[9] = totals.LTCGUSD
150151
return cliio.WriteTableWithTotals(writer, headers, sections, totalsRow)

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
8383
ibctlpath.ActivityStatementsDirPath(config.DirPath),
8484
ibctlpath.SeedDirPath(config.DirPath),
8585
config.AccountAliases,
86+
config.Additions,
8687
)
8788
if err != nil {
8889
return err

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
9696
ibctlpath.ActivityStatementsDirPath(config.DirPath),
9797
ibctlpath.SeedDirPath(config.DirPath),
9898
config.AccountAliases,
99+
config.Additions,
99100
)
100101
if err != nil {
101102
return err
@@ -123,7 +124,7 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
123124
totalsRow[9] = totals.PnLUSD
124125
totalsRow[10] = totals.STCGUSD
125126
totalsRow[11] = totals.LTCGUSD
126-
totalsRow[12] = totals.ValueUSD
127+
totalsRow[12] = totals.MktValUSD
127128
return cliio.WriteTableWithTotals(writer, headers, rows, totalsRow)
128129
case cliio.FormatCSV:
129130
headers := ibctlholdings.LotListHeaders()

internal/gen/proto/go/ibctl/data/v1/cash_position.pb.go

Lines changed: 6 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/gen/proto/go/ibctl/data/v1/corporate_action.pb.go

Lines changed: 6 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)