diff --git a/packages/sdk-go/websocket/orderbook/book.go b/packages/sdk-go/websocket/orderbook/book.go index 0df37552..8eebe753 100644 --- a/packages/sdk-go/websocket/orderbook/book.go +++ b/packages/sdk-go/websocket/orderbook/book.go @@ -36,9 +36,14 @@ type OrderBook struct { const maxCachedPriceLength = 128 +type cachedPriceValue struct { + decimal types.Decimal + val float64 +} + type decimalCache struct { mu sync.RWMutex - values map[string]types.Decimal + values map[string]cachedPriceValue } // NewOrderBook creates a new empty OrderBook for a symbol. @@ -47,7 +52,7 @@ func NewOrderBook(symbol string) *OrderBook { symbol: symbol, bids: make([]PriceLevel, 0, 128), asks: make([]PriceLevel, 0, 128), - priceCache: decimalCache{values: make(map[string]types.Decimal)}, + priceCache: decimalCache{values: make(map[string]cachedPriceValue)}, } } @@ -127,7 +132,7 @@ func parseLevels(raw [][]string, allowZeroAmount bool, cache *decimalCache) ([]P } func parseLevel(priceStr, amountStr string, allowZeroAmount bool, cache *decimalCache) (PriceLevel, error) { - price, err := cachedPrice(cache, priceStr) + price, priceVal, err := cachedPrice(cache, priceStr) if err != nil || !price.IsPositive() { return PriceLevel{}, fmt.Errorf("price %q must be positive decimal", priceStr) } @@ -138,36 +143,42 @@ func parseLevel(priceStr, amountStr string, allowZeroAmount bool, cache *decimal return PriceLevel{ Price: priceStr, Amount: amountStr, - val: price.Float64(), + val: priceVal, decimal: price, amountDec: amount, }, nil } -func cachedPrice(cache *decimalCache, raw string) (types.Decimal, error) { +func cachedPrice(cache *decimalCache, raw string) (types.Decimal, float64, error) { key := strings.TrimSpace(raw) if cache != nil && len(key) <= maxCachedPriceLength { cache.mu.RLock() - price, ok := cache.values[key] + cached, ok := cache.values[key] cache.mu.RUnlock() if ok { - return price, nil + return cached.decimal, cached.val, nil } } price, err := types.ParseDecimal(raw) if err != nil { - return types.Decimal{}, err + return types.Decimal{}, 0, err } + priceVal := price.Float64() + if cache != nil && len(key) <= maxCachedPriceLength { cache.mu.Lock() if len(cache.values) >= 4096 { clear(cache.values) } - cache.values[key] = price + cache.values[key] = cachedPriceValue{ + decimal: price, + val: priceVal, + } cache.mu.Unlock() } - return price, nil + + return price, priceVal, nil } // ApplyDiff atomically applies a batch of bid and ask level updates and updates the sequence ID under a single lock. diff --git a/packages/sdk-go/websocket/orderbook/book_test.go b/packages/sdk-go/websocket/orderbook/book_test.go index bc296d9d..a7f7d1ec 100644 --- a/packages/sdk-go/websocket/orderbook/book_test.go +++ b/packages/sdk-go/websocket/orderbook/book_test.go @@ -820,3 +820,77 @@ func TestLiveOrderBook_BBOListenerUnregister(t *testing.T) { default: } } + +func TestOrderBook_PriceCachePreservesFloatMetrics(t *testing.T) { + book := NewOrderBook("BTCUSD") + + if err := book.ApplySnapshot( + 1, + [][]string{{"65000.00", "1"}}, + [][]string{{"65100.00", "1"}}, + ); err != nil { + t.Fatalf("ApplySnapshot failed: %v", err) + } + + wantSpread, ok := book.Spread() + if !ok { + t.Fatal("expected spread") + } + wantMid, ok := book.Mid() + if !ok { + t.Fatal("expected mid") + } + + // Repeated updates hit the price cache. Cached float values must remain + // identical to those produced when the prices were first parsed. + for i := 0; i < 100; i++ { + if err := book.ApplyLevel(true, "65000.00", "2"); err != nil { + t.Fatalf("ApplyLevel bid failed: %v", err) + } + if err := book.ApplyLevel(false, "65100.00", "2"); err != nil { + t.Fatalf("ApplyLevel ask failed: %v", err) + } + + gotSpread, ok := book.Spread() + if !ok || gotSpread != wantSpread { + t.Fatalf("Spread = %v, %v; want %v, true", gotSpread, ok, wantSpread) + } + gotMid, ok := book.Mid() + if !ok || gotMid != wantMid { + t.Fatalf("Mid = %v, %v; want %v, true", gotMid, ok, wantMid) + } + } +} + +func TestOrderBook_PriceCachePreservesExactPriceIdentity(t *testing.T) { + book := NewOrderBook("BTCUSD") + + if err := book.ApplySnapshot( + 1, + [][]string{ + {"10000000000000000.01", "1"}, + {"10000000000000000.02", "2"}, + }, + [][]string{{"10000000000000000.03", "1"}}, + ); err != nil { + t.Fatalf("ApplySnapshot failed: %v", err) + } + + for i := 0; i < 100; i++ { + if err := book.ApplyLevel(true, "10000000000000000.01", "3"); err != nil { + t.Fatalf("ApplyLevel failed: %v", err) + } + } + + bids := book.Bids(10) + if len(bids) != 2 { + t.Fatalf("got %d bids, want 2", len(bids)) + } + if bids[0].Price != "10000000000000000.02" || + bids[1].Price != "10000000000000000.01" { + t.Fatalf("cached prices changed exact ordering: %+v", bids) + } + if bids[1].Amount != "3" { + t.Fatalf("cached update changed wrong level: %+v", bids) + } +}