Skip to content

Commit 2aa26de

Browse files
committed
commit
1 parent 143a563 commit 2aa26de

6 files changed

Lines changed: 216 additions & 40 deletions

File tree

README.md

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

3+
## TODO:
4+
5+
- other manual things other than just cash adjustments, and a list of them
6+
37
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.
48

59
## Prerequisites

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"buf.build/go/app/appext"
1111
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/category"
1212
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/holdinglist"
13+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/holdingvalue"
1314
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/holding/lot"
1415
)
1516

@@ -22,6 +23,7 @@ func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
2223
category.NewCommand("category", builder),
2324
holdinglist.NewCommand("list", builder),
2425
lot.NewCommand("lot", builder),
26+
holdingvalue.NewCommand("value", builder),
2527
},
2628
}
2729
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright 2026 Peter Edge
2+
//
3+
// All rights reserved.
4+
5+
// Package holdingvalue implements the "holding value" command.
6+
package holdingvalue
7+
8+
import (
9+
"context"
10+
"fmt"
11+
"math"
12+
"os"
13+
14+
"buf.build/go/app/appcmd"
15+
"buf.build/go/app/appext"
16+
"github.com/bufdev/ibctl/cmd/ibctl/internal/ibctlcmd"
17+
"github.com/bufdev/ibctl/internal/ibctl/ibctlconfig"
18+
"github.com/bufdev/ibctl/internal/ibctl/ibctlfxrates"
19+
"github.com/bufdev/ibctl/internal/ibctl/ibctlholdings"
20+
"github.com/bufdev/ibctl/internal/ibctl/ibctlmerge"
21+
"github.com/bufdev/ibctl/internal/ibctl/ibctlpath"
22+
"github.com/bufdev/ibctl/internal/pkg/cliio"
23+
"github.com/bufdev/ibctl/internal/pkg/mathpb"
24+
"github.com/spf13/pflag"
25+
)
26+
27+
// downloadFlagName is the flag name for downloading fresh data before displaying.
28+
const downloadFlagName = "download"
29+
30+
// NewCommand returns a new holding value command.
31+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
32+
flags := newFlags()
33+
return &appcmd.Command{
34+
Use: name,
35+
Short: "Display portfolio value with estimated tax impact",
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+
// Download fetches fresh data before displaying.
50+
Download bool
51+
}
52+
53+
func newFlags() *flags {
54+
return &flags{}
55+
}
56+
57+
// Bind registers the flag definitions with the given flag set.
58+
func (f *flags) Bind(flagSet *pflag.FlagSet) {
59+
flagSet.StringVar(&f.Dir, ibctlcmd.DirFlagName, ".", "The ibctl directory containing ibctl.yaml")
60+
flagSet.BoolVar(&f.Download, downloadFlagName, false, "Download fresh data before displaying")
61+
}
62+
63+
func run(ctx context.Context, container appext.Container, flags *flags) error {
64+
// Read and validate the configuration file from the base directory.
65+
config, err := ibctlconfig.ReadConfig(flags.Dir)
66+
if err != nil {
67+
return err
68+
}
69+
// Download fresh data if --download is set.
70+
if flags.Download {
71+
downloader, err := ibctlcmd.NewDownloader(container, flags.Dir)
72+
if err != nil {
73+
return err
74+
}
75+
if err := downloader.Download(ctx); err != nil {
76+
return err
77+
}
78+
}
79+
// Merge trade data from all sources.
80+
mergedData, err := ibctlmerge.Merge(
81+
ibctlpath.DataAccountsDirPath(config.DirPath),
82+
ibctlpath.CacheAccountsDirPath(config.DirPath),
83+
ibctlpath.ActivityStatementsDirPath(config.DirPath),
84+
ibctlpath.SeedDirPath(config.DirPath),
85+
config.AccountAliases,
86+
)
87+
if err != nil {
88+
return err
89+
}
90+
// Load FX rates for USD conversion.
91+
fxStore := ibctlfxrates.NewStore(ibctlpath.CacheFXDirPath(config.DirPath))
92+
// Compute holdings via FIFO from all trade data.
93+
result, err := ibctlholdings.GetHoldingsOverview(mergedData.Trades, mergedData.Positions, mergedData.CashPositions, config, fxStore)
94+
if err != nil {
95+
return err
96+
}
97+
// Sum up portfolio value, STCG, and LTCG from all holdings.
98+
var totalValueMicros, totalSTCGMicros, totalLTCGMicros int64
99+
for _, h := range result.Holdings {
100+
totalValueMicros += mathpb.ParseMicros(h.MarketValueUSD)
101+
totalSTCGMicros += mathpb.ParseMicros(h.STCGUSD)
102+
totalLTCGMicros += mathpb.ParseMicros(h.LTCGUSD)
103+
}
104+
// Compute tax amounts. Gains are taxed; losses reduce taxes (can be negative).
105+
stcgTaxMicros := int64(math.Round(float64(totalSTCGMicros) * config.TaxRateSTCG))
106+
ltcgTaxMicros := int64(math.Round(float64(totalLTCGMicros) * config.TaxRateLTCG))
107+
totalTaxMicros := stcgTaxMicros + ltcgTaxMicros
108+
// After-tax value = portfolio value - total taxes.
109+
afterTaxMicros := totalValueMicros - totalTaxMicros
110+
// Print the summary.
111+
writer := os.Stdout
112+
fmt.Fprintf(writer, "Portfolio Value: %s\n", cliio.FormatUSDMicros(totalValueMicros))
113+
fmt.Fprintf(writer, "\n")
114+
fmt.Fprintf(writer, "STCG: %s\n", cliio.FormatUSDMicros(totalSTCGMicros))
115+
fmt.Fprintf(writer, "STCG Tax (%.1f%%): %s\n", config.TaxRateSTCG*100, cliio.FormatUSDMicros(stcgTaxMicros))
116+
fmt.Fprintf(writer, "LTCG: %s\n", cliio.FormatUSDMicros(totalLTCGMicros))
117+
fmt.Fprintf(writer, "LTCG Tax (%.1f%%): %s\n", config.TaxRateLTCG*100, cliio.FormatUSDMicros(ltcgTaxMicros))
118+
fmt.Fprintf(writer, "Total Tax: %s\n", cliio.FormatUSDMicros(totalTaxMicros))
119+
fmt.Fprintf(writer, "\n")
120+
fmt.Fprintf(writer, "After-Tax Value: %s\n", cliio.FormatUSDMicros(afterTaxMicros))
121+
return nil
122+
}

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

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,25 @@ import (
2121
"github.com/spf13/pflag"
2222
)
2323

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

