@@ -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 ,
0 commit comments