@@ -329,7 +329,24 @@ func ComputeTaxLots(trades []*datav1.Trade) (*TaxLotResult, error) {
329329 return dateI < dateJ
330330 }
331331 // Within the same date, buys (side=1) come before sells (side=2).
332- return trades [i ].GetSide () < trades [j ].GetSide ()
332+ if trades [i ].GetSide () != trades [j ].GetSide () {
333+ return trades [i ].GetSide () < trades [j ].GetSide ()
334+ }
335+ // Price tiebreaker: sort by trade price ascending so FIFO consumes
336+ // cheaper lots first when multiple lots open on the same date.
337+ priceI := moneypb .MoneyToMicros (trades [i ].GetTradePrice ())
338+ priceJ := moneypb .MoneyToMicros (trades [j ].GetTradePrice ())
339+ if priceI != priceJ {
340+ return priceI < priceJ
341+ }
342+ // Quantity tiebreaker for deterministic ordering of same-price lots.
343+ qtyI := mathpb .ToMicros (trades [i ].GetQuantity ())
344+ qtyJ := mathpb .ToMicros (trades [j ].GetQuantity ())
345+ if qtyI != qtyJ {
346+ return qtyI < qtyJ
347+ }
348+ // Final tiebreaker: trade ID for guaranteed determinism.
349+ return trades [i ].GetTradeId () < trades [j ].GetTradeId ()
333350 })
334351 }
335352 // Process trades using FIFO within each (account, symbol) group.
@@ -499,6 +516,38 @@ func ComputeTaxLots(trades []*datav1.Trade) (*TaxLotResult, error) {
499516 // Final tiebreaker: sort by quantity ascending.
500517 return mathpb .ToMicros (result [i ].GetQuantity ()) < mathpb .ToMicros (result [j ].GetQuantity ())
501518 })
519+ // Sort realized matches for deterministic output. Without this sort, the
520+ // matches appear in map iteration order (from the keyTrades loop above),
521+ // and sort.Slice in downstream consumers can produce different results
522+ // for different initial orderings even with a complete comparator.
523+ sort .Slice (realizedMatches , func (i , j int ) bool {
524+ si := FormatDate (realizedMatches [i ].SellDate )
525+ sj := FormatDate (realizedMatches [j ].SellDate )
526+ if si != sj {
527+ return si < sj
528+ }
529+ if realizedMatches [i ].Symbol != realizedMatches [j ].Symbol {
530+ return realizedMatches [i ].Symbol < realizedMatches [j ].Symbol
531+ }
532+ if realizedMatches [i ].AccountAlias != realizedMatches [j ].AccountAlias {
533+ return realizedMatches [i ].AccountAlias < realizedMatches [j ].AccountAlias
534+ }
535+ bi := FormatDate (realizedMatches [i ].BuyDate )
536+ bj := FormatDate (realizedMatches [j ].BuyDate )
537+ if bi != bj {
538+ return bi < bj
539+ }
540+ if realizedMatches [i ].BuyPriceMicros != realizedMatches [j ].BuyPriceMicros {
541+ return realizedMatches [i ].BuyPriceMicros < realizedMatches [j ].BuyPriceMicros
542+ }
543+ if realizedMatches [i ].SellPriceMicros != realizedMatches [j ].SellPriceMicros {
544+ return realizedMatches [i ].SellPriceMicros < realizedMatches [j ].SellPriceMicros
545+ }
546+ if realizedMatches [i ].QuantityMicros != realizedMatches [j ].QuantityMicros {
547+ return realizedMatches [i ].QuantityMicros < realizedMatches [j ].QuantityMicros
548+ }
549+ return realizedMatches [i ].CurrencyCode < realizedMatches [j ].CurrencyCode
550+ })
502551 return & TaxLotResult {
503552 TaxLots : result ,
504553 UnmatchedSells : unmatchedSells ,
0 commit comments