Skip to content

Commit 1dd05d9

Browse files
committed
commit
1 parent 42f3b34 commit 1dd05d9

File tree

7 files changed

+443
-183
lines changed

7 files changed

+443
-183
lines changed
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 sale implements the "trade sale" command group.
6+
package sale
7+
8+
import (
9+
"buf.build/go/app/appcmd"
10+
"buf.build/go/app/appext"
11+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/trade/sale/salelist"
12+
)
13+
14+
// NewCommand returns a new trade sale command group.
15+
func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
16+
return &appcmd.Command{
17+
Use: name,
18+
Short: "Display realized security sale information",
19+
SubCommands: []*appcmd.Command{
20+
salelist.NewCommand("list", builder),
21+
},
22+
}
23+
}

cmd/ibctl/internal/command/trade/tradelist/tradelist.go renamed to cmd/ibctl/internal/command/trade/sale/salelist/salelist.go

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

5-
// Package tradelist implements the "trade list" command.
6-
package tradelist
5+
// Package salelist implements the "trade sale list" command.
6+
package salelist
77

88
import (
99
"context"
@@ -44,8 +44,8 @@ func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
4444
flags := newFlags()
4545
return &appcmd.Command{
4646
Use: name,
47-
Short: "List realized trades with FIFO lot matching for tax reporting",
48-
Long: `List realized trades (sells matched to their FIFO buy lots) for tax reporting.
47+
Short: "List realized security sales with FIFO lot matching for tax reporting",
48+
Long: `List realized security sales (sells matched to their FIFO buy lots) for tax reporting.
4949
5050
Each row represents one lot match. A sell of 150 shares that consumes two
5151
FIFO lots (100 + 50) produces two rows, each with its own purchase date

cmd/ibctl/internal/command/trade/trade.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ package trade
88
import (
99
"buf.build/go/app/appcmd"
1010
"buf.build/go/app/appext"
11-
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/trade/tradelist"
11+
"github.com/bufdev/ibctl/cmd/ibctl/internal/command/trade/sale"
1212
)
1313

1414
// NewCommand returns a new trade command group.
@@ -17,7 +17,7 @@ func NewCommand(name string, builder appext.SubCommandBuilder) *appcmd.Command {
1717
Use: name,
1818
Short: "Display realized trade information",
1919
SubCommands: []*appcmd.Command{
20-
tradelist.NewCommand("list", builder),
20+
sale.NewCommand("sale", builder),
2121
},
2222
}
2323
}

internal/ibctl/ibctldownload/ibctldownload.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ import (
2020
"time"
2121

2222
datav1 "github.com/bufdev/ibctl/internal/gen/proto/go/ibctl/data/v1"
23+
mathv1 "github.com/bufdev/ibctl/internal/gen/proto/go/standard/math/v1"
24+
timev1 "github.com/bufdev/ibctl/internal/gen/proto/go/standard/time/v1"
2325
"github.com/bufdev/ibctl/internal/ibctl/ibctlconfig"
2426
"github.com/bufdev/ibctl/internal/ibctl/ibctlpath"
2527
"github.com/bufdev/ibctl/internal/pkg/bankofcanada"
@@ -138,6 +140,10 @@ func (d *downloader) processAccountData(alias string, dataAccountDir string, cac
138140
if err != nil {
139141
return nil, err
140142
}
143+
// Convert option exercises, assignments, and expirations to synthetic trades.
144+
// These come from a separate Flex Query section and are merged with regular trades.
145+
optionTrades := d.convertOptionEAE(statement.OptionEAE, alias)
146+
newTrades = append(newTrades, optionTrades...)
141147
trades := d.mergeTradesWithCache(newTrades, dataAccountDir)
142148
tradesPath := filepath.Join(dataAccountDir, "trades.json")
143149
if err := protoio.WriteMessagesJSON(tradesPath, trades); err != nil {
@@ -560,6 +566,174 @@ func (d *downloader) convertTradeTransfers(xmlTradeTransfers []ibkrflexquery.XML
560566
return tradeTransfers, nil
561567
}
562568

569+
// convertOptionEAE converts option exercise, assignment, and expiration records
570+
// to synthetic Trade protos for FIFO processing.
571+
//
572+
// - Expiration of a long option: SELL at price $0, closes the long lot (realized loss).
573+
// - Expiration of a short option: BUY at price $0, closes the short lot (realized gain).
574+
// - Exercise of a long call or assignment of a short put: results in stock purchase.
575+
// The option lot is closed (SELL at $0) and a stock buy is created at the strike price.
576+
// - Exercise of a long put or assignment of a short call: results in stock sale.
577+
// The option lot is closed (BUY at $0) and a stock sell is created at the strike price.
578+
func (d *downloader) convertOptionEAE(xmlOptionEAE []ibkrflexquery.XMLOptionEAE, accountAlias string) []*datav1.Trade {
579+
var trades []*datav1.Trade
580+
for i := range xmlOptionEAE {
581+
eae := &xmlOptionEAE[i]
582+
// Parse the date from the date or dateTime field.
583+
dateStr := eae.Date
584+
if dateStr == "" {
585+
dateStr = eae.DateTime
586+
}
587+
if len(dateStr) >= 8 {
588+
dateStr = dateStr[:8]
589+
}
590+
parsedDate, err := parseIBKRDate(dateStr)
591+
if err != nil {
592+
d.logger.Warn("skipping unparseable option EAE date", "index", i, "date", dateStr, "error", err)
593+
continue
594+
}
595+
protoDate, err := timepb.NewProtoDate(parsedDate.Year(), parsedDate.Month(), parsedDate.Day())
596+
if err != nil {
597+
d.logger.Warn("skipping invalid option EAE date", "index", i, "error", err)
598+
continue
599+
}
600+
// Parse the quantity as a decimal.
601+
quantity, err := mathpb.NewDecimal(eae.Quantity)
602+
if err != nil {
603+
d.logger.Warn("skipping unparseable option EAE quantity", "index", i, "error", err)
604+
continue
605+
}
606+
currencyCode := eae.Currency
607+
// Generate a deterministic trade ID for the synthetic trade.
608+
tradeID := fmt.Sprintf("option-eae-%s-%s-%s-%s",
609+
accountAlias, eae.Symbol, dateStr, eae.TransactionType)
610+
// Parse trade price (zero for expirations).
611+
tradePrice := moneypb.MoneyFromMicros(currencyCode, 0)
612+
if eae.TradePrice != "" && eae.TradePrice != "0" {
613+
parsed, err := moneypb.NewProtoMoney(currencyCode, eae.TradePrice)
614+
if err == nil {
615+
tradePrice = parsed
616+
}
617+
}
618+
// Parse proceeds.
619+
proceeds := moneypb.MoneyFromMicros(currencyCode, 0)
620+
if eae.Proceeds != "" && eae.Proceeds != "0" {
621+
parsed, err := moneypb.NewProtoMoney(currencyCode, eae.Proceeds)
622+
if err == nil {
623+
proceeds = parsed
624+
}
625+
}
626+
// Determine buy/sell side from quantity sign.
627+
// IBKR uses negative quantity for closing long positions, positive for closing short.
628+
side := datav1.TradeSide_TRADE_SIDE_BUY
629+
if mathpb.ToMicros(quantity) < 0 {
630+
side = datav1.TradeSide_TRADE_SIDE_SELL
631+
}
632+
// Create the option trade (closes the option lot).
633+
trades = append(trades, &datav1.Trade{
634+
TradeId: tradeID,
635+
AccountAlias: accountAlias,
636+
TradeDate: protoDate,
637+
SettleDate: protoDate,
638+
Symbol: eae.Symbol,
639+
Description: eae.Description,
640+
AssetCategory: eae.AssetCategory,
641+
Side: side,
642+
Quantity: quantity,
643+
TradePrice: tradePrice,
644+
Proceeds: proceeds,
645+
Commission: moneypb.MoneyFromMicros(currencyCode, 0),
646+
CurrencyCode: currencyCode,
647+
})
648+
// For exercises and assignments, also create a stock trade at the strike price.
649+
// This is the delivery side: exercising a call buys stock, exercising a put sells stock.
650+
if eae.TransactionType == "Exercise" || eae.TransactionType == "Assignment" {
651+
stockTrade := d.convertOptionEAEToStockTrade(eae, accountAlias, protoDate, currencyCode)
652+
if stockTrade != nil {
653+
trades = append(trades, stockTrade)
654+
}
655+
}
656+
}
657+
return trades
658+
}
659+
660+
// convertOptionEAEToStockTrade creates a synthetic stock trade for an option exercise/assignment.
661+
// Exercising a long call or being assigned on a short put → stock BUY at strike.
662+
// Exercising a long put or being assigned on a short call → stock SELL at strike.
663+
func (d *downloader) convertOptionEAEToStockTrade(eae *ibkrflexquery.XMLOptionEAE, accountAlias string, protoDate *timev1.Date, currencyCode string) *datav1.Trade {
664+
if eae.UnderlyingSymbol == "" || eae.Strike == "" {
665+
return nil
666+
}
667+
// Parse the strike price as the stock trade price.
668+
strikePrice, err := moneypb.NewProtoMoney(currencyCode, eae.Strike)
669+
if err != nil {
670+
d.logger.Warn("skipping option EAE stock trade with unparseable strike", "symbol", eae.Symbol, "strike", eae.Strike)
671+
return nil
672+
}
673+
// Parse the multiplier to compute the stock quantity (options typically cover 100 shares).
674+
multiplier := int64(100)
675+
if eae.Multiplier != "" && eae.Multiplier != "0" {
676+
parsed, err := mathpb.NewDecimal(eae.Multiplier)
677+
if err == nil {
678+
multiplier = mathpb.ToMicros(parsed) / 1_000_000
679+
if multiplier == 0 {
680+
multiplier = 100
681+
}
682+
}
683+
}
684+
// The stock quantity = option quantity * multiplier.
685+
optionQtyMicros := mathpb.ToMicros(mustParseDecimal(eae.Quantity))
686+
// Determine stock trade direction from option type and transaction type.
687+
// Long call exercise or short put assignment → BUY stock.
688+
// Long put exercise or short call assignment → SELL stock.
689+
var stockSide datav1.TradeSide
690+
isLong := optionQtyMicros < 0 // Negative option qty = closing a long position.
691+
isCall := eae.PutCall == "C" || eae.PutCall == "CALL"
692+
if (isLong && isCall) || (!isLong && !isCall) {
693+
// Long call exercise or short put assignment → buy stock.
694+
stockSide = datav1.TradeSide_TRADE_SIDE_BUY
695+
} else {
696+
// Long put exercise or short call assignment → sell stock.
697+
stockSide = datav1.TradeSide_TRADE_SIDE_SELL
698+
}
699+
// Compute the stock quantity (absolute value of option qty * multiplier).
700+
absOptionQty := optionQtyMicros
701+
if absOptionQty < 0 {
702+
absOptionQty = -absOptionQty
703+
}
704+
stockQtyMicros := (absOptionQty / 1_000_000) * multiplier * 1_000_000
705+
if stockSide == datav1.TradeSide_TRADE_SIDE_SELL {
706+
stockQtyMicros = -stockQtyMicros
707+
}
708+
// Generate a deterministic trade ID for the stock delivery trade.
709+
dateStr := fmt.Sprintf("%04d%02d%02d", protoDate.GetYear(), protoDate.GetMonth(), protoDate.GetDay())
710+
stockTradeID := fmt.Sprintf("option-eae-stock-%s-%s-%s-%s",
711+
accountAlias, eae.UnderlyingSymbol, dateStr, eae.TransactionType)
712+
return &datav1.Trade{
713+
TradeId: stockTradeID,
714+
AccountAlias: accountAlias,
715+
TradeDate: protoDate,
716+
SettleDate: protoDate,
717+
Symbol: eae.UnderlyingSymbol,
718+
AssetCategory: "STK",
719+
Side: stockSide,
720+
Quantity: mathpb.FromMicros(stockQtyMicros),
721+
TradePrice: strikePrice,
722+
Proceeds: moneypb.MoneyFromMicros(currencyCode, 0),
723+
Commission: moneypb.MoneyFromMicros(currencyCode, 0),
724+
CurrencyCode: currencyCode,
725+
}
726+
}
727+
728+
// mustParseDecimal parses a decimal string, returning a zero decimal on error.
729+
func mustParseDecimal(s string) *mathv1.Decimal {
730+
d, err := mathpb.NewDecimal(s)
731+
if err != nil {
732+
return mathpb.FromMicros(0)
733+
}
734+
return d
735+
}
736+
563737
// convertCorporateActions converts XML corporate actions to proto corporate actions.
564738
func (d *downloader) convertCorporateActions(xmlActions []ibkrflexquery.XMLCorporateAction, accountAlias string) ([]*datav1.CorporateAction, error) {
565739
actions := make([]*datav1.CorporateAction, 0, len(xmlActions))

internal/pkg/ibkractivitycsv/ibkractivitycsv.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,8 +250,9 @@ func parseTrade(record []string, header []string, statement *ActivityStatement)
250250
}
251251

252252
switch assetCategory {
253-
case "Stocks":
254-
// Stock trades have: DataDiscriminator,Asset Category,Currency,Symbol,Date/Time,Quantity,T. Price,C. Price,Proceeds,Comm/Fee,Basis,Realized P/L,MTM P/L,Code
253+
case "Stocks", "Equity and Index Options":
254+
// Stock and option trades share the same column layout:
255+
// DataDiscriminator,Asset Category,Currency,Symbol,Date/Time,Quantity,T. Price,C. Price,Proceeds,Comm/Fee,Basis,Realized P/L,MTM P/L,Code
255256
if len(record) < 16 {
256257
return nil
257258
}

internal/pkg/ibkrflexquery/ibkrflexquery.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ type FlexStatement struct {
8787
TradeTransfers []XMLTradeTransfer `xml:"TradeTransfers>TradeTransfer"`
8888
// CorporateActions is the list of corporate action events.
8989
CorporateActions []XMLCorporateAction `xml:"CorporateActions>CorporateAction"`
90+
// OptionEAE is the list of option exercises, assignments, and expirations.
91+
OptionEAE []XMLOptionEAE `xml:"OptionEAE>OptionEAE"`
9092
// CashReport is the cash balance report by currency.
9193
CashReport []XMLCashReportCurrency `xml:"CashReport>CashReportCurrency"`
9294
}
@@ -177,6 +179,43 @@ type XMLCorporateAction struct {
177179
AssetCategory string `xml:"assetCategory,attr"`
178180
}
179181

182+
// XMLOptionEAE represents an option exercise, assignment, or expiration
183+
// from the IBKR Flex Query XML format. This section is separate from Trades.
184+
type XMLOptionEAE struct {
185+
// TransactionType distinguishes Exercise, Assignment, and Expiration.
186+
TransactionType string `xml:"transactionType,attr"`
187+
// Symbol is the option symbol (e.g., "AAPL 240315C00170000").
188+
Symbol string `xml:"symbol,attr"`
189+
// UnderlyingSymbol is the underlying stock symbol (e.g., "AAPL").
190+
UnderlyingSymbol string `xml:"underlyingSymbol,attr"`
191+
// Description is the option description.
192+
Description string `xml:"description,attr"`
193+
// AssetCategory is the asset category (typically "OPT").
194+
AssetCategory string `xml:"assetCategory,attr"`
195+
// DateTime is the date of the event (YYYYMMDD or YYYYMMDD;HHMMSS format).
196+
DateTime string `xml:"dateTime,attr"`
197+
// Date is the transaction date (YYYYMMDD format).
198+
Date string `xml:"date,attr"`
199+
// Quantity is the number of contracts. Negative for sells/expirations of long positions.
200+
Quantity string `xml:"quantity,attr"`
201+
// TradePrice is the price per share at exercise/assignment. Zero for expirations.
202+
TradePrice string `xml:"tradePrice,attr"`
203+
// Strike is the option strike price.
204+
Strike string `xml:"strike,attr"`
205+
// Expiry is the option expiration date (YYYYMMDD format).
206+
Expiry string `xml:"expiry,attr"`
207+
// PutCall indicates whether the option is a PUT or CALL.
208+
PutCall string `xml:"putCall,attr"`
209+
// Multiplier is the contract multiplier (typically "100" for equity options).
210+
Multiplier string `xml:"multiplier,attr"`
211+
// Proceeds is the total proceeds from the event.
212+
Proceeds string `xml:"proceeds,attr"`
213+
// Currency is the ISO currency code.
214+
Currency string `xml:"currency,attr"`
215+
// FifoPnlRealized is the realized P&L computed by IBKR.
216+
FifoPnlRealized string `xml:"fifoPnlRealized,attr"`
217+
}
218+
180219
// XMLCashReportCurrency represents a cash balance for a single currency
181220
// from the IBKR Flex Query Cash Report section.
182221
type XMLCashReportCurrency struct {

0 commit comments

Comments
 (0)