@@ -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.
564738func (d * downloader ) convertCorporateActions (xmlActions []ibkrflexquery.XMLCorporateAction , accountAlias string ) ([]* datav1.CorporateAction , error ) {
565739 actions := make ([]* datav1.CorporateAction , 0 , len (xmlActions ))
0 commit comments