diff --git a/.github/workflows/proto-lint.yml b/.github/workflows/proto-lint.yml index fb45f7d8af5..6b86a966810 100644 --- a/.github/workflows/proto-lint.yml +++ b/.github/workflows/proto-lint.yml @@ -21,7 +21,7 @@ jobs: go install google.golang.org/protobuf/cmd/protoc-gen-go@latest go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest - - uses: bufbuild/buf-setup-action@v1.47.2 + - uses: bufbuild/buf-setup-action@v1.50.0 - name: buf generate working-directory: ./gctrpc diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000000..db3748580ea --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,42 @@ +name: stale-checks +on: + schedule: + - cron: '0 0 * * 1-5' + workflow_dispatch: + +permissions: + contents: read + +env: + DAYS_BEFORE_STALE: ${{ vars.DAYS_BEFORE_STALE }} + DAYS_BEFORE_CLOSE: ${{ vars.DAYS_BEFORE_CLOSE }} + EXEMPT_ISSUE_LABELS: ${{ vars.EXEMPT_ISSUE_LABELS }} + EXEMPT_PR_LABELS: ${{ vars.EXEMPT_PR_LABELS }} + +jobs: + stale: + name: Stale issues and PRs check + runs-on: ubuntu-latest + environment: ci + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v9 + with: + # General settings + days-before-stale: ${{ env.DAYS_BEFORE_STALE }} + days-before-close: ${{ env.DAYS_BEFORE_CLOSE }} + exempt-issue-labels: ${{ env.EXEMPT_ISSUE_LABELS }} + exempt-pr-labels: ${{ env.EXEMPT_PR_LABELS }} + enable-statistics: true + + # Issue settings + stale-issue-message: 'This issue is stale because it has been open ${{ env.DAYS_BEFORE_STALE }} days with no activity. Please provide an update or this issue will be automatically closed in ${{ env.DAYS_BEFORE_CLOSE }} days.' + close-issue-message: 'This issue was closed because it has been stalled for ${{ env.DAYS_BEFORE_CLOSE }} days with no activity.' + stale-issue-label: 'stale' + + # PR settings + stale-pr-message: 'This PR is stale because it has been open ${{ env.DAYS_BEFORE_STALE }} days with no activity. Please provide an update on the progress of this PR.' + days-before-pr-close: -1 + stale-pr-label: 'stale' \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml index b59ae9635b3..c577a1f7d5d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -150,6 +150,9 @@ issues: - text: "Expect WriteFile permissions to be 0600 or less" linters: - gosec + - text: 'shadow: declaration of "err" shadows declaration at' + linters: [ govet ] + exclude-dirs: - vendor diff --git a/README.md b/README.md index 16819ec0f39..b76c4bc3ade 100644 --- a/README.md +++ b/README.md @@ -148,11 +148,11 @@ Binaries will be published once the codebase reaches a stable condition. |User|Contribution Amount| |--|--| -| [thrasher-](https://github.com/thrasher-) | 700 | -| [shazbert](https://github.com/shazbert) | 345 | -| [dependabot[bot]](https://github.com/apps/dependabot) | 317 | -| [gloriousCode](https://github.com/gloriousCode) | 234 | -| [gbjk](https://github.com/gbjk) | 93 | +| [thrasher-](https://github.com/thrasher-) | 703 | +| [shazbert](https://github.com/shazbert) | 355 | +| [dependabot[bot]](https://github.com/apps/dependabot) | 331 | +| [gloriousCode](https://github.com/gloriousCode) | 236 | +| [gbjk](https://github.com/gbjk) | 107 | | [dependabot-preview[bot]](https://github.com/apps/dependabot-preview) | 88 | | [xtda](https://github.com/xtda) | 47 | | [lrascao](https://github.com/lrascao) | 27 | diff --git a/cmd/documentation/exchanges_templates/deribit.tmpl b/cmd/documentation/exchanges_templates/deribit.tmpl index 9e04bfa9c36..1277ed1a3e7 100644 --- a/cmd/documentation/exchanges_templates/deribit.tmpl +++ b/cmd/documentation/exchanges_templates/deribit.tmpl @@ -93,12 +93,23 @@ if err != nil { } ``` -### How to do Websocket public/private calls +### Subscriptions -```go - // Exchanges will be abstracted out in further updates and examples will be - // supplied then -``` +All default subscriptions are for all enabled assets. + +Default Public Subscriptions: +- Candles ( Timeframe: 1 day ) +- Orderbook ( Full depth @ Interval: 100ms ) +- Ticker ( Interval: 100ms ) +- All Trades ( Interval: 100ms ) + +Default Authenticated Subscriptions: +- My Account Orders +- My Account Trades + +kline.Raw Interval configurable for a raw orderbook subscription when authenticated + +Subscriptions are subject to enabled assets and pairs. ### Please click GoDocs chevron above to view current GoDoc information for this package {{template "contributions"}} diff --git a/cmd/documentation/exchanges_templates/kucoin.tmpl b/cmd/documentation/exchanges_templates/kucoin.tmpl index 1eb9ca856c9..1ae61cc437c 100644 --- a/cmd/documentation/exchanges_templates/kucoin.tmpl +++ b/cmd/documentation/exchanges_templates/kucoin.tmpl @@ -23,6 +23,9 @@ Default Authenticated Subscriptions: Subscriptions are subject to enabled assets and pairs. +Margin subscriptions for ticker, orderbook and All trades are merged into Spot subscriptions because duplicates are not allowed, +unless Spot subscription does not exist, i.e. Spot asset not enabled, or subscription configured only for Margin + Limitations: - 100 symbols per subscription - 300 symbols per connection diff --git a/currency/pairs.go b/currency/pairs.go index cc5e2f6db52..99398cd1e0d 100644 --- a/currency/pairs.go +++ b/currency/pairs.go @@ -9,14 +9,15 @@ import ( "strings" ) +// Public Errors +var ( + ErrPairDuplication = errors.New("currency pair duplication") +) + var ( errSymbolEmpty = errors.New("symbol is empty") errNoDelimiter = errors.New("no delimiter was supplied") errPairFormattingInconsistent = errors.New("pair formatting is inconsistent") - - // ErrPairDuplication defines an error when there is multiple of the same - // currency pairs found. - ErrPairDuplication = errors.New("currency pair duplication") ) // NewPairsFromStrings takes in currency pair strings and returns a currency diff --git a/engine/currency_state_manager.md b/engine/currency_state_manager.md index 5e938e72fab..cb6a44d9a57 100644 --- a/engine/currency_state_manager.md +++ b/engine/currency_state_manager.md @@ -1,22 +1,22 @@ -# GoCryptoTrader package Currency state manager - - - - -[![Build Status](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml) -[![Software License](https://img.shields.io/badge/License-MIT-orange.svg?style=flat-square)](https://github.com/thrasher-corp/gocryptotrader/blob/master/LICENSE) -[![GoDoc](https://godoc.org/github.com/thrasher-corp/gocryptotrader?status.svg)](https://godoc.org/github.com/thrasher-corp/gocryptotrader/engine/currency_state_manager) -[![Coverage Status](https://codecov.io/gh/thrasher-corp/gocryptotrader/graph/badge.svg?token=41784B23TS)](https://codecov.io/gh/thrasher-corp/gocryptotrader) -[![Go Report Card](https://goreportcard.com/badge/github.com/thrasher-corp/gocryptotrader)](https://goreportcard.com/report/github.com/thrasher-corp/gocryptotrader) - - -This currency_state_manager package is part of the GoCryptoTrader codebase. - -## This is still in active development - -You can track ideas, planned features and what's in progress on our [GoCryptoTrader Kanban board](https://github.com/orgs/thrasher-corp/projects/3). - -Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader Slack](https://join.slack.com/t/gocryptotrader/shared_invite/enQtNTQ5NDAxMjA2Mjc5LTc5ZDE1ZTNiOGM3ZGMyMmY1NTAxYWZhODE0MWM5N2JlZDk1NDU0YTViYzk4NTk3OTRiMDQzNGQ1YTc4YmRlMTk) +# GoCryptoTrader package Currency state manager + + + + +[![Build Status](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml/badge.svg?branch=master)](https://github.com/thrasher-corp/gocryptotrader/actions/workflows/tests.yml) +[![Software License](https://img.shields.io/badge/License-MIT-orange.svg?style=flat-square)](https://github.com/thrasher-corp/gocryptotrader/blob/master/LICENSE) +[![GoDoc](https://godoc.org/github.com/thrasher-corp/gocryptotrader?status.svg)](https://godoc.org/github.com/thrasher-corp/gocryptotrader/engine/currency_state_manager) +[![Coverage Status](https://codecov.io/gh/thrasher-corp/gocryptotrader/graph/badge.svg?token=41784B23TS)](https://codecov.io/gh/thrasher-corp/gocryptotrader) +[![Go Report Card](https://goreportcard.com/badge/github.com/thrasher-corp/gocryptotrader)](https://goreportcard.com/report/github.com/thrasher-corp/gocryptotrader) + + +This currency_state_manager package is part of the GoCryptoTrader codebase. + +## This is still in active development + +You can track ideas, planned features and what's in progress on our [GoCryptoTrader Kanban board](https://github.com/orgs/thrasher-corp/projects/3). + +Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader Slack](https://join.slack.com/t/gocryptotrader/shared_invite/enQtNTQ5NDAxMjA2Mjc5LTc5ZDE1ZTNiOGM3ZGMyMmY1NTAxYWZhODE0MWM5N2JlZDk1NDU0YTViYzk4NTk3OTRiMDQzNGQ1YTc4YmRlMTk) ## Current Features for Currency state manager + The state manager keeps currency states up to date, which include: @@ -27,22 +27,22 @@ Join our slack to discuss all things related to GoCryptoTrader! [GoCryptoTrader + This allows for an internal state check to compliment internal and external strategies. - -## Contribution - -Please feel free to submit any pull requests or suggest any desired features to be added. - -When submitting a PR, please abide by our coding guidelines: - -+ Code must adhere to the official Go [formatting](https://golang.org/doc/effective_go.html#formatting) guidelines (i.e. uses [gofmt](https://golang.org/cmd/gofmt/)). -+ Code must be documented adhering to the official Go [commentary](https://golang.org/doc/effective_go.html#commentary) guidelines. -+ Code must adhere to our [coding style](https://github.com/thrasher-corp/gocryptotrader/blob/master/doc/coding_style.md). -+ Pull requests need to be based on and opened against the `master` branch. - -## Donations - - - -If this framework helped you in any way, or you would like to support the developers working on it, please donate Bitcoin to: - + +## Contribution + +Please feel free to submit any pull requests or suggest any desired features to be added. + +When submitting a PR, please abide by our coding guidelines: + ++ Code must adhere to the official Go [formatting](https://golang.org/doc/effective_go.html#formatting) guidelines (i.e. uses [gofmt](https://golang.org/cmd/gofmt/)). ++ Code must be documented adhering to the official Go [commentary](https://golang.org/doc/effective_go.html#commentary) guidelines. ++ Code must adhere to our [coding style](https://github.com/thrasher-corp/gocryptotrader/blob/master/doc/coding_style.md). ++ Pull requests need to be based on and opened against the `master` branch. + +## Donations + + + +If this framework helped you in any way, or you would like to support the developers working on it, please donate Bitcoin to: + ***bc1qk0jareu4jytc0cfrhr5wgshsq8282awpavfahc*** diff --git a/engine/engine.go b/engine/engine.go index bd9dba18aec..8ad14d87ed6 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -791,17 +791,11 @@ func (bot *Engine) LoadExchange(name string) error { localWG.Wait() if !bot.Settings.EnableExchangeHTTPRateLimiter { - gctlog.Warnf(gctlog.ExchangeSys, - "Loaded exchange %s rate limiting has been turned off.\n", - exch.GetName(), - ) err = exch.DisableRateLimiter() if err != nil { - gctlog.Errorf(gctlog.ExchangeSys, - "Loaded exchange %s rate limiting cannot be turned off: %s.\n", - exch.GetName(), - err, - ) + gctlog.Errorf(gctlog.ExchangeSys, "%s error disabling rate limiter: %v", exch.GetName(), err) + } else { + gctlog.Warnf(gctlog.ExchangeSys, "%s rate limiting has been turned off", exch.GetName()) } } @@ -820,29 +814,18 @@ func (bot *Engine) LoadExchange(name string) error { return err } - base := exch.GetBase() - if base.API.AuthenticatedSupport || - base.API.AuthenticatedWebsocketSupport { - assetTypes := base.GetAssetTypes(false) - var useAsset asset.Item - for a := range assetTypes { - err = base.CurrencyPairs.IsAssetEnabled(assetTypes[a]) - if err != nil { - continue - } - useAsset = assetTypes[a] - break - } - err = exch.ValidateAPICredentials(context.TODO(), useAsset) + b := exch.GetBase() + if b.API.AuthenticatedSupport || b.API.AuthenticatedWebsocketSupport { + err = exch.ValidateAPICredentials(context.TODO(), asset.Spot) if err != nil { - gctlog.Warnf(gctlog.ExchangeSys, - "%s: Cannot validate credentials, authenticated support has been disabled, Error: %s\n", - base.Name, - err) - base.API.AuthenticatedSupport = false - base.API.AuthenticatedWebsocketSupport = false + gctlog.Warnf(gctlog.ExchangeSys, "%s: Error validating credentials: %v", b.Name, err) + b.API.AuthenticatedSupport = false + b.API.AuthenticatedWebsocketSupport = false exchCfg.API.AuthenticatedSupport = false exchCfg.API.AuthenticatedWebsocketSupport = false + if b.Websocket != nil { + b.Websocket.SetCanUseAuthenticatedEndpoints(false) + } } } @@ -854,10 +837,7 @@ func (bot *Engine) dryRunParamInteraction(param string) { return } - gctlog.Warnf(gctlog.Global, - "Command line argument '-%s' induces dry run mode."+ - " Set -dryrun=false if you wish to override this.", - param) + gctlog.Warnf(gctlog.Global, "Command line argument '-%s' induces dry run mode. Set -dryrun=false if you wish to override this.", param) if !bot.Settings.EnableDryRun { bot.Settings.EnableDryRun = true diff --git a/engine/rpcserver.go b/engine/rpcserver.go index 72e7e76e8b6..76fdf12579a 100644 --- a/engine/rpcserver.go +++ b/engine/rpcserver.go @@ -79,7 +79,6 @@ var ( errGRPCShutdownSignalIsNil = errors.New("cannot shutdown, gRPC shutdown channel is nil") errInvalidStrategy = errors.New("invalid strategy") errSpecificPairNotEnabled = errors.New("specified pair is not enabled") - errPairNotEnabled = errors.New("pair is not enabled") ) // RPCServer struct @@ -4723,7 +4722,7 @@ func (s *RPCServer) GetFundingRates(ctx context.Context, r *gctrpc.GetFundingRat } if !pairs.Contains(cp, true) { - return nil, fmt.Errorf("%w %v", errPairNotEnabled, cp) + return nil, fmt.Errorf("%w %v", currency.ErrPairNotEnabled, cp) } funding, err := exch.GetHistoricalFundingRates(ctx, &fundingrate.HistoricalRatesRequest{ @@ -4821,7 +4820,7 @@ func (s *RPCServer) GetLatestFundingRate(ctx context.Context, r *gctrpc.GetLates } if !pairs.Contains(cp, true) { - return nil, fmt.Errorf("%w %v", errPairNotEnabled, cp) + return nil, fmt.Errorf("%w %v", currency.ErrPairNotEnabled, cp) } fundingRates, err := exch.GetLatestFundingRates(ctx, &fundingrate.LatestRateRequest{ diff --git a/exchanges/bitfinex/bitfinex_websocket.go b/exchanges/bitfinex/bitfinex_websocket.go index 74c04afc2d3..ee1b854b0b0 100644 --- a/exchanges/bitfinex/bitfinex_websocket.go +++ b/exchanges/bitfinex/bitfinex_websocket.go @@ -456,17 +456,20 @@ func (b *Bitfinex) handleWSEvent(respRaw []byte) error { if err != nil { return fmt.Errorf("%w 'chanId': %w from message: %s", errParsingWSField, err, respRaw) } - if !b.Websocket.Match.IncomingWithData("unsubscribe:"+chanID, respRaw) { - return fmt.Errorf("%w: unsubscribe:%v", stream.ErrNoMessageListener, chanID) + err = b.Websocket.Match.RequireMatchWithData("unsubscribe:"+chanID, respRaw) + if err != nil { + return fmt.Errorf("%w: unsubscribe:%v", err, chanID) } case wsEventError: if subID, err := jsonparser.GetUnsafeString(respRaw, "subId"); err == nil { - if !b.Websocket.Match.IncomingWithData("subscribe:"+subID, respRaw) { - return fmt.Errorf("%w: subscribe:%v", stream.ErrNoMessageListener, subID) + err = b.Websocket.Match.RequireMatchWithData("subscribe:"+subID, respRaw) + if err != nil { + return fmt.Errorf("%w: subscribe:%v", err, subID) } } else if chanID, err := jsonparser.GetUnsafeString(respRaw, "chanId"); err == nil { - if !b.Websocket.Match.IncomingWithData("unsubscribe:"+chanID, respRaw) { - return fmt.Errorf("%w: unsubscribe:%v", stream.ErrNoMessageListener, chanID) + err = b.Websocket.Match.RequireMatchWithData("unsubscribe:"+chanID, respRaw) + if err != nil { + return fmt.Errorf("%w: unsubscribe:%v", err, chanID) } } else { return fmt.Errorf("unknown channel error; Message: %s", respRaw) @@ -531,17 +534,16 @@ func (b *Bitfinex) handleWSSubscribed(respRaw []byte) error { c.Key = int(chanID) // subscribeToChan removes the old subID keyed Subscription - if err := b.Websocket.AddSuccessfulSubscriptions(b.Websocket.Conn, c); err != nil { + err = b.Websocket.AddSuccessfulSubscriptions(b.Websocket.Conn, c) + if err != nil { return fmt.Errorf("%w: %w subID: %s", stream.ErrSubscriptionFailure, err, subID) } if b.Verbose { log.Debugf(log.ExchangeSys, "%s Subscribed to Channel: %s Pair: %s ChannelID: %d\n", b.Name, c.Channel, c.Pairs, chanID) } - if !b.Websocket.Match.IncomingWithData("subscribe:"+subID, respRaw) { - return fmt.Errorf("%w: subscribe:%v", stream.ErrNoMessageListener, subID) - } - return nil + + return b.Websocket.Match.RequireMatchWithData("subscribe:"+subID, respRaw) } func (b *Bitfinex) handleWSChannelUpdate(s *subscription.Subscription, eventType string, d []interface{}) error { diff --git a/exchanges/bitmex/bitmex_websocket.go b/exchanges/bitmex/bitmex_websocket.go index f539787cd1e..d322d5a6713 100644 --- a/exchanges/bitmex/bitmex_websocket.go +++ b/exchanges/bitmex/bitmex_websocket.go @@ -170,8 +170,9 @@ func (b *Bitmex) wsHandleData(respRaw []byte) error { if e2 != nil { return fmt.Errorf("%w parsing stream", e2) } - if !b.Websocket.Match.IncomingWithData(op+":"+streamID, msg) { - return fmt.Errorf("%w: %s:%s", stream.ErrNoMessageListener, op, streamID) + err = b.Websocket.Match.RequireMatchWithData(op+":"+streamID, msg) + if err != nil { + return fmt.Errorf("%w: %s:%s", err, op, streamID) } return nil } diff --git a/exchanges/bitstamp/bitstamp_websocket.go b/exchanges/bitstamp/bitstamp_websocket.go index de45f2fba95..8fe4f2fc1fa 100644 --- a/exchanges/bitstamp/bitstamp_websocket.go +++ b/exchanges/bitstamp/bitstamp_websocket.go @@ -135,10 +135,7 @@ func (b *Bitstamp) handleWSSubscription(event string, respRaw []byte) error { return fmt.Errorf("%w `channel`: %w", errParsingWSField, err) } event = strings.TrimSuffix(event, "scription_succeeded") - if !b.Websocket.Match.IncomingWithData(event+":"+channel, respRaw) { - return fmt.Errorf("%w: %s", stream.ErrNoMessageListener, event+":"+channel) - } - return nil + return b.Websocket.Match.RequireMatchWithData(event+":"+channel, respRaw) } func (b *Bitstamp) handleWSTrade(msg []byte) error { diff --git a/exchanges/btse/btse_wrapper.go b/exchanges/btse/btse_wrapper.go index 72d25f42925..54fa94d2a79 100644 --- a/exchanges/btse/btse_wrapper.go +++ b/exchanges/btse/btse_wrapper.go @@ -1281,7 +1281,7 @@ func (b *BTSE) UpdateOrderExecutionLimits(ctx context.Context, a asset.Item) err var errs error limits := make([]order.MinMaxLevel, 0, len(summary)) for _, marketInfo := range summary { - p, err := marketInfo.Pair() //nolint:govet // Deliberately shadow err + p, err := marketInfo.Pair() if err != nil { errs = common.AppendError(err, fmt.Errorf("%s: %w", p, err)) continue diff --git a/exchanges/bybit/bybit.go b/exchanges/bybit/bybit.go index 2a5503a73de..6ff82ea5f0a 100644 --- a/exchanges/bybit/bybit.go +++ b/exchanges/bybit/bybit.go @@ -27,10 +27,7 @@ import ( // Bybit is the overarching type across this package type Bybit struct { exchange.Base - - // AccountType holds information about whether the account to which the api key belongs is a unified margin account or not. - // 0: unified, and 1: for normal account - AccountType int64 + account accountTypeHolder } const ( @@ -45,8 +42,8 @@ const ( cSpot, cLinear, cOption, cInverse = "spot", "linear", "option", "inverse" - accountTypeNormal = 0 // 0: regular account - accountTypeUnified = 1 // 1: unified trade account + accountTypeNormal AccountType = 1 + accountTypeUnified AccountType = 2 longDatedFormat = "02Jan06" ) @@ -1176,8 +1173,9 @@ func (by *Bybit) GetPreUpgradeOrderHistory(ctx context.Context, category, symbol if err != nil { return nil, err } - if by.AccountType == accountTypeNormal { - return nil, errAPIKeyIsNotUnified + err = by.RequiresUnifiedAccount(ctx) + if err != nil { + return nil, err } var resp *TradeOrders return resp, by.SendAuthHTTPRequestV5(ctx, exchange.RestSpot, http.MethodGet, "/v5/pre-upgrade/order/history", params, nil, &resp, defaultEPL) @@ -1189,8 +1187,9 @@ func (by *Bybit) GetPreUpgradeTradeHistory(ctx context.Context, category, symbol if err != nil { return nil, err } - if by.AccountType == accountTypeNormal { - return nil, errAPIKeyIsNotUnified + err = by.RequiresUnifiedAccount(ctx) + if err != nil { + return nil, err } if executionType != "" { params.Set("executionType", executionType) @@ -1205,8 +1204,9 @@ func (by *Bybit) GetPreUpgradeClosedPnL(ctx context.Context, category, symbol, c if err != nil { return nil, err } - if by.AccountType == accountTypeNormal { - return nil, errAPIKeyIsNotUnified + err = by.RequiresUnifiedAccount(ctx) + if err != nil { + return nil, err } var resp *ClosedProfitAndLossResponse return resp, by.SendAuthHTTPRequestV5(ctx, exchange.RestSpot, http.MethodGet, "/v5/pre-upgrade/position/closed-pnl", params, nil, &resp, defaultEPL) @@ -1218,8 +1218,9 @@ func (by *Bybit) GetPreUpgradeTransactionLog(ctx context.Context, category, base if err != nil { return nil, err } - if by.AccountType == accountTypeNormal { - return nil, errAPIKeyIsNotUnified + err = by.RequiresUnifiedAccount(ctx) + if err != nil { + return nil, err } if transactionType != "" { params.Set("type", transactionType) @@ -1234,8 +1235,9 @@ func (by *Bybit) GetPreUpgradeOptionDeliveryRecord(ctx context.Context, category if err != nil { return nil, err } - if by.AccountType == accountTypeNormal { - return nil, errAPIKeyIsNotUnified + err = by.RequiresUnifiedAccount(ctx) + if err != nil { + return nil, err } if !expiryDate.IsZero() { params.Set("expData", expiryDate.Format(longDatedFormat)) @@ -1250,8 +1252,9 @@ func (by *Bybit) GetPreUpgradeUSDCSessionSettlement(ctx context.Context, categor if err != nil { return nil, err } - if by.AccountType == accountTypeNormal { - return nil, errAPIKeyIsNotUnified + err = by.RequiresUnifiedAccount(ctx) + if err != nil { + return nil, err } var resp *SettlementSession return resp, by.SendAuthHTTPRequestV5(ctx, exchange.RestSpot, http.MethodGet, "/v5/pre-upgrade/asset/settlement-record", params, nil, &resp, defaultEPL) @@ -2699,13 +2702,31 @@ func getSign(sign, secret string) (string, error) { return crypto.HexEncodeToString(hmacSigned), nil } -// RetrieveAndSetAccountType retrieve to set account type information -func (by *Bybit) RetrieveAndSetAccountType(ctx context.Context) error { - accInfo, err := by.GetAPIKeyInformation(ctx) +// FetchAccountType if not set fetches the account type from the API, stores it and returns it. Else returns the stored account type. +func (by *Bybit) FetchAccountType(ctx context.Context) (AccountType, error) { + by.account.m.Lock() + defer by.account.m.Unlock() + if by.account.accountType == 0 { + accInfo, err := by.GetAPIKeyInformation(ctx) + if err != nil { + return 0, err + } + // From endpoint 0:regular account; 1:unified trade account + // + 1 to make it 1 and 2 so that a zero value can be used to check if the account type has been set or not. + by.account.accountType = AccountType(accInfo.IsUnifiedTradeAccount + 1) + } + return by.account.accountType, nil +} + +// RequiresUnifiedAccount checks account type and returns error if not unified +func (by *Bybit) RequiresUnifiedAccount(ctx context.Context) error { + at, err := by.FetchAccountType(ctx) if err != nil { - return err + return nil //nolint:nilerr // if we can't get the account type, we can't check if it's unified or not, fail on call + } + if at != accountTypeUnified { + return fmt.Errorf("%w, account type: %s", errAPIKeyIsNotUnified, at) } - by.AccountType = accInfo.IsUnifiedTradeAccount // 0:regular account; 1:unified trade account return nil } diff --git a/exchanges/bybit/bybit_live_test.go b/exchanges/bybit/bybit_live_test.go index fc792be0955..299e9e2dc42 100644 --- a/exchanges/bybit/bybit_live_test.go +++ b/exchanges/bybit/bybit_live_test.go @@ -31,8 +31,8 @@ func TestMain(m *testing.M) { } if b.API.AuthenticatedSupport { - if err := b.RetrieveAndSetAccountType(context.Background()); err != nil { - log.Printf("%s unable to RetrieveAndSetAccountType: %v", b.Name, err) + if _, err := b.FetchAccountType(context.Background()); err != nil { + log.Printf("%s unable to FetchAccountType: %v", b.Name, err) } } diff --git a/exchanges/bybit/bybit_test.go b/exchanges/bybit/bybit_test.go index 4259eab31a7..9fa89c6fa13 100644 --- a/exchanges/bybit/bybit_test.go +++ b/exchanges/bybit/bybit_test.go @@ -3548,12 +3548,35 @@ func TestStringToOrderStatus(t *testing.T) { } } -func TestRetrieveAndSetAccountType(t *testing.T) { - sharedtestvalues.SkipTestIfCredentialsUnset(t, b, canManipulateRealOrders) - err := b.RetrieveAndSetAccountType(context.Background()) - if err != nil { - t.Fatal(err) +func TestFetchAccountType(t *testing.T) { + t.Parallel() + if !mockTests { + sharedtestvalues.SkipTestIfCredentialsUnset(t, b) } + val, err := b.FetchAccountType(context.Background()) + require.NoError(t, err) + require.NotZero(t, val) +} + +func TestAccountTypeString(t *testing.T) { + t.Parallel() + require.Equal(t, "unset", AccountType(0).String()) + require.Equal(t, "unified", accountTypeUnified.String()) + require.Equal(t, "normal", accountTypeNormal.String()) + require.Equal(t, "unknown", AccountType(3).String()) +} + +func TestRequiresUnifiedAccount(t *testing.T) { + t.Parallel() + if !mockTests { + sharedtestvalues.SkipTestIfCredentialsUnset(t, b) + } + err := b.RequiresUnifiedAccount(context.Background()) + require.NoError(t, err) + b := &Bybit{} //nolint:govet // Intentional shadow to avoid future copy/paste mistakes. Also stops race below. + b.account.accountType = accountTypeNormal + err = b.RequiresUnifiedAccount(context.Background()) + require.ErrorIs(t, err, errAPIKeyIsNotUnified) } func TestGetLatestFundingRates(t *testing.T) { diff --git a/exchanges/bybit/bybit_types.go b/exchanges/bybit/bybit_types.go index 656a4d2ffa7..16114557684 100644 --- a/exchanges/bybit/bybit_types.go +++ b/exchanges/bybit/bybit_types.go @@ -2,6 +2,7 @@ package bybit import ( "encoding/json" + "sync" "time" "github.com/gofrs/uuid" @@ -2035,3 +2036,26 @@ type Error struct { ExtCode string `json:"ext_code"` ExtMsg string `json:"ext_info"` } + +// accountTypeHolder holds the account type associated with the loaded API key. +type accountTypeHolder struct { + accountType AccountType + m sync.Mutex +} + +// AccountType constants +type AccountType uint8 + +// String returns the account type as a string +func (a AccountType) String() string { + switch a { + case 0: + return "unset" + case accountTypeNormal: + return "normal" + case accountTypeUnified: + return "unified" + default: + return "unknown" + } +} diff --git a/exchanges/bybit/bybit_wrapper.go b/exchanges/bybit/bybit_wrapper.go index acb3ae66949..1d473a47830 100644 --- a/exchanges/bybit/bybit_wrapper.go +++ b/exchanges/bybit/bybit_wrapper.go @@ -562,7 +562,7 @@ func (by *Bybit) UpdateAccountInfo(ctx context.Context, assetType asset.Item) (a var acc account.SubAccount var accountType string info.Exchange = by.Name - err := by.RetrieveAndSetAccountType(ctx) + at, err := by.FetchAccountType(ctx) if err != nil { return info, err } @@ -570,7 +570,7 @@ func (by *Bybit) UpdateAccountInfo(ctx context.Context, assetType asset.Item) (a case asset.Spot, asset.Options, asset.USDCMarginedFutures, asset.USDTMarginedFutures: - switch by.AccountType { + switch at { case accountTypeUnified: accountType = "UNIFIED" case accountTypeNormal: diff --git a/exchanges/coinbasepro/coinbasepro_test.go b/exchanges/coinbasepro/coinbasepro_test.go index bf88705f5af..80d5e95e343 100644 --- a/exchanges/coinbasepro/coinbasepro_test.go +++ b/exchanges/coinbasepro/coinbasepro_test.go @@ -3,7 +3,6 @@ package coinbasepro import ( "context" "errors" - "log" "net/http" "os" "testing" @@ -14,7 +13,6 @@ import ( "github.com/stretchr/testify/require" "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/convert" - "github.com/thrasher-corp/gocryptotrader/config" "github.com/thrasher-corp/gocryptotrader/core" "github.com/thrasher-corp/gocryptotrader/currency" exchange "github.com/thrasher-corp/gocryptotrader/exchanges" @@ -42,33 +40,11 @@ const ( canManipulateRealOrders = false ) -func TestMain(m *testing.M) { - c.SetDefaults() - cfg := config.GetConfig() - err := cfg.LoadConfig("../../testdata/configtest.json", true) - if err != nil { - log.Fatal("coinbasepro load config error", err) - } - gdxConfig, err := cfg.GetExchangeConfig("CoinbasePro") - if err != nil { - log.Fatal("coinbasepro Setup() init error") - } - gdxConfig.API.Credentials.Key = apiKey - gdxConfig.API.Credentials.Secret = apiSecret - gdxConfig.API.Credentials.ClientID = clientID - gdxConfig.API.AuthenticatedSupport = true - gdxConfig.API.AuthenticatedWebsocketSupport = true - c.Websocket = sharedtestvalues.NewTestWebsocket() - err = c.Setup(gdxConfig) - if err != nil { - log.Fatal("CoinbasePro setup error", err) - } - os.Exit(m.Run()) +func TestMain(_ *testing.M) { + os.Exit(0) // Disable full test suite until PR #1381 is merged as more API endpoints have been deprecated over time } func TestGetProducts(t *testing.T) { - t.Skip("API is deprecated") - _, err := c.GetProducts(context.Background()) if err != nil { t.Errorf("Coinbase, GetProducts() Error: %s", err) @@ -76,8 +52,6 @@ func TestGetProducts(t *testing.T) { } func TestGetOrderbook(t *testing.T) { - t.Skip("API is deprecated") - _, err := c.GetOrderbook(context.Background(), testPair.String(), 2) if err != nil { t.Error(err) @@ -89,8 +63,6 @@ func TestGetOrderbook(t *testing.T) { } func TestGetTicker(t *testing.T) { - t.Skip("API is deprecated") - _, err := c.GetTicker(context.Background(), testPair.String()) if err != nil { t.Error("GetTicker() error", err) @@ -105,8 +77,6 @@ func TestGetTrades(t *testing.T) { } func TestGetHistoricRatesGranularityCheck(t *testing.T) { - t.Skip("API is deprecated") - end := time.Now() start := end.Add(-time.Hour * 2) _, err := c.GetHistoricCandles(context.Background(), @@ -117,8 +87,6 @@ func TestGetHistoricRatesGranularityCheck(t *testing.T) { } func TestCoinbasePro_GetHistoricCandlesExtended(t *testing.T) { - t.Skip("API is deprecated") - start := time.Unix(1546300800, 0) end := time.Unix(1577836799, 0) diff --git a/exchanges/deribit/README.md b/exchanges/deribit/README.md index 57e04fdcaee..3e6595258f7 100644 --- a/exchanges/deribit/README.md +++ b/exchanges/deribit/README.md @@ -111,12 +111,23 @@ if err != nil { } ``` -### How to do Websocket public/private calls +### Subscriptions -```go - // Exchanges will be abstracted out in further updates and examples will be - // supplied then -``` +All default subscriptions are for all enabled assets. + +Default Public Subscriptions: +- Candles ( Timeframe: 1 day ) +- Orderbook ( Full depth @ Interval: 100ms ) +- Ticker ( Interval: 100ms ) +- All Trades ( Interval: 100ms ) + +Default Authenticated Subscriptions: +- My Account Orders +- My Account Trades + +kline.Raw Interval configurable for a raw orderbook subscription when authenticated + +Subscriptions are subject to enabled assets and pairs. ### Please click GoDocs chevron above to view current GoDoc information for this package diff --git a/exchanges/deribit/deribit.go b/exchanges/deribit/deribit.go index f4787df6be4..c13c24fcef1 100644 --- a/exchanges/deribit/deribit.go +++ b/exchanges/deribit/deribit.go @@ -7,7 +7,6 @@ import ( "fmt" "net/http" "net/url" - "regexp" "strconv" "strings" "time" @@ -28,12 +27,6 @@ type Deribit struct { exchange.Base } -var ( - // optionRegex compiles optionDecimalRegex at startup and is used to help set - // option currency lower-case d eg MATIC-USDC-3JUN24-0d64-P - optionRegex *regexp.Regexp -) - const ( deribitAPIVersion = "/api/v2" tradeBaseURL = "https://www.deribit.com/" @@ -43,8 +36,7 @@ const ( tradeFuturesCombo = "futures-spreads/" tradeOptionsCombo = "combos/" - perpString = "PERPETUAL" - optionDecimalRegex = `\d+(D)\d+` + perpString = "PERPETUAL" // Public endpoints // Market Data @@ -2837,20 +2829,19 @@ func (d *Deribit) formatFuturesTradablePair(pair currency.Pair) string { return instrumentID } -// optionPairToString to format and return an Options currency pairs with the following format: MATIC_USDC-6APR24-0d98-P -// it has both uppercase or lowercase characters, which we can not achieve with the Upper=true or Upper=false +// optionPairToString formats an options pair as: SYMBOL-EXPIRE-STRIKE-TYPE +// SYMBOL may be a currency (BTC) or a pair (XRP_USDC) +// EXPIRE is DDMMMYY +// STRIKE may include a d for decimal point in linear options +// TYPE is Call or Put func (d *Deribit) optionPairToString(pair currency.Pair) string { - subCodes := strings.Split(pair.Quote.String(), currency.DashDelimiter) initialDelimiter := currency.DashDelimiter - if subCodes[0] == "USDC" { + q := pair.Quote.String() + if strings.HasPrefix(q, "USDC") && len(q) > 11 { // Linear option initialDelimiter = currency.UnderscoreDelimiter + // Replace a capital D with d for decimal place in Strike price + // Char 11 is either the hyphen before Strike price or first digit + q = q[:11] + strings.Replace(q[11:], "D", "d", -1) } - for i := range subCodes { - if match := optionRegex.MatchString(subCodes[i]); match { - subCodes[i] = strings.ToLower(subCodes[i]) - break - } - } - - return pair.Base.String() + initialDelimiter + strings.Join(subCodes, currency.DashDelimiter) + return pair.Base.String() + initialDelimiter + q } diff --git a/exchanges/deribit/deribit_test.go b/exchanges/deribit/deribit_test.go index 84b92c7b2d2..51a505b337f 100644 --- a/exchanges/deribit/deribit_test.go +++ b/exchanges/deribit/deribit_test.go @@ -25,9 +25,11 @@ import ( "github.com/thrasher-corp/gocryptotrader/exchanges/order" "github.com/thrasher-corp/gocryptotrader/exchanges/orderbook" "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" + "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" "github.com/thrasher-corp/gocryptotrader/exchanges/ticker" "github.com/thrasher-corp/gocryptotrader/exchanges/trade" testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" + testsubs "github.com/thrasher-corp/gocryptotrader/internal/testing/subscriptions" "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" ) @@ -3266,11 +3268,82 @@ func setupWs() { } } -func TestGenerateDefaultSubscriptions(t *testing.T) { +func TestGenerateSubscriptions(t *testing.T) { t.Parallel() - result, err := d.GenerateDefaultSubscriptions() + + d := new(Deribit) //nolint:govet // Intentional lexical scope shadow + require.NoError(t, testexch.Setup(d), "Test instance Setup must not error") + + d.Websocket.SetCanUseAuthenticatedEndpoints(true) + subs, err := d.generateSubscriptions() require.NoError(t, err) - assert.NotNil(t, result) + exp := subscription.List{} + for _, s := range d.Features.Subscriptions { + for _, a := range d.GetAssetTypes(true) { + if !d.IsAssetWebsocketSupported(a) { + continue + } + pairs, err := d.GetEnabledPairs(a) + require.NoErrorf(t, err, "GetEnabledPairs %s must not error", a) + s := s.Clone() //nolint:govet // Intentional lexical scope shadow + s.Asset = a + if isSymbolChannel(s) { + for i, p := range pairs { + s := s.Clone() //nolint:govet // Intentional lexical scope shadow + s.QualifiedChannel = channelName(s) + "." + p.String() + if s.Interval != 0 { + s.QualifiedChannel += "." + channelInterval(s) + } + s.Pairs = pairs[i : i+1] + exp = append(exp, s) + } + } else { + s.Pairs = pairs + s.QualifiedChannel = channelName(s) + exp = append(exp, s) + } + } + } + testsubs.EqualLists(t, exp, subs) +} + +func TestChannelInterval(t *testing.T) { + t.Parallel() + + for _, i := range []int64{1, 3, 5, 10, 15, 30, 60, 120, 180, 360, 720} { + a := channelInterval(&subscription.Subscription{Channel: subscription.CandlesChannel, Interval: kline.Interval(i * int64(time.Minute))}) + assert.Equal(t, strconv.Itoa(int(i)), a) + } + + a := channelInterval(&subscription.Subscription{Channel: subscription.CandlesChannel, Interval: kline.OneDay}) + assert.Equal(t, "1D", a) + + assert.Panics(t, func() { + channelInterval(&subscription.Subscription{Channel: subscription.CandlesChannel, Interval: kline.OneMonth}) + }) + + a = channelInterval(&subscription.Subscription{Channel: subscription.OrderbookChannel, Interval: kline.ThousandMilliseconds}) + assert.Equal(t, "agg2", a, "1 second should expand to agg2") + + a = channelInterval(&subscription.Subscription{Channel: subscription.OrderbookChannel, Interval: kline.HundredMilliseconds}) + assert.Equal(t, "100ms", a, "100ms should expand correctly") + + a = channelInterval(&subscription.Subscription{Channel: subscription.OrderbookChannel, Interval: kline.Raw}) + assert.Equal(t, "raw", a, "raw should expand correctly") + + assert.Panics(t, func() { + channelInterval(&subscription.Subscription{Channel: subscription.OrderbookChannel, Interval: kline.OneMonth}) + }) + + a = channelInterval(&subscription.Subscription{Channel: userAccessLogChannel}) + assert.Empty(t, a, "Anything else should return empty") +} + +func TestChannelName(t *testing.T) { + t.Parallel() + assert.Equal(t, tickerChannel, channelName(&subscription.Subscription{Channel: subscription.TickerChannel})) + assert.Equal(t, userLockChannel, channelName(&subscription.Subscription{Channel: userLockChannel})) + assert.Panics(t, func() { channelName(&subscription.Subscription{Channel: "wibble"}) }, "Unknown channels should panic") } func TestFetchTicker(t *testing.T) { @@ -3681,18 +3754,15 @@ func TestFormatFuturesTradablePair(t *testing.T) { func TestOptionPairToString(t *testing.T) { t.Parallel() - optionsList := map[currency.Pair]string{ + for pair, exp := range map[currency.Pair]string{ {Delimiter: currency.DashDelimiter, Base: currency.BTC, Quote: currency.NewCode("30MAY24-61000-C")}: "BTC-30MAY24-61000-C", {Delimiter: currency.DashDelimiter, Base: currency.ETH, Quote: currency.NewCode("1JUN24-3200-P")}: "ETH-1JUN24-3200-P", {Delimiter: currency.DashDelimiter, Base: currency.SOL, Quote: currency.NewCode("USDC-31MAY24-162-P")}: "SOL_USDC-31MAY24-162-P", {Delimiter: currency.DashDelimiter, Base: currency.MATIC, Quote: currency.NewCode("USDC-6APR24-0d98-P")}: "MATIC_USDC-6APR24-0d98-P", - } - for pair, instrumentID := range optionsList { - t.Run(instrumentID, func(t *testing.T) { - t.Parallel() - instrument := d.optionPairToString(pair) - require.Equal(t, instrumentID, instrument) - }) + {Delimiter: currency.DashDelimiter, Base: currency.MATIC, Quote: currency.NewCode("USDC-8JUN24-0D99-P")}: "MATIC_USDC-8JUN24-0d99-P", + {Delimiter: currency.DashDelimiter, Base: currency.MATIC, Quote: currency.NewCode("USDC-6DEC29-0D87-C")}: "MATIC_USDC-6DEC29-0d87-C", + } { + assert.Equal(t, exp, d.optionPairToString(pair), "optionPairToString should return correctly") } } diff --git a/exchanges/deribit/deribit_websocket.go b/exchanges/deribit/deribit_websocket.go index d99dc86293d..21e7088bb7b 100644 --- a/exchanges/deribit/deribit_websocket.go +++ b/exchanges/deribit/deribit_websocket.go @@ -3,10 +3,12 @@ package deribit import ( "context" "encoding/json" + "errors" "fmt" "net/http" "strconv" "strings" + "text/template" "time" "github.com/gorilla/websocket" @@ -50,33 +52,59 @@ const ( platformStatePublicMethodsStateChannel = "platform_state.public_methods_state" quoteChannel = "quote" requestForQuoteChannel = "rfq" - tickerChannel = "ticker." - tradesChannel = "trades." - tradesWithKindChannel = "trades" + tickerChannel = "ticker" + tradesChannel = "trades" // private websocket channels - userAccessLogChannel = "user.access_log" - userChangesInstrumentsChannel = "user.changes." - userChangesCurrencyChannel = "user.changes" - userLockChannel = "user.lock" - userMMPTriggerChannel = "user.mmp_trigger" - rawUserOrdersChannel = "user.orders.%s.raw" - userOrdersWithIntervalChannel = "user.orders." - rawUsersOrdersKindCurrencyChannel = "user.orders.%s.%s.raw" - rawUsersOrdersWithKindCurrencyAndIntervalChannel = "user.orders" - userPortfolioChannel = "user.portfolio" - userTradesChannelByInstrument = "user.trades." - userTradesByKindCurrencyAndIntervalChannel = "user.trades" + userAccessLogChannel = "user.access_log" + userChangesInstrumentsChannel = "user.changes." + userChangesCurrencyChannel = "user.changes" + userLockChannel = "user.lock" + userMMPTriggerChannel = "user.mmp_trigger" + userOrdersChannel = "user.orders" + userTradesChannel = "user.trades" + userPortfolioChannel = "user.portfolio" ) -var ( - defaultSubscriptions = []string{ - chartTradesChannel, // chart trades channel to fetch candlestick data. - orderbookChannel, - tickerChannel, - tradesWithKindChannel, - } +var subscriptionNames = map[string]string{ + subscription.TickerChannel: tickerChannel, + subscription.OrderbookChannel: orderbookChannel, + subscription.CandlesChannel: chartTradesChannel, + subscription.AllTradesChannel: tradesChannel, + subscription.MyTradesChannel: userTradesChannel, + subscription.MyOrdersChannel: userOrdersChannel, + announcementsChannel: announcementsChannel, + priceIndexChannel: priceIndexChannel, + priceRankingChannel: priceRankingChannel, + priceStatisticsChannel: priceStatisticsChannel, + volatilityIndexChannel: volatilityIndexChannel, + estimatedExpirationPriceChannel: estimatedExpirationPriceChannel, + incrementalTickerChannel: incrementalTickerChannel, + instrumentStateChannel: instrumentStateChannel, + markPriceOptionsChannel: markPriceOptionsChannel, + perpetualChannel: perpetualChannel, + platformStateChannel: platformStateChannel, + platformStatePublicMethodsStateChannel: platformStatePublicMethodsStateChannel, + quoteChannel: quoteChannel, + requestForQuoteChannel: requestForQuoteChannel, + userAccessLogChannel: userAccessLogChannel, + userChangesInstrumentsChannel: userChangesInstrumentsChannel, + userChangesCurrencyChannel: userChangesCurrencyChannel, + userLockChannel: userLockChannel, + userMMPTriggerChannel: userMMPTriggerChannel, + userPortfolioChannel: userPortfolioChannel, +} +var defaultSubscriptions = subscription.List{ + {Enabled: true, Asset: asset.All, Channel: subscription.CandlesChannel, Interval: kline.OneDay}, + {Enabled: true, Asset: asset.All, Channel: subscription.OrderbookChannel, Interval: kline.HundredMilliseconds}, // Raw is available for authenticated users + {Enabled: true, Asset: asset.All, Channel: subscription.TickerChannel, Interval: kline.HundredMilliseconds}, + {Enabled: true, Asset: asset.All, Channel: subscription.AllTradesChannel, Interval: kline.HundredMilliseconds}, + {Enabled: true, Asset: asset.All, Channel: subscription.MyOrdersChannel, Interval: kline.HundredMilliseconds, Authenticated: true}, + {Enabled: true, Asset: asset.All, Channel: subscription.MyTradesChannel, Interval: kline.HundredMilliseconds, Authenticated: true}, +} + +var ( indexENUMS = []string{"ada_usd", "algo_usd", "avax_usd", "bch_usd", "bnb_usd", "btc_usd", "doge_usd", "dot_usd", "eth_usd", "link_usd", "ltc_usd", "luna_usd", "matic_usd", "near_usd", "shib_usd", "sol_usd", "trx_usd", "uni_usd", "usdc_usd", "xrp_usd", "ada_usdc", "bch_usdc", "algo_usdc", "avax_usdc", "btc_usdc", "doge_usdc", "dot_usdc", "bch_usdc", "bnb_usdc", "eth_usdc", "link_usdc", "ltc_usdc", "luna_usdc", "matic_usdc", "near_usdc", "shib_usdc", "sol_usdc", "trx_usdc", "uni_usdc", "xrp_usdc", "btcdvol_usdc", "ethdvol_usdc"} pingMessage = WsSubscriptionInput{ @@ -740,446 +768,75 @@ func (d *Deribit) processOrderbook(respRaw []byte, channels []string) error { return nil } -// GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() -func (d *Deribit) GenerateDefaultSubscriptions() (subscription.List, error) { - var subscriptions subscription.List - assets := d.GetAssetTypes(true) - subscriptionChannels := defaultSubscriptions - if d.Websocket.CanUseAuthenticatedEndpoints() { - subscriptionChannels = append( - subscriptionChannels, - - // authenticated subscriptions - rawUsersOrdersKindCurrencyChannel, - rawUsersOrdersWithKindCurrencyAndIntervalChannel, - userTradesByKindCurrencyAndIntervalChannel, - ) - } - var err error - assetPairs := make(map[asset.Item][]currency.Pair, len(assets)) - for _, a := range assets { - assetPairs[a], err = d.GetEnabledPairs(a) - if err != nil { - return nil, err - } - if len(assetPairs[a]) > 5 { - assetPairs[a] = assetPairs[a][:5] - } - } - for x := range subscriptionChannels { - switch subscriptionChannels[x] { - case chartTradesChannel: - for _, a := range assets { - for z := range assetPairs[a] { - if ((assetPairs[a][z].Quote.Upper().String() == perpString || - !strings.Contains(assetPairs[a][z].Quote.Upper().String(), perpString)) && - a == asset.Futures) || (a != asset.Spot && a != asset.Futures) { - continue - } - subscriptions = append(subscriptions, - &subscription.Subscription{ - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{assetPairs[a][z]}, - Params: map[string]interface{}{ - "resolution": "1D", - }, - Asset: a, - }) - } - } - case incrementalTickerChannel, - quoteChannel, - rawUserOrdersChannel: - for _, a := range assets { - for z := range assetPairs[a] { - if ((assetPairs[a][z].Quote.Upper().String() == perpString || - !strings.Contains(assetPairs[a][z].Quote.Upper().String(), perpString)) && - a == asset.Futures) || (a != asset.Spot && a != asset.Futures) { - continue - } - subscriptions = append(subscriptions, &subscription.Subscription{ - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{assetPairs[a][z]}, - Asset: a, - }) - } - } - case orderbookChannel: - for _, a := range assets { - for z := range assetPairs[a] { - if ((assetPairs[a][z].Quote.Upper().String() == perpString || - !strings.Contains(assetPairs[a][z].Quote.Upper().String(), perpString)) && - a == asset.Futures) || (a != asset.Spot && a != asset.Futures) { - continue - } - subscriptions = append(subscriptions, - &subscription.Subscription{ - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{assetPairs[a][z]}, - // if needed, group and depth of orderbook can be passed as follow "group": "250", "depth": "20", - Interval: kline.HundredMilliseconds, - Asset: a, - Params: map[string]interface{}{ - "group": "none", - "depth": "10", - }, - }, - ) - if d.Websocket.CanUseAuthenticatedEndpoints() { - subscriptions = append(subscriptions, &subscription.Subscription{ - Channel: orderbookChannel, - Pairs: currency.Pairs{assetPairs[a][z]}, - Asset: a, - Interval: kline.Interval(0), - Params: map[string]interface{}{ - "group": "none", - "depth": "10", - }, - }) - } - } - } - case tickerChannel, - tradesChannel: - for _, a := range assets { - for z := range assetPairs[a] { - if ((assetPairs[a][z].Quote.Upper().String() != perpString && - !strings.Contains(assetPairs[a][z].Quote.Upper().String(), perpString)) && - a == asset.Futures) || (a != asset.Spot && a != asset.Futures) { - continue - } - subscriptions = append(subscriptions, - &subscription.Subscription{ - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{assetPairs[a][z]}, - Interval: kline.HundredMilliseconds, - Asset: a, - }) - } - } - case perpetualChannel, - userChangesInstrumentsChannel, - userTradesChannelByInstrument: - for _, a := range assets { - for z := range assetPairs[a] { - if subscriptionChannels[x] == perpetualChannel && !strings.Contains(assetPairs[a][z].Quote.Upper().String(), perpString) { - continue - } - subscriptions = append(subscriptions, - &subscription.Subscription{ - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{assetPairs[a][z]}, - Interval: kline.HundredMilliseconds, - Asset: a, - }) - } - } - case instrumentStateChannel, - rawUsersOrdersKindCurrencyChannel: - var okay bool - for _, a := range assets { - currencyPairsName := make(map[currency.Code]bool, 2*len(assetPairs[a])) - for z := range assetPairs[a] { - if okay = currencyPairsName[assetPairs[a][z].Base]; !okay { - subscriptions = append(subscriptions, &subscription.Subscription{ - Asset: a, - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{currency.Pair{Base: assetPairs[a][z].Base}}, - }) - currencyPairsName[assetPairs[a][z].Base] = true - } - if okay = currencyPairsName[assetPairs[a][z].Quote]; !okay { - subscriptions = append(subscriptions, &subscription.Subscription{ - Asset: a, - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{currency.Pair{Base: assetPairs[a][z].Quote}}, - }) - currencyPairsName[assetPairs[a][z].Quote] = true - } - } - } - case userChangesCurrencyChannel, - userOrdersWithIntervalChannel, - rawUsersOrdersWithKindCurrencyAndIntervalChannel, - userTradesByKindCurrencyAndIntervalChannel, - tradesWithKindChannel: - for _, a := range assets { - currencyPairsName := make(map[currency.Code]bool, 2*len(assetPairs[a])) - var okay bool - for z := range assetPairs[a] { - if okay = currencyPairsName[assetPairs[a][z].Base]; !okay { - subscriptions = append(subscriptions, &subscription.Subscription{ - Asset: a, - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{currency.Pair{Base: assetPairs[a][z].Base}}, - Interval: kline.HundredMilliseconds, - }) - currencyPairsName[assetPairs[a][z].Base] = true - } - if okay = currencyPairsName[assetPairs[a][z].Quote]; !okay { - subscriptions = append(subscriptions, &subscription.Subscription{ - Asset: a, - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{currency.Pair{Base: assetPairs[a][z].Quote}}, - Interval: kline.HundredMilliseconds, - }) - currencyPairsName[assetPairs[a][z].Quote] = true - } - } - } - case requestForQuoteChannel, - userMMPTriggerChannel, - userPortfolioChannel: - for _, a := range assets { - currencyPairsName := make(map[currency.Code]bool, 2*len(assetPairs[a])) - var okay bool - for z := range assetPairs[a] { - if okay = currencyPairsName[assetPairs[a][z].Base]; !okay { - subscriptions = append(subscriptions, &subscription.Subscription{ - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{currency.Pair{Base: assetPairs[a][z].Base}}, - Asset: a, - }) - currencyPairsName[assetPairs[a][z].Base] = true - } - if okay = currencyPairsName[assetPairs[a][z].Quote]; !okay { - subscriptions = append(subscriptions, &subscription.Subscription{ - Channel: subscriptionChannels[x], - Pairs: currency.Pairs{currency.Pair{Base: assetPairs[a][z].Quote}}, - Asset: a, - }) - currencyPairsName[assetPairs[a][z].Quote] = true - } - } - } - case announcementsChannel, - userAccessLogChannel, - platformStateChannel, - userLockChannel, - platformStatePublicMethodsStateChannel: - subscriptions = append(subscriptions, &subscription.Subscription{ - Channel: subscriptionChannels[x], - }) - case priceIndexChannel, - priceRankingChannel, - priceStatisticsChannel, - volatilityIndexChannel, - markPriceOptionsChannel, - estimatedExpirationPriceChannel: - for i := range indexENUMS { - subscriptions = append(subscriptions, &subscription.Subscription{ - Channel: subscriptionChannels[x], - Params: map[string]interface{}{ - "index_name": indexENUMS[i], - }, - }) - } - } - } - return subscriptions, nil +// generateSubscriptions returns a list of configured subscriptions +func (d *Deribit) generateSubscriptions() (subscription.List, error) { + return d.Features.Subscriptions.ExpandTemplates(d) } -func (d *Deribit) generatePayloadFromSubscriptionInfos(operation string, subscs subscription.List) ([]WsSubscriptionInput, error) { - subscriptionPayloads := make([]WsSubscriptionInput, len(subscs)) - for x := range subscs { - if len(subscs[x].Pairs) > 1 { - return nil, subscription.ErrNotSinglePair - } - sub := WsSubscriptionInput{ - JSONRPCVersion: rpcVersion, - ID: d.Websocket.Conn.GenerateMessageID(false), - Method: "public/" + operation, - Params: map[string][]string{}, - } - switch subscs[x].Channel { - case userAccessLogChannel, userChangesInstrumentsChannel, userChangesCurrencyChannel, userLockChannel, userMMPTriggerChannel, rawUserOrdersChannel, - userOrdersWithIntervalChannel, rawUsersOrdersKindCurrencyChannel, userPortfolioChannel, userTradesChannelByInstrument, userTradesByKindCurrencyAndIntervalChannel: - if !d.Websocket.CanUseAuthenticatedEndpoints() { - continue - } - sub.Method = "private/" + operation - } - var instrumentID string - if len(subscs[x].Pairs) == 1 { - pairFormat, err := d.GetPairFormat(subscs[x].Asset, true) - if err != nil { - return nil, err - } - subscs[x].Pairs = subscs[x].Pairs.Format(pairFormat) - if subscs[x].Asset == asset.Futures { - instrumentID = d.formatFuturesTradablePair(subscs[x].Pairs[0]) - } else { - instrumentID = subscs[x].Pairs.Join() - } - } - switch subscs[x].Channel { - case announcementsChannel, - userAccessLogChannel, - platformStateChannel, - platformStatePublicMethodsStateChannel, - userLockChannel: - sub.Params["channels"] = []string{subscs[x].Channel} - case orderbookChannel: - if len(subscs[x].Pairs) != 1 { - return nil, currency.ErrCurrencyPairEmpty - } - intervalString, err := d.GetResolutionFromInterval(subscs[x].Interval) - if err != nil { - return nil, err - } - group, okay := subscs[x].Params["group"].(string) - if !okay { - sub.Params["channels"] = []string{orderbookChannel + "." + instrumentID + "." + intervalString} - break - } - depth, okay := subscs[x].Params["depth"].(string) - if !okay { - sub.Params["channels"] = []string{orderbookChannel + "." + instrumentID + "." + intervalString} - break - } - sub.Params["channels"] = []string{orderbookChannel + "." + instrumentID + "." + group + "." + depth + "." + intervalString} - case chartTradesChannel: - if len(subscs[x].Pairs) != 1 { - return nil, currency.ErrCurrencyPairEmpty - } - resolution, okay := subscs[x].Params["resolution"].(string) - if !okay { - resolution = "1D" - } - sub.Params["channels"] = []string{chartTradesChannel + "." + d.formatFuturesTradablePair(subscs[x].Pairs[0]) + "." + resolution} - case priceIndexChannel, - priceRankingChannel, - priceStatisticsChannel, - volatilityIndexChannel, - markPriceOptionsChannel, - estimatedExpirationPriceChannel: - indexName, okay := subscs[x].Params["index_name"].(string) - if !okay { - return nil, errUnsupportedIndexName - } - sub.Params["channels"] = []string{subscs[x].Channel + "." + indexName} - case instrumentStateChannel: - if len(subscs[x].Pairs) != 1 { - return nil, currency.ErrCurrencyPairEmpty - } - kind := d.GetAssetKind(subscs[x].Asset) - currencyCode := getValidatedCurrencyCode(subscs[x].Pairs[0]) - sub.Params["channels"] = []string{"instrument.state." + kind + "." + currencyCode} - case rawUsersOrdersKindCurrencyChannel: - if len(subscs[x].Pairs) != 1 { - return nil, currency.ErrCurrencyPairEmpty - } - kind := d.GetAssetKind(subscs[x].Asset) - currencyCode := getValidatedCurrencyCode(subscs[x].Pairs[0]) - sub.Params["channels"] = []string{"user.orders." + kind + "." + currencyCode + ".raw"} - case quoteChannel, - incrementalTickerChannel: - if len(subscs[x].Pairs) != 1 { - return nil, currency.ErrCurrencyPairEmpty - } - sub.Params["channels"] = []string{subscs[x].Channel + "." + instrumentID} - case rawUserOrdersChannel: - if len(subscs[x].Pairs) != 1 { - return nil, currency.ErrCurrencyPairEmpty - } - sub.Params["channels"] = []string{"user.orders." + instrumentID + ".raw"} - case requestForQuoteChannel, - userMMPTriggerChannel, - userPortfolioChannel: - if len(subscs[x].Pairs) != 1 { - return nil, currency.ErrCurrencyPairEmpty - } - currencyCode := getValidatedCurrencyCode(subscs[x].Pairs[0]) - sub.Params["channels"] = []string{subscs[x].Channel + "." + currencyCode} - case tradesChannel, - userChangesInstrumentsChannel, - userOrdersWithIntervalChannel, - tickerChannel, - perpetualChannel, - userTradesChannelByInstrument: - if len(subscs[x].Pairs) != 1 { - return nil, currency.ErrCurrencyPairEmpty - } - if subscs[x].Interval.Duration() == 0 { - sub.Params["channels"] = []string{subscs[x].Channel + instrumentID} - continue - } - intervalString, err := d.GetResolutionFromInterval(subscs[x].Interval) - if err != nil { - return nil, err - } - sub.Params["channels"] = []string{subscs[x].Channel + instrumentID + "." + intervalString} - case userChangesCurrencyChannel, - tradesWithKindChannel, - rawUsersOrdersWithKindCurrencyAndIntervalChannel, - userTradesByKindCurrencyAndIntervalChannel: - kind := d.GetAssetKind(subscs[x].Asset) - if len(subscs[x].Pairs) != 1 { - return nil, currency.ErrCurrencyPairEmpty - } - currencyCode := getValidatedCurrencyCode(subscs[x].Pairs[0]) - if subscs[x].Interval.Duration() == 0 { - sub.Params["channels"] = []string{subscs[x].Channel + "." + kind + "." + currencyCode} - continue - } - intervalString, err := d.GetResolutionFromInterval(subscs[x].Interval) - if err != nil { - return nil, err - } - sub.Params["channels"] = []string{subscs[x].Channel + "." + kind + "." + currencyCode + "." + intervalString} - default: - return nil, errUnsupportedChannel - } - subscriptionPayloads[x] = sub - } - return filterSubscriptionPayloads(subscriptionPayloads), nil +// GetSubscriptionTemplate returns a subscription channel template +func (d *Deribit) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) { + return template.New("master.tmpl").Funcs(template.FuncMap{ + "channelName": channelName, + "interval": channelInterval, + "isSymbolChannel": isSymbolChannel, + }). + Parse(subTplText) } // Subscribe sends a websocket message to receive data from the channel -func (d *Deribit) Subscribe(channelsToSubscribe subscription.List) error { - return d.handleSubscription("subscribe", channelsToSubscribe) +func (d *Deribit) Subscribe(subs subscription.List) error { + errs := d.handleSubscription("public/subscribe", subs.Public()) + return common.AppendError(errs, + d.handleSubscription("private/subscribe", subs.Private()), + ) } // Unsubscribe sends a websocket message to stop receiving data from the channel -func (d *Deribit) Unsubscribe(channelsToUnsubscribe subscription.List) error { - return d.handleSubscription("unsubscribe", channelsToUnsubscribe) +func (d *Deribit) Unsubscribe(subs subscription.List) error { + errs := d.handleSubscription("public/unsubscribe", subs.Public()) + return common.AppendError(errs, + d.handleSubscription("private/unsubscribe", subs.Private()), + ) } -func filterSubscriptionPayloads(subscription []WsSubscriptionInput) []WsSubscriptionInput { - newSubscriptionsMap := map[string]bool{} - newSubscs := make([]WsSubscriptionInput, 0, len(subscription)) - for x := range subscription { - if len(subscription[x].Params["channels"]) == 0 { - continue - } - if !newSubscriptionsMap[subscription[x].Params["channels"][0]] { - newSubscriptionsMap[subscription[x].Params["channels"][0]] = true - newSubscs = append(newSubscs, subscription[x]) - } +func (d *Deribit) handleSubscription(method string, subs subscription.List) error { + var err error + subs, err = subs.ExpandTemplates(d) + if err != nil || len(subs) == 0 { + return err } - return newSubscs -} - -func (d *Deribit) handleSubscription(operation string, channels subscription.List) error { - payloads, err := d.generatePayloadFromSubscriptionInfos(operation, channels) + r := WsSubscriptionInput{ + JSONRPCVersion: rpcVersion, + ID: d.Websocket.Conn.GenerateMessageID(false), + Method: method, + Params: map[string][]string{ + "channels": subs.QualifiedChannels(), + }, + } + data, err := d.Websocket.Conn.SendMessageReturnResponse(context.TODO(), request.Unset, r.ID, r) if err != nil { return err } - for x := range payloads { - data, err := d.Websocket.Conn.SendMessageReturnResponse(context.TODO(), request.Unset, payloads[x].ID, payloads[x]) - if err != nil { - return err - } - var response wsSubscriptionResponse - err = json.Unmarshal(data, &response) - if err != nil { - return fmt.Errorf("%v %v", d.Name, err) - } - if payloads[x].ID == response.ID && len(response.Result) == 0 { - log.Errorf(log.ExchangeSys, "subscription to channel %s was not successful", payloads[x].Params["channels"][0]) + var response wsSubscriptionResponse + err = json.Unmarshal(data, &response) + if err != nil { + return fmt.Errorf("%v %v", d.Name, err) + } + subAck := map[string]bool{} + for _, c := range response.Result { + subAck[c] = true + } + if len(subAck) != len(subs) { + err = common.ErrUnknownError + } + for _, s := range subs { + if _, ok := subAck[s.QualifiedChannel]; ok { + err = common.AppendError(err, d.Websocket.AddSuccessfulSubscriptions(d.Websocket.Conn, s)) + } else { + err = common.AppendError(err, errors.New(s.String())) } } - return nil + return err } func getValidatedCurrencyCode(pair currency.Pair) string { @@ -1199,3 +856,61 @@ func getValidatedCurrencyCode(pair currency.Pair) string { return "any" } } + +func channelName(s *subscription.Subscription) string { + if name, ok := subscriptionNames[s.Channel]; ok { + return name + } + panic(fmt.Errorf("%w: %s", subscription.ErrNotSupported, s.Channel)) +} + +// channelInterval converts an interval to an exchange specific interval +// We convert 1s to agg2; Docs do not explain agg2 but support explained that it may vary under load but is currently 1 second +func channelInterval(s *subscription.Subscription) string { + if s.Interval != 0 { + if channelName(s) == chartTradesChannel { + if s.Interval == kline.OneDay { + return "1D" + } + m := s.Interval.Duration().Minutes() + switch m { + case 1, 3, 5, 10, 15, 30, 60, 120, 180, 360, 720: // Valid Minute intervals + return strconv.Itoa(int(m)) + } + panic(fmt.Errorf("%w: %s", kline.ErrUnsupportedInterval, s.Interval)) + } + switch s.Interval { + case kline.ThousandMilliseconds: + return "agg2" + case kline.HundredMilliseconds, kline.Raw: + return s.Interval.Short() + } + panic(fmt.Errorf("%w: %s", kline.ErrUnsupportedInterval, s.Interval)) + } + return "" +} + +func isSymbolChannel(s *subscription.Subscription) bool { + switch channelName(s) { + case orderbookChannel, chartTradesChannel, tickerChannel, tradesChannel, perpetualChannel, quoteChannel, + userChangesInstrumentsChannel, incrementalTickerChannel, userOrdersChannel, userTradesChannel: + return true + } + return false +} + +const subTplText = ` +{{- if isSymbolChannel $.S -}} + {{- range $asset, $pairs := $.AssetPairs }} + {{- range $p := $pairs }} + {{- channelName $.S -}} . {{- $p }} + {{- with $i := interval $.S -}} . {{- $i }}{{ end }} + {{- $.PairSeparator }} + {{- end }} + {{- $.AssetSeparator }} + {{- end }} +{{- else }} + {{- channelName $.S -}} + {{- with $i := interval $.S -}} . {{- $i }}{{ end }} +{{- end }} +` diff --git a/exchanges/deribit/deribit_wrapper.go b/exchanges/deribit/deribit_wrapper.go index 38023875caa..c3bce491083 100644 --- a/exchanges/deribit/deribit_wrapper.go +++ b/exchanges/deribit/deribit_wrapper.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "regexp" "sort" "strconv" "strings" @@ -34,27 +33,6 @@ import ( "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" ) -// GetDefaultConfig returns a default exchange config -func (d *Deribit) GetDefaultConfig(ctx context.Context) (*config.Exchange, error) { - d.SetDefaults() - exchCfg, err := d.GetStandardConfig() - if err != nil { - return nil, err - } - - err = d.SetupDefaults(exchCfg) - if err != nil { - return nil, err - } - if d.Features.Supports.RESTCapabilities.AutoPairUpdates { - err := d.UpdateTradablePairs(ctx, true) - if err != nil { - return nil, err - } - } - return exchCfg, nil -} - // SetDefaults sets the basic defaults for Deribit func (d *Deribit) SetDefaults() { d.Name = "Deribit" @@ -148,6 +126,7 @@ func (d *Deribit) SetDefaults() { GlobalResultLimit: 500, }, }, + Subscriptions: defaultSubscriptions.Clone(), } d.Requester, err = request.New(d.Name, common.NewHTTPClientWithTimeout(exchange.DefaultHTTPTimeout), @@ -197,7 +176,7 @@ func (d *Deribit) Setup(exch *config.Exchange) error { Connector: d.WsConnect, Subscriber: d.Subscribe, Unsubscriber: d.Unsubscribe, - GenerateSubscriptions: d.GenerateDefaultSubscriptions, + GenerateSubscriptions: d.generateSubscriptions, Features: &d.Features.Supports.WebsocketCapabilities, OrderbookBufferConfig: buffer.Config{ SortBuffer: true, @@ -208,9 +187,6 @@ func (d *Deribit) Setup(exch *config.Exchange) error { return err } - // setup option decimal regex at startup to make constant checks more efficient - optionRegex = regexp.MustCompile(optionDecimalRegex) - return d.Websocket.SetupNewConnection(&stream.ConnectionSetup{ URL: d.Websocket.GetWebsocketURL(), ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, diff --git a/exchanges/deribit/deribit_websocket_eps.go b/exchanges/deribit/deribit_ws_endpoints.go similarity index 100% rename from exchanges/deribit/deribit_websocket_eps.go rename to exchanges/deribit/deribit_ws_endpoints.go diff --git a/exchanges/exchange.go b/exchanges/exchange.go index 7d5b1df4594..63ecb7517f5 100644 --- a/exchanges/exchange.go +++ b/exchanges/exchange.go @@ -975,8 +975,7 @@ func (b *Base) SupportsAsset(a asset.Item) bool { // PrintEnabledPairs prints the exchanges enabled asset pairs func (b *Base) PrintEnabledPairs() { for k, v := range b.CurrencyPairs.Pairs { - log.Infof(log.ExchangeSys, "%s Asset type %v:\n\t Enabled pairs: %v", - b.Name, strings.ToUpper(k.String()), v.Enabled) + log.Infof(log.ExchangeSys, "%s Asset type %v:\n\t Enabled pairs: %v", b.Name, strings.ToUpper(k.String()), v.Enabled) } } @@ -987,10 +986,7 @@ func (b *Base) GetBase() *Base { return b } // for validation of API credentials func (b *Base) CheckTransientError(err error) error { if _, ok := err.(net.Error); ok { - log.Warnf(log.ExchangeSys, - "%s net error captured, will not disable authentication %s", - b.Name, - err) + log.Warnf(log.ExchangeSys, "%s net error captured, will not disable authentication %s", b.Name, err) return nil } return err diff --git a/exchanges/gateio/gateio.go b/exchanges/gateio/gateio.go index e4187d606fa..a0658d44ac7 100644 --- a/exchanges/gateio/gateio.go +++ b/exchanges/gateio/gateio.go @@ -10,7 +10,6 @@ import ( "fmt" "net/http" "net/url" - "slices" "strconv" "strings" "time" @@ -776,12 +775,11 @@ func (g *Gateio) CancelSingleSpotOrder(ctx context.Context, orderID, currencyPai return response, g.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, spotCancelSingleOrderEPL, http.MethodDelete, gateioSpotOrders+"/"+orderID, params, nil, &response) } -// GateIOGetPersonalTradingHistory retrieves personal trading history -func (g *Gateio) GateIOGetPersonalTradingHistory(ctx context.Context, currencyPair currency.Pair, - orderID string, page, limit uint64, crossMarginAccount bool, from, to time.Time) ([]SpotPersonalTradeHistory, error) { +// GetMySpotTradingHistory retrieves personal trading history +func (g *Gateio) GetMySpotTradingHistory(ctx context.Context, p currency.Pair, orderID string, page, limit uint64, crossMargin bool, from, to time.Time) ([]SpotPersonalTradeHistory, error) { params := url.Values{} - if currencyPair.IsPopulated() { - params.Set("currency_pair", currencyPair.String()) + if p.IsPopulated() { + params.Set("currency_pair", p.String()) } if orderID != "" { params.Set("order_id", orderID) @@ -792,7 +790,7 @@ func (g *Gateio) GateIOGetPersonalTradingHistory(ctx context.Context, currencyPa if page > 0 { params.Set("page", strconv.FormatUint(page, 10)) } - if crossMarginAccount { + if crossMargin { params.Set("account", asset.CrossMargin.String()) } if !from.IsZero() { @@ -1843,8 +1841,8 @@ func (g *Gateio) GetAllFutureContracts(ctx context.Context, settle currency.Code return contracts, g.SendHTTPRequest(ctx, exchange.RestSpot, publicFuturesContractsEPL, futuresPath+settle.Item.Lower+"/contracts", &contracts) } -// GetSingleContract returns a single contract info for the specified settle and Currency Pair (contract << in this case) -func (g *Gateio) GetSingleContract(ctx context.Context, settle currency.Code, contract string) (*FuturesContract, error) { +// GetFuturesContract returns a single futures contract info for the specified settle and Currency Pair (contract << in this case) +func (g *Gateio) GetFuturesContract(ctx context.Context, settle currency.Code, contract string) (*FuturesContract, error) { if contract == "" { return nil, currency.ErrCurrencyPairEmpty } @@ -2429,8 +2427,8 @@ func (g *Gateio) AmendFuturesOrder(ctx context.Context, settle currency.Code, or return response, g.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, perpetualAmendOrderEPL, http.MethodPut, futuresPath+settle.Item.Lower+"/orders/"+orderID, nil, &arg, &response) } -// GetMyPersonalTradingHistory retrieves my personal trading history -func (g *Gateio) GetMyPersonalTradingHistory(ctx context.Context, settle currency.Code, lastID, orderID string, contract currency.Pair, limit, offset, countTotal uint64) ([]TradingHistoryItem, error) { +// GetMyFuturesTradingHistory retrieves authenticated account's futures trading history +func (g *Gateio) GetMyFuturesTradingHistory(ctx context.Context, settle currency.Code, lastID, orderID string, contract currency.Pair, limit, offset, countTotal uint64) ([]TradingHistoryItem, error) { if settle.IsEmpty() { return nil, errEmptyOrInvalidSettlementCurrency } @@ -2624,8 +2622,8 @@ func (g *Gateio) GetAllDeliveryContracts(ctx context.Context, settle currency.Co return contracts, g.SendHTTPRequest(ctx, exchange.RestSpot, publicDeliveryContractsEPL, deliveryPath+settle.Item.Lower+"/contracts", &contracts) } -// GetSingleDeliveryContracts retrieves a single delivery contract instance. -func (g *Gateio) GetSingleDeliveryContracts(ctx context.Context, settle currency.Code, contract currency.Pair) (*DeliveryContract, error) { +// GetDeliveryContract retrieves a single delivery contract instance +func (g *Gateio) GetDeliveryContract(ctx context.Context, settle currency.Code, contract currency.Pair) (*DeliveryContract, error) { if settle.IsEmpty() { return nil, errEmptyOrInvalidSettlementCurrency } @@ -2657,7 +2655,7 @@ func (g *Gateio) GetDeliveryOrderbook(ctx context.Context, settle currency.Code, } // GetDeliveryTradingHistory retrieves futures trading history -func (g *Gateio) GetDeliveryTradingHistory(ctx context.Context, settle currency.Code, lastID string, contract currency.Pair, limit uint64, from, to time.Time) ([]DeliveryTradingHistory, error) { +func (g *Gateio) GetDeliveryTradingHistory(ctx context.Context, settle currency.Code, lastID string, contract currency.Pair, limit uint64, from, to time.Time) ([]TradingHistoryItem, error) { if settle.IsEmpty() { return nil, errEmptyOrInvalidSettlementCurrency } @@ -2678,7 +2676,7 @@ func (g *Gateio) GetDeliveryTradingHistory(ctx context.Context, settle currency. if lastID != "" { params.Set("last_id", lastID) } - var histories []DeliveryTradingHistory + var histories []TradingHistoryItem return histories, g.SendHTTPRequest(ctx, exchange.RestSpot, publicTradingHistoryDeliveryEPL, common.EncodeURLValues(deliveryPath+settle.Item.Lower+"/trades", params), &histories) } @@ -2792,7 +2790,7 @@ func (g *Gateio) GetSingleDeliveryPosition(ctx context.Context, settle currency. // UpdateDeliveryPositionMargin updates position margin func (g *Gateio) UpdateDeliveryPositionMargin(ctx context.Context, settle currency.Code, change float64, contract currency.Pair) (*Position, error) { - if !slices.Contains(settlementCurrencies, settle) { + if settle.IsEmpty() { return nil, errEmptyOrInvalidSettlementCurrency } if contract.IsInvalid() { @@ -2809,7 +2807,7 @@ func (g *Gateio) UpdateDeliveryPositionMargin(ctx context.Context, settle curren // UpdateDeliveryPositionLeverage updates position leverage func (g *Gateio) UpdateDeliveryPositionLeverage(ctx context.Context, settle currency.Code, contract currency.Pair, leverage float64) (*Position, error) { - if !slices.Contains(settlementCurrencies, settle) { + if settle.IsEmpty() { return nil, errEmptyOrInvalidSettlementCurrency } if contract.IsInvalid() { @@ -2827,7 +2825,7 @@ func (g *Gateio) UpdateDeliveryPositionLeverage(ctx context.Context, settle curr // UpdateDeliveryPositionRiskLimit update position risk limit func (g *Gateio) UpdateDeliveryPositionRiskLimit(ctx context.Context, settle currency.Code, contract currency.Pair, riskLimit uint64) (*Position, error) { - if !slices.Contains(settlementCurrencies, settle) { + if settle.IsEmpty() { return nil, errEmptyOrInvalidSettlementCurrency } if contract.IsInvalid() { @@ -2920,7 +2918,7 @@ func (g *Gateio) CancelMultipleDeliveryOrders(ctx context.Context, contract curr // GetSingleDeliveryOrder Get a single order // Zero-filled order cannot be retrieved 10 minutes after order cancellation func (g *Gateio) GetSingleDeliveryOrder(ctx context.Context, settle currency.Code, orderID string) (*Order, error) { - if !slices.Contains(settlementCurrencies, settle) { + if settle.IsEmpty() { return nil, errEmptyOrInvalidSettlementCurrency } if orderID == "" { @@ -2942,8 +2940,8 @@ func (g *Gateio) CancelSingleDeliveryOrder(ctx context.Context, settle currency. return response, g.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, deliveryCancelOrderEPL, http.MethodDelete, deliveryPath+settle.Item.Lower+"/orders/"+orderID, nil, nil, &response) } -// GetDeliveryPersonalTradingHistory retrieves personal trading history -func (g *Gateio) GetDeliveryPersonalTradingHistory(ctx context.Context, settle currency.Code, orderID string, contract currency.Pair, limit, offset, countTotal uint64, lastID string) ([]TradingHistoryItem, error) { +// GetMyDeliveryTradingHistory retrieves authenticated account delivery futures trading history +func (g *Gateio) GetMyDeliveryTradingHistory(ctx context.Context, settle currency.Code, orderID string, contract currency.Pair, limit, offset, countTotal uint64, lastID string) ([]TradingHistoryItem, error) { if settle.IsEmpty() { return nil, errEmptyOrInvalidSettlementCurrency } @@ -3408,8 +3406,8 @@ func (g *Gateio) CancelOptionSingleOrder(ctx context.Context, orderID string) (* return response, g.SendAuthenticatedHTTPRequest(ctx, exchange.RestSpot, optionsCancelOrderEPL, http.MethodDelete, "options/orders/"+orderID, nil, nil, &response) } -// GetOptionsPersonalTradingHistory retrieves personal tradign histories given the underlying{Required}, contract, and other pagination params. -func (g *Gateio) GetOptionsPersonalTradingHistory(ctx context.Context, underlying string, contract currency.Pair, offset, limit uint64, from, to time.Time) ([]OptionTradingHistory, error) { +// GetMyOptionsTradingHistory retrieves authenticated account's option trading history +func (g *Gateio) GetMyOptionsTradingHistory(ctx context.Context, underlying string, contract currency.Pair, offset, limit uint64, from, to time.Time) ([]OptionTradingHistory, error) { if underlying == "" { return nil, errInvalidUnderlying } diff --git a/exchanges/gateio/gateio_test.go b/exchanges/gateio/gateio_test.go index 80f7a81b099..5c0468567ff 100644 --- a/exchanges/gateio/gateio_test.go +++ b/exchanges/gateio/gateio_test.go @@ -439,12 +439,11 @@ func TestCancelSingleSpotOrder(t *testing.T) { } } -func TestGetPersonalTradingHistory(t *testing.T) { +func TestGetMySpotTradingHistory(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - if _, err := g.GateIOGetPersonalTradingHistory(context.Background(), currency.Pair{Base: currency.BTC, Quote: currency.USDT, Delimiter: currency.UnderscoreDelimiter}, "", 0, 0, false, time.Time{}, time.Time{}); err != nil { - t.Errorf("%s GetPersonalTradingHistory() error %v", g.Name, err) - } + _, err := g.GetMySpotTradingHistory(context.Background(), currency.Pair{Base: currency.BTC, Quote: currency.USDT, Delimiter: currency.UnderscoreDelimiter}, "", 0, 0, false, time.Time{}, time.Time{}) + require.NoError(t, err) } func TestGetServerTime(t *testing.T) { @@ -968,12 +967,12 @@ func TestGetAllFutureContracts(t *testing.T) { } } -func TestGetSingleContract(t *testing.T) { +func TestGetFuturesContract(t *testing.T) { t.Parallel() settle, err := getSettlementFromCurrency(getPair(t, asset.Futures)) require.NoError(t, err, "getSettlementFromCurrency must not error") - _, err = g.GetSingleContract(context.Background(), settle, getPair(t, asset.Futures).String()) - assert.NoError(t, err, "GetSingleContract should not error") + _, err = g.GetFuturesContract(context.Background(), settle, getPair(t, asset.Futures).String()) + assert.NoError(t, err, "GetFuturesContract should not error") } func TestGetFuturesOrderbook(t *testing.T) { @@ -1142,29 +1141,25 @@ func TestCancelMultipleDeliveryOrders(t *testing.T) { func TestGetSingleDeliveryOrder(t *testing.T) { t.Parallel() - _, err := g.GetSingleDeliveryOrder(context.Background(), currency.USD, "123456") + _, err := g.GetSingleDeliveryOrder(context.Background(), currency.EMPTYCODE, "123456") assert.ErrorIs(t, err, errEmptyOrInvalidSettlementCurrency, "GetSingleDeliveryOrder should return errEmptyOrInvalidSettlementCurrency") sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - for _, settle := range settlementCurrencies { - _, err := g.GetSingleDeliveryOrder(context.Background(), settle, "123456") - assert.NoErrorf(t, err, "GetSingleDeliveryOrder %s should not error", settle) - } + _, err = g.GetSingleDeliveryOrder(context.Background(), currency.USDT, "123456") + assert.NoError(t, err, "GetSingleDeliveryOrder should not error") } func TestCancelSingleDeliveryOrder(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) - for _, settle := range settlementCurrencies { - _, err := g.CancelSingleDeliveryOrder(context.Background(), settle, "123456") - assert.NoErrorf(t, err, "CancelSingleDeliveryOrder %s should not error", settle) - } + _, err := g.CancelSingleDeliveryOrder(context.Background(), currency.USDT, "123456") + assert.NoError(t, err, "CancelSingleDeliveryOrder should not error") } -func TestGetDeliveryPersonalTradingHistory(t *testing.T) { +func TestGetMyDeliveryTradingHistory(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - _, err := g.GetDeliveryPersonalTradingHistory(context.Background(), currency.USDT, "", getPair(t, asset.DeliveryFutures), 0, 0, 1, "") - assert.NoError(t, err, "GetDeliveryPersonalTradingHistory should not error") + _, err := g.GetMyDeliveryTradingHistory(context.Background(), currency.USDT, "", getPair(t, asset.DeliveryFutures), 0, 0, 1, "") + assert.NoError(t, err, "GetMyDeliveryTradingHistory should not error") } func TestGetDeliveryPositionCloseHistory(t *testing.T) { @@ -1225,7 +1220,7 @@ func TestCancelAllDeliveryPriceTriggeredOrder(t *testing.T) { func TestGetSingleDeliveryPriceTriggeredOrder(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - _, err := g.GetSingleDeliveryPriceTriggeredOrder(context.Background(), currency.BTC, "12345") + _, err := g.GetSingleDeliveryPriceTriggeredOrder(context.Background(), currency.USDT, "12345") assert.NoError(t, err, "GetSingleDeliveryPriceTriggeredOrder should not error") } @@ -1372,11 +1367,11 @@ func TestAmendFuturesOrder(t *testing.T) { assert.NoError(t, err, "AmendFuturesOrder should not error") } -func TestGetMyPersonalTradingHistory(t *testing.T) { +func TestGetMyFuturesTradingHistory(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - _, err := g.GetMyPersonalTradingHistory(context.Background(), currency.BTC, "", "", getPair(t, asset.Futures), 0, 0, 0) - assert.NoError(t, err, "GetMyPersonalTradingHistory should not error") + _, err := g.GetMyFuturesTradingHistory(context.Background(), currency.BTC, "", "", getPair(t, asset.Futures), 0, 0, 0) + assert.NoError(t, err, "GetMyFuturesTradingHistory should not error") } func TestGetFuturesPositionCloseHistory(t *testing.T) { @@ -1450,16 +1445,23 @@ func TestCancelAllFuturesOpenOrders(t *testing.T) { func TestGetAllDeliveryContracts(t *testing.T) { t.Parallel() - _, err := g.GetAllDeliveryContracts(context.Background(), currency.USDT) - assert.NoError(t, err, "GetAllDeliveryContracts should not error") + r, err := g.GetAllDeliveryContracts(context.Background(), currency.USDT) + require.NoError(t, err, "GetAllDeliveryContracts must not error") + assert.NotEmpty(t, r, "GetAllDeliveryContracts should return data") + r, err = g.GetAllDeliveryContracts(context.Background(), currency.BTC) + require.NoError(t, err, "GetAllDeliveryContracts must not error") + // The test below will fail if support for BTC settlement is added. This is intentional, as it ensures we are alerted when it's time to reintroduce support + if !assert.Empty(t, r, "GetAllDeliveryContracts should not return any data with unsupported settlement currency BTC") { + t.Error("BTC settlement for delivery futures appears to be supported again by the API. Please raise an issue to reintroduce BTC support for this exchange") + } } -func TestGetSingleDeliveryContracts(t *testing.T) { +func TestGetDeliveryContract(t *testing.T) { t.Parallel() settle, err := getSettlementFromCurrency(getPair(t, asset.DeliveryFutures)) require.NoError(t, err, "getSettlementFromCurrency must not error") - _, err = g.GetSingleDeliveryContracts(context.Background(), settle, getPair(t, asset.DeliveryFutures)) - assert.NoError(t, err, "GetSingleDeliveryContracts should not error") + _, err = g.GetDeliveryContract(context.Background(), settle, getPair(t, asset.DeliveryFutures)) + assert.NoError(t, err, "GetDeliveryContract should not error") } func TestGetDeliveryOrderbook(t *testing.T) { @@ -1527,6 +1529,8 @@ func TestGetSingleDeliveryPosition(t *testing.T) { func TestUpdateDeliveryPositionMargin(t *testing.T) { t.Parallel() + _, err := g.UpdateDeliveryPositionMargin(context.Background(), currency.EMPTYCODE, 0.001, currency.Pair{}) + assert.ErrorIs(t, err, errEmptyOrInvalidSettlementCurrency) sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) settle, err := getSettlementFromCurrency(getPair(t, asset.DeliveryFutures)) require.NoError(t, err, "getSettlementFromCurrency must not error") @@ -1536,15 +1540,19 @@ func TestUpdateDeliveryPositionMargin(t *testing.T) { func TestUpdateDeliveryPositionLeverage(t *testing.T) { t.Parallel() + _, err := g.UpdateDeliveryPositionLeverage(context.Background(), currency.EMPTYCODE, currency.Pair{}, 0.001) + assert.ErrorIs(t, err, errEmptyOrInvalidSettlementCurrency) sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) - _, err := g.UpdateDeliveryPositionLeverage(context.Background(), currency.USDT, getPair(t, asset.DeliveryFutures), 0.001) + _, err = g.UpdateDeliveryPositionLeverage(context.Background(), currency.USDT, getPair(t, asset.DeliveryFutures), 0.001) assert.NoError(t, err, "UpdateDeliveryPositionLeverage should not error") } func TestUpdateDeliveryPositionRiskLimit(t *testing.T) { t.Parallel() + _, err := g.UpdateDeliveryPositionRiskLimit(context.Background(), currency.EMPTYCODE, currency.Pair{}, 0) + assert.ErrorIs(t, err, errEmptyOrInvalidSettlementCurrency) sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) - _, err := g.UpdateDeliveryPositionRiskLimit(context.Background(), currency.USDT, getPair(t, asset.DeliveryFutures), 30) + _, err = g.UpdateDeliveryPositionRiskLimit(context.Background(), currency.USDT, getPair(t, asset.DeliveryFutures), 30) assert.NoError(t, err, "UpdateDeliveryPositionRiskLimit should not error") } @@ -1758,12 +1766,12 @@ func TestCancelSingleOrder(t *testing.T) { } } -func TestGetOptionsPersonalTradingHistory(t *testing.T) { +func TestGetMyOptionsTradingHistory(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, g) - if _, err := g.GetOptionsPersonalTradingHistory(context.Background(), "BTC_USDT", currency.EMPTYPAIR, 0, 0, time.Time{}, time.Time{}); err != nil { - t.Errorf("%s GetOptionPersonalTradingHistory() error %v", g.Name, err) - } + _, err := g.GetMyOptionsTradingHistory(context.Background(), "BTC_USDT", currency.EMPTYPAIR, 0, 0, time.Time{}, time.Time{}) + require.NoError(t, err) } func TestWithdrawCurrency(t *testing.T) { @@ -3177,22 +3185,20 @@ func TestForceFileStandard(t *testing.T) { func TestGetFuturesContractDetails(t *testing.T) { t.Parallel() _, err := g.GetFuturesContractDetails(context.Background(), asset.Spot) - if !errors.Is(err, futures.ErrNotFuturesAsset) { - t.Error(err) - } + require.ErrorIs(t, err, futures.ErrNotFuturesAsset) + _, err = g.GetFuturesContractDetails(context.Background(), asset.PerpetualContract) - if !errors.Is(err, asset.ErrNotSupported) { - t.Error(err) - } + require.ErrorIs(t, err, asset.ErrNotSupported) - _, err = g.GetFuturesContractDetails(context.Background(), asset.DeliveryFutures) - if !errors.Is(err, nil) { - t.Error(err) - } - _, err = g.GetFuturesContractDetails(context.Background(), asset.Futures) - if !errors.Is(err, nil) { - t.Error(err) - } + exp, err := g.GetAllDeliveryContracts(context.Background(), currency.USDT) + require.NoError(t, err, "GetAllDeliveryContracts must not error") + c, err := g.GetFuturesContractDetails(context.Background(), asset.DeliveryFutures) + require.NoError(t, err, "GetFuturesContractDetails must not error for DeliveryFutures") + assert.Equal(t, len(exp), len(c), "GetFuturesContractDetails should return same number of Delivery contracts as exist") + + c, err = g.GetFuturesContractDetails(context.Background(), asset.Futures) + require.NoError(t, err, "GetFuturesContractDetails must not error for DeliveryFutures") + assert.NotEmpty(t, c, "GetFuturesContractDetails should return same number of Future contracts as exist") } func TestGetLatestFundingRates(t *testing.T) { @@ -3519,3 +3525,28 @@ func TestHandleSubscriptions(t *testing.T) { }) require.NoError(t, err) } + +func TestParseWSHeader(t *testing.T) { + in := []string{ + `{"time":1726121320,"time_ms":1726121320745,"id":1,"channel":"spot.tickers","event":"subscribe","result":{"status":"success"},"request_id":"a4"}`, + `{"time_ms":1726121320746,"id":2,"channel":"spot.tickers","event":"subscribe","result":{"status":"success"},"request_id":"a4"}`, + `{"time":1726121321,"id":3,"channel":"spot.tickers","event":"subscribe","result":{"status":"success"},"request_id":"a4"}`, + } + for _, i := range in { + h, err := parseWSHeader([]byte(i)) + require.NoError(t, err) + require.NotEmpty(t, h.ID) + assert.Equal(t, "a4", h.RequestID) + assert.Equal(t, "spot.tickers", h.Channel) + assert.Equal(t, "subscribe", h.Event) + assert.NotEmpty(t, h.Result) + switch h.ID { + case 1: + assert.Equal(t, int64(1726121320745), h.Time.UnixMilli()) + case 2: + assert.Equal(t, int64(1726121320746), h.Time.UnixMilli()) + case 3: + assert.Equal(t, int64(1726121321), h.Time.Unix()) + } + } +} diff --git a/exchanges/gateio/gateio_types.go b/exchanges/gateio/gateio_types.go index 80a2c8b8df3..3e2d7520237 100644 --- a/exchanges/gateio/gateio_types.go +++ b/exchanges/gateio/gateio_types.go @@ -585,18 +585,17 @@ type Orderbook struct { // Trade represents market trade. type Trade struct { - ID int64 `json:"id,string"` - TradingTime types.Time `json:"create_time"` - CreateTimeMs types.Time `json:"create_time_ms"` - OrderID string `json:"order_id"` - Side string `json:"side"` - Role string `json:"role"` - Amount types.Number `json:"amount"` - Price types.Number `json:"price"` - Fee types.Number `json:"fee"` - FeeCurrency string `json:"fee_currency"` - PointFee string `json:"point_fee"` - GtFee string `json:"gt_fee"` + ID int64 `json:"id,string"` + CreateTime types.Time `json:"create_time_ms"` + OrderID string `json:"order_id"` + Side string `json:"side"` + Role string `json:"role"` + Amount types.Number `json:"amount"` + Price types.Number `json:"price"` + Fee types.Number `json:"fee"` + FeeCurrency string `json:"fee_currency"` + PointFee string `json:"point_fee"` + GtFee string `json:"gt_fee"` } // Candlestick represents candlestick data point detail. @@ -679,17 +678,22 @@ type FuturesContract struct { OrdersLimit int64 `json:"orders_limit"` TradeID int64 `json:"trade_id"` OrderbookID int64 `json:"orderbook_id"` + EnableBonus bool `json:"enable_bonus"` + EnableCredit bool `json:"enable_credit"` + CreateTime types.Time `json:"create_time"` + FundingCapRatio types.Number `json:"funding_cap_ratio"` + VoucherLeverage types.Number `json:"voucher_leverage"` } // TradingHistoryItem represents futures trading history item. type TradingHistoryItem struct { ID int64 `json:"id"` - CreateTime types.Time `json:"create_time"` + CreateTime types.Time `json:"create_time_ms"` Contract string `json:"contract"` Text string `json:"text"` Size float64 `json:"size"` Price types.Number `json:"price"` - // Added for Derived market trade history datas. + // Added for Derived market trade history data Fee types.Number `json:"fee"` PointFee types.Number `json:"point_fee"` Role string `json:"role"` @@ -704,9 +708,7 @@ type FuturesCandlestick struct { LowestPrice types.Number `json:"l"` OpenPrice types.Number `json:"o"` Sum types.Number `json:"sum"` // Trading volume (unit: Quote currency) - - // Added for websocket push data - Name string `json:"n,omitempty"` + Name string `json:"n,omitempty"` } // FuturesPremiumIndexKLineResponse represents premium index K-Line information. @@ -836,15 +838,6 @@ type DeliveryContract struct { InDelisting bool `json:"in_delisting"` } -// DeliveryTradingHistory represents futures trading history -type DeliveryTradingHistory struct { - ID int64 `json:"id"` - CreateTime types.Time `json:"create_time"` - Contract string `json:"contract"` - Size float64 `json:"size"` - Price types.Number `json:"price"` -} - // OptionUnderlying represents option underlying and it's index price. type OptionUnderlying struct { Name string `json:"name"` @@ -1223,8 +1216,7 @@ type AccountBalanceInformation struct { // MarginAccountBalanceChangeInfo represents margin account balance type MarginAccountBalanceChangeInfo struct { ID string `json:"id"` - Time types.Time `json:"time"` - TimeMs types.Time `json:"time_ms"` + Time types.Time `json:"time_ms"` Currency string `json:"currency"` CurrencyPair string `json:"currency_pair"` AmountChanged string `json:"change"` @@ -1393,10 +1385,8 @@ type SpotOrder struct { Succeeded bool `json:"succeeded"` ErrorLabel string `json:"label,omitempty"` Message string `json:"message,omitempty"` - CreateTime types.Time `json:"create_time,omitempty"` - CreateTimeMs types.Time `json:"create_time_ms,omitempty"` - UpdateTime types.Time `json:"update_time,omitempty"` - UpdateTimeMs types.Time `json:"update_time_ms,omitempty"` + CreateTime types.Time `json:"create_time_ms,omitempty"` + UpdateTime types.Time `json:"update_time_ms,omitempty"` CurrencyPair string `json:"currency_pair,omitempty"` Status string `json:"status,omitempty"` Type string `json:"type,omitempty"` @@ -1457,8 +1447,7 @@ type CancelOrderByIDResponse struct { // SpotPersonalTradeHistory represents personal trading history. type SpotPersonalTradeHistory struct { TradeID string `json:"id"` - CreateTime types.Time `json:"create_time"` - CreateTimeMs types.Time `json:"create_time_ms"` + CreateTime types.Time `json:"create_time_ms"` CurrencyPair string `json:"currency_pair"` OrderID string `json:"order_id"` Side string `json:"side"` @@ -2006,14 +1995,14 @@ type WsEventResponse struct { } } -// WsResponse represents generalized websocket push data from the server. -type WsResponse struct { - ID int64 `json:"id"` - Time types.Time `json:"time"` - TimeMs types.Time `json:"time_ms"` - Channel string `json:"channel"` - Event string `json:"event"` - Result json.RawMessage `json:"result"` +// WSResponse represents generalized websocket push data from the server. +type WSResponse struct { + ID int64 `json:"id"` + Time time.Time `json:"time"` + Channel string `json:"channel"` + Event string `json:"event"` + Result json.RawMessage `json:"result"` + RequestID string `json:"request_id"` } // WsTicker websocket ticker information. @@ -2032,8 +2021,7 @@ type WsTicker struct { // WsTrade represents a websocket push data response for a trade type WsTrade struct { ID int64 `json:"id"` - CreateTime types.Time `json:"create_time"` - CreateTimeMs types.Time `json:"create_time_ms"` + CreateTime types.Time `json:"create_time_ms"` Side string `json:"side"` CurrencyPair currency.Pair `json:"currency_pair"` Amount types.Number `json:"amount"` @@ -2053,7 +2041,7 @@ type WsCandlesticks struct { // WsOrderbookTickerData represents the websocket orderbook best bid or best ask push data type WsOrderbookTickerData struct { - UpdateTimeMS types.Time `json:"t"` + UpdateTime types.Time `json:"t"` UpdateOrderID int64 `json:"u"` CurrencyPair currency.Pair `json:"s"` BestBidPrice types.Number `json:"b"` @@ -2064,9 +2052,7 @@ type WsOrderbookTickerData struct { // WsOrderbookUpdate represents websocket orderbook update push data type WsOrderbookUpdate struct { - UpdateTimeMs types.Time `json:"t"` - IgnoreField string `json:"e"` - UpdateTime types.Time `json:"E"` + UpdateTime types.Time `json:"t"` CurrencyPair currency.Pair `json:"s"` FirstOrderbookUpdatedID int64 `json:"U"` // First update order book id in this event since last update LastOrderbookUpdatedID int64 `json:"u"` @@ -2076,7 +2062,7 @@ type WsOrderbookUpdate struct { // WsOrderbookSnapshot represents a websocket orderbook snapshot push data type WsOrderbookSnapshot struct { - UpdateTimeMs types.Time `json:"t"` + UpdateTime types.Time `json:"t"` LastUpdateID int64 `json:"lastUpdateId"` CurrencyPair currency.Pair `json:"s"` Bids [][2]types.Number `json:"bids"` @@ -2109,10 +2095,8 @@ type WsSpotOrder struct { RebatedFee string `json:"rebated_fee,omitempty"` RebatedFeeCurrency string `json:"rebated_fee_currency,omitempty"` Event string `json:"event"` - CreateTime types.Time `json:"create_time,omitempty"` - CreateTimeMs types.Time `json:"create_time_ms,omitempty"` - UpdateTime types.Time `json:"update_time,omitempty"` - UpdateTimeMs types.Time `json:"update_time_ms,omitempty"` + CreateTime types.Time `json:"create_time_ms,omitempty"` + UpdateTime types.Time `json:"update_time_ms,omitempty"` } // WsUserPersonalTrade represents a user's personal trade pushed through the websocket connection. @@ -2121,8 +2105,7 @@ type WsUserPersonalTrade struct { UserID int64 `json:"user_id"` OrderID string `json:"order_id"` CurrencyPair currency.Pair `json:"currency_pair"` - CreateTime types.Time `json:"create_time"` - CreateTimeMs types.Time `json:"create_time_ms"` + CreateTime types.Time `json:"create_time_ms"` Side string `json:"side"` Amount types.Number `json:"amount"` Role string `json:"role"` @@ -2135,19 +2118,17 @@ type WsUserPersonalTrade struct { // WsSpotBalance represents a spot balance. type WsSpotBalance struct { - Timestamp types.Time `json:"timestamp"` - TimestampMs types.Time `json:"timestamp_ms"` - User string `json:"user"` - Currency string `json:"currency"` - Change types.Number `json:"change"` - Total types.Number `json:"total"` - Available types.Number `json:"available"` + Timestamp types.Time `json:"timestamp_ms"` + User string `json:"user"` + Currency string `json:"currency"` + Change types.Number `json:"change"` + Total types.Number `json:"total"` + Available types.Number `json:"available"` } // WsMarginBalance represents margin account balance push data type WsMarginBalance struct { - Timestamp types.Time `json:"timestamp"` - TimestampMs types.Time `json:"timestamp_ms"` + Timestamp types.Time `json:"timestamp_ms"` User string `json:"user"` CurrencyPair string `json:"currency_pair"` Currency string `json:"currency"` @@ -2160,24 +2141,22 @@ type WsMarginBalance struct { // WsFundingBalance represents funding balance push data. type WsFundingBalance struct { - Timestamp types.Time `json:"timestamp"` - TimestampMs types.Time `json:"timestamp_ms"` - User string `json:"user"` - Currency string `json:"currency"` - Change string `json:"change"` - Freeze string `json:"freeze"` - Lent string `json:"lent"` + Timestamp types.Time `json:"timestamp_ms"` + User string `json:"user"` + Currency string `json:"currency"` + Change string `json:"change"` + Freeze string `json:"freeze"` + Lent string `json:"lent"` } // WsCrossMarginBalance represents a cross margin balance detail type WsCrossMarginBalance struct { - Timestamp types.Time `json:"timestamp"` - TimestampMs types.Time `json:"timestamp_ms"` - User string `json:"user"` - Currency string `json:"currency"` - Change string `json:"change"` - Total types.Number `json:"total"` - Available types.Number `json:"available"` + Timestamp types.Time `json:"timestamp_ms"` + User string `json:"user"` + Currency string `json:"currency"` + Change string `json:"change"` + Total types.Number `json:"total"` + Available types.Number `json:"available"` } // WsCrossMarginLoan represents a cross margin loan push data @@ -2215,17 +2194,16 @@ type WsFutureTicker struct { // WsFuturesTrades represents a list of trades push data type WsFuturesTrades struct { - Size float64 `json:"size"` - ID int64 `json:"id"` - CreateTime types.Time `json:"create_time"` - CreateTimeMs types.Time `json:"create_time_ms"` - Price types.Number `json:"price"` - Contract currency.Pair `json:"contract"` + Size float64 `json:"size"` + ID int64 `json:"id"` + CreateTime types.Time `json:"create_time_ms"` + Price types.Number `json:"price"` + Contract currency.Pair `json:"contract"` } // WsFuturesOrderbookTicker represents the orderbook ticker push data type WsFuturesOrderbookTicker struct { - TimestampMs types.Time `json:"t"` + Timestamp types.Time `json:"t"` UpdateID int64 `json:"u"` CurrencyPair string `json:"s"` BestBidPrice types.Number `json:"b"` @@ -2236,7 +2214,7 @@ type WsFuturesOrderbookTicker struct { // WsFuturesAndOptionsOrderbookUpdate represents futures and options account orderbook update push data type WsFuturesAndOptionsOrderbookUpdate struct { - TimestampInMs types.Time `json:"t"` + Timestamp types.Time `json:"t"` ContractName currency.Pair `json:"s"` FirstUpdatedID int64 `json:"U"` LastUpdatedID int64 `json:"u"` @@ -2252,10 +2230,10 @@ type WsFuturesAndOptionsOrderbookUpdate struct { // WsFuturesOrderbookSnapshot represents a futures orderbook snapshot push data type WsFuturesOrderbookSnapshot struct { - TimestampInMs types.Time `json:"t"` - Contract currency.Pair `json:"contract"` - OrderbookID int64 `json:"id"` - Asks []struct { + Timestamp types.Time `json:"t"` + Contract currency.Pair `json:"contract"` + OrderbookID int64 `json:"id"` + Asks []struct { Price types.Number `json:"p"` Size float64 `json:"s"` } `json:"asks"` @@ -2276,12 +2254,10 @@ type WsFuturesOrderbookUpdateEvent struct { // WsFuturesOrder represents futures order type WsFuturesOrder struct { Contract currency.Pair `json:"contract"` - CreateTime types.Time `json:"create_time"` - CreateTimeMs types.Time `json:"create_time_ms"` + CreateTime types.Time `json:"create_time_ms"` FillPrice float64 `json:"fill_price"` FinishAs string `json:"finish_as"` - FinishTime types.Time `json:"finish_time"` - FinishTimeMs types.Time `json:"finish_time_ms"` + FinishTime types.Time `json:"finish_time_ms"` Iceberg int64 `json:"iceberg"` ID int64 `json:"id"` IsClose bool `json:"is_close"` @@ -2302,17 +2278,16 @@ type WsFuturesOrder struct { // WsFuturesUserTrade represents a futures account user trade push data type WsFuturesUserTrade struct { - ID string `json:"id"` - CreateTime types.Time `json:"create_time"` - CreateTimeMs types.Time `json:"create_time_ms"` - Contract currency.Pair `json:"contract"` - OrderID string `json:"order_id"` - Size float64 `json:"size"` - Price types.Number `json:"price"` - Role string `json:"role"` - Text string `json:"text"` - Fee float64 `json:"fee"` - PointFee int64 `json:"point_fee"` + ID string `json:"id"` + CreateTime types.Time `json:"create_time_ms"` + Contract currency.Pair `json:"contract"` + OrderID string `json:"order_id"` + Size float64 `json:"size"` + Price types.Number `json:"price"` + Role string `json:"role"` + Text string `json:"text"` + Fee float64 `json:"fee"` + PointFee int64 `json:"point_fee"` } // WsFuturesLiquidationNotification represents a liquidation notification push data @@ -2327,8 +2302,7 @@ type WsFuturesLiquidationNotification struct { OrderID int64 `json:"order_id"` OrderPrice float64 `json:"order_price"` Size float64 `json:"size"` - Time types.Time `json:"time"` - TimeMs types.Time `json:"time_ms"` + Time types.Time `json:"time_ms"` Contract string `json:"contract"` User string `json:"user"` } @@ -2339,8 +2313,7 @@ type WsFuturesAutoDeleveragesNotification struct { FillPrice float64 `json:"fill_price"` PositionSize int64 `json:"position_size"` TradeSize int64 `json:"trade_size"` - Time types.Time `json:"time"` - TimeMs types.Time `json:"time_ms"` + Time types.Time `json:"time_ms"` Contract string `json:"contract"` User string `json:"user"` } @@ -2351,8 +2324,7 @@ type WsPositionClose struct { ProfitAndLoss float64 `json:"pnl,omitempty"` Side string `json:"side"` Text string `json:"text"` - Time types.Time `json:"time"` - TimeMs types.Time `json:"time_ms"` + Time types.Time `json:"time_ms"` User string `json:"user"` // Added in options close position push datas @@ -2365,8 +2337,7 @@ type WsBalance struct { Balance float64 `json:"balance"` Change float64 `json:"change"` Text string `json:"text"` - Time types.Time `json:"time"` - TimeMs types.Time `json:"time_ms"` + Time types.Time `json:"time_ms"` Type string `json:"type"` User string `json:"user"` } @@ -2379,8 +2350,7 @@ type WsFuturesReduceRiskLimitNotification struct { LiqPrice float64 `json:"liq_price"` MaintenanceRate float64 `json:"maintenance_rate"` RiskLimit int64 `json:"risk_limit"` - Time types.Time `json:"time"` - TimeMs types.Time `json:"time_ms"` + Time types.Time `json:"time_ms"` User string `json:"user"` } @@ -2402,8 +2372,7 @@ type WsFuturesPosition struct { RealisedPoint float64 `json:"realised_point"` RiskLimit float64 `json:"risk_limit"` Size float64 `json:"size"` - Time types.Time `json:"time"` - TimeMs types.Time `json:"time_ms"` + Time types.Time `json:"time_ms"` User string `json:"user"` } @@ -2452,31 +2421,26 @@ type WsOptionUnderlyingTicker struct { // WsOptionsTrades represents options trades for websocket push data. type WsOptionsTrades struct { ID int64 `json:"id"` - CreateTime types.Time `json:"create_time"` + CreateTime types.Time `json:"create_time_ms"` Contract currency.Pair `json:"contract"` Size float64 `json:"size"` Price float64 `json:"price"` - - // Added in options websocket push data - CreateTimeMs types.Time `json:"create_time_ms"` - Underlying string `json:"underlying"` - IsCall bool `json:"is_call"` // added in underlying trades + Underlying string `json:"underlying"` + IsCall bool `json:"is_call"` // added in underlying trades } // WsOptionsUnderlyingPrice represents the underlying price. type WsOptionsUnderlyingPrice struct { - Underlying string `json:"underlying"` - Price float64 `json:"price"` - UpdateTime types.Time `json:"time"` - UpdateTimeMs types.Time `json:"time_ms"` + Underlying string `json:"underlying"` + Price float64 `json:"price"` + UpdateTime types.Time `json:"time_ms"` } // WsOptionsMarkPrice represents options mark price push data. type WsOptionsMarkPrice struct { - Contract string `json:"contract"` - Price float64 `json:"price"` - UpdateTimeMs types.Time `json:"time_ms"` - UpdateTime types.Time `json:"time"` + Contract string `json:"contract"` + Price float64 `json:"price"` + UpdateTime types.Time `json:"time_ms"` } // WsOptionsSettlement represents a options settlement push data. @@ -2491,8 +2455,7 @@ type WsOptionsSettlement struct { TradeID int64 `json:"trade_id"` TradeSize int64 `json:"trade_size"` Underlying string `json:"underlying"` - UpdateTime types.Time `json:"time"` - UpdateTimeMs types.Time `json:"time_ms"` + UpdateTime types.Time `json:"time_ms"` } // WsOptionsContract represents an option contract push data. @@ -2520,8 +2483,7 @@ type WsOptionsContract struct { Tag string `json:"tag"` TakerFeeRate float64 `json:"taker_fee_rate"` Underlying string `json:"underlying"` - Time types.Time `json:"time"` - TimeMs types.Time `json:"time_ms"` + Time types.Time `json:"time_ms"` } // WsOptionsContractCandlestick represents an options contract candlestick push data. @@ -2564,42 +2526,40 @@ type WsOptionsOrderbookSnapshot struct { // WsOptionsOrder represents options order push data. type WsOptionsOrder struct { - ID int64 `json:"id"` - Contract currency.Pair `json:"contract"` - CreateTime types.Time `json:"create_time"` - FillPrice float64 `json:"fill_price"` - FinishAs string `json:"finish_as"` - Iceberg float64 `json:"iceberg"` - IsClose bool `json:"is_close"` - IsLiq bool `json:"is_liq"` - IsReduceOnly bool `json:"is_reduce_only"` - Left float64 `json:"left"` - Mkfr float64 `json:"mkfr"` - Price float64 `json:"price"` - Refr float64 `json:"refr"` - Refu float64 `json:"refu"` - Size float64 `json:"size"` - Status string `json:"status"` - Text string `json:"text"` - Tif string `json:"tif"` - Tkfr float64 `json:"tkfr"` - Underlying string `json:"underlying"` - User string `json:"user"` - CreationTime types.Time `json:"time"` - CreationTimeMs types.Time `json:"time_ms"` + ID int64 `json:"id"` + Contract currency.Pair `json:"contract"` + CreateTime types.Time `json:"create_time"` + FillPrice float64 `json:"fill_price"` + FinishAs string `json:"finish_as"` + Iceberg float64 `json:"iceberg"` + IsClose bool `json:"is_close"` + IsLiq bool `json:"is_liq"` + IsReduceOnly bool `json:"is_reduce_only"` + Left float64 `json:"left"` + Mkfr float64 `json:"mkfr"` + Price float64 `json:"price"` + Refr float64 `json:"refr"` + Refu float64 `json:"refu"` + Size float64 `json:"size"` + Status string `json:"status"` + Text string `json:"text"` + Tif string `json:"tif"` + Tkfr float64 `json:"tkfr"` + Underlying string `json:"underlying"` + User string `json:"user"` + CreationTime types.Time `json:"time_ms"` } // WsOptionsUserTrade represents user's personal trades of option account. type WsOptionsUserTrade struct { - ID string `json:"id"` - Underlying string `json:"underlying"` - OrderID string `json:"order"` - Contract currency.Pair `json:"contract"` - CreateTime types.Time `json:"create_time"` - CreateTimeMs types.Time `json:"create_time_ms"` - Price types.Number `json:"price"` - Role string `json:"role"` - Size float64 `json:"size"` + ID string `json:"id"` + Underlying string `json:"underlying"` + OrderID string `json:"order"` + Contract currency.Pair `json:"contract"` + CreateTime types.Time `json:"create_time_ms"` + Price types.Number `json:"price"` + Role string `json:"role"` + Size float64 `json:"size"` } // WsOptionsLiquidates represents the liquidates push data of option account. @@ -2608,8 +2568,7 @@ type WsOptionsLiquidates struct { InitMargin float64 `json:"init_margin"` MaintMargin float64 `json:"maint_margin"` OrderMargin float64 `json:"order_margin"` - Time types.Time `json:"time"` - TimeMs types.Time `json:"time_ms"` + Time types.Time `json:"time_ms"` } // WsOptionsUserSettlement represents user's personal settlements push data of options account. @@ -2622,19 +2581,17 @@ type WsOptionsUserSettlement struct { Size float64 `json:"size"` StrikePrice float64 `json:"strike_price"` Underlying string `json:"underlying"` - SettleTime types.Time `json:"time"` - SettleTimeMs types.Time `json:"time_ms"` + SettleTime types.Time `json:"time_ms"` } // WsOptionsPosition represents positions push data for options account. type WsOptionsPosition struct { - EntryPrice float64 `json:"entry_price"` - RealisedPnl float64 `json:"realised_pnl"` - Size float64 `json:"size"` - Contract string `json:"contract"` - User string `json:"user"` - UpdateTime types.Time `json:"time"` - UpdateTimeMs types.Time `json:"time_ms"` + EntryPrice float64 `json:"entry_price"` + RealisedPnl float64 `json:"realised_pnl"` + Size float64 `json:"size"` + Contract string `json:"contract"` + User string `json:"user"` + UpdateTime types.Time `json:"time_ms"` } // InterSubAccountTransferParams represents parameters to transfer funds between sub-accounts. diff --git a/exchanges/gateio/gateio_websocket.go b/exchanges/gateio/gateio_websocket.go index 0733a8e7ae7..1d30cd05cca 100644 --- a/exchanges/gateio/gateio_websocket.go +++ b/exchanges/gateio/gateio_websocket.go @@ -15,6 +15,7 @@ import ( "time" "github.com/Masterminds/sprig/v3" + "github.com/buger/jsonparser" "github.com/gorilla/websocket" "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/currency" @@ -102,6 +103,64 @@ func (g *Gateio) WsConnectSpot(ctx context.Context, conn stream.Connection) erro return nil } +// authenticateSpot sends an authentication message to the websocket connection +func (g *Gateio) authenticateSpot(ctx context.Context, conn stream.Connection) error { + return g.websocketLogin(ctx, conn, "spot.login") +} + +// websocketLogin authenticates the websocket connection +func (g *Gateio) websocketLogin(ctx context.Context, conn stream.Connection, channel string) error { + if conn == nil { + return fmt.Errorf("%w: %T", common.ErrNilPointer, conn) + } + + if channel == "" { + return errChannelEmpty + } + + creds, err := g.GetCredentials(ctx) + if err != nil { + return err + } + + tn := time.Now().Unix() + msg := "api\n" + channel + "\n" + "\n" + strconv.FormatInt(tn, 10) + mac := hmac.New(sha512.New, []byte(creds.Secret)) + if _, err = mac.Write([]byte(msg)); err != nil { + return err + } + signature := hex.EncodeToString(mac.Sum(nil)) + + payload := WebsocketPayload{ + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), + APIKey: creds.Key, + Signature: signature, + Timestamp: strconv.FormatInt(tn, 10), + } + + req := WebsocketRequest{Time: tn, Channel: channel, Event: "api", Payload: payload} + + resp, err := conn.SendMessageReturnResponse(ctx, websocketRateLimitNotNeededEPL, req.Payload.RequestID, req) + if err != nil { + return err + } + + var inbound WebsocketAPIResponse + if err := json.Unmarshal(resp, &inbound); err != nil { + return err + } + + if inbound.Header.Status != "200" { + var wsErr WebsocketErrors + if err := json.Unmarshal(inbound.Data, &wsErr.Errors); err != nil { + return err + } + return fmt.Errorf("%s: %s", wsErr.Errors.Label, wsErr.Errors.Message) + } + + return nil +} + func (g *Gateio) generateWsSignature(secret, event, channel string, t int64) (string, error) { msg := "channel=" + channel + "&event=" + event + "&time=" + strconv.FormatInt(t, 10) mac := hmac.New(sha512.New, []byte(secret)) @@ -113,32 +172,32 @@ func (g *Gateio) generateWsSignature(secret, event, channel string, t int64) (st // WsHandleSpotData handles spot data func (g *Gateio) WsHandleSpotData(_ context.Context, respRaw []byte) error { - var push WsResponse - err := json.Unmarshal(respRaw, &push) + push, err := parseWSHeader(respRaw) if err != nil { return err } + if push.RequestID != "" { + return g.Websocket.Match.RequireMatchWithData(push.RequestID, respRaw) + } + if push.Event == subscribeEvent || push.Event == unsubscribeEvent { - if !g.Websocket.Match.IncomingWithData(push.ID, respRaw) { - return fmt.Errorf("couldn't match subscription message with ID: %d", push.ID) - } - return nil + return g.Websocket.Match.RequireMatchWithData(push.ID, respRaw) } switch push.Channel { // TODO: Convert function params below to only use push.Result case spotTickerChannel: - return g.processTicker(push.Result, push.Time.Time()) + return g.processTicker(push.Result, push.Time) case spotTradesChannel: return g.processTrades(push.Result) case spotCandlesticksChannel: return g.processCandlestick(push.Result) case spotOrderbookTickerChannel: - return g.processOrderbookTicker(push.Result, push.TimeMs.Time()) + return g.processOrderbookTicker(push.Result, push.Time) case spotOrderbookUpdateChannel: - return g.processOrderbookUpdate(push.Result, push.TimeMs.Time()) + return g.processOrderbookUpdate(push.Result, push.Time) case spotOrderbookChannel: - return g.processOrderbookSnapshot(push.Result, push.TimeMs.Time()) + return g.processOrderbookSnapshot(push.Result, push.Time) case spotOrdersChannel: return g.processSpotOrders(respRaw) case spotUserTradesChannel: @@ -163,6 +222,45 @@ func (g *Gateio) WsHandleSpotData(_ context.Context, respRaw []byte) error { return nil } +func parseWSHeader(msg []byte) (r *WSResponse, errs error) { + r = &WSResponse{} + paths := [][]string{{"time_ms"}, {"time"}, {"channel"}, {"event"}, {"request_id"}, {"id"}, {"result"}} + jsonparser.EachKey(msg, func(idx int, v []byte, _ jsonparser.ValueType, _ error) { + switch idx { + case 0: // time_ms + if ts, err := strconv.ParseInt(string(v), 10, 64); err != nil { + errs = common.AppendError(errs, fmt.Errorf("%w parsing `time_ms`", err)) + } else { + r.Time = time.UnixMilli(ts) + } + case 1: // time + if r.Time.IsZero() { + if ts, err := strconv.ParseInt(string(v), 10, 64); err != nil { + errs = common.AppendError(errs, fmt.Errorf("%w parsing `time`", err)) + } else { + r.Time = time.Unix(ts, 0) + } + } + case 2: + r.Channel = string(v) + case 3: + r.Event = string(v) + case 4: + r.RequestID = string(v) + case 5: + if id, err := strconv.ParseInt(string(v), 10, 64); err != nil { + errs = common.AppendError(errs, fmt.Errorf("%w parsing `id`", err)) + } else { + r.ID = id + } + case 6: + r.Result = json.RawMessage(v) + } + }, paths...) + + return r, errs +} + func (g *Gateio) processTicker(incoming []byte, pushTime time.Time) error { var data WsTicker if err := json.Unmarshal(incoming, &data); err != nil { @@ -209,7 +307,7 @@ func (g *Gateio) processTrades(incoming []byte) error { for _, a := range standardMarginAssetTypes { if enabled, _ := g.CurrencyPairs.IsPairEnabled(data.CurrencyPair, a); enabled { if err := g.Websocket.Trade.Update(saveTradeData, trade.Data{ - Timestamp: data.CreateTimeMs.Time(), + Timestamp: data.CreateTime.Time(), CurrencyPair: data.CurrencyPair, AssetType: a, Exchange: g.Name, @@ -270,7 +368,7 @@ func (g *Gateio) processOrderbookTicker(incoming []byte, updatePushedAt time.Tim Exchange: g.Name, Pair: data.CurrencyPair, Asset: asset.Spot, - LastUpdated: data.UpdateTimeMS.Time(), + LastUpdated: data.UpdateTime.Time(), UpdatePushedAt: updatePushedAt, Bids: []orderbook.Tranche{{Price: data.BestBidPrice.Float64(), Amount: data.BestBidAmount.Float64()}}, Asks: []orderbook.Tranche{{Price: data.BestAskPrice.Float64(), Amount: data.BestAskAmount.Float64()}}, @@ -325,7 +423,7 @@ func (g *Gateio) processOrderbookUpdate(incoming []byte, updatePushedAt time.Tim for _, a := range enabledAssets { if err := g.Websocket.Orderbook.Update(&orderbook.Update{ - UpdateTime: data.UpdateTimeMs.Time(), + UpdateTime: data.UpdateTime.Time(), UpdatePushedAt: updatePushedAt, Pair: data.CurrencyPair, Asset: a, @@ -362,7 +460,7 @@ func (g *Gateio) processOrderbookSnapshot(incoming []byte, updatePushedAt time.T Exchange: g.Name, Pair: data.CurrencyPair, Asset: a, - LastUpdated: data.UpdateTimeMs.Time(), + LastUpdated: data.UpdateTime.Time(), UpdatePushedAt: updatePushedAt, Bids: bids, Asks: asks, @@ -410,8 +508,8 @@ func (g *Gateio) processSpotOrders(data []byte) error { AssetType: a, Price: resp.Result[x].Price.Float64(), ExecutedAmount: resp.Result[x].Amount.Float64() - resp.Result[x].Left.Float64(), - Date: resp.Result[x].CreateTimeMs.Time(), - LastUpdated: resp.Result[x].UpdateTimeMs.Time(), + Date: resp.Result[x].CreateTime.Time(), + LastUpdated: resp.Result[x].UpdateTime.Time(), } } g.Websocket.DataHandler <- details @@ -440,7 +538,7 @@ func (g *Gateio) processUserPersonalTrades(data []byte) error { return err } fills[x] = fill.Data{ - Timestamp: resp.Result[x].CreateTimeMs.Time(), + Timestamp: resp.Result[x].CreateTime.Time(), Exchange: g.Name, CurrencyPair: resp.Result[x].CurrencyPair, Side: side, diff --git a/exchanges/gateio/gateio_ws_delivery_futures.go b/exchanges/gateio/gateio_websocket_delivery_futures.go similarity index 100% rename from exchanges/gateio/gateio_ws_delivery_futures.go rename to exchanges/gateio/gateio_websocket_delivery_futures.go diff --git a/exchanges/gateio/gateio_ws_futures.go b/exchanges/gateio/gateio_websocket_futures.go similarity index 98% rename from exchanges/gateio/gateio_ws_futures.go rename to exchanges/gateio/gateio_websocket_futures.go index ba393277553..628824a08ca 100644 --- a/exchanges/gateio/gateio_ws_futures.go +++ b/exchanges/gateio/gateio_websocket_futures.go @@ -149,8 +149,7 @@ func (g *Gateio) FuturesUnsubscribe(ctx context.Context, conn stream.Connection, // WsHandleFuturesData handles futures websocket data func (g *Gateio) WsHandleFuturesData(_ context.Context, respRaw []byte, a asset.Item) error { - var push WsResponse - err := json.Unmarshal(respRaw, &push) + push, err := parseWSHeader(respRaw) if err != nil { return err } @@ -168,7 +167,7 @@ func (g *Gateio) WsHandleFuturesData(_ context.Context, respRaw []byte, a asset. case futuresTradesChannel: return g.processFuturesTrades(respRaw, a) case futuresOrderbookChannel: - return g.processFuturesOrderbookSnapshot(push.Event, push.Result, a, push.TimeMs.Time()) + return g.processFuturesOrderbookSnapshot(push.Event, push.Result, a, push.Time) case futuresOrderbookTickerChannel: return g.processFuturesOrderbookTicker(push.Result) case futuresOrderbookUpdateChannel: @@ -353,7 +352,7 @@ func (g *Gateio) processFuturesTrades(data []byte, assetType asset.Item) error { trades := make([]trade.Data, len(resp.Result)) for x := range resp.Result { trades[x] = trade.Data{ - Timestamp: resp.Result[x].CreateTimeMs.Time(), + Timestamp: resp.Result[x].CreateTime.Time(), CurrencyPair: resp.Result[x].Contract, AssetType: assetType, Exchange: g.Name, @@ -439,7 +438,7 @@ func (g *Gateio) processFuturesAndOptionsOrderbookUpdate(incoming []byte, assetT } } updates := orderbook.Update{ - UpdateTime: data.TimestampInMs.Time(), + UpdateTime: data.Timestamp.Time(), Pair: data.ContractName, Asset: assetType, } @@ -470,7 +469,7 @@ func (g *Gateio) processFuturesOrderbookSnapshot(event string, incoming []byte, Asset: assetType, Exchange: g.Name, Pair: data.Contract, - LastUpdated: data.TimestampInMs.Time(), + LastUpdated: data.Timestamp.Time(), UpdatePushedAt: updatePushedAt, VerifyOrderbook: g.CanVerifyOrderbook, } @@ -574,13 +573,13 @@ func (g *Gateio) processFuturesOrdersPushData(data []byte, assetType asset.Item) OrderID: strconv.FormatInt(resp.Result[x].ID, 10), Status: status, Pair: resp.Result[x].Contract, - LastUpdated: resp.Result[x].FinishTimeMs.Time(), - Date: resp.Result[x].CreateTimeMs.Time(), + LastUpdated: resp.Result[x].FinishTime.Time(), + Date: resp.Result[x].CreateTime.Time(), ExecutedAmount: resp.Result[x].Size - resp.Result[x].Left, Price: resp.Result[x].Price, AssetType: assetType, AccountID: resp.Result[x].User, - CloseTime: resp.Result[x].FinishTimeMs.Time(), + CloseTime: resp.Result[x].FinishTime.Time(), } } return orderDetails, nil @@ -604,7 +603,7 @@ func (g *Gateio) procesFuturesUserTrades(data []byte, assetType asset.Item) erro fills := make([]fill.Data, len(resp.Result)) for x := range resp.Result { fills[x] = fill.Data{ - Timestamp: resp.Result[x].CreateTimeMs.Time(), + Timestamp: resp.Result[x].CreateTime.Time(), Exchange: g.Name, CurrencyPair: resp.Result[x].Contract, OrderID: resp.Result[x].OrderID, diff --git a/exchanges/gateio/gateio_ws_option.go b/exchanges/gateio/gateio_websocket_option.go similarity index 98% rename from exchanges/gateio/gateio_ws_option.go rename to exchanges/gateio/gateio_websocket_option.go index 091a9ad1f0f..7ec39737e6e 100644 --- a/exchanges/gateio/gateio_ws_option.go +++ b/exchanges/gateio/gateio_websocket_option.go @@ -294,8 +294,7 @@ func (g *Gateio) OptionsUnsubscribe(ctx context.Context, conn stream.Connection, // WsHandleOptionsData handles options websocket data func (g *Gateio) WsHandleOptionsData(_ context.Context, respRaw []byte) error { - var push WsResponse - err := json.Unmarshal(respRaw, &push) + push, err := parseWSHeader(respRaw) if err != nil { return err } @@ -327,7 +326,7 @@ func (g *Gateio) WsHandleOptionsData(_ context.Context, respRaw []byte) error { optionsUnderlyingCandlesticksChannel: return g.processOptionsCandlestickPushData(respRaw) case optionsOrderbookChannel: - return g.processOptionsOrderbookSnapshotPushData(push.Event, push.Result, push.Time.Time()) + return g.processOptionsOrderbookSnapshotPushData(push.Event, push.Result, push.Time) case optionsOrderbookTickerChannel: return g.processOrderbookTickerPushData(respRaw) case optionsOrderbookUpdateChannel: @@ -402,7 +401,7 @@ func (g *Gateio) processOptionsTradesPushData(data []byte) error { trades := make([]trade.Data, len(resp.Result)) for x := range resp.Result { trades[x] = trade.Data{ - Timestamp: resp.Result[x].CreateTimeMs.Time(), + Timestamp: resp.Result[x].CreateTime.Time(), CurrencyPair: resp.Result[x].Contract, AssetType: asset.Options, Exchange: g.Name, @@ -606,7 +605,7 @@ func (g *Gateio) processOptionsOrderPushData(data []byte) error { OrderID: strconv.FormatInt(resp.Result[x].ID, 10), Status: status, Pair: resp.Result[x].Contract, - Date: resp.Result[x].CreationTimeMs.Time(), + Date: resp.Result[x].CreationTime.Time(), ExecutedAmount: resp.Result[x].Size - resp.Result[x].Left, Price: resp.Result[x].Price, AssetType: asset.Options, @@ -634,7 +633,7 @@ func (g *Gateio) processOptionsUserTradesPushData(data []byte) error { fills := make([]fill.Data, len(resp.Result)) for x := range resp.Result { fills[x] = fill.Data{ - Timestamp: resp.Result[x].CreateTimeMs.Time(), + Timestamp: resp.Result[x].CreateTime.Time(), Exchange: g.Name, CurrencyPair: resp.Result[x].Contract, OrderID: resp.Result[x].OrderID, diff --git a/exchanges/gateio/gateio_websocket_request_spot.go b/exchanges/gateio/gateio_websocket_request_spot.go new file mode 100644 index 00000000000..25ac1ea1689 --- /dev/null +++ b/exchanges/gateio/gateio_websocket_request_spot.go @@ -0,0 +1,224 @@ +package gateio + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/thrasher-corp/gocryptotrader/common" + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/exchanges/request" +) + +var ( + errOrdersEmpty = errors.New("orders cannot be empty") + errNoOrdersToCancel = errors.New("no orders to cancel") + errChannelEmpty = errors.New("channel cannot be empty") +) + +// WebsocketSpotSubmitOrder submits an order via the websocket connection +func (g *Gateio) WebsocketSpotSubmitOrder(ctx context.Context, order *WebsocketOrder) ([]WebsocketOrderResponse, error) { + return g.WebsocketSpotSubmitOrders(ctx, []WebsocketOrder{*order}) +} + +// WebsocketSpotSubmitOrders submits orders via the websocket connection. You can +// send multiple orders in a single request. But only for one asset route. +func (g *Gateio) WebsocketSpotSubmitOrders(ctx context.Context, orders []WebsocketOrder) ([]WebsocketOrderResponse, error) { + if len(orders) == 0 { + return nil, errOrdersEmpty + } + + for i := range orders { + if orders[i].Text == "" { + // API requires Text field, or it will be rejected + orders[i].Text = "t-" + strconv.FormatInt(g.Counter.IncrementAndGet(), 10) + } + if orders[i].CurrencyPair == "" { + return nil, currency.ErrCurrencyPairEmpty + } + if orders[i].Side == "" { + return nil, order.ErrSideIsInvalid + } + if orders[i].Amount == "" { + return nil, errInvalidAmount + } + if orders[i].Type == "limit" && orders[i].Price == "" { + return nil, errInvalidPrice + } + } + + if len(orders) == 1 { + var singleResponse WebsocketOrderResponse + return []WebsocketOrderResponse{singleResponse}, g.SendWebsocketRequest(ctx, spotPlaceOrderEPL, "spot.order_place", asset.Spot, orders[0], &singleResponse, 2) + } + var resp []WebsocketOrderResponse + return resp, g.SendWebsocketRequest(ctx, spotBatchOrdersEPL, "spot.order_place", asset.Spot, orders, &resp, 2) +} + +// WebsocketSpotCancelOrder cancels an order via the websocket connection +func (g *Gateio) WebsocketSpotCancelOrder(ctx context.Context, orderID string, pair currency.Pair, account string) (*WebsocketOrderResponse, error) { + if orderID == "" { + return nil, order.ErrOrderIDNotSet + } + if pair.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + + params := &WebsocketOrderRequest{OrderID: orderID, Pair: pair.String(), Account: account} + + var resp WebsocketOrderResponse + return &resp, g.SendWebsocketRequest(ctx, spotCancelSingleOrderEPL, "spot.order_cancel", asset.Spot, params, &resp, 1) +} + +// WebsocketSpotCancelAllOrdersByIDs cancels multiple orders via the websocket +func (g *Gateio) WebsocketSpotCancelAllOrdersByIDs(ctx context.Context, o []WebsocketOrderBatchRequest) ([]WebsocketCancellAllResponse, error) { + if len(o) == 0 { + return nil, errNoOrdersToCancel + } + + for i := range o { + if o[i].OrderID == "" { + return nil, order.ErrOrderIDNotSet + } + if o[i].Pair.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + } + + var resp []WebsocketCancellAllResponse + return resp, g.SendWebsocketRequest(ctx, spotCancelBatchOrdersEPL, "spot.order_cancel_ids", asset.Spot, o, &resp, 2) +} + +// WebsocketSpotCancelAllOrdersByPair cancels all orders for a specific pair +func (g *Gateio) WebsocketSpotCancelAllOrdersByPair(ctx context.Context, pair currency.Pair, side order.Side, account string) ([]WebsocketOrderResponse, error) { + if !pair.IsEmpty() && side == order.UnknownSide { + // This case will cancel all orders for every pair, this can be introduced later + return nil, fmt.Errorf("'%v' %w while pair is set", side, order.ErrSideIsInvalid) + } + + sideStr := "" + if side != order.UnknownSide { + sideStr = side.Lower() + } + + params := &WebsocketCancelParam{ + Pair: pair, + Side: sideStr, + Account: account, + } + + var resp []WebsocketOrderResponse + return resp, g.SendWebsocketRequest(ctx, spotCancelAllOpenOrdersEPL, "spot.order_cancel_cp", asset.Spot, params, &resp, 1) +} + +// WebsocketSpotAmendOrder amends an order via the websocket connection +func (g *Gateio) WebsocketSpotAmendOrder(ctx context.Context, amend *WebsocketAmendOrder) (*WebsocketOrderResponse, error) { + if amend == nil { + return nil, fmt.Errorf("%w: %T", common.ErrNilPointer, amend) + } + + if amend.OrderID == "" { + return nil, order.ErrOrderIDNotSet + } + + if amend.Pair.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + + if amend.Amount == "" && amend.Price == "" { + return nil, fmt.Errorf("%w: amount or price must be set", errInvalidAmount) + } + + var resp WebsocketOrderResponse + return &resp, g.SendWebsocketRequest(ctx, spotAmendOrderEPL, "spot.order_amend", asset.Spot, amend, &resp, 1) +} + +// WebsocketSpotGetOrderStatus gets the status of an order via the websocket connection +func (g *Gateio) WebsocketSpotGetOrderStatus(ctx context.Context, orderID string, pair currency.Pair, account string) (*WebsocketOrderResponse, error) { + if orderID == "" { + return nil, order.ErrOrderIDNotSet + } + if pair.IsEmpty() { + return nil, currency.ErrCurrencyPairEmpty + } + + params := &WebsocketOrderRequest{OrderID: orderID, Pair: pair.String(), Account: account} + + var resp WebsocketOrderResponse + return &resp, g.SendWebsocketRequest(ctx, spotGetOrdersEPL, "spot.order_status", asset.Spot, params, &resp, 1) +} + +// funnelResult is used to unmarshal the result of a websocket request back to the required caller type +type funnelResult struct { + Result any `json:"result"` +} + +// SendWebsocketRequest sends a websocket request to the exchange +func (g *Gateio) SendWebsocketRequest(ctx context.Context, epl request.EndpointLimit, channel string, connSignature, params, result any, expectedResponses int) error { + paramPayload, err := json.Marshal(params) + if err != nil { + return err + } + + conn, err := g.Websocket.GetConnection(connSignature) + if err != nil { + return err + } + + tn := time.Now().Unix() + req := &WebsocketRequest{ + Time: tn, + Channel: channel, + Event: "api", + Payload: WebsocketPayload{ + // This request ID associated with the payload is the match to the + // response. + RequestID: strconv.FormatInt(conn.GenerateMessageID(false), 10), + RequestParam: paramPayload, + Timestamp: strconv.FormatInt(tn, 10), + }, + } + + responses, err := conn.SendMessageReturnResponsesWithInspector(ctx, epl, req.Payload.RequestID, req, expectedResponses, wsRespAckInspector{}) + if err != nil { + return err + } + + if len(responses) == 0 { + return common.ErrNoResponse + } + + var inbound WebsocketAPIResponse + // The last response is the one we want to unmarshal, the other is just + // an ack. If the request fails on the ACK then we can unmarshal the error + // from that as the next response won't come anyway. + endResponse := responses[len(responses)-1] + + if err := json.Unmarshal(endResponse, &inbound); err != nil { + return err + } + + if inbound.Header.Status != "200" { + var wsErr WebsocketErrors + if err := json.Unmarshal(inbound.Data, &wsErr); err != nil { + return err + } + return fmt.Errorf("%s: %s", wsErr.Errors.Label, wsErr.Errors.Message) + } + + return json.Unmarshal(inbound.Data, &funnelResult{Result: result}) +} + +type wsRespAckInspector struct{} + +// IsFinal checks the payload for an ack, it returns true if the payload does not contain an ack. +// This will force the cancellation of further waiting for responses. +func (wsRespAckInspector) IsFinal(data []byte) bool { + return !strings.Contains(string(data), "ack") +} diff --git a/exchanges/gateio/gateio_websocket_request_spot_test.go b/exchanges/gateio/gateio_websocket_request_spot_test.go new file mode 100644 index 00000000000..7933c117294 --- /dev/null +++ b/exchanges/gateio/gateio_websocket_request_spot_test.go @@ -0,0 +1,248 @@ +package gateio + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/thrasher-corp/gocryptotrader/common" + "github.com/thrasher-corp/gocryptotrader/config" + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/order" + "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" + "github.com/thrasher-corp/gocryptotrader/exchanges/stream" + testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" +) + +func TestWebsocketLogin(t *testing.T) { + t.Parallel() + err := g.websocketLogin(context.Background(), nil, "") + require.ErrorIs(t, err, common.ErrNilPointer) + + err = g.websocketLogin(context.Background(), &stream.WebsocketConnection{}, "") + require.ErrorIs(t, err, errChannelEmpty) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + demonstrationConn, err := g.Websocket.GetConnection(asset.Spot) + require.NoError(t, err) + + err = g.websocketLogin(context.Background(), demonstrationConn, "spot.login") + require.NoError(t, err) +} + +func TestWebsocketSpotSubmitOrder(t *testing.T) { + t.Parallel() + _, err := g.WebsocketSpotSubmitOrder(context.Background(), &WebsocketOrder{}) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + out := &WebsocketOrder{CurrencyPair: "BTC_USDT"} + _, err = g.WebsocketSpotSubmitOrder(context.Background(), out) + require.ErrorIs(t, err, order.ErrSideIsInvalid) + out.Side = strings.ToLower(order.Buy.String()) + _, err = g.WebsocketSpotSubmitOrder(context.Background(), out) + require.ErrorIs(t, err, errInvalidAmount) + out.Amount = "0.0003" + out.Type = "limit" + _, err = g.WebsocketSpotSubmitOrder(context.Background(), out) + require.ErrorIs(t, err, errInvalidPrice) + out.Price = "20000" + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + got, err := g.WebsocketSpotSubmitOrder(context.Background(), out) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketSpotSubmitOrders(t *testing.T) { + t.Parallel() + _, err := g.WebsocketSpotSubmitOrders(context.Background(), nil) + require.ErrorIs(t, err, errOrdersEmpty) + _, err = g.WebsocketSpotSubmitOrders(context.Background(), make([]WebsocketOrder, 1)) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + out := WebsocketOrder{CurrencyPair: "BTC_USDT"} + _, err = g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out}) + require.ErrorIs(t, err, order.ErrSideIsInvalid) + out.Side = strings.ToLower(order.Buy.String()) + _, err = g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out}) + require.ErrorIs(t, err, errInvalidAmount) + out.Amount = "0.0003" + out.Type = "limit" + _, err = g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out}) + require.ErrorIs(t, err, errInvalidPrice) + out.Price = "20000" + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + // test single order + got, err := g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out}) + require.NoError(t, err) + require.NotEmpty(t, got) + + // test batch orders + got, err = g.WebsocketSpotSubmitOrders(context.Background(), []WebsocketOrder{out, out}) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketSpotCancelOrder(t *testing.T) { + t.Parallel() + _, err := g.WebsocketSpotCancelOrder(context.Background(), "", currency.EMPTYPAIR, "") + require.ErrorIs(t, err, order.ErrOrderIDNotSet) + _, err = g.WebsocketSpotCancelOrder(context.Background(), "1337", currency.EMPTYPAIR, "") + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + btcusdt, err := currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + got, err := g.WebsocketSpotCancelOrder(context.Background(), "644913098758", btcusdt, "") + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketSpotCancelAllOrdersByIDs(t *testing.T) { + t.Parallel() + _, err := g.WebsocketSpotCancelAllOrdersByIDs(context.Background(), []WebsocketOrderBatchRequest{}) + require.ErrorIs(t, err, errNoOrdersToCancel) + out := WebsocketOrderBatchRequest{} + _, err = g.WebsocketSpotCancelAllOrdersByIDs(context.Background(), []WebsocketOrderBatchRequest{out}) + require.ErrorIs(t, err, order.ErrOrderIDNotSet) + out.OrderID = "1337" + _, err = g.WebsocketSpotCancelAllOrdersByIDs(context.Background(), []WebsocketOrderBatchRequest{out}) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + out.Pair, err = currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + out.OrderID = "644913101755" + got, err := g.WebsocketSpotCancelAllOrdersByIDs(context.Background(), []WebsocketOrderBatchRequest{out}) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketSpotCancelAllOrdersByPair(t *testing.T) { + t.Parallel() + pair, err := currency.NewPairFromString("LTC_USDT") + require.NoError(t, err) + + _, err = g.WebsocketSpotCancelAllOrdersByPair(context.Background(), pair, 0, "") + require.ErrorIs(t, err, order.ErrSideIsInvalid) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + got, err := g.WebsocketSpotCancelAllOrdersByPair(context.Background(), currency.EMPTYPAIR, order.Buy, "") + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketSpotAmendOrder(t *testing.T) { + t.Parallel() + + _, err := g.WebsocketSpotAmendOrder(context.Background(), nil) + require.ErrorIs(t, err, common.ErrNilPointer) + + amend := &WebsocketAmendOrder{} + _, err = g.WebsocketSpotAmendOrder(context.Background(), amend) + require.ErrorIs(t, err, order.ErrOrderIDNotSet) + + amend.OrderID = "1337" + _, err = g.WebsocketSpotAmendOrder(context.Background(), amend) + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + amend.Pair, err = currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + + _, err = g.WebsocketSpotAmendOrder(context.Background(), amend) + require.ErrorIs(t, err, errInvalidAmount) + + amend.Amount = "0.0004" + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + amend.OrderID = "645029162673" + got, err := g.WebsocketSpotAmendOrder(context.Background(), amend) + require.NoError(t, err) + require.NotEmpty(t, got) +} + +func TestWebsocketSpotGetOrderStatus(t *testing.T) { + t.Parallel() + + _, err := g.WebsocketSpotGetOrderStatus(context.Background(), "", currency.EMPTYPAIR, "") + require.ErrorIs(t, err, order.ErrOrderIDNotSet) + + _, err = g.WebsocketSpotGetOrderStatus(context.Background(), "1337", currency.EMPTYPAIR, "") + require.ErrorIs(t, err, currency.ErrCurrencyPairEmpty) + + sharedtestvalues.SkipTestIfCredentialsUnset(t, g, canManipulateRealOrders) + + testexch.UpdatePairsOnce(t, g) + g := getWebsocketInstance(t, g) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + pair, err := currency.NewPairFromString("BTC_USDT") + require.NoError(t, err) + + got, err := g.WebsocketSpotGetOrderStatus(context.Background(), "644999650452", pair, "") + require.NoError(t, err) + require.NotEmpty(t, got) +} + +// getWebsocketInstance returns a websocket instance copy for testing. +// This restricts the pairs to a single pair per asset type to reduce test time. +func getWebsocketInstance(t *testing.T, g *Gateio) *Gateio { + t.Helper() + + cpy := new(Gateio) + cpy.SetDefaults() + gConf, err := config.GetConfig().GetExchangeConfig("GateIO") + require.NoError(t, err) + gConf.API.AuthenticatedSupport = true + gConf.API.AuthenticatedWebsocketSupport = true + gConf.API.Credentials.Key = apiKey + gConf.API.Credentials.Secret = apiSecret + + require.NoError(t, cpy.Setup(gConf), "Test instance Setup must not error") + cpy.CurrencyPairs.Load(&g.CurrencyPairs) + + for _, a := range cpy.GetAssetTypes(true) { + if a != asset.Spot { + require.NoError(t, cpy.CurrencyPairs.SetAssetEnabled(a, false)) + continue + } + avail, err := cpy.GetAvailablePairs(a) + require.NoError(t, err) + if len(avail) > 1 { + avail = avail[:1] + } + require.NoError(t, cpy.SetPairs(avail, a, true)) + } + require.NoError(t, cpy.Websocket.Connect()) + return cpy +} diff --git a/exchanges/gateio/gateio_websocket_request_types.go b/exchanges/gateio/gateio_websocket_request_types.go new file mode 100644 index 00000000000..165eea41cba --- /dev/null +++ b/exchanges/gateio/gateio_websocket_request_types.go @@ -0,0 +1,143 @@ +package gateio + +import ( + "encoding/json" + + "github.com/thrasher-corp/gocryptotrader/currency" + "github.com/thrasher-corp/gocryptotrader/types" +) + +// WebsocketAPIResponse defines a general websocket response for api calls +type WebsocketAPIResponse struct { + Header Header `json:"header"` + Data json.RawMessage `json:"data"` +} + +// Header defines a websocket header +type Header struct { + ResponseTime types.Time `json:"response_time"` + Status string `json:"status"` + Channel string `json:"channel"` + Event string `json:"event"` + ClientID string `json:"client_id"` + ConnectionID string `json:"conn_id"` + TraceID string `json:"trace_id"` +} + +// WebsocketRequest defines a websocket request +type WebsocketRequest struct { + Time int64 `json:"time,omitempty"` + ID int64 `json:"id,omitempty"` + Channel string `json:"channel"` + Event string `json:"event"` + Payload WebsocketPayload `json:"payload"` +} + +// WebsocketPayload defines an individualised websocket payload +type WebsocketPayload struct { + RequestID string `json:"req_id,omitempty"` + // APIKey and signature are only required in the initial login request + // which is done when the connection is established. + APIKey string `json:"api_key,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Signature string `json:"signature,omitempty"` + RequestParam json.RawMessage `json:"req_param,omitempty"` +} + +// WebsocketErrors defines a websocket error +type WebsocketErrors struct { + Errors struct { + Label string `json:"label"` + Message string `json:"message"` + } `json:"errs"` +} + +// WebsocketOrder defines a websocket order +type WebsocketOrder struct { + Text string `json:"text"` + CurrencyPair string `json:"currency_pair,omitempty"` + Type string `json:"type,omitempty"` + Account string `json:"account,omitempty"` + Side string `json:"side,omitempty"` + Amount string `json:"amount,omitempty"` + Price string `json:"price,omitempty"` + TimeInForce string `json:"time_in_force,omitempty"` + Iceberg string `json:"iceberg,omitempty"` + AutoBorrow bool `json:"auto_borrow,omitempty"` + AutoRepay bool `json:"auto_repay,omitempty"` + StpAct string `json:"stp_act,omitempty"` +} + +// WebsocketOrderResponse defines a websocket order response +type WebsocketOrderResponse struct { + Left types.Number `json:"left"` + UpdateTime types.Time `json:"update_time"` + Amount types.Number `json:"amount"` + CreateTime types.Time `json:"create_time"` + Price types.Number `json:"price"` + FinishAs string `json:"finish_as"` + TimeInForce string `json:"time_in_force"` + CurrencyPair currency.Pair `json:"currency_pair"` + Type string `json:"type"` + Account string `json:"account"` + Side string `json:"side"` + AmendText string `json:"amend_text"` + Text string `json:"text"` + Status string `json:"status"` + Iceberg types.Number `json:"iceberg"` + FilledTotal types.Number `json:"filled_total"` + ID string `json:"id"` + FillPrice types.Number `json:"fill_price"` + UpdateTimeMs types.Time `json:"update_time_ms"` + CreateTimeMs types.Time `json:"create_time_ms"` + Fee types.Number `json:"fee"` + FeeCurrency currency.Code `json:"fee_currency"` + PointFee types.Number `json:"point_fee"` + GTFee types.Number `json:"gt_fee"` + GTMakerFee types.Number `json:"gt_maker_fee"` + GTTakerFee types.Number `json:"gt_taker_fee"` + GTDiscount bool `json:"gt_discount"` + RebatedFee types.Number `json:"rebated_fee"` + RebatedFeeCurrency currency.Code `json:"rebated_fee_currency"` + STPID int `json:"stp_id"` + STPAct string `json:"stp_act"` +} + +// WebsocketOrderBatchRequest defines a websocket order batch request +type WebsocketOrderBatchRequest struct { + OrderID string `json:"id"` // This require id tag not order_id + Pair currency.Pair `json:"currency_pair"` + Account string `json:"account,omitempty"` +} + +// WebsocketOrderRequest defines a websocket order request +type WebsocketOrderRequest struct { + OrderID string `json:"order_id"` // This requires order_id tag + Pair string `json:"pair"` + Account string `json:"account,omitempty"` +} + +// WebsocketCancellAllResponse defines a websocket order cancel response +type WebsocketCancellAllResponse struct { + Pair currency.Pair `json:"currency_pair"` + Label string `json:"label"` + Message string `json:"message"` + Succeeded bool `json:"succeeded"` +} + +// WebsocketCancelParam is a struct to hold the parameters for cancelling orders +type WebsocketCancelParam struct { + Pair currency.Pair `json:"pair"` + Side string `json:"side"` + Account string `json:"account,omitempty"` +} + +// WebsocketAmendOrder defines a websocket amend order +type WebsocketAmendOrder struct { + OrderID string `json:"order_id"` + Pair currency.Pair `json:"currency_pair"` + Account string `json:"account,omitempty"` + AmendText string `json:"amend_text,omitempty"` + Price string `json:"price,omitempty"` + Amount string `json:"amount,omitempty"` +} diff --git a/exchanges/gateio/gateio_wrapper.go b/exchanges/gateio/gateio_wrapper.go index bb28b848ea7..239e3179245 100644 --- a/exchanges/gateio/gateio_wrapper.go +++ b/exchanges/gateio/gateio_wrapper.go @@ -218,6 +218,8 @@ func (g *Gateio) Setup(exch *config.Exchange) error { Unsubscriber: g.Unsubscribe, GenerateSubscriptions: g.generateSubscriptionsSpot, Connector: g.WsConnectSpot, + Authenticate: g.authenticateSpot, + MessageFilter: asset.Spot, BespokeGenerateMessageID: g.GenerateWebsocketMessageID, }) if err != nil { @@ -235,6 +237,7 @@ func (g *Gateio) Setup(exch *config.Exchange) error { Unsubscriber: g.FuturesUnsubscribe, GenerateSubscriptions: func() (subscription.List, error) { return g.GenerateFuturesDefaultSubscriptions(currency.USDT) }, Connector: g.WsFuturesConnect, + MessageFilter: asset.USDTMarginedFutures, BespokeGenerateMessageID: g.GenerateWebsocketMessageID, }) if err != nil { @@ -253,6 +256,7 @@ func (g *Gateio) Setup(exch *config.Exchange) error { Unsubscriber: g.FuturesUnsubscribe, GenerateSubscriptions: func() (subscription.List, error) { return g.GenerateFuturesDefaultSubscriptions(currency.BTC) }, Connector: g.WsFuturesConnect, + MessageFilter: asset.CoinMarginedFutures, BespokeGenerateMessageID: g.GenerateWebsocketMessageID, }) if err != nil { @@ -272,6 +276,7 @@ func (g *Gateio) Setup(exch *config.Exchange) error { Unsubscriber: g.DeliveryFuturesUnsubscribe, GenerateSubscriptions: g.GenerateDeliveryFuturesDefaultSubscriptions, Connector: g.WsDeliveryFuturesConnect, + MessageFilter: asset.DeliveryFutures, BespokeGenerateMessageID: g.GenerateWebsocketMessageID, }) if err != nil { @@ -288,6 +293,7 @@ func (g *Gateio) Setup(exch *config.Exchange) error { Unsubscriber: g.OptionsUnsubscribe, GenerateSubscriptions: g.GenerateOptionsDefaultSubscriptions, Connector: g.WsOptionsConnect, + MessageFilter: asset.Options, BespokeGenerateMessageID: g.GenerateWebsocketMessageID, }) } @@ -520,21 +526,16 @@ func (g *Gateio) FetchTradablePairs(ctx context.Context, a asset.Item) (currency } return pairs, nil case asset.DeliveryFutures: - btcContracts, err := g.GetAllDeliveryContracts(ctx, currency.BTC) - if err != nil { - return nil, err - } usdtContracts, err := g.GetAllDeliveryContracts(ctx, currency.USDT) if err != nil { return nil, err } - btcContracts = append(btcContracts, usdtContracts...) - pairs := make([]currency.Pair, 0, len(btcContracts)) - for x := range btcContracts { - if btcContracts[x].InDelisting { + pairs := make([]currency.Pair, 0, len(usdtContracts)) + for x := range usdtContracts { + if usdtContracts[x].InDelisting { continue } - p := strings.ToUpper(btcContracts[x].Name) + p := strings.ToUpper(usdtContracts[x].Name) if !g.IsValidPairString(p) { continue } @@ -633,6 +634,11 @@ func (g *Gateio) UpdateTickers(ctx context.Context, a asset.Item) error { var tickers []FuturesTicker var ticks []FuturesTicker for _, settle := range settlementCurrencies { + // All delivery futures are settled in USDT only, despite the API accepting a settlement currency parameter for all delivery futures endpoints + if a == asset.DeliveryFutures && !settle.Equal(currency.USDT) { + continue + } + if a == asset.Futures { ticks, err = g.GetFuturesTickers(ctx, settle, currency.EMPTYPAIR) } else { @@ -828,6 +834,11 @@ func (g *Gateio) UpdateAccountInfo(ctx context.Context, a asset.Item) (account.H case asset.Futures, asset.DeliveryFutures: currencies := make([]account.Balance, 0, 2) for x := range settlementCurrencies { + // All delivery futures are settled in USDT only, despite the API accepting a settlement currency parameter for all delivery futures endpoints + if a == asset.DeliveryFutures && !settlementCurrencies[x].Equal(currency.USDT) { + continue + } + var balance *FuturesAccount if a == asset.Futures { balance, err = g.QueryFuturesAccount(ctx, settlementCurrencies[x]) @@ -934,18 +945,16 @@ func (g *Gateio) GetRecentTrades(ctx context.Context, p currency.Pair, a asset.I var resp []trade.Data switch a { case asset.Spot, asset.Margin, asset.CrossMargin: - var tradeData []Trade if p.IsEmpty() { return nil, currency.ErrCurrencyPairEmpty } - tradeData, err = g.GetMarketTrades(ctx, p, 0, "", false, time.Time{}, time.Time{}, 0) + tradeData, err := g.GetMarketTrades(ctx, p, 0, "", false, time.Time{}, time.Time{}, 0) if err != nil { return nil, err } resp = make([]trade.Data, len(tradeData)) for i := range tradeData { - var side order.Side - side, err = order.StringToOrderSide(tradeData[i].Side) + side, err := order.StringToOrderSide(tradeData[i].Side) if err != nil { return nil, err } @@ -957,17 +966,15 @@ func (g *Gateio) GetRecentTrades(ctx context.Context, p currency.Pair, a asset.I Side: side, Price: tradeData[i].Price.Float64(), Amount: tradeData[i].Amount.Float64(), - Timestamp: tradeData[i].CreateTimeMs.Time(), + Timestamp: tradeData[i].CreateTime.Time(), } } case asset.Futures: - var settle currency.Code - settle, err = getSettlementFromCurrency(p) + settle, err := getSettlementFromCurrency(p) if err != nil { return nil, err } - var futuresTrades []TradingHistoryItem - futuresTrades, err = g.GetFuturesTradingHistory(ctx, settle, p, 0, 0, "", time.Time{}, time.Time{}) + futuresTrades, err := g.GetFuturesTradingHistory(ctx, settle, p, 0, 0, "", time.Time{}, time.Time{}) if err != nil { return nil, err } @@ -984,13 +991,11 @@ func (g *Gateio) GetRecentTrades(ctx context.Context, p currency.Pair, a asset.I } } case asset.DeliveryFutures: - var settle currency.Code - settle, err = getSettlementFromCurrency(p) + settle, err := getSettlementFromCurrency(p) if err != nil { return nil, err } - var deliveryTrades []DeliveryTradingHistory - deliveryTrades, err = g.GetDeliveryTradingHistory(ctx, settle, "", p.Upper(), 0, time.Time{}, time.Time{}) + deliveryTrades, err := g.GetDeliveryTradingHistory(ctx, settle, "", p.Upper(), 0, time.Time{}, time.Time{}) if err != nil { return nil, err } @@ -1007,8 +1012,7 @@ func (g *Gateio) GetRecentTrades(ctx context.Context, p currency.Pair, a asset.I } } case asset.Options: - var trades []TradingHistoryItem - trades, err = g.GetOptionsTradeHistory(ctx, p.Upper(), "", 0, 0, time.Time{}, time.Time{}) + trades, err := g.GetOptionsTradeHistory(ctx, p.Upper(), "", 0, 0, time.Time{}, time.Time{}) if err != nil { return nil, err } @@ -1103,8 +1107,8 @@ func (g *Gateio) SubmitOrder(ctx context.Context, s *order.Submit) (*order.Submi response.Pair = s.Pair response.Date = sOrder.CreateTime.Time() response.ClientOrderID = sOrder.Text - response.Date = sOrder.CreateTimeMs.Time() - response.LastUpdated = sOrder.UpdateTimeMs.Time() + response.Date = sOrder.CreateTime.Time() + response.LastUpdated = sOrder.UpdateTime.Time() return response, nil case asset.Futures: // TODO: See https://www.gate.io/docs/developers/apiv4/en/#create-a-futures-order @@ -1486,8 +1490,8 @@ func (g *Gateio) GetOrderInfo(ctx context.Context, orderID string, pair currency Status: orderStatus, Price: spotOrder.Price.Float64(), ExecutedAmount: spotOrder.Amount.Float64() - spotOrder.Left.Float64(), - Date: spotOrder.CreateTimeMs.Time(), - LastUpdated: spotOrder.UpdateTimeMs.Time(), + Date: spotOrder.CreateTime.Time(), + LastUpdated: spotOrder.UpdateTime.Time(), }, nil case asset.Futures, asset.DeliveryFutures: var settle currency.Code @@ -1694,8 +1698,8 @@ func (g *Gateio) GetActiveOrders(ctx context.Context, req *order.MultiOrderReque RemainingAmount: spotOrders[x].Orders[y].Left.Float64(), Price: spotOrders[x].Orders[y].Price.Float64(), AverageExecutedPrice: spotOrders[x].Orders[y].AverageFillPrice.Float64(), - Date: spotOrders[x].Orders[y].CreateTimeMs.Time(), - LastUpdated: spotOrders[x].Orders[y].UpdateTimeMs.Time(), + Date: spotOrders[x].Orders[y].CreateTime.Time(), + LastUpdated: spotOrders[x].Orders[y].UpdateTime.Time(), Exchange: g.Name, AssetType: req.AssetType, ClientOrderID: spotOrders[x].Orders[y].Text, @@ -1721,6 +1725,11 @@ func (g *Gateio) GetActiveOrders(ctx context.Context, req *order.MultiOrderReque } for settlement := range settlements { + // All delivery futures are settled in USDT only, despite the API accepting a settlement currency parameter for all delivery futures endpoints + if req.AssetType == asset.DeliveryFutures && !settlement.Equal(currency.USDT) { + continue + } + var futuresOrders []Order if req.AssetType == asset.Futures { futuresOrders, err = g.GetFuturesOrders(ctx, currency.EMPTYPAIR, "open", "", settlement, 0, 0, 0) @@ -1824,7 +1833,7 @@ func (g *Gateio) GetOrderHistory(ctx context.Context, req *order.MultiOrderReque for x := range req.Pairs { fPair := req.Pairs[x].Format(format) var spotOrders []SpotPersonalTradeHistory - spotOrders, err = g.GateIOGetPersonalTradingHistory(ctx, fPair, req.FromOrderID, 0, 0, req.AssetType == asset.CrossMargin, req.StartTime, req.EndTime) + spotOrders, err = g.GetMySpotTradingHistory(ctx, fPair, req.FromOrderID, 0, 0, req.AssetType == asset.CrossMargin, req.StartTime, req.EndTime) if err != nil { return nil, err } @@ -1861,9 +1870,9 @@ func (g *Gateio) GetOrderHistory(ctx context.Context, req *order.MultiOrderReque } var futuresOrder []TradingHistoryItem if req.AssetType == asset.Futures { - futuresOrder, err = g.GetMyPersonalTradingHistory(ctx, settle, "", req.FromOrderID, fPair, 0, 0, 0) + futuresOrder, err = g.GetMyFuturesTradingHistory(ctx, settle, "", req.FromOrderID, fPair, 0, 0, 0) } else { - futuresOrder, err = g.GetDeliveryPersonalTradingHistory(ctx, settle, req.FromOrderID, fPair, 0, 0, 0, "") + futuresOrder, err = g.GetMyDeliveryTradingHistory(ctx, settle, req.FromOrderID, fPair, 0, 0, 0, "") } if err != nil { return nil, err @@ -1885,8 +1894,7 @@ func (g *Gateio) GetOrderHistory(ctx context.Context, req *order.MultiOrderReque case asset.Options: for x := range req.Pairs { fPair := req.Pairs[x].Format(format) - var optionOrders []OptionTradingHistory - optionOrders, err = g.GetOptionsPersonalTradingHistory(ctx, fPair.String(), fPair.Upper(), 0, 0, req.StartTime, req.EndTime) + optionOrders, err := g.GetMyOptionsTradingHistory(ctx, fPair.String(), fPair.Upper(), 0, 0, req.StartTime, req.EndTime) if err != nil { return nil, err } @@ -2111,61 +2119,54 @@ func (g *Gateio) GetFuturesContractDetails(ctx context.Context, item asset.Item) } return resp, nil case asset.DeliveryFutures: - var resp []futures.Contract - for k := range settlementCurrencies { - contracts, err := g.GetAllDeliveryContracts(ctx, settlementCurrencies[k]) + contracts, err := g.GetAllDeliveryContracts(ctx, currency.USDT) + if err != nil { + return nil, err + } + resp := make([]futures.Contract, len(contracts)) + for i := range contracts { + name, err := currency.NewPairFromString(contracts[i].Name) if err != nil { return nil, err } - contractsToAdd := make([]futures.Contract, len(contracts)) - for j := range contracts { - var name, underlying currency.Pair - name, err = currency.NewPairFromString(contracts[j].Name) - if err != nil { - return nil, err - } - underlying, err = currency.NewPairFromString(contracts[j].Underlying) - if err != nil { - return nil, err - } - var ct futures.ContractType - // no start information, inferring it based on contract type - // gateio also reuses contracts for kline data, cannot use a lookup to see the first trade - var s, e time.Time - e = contracts[j].ExpireTime.Time() - switch contracts[j].Cycle { - case "WEEKLY": - ct = futures.Weekly - s = e.Add(-kline.OneWeek.Duration()) - case "BI-WEEKLY": - ct = futures.Fortnightly - s = e.Add(-kline.TwoWeek.Duration()) - case "QUARTERLY": - ct = futures.Quarterly - s = e.Add(-kline.ThreeMonth.Duration()) - case "BI-QUARTERLY": - ct = futures.HalfYearly - s = e.Add(-kline.SixMonth.Duration()) - default: - ct = futures.LongDated - } - contractsToAdd[j] = futures.Contract{ - Exchange: g.Name, - Name: name, - Underlying: underlying, - Asset: item, - StartDate: s, - EndDate: e, - SettlementType: futures.Linear, - IsActive: !contracts[j].InDelisting, - Type: ct, - SettlementCurrencies: currency.Currencies{settlementCurrencies[k]}, - MarginCurrency: currency.Code{}, - Multiplier: contracts[j].QuantoMultiplier.Float64(), - MaxLeverage: contracts[j].LeverageMax.Float64(), - } + underlying, err := currency.NewPairFromString(contracts[i].Underlying) + if err != nil { + return nil, err + } + // no start information, inferring it based on contract type + // gateio also reuses contracts for kline data, cannot use a lookup to see the first trade + var s time.Time + e := contracts[i].ExpireTime.Time() + ct := futures.LongDated + switch contracts[i].Cycle { + case "WEEKLY": + ct = futures.Weekly + s = e.Add(-kline.OneWeek.Duration()) + case "BI-WEEKLY": + ct = futures.Fortnightly + s = e.Add(-kline.TwoWeek.Duration()) + case "QUARTERLY": + ct = futures.Quarterly + s = e.Add(-kline.ThreeMonth.Duration()) + case "BI-QUARTERLY": + ct = futures.HalfYearly + s = e.Add(-kline.SixMonth.Duration()) + } + resp[i] = futures.Contract{ + Exchange: g.Name, + Name: name, + Underlying: underlying, + Asset: item, + StartDate: s, + EndDate: e, + SettlementType: futures.Linear, + IsActive: !contracts[i].InDelisting, + Type: ct, + SettlementCurrencies: currency.Currencies{currency.USDT}, + MarginCurrency: currency.Code{}, + Multiplier: contracts[i].QuantoMultiplier.Float64(), + MaxLeverage: contracts[i].LeverageMax.Float64(), } - resp = append(resp, contractsToAdd...) } return resp, nil } @@ -2324,7 +2325,7 @@ func (g *Gateio) GetLatestFundingRates(ctx context.Context, r *fundingrate.Lates if err != nil { return nil, err } - contract, err := g.GetSingleContract(ctx, settle, fPair.String()) + contract, err := g.GetFuturesContract(ctx, settle, fPair.String()) if err != nil { return nil, err } @@ -2410,11 +2411,11 @@ func (g *Gateio) GetOpenInterest(ctx context.Context, k ...key.PairAsset) ([]fut return nil, err } if !isEnabled { - return nil, fmt.Errorf("%w %v", asset.ErrNotEnabled, k[0].Pair()) + return nil, fmt.Errorf("%w: %v", currency.ErrPairNotEnabled, k[0].Pair()) } switch k[0].Asset { case asset.DeliveryFutures: - contractResp, err := g.GetSingleDeliveryContracts(ctx, currency.USDT, p) + contractResp, err := g.GetDeliveryContract(ctx, currency.USDT, p) if err != nil { return nil, err } @@ -2432,7 +2433,7 @@ func (g *Gateio) GetOpenInterest(ctx context.Context, k ...key.PairAsset) ([]fut }, nil case asset.Futures: for _, s := range settlementCurrencies { - contractResp, err := g.GetSingleContract(ctx, s, p.String()) + contractResp, err := g.GetFuturesContract(ctx, s, p.String()) if err != nil { continue } diff --git a/exchanges/interfaces.go b/exchanges/interfaces.go index 0f6071a864d..6ef32727d63 100644 --- a/exchanges/interfaces.go +++ b/exchanges/interfaces.go @@ -51,6 +51,7 @@ type IBotExchange interface { UpdateTradablePairs(ctx context.Context, forceUpdate bool) error GetEnabledPairs(a asset.Item) (currency.Pairs, error) GetAvailablePairs(a asset.Item) (currency.Pairs, error) + GetPairFormat(asset.Item, bool) (currency.PairFormat, error) SetPairs(pairs currency.Pairs, a asset.Item, enabled bool) error GetAssetTypes(enabled bool) asset.Items GetRecentTrades(ctx context.Context, p currency.Pair, a asset.Item) ([]trade.Data, error) diff --git a/exchanges/kline/kline.go b/exchanges/kline/kline.go index 5a8ea21ebb1..a972af3af18 100644 --- a/exchanges/kline/kline.go +++ b/exchanges/kline/kline.go @@ -135,6 +135,9 @@ func (i Interval) Duration() time.Duration { // Short returns short string version of interval func (i Interval) Short() string { + if i == Raw { + return "raw" + } s := i.String() if strings.HasSuffix(s, "m0s") { s = s[:len(s)-2] @@ -149,6 +152,10 @@ func (i Interval) Short() string { // It does not validate the duration is aligned, only that it is a parsable duration func (i *Interval) UnmarshalJSON(text []byte) error { text = bytes.Trim(text, `"`) + if string(text) == "raw" { + *i = Raw + return nil + } if len(bytes.TrimLeft(text, `0123456789`)) > 0 { // contains non-numerics, ParseDuration can handle errors d, err := time.ParseDuration(string(text)) if err != nil { diff --git a/exchanges/kline/kline_test.go b/exchanges/kline/kline_test.go index a17e21637e1..cf29e9bffb1 100644 --- a/exchanges/kline/kline_test.go +++ b/exchanges/kline/kline_test.go @@ -143,9 +143,9 @@ func TestKlineDuration(t *testing.T) { func TestKlineShort(t *testing.T) { t.Parallel() - if OneDay.Short() != "24h" { - t.Fatalf("unexpected result: %v", OneDay.Short()) - } + assert.Equal(t, "24h", OneDay.Short(), "One day should show as 24h") + assert.Equal(t, "1h", OneHour.Short(), "One hour should truncate 0m0s suffix") + assert.Equal(t, "raw", Raw.Short(), "Raw should return raw") } func TestDurationToWord(t *testing.T) { @@ -1403,16 +1403,15 @@ func TestGetIntervalResultLimit(t *testing.T) { } func TestUnmarshalJSON(t *testing.T) { - i := new(Interval) - err := i.UnmarshalJSON([]byte(`"3m"`)) - assert.NoError(t, err, "UnmarshalJSON should not error") - assert.Equal(t, time.Minute*3, i.Duration(), "Interval should have correct value") - err = i.UnmarshalJSON([]byte(`"15s"`)) - assert.NoError(t, err, "UnmarshalJSON should not error") - assert.Equal(t, time.Second*15, i.Duration(), "Interval should have correct value") - err = i.UnmarshalJSON([]byte(`720000000000`)) - assert.NoError(t, err, "UnmarshalJSON should not error") - assert.Equal(t, time.Minute*12, i.Duration(), "Interval should have correct value") - err = i.UnmarshalJSON([]byte(`"6hedgehogs"`)) + t.Parallel() + var i Interval + for _, tt := range []struct { + in string + exp Interval + }{{`"3m"`, ThreeMin}, {`"15s"`, FifteenSecond}, {`720000000000`, OneMin * 12}, {`"-1ns"`, Raw}, {`"raw"`, Raw}} { + err := i.UnmarshalJSON([]byte(tt.in)) + assert.NoErrorf(t, err, "UnmarshalJSON should not error on %q", tt.in) + } + err := i.UnmarshalJSON([]byte(`"6hedgehogs"`)) assert.ErrorContains(t, err, "unknown unit", "UnmarshalJSON should error") } diff --git a/exchanges/kraken/kraken_websocket.go b/exchanges/kraken/kraken_websocket.go index 22088590463..5b276055181 100644 --- a/exchanges/kraken/kraken_websocket.go +++ b/exchanges/kraken/kraken_websocket.go @@ -231,7 +231,7 @@ func (k *Kraken) wsHandleData(respRaw []byte) error { return nil case krakenWsCancelOrderStatus, krakenWsCancelAllOrderStatus, krakenWsAddOrderStatus, krakenWsSubscriptionStatus: // All of these should have found a listener already - return fmt.Errorf("%w: %s %v", stream.ErrNoMessageListener, event, reqID) + return fmt.Errorf("%w: %s %v", stream.ErrSignatureNotMatched, event, reqID) case krakenWsSystemStatus: return k.wsProcessSystemStatus(respRaw) default: diff --git a/exchanges/kucoin/README.md b/exchanges/kucoin/README.md index 3439d186f84..b027535efcd 100644 --- a/exchanges/kucoin/README.md +++ b/exchanges/kucoin/README.md @@ -41,6 +41,9 @@ Default Authenticated Subscriptions: Subscriptions are subject to enabled assets and pairs. +Margin subscriptions for ticker, orderbook and All trades are merged into Spot subscriptions because duplicates are not allowed, +unless Spot subscription does not exist, i.e. Spot asset not enabled, or subscription configured only for Margin + Limitations: - 100 symbols per subscription - 300 symbols per connection diff --git a/exchanges/kucoin/kucoin_test.go b/exchanges/kucoin/kucoin_test.go index 86c3ac38b33..aab3357280d 100644 --- a/exchanges/kucoin/kucoin_test.go +++ b/exchanges/kucoin/kucoin_test.go @@ -2266,52 +2266,53 @@ func TestGenerateSubscriptions(t *testing.T) { // Only in Spot: BTC-USDT, ETH-USDT // In Both: ETH-BTC, LTC-USDT // Only in Margin: TRX-BTC, SOL-USDC - subPairs := currency.Pairs{} - for _, pp := range [][]string{ - {"BTC", "USDT", "-"}, {"ETH", "BTC", "-"}, {"ETH", "USDT", "-"}, {"LTC", "USDT", "-"}, // Spot - {"ETH", "BTC", "-"}, {"LTC", "USDT", "-"}, {"SOL", "USDC", "-"}, {"TRX", "BTC", "-"}, // Margin - {"ETH", "USDCM", ""}, {"SOL", "USDTM", ""}, {"XBT", "USDCM", ""}, // Futures + pairs := map[string]currency.Pairs{} + for a, ss := range map[string][]string{ + "spot": {"BTC-USDT", "ETH-BTC", "ETH-USDT", "LTC-USDT"}, + "margin": {"ETH-BTC", "LTC-USDT", "SOL-USDC", "TRX-BTC"}, + "futures": {"ETHUSDCM", "SOLUSDTM", "XBTUSDCM"}, } { - subPairs = append(subPairs, currency.NewPairWithDelimiter(pp[0], pp[1], pp[2])) + for _, s := range ss { + p, err := currency.NewPairFromString(s) + require.NoError(t, err, "NewPairFromString must not error") + pairs[a] = pairs[a].Add(p) + } } + pairs["both"] = common.SortStrings(pairs["spot"].Add(pairs["margin"]...)) exp := subscription.List{ - {Channel: subscription.TickerChannel, Asset: asset.Spot, Pairs: subPairs[0:4], QualifiedChannel: "/market/ticker:" + subPairs[0:4].Join()}, - {Channel: subscription.TickerChannel, Asset: asset.Margin, Pairs: subPairs[6:8], QualifiedChannel: "/market/ticker:" + subPairs[6:8].Join()}, - {Channel: subscription.TickerChannel, Asset: asset.Futures, Pairs: subPairs[8:], QualifiedChannel: "/contractMarket/tickerV2:" + subPairs[8:].Join()}, - {Channel: subscription.OrderbookChannel, Asset: asset.Spot, Pairs: subPairs[0:4], QualifiedChannel: "/spotMarket/level2Depth5:" + subPairs[0:4].Join(), - Interval: kline.HundredMilliseconds}, - {Channel: subscription.OrderbookChannel, Asset: asset.Margin, Pairs: subPairs[6:8], QualifiedChannel: "/spotMarket/level2Depth5:" + subPairs[6:8].Join(), + {Channel: subscription.TickerChannel, Asset: asset.Spot, Pairs: pairs["both"], QualifiedChannel: "/market/ticker:" + pairs["both"].Join()}, + {Channel: subscription.TickerChannel, Asset: asset.Futures, Pairs: pairs["futures"], QualifiedChannel: "/contractMarket/tickerV2:" + pairs["futures"].Join()}, + {Channel: subscription.OrderbookChannel, Asset: asset.Spot, Pairs: pairs["both"], QualifiedChannel: "/spotMarket/level2Depth5:" + pairs["both"].Join(), Interval: kline.HundredMilliseconds}, - {Channel: subscription.OrderbookChannel, Asset: asset.Futures, Pairs: subPairs[8:], QualifiedChannel: "/contractMarket/level2Depth5:" + subPairs[8:].Join(), + {Channel: subscription.OrderbookChannel, Asset: asset.Futures, Pairs: pairs["futures"], QualifiedChannel: "/contractMarket/level2Depth5:" + pairs["futures"].Join(), Interval: kline.HundredMilliseconds}, - {Channel: subscription.AllTradesChannel, Asset: asset.Spot, Pairs: subPairs[0:4], QualifiedChannel: "/market/match:" + subPairs[0:4].Join()}, - {Channel: subscription.AllTradesChannel, Asset: asset.Margin, Pairs: subPairs[6:8], QualifiedChannel: "/market/match:" + subPairs[6:8].Join()}, + {Channel: subscription.AllTradesChannel, Asset: asset.Spot, Pairs: pairs["both"], QualifiedChannel: "/market/match:" + pairs["both"].Join()}, } subs, err := ku.generateSubscriptions() - assert.NoError(t, err, "generateSubscriptions must not error") + require.NoError(t, err, "generateSubscriptions must not error") testsubs.EqualLists(t, exp, subs) ku.Websocket.SetCanUseAuthenticatedEndpoints(true) var loanPairs currency.Pairs - loanCurrs := common.SortStrings(subPairs[0:8].GetCurrencies()) + loanCurrs := common.SortStrings(pairs["both"].GetCurrencies()) for _, c := range loanCurrs { loanPairs = append(loanPairs, currency.Pair{Base: c}) } exp = append(exp, subscription.List{ - {Asset: asset.Futures, Channel: futuresTradeOrderChannel, QualifiedChannel: "/contractMarket/tradeOrders", Pairs: subPairs[8:]}, - {Asset: asset.Futures, Channel: futuresStopOrdersLifecycleEventChannel, QualifiedChannel: "/contractMarket/advancedOrders", Pairs: subPairs[8:]}, - {Asset: asset.Futures, Channel: futuresAccountBalanceEventChannel, QualifiedChannel: "/contractAccount/wallet", Pairs: subPairs[8:]}, - {Asset: asset.Margin, Channel: marginPositionChannel, QualifiedChannel: "/margin/position", Pairs: subPairs[4:8]}, + {Asset: asset.Futures, Channel: futuresTradeOrderChannel, QualifiedChannel: "/contractMarket/tradeOrders", Pairs: pairs["futures"]}, + {Asset: asset.Futures, Channel: futuresStopOrdersLifecycleEventChannel, QualifiedChannel: "/contractMarket/advancedOrders", Pairs: pairs["futures"]}, + {Asset: asset.Futures, Channel: futuresAccountBalanceEventChannel, QualifiedChannel: "/contractAccount/wallet", Pairs: pairs["futures"]}, + {Asset: asset.Margin, Channel: marginPositionChannel, QualifiedChannel: "/margin/position", Pairs: pairs["margin"]}, {Asset: asset.Margin, Channel: marginLoanChannel, QualifiedChannel: "/margin/loan:" + loanCurrs.Join(), Pairs: loanPairs}, {Channel: accountBalanceChannel, QualifiedChannel: "/account/balance"}, }...) subs, err = ku.generateSubscriptions() - assert.NoError(t, err, "generateSubscriptions with Auth must not error") + require.NoError(t, err, "generateSubscriptions with Auth must not error") testsubs.EqualLists(t, exp, subs) } @@ -2320,21 +2321,16 @@ func TestGenerateTickerAllSub(t *testing.T) { ku := testInstance(t) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes avail, err := ku.GetAvailablePairs(asset.Spot) - assert.NoError(t, err, "GetAvailablePairs must not error") - for i := 0; i <= 10; i++ { - err = ku.CurrencyPairs.EnablePair(asset.Spot, avail[i]) - assert.NoError(t, common.ExcludeError(err, currency.ErrPairAlreadyEnabled), "EnablePair must not error") - } - - enabled, err := ku.GetEnabledPairs(asset.Spot) - assert.NoError(t, err, "GetEnabledPairs must not error") + require.NoError(t, err, "GetAvailablePairs must not error") + err = ku.CurrencyPairs.StorePairs(asset.Spot, avail[:11], true) + require.NoError(t, err, "StorePairs must not error") ku.Features.Subscriptions = subscription.List{{Channel: subscription.TickerChannel, Asset: asset.Spot}} exp := subscription.List{ - {Channel: subscription.TickerChannel, Asset: asset.Spot, QualifiedChannel: "/market/ticker:all", Pairs: enabled}, + {Channel: subscription.TickerChannel, Asset: asset.Spot, QualifiedChannel: "/market/ticker:all", Pairs: avail[:11]}, } subs, err := ku.generateSubscriptions() - assert.NoError(t, err, "generateSubscriptions with Auth must not error") + require.NoError(t, err, "generateSubscriptions with Auth must not error") testsubs.EqualLists(t, exp, subs) } @@ -2353,7 +2349,7 @@ func TestGenerateOtherSubscriptions(t *testing.T) { ku.Features.Subscriptions = subscription.List{s} got, err := ku.generateSubscriptions() assert.NoError(t, err, "generateSubscriptions should not error") - assert.Len(t, got, 1, "Should generate just one sub") + require.Len(t, got, 1, "Must generate just one sub") assert.NotEmpty(t, got[0].QualifiedChannel, "Qualified Channel should not be empty") if got[0].Channel == subscription.CandlesChannel { assert.Equal(t, "/market/candles:BTC-USDT_4hour,ETH-BTC_4hour,ETH-USDT_4hour,LTC-USDT_4hour", got[0].QualifiedChannel, "QualifiedChannel should be correct") @@ -2361,6 +2357,43 @@ func TestGenerateOtherSubscriptions(t *testing.T) { } } +// TestGenerateMarginSubscriptions is a regression test for #1755 and ensures margin subscriptions work without spot subs +func TestGenerateMarginSubscriptions(t *testing.T) { + t.Parallel() + + ku := testInstance(t) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + + avail, err := ku.GetAvailablePairs(asset.Spot) + require.NoError(t, err, "GetAvailablePairs must not error storing spot pairs") + avail = common.SortStrings(avail) + err = ku.CurrencyPairs.StorePairs(asset.Margin, avail[:6], true) + require.NoError(t, err, "StorePairs must not error storing margin pairs") + err = ku.CurrencyPairs.StorePairs(asset.Spot, avail[:3], true) + require.NoError(t, err, "StorePairs must not error storing spot pairs") + + ku.Features.Subscriptions = subscription.List{{Channel: subscription.TickerChannel, Asset: asset.Margin}} + subs, err := ku.Features.Subscriptions.ExpandTemplates(ku) + require.NoError(t, err, "ExpandTemplates must not error") + require.Len(t, subs, 1, "Must generate just one sub") + assert.Equal(t, asset.Margin, subs[0].Asset, "Asset should be correct") + assert.Equal(t, "/market/ticker:"+avail[:6].Join(), subs[0].QualifiedChannel, "QualifiedChannel should be correct") + + require.NoError(t, ku.CurrencyPairs.SetAssetEnabled(asset.Margin, false), "SetAssetEnabled Spot must not error") + require.NoError(t, err, "SetAssetEnabled must not error") + ku.Features.Subscriptions = subscription.List{{Channel: subscription.TickerChannel, Asset: asset.All}} + subs, err = ku.Features.Subscriptions.ExpandTemplates(ku) + require.NoError(t, err, "mergeMarginPairs must not cause errAssetRecords by adding an empty asset when Margin is disabled") + require.NotEmpty(t, subs, "ExpandTemplates must return some subs") + + require.NoError(t, ku.CurrencyPairs.SetAssetEnabled(asset.Margin, true), "SetAssetEnabled Margin must not error") + require.NoError(t, ku.CurrencyPairs.SetAssetEnabled(asset.Spot, false), "SetAssetEnabled Spot must not error") + require.NoError(t, ku.CurrencyPairs.SetAssetEnabled(asset.Futures, false), "SetAssetEnabled Futures must not error") + ku.Features.Subscriptions = subscription.List{{Channel: subscription.TickerChannel, Asset: asset.All}} + subs, err = ku.Features.Subscriptions.ExpandTemplates(ku) + require.NoError(t, err, "mergeMarginPairs must not cause errAssetRecords by adding an empty asset when Spot is disabled") + require.NotEmpty(t, subs, "ExpandTemplates must return some subs") +} + // TestCheckSubscriptions ensures checkSubscriptions upgrades user config correctly func TestCheckSubscriptions(t *testing.T) { t.Parallel() @@ -2934,8 +2967,8 @@ func TestSubscribeBatches(t *testing.T) { } subs, err := ku.generateSubscriptions() - assert.NoError(t, err, "generateSubscriptions must not error") - assert.Len(t, subs, len(ku.Features.Subscriptions), "Must generate batched subscriptions") + require.NoError(t, err, "generateSubscriptions must not error") + require.Len(t, subs, len(ku.Features.Subscriptions), "Must generate batched subscriptions") err = ku.Subscribe(subs) assert.NoError(t, err, "Subscribe to small batches should not error") @@ -2953,32 +2986,32 @@ func TestSubscribeBatchLimit(t *testing.T) { testexch.SetupWs(t, ku) avail, err := ku.GetAvailablePairs(asset.Spot) - assert.NoError(t, err, "GetAvailablePairs must not error") + require.NoError(t, err, "GetAvailablePairs must not error") err = ku.CurrencyPairs.StorePairs(asset.Spot, avail[:299], true) - assert.NoError(t, err, "StorePairs must not error") + require.NoError(t, err, "StorePairs must not error") ku.Features.Subscriptions = subscription.List{{Asset: asset.Spot, Channel: subscription.AllTradesChannel}} subs, err := ku.generateSubscriptions() - assert.NoError(t, err, "generateSubscriptions must not error") - assert.Len(t, subs, 3, "Must get 3 subs") + require.NoError(t, err, "generateSubscriptions must not error") + require.Len(t, subs, 3, "Must get 3 subs") err = ku.Subscribe(subs) - assert.NoError(t, err, "Subscribe must not error") + require.NoError(t, err, "Subscribe must not error") err = ku.Unsubscribe(subs) - assert.NoError(t, err, "Unsubscribe must not error") + require.NoError(t, err, "Unsubscribe must not error") err = ku.CurrencyPairs.StorePairs(asset.Spot, avail[:320], true) - assert.NoError(t, err, "StorePairs must not error") + require.NoError(t, err, "StorePairs must not error") ku.Features.Subscriptions = subscription.List{{Asset: asset.Spot, Channel: subscription.AllTradesChannel}} subs, err = ku.generateSubscriptions() - assert.NoError(t, err, "generateSubscriptions must not error") - assert.Len(t, subs, 4, "Must get 4 subs") + require.NoError(t, err, "generateSubscriptions must not error") + require.Len(t, subs, 4, "Must get 4 subs") err = ku.Subscribe(subs) - assert.ErrorContains(t, err, "exceed max subscription count limitation of 300 per session", "Subscribe to MarketSnapshot must error above connection symbol limit") + assert.ErrorContains(t, err, "exceed max subscription count limitation of 300 per session", "Subscribe to MarketSnapshot should error above connection symbol limit") } func TestSubscribeTickerAll(t *testing.T) { @@ -2989,20 +3022,20 @@ func TestSubscribeTickerAll(t *testing.T) { testexch.SetupWs(t, ku) avail, err := ku.GetAvailablePairs(asset.Spot) - assert.NoError(t, err, "GetAvailablePairs must not error") + require.NoError(t, err, "GetAvailablePairs must not error") err = ku.CurrencyPairs.StorePairs(asset.Spot, avail[:500], true) - assert.NoError(t, err, "StorePairs must not error") + require.NoError(t, err, "StorePairs must not error") ku.Features.Subscriptions = subscription.List{{Asset: asset.Spot, Channel: subscription.TickerChannel}} subs, err := ku.generateSubscriptions() - assert.NoError(t, err, "generateSubscriptions must not error") - assert.Len(t, subs, 1, "Must generate one subscription") - assert.Equal(t, "/market/ticker:all", subs[0].QualifiedChannel, "QualifiedChannel must be correct") + require.NoError(t, err, "generateSubscriptions must not error") + require.Len(t, subs, 1, "Must generate one subscription") + assert.Equal(t, "/market/ticker:all", subs[0].QualifiedChannel, "QualifiedChannel should be correct") err = ku.Subscribe(subs) - assert.NoError(t, err, "Subscribe to must not error") + assert.NoError(t, err, "Subscribe to should not error") } func TestSeedLocalCache(t *testing.T) { @@ -3957,7 +3990,7 @@ func TestGetCurrencyTradeURL(t *testing.T) { func testInstance(tb testing.TB) *Kucoin { tb.Helper() kucoin := new(Kucoin) - assert.NoError(tb, testexch.Setup(kucoin), "Test instance Setup must not error") + require.NoError(tb, testexch.Setup(kucoin), "Test instance Setup must not error") kucoin.obm = &orderbookManager{ state: make(map[currency.Code]map[currency.Code]map[asset.Item]*update), jobs: make(chan job, maxWSOrderbookJobs), diff --git a/exchanges/kucoin/kucoin_websocket.go b/exchanges/kucoin/kucoin_websocket.go index 8b4aa535e26..a1c43bb68d5 100644 --- a/exchanges/kucoin/kucoin_websocket.go +++ b/exchanges/kucoin/kucoin_websocket.go @@ -215,18 +215,14 @@ func (ku *Kucoin) wsReadData() { // wsHandleData processes a websocket incoming data. func (ku *Kucoin) wsHandleData(respData []byte) error { resp := WsPushData{} - err := json.Unmarshal(respData, &resp) - if err != nil { + if err := json.Unmarshal(respData, &resp); err != nil { return err } if resp.Type == "pong" || resp.Type == "welcome" { return nil } if resp.ID != "" { - if !ku.Websocket.Match.IncomingWithData("msgID:"+resp.ID, respData) { - return fmt.Errorf("%w: %s", stream.ErrNoMessageListener, resp.ID) - } - return nil + return ku.Websocket.Match.RequireMatchWithData("msgID:"+resp.ID, respData) } topicInfo := strings.Split(resp.Topic, ":") switch topicInfo[0] { @@ -1074,11 +1070,8 @@ func (ku *Kucoin) generateSubscriptions() (subscription.List, error) { func (ku *Kucoin) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) { return template.New("master.tmpl"). Funcs(template.FuncMap{ - "channelName": channelName, - "removeSpotFromMargin": func(s *subscription.Subscription, ap map[asset.Item]currency.Pairs) string { - spotPairs, _ := ku.GetEnabledPairs(asset.Spot) - return removeSpotFromMargin(s, ap, spotPairs) - }, + "channelName": channelName, + "mergeMarginPairs": ku.mergeMarginPairs, "isCurrencyChannel": isCurrencyChannel, "isSymbolChannel": isSymbolChannel, "channelInterval": channelInterval, @@ -1690,13 +1683,45 @@ func channelName(s *subscription.Subscription, a asset.Item) string { return s.Channel } -// removeSpotFromMargin removes spot pairs from margin pairs in the supplied AssetPairs map for subscriptions to non-margin endpoints -func removeSpotFromMargin(s *subscription.Subscription, ap map[asset.Item]currency.Pairs, spotPairs currency.Pairs) string { +// mergeMarginPairs merges margin pairs into spot pairs for shared subs (ticker, orderbook, etc) if Spot asset and sub are enabled, +// because Kucoin errors on duplicate pairs in separate subs, and doesn't have separate subs for spot and margin +func (ku *Kucoin) mergeMarginPairs(s *subscription.Subscription, ap map[asset.Item]currency.Pairs) string { if strings.HasPrefix(s.Channel, "/margin") { return "" } - if p, ok := ap[asset.Margin]; ok { - ap[asset.Margin] = p.Remove(spotPairs...) + wantKey := &subscription.IgnoringAssetKey{Subscription: s} + switch s.Asset { + case asset.All: + _, marginEnabled := ap[asset.Margin] + _, spotEnabled := ap[asset.Spot] + if marginEnabled && spotEnabled { + marginPairs, _ := ku.GetEnabledPairs(asset.Margin) + ap[asset.Spot] = common.SortStrings(ap[asset.Spot].Add(marginPairs...)) + ap[asset.Margin] = currency.Pairs{} + } + case asset.Spot: + // If there's a margin sub then we should merge the pairs into spot + hasMarginSub := slices.ContainsFunc(ku.Features.Subscriptions, func(sB *subscription.Subscription) bool { + if sB.Asset != asset.Margin && sB.Asset != asset.All { + return false + } + return wantKey.Match(&subscription.IgnoringAssetKey{Subscription: sB}) + }) + if hasMarginSub { + marginPairs, _ := ku.GetEnabledPairs(asset.Margin) + ap[asset.Spot] = common.SortStrings(ap[asset.Spot].Add(marginPairs...)) + } + case asset.Margin: + // If there's a spot sub, all margin pairs are already merged, so empty the margin pairs + hasSpotSub := slices.ContainsFunc(ku.Features.Subscriptions, func(sB *subscription.Subscription) bool { + if sB.Asset != asset.Spot && sB.Asset != asset.All { + return false + } + return wantKey.Match(&subscription.IgnoringAssetKey{Subscription: sB}) + }) + if hasSpotSub { + ap[asset.Margin] = currency.Pairs{} + } } return "" } @@ -1753,27 +1778,27 @@ func joinPairsWithInterval(b currency.Pairs, s *subscription.Subscription) strin } const subTplText = ` -{{- removeSpotFromMargin $.S $.AssetPairs -}} +{{- mergeMarginPairs $.S $.AssetPairs }} {{- if isCurrencyChannel $.S }} - {{ channelName $.S $.S.Asset -}} : {{- (assetCurrencies $.S $.AssetPairs).Join -}} + {{- channelName $.S $.S.Asset -}} : {{- (assetCurrencies $.S $.AssetPairs).Join }} {{- else if isSymbolChannel $.S }} - {{ range $asset, $pairs := $.AssetPairs }} + {{- range $asset, $pairs := $.AssetPairs }} {{- with $name := channelName $.S $asset }} - {{- if and (eq $name "/market/ticker") (gt (len $pairs) 10) -}} + {{- if and (eq $name "/market/ticker") (gt (len $pairs) 10) }} {{- $name -}} :all - {{- with $i := channelInterval $.S -}}_{{- $i -}}{{- end -}} - {{- $.BatchSize -}} {{ len $pairs }} - {{- else -}} - {{- range $b := batch $pairs 100 -}} - {{- $name -}} : {{- joinPairsWithInterval $b $.S -}} - {{ $.PairSeparator }} - {{- end -}} + {{- with $i := channelInterval $.S }}_{{ $i }}{{ end }} + {{- $.BatchSize }} {{- len $pairs }} + {{- else }} + {{- range $b := batch $pairs 100 }} + {{- $name -}} : {{- joinPairsWithInterval $b $.S }} + {{- $.PairSeparator }} + {{- end }} {{- $.BatchSize -}} 100 {{- end }} {{- end }} - {{ $.AssetSeparator }} + {{- $.AssetSeparator }} {{- end }} -{{- else -}} - {{ channelName $.S $.S.Asset }} +{{- else }} + {{- channelName $.S $.S.Asset }} {{- end }} ` diff --git a/exchanges/okx/okx_test.go b/exchanges/okx/okx_test.go index a5d86fde9f7..14138bbea33 100644 --- a/exchanges/okx/okx_test.go +++ b/exchanges/okx/okx_test.go @@ -3380,80 +3380,33 @@ func TestIsPerpetualFutureCurrency(t *testing.T) { func TestGetAssetsFromInstrumentTypeOrID(t *testing.T) { t.Parallel() - _, err := ok.GetAssetsFromInstrumentTypeOrID("", "") - if !errors.Is(err, errEmptyArgument) { - t.Error(err) - } - assets, err := ok.GetAssetsFromInstrumentTypeOrID("SPOT", "") - if !errors.Is(err, nil) { - t.Error(err) - } - if len(assets) != 1 { - t.Errorf("received %v expected %v", len(assets), 1) - } - if assets[0] != asset.Spot { - t.Errorf("received %v expected %v", assets[0], asset.Spot) - } + ok := new(Okx) //nolint:govet // Intentional shadow + require.NoError(t, testexch.Setup(ok), "Setup must not error") - assets, err = ok.GetAssetsFromInstrumentTypeOrID("", ok.CurrencyPairs.Pairs[asset.Futures].Enabled[0].String()) - if !errors.Is(err, nil) { - t.Error(err) - } - if len(assets) != 1 { - t.Errorf("received %v expected %v", len(assets), 1) - } - if assets[0] != asset.Futures { - t.Errorf("received %v expected %v", assets[0], asset.Futures) - } + _, err := ok.GetAssetsFromInstrumentTypeOrID("", "") + assert.ErrorIs(t, err, errEmptyArgument) - assets, err = ok.GetAssetsFromInstrumentTypeOrID("", ok.CurrencyPairs.Pairs[asset.PerpetualSwap].Enabled[0].String()) - if !errors.Is(err, nil) { - t.Error(err) - } - if len(assets) != 1 { - t.Errorf("received %v expected %v", len(assets), 1) - } - if assets[0] != asset.PerpetualSwap { - t.Errorf("received %v expected %v", assets[0], asset.PerpetualSwap) + for _, a := range []asset.Item{asset.Spot, asset.Futures, asset.PerpetualSwap, asset.Options} { + symbol := "" + if a != asset.Spot { + symbol = ok.CurrencyPairs.Pairs[a].Enabled[0].String() + } + assets, err2 := ok.GetAssetsFromInstrumentTypeOrID(a.String(), symbol) + require.NoErrorf(t, err2, "GetAssetsFromInstrumentTypeOrID must not error for asset: %s", a) + require.Len(t, assets, 1) + assert.Equalf(t, a, assets[0], "Should contain asset: %s", a) } _, err = ok.GetAssetsFromInstrumentTypeOrID("", "test") - if !errors.Is(err, currency.ErrCurrencyNotSupported) { - t.Error(err) - } - + assert.ErrorIs(t, err, currency.ErrCurrencyNotSupported) _, err = ok.GetAssetsFromInstrumentTypeOrID("", "test-test") - if !errors.Is(err, asset.ErrNotSupported) { - t.Error(err) - } - - assets, err = ok.GetAssetsFromInstrumentTypeOrID("", ok.CurrencyPairs.Pairs[asset.Margin].Enabled[0].String()) - if !errors.Is(err, nil) { - t.Error(err) - } - var found bool - for i := range assets { - if assets[i] == asset.Margin { - found = true - } - } - if !found { - t.Errorf("received %v expected %v", assets, asset.Margin) - } + assert.ErrorIs(t, err, asset.ErrNotSupported) - assets, err = ok.GetAssetsFromInstrumentTypeOrID("", ok.CurrencyPairs.Pairs[asset.Spot].Enabled[0].String()) - if !errors.Is(err, nil) { - t.Error(err) - } - found = false - for i := range assets { - if assets[i] == asset.Spot { - found = true - } - } - if !found { - t.Errorf("received %v expected %v", assets, asset.Spot) + for _, a := range []asset.Item{asset.Margin, asset.Spot} { + assets, err2 := ok.GetAssetsFromInstrumentTypeOrID("", ok.CurrencyPairs.Pairs[a].Enabled[0].String()) + require.NoErrorf(t, err2, "GetAssetsFromInstrumentTypeOrID must not error for asset: %s", a) + assert.Contains(t, assets, a) } } diff --git a/exchanges/stream/stream_match.go b/exchanges/stream/stream_match.go index a7b8a10bfab..430688a816b 100644 --- a/exchanges/stream/stream_match.go +++ b/exchanges/stream/stream_match.go @@ -2,9 +2,13 @@ package stream import ( "errors" + "fmt" "sync" ) +// ErrSignatureNotMatched is returned when a signature does not match a request +var ErrSignatureNotMatched = errors.New("websocket response to request signature not matched") + var ( errSignatureCollision = errors.New("signature collision") errInvalidBufferSize = errors.New("buffer size must be positive") @@ -47,6 +51,15 @@ func (m *Match) IncomingWithData(signature any, data []byte) bool { return true } +// RequireMatchWithData validates that incoming data matches a request's signature. +// If a match is found, the data is processed; otherwise, it returns an error. +func (m *Match) RequireMatchWithData(signature any, data []byte) error { + if m.IncomingWithData(signature, data) { + return nil + } + return fmt.Errorf("'%v' %w with data %v", signature, ErrSignatureNotMatched, string(data)) +} + // Set the signature response channel for incoming data func (m *Match) Set(signature any, bufSize int) (<-chan []byte, error) { if bufSize <= 0 { diff --git a/exchanges/stream/stream_match_test.go b/exchanges/stream/stream_match_test.go index b7a21b23c05..8053cb37934 100644 --- a/exchanges/stream/stream_match_test.go +++ b/exchanges/stream/stream_match_test.go @@ -51,3 +51,18 @@ func TestRemoveSignature(t *testing.T) { t.Fatal("Should be able to read from a closed channel") } } + +func TestRequireMatchWithData(t *testing.T) { + t.Parallel() + match := NewMatch() + err := match.RequireMatchWithData("hello", []byte("world")) + require.ErrorIs(t, err, ErrSignatureNotMatched, "Must error on unmatched signature") + assert.Contains(t, err.Error(), "world", "Should contain the data in the error message") + assert.Contains(t, err.Error(), "hello", "Should contain the signature in the error message") + + ch, err := match.Set("hello", 1) + require.NoError(t, err, "Set must not error") + err = match.RequireMatchWithData("hello", []byte("world")) + require.NoError(t, err, "Must not error on matched signature") + assert.Equal(t, "world", string(<-ch)) +} diff --git a/exchanges/stream/stream_types.go b/exchanges/stream/stream_types.go index 2cbf0a2fe04..832e74c5526 100644 --- a/exchanges/stream/stream_types.go +++ b/exchanges/stream/stream_types.go @@ -27,6 +27,8 @@ type Connection interface { SendMessageReturnResponse(ctx context.Context, epl request.EndpointLimit, signature any, request any) ([]byte, error) // SendMessageReturnResponses will send a WS message to the connection and wait for N responses SendMessageReturnResponses(ctx context.Context, epl request.EndpointLimit, signature any, request any, expected int) ([][]byte, error) + // SendMessageReturnResponsesWithInspector will send a WS message to the connection and wait for N responses with message inspection + SendMessageReturnResponsesWithInspector(ctx context.Context, epl request.EndpointLimit, signature any, request any, expected int, messageInspector Inspector) ([][]byte, error) // SendRawMessage sends a message over the connection without JSON encoding it SendRawMessage(ctx context.Context, epl request.EndpointLimit, messageType int, message []byte) error // SendJSONMessage sends a JSON encoded message over the connection @@ -37,6 +39,12 @@ type Connection interface { Shutdown() error } +// Inspector is used to verify messages via SendMessageReturnResponsesWithInspection +// It inspects the []bytes websocket message and returns true if the message is the final message in a sequence of expected messages +type Inspector interface { + IsFinal([]byte) bool +} + // Response defines generalised data from the stream connection type Response struct { Type int @@ -76,6 +84,11 @@ type ConnectionSetup struct { // This is useful for when an exchange connection requires a unique or // structured message ID for each message sent. BespokeGenerateMessageID func(highPrecision bool) int64 + // 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 } // ConnectionWrapper contains the connection setup details to be used when diff --git a/exchanges/stream/websocket.go b/exchanges/stream/websocket.go index 309db9a79d4..737c0eadc7f 100644 --- a/exchanges/stream/websocket.go +++ b/exchanges/stream/websocket.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/url" + "reflect" "slices" "sync" "time" @@ -27,8 +28,10 @@ var ( ErrUnsubscribeFailure = errors.New("unsubscribe failure") ErrAlreadyDisabled = errors.New("websocket already disabled") ErrNotConnected = errors.New("websocket is not connected") - ErrNoMessageListener = errors.New("websocket listener not found for message") ErrSignatureTimeout = errors.New("websocket timeout waiting for response with signature") + ErrRequestRouteNotFound = errors.New("request route not found") + ErrSignatureNotSet = errors.New("signature not set") + ErrRequestPayloadNotSet = errors.New("request payload not set") ) // Private websocket errors @@ -64,6 +67,9 @@ var ( errConnectionWrapperDuplication = errors.New("connection wrapper duplication") errCannotChangeConnectionURL = errors.New("cannot change connection URL when using multi connection management") errExchangeConfigEmpty = errors.New("exchange config is empty") + errCannotObtainOutboundConnection = errors.New("cannot obtain outbound connection") + errMessageFilterNotSet = errors.New("message filter not set") + errMessageFilterNotComparable = errors.New("message filter is not comparable") ) var globalReporter Reporter @@ -259,13 +265,19 @@ func (w *Websocket) SetupNewConnection(c *ConnectionSetup) error { return fmt.Errorf("%w: %w", errConnSetup, errWebsocketDataHandlerUnset) } + if c.MessageFilter != nil && !reflect.TypeOf(c.MessageFilter).Comparable() { + return errMessageFilterNotComparable + } + for x := range w.connectionManager { - if w.connectionManager[x].Setup.URL == c.URL { + // Below allows for multiple connections to the same URL with different outbound request signatures. This + // allows for easier determination of inbound and outbound messages. e.g. Gateio cross_margin, margin on + // a spot connection. + if w.connectionManager[x].Setup.URL == c.URL && c.MessageFilter == w.connectionManager[x].Setup.MessageFilter { return fmt.Errorf("%w: %w", errConnSetup, errConnectionWrapperDuplication) } } - - w.connectionManager = append(w.connectionManager, ConnectionWrapper{ + w.connectionManager = append(w.connectionManager, &ConnectionWrapper{ Setup: c, Subscriptions: subscription.NewStore(), }) @@ -422,12 +434,21 @@ func (w *Websocket) connect() error { break } - w.connections[conn] = &w.connectionManager[i] + w.connections[conn] = w.connectionManager[i] w.connectionManager[i].Connection = conn w.Wg.Add(1) go w.Reader(context.TODO(), conn, w.connectionManager[i].Setup.Handler) + if w.connectionManager[i].Setup.Authenticate != nil && w.CanUseAuthenticatedEndpoints() { + err = w.connectionManager[i].Setup.Authenticate(context.TODO(), conn) + if err != nil { + // Opted to not fail entirely here for POC. This should be + // revisited and handled more gracefully. + log.Errorf(log.WebsocketMgr, "%s websocket: [conn:%d] [URL:%s] failed to authenticate %v", w.exchangeName, i+1, conn.URL, err) + } + } + err = w.connectionManager[i].Setup.Subscriber(context.TODO(), conn, subs) if err != nil { multiConnectFatalError = fmt.Errorf("%v Error subscribing %w", w.exchangeName, err) @@ -633,7 +654,7 @@ func (w *Websocket) FlushChannels() error { } w.Wg.Add(1) go w.Reader(context.TODO(), conn, w.connectionManager[x].Setup.Handler) - w.connections[conn] = &w.connectionManager[x] + w.connections[conn] = w.connectionManager[x] w.connectionManager[x].Connection = conn } @@ -1064,7 +1085,7 @@ func (w *Websocket) checkSubscriptions(conn Connection, subs subscription.List) if s.State() == subscription.ResubscribingState { continue } - if found := w.subscriptions.Get(s); found != nil { + if found := subscriptionStore.Get(s); found != nil { return fmt.Errorf("%w: %s", subscription.ErrDuplicate, s) } } @@ -1241,3 +1262,37 @@ func signalReceived(ch chan struct{}) bool { return false } } + +// GetConnection returns a connection by message filter (defined in exchange package _wrapper.go websocket connection) +// for request and response handling in a multi connection context. +func (w *Websocket) GetConnection(messageFilter any) (Connection, error) { + if w == nil { + return nil, fmt.Errorf("%w: %T", common.ErrNilPointer, w) + } + + if messageFilter == nil { + return nil, errMessageFilterNotSet + } + + w.m.Lock() + defer w.m.Unlock() + + if !w.useMultiConnectionManagement { + return nil, fmt.Errorf("%s: multi connection management not enabled %w please use exported Conn and AuthConn fields", w.exchangeName, errCannotObtainOutboundConnection) + } + + if !w.IsConnected() { + return nil, ErrNotConnected + } + + for _, wrapper := range w.connectionManager { + if wrapper.Setup.MessageFilter == messageFilter { + if wrapper.Connection == nil { + return nil, fmt.Errorf("%s: %s %w associated with message filter: '%v'", w.exchangeName, wrapper.Setup.URL, ErrNotConnected, messageFilter) + } + return wrapper.Connection, nil + } + } + + return nil, fmt.Errorf("%s: %w associated with message filter: '%v'", w.exchangeName, ErrRequestRouteNotFound, messageFilter) +} diff --git a/exchanges/stream/websocket_connection.go b/exchanges/stream/websocket_connection.go index 1f6f5e6019a..55fd71682e6 100644 --- a/exchanges/stream/websocket_connection.go +++ b/exchanges/stream/websocket_connection.go @@ -304,6 +304,12 @@ func (w *WebsocketConnection) SendMessageReturnResponse(ctx context.Context, epl // SendMessageReturnResponses will send a WS message to the connection and wait for N responses // An error of ErrSignatureTimeout can be ignored if individual responses are being otherwise tracked func (w *WebsocketConnection) SendMessageReturnResponses(ctx context.Context, epl request.EndpointLimit, signature, payload any, expected int) ([][]byte, error) { + return w.SendMessageReturnResponsesWithInspector(ctx, epl, signature, payload, expected, nil) +} + +// SendMessageReturnResponsesWithInspector will send a WS message to the connection and wait for N responses +// An error of ErrSignatureTimeout can be ignored if individual responses are being otherwise tracked +func (w *WebsocketConnection) SendMessageReturnResponsesWithInspector(ctx context.Context, epl request.EndpointLimit, signature, payload any, expected int, messageInspector Inspector) ([][]byte, error) { outbound, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("error marshaling json for %s: %w", signature, err) @@ -320,28 +326,43 @@ func (w *WebsocketConnection) SendMessageReturnResponses(ctx context.Context, ep return nil, err } + resps, err := w.waitForResponses(ctx, signature, ch, expected, messageInspector) + if err != nil { + return nil, err + } + + if w.Reporter != nil { + w.Reporter.Latency(w.ExchangeName, outbound, time.Since(start)) + } + + return resps, err +} + +// waitForResponses waits for N responses from a channel +func (w *WebsocketConnection) waitForResponses(ctx context.Context, signature any, ch <-chan []byte, expected int, messageInspector Inspector) ([][]byte, error) { timeout := time.NewTimer(w.ResponseMaxLimit * time.Duration(expected)) + defer timeout.Stop() resps := make([][]byte, 0, expected) - for err == nil && len(resps) < expected { +inspection: + for range expected { select { case resp := <-ch: resps = append(resps, resp) + // Checks recently received message to determine if this is in fact the final message in a sequence of messages. + if messageInspector != nil && messageInspector.IsFinal(resp) { + w.Match.RemoveSignature(signature) + break inspection + } case <-timeout.C: w.Match.RemoveSignature(signature) - err = fmt.Errorf("%s %w %v", w.ExchangeName, ErrSignatureTimeout, signature) + return nil, fmt.Errorf("%s %w %v", w.ExchangeName, ErrSignatureTimeout, signature) case <-ctx.Done(): w.Match.RemoveSignature(signature) - err = ctx.Err() + return nil, ctx.Err() } } - timeout.Stop() - - if err == nil && w.Reporter != nil { - w.Reporter.Latency(w.ExchangeName, outbound, time.Since(start)) - } - // Only check context verbosity. If the exchange is verbose, it will log the responses in the ReadMessage() call. if request.IsVerbose(ctx, false) { for i := range resps { @@ -349,7 +370,7 @@ func (w *WebsocketConnection) SendMessageReturnResponses(ctx context.Context, ep } } - return resps, err + return resps, nil } func removeURLQueryString(url string) string { diff --git a/exchanges/stream/websocket_test.go b/exchanges/stream/websocket_test.go index 2904bccadee..b6f3a762404 100644 --- a/exchanges/stream/websocket_test.go +++ b/exchanges/stream/websocket_test.go @@ -223,13 +223,16 @@ func TestConnectionMessageErrors(t *testing.T) { assert.ErrorIs(t, err, errNoPendingConnections, "Connect should error correctly") ws.useMultiConnectionManagement = true + ws.SetCanUseAuthenticatedEndpoints(true) mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mockws.WsMockUpgrader(t, w, r, mockws.EchoHandler) })) defer mock.Close() - ws.connectionManager = []ConnectionWrapper{{Setup: &ConnectionSetup{URL: "ws" + mock.URL[len("http"):] + "/ws"}}} + ws.connectionManager = []*ConnectionWrapper{{Setup: &ConnectionSetup{URL: "ws" + mock.URL[len("http"):] + "/ws"}}} err = ws.Connect() require.ErrorIs(t, err, errWebsocketSubscriptionsGeneratorUnset) + ws.connectionManager[0].Setup.Authenticate = func(context.Context, Connection) error { return errDastardlyReason } + ws.connectionManager[0].Setup.GenerateSubscriptions = func() (subscription.List, error) { return nil, errDastardlyReason } @@ -371,7 +374,7 @@ func TestWebsocket(t *testing.T) { ws.useMultiConnectionManagement = true - ws.connectionManager = []ConnectionWrapper{{Setup: &ConnectionSetup{URL: "ws://demos.kaazing.com/echo"}, Connection: &WebsocketConnection{}}} + ws.connectionManager = []*ConnectionWrapper{{Setup: &ConnectionSetup{URL: "ws://demos.kaazing.com/echo"}, Connection: &WebsocketConnection{}}} err = ws.SetProxyAddress("https://192.168.0.1:1337") require.NoError(t, err) } @@ -464,7 +467,7 @@ func TestSubscribeUnsubscribe(t *testing.T) { amazingConn := multi.getConnectionFromSetup(amazingCandidate) multi.connections = map[Connection]*ConnectionWrapper{ - amazingConn: &multi.connectionManager[0], + amazingConn: multi.connectionManager[0], } subs, err = amazingCandidate.GenerateSubscriptions() @@ -761,8 +764,43 @@ func TestSendMessageReturnResponse(t *testing.T) { wc.ResponseMaxLimit = 1 _, err = wc.SendMessageReturnResponse(context.Background(), request.Unset, "123", req) assert.ErrorIs(t, err, ErrSignatureTimeout, "SendMessageReturnResponse should error when request ID not found") + + _, err = wc.SendMessageReturnResponsesWithInspector(context.Background(), request.Unset, "123", req, 1, inspection{}) + assert.ErrorIs(t, err, ErrSignatureTimeout, "SendMessageReturnResponse should error when request ID not found") } +func TestWaitForResponses(t *testing.T) { + t.Parallel() + dummy := &WebsocketConnection{ + ResponseMaxLimit: time.Nanosecond, + Match: NewMatch(), + } + _, err := dummy.waitForResponses(context.Background(), "silly", nil, 1, inspection{}) + require.ErrorIs(t, err, ErrSignatureTimeout) + + dummy.ResponseMaxLimit = time.Second + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, err = dummy.waitForResponses(ctx, "silly", nil, 1, inspection{}) + require.ErrorIs(t, err, context.Canceled) + + // test break early and hit verbose path + ch := make(chan []byte, 1) + ch <- []byte("hello") + ctx = request.WithVerbose(context.Background()) + + got, err := dummy.waitForResponses(ctx, "silly", ch, 2, inspection{breakEarly: true}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.Equal(t, "hello", string(got[0])) +} + +type inspection struct { + breakEarly bool +} + +func (i inspection) IsFinal([]byte) bool { return i.breakEarly } + type reporter struct { name string msg []byte @@ -1229,6 +1267,11 @@ func TestSetupNewConnection(t *testing.T) { require.ErrorIs(t, err, errWebsocketDataHandlerUnset) connSetup.Handler = func(context.Context, []byte) error { return nil } + connSetup.MessageFilter = []string{"slices are super naughty and not comparable"} + err = multi.SetupNewConnection(connSetup) + require.ErrorIs(t, err, errMessageFilterNotComparable) + + connSetup.MessageFilter = "comparable string signature" err = multi.SetupNewConnection(connSetup) require.NoError(t, err) @@ -1484,3 +1527,42 @@ func TestMonitorTraffic(t *testing.T) { ws.TrafficAlert <- struct{}{} require.False(t, innerShell()) } + +func TestGetConnection(t *testing.T) { + t.Parallel() + var ws *Websocket + _, err := ws.GetConnection(nil) + require.ErrorIs(t, err, common.ErrNilPointer) + + ws = &Websocket{} + + _, err = ws.GetConnection(nil) + require.ErrorIs(t, err, errMessageFilterNotSet) + + _, err = ws.GetConnection("testURL") + require.ErrorIs(t, err, errCannotObtainOutboundConnection) + + ws.useMultiConnectionManagement = true + + _, err = ws.GetConnection("testURL") + require.ErrorIs(t, err, ErrNotConnected) + + ws.setState(connectedState) + + _, err = ws.GetConnection("testURL") + require.ErrorIs(t, err, ErrRequestRouteNotFound) + + ws.connectionManager = []*ConnectionWrapper{{ + Setup: &ConnectionSetup{MessageFilter: "testURL", URL: "testURL"}, + }} + + _, err = ws.GetConnection("testURL") + require.ErrorIs(t, err, ErrNotConnected) + + expected := &WebsocketConnection{} + ws.connectionManager[0].Connection = expected + + conn, err := ws.GetConnection("testURL") + require.NoError(t, err) + assert.Same(t, expected, conn) +} diff --git a/exchanges/stream/websocket_types.go b/exchanges/stream/websocket_types.go index 27a5c81963f..26b20f1ee74 100644 --- a/exchanges/stream/websocket_types.go +++ b/exchanges/stream/websocket_types.go @@ -54,7 +54,7 @@ type Websocket struct { // For example, separate connections can be used for Spot, Margin, and Futures trading. This structure is especially useful // for exchanges that differentiate between trading pairs by using different connection endpoints or protocols for various asset classes. // If an exchange does not require such differentiation, all connections may be managed under a single ConnectionWrapper. - connectionManager []ConnectionWrapper + connectionManager []*ConnectionWrapper // connections holds a look up table for all connections to their corresponding ConnectionWrapper and subscription holder connections map[Connection]*ConnectionWrapper diff --git a/exchanges/subscription/interface_test.go b/exchanges/subscription/interface_test.go new file mode 100644 index 00000000000..c608c97c046 --- /dev/null +++ b/exchanges/subscription/interface_test.go @@ -0,0 +1,16 @@ +package subscription_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + exchange "github.com/thrasher-corp/gocryptotrader/exchanges" + shared "github.com/thrasher-corp/gocryptotrader/exchanges/sharedtestvalues" + "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" +) + +// TestIExchange ensures that IExchange is a subset of IBotExchange, so when an exchange is passed by interface, it can still use ExpandTemplates +func TestIExchange(t *testing.T) { + assert.Implements(t, (*subscription.IExchange)(nil), exchange.IBotExchange(&shared.CustomEx{})) + var _ subscription.IExchange = exchange.IBotExchange(nil) +} diff --git a/exchanges/subscription/list.go b/exchanges/subscription/list.go index 6dc871a3cb9..5cadd716c4a 100644 --- a/exchanges/subscription/list.go +++ b/exchanges/subscription/list.go @@ -14,7 +14,8 @@ type List []*Subscription type assetPairs map[asset.Item]currency.Pairs -type iExchange interface { +// IExchange provides method requirements for exchanges to use subscription templating +type IExchange interface { GetAssetTypes(enabled bool) asset.Items GetEnabledPairs(asset.Item) (currency.Pairs, error) GetPairFormat(asset.Item, bool) (currency.PairFormat, error) @@ -93,7 +94,7 @@ func (l List) SetStates(state State) error { return err } -func fillAssetPairs(ap assetPairs, a asset.Item, e iExchange) error { +func fillAssetPairs(ap assetPairs, a asset.Item, e IExchange) error { p, err := e.GetEnabledPairs(a) if err != nil { return err @@ -107,7 +108,7 @@ func fillAssetPairs(ap assetPairs, a asset.Item, e iExchange) error { } // assetPairs returns a map of enabled pairs for the subscriptions in the list, formatted for the asset -func (l List) assetPairs(e iExchange) (assetPairs, error) { +func (l List) assetPairs(e IExchange) (assetPairs, error) { at := []asset.Item{} for _, a := range e.GetAssetTypes(true) { if e.IsAssetWebsocketSupported(a) { diff --git a/exchanges/subscription/template.go b/exchanges/subscription/template.go index ad48f3b462e..d35d8a5e62e 100644 --- a/exchanges/subscription/template.go +++ b/exchanges/subscription/template.go @@ -42,7 +42,7 @@ type tplCtx struct { // Calls e.GetSubscriptionTemplate to find a template for each subscription // Filters out Authenticated subscriptions if !e.CanUseAuthenticatedEndpoints // See README.md for more details -func (l List) ExpandTemplates(e iExchange) (List, error) { +func (l List) ExpandTemplates(e IExchange) (List, error) { if !slices.ContainsFunc(l, func(s *Subscription) bool { return s.QualifiedChannel == "" }) { // Empty list, or already processed return slices.Clone(l), nil @@ -82,7 +82,7 @@ func (l List) ExpandTemplates(e iExchange) (List, error) { return subs, err } -func expandTemplate(e iExchange, s *Subscription, ap assetPairs, assets asset.Items) (List, error) { +func expandTemplate(e IExchange, s *Subscription, ap assetPairs, assets asset.Items) (List, error) { if s.QualifiedChannel != "" { return List{s}, nil } @@ -129,7 +129,7 @@ func expandTemplate(e iExchange, s *Subscription, ap assetPairs, assets asset.It } buf := &bytes.Buffer{} - if err := t.Execute(buf, subCtx); err != nil { //nolint:govet // Shadow, or gocritic will complain sloppyReassign + if err := t.Execute(buf, subCtx); err != nil { return nil, err } diff --git a/go.mod b/go.mod index 73799fb7ad0..80cb89f4f6d 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 - github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 + github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 github.com/kat-co/vala v0.0.0-20170210184112-42e1d8b61f12 github.com/lib/pq v1.10.9 github.com/mattn/go-sqlite3 v1.14.23 @@ -24,14 +24,14 @@ require ( github.com/thrasher-corp/sqlboiler v1.0.1-0.20191001234224-71e17f37a85e github.com/urfave/cli/v2 v2.27.5 github.com/volatiletech/null v8.0.0+incompatible - golang.org/x/crypto v0.31.0 - golang.org/x/net v0.32.0 - golang.org/x/term v0.27.0 + golang.org/x/crypto v0.32.0 + golang.org/x/net v0.34.0 + golang.org/x/term v0.28.0 golang.org/x/text v0.21.0 - golang.org/x/time v0.8.0 - google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 - google.golang.org/grpc v1.69.0 - google.golang.org/protobuf v1.35.2 + golang.org/x/time v0.9.0 + google.golang.org/genproto/googleapis/api v0.0.0-20241219192143-6b3ec007d9bb + google.golang.org/grpc v1.69.4 + google.golang.org/protobuf v1.36.3 ) require ( @@ -66,9 +66,9 @@ require ( go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.29.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241219192143-6b3ec007d9bb // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index e3504750ac0..b92aa8170c6 100644 --- a/go.sum +++ b/go.sum @@ -107,8 +107,8 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDa github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= @@ -281,8 +281,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= @@ -303,8 +303,8 @@ golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= -golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= @@ -325,18 +325,18 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190927073244-c990c680b611/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -358,20 +358,20 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 h1:pgr/4QbFyktUv9CtQ/Fq4gzEE6/Xs7iCXbktaGzLHbQ= -google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697/go.mod h1:+D9ySVjN8nY8YCVjc5O7PZDIdZporIDY3KaGfJunh88= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 h1:LWZqQOEjDyONlF1H6afSWpAL/znlREo2tHfLoe+8LMA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/genproto/googleapis/api v0.0.0-20241219192143-6b3ec007d9bb h1:B7GIB7sr443wZ/EAEl7VZjmh1V6qzkt5V+RYcUYtS1U= +google.golang.org/genproto/googleapis/api v0.0.0-20241219192143-6b3ec007d9bb/go.mod h1:E5//3O5ZIG2l71Xnt+P/CYUY8Bxs8E7WMoZ9tlcMbAY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241219192143-6b3ec007d9bb h1:3oy2tynMOP1QbTC0MsNNAV+Se8M2Bd0A5+x1QHyw+pI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241219192143-6b3ec007d9bb/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= -google.golang.org/grpc v1.69.0 h1:quSiOM1GJPmPH5XtU+BCoVXcDVJJAzNcoyfC2cCjGkI= -google.golang.org/grpc v1.69.0/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= -google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= -google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= +google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=