diff --git a/cmd/exchange_template/main_file.tmpl b/cmd/exchange_template/main_file.tmpl index 0434e971..224dc52d 100644 --- a/cmd/exchange_template/main_file.tmpl +++ b/cmd/exchange_template/main_file.tmpl @@ -55,6 +55,7 @@ func ({{.Variable}} *{{.CapitalName}}) Setup(exch *config.ExchangeConfig) error } else { {{.Variable}}.Enabled = true {{.Variable}}.API.AuthenticatedSupport = exch.API.AuthenticatedSupport + {{.Variable}}.API.AuthenticatedWebsocketSupport = exch.API.AuthenticatedWebsocketSupport {{.Variable}}.SetAPIKeys(exch.API.Credentials.Key, exch.API.Credentials.Secret, "", false) {{.Variable}}.SetHTTPClientTimeout(exch.HTTPTimeout) {{.Variable}}.SetHTTPClientUserAgent(exch.HTTPUserAgent) diff --git a/cmd/exchange_template/wrapper_file.tmpl b/cmd/exchange_template/wrapper_file.tmpl index 351096d6..672ad855 100644 --- a/cmd/exchange_template/wrapper_file.tmpl +++ b/cmd/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}} diff --git a/config/config.go b/config/config.go index 9adfdbb2..a0ac2a35 100644 --- a/config/config.go +++ b/config/config.go @@ -215,10 +215,11 @@ func (c *Config) PurgeExchangeAPICredentials() { m.Lock() defer m.Unlock() for x := range c.Exchanges { - if !c.Exchanges[x].API.AuthenticatedSupport { + if !c.Exchanges[x].API.AuthenticatedSupport && !c.Exchanges[x].API.AuthenticatedWebsocketSupport { continue } c.Exchanges[x].API.AuthenticatedSupport = false + c.Exchanges[x].API.AuthenticatedWebsocketSupport = false if c.Exchanges[x].API.CredentialsValidator.RequiresKey { c.Exchanges[x].API.Credentials.Key = DefaultAPIKey @@ -838,6 +839,9 @@ func (c *Config) CheckExchangeConfigValues() error { if c.Exchanges[i].APIKey != nil { // It is, migrate settings to new format c.Exchanges[i].API.AuthenticatedSupport = *c.Exchanges[i].AuthenticatedAPISupport + if c.Exchanges[i].AuthenticatedWebsocketAPISupport != nil { + c.Exchanges[i].API.AuthenticatedWebsocketSupport = *c.Exchanges[i].AuthenticatedWebsocketAPISupport + } c.Exchanges[i].API.Credentials.Key = *c.Exchanges[i].APIKey c.Exchanges[i].API.Credentials.Secret = *c.Exchanges[i].APISecret @@ -862,6 +866,7 @@ func (c *Config) CheckExchangeConfigValues() error { // Flush settings c.Exchanges[i].AuthenticatedAPISupport = nil + c.Exchanges[i].AuthenticatedWebsocketAPISupport = nil c.Exchanges[i].APIKey = nil c.Exchanges[i].APIAuthPEMKey = nil c.Exchanges[i].APISecret = nil @@ -941,20 +946,23 @@ func (c *Config) CheckExchangeConfigValues() error { c.Exchanges[i].Enabled = false continue } - if c.Exchanges[i].API.AuthenticatedSupport && c.Exchanges[i].API.CredentialsValidator != nil { + if (c.Exchanges[i].API.AuthenticatedSupport || c.Exchanges[i].API.AuthenticatedWebsocketSupport) && c.Exchanges[i].API.CredentialsValidator != nil { + var failed bool if c.Exchanges[i].API.CredentialsValidator.RequiresKey && (c.Exchanges[i].API.Credentials.Key == "" || c.Exchanges[i].API.Credentials.Key == DefaultAPIKey) { - c.Exchanges[i].API.AuthenticatedSupport = false + failed = true } if c.Exchanges[i].API.CredentialsValidator.RequiresSecret && (c.Exchanges[i].API.Credentials.Secret == "" || c.Exchanges[i].API.Credentials.Secret == DefaultAPISecret) { - c.Exchanges[i].API.AuthenticatedSupport = false + failed = true } if c.Exchanges[i].API.CredentialsValidator.RequiresClientID && (c.Exchanges[i].API.Credentials.ClientID == DefaultAPIClientID || c.Exchanges[i].API.Credentials.ClientID == "") { - c.Exchanges[i].API.AuthenticatedSupport = false + failed = true } - if !c.Exchanges[i].API.AuthenticatedSupport { + if failed { + c.Exchanges[i].API.AuthenticatedSupport = false + c.Exchanges[i].API.AuthenticatedWebsocketSupport = false log.Warnf(WarningExchangeAuthAPIDefaultOrEmptyValues, c.Exchanges[i].Name) } } diff --git a/config/config_test.go b/config/config_test.go index 92c3cf48..c4917dd7 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -628,6 +628,7 @@ func TestUpdateExchangeConfig(t *testing.T) { } } +// TestCheckExchangeConfigValues logic test func TestCheckExchangeConfigValues(t *testing.T) { checkExchangeConfigValues := Config{} @@ -651,25 +652,43 @@ func TestCheckExchangeConfigValues(t *testing.T) { t.Fatalf("Test failed. Expected exchange %s to have updated HTTPTimeout value", checkExchangeConfigValues.Exchanges[0].Name) } + v := &APICredentialsValidatorConfig{ + RequiresKey: true, + RequiresSecret: true, + } + checkExchangeConfigValues.Exchanges[0].API.CredentialsValidator = v checkExchangeConfigValues.Exchanges[0].API.Credentials.Key = "Key" checkExchangeConfigValues.Exchanges[0].API.Credentials.Secret = "Secret" checkExchangeConfigValues.Exchanges[0].API.AuthenticatedSupport = true - err = checkExchangeConfigValues.CheckExchangeConfigValues() - if err != nil { - t.Errorf( - "Test failed. checkExchangeConfigValues.CheckExchangeConfigValues Error", - ) + checkExchangeConfigValues.Exchanges[0].API.AuthenticatedWebsocketSupport = true + checkExchangeConfigValues.CheckExchangeConfigValues() + if checkExchangeConfigValues.Exchanges[0].API.AuthenticatedSupport || + checkExchangeConfigValues.Exchanges[0].API.AuthenticatedWebsocketSupport { + t.Error("Expected authenticated endpoints to be false from invalid API keys") } + v.RequiresKey = false + v.RequiresClientID = true checkExchangeConfigValues.Exchanges[0].API.AuthenticatedSupport = true - checkExchangeConfigValues.Exchanges[0].API.Credentials.Key = "TESTYTEST" + checkExchangeConfigValues.Exchanges[0].API.AuthenticatedWebsocketSupport = true + checkExchangeConfigValues.Exchanges[0].API.Credentials.ClientID = DefaultAPIClientID checkExchangeConfigValues.Exchanges[0].API.Credentials.Secret = "TESTYTEST" - checkExchangeConfigValues.Exchanges[0].Name = "ITBIT" - err = checkExchangeConfigValues.CheckExchangeConfigValues() - if err != nil { - t.Errorf( - "Test failed. checkExchangeConfigValues.CheckExchangeConfigValues Error", - ) + checkExchangeConfigValues.CheckExchangeConfigValues() + if checkExchangeConfigValues.Exchanges[0].API.AuthenticatedSupport || + checkExchangeConfigValues.Exchanges[0].API.AuthenticatedWebsocketSupport { + t.Error("Expected AuthenticatedAPISupport to be false from invalid API keys") + } + + v.RequiresKey = true + checkExchangeConfigValues.Exchanges[0].API.AuthenticatedSupport = true + checkExchangeConfigValues.Exchanges[0].API.AuthenticatedWebsocketSupport = true + checkExchangeConfigValues.Exchanges[0].API.Credentials.Key = "meow" + checkExchangeConfigValues.Exchanges[0].API.Credentials.Secret = "test123" + checkExchangeConfigValues.Exchanges[0].API.Credentials.ClientID = "clientIDerino" + checkExchangeConfigValues.CheckExchangeConfigValues() + if !checkExchangeConfigValues.Exchanges[0].API.AuthenticatedSupport || + !checkExchangeConfigValues.Exchanges[0].API.AuthenticatedWebsocketSupport { + t.Error("Expected AuthenticatedAPISupport and AuthenticatedWebsocketAPISupport to be false from invalid API keys") } checkExchangeConfigValues.Exchanges[0].Enabled = true diff --git a/config/config_types.go b/config/config_types.go index 4f506a23..b4e82549 100644 --- a/config/config_types.go +++ b/config/config_types.go @@ -61,23 +61,24 @@ type ExchangeConfig struct { BankAccounts []BankAccount `json:"bankAccounts,omitempty"` // Deprecated settings which will be removed in a future update - AvailablePairs *currency.Pairs `json:"availablePairs,omitempty"` - EnabledPairs *currency.Pairs `json:"enabledPairs,omitempty"` - AssetTypes *string `json:"assetTypes,omitempty"` - PairsLastUpdated *int64 `json:"pairsLastUpdated,omitempty"` - ConfigCurrencyPairFormat *currency.PairFormat `json:"configCurrencyPairFormat,omitempty"` - RequestCurrencyPairFormat *currency.PairFormat `json:"requestCurrencyPairFormat,omitempty"` - AuthenticatedAPISupport *bool `json:"authenticatedApiSupport,omitempty"` - APIKey *string `json:"apiKey,omitempty"` - APISecret *string `json:"apiSecret,omitempty"` - APIAuthPEMKeySupport *bool `json:"apiAuthPemKeySupport,omitempty"` - APIAuthPEMKey *string `json:"apiAuthPemKey,omitempty"` - APIURL *string `json:"apiUrl,omitempty"` - APIURLSecondary *string `json:"apiUrlSecondary,omitempty"` - ClientID *string `json:"clientId,omitempty"` - SupportsAutoPairUpdates *bool `json:"supportsAutoPairUpdates,omitempty"` - Websocket *bool `json:"websocket,omitempty"` - WebsocketURL *string `json:"websocketUrl,omitempty"` + AvailablePairs *currency.Pairs `json:"availablePairs,omitempty"` + EnabledPairs *currency.Pairs `json:"enabledPairs,omitempty"` + AssetTypes *string `json:"assetTypes,omitempty"` + PairsLastUpdated *int64 `json:"pairsLastUpdated,omitempty"` + ConfigCurrencyPairFormat *currency.PairFormat `json:"configCurrencyPairFormat,omitempty"` + RequestCurrencyPairFormat *currency.PairFormat `json:"requestCurrencyPairFormat,omitempty"` + AuthenticatedAPISupport *bool `json:"authenticatedApiSupport,omitempty"` + AuthenticatedWebsocketAPISupport *bool `json:"authenticatedWebsocketApiSupport,omitempty"` + APIKey *string `json:"apiKey,omitempty"` + APISecret *string `json:"apiSecret,omitempty"` + APIAuthPEMKeySupport *bool `json:"apiAuthPemKeySupport,omitempty"` + APIAuthPEMKey *string `json:"apiAuthPemKey,omitempty"` + APIURL *string `json:"apiUrl,omitempty"` + APIURLSecondary *string `json:"apiUrlSecondary,omitempty"` + ClientID *string `json:"clientId,omitempty"` + SupportsAutoPairUpdates *bool `json:"supportsAutoPairUpdates,omitempty"` + Websocket *bool `json:"websocket,omitempty"` + WebsocketURL *string `json:"websocketUrl,omitempty"` } // ProfilerConfig defines the profiler configuration to enable pprof @@ -339,8 +340,9 @@ type APICredentialsValidatorConfig struct { // APIConfig stores the exchange API config type APIConfig struct { - AuthenticatedSupport bool `json:"authenticatedSupport"` - PEMKeySupport bool `json:"pemKeySupport,omitempty"` + AuthenticatedSupport bool `json:"authenticatedSupport"` + AuthenticatedWebsocketSupport bool `json:"authenticatedWebsocketApiSupport"` + PEMKeySupport bool `json:"pemKeySupport,omitempty"` Endpoints APIEndpointsConfig `json:"endpoints"` Credentials APICredentialsConfig `json:"credentials"` 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/engine/helpers.go b/engine/helpers.go index 6408f041..c97122ab 100644 --- a/engine/helpers.go +++ b/engine/helpers.go @@ -153,7 +153,8 @@ func GetExchangeoOTPByName(exchName string) (string, error) { func GetAuthAPISupportedExchanges() []string { var exchanges []string for x := range Bot.Exchanges { - if !Bot.Exchanges[x].GetAuthenticatedAPISupport() { + if !Bot.Exchanges[x].GetAuthenticatedAPISupport(exchange.RestAuthentication) && + !Bot.Exchanges[x].GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) { continue } exchanges = append(exchanges, Bot.Exchanges[x].GetName()) @@ -649,7 +650,7 @@ func GetExchangeCryptocurrencyDepositAddresses() map[string]map[string]string { } exchName := Bot.Exchanges[x].GetName() - if !Bot.Exchanges[x].GetAuthenticatedAPISupport() { + if !Bot.Exchanges[x].GetAuthenticatedAPISupport(exchange.RestAuthentication) { if Bot.Settings.Verbose { log.Debugf("GetExchangeCryptocurrencyDepositAddresses: Skippping %s due to disabled authenticated API support.", exchName) } @@ -771,7 +772,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) { if Bot.Settings.Verbose { log.Debugf("GetAllEnabledExchangeAccountInfo: Skippping %s due to disabled authenticated API support.", individualBot.GetName()) } diff --git a/exchanges/alphapoint/alphapoint_wrapper.go b/exchanges/alphapoint/alphapoint_wrapper.go index b46f4d62..ff51c802 100644 --- a/exchanges/alphapoint/alphapoint_wrapper.go +++ b/exchanges/alphapoint/alphapoint_wrapper.go @@ -391,3 +391,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 d3e1610b..70705718 100644 --- a/exchanges/anx/anx_wrapper.go +++ b/exchanges/anx/anx_wrapper.go @@ -551,3 +551,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 fdf1b815..275f89bc 100644 --- a/exchanges/binance/binance_wrapper.go +++ b/exchanges/binance/binance_wrapper.go @@ -541,3 +541,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_test.go b/exchanges/bitfinex/bitfinex_test.go index 5f975349..11822cee 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 @@ -38,6 +41,7 @@ func TestSetup(t *testing.T) { } b.API.AuthenticatedSupport = true + b.API.AuthenticatedWebsocketSupport = true // custom rate limit for testing b.Requester.SetRateLimit(true, time.Millisecond*300, 1) b.Requester.SetRateLimit(false, time.Millisecond*300, 1) @@ -962,3 +966,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.API.AuthenticatedWebsocketSupport || !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 8f264efd..b535a63c 100644 --- a/exchanges/bitfinex/bitfinex_websocket.go +++ b/exchanges/bitfinex/bitfinex_websocket.go @@ -20,28 +20,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 @@ -78,6 +80,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" @@ -91,7 +96,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 @@ -151,6 +161,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 { @@ -158,13 +173,6 @@ func (b *Bitfinex) WsConnect() error { } } - if b.AllowAuthenticatedRequest() { - err = b.WsSendAuth() - if err != nil { - return err - } - } - pongReceive = make(chan struct{}, 1) go b.WsDataHandler() @@ -226,15 +234,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.API.AuthenticatedSupport = false } } @@ -418,6 +424,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 } @@ -603,7 +622,7 @@ func (b *Bitfinex) WsUpdateOrderbook(p currency.Pair, assetType asset.Item, book // 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 { enabledPairs := b.GetEnabledPairs(asset.Spot) for j := range enabledPairs { @@ -626,7 +645,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 72e98e78..b10e8aa2 100644 --- a/exchanges/bitfinex/bitfinex_wrapper.go +++ b/exchanges/bitfinex/bitfinex_wrapper.go @@ -579,3 +579,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 0a5f485f..4f254ffc 100644 --- a/exchanges/bitflyer/bitflyer_wrapper.go +++ b/exchanges/bitflyer/bitflyer_wrapper.go @@ -364,3 +364,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 333b968b..6836b8ea 100644 --- a/exchanges/bithumb/bithumb_wrapper.go +++ b/exchanges/bithumb/bithumb_wrapper.go @@ -520,3 +520,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_test.go b/exchanges/bitmex/bitmex_test.go index aca033b1..bb56c505 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 @@ -33,6 +36,7 @@ func TestSetup(t *testing.T) { } bitmexConfig.API.AuthenticatedSupport = true + bitmexConfig.API.AuthenticatedWebsocketSupport = true bitmexConfig.API.Credentials.Key = apiKey bitmexConfig.API.Credentials.Secret = apiSecret @@ -683,3 +687,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.API.AuthenticatedWebsocketSupport || !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 e8f3be7a..ea4df7f3 100644 --- a/exchanges/bitmex/bitmex_websocket.go +++ b/exchanges/bitmex/bitmex_websocket.go @@ -110,12 +110,11 @@ func (b *Bitmex) WsConnector() error { go b.wsHandleIncomingData() b.GenerateDefaultSubscriptions() - if b.AllowAuthenticatedRequest() { - 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 } @@ -193,11 +192,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) } @@ -267,7 +270,6 @@ func (b *Bitmex) wsHandleIncomingData() { case bitmexWSAnnouncement: var announcement AnnouncementData - err = common.JSONDecode(resp.Raw, &announcement) if err != nil { b.Websocket.DataHandler <- err @@ -279,7 +281,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) @@ -396,6 +461,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.GetEnabledPairs(asset.PerpetualContract) + 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{ @@ -427,19 +533,27 @@ 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 := crypto.GetHMAC(crypto.HashSHA256, []byte("GET/realtime"+newTimestamp), []byte(b.API.Credentials.Secret)) - signature := crypto.HexEncodeToString(hmac) var sendAuth WebsocketRequest sendAuth.Command = "authKeyExpires" sendAuth.Arguments = append(sendAuth.Arguments, b.API.Credentials.Key, 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 5cd59837..f62a4335 100644 --- a/exchanges/bitmex/bitmex_wrapper.go +++ b/exchanges/bitmex/bitmex_wrapper.go @@ -587,3 +587,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 4dff9dcc..7d97743b 100644 --- a/exchanges/bitstamp/bitstamp_websocket.go +++ b/exchanges/bitstamp/bitstamp_websocket.go @@ -154,7 +154,7 @@ func (b *Bitstamp) WsHandleData() { func (b *Bitstamp) generateDefaultSubscriptions() { var channels = []string{"live_trades_", "diff_order_book_"} enabledCurrencies := b.GetEnabledPairs(asset.Spot) - 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 33a090b9..9e1d4b22 100644 --- a/exchanges/bitstamp/bitstamp_wrapper.go +++ b/exchanges/bitstamp/bitstamp_wrapper.go @@ -535,3 +535,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 77129ac4..ba6ef544 100644 --- a/exchanges/bittrex/bittrex_wrapper.go +++ b/exchanges/bittrex/bittrex_wrapper.go @@ -507,3 +507,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 7921b0af..51626680 100644 --- a/exchanges/btcmarkets/btcmarkets_wrapper.go +++ b/exchanges/btcmarkets/btcmarkets_wrapper.go @@ -577,3 +577,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 f7c0a42b..66c44252 100644 --- a/exchanges/btse/btse_websocket.go +++ b/exchanges/btse/btse_websocket.go @@ -207,7 +207,7 @@ func (b *BTSE) wsProcessSnapshot(snapshot *websocketOrderbookSnapshot) error { func (b *BTSE) GenerateDefaultSubscriptions() { var channels = []string{"snapshot", "ticker"} enabledCurrencies := b.GetEnabledPairs(asset.Spot) - 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 9ee5fff9..4f591a10 100644 --- a/exchanges/btse/btse_wrapper.go +++ b/exchanges/btse/btse_wrapper.go @@ -499,3 +499,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 0bc9fdf5..569a4646 100644 --- a/exchanges/coinbasepro/coinbasepro.go +++ b/exchanges/coinbasepro/coinbasepro.go @@ -735,7 +735,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 := crypto.GetHMAC(crypto.HashSHA256, []byte(message), []byte(c.API.Credentials.Secret)) headers := make(map[string]string) diff --git a/exchanges/coinbasepro/coinbasepro_test.go b/exchanges/coinbasepro/coinbasepro_test.go index 8e080db2..3e8aa4e5 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.API.Credentials.Key = apiKey gdxConfig.API.Credentials.Secret = apiSecret + gdxConfig.API.Credentials.ClientID = clientID gdxConfig.API.AuthenticatedSupport = true + gdxConfig.API.AuthenticatedWebsocketSupport = true c.Setup(gdxConfig) } @@ -87,139 +92,85 @@ func TestGetServerTime(t *testing.T) { } func TestAuthRequests(t *testing.T) { - - if c.ValidateAPICredentials() { - - _, 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, exchange.BuyOrderSide.ToLower().ToString(), - "", "", "BTC-USD", "", false) - if err == nil { - t.Error("Test failed - PlaceLimitOrder() error", err) - } - - _, err = c.PlaceMarketOrder("", 1, 0, exchange.BuyOrderSide.ToLower().ToString(), - "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) } } @@ -637,3 +588,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.API.AuthenticatedWebsocketSupport || !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 02cdc693..212311fc 100644 --- a/exchanges/coinbasepro/coinbasepro_websocket.go +++ b/exchanges/coinbasepro/coinbasepro_websocket.go @@ -10,6 +10,7 @@ import ( "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" + "github.com/thrasher-/gocryptotrader/common/crypto" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/asset" @@ -149,6 +150,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 } } } @@ -243,10 +289,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.GetEnabledPairs(asset.Spot) - 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{ @@ -271,6 +320,16 @@ 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 := crypto.GetHMAC(crypto.HashSHA256, []byte(message), + []byte(c.API.Credentials.Secret)) + subscribe.Signature = crypto.Base64Encode(hmac) + subscribe.Key = c.API.Credentials.Key + subscribe.Passphrase = c.API.Credentials.ClientID + subscribe.Timestamp = n + } return c.wsSend(subscribe) } diff --git a/exchanges/coinbasepro/coinbasepro_wrapper.go b/exchanges/coinbasepro/coinbasepro_wrapper.go index 06c12a73..9a71c5e1 100644 --- a/exchanges/coinbasepro/coinbasepro_wrapper.go +++ b/exchanges/coinbasepro/coinbasepro_wrapper.go @@ -512,3 +512,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 c51c99bb..28f646d3 100644 --- a/exchanges/coinut/coinut.go +++ b/exchanges/coinut/coinut.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "strings" "sync" "github.com/gorilla/websocket" @@ -13,6 +14,7 @@ import ( "github.com/thrasher-/gocryptotrader/common/crypto" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/asset" log "github.com/thrasher-/gocryptotrader/logger" ) @@ -53,7 +55,7 @@ type COINUT struct { func (c *COINUT) GetInstruments() (Instruments, error) { var result Instruments params := make(map[string]interface{}) - params["sec_type"] = "SPOT" + params["sec_type"] = strings.ToUpper(asset.Spot.String()) return result, c.SendHTTPRequest(coinutInstruments, params, false, &result) } diff --git a/exchanges/coinut/coinut_test.go b/exchanges/coinut/coinut_test.go index 30d5cbe3..dc28602e 100644 --- a/exchanges/coinut/coinut_test.go +++ b/exchanges/coinut/coinut_test.go @@ -1,15 +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 ( @@ -30,6 +35,7 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - Coinut Setup() init error") } bConfig.API.AuthenticatedSupport = true + bConfig.API.AuthenticatedWebsocketSupport = true bConfig.API.Credentials.Key = apiKey bConfig.API.Credentials.ClientID = clientID bConfig.Verbose = true @@ -41,6 +47,46 @@ func TestSetup(t *testing.T) { } } +func setupWSTestAuth(t *testing.T) { + if wsSetupRan { + return + } + c.SetDefaults() + TestSetup(t) + if !c.Websocket.IsEnabled() && !c.API.AuthenticatedWebsocketSupport || !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 { @@ -403,3 +449,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 bd0ef301..44b8f4d4 100644 --- a/exchanges/coinut/coinut_websocket.go +++ b/exchanges/coinut/coinut_websocket.go @@ -2,12 +2,15 @@ package coinut import ( "errors" + "fmt" "net/http" "net/url" + "strings" "time" "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" + "github.com/thrasher-/gocryptotrader/common/crypto" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/asset" @@ -29,142 +32,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 - } - - currencyPair := instrumentListByCode[ticker.InstID] - c.Websocket.DataHandler <- exchange.TickerData{ - Timestamp: time.Unix(0, ticker.Timestamp), - Pair: currency.NewPairFromString(currencyPair), - Exchange: c.GetName(), - AssetType: asset.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: 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: 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: asset.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() { @@ -200,7 +67,7 @@ func (c *COINUT) WsConnect() error { } populatedList = true } - + c.wsAuthenticate() c.GenerateDefaultSubscriptions() // define bi-directional communication @@ -208,10 +75,244 @@ 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.API.Credentials.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 + } + currencyPair := instrumentListByCode[ticker.InstID] + c.Websocket.DataHandler <- exchange.TickerData{ + Timestamp: time.Unix(0, ticker.Timestamp), + Pair: currency.NewPairFromString(currencyPair), + Exchange: c.GetName(), + AssetType: asset.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: 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: 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: asset.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 { @@ -227,7 +328,7 @@ func (c *COINUT) GetNonce() int64 { func (c *COINUT) WsSetInstrumentList() error { err := c.wsSend(wsRequest{ Request: "inst_list", - SecType: "SPOT", + SecType: strings.ToUpper(asset.Spot.String()), Nonce: c.GetNonce(), }) if err != nil { @@ -313,7 +414,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.GetEnabledPairs(asset.Spot) for i := range channels { for j := range enabledCurrencies { @@ -364,3 +465,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.API.Credentials.ClientID, timestamp, nonce) + hmac := crypto.GetHMAC(crypto.HashSHA256, []byte(payload), []byte(c.API.Credentials.Key)) + 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.API.Credentials.ClientID, + Nonce: nonce, + Hmac: crypto.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 := c.FormatExchangeCurrency(order.Currency, asset.Spot).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 := c.FormatExchangeCurrency(orders[i].Currency, asset.Spot).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 := c.FormatExchangeCurrency(p, asset.Spot).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 := c.FormatExchangeCurrency(cancellation.Currency, asset.Spot).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 := c.FormatExchangeCurrency(cancellations[i].Currency, asset.Spot).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 := c.FormatExchangeCurrency(p, asset.Spot).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 6bedf032..78b8d807 100644 --- a/exchanges/coinut/coinut_wrapper.go +++ b/exchanges/coinut/coinut_wrapper.go @@ -631,3 +631,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 2752c8a2..dd49aa89 100644 --- a/exchanges/exchange.go +++ b/exchanges/exchange.go @@ -282,8 +282,14 @@ func (e *Base) SetCurrencyPairFormat() { // GetAuthenticatedAPISupport returns whether the exchange supports // authenticated API requests -func (e *Base) GetAuthenticatedAPISupport() bool { - return e.API.AuthenticatedSupport +func (e *Base) GetAuthenticatedAPISupport(endpoint uint8) bool { + switch endpoint { + case RestAuthentication: + return e.API.AuthenticatedSupport + case WebsocketAuthentication: + return e.API.AuthenticatedWebsocketSupport + } + return false } // GetName is a method that returns the name of the exchange base @@ -388,6 +394,7 @@ func (e *Base) SetAPIKeys(apiKey, apiSecret, clientID string) { result, err := crypto.Base64Decode(apiSecret) if err != nil { e.API.AuthenticatedSupport = false + e.API.AuthenticatedWebsocketSupport = false log.Warnf(warningBase64DecryptSecretKeyFailed, e.Name) } e.API.Credentials.Secret = string(result) @@ -404,7 +411,8 @@ func (e *Base) SetupDefaults(exch *config.ExchangeConfig) error { e.Verbose = exch.Verbose e.API.AuthenticatedSupport = exch.API.AuthenticatedSupport - if e.API.AuthenticatedSupport { + e.API.AuthenticatedWebsocketSupport = exch.API.AuthenticatedWebsocketSupport + if e.API.AuthenticatedSupport || e.API.AuthenticatedWebsocketSupport { e.SetAPIKeys(exch.API.Credentials.Key, exch.API.Credentials.Secret, exch.API.Credentials.ClientID) } @@ -456,13 +464,13 @@ func (e *Base) AllowAuthenticatedRequest() bool { // Bot usage, AuthenticatedSupport can be disabled by user if desired, so don't // allow authenticated requests. - if !e.API.AuthenticatedSupport && e.LoadedByConfig { + if (!e.API.AuthenticatedSupport && !e.API.AuthenticatedWebsocketSupport) && e.LoadedByConfig { return false } // Check to see if the user has enabled AuthenticatedSupport, but has invalid // API credentials set and loaded by config - if e.API.AuthenticatedSupport && e.LoadedByConfig && !e.ValidateAPICredentials() { + if (e.API.AuthenticatedSupport || e.API.AuthenticatedWebsocketSupport) && e.LoadedByConfig && !e.ValidateAPICredentials() { return false } diff --git a/exchanges/exchange_test.go b/exchanges/exchange_test.go index 32819ec8..49c69011 100644 --- a/exchanges/exchange_test.go +++ b/exchanges/exchange_test.go @@ -263,10 +263,27 @@ func TestSetCurrencyPairFormat(t *testing.T) { } } +// TestGetAuthenticatedAPISupport logic test func TestGetAuthenticatedAPISupport(t *testing.T) { - var base Base - if base.GetAuthenticatedAPISupport() { - t.Fatal("Test failed. TestGetAuthenticatedAPISupport returned true when it should of been false.") + base := Base{ + API: API{ + AuthenticatedSupport: true, + AuthenticatedWebsocketSupport: 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.API.AuthenticatedWebsocketSupport = 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") } } @@ -539,19 +556,21 @@ func TestIsEnabled(t *testing.T) { } } +// TestSetAPIKeys logic test func TestSetAPIKeys(t *testing.T) { SetAPIKeys := Base{ Name: "TESTNAME", Enabled: false, + API: API{ + AuthenticatedSupport: false, + AuthenticatedWebsocketSupport: false, + }, } SetAPIKeys.SetAPIKeys("RocketMan", "Digereedoo", "007") if SetAPIKeys.API.Credentials.Key != "RocketMan" && SetAPIKeys.API.Credentials.Secret != "Digereedoo" && SetAPIKeys.API.Credentials.ClientID != "007" { t.Error("Test Failed - SetAPIKeys() unable to set API credentials") } - - SetAPIKeys.API.CredentialsValidator.RequiresBase64DecodeSecret = true - SetAPIKeys.SetAPIKeys("RocketMan", "Digereedoo", "007") } func TestSetPairs(t *testing.T) { diff --git a/exchanges/exchange_types.go b/exchanges/exchange_types.go index 3adb5b5d..ada39684 100644 --- a/exchanges/exchange_types.go +++ b/exchanges/exchange_types.go @@ -8,6 +8,12 @@ import ( "github.com/thrasher-/gocryptotrader/exchanges/request" ) +// Endpoint authentication types +const ( + RestAuthentication uint8 = 0 + WebsocketAuthentication uint8 = 1 +) + // FeeType custom type for calculating fees based on method type FeeType uint8 @@ -261,8 +267,9 @@ type FeaturesSupported struct { // API stores the exchange API settings type API struct { - AuthenticatedSupport bool - PEMKeySupport bool + AuthenticatedSupport bool + AuthenticatedWebsocketSupport bool + PEMKeySupport bool Endpoints struct { URL string diff --git a/exchanges/exmo/exmo_wrapper.go b/exchanges/exmo/exmo_wrapper.go index aef67d24..d8731a2e 100644 --- a/exchanges/exmo/exmo_wrapper.go +++ b/exchanges/exmo/exmo_wrapper.go @@ -507,3 +507,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_test.go b/exchanges/gateio/gateio_test.go index c4466f15..714291c1 100644 --- a/exchanges/gateio/gateio_test.go +++ b/exchanges/gateio/gateio_test.go @@ -1,12 +1,17 @@ package gateio import ( + "net/http" + "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 your own APIKEYS here for due diligence testing @@ -31,6 +36,7 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - GateIO Setup() init error") } gateioConfig.API.AuthenticatedSupport = true + gateioConfig.API.AuthenticatedWebsocketSupport = true gateioConfig.API.Credentials.Key = apiKey gateioConfig.API.Credentials.Secret = apiSecret @@ -493,3 +499,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.API.AuthenticatedWebsocketSupport || !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 !strings.Contains(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 51f4ce6e..48d25700 100644 --- a/exchanges/gateio/gateio_websocket.go +++ b/exchanges/gateio/gateio_websocket.go @@ -48,22 +48,21 @@ func (g *Gateio) WsConnect() error { if err != nil { return err } - - if g.API.AuthenticatedSupport { - 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 := crypto.Base64Encode(sigTemp) @@ -72,7 +71,13 @@ func (g *Gateio) wsServerSignIn() error { Method: "server.sign", Params: []interface{}{g.API.Credentials.Key, 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 @@ -116,20 +121,22 @@ func (g *Gateio) WsHandleData() { g.Websocket.DataHandler <- err continue } - if result.Error.Code != 0 { if strings.Contains(result.Error.Message, "authentication") { - g.Websocket.DataHandler <- fmt.Errorf("%v - WebSocket authentication failed ", - g.GetName()) - g.API.AuthenticatedSupport = 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{} @@ -342,14 +349,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.GetEnabledPairs(asset.Spot) + 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.AllowAuthenticatedRequest() { - channels = append(channels, "balance.subscribe", "order.subscribe") - } - - subscriptions := []exchange.WebsocketChannelSubscription{} + var subscriptions []exchange.WebsocketChannelSubscription enabledCurrencies := g.GetEnabledPairs(asset.Spot) for i := range channels { for j := range enabledCurrencies { @@ -402,6 +424,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", @@ -411,6 +436,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 2cfdd3e2..9de26b67 100644 --- a/exchanges/gateio/gateio_wrapper.go +++ b/exchanges/gateio/gateio_wrapper.go @@ -575,3 +575,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_test.go b/exchanges/gemini/gemini_test.go index 162c8eec..71737542 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 @@ -62,6 +65,7 @@ func TestSetup(t *testing.T) { } geminiConfig.API.AuthenticatedSupport = true + geminiConfig.API.AuthenticatedWebsocketSupport = true Session[1].Setup(geminiConfig) Session[2].Setup(geminiConfig) @@ -559,3 +563,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.API.Endpoints.WebsocketURL = geminiWebsocketSandboxEndpoint + + if !g.Websocket.IsEnabled() && !g.API.AuthenticatedWebsocketSupport || !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 c3e21e4b..661e1ef4 100644 --- a/exchanges/gemini/gemini_websocket.go +++ b/exchanges/gemini/gemini_websocket.go @@ -11,16 +11,20 @@ import ( "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" + "github.com/thrasher-/gocryptotrader/common/crypto" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/asset" "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 @@ -38,12 +42,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) } @@ -53,59 +59,76 @@ 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.API.Endpoints.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("%s websocket endpoint: %v Status: %v Error: %v", g.Name, + endpoint, conStatus, 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.API.Endpoints.WebsocketURL, url) + PayloadBase64 := crypto.Base64Encode(PayloadJSON) + hmac := crypto.GetHMAC(crypto.HashSHA512_384, []byte(PayloadBase64), []byte(g.API.Credentials.Secret)) + 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.API.Credentials.Key) + headers.Add("X-GEMINI-SIGNATURE", crypto.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 @@ -113,120 +136,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 = asset.Spot - newOrderBook.LastUpdated = time.Now() - 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: 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: asset.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(), - asset.Spot) - if err != nil { - g.Websocket.DataHandler <- err - } - } else { - err := g.Websocket.Orderbook.Update([]orderbook.Item{i}, - nil, - resp.Currency, - time.Now(), - g.GetName(), - asset.Spot) - if err != nil { - g.Websocket.DataHandler <- err - } - } - } - } - - g.Websocket.DataHandler <- exchange.WebsocketOrderbookUpdate{Pair: resp.Currency, - Asset: 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 = asset.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: 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: asset.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(), + asset.Spot) + if err != nil { + g.Websocket.DataHandler <- err + } + } else { + err := g.Websocket.Orderbook.Update([]orderbook.Item{i}, + nil, + pair, + time.Now(), + g.GetName(), + asset.Spot) + if err != nil { + g.Websocket.DataHandler <- err + } + } + } + } + + g.Websocket.DataHandler <- exchange.WebsocketOrderbookUpdate{Pair: pair, + Asset: asset.Spot, + Exchange: g.GetName()} + } +} diff --git a/exchanges/gemini/gemini_wrapper.go b/exchanges/gemini/gemini_wrapper.go index acb371d4..eb765659 100644 --- a/exchanges/gemini/gemini_wrapper.go +++ b/exchanges/gemini/gemini_wrapper.go @@ -480,3 +480,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_test.go b/exchanges/hitbtc/hitbtc_test.go index d8fad344..6feacae6 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 @@ -30,6 +34,7 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - HitBTC Setup() init error") } hitbtcConfig.API.AuthenticatedSupport = true + hitbtcConfig.API.AuthenticatedWebsocketSupport = true hitbtcConfig.API.Credentials.Key = apiKey hitbtcConfig.API.Credentials.Secret = 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) } @@ -383,3 +388,107 @@ func TestGetDepositAddress(t *testing.T) { } } } +func setupWsAuth(t *testing.T) { + TestSetDefaults(t) + TestSetup(t) + if !h.Websocket.IsEnabled() && !h.API.AuthenticatedWebsocketSupport || !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 af28bfce..d4e4590a 100644 --- a/exchanges/hitbtc/hitbtc_websocket.go +++ b/exchanges/hitbtc/hitbtc_websocket.go @@ -10,9 +10,11 @@ import ( "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" + "github.com/thrasher-/gocryptotrader/common/crypto" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/asset" + "github.com/thrasher-/gocryptotrader/exchanges/nonce" "github.com/thrasher-/gocryptotrader/exchanges/orderbook" log "github.com/thrasher-/gocryptotrader/logger" ) @@ -22,6 +24,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() { @@ -46,6 +50,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 @@ -90,86 +99,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: asset.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: asset.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 { @@ -242,7 +311,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.GetEnabledPairs(asset.Spot) for i := range channels { for j := range enabledCurrencies { @@ -259,11 +333,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{ @@ -316,7 +391,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 := crypto.GetHMAC(crypto.HashSHA256, []byte(nonce), []byte(h.API.Credentials.Secret)) + request := WsLoginRequest{ + Method: "login", + Params: WsLoginData{ + Algo: "HS256", + PKey: h.API.Credentials.Key, + Nonce: nonce, + Signature: crypto.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: strings.ToLower(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 5506c5d2..99734a78 100644 --- a/exchanges/hitbtc/hitbtc_wrapper.go +++ b/exchanges/hitbtc/hitbtc_wrapper.go @@ -427,18 +427,12 @@ func (h *HitBTC) GetActiveOrders(getOrdersRequest *exchange.GetOrdersRequest) ([ symbol := currency.NewPairDelimiter(allOrders[i].Symbol, h.CurrencyPairs.Get(asset.Spot).ConfigFormat.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, }) @@ -471,18 +465,12 @@ func (h *HitBTC) GetOrderHistory(getOrdersRequest *exchange.GetOrdersRequest) ([ symbol := currency.NewPairDelimiter(allOrders[i].Symbol, h.CurrencyPairs.Get(asset.Spot).ConfigFormat.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, }) @@ -506,3 +494,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 a9290c94..bbac3f90 100644 --- a/exchanges/huobi/huobi.go +++ b/exchanges/huobi/huobi.go @@ -64,9 +64,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 } // GetSpotKline returns kline data diff --git a/exchanges/huobi/huobi_test.go b/exchanges/huobi/huobi_test.go index 033dbfcb..79f28728 100644 --- a/exchanges/huobi/huobi_test.go +++ b/exchanges/huobi/huobi_test.go @@ -9,12 +9,15 @@ import ( "strconv" "strings" "testing" + "time" + "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" "github.com/thrasher-/gocryptotrader/common/crypto" "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. @@ -26,6 +29,7 @@ const ( ) var h HUOBI +var wsSetupRan bool func TestSetDefaults(t *testing.T) { h.SetDefaults() @@ -39,12 +43,54 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - Huobi Setup() init error") } hConfig.API.AuthenticatedSupport = true + hConfig.API.AuthenticatedWebsocketSupport = true hConfig.API.Credentials.Key = apiKey hConfig.API.Credentials.Secret = apiSecret h.Setup(hConfig) } +func setupWsTests(t *testing.T) { + if wsSetupRan { + return + } + TestSetDefaults(t) + TestSetup(t) + if !h.Websocket.IsEnabled() && !h.API.AuthenticatedWebsocketSupport || !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{ @@ -592,3 +638,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 bc7f9286..702bdf44 100644 --- a/exchanges/huobi/huobi_websocket.go +++ b/exchanges/huobi/huobi_websocket.go @@ -13,6 +13,7 @@ import ( "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" + "github.com/thrasher-/gocryptotrader/common/crypto" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/asset" @@ -21,12 +22,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() { @@ -44,140 +66,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 strings.Contains(init.Topic, "orders") && + strings.Contains(init.Topic, "update"): + var response WsAuthenticatedOrdersUpdateResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case strings.Contains(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 strings.Contains(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 := strings.Split(depth.Channel, ".") - - h.WsProcessOrderbook(&depth, data[1]) - - case strings.Contains(init.Channel, "kline"): - var kline WsKline - err := common.JSONDecode(resp.Raw, &kline) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - data := strings.Split(kline.Channel, ".") - - h.Websocket.DataHandler <- exchange.KlineData{ - Timestamp: time.Unix(0, kline.Timestamp), - Exchange: h.GetName(), - AssetType: asset.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 strings.Contains(init.Channel, "trade"): - var trade WsTrade - err := common.JSONDecode(resp.Raw, &trade) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - data := strings.Split(trade.Channel, ".") - - h.Websocket.DataHandler <- exchange.TradeData{ - Exchange: h.GetName(), - AssetType: asset.Spot, - CurrencyPair: currency.NewPairFromString(data[1]), - Timestamp: time.Unix(0, trade.Tick.Timestamp), - } - } + switch { + case strings.Contains(init.Channel, "depth"): + var depth WsDepth + err := common.JSONDecode(resp.Raw, &depth) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := strings.Split(depth.Channel, ".") + h.WsProcessOrderbook(&depth, data[1]) + case strings.Contains(init.Channel, "kline"): + var kline WsKline + err := common.JSONDecode(resp.Raw, &kline) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := strings.Split(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 strings.Contains(init.Channel, "trade"): + var trade WsTrade + err := common.JSONDecode(resp.Raw, &trade) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := strings.Split(trade.Channel, ".") + h.Websocket.DataHandler <- exchange.TradeData{ + Exchange: h.GetName(), + AssetType: "SPOT", + CurrencyPair: currency.NewPairFromString(data[1]), + Timestamp: time.Unix(0, trade.Tick.Timestamp), } } } @@ -222,8 +368,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.GetEnabledPairs(asset.Spot) - subscriptions := []exchange.WebsocketChannelSubscription{} for i := range channels { for j := range enabledCurrencies { enabledCurrencies[j].Delimiter = "" @@ -239,11 +391,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 strings.Contains(channelToSubscribe.Channel, "orders.") || + strings.Contains(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 } @@ -252,6 +404,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 strings.Contains(channelToSubscribe.Channel, "orders.") || + strings.Contains(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 @@ -268,3 +424,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.API.Credentials.Key, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + } + hmac := h.wsGenerateSignature(timestamp, wsAccountsOrdersEndPoint) + request.Signature = crypto.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.API.Credentials.Key) + 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 crypto.GetHMAC(crypto.HashSHA256, []byte(payload), []byte(h.API.Credentials.Secret)) +} + +func (h *HUOBI) wsAuthenticatedSubscribe(operation, endpoint, topic string) error { + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticatedSubscriptionRequest{ + Op: operation, + AccessKeyID: h.API.Credentials.Key, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: topic, + } + hmac := h.wsGenerateSignature(timestamp, endpoint) + request.Signature = crypto.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.API.Credentials.Key, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsAccountsList, + Symbol: pair, + } + hmac := h.wsGenerateSignature(timestamp, wsAccountListEndpoint) + request.Signature = crypto.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.API.Credentials.Key, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsOrdersList, + AccountID: accountID, + Symbol: pair.Lower(), + States: "submitted,partial-filled", + } + hmac := h.wsGenerateSignature(timestamp, wsOrdersListEndpoint) + request.Signature = crypto.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.API.Credentials.Key, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsOrdersDetail, + OrderID: orderID, + } + hmac := h.wsGenerateSignature(timestamp, wsOrdersDetailEndpoint) + request.Signature = crypto.Base64Encode(hmac) + return h.wsAuthenticatedSend(request) +} diff --git a/exchanges/huobi/huobi_wrapper.go b/exchanges/huobi/huobi_wrapper.go index a09a9ea2..137d1cd3 100644 --- a/exchanges/huobi/huobi_wrapper.go +++ b/exchanges/huobi/huobi_wrapper.go @@ -117,7 +117,7 @@ func (h *HUOBI) Setup(exch *config.ExchangeConfig) error { exch.Name, exch.Features.Enabled.Websocket, exch.Verbose, - huobiSocketIOAddress, + wsMarketURL, exch.API.Endpoints.WebsocketURL) } @@ -133,7 +133,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) h.PrintEnabledPairs() } @@ -635,3 +635,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 ace01ae7..23f5a7bf 100644 --- a/exchanges/huobihadax/huobihadax.go +++ b/exchanges/huobihadax/huobihadax.go @@ -60,7 +60,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 } diff --git a/exchanges/huobihadax/huobihadax_test.go b/exchanges/huobihadax/huobihadax_test.go index be5c4524..b2990155 100644 --- a/exchanges/huobihadax/huobihadax_test.go +++ b/exchanges/huobihadax/huobihadax_test.go @@ -4,12 +4,15 @@ 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/asset" + "github.com/thrasher-/gocryptotrader/exchanges/sharedtestvalues" ) // Please supply your own APIKEYS here for due diligence testing @@ -22,6 +25,7 @@ const ( ) var h HUOBIHADAX +var wsSetupRan bool // getDefaultConfig returns a default hadax config func getDefaultConfig() config.ExchangeConfig { @@ -89,12 +93,54 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - HuobiHadax Setup() init error") } hadaxConfig.API.AuthenticatedSupport = true + hadaxConfig.API.AuthenticatedWebsocketSupport = true hadaxConfig.API.Credentials.Key = apiKey hadaxConfig.API.Credentials.Secret = apiSecret h.Setup(hadaxConfig) } +func setupWsTests(t *testing.T) { + if wsSetupRan { + return + } + TestSetDefaults(t) + TestSetup(t) + if !h.Websocket.IsEnabled() && !h.API.AuthenticatedWebsocketSupport || !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{ @@ -627,3 +673,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 34efc9e6..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 @@ -313,11 +317,203 @@ type WsTrade struct { ID int64 `json:"id"` Timestamp int64 `json:"ts"` Data []struct { - ID float64 `json:"id"` - Timestamp int64 `json:"ts"` Amount float64 `json:"amount"` + Timestamp int64 `json:"ts"` + 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 81822531..2747a081 100644 --- a/exchanges/huobihadax/huobihadax_websocket.go +++ b/exchanges/huobihadax/huobihadax_websocket.go @@ -13,6 +13,7 @@ import ( "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" + "github.com/thrasher-/gocryptotrader/common/crypto" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/asset" @@ -20,15 +21,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() { @@ -46,141 +66,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 strings.Contains(init.Topic, "orders") && + strings.Contains(init.Topic, "update"): + var response WsAuthenticatedOrdersUpdateResponse + err := common.JSONDecode(resp.Raw, &response) + if err != nil { + h.Websocket.DataHandler <- err + } + h.Websocket.DataHandler <- response + case strings.Contains(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 strings.Contains(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 := strings.Split(depth.Channel, ".") - - h.WsProcessOrderbook(&depth, data[1]) - - case strings.Contains(init.Channel, "kline"): - var kline WsKline - err := common.JSONDecode(resp.Raw, &kline) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - data := strings.Split(kline.Channel, ".") - - h.Websocket.DataHandler <- exchange.KlineData{ - Timestamp: time.Unix(0, kline.Timestamp), - Exchange: h.GetName(), - AssetType: asset.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 strings.Contains(init.Channel, "trade"): - var trade WsTrade - err := common.JSONDecode(resp.Raw, &trade) - if err != nil { - h.Websocket.DataHandler <- err - continue - } - - data := strings.Split(trade.Channel, ".") - - h.Websocket.DataHandler <- exchange.TradeData{ - Exchange: h.GetName(), - AssetType: asset.Spot, - CurrencyPair: currency.NewPairFromString(data[1]), - Timestamp: time.Unix(0, trade.Tick.Timestamp), - } - } + switch { + case strings.Contains(init.Channel, "depth"): + var depth WsDepth + err := common.JSONDecode(resp.Raw, &depth) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := strings.Split(depth.Channel, ".") + h.WsProcessOrderbook(&depth, data[1]) + case strings.Contains(init.Channel, "kline"): + var kline WsKline + err := common.JSONDecode(resp.Raw, &kline) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := strings.Split(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 strings.Contains(init.Channel, "trade"): + var trade WsTrade + err := common.JSONDecode(resp.Raw, &trade) + if err != nil { + h.Websocket.DataHandler <- err + return + } + data := strings.Split(trade.Channel, ".") + h.Websocket.DataHandler <- exchange.TradeData{ + Exchange: h.GetName(), + AssetType: "SPOT", + CurrencyPair: currency.NewPairFromString(data[1]), + Timestamp: time.Unix(0, trade.Tick.Timestamp), } } } @@ -225,8 +368,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.GetEnabledPairs(asset.Spot) - subscriptions := []exchange.WebsocketChannelSubscription{} for i := range channels { for j := range enabledCurrencies { enabledCurrencies[j].Delimiter = "" @@ -242,6 +391,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 strings.Contains(channelToSubscribe.Channel, "orders.") || + strings.Contains(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 @@ -251,6 +404,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 strings.Contains(channelToSubscribe.Channel, "orders.") || + strings.Contains(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 @@ -267,3 +424,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.API.Credentials.Key, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + } + hmac := h.wsGenerateSignature(timestamp, wsAccountsOrdersEndPoint) + request.Signature = crypto.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.API.Credentials.Key) + 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 crypto.GetHMAC(crypto.HashSHA256, []byte(payload), []byte(h.API.Credentials.Secret)) +} + +func (h *HUOBIHADAX) wsAuthenticatedSubscribe(operation, endpoint, topic string) error { + timestamp := time.Now().UTC().Format(wsDateTimeFormatting) + request := WsAuthenticatedSubscriptionRequest{ + Op: operation, + AccessKeyID: h.API.Credentials.Key, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: topic, + } + hmac := h.wsGenerateSignature(timestamp, endpoint) + request.Signature = crypto.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.API.Credentials.Key, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsAccountsList, + Symbol: pair, + } + hmac := h.wsGenerateSignature(timestamp, wsAccountListEndpoint) + request.Signature = crypto.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.API.Credentials.Key, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsOrdersList, + AccountID: accountID, + Symbol: pair.Lower(), + States: "submitted,partial-filled", + } + hmac := h.wsGenerateSignature(timestamp, wsOrdersListEndpoint) + request.Signature = crypto.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.API.Credentials.Key, + SignatureMethod: signatureMethod, + SignatureVersion: signatureVersion, + Timestamp: timestamp, + Topic: wsOrdersDetail, + OrderID: orderID, + } + hmac := h.wsGenerateSignature(timestamp, wsOrdersDetailEndpoint) + request.Signature = crypto.Base64Encode(hmac) + return h.wsAuthenticatedSend(request) +} diff --git a/exchanges/huobihadax/huobihadax_wrapper.go b/exchanges/huobihadax/huobihadax_wrapper.go index ee30a61c..fc50405a 100644 --- a/exchanges/huobihadax/huobihadax_wrapper.go +++ b/exchanges/huobihadax/huobihadax_wrapper.go @@ -114,7 +114,7 @@ func (h *HUOBIHADAX) Setup(exch *config.ExchangeConfig) error { exch.Name, exch.Features.Enabled.Websocket, exch.Verbose, - huobiGlobalWebsocketEndpoint, + HuobiHadaxSocketIOAddress, exch.API.Endpoints.WebsocketURL) } @@ -577,3 +577,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/interfaces.go b/exchanges/interfaces.go index a55fe432..6727fd61 100644 --- a/exchanges/interfaces.go +++ b/exchanges/interfaces.go @@ -28,7 +28,7 @@ type IBotExchange interface { GetEnabledPairs(assetType asset.Item) currency.Pairs GetAvailablePairs(assetType asset.Item) currency.Pairs GetAccountInfo() (AccountInfo, error) - GetAuthenticatedAPISupport() bool + GetAuthenticatedAPISupport(endpoint uint8) bool SetPairs(pairs currency.Pairs, assetType asset.Item, enabled bool) error GetAssetTypes() asset.Items GetExchangeHistory(currencyPair currency.Pair, assetType asset.Item) ([]TradeHistory, error) @@ -61,4 +61,6 @@ type IBotExchange interface { SubscribeToWebsocketChannels(channels []WebsocketChannelSubscription) error UnsubscribeToWebsocketChannels(channels []WebsocketChannelSubscription) error GetDefaultConfig() (*config.ExchangeConfig, error) + GetSubscriptions() ([]WebsocketChannelSubscription, error) + AuthenticateWebsocket() error } diff --git a/exchanges/itbit/itbit_wrapper.go b/exchanges/itbit/itbit_wrapper.go index 1df2ca02..07963c36 100644 --- a/exchanges/itbit/itbit_wrapper.go +++ b/exchanges/itbit/itbit_wrapper.go @@ -509,3 +509,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 b6d1ed52..80cf058a 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 @@ -666,7 +667,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 { @@ -715,7 +716,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 6d0150d4..662306ea 100644 --- a/exchanges/kraken/kraken_websocket.go +++ b/exchanges/kraken/kraken_websocket.go @@ -752,7 +752,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.GetEnabledPairs(asset.Spot) - 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 e57108b6..8ee63788 100644 --- a/exchanges/kraken/kraken_wrapper.go +++ b/exchanges/kraken/kraken_wrapper.go @@ -525,3 +525,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 f222727f..87ff7b58 100644 --- a/exchanges/lakebtc/lakebtc_wrapper.go +++ b/exchanges/lakebtc/lakebtc_wrapper.go @@ -465,3 +465,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 9193be10..9ca6b872 100644 --- a/exchanges/localbitcoins/localbitcoins_wrapper.go +++ b/exchanges/localbitcoins/localbitcoins_wrapper.go @@ -539,3 +539,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_test.go b/exchanges/okcoin/okcoin_test.go index 231d9324..53204577 100644 --- a/exchanges/okcoin/okcoin_test.go +++ b/exchanges/okcoin/okcoin_test.go @@ -12,7 +12,9 @@ import ( "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/asset" "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 +71,15 @@ func TestSetup(t *testing.T) { } okcoinConfig.API.AuthenticatedSupport = true + okcoinConfig.API.AuthenticatedWebsocketSupport = true okcoinConfig.API.Credentials.Key = apiKey okcoinConfig.API.Credentials.Secret = apiSecret okcoinConfig.API.Credentials.ClientID = passphrase okcoinConfig.API.Endpoints.WebsocketURL = o.API.Endpoints.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 { @@ -796,13 +800,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.API.AuthenticatedWebsocketSupport || !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 { @@ -826,16 +829,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) } } @@ -844,7 +843,7 @@ func TestSendWsMessages(t *testing.T) { func TestGetAssetTypeFromTableName(t *testing.T) { str := "spot/candle300s:BTC-USDT" spot := o.GetAssetTypeFromTableName(str) - if spot != "SPOT" { + if !strings.EqualFold(spot.String(), asset.Spot.String()) { t.Errorf("Error, expected 'SPOT', received: '%v'", spot) } } diff --git a/exchanges/okex/okex_test.go b/exchanges/okex/okex_test.go index 06271113..7f784554 100644 --- a/exchanges/okex/okex_test.go +++ b/exchanges/okex/okex_test.go @@ -13,7 +13,9 @@ import ( "github.com/thrasher-/gocryptotrader/config" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" + "github.com/thrasher-/gocryptotrader/exchanges/asset" "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 +72,15 @@ func TestSetup(t *testing.T) { websocketEnabled = true } okexConfig.API.AuthenticatedSupport = true + okexConfig.API.AuthenticatedWebsocketSupport = true okexConfig.API.Credentials.Key = apiKey okexConfig.API.Credentials.Secret = apiSecret okexConfig.API.Credentials.ClientID = passphrase okexConfig.API.Endpoints.WebsocketURL = o.API.Endpoints.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 { @@ -1557,13 +1561,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.API.AuthenticatedWebsocketSupport || !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 { @@ -1587,16 +1590,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) } } @@ -1605,7 +1604,7 @@ func TestSendWsMessages(t *testing.T) { func TestGetAssetTypeFromTableName(t *testing.T) { str := "spot/candle300s:BTC-USDT" spot := o.GetAssetTypeFromTableName(str) - if spot != "SPOT" { + if !strings.EqualFold(spot.String(), asset.Spot.String()) { t.Errorf("Error, expected 'SPOT', received: '%v'", spot) } } 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 9aebb51c..b00e849d 100644 --- a/exchanges/okgroup/okgroup_websocket.go +++ b/exchanges/okgroup/okgroup_websocket.go @@ -200,8 +200,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 @@ -301,10 +307,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 } } @@ -313,6 +323,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" @@ -325,10 +336,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 @@ -371,6 +384,7 @@ func (o *OKGroup) GetAssetTypeFromTableName(table string) asset.Item { // 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: @@ -684,7 +698,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.GetEnabledPairs(asset.Spot) - 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 = "-" @@ -703,6 +720,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 9e839b56..572ae026 100644 --- a/exchanges/okgroup/okgroup_wrapper.go +++ b/exchanges/okgroup/okgroup_wrapper.go @@ -446,3 +446,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_test.go b/exchanges/poloniex/poloniex_test.go index b4122ca2..4e4ef942 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 = "" @@ -30,6 +33,7 @@ func TestSetup(t *testing.T) { t.Error("Test Failed - Poloniex Setup() init error") } poloniexConfig.API.AuthenticatedSupport = true + poloniexConfig.API.AuthenticatedWebsocketSupport = true poloniexConfig.API.Credentials.Key = apiKey poloniexConfig.API.Credentials.Secret = apiSecret p.SetDefaults() @@ -410,3 +414,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.API.AuthenticatedWebsocketSupport || !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 9ecb7616..d6415070 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 { @@ -402,3 +406,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 a4f91ff0..e81b05a6 100644 --- a/exchanges/poloniex/poloniex_websocket.go +++ b/exchanges/poloniex/poloniex_websocket.go @@ -11,6 +11,7 @@ import ( "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" + "github.com/thrasher-/gocryptotrader/common/crypto" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/asset" @@ -131,43 +132,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 - - currencyPair := currencyIDMap[int(tickerData[0].(float64))] - 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(), - Pair: currency.NewPairDelimiter(currencyPair, "_"), - Exchange: p.GetName(), - AssetType: asset.Spot, - ClosePrice: t.LastPrice, - LowPrice: t.LowestAsk, - HighPrice: t.HighestBid, - } + p.wsHandleTickerData(data) case ws24HourExchangeVolumeID: case wsHeartbeat: default: @@ -248,6 +222,90 @@ func (p *Poloniex) WsHandleData() { } } +func (p *Poloniex) wsHandleTickerData(data []interface{}) { + tickerData := data[2].([]interface{}) + var t WsTicker + currencyPair := currencyIDMap[int(tickerData[0].(float64))] + 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(), + Pair: currency.NewPairDelimiter(currencyPair, "_"), + Exchange: p.GetName(), + AssetType: asset.Spot, + ClosePrice: t.LastPrice, + LowPrice: t.LowestAsk, + HighPrice: t.HighestBid, + Quantity: t.QuoteCurrencyVolume24H, + } +} + +// 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 { @@ -334,12 +392,18 @@ func (p *Poloniex) WsProcessOrderbookUpdate(target []interface{}, symbol 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.GetEnabledPairs(asset.Spot) for j := range enabledCurrencies { enabledCurrencies[j].Delimiter = "_" @@ -356,9 +420,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) @@ -369,9 +436,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) @@ -390,3 +460,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 := crypto.GetHMAC(crypto.HashSHA512, []byte(nonce), []byte(p.API.Credentials.Secret)) + request := WsAuthorisationRequest{ + Command: command, + Channel: 1000, + Sign: crypto.HexEncodeToString(hmac), + Key: p.API.Credentials.Key, + Payload: nonce, + } + return p.wsSend(request) +} diff --git a/exchanges/poloniex/poloniex_wrapper.go b/exchanges/poloniex/poloniex_wrapper.go index 2f9ea6ca..f0ab0cc3 100644 --- a/exchanges/poloniex/poloniex_wrapper.go +++ b/exchanges/poloniex/poloniex_wrapper.go @@ -529,3 +529,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/websocket.go b/exchanges/websocket.go index 42e38471..0095bb2b 100644 --- a/exchanges/websocket.go +++ b/exchanges/websocket.go @@ -51,6 +51,7 @@ func (e *Base) WebsocketSetup(connector func() error, e.Websocket.SetConnector(connector) e.Websocket.SetWebsocketURL(runningURL) e.Websocket.SetExchangeName(exchangeName) + e.Websocket.SetCanUseAuthenticatedEndpoints(e.API.AuthenticatedWebsocketSupport) e.Websocket.init = false e.Websocket.noConnectionCheckLimit = 5 @@ -674,6 +675,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)) @@ -839,7 +855,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 } @@ -856,3 +880,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/websocket_test.go b/exchanges/websocket_test.go index ab182537..205d5f2f 100644 --- a/exchanges/websocket_test.go +++ b/exchanges/websocket_test.go @@ -567,3 +567,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/websocket_types.go b/exchanges/websocket_types.go index c67f8e95..a636bb66 100644 --- a/exchanges/websocket_types.go +++ b/exchanges/websocket_types.go @@ -20,17 +20,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" @@ -89,7 +99,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/yobit/yobit_wrapper.go b/exchanges/yobit/yobit_wrapper.go index a9121ec6..2d5ac159 100644 --- a/exchanges/yobit/yobit_wrapper.go +++ b/exchanges/yobit/yobit_wrapper.go @@ -500,3 +500,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_test.go b/exchanges/zb/zb_test.go index 2cde1af8..cecc804e 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.API.AuthenticatedSupport = true + zbConfig.API.AuthenticatedWebsocketSupport = true zbConfig.API.Credentials.Key = apiKey zbConfig.API.Credentials.Secret = apiSecret z.Setup(zbConfig) } +func setupWsAuth(t *testing.T) { + if wsSetupRan { + return + } + z.SetDefaults() + TestSetup(t) + if !z.Websocket.IsEnabled() && !z.API.AuthenticatedWebsocketSupport || !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() @@ -462,3 +490,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 26422087..6e3bcefa 100644 --- a/exchanges/zb/zb_websocket.go +++ b/exchanges/zb/zb_websocket.go @@ -5,11 +5,13 @@ import ( "fmt" "net/http" "net/url" + "regexp" "strings" "time" "github.com/gorilla/websocket" "github.com/thrasher-/gocryptotrader/common" + "github.com/thrasher-/gocryptotrader/common/crypto" "github.com/thrasher-/gocryptotrader/currency" exchange "github.com/thrasher-/gocryptotrader/exchanges" "github.com/thrasher-/gocryptotrader/exchanges/asset" @@ -18,7 +20,8 @@ import ( ) const ( - zbWebsocketAPI = "wss://api.zb.cn:9999/websocket" + zbWebsocketAPI = "wss://api.zb.cn:9999/websocket" + zWebsocketAddChannel = "addChannel" ) // WsConnect initiates a websocket connection @@ -82,9 +85,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 @@ -108,7 +111,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 @@ -126,7 +129,7 @@ func (z *ZB) WsHandleData() { case strings.Contains(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 @@ -175,13 +178,16 @@ func (z *ZB) WsHandleData() { case strings.Contains(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 := strings.Split(result.Channel, "_") @@ -197,7 +203,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 strings.Contains(result.Channel, "_order"): + var response WsSubmitOrderResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case strings.Contains(result.Channel, "_cancelorder"): + var response WsCancelOrderResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case strings.Contains(result.Channel, "_getorders"): + var response WsGetOrdersResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case strings.Contains(result.Channel, "_getorder"): + var response WsGetOrderResponse + err := common.JSONDecode(fixedJSON, &response) + if err != nil { + z.Websocket.DataHandler <- err + continue + } + z.Websocket.DataHandler <- response + case strings.Contains(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 @@ -242,7 +327,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", @@ -264,7 +349,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) @@ -279,7 +364,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.API.Credentials.Key + 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.API.Credentials.Key + 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.API.Credentials.Key + 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.API.Credentials.Key + 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 := crypto.GetHMAC(crypto.HashMD5, + jsonResponse, + []byte(crypto.Sha1ToHex(z.API.Credentials.Secret))) + 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 := strings.Replace(string(matchingResults), "\"", "", 1) + // Remove last quote character + fixedJSON := capturedInvalidZBJSON[:len(capturedInvalidZBJSON)-1] + return []byte(strings.Replace(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.API.Credentials.Key + 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.API.Credentials.Key + 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.API.Credentials.Key + 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.API.Credentials.Key + 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.API.Credentials.Key + 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.API.Credentials.Key, + 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 03b0f5fe..9996c924 100644 --- a/exchanges/zb/zb_wrapper.go +++ b/exchanges/zb/zb_wrapper.go @@ -541,3 +541,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/testdata/configtest.json b/testdata/configtest.json index c0efb62f..8ccdff17 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,