exchanges/gateio: Add WebsocketSubmitOrders wrapper func for spot (#1924)

* exchanges/gateio: Add WebsocketSubmitOrders wrapper func for spot

* linter: fix

* Update exchanges/gateio/gateio_wrapper.go

Co-authored-by: Scott <gloriousCode@users.noreply.github.com>

* glorious 👶 nits

* Update exchanges/gateio/gateio_wrapper.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/gateio/gateio_wrapper.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/gateio/gateio_wrapper.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/gateio/gateio_wrapper.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* gk: nits

* glorious: nits

---------

Co-authored-by: shazbert <ryan.oharareid@thrasher.io>
Co-authored-by: Scott <gloriousCode@users.noreply.github.com>
Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>
This commit is contained in:
Ryan O'Hara-Reid
2025-08-11 17:14:35 +10:00
committed by GitHub
parent edf5d84d34
commit 9030ebf3da
11 changed files with 220 additions and 49 deletions

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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.