Skip to content

Commit e5bdfd0

Browse files
committed
commit
1 parent 5f3fb4c commit e5bdfd0

File tree

3 files changed

+65
-13
lines changed

3 files changed

+65
-13
lines changed

internal/ibctl/ibctlmerge/ibctlmerge.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,15 @@ func Merge(
195195
if allTrades[i].GetAccountAlias() != allTrades[j].GetAccountAlias() {
196196
return allTrades[i].GetAccountAlias() < allTrades[j].GetAccountAlias()
197197
}
198-
return allTrades[i].GetSymbol() < allTrades[j].GetSymbol()
198+
if allTrades[i].GetSymbol() != allTrades[j].GetSymbol() {
199+
return allTrades[i].GetSymbol() < allTrades[j].GetSymbol()
200+
}
201+
// Side tiebreaker: buys before sells for consistent FIFO lot creation.
202+
if allTrades[i].GetSide() != allTrades[j].GetSide() {
203+
return allTrades[i].GetSide() < allTrades[j].GetSide()
204+
}
205+
// Trade ID as final tiebreaker for guaranteed determinism.
206+
return allTrades[i].GetTradeId() < allTrades[j].GetTradeId()
199207
})
200208
return &MergedData{
201209
Trades: allTrades,

internal/ibctl/ibctltaxlot/ibctltaxlot.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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,

internal/ibctl/ibctltrades/ibctltrades.go

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ package ibctltrades
1313

1414
import (
1515
"fmt"
16-
"sort"
1716

1817
datav1 "github.com/bufdev/ibctl/internal/gen/proto/go/ibctl/data/v1"
1918
"github.com/bufdev/ibctl/internal/ibctl/ibctlfxrates"
@@ -233,16 +232,12 @@ func GetTradeList(
233232
}
234233
tradeOverviews = append(tradeOverviews, overview)
235234
}
236-
// Sort by sale date, then symbol, then account.
237-
sort.Slice(tradeOverviews, func(i, j int) bool {
238-
if tradeOverviews[i].SaleDate != tradeOverviews[j].SaleDate {
239-
return tradeOverviews[i].SaleDate < tradeOverviews[j].SaleDate
240-
}
241-
if tradeOverviews[i].Symbol != tradeOverviews[j].Symbol {
242-
return tradeOverviews[i].Symbol < tradeOverviews[j].Symbol
243-
}
244-
return tradeOverviews[i].Account < tradeOverviews[j].Account
245-
})
235+
// No sort needed here — realizedMatches are already sorted deterministically
236+
// in ComputeTaxLots by sell date, symbol, account, buy date, buy price,
237+
// sell price, and quantity (all using numeric comparison for prices/quantities).
238+
// A previous string-based sort here caused non-determinism because string
239+
// comparison of decimal numbers (e.g., "24.87" vs "24.8717") disagrees with
240+
// numeric comparison, and sort.Slice is input-order-sensitive for ties.
246241
return &TradeListResult{
247242
Trades: tradeOverviews,
248243
UnmatchedSells: taxLotResult.UnmatchedSells,

0 commit comments

Comments
 (0)