Skip to content

Commit 2af3d3e

Browse files
committed
commit
1 parent 04b1e95 commit 2af3d3e

12 files changed

Lines changed: 563 additions & 209 deletions

File tree

README.md

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ ibctl config edit
2323
export IBKR_FLEX_WEB_SERVICE_TOKEN="your-flex-web-service-token"
2424

2525
# View holdings (downloads data automatically).
26-
ibctl holdings overview
26+
ibctl holding list
2727
```
2828

2929
## Directory Structure
@@ -132,11 +132,11 @@ symbols:
132132
# Set the IBKR token.
133133
export IBKR_FLEX_WEB_SERVICE_TOKEN="your-flex-web-service-token"
134134
135-
# View combined holdings overview (downloads data automatically).
136-
ibctl holdings overview
137-
ibctl holdings overview --format csv
138-
ibctl holdings overview --format json
139-
ibctl holdings overview --cached # Skip download, use cached data only
135+
# View combined holding list (downloads data automatically).
136+
ibctl holding list
137+
ibctl holding list --format csv
138+
ibctl holding list --format json
139+
ibctl holding list --cached # Skip download, use cached data only
140140
141141
# Force re-download of IBKR data (all accounts).
142142
ibctl download
@@ -148,7 +148,7 @@ ibctl probe
148148
ibctl data zip -o backup.zip
149149
150150
# Use a different ibctl directory (default is current directory).
151-
ibctl holdings overview --dir ~/Documents/ibkr
151+
ibctl holding list --dir ~/Documents/ibkr
152152
```
153153

154154
## Commands
@@ -160,7 +160,7 @@ ibctl holdings overview --dir ~/Documents/ibkr
160160
| `ibctl config validate` | Validate ibctl.yaml |
161161
| `ibctl data zip -o <file>` | Archive the ibctl directory to a zip file |
162162
| `ibctl download` | Download and cache IBKR data via Flex Query API |
163-
| `ibctl holdings overview` | Display holdings with prices, positions, and classifications |
163+
| `ibctl holding list` | Display holdings with prices, positions, and classifications |
164164
| `ibctl probe` | Probe the API and show per-account data counts |
165165

166166
All commands accept `--dir` to specify the ibctl directory (defaults to `.`).
@@ -188,7 +188,7 @@ IBKR limits all data access to 365 days per request. To get your full trade hist
188188

189189
6. Save each CSV in the account's subdirectory. Filenames don't matter — ibctl reads all `*.csv` files recursively.
190190

191-
7. Run `ibctl holdings overview` — data from the CSVs is merged with Flex Query API data.
191+
7. Run `ibctl holding list` — data from the CSVs is merged with Flex Query API data.
192192

193193
### How Merging Works
194194

@@ -220,7 +220,7 @@ The optional `seed/` directory contains permanent, manually curated transaction
220220

221221
### Data Pipeline
222222

223-
The `holdings overview` command runs:
223+
The `holding list` command runs:
224224

225225
1. **Download**: Fetches all accounts' data from the IBKR Flex Query API. Trades are incrementally merged. FX rates are eagerly downloaded for all currency pairs from the earliest trade date to today.
226226
2. **Merge**: Combines Activity Statement CSVs + seed data + Flex Query cache, with CSV data taking precedence for overlapping dates.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package holding implements the "holding" command group.
6+
package holding
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/holdinglist"
12+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/lot"
13+
)
14+
15+
// NewCommand returns a new holding command group.
16+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
17+
return &appcmd.Command{
18+
Use: name,
19+
Short: "Display holding information",
20+
SubCommands: []*appcmd.Command{
21+
holdinglist.NewCommand("list", builder),
22+
lot.NewCommand("lot", builder),
23+
},
24+
}
25+
}

cmd/ibctl/internal/command/holdings/holdingsoverview/holdingsoverview.go renamed to cmd/ibctl/internal/command/holding/holdinglist/holdinglist.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
//
33
// All rights reserved.
44

5-
// Package holdingsoverview implements the "holdings overview" command.
6-
package holdingsoverview
5+
// Package holdinglist implements the "holding list" command.
6+
package holdinglist
77

