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; 🚀

* 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 <gloriousCode@users.noreply.github.com>

* 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 <gbjkirwan@gmail.com>

* gk: nitsssssss

* linter: fix

* Update exchanges/bybit/bybit_test.go

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

* Update exchanges/bybit/bybit_test.go

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

* 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 <gloriousCode@users.noreply.github.com>

* glorious: nits

* fix test

* fix alignment issue and rm println

* Update exchanges/bybit/bybit_websocket.go

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

* Update exchanges/bybit/bybit_websocket.go

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

* glorious: fix

* Update exchanges/bybit/bybit_websocket.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* 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 <gbjkirwan@gmail.com>

* gk: nits

* boss king: nits

* canktakular: nits

* Update exchanges/bybit/bybit_websocket.go

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

* Update exchanges/bybit/bybit_websocket_requests.go

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

* Update exchanges/bybit/bybit_websocket_requests.go

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

* Update exchanges/bybit/bybit_websocket_requests.go

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

* Update exchanges/bybit/bybit_websocket_requests.go

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

* 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 <gloriousCode@users.noreply.github.com>

* Update exchanges/bybit/bybit_websocket_requests.go

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

* Update exchanges/bybit/bybit_wrapper.go

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

* glorious: nits!

* thrasher: nits

---------

Co-authored-by: shazbert <ryan.oharareid@thrasher.io>
Co-authored-by: Scott <gloriousCode@users.noreply.github.com>
Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>
Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>
Co-authored-by: Samuael A. <39623015+samuael@users.noreply.github.com>
This commit is contained in:
Ryan O'Hara-Reid
2025-09-17 13:45:58 +10:00
committed by GitHub
parent fd9aaf00a2
commit 3f8d799613
36 changed files with 1981 additions and 741 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long