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/contrib/spellcheck/exclude_lines.txt b/contrib/spellcheck/exclude_lines.txt index 4c7bbba8fa4..65846e7698b 100644 --- a/contrib/spellcheck/exclude_lines.txt +++ b/contrib/spellcheck/exclude_lines.txt @@ -22,4 +22,7 @@ SHFT = NewCode("SHFT") currency.SHFT: 84, TotalIn float64 `json:"totalIn"` - TotalIn int64 `json:"totalIn"` \ No newline at end of file + TotalIn int64 `json:"totalIn"` + currency.ANC: 44, + eto := request.EndTimeOverride.AsTime() + if eto.Unix() != 0 && !eto.IsZero() { \ No newline at end of file diff --git a/exchanges/credentials.go b/exchanges/credentials.go index 56c9d80dd8c..82a36a856f6 100644 --- a/exchanges/credentials.go +++ b/exchanges/credentials.go @@ -9,6 +9,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/common/crypto" "github.com/thrasher-corp/gocryptotrader/config" "github.com/thrasher-corp/gocryptotrader/exchanges/account" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" "github.com/thrasher-corp/gocryptotrader/log" ) @@ -111,6 +112,10 @@ func (b *Base) GetDefaultCredentials() *account.Credentials { // GetCredentials checks and validates current credentials, context credentials // override default credentials, if no credentials found, will return an error. func (b *Base) GetCredentials(ctx context.Context) (*account.Credentials, error) { + if request.IsMockResponse(ctx) { + return &account.Credentials{}, nil + } + value := ctx.Value(account.ContextCredentialsFlag) if value != nil { ctxCredStore, ok := value.(*account.ContextCredentialsStore) diff --git a/exchanges/credentials_test.go b/exchanges/credentials_test.go index 0e7f5e721ec..8cd621b34cc 100644 --- a/exchanges/credentials_test.go +++ b/exchanges/credentials_test.go @@ -5,8 +5,10 @@ import ( "errors" "testing" + "github.com/stretchr/testify/require" "github.com/thrasher-corp/gocryptotrader/config" "github.com/thrasher-corp/gocryptotrader/exchanges/account" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" ) func TestGetCredentials(t *testing.T) { @@ -127,6 +129,11 @@ func TestGetCredentials(t *testing.T) { notOverrided.SubAccount != "" { t.Fatal("unexpected values") } + + creds, err = b.GetCredentials(request.WithMockResponse(context.Background(), nil)) + require.NoError(t, err) + require.NotNil(t, creds) + require.Empty(t, creds) } func TestAreCredentialsValid(t *testing.T) { diff --git a/exchanges/exchange.go b/exchanges/exchange.go index 4369b1dd110..312993dcf2b 100644 --- a/exchanges/exchange.go +++ b/exchanges/exchange.go @@ -1943,3 +1943,13 @@ 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 +} + +// WebsocketSubmitBatchOrders submits multiple orders in a batch via the websocket connection +func (*Base) WebsocketSubmitBatchOrders(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 a5db589d047..adfbb3486e1 100644 --- a/exchanges/exchange_test.go +++ b/exchanges/exchange_test.go @@ -3076,3 +3076,13 @@ 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) +} + +func TestWebsocketSubmitBatchOrders(t *testing.T) { + _, err := (&Base{}).WebsocketSubmitBatchOrders(context.Background(), nil) + require.ErrorIs(t, err, common.ErrFunctionNotSupported) +} diff --git a/exchanges/gateio/gateio.go b/exchanges/gateio/gateio.go index a0658d44ac7..64f544c3b26 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 } @@ -2263,7 +2263,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 } @@ -2351,7 +2351,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 } @@ -2839,7 +2839,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 469a4ee9d37..21b39e28655 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) } } @@ -489,7 +489,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) } } @@ -563,7 +563,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) } } @@ -1109,7 +1109,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, @@ -1126,7 +1126,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") } @@ -1204,7 +1204,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") } @@ -1278,7 +1278,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, @@ -1293,7 +1293,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") } @@ -1323,7 +1323,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, @@ -1430,7 +1430,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") } @@ -3550,3 +3550,337 @@ func TestParseWSHeader(t *testing.T) { } } } + +func TestDeriveSpotSubmitOrderResponses(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + order []byte + error error + expected []*order.SubmitResponse + }{ + { + name: "sell order market", + order: []byte(`{"left":"0","update_time":"1735720637","amount":"0.0001","create_time":"1735720637","price":"0","finish_as":"filled","time_in_force":"ioc","currency_pair":"BTC_USDT","type":"market","account":"spot","side":"sell","amend_text":"-","text":"t-1735720637181634009","status":"closed","iceberg":"0","avg_deal_price":"93503.3","filled_total":"9.35033","id":"766075454481","fill_price":"9.35033","update_time_ms":1735720637188,"create_time_ms":1735720637188}`), + expected: []*order.SubmitResponse{ + { + Exchange: g.Name, + OrderID: "766075454481", + AssetType: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "t-1735720637181634009", + Date: time.UnixMilli(1735720637188), + LastUpdated: time.UnixMilli(1735720637188), + RemainingAmount: 0, + Amount: 0.0001, + Price: 0, + AverageExecutedPrice: 93503.3, + Type: order.Market, + Side: order.Sell, + Status: order.Filled, + ImmediateOrCancel: true, + FillOrKill: false, + PostOnly: false, + Cost: 0.0001, + Purchased: 9.35033, + }, + }, + }, + { + name: "buy order market", + order: []byte(`{"left":"0.000008","update_time":"1735720637","amount":"9.99152","create_time":"1735720637","price":"0","finish_as":"filled","time_in_force":"ioc","currency_pair":"HNS_USDT","type":"market","account":"spot","side":"buy","amend_text":"-","text":"t-1735720637126962151","status":"closed","iceberg":"0","avg_deal_price":"0.01224","filled_total":"9.991512","id":"766075454188","fill_price":"9.991512","update_time_ms":1735720637142,"create_time_ms":1735720637142}`), + expected: []*order.SubmitResponse{ + { + Exchange: g.Name, + OrderID: "766075454188", + AssetType: asset.Spot, + Pair: currency.NewPair(currency.HNS, currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "t-1735720637126962151", + Date: time.UnixMilli(1735720637142), + LastUpdated: time.UnixMilli(1735720637142), + RemainingAmount: 0.000008, + Amount: 9.99152, + Price: 0, + AverageExecutedPrice: 0.01224, + Type: order.Market, + Side: order.Buy, + Status: order.Filled, + ImmediateOrCancel: true, + FillOrKill: false, + PostOnly: false, + Cost: 9.991512, + Purchased: 816.3, + }, + }, + }, + { + name: "buy order limit - FOK", + order: []byte(`{"left":"0","update_time":"1735778597","amount":"200","create_time":"1735778597","price":"0.03673","finish_as":"filled","time_in_force":"fok","currency_pair":"REX_USDT","type":"limit","account":"spot","side":"buy","amend_text":"-","text":"t-1364","status":"closed","iceberg":"0","avg_deal_price":"0.03673","filled_total":"7.346","id":"766488882062","fill_price":"7.346","update_time_ms":1735778597363,"create_time_ms":1735778597363}`), + expected: []*order.SubmitResponse{ + { + Exchange: g.Name, + OrderID: "766488882062", + AssetType: asset.Spot, + Pair: currency.NewPair(currency.NewCode("REX"), currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "t-1364", + Date: time.UnixMilli(1735778597363), + LastUpdated: time.UnixMilli(1735778597363), + RemainingAmount: 0, + Amount: 200, + Price: 0.03673, + AverageExecutedPrice: 0.03673, + Type: order.Limit, + Side: order.Buy, + Status: order.Filled, + ImmediateOrCancel: false, + FillOrKill: true, + PostOnly: false, + Cost: 7.346, + Purchased: 200, + }, + }, + }, + { + name: "buy order limit - POC", + order: []byte(`{"left":"0.0003","update_time":"1735780321","amount":"0.0003","create_time":"1735780321","price":"20000","finish_as":"open","time_in_force":"poc","currency_pair":"BTC_USDT","type":"limit","account":"spot","side":"buy","amend_text":"-","text":"t-1735780321603944400","status":"open","iceberg":"0","filled_total":"0","id":"766504537761","fill_price":"0","update_time_ms":1735780321729,"create_time_ms":1735780321729}`), + expected: []*order.SubmitResponse{ + { + Exchange: g.Name, + OrderID: "766504537761", + AssetType: asset.Spot, + Pair: currency.NewPair(currency.BTC, currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "t-1735780321603944400", + Date: time.UnixMilli(1735780321729), + LastUpdated: time.UnixMilli(1735780321729), + RemainingAmount: 0.0003, + Amount: 0.0003, + Price: 20000, + AverageExecutedPrice: 0, + Type: order.Limit, + Side: order.Buy, + Status: order.Open, + ImmediateOrCancel: false, + FillOrKill: false, + PostOnly: true, + Cost: 0, + Purchased: 0, + }, + }, + }, + { + name: "sell order limit - GTC", + order: []byte(`{"left":"1","update_time":"1735784755","amount":"1","create_time":"1735784755","price":"100","finish_as":"open","time_in_force":"gtc","currency_pair":"GT_USDT","type":"limit","account":"spot","side":"sell","amend_text":"-","text":"t-1735784754905434100","status":"open","iceberg":"0","filled_total":"0","id":"766536556747","fill_price":"0","update_time_ms":1735784755068,"create_time_ms":1735784755068}`), + expected: []*order.SubmitResponse{ + { + Exchange: g.Name, + OrderID: "766536556747", + AssetType: asset.Spot, + Pair: currency.NewPair(currency.NewCode("GT"), currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "t-1735784754905434100", + Date: time.UnixMilli(1735784755068), + LastUpdated: time.UnixMilli(1735784755068), + RemainingAmount: 1, + Amount: 1, + Price: 100, + AverageExecutedPrice: 0, + Type: order.Limit, + Side: order.Sell, + Status: order.Open, + ImmediateOrCancel: false, + FillOrKill: false, + PostOnly: false, + Cost: 0, + Purchased: 0, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var resp WebsocketOrderResponse + require.NoError(t, json.Unmarshal(tc.order, &resp)) + + got, err := g.DeriveSpotSubmitOrderResponses([]WebsocketOrderResponse{resp}) + require.ErrorIs(t, err, tc.error) + + require.Len(t, got, len(tc.expected)) + for i := range got { + assert.Equal(t, tc.expected[i], got[i]) + } + }) + } +} + +func TestDeriveFuturesSubmitOrderResponses(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + order []byte + error error + expected []*order.SubmitResponse + }{ + { + name: "short order market - reduce only", + order: []byte(`{"text":"t-1337","price":"0","biz_info":"-","tif":"ioc","amend_text":"-","status":"finished","contract":"CWIF_USDT","stp_act":"-","finish_as":"filled","fill_price":"0.0000002625","id":596729318437,"create_time":1735787107.449,"size":2,"finish_time":1735787107.45,"update_time":1735787107.45,"left":0,"user":12870774,"is_reduce_only":true}`), + expected: []*order.SubmitResponse{ + { + Exchange: g.Name, + OrderID: "596729318437", + AssetType: asset.Futures, + Pair: currency.NewPair(currency.NewCode("CWIF"), currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "t-1337", + Date: time.UnixMilli(1735787107449), + LastUpdated: time.UnixMilli(1735787107450), + RemainingAmount: 0, + Amount: 2, + Price: 0, + AverageExecutedPrice: 0.0000002625, + Type: order.Market, + Side: order.Short, + Status: order.Filled, + ImmediateOrCancel: true, + FillOrKill: false, + PostOnly: false, + ReduceOnly: true, + }, + }, + }, + { + name: "short order market", + order: []byte(`{"text":"t-1336","price":"0","biz_info":"-","tif":"ioc","amend_text":"-","status":"finished","contract":"REX_USDT","stp_act":"-","finish_as":"filled","fill_price":"0.03654","id":596662040388,"create_time":1735778597.374,"size":-2,"finish_time":1735778597.374,"update_time":1735778597.374,"left":0,"user":12870774}`), + expected: []*order.SubmitResponse{ + { + Exchange: g.Name, + OrderID: "596662040388", + AssetType: asset.Futures, + Pair: currency.NewPair(currency.NewCode("REX"), currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "t-1336", + Date: time.UnixMilli(1735778597374), + LastUpdated: time.UnixMilli(1735778597374), + RemainingAmount: 0, + Amount: 2, + Price: 0, + AverageExecutedPrice: 0.03654, + Type: order.Market, + Side: order.Short, + Status: order.Filled, + ImmediateOrCancel: true, + FillOrKill: false, + PostOnly: false, + ReduceOnly: false, + }, + }, + }, + { + name: "long order limit", + order: []byte(`{"text":"apiv4-ws","price":"40000","biz_info":"-","tif":"gtc","amend_text":"-","status":"open","contract":"BTC_USDT","stp_act":"-","fill_price":"0","id":596746193678,"create_time":1735789790.476,"size":1,"update_time":1735789790.476,"left":1,"user":2365748}`), + expected: []*order.SubmitResponse{ + { + Exchange: g.Name, + OrderID: "596746193678", + AssetType: asset.Futures, + Pair: currency.NewPair(currency.BTC, currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "", + Date: time.UnixMilli(1735789790476), + LastUpdated: time.UnixMilli(1735789790476), + RemainingAmount: 1, + Amount: 1, + Price: 40000, + AverageExecutedPrice: 0, + Type: order.Limit, + Side: order.Long, + Status: order.Open, + ImmediateOrCancel: false, + FillOrKill: false, + PostOnly: false, + ReduceOnly: false, + }, + }, + }, + { + name: "short order limit", + order: []byte(`{"text":"apiv4-ws","price":"200000","biz_info":"-","tif":"gtc","amend_text":"-","status":"open","contract":"BTC_USDT","stp_act":"-","fill_price":"0","id":596748780649,"create_time":1735790222.185,"size":-1,"update_time":1735790222.185,"left":-1,"user":2365748}`), + expected: []*order.SubmitResponse{ + { + Exchange: g.Name, + OrderID: "596748780649", + AssetType: asset.Futures, + Pair: currency.NewPair(currency.BTC, currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"}), + ClientOrderID: "", + Date: time.UnixMilli(1735790222185), + LastUpdated: time.UnixMilli(1735790222185), + RemainingAmount: 1, + Amount: 1, + Price: 200000, + AverageExecutedPrice: 0, + Type: order.Limit, + Side: order.Short, + Status: order.Open, + ImmediateOrCancel: false, + FillOrKill: false, + PostOnly: false, + ReduceOnly: false, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var resp WebsocketFuturesOrderResponse + require.NoError(t, json.Unmarshal(tc.order, &resp)) + + got, err := g.DeriveFuturesSubmitOrderResponses([]WebsocketFuturesOrderResponse{resp}) + require.ErrorIs(t, err, tc.error) + + require.Len(t, got, len(tc.expected)) + for i := range got { + assert.Equal(t, tc.expected[i], got[i]) + } + }) + } +} + +func TestWebsocketSubmitBatchOrders(t *testing.T) { + t.Parallel() + _, err := g.WebsocketSubmitBatchOrders(context.Background(), []*order.Submit{{}}) + require.ErrorIs(t, err, order.ErrExchangeNameUnset) + + dummy := &order.Submit{ + Exchange: "test", + Pair: currency.NewPair(currency.BTC, currency.USDT), + AssetType: asset.Spot, + Type: order.Market, + Side: order.Buy, + QuoteAmount: 1, + } + + other := *dummy + other.AssetType = asset.Futures + + _, err = g.WebsocketSubmitBatchOrders(context.Background(), []*order.Submit{dummy, &other}) + require.ErrorIs(t, err, errSingleAssetRequired) + + other.AssetType = asset.Futures + _, err = g.WebsocketSubmitBatchOrders(context.Background(), []*order.Submit{&other}) + require.ErrorIs(t, err, common.ErrNotYetImplemented) + + mockResponse1 := []byte(`{"header":{"response_time":"1736485230579","status":"200","channel":"spot.order_place","event":"api","client_id":"35.72.184.127-0xc05f1d8d00","conn_id":"6f7d1aad7c5d05cd","conn_trace_id":"4ca4bd9b2484834eb365c5e079ba69d8","trace_id":"b9835664bf217746b6a8e9644412d6cd","x_in_time":1736485230578603,"x_out_time":1736485230579021},"data":{"result":{"req_id":"777","api_key":"","timestamp":"1736485230","signature":"","trace_id":"","text":"","req_header":{},"req_param":[{"time_in_force":"ioc","text":"t-774","currency_pair":"RBC_USDT","type":"market","account":"spot","side":"buy","amount":"9.98662"},{"text":"t-775","currency_pair":"RBC_ETH","type":"market","account":"spot","side":"sell","amount":"397","time_in_force":"ioc"},{"time_in_force":"ioc","text":"t-776","currency_pair":"ETH_USDT","type":"market","account":"spot","side":"sell","amount":"0.003"}]}},"request_id":"777","ack":true}`) + mockResponse2 := []byte(`{"header":{"response_time":"1736485230624","status":"200","channel":"spot.order_place","event":"api","client_id":"35.72.184.127-0xc05f1d8d00","conn_trace_id":"4ca4bd9b2484834eb365c5e079ba69d8","trace_id":"b9835664bf217746b6a8e9644412d6cd","x_in_time":1736485230578603,"x_out_time":1736485230624136},"data":{"result":[{"account":"spot","status":"closed","side":"buy","amount":"9.98662","id":"771815277347","create_time":"1736485230","update_time":"1736485230","text":"t-774","left":"0.0002093","currency_pair":"RBC_USDT","type":"market","finish_as":"filled","price":"0","time_in_force":"ioc","iceberg":"0","filled_total":"9.9864107","fill_price":"9.9864107","create_time_ms":1736485230593,"update_time_ms":1736485230593,"succeeded":true},{"account":"spot","status":"closed","side":"sell","amount":"397","id":"771815277391","create_time":"1736485230","update_time":"1736485230","text":"t-775","left":"0","currency_pair":"RBC_ETH","type":"market","finish_as":"filled","price":"0","time_in_force":"ioc","iceberg":"0","filled_total":"0.002976309","fill_price":"0.002976309","create_time_ms":1736485230600,"update_time_ms":1736485230600,"succeeded":true},{"account":"spot","status":"closed","side":"sell","amount":"0.003","id":"771815277451","create_time":"1736485230","update_time":"1736485230","text":"t-776","left":"0","currency_pair":"ETH_USDT","type":"market","finish_as":"filled","price":"0","time_in_force":"ioc","iceberg":"0","filled_total":"9.76572","fill_price":"9.76572","create_time_ms":1736485230608,"update_time_ms":1736485230608,"succeeded":true}]},"request_id":"777"}`) + ctx := context.Background() + got, err := g.WebsocketSubmitBatchOrders(request.WithMockResponse(ctx, mockResponse1, mockResponse2), []*order.Submit{dummy, dummy, dummy}) + require.NoError(t, err) + require.Len(t, got, 3) + + mockResponse1 = []byte(`{"header":{"response_time":"1736980695937","status":"200","channel":"spot.order_place","event":"api","client_id":"35.72.184.127-0xc13ed551e0","conn_id":"138c696791d9dc0d","conn_trace_id":"dca3f78ba2d34e8c52a4217258783552","trace_id":"d096e1f953d017d054f678980aff4087","x_in_time":1736980695937125,"x_out_time":1736980695937383},"data":{"result":{"req_id":"743","api_key":"","timestamp":"1736980695","signature":"","trace_id":"","text":"","req_header":{},"req_param":[{"side":"buy","amount":"9.98","time_in_force":"fok","text":"t-740","currency_pair":"ETH_USDT","type":"market","account":"spot"},{"text":"t-741","currency_pair":"LIKE_ETH","type":"market","account":"spot","side":"buy","amount":"0.00289718","time_in_force":"fok"},{"type":"market","account":"spot","side":"sell","amount":"297.16","time_in_force":"fok","text":"t-742","currency_pair":"LIKE_USDT"}]}},"request_id":"743","ack":true}`) + mockResponse2 = []byte(`{"header":{"response_time":"1736980695972","status":"200","channel":"spot.order_place","event":"api","client_id":"35.72.184.127-0xc13ed551e0","conn_trace_id":"dca3f78ba2d34e8c52a4217258783552","trace_id":"d096e1f953d017d054f678980aff4087","x_in_time":1736980695937125,"x_out_time":1736980695972307},"data":{"result":[{"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},{"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},{"text":"t-742","label":"BALANCE_NOT_ENOUGH","message":"Not enough balance"}]},"request_id":"743"}`) + got, err = g.WebsocketSubmitBatchOrders(request.WithMockResponse(ctx, mockResponse1, mockResponse2), []*order.Submit{dummy, dummy, dummy}) + require.NoError(t, err) + require.Len(t, got, 3) + require.ErrorIs(t, got[2].Error, order.ErrUnableToPlaceOrder) +} diff --git a/exchanges/gateio/gateio_types.go b/exchanges/gateio/gateio_types.go index 3e2d7520237..4cee0332141 100644 --- a/exchanges/gateio/gateio_types.go +++ b/exchanges/gateio/gateio_types.go @@ -14,7 +14,7 @@ const ( // Order time in force variables gtcTIF = "gtc" // good-'til-canceled iocTIF = "ioc" // immediate-or-cancel - pocTIF = "poc" + pocTIF = "poc" // pending-or-cancel - post only fokTIF = "fok" // fill-or-kill // Frequently used order Status @@ -1301,7 +1301,7 @@ type CrossMarginBalance struct { BorrowedNet string `json:"borrowed_net"` TotalNetAssetInUSDT string `json:"net"` PositionLeverage string `json:"leverage"` - Risk string `json:"risk"` // Risk rate. When it belows 110%, liquidation will be triggered. Calculation formula: total / (borrowed+interest) + Risk string `json:"risk"` // Risk rate. When it falls below 110%, liquidation will be triggered. Calculation formula: total / (borrowed+interest) } // WalletSavedAddress represents currency saved address @@ -1364,8 +1364,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"` @@ -1376,6 +1376,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. @@ -1800,18 +1802,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, requires zero in size field + 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 5849e72d706..e3160a350c9 100644 --- a/exchanges/gateio/gateio_websocket.go +++ b/exchanges/gateio/gateio_websocket.go @@ -25,6 +25,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" @@ -98,11 +99,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 { @@ -816,3 +812,72 @@ func (g *Gateio) handleSubscription(ctx context.Context, conn stream.Connection, } return errs } + +// ResultHolder is used to unmarshal the result of a websocket request back to the required caller type +type ResultHolder 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(ctx, 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, &ResultHolder{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 628824a08ca..b5840a4ee42 100644 --- a/exchanges/gateio/gateio_websocket_futures.go +++ b/exchanges/gateio/gateio_websocket_futures.go @@ -154,11 +154,12 @@ func (g *Gateio) WsHandleFuturesData(_ context.Context, respRaw []byte, a asset. 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 { @@ -175,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 7ec39737e6e..cdc84c28a1d 100644 --- a/exchanges/gateio/gateio_websocket_option.go +++ b/exchanges/gateio/gateio_websocket_option.go @@ -300,10 +300,7 @@ func (g *Gateio) WsHandleOptionsData(_ context.Context, respRaw []byte) error { } 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..fe5c228a35c --- /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, 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..979ecebe134 --- /dev/null +++ b/exchanges/gateio/gateio_websocket_request_futures_test.go @@ -0,0 +1,222 @@ +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/request" + "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().Format(currency.PairFormat{Uppercase: true, Delimiter: "_"})} + _, 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 + out.AutoSize = "" + + 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()) + require.ErrorIs(t, err, errOrdersEmpty) + + out := &ContractOrderCreateParams{} + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), out) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + out.Contract, err = currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), out) + require.ErrorIs(t, err, errInvalidPrice) + + out.Price = "40000" + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), out) + require.ErrorIs(t, err, errInvalidAmount) + + out.Size = 1 // 1 lovely long contract + out.AutoSize = "silly_billies" + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), out) + require.ErrorIs(t, err, errInvalidAutoSize) + + out.AutoSize = "close_long" + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), 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(), out, &outBad) + require.ErrorIs(t, err, errSettlementCurrencyConflict) + + outBad.Contract, out.Contract = out.Contract, outBad.Contract // swapsies + _, err = g.WebsocketFuturesSubmitOrders(context.Background(), 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(request.WithVerbose(context.Background()), out) + require.NoError(t, err) + require.NotEmpty(t, got) + + // test batch orders + got, err = g.WebsocketFuturesSubmitOrders(context.Background(), 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..764b42b6275 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, 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..5e0d875204c 100644 --- a/exchanges/gateio/gateio_websocket_request_spot_test.go +++ b/exchanges/gateio/gateio_websocket_request_spot_test.go @@ -2,6 +2,7 @@ package gateio import ( "context" + "path/filepath" "strings" "testing" @@ -14,6 +15,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" "github.com/thrasher-corp/gocryptotrader/exchanges/stream" testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" + testutils "github.com/thrasher-corp/gocryptotrader/internal/testing/utils" ) func TestWebsocketLogin(t *testing.T) { @@ -29,7 +31,7 @@ func TestWebsocketLogin(t *testing.T) { testexch.UpdatePairsOnce(t, g) g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes - demonstrationConn, err := g.Websocket.GetConnection(asset.Spot) + demonstrationConn, err := g.Websocket.GetConnection(context.Background(), asset.Spot) require.NoError(t, err) err = g.websocketLogin(context.Background(), demonstrationConn, "spot.login") @@ -38,19 +40,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.NewPair(currency.NewCode("GT"), currency.USDT).Format(currency.PairFormat{Uppercase: true, Delimiter: "_"})} _, err = g.WebsocketSpotSubmitOrder(context.Background(), out) require.ErrorIs(t, err, order.ErrSideIsInvalid) - out.Side = strings.ToLower(order.Buy.String()) + out.Side = strings.ToLower(order.Sell.String()) _, err = g.WebsocketSpotSubmitOrder(context.Background(), out) require.ErrorIs(t, err, errInvalidAmount) - out.Amount = "0.0003" + out.Amount = 1 out.Type = "limit" _, err = g.WebsocketSpotSubmitOrder(context.Background(), out) require.ErrorIs(t, err, errInvalidPrice) - out.Price = "20000" + out.Price = 100 sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) @@ -64,21 +66,22 @@ func TestWebsocketSpotSubmitOrder(t *testing.T) { func TestWebsocketSpotSubmitOrders(t *testing.T) { t.Parallel() - _, err := g.WebsocketSpotSubmitOrders(context.Background(), nil) + _, err := g.WebsocketSpotSubmitOrders(context.Background()) require.ErrorIs(t, err, errOrdersEmpty) - _, err = g.WebsocketSpotSubmitOrders(context.Background(), make([]WebsocketOrder, 1)) + out := &CreateOrderRequest{} + _, err = g.WebsocketSpotSubmitOrders(context.Background(), out) require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) - out := WebsocketOrder{CurrencyPair: "BTC_USDT"} - _, err = g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out}) + out.CurrencyPair = currency.NewBTCUSDT() + _, err = g.WebsocketSpotSubmitOrders(context.Background(), 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(), 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(), out) require.ErrorIs(t, err, errInvalidPrice) - out.Price = "20000" + out.Price = 20000 sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) @@ -86,12 +89,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(), 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(), out, out) require.NoError(t, err) require.NotEmpty(t, got) } @@ -219,9 +222,16 @@ func TestWebsocketSpotGetOrderStatus(t *testing.T) { func getWebsocketInstance(t *testing.T, g *Gateio) *Gateio { t.Helper() + cfg := &config.Config{} + + root, err := testutils.RootPathFromCWD() + require.NoError(t, err) + + require.NoError(t, cfg.LoadConfig(filepath.Join(root, "testdata", "configtest.json"), true)) + cpy := new(Gateio) cpy.SetDefaults() - gConf, err := config.GetConfig().GetExchangeConfig("GateIO") + gConf, err := cfg.GetExchangeConfig("GateIO") require.NoError(t, err) gConf.API.AuthenticatedSupport = true gConf.API.AuthenticatedWebsocketSupport = true @@ -231,15 +241,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..57015a5b6d7 100644 --- a/exchanges/gateio/gateio_websocket_request_types.go +++ b/exchanges/gateio/gateio_websocket_request_types.go @@ -4,6 +4,7 @@ import ( "encoding/json" "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" "github.com/thrasher-corp/gocryptotrader/types" ) @@ -52,22 +53,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"` @@ -79,7 +64,7 @@ type WebsocketOrderResponse struct { TimeInForce string `json:"time_in_force"` CurrencyPair currency.Pair `json:"currency_pair"` Type string `json:"type"` - Account string `json:"account"` + Account asset.Item `json:"account"` Side string `json:"side"` AmendText string `json:"amend_text"` Text string `json:"text"` @@ -101,11 +86,37 @@ type WebsocketOrderResponse struct { RebatedFeeCurrency currency.Code `json:"rebated_fee_currency"` STPID int `json:"stp_id"` STPAct 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 +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 float64 `json:"size"` + Left float64 `json:"left"` + User int64 `json:"user"` + Succeeded *bool `json:"succeeded"` // Nil if not present in returned response. + IsReduceOnly bool `json:"is_reduce_only"` } // 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"` } @@ -113,7 +124,7 @@ type WebsocketOrderBatchRequest struct { // WebsocketOrderRequest defines a websocket order request type WebsocketOrderRequest struct { OrderID string `json:"order_id"` // This requires order_id tag - Pair string `json:"pair"` + Pair string `json:"currency_pair"` Account string `json:"account,omitempty"` } @@ -141,3 +152,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 239e3179245..9adaf44b986 100644 --- a/exchanges/gateio/gateio_wrapper.go +++ b/exchanges/gateio/gateio_wrapper.go @@ -40,6 +40,11 @@ import ( // this error. const unfundedFuturesAccount = `please transfer funds first to create futures account` +var ( + errNoResponseReceived = errors.New("no response received") + errSingleAssetRequired = errors.New("single asset type required") +) + // SetDefaults sets default values for the exchange func (g *Gateio) SetDefaults() { g.Name = "GateIO" @@ -237,6 +242,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, }) @@ -1057,34 +1063,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 } @@ -1129,7 +1115,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. @@ -1145,7 +1131,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 @@ -1175,7 +1161,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. @@ -1192,7 +1178,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 @@ -1510,7 +1496,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 @@ -1669,7 +1655,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 @@ -1732,9 +1718,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) { @@ -1750,7 +1736,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 } @@ -1773,14 +1759,14 @@ func (g *Gateio) GetActiveOrders(ctx context.Context, req *order.MultiOrderReque Type: order.Limit, SettlementCurrency: settlement, ReduceOnly: futuresOrders[x].IsReduceOnly, - PostOnly: futuresOrders[x].TimeInForce == "poc", + PostOnly: futuresOrders[x].TimeInForce == pocTIF, AverageExecutedPrice: futuresOrders[x].FillPrice.Float64(), }) } } 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 } @@ -2543,11 +2529,11 @@ func getClientOrderIDFromText(text string) string { // getTypeFromTimeInForce returns the order type and if the order is post only func getTypeFromTimeInForce(tif string) (orderType order.Type, postOnly bool) { switch tif { - case "ioc": + case iocTIF: return order.Market, false - case "fok": + case fokTIF: return order.Market, false - case "poc": + case pocTIF: return order.Limit, true default: return order.Limit, false @@ -2581,16 +2567,16 @@ var errPostOnlyOrderTypeUnsupported = errors.New("post only is only supported fo func getTimeInForce(s *order.Submit) (string, error) { timeInForce := "gtc" // limit order taker/maker if s.Type == order.Market || s.ImmediateOrCancel { - timeInForce = "ioc" // market taker only + timeInForce = iocTIF // market taker only } if s.PostOnly { if s.Type != order.Limit { return "", fmt.Errorf("%w not for %v", errPostOnlyOrderTypeUnsupported, s.Type) } - timeInForce = "poc" // limit order maker only + timeInForce = pocTIF // limit order maker only } if s.FillOrKill { - timeInForce = "fok" // market order entire fill or kill + timeInForce = fokTIF // limit order entire fill or kill } return timeInForce, nil } @@ -2613,3 +2599,288 @@ 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. +// NOTE: Regarding spot orders, fee is applied to purchased currency. +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 + } + + var sr []*order.SubmitResponse + sr, err = g.DeriveSpotSubmitOrderResponses(got) + if err != nil { + return nil, err + } + return sr[0], 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 + } + var sr []*order.SubmitResponse + sr, err = g.DeriveFuturesSubmitOrderResponses(got) + if err != nil { + return nil, err + } + return sr[0], nil + default: + return nil, common.ErrNotYetImplemented + } +} + +// WebsocketSubmitBatchOrders submits multiple orders to the exchange through the websocket +// RE: Spot batch orders; cannot derive purchased amount as the average price is omitted from the response and the fill +// price is not accurate. +func (g *Gateio) WebsocketSubmitBatchOrders(ctx context.Context, orders []*order.Submit) (responses []*order.SubmitResponse, err error) { + var a asset.Item + for x := range orders { + if err = orders[x].Validate(g.GetTradingRequirements()); err != nil { + return nil, err + } + + if !a.IsValid() { + a = orders[x].AssetType + continue + } + + if a != orders[x].AssetType { + return nil, fmt.Errorf("%w %v", errSingleAssetRequired, a) + } + } + + if !g.CurrencyPairs.IsAssetSupported(a) { + return nil, fmt.Errorf("%w %v", asset.ErrNotSupported, a) + } + + switch a { + case asset.Spot: + reqs := make([]*CreateOrderRequest, len(orders)) + for x := range orders { + reqs[x], err = g.getSpotOrderRequest(orders[x]) + if err != nil { + return nil, err + } + } + + got, err := g.WebsocketSpotSubmitOrders(ctx, reqs...) + if err != nil { + return nil, err + } + + resps, err := g.DeriveSpotSubmitOrderResponses(got) + if err != nil { + return nil, err + } + return resps, nil + default: + return nil, fmt.Errorf("%w for %s", common.ErrNotYetImplemented, a) + } +} + +// DeriveSpotSubmitOrderResponses returns the order submission responses for spot +func (g *Gateio) DeriveSpotSubmitOrderResponses(responses []WebsocketOrderResponse) ([]*order.SubmitResponse, error) { + if len(responses) == 0 { + return nil, errNoResponseReceived + } + + out := make([]*order.SubmitResponse, 0, len(responses)) + for x := range responses { + if responses[x].Label != "" { // Only returned in a batch order response context + out = append(out, &order.SubmitResponse{ + Exchange: g.Name, + ClientOrderID: responses[x].Text, + Error: fmt.Errorf("%w reason label:%s message:%s", order.ErrUnableToPlaceOrder, responses[x].Label, responses[x].Message), + }) + continue + } + + side, err := order.StringToOrderSide(responses[x].Side) + if err != nil { + return nil, err + } + status := order.Open + if responses[x].FinishAs != "" && responses[x].FinishAs != statusOpen { + status, err = order.StringToOrderStatus(responses[x].FinishAs) + if err != nil { + return nil, err + } + } + oType, err := order.StringToOrderType(responses[x].Type) + if err != nil { + return nil, err + } + + var cost float64 + var purchased float64 + if responses[x].AverageDealPrice != 0 { + if side.IsLong() { + cost = responses[x].FilledTotal.Float64() + purchased = responses[x].FilledTotal.Decimal().Div(responses[x].AverageDealPrice.Decimal()).InexactFloat64() + } else { + cost = responses[x].Amount.Float64() + purchased = responses[x].FilledTotal.Float64() + } + } + + out = append(out, &order.SubmitResponse{ + Exchange: g.Name, + OrderID: responses[x].ID, + AssetType: responses[x].Account, + Pair: responses[x].CurrencyPair, + ClientOrderID: responses[x].Text, + Date: responses[x].CreateTimeMs.Time(), + LastUpdated: responses[x].UpdateTimeMs.Time(), + RemainingAmount: responses[x].Left.Float64(), + Amount: responses[x].Amount.Float64(), + Price: responses[x].Price.Float64(), + AverageExecutedPrice: responses[x].AverageDealPrice.Float64(), + Type: oType, + Side: side, + Status: status, + ImmediateOrCancel: responses[x].TimeInForce == iocTIF, + FillOrKill: responses[x].TimeInForce == fokTIF, + PostOnly: responses[x].TimeInForce == pocTIF, + Cost: cost, + Purchased: purchased, + Fee: responses[x].Fee.Float64(), + FeeAsset: responses[x].FeeCurrency, + }) + } + return out, nil +} + +// DeriveFuturesSubmitOrderResponses returns the order submission responses for futures +func (g *Gateio) DeriveFuturesSubmitOrderResponses(responses []WebsocketFuturesOrderResponse) ([]*order.SubmitResponse, error) { + if len(responses) == 0 { + return nil, errNoResponseReceived + } + + out := make([]*order.SubmitResponse, 0, len(responses)) + for x := range responses { + status := order.Open + if responses[x].FinishAs != "" && responses[x].FinishAs != statusOpen { + var err error + status, err = order.StringToOrderStatus(responses[x].FinishAs) + if err != nil { + return nil, err + } + } + + oType := order.Market + if responses[x].Price != 0 { + oType = order.Limit + } + + side := order.Long + if responses[x].Size < 0 { + side = order.Short + } + + if responses[x].IsReduceOnly { + if side.IsLong() { + side = order.Short + } else { + side = order.Long + } + } + + var clientOrderID string + if responses[x].Text != "" && strings.HasPrefix(responses[x].Text, "t-") { + clientOrderID = responses[x].Text + } + + out = append(out, &order.SubmitResponse{ + Exchange: g.Name, + OrderID: strconv.FormatInt(responses[x].ID, 10), + AssetType: asset.Futures, + Pair: responses[x].Contract, + ClientOrderID: clientOrderID, + Date: responses[x].CreateTime.Time(), + LastUpdated: responses[x].UpdateTime.Time(), + RemainingAmount: math.Abs(responses[x].Left), + Amount: math.Abs(responses[x].Size), + Price: responses[x].Price.Float64(), + AverageExecutedPrice: responses[x].FillPrice.Float64(), + Type: oType, + Side: side, + Status: status, + ImmediateOrCancel: responses[x].TimeInForce == iocTIF, + FillOrKill: responses[x].TimeInForce == fokTIF, + PostOnly: responses[x].TimeInForce == pocTIF, + ReduceOnly: responses[x].IsReduceOnly, + }) + } + return out, nil +} + +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 6ef32727d63..d1c3af7ea74 100644 --- a/exchanges/interfaces.go +++ b/exchanges/interfaces.go @@ -134,6 +134,11 @@ 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) + // WebsocketSubmitBatchOrders submits multiple orders in a batch via the websocket connection + WebsocketSubmitBatchOrders(ctx context.Context, orders []*order.Submit) (responses []*order.SubmitResponse, err error) } // CurrencyStateManagement defines functionality for currency state management diff --git a/exchanges/order/order_test.go b/exchanges/order/order_test.go index b01d62aeba9..af527ae41b4 100644 --- a/exchanges/order/order_test.go +++ b/exchanges/order/order_test.go @@ -38,7 +38,7 @@ func TestSubmit_Validate(t *testing.T) { Submit: nil, }, // nil struct { - ExpectedErr: errExchangeNameUnset, + ExpectedErr: ErrExchangeNameUnset, Submit: &Submit{}, }, // empty exchange { diff --git a/exchanges/order/order_types.go b/exchanges/order/order_types.go index 75193c1ccab..99ccb9c4436 100644 --- a/exchanges/order/order_types.go +++ b/exchanges/order/order_types.go @@ -111,6 +111,7 @@ type SubmitResponse struct { AverageExecutedPrice float64 Amount float64 QuoteAmount float64 + RemainingAmount float64 TriggerPrice float64 ClientID string ClientOrderID string @@ -122,11 +123,17 @@ type SubmitResponse struct { Trades []TradeHistory Fee float64 FeeAsset currency.Code - Cost float64 + // Cost is the total cost of the order if hitting the bids it will be the base amount else lifting the asks quote amount + Cost float64 + // Purchased is the amount of purchased currency hitting the bids will quote amount else lifting asks base amount + Purchased float64 BorrowSize float64 LoanApplyID string MarginType margin.Type + + // Error is populated if the order was not successful, this is used in batch order submissions + Error error } // Modify contains all properties of an order diff --git a/exchanges/order/orders.go b/exchanges/order/orders.go index ad09ba5dfc8..61cc4f1b175 100644 --- a/exchanges/order/orders.go +++ b/exchanges/order/orders.go @@ -39,13 +39,13 @@ var ( ErrAmountMustBeSet = errors.New("amount must be set") ErrClientOrderIDMustBeSet = errors.New("client order ID must be set") ErrUnknownSubmissionAmountType = errors.New("unknown submission amount type") + ErrExchangeNameUnset = errors.New("exchange name unset") ) var ( errTimeInForceConflict = errors.New("multiple time in force options applied") errUnrecognisedOrderType = errors.New("unrecognised order type") errUnrecognisedOrderStatus = errors.New("unrecognised order status") - errExchangeNameUnset = errors.New("exchange name unset") errOrderSubmitIsNil = errors.New("order submit is nil") errOrderSubmitResponseIsNil = errors.New("order submit response is nil") errOrderDetailIsNil = errors.New("order detail is nil") @@ -64,7 +64,7 @@ func (s *Submit) Validate(requirements protocol.TradingRequirements, opt ...vali } if s.Exchange == "" { - return errExchangeNameUnset + return ErrExchangeNameUnset } if s.Pair.IsEmpty() { diff --git a/exchanges/request/mock.go b/exchanges/request/mock.go new file mode 100644 index 00000000000..e073a1eb6dd --- /dev/null +++ b/exchanges/request/mock.go @@ -0,0 +1,38 @@ +package request + +import ( + "bytes" + "context" + "io" + "net/http" +) + +var mockResponseFlag = struct{ name string }{name: "mockResponse"} + +// IsMockResponse returns true if the request has a mock response set +func IsMockResponse(ctx context.Context) bool { + return ctx.Value(mockResponseFlag) != nil +} + +// WithMockResponse sets the mock response for a request. This is used for testing purposes. +// REST response is single. Websocket response can be multiple. This allows expected responses to be set for a request if required. +func WithMockResponse(ctx context.Context, mockResponse ...[]byte) context.Context { + return context.WithValue(ctx, mockResponseFlag, mockResponse) +} + +// GetMockResponse returns the mock response for a request +func GetMockResponse(ctx context.Context) [][]byte { + mockResponse, _ := ctx.Value(mockResponseFlag).([][]byte) + return mockResponse +} + +func getRESTResponseFromMock(ctx context.Context) *http.Response { + mockResp := GetMockResponse(ctx) + if len(mockResp) != 1 { + panic("mock REST response invalid, requires exactly one response") + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(io.Reader(io.LimitReader(bytes.NewBuffer(mockResp[0]), drainBodyLimit))), + } +} diff --git a/exchanges/request/mock_test.go b/exchanges/request/mock_test.go new file mode 100644 index 00000000000..af1ba3f70d5 --- /dev/null +++ b/exchanges/request/mock_test.go @@ -0,0 +1,27 @@ +package request + +import ( + "context" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMockResponse(t *testing.T) { + t.Parallel() + + ctx := context.Background() + require.False(t, IsMockResponse(ctx)) + require.Nil(t, GetMockResponse(ctx)) + require.Panics(t, func() { getRESTResponseFromMock(ctx) }) + mockCtx := WithMockResponse(ctx, []byte("test")) + require.True(t, IsMockResponse(mockCtx)) + require.NotNil(t, GetMockResponse(mockCtx)) + got := getRESTResponseFromMock(mockCtx) + require.NotNil(t, got) + require.Equal(t, 200, got.StatusCode) + hotBod, err := io.ReadAll(got.Body) + require.NoError(t, err) + require.Equal(t, []byte("test"), hotBod) +} diff --git a/exchanges/request/request.go b/exchanges/request/request.go index 8bc1690ce83..619f92c2ae7 100644 --- a/exchanges/request/request.go +++ b/exchanges/request/request.go @@ -194,7 +194,12 @@ func (r *Requester) doRequest(ctx context.Context, endpoint EndpointLimit, newRe start := time.Now() - resp, err := r._HTTPClient.do(req) + var resp *http.Response + if IsMockResponse(ctx) { + resp = getRESTResponseFromMock(ctx) + } else { + resp, err = r._HTTPClient.do(req) + } if r.reporter != nil && err == nil { r.reporter.Latency(r.name, p.Method, p.Path, time.Since(start)) @@ -204,7 +209,7 @@ func (r *Requester) doRequest(ctx context.Context, endpoint EndpointLimit, newRe return checkErr } else if retry { if err == nil { - // If the body isn't fully read, the connection cannot be re-used + // If the body isn't fully read, the connection cannot be reused r.drainBody(resp.Body) } @@ -383,10 +388,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..db660584f39 100644 --- a/exchanges/request/request_test.go +++ b/exchanges/request/request_test.go @@ -373,6 +373,15 @@ func TestDoRequest(t *testing.T) { if failed != 0 { t.Fatal("request failed") } + + m := struct { + Mock bool `json:"mock"` + }{} + + ctx = WithMockResponse(ctx, []byte(`{"mock":true}`)) + err = r.SendPayload(ctx, UnAuth, func() (*Item, error) { return &Item{Method: http.MethodGet, Path: testURL, Result: &m}, nil }, UnauthenticatedRequest) + require.NoError(t, err) + require.True(t, m.Mock) } func TestDoRequest_Retries(t *testing.T) { @@ -698,7 +707,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/mock.go b/exchanges/stream/mock.go new file mode 100644 index 00000000000..e59e7515dec --- /dev/null +++ b/exchanges/stream/mock.go @@ -0,0 +1,33 @@ +package stream + +import ( + "context" + + "github.com/thrasher-corp/gocryptotrader/exchanges/request" +) + +// MockWebsocketConnection is a mock websocket connection +type MockWebsocketConnection struct { + WebsocketConnection +} + +// SendMessageReturnResponse returns a mock response from context +func (m *MockWebsocketConnection) SendMessageReturnResponse(ctx context.Context, epl request.EndpointLimit, signature, payload any) ([]byte, error) { + resps, _ := m.SendMessageReturnResponses(ctx, epl, signature, payload, 1) + return resps[0], nil +} + +// SendMessageReturnResponses returns a mock response from context +func (m *MockWebsocketConnection) SendMessageReturnResponses(ctx context.Context, epl request.EndpointLimit, signature, payload any, expected int) ([][]byte, error) { + return m.SendMessageReturnResponsesWithInspector(ctx, epl, signature, payload, expected, nil) +} + +// SendMessageReturnResponsesWithInspector returns a mock response from context +func (*MockWebsocketConnection) SendMessageReturnResponsesWithInspector(ctx context.Context, _ request.EndpointLimit, _, _ any, _ int, _ Inspector) ([][]byte, error) { + return request.GetMockResponse(ctx), nil +} + +// newMockConnection returns a new mock websocket connection, used so that the websocket does not need to be connected +func newMockWebsocketConnection() Connection { + return &MockWebsocketConnection{} +} diff --git a/exchanges/stream/mock_test.go b/exchanges/stream/mock_test.go new file mode 100644 index 00000000000..df3b2f89b06 --- /dev/null +++ b/exchanges/stream/mock_test.go @@ -0,0 +1,25 @@ +package stream + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" +) + +func TestNewMockWebsocketConnection(t *testing.T) { + t.Parallel() + got := newMockWebsocketConnection() + require.NotNil(t, got) + require.Panics(t, func() { got.SendMessageReturnResponse(context.Background(), 0, nil, nil) }) + resp, err := got.SendMessageReturnResponsesWithInspector(context.Background(), 0, nil, nil, 0, nil) + require.NoError(t, err) + require.Nil(t, resp) + singleResp, err := got.SendMessageReturnResponse(request.WithMockResponse(context.Background(), []byte("test")), 0, nil, nil) + require.NoError(t, err) + require.NotNil(t, singleResp) + resp, err = got.SendMessageReturnResponsesWithInspector(request.WithMockResponse(context.Background(), []byte("test")), 0, nil, nil, 0, nil) + require.NoError(t, err) + require.NotNil(t, resp) +} diff --git a/exchanges/stream/websocket.go b/exchanges/stream/websocket.go index 737c0eadc7f..e2bbefd0666 100644 --- a/exchanges/stream/websocket.go +++ b/exchanges/stream/websocket.go @@ -13,6 +13,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/config" "github.com/thrasher-corp/gocryptotrader/exchanges/protocol" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" "github.com/thrasher-corp/gocryptotrader/exchanges/stream/buffer" "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" "github.com/thrasher-corp/gocryptotrader/log" @@ -1265,7 +1266,7 @@ func signalReceived(ch chan struct{}) bool { // GetConnection returns a connection by message filter (defined in exchange package _wrapper.go websocket connection) // for request and response handling in a multi connection context. -func (w *Websocket) GetConnection(messageFilter any) (Connection, error) { +func (w *Websocket) GetConnection(ctx context.Context, messageFilter any) (Connection, error) { if w == nil { return nil, fmt.Errorf("%w: %T", common.ErrNilPointer, w) } @@ -1274,6 +1275,10 @@ func (w *Websocket) GetConnection(messageFilter any) (Connection, error) { return nil, errMessageFilterNotSet } + if request.IsMockResponse(ctx) { + return newMockWebsocketConnection(), nil + } + w.m.Lock() defer w.m.Unlock() 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 } diff --git a/exchanges/stream/websocket_test.go b/exchanges/stream/websocket_test.go index b6f3a762404..2cbddee6aa3 100644 --- a/exchanges/stream/websocket_test.go +++ b/exchanges/stream/websocket_test.go @@ -1531,38 +1531,43 @@ func TestMonitorTraffic(t *testing.T) { func TestGetConnection(t *testing.T) { t.Parallel() var ws *Websocket - _, err := ws.GetConnection(nil) + _, err := ws.GetConnection(context.Background(), nil) require.ErrorIs(t, err, common.ErrNilPointer) ws = &Websocket{} - _, err = ws.GetConnection(nil) + _, err = ws.GetConnection(context.Background(), nil) require.ErrorIs(t, err, errMessageFilterNotSet) - _, err = ws.GetConnection("testURL") + _, err = ws.GetConnection(context.Background(), "testURL") require.ErrorIs(t, err, errCannotObtainOutboundConnection) ws.useMultiConnectionManagement = true - _, err = ws.GetConnection("testURL") + _, err = ws.GetConnection(context.Background(), "testURL") require.ErrorIs(t, err, ErrNotConnected) ws.setState(connectedState) - _, err = ws.GetConnection("testURL") + _, err = ws.GetConnection(context.Background(), "testURL") require.ErrorIs(t, err, ErrRequestRouteNotFound) ws.connectionManager = []*ConnectionWrapper{{ Setup: &ConnectionSetup{MessageFilter: "testURL", URL: "testURL"}, }} - _, err = ws.GetConnection("testURL") + _, err = ws.GetConnection(context.Background(), "testURL") require.ErrorIs(t, err, ErrNotConnected) expected := &WebsocketConnection{} ws.connectionManager[0].Connection = expected - conn, err := ws.GetConnection("testURL") + conn, err := ws.GetConnection(context.Background(), "testURL") require.NoError(t, err) assert.Same(t, expected, conn) + + conn, err = ws.GetConnection(request.WithMockResponse(context.Background(), []byte("mock")), "testURL") + require.NoError(t, err) + require.NotNil(t, conn) + require.Empty(t, conn) }