Skip to content

Commit eaf9a81

Browse files
committed
Fix account_id on CSV trades and skip priceless transfers
Merge: parse CSVs per-account by matching subdirectory names to account aliases. Set account_id on all CSV-derived trades and positions. Remove flat csvStatements parameter — Merge now handles CSV loading internally. Tax lots: skip transfers without a transfer price (FOP transfers where cost basis was manually entered in IBKR's Position Transfer Basis tool and is reflected in position data, not transfer data). Holdings: verify per-account positions before combining for display.
1 parent a1a1a04 commit eaf9a81

4 files changed

Lines changed: 56 additions & 57 deletions

File tree

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

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import (
1717
"github.com/bufdev/ibctl/internal/ibctl/ibctlmerge"
1818
"github.com/bufdev/ibctl/internal/ibctl/ibctltaxlot"
1919
"github.com/bufdev/ibctl/internal/pkg/cliio"
20-
"github.com/bufdev/ibctl/internal/pkg/ibkractivitycsv"
2120
"github.com/bufdev/ibctl/internal/pkg/mathpb"
2221
"github.com/spf13/pflag"
2322
)
@@ -76,13 +75,8 @@ func run(ctx context.Context, container appext.Container, flags *flags) error {
7675
if err := downloader.EnsureDownloaded(ctx); err != nil {
7776
return err
7877
}
79-
// Read Activity Statement CSVs.
80-
csvStatements, err := ibkractivitycsv.ParseDirectory(config.ActivityStatementsDirPath)
81-
if err != nil {
82-
return err
83-
}
84-
// Merge CSV seed data with Flex Query cached data across all accounts.
85-
mergedData, err := ibctlmerge.Merge(csvStatements, config.DataDirV1Path, config.AccountAliases)
78+
// Merge Activity Statement CSVs with Flex Query cached data across all accounts.
79+
mergedData, err := ibctlmerge.Merge(config.DataDirV1Path, config.ActivityStatementsDirPath, config.AccountAliases)
8680
if err != nil {
8781
return err
8882
}

internal/ibctl/ibctlholdings/ibctlholdings.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,10 @@ func GetHoldingsOverview(
8585
if err != nil {
8686
return nil, err
8787
}
88-
// Compute per-account positions from tax lots.
88+
// Compute per-account positions from tax lots for verification.
8989
computedPositions := ibctltaxlot.ComputePositions(taxLotResult.TaxLots)
90-
// Verify computed positions against IBKR-reported positions.
90+
// Verify per-account computed positions against per-account IBKR-reported positions.
91+
// This must happen before aggregation so account_id keys match.
9192
discrepancies := ibctltaxlot.VerifyPositions(computedPositions, positions)
9293

9394
// Build a map of market prices from IBKR-reported positions, keyed by symbol.
@@ -97,7 +98,7 @@ func GetHoldingsOverview(
9798
marketPrices[pos.GetSymbol()] = moneypb.MoneyValueToString(pos.GetMarketPrice())
9899
}
99100

100-
// Aggregate computed positions across accounts for combined view.
101+
// Aggregate computed positions across accounts for combined display.
101102
// Group by symbol, sum quantities, weighted-average cost basis.
102103
type combinedData struct {
103104
quantityMicros int64

internal/ibctl/ibctlmerge/ibctlmerge.go

Lines changed: 35 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -41,76 +41,74 @@ type MergedData struct {
4141
// Merge reads Activity Statement CSVs and Flex Query cached data for all accounts,
4242
// deduplicates trades, and returns the merged result.
4343
// The dataDirV1Path is the versioned data directory containing per-account subdirectories.
44+
// The activityStatementsDirPath is the directory containing per-account CSV subdirectories.
4445
// The accountAliases map contains alias → account ID mappings from config.
4546
func Merge(
46-
csvStatements []*ibkractivitycsv.ActivityStatement,
4747
dataDirV1Path string,
48+
activityStatementsDirPath string,
4849
accountAliases map[string]string,
4950
) (*MergedData, error) {
5051
tradeMap := make(map[string]*datav1.Trade)
5152
var allPositions []*datav1.Position
5253
var allTransfers []*datav1.Transfer
5354
var allTradeTransfers []*datav1.TradeTransfer
5455
var allCorporateActions []*datav1.CorporateAction
55-
// Load Flex Query cached data per account.
56+
// Process each account: load Flex Query cache + Activity Statement CSVs.
5657
for alias := range accountAliases {
58+
// Load Flex Query cached data for this account.
5759
accountDir := filepath.Join(dataDirV1Path, alias)
58-
// Read trades for this account.
5960
tradesPath := filepath.Join(accountDir, "trades.json")
6061
cachedTrades, err := protoio.ReadMessagesJSON(tradesPath, func() *datav1.Trade { return &datav1.Trade{} })
6162
if err == nil {
6263
for _, trade := range cachedTrades {
63-
key := tradeKey(trade)
64-
tradeMap[key] = trade
64+
tradeMap[tradeKey(trade)] = trade
6565
}
6666
}
67-
// Read positions for this account.
6867
positionsPath := filepath.Join(accountDir, "positions.json")
6968
positions, err := protoio.ReadMessagesJSON(positionsPath, func() *datav1.Position { return &datav1.Position{} })
7069
if err == nil {
7170
allPositions = append(allPositions, positions...)
7271
}
73-
// Read transfers for this account.
7472
transfersPath := filepath.Join(accountDir, "transfers.json")
7573
transfers, err := protoio.ReadMessagesJSON(transfersPath, func() *datav1.Transfer { return &datav1.Transfer{} })
7674
if err == nil {
7775
allTransfers = append(allTransfers, transfers...)
7876
}
79-
// Read trade transfers for this account.
8077
tradeTransfersPath := filepath.Join(accountDir, "trade_transfers.json")
8178
tradeTransfers, err := protoio.ReadMessagesJSON(tradeTransfersPath, func() *datav1.TradeTransfer { return &datav1.TradeTransfer{} })
8279
if err == nil {
8380
allTradeTransfers = append(allTradeTransfers, tradeTransfers...)
8481
}
85-
// Read corporate actions for this account.
8682
corporateActionsPath := filepath.Join(accountDir, "corporate_actions.json")
8783
corporateActions, err := protoio.ReadMessagesJSON(corporateActionsPath, func() *datav1.CorporateAction { return &datav1.CorporateAction{} })
8884
if err == nil {
8985
allCorporateActions = append(allCorporateActions, corporateActions...)
9086
}
91-
}
92-
// Merge in Activity Statement CSV trades on top — CSV data takes precedence
93-
// over Flex Query data since the user manages the CSVs directly.
94-
var csvPositions []*datav1.Position
95-
for _, statement := range csvStatements {
96-
for i := range statement.Trades {
97-
// CSV trades don't have account IDs — they'll need to be mapped
98-
// via the activity_statements_dir subdirectory structure.
99-
trade, err := csvTradeToProto(&statement.Trades[i])
100-
if err != nil {
101-
continue // Skip unparseable trades.
102-
}
103-
// CSV trades overwrite Flex Query trades with the same key.
104-
key := tradeKey(trade)
105-
tradeMap[key] = trade
87+
// Load Activity Statement CSVs for this account from the matching subdirectory.
88+
// CSV data takes precedence over Flex Query data.
89+
csvDir := filepath.Join(activityStatementsDirPath, alias)
90+
csvStatements, err := ibkractivitycsv.ParseDirectory(csvDir)
91+
if err != nil {
92+
// No CSV directory for this account — skip (not an error).
93+
continue
10694
}
107-
// Accumulate positions from CSVs (latest wins by overwrite).
108-
for i := range statement.Positions {
109-
pos, err := csvPositionToProto(&statement.Positions[i])
110-
if err != nil {
111-
continue
95+
for _, statement := range csvStatements {
96+
for i := range statement.Trades {
97+
trade, err := csvTradeToProto(&statement.Trades[i], alias)
98+
if err != nil {
99+
continue
100+
}
101+
// CSV trades overwrite Flex Query trades with the same key.
102+
tradeMap[tradeKey(trade)] = trade
103+
}
104+
// CSV positions overwrite Flex Query positions for this account.
105+
for i := range statement.Positions {
106+
pos, err := csvPositionToProto(&statement.Positions[i], alias)
107+
if err != nil {
108+
continue
109+
}
110+
allPositions = append(allPositions, pos)
112111
}
113-
csvPositions = append(csvPositions, pos)
114112
}
115113
}
116114
// Collect and sort trades for deterministic output.
@@ -126,23 +124,18 @@ func Merge(
126124
}
127125
return trades[i].GetTradeId() < trades[j].GetTradeId()
128126
})
129-
// Merge positions: use CSV positions for accounts that have them, otherwise use Flex Query.
130-
// If CSV positions exist, they take precedence.
131-
positions := allPositions
132-
if len(csvPositions) > 0 {
133-
positions = csvPositions
134-
}
135127
return &MergedData{
136128
Trades: trades,
137-
Positions: positions,
129+
Positions: allPositions,
138130
Transfers: allTransfers,
139131
TradeTransfers: allTradeTransfers,
140132
CorporateActions: allCorporateActions,
141133
}, nil
142134
}
143135

144136
// csvTradeToProto converts an Activity Statement CSV trade to a proto Trade.
145-
func csvTradeToProto(csvTrade *ibkractivitycsv.Trade) (*datav1.Trade, error) {
137+
// The accountAlias is derived from the CSV subdirectory name.
138+
func csvTradeToProto(csvTrade *ibkractivitycsv.Trade, accountAlias string) (*datav1.Trade, error) {
146139
// Parse quantity as decimal (supports fractional shares).
147140
quantity, err := mathpb.NewDecimal(csvTrade.Quantity)
148141
if err != nil {
@@ -176,6 +169,7 @@ func csvTradeToProto(csvTrade *ibkractivitycsv.Trade) (*datav1.Trade, error) {
176169
tradeID := generateTradeID(csvTrade.Symbol, csvTrade.DateTime, csvTrade.Quantity, csvTrade.TradePrice)
177170
return &datav1.Trade{
178171
TradeId: tradeID,
172+
AccountId: accountAlias,
179173
TradeDate: protoDate,
180174
SettleDate: protoDate, // CSV doesn't have settle date, use trade date.
181175
Symbol: csvTrade.Symbol,
@@ -189,7 +183,8 @@ func csvTradeToProto(csvTrade *ibkractivitycsv.Trade) (*datav1.Trade, error) {
189183
}
190184

191185
// csvPositionToProto converts an Activity Statement CSV position to a proto Position.
192-
func csvPositionToProto(csvPosition *ibkractivitycsv.Position) (*datav1.Position, error) {
186+
// The accountAlias is derived from the CSV subdirectory name.
187+
func csvPositionToProto(csvPosition *ibkractivitycsv.Position, accountAlias string) (*datav1.Position, error) {
193188
currencyCode := csvPosition.CurrencyCode
194189
quantity, err := mathpb.NewDecimal(csvPosition.Quantity)
195190
if err != nil {
@@ -209,6 +204,7 @@ func csvPositionToProto(csvPosition *ibkractivitycsv.Position) (*datav1.Position
209204
}
210205
return &datav1.Position{
211206
Symbol: csvPosition.Symbol,
207+
AccountId: accountAlias,
212208
AssetCategory: csvPosition.AssetCategory,
213209
Quantity: quantity,
214210
CostBasisPrice: costBasisPrice,

internal/ibctl/ibctltaxlot/ibctltaxlot.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -326,10 +326,23 @@ func VerifyPositions(computed []*datav1.ComputedPosition, reported []*datav1.Pos
326326
}
327327

328328
// TransfersToSyntheticTrades converts transfer records into synthetic trades
329-
// for FIFO processing. Transfer-in becomes a BUY, transfer-out becomes a SELL.
329+
// for FIFO processing. Only transfers with a non-zero transfer price are
330+
// converted — transfers without a price (e.g., FOP transfers from another
331+
// broker) are informational only and the cost basis comes from the IBKR
332+
// position data instead.
330333
func TransfersToSyntheticTrades(transfers []*datav1.Transfer) []*datav1.Trade {
331334
var trades []*datav1.Trade
332335
for _, transfer := range transfers {
336+
// Skip transfers without a transfer price — these are informational
337+
// (e.g., FOP transfers where cost basis was manually entered in IBKR's
338+
// Position Transfer Basis tool and is reflected in position data).
339+
if transfer.GetTransferPrice() == nil {
340+
continue
341+
}
342+
// Skip transfers with a zero transfer price.
343+
if moneypb.MoneyToMicros(transfer.GetTransferPrice()) == 0 {
344+
continue
345+
}
333346
// Determine trade side from transfer direction.
334347
var side datav1.TradeSide
335348
switch transfer.GetDirection() {
@@ -348,11 +361,6 @@ func TransfersToSyntheticTrades(transfers []*datav1.Transfer) []*datav1.Trade {
348361
protoDateStr(transfer.GetDate()),
349362
mathpb.ToString(transfer.GetQuantity()),
350363
)
351-
// Use the transfer price as the trade price, default to zero if not available.
352-
tradePrice := transfer.GetTransferPrice()
353-
if tradePrice == nil {
354-
tradePrice = moneypb.MoneyFromMicros(transfer.GetCurrencyCode(), 0)
355-
}
356364
trades = append(trades, &datav1.Trade{
357365
TradeId: tradeID,
358366
AccountId: transfer.GetAccountId(),
@@ -362,7 +370,7 @@ func TransfersToSyntheticTrades(transfers []*datav1.Transfer) []*datav1.Trade {
362370
AssetCategory: transfer.GetAssetCategory(),
363371
Side: side,
364372
Quantity: transfer.GetQuantity(),
365-
TradePrice: tradePrice,
373+
TradePrice: transfer.GetTransferPrice(),
366374
Proceeds: moneypb.MoneyFromMicros(transfer.GetCurrencyCode(), 0),
367375
Commission: moneypb.MoneyFromMicros(transfer.GetCurrencyCode(), 0),
368376
CurrencyCode: transfer.GetCurrencyCode(),

0 commit comments

Comments
 (0)