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