From 3f8d799613aa04f64ad8a6b270aad435678482c8 Mon Sep 17 00:00:00 2001 From: Ryan O'Hara-Reid Date: Wed, 17 Sep 2025 13:45:58 +1000 Subject: [PATCH] bybit: Add websocket trading functionality across all assets (#1672) * fix whoops * const trafficCheckInterval; rm testmain * y * fix lint * bump time check window * stream: fix intermittant test failures while testing routines and remove code that is not needed. * spells * cant do what I did * protect race due to routine. * update testURL * use mock websocket connection instead of test URL's * linter: fix * remove url because its throwing errors on CI builds * connections drop all the time, don't need to worry about not being able to echo back ws data as it can be easily reviewed _test file side. * remove another superfluous url thats not really set up for this * spawn overwatch routine when there is no errors, inline checker instead of waiting for a time period, add sleep inline with echo handler as this is really quick and wanted to ensure that latency is handing correctly * linter: fixerino uperino * fix ID bug, why I do this, I don't know. * glorious: panix * linter: things * whoops * dont need to make consecutive Unix() calls * websocket: fix potential panic on error and no responses and adding waitForResponses * bybit: enable multiconnection handling across websocket endpoints * rm debug lines * bybit: Add websocket trading functionality across all assets * rm json parser and handle in json package instead * in favour of json package unmarshalling * Add bool ConnectionDoesNotRequireSubscriptions so that we don't need to handle dummy sub * handle pong response * spelling * linter: fix * fix processing issues with tickers * fix processing issues with tickers * linter: fix * linter: fix again * * change field name OutboundRequestSignature to WrapperDefinedConnectionSignature for agnostic inbound and outbound connections. * change method name GetOutboundConnection to GetConnection for agnostic inbound and outbound connections. * drop outbound field map for improved performance just using a range and field check (less complex as well) * change field name connections to connectionToWrapper for better clarity * spells and magic and wands * merge: fixup * linter: fix * spelling: fix * glorious: nits * comparable check for signature * mv err var * rm comment as it does not * update time fields for orderbook latency * fix time conversion * Add func MatchReturnResponses * glorious: nits and stuff * lint: fix * attempt to fix race * linter: fix * fix tests * types/time: strict usage of time type for usage with unix timestamps * fix tests etc * Allow match back with order details * Add time in force values for different order types + extra return information on websocket trading * glorious: nits * gk: nits; engine log cleanup * gk: nits; OCD * gk: nits; move function change file names * gk: nits; :rocket: * gk: nits; convert variadic function and message inspection to interface and include a specific function for that handling so as to not need nil on every call * gk: nits; continued * gk: engine nits; rm loaded exchange * gk: nits; drop WebsocketLoginResponse * stream: Add match method EnsureMatchWithData * gk: nits; rn Inspect to IsFinal * gk: nits; rn to MessageFilter * linter: fix * gateio: update rate limit definitions (cherry-pick) * Add test and missing * Shared REST rate limit definitions with Websocket service, set lookup item to nil for systems that do not require rate limiting; add glorious nit * integrate rate limits for websocket trading spot * bybit: split public and private processing to dedicated handler add supporting function and tests * use correct handler for private inbound connection * bybit/websocket: allow a shared ID between outbound payloads for inbound matching * conform to match upstream changes * standardise names to upstream style * fix wrapper standards test when sending a auth request through a websocket connection * whoops * Update exchanges/gateio/gateio_types.go Co-authored-by: Scott * glorious: nits * linter: fix * linter: overload * whoops * spelling fixes on recent merge * glorious: nits * linter: fix? * glorious: nits * gk: assert errors touched * gk: unexport derive functions * gk: nitssssssss * fix test * gk: nitters v1 * gk: http status * gk/nits: Add getAssetFromFuturesPair * gk: nits single response when submitting * gk: new pair with delimiter in tests * gk: param update slice to slice of pointers * gk: add asset type in params, includes t.Context() for tests * linter: fix * linter: fix * fix merge whoopsie * glorious: nits * gk: nit * linter: fix * glorious: nits * linter/misc: fix and remove meows * linter: fix * misc/linter: fix * change function names * okx: update requestID gen func without func wrapping * RM: functions not needed * Update docs/ADD_NEW_EXCHANGE.md Co-authored-by: Gareth Kirwan * gk: nitsssssss * linter: fix * Update exchanges/bybit/bybit_test.go Co-authored-by: Gareth Kirwan * Update exchanges/bybit/bybit_test.go Co-authored-by: Gareth Kirwan * gk: nit words * cranktakular: nits * websocket: skip connections with subscriptions not required during channel flush * websocket: simplify error handling in FlushChannels using if short * linter: fix * cranktakular: nits and expand coverage * linter: fix? * misc fix * cranktakular: missing nit which I thumbed up but did not do. Sillllllly billlyyyy nilllyyy * fix comments * bybit: fix merge regression on websocket message filter * cranktakular: nits * bybit: Add global rate limits for websocket * ai: nits * linter: fix * cranktakular: purge DCP ref/handling and add another TODO * Update exchanges/bybit/bybit_websocket.go Co-authored-by: Scott * glorious: nits * fix test * fix alignment issue and rm println * Update exchanges/bybit/bybit_websocket.go Co-authored-by: Scott * Update exchanges/bybit/bybit_websocket.go Co-authored-by: Scott * glorious: fix * Update exchanges/bybit/bybit_websocket.go Co-authored-by: Adrian Gallagher * bybit: use connection method for segregated match on multi-connection * cleanup after master merge * fix test and config whoops * cranktakular: nits * exchange: add missing tests for base method websocket order funcs * cranktakular: nits and refresh + tests * cranktakular: pedantic nits * linter: fixes * t.Parallel tests * glorious nit * Update exchange/websocket/connection.go Co-authored-by: Gareth Kirwan * gk: nits * boss king: nits * canktakular: nits * Update exchanges/bybit/bybit_websocket.go Co-authored-by: Gareth Kirwan * Update exchanges/bybit/bybit_websocket_requests.go Co-authored-by: Gareth Kirwan * Update exchanges/bybit/bybit_websocket_requests.go Co-authored-by: Gareth Kirwan * Update exchanges/bybit/bybit_websocket_requests.go Co-authored-by: Gareth Kirwan * Update exchanges/bybit/bybit_websocket_requests.go Co-authored-by: Gareth Kirwan * gk: nits * linter: fix * Update exchanges/bybit/bybit.go Co-authored-by: Samuael A. <39623015+samuael@users.noreply.github.com> * Update exchanges/bybit/bybit.go Co-authored-by: Samuael A. <39623015+samuael@users.noreply.github.com> * bossking: nits * gk: much nicer design * gk: revised naming for consideration * gk: nits * gk: nits restrict in configtest.json and not worry about many pairs enabled * rm log * linter: fix * codex: nit * cranktakular: nits * Update exchanges/bybit/bybit_websocket_requests.go Co-authored-by: Scott * Update exchanges/bybit/bybit_websocket_requests.go Co-authored-by: Scott * Update exchanges/bybit/bybit_wrapper.go Co-authored-by: Scott * glorious: nits! * thrasher: nits --------- Co-authored-by: shazbert Co-authored-by: Scott Co-authored-by: Gareth Kirwan Co-authored-by: Adrian Gallagher Co-authored-by: Samuael A. <39623015+samuael@users.noreply.github.com> --- engine/websocketroutine_manager.go | 2 +- exchange/websocket/connection.go | 47 +- exchange/websocket/connection_test.go | 58 +++ exchange/websocket/manager.go | 57 ++- exchange/websocket/manager_test.go | 15 - exchange/websocket/subscriptions.go | 7 +- exchanges/bybit/bybit.go | 140 ++---- exchanges/bybit/bybit_test.go | 445 ++++++++---------- exchanges/bybit/bybit_types.go | 89 ++-- exchanges/bybit/bybit_websocket.go | 139 ++++-- exchanges/bybit/bybit_websocket_requests.go | 141 ++++++ .../bybit/bybit_websocket_requests_test.go | 210 +++++++++ .../bybit/bybit_websocket_requests_types.go | 85 ++++ exchanges/bybit/bybit_websocket_test.go | 40 ++ exchanges/bybit/bybit_wrapper.go | 265 +++++------ exchanges/bybit/linear_websocket_test.go | 6 +- exchanges/bybit/options_websocket_test.go | 6 +- exchanges/bybit/order_arguments.go | 132 ++++++ exchanges/bybit/order_arguments_test.go | 239 ++++++++++ exchanges/bybit/ratelimit.go | 152 +++--- exchanges/bybit/ratelimit_test.go | 35 ++ exchanges/bybit/spot_websocket.go | 2 +- exchanges/bybit/unmarshal.go | 10 + exchanges/bybit/unmarshal_test.go | 24 + exchanges/bybit/validate.go | 93 ++++ exchanges/bybit/validate_test.go | 195 ++++++++ exchanges/exchange.go | 14 + exchanges/exchange_test.go | 14 + exchanges/exchange_types.go | 3 + exchanges/gateio/gateio_wrapper.go | 4 +- exchanges/interfaces.go | 2 + exchanges/order/order_test.go | 14 +- exchanges/order/orders.go | 8 +- exchanges/protocol/features.go | 16 +- exchanges/request/limit.go | 3 + testdata/configtest.json | 10 +- 36 files changed, 1981 insertions(+), 741 deletions(-) create mode 100644 exchange/websocket/connection_test.go create mode 100644 exchanges/bybit/bybit_websocket_requests.go create mode 100644 exchanges/bybit/bybit_websocket_requests_test.go create mode 100644 exchanges/bybit/bybit_websocket_requests_types.go create mode 100644 exchanges/bybit/bybit_websocket_test.go create mode 100644 exchanges/bybit/order_arguments.go create mode 100644 exchanges/bybit/order_arguments_test.go create mode 100644 exchanges/bybit/ratelimit_test.go create mode 100644 exchanges/bybit/unmarshal.go create mode 100644 exchanges/bybit/unmarshal_test.go create mode 100644 exchanges/bybit/validate.go create mode 100644 exchanges/bybit/validate_test.go diff --git a/engine/websocketroutine_manager.go b/engine/websocketroutine_manager.go index 7c9efd7f..b2ee0ced 100644 --- a/engine/websocketroutine_manager.go +++ b/engine/websocketroutine_manager.go @@ -328,7 +328,7 @@ func (m *WebsocketRoutineManager) websocketDataHandler(exchName string, data any case order.ClassificationError: return fmt.Errorf("%w %s", d.Err, d.Error()) case websocket.UnhandledMessageWarning: - log.Warnln(log.WebsocketMgr, d.Message) + log.Warnf(log.WebsocketMgr, "%s unhandled message - %s", exchName, d.Message) case account.Change: if m.verbose { m.printAccountHoldingsChangeSummary(exchName, d) diff --git a/exchange/websocket/connection.go b/exchange/websocket/connection.go index 45f2be50..d7a8bbf8 100644 --- a/exchange/websocket/connection.go +++ b/exchange/websocket/connection.go @@ -57,15 +57,20 @@ type Connection interface { Shutdown() error // RequireMatchWithData routes incoming data using the connection specific match system to the correct handler RequireMatchWithData(signature any, incoming []byte) error + // IncomingWithData routes incoming data using the connection specific match system to the correct handler + IncomingWithData(signature any, data []byte) bool + // MatchReturnResponses sets up a channel to listen for an expected number of responses. + MatchReturnResponses(ctx context.Context, signature any, expected int) (<-chan MatchedResponse, error) } // ConnectionSetup defines variables for an individual stream connection type ConnectionSetup struct { - ResponseCheckTimeout time.Duration - ResponseMaxLimit time.Duration - RateLimit *request.RateLimiterWithWeight - Authenticated bool - ConnectionLevelReporter Reporter + ResponseCheckTimeout time.Duration + ResponseMaxLimit time.Duration + RateLimit *request.RateLimiterWithWeight + Authenticated bool // unused for multi-connection websocket + SubscriptionsNotRequired bool + ConnectionLevelReporter Reporter // URL defines the websocket server URL to connect to URL string @@ -92,7 +97,8 @@ type ConnectionSetup struct { // This is useful for when an exchange connection requires a unique or // structured message ID for each message sent. RequestIDGenerator func() int64 - Authenticate func(ctx context.Context, conn Connection) error + // Authenticate will be called to authenticate the connection + Authenticate func(ctx context.Context, conn Connection) error // MessageFilter defines the criteria used to match messages to a specific connection. // The filter enables precise routing and handling of messages for distinct connection contexts. MessageFilter any @@ -467,6 +473,30 @@ inspection: return resps, nil } +// MatchedResponse encapsulates the matched responses along with any errors encountered. +type MatchedResponse struct { + Responses [][]byte + Err error +} + +// MatchReturnResponses returns channel of exactly expected matched responses +func (c *connection) MatchReturnResponses(ctx context.Context, signature any, expected int) (<-chan MatchedResponse, error) { + connectionListen, err := c.Match.Set(signature, expected) + if err != nil { + return nil, err + } + + out := make(chan MatchedResponse, 1) // buffered so routine below doesn't block if no receiver + + go func() { + resps, err := c.waitForResponses(ctx, signature, connectionListen, expected, nil) + out <- MatchedResponse{Responses: resps, Err: err} + close(out) + }() + + return out, nil +} + func removeURLQueryString(u string) string { if index := strings.Index(u, "?"); index != -1 { return u[:index] @@ -478,3 +508,8 @@ func removeURLQueryString(u string) string { func (c *connection) RequireMatchWithData(signature any, incoming []byte) error { return c.Match.RequireMatchWithData(signature, incoming) } + +// IncomingWithData routes incoming data using the connection specific match system to the correct handler +func (c *connection) IncomingWithData(signature any, data []byte) bool { + return c.Match.IncomingWithData(signature, data) +} diff --git a/exchange/websocket/connection_test.go b/exchange/websocket/connection_test.go new file mode 100644 index 00000000..003c2a72 --- /dev/null +++ b/exchange/websocket/connection_test.go @@ -0,0 +1,58 @@ +package websocket + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatchReturnResponses(t *testing.T) { + t.Parallel() + + conn := connection{Match: NewMatch()} + _, err := conn.MatchReturnResponses(t.Context(), nil, 0) + require.ErrorIs(t, err, errInvalidBufferSize) + + ch, err := conn.MatchReturnResponses(t.Context(), nil, 1) + require.NoError(t, err) + + require.ErrorIs(t, (<-ch).Err, ErrSignatureTimeout) + conn.ResponseMaxLimit = time.Millisecond + + ch, err = conn.MatchReturnResponses(t.Context(), nil, 1) + require.NoError(t, err) + + exp := []byte("test") + require.True(t, conn.Match.IncomingWithData(nil, exp)) + assert.Equal(t, exp, (<-ch).Responses[0]) +} + +func TestWebsocketConnectionRequireMatchWithData(t *testing.T) { + t.Parallel() + ws := connection{Match: NewMatch()} + err := ws.RequireMatchWithData(0, nil) + require.ErrorIs(t, err, ErrSignatureNotMatched) + + ch, err := ws.Match.Set(0, 1) + require.NoError(t, err) + + err = ws.RequireMatchWithData(0, []byte("test")) + require.NoError(t, err) + require.Len(t, ch, 1, "must have one item in channel") + assert.Equal(t, []byte("test"), <-ch) +} + +func TestIncomingWithData(t *testing.T) { + t.Parallel() + ws := connection{Match: NewMatch()} + require.False(t, ws.IncomingWithData(0, nil)) + + ch, err := ws.Match.Set(0, 1) + require.NoError(t, err) + + require.True(t, ws.IncomingWithData(0, []byte("test"))) + require.Len(t, ch, 1, "must have one item in channel") + assert.Equal(t, []byte("test"), <-ch) +} diff --git a/exchange/websocket/manager.go b/exchange/websocket/manager.go index 7d6c67d5..45db1630 100644 --- a/exchange/websocket/manager.go +++ b/exchange/websocket/manager.go @@ -334,13 +334,13 @@ func (m *Manager) SetupNewConnection(c *ConnectionSetup) error { if c.Connector == nil { return fmt.Errorf("%w: %w", errConnSetup, errWebsocketConnectorUnset) } - if c.GenerateSubscriptions == nil { + if c.GenerateSubscriptions == nil && !c.SubscriptionsNotRequired { return fmt.Errorf("%w: %w", errConnSetup, errWebsocketSubscriptionsGeneratorUnset) } - if c.Subscriber == nil { + if c.Subscriber == nil && !c.SubscriptionsNotRequired { return fmt.Errorf("%w: %w", errConnSetup, errWebsocketSubscriberUnset) } - if c.Unsubscriber == nil && m.features.Unsubscribe { + if c.Unsubscriber == nil && m.features.Unsubscribe && !c.SubscriptionsNotRequired { return fmt.Errorf("%w: %w", errConnSetup, errWebsocketUnsubscriberUnset) } if c.Handler == nil { @@ -482,23 +482,27 @@ func (m *Manager) connect() error { // TODO: Implement concurrency below. for i := range m.connectionManager { - if m.connectionManager[i].setup.GenerateSubscriptions == nil { - multiConnectFatalError = fmt.Errorf("cannot connect to [conn:%d] [URL:%s]: %w ", i+1, m.connectionManager[i].setup.URL, errWebsocketSubscriptionsGeneratorUnset) - break - } - - subs, err := m.connectionManager[i].setup.GenerateSubscriptions() // regenerate state on new connection - if err != nil { - multiConnectFatalError = fmt.Errorf("%s websocket: %w", m.exchangeName, common.AppendError(ErrSubscriptionFailure, err)) - break - } - - if len(subs) == 0 { - // If no subscriptions are generated, we skip the connection - if m.verbose { - log.Warnf(log.WebsocketMgr, "%s websocket: no subscriptions generated", m.exchangeName) + var subs subscription.List + if !m.connectionManager[i].setup.SubscriptionsNotRequired { + if m.connectionManager[i].setup.GenerateSubscriptions == nil { + multiConnectFatalError = fmt.Errorf("cannot connect to [conn:%d] [URL:%s]: %w ", i+1, m.connectionManager[i].setup.URL, errWebsocketSubscriptionsGeneratorUnset) + break + } + + var err error + subs, err = m.connectionManager[i].setup.GenerateSubscriptions() // regenerate state on new connection + if err != nil { + multiConnectFatalError = fmt.Errorf("%s websocket: %w", m.exchangeName, common.AppendError(ErrSubscriptionFailure, err)) + break + } + + if len(subs) == 0 { + // If no subscriptions are generated, we skip the connection + if m.verbose { + log.Warnf(log.WebsocketMgr, "%s websocket: no subscriptions generated", m.exchangeName) + } + continue } - continue } if m.connectionManager[i].setup.Connector == nil { @@ -509,7 +513,7 @@ func (m *Manager) connect() error { multiConnectFatalError = fmt.Errorf("cannot connect to [conn:%d] [URL:%s]: %w ", i+1, m.connectionManager[i].setup.URL, errWebsocketDataHandlerUnset) break } - if m.connectionManager[i].setup.Subscriber == nil { + if m.connectionManager[i].setup.Subscriber == nil && !m.connectionManager[i].setup.SubscriptionsNotRequired { multiConnectFatalError = fmt.Errorf("cannot connect to [conn:%d] [URL:%s]: %w ", i+1, m.connectionManager[i].setup.URL, errWebsocketSubscriberUnset) break } @@ -518,8 +522,7 @@ func (m *Manager) connect() error { conn := m.getConnectionFromSetup(m.connectionManager[i].setup) - err = m.connectionManager[i].setup.Connector(context.TODO(), conn) - if err != nil { + if err := m.connectionManager[i].setup.Connector(context.TODO(), conn); err != nil { multiConnectFatalError = fmt.Errorf("%v Error connecting %w", m.exchangeName, err) break } @@ -536,15 +539,17 @@ func (m *Manager) connect() error { go m.Reader(context.TODO(), conn, m.connectionManager[i].setup.Handler) if m.connectionManager[i].setup.Authenticate != nil && m.CanUseAuthenticatedEndpoints() { - err = m.connectionManager[i].setup.Authenticate(context.TODO(), conn) - if err != nil { + if err := m.connectionManager[i].setup.Authenticate(context.TODO(), conn); err != nil { multiConnectFatalError = fmt.Errorf("%s websocket: [conn:%d] [URL:%s] failed to authenticate %w", m.exchangeName, i+1, conn.URL, err) break } } - err = m.connectionManager[i].setup.Subscriber(context.TODO(), conn, subs) - if err != nil { + if m.connectionManager[i].setup.SubscriptionsNotRequired { + continue + } + + if err := m.connectionManager[i].setup.Subscriber(context.TODO(), conn, subs); err != nil { subscriptionError = common.AppendError(subscriptionError, fmt.Errorf("%v Error subscribing %w", m.exchangeName, err)) continue } diff --git a/exchange/websocket/manager_test.go b/exchange/websocket/manager_test.go index 557a001a..32f29231 100644 --- a/exchange/websocket/manager_test.go +++ b/exchange/websocket/manager_test.go @@ -1336,18 +1336,3 @@ func TestGetConnection(t *testing.T) { require.NoError(t, err) assert.Same(t, expected, conn) } - -func TestWebsocketConnectionRequireMatchWithData(t *testing.T) { - t.Parallel() - ws := connection{Match: NewMatch()} - err := ws.RequireMatchWithData(0, nil) - require.ErrorIs(t, err, ErrSignatureNotMatched) - - ch, err := ws.Match.Set(0, 1) - require.NoError(t, err) - - err = ws.RequireMatchWithData(0, []byte("test")) - require.NoError(t, err) - require.Len(t, ch, 1, "must have one item in channel") - assert.Equal(t, []byte("test"), <-ch) -} diff --git a/exchange/websocket/subscriptions.go b/exchange/websocket/subscriptions.go index 8cea5353..b3dc280d 100644 --- a/exchange/websocket/subscriptions.go +++ b/exchange/websocket/subscriptions.go @@ -284,6 +284,10 @@ func (m *Manager) FlushChannels() error { } for x := range m.connectionManager { + if m.connectionManager[x].setup.SubscriptionsNotRequired { + continue + } + newSubs, err := m.connectionManager[x].setup.GenerateSubscriptions() if err != nil { return err @@ -306,8 +310,7 @@ func (m *Manager) FlushChannels() error { m.connectionManager[x].connection = conn } - err = m.updateChannelSubscriptions(m.connectionManager[x].connection, m.connectionManager[x].subscriptions, newSubs) - if err != nil { + if err := m.updateChannelSubscriptions(m.connectionManager[x].connection, m.connectionManager[x].subscriptions, newSubs); err != nil { return err } diff --git a/exchanges/bybit/bybit.go b/exchanges/bybit/bybit.go index 8513fe65..df8e571b 100644 --- a/exchanges/bybit/bybit.go +++ b/exchanges/bybit/bybit.go @@ -96,6 +96,7 @@ var ( var ( intervalMap = map[kline.Interval]string{kline.OneMin: "1", kline.ThreeMin: "3", kline.FiveMin: "5", kline.FifteenMin: "15", kline.ThirtyMin: "30", kline.OneHour: "60", kline.TwoHour: "120", kline.FourHour: "240", kline.SixHour: "360", kline.SevenHour: "720", kline.OneDay: "D", kline.OneWeek: "W", kline.OneMonth: "M"} stringToIntervalMap = map[string]kline.Interval{"1": kline.OneMin, "3": kline.ThreeMin, "5": kline.FiveMin, "15": kline.FifteenMin, "30": kline.ThirtyMin, "60": kline.OneHour, "120": kline.TwoHour, "240": kline.FourHour, "360": kline.SixHour, "720": kline.SevenHour, "D": kline.OneDay, "W": kline.OneWeek, "M": kline.OneMonth} + validCategory = []string{cSpot, cLinear, cOption, cInverse} ) func intervalToString(interval kline.Interval) (string, error) { @@ -250,7 +251,23 @@ func (e *Exchange) GetOrderBook(ctx context.Context, category, symbol string, li if err != nil { return nil, err } - return constructOrderbook(&resp) + + return &Orderbook{ + Symbol: resp.Symbol, + UpdateID: resp.UpdateID, + GenerationTime: resp.Timestamp.Time(), + Bids: processOB(resp.Bids), + Asks: processOB(resp.Asks), + }, nil +} + +func processOB(ob [][2]types.Number) []orderbook.Level { + o := make([]orderbook.Level, len(ob)) + for x := range ob { + o[x].Price = ob[x][0].Float64() + o[x].Amount = ob[x][1].Float64() + } + return o } func fillCategoryAndSymbol(category, symbol string, optionalSymbol ...bool) (url.Values, error) { @@ -465,109 +482,37 @@ func isValidCategory(category string) error { } // PlaceOrder creates an order for spot, spot margin, USDT perpetual, USDC perpetual, USDC futures, inverse futures and options. -func (e *Exchange) PlaceOrder(ctx context.Context, arg *PlaceOrderParams) (*OrderResponse, error) { - if arg == nil { - return nil, errNilArgument - } - err := isValidCategory(arg.Category) - if err != nil { +func (e *Exchange) PlaceOrder(ctx context.Context, arg *PlaceOrderRequest) (*OrderResponse, error) { + if err := arg.Validate(); err != nil { return nil, err } - if arg.Symbol.IsEmpty() { - return nil, currency.ErrCurrencyPairEmpty - } - if arg.WhetherToBorrow { - arg.IsLeverage = 1 - } - // specifies whether to borrow or to trade. - if arg.IsLeverage != 0 && arg.IsLeverage != 1 { - return nil, errors.New("please provide a valid isLeverage value; must be 0 for unified spot and 1 for margin trading") - } - if arg.Side == "" { - return nil, order.ErrSideIsInvalid - } - if arg.OrderType == "" { // Market and Limit order types are allowed - return nil, order.ErrTypeIsInvalid - } - if arg.OrderQuantity <= 0 { - return nil, limits.ErrAmountBelowMin - } - switch arg.TriggerDirection { - case 0, 1, 2: // 0: None, 1: triggered when market price rises to triggerPrice, 2: triggered when market price falls to triggerPrice - default: - return nil, fmt.Errorf("%w, triggerDirection: %d", errInvalidTriggerDirection, arg.TriggerDirection) - } - if arg.OrderFilter != "" && arg.Category == cSpot { - switch arg.OrderFilter { - case "Order", "tpslOrder", "StopOrder": - default: - return nil, fmt.Errorf("%w, orderFilter=%s", errInvalidOrderFilter, arg.OrderFilter) - } - } - switch arg.TriggerPriceType { - case "", "LastPrice", "IndexPrice", "MarkPrice": - default: - return nil, errInvalidTriggerPriceType - } - var resp OrderResponse - epl := createOrderEPL - if arg.Category == "spot" { + if arg.Category == cSpot { epl = createSpotOrderEPL } - return &resp, e.SendAuthHTTPRequestV5(ctx, exchange.RestSpot, http.MethodPost, "/v5/order/create", nil, arg, &resp, epl) + var resp *OrderResponse + return resp, e.SendAuthHTTPRequestV5(ctx, exchange.RestSpot, http.MethodPost, "/v5/order/create", nil, arg, &resp, epl) } // AmendOrder amends an open unfilled or partially filled orders. -func (e *Exchange) AmendOrder(ctx context.Context, arg *AmendOrderParams) (*OrderResponse, error) { - if arg == nil { - return nil, errNilArgument - } - if arg.OrderID == "" && arg.OrderLinkID == "" { - return nil, errEitherOrderIDOROrderLinkIDRequired - } - err := isValidCategory(arg.Category) - if err != nil { +func (e *Exchange) AmendOrder(ctx context.Context, arg *AmendOrderRequest) (*OrderResponse, error) { + if err := arg.Validate(); err != nil { return nil, err } - if arg.Symbol.IsEmpty() { - return nil, currency.ErrCurrencyPairEmpty - } var resp *OrderResponse return resp, e.SendAuthHTTPRequestV5(ctx, exchange.RestSpot, http.MethodPost, "/v5/order/amend", nil, arg, &resp, amendOrderEPL) } // CancelTradeOrder cancels an open unfilled or partially filled order. -func (e *Exchange) CancelTradeOrder(ctx context.Context, arg *CancelOrderParams) (*OrderResponse, error) { - if arg == nil { - return nil, errNilArgument - } - if arg.OrderID == "" && arg.OrderLinkID == "" { - return nil, errEitherOrderIDOROrderLinkIDRequired - } - err := isValidCategory(arg.Category) - if err != nil { +func (e *Exchange) CancelTradeOrder(ctx context.Context, arg *CancelOrderRequest) (*OrderResponse, error) { + if err := arg.Validate(); err != nil { return nil, err } - if arg.Symbol.IsEmpty() { - return nil, currency.ErrCurrencyPairEmpty - } - switch { - case arg.OrderFilter != "" && arg.Category == cSpot: - switch arg.OrderFilter { - case "Order", "tpslOrder", "StopOrder": - default: - return nil, fmt.Errorf("%w, orderFilter=%s", errInvalidOrderFilter, arg.OrderFilter) - } - case arg.OrderFilter != "": - return nil, fmt.Errorf("%w, orderFilter is valid for 'spot' only", errInvalidCategory) - } - var resp *OrderResponse - epl := cancelOrderEPL - if arg.Category == "spot" { + if arg.Category == cSpot { epl = cancelSpotEPL } + var resp *OrderResponse return resp, e.SendAuthHTTPRequestV5(ctx, exchange.RestSpot, http.MethodPost, "/v5/order/cancel", nil, arg, &resp, epl) } @@ -615,12 +560,12 @@ func (e *Exchange) CancelAllTradeOrders(ctx context.Context, arg *CancelAllOrder if err != nil { return nil, err } - if arg.OrderFilter != "" && (arg.Category != "linear" && arg.Category != "inverse") { + if arg.OrderFilter != "" && (arg.Category != cLinear && arg.Category != cInverse) { return nil, fmt.Errorf("%w, only used for category=linear or inverse", errInvalidOrderFilter) } var resp CancelAllResponse epl := cancelAllEPL - if arg.Category == "spot" { + if arg.Category == cSpot { epl = cancelAllSpotEPL } return resp.List, e.SendAuthHTTPRequestV5(ctx, exchange.RestSpot, http.MethodPost, "/v5/order/cancel-all", nil, arg, &resp, epl) @@ -2490,15 +2435,6 @@ func (e *Exchange) GetBrokerEarning(ctx context.Context, businessType, cursor st return resp.List, e.SendAuthHTTPRequestV5(ctx, exchange.RestSpot, http.MethodGet, "/v5/broker/earning-record", params, nil, &resp, defaultEPL) } -func processOB(ob [][2]types.Number) []orderbook.Level { - o := make([]orderbook.Level, len(ob)) - for x := range ob { - o[x].Price = ob[x][0].Float64() - o[x].Amount = ob[x][1].Float64() - } - return o -} - // SendHTTPRequest sends an unauthenticated request func (e *Exchange) SendHTTPRequest(ctx context.Context, ePath exchange.URL, path string, f request.EndpointLimit, result any) error { endpointPath, err := e.API.Endpoints.GetURL(ePath) @@ -2676,6 +2612,20 @@ func (e *Exchange) FetchAccountType(ctx context.Context) (AccountType, error) { return e.account.accountType, nil } +// String returns the account type as a string +func (a AccountType) String() string { + switch a { + case 0: + return "unset" + case accountTypeNormal: + return "normal" + case accountTypeUnified: + return "unified" + default: + return "unknown" + } +} + // RequiresUnifiedAccount checks account type and returns error if not unified func (e *Exchange) RequiresUnifiedAccount(ctx context.Context) error { at, err := e.FetchAccountType(ctx) diff --git a/exchanges/bybit/bybit_test.go b/exchanges/bybit/bybit_test.go index 2fb440f4..d106ede8 100644 --- a/exchanges/bybit/bybit_test.go +++ b/exchanges/bybit/bybit_test.go @@ -58,15 +58,15 @@ var ( func TestGetInstrumentInfo(t *testing.T) { t.Parallel() - _, err := e.GetInstrumentInfo(t.Context(), "spot", "", "", "", "", 0) + _, err := e.GetInstrumentInfo(t.Context(), cSpot, "", "", "", "", 0) require.NoError(t, err) - _, err = e.GetInstrumentInfo(t.Context(), "linear", "", "", "", "", 0) + _, err = e.GetInstrumentInfo(t.Context(), cLinear, "", "", "", "", 0) require.NoError(t, err) - _, err = e.GetInstrumentInfo(t.Context(), "inverse", "", "", "", "", 0) + _, err = e.GetInstrumentInfo(t.Context(), cInverse, "", "", "", "", 0) require.NoError(t, err) - _, err = e.GetInstrumentInfo(t.Context(), "option", "", "", "", "", 0) + _, err = e.GetInstrumentInfo(t.Context(), cOption, "", "", "", "", 0) require.NoError(t, err) - payload, err := e.GetInstrumentInfo(t.Context(), "linear", "10000000AIDOGEUSDT", "", "", "", 0) + payload, err := e.GetInstrumentInfo(t.Context(), cLinear, "10000000AIDOGEUSDT", "", "", "", 0) require.NoError(t, err) require.NotEmpty(t, payload.List) require.NotZero(t, payload.List[0].LotSizeFilter.MinNotionalValue) @@ -87,11 +87,11 @@ func TestGetKlines(t *testing.T) { expRespLen int expError error }{ - {"spot", spotTradablePair, 100, 34, nil}, // TODO: Update expected limit when mock data is updated - {"linear", usdtMarginedTradablePair, 5, 5, nil}, - {"linear", usdcMarginedTradablePair, 5, 5, nil}, - {"inverse", inverseTradablePair, 5, 5, nil}, - {"option", optionsTradablePair, 5, 5, errInvalidCategory}, + {cSpot, spotTradablePair, 100, 34, nil}, // TODO: Update expected limit when mock data is updated + {cLinear, usdtMarginedTradablePair, 5, 5, nil}, + {cLinear, usdcMarginedTradablePair, 5, 5, nil}, + {cInverse, inverseTradablePair, 5, 5, nil}, + {cOption, optionsTradablePair, 5, 5, errInvalidCategory}, } { t.Run(fmt.Sprintf("%s-%s", tc.category, tc.pair), func(t *testing.T) { t.Parallel() @@ -105,15 +105,15 @@ func TestGetKlines(t *testing.T) { require.Equal(t, tc.expRespLen, len(r)) switch tc.category { - case "spot": + case cSpot: assert.Equal(t, KlineItem{StartTime: types.Time(endTime), Open: 29393.99, High: 29399.76, Low: 29393.98, Close: 29399.76, TradeVolume: 1.168988, Turnover: 34363.5346739}, r[0]) - case "linear": + case cLinear: if tc.pair == usdtMarginedTradablePair { assert.Equal(t, KlineItem{StartTime: types.Time(endTime), Open: 0.0003, High: 0.0003, Low: 0.0002995, Close: 0.0003, TradeVolume: 55102100, Turnover: 16506.2427}, r[0]) return } assert.Equal(t, KlineItem{StartTime: types.Time(endTime), Open: 239.7, High: 239.7, Low: 239.7, Close: 239.7}, r[0]) - case "inverse": + case cInverse: assert.Equal(t, KlineItem{StartTime: types.Time(endTime), Open: 0.2908, High: 0.2912, Low: 0.2908, Close: 0.2912, TradeVolume: 5131, Turnover: 17626.40000346}, r[0]) } } else { @@ -131,19 +131,19 @@ func TestGetMarkPriceKline(t *testing.T) { startTime = time.UnixMilli(1693077167971) endTime = time.UnixMilli(1693080767971) } - _, err := e.GetMarkPriceKline(t.Context(), "linear", usdtMarginedTradablePair.String(), kline.FiveMin, startTime, endTime, 5) + _, err := e.GetMarkPriceKline(t.Context(), cLinear, usdtMarginedTradablePair.String(), kline.FiveMin, startTime, endTime, 5) if err != nil { t.Fatal(err) } - _, err = e.GetMarkPriceKline(t.Context(), "linear", usdcMarginedTradablePair.String(), kline.FiveMin, startTime, endTime, 5) + _, err = e.GetMarkPriceKline(t.Context(), cLinear, usdcMarginedTradablePair.String(), kline.FiveMin, startTime, endTime, 5) if err != nil { t.Fatal(err) } - _, err = e.GetMarkPriceKline(t.Context(), "inverse", inverseTradablePair.String(), kline.FiveMin, startTime, endTime, 5) + _, err = e.GetMarkPriceKline(t.Context(), cInverse, inverseTradablePair.String(), kline.FiveMin, startTime, endTime, 5) if err != nil { t.Fatal(err) } - _, err = e.GetMarkPriceKline(t.Context(), "option", optionsTradablePair.String(), kline.FiveMin, startTime, endTime, 5) + _, err = e.GetMarkPriceKline(t.Context(), cOption, optionsTradablePair.String(), kline.FiveMin, startTime, endTime, 5) if err == nil { t.Fatalf("expected 'params error: Category is invalid', but found nil") } @@ -157,15 +157,15 @@ func TestGetIndexPriceKline(t *testing.T) { startTime = time.UnixMilli(1693077165571) endTime = time.UnixMilli(1693080765571) } - _, err := e.GetIndexPriceKline(t.Context(), "linear", usdtMarginedTradablePair.String(), kline.FiveMin, startTime, endTime, 5) + _, err := e.GetIndexPriceKline(t.Context(), cLinear, usdtMarginedTradablePair.String(), kline.FiveMin, startTime, endTime, 5) if err != nil { t.Fatal(err) } - _, err = e.GetIndexPriceKline(t.Context(), "linear", usdcMarginedTradablePair.String(), kline.FiveMin, startTime, endTime, 5) + _, err = e.GetIndexPriceKline(t.Context(), cLinear, usdcMarginedTradablePair.String(), kline.FiveMin, startTime, endTime, 5) if err != nil { t.Fatal(err) } - _, err = e.GetIndexPriceKline(t.Context(), "inverse", inverseTradablePair.String(), kline.FiveMin, startTime, endTime, 5) + _, err = e.GetIndexPriceKline(t.Context(), cInverse, inverseTradablePair.String(), kline.FiveMin, startTime, endTime, 5) if err != nil { t.Fatal(err) } @@ -173,23 +173,23 @@ func TestGetIndexPriceKline(t *testing.T) { func TestGetOrderBook(t *testing.T) { t.Parallel() - _, err := e.GetOrderBook(t.Context(), "spot", spotTradablePair.String(), 100) + _, err := e.GetOrderBook(t.Context(), cSpot, spotTradablePair.String(), 100) if err != nil { t.Fatal(err) } - _, err = e.GetOrderBook(t.Context(), "linear", usdtMarginedTradablePair.String(), 100) + _, err = e.GetOrderBook(t.Context(), cLinear, usdtMarginedTradablePair.String(), 100) if err != nil { t.Fatal(err) } - _, err = e.GetOrderBook(t.Context(), "linear", usdcMarginedTradablePair.String(), 100) + _, err = e.GetOrderBook(t.Context(), cLinear, usdcMarginedTradablePair.String(), 100) if err != nil { t.Fatal(err) } - _, err = e.GetOrderBook(t.Context(), "inverse", inverseTradablePair.String(), 100) + _, err = e.GetOrderBook(t.Context(), cInverse, inverseTradablePair.String(), 100) if err != nil { t.Fatal(err) } - _, err = e.GetOrderBook(t.Context(), "option", optionsTradablePair.String(), 0) + _, err = e.GetOrderBook(t.Context(), cOption, optionsTradablePair.String(), 0) if err != nil { t.Fatal(err) } @@ -197,22 +197,22 @@ func TestGetOrderBook(t *testing.T) { func TestGetRiskLimit(t *testing.T) { t.Parallel() - _, err := e.GetRiskLimit(t.Context(), "linear", usdtMarginedTradablePair.String()) + _, err := e.GetRiskLimit(t.Context(), cLinear, usdtMarginedTradablePair.String()) if err != nil { t.Error(err) } - _, err = e.GetRiskLimit(t.Context(), "linear", usdcMarginedTradablePair.String()) + _, err = e.GetRiskLimit(t.Context(), cLinear, usdcMarginedTradablePair.String()) if err != nil { t.Error(err) } - _, err = e.GetRiskLimit(t.Context(), "inverse", inverseTradablePair.String()) + _, err = e.GetRiskLimit(t.Context(), cInverse, inverseTradablePair.String()) if err != nil { t.Error(err) } - _, err = e.GetRiskLimit(t.Context(), "option", optionsTradablePair.String()) + _, err = e.GetRiskLimit(t.Context(), cOption, optionsTradablePair.String()) assert.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetRiskLimit(t.Context(), "spot", spotTradablePair.String()) + _, err = e.GetRiskLimit(t.Context(), cSpot, spotTradablePair.String()) assert.ErrorIs(t, err, errInvalidCategory) } @@ -646,15 +646,15 @@ func TestGetTickersV5(t *testing.T) { t.Parallel() _, err := e.GetTickers(t.Context(), "bruh", "", "", time.Time{}) require.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetTickers(t.Context(), "option", "BTC-26NOV24-92000-C", "", time.Time{}) + _, err = e.GetTickers(t.Context(), cOption, "BTC-26NOV24-92000-C", "", time.Time{}) require.NoError(t, err) - _, err = e.GetTickers(t.Context(), "spot", "", "", time.Time{}) + _, err = e.GetTickers(t.Context(), cSpot, "", "", time.Time{}) require.NoError(t, err) - _, err = e.GetTickers(t.Context(), "inverse", "", "", time.Time{}) + _, err = e.GetTickers(t.Context(), cInverse, "", "", time.Time{}) require.NoError(t, err) - _, err = e.GetTickers(t.Context(), "linear", "", "", time.Time{}) + _, err = e.GetTickers(t.Context(), cLinear, "", "", time.Time{}) require.NoError(t, err) - _, err = e.GetTickers(t.Context(), "option", "", "BTC", time.Time{}) + _, err = e.GetTickers(t.Context(), cOption, "", "BTC", time.Time{}) require.NoError(t, err) } @@ -663,44 +663,44 @@ func TestGetFundingRateHistory(t *testing.T) { _, err := e.GetFundingRateHistory(t.Context(), "bruh", "", time.Time{}, time.Time{}, 0) assert.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetFundingRateHistory(t.Context(), "spot", spotTradablePair.String(), time.Time{}, time.Time{}, 100) + _, err = e.GetFundingRateHistory(t.Context(), cSpot, spotTradablePair.String(), time.Time{}, time.Time{}, 100) assert.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetFundingRateHistory(t.Context(), "linear", usdtMarginedTradablePair.String(), time.Time{}, time.Time{}, 100) + _, err = e.GetFundingRateHistory(t.Context(), cLinear, usdtMarginedTradablePair.String(), time.Time{}, time.Time{}, 100) if err != nil { t.Error(err) } - _, err = e.GetFundingRateHistory(t.Context(), "linear", usdcMarginedTradablePair.String(), time.Time{}, time.Time{}, 100) + _, err = e.GetFundingRateHistory(t.Context(), cLinear, usdcMarginedTradablePair.String(), time.Time{}, time.Time{}, 100) if err != nil { t.Error(err) } - _, err = e.GetFundingRateHistory(t.Context(), "inverse", inverseTradablePair.String(), time.Time{}, time.Time{}, 100) + _, err = e.GetFundingRateHistory(t.Context(), cInverse, inverseTradablePair.String(), time.Time{}, time.Time{}, 100) if err != nil { t.Error(err) } - _, err = e.GetFundingRateHistory(t.Context(), "option", optionsTradablePair.String(), time.Time{}, time.Time{}, 100) + _, err = e.GetFundingRateHistory(t.Context(), cOption, optionsTradablePair.String(), time.Time{}, time.Time{}, 100) assert.ErrorIs(t, err, errInvalidCategory) } func TestGetPublicTradingHistory(t *testing.T) { t.Parallel() - _, err := e.GetPublicTradingHistory(t.Context(), "spot", spotTradablePair.String(), "", "", 30) + _, err := e.GetPublicTradingHistory(t.Context(), cSpot, spotTradablePair.String(), "", "", 30) if err != nil { t.Error(err) } - _, err = e.GetPublicTradingHistory(t.Context(), "linear", usdtMarginedTradablePair.String(), "", "", 30) + _, err = e.GetPublicTradingHistory(t.Context(), cLinear, usdtMarginedTradablePair.String(), "", "", 30) if err != nil { t.Error(err) } - _, err = e.GetPublicTradingHistory(t.Context(), "linear", usdcMarginedTradablePair.String(), "", "", 30) + _, err = e.GetPublicTradingHistory(t.Context(), cLinear, usdcMarginedTradablePair.String(), "", "", 30) if err != nil { t.Error(err) } - _, err = e.GetPublicTradingHistory(t.Context(), "inverse", inverseTradablePair.String(), "", "", 30) + _, err = e.GetPublicTradingHistory(t.Context(), cInverse, inverseTradablePair.String(), "", "", 30) if err != nil { t.Error(err) } - _, err = e.GetPublicTradingHistory(t.Context(), "option", optionsTradablePair.String(), "BTC", "", 30) + _, err = e.GetPublicTradingHistory(t.Context(), cOption, optionsTradablePair.String(), "BTC", "", 30) if err != nil { t.Error(err) } @@ -708,22 +708,22 @@ func TestGetPublicTradingHistory(t *testing.T) { func TestGetOpenInterestData(t *testing.T) { t.Parallel() - _, err := e.GetOpenInterestData(t.Context(), "spot", spotTradablePair.String(), "5min", time.Time{}, time.Time{}, 0, "") + _, err := e.GetOpenInterestData(t.Context(), cSpot, spotTradablePair.String(), "5min", time.Time{}, time.Time{}, 0, "") assert.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetOpenInterestData(t.Context(), "linear", usdtMarginedTradablePair.String(), "5min", time.Time{}, time.Time{}, 0, "") + _, err = e.GetOpenInterestData(t.Context(), cLinear, usdtMarginedTradablePair.String(), "5min", time.Time{}, time.Time{}, 0, "") if err != nil { t.Error(err) } - _, err = e.GetOpenInterestData(t.Context(), "linear", usdcMarginedTradablePair.String(), "5min", time.Time{}, time.Time{}, 0, "") + _, err = e.GetOpenInterestData(t.Context(), cLinear, usdcMarginedTradablePair.String(), "5min", time.Time{}, time.Time{}, 0, "") if err != nil { t.Error(err) } - _, err = e.GetOpenInterestData(t.Context(), "inverse", inverseTradablePair.String(), "5min", time.Time{}, time.Time{}, 0, "") + _, err = e.GetOpenInterestData(t.Context(), cInverse, inverseTradablePair.String(), "5min", time.Time{}, time.Time{}, 0, "") if err != nil { t.Error(err) } - _, err = e.GetOpenInterestData(t.Context(), "option", optionsTradablePair.String(), "5min", time.Time{}, time.Time{}, 0, "") + _, err = e.GetOpenInterestData(t.Context(), cOption, optionsTradablePair.String(), "5min", time.Time{}, time.Time{}, 0, "") assert.ErrorIs(t, err, errInvalidCategory) } @@ -735,11 +735,11 @@ func TestGetHistoricalVolatility(t *testing.T) { end = time.UnixMilli(1693080759395) start = time.UnixMilli(1690488759395) } - _, err := e.GetHistoricalVolatility(t.Context(), "option", "", 123, start, end) + _, err := e.GetHistoricalVolatility(t.Context(), cOption, "", 123, start, end) if err != nil { t.Error(err) } - _, err = e.GetHistoricalVolatility(t.Context(), "spot", "", 123, start, end) + _, err = e.GetHistoricalVolatility(t.Context(), cSpot, "", 123, start, end) assert.ErrorIs(t, err, errInvalidCategory) } @@ -753,18 +753,18 @@ func TestGetInsurance(t *testing.T) { func TestGetDeliveryPrice(t *testing.T) { t.Parallel() - _, err := e.GetDeliveryPrice(t.Context(), "spot", spotTradablePair.String(), "", "", 200) + _, err := e.GetDeliveryPrice(t.Context(), cSpot, spotTradablePair.String(), "", "", 200) assert.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetDeliveryPrice(t.Context(), "linear", "", "", "", 200) + _, err = e.GetDeliveryPrice(t.Context(), cLinear, "", "", "", 200) if err != nil { t.Error(err) } - _, err = e.GetDeliveryPrice(t.Context(), "inverse", "", "", "", 200) + _, err = e.GetDeliveryPrice(t.Context(), cInverse, "", "", "", 200) if err != nil { t.Error(err) } - _, err = e.GetDeliveryPrice(t.Context(), "option", "", "BTC", "", 200) + _, err = e.GetDeliveryPrice(t.Context(), cOption, "", "BTC", "", 200) if err != nil { t.Error(err) } @@ -787,60 +787,17 @@ func TestUpdateOrderExecutionLimits(t *testing.T) { func TestPlaceOrder(t *testing.T) { t.Parallel() + + _, err := e.PlaceOrder(t.Context(), &PlaceOrderRequest{}) + require.ErrorIs(t, err, errCategoryNotSet) + if mockTests { t.Skip(skipAuthenticatedFunctionsForMockTesting) } sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) - ctx := t.Context() - _, err := e.PlaceOrder(ctx, nil) - require.ErrorIs(t, err, errNilArgument) - _, err = e.PlaceOrder(ctx, &PlaceOrderParams{}) - require.ErrorIs(t, err, errCategoryNotSet) - - _, err = e.PlaceOrder(ctx, &PlaceOrderParams{ - Category: "my-category", - }) - require.ErrorIs(t, err, errInvalidCategory) - - _, err = e.PlaceOrder(ctx, &PlaceOrderParams{ - Category: "spot", - }) - require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) - - _, err = e.PlaceOrder(ctx, &PlaceOrderParams{ - Category: "spot", - Symbol: currency.Pair{Delimiter: "", Base: currency.BTC, Quote: currency.USDT}, - }) - require.ErrorIs(t, err, order.ErrSideIsInvalid) - - _, err = e.PlaceOrder(ctx, &PlaceOrderParams{ - Category: "spot", - Symbol: spotTradablePair, - Side: "buy", - }) - require.ErrorIs(t, err, order.ErrTypeIsInvalid) - - _, err = e.PlaceOrder(ctx, &PlaceOrderParams{ - Category: "spot", - Symbol: spotTradablePair, - Side: "buy", - OrderType: "limit", - }) - require.ErrorIs(t, err, limits.ErrAmountBelowMin) - - _, err = e.PlaceOrder(ctx, &PlaceOrderParams{ - Category: "spot", - Symbol: spotTradablePair, - Side: "buy", - OrderType: "limit", - OrderQuantity: 1, - TriggerDirection: 3, - }) - require.ErrorIs(t, err, errInvalidTriggerDirection) - - _, err = e.PlaceOrder(t.Context(), &PlaceOrderParams{ - Category: "spot", + _, err = e.PlaceOrder(t.Context(), &PlaceOrderRequest{ + Category: cSpot, Symbol: spotTradablePair, Side: "buy", OrderType: "limit", @@ -852,14 +809,14 @@ func TestPlaceOrder(t *testing.T) { t.Error(err) } // Spot post only normal order - arg := &PlaceOrderParams{Category: "spot", Symbol: spotTradablePair, Side: "Buy", OrderType: "Limit", OrderQuantity: 0.1, Price: 15600, TimeInForce: "PostOnly", OrderLinkID: "spot-test-01", IsLeverage: 0, OrderFilter: "Order"} + arg := &PlaceOrderRequest{Category: cSpot, Symbol: spotTradablePair, Side: "Buy", OrderType: "Limit", OrderQuantity: 0.1, Price: 15600, TimeInForce: "PostOnly", OrderLinkID: "spot-test-01", IsLeverage: 0, OrderFilter: "Order"} _, err = e.PlaceOrder(t.Context(), arg) if err != nil { t.Error(err) } // Spot TP/SL order - arg = &PlaceOrderParams{ - Category: "spot", + arg = &PlaceOrderRequest{ + Category: cSpot, Symbol: spotTradablePair, Side: "Buy", OrderType: "Limit", OrderQuantity: 0.1, Price: 15600, TriggerPrice: 15000, @@ -870,16 +827,16 @@ func TestPlaceOrder(t *testing.T) { t.Error(err) } // Spot margin normal order (UTA) - arg = &PlaceOrderParams{ - Category: "spot", Symbol: spotTradablePair, Side: "Buy", OrderType: "Limit", + arg = &PlaceOrderRequest{ + Category: cSpot, Symbol: spotTradablePair, Side: "Buy", OrderType: "Limit", OrderQuantity: 0.1, Price: 15600, TimeInForce: "IOC", OrderLinkID: "spot-test-limit", IsLeverage: 1, OrderFilter: "Order", } _, err = e.PlaceOrder(t.Context(), arg) if err != nil { t.Error(err) } - arg = &PlaceOrderParams{ - Category: "spot", + arg = &PlaceOrderRequest{ + Category: cSpot, Symbol: spotTradablePair, Side: "Buy", OrderType: "Market", OrderQuantity: 200, TimeInForce: "IOC", OrderLinkID: "spot-test-04", @@ -890,8 +847,8 @@ func TestPlaceOrder(t *testing.T) { t.Error(err) } // USDT Perp open long position (one-way mode) - arg = &PlaceOrderParams{ - Category: "linear", + arg = &PlaceOrderRequest{ + Category: cLinear, Symbol: usdcMarginedTradablePair, Side: "Buy", OrderType: "Limit", OrderQuantity: 1, Price: 25000, TimeInForce: "GTC", PositionIdx: 0, OrderLinkID: "usdt-test-01", ReduceOnly: false, TakeProfitPrice: 28000, StopLossPrice: 20000, TpslMode: "Partial", TpOrderType: "Limit", SlOrderType: "Limit", TpLimitPrice: 27500, SlLimitPrice: 20500, } _, err = e.PlaceOrder(t.Context(), arg) @@ -899,8 +856,8 @@ func TestPlaceOrder(t *testing.T) { t.Error(err) } // USDT Perp close long position (one-way mode) - arg = &PlaceOrderParams{ - Category: "linear", Symbol: usdtMarginedTradablePair, Side: "Sell", + arg = &PlaceOrderRequest{ + Category: cLinear, Symbol: usdtMarginedTradablePair, Side: "Sell", OrderType: "Limit", OrderQuantity: 1, Price: 3000, TimeInForce: "GTC", PositionIdx: 0, OrderLinkID: "usdt-test-02", ReduceOnly: true, } _, err = e.PlaceOrder(t.Context(), arg) @@ -911,42 +868,22 @@ func TestPlaceOrder(t *testing.T) { func TestAmendOrder(t *testing.T) { t.Parallel() + + _, err := e.AmendOrder(t.Context(), &AmendOrderRequest{}) + require.ErrorIs(t, err, errCategoryNotSet) + if mockTests { t.Skip(skipAuthenticatedFunctionsForMockTesting) } sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) - _, err := e.AmendOrder(t.Context(), nil) - require.ErrorIs(t, err, errNilArgument) - _, err = e.AmendOrder(t.Context(), &AmendOrderParams{}) - require.ErrorIs(t, err, errEitherOrderIDOROrderLinkIDRequired) - - _, err = e.AmendOrder(t.Context(), &AmendOrderParams{ - OrderID: "c6f055d9-7f21-4079-913d-e6523a9cfffa", - }) - require.ErrorIs(t, err, errCategoryNotSet) - - _, err = e.AmendOrder(t.Context(), &AmendOrderParams{ - OrderID: "c6f055d9-7f21-4079-913d-e6523a9cfffa", - Category: "mycat", - }) - require.ErrorIs(t, err, errInvalidCategory) - - _, err = e.AmendOrder(t.Context(), &AmendOrderParams{ - OrderID: "c6f055d9-7f21-4079-913d-e6523a9cfffa", - Category: "option", - }) - require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) - - _, err = e.AmendOrder(t.Context(), &AmendOrderParams{ - OrderID: "c6f055d9-7f21-4079-913d-e6523a9cfffa", - Category: cSpot, - Symbol: spotTradablePair, - TriggerPrice: 1145, - OrderQuantity: 0.15, - Price: 1050, - TakeProfitPrice: 0, - StopLossPrice: 0, + _, err = e.AmendOrder(t.Context(), &AmendOrderRequest{ + OrderID: "c6f055d9-7f21-4079-913d-e6523a9cfffa", + Category: cSpot, + Symbol: spotTradablePair, + TriggerPrice: 1145, + OrderQuantity: 0.15, + Price: 1050, }) if err != nil { t.Error(err) @@ -955,36 +892,18 @@ func TestAmendOrder(t *testing.T) { func TestCancelTradeOrder(t *testing.T) { t.Parallel() + + _, err := e.CancelTradeOrder(t.Context(), &CancelOrderRequest{}) + require.ErrorIs(t, err, errCategoryNotSet) + if mockTests { t.Skip(skipAuthenticatedFunctionsForMockTesting) } sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) - _, err := e.CancelTradeOrder(t.Context(), nil) - require.ErrorIs(t, err, errNilArgument) - _, err = e.CancelTradeOrder(t.Context(), &CancelOrderParams{}) - require.ErrorIs(t, err, errEitherOrderIDOROrderLinkIDRequired) - - _, err = e.CancelTradeOrder(t.Context(), &CancelOrderParams{ - OrderID: "c6f055d9-7f21-4079-913d-e6523a9cfffa", - }) - require.ErrorIs(t, err, errCategoryNotSet) - - _, err = e.CancelTradeOrder(t.Context(), &CancelOrderParams{ + _, err = e.CancelTradeOrder(t.Context(), &CancelOrderRequest{ OrderID: "c6f055d9-7f21-4079-913d-e6523a9cfffa", - Category: "mycat", - }) - require.ErrorIs(t, err, errInvalidCategory) - - _, err = e.CancelTradeOrder(t.Context(), &CancelOrderParams{ - OrderID: "c6f055d9-7f21-4079-913d-e6523a9cfffa", - Category: "option", - }) - require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) - - _, err = e.CancelTradeOrder(t.Context(), &CancelOrderParams{ - OrderID: "c6f055d9-7f21-4079-913d-e6523a9cfffa", - Category: "option", + Category: cOption, Symbol: optionsTradablePair, }) if err != nil { @@ -1000,7 +919,7 @@ func TestGetOpenOrders(t *testing.T) { _, err := e.GetOpenOrders(t.Context(), "", "", "", "", "", "", "", "", 0, 100) require.ErrorIs(t, err, errCategoryNotSet) - _, err = e.GetOpenOrders(t.Context(), "spot", "", "", "", "", "", "", "", 0, 0) + _, err = e.GetOpenOrders(t.Context(), cSpot, "", "", "", "", "", "", "", 0, 0) if err != nil { t.Error(err) } @@ -1018,7 +937,7 @@ func TestCancelAllTradeOrders(t *testing.T) { _, err = e.CancelAllTradeOrders(t.Context(), &CancelAllOrdersParam{}) require.ErrorIs(t, err, errCategoryNotSet) - _, err = e.CancelAllTradeOrders(t.Context(), &CancelAllOrdersParam{Category: "option"}) + _, err = e.CancelAllTradeOrders(t.Context(), &CancelAllOrdersParam{Category: cOption}) if err != nil { t.Error(err) } @@ -1037,7 +956,7 @@ func TestGetTradeOrderHistory(t *testing.T) { _, err := e.GetTradeOrderHistory(t.Context(), "", "", "", "", "", "", "", "", "", start, end, 100) require.ErrorIs(t, err, errCategoryNotSet) - _, err = e.GetTradeOrderHistory(t.Context(), "spot", spotTradablePair.String(), "", "", "BTC", "", "StopOrder", "", "", start, end, 100) + _, err = e.GetTradeOrderHistory(t.Context(), cSpot, spotTradablePair.String(), "", "", "BTC", "", "StopOrder", "", "", start, end, 100) if err != nil { t.Error(err) } @@ -1056,12 +975,12 @@ func TestPlaceBatchOrder(t *testing.T) { require.ErrorIs(t, err, errCategoryNotSet) _, err = e.PlaceBatchOrder(t.Context(), &PlaceBatchOrderParam{ - Category: "linear", + Category: cLinear, }) require.ErrorIs(t, err, errNoOrderPassed) _, err = e.PlaceBatchOrder(t.Context(), &PlaceBatchOrderParam{ - Category: "option", + Category: cOption, Request: []BatchOrderItemParam{ { Symbol: optionsTradablePair, @@ -1091,7 +1010,7 @@ func TestPlaceBatchOrder(t *testing.T) { t.Fatal(err) } _, err = e.PlaceBatchOrder(t.Context(), &PlaceBatchOrderParam{ - Category: "linear", + Category: cLinear, Request: []BatchOrderItemParam{ { Symbol: optionsTradablePair, @@ -1128,7 +1047,7 @@ func TestBatchAmendOrder(t *testing.T) { t.Skip(skipAuthenticatedFunctionsForMockTesting) } sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) - _, err := e.BatchAmendOrder(t.Context(), "linear", nil) + _, err := e.BatchAmendOrder(t.Context(), cLinear, nil) require.ErrorIs(t, err, errNilArgument) _, err = e.BatchAmendOrder(t.Context(), "", []BatchAmendOrderParamItem{ @@ -1140,7 +1059,7 @@ func TestBatchAmendOrder(t *testing.T) { }) require.ErrorIs(t, err, errCategoryNotSet) - _, err = e.BatchAmendOrder(t.Context(), "option", []BatchAmendOrderParamItem{ + _, err = e.BatchAmendOrder(t.Context(), cOption, []BatchAmendOrderParamItem{ { Symbol: optionsTradablePair, OrderImpliedVolatility: "6.8", @@ -1173,8 +1092,8 @@ func TestCancelBatchOrder(t *testing.T) { require.ErrorIs(t, err, errNoOrderPassed) _, err = e.CancelBatchOrder(t.Context(), &CancelBatchOrder{ - Category: "option", - Request: []CancelOrderParams{ + Category: cOption, + Request: []CancelOrderRequest{ { Symbol: optionsTradablePair, OrderID: "b551f227-7059-4fb5-a6a6-699c04dbd2f2", @@ -1198,13 +1117,13 @@ func TestGetBorrowQuota(t *testing.T) { _, err := e.GetBorrowQuota(t.Context(), "", "BTCUSDT", "Buy") require.ErrorIs(t, err, errCategoryNotSet) - _, err = e.GetBorrowQuota(t.Context(), "spot", "", "Buy") + _, err = e.GetBorrowQuota(t.Context(), cSpot, "", "Buy") require.ErrorIs(t, err, errSymbolMissing) - _, err = e.GetBorrowQuota(t.Context(), "spot", spotTradablePair.String(), "") + _, err = e.GetBorrowQuota(t.Context(), cSpot, spotTradablePair.String(), "") assert.ErrorIs(t, err, order.ErrSideIsInvalid) - _, err = e.GetBorrowQuota(t.Context(), "spot", spotTradablePair.String(), "Buy") + _, err = e.GetBorrowQuota(t.Context(), cSpot, spotTradablePair.String(), "Buy") if err != nil { t.Error(err) } @@ -1233,14 +1152,14 @@ func TestGetPositionInfo(t *testing.T) { _, err := e.GetPositionInfo(t.Context(), "", "", "", "", "", 20) require.ErrorIs(t, err, errCategoryNotSet) - _, err = e.GetPositionInfo(t.Context(), "spot", "", "", "", "", 20) + _, err = e.GetPositionInfo(t.Context(), cSpot, "", "", "", "", 20) require.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetPositionInfo(t.Context(), "linear", "BTCUSDT", "", "", "", 20) + _, err = e.GetPositionInfo(t.Context(), cLinear, "BTCUSDT", "", "", "", 20) if err != nil { t.Error(err) } - _, err = e.GetPositionInfo(t.Context(), "option", "BTC-26NOV24-92000-C", "BTC", "", "", 20) + _, err = e.GetPositionInfo(t.Context(), cOption, "BTC-26NOV24-92000-C", "BTC", "", "", 20) if err != nil { t.Error(err) } @@ -1258,16 +1177,16 @@ func TestSetLeverageLevel(t *testing.T) { err = e.SetLeverageLevel(t.Context(), &SetLeverageParams{}) require.ErrorIs(t, err, errCategoryNotSet) - err = e.SetLeverageLevel(t.Context(), &SetLeverageParams{Category: "spot"}) + err = e.SetLeverageLevel(t.Context(), &SetLeverageParams{Category: cSpot}) require.ErrorIs(t, err, errInvalidCategory) - err = e.SetLeverageLevel(t.Context(), &SetLeverageParams{Category: "linear"}) + err = e.SetLeverageLevel(t.Context(), &SetLeverageParams{Category: cLinear}) require.ErrorIs(t, err, errSymbolMissing) - err = e.SetLeverageLevel(t.Context(), &SetLeverageParams{Category: "linear", Symbol: "BTCUSDT"}) + err = e.SetLeverageLevel(t.Context(), &SetLeverageParams{Category: cLinear, Symbol: "BTCUSDT"}) require.ErrorIs(t, err, errInvalidLeverage) - err = e.SetLeverageLevel(t.Context(), &SetLeverageParams{Category: "linear", Symbol: "BTCUSDT", SellLeverage: 3, BuyLeverage: 3}) + err = e.SetLeverageLevel(t.Context(), &SetLeverageParams{Category: cLinear, Symbol: "BTCUSDT", SellLeverage: 3, BuyLeverage: 3}) if err != nil { t.Error(err) } @@ -1285,19 +1204,19 @@ func TestSwitchTradeMode(t *testing.T) { err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{}) require.ErrorIs(t, err, errCategoryNotSet) - err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{Category: "spot"}) + err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{Category: cSpot}) require.ErrorIs(t, err, errInvalidCategory) - err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{Category: "linear"}) + err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{Category: cLinear}) require.ErrorIs(t, err, errSymbolMissing) - err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{Category: "linear", Symbol: usdtMarginedTradablePair.String()}) + err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{Category: cLinear, Symbol: usdtMarginedTradablePair.String()}) require.ErrorIs(t, err, errInvalidLeverage) - err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{Category: "linear", Symbol: usdcMarginedTradablePair.String(), SellLeverage: 3, BuyLeverage: 3, TradeMode: 2}) + err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{Category: cLinear, Symbol: usdcMarginedTradablePair.String(), SellLeverage: 3, BuyLeverage: 3, TradeMode: 2}) require.ErrorIs(t, err, errInvalidTradeModeValue) - err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{Category: "linear", Symbol: usdtMarginedTradablePair.String(), SellLeverage: 3, BuyLeverage: 3, TradeMode: 1}) + err = e.SwitchTradeMode(t.Context(), &SwitchTradeModeParams{Category: cLinear, Symbol: usdtMarginedTradablePair.String(), SellLeverage: 3, BuyLeverage: 3, TradeMode: 1}) if err != nil { t.Error(err) } @@ -1316,20 +1235,20 @@ func TestSetTakeProfitStopLossMode(t *testing.T) { require.ErrorIs(t, err, errCategoryNotSet) _, err = e.SetTakeProfitStopLossMode(t.Context(), &TPSLModeParams{ - Category: "spot", + Category: cSpot, }) require.ErrorIs(t, err, errInvalidCategory) - _, err = e.SetTakeProfitStopLossMode(t.Context(), &TPSLModeParams{Category: "spot"}) + _, err = e.SetTakeProfitStopLossMode(t.Context(), &TPSLModeParams{Category: cSpot}) require.ErrorIs(t, err, errInvalidCategory) - _, err = e.SetTakeProfitStopLossMode(t.Context(), &TPSLModeParams{Category: "linear"}) + _, err = e.SetTakeProfitStopLossMode(t.Context(), &TPSLModeParams{Category: cLinear}) require.ErrorIs(t, err, errSymbolMissing) - _, err = e.SetTakeProfitStopLossMode(t.Context(), &TPSLModeParams{Category: "linear", Symbol: "BTCUSDT"}) + _, err = e.SetTakeProfitStopLossMode(t.Context(), &TPSLModeParams{Category: cLinear, Symbol: "BTCUSDT"}) require.ErrorIs(t, err, errTakeProfitOrStopLossModeMissing) - _, err = e.SetTakeProfitStopLossMode(t.Context(), &TPSLModeParams{Category: "linear", Symbol: "BTCUSDT", TpslMode: "Partial"}) + _, err = e.SetTakeProfitStopLossMode(t.Context(), &TPSLModeParams{Category: cLinear, Symbol: "BTCUSDT", TpslMode: "Partial"}) if err != nil { t.Error(err) } @@ -1347,10 +1266,10 @@ func TestSwitchPositionMode(t *testing.T) { err = e.SwitchPositionMode(t.Context(), &SwitchPositionModeParams{}) require.ErrorIs(t, err, errCategoryNotSet) - err = e.SwitchPositionMode(t.Context(), &SwitchPositionModeParams{Category: "linear"}) + err = e.SwitchPositionMode(t.Context(), &SwitchPositionModeParams{Category: cLinear}) require.ErrorIs(t, err, errEitherSymbolOrCoinRequired) - err = e.SwitchPositionMode(t.Context(), &SwitchPositionModeParams{Category: "linear", Symbol: usdtMarginedTradablePair, PositionMode: 3}) + err = e.SwitchPositionMode(t.Context(), &SwitchPositionModeParams{Category: cLinear, Symbol: usdtMarginedTradablePair, PositionMode: 3}) if err != nil { t.Error(err) } @@ -1368,14 +1287,14 @@ func TestSetRiskLimit(t *testing.T) { _, err = e.SetRiskLimit(t.Context(), &SetRiskLimitParam{}) assert.ErrorIs(t, err, errCategoryNotSet) - _, err = e.SetRiskLimit(t.Context(), &SetRiskLimitParam{Category: "linear", PositionMode: -2}) + _, err = e.SetRiskLimit(t.Context(), &SetRiskLimitParam{Category: cLinear, PositionMode: -2}) assert.ErrorIs(t, err, errInvalidPositionMode) - _, err = e.SetRiskLimit(t.Context(), &SetRiskLimitParam{Category: "linear"}) + _, err = e.SetRiskLimit(t.Context(), &SetRiskLimitParam{Category: cLinear}) assert.ErrorIs(t, err, errSymbolMissing) _, err = e.SetRiskLimit(t.Context(), &SetRiskLimitParam{ - Category: "linear", + Category: cLinear, RiskID: 1234, Symbol: usdtMarginedTradablePair, PositionMode: 0, @@ -1394,11 +1313,11 @@ func TestSetTradingStop(t *testing.T) { err := e.SetTradingStop(t.Context(), &TradingStopParams{}) assert.ErrorIs(t, err, errCategoryNotSet) - err = e.SetTradingStop(t.Context(), &TradingStopParams{Category: "spot"}) + err = e.SetTradingStop(t.Context(), &TradingStopParams{Category: cSpot}) assert.ErrorIs(t, err, errInvalidCategory) err = e.SetTradingStop(t.Context(), &TradingStopParams{ - Category: "linear", + Category: cLinear, Symbol: usdtMarginedTradablePair, TakeProfit: "0.5", StopLoss: "0.2", @@ -1417,7 +1336,7 @@ func TestSetTradingStop(t *testing.T) { t.Error(err) } err = e.SetTradingStop(t.Context(), &TradingStopParams{ - Category: "linear", + Category: cLinear, Symbol: usdcMarginedTradablePair, TakeProfit: "0.5", StopLoss: "0.2", @@ -1444,7 +1363,7 @@ func TestSetAutoAddMargin(t *testing.T) { } sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) err := e.SetAutoAddMargin(t.Context(), &AutoAddMarginParam{ - Category: "inverse", + Category: cInverse, Symbol: inverseTradablePair, AutoAddmargin: 0, PositionIndex: 2, @@ -1461,7 +1380,7 @@ func TestAddOrReduceMargin(t *testing.T) { } sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) _, err := e.AddOrReduceMargin(t.Context(), &AddOrReduceMarginParam{ - Category: "inverse", + Category: cInverse, Symbol: inverseTradablePair, Margin: -10, PositionIndex: 2, @@ -1476,7 +1395,7 @@ func TestGetExecution(t *testing.T) { if !mockTests { sharedtestvalues.SkipTestIfCredentialsUnset(t, e) } - _, err := e.GetExecution(t.Context(), "spot", "", "", "", "", "Trade", "tpslOrder", "", time.Time{}, time.Time{}, 0) + _, err := e.GetExecution(t.Context(), cSpot, "", "", "", "", "Trade", "tpslOrder", "", time.Time{}, time.Time{}, 0) if err != nil { t.Fatal(err) } @@ -1487,10 +1406,10 @@ func TestGetClosedPnL(t *testing.T) { if !mockTests { sharedtestvalues.SkipTestIfCredentialsUnset(t, e) } - _, err := e.GetClosedPnL(t.Context(), "spot", "", "", time.Time{}, time.Time{}, 0) + _, err := e.GetClosedPnL(t.Context(), cSpot, "", "", time.Time{}, time.Time{}, 0) require.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetClosedPnL(t.Context(), "linear", "", "", time.Time{}, time.Time{}, 0) + _, err = e.GetClosedPnL(t.Context(), cLinear, "", "", time.Time{}, time.Time{}, 0) if err != nil { t.Fatal(err) } @@ -1502,7 +1421,7 @@ func TestConfirmNewRiskLimit(t *testing.T) { t.Skip(skipAuthenticatedFunctionsForMockTesting) } sharedtestvalues.SkipTestIfCredentialsUnset(t, e) - err := e.ConfirmNewRiskLimit(t.Context(), "linear", "BTCUSDT") + err := e.ConfirmNewRiskLimit(t.Context(), cLinear, "BTCUSDT") if err != nil { t.Error(err) } @@ -1517,10 +1436,10 @@ func TestGetPreUpgradeOrderHistory(t *testing.T) { _, err := e.GetPreUpgradeOrderHistory(t.Context(), "", "", "", "", "", "", "", "", time.Time{}, time.Time{}, 100) require.ErrorIs(t, err, errCategoryNotSet) - _, err = e.GetPreUpgradeOrderHistory(t.Context(), "option", "", "", "", "", "", "", "", time.Time{}, time.Time{}, 0) + _, err = e.GetPreUpgradeOrderHistory(t.Context(), cOption, "", "", "", "", "", "", "", time.Time{}, time.Time{}, 0) require.ErrorIs(t, err, errBaseNotSet) - _, err = e.GetPreUpgradeOrderHistory(t.Context(), "linear", "", "", "", "", "", "", "", time.Time{}, time.Time{}, 0) + _, err = e.GetPreUpgradeOrderHistory(t.Context(), cLinear, "", "", "", "", "", "", "", time.Time{}, time.Time{}, 0) if err != nil { t.Error(err) } @@ -1535,10 +1454,10 @@ func TestGetPreUpgradeTradeHistory(t *testing.T) { _, err := e.GetPreUpgradeTradeHistory(t.Context(), "", "", "", "", "", "", "", time.Time{}, time.Time{}, 0) require.ErrorIs(t, err, errCategoryNotSet) - _, err = e.GetPreUpgradeTradeHistory(t.Context(), "option", "", "", "", "", "", "", time.Time{}, time.Time{}, 0) + _, err = e.GetPreUpgradeTradeHistory(t.Context(), cOption, "", "", "", "", "", "", time.Time{}, time.Time{}, 0) require.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetPreUpgradeTradeHistory(t.Context(), "linear", "", "", "", "", "", "", time.Time{}, time.Time{}, 0) + _, err = e.GetPreUpgradeTradeHistory(t.Context(), cLinear, "", "", "", "", "", "", time.Time{}, time.Time{}, 0) if err != nil { t.Error(err) } @@ -1550,10 +1469,10 @@ func TestGetPreUpgradeClosedPnL(t *testing.T) { t.Skip(skipAuthenticatedFunctionsForMockTesting) } sharedtestvalues.SkipTestIfCredentialsUnset(t, e) - _, err := e.GetPreUpgradeClosedPnL(t.Context(), "option", "BTCUSDT", "", time.Time{}, time.Time{}, 0) + _, err := e.GetPreUpgradeClosedPnL(t.Context(), cOption, "BTCUSDT", "", time.Time{}, time.Time{}, 0) require.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetPreUpgradeClosedPnL(t.Context(), "linear", "BTCUSDT", "", time.Time{}, time.Time{}, 0) + _, err = e.GetPreUpgradeClosedPnL(t.Context(), cLinear, "BTCUSDT", "", time.Time{}, time.Time{}, 0) if err != nil { t.Error(err) } @@ -1565,10 +1484,10 @@ func TestGetPreUpgradeTransactionLog(t *testing.T) { t.Skip(skipAuthenticatedFunctionsForMockTesting) } sharedtestvalues.SkipTestIfCredentialsUnset(t, e) - _, err := e.GetPreUpgradeTransactionLog(t.Context(), "option", "", "", "", time.Time{}, time.Time{}, 0) + _, err := e.GetPreUpgradeTransactionLog(t.Context(), cOption, "", "", "", time.Time{}, time.Time{}, 0) require.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetPreUpgradeTransactionLog(t.Context(), "linear", "", "", "", time.Time{}, time.Time{}, 0) + _, err = e.GetPreUpgradeTransactionLog(t.Context(), cLinear, "", "", "", time.Time{}, time.Time{}, 0) if err != nil { t.Error(err) } @@ -1580,10 +1499,10 @@ func TestGetPreUpgradeOptionDeliveryRecord(t *testing.T) { t.Skip(skipAuthenticatedFunctionsForMockTesting) } sharedtestvalues.SkipTestIfCredentialsUnset(t, e) - _, err := e.GetPreUpgradeOptionDeliveryRecord(t.Context(), "linear", "", "", time.Time{}, 0) + _, err := e.GetPreUpgradeOptionDeliveryRecord(t.Context(), cLinear, "", "", time.Time{}, 0) assert.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetPreUpgradeOptionDeliveryRecord(t.Context(), "option", "", "", time.Time{}, 0) + _, err = e.GetPreUpgradeOptionDeliveryRecord(t.Context(), cOption, "", "", time.Time{}, 0) if err != nil { t.Error(err) } @@ -1595,10 +1514,10 @@ func TestGetPreUpgradeUSDCSessionSettlement(t *testing.T) { t.Skip(skipAuthenticatedFunctionsForMockTesting) } sharedtestvalues.SkipTestIfCredentialsUnset(t, e) - _, err := e.GetPreUpgradeUSDCSessionSettlement(t.Context(), "option", "", "", 10) + _, err := e.GetPreUpgradeUSDCSessionSettlement(t.Context(), cOption, "", "", 10) require.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetPreUpgradeUSDCSessionSettlement(t.Context(), "linear", "", "", 10) + _, err = e.GetPreUpgradeUSDCSessionSettlement(t.Context(), cLinear, "", "", 10) if err != nil { t.Error(err) } @@ -1758,7 +1677,7 @@ func TestGetFeeRate(t *testing.T) { _, err := e.GetFeeRate(t.Context(), "something", "", "BTC") require.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetFeeRate(t.Context(), "linear", "", "BTC") + _, err = e.GetFeeRate(t.Context(), cLinear, "", "BTC") if err != nil { t.Error(err) } @@ -1780,11 +1699,11 @@ func TestGetTransactionLog(t *testing.T) { if !mockTests { sharedtestvalues.SkipTestIfCredentialsUnset(t, e) } - _, err := e.GetTransactionLog(t.Context(), "option", "", "", "", time.Time{}, time.Time{}, 0) + _, err := e.GetTransactionLog(t.Context(), cOption, "", "", "", time.Time{}, time.Time{}, 0) if err != nil { t.Error(err) } - _, err = e.GetTransactionLog(t.Context(), "linear", "", "", "", time.Time{}, time.Time{}, 0) + _, err = e.GetTransactionLog(t.Context(), cLinear, "", "", "", time.Time{}, time.Time{}, 0) if err != nil { t.Error(err) } @@ -1892,9 +1811,9 @@ func TestGetDeliveryRecord(t *testing.T) { } else { expiryTime = time.UnixMilli(1700216290093) } - _, err := e.GetDeliveryRecord(t.Context(), "spot", "", "", expiryTime, 20) + _, err := e.GetDeliveryRecord(t.Context(), cSpot, "", "", expiryTime, 20) assert.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetDeliveryRecord(t.Context(), "linear", "", "", expiryTime, 20) + _, err = e.GetDeliveryRecord(t.Context(), cLinear, "", "", expiryTime, 20) assert.NoError(t, err, "GetDeliveryRecord should not error for linear category") } @@ -1903,10 +1822,10 @@ func TestGetUSDCSessionSettlement(t *testing.T) { if !mockTests { sharedtestvalues.SkipTestIfCredentialsUnset(t, e) } - _, err := e.GetUSDCSessionSettlement(t.Context(), "option", "", "", 10) + _, err := e.GetUSDCSessionSettlement(t.Context(), cOption, "", "", 10) require.ErrorIs(t, err, errInvalidCategory) - _, err = e.GetUSDCSessionSettlement(t.Context(), "linear", "", "", 10) + _, err = e.GetUSDCSessionSettlement(t.Context(), cLinear, "", "", 10) if err != nil { t.Error(err) } @@ -3014,7 +2933,7 @@ func TestCancelBatchOrders(t *testing.T) { type FixtureConnection struct { dialError error sendMessageReturnResponseOverride []byte - match websocket.Match + match *websocket.Match websocket.Connection } @@ -3037,6 +2956,10 @@ func (d *FixtureConnection) RequireMatchWithData(signature any, data []byte) err return d.match.RequireMatchWithData(signature, data) } +func (d *FixtureConnection) IncomingWithData(signature any, data []byte) bool { + return d.match.IncomingWithData(signature, data) +} + func TestWsConnect(t *testing.T) { t.Parallel() err := e.WsConnect(t.Context(), &FixtureConnection{dialError: nil}) @@ -3064,7 +2987,6 @@ func TestPushDataPublic(t *testing.T) { keys := slices.Collect(maps.Keys(pushDataMap)) slices.Sort(keys) - for x := range keys { err := e.wsHandleData(nil, asset.Spot, []byte(pushDataMap[keys[x]])) if keys[x] == "unhandled" { @@ -3093,7 +3015,7 @@ func TestWSHandleAuthenticatedData(t *testing.T) { if bytes.Contains(r, []byte("%s")) { r = fmt.Appendf(nil, string(r), optionsTradablePair.String()) } - return e.wsHandleAuthenticatedData(ctx, nil, r) + return e.wsHandleAuthenticatedData(ctx, &FixtureConnection{match: websocket.NewMatch()}, r) }) close(e.Websocket.DataHandler) require.Len(t, e.Websocket.DataHandler, 6, "Should see correct number of messages") @@ -3128,7 +3050,7 @@ func TestWSHandleAuthenticatedData(t *testing.T) { assert.Equal(t, "Full", v[0].TpslMode, "TPSL mode should be correct") assert.Zero(t, v[0].LiqPrice.Float64(), "Liq price should be 0") assert.Zero(t, v[0].BustPrice.Float64(), "Bust price should be 0") - assert.Equal(t, "linear", v[0].Category, "Category should be correct") + assert.Equal(t, cLinear, v[0].Category, "Category should be correct") assert.Equal(t, "Normal", v[0].PositionStatus, "Position status should be correct") assert.Equal(t, int64(2), v[0].AdlRankIndicator, "ADL Rank Indicator should be correct") case []order.Detail: @@ -3518,15 +3440,15 @@ func TestDeltaUpdateOrderbook(t *testing.T) { func TestGetLongShortRatio(t *testing.T) { t.Parallel() - _, err := e.GetLongShortRatio(t.Context(), "linear", "BTCUSDT", kline.FiveMin, 0) + _, err := e.GetLongShortRatio(t.Context(), cLinear, "BTCUSDT", kline.FiveMin, 0) if err != nil { t.Fatal(err) } - _, err = e.GetLongShortRatio(t.Context(), "inverse", "BTCUSDT", kline.FiveMin, 0) + _, err = e.GetLongShortRatio(t.Context(), cInverse, "BTCUSDT", kline.FiveMin, 0) if err != nil { t.Fatal(err) } - _, err = e.GetLongShortRatio(t.Context(), "spot", "BTCUSDT", kline.FiveMin, 0) + _, err = e.GetLongShortRatio(t.Context(), cSpot, "BTCUSDT", kline.FiveMin, 0) require.ErrorIs(t, err, errInvalidCategory) } @@ -3798,22 +3720,41 @@ func TestAuthSubscribe(t *testing.T) { require.NoError(t, e.authUnsubscribe(t.Context(), &FixtureConnection{}, authsubs)) } -func TestWebsocketAuthenticateConnection(t *testing.T) { +func TestWebsocketAuthenticatePrivateConnection(t *testing.T) { t.Parallel() e := new(Exchange) //nolint:govet // Intentional shadow require.NoError(t, testexch.Setup(e)) - err := e.WebsocketAuthenticateConnection(t.Context(), &FixtureConnection{}) + err := e.WebsocketAuthenticatePrivateConnection(t.Context(), &FixtureConnection{}) require.ErrorIs(t, err, exchange.ErrAuthenticationSupportNotEnabled) e.API.AuthenticatedSupport = true e.API.AuthenticatedWebsocketSupport = true e.Websocket.SetCanUseAuthenticatedEndpoints(true) ctx := account.DeployCredentialsToContext(t.Context(), &account.Credentials{Key: "dummy", Secret: "dummy"}) - err = e.WebsocketAuthenticateConnection(ctx, &FixtureConnection{}) + err = e.WebsocketAuthenticatePrivateConnection(ctx, &FixtureConnection{}) require.NoError(t, err) - err = e.WebsocketAuthenticateConnection(ctx, &FixtureConnection{sendMessageReturnResponseOverride: []byte(`{"success":false,"ret_msg":"failed auth","conn_id":"5758770c-8152-4545-a84f-dae089e56499","req_id":"1","op":"subscribe"}`)}) + err = e.WebsocketAuthenticatePrivateConnection(ctx, &FixtureConnection{sendMessageReturnResponseOverride: []byte(`{"success":false,"ret_msg":"failed auth","conn_id":"5758770c-8152-4545-a84f-dae089e56499","req_id":"1","op":"subscribe"}`)}) + require.Error(t, err) +} + +func TestWebsocketAuthenticateTradeConnection(t *testing.T) { + t.Parallel() + + e := new(Exchange) //nolint:govet // Intentional shadow + require.NoError(t, testexch.Setup(e)) + + err := e.WebsocketAuthenticateTradeConnection(t.Context(), &FixtureConnection{}) + require.ErrorIs(t, err, exchange.ErrAuthenticationSupportNotEnabled) + + e.API.AuthenticatedSupport = true + e.API.AuthenticatedWebsocketSupport = true + e.Websocket.SetCanUseAuthenticatedEndpoints(true) + ctx := account.DeployCredentialsToContext(t.Context(), &account.Credentials{Key: "dummy", Secret: "dummy"}) + err = e.WebsocketAuthenticateTradeConnection(ctx, &FixtureConnection{sendMessageReturnResponseOverride: []byte(`{"retCode":0,"retMsg":"OK","op":"auth","connId":"d2a641kgcg7ab33b7mdg-4x6a"}`)}) + require.NoError(t, err) + err = e.WebsocketAuthenticateTradeConnection(ctx, &FixtureConnection{sendMessageReturnResponseOverride: []byte(`{"retCode":10004,"retMsg":"Invalid sign","op":"auth","connId":"d2a63t6p49kk82nefh90-4ye8"}`)}) require.Error(t, err) } @@ -3892,13 +3833,13 @@ func TestMatchPairAssetFromResponse(t *testing.T) { expectedPair currency.Pair err error }{ - {pair: noDelim.Format(spotTradablePair), category: "spot", expectedAsset: asset.Spot, expectedPair: spotTradablePair}, - {pair: noDelim.Format(usdtMarginedTradablePair), category: "linear", expectedAsset: asset.USDTMarginedFutures, expectedPair: usdtMarginedTradablePair}, - {pair: noDelim.Format(usdcMarginedTradablePair), category: "linear", expectedAsset: asset.USDCMarginedFutures, expectedPair: usdcMarginedTradablePair}, - {pair: noDelim.Format(inverseTradablePair), category: "inverse", expectedAsset: asset.CoinMarginedFutures, expectedPair: inverseTradablePair}, - {pair: optionsTradablePair.String(), category: "option", expectedAsset: asset.Options, expectedPair: optionsTradablePair}, + {pair: noDelim.Format(spotTradablePair), category: cSpot, expectedAsset: asset.Spot, expectedPair: spotTradablePair}, + {pair: noDelim.Format(usdtMarginedTradablePair), category: cLinear, expectedAsset: asset.USDTMarginedFutures, expectedPair: usdtMarginedTradablePair}, + {pair: noDelim.Format(usdcMarginedTradablePair), category: cLinear, expectedAsset: asset.USDCMarginedFutures, expectedPair: usdcMarginedTradablePair}, + {pair: noDelim.Format(inverseTradablePair), category: cInverse, expectedAsset: asset.CoinMarginedFutures, expectedPair: inverseTradablePair}, + {pair: optionsTradablePair.String(), category: cOption, expectedAsset: asset.Options, expectedPair: optionsTradablePair}, {pair: optionsTradablePair.String(), category: "silly", err: errUnsupportedCategory, expectedAsset: 0}, - {pair: "bad pair", category: "spot", err: currency.ErrPairNotFound}, + {pair: "bad pair", category: cSpot, err: currency.ErrPairNotFound}, } { t.Run(fmt.Sprintf("pair: %s, category: %s", tc.pair, tc.category), func(t *testing.T) { t.Parallel() @@ -3927,7 +3868,7 @@ func TestHandleNoTopicWebsocketResponse(t *testing.T) { } { t.Run(fmt.Sprintf("operation: %s, requestID: %s", tc.operation, tc.requestID), func(t *testing.T) { t.Parallel() - err := e.handleNoTopicWebsocketResponse(&FixtureConnection{}, &WebsocketResponse{Operation: tc.operation, RequestID: tc.requestID}, nil) + err := e.handleNoTopicWebsocketResponse(&FixtureConnection{match: websocket.NewMatch()}, &WebsocketResponse{Operation: tc.operation, RequestID: tc.requestID}, nil) assert.ErrorIs(t, err, tc.error, "handleNoTopicWebsocketResponse should return expected error") }) } diff --git a/exchanges/bybit/bybit_types.go b/exchanges/bybit/bybit_types.go index ac722d4a..b080137a 100644 --- a/exchanges/bybit/bybit_types.go +++ b/exchanges/bybit/bybit_types.go @@ -12,8 +12,6 @@ import ( "github.com/thrasher-corp/gocryptotrader/types" ) -var validCategory = []string{"spot", "linear", "inverse", "option"} - // supportedOptionsTypes Bybit does not offer a way to retrieve option denominations via its API var supportedOptionsTypes = []string{"BTC", "ETH", "SOL"} @@ -134,11 +132,6 @@ type KlineItem struct { Turnover types.Number } -// UnmarshalJSON implements the json.Unmarshaler interface for KlineItem -func (k *KlineItem) UnmarshalJSON(data []byte) error { - return json.Unmarshal(data, &[7]any{&k.StartTime, &k.Open, &k.High, &k.Low, &k.Close, &k.TradeVolume, &k.Turnover}) -} - // MarkPriceKlineResponse represents a kline data item. type MarkPriceKlineResponse struct { Symbol string `json:"symbol"` @@ -146,17 +139,6 @@ type MarkPriceKlineResponse struct { List []KlineItem `json:"list"` } -func constructOrderbook(o *orderbookResponse) (*Orderbook, error) { - s := Orderbook{ - Symbol: o.Symbol, - UpdateID: o.UpdateID, - GenerationTime: o.Timestamp.Time(), - } - s.Bids = processOB(o.Bids) - s.Asks = processOB(o.Asks) - return &s, nil -} - // TickerData represents a list of ticker detailed information. type TickerData struct { Category string `json:"category"` @@ -306,17 +288,17 @@ type DeliveryPrice struct { } `json:"list"` } -// PlaceOrderParams represents -type PlaceOrderParams struct { +// PlaceOrderRequest represents +type PlaceOrderRequest struct { Category string `json:"category"` // Required Symbol currency.Pair `json:"symbol"` // Required Side string `json:"side"` // Required OrderType string `json:"orderType"` // Required // Market, Limit OrderQuantity float64 `json:"qty,string"` // Required // Order quantity. For Spot Market Buy order, please note that qty should be quote currency amount Price float64 `json:"price,string,omitempty"` - TimeInForce string `json:"timeInForce,omitempty"` // IOC and GTC - OrderLinkID string `json:"orderLinkId,omitempty"` // User customised order ID. A max of 36 characters. Combinations of numbers, letters (upper and lower cases), dashes, and underscores are supported. future orderLinkId rules: - WhetherToBorrow bool `json:"-"` // '0' for default spot, '1' for Margin trading. + TimeInForce string `json:"timeInForce,omitempty"` // IOC and GTC + OrderLinkID string `json:"orderLinkId,omitempty"` // User customised order ID. A max of 36 characters. Combinations of numbers, letters (upper and lower cases), dashes, and underscores are supported. future orderLinkId rules: + EnableBorrow bool `json:"-"` IsLeverage int64 `json:"isLeverage,omitempty"` // Required // '0' for default spot, '1' for Margin trading. OrderFilter string `json:"orderFilter,omitempty"` // Valid for spot only. Order,tpslOrder. If not passed, Order by default TriggerDirection int64 `json:"triggerDirection,omitempty"` // Required // Conditional order param. Used to identify the expected direction of the conditional order. '1': triggered when market price rises to triggerPrice '2': triggered when market price falls to triggerPrice @@ -349,16 +331,18 @@ type OrderResponse struct { OrderLinkID string `json:"orderLinkId"` } -// AmendOrderParams represents a parameter for amending order. -type AmendOrderParams struct { - Category string `json:"category,omitempty"` - Symbol currency.Pair `json:"symbol,omitzero"` - OrderID string `json:"orderId,omitempty"` - OrderLinkID string `json:"orderLinkId,omitempty"` // User customised order ID. A max of 36 characters. Combinations of numbers, letters (upper and lower cases), dashes, and underscores are supported. future orderLinkId rules: - OrderImpliedVolatility string `json:"orderIv,omitempty"` - TriggerPrice float64 `json:"triggerPrice,omitempty,string"` - OrderQuantity float64 `json:"qty,omitempty,string"` // Order quantity. For Spot Market Buy order, please note that qty should be quote currency amount - Price float64 `json:"price,string,omitempty"` +// AmendOrderRequest represents a parameter for amending order. +type AmendOrderRequest struct { + Category string `json:"category"` // Required + Symbol currency.Pair `json:"symbol"` // Required + OrderID string `json:"orderId,omitempty"` // This or OrderLinkID required + OrderLinkID string `json:"orderLinkId,omitempty"` // User customised order ID. A max of 36 characters. Combinations of numbers, letters (upper and lower cases), dashes, and underscores are supported. future orderLinkId rules: + + // At least one of the following fields is required + OrderImpliedVolatility string `json:"orderIv,omitempty"` + TriggerPrice float64 `json:"triggerPrice,omitempty,string"` + OrderQuantity float64 `json:"qty,omitempty,string"` // Order quantity. For Spot Market Buy order, please note that qty should be quote currency amount + Price float64 `json:"price,string,omitempty"` TakeProfitPrice float64 `json:"takeProfit,omitempty,string"` StopLossPrice float64 `json:"stopLoss,omitempty,string"` @@ -378,14 +362,13 @@ type AmendOrderParams struct { TPSLMode string `json:"tpslMode,omitempty"` } -// CancelOrderParams represents a cancel order parameters. -type CancelOrderParams struct { - Category string `json:"category,omitempty"` - Symbol currency.Pair `json:"symbol,omitzero"` +// CancelOrderRequest represents a cancel order parameters. +type CancelOrderRequest struct { + Category string `json:"category"` + Symbol currency.Pair `json:"symbol"` OrderID string `json:"orderId,omitempty"` OrderLinkID string `json:"orderLinkId,omitempty"` // User customised order ID. A max of 36 characters. Combinations of numbers, letters (upper and lower cases), dashes, and underscores are supported. future orderLinkId rules: - - OrderFilter string `json:"orderFilter,omitempty"` // Valid for spot only. Order,tpslOrder. If not passed, Order by default + OrderFilter string `json:"orderFilter,omitempty"` // Valid for spot only. Order,tpslOrder. If not passed, Order by default } // TradeOrders represents category and list of trade orders of the category. @@ -548,8 +531,8 @@ type BatchAmendOrderParamItem struct { // CancelBatchOrder represents a batch cancel request parameters. type CancelBatchOrder struct { - Category string `json:"category"` - Request []CancelOrderParams `json:"request"` + Category string `json:"category"` + Request []CancelOrderRequest `json:"request"` } // CancelBatchResponseItem represents a batch cancel response item. @@ -1775,11 +1758,11 @@ type WsOrderbookDetail struct { // SubscriptionResponse represents a subscription response. type SubscriptionResponse struct { - Success bool `json:"success"` - RetMsg string `json:"ret_msg"` - ConnID string `json:"conn_id"` - RequestID string `json:"req_id"` - Operation string `json:"op"` + Success bool `json:"success"` + ReturnMessage string `json:"ret_msg"` + ConnectionID string `json:"conn_id"` + RequestID string `json:"req_id"` + Operation string `json:"op"` } // WebsocketResponse represents push data response struct. @@ -2055,17 +2038,3 @@ type accountTypeHolder struct { // AccountType constants type AccountType uint8 - -// String returns the account type as a string -func (a AccountType) String() string { - switch a { - case 0: - return "unset" - case accountTypeNormal: - return "normal" - case accountTypeUnified: - return "unified" - default: - return "unknown" - } -} diff --git a/exchanges/bybit/bybit_websocket.go b/exchanges/bybit/bybit_websocket.go index 5bfe4dfb..26d08130 100644 --- a/exchanges/bybit/bybit_websocket.go +++ b/exchanges/bybit/bybit_websocket.go @@ -11,6 +11,7 @@ import ( "text/template" "time" + "github.com/buger/jsonparser" gws "github.com/gorilla/websocket" "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/crypto" @@ -58,6 +59,7 @@ const ( // Main-net private websocketPrivate = "wss://stream.bybit.com/v5/private" + websocketTrade = "wss://stream.bybit.com/v5/trade" ) var defaultSubscriptions = subscription.List{ @@ -97,24 +99,14 @@ func (e *Exchange) WsConnect(ctx context.Context, conn websocket.Connection) err return nil } -// WebsocketAuthenticateConnection sends an authentication message to receive auth data -func (e *Exchange) WebsocketAuthenticateConnection(ctx context.Context, conn websocket.Connection) error { - creds, err := e.GetCredentials(ctx) +// WebsocketAuthenticatePrivateConnection sends an authentication message to the private websocket for inbound account +// data +func (e *Exchange) WebsocketAuthenticatePrivateConnection(ctx context.Context, conn websocket.Connection) error { + req, err := e.GetAuthenticationPayload(ctx, strconv.FormatInt(conn.GenerateMessageID(false), 10)) if err != nil { return err } - intNonce := time.Now().Add(time.Hour * 6).UnixMilli() - strNonce := strconv.FormatInt(intNonce, 10) - hmac, err := crypto.GetHMAC(crypto.HashSHA256, []byte("GET/realtime"+strNonce), []byte(creds.Secret)) - if err != nil { - return err - } - req := Authenticate{ - RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), - Operation: "auth", - Args: []any{creds.Key, intNonce, hex.EncodeToString(hmac)}, - } - resp, err := conn.SendMessageReturnResponse(ctx, request.Unset, req.RequestID, req) + resp, err := conn.SendMessageReturnResponse(ctx, wsSubscriptionEPL, req.RequestID, req) if err != nil { return err } @@ -123,11 +115,61 @@ func (e *Exchange) WebsocketAuthenticateConnection(ctx context.Context, conn web return err } if !response.Success { - return fmt.Errorf("%s with request ID %s msg: %s", response.Operation, response.RequestID, response.RetMsg) + return fmt.Errorf("%s with request ID %s msg: %s", response.Operation, response.RequestID, response.ReturnMessage) } return nil } +// WebsocketAuthenticateTradeConnection sends an authentication message to the private trade websocket for outbound +// account data +func (e *Exchange) WebsocketAuthenticateTradeConnection(ctx context.Context, conn websocket.Connection) error { + // request ID is not returned with the response, a workaround in the trade connection handler monitors the response + // for the operation type "auth", which is then set in the response match key. + req, err := e.GetAuthenticationPayload(ctx, "auth") + if err != nil { + return err + } + resp, err := conn.SendMessageReturnResponse(ctx, wsSubscriptionEPL, req.RequestID, req) + if err != nil { + return err + } + var response struct { + ReturnCode int64 `json:"retCode"` + ReturnMessage string `json:"retMsg"` + Operation string `json:"op"` + ConnectionID string `json:"connId"` + } + if err := json.Unmarshal(resp, &response); err != nil { + return err + } + if response.ReturnCode != 0 { + c, ok := retCode[response.ReturnCode] + if !ok { + c = "unknown return error code" + } + return fmt.Errorf("%s failed - code:%d [%v] msg:%s", response.Operation, response.ReturnCode, c, response.ReturnMessage) + } + return nil +} + +// GetAuthenticationPayload returns the authentication payload for the websocket connection to upgrade the connection. +func (e *Exchange) GetAuthenticationPayload(ctx context.Context, requestID string) (*Authenticate, error) { + creds, err := e.GetCredentials(ctx) + if err != nil { + return nil, err + } + expires := time.Now().Add(time.Hour * 6).UnixMilli() + hmac, err := crypto.GetHMAC(crypto.HashSHA256, []byte("GET/realtime"+strconv.FormatInt(expires, 10)), []byte(creds.Secret)) + if err != nil { + return nil, err + } + return &Authenticate{ + RequestID: requestID, + Operation: "auth", + Args: []any{creds.Key, expires, hex.EncodeToString(hmac)}, + }, nil +} + func (e *Exchange) handleSubscriptions(conn websocket.Connection, operation string, subs subscription.List) (args []SubscriptionArgument, err error) { subs, err = subs.ExpandTemplates(e) if err != nil { @@ -163,6 +205,29 @@ func (e *Exchange) GetSubscriptionTemplate(_ *subscription.Subscription) (*templ }).Parse(subTplText) } +func (e *Exchange) wsHandleTradeData(conn websocket.Connection, respRaw []byte) error { + var response struct { + RequestID string `json:"reqId"` + Operation string `json:"op"` + } + if err := json.Unmarshal(respRaw, &response); err != nil { + return err + } + + if response.RequestID != "" { + return conn.RequireMatchWithData(response.RequestID, respRaw) + } + + switch response.Operation { + case "auth": // When authenticating the connection there is no request ID, so a static value is used. + return conn.RequireMatchWithData(response.Operation, respRaw) + case "pong": + return nil + default: + return fmt.Errorf("%w for trade: %v", errUnhandledStreamData, string(respRaw)) + } +} + func (e *Exchange) wsHandleData(conn websocket.Connection, assetType asset.Item, respRaw []byte) error { var result WebsocketResponse if err := json.Unmarshal(respRaw, &result); err != nil { @@ -208,6 +273,12 @@ func (e *Exchange) wsHandleAuthenticatedData(ctx context.Context, conn websocket case chanExecution: return e.wsProcessExecution(&result) case chanOrder: + // Use first order's orderLinkId to match with an entire batch of order change requests + if id, err := jsonparser.GetString(respRaw, "data", "[0]", "orderLinkId"); err == nil { + if conn.IncomingWithData(id, respRaw) { + return nil // If the data has been routed, return + } + } return e.wsProcessOrder(&result) case chanWallet: return e.wsProcessWalletPushData(ctx, respRaw) @@ -268,7 +339,7 @@ func (e *Exchange) wsProcessWalletPushData(ctx context.Context, resp []byte) err // wsProcessOrder the order stream to see changes to your orders in real-time. func (e *Exchange) wsProcessOrder(resp *WebsocketResponse) error { - var result WsOrders + var result []WebsocketOrderDetails if err := json.Unmarshal(resp.Data, &result); err != nil { return err } @@ -282,30 +353,26 @@ func (e *Exchange) wsProcessOrder(resp *WebsocketResponse) error { if err != nil { return err } - side, err := order.StringToOrderSide(result[x].Side) - if err != nil { - return err - } tif, err := order.StringToTimeInForce(result[x].TimeInForce) if err != nil { return err } execution[x] = order.Detail{ TimeInForce: tif, - Amount: result[x].Qty.Float64(), + Amount: result[x].Quantity.Float64(), Exchange: e.Name, OrderID: result[x].OrderID, ClientOrderID: result[x].OrderLinkID, - Side: side, + Side: result[x].Side, Type: orderType, Pair: cp, - Cost: result[x].CumExecQty.Float64() * result[x].AvgPrice.Float64(), - Fee: result[x].CumExecFee.Float64(), + Cost: result[x].CumulativeExecutedQuantity.Float64() * result[x].AveragePrice.Float64(), + Fee: result[x].CumulativeExecutedFee.Float64(), AssetType: a, Status: StringToOrderStatus(result[x].OrderStatus), Price: result[x].Price.Float64(), - ExecutedAmount: result[x].CumExecQty.Float64(), - AverageExecutedPrice: result[x].AvgPrice.Float64(), + ExecutedAmount: result[x].CumulativeExecutedQuantity.Float64(), + AverageExecutedPrice: result[x].AveragePrice.Float64(), Date: result[x].CreatedTime.Time(), LastUpdated: result[x].UpdatedTime.Time(), } @@ -581,9 +648,6 @@ func (e *Exchange) wsProcessOrderbook(assetType asset.Item, resp *WebsocketRespo if err := json.Unmarshal(resp.Data, &result); err != nil { return err } - if len(result.Bids) == 0 && len(result.Asks) == 0 { - return nil - } cp, err := e.MatchSymbolWithAvailablePairs(result.Symbol, assetType, hasPotentialDelimiter(assetType)) if err != nil { @@ -620,6 +684,7 @@ func (e *Exchange) wsProcessOrderbook(assetType asset.Item, resp *WebsocketRespo UpdateID: result.UpdateID, UpdateTime: resp.OrderbookLastUpdated.Time(), LastPushed: resp.PushTimestamp.Time(), + AllowEmpty: true, }) } @@ -678,11 +743,11 @@ func (e *Exchange) submitDirectSubscription(ctx context.Context, conn websocket. if a == asset.Options { // The options connection does not send the subscription request id back with the subscription notification payload // therefore the code doesn't wait for the response to check whether the subscription is successful or not. - if err := conn.SendJSONMessage(ctx, request.Unset, payload); err != nil { + if err := conn.SendJSONMessage(ctx, wsSubscriptionEPL, payload); err != nil { return err } } else { - response, err := conn.SendMessageReturnResponse(ctx, request.Unset, payload.RequestID, payload) + response, err := conn.SendMessageReturnResponse(ctx, wsSubscriptionEPL, payload.RequestID, payload) if err != nil { return err } @@ -691,7 +756,7 @@ func (e *Exchange) submitDirectSubscription(ctx context.Context, conn websocket. return err } if !resp.Success { - return fmt.Errorf("%s with request ID %s msg: %s", resp.Operation, resp.RequestID, resp.RetMsg) + return fmt.Errorf("%s with request ID %s msg: %s", resp.Operation, resp.RequestID, resp.ReturnMessage) } } if err := op(conn, payload.associatedSubs...); err != nil { @@ -807,13 +872,13 @@ func (e *Exchange) authUnsubscribe(ctx context.Context, conn websocket.Connectio func (e *Exchange) matchPairAssetFromResponse(category, symbol string) (currency.Pair, asset.Item, error) { assets := make([]asset.Item, 0, 2) switch category { - case "spot": + case cSpot: assets = append(assets, asset.Spot) - case "inverse": + case cInverse: assets = append(assets, asset.CoinMarginedFutures) - case "linear": + case cLinear: assets = append(assets, asset.USDTMarginedFutures, asset.USDCMarginedFutures) - case "option": + case cOption: assets = append(assets, asset.Options) default: return currency.EMPTYPAIR, 0, fmt.Errorf("incoming symbol %q %w: %q", symbol, errUnsupportedCategory, category) diff --git a/exchanges/bybit/bybit_websocket_requests.go b/exchanges/bybit/bybit_websocket_requests.go new file mode 100644 index 00000000..0e1478b7 --- /dev/null +++ b/exchanges/bybit/bybit_websocket_requests.go @@ -0,0 +1,141 @@ +package bybit + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/gofrs/uuid" + "github.com/thrasher-corp/gocryptotrader/encoding/json" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" +) + +// Websocket request operation types +const ( + OutboundTradeConnection = "PRIVATE_TRADE" + InboundPrivateConnection = "PRIVATE" +) + +// WSCreateOrder creates an order through the websocket connection +func (e *Exchange) WSCreateOrder(ctx context.Context, r *PlaceOrderRequest) (*WebsocketOrderDetails, error) { + if err := r.Validate(); err != nil { + return nil, err + } + epl, err := getWSRateLimitEPLByCategory(r.Category) + if err != nil { + return nil, err + } + if r.OrderLinkID == "" { + r.OrderLinkID = uuid.Must(uuid.NewV7()).String() + } + return e.sendWebsocketTradeRequest(ctx, "order.create", r.OrderLinkID, r, epl) +} + +// WSAmendOrder amends an order through the websocket connection +func (e *Exchange) WSAmendOrder(ctx context.Context, r *AmendOrderRequest) (*WebsocketOrderDetails, error) { + if err := r.Validate(); err != nil { + return nil, err + } + epl, err := getWSRateLimitEPLByCategory(r.Category) + if err != nil { + return nil, err + } + if r.OrderLinkID == "" { + r.OrderLinkID = uuid.Must(uuid.NewV7()).String() + } + return e.sendWebsocketTradeRequest(ctx, "order.amend", r.OrderLinkID, r, epl) +} + +// WSCancelOrder cancels an order through the websocket connection +func (e *Exchange) WSCancelOrder(ctx context.Context, r *CancelOrderRequest) (*WebsocketOrderDetails, error) { + if err := r.Validate(); err != nil { + return nil, err + } + epl, err := getWSRateLimitEPLByCategory(r.Category) + if err != nil { + return nil, err + } + if r.OrderLinkID == "" { + r.OrderLinkID = uuid.Must(uuid.NewV7()).String() + } + return e.sendWebsocketTradeRequest(ctx, "order.cancel", r.OrderLinkID, r, epl) +} + +// sendWebsocketTradeRequest sends a trade request to the exchange through the websocket connection +func (e *Exchange) sendWebsocketTradeRequest(ctx context.Context, op, orderLinkID string, payload any, limit request.EndpointLimit) (*WebsocketOrderDetails, error) { + // Get the outbound and inbound connections to send and receive the request. This makes sure both are live before + // sending the request. + outbound, err := e.Websocket.GetConnection(OutboundTradeConnection) + if err != nil { + return nil, err + } + inbound, err := e.Websocket.GetConnection(InboundPrivateConnection) + if err != nil { + return nil, err + } + + tn := time.Now() + requestID := strconv.FormatInt(outbound.GenerateMessageID(false), 10) + + // Set up a listener to wait for the response to come back from the inbound connection. The request is sent through + // the outbound trade connection, the response can come back through the inbound private connection before the + // outbound connection sends its acknowledgement. + ch, err := inbound.MatchReturnResponses(ctx, orderLinkID, 1) + if err != nil { + return nil, err + } + + outResp, err := outbound.SendMessageReturnResponse(ctx, limit, requestID, WebsocketGeneralPayload{ + RequestID: requestID, + Header: map[string]string{"X-BAPI-TIMESTAMP": strconv.FormatInt(tn.UnixMilli(), 10)}, + Operation: op, + Arguments: []any{payload}, + }) + if err != nil { + return nil, err + } + + var confirmation WebsocketConfirmation + if err := json.Unmarshal(outResp, &confirmation); err != nil { + return nil, err + } + + if confirmation.RetCode != 0 { + return nil, fmt.Errorf("code:%d, info:%v message:%s", confirmation.RetCode, retCode[confirmation.RetCode], confirmation.RetMsg) + } + + inResp := <-ch // Blocking read is acceptable; channel has a built in timeout already + if inResp.Err != nil { + return nil, inResp.Err + } + + if len(inResp.Responses) != 1 { + return nil, fmt.Errorf("expected 1 response, received %d", len(inResp.Responses)) + } + + var ret WebsocketOrderResponse + if err := json.Unmarshal(inResp.Responses[0], &ret); err != nil { + return nil, err + } + + if len(ret.Data) != 1 { + return nil, fmt.Errorf("expected 1 response, received %d", len(ret.Data)) + } + + if ret.Data[0].RejectReason != "EC_NoError" { + return nil, fmt.Errorf("order rejected: %s", ret.Data[0].RejectReason) + } + + return &ret.Data[0], nil +} + +var retCode = map[int64]string{ + 10404: "either op type is not found or category is not correct/supported", + 10429: "request exceeds rate limit", + 20006: "reqId is duplicated", + 10016: "internal server error", + 10019: "ws trade service is restarting, please reconnect", + 20003: "too frequent requests under the same session", + 10403: "exceed IP rate limit. 3000 requests per second per IP", +} diff --git a/exchanges/bybit/bybit_websocket_requests_test.go b/exchanges/bybit/bybit_websocket_requests_test.go new file mode 100644 index 00000000..c32279e0 --- /dev/null +++ b/exchanges/bybit/bybit_websocket_requests_test.go @@ -0,0 +1,210 @@ +package bybit + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/config" + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchange/order/limits" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" + testutils "github.com/thrasher-corp/gocryptotrader/internal/testing/utils" +) + +func TestWSCreateOrder(t *testing.T) { + t.Parallel() + + arg := &PlaceOrderRequest{} + _, err := e.WSCreateOrder(t.Context(), arg) + require.ErrorIs(t, err, errCategoryNotSet) + + arg.Category = cSpot + _, err = e.WSCreateOrder(t.Context(), arg) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + arg.Symbol = currency.NewBTCUSDT() + _, err = e.WSCreateOrder(t.Context(), arg) + require.ErrorIs(t, err, order.ErrSideIsInvalid) + + arg.Side = "Buy" + _, err = e.WSCreateOrder(t.Context(), arg) + require.ErrorIs(t, err, order.ErrTypeIsInvalid) + + arg.OrderType = "Limit" + _, err = e.WSCreateOrder(t.Context(), arg) + require.ErrorIs(t, err, limits.ErrAmountBelowMin) + + arg.OrderQuantity = 0.0001 + arg.TriggerDirection = 69 + _, err = e.WSCreateOrder(t.Context(), arg) + require.ErrorIs(t, err, errInvalidTriggerDirection) + + arg.TriggerDirection = 0 + arg.OrderFilter = "dodgy" + _, err = e.WSCreateOrder(t.Context(), arg) + require.ErrorIs(t, err, errInvalidOrderFilter) + + arg.OrderFilter = "Order" + arg.TriggerPriceType = "dodgy" + _, err = e.WSCreateOrder(t.Context(), arg) + require.ErrorIs(t, err, errInvalidTriggerPriceType) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + e := getWebsocketInstance(t) //nolint:govet // Intentional shadow + got, err := e.WSCreateOrder(t.Context(), &PlaceOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + Side: "Buy", + OrderType: "Limit", + Price: 55000, + OrderQuantity: -0.0001, // Replace with a valid quantity + TimeInForce: "FOK", // Replace with GTC to submit a valid order if outside current trading price range. + }) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketSubmitOrder(t *testing.T) { + t.Parallel() + + // Test quote amount needs to be used due to protocol trade requirements + s := &order.Submit{ + Exchange: e.Name, + Pair: currency.NewBTCUSDT(), + AssetType: asset.Spot, + Side: order.Buy, + Type: order.Market, + Amount: 0.0001, + } + + _, err := e.WebsocketSubmitOrder(t.Context(), s) + require.ErrorIs(t, err, order.ErrAmountMustBeSet) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + e := getWebsocketInstance(t) //nolint:govet // Intentional shadow + + s.Type = order.Limit + s.Price = 55000 + s.Amount = -0.0001 // Replace with a valid quantity + got, err := e.WebsocketSubmitOrder(t.Context(), s) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWSAmendOrder(t *testing.T) { + t.Parallel() + arg := &AmendOrderRequest{} + _, err := e.WSAmendOrder(t.Context(), arg) + require.ErrorIs(t, err, errCategoryNotSet) + + arg.Category = cSpot + _, err = e.WSAmendOrder(t.Context(), arg) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + arg.Symbol = currency.NewBTCUSDT() + _, err = e.WSAmendOrder(t.Context(), arg) + require.ErrorIs(t, err, errEitherOrderIDOROrderLinkIDRequired) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + e := getWebsocketInstance(t) //nolint:govet // Intentional shadow + arg.OrderID = "1793353687809485568" // Replace with a valid order ID + arg.OrderQuantity = 0.0002 + got, err := e.WSAmendOrder(t.Context(), arg) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketModifyOrder(t *testing.T) { + t.Parallel() + mod := &order.Modify{ + Pair: currency.NewBTCUSDT(), + AssetType: asset.Spot, + Amount: 0.0001, + OrderID: "1793388409122024192", // Replace with a valid order ID + } + + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + e := getWebsocketInstance(t) //nolint:govet // Intentional shadow + + got, err := e.WebsocketModifyOrder(t.Context(), mod) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWSCancelOrder(t *testing.T) { + t.Parallel() + arg := &CancelOrderRequest{} + _, err := e.WSCancelOrder(t.Context(), arg) + require.ErrorIs(t, err, errCategoryNotSet) + + arg.Category = cSpot + _, err = e.WSCancelOrder(t.Context(), arg) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + arg.Symbol = currency.NewBTCUSDT() + _, err = e.WSCancelOrder(t.Context(), arg) + require.ErrorIs(t, err, errEitherOrderIDOROrderLinkIDRequired) + + arg.OrderID = "1793353687809485568" // Replace with a valid order ID + + arg.OrderFilter = "dodgy" + _, err = e.WSCancelOrder(t.Context(), arg) + require.ErrorIs(t, err, errInvalidOrderFilter) + + arg.Category = cLinear + _, err = e.WSCancelOrder(t.Context(), arg) + require.ErrorIs(t, err, errInvalidCategory) + + arg.Category = cSpot + arg.OrderFilter = "Order" + + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + e := getWebsocketInstance(t) //nolint:govet // Intentional shadow + got, err := e.WSCancelOrder(t.Context(), arg) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketCancelOrder(t *testing.T) { + t.Parallel() + cancel := &order.Cancel{ + OrderID: "1793388409122024192", // Replace with a valid order ID + Pair: currency.NewBTCUSDT(), + AssetType: asset.Spot, + } + + sharedtestvalues.SkipTestIfCredentialsUnset(t, e, canManipulateRealOrders) + e := getWebsocketInstance(t) //nolint:govet // Intentional shadow + + err := e.WebsocketCancelOrder(t.Context(), cancel) + require.NoError(t, err) +} + +// getWebsocketInstance returns a websocket instance copy for live bi-directional testing +func getWebsocketInstance(t *testing.T) *Exchange { + t.Helper() + cfg := &config.Config{} + root, err := testutils.RootPathFromCWD() + require.NoError(t, err) + + err = cfg.LoadConfig(filepath.Join(root, "testdata", "configtest.json"), true) + require.NoError(t, err) + + pairs := &e.CurrencyPairs + e := new(Exchange) //nolint:govet // Intentional shadow + e.SetDefaults() + bConf, err := cfg.GetExchangeConfig("Bybit") + require.NoError(t, err) + bConf.API.AuthenticatedSupport = true + bConf.API.AuthenticatedWebsocketSupport = true + bConf.API.Credentials.Key = apiKey + bConf.API.Credentials.Secret = apiSecret + + require.NoError(t, e.Setup(bConf), "Setup must not error") + e.CurrencyPairs.Load(pairs) + require.NoError(t, e.Websocket.Connect()) + return e +} diff --git a/exchanges/bybit/bybit_websocket_requests_types.go b/exchanges/bybit/bybit_websocket_requests_types.go new file mode 100644 index 00000000..442e8a1e --- /dev/null +++ b/exchanges/bybit/bybit_websocket_requests_types.go @@ -0,0 +1,85 @@ +package bybit + +import ( + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/types" +) + +// WebsocketOrderDetails is the order details from the websocket response. +type WebsocketOrderDetails struct { + Category string `json:"category"` + OrderID string `json:"orderId"` + OrderLinkID string `json:"orderLinkId"` + IsLeverage string `json:"isLeverage"` // Whether to borrow. Unified spot only. 0: false, 1: true; Classic spot is not supported, always 0 + BlockTradeID string `json:"blockTradeId"` + Symbol string `json:"symbol"` // Undelimited so inbuilt string used. + Price types.Number `json:"price"` + Quantity types.Number `json:"qty"` + Side order.Side `json:"side"` + PositionIdx int64 `json:"positionIdx"` + OrderStatus string `json:"orderStatus"` + CreateType string `json:"createType"` + CancelType string `json:"cancelType"` + RejectReason string `json:"rejectReason"` // Classic spot is not supported + AveragePrice types.Number `json:"avgPrice"` + LeavesQuantity types.Number `json:"leavesQty"` // The remaining qty not executed. Classic spot is not supported + LeavesValue types.Number `json:"leavesValue"` // The remaining value not executed. Classic spot is not supported + CumulativeExecutedQuantity types.Number `json:"cumExecQty"` + CumulativeExecutedValue types.Number `json:"cumExecValue"` + CumulativeExecutedFee types.Number `json:"cumExecFee"` + ClosedPNL types.Number `json:"closedPnl"` + FeeCurrency currency.Code `json:"feeCurrency"` // Trading fee currency for Spot only. + TimeInForce string `json:"timeInForce"` + OrderType string `json:"orderType"` + StopOrderType string `json:"stopOrderType"` + OneCancelsOtherTriggerBy string `json:"ocoTriggerBy"` // UTA Spot: add new response field ocoTriggerBy, and the value can be OcoTriggerByUnknown, OcoTriggerByTp, OcoTriggerBySl + OrderImpliedVolatility types.Number `json:"orderIv"` + MarketUnit string `json:"marketUnit"` // The unit for qty when create Spot market orders for UTA account. baseCoin, quoteCoin + TriggerPrice types.Number `json:"triggerPrice"` // Trigger price. If stopOrderType=TrailingStop, it is activate price. Otherwise, it is trigger price + TakeProfit types.Number `json:"takeProfit"` + StopLoss types.Number `json:"stopLoss"` + TakeProfitStopLossMode string `json:"tpslMode"` // TP/SL mode, Full: entire position for TP/SL. Partial: partial position tp/sl. Spot does not have this field, and Option returns always "" + TakeProfitLimitPrice types.Number `json:"tpLimitPrice"` + StopLossLimitPrice types.Number `json:"slLimitPrice"` + TakeProfitTriggerBy string `json:"tpTriggerBy"` + StopLossTriggerBy string `json:"slTriggerBy"` + TriggerDirection int64 `json:"triggerDirection"` // Trigger direction. 1: rise, 2: fall + TriggerBy string `json:"triggerBy"` + LastPriceOnCreated types.Number `json:"lastPriceOnCreated"` + ReduceOnly bool `json:"reduceOnly"` + CloseOnTrigger bool `json:"closeOnTrigger"` + PlaceType string `json:"placeType"` // Place type, option used. iv, price + SMPType string `json:"smpType"` + SMPGroup int `json:"smpGroup"` + SMPOrderID string `json:"smpOrderId"` + CreatedTime types.Time `json:"createdTime"` + UpdatedTime types.Time `json:"updatedTime"` +} + +// WebsocketConfirmation is the initial response from the websocket connection +type WebsocketConfirmation struct { + RequestID string `json:"reqId"` + RetCode int64 `json:"retCode"` + RetMsg string `json:"retMsg"` + Operation string `json:"op"` + RequestAcknowledgement OrderResponse `json:"data"` + Header map[string]string `json:"header"` + ConnectionID string `json:"connId"` +} + +// WebsocketOrderResponse is the response from an order request through the websocket connection +type WebsocketOrderResponse struct { + ID string `json:"id"` + Topic string `json:"topic"` + CreationTime types.Time `json:"creationTime"` + Data []WebsocketOrderDetails `json:"data"` +} + +// WebsocketGeneralPayload is the general payload for websocket requests +type WebsocketGeneralPayload struct { + RequestID string `json:"reqId"` + Header map[string]string `json:"header"` + Operation string `json:"op"` + Arguments []any `json:"args"` +} diff --git a/exchanges/bybit/bybit_websocket_test.go b/exchanges/bybit/bybit_websocket_test.go new file mode 100644 index 00000000..9daddecd --- /dev/null +++ b/exchanges/bybit/bybit_websocket_test.go @@ -0,0 +1,40 @@ +package bybit + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/exchange/websocket" +) + +func TestWSHandleTradeData(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + input []byte + match string + err error + }{ + {input: []byte(`{"reqId":"12345"}`), match: "12345", err: nil}, + {input: []byte(`{"op":"auth"}`), match: "auth", err: nil}, + {input: []byte(`{"op":"pong"}`), err: nil}, + {input: []byte(`{"op":"pewpewpew"}`), err: errUnhandledStreamData}, + } { + conn := &FixtureConnection{match: websocket.NewMatch()} + var ch <-chan []byte + if tc.match != "" { + var err error + ch, err = conn.match.Set(tc.match, 1) + require.NoError(t, err, "match.Set must not error") + } + err := e.wsHandleTradeData(conn, tc.input) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + continue + } + require.NoError(t, err) + if tc.match != "" { + require.Len(t, ch, 1, "must receive 1 message from channel") + require.Equal(t, tc.input, <-ch, "must be correct") + } + } +} diff --git a/exchanges/bybit/bybit_wrapper.go b/exchanges/bybit/bybit_wrapper.go index 583863f0..e31272c6 100644 --- a/exchanges/bybit/bybit_wrapper.go +++ b/exchanges/bybit/bybit_wrapper.go @@ -96,6 +96,9 @@ func (e *Exchange) SetDefaults() { currency.NewCode("SHIB1000"): currency.SHIB, }, ), + TradingRequirements: protocol.TradingRequirements{ + SpotMarketBuyQuotation: true, + }, Supports: exchange.FeaturesSupported{ REST: true, Websocket: true, @@ -194,16 +197,14 @@ func (e *Exchange) SetDefaults() { exchange.WebsocketUSDTMargined: linearPublic, exchange.WebsocketUSDCMargined: linearPublic, exchange.WebsocketOptions: optionPublic, + exchange.WebsocketTrade: websocketTrade, exchange.WebsocketPrivate: websocketPrivate, }) if err != nil { log.Errorln(log.ExchangeSys, err) } - if e.Requester, err = request.New(e.Name, - common.NewHTTPClientWithTimeout(exchange.DefaultHTTPTimeout), - request.WithLimiter(GetRateLimit()), - ); err != nil { + if e.Requester, err = request.New(e.Name, common.NewHTTPClientWithTimeout(exchange.DefaultHTTPTimeout), request.WithLimiter(rateLimits)); err != nil { log.Errorln(log.ExchangeSys, err) } @@ -232,6 +233,7 @@ func (e *Exchange) Setup(exch *config.Exchange) error { OrderbookBufferConfig: buffer.Config{SortBuffer: true, SortBufferByUpdateIDs: true}, TradeFeed: e.Features.Enabled.TradeFeed, UseMultiConnectionManagement: true, + RateLimitDefinitions: rateLimits, }); err != nil { return err } @@ -241,12 +243,11 @@ func (e *Exchange) Setup(exch *config.Exchange) error { return err } - // Spot + // Spot - Inbound public data. if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ URL: wsSpotURL, ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), Connector: e.WsConnect, GenerateSubscriptions: e.generateSubscriptions, Subscriber: e.SpotSubscribe, @@ -264,12 +265,11 @@ func (e *Exchange) Setup(exch *config.Exchange) error { return err } - // Options + // Options - Inbound public data. if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ URL: wsOptionsURL, ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), Connector: e.WsConnect, GenerateSubscriptions: e.GenerateOptionsDefaultSubscriptions, Subscriber: e.OptionsSubscribe, @@ -287,12 +287,11 @@ func (e *Exchange) Setup(exch *config.Exchange) error { return err } - // Linear - USDT margined futures. + // Linear - USDT margined futures inbound public data. if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ URL: wsUSDTLinearURL, ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), Connector: e.WsConnect, GenerateSubscriptions: func() (subscription.List, error) { return e.GenerateLinearDefaultSubscriptions(asset.USDTMarginedFutures) @@ -317,12 +316,11 @@ func (e *Exchange) Setup(exch *config.Exchange) error { return err } - // Linear - USDC margined futures. + // Linear - USDC margined futures inbound public data. if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ URL: wsUSDCLinearURL, ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), Connector: e.WsConnect, GenerateSubscriptions: func() (subscription.List, error) { return e.GenerateLinearDefaultSubscriptions(asset.USDCMarginedFutures) @@ -347,12 +345,11 @@ func (e *Exchange) Setup(exch *config.Exchange) error { return err } - // Inverse - Coin margined futures. + // Inverse - Coin margined futures inbound public data. if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ URL: wsInverseURL, ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), Connector: e.WsConnect, GenerateSubscriptions: e.GenerateInverseDefaultSubscriptions, Subscriber: e.InverseSubscribe, @@ -365,17 +362,38 @@ func (e *Exchange) Setup(exch *config.Exchange) error { return err } + wsTradeURL, err := e.API.Endpoints.GetURL(exchange.WebsocketTrade) + if err != nil { + return err + } + + // Trade - Dedicated trade connection for all outbound trading requests. + if err := e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ + URL: wsTradeURL, + ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, + ResponseMaxLimit: exch.WebsocketResponseMaxLimit, + Connector: e.WsConnect, + Handler: func(_ context.Context, conn websocket.Connection, resp []byte) error { + return e.wsHandleTradeData(conn, resp) + }, + RequestIDGenerator: e.messageIDSeq.IncrementAndGet, + Authenticate: e.WebsocketAuthenticateTradeConnection, + MessageFilter: OutboundTradeConnection, + SubscriptionsNotRequired: true, + }); err != nil { + return err + } + wsPrivateURL, err := e.API.Endpoints.GetURL(exchange.WebsocketPrivate) if err != nil { return err } - // Private + // Private - Inbound private data connection for authenticated data return e.Websocket.SetupNewConnection(&websocket.ConnectionSetup{ URL: wsPrivateURL, ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - RateLimit: request.NewWeightedRateLimitByDuration(time.Microsecond), Authenticated: true, Connector: e.WsConnect, GenerateSubscriptions: e.generateAuthSubscriptions, @@ -383,7 +401,8 @@ func (e *Exchange) Setup(exch *config.Exchange) error { Unsubscriber: e.authUnsubscribe, Handler: e.wsHandleAuthenticatedData, RequestIDGenerator: e.messageIDSeq.IncrementAndGet, - Authenticate: e.WebsocketAuthenticateConnection, + Authenticate: e.WebsocketAuthenticatePrivateConnection, + MessageFilter: InboundPrivateConnection, }) } @@ -467,13 +486,13 @@ func (e *Exchange) FetchTradablePairs(ctx context.Context, a asset.Item) (curren func getCategoryName(a asset.Item) string { switch a { case asset.CoinMarginedFutures: - return "inverse" + return cInverse case asset.USDTMarginedFutures, asset.USDCMarginedFutures: - return "linear" + return cLinear case asset.Spot: return a.String() case asset.Options: - return "option" + return cOption default: return "" } @@ -604,10 +623,7 @@ func (e *Exchange) UpdateOrderbook(ctx context.Context, p currency.Pair, assetTy } switch assetType { - case asset.Spot, asset.USDTMarginedFutures, - asset.USDCMarginedFutures, - asset.CoinMarginedFutures, - asset.Options: + case asset.Spot, asset.USDTMarginedFutures, asset.USDCMarginedFutures, asset.CoinMarginedFutures, asset.Options: if assetType == asset.USDCMarginedFutures && !p.Quote.Equal(currency.PERP) { p.Delimiter = currency.DashDelimiter } @@ -866,79 +882,52 @@ func orderTypeToString(oType order.Type) string { // SubmitOrder submits a new order func (e *Exchange) SubmitOrder(ctx context.Context, s *order.Submit) (*order.SubmitResponse, error) { - err := s.Validate(e.GetTradingRequirements()) + arg, err := e.deriveSubmitOrderArguments(s) if err != nil { return nil, err } - formattedPair, err := e.FormatExchangeCurrency(s.Pair, s.AssetType) + response, err := e.PlaceOrder(ctx, arg) if err != nil { return nil, err } - var sideType string - switch { - case s.Side.IsLong(): - sideType = sideBuy - case s.Side.IsShort(): - sideType = sideSell - default: - return nil, order.ErrSideIsInvalid + resp, err := s.DeriveSubmitResponse(response.OrderID) + if err != nil { + return nil, err } - status := order.New - switch s.AssetType { - case asset.Spot, asset.Options, asset.USDTMarginedFutures, asset.USDCMarginedFutures, asset.CoinMarginedFutures: - if s.AssetType == asset.USDCMarginedFutures && !formattedPair.Quote.Equal(currency.PERP) { - formattedPair.Delimiter = currency.DashDelimiter - } - var response *OrderResponse - arg := &PlaceOrderParams{ - Category: getCategoryName(s.AssetType), - Symbol: formattedPair, - Side: sideType, - OrderType: orderTypeToString(s.Type), - OrderQuantity: s.Amount, - Price: s.Price, - OrderLinkID: s.ClientOrderID, - WhetherToBorrow: s.AssetType == asset.Margin, - ReduceOnly: s.ReduceOnly, - OrderFilter: func() string { - if s.RiskManagementModes.TakeProfit.Price != 0 || s.RiskManagementModes.TakeProfit.LimitPrice != 0 || - s.RiskManagementModes.StopLoss.Price != 0 || s.RiskManagementModes.StopLoss.LimitPrice != 0 { - return "" - } else if s.TriggerPrice != 0 { - return "tpslOrder" - } - return "Order" - }(), - TriggerPrice: s.TriggerPrice, - } - if arg.TriggerPrice != 0 { - arg.TriggerPriceType = s.TriggerPriceType.String() - } - if s.RiskManagementModes.TakeProfit.Price != 0 { - arg.TakeProfitPrice = s.RiskManagementModes.TakeProfit.Price - arg.TakeProfitTriggerBy = s.RiskManagementModes.TakeProfit.TriggerPriceType.String() - arg.TpOrderType = getOrderTypeString(s.RiskManagementModes.TakeProfit.OrderType) - arg.TpLimitPrice = s.RiskManagementModes.TakeProfit.LimitPrice - } - if s.RiskManagementModes.StopLoss.Price != 0 { - arg.StopLossPrice = s.RiskManagementModes.StopLoss.Price - arg.StopLossTriggerBy = s.RiskManagementModes.StopLoss.TriggerPriceType.String() - arg.SlOrderType = getOrderTypeString(s.RiskManagementModes.StopLoss.OrderType) - arg.SlLimitPrice = s.RiskManagementModes.StopLoss.LimitPrice - } - response, err = e.PlaceOrder(ctx, arg) - if err != nil { - return nil, err - } - resp, err := s.DeriveSubmitResponse(response.OrderID) - if err != nil { - return nil, err - } - resp.Status = status - return resp, nil - default: - return nil, fmt.Errorf("%s %w", s.AssetType, asset.ErrNotSupported) + resp.Status = order.New + return resp, nil +} + +// WebsocketSubmitOrder submits a new order through the websocket connection +func (e *Exchange) WebsocketSubmitOrder(ctx context.Context, s *order.Submit) (*order.SubmitResponse, error) { + arg, err := e.deriveSubmitOrderArguments(s) + if err != nil { + return nil, err } + orderDetails, err := e.WSCreateOrder(ctx, arg) + if err != nil { + return nil, err + } + resp, err := s.DeriveSubmitResponse(orderDetails.OrderID) + if err != nil { + return nil, err + } + resp.Status, err = order.StringToOrderStatus(orderDetails.OrderStatus) + if err != nil { + return nil, err + } + resp.TimeInForce, err = order.StringToTimeInForce(orderDetails.TimeInForce) + if err != nil { + return nil, err + } + + resp.ReduceOnly = orderDetails.ReduceOnly + resp.TriggerPrice = orderDetails.TriggerPrice.Float64() + resp.AverageExecutedPrice = orderDetails.AveragePrice.Float64() + resp.ClientOrderID = orderDetails.OrderLinkID + resp.Fee = orderDetails.CumulativeExecutedFee.Float64() + resp.Cost = orderDetails.CumulativeExecutedValue.Float64() + return resp, nil } func getOrderTypeString(oType order.Type) string { @@ -950,48 +939,13 @@ func getOrderTypeString(oType order.Type) string { } } -// ModifyOrder will allow of changing orderbook placement and limit to -// market conversion +// ModifyOrder will allow of changing orderbook placement and limit to market conversion func (e *Exchange) ModifyOrder(ctx context.Context, action *order.Modify) (*order.ModifyResponse, error) { - if err := action.Validate(); err != nil { - return nil, err - } - var ( - result *OrderResponse - err error - ) - action.Pair, err = e.FormatExchangeCurrency(action.Pair, action.AssetType) + arg, err := e.deriveAmendOrderArguments(action) if err != nil { return nil, err } - switch action.AssetType { - case asset.Spot, asset.USDTMarginedFutures, asset.USDCMarginedFutures, asset.CoinMarginedFutures, asset.Options: - if action.AssetType == asset.USDCMarginedFutures && !action.Pair.Quote.Equal(currency.PERP) { - action.Pair.Delimiter = currency.DashDelimiter - } - arg := &AmendOrderParams{ - Category: getCategoryName(action.AssetType), - Symbol: action.Pair, - OrderID: action.OrderID, - OrderLinkID: action.ClientOrderID, - OrderQuantity: action.Amount, - Price: action.Price, - TriggerPrice: action.TriggerPrice, - TriggerPriceType: action.TriggerPriceType.String(), - TakeProfitPrice: action.RiskManagementModes.TakeProfit.Price, - TakeProfitTriggerBy: getOrderTypeString(action.RiskManagementModes.TakeProfit.OrderType), - TakeProfitLimitPrice: action.RiskManagementModes.TakeProfit.LimitPrice, - StopLossPrice: action.RiskManagementModes.StopLoss.Price, - StopLossTriggerBy: action.RiskManagementModes.StopLoss.TriggerPriceType.String(), - StopLossLimitPrice: action.RiskManagementModes.StopLoss.LimitPrice, - } - result, err = e.AmendOrder(ctx, arg) - if err != nil { - return nil, err - } - default: - err = fmt.Errorf("%s %w", action.AssetType, asset.ErrNotSupported) - } + result, err := e.AmendOrder(ctx, arg) if err != nil { return nil, err } @@ -1003,29 +957,44 @@ func (e *Exchange) ModifyOrder(ctx context.Context, action *order.Modify) (*orde return resp, nil } +// WebsocketModifyOrder modifies an existing order +func (e *Exchange) WebsocketModifyOrder(ctx context.Context, action *order.Modify) (*order.ModifyResponse, error) { + arg, err := e.deriveAmendOrderArguments(action) + if err != nil { + return nil, err + } + result, err := e.WSAmendOrder(ctx, arg) + if err != nil { + return nil, err + } + resp, err := action.DeriveModifyResponse() + if err != nil { + return nil, err + } + resp.OrderID = result.OrderID + resp.ClientOrderID = result.OrderLinkID + resp.Amount = result.Quantity.Float64() + resp.Price = action.Price + return resp, nil +} + // CancelOrder cancels an order by its corresponding ID number func (e *Exchange) CancelOrder(ctx context.Context, ord *order.Cancel) error { - if err := ord.Validate(ord.StandardCancel()); err != nil { - return err - } - format, err := e.GetPairFormat(ord.AssetType, true) + arg, err := e.deriveCancelOrderArguments(ord) if err != nil { return err } - switch ord.AssetType { - case asset.Spot, asset.USDTMarginedFutures, asset.USDCMarginedFutures, asset.CoinMarginedFutures, asset.Options: - if ord.AssetType == asset.USDCMarginedFutures && !ord.Pair.Quote.Equal(currency.PERP) { - ord.Pair.Delimiter = currency.DashDelimiter - } - _, err = e.CancelTradeOrder(ctx, &CancelOrderParams{ - Category: getCategoryName(ord.AssetType), - Symbol: ord.Pair.Format(format), - OrderID: ord.OrderID, - OrderLinkID: ord.ClientOrderID, - }) - default: - return fmt.Errorf("%s %w", ord.AssetType, asset.ErrNotSupported) + _, err = e.CancelTradeOrder(ctx, arg) + return err +} + +// WebsocketCancelOrder cancels an order by ID +func (e *Exchange) WebsocketCancelOrder(ctx context.Context, ord *order.Cancel) error { + arg, err := e.deriveCancelOrderArguments(ord) + if err != nil { + return err } + _, err = e.WSCancelOrder(ctx, arg) return err } @@ -1034,7 +1003,7 @@ func (e *Exchange) CancelBatchOrders(ctx context.Context, o []order.Cancel) (*or if len(o) == 0 { return nil, order.ErrCancelOrderIsNil } - requests := make([]CancelOrderParams, len(o)) + requests := make([]CancelOrderRequest, len(o)) category := asset.Options var err error for i := range o { @@ -1053,7 +1022,7 @@ func (e *Exchange) CancelBatchOrders(ctx context.Context, o []order.Cancel) (*or if err != nil { return nil, err } - requests[i] = CancelOrderParams{ + requests[i] = CancelOrderRequest{ OrderID: o[i].OrderID, OrderLinkID: o[i].ClientOrderID, Symbol: o[i].Pair, @@ -1828,7 +1797,7 @@ func (e *Exchange) GetFuturesContractDetails(ctx context.Context, item asset.Ite } return resp, nil case asset.USDCMarginedFutures: - linearContracts, err := e.GetInstrumentInfo(ctx, "linear", "", "", "", "", 1000) + linearContracts, err := e.GetInstrumentInfo(ctx, cLinear, "", "", "", "", 1000) if err != nil { return nil, err } @@ -1907,7 +1876,7 @@ func (e *Exchange) GetFuturesContractDetails(ctx context.Context, item asset.Ite } return resp, nil case asset.USDTMarginedFutures: - linearContracts, err := e.GetInstrumentInfo(ctx, "linear", "", "", "", "", 1000) + linearContracts, err := e.GetInstrumentInfo(ctx, cLinear, "", "", "", "", 1000) if err != nil { return nil, err } diff --git a/exchanges/bybit/linear_websocket_test.go b/exchanges/bybit/linear_websocket_test.go index 45371171..bee00b75 100644 --- a/exchanges/bybit/linear_websocket_test.go +++ b/exchanges/bybit/linear_websocket_test.go @@ -15,7 +15,7 @@ func TestGenerateLinearDefaultSubscriptions(t *testing.T) { _, err := e.GenerateLinearDefaultSubscriptions(asset.OptionCombo) assert.ErrorIs(t, err, asset.ErrInvalidAsset) - e := new(Exchange) + e := new(Exchange) //nolint:govet // Intentional shadow require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") subs, err := e.GenerateLinearDefaultSubscriptions(asset.USDTMarginedFutures) @@ -43,7 +43,7 @@ func TestGenerateLinearDefaultSubscriptions(t *testing.T) { func TestLinearSubscribe(t *testing.T) { t.Parallel() - e := new(Exchange) + e := new(Exchange) //nolint:govet // Intentional shadow require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") subs, err := e.GenerateLinearDefaultSubscriptions(asset.USDTMarginedFutures) @@ -60,7 +60,7 @@ func TestLinearSubscribe(t *testing.T) { func TestLinearUnsubscribe(t *testing.T) { t.Parallel() - e := new(Exchange) + e := new(Exchange) //nolint:govet // Intentional shadow require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") subs, err := e.GenerateLinearDefaultSubscriptions(asset.USDTMarginedFutures) diff --git a/exchanges/bybit/options_websocket_test.go b/exchanges/bybit/options_websocket_test.go index b79f10c6..7dba7017 100644 --- a/exchanges/bybit/options_websocket_test.go +++ b/exchanges/bybit/options_websocket_test.go @@ -11,7 +11,7 @@ import ( func TestGenerateOptionsDefaultSubscriptions(t *testing.T) { t.Parallel() - e := new(Exchange) + e := new(Exchange) //nolint:govet // Intentional shadow require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") subs, err := e.GenerateOptionsDefaultSubscriptions() require.NoError(t, err, "GenerateOptionsDefaultSubscriptions must not error") @@ -31,7 +31,7 @@ func TestGenerateOptionsDefaultSubscriptions(t *testing.T) { func TestOptionSubscribe(t *testing.T) { t.Parallel() - e := new(Exchange) + e := new(Exchange) //nolint:govet // Intentional shadow require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") subs, err := e.GenerateOptionsDefaultSubscriptions() @@ -44,7 +44,7 @@ func TestOptionSubscribe(t *testing.T) { func TestOptionsUnsubscribe(t *testing.T) { t.Parallel() - e := new(Exchange) + e := new(Exchange) //nolint:govet // Intentional shadow require.NoError(t, testexch.Setup(e), "Test instance Setup must not error") subs, err := e.GenerateOptionsDefaultSubscriptions() diff --git a/exchanges/bybit/order_arguments.go b/exchanges/bybit/order_arguments.go new file mode 100644 index 00000000..8d71202f --- /dev/null +++ b/exchanges/bybit/order_arguments.go @@ -0,0 +1,132 @@ +package bybit + +import ( + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" +) + +func (e *Exchange) deriveSubmitOrderArguments(s *order.Submit) (*PlaceOrderRequest, error) { + if err := s.Validate(e.GetTradingRequirements()); err != nil { + return nil, err + } + + formattedPair, err := e.FormatExchangeCurrency(s.Pair, s.AssetType) + if err != nil { + return nil, err + } + + side := sideBuy + if s.Side.IsShort() { + side = sideSell + } + + if s.AssetType == asset.USDCMarginedFutures && !formattedPair.Quote.Equal(currency.PERP) { + formattedPair.Delimiter = currency.DashDelimiter + } + + timeInForce := "GTC" + if s.Type == order.Market { + timeInForce = "IOC" + } else { + switch { + case s.TimeInForce.Is(order.FillOrKill): + timeInForce = "FOK" + case s.TimeInForce.Is(order.PostOnly): + timeInForce = "PostOnly" + case s.TimeInForce.Is(order.ImmediateOrCancel): + timeInForce = "IOC" + } + } + + orderFilter := "" // If "Order" is not passed, "Order" by default. + if s.AssetType == asset.Spot && s.TriggerPrice != 0 { + orderFilter = "tpslOrder" + } + + var triggerPriceType string + if s.TriggerPrice != 0 { + triggerPriceType = s.TriggerPriceType.String() + } + + arg := &PlaceOrderRequest{ + Category: getCategoryName(s.AssetType), + Symbol: formattedPair, + Side: side, + OrderType: orderTypeToString(s.Type), + OrderQuantity: s.Amount, + Price: s.Price, + OrderLinkID: s.ClientOrderID, + EnableBorrow: s.AssetType == asset.Margin, + ReduceOnly: s.ReduceOnly, + OrderFilter: orderFilter, + TriggerPrice: s.TriggerPrice, + TimeInForce: timeInForce, + TriggerPriceType: triggerPriceType, + } + + if s.RiskManagementModes.TakeProfit.Price != 0 { + arg.TakeProfitPrice = s.RiskManagementModes.TakeProfit.Price + arg.TakeProfitTriggerBy = s.RiskManagementModes.TakeProfit.TriggerPriceType.String() + arg.TpOrderType = getOrderTypeString(s.RiskManagementModes.TakeProfit.OrderType) + arg.TpLimitPrice = s.RiskManagementModes.TakeProfit.LimitPrice + } + if s.RiskManagementModes.StopLoss.Price != 0 { + arg.StopLossPrice = s.RiskManagementModes.StopLoss.Price + arg.StopLossTriggerBy = s.RiskManagementModes.StopLoss.TriggerPriceType.String() + arg.SlOrderType = getOrderTypeString(s.RiskManagementModes.StopLoss.OrderType) + arg.SlLimitPrice = s.RiskManagementModes.StopLoss.LimitPrice + } + return arg, nil +} + +func (e *Exchange) deriveAmendOrderArguments(action *order.Modify) (*AmendOrderRequest, error) { + if err := action.Validate(); err != nil { + return nil, err + } + + pair, err := e.FormatExchangeCurrency(action.Pair, action.AssetType) + if err != nil { + return nil, err + } + + if action.AssetType == asset.USDCMarginedFutures && !pair.Quote.Equal(currency.PERP) { + pair.Delimiter = currency.DashDelimiter + } + + return &AmendOrderRequest{ + Category: getCategoryName(action.AssetType), + Symbol: pair, + OrderID: action.OrderID, + OrderLinkID: action.ClientOrderID, + OrderQuantity: action.Amount, + Price: action.Price, + TriggerPrice: action.TriggerPrice, + TriggerPriceType: action.TriggerPriceType.String(), + TakeProfitPrice: action.RiskManagementModes.TakeProfit.Price, + TakeProfitTriggerBy: getOrderTypeString(action.RiskManagementModes.TakeProfit.OrderType), + TakeProfitLimitPrice: action.RiskManagementModes.TakeProfit.LimitPrice, + StopLossPrice: action.RiskManagementModes.StopLoss.Price, + StopLossTriggerBy: action.RiskManagementModes.StopLoss.TriggerPriceType.String(), + StopLossLimitPrice: action.RiskManagementModes.StopLoss.LimitPrice, + }, nil +} + +func (e *Exchange) deriveCancelOrderArguments(ord *order.Cancel) (*CancelOrderRequest, error) { + if err := ord.Validate(ord.StandardCancel()); err != nil { + return nil, err + } + pair, err := e.FormatExchangeCurrency(ord.Pair, ord.AssetType) + if err != nil { + return nil, err + } + if ord.AssetType == asset.USDCMarginedFutures && !pair.Quote.Equal(currency.PERP) { + pair.Delimiter = currency.DashDelimiter + } + return &CancelOrderRequest{ + Category: getCategoryName(ord.AssetType), + Symbol: pair, + OrderID: ord.OrderID, + OrderLinkID: ord.ClientOrderID, + }, nil +} diff --git a/exchanges/bybit/order_arguments_test.go b/exchanges/bybit/order_arguments_test.go new file mode 100644 index 00000000..a6e2892e --- /dev/null +++ b/exchanges/bybit/order_arguments_test.go @@ -0,0 +1,239 @@ +package bybit + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" +) + +func TestDeriveSubmitOrderArguments(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + submit *order.Submit + exp *PlaceOrderRequest + err error + }{ + {err: order.ErrSubmissionIsNil}, + { + submit: &order.Submit{ + Exchange: e.GetName(), + Pair: currency.NewBTCUSDT(), + AssetType: asset.Binary, + Side: order.Buy, + Type: order.Market, + Amount: 1, + }, + err: asset.ErrNotSupported, + }, + { + submit: &order.Submit{ + Exchange: e.GetName(), + Pair: currency.NewBTCUSDT(), + AssetType: asset.USDCMarginedFutures, + Side: order.Buy, + Type: order.Market, + Amount: 1, + }, + exp: &PlaceOrderRequest{ + Category: getCategoryName(asset.USDCMarginedFutures), + Symbol: currency.NewBTCUSDT().Format(currency.PairFormat{Uppercase: true, Delimiter: currency.DashDelimiter}), + Side: sideBuy, + OrderType: orderTypeToString(order.Market), + OrderQuantity: 1, + TimeInForce: "IOC", + }, + }, + { + submit: &order.Submit{ + Exchange: e.GetName(), + Pair: currency.NewBTCUSDT(), + AssetType: asset.USDCMarginedFutures, + Side: order.Buy, + Type: order.Market, + Amount: 1, + RiskManagementModes: order.RiskManagementModes{ + TakeProfit: order.RiskManagement{Price: 100}, + StopLoss: order.RiskManagement{Price: 200}, + }, + }, + exp: &PlaceOrderRequest{ + Category: getCategoryName(asset.USDCMarginedFutures), + Symbol: currency.NewBTCUSDT().Format(currency.PairFormat{Uppercase: true, Delimiter: currency.DashDelimiter}), + Side: sideBuy, + OrderType: orderTypeToString(order.Market), + OrderQuantity: 1, + TimeInForce: "IOC", + TakeProfitPrice: 100, + TakeProfitTriggerBy: "LastPrice", + StopLossPrice: 200, + StopLossTriggerBy: "LastPrice", + }, + }, + { + submit: &order.Submit{ + Exchange: e.GetName(), + Pair: currency.NewBTCUSDT(), + AssetType: asset.USDCMarginedFutures, + Side: order.Sell, + Type: order.Limit, + Amount: 1, + Price: 5000, + TriggerPrice: 150, + TimeInForce: order.FillOrKill, + }, + exp: &PlaceOrderRequest{ + Category: getCategoryName(asset.USDCMarginedFutures), + Symbol: currency.NewBTCUSDT().Format(currency.PairFormat{Uppercase: true, Delimiter: currency.DashDelimiter}), + Side: sideSell, + OrderType: orderTypeToString(order.Limit), + OrderQuantity: 1, + TimeInForce: "FOK", + TriggerPrice: 150, + Price: 5000, + TriggerPriceType: "LastPrice", + }, + }, + { + submit: &order.Submit{ + Exchange: e.GetName(), + Pair: currency.NewBTCUSDT(), + AssetType: asset.USDCMarginedFutures, + Side: order.Sell, + Type: order.Limit, + Amount: 1, + Price: 5000, + TriggerPrice: 150, + TimeInForce: order.PostOnly, + }, + exp: &PlaceOrderRequest{ + Category: getCategoryName(asset.USDCMarginedFutures), + Symbol: currency.NewBTCUSDT().Format(currency.PairFormat{Uppercase: true, Delimiter: currency.DashDelimiter}), + Side: sideSell, + OrderType: orderTypeToString(order.Limit), + OrderQuantity: 1, + TimeInForce: "PostOnly", + TriggerPrice: 150, + Price: 5000, + TriggerPriceType: "LastPrice", + }, + }, + { + submit: &order.Submit{ + Exchange: e.GetName(), + Pair: currency.NewBTCUSDT(), + AssetType: asset.Spot, + Side: order.Sell, + Type: order.Limit, + Amount: 1, + Price: 5000, + TriggerPrice: 150, + TimeInForce: order.ImmediateOrCancel, + }, + exp: &PlaceOrderRequest{ + Category: getCategoryName(asset.Spot), + Symbol: currency.NewBTCUSDT().Format(currency.PairFormat{Uppercase: true}), + Side: sideSell, + OrderType: orderTypeToString(order.Limit), + OrderQuantity: 1, + TimeInForce: "IOC", + TriggerPrice: 150, + Price: 5000, + OrderFilter: "tpslOrder", + TriggerPriceType: "LastPrice", + }, + }, + } { + got, err := e.deriveSubmitOrderArguments(tc.submit) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + continue + } + require.NoError(t, err) + assert.Equal(t, tc.exp, got) + } +} + +func TestDeriveAmendOrderArguments(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + action *order.Modify + exp *AmendOrderRequest + err error + }{ + {err: order.ErrModifyOrderIsNil}, + { + action: &order.Modify{ + OrderID: "69420", + Pair: currency.NewBTCUSDT(), + AssetType: asset.Binary, + }, + err: asset.ErrNotSupported, + }, + { + action: &order.Modify{ + OrderID: "69420", + Pair: currency.NewBTCUSDT(), + AssetType: asset.USDCMarginedFutures, + }, + exp: &AmendOrderRequest{ + Category: getCategoryName(asset.USDCMarginedFutures), + Symbol: currency.NewBTCUSDT().Format(currency.PairFormat{Uppercase: true, Delimiter: currency.DashDelimiter}), + OrderID: "69420", + StopLossTriggerBy: "LastPrice", + TriggerPriceType: "LastPrice", + }, + }, + } { + got, err := e.deriveAmendOrderArguments(tc.action) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + continue + } + require.NoError(t, err) + assert.Equal(t, tc.exp, got) + } +} + +func TestDeriveCancelOrderArguments(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + action *order.Cancel + exp *CancelOrderRequest + err error + }{ + {err: order.ErrCancelOrderIsNil}, + { + action: &order.Cancel{ + OrderID: "69420", + Pair: currency.NewBTCUSDT(), + AssetType: asset.Binary, + }, + err: asset.ErrNotSupported, + }, + { + action: &order.Cancel{ + OrderID: "69420", + Pair: currency.NewBTCUSDT(), + AssetType: asset.USDCMarginedFutures, + }, + exp: &CancelOrderRequest{ + Category: getCategoryName(asset.USDCMarginedFutures), + Symbol: currency.NewBTCUSDT().Format(currency.PairFormat{Uppercase: true, Delimiter: currency.DashDelimiter}), + OrderID: "69420", + }, + }, + } { + got, err := e.deriveCancelOrderArguments(tc.action) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + continue + } + require.NoError(t, err) + assert.Equal(t, tc.exp, got) + } +} diff --git a/exchanges/bybit/ratelimit.go b/exchanges/bybit/ratelimit.go index 4cdcbff2..8cf82fb7 100644 --- a/exchanges/bybit/ratelimit.go +++ b/exchanges/bybit/ratelimit.go @@ -1,11 +1,15 @@ package bybit import ( + "errors" + "fmt" "time" "github.com/thrasher-corp/gocryptotrader/exchanges/request" ) +var errUnknownCategory = errors.New("unknown category") + const ( defaultEPL request.EndpointLimit = iota createOrderEPL @@ -66,69 +70,93 @@ const ( spotCrossMarginTradeLoanEPL spotCrossMarginTradeRepayEPL spotCrossMarginTradeSwitchEPL + + wsOrderSpotEPL + wsOrderInverseEPL + wsOrderLinearEPL + wsOrderOptionsEPL + wsSubscriptionEPL ) -// GetRateLimit returns the rate limit for the exchange -func GetRateLimit() request.RateLimitDefinitions { - return request.RateLimitDefinitions{ - defaultEPL: request.NewRateLimitWithWeight(time.Second*5 /* See: https://bybit-exchange.github.io/docs/v5/rate-limit */, 600, 1), - createOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - createSpotOrderEPL: request.NewRateLimitWithWeight(time.Second, 20, 20), - amendOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - cancelOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - cancelSpotEPL: request.NewRateLimitWithWeight(time.Second, 20, 20), - cancelAllEPL: request.NewWeightedRateLimitByDuration(time.Second), - cancelAllSpotEPL: request.NewRateLimitWithWeight(time.Second, 20, 20), - createBatchOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - amendBatchOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - cancelBatchOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - getOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - getOrderHistoryEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - getPositionListEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - getExecutionListEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - getPositionClosedPNLEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - postPositionSetLeverageEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - setPositionTPLSModeEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - setPositionRiskLimitEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - stopTradingPositionEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - getAccountWalletBalanceEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - getAccountFeeEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - getAssetTransferQueryInfoEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), - getAssetTransferQueryTransferCoinListEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), - getAssetTransferCoinListEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), - getAssetInterTransferListEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), - getSubMemberListEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), - getAssetUniversalTransferListEPL: request.NewRateLimitWithWeight(time.Second, 2, 2), - getAssetAccountCoinBalanceEPL: request.NewRateLimitWithWeight(time.Second, 2, 2), - getAssetDepositRecordsEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), - getAssetDepositSubMemberRecordsEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), - getAssetDepositSubMemberAddressEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), - getWithdrawRecordsEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), - getAssetCoinInfoEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), - getExchangeOrderRecordEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), - interTransferEPL: request.NewRateLimitWithWeight(time.Minute, 20, 1), - saveTransferSubMemberEPL: request.NewRateLimitWithWeight(time.Minute, 20, 1), - universalTransferEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), - createWithdrawalEPL: request.NewWeightedRateLimitByDuration(time.Second), - cancelWithdrawalEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), - userCreateSubMemberEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), - userCreateSubAPIKeyEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), - userFrozenSubMemberEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), - userUpdateAPIEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), - userUpdateSubAPIEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), - userDeleteAPIEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), - userDeleteSubAPIEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), - userQuerySubMembersEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - userQueryAPIEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), - getSpotLeverageTokenOrderRecordsEPL: request.NewRateLimitWithWeight(time.Second, 50, 50), - spotLeverageTokenPurchaseEPL: request.NewRateLimitWithWeight(time.Second, 20, 20), - spotLeverTokenRedeemEPL: request.NewRateLimitWithWeight(time.Second, 20, 20), - getSpotCrossMarginTradeLoanInfoEPL: request.NewRateLimitWithWeight(time.Second, 50, 50), - getSpotCrossMarginTradeAccountEPL: request.NewRateLimitWithWeight(time.Second, 50, 50), - getSpotCrossMarginTradeOrdersEPL: request.NewRateLimitWithWeight(time.Second, 50, 50), - getSpotCrossMarginTradeRepayHistoryEPL: request.NewRateLimitWithWeight(time.Second, 50, 50), - spotCrossMarginTradeLoanEPL: request.NewRateLimitWithWeight(time.Second, 20, 50), - spotCrossMarginTradeRepayEPL: request.NewRateLimitWithWeight(time.Second, 20, 50), - spotCrossMarginTradeSwitchEPL: request.NewRateLimitWithWeight(time.Second, 20, 50), +var rateLimits = request.RateLimitDefinitions{ + defaultEPL: request.NewRateLimitWithWeight(time.Second*5, 600, 1), // See: https://bybit-exchange.github.io/docs/v5/rate-limit + createOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + createSpotOrderEPL: request.NewRateLimitWithWeight(time.Second, 20, 20), + amendOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + cancelOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + cancelSpotEPL: request.NewRateLimitWithWeight(time.Second, 20, 20), + cancelAllEPL: request.NewWeightedRateLimitByDuration(time.Second), + cancelAllSpotEPL: request.NewRateLimitWithWeight(time.Second, 20, 20), + createBatchOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + amendBatchOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + cancelBatchOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + getOrderEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + getOrderHistoryEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + getPositionListEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + getExecutionListEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + getPositionClosedPNLEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + postPositionSetLeverageEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + setPositionTPLSModeEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + setPositionRiskLimitEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + stopTradingPositionEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + getAccountWalletBalanceEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + getAccountFeeEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + getAssetTransferQueryInfoEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), + getAssetTransferQueryTransferCoinListEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), + getAssetTransferCoinListEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), + getAssetInterTransferListEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), + getSubMemberListEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), + getAssetUniversalTransferListEPL: request.NewRateLimitWithWeight(time.Second, 2, 2), + getAssetAccountCoinBalanceEPL: request.NewRateLimitWithWeight(time.Second, 2, 2), + getAssetDepositRecordsEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), + getAssetDepositSubMemberRecordsEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), + getAssetDepositSubMemberAddressEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), + getWithdrawRecordsEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), + getAssetCoinInfoEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), + getExchangeOrderRecordEPL: request.NewRateLimitWithWeight(time.Minute, 30, 1), + interTransferEPL: request.NewRateLimitWithWeight(time.Minute, 20, 1), + saveTransferSubMemberEPL: request.NewRateLimitWithWeight(time.Minute, 20, 1), + universalTransferEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), + createWithdrawalEPL: request.NewWeightedRateLimitByDuration(time.Second), + cancelWithdrawalEPL: request.NewRateLimitWithWeight(time.Minute, 60, 1), + userCreateSubMemberEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), + userCreateSubAPIKeyEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), + userFrozenSubMemberEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), + userUpdateAPIEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), + userUpdateSubAPIEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), + userDeleteAPIEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), + userDeleteSubAPIEPL: request.NewRateLimitWithWeight(time.Second, 5, 5), + userQuerySubMembersEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + userQueryAPIEPL: request.NewRateLimitWithWeight(time.Second, 10, 10), + getSpotLeverageTokenOrderRecordsEPL: request.NewRateLimitWithWeight(time.Second, 50, 50), + spotLeverageTokenPurchaseEPL: request.NewRateLimitWithWeight(time.Second, 20, 20), + spotLeverTokenRedeemEPL: request.NewRateLimitWithWeight(time.Second, 20, 20), + getSpotCrossMarginTradeLoanInfoEPL: request.NewRateLimitWithWeight(time.Second, 50, 50), + getSpotCrossMarginTradeAccountEPL: request.NewRateLimitWithWeight(time.Second, 50, 50), + getSpotCrossMarginTradeOrdersEPL: request.NewRateLimitWithWeight(time.Second, 50, 50), + getSpotCrossMarginTradeRepayHistoryEPL: request.NewRateLimitWithWeight(time.Second, 50, 50), + spotCrossMarginTradeLoanEPL: request.NewRateLimitWithWeight(time.Second, 20, 50), + spotCrossMarginTradeRepayEPL: request.NewRateLimitWithWeight(time.Second, 20, 50), + spotCrossMarginTradeSwitchEPL: request.NewRateLimitWithWeight(time.Second, 20, 50), + + wsOrderSpotEPL: request.NewRateLimitWithWeight(time.Second, 20, 1), + wsOrderInverseEPL: request.NewRateLimitWithWeight(time.Second, 20, 1), + wsOrderLinearEPL: request.NewRateLimitWithWeight(time.Second, 20, 1), + wsOrderOptionsEPL: request.NewRateLimitWithWeight(time.Second, 20, 1), + wsSubscriptionEPL: request.RateLimitNotRequired, +} + +func getWSRateLimitEPLByCategory(category string) (request.EndpointLimit, error) { + switch category { + case cSpot: + return wsOrderSpotEPL, nil + case cInverse: + return wsOrderInverseEPL, nil + case cLinear: + return wsOrderLinearEPL, nil + case cOption: + return wsOrderOptionsEPL, nil + default: + return 0, fmt.Errorf("%w: %q", errUnknownCategory, category) } } diff --git a/exchanges/bybit/ratelimit_test.go b/exchanges/bybit/ratelimit_test.go new file mode 100644 index 00000000..011e9589 --- /dev/null +++ b/exchanges/bybit/ratelimit_test.go @@ -0,0 +1,35 @@ +package bybit + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" +) + +func TestGetWSRateLimitEPLByCategory(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + category string + expected request.EndpointLimit + err error + }{ + {"", 0, errUnknownCategory}, + {cSpot, wsOrderSpotEPL, nil}, + {cInverse, wsOrderInverseEPL, nil}, + {cLinear, wsOrderLinearEPL, nil}, + {cOption, wsOrderOptionsEPL, nil}, + } { + t.Run(tc.category, func(t *testing.T) { + t.Parallel() + actual, err := getWSRateLimitEPLByCategory(tc.category) + if tc.err != nil { + require.ErrorIs(t, err, tc.err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/exchanges/bybit/spot_websocket.go b/exchanges/bybit/spot_websocket.go index c28715d0..04f12bd7 100644 --- a/exchanges/bybit/spot_websocket.go +++ b/exchanges/bybit/spot_websocket.go @@ -25,7 +25,7 @@ func (e *Exchange) handleSpotSubscription(ctx context.Context, conn websocket.Co return err } if !resp.Success { - return fmt.Errorf("%s with request ID %s msg: %s", resp.Operation, resp.RequestID, resp.RetMsg) + return fmt.Errorf("%s with request ID %s msg: %s", resp.Operation, resp.RequestID, resp.ReturnMessage) } if operation == "unsubscribe" { err = e.Websocket.RemoveSubscriptions(conn, payload.associatedSubs...) diff --git a/exchanges/bybit/unmarshal.go b/exchanges/bybit/unmarshal.go new file mode 100644 index 00000000..05efb04d --- /dev/null +++ b/exchanges/bybit/unmarshal.go @@ -0,0 +1,10 @@ +package bybit + +import ( + "github.com/thrasher-corp/gocryptotrader/encoding/json" +) + +// UnmarshalJSON implements the json.Unmarshaler interface for KlineItem +func (k *KlineItem) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &[7]any{&k.StartTime, &k.Open, &k.High, &k.Low, &k.Close, &k.TradeVolume, &k.Turnover}) +} diff --git a/exchanges/bybit/unmarshal_test.go b/exchanges/bybit/unmarshal_test.go new file mode 100644 index 00000000..39e5aad0 --- /dev/null +++ b/exchanges/bybit/unmarshal_test.go @@ -0,0 +1,24 @@ +package bybit + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/types" +) + +func TestUnmarshalJSONKlineItem(t *testing.T) { + t.Parallel() + ki := &KlineItem{} + err := ki.UnmarshalJSON([]byte(`["1691905800000","0.000301","0.0003015","0.0002995","0.0003","213303600","64084.7623"]`)) + require.NoError(t, err) + + require.Equal(t, time.UnixMilli(1691905800000), ki.StartTime.Time()) + require.Equal(t, types.Number(0.000301), ki.Open) + require.Equal(t, types.Number(0.0003015), ki.High) + require.Equal(t, types.Number(0.0002995), ki.Low) + require.Equal(t, types.Number(0.0003), ki.Close) + require.Equal(t, types.Number(213303600), ki.TradeVolume) + require.Equal(t, types.Number(64084.7623), ki.Turnover) +} diff --git a/exchanges/bybit/validate.go b/exchanges/bybit/validate.go new file mode 100644 index 00000000..9f6440e8 --- /dev/null +++ b/exchanges/bybit/validate.go @@ -0,0 +1,93 @@ +package bybit + +import ( + "fmt" + "slices" + + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchange/order/limits" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" +) + +var ( + validSides = []string{sideBuy, sideSell} + validOrderTypes = []string{"Market", "Limit"} + validOrderFilters = []string{"Order", "tpslOrder", "StopOrder"} + validTriggerPrices = []string{"", "LastPrice", "IndexPrice", "MarkPrice"} +) + +// Validate checks the input parameters and returns an error if they are invalid. +func (r *PlaceOrderRequest) Validate() error { + if err := isValidCategory(r.Category); err != nil { + return err + } + if r.Symbol.IsEmpty() { + return currency.ErrCurrencyPairEmpty + } + if r.EnableBorrow { + r.IsLeverage = 1 + } + if !slices.Contains(validSides, r.Side) { + return fmt.Errorf("%w: %q", order.ErrSideIsInvalid, r.Side) + } + if !slices.Contains(validOrderTypes, r.OrderType) { + return fmt.Errorf("%w: %q", order.ErrTypeIsInvalid, r.OrderType) + } + if r.OrderQuantity <= 0 { + return limits.ErrAmountBelowMin + } + switch r.TriggerDirection { + case 0, 1, 2: // 0: None, 1: triggered when market price rises to triggerPrice, 2: triggered when market price falls to triggerPrice + default: + return fmt.Errorf("%w, triggerDirection: %d", errInvalidTriggerDirection, r.TriggerDirection) + } + if r.OrderFilter != "" { + if r.Category != cSpot { + return fmt.Errorf("%w, orderFilter is valid for 'spot' only", errInvalidCategory) + } + if !slices.Contains(validOrderFilters, r.OrderFilter) { + return fmt.Errorf("%w, orderFilter=%s", errInvalidOrderFilter, r.OrderFilter) + } + } + if !slices.Contains(validTriggerPrices, r.TriggerPriceType) { + return errInvalidTriggerPriceType + } + + return nil +} + +// Validate checks the input parameters and returns an error if they are invalid +func (r *AmendOrderRequest) Validate() error { + if err := isValidCategory(r.Category); err != nil { + return err + } + if r.Symbol.IsEmpty() { + return currency.ErrCurrencyPairEmpty + } + if r.OrderID == "" && r.OrderLinkID == "" { + return errEitherOrderIDOROrderLinkIDRequired + } + return nil +} + +// Validate checks the input parameters and returns an error if they are invalid +func (r *CancelOrderRequest) Validate() error { + if err := isValidCategory(r.Category); err != nil { + return err + } + if r.Symbol.IsEmpty() { + return currency.ErrCurrencyPairEmpty + } + if r.OrderID == "" && r.OrderLinkID == "" { + return errEitherOrderIDOROrderLinkIDRequired + } + if r.OrderFilter != "" { + if r.Category != cSpot { + return fmt.Errorf("%w, orderFilter is valid for 'spot' only", errInvalidCategory) + } + if !slices.Contains(validOrderFilters, r.OrderFilter) { + return fmt.Errorf("%w, orderFilter=%s", errInvalidOrderFilter, r.OrderFilter) + } + } + return nil +} diff --git a/exchanges/bybit/validate_test.go b/exchanges/bybit/validate_test.go new file mode 100644 index 00000000..18e63450 --- /dev/null +++ b/exchanges/bybit/validate_test.go @@ -0,0 +1,195 @@ +package bybit + +import ( + "testing" + + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchange/order/limits" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" +) + +func TestValidatePlaceOrderRequest(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + params PlaceOrderRequest + err error + }{ + {err: errCategoryNotSet}, + {params: PlaceOrderRequest{Category: cSpot}, err: currency.ErrCurrencyPairEmpty}, + { + params: PlaceOrderRequest{Category: cSpot, Symbol: currency.NewBTCUSDT(), EnableBorrow: true}, + err: order.ErrSideIsInvalid, + }, + { + params: PlaceOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + EnableBorrow: true, + Side: sideBuy, + }, + err: order.ErrTypeIsInvalid, + }, + { + params: PlaceOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + EnableBorrow: true, + Side: sideBuy, + OrderType: orderTypeToString(order.Limit), + }, + err: limits.ErrAmountBelowMin, + }, + { + params: PlaceOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + EnableBorrow: true, + Side: sideBuy, + OrderType: orderTypeToString(order.Limit), + OrderQuantity: 0.0001, + TriggerDirection: 69, + }, + err: errInvalidTriggerDirection, + }, + { + params: PlaceOrderRequest{ + Category: cInverse, + Symbol: currency.NewBTCUSDT(), + EnableBorrow: true, + Side: sideBuy, + OrderType: orderTypeToString(order.Limit), + OrderQuantity: 0.0001, + OrderFilter: "dodgy", + }, + err: errInvalidCategory, + }, + { + params: PlaceOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + EnableBorrow: true, + Side: sideBuy, + OrderType: orderTypeToString(order.Limit), + OrderQuantity: 0.0001, + OrderFilter: "dodgy", + }, + err: errInvalidOrderFilter, + }, + { + params: PlaceOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + EnableBorrow: true, + Side: sideBuy, + OrderType: orderTypeToString(order.Limit), + OrderQuantity: 0.0001, + TriggerPriceType: "dodgy", + }, + err: errInvalidTriggerPriceType, + }, + { + params: PlaceOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + EnableBorrow: true, + Side: sideBuy, + OrderType: orderTypeToString(order.Limit), + OrderQuantity: 0.0001, + }, + err: nil, + }, + } { + if tc.err != nil { + require.ErrorIs(t, tc.params.Validate(), tc.err) + continue + } + require.NoError(t, tc.params.Validate()) + } +} + +func TestValidateAmendOrderRequest(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + params AmendOrderRequest + err error + }{ + {err: errCategoryNotSet}, + {params: AmendOrderRequest{Category: cSpot}, err: currency.ErrCurrencyPairEmpty}, + { + params: AmendOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + }, + err: errEitherOrderIDOROrderLinkIDRequired, + }, + { + params: AmendOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + OrderID: "69420", + TPSLMode: "TP", + }, + err: nil, + }, + } { + if tc.err != nil { + require.ErrorIs(t, tc.params.Validate(), tc.err) + continue + } + require.NoError(t, tc.params.Validate()) + } +} + +func TestValidateCancelOrderRequest(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + params CancelOrderRequest + err error + }{ + {err: errCategoryNotSet}, + {params: CancelOrderRequest{Category: cSpot}, err: currency.ErrCurrencyPairEmpty}, + { + params: CancelOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + }, + err: errEitherOrderIDOROrderLinkIDRequired, + }, + { + params: CancelOrderRequest{ + Category: cLinear, + Symbol: currency.NewBTCUSDT(), + OrderID: "69420", + OrderFilter: "dodgy", + }, + err: errInvalidCategory, + }, + { + params: CancelOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + OrderID: "69420", + OrderFilter: "dodgy", + }, + err: errInvalidOrderFilter, + }, + { + params: CancelOrderRequest{ + Category: cSpot, + Symbol: currency.NewBTCUSDT(), + OrderID: "69420", + }, + err: nil, + }, + } { + if tc.err != nil { + require.ErrorIs(t, tc.params.Validate(), tc.err) + continue + } + require.NoError(t, tc.params.Validate()) + } +} diff --git a/exchanges/exchange.go b/exchanges/exchange.go index d25b9aab..7e79dd18 100644 --- a/exchanges/exchange.go +++ b/exchanges/exchange.go @@ -1377,6 +1377,8 @@ func (u URL) String() string { return websocketUSDCMarginedURL case WebsocketOptions: return websocketOptionsURL + case WebsocketTrade: + return websocketTradeURL case WebsocketPrivate: return websocketPrivateURL case WebsocketSpotSupplementary: @@ -1425,6 +1427,8 @@ func getURLTypeFromString(ep string) (URL, error) { return WebsocketUSDCMargined, nil case websocketOptionsURL: return WebsocketOptions, nil + case websocketTradeURL: + return WebsocketTrade, nil case websocketPrivateURL: return WebsocketPrivate, nil case websocketSpotSupplementaryURL: @@ -1968,6 +1972,16 @@ func (*Base) WebsocketSubmitOrders(context.Context, []*order.Submit) (responses return nil, common.ErrFunctionNotSupported } +// WebsocketModifyOrder modifies an order via the websocket connection +func (*Base) WebsocketModifyOrder(context.Context, *order.Modify) (*order.ModifyResponse, error) { + return nil, common.ErrFunctionNotSupported +} + +// WebsocketCancelOrder cancels an order via the websocket connection +func (*Base) WebsocketCancelOrder(context.Context, *order.Cancel) error { + return common.ErrFunctionNotSupported +} + // MessageID returns a universally unique id using UUID V7 // In the future additional params may be added to method signature to provide context for the message id for overriding exchange implementations func (b *Base) MessageID() string { diff --git a/exchanges/exchange_test.go b/exchanges/exchange_test.go index ab0a0804..f57c97bd 100644 --- a/exchanges/exchange_test.go +++ b/exchanges/exchange_test.go @@ -1665,6 +1665,7 @@ func TestString(t *testing.T) { {WebsocketUSDTMargined, websocketUSDTMarginedURL}, {WebsocketUSDCMargined, websocketUSDCMarginedURL}, {WebsocketOptions, websocketOptionsURL}, + {WebsocketTrade, websocketTradeURL}, {WebsocketPrivate, websocketPrivateURL}, {WebsocketSpotSupplementary, websocketSpotSupplementaryURL}, {ChainAnalysis, chainAnalysisURL}, @@ -1828,6 +1829,7 @@ func TestGetGetURLTypeFromString(t *testing.T) { {Endpoint: websocketUSDTMarginedURL, Expected: WebsocketUSDTMargined}, {Endpoint: websocketUSDCMarginedURL, Expected: WebsocketUSDCMargined}, {Endpoint: websocketOptionsURL, Expected: WebsocketOptions}, + {Endpoint: websocketTradeURL, Expected: WebsocketTrade}, {Endpoint: websocketPrivateURL, Expected: WebsocketPrivate}, {Endpoint: websocketSpotSupplementaryURL, Expected: WebsocketSpotSupplementary}, {Endpoint: chainAnalysisURL, Expected: ChainAnalysis}, @@ -2877,6 +2879,18 @@ func TestWebsocketSubmitOrders(t *testing.T) { require.ErrorIs(t, err, common.ErrFunctionNotSupported) } +func TestWebsocketModifyOrder(t *testing.T) { + t.Parallel() + _, err := (&Base{}).WebsocketModifyOrder(t.Context(), nil) + require.ErrorIs(t, err, common.ErrFunctionNotSupported) +} + +func TestWebsocketCancelOrder(t *testing.T) { + t.Parallel() + err := (&Base{}).WebsocketCancelOrder(t.Context(), nil) + require.ErrorIs(t, err, common.ErrFunctionNotSupported) +} + func TestMessageID(t *testing.T) { t.Parallel() id := (new(Base)).MessageID() diff --git a/exchanges/exchange_types.go b/exchanges/exchange_types.go index fec84050..b8ed92b7 100644 --- a/exchanges/exchange_types.go +++ b/exchanges/exchange_types.go @@ -276,6 +276,7 @@ const ( WebsocketUSDTMargined WebsocketUSDCMargined WebsocketOptions + WebsocketTrade WebsocketPrivate WebsocketSpotSupplementary ChainAnalysis @@ -297,6 +298,7 @@ const ( websocketUSDTMarginedURL = "WebsocketUSDTMarginedURL" websocketUSDCMarginedURL = "WebsocketUSDCMarginedURL" websocketOptionsURL = "WebsocketOptionsURL" + websocketTradeURL = "WebsocketTradeURL" websocketPrivateURL = "WebsocketPrivateURL" websocketSpotSupplementaryURL = "WebsocketSpotSupplementaryURL" chainAnalysisURL = "ChainAnalysisURL" @@ -320,6 +322,7 @@ var keyURLs = []URL{ WebsocketUSDTMargined, WebsocketUSDCMargined, WebsocketOptions, + WebsocketTrade, WebsocketPrivate, WebsocketSpotSupplementary, ChainAnalysis, diff --git a/exchanges/gateio/gateio_wrapper.go b/exchanges/gateio/gateio_wrapper.go index f6a1a323..e6cc4b72 100644 --- a/exchanges/gateio/gateio_wrapper.go +++ b/exchanges/gateio/gateio_wrapper.go @@ -57,8 +57,8 @@ func (e *Exchange) SetDefaults() { currency.NewCode("MBABYDOGE"): currency.BABYDOGE, }), TradingRequirements: protocol.TradingRequirements{ - SpotMarketOrderAmountPurchaseQuotationOnly: true, - SpotMarketOrderAmountSellBaseOnly: true, + SpotMarketBuyQuotation: true, + SpotMarketSellBase: true, }, Supports: exchange.FeaturesSupported{ REST: true, diff --git a/exchanges/interfaces.go b/exchanges/interfaces.go index 399e64a9..ad9ccfec 100644 --- a/exchanges/interfaces.go +++ b/exchanges/interfaces.go @@ -135,6 +135,8 @@ type OrderManagement interface { GetOrderHistory(ctx context.Context, getOrdersRequest *order.MultiOrderRequest) (order.FilteredOrders, error) WebsocketSubmitOrder(ctx context.Context, s *order.Submit) (*order.SubmitResponse, error) WebsocketSubmitOrders(ctx context.Context, orders []*order.Submit) (responses []*order.SubmitResponse, err error) + WebsocketModifyOrder(ctx context.Context, action *order.Modify) (*order.ModifyResponse, error) + WebsocketCancelOrder(ctx context.Context, ord *order.Cancel) error } // CurrencyStateManagement defines functionality for currency state management diff --git a/exchanges/order/order_test.go b/exchanges/order/order_test.go index 8abd5940..8d4319bb 100644 --- a/exchanges/order/order_test.go +++ b/exchanges/order/order_test.go @@ -240,9 +240,9 @@ func TestSubmitValidate(t *testing.T) { t.Run(strconv.Itoa(x), func(t *testing.T) { t.Parallel() requirements := protocol.TradingRequirements{ - SpotMarketOrderAmountPurchaseQuotationOnly: tc.HasToPurchaseWithQuoteAmountSet, - SpotMarketOrderAmountSellBaseOnly: tc.HasToSellWithBaseAmountSet, - ClientOrderID: tc.RequiresID, + SpotMarketBuyQuotation: tc.HasToPurchaseWithQuoteAmountSet, + SpotMarketSellBase: tc.HasToSellWithBaseAmountSet, + ClientOrderID: tc.RequiresID, } err := tc.Submit.Validate(requirements, tc.ValidOpts) assert.ErrorIs(t, err, tc.ExpectedErr) @@ -1693,14 +1693,14 @@ func TestGetTradeAmount(t *testing.T) { s = &Submit{Amount: baseAmount, QuoteAmount: quoteAmount} // below will default to base amount with nothing set require.Equal(t, baseAmount, s.GetTradeAmount(protocol.TradingRequirements{})) - require.Equal(t, baseAmount, s.GetTradeAmount(protocol.TradingRequirements{SpotMarketOrderAmountPurchaseQuotationOnly: true})) + require.Equal(t, baseAmount, s.GetTradeAmount(protocol.TradingRequirements{SpotMarketBuyQuotation: true})) s.AssetType = asset.Spot s.Type = Market s.Side = Buy - require.Equal(t, quoteAmount, s.GetTradeAmount(protocol.TradingRequirements{SpotMarketOrderAmountPurchaseQuotationOnly: true})) - require.Equal(t, baseAmount, s.GetTradeAmount(protocol.TradingRequirements{SpotMarketOrderAmountSellBaseOnly: true})) + require.Equal(t, quoteAmount, s.GetTradeAmount(protocol.TradingRequirements{SpotMarketBuyQuotation: true})) + require.Equal(t, baseAmount, s.GetTradeAmount(protocol.TradingRequirements{SpotMarketSellBase: true})) s.Side = Sell - require.Equal(t, baseAmount, s.GetTradeAmount(protocol.TradingRequirements{SpotMarketOrderAmountSellBaseOnly: true})) + require.Equal(t, baseAmount, s.GetTradeAmount(protocol.TradingRequirements{SpotMarketSellBase: true})) } func TestStringToTrackingMode(t *testing.T) { diff --git a/exchanges/order/orders.go b/exchanges/order/orders.go index 6382dacf..7d63b9b8 100644 --- a/exchanges/order/orders.go +++ b/exchanges/order/orders.go @@ -110,11 +110,11 @@ func (s *Submit) Validate(requirements protocol.TradingRequirements, opt ...vali return fmt.Errorf("submit validation error %w, client order ID must be set to satisfy submission requirements", ErrClientOrderIDMustBeSet) } - if requirements.SpotMarketOrderAmountPurchaseQuotationOnly && s.QuoteAmount == 0 && s.Type == Market && s.AssetType == asset.Spot && s.Side.IsLong() { + if requirements.SpotMarketBuyQuotation && s.QuoteAmount == 0 && s.Type == Market && s.AssetType == asset.Spot && s.Side.IsLong() { return fmt.Errorf("submit validation error %w, quote amount to be sold must be set to 'QuoteAmount' field to satisfy trading requirements", ErrAmountMustBeSet) } - if requirements.SpotMarketOrderAmountSellBaseOnly && s.Amount == 0 && s.Type == Market && s.AssetType == asset.Spot && s.Side.IsShort() { + if requirements.SpotMarketSellBase && s.Amount == 0 && s.Type == Market && s.AssetType == asset.Spot && s.Side.IsShort() { return fmt.Errorf("submit validation error %w, base amount being sold must be set to 'Amount' field to satisfy trading requirements", ErrAmountMustBeSet) } @@ -137,9 +137,9 @@ func (s *Submit) GetTradeAmount(tr protocol.TradingRequirements) float64 { return 0 } switch { - case tr.SpotMarketOrderAmountPurchaseQuotationOnly && s.AssetType == asset.Spot && s.Type == Market && s.Side.IsLong(): + case tr.SpotMarketBuyQuotation && s.AssetType == asset.Spot && s.Type == Market && s.Side.IsLong(): return s.QuoteAmount - case tr.SpotMarketOrderAmountSellBaseOnly && s.AssetType == asset.Spot && s.Type == Market && s.Side.IsShort(): + case tr.SpotMarketSellBase && s.AssetType == asset.Spot && s.Type == Market && s.Side.IsShort(): return s.Amount } return s.Amount diff --git a/exchanges/protocol/features.go b/exchanges/protocol/features.go index d7a0a43d..a9bc62be 100644 --- a/exchanges/protocol/features.go +++ b/exchanges/protocol/features.go @@ -50,18 +50,10 @@ type Features struct { // TradingRequirements defines the requirements for trading on the exchange. type TradingRequirements struct { - // SpotMarketOrderAmountPurchaseQuotationOnly requires the amount to be in - // quote currency or what is to be sold for when you purchase base currency. - // For example, long BTC-USD, the quotation amount is USD. - // NOTE: Due to an exchange's matching engine process, the base amount - // acquired may vary from what is intended due to price fluctuations and - // liquidity on the books. Care must be taken when implementing a market - // neutral strategy. - SpotMarketOrderAmountPurchaseQuotationOnly bool - // SpotMarketOrderAmountSellBaseOnly requires the amount to be in the - // base currency or what is intended to be purchased. For example, short - // BTC-USD, the base amount is BTC. - SpotMarketOrderAmountSellBaseOnly bool + // SpotMarketBuyQuotation requires the amount to be in quote currency + SpotMarketBuyQuotation bool + // SpotMarketSellBase requires the amount to be in the base currency + SpotMarketSellBase bool // ClientOrderID is a unique identifier for the order that is generated by // the client and is required for order submission. ClientOrderID bool diff --git a/exchanges/request/limit.go b/exchanges/request/limit.go index 963cbea6..539e31a8 100644 --- a/exchanges/request/limit.go +++ b/exchanges/request/limit.go @@ -20,6 +20,9 @@ var ( errSpecificRateLimiterIsNil = errors.New("specific rate limiter is nil") ) +// RateLimitNotRequired is a no-op rate limiter +var RateLimitNotRequired *RateLimiterWithWeight + // Const here define individual functionality sub types for rate limiting const ( Unset EndpointLimit = iota diff --git a/testdata/configtest.json b/testdata/configtest.json index fa140786..07feebdd 100644 --- a/testdata/configtest.json +++ b/testdata/configtest.json @@ -1074,7 +1074,7 @@ "pairs": { "spot": { "assetEnabled": true, - "enabled": "BTC_USDT,ETH_USDT,XRP_USDT,EOS_USDT,ETH_BTC,XRP_BTC,DOT_USDT,XLM_USDT,LTC_USDT", + "enabled": "BTC_USDT", "available": "BTC_USDT,ETH_USDT,XRP_USDT,EOS_USDT,ETH_BTC,XRP_BTC,DOT_USDT,XLM_USDT,LTC_USDT,DOGE_USDT,CHZ_USDT,AXS_USDT,MANA_USDT,DYDX_USDT,MKR_USDT,COMP_USDT,AAVE_USDT,YFI_USDT,LINK_USDT,SUSHI_USDT,UNI_USDT,KSM_USDT,ICP_USDT,ADA_USDT,ETC_USDT,KLAY_USDT,XTZ_USDT,BCH_USDT,SRM_USDT,QNT_USDT,USDC_USDT,GRT_USDT,SOL_USDT,FIL_USDT,OMG_USDT,TRIBE_USDT,BAT_USDT,ZRX_USDT,CRV_USDT,AGLD_USDT,ANKR_USDT,PERP_USDT,MATIC_USDT,WAVES_USDT,LUNC_USDT,SPELL_USDT,SHIB_USDT,FTM_USDT,ATOM_USDT,ALGO_USDT,ENJ_USDT,CBX_USDT,SAND_USDT,AVAX_USDT,WOO_USDT,FTT_USDT,GODS_USDT,IMX_USDT,ENS_USDT,GM_USDT,CWAR_USDT,CAKE_USDT,STETH_USDT,GALFT_USDT,LFW_USDT,SLP_USDT,C98_USDT,PSP_USDT,GENE_USDT,AVA_USDT,ONE_USDT,PTU_USDT,SHILL_USDT,XYM_USDT,BOBA_USDT,JASMY_USDT,GALA_USDT,RNDR_USDT,TRVL_USDT,WEMIX_USDT,XEM_USDT,BICO_USDT,CEL_USDT,UMA_USDT,HOT_USDT,NEXO_USDT,BNT_USDT,SNX_USDT,REN_USDT,1INCH_USDT,TEL_USDT,SIS_USDT,LRC_USDT,LDO_USDT,REAL_USDT,KRL_USDT,DEVT_USDT,ETH_USDC,BTC_USDC,1SOL_USDT,PLT_USDT,IZI_USDT,QTUM_USDT,DCR_USDT,ZEN_USDT,THETA_USDT,MX_USDT,DGB_USDT,RVN_USDT,EGLD_USDT,RUNE_USDT,XLM_BTC,XLM_USDC,SOL_USDC,XRP_USDC,ALGO_BTC,SOL_BTC,RAIN_USDT,XEC_USDT,ICX_USDT,XDC_USDT,HNT_USDT,BTG_USDT,ZIL_USDT,HBAR_USDT,FLOW_USDT,SOS_USDT,KASTA_USDT,STX_USDT,SIDUS_USDT,VPAD_USDT,GGM_USDT,LOOKS_USDT,MBS_USDT,DAI_USDT,BUSD_USDT,ACA_USDT,MV_USDT,MIX_USDT,LTC_USDC,MANA_BTC,MATIC_BTC,LTC_BTC,DOT_BTC,SAND_BTC,MANA_USDC,MATIC_USDC,SAND_USDC,DOT_USDC,LUNC_USDC,RSS3_USDT,SYNR_USDT,TAP_USDT,ERTHA_USDT,GMX_USDT,T_USDT,ACH_USDT,JST_USDT,SUN_USDT,BTT_USDT,TRX_USDT,NFT_USDT,POKT_USDT,SCRT_USDT,PSTAKE_USDT,SON_USDT,HERO_USDT,DOME_USDT,USTC_USDT,BNB_USDT,NEAR_USDT,PAXG_USDT,SD_USDT,APE_USDT,BTC3S_USDT,BTC3L_USDT,FIDA_USDT,MINA_USDT,SC_USDT,RACA_USDT,CAPS_USDT,STG_USDT,GLMR_USDT,MOVR_USDT,ZAM_USDT,ETH_DAI,BTC_DAI,WBTC_USDT,XAVA_USDT,MELOS_USDT,GMT_USDT,GST_USDT,CELO_USDT,SFUND_USDT,ELT_USDT,LGX_USDT,APEX_USDT,CTC_USDT,COT_USDT,KMON_USDT,PLY_USDT,XWG_USDT,FITFI_USDT,STRM_USDT,GAL_USDT,ETH3S_USDT,ETH3L_USDT,KOK_USDT,FAME_USDT,XRP3S_USDT,XRP3L_USDT,USDD_USDT,OP_USDT,LUNA_USDT,DFI_USDT,MOVEZ_USDT,THN_USDT,DOT3S_USDT,DOT3L_USDT,VINU_USDT,BEL_USDT,FORT_USDT,AVAX2S_USDT,AVAX2L_USDT,ADA2S_USDT,ADA2L_USDT,WLKN_USDT,KON_USDT,LTC2S_USDT,LTC2L_USDT,SAND2S_USDT,SAND2L_USDT,OBX_USDT,SEOR_USDT,MNZ_USDT,CULT_USDT,DOGE_USDC,EOS_USDC,CUSD_USDT,SLG_USDT,CMP_USDT,KUNCI_USDT,GSTS_USDT,XETA_USDT,AZY_USDT,MMC_USDT,FLOKI_USDT,BABYDOGE_USDT,STAT_USDT,SAITAMA_USDT,MATIC2S_USDT,MATIC2L_USDT,ETC2S_USDT,ETC2L_USDT,DICE_USDT,WAXP_USDT,AR_USDT,KDA_USDT,ROSE_USDT,SLG_USDC,APE2S_USDT,APE2L_USDT,GMT2S_USDT,GMT2L_USDT,DEFY_USDT,PSG_USDT,BAR_USDT,JUV_USDT,ACM_USDT,INTER_USDT,AFC_USDT,CITY_USDT,LINK2L_USDT,LINK2S_USDT,FTM2L_USDT,FTM2S_USDT,SOLO_USDT,W_BTC,AVAX_USDC,ADA_USDC,OP_USDC,DOGE2S_USDT,DOGE2L_USDT,ATOM2S_USDT,ATOM2L_USDT,APEX_USDC,TRX_USDC,ICP_USDC,LINK_USDC,GMT_USDC,CHZ_USDC,SHIB_USDC,LDO_USDC,APE_USDC,FIL_USDC,CHRP_USDT,EOS2S_USDT,EOS2L_USDT,WWY_USDT,LING_USDT,SWEAT_USDT,DLC_USDT,OKG_USDT,ETHW_USDT,INJ_USDT,MPLX_USDT,MIBR_USDT,CO_USDT,AGLA_USDT,ROND_USDT,QMALL_USDT,PUMLX_USDT,GCAKE_USDT,APT_USDT,APT_USDC,USDT_EUR,MTK_USDT,MCRT_USDT,MASK_USDT,ECOX_USDT,HFT_USDC,HFT_USDT,KCAL_USDT,PEOPLE_USDT,TWT_USDT,ORT_USDT,HOOK_USDT,PRIMAL_USDT,MCT_USDT,OAS_USDT,MAGIC_USDT,MEE_USDT,TON_USDT,BONK_USDT,FLR_USDT,TIME_USDT,3P_USDT,RPL_USDT,SSV_USDT,FXS_USDT,CORE_USDT,RDNT_USDT,BLUR_USDT,LIS_USDT,AGIX_USDT,MDAO_USDT,ACS_USDT,HVH_USDT,GNS_USDT,DPX_USDT,PIP_USDT,PRIME_USDT,EVER_USDT,VRA_USDT,GPT_USDT,FB_USDT,DZOO_USDT,ID_USDT,ARB_USDC,ARB_USDT,XCAD_USDT,MBX_USDT,AXL_USDT,CGPT_USDT,PLAY_USDT,AGI_USDT,RLTM_USDT,SUI_USDT,SUI_USDC,TAMA_USDT,MVL_USDT,PEPE_USDT,LADYS_USDT,LMWR_USDT,BOB_USDT,TOMI_USDT,KARATE_USDT,SUIA_USDT,TURBOS_USDT,FMB_USDT,CAPO_USDT,TENET_USDT,VELO_USDT,ELDA_USDT,CANDY_USDT,FON_USDT,OMN_USDT,TOMS_USDT,MTC_USDT,VELA_USDT,USDT_BRZ,BTC_BRZ,PENDLE_USDT,EGO_USDT,PEPE2_USDT,NYM_USDT,MNT_USDT,MNT_USDC,MNT_BTC,GSWIFT_USDT,SALD_USDT,ARKM_USDT,NEON_USDT,WLD_USDC,WLD_USDT,PLANET_USDT,DSRUN_USDT,SPARTA_USDT,TAVA_USDT,SEILOR_USDT,SEI_USDT,CYBER_USDT,ORDI_USDT,KAVA_USDT,VV_USDT,SAIL_USDT,PYUSD_USDT,SOL_EUR,USDC_EUR,ADA_EUR,DOGE_EUR,LTC_EUR,XRP_EUR,ETH_EUR,BTC_EUR,VEXT_USDT,CTT_USDT,NEXT_USDT,KAS_USDT,NESS_USDT,CAT_USDT,FET_USDT,LEVER_USDT,VEGA_USDT,ZTX_USDT", "requestFormat": { "uppercase": true @@ -1086,7 +1086,7 @@ }, "coinmarginedfutures": { "assetEnabled": true, - "enabled": "ADA_USD,BTC_USD,DOT_USD", + "enabled": "BTC_USD", "available": "ADA_USD,BTC_USD,DOT_USD,EOS_USD,ETH_USD,ETH_USDH24,LTC_USD,MAN_AUSD,XRP_USD", "requestFormat": { "uppercase": true @@ -1098,7 +1098,7 @@ }, "usdcmarginedfutures": { "assetEnabled": true, - "enabled": "ETH-PERP,BNB-PERP,SOL-PERP,BTC-PERP", + "enabled": "BTC-PERP", "available": "BNB-PERP,BTC-03NOV23,BTC-20OCT23,BTC-24NOV23,BTC-27OCT23,BTC-28JUN24,BTC-29DEC23,BTC-29MAR24,BTC-PERP,ETC-PERP,ETH-03NOV23,ETH-20OCT23,ETH-24NOV23,ETH-27OCT23,ETH-28JUN24,ETH-29DEC23,ETH-29MAR24,ETH-PERP,MAT-ICPERP,OPP-ERP,SOL-PERP,XRP-PERP", "requestFormat": { "uppercase": true @@ -1110,7 +1110,7 @@ }, "usdtmarginedfutures": { "assetEnabled": true, - "enabled": "BTC_USDT,10000LADYS_USDT,IOTA_USDT,AAVE_USDT", + "enabled": "BTC_USDT", "available": "10000LADYS_USDT,10000NFT_USDT,1000BONK_USDT,1000BTT_USDT,1000FLOKI_USDT,1000LUNC_USDT,1000PEPE_USDT,1000XEC_USDT,1INCH_USDT,AAVE_USDT,ACH_USDT,ADA_USDT,AGIX_USDT,AGLD_USDT,AKRO_USDT,ALGO_USDT,ALICE_USDT,ALPACA_USDT,ALPHA_USDT,AMB_USDT,ANKR_USDT,ANT_USDT,APE_USDT,API3_USDT,APT_USDT,ARB_USDT,ARKM_USDT,ARK_USDT,ARPA_USDT,AR_USDT,ASTR_USDT,ATA_USDT,ATOM_USDT,AUCTION_USDT,AUDIO_USDT,AVAX_USDT,AXS_USDT,BADGER_USDT,BAKE_USDT,BAL_USDT,BAND_USDT,BAT_USDT,BCH_USDT,BEL_USDT,BICO_USDT,BIGTIME_USDT,BLUR_USDT,BLZ_USDT,BNB_USDT,BNT_USDT,BNX_USDT,BOBA_USDT,BOND_USDT,BSV_USDT,BSW_USDT,BTC_USDT,BUSD_USDT,C98_USDT,CEEK_USDT,CELO_USDT,CELR_USDT,CFX_USDT,CHR_USDT,CHZ_USDT,CKB_USDT,COMBO_USDT,COMP_USDT,CORE_USDT,COTI_USDT,CRO_USDT,CRV_USDT,CTC_USDT,CTK_USDT,CTSI_USDT,CVC_USDT,CVX_USDT,CYBER_USDT,DAR_USDT,DASH_USDT,DENT_USDT,DGB_USDT,DODO_USDT,DOGE_USDT,DOT_USDT,DUSK_USDT,DYDX_USDT,EDU_USDT,EGLD_USDT,ENJ_USDT,ENS_USDT,EOS_USDT,ETC_USDT,ETH_USDT,ETHW_USDT,FET_USDT,FIL_USDT,FITFI_USDT,FLM_USDT,FLOW_USDT,FLR_USDT,FORTH_USDT,FRONT_USDT,FTM_USDT,FXS_USDT,GALA_USDT,GAL_USDT,GFT_USDT,GLMR_USDT,GLM_USDT,GMT_USDT,GMX_USDT,GPT_USDT,GRT_USDT,GTC_USDT,HBAR_USDT,HFT_USDT,HIFI_USDT,HIGH_USDT,HNT_USDT,HOOK_USDT,HOT_USDT,ICP_USDT,ICX_USDT,IDEX_USDT,ID_USDT,ILV_USDT,IMX_USDT,INJ_USDT,IOST_USDT,IOTA_USDT,IOTX_USDT,JASMY_USDT,JOE_USDT,JST_USDT,KAS_USDT,KAVA_USDT,KDA_USDT,KEY_USDT,KLAY_USDT,KNC_USDT,KSM_USDT,LDO_USDT,LEVER_USDT,LINA_USDT,LINK_USDT,LIT_USDT,LOOKS_USDT,LOOM_USDT,LPT_USDT,LQTY_USDT,LRC_USDT,LTC_USDT,LUNA2_USDT,MAGIC_USDT,MANA_USDT,MASK_USDT,MATIC_USDT,MAV_USDT,MC_USDT,MDT_USDT,MINA_USDT,MKR_USDT,MNT_USDT,MTL_USDT,MULTI_USDT,NEAR_USDT,NEO_USDT,NKN_USDT,NMR_USDT,NTRN_USDT,OCEAN_USDT,OGN_USDT,OG_USDT,OMG_USDT,ONE_USDT,ONT_USDT,OP_USDT,ORBS_USDT,ORDI_USDT,OXT_USDT,PAXG_USDT,PENDLE_USDT,PEOPLE_USDT,PERP_USDT,PHB_USDT,PROM_USDT,QNT_USDT,QTUM_USDT,RAD_USDT,RDNT_USDT,REEF_USDT,REN_USDT,REQ_USDT,RLC_USDT,RNDR_USDT,ROSE_USDT,RPL_USDT,RSR_USDT,RSS3_USDT,RUNE_USDT,RVN_USDT,SAND_USDT,SCRT_USDT,SC_USDT,SEI_USDT,SFP_USDT,SHIB1000_USDT,SKL_USDT,SLP_USDT,SNX_USDT,SOL_USDT,SPELL_USDT,SSV_USDT,STG_USDT,STMX_USDT,STORJ_USDT,STPT_USDT,STRAX_USDT,STX_USDT,SUI_USDT,SUN_USDT,SUSHI_USDT,SWEAT_USDT,SXP_USDT,THETA_USDT,TLM_USDT,TOMI_USDT,TOMO_USDT,TON_USDT,TRB_USDT,TRU_USDT,TRX_USDT,T_USDT,TWT_USDT,UMA_USDT,UNFI_USDT,UNI_USDT,USDC_USDT,VET_USDT,VGX_USDT,VRA_USDT,WAVES_USDT,WAXP_USDT,WLD_USDT,WOO_USDT,WSM_USDT,XCN_USDT,XEM_USDT,XLM_USDT,XMR_USDT,XNO_USDT,XRP_USDT,XTZ_USDT,XVG_USDT,XVS_USDT,YFII_USDT,YFI_USDT,YGG_USDT,ZEC_USDT,ZEN_USDT,ZIL_USDT,ZRX_USDT", "requestFormat": { "uppercase": true @@ -1122,7 +1122,7 @@ }, "options": { "assetEnabled": true, - "enabled": "BTC-26NOV24-92000-C,BTC-28JUN24-60000-C,BTC-28JUN24-60000-P", + "enabled": "BTC-26NOV24-92000-C", "available": "BTC-26NOV24-92000-C,BTC-28JUN24-60000-C,BTC-28JUN24-60000-P,BTC-28JUN24-50000-C,BTC-28JUN24-50000-P,BTC-28JUN24-40000-C,BTC-28JUN24-40000-P,BTC-28JUN24-32000-C,BTC-28JUN24-32000-P,BTC-28JUN24-30000-C,BTC-28JUN24-30000-P,BTC-28JUN24-28000-C,BTC-28JUN24-28000-P,BTC-28JUN24-25000-C,BTC-28JUN24-25000-P,BTC-28JUN24-20000-C,BTC-28JUN24-20000-P,BTC-28JUN24-10000-C,BTC-28JUN24-10000-P,BTC-29MAR24-70000-C,BTC-29MAR24-70000-P,BTC-29MAR24-60000-C,BTC-29MAR24-60000-P,BTC-29MAR24-50000-C,BTC-29MAR24-50000-P,BTC-29MAR24-45000-C,BTC-29MAR24-45000-P,BTC-29MAR24-40000-C,BTC-29MAR24-40000-P,BTC-29MAR24-36000-C,BTC-29MAR24-36000-P,BTC-29MAR24-35000-C,BTC-29MAR24-35000-P,BTC-29MAR24-33000-C,BTC-29MAR24-33000-P,BTC-29MAR24-31000-C,BTC-29MAR24-31000-P,BTC-29MAR24-30000-C,BTC-29MAR24-30000-P,BTC-29MAR24-28000-C,BTC-29MAR24-28000-P,BTC-29MAR24-27000-C,BTC-29MAR24-27000-P,BTC-29MAR24-26000-C,BTC-29MAR24-26000-P,BTC-29MAR24-24000-C,BTC-29MAR24-24000-P,BTC-29MAR24-20000-C,BTC-29MAR24-20000-P,BTC-29MAR24-10000-C,BTC-29MAR24-10000-P,BTC-26NOV24-92000-C,BTC-29DEC23-80000-P,BTC-29DEC23-70000-C,BTC-29DEC23-70000-P,BTC-29DEC23-60000-C,BTC-29DEC23-60000-P,BTC-29DEC23-50000-C,BTC-29DEC23-50000-P,BTC-29DEC23-40000-C,BTC-29DEC23-40000-P,BTC-29DEC23-36000-C,BTC-29DEC23-36000-P,BTC-29DEC23-35000-C,BTC-29DEC23-35000-P,BTC-29DEC23-34000-C,BTC-29DEC23-34000-P,BTC-29DEC23-32000-C,BTC-29DEC23-32000-P,BTC-29DEC23-31500-C,BTC-29DEC23-31500-P,BTC-29DEC23-30500-C,BTC-29DEC23-30500-P,BTC-29DEC23-30000-C,BTC-29DEC23-30000-P,BTC-29DEC23-29500-C,BTC-29DEC23-29500-P,BTC-29DEC23-29000-C,BTC-29DEC23-29000-P,BTC-29DEC23-28000-C,BTC-29DEC23-28000-P,BTC-29DEC23-27500-C,BTC-29DEC23-27500-P,BTC-29DEC23-27000-C,BTC-29DEC23-27000-P,BTC-29DEC23-26000-C,BTC-29DEC23-26000-P,BTC-29DEC23-25000-C,BTC-29DEC23-25000-P,BTC-29DEC23-24000-C,BTC-29DEC23-24000-P,BTC-29DEC23-22000-C,BTC-29DEC23-22000-P,BTC-29DEC23-20000-C,BTC-29DEC23-20000-P,BTC-29DEC23-15000-C,BTC-29DEC23-15000-P,BTC-29DEC23-10000-C,BTC-29DEC23-10000-P,BTC-24NOV23-40000-C,BTC-24NOV23-40000-P,BTC-24NOV23-38000-C,BTC-24NOV23-38000-P,BTC-24NOV23-36000-C,BTC-24NOV23-36000-P,BTC-24NOV23-34000-C,BTC-24NOV23-34000-P,BTC-24NOV23-32000-C,BTC-24NOV23-32000-P,BTC-24NOV23-31500-C,BTC-24NOV23-31500-P,BTC-24NOV23-30500-C,BTC-24NOV23-30500-P,BTC-24NOV23-30000-C,BTC-24NOV23-30000-P,BTC-24NOV23-29500-C,BTC-24NOV23-29500-P,BTC-24NOV23-29000-C,BTC-24NOV23-29000-P,BTC-24NOV23-28500-C,BTC-24NOV23-28500-P,BTC-24NOV23-28000-C,BTC-24NOV23-28000-P,BTC-24NOV23-27500-C,BTC-24NOV23-27500-P,BTC-24NOV23-27000-C,BTC-24NOV23-27000-P,BTC-24NOV23-26500-C,BTC-24NOV23-26500-P,BTC-24NOV23-26000-C,BTC-24NOV23-26000-P,BTC-24NOV23-25500-C,BTC-24NOV23-25500-P,BTC-24NOV23-25000-C,BTC-24NOV23-25000-P,BTC-24NOV23-24000-C,BTC-24NOV23-24000-P,BTC-24NOV23-23000-C,BTC-24NOV23-23000-P,BTC-24NOV23-22000-C,BTC-24NOV23-22000-P,BTC-24NOV23-20000-C,BTC-24NOV23-20000-P,BTC-24NOV23-18000-C,BTC-24NOV23-18000-P,BTC-24NOV23-16000-C,BTC-24NOV23-16000-P,BTC-3NOV23-36000-C,BTC-3NOV23-36000-P,BTC-3NOV23-34000-C,BTC-3NOV23-34000-P,BTC-3NOV23-32000-C,BTC-3NOV23-32000-P,BTC-3NOV23-30000-C,BTC-3NOV23-30000-P,BTC-3NOV23-29000-C,BTC-3NOV23-29000-P,BTC-3NOV23-28500-C,BTC-3NOV23-28500-P,BTC-3NOV23-27500-C,BTC-3NOV23-27500-P,BTC-3NOV23-27000-C,BTC-3NOV23-27000-P,BTC-3NOV23-26500-C,BTC-3NOV23-26500-P,BTC-3NOV23-26000-C,BTC-3NOV23-26000-P,BTC-3NOV23-25000-C,BTC-3NOV23-25000-P,BTC-3NOV23-24000-C,BTC-3NOV23-24000-P,BTC-3NOV23-22000-C,BTC-3NOV23-22000-P,BTC-3NOV23-20000-C,BTC-3NOV23-20000-P,BTC-3NOV23-18000-C,BTC-3NOV23-18000-P,BTC-27OCT23-44000-C,BTC-27OCT23-44000-P,BTC-27OCT23-42000-C,BTC-27OCT23-42000-P,BTC-27OCT23-40000-C,BTC-27OCT23-40000-P,BTC-27OCT23-38000-C,BTC-27OCT23-38000-P,BTC-27OCT23-37000-C,BTC-27OCT23-37000-P,BTC-27OCT23-35000-C,BTC-27OCT23-35000-P,BTC-27OCT23-34500-C,BTC-27OCT23-34500-P,BTC-27OCT23-33500-C,BTC-27OCT23-33500-P,BTC-27OCT23-32500-C,BTC-27OCT23-32500-P,BTC-27OCT23-31500-C,BTC-27OCT23-31500-P,BTC-27OCT23-31000-C,BTC-27OCT23-31000-P,BTC-27OCT23-30500-C,BTC-27OCT23-30500-P,BTC-27OCT23-30000-C,BTC-27OCT23-30000-P,BTC-27OCT23-29500-C,BTC-27OCT23-29500-P,BTC-27OCT23-29000-C,BTC-27OCT23-29000-P,BTC-27OCT23-28750-C,BTC-27OCT23-28750-P,BTC-27OCT23-28500-C,BTC-27OCT23-28500-P,BTC-27OCT23-28250-C,BTC-27OCT23-28250-P,BTC-27OCT23-28000-C,BTC-27OCT23-28000-P,BTC-27OCT23-27750-C,BTC-27OCT23-27750-P,BTC-27OCT23-27500-C,BTC-27OCT23-27500-P,BTC-27OCT23-27250-C,BTC-27OCT23-27250-P,BTC-27OCT23-27000-C,BTC-27OCT23-27000-P,BTC-27OCT23-26500-C,BTC-27OCT23-26500-P,BTC-27OCT23-26000-C,BTC-27OCT23-26000-P,BTC-27OCT23-25500-C,BTC-27OCT23-25500-P,BTC-27OCT23-25000-C,BTC-27OCT23-25000-P,BTC-27OCT23-24000-C,BTC-27OCT23-24000-P,BTC-27OCT23-23000-C,BTC-27OCT23-23000-P,BTC-27OCT23-22000-C,BTC-27OCT23-22000-P,BTC-27OCT23-20000-C,BTC-27OCT23-20000-P,BTC-27OCT23-18000-C,BTC-27OCT23-18000-P,BTC-27OCT23-16000-C,BTC-27OCT23-16000-P,BTC-20OCT23-36000-C,BTC-20OCT23-36000-P,BTC-20OCT23-34000-C,BTC-20OCT23-34000-P,BTC-20OCT23-32000-C,BTC-20OCT23-32000-P,BTC-20OCT23-31000-C,BTC-20OCT23-31000-P,BTC-20OCT23-30500-C,BTC-20OCT23-30500-P,BTC-20OCT23-30000-C,BTC-20OCT23-30000-P,BTC-20OCT23-29500-C,BTC-20OCT23-29500-P,BTC-20OCT23-29000-C,BTC-20OCT23-29000-P,BTC-20OCT23-28750-C,BTC-20OCT23-28750-P,BTC-20OCT23-28500-C,BTC-20OCT23-28500-P,BTC-20OCT23-28250-C,BTC-20OCT23-28250-P,BTC-20OCT23-28000-C,BTC-20OCT23-28000-P,BTC-20OCT23-27750-C,BTC-20OCT23-27750-P,BTC-20OCT23-27500-C,BTC-20OCT23-27500-P,BTC-20OCT23-27250-C,BTC-20OCT23-27250-P,BTC-20OCT23-27000-C,BTC-20OCT23-27000-P,BTC-20OCT23-26750-C,BTC-20OCT23-26750-P,BTC-20OCT23-26500-C,BTC-20OCT23-26500-P,BTC-20OCT23-26250-C,BTC-20OCT23-26250-P,BTC-20OCT23-26000-C,BTC-20OCT23-26000-P,BTC-20OCT23-25750-C,BTC-20OCT23-25750-P,BTC-20OCT23-25500-C,BTC-20OCT23-25500-P,BTC-20OCT23-25000-C,BTC-20OCT23-25000-P,BTC-20OCT23-24000-C,BTC-20OCT23-24000-P,BTC-20OCT23-22000-C,BTC-20OCT23-22000-P,BTC-20OCT23-20000-C,BTC-20OCT23-20000-P,BTC-20OCT23-18000-C,BTC-20OCT23-18000-P,BTC-19OCT23-29750-C,BTC-19OCT23-29750-P,BTC-19OCT23-29500-C,BTC-19OCT23-29500-P,BTC-19OCT23-29250-C,BTC-19OCT23-29250-P,BTC-19OCT23-29000-C,BTC-19OCT23-29000-P,BTC-19OCT23-28750-C,BTC-19OCT23-28750-P,BTC-19OCT23-28500-C,BTC-19OCT23-28500-P,BTC-19OCT23-28250-C,BTC-19OCT23-28250-P,BTC-19OCT23-28000-C,BTC-19OCT23-28000-P,BTC-19OCT23-27750-C,BTC-19OCT23-27750-P,BTC-19OCT23-27500-C,BTC-19OCT23-27500-P,BTC-19OCT23-27250-C,BTC-19OCT23-27250-P,BTC-19OCT23-27000-C,BTC-19OCT23-27000-P,BTC-19OCT23-26750-C,BTC-19OCT23-26750-P,BTC-19OCT23-26500-C,BTC-19OCT23-26500-P,BTC-19OCT23-26250-C,BTC-19OCT23-26250-P,BTC-19OCT23-26000-C,BTC-19OCT23-26000-P,BTC-19OCT23-25750-C,BTC-19OCT23-25750-P,BTC-18OCT23-29500-C,BTC-18OCT23-29500-P,BTC-18OCT23-29250-C,BTC-18OCT23-29250-P,BTC-18OCT23-29000-C,BTC-18OCT23-29000-P,BTC-18OCT23-28750-C,BTC-18OCT23-28750-P,BTC-18OCT23-28500-C,BTC-18OCT23-28500-P,BTC-18OCT23-28250-C,BTC-18OCT23-28250-P,BTC-18OCT23-28000-C,BTC-18OCT23-28000-P,BTC-18OCT23-27750-C,BTC-18OCT23-27750-P,BTC-18OCT23-27500-C,BTC-18OCT23-27500-P,BTC-18OCT23-27250-C,BTC-18OCT23-27250-P,BTC-18OCT23-27000-C,BTC-18OCT23-27000-P,BTC-18OCT23-26750-C,BTC-18OCT23-26750-P,BTC-18OCT23-26500-C,BTC-18OCT23-26500-P,BTC-18OCT23-26250-C,BTC-18OCT23-26250-P,BTC-18OCT23-26000-C,BTC-18OCT23-26000-P,BTC-18OCT23-25750-C,BTC-18OCT23-25750-P,BTC-18OCT23-25500-C,BTC-18OCT23-25500-P,BTC-17OCT23-29250-C,BTC-17OCT23-29250-P,BTC-17OCT23-29000-C,BTC-17OCT23-29000-P,BTC-17OCT23-28750-C,BTC-17OCT23-28750-P,BTC-17OCT23-28500-C,BTC-17OCT23-28500-P,BTC-17OCT23-28250-C,BTC-17OCT23-28250-P,BTC-17OCT23-28000-C,BTC-17OCT23-28000-P,BTC-17OCT23-27750-C,BTC-17OCT23-27750-P,BTC-17OCT23-27500-C,BTC-17OCT23-27500-P,BTC-17OCT23-27250-C,BTC-17OCT23-27250-P,BTC-17OCT23-27000-C,BTC-17OCT23-27000-P,BTC-17OCT23-26750-C,BTC-17OCT23-26750-P,BTC-17OCT23-26500-C,BTC-17OCT23-26500-P,BTC-17OCT23-26250-C,BTC-17OCT23-26250-P,BTC-17OCT23-26000-C,BTC-17OCT23-26000-P,BTC-17OCT23-25750-C,BTC-17OCT23-25750-P,BTC-17OCT23-25500-C,BTC-17OCT23-25500-P", "requestFormat": { "uppercase": true,