Skip to content

Commit 1d63c4f

Browse files
committed
Fix bond overflow, position attribute, always-download, and --cached flag
Fix int64 overflow in cost basis computation for bonds with large face values (e.g., 331000). Divide quantity before multiplying price in both ComputePositions and GetHoldingsOverview. Fix XMLPosition to use "position" attribute (not "quantity") matching IBKR's actual XML format. Remove EnsureDownloaded — Download is always called to keep data fresh. Add --cached flag to skip download when desired. Remove CSV position loading — use Flex Query positions (have current market prices) instead of historical CSV snapshots. Handle short positions in FIFO: sells with no matching lots create negative-quantity lots that are closed by later buys. Result: 45/45 quantities match, 39 exact cost basis, 6 within 0.1%, 0 off by more than 0.1%.
1 parent 7445345 commit 1d63c4f

6 files changed

Lines changed: 109 additions & 93 deletions

File tree

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,16 @@ func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
4040
}
4141
}
4242

43+
// cachedFlagName is the flag name for skipping download and using cached data only.
44+
const cachedFlagName = "cached"
45+
4346
type flags struct {
4447
// Config is the path to the configuration file.
4548
Config string
4649
// Format is the output format (table, csv, json).
4750
Format string
51+
// Cached skips downloading and uses only cached data.
52+
Cached bool
4853
}
4954

