Skip to content

Commit 7445345

Browse files
committed
Add ImportedTransaction proto and read seed transactions for FIFO
New proto: ImportedTransaction with account_id, date, type (BUY/SELL/ SPLIT/STOCK_DIVIDEND/EXPIRY), symbol, ibkr_symbol, quantity, price. Stores the complete normalized transaction history from previous brokers (UBS/RBC) as permanent seed data. Merge: read seed/<account>/transactions.json and convert to Trade protos for FIFO. Replaces the pre-computed lots.json approach with the full transaction history so FIFO can process buys AND sells. FIFO: sort buys before sells within the same date to handle same-day buy+sell scenarios correctly. Status: all 45 equity positions match IBKR truth file on quantity. Remaining issues: bond cost basis (face value vs unit pricing), short options (sell-to-open before buy-to-close), 8 expired options still showing.
1 parent cc9de33 commit 7445345

4 files changed

Lines changed: 410 additions & 7 deletions

File tree

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

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

internal/ibctl/ibctlmerge/ibctlmerge.go

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,13 +89,17 @@ func Merge(
8989
// Flex Query trades within this range will be excluded.
9090
csvMinDate, csvMaxDate := tradeDateRange(csvTrades)
9191
allTrades = append(allTrades, csvTrades...)
92-
// Step 2: Load seed lots (pre-transfer tax lots from previous broker).
93-
// These are permanent data that represent the original cost basis.
92+
// Step 2: Load imported transactions from previous broker (seed data).
93+
// These are the complete normalized transaction history (buys, sells,
94+
// splits, dividends, expiries) from UBS/RBC, converted to Trade protos.
9495
if seedDirPath != "" {
95-
seedLotsPath := filepath.Join(seedDirPath, alias, "lots.json")
96-
seedLots, err := protoio.ReadMessagesJSON(seedLotsPath, func() *datav1.Trade { return &datav1.Trade{} })
96+
seedTxnPath := filepath.Join(seedDirPath, alias, "transactions.json")
97+
importedTxns, err := protoio.ReadMessagesJSON(seedTxnPath, func() *datav1.ImportedTransaction { return &datav1.ImportedTransaction{} })
9798
if err == nil {
98-
allTrades = append(allTrades, seedLots...)
99+
for _, txn := range importedTxns {
100+
trade := importedTransactionToTrade(txn)
101+
allTrades = append(allTrades, trade)
102+
}
99103
}
100104
}
101105
// Step 3: Load Flex Query cached trades, excluding dates covered by CSVs.
@@ -270,6 +274,68 @@ func generateTradeID(symbol string, dateTime time.Time, quantity string, price s
270274
return fmt.Sprintf("csv-%x", hash[:8])
271275
}
272276

277+
// importedTransactionToTrade converts an ImportedTransaction (from the previous broker)
278+
// to a Trade proto for FIFO processing. Uses the ibkr_symbol field for the symbol
279+
// so it matches IBKR's naming convention.
280+
func importedTransactionToTrade(txn *datav1.ImportedTransaction) *datav1.Trade {
281+
// Map imported transaction type to trade side.
282+
var side datav1.TradeSide
283+
switch txn.GetType() {
284+
case datav1.ImportedTransactionType_IMPORTED_TRANSACTION_TYPE_BUY,
285+
datav1.ImportedTransactionType_IMPORTED_TRANSACTION_TYPE_STOCK_DIVIDEND:
286+
// Buys and stock dividends add to position.
287+
side = datav1.TradeSide_TRADE_SIDE_BUY
288+
case datav1.ImportedTransactionType_IMPORTED_TRANSACTION_TYPE_SELL,
289+
datav1.ImportedTransactionType_IMPORTED_TRANSACTION_TYPE_EXPIRY:
290+
// Sells and expiries reduce position.
291+
side = datav1.TradeSide_TRADE_SIDE_SELL
292+
case datav1.ImportedTransactionType_IMPORTED_TRANSACTION_TYPE_SPLIT:
293+
// Splits are handled as buys with zero price (quantity adjustment).
294+
if txn.GetQuantity() >= 0 {
295+
side = datav1.TradeSide_TRADE_SIDE_BUY
296+
} else {
297+
side = datav1.TradeSide_TRADE_SIDE_SELL
298+
}
299+
case datav1.ImportedTransactionType_IMPORTED_TRANSACTION_TYPE_UNSPECIFIED:
300+
// Skip unspecified transactions.
301+
side = datav1.TradeSide_TRADE_SIDE_UNSPECIFIED
302+
}
303+
// Use ibkr_symbol for FIFO matching.
304+
symbol := txn.GetIbkrSymbol()
305+
// Determine currency from price if available.
306+
currencyCode := "USD"
307+
if txn.GetPrice() != nil {
308+
currencyCode = txn.GetPrice().GetCurrencyCode()
309+
}
310+
// Build quantity as Decimal.
311+
quantity := mathpb.FromMicros(txn.GetQuantity() * 1_000_000)
312+
// Use price if available, otherwise zero.
313+
tradePrice := txn.GetPrice()
314+
if tradePrice == nil {
315+
tradePrice = moneypb.MoneyFromMicros(currencyCode, 0)
316+
}
317+
// Generate a deterministic trade ID.
318+
tradeID := fmt.Sprintf("imported-%s-%04d%02d%02d-%d",
319+
symbol,
320+
txn.GetDate().GetYear(), txn.GetDate().GetMonth(), txn.GetDate().GetDay(),
321+
txn.GetQuantity(),
322+
)
323+
return &datav1.Trade{
324+
TradeId: tradeID,
325+
AccountId: txn.GetAccountId(),
326+
TradeDate: txn.GetDate(),
327+
SettleDate: txn.GetDate(),
328+
Symbol: symbol,
329+
AssetCategory: "STK",
330+
Side: side,
331+
Quantity: quantity,
332+
TradePrice: tradePrice,
333+
Proceeds: moneypb.MoneyFromMicros(currencyCode, 0),
334+
Commission: moneypb.MoneyFromMicros(currencyCode, 0),
335+
CurrencyCode: currencyCode,
336+
}
337+
}
338+
273339
// protoDateString returns a sortable date string from a proto Date.
274340
func protoDateString(d interface {
275341
GetYear() uint32

internal/ibctl/ibctltaxlot/ibctltaxlot.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,18 @@ func ComputeTaxLots(trades []*datav1.Trade) (*TaxLotResult, error) {
108108
key := lotKey{accountAlias: trade.GetAccountId(), symbol: trade.GetSymbol()}
109109
keyTrades[key] = append(keyTrades[key], trade)
110110
}
111-
// Sort trades within each group by trade date.
111+
// Sort trades within each group by trade date, with buys before sells
112+
// on the same date. This ensures FIFO has lots available before sells
113+
// try to consume them (important for same-day buy+sell scenarios).
112114
for _, trades := range keyTrades {
113115
sort.Slice(trades, func(i, j int) bool {
114116
dateI := tradeDateString(trades[i])
115117
dateJ := tradeDateString(trades[j])
116-
return dateI < dateJ
118+
if dateI != dateJ {
119+
return dateI < dateJ
120+
}
121+
// Within the same date, buys (side=1) come before sells (side=2).
122+
return trades[i].GetSide() < trades[j].GetSide()
117123
})
118124
}
119125
// Process trades using FIFO within each (account, symbol) group.

0 commit comments

Comments
 (0)