diff --git a/exchanges/exchange.go b/exchanges/exchange.go index 83a5d3e7..6e39ac36 100644 --- a/exchanges/exchange.go +++ b/exchanges/exchange.go @@ -1957,3 +1957,8 @@ func (b *Base) GetCachedAccountInfo(ctx context.Context, assetType asset.Item) ( func (*Base) WebsocketSubmitOrder(context.Context, *order.Submit) (*order.SubmitResponse, error) { return nil, common.ErrFunctionNotSupported } + +// WebsocketSubmitOrders submits multiple orders (batch) via the websocket connection +func (*Base) WebsocketSubmitOrders(context.Context, []*order.Submit) (responses []*order.SubmitResponse, err error) { + return nil, common.ErrFunctionNotSupported +} diff --git a/exchanges/exchange_test.go b/exchanges/exchange_test.go index 606dd9eb..37b72aae 100644 --- a/exchanges/exchange_test.go +++ b/exchanges/exchange_test.go @@ -2897,3 +2897,8 @@ func TestWebsocketSubmitOrder(t *testing.T) { _, err := (&Base{}).WebsocketSubmitOrder(t.Context(), nil) require.ErrorIs(t, err, common.ErrFunctionNotSupported) } + +func TestWebsocketSubmitOrders(t *testing.T) { + _, err := (&Base{}).WebsocketSubmitOrders(t.Context(), nil) + require.ErrorIs(t, err, common.ErrFunctionNotSupported) +} diff --git a/exchanges/gateio/gateio.go b/exchanges/gateio/gateio.go index 9ec8f3e3..caab6340 100644 --- a/exchanges/gateio/gateio.go +++ b/exchanges/gateio/gateio.go @@ -166,6 +166,7 @@ var ( errInvalidSettlementBase = errors.New("symbol base currency does not match asset settlement currency") errMissingAPIKey = errors.New("missing API key information") errInvalidTextValue = errors.New("invalid text value, requires prefix `t-`") + errSingleAssetRequired = errors.New("single asset type required") ) // validTimesInForce holds a list of supported time-in-force values and corresponding string representations. diff --git a/exchanges/gateio/gateio_test.go b/exchanges/gateio/gateio_test.go index 7c146b41..c2876b3e 100644 --- a/exchanges/gateio/gateio_test.go +++ b/exchanges/gateio/gateio_test.go @@ -3052,6 +3052,53 @@ func TestDeriveSpotWebsocketOrderResponses(t *testing.T) { }, }, }, + { + name: "batch of spot orders with error at end", + // This is specifically testing the return responses of WebsocketSpotSubmitOrders + // AverageDealPrice is not returned when using this endpoint so purchased and cost fields cannot be set. + orders: [][]byte{ + []byte(`{"account":"spot","status":"closed","side":"buy","amount":"9.98","id":"775453816782","create_time":"1736980695","update_time":"1736980695","text":"t-740","left":"0.047239","currency_pair":"ETH_USDT","type":"market","finish_as":"filled","price":"0","time_in_force":"fok","iceberg":"0","filled_total":"9.932761","fill_price":"9.932761","create_time_ms":1736980695949,"update_time_ms":1736980695949,"succeeded":true}`), + []byte(`{"account":"spot","status":"closed","side":"buy","amount":"0.00289718","id":"775453816824","create_time":"1736980695","update_time":"1736980695","text":"t-741","left":"0.00000000962","currency_pair":"LIKE_ETH","type":"market","finish_as":"filled","price":"0","time_in_force":"fok","iceberg":"0","filled_total":"0.00289717038","fill_price":"0.00289717038","create_time_ms":1736980695956,"update_time_ms":1736980695956,"succeeded":true}`), + []byte(`{"text":"t-742","label":"BALANCE_NOT_ENOUGH","message":"Not enough balance"}`), + }, + expected: []*order.SubmitResponse{ + { + Exchange: e.Name, + OrderID: "775453816782", + AssetType: asset.Spot, + Pair: currency.NewPair(currency.ETH, currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "t-740", + Date: time.UnixMilli(1736980695949), + LastUpdated: time.UnixMilli(1736980695949), + Amount: 9.98, + RemainingAmount: 0.047239, + Type: order.Market, + Side: order.Buy, + Status: order.Filled, + TimeInForce: order.FillOrKill, + }, + { + Exchange: e.Name, + OrderID: "775453816824", + AssetType: asset.Spot, + Pair: currency.NewPair(currency.LIKE, currency.ETH).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "t-741", + Date: time.UnixMilli(1736980695956), + LastUpdated: time.UnixMilli(1736980695956), + RemainingAmount: 0.00000000962, + Amount: 0.00289718, + Type: order.Market, + Side: order.Buy, + Status: order.Filled, + TimeInForce: order.FillOrKill, + }, + { + Exchange: e.Name, + ClientOrderID: "t-742", + SubmissionError: order.ErrUnableToPlaceOrder, + }, + }, + }, } for _, tc := range testCases { @@ -3069,6 +3116,12 @@ func TestDeriveSpotWebsocketOrderResponses(t *testing.T) { require.Len(t, got, len(tc.expected)) for i := range got { + if tc.expected[i].SubmissionError != nil { + assert.ErrorIs(t, got[i].SubmissionError, tc.expected[i].SubmissionError) + assert.Equal(t, tc.expected[i].Exchange, got[i].Exchange) + assert.Equal(t, tc.expected[i].ClientOrderID, got[i].ClientOrderID) + continue + } assert.Equal(t, tc.expected[i], got[i]) } }) @@ -3575,3 +3628,45 @@ func TestGetIntervalString(t *testing.T) { _, err = getIntervalString(kline.FiveDay) assert.ErrorIs(t, err, kline.ErrUnsupportedInterval, "Any other random interval should also be invalid") } + +func TestWebsocketSubmitOrders(t *testing.T) { + t.Parallel() + + _, err := e.WebsocketSubmitOrders(t.Context(), nil) + require.ErrorIs(t, err, asset.ErrNotSupported) + + sub := &order.Submit{ + Exchange: e.Name, + AssetType: asset.Spot, + Side: order.Buy, + Type: order.Market, + QuoteAmount: 10, + } + _, err = e.WebsocketSubmitOrders(t.Context(), []*order.Submit{sub}) + require.ErrorIs(t, err, order.ErrPairIsEmpty) + + sub.Pair = currency.NewBTCUSD() + cpy := *sub + cpy.AssetType = asset.Futures + _, err = e.WebsocketSubmitOrders(t.Context(), []*order.Submit{sub, &cpy}) + require.ErrorIs(t, err, errSingleAssetRequired) + + cpy.AssetType = asset.Spread + sub.AssetType = asset.Spread + _, err = e.WebsocketSubmitOrders(t.Context(), []*order.Submit{sub, &cpy}) + require.ErrorIs(t, err, asset.ErrNotSupported) + + sub.AssetType = asset.USDTMarginedFutures + cpy.AssetType = asset.USDTMarginedFutures + _, err = e.WebsocketSubmitOrders(t.Context(), []*order.Submit{sub, &cpy}) + require.ErrorIs(t, err, common.ErrNotYetImplemented) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + + e := newExchangeWithWebsocket(t, asset.Spot) //nolint:govet // Intentional shadow + + sub.AssetType = asset.Spot + cpy.AssetType = asset.Spot + _, err = e.WebsocketSubmitOrders(request.WithVerbose(t.Context()), []*order.Submit{sub, &cpy}) + require.NoError(t, err) +} diff --git a/exchanges/gateio/gateio_websocket_request_futures.go b/exchanges/gateio/gateio_websocket_request_futures.go index bc68ca49..a5120f71 100644 --- a/exchanges/gateio/gateio_websocket_request_futures.go +++ b/exchanges/gateio/gateio_websocket_request_futures.go @@ -31,11 +31,11 @@ func (e *Exchange) WebsocketFuturesSubmitOrder(ctx context.Context, a asset.Item if len(resps) != 1 { return nil, common.ErrInvalidResponse } - return &resps[0], err + return resps[0], err } // WebsocketFuturesSubmitOrders submits orders via the websocket connection. All orders must be for the same asset. -func (e *Exchange) WebsocketFuturesSubmitOrders(ctx context.Context, a asset.Item, orders ...*ContractOrderCreateParams) ([]WebsocketFuturesOrderResponse, error) { +func (e *Exchange) WebsocketFuturesSubmitOrders(ctx context.Context, a asset.Item, orders ...*ContractOrderCreateParams) ([]*WebsocketFuturesOrderResponse, error) { if len(orders) == 0 { return nil, errOrdersEmpty } @@ -64,12 +64,12 @@ func (e *Exchange) WebsocketFuturesSubmitOrders(ctx context.Context, a asset.Ite } if len(orders) == 1 { - var singleResponse WebsocketFuturesOrderResponse + var singleResponse *WebsocketFuturesOrderResponse err := e.SendWebsocketRequest(ctx, perpetualSubmitOrderEPL, "futures.order_place", a, orders[0], &singleResponse, 2) - return []WebsocketFuturesOrderResponse{singleResponse}, err + return []*WebsocketFuturesOrderResponse{singleResponse}, err } - var resp []WebsocketFuturesOrderResponse + var resp []*WebsocketFuturesOrderResponse return resp, e.SendWebsocketRequest(ctx, perpetualSubmitBatchOrdersEPL, "futures.order_batch_place", a, orders, &resp, 2) } diff --git a/exchanges/gateio/gateio_websocket_request_spot.go b/exchanges/gateio/gateio_websocket_request_spot.go index 24992ff2..1939571a 100644 --- a/exchanges/gateio/gateio_websocket_request_spot.go +++ b/exchanges/gateio/gateio_websocket_request_spot.go @@ -33,12 +33,12 @@ func (e *Exchange) WebsocketSpotSubmitOrder(ctx context.Context, order *CreateOr if len(resps) != 1 { return nil, common.ErrInvalidResponse } - return &resps[0], nil + return resps[0], nil } // WebsocketSpotSubmitOrders submits orders via the websocket connection. You can // send multiple orders in a single request. But only for one asset route. -func (e *Exchange) WebsocketSpotSubmitOrders(ctx context.Context, orders ...*CreateOrderRequest) ([]WebsocketOrderResponse, error) { +func (e *Exchange) WebsocketSpotSubmitOrders(ctx context.Context, orders ...*CreateOrderRequest) ([]*WebsocketOrderResponse, error) { if len(orders) == 0 { return nil, errOrdersEmpty } @@ -63,10 +63,10 @@ func (e *Exchange) WebsocketSpotSubmitOrders(ctx context.Context, orders ...*Cre } if len(orders) == 1 { - var singleResponse WebsocketOrderResponse - return []WebsocketOrderResponse{singleResponse}, e.SendWebsocketRequest(ctx, spotPlaceOrderEPL, "spot.order_place", asset.Spot, orders[0], &singleResponse, 2) + var singleResponse *WebsocketOrderResponse + return []*WebsocketOrderResponse{singleResponse}, e.SendWebsocketRequest(ctx, spotPlaceOrderEPL, "spot.order_place", asset.Spot, orders[0], &singleResponse, 2) } - var resp []WebsocketOrderResponse + var resp []*WebsocketOrderResponse return resp, e.SendWebsocketRequest(ctx, spotBatchOrdersEPL, "spot.order_place", asset.Spot, orders, &resp, 2) } diff --git a/exchanges/gateio/gateio_websocket_request_spot_test.go b/exchanges/gateio/gateio_websocket_request_spot_test.go index 8e28da4e..57a6c6a6 100644 --- a/exchanges/gateio/gateio_websocket_request_spot_test.go +++ b/exchanges/gateio/gateio_websocket_request_spot_test.go @@ -231,6 +231,13 @@ func newExchangeWithWebsocket(t *testing.T, a asset.Item) *Exchange { require.NoError(t, e.CurrencyPairs.SetAssetEnabled(a, false)) } + // Disable all other asset types to ensure only the specified asset type is used for websocket tests. + for _, enabled := range e.GetAssetTypes(true) { + if enabled != a { + require.NoError(t, e.CurrencyPairs.SetAssetEnabled(enabled, false)) + } + } + require.NoError(t, e.Websocket.Connect()) return e } diff --git a/exchanges/gateio/gateio_websocket_request_types.go b/exchanges/gateio/gateio_websocket_request_types.go index 80f08da8..7ca7fa9c 100644 --- a/exchanges/gateio/gateio_websocket_request_types.go +++ b/exchanges/gateio/gateio_websocket_request_types.go @@ -86,6 +86,8 @@ type WebsocketOrderResponse struct { SelfTradePreventionID int `json:"stp_id"` SelfTradePreventionAction string `json:"stp_act"` AverageDealPrice types.Number `json:"avg_deal_price"` + Label string `json:"label"` + Message string `json:"message"` } // WebsocketFuturesOrderResponse defines a websocket futures order response diff --git a/exchanges/gateio/gateio_wrapper.go b/exchanges/gateio/gateio_wrapper.go index 9c685983..2b3dc35a 100644 --- a/exchanges/gateio/gateio_wrapper.go +++ b/exchanges/gateio/gateio_wrapper.go @@ -2344,19 +2344,11 @@ func (e *Exchange) WebsocketSubmitOrder(ctx context.Context, s *order.Submit) (* } return e.deriveSpotWebsocketOrderResponse(resp) case asset.CoinMarginedFutures, asset.USDTMarginedFutures: - amountWithDirection, err := getFutureOrderSize(s) + req, err := getFuturesOrderRequest(s) if err != nil { return nil, err } - - resp, err := e.WebsocketFuturesSubmitOrder(ctx, s.AssetType, &ContractOrderCreateParams{ - Contract: s.Pair, - Size: amountWithDirection, - Price: strconv.FormatFloat(s.Price, 'f', -1, 64), - ReduceOnly: s.ReduceOnly, - TimeInForce: timeInForceString(s.TimeInForce), - Text: s.ClientOrderID, - }) + resp, err := e.WebsocketFuturesSubmitOrder(ctx, s.AssetType, req) if err != nil { return nil, err } @@ -2366,6 +2358,22 @@ func (e *Exchange) WebsocketSubmitOrder(ctx context.Context, s *order.Submit) (* } } +func getFuturesOrderRequest(s *order.Submit) (*ContractOrderCreateParams, error) { + amountWithDirection, err := getFutureOrderSize(s) + if err != nil { + return nil, err + } + + return &ContractOrderCreateParams{ + Contract: s.Pair, + Size: amountWithDirection, + Price: strconv.FormatFloat(s.Price, 'f', -1, 64), + ReduceOnly: s.ReduceOnly, + TimeInForce: timeInForceString(s.TimeInForce), + Text: s.ClientOrderID, + }, nil +} + // timeInForceString returns the most relevant time-in-force exchange string for a TimeInForce // Any TIF value that is combined with POC, IOC or FOK will just return that // Otherwise the lowercase representation is returned @@ -2398,8 +2406,17 @@ func (e *Exchange) deriveSpotWebsocketOrderResponses(responses []*WebsocketOrder return nil, common.ErrNoResponse } - out := make([]*order.SubmitResponse, 0, len(responses)) - for _, resp := range responses { + out := make([]*order.SubmitResponse, len(responses)) + for i, resp := range responses { + if resp.Label != "" { // batch only, denotes error type in string format + out[i] = &order.SubmitResponse{ + Exchange: e.Name, + ClientOrderID: resp.Text, + SubmissionError: fmt.Errorf("%w reason label:%q message:%q", order.ErrUnableToPlaceOrder, resp.Label, resp.Message), + } + continue + } + side, err := order.StringToOrderSide(resp.Side) if err != nil { return nil, err @@ -2431,7 +2448,7 @@ func (e *Exchange) deriveSpotWebsocketOrderResponses(responses []*WebsocketOrder if err != nil { return nil, err } - out = append(out, &order.SubmitResponse{ + out[i] = &order.SubmitResponse{ Exchange: e.Name, OrderID: resp.ID, AssetType: resp.Account, @@ -2442,16 +2459,16 @@ func (e *Exchange) deriveSpotWebsocketOrderResponses(responses []*WebsocketOrder RemainingAmount: resp.Left.Float64(), Amount: resp.Amount.Float64(), Price: resp.Price.Float64(), - AverageExecutedPrice: resp.AverageDealPrice.Float64(), Type: oType, Side: side, - Status: status, + Fee: resp.Fee.Float64(), + FeeAsset: resp.FeeCurrency, TimeInForce: tif, Cost: cost, Purchased: purchased, - Fee: resp.Fee.Float64(), - FeeAsset: resp.FeeCurrency, - }) + Status: status, + AverageExecutedPrice: resp.AverageDealPrice.Float64(), + } } return out, nil } @@ -2573,3 +2590,44 @@ func getSettlementCurrency(p currency.Pair, a asset.Item) (currency.Code, error) } return currency.EMPTYCODE, fmt.Errorf("%w: %s", asset.ErrNotSupported, a) } + +// WebsocketSubmitOrders submits orders to the exchange through the websocket +func (e *Exchange) WebsocketSubmitOrders(ctx context.Context, orders []*order.Submit) ([]*order.SubmitResponse, error) { + var a asset.Item + for x := range orders { + if err := orders[x].Validate(e.GetTradingRequirements()); err != nil { + return nil, err + } + + if a == asset.Empty { + a = orders[x].AssetType + continue + } + + if a != orders[x].AssetType { + return nil, fmt.Errorf("%w; Passed %q and %q", errSingleAssetRequired, a, orders[x].AssetType) + } + } + + if !e.CurrencyPairs.IsAssetSupported(a) { + return nil, fmt.Errorf("%w: %q", asset.ErrNotSupported, a) + } + + switch a { + case asset.Spot: + reqs := make([]*CreateOrderRequest, len(orders)) + for x := range orders { + var err error + if reqs[x], err = e.getSpotOrderRequest(orders[x]); err != nil { + return nil, err + } + } + resp, err := e.WebsocketSpotSubmitOrders(ctx, reqs...) + if err != nil { + return nil, err + } + return e.deriveSpotWebsocketOrderResponses(resp) + default: + return nil, fmt.Errorf("%w for %s", common.ErrNotYetImplemented, a) + } +} diff --git a/exchanges/interfaces.go b/exchanges/interfaces.go index 24614322..efff3a08 100644 --- a/exchanges/interfaces.go +++ b/exchanges/interfaces.go @@ -135,6 +135,7 @@ type OrderManagement interface { GetActiveOrders(ctx context.Context, getOrdersRequest *order.MultiOrderRequest) (order.FilteredOrders, error) GetOrderHistory(ctx context.Context, getOrdersRequest *order.MultiOrderRequest) (order.FilteredOrders, error) WebsocketSubmitOrder(ctx context.Context, s *order.Submit) (*order.SubmitResponse, error) + WebsocketSubmitOrders(ctx context.Context, orders []*order.Submit) (responses []*order.SubmitResponse, err error) } // CurrencyStateManagement defines functionality for currency state management diff --git a/exchanges/order/order_types.go b/exchanges/order/order_types.go index 89733416..e565c5c6 100644 --- a/exchanges/order/order_types.go +++ b/exchanges/order/order_types.go @@ -101,12 +101,11 @@ type Submit struct { // SubmitResponse is what is returned after submitting an order to an exchange type SubmitResponse struct { - Exchange string - Type Type - Side Side - Pair currency.Pair - AssetType asset.Item - + Exchange string + Type Type + Side Side + Pair currency.Pair + AssetType asset.Item TimeInForce TimeInForce ReduceOnly bool Leverage float64 @@ -118,21 +117,19 @@ type SubmitResponse struct { ClientID string ClientOrderID string AverageExecutedPrice float64 - - LastUpdated time.Time - Date time.Time - Status Status - OrderID string - Trades []TradeHistory - Fee float64 - FeeAsset currency.Code - - Cost float64 - Purchased float64 // Buy in base currency, Sell in quote - - BorrowSize float64 - LoanApplyID string - MarginType margin.Type + LastUpdated time.Time + Date time.Time + Status Status + OrderID string + Trades []TradeHistory + Fee float64 + FeeAsset currency.Code + Cost float64 + Purchased float64 // Buy in base currency, Sell in quote + BorrowSize float64 + LoanApplyID string + MarginType margin.Type + SubmissionError error } // TrackingMode defines how the stop price follows the market price.