88
import (
99
"context"
@@ -34,7 +34,7 @@ func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
3434
flags := newFlags()
3535
return &appcmd.Command{
3636
Use: name,
37-
Short: "Display holdings with prices, positions, and classifications",
37+
Short: "List holdings with prices, positions, and classifications",
3838
Args: appcmd.NoArgs,
3939
Run: builder.NewRunFunc(
4040
func(ctx context.Context, container appext.Container) error {
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 lot implements the "holding lot" command group.
6+
package lot
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/lot/lotlist"
12+
)
13+
14+
// NewCommand returns a new lot command group.
15+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
16+
return &appcmd.Command{
17+
Use: name,
18+
Short: "Display individual tax lots",
19+
SubCommands: []*appcmd.Command{
20+
lotlist.NewCommand("list", builder),
21+
},
22+
}
23+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package lotlist implements the "holding lot list" command.
6+
package lotlist
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+
// cachedFlagName is the flag name for skipping download and using cached data only.
28+
const cachedFlagName = "cached"
29+
30+
// NewCommand returns a new lot list command.
31+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
32+
flags := newFlags()
33+
return &appcmd.Command{
34+
Use: name + " <symbol>",
35+
Short: "List individual tax lots for a symbol",
36+
Args: appcmd.ExactArgs(1),
37+
Run: builder.NewRunFunc(
38+
func(ctx context.Context, container appext.Container) error {
39+
return run(ctx, container, flags, container.Arg(0))
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+
// Cached skips downloading and uses only cached data.
52+
Cached 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.Cached, cachedFlagName, false, "Skip downloading and use only cached data")
64+
}
65+
66+
func run(ctx context.Context, container appext.Container, flags *flags, symbol string) 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 unless --cached is set.
77+
if !flags.Cached {
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+
// Get the lot list for the requested symbol.
100+
result, err := ibctlholdings.GetLotList(symbol, mergedData.Trades, mergedData.Positions, fxStore)
101+
if err != nil {
102+
return err
103+
}
104+
// Write output in the requested format.
105+
writer := os.Stdout
106+
switch format {
107+
case cliio.FormatTable:
108+
headers := ibctlholdings.LotListHeaders()
109+
rows := make([][]string, 0, len(result.Lots))
110+
for _, l := range result.Lots {
111+
rows = append(rows, ibctlholdings.LotOverviewToTableRow(l))
112+
}
113+
// Build totals row for P&L USD and VALUE USD columns.
114+
totalPnL, totalValue := ibctlholdings.ComputeLotTotals(result.Lots)
115+
totalsRow := make([]string, len(headers))
116+
totalsRow[0] = "TOTAL"
117+
totalsRow[8] = totalPnL
118+
totalsRow[9] = totalValue
119+
return cliio.WriteTableWithTotals(writer, headers, rows, totalsRow)
120+
case cliio.FormatCSV:
121+
headers := ibctlholdings.LotListHeaders()
122+
records := make([][]string, 0, len(result.Lots)+1)
123+
records = append(records, headers)
124+
for _, l := range result.Lots {
125+
records = append(records, ibctlholdings.LotOverviewToRow(l))
126+
}
127+
return cliio.WriteCSVRecords(writer, records)
128+
case cliio.FormatJSON:
129+
return cliio.WriteJSON(writer, result.Lots...)
130+
default:
131+
return appcmd.NewInvalidArgumentErrorf("unsupported format: %s", format)
132+
}
133+
}

cmd/ibctl/internal/command/holdings/holdings.go

Lines changed: 0 additions & 23 deletions
This file was deleted.

cmd/ibctl/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/config"
1313
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/data"
1414
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/download"
15-
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holdings"
15+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding"
1616
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/probe"
1717
)
1818

@@ -37,7 +37,7 @@ Run "ibctl config init" to create a new ibctl directory.`,
3737
config.NewCommand("config", builder),
3838
data.NewCommand("data", builder),
3939
download.NewCommand("download", builder),
40-
holdings.NewCommand("holdings", builder),
40+
holding.NewCommand("holding", builder),
4141
probe.NewCommand("probe", builder),
4242
},
4343
}

internal/ibctl/ibctlconfig/ibctlconfig.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"regexp"
1515

1616
"github.com/bufdev/ibctl/internal/ibctl/ibctlpath"
17+
"github.com/bufdev/ibctl/internal/pkg/mathpb"
1718
"gopkg.in/yaml.v3"
1819
)
1920

@@ -63,6 +64,9 @@ type ExternalConfigV1 struct {
6364
Accounts map[string]string `yaml:"accounts"`
6465
// Symbols is the optional list of symbol classifications.
6566
Symbols []ExternalSymbolConfigV1 `yaml:"symbols"`
67+
// Adjustments maps currency codes to manual cash adjustments (positive or negative).
68+
// Applied to cash positions in the holdings display.
69+
Adjustments map[string]string `yaml:"adjustments"`
6670
}
6771

6872
// ExternalSymbolConfigV1 holds classification metadata for a symbol in v1 config.
@@ -92,6 +96,9 @@ type Config struct {
9296
AccountIDToAlias map[string]string
9397
// SymbolConfigs maps ticker symbols to their classification metadata.
9498
SymbolConfigs map[string]SymbolConfig
99+
// CashAdjustments maps currency codes to manual cash adjustments in micros.
100+
// Applied to cash positions in the holdings display.
101+
CashAdjustments map[string]int64
95102
}
96103

97104
// SymbolConfig holds classification metadata for a symbol.
@@ -150,12 +157,22 @@ func NewConfigV1(externalConfig ExternalConfigV1, dirPath string) (*Config, erro
150157
Geo: s.Geo,
151158
}
152159
}
160+
// Parse cash adjustments, validating currency codes and decimal values.
161+
cashAdjustments := make(map[string]int64, len(externalConfig.Adjustments))
162+
for currency, value := range externalConfig.Adjustments {
163+
units, micros, err := mathpb.ParseToUnitsMicros(value)
164+
if err != nil {
165+
return nil, fmt.Errorf("invalid adjustment for %s: %w", currency, err)
166+
}
167+
cashAdjustments[currency] = units*1_000_000 + micros
168+
}
153169
return &Config{
154170
DirPath: dirPath,
155171
IBKRFlexQueryID: externalConfig.FlexQueryID,
156172
AccountAliases: accountAliases,
157173
AccountIDToAlias: accountIDToAlias,
158174
SymbolConfigs: symbolConfigs,
175+
CashAdjustments: cashAdjustments,
159176
}, nil
160177
}
161178

0 commit comments

Comments
 (0)