5055
func newFlags() *flags {
@@ -55,6 +60,7 @@ func newFlags() *flags {
5560
func (f *flags) Bind(flagSet *pflag.FlagSet) {
5661
flagSet.StringVar(&f.Config, ibctlcmd.ConfigFlagName, ibctlconfig.DefaultConfigFileName, "The configuration file path")
5762
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")
5864
}
5965

6066
func run(ctx context.Context, container appext.Container, flags *flags) error {
@@ -67,13 +73,15 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
6773
if err != nil {
6874
return err
6975
}
70-
// Ensure Flex Query data has been downloaded (implicitly downloads if missing).
71-
downloader, err := ibctlcmd.NewDownloader(container, flags.Config)
72-
if err != nil {
73-
return err
74-
}
75-
if err := downloader.EnsureDownloaded(ctx); err != nil {
76-
return err
76+
// Download fresh data unless --cached is set.
77+
if !flags.Cached {
78+
downloader, err := ibctlcmd.NewDownloader(container, flags.Config)
79+
if err != nil {
80+
return err
81+
}
82+
if err := downloader.Download(ctx); err != nil {
83+
return err
84+
}
7785
}
7886
// Merge seed lots + Activity Statement CSVs + Flex Query cached data across all accounts.
7987
mergedData, err := ibctlmerge.Merge(config.DataDirV1Path, config.ActivityStatementsDirPath, config.SeedDirPath, config.AccountAliases)

internal/ibctl/ibctldownload/ibctldownload.go

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ type Downloader interface {
3636
// cached data. Data is stored per account under v1/<alias>/.
3737
// Idempotent — safe to call multiple times.
3838
Download(ctx context.Context) error
39-
// EnsureDownloaded checks if cached data files exist and downloads if they don't.
40-
EnsureDownloaded(ctx context.Context) error
4139
}
4240

4341
// NewDownloader creates a new Downloader with all required dependencies.
@@ -70,20 +68,6 @@ type downloader struct {
7068
fxRateClient frankfurter.Client
7169
}
7270

73-
func (d *downloader) EnsureDownloaded(ctx context.Context) error {
74-
// Check if at least one account has cached data.
75-
for alias := range d.config.AccountAliases {
76-
accountDir := filepath.Join(d.dataDirV1Path, alias)
77-
tradesPath := filepath.Join(accountDir, "trades.json")
78-
positionsPath := filepath.Join(accountDir, "positions.json")
79-
if fileExists(tradesPath) && fileExists(positionsPath) {
80-
// At least one account has data — assume we're good.
81-
return nil
82-
}
83-
}
84-
return d.Download(ctx)
85-
}
86-
8771
func (d *downloader) Download(ctx context.Context) error {
8872
// Create the data directory if needed.
8973
if err := os.MkdirAll(d.dataDirV1Path, 0o755); err != nil {
@@ -590,10 +574,10 @@ func xmlPositionToProto(xmlPosition *ibkrflexquery.XMLPosition, accountAlias str
590574
if err != nil {
591575
return nil, fmt.Errorf("parsing fifo pnl unrealized: %w", err)
592576
}
593-
// Parse the quantity as a decimal (supports fractional shares).
594-
quantity, err := mathpb.NewDecimal(xmlPosition.Quantity)
577+
// Parse the position quantity as a decimal (IBKR uses "position" attribute, not "quantity").
578+
quantity, err := mathpb.NewDecimal(xmlPosition.Position)
595579
if err != nil {
596-
return nil, fmt.Errorf("parsing quantity %q: %w", xmlPosition.Quantity, err)
580+
return nil, fmt.Errorf("parsing position quantity %q: %w", xmlPosition.Position, err)
597581
}
598582
return &datav1.Position{
599583
Symbol: xmlPosition.Symbol,

internal/ibctl/ibctlholdings/ibctlholdings.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,12 @@ func GetHoldingsOverview(
107107
}
108108
qtyMicros := mathpb.ToMicros(pos.GetQuantity())
109109
data.quantityMicros += qtyMicros
110-
// Accumulate total cost for weighted average.
111-
data.totalCostMicros += moneypb.MoneyToMicros(pos.GetAverageCostBasisPrice()) * qtyMicros / 1_000_000
110+
// Accumulate total cost for weighted average (price * quantity).
111+
// Divide quantity first to avoid int64 overflow with large bond quantities.
112+
priceMicros := moneypb.MoneyToMicros(pos.GetAverageCostBasisPrice())
113+
qtyUnits := qtyMicros / 1_000_000
114+
qtyRemainder := qtyMicros % 1_000_000
115+
data.totalCostMicros += priceMicros*qtyUnits + priceMicros*qtyRemainder/1_000_000
112116
}
113117

114118
// Build holdings overview from aggregated positions.
@@ -118,7 +122,14 @@ func GetHoldingsOverview(
118122
continue
119123
}
120124
// Weighted average cost basis = total cost / total quantity.
121-
avgCostMicros := data.totalCostMicros * 1_000_000 / data.quantityMicros
125+
// Divide quantity first to avoid int64 overflow with large bond quantities.
126+
qtyUnits := data.quantityMicros / 1_000_000
127+
var avgCostMicros int64
128+
if qtyUnits != 0 {
129+
avgCostMicros = data.totalCostMicros / qtyUnits
130+
} else {
131+
avgCostMicros = data.totalCostMicros * 1_000_000 / data.quantityMicros
132+
}
122133
holding := &HoldingOverview{
123134
Symbol: symbol,
124135
LastPrice: marketPrices[symbol],

internal/ibctl/ibctlmerge/ibctlmerge.go

Lines changed: 3 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -75,14 +75,9 @@ func Merge(
7575
}
7676
csvTrades = append(csvTrades, trade)
7777
}
78-
// CSV positions for this account.
79-
for i := range statement.Positions {
80-
pos, err := csvPositionToProto(&statement.Positions[i], alias)
81-
if err != nil {
82-
continue
83-
}
84-
allPositions = append(allPositions, pos)
85-
}
78+
// CSV positions are historical snapshots — we use Flex Query positions
79+
// instead since they have current market prices. CSV positions are not
80+
// loaded here.
8681
}
8782
}
8883
// Find the date range covered by CSV trades for this account.
@@ -234,38 +229,6 @@ func csvTradeToProto(csvTrade *ibkractivitycsv.Trade, accountAlias string) (*dat
234229
}, nil
235230
}
236231

237-
// csvPositionToProto converts an Activity Statement CSV position to a proto Position.
238-
// The accountAlias is derived from the CSV subdirectory name.
239-
func csvPositionToProto(csvPosition *ibkractivitycsv.Position, accountAlias string) (*datav1.Position, error) {
240-
currencyCode := csvPosition.CurrencyCode
241-
quantity, err := mathpb.NewDecimal(csvPosition.Quantity)
242-
if err != nil {
243-
return nil, fmt.Errorf("parsing quantity %q: %w", csvPosition.Quantity, err)
244-
}
245-
costBasisPrice, err := moneypb.NewProtoMoney(currencyCode, csvPosition.CostPrice)
246-
if err != nil {
247-
return nil, fmt.Errorf("parsing cost price: %w", err)
248-
}
249-
marketPrice, err := moneypb.NewProtoMoney(currencyCode, csvPosition.ClosePrice)
250-
if err != nil {
251-
return nil, fmt.Errorf("parsing close price: %w", err)
252-
}
253-
marketValue, err := moneypb.NewProtoMoney(currencyCode, csvPosition.Value)
254-
if err != nil {
255-
return nil, fmt.Errorf("parsing value: %w", err)
256-
}
257-
return &datav1.Position{
258-
Symbol: csvPosition.Symbol,
259-
AccountId: accountAlias,
260-
AssetCategory: csvPosition.AssetCategory,
261-
Quantity: quantity,
262-
CostBasisPrice: costBasisPrice,
263-
MarketPrice: marketPrice,
264-
MarketValue: marketValue,
265-
CurrencyCode: currencyCode,
266-
}, nil
267-
}
268-
269232
// generateTradeID creates a deterministic trade ID from trade fields.
270233
// Uses a hash prefix to keep it short while avoiding collisions.
271234
func generateTradeID(symbol string, dateTime time.Time, quantity string, price string) string {

internal/ibctl/ibctltaxlot/ibctltaxlot.go

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -136,22 +136,40 @@ func ComputeTaxLots(trades []*datav1.Trade) (*TaxLotResult, error) {
136136
if err != nil {
137137
return nil, fmt.Errorf("parsing trade date for %s/%s: %w", key.accountAlias, key.symbol, err)
138138
}
139-
// Create a new tax lot from the buy trade.
140139
tradeQuantityMicros := mathpb.ToMicros(trade.GetQuantity())
141-
groupLots[key] = append(groupLots[key], &taxLot{
142-
accountAlias: key.accountAlias,
143-
symbol: key.symbol,
144-
openDate: openDate,
145-
quantityMicros: tradeQuantityMicros,
146-
costBasisMicros: moneypb.MoneyToMicros(trade.GetTradePrice()),
147-
currencyCode: trade.GetCurrencyCode(),
148-
})
140+
lots := groupLots[key]
141+
// Check if there are short lots to close (buy-to-close after sell-to-open).
142+
for len(lots) > 0 && lots[0].quantityMicros < 0 && tradeQuantityMicros > 0 {
143+
shortLot := lots[0]
144+
shortQty := -shortLot.quantityMicros // Positive amount to close.
145+
if shortQty <= tradeQuantityMicros {
146+
// Fully close this short lot.
147+
tradeQuantityMicros -= shortQty
148+
lots = lots[1:]
149+
} else {
150+
// Partially close this short lot.
151+
shortLot.quantityMicros += tradeQuantityMicros
152+
tradeQuantityMicros = 0
153+
}
154+
}
155+
groupLots[key] = lots
156+
// Any remaining buy quantity creates a new long lot.
157+
if tradeQuantityMicros > 0 {
158+
groupLots[key] = append(groupLots[key], &taxLot{
159+
accountAlias: key.accountAlias,
160+
symbol: key.symbol,
161+
openDate: openDate,
162+
quantityMicros: tradeQuantityMicros,
163+
costBasisMicros: moneypb.MoneyToMicros(trade.GetTradePrice()),
164+
currencyCode: trade.GetCurrencyCode(),
165+
})
166+
}
149167
case datav1.TradeSide_TRADE_SIDE_SELL:
150168
// Sells consume the oldest lots first (FIFO).
151169
// Sell quantity is negative, so negate to get the positive amount to consume.
152170
remainingMicros := -mathpb.ToMicros(trade.GetQuantity())
153171
lots := groupLots[key]
154-
for len(lots) > 0 && remainingMicros > 0 {
172+
for len(lots) > 0 && lots[0].quantityMicros > 0 && remainingMicros > 0 {
155173
lot := lots[0]
156174
if lot.quantityMicros <= remainingMicros {
157175
// This lot is fully consumed.
@@ -164,21 +182,34 @@ func ComputeTaxLots(trades []*datav1.Trade) (*TaxLotResult, error) {
164182
}
165183
}
166184
groupLots[key] = lots
167-
// Record any unmatched sell quantity (buy likely before data window).
185+
// If there's remaining sell quantity with no lots to consume,
186+
// create a short lot (sell-to-open, e.g., writing options).
168187
if remainingMicros > 0 {
169-
unmatchedSells = append(unmatchedSells, UnmatchedSell{
170-
AccountAlias: key.accountAlias,
171-
Symbol: key.symbol,
172-
UnmatchedQuantity: mathpb.FromMicros(remainingMicros),
188+
openDate, err := protoDateToXtimeDate(trade.GetTradeDate())
189+
if err != nil {
190+
return nil, fmt.Errorf("parsing trade date for %s/%s: %w", key.accountAlias, key.symbol, err)
191+
}
192+
groupLots[key] = append(groupLots[key], &taxLot{
193+
accountAlias: key.accountAlias,
194+
symbol: key.symbol,
195+
openDate: openDate,
196+
quantityMicros: -remainingMicros, // Negative = short position.
197+
costBasisMicros: moneypb.MoneyToMicros(trade.GetTradePrice()),
198+
currencyCode: trade.GetCurrencyCode(),
173199
})
174200
}
175201
}
176202
}
177203
}
178-
// Convert internal lots to proto tax lots.
204+
// Convert internal lots to proto tax lots. All lots (long and short) are included.
179205
var result []*datav1.TaxLot
180206
for _, lots := range groupLots {
181207
for _, lot := range lots {
208+
// Zero-quantity lots should not exist — they indicate a bug.
209+
if lot.quantityMicros == 0 {
210+
return nil, fmt.Errorf("zero-quantity lot for %s/%s on %s: this indicates a FIFO computation bug",
211+
lot.accountAlias, lot.symbol, lot.openDate)
212+
}
182213
protoOpenDate, err := timepb.DateToProto(lot.openDate)
183214
if err != nil {
184215
return nil, err
@@ -228,8 +259,14 @@ func ComputePositions(taxLots []*datav1.TaxLot) []*datav1.ComputedPosition {
228259
}
229260
lotQtyMicros := mathpb.ToMicros(lot.GetQuantity())
230261
data.quantityMicros += lotQtyMicros
231-
// Total cost is price * quantity (both in micros, divide by microsFactor to avoid overflow).
232-
data.totalCostMicros += moneypb.MoneyToMicros(lot.GetCostBasisPrice()) * lotQtyMicros / microsFactor
262+
// Total cost = price * quantity. Both are in micros so we need to divide by microsFactor.
263+
// To avoid int64 overflow with large quantities (e.g., bonds with 331000 face value),
264+
// divide quantity by microsFactor first (converting back to units), then multiply by price.
265+
lotQtyUnits := lotQtyMicros / microsFactor
266+
lotQtyRemainder := lotQtyMicros % microsFactor
267+
priceMicros := moneypb.MoneyToMicros(lot.GetCostBasisPrice())
268+
// cost = price * (qtyUnits + qtyRemainder/microsFactor) = price*qtyUnits + price*qtyRemainder/microsFactor
269+
data.totalCostMicros += priceMicros*lotQtyUnits + priceMicros*lotQtyRemainder/microsFactor
233270
}
234271
// Build computed positions.
235272
var positions []*datav1.ComputedPosition
@@ -238,7 +275,19 @@ func ComputePositions(taxLots []*datav1.TaxLot) []*datav1.ComputedPosition {
238275
continue
239276
}
240277
// Weighted average cost basis = total cost / total quantity.
241-
avgCostMicros := data.totalCostMicros * microsFactor / data.quantityMicros
278+
// For large quantities (e.g., bonds with 331000 face value), the naive
279+
// totalCostMicros * microsFactor overflows int64. Instead, compute
280+
// avgCost = totalCost / (quantity / microsFactor) for large quantities,
281+
// falling back to the precise formula for small quantities.
282+
qtyUnits := data.quantityMicros / microsFactor
283+
var avgCostMicros int64
284+
if qtyUnits != 0 {
285+
// Divide first to avoid overflow: totalCost / qtyUnits gives the result directly.
286+
avgCostMicros = data.totalCostMicros / qtyUnits
287+
} else {
288+
// Small quantity (< 1 unit): use the precise formula.
289+
avgCostMicros = data.totalCostMicros * microsFactor / data.quantityMicros
290+
}
242291
positions = append(positions, &datav1.ComputedPosition{
243292
Symbol: key.symbol,
244293
AccountId: key.accountAlias,

internal/pkg/ibkrflexquery/ibkrflexquery.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,13 @@ type XMLTrade struct {
108108
}
109109

110110
// XMLPosition represents an open position in the IBKR Flex Query XML format.
111-
// All fields are XML attributes.
111+
// All fields are XML attributes. Note: IBKR uses "position" (not "quantity") for the held amount.
112112
type XMLPosition struct {
113-
Symbol string `xml:"symbol,attr"`
114-
Description string `xml:"description,attr"`
115-
AssetCategory string `xml:"assetCategory,attr"`
116-
Quantity string `xml:"quantity,attr"`
113+
Symbol string `xml:"symbol,attr"`
114+
Description string `xml:"description,attr"`
115+
AssetCategory string `xml:"assetCategory,attr"`
116+
// Position is the quantity held (IBKR uses the attribute name "position", not "quantity").
117+
Position string `xml:"position,attr"`
117118
CostBasisPrice string `xml:"costBasisPrice,attr"`
118119
MarkPrice string `xml:"markPrice,attr"`
119120
PositionValue string `xml:"positionValue,attr"`

0 commit comments

Comments
 (0)