3033
// NewCommand returns a new lot list command.
3134
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
3235
flags := newFlags()
3336
return &appcmd.Command{
34-
Use: name + " <symbol>",
35-
Short: "List individual tax lots for a symbol",
36-
Args: appcmd.ExactArgs(1),
37+
Use: name,
38+
Short: "List individual tax lots, optionally filtered by symbol",
39+
Args: appcmd.NoArgs,
3740
Run: builder.NewRunFunc(
3841
func(ctx context.Context, container appext.Container) error {
39-
return run(ctx, container, flags, container.Arg(0))
42+
return run(ctx, container, flags)
4043
},
4144
),
4245
BindFlags: flags.Bind,
@@ -50,6 +53,8 @@ type flags struct {
5053
Format string
5154
// Download fetches fresh data before displaying.
5255
Download bool
56+
// Symbol filters lots to a specific symbol. Empty means all symbols.
57+
Symbol string
5358
}
5459

5560
func newFlags() *flags {
@@ -61,9 +66,10 @@ func (f *flags) Bind(flagSet *pflag.FlagSet) {
6166
flagSet.StringVar(&f.Dir, ibctlcmd.DirFlagName, ".", "The ibctl directory containing ibctl.yaml")
6267
flagSet.StringVar(&f.Format, formatFlagName, "table", "Output format (table, csv, json)")
6368
flagSet.BoolVar(&f.Download, downloadFlagName, false, "Download fresh data before displaying")
69+
flagSet.StringVar(&f.Symbol, symbolFlagName, "", "Filter by symbol (omit for all symbols)")
6470
}
6571

66-
func run(ctx context.Context, container appext.Container, flags *flags, symbol string) error {
72+
func run(ctx context.Context, container appext.Container, flags *flags) error {
6773
format, err := cliio.ParseFormat(flags.Format)
6874
if err != nil {
6975
return appcmd.NewInvalidArgumentError(err.Error())
@@ -96,8 +102,8 @@ func run(ctx context.Context, container appext.Container, flags *flags, symbol s
96102
}
97103
// Load FX rates for USD conversion.
98104
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)
105+
// Get the lot list, optionally filtered by symbol.
106+
result, err := ibctlholdings.GetLotList(flags.Symbol, mergedData.Trades, mergedData.Positions, fxStore)
101107
if err != nil {
102108
return err
103109
}
@@ -110,14 +116,14 @@ func run(ctx context.Context, container appext.Container, flags *flags, symbol s
110116
for _, l := range result.Lots {
111117
rows = append(rows, ibctlholdings.LotOverviewToTableRow(l))
112118
}
113-
// Build totals row for P&L USD, VALUE USD, STCG USD, LTCG USD columns.
119+
// Build totals row.
114120
totals := ibctlholdings.ComputeLotTotals(result.Lots)
115121
totalsRow := make([]string, len(headers))
116122
totalsRow[0] = "TOTAL"
117-
totalsRow[8] = totals.PnLUSD
118-
totalsRow[9] = totals.STCGUSD
119-
totalsRow[10] = totals.LTCGUSD
120-
totalsRow[11] = totals.ValueUSD
123+
totalsRow[9] = totals.PnLUSD
124+
totalsRow[10] = totals.STCGUSD
125+
totalsRow[11] = totals.LTCGUSD
126+
totalsRow[12] = totals.ValueUSD
121127
return cliio.WriteTableWithTotals(writer, headers, rows, totalsRow)
122128
case cliio.FormatCSV:
123129
headers := ibctlholdings.LotListHeaders()

internal/ibctl/ibctlconfig/ibctlconfig.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ type ExternalConfigV1 struct {
6767
// Adjustments maps currency codes to manual cash adjustments (positive or negative).
6868
// Applied to cash positions in the holdings display.
6969
Adjustments map[string]string `yaml:"adjustments"`
70+
// Taxes configures capital gains tax rates for portfolio value computation.
71+
Taxes *ExternalTaxConfigV1 `yaml:"taxes"`
72+
}
73+
74+
// ExternalTaxConfigV1 holds capital gains tax rate configuration.
75+
type ExternalTaxConfigV1 struct {
76+
// STCG is the short-term capital gains tax rate (e.g., 0.408 for 40.8%).
77+
STCG float64 `yaml:"stcg"`
78+
// LTCG is the long-term capital gains tax rate (e.g., 0.28 for 28%).
79+
LTCG float64 `yaml:"ltcg"`
7080
}
7181

7282
// ExternalSymbolConfigV1 holds classification metadata for a symbol in v1 config.
@@ -99,6 +109,10 @@ type Config struct {
99109
// CashAdjustments maps currency codes to manual cash adjustments in micros.
100110
// Applied to cash positions in the holdings display.
101111
CashAdjustments map[string]int64
112+
// TaxRateSTCG is the short-term capital gains tax rate (e.g., 0.408).
113+
TaxRateSTCG float64
114+
// TaxRateLTCG is the long-term capital gains tax rate (e.g., 0.28).
115+
TaxRateLTCG float64
102116
}
103117

104118
// SymbolConfig holds classification metadata for a symbol.
@@ -166,13 +180,21 @@ func NewConfigV1(externalConfig ExternalConfigV1, dirPath string) (*Config, erro
166180
}
167181
cashAdjustments[currency] = units*1_000_000 + micros
168182
}
183+
// Extract tax rates if configured.
184+
var taxRateSTCG, taxRateLTCG float64
185+
if externalConfig.Taxes != nil {
186+
taxRateSTCG = externalConfig.Taxes.STCG
187+
taxRateLTCG = externalConfig.Taxes.LTCG
188+
}
169189
return &Config{
170190
DirPath: dirPath,
171191
IBKRFlexQueryID: externalConfig.FlexQueryID,
172192
AccountAliases: accountAliases,
173193
AccountIDToAlias: accountIDToAlias,
174194
SymbolConfigs: symbolConfigs,
175195
CashAdjustments: cashAdjustments,
196+
TaxRateSTCG: taxRateSTCG,
197+
TaxRateLTCG: taxRateLTCG,
176198
}, nil
177199
}
178200

0 commit comments

Comments
 (0)