diff --git a/common/common.go b/common/common.go index fae7a984..b1979a7b 100644 --- a/common/common.go +++ b/common/common.go @@ -669,15 +669,15 @@ func SortStrings[S ~[]E, E fmt.Stringer](x S) S { // Counter is a thread-safe counter. type Counter struct { - n int64 // privatised so you can't use counter as a value type + n atomic.Int64 // private so you can't use counter as a value type } // IncrementAndGet returns the next count after incrementing. func (c *Counter) IncrementAndGet() int64 { - newID := atomic.AddInt64(&c.n, 1) + newID := c.n.Add(1) // Handle overflow by resetting the counter to 1 if it becomes negative if newID < 0 { - atomic.StoreInt64(&c.n, 1) + c.n.Store(1) return 1 } return newID diff --git a/common/common_test.go b/common/common_test.go index 7d75fda7..ab95180d 100644 --- a/common/common_test.go +++ b/common/common_test.go @@ -644,8 +644,9 @@ func TestSortStrings(t *testing.T) { func TestCounter(t *testing.T) { t.Parallel() - c := Counter{n: -5} - require.Equal(t, int64(1), c.IncrementAndGet()) + c := Counter{} + c.n.Store(-5) + require.Equal(t, int64(1), c.IncrementAndGet(), "Adding to a negative Counter must reset to zero and then increment") require.Equal(t, int64(2), c.IncrementAndGet()) } diff --git a/docs/ADD_NEW_EXCHANGE.md b/docs/ADD_NEW_EXCHANGE.md index ec6e33d2..e1497dee 100644 --- a/docs/ADD_NEW_EXCHANGE.md +++ b/docs/ADD_NEW_EXCHANGE.md @@ -1123,7 +1123,7 @@ Please test all `pair` commands to disable and enable different assets types to - `get` to ensure correct enabled and disabled pairs for a supported asset type. - `disableasset` to ensure disabling of entire asset class and associated unsubscriptions. - `enableasset` to ensure correct enabling of entire asset class and associated subscriptions. -- `disable` to ensure correct disabling of pair(s) and and associated unsubscriptions. +- `disable` to ensure correct disabling of pair(s) and associated subscriptions. - `enable` to ensure correct enabling of pair(s) and associated subscriptions. - `enableall` to ensure correct enabling of all pairs for an asset type and associated subscriptions. - `disableall` to ensure correct disabling of all pairs for an asset type and associated unsubscriptions. diff --git a/exchange/websocket/connection.go b/exchange/websocket/connection.go index 20b28a3f..117567c4 100644 --- a/exchange/websocket/connection.go +++ b/exchange/websocket/connection.go @@ -88,11 +88,11 @@ type ConnectionSetup struct { // received from the exchange's websocket server. This function should // handle the incoming message and pass it to the appropriate data handler. Handler func(ctx context.Context, conn Connection, incoming []byte) error - // BespokeGenerateMessageID is a function that returns a unique message ID. + // RequestIDGenerator is a function that returns a unique message ID. // This is useful for when an exchange connection requires a unique or // structured message ID for each message sent. - BespokeGenerateMessageID func(highPrecision bool) int64 - Authenticate func(ctx context.Context, conn Connection) error + RequestIDGenerator func() int64 + Authenticate func(ctx context.Context, conn Connection) error // MessageFilter defines the criteria used to match messages to a specific connection. // The filter enables precise routing and handling of messages for distinct connection contexts. MessageFilter any @@ -112,23 +112,23 @@ type Response struct { // connection contains all the data needed to send a message to a websocket connection type connection struct { - Verbose bool - connected int32 - writeControl sync.Mutex // Gorilla websocket does not allow more than one goroutine to utilise write methods - RateLimit *request.RateLimiterWithWeight // RateLimit is a rate limiter for the connection itself - RateLimitDefinitions request.RateLimitDefinitions // RateLimitDefinitions contains the rate limiters shared between WebSocket and REST connections - Reporter Reporter - ExchangeName string - URL string - ProxyURL string - Wg *sync.WaitGroup - Connection *gws.Conn - shutdown chan struct{} - Match *Match - ResponseMaxLimit time.Duration - Traffic chan struct{} - readMessageErrors chan error - bespokeGenerateMessageID func(highPrecision bool) int64 + Verbose bool + connected int32 + writeControl sync.Mutex // Gorilla websocket does not allow more than one goroutine to utilise write methods + RateLimit *request.RateLimiterWithWeight // RateLimit is a rate limiter for the connection itself + RateLimitDefinitions request.RateLimitDefinitions // RateLimitDefinitions contains the rate limiters shared between WebSocket and REST connections + Reporter Reporter + ExchangeName string + URL string + ProxyURL string + Wg *sync.WaitGroup + Connection *gws.Conn + shutdown chan struct{} + Match *Match + ResponseMaxLimit time.Duration + Traffic chan struct{} + readMessageErrors chan error + requestIDGenerator func() int64 } // Dial sets proxy urls and then connects to the websocket @@ -339,8 +339,8 @@ func (c *connection) parseBinaryResponse(resp []byte) ([]byte, error) { // If a bespoke function is set (by using SetupNewConnection) it will use that, // otherwise it will use the defaultGenerateMessageID function. func (c *connection) GenerateMessageID(highPrec bool) int64 { - if c.bespokeGenerateMessageID != nil { - return c.bespokeGenerateMessageID(highPrec) + if c.requestIDGenerator != nil { + return c.requestIDGenerator() } return c.defaultGenerateMessageID(highPrec) } diff --git a/exchange/websocket/manager.go b/exchange/websocket/manager.go index 70440f93..8633f234 100644 --- a/exchange/websocket/manager.go +++ b/exchange/websocket/manager.go @@ -305,12 +305,7 @@ func (m *Manager) SetupNewConnection(c *ConnectionSetup) error { return err } - if c == nil || c.ResponseCheckTimeout == 0 && - c.ResponseMaxLimit == 0 && - c.RateLimit == nil && - c.URL == "" && - c.ConnectionLevelReporter == nil && - c.BespokeGenerateMessageID == nil { + if c.ResponseCheckTimeout == 0 && c.ResponseMaxLimit == 0 && c.RateLimit == nil && c.URL == "" && c.ConnectionLevelReporter == nil && c.RequestIDGenerator == nil { return fmt.Errorf("%w: %w", errConnSetup, errExchangeConfigEmpty) } @@ -394,20 +389,20 @@ func (m *Manager) getConnectionFromSetup(c *ConnectionSetup) *connection { match = NewMatch() } return &connection{ - ExchangeName: m.exchangeName, - URL: connectionURL, - ProxyURL: m.GetProxyAddress(), - Verbose: m.verbose, - ResponseMaxLimit: c.ResponseMaxLimit, - Traffic: m.TrafficAlert, - readMessageErrors: m.ReadMessageErrors, - shutdown: m.ShutdownC, - Wg: &m.Wg, - Match: match, - RateLimit: c.RateLimit, - Reporter: c.ConnectionLevelReporter, - bespokeGenerateMessageID: c.BespokeGenerateMessageID, - RateLimitDefinitions: m.rateLimitDefinitions, + ExchangeName: m.exchangeName, + URL: connectionURL, + ProxyURL: m.GetProxyAddress(), + Verbose: m.verbose, + ResponseMaxLimit: c.ResponseMaxLimit, + Traffic: m.TrafficAlert, + readMessageErrors: m.ReadMessageErrors, + shutdown: m.ShutdownC, + Wg: &m.Wg, + Match: match, + RateLimit: c.RateLimit, + Reporter: c.ConnectionLevelReporter, + requestIDGenerator: c.RequestIDGenerator, + RateLimitDefinitions: m.rateLimitDefinitions, } } diff --git a/exchange/websocket/manager_test.go b/exchange/websocket/manager_test.go index 83d28aa8..c70ff718 100644 --- a/exchange/websocket/manager_test.go +++ b/exchange/websocket/manager_test.go @@ -769,7 +769,7 @@ func TestGenerateMessageID(t *testing.T) { ids[i] = id } - wc.bespokeGenerateMessageID = func(bool) int64 { return 42 } + wc.requestIDGenerator = func() int64 { return 42 } assert.EqualValues(t, 42, wc.GenerateMessageID(true), "GenerateMessageID should use bespokeGenerateMessageID") } diff --git a/exchanges/bybit/bybit.go b/exchanges/bybit/bybit.go index c72b536c..096f2974 100644 --- a/exchanges/bybit/bybit.go +++ b/exchanges/bybit/bybit.go @@ -29,7 +29,9 @@ import ( // Exchange implements exchange.IBotExchange and contains additional specific api methods for interacting with Bybit type Exchange struct { exchange.Base - account accountTypeHolder + + messageIDSeq common.Counter + account accountTypeHolder } const ( @@ -76,7 +78,6 @@ var ( errTimeWindowRequired = errors.New("time window is required") errFrozenPeriodRequired = errors.New("frozen period required") errQuantityLimitRequired = errors.New("quantity limit required") - errInvalidPushData = errors.New("invalid push data") errInvalidLeverage = errors.New("leverage can't be zero or less then it") errInvalidPositionMode = errors.New("position mode is invalid") errInvalidMode = errors.New("mode can't be empty or missing") diff --git a/exchanges/bybit/bybit_inverse_websocket.go b/exchanges/bybit/bybit_inverse_websocket.go deleted file mode 100644 index dca241b0..00000000 --- a/exchanges/bybit/bybit_inverse_websocket.go +++ /dev/null @@ -1,85 +0,0 @@ -package bybit - -import ( - "context" - "net/http" - - gws "github.com/gorilla/websocket" - "github.com/thrasher-corp/gocryptotrader/currency" - "github.com/thrasher-corp/gocryptotrader/exchange/websocket" - "github.com/thrasher-corp/gocryptotrader/exchanges/asset" - "github.com/thrasher-corp/gocryptotrader/exchanges/request" - "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" -) - -// WsInverseConnect connects to inverse websocket feed -func (e *Exchange) WsInverseConnect() error { - ctx := context.TODO() - if !e.Websocket.IsEnabled() || !e.IsEnabled() || !e.IsAssetWebsocketSupported(asset.CoinMarginedFutures) { - return websocket.ErrWebsocketNotEnabled - } - e.Websocket.Conn.SetURL(inversePublic) - var dialer gws.Dialer - err := e.Websocket.Conn.Dial(ctx, &dialer, http.Header{}) - if err != nil { - return err - } - e.Websocket.Conn.SetupPingHandler(request.Unset, websocket.PingHandler{ - MessageType: gws.TextMessage, - Message: []byte(`{"op": "ping"}`), - Delay: bybitWebsocketTimer, - }) - - e.Websocket.Wg.Add(1) - go e.wsReadData(ctx, asset.CoinMarginedFutures, e.Websocket.Conn) - return nil -} - -// GenerateInverseDefaultSubscriptions generates default subscription -func (e *Exchange) GenerateInverseDefaultSubscriptions() (subscription.List, error) { - var subscriptions subscription.List - channels := []string{chanOrderbook, chanPublicTrade, chanPublicTicker} - pairs, err := e.GetEnabledPairs(asset.CoinMarginedFutures) - if err != nil { - return nil, err - } - for z := range pairs { - for x := range channels { - subscriptions = append(subscriptions, - &subscription.Subscription{ - Channel: channels[x], - Pairs: currency.Pairs{pairs[z]}, - Asset: asset.CoinMarginedFutures, - }) - } - } - return subscriptions, nil -} - -// InverseSubscribe sends a subscription message to linear public channels. -func (e *Exchange) InverseSubscribe(channelSubscriptions subscription.List) error { - ctx := context.TODO() - return e.handleInversePayloadSubscription(ctx, "subscribe", channelSubscriptions) -} - -// InverseUnsubscribe sends an unsubscription messages through linear public channels. -func (e *Exchange) InverseUnsubscribe(channelSubscriptions subscription.List) error { - ctx := context.TODO() - return e.handleInversePayloadSubscription(ctx, "unsubscribe", channelSubscriptions) -} - -func (e *Exchange) handleInversePayloadSubscription(ctx context.Context, operation string, channelSubscriptions subscription.List) error { - payloads, err := e.handleSubscriptions(operation, channelSubscriptions) - if err != nil { - return err - } - for a := range payloads { - // The options connection does not send the subscription request id back with the subscription notification payload - // therefore the code doesn't wait for the response to check whether the subscription is successful or not. - err = e.Websocket.Conn.SendJSONMessage(ctx, request.Unset, payloads[a]) - if err != nil { - return err - } - } - return nil -} diff --git a/exchanges/bybit/bybit_linear_websocket.go b/exchanges/bybit/bybit_linear_websocket.go deleted file mode 100644 index 0e9e8c9b..00000000 --- a/exchanges/bybit/bybit_linear_websocket.go +++ /dev/null @@ -1,103 +0,0 @@ -package bybit - -import ( - "context" - "net/http" - - gws "github.com/gorilla/websocket" - "github.com/thrasher-corp/gocryptotrader/currency" - "github.com/thrasher-corp/gocryptotrader/exchange/websocket" - "github.com/thrasher-corp/gocryptotrader/exchanges/asset" - "github.com/thrasher-corp/gocryptotrader/exchanges/request" - "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" -) - -// WsLinearConnect connects to linear a websocket feed -func (e *Exchange) WsLinearConnect() error { - ctx := context.TODO() - if !e.Websocket.IsEnabled() || !e.IsEnabled() || !e.IsAssetWebsocketSupported(asset.LinearContract) { - return websocket.ErrWebsocketNotEnabled - } - e.Websocket.Conn.SetURL(linearPublic) - var dialer gws.Dialer - err := e.Websocket.Conn.Dial(ctx, &dialer, http.Header{}) - if err != nil { - return err - } - e.Websocket.Conn.SetupPingHandler(request.Unset, websocket.PingHandler{ - MessageType: gws.TextMessage, - Message: []byte(`{"op": "ping"}`), - Delay: bybitWebsocketTimer, - }) - - e.Websocket.Wg.Add(1) - go e.wsReadData(ctx, asset.LinearContract, e.Websocket.Conn) - if e.IsWebsocketAuthenticationSupported() { - err = e.WsAuth(ctx) - if err != nil { - e.Websocket.DataHandler <- err - e.Websocket.SetCanUseAuthenticatedEndpoints(false) - } - } - return nil -} - -// GenerateLinearDefaultSubscriptions generates default subscription -func (e *Exchange) GenerateLinearDefaultSubscriptions() (subscription.List, error) { - var subscriptions subscription.List - channels := []string{chanOrderbook, chanPublicTrade, chanPublicTicker} - pairs, err := e.GetEnabledPairs(asset.USDTMarginedFutures) - if err != nil { - return nil, err - } - linearPairMap := map[asset.Item]currency.Pairs{ - asset.USDTMarginedFutures: pairs, - } - usdcPairs, err := e.GetEnabledPairs(asset.USDCMarginedFutures) - if err != nil { - return nil, err - } - linearPairMap[asset.USDCMarginedFutures] = usdcPairs - pairs = append(pairs, usdcPairs...) - for a := range linearPairMap { - for p := range linearPairMap[a] { - for x := range channels { - subscriptions = append(subscriptions, - &subscription.Subscription{ - Channel: channels[x], - Pairs: currency.Pairs{pairs[p]}, - Asset: a, - }) - } - } - } - return subscriptions, nil -} - -// LinearSubscribe sends a subscription message to linear public channels. -func (e *Exchange) LinearSubscribe(channelSubscriptions subscription.List) error { - ctx := context.TODO() - return e.handleLinearPayloadSubscription(ctx, "subscribe", channelSubscriptions) -} - -// LinearUnsubscribe sends an unsubscription messages through linear public channels. -func (e *Exchange) LinearUnsubscribe(channelSubscriptions subscription.List) error { - ctx := context.TODO() - return e.handleLinearPayloadSubscription(ctx, "unsubscribe", channelSubscriptions) -} - -func (e *Exchange) handleLinearPayloadSubscription(ctx context.Context, operation string, channelSubscriptions subscription.List) error { - payloads, err := e.handleSubscriptions(operation, channelSubscriptions) - if err != nil { - return err - } - for a := range payloads { - // The options connection does not send the subscription request id back with the subscription notification payload - // therefore the code doesn't wait for the response to check whether the subscription is successful or not. - err = e.Websocket.Conn.SendJSONMessage(ctx, request.Unset, payloads[a]) - if err != nil { - return err - } - } - return nil -} diff --git a/exchanges/bybit/bybit_options_websocket.go b/exchanges/bybit/bybit_options_websocket.go deleted file mode 100644 index 7bda9ad7..00000000 --- a/exchanges/bybit/bybit_options_websocket.go +++ /dev/null @@ -1,92 +0,0 @@ -package bybit - -import ( - "context" - "net/http" - "strconv" - - gws "github.com/gorilla/websocket" - "github.com/thrasher-corp/gocryptotrader/currency" - "github.com/thrasher-corp/gocryptotrader/encoding/json" - "github.com/thrasher-corp/gocryptotrader/exchange/websocket" - "github.com/thrasher-corp/gocryptotrader/exchanges/asset" - "github.com/thrasher-corp/gocryptotrader/exchanges/request" - "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" -) - -// WsOptionsConnect connects to options a websocket feed -func (e *Exchange) WsOptionsConnect() error { - ctx := context.TODO() - if !e.Websocket.IsEnabled() || !e.IsEnabled() || !e.IsAssetWebsocketSupported(asset.Options) { - return websocket.ErrWebsocketNotEnabled - } - e.Websocket.Conn.SetURL(optionPublic) - var dialer gws.Dialer - err := e.Websocket.Conn.Dial(ctx, &dialer, http.Header{}) - if err != nil { - return err - } - pingMessage := PingMessage{Operation: "ping", RequestID: strconv.FormatInt(e.Websocket.Conn.GenerateMessageID(false), 10)} - pingData, err := json.Marshal(pingMessage) - if err != nil { - return err - } - e.Websocket.Conn.SetupPingHandler(request.Unset, websocket.PingHandler{ - MessageType: gws.TextMessage, - Message: pingData, - Delay: bybitWebsocketTimer, - }) - - e.Websocket.Wg.Add(1) - go e.wsReadData(ctx, asset.Options, e.Websocket.Conn) - return nil -} - -// GenerateOptionsDefaultSubscriptions generates default subscription -func (e *Exchange) GenerateOptionsDefaultSubscriptions() (subscription.List, error) { - var subscriptions subscription.List - channels := []string{chanOrderbook, chanPublicTrade, chanPublicTicker} - pairs, err := e.GetEnabledPairs(asset.Options) - if err != nil { - return nil, err - } - for z := range pairs { - for x := range channels { - subscriptions = append(subscriptions, - &subscription.Subscription{ - Channel: channels[x], - Pairs: currency.Pairs{pairs[z]}, - Asset: asset.Options, - }) - } - } - return subscriptions, nil -} - -// OptionSubscribe sends a subscription message to options public channels. -func (e *Exchange) OptionSubscribe(channelSubscriptions subscription.List) error { - ctx := context.TODO() - return e.handleOptionsPayloadSubscription(ctx, "subscribe", channelSubscriptions) -} - -// OptionUnsubscribe sends an unsubscription messages through options public channels. -func (e *Exchange) OptionUnsubscribe(channelSubscriptions subscription.List) error { - ctx := context.TODO() - return e.handleOptionsPayloadSubscription(ctx, "unsubscribe", channelSubscriptions) -} - -func (e *Exchange) handleOptionsPayloadSubscription(ctx context.Context, operation string, channelSubscriptions subscription.List) error { - payloads, err := e.handleSubscriptions(operation, channelSubscriptions) - if err != nil { - return err - } - for a := range payloads { - // The options connection does not send the subscription request id back with the subscription notification payload - // therefore the code doesn't wait for the response to check whether the subscription is successful or not. - err = e.Websocket.Conn.SendJSONMessage(ctx, request.Unset, payloads[a]) - if err != nil { - return err - } - } - return nil -} diff --git a/exchanges/bybit/bybit_test.go b/exchanges/bybit/bybit_test.go index 0047c6ab..dc5cae92 100644 --- a/exchanges/bybit/bybit_test.go +++ b/exchanges/bybit/bybit_test.go @@ -1,10 +1,12 @@ package bybit import ( + "bytes" "context" "errors" "fmt" "maps" + "net/http" "slices" "testing" "time" @@ -19,18 +21,20 @@ import ( "github.com/thrasher-corp/gocryptotrader/encoding/json" "github.com/thrasher-corp/gocryptotrader/exchange/websocket" exchange "github.com/thrasher-corp/gocryptotrader/exchanges" + "github.com/thrasher-corp/gocryptotrader/exchanges/account" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/fill" "github.com/thrasher-corp/gocryptotrader/exchanges/fundingrate" "github.com/thrasher-corp/gocryptotrader/exchanges/futures" "github.com/thrasher-corp/gocryptotrader/exchanges/kline" "github.com/thrasher-corp/gocryptotrader/exchanges/margin" "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" "github.com/thrasher-corp/gocryptotrader/exchanges/ticker" testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" testsubs "github.com/thrasher-corp/gocryptotrader/internal/testing/subscriptions" - testws "github.com/thrasher-corp/gocryptotrader/internal/testing/websocket" "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" "github.com/thrasher-corp/gocryptotrader/types" ) @@ -1623,78 +1627,78 @@ func TestGetWalletBalance(t *testing.T) { if mockTests { require.Len(t, r.List, 1, "GetWalletBalance must return a single list result") - assert.Equal(t, types.Number(0.1997), r.List[0].AccountIMRate, "AccountIMRate should match") - assert.Equal(t, types.Number(0.4996), r.List[0].AccountLTV, "AccountLTV should match") - assert.Equal(t, types.Number(0.0399), r.List[0].AccountMMRate, "AccountMMRate should match") - assert.Equal(t, "UNIFIED", r.List[0].AccountType, "AccountType should match") - assert.Equal(t, types.Number(24616.49915805), r.List[0].TotalAvailableBalance, "TotalAvailableBalance should match") - assert.Equal(t, types.Number(41445.9203332), r.List[0].TotalEquity, "TotalEquity should match") - assert.Equal(t, types.Number(6144.46796478), r.List[0].TotalInitialMargin, "TotalInitialMargin should match") - assert.Equal(t, types.Number(1228.89359295), r.List[0].TotalMaintenanceMargin, "TotalMaintenanceMargin should match") - assert.Equal(t, types.Number(30760.96712284), r.List[0].TotalMarginBalance, "TotalMarginBalance should match") - assert.Equal(t, types.Number(0.0), r.List[0].TotalPerpUPL, "TotalPerpUPL should match") - assert.Equal(t, types.Number(30760.96712284), r.List[0].TotalWalletBalance, "TotalWalletBalance should match") + assert.Equal(t, types.Number(0.1997), r.List[0].AccountIMRate, "AccountIMRate should be correct") + assert.Equal(t, types.Number(0.4996), r.List[0].AccountLTV, "AccountLTV should be correct") + assert.Equal(t, types.Number(0.0399), r.List[0].AccountMMRate, "AccountMMRate should be correct") + assert.Equal(t, "UNIFIED", r.List[0].AccountType, "AccountType should be correct") + assert.Equal(t, types.Number(24616.49915805), r.List[0].TotalAvailableBalance, "TotalAvailableBalance should be correct") + assert.Equal(t, types.Number(41445.9203332), r.List[0].TotalEquity, "TotalEquity should be correct") + assert.Equal(t, types.Number(6144.46796478), r.List[0].TotalInitialMargin, "TotalInitialMargin should be correct") + assert.Equal(t, types.Number(1228.89359295), r.List[0].TotalMaintenanceMargin, "TotalMaintenanceMargin should be correct") + assert.Equal(t, types.Number(30760.96712284), r.List[0].TotalMarginBalance, "TotalMarginBalance should be correct") + assert.Equal(t, types.Number(0.0), r.List[0].TotalPerpUPL, "TotalPerpUPL should be correct") + assert.Equal(t, types.Number(30760.96712284), r.List[0].TotalWalletBalance, "TotalWalletBalance should be correct") require.Len(t, r.List[0].Coin, 3, "GetWalletBalance must return 3 coins") for x := range r.List[0].Coin { switch x { case 0: - assert.Equal(t, types.Number(0.21976631), r.List[0].Coin[x].AccruedInterest, "AccruedInterest should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].AvailableToBorrow, "AvailableToBorrow should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].AvailableToWithdraw, "AvailableToWithdraw should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].Bonus, "Bonus should match") - assert.Equal(t, types.Number(30723.630216383711792744), r.List[0].Coin[x].BorrowAmount, "BorrowAmount should match") - assert.Equal(t, currency.USDC, r.List[0].Coin[x].Coin, "Coin should match") + assert.Equal(t, types.Number(0.21976631), r.List[0].Coin[x].AccruedInterest, "AccruedInterest should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].AvailableToBorrow, "AvailableToBorrow should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].AvailableToWithdraw, "AvailableToWithdraw should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].Bonus, "Bonus should be correct") + assert.Equal(t, types.Number(30723.630216383711792744), r.List[0].Coin[x].BorrowAmount, "BorrowAmount should be correct") + assert.Equal(t, currency.USDC, r.List[0].Coin[x].Coin, "Coin should be correct") assert.True(t, r.List[0].Coin[x].CollateralSwitch, "CollateralSwitch should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].CumulativeRealisedPNL, "CumulativeRealisedPNL should match") - assert.Equal(t, types.Number(-30723.63021638), r.List[0].Coin[x].Equity, "Equity should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].Locked, "Locked should match") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].CumulativeRealisedPNL, "CumulativeRealisedPNL should be correct") + assert.Equal(t, types.Number(-30723.63021638), r.List[0].Coin[x].Equity, "Equity should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].Locked, "Locked should be correct") assert.True(t, r.List[0].Coin[x].MarginCollateral, "MarginCollateral should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].SpotHedgingQuantity, "SpotHedgingQuantity should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalOrderIM, "TotalOrderIM should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionIM, "TotalPositionIM should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionMM, "TotalPositionMM should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].UnrealisedPNL, "UnrealisedPNL should match") - assert.Equal(t, types.Number(-30722.33982391), r.List[0].Coin[x].USDValue, "USDValue should match") - assert.Equal(t, types.Number(-30723.63021638), r.List[0].Coin[x].WalletBalance, "WalletBalance should match") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].SpotHedgingQuantity, "SpotHedgingQuantity should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalOrderIM, "TotalOrderIM should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionIM, "TotalPositionIM should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionMM, "TotalPositionMM should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].UnrealisedPNL, "UnrealisedPNL should be correct") + assert.Equal(t, types.Number(-30722.33982391), r.List[0].Coin[x].USDValue, "USDValue should be correct") + assert.Equal(t, types.Number(-30723.63021638), r.List[0].Coin[x].WalletBalance, "WalletBalance should be correct") case 1: - assert.Equal(t, types.Number(0), r.List[0].Coin[x].AccruedInterest, "AccruedInterest should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].AvailableToBorrow, "AvailableToBorrow should match") - assert.Equal(t, types.Number(1005.79191187), r.List[0].Coin[x].AvailableToWithdraw, "AvailableToWithdraw should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].Bonus, "Bonus should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].BorrowAmount, "BorrowAmount should match") - assert.Equal(t, currency.AVAX, r.List[0].Coin[x].Coin, "Coin should match") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].AccruedInterest, "AccruedInterest should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].AvailableToBorrow, "AvailableToBorrow should be correct") + assert.Equal(t, types.Number(1005.79191187), r.List[0].Coin[x].AvailableToWithdraw, "AvailableToWithdraw should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].Bonus, "Bonus should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].BorrowAmount, "BorrowAmount should be correct") + assert.Equal(t, currency.AVAX, r.List[0].Coin[x].Coin, "Coin should be correct") assert.True(t, r.List[0].Coin[x].CollateralSwitch, "CollateralSwitch should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].CumulativeRealisedPNL, "CumulativeRealisedPNL should match") - assert.Equal(t, types.Number(2473.9), r.List[0].Coin[x].Equity, "Equity should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].Locked, "Locked should match") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].CumulativeRealisedPNL, "CumulativeRealisedPNL should be correct") + assert.Equal(t, types.Number(2473.9), r.List[0].Coin[x].Equity, "Equity should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].Locked, "Locked should be correct") assert.True(t, r.List[0].Coin[x].MarginCollateral, "MarginCollateral should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].SpotHedgingQuantity, "SpotHedgingQuantity should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalOrderIM, "TotalOrderIM should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionIM, "TotalPositionIM should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionMM, "TotalPositionMM should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].UnrealisedPNL, "UnrealisedPNL should match") - assert.Equal(t, types.Number(71233.0214024), r.List[0].Coin[x].USDValue, "USDValue should match") - assert.Equal(t, types.Number(2473.9), r.List[0].Coin[x].WalletBalance, "WalletBalance should match") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].SpotHedgingQuantity, "SpotHedgingQuantity should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalOrderIM, "TotalOrderIM should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionIM, "TotalPositionIM should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionMM, "TotalPositionMM should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].UnrealisedPNL, "UnrealisedPNL should be correct") + assert.Equal(t, types.Number(71233.0214024), r.List[0].Coin[x].USDValue, "USDValue should be correct") + assert.Equal(t, types.Number(2473.9), r.List[0].Coin[x].WalletBalance, "WalletBalance should be correct") case 2: - assert.Equal(t, types.Number(0), r.List[0].Coin[x].AccruedInterest, "AccruedInterest should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].AvailableToBorrow, "AvailableToBorrow should match") - assert.Equal(t, types.Number(935.1415), r.List[0].Coin[x].AvailableToWithdraw, "AvailableToWithdraw should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].Bonus, "Bonus should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].BorrowAmount, "BorrowAmount should match") - assert.Equal(t, currency.USDT, r.List[0].Coin[x].Coin, "Coin should match") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].AccruedInterest, "AccruedInterest should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].AvailableToBorrow, "AvailableToBorrow should be correct") + assert.Equal(t, types.Number(935.1415), r.List[0].Coin[x].AvailableToWithdraw, "AvailableToWithdraw should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].Bonus, "Bonus should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].BorrowAmount, "BorrowAmount should be correct") + assert.Equal(t, currency.USDT, r.List[0].Coin[x].Coin, "Coin should be correct") assert.True(t, r.List[0].Coin[x].CollateralSwitch, "CollateralSwitch should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].CumulativeRealisedPNL, "CumulativeRealisedPNL should match") - assert.Equal(t, types.Number(935.1415), r.List[0].Coin[x].Equity, "Equity should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].Locked, "Locked should match") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].CumulativeRealisedPNL, "CumulativeRealisedPNL should be correct") + assert.Equal(t, types.Number(935.1415), r.List[0].Coin[x].Equity, "Equity should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].Locked, "Locked should be correct") assert.True(t, r.List[0].Coin[x].MarginCollateral, "MarginCollateral should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].SpotHedgingQuantity, "SpotHedgingQuantity should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalOrderIM, "TotalOrderIM should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionIM, "TotalPositionIM should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionMM, "TotalPositionMM should match") - assert.Equal(t, types.Number(0), r.List[0].Coin[x].UnrealisedPNL, "UnrealisedPNL should match") - assert.Equal(t, types.Number(935.23875471), r.List[0].Coin[x].USDValue, "USDValue should match") - assert.Equal(t, types.Number(935.1415), r.List[0].Coin[x].WalletBalance, "WalletBalance should match") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].SpotHedgingQuantity, "SpotHedgingQuantity should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalOrderIM, "TotalOrderIM should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionIM, "TotalPositionIM should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].TotalPositionMM, "TotalPositionMM should be correct") + assert.Equal(t, types.Number(0), r.List[0].Coin[x].UnrealisedPNL, "UnrealisedPNL should be correct") + assert.Equal(t, types.Number(935.23875471), r.List[0].Coin[x].USDValue, "USDValue should be correct") + assert.Equal(t, types.Number(935.1415), r.List[0].Coin[x].WalletBalance, "WalletBalance should be correct") } } } @@ -2885,22 +2889,22 @@ func TestUpdateAccountInfo(t *testing.T) { switch x { case 0: assert.Equal(t, currency.USDC, r.Accounts[0].Currencies[x].Currency, "Currency should be USDC") - assert.Equal(t, -30723.63021638, r.Accounts[0].Currencies[x].Total, "Total amount should match") - assert.Equal(t, -30723.63021638, r.Accounts[0].Currencies[x].Hold, "Hold amount should match") - assert.Equal(t, 30723.630216383714, r.Accounts[0].Currencies[x].Borrowed, "Borrowed amount should match") - assert.Equal(t, 0.0, r.Accounts[0].Currencies[x].Free, "Free amount should match") + assert.Equal(t, -30723.63021638, r.Accounts[0].Currencies[x].Total, "Total amount should be correct") + assert.Equal(t, -30723.63021638, r.Accounts[0].Currencies[x].Hold, "Hold amount should be correct") + assert.Equal(t, 30723.630216383714, r.Accounts[0].Currencies[x].Borrowed, "Borrowed amount should be correct") + assert.Equal(t, 0.0, r.Accounts[0].Currencies[x].Free, "Free amount should be correct") case 1: assert.Equal(t, currency.AVAX, r.Accounts[0].Currencies[x].Currency, "Currency should be AVAX") - assert.Equal(t, 2473.9, r.Accounts[0].Currencies[x].Total, "Total amount should match") - assert.Equal(t, 1468.10808813, r.Accounts[0].Currencies[x].Hold, "Hold amount should match") - assert.Equal(t, 0.0, r.Accounts[0].Currencies[x].Borrowed, "Borrowed amount should match") - assert.Equal(t, 1005.79191187, r.Accounts[0].Currencies[x].Free, "Free amount should match") + assert.Equal(t, 2473.9, r.Accounts[0].Currencies[x].Total, "Total amount should be correct") + assert.Equal(t, 1468.10808813, r.Accounts[0].Currencies[x].Hold, "Hold amount should be correct") + assert.Equal(t, 0.0, r.Accounts[0].Currencies[x].Borrowed, "Borrowed amount should be correct") + assert.Equal(t, 1005.79191187, r.Accounts[0].Currencies[x].Free, "Free amount should be correct") case 2: assert.Equal(t, currency.USDT, r.Accounts[0].Currencies[x].Currency, "Currency should be USDT") - assert.Equal(t, 935.1415, r.Accounts[0].Currencies[x].Total, "Total amount should match") - assert.Equal(t, 0.0, r.Accounts[0].Currencies[x].Borrowed, "Borrowed amount should match") - assert.Equal(t, 0.0, r.Accounts[0].Currencies[x].Hold, "Hold amount should match") - assert.Equal(t, 935.1415, r.Accounts[0].Currencies[x].Free, "Free amount should match") + assert.Equal(t, 935.1415, r.Accounts[0].Currencies[x].Total, "Total amount should be correct") + assert.Equal(t, 0.0, r.Accounts[0].Currencies[x].Borrowed, "Borrowed amount should be correct") + assert.Equal(t, 0.0, r.Accounts[0].Currencies[x].Hold, "Hold amount should be correct") + assert.Equal(t, 935.1415, r.Accounts[0].Currencies[x].Free, "Free amount should be correct") } } } @@ -3015,42 +3019,39 @@ func TestCancelBatchOrders(t *testing.T) { } } +type FixtureConnection struct { + dialError error + sendMessageReturnResponseOverride []byte + match websocket.Match + websocket.Connection +} + +func (d *FixtureConnection) GenerateMessageID(bool) int64 { return 1337 } +func (d *FixtureConnection) SetupPingHandler(request.EndpointLimit, websocket.PingHandler) {} +func (d *FixtureConnection) Dial(context.Context, *gws.Dialer, http.Header) error { return d.dialError } + +func (d *FixtureConnection) SendMessageReturnResponse(context.Context, request.EndpointLimit, any, any) ([]byte, error) { + if d.sendMessageReturnResponseOverride != nil { + return d.sendMessageReturnResponseOverride, nil + } + return []byte(`{"success":true,"ret_msg":"subscribe","conn_id":"5758770c-8152-4545-a84f-dae089e56499","req_id":"1","op":"subscribe"}`), nil +} + +func (d *FixtureConnection) SendJSONMessage(context.Context, request.EndpointLimit, any) error { + return nil +} + +func (d *FixtureConnection) RequireMatchWithData(signature any, data []byte) error { + return d.match.RequireMatchWithData(signature, data) +} + func TestWsConnect(t *testing.T) { t.Parallel() - if mockTests { - t.Skip(skippingWebsocketFunctionsForMockTesting) - } - err := e.WsConnect() - if err != nil { - t.Error(err) - } -} - -func TestWsLinearConnect(t *testing.T) { - t.Parallel() - if mockTests { - t.Skip(skippingWebsocketFunctionsForMockTesting) - } - err := e.WsLinearConnect() - assert.Truef(t, errors.Is(err, websocket.ErrWebsocketNotEnabled) || err == nil, "WsLinerConnect should not error: %s", err) -} - -func TestWsInverseConnect(t *testing.T) { - t.Parallel() - if mockTests { - t.Skip(skippingWebsocketFunctionsForMockTesting) - } - err := e.WsInverseConnect() - assert.Truef(t, errors.Is(err, websocket.ErrWebsocketNotEnabled) || err == nil, "WsInverseConnect should not error: %s", err) -} - -func TestWsOptionsConnect(t *testing.T) { - t.Parallel() - if mockTests { - t.Skip(skippingWebsocketFunctionsForMockTesting) - } - err := e.WsOptionsConnect() - assert.Truef(t, errors.Is(err, websocket.ErrWebsocketNotEnabled) || err == nil, "WsOptionsConnect should not error: %s", err) + err := e.WsConnect(t.Context(), &FixtureConnection{dialError: nil}) + require.NoError(t, err) + exp := errors.New("dial error") + err = e.WsConnect(t.Context(), &FixtureConnection{dialError: exp}) + require.ErrorIs(t, err, exp) } var pushDataMap = map[string]string{ @@ -3062,22 +3063,195 @@ var pushDataMap = map[string]string{ "Public LT Kline": `{ "type": "snapshot", "topic": "kline_lt.5.BTCUSDT", "data": [ { "start": 1672325100000, "end": 1672325399999, "interval": "5", "open": "0.416039541212402799", "close": "0.41477848043290448", "high": "0.416039541212402799", "low": "0.409734237314911206", "confirm": false, "timestamp": 1672325322393} ], "ts": 1672325322393}`, "Public LT Ticker": `{ "topic": "tickers_lt.BTCUSDT", "ts": 1672325446847, "type": "snapshot", "data": { "symbol": "BTCUSDT", "lastPrice": "0.41477848043290448", "highPrice24h": "0.435285472510871305", "lowPrice24h": "0.394601507960931382", "prevPrice24h": "0.431502290172376349", "price24hPcnt": "-0.0388" } }`, "Public LT Navigation": `{ "topic": "lt.EOS3LUSDT", "ts": 1672325564669, "type": "snapshot", "data": { "symbol": "BTCUSDT", "time": 1672325564554, "nav": "0.413517419653406162", "basketPosition": "1.261060779498318641", "leverage": "2.656197506416192150", "basketLoan": "-0.684866519289629374", "circulation": "72767.309468460367138199", "basket": "91764.000000292013277472" } }`, - "Private Position": `{"id": "59232430b58efe-5fc5-4470-9337-4ce293b68edd", "topic": "position", "creationTime": 1672364174455, "data": [ { "positionIdx": 0, "tradeMode": 0, "riskId": 41, "riskLimitValue": "200000", "symbol": "XRPUSDT", "side": "Buy", "size": "75", "entryPrice": "0.3615", "leverage": "10", "positionValue": "27.1125", "positionBalance": "0", "markPrice": "0.3374", "positionIM": "2.72589075", "positionMM": "0.28576575", "takeProfit": "0", "stopLoss": "0", "trailingStop": "0", "unrealisedPnl": "-1.8075", "cumRealisedPnl": "0.64782276", "createdTime": "1672121182216", "updatedTime": "1672364174449", "tpslMode": "Full", "liqPrice": "", "bustPrice": "", "category": "linear","positionStatus":"Normal","adlRankIndicator":2}]}`, - "Private Order": `{ "id": "5923240c6880ab-c59f-420b-9adb-3639adc9dd90", "topic": "order", "creationTime": 1672364262474, "data": [ { "symbol": "BTCUSDT", "orderId": "5cf98598-39a7-459e-97bf-76ca765ee020", "side": "Sell", "orderType": "Market", "cancelType": "UNKNOWN", "price": "72.5", "qty": "1", "orderIv": "", "timeInForce": "IOC", "orderStatus": "Filled", "orderLinkId": "", "lastPriceOnCreated": "", "reduceOnly": false, "leavesQty": "", "leavesValue": "", "cumExecQty": "1", "cumExecValue": "75", "avgPrice": "75", "blockTradeId": "", "positionIdx": 0, "cumExecFee": "0.358635", "createdTime": "1672364262444", "updatedTime": "1672364262457", "rejectReason": "EC_NoError", "stopOrderType": "", "tpslMode": "", "triggerPrice": "", "takeProfit": "", "stopLoss": "", "tpTriggerBy": "", "slTriggerBy": "", "tpLimitPrice": "", "slLimitPrice": "", "triggerDirection": 0, "triggerBy": "", "closeOnTrigger": false, "category": "option", "placeType": "price", "smpType": "None", "smpGroup": 0, "smpOrderId": "" } ] }`, - "Private Wallet": `{ "id": "5923242c464be9-25ca-483d-a743-c60101fc656f", "topic": "wallet", "creationTime": 1672364262482, "data": [ { "accountIMRate": "0.016", "accountMMRate": "0.003", "totalEquity": "12837.78330098", "totalWalletBalance": "12840.4045924", "totalMarginBalance": "12837.78330188", "totalAvailableBalance": "12632.05767702", "totalPerpUPL": "-2.62129051", "totalInitialMargin": "205.72562486", "totalMaintenanceMargin": "39.42876721", "coin": [ { "coin": "USDC", "equity": "200.62572554", "usdValue": "200.62572554", "walletBalance": "201.34882644", "availableToWithdraw": "0", "availableToBorrow": "1500000", "borrowAmount": "0", "accruedInterest": "0", "totalOrderIM": "0", "totalPositionIM": "202.99874213", "totalPositionMM": "39.14289747", "unrealisedPnl": "74.2768991", "cumRealisedPnl": "-209.1544627", "bonus": "0" }, { "coin": "BTC", "equity": "0.06488393", "usdValue": "1023.08402268", "walletBalance": "0.06488393", "availableToWithdraw": "0.06488393", "availableToBorrow": "2.5", "borrowAmount": "0", "accruedInterest": "0", "totalOrderIM": "0", "totalPositionIM": "0", "totalPositionMM": "0", "unrealisedPnl": "0", "cumRealisedPnl": "0", "bonus": "0" }, { "coin": "ETH", "equity": "0", "usdValue": "0", "walletBalance": "0", "availableToWithdraw": "0", "availableToBorrow": "26", "borrowAmount": "0", "accruedInterest": "0", "totalOrderIM": "0", "totalPositionIM": "0", "totalPositionMM": "0", "unrealisedPnl": "0", "cumRealisedPnl": "0", "bonus": "0" }, { "coin": "USDT", "equity": "11726.64664904", "usdValue": "11613.58597018", "walletBalance": "11728.54414904", "availableToWithdraw": "11723.92075829", "availableToBorrow": "2500000", "borrowAmount": "0", "accruedInterest": "0", "totalOrderIM": "0", "totalPositionIM": "2.72589075", "totalPositionMM": "0.28576575", "unrealisedPnl": "-1.8975", "cumRealisedPnl": "0.64782276", "bonus": "0" }, { "coin": "EOS3L", "equity": "215.0570412", "usdValue": "0", "walletBalance": "215.0570412", "availableToWithdraw": "215.0570412", "availableToBorrow": "0", "borrowAmount": "0", "accruedInterest": "", "totalOrderIM": "0", "totalPositionIM": "0", "totalPositionMM": "0", "unrealisedPnl": "0", "cumRealisedPnl": "0", "bonus": "0" }, { "coin": "BIT", "equity": "1.82", "usdValue": "0.48758257", "walletBalance": "1.82", "availableToWithdraw": "1.82", "availableToBorrow": "0", "borrowAmount": "0", "accruedInterest": "", "totalOrderIM": "0", "totalPositionIM": "0", "totalPositionMM": "0", "unrealisedPnl": "0", "cumRealisedPnl": "0", "bonus": "0" } ], "accountType": "UNIFIED", "accountLTV": "0.017" } ] }`, - "Private Greek": `{ "id": "592324fa945a30-2603-49a5-b865-21668c29f2a6", "topic": "greeks", "creationTime": 1672364262482, "data": [ { "baseCoin": "ETH", "totalDelta": "0.06999986", "totalGamma": "-0.00000001", "totalVega": "-0.00000024", "totalTheta": "0.00001314" } ] }`, - "Execution": `{"id": "592324803b2785-26fa-4214-9963-bdd4727f07be", "topic": "execution", "creationTime": 1672364174455, "data": [ { "category": "linear", "symbol": "XRPUSDT", "execFee": "0.005061", "execId": "7e2ae69c-4edf-5800-a352-893d52b446aa", "execPrice": "0.3374", "execQty": "25", "execType": "Trade", "execValue": "8.435", "isMaker": false, "feeRate": "0.0006", "tradeIv": "", "markIv": "", "blockTradeId": "", "markPrice": "0.3391", "indexPrice": "", "underlyingPrice": "", "leavesQty": "0", "orderId": "f6e324ff-99c2-4e89-9739-3086e47f9381", "orderLinkId": "", "orderPrice": "0.3207", "orderQty":"25","orderType":"Market","stopOrderType":"UNKNOWN","side":"Sell","execTime":"1672364174443","isLeverage": "0","closedSize": "","seq":4688002127}]}`, + "pong": `{"op":"pong","args":["1753340040127"],"conn_id":"d157a7favkf4mm3ibuvg-14toog"}`, + "unhandled": `{"topic": "unhandled"}`, } -func TestPushData(t *testing.T) { +func TestPushDataPublic(t *testing.T) { t.Parallel() keys := slices.Collect(maps.Keys(pushDataMap)) slices.Sort(keys) for x := range keys { - err := e.wsHandleData(t.Context(), asset.Spot, []byte(pushDataMap[keys[x]])) - assert.NoError(t, err, "wsHandleData should not error") + err := e.wsHandleData(nil, asset.Spot, []byte(pushDataMap[keys[x]])) + if keys[x] == "unhandled" { + assert.ErrorIs(t, err, errUnhandledStreamData, "wsHandleData should error correctly for unhandled topics") + } else { + assert.NoError(t, err, "wsHandleData should not error") + } + } +} + +func TestWSHandleAuthenticatedData(t *testing.T) { + t.Parallel() + + err := e.wsHandleAuthenticatedData(t.Context(), nil, []byte(`{"op":"pong","args":["1753340040127"],"conn_id":"d157a7favkf4mm3ibuvg-14toog"}`)) + require.NoError(t, err, "wsHandleAuthenticatedData must not error for pong message") + + err = e.wsHandleAuthenticatedData(t.Context(), nil, []byte(`{"topic": "unhandled"}`)) + require.ErrorIs(t, err, errUnhandledStreamData, "wsHandleAuthenticatedData must error for unhandled stream data") + + e := new(Exchange) //nolint:govet // Intentional shadow + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + e.API.AuthenticatedSupport = true + e.API.AuthenticatedWebsocketSupport = true + e.SetCredentials("test", "test", "", "", "", "") + testexch.FixtureToDataHandler(t, "testdata/wsAuth.json", func(ctx context.Context, r []byte) error { + if bytes.Contains(r, []byte("%s")) { + r = fmt.Appendf(nil, string(r), optionsTradablePair.String()) + } + return e.wsHandleAuthenticatedData(ctx, nil, r) + }) + close(e.Websocket.DataHandler) + require.Len(t, e.Websocket.DataHandler, 6, "Should see correct number of messages") + + i := 0 + for data := range e.Websocket.DataHandler { + i++ + switch v := data.(type) { + case WsPositions: + require.Len(t, v, 1, "must see 1 position") + assert.Zero(t, v[0].PositionIdx, "PositionIdx should be 0") + assert.Zero(t, v[0].TradeMode, "TradeMode should be 0") + assert.Equal(t, int64(41), v[0].RiskID, "RiskID should be correct") + assert.Equal(t, 200000.0, v[0].RiskLimitValue.Float64(), "RiskLimitValue should be correct") + assert.Equal(t, "XRPUSDT", v[0].Symbol, "Symbol should be correct") + assert.Equal(t, "Buy", v[0].Side, "Side should be correct") + assert.Equal(t, 75.0, v[0].Size.Float64(), "Size should be correct") + assert.Equal(t, 0.3615, v[0].EntryPrice.Float64(), "Entry price should be correct") + assert.Equal(t, 10.0, v[0].Leverage.Float64(), "Leverage should be correct") + assert.Equal(t, 27.1125, v[0].PositionValue.Float64(), "Position value should be correct") + assert.Zero(t, v[0].PositionBalance.Float64(), "Position balance should be 0") + assert.Equal(t, 0.3374, v[0].MarkPrice.Float64(), "Mark price should be correct") + assert.Equal(t, 2.72589075, v[0].PositionIM.Float64(), "Position IM should be correct") + assert.Equal(t, 0.28576575, v[0].PositionMM.Float64(), "Position MM should be correct") + assert.Zero(t, v[0].TakeProfit.Float64(), "Take profit should be 0") + assert.Zero(t, v[0].StopLoss.Float64(), "Stop loss should be 0") + assert.Zero(t, v[0].TrailingStop.Float64(), "Trailing stop should be 0") + assert.Equal(t, -1.8075, v[0].UnrealisedPnl.Float64(), "Unrealised PnL should be correct") + assert.Equal(t, 0.64782276, v[0].CumRealisedPnl.Float64(), "Cum realised PnL should be correct") + assert.Equal(t, time.UnixMilli(1672121182216), v[0].CreatedTime.Time(), "Creation time should be correct") + assert.Equal(t, time.UnixMilli(1672364174449), v[0].UpdatedTime.Time(), "Updated time should be correct") + assert.Equal(t, "Full", v[0].TpslMode, "TPSL mode should be correct") + assert.Zero(t, v[0].LiqPrice.Float64(), "Liq price should be 0") + assert.Zero(t, v[0].BustPrice.Float64(), "Bust price should be 0") + assert.Equal(t, "linear", v[0].Category, "Category should be correct") + assert.Equal(t, "Normal", v[0].PositionStatus, "Position status should be correct") + assert.Equal(t, int64(2), v[0].AdlRankIndicator, "ADL Rank Indicator should be correct") + case []order.Detail: + if i == 6 { + require.Len(t, v, 1) + assert.Equal(t, "c1956690-b731-4191-97c0-94b00422231b", v[0].OrderID) + assert.Equal(t, "BTC_USDT", v[0].Pair.String()) + assert.Equal(t, order.Sell, v[0].Side) + assert.Equal(t, order.Filled, v[0].Status) + assert.Equal(t, 1.7, v[0].Amount) + assert.Equal(t, 4.033, v[0].Price) + assert.Equal(t, 4.24, v[0].AverageExecutedPrice) + assert.Equal(t, 0.0, v[0].RemainingAmount) + assert.Equal(t, asset.USDTMarginedFutures, v[0].AssetType) + continue + } + require.Len(t, v, 1, "must see 1 order") + assert.True(t, optionsTradablePair.Equal(v[0].Pair), "Pair should match") + assert.Equal(t, "5cf98598-39a7-459e-97bf-76ca765ee020", v[0].OrderID, "Order ID should be correct") + assert.Equal(t, order.Sell, v[0].Side, "Side should be correct") + assert.Equal(t, order.Market, v[0].Type, "Order type should be correct") + assert.Equal(t, 72.5, v[0].Price, "Price should be correct") + assert.Equal(t, 1.0, v[0].Amount, "Amount should be correct") + assert.Equal(t, order.ImmediateOrCancel, v[0].TimeInForce, "Time in force should be correct") + assert.Equal(t, order.Filled, v[0].Status, "Order status should be correct") + assert.Empty(t, v[0].ClientOrderID, "client order ID should be empty") + assert.False(t, v[0].ReduceOnly, "Reduce only should be false") + assert.Equal(t, 1.0, v[0].ExecutedAmount, "executed amount should be correct") + assert.Equal(t, 75.0, v[0].AverageExecutedPrice, "Avg price should be correct") + assert.Equal(t, 0.358635, v[0].Fee, "fee should be correct") + assert.Equal(t, time.UnixMilli(1672364262444), v[0].Date, "Created time should be correct") + assert.Equal(t, time.UnixMilli(1672364262457), v[0].LastUpdated, "Updated time should be correct") + case []account.Change: + require.Len(t, v, 6, "must see 6 items") + for i, change := range v { + assert.Empty(t, change.Account, "Account type should be empty") + assert.Equal(t, asset.Spot, change.AssetType, "Asset type should be Spot") + require.NotNil(t, change.Balance, "balance must not be nil") + switch i { + case 0: + assert.True(t, currency.USDC.Equal(change.Balance.Currency), "currency should match") + assert.Zero(t, change.Balance.AvailableWithoutBorrow, "AvailableWithoutBorrow should zero") + assert.Zero(t, change.Balance.Borrowed, "Borrowed should be 0") + assert.Equal(t, 201.34882644, change.Balance.Free, "Free should be correct") + assert.Zero(t, change.Balance.Hold, "Hold should be 0") + assert.Equal(t, 201.34882644, change.Balance.Total, "Total should be correct") + assert.Equal(t, time.UnixMilli(1672364262482), change.Balance.UpdatedAt, "Last updated should be correct") + case 1: + assert.True(t, currency.BTC.Equal(change.Balance.Currency), "currency should match") + assert.Equal(t, 0.06488393, change.Balance.Free, "Free should be correct") + assert.Zero(t, change.Balance.AvailableWithoutBorrow, "AvailableWithoutBorrow should zero") + assert.Zero(t, change.Balance.Borrowed, "Borrowed should be 0") + assert.Zero(t, change.Balance.Hold, "Hold should be 0") + assert.Equal(t, 0.06488393, change.Balance.Total, "Total should be correct") + assert.Equal(t, time.UnixMilli(1672364262482), change.Balance.UpdatedAt, "Last updated should be correct") + case 2: + assert.True(t, currency.ETH.Equal(change.Balance.Currency), "currency should match") + assert.Zero(t, change.Balance.Free, "Free should be 0") + assert.Zero(t, change.Balance.AvailableWithoutBorrow, "AvailableWithoutBorrow should zero") + assert.Zero(t, change.Balance.Borrowed, "Borrowed should be 0") + assert.Zero(t, change.Balance.Hold, "Hold should be 0") + assert.Zero(t, change.Balance.Total, "Total should be 0") + assert.Equal(t, time.UnixMilli(1672364262482), change.Balance.UpdatedAt, "Last updated should be correct") + case 3: + assert.True(t, currency.USDT.Equal(change.Balance.Currency), "currency should match") + assert.Equal(t, 11728.54414904, change.Balance.Free, "Free should be correct") + assert.Zero(t, change.Balance.AvailableWithoutBorrow, "AvailableWithoutBorrow should be 0") + assert.Zero(t, change.Balance.Borrowed, "Borrowed should be 0") + assert.Zero(t, change.Balance.Hold, "Hold should be 0") + assert.Equal(t, 11728.54414904, change.Balance.Total, "Total should be correct") + assert.Equal(t, time.UnixMilli(1672364262482), change.Balance.UpdatedAt, "Last updated should be correct") + case 4: + assert.True(t, currency.NewCode("EOS3L").Equal(change.Balance.Currency), "currency should match") + assert.Equal(t, 215.0570412, change.Balance.Free, "Free should be correct") + assert.Zero(t, change.Balance.AvailableWithoutBorrow, "AvailableWithoutBorrow should be 0") + assert.Zero(t, change.Balance.Borrowed, "Borrowed should be 0") + assert.Zero(t, change.Balance.Hold, "Hold should be 0") + assert.Equal(t, 215.0570412, change.Balance.Total, "Total should be correct") + assert.Equal(t, time.UnixMilli(1672364262482), change.Balance.UpdatedAt, "Last updated should be correct") + case 5: + assert.True(t, currency.BIT.Equal(change.Balance.Currency), "currency should match") + assert.Equal(t, 1.82, change.Balance.Free, "Free should be correct") + assert.Zero(t, change.Balance.AvailableWithoutBorrow, "AvailableWithoutBorrow should be 0") + assert.Zero(t, change.Balance.Borrowed, "Borrowed should be 0") + assert.Zero(t, change.Balance.Hold, "Hold should be 0") + assert.Equal(t, 1.82, change.Balance.Total, "Total should be correct") + assert.Equal(t, time.UnixMilli(1672364262482), change.Balance.UpdatedAt, "Last updated should be correct") + } + } + case *GreeksResponse: + assert.Equal(t, "592324fa945a30-2603-49a5-b865-21668c29f2a6", v.ID, "ID should be correct") + assert.Equal(t, "greeks", v.Topic, "Topic should be correct") + assert.Equal(t, time.UnixMilli(1672364262482), v.CreationTime.Time(), "Creation time should be correct") + require.Len(t, v.Data, 1, "must see 1 greek") + assert.Equal(t, "ETH", v.Data[0].BaseCoin.String(), "Base coin should be correct") + assert.Equal(t, 0.06999986, v.Data[0].TotalDelta.Float64(), "Total delta should be correct") + assert.Equal(t, -0.00000001, v.Data[0].TotalGamma.Float64(), "Total gamma should be correct") + assert.Equal(t, -0.00000024, v.Data[0].TotalVega.Float64(), "Total vega should be correct") + assert.Equal(t, 0.00001314, v.Data[0].TotalTheta.Float64(), "Total theta should be correct") + case []fill.Data: + require.Len(t, v, 1, "must see 1 fill") + assert.Equal(t, "7e2ae69c-4edf-5800-a352-893d52b446aa", v[0].ID, "ID should be correct") + assert.Equal(t, time.UnixMilli(1672364174443), v[0].Timestamp, "time should be correct") + assert.Equal(t, e.Name, v[0].Exchange, "Exchange name should be correct") + assert.Equal(t, asset.USDTMarginedFutures, v[0].AssetType, "Asset type should be correct") + assert.Equal(t, "XRP_USDT", v[0].CurrencyPair.String(), "Symbol should be correct") + assert.Equal(t, order.Sell, v[0].Side, "Side should be correct") + assert.Equal(t, "f6e324ff-99c2-4e89-9739-3086e47f9381", v[0].OrderID, "Order ID should be correct") + assert.Empty(t, v[0].ClientOrderID, "Client order ID should be empty") + assert.Empty(t, v[0].TradeID, "Trade ID should be empty") + assert.Equal(t, 0.3374, v[0].Price, "price should be correct") + assert.Equal(t, 25.0, v[0].Amount, "amount should be correct") + default: + t.Errorf("Unexpected data received: %v", v) + } } } @@ -3091,7 +3265,7 @@ func TestWsTicker(t *testing.T) { require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") testexch.FixtureToDataHandler(t, "testdata/wsTicker.json", func(_ context.Context, r []byte) error { defer slices.Delete(assetRouting, 0, 1) - return e.wsHandleData(t.Context(), assetRouting[0], r) + return e.wsHandleData(nil, assetRouting[0], r) }) close(e.Websocket.DataHandler) expected := 8 @@ -3340,20 +3514,14 @@ func TestFetchTradablePairs(t *testing.T) { func TestDeltaUpdateOrderbook(t *testing.T) { t.Parallel() data := []byte(`{"topic":"orderbook.50.WEMIXUSDT","ts":1697573183768,"type":"snapshot","data":{"s":"WEMIXUSDT","b":[["0.9511","260.703"],["0.9677","0"]],"a":[],"u":3119516,"seq":14126848493},"cts":1728966699481}`) - err := e.wsHandleData(t.Context(), asset.Spot, data) - if err != nil { - t.Fatal(err) - } + err := e.wsHandleData(nil, asset.Spot, data) + require.NoError(t, err, "wsHandleData must not error") update := []byte(`{"topic":"orderbook.50.WEMIXUSDT","ts":1697573183768,"type":"delta","data":{"s":"WEMIXUSDT","b":[["0.9511","260.703"],["0.9677","0"]],"a":[],"u":3119516,"seq":14126848493},"cts":1728966699481}`) var wsResponse WebsocketResponse err = json.Unmarshal(update, &wsResponse) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err, "Unmarshal must not error") err = e.wsProcessOrderbook(asset.Spot, &wsResponse) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err, "wsProcessOrderbook must not error") } func TestGetLongShortRatio(t *testing.T) { @@ -3577,7 +3745,7 @@ func TestGetCurrencyTradeURL(t *testing.T) { func TestGenerateSubscriptions(t *testing.T) { t.Parallel() - e := new(Exchange) + e := new(Exchange) //nolint:govet // Intentional shadow require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") e.Websocket.SetCanUseAuthenticatedEndpoints(true) @@ -3611,11 +3779,6 @@ func TestGenerateSubscriptions(t *testing.T) { } else { s.Pairs = pairs s.QualifiedChannel = channelName(s) - categoryName := getCategoryName(a) - if isCategorisedChannel(s.QualifiedChannel) && categoryName != "" { - s.QualifiedChannel += "." + categoryName - } - exp = append(exp, s) } } @@ -3623,48 +3786,43 @@ func TestGenerateSubscriptions(t *testing.T) { testsubs.EqualLists(t, exp, subs) } -func TestSubscribe(t *testing.T) { - t.Parallel() - e := new(Exchange) - require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") - subs, err := e.Features.Subscriptions.ExpandTemplates(e) - require.NoError(t, err, "ExpandTemplates must not error") - e.Features.Subscriptions = subscription.List{} - testexch.SetupWs(t, e) - err = e.Subscribe(subs) - require.NoError(t, err, "Subscribe must not error") -} - func TestAuthSubscribe(t *testing.T) { t.Parallel() - e := new(Exchange) + + e := new(Exchange) //nolint:govet // Intentional shadow require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + require.NoError(t, e.authSubscribe(t.Context(), &FixtureConnection{}, subscription.List{})) + + authsubs, err := e.generateAuthSubscriptions() + require.NoError(t, err, "generateAuthSubscriptions must not error") + require.Empty(t, authsubs, "generateAuthSubscriptions must not return subs") + e.Websocket.SetCanUseAuthenticatedEndpoints(true) - subs, err := e.Features.Subscriptions.ExpandTemplates(e) - require.NoError(t, err, "ExpandTemplates must not error") - e.Features.Subscriptions = subscription.List{} - success := true - mock := func(tb testing.TB, msg []byte, w *gws.Conn) error { - tb.Helper() - var req SubscriptionArgument - require.NoError(tb, json.Unmarshal(msg, &req), "Unmarshal must not error") - require.Equal(tb, "subscribe", req.Operation) - msg, err = json.Marshal(SubscriptionResponse{ - Success: success, - RetMsg: "Mock Resp Error", - RequestID: req.RequestID, - Operation: req.Operation, - }) - require.NoError(tb, err, "Marshal must not error") - return w.WriteMessage(gws.TextMessage, msg) - } - e = testexch.MockWsInstance[Exchange](t, testws.CurryWsMockUpgrader(t, mock)) - e.Websocket.AuthConn = e.Websocket.Conn - err = e.Subscribe(subs) - require.NoError(t, err, "Subscribe must not error") - success = false - err = e.Subscribe(subs) - assert.ErrorContains(t, err, "Mock Resp Error", "Subscribe should error containing the returned RetMsg") + authsubs, err = e.generateAuthSubscriptions() + require.NoError(t, err, "generateAuthSubscriptions must not error") + require.NotEmpty(t, authsubs, "generateAuthSubscriptions must return subs") + + require.NoError(t, e.authSubscribe(t.Context(), &FixtureConnection{}, authsubs)) + require.NoError(t, e.authUnsubscribe(t.Context(), &FixtureConnection{}, authsubs)) +} + +func TestWebsocketAuthenticateConnection(t *testing.T) { + t.Parallel() + + e := new(Exchange) //nolint:govet // Intentional shadow + require.NoError(t, testexch.Setup(e)) + + err := e.WebsocketAuthenticateConnection(t.Context(), &FixtureConnection{}) + require.ErrorIs(t, err, exchange.ErrAuthenticationSupportNotEnabled) + + e.API.AuthenticatedSupport = true + e.API.AuthenticatedWebsocketSupport = true + e.Websocket.SetCanUseAuthenticatedEndpoints(true) + ctx := account.DeployCredentialsToContext(t.Context(), &account.Credentials{Key: "dummy", Secret: "dummy"}) + err = e.WebsocketAuthenticateConnection(ctx, &FixtureConnection{}) + require.NoError(t, err) + err = e.WebsocketAuthenticateConnection(ctx, &FixtureConnection{sendMessageReturnResponseOverride: []byte(`{"success":false,"ret_msg":"failed auth","conn_id":"5758770c-8152-4545-a84f-dae089e56499","req_id":"1","op":"subscribe"}`)}) + require.Error(t, err) } func TestTransformSymbol(t *testing.T) { @@ -3730,3 +3888,55 @@ func TestTransformSymbol(t *testing.T) { }) } } + +func TestMatchPairAssetFromResponse(t *testing.T) { + t.Parallel() + + noDelim := currency.PairFormat{Uppercase: true} + for _, tc := range []struct { + pair string + category string + expectedAsset asset.Item + expectedPair currency.Pair + err error + }{ + {pair: noDelim.Format(spotTradablePair), category: "spot", expectedAsset: asset.Spot, expectedPair: spotTradablePair}, + {pair: noDelim.Format(usdtMarginedTradablePair), category: "linear", expectedAsset: asset.USDTMarginedFutures, expectedPair: usdtMarginedTradablePair}, + {pair: noDelim.Format(usdcMarginedTradablePair), category: "linear", expectedAsset: asset.USDCMarginedFutures, expectedPair: usdcMarginedTradablePair}, + {pair: noDelim.Format(inverseTradablePair), category: "inverse", expectedAsset: asset.CoinMarginedFutures, expectedPair: inverseTradablePair}, + {pair: optionsTradablePair.String(), category: "option", expectedAsset: asset.Options, expectedPair: optionsTradablePair}, + {pair: optionsTradablePair.String(), category: "silly", err: errUnsupportedCategory, expectedAsset: 0}, + {pair: "bad pair", category: "spot", err: currency.ErrPairNotFound}, + } { + t.Run(fmt.Sprintf("pair: %s, category: %s", tc.pair, tc.category), func(t *testing.T) { + t.Parallel() + p, a, err := e.matchPairAssetFromResponse(tc.category, tc.pair) + require.ErrorIs(t, err, tc.err) + assert.Equal(t, tc.expectedAsset, a) + assert.True(t, tc.expectedPair.Equal(p)) + }) + } +} + +func TestHandleNoTopicWebsocketResponse(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + operation string + requestID string + error error + }{ + {operation: "subscribe"}, + {operation: "unsubscribe"}, + {operation: "auth"}, + {operation: "auth", requestID: "noMatch", error: websocket.ErrSignatureNotMatched}, + {operation: "ping"}, + {operation: "pong"}, + } { + t.Run(fmt.Sprintf("operation: %s, requestID: %s", tc.operation, tc.requestID), func(t *testing.T) { + t.Parallel() + err := e.handleNoTopicWebsocketResponse(&FixtureConnection{}, &WebsocketResponse{Operation: tc.operation, RequestID: tc.requestID}, nil) + assert.ErrorIs(t, err, tc.error, "handleNoTopicWebsocketResponse should return expected error") + }) + } +} diff --git a/exchanges/bybit/bybit_types.go b/exchanges/bybit/bybit_types.go index 4515682b..a8eafb13 100644 --- a/exchanges/bybit/bybit_types.go +++ b/exchanges/bybit/bybit_types.go @@ -160,11 +160,23 @@ func constructOrderbook(o *orderbookResponse) (*Orderbook, error) { // TickerData represents a list of ticker detailed information. type TickerData struct { Category string `json:"category"` - List []TickerItem `json:"list"` + List []TickerREST `json:"list"` } -// TickerItem represents a ticker item detail -type TickerItem struct { +// TickerREST for REST API +type TickerREST struct { + TickerCommon + DeliveryTime types.Time `json:"deliveryTime"` +} + +// TickerWebsocket for websocket API +type TickerWebsocket struct { + TickerCommon + DeliveryTime time.Time `json:"deliveryTime"` // "2025-03-28T08:00:00Z" +} + +// TickerCommon common ticker fields +type TickerCommon struct { Symbol string `json:"symbol"` TickDirection string `json:"tickDirection"` LastPrice types.Number `json:"lastPrice"` @@ -1976,21 +1988,21 @@ type WebsocketWallet struct { TotalInitialMargin types.Number `json:"totalInitialMargin"` TotalMaintenanceMargin types.Number `json:"totalMaintenanceMargin"` Coin []struct { - Coin string `json:"coin"` - Equity types.Number `json:"equity"` - UsdValue types.Number `json:"usdValue"` - WalletBalance types.Number `json:"walletBalance"` - AvailableToWithdraw types.Number `json:"availableToWithdraw"` - AvailableToBorrow types.Number `json:"availableToBorrow"` - BorrowAmount types.Number `json:"borrowAmount"` - AccruedInterest types.Number `json:"accruedInterest"` - TotalOrderIM types.Number `json:"totalOrderIM"` - TotalPositionIM types.Number `json:"totalPositionIM"` - TotalPositionMM types.Number `json:"totalPositionMM"` - UnrealisedPnl types.Number `json:"unrealisedPnl"` - CumRealisedPnl types.Number `json:"cumRealisedPnl"` - Bonus types.Number `json:"bonus"` - SpotHedgingQuantity types.Number `json:"spotHedgingQty"` + Coin currency.Code `json:"coin"` + Equity types.Number `json:"equity"` + UsdValue types.Number `json:"usdValue"` + WalletBalance types.Number `json:"walletBalance"` + AvailableToWithdraw types.Number `json:"availableToWithdraw"` + AvailableToBorrow types.Number `json:"availableToBorrow"` + BorrowAmount types.Number `json:"borrowAmount"` + AccruedInterest types.Number `json:"accruedInterest"` + TotalOrderIM types.Number `json:"totalOrderIM"` + TotalPositionIM types.Number `json:"totalPositionIM"` + TotalPositionMM types.Number `json:"totalPositionMM"` + UnrealisedPnl types.Number `json:"unrealisedPnl"` + CumRealisedPnl types.Number `json:"cumRealisedPnl"` + Bonus types.Number `json:"bonus"` + SpotHedgingQuantity types.Number `json:"spotHedgingQty"` } `json:"coin"` AccountType string `json:"accountType"` AccountLTV string `json:"accountLTV"` @@ -2003,11 +2015,11 @@ type GreeksResponse struct { Topic string `json:"topic"` CreationTime types.Time `json:"creationTime"` Data []struct { - BaseCoin string `json:"baseCoin"` - TotalDelta types.Number `json:"totalDelta"` - TotalGamma types.Number `json:"totalGamma"` - TotalVega types.Number `json:"totalVega"` - TotalTheta types.Number `json:"totalTheta"` + BaseCoin currency.Code `json:"baseCoin"` + TotalDelta types.Number `json:"totalDelta"` + TotalGamma types.Number `json:"totalGamma"` + TotalVega types.Number `json:"totalVega"` + TotalTheta types.Number `json:"totalTheta"` } `json:"data"` } diff --git a/exchanges/bybit/bybit_websocket.go b/exchanges/bybit/bybit_websocket.go index 6a3f8cee..5bfe4dfb 100644 --- a/exchanges/bybit/bybit_websocket.go +++ b/exchanges/bybit/bybit_websocket.go @@ -3,6 +3,7 @@ package bybit import ( "context" "encoding/hex" + "errors" "fmt" "net/http" "strconv" @@ -26,6 +27,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" "github.com/thrasher-corp/gocryptotrader/exchanges/ticker" "github.com/thrasher-corp/gocryptotrader/exchanges/trade" + "github.com/thrasher-corp/gocryptotrader/log" ) const ( @@ -47,7 +49,7 @@ const ( chanOrder = "order" chanWallet = "wallet" chanGreeks = "greeks" - chanDCP = "dcp" + // TODO: Implement DCP (Disconnection Protect) subscription spotPublic = "wss://stream.bybit.com/v5/public/spot" linearPublic = "wss://stream.bybit.com/v5/public/linear" // USDT, USDC perpetual & USDC Futures @@ -63,9 +65,8 @@ var defaultSubscriptions = subscription.List{ {Enabled: true, Asset: asset.Spot, Channel: subscription.OrderbookChannel, Levels: 50}, {Enabled: true, Asset: asset.Spot, Channel: subscription.AllTradesChannel}, {Enabled: true, Asset: asset.Spot, Channel: subscription.CandlesChannel, Interval: kline.OneHour}, - {Enabled: true, Asset: asset.Spot, Authenticated: true, Channel: subscription.MyOrdersChannel}, - {Enabled: true, Asset: asset.Spot, Authenticated: true, Channel: subscription.MyWalletChannel}, - {Enabled: true, Asset: asset.Spot, Authenticated: true, Channel: subscription.MyTradesChannel}, + // Authenticated channels are currently being managed by the `generateAuthSubscriptions` method for the private connection + // TODO: expand subscription template generation to handle authenticated subscriptions across all assets } var subscriptionNames = map[string]string{ @@ -73,84 +74,52 @@ var subscriptionNames = map[string]string{ subscription.OrderbookChannel: chanOrderbook, subscription.AllTradesChannel: chanPublicTrade, subscription.MyOrdersChannel: chanOrder, - subscription.MyTradesChannel: chanExecution, subscription.MyWalletChannel: chanWallet, + subscription.MyTradesChannel: chanExecution, subscription.CandlesChannel: chanKline, } +var ( + errUnhandledStreamData = errors.New("unhandled stream data") + errUnsupportedCategory = errors.New("unsupported category") +) + // WsConnect connects to a websocket feed -func (e *Exchange) WsConnect() error { - ctx := context.TODO() - if !e.Websocket.IsEnabled() || !e.IsEnabled() || !e.IsAssetWebsocketSupported(asset.Spot) { - return websocket.ErrWebsocketNotEnabled - } - var dialer gws.Dialer - err := e.Websocket.Conn.Dial(ctx, &dialer, http.Header{}) - if err != nil { +func (e *Exchange) WsConnect(ctx context.Context, conn websocket.Connection) error { + if err := conn.Dial(ctx, &gws.Dialer{}, http.Header{}); err != nil { return err } - e.Websocket.Conn.SetupPingHandler(request.Unset, websocket.PingHandler{ + conn.SetupPingHandler(request.Unset, websocket.PingHandler{ MessageType: gws.TextMessage, Message: []byte(`{"op": "ping"}`), Delay: bybitWebsocketTimer, }) - - e.Websocket.Wg.Add(1) - go e.wsReadData(ctx, asset.Spot, e.Websocket.Conn) - if e.Websocket.CanUseAuthenticatedEndpoints() { - err = e.WsAuth(ctx) - if err != nil { - e.Websocket.DataHandler <- err - e.Websocket.SetCanUseAuthenticatedEndpoints(false) - } - } return nil } -// WsAuth sends an authentication message to receive auth data -func (e *Exchange) WsAuth(ctx context.Context) error { +// WebsocketAuthenticateConnection sends an authentication message to receive auth data +func (e *Exchange) WebsocketAuthenticateConnection(ctx context.Context, conn websocket.Connection) error { creds, err := e.GetCredentials(ctx) if err != nil { return err } - - var dialer gws.Dialer - if err := e.Websocket.AuthConn.Dial(ctx, &dialer, http.Header{}); err != nil { - return err - } - - e.Websocket.AuthConn.SetupPingHandler(request.Unset, websocket.PingHandler{ - MessageType: gws.TextMessage, - Message: []byte(`{"op":"ping"}`), - Delay: bybitWebsocketTimer, - }) - - e.Websocket.Wg.Add(1) - go e.wsReadData(ctx, asset.Spot, e.Websocket.AuthConn) - intNonce := time.Now().Add(time.Hour * 6).UnixMilli() strNonce := strconv.FormatInt(intNonce, 10) - hmac, err := crypto.GetHMAC( - crypto.HashSHA256, - []byte("GET/realtime"+strNonce), - []byte(creds.Secret), - ) + hmac, err := crypto.GetHMAC(crypto.HashSHA256, []byte("GET/realtime"+strNonce), []byte(creds.Secret)) if err != nil { return err } - sign := hex.EncodeToString(hmac) req := Authenticate{ - RequestID: strconv.FormatInt(e.Websocket.AuthConn.GenerateMessageID(false), 10), + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), Operation: "auth", - Args: []any{creds.Key, intNonce, sign}, + Args: []any{creds.Key, intNonce, hex.EncodeToString(hmac)}, } - resp, err := e.Websocket.AuthConn.SendMessageReturnResponse(ctx, request.Unset, req.RequestID, req) + resp, err := conn.SendMessageReturnResponse(ctx, request.Unset, req.RequestID, req) if err != nil { return err } var response SubscriptionResponse - err = json.Unmarshal(resp, &response) - if err != nil { + if err := json.Unmarshal(resp, &response); err != nil { return err } if !response.Success { @@ -159,13 +128,7 @@ func (e *Exchange) WsAuth(ctx context.Context) error { return nil } -// Subscribe sends a websocket message to receive data from the channel -func (e *Exchange) Subscribe(channelsToSubscribe subscription.List) error { - ctx := context.TODO() - return e.handleSpotSubscription(ctx, "subscribe", channelsToSubscribe) -} - -func (e *Exchange) handleSubscriptions(operation string, subs subscription.List) (args []SubscriptionArgument, err error) { +func (e *Exchange) handleSubscriptions(conn websocket.Connection, operation string, subs subscription.List) (args []SubscriptionArgument, err error) { subs, err = subs.ExpandTemplates(e) if err != nil { return @@ -176,68 +139,15 @@ func (e *Exchange) handleSubscriptions(operation string, subs subscription.List) args = append(args, SubscriptionArgument{ auth: b[0].Authenticated, Operation: operation, - RequestID: strconv.FormatInt(e.Websocket.Conn.GenerateMessageID(false), 10), + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), Arguments: b.QualifiedChannels(), associatedSubs: b, }) } } - return } -// Unsubscribe sends a websocket message to stop receiving data from the channel -func (e *Exchange) Unsubscribe(channelsToUnsubscribe subscription.List) error { - ctx := context.TODO() - return e.handleSpotSubscription(ctx, "unsubscribe", channelsToUnsubscribe) -} - -func (e *Exchange) handleSpotSubscription(ctx context.Context, operation string, channelsToSubscribe subscription.List) error { - payloads, err := e.handleSubscriptions(operation, channelsToSubscribe) - if err != nil { - return err - } - for a := range payloads { - var response []byte - if payloads[a].auth { - response, err = e.Websocket.AuthConn.SendMessageReturnResponse(ctx, request.Unset, payloads[a].RequestID, payloads[a]) - if err != nil { - return err - } - } else { - response, err = e.Websocket.Conn.SendMessageReturnResponse(ctx, request.Unset, payloads[a].RequestID, payloads[a]) - if err != nil { - return err - } - } - var resp SubscriptionResponse - err = json.Unmarshal(response, &resp) - if err != nil { - return err - } - if !resp.Success { - return fmt.Errorf("%s with request ID %s msg: %s", resp.Operation, resp.RequestID, resp.RetMsg) - } - - var conn websocket.Connection - if payloads[a].auth { - conn = e.Websocket.AuthConn - } else { - conn = e.Websocket.Conn - } - - if operation == "unsubscribe" { - err = e.Websocket.RemoveSubscriptions(conn, payloads[a].associatedSubs...) - } else { - err = e.Websocket.AddSubscriptions(conn, payloads[a].associatedSubs...) - } - if err != nil { - return err - } - } - return nil -} - // generateSubscriptions generates default subscription func (e *Exchange) generateSubscriptions() (subscription.List, error) { return e.Features.Subscriptions.ExpandTemplates(e) @@ -246,61 +156,22 @@ func (e *Exchange) generateSubscriptions() (subscription.List, error) { // GetSubscriptionTemplate returns a subscription channel template func (e *Exchange) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) { return template.New("master.tmpl").Funcs(template.FuncMap{ - "channelName": channelName, - "isSymbolChannel": isSymbolChannel, - "intervalToString": intervalToString, - "getCategoryName": getCategoryName, - "isCategorisedChannel": isCategorisedChannel, + "channelName": channelName, + "isSymbolChannel": isSymbolChannel, + "intervalToString": intervalToString, + "getCategoryName": getCategoryName, }).Parse(subTplText) } -// wsReadData receives and passes on websocket messages for processing -func (e *Exchange) wsReadData(ctx context.Context, assetType asset.Item, ws websocket.Connection) { - defer e.Websocket.Wg.Done() - for { - select { - case <-e.Websocket.ShutdownC: - return - default: - resp := ws.ReadMessage() - if resp.Raw == nil { - return - } - err := e.wsHandleData(ctx, assetType, resp.Raw) - if err != nil { - e.Websocket.DataHandler <- err - } - } - } -} - -func (e *Exchange) wsHandleData(ctx context.Context, assetType asset.Item, respRaw []byte) error { +func (e *Exchange) wsHandleData(conn websocket.Connection, assetType asset.Item, respRaw []byte) error { var result WebsocketResponse - err := json.Unmarshal(respRaw, &result) - if err != nil { + if err := json.Unmarshal(respRaw, &result); err != nil { return err } if result.Topic == "" { - switch result.Operation { - case "subscribe", "unsubscribe", "auth": - if result.RequestID != "" { - if !e.Websocket.Match.IncomingWithData(result.RequestID, respRaw) { - return fmt.Errorf("could not match subscription with id %s data %s", result.RequestID, respRaw) - } - } - case "ping", "pong": - default: - e.Websocket.DataHandler <- websocket.UnhandledMessageWarning{ - Message: string(respRaw), - } - return nil - } - return nil + return e.handleNoTopicWebsocketResponse(conn, &result, respRaw) } topicSplit := strings.Split(result.Topic, ".") - if len(topicSplit) == 0 { - return errInvalidPushData - } switch topicSplit[0] { case chanOrderbook: return e.wsProcessOrderbook(assetType, &result) @@ -318,36 +189,59 @@ func (e *Exchange) wsHandleData(ctx context.Context, assetType asset.Item, respR return e.wsProcessLeverageTokenTicker(assetType, &result) case chanLeverageTokenNav: return e.wsLeverageTokenNav(&result) + } + return fmt.Errorf("%w %s", errUnhandledStreamData, string(respRaw)) +} + +func (e *Exchange) wsHandleAuthenticatedData(ctx context.Context, conn websocket.Connection, respRaw []byte) error { + var result WebsocketResponse + if err := json.Unmarshal(respRaw, &result); err != nil { + return err + } + if result.Topic == "" { + return e.handleNoTopicWebsocketResponse(conn, &result, respRaw) + } + topicSplit := strings.Split(result.Topic, ".") + switch topicSplit[0] { case chanPositions: return e.wsProcessPosition(&result) case chanExecution: - return e.wsProcessExecution(asset.Spot, &result) + return e.wsProcessExecution(&result) case chanOrder: - return e.wsProcessOrder(asset.Spot, &result) + return e.wsProcessOrder(&result) case chanWallet: - return e.wsProcessWalletPushData(ctx, asset.Spot, respRaw) + return e.wsProcessWalletPushData(ctx, respRaw) case chanGreeks: return e.wsProcessGreeks(respRaw) - case chanDCP: - return nil } - return fmt.Errorf("unhandled stream data %s", string(respRaw)) + return fmt.Errorf("%w %s", errUnhandledStreamData, string(respRaw)) +} + +func (e *Exchange) handleNoTopicWebsocketResponse(conn websocket.Connection, result *WebsocketResponse, respRaw []byte) error { + switch result.Operation { + case "subscribe", "unsubscribe", "auth": + if result.RequestID != "" { + return conn.RequireMatchWithData(result.RequestID, respRaw) + } + case "ping", "pong": + default: + e.Websocket.DataHandler <- websocket.UnhandledMessageWarning{Message: string(respRaw)} + } + return nil } func (e *Exchange) wsProcessGreeks(resp []byte) error { var result GreeksResponse - err := json.Unmarshal(resp, &result) - if err != nil { + if err := json.Unmarshal(resp, &result); err != nil { return err } e.Websocket.DataHandler <- &result return nil } -func (e *Exchange) wsProcessWalletPushData(ctx context.Context, assetType asset.Item, resp []byte) error { +func (e *Exchange) wsProcessWalletPushData(ctx context.Context, resp []byte) error { var result WebsocketWallet - err := json.Unmarshal(resp, &result) - if err != nil { + if err := json.Unmarshal(resp, &result); err != nil { return err } creds, err := e.GetCredentials(ctx) @@ -358,9 +252,9 @@ func (e *Exchange) wsProcessWalletPushData(ctx context.Context, assetType asset. for x := range result.Data { for y := range result.Data[x].Coin { changes = append(changes, account.Change{ - AssetType: assetType, + AssetType: asset.Spot, Balance: &account.Balance{ - Currency: currency.NewCode(result.Data[x].Coin[y].Coin), + Currency: result.Data[x].Coin[y].Coin, Total: result.Data[x].Coin[y].WalletBalance.Float64(), Free: result.Data[x].Coin[y].WalletBalance.Float64(), UpdatedAt: result.CreationTime.Time(), @@ -373,15 +267,14 @@ func (e *Exchange) wsProcessWalletPushData(ctx context.Context, assetType asset. } // wsProcessOrder the order stream to see changes to your orders in real-time. -func (e *Exchange) wsProcessOrder(assetType asset.Item, resp *WebsocketResponse) error { +func (e *Exchange) wsProcessOrder(resp *WebsocketResponse) error { var result WsOrders - err := json.Unmarshal(resp.Data, &result) - if err != nil { + if err := json.Unmarshal(resp.Data, &result); err != nil { return err } execution := make([]order.Detail, len(result)) for x := range result { - cp, err := e.MatchSymbolWithAvailablePairs(result[x].Symbol, assetType, hasPotentialDelimiter(assetType)) + cp, a, err := e.matchPairAssetFromResponse(result[x].Category, result[x].Symbol) if err != nil { return err } @@ -393,36 +286,42 @@ func (e *Exchange) wsProcessOrder(assetType asset.Item, resp *WebsocketResponse) if err != nil { return err } + tif, err := order.StringToTimeInForce(result[x].TimeInForce) + if err != nil { + return err + } execution[x] = order.Detail{ - Amount: result[x].Qty.Float64(), - Exchange: e.Name, - OrderID: result[x].OrderID, - ClientOrderID: result[x].OrderLinkID, - Side: side, - Type: orderType, - Pair: cp, - Cost: result[x].CumExecQty.Float64() * result[x].AvgPrice.Float64(), - AssetType: assetType, - Status: StringToOrderStatus(result[x].OrderStatus), - Price: result[x].Price.Float64(), - ExecutedAmount: result[x].CumExecQty.Float64(), - Date: result[x].CreatedTime.Time(), - LastUpdated: result[x].UpdatedTime.Time(), + TimeInForce: tif, + Amount: result[x].Qty.Float64(), + Exchange: e.Name, + OrderID: result[x].OrderID, + ClientOrderID: result[x].OrderLinkID, + Side: side, + Type: orderType, + Pair: cp, + Cost: result[x].CumExecQty.Float64() * result[x].AvgPrice.Float64(), + Fee: result[x].CumExecFee.Float64(), + AssetType: a, + Status: StringToOrderStatus(result[x].OrderStatus), + Price: result[x].Price.Float64(), + ExecutedAmount: result[x].CumExecQty.Float64(), + AverageExecutedPrice: result[x].AvgPrice.Float64(), + Date: result[x].CreatedTime.Time(), + LastUpdated: result[x].UpdatedTime.Time(), } } e.Websocket.DataHandler <- execution return nil } -func (e *Exchange) wsProcessExecution(assetType asset.Item, resp *WebsocketResponse) error { +func (e *Exchange) wsProcessExecution(resp *WebsocketResponse) error { var result WsExecutions - err := json.Unmarshal(resp.Data, &result) - if err != nil { + if err := json.Unmarshal(resp.Data, &result); err != nil { return err } executions := make([]fill.Data, len(result)) for x := range result { - cp, err := e.MatchSymbolWithAvailablePairs(result[x].Symbol, assetType, hasPotentialDelimiter(assetType)) + cp, a, err := e.matchPairAssetFromResponse(result[x].Category, result[x].Symbol) if err != nil { return err } @@ -434,7 +333,7 @@ func (e *Exchange) wsProcessExecution(assetType asset.Item, resp *WebsocketRespo ID: result[x].ExecID, Timestamp: result[x].ExecTime.Time(), Exchange: e.Name, - AssetType: assetType, + AssetType: a, CurrencyPair: cp, Side: side, OrderID: result[x].OrderID, @@ -449,8 +348,7 @@ func (e *Exchange) wsProcessExecution(assetType asset.Item, resp *WebsocketRespo func (e *Exchange) wsProcessPosition(resp *WebsocketResponse) error { var result WsPositions - err := json.Unmarshal(resp.Data, &result) - if err != nil { + if err := json.Unmarshal(resp.Data, &result); err != nil { return err } e.Websocket.DataHandler <- result @@ -459,8 +357,7 @@ func (e *Exchange) wsProcessPosition(resp *WebsocketResponse) error { func (e *Exchange) wsLeverageTokenNav(resp *WebsocketResponse) error { var result LTNav - err := json.Unmarshal(resp.Data, &result) - if err != nil { + if err := json.Unmarshal(resp.Data, &result); err != nil { return err } e.Websocket.DataHandler <- result @@ -468,9 +365,8 @@ func (e *Exchange) wsLeverageTokenNav(resp *WebsocketResponse) error { } func (e *Exchange) wsProcessLeverageTokenTicker(assetType asset.Item, resp *WebsocketResponse) error { - var result TickerItem - err := json.Unmarshal(resp.Data, &result) - if err != nil { + var result TickerWebsocket + if err := json.Unmarshal(resp.Data, &result); err != nil { return err } cp, err := e.MatchSymbolWithAvailablePairs(result.Symbol, assetType, hasPotentialDelimiter(assetType)) @@ -491,8 +387,7 @@ func (e *Exchange) wsProcessLeverageTokenTicker(assetType asset.Item, resp *Webs func (e *Exchange) wsProcessLeverageTokenKline(assetType asset.Item, resp *WebsocketResponse, topicSplit []string) error { var result LTKlines - err := json.Unmarshal(resp.Data, &result) - if err != nil { + if err := json.Unmarshal(resp.Data, &result); err != nil { return err } cp, err := e.MatchSymbolWithAvailablePairs(topicSplit[2], assetType, hasPotentialDelimiter(assetType)) @@ -525,8 +420,7 @@ func (e *Exchange) wsProcessLeverageTokenKline(assetType asset.Item, resp *Webso func (e *Exchange) wsProcessLiquidation(resp *WebsocketResponse) error { var result WebsocketLiquidation - err := json.Unmarshal(resp.Data, &result) - if err != nil { + if err := json.Unmarshal(resp.Data, &result); err != nil { return err } e.Websocket.DataHandler <- result @@ -535,8 +429,7 @@ func (e *Exchange) wsProcessLiquidation(resp *WebsocketResponse) error { func (e *Exchange) wsProcessKline(assetType asset.Item, resp *WebsocketResponse, topicSplit []string) error { var result WsKlines - err := json.Unmarshal(resp.Data, &result) - if err != nil { + if err := json.Unmarshal(resp.Data, &result); err != nil { return err } cp, err := e.MatchSymbolWithAvailablePairs(topicSplit[2], assetType, hasPotentialDelimiter(assetType)) @@ -569,8 +462,8 @@ func (e *Exchange) wsProcessKline(assetType asset.Item, resp *WebsocketResponse, } func (e *Exchange) wsProcessPublicTicker(assetType asset.Item, resp *WebsocketResponse) error { - tickResp := new(TickerItem) - if err := json.Unmarshal(resp.Data, tickResp); err != nil { + var tickResp TickerWebsocket + if err := json.Unmarshal(resp.Data, &tickResp); err != nil { return err } @@ -578,38 +471,25 @@ func (e *Exchange) wsProcessPublicTicker(assetType asset.Item, resp *WebsocketRe if err != nil { return err } - pFmt, err := e.GetPairFormat(assetType, false) - if err != nil { - return err - } - p = p.Format(pFmt) - var tick *ticker.Price - if resp.Type == "snapshot" { - tick = &ticker.Price{ - Pair: p, - ExchangeName: e.Name, - AssetType: assetType, - } - } else { + tick := &ticker.Price{Pair: p, ExchangeName: e.Name, AssetType: assetType} + if resp.Type != "snapshot" { // ticker updates may be partial, so we need to update the current ticker - tick, err = ticker.GetTicker(e.Name, p, assetType) + tick, err = e.GetCachedTicker(p, assetType) if err != nil { return err } } - - updateTicker(tick, tickResp) + updateTicker(tick, &tickResp) tick.LastUpdated = resp.PushTimestamp.Time() - - if err = ticker.ProcessTicker(tick); err == nil { - e.Websocket.DataHandler <- tick + if err := ticker.ProcessTicker(tick); err != nil { + return err } - - return err + e.Websocket.DataHandler <- tick + return nil } -func updateTicker(tick *ticker.Price, resp *TickerItem) { +func updateTicker(tick *ticker.Price, resp *TickerWebsocket) { if resp.LastPrice.Float64() != 0 { tick.Last = resp.LastPrice.Float64() } @@ -669,8 +549,7 @@ func updateTicker(tick *ticker.Price, resp *TickerItem) { func (e *Exchange) wsProcessPublicTrade(assetType asset.Item, resp *WebsocketResponse) error { var result WebsocketPublicTrades - err := json.Unmarshal(resp.Data, &result) - if err != nil { + if err := json.Unmarshal(resp.Data, &result); err != nil { return err } tradeDatas := make([]trade.Data, len(result)) @@ -755,20 +634,12 @@ func channelName(s *subscription.Subscription) string { // isSymbolChannel returns whether the channel accepts a symbol parameter func isSymbolChannel(name string) bool { switch name { - case chanPositions, chanExecution, chanOrder, chanDCP, chanWallet: + case chanPositions, chanExecution, chanOrder, chanWallet: return false } return true } -func isCategorisedChannel(name string) bool { - switch name { - case chanPositions, chanExecution, chanOrder: - return true - } - return false -} - const subTplText = ` {{ with $name := channelName $.S }} {{- range $asset, $pairs := $.AssetPairs }} @@ -780,9 +651,6 @@ const subTplText = ` {{- $p }} {{- $.PairSeparator }} {{- end }} - {{- else }} - {{- $name }} - {{- if and (isCategorisedChannel $name) ($categoryName := getCategoryName $asset) -}} . {{- $categoryName -}} {{- end }} {{- end }} {{- end }} {{- $.AssetSeparator }} @@ -793,3 +661,172 @@ const subTplText = ` func hasPotentialDelimiter(a asset.Item) bool { return a == asset.Options || a == asset.USDCMarginedFutures } + +// TODO: Remove this function when template expansion is across all assets +func (e *Exchange) submitDirectSubscription(ctx context.Context, conn websocket.Connection, a asset.Item, operation string, channelsToSubscribe subscription.List) error { + payloads, err := e.directSubscriptionPayload(conn, a, operation, channelsToSubscribe) + if err != nil { + return err + } + + op := e.Websocket.AddSubscriptions + if operation == "unsubscribe" { + op = e.Websocket.RemoveSubscriptions + } + + for _, payload := range payloads { + if a == asset.Options { + // The options connection does not send the subscription request id back with the subscription notification payload + // therefore the code doesn't wait for the response to check whether the subscription is successful or not. + if err := conn.SendJSONMessage(ctx, request.Unset, payload); err != nil { + return err + } + } else { + response, err := conn.SendMessageReturnResponse(ctx, request.Unset, payload.RequestID, payload) + if err != nil { + return err + } + var resp SubscriptionResponse + if err := json.Unmarshal(response, &resp); err != nil { + return err + } + if !resp.Success { + return fmt.Errorf("%s with request ID %s msg: %s", resp.Operation, resp.RequestID, resp.RetMsg) + } + } + if err := op(conn, payload.associatedSubs...); err != nil { + return err + } + } + return nil +} + +// TODO: Remove this function when template expansion is across all assets +func (e *Exchange) directSubscriptionPayload(conn websocket.Connection, assetType asset.Item, operation string, channelsToSubscribe subscription.List) ([]SubscriptionArgument, error) { + var args []SubscriptionArgument + arg := SubscriptionArgument{ + Operation: operation, + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), + Arguments: []string{}, + } + authArg := SubscriptionArgument{ + auth: true, + Operation: operation, + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), + Arguments: []string{}, + } + + chanMap := map[string]bool{} + pairFmt, err := e.GetPairFormat(assetType, true) + if err != nil { + return nil, err + } + for _, s := range channelsToSubscribe { + var pair currency.Pair + if len(s.Pairs) > 1 { + return nil, subscription.ErrNotSinglePair + } + if len(s.Pairs) == 1 { + pair = s.Pairs[0] + } + switch s.Channel { + case chanOrderbook: + arg.Arguments = append(arg.Arguments, fmt.Sprintf("%s.%d.%s", s.Channel, 50, pairFmt.Format(pair))) + arg.associatedSubs = append(arg.associatedSubs, s) + case chanPublicTrade, chanPublicTicker, chanLiquidation, chanLeverageTokenTicker, chanLeverageTokenNav: + arg.Arguments = append(arg.Arguments, s.Channel+"."+pairFmt.Format(pair)) + arg.associatedSubs = append(arg.associatedSubs, s) + case chanKline, chanLeverageTokenKline: + interval, err := intervalToString(kline.FiveMin) + if err != nil { + return nil, err + } + arg.Arguments = append(arg.Arguments, s.Channel+"."+interval+"."+pairFmt.Format(pair)) + arg.associatedSubs = append(arg.associatedSubs, s) + case chanPositions, chanExecution, chanOrder, chanWallet, chanGreeks: + if chanMap[s.Channel] { + continue + } + authArg.Arguments = append(authArg.Arguments, s.Channel) + // add channel name to map so we only subscribe to channel once + chanMap[s.Channel] = true + authArg.associatedSubs = append(authArg.associatedSubs, s) + } + + if len(arg.Arguments) >= 10 { + args = append(args, arg) + arg = SubscriptionArgument{ + Operation: operation, + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), + Arguments: []string{}, + } + } + } + if len(arg.Arguments) != 0 { + args = append(args, arg) + } + if len(authArg.Arguments) != 0 { + args = append(args, authArg) + } + return args, nil +} + +// generateAuthSubscriptions generates default subscription for the dedicated auth websocket connection. These are +// agnostic to the asset type and pair as all account level data will be routed through this connection. +// TODO: Remove this function when template expansion is across all assets +func (e *Exchange) generateAuthSubscriptions() (subscription.List, error) { + if !e.Websocket.CanUseAuthenticatedEndpoints() { + return nil, nil + } + + for _, configSub := range e.Config.Features.Subscriptions.Enabled() { + if configSub.Authenticated { + log.Warnf(log.WebsocketMgr, "%s has an authenticated subscription %q in config which is not supported. Please remove.", e.Name, configSub.Channel) + configSub.Enabled = false + } + } + + var subscriptions subscription.List + // TODO: Implement DCP (Disconnection Protect) subscription + for _, channel := range []string{chanPositions, chanExecution, chanOrder, chanWallet} { + subscriptions = append(subscriptions, &subscription.Subscription{Channel: channel, Asset: asset.All}) + } + return subscriptions, nil +} + +func (e *Exchange) authSubscribe(ctx context.Context, conn websocket.Connection, channelSubscriptions subscription.List) error { + return e.submitDirectSubscription(ctx, conn, asset.Spot, "subscribe", channelSubscriptions) +} + +func (e *Exchange) authUnsubscribe(ctx context.Context, conn websocket.Connection, channelSubscriptions subscription.List) error { + return e.submitDirectSubscription(ctx, conn, asset.Spot, "unsubscribe", channelSubscriptions) +} + +// matchPairAssetFromResponse returns the currency pair and asset type based on the category and symbol. Used with a dedicated +// auth connection where multiple asset type changes are piped through a single connection. +func (e *Exchange) matchPairAssetFromResponse(category, symbol string) (currency.Pair, asset.Item, error) { + assets := make([]asset.Item, 0, 2) + switch category { + case "spot": + assets = append(assets, asset.Spot) + case "inverse": + assets = append(assets, asset.CoinMarginedFutures) + case "linear": + assets = append(assets, asset.USDTMarginedFutures, asset.USDCMarginedFutures) + case "option": + assets = append(assets, asset.Options) + default: + return currency.EMPTYPAIR, 0, fmt.Errorf("incoming symbol %q %w: %q", symbol, errUnsupportedCategory, category) + } + for _, a := range assets { + cp, err := e.MatchSymbolWithAvailablePairs(symbol, a, hasPotentialDelimiter(a)) + if err != nil { + if !errors.Is(err, currency.ErrPairNotFound) { + return currency.EMPTYPAIR, 0, fmt.Errorf("%w for symbol %q: %q", err, category, symbol) + } + continue + } + return cp, a, nil + } + return currency.EMPTYPAIR, 0, currency.ErrPairNotFound +} diff --git a/exchanges/bybit/bybit_wrapper.go b/exchanges/bybit/bybit_wrapper.go index 3a373646..2af0ea4f 100644 --- a/exchanges/bybit/bybit_wrapper.go +++ b/exchanges/bybit/bybit_wrapper.go @@ -27,6 +27,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/exchanges/orderbook" "github.com/thrasher-corp/gocryptotrader/exchanges/protocol" "github.com/thrasher-corp/gocryptotrader/exchanges/request" + "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" "github.com/thrasher-corp/gocryptotrader/exchanges/ticker" "github.com/thrasher-corp/gocryptotrader/exchanges/trade" "github.com/thrasher-corp/gocryptotrader/log" @@ -67,12 +68,6 @@ func (e *Exchange) SetDefaults() { } } - for _, a := range []asset.Item{asset.CoinMarginedFutures, asset.USDTMarginedFutures, asset.USDCMarginedFutures, asset.Options} { - if err := e.DisableAssetWebsocketSupport(a); err != nil { - log.Errorf(log.ExchangeSys, "%s error disabling %q asset type websocket support: %s", e.Name, a, err) - } - } - e.Features = exchange.Features{ CurrencyTranslations: currency.NewTranslations( map[currency.Code]currency.Code{ @@ -188,12 +183,17 @@ func (e *Exchange) SetDefaults() { e.API.Endpoints = e.NewEndpoints() err := e.API.Endpoints.SetDefaultEndpoints(map[exchange.URL]string{ - exchange.RestSpot: bybitAPIURL, - exchange.RestCoinMargined: bybitAPIURL, - exchange.RestUSDTMargined: bybitAPIURL, - exchange.RestFutures: bybitAPIURL, - exchange.RestUSDCMargined: bybitAPIURL, - exchange.WebsocketSpot: spotPublic, + exchange.RestSpot: bybitAPIURL, + exchange.RestCoinMargined: bybitAPIURL, + exchange.RestUSDTMargined: bybitAPIURL, + exchange.RestFutures: bybitAPIURL, + exchange.RestUSDCMargined: bybitAPIURL, + exchange.WebsocketSpot: spotPublic, + exchange.WebsocketCoinMargined: inversePublic, + exchange.WebsocketUSDTMargined: linearPublic, + exchange.WebsocketUSDCMargined: linearPublic, + exchange.WebsocketOptions: optionPublic, + exchange.WebsocketPrivate: websocketPrivate, }) if err != nil { log.Errorln(log.ExchangeSys, err) @@ -214,65 +214,176 @@ func (e *Exchange) SetDefaults() { // Setup takes in the supplied exchange configuration details and sets params func (e *Exchange) Setup(exch *config.Exchange) error { - err := exch.Validate() - if err != nil { + if err := exch.Validate(); err != nil { return err } if !exch.Enabled { e.SetEnabled(false) return nil } + if err := e.SetupDefaults(exch); err != nil { + return err + } - err = e.SetupDefaults(exch) + if err := e.Websocket.Setup(&websocket.ManagerSetup{ + ExchangeConfig: exch, + Features: &e.Features.Supports.WebsocketCapabilities, + OrderbookBufferConfig: buffer.Config{SortBuffer: true, SortBufferByUpdateIDs: true}, + TradeFeed: e.Features.Enabled.TradeFeed, + UseMultiConnectionManagement: true, + }); err != nil { + return err + } + + wsSpotURL, err := e.API.Endpoints.GetURL(exchange.WebsocketSpot) if err != nil { return err } - wsRunningEndpoint, err := e.API.Endpoints.GetURL(exchange.WebsocketSpot) + // Spot + if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ + URL: wsSpotURL, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Connector: e.WsConnect, + GenerateSubscriptions: e.generateSubscriptions, + Subscriber: e.SpotSubscribe, + Unsubscriber: e.SpotUnsubscribe, + Handler: func(_ context.Context, conn websocket.Connection, resp []byte) error { + return e.wsHandleData(conn, asset.Spot, resp) + }, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, + }); err != nil { + return err + } + + wsOptionsURL, err := e.API.Endpoints.GetURL(exchange.WebsocketOptions) if err != nil { return err } - err = e.Websocket.Setup( - &websocket.ManagerSetup{ - ExchangeConfig: exch, - DefaultURL: spotPublic, - RunningURL: wsRunningEndpoint, - RunningURLAuth: websocketPrivate, - Connector: e.WsConnect, - Subscriber: e.Subscribe, - Unsubscriber: e.Unsubscribe, - GenerateSubscriptions: e.generateSubscriptions, - Features: &e.Features.Supports.WebsocketCapabilities, - OrderbookBufferConfig: buffer.Config{ - SortBuffer: true, - SortBufferByUpdateIDs: true, - }, - TradeFeed: e.Features.Enabled.TradeFeed, - }) - if err != nil { + // Options + if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ + URL: wsOptionsURL, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Connector: e.WsConnect, + GenerateSubscriptions: e.GenerateOptionsDefaultSubscriptions, + Subscriber: e.OptionsSubscribe, + Unsubscriber: e.OptionsUnsubscribe, + Handler: func(_ context.Context, conn websocket.Connection, resp []byte) error { + return e.wsHandleData(conn, asset.Options, resp) + }, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, + }); err != nil { return err } - err = e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ - URL: e.Websocket.GetWebsocketURL(), - ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, - ResponseMaxLimit: bybitWebsocketTimer, - }) + + wsUSDTLinearURL, err := e.API.Endpoints.GetURL(exchange.WebsocketUSDTMargined) if err != nil { return err } - return e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ - URL: websocketPrivate, + // Linear - USDT margined futures. + if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ + URL: wsUSDTLinearURL, ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - Authenticated: true, - }) -} + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Connector: e.WsConnect, + GenerateSubscriptions: func() (subscription.List, error) { + return e.GenerateLinearDefaultSubscriptions(asset.USDTMarginedFutures) + }, + Subscriber: func(ctx context.Context, conn websocket.Connection, sub subscription.List) error { + return e.LinearSubscribe(ctx, conn, asset.USDTMarginedFutures, sub) + }, + Unsubscriber: func(ctx context.Context, conn websocket.Connection, unsub subscription.List) error { + return e.LinearUnsubscribe(ctx, conn, asset.USDTMarginedFutures, unsub) + }, + Handler: func(_ context.Context, conn websocket.Connection, resp []byte) error { + return e.wsHandleData(conn, asset.USDTMarginedFutures, resp) + }, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, + MessageFilter: asset.USDTMarginedFutures, // Unused but it allows us to differentiate between the two linear futures types. + }); err != nil { + return err + } -// AuthenticateWebsocket sends an authentication message to the websocket -func (e *Exchange) AuthenticateWebsocket(ctx context.Context) error { - return e.WsAuth(ctx) + wsUSDCLinearURL, err := e.API.Endpoints.GetURL(exchange.WebsocketUSDCMargined) + if err != nil { + return err + } + + // Linear - USDC margined futures. + if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ + URL: wsUSDCLinearURL, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Connector: e.WsConnect, + GenerateSubscriptions: func() (subscription.List, error) { + return e.GenerateLinearDefaultSubscriptions(asset.USDCMarginedFutures) + }, + Subscriber: func(ctx context.Context, conn websocket.Connection, sub subscription.List) error { + return e.LinearSubscribe(ctx, conn, asset.USDCMarginedFutures, sub) + }, + Unsubscriber: func(ctx context.Context, conn websocket.Connection, unsub subscription.List) error { + return e.LinearUnsubscribe(ctx, conn, asset.USDCMarginedFutures, unsub) + }, + Handler: func(_ context.Context, conn websocket.Connection, resp []byte) error { + return e.wsHandleData(conn, asset.USDCMarginedFutures, resp) + }, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, + MessageFilter: asset.USDCMarginedFutures, // Unused but it allows us to differentiate between the two linear futures types. + }); err != nil { + return err + } + + wsInverseURL, err := e.API.Endpoints.GetURL(exchange.WebsocketCoinMargined) + if err != nil { + return err + } + + // Inverse - Coin margined futures. + if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ + URL: wsInverseURL, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Connector: e.WsConnect, + GenerateSubscriptions: e.GenerateInverseDefaultSubscriptions, + Subscriber: e.InverseSubscribe, + Unsubscriber: e.InverseUnsubscribe, + Handler: func(_ context.Context, conn websocket.Connection, resp []byte) error { + return e.wsHandleData(conn, asset.CoinMarginedFutures, resp) + }, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, + }); err != nil { + return err + } + + wsPrivateURL, err := e.API.Endpoints.GetURL(exchange.WebsocketPrivate) + if err != nil { + return err + } + + // Private + return e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ + URL: wsPrivateURL, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Authenticated: true, + Connector: e.WsConnect, + GenerateSubscriptions: e.generateAuthSubscriptions, + Subscriber: e.authSubscribe, + Unsubscriber: e.authUnsubscribe, + Handler: e.wsHandleAuthenticatedData, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, + Authenticate: e.WebsocketAuthenticateConnection, + }) } // FetchTradablePairs returns a list of the exchanges tradable pairs diff --git a/exchanges/bybit/inverse_websocket.go b/exchanges/bybit/inverse_websocket.go new file mode 100644 index 00000000..ce41f0ff --- /dev/null +++ b/exchanges/bybit/inverse_websocket.go @@ -0,0 +1,44 @@ +package bybit + +import ( + "context" + "errors" + + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchange/websocket" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" +) + +// GenerateInverseDefaultSubscriptions generates default subscription +func (e *Exchange) GenerateInverseDefaultSubscriptions() (subscription.List, error) { + pairs, err := e.GetEnabledPairs(asset.CoinMarginedFutures) + if err != nil { + if errors.Is(err, asset.ErrNotEnabled) { + return nil, nil + } + return nil, err + } + + var subscriptions subscription.List + for z := range pairs { + for _, channel := range []string{chanOrderbook, chanPublicTrade, chanPublicTicker} { + subscriptions = append(subscriptions, &subscription.Subscription{ + Channel: channel, + Pairs: currency.Pairs{pairs[z]}, + Asset: asset.CoinMarginedFutures, + }) + } + } + return subscriptions, nil +} + +// InverseSubscribe sends a websocket message to receive data from the channel +func (e *Exchange) InverseSubscribe(ctx context.Context, conn websocket.Connection, channelSubscriptions subscription.List) error { + return e.submitDirectSubscription(ctx, conn, asset.CoinMarginedFutures, "subscribe", channelSubscriptions) +} + +// InverseUnsubscribe sends a websocket message to stop receiving data from the channel +func (e *Exchange) InverseUnsubscribe(ctx context.Context, conn websocket.Connection, channelSubscriptions subscription.List) error { + return e.submitDirectSubscription(ctx, conn, asset.CoinMarginedFutures, "unsubscribe", channelSubscriptions) +} diff --git a/exchanges/bybit/inverse_websocket_test.go b/exchanges/bybit/inverse_websocket_test.go new file mode 100644 index 00000000..06d6562a --- /dev/null +++ b/exchanges/bybit/inverse_websocket_test.go @@ -0,0 +1,58 @@ +package bybit + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" +) + +func TestGenerateInverseDefaultSubscriptions(t *testing.T) { + t.Parallel() + e := new(Exchange) //nolint:govet // Intentional shadow + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + subs, err := e.GenerateInverseDefaultSubscriptions() + require.NoError(t, err, "GenerateInverseDefaultSubscriptions must not error") + assert.NotEmpty(t, subs, "Subscriptions should not be empty") + for i := range subs { + assert.Equal(t, asset.CoinMarginedFutures, subs[i].Asset, "Asset type should be CoinMarginedFutures") + } + + err = e.CurrencyPairs.SetAssetEnabled(asset.CoinMarginedFutures, false) + require.NoError(t, err, "SetAssetEnabled must not error") + + subs, err = e.GenerateInverseDefaultSubscriptions() + require.NoError(t, err, "GenerateInverseDefaultSubscriptions must not error") + assert.Empty(t, subs, "Subscriptions should be empty when asset is disabled") +} + +func TestInverseSubscribe(t *testing.T) { + t.Parallel() + + e := new(Exchange) //nolint:govet // Intentional shadow + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + + subs, err := e.GenerateInverseDefaultSubscriptions() + require.NoError(t, err, "GenerateInverseDefaultSubscriptions must not error") + + err = e.InverseSubscribe(t.Context(), &FixtureConnection{}, subs) + require.NoError(t, err, "InverseSubscribe must not error") +} + +func TestInverseUnsubscribe(t *testing.T) { + t.Parallel() + + e := new(Exchange) //nolint:govet // Intentional shadow + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + + subs, err := e.GenerateInverseDefaultSubscriptions() + require.NoError(t, err, "GenerateInverseDefaultSubscriptions must not error") + + err = e.InverseSubscribe(t.Context(), &FixtureConnection{}, subs) + require.NoError(t, err, "InverseSubscribe must not error") + + err = e.InverseUnsubscribe(t.Context(), &FixtureConnection{}, subs) + require.NoError(t, err, "InverseUnsubscribe must not error") +} diff --git a/exchanges/bybit/linear_websocket.go b/exchanges/bybit/linear_websocket.go new file mode 100644 index 00000000..8583808c --- /dev/null +++ b/exchanges/bybit/linear_websocket.go @@ -0,0 +1,61 @@ +package bybit + +import ( + "context" + "errors" + "fmt" + + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchange/websocket" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" +) + +// GenerateLinearDefaultSubscriptions generates default subscription +func (e *Exchange) GenerateLinearDefaultSubscriptions(a asset.Item) (subscription.List, error) { + if err := checkLinearAsset(a); err != nil { + return nil, err + } + pairs, err := e.GetEnabledPairs(a) + if err != nil { + if errors.Is(err, asset.ErrNotEnabled) { + return nil, nil + } + return nil, err + } + + var subscriptions subscription.List + for _, pair := range pairs { + for _, channel := range []string{chanOrderbook, chanPublicTrade, chanPublicTicker} { + subscriptions = append(subscriptions, &subscription.Subscription{ + Channel: channel, + Pairs: currency.Pairs{pair}, + Asset: a, + }) + } + } + return subscriptions, nil +} + +// LinearSubscribe sends a websocket message to receive data from the channel +func (e *Exchange) LinearSubscribe(ctx context.Context, conn websocket.Connection, a asset.Item, channelSubscriptions subscription.List) error { + if err := checkLinearAsset(a); err != nil { + return err + } + return e.submitDirectSubscription(ctx, conn, a, "subscribe", channelSubscriptions) +} + +// LinearUnsubscribe sends a websocket message to stop receiving data from the channel +func (e *Exchange) LinearUnsubscribe(ctx context.Context, conn websocket.Connection, a asset.Item, channelSubscriptions subscription.List) error { + if err := checkLinearAsset(a); err != nil { + return err + } + return e.submitDirectSubscription(ctx, conn, a, "unsubscribe", channelSubscriptions) +} + +func checkLinearAsset(a asset.Item) error { + if a != asset.USDTMarginedFutures && a != asset.USDCMarginedFutures { + return fmt.Errorf("%q %w for linear subscriptions", a, asset.ErrInvalidAsset) + } + return nil +} diff --git a/exchanges/bybit/linear_websocket_test.go b/exchanges/bybit/linear_websocket_test.go new file mode 100644 index 00000000..45371171 --- /dev/null +++ b/exchanges/bybit/linear_websocket_test.go @@ -0,0 +1,78 @@ +package bybit + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" +) + +func TestGenerateLinearDefaultSubscriptions(t *testing.T) { + t.Parallel() + + _, err := e.GenerateLinearDefaultSubscriptions(asset.OptionCombo) + assert.ErrorIs(t, err, asset.ErrInvalidAsset) + + e := new(Exchange) + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + + subs, err := e.GenerateLinearDefaultSubscriptions(asset.USDTMarginedFutures) + require.NoError(t, err, "GenerateLinearDefaultSubscriptions must not error") + assert.NotEmpty(t, subs, "Subscriptions should not be empty") + for i := range subs { + assert.Equal(t, asset.USDTMarginedFutures, subs[i].Asset, "Asset type should be USDTMarginedFutures") + } + + err = e.CurrencyPairs.SetAssetEnabled(asset.USDTMarginedFutures, false) + require.NoError(t, err, "SetAssetEnabled must not error") + + subs, err = e.GenerateLinearDefaultSubscriptions(asset.USDTMarginedFutures) + require.NoError(t, err, "GenerateLinearDefaultSubscriptions must not error") + assert.Empty(t, subs, "Subscriptions should be empty when asset is disabled") + + subs, err = e.GenerateLinearDefaultSubscriptions(asset.USDCMarginedFutures) + require.NoError(t, err, "GenerateLinearDefaultSubscriptions must not error") + assert.NotEmpty(t, subs, "Subscriptions should not be empty") + for i := range subs { + assert.Equal(t, asset.USDCMarginedFutures, subs[i].Asset, "Asset type should be USDCMarginedFutures") + } +} + +func TestLinearSubscribe(t *testing.T) { + t.Parallel() + + e := new(Exchange) + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + + subs, err := e.GenerateLinearDefaultSubscriptions(asset.USDTMarginedFutures) + require.NoError(t, err, "GenerateLinearDefaultSubscriptions must not error") + assert.NotEmpty(t, subs, "Subscriptions should not be empty") + + err = e.LinearSubscribe(t.Context(), &FixtureConnection{}, asset.OptionCombo, subs) + require.ErrorIs(t, err, asset.ErrInvalidAsset) + + err = e.LinearSubscribe(t.Context(), &FixtureConnection{}, asset.USDTMarginedFutures, subs) + require.NoError(t, err, "LinearSubscribe must not error") +} + +func TestLinearUnsubscribe(t *testing.T) { + t.Parallel() + + e := new(Exchange) + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + + subs, err := e.GenerateLinearDefaultSubscriptions(asset.USDTMarginedFutures) + require.NoError(t, err, "GenerateLinearDefaultSubscriptions must not error") + assert.NotEmpty(t, subs, "Subscriptions should not be empty") + + err = e.LinearSubscribe(t.Context(), &FixtureConnection{}, asset.USDTMarginedFutures, subs) + require.NoError(t, err, "LinearSubscribe must not error") + + err = e.LinearUnsubscribe(t.Context(), &FixtureConnection{}, asset.OptionCombo, subs) + require.ErrorIs(t, err, asset.ErrInvalidAsset) + + err = e.LinearUnsubscribe(t.Context(), &FixtureConnection{}, asset.USDTMarginedFutures, subs) + require.NoError(t, err, "LinearUnsubscribe must not error") +} diff --git a/exchanges/bybit/options_websocket.go b/exchanges/bybit/options_websocket.go new file mode 100644 index 00000000..876713c5 --- /dev/null +++ b/exchanges/bybit/options_websocket.go @@ -0,0 +1,44 @@ +package bybit + +import ( + "context" + "errors" + + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchange/websocket" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" +) + +// GenerateOptionsDefaultSubscriptions generates default subscription +func (e *Exchange) GenerateOptionsDefaultSubscriptions() (subscription.List, error) { + pairs, err := e.GetEnabledPairs(asset.Options) + if err != nil { + if errors.Is(err, asset.ErrNotEnabled) { + return nil, nil + } + return nil, err + } + + var subscriptions subscription.List + for z := range pairs { + for _, channel := range []string{chanOrderbook, chanPublicTrade, chanPublicTicker} { + subscriptions = append(subscriptions, &subscription.Subscription{ + Channel: channel, + Pairs: currency.Pairs{pairs[z]}, + Asset: asset.Options, + }) + } + } + return subscriptions, nil +} + +// OptionsSubscribe sends a websocket message to receive data from the channel +func (e *Exchange) OptionsSubscribe(ctx context.Context, conn websocket.Connection, channelSubscriptions subscription.List) error { + return e.submitDirectSubscription(ctx, conn, asset.Options, "subscribe", channelSubscriptions) +} + +// OptionsUnsubscribe sends a websocket message to stop receiving data from the channel +func (e *Exchange) OptionsUnsubscribe(ctx context.Context, conn websocket.Connection, channelSubscriptions subscription.List) error { + return e.submitDirectSubscription(ctx, conn, asset.Options, "unsubscribe", channelSubscriptions) +} diff --git a/exchanges/bybit/options_websocket_test.go b/exchanges/bybit/options_websocket_test.go new file mode 100644 index 00000000..b79f10c6 --- /dev/null +++ b/exchanges/bybit/options_websocket_test.go @@ -0,0 +1,58 @@ +package bybit + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" +) + +func TestGenerateOptionsDefaultSubscriptions(t *testing.T) { + t.Parallel() + e := new(Exchange) + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + subs, err := e.GenerateOptionsDefaultSubscriptions() + require.NoError(t, err, "GenerateOptionsDefaultSubscriptions must not error") + assert.NotEmpty(t, subs, "Subscriptions should not be empty") + for i := range subs { + assert.Equal(t, asset.Options, subs[i].Asset, "Asset type should be Options") + } + + err = e.CurrencyPairs.SetAssetEnabled(asset.Options, false) + require.NoError(t, err, "SetAssetEnabled must not error") + + subs, err = e.GenerateOptionsDefaultSubscriptions() + require.NoError(t, err, "GenerateOptionsDefaultSubscriptions must not error") + assert.Empty(t, subs, "Subscriptions should be empty when asset is disabled") +} + +func TestOptionSubscribe(t *testing.T) { + t.Parallel() + + e := new(Exchange) + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + + subs, err := e.GenerateOptionsDefaultSubscriptions() + require.NoError(t, err, "GenerateOptionsDefaultSubscriptions must not error") + + err = e.OptionsSubscribe(t.Context(), &FixtureConnection{}, subs) + require.NoError(t, err, "OptionsSubscribe must not error") +} + +func TestOptionsUnsubscribe(t *testing.T) { + t.Parallel() + + e := new(Exchange) + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + + subs, err := e.GenerateOptionsDefaultSubscriptions() + require.NoError(t, err, "GenerateOptionsDefaultSubscriptions must not error") + + err = e.OptionsSubscribe(t.Context(), &FixtureConnection{}, subs) + require.NoError(t, err, "OptionsSubscribe must not error") + + err = e.OptionsUnsubscribe(t.Context(), &FixtureConnection{}, subs) + require.NoError(t, err, "OptionsUnsubscribe must not error") +} diff --git a/exchanges/bybit/spot_websocket.go b/exchanges/bybit/spot_websocket.go new file mode 100644 index 00000000..c28715d0 --- /dev/null +++ b/exchanges/bybit/spot_websocket.go @@ -0,0 +1,50 @@ +package bybit + +import ( + "context" + "fmt" + + "github.com/thrasher-corp/gocryptotrader/encoding/json" + "github.com/thrasher-corp/gocryptotrader/exchange/websocket" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" + "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" +) + +func (e *Exchange) handleSpotSubscription(ctx context.Context, conn websocket.Connection, operation string, channelsToSubscribe subscription.List) error { + payloads, err := e.handleSubscriptions(conn, operation, channelsToSubscribe) + if err != nil { + return err + } + for _, payload := range payloads { + response, err := conn.SendMessageReturnResponse(ctx, request.Unset, payload.RequestID, payload) + if err != nil { + return err + } + var resp SubscriptionResponse + if err := json.Unmarshal(response, &resp); err != nil { + return err + } + if !resp.Success { + return fmt.Errorf("%s with request ID %s msg: %s", resp.Operation, resp.RequestID, resp.RetMsg) + } + if operation == "unsubscribe" { + err = e.Websocket.RemoveSubscriptions(conn, payload.associatedSubs...) + } else { + err = e.Websocket.AddSubscriptions(conn, payload.associatedSubs...) + } + if err != nil { + return err + } + } + return nil +} + +// SpotSubscribe sends a websocket message to receive data from the channel +func (e *Exchange) SpotSubscribe(ctx context.Context, conn websocket.Connection, channelsToSubscribe subscription.List) error { + return e.handleSpotSubscription(ctx, conn, "subscribe", channelsToSubscribe) +} + +// SpotUnsubscribe sends a websocket message to stop receiving data from the channel +func (e *Exchange) SpotUnsubscribe(ctx context.Context, conn websocket.Connection, channelsToUnsubscribe subscription.List) error { + return e.handleSpotSubscription(ctx, conn, "unsubscribe", channelsToUnsubscribe) +} diff --git a/exchanges/bybit/spot_websocket_test.go b/exchanges/bybit/spot_websocket_test.go new file mode 100644 index 00000000..ce86982e --- /dev/null +++ b/exchanges/bybit/spot_websocket_test.go @@ -0,0 +1,30 @@ +package bybit + +import ( + "testing" + + "github.com/stretchr/testify/require" + testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" +) + +func TestSpotSubscribe(t *testing.T) { + t.Parallel() + e := new(Exchange) + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + subs, err := e.Features.Subscriptions.ExpandTemplates(e) + require.NoError(t, err, "ExpandTemplates must not error") + err = e.SpotSubscribe(t.Context(), &FixtureConnection{}, subs) + require.NoError(t, err, "Subscribe must not error") +} + +func TestSpotUnsubscribe(t *testing.T) { + t.Parallel() + e := new(Exchange) + require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") + subs, err := e.Features.Subscriptions.ExpandTemplates(e) + require.NoError(t, err, "ExpandTemplates must not error") + err = e.SpotSubscribe(t.Context(), &FixtureConnection{}, subs) + require.NoError(t, err, "Subscribe must not error") + err = e.SpotUnsubscribe(t.Context(), &FixtureConnection{}, subs) + require.NoError(t, err, "Unsubscribe must not error") +} diff --git a/exchanges/bybit/testdata/wsAuth.json b/exchanges/bybit/testdata/wsAuth.json new file mode 100644 index 00000000..ee94dbcf --- /dev/null +++ b/exchanges/bybit/testdata/wsAuth.json @@ -0,0 +1,6 @@ +{"id": "59232430b58efe-5fc5-4470-9337-4ce293b68edd", "topic": "position", "creationTime": 1672364174455, "data": [ { "positionIdx": 0, "tradeMode": 0, "riskId": 41, "riskLimitValue": "200000", "symbol": "XRPUSDT", "side": "Buy", "size": "75", "entryPrice": "0.3615", "leverage": "10", "positionValue": "27.1125", "positionBalance": "0", "markPrice": "0.3374", "positionIM": "2.72589075", "positionMM": "0.28576575", "takeProfit": "0", "stopLoss": "0", "trailingStop": "0", "unrealisedPnl": "-1.8075", "cumRealisedPnl": "0.64782276", "createdTime": "1672121182216", "updatedTime": "1672364174449", "tpslMode": "Full", "liqPrice": "", "bustPrice": "", "category": "linear","positionStatus":"Normal","adlRankIndicator":2}]} +{ "id": "5923240c6880ab-c59f-420b-9adb-3639adc9dd90", "topic": "order", "creationTime": 1672364262474, "data": [ { "symbol": "%s", "orderId": "5cf98598-39a7-459e-97bf-76ca765ee020", "side": "Sell", "orderType": "Market", "cancelType": "UNKNOWN", "price": "72.5", "qty": "1", "orderIv": "", "timeInForce": "IOC", "orderStatus": "Filled", "orderLinkId": "", "lastPriceOnCreated": "", "reduceOnly": false, "leavesQty": "", "leavesValue": "", "cumExecQty": "1", "cumExecValue": "75", "avgPrice": "75", "blockTradeId": "", "positionIdx": 0, "cumExecFee": "0.358635", "createdTime": "1672364262444", "updatedTime": "1672364262457", "rejectReason": "EC_NoError", "stopOrderType": "", "tpslMode": "", "triggerPrice": "", "takeProfit": "", "stopLoss": "", "tpTriggerBy": "", "slTriggerBy": "", "tpLimitPrice": "", "slLimitPrice": "", "triggerDirection": 0, "triggerBy": "", "closeOnTrigger": false, "category": "option", "placeType": "price", "smpType": "None", "smpGroup": 0, "smpOrderId": "" } ] } +{ "id": "5923242c464be9-25ca-483d-a743-c60101fc656f", "topic": "wallet", "creationTime": 1672364262482, "data": [ { "accountIMRate": "0.016", "accountMMRate": "0.003", "totalEquity": "12837.78330098", "totalWalletBalance": "12840.4045924", "totalMarginBalance": "12837.78330188", "totalAvailableBalance": "12632.05767702", "totalPerpUPL": "-2.62129051", "totalInitialMargin": "205.72562486", "totalMaintenanceMargin": "39.42876721", "coin": [ { "coin": "USDC", "equity": "200.62572554", "usdValue": "200.62572554", "walletBalance": "201.34882644", "availableToWithdraw": "0", "availableToBorrow": "1500000", "borrowAmount": "0", "accruedInterest": "0", "totalOrderIM": "0", "totalPositionIM": "202.99874213", "totalPositionMM": "39.14289747", "unrealisedPnl": "74.2768991", "cumRealisedPnl": "-209.1544627", "bonus": "0" }, { "coin": "BTC", "equity": "0.06488393", "usdValue": "1023.08402268", "walletBalance": "0.06488393", "availableToWithdraw": "0.06488393", "availableToBorrow": "2.5", "borrowAmount": "0", "accruedInterest": "0", "totalOrderIM": "0", "totalPositionIM": "0", "totalPositionMM": "0", "unrealisedPnl": "0", "cumRealisedPnl": "0", "bonus": "0" }, { "coin": "ETH", "equity": "0", "usdValue": "0", "walletBalance": "0", "availableToWithdraw": "0", "availableToBorrow": "26", "borrowAmount": "0", "accruedInterest": "0", "totalOrderIM": "0", "totalPositionIM": "0", "totalPositionMM": "0", "unrealisedPnl": "0", "cumRealisedPnl": "0", "bonus": "0" }, { "coin": "USDT", "equity": "11726.64664904", "usdValue": "11613.58597018", "walletBalance": "11728.54414904", "availableToWithdraw": "11723.92075829", "availableToBorrow": "2500000", "borrowAmount": "0", "accruedInterest": "0", "totalOrderIM": "0", "totalPositionIM": "2.72589075", "totalPositionMM": "0.28576575", "unrealisedPnl": "-1.8975", "cumRealisedPnl": "0.64782276", "bonus": "0" }, { "coin": "EOS3L", "equity": "215.0570412", "usdValue": "0", "walletBalance": "215.0570412", "availableToWithdraw": "215.0570412", "availableToBorrow": "0", "borrowAmount": "0", "accruedInterest": "", "totalOrderIM": "0", "totalPositionIM": "0", "totalPositionMM": "0", "unrealisedPnl": "0", "cumRealisedPnl": "0", "bonus": "0" }, { "coin": "BIT", "equity": "1.82", "usdValue": "0.48758257", "walletBalance": "1.82", "availableToWithdraw": "1.82", "availableToBorrow": "0", "borrowAmount": "0", "accruedInterest": "", "totalOrderIM": "0", "totalPositionIM": "0", "totalPositionMM": "0", "unrealisedPnl": "0", "cumRealisedPnl": "0", "bonus": "0" } ], "accountType": "UNIFIED", "accountLTV": "0.017" } ] } +{ "id": "592324fa945a30-2603-49a5-b865-21668c29f2a6", "topic": "greeks", "creationTime": 1672364262482, "data": [ { "baseCoin": "ETH", "totalDelta": "0.06999986", "totalGamma": "-0.00000001", "totalVega": "-0.00000024", "totalTheta": "0.00001314" } ] } +{"id": "592324803b2785-26fa-4214-9963-bdd4727f07be", "topic": "execution", "creationTime": 1672364174455, "data": [ { "category": "linear", "symbol": "XRPUSDT", "execFee": "0.005061", "execId": "7e2ae69c-4edf-5800-a352-893d52b446aa", "execPrice": "0.3374", "execQty": "25", "execType": "Trade", "execValue": "8.435", "isMaker": false, "feeRate": "0.0006", "tradeIv": "", "markIv": "", "blockTradeId": "", "markPrice": "0.3391", "indexPrice": "", "underlyingPrice": "", "leavesQty": "0", "orderId": "f6e324ff-99c2-4e89-9739-3086e47f9381", "orderLinkId": "", "orderPrice": "0.3207", "orderQty":"25","orderType":"Market","stopOrderType":"UNKNOWN","side":"Sell","execTime":"1672364174443","isLeverage": "0","closedSize": "","seq":4688002127}]} +{ "id": "someID", "topic": "order", "creationTime": 1672364262474, "data": [{"category":"linear","symbol":"BTCUSDT","orderId":"c1956690-b731-4191-97c0-94b00422231b","orderLinkId":"","blockTradeId":"","side":"Sell","positionIdx":0,"orderStatus":"Filled","cancelType":"UNKNOWN","rejectReason":"EC_NoError","timeInForce":"IOC","isLeverage":"","price":"4.033","qty":"1.7","avgPrice":"4.24","leavesQty":"0","leavesValue":"0","cumExecQty":"1.7","cumExecValue":"7.2086","cumExecFee":"0.00288344","orderType":"Market","stopOrderType":"","orderIv":"","triggerPrice":"","takeProfit":"","stopLoss":"","triggerBy":"","tpTriggerBy":"","slTriggerBy":"","triggerDirection":0,"placeType":"","lastPriceOnCreated":"4.245","closeOnTrigger":false,"reduceOnly":false,"smpGroup":0,"smpType":"None","smpOrderId":"","slLimitPrice":"0","tpLimitPrice":"0","tpslMode":"UNKNOWN","createType":"CreateByUser","marketUnit":"","createdTime":"1733778525913","updatedTime":"1733778525917","feeCurrency":"","closedPnl":"0"}]} \ No newline at end of file diff --git a/exchanges/exchange.go b/exchanges/exchange.go index ed0cf583..83a5d3e7 100644 --- a/exchanges/exchange.go +++ b/exchanges/exchange.go @@ -1242,8 +1242,7 @@ func (b *Base) NewEndpoints() *Endpoints { // SetDefaultEndpoints declares and sets the default URLs map func (e *Endpoints) SetDefaultEndpoints(m map[URL]string) error { for k, v := range m { - err := e.SetRunningURL(k.String(), v) - if err != nil { + if err := e.SetRunningURL(k.String(), v); err != nil { return err } } @@ -1380,6 +1379,16 @@ func (u URL) String() string { return restSwapURL case WebsocketSpot: return websocketSpotURL + case WebsocketCoinMargined: + return websocketCoinMarginedURL + case WebsocketUSDTMargined: + return websocketUSDTMarginedURL + case WebsocketUSDCMargined: + return websocketUSDCMarginedURL + case WebsocketOptions: + return websocketOptionsURL + case WebsocketPrivate: + return websocketPrivateURL case WebsocketSpotSupplementary: return websocketSpotSupplementaryURL case ChainAnalysis: @@ -1418,6 +1427,16 @@ func getURLTypeFromString(ep string) (URL, error) { return RestSwap, nil case websocketSpotURL: return WebsocketSpot, nil + case websocketCoinMarginedURL: + return WebsocketCoinMargined, nil + case websocketUSDTMarginedURL: + return WebsocketUSDTMargined, nil + case websocketUSDCMarginedURL: + return WebsocketUSDCMargined, nil + case websocketOptionsURL: + return WebsocketOptions, nil + case websocketPrivateURL: + return WebsocketPrivate, nil case websocketSpotSupplementaryURL: return WebsocketSpotSupplementary, nil case chainAnalysisURL: diff --git a/exchanges/exchange_test.go b/exchanges/exchange_test.go index 6b81a9f6..606dd9eb 100644 --- a/exchanges/exchange_test.go +++ b/exchanges/exchange_test.go @@ -1692,50 +1692,39 @@ func TestAddTradesToBuffer(t *testing.T) { } func TestString(t *testing.T) { - if RestSpot.String() != restSpotURL { - t.Errorf("received '%v' expected '%v'", RestSpot, restSpotURL) - } - if RestSpotSupplementary.String() != restSpotSupplementaryURL { - t.Errorf("received '%v' expected '%v'", RestSpotSupplementary, restSpotSupplementaryURL) - } - if RestUSDTMargined.String() != "RestUSDTMarginedFuturesURL" { - t.Errorf("received '%v' expected '%v'", RestUSDTMargined, "RestUSDTMarginedFuturesURL") - } - if RestCoinMargined.String() != restCoinMarginedFuturesURL { - t.Errorf("received '%v' expected '%v'", RestCoinMargined, restCoinMarginedFuturesURL) - } - if RestFutures.String() != restFuturesURL { - t.Errorf("received '%v' expected '%v'", RestFutures, restFuturesURL) - } - if RestFuturesSupplementary.String() != restFuturesSupplementaryURL { - t.Errorf("received '%v' expected '%v'", RestFutures, restFuturesSupplementaryURL) - } - if RestUSDCMargined.String() != restUSDCMarginedFuturesURL { - t.Errorf("received '%v' expected '%v'", RestUSDCMargined, restUSDCMarginedFuturesURL) - } - if RestSandbox.String() != restSandboxURL { - t.Errorf("received '%v' expected '%v'", RestSandbox, restSandboxURL) - } - if RestSwap.String() != restSwapURL { - t.Errorf("received '%v' expected '%v'", RestSwap, restSwapURL) - } - if WebsocketSpot.String() != websocketSpotURL { - t.Errorf("received '%v' expected '%v'", WebsocketSpot, websocketSpotURL) - } - if WebsocketSpotSupplementary.String() != websocketSpotSupplementaryURL { - t.Errorf("received '%v' expected '%v'", WebsocketSpotSupplementary, websocketSpotSupplementaryURL) - } - if ChainAnalysis.String() != chainAnalysisURL { - t.Errorf("received '%v' expected '%v'", ChainAnalysis, chainAnalysisURL) - } - if EdgeCase1.String() != edgeCase1URL { - t.Errorf("received '%v' expected '%v'", EdgeCase1, edgeCase1URL) - } - if EdgeCase2.String() != edgeCase2URL { - t.Errorf("received '%v' expected '%v'", EdgeCase2, edgeCase2URL) - } - if EdgeCase3.String() != edgeCase3URL { - t.Errorf("received '%v' expected '%v'", EdgeCase3, edgeCase3URL) + t.Parallel() + + for _, tc := range []struct { + url URL + expected string + }{ + {0, ""}, + {RestSpot, restSpotURL}, + {RestSpotSupplementary, restSpotSupplementaryURL}, + {RestUSDTMargined, restUSDTMarginedFuturesURL}, + {RestCoinMargined, restCoinMarginedFuturesURL}, + {RestFutures, restFuturesURL}, + {RestFuturesSupplementary, restFuturesSupplementaryURL}, + {RestUSDCMargined, restUSDCMarginedFuturesURL}, + {RestSandbox, restSandboxURL}, + {RestSwap, restSwapURL}, + {WebsocketSpot, websocketSpotURL}, + {WebsocketCoinMargined, websocketCoinMarginedURL}, + {WebsocketUSDTMargined, websocketUSDTMarginedURL}, + {WebsocketUSDCMargined, websocketUSDCMarginedURL}, + {WebsocketOptions, websocketOptionsURL}, + {WebsocketPrivate, websocketPrivateURL}, + {WebsocketSpotSupplementary, websocketSpotSupplementaryURL}, + {ChainAnalysis, chainAnalysisURL}, + {EdgeCase1, edgeCase1URL}, + {EdgeCase2, edgeCase2URL}, + {EdgeCase3, edgeCase3URL}, + {420, ""}, + } { + t.Run(tc.url.String(), func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, tc.url.String(), "String() should return the expected URL") + }) } } @@ -1886,20 +1875,26 @@ func TestGetGetURLTypeFromString(t *testing.T) { Expected URL Error error }{ - {Endpoint: "RestSpotURL", Expected: RestSpot}, - {Endpoint: "RestSpotSupplementaryURL", Expected: RestSpotSupplementary}, - {Endpoint: "RestUSDTMarginedFuturesURL", Expected: RestUSDTMargined}, - {Endpoint: "RestCoinMarginedFuturesURL", Expected: RestCoinMargined}, - {Endpoint: "RestFuturesURL", Expected: RestFutures}, - {Endpoint: "RestUSDCMarginedFuturesURL", Expected: RestUSDCMargined}, - {Endpoint: "RestSandboxURL", Expected: RestSandbox}, - {Endpoint: "RestSwapURL", Expected: RestSwap}, - {Endpoint: "WebsocketSpotURL", Expected: WebsocketSpot}, - {Endpoint: "WebsocketSpotSupplementaryURL", Expected: WebsocketSpotSupplementary}, - {Endpoint: "ChainAnalysisURL", Expected: ChainAnalysis}, - {Endpoint: "EdgeCase1URL", Expected: EdgeCase1}, - {Endpoint: "EdgeCase2URL", Expected: EdgeCase2}, - {Endpoint: "EdgeCase3URL", Expected: EdgeCase3}, + {Endpoint: restSpotURL, Expected: RestSpot}, + {Endpoint: restSpotSupplementaryURL, Expected: RestSpotSupplementary}, + {Endpoint: restUSDTMarginedFuturesURL, Expected: RestUSDTMargined}, + {Endpoint: restCoinMarginedFuturesURL, Expected: RestCoinMargined}, + {Endpoint: restFuturesURL, Expected: RestFutures}, + {Endpoint: restFuturesSupplementaryURL, Expected: RestFuturesSupplementary}, + {Endpoint: restUSDCMarginedFuturesURL, Expected: RestUSDCMargined}, + {Endpoint: restSandboxURL, Expected: RestSandbox}, + {Endpoint: restSwapURL, Expected: RestSwap}, + {Endpoint: websocketSpotURL, Expected: WebsocketSpot}, + {Endpoint: websocketCoinMarginedURL, Expected: WebsocketCoinMargined}, + {Endpoint: websocketUSDTMarginedURL, Expected: WebsocketUSDTMargined}, + {Endpoint: websocketUSDCMarginedURL, Expected: WebsocketUSDCMargined}, + {Endpoint: websocketOptionsURL, Expected: WebsocketOptions}, + {Endpoint: websocketPrivateURL, Expected: WebsocketPrivate}, + {Endpoint: websocketSpotSupplementaryURL, Expected: WebsocketSpotSupplementary}, + {Endpoint: chainAnalysisURL, Expected: ChainAnalysis}, + {Endpoint: edgeCase1URL, Expected: EdgeCase1}, + {Endpoint: edgeCase2URL, Expected: EdgeCase2}, + {Endpoint: edgeCase3URL, Expected: EdgeCase3}, {Endpoint: "sillyMcSillyBilly", Expected: 0, Error: errEndpointStringNotFound}, } diff --git a/exchanges/exchange_types.go b/exchanges/exchange_types.go index ae1a2971..5539bc25 100644 --- a/exchanges/exchange_types.go +++ b/exchanges/exchange_types.go @@ -273,6 +273,11 @@ const ( RestSwap RestSandbox WebsocketSpot + WebsocketCoinMargined + WebsocketUSDTMargined + WebsocketUSDCMargined + WebsocketOptions + WebsocketPrivate WebsocketSpotSupplementary ChainAnalysis EdgeCase1 @@ -289,6 +294,11 @@ const ( restSandboxURL = "RestSandboxURL" restSwapURL = "RestSwapURL" websocketSpotURL = "WebsocketSpotURL" + websocketCoinMarginedURL = "WebsocketCoinMarginedURL" + websocketUSDTMarginedURL = "WebsocketUSDTMarginedURL" + websocketUSDCMarginedURL = "WebsocketUSDCMarginedURL" + websocketOptionsURL = "WebsocketOptionsURL" + websocketPrivateURL = "WebsocketPrivateURL" websocketSpotSupplementaryURL = "WebsocketSpotSupplementaryURL" chainAnalysisURL = "ChainAnalysisURL" edgeCase1URL = "EdgeCase1URL" @@ -307,6 +317,11 @@ var keyURLs = []URL{ RestSwap, RestSandbox, WebsocketSpot, + WebsocketCoinMargined, + WebsocketUSDTMargined, + WebsocketUSDCMargined, + WebsocketOptions, + WebsocketPrivate, WebsocketSpotSupplementary, ChainAnalysis, EdgeCase1, diff --git a/exchanges/gateio/gateio.go b/exchanges/gateio/gateio.go index e373e58b..9ec8f3e3 100644 --- a/exchanges/gateio/gateio.go +++ b/exchanges/gateio/gateio.go @@ -188,8 +188,9 @@ func timeInForceFromString(tif string) (order.TimeInForce, error) { // Exchange implements exchange.IBotExchange and contains additional specific api methods for interacting with GateIO type Exchange struct { - Counter common.Counter // Must be first due to alignment requirements exchange.Base + + messageIDSeq common.Counter wsOBUpdateMgr *wsOBUpdateManager } diff --git a/exchanges/gateio/gateio_test.go b/exchanges/gateio/gateio_test.go index e9458f7c..7c146b41 100644 --- a/exchanges/gateio/gateio_test.go +++ b/exchanges/gateio/gateio_test.go @@ -2324,7 +2324,7 @@ func TestSubscribe(t *testing.T) { subs, err := e.Features.Subscriptions.ExpandTemplates(e) require.NoError(t, err, "ExpandTemplates must not error") e.Features.Subscriptions = subscription.List{} - err = e.Subscribe(t.Context(), &DummyConnection{}, subs) + err = e.Subscribe(t.Context(), &FixtureConnection{}, subs) require.NoError(t, err, "Subscribe must not error") } @@ -2866,15 +2866,10 @@ func TestGetSettlementCurrency(t *testing.T) { } } -func TestGenerateWebsocketMessageID(t *testing.T) { - t.Parallel() - require.NotEmpty(t, e.GenerateWebsocketMessageID(false)) -} +type FixtureConnection struct{ websocket.Connection } -type DummyConnection struct{ websocket.Connection } - -func (d *DummyConnection) GenerateMessageID(bool) int64 { return 1337 } -func (d *DummyConnection) SendMessageReturnResponse(context.Context, request.EndpointLimit, any, any) ([]byte, error) { +func (d *FixtureConnection) GenerateMessageID(bool) int64 { return 1337 } +func (d *FixtureConnection) SendMessageReturnResponse(context.Context, request.EndpointLimit, any, any) ([]byte, error) { return []byte(`{"time":1726121320,"time_ms":1726121320745,"id":1,"conn_id":"f903779a148987ca","trace_id":"d8ee37cd14347e4ed298d44e69aedaa7","channel":"spot.tickers","event":"subscribe","payload":["BRETT_USDT"],"result":{"status":"success"},"requestId":"d8ee37cd14347e4ed298d44e69aedaa7"}`), nil } @@ -2883,12 +2878,12 @@ func TestHandleSubscriptions(t *testing.T) { subs := subscription.List{{Channel: subscription.OrderbookChannel}} - err := e.handleSubscription(t.Context(), &DummyConnection{}, subscribeEvent, subs, func(context.Context, websocket.Connection, string, subscription.List) ([]WsInput, error) { + err := e.handleSubscription(t.Context(), &FixtureConnection{}, subscribeEvent, subs, func(context.Context, websocket.Connection, string, subscription.List) ([]WsInput, error) { return []WsInput{{}}, nil }) require.NoError(t, err) - err = e.handleSubscription(t.Context(), &DummyConnection{}, unsubscribeEvent, subs, func(context.Context, websocket.Connection, string, subscription.List) ([]WsInput, error) { + err = e.handleSubscription(t.Context(), &FixtureConnection{}, unsubscribeEvent, subs, func(context.Context, websocket.Connection, string, subscription.List) ([]WsInput, error) { return []WsInput{{}}, nil }) require.NoError(t, err) diff --git a/exchanges/gateio/gateio_websocket.go b/exchanges/gateio/gateio_websocket.go index 06818953..403785b7 100644 --- a/exchanges/gateio/gateio_websocket.go +++ b/exchanges/gateio/gateio_websocket.go @@ -729,11 +729,6 @@ func (e *Exchange) Unsubscribe(ctx context.Context, conn websocket.Connection, s return e.manageSubs(ctx, unsubscribeEvent, conn, subs) } -// GenerateWebsocketMessageID generates a message ID for the individual connection -func (e *Exchange) GenerateWebsocketMessageID(bool) int64 { - return e.Counter.IncrementAndGet() -} - // channelName converts global channel names to gateio specific channel names func channelName(s *subscription.Subscription) string { if name, ok := subscriptionNames[s.Channel]; ok { diff --git a/exchanges/gateio/gateio_websocket_request_spot.go b/exchanges/gateio/gateio_websocket_request_spot.go index 0cf2bdfe..24992ff2 100644 --- a/exchanges/gateio/gateio_websocket_request_spot.go +++ b/exchanges/gateio/gateio_websocket_request_spot.go @@ -46,7 +46,7 @@ func (e *Exchange) WebsocketSpotSubmitOrders(ctx context.Context, orders ...*Cre for i := range orders { if orders[i].Text == "" { // API requires Text field, or it will be rejected - orders[i].Text = "t-" + strconv.FormatInt(e.Counter.IncrementAndGet(), 10) + orders[i].Text = "t-" + strconv.FormatInt(e.messageIDSeq.IncrementAndGet(), 10) } if orders[i].CurrencyPair.IsEmpty() { return nil, currency.ErrCurrencyPairEmpty diff --git a/exchanges/gateio/gateio_wrapper.go b/exchanges/gateio/gateio_wrapper.go index 18044949..9c685983 100644 --- a/exchanges/gateio/gateio_wrapper.go +++ b/exchanges/gateio/gateio_wrapper.go @@ -208,17 +208,17 @@ func (e *Exchange) Setup(exch *config.Exchange) error { } // Spot connection err = e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ - URL: gateioWebsocketEndpoint, - ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, - ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - Handler: e.WsHandleSpotData, - Subscriber: e.Subscribe, - Unsubscriber: e.Unsubscribe, - GenerateSubscriptions: e.generateSubscriptionsSpot, - Connector: e.WsConnectSpot, - Authenticate: e.authenticateSpot, - MessageFilter: asset.Spot, - BespokeGenerateMessageID: e.GenerateWebsocketMessageID, + URL: gateioWebsocketEndpoint, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + Handler: e.WsHandleSpotData, + Subscriber: e.Subscribe, + Unsubscriber: e.Unsubscribe, + GenerateSubscriptions: e.generateSubscriptionsSpot, + Connector: e.WsConnectSpot, + Authenticate: e.authenticateSpot, + MessageFilter: asset.Spot, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, }) if err != nil { return err @@ -236,10 +236,10 @@ func (e *Exchange) Setup(exch *config.Exchange) error { GenerateSubscriptions: func() (subscription.List, error) { return e.GenerateFuturesDefaultSubscriptions(asset.USDTMarginedFutures) }, - Connector: e.WsFuturesConnect, - Authenticate: e.authenticateFutures, - MessageFilter: asset.USDTMarginedFutures, - BespokeGenerateMessageID: e.GenerateWebsocketMessageID, + Connector: e.WsFuturesConnect, + Authenticate: e.authenticateFutures, + MessageFilter: asset.USDTMarginedFutures, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, }) if err != nil { return err @@ -258,9 +258,9 @@ func (e *Exchange) Setup(exch *config.Exchange) error { GenerateSubscriptions: func() (subscription.List, error) { return e.GenerateFuturesDefaultSubscriptions(asset.CoinMarginedFutures) }, - Connector: e.WsFuturesConnect, - MessageFilter: asset.CoinMarginedFutures, - BespokeGenerateMessageID: e.GenerateWebsocketMessageID, + Connector: e.WsFuturesConnect, + MessageFilter: asset.CoinMarginedFutures, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, }) if err != nil { return err @@ -275,12 +275,12 @@ func (e *Exchange) Setup(exch *config.Exchange) error { Handler: func(ctx context.Context, conn websocket.Connection, incoming []byte) error { return e.WsHandleFuturesData(ctx, conn, incoming, asset.DeliveryFutures) }, - Subscriber: e.DeliveryFuturesSubscribe, - Unsubscriber: e.DeliveryFuturesUnsubscribe, - GenerateSubscriptions: e.GenerateDeliveryFuturesDefaultSubscriptions, - Connector: e.WsDeliveryFuturesConnect, - MessageFilter: asset.DeliveryFutures, - BespokeGenerateMessageID: e.GenerateWebsocketMessageID, + Subscriber: e.DeliveryFuturesSubscribe, + Unsubscriber: e.DeliveryFuturesUnsubscribe, + GenerateSubscriptions: e.GenerateDeliveryFuturesDefaultSubscriptions, + Connector: e.WsDeliveryFuturesConnect, + MessageFilter: asset.DeliveryFutures, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, }) if err != nil { return err @@ -288,16 +288,16 @@ func (e *Exchange) Setup(exch *config.Exchange) error { // Futures connection - Options return e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ - URL: optionsWebsocketURL, - ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, - ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - Handler: e.WsHandleOptionsData, - Subscriber: e.OptionsSubscribe, - Unsubscriber: e.OptionsUnsubscribe, - GenerateSubscriptions: e.GenerateOptionsDefaultSubscriptions, - Connector: e.WsOptionsConnect, - MessageFilter: asset.Options, - BespokeGenerateMessageID: e.GenerateWebsocketMessageID, + URL: optionsWebsocketURL, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + Handler: e.WsHandleOptionsData, + Subscriber: e.OptionsSubscribe, + Unsubscriber: e.OptionsUnsubscribe, + GenerateSubscriptions: e.GenerateOptionsDefaultSubscriptions, + Connector: e.WsOptionsConnect, + MessageFilter: asset.Options, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, }) } diff --git a/exchanges/okx/okx_wrapper.go b/exchanges/okx/okx_wrapper.go index 498dff32..12859200 100644 --- a/exchanges/okx/okx_wrapper.go +++ b/exchanges/okx/okx_wrapper.go @@ -219,22 +219,22 @@ func (e *Exchange) Setup(exch *config.Exchange) error { } if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ - URL: apiWebsocketPublicURL, - ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, - ResponseMaxLimit: websocketResponseMaxLimit, - RateLimit: request.NewRateLimitWithWeight(time.Second, 2, 1), - BespokeGenerateMessageID: func(bool) int64 { return e.messageIDSeq.IncrementAndGet() }, + URL: apiWebsocketPublicURL, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: websocketResponseMaxLimit, + RateLimit: request.NewRateLimitWithWeight(time.Second, 2, 1), + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, }); err != nil { return err } return e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ - URL: apiWebsocketPrivateURL, - ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, - ResponseMaxLimit: websocketResponseMaxLimit, - Authenticated: true, - RateLimit: request.NewRateLimitWithWeight(time.Second, 2, 1), - BespokeGenerateMessageID: func(bool) int64 { return e.messageIDSeq.IncrementAndGet() }, + URL: apiWebsocketPrivateURL, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: websocketResponseMaxLimit, + Authenticated: true, + RateLimit: request.NewRateLimitWithWeight(time.Second, 2, 1), + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, }) }