From 3a66e9989998d945cd6dd620ef3ce2a141333afc Mon Sep 17 00:00:00 2001 From: Scott Date: Wed, 19 Jun 2019 13:19:01 +1000 Subject: [PATCH] Authenticated Websocket support (#315) * Improves subscribing by not allowing duplicates. Adds bitmex auth support * Adds coinbase pro support. Partial BTCC support. Adds WebsocketAuthenticatedEndpointsSupported websocket feature. Adds GateIO support * Adds Coinut support * Moves Coinut WS types to file. Implements Gemini's secure WS endpoint * Adds HitBTC ws authenticated support. Fixes var names * Adds huobi and hadax authenticated websocket support * Adds auth to okgroup (okex, okcoin). Fixes some linting * Adds Poloniex support * Adds ZB support * Adds proper bitmex support * Improves bitfinex support, improves websocket functionality definitions * Fixes coinbasepro auth * Tests all endpoints * go formatting, importing, linting run * Adds wrapper supports * General clean up. Data race destruction * Improves testing on all exchanges except ZB * Fixes ZB hashing, parsing and tests * minor nits before someone else sees them <_< * Fixes some nits pertaining to variable usage, comments, typos and rate limiting * Addresses nits regarding types and test responses where applicable * fmt import * Fixes linting issues * No longer returns an error on failure to authenticate, just logs. Adds new AuthenticatedWebsocketAPISupport config value to allow a user to seperate auth from REST and WS. Prevents WS auth if AuthenticatedWebsocketAPISupport is false, adds additional login check 'CanUseAuthenticatedEndpoints' for when login only occurs once (not per request). Removes unnecessary time.Sleeps from code. Moves WS auth error logic to auth function so that wrappers can get involved in all the auth fun. New-fandangled shared test package, used exclusively in testing, will be the store of all the constant boilerplate things like timeout values. Moves WS test setup function to only run once when there are multiple WS endpoint tests. Cleans up some struct types * Increases test coverage with tests for config.areAuthenticatedCredentialsValid config.CheckExchangeConfigValues, exchange.SetAPIKeys, exchange.GetAuthenticatedAPISupport, exchange_websocket.CanUseAuthenticatedEndpoitns and exchange_websocket.SetCanUseAuthenticatedEndpoints. Adds b.Websocket.SetCanUseAuthenticatedEndpoints(false) when bitfinex fails to authenticate Fixes a typo. gofmt and goimport * Trim Test Typos * Reformats various websocket types. Adds more specific error messaging to config.areAuthenticatedCredentialsValid --- config/config.go | 115 ++-- config/config_test.go | 92 ++++ config_example.json | 11 + exchanges/alphapoint/alphapoint_wrapper.go | 10 + exchanges/anx/anx_wrapper.go | 10 + exchanges/binance/binance_wrapper.go | 10 + exchanges/bitfinex/bitfinex.go | 4 +- exchanges/bitfinex/bitfinex_test.go | 38 ++ exchanges/bitfinex/bitfinex_types.go | 12 + exchanges/bitfinex/bitfinex_websocket.go | 91 +-- exchanges/bitfinex/bitfinex_wrapper.go | 10 + exchanges/bitflyer/bitflyer_wrapper.go | 10 + exchanges/bithumb/bithumb_wrapper.go | 10 + exchanges/bitmex/bitmex.go | 5 +- exchanges/bitmex/bitmex_test.go | 38 ++ exchanges/bitmex/bitmex_websocket.go | 138 ++++- exchanges/bitmex/bitmex_websocket_types.go | 257 +++++++++ exchanges/bitmex/bitmex_wrapper.go | 10 + exchanges/bitstamp/bitstamp_websocket.go | 2 +- exchanges/bitstamp/bitstamp_wrapper.go | 10 + exchanges/bittrex/bittrex_wrapper.go | 10 + exchanges/btcmarkets/btcmarkets_wrapper.go | 10 + exchanges/btse/btse_websocket.go | 2 +- exchanges/btse/btse_wrapper.go | 10 + exchanges/coinbasepro/coinbasepro.go | 6 +- exchanges/coinbasepro/coinbasepro_test.go | 249 ++++----- exchanges/coinbasepro/coinbasepro_types.go | 30 +- .../coinbasepro/coinbasepro_websocket.go | 61 ++- exchanges/coinbasepro/coinbasepro_wrapper.go | 10 + exchanges/coinut/coinut.go | 6 +- exchanges/coinut/coinut_test.go | 143 +++++ exchanges/coinut/coinut_types.go | 254 ++++++++- exchanges/coinut/coinut_websocket.go | 517 +++++++++++++----- exchanges/coinut/coinut_wrapper.go | 10 + exchanges/exchange.go | 23 +- exchanges/exchange_test.go | 42 +- exchanges/exchange_websocket.go | 48 +- exchanges/exchange_websocket_test.go | 14 + exchanges/exchange_websocket_types.go | 33 +- exchanges/exmo/exmo_wrapper.go | 10 + exchanges/gateio/gateio.go | 4 +- exchanges/gateio/gateio_test.go | 50 ++ exchanges/gateio/gateio_websocket.go | 72 ++- exchanges/gateio/gateio_wrapper.go | 10 + exchanges/gemini/gemini.go | 11 +- exchanges/gemini/gemini_test.go | 35 +- exchanges/gemini/gemini_types.go | 198 ++++++- exchanges/gemini/gemini_websocket.go | 357 +++++++----- exchanges/gemini/gemini_wrapper.go | 10 + exchanges/hitbtc/hitbtc.go | 6 +- exchanges/hitbtc/hitbtc_test.go | 119 +++- exchanges/hitbtc/hitbtc_types.go | 272 ++++++++- exchanges/hitbtc/hitbtc_websocket.go | 326 ++++++++--- exchanges/hitbtc/hitbtc_wrapper.go | 27 +- exchanges/huobi/huobi.go | 14 +- exchanges/huobi/huobi_test.go | 122 ++++- exchanges/huobi/huobi_types.go | 210 ++++++- exchanges/huobi/huobi_websocket.go | 500 +++++++++++++---- exchanges/huobi/huobi_wrapper.go | 12 +- exchanges/huobihadax/huobihadax.go | 10 +- exchanges/huobihadax/huobihadax_test.go | 122 ++++- exchanges/huobihadax/huobihadax_types.go | 212 ++++++- exchanges/huobihadax/huobihadax_websocket.go | 499 +++++++++++++---- exchanges/huobihadax/huobihadax_wrapper.go | 10 + exchanges/itbit/itbit_wrapper.go | 10 + exchanges/kraken/kraken_test.go | 5 +- exchanges/kraken/kraken_websocket.go | 2 +- exchanges/kraken/kraken_wrapper.go | 10 + exchanges/lakebtc/lakebtc_wrapper.go | 10 + .../localbitcoins/localbitcoins_wrapper.go | 10 + exchanges/okcoin/okcoin.go | 3 +- exchanges/okcoin/okcoin_test.go | 18 +- exchanges/okex/okex.go | 3 +- exchanges/okex/okex_test.go | 18 +- exchanges/okgroup/okgroup.go | 1 + exchanges/okgroup/okgroup_types.go | 3 +- exchanges/okgroup/okgroup_websocket.go | 27 +- exchanges/okgroup/okgroup_wrapper.go | 10 + exchanges/poloniex/poloniex.go | 4 +- exchanges/poloniex/poloniex_test.go | 56 +- exchanges/poloniex/poloniex_types.go | 50 +- exchanges/poloniex/poloniex_websocket.go | 144 +++-- exchanges/poloniex/poloniex_wrapper.go | 10 + .../sharedtestvalues/sharedtestvalues.go | 28 + exchanges/yobit/yobit_wrapper.go | 10 + exchanges/zb/zb.go | 7 +- exchanges/zb/zb_test.go | 258 +++++++++ exchanges/zb/zb_websocket.go | 296 +++++++++- exchanges/zb/zb_websocket_types.go | 226 +++++++- exchanges/zb/zb_wrapper.go | 10 + restful_server.go | 2 +- testdata/configtest.json | 31 +- tools/exchange_template/main_file.tmpl | 1 + tools/exchange_template/wrapper_file.tmpl | 10 + 94 files changed, 5794 insertions(+), 1058 deletions(-) create mode 100644 exchanges/sharedtestvalues/sharedtestvalues.go diff --git a/config/config.go b/config/config.go index 9aa9e622..98b2996c 100644 --- a/config/config.go +++ b/config/config.go @@ -150,34 +150,35 @@ type NTPClientConfig struct { // ExchangeConfig holds all the information needed for each enabled Exchange. type ExchangeConfig struct { - Name string `json:"name"` - Enabled bool `json:"enabled"` - Verbose bool `json:"verbose"` - Websocket bool `json:"websocket"` - UseSandbox bool `json:"useSandbox"` - RESTPollingDelay time.Duration `json:"restPollingDelay"` - HTTPTimeout time.Duration `json:"httpTimeout"` - HTTPUserAgent string `json:"httpUserAgent"` - HTTPDebugging bool `json:"httpDebugging"` - AuthenticatedAPISupport bool `json:"authenticatedApiSupport"` - APIKey string `json:"apiKey"` - APISecret string `json:"apiSecret"` - APIAuthPEMKeySupport bool `json:"apiAuthPemKeySupport,omitempty"` - APIAuthPEMKey string `json:"apiAuthPemKey,omitempty"` - APIURL string `json:"apiUrl"` - APIURLSecondary string `json:"apiUrlSecondary"` - ProxyAddress string `json:"proxyAddress"` - WebsocketURL string `json:"websocketUrl"` - ClientID string `json:"clientId,omitempty"` - AvailablePairs currency.Pairs `json:"availablePairs"` - EnabledPairs currency.Pairs `json:"enabledPairs"` - BaseCurrencies currency.Currencies `json:"baseCurrencies"` - AssetTypes string `json:"assetTypes"` - SupportsAutoPairUpdates bool `json:"supportsAutoPairUpdates"` - PairsLastUpdated int64 `json:"pairsLastUpdated,omitempty"` - ConfigCurrencyPairFormat *CurrencyPairFormatConfig `json:"configCurrencyPairFormat"` - RequestCurrencyPairFormat *CurrencyPairFormatConfig `json:"requestCurrencyPairFormat"` - BankAccounts []BankAccount `json:"bankAccounts"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Verbose bool `json:"verbose"` + Websocket bool `json:"websocket"` + UseSandbox bool `json:"useSandbox"` + RESTPollingDelay time.Duration `json:"restPollingDelay"` + HTTPTimeout time.Duration `json:"httpTimeout"` + HTTPUserAgent string `json:"httpUserAgent"` + HTTPDebugging bool `json:"httpDebugging"` + AuthenticatedAPISupport bool `json:"authenticatedApiSupport"` + AuthenticatedWebsocketAPISupport bool `json:"authenticatedWebsocketApiSupport"` + APIKey string `json:"apiKey"` + APISecret string `json:"apiSecret"` + APIAuthPEMKeySupport bool `json:"apiAuthPemKeySupport,omitempty"` + APIAuthPEMKey string `json:"apiAuthPemKey,omitempty"` + APIURL string `json:"apiUrl"` + APIURLSecondary string `json:"apiUrlSecondary"` + ProxyAddress string `json:"proxyAddress"` + WebsocketURL string `json:"websocketUrl"` + ClientID string `json:"clientId,omitempty"` + AvailablePairs currency.Pairs `json:"availablePairs"` + EnabledPairs currency.Pairs `json:"enabledPairs"` + BaseCurrencies currency.Currencies `json:"baseCurrencies"` + AssetTypes string `json:"assetTypes"` + SupportsAutoPairUpdates bool `json:"supportsAutoPairUpdates"` + PairsLastUpdated int64 `json:"pairsLastUpdated,omitempty"` + ConfigCurrencyPairFormat *CurrencyPairFormatConfig `json:"configCurrencyPairFormat"` + RequestCurrencyPairFormat *CurrencyPairFormatConfig `json:"requestCurrencyPairFormat"` + BankAccounts []BankAccount `json:"bankAccounts"` } // BankAccount holds differing bank account details by supported funding @@ -798,25 +799,18 @@ func (c *Config) CheckExchangeConfigValues() error { if len(c.Exchanges[i].BaseCurrencies) == 0 { return fmt.Errorf(ErrExchangeBaseCurrenciesEmpty, c.Exchanges[i].Name) } - if c.Exchanges[i].AuthenticatedAPISupport { // non-fatal error - if c.Exchanges[i].APIKey == "" || c.Exchanges[i].APIKey == DefaultUnsetAPIKey { - c.Exchanges[i].AuthenticatedAPISupport = false - } - if (c.Exchanges[i].APISecret == "" || c.Exchanges[i].APISecret == DefaultUnsetAPISecret) && - c.Exchanges[i].Name != "COINUT" { - c.Exchanges[i].AuthenticatedAPISupport = false - } - - if (c.Exchanges[i].ClientID == "ClientID" || c.Exchanges[i].ClientID == "") && - (c.Exchanges[i].Name == "ITBIT" || c.Exchanges[i].Name == "Bitstamp" || c.Exchanges[i].Name == "COINUT" || c.Exchanges[i].Name == "CoinbasePro") { - c.Exchanges[i].AuthenticatedAPISupport = false - } - - if !c.Exchanges[i].AuthenticatedAPISupport { - log.Warnf(WarningExchangeAuthAPIDefaultOrEmptyValues, c.Exchanges[i].Name) - } + var areAuthenticatedCredentialsValid bool + if c.Exchanges[i].AuthenticatedWebsocketAPISupport || c.Exchanges[i].AuthenticatedAPISupport { + areAuthenticatedCredentialsValid = c.areAuthenticatedCredentialsValid(i) } + if c.Exchanges[i].AuthenticatedWebsocketAPISupport { + c.Exchanges[i].AuthenticatedWebsocketAPISupport = areAuthenticatedCredentialsValid + } + if c.Exchanges[i].AuthenticatedAPISupport { + c.Exchanges[i].AuthenticatedAPISupport = areAuthenticatedCredentialsValid + } + if !c.Exchanges[i].SupportsAutoPairUpdates { lastUpdated := common.UnixTimestampToTime(c.Exchanges[i].PairsLastUpdated) lastUpdated = lastUpdated.AddDate(0, 0, configPairsLastUpdatedWarningThreshold) @@ -877,6 +871,37 @@ func (c *Config) CheckExchangeConfigValues() error { return nil } +func (c *Config) areAuthenticatedCredentialsValid(i int) bool { + if c.Exchanges == nil { + log.Error("Config: Failed to check exchange authenticated credentials due to c.Exchanges not setup") + return false + } + if i < 0 || c.Exchanges == nil || len(c.Exchanges) < i { + log.Error("Config: Failed to check exchange authenticated credentials due to invalid index") + return false + } + + resp := true + if c.Exchanges[i].APIKey == "" || c.Exchanges[i].APIKey == DefaultUnsetAPIKey { + resp = false + } + + if (c.Exchanges[i].APISecret == "" || c.Exchanges[i].APISecret == DefaultUnsetAPISecret) && + c.Exchanges[i].Name != "COINUT" { + resp = false + } + + if (c.Exchanges[i].ClientID == "ClientID" || c.Exchanges[i].ClientID == "") && + (c.Exchanges[i].Name == "ITBIT" || c.Exchanges[i].Name == "Bitstamp" || c.Exchanges[i].Name == "COINUT" || c.Exchanges[i].Name == "CoinbasePro") { + resp = false + } + // non-fatal error + if !resp { + log.Warnf(WarningExchangeAuthAPIDefaultOrEmptyValues, c.Exchanges[i].Name) + } + return resp +} + // CheckWebserverConfigValues checks information before webserver starts and // returns an error if values are incorrect. func (c *Config) CheckWebserverConfigValues() error { diff --git a/config/config_test.go b/config/config_test.go index 33e7b31e..6f0f4cce 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -647,6 +647,7 @@ func TestUpdateExchangeConfig(t *testing.T) { } } +// TestCheckExchangeConfigValues logic test func TestCheckExchangeConfigValues(t *testing.T) { checkExchangeConfigValues := Config{} @@ -673,12 +674,16 @@ func TestCheckExchangeConfigValues(t *testing.T) { checkExchangeConfigValues.Exchanges[0].APIKey = "Key" checkExchangeConfigValues.Exchanges[0].APISecret = "Secret" checkExchangeConfigValues.Exchanges[0].AuthenticatedAPISupport = true + checkExchangeConfigValues.Exchanges[0].AuthenticatedWebsocketAPISupport = true err = checkExchangeConfigValues.CheckExchangeConfigValues() if err != nil { t.Errorf( "Test failed. checkExchangeConfigValues.CheckExchangeConfigValues Error", ) } + if checkExchangeConfigValues.Exchanges[0].AuthenticatedWebsocketAPISupport { + t.Error("Expected AuthenticatedWebsocketAPISupport to be false from invalid API keys") + } checkExchangeConfigValues.Exchanges[0].AuthenticatedAPISupport = true checkExchangeConfigValues.Exchanges[0].APIKey = "TESTYTEST" @@ -690,6 +695,24 @@ func TestCheckExchangeConfigValues(t *testing.T) { "Test failed. checkExchangeConfigValues.CheckExchangeConfigValues Error", ) } + if checkExchangeConfigValues.Exchanges[0].AuthenticatedAPISupport { + t.Error("Expected AuthenticatedAPISupport to be true from valid API keys") + } + + checkExchangeConfigValues.Exchanges[0].AuthenticatedAPISupport = true + checkExchangeConfigValues.Exchanges[0].AuthenticatedWebsocketAPISupport = true + checkExchangeConfigValues.Exchanges[0].APIKey = "" + checkExchangeConfigValues.Exchanges[0].APISecret = "" + checkExchangeConfigValues.Exchanges[0].Name = "ITBIT" + err = checkExchangeConfigValues.CheckExchangeConfigValues() + if err != nil { + t.Errorf( + "Test failed. checkExchangeConfigValues.CheckExchangeConfigValues Error", + ) + } + if checkExchangeConfigValues.Exchanges[0].AuthenticatedAPISupport || checkExchangeConfigValues.Exchanges[0].AuthenticatedWebsocketAPISupport { + t.Error("Expected AuthenticatedAPISupport and AuthenticatedWebsocketAPISupport to be false from invalid API keys") + } checkExchangeConfigValues.Exchanges[0].BaseCurrencies = currency.NewCurrenciesFromStringArray([]string{""}) err = checkExchangeConfigValues.CheckExchangeConfigValues() @@ -1027,3 +1050,72 @@ func TestCheckNTPConfig(t *testing.T) { t.Error("ntpclient with nil allowednegativedifference should default to sane value") } } + +// TestAreAuthenticatedCredentialsValid logic test +func TestAreAuthenticatedCredentialsValid(t *testing.T) { + var c Config + resp := c.areAuthenticatedCredentialsValid(0) + if resp { + t.Error("Expecting false with no exchanges loaded") + } + resp = c.areAuthenticatedCredentialsValid(-1) + if resp { + t.Error("Expecting false with an invalid index") + } + + c.Exchanges = []ExchangeConfig{ + { + APIKey: "", + APISecret: "", + ClientID: "", + Name: "", + }, + } + resp = c.areAuthenticatedCredentialsValid(0) + if resp { + t.Error("Expecting false with no credentials set") + } + + c.Exchanges[0].APIKey = DefaultUnsetAPIKey + c.Exchanges[0].APISecret = DefaultUnsetAPISecret + resp = c.areAuthenticatedCredentialsValid(0) + if resp { + t.Error("Expecting false with default credentials set") + } + + c.Exchanges[0].Name = "COINUT" + resp = c.areAuthenticatedCredentialsValid(0) + if resp { + t.Error("Expecting false with COINUT and no APIKEY set") + } + c.Exchanges[0].APIKey = "Im a key!" + c.Exchanges[0].ClientID = "Im a Client!" + resp = c.areAuthenticatedCredentialsValid(0) + if !resp { + t.Error("Expecting true with COINUT api credentials set") + } + + c.Exchanges[0].Name = "Bitstamp" + resp = c.areAuthenticatedCredentialsValid(0) + if resp { + t.Error("Expecting false with Bitstamp and no APISecret set") + } + c.Exchanges[0].APISecret = "Im a Secret!" + resp = c.areAuthenticatedCredentialsValid(0) + if !resp { + t.Error("Expecting true with Bitstamp api credentials set") + } + + c.Exchanges[0].Name = "ANX" + c.Exchanges[0].APIKey = DefaultUnsetAPIKey + c.Exchanges[0].APISecret = "Im a Secret!" + resp = c.areAuthenticatedCredentialsValid(0) + if resp { + t.Error("Expecting false with ANX and no APIKey set") + } + + resp = c.areAuthenticatedCredentialsValid(1337) + if resp { + t.Error("Expecting false with an invalid index") + } +} diff --git a/config_example.json b/config_example.json index 7fae4881..621dd928 100644 --- a/config_example.json +++ b/config_example.json @@ -254,6 +254,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -379,6 +380,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -669,6 +671,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -711,6 +714,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -753,6 +757,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -793,6 +798,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -834,6 +840,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiAuthPemKey": "-----BEGIN EC PRIVATE KEY-----\nJUSTADUMMY\n-----END EC PRIVATE KEY-----\n", @@ -876,6 +883,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiAuthPemKey": "-----BEGIN EC PRIVATE KEY-----\nJUSTADUMMY\n-----END EC PRIVATE KEY-----\n", @@ -1082,6 +1090,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -1124,6 +1133,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -1166,6 +1176,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", diff --git a/exchanges/alphapoint/alphapoint_wrapper.go b/exchanges/alphapoint/alphapoint_wrapper.go index 954e9ffe..d4c86cc6 100644 --- a/exchanges/alphapoint/alphapoint_wrapper.go +++ b/exchanges/alphapoint/alphapoint_wrapper.go @@ -329,3 +329,13 @@ func (a *Alphapoint) SubscribeToWebsocketChannels(channels []exchange.WebsocketC func (a *Alphapoint) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (a *Alphapoint) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (a *Alphapoint) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/anx/anx_wrapper.go b/exchanges/anx/anx_wrapper.go index 6614a7d5..ac568595 100644 --- a/exchanges/anx/anx_wrapper.go +++ b/exchanges/anx/anx_wrapper.go @@ -457,3 +457,13 @@ func (a *ANX) SubscribeToWebsocketChannels(channels []exchange.WebsocketChannelS func (a *ANX) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (a *ANX) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (a *ANX) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/binance/binance_wrapper.go b/exchanges/binance/binance_wrapper.go index d7de79f8..194d63f1 100644 --- a/exchanges/binance/binance_wrapper.go +++ b/exchanges/binance/binance_wrapper.go @@ -425,3 +425,13 @@ func (b *Binance) SubscribeToWebsocketChannels(channels []exchange.WebsocketChan func (b *Binance) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (b *Binance) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return b.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (b *Binance) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/bitfinex/bitfinex.go b/exchanges/bitfinex/bitfinex.go index 8769217e..8679d1ca 100644 --- a/exchanges/bitfinex/bitfinex.go +++ b/exchanges/bitfinex/bitfinex.go @@ -118,7 +118,8 @@ func (b *Bitfinex) SetDefaults() { exchange.WebsocketTradeDataSupported | exchange.WebsocketOrderbookSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported } // Setup takes in the supplied exchange configuration details and sets params @@ -128,6 +129,7 @@ func (b *Bitfinex) Setup(exch *config.ExchangeConfig) { } else { b.Enabled = true b.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + b.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport b.SetAPIKeys(exch.APIKey, exch.APISecret, "", false) b.SetHTTPClientTimeout(exch.HTTPTimeout) b.SetHTTPClientUserAgent(exch.HTTPUserAgent) diff --git a/exchanges/bitfinex/bitfinex_test.go b/exchanges/bitfinex/bitfinex_test.go index 3f55ba59..2d284ad7 100644 --- a/exchanges/bitfinex/bitfinex_test.go +++ b/exchanges/bitfinex/bitfinex_test.go @@ -1,15 +1,18 @@ package bitfinex import ( + "net/http" "net/url" "reflect" "testing" "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) // Please supply your own keys here to do better tests @@ -37,6 +40,7 @@ func TestSetup(t *testing.T) { len(b.AvailablePairs) < 1 || len(b.EnabledPairs) < 1 { t.Error("Test Failed - Bitfinex Setup values not set correctly") } + b.AuthenticatedWebsocketAPISupport = true b.AuthenticatedAPISupport = true // custom rate limit for testing b.Requester.SetRateLimit(true, time.Millisecond*300, 1) @@ -951,3 +955,37 @@ func TestGetDepositAddress(t *testing.T) { } } } + +// TestWsAuth dials websocket, sends login request. +func TestWsAuth(t *testing.T) { + b.SetDefaults() + TestSetup(t) + if !b.Websocket.IsEnabled() && !b.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) + } + var err error + var dialer websocket.Dialer + b.WebsocketConn, _, err = dialer.Dial(b.Websocket.GetWebsocketURL(), + http.Header{}) + if err != nil { + t.Fatal(err) + } + b.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + b.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() + go b.WsDataHandler() + defer b.WebsocketConn.Close() + err = b.WsSendAuth() + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-b.Websocket.DataHandler: + if resp.(map[string]interface{})["event"] != "auth" && resp.(map[string]interface{})["status"] != "OK" { + t.Error("expected successful login") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} diff --git a/exchanges/bitfinex/bitfinex_types.go b/exchanges/bitfinex/bitfinex_types.go index 3eea281c..38a5d585 100644 --- a/exchanges/bitfinex/bitfinex_types.go +++ b/exchanges/bitfinex/bitfinex_types.go @@ -448,6 +448,18 @@ type WebsocketTradeExecuted struct { PriceExecuted float64 } +// WebsocketTradeData holds executed trade data +type WebsocketTradeData struct { + TradeID int64 + Pair string + Timestamp int64 + OrderID int64 + AmountExecuted float64 + PriceExecuted float64 + Fee float64 + FeeCurrency string +} + // ErrorCapture is a simple type for returned errors from Bitfinex type ErrorCapture struct { Message string `json:"message"` diff --git a/exchanges/bitfinex/bitfinex_websocket.go b/exchanges/bitfinex/bitfinex_websocket.go index 09554df2..00249157 100644 --- a/exchanges/bitfinex/bitfinex_websocket.go +++ b/exchanges/bitfinex/bitfinex_websocket.go @@ -18,28 +18,30 @@ import ( ) const ( - bitfinexWebsocket = "wss://api.bitfinex.com/ws" - bitfinexWebsocketVersion = "1.1" - bitfinexWebsocketPositionSnapshot = "ps" - bitfinexWebsocketPositionNew = "pn" - bitfinexWebsocketPositionUpdate = "pu" - bitfinexWebsocketPositionClose = "pc" - bitfinexWebsocketWalletSnapshot = "ws" - bitfinexWebsocketWalletUpdate = "wu" - bitfinexWebsocketOrderSnapshot = "os" - bitfinexWebsocketOrderNew = "on" - bitfinexWebsocketOrderUpdate = "ou" - bitfinexWebsocketOrderCancel = "oc" - bitfinexWebsocketTradeExecuted = "te" - bitfinexWebsocketHeartbeat = "hb" - bitfinexWebsocketAlertRestarting = "20051" - bitfinexWebsocketAlertRefreshing = "20060" - bitfinexWebsocketAlertResume = "20061" - bitfinexWebsocketUnknownEvent = "10000" - bitfinexWebsocketUnknownPair = "10001" - bitfinexWebsocketSubscriptionFailed = "10300" - bitfinexWebsocketAlreadySubscribed = "10301" - bitfinexWebsocketUnknownChannel = "10302" + bitfinexWebsocket = "wss://api.bitfinex.com/ws" + bitfinexWebsocketVersion = "1.1" + bitfinexWebsocketPositionSnapshot = "ps" + bitfinexWebsocketPositionNew = "pn" + bitfinexWebsocketPositionUpdate = "pu" + bitfinexWebsocketPositionClose = "pc" + bitfinexWebsocketWalletSnapshot = "ws" + bitfinexWebsocketWalletUpdate = "wu" + bitfinexWebsocketOrderSnapshot = "os" + bitfinexWebsocketOrderNew = "on" + bitfinexWebsocketOrderUpdate = "ou" + bitfinexWebsocketOrderCancel = "oc" + bitfinexWebsocketTradeExecuted = "te" + bitfinexWebsocketTradeExecutionUpdate = "tu" + bitfinexWebsocketTradeSnapshots = "ts" + bitfinexWebsocketHeartbeat = "hb" + bitfinexWebsocketAlertRestarting = "20051" + bitfinexWebsocketAlertRefreshing = "20060" + bitfinexWebsocketAlertResume = "20061" + bitfinexWebsocketUnknownEvent = "10000" + bitfinexWebsocketUnknownPair = "10001" + bitfinexWebsocketSubscriptionFailed = "10300" + bitfinexWebsocketAlreadySubscribed = "10301" + bitfinexWebsocketUnknownChannel = "10302" ) // WebsocketHandshake defines the communication between the websocket API for @@ -76,6 +78,9 @@ func (b *Bitfinex) wsSend(data interface{}) error { // WsSendAuth sends a autheticated event payload func (b *Bitfinex) WsSendAuth() error { + if !b.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", b.Name) + } req := make(map[string]interface{}) payload := "AUTH" + strconv.FormatInt(time.Now().UnixNano(), 10)[:13] req["event"] = "auth" @@ -89,7 +94,12 @@ func (b *Bitfinex) WsSendAuth() error { req["authPayload"] = payload - return b.wsSend(req) + err := b.wsSend(req) + if err != nil { + b.Websocket.SetCanUseAuthenticatedEndpoints(false) + return err + } + return nil } // WsSendUnauth sends an unauthenticated payload @@ -149,6 +159,11 @@ func (b *Bitfinex) WsConnect() error { return err } + err = b.WsSendAuth() + if err != nil { + log.Errorf("%v - authentication failed: %v", b.Name, err) + } + b.GenerateDefaultSubscriptions() if hs.Event == "info" { if b.Verbose { @@ -156,13 +171,6 @@ func (b *Bitfinex) WsConnect() error { } } - if b.AuthenticatedAPISupport { - err = b.WsSendAuth() - if err != nil { - return err - } - } - pongReceive = make(chan struct{}, 1) go b.WsDataHandler() @@ -224,15 +232,13 @@ func (b *Bitfinex) WsDataHandler() { case "auth": status := eventData["status"].(string) - if status == "OK" { + b.Websocket.DataHandler <- eventData b.WsAddSubscriptionChannel(0, "account", "N/A") } else if status == "fail" { b.Websocket.DataHandler <- fmt.Errorf("bitfinex.go error - Websocket unable to AUTH. Error code: %s", eventData["code"].(string)) - - b.AuthenticatedAPISupport = false } } @@ -416,6 +422,19 @@ func (b *Bitfinex) WsDataHandler() { AmountExecuted: data[4].(float64), PriceExecuted: data[5].(float64)} + b.Websocket.DataHandler <- trade + case bitfinexWebsocketTradeSnapshots, bitfinexWebsocketTradeExecutionUpdate: + data := chanData[2].([]interface{}) + trade := WebsocketTradeData{ + TradeID: int64(data[0].(float64)), + Pair: data[1].(string), + Timestamp: int64(data[2].(float64)), + OrderID: int64(data[3].(float64)), + AmountExecuted: data[4].(float64), + PriceExecuted: data[5].(float64), + Fee: data[6].(float64), + FeeCurrency: data[7].(string)} + b.Websocket.DataHandler <- trade } @@ -601,7 +620,7 @@ func (b *Bitfinex) WsUpdateOrderbook(p currency.Pair, assetType string, book Web // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (b *Bitfinex) GenerateDefaultSubscriptions() { var channels = []string{"book", "trades", "ticker"} - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription for i := range channels { for j := range b.EnabledPairs { params := make(map[string]interface{}) @@ -623,7 +642,9 @@ func (b *Bitfinex) Subscribe(channelToSubscribe exchange.WebsocketChannelSubscri req := make(map[string]interface{}) req["event"] = "subscribe" req["channel"] = channelToSubscribe.Channel - req["pair"] = channelToSubscribe.Currency.String() + if channelToSubscribe.Currency.String() != "" { + req["pair"] = channelToSubscribe.Currency.String() + } if len(channelToSubscribe.Params) > 0 { for k, v := range channelToSubscribe.Params { req[k] = v diff --git a/exchanges/bitfinex/bitfinex_wrapper.go b/exchanges/bitfinex/bitfinex_wrapper.go index c15c83ce..a3071ffe 100644 --- a/exchanges/bitfinex/bitfinex_wrapper.go +++ b/exchanges/bitfinex/bitfinex_wrapper.go @@ -468,3 +468,13 @@ func (b *Bitfinex) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketC b.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (b *Bitfinex) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return b.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (b *Bitfinex) AuthenticateWebsocket() error { + return b.WsSendAuth() +} diff --git a/exchanges/bitflyer/bitflyer_wrapper.go b/exchanges/bitflyer/bitflyer_wrapper.go index e277ea2e..6572223d 100644 --- a/exchanges/bitflyer/bitflyer_wrapper.go +++ b/exchanges/bitflyer/bitflyer_wrapper.go @@ -254,3 +254,13 @@ func (b *Bitflyer) SubscribeToWebsocketChannels(channels []exchange.WebsocketCha func (b *Bitflyer) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (b *Bitflyer) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (b *Bitflyer) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/bithumb/bithumb_wrapper.go b/exchanges/bithumb/bithumb_wrapper.go index 6717b52e..df519436 100644 --- a/exchanges/bithumb/bithumb_wrapper.go +++ b/exchanges/bithumb/bithumb_wrapper.go @@ -432,3 +432,13 @@ func (b *Bithumb) SubscribeToWebsocketChannels(channels []exchange.WebsocketChan func (b *Bithumb) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (b *Bithumb) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (b *Bithumb) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/bitmex/bitmex.go b/exchanges/bitmex/bitmex.go index d2d94d3e..48232e2a 100644 --- a/exchanges/bitmex/bitmex.go +++ b/exchanges/bitmex/bitmex.go @@ -139,7 +139,9 @@ func (b *Bitmex) SetDefaults() { b.Websocket.Functionality = exchange.WebsocketTradeDataSupported | exchange.WebsocketOrderbookSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported | + exchange.WebsocketAccountDataSupported } // Setup takes in the supplied exchange configuration details and sets params @@ -149,6 +151,7 @@ func (b *Bitmex) Setup(exch *config.ExchangeConfig) { } else { b.Enabled = true b.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + b.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport b.SetAPIKeys(exch.APIKey, exch.APISecret, "", false) b.RESTPollingDelay = exch.RESTPollingDelay b.Verbose = exch.Verbose diff --git a/exchanges/bitmex/bitmex_test.go b/exchanges/bitmex/bitmex_test.go index 8a231924..5f86a890 100644 --- a/exchanges/bitmex/bitmex_test.go +++ b/exchanges/bitmex/bitmex_test.go @@ -1,14 +1,17 @@ package bitmex import ( + "net/http" "sync" "testing" "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) // Please supply your own keys here for due diligence testing @@ -31,6 +34,7 @@ func TestSetup(t *testing.T) { if err != nil { t.Error("Test Failed - Bitmex Setup() init error") } + bitmexConfig.AuthenticatedWebsocketAPISupport = true bitmexConfig.AuthenticatedAPISupport = true bitmexConfig.APIKey = apiKey bitmexConfig.APISecret = apiSecret @@ -679,3 +683,37 @@ func TestGetDepositAddress(t *testing.T) { } } } + +// TestWsAuth dials websocket, sends login request. +func TestWsAuth(t *testing.T) { + b.SetDefaults() + TestSetup(t) + if !b.Websocket.IsEnabled() && !b.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) + } + var err error + var dialer websocket.Dialer + b.WebsocketConn, _, err = dialer.Dial(b.Websocket.GetWebsocketURL(), + http.Header{}) + if err != nil { + t.Fatal(err) + } + b.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + b.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() + go b.wsHandleIncomingData() + defer b.WebsocketConn.Close() + err = b.websocketSendAuth() + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-b.Websocket.DataHandler: + if !resp.(WebsocketSubscribeResp).Success { + t.Error("Expected successful subscription") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} diff --git a/exchanges/bitmex/bitmex_websocket.go b/exchanges/bitmex/bitmex_websocket.go index d025583f..56f9a494 100644 --- a/exchanges/bitmex/bitmex_websocket.go +++ b/exchanges/bitmex/bitmex_websocket.go @@ -107,12 +107,11 @@ func (b *Bitmex) WsConnector() error { go b.wsHandleIncomingData() b.GenerateDefaultSubscriptions() - if b.AuthenticatedAPISupport { - err := b.websocketSendAuth() - if err != nil { - return err - } + err = b.websocketSendAuth() + if err != nil { + log.Errorf("%v - authentication failed: %v", b.Name, err) } + b.GenerateAuthenticatedSubscriptions() return nil } @@ -190,11 +189,15 @@ func (b *Bitmex) wsHandleIncomingData() { } if decodedResp.Success { - if b.Verbose { - if len(quickCapture) == 3 { + b.Websocket.DataHandler <- decodedResp + if len(quickCapture) == 3 { + if b.Verbose { log.Debugf("%s websocket: Successfully subscribed to %s", b.Name, decodedResp.Subscribe) - } else { + } + } else { + b.Websocket.SetCanUseAuthenticatedEndpoints(true) + if b.Verbose { log.Debugf("%s websocket: Successfully authenticated websocket connection", b.Name) } @@ -264,7 +267,6 @@ func (b *Bitmex) wsHandleIncomingData() { case bitmexWSAnnouncement: var announcement AnnouncementData - err = common.JSONDecode(resp.Raw, &announcement) if err != nil { b.Websocket.DataHandler <- err @@ -276,7 +278,70 @@ func (b *Bitmex) wsHandleIncomingData() { } b.Websocket.DataHandler <- announcement.Data - + case bitmexWSAffiliate: + var response WsAffiliateResponse + err = common.JSONDecode(resp.Raw, &response) + if err != nil { + b.Websocket.DataHandler <- err + continue + } + b.Websocket.DataHandler <- response + case bitmexWSExecution: + var response WsExecutionResponse + err = common.JSONDecode(resp.Raw, &response) + if err != nil { + b.Websocket.DataHandler <- err + continue + } + b.Websocket.DataHandler <- response + case bitmexWSOrder: + var response WsOrderResponse + err = common.JSONDecode(resp.Raw, &response) + if err != nil { + b.Websocket.DataHandler <- err + continue + } + b.Websocket.DataHandler <- response + case bitmexWSMargin: + var response WsMarginResponse + err = common.JSONDecode(resp.Raw, &response) + if err != nil { + b.Websocket.DataHandler <- err + continue + } + b.Websocket.DataHandler <- response + case bitmexWSPosition: + var response WsPositionResponse + err = common.JSONDecode(resp.Raw, &response) + if err != nil { + b.Websocket.DataHandler <- err + continue + } + b.Websocket.DataHandler <- response + case bitmexWSPrivateNotifications: + var response WsPrivateNotificationsResponse + err = common.JSONDecode(resp.Raw, &response) + if err != nil { + b.Websocket.DataHandler <- err + continue + } + b.Websocket.DataHandler <- response + case bitmexWSTransact: + var response WsTransactResponse + err = common.JSONDecode(resp.Raw, &response) + if err != nil { + b.Websocket.DataHandler <- err + continue + } + b.Websocket.DataHandler <- response + case bitmexWSWallet: + var response WsWalletResponse + err = common.JSONDecode(resp.Raw, &response) + if err != nil { + b.Websocket.DataHandler <- err + continue + } + b.Websocket.DataHandler <- response default: b.Websocket.DataHandler <- fmt.Errorf("%s websocket error: Table unknown - %s", b.Name, decodedResp.Table) @@ -393,6 +458,47 @@ func (b *Bitmex) GenerateDefaultSubscriptions() { Channel: bitmexWSAnnouncement, }, } + + for i := range channels { + for j := range contracts { + subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ + Channel: fmt.Sprintf("%v:%v", channels[i], contracts[j].String()), + Currency: contracts[j], + }) + } + } + b.Websocket.SubscribeToChannels(subscriptions) +} + +// GenerateAuthenticatedSubscriptions Adds authenticated subscriptions to websocket to be handled by ManageSubscriptions() +func (b *Bitmex) GenerateAuthenticatedSubscriptions() { + if !b.Websocket.CanUseAuthenticatedEndpoints() { + return + } + contracts := b.GetEnabledCurrencies() + channels := []string{bitmexWSExecution, + bitmexWSPosition, + } + subscriptions := []exchange.WebsocketChannelSubscription{ + { + Channel: bitmexWSAffiliate, + }, + { + Channel: bitmexWSOrder, + }, + { + Channel: bitmexWSMargin, + }, + { + Channel: bitmexWSPrivateNotifications, + }, + { + Channel: bitmexWSTransact, + }, + { + Channel: bitmexWSWallet, + }, + } for i := range channels { for j := range contracts { subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ @@ -424,18 +530,26 @@ func (b *Bitmex) Unsubscribe(channelToSubscribe exchange.WebsocketChannelSubscri // WebsocketSendAuth sends an authenticated subscription func (b *Bitmex) websocketSendAuth() error { + if !b.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", b.Name) + } + b.Websocket.SetCanUseAuthenticatedEndpoints(true) timestamp := time.Now().Add(time.Hour * 1).Unix() newTimestamp := strconv.FormatInt(timestamp, 10) hmac := common.GetHMAC(common.HashSHA256, []byte("GET/realtime"+newTimestamp), []byte(b.APISecret)) signature := common.HexEncodeToString(hmac) - var sendAuth WebsocketRequest sendAuth.Command = "authKeyExpires" sendAuth.Arguments = append(sendAuth.Arguments, b.APIKey, timestamp, signature) - return b.wsSend(sendAuth) + err := b.wsSend(sendAuth) + if err != nil { + b.Websocket.SetCanUseAuthenticatedEndpoints(false) + return err + } + return nil } // WsSend sends data to the websocket server diff --git a/exchanges/bitmex/bitmex_websocket_types.go b/exchanges/bitmex/bitmex_websocket_types.go index d895762e..12f9f053 100644 --- a/exchanges/bitmex/bitmex_websocket_types.go +++ b/exchanges/bitmex/bitmex_websocket_types.go @@ -70,3 +70,260 @@ type AnnouncementData struct { Data []Announcement `json:"data"` Action string `json:"action"` } + +// WsAffiliateResponse private api response +type WsAffiliateResponse struct { + WsDataResponse + ForeignKeys interface{} `json:"foreignKeys"` + Attributes WsAffiliateResponseAttributes `json:"attributes"` + Filter WsAffiliateResponseFilter `json:"filter"` + Data []interface{} `json:"data"` +} + +// WsAffiliateResponseAttributes private api data +type WsAffiliateResponseAttributes struct { + Account string `json:"account"` + Currency string `json:"currency"` +} + +// WsAffiliateResponseFilter private api data +type WsAffiliateResponseFilter struct { + Account int64 `json:"account"` +} + +// WsOrderResponse private api response +type WsOrderResponse struct { + WsDataResponse + ForeignKeys WsOrderResponseForeignKeys `json:"foreignKeys"` + Attributes WsOrderResponseAttributes `json:"attributes"` + Filter WsOrderResponseFilter `json:"filter"` + Data []interface{} `json:"data"` +} + +// WsOrderResponseAttributes private api data +type WsOrderResponseAttributes struct { + OrderID string `json:"orderID"` + Account string `json:"account"` + OrdStatus string `json:"ordStatus"` + WorkingIndicator string `json:"workingIndicator"` +} + +// WsOrderResponseFilter private api data +type WsOrderResponseFilter struct { + Account int64 `json:"account"` +} + +// WsOrderResponseForeignKeys private api data +type WsOrderResponseForeignKeys struct { + Symbol string `json:"symbol"` + Side string `json:"side"` + OrdStatus string `json:"ordStatus"` +} + +// WsTransactResponse private api response +type WsTransactResponse struct { + WsDataResponse + ForeignKeys interface{} `json:"foreignKeys"` + Attributes WsTransactResponseAttributes `json:"attributes"` + Filter WsTransactResponseFilter `json:"filter"` + Data []interface{} `json:"data"` +} + +// WsTransactResponseAttributes private api data +type WsTransactResponseAttributes struct { + TransactID string `json:"transactID"` + TransactTime string `json:"transactTime"` +} + +// WsTransactResponseFilter private api data +type WsTransactResponseFilter struct { + Account int64 `json:"account"` +} + +// WsWalletResponse private api response +type WsWalletResponse struct { + WsDataResponse + ForeignKeys interface{} `json:"foreignKeys"` + Attributes WsWalletResponseAttributes `json:"attributes"` + Filter WsWalletResponseFilter `json:"filter"` + Data []WsWalletResponseData `json:"data"` +} + +// WsWalletResponseAttributes private api data +type WsWalletResponseAttributes struct { + Account string `json:"account"` + Currency string `json:"currency"` +} + +// WsWalletResponseData private api data +type WsWalletResponseData struct { + Account int64 `json:"account"` + Currency string `json:"currency"` + PrevDeposited float64 `json:"prevDeposited"` + PrevWithdrawn float64 `json:"prevWithdrawn"` + PrevTransferIn float64 `json:"prevTransferIn"` + PrevTransferOut float64 `json:"prevTransferOut"` + PrevAmount float64 `json:"prevAmount"` + PrevTimestamp string `json:"prevTimestamp"` + DeltaDeposited float64 `json:"deltaDeposited"` + DeltaWithdrawn float64 `json:"deltaWithdrawn"` + DeltaTransferIn float64 `json:"deltaTransferIn"` + DeltaTransferOut float64 `json:"deltaTransferOut"` + DeltaAmount float64 `json:"deltaAmount"` + Deposited float64 `json:"deposited"` + Withdrawn float64 `json:"withdrawn"` + TransferIn float64 `json:"transferIn"` + TransferOut float64 `json:"transferOut"` + Amount float64 `json:"amount"` + PendingCredit float64 `json:"pendingCredit"` + PendingDebit float64 `json:"pendingDebit"` + ConfirmedDebit int64 `json:"confirmedDebit"` + Timestamp string `json:"timestamp"` + Addr string `json:"addr"` + Script string `json:"script"` + WithdrawalLock []interface{} `json:"withdrawalLock"` +} + +// WsWalletResponseFilter private api data +type WsWalletResponseFilter struct { + Account int64 `json:"account"` +} + +// WsExecutionResponse private api response +type WsExecutionResponse struct { + WsDataResponse + ForeignKeys WsExecutionResponseForeignKeys `json:"foreignKeys"` + Attributes WsExecutionResponseAttributes `json:"attributes"` + Filter WsExecutionResponseFilter `json:"filter"` + Data []interface{} `json:"data"` +} + +// WsExecutionResponseAttributes private api data +type WsExecutionResponseAttributes struct { + ExecID string `json:"execID"` + Account string `json:"account"` + ExecType string `json:"execType"` + TransactTime string `json:"transactTime"` +} + +// WsExecutionResponseFilter private api data +type WsExecutionResponseFilter struct { + Account int64 `json:"account"` + Symbol string `json:"symbol"` +} + +// WsExecutionResponseForeignKeys private api data +type WsExecutionResponseForeignKeys struct { + Symbol string `json:"symbol"` + Side string `json:"side"` + OrdStatus string `json:"ordStatus"` +} + +// WsDataResponse contains common elements +type WsDataResponse struct { + Table string `json:"table"` + Action string `json:"action"` + Keys []string `json:"keys"` + Types map[string]string `json:"types"` +} + +// WsMarginResponse private api response +type WsMarginResponse struct { + WsDataResponse + ForeignKeys interface{} `json:"foreignKeys"` + Attributes WsMarginResponseAttributes `json:"attributes"` + Filter WsMarginResponseFilter `json:"filter"` + Data []WsMarginResponseData `json:"data"` +} + +// WsMarginResponseAttributes private api data +type WsMarginResponseAttributes struct { + Account string `json:"account"` + Currency string `json:"currency"` +} + +// WsMarginResponseData private api data +type WsMarginResponseData struct { + Account int64 `json:"account"` + Currency string `json:"currency"` + RiskLimit float64 `json:"riskLimit"` + PrevState string `json:"prevState"` + State string `json:"state"` + Action string `json:"action"` + Amount float64 `json:"amount"` + PendingCredit float64 `json:"pendingCredit"` + PendingDebit float64 `json:"pendingDebit"` + ConfirmedDebit float64 `json:"confirmedDebit"` + PrevRealisedPnl float64 `json:"prevRealisedPnl"` + PrevUnrealisedPnl float64 `json:"prevUnrealisedPnl"` + GrossComm float64 `json:"grossComm"` + GrossOpenCost float64 `json:"grossOpenCost"` + GrossOpenPremium float64 `json:"grossOpenPremium"` + GrossExecCost float64 `json:"grossExecCost"` + GrossMarkValue float64 `json:"grossMarkValue"` + RiskValue float64 `json:"riskValue"` + TaxableMargin float64 `json:"taxableMargin"` + InitMargin float64 `json:"initMargin"` + MaintMargin float64 `json:"maintMargin"` + SessionMargin float64 `json:"sessionMargin"` + TargetExcessMargin float64 `json:"targetExcessMargin"` + VarMargin float64 `json:"varMargin"` + RealisedPnl float64 `json:"realisedPnl"` + UnrealisedPnl float64 `json:"unrealisedPnl"` + IndicativeTax float64 `json:"indicativeTax"` + UnrealisedProfit float64 `json:"unrealisedProfit"` + SyntheticMargin interface{} `json:"syntheticMargin"` + WalletBalance float64 `json:"walletBalance"` + MarginBalance float64 `json:"marginBalance"` + MarginBalancePcnt float64 `json:"marginBalancePcnt"` + MarginLeverage float64 `json:"marginLeverage"` + MarginUsedPcnt float64 `json:"marginUsedPcnt"` + ExcessMargin float64 `json:"excessMargin"` + ExcessMarginPcnt float64 `json:"excessMarginPcnt"` + AvailableMargin float64 `json:"availableMargin"` + WithdrawableMargin float64 `json:"withdrawableMargin"` + Timestamp string `json:"timestamp"` + GrossLastValue float64 `json:"grossLastValue"` + Commission interface{} `json:"commission"` +} + +// WsMarginResponseFilter private api data +type WsMarginResponseFilter struct { + Account int64 `json:"account"` +} + +// WsPositionResponse private api response +type WsPositionResponse struct { + WsDataResponse + ForeignKeys WsPositionResponseForeignKeys `json:"foreignKeys"` + Attributes WsPositionResponseAttributes `json:"attributes"` + Filter WsPositionResponseFilter `json:"filter"` + Data []interface{} `json:"data"` +} + +// WsPositionResponseAttributes private api data +type WsPositionResponseAttributes struct { + Account string `json:"account"` + Symbol string `json:"symbol"` + Currency string `json:"currency"` + Underlying string `json:"underlying"` + QuoteCurrency string `json:"quoteCurrency"` +} + +// WsPositionResponseFilter private api data +type WsPositionResponseFilter struct { + Account int64 `json:"account"` + Symbol string `json:"symbol"` +} + +// WsPositionResponseForeignKeys private api data +type WsPositionResponseForeignKeys struct { + Symbol string `json:"symbol"` +} + +// WsPrivateNotificationsResponse private api response +type WsPrivateNotificationsResponse struct { + Table string `json:"table"` + Action string `json:"action"` + Data []interface{} `json:"data"` +} diff --git a/exchanges/bitmex/bitmex_wrapper.go b/exchanges/bitmex/bitmex_wrapper.go index 539a0871..6aa2f0c9 100644 --- a/exchanges/bitmex/bitmex_wrapper.go +++ b/exchanges/bitmex/bitmex_wrapper.go @@ -412,3 +412,13 @@ func (b *Bitmex) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketCha b.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (b *Bitmex) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return b.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (b *Bitmex) AuthenticateWebsocket() error { + return b.websocketSendAuth() +} diff --git a/exchanges/bitstamp/bitstamp_websocket.go b/exchanges/bitstamp/bitstamp_websocket.go index 58b4409f..9aab11db 100644 --- a/exchanges/bitstamp/bitstamp_websocket.go +++ b/exchanges/bitstamp/bitstamp_websocket.go @@ -153,7 +153,7 @@ func (b *Bitstamp) WsHandleData() { func (b *Bitstamp) generateDefaultSubscriptions() { var channels = []string{"live_trades_", "diff_order_book_"} enabledCurrencies := b.GetEnabledCurrencies() - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription for i := range channels { for j := range enabledCurrencies { subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ diff --git a/exchanges/bitstamp/bitstamp_wrapper.go b/exchanges/bitstamp/bitstamp_wrapper.go index 9526d874..2f1a2ceb 100644 --- a/exchanges/bitstamp/bitstamp_wrapper.go +++ b/exchanges/bitstamp/bitstamp_wrapper.go @@ -416,3 +416,13 @@ func (b *Bitstamp) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketC b.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (b *Bitstamp) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return b.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (b *Bitstamp) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/bittrex/bittrex_wrapper.go b/exchanges/bittrex/bittrex_wrapper.go index bfae5b73..cbb0ebea 100644 --- a/exchanges/bittrex/bittrex_wrapper.go +++ b/exchanges/bittrex/bittrex_wrapper.go @@ -409,3 +409,13 @@ func (b *Bittrex) SubscribeToWebsocketChannels(channels []exchange.WebsocketChan func (b *Bittrex) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (b *Bittrex) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (b *Bittrex) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/btcmarkets/btcmarkets_wrapper.go b/exchanges/btcmarkets/btcmarkets_wrapper.go index b3561f31..003f6e14 100644 --- a/exchanges/btcmarkets/btcmarkets_wrapper.go +++ b/exchanges/btcmarkets/btcmarkets_wrapper.go @@ -472,3 +472,13 @@ func (b *BTCMarkets) SubscribeToWebsocketChannels(channels []exchange.WebsocketC func (b *BTCMarkets) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (b *BTCMarkets) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (b *BTCMarkets) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/btse/btse_websocket.go b/exchanges/btse/btse_websocket.go index 78ef4347..a044bca6 100644 --- a/exchanges/btse/btse_websocket.go +++ b/exchanges/btse/btse_websocket.go @@ -204,7 +204,7 @@ func (b *BTSE) wsProcessSnapshot(snapshot *websocketOrderbookSnapshot) error { func (b *BTSE) GenerateDefaultSubscriptions() { var channels = []string{"snapshot", "ticker"} enabledCurrencies := b.GetEnabledCurrencies() - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription for i := range channels { for j := range enabledCurrencies { subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ diff --git a/exchanges/btse/btse_wrapper.go b/exchanges/btse/btse_wrapper.go index 22d1be50..6511289b 100644 --- a/exchanges/btse/btse_wrapper.go +++ b/exchanges/btse/btse_wrapper.go @@ -372,3 +372,13 @@ func (b *BTSE) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChann b.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (b *BTSE) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return b.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (b *BTSE) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/coinbasepro/coinbasepro.go b/exchanges/coinbasepro/coinbasepro.go index fb557de4..d60df3a7 100644 --- a/exchanges/coinbasepro/coinbasepro.go +++ b/exchanges/coinbasepro/coinbasepro.go @@ -92,7 +92,8 @@ func (c *CoinbasePro) SetDefaults() { c.Websocket.Functionality = exchange.WebsocketTickerSupported | exchange.WebsocketOrderbookSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported } // Setup initialises the exchange parameters with the current configuration @@ -102,6 +103,7 @@ func (c *CoinbasePro) Setup(exch *config.ExchangeConfig) { } else { c.Enabled = true c.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + c.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport c.SetAPIKeys(exch.APIKey, exch.APISecret, exch.ClientID, true) c.SetHTTPClientTimeout(exch.HTTPTimeout) c.SetHTTPClientUserAgent(exch.HTTPUserAgent) @@ -823,7 +825,7 @@ func (c *CoinbasePro) SendAuthenticatedHTTPRequest(method, path string, params m } } - n := c.Requester.GetNonce(true).String() + n := c.Requester.GetNonce(false).String() message := n + method + "/" + path + string(payload) hmac := common.GetHMAC(common.HashSHA256, []byte(message), []byte(c.APISecret)) headers := make(map[string]string) diff --git a/exchanges/coinbasepro/coinbasepro_test.go b/exchanges/coinbasepro/coinbasepro_test.go index 46cb5d4a..52048b90 100644 --- a/exchanges/coinbasepro/coinbasepro_test.go +++ b/exchanges/coinbasepro/coinbasepro_test.go @@ -1,12 +1,15 @@ package coinbasepro import ( + "net/http" "testing" "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) var c CoinbasePro @@ -33,7 +36,9 @@ func TestSetup(t *testing.T) { } gdxConfig.APIKey = apiKey gdxConfig.APISecret = apiSecret + gdxConfig.ClientID = clientID gdxConfig.AuthenticatedAPISupport = true + gdxConfig.AuthenticatedWebsocketAPISupport = true c.Setup(&gdxConfig) } @@ -87,137 +92,85 @@ func TestGetServerTime(t *testing.T) { } func TestAuthRequests(t *testing.T) { - - if c.APIKey != "" && c.APISecret != "" && c.ClientID != "" { - - _, err := c.GetAccounts() - if err == nil { - t.Error("Test failed - GetAccounts() error", err) - } - - _, err = c.GetAccount("234cb213-ac6f-4ed8-b7b6-e62512930945") - if err == nil { - t.Error("Test failed - GetAccount() error", err) - } - - _, err = c.GetAccountHistory("234cb213-ac6f-4ed8-b7b6-e62512930945") - if err == nil { - t.Error("Test failed - GetAccountHistory() error", err) - } - - _, err = c.GetHolds("234cb213-ac6f-4ed8-b7b6-e62512930945") - if err == nil { - t.Error("Test failed - GetHolds() error", err) - } - - _, err = c.PlaceLimitOrder("", 0, 0, "buy", "", "", "BTC-USD", "", false) - if err == nil { - t.Error("Test failed - PlaceLimitOrder() error", err) - } - - _, err = c.PlaceMarketOrder("", 1, 0, "buy", "BTC-USD", "") - if err == nil { - t.Error("Test failed - PlaceMarketOrder() error", err) - } - - err = c.CancelExistingOrder("1337") - if err == nil { - t.Error("Test failed - CancelExistingOrder() error", err) - } - - _, err = c.CancelAllExistingOrders("BTC-USD") - if err == nil { - t.Error("Test failed - CancelAllExistingOrders() error", err) - } - - _, err = c.GetOrders([]string{"open", "done"}, "BTC-USD") - if err == nil { - t.Error("Test failed - GetOrders() error", err) - } - - _, err = c.GetOrder("1337") - if err == nil { - t.Error("Test failed - GetOrders() error", err) - } - - _, err = c.GetFills("1337", "BTC-USD") - if err == nil { - t.Error("Test failed - GetFills() error", err) - } - _, err = c.GetFills("", "") - if err == nil { - t.Error("Test failed - GetFills() error", err) - } - - _, err = c.GetFundingRecords("rejected") - if err == nil { - t.Error("Test failed - GetFundingRecords() error", err) - } - - // _, err := c.RepayFunding("1", "BTC") - // if err != nil { - // t.Error("Test failed - RepayFunding() error", err) - // } - - _, err = c.MarginTransfer(1, "withdraw", "45fa9e3b-00ba-4631-b907-8a98cbdf21be", "BTC") - if err == nil { - t.Error("Test failed - MarginTransfer() error", err) - } - - _, err = c.GetPosition() - if err == nil { - t.Error("Test failed - GetPosition() error", err) - } - - _, err = c.ClosePosition(false) - if err == nil { - t.Error("Test failed - ClosePosition() error", err) - } - - _, err = c.GetPayMethods() - if err == nil { - t.Error("Test failed - GetPayMethods() error", err) - } - - _, err = c.DepositViaPaymentMethod(1, "BTC", "1337") - if err == nil { - t.Error("Test failed - DepositViaPaymentMethod() error", err) - } - - _, err = c.DepositViaCoinbase(1, "BTC", "1337") - if err == nil { - t.Error("Test failed - DepositViaCoinbase() error", err) - } - - _, err = c.WithdrawViaPaymentMethod(1, "BTC", "1337") - if err == nil { - t.Error("Test failed - WithdrawViaPaymentMethod() error", err) - } - - // _, err := c.WithdrawViaCoinbase(1, "BTC", "c13cd0fc-72ca-55e9-843b-b84ef628c198") - // if err != nil { - // t.Error("Test failed - WithdrawViaCoinbase() error", err) - // } - - _, err = c.WithdrawCrypto(1, "BTC", "1337") - if err == nil { - t.Error("Test failed - WithdrawViaCoinbase() error", err) - } - - _, err = c.GetCoinbaseAccounts() - if err == nil { - t.Error("Test failed - GetCoinbaseAccounts() error", err) - } - - _, err = c.GetReportStatus("1337") - if err == nil { - t.Error("Test failed - GetReportStatus() error", err) - } - - _, err = c.GetTrailingVolume() - if err == nil { - t.Error("Test failed - GetTrailingVolume() error", err) - } + if !areTestAPIKeysSet() { + t.Skip("API keys not set, skipping test") + } + _, err := c.GetAccounts() + if err != nil { + t.Error("Test failed - GetAccounts() error", err) + } + accountResponse, err := c.GetAccount("13371337-1337-1337-1337-133713371337") + if accountResponse.ID != "" { + t.Error("Expecting no data returned") + } + if err == nil { + t.Error("Expecting error") + } + accountHistoryResponse, err := c.GetAccountHistory("13371337-1337-1337-1337-133713371337") + if len(accountHistoryResponse) > 0 { + t.Error("Expecting no data returned") + } + if err == nil { + t.Error("Expecting error") + } + getHoldsResponse, err := c.GetHolds("13371337-1337-1337-1337-133713371337") + if len(getHoldsResponse) > 0 { + t.Error("Expecting no data returned") + } + if err == nil { + t.Error("Expecting error") + } + orderResponse, err := c.PlaceLimitOrder("", 0.001, 0.001, "buy", "", "", "BTC-USD", "", false) + if orderResponse != "" { + t.Error("Expecting no data returned") + } + if err == nil { + t.Error("Expecting error") + } + marketOrderResponse, err := c.PlaceMarketOrder("", 1, 0, "buy", "BTC-USD", "") + if marketOrderResponse != "" { + t.Error("Expecting no data returned") + } + if err == nil { + t.Error("Expecting error") + } + fillsResponse, err := c.GetFills("1337", "BTC-USD") + if len(fillsResponse) > 0 { + t.Error("Expecting no data returned") + } + if err == nil { + t.Error("Expecting error") + } + _, err = c.GetFills("", "") + if err == nil { + t.Error("Expecting error") + } + _, err = c.GetFundingRecords("rejected") + if err == nil { + t.Error("Expecting error") + } + marginTransferResponse, err := c.MarginTransfer(1, "withdraw", "13371337-1337-1337-1337-133713371337", "BTC") + if marginTransferResponse.ID != "" { + t.Error("Expecting no data returned") + } + if err == nil { + t.Error("Expecting error") + } + _, err = c.GetPosition() + if err == nil { + t.Error("Expecting error") + } + _, err = c.ClosePosition(false) + if err == nil { + t.Error("Expecting error") + } + _, err = c.GetPayMethods() + if err != nil { + t.Error("Test failed - GetPayMethods() error", err) + } + _, err = c.GetCoinbaseAccounts() + if err != nil { + t.Error("Test failed - GetCoinbaseAccounts() error", err) } } @@ -626,3 +579,37 @@ func TestGetDepositAddress(t *testing.T) { t.Error("Test Failed - GetDepositAddress() error", err) } } + +// TestWsAuth dials websocket, sends login request. +func TestWsAuth(t *testing.T) { + c.SetDefaults() + TestSetup(t) + if !c.Websocket.IsEnabled() && !c.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) + } + var err error + var dialer websocket.Dialer + c.WebsocketConn, _, err = dialer.Dial(c.Websocket.GetWebsocketURL(), + http.Header{}) + if err != nil { + t.Fatal(err) + } + c.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + c.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() + go c.WsHandleData() + defer c.WebsocketConn.Close() + err = c.Subscribe(exchange.WebsocketChannelSubscription{ + Channel: "user", + Currency: currency.NewPairFromString("BTC-USD"), + }) + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case badResponse := <-c.Websocket.DataHandler: + t.Error(badResponse) + case <-timer.C: + } + timer.Stop() +} diff --git a/exchanges/coinbasepro/coinbasepro_types.go b/exchanges/coinbasepro/coinbasepro_types.go index f9e5b281..23b75ea2 100644 --- a/exchanges/coinbasepro/coinbasepro_types.go +++ b/exchanges/coinbasepro/coinbasepro_types.go @@ -343,9 +343,13 @@ type FillResponse struct { // WebsocketSubscribe takes in subscription information type WebsocketSubscribe struct { - Type string `json:"type"` - ProductID string `json:"product_id,omitempty"` - Channels []WsChannels `json:"channels,omitempty"` + Type string `json:"type"` + ProductID string `json:"product_id,omitempty"` + Channels []WsChannels `json:"channels,omitempty"` + Signature string `json:"signature,omitempty"` + Key string `json:"key,omitempty"` + Passphrase string `json:"passphrase,omitempty"` + Timestamp string `json:"timestamp,omitempty"` } // WsChannels defines outgoing channels for subscription purposes @@ -360,7 +364,8 @@ type WebsocketReceived struct { OrderID string `json:"order_id"` OrderType string `json:"order_type"` Size float64 `json:"size,string"` - Price float64 `json:"price,string"` + Price float64 `json:"price,omitempty,string"` + Funds float64 `json:"funds,omitempty,string"` Side string `json:"side"` ClientOID string `json:"client_oid"` ProductID string `json:"product_id"` @@ -462,3 +467,20 @@ type WebsocketL2Update struct { Time string `json:"time"` Changes [][]interface{} `json:"changes"` } + +// WebsocketActivate an activate message is sent when a stop order is placed +type WebsocketActivate struct { + Type string `json:"type"` + ProductID string `json:"product_id"` + Timestamp string `json:"timestamp"` + UserID string `json:"user_id"` + ProfileID string `json:"profile_id"` + OrderID string `json:"order_id"` + StopType string `json:"stop_type"` + Side string `json:"side"` + StopPrice float64 `json:"stop_price,string"` + Size float64 `json:"size,string"` + Funds float64 `json:"funds,string"` + TakerFeeRate float64 `json:"taker_fee_rate,string"` + Private bool `json:"private"` +} diff --git a/exchanges/coinbasepro/coinbasepro_websocket.go b/exchanges/coinbasepro/coinbasepro_websocket.go index a773d928..fe24470c 100644 --- a/exchanges/coinbasepro/coinbasepro_websocket.go +++ b/exchanges/coinbasepro/coinbasepro_websocket.go @@ -148,6 +148,51 @@ func (c *CoinbasePro) WsHandleData() { c.Websocket.DataHandler <- err continue } + case "received": + // We currently use l2update to calculate orderbook changes + received := WebsocketReceived{} + err := common.JSONDecode(resp.Raw, &received) + if err != nil { + c.Websocket.DataHandler <- err + continue + } + c.Websocket.DataHandler <- received + case "open": + // We currently use l2update to calculate orderbook changes + open := WebsocketOpen{} + err := common.JSONDecode(resp.Raw, &open) + if err != nil { + c.Websocket.DataHandler <- err + continue + } + c.Websocket.DataHandler <- open + case "done": + // We currently use l2update to calculate orderbook changes + done := WebsocketDone{} + err := common.JSONDecode(resp.Raw, &done) + if err != nil { + c.Websocket.DataHandler <- err + continue + } + c.Websocket.DataHandler <- done + case "change": + // We currently use l2update to calculate orderbook changes + change := WebsocketChange{} + err := common.JSONDecode(resp.Raw, &change) + if err != nil { + c.Websocket.DataHandler <- err + continue + } + c.Websocket.DataHandler <- change + case "activate": + // We currently use l2update to calculate orderbook changes + activate := WebsocketActivate{} + err := common.JSONDecode(resp.Raw, &activate) + if err != nil { + c.Websocket.DataHandler <- err + continue + } + c.Websocket.DataHandler <- activate } } } @@ -241,10 +286,13 @@ func (c *CoinbasePro) ProcessUpdate(update WebsocketL2Update) error { // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (c *CoinbasePro) GenerateDefaultSubscriptions() { - var channels = []string{"heartbeat", "level2", "ticker"} + var channels = []string{"heartbeat", "level2", "ticker", "user"} enabledCurrencies := c.GetEnabledCurrencies() - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription for i := range channels { + if (channels[i] == "user" || channels[i] == "full") && !c.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + continue + } for j := range enabledCurrencies { enabledCurrencies[j].Delimiter = "-" subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ @@ -269,6 +317,15 @@ func (c *CoinbasePro) Subscribe(channelToSubscribe exchange.WebsocketChannelSubs }, }, } + if channelToSubscribe.Channel == "user" || channelToSubscribe.Channel == "full" { + n := fmt.Sprintf("%v", time.Now().Unix()) + message := n + "GET" + "/users/self/verify" + hmac := common.GetHMAC(common.HashSHA256, []byte(message), []byte(c.APISecret)) + subscribe.Signature = common.Base64Encode(hmac) + subscribe.Key = c.APIKey + subscribe.Passphrase = c.ClientID + subscribe.Timestamp = n + } return c.wsSend(subscribe) } diff --git a/exchanges/coinbasepro/coinbasepro_wrapper.go b/exchanges/coinbasepro/coinbasepro_wrapper.go index 62e1883d..611d40f1 100644 --- a/exchanges/coinbasepro/coinbasepro_wrapper.go +++ b/exchanges/coinbasepro/coinbasepro_wrapper.go @@ -396,3 +396,13 @@ func (c *CoinbasePro) UnsubscribeToWebsocketChannels(channels []exchange.Websock c.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (c *CoinbasePro) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return c.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (c *CoinbasePro) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/coinut/coinut.go b/exchanges/coinut/coinut.go index 3990cecb..de9a05f5 100644 --- a/exchanges/coinut/coinut.go +++ b/exchanges/coinut/coinut.go @@ -81,7 +81,10 @@ func (c *COINUT) SetDefaults() { exchange.WebsocketOrderbookSupported | exchange.WebsocketTradeDataSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported | + exchange.WebsocketSubmitOrderSupported | + exchange.WebsocketCancelOrderSupported } // Setup sets the current exchange configuration @@ -91,6 +94,7 @@ func (c *COINUT) Setup(exch *config.ExchangeConfig) { } else { c.Enabled = true c.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + c.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport c.SetAPIKeys(exch.APIKey, exch.APISecret, exch.ClientID, false) c.SetHTTPClientTimeout(exch.HTTPTimeout) c.SetHTTPClientUserAgent(exch.HTTPUserAgent) diff --git a/exchanges/coinut/coinut_test.go b/exchanges/coinut/coinut_test.go index 755748d2..553ea098 100644 --- a/exchanges/coinut/coinut_test.go +++ b/exchanges/coinut/coinut_test.go @@ -1,16 +1,20 @@ package coinut import ( + "net/http" "testing" "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) var c COINUT +var wsSetupRan bool // Please supply your own keys here to do better tests const ( @@ -31,6 +35,7 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - Coinut Setup() init error") } bConfig.AuthenticatedAPISupport = true + bConfig.AuthenticatedWebsocketAPISupport = true bConfig.APIKey = apiKey c.Setup(&bConfig) c.ClientID = clientID @@ -43,6 +48,46 @@ func TestSetup(t *testing.T) { } } +func setupWSTestAuth(t *testing.T) { + if wsSetupRan { + return + } + c.SetDefaults() + TestSetup(t) + if !c.Websocket.IsEnabled() && !c.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) + } + var err error + var dialer websocket.Dialer + c.WebsocketConn, _, err = dialer.Dial(c.Websocket.GetWebsocketURL(), + http.Header{}) + if err != nil { + t.Fatal(err) + } + c.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + c.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() + go c.WsHandleData() + err = c.wsAuthenticate() + if err != nil { + t.Error(err) + } + + timer := time.NewTimer(5 * time.Second) + select { + case resp := <-c.Websocket.DataHandler: + if resp.(WsLoginResponse).Username != clientID { + t.Fatal("Unsuccessful login") + } + case <-timer.C: + t.Fatal("Expected response") + } + timer.Stop() + time.Sleep(2 * time.Second) + instrumentListByString = make(map[string]int64) + instrumentListByString[currency.NewPair(currency.LTC, currency.BTC).String()] = 1 + wsSetupRan = true +} + func TestGetInstruments(t *testing.T) { _, err := c.GetInstruments() if err != nil { @@ -402,3 +447,101 @@ func TestGetDepositAddress(t *testing.T) { t.Error("Test Failed - GetDepositAddress() function unsupported cannot be nil") } } + +// TestWsAuthGetAccountBalance dials websocket, sends login request. +func TestWsAuthGetAccountBalance(t *testing.T) { + setupWSTestAuth(t) + err := c.wsGetAccountBalance() + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseExtendedTimeout) + select { + case resp := <-c.Websocket.DataHandler: + if resp.(WsUserBalanceResponse).Status[0] != "OK" { + t.Error("Expected successful response") + } + case <-timer.C: + t.Error("Expected response") + } + timer.Stop() +} + +// TestWsAuthSubmitOrders dials websocket, sends login request. +func TestWsAuthSubmitOrders(t *testing.T) { + setupWSTestAuth(t) + order := WsSubmitOrderParameters{ + Amount: 1, + Currency: currency.NewPair(currency.LTC, currency.BTC), + OrderID: 1, + Price: 1, + Side: exchange.BuyOrderSide, + } + err := c.wsSubmitOrders([]WsSubmitOrderParameters{order, order}) + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseExtendedTimeout) + select { + case <-c.Websocket.DataHandler: + case <-timer.C: + t.Error("Expected response") + } + timer.Stop() +} + +// TestWsAuthCancelOrders dials websocket, sends login request. +func TestWsAuthCancelOrders(t *testing.T) { + setupWSTestAuth(t) + order := WsCancelOrderParameters{ + Currency: currency.NewPair(currency.LTC, currency.BTC), + OrderID: 1, + } + err := c.wsCancelOrders([]WsCancelOrderParameters{order, order}) + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseExtendedTimeout) + select { + case <-c.Websocket.DataHandler: + case <-timer.C: + t.Error("Expected response") + } + timer.Stop() +} + +// TestWsAuthCancelOrder dials websocket, sends login request. +func TestWsAuthCancelOrder(t *testing.T) { + setupWSTestAuth(t) + order := WsCancelOrderParameters{ + Currency: currency.NewPair(currency.LTC, currency.BTC), + OrderID: 1, + } + err := c.wsCancelOrder(order) + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseExtendedTimeout) + select { + case <-c.Websocket.DataHandler: + case <-timer.C: + t.Error("Expected response") + } + timer.Stop() +} + +// TestWsAuthGetOpenOrders dials websocket, sends login request. +func TestWsAuthGetOpenOrders(t *testing.T) { + setupWSTestAuth(t) + err := c.wsGetOpenOrders(currency.NewPair(currency.LTC, currency.BTC)) + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseExtendedTimeout) + select { + case <-c.Websocket.DataHandler: + case <-timer.C: + t.Error("Expected response") + } + timer.Stop() +} diff --git a/exchanges/coinut/coinut_types.go b/exchanges/coinut/coinut_types.go index 1acb2e89..cd708998 100644 --- a/exchanges/coinut/coinut_types.go +++ b/exchanges/coinut/coinut_types.go @@ -1,5 +1,10 @@ package coinut +import ( + "github.com/thrasher-/gocryptotrader/currency" + exchange "github.com/thrasher-/gocryptotrader/exchanges" +) + // GenericResponse is the generic response you will get from coinut type GenericResponse struct { Nonce int64 `json:"nonce"` @@ -111,8 +116,8 @@ type OrderResponse struct { // Commission holds trade commission structure type Commission struct { - Currency string `json:"currency"` - Amount float64 `json:"amount,string"` + Currency currency.Pair `json:"currency"` + Amount float64 `json:"amount,string"` } // OrderFilledResponse contains order filled response @@ -362,3 +367,248 @@ type WsSupportedCurrency struct { DecimalPlaces int64 `json:"decimal_places"` Quote string `json:"quote"` } + +// WsRequest base request +type WsRequest struct { + Request string `json:"request"` + Nonce int64 `json:"nonce"` +} + +// WsTradeHistoryRequest ws request +type WsTradeHistoryRequest struct { + InstID int64 `json:"inst_id"` + Start int64 `json:"start,omitempty"` + Limit int64 `json:"limit,omitempty"` + WsRequest +} + +// WsCancelOrdersRequest ws request +type WsCancelOrdersRequest struct { + Entries []WsCancelOrdersRequestEntry `json:"entries"` + WsRequest +} + +// WsCancelOrdersRequestEntry ws request entry +type WsCancelOrdersRequestEntry struct { + InstID int64 `json:"inst_id"` + OrderID int64 `json:"order_id"` +} + +// WsCancelOrderParameters ws request parameters +type WsCancelOrderParameters struct { + Currency currency.Pair + OrderID int64 +} + +// WsCancelOrderRequest ws request +type WsCancelOrderRequest struct { + InstID int64 `json:"inst_id"` + OrderID int64 `json:"order_id"` + WsRequest +} + +// WsCancelOrderResponse ws response +type WsCancelOrderResponse struct { + Nonce int64 `json:"nonce"` + Reply string `json:"reply"` + OrderID int64 `json:"order_id"` + ClientOrdID int64 `json:"client_ord_id"` + Status []string `json:"status"` +} + +// WsCancelOrdersResponse ws response +type WsCancelOrdersResponse struct { + WsRequest + Entries []WsCancelOrdersResponseEntry `json:"entries"` +} + +// WsCancelOrdersResponseEntry ws response entry +type WsCancelOrdersResponseEntry struct { + InstID int64 `json:"inst_id"` + OrderID int64 `json:"order_id"` +} + +// WsGetOpenOrdersRequest ws request +type WsGetOpenOrdersRequest struct { + InstID int64 `json:"inst_id"` + WsRequest +} + +// WsSubmitOrdersRequest ws request +type WsSubmitOrdersRequest struct { + Orders []WsSubmitOrdersRequestData `json:"orders"` + WsRequest +} + +// WsSubmitOrdersRequestData ws request data +type WsSubmitOrdersRequestData struct { + InstID int64 `json:"inst_id"` + Price float64 `json:"price,string"` + Qty float64 `json:"qty,string"` + ClientOrdID int `json:"client_ord_id"` + Side string `json:"side"` +} + +// WsSubmitOrderRequest ws request +type WsSubmitOrderRequest struct { + InstID int64 `json:"inst_id"` + Price float64 `json:"price,string"` + Qty float64 `json:"qty,string"` + OrderID int64 `json:"client_ord_id"` + Side string `json:"side"` + WsRequest +} + +// WsSubmitOrderParameters ws request parameters +type WsSubmitOrderParameters struct { + Currency currency.Pair + Side exchange.OrderSide + Amount, Price float64 + OrderID int64 +} + +// WsUserBalanceResponse ws response +type WsUserBalanceResponse struct { + Nonce int64 `json:"nonce"` + Status []string `json:"status"` + Btc float64 `json:"BTC,string"` + Ltc float64 `json:"LTC,string"` + Etc float64 `json:"ETC,string"` + Eth float64 `json:"ETH,string"` + FloatingPl float64 `json:"floating_pl,string"` + InitialMargin float64 `json:"initial_margin,string"` + RealizedPl float64 `json:"realized_pl,string"` + MaintenanceMargin float64 `json:"maintenance_margin,string"` + Equity float64 `json:"equity,string"` + Reply string `json:"reply"` + TransID int64 `json:"trans_id"` +} + +// WsOrderAcceptedResponse ws response +type WsOrderAcceptedResponse struct { + Nonce int64 `json:"nonce"` + Status []string `json:"status"` + OrderID int64 `json:"order_id"` + OpenQty float64 `json:"open_qty,string"` + InstID int64 `json:"inst_id"` + Qty float64 `json:"qty,string"` + ClientOrdID int64 `json:"client_ord_id"` + OrderPrice float64 `json:"order_price,string"` + Reply string `json:"reply"` + Side string `json:"side"` + TransID int64 `json:"trans_id"` +} + +// WsOrderFilledResponse ws response +type WsOrderFilledResponse struct { + Commission WsOrderFilledCommissionData `json:"commission"` + FillPrice float64 `json:"fill_price,string"` + FillQty float64 `json:"fill_qty,string"` + Nonce int64 `json:"nonce"` + Order WsOrderData `json:"order"` + Reply string `json:"reply"` + Status []string `json:"status"` + Timestamp int64 `json:"timestamp"` + TransID int64 `json:"trans_id"` +} + +// WsOrderData ws response data +type WsOrderData struct { + ClientOrdID int64 `json:"client_ord_id"` + InstID int64 `json:"inst_id"` + OpenQty float64 `json:"open_qty,string"` + OrderID int64 `json:"order_id"` + Price float64 `json:"price,string"` + Qty float64 `json:"qty,string"` + Side string `json:"side"` + Timestamp int64 `json:"timestamp"` +} + +// WsOrderFilledCommissionData ws response data +type WsOrderFilledCommissionData struct { + Amount float64 `json:"amount,string"` + Currency currency.Pair `json:"currency"` +} + +// WsOrderRejectedResponse ws response +type WsOrderRejectedResponse struct { + Nonce int64 `json:"nonce"` + Status []string `json:"status"` + OrderID int64 `json:"order_id"` + OpenQty float64 `json:"open_qty,string"` + Price float64 `json:"price,string"` + InstID int64 `json:"inst_id"` + Reasons []string `json:"reasons"` + ClientOrdID int64 `json:"client_ord_id"` + Timestamp int64 `json:"timestamp"` + Reply string `json:"reply"` + Qty float64 `json:"qty,string"` + Side string `json:"side"` + TransID int64 `json:"trans_id"` +} + +// WsUserOpenOrdersResponse ws response +type WsUserOpenOrdersResponse struct { + Nonce int64 `json:"nonce"` + Reply string `json:"reply"` + Status []string `json:"status"` + Orders []WsOrderData `json:"orders"` +} + +// WsTradeHistoryResponse ws response +type WsTradeHistoryResponse struct { + Nonce int64 `json:"nonce"` + Reply string `json:"reply"` + Status []string `json:"status"` + TotalNumber int64 `json:"total_number"` + Trades []WsOrderData `json:"trades"` +} + +// WsTradeHistoryCommissionData ws response data +type WsTradeHistoryCommissionData struct { + Amount float64 `json:"amount,string"` + Currency currency.Pair `json:"currency"` +} + +// WsTradeHistoryTradeData ws response data +type WsTradeHistoryTradeData struct { + Commission WsTradeHistoryCommissionData `json:"commission"` + Order WsOrderData `json:"order"` + FillPrice float64 `json:"fill_price,string"` + FillQty float64 `json:"fill_qty,string"` + Timestamp int64 `json:"timestamp"` + TransID int64 `json:"trans_id"` +} + +// WsLoginResponse ws response data +type WsLoginResponse struct { + APIKey string `json:"api_key"` + Country string `json:"country"` + DepositEnabled bool `json:"deposit_enabled"` + Deposited bool `json:"deposited"` + Email string `json:"email"` + FailedTimes int64 `json:"failed_times"` + KycPassed bool `json:"kyc_passed"` + Lang string `json:"lang"` + Nonce int64 `json:"nonce"` + OtpEnabled bool `json:"otp_enabled"` + PhoneNumber string `json:"phone_number"` + ProductsEnabled []string `json:"products_enabled"` + Referred bool `json:"referred"` + Reply string `json:"reply"` + SessionID string `json:"session_id"` + Status []string `json:"status"` + Timezone string `json:"timezone"` + Traded bool `json:"traded"` + UnverifiedEmail string `json:"unverified_email"` + Username string `json:"username"` + WithdrawEnabled bool `json:"withdraw_enabled"` +} + +// WsNewOrderResponse returns if new_order response failes +type WsNewOrderResponse struct { + Msg string `json:"msg"` + Nonce int64 `json:"nonce"` + Reply string `json:"reply"` + Status []string `json:"status"` +} diff --git a/exchanges/coinut/coinut_websocket.go b/exchanges/coinut/coinut_websocket.go index 22218d70..bc247edf 100644 --- a/exchanges/coinut/coinut_websocket.go +++ b/exchanges/coinut/coinut_websocket.go @@ -2,8 +2,10 @@ package coinut import ( "errors" + "fmt" "net/http" "net/url" + "strings" "time" "github.com/gorilla/websocket" @@ -28,140 +30,6 @@ var populatedList bool // wss://wsapi-na.coinut.com // wss://wsapi-eu.coinut.com -// WsReadData reads data from the websocket connection -func (c *COINUT) WsReadData() (exchange.WebsocketResponse, error) { - _, resp, err := c.WebsocketConn.ReadMessage() - if err != nil { - return exchange.WebsocketResponse{}, err - } - - c.Websocket.TrafficAlert <- struct{}{} - return exchange.WebsocketResponse{Raw: resp}, nil -} - -// WsHandleData handles read data -func (c *COINUT) WsHandleData() { - c.Websocket.Wg.Add(1) - - defer func() { - c.Websocket.Wg.Done() - }() - - for { - select { - case <-c.Websocket.ShutdownC: - return - - default: - resp, err := c.WsReadData() - if err != nil { - c.Websocket.DataHandler <- err - return - } - - var incoming wsResponse - err = common.JSONDecode(resp.Raw, &incoming) - if err != nil { - c.Websocket.DataHandler <- err - continue - } - switch incoming.Reply { - case "hb": - channels["hb"] <- resp.Raw - - case "inst_tick": - var ticker WsTicker - err := common.JSONDecode(resp.Raw, &ticker) - if err != nil { - c.Websocket.DataHandler <- err - continue - } - - c.Websocket.DataHandler <- exchange.TickerData{ - Timestamp: time.Unix(0, ticker.Timestamp), - Exchange: c.GetName(), - AssetType: "SPOT", - HighPrice: ticker.HighestBuy, - LowPrice: ticker.LowestSell, - ClosePrice: ticker.Last, - Quantity: ticker.Volume, - } - - case "inst_order_book": - var orderbooksnapshot WsOrderbookSnapshot - err := common.JSONDecode(resp.Raw, &orderbooksnapshot) - if err != nil { - c.Websocket.DataHandler <- err - continue - } - - err = c.WsProcessOrderbookSnapshot(&orderbooksnapshot) - if err != nil { - c.Websocket.DataHandler <- err - continue - } - - currencyPair := instrumentListByCode[orderbooksnapshot.InstID] - - c.Websocket.DataHandler <- exchange.WebsocketOrderbookUpdate{ - Exchange: c.GetName(), - Asset: "SPOT", - Pair: currency.NewPairFromString(currencyPair), - } - - case "inst_order_book_update": - var orderbookUpdate WsOrderbookUpdate - err := common.JSONDecode(resp.Raw, &orderbookUpdate) - if err != nil { - c.Websocket.DataHandler <- err - continue - } - - err = c.WsProcessOrderbookUpdate(&orderbookUpdate) - if err != nil { - c.Websocket.DataHandler <- err - continue - } - - currencyPair := instrumentListByCode[orderbookUpdate.InstID] - - c.Websocket.DataHandler <- exchange.WebsocketOrderbookUpdate{ - Exchange: c.GetName(), - Asset: "SPOT", - Pair: currency.NewPairFromString(currencyPair), - } - - case "inst_trade": - var tradeSnap WsTradeSnapshot - err := common.JSONDecode(resp.Raw, &tradeSnap) - if err != nil { - c.Websocket.DataHandler <- err - continue - } - - case "inst_trade_update": - var tradeUpdate WsTradeUpdate - err := common.JSONDecode(resp.Raw, &tradeUpdate) - if err != nil { - c.Websocket.DataHandler <- err - continue - } - - currencyPair := instrumentListByCode[tradeUpdate.InstID] - - c.Websocket.DataHandler <- exchange.TradeData{ - Timestamp: time.Unix(tradeUpdate.Timestamp, 0), - CurrencyPair: currency.NewPairFromString(currencyPair), - AssetType: "SPOT", - Exchange: c.GetName(), - Price: tradeUpdate.Price, - Side: tradeUpdate.Side, - } - } - } - } -} - // WsConnect intiates a websocket connection func (c *COINUT) WsConnect() error { if !c.Websocket.IsEnabled() || !c.IsEnabled() { @@ -197,7 +65,7 @@ func (c *COINUT) WsConnect() error { } populatedList = true } - + c.wsAuthenticate() c.GenerateDefaultSubscriptions() // define bi-directional communication @@ -205,10 +73,242 @@ func (c *COINUT) WsConnect() error { channels["hb"] = make(chan []byte, 1) go c.WsHandleData() - return nil } +// WsReadData reads data from the websocket connection +func (c *COINUT) WsReadData() (exchange.WebsocketResponse, error) { + _, resp, err := c.WebsocketConn.ReadMessage() + if err != nil { + return exchange.WebsocketResponse{}, err + } + + c.Websocket.TrafficAlert <- struct{}{} + return exchange.WebsocketResponse{Raw: resp}, nil +} + +// WsHandleData handles read data +func (c *COINUT) WsHandleData() { + c.Websocket.Wg.Add(1) + + defer func() { + c.Websocket.Wg.Done() + }() + + for { + select { + case <-c.Websocket.ShutdownC: + return + + default: + resp, err := c.WsReadData() + if err != nil { + c.Websocket.DataHandler <- err + return + } + + if strings.HasPrefix(string(resp.Raw), "[") { + var incoming []wsResponse + err = common.JSONDecode(resp.Raw, &incoming) + if err != nil { + c.Websocket.DataHandler <- err + continue + } + for i := range incoming { + var individualJSON []byte + individualJSON, err = common.JSONEncode(incoming[i]) + if err != nil { + c.Websocket.DataHandler <- err + continue + } + c.wsProcessResponse(individualJSON) + } + + } else { + var incoming wsResponse + err = common.JSONDecode(resp.Raw, &incoming) + if err != nil { + c.Websocket.DataHandler <- err + continue + } + c.wsProcessResponse(resp.Raw) + } + + } + } +} + +func (c *COINUT) wsProcessResponse(resp []byte) { + var incoming wsResponse + err := common.JSONDecode(resp, &incoming) + if err != nil { + c.Websocket.DataHandler <- err + return + } + switch incoming.Reply { + case "login": + var login WsLoginResponse + err := common.JSONDecode(resp, &login) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.SetCanUseAuthenticatedEndpoints(login.Username == c.ClientID) + c.Websocket.DataHandler <- login + case "hb": + channels["hb"] <- resp + case "inst_tick": + var ticker WsTicker + err := common.JSONDecode(resp, &ticker) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.DataHandler <- exchange.TickerData{ + Timestamp: time.Unix(0, ticker.Timestamp), + Exchange: c.GetName(), + AssetType: "SPOT", + HighPrice: ticker.HighestBuy, + LowPrice: ticker.LowestSell, + ClosePrice: ticker.Last, + Quantity: ticker.Volume, + } + + case "inst_order_book": + var orderbooksnapshot WsOrderbookSnapshot + err := common.JSONDecode(resp, &orderbooksnapshot) + if err != nil { + c.Websocket.DataHandler <- err + return + } + err = c.WsProcessOrderbookSnapshot(&orderbooksnapshot) + if err != nil { + c.Websocket.DataHandler <- err + return + } + currencyPair := instrumentListByCode[orderbooksnapshot.InstID] + c.Websocket.DataHandler <- exchange.WebsocketOrderbookUpdate{ + Exchange: c.GetName(), + Asset: "SPOT", + Pair: currency.NewPairFromString(currencyPair), + } + case "inst_order_book_update": + var orderbookUpdate WsOrderbookUpdate + err := common.JSONDecode(resp, &orderbookUpdate) + if err != nil { + c.Websocket.DataHandler <- err + return + } + err = c.WsProcessOrderbookUpdate(&orderbookUpdate) + if err != nil { + c.Websocket.DataHandler <- err + return + } + currencyPair := instrumentListByCode[orderbookUpdate.InstID] + c.Websocket.DataHandler <- exchange.WebsocketOrderbookUpdate{ + Exchange: c.GetName(), + Asset: "SPOT", + Pair: currency.NewPairFromString(currencyPair), + } + case "inst_trade": + var tradeSnap WsTradeSnapshot + err := common.JSONDecode(resp, &tradeSnap) + if err != nil { + c.Websocket.DataHandler <- err + return + } + + case "inst_trade_update": + var tradeUpdate WsTradeUpdate + err := common.JSONDecode(resp, &tradeUpdate) + if err != nil { + c.Websocket.DataHandler <- err + return + } + currencyPair := instrumentListByCode[tradeUpdate.InstID] + c.Websocket.DataHandler <- exchange.TradeData{ + Timestamp: time.Unix(tradeUpdate.Timestamp, 0), + CurrencyPair: currency.NewPairFromString(currencyPair), + AssetType: "SPOT", + Exchange: c.GetName(), + Price: tradeUpdate.Price, + Side: tradeUpdate.Side, + } + case "user_balance": + var userBalance WsUserBalanceResponse + err := common.JSONDecode(resp, &userBalance) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.DataHandler <- userBalance + case "new_order": + var newOrder WsNewOrderResponse + err := common.JSONDecode(resp, &newOrder) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.DataHandler <- newOrder + case "order_accepted": + var orderAccepted WsOrderAcceptedResponse + err := common.JSONDecode(resp, &orderAccepted) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.DataHandler <- orderAccepted + case "order_filled": + var orderFilled WsOrderFilledResponse + err := common.JSONDecode(resp, &orderFilled) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.DataHandler <- orderFilled + case "order_rejected": + var orderRejected WsOrderRejectedResponse + err := common.JSONDecode(resp, &orderRejected) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.DataHandler <- orderRejected + case "user_open_orders": + var openOrders WsUserOpenOrdersResponse + err := common.JSONDecode(resp, &openOrders) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.DataHandler <- openOrders + case "trade_history": + var tradeHistory WsTradeHistoryResponse + err := common.JSONDecode(resp, &tradeHistory) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.DataHandler <- tradeHistory + case "cancel_orders": + var cancelOrders WsCancelOrdersResponse + err := common.JSONDecode(resp, &cancelOrders) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.DataHandler <- cancelOrders + case "cancel_order": + var cancelOrder WsCancelOrderResponse + err := common.JSONDecode(resp, &cancelOrder) + if err != nil { + c.Websocket.DataHandler <- err + return + } + c.Websocket.DataHandler <- cancelOrder + } +} + // GetNonce returns a nonce for a required request func (c *COINUT) GetNonce() int64 { if c.Nonce.Get() == 0 { @@ -309,7 +409,7 @@ func (c *COINUT) WsProcessOrderbookUpdate(ob *WsOrderbookUpdate) error { // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (c *COINUT) GenerateDefaultSubscriptions() { var channels = []string{"inst_tick", "inst_order_book"} - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription enabledCurrencies := c.GetEnabledCurrencies() for i := range channels { for j := range enabledCurrencies { @@ -360,3 +460,146 @@ func (c *COINUT) wsSend(data interface{}) error { time.Sleep(coinutWebsocketRateLimit) return c.WebsocketConn.WriteMessage(websocket.TextMessage, json) } + +func (c *COINUT) wsAuthenticate() error { + if !c.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", c.Name) + } + timestamp := time.Now().Unix() + nonce := c.GetNonce() + payload := fmt.Sprintf("%v|%v|%v", c.ClientID, timestamp, nonce) + hmac := common.GetHMAC(common.HashSHA256, []byte(payload), []byte(c.APIKey)) + loginRequest := struct { + Request string `json:"request"` + Username string `json:"username"` + Nonce int64 `json:"nonce"` + Hmac string `json:"hmac_sha256"` + Timestamp int64 `json:"timestamp"` + }{ + Request: "login", + Username: c.ClientID, + Nonce: nonce, + Hmac: common.HexEncodeToString(hmac), + Timestamp: timestamp, + } + + err := c.wsSend(loginRequest) + if err != nil { + c.Websocket.SetCanUseAuthenticatedEndpoints(false) + return err + } + return nil + +} + +func (c *COINUT) wsGetAccountBalance() error { + if !c.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authorised to submit order", c.Name) + } + accBalance := wsRequest{ + Request: "user_balance", + Nonce: c.GetNonce(), + } + return c.wsSend(accBalance) +} + +func (c *COINUT) wsSubmitOrder(order *WsSubmitOrderParameters) error { + if !c.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authorised to submit order", c.Name) + } + currency := exchange.FormatExchangeCurrency(c.Name, order.Currency).String() + var orderSubmissionRequest WsSubmitOrderRequest + orderSubmissionRequest.Request = "new_order" + orderSubmissionRequest.Nonce = c.GetNonce() + orderSubmissionRequest.InstID = instrumentListByString[currency] + orderSubmissionRequest.Qty = order.Amount + orderSubmissionRequest.Price = order.Price + orderSubmissionRequest.Side = string(order.Side) + + if order.OrderID > 0 { + orderSubmissionRequest.OrderID = order.OrderID + } + return c.wsSend(orderSubmissionRequest) +} + +func (c *COINUT) wsSubmitOrders(orders []WsSubmitOrderParameters) error { + if !c.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authorised to submit orders", c.Name) + } + orderRequest := WsSubmitOrdersRequest{} + for i := range orders { + currency := exchange.FormatExchangeCurrency(c.Name, orders[i].Currency).String() + orderRequest.Orders = append(orderRequest.Orders, + WsSubmitOrdersRequestData{ + Qty: orders[i].Amount, + Price: orders[i].Price, + Side: string(orders[i].Side), + InstID: instrumentListByString[currency], + ClientOrdID: i + 1, + }) + } + + orderRequest.Nonce = c.GetNonce() + orderRequest.Request = "new_orders" + return c.wsSend(orderRequest) +} + +func (c *COINUT) wsGetOpenOrders(p currency.Pair) error { + if !c.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authorised to get open orders", c.Name) + } + currency := exchange.FormatExchangeCurrency(c.Name, p).String() + var openOrdersRequest WsGetOpenOrdersRequest + openOrdersRequest.Request = "user_open_orders" + openOrdersRequest.Nonce = c.GetNonce() + openOrdersRequest.InstID = instrumentListByString[currency] + + return c.wsSend(openOrdersRequest) +} + +func (c *COINUT) wsCancelOrder(cancellation WsCancelOrderParameters) error { + if !c.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authorised to cancel order", c.Name) + } + currency := exchange.FormatExchangeCurrency(c.Name, cancellation.Currency).String() + var cancellationRequest WsCancelOrderRequest + cancellationRequest.Request = "cancel_order" + cancellationRequest.InstID = instrumentListByString[currency] + cancellationRequest.OrderID = cancellation.OrderID + cancellationRequest.Nonce = c.GetNonce() + + return c.wsSend(cancellationRequest) +} + +func (c *COINUT) wsCancelOrders(cancellations []WsCancelOrderParameters) error { + if !c.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authorised to cancel orders", c.Name) + } + cancelOrderRequest := WsCancelOrdersRequest{} + for i := range cancellations { + currency := exchange.FormatExchangeCurrency(c.Name, cancellations[i].Currency).String() + cancelOrderRequest.Entries = append(cancelOrderRequest.Entries, WsCancelOrdersRequestEntry{ + InstID: instrumentListByString[currency], + OrderID: cancellations[i].OrderID, + }) + } + + cancelOrderRequest.Request = "cancel_orders" + cancelOrderRequest.Nonce = c.GetNonce() + return c.wsSend(cancelOrderRequest) +} + +func (c *COINUT) wsGetTradeHistory(p currency.Pair, start, limit int64) error { + if !c.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authorised to get trade history", c.Name) + } + currency := exchange.FormatExchangeCurrency(c.Name, p).String() + var request WsTradeHistoryRequest + request.Request = "trade_history" + request.InstID = instrumentListByString[currency] + request.Nonce = c.GetNonce() + request.Start = start + request.Limit = limit + + return c.wsSend(request) +} diff --git a/exchanges/coinut/coinut_wrapper.go b/exchanges/coinut/coinut_wrapper.go index 5807adbe..25c4b0f0 100644 --- a/exchanges/coinut/coinut_wrapper.go +++ b/exchanges/coinut/coinut_wrapper.go @@ -517,3 +517,13 @@ func (c *COINUT) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketCha c.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (c *COINUT) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return c.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (c *COINUT) AuthenticateWebsocket() error { + return c.wsAuthenticate() +} diff --git a/exchanges/exchange.go b/exchanges/exchange.go index a163c8dd..39e98306 100644 --- a/exchanges/exchange.go +++ b/exchanges/exchange.go @@ -181,6 +181,9 @@ const ( NoFiatWithdrawalsText string = "NO FIAT WITHDRAWAL" UnknownWithdrawalTypeText string = "UNKNOWN" + + RestAuthentication uint8 = 0 + WebsocketAuthentication uint8 = 1 ) // AccountInfo is a Generic type to hold each exchange's holdings in @@ -258,6 +261,7 @@ type Base struct { Verbose bool RESTPollingDelay time.Duration AuthenticatedAPISupport bool + AuthenticatedWebsocketAPISupport bool APIWithdrawPermissions uint32 APIAuthPEMKeySupport bool APISecret, APIKey, APIAuthPEMKey, ClientID string @@ -300,7 +304,7 @@ type IBotExchange interface { GetAvailableCurrencies() currency.Pairs GetAssetTypes() []string GetAccountInfo() (AccountInfo, error) - GetAuthenticatedAPISupport() bool + GetAuthenticatedAPISupport(endpoint uint8) bool SetCurrencies(pairs []currency.Pair, enabledPairs bool) error GetExchangeHistory(p currency.Pair, assetType string) ([]TradeHistory, error) SupportsAutoPairUpdates() bool @@ -325,6 +329,8 @@ type IBotExchange interface { GetWebsocket() (*Websocket, error) SubscribeToWebsocketChannels(channels []WebsocketChannelSubscription) error UnsubscribeToWebsocketChannels(channels []WebsocketChannelSubscription) error + AuthenticateWebsocket() error + GetSubscriptions() ([]WebsocketChannelSubscription, error) } // SupportsRESTTickerBatchUpdates returns whether or not the @@ -573,8 +579,14 @@ func (e *Base) SetCurrencyPairFormat() error { // GetAuthenticatedAPISupport returns whether the exchange supports // authenticated API requests -func (e *Base) GetAuthenticatedAPISupport() bool { - return e.AuthenticatedAPISupport +func (e *Base) GetAuthenticatedAPISupport(endpoint uint8) bool { + switch endpoint { + case RestAuthentication: + return e.AuthenticatedAPISupport + case WebsocketAuthentication: + return e.AuthenticatedWebsocketAPISupport + } + return false } // GetName is a method that returns the name of the exchange base @@ -672,17 +684,16 @@ func (e *Base) IsEnabled() bool { // SetAPIKeys is a method that sets the current API keys for the exchange func (e *Base) SetAPIKeys(apiKey, apiSecret, clientID string, b64Decode bool) { - if !e.AuthenticatedAPISupport { + if !e.AuthenticatedAPISupport && !e.AuthenticatedWebsocketAPISupport { return } - e.APIKey = apiKey e.ClientID = clientID - if b64Decode { result, err := common.Base64Decode(apiSecret) if err != nil { e.AuthenticatedAPISupport = false + e.AuthenticatedWebsocketAPISupport = false log.Warnf(warningBase64DecryptSecretKeyFailed, e.Name) } e.APISecret = string(result) diff --git a/exchanges/exchange_test.go b/exchanges/exchange_test.go index a0fd98db..340e256a 100644 --- a/exchanges/exchange_test.go +++ b/exchanges/exchange_test.go @@ -371,13 +371,25 @@ func TestSetCurrencyPairFormat(t *testing.T) { } } +// TestGetAuthenticatedAPISupport logic test func TestGetAuthenticatedAPISupport(t *testing.T) { base := Base{ - AuthenticatedAPISupport: false, + AuthenticatedAPISupport: true, + AuthenticatedWebsocketAPISupport: false, } - if base.GetAuthenticatedAPISupport() { - t.Fatal("Test failed. TestGetAuthenticatedAPISupport returned true when it should of been false.") + if !base.GetAuthenticatedAPISupport(RestAuthentication) { + t.Fatal("Test failed. Expected RestAuthentication to return true") + } + if base.GetAuthenticatedAPISupport(WebsocketAuthentication) { + t.Fatal("Test failed. Expected WebsocketAuthentication to return false") + } + base.AuthenticatedWebsocketAPISupport = true + if !base.GetAuthenticatedAPISupport(WebsocketAuthentication) { + t.Fatal("Test failed. Expected WebsocketAuthentication to return true") + } + if base.GetAuthenticatedAPISupport(2) { + t.Fatal("Test failed. Expected default case of 'false' to be returned") } } @@ -669,11 +681,13 @@ func TestIsEnabled(t *testing.T) { } } +// TestSetAPIKeys logic test func TestSetAPIKeys(t *testing.T) { SetAPIKeys := Base{ - Name: "TESTNAME", - Enabled: false, - AuthenticatedAPISupport: false, + Name: "TESTNAME", + Enabled: false, + AuthenticatedAPISupport: false, + AuthenticatedWebsocketAPISupport: false, } SetAPIKeys.SetAPIKeys("RocketMan", "Digereedoo", "007", false) @@ -682,10 +696,26 @@ func TestSetAPIKeys(t *testing.T) { } SetAPIKeys.AuthenticatedAPISupport = true + SetAPIKeys.AuthenticatedWebsocketAPISupport = true SetAPIKeys.SetAPIKeys("RocketMan", "Digereedoo", "007", false) if SetAPIKeys.APIKey != "RocketMan" && SetAPIKeys.APISecret != "Digereedoo" && SetAPIKeys.ClientID != "007" { t.Error("Test Failed - Exchange SetAPIKeys() did not set correct values") } + + SetAPIKeys.AuthenticatedAPISupport = false + SetAPIKeys.AuthenticatedWebsocketAPISupport = true + SetAPIKeys.SetAPIKeys("RocketMan", "Digereedoo", "007", false) + if SetAPIKeys.APIKey != "RocketMan" && SetAPIKeys.APISecret != "Digereedoo" && SetAPIKeys.ClientID != "007" { + t.Error("Test Failed - Exchange SetAPIKeys() did not set correct values") + } + + SetAPIKeys.AuthenticatedAPISupport = true + SetAPIKeys.AuthenticatedWebsocketAPISupport = false + SetAPIKeys.SetAPIKeys("RocketMan", "Digereedoo", "007", false) + if SetAPIKeys.APIKey != "RocketMan" && SetAPIKeys.APISecret != "Digereedoo" && SetAPIKeys.ClientID != "007" { + t.Error("Test Failed - Exchange SetAPIKeys() did not set correct values") + } + SetAPIKeys.SetAPIKeys("RocketMan", "Digereedoo", "007", true) } diff --git a/exchanges/exchange_websocket.go b/exchanges/exchange_websocket.go index 63ba1c7e..1edb9874 100644 --- a/exchanges/exchange_websocket.go +++ b/exchanges/exchange_websocket.go @@ -50,6 +50,7 @@ func (e *Base) WebsocketSetup(connector func() error, e.Websocket.SetConnector(connector) e.Websocket.SetWebsocketURL(runningURL) e.Websocket.SetExchangeName(exchangeName) + e.Websocket.SetCanUseAuthenticatedEndpoints(e.AuthenticatedWebsocketAPISupport) e.Websocket.init = false e.Websocket.noConnectionCheckLimit = 5 @@ -673,6 +674,21 @@ func (w *Websocket) FormatFunctionality() string { case WebsocketUnsubscribeSupported: functionality = append(functionality, WebsocketUnsubscribeSupportedText) + case WebsocketAuthenticatedEndpointsSupported: + functionality = append(functionality, WebsocketAuthenticatedEndpointsSupportedText) + + case WebsocketAccountDataSupported: + functionality = append(functionality, WebsocketAccountDataSupportedText) + + case WebsocketSubmitOrderSupported: + functionality = append(functionality, WebsocketSubmitOrderSupportedText) + + case WebsocketCancelOrderSupported: + functionality = append(functionality, WebsocketCancelOrderSupportedText) + + case WebsocketWithdrawSupported: + functionality = append(functionality, WebsocketWithdrawSupportedText) + default: functionality = append(functionality, fmt.Sprintf("%s[1<<%v]", UnknownWebsocketFunctionality, i)) @@ -838,7 +854,15 @@ func (w *Websocket) ResubscribeToChannel(subscribedChannel WebsocketChannelSubsc // SubscribeToChannels appends supplied channels to channelsToSubscribe func (w *Websocket) SubscribeToChannels(channels []WebsocketChannelSubscription) { for i := range channels { - w.channelsToSubscribe = append(w.channelsToSubscribe, channels[i]) + channelFound := false + for j := range w.channelsToSubscribe { + if w.channelsToSubscribe[j].Equal(&channels[i]) { + channelFound = true + } + } + if !channelFound { + w.channelsToSubscribe = append(w.channelsToSubscribe, channels[i]) + } } w.noConnectionChecks = 0 } @@ -855,3 +879,25 @@ func (w *WebsocketChannelSubscription) Equal(subscribedChannel *WebsocketChannel return strings.EqualFold(w.Channel, subscribedChannel.Channel) && strings.EqualFold(w.Currency.String(), subscribedChannel.Currency.String()) } + +// GetSubscriptions returns a copied list of subscriptions +// subscriptions is a private member and cannot be manipulated +func (w *Websocket) GetSubscriptions() []WebsocketChannelSubscription { + return append(w.subscribedChannels[:0:0], w.subscribedChannels...) +} + +// SetCanUseAuthenticatedEndpoints sets canUseAuthenticatedEndpoints val in +// a thread safe manner +func (w *Websocket) SetCanUseAuthenticatedEndpoints(val bool) { + w.subscriptionLock.Lock() + defer w.subscriptionLock.Unlock() + w.canUseAuthenticatedEndpoints = val +} + +// CanUseAuthenticatedEndpoints gets canUseAuthenticatedEndpoints val in +// a thread safe manner +func (w *Websocket) CanUseAuthenticatedEndpoints() bool { + w.subscriptionLock.Lock() + defer w.subscriptionLock.Unlock() + return w.canUseAuthenticatedEndpoints +} diff --git a/exchanges/exchange_websocket_test.go b/exchanges/exchange_websocket_test.go index 8210d5bd..e61e28e7 100644 --- a/exchanges/exchange_websocket_test.go +++ b/exchanges/exchange_websocket_test.go @@ -566,3 +566,17 @@ func TestSliceCopyDoesntImpactBoth(t *testing.T) { t.Errorf("Slice has not been copies appropriately") } } + +// TestSetCanUseAuthenticatedEndpoints logic test +func TestSetCanUseAuthenticatedEndpoints(t *testing.T) { + w := Websocket{} + result := w.CanUseAuthenticatedEndpoints() + if result { + t.Error("expected `canUseAuthenticatedEndpoints` to be false") + } + w.SetCanUseAuthenticatedEndpoints(true) + result = w.CanUseAuthenticatedEndpoints() + if !result { + t.Error("expected `canUseAuthenticatedEndpoints` to be true") + } +} diff --git a/exchanges/exchange_websocket_types.go b/exchanges/exchange_websocket_types.go index b488dfd8..c35265fe 100644 --- a/exchanges/exchange_websocket_types.go +++ b/exchanges/exchange_websocket_types.go @@ -19,17 +19,27 @@ const ( WebsocketAllowsRequests WebsocketSubscribeSupported WebsocketUnsubscribeSupported + WebsocketAuthenticatedEndpointsSupported + WebsocketAccountDataSupported + WebsocketSubmitOrderSupported + WebsocketCancelOrderSupported + WebsocketWithdrawSupported - WebsocketTickerSupportedText = "TICKER STREAMING SUPPORTED" - WebsocketOrderbookSupportedText = "ORDERBOOK STREAMING SUPPORTED" - WebsocketKlineSupportedText = "KLINE STREAMING SUPPORTED" - WebsocketTradeDataSupportedText = "TRADE STREAMING SUPPORTED" - WebsocketAccountSupportedText = "ACCOUNT STREAMING SUPPORTED" - WebsocketAllowsRequestsText = "WEBSOCKET REQUESTS SUPPORTED" - NoWebsocketSupportText = "WEBSOCKET NOT SUPPORTED" - UnknownWebsocketFunctionality = "UNKNOWN FUNCTIONALITY BITMASK" - WebsocketSubscribeSupportedText = "WEBSOCKET SUBSCRIBE SUPPORTED" - WebsocketUnsubscribeSupportedText = "WEBSOCKET UNSUBSCRIBE SUPPORTED" + WebsocketTickerSupportedText = "TICKER STREAMING SUPPORTED" + WebsocketOrderbookSupportedText = "ORDERBOOK STREAMING SUPPORTED" + WebsocketKlineSupportedText = "KLINE STREAMING SUPPORTED" + WebsocketTradeDataSupportedText = "TRADE STREAMING SUPPORTED" + WebsocketAccountSupportedText = "ACCOUNT STREAMING SUPPORTED" + WebsocketAllowsRequestsText = "WEBSOCKET REQUESTS SUPPORTED" + NoWebsocketSupportText = "WEBSOCKET NOT SUPPORTED" + UnknownWebsocketFunctionality = "UNKNOWN FUNCTIONALITY BITMASK" + WebsocketSubscribeSupportedText = "WEBSOCKET SUBSCRIBE SUPPORTED" + WebsocketUnsubscribeSupportedText = "WEBSOCKET UNSUBSCRIBE SUPPORTED" + WebsocketAuthenticatedEndpointsSupportedText = "WEBSOCKET AUTHENTICATED ENDPOINTS SUPPORTED" + WebsocketAccountDataSupportedText = "WEBSOCKET ACCOUNT DATA SUPPORTED" + WebsocketSubmitOrderSupportedText = "WEBSOCKET SUBMIT ORDER SUPPORTED" + WebsocketCancelOrderSupportedText = "WEBSOCKET CANCEL ORDER SUPPORTED" + WebsocketWithdrawSupportedText = "WEBSOCKET WITHDRAW SUPPORTED" // WebsocketNotEnabled alerts of a disabled websocket WebsocketNotEnabled = "exchange_websocket_not_enabled" @@ -88,7 +98,8 @@ type Websocket struct { // TrafficAlert monitors if there is a halt in traffic throughput TrafficAlert chan struct{} // Functionality defines websocket stream capabilities - Functionality uint32 + Functionality uint32 + canUseAuthenticatedEndpoints bool } // WebsocketChannelSubscription container for websocket subscriptions diff --git a/exchanges/exmo/exmo_wrapper.go b/exchanges/exmo/exmo_wrapper.go index a97e01ae..e75bb830 100644 --- a/exchanges/exmo/exmo_wrapper.go +++ b/exchanges/exmo/exmo_wrapper.go @@ -408,3 +408,13 @@ func (e *EXMO) SubscribeToWebsocketChannels(channels []exchange.WebsocketChannel func (e *EXMO) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (e *EXMO) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (e *EXMO) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/gateio/gateio.go b/exchanges/gateio/gateio.go index 7936bb1c..541b16d9 100644 --- a/exchanges/gateio/gateio.go +++ b/exchanges/gateio/gateio.go @@ -82,7 +82,8 @@ func (g *Gateio) SetDefaults() { exchange.WebsocketOrderbookSupported | exchange.WebsocketKlineSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported } // Setup sets user configuration @@ -92,6 +93,7 @@ func (g *Gateio) Setup(exch *config.ExchangeConfig) { } else { g.Enabled = true g.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + g.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport g.SetAPIKeys(exch.APIKey, exch.APISecret, "", false) g.APIAuthPEMKey = exch.APIAuthPEMKey g.SetHTTPClientTimeout(exch.HTTPTimeout) diff --git a/exchanges/gateio/gateio_test.go b/exchanges/gateio/gateio_test.go index efd770f6..c65be299 100644 --- a/exchanges/gateio/gateio_test.go +++ b/exchanges/gateio/gateio_test.go @@ -1,12 +1,16 @@ package gateio import ( + "net/http" "testing" + "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) // Please supply your own APIKEYS here for due diligence testing @@ -30,6 +34,7 @@ func TestSetup(t *testing.T) { if err != nil { t.Error("Test Failed - GateIO Setup() init error") } + gateioConfig.AuthenticatedWebsocketAPISupport = true gateioConfig.AuthenticatedAPISupport = true gateioConfig.APIKey = apiKey gateioConfig.APISecret = apiSecret @@ -490,3 +495,48 @@ func TestGetOrderInfo(t *testing.T) { } } } + +// TestWsAuth dials websocket, sends login request. +func TestWsAuth(t *testing.T) { + g.SetDefaults() + TestSetup(t) + if !g.Websocket.IsEnabled() && !g.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) + } + var err error + var dialer websocket.Dialer + g.WebsocketConn, _, err = dialer.Dial(g.Websocket.GetWebsocketURL(), + http.Header{}) + if err != nil { + t.Fatal(err) + } + g.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + g.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() + go g.WsHandleData() + defer g.WebsocketConn.Close() + err = g.wsServerSignIn() + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resultString := <-g.Websocket.DataHandler: + if !common.StringContains(resultString.(string), "success") { + t.Error("Authentication failed") + } + case <-timer.C: + t.Error("Expected response") + } + timer.Stop() + err = g.wsGetBalance() + if err != nil { + t.Error(err) + } + timer = time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case <-g.Websocket.DataHandler: + case <-timer.C: + t.Error("Expected response") + } + timer.Stop() +} diff --git a/exchanges/gateio/gateio_websocket.go b/exchanges/gateio/gateio_websocket.go index f973a4c2..91ea4fc0 100644 --- a/exchanges/gateio/gateio_websocket.go +++ b/exchanges/gateio/gateio_websocket.go @@ -46,22 +46,21 @@ func (g *Gateio) WsConnect() error { if err != nil { return err } - - if g.AuthenticatedAPISupport { - err = g.wsServerSignIn() - if err != nil { - log.Errorf("%v - wsServerSignin() failed: %v", g.GetName(), err) - } - time.Sleep(time.Second * 2) // sleep to allow server to complete sign-on if further authenticated requests are sent piror to this they will fail - } - go g.WsHandleData() - g.GenerateDefaultSubscriptions() + err = g.wsServerSignIn() + if err != nil { + log.Errorf("%v - authentication failed: %v", g.Name, err) + } + g.GenerateAuthenticatedSubscriptions() + g.GenerateDefaultSubscriptions() return nil } func (g *Gateio) wsServerSignIn() error { + if !g.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", g.Name) + } nonce := int(time.Now().Unix() * 1000) sigTemp := g.GenerateSignature(strconv.Itoa(nonce)) signature := common.Base64Encode(sigTemp) @@ -70,7 +69,13 @@ func (g *Gateio) wsServerSignIn() error { Method: "server.sign", Params: []interface{}{g.APIKey, signature, nonce}, } - return g.wsSend(signinWsRequest) + err := g.wsSend(signinWsRequest) + if err != nil { + g.Websocket.SetCanUseAuthenticatedEndpoints(false) + return err + } + time.Sleep(time.Second * 2) // sleep to allow server to complete sign-on if further authenticated requests are sent prior to this they will fail + return nil } // WsReadData reads from the websocket connection and returns the websocket @@ -114,20 +119,22 @@ func (g *Gateio) WsHandleData() { g.Websocket.DataHandler <- err continue } - if result.Error.Code != 0 { if common.StringContains(result.Error.Message, "authentication") { - g.Websocket.DataHandler <- fmt.Errorf("%v - WebSocket authentication failed ", - g.GetName()) - g.AuthenticatedAPISupport = false + g.Websocket.DataHandler <- fmt.Errorf("%v - authentication failed: %v", g.Name, err) + g.Websocket.SetCanUseAuthenticatedEndpoints(false) + continue } - g.Websocket.DataHandler <- fmt.Errorf("gateio_websocket.go error %s", - result.Error.Message) + g.Websocket.DataHandler <- fmt.Errorf("%v error %s", + g.Name, result.Error.Message) continue } switch result.ID { + case IDSignIn: + g.Websocket.SetCanUseAuthenticatedEndpoints(true) + g.Websocket.DataHandler <- string(result.Result) case IDBalance: var balance WebsocketBalance var balanceInterface interface{} @@ -339,14 +346,29 @@ func (g *Gateio) WsHandleData() { } } +// GenerateAuthenticatedSubscriptions Adds authenticated subscriptions to websocket to be handled by ManageSubscriptions() +func (g *Gateio) GenerateAuthenticatedSubscriptions() { + if !g.Websocket.CanUseAuthenticatedEndpoints() { + return + } + var channels = []string{"balance.subscribe", "order.subscribe"} + var subscriptions []exchange.WebsocketChannelSubscription + enabledCurrencies := g.GetEnabledCurrencies() + for i := range channels { + for j := range enabledCurrencies { + subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ + Channel: channels[i], + Currency: enabledCurrencies[j], + }) + } + } + g.Websocket.SubscribeToChannels(subscriptions) +} + // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (g *Gateio) GenerateDefaultSubscriptions() { var channels = []string{"ticker.subscribe", "trades.subscribe", "depth.subscribe", "kline.subscribe"} - if g.AuthenticatedAPISupport { - channels = append(channels, "balance.subscribe", "order.subscribe") - } - - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription enabledCurrencies := g.GetEnabledCurrencies() for i := range channels { for j := range enabledCurrencies { @@ -399,6 +421,9 @@ func (g *Gateio) Unsubscribe(channelToSubscribe exchange.WebsocketChannelSubscri } func (g *Gateio) wsGetBalance() error { + if !g.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authorised to get balance", g.Name) + } balanceWsRequest := WebsocketRequest{ ID: IDBalance, Method: "balance.query", @@ -408,6 +433,9 @@ func (g *Gateio) wsGetBalance() error { } func (g *Gateio) wsGetOrderInfo(market string, offset, limit int) error { + if !g.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authorised to get order info", g.Name) + } order := WebsocketRequest{ ID: IDOrderQuery, Method: "order.query", diff --git a/exchanges/gateio/gateio_wrapper.go b/exchanges/gateio/gateio_wrapper.go index f05d2c4e..266440da 100644 --- a/exchanges/gateio/gateio_wrapper.go +++ b/exchanges/gateio/gateio_wrapper.go @@ -459,3 +459,13 @@ func (g *Gateio) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketCha g.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (g *Gateio) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return g.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (g *Gateio) AuthenticateWebsocket() error { + return g.wsServerSignIn() +} diff --git a/exchanges/gemini/gemini.go b/exchanges/gemini/gemini.go index e2c61ccb..22235e93 100644 --- a/exchanges/gemini/gemini.go +++ b/exchanges/gemini/gemini.go @@ -123,7 +123,8 @@ func (g *Gemini) SetDefaults() { g.APIUrl = g.APIUrlDefault g.WebsocketInit() g.Websocket.Functionality = exchange.WebsocketOrderbookSupported | - exchange.WebsocketTradeDataSupported + exchange.WebsocketTradeDataSupported | + exchange.WebsocketAuthenticatedEndpointsSupported } // Setup sets exchange configuration parameters @@ -133,6 +134,7 @@ func (g *Gemini) Setup(exch *config.ExchangeConfig) { } else { g.Enabled = true g.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + g.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport g.SetAPIKeys(exch.APIKey, exch.APISecret, "", false) g.SetHTTPClientTimeout(exch.HTTPTimeout) g.SetHTTPClientUserAgent(exch.HTTPUserAgent) @@ -142,7 +144,7 @@ func (g *Gemini) Setup(exch *config.ExchangeConfig) { g.BaseCurrencies = exch.BaseCurrencies g.AvailablePairs = exch.AvailablePairs g.EnabledPairs = exch.EnabledPairs - + g.WebsocketURL = geminiWebsocketEndpoint err := g.SetCurrencyPairFormat() if err != nil { log.Fatal(err) @@ -161,6 +163,7 @@ func (g *Gemini) Setup(exch *config.ExchangeConfig) { } if exch.UseSandbox { g.APIUrl = geminiSandboxAPIURL + g.WebsocketURL = geminiWebsocketSandboxEndpoint } err = g.SetClientProxyAddress(exch.ProxyAddress) if err != nil { @@ -172,8 +175,8 @@ func (g *Gemini) Setup(exch *config.ExchangeConfig) { exch.Name, exch.Websocket, exch.Verbose, - geminiWebsocketEndpoint, - exch.WebsocketURL) + g.WebsocketURL, + g.WebsocketURL) if err != nil { log.Fatal(err) } diff --git a/exchanges/gemini/gemini_test.go b/exchanges/gemini/gemini_test.go index 1620e60e..66859b74 100644 --- a/exchanges/gemini/gemini_test.go +++ b/exchanges/gemini/gemini_test.go @@ -3,11 +3,14 @@ package gemini import ( "net/url" "testing" + "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) // Please enter sandbox API keys & assigned roles for better testing procedures @@ -61,8 +64,9 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - Gemini Setup() init error") } + geminiConfig.AuthenticatedWebsocketAPISupport = true geminiConfig.AuthenticatedAPISupport = true - + geminiConfig.Websocket = true Session[1].Setup(&geminiConfig) Session[2].Setup(&geminiConfig) @@ -554,3 +558,32 @@ func TestGetDepositAddress(t *testing.T) { t.Error("Test Failed - GetDepositAddress error cannot be nil") } } + +// TestWsAuth dials websocket, sends login request. +func TestWsAuth(t *testing.T) { + TestAddSession(t) + TestSetDefaults(t) + TestSetup(t) + g := Session[1] + g.WebsocketURL = geminiWebsocketSandboxEndpoint + + if !g.Websocket.IsEnabled() && !g.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) + } + var dialer websocket.Dialer + go g.WsHandleData() + err := g.WsSecureSubscribe(&dialer, geminiWsOrderEvents) + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-g.Websocket.DataHandler: + if resp.(WsSubscriptionAcknowledgementResponse).Type != "subscription_ack" { + t.Error("Login failed") + } + case <-timer.C: + t.Error("Expected response") + } + timer.Stop() +} diff --git a/exchanges/gemini/gemini_types.go b/exchanges/gemini/gemini_types.go index 71d1313e..1fe1c012 100644 --- a/exchanges/gemini/gemini_types.go +++ b/exchanges/gemini/gemini_types.go @@ -195,8 +195,13 @@ type ErrorCapture struct { Message string `json:"message"` } -// Response defines the main response type -type Response struct { +// WsResponse generic response +type WsResponse struct { + Type string `json:"type"` +} + +// WsMarketUpdateResponse defines the main response type +type WsMarketUpdateResponse struct { Type string `json:"type"` EventID int64 `json:"eventId"` Timestamp int64 `json:"timestamp"` @@ -221,5 +226,192 @@ type Event struct { type ReadData struct { Raw []byte Currency currency.Pair - FeedType string +} + +// WsRequestPayload Request info to subscribe to a WS enpoint +type WsRequestPayload struct { + Request string `json:"request"` + Nonce int64 `json:"nonce"` +} + +// WsSubscriptionAcknowledgementResponse The first message you receive acknowledges your subscription +type WsSubscriptionAcknowledgementResponse struct { + Type string `json:"type"` + AccountID int64 `json:"accountId"` + SubscriptionID string `json:"subscriptionId"` + SymbolFilter []string `json:"symbolFilter"` + APISessionFilter []string `json:"apiSessionFilter"` + EventTypeFilter []string `json:"eventTypeFilter"` +} + +// WsHeartbeatResponse Gemini will send a heartbeat every five seconds so you'll know your WebSocket connection is active. +type WsHeartbeatResponse struct { + Type string `json:"type"` + Timestampms int64 `json:"timestampms"` + Sequence int64 `json:"sequence"` + TraceID string `json:"trace_id"` + SocketSequence int64 `json:"socket_sequence"` +} + +// WsActiveOrdersResponse contains active orders +type WsActiveOrdersResponse struct { + Type string `json:"type"` + OrderID string `json:"order_id"` + APISession string `json:"api_session"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + OrderType string `json:"order_type"` + Timestamp string `json:"timestamp"` + Timestampms int64 `json:"timestampms"` + IsLive bool `json:"is_live"` + IsCancelled bool `json:"is_cancelled"` + IsHidden bool `json:"is_hidden"` + AvgExecutionPrice float64 `json:"avg_execution_price,string"` + ExecutedAmount float64 `json:"executed_amount,string"` + RemainingAmount float64 `json:"remaining_amount,string"` + OriginalAmount float64 `json:"original_amount,string"` + Price float64 `json:"price,string"` + SocketSequence int64 `json:"socket_sequence"` +} + +// WsOrderRejectedResponse ws response +type WsOrderRejectedResponse struct { + Type string `json:"type"` + OrderID string `json:"order_id"` + EventID string `json:"event_id"` + Reason string `json:"reason"` + APISession string `json:"api_session"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + OrderType string `json:"order_type"` + Timestamp string `json:"timestamp"` + Timestampms int64 `json:"timestampms"` + IsLive bool `json:"is_live"` + OriginalAmount float64 `json:"original_amount,string"` + Price float64 `json:"price,string"` + SocketSequence int64 `json:"socket_sequence"` +} + +// WsOrderBookedResponse ws response +type WsOrderBookedResponse struct { + Type string `json:"type"` + OrderID string `json:"order_id"` + EventID string `json:"event_id"` + APISession string `json:"api_session"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + OrderType string `json:"order_type"` + Timestamp string `json:"timestamp"` + Timestampms int64 `json:"timestampms"` + IsLive bool `json:"is_live"` + IsCancelled bool `json:"is_cancelled"` + IsHidden bool `json:"is_hidden"` + AvgExecutionPrice float64 `json:"avg_execution_price,string"` + ExecutedAmount float64 `json:"executed_amount,string"` + RemainingAmount float64 `json:"remaining_amount,string"` + OriginalAmount float64 `json:"original_amount,string"` + Price float64 `json:"price,string"` + SocketSequence int64 `json:"socket_sequence"` +} + +// WsOrderFilledResponse ws response +type WsOrderFilledResponse struct { + Type string `json:"type"` + OrderID string `json:"order_id"` + APISession string `json:"api_session"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + OrderType string `json:"order_type"` + Timestamp string `json:"timestamp"` + Timestampms int64 `json:"timestampms"` + IsLive bool `json:"is_live"` + IsCancelled bool `json:"is_cancelled"` + IsHidden bool `json:"is_hidden"` + AvgExecutionPrice float64 `json:"avg_execution_price,string"` + ExecutedAmount float64 `json:"executed_amount,string"` + RemainingAmount float64 `json:"remaining_amount,string"` + OriginalAmount float64 `json:"original_amount,string"` + Price float64 `json:"price,string"` + Fill WsOrderFilledData `json:"fill"` + SocketSequence int64 `json:"socket_sequence"` +} + +// WsOrderFilledData ws response data +type WsOrderFilledData struct { + TradeID string `json:"trade_id"` + Liquidity string `json:"liquidity"` + Price float64 `json:"price,string"` + Amount float64 `json:"amount,string"` + Fee float64 `json:"fee,string"` + FeeCurrency string `json:"fee_currency"` +} + +// WsOrderCancelledResponse ws response +type WsOrderCancelledResponse struct { + Type string `json:"type"` + OrderID string `json:"order_id"` + EventID string `json:"event_id"` + CancelCommandID string `json:"cancel_command_id,omitempty"` + Reason string `json:"reason"` + APISession string `json:"api_session"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + OrderType string `json:"order_type"` + Timestamp string `json:"timestamp"` + Timestampms int64 `json:"timestampms"` + IsLive bool `json:"is_live"` + IsCancelled bool `json:"is_cancelled"` + IsHidden bool `json:"is_hidden"` + AvgExecutionPrice float64 `json:"avg_execution_price,string"` + ExecutedAmount float64 `json:"executed_amount,string"` + RemainingAmount float64 `json:"remaining_amount,string"` + OriginalAmount float64 `json:"original_amount,string"` + Price float64 `json:"price,string"` + SocketSequence int64 `json:"socket_sequence"` +} + +// WsOrderCancellationRejectedResponse ws response +type WsOrderCancellationRejectedResponse struct { + Type string `json:"type"` + OrderID string `json:"order_id"` + EventID string `json:"event_id"` + CancelCommandID string `json:"cancel_command_id"` + Reason string `json:"reason"` + APISession string `json:"api_session"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + OrderType string `json:"order_type"` + Timestamp string `json:"timestamp"` + Timestampms int64 `json:"timestampms"` + IsLive bool `json:"is_live"` + IsCancelled bool `json:"is_cancelled"` + IsHidden bool `json:"is_hidden"` + AvgExecutionPrice float64 `json:"avg_execution_price,string"` + ExecutedAmount float64 `json:"executed_amount,string"` + RemainingAmount float64 `json:"remaining_amount,string"` + OriginalAmount float64 `json:"original_amount,string"` + Price float64 `json:"price,string"` + SocketSequence int64 `json:"socket_sequence"` +} + +// WsOrderClosedResponse ws response +type WsOrderClosedResponse struct { + Type string `json:"type"` + OrderID string `json:"order_id"` + EventID string `json:"event_id"` + APISession string `json:"api_session"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + OrderType string `json:"order_type"` + Timestamp string `json:"timestamp"` + Timestampms int64 `json:"timestampms"` + IsLive bool `json:"is_live"` + IsCancelled bool `json:"is_cancelled"` + IsHidden bool `json:"is_hidden"` + AvgExecutionPrice float64 `json:"avg_execution_price,string"` + ExecutedAmount float64 `json:"executed_amount,string"` + RemainingAmount float64 `json:"remaining_amount,string"` + OriginalAmount float64 `json:"original_amount,string"` + Price float64 `json:"price,string"` + SocketSequence int64 `json:"socket_sequence"` } diff --git a/exchanges/gemini/gemini_websocket.go b/exchanges/gemini/gemini_websocket.go index 79727206..40ad7a5f 100644 --- a/exchanges/gemini/gemini_websocket.go +++ b/exchanges/gemini/gemini_websocket.go @@ -14,12 +14,15 @@ import ( "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/orderbook" + log "github.com/thrasher-/gocryptotrader/logger" ) const ( - geminiWebsocketEndpoint = "wss://api.gemini.com/v1/marketdata/%s?%s" - geminiWsEvent = "event" - geminiWsMarketData = "marketdata" + geminiWebsocketEndpoint = "wss://api.gemini.com/v1/" + geminiWebsocketSandboxEndpoint = "wss://api.sandbox.gemini.com/v1/" + geminiWsEvent = "event" + geminiWsMarketData = "marketdata" + geminiWsOrderEvents = "order/events" ) // Instantiates a communications channel between websocket connections @@ -37,12 +40,14 @@ func (g *Gemini) WsConnect() error { if err != nil { return err } - dialer.Proxy = http.ProxyURL(proxy) } go g.WsHandleData() - + err := g.WsSecureSubscribe(&dialer, geminiWsOrderEvents) + if err != nil { + log.Errorf("%v - authentication failed: %v", g.Name, err) + } return g.WsSubscribe(&dialer) } @@ -52,59 +57,75 @@ func (g *Gemini) WsSubscribe(dialer *websocket.Dialer) error { for i, c := range enabledCurrencies { val := url.Values{} val.Set("heartbeat", "true") - - endpoint := fmt.Sprintf(g.Websocket.GetWebsocketURL(), + endpoint := fmt.Sprintf("%s%s/%s?%s", + g.WebsocketURL, + geminiWsMarketData, c.String(), val.Encode()) - - conn, _, err := dialer.Dial(endpoint, http.Header{}) + conn, conStatus, err := dialer.Dial(endpoint, http.Header{}) if err != nil { - return err + return fmt.Errorf("%v %v %v Error: %v", endpoint, conStatus, conStatus.StatusCode, err) } - - go g.WsReadData(conn, c, geminiWsMarketData) - + go g.WsReadData(conn, c) if len(enabledCurrencies)-1 == i { return nil } - - time.Sleep(5 * time.Second) // rate limiter, limit of 12 requests per - // minute } return nil } +// WsSecureSubscribe will connect to Gemini's secure endpoint +func (g *Gemini) WsSecureSubscribe(dialer *websocket.Dialer, url string) error { + if !g.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", g.Name) + } + payload := WsRequestPayload{ + Request: fmt.Sprintf("/v1/%v", url), + Nonce: time.Now().UnixNano(), + } + PayloadJSON, err := common.JSONEncode(payload) + if err != nil { + return fmt.Errorf("%v sendAuthenticatedHTTPRequest: Unable to JSON request", g.Name) + } + + endpoint := fmt.Sprintf("%v%v", g.WebsocketURL, url) + PayloadBase64 := common.Base64Encode(PayloadJSON) + hmac := common.GetHMAC(common.HashSHA512_384, []byte(PayloadBase64), []byte(g.APISecret)) + headers := http.Header{} + headers.Add("Content-Length", "0") + headers.Add("Content-Type", "text/plain") + headers.Add("X-GEMINI-PAYLOAD", PayloadBase64) + headers.Add("X-GEMINI-APIKEY", g.APIKey) + headers.Add("X-GEMINI-SIGNATURE", common.HexEncodeToString(hmac)) + headers.Add("Cache-Control", "no-cache") + + conn, conStatus, err := dialer.Dial(endpoint, headers) + if err != nil { + return fmt.Errorf("%v %v %v Error: %v", endpoint, conStatus, conStatus.StatusCode, err) + } + go g.WsReadData(conn, currency.Pair{}) + return nil +} + // WsReadData reads from the websocket connection and returns the websocket // response -func (g *Gemini) WsReadData(ws *websocket.Conn, c currency.Pair, feedType string) { +func (g *Gemini) WsReadData(ws *websocket.Conn, c currency.Pair) { g.Websocket.Wg.Add(1) - - defer func() { - err := ws.Close() - if err != nil { - g.Websocket.DataHandler <- fmt.Errorf("gemini_websocket.go - Unable to to close Websocket connection. Error: %s", - err) - } - g.Websocket.Wg.Done() - }() - + defer g.Websocket.Wg.Done() for { select { case <-g.Websocket.ShutdownC: return - default: _, resp, err := ws.ReadMessage() if err != nil { g.Websocket.DataHandler <- err return } - g.Websocket.TrafficAlert <- struct{}{} - comms <- ReadData{Raw: resp, Currency: c, FeedType: feedType} + comms <- ReadData{Raw: resp, Currency: c} } } - } // WsHandleData handles all the websocket data coming from the websocket @@ -112,119 +133,191 @@ func (g *Gemini) WsReadData(ws *websocket.Conn, c currency.Pair, feedType string func (g *Gemini) WsHandleData() { g.Websocket.Wg.Add(1) defer g.Websocket.Wg.Done() - for { select { case <-g.Websocket.ShutdownC: return - case resp := <-comms: - switch resp.FeedType { - case geminiWsEvent: - - case geminiWsMarketData: - var result Response + // Gemini likes to send empty arrays + if string(resp.Raw) == "[]" { + continue + } + var result map[string]interface{} + err := common.JSONDecode(resp.Raw, &result) + if err != nil { + g.Websocket.DataHandler <- fmt.Errorf("%v Error: %v, Raw: %v", g.Name, err, string(resp.Raw)) + continue + } + switch result["type"] { + case "subscription_ack": + var result WsSubscriptionAcknowledgementResponse err := common.JSONDecode(resp.Raw, &result) if err != nil { g.Websocket.DataHandler <- err continue } - - switch result.Type { - case "update": - if result.Timestamp == 0 && result.TimestampMS == 0 { - var bids, asks []orderbook.Item - for _, event := range result.Events { - if event.Reason != "initial" { - g.Websocket.DataHandler <- errors.New("gemini_websocket.go orderbook should be snapshot only") - continue - } - - if event.Side == "ask" { - asks = append(asks, orderbook.Item{ - Amount: event.Remaining, - Price: event.Price, - }) - } else { - bids = append(bids, orderbook.Item{ - Amount: event.Remaining, - Price: event.Price, - }) - } - } - - var newOrderBook orderbook.Base - newOrderBook.Asks = asks - newOrderBook.Bids = bids - newOrderBook.AssetType = "SPOT" - newOrderBook.Pair = resp.Currency - - err := g.Websocket.Orderbook.LoadSnapshot(&newOrderBook, - g.GetName(), - false) - if err != nil { - g.Websocket.DataHandler <- err - break - } - - g.Websocket.DataHandler <- exchange.WebsocketOrderbookUpdate{Pair: resp.Currency, - Asset: "SPOT", - Exchange: g.GetName()} - - } else { - for _, event := range result.Events { - if event.Type == "trade" { - g.Websocket.DataHandler <- exchange.TradeData{ - Timestamp: time.Now(), - CurrencyPair: resp.Currency, - AssetType: "SPOT", - Exchange: g.GetName(), - EventTime: result.Timestamp, - Price: event.Price, - Amount: event.Amount, - Side: event.MakerSide, - } - - } else { - var i orderbook.Item - i.Amount = event.Remaining - i.Price = event.Price - if event.Side == "ask" { - err := g.Websocket.Orderbook.Update(nil, - []orderbook.Item{i}, - resp.Currency, - time.Now(), - g.GetName(), - "SPOT") - if err != nil { - g.Websocket.DataHandler <- err - } - } else { - err := g.Websocket.Orderbook.Update([]orderbook.Item{i}, - nil, - resp.Currency, - time.Now(), - g.GetName(), - "SPOT") - if err != nil { - g.Websocket.DataHandler <- err - } - } - } - } - - g.Websocket.DataHandler <- exchange.WebsocketOrderbookUpdate{Pair: resp.Currency, - Asset: "SPOT", - Exchange: g.GetName()} - } - - case "heartbeat": - - default: - g.Websocket.DataHandler <- fmt.Errorf("gemini_websocket.go - unhandled data %s", - resp.Raw) + g.Websocket.DataHandler <- result + case "initial": + var result WsSubscriptionAcknowledgementResponse + err := common.JSONDecode(resp.Raw, &result) + if err != nil { + g.Websocket.DataHandler <- err + continue } + g.Websocket.DataHandler <- result + case "accepted": + var result WsActiveOrdersResponse + err := common.JSONDecode(resp.Raw, &result) + if err != nil { + g.Websocket.DataHandler <- err + continue + } + g.Websocket.DataHandler <- result + case "booked": + var result WsOrderBookedResponse + err := common.JSONDecode(resp.Raw, &result) + if err != nil { + g.Websocket.DataHandler <- err + continue + } + g.Websocket.DataHandler <- result + case "fill": + var result WsOrderFilledResponse + err := common.JSONDecode(resp.Raw, &result) + if err != nil { + g.Websocket.DataHandler <- err + continue + } + g.Websocket.DataHandler <- result + case "cancelled": + var result WsOrderCancelledResponse + err := common.JSONDecode(resp.Raw, &result) + if err != nil { + g.Websocket.DataHandler <- err + continue + } + g.Websocket.DataHandler <- result + case "closed": + var result WsOrderClosedResponse + err := common.JSONDecode(resp.Raw, &result) + if err != nil { + g.Websocket.DataHandler <- err + continue + } + g.Websocket.DataHandler <- result + case "heartbeat": + var result WsHeartbeatResponse + err := common.JSONDecode(resp.Raw, &result) + if err != nil { + g.Websocket.DataHandler <- err + continue + } + g.Websocket.DataHandler <- result + case "update": + if resp.Currency.IsEmpty() { + g.Websocket.DataHandler <- fmt.Errorf("%v - unhandled data %s", + g.Name, resp.Raw) + continue + } + var marketUpdate WsMarketUpdateResponse + err := common.JSONDecode(resp.Raw, &marketUpdate) + if err != nil { + g.Websocket.DataHandler <- err + continue + } + g.wsProcessUpdate(marketUpdate, resp.Currency) + default: + g.Websocket.DataHandler <- fmt.Errorf("%v - unhandled data %s", + g.Name, resp.Raw) } } } } + +// wsProcessUpdate handles order book data +func (g *Gemini) wsProcessUpdate(result WsMarketUpdateResponse, pair currency.Pair) { + if result.Timestamp == 0 && result.TimestampMS == 0 { + var bids, asks []orderbook.Item + for _, event := range result.Events { + if event.Reason != "initial" { + g.Websocket.DataHandler <- errors.New("gemini_websocket.go orderbook should be snapshot only") + continue + } + + if event.Side == "ask" { + asks = append(asks, orderbook.Item{ + Amount: event.Remaining, + Price: event.Price, + }) + } else { + bids = append(bids, orderbook.Item{ + Amount: event.Remaining, + Price: event.Price, + }) + } + } + + var newOrderBook orderbook.Base + newOrderBook.Asks = asks + newOrderBook.Bids = bids + newOrderBook.AssetType = "SPOT" + newOrderBook.Pair = pair + + err := g.Websocket.Orderbook.LoadSnapshot(&newOrderBook, + g.GetName(), + false) + if err != nil { + g.Websocket.DataHandler <- err + return + } + + g.Websocket.DataHandler <- exchange.WebsocketOrderbookUpdate{Pair: pair, + Asset: "SPOT", + Exchange: g.GetName()} + } else { + for _, event := range result.Events { + if event.Type == "trade" { + g.Websocket.DataHandler <- exchange.TradeData{ + Timestamp: time.Now(), + CurrencyPair: pair, + AssetType: "SPOT", + Exchange: g.Name, + EventTime: result.Timestamp, + Price: event.Price, + Amount: event.Amount, + Side: event.MakerSide, + } + + } else { + var i orderbook.Item + i.Amount = event.Remaining + i.Price = event.Price + if event.Side == "ask" { + err := g.Websocket.Orderbook.Update(nil, + []orderbook.Item{i}, + pair, + time.Now(), + g.GetName(), + "SPOT") + if err != nil { + g.Websocket.DataHandler <- err + } + } else { + err := g.Websocket.Orderbook.Update([]orderbook.Item{i}, + nil, + pair, + time.Now(), + g.GetName(), + "SPOT") + if err != nil { + g.Websocket.DataHandler <- err + } + } + } + } + + g.Websocket.DataHandler <- exchange.WebsocketOrderbookUpdate{Pair: pair, + Asset: "SPOT", + Exchange: g.GetName()} + } +} diff --git a/exchanges/gemini/gemini_wrapper.go b/exchanges/gemini/gemini_wrapper.go index 44bd055b..c56f8136 100644 --- a/exchanges/gemini/gemini_wrapper.go +++ b/exchanges/gemini/gemini_wrapper.go @@ -369,3 +369,13 @@ func (g *Gemini) SubscribeToWebsocketChannels(channels []exchange.WebsocketChann func (g *Gemini) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (g *Gemini) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (g *Gemini) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/hitbtc/hitbtc.go b/exchanges/hitbtc/hitbtc.go index 63f439f2..6eaeb53e 100644 --- a/exchanges/hitbtc/hitbtc.go +++ b/exchanges/hitbtc/hitbtc.go @@ -84,7 +84,10 @@ func (h *HitBTC) SetDefaults() { h.Websocket.Functionality = exchange.WebsocketTickerSupported | exchange.WebsocketOrderbookSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported | + exchange.WebsocketSubmitOrderSupported | + exchange.WebsocketCancelOrderSupported } // Setup sets user exchange configuration settings @@ -94,6 +97,7 @@ func (h *HitBTC) Setup(exch *config.ExchangeConfig) { } else { h.Enabled = true h.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + h.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport h.SetAPIKeys(exch.APIKey, exch.APISecret, "", false) h.SetHTTPClientTimeout(exch.HTTPTimeout) h.SetHTTPClientUserAgent(exch.HTTPUserAgent) diff --git a/exchanges/hitbtc/hitbtc_test.go b/exchanges/hitbtc/hitbtc_test.go index 52b0082b..9a6624eb 100644 --- a/exchanges/hitbtc/hitbtc_test.go +++ b/exchanges/hitbtc/hitbtc_test.go @@ -1,12 +1,16 @@ package hitbtc import ( + "net/http" "testing" + "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) var h HitBTC @@ -29,6 +33,7 @@ func TestSetup(t *testing.T) { if err != nil { t.Error("Test Failed - HitBTC Setup() init error") } + hitbtcConfig.AuthenticatedWebsocketAPISupport = true hitbtcConfig.AuthenticatedAPISupport = true hitbtcConfig.APIKey = apiKey hitbtcConfig.APISecret = apiSecret @@ -99,7 +104,7 @@ func TestGetFee(t *testing.T) { // CryptocurrencyTradeFee Basic if resp, err := h.GetFee(feeBuilder); resp != float64(0.001) || err != nil { t.Error(err) - t.Errorf("Test Failed - GetFee() error. Expected: %f, Received: %f", float64(0.001), resp) + t.Errorf("Test Failed - GetFee() error. Expected: %f, Received: %f", float64(0.002), resp) } // CryptocurrencyTradeFee High quantity @@ -107,7 +112,7 @@ func TestGetFee(t *testing.T) { feeBuilder.Amount = 1000 feeBuilder.PurchasePrice = 1000 if resp, err := h.GetFee(feeBuilder); resp != float64(1000) || err != nil { - t.Errorf("Test Failed - GetFee() error. Expected: %f, Received: %f", float64(1000), resp) + t.Errorf("Test Failed - GetFee() error. Expected: %f, Received: %f", float64(2000), resp) t.Error(err) } @@ -115,7 +120,7 @@ func TestGetFee(t *testing.T) { feeBuilder = setFeeBuilder() feeBuilder.IsMaker = true if resp, err := h.GetFee(feeBuilder); resp != float64(-0.0001) || err != nil { - t.Errorf("Test Failed - GetFee() error. Expected: %f, Received: %f", float64(-0.0001), resp) + t.Errorf("Test Failed - GetFee() error. Expected: %f, Received: %f", float64(0.001), resp) t.Error(err) } @@ -123,7 +128,7 @@ func TestGetFee(t *testing.T) { feeBuilder = setFeeBuilder() feeBuilder.PurchasePrice = -1000 if resp, err := h.GetFee(feeBuilder); resp != float64(-1) || err != nil { - t.Errorf("Test Failed - GetFee() error. Expected: %f, Received: %f", float64(-1), resp) + t.Errorf("Test Failed - GetFee() error. Expected: %f, Received: %f", float64(0), resp) t.Error(err) } @@ -131,7 +136,7 @@ func TestGetFee(t *testing.T) { feeBuilder = setFeeBuilder() feeBuilder.FeeType = exchange.CryptocurrencyWithdrawalFee if resp, err := h.GetFee(feeBuilder); resp != float64(0.009580) || err != nil { - t.Errorf("Test Failed - GetFee() error. Expected: %f, Received: %f", float64(0.009580), resp) + t.Errorf("Test Failed - GetFee() error. Expected: %f, Received: %f", float64(0.042800), resp) t.Error(err) } @@ -381,3 +386,107 @@ func TestGetDepositAddress(t *testing.T) { } } } +func setupWsAuth(t *testing.T) { + TestSetDefaults(t) + TestSetup(t) + if !h.Websocket.IsEnabled() && !h.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) + } + var err error + var dialer websocket.Dialer + h.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + h.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() + h.WebsocketConn, _, err = dialer.Dial(hitbtcWebsocketAddress, http.Header{}) + if err != nil { + t.Fatal(err) + } + go h.WsHandleData() + h.wsLogin() + timer := time.NewTimer(time.Second) + select { + case loginError := <-h.Websocket.DataHandler: + t.Fatal(loginError) + case <-timer.C: + } + timer.Stop() +} + +// TestWsCancelOrder dials websocket, sends cancel request. +func TestWsCancelOrder(t *testing.T) { + setupWsAuth(t) + err := h.wsCancelOrder("ImNotARealOrderID") + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case <-h.Websocket.DataHandler: + case <-timer.C: + t.Error("Expecting response") + } + timer.Stop() +} + +// TestWsPlaceOrder dials websocket, sends order submission. +func TestWsPlaceOrder(t *testing.T) { + setupWsAuth(t) + err := h.wsPlaceOrder(currency.NewPair(currency.LTC, currency.BTC), exchange.BuyOrderSide.ToString(), 1, 1) + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case <-h.Websocket.DataHandler: + case <-timer.C: + t.Error("Expecting response") + } + timer.Stop() +} + +// TestWsReplaceOrder dials websocket, sends replace order request. +func TestWsReplaceOrder(t *testing.T) { + setupWsAuth(t) + err := h.wsReplaceOrder("ImNotARealOrderID", 1, 1) + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case <-h.Websocket.DataHandler: + case <-timer.C: + t.Error("Expecting response") + } + timer.Stop() +} + +// TestWsGetActiveOrders dials websocket, sends get active orders request. +func TestWsGetActiveOrders(t *testing.T) { + setupWsAuth(t) + err := h.wsGetActiveOrders() + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case <-h.Websocket.DataHandler: + case <-timer.C: + t.Error("Expecting response") + } + timer.Stop() +} + +// TestWsGetTradingBalance dials websocket, sends get trading balance request. +func TestWsGetTradingBalance(t *testing.T) { + setupWsAuth(t) + err := h.wsGetTradingBalance() + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case <-h.Websocket.DataHandler: + case <-timer.C: + t.Error("Expecting response") + } + timer.Stop() +} diff --git a/exchanges/hitbtc/hitbtc_types.go b/exchanges/hitbtc/hitbtc_types.go index ecc9ef30..61ba6ff5 100644 --- a/exchanges/hitbtc/hitbtc_types.go +++ b/exchanges/hitbtc/hitbtc_types.go @@ -1,6 +1,10 @@ package hitbtc -import "time" +import ( + "time" + + "github.com/thrasher-/gocryptotrader/currency" +) // Ticker holds ticker information type Ticker struct { @@ -186,19 +190,19 @@ type AuthenticatedTradeHistoryResponse struct { // OrderHistoryResponse used for GetOrderHistory type OrderHistoryResponse struct { - ID string `json:"id"` - ClientOrderID string `json:"clientOrderId"` - Symbol string `json:"symbol"` - Side string `json:"side"` - Status string `json:"status"` - Type string `json:"type"` - TimeInForce string `json:"timeInForce"` - Price float64 `json:"price,string"` - Quantity float64 `json:"quantity,string"` - PostOnly bool `json:"postOnly"` - CumQuantity float64 `json:"cumQuantity,string"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID string `json:"id"` + ClientOrderID string `json:"clientOrderId"` + Symbol string `json:"symbol"` + Side string `json:"side"` + Status string `json:"status"` + Type string `json:"type"` + TimeInForce string `json:"timeInForce"` + Price float64 `json:"price,string"` + Quantity float64 `json:"quantity,string"` + PostOnly bool `json:"postOnly"` + CumQuantity float64 `json:"cumQuantity,string"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } // ResultingTrades holds resulting trade information @@ -295,12 +299,13 @@ type LendingHistory struct { } type capture struct { - Method string `json:"method"` - Result bool `json:"result"` + Method string `json:"method,omitempty"` + Result interface{} `json:"result"` Error struct { Code int `json:"code"` Message string `json:"message"` } `json:"error"` + ID int64 `json:"id,omitempty"` } // WsRequest defines a request obj for the JSON-RPC and gets a websocket @@ -314,13 +319,13 @@ type WsRequest struct { // WsNotification defines a notification obj for the JSON-RPC this does not get // a websocket response type WsNotification struct { - JSONRPCVersion string `json:"jsonrpc"` + JSONRPCVersion string `json:"jsonrpc,omitempty"` Method string `json:"method"` Params interface{} `json:"params"` } type params struct { - Symbol string `json:"symbol"` + Symbol string `json:"symbol,omitempty"` Period string `json:"period,omitempty"` Limit int64 `json:"limit,omitempty"` } @@ -370,3 +375,234 @@ type WsTrade struct { Symbol string `json:"symbol"` } `json:"params"` } + +// WsLoginRequest defines login requirements for ws +type WsLoginRequest struct { + Method string `json:"method"` + Params WsLoginData `json:"params"` +} + +// WsLoginData sets credentials for WsLoginRequest +type WsLoginData struct { + Algo string `json:"algo"` + PKey string `json:"pKey"` + Nonce string `json:"nonce"` + Signature string `json:"signature"` +} + +// WsActiveOrdersResponse Active order response for auth subscription to reports +type WsActiveOrdersResponse struct { + Params []WsActiveOrdersResponseData `json:"params"` +} + +// WsActiveOrdersResponseData Active order data for WsActiveOrdersResponse +type WsActiveOrdersResponseData struct { + ID string `json:"id"` + ClientOrderID string `json:"clientOrderId"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + Status string `json:"status"` + Type string `json:"type"` + TimeInForce string `json:"timeInForce"` + Quantity float64 `json:"quantity,string"` + Price float64 `json:"price,string"` + CumQuantity float64 `json:"cumQuantity,string"` + PostOnly bool `json:"postOnly"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ReportType string `json:"reportType"` +} + +// WsReportResponse report response for auth subscription to reports +type WsReportResponse struct { + Params WsReportResponseData `json:"params"` +} + +// WsReportResponseData Report data for WsReportResponse +type WsReportResponseData struct { + ID string `json:"id"` + ClientOrderID string `json:"clientOrderId"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + Status string `json:"status"` + Type string `json:"type"` + TimeInForce string `json:"timeInForce"` + Quantity float64 `json:"quantity,string"` + Price float64 `json:"price,string"` + CumQuantity float64 `json:"cumQuantity,string"` + PostOnly bool `json:"postOnly"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ReportType string `json:"reportType"` + TradeQuantity float64 `json:"tradeQuantity,string"` + TradePrice float64 `json:"tradePrice,string"` + TradeID int64 `json:"tradeId"` + TradeFee float64 `json:"tradeFee,string"` +} + +// WsSubmitOrderRequest WS request +type WsSubmitOrderRequest struct { + Method string `json:"method"` + Params WsSubmitOrderRequestData `json:"params"` + ID int64 `json:"id"` +} + +// WsSubmitOrderRequestData WS request data +type WsSubmitOrderRequestData struct { + ClientOrderID string `json:"clientOrderId"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + Price float64 `json:"price,string"` + Quantity float64 `json:"quantity,string"` +} + +// WsSubmitOrderSuccessResponse WS response +type WsSubmitOrderSuccessResponse struct { + Result WsSubmitOrderSuccessResponseData `json:"result"` + ID int64 `json:"id"` +} + +// WsSubmitOrderSuccessResponseData WS response data +type WsSubmitOrderSuccessResponseData struct { + ID string `json:"id"` + ClientOrderID string `json:"clientOrderId"` + Symbol string `json:"symbol"` + Side string `json:"side"` + Status string `json:"status"` + Type string `json:"type"` + TimeInForce string `json:"timeInForce"` + Quantity float64 `json:"quantity,string"` + Price float64 `json:"price,string"` + CumQuantity float64 `json:"cumQuantity,string"` + PostOnly bool `json:"postOnly"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ReportType string `json:"reportType"` +} + +// WsSubmitOrderErrorResponse WS error response +type WsSubmitOrderErrorResponse struct { + Error WsSubmitOrderErrorResponseData `json:"error"` + ID int64 `json:"id"` +} + +// WsSubmitOrderErrorResponseData WS error response data +type WsSubmitOrderErrorResponseData struct { + Code int64 `json:"code"` + Message string `json:"message"` + Description string `json:"description"` +} + +// WsCancelOrderResponse WS response +type WsCancelOrderResponse struct { + Result WsCancelOrderResponseData `json:"result"` + ID int64 `json:"id"` +} + +// WsCancelOrderResponseData WS response data +type WsCancelOrderResponseData struct { + ID string `json:"id"` + ClientOrderID string `json:"clientOrderId"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + Status string `json:"status"` + Type string `json:"type"` + TimeInForce string `json:"timeInForce"` + Quantity float64 `json:"quantity,string"` + Price float64 `json:"price,string"` + CumQuantity float64 `json:"cumQuantity,string"` + PostOnly bool `json:"postOnly"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ReportType string `json:"reportType"` +} + +// WsReplaceOrderResponse WS response +type WsReplaceOrderResponse struct { + Result WsReplaceOrderResponseData `json:"result"` + ID int64 `json:"id"` +} + +// WsReplaceOrderResponseData WS response data +type WsReplaceOrderResponseData struct { + ID string `json:"id"` + ClientOrderID string `json:"clientOrderId"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + Status string `json:"status"` + Type string `json:"type"` + TimeInForce string `json:"timeInForce"` + Quantity float64 `json:"quantity,string"` + Price float64 `json:"price,string"` + CumQuantity float64 `json:"cumQuantity,string"` + PostOnly bool `json:"postOnly"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ReportType string `json:"reportType"` + OriginalRequestClientOrderID string `json:"originalRequestClientOrderId"` +} + +// WsGetActiveOrdersResponse WS response +type WsGetActiveOrdersResponse struct { + Result []WsGetActiveOrdersResponseData `json:"result"` + ID int64 `json:"id"` +} + +// WsGetActiveOrdersResponseData WS response data +type WsGetActiveOrdersResponseData struct { + ID string `json:"id"` + ClientOrderID string `json:"clientOrderId"` + Symbol currency.Pair `json:"symbol"` + Side string `json:"side"` + Status string `json:"status"` + Type string `json:"type"` + TimeInForce string `json:"timeInForce"` + Quantity float64 `json:"quantity,string"` + Price float64 `json:"price,string"` + CumQuantity float64 `json:"cumQuantity,string"` + PostOnly bool `json:"postOnly"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ReportType string `json:"reportType"` + OriginalRequestClientOrderID string `json:"originalRequestClientOrderId"` +} + +// WsGetTradingBalanceResponse WS response +type WsGetTradingBalanceResponse struct { + Result []WsGetTradingBalanceResponseData `json:"result"` + ID int64 `json:"id"` +} + +// WsGetTradingBalanceResponseData WS response data +type WsGetTradingBalanceResponseData struct { + Currency currency.Code `json:"currency"` + Available float64 `json:"available,string"` + Reserved float64 `json:"reserved,string"` +} + +// WsCancelOrderRequest WS request +type WsCancelOrderRequest struct { + Method string `json:"method"` + Params WsCancelOrderRequestData `json:"params"` + ID int64 `json:"id"` +} + +// WsCancelOrderRequestData WS request data +type WsCancelOrderRequestData struct { + ClientOrderID string `json:"clientOrderId"` +} + +// WsReplaceOrderRequest WS request +type WsReplaceOrderRequest struct { + Method string `json:"method"` + Params WsReplaceOrderRequestData `json:"params"` + ID int64 `json:"id,omitempty"` +} + +// WsReplaceOrderRequestData WS request data +type WsReplaceOrderRequestData struct { + ClientOrderID string `json:"clientOrderId,omitempty"` + RequestClientID string `json:"requestClientId,omitempty"` + Quantity float64 `json:"quantity,string,omitempty"` + Price float64 `json:"price,string,omitempty"` +} diff --git a/exchanges/hitbtc/hitbtc_websocket.go b/exchanges/hitbtc/hitbtc_websocket.go index 1178d2f7..511e4bf1 100644 --- a/exchanges/hitbtc/hitbtc_websocket.go +++ b/exchanges/hitbtc/hitbtc_websocket.go @@ -12,6 +12,7 @@ import ( "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/nonce" "github.com/thrasher-/gocryptotrader/exchanges/orderbook" log "github.com/thrasher-/gocryptotrader/logger" ) @@ -21,6 +22,8 @@ const ( rpcVersion = "2.0" ) +var requestID nonce.Nonce + // WsConnect starts a new connection with the websocket API func (h *HitBTC) WsConnect() error { if !h.Websocket.IsEnabled() || !h.IsEnabled() { @@ -45,6 +48,11 @@ func (h *HitBTC) WsConnect() error { } go h.WsHandleData() + err = h.wsLogin() + if err != nil { + log.Errorf("%v - authentication failed: %v", h.Name, err) + } + h.GenerateDefaultSubscriptions() return nil @@ -89,86 +97,146 @@ func (h *HitBTC) WsHandleData() { } if init.Error.Message != "" || init.Error.Code != 0 { + if init.Error.Code == 1002 { + h.Websocket.SetCanUseAuthenticatedEndpoints(false) + } h.Websocket.DataHandler <- fmt.Errorf("hitbtc.go error - Code: %d, Message: %s", init.Error.Code, init.Error.Message) continue } - - if init.Result { + if _, ok := init.Result.(bool); ok { continue } - - switch init.Method { - case "ticker": - var ticker WsTicker - err := common.JSONDecode(resp.Raw, &ticker) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - ts, err := time.Parse(time.RFC3339, ticker.Params.Timestamp) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - h.Websocket.DataHandler <- exchange.TickerData{ - Exchange: h.GetName(), - AssetType: "SPOT", - Pair: currency.NewPairFromString(ticker.Params.Symbol), - Quantity: ticker.Params.Volume, - Timestamp: ts, - OpenPrice: ticker.Params.Open, - HighPrice: ticker.Params.High, - LowPrice: ticker.Params.Low, - } - - case "snapshotOrderbook": - var obSnapshot WsOrderbook - err := common.JSONDecode(resp.Raw, &obSnapshot) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - err = h.WsProcessOrderbookSnapshot(obSnapshot) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - case "updateOrderbook": - var obUpdate WsOrderbook - err := common.JSONDecode(resp.Raw, &obUpdate) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - h.WsProcessOrderbookUpdate(obUpdate) - - case "snapshotTrades": - var tradeSnapshot WsTrade - err := common.JSONDecode(resp.Raw, &tradeSnapshot) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - case "updateTrades": - var tradeUpdates WsTrade - err := common.JSONDecode(resp.Raw, &tradeUpdates) - if err != nil { - h.Websocket.DataHandler <- err - continue - } + if init.Method != "" { + h.handleSubscriptionUpdates(resp, init) + } else { + h.handleCommandResponses(resp, init) } } } } +func (h *HitBTC) handleSubscriptionUpdates(resp exchange.WebsocketResponse, init capture) { + switch init.Method { + case "ticker": + var ticker WsTicker + err := common.JSONDecode(resp.Raw, &ticker) + if err != nil { + h.Websocket.DataHandler <- err + return + } + ts, err := time.Parse(time.RFC3339, ticker.Params.Timestamp) + if err != nil { + h.Websocket.DataHandler <- err + return + } + h.Websocket.DataHandler <- exchange.TickerData{ + Exchange: h.GetName(), + AssetType: "SPOT", + Pair: currency.NewPairFromString(ticker.Params.Symbol), + Quantity: ticker.Params.Volume, + Timestamp: ts, + OpenPrice: ticker.Params.Open, + HighPrice: ticker.Params.High, + LowPrice: ticker.Params.Low, + } + case "snapshotOrderbook": + var obSnapshot WsOrderbook + err := common.JSONDecode(resp.Raw, &obSnapshot) + if err != nil { + h.Websocket.DataHandler <- err + } + err = h.WsProcessOrderbookSnapshot(obSnapshot) + if err != nil { + h.Websocket.DataHandler <- err + } + case "updateOrderbook": + var obUpdate WsOrderbook + err := common.JSONDecode(resp.Raw, &obUpdate) + if err != nil { + h.Websocket.DataHandler <- err + } + h.WsProcessOrderbookUpdate(obUpdate) + case "snapshotTrades": + var tradeSnapshot WsTrade + err := common.JSONDecode(resp.Raw, &tradeSnapshot) + if err != nil { + h.Websocket.DataHandler <- err + } + case "updateTrades": + var tradeUpdates WsTrade + err := common.JSONDecode(resp.Raw, &tradeUpdates) + if err != nil { + h.Websocket.DataHandler <- err + } + case "activeOrders": + var activeOrders WsActiveOrdersResponse + err := common.JSONDecode(resp.Raw, &activeOrders) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- activeOrders + case "report": + var reportData WsReportResponse + err := common.JSONDecode(resp.Raw, &reportData) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- reportData + } +} + +func (h *HitBTC) handleCommandResponses(resp exchange.WebsocketResponse, init capture) { + switch resultType := init.Result.(type) { + case map[string]interface{}: + switch resultType["reportType"].(string) { + case "new": + var response WsSubmitOrderSuccessResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case "canceled": + var response WsCancelOrderResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case "replaced": + var response WsReplaceOrderResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + } + case []interface{}: + if len(resultType) == 0 { + h.Websocket.DataHandler <- fmt.Sprintf("No data returned. ID: %v", init.ID) + return + } + data := resultType[0].(map[string]interface{}) + if _, ok := data["clientOrderId"]; ok { + var response WsActiveOrdersResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + } else if _, ok := data["available"]; ok { + var response WsGetTradingBalanceResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + } + } +} + // WsProcessOrderbookSnapshot processes a full orderbook snapshot to a local cache func (h *HitBTC) WsProcessOrderbookSnapshot(ob WsOrderbook) error { if len(ob.Params.Bid) == 0 || len(ob.Params.Ask) == 0 { @@ -240,7 +308,12 @@ func (h *HitBTC) WsProcessOrderbookUpdate(ob WsOrderbook) error { // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (h *HitBTC) GenerateDefaultSubscriptions() { var channels = []string{"subscribeTicker", "subscribeOrderbook", "subscribeTrades", "subscribeCandles"} - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription + if h.Websocket.CanUseAuthenticatedEndpoints() { + subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ + Channel: "subscribeReports", + }) + } enabledCurrencies := h.GetEnabledCurrencies() for i := range channels { for j := range enabledCurrencies { @@ -257,11 +330,12 @@ func (h *HitBTC) GenerateDefaultSubscriptions() { // Subscribe sends a websocket message to receive data from the channel func (h *HitBTC) Subscribe(channelToSubscribe exchange.WebsocketChannelSubscription) error { subscribe := WsNotification{ - JSONRPCVersion: rpcVersion, - Method: channelToSubscribe.Channel, - Params: params{ + Method: channelToSubscribe.Channel, + } + if channelToSubscribe.Currency.String() != "" { + subscribe.Params = params{ Symbol: channelToSubscribe.Currency.String(), - }, + } } if strings.EqualFold(channelToSubscribe.Channel, "subscribeTrades") { subscribe.Params = params{ @@ -314,7 +388,111 @@ func (h *HitBTC) wsSend(data interface{}) error { return err } if h.Verbose { - log.Debugf("%v sending message to websocket %v", h.Name, data) + log.Debugf("%v sending message to websocket %v", h.Name, string(json)) } return h.WebsocketConn.WriteMessage(websocket.TextMessage, json) } + +// Unsubscribe sends a websocket message to stop receiving data from the channel +func (h *HitBTC) wsLogin() error { + if !h.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", h.Name) + } + h.Websocket.SetCanUseAuthenticatedEndpoints(true) + nonce := fmt.Sprintf("%v", time.Now().Unix()) + hmac := common.GetHMAC(common.HashSHA256, []byte(nonce), []byte(h.APISecret)) + request := WsLoginRequest{ + Method: "login", + Params: WsLoginData{ + Algo: "HS256", + PKey: h.APIKey, + Nonce: nonce, + Signature: common.HexEncodeToString(hmac), + }, + } + + err := h.wsSend(request) + if err != nil { + h.Websocket.SetCanUseAuthenticatedEndpoints(false) + return err + } + return nil +} + +// wsPlaceOrder sends a websocket message to submit an order +func (h *HitBTC) wsPlaceOrder(pair currency.Pair, side string, price, quantity float64) error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated, cannot place order", h.Name) + } + request := WsSubmitOrderRequest{ + Method: "newOrder", + Params: WsSubmitOrderRequestData{ + ClientOrderID: fmt.Sprintf("%v", time.Now().Unix()), + Symbol: pair, + Side: common.StringToLower(side), + Price: price, + Quantity: quantity, + }, + ID: int64(requestID.GetInc()), + } + return h.wsSend(request) +} + +// wsCancelOrder sends a websocket message to cancel an order +func (h *HitBTC) wsCancelOrder(clientOrderID string) error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated, cannot place order", h.Name) + } + request := WsCancelOrderRequest{ + Method: "cancelOrder", + Params: WsCancelOrderRequestData{ + ClientOrderID: clientOrderID, + }, + ID: int64(requestID.GetInc()), + } + return h.wsSend(request) +} + +// wsReplaceOrder sends a websocket message to replace an order +func (h *HitBTC) wsReplaceOrder(clientOrderID string, quantity, price float64) error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated, cannot place order", h.Name) + } + request := WsReplaceOrderRequest{ + Method: "cancelReplaceOrder", + Params: WsReplaceOrderRequestData{ + ClientOrderID: clientOrderID, + RequestClientID: fmt.Sprintf("%v", time.Now().Unix()), + Quantity: quantity, + Price: price, + }, + ID: int64(requestID.GetInc()), + } + return h.wsSend(request) +} + +// wsGetActiveOrders sends a websocket message to get all active orders +func (h *HitBTC) wsGetActiveOrders() error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated, cannot place order", h.Name) + } + request := WsReplaceOrderRequest{ + Method: "getOrders", + Params: WsReplaceOrderRequestData{}, + ID: int64(requestID.GetInc()), + } + return h.wsSend(request) +} + +// wsGetTradingBalance sends a websocket message to get trading balance +func (h *HitBTC) wsGetTradingBalance() error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated, cannot place order", h.Name) + } + request := WsReplaceOrderRequest{ + Method: "getTradingBalance", + Params: WsReplaceOrderRequestData{}, + ID: int64(requestID.GetInc()), + } + return h.wsSend(request) +} diff --git a/exchanges/hitbtc/hitbtc_wrapper.go b/exchanges/hitbtc/hitbtc_wrapper.go index 9460ef1f..276084ce 100644 --- a/exchanges/hitbtc/hitbtc_wrapper.go +++ b/exchanges/hitbtc/hitbtc_wrapper.go @@ -6,7 +6,6 @@ import ( "strconv" "strings" "sync" - "time" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/currency" @@ -319,18 +318,12 @@ func (h *HitBTC) GetActiveOrders(getOrdersRequest *exchange.GetOrdersRequest) ([ symbol := currency.NewPairDelimiter(allOrders[i].Symbol, h.ConfigCurrencyPairFormat.Delimiter) side := exchange.OrderSide(strings.ToUpper(allOrders[i].Side)) - orderDate, err := time.Parse(time.RFC3339, allOrders[i].CreatedAt) - if err != nil { - log.Warnf("Exchange %v Func %v Order %v Could not parse date to unix with value of %v", - h.Name, "GetActiveOrders", allOrders[i].ID, allOrders[i].CreatedAt) - } - orders = append(orders, exchange.OrderDetail{ ID: allOrders[i].ID, Amount: allOrders[i].Quantity, Exchange: h.Name, Price: allOrders[i].Price, - OrderDate: orderDate, + OrderDate: allOrders[i].CreatedAt, OrderSide: side, CurrencyPair: symbol, }) @@ -364,18 +357,12 @@ func (h *HitBTC) GetOrderHistory(getOrdersRequest *exchange.GetOrdersRequest) ([ symbol := currency.NewPairDelimiter(allOrders[i].Symbol, h.ConfigCurrencyPairFormat.Delimiter) side := exchange.OrderSide(strings.ToUpper(allOrders[i].Side)) - orderDate, err := time.Parse(time.RFC3339, allOrders[i].CreatedAt) - if err != nil { - log.Warnf("Exchange %v Func %v Order %v Could not parse date to unix with value of %v", - h.Name, "GetOrderHistory", allOrders[i].ID, allOrders[i].CreatedAt) - } - orders = append(orders, exchange.OrderDetail{ ID: allOrders[i].ID, Amount: allOrders[i].Quantity, Exchange: h.Name, Price: allOrders[i].Price, - OrderDate: orderDate, + OrderDate: allOrders[i].CreatedAt, OrderSide: side, CurrencyPair: symbol, }) @@ -400,3 +387,13 @@ func (h *HitBTC) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketCha h.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (h *HitBTC) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return h.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (h *HitBTC) AuthenticateWebsocket() error { + return h.wsLogin() +} diff --git a/exchanges/huobi/huobi.go b/exchanges/huobi/huobi.go index 80931dcb..27a893a3 100644 --- a/exchanges/huobi/huobi.go +++ b/exchanges/huobi/huobi.go @@ -67,9 +67,10 @@ const ( // HUOBI is the overarching type across this package type HUOBI struct { exchange.Base - AccountID string - WebsocketConn *websocket.Conn - wsRequestMtx sync.Mutex + AccountID string + WebsocketConn *websocket.Conn + AuthenticatedWebsocketConn *websocket.Conn + wsRequestMtx sync.Mutex } // SetDefaults sets default values for the exchange @@ -99,7 +100,9 @@ func (h *HUOBI) SetDefaults() { exchange.WebsocketOrderbookSupported | exchange.WebsocketTradeDataSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported | + exchange.WebsocketAccountDataSupported } // Setup sets user configuration @@ -109,6 +112,7 @@ func (h *HUOBI) Setup(exch *config.ExchangeConfig) { } else { h.Enabled = true h.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + h.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport h.SetAPIKeys(exch.APIKey, exch.APISecret, "", false) h.APIAuthPEMKeySupport = exch.APIAuthPEMKeySupport h.APIAuthPEMKey = exch.APIAuthPEMKey @@ -147,7 +151,7 @@ func (h *HUOBI) Setup(exch *config.ExchangeConfig) { exch.Name, exch.Websocket, exch.Verbose, - huobiSocketIOAddress, + wsMarketURL, exch.WebsocketURL) if err != nil { log.Fatal(err) diff --git a/exchanges/huobi/huobi_test.go b/exchanges/huobi/huobi_test.go index 2e5ac68d..422fad7e 100644 --- a/exchanges/huobi/huobi_test.go +++ b/exchanges/huobi/huobi_test.go @@ -9,11 +9,14 @@ import ( "strconv" "strings" "testing" + "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) // Please supply you own test keys here for due diligence testing. @@ -25,35 +28,7 @@ const ( ) var h HUOBI - -// getDefaultConfig returns a default huobi config -func getDefaultConfig() config.ExchangeConfig { - return config.ExchangeConfig{ - Name: "Huobi", - Enabled: true, - Verbose: true, - Websocket: false, - UseSandbox: false, - RESTPollingDelay: 10, - HTTPTimeout: 15000000000, - AuthenticatedAPISupport: true, - APIKey: "", - APISecret: "", - ClientID: "", - AvailablePairs: currency.NewPairsFromStrings([]string{"BTC-USDT", "BCH-USDT"}), - EnabledPairs: currency.NewPairsFromStrings([]string{"BTC-USDT"}), - BaseCurrencies: currency.NewCurrenciesFromStringArray([]string{"USD"}), - AssetTypes: "SPOT", - SupportsAutoPairUpdates: false, - ConfigCurrencyPairFormat: &config.CurrencyPairFormatConfig{ - Uppercase: true, - Delimiter: "-", - }, - RequestCurrencyPairFormat: &config.CurrencyPairFormatConfig{ - Uppercase: false, - }, - } -} +var wsSetupRan bool func TestSetDefaults(t *testing.T) { h.SetDefaults() @@ -67,12 +42,54 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - Huobi Setup() init error") } hConfig.AuthenticatedAPISupport = true + hConfig.AuthenticatedWebsocketAPISupport = true hConfig.APIKey = apiKey hConfig.APISecret = apiSecret h.Setup(&hConfig) } +func setupWsTests(t *testing.T) { + if wsSetupRan { + return + } + TestSetDefaults(t) + TestSetup(t) + if !h.Websocket.IsEnabled() && !h.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) + } + var err error + var dialer websocket.Dialer + comms = make(chan WsMessage, sharedtestvalues.WebsocketChannelOverrideCapacity) + h.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + h.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() + go h.WsHandleData() + err = h.wsAuthenticatedDial(&dialer) + if err != nil { + t.Error(err) + } + err = h.wsLogin() + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case response := <-h.Websocket.DataHandler: + switch respType := response.(type) { + case WsAuthenticatedDataResponse: + if respType.ErrorCode > 0 { + t.Error(respType) + } + case error: + t.Error(respType) + } + case <-timer.C: + t.Error("Websocket did not receive a response") + } + timer.Stop() + wsSetupRan = true +} + func TestGetSpotKline(t *testing.T) { t.Parallel() _, err := h.GetSpotKline(KlinesRequestParams{ @@ -622,3 +639,50 @@ func TestGetDepositAddress(t *testing.T) { t.Error("Test Failed - GetDepositAddress() error cannot be nil") } } + +// TestWsGetAccountsList connects to WS, logs in, gets account list +func TestWsGetAccountsList(t *testing.T) { + setupWsTests(t) + h.wsGetAccountsList(currency.NewPairFromString("ethbtc")) + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case response := <-h.Websocket.DataHandler: + switch respType := response.(type) { + case WsAuthenticatedAccountsListResponse: + if respType.ErrorCode > 0 { + t.Error(respType) + } + case error: + t.Error(respType) + } + case <-timer.C: + t.Error("Websocket did not receive a response") + } + timer.Stop() +} + +// TestWsGetOrderList connects to WS, logs in, gets order list +func TestWsGetOrderList(t *testing.T) { + setupWsTests(t) + h.wsGetOrdersList(1, currency.NewPairFromString("ethbtc")) + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case <-h.Websocket.DataHandler: + case <-timer.C: + t.Error("Websocket did not receive a response") + } + timer.Stop() +} + +// TestWsGetOrderDetails connects to WS, logs in, gets order details +func TestWsGetOrderDetails(t *testing.T) { + setupWsTests(t) + h.wsGetOrderDetails("123") + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case <-h.Websocket.DataHandler: + case <-timer.C: + t.Error("Websocket did not receive a response") + } + timer.Stop() +} diff --git a/exchanges/huobi/huobi_types.go b/exchanges/huobi/huobi_types.go index 0f90346d..958a09ad 100644 --- a/exchanges/huobi/huobi_types.go +++ b/exchanges/huobi/huobi_types.go @@ -1,5 +1,7 @@ package huobi +import "github.com/thrasher-/gocryptotrader/currency" + // Response stores the Huobi response information type Response struct { Status string `json:"status"` @@ -271,13 +273,13 @@ type WsRequest struct { // WsResponse defines a response from the websocket connection when there // is an error type WsResponse struct { - TS int64 `json:"ts"` - Status string `json:"status"` - ErrorCode string `json:"err-code"` - ErrorMessage string `json:"err-msg"` - Ping int64 `json:"ping"` - Channel string `json:"ch"` - Subscribed string `json:"subbed"` + TS int64 `json:"ts"` + Status string `json:"status"` + ErrorCode interface{} `json:"err-code"` + ErrorMessage string `json:"err-msg"` + Ping int64 `json:"ping"` + Channel string `json:"ch"` + Subscribed string `json:"subbed"` } // WsHeartBeat defines a heartbeat request @@ -323,9 +325,201 @@ type WsTrade struct { Data []struct { Amount float64 `json:"amount"` Timestamp int64 `json:"ts"` - ID float64 `json:"id,string"` + ID float64 `json:"id"` Price float64 `json:"price"` Direction string `json:"direction"` } `json:"data"` } } + +// WsAuthenticationRequest data for login +type WsAuthenticationRequest struct { + Op string `json:"op"` + AccessKeyID string `json:"AccessKeyId"` + SignatureMethod string `json:"SignatureMethod"` + SignatureVersion string `json:"SignatureVersion"` + Timestamp string `json:"Timestamp"` + Signature string `json:"Signature"` +} + +// WsMessage defines read data from the websocket connection +type WsMessage struct { + Raw []byte + URL string +} + +// WsAuthenticatedSubscriptionRequest request for subscription on authenticated connection +type WsAuthenticatedSubscriptionRequest struct { + Op string `json:"op"` + AccessKeyID string `json:"AccessKeyId"` + SignatureMethod string `json:"SignatureMethod"` + SignatureVersion string `json:"SignatureVersion"` + Timestamp string `json:"Timestamp"` + Signature string `json:"Signature"` + Topic string `json:"topic"` +} + +// WsAuthenticatedAccountsListRequest request for account list authenticated connection +type WsAuthenticatedAccountsListRequest struct { + Op string `json:"op"` + AccessKeyID string `json:"AccessKeyId"` + SignatureMethod string `json:"SignatureMethod"` + SignatureVersion string `json:"SignatureVersion"` + Timestamp string `json:"Timestamp"` + Signature string `json:"Signature"` + Topic string `json:"topic"` + Symbol currency.Pair `json:"symbol"` +} + +// WsAuthenticatedOrderDetailsRequest request for order details authenticated connection +type WsAuthenticatedOrderDetailsRequest struct { + Op string `json:"op"` + AccessKeyID string `json:"AccessKeyId"` + SignatureMethod string `json:"SignatureMethod"` + SignatureVersion string `json:"SignatureVersion"` + Timestamp string `json:"Timestamp"` + Signature string `json:"Signature"` + Topic string `json:"topic"` + OrderID string `json:"order-id"` +} + +// WsAuthenticatedOrdersListRequest request for orderslist authenticated connection +type WsAuthenticatedOrdersListRequest struct { + Op string `json:"op"` + AccessKeyID string `json:"AccessKeyId"` + SignatureMethod string `json:"SignatureMethod"` + SignatureVersion string `json:"SignatureVersion"` + Timestamp string `json:"Timestamp"` + Signature string `json:"Signature"` + Topic string `json:"topic"` + States string `json:"states"` + AccountID int64 `json:"account-id"` + Symbol currency.Pair `json:"symbol"` +} + +// WsAuthenticatedDataResponse response from authenticated connection +type WsAuthenticatedDataResponse struct { + Op string `json:"op,omitempty"` + Ts int64 `json:"ts,omitempty"` + Topic string `json:"topic,omitempty"` + ErrorCode int64 `json:"err-code,omitempty"` + ErrorMessage string `json:"err-msg,omitempty"` + Ping int64 `json:"ping,omitempty"` + CID string `json:"cid,omitempty"` +} + +// WsAuthenticatedAccountsResponse response from Accounts authenticated subscription +type WsAuthenticatedAccountsResponse struct { + WsAuthenticatedDataResponse + Data WsAuthenticatedAccountsResponseData `json:"data"` +} + +// WsAuthenticatedAccountsResponseData account data +type WsAuthenticatedAccountsResponseData struct { + Event string `json:"event"` + List []WsAuthenticatedAccountsResponseDataList `json:"list"` +} + +// WsAuthenticatedAccountsResponseDataList detailed account data +type WsAuthenticatedAccountsResponseDataList struct { + AccountID int64 `json:"account-id"` + Currency string `json:"currency"` + Type string `json:"type"` + Balance float64 `json:"balance,string"` +} + +// WsAuthenticatedOrdersUpdateResponse response from OrdersUpdate authenticated subscription +type WsAuthenticatedOrdersUpdateResponse struct { + WsAuthenticatedDataResponse + Data WsAuthenticatedOrdersUpdateResponseData `json:"data"` +} + +// WsAuthenticatedOrdersUpdateResponseData order updatedata +type WsAuthenticatedOrdersUpdateResponseData struct { + UnfilledAmount float64 `json:"unfilled-amount,string"` + FilledAmount float64 `json:"filled-amount,string"` + Price float64 `json:"price,string"` + OrderID int64 `json:"order-id"` + Symbol currency.Pair `json:"symbol"` + MatchID int64 `json:"match-id"` + FilledCashAmount float64 `json:"filled-cash-amount,string"` + Role string `json:"role"` + OrderState string `json:"order-state"` +} + +// WsAuthenticatedOrdersResponse response from Orders authenticated subscription +type WsAuthenticatedOrdersResponse struct { + WsAuthenticatedDataResponse + Data []WsAuthenticatedOrdersResponseData `json:"data"` +} + +// WsAuthenticatedOrdersResponseData order data +type WsAuthenticatedOrdersResponseData struct { + SeqID int64 `json:"seq-id"` + OrderID int64 `json:"order-id"` + Symbol currency.Pair `json:"symbol"` + AccountID int64 `json:"account-id"` + OrderAmount float64 `json:"order-amount,string"` + OrderPrice float64 `json:"order-price,string"` + CreatedAt int64 `json:"created-at"` + OrderType string `json:"order-type"` + OrderSource string `json:"order-source"` + OrderState string `json:"order-state"` + Role string `json:"role"` + Price float64 `json:"price,string"` + FilledAmount float64 `json:"filled-amount,string"` + UnfilledAmount float64 `json:"unfilled-amount,string"` + FilledCashAmount float64 `json:"filled-cash-amount,string"` + FilledFees float64 `json:"filled-fees,string"` +} + +// WsAuthenticatedAccountsListResponse response from AccountsList authenticated endpoint +type WsAuthenticatedAccountsListResponse struct { + WsAuthenticatedDataResponse + Data []WsAuthenticatedAccountsListResponseData `json:"data"` +} + +// WsAuthenticatedAccountsListResponseData account data +type WsAuthenticatedAccountsListResponseData struct { + ID int64 `json:"id"` + Type string `json:"type"` + State string `json:"state"` + List []WsAuthenticatedAccountsListResponseDataList `json:"list"` +} + +// WsAuthenticatedAccountsListResponseDataList detailed account data +type WsAuthenticatedAccountsListResponseDataList struct { + Currency string `json:"currency"` + Type string `json:"type"` + Balance float64 `json:"balance,string"` +} + +// WsAuthenticatedOrdersListResponse response from OrdersList authenticated endpoint +type WsAuthenticatedOrdersListResponse struct { + WsAuthenticatedDataResponse + Data []WsAuthenticatedOrdersListResponseData `json:"data"` +} + +// WsAuthenticatedOrdersListResponseData contains order details +type WsAuthenticatedOrdersListResponseData struct { + ID int64 `json:"id"` + Symbol currency.Pair `json:"symbol"` + AccountID int64 `json:"account-id"` + Amount float64 `json:"amount,string"` + Price float64 `json:"price,string"` + CreatedAt int64 `json:"created-at"` + Type string `json:"type"` + FilledAmount float64 `json:"filled-amount,string"` + FilledCashAmount float64 `json:"filled-cash-amount,string"` + FilledFees float64 `json:"filled-fees,string"` + FinishedAt int64 `json:"finished-at"` + Source string `json:"source"` + State string `json:"state"` + CanceledAt int64 `json:"canceled-at"` +} + +// WsAuthenticatedOrderDetailResponse response from OrderDetail authenticated endpoint +type WsAuthenticatedOrderDetailResponse struct { + WsAuthenticatedDataResponse + Data WsAuthenticatedOrdersListResponseData `json:"data"` +} diff --git a/exchanges/huobi/huobi_websocket.go b/exchanges/huobi/huobi_websocket.go index f59d576b..691f45b9 100644 --- a/exchanges/huobi/huobi_websocket.go +++ b/exchanges/huobi/huobi_websocket.go @@ -8,6 +8,7 @@ import ( "io/ioutil" "net/http" "net/url" + "strings" "time" "github.com/gorilla/websocket" @@ -19,12 +20,33 @@ import ( ) const ( - huobiSocketIOAddress = "wss://api.huobi.pro/hbus/ws" - wsMarketKline = "market.%s.kline.1min" - wsMarketDepth = "market.%s.depth.step0" - wsMarketTrade = "market.%s.trade.detail" + baseWSURL = "wss://api.huobi.pro" + + wsMarketURL = baseWSURL + "/ws" + wsMarketKline = "market.%s.kline.1min" + wsMarketDepth = "market.%s.depth.step0" + wsMarketTrade = "market.%s.trade.detail" + + wsAccountsOrdersEndPoint = "/ws/v1" + wsAccountsList = "accounts.list" + wsOrdersList = "orders.list" + wsOrdersDetail = "orders.detail" + wsAccountsOrdersURL = baseWSURL + wsAccountsOrdersEndPoint + wsAccountListEndpoint = wsAccountsOrdersEndPoint + "/" + wsAccountsList + wsOrdersListEndpoint = wsAccountsOrdersEndPoint + "/" + wsOrdersList + wsOrdersDetailEndpoint = wsAccountsOrdersEndPoint + "/" + wsOrdersDetail + + wsDateTimeFormatting = "2006-01-02T15:04:05" + + signatureMethod = "HmacSHA256" + signatureVersion = "2" + requestOp = "req" + authOp = "auth" ) +// Instantiates a communications channel between websocket connections +var comms = make(chan WsMessage, 1) + // WsConnect initiates a new websocket connection func (h *HUOBI) WsConnect() error { if !h.Websocket.IsEnabled() || !h.IsEnabled() { @@ -42,140 +64,264 @@ func (h *HUOBI) WsConnect() error { dialer.Proxy = http.ProxyURL(proxy) } - var err error - h.WebsocketConn, _, err = dialer.Dial(h.Websocket.GetWebsocketURL(), http.Header{}) + err := h.wsDial(&dialer) if err != nil { return err } + err = h.wsAuthenticatedDial(&dialer) + if err != nil { + log.Errorf("%v - authenticated dial failed: %v", h.Name, err) + } + err = h.wsLogin() + if err != nil { + log.Errorf("%v - authentication failed: %v", h.Name, err) + } go h.WsHandleData() - h.GenerateDefaultSubscriptions() + return nil } -// WsReadData reads data from the websocket connection -func (h *HUOBI) WsReadData() (exchange.WebsocketResponse, error) { - _, resp, err := h.WebsocketConn.ReadMessage() +func (h *HUOBI) wsDial(dialer *websocket.Dialer) error { + var err error + var conStatus *http.Response + h.WebsocketConn, conStatus, err = dialer.Dial(wsMarketURL, http.Header{}) if err != nil { - return exchange.WebsocketResponse{}, err + return fmt.Errorf("%v %v %v Error: %v", wsMarketURL, conStatus, conStatus.StatusCode, err) } + go h.wsMultiConnectionFunnel(h.WebsocketConn, wsMarketURL) + return nil +} - h.Websocket.TrafficAlert <- struct{}{} - - b := bytes.NewReader(resp) - gReader, err := gzip.NewReader(b) +func (h *HUOBI) wsAuthenticatedDial(dialer *websocket.Dialer) error { + if !h.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", h.Name) + } + var err error + var conStatus *http.Response + h.AuthenticatedWebsocketConn, conStatus, err = dialer.Dial(wsAccountsOrdersURL, http.Header{}) if err != nil { - return exchange.WebsocketResponse{}, err + return fmt.Errorf("%v %v %v Error: %v", wsAccountsOrdersURL, conStatus, conStatus.StatusCode, err) } + go h.wsMultiConnectionFunnel(h.AuthenticatedWebsocketConn, wsAccountsOrdersURL) + return nil +} - unzipped, err := ioutil.ReadAll(gReader) - if err != nil { - return exchange.WebsocketResponse{}, err +// wsMultiConnectionFunnel manages data from multiple endpoints and passes it to a channel +func (h *HUOBI) wsMultiConnectionFunnel(ws *websocket.Conn, url string) { + h.Websocket.Wg.Add(1) + defer h.Websocket.Wg.Done() + for { + select { + case <-h.Websocket.ShutdownC: + return + default: + _, resp, err := ws.ReadMessage() + if err != nil { + h.Websocket.DataHandler <- err + return + } + h.Websocket.TrafficAlert <- struct{}{} + b := bytes.NewReader(resp) + gReader, err := gzip.NewReader(b) + if err != nil { + h.Websocket.DataHandler <- err + return + } + unzipped, err := ioutil.ReadAll(gReader) + if err != nil { + h.Websocket.DataHandler <- err + return + } + err = gReader.Close() + if err != nil { + h.Websocket.DataHandler <- err + return + } + comms <- WsMessage{Raw: unzipped, URL: url} + } } - gReader.Close() - - return exchange.WebsocketResponse{Raw: unzipped}, nil } // WsHandleData handles data read from the websocket connection func (h *HUOBI) WsHandleData() { h.Websocket.Wg.Add(1) - - defer func() { - h.Websocket.Wg.Done() - }() - + defer h.Websocket.Wg.Done() for { select { case <-h.Websocket.ShutdownC: return - - default: - resp, err := h.WsReadData() - if err != nil { - h.Websocket.DataHandler <- err - return + case resp := <-comms: + if h.Verbose { + log.Debugf("%v: %v: %v", h.Name, resp.URL, string(resp.Raw)) } - - var init WsResponse - err = common.JSONDecode(resp.Raw, &init) - if err != nil { - h.Websocket.DataHandler <- err - continue + switch resp.URL { + case wsMarketURL: + h.wsHandleMarketData(resp) + case wsAccountsOrdersURL: + h.wsHandleAuthenticatedData(resp) } + } + } +} - if init.Status == "error" { - h.Websocket.DataHandler <- fmt.Errorf("huobi.go Websocker error %s %s", - init.ErrorCode, - init.ErrorMessage) - continue - } +func (h *HUOBI) wsHandleAuthenticatedData(resp WsMessage) { + var init WsAuthenticatedDataResponse + err := common.JSONDecode(resp.Raw, &init) + if err != nil { + h.Websocket.DataHandler <- err + return + } + if init.ErrorCode > 0 { + if init.ErrorMessage == "api-signature-not-valid" { + h.Websocket.SetCanUseAuthenticatedEndpoints(false) + } + h.Websocket.DataHandler <- fmt.Errorf("%v %v Websocket error %v %s", + h.Name, + resp.URL, + init.ErrorCode, + init.ErrorMessage) + return + } + if init.Ping != 0 { + err = h.WebsocketConn.WriteJSON(`{"pong":1337}`) + if err != nil { + log.Error(err) + } + return + } - if init.Subscribed != "" { - continue - } + if init.Op == "sub" { + if h.Verbose { + log.Debugf("%v: %v: Successfully subscribed to %v", h.Name, resp.URL, init.Topic) + } + return + } - if init.Ping != 0 { - err = h.WebsocketConn.WriteJSON(`{"pong":1337}`) - if err != nil { - log.Error(err) - } - continue - } + switch { + case strings.EqualFold(init.Op, authOp): + h.Websocket.SetCanUseAuthenticatedEndpoints(true) + var response WsAuthenticatedDataResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case strings.EqualFold(init.Topic, "accounts"): + var response WsAuthenticatedAccountsResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case common.StringContains(init.Topic, "orders") && + common.StringContains(init.Topic, "update"): + var response WsAuthenticatedOrdersUpdateResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case common.StringContains(init.Topic, "orders"): + var response WsAuthenticatedOrdersResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case strings.EqualFold(init.Topic, wsAccountsList): + var response WsAuthenticatedAccountsListResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case strings.EqualFold(init.Topic, wsOrdersList): + var response WsAuthenticatedOrdersListResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case strings.EqualFold(init.Topic, wsOrdersDetail): + var response WsAuthenticatedOrderDetailResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + } +} - switch { - case common.StringContains(init.Channel, "depth"): - var depth WsDepth - err := common.JSONDecode(resp.Raw, &depth) - if err != nil { - h.Websocket.DataHandler <- err - continue - } +func (h *HUOBI) wsHandleMarketData(resp WsMessage) { + var init WsResponse + err := common.JSONDecode(resp.Raw, &init) + if err != nil { + h.Websocket.DataHandler <- err + return + } + if init.Status == "error" { + h.Websocket.DataHandler <- fmt.Errorf("%v %v Websocket error %s %s", + h.Name, + resp.URL, + init.ErrorCode, + init.ErrorMessage) + return + } + if init.Subscribed != "" { + return + } + if init.Ping != 0 { + err = h.WebsocketConn.WriteJSON(`{"pong":1337}`) + if err != nil { + log.Error(err) + } + return + } - data := common.SplitStrings(depth.Channel, ".") - - h.WsProcessOrderbook(&depth, data[1]) - - case common.StringContains(init.Channel, "kline"): - var kline WsKline - err := common.JSONDecode(resp.Raw, &kline) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - data := common.SplitStrings(kline.Channel, ".") - - h.Websocket.DataHandler <- exchange.KlineData{ - Timestamp: time.Unix(0, kline.Timestamp), - Exchange: h.GetName(), - AssetType: "SPOT", - Pair: currency.NewPairFromString(data[1]), - OpenPrice: kline.Tick.Open, - ClosePrice: kline.Tick.Close, - HighPrice: kline.Tick.High, - LowPrice: kline.Tick.Low, - Volume: kline.Tick.Volume, - } - - case common.StringContains(init.Channel, "trade"): - var trade WsTrade - err := common.JSONDecode(resp.Raw, &trade) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - data := common.SplitStrings(trade.Channel, ".") - - h.Websocket.DataHandler <- exchange.TradeData{ - Exchange: h.GetName(), - AssetType: "SPOT", - CurrencyPair: currency.NewPairFromString(data[1]), - Timestamp: time.Unix(0, trade.Tick.Timestamp), - } - } + switch { + case common.StringContains(init.Channel, "depth"): + var depth WsDepth + err := common.JSONDecode(resp.Raw, &depth) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := common.SplitStrings(depth.Channel, ".") + h.WsProcessOrderbook(&depth, data[1]) + case common.StringContains(init.Channel, "kline"): + var kline WsKline + err := common.JSONDecode(resp.Raw, &kline) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := common.SplitStrings(kline.Channel, ".") + h.Websocket.DataHandler <- exchange.KlineData{ + Timestamp: time.Unix(0, kline.Timestamp), + Exchange: h.GetName(), + AssetType: "SPOT", + Pair: currency.NewPairFromString(data[1]), + OpenPrice: kline.Tick.Open, + ClosePrice: kline.Tick.Close, + HighPrice: kline.Tick.High, + LowPrice: kline.Tick.Low, + Volume: kline.Tick.Volume, + } + case common.StringContains(init.Channel, "trade"): + var trade WsTrade + err := common.JSONDecode(resp.Raw, &trade) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := common.SplitStrings(trade.Channel, ".") + h.Websocket.DataHandler <- exchange.TradeData{ + Exchange: h.GetName(), + AssetType: "SPOT", + CurrencyPair: currency.NewPairFromString(data[1]), + Timestamp: time.Unix(0, trade.Tick.Timestamp), } } } @@ -220,8 +366,14 @@ func (h *HUOBI) WsProcessOrderbook(ob *WsDepth, symbol string) error { // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (h *HUOBI) GenerateDefaultSubscriptions() { var channels = []string{wsMarketKline, wsMarketDepth, wsMarketTrade} + var subscriptions []exchange.WebsocketChannelSubscription + if h.Websocket.CanUseAuthenticatedEndpoints() { + channels = append(channels, "orders.%v", "orders.%v.update") + subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ + Channel: "accounts", + }) + } enabledCurrencies := h.GetEnabledCurrencies() - subscriptions := []exchange.WebsocketChannelSubscription{} for i := range channels { for j := range enabledCurrencies { enabledCurrencies[j].Delimiter = "" @@ -237,11 +389,11 @@ func (h *HUOBI) GenerateDefaultSubscriptions() { // Subscribe sends a websocket message to receive data from the channel func (h *HUOBI) Subscribe(channelToSubscribe exchange.WebsocketChannelSubscription) error { - subscriptionRequest := WsRequest{Subscribe: channelToSubscribe.Channel} - if h.Verbose { - log.Debugf("Subscription: %v", subscriptionRequest) + if common.StringContains(channelToSubscribe.Channel, "orders.") || + common.StringContains(channelToSubscribe.Channel, "accounts") { + return h.wsAuthenticatedSubscribe("sub", wsAccountsOrdersEndPoint+channelToSubscribe.Channel, channelToSubscribe.Channel) } - subscription, err := common.JSONEncode(subscriptionRequest) + subscription, err := common.JSONEncode(WsRequest{Subscribe: channelToSubscribe.Channel}) if err != nil { return err } @@ -250,6 +402,10 @@ func (h *HUOBI) Subscribe(channelToSubscribe exchange.WebsocketChannelSubscripti // Unsubscribe sends a websocket message to stop receiving data from the channel func (h *HUOBI) Unsubscribe(channelToSubscribe exchange.WebsocketChannelSubscription) error { + if common.StringContains(channelToSubscribe.Channel, "orders.") || + common.StringContains(channelToSubscribe.Channel, "accounts") { + return h.wsAuthenticatedSubscribe("unsub", wsAccountsOrdersEndPoint+channelToSubscribe.Channel, channelToSubscribe.Channel) + } subscription, err := common.JSONEncode(WsRequest{Unsubscribe: channelToSubscribe.Channel}) if err != nil { return err @@ -266,3 +422,125 @@ func (h *HUOBI) wsSend(data []byte) error { } return h.WebsocketConn.WriteMessage(websocket.TextMessage, data) } + +func (h *HUOBI) wsLogin() error { + if !h.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", h.Name) + } + h.Websocket.SetCanUseAuthenticatedEndpoints(true) + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticationRequest{ + Op: authOp, + AccessKeyID: h.APIKey, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + } + hmac := h.wsGenerateSignature(timestamp, wsAccountsOrdersEndPoint) + request.Signature = common.Base64Encode(hmac) + err := h.wsAuthenticatedSend(request) + if err != nil { + h.Websocket.SetCanUseAuthenticatedEndpoints(false) + return err + } + return nil +} + +func (h *HUOBI) wsAuthenticatedSend(request interface{}) error { + h.wsRequestMtx.Lock() + defer h.wsRequestMtx.Unlock() + encodedRequest, err := common.JSONEncode(request) + if err != nil { + return err + } + if h.Verbose { + log.Debugf("%v sending Authenticated message to websocket %s", h.Name, string(encodedRequest)) + } + return h.AuthenticatedWebsocketConn.WriteMessage(websocket.TextMessage, encodedRequest) +} + +func (h *HUOBI) wsGenerateSignature(timestamp, endpoint string) []byte { + values := url.Values{} + values.Set("AccessKeyId", h.APIKey) + values.Set("SignatureMethod", signatureMethod) + values.Set("SignatureVersion", signatureVersion) + values.Set("Timestamp", timestamp) + host := "api.huobi.pro" + payload := fmt.Sprintf("%s\n%s\n%s\n%s", + "GET", host, endpoint, values.Encode()) + return common.GetHMAC(common.HashSHA256, []byte(payload), []byte(h.APISecret)) +} + +func (h *HUOBI) wsAuthenticatedSubscribe(operation, endpoint, topic string) error { + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticatedSubscriptionRequest{ + Op: operation, + AccessKeyID: h.APIKey, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: topic, + } + hmac := h.wsGenerateSignature(timestamp, endpoint) + request.Signature = common.Base64Encode(hmac) + return h.wsAuthenticatedSend(request) +} + +func (h *HUOBI) wsGetAccountsList(pair currency.Pair) error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated cannot get accounts list", h.Name) + } + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticatedAccountsListRequest{ + Op: requestOp, + AccessKeyID: h.APIKey, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsAccountsList, + Symbol: pair, + } + hmac := h.wsGenerateSignature(timestamp, wsAccountListEndpoint) + request.Signature = common.Base64Encode(hmac) + return h.wsAuthenticatedSend(request) +} + +func (h *HUOBI) wsGetOrdersList(accountID int64, pair currency.Pair) error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated cannot get orders list", h.Name) + } + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticatedOrdersListRequest{ + Op: requestOp, + AccessKeyID: h.APIKey, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsOrdersList, + AccountID: accountID, + Symbol: pair.Lower(), + States: "submitted,partial-filled", + } + hmac := h.wsGenerateSignature(timestamp, wsOrdersListEndpoint) + request.Signature = common.Base64Encode(hmac) + return h.wsAuthenticatedSend(request) +} + +func (h *HUOBI) wsGetOrderDetails(orderID string) error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated cannot get order details", h.Name) + } + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticatedOrderDetailsRequest{ + Op: requestOp, + AccessKeyID: h.APIKey, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsOrdersDetail, + OrderID: orderID, + } + hmac := h.wsGenerateSignature(timestamp, wsOrdersDetailEndpoint) + request.Signature = common.Base64Encode(hmac) + return h.wsAuthenticatedSend(request) +} diff --git a/exchanges/huobi/huobi_wrapper.go b/exchanges/huobi/huobi_wrapper.go index ce5739d2..e88e7fb3 100644 --- a/exchanges/huobi/huobi_wrapper.go +++ b/exchanges/huobi/huobi_wrapper.go @@ -29,7 +29,7 @@ func (h *HUOBI) Start(wg *sync.WaitGroup) { // Run implements the HUOBI wrapper func (h *HUOBI) Run() { if h.Verbose { - log.Debugf("%s Websocket: %s (url: %s).\n", h.GetName(), common.IsEnabled(h.Websocket.IsEnabled()), huobiSocketIOAddress) + log.Debugf("%s Websocket: %s (url: %s).\n", h.GetName(), common.IsEnabled(h.Websocket.IsEnabled()), wsMarketURL) log.Debugf("%s polling delay: %ds.\n", h.GetName(), h.RESTPollingDelay) log.Debugf("%s %d currencies enabled: %s.\n", h.GetName(), len(h.EnabledPairs), h.EnabledPairs) } @@ -523,3 +523,13 @@ func (h *HUOBI) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChan h.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (h *HUOBI) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return h.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (h *HUOBI) AuthenticateWebsocket() error { + return h.wsLogin() +} diff --git a/exchanges/huobihadax/huobihadax.go b/exchanges/huobihadax/huobihadax.go index 7c3333c8..1160edde 100644 --- a/exchanges/huobihadax/huobihadax.go +++ b/exchanges/huobihadax/huobihadax.go @@ -62,7 +62,8 @@ const ( // HUOBIHADAX is the overarching type across this package type HUOBIHADAX struct { - WebsocketConn *websocket.Conn + WebsocketConn *websocket.Conn + AuthenticatedWebsocketConn *websocket.Conn exchange.Base wsRequestMtx sync.Mutex } @@ -94,7 +95,9 @@ func (h *HUOBIHADAX) SetDefaults() { exchange.WebsocketTradeDataSupported | exchange.WebsocketOrderbookSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported | + exchange.WebsocketAccountDataSupported } // Setup sets user configuration @@ -104,6 +107,7 @@ func (h *HUOBIHADAX) Setup(exch *config.ExchangeConfig) { } else { h.Enabled = true h.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + h.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport h.SetAPIKeys(exch.APIKey, exch.APISecret, "", false) h.APIAuthPEMKeySupport = exch.APIAuthPEMKeySupport h.APIAuthPEMKey = exch.APIAuthPEMKey @@ -141,7 +145,7 @@ func (h *HUOBIHADAX) Setup(exch *config.ExchangeConfig) { exch.Name, exch.Websocket, exch.Verbose, - huobiGlobalWebsocketEndpoint, + HuobiHadaxSocketIOAddress, exch.WebsocketURL) if err != nil { log.Fatal(err) diff --git a/exchanges/huobihadax/huobihadax_test.go b/exchanges/huobihadax/huobihadax_test.go index 1e3cacb8..0647d55c 100644 --- a/exchanges/huobihadax/huobihadax_test.go +++ b/exchanges/huobihadax/huobihadax_test.go @@ -4,11 +4,14 @@ import ( "fmt" "strconv" "testing" + "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) // Please supply your own APIKEYS here for due diligence testing @@ -21,35 +24,7 @@ const ( ) var h HUOBIHADAX - -// getDefaultConfig returns a default huobi config -func getDefaultConfig() config.ExchangeConfig { - return config.ExchangeConfig{ - Name: "huobihadax", - Enabled: true, - Verbose: true, - Websocket: false, - UseSandbox: false, - RESTPollingDelay: 10, - HTTPTimeout: 15000000000, - AuthenticatedAPISupport: true, - APIKey: "", - APISecret: "", - ClientID: "", - AvailablePairs: currency.NewPairsFromStrings([]string{"BTC-USDT", "BCH-USDT"}), - EnabledPairs: currency.NewPairsFromStrings([]string{"BTC-USDT"}), - BaseCurrencies: currency.NewCurrenciesFromStringArray([]string{"USD"}), - AssetTypes: "SPOT", - SupportsAutoPairUpdates: false, - ConfigCurrencyPairFormat: &config.CurrencyPairFormatConfig{ - Uppercase: true, - Delimiter: "-", - }, - RequestCurrencyPairFormat: &config.CurrencyPairFormatConfig{ - Uppercase: false, - }, - } -} +var wsSetupRan bool func TestSetDefaults(t *testing.T) { h.SetDefaults() @@ -63,12 +38,54 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - HuobiHadax Setup() init error") } hadaxConfig.AuthenticatedAPISupport = true + hadaxConfig.AuthenticatedWebsocketAPISupport = true hadaxConfig.APIKey = apiKey hadaxConfig.APISecret = apiSecret h.Setup(&hadaxConfig) } +func setupWsTests(t *testing.T) { + if wsSetupRan { + return + } + TestSetDefaults(t) + TestSetup(t) + if !h.Websocket.IsEnabled() && !h.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) + } + var err error + var dialer websocket.Dialer + comms = make(chan WsMessage, sharedtestvalues.WebsocketChannelOverrideCapacity) + h.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + h.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() + go h.WsHandleData() + err = h.wsAuthenticatedDial(&dialer) + if err != nil { + t.Error(err) + } + err = h.wsLogin() + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case response := <-h.Websocket.DataHandler: + switch respType := response.(type) { + case WsAuthenticatedDataResponse: + if respType.ErrorCode > 0 { + t.Error(respType) + } + case error: + t.Error(respType) + } + case <-timer.C: + t.Error("Websocket did not receive a response") + } + timer.Stop() + wsSetupRan = true +} + func TestGetSpotKline(t *testing.T) { t.Parallel() _, err := h.GetSpotKline(KlinesRequestParams{ @@ -603,3 +620,50 @@ func TestGetDepositAddress(t *testing.T) { t.Error("Test Failed - GetDepositAddress() error cannot be nil") } } + +// TestWsGetAccountsList connects to WS, logs in, gets account list +func TestWsGetAccountsList(t *testing.T) { + setupWsTests(t) + h.wsGetAccountsList(currency.NewPairFromString("ethbtc")) + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case response := <-h.Websocket.DataHandler: + switch respType := response.(type) { + case WsAuthenticatedAccountsListResponse: + if respType.ErrorCode > 0 { + t.Error(respType) + } + case error: + t.Error(respType) + } + case <-timer.C: + t.Error("Websocket did not receive a response") + } + timer.Stop() +} + +// TestWsGetOrderList connects to WS, logs in, gets order list +func TestWsGetOrderList(t *testing.T) { + setupWsTests(t) + h.wsGetOrdersList(1, currency.NewPairFromString("ethbtc")) + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case <-h.Websocket.DataHandler: + case <-timer.C: + t.Error("Websocket did not receive a response") + } + timer.Stop() +} + +// TestWsGetOrderDetails connects to WS, logs in, gets order details +func TestWsGetOrderDetails(t *testing.T) { + setupWsTests(t) + h.wsGetOrderDetails("123") + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case <-h.Websocket.DataHandler: + case <-timer.C: + t.Error("Websocket did not receive a response") + } + timer.Stop() +} diff --git a/exchanges/huobihadax/huobihadax_types.go b/exchanges/huobihadax/huobihadax_types.go index c533c317..20ee992b 100644 --- a/exchanges/huobihadax/huobihadax_types.go +++ b/exchanges/huobihadax/huobihadax_types.go @@ -1,5 +1,9 @@ package huobihadax +import ( + "github.com/thrasher-/gocryptotrader/currency" +) + // Response stores the Huobi response information type Response struct { Status string `json:"status"` @@ -263,13 +267,13 @@ type WsRequest struct { // WsResponse defines a response from the websocket connection when there // is an error type WsResponse struct { - TS int64 `json:"ts"` - Status string `json:"status"` - ErrorCode string `json:"err-code"` - ErrorMessage string `json:"err-msg"` - Ping int64 `json:"ping"` - Channel string `json:"ch"` - Subscribed string `json:"subbed"` + TS int64 `json:"ts"` + Status string `json:"status"` + ErrorCode interface{} `json:"err-code"` + ErrorMessage string `json:"err-msg"` + Ping int64 `json:"ping"` + Channel string `json:"ch"` + Subscribed string `json:"subbed"` } // WsHeartBeat defines a heartbeat request @@ -315,9 +319,201 @@ type WsTrade struct { Data []struct { Amount float64 `json:"amount"` Timestamp int64 `json:"ts"` - ID float64 `json:"id,string"` + ID float64 `json:"id"` Price float64 `json:"price"` Direction string `json:"direction"` } `json:"data"` } } + +// WsAuthenticationRequest data for login +type WsAuthenticationRequest struct { + Op string `json:"op"` + AccessKeyID string `json:"AccessKeyId"` + SignatureMethod string `json:"SignatureMethod"` + SignatureVersion string `json:"SignatureVersion"` + Timestamp string `json:"Timestamp"` + Signature string `json:"Signature"` +} + +// WsMessage defines read data from the websocket connection +type WsMessage struct { + Raw []byte + URL string +} + +// WsAuthenticatedSubscriptionRequest request for subscription on authenticated connection +type WsAuthenticatedSubscriptionRequest struct { + Op string `json:"op"` + AccessKeyID string `json:"AccessKeyId"` + SignatureMethod string `json:"SignatureMethod"` + SignatureVersion string `json:"SignatureVersion"` + Timestamp string `json:"Timestamp"` + Signature string `json:"Signature"` + Topic string `json:"topic"` +} + +// WsAuthenticatedAccountsListRequest request for account list authenticated connection +type WsAuthenticatedAccountsListRequest struct { + Op string `json:"op"` + AccessKeyID string `json:"AccessKeyId"` + SignatureMethod string `json:"SignatureMethod"` + SignatureVersion string `json:"SignatureVersion"` + Timestamp string `json:"Timestamp"` + Signature string `json:"Signature"` + Topic string `json:"topic"` + Symbol currency.Pair `json:"symbol"` +} + +// WsAuthenticatedOrderDetailsRequest request for order details authenticated connection +type WsAuthenticatedOrderDetailsRequest struct { + Op string `json:"op"` + AccessKeyID string `json:"AccessKeyId"` + SignatureMethod string `json:"SignatureMethod"` + SignatureVersion string `json:"SignatureVersion"` + Timestamp string `json:"Timestamp"` + Signature string `json:"Signature"` + Topic string `json:"topic"` + OrderID string `json:"order-id"` +} + +// WsAuthenticatedOrdersListRequest request for orderslist authenticated connection +type WsAuthenticatedOrdersListRequest struct { + Op string `json:"op"` + AccessKeyID string `json:"AccessKeyId"` + SignatureMethod string `json:"SignatureMethod"` + SignatureVersion string `json:"SignatureVersion"` + Timestamp string `json:"Timestamp"` + Signature string `json:"Signature"` + Topic string `json:"topic"` + States string `json:"states"` + AccountID int64 `json:"account-id"` + Symbol currency.Pair `json:"symbol"` +} + +// WsAuthenticatedDataResponse response from authenticated connection +type WsAuthenticatedDataResponse struct { + Op string `json:"op,omitempty"` + Ts int64 `json:"ts,omitempty"` + Topic string `json:"topic,omitempty"` + ErrorCode int64 `json:"err-code,omitempty"` + ErrorMessage string `json:"err-msg,omitempty"` + Ping int64 `json:"ping,omitempty"` + CID string `json:"cid,omitempty"` +} + +// WsAuthenticatedAccountsResponse response from Accounts authenticated subscription +type WsAuthenticatedAccountsResponse struct { + WsAuthenticatedDataResponse + Data WsAuthenticatedAccountsResponseData `json:"data"` +} + +// WsAuthenticatedAccountsResponseData account data +type WsAuthenticatedAccountsResponseData struct { + Event string `json:"event"` + List []WsAuthenticatedAccountsResponseDataList `json:"list"` +} + +// WsAuthenticatedAccountsResponseDataList detailed account data +type WsAuthenticatedAccountsResponseDataList struct { + AccountID int64 `json:"account-id"` + Currency string `json:"currency"` + Type string `json:"type"` + Balance float64 `json:"balance,string"` +} + +// WsAuthenticatedOrdersUpdateResponse response from OrdersUpdate authenticated subscription +type WsAuthenticatedOrdersUpdateResponse struct { + WsAuthenticatedDataResponse + Data WsAuthenticatedOrdersUpdateResponseData `json:"data"` +} + +// WsAuthenticatedOrdersUpdateResponseData order updatedata +type WsAuthenticatedOrdersUpdateResponseData struct { + UnfilledAmount float64 `json:"unfilled-amount,string"` + FilledAmount float64 `json:"filled-amount,string"` + Price float64 `json:"price,string"` + OrderID int64 `json:"order-id"` + Symbol currency.Pair `json:"symbol"` + MatchID int64 `json:"match-id"` + FilledCashAmount float64 `json:"filled-cash-amount,string"` + Role string `json:"role"` + OrderState string `json:"order-state"` +} + +// WsAuthenticatedOrdersResponse response from Orders authenticated subscription +type WsAuthenticatedOrdersResponse struct { + WsAuthenticatedDataResponse + Data []WsAuthenticatedOrdersResponseData `json:"data"` +} + +// WsAuthenticatedOrdersResponseData order data +type WsAuthenticatedOrdersResponseData struct { + SeqID int64 `json:"seq-id"` + OrderID int64 `json:"order-id"` + Symbol currency.Pair `json:"symbol"` + AccountID int64 `json:"account-id"` + OrderAmount float64 `json:"order-amount,string"` + OrderPrice float64 `json:"order-price,string"` + CreatedAt int64 `json:"created-at"` + OrderType string `json:"order-type"` + OrderSource string `json:"order-source"` + OrderState string `json:"order-state"` + Role string `json:"role"` + Price float64 `json:"price,string"` + FilledAmount float64 `json:"filled-amount,string"` + UnfilledAmount float64 `json:"unfilled-amount,string"` + FilledCashAmount float64 `json:"filled-cash-amount,string"` + FilledFees float64 `json:"filled-fees,string"` +} + +// WsAuthenticatedAccountsListResponse response from AccountsList authenticated endpoint +type WsAuthenticatedAccountsListResponse struct { + WsAuthenticatedDataResponse + Data []WsAuthenticatedAccountsListResponseData `json:"data"` +} + +// WsAuthenticatedAccountsListResponseData account data +type WsAuthenticatedAccountsListResponseData struct { + ID int64 `json:"id"` + Type string `json:"type"` + State string `json:"state"` + List []WsAuthenticatedAccountsListResponseDataList `json:"list"` +} + +// WsAuthenticatedAccountsListResponseDataList detailed account data +type WsAuthenticatedAccountsListResponseDataList struct { + Currency string `json:"currency"` + Type string `json:"type"` + Balance float64 `json:"balance,string"` +} + +// WsAuthenticatedOrdersListResponse response from OrdersList authenticated endpoint +type WsAuthenticatedOrdersListResponse struct { + WsAuthenticatedDataResponse + Data []WsAuthenticatedOrdersListResponseData `json:"data"` +} + +// WsAuthenticatedOrdersListResponseData contains order details +type WsAuthenticatedOrdersListResponseData struct { + ID int64 `json:"id"` + Symbol currency.Pair `json:"symbol"` + AccountID int64 `json:"account-id"` + Amount float64 `json:"amount,string"` + Price float64 `json:"price,string"` + CreatedAt int64 `json:"created-at"` + Type string `json:"type"` + FilledAmount float64 `json:"filled-amount,string"` + FilledCashAmount float64 `json:"filled-cash-amount,string"` + FilledFees float64 `json:"filled-fees,string"` + FinishedAt int64 `json:"finished-at"` + Source string `json:"source"` + State string `json:"state"` + CanceledAt int64 `json:"canceled-at"` +} + +// WsAuthenticatedOrderDetailResponse response from OrderDetail authenticated endpoint +type WsAuthenticatedOrderDetailResponse struct { + WsAuthenticatedDataResponse + Data WsAuthenticatedOrdersListResponseData `json:"data"` +} diff --git a/exchanges/huobihadax/huobihadax_websocket.go b/exchanges/huobihadax/huobihadax_websocket.go index b3fcdbe5..79e41a9d 100644 --- a/exchanges/huobihadax/huobihadax_websocket.go +++ b/exchanges/huobihadax/huobihadax_websocket.go @@ -8,6 +8,7 @@ import ( "io/ioutil" "net/http" "net/url" + "strings" "time" "github.com/gorilla/websocket" @@ -18,15 +19,34 @@ import ( log "github.com/thrasher-/gocryptotrader/logger" ) +// WS URL values const ( - huobiGlobalWebsocketEndpoint = "wss://api.huobi.pro/ws" - huobiGlobalAssetWebsocketEndpoint = "wss://api.huobi.pro/ws/v1" - huobiGlobalContractWebsocketEndpoint = "wss://www.hbdm.com/ws" - wsMarketKline = "market.%s.kline.1min" - wsMarketDepth = "market.%s.depth.step0" - wsMarketTrade = "market.%s.trade.detail" + HuobiHadaxSocketIOAddress = "wss://api.hadax.com/ws" + wsMarketKline = "market.%s.kline.1min" + wsMarketDepth = "market.%s.depth.step0" + wsMarketTrade = "market.%s.trade.detail" + + wsAccountsOrdersBaseURL = "wss://api.huobi.pro" + wsAccountsOrdersEndPoint = "/ws/v1" + wsAccountsList = "accounts.list" + wsOrdersList = "orders.list" + wsOrdersDetail = "orders.detail" + wsAccountsOrdersURL = wsAccountsOrdersBaseURL + wsAccountsOrdersEndPoint + wsAccountListEndpoint = wsAccountsOrdersEndPoint + "/" + wsAccountsList + wsOrdersListEndpoint = wsAccountsOrdersEndPoint + "/" + wsOrdersList + wsOrdersDetailEndpoint = wsAccountsOrdersEndPoint + "/" + wsOrdersDetail + + wsDateTimeFormatting = "2006-01-02T15:04:05" + + signatureMethod = "HmacSHA256" + signatureVersion = "2" + requestOp = "req" + authOp = "auth" ) +// Instantiates a communications channel between websocket connections +var comms = make(chan WsMessage, 1) + // WsConnect initiates a new websocket connection func (h *HUOBIHADAX) WsConnect() error { if !h.Websocket.IsEnabled() || !h.IsEnabled() { @@ -44,141 +64,264 @@ func (h *HUOBIHADAX) WsConnect() error { dialer.Proxy = http.ProxyURL(proxy) } - var err error - h.WebsocketConn, _, err = dialer.Dial(h.Websocket.GetWebsocketURL(), http.Header{}) + err := h.wsDial(&dialer) if err != nil { return err } + err = h.wsAuthenticatedDial(&dialer) + if err != nil { + log.Errorf("%v - authenticated dial failed: %v", h.Name, err) + } + err = h.wsLogin() + if err != nil { + log.Errorf("%v - authentication failed: %v", h.Name, err) + } go h.WsHandleData() - h.GenerateDefaultSubscriptions() + return nil } -// WsReadData reads data from the websocket connection -func (h *HUOBIHADAX) WsReadData() (exchange.WebsocketResponse, error) { - _, resp, err := h.WebsocketConn.ReadMessage() +func (h *HUOBIHADAX) wsDial(dialer *websocket.Dialer) error { + var err error + var conStatus *http.Response + h.WebsocketConn, conStatus, err = dialer.Dial(HuobiHadaxSocketIOAddress, http.Header{}) if err != nil { - return exchange.WebsocketResponse{}, err + return fmt.Errorf("%v %v %v Error: %v", HuobiHadaxSocketIOAddress, conStatus, conStatus.StatusCode, err) } + go h.wsMultiConnectionFunnel(h.WebsocketConn, HuobiHadaxSocketIOAddress) + return nil +} - h.Websocket.TrafficAlert <- struct{}{} - - b := bytes.NewReader(resp) - gReader, err := gzip.NewReader(b) +func (h *HUOBIHADAX) wsAuthenticatedDial(dialer *websocket.Dialer) error { + if !h.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", h.Name) + } + var err error + var conStatus *http.Response + h.AuthenticatedWebsocketConn, conStatus, err = dialer.Dial(wsAccountsOrdersURL, http.Header{}) if err != nil { - return exchange.WebsocketResponse{}, err + return fmt.Errorf("%v %v %v Error: %v", wsAccountsOrdersURL, conStatus, conStatus.StatusCode, err) } + go h.wsMultiConnectionFunnel(h.AuthenticatedWebsocketConn, wsAccountsOrdersURL) + return nil +} - unzipped, err := ioutil.ReadAll(gReader) - if err != nil { - return exchange.WebsocketResponse{}, err +// wsMultiConnectionFunnel manages data from multiple endpoints and passes it to a channel +func (h *HUOBIHADAX) wsMultiConnectionFunnel(ws *websocket.Conn, url string) { + h.Websocket.Wg.Add(1) + defer h.Websocket.Wg.Done() + for { + select { + case <-h.Websocket.ShutdownC: + return + default: + _, resp, err := ws.ReadMessage() + if err != nil { + h.Websocket.DataHandler <- err + return + } + h.Websocket.TrafficAlert <- struct{}{} + b := bytes.NewReader(resp) + gReader, err := gzip.NewReader(b) + if err != nil { + h.Websocket.DataHandler <- err + return + } + unzipped, err := ioutil.ReadAll(gReader) + if err != nil { + h.Websocket.DataHandler <- err + return + } + err = gReader.Close() + if err != nil { + h.Websocket.DataHandler <- err + return + } + comms <- WsMessage{Raw: unzipped, URL: url} + } } - gReader.Close() - - return exchange.WebsocketResponse{Raw: unzipped}, nil } // WsHandleData handles data read from the websocket connection func (h *HUOBIHADAX) WsHandleData() { h.Websocket.Wg.Add(1) - - defer func() { - h.Websocket.Wg.Done() - }() - + defer h.Websocket.Wg.Done() for { select { case <-h.Websocket.ShutdownC: return - - default: - resp, err := h.WsReadData() - if err != nil { - h.Websocket.DataHandler <- err - return + case resp := <-comms: + if h.Verbose { + log.Debugf("%v: %v: %v", h.Name, resp.URL, string(resp.Raw)) } - - var init WsResponse - err = common.JSONDecode(resp.Raw, &init) - if err != nil { - h.Websocket.DataHandler <- err - continue + switch resp.URL { + case HuobiHadaxSocketIOAddress: + h.wsHandleMarketData(resp) + case wsAccountsOrdersURL: + h.wsHandleAuthenticatedData(resp) } + } + } +} - if init.Status == "error" { - h.Websocket.DataHandler <- fmt.Errorf("huobi.go Websocker error %s %s", - init.ErrorCode, - init.ErrorMessage) - continue - } +func (h *HUOBIHADAX) wsHandleAuthenticatedData(resp WsMessage) { + var init WsAuthenticatedDataResponse + err := common.JSONDecode(resp.Raw, &init) + if err != nil { + h.Websocket.DataHandler <- err + return + } + if init.ErrorCode > 0 { + if init.ErrorMessage == "api-signature-not-valid" { + h.Websocket.SetCanUseAuthenticatedEndpoints(false) + } + h.Websocket.DataHandler <- fmt.Errorf("%v %v Websocket error %v %s", + h.Name, + resp.URL, + init.ErrorCode, + init.ErrorMessage) + return + } + if init.Ping != 0 { + err = h.WebsocketConn.WriteJSON(`{"pong":1337}`) + if err != nil { + log.Error(err) + } + return + } - if init.Subscribed != "" { - continue - } + if init.Op == "sub" { + if h.Verbose { + log.Debugf("%v: %v: Successfully subscribed to %v", h.Name, resp.URL, init.Topic) + } + return + } - if init.Ping != 0 { - err = h.WebsocketConn.WriteJSON(`{"pong":1337}`) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - continue - } + switch { + case strings.EqualFold(init.Op, authOp): + h.Websocket.SetCanUseAuthenticatedEndpoints(true) + var response WsAuthenticatedDataResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case strings.EqualFold(init.Topic, "accounts"): + var response WsAuthenticatedAccountsResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case common.StringContains(init.Topic, "orders") && + common.StringContains(init.Topic, "update"): + var response WsAuthenticatedOrdersUpdateResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case common.StringContains(init.Topic, "orders"): + var response WsAuthenticatedOrdersResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case strings.EqualFold(init.Topic, wsAccountsList): + var response WsAuthenticatedAccountsListResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case strings.EqualFold(init.Topic, wsOrdersList): + var response WsAuthenticatedOrdersListResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case strings.EqualFold(init.Topic, wsOrdersDetail): + var response WsAuthenticatedOrderDetailResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + } +} - switch { - case common.StringContains(init.Channel, "depth"): - var depth WsDepth - err := common.JSONDecode(resp.Raw, &depth) - if err != nil { - h.Websocket.DataHandler <- err - continue - } +func (h *HUOBIHADAX) wsHandleMarketData(resp WsMessage) { + var init WsResponse + err := common.JSONDecode(resp.Raw, &init) + if err != nil { + h.Websocket.DataHandler <- err + return + } + if init.Status == "error" { + h.Websocket.DataHandler <- fmt.Errorf("%v %v Websocket error %s %s", + h.Name, + resp.URL, + init.ErrorCode, + init.ErrorMessage) + return + } + if init.Subscribed != "" { + return + } + if init.Ping != 0 { + err = h.WebsocketConn.WriteJSON(`{"pong":1337}`) + if err != nil { + log.Error(err) + } + return + } - data := common.SplitStrings(depth.Channel, ".") - - h.WsProcessOrderbook(&depth, data[1]) - - case common.StringContains(init.Channel, "kline"): - var kline WsKline - err := common.JSONDecode(resp.Raw, &kline) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - data := common.SplitStrings(kline.Channel, ".") - - h.Websocket.DataHandler <- exchange.KlineData{ - Timestamp: time.Unix(0, kline.Timestamp), - Exchange: h.GetName(), - AssetType: "SPOT", - Pair: currency.NewPairFromString(data[1]), - OpenPrice: kline.Tick.Open, - ClosePrice: kline.Tick.Close, - HighPrice: kline.Tick.High, - LowPrice: kline.Tick.Low, - Volume: kline.Tick.Volume, - } - - case common.StringContains(init.Channel, "trade"): - var trade WsTrade - err := common.JSONDecode(resp.Raw, &trade) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - data := common.SplitStrings(trade.Channel, ".") - - h.Websocket.DataHandler <- exchange.TradeData{ - Exchange: h.GetName(), - AssetType: "SPOT", - CurrencyPair: currency.NewPairFromString(data[1]), - Timestamp: time.Unix(0, trade.Tick.Timestamp), - } - } + switch { + case common.StringContains(init.Channel, "depth"): + var depth WsDepth + err := common.JSONDecode(resp.Raw, &depth) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := common.SplitStrings(depth.Channel, ".") + h.WsProcessOrderbook(&depth, data[1]) + case common.StringContains(init.Channel, "kline"): + var kline WsKline + err := common.JSONDecode(resp.Raw, &kline) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := common.SplitStrings(kline.Channel, ".") + h.Websocket.DataHandler <- exchange.KlineData{ + Timestamp: time.Unix(0, kline.Timestamp), + Exchange: h.GetName(), + AssetType: "SPOT", + Pair: currency.NewPairFromString(data[1]), + OpenPrice: kline.Tick.Open, + ClosePrice: kline.Tick.Close, + HighPrice: kline.Tick.High, + LowPrice: kline.Tick.Low, + Volume: kline.Tick.Volume, + } + case common.StringContains(init.Channel, "trade"): + var trade WsTrade + err := common.JSONDecode(resp.Raw, &trade) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := common.SplitStrings(trade.Channel, ".") + h.Websocket.DataHandler <- exchange.TradeData{ + Exchange: h.GetName(), + AssetType: "SPOT", + CurrencyPair: currency.NewPairFromString(data[1]), + Timestamp: time.Unix(0, trade.Tick.Timestamp), } } } @@ -223,8 +366,14 @@ func (h *HUOBIHADAX) WsProcessOrderbook(ob *WsDepth, symbol string) error { // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (h *HUOBIHADAX) GenerateDefaultSubscriptions() { var channels = []string{wsMarketKline, wsMarketDepth, wsMarketTrade} + var subscriptions []exchange.WebsocketChannelSubscription + if h.Websocket.CanUseAuthenticatedEndpoints() { + channels = append(channels, "orders.%v", "orders.%v.update") + subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ + Channel: "accounts", + }) + } enabledCurrencies := h.GetEnabledCurrencies() - subscriptions := []exchange.WebsocketChannelSubscription{} for i := range channels { for j := range enabledCurrencies { enabledCurrencies[j].Delimiter = "" @@ -240,6 +389,10 @@ func (h *HUOBIHADAX) GenerateDefaultSubscriptions() { // Subscribe sends a websocket message to receive data from the channel func (h *HUOBIHADAX) Subscribe(channelToSubscribe exchange.WebsocketChannelSubscription) error { + if common.StringContains(channelToSubscribe.Channel, "orders.") || + common.StringContains(channelToSubscribe.Channel, "accounts") { + return h.wsAuthenticatedSubscribe("sub", wsAccountsOrdersEndPoint+channelToSubscribe.Channel, channelToSubscribe.Channel) + } subscription, err := common.JSONEncode(WsRequest{Subscribe: channelToSubscribe.Channel}) if err != nil { return err @@ -249,6 +402,10 @@ func (h *HUOBIHADAX) Subscribe(channelToSubscribe exchange.WebsocketChannelSubsc // Unsubscribe sends a websocket message to stop receiving data from the channel func (h *HUOBIHADAX) Unsubscribe(channelToSubscribe exchange.WebsocketChannelSubscription) error { + if common.StringContains(channelToSubscribe.Channel, "orders.") || + common.StringContains(channelToSubscribe.Channel, "accounts") { + return h.wsAuthenticatedSubscribe("unsub", wsAccountsOrdersEndPoint+channelToSubscribe.Channel, channelToSubscribe.Channel) + } subscription, err := common.JSONEncode(WsRequest{Unsubscribe: channelToSubscribe.Channel}) if err != nil { return err @@ -265,3 +422,125 @@ func (h *HUOBIHADAX) wsSend(data []byte) error { } return h.WebsocketConn.WriteMessage(websocket.TextMessage, data) } + +func (h *HUOBIHADAX) wsLogin() error { + if !h.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", h.Name) + } + h.Websocket.SetCanUseAuthenticatedEndpoints(true) + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticationRequest{ + Op: authOp, + AccessKeyID: h.APIKey, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + } + hmac := h.wsGenerateSignature(timestamp, wsAccountsOrdersEndPoint) + request.Signature = common.Base64Encode(hmac) + err := h.wsAuthenticatedSend(request) + if err != nil { + h.Websocket.SetCanUseAuthenticatedEndpoints(false) + return err + } + return nil +} + +func (h *HUOBIHADAX) wsAuthenticatedSend(request interface{}) error { + h.wsRequestMtx.Lock() + defer h.wsRequestMtx.Unlock() + encodedRequest, err := common.JSONEncode(request) + if err != nil { + return err + } + if h.Verbose { + log.Debugf("%v sending Authenticated message to websocket %s", h.Name, string(encodedRequest)) + } + return h.AuthenticatedWebsocketConn.WriteMessage(websocket.TextMessage, encodedRequest) +} + +func (h *HUOBIHADAX) wsGenerateSignature(timestamp, endpoint string) []byte { + values := url.Values{} + values.Set("AccessKeyId", h.APIKey) + values.Set("SignatureMethod", signatureMethod) + values.Set("SignatureVersion", signatureVersion) + values.Set("Timestamp", timestamp) + host := "api.huobi.pro" + payload := fmt.Sprintf("%s\n%s\n%s\n%s", + "GET", host, endpoint, values.Encode()) + return common.GetHMAC(common.HashSHA256, []byte(payload), []byte(h.APISecret)) +} + +func (h *HUOBIHADAX) wsAuthenticatedSubscribe(operation, endpoint, topic string) error { + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticatedSubscriptionRequest{ + Op: operation, + AccessKeyID: h.APIKey, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: topic, + } + hmac := h.wsGenerateSignature(timestamp, endpoint) + request.Signature = common.Base64Encode(hmac) + return h.wsAuthenticatedSend(request) +} + +func (h *HUOBIHADAX) wsGetAccountsList(pair currency.Pair) error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated cannot get accounts list", h.Name) + } + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticatedAccountsListRequest{ + Op: requestOp, + AccessKeyID: h.APIKey, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsAccountsList, + Symbol: pair, + } + hmac := h.wsGenerateSignature(timestamp, wsAccountListEndpoint) + request.Signature = common.Base64Encode(hmac) + return h.wsAuthenticatedSend(request) +} + +func (h *HUOBIHADAX) wsGetOrdersList(accountID int64, pair currency.Pair) error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated cannot get orders list", h.Name) + } + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticatedOrdersListRequest{ + Op: requestOp, + AccessKeyID: h.APIKey, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsOrdersList, + AccountID: accountID, + Symbol: pair.Lower(), + States: "submitted,partial-filled", + } + hmac := h.wsGenerateSignature(timestamp, wsOrdersListEndpoint) + request.Signature = common.Base64Encode(hmac) + return h.wsAuthenticatedSend(request) +} + +func (h *HUOBIHADAX) wsGetOrderDetails(orderID string) error { + if !h.Websocket.CanUseAuthenticatedEndpoints() { + return fmt.Errorf("%v not authenticated cannot get order details", h.Name) + } + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticatedOrderDetailsRequest{ + Op: requestOp, + AccessKeyID: h.APIKey, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsOrdersDetail, + OrderID: orderID, + } + hmac := h.wsGenerateSignature(timestamp, wsOrdersDetailEndpoint) + request.Signature = common.Base64Encode(hmac) + return h.wsAuthenticatedSend(request) +} diff --git a/exchanges/huobihadax/huobihadax_wrapper.go b/exchanges/huobihadax/huobihadax_wrapper.go index 59ea1c10..fd7057cf 100644 --- a/exchanges/huobihadax/huobihadax_wrapper.go +++ b/exchanges/huobihadax/huobihadax_wrapper.go @@ -462,3 +462,13 @@ func (h *HUOBIHADAX) UnsubscribeToWebsocketChannels(channels []exchange.Websocke h.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (h *HUOBIHADAX) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return h.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (h *HUOBIHADAX) AuthenticateWebsocket() error { + return h.wsLogin() +} diff --git a/exchanges/itbit/itbit_wrapper.go b/exchanges/itbit/itbit_wrapper.go index 8af6facb..be36d7f5 100644 --- a/exchanges/itbit/itbit_wrapper.go +++ b/exchanges/itbit/itbit_wrapper.go @@ -418,3 +418,13 @@ func (i *ItBit) SubscribeToWebsocketChannels(channels []exchange.WebsocketChanne func (i *ItBit) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (i *ItBit) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (i *ItBit) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/kraken/kraken_test.go b/exchanges/kraken/kraken_test.go index 767a5e1c..06740c05 100644 --- a/exchanges/kraken/kraken_test.go +++ b/exchanges/kraken/kraken_test.go @@ -9,6 +9,7 @@ import ( "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) var k Kraken @@ -656,7 +657,7 @@ func TestOrderbookBufferReset(t *testing.T) { for i := 1; i < orderbookBufferLimit+2; i++ { obUpdates = append(obUpdates, fmt.Sprintf(`[0,{"a":[["5541.30000","2.50700000","%v"]],"b":[["5541.30000","1.00000000","%v"]]}]`, i, i)) } - k.Websocket.DataHandler = make(chan interface{}, 10) + k.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() var dataResponse WebsocketDataResponse err := common.JSONDecode([]byte(obpartial), &dataResponse) if err != nil { @@ -705,7 +706,7 @@ func TestOrderBookOutOfOrder(t *testing.T) { obupdate1 := `[0,{"a":[["5541.30000","0.00000000","1"]],"b":[["5541.30000","0.00000000","3"]]}]` obupdate2 := `[0,{"a":[["5541.30000","2.50700000","2"]],"b":[["5541.30000","0.00000000","1"]]}]` - k.Websocket.DataHandler = make(chan interface{}, 10) + k.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() var dataResponse WebsocketDataResponse err := common.JSONDecode([]byte(obpartial), &dataResponse) if err != nil { diff --git a/exchanges/kraken/kraken_websocket.go b/exchanges/kraken/kraken_websocket.go index f064cd65..2126f192 100644 --- a/exchanges/kraken/kraken_websocket.go +++ b/exchanges/kraken/kraken_websocket.go @@ -751,7 +751,7 @@ func (k *Kraken) wsProcessCandles(channelData *WebsocketChannelData, data interf // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (k *Kraken) GenerateDefaultSubscriptions() { enabledCurrencies := k.GetEnabledCurrencies() - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription for i := range defaultSubscribedChannels { for j := range enabledCurrencies { enabledCurrencies[j].Delimiter = "/" diff --git a/exchanges/kraken/kraken_wrapper.go b/exchanges/kraken/kraken_wrapper.go index c359427e..c864a980 100644 --- a/exchanges/kraken/kraken_wrapper.go +++ b/exchanges/kraken/kraken_wrapper.go @@ -408,3 +408,13 @@ func (k *Kraken) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketCha k.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (k *Kraken) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return k.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (k *Kraken) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/lakebtc/lakebtc_wrapper.go b/exchanges/lakebtc/lakebtc_wrapper.go index 33061c81..987f077c 100644 --- a/exchanges/lakebtc/lakebtc_wrapper.go +++ b/exchanges/lakebtc/lakebtc_wrapper.go @@ -360,3 +360,13 @@ func (l *LakeBTC) SubscribeToWebsocketChannels(channels []exchange.WebsocketChan func (l *LakeBTC) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (l *LakeBTC) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (l *LakeBTC) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/localbitcoins/localbitcoins_wrapper.go b/exchanges/localbitcoins/localbitcoins_wrapper.go index 8f27977a..aa869a6f 100644 --- a/exchanges/localbitcoins/localbitcoins_wrapper.go +++ b/exchanges/localbitcoins/localbitcoins_wrapper.go @@ -439,3 +439,13 @@ func (l *LocalBitcoins) SubscribeToWebsocketChannels(channels []exchange.Websock func (l *LocalBitcoins) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (l *LocalBitcoins) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (l *LocalBitcoins) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/okcoin/okcoin.go b/exchanges/okcoin/okcoin.go index bc0a2572..763942b3 100644 --- a/exchanges/okcoin/okcoin.go +++ b/exchanges/okcoin/okcoin.go @@ -56,5 +56,6 @@ func (o *OKCoin) SetDefaults() { exchange.WebsocketKlineSupported | exchange.WebsocketOrderbookSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported } diff --git a/exchanges/okcoin/okcoin_test.go b/exchanges/okcoin/okcoin_test.go index d7470cd8..28d18f8f 100644 --- a/exchanges/okcoin/okcoin_test.go +++ b/exchanges/okcoin/okcoin_test.go @@ -13,6 +13,7 @@ import ( "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/okgroup" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) // Please supply you own test keys here for due diligence testing. @@ -69,13 +70,15 @@ func TestSetup(t *testing.T) { } okcoinConfig.AuthenticatedAPISupport = true + okcoinConfig.AuthenticatedWebsocketAPISupport = true okcoinConfig.APIKey = apiKey okcoinConfig.APISecret = apiSecret okcoinConfig.ClientID = passphrase okcoinConfig.WebsocketURL = o.WebsocketURL o.Setup(&okcoinConfig) testSetupRan = true - o.Websocket.DataHandler = make(chan interface{}, 999) + o.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + o.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() } func areTestAPIKeysSet() bool { @@ -800,13 +803,12 @@ func TestGetMarginTransactionDetails(t *testing.T) { // Will log in if credentials are present func TestSendWsMessages(t *testing.T) { TestSetDefaults(t) - if !websocketEnabled { - t.Skip("Websocket not enabled, skipping") + if !o.Websocket.IsEnabled() && !o.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) } var dialer websocket.Dialer var err error var ok bool - o.Websocket.TrafficAlert = make(chan struct{}, 99) o.WebsocketConn, _, err = dialer.Dial(o.Websocket.GetWebsocketURL(), http.Header{}) if err != nil { @@ -830,16 +832,12 @@ func TestSendWsMessages(t *testing.T) { t.Error("Expecting OKEX error - 30040 message: Channel badChannel doesn't exist") } } - - if !areTestAPIKeysSet() { - return - } err = o.WsLogin() if err != nil { t.Error(err) } - response = <-o.Websocket.DataHandler - if err, ok := response.(error); ok && err != nil { + responseTwo := <-o.Websocket.DataHandler + if err, ok := responseTwo.(error); ok && err != nil { t.Error(err) } } diff --git a/exchanges/okex/okex.go b/exchanges/okex/okex.go index 01f17834..428665d7 100644 --- a/exchanges/okex/okex.go +++ b/exchanges/okex/okex.go @@ -81,7 +81,8 @@ func (o *OKEX) SetDefaults() { exchange.WebsocketKlineSupported | exchange.WebsocketOrderbookSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported } // GetFuturesPostions Get the information of all holding positions in futures trading. diff --git a/exchanges/okex/okex_test.go b/exchanges/okex/okex_test.go index 69792eb5..5431252a 100644 --- a/exchanges/okex/okex_test.go +++ b/exchanges/okex/okex_test.go @@ -14,6 +14,7 @@ import ( "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/okgroup" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) // Please supply you own test keys here for due diligence testing. @@ -70,13 +71,15 @@ func TestSetup(t *testing.T) { websocketEnabled = true } okexConfig.AuthenticatedAPISupport = true + okexConfig.AuthenticatedWebsocketAPISupport = true okexConfig.APIKey = apiKey okexConfig.APISecret = apiSecret okexConfig.ClientID = passphrase okexConfig.WebsocketURL = o.WebsocketURL o.Setup(&okexConfig) testSetupRan = true - o.Websocket.DataHandler = make(chan interface{}, 999) + o.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + o.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() } func areTestAPIKeysSet() bool { @@ -1565,13 +1568,12 @@ func TestGetETTSettlementPriceHistory(t *testing.T) { // Will log in if credentials are present func TestSendWsMessages(t *testing.T) { TestSetDefaults(t) - if !websocketEnabled { - t.Skip("Websocket not enabled, skipping") + if !o.Websocket.IsEnabled() && !o.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) } var dialer websocket.Dialer var err error var ok bool - o.Websocket.TrafficAlert = make(chan struct{}, 99) o.WebsocketConn, _, err = dialer.Dial(o.Websocket.GetWebsocketURL(), http.Header{}) if err != nil { @@ -1595,16 +1597,12 @@ func TestSendWsMessages(t *testing.T) { t.Error("Expecting OKEX error - 30040 message: Channel badChannel doesn't exist") } } - - if !areTestAPIKeysSet() { - return - } err = o.WsLogin() if err != nil { t.Error(err) } - response = <-o.Websocket.DataHandler - if err, ok := response.(error); ok && err != nil { + responseTwo := <-o.Websocket.DataHandler + if err, ok := responseTwo.(error); ok && err != nil { t.Error(err) } } diff --git a/exchanges/okgroup/okgroup.go b/exchanges/okgroup/okgroup.go index bc6f63e1..20097be1 100644 --- a/exchanges/okgroup/okgroup.go +++ b/exchanges/okgroup/okgroup.go @@ -110,6 +110,7 @@ func (o *OKGroup) Setup(exch *config.ExchangeConfig) { o.Name = exch.Name o.Enabled = true o.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + o.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport o.SetAPIKeys(exch.APIKey, exch.APISecret, exch.ClientID, false) o.SetHTTPClientTimeout(exch.HTTPTimeout) o.SetHTTPClientUserAgent(exch.HTTPUserAgent) diff --git a/exchanges/okgroup/okgroup_types.go b/exchanges/okgroup/okgroup_types.go index 4230c05f..bf495f52 100644 --- a/exchanges/okgroup/okgroup_types.go +++ b/exchanges/okgroup/okgroup_types.go @@ -1303,7 +1303,8 @@ type WebsocketEventRequest struct { // WebsocketEventResponse contains event data for a websocket channel type WebsocketEventResponse struct { Event string `json:"event"` - Channel string `json:"channel"` + Channel string `json:"channel,omitempty"` + Success bool `json:"success,omitempty"` } // WebsocketDataResponse formats all response data for a websocket event diff --git a/exchanges/okgroup/okgroup_websocket.go b/exchanges/okgroup/okgroup_websocket.go index 84fafe1c..66f7fd12 100644 --- a/exchanges/okgroup/okgroup_websocket.go +++ b/exchanges/okgroup/okgroup_websocket.go @@ -197,8 +197,14 @@ func (o *OKGroup) WsConnect() error { wg.Add(2) go o.WsHandleData(&wg) go o.wsPingHandler(&wg) - o.GenerateDefaultSubscriptions() + if o.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + err = o.WsLogin() + if err != nil { + log.Errorf("%v - authentication failed: %v", o.Name, err) + } + } + o.GenerateDefaultSubscriptions() // Ensures that we start the routines and we dont race when shutdown occurs wg.Wait() return nil @@ -298,10 +304,14 @@ func (o *OKGroup) WsHandleData(wg *sync.WaitGroup) { } var eventResponse WebsocketEventResponse err = common.JSONDecode(resp.Raw, &eventResponse) - if err == nil && len(eventResponse.Channel) > 0 { + if err == nil && eventResponse.Event != "" { + if eventResponse.Event == "login" { + o.Websocket.SetCanUseAuthenticatedEndpoints(eventResponse.Success) + } if o.Verbose { log.Debugf("WS Event: %v on Channel: %v", eventResponse.Event, eventResponse.Channel) } + o.Websocket.DataHandler <- eventResponse continue } } @@ -310,6 +320,7 @@ func (o *OKGroup) WsHandleData(wg *sync.WaitGroup) { // WsLogin sends a login request to websocket to enable access to authenticated endpoints func (o *OKGroup) WsLogin() error { + o.Websocket.SetCanUseAuthenticatedEndpoints(true) utcTime := time.Now().UTC() unixTime := utcTime.Unix() signPath := "/users/self/verify" @@ -321,10 +332,12 @@ func (o *OKGroup) WsLogin() error { } json, err := common.JSONEncode(resp) if err != nil { + o.Websocket.SetCanUseAuthenticatedEndpoints(false) return err } err = o.writeToWebsocket(string(json)) if err != nil { + o.Websocket.SetCanUseAuthenticatedEndpoints(false) return err } return nil @@ -367,6 +380,7 @@ func (o *OKGroup) GetAssetTypeFromTableName(table string) string { // WsHandleDataResponse classifies the WS response and sends to appropriate handler func (o *OKGroup) WsHandleDataResponse(response *WebsocketDataResponse) { switch o.GetWsChannelWithoutOrderType(response.Table) { + case okGroupWsCandle60s, okGroupWsCandle180s, okGroupWsCandle300s, okGroupWsCandle900s, okGroupWsCandle1800s, okGroupWsCandle3600s, okGroupWsCandle7200s, okGroupWsCandle14400s, okGroupWsCandle21600s, okGroupWsCandle43200s, okGroupWsCandle86400s, okGroupWsCandle604900s: @@ -680,7 +694,10 @@ func (o *OKGroup) CalculateUpdateOrderbookChecksum(orderbookData *orderbook.Base // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (o *OKGroup) GenerateDefaultSubscriptions() { enabledCurrencies := o.GetEnabledCurrencies() - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription + if o.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + defaultSubscribedChannels = append(defaultSubscribedChannels, okGroupWsSpotMarginAccount, okGroupWsSpotAccount, okGroupWsSpotOrder) + } for i := range defaultSubscribedChannels { for j := range enabledCurrencies { enabledCurrencies[j].Delimiter = "-" @@ -699,6 +716,10 @@ func (o *OKGroup) Subscribe(channelToSubscribe exchange.WebsocketChannelSubscrip Operation: "subscribe", Arguments: []string{fmt.Sprintf("%v:%v", channelToSubscribe.Channel, channelToSubscribe.Currency.String())}, } + if strings.EqualFold(channelToSubscribe.Channel, okGroupWsSpotAccount) { + resp.Arguments = []string{fmt.Sprintf("%v:%v", channelToSubscribe.Channel, channelToSubscribe.Currency.Base.String())} + } + json, err := common.JSONEncode(resp) if err != nil { if o.Verbose { diff --git a/exchanges/okgroup/okgroup_wrapper.go b/exchanges/okgroup/okgroup_wrapper.go index b0ceb467..7354ea1a 100644 --- a/exchanges/okgroup/okgroup_wrapper.go +++ b/exchanges/okgroup/okgroup_wrapper.go @@ -445,3 +445,13 @@ func (o *OKGroup) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketCh o.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (o *OKGroup) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return o.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (o *OKGroup) AuthenticateWebsocket() error { + return o.WsLogin() +} diff --git a/exchanges/poloniex/poloniex.go b/exchanges/poloniex/poloniex.go index 9a2cf9fa..61d31ddb 100644 --- a/exchanges/poloniex/poloniex.go +++ b/exchanges/poloniex/poloniex.go @@ -93,7 +93,8 @@ func (p *Poloniex) SetDefaults() { exchange.WebsocketOrderbookSupported | exchange.WebsocketTickerSupported | exchange.WebsocketSubscribeSupported | - exchange.WebsocketUnsubscribeSupported + exchange.WebsocketUnsubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported } // Setup sets user exchange configuration settings @@ -103,6 +104,7 @@ func (p *Poloniex) Setup(exch *config.ExchangeConfig) { } else { p.Enabled = true p.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + p.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport p.SetAPIKeys(exch.APIKey, exch.APISecret, "", false) p.SetHTTPClientTimeout(exch.HTTPTimeout) p.SetHTTPClientUserAgent(exch.HTTPUserAgent) diff --git a/exchanges/poloniex/poloniex_test.go b/exchanges/poloniex/poloniex_test.go index 7c80c5f1..2eca506b 100644 --- a/exchanges/poloniex/poloniex_test.go +++ b/exchanges/poloniex/poloniex_test.go @@ -1,18 +1,21 @@ package poloniex import ( + "net/http" "testing" + "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) var p Poloniex // Please supply your own APIKEYS here for due diligence testing - const ( apiKey = "" apiSecret = "" @@ -29,6 +32,7 @@ func TestSetup(t *testing.T) { if err != nil { t.Error("Test Failed - Poloniex Setup() init error") } + poloniexConfig.AuthenticatedWebsocketAPISupport = true poloniexConfig.AuthenticatedAPISupport = true poloniexConfig.APIKey = apiKey poloniexConfig.APISecret = apiSecret @@ -414,3 +418,53 @@ func TestGetDepositAddress(t *testing.T) { } } } + +func TestWsHandleAccountData(t *testing.T) { + t.Parallel() + TestSetup(t) + p.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + jsons := []string{ + `[["n",225,807230187,0,"1000.00000000","0.10000000","2018-11-07 16:42:42"],["b",267,"e","-0.10000000"]]`, + `[["o",807230187,"0.00000000"],["b",267,"e","0.10000000"]]`, + `[["t", 12345, "0.03000000", "0.50000000", "0.00250000", 0, 6083059, "0.00000375", "2018-09-08 05:54:09"]]`, + } + for i := range jsons { + var result [][]interface{} + err := common.JSONDecode([]byte(jsons[i]), &result) + if err != nil { + t.Error(err) + } + p.wsHandleAccountData(result) + } +} + +// TestWsAuth dials websocket, sends login request. +// Will receive a message only on failure +func TestWsAuth(t *testing.T) { + TestSetup(t) + if !p.Websocket.IsEnabled() && !p.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() { + t.Skip(exchange.WebsocketNotEnabled) + } + var err error + var dialer websocket.Dialer + p.WebsocketConn, _, err = dialer.Dial(p.Websocket.GetWebsocketURL(), + http.Header{}) + if err != nil { + t.Fatal(err) + } + p.Websocket.DataHandler = sharedtestvalues.GetWebsocketInterfaceChannelOverride() + p.Websocket.TrafficAlert = sharedtestvalues.GetWebsocketStructChannelOverride() + go p.WsHandleData() + defer p.WebsocketConn.Close() + err = p.wsSendAuthorisedCommand("subscribe") + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case response := <-p.Websocket.DataHandler: + t.Error(response) + case <-timer.C: + } + timer.Stop() +} diff --git a/exchanges/poloniex/poloniex_types.go b/exchanges/poloniex/poloniex_types.go index c4faae61..de84757f 100644 --- a/exchanges/poloniex/poloniex_types.go +++ b/exchanges/poloniex/poloniex_types.go @@ -1,6 +1,10 @@ package poloniex -import "github.com/thrasher-/gocryptotrader/currency" +import ( + "time" + + "github.com/thrasher-/gocryptotrader/currency" +) // Ticker holds ticker data type Ticker struct { @@ -401,3 +405,47 @@ var WithdrawalFees = map[currency.Code]float64{ currency.VIA: 0.01, currency.ZEC: 0.001, } + +// WsAccountBalanceUpdateResponse Authenticated Ws Account data +type WsAccountBalanceUpdateResponse struct { + currencyID float64 + wallet string + amount float64 +} + +// WsNewLimitOrderResponse Authenticated Ws Account data +type WsNewLimitOrderResponse struct { + currencyID float64 + orderNumber float64 + orderType float64 + rate float64 + amount float64 + date time.Time +} + +// WsOrderUpdateResponse Authenticated Ws Account data +type WsOrderUpdateResponse struct { + OrderNumber float64 + NewAmount string +} + +// WsTradeNotificationResponse Authenticated Ws Account data +type WsTradeNotificationResponse struct { + TradeID float64 + Rate float64 + Amount float64 + FeeMultiplier float64 + FundingType float64 + OrderNumber float64 + TotalFee float64 + Date time.Time +} + +// WsAuthorisationRequest Authenticated Ws Account data request +type WsAuthorisationRequest struct { + Command string `json:"command"` + Channel int64 `json:"channel"` + Sign string `json:"sign"` + Key string `json:"key"` + Payload string `json:"payload"` +} diff --git a/exchanges/poloniex/poloniex_websocket.go b/exchanges/poloniex/poloniex_websocket.go index 40e6c83c..7d840723 100644 --- a/exchanges/poloniex/poloniex_websocket.go +++ b/exchanges/poloniex/poloniex_websocket.go @@ -130,39 +130,16 @@ func (p *Poloniex) WsHandleData() { log.Debugf("poloniex websocket subscribed to channel successfully. %d", chanID) } } else { - if p.Verbose { - log.Debugf("poloniex websocket subscription to channel failed. %d", chanID) - } + p.Websocket.DataHandler <- fmt.Errorf("poloniex websocket subscription to channel failed. %d", chanID) } continue } switch chanID { case wsAccountNotificationID: + p.wsHandleAccountData(data[2].([][]interface{})) case wsTickerDataID: - tickerData := data[2].([]interface{}) - var t WsTicker - t.LastPrice, _ = strconv.ParseFloat(tickerData[1].(string), 64) - t.LowestAsk, _ = strconv.ParseFloat(tickerData[2].(string), 64) - t.HighestBid, _ = strconv.ParseFloat(tickerData[3].(string), 64) - t.PercentageChange, _ = strconv.ParseFloat(tickerData[4].(string), 64) - t.BaseCurrencyVolume24H, _ = strconv.ParseFloat(tickerData[5].(string), 64) - t.QuoteCurrencyVolume24H, _ = strconv.ParseFloat(tickerData[6].(string), 64) - isFrozen := false - if tickerData[7].(float64) == 1 { - isFrozen = true - } - t.IsFrozen = isFrozen - t.HighestTradeIn24H, _ = strconv.ParseFloat(tickerData[8].(string), 64) - t.LowestTradePrice24H, _ = strconv.ParseFloat(tickerData[9].(string), 64) - - p.Websocket.DataHandler <- exchange.TickerData{ - Timestamp: time.Now(), - Exchange: p.GetName(), - AssetType: "SPOT", - LowPrice: t.LowestAsk, - HighPrice: t.HighestBid, - } + p.wsHandleTickerData(data) case ws24HourExchangeVolumeID: case wsHeartbeat: default: @@ -243,6 +220,86 @@ func (p *Poloniex) WsHandleData() { } } +func (p *Poloniex) wsHandleTickerData(data []interface{}) { + tickerData := data[2].([]interface{}) + var t WsTicker + t.LastPrice, _ = strconv.ParseFloat(tickerData[1].(string), 64) + t.LowestAsk, _ = strconv.ParseFloat(tickerData[2].(string), 64) + t.HighestBid, _ = strconv.ParseFloat(tickerData[3].(string), 64) + t.PercentageChange, _ = strconv.ParseFloat(tickerData[4].(string), 64) + t.BaseCurrencyVolume24H, _ = strconv.ParseFloat(tickerData[5].(string), 64) + t.QuoteCurrencyVolume24H, _ = strconv.ParseFloat(tickerData[6].(string), 64) + isFrozen := false + if tickerData[7].(float64) == 1 { + isFrozen = true + } + t.IsFrozen = isFrozen + t.HighestTradeIn24H, _ = strconv.ParseFloat(tickerData[8].(string), 64) + t.LowestTradePrice24H, _ = strconv.ParseFloat(tickerData[9].(string), 64) + + p.Websocket.DataHandler <- exchange.TickerData{ + Timestamp: time.Now(), + Exchange: p.GetName(), + AssetType: "SPOT", + LowPrice: t.LowestAsk, + HighPrice: t.HighestBid, + } +} + +// wsHandleAccountData Parses account data and sends to datahandler +func (p *Poloniex) wsHandleAccountData(accountData [][]interface{}) { + for i := range accountData { + switch accountData[i][0].(string) { + case "b": + amount, _ := strconv.ParseFloat(accountData[i][3].(string), 64) + response := WsAccountBalanceUpdateResponse{ + currencyID: accountData[i][1].(float64), + wallet: accountData[i][2].(string), + amount: amount, + } + p.Websocket.DataHandler <- response + case "n": + timeParse, _ := time.Parse("2006-01-02 15:04:05", accountData[i][6].(string)) + rate, _ := strconv.ParseFloat(accountData[i][4].(string), 64) + amount, _ := strconv.ParseFloat(accountData[i][5].(string), 64) + + response := WsNewLimitOrderResponse{ + currencyID: accountData[i][1].(float64), + orderNumber: accountData[i][2].(float64), + orderType: accountData[i][3].(float64), + rate: rate, + amount: amount, + date: timeParse, + } + p.Websocket.DataHandler <- response + case "o": + response := WsOrderUpdateResponse{ + OrderNumber: accountData[i][1].(float64), + NewAmount: accountData[i][2].(string), + } + p.Websocket.DataHandler <- response + case "t": + timeParse, _ := time.Parse("2006-01-02 15:04:05", accountData[i][8].(string)) + rate, _ := strconv.ParseFloat(accountData[i][2].(string), 64) + amount, _ := strconv.ParseFloat(accountData[i][3].(string), 64) + feeMultiplier, _ := strconv.ParseFloat(accountData[i][4].(string), 64) + totalFee, _ := strconv.ParseFloat(accountData[i][7].(string), 64) + + response := WsTradeNotificationResponse{ + TradeID: accountData[i][1].(float64), + Rate: rate, + Amount: amount, + FeeMultiplier: feeMultiplier, + FundingType: accountData[i][5].(float64), + OrderNumber: accountData[i][6].(float64), + TotalFee: totalFee, + Date: timeParse, + } + p.Websocket.DataHandler <- response + } + } +} + // WsProcessOrderbookSnapshot processes a new orderbook snapshot into a local // of orderbooks func (p *Poloniex) WsProcessOrderbookSnapshot(ob []interface{}, symbol string) error { @@ -437,12 +494,18 @@ var CurrencyPairID = map[int]string{ // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (p *Poloniex) GenerateDefaultSubscriptions() { - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription // Tickerdata is its own channel subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ Channel: fmt.Sprintf("%v", wsTickerDataID), }) + if p.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ + Channel: fmt.Sprintf("%v", wsAccountNotificationID), + }) + } + enabledCurrencies := p.GetEnabledCurrencies() for j := range enabledCurrencies { enabledCurrencies[j].Delimiter = "_" @@ -459,9 +522,12 @@ func (p *Poloniex) Subscribe(channelToSubscribe exchange.WebsocketChannelSubscri subscriptionRequest := WsCommand{ Command: "subscribe", } - if strings.EqualFold(fmt.Sprintf("%v", wsTickerDataID), channelToSubscribe.Channel) { + switch { + case strings.EqualFold(fmt.Sprintf("%v", wsAccountNotificationID), channelToSubscribe.Channel): + return p.wsSendAuthorisedCommand("subscribe") + case strings.EqualFold(fmt.Sprintf("%v", wsTickerDataID), channelToSubscribe.Channel): subscriptionRequest.Channel = wsTickerDataID - } else { + default: subscriptionRequest.Channel = channelToSubscribe.Currency.String() } return p.wsSend(subscriptionRequest) @@ -472,9 +538,12 @@ func (p *Poloniex) Unsubscribe(channelToSubscribe exchange.WebsocketChannelSubsc unsubscriptionRequest := WsCommand{ Command: "unsubscribe", } - if strings.EqualFold(fmt.Sprintf("%v", wsTickerDataID), channelToSubscribe.Channel) { + switch { + case strings.EqualFold(fmt.Sprintf("%v", wsAccountNotificationID), channelToSubscribe.Channel): + return p.wsSendAuthorisedCommand("unsubscribe") + case strings.EqualFold(fmt.Sprintf("%v", wsTickerDataID), channelToSubscribe.Channel): unsubscriptionRequest.Channel = wsTickerDataID - } else { + default: unsubscriptionRequest.Channel = channelToSubscribe.Currency.String() } return p.wsSend(unsubscriptionRequest) @@ -493,3 +562,16 @@ func (p *Poloniex) wsSend(data interface{}) error { } return p.WebsocketConn.WriteMessage(websocket.TextMessage, json) } + +func (p *Poloniex) wsSendAuthorisedCommand(command string) error { + nonce := fmt.Sprintf("nonce=%v", time.Now().UnixNano()) + hmac := common.GetHMAC(common.HashSHA512, []byte(nonce), []byte(p.APISecret)) + request := WsAuthorisationRequest{ + Command: command, + Channel: 1000, + Sign: common.HexEncodeToString(hmac), + Key: p.APIKey, + Payload: nonce, + } + return p.wsSend(request) +} diff --git a/exchanges/poloniex/poloniex_wrapper.go b/exchanges/poloniex/poloniex_wrapper.go index 11629b61..6864a5fe 100644 --- a/exchanges/poloniex/poloniex_wrapper.go +++ b/exchanges/poloniex/poloniex_wrapper.go @@ -407,3 +407,13 @@ func (p *Poloniex) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketC p.Websocket.UnsubscribeToChannels(channels) return nil } + +// GetSubscriptions returns a copied list of subscriptions +func (p *Poloniex) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return p.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (p *Poloniex) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/sharedtestvalues/sharedtestvalues.go b/exchanges/sharedtestvalues/sharedtestvalues.go new file mode 100644 index 00000000..e8e1fef4 --- /dev/null +++ b/exchanges/sharedtestvalues/sharedtestvalues.go @@ -0,0 +1,28 @@ +package sharedtestvalues + +import "time" + +// This package is only to be referenced in test files +const ( + // WebsocketResponseDefaultTimeout used in websocket testing + // Defines wait time for receiving websocket response before cancelling + WebsocketResponseDefaultTimeout = (3 * time.Second) + // WebsocketResponseExtendedTimeout used in websocket testing + // Defines wait time for receiving websocket response before cancelling + WebsocketResponseExtendedTimeout = (15 * time.Second) + // WebsocketChannelOverrideCapacity used in websocket testing + // Defines channel capacity as defaults size can block tests + WebsocketChannelOverrideCapacity = 5 +) + +// GetWebsocketInterfaceChannelOverride returns a new interface based channel +// with the capacity set to WebsocketChannelOverrideCapacity +func GetWebsocketInterfaceChannelOverride() chan interface{} { + return make(chan interface{}, WebsocketChannelOverrideCapacity) +} + +// GetWebsocketStructChannelOverride returns a new struct based channel +// with the capacity set to WebsocketChannelOverrideCapacity +func GetWebsocketStructChannelOverride() chan struct{} { + return make(chan struct{}, WebsocketChannelOverrideCapacity) +} diff --git a/exchanges/yobit/yobit_wrapper.go b/exchanges/yobit/yobit_wrapper.go index 3279a93f..88cd1d86 100644 --- a/exchanges/yobit/yobit_wrapper.go +++ b/exchanges/yobit/yobit_wrapper.go @@ -376,3 +376,13 @@ func (y *Yobit) SubscribeToWebsocketChannels(channels []exchange.WebsocketChanne func (y *Yobit) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (y *Yobit) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrFunctionNotSupported +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (y *Yobit) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/exchanges/zb/zb.go b/exchanges/zb/zb.go index 575ed837..aab72d2c 100644 --- a/exchanges/zb/zb.go +++ b/exchanges/zb/zb.go @@ -81,7 +81,11 @@ func (z *ZB) SetDefaults() { z.Websocket.Functionality = exchange.WebsocketTickerSupported | exchange.WebsocketOrderbookSupported | exchange.WebsocketTradeDataSupported | - exchange.WebsocketSubscribeSupported + exchange.WebsocketSubscribeSupported | + exchange.WebsocketAuthenticatedEndpointsSupported | + exchange.WebsocketAccountDataSupported | + exchange.WebsocketCancelOrderSupported | + exchange.WebsocketSubmitOrderSupported } // Setup sets user configuration @@ -91,6 +95,7 @@ func (z *ZB) Setup(exch *config.ExchangeConfig) { } else { z.Enabled = true z.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + z.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport z.SetAPIKeys(exch.APIKey, exch.APISecret, "", false) z.APIAuthPEMKey = exch.APIAuthPEMKey z.SetHTTPClientTimeout(exch.HTTPTimeout) diff --git a/exchanges/zb/zb_test.go b/exchanges/zb/zb_test.go index a60b042a..6bbf5124 100644 --- a/exchanges/zb/zb_test.go +++ b/exchanges/zb/zb_test.go @@ -2,12 +2,16 @@ package zb import ( "fmt" + "net/http" "testing" + "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) // Please supply you own test keys here for due diligence testing. @@ -18,6 +22,7 @@ const ( ) var z ZB +var wsSetupRan bool func TestSetDefaults(t *testing.T) { z.SetDefaults() @@ -31,12 +36,35 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - ZB Setup() init error") } zbConfig.AuthenticatedAPISupport = true + zbConfig.AuthenticatedWebsocketAPISupport = true zbConfig.APIKey = apiKey zbConfig.APISecret = apiSecret z.Setup(&zbConfig) } +func setupWsAuth(t *testing.T) { + if wsSetupRan { + return + } + z.SetDefaults() + TestSetup(t) + if !z.Websocket.IsEnabled() && !z.AuthenticatedWebsocketAPISupport || !areTestAPIKeysSet() || !canManipulateRealOrders { + t.Skip(exchange.WebsocketNotEnabled) + } + var err error + var dialer websocket.Dialer + z.WebsocketConn, _, err = dialer.Dial(z.Websocket.GetWebsocketURL(), + http.Header{}) + if err != nil { + t.Fatal(err) + } + z.Websocket.DataHandler = make(chan interface{}, 11) + z.Websocket.TrafficAlert = make(chan struct{}, 11) + go z.WsHandleData() + wsSetupRan = true +} + func TestSpotNewOrder(t *testing.T) { t.Parallel() @@ -466,3 +494,233 @@ func TestGetDepositAddress(t *testing.T) { } } } + +// TestZBInvalidJSON ZB sends poorly formed JSON. this tests the JSON fixer +// Then JSON decode it to test if successful +func TestZBInvalidJSON(t *testing.T) { + json := `{"success":true,"code":1000,"channel":"getSubUserList","message":"[{"isOpenApi":false,"memo":"Memo","userName":"hello@imgoodthanksandyou.com@good","userId":1337,"isFreez":false}]","no":"0"}` + fixedJSON := z.wsFixInvalidJSON([]byte(json)) + var response WsGetSubUserListResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + t.Log(err) + } + if response.Message[0].UserID != 1337 { + t.Error("Expected extracted JSON USERID to equal 1337") + } + + json = `{"success":true,"code":1000,"channel":"createSubUserKey","message":"{"apiKey":"thisisnotareallykeyyousillybilly","apiSecret":"lol"}","no":"14728151154382111746154"}` + fixedJSON = z.wsFixInvalidJSON([]byte(json)) + var response2 WsRequestResponse + err = common.JSONDecode(fixedJSON, &response2) + if err != nil { + t.Log(err) + } +} + +// TestWsTransferFunds ws test +func TestWsTransferFunds(t *testing.T) { + setupWsAuth(t) + err := z.wsDoTransferFunds(currency.BTC, + 0.0001, + "username1", + "username2", + ) + if err != nil { + t.Error(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-z.Websocket.DataHandler: + if resp.(WsRequestResponse).Code == 1002 || resp.(WsRequestResponse).Code == 1003 { + t.Error("Hash not calculated correctly") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} + +// TestWsCreateSuUserKey ws test +func TestWsCreateSuUserKey(t *testing.T) { + setupWsAuth(t) + z.wsGetSubUserList() + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + var userID int64 + select { + case resp := <-z.Websocket.DataHandler: + if len(resp.(WsGetSubUserListResponse).Message) == 0 { + t.Fatal("Expected a userID. Ensure you have made a subuserID before running this test") + } + userID = resp.(WsGetSubUserListResponse).Message[0].UserID + case <-timer.C: + t.Fatal("Have not received a response") + } + timer.Stop() + err := z.wsCreateSubUserKey(true, true, true, true, "subu", fmt.Sprintf("%v", userID)) + if err != nil { + t.Error(err) + } + timer = time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-z.Websocket.DataHandler: + if resp.(WsRequestResponse).Code == 1002 || resp.(WsRequestResponse).Code == 1003 { + t.Error("Hash not calculated correctly") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} + +// TestGetSubUserList ws test +func TestGetSubUserList(t *testing.T) { + setupWsAuth(t) + err := z.wsGetSubUserList() + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-z.Websocket.DataHandler: + if resp.(WsGetSubUserListResponse).Code == 1002 || resp.(WsGetSubUserListResponse).Code == 1003 { + t.Error("Hash not calculated correctly") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} + +// TestAddSubUser ws test +func TestAddSubUser(t *testing.T) { + setupWsAuth(t) + err := z.wsAddSubUser("abcde", "123456789101112aA!") + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-z.Websocket.DataHandler: + if resp.(WsRequestResponse).Code == 1002 || resp.(WsRequestResponse).Code == 1003 { + t.Error("Hash not calculated correctly") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} + +// TestWsSubmitOrder ws test +func TestWsSubmitOrder(t *testing.T) { + setupWsAuth(t) + err := z.wsSubmitOrder(currency.NewPairWithDelimiter(currency.LTC.String(), currency.BTC.String(), "").Lower(), 1, 1, 1) + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-z.Websocket.DataHandler: + if resp.(WsSubmitOrderResponse).Code == 1002 || resp.(WsSubmitOrderResponse).Code == 1003 { + t.Error("Hash not calculated correctly") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} + +// TestWsCancelOrder ws test +func TestWsCancelOrder(t *testing.T) { + setupWsAuth(t) + err := z.wsCancelOrder(currency.NewPairWithDelimiter(currency.LTC.String(), currency.BTC.String(), "").Lower(), 1234) + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-z.Websocket.DataHandler: + if resp.(WsCancelOrderResponse).Code == 1002 || resp.(WsCancelOrderResponse).Code == 1003 { + t.Error("Hash not calculated correctly") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} + +// TestWsGetAccountInfo ws test +func TestWsGetAccountInfo(t *testing.T) { + setupWsAuth(t) + err := z.wsGetAccountInfoRequest() + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-z.Websocket.DataHandler: + if resp.(WsGetAccountInfoResponse).Code == 1002 || resp.(WsGetAccountInfoResponse).Code == 1003 { + t.Error("Hash not calculated correctly") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} + +// TestWsGetOrder ws test +func TestWsGetOrder(t *testing.T) { + setupWsAuth(t) + err := z.wsGetOrder(currency.NewPairWithDelimiter(currency.LTC.String(), currency.BTC.String(), "").Lower(), 1234) + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-z.Websocket.DataHandler: + if resp.(WsGetOrderResponse).Code == 1002 || resp.(WsGetOrderResponse).Code == 1003 { + t.Error("Hash not calculated correctly") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} + +// TestWsGetOrders ws test +func TestWsGetOrders(t *testing.T) { + setupWsAuth(t) + err := z.wsGetOrders(currency.NewPairWithDelimiter(currency.LTC.String(), currency.BTC.String(), "").Lower(), 1, 1) + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-z.Websocket.DataHandler: + if resp.(WsGetOrdersResponse).Code == 1002 || resp.(WsGetOrdersResponse).Code == 1003 { + t.Error("Hash not calculated correctly") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} + +// TestWsGetOrdersIgnoreTradeType ws test +func TestWsGetOrdersIgnoreTradeType(t *testing.T) { + setupWsAuth(t) + err := z.wsGetOrdersIgnoreTradeType(currency.NewPairWithDelimiter(currency.LTC.String(), currency.BTC.String(), "").Lower(), 1, 1) + if err != nil { + t.Fatal(err) + } + timer := time.NewTimer(sharedtestvalues.WebsocketResponseDefaultTimeout) + select { + case resp := <-z.Websocket.DataHandler: + if resp.(WsGetOrdersResponse).Code == 1002 || resp.(WsGetOrdersResponse).Code == 1003 { + t.Error("Hash not calculated correctly") + } + case <-timer.C: + t.Error("Have not received a response") + } + timer.Stop() +} diff --git a/exchanges/zb/zb_websocket.go b/exchanges/zb/zb_websocket.go index b219cee9..1b420089 100644 --- a/exchanges/zb/zb_websocket.go +++ b/exchanges/zb/zb_websocket.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "net/url" + "regexp" + "strings" "time" "github.com/gorilla/websocket" @@ -16,7 +18,8 @@ import ( ) const ( - zbWebsocketAPI = "wss://api.zb.cn:9999/websocket" + zbWebsocketAPI = "wss://api.zb.cn:9999/websocket" + zWebsocketAddChannel = "addChannel" ) // WsConnect initiates a websocket connection @@ -80,9 +83,9 @@ func (z *ZB) WsHandleData() { time.Sleep(time.Second) continue } - + fixedJSON := z.wsFixInvalidJSON(resp.Raw) var result Generic - err = common.JSONDecode(resp.Raw, &result) + err = common.JSONDecode(fixedJSON, &result) if err != nil { z.Websocket.DataHandler <- err continue @@ -106,7 +109,7 @@ func (z *ZB) WsHandleData() { var ticker WsTicker - err := common.JSONDecode(resp.Raw, &ticker) + err := common.JSONDecode(fixedJSON, &ticker) if err != nil { z.Websocket.DataHandler <- err continue @@ -124,7 +127,7 @@ func (z *ZB) WsHandleData() { case common.StringContains(result.Channel, "depth"): var depth WsDepth - err := common.JSONDecode(resp.Raw, &depth) + err := common.JSONDecode(fixedJSON, &depth) if err != nil { z.Websocket.DataHandler <- err continue @@ -173,13 +176,16 @@ func (z *ZB) WsHandleData() { case common.StringContains(result.Channel, "trades"): var trades WsTrades - err := common.JSONDecode(resp.Raw, &trades) + err := common.JSONDecode(fixedJSON, &trades) if err != nil { z.Websocket.DataHandler <- err continue } // Most up to date trade + if len(trades.Data) == 0 { + continue + } t := trades.Data[len(trades.Data)-1] channelInfo := common.SplitStrings(result.Channel, "_") @@ -195,7 +201,86 @@ func (z *ZB) WsHandleData() { Amount: t.Amount, Side: t.TradeType, } - + case strings.EqualFold(result.Channel, "addSubUser"): + var response WsRequestResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case strings.EqualFold(result.Channel, "getSubUserList"): + var response WsGetSubUserListResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case strings.EqualFold(result.Channel, "doTransferFunds"): + var response WsRequestResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case strings.EqualFold(result.Channel, "createSubUserKey"): + var response WsRequestResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case common.StringContains(result.Channel, "_order"): + var response WsSubmitOrderResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case common.StringContains(result.Channel, "_cancelorder"): + var response WsCancelOrderResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case common.StringContains(result.Channel, "_getorders"): + var response WsGetOrdersResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case common.StringContains(result.Channel, "_getorder"): + var response WsGetOrderResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case common.StringContains(result.Channel, "_getordersignoretradetype"): + var response WsGetOrdersIgnoreTradeTypeResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case strings.EqualFold(result.Channel, "getAccountInfo"): + var response WsGetAccountInfoResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response default: z.Websocket.DataHandler <- errors.New("zb_websocket.go error - unhandled websocket response") continue @@ -240,7 +325,7 @@ var wsErrCodes = map[int64]string{ // GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() func (z *ZB) GenerateDefaultSubscriptions() { - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription // Tickerdata is its own channel subscriptions = append(subscriptions, exchange.WebsocketChannelSubscription{ Channel: "markets", @@ -262,7 +347,7 @@ func (z *ZB) GenerateDefaultSubscriptions() { // Subscribe sends a websocket message to receive data from the channel func (z *ZB) Subscribe(channelToSubscribe exchange.WebsocketChannelSubscription) error { subscriptionRequest := Subscription{ - Event: "addChannel", + Event: zWebsocketAddChannel, Channel: channelToSubscribe.Channel, } return z.wsSend(subscriptionRequest) @@ -277,7 +362,198 @@ func (z *ZB) wsSend(data interface{}) error { return err } if z.Verbose { - log.Debugf("%v sending message to websocket %v", z.Name, data) + log.Debugf("%v sending message to websocket %v", z.Name, string(json)) } return z.WebsocketConn.WriteMessage(websocket.TextMessage, json) } + +func (z *ZB) wsAddSubUser(username, password string) error { + if !z.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", z.Name) + } + request := WsAddSubUserRequest{ + Memo: "Memo", + Password: password, + SubUserName: username, + } + request.Channel = "addSubUser" + request.Event = zWebsocketAddChannel + request.Accesskey = z.APIKey + request.Sign = z.wsGenerateSignature(request) + + return z.wsSend(request) +} + +func (z *ZB) wsGetSubUserList() error { + if !z.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", z.Name) + } + request := WsAuthenticatedRequest{} + request.Channel = "getSubUserList" + request.Event = zWebsocketAddChannel + request.Accesskey = z.APIKey + request.Sign = z.wsGenerateSignature(request) + + return z.wsSend(request) +} + +func (z *ZB) wsDoTransferFunds(pair currency.Code, amount float64, fromUserName, toUserName string) error { + if !z.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", z.Name) + } + request := WsDoTransferFundsRequest{ + Amount: amount, + Currency: pair, + FromUserName: fromUserName, + ToUserName: toUserName, + No: fmt.Sprintf("%v", time.Now().Unix()), + } + request.Channel = "doTransferFunds" + request.Event = zWebsocketAddChannel + request.Accesskey = z.APIKey + request.Sign = z.wsGenerateSignature(request) + + return z.wsSend(request) +} + +func (z *ZB) wsCreateSubUserKey(assetPerm, entrustPerm, leverPerm, moneyPerm bool, keyName, toUserID string) error { + if !z.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", z.Name) + } + request := WsCreateSubUserKeyRequest{ + AssetPerm: assetPerm, + EntrustPerm: entrustPerm, + KeyName: keyName, + LeverPerm: leverPerm, + MoneyPerm: moneyPerm, + No: fmt.Sprintf("%v", time.Now().Unix()), + ToUserID: toUserID, + } + request.Channel = "createSubUserKey" + request.Event = zWebsocketAddChannel + request.Accesskey = z.APIKey + request.Sign = z.wsGenerateSignature(request) + + return z.wsSend(request) +} + +func (z *ZB) wsGenerateSignature(request interface{}) string { + jsonResponse, err := common.JSONEncode(request) + if err != nil { + log.Error(err) + } + hmac := common.GetHMAC(common.HashMD5, + jsonResponse, + []byte(common.Sha1ToHex(z.APISecret))) + return fmt.Sprintf("%x", hmac) + +} + +func (z *ZB) wsFixInvalidJSON(json []byte) []byte { + invalidZbJSONRegex := `(\"\[|\"\{)(.*)(\]\"|\}\")` + regexChecker := regexp.MustCompile(invalidZbJSONRegex) + matchingResults := regexChecker.Find(json) + if matchingResults == nil { + return json + } + // Remove first quote character + capturedInvalidZBJSON := common.ReplaceString(string(matchingResults), "\"", "", 1) + // Remove last quote character + fixedJSON := capturedInvalidZBJSON[:len(capturedInvalidZBJSON)-1] + return []byte(common.ReplaceString(string(json), string(matchingResults), fixedJSON, 1)) +} + +func (z *ZB) wsSubmitOrder(pair currency.Pair, amount, price float64, tradeType int64) error { + if !z.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", z.Name) + } + request := WsSubmitOrderRequest{ + Amount: amount, + Price: price, + TradeType: tradeType, + No: fmt.Sprintf("%v", time.Now().Unix()), + } + request.Channel = fmt.Sprintf("%v_order", pair.String()) + request.Event = zWebsocketAddChannel + request.Accesskey = z.APIKey + request.Sign = z.wsGenerateSignature(request) + + return z.wsSend(request) +} + +func (z *ZB) wsCancelOrder(pair currency.Pair, orderID int64) error { + if !z.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", z.Name) + } + request := WsCancelOrderRequest{ + ID: orderID, + } + request.Channel = fmt.Sprintf("%v_cancelorder", pair.String()) + request.Event = zWebsocketAddChannel + request.Accesskey = z.APIKey + request.Sign = z.wsGenerateSignature(request) + + return z.wsSend(request) +} + +func (z *ZB) wsGetOrder(pair currency.Pair, orderID int64) error { + if !z.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", z.Name) + } + request := WsGetOrderRequest{ + ID: orderID, + } + request.Channel = fmt.Sprintf("%v_getorder", pair.String()) + request.Event = zWebsocketAddChannel + request.Accesskey = z.APIKey + request.Sign = z.wsGenerateSignature(request) + + return z.wsSend(request) +} + +func (z *ZB) wsGetOrders(pair currency.Pair, pageIndex, tradeType int64) error { + if !z.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", z.Name) + } + request := WsGetOrdersRequest{ + PageIndex: pageIndex, + TradeType: tradeType, + } + request.Channel = fmt.Sprintf("%v_getorders", pair.String()) + request.Event = zWebsocketAddChannel + request.Accesskey = z.APIKey + request.Sign = z.wsGenerateSignature(request) + + return z.wsSend(request) +} + +func (z *ZB) wsGetOrdersIgnoreTradeType(pair currency.Pair, pageIndex, pageSize int64) error { + if !z.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", z.Name) + } + request := WsGetOrdersIgnoreTradeTypeRequest{ + PageIndex: pageIndex, + PageSize: pageSize, + } + request.Channel = fmt.Sprintf("%v_getordersignoretradetype", pair.String()) + request.Event = zWebsocketAddChannel + request.Accesskey = z.APIKey + request.Sign = z.wsGenerateSignature(request) + + return z.wsSend(request) +} + +func (z *ZB) wsGetAccountInfoRequest() error { + if !z.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { + return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", z.Name) + } + request := WsAuthenticatedRequest{ + Channel: "getaccountinfo", + Event: zWebsocketAddChannel, + Accesskey: z.APIKey, + No: fmt.Sprintf("%v", time.Now().Unix()), + } + request.Sign = z.wsGenerateSignature(request) + + return z.wsSend(request) +} diff --git a/exchanges/zb/zb_websocket_types.go b/exchanges/zb/zb_websocket_types.go index 691d11cf..38fd95fa 100644 --- a/exchanges/zb/zb_websocket_types.go +++ b/exchanges/zb/zb_websocket_types.go @@ -1,6 +1,10 @@ package zb -import "encoding/json" +import ( + "encoding/json" + + "github.com/thrasher-/gocryptotrader/currency" +) // Subscription defines an initial subscription type to be sent type Subscription struct { @@ -13,7 +17,7 @@ type Generic struct { Code int64 `json:"code"` Success bool `json:"success"` Channel string `json:"channel"` - Message string `json:"message"` + Message interface{} `json:"message"` No string `json:"no"` Data json.RawMessage `json:"data"` } @@ -55,3 +59,221 @@ type WsTrades struct { TradeType string `json:"trade_type"` } `json:"data"` } + +// WsAuthenticatedRequest base request type +type WsAuthenticatedRequest struct { + Accesskey string `json:"accesskey"` + Channel string `json:"channel"` + Event string `json:"event"` + No string `json:"no,omitempty"` + Sign string `json:"sign,omitempty"` +} + +// WsAddSubUserRequest data to add sub users +type WsAddSubUserRequest struct { + Accesskey string `json:"accesskey"` + Channel string `json:"channel"` + Event string `json:"event"` + Sign string `json:"sign,omitempty"` + Memo string `json:"memo"` + Password string `json:"password"` + SubUserName string `json:"subUserName"` +} + +// WsCreateSubUserKeyRequest data to add sub user keys +type WsCreateSubUserKeyRequest struct { + Accesskey string `json:"accesskey"` + AssetPerm bool `json:"assetPerm,string"` + Channel string `json:"channel"` + EntrustPerm bool `json:"entrustPerm,string"` + Event string `json:"event"` + KeyName string `json:"keyName"` + LeverPerm bool `json:"leverPerm,string"` + MoneyPerm bool `json:"moneyPerm,string"` + No string `json:"no"` + Sign string `json:"sign,omitempty"` + ToUserID string `json:"toUserId"` +} + +// WsDoTransferFundsRequest data to transfer funds +type WsDoTransferFundsRequest struct { + Accesskey string `json:"accesskey"` + Amount float64 `json:"amount,string"` + Channel string `json:"channel"` + Currency currency.Code `json:"currency"` + Event string `json:"event"` + FromUserName string `json:"fromUserName"` + No string `json:"no"` + Sign string `json:"sign,omitempty"` + ToUserName string `json:"toUserName"` +} + +// WsGetSubUserListResponse data response from GetSubUserList +type WsGetSubUserListResponse struct { + Success bool `json:"success"` + Code int64 `json:"code"` + Channel string `json:"channel"` + Message []WsGetSubUserListResponseData `json:"message"` + No string `json:"no"` +} + +// WsGetSubUserListResponseData user data +type WsGetSubUserListResponseData struct { + IsOpenAPI bool `json:"isOpenApi,omitempty"` + Memo string `json:"memo,omitempty"` + UserName string `json:"userName,omitempty"` + UserID int64 `json:"userId,omitempty"` + IsFreez bool `json:"isFreez,omitempty"` +} + +// WsRequestResponse generic response data +type WsRequestResponse struct { + Success bool `json:"success"` + Code int64 `json:"code"` + Channel string `json:"channel"` + Message interface{} `json:"message"` + No string `json:"no"` +} + +// WsSubmitOrderRequest creates an order via ws +type WsSubmitOrderRequest struct { + Accesskey string `json:"accesskey"` + Amount float64 `json:"amount,string"` + Channel string `json:"channel"` + Event string `json:"event"` + No string `json:"no,omitempty"` + Price float64 `json:"price,string"` + Sign string `json:"sign,omitempty"` + TradeType int64 `json:"tradeType,string"` +} + +// WsSubmitOrderResponse data about submitted order +type WsSubmitOrderResponse struct { + Message string `json:"message"` + No string `json:"no"` + Data struct { + EntrustID int64 `json:"intrustID"` + } `json:"data"` + Code int64 `json:"code"` + Channel string `json:"channel"` + Success bool `json:"success"` +} + +// WsCancelOrderRequest order cancel request +type WsCancelOrderRequest struct { + Accesskey string `json:"accesskey"` + Channel string `json:"channel"` + Event string `json:"event"` + ID int64 `json:"id"` + Sign string `json:"sign,omitempty"` +} + +// WsCancelOrderResponse order cancel response +type WsCancelOrderResponse struct { + Message string `json:"message"` + No string `json:"no"` + Code int64 `json:"code"` + Channel string `json:"channel"` + Success bool `json:"success"` +} + +// WsGetOrderRequest Get specific order details +type WsGetOrderRequest struct { + Accesskey string `json:"accesskey"` + Channel string `json:"channel"` + Event string `json:"event"` + ID int64 `json:"id"` + Sign string `json:"sign,omitempty"` +} + +// WsGetOrderResponse contains order data +type WsGetOrderResponse struct { + Message string `json:"message"` + No string `json:"no"` + Code int64 `json:"code"` + Channel string `json:"channel"` + Success bool `json:"success"` + Data WsGetOrderResponseData `json:"data"` +} + +// WsGetOrderResponseData Detailed order data +type WsGetOrderResponseData struct { + Currency string `json:"currency"` + Fees float64 `json:"fees"` + ID string `json:"id"` + Price float64 `json:"price"` + Status int64 `json:"status"` + TotalAmount float64 `json:"total_amount"` + TradeAmount float64 `json:"trade_amount"` + TradePrice float64 `json:"trade_price"` + TradeDate int64 `json:"trade_date"` + TradeMoney float64 `json:"trade_money"` + Type int64 `json:"type"` +} + +// WsGetOrdersRequest get more orders, with no orderID filtering +type WsGetOrdersRequest struct { + Accesskey string `json:"accesskey"` + Channel string `json:"channel"` + Event string `json:"event"` + PageIndex int64 `json:"pageIndex"` + TradeType int64 `json:"tradeType"` + Sign string `json:"sign,omitempty"` +} + +// WsGetOrdersResponse contains orders data +type WsGetOrdersResponse struct { + Message string `json:"message"` + No string `json:"no"` + Code int64 `json:"code"` + Channel string `json:"channel"` + Success bool `json:"success"` + Data []WsGetOrderResponseData `json:"data"` +} + +// WsGetOrdersIgnoreTradeTypeRequest ws request +type WsGetOrdersIgnoreTradeTypeRequest struct { + Accesskey string `json:"accesskey"` + Channel string `json:"channel"` + Event string `json:"event"` + ID int64 `json:"id"` + PageIndex int64 `json:"pageIndex"` + PageSize int64 `json:"pageSize"` + Sign string `json:"sign,omitempty"` +} + +// WsGetOrdersIgnoreTradeTypeResponse contains orders data +type WsGetOrdersIgnoreTradeTypeResponse struct { + Message string `json:"message"` + No string `json:"no"` + Code int64 `json:"code"` + Channel string `json:"channel"` + Success bool `json:"success"` + Data []WsGetOrderResponseData `json:"data"` +} + +// WsGetAccountInfoResponse contains account data +type WsGetAccountInfoResponse struct { + Message string `json:"message"` + No string `json:"no"` + Data struct { + Coins []struct { + Freez float64 `json:"freez,string"` + EnName string `json:"enName"` + UnitDecimal int `json:"unitDecimal"` + CnName string `json:"cnName"` + UnitTag string `json:"unitTag"` + Available float64 `json:"available,string"` + Key string `json:"key"` + } `json:"coins"` + Base struct { + Username string `json:"username"` + TradePasswordEnabled bool `json:"trade_password_enabled"` + AuthGoogleEnabled bool `json:"auth_google_enabled"` + AuthMobileEnabled bool `json:"auth_mobile_enabled"` + } `json:"base"` + } `json:"data"` + Code int `json:"code"` + Channel string `json:"channel"` + Success bool `json:"success"` +} diff --git a/exchanges/zb/zb_wrapper.go b/exchanges/zb/zb_wrapper.go index 0d36862f..e977ee8d 100644 --- a/exchanges/zb/zb_wrapper.go +++ b/exchanges/zb/zb_wrapper.go @@ -424,3 +424,13 @@ func (z *ZB) SubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSu func (z *ZB) UnsubscribeToWebsocketChannels(channels []exchange.WebsocketChannelSubscription) error { return common.ErrFunctionNotSupported } + +// GetSubscriptions returns a copied list of subscriptions +func (z *ZB) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return z.Websocket.GetSubscriptions(), nil +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func (z *ZB) AuthenticateWebsocket() error { + return common.ErrFunctionNotSupported +} diff --git a/restful_server.go b/restful_server.go index bf21e703..ce8a2734 100644 --- a/restful_server.go +++ b/restful_server.go @@ -272,7 +272,7 @@ func GetAllEnabledExchangeAccountInfo() AllEnabledExchangeAccounts { var response AllEnabledExchangeAccounts for _, individualBot := range bot.exchanges { if individualBot != nil && individualBot.IsEnabled() { - if !individualBot.GetAuthenticatedAPISupport() { + if !individualBot.GetAuthenticatedAPISupport(exchange.RestAuthentication) { log.Warnf("GetAllEnabledExchangeAccountInfo: Skippping %s due to disabled authenticated API support.", individualBot.GetName()) continue } diff --git a/testdata/configtest.json b/testdata/configtest.json index e6640915..b04f691d 100644 --- a/testdata/configtest.json +++ b/testdata/configtest.json @@ -173,6 +173,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -214,6 +215,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -255,6 +257,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -304,6 +307,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -347,6 +351,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -389,6 +394,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -430,6 +436,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -472,6 +479,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -514,6 +522,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -555,6 +564,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -596,6 +606,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -639,6 +650,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -681,6 +693,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -723,6 +736,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -763,6 +777,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -804,6 +819,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiAuthPemKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPVSj8YkpXibCAL9HwpGkDNSEXR9jcpiCthdikJqipNooAoGCCqGSM49\nAwEHoUQDQgAEHiB7q/HCqUrCNqPeTtRmKjyi2T+2O2JgoU8Mjx2R4z1h81uOZHCk\nxbsDg1fb7ACRMpKWPs59QWpQxhqMQrNw8w==\n-----END EC PRIVATE KEY-----\n", @@ -846,6 +862,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiAuthPemKey": "-----BEGIN EC PRIVATE KEY-----\nJUSTADUMMY\n-----END EC PRIVATE KEY-----\n", @@ -888,6 +905,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -930,6 +948,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -972,6 +991,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -979,7 +999,7 @@ "proxyAddress": "", "websocketUrl": "NON_DEFAULT_HTTP_LINK_TO_WEBSOCKET_EXCHANGE_API", "availablePairs": "ETHBTC,USDNGN,USDSGD,EURUSD,USDHKD,BACETH,BTCCHF,BTCGBP,BTCJPY,BTCCAD,BTCEUR,USDCAD,BTCNGN,AUDUSD,GBPUSD,USDJPY,LTCBTC,BCHBTC,USDCHF,NZDUSD,XRPBTC", - "enabledPairs": "BTCUSD,BTCAUD", + "enabledPairs": "ETHBTC", "baseCurrencies": "USD,EUR,HKD,AUD,GBP,NZD,JPY,SGD,NGN,CHF,CAD", "assetTypes": "SPOT", "supportsAutoPairUpdates": true, @@ -1012,6 +1032,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -1052,6 +1073,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -1094,6 +1116,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -1136,6 +1159,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -1178,6 +1202,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -1222,6 +1247,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -1264,6 +1290,7 @@ "httpUserAgent": "", "httpDebugging": false, "authenticatedApiSupport": false, + "authenticatedWebsocketApiSupport": false, "apiKey": "Key", "apiSecret": "Secret", "apiUrl": "NON_DEFAULT_HTTP_LINK_TO_EXCHANGE_API", @@ -1271,7 +1298,7 @@ "proxyAddress": "", "websocketUrl": "NON_DEFAULT_HTTP_LINK_TO_WEBSOCKET_EXCHANGE_API", "availablePairs": "XRPM19,BCHM19,ADAM19,EOSM19,TRXM19,XBTUSD,XBT7D_U105,XBT7D_D95,XBTM19,XBTU19,ETHUSD,ETHM19,LTCM19", - "enabledPairs": "XRPH19", + "enabledPairs": "LTCM19", "baseCurrencies": "USD", "assetTypes": "SPOT", "supportsAutoPairUpdates": true, diff --git a/tools/exchange_template/main_file.tmpl b/tools/exchange_template/main_file.tmpl index 029cc56e..3458413a 100644 --- a/tools/exchange_template/main_file.tmpl +++ b/tools/exchange_template/main_file.tmpl @@ -56,6 +56,7 @@ func ({{.Variable}} *{{.CapitalName}}) Setup(exch *config.ExchangeConfig) { } else { {{.Variable}}.Enabled = true {{.Variable}}.AuthenticatedAPISupport = exch.AuthenticatedAPISupport + {{.Variable}}.AuthenticatedWebsocketAPISupport = exch.AuthenticatedWebsocketAPISupport {{.Variable}}.SetAPIKeys(exch.APIKey, exch.APISecret, "", false) {{.Variable}}.SetHTTPClientTimeout(exch.HTTPTimeout) {{.Variable}}.SetHTTPClientUserAgent(exch.HTTPUserAgent) diff --git a/tools/exchange_template/wrapper_file.tmpl b/tools/exchange_template/wrapper_file.tmpl index d2aaec0e..9fbcc894 100644 --- a/tools/exchange_template/wrapper_file.tmpl +++ b/tools/exchange_template/wrapper_file.tmpl @@ -199,4 +199,14 @@ func ({{.Variable}} *{{.CapitalName}}) UnsubscribeToWebsocketChannels(channels [ return nil } +// GetSubscriptions returns a copied list of subscriptions +func ({{.Variable}} *{{.CapitalName}}) GetSubscriptions() ([]exchange.WebsocketChannelSubscription, error) { + return nil, common.ErrNotYetImplemented +} + +// AuthenticateWebsocket sends an authentication message to the websocket +func ({{.Variable}} *{{.CapitalName}}) AuthenticateWebsocket() error { + return common.ErrNotYetImplemented +} + {{end}}