diff --git a/cmd/exchange_wrapper_standards/exchange_wrapper_standards_test.go b/cmd/exchange_wrapper_standards/exchange_wrapper_standards_test.go index c0c9fc9043f..4a87f9f7d5b 100644 --- a/cmd/exchange_wrapper_standards/exchange_wrapper_standards_test.go +++ b/cmd/exchange_wrapper_standards/exchange_wrapper_standards_test.go @@ -26,6 +26,7 @@ import ( "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/stream" "github.com/thrasher-corp/gocryptotrader/portfolio/banking" "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" ) @@ -641,6 +642,7 @@ var acceptableErrors = []error{ order.ErrCannotValidateAsset, // Is thrown when attempting to get order limits from an asset that is not yet loaded order.ErrCannotValidateBaseCurrency, // Is thrown when attempting to get order limits from an base currency that is not yet loaded order.ErrCannotValidateQuoteCurrency, // Is thrown when attempting to get order limits from an quote currency that is not yet loaded + stream.ErrNotConnected, // Is thrown when attempting to send a message to a websocket that is not connected } // warningErrors will t.Log(err) when thrown to diagnose things, but not necessarily suggest diff --git a/docs/ADD_NEW_EXCHANGE.md b/docs/ADD_NEW_EXCHANGE.md index 41e5712d192..166f30dd897 100644 --- a/docs/ADD_NEW_EXCHANGE.md +++ b/docs/ADD_NEW_EXCHANGE.md @@ -1176,7 +1176,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 unsubscriptions. - `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/exchanges/bybit/bybit.go b/exchanges/bybit/bybit.go index 2a5503a73de..c2cee3959d1 100644 --- a/exchanges/bybit/bybit.go +++ b/exchanges/bybit/bybit.go @@ -27,10 +27,10 @@ import ( // Bybit is the overarching type across this package type Bybit struct { exchange.Base - // AccountType holds information about whether the account to which the api key belongs is a unified margin account or not. // 0: unified, and 1: for normal account AccountType int64 + Counter common.Counter } const ( diff --git a/exchanges/bybit/bybit_inverse_websocket.go b/exchanges/bybit/bybit_inverse_websocket.go index 0a0346bde58..79d47b20c96 100644 --- a/exchanges/bybit/bybit_inverse_websocket.go +++ b/exchanges/bybit/bybit_inverse_websocket.go @@ -2,81 +2,43 @@ package bybit import ( "context" - "net/http" + "errors" - "github.com/gorilla/websocket" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" - "github.com/thrasher-corp/gocryptotrader/exchanges/request" "github.com/thrasher-corp/gocryptotrader/exchanges/stream" "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" ) -// WsInverseConnect connects to inverse websocket feed -func (by *Bybit) WsInverseConnect() error { - if !by.Websocket.IsEnabled() || !by.IsEnabled() || !by.IsAssetWebsocketSupported(asset.CoinMarginedFutures) { - return stream.ErrWebsocketNotEnabled - } - by.Websocket.Conn.SetURL(inversePublic) - var dialer websocket.Dialer - err := by.Websocket.Conn.Dial(&dialer, http.Header{}) - if err != nil { - return err - } - by.Websocket.Conn.SetupPingHandler(request.Unset, stream.PingHandler{ - MessageType: websocket.TextMessage, - Message: []byte(`{"op": "ping"}`), - Delay: bybitWebsocketTimer, - }) - - by.Websocket.Wg.Add(1) - go by.wsReadData(asset.CoinMarginedFutures, by.Websocket.Conn) - return nil -} - // GenerateInverseDefaultSubscriptions generates default subscription func (by *Bybit) GenerateInverseDefaultSubscriptions() (subscription.List, error) { - var subscriptions subscription.List - var channels = []string{chanOrderbook, chanPublicTrade, chanPublicTicker} pairs, err := by.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 x := range channels { - subscriptions = append(subscriptions, - &subscription.Subscription{ - Channel: channels[x], - Pairs: currency.Pairs{pairs[z]}, - Asset: asset.CoinMarginedFutures, - }) + 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 subscription message to linear public channels. -func (by *Bybit) InverseSubscribe(channelSubscriptions subscription.List) error { - return by.handleInversePayloadSubscription("subscribe", channelSubscriptions) +func (by *Bybit) InverseSubscribe(ctx context.Context, conn stream.Connection, channelSubscriptions subscription.List) error { + return by.handleSubscriptionNonTemplate(ctx, conn, asset.CoinMarginedFutures, "subscribe", channelSubscriptions) } // InverseUnsubscribe sends an unsubscription messages through linear public channels. -func (by *Bybit) InverseUnsubscribe(channelSubscriptions subscription.List) error { - return by.handleInversePayloadSubscription("unsubscribe", channelSubscriptions) -} - -func (by *Bybit) handleInversePayloadSubscription(operation string, channelSubscriptions subscription.List) error { - payloads, err := by.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 = by.Websocket.Conn.SendJSONMessage(context.TODO(), request.Unset, payloads[a]) - if err != nil { - return err - } - } - return nil +func (by *Bybit) InverseUnsubscribe(ctx context.Context, conn stream.Connection, channelSubscriptions subscription.List) error { + return by.handleSubscriptionNonTemplate(ctx, conn, asset.CoinMarginedFutures, "unsubscribe", channelSubscriptions) } diff --git a/exchanges/bybit/bybit_linear_websocket.go b/exchanges/bybit/bybit_linear_websocket.go index 0edb1a322b3..91e83127812 100644 --- a/exchanges/bybit/bybit_linear_websocket.go +++ b/exchanges/bybit/bybit_linear_websocket.go @@ -2,99 +2,43 @@ package bybit import ( "context" - "net/http" + "errors" - "github.com/gorilla/websocket" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" - "github.com/thrasher-corp/gocryptotrader/exchanges/request" "github.com/thrasher-corp/gocryptotrader/exchanges/stream" "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" ) -// WsLinearConnect connects to linear a websocket feed -func (by *Bybit) WsLinearConnect() error { - if !by.Websocket.IsEnabled() || !by.IsEnabled() || !by.IsAssetWebsocketSupported(asset.LinearContract) { - return stream.ErrWebsocketNotEnabled - } - by.Websocket.Conn.SetURL(linearPublic) - var dialer websocket.Dialer - err := by.Websocket.Conn.Dial(&dialer, http.Header{}) +// GenerateLinearDefaultSubscriptions generates default subscription +func (by *Bybit) GenerateLinearDefaultSubscriptions(a asset.Item) (subscription.List, error) { + pairs, err := by.GetEnabledPairs(a) if err != nil { - return err - } - by.Websocket.Conn.SetupPingHandler(request.Unset, stream.PingHandler{ - MessageType: websocket.TextMessage, - Message: []byte(`{"op": "ping"}`), - Delay: bybitWebsocketTimer, - }) - - by.Websocket.Wg.Add(1) - go by.wsReadData(asset.LinearContract, by.Websocket.Conn) - if by.IsWebsocketAuthenticationSupported() { - err = by.WsAuth(context.TODO()) - if err != nil { - by.Websocket.DataHandler <- err - by.Websocket.SetCanUseAuthenticatedEndpoints(false) + if errors.Is(err, asset.ErrNotEnabled) { + return nil, nil } + return nil, err } - return nil -} -// GenerateLinearDefaultSubscriptions generates default subscription -func (by *Bybit) GenerateLinearDefaultSubscriptions() (subscription.List, error) { var subscriptions subscription.List - var channels = []string{chanOrderbook, chanPublicTrade, chanPublicTicker} - pairs, err := by.GetEnabledPairs(asset.USDTMarginedFutures) - if err != nil { - return nil, err - } - linearPairMap := map[asset.Item]currency.Pairs{ - asset.USDTMarginedFutures: pairs, - } - usdcPairs, err := by.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, - }) - } + 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 subscription message to linear public channels. -func (by *Bybit) LinearSubscribe(channelSubscriptions subscription.List) error { - return by.handleLinearPayloadSubscription("subscribe", channelSubscriptions) +func (by *Bybit) LinearSubscribe(ctx context.Context, conn stream.Connection, channelSubscriptions subscription.List) error { + return by.handleSubscriptionNonTemplate(ctx, conn, asset.USDTMarginedFutures, "subscribe", channelSubscriptions) } // LinearUnsubscribe sends an unsubscription messages through linear public channels. -func (by *Bybit) LinearUnsubscribe(channelSubscriptions subscription.List) error { - return by.handleLinearPayloadSubscription("unsubscribe", channelSubscriptions) -} - -func (by *Bybit) handleLinearPayloadSubscription(operation string, channelSubscriptions subscription.List) error { - payloads, err := by.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 = by.Websocket.Conn.SendJSONMessage(context.TODO(), request.Unset, payloads[a]) - if err != nil { - return err - } - } - return nil +func (by *Bybit) LinearUnsubscribe(ctx context.Context, conn stream.Connection, channelSubscriptions subscription.List) error { + return by.handleSubscriptionNonTemplate(ctx, conn, asset.USDTMarginedFutures, "unsubscribe", channelSubscriptions) } diff --git a/exchanges/bybit/bybit_options_websocket.go b/exchanges/bybit/bybit_options_websocket.go index e2e5836346b..06895875061 100644 --- a/exchanges/bybit/bybit_options_websocket.go +++ b/exchanges/bybit/bybit_options_websocket.go @@ -2,88 +2,43 @@ package bybit import ( "context" - "encoding/json" - "net/http" - "strconv" + "errors" - "github.com/gorilla/websocket" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" - "github.com/thrasher-corp/gocryptotrader/exchanges/request" "github.com/thrasher-corp/gocryptotrader/exchanges/stream" "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" ) -// WsOptionsConnect connects to options a websocket feed -func (by *Bybit) WsOptionsConnect() error { - if !by.Websocket.IsEnabled() || !by.IsEnabled() || !by.IsAssetWebsocketSupported(asset.Options) { - return stream.ErrWebsocketNotEnabled - } - by.Websocket.Conn.SetURL(optionPublic) - var dialer websocket.Dialer - err := by.Websocket.Conn.Dial(&dialer, http.Header{}) - if err != nil { - return err - } - pingMessage := PingMessage{Operation: "ping", RequestID: strconv.FormatInt(by.Websocket.Conn.GenerateMessageID(false), 10)} - pingData, err := json.Marshal(pingMessage) - if err != nil { - return err - } - by.Websocket.Conn.SetupPingHandler(request.Unset, stream.PingHandler{ - MessageType: websocket.TextMessage, - Message: pingData, - Delay: bybitWebsocketTimer, - }) - - by.Websocket.Wg.Add(1) - go by.wsReadData(asset.Options, by.Websocket.Conn) - return nil -} - // GenerateOptionsDefaultSubscriptions generates default subscription func (by *Bybit) GenerateOptionsDefaultSubscriptions() (subscription.List, error) { - var subscriptions subscription.List - var channels = []string{chanOrderbook, chanPublicTrade, chanPublicTicker} pairs, err := by.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 x := range channels { - subscriptions = append(subscriptions, - &subscription.Subscription{ - Channel: channels[x], - Pairs: currency.Pairs{pairs[z]}, - Asset: asset.Options, - }) + 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 } // OptionSubscribe sends a subscription message to options public channels. -func (by *Bybit) OptionSubscribe(channelSubscriptions subscription.List) error { - return by.handleOptionsPayloadSubscription("subscribe", channelSubscriptions) +func (by *Bybit) OptionSubscribe(ctx context.Context, conn stream.Connection, channelSubscriptions subscription.List) error { + return by.handleSubscriptionNonTemplate(ctx, conn, asset.Options, "subscribe", channelSubscriptions) } // OptionUnsubscribe sends an unsubscription messages through options public channels. -func (by *Bybit) OptionUnsubscribe(channelSubscriptions subscription.List) error { - return by.handleOptionsPayloadSubscription("unsubscribe", channelSubscriptions) -} - -func (by *Bybit) handleOptionsPayloadSubscription(operation string, channelSubscriptions subscription.List) error { - payloads, err := by.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 = by.Websocket.Conn.SendJSONMessage(context.TODO(), request.Unset, payloads[a]) - if err != nil { - return err - } - } - return nil +func (by *Bybit) OptionUnsubscribe(ctx context.Context, conn stream.Connection, channelSubscriptions subscription.List) error { + return by.handleSubscriptionNonTemplate(ctx, conn, asset.Options, "unsubscribe", channelSubscriptions) } diff --git a/exchanges/bybit/bybit_test.go b/exchanges/bybit/bybit_test.go index 4259eab31a7..b1b37c0840c 100644 --- a/exchanges/bybit/bybit_test.go +++ b/exchanges/bybit/bybit_test.go @@ -6,7 +6,10 @@ import ( "errors" "fmt" "maps" + "net/http" "slices" + "strings" + "sync" "testing" "time" @@ -18,19 +21,20 @@ import ( "github.com/thrasher-corp/gocryptotrader/common/key" "github.com/thrasher-corp/gocryptotrader/currency" 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/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/stream" "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" ) @@ -3131,48 +3135,24 @@ func TestCancelBatchOrders(t *testing.T) { } } -func TestWsConnect(t *testing.T) { - t.Parallel() - if mockTests { - t.Skip(skippingWebsocketFunctionsForMockTesting) - } - err := b.WsConnect() - if err != nil { - t.Error(err) - } +type DummyConnection struct{ stream.Connection } + +func (d *DummyConnection) GenerateMessageID(bool) int64 { return 1337 } +func (d *DummyConnection) SetupPingHandler(request.EndpointLimit, stream.PingHandler) {} +func (d *DummyConnection) DialContext(context.Context, *websocket.Dialer, http.Header) error { + return nil } -func TestWsLinearConnect(t *testing.T) { - t.Parallel() - if mockTests { - t.Skip(skippingWebsocketFunctionsForMockTesting) - } - err := b.WsLinearConnect() - if err != nil && !errors.Is(err, stream.ErrWebsocketNotEnabled) { - t.Error(err) - } +func (d *DummyConnection) SendMessageReturnResponse(context.Context, request.EndpointLimit, any, any) ([]byte, error) { + return []byte(`{"success":true,"ret_msg":"subscribe","conn_id":"5758770c-8152-4545-a84f-dae089e56499","req_id":"1","op":"subscribe"}`), nil } -func TestWsInverseConnect(t *testing.T) { - t.Parallel() - if mockTests { - t.Skip(skippingWebsocketFunctionsForMockTesting) - } - err := b.WsInverseConnect() - if err != nil && !errors.Is(err, stream.ErrWebsocketNotEnabled) { - t.Error(err) - } -} -func TestWsOptionsConnect(t *testing.T) { + +func TestWsConnect(t *testing.T) { t.Parallel() - if mockTests { - t.Skip(skippingWebsocketFunctionsForMockTesting) - } - err := b.WsOptionsConnect() - if err != nil && !errors.Is(err, stream.ErrWebsocketNotEnabled) { - t.Error(err) - } + err := b.WsConnect(context.Background(), &DummyConnection{}) + require.NoError(t, err) } -var pushDataMap = map[string]string{ +var pushDataPublicMap = map[string]string{ "Orderbook Snapshot": `{"topic":"orderbook.50.BTCUSDT","ts":1731035685326,"type":"snapshot","data":{"s":"BTCUSDT","b":[["75848.74","0.067669"],["75848.63","0.004772"],["75848.61","0.00659"],["75848.05","0.000329"],["75847.68","0.00159"],["75846.88","0.00159"],["75845.97","0.026366"],["75845.87","0.013185"],["75845.41","0.077259"],["75845.4","0.132228"],["75844.61","0.00159"],["75844.44","0.026367"],["75844.2","0.013185"],["75844","0.00039"],["75843.13","0.00159"],["75843.07","0.013185"],["75842.33","0.00159"],["75841.99","0.006"],["75841.75","0.019538"],["75841.74","0.04"],["75841.71","0.031817"],["75841.36","0.017336"],["75841.33","0.000072"],["75841.16","0.001872"],["75841.11","0.172641"],["75841.04","0.029772"],["75841","0.000065"],["75840.93","0.015244"],["75840.86","0.00159"],["75840.79","0.000072"],["75840.38","0.043333"],["75840.32","0.092539"],["75840.3","0.132228"],["75840.2","0.054966"],["75840.06","0.00159"],["75840","0.20726"],["75839.64","0.003744"],["75839.29","0.006592"],["75838.58","0.00159"],["75838.52","0.049778"],["75838.14","0.003955"],["75838","0.000065"],["75837.78","0.00159"],["75837.75","0.000587"],["75837.53","0.322245"],["75837.52","0.593323"],["75837.37","0.00384"],["75837.29","0.044335"],["75837.24","0.119228"],["75837.13","0.152844"]],"a":[["75848.75","0.747137"],["75848.89","0.060306"],["75848.9","0.1"],["75851.43","0.00159"],["75851.44","0.080754"],["75852.23","0.00159"],["75852.54","0.131067"],["75852.65","0.003955"],["75853.71","0.00159"],["75853.86","0.003955"],["75854.43","0.015684"],["75854.5","0.130389"],["75854.51","0.00159"],["75855.21","0.031168"],["75855.23","0.271494"],["75855.73","0.042698"],["75855.98","0.00159"],["75856.04","0.01346"],["75856.33","0.001872"],["75856.78","0.00159"],["75857.15","0.000072"],["75857.17","0.015127"],["75857.8","0.043322"],["75857.81","0.045305"],["75857.85","0.003792"],["75858.09","0.026344"],["75858.26","0.00159"],["75859.06","0.031618"],["75859.07","0.025"],["75859.1","0.006592"],["75859.98","0.013183"],["75860.12","0.00384"],["75860.54","0.00159"],["75860.74","0.051204"],["75860.75","0.065861"],["75861.18","0.031222"],["75861.33","0.00159"],["75861.64","0.003888"],["75861.96","0.042213"],["75862.28","0.000777"],["75862.79","0.013184"],["75862.81","0.00159"],["75862.84","0.027959"],["75863.16","0.003888"],["75863.51","0.043628"],["75863.52","0.002525"],["75863.61","0.00159"],["75864.2","0.003955"],["75864.76","0.000072"],["75864.81","0.002018"]],"u":2876700,"seq":47474967795},"cts":1731035685323}`, "Orderbook Update": `{"topic":"orderbook.50.BTCUSDT","ts":1731035685345,"type":"delta","data":{"s":"BTCUSDT","b":[["75848.62","0.014895"],["75837.13","0"]],"a":[["75848.89","0.088149"],["75851.44","0.078379"],["75852.65","0"],["75855.23","0.260219"],["75857.74","0.049778"]],"u":2876701,"seq":47474967823},"cts":1731035685342}`, "Public Trade": `{"topic":"publicTrade.BTCUSDT","ts":1690720953113,"type":"snapshot","data":[{"i":"2200000000067341890","T":1690720953111,"p":"3.6279","v":"1.3637","S":"Sell","s":"BTCUSDT","BT":false}]}`, @@ -3181,21 +3161,36 @@ 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}]}`, } -func TestPushData(t *testing.T) { +func TestPushDataPublic(t *testing.T) { t.Parallel() - keys := slices.Collect(maps.Keys(pushDataMap)) + keys := slices.Collect(maps.Keys(pushDataPublicMap)) slices.Sort(keys) for x := range keys { - err := b.wsHandleData(asset.Spot, []byte(pushDataMap[keys[x]])) + err := b.wsHandleData(context.Background(), []byte(pushDataPublicMap[keys[x]]), asset.Spot) + assert.NoError(t, err, "wsHandleData should not error") + } +} + +var pushDataPrivateMap = map[string]string{ + "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": "%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": "" } ] }`, + "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}]}`, +} + +func TestPushDataPrivate(t *testing.T) { + t.Parallel() + + for _, payload := range pushDataPrivateMap { + if strings.Contains(payload, "%s") { + payload = fmt.Sprintf(payload, optionsTradablePair.String()) + } + err := b.wsHandleAuthenticated(context.Background(), []byte(payload)) assert.NoError(t, err, "wsHandleData should not error") } } @@ -3210,7 +3205,7 @@ func TestWsTicker(t *testing.T) { require.NoError(t, testexch.Setup(b), "Test instance Setup must not error") testexch.FixtureToDataHandler(t, "testdata/wsTicker.json", func(r []byte) error { defer slices.Delete(assetRouting, 0, 1) - return b.wsHandleData(assetRouting[0], r) + return b.wsHandleData(context.Background(), r, assetRouting[0]) }) close(b.Websocket.DataHandler) expected := 8 @@ -3472,7 +3467,7 @@ func TestFetchTradablePairs(t *testing.T) { func TestDeltaUpdateOrderbook(t *testing.T) { t.Parallel() data := `{"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 := b.wsHandleData(asset.Spot, []byte(data)) + err := b.wsHandleData(context.Background(), []byte(data), asset.Spot) if err != nil { t.Fatal(err) } @@ -3740,40 +3735,41 @@ func TestSubscribe(t *testing.T) { require.NoError(t, err, "ExpandTemplates must not error") b.Features.Subscriptions = subscription.List{} testexch.SetupWs(t, b) - err = b.Subscribe(subs) + err = b.Subscribe(context.Background(), &DummyConnection{}, subs) require.NoError(t, err, "Subscribe must not error") } func TestAuthSubscribe(t *testing.T) { t.Parallel() + b := new(Bybit) - require.NoError(t, testexch.Setup(b), "Test instance Setup must not error") + require.NoError(t, testexch.Setup(b)) + require.NoError(t, b.authSubscribe(context.Background(), &DummyConnection{}, subscription.List{})) + + authsubs, err := b.generateAuthSubscriptions() + require.NoError(t, err) + require.Empty(t, authsubs) + b.Websocket.SetCanUseAuthenticatedEndpoints(true) - subs, err := b.Features.Subscriptions.ExpandTemplates(b) - require.NoError(t, err, "ExpandTemplates must not error") - b.Features.Subscriptions = subscription.List{} - success := true - mock := func(tb testing.TB, msg []byte, w *websocket.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(websocket.TextMessage, msg) - } - b = testexch.MockWsInstance[Bybit](t, testws.CurryWsMockUpgrader(t, mock)) - b.Websocket.AuthConn = b.Websocket.Conn - err = b.Subscribe(subs) - require.NoError(t, err, "Subscribe must not error") - success = false - err = b.Subscribe(subs) - assert.ErrorContains(t, err, "Mock Resp Error", "Subscribe should error containing the returned RetMsg") + authsubs, err = b.generateAuthSubscriptions() + require.NoError(t, err) + require.NotEmpty(t, authsubs) + + require.NoError(t, b.authSubscribe(context.Background(), &DummyConnection{}, authsubs)) + require.NoError(t, b.authUnsubscribe(context.Background(), &DummyConnection{}, authsubs)) +} + +func TestWebsocketAuthenticateConnection(t *testing.T) { + t.Parallel() + + b := new(Bybit) + require.NoError(t, testexch.Setup(b)) + b.API.AuthenticatedSupport = true + b.API.AuthenticatedWebsocketSupport = true + b.Websocket.SetCanUseAuthenticatedEndpoints(true) + ctx := account.DeployCredentialsToContext(context.Background(), &account.Credentials{Key: "dummy", Secret: "dummy"}) + err := b.WebsocketAuthenticateConnection(ctx, &DummyConnection{}) + require.NoError(t, err) } func TestTransformSymbol(t *testing.T) { @@ -3839,3 +3835,85 @@ func TestTransformSymbol(t *testing.T) { }) } } + +func TestWswsProcessOrder(t *testing.T) { + t.Parallel() + b := new(Bybit) + require.NoError(t, testexch.Setup(b)) + + var wsOrderResponse WebsocketResponse + err := b.wsProcessOrder(&wsOrderResponse) + require.NotNil(t, err) + require.Contains(t, err.Error(), "unexpected end of JSON input") + + wsOrderResponse = WebsocketResponse{ + Data: []byte(`[{"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"}]`), + } + + var o []order.Detail + var wg sync.WaitGroup + wg.Add(1) + go func() { + data := <-b.Websocket.DataHandler + var ok bool + o, ok = data.([]order.Detail) + require.True(t, ok) + wg.Done() + }() + require.NoError(t, b.wsProcessOrder(&wsOrderResponse)) + wg.Wait() + require.Len(t, o, 1) + require.Equal(t, "c1956690-b731-4191-97c0-94b00422231b", o[0].OrderID) + require.Equal(t, "BTC_USDT", o[0].Pair.String()) + require.Equal(t, order.Sell, o[0].Side) + require.Equal(t, order.Filled, o[0].Status) + require.Equal(t, 1.7, o[0].Amount) + require.Equal(t, 4.033, o[0].Price) + require.Equal(t, 4.24, o[0].AverageExecutedPrice) + require.Equal(t, 0., o[0].RemainingAmount) + require.Equal(t, asset.USDTMarginedFutures, o[0].AssetType) +} + +func TestGetPairFromCategory(t *testing.T) { + t.Parallel() + + exp := spotTradablePair + exp.Delimiter = "" + p, a, err := b.getPairFromCategory("spot", exp.String()) + require.NoError(t, err) + require.True(t, exp.Equal(p)) + require.Equal(t, asset.Spot, a) + + exp = usdtMarginedTradablePair + exp.Delimiter = "" + p, a, err = b.getPairFromCategory("linear", exp.String()) + require.NoError(t, err) + require.True(t, exp.Equal(p)) + require.Equal(t, asset.USDTMarginedFutures, a) + + exp = usdcMarginedTradablePair + exp.Delimiter = "" + p, a, err = b.getPairFromCategory("linear", exp.String()) + require.NoError(t, err) + require.True(t, exp.Equal(p)) + require.Equal(t, asset.USDCMarginedFutures, a) + + exp = inverseTradablePair + exp.Delimiter = "" + p, a, err = b.getPairFromCategory("inverse", exp.String()) + require.NoError(t, err) + require.True(t, exp.Equal(p)) + require.Equal(t, asset.CoinMarginedFutures, a) + + exp = optionsTradablePair + p, a, err = b.getPairFromCategory("option", exp.String()) + require.NoError(t, err) + require.True(t, exp.Equal(p)) + require.Equal(t, asset.Options, a) + + p, a, err = b.getPairFromCategory("silly", exp.String()) + require.Error(t, err) + + p, a, err = b.getPairFromCategory("spot", "bad pair") + require.ErrorIs(t, err, currency.ErrPairNotFound) +} diff --git a/exchanges/bybit/bybit_types.go b/exchanges/bybit/bybit_types.go index 656a4d2ffa7..9cff81fe438 100644 --- a/exchanges/bybit/bybit_types.go +++ b/exchanges/bybit/bybit_types.go @@ -161,11 +161,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"` diff --git a/exchanges/bybit/bybit_websocket.go b/exchanges/bybit/bybit_websocket.go index 9d1b84268c1..3e1d49fce87 100644 --- a/exchanges/bybit/bybit_websocket.go +++ b/exchanges/bybit/bybit_websocket.go @@ -10,6 +10,7 @@ import ( "text/template" "time" + "github.com/buger/jsonparser" "github.com/gorilla/websocket" "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/crypto" @@ -55,6 +56,8 @@ const ( // Main-net private websocketPrivate = "wss://stream.bybit.com/v5/private" + + privateConnection = "private" ) var defaultSubscriptions = subscription.List{ @@ -62,93 +65,58 @@ 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}, - {Enabled: true, Asset: asset.Spot, Authenticated: true, Channel: chanPositions}, + // {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}, + // {Enabled: true, Asset: asset.Spot, Authenticated: true, Channel: chanPositions}, } var subscriptionNames = map[string]string{ subscription.TickerChannel: chanPublicTicker, subscription.OrderbookChannel: chanOrderbook, subscription.AllTradesChannel: chanPublicTrade, - subscription.MyOrdersChannel: chanOrder, - subscription.MyTradesChannel: chanExecution, - subscription.MyWalletChannel: chanWallet, - subscription.CandlesChannel: chanKline, + // subscription.MyOrdersChannel: chanOrder, + // subscription.MyTradesChannel: chanExecution, + // subscription.MyWalletChannel: chanWallet, + subscription.CandlesChannel: chanKline, } // WsConnect connects to a websocket feed -func (by *Bybit) WsConnect() error { - if !by.Websocket.IsEnabled() || !by.IsEnabled() || !by.IsAssetWebsocketSupported(asset.Spot) { - return stream.ErrWebsocketNotEnabled - } - var dialer websocket.Dialer - err := by.Websocket.Conn.Dial(&dialer, http.Header{}) - if err != nil { +func (by *Bybit) WsConnect(ctx context.Context, conn stream.Connection) error { + if err := conn.DialContext(ctx, &websocket.Dialer{}, http.Header{}); err != nil { return err } - by.Websocket.Conn.SetupPingHandler(request.Unset, stream.PingHandler{ + conn.SetupPingHandler(request.Unset, stream.PingHandler{ MessageType: websocket.TextMessage, Message: []byte(`{"op": "ping"}`), Delay: bybitWebsocketTimer, }) - - by.Websocket.Wg.Add(1) - go by.wsReadData(asset.Spot, by.Websocket.Conn) - if by.Websocket.CanUseAuthenticatedEndpoints() { - err = by.WsAuth(context.TODO()) - if err != nil { - by.Websocket.DataHandler <- err - by.Websocket.SetCanUseAuthenticatedEndpoints(false) - } - } return nil } -// WsAuth sends an authentication message to receive auth data -func (by *Bybit) WsAuth(ctx context.Context) error { - var dialer websocket.Dialer - err := by.Websocket.AuthConn.Dial(&dialer, http.Header{}) - if err != nil { - return err - } - - by.Websocket.AuthConn.SetupPingHandler(request.Unset, stream.PingHandler{ - MessageType: websocket.TextMessage, - Message: []byte(`{"op":"ping"}`), - Delay: bybitWebsocketTimer, - }) - - by.Websocket.Wg.Add(1) - go by.wsReadData(asset.Spot, by.Websocket.AuthConn) +// WebsocketAuthenticateConnection sends an authentication message to the websocket +func (by *Bybit) WebsocketAuthenticateConnection(ctx context.Context, conn stream.Connection) error { creds, err := by.GetCredentials(ctx) if err != nil { return err } 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 := crypto.HexEncodeToString(hmac) req := Authenticate{ - RequestID: strconv.FormatInt(by.Websocket.AuthConn.GenerateMessageID(false), 10), + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), Operation: "auth", - Args: []interface{}{creds.Key, intNonce, sign}, + Args: []interface{}{creds.Key, intNonce, crypto.HexEncodeToString(hmac)}, } - resp, err := by.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), 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 { @@ -158,13 +126,14 @@ func (by *Bybit) WsAuth(ctx context.Context) error { } // Subscribe sends a websocket message to receive data from the channel -func (by *Bybit) Subscribe(channelsToSubscribe subscription.List) error { - return by.handleSpotSubscription("subscribe", channelsToSubscribe) +func (by *Bybit) Subscribe(ctx context.Context, conn stream.Connection, channelsToSubscribe subscription.List) error { + return by.handleSpotSubscription(ctx, conn, "subscribe", channelsToSubscribe) } -func (by *Bybit) handleSubscriptions(operation string, subs subscription.List) (args []SubscriptionArgument, err error) { +func (by *Bybit) handleSubscriptions(conn stream.Connection, operation string, subs subscription.List) (args []SubscriptionArgument, err error) { subs, err = subs.ExpandTemplates(by) if err != nil { + fmt.Println("expandy silly", conn.GetURL()) return } chans := []string{} @@ -179,7 +148,7 @@ func (by *Bybit) handleSubscriptions(operation string, subs subscription.List) ( for _, b := range common.Batch(chans, 10) { args = append(args, SubscriptionArgument{ Operation: operation, - RequestID: strconv.FormatInt(by.Websocket.Conn.GenerateMessageID(false), 10), + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), Arguments: b, }) } @@ -187,7 +156,7 @@ func (by *Bybit) handleSubscriptions(operation string, subs subscription.List) ( args = append(args, SubscriptionArgument{ auth: true, Operation: operation, - RequestID: strconv.FormatInt(by.Websocket.Conn.GenerateMessageID(false), 10), + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), Arguments: authChans, }) } @@ -195,27 +164,20 @@ func (by *Bybit) handleSubscriptions(operation string, subs subscription.List) ( } // Unsubscribe sends a websocket message to stop receiving data from the channel -func (by *Bybit) Unsubscribe(channelsToUnsubscribe subscription.List) error { - return by.handleSpotSubscription("unsubscribe", channelsToUnsubscribe) +func (by *Bybit) Unsubscribe(ctx context.Context, conn stream.Connection, channelsToUnsubscribe subscription.List) error { + return by.handleSpotSubscription(ctx, conn, "unsubscribe", channelsToUnsubscribe) } -func (by *Bybit) handleSpotSubscription(operation string, channelsToSubscribe subscription.List) error { - payloads, err := by.handleSubscriptions(operation, channelsToSubscribe) +func (by *Bybit) handleSpotSubscription(ctx context.Context, conn stream.Connection, operation string, channelsToSubscribe subscription.List) error { + payloads, err := by.handleSubscriptions(conn, operation, channelsToSubscribe) if err != nil { return err } - for a := range payloads { + for _, payload := range payloads { var response []byte - if payloads[a].auth { - response, err = by.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, payloads[a].RequestID, payloads[a]) - if err != nil { - return err - } - } else { - response, err = by.Websocket.Conn.SendMessageReturnResponse(context.TODO(), request.Unset, payloads[a].RequestID, payloads[a]) - if err != nil { - return err - } + response, err = conn.SendMessageReturnResponse(ctx, request.Unset, payload.RequestID, payload) + if err != nil { + return err } var resp SubscriptionResponse err = json.Unmarshal(response, &resp) @@ -243,30 +205,9 @@ func (by *Bybit) GetSubscriptionTemplate(_ *subscription.Subscription) (*templat }).Parse(subTplText) } -// wsReadData receives and passes on websocket messages for processing -func (by *Bybit) wsReadData(assetType asset.Item, ws stream.Connection) { - defer by.Websocket.Wg.Done() - for { - select { - case <-by.Websocket.ShutdownC: - return - default: - resp := ws.ReadMessage() - if resp.Raw == nil { - return - } - err := by.wsHandleData(assetType, resp.Raw) - if err != nil { - by.Websocket.DataHandler <- err - } - } - } -} - -func (by *Bybit) wsHandleData(assetType asset.Item, respRaw []byte) error { +func (by *Bybit) wsHandleData(_ context.Context, respRaw []byte, assetType asset.Item) 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 == "" { @@ -279,9 +220,7 @@ func (by *Bybit) wsHandleData(assetType asset.Item, respRaw []byte) error { } case "ping", "pong": default: - by.Websocket.DataHandler <- stream.UnhandledMessageWarning{ - Message: string(respRaw), - } + by.Websocket.DataHandler <- stream.UnhandledMessageWarning{Message: string(respRaw)} return nil } return nil @@ -307,14 +246,51 @@ func (by *Bybit) wsHandleData(assetType asset.Item, respRaw []byte) error { return by.wsProcessLeverageTokenTicker(assetType, &result) case chanLeverageTokenNav: return by.wsLeverageTokenNav(&result) + } + return fmt.Errorf("unhandled stream data %s", string(respRaw)) +} + +func (by *Bybit) wsHandleAuthenticated(_ context.Context, respRaw []byte) error { + var result WebsocketResponse + if err := json.Unmarshal(respRaw, &result); err != nil { + return err + } + if result.Topic == "" { + switch result.Operation { + case "subscribe", "unsubscribe", "auth": + if result.RequestID != "" { + if !by.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: + by.Websocket.DataHandler <- stream.UnhandledMessageWarning{Message: string(respRaw)} + return nil + } + return nil + } + topicSplit := strings.Split(result.Topic, ".") + if len(topicSplit) == 0 { + return errInvalidPushData + } + + switch topicSplit[0] { case chanPositions: return by.wsProcessPosition(&result) case chanExecution: - return by.wsProcessExecution(asset.Spot, &result) + return by.wsProcessExecution(&result) case chanOrder: - return by.wsProcessOrder(asset.Spot, &result) + // Below provides a way of matching an order change to a websocket request. There is no batch support for this + // so the first element will be used to match the order ID. + if id, err := jsonparser.GetString(respRaw, "data", "[0]", "orderId"); err == nil { + if by.Websocket.Match.IncomingWithData(id, respRaw) { + return nil // If the data has been routed, return + } + } + return by.wsProcessOrder(&result) case chanWallet: - return by.wsProcessWalletPushData(asset.Spot, respRaw) + return by.wsProcessWalletPushData(respRaw) case chanGreeks: return by.wsProcessGreeks(respRaw) case chanDCP: @@ -333,10 +309,9 @@ func (by *Bybit) wsProcessGreeks(resp []byte) error { return nil } -func (by *Bybit) wsProcessWalletPushData(assetType asset.Item, resp []byte) error { +func (by *Bybit) wsProcessWalletPushData(resp []byte) error { var result WebsocketWallet - err := json.Unmarshal(resp, &result) - if err != nil { + if err := json.Unmarshal(resp, &result); err != nil { return err } accounts := []account.Change{} @@ -345,7 +320,7 @@ func (by *Bybit) wsProcessWalletPushData(assetType asset.Item, resp []byte) erro accounts = append(accounts, account.Change{ Exchange: by.Name, Currency: currency.NewCode(result.Data[x].Coin[y].Coin), - Asset: assetType, + Asset: asset.Spot, Amount: result.Data[x].Coin[y].WalletBalance.Float64(), }) } @@ -355,15 +330,14 @@ func (by *Bybit) wsProcessWalletPushData(assetType asset.Item, resp []byte) erro } // wsProcessOrder the order stream to see changes to your orders in real-time. -func (by *Bybit) wsProcessOrder(assetType asset.Item, resp *WebsocketResponse) error { +func (by *Bybit) 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 := by.MatchSymbolWithAvailablePairs(result[x].Symbol, assetType, hasPotentialDelimiter(assetType)) + cp, a, err := by.getPairFromCategory(result[x].Category, result[x].Symbol) if err != nil { return err } @@ -376,35 +350,35 @@ func (by *Bybit) wsProcessOrder(assetType asset.Item, resp *WebsocketResponse) e return err } execution[x] = order.Detail{ - Amount: result[x].Qty.Float64(), - Exchange: by.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(), + Amount: result[x].Qty.Float64(), + Exchange: by.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: 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(), } } by.Websocket.DataHandler <- execution return nil } -func (by *Bybit) wsProcessExecution(assetType asset.Item, resp *WebsocketResponse) error { +func (by *Bybit) 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 := by.MatchSymbolWithAvailablePairs(result[x].Symbol, assetType, hasPotentialDelimiter(assetType)) + cp, a, err := by.getPairFromCategory(result[x].Category, result[x].Symbol) if err != nil { return err } @@ -416,7 +390,7 @@ func (by *Bybit) wsProcessExecution(assetType asset.Item, resp *WebsocketRespons ID: result[x].ExecID, Timestamp: result[x].ExecTime.Time(), Exchange: by.Name, - AssetType: assetType, + AssetType: a, CurrencyPair: cp, Side: side, OrderID: result[x].OrderID, @@ -450,7 +424,7 @@ func (by *Bybit) wsLeverageTokenNav(resp *WebsocketResponse) error { } func (by *Bybit) wsProcessLeverageTokenTicker(assetType asset.Item, resp *WebsocketResponse) error { - var result TickerItem + var result TickerWebsocket err := json.Unmarshal(resp.Data, &result) if err != nil { return err @@ -551,8 +525,8 @@ func (by *Bybit) wsProcessKline(assetType asset.Item, resp *WebsocketResponse, t } func (by *Bybit) 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 } @@ -560,38 +534,24 @@ func (by *Bybit) wsProcessPublicTicker(assetType asset.Item, resp *WebsocketResp if err != nil { return err } - pFmt, err := by.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: by.Name, - AssetType: assetType, - } - } else { + tick := &ticker.Price{Pair: p, ExchangeName: by.Name, AssetType: assetType} + snapshot, err := ticker.GetTicker(by.Name, p, assetType) + if err == nil && resp.Type != "snapshot" { // ticker updates may be partial, so we need to update the current ticker - tick, err = ticker.GetTicker(by.Name, p, assetType) - if err != nil { - return err - } + tick = snapshot } - updateTicker(tick, tickResp) + updateTicker(tick, &tickResp) tick.LastUpdated = resp.PushTimestamp.Time() if err = ticker.ProcessTicker(tick); err == nil { by.Websocket.DataHandler <- tick } - return err } -func updateTicker(tick *ticker.Price, resp *TickerItem) { +func updateTicker(tick *ticker.Price, resp *TickerWebsocket) { if resp.LastPrice.Float64() != 0 { tick.Last = resp.LastPrice.Float64() } @@ -777,3 +737,152 @@ 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 (by *Bybit) handleSubscriptionNonTemplate(ctx context.Context, conn stream.Connection, a asset.Item, operation string, channelsToSubscribe subscription.List) error { + payloads, err := by.handleSubscriptionsNonTemplate(conn, a, operation, channelsToSubscribe) + if err != nil { + return err + } + 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. + err = conn.SendJSONMessage(ctx, request.Unset, payload) + if err != nil { + return err + } + continue + } + var response []byte + response, err = conn.SendMessageReturnResponse(ctx, request.Unset, payload.RequestID, payload) + 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) + } + } + return nil +} + +// TODO: Remove this function when template expansion is across all assets +func (by *Bybit) handleSubscriptionsNonTemplate(conn stream.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{} + pairFormat, err := by.GetPairFormat(assetType, true) + if err != nil { + return nil, err + } + for i := range channelsToSubscribe { + if len(channelsToSubscribe[i].Pairs) != 1 { + return nil, subscription.ErrNotSinglePair + } + pair := channelsToSubscribe[i].Pairs[0] + switch channelsToSubscribe[i].Channel { + case chanOrderbook: + arg.Arguments = append(arg.Arguments, fmt.Sprintf("%s.%d.%s", channelsToSubscribe[i].Channel, 50, pair.Format(pairFormat).String())) + case chanPublicTrade, chanPublicTicker, chanLiquidation, chanLeverageTokenTicker, chanLeverageTokenNav: + arg.Arguments = append(arg.Arguments, channelsToSubscribe[i].Channel+"."+pair.Format(pairFormat).String()) + case chanKline, chanLeverageTokenKline: + interval, err := intervalToString(kline.FiveMin) + if err != nil { + return nil, err + } + arg.Arguments = append(arg.Arguments, channelsToSubscribe[i].Channel+"."+interval+"."+pair.Format(pairFormat).String()) + case chanPositions, chanExecution, chanOrder, chanWallet, chanGreeks, chanDCP: + if chanMap[channelsToSubscribe[i].Channel] { + continue + } + authArg.Arguments = append(authArg.Arguments, channelsToSubscribe[i].Channel) + // adding the channel to selected channels so that we will not visit it again. + chanMap[channelsToSubscribe[i].Channel] = true + } + 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 (by *Bybit) generateAuthSubscriptions() (subscription.List, error) { + if !by.Websocket.CanUseAuthenticatedEndpoints() { + return nil, nil + } + var subscriptions subscription.List + for _, channel := range []string{chanPositions, chanExecution, chanOrder, chanWallet} { + subscriptions = append(subscriptions, &subscription.Subscription{ + Channel: channel, + Pairs: currency.Pairs{currency.EMPTYPAIR}, // This is a placeholder, the actual pair is not required for these channels + Asset: asset.All, + }) + } + return subscriptions, nil +} + +// LinearSubscribe sends a subscription message to linear public channels. +func (by *Bybit) authSubscribe(ctx context.Context, conn stream.Connection, channelSubscriptions subscription.List) error { + return by.handleSubscriptionNonTemplate(ctx, conn, asset.Spot, "subscribe", channelSubscriptions) +} + +// LinearUnsubscribe sends an unsubscription messages through linear public channels. +func (by *Bybit) authUnsubscribe(ctx context.Context, conn stream.Connection, channelSubscriptions subscription.List) error { + return by.handleSubscriptionNonTemplate(ctx, conn, asset.Spot, "unsubscribe", channelSubscriptions) +} + +// getPairFromCategory 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 (by *Bybit) getPairFromCategory(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("category '%s' not supported for incoming symbol '%s'", category, symbol) + } + var err error + for _, a := range assets { + var cp currency.Pair + if cp, err = by.MatchSymbolWithAvailablePairs(symbol, a, hasPotentialDelimiter(a)); err == nil { + return cp, a, nil + } + } + return currency.EMPTYPAIR, 0, err +} diff --git a/exchanges/bybit/bybit_wrapper.go b/exchanges/bybit/bybit_wrapper.go index acb3ae66949..cd0607992cd 100644 --- a/exchanges/bybit/bybit_wrapper.go +++ b/exchanges/bybit/bybit_wrapper.go @@ -27,6 +27,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/exchanges/request" "github.com/thrasher-corp/gocryptotrader/exchanges/stream" "github.com/thrasher-corp/gocryptotrader/exchanges/stream/buffer" + "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 (by *Bybit) SetDefaults() { } } - for _, a := range []asset.Item{asset.CoinMarginedFutures, asset.USDTMarginedFutures, asset.USDCMarginedFutures, asset.Options} { - if err := by.DisableAssetWebsocketSupport(a); err != nil { - log.Errorln(log.ExchangeSys, err) - } - } - by.Features = exchange.Features{ CurrencyTranslations: currency.NewTranslations( map[currency.Code]currency.Code{ @@ -214,65 +209,142 @@ func (by *Bybit) SetDefaults() { // Setup takes in the supplied exchange configuration details and sets params func (by *Bybit) Setup(exch *config.Exchange) error { - err := exch.Validate() - if err != nil { + if err := exch.Validate(); err != nil { return err } + if !exch.Enabled { by.SetEnabled(false) return nil } - err = by.SetupDefaults(exch) - if err != nil { + if err := by.SetupDefaults(exch); err != nil { return err } - wsRunningEndpoint, err := by.API.Endpoints.GetURL(exchange.WebsocketSpot) - if err != nil { + if err := by.Websocket.Setup(&stream.WebsocketSetup{ + ExchangeConfig: exch, + RunningURLAuth: websocketPrivate, + Features: &by.Features.Supports.WebsocketCapabilities, + OrderbookBufferConfig: buffer.Config{SortBuffer: true, SortBufferByUpdateIDs: true}, + TradeFeed: by.Features.Enabled.TradeFeed, + UseMultiConnectionManagement: true, + }); err != nil { return err } - err = by.Websocket.Setup( - &stream.WebsocketSetup{ - ExchangeConfig: exch, - DefaultURL: spotPublic, - RunningURL: wsRunningEndpoint, - RunningURLAuth: websocketPrivate, - Connector: by.WsConnect, - Subscriber: by.Subscribe, - Unsubscriber: by.Unsubscribe, - GenerateSubscriptions: by.generateSubscriptions, - Features: &by.Features.Supports.WebsocketCapabilities, - OrderbookBufferConfig: buffer.Config{ - SortBuffer: true, - SortBufferByUpdateIDs: true, - }, - TradeFeed: by.Features.Enabled.TradeFeed, - }) - if err != nil { + // Spot + if err := by.Websocket.SetupNewConnection(&stream.ConnectionSetup{ + URL: spotPublic, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Connector: by.WsConnect, + GenerateSubscriptions: func() (subscription.List, error) { return by.generateSubscriptions() }, + Subscriber: by.Subscribe, + Unsubscriber: by.Unsubscribe, + Handler: func(ctx context.Context, resp []byte) error { return by.wsHandleData(ctx, resp, asset.Spot) }, + BespokeGenerateMessageID: by.bespokeWebsocketRequestID, + }); err != nil { return err } - err = by.Websocket.SetupNewConnection(&stream.ConnectionSetup{ - URL: by.Websocket.GetWebsocketURL(), + + // Options + if err := by.Websocket.SetupNewConnection(&stream.ConnectionSetup{ + URL: optionPublic, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Connector: by.WsConnect, + GenerateSubscriptions: by.GenerateOptionsDefaultSubscriptions, + Subscriber: by.OptionSubscribe, + Unsubscriber: by.OptionUnsubscribe, + Handler: func(ctx context.Context, resp []byte) error { return by.wsHandleData(ctx, resp, asset.Options) }, + BespokeGenerateMessageID: by.bespokeWebsocketRequestID, + }); err != nil { + return err + } + + // Linear - USDT margined futures. + if err := by.Websocket.SetupNewConnection(&stream.ConnectionSetup{ + URL: linearPublic, ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, - ResponseMaxLimit: bybitWebsocketTimer, - }) - if err != nil { + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Connector: by.WsConnect, + GenerateSubscriptions: func() (subscription.List, error) { + return by.GenerateLinearDefaultSubscriptions(asset.USDTMarginedFutures) + }, + Subscriber: by.LinearSubscribe, + Unsubscriber: by.LinearUnsubscribe, + Handler: func(ctx context.Context, resp []byte) error { + return by.wsHandleData(ctx, resp, asset.USDTMarginedFutures) + }, + BespokeGenerateMessageID: by.bespokeWebsocketRequestID, + MessageFilter: asset.USDTMarginedFutures, // Unused but it allows us to differentiate between the two linear futures types. + }); err != nil { return err } - return by.Websocket.SetupNewConnection(&stream.ConnectionSetup{ - URL: websocketPrivate, + // Linear - USDC margined futures. + if err := by.Websocket.SetupNewConnection(&stream.ConnectionSetup{ + URL: linearPublic, ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - Authenticated: true, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Connector: by.WsConnect, + GenerateSubscriptions: func() (subscription.List, error) { + return by.GenerateLinearDefaultSubscriptions(asset.USDCMarginedFutures) + }, + Subscriber: by.LinearSubscribe, + Unsubscriber: by.LinearUnsubscribe, + Handler: func(ctx context.Context, resp []byte) error { + return by.wsHandleData(ctx, resp, asset.USDCMarginedFutures) + }, + BespokeGenerateMessageID: by.bespokeWebsocketRequestID, + MessageFilter: asset.USDCMarginedFutures, // Unused but it allows us to differentiate between the two linear futures types. + }); err != nil { + return err + } + + // Inverse - Coin margined futures. + if err := by.Websocket.SetupNewConnection(&stream.ConnectionSetup{ + URL: inversePublic, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Connector: by.WsConnect, + GenerateSubscriptions: by.GenerateInverseDefaultSubscriptions, + Subscriber: by.InverseSubscribe, + Unsubscriber: by.InverseUnsubscribe, + Handler: func(ctx context.Context, resp []byte) error { + return by.wsHandleData(ctx, resp, asset.CoinMarginedFutures) + }, + BespokeGenerateMessageID: by.bespokeWebsocketRequestID, + }); err != nil { + return err + } + + // Private + return by.Websocket.SetupNewConnection(&stream.ConnectionSetup{ + URL: websocketPrivate, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), + Authenticated: true, + Connector: by.WsConnect, + GenerateSubscriptions: by.generateAuthSubscriptions, + Subscriber: by.authSubscribe, + Unsubscriber: by.authUnsubscribe, + Handler: by.wsHandleAuthenticated, + BespokeGenerateMessageID: by.bespokeWebsocketRequestID, + Authenticate: by.WebsocketAuthenticateConnection, }) } -// AuthenticateWebsocket sends an authentication message to the websocket -func (by *Bybit) AuthenticateWebsocket(ctx context.Context) error { - return by.WsAuth(ctx) +// bespokeWebsocketRequestID generates a unique ID for websocket requests, this is just a simple counter. +func (by *Bybit) bespokeWebsocketRequestID(bool) int64 { + return by.Counter.IncrementAndGet() } // FetchTradablePairs returns a list of the exchanges tradable pairs diff --git a/exchanges/exchange.go b/exchanges/exchange.go index 4369b1dd110..ec7bd55daf8 100644 --- a/exchanges/exchange.go +++ b/exchanges/exchange.go @@ -1943,3 +1943,8 @@ func (b *Base) GetTradingRequirements() protocol.TradingRequirements { } return b.Features.TradingRequirements } + +// WebsocketSubmitOrder submits an order to the exchange via a websocket connection +func (*Base) WebsocketSubmitOrder(context.Context, *order.Submit) (*order.SubmitResponse, error) { + return nil, common.ErrFunctionNotSupported +} diff --git a/exchanges/exchange_test.go b/exchanges/exchange_test.go index a5db589d047..339aafbcbbe 100644 --- a/exchanges/exchange_test.go +++ b/exchanges/exchange_test.go @@ -3076,3 +3076,8 @@ func TestGetTradingRequirements(t *testing.T) { requirements = (&Base{Features: Features{TradingRequirements: protocol.TradingRequirements{ClientOrderID: true}}}).GetTradingRequirements() require.NotEmpty(t, requirements) } + +func TestWebsocketSubmitOrder(t *testing.T) { + _, err := (&Base{}).WebsocketSubmitOrder(context.Background(), nil) + require.ErrorIs(t, err, common.ErrFunctionNotSupported) +} diff --git a/exchanges/gateio/gateio.go b/exchanges/gateio/gateio.go index 68ae378dee0..4a6f86f30e8 100644 --- a/exchanges/gateio/gateio.go +++ b/exchanges/gateio/gateio.go @@ -560,7 +560,7 @@ func (g *Gateio) GetUnifiedAccount(ctx context.Context, ccy currency.Code) (*Uni // CreateBatchOrders Create a batch of orders Batch orders requirements: custom order field text is required At most 4 currency pairs, // maximum 10 orders each, are allowed in one request No mixture of spot orders and margin orders, i.e. account must be identical for all orders -func (g *Gateio) CreateBatchOrders(ctx context.Context, args []CreateOrderRequestData) ([]SpotOrder, error) { +func (g *Gateio) CreateBatchOrders(ctx context.Context, args []CreateOrderRequest) ([]SpotOrder, error) { if len(args) > 10 { return nil, fmt.Errorf("%w only 10 orders are canceled at once", errMultipleOrders) } @@ -633,7 +633,7 @@ func (g *Gateio) SpotClosePositionWhenCrossCurrencyDisabled(ctx context.Context, // PlaceSpotOrder creates a spot order you can place orders with spot, margin or cross margin account through setting the accountfield. // It defaults to spot, which means spot account is used to place orders. -func (g *Gateio) PlaceSpotOrder(ctx context.Context, arg *CreateOrderRequestData) (*SpotOrder, error) { +func (g *Gateio) PlaceSpotOrder(ctx context.Context, arg *CreateOrderRequest) (*SpotOrder, error) { if arg == nil { return nil, errNilArgument } @@ -2264,7 +2264,7 @@ func (g *Gateio) UpdatePositionRiskLimitInDualMode(ctx context.Context, settle c // Set reduce_only to true can keep the position from changing side when reducing position size // In single position mode, to close a position, you need to set size to 0 and close to true // In dual position mode, to close one side position, you need to set auto_size side, reduce_only to true and size to 0 -func (g *Gateio) PlaceFuturesOrder(ctx context.Context, arg *OrderCreateParams) (*Order, error) { +func (g *Gateio) PlaceFuturesOrder(ctx context.Context, arg *ContractOrderCreateParams) (*Order, error) { if arg == nil { return nil, errNilArgument } @@ -2352,7 +2352,7 @@ func (g *Gateio) CancelMultipleFuturesOpenOrders(ctx context.Context, contract c // In the returned result, the succeeded field of type bool indicates whether the execution was successful or not // If the execution is successful, the normal order content is included; if the execution fails, the label field is included to indicate the cause of the error // In the rate limiting, each order is counted individually -func (g *Gateio) PlaceBatchFuturesOrders(ctx context.Context, settle currency.Code, args []OrderCreateParams) ([]Order, error) { +func (g *Gateio) PlaceBatchFuturesOrders(ctx context.Context, settle currency.Code, args []ContractOrderCreateParams) ([]Order, error) { if settle.IsEmpty() { return nil, errEmptyOrInvalidSettlementCurrency } @@ -2840,7 +2840,7 @@ func (g *Gateio) UpdateDeliveryPositionRiskLimit(ctx context.Context, settle cur // PlaceDeliveryOrder create a futures order // Zero-filled order cannot be retrieved 10 minutes after order cancellation -func (g *Gateio) PlaceDeliveryOrder(ctx context.Context, arg *OrderCreateParams) (*Order, error) { +func (g *Gateio) PlaceDeliveryOrder(ctx context.Context, arg *ContractOrderCreateParams) (*Order, error) { if arg == nil { return nil, errNilArgument } diff --git a/exchanges/gateio/gateio_test.go b/exchanges/gateio/gateio_test.go index d5d4bfe6c98..6bc4a335bcc 100644 --- a/exchanges/gateio/gateio_test.go +++ b/exchanges/gateio/gateio_test.go @@ -308,7 +308,7 @@ func TestGetSpotAccounts(t *testing.T) { func TestCreateBatchOrders(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) - if _, err := g.CreateBatchOrders(context.Background(), []CreateOrderRequestData{ + if _, err := g.CreateBatchOrders(context.Background(), []CreateOrderRequest{ { CurrencyPair: getPair(t, asset.Spot), Side: "sell", @@ -353,7 +353,7 @@ func TestSpotClosePositionWhenCrossCurrencyDisabled(t *testing.T) { func TestCreateSpotOrder(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) - if _, err := g.PlaceSpotOrder(context.Background(), &CreateOrderRequestData{ + if _, err := g.PlaceSpotOrder(context.Background(), &CreateOrderRequest{ CurrencyPair: getPair(t, asset.Spot), Side: "buy", Amount: 1, @@ -368,7 +368,7 @@ func TestCreateSpotOrder(t *testing.T) { func TestGetSpotOrders(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - if _, err := g.GetSpotOrders(context.Background(), currency.Pair{Base: currency.BTC, Quote: currency.USDT, Delimiter: currency.UnderscoreDelimiter}, "open", 0, 0); err != nil { + if _, err := g.GetSpotOrders(context.Background(), currency.Pair{Base: currency.BTC, Quote: currency.USDT, Delimiter: currency.UnderscoreDelimiter}, statusOpen, 0, 0); err != nil { t.Errorf("%s GetSpotOrders() error %v", g.Name, err) } } @@ -490,7 +490,7 @@ func TestCreatePriceTriggeredOrder(t *testing.T) { func TestGetPriceTriggeredOrderList(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - if _, err := g.GetPriceTriggeredOrderList(context.Background(), "open", currency.EMPTYPAIR, asset.Empty, 0, 0); err != nil { + if _, err := g.GetPriceTriggeredOrderList(context.Background(), statusOpen, currency.EMPTYPAIR, asset.Empty, 0, 0); err != nil { t.Errorf("%s GetPriceTriggeredOrderList() error %v", g.Name, err) } } @@ -564,7 +564,7 @@ func TestMarginLoan(t *testing.T) { func TestGetMarginAllLoans(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - if _, err := g.GetMarginAllLoans(context.Background(), "open", "lend", "", currency.BTC, currency.Pair{Base: currency.BTC, Delimiter: currency.UnderscoreDelimiter, Quote: currency.USDT}, false, 0, 0); err != nil { + if _, err := g.GetMarginAllLoans(context.Background(), statusOpen, "lend", "", currency.BTC, currency.Pair{Base: currency.BTC, Delimiter: currency.UnderscoreDelimiter, Quote: currency.USDT}, false, 0, 0); err != nil { t.Errorf("%s GetMarginAllLoans() error %v", g.Name, err) } } @@ -1110,7 +1110,7 @@ func TestPlaceDeliveryOrder(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) settle, err := getSettlementFromCurrency(getPair(t, asset.DeliveryFutures)) require.NoError(t, err, "getSettlementFromCurrency must not error") - _, err = g.PlaceDeliveryOrder(context.Background(), &OrderCreateParams{ + _, err = g.PlaceDeliveryOrder(context.Background(), &ContractOrderCreateParams{ Contract: getPair(t, asset.DeliveryFutures), Size: 6024, Iceberg: 0, @@ -1127,7 +1127,7 @@ func TestGetDeliveryOrders(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, g) settle, err := getSettlementFromCurrency(getPair(t, asset.DeliveryFutures)) require.NoError(t, err, "getSettlementFromCurrency must not error") - _, err = g.GetDeliveryOrders(context.Background(), getPair(t, asset.DeliveryFutures), "open", settle, "", 0, 0, 1) + _, err = g.GetDeliveryOrders(context.Background(), getPair(t, asset.DeliveryFutures), statusOpen, settle, "", 0, 0, 1) assert.NoError(t, err, "GetDeliveryOrders should not error") } @@ -1205,7 +1205,7 @@ func TestGetDeliveryPriceTriggeredOrder(t *testing.T) { func TestGetDeliveryAllAutoOrder(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - _, err := g.GetDeliveryAllAutoOrder(context.Background(), "open", currency.USDT, getPair(t, asset.DeliveryFutures), 0, 1) + _, err := g.GetDeliveryAllAutoOrder(context.Background(), statusOpen, currency.USDT, getPair(t, asset.DeliveryFutures), 0, 1) assert.NoError(t, err, "GetDeliveryAllAutoOrder should not error") } @@ -1279,7 +1279,7 @@ func TestPlaceFuturesOrder(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) settle, err := getSettlementFromCurrency(getPair(t, asset.Futures)) require.NoError(t, err, "getSettlementFromCurrency must not error") - _, err = g.PlaceFuturesOrder(context.Background(), &OrderCreateParams{ + _, err = g.PlaceFuturesOrder(context.Background(), &ContractOrderCreateParams{ Contract: getPair(t, asset.Futures), Size: 6024, Iceberg: 0, @@ -1294,7 +1294,7 @@ func TestPlaceFuturesOrder(t *testing.T) { func TestGetFuturesOrders(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - _, err := g.GetFuturesOrders(context.Background(), currency.NewPair(currency.BTC, currency.USD), "open", "", currency.BTC, 0, 0, 1) + _, err := g.GetFuturesOrders(context.Background(), currency.NewPair(currency.BTC, currency.USD), statusOpen, "", currency.BTC, 0, 0, 1) assert.NoError(t, err, "GetFuturesOrders should not error") } @@ -1324,7 +1324,7 @@ func TestPlaceBatchFuturesOrders(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) settle, err := getSettlementFromCurrency(getPair(t, asset.Futures)) require.NoError(t, err, "getSettlementFromCurrency must not error") - _, err = g.PlaceBatchFuturesOrders(context.Background(), currency.BTC, []OrderCreateParams{ + _, err = g.PlaceBatchFuturesOrders(context.Background(), currency.BTC, []ContractOrderCreateParams{ { Contract: getPair(t, asset.Futures), Size: 6024, @@ -1431,7 +1431,7 @@ func TestCreatePriceTriggeredFuturesOrder(t *testing.T) { func TestListAllFuturesAutoOrders(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - _, err := g.ListAllFuturesAutoOrders(context.Background(), "open", currency.BTC, currency.EMPTYPAIR, 0, 0) + _, err := g.ListAllFuturesAutoOrders(context.Background(), statusOpen, currency.BTC, currency.EMPTYPAIR, 0, 0) assert.NoError(t, err, "ListAllFuturesAutoOrders should not error") } @@ -2611,7 +2611,7 @@ func TestFuturesPositionsNotification(t *testing.T) { } } -const wsFuturesAutoOrdersPushDataJSON = `{"time": 1596798126,"channel": "futures.autoorders", "event": "update", "error": null, "result": [ { "user": 123456, "trigger": { "strategy_type": 0, "price_type": 0, "price": "10000", "rule": 2, "expiration": 86400 }, "initial": { "contract": "BTC_USDT", "size": 10, "price": "10000", "tif": "gtc", "text": "web", "iceberg": 0, "is_close": false, "is_reduce_only": false }, "id": 9256, "trade_id": 0, "status": "open", "reason": "", "create_time": 1596798126, "name": "price_autoorders", "is_stop_order": false, "stop_trigger": { "rule": 0, "trigger_price": "", "order_price": "" } } ]}` +const wsFuturesAutoOrdersPushDataJSON = `{"time": 1596798126,"channel": "futures.autoorders", "event": "update", "error": null, "result": [ { "user": 123456, "trigger": { "strategy_type": 0, "price_type": 0, "price": "10000", "rule": 2, "expiration": 86400 }, "initial": { "contract": "BTC_USDT", "size": 10, "price": "10000", "tif": "gtc", "text": "web", "iceberg": 0, "is_close": false, "is_reduce_only": false }, "id": 9256, "trade_id": 0, "status": "OPEN", "reason": "", "create_time": 1596798126, "name": "price_autoorders", "is_stop_order": false, "stop_trigger": { "rule": 0, "trigger_price": "", "order_price": "" } } ]}` func TestFuturesAutoOrderPushData(t *testing.T) { t.Parallel() @@ -3451,7 +3451,7 @@ func TestProcessFuturesOrdersPushData(t *testing.T) { incoming string status order.Status }{ - {`{"channel":"futures.orders","event":"update","time":1541505434,"time_ms":1541505434123,"result":[{"contract":"BTC_USD","create_time":1628736847,"create_time_ms":1628736847325,"fill_price":40000.4,"finish_as":"","finish_time":1628736848,"finish_time_ms":1628736848321,"iceberg":0,"id":4872460,"is_close":false,"is_liq":false,"is_reduce_only":false,"left":0,"mkfr":-0.00025,"price":40000.4,"refr":0,"refu":0,"size":1,"status":"open","text":"-","tif":"gtc","tkfr":0.0005,"user":"110xxxxx"}]}`, order.Open}, + {`{"channel":"futures.orders","event":"update","time":1541505434,"time_ms":1541505434123,"result":[{"contract":"BTC_USD","create_time":1628736847,"create_time_ms":1628736847325,"fill_price":40000.4,"finish_as":"","finish_time":1628736848,"finish_time_ms":1628736848321,"iceberg":0,"id":4872460,"is_close":false,"is_liq":false,"is_reduce_only":false,"left":0,"mkfr":-0.00025,"price":40000.4,"refr":0,"refu":0,"size":1,"status":"OPEN","text":"-","tif":"gtc","tkfr":0.0005,"user":"110xxxxx"}]}`, order.Open}, {`{"channel":"futures.orders","event":"update","time":1541505434,"time_ms":1541505434123,"result":[{"contract":"BTC_USD","create_time":1628736847,"create_time_ms":1628736847325,"fill_price":40000.4,"finish_as":"filled","finish_time":1628736848,"finish_time_ms":1628736848321,"iceberg":0,"id":4872460,"is_close":false,"is_liq":false,"is_reduce_only":false,"left":0,"mkfr":-0.00025,"price":40000.4,"refr":0,"refu":0,"size":1,"status":"finished","text":"-","tif":"gtc","tkfr":0.0005,"user":"110xxxxx"}]}`, order.Filled}, {`{"channel":"futures.orders","event":"update","time":1541505434,"time_ms":1541505434123,"result":[{"contract":"BTC_USD","create_time":1628736847,"create_time_ms":1628736847325,"fill_price":40000.4,"finish_as":"cancelled","finish_time":1628736848,"finish_time_ms":1628736848321,"iceberg":0,"id":4872460,"is_close":false,"is_liq":false,"is_reduce_only":false,"left":0,"mkfr":-0.00025,"price":40000.4,"refr":0,"refu":0,"size":1,"status":"finished","text":"-","tif":"gtc","tkfr":0.0005,"user":"110xxxxx"}]}`, order.Cancelled}, {`{"channel":"futures.orders","event":"update","time":1541505434,"time_ms":1541505434123,"result":[{"contract":"BTC_USD","create_time":1628736847,"create_time_ms":1628736847325,"fill_price":40000.4,"finish_as":"liquidated","finish_time":1628736848,"finish_time_ms":1628736848321,"iceberg":0,"id":4872460,"is_close":false,"is_liq":false,"is_reduce_only":false,"left":0,"mkfr":-0.00025,"price":40000.4,"refr":0,"refu":0,"size":1,"status":"finished","text":"-","tif":"gtc","tkfr":0.0005,"user":"110xxxxx"}]}`, order.Liquidated}, diff --git a/exchanges/gateio/gateio_types.go b/exchanges/gateio/gateio_types.go index 0577f6d2d11..d8bd7976ea6 100644 --- a/exchanges/gateio/gateio_types.go +++ b/exchanges/gateio/gateio_types.go @@ -1372,8 +1372,8 @@ type SpotAccount struct { Locked types.Number `json:"locked"` } -// CreateOrderRequestData represents a single order creation param. -type CreateOrderRequestData struct { +// CreateOrderRequest represents a single order creation param. +type CreateOrderRequest struct { Text string `json:"text,omitempty"` CurrencyPair currency.Pair `json:"currency_pair,omitempty"` Type string `json:"type,omitempty"` @@ -1384,6 +1384,8 @@ type CreateOrderRequestData struct { Price types.Number `json:"price,omitempty"` TimeInForce string `json:"time_in_force,omitempty"` AutoBorrow bool `json:"auto_borrow,omitempty"` + AutoRepay bool `json:"auto_repay,omitempty"` + StpAct string `json:"stp_act,omitempty"` } // SpotOrder represents create order response. @@ -1811,18 +1813,19 @@ type DualModeResponse struct { } `json:"history"` } -// OrderCreateParams represents future order creation parameters -type OrderCreateParams struct { +// ContractOrderCreateParams represents future order creation parameters +type ContractOrderCreateParams struct { Contract currency.Pair `json:"contract"` - Size float64 `json:"size"` - Iceberg int64 `json:"iceberg"` - Price string `json:"price"` // NOTE: Market orders require string "0" + Size float64 `json:"size"` // positive long, negative short + Iceberg int64 `json:"iceberg"` // required; can be zero + Price string `json:"price"` // NOTE: Market orders require string "0" TimeInForce string `json:"tif"` Text string `json:"text,omitempty"` // Omitempty required as payload sent as `text:""` will return error message: Text content not starting with `t-`" ClosePosition bool `json:"close,omitempty"` // Size needs to be zero if true ReduceOnly bool `json:"reduce_only,omitempty"` - AutoSize string `json:"auto_size,omitempty"` - Settle currency.Code `json:"-"` // Used in URL. + AutoSize string `json:"auto_size,omitempty"` // either close_long or close_short, size needs to be zero. + Settle currency.Code `json:"-"` // Used in URL. REST Calls only. + StpAct string `json:"stp_act,omitempty"` } // Order represents future order response diff --git a/exchanges/gateio/gateio_websocket.go b/exchanges/gateio/gateio_websocket.go index ea53f12029a..540ae3d2366 100644 --- a/exchanges/gateio/gateio_websocket.go +++ b/exchanges/gateio/gateio_websocket.go @@ -24,6 +24,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/exchanges/kline" "github.com/thrasher-corp/gocryptotrader/exchanges/order" "github.com/thrasher-corp/gocryptotrader/exchanges/orderbook" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" "github.com/thrasher-corp/gocryptotrader/exchanges/stream" "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" "github.com/thrasher-corp/gocryptotrader/exchanges/ticker" @@ -97,11 +98,6 @@ func (g *Gateio) WsConnectSpot(ctx context.Context, conn stream.Connection) erro return nil } -// authenticateSpot sends an authentication message to the websocket connection -func (g *Gateio) authenticateSpot(ctx context.Context, conn stream.Connection) error { - return g.websocketLogin(ctx, conn, "spot.login") -} - // websocketLogin authenticates the websocket connection func (g *Gateio) websocketLogin(ctx context.Context, conn stream.Connection, channel string) error { if conn == nil { @@ -776,3 +772,72 @@ func (g *Gateio) handleSubscription(ctx context.Context, conn stream.Connection, } return errs } + +// funnelResult is used to unmarshal the result of a websocket request back to the required caller type +type funnelResult struct { + Result any `json:"result"` +} + +// SendWebsocketRequest sends a websocket request to the exchange +func (g *Gateio) SendWebsocketRequest(ctx context.Context, epl request.EndpointLimit, channel string, connSignature, params, result any, expectedResponses int) error { + paramPayload, err := json.Marshal(params) + if err != nil { + return err + } + + conn, err := g.Websocket.GetConnection(connSignature) + if err != nil { + return err + } + + tn := time.Now().Unix() + req := &WebsocketRequest{ + Time: tn, + Channel: channel, + Event: "api", + Payload: WebsocketPayload{ + // This request ID associated with the payload is the match to the + // response. + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), + RequestParam: paramPayload, + Timestamp: strconv.FormatInt(tn, 10), + }, + } + + responses, err := conn.SendMessageReturnResponsesWithInspector(ctx, epl, req.Payload.RequestID, req, expectedResponses, wsRespAckInspector{}) + if err != nil { + return err + } + + if len(responses) == 0 { + return common.ErrNoResponse + } + + var inbound WebsocketAPIResponse + // The last response is the one we want to unmarshal, the other is just + // an ack. If the request fails on the ACK then we can unmarshal the error + // from that as the next response won't come anyway. + endResponse := responses[len(responses)-1] + + if err := json.Unmarshal(endResponse, &inbound); err != nil { + return err + } + + if inbound.Header.Status != "200" { + var wsErr WebsocketErrors + if err := json.Unmarshal(inbound.Data, &wsErr); err != nil { + return err + } + return fmt.Errorf("%s: %s", wsErr.Errors.Label, wsErr.Errors.Message) + } + + return json.Unmarshal(inbound.Data, &funnelResult{Result: result}) +} + +type wsRespAckInspector struct{} + +// IsFinal checks the payload for an ack, it returns true if the payload does not contain an ack. +// This will force the cancellation of further waiting for responses. +func (wsRespAckInspector) IsFinal(data []byte) bool { + return !strings.Contains(string(data), "ack") +} diff --git a/exchanges/gateio/gateio_websocket_futures.go b/exchanges/gateio/gateio_websocket_futures.go index ba393277553..ad4cf79785e 100644 --- a/exchanges/gateio/gateio_websocket_futures.go +++ b/exchanges/gateio/gateio_websocket_futures.go @@ -150,16 +150,16 @@ func (g *Gateio) FuturesUnsubscribe(ctx context.Context, conn stream.Connection, // WsHandleFuturesData handles futures websocket data func (g *Gateio) WsHandleFuturesData(_ context.Context, respRaw []byte, a asset.Item) error { var push WsResponse - err := json.Unmarshal(respRaw, &push) - if err != nil { + if err := json.Unmarshal(respRaw, &push); err != nil { return err } + if push.RequestID != "" { + return g.Websocket.Match.RequireMatchWithData(push.RequestID, respRaw) + } + if push.Event == subscribeEvent || push.Event == unsubscribeEvent { - if !g.Websocket.Match.IncomingWithData(push.ID, respRaw) { - return fmt.Errorf("couldn't match subscription message with ID: %d", push.ID) - } - return nil + return g.Websocket.Match.RequireMatchWithData(push.ID, respRaw) } switch push.Channel { @@ -176,8 +176,7 @@ func (g *Gateio) WsHandleFuturesData(_ context.Context, respRaw []byte, a asset. case futuresCandlesticksChannel: return g.processFuturesCandlesticks(respRaw, a) case futuresOrdersChannel: - var processed []order.Detail - processed, err = g.processFuturesOrdersPushData(respRaw, a) + processed, err := g.processFuturesOrdersPushData(respRaw, a) if err != nil { return err } diff --git a/exchanges/gateio/gateio_websocket_option.go b/exchanges/gateio/gateio_websocket_option.go index 091a9ad1f0f..28b9d652904 100644 --- a/exchanges/gateio/gateio_websocket_option.go +++ b/exchanges/gateio/gateio_websocket_option.go @@ -295,16 +295,12 @@ func (g *Gateio) OptionsUnsubscribe(ctx context.Context, conn stream.Connection, // WsHandleOptionsData handles options websocket data func (g *Gateio) WsHandleOptionsData(_ context.Context, respRaw []byte) error { var push WsResponse - err := json.Unmarshal(respRaw, &push) - if err != nil { + if err := json.Unmarshal(respRaw, &push); err != nil { return err } if push.Event == subscribeEvent || push.Event == unsubscribeEvent { - if !g.Websocket.Match.IncomingWithData(push.ID, respRaw) { - return fmt.Errorf("couldn't match subscription message with ID: %d", push.ID) - } - return nil + return g.Websocket.Match.RequireMatchWithData(push.ID, respRaw) } switch push.Channel { diff --git a/exchanges/gateio/gateio_websocket_request_futures.go b/exchanges/gateio/gateio_websocket_request_futures.go new file mode 100644 index 00000000000..820bab01398 --- /dev/null +++ b/exchanges/gateio/gateio_websocket_request_futures.go @@ -0,0 +1,206 @@ +package gateio + +import ( + "context" + "errors" + "fmt" + + "github.com/thrasher-corp/gocryptotrader/common" + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/exchanges/stream" +) + +var ( + errInvalidAutoSize = errors.New("invalid auto size") + errSettlementCurrencyConflict = errors.New("settlement currency conflict") + errInvalidSide = errors.New("invalid side") + errStatusNotSet = errors.New("status not set") +) + +// authenticateFutures sends an authentication message to the websocket connection +func (g *Gateio) authenticateFutures(ctx context.Context, conn stream.Connection) error { + return g.websocketLogin(ctx, conn, "futures.login") +} + +// WebsocketFuturesSubmitOrder submits an order via the websocket connection +func (g *Gateio) WebsocketFuturesSubmitOrder(ctx context.Context, order *ContractOrderCreateParams) ([]WebsocketFuturesOrderResponse, error) { + return g.WebsocketFuturesSubmitOrders(ctx, []ContractOrderCreateParams{*order}) +} + +// WebsocketFuturesSubmitOrders places an order via the websocket connection. You can +// send multiple orders in a single request. NOTE: When sending multiple orders +// the response will be an array of responses and a succeeded bool will be +// returned in the response. +func (g *Gateio) WebsocketFuturesSubmitOrders(ctx context.Context, orders []ContractOrderCreateParams) ([]WebsocketFuturesOrderResponse, error) { + if len(orders) == 0 { + return nil, errOrdersEmpty + } + + var a asset.Item + for i := range orders { + if orders[i].Contract.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + + if orders[i].Price == "" && orders[i].TimeInForce != "ioc" { + return nil, fmt.Errorf("%w: cannot be zero when time in force is not IOC", errInvalidPrice) + } + + if orders[i].Size == 0 && orders[i].AutoSize == "" { + return nil, fmt.Errorf("%w: size cannot be zero", errInvalidAmount) + } + + if orders[i].AutoSize != "" { + if orders[i].AutoSize != "close_long" && orders[i].AutoSize != "close_short" { + return nil, fmt.Errorf("%w: %s", errInvalidAutoSize, orders[i].AutoSize) + } + if orders[i].Size != 0 { + return nil, fmt.Errorf("%w: size needs to be zero when auto size is set", errInvalidAmount) + } + } + + switch { + case orders[i].Contract.Quote.Equal(currency.USDT): + if a != asset.Empty && a != asset.USDTMarginedFutures { + return nil, fmt.Errorf("%w: either btc or usdt margined can only be batched as they are using different connections", errSettlementCurrencyConflict) + } + a = asset.USDTMarginedFutures + case orders[i].Contract.Quote.Equal(currency.USD): + if a != asset.Empty && a != asset.CoinMarginedFutures { + return nil, fmt.Errorf("%w: either btc or usdt margined can only be batched as they are using different connections", errSettlementCurrencyConflict) + } + a = asset.CoinMarginedFutures + } + } + + if len(orders) == 1 { + var singleResponse WebsocketFuturesOrderResponse + err := g.SendWebsocketRequest(ctx, perpetualSubmitOrderEPL, "futures.order_place", a, orders[0], &singleResponse, 2) + return []WebsocketFuturesOrderResponse{singleResponse}, err + } + + var resp []WebsocketFuturesOrderResponse + return resp, g.SendWebsocketRequest(ctx, perpetualSubmitBatchOrdersEPL, "futures.order_batch_place", a, orders, &resp, 2) +} + +// WebsocketFuturesCancelOrder cancels an order via the websocket connection. +func (g *Gateio) WebsocketFuturesCancelOrder(ctx context.Context, orderID string, contract currency.Pair) (*WebsocketFuturesOrderResponse, error) { + if orderID == "" { + return nil, order.ErrOrderIDNotSet + } + + if contract.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + + a := asset.USDTMarginedFutures + if contract.Quote.Equal(currency.USD) { + a = asset.CoinMarginedFutures + } + + params := &struct { + OrderID string `json:"order_id"` + }{OrderID: orderID} + + var resp WebsocketFuturesOrderResponse + return &resp, g.SendWebsocketRequest(ctx, perpetualCancelOrderEPL, "futures.order_cancel", a, params, &resp, 1) +} + +// WebsocketFuturesCancelAllOpenFuturesOrders cancels multiple orders via the websocket. +func (g *Gateio) WebsocketFuturesCancelAllOpenFuturesOrders(ctx context.Context, contract currency.Pair, side string) ([]WebsocketFuturesOrderResponse, error) { + if contract.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + + if side != "" && side != "ask" && side != "bid" { + return nil, fmt.Errorf("%w: %s", errInvalidSide, side) + } + + params := struct { + Contract currency.Pair `json:"contract"` + Side string `json:"side,omitempty"` + }{Contract: contract, Side: side} + + a := asset.USDTMarginedFutures + if contract.Quote.Equal(currency.USD) { + a = asset.CoinMarginedFutures + } + + var resp []WebsocketFuturesOrderResponse + return resp, g.SendWebsocketRequest(ctx, perpetualCancelOpenOrdersEPL, "futures.order_cancel_cp", a, params, &resp, 2) +} + +// WebsocketFuturesAmendOrder amends an order via the websocket connection +func (g *Gateio) WebsocketFuturesAmendOrder(ctx context.Context, amend *WebsocketFuturesAmendOrder) (*WebsocketFuturesOrderResponse, error) { + if amend == nil { + return nil, fmt.Errorf("%w: %T", common.ErrNilPointer, amend) + } + + if amend.OrderID == "" { + return nil, order.ErrOrderIDNotSet + } + + if amend.Contract.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + + if amend.Size == 0 && amend.Price == "" { + return nil, fmt.Errorf("%w: size or price must be set", errInvalidAmount) + } + + a := asset.USDTMarginedFutures + if amend.Contract.Quote.Equal(currency.USD) { + a = asset.CoinMarginedFutures + } + + var resp WebsocketFuturesOrderResponse + return &resp, g.SendWebsocketRequest(ctx, perpetualAmendOrderEPL, "futures.order_amend", a, amend, &resp, 1) +} + +// WebsocketFuturesOrderList fetches a list of orders via the websocket connection +func (g *Gateio) WebsocketFuturesOrderList(ctx context.Context, list *WebsocketFutureOrdersList) ([]WebsocketFuturesOrderResponse, error) { + if list == nil { + return nil, fmt.Errorf("%w: %T", common.ErrNilPointer, list) + } + + if list.Contract.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + + if list.Status == "" { + return nil, errStatusNotSet + } + + a := asset.USDTMarginedFutures + if list.Contract.Quote.Equal(currency.USD) { + a = asset.CoinMarginedFutures + } + + var resp []WebsocketFuturesOrderResponse + return resp, g.SendWebsocketRequest(ctx, perpetualGetOrdersEPL, "futures.order_list", a, list, &resp, 1) +} + +// WebsocketFuturesGetOrderStatus gets the status of an order via the websocket connection. +func (g *Gateio) WebsocketFuturesGetOrderStatus(ctx context.Context, contract currency.Pair, orderID string) (*WebsocketFuturesOrderResponse, error) { + if contract.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + + if orderID == "" { + return nil, order.ErrOrderIDNotSet + } + + params := &struct { + OrderID string `json:"order_id"` + }{OrderID: orderID} + + a := asset.USDTMarginedFutures + if contract.Quote.Equal(currency.USD) { + a = asset.CoinMarginedFutures + } + + var resp WebsocketFuturesOrderResponse + return &resp, g.SendWebsocketRequest(ctx, perpetualFetchOrderEPL, "futures.order_status", a, params, &resp, 1) +} diff --git a/exchanges/gateio/gateio_websocket_request_futures_test.go b/exchanges/gateio/gateio_websocket_request_futures_test.go new file mode 100644 index 00000000000..a06b6d8c718 --- /dev/null +++ b/exchanges/gateio/gateio_websocket_request_futures_test.go @@ -0,0 +1,220 @@ +package gateio + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/common" + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" + testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" +) + +func TestWebsocketFuturesSubmitOrder(t *testing.T) { + t.Parallel() + _, err := g.WebsocketFuturesSubmitOrder(context.Background(), &ContractOrderCreateParams{}) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + out := &ContractOrderCreateParams{Contract: currency.NewBTCUSDT()} + _, err = g.WebsocketFuturesSubmitOrder(context.Background(), out) + require.ErrorIs(t, err, errInvalidPrice) + out.Price = "40000" + _, err = g.WebsocketFuturesSubmitOrder(context.Background(), out) + require.ErrorIs(t, err, errInvalidAmount) + out.Size = 1 // 1 lovely long contract + out.AutoSize = "silly_billies" + _, err = g.WebsocketFuturesSubmitOrder(context.Background(), out) + require.ErrorIs(t, err, errInvalidAutoSize) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + got, err := g.WebsocketFuturesSubmitOrder(context.Background(), out) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketFuturesSubmitOrders(t *testing.T) { + t.Parallel() + _, err := g.WebsocketFuturesSubmitOrders(context.Background(), nil) + require.ErrorIs(t, err, errOrdersEmpty) + + out := ContractOrderCreateParams{} + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), []ContractOrderCreateParams{out}) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + out.Contract, err = currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), []ContractOrderCreateParams{out}) + require.ErrorIs(t, err, errInvalidPrice) + + out.Price = "40000" + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), []ContractOrderCreateParams{out}) + require.ErrorIs(t, err, errInvalidAmount) + + out.Size = 1 // 1 lovely long contract + out.AutoSize = "silly_billies" + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), []ContractOrderCreateParams{out}) + require.ErrorIs(t, err, errInvalidAutoSize) + + out.AutoSize = "close_long" + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), []ContractOrderCreateParams{out}) + require.ErrorIs(t, err, errInvalidAmount) + + out.AutoSize = "" + outBad := out + outBad.Contract, err = currency.NewPairFromString("BTC_USD") + require.NoError(t, err) + + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), []ContractOrderCreateParams{out, outBad}) + require.ErrorIs(t, err, errSettlementCurrencyConflict) + + outBad.Contract, out.Contract = out.Contract, outBad.Contract // swapsies + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), []ContractOrderCreateParams{out, outBad}) + require.ErrorIs(t, err, errSettlementCurrencyConflict) + + outBad.Contract, out.Contract = out.Contract, outBad.Contract // swapsies back + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + // test single order + got, err := g.WebsocketFuturesSubmitOrders(context.Background(), []ContractOrderCreateParams{out}) + require.NoError(t, err) + require.NotEmpty(t, got) + + // test batch orders + got, err = g.WebsocketFuturesSubmitOrders(context.Background(), []ContractOrderCreateParams{out, out}) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketFuturesCancelOrder(t *testing.T) { + t.Parallel() + + _, err := g.WebsocketFuturesCancelOrder(context.Background(), "", currency.EMPTYPAIR) + require.ErrorIs(t, err, order.ErrOrderIDNotSet) + + _, err = g.WebsocketFuturesCancelOrder(context.Background(), "42069", currency.EMPTYPAIR) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + pair, err := currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + + got, err := g.WebsocketFuturesCancelOrder(context.Background(), "513160761072", pair) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketFuturesCancelAllOpenFuturesOrders(t *testing.T) { + t.Parallel() + _, err := g.WebsocketFuturesCancelAllOpenFuturesOrders(context.Background(), currency.EMPTYPAIR, "") + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + pair, err := currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + _, err = g.WebsocketFuturesCancelAllOpenFuturesOrders(context.Background(), pair, "bruh") + require.ErrorIs(t, err, errInvalidSide) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + got, err := g.WebsocketFuturesCancelAllOpenFuturesOrders(context.Background(), pair, "bid") + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketFuturesAmendOrder(t *testing.T) { + t.Parallel() + + _, err := g.WebsocketFuturesAmendOrder(context.Background(), nil) + require.ErrorIs(t, err, common.ErrNilPointer) + + amend := &WebsocketFuturesAmendOrder{} + _, err = g.WebsocketFuturesAmendOrder(context.Background(), amend) + require.ErrorIs(t, err, order.ErrOrderIDNotSet) + + amend.OrderID = "1337" + _, err = g.WebsocketFuturesAmendOrder(context.Background(), amend) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + amend.Contract, err = currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + + _, err = g.WebsocketFuturesAmendOrder(context.Background(), amend) + require.ErrorIs(t, err, errInvalidAmount) + + amend.Size = 2 + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + amend.OrderID = "513170215869" + got, err := g.WebsocketFuturesAmendOrder(context.Background(), amend) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketFuturesOrderList(t *testing.T) { + t.Parallel() + + _, err := g.WebsocketFuturesOrderList(context.Background(), nil) + require.ErrorIs(t, err, common.ErrNilPointer) + + list := &WebsocketFutureOrdersList{} + _, err = g.WebsocketFuturesOrderList(context.Background(), list) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + list.Contract, err = currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + + _, err = g.WebsocketFuturesOrderList(context.Background(), list) + require.ErrorIs(t, err, errStatusNotSet) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + list.Status = statusOpen + got, err := g.WebsocketFuturesOrderList(context.Background(), list) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketFuturesGetOrderStatus(t *testing.T) { + t.Parallel() + + _, err := g.WebsocketFuturesGetOrderStatus(context.Background(), currency.EMPTYPAIR, "") + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + pair, err := currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + + _, err = g.WebsocketFuturesGetOrderStatus(context.Background(), pair, "") + require.ErrorIs(t, err, order.ErrOrderIDNotSet) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + got, err := g.WebsocketFuturesGetOrderStatus(context.Background(), pair, "513170215869") + require.NoError(t, err) + require.NotEmpty(t, got) +} diff --git a/exchanges/gateio/gateio_websocket_request_spot.go b/exchanges/gateio/gateio_websocket_request_spot.go index 25ac1ea1689..68e35d16d29 100644 --- a/exchanges/gateio/gateio_websocket_request_spot.go +++ b/exchanges/gateio/gateio_websocket_request_spot.go @@ -2,18 +2,15 @@ package gateio import ( "context" - "encoding/json" "errors" "fmt" "strconv" - "strings" - "time" "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" "github.com/thrasher-corp/gocryptotrader/exchanges/order" - "github.com/thrasher-corp/gocryptotrader/exchanges/request" + "github.com/thrasher-corp/gocryptotrader/exchanges/stream" ) var ( @@ -22,14 +19,19 @@ var ( errChannelEmpty = errors.New("channel cannot be empty") ) +// authenticateSpot sends an authentication message to the websocket connection +func (g *Gateio) authenticateSpot(ctx context.Context, conn stream.Connection) error { + return g.websocketLogin(ctx, conn, "spot.login") +} + // WebsocketSpotSubmitOrder submits an order via the websocket connection -func (g *Gateio) WebsocketSpotSubmitOrder(ctx context.Context, order *WebsocketOrder) ([]WebsocketOrderResponse, error) { - return g.WebsocketSpotSubmitOrders(ctx, []WebsocketOrder{*order}) +func (g *Gateio) WebsocketSpotSubmitOrder(ctx context.Context, order *CreateOrderRequest) ([]WebsocketOrderResponse, error) { + return g.WebsocketSpotSubmitOrders(ctx, []CreateOrderRequest{*order}) } // WebsocketSpotSubmitOrders submits orders via the websocket connection. You can // send multiple orders in a single request. But only for one asset route. -func (g *Gateio) WebsocketSpotSubmitOrders(ctx context.Context, orders []WebsocketOrder) ([]WebsocketOrderResponse, error) { +func (g *Gateio) WebsocketSpotSubmitOrders(ctx context.Context, orders []CreateOrderRequest) ([]WebsocketOrderResponse, error) { if len(orders) == 0 { return nil, errOrdersEmpty } @@ -39,16 +41,16 @@ func (g *Gateio) WebsocketSpotSubmitOrders(ctx context.Context, orders []Websock // API requires Text field, or it will be rejected orders[i].Text = "t-" + strconv.FormatInt(g.Counter.IncrementAndGet(), 10) } - if orders[i].CurrencyPair == "" { + if orders[i].CurrencyPair.IsEmpty() { return nil, currency.ErrCurrencyPairEmpty } if orders[i].Side == "" { return nil, order.ErrSideIsInvalid } - if orders[i].Amount == "" { + if orders[i].Amount == 0 { return nil, errInvalidAmount } - if orders[i].Type == "limit" && orders[i].Price == "" { + if orders[i].Type == "limit" && orders[i].Price == 0 { return nil, errInvalidPrice } } @@ -153,72 +155,3 @@ func (g *Gateio) WebsocketSpotGetOrderStatus(ctx context.Context, orderID string var resp WebsocketOrderResponse return &resp, g.SendWebsocketRequest(ctx, spotGetOrdersEPL, "spot.order_status", asset.Spot, params, &resp, 1) } - -// funnelResult is used to unmarshal the result of a websocket request back to the required caller type -type funnelResult struct { - Result any `json:"result"` -} - -// SendWebsocketRequest sends a websocket request to the exchange -func (g *Gateio) SendWebsocketRequest(ctx context.Context, epl request.EndpointLimit, channel string, connSignature, params, result any, expectedResponses int) error { - paramPayload, err := json.Marshal(params) - if err != nil { - return err - } - - conn, err := g.Websocket.GetConnection(connSignature) - if err != nil { - return err - } - - tn := time.Now().Unix() - req := &WebsocketRequest{ - Time: tn, - Channel: channel, - Event: "api", - Payload: WebsocketPayload{ - // This request ID associated with the payload is the match to the - // response. - RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), - RequestParam: paramPayload, - Timestamp: strconv.FormatInt(tn, 10), - }, - } - - responses, err := conn.SendMessageReturnResponsesWithInspector(ctx, epl, req.Payload.RequestID, req, expectedResponses, wsRespAckInspector{}) - if err != nil { - return err - } - - if len(responses) == 0 { - return common.ErrNoResponse - } - - var inbound WebsocketAPIResponse - // The last response is the one we want to unmarshal, the other is just - // an ack. If the request fails on the ACK then we can unmarshal the error - // from that as the next response won't come anyway. - endResponse := responses[len(responses)-1] - - if err := json.Unmarshal(endResponse, &inbound); err != nil { - return err - } - - if inbound.Header.Status != "200" { - var wsErr WebsocketErrors - if err := json.Unmarshal(inbound.Data, &wsErr); err != nil { - return err - } - return fmt.Errorf("%s: %s", wsErr.Errors.Label, wsErr.Errors.Message) - } - - return json.Unmarshal(inbound.Data, &funnelResult{Result: result}) -} - -type wsRespAckInspector struct{} - -// IsFinal checks the payload for an ack, it returns true if the payload does not contain an ack. -// This will force the cancellation of further waiting for responses. -func (wsRespAckInspector) IsFinal(data []byte) bool { - return !strings.Contains(string(data), "ack") -} diff --git a/exchanges/gateio/gateio_websocket_request_spot_test.go b/exchanges/gateio/gateio_websocket_request_spot_test.go index 7933c117294..e3237c83ea4 100644 --- a/exchanges/gateio/gateio_websocket_request_spot_test.go +++ b/exchanges/gateio/gateio_websocket_request_spot_test.go @@ -38,19 +38,19 @@ func TestWebsocketLogin(t *testing.T) { func TestWebsocketSpotSubmitOrder(t *testing.T) { t.Parallel() - _, err := g.WebsocketSpotSubmitOrder(context.Background(), &WebsocketOrder{}) + _, err := g.WebsocketSpotSubmitOrder(context.Background(), &CreateOrderRequest{}) require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) - out := &WebsocketOrder{CurrencyPair: "BTC_USDT"} + out := &CreateOrderRequest{CurrencyPair: currency.NewBTCUSDT()} _, err = g.WebsocketSpotSubmitOrder(context.Background(), out) require.ErrorIs(t, err, order.ErrSideIsInvalid) out.Side = strings.ToLower(order.Buy.String()) _, err = g.WebsocketSpotSubmitOrder(context.Background(), out) require.ErrorIs(t, err, errInvalidAmount) - out.Amount = "0.0003" + out.Amount = 0.0003 out.Type = "limit" _, err = g.WebsocketSpotSubmitOrder(context.Background(), out) require.ErrorIs(t, err, errInvalidPrice) - out.Price = "20000" + out.Price = 20000 sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) @@ -66,19 +66,19 @@ func TestWebsocketSpotSubmitOrders(t *testing.T) { t.Parallel() _, err := g.WebsocketSpotSubmitOrders(context.Background(), nil) require.ErrorIs(t, err, errOrdersEmpty) - _, err = g.WebsocketSpotSubmitOrders(context.Background(), make([]WebsocketOrder, 1)) + _, err = g.WebsocketSpotSubmitOrders(context.Background(), make([]CreateOrderRequest, 1)) require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) - out := WebsocketOrder{CurrencyPair: "BTC_USDT"} - _, err = g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out}) + out := CreateOrderRequest{CurrencyPair: currency.NewBTCUSDT()} + _, err = g.WebsocketSpotSubmitOrders(context.Background(), []CreateOrderRequest{out}) require.ErrorIs(t, err, order.ErrSideIsInvalid) out.Side = strings.ToLower(order.Buy.String()) - _, err = g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out}) + _, err = g.WebsocketSpotSubmitOrders(context.Background(), []CreateOrderRequest{out}) require.ErrorIs(t, err, errInvalidAmount) - out.Amount = "0.0003" + out.Amount = 0.0003 out.Type = "limit" - _, err = g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out}) + _, err = g.WebsocketSpotSubmitOrders(context.Background(), []CreateOrderRequest{out}) require.ErrorIs(t, err, errInvalidPrice) - out.Price = "20000" + out.Price = 20000 sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) @@ -86,12 +86,12 @@ func TestWebsocketSpotSubmitOrders(t *testing.T) { g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes // test single order - got, err := g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out}) + got, err := g.WebsocketSpotSubmitOrders(context.Background(), []CreateOrderRequest{out}) require.NoError(t, err) require.NotEmpty(t, got) // test batch orders - got, err = g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out, out}) + got, err = g.WebsocketSpotSubmitOrders(context.Background(), []CreateOrderRequest{out, out}) require.NoError(t, err) require.NotEmpty(t, got) } @@ -231,15 +231,31 @@ func getWebsocketInstance(t *testing.T, g *Gateio) *Gateio { require.NoError(t, cpy.Setup(gConf), "Test instance Setup must not error") cpy.CurrencyPairs.Load(&g.CurrencyPairs) +assetLoader: for _, a := range cpy.GetAssetTypes(true) { - if a != asset.Spot { + var avail currency.Pairs + switch a { + case asset.Spot: + avail, err = cpy.GetAvailablePairs(a) + require.NoError(t, err) + if len(avail) > 1 { // reduce pairs to 1 to speed up tests + avail = avail[:1] + } + case asset.Futures: + avail, err = cpy.GetAvailablePairs(a) + require.NoError(t, err) + usdtPairs, err := avail.GetPairsByQuote(currency.USDT) // Get USDT margin pairs + require.NoError(t, err) + btcPairs, err := avail.GetPairsByQuote(currency.USD) // Get BTC margin pairs + require.NoError(t, err) + // below makes sure there is both a USDT and BTC pair available + // so that allows two connections to be made. + avail[0] = usdtPairs[0] + avail[1] = btcPairs[0] + avail = avail[:2] + default: require.NoError(t, cpy.CurrencyPairs.SetAssetEnabled(a, false)) - continue - } - avail, err := cpy.GetAvailablePairs(a) - require.NoError(t, err) - if len(avail) > 1 { - avail = avail[:1] + continue assetLoader } require.NoError(t, cpy.SetPairs(avail, a, true)) } diff --git a/exchanges/gateio/gateio_websocket_request_types.go b/exchanges/gateio/gateio_websocket_request_types.go index 165eea41cba..125a8f72963 100644 --- a/exchanges/gateio/gateio_websocket_request_types.go +++ b/exchanges/gateio/gateio_websocket_request_types.go @@ -52,22 +52,6 @@ type WebsocketErrors struct { } `json:"errs"` } -// WebsocketOrder defines a websocket order -type WebsocketOrder struct { - Text string `json:"text"` - CurrencyPair string `json:"currency_pair,omitempty"` - Type string `json:"type,omitempty"` - Account string `json:"account,omitempty"` - Side string `json:"side,omitempty"` - Amount string `json:"amount,omitempty"` - Price string `json:"price,omitempty"` - TimeInForce string `json:"time_in_force,omitempty"` - Iceberg string `json:"iceberg,omitempty"` - AutoBorrow bool `json:"auto_borrow,omitempty"` - AutoRepay bool `json:"auto_repay,omitempty"` - StpAct string `json:"stp_act,omitempty"` -} - // WebsocketOrderResponse defines a websocket order response type WebsocketOrderResponse struct { Left types.Number `json:"left"` @@ -103,9 +87,31 @@ type WebsocketOrderResponse struct { STPAct string `json:"stp_act"` } +// WebsocketFuturesOrderResponse defines a websocket futures order response +type WebsocketFuturesOrderResponse struct { + Text string `json:"text"` + Price types.Number `json:"price"` + BizInfo string `json:"biz_info"` + TimeInForce string `json:"tif"` + AmendText string `json:"amend_text"` + Status string `json:"status"` + Contract currency.Pair `json:"contract"` + STPAct string `json:"stp_act"` + FinishAs string `json:"finish_as"` + FillPrice types.Number `json:"fill_price"` + ID int64 `json:"id"` + CreateTime types.Time `json:"create_time"` + UpdateTime types.Time `json:"update_time"` + FinishTime types.Time `json:"finish_time"` + Size int64 `json:"size"` + Left int64 `json:"left"` + User int64 `json:"user"` + Succeeded *bool `json:"succeeded"` // Nil if not present in returned response. +} + // WebsocketOrderBatchRequest defines a websocket order batch request type WebsocketOrderBatchRequest struct { - OrderID string `json:"id"` // This require id tag not order_id + OrderID string `json:"id"` // This requires id tag not order_id Pair currency.Pair `json:"currency_pair"` Account string `json:"account,omitempty"` } @@ -141,3 +147,21 @@ type WebsocketAmendOrder struct { Price string `json:"price,omitempty"` Amount string `json:"amount,omitempty"` } + +// WebsocketFuturesAmendOrder defines a websocket amend order +type WebsocketFuturesAmendOrder struct { + OrderID string `json:"order_id"` + Contract currency.Pair `json:"-"` // This is not required in the payload, it is used to determine the asset type. + AmendText string `json:"amend_text,omitempty"` + Price string `json:"price,omitempty"` + Size int64 `json:"size,omitempty"` +} + +// WebsocketFutureOrdersList defines a websocket future orders list +type WebsocketFutureOrdersList struct { + Contract currency.Pair `json:"contract,omitempty"` + Status string `json:"status"` + Limit int64 `json:"limit,omitempty"` + Offset int64 `json:"offset,omitempty"` + LastID string `json:"last_id,omitempty"` +} diff --git a/exchanges/gateio/gateio_wrapper.go b/exchanges/gateio/gateio_wrapper.go index 9d9929f6e56..a017442c18f 100644 --- a/exchanges/gateio/gateio_wrapper.go +++ b/exchanges/gateio/gateio_wrapper.go @@ -40,6 +40,8 @@ import ( // this error. const unfundedFuturesAccount = `please transfer funds first to create futures account` +var errNoResponseReceived = errors.New("no response received") + // SetDefaults sets default values for the exchange func (g *Gateio) SetDefaults() { g.Name = "GateIO" @@ -237,6 +239,7 @@ func (g *Gateio) Setup(exch *config.Exchange) error { Unsubscriber: g.FuturesUnsubscribe, GenerateSubscriptions: func() (subscription.List, error) { return g.GenerateFuturesDefaultSubscriptions(currency.USDT) }, Connector: g.WsFuturesConnect, + Authenticate: g.authenticateFutures, MessageFilter: asset.USDTMarginedFutures, BespokeGenerateMessageID: g.GenerateWebsocketMessageID, }) @@ -1064,34 +1067,14 @@ func (g *Gateio) SubmitOrder(ctx context.Context, s *order.Submit) (*order.Submi return nil, err } s.Pair = s.Pair.Upper() + switch s.AssetType { case asset.Spot, asset.Margin, asset.CrossMargin: - switch { - case s.Side.IsLong(): - s.Side = order.Buy - case s.Side.IsShort(): - s.Side = order.Sell - default: - return nil, errInvalidOrderSide - } - timeInForce, err := getTimeInForce(s) + req, err := g.getSpotOrderRequest(s) if err != nil { return nil, err } - - sOrder, err := g.PlaceSpotOrder(ctx, &CreateOrderRequestData{ - Side: s.Side.Lower(), - Type: s.Type.Lower(), - Account: g.assetTypeToString(s.AssetType), - // When doing spot market orders when purchasing base currency, the - // quote currency amount is used. When selling the base currency the - // base currency amount is used. - Amount: types.Number(s.GetTradeAmount(g.GetTradingRequirements())), - Price: types.Number(s.Price), - CurrencyPair: s.Pair, - Text: s.ClientOrderID, - TimeInForce: timeInForce, - }) + sOrder, err := g.PlaceSpotOrder(ctx, req) if err != nil { return nil, err } @@ -1136,7 +1119,7 @@ func (g *Gateio) SubmitOrder(ctx context.Context, s *order.Submit) (*order.Submi if err != nil { return nil, err } - fOrder, err := g.PlaceFuturesOrder(ctx, &OrderCreateParams{ + fOrder, err := g.PlaceFuturesOrder(ctx, &ContractOrderCreateParams{ Contract: s.Pair, Size: amountWithDirection, Price: strconv.FormatFloat(s.Price, 'f', -1, 64), // Cannot be an empty string, requires "0" for market orders. @@ -1152,7 +1135,7 @@ func (g *Gateio) SubmitOrder(ctx context.Context, s *order.Submit) (*order.Submi return nil, err } var status = order.Open - if fOrder.Status != "open" { + if fOrder.Status != statusOpen { status, err = order.StringToOrderStatus(fOrder.FinishAs) if err != nil { return nil, err @@ -1182,7 +1165,7 @@ func (g *Gateio) SubmitOrder(ctx context.Context, s *order.Submit) (*order.Submi if err != nil { return nil, err } - newOrder, err := g.PlaceDeliveryOrder(ctx, &OrderCreateParams{ + newOrder, err := g.PlaceDeliveryOrder(ctx, &ContractOrderCreateParams{ Contract: s.Pair, Size: amountWithDirection, Price: strconv.FormatFloat(s.Price, 'f', -1, 64), // Cannot be an empty string, requires "0" for market orders. @@ -1199,7 +1182,7 @@ func (g *Gateio) SubmitOrder(ctx context.Context, s *order.Submit) (*order.Submi return nil, err } var status = order.Open - if newOrder.Status != "open" { + if newOrder.Status != statusOpen { status, err = order.StringToOrderStatus(newOrder.FinishAs) if err != nil { return nil, err @@ -1517,7 +1500,7 @@ func (g *Gateio) GetOrderInfo(ctx context.Context, orderID string, pair currency return nil, err } orderStatus := order.Open - if fOrder.Status != "open" { + if fOrder.Status != statusOpen { orderStatus, err = order.StringToOrderStatus(fOrder.FinishAs) if err != nil { return nil, err @@ -1676,7 +1659,7 @@ func (g *Gateio) GetActiveOrders(ctx context.Context, req *order.MultiOrderReque return nil, err } for y := range spotOrders[x].Orders { - if spotOrders[x].Orders[y].Status != "open" { + if spotOrders[x].Orders[y].Status != statusOpen { continue } var side order.Side @@ -1739,9 +1722,9 @@ func (g *Gateio) GetActiveOrders(ctx context.Context, req *order.MultiOrderReque var futuresOrders []Order if req.AssetType == asset.Futures { - futuresOrders, err = g.GetFuturesOrders(ctx, currency.EMPTYPAIR, "open", "", settlement, 0, 0, 0) + futuresOrders, err = g.GetFuturesOrders(ctx, currency.EMPTYPAIR, statusOpen, "", settlement, 0, 0, 0) } else { - futuresOrders, err = g.GetDeliveryOrders(ctx, currency.EMPTYPAIR, "open", settlement, "", 0, 0, 0) + futuresOrders, err = g.GetDeliveryOrders(ctx, currency.EMPTYPAIR, statusOpen, settlement, "", 0, 0, 0) } if err != nil { if strings.Contains(err.Error(), unfundedFuturesAccount) { @@ -1757,7 +1740,7 @@ func (g *Gateio) GetActiveOrders(ctx context.Context, req *order.MultiOrderReque return nil, err } - if futuresOrders[x].Status != "open" || (len(req.Pairs) > 0 && !req.Pairs.Contains(pair, true)) { + if futuresOrders[x].Status != statusOpen || (len(req.Pairs) > 0 && !req.Pairs.Contains(pair, true)) { continue } @@ -1787,7 +1770,7 @@ func (g *Gateio) GetActiveOrders(ctx context.Context, req *order.MultiOrderReque } case asset.Options: var optionsOrders []OptionOrderResponse - optionsOrders, err = g.GetOptionFuturesOrders(ctx, currency.EMPTYPAIR, "", "open", 0, 0, req.StartTime, req.EndTime) + optionsOrders, err = g.GetOptionFuturesOrders(ctx, currency.EMPTYPAIR, "", statusOpen, 0, 0, req.StartTime, req.EndTime) if err != nil { return nil, err } @@ -2626,3 +2609,137 @@ func (g *Gateio) GetCurrencyTradeURL(_ context.Context, a asset.Item, cp currenc return "", fmt.Errorf("%w %v", asset.ErrNotSupported, a) } } + +// WebsocketSubmitOrder submits an order to the exchange through the websocket +// connection. +func (g *Gateio) WebsocketSubmitOrder(ctx context.Context, s *order.Submit) (*order.SubmitResponse, error) { + err := s.Validate(g.GetTradingRequirements()) + if err != nil { + return nil, err + } + + s.Pair, err = g.FormatExchangeCurrency(s.Pair, s.AssetType) + if err != nil { + return nil, err + } + s.Pair = s.Pair.Upper() + + switch s.AssetType { + case asset.Spot: + var req *CreateOrderRequest + req, err = g.getSpotOrderRequest(s) + if err != nil { + return nil, err + } + + var got []WebsocketOrderResponse + got, err = g.WebsocketSpotSubmitOrder(ctx, req) + if err != nil { + return nil, err + } + + if len(got) == 0 { + return nil, errNoResponseReceived + } + + var resp *order.SubmitResponse + resp, err = s.DeriveSubmitResponse(got[0].ID) + if err != nil { + return nil, err + } + resp.Side, err = order.StringToOrderSide(got[0].Side) + if err != nil { + return nil, err + } + resp.Status, err = order.StringToOrderStatus(got[0].Status) + if err != nil { + return nil, err + } + resp.Pair = s.Pair + resp.Date = got[0].CreateTime.Time() + resp.ClientOrderID = got[0].Text + resp.Date = got[0].CreateTimeMs.Time() + resp.LastUpdated = got[0].UpdateTimeMs.Time() + return resp, nil + case asset.Futures: + var amountWithDirection float64 + amountWithDirection, err = getFutureOrderSize(s) + if err != nil { + return nil, err + } + + var timeInForce string + timeInForce, err = getTimeInForce(s) + if err != nil { + return nil, err + } + + var got []WebsocketFuturesOrderResponse + got, err = g.WebsocketFuturesSubmitOrder(ctx, &ContractOrderCreateParams{ + Contract: s.Pair, + Size: amountWithDirection, + Price: strconv.FormatFloat(s.Price, 'f', -1, 64), + ReduceOnly: s.ReduceOnly, + TimeInForce: timeInForce, + Text: s.ClientOrderID, + }) + if err != nil { + return nil, err + } + + if len(got) == 0 { + return nil, errNoResponseReceived + } + + resp, err := s.DeriveSubmitResponse(strconv.FormatInt(got[0].ID, 10)) + if err != nil { + return nil, err + } + resp.Status = order.Open + if got[0].Status != statusOpen { + resp.Status, err = order.StringToOrderStatus(got[0].FinishAs) + if err != nil { + return nil, err + } + } + resp.Pair = s.Pair + resp.Date = got[0].CreateTime.Time() + resp.ClientOrderID = getClientOrderIDFromText(got[0].Text) + resp.ReduceOnly = s.ReduceOnly + resp.Amount = math.Abs(float64(got[0].Size)) + resp.Price = got[0].FillPrice.Float64() + resp.AverageExecutedPrice = got[0].FillPrice.Float64() + return resp, nil + default: + return nil, common.ErrNotYetImplemented + } +} + +func (g *Gateio) getSpotOrderRequest(s *order.Submit) (*CreateOrderRequest, error) { + switch { + case s.Side.IsLong(): + s.Side = order.Buy + case s.Side.IsShort(): + s.Side = order.Sell + default: + return nil, errInvalidOrderSide + } + + timeInForce, err := getTimeInForce(s) + if err != nil { + return nil, err + } + + return &CreateOrderRequest{ + Side: s.Side.Lower(), + Type: s.Type.Lower(), + Account: g.assetTypeToString(s.AssetType), + // When doing spot market orders when purchasing base currency, the quote currency amount is used. When selling + // the base currency the base currency amount is used. + Amount: types.Number(s.GetTradeAmount(g.GetTradingRequirements())), + Price: types.Number(s.Price), + CurrencyPair: s.Pair, + Text: s.ClientOrderID, + TimeInForce: timeInForce, + }, nil +} diff --git a/exchanges/interfaces.go b/exchanges/interfaces.go index 0f6071a864d..8f30c563f70 100644 --- a/exchanges/interfaces.go +++ b/exchanges/interfaces.go @@ -133,6 +133,9 @@ type OrderManagement interface { GetOrderInfo(ctx context.Context, orderID string, pair currency.Pair, assetType asset.Item) (*order.Detail, error) GetActiveOrders(ctx context.Context, getOrdersRequest *order.MultiOrderRequest) (order.FilteredOrders, error) GetOrderHistory(ctx context.Context, getOrdersRequest *order.MultiOrderRequest) (order.FilteredOrders, error) + + // WebsocketSubmitOrder submits an order via the websocket connection + WebsocketSubmitOrder(ctx context.Context, s *order.Submit) (*order.SubmitResponse, error) } // CurrencyStateManagement defines functionality for currency state management diff --git a/exchanges/request/request.go b/exchanges/request/request.go index 8bc1690ce83..25a6cbba0a5 100644 --- a/exchanges/request/request.go +++ b/exchanges/request/request.go @@ -383,10 +383,8 @@ func WithVerbose(ctx context.Context) context.Context { // IsVerbose checks main verbosity first then checks context verbose values // for specific request verbosity. func IsVerbose(ctx context.Context, verbose bool) bool { - if verbose { - return true + if !verbose { + verbose, _ = ctx.Value(contextVerboseFlag).(bool) } - - isCtxVerbose, _ := ctx.Value(contextVerboseFlag).(bool) - return isCtxVerbose + return verbose } diff --git a/exchanges/request/request_test.go b/exchanges/request/request_test.go index 5793ef66324..ef2bbc583df 100644 --- a/exchanges/request/request_test.go +++ b/exchanges/request/request_test.go @@ -698,7 +698,7 @@ func TestGetHTTPClientUserAgent(t *testing.T) { } } -func TestContextVerbosity(t *testing.T) { +func TestIsVerbose(t *testing.T) { t.Parallel() require.False(t, IsVerbose(context.Background(), false)) require.True(t, IsVerbose(context.Background(), true)) diff --git a/exchanges/stream/websocket.go b/exchanges/stream/websocket.go index 737c0eadc7f..f84c067478f 100644 --- a/exchanges/stream/websocket.go +++ b/exchanges/stream/websocket.go @@ -1102,7 +1102,7 @@ func (w *Websocket) Reader(ctx context.Context, conn Connection, handler func(ct return // Connection has been closed } if err := handler(ctx, resp.Raw); err != nil { - w.DataHandler <- fmt.Errorf("connection URL:[%v] error: %w", conn.GetURL(), err) + w.DataHandler <- fmt.Errorf("connection URL:[%v] error: %w for %s", conn.GetURL(), err, resp.Raw) } } } diff --git a/exchanges/stream/websocket_connection.go b/exchanges/stream/websocket_connection.go index 55fd71682e6..bee11d56b08 100644 --- a/exchanges/stream/websocket_connection.go +++ b/exchanges/stream/websocket_connection.go @@ -293,8 +293,8 @@ func (w *WebsocketConnection) GetURL() string { } // SendMessageReturnResponse will send a WS message to the connection and wait for response -func (w *WebsocketConnection) SendMessageReturnResponse(ctx context.Context, epl request.EndpointLimit, signature, request any) ([]byte, error) { - resps, err := w.SendMessageReturnResponses(ctx, epl, signature, request, 1) +func (w *WebsocketConnection) SendMessageReturnResponse(ctx context.Context, epl request.EndpointLimit, signature, payload any) ([]byte, error) { + resps, err := w.SendMessageReturnResponses(ctx, epl, signature, payload, 1) if err != nil { return nil, err }