Files
gocryptotrader/exchanges/bittrex/bittrex_websocket.go
Scott 197ef2df21 Feature: Data history manager engine subsystem (#693)
* Adds lovely initial concept for historical data doer

* Adds ability to save tasks. Adds config. Adds startStop to engine

* Has a database microservice without use of globals! Further infrastructure design. Adds readme

* Commentary to help design

* Adds migrations for database

* readme and adds database models

* Some modelling that doesn't work end of day

* Completes datahistoryjob sql.Begins datahistoryjobresult

* Adds datahistoryjob functions to retreive job results. Adapts subsystem

* Adds process for upserting jobs and job results to the database

* Broken end of day weird sqlboiler crap

* Fixes issue with SQL generation.

* RPC generation and addition of basic upsert command

* Renames types

* Adds rpc functions

* quick commit before context swithc. Exchanges aren't being populated

* Begin the tests!

* complete sql tests. stop failed jobs. CLI command creation

* Defines rpc commands

* Fleshes out RPC implementation

* Expands testing

* Expands testing, removes double remove

* Adds coverage of data history subsystem, expands errors and nil checks

* Minor logic improvement

* streamlines datahistory test setup

* End of day minor linting

* Lint, convert simplify, rpc expansion, type expansion, readme expansion

* Documentation update

* Renames for consistency

* Completes RPC server commands

* Fixes tests

* Speeds up testing by reducing unnecessary actions. Adds maxjobspercycle config

* Comments for everything

* Adds missing result string. checks interval supported. default start end cli

* Fixes ID problem. Improves binance trade fetch. job ranges are processed

* adds dbservice coverage. adds rpcserver coverage

* docs regen, uses dbcon interface, reverts binance, fixes races, toggle manager

* Speed up tests, remove bad global usage, fix uuid check

* Adds verbose. Updates docs. Fixes postgres

* Minor changes to logging and start stop

* Fixes postgres db tests, fixes postgres column typo

* Fixes old string typo,removes constraint,error parsing for nonreaders

* prevents dhm running when table doesn't exist. Adds prereq documentation

* Adds parallel, rmlines, err fix, comment fix, minor param fixes

* doc regen, common time range check and test updating

* Fixes job validation issues. Updates candle range checker.

* Ensures test cannot fail due to time.Now() shenanigans

* Fixes oopsie, adds documentation and a warn

* Fixes another time test, adjusts copy

* Drastically speeds up data history manager tests via function overrides

* Fixes summary bug and better logs

* Fixes local time test, fixes websocket tests

* removes defaults and comment,updates error messages,sets cli command args

* Fixes FTX trade processing

* Fixes issue where jobs got stuck if data wasn't returned but retrieval was successful

* Improves test speed. Simplifies trade verification SQL. Adds command help

* Fixes the oopsies

* Fixes use of query within transaction. Fixes trade err

* oopsie, not needed

* Adds missing data status. Properly ends job even when data is missing

* errors are more verbose and so have more words to describe them

* Doc regen for new status

* tiny test tinkering

* str := string("Removes .String()").String()

* Merge fixups

* Fixes a data race discovered during github actions

* Allows websocket test to pass consistently

* Fixes merge issue preventing datahistorymanager from starting via config

* Niterinos cmd defaults and explanations

* fixes default oopsie

* Fixes lack of nil protection

* Additional oopsie

* More detailed error for validating job exchange
2021-07-01 16:21:48 +10:00

619 lines
17 KiB
Go

package bittrex
import (
"bytes"
"compress/flate"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strconv"
"sync"
"time"
"github.com/gofrs/uuid"
"github.com/gorilla/websocket"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/common/crypto"
"github.com/thrasher-corp/gocryptotrader/currency"
exchange "github.com/thrasher-corp/gocryptotrader/exchanges"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
"github.com/thrasher-corp/gocryptotrader/exchanges/request"
"github.com/thrasher-corp/gocryptotrader/exchanges/stream"
"github.com/thrasher-corp/gocryptotrader/exchanges/ticker"
"github.com/thrasher-corp/gocryptotrader/log"
)
const (
bittrexAPIWSURL = "wss://socket-v3.bittrex.com/signalr"
bittrexAPIWSNegotiationsURL = "https://socket-v3.bittrex.com/signalr"
bittrexWebsocketTimer = 13 * time.Second
wsTicker = "ticker"
wsOrderbook = "orderbook"
wsMarketSummary = "market_summary"
wsOrders = "order"
wsHeartbeat = "heartbeat"
authenticate = "Authenticate"
subscribe = "subscribe"
unsubscribe = "unsubscribe"
wsRateLimit = 50
wsMessageRateLimit = 60
)
var defaultSpotSubscribedChannels = []string{
// wsHeartbeat,
wsOrderbook,
wsTicker,
wsMarketSummary,
}
var defaultSpotSubscribedChannelsAuth = []string{
wsOrders,
}
// TickerCache holds ticker and market summary data
// in order to combine them when processing data
type TickerCache struct {
MarketSummaries map[string]*MarketSummaryData
Tickers map[string]*TickerData
sync.RWMutex
}
// WsConnect connects to a websocket feed
func (b *Bittrex) WsConnect() error {
if !b.Websocket.IsEnabled() || !b.IsEnabled() {
return errors.New(stream.WebsocketNotEnabled)
}
var wsHandshakeData WsSignalRHandshakeData
err := b.WsSignalRHandshake(&wsHandshakeData)
if err != nil {
return err
}
var dialer websocket.Dialer
endpoint, err := b.API.Endpoints.GetURL(exchange.WebsocketSpot)
if err != nil {
return err
}
params := url.Values{}
params.Set("clientProtocol", "1.5")
params.Set("transport", "webSockets")
params.Set("connectionToken", wsHandshakeData.ConnectionToken)
params.Set("connectionData", "[{name:\"c3\"}]")
params.Set("tid", "10")
path := common.EncodeURLValues("/connect", params)
err = b.Websocket.SetWebsocketURL(endpoint+path, false, false)
if err != nil {
return err
}
err = b.Websocket.Conn.Dial(&dialer, http.Header{})
if err != nil {
return err
}
// Can set up custom ping handler per websocket connection.
b.Websocket.Conn.SetupPingHandler(stream.PingHandler{
MessageType: websocket.PingMessage,
Delay: bittrexWebsocketTimer,
})
// This reader routine is called prior to initiating a subscription for
// efficient processing.
go b.wsReadData()
b.setupOrderbookManager()
b.tickerCache = &TickerCache{
MarketSummaries: make(map[string]*MarketSummaryData),
Tickers: make(map[string]*TickerData),
}
if b.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) {
err = b.WsAuth()
if err != nil {
b.Websocket.DataHandler <- err
b.Websocket.SetCanUseAuthenticatedEndpoints(false)
}
}
return nil
}
// WsSignalRHandshake requests the SignalR connection token over https
func (b *Bittrex) WsSignalRHandshake(result interface{}) error {
endpoint, err := b.API.Endpoints.GetURL(exchange.WebsocketSpotSupplementary)
if err != nil {
return err
}
path := "/negotiate?connectionData=[{name:\"c3\"}]&clientProtocol=1.5"
return b.SendPayload(context.Background(), &request.Item{
Method: http.MethodGet,
Path: endpoint + path,
Result: result,
Verbose: b.Verbose,
HTTPDebugging: b.HTTPDebugging,
HTTPRecording: b.HTTPRecording,
})
}
// WsAuth sends an authentication message to receive auth data
// Authentications expire after 10 minutes
func (b *Bittrex) WsAuth() error {
// [apiKey, timestamp in ms, random uuid, signed payload]
apiKey := b.API.Credentials.Key
randomContent, err := uuid.NewV4()
if err != nil {
return err
}
timestamp := strconv.FormatInt(time.Now().UnixNano()/1000000, 10)
hmac := crypto.GetHMAC(
crypto.HashSHA512,
[]byte(timestamp+randomContent.String()),
[]byte(b.API.Credentials.Secret),
)
signature := crypto.HexEncodeToString(hmac)
req := WsEventRequest{
Hub: "c3",
Method: authenticate,
InvocationID: b.Websocket.Conn.GenerateMessageID(false),
}
arguments := make([]string, 0)
arguments = append(arguments, apiKey, timestamp, randomContent.String(), signature)
req.Arguments = arguments
requestString, err := json.Marshal(req)
if err != nil {
return err
}
if b.Verbose {
log.Debugf(log.WebsocketMgr, "%s Sending JSON message - %s\n", b.Name, requestString)
}
respRaw, err := b.Websocket.Conn.SendMessageReturnResponse(req.InvocationID, req)
if err != nil {
return err
}
var response WsAuthResponse
err = json.Unmarshal(respRaw, &response)
if err != nil {
log.Warnf(log.WebsocketMgr, "%s - Cannot unmarshal into WsAuthResponse (%s)\n", b.Name, string(respRaw))
return err
}
if !response.Response.Success {
log.Warnf(log.WebsocketMgr, "%s - Unable to authenticate (%s)", b.Name, response.Response.ErrorCode)
b.Websocket.SetCanUseAuthenticatedEndpoints(false)
}
return nil
}
// GenerateDefaultSubscriptions Adds default subscriptions to websocket to be
// handled by ManageSubscriptions()
func (b *Bittrex) GenerateDefaultSubscriptions() ([]stream.ChannelSubscription, error) {
var subscriptions []stream.ChannelSubscription
pairs, err := b.GetEnabledPairs(asset.Spot)
if err != nil {
return nil, err
}
channels := defaultSpotSubscribedChannels
if b.GetAuthenticatedAPISupport(exchange.WebsocketAuthentication) {
channels = append(channels, defaultSpotSubscribedChannelsAuth...)
}
for i := range pairs {
pair, err := b.FormatExchangeCurrency(pairs[i], asset.Spot)
if err != nil {
return nil, err
}
for y := range channels {
var channel string
switch channels[y] {
case wsOrderbook:
channel = channels[y] + "_" + pair.String() + "_" + strconv.FormatInt(orderbookDepth, 10)
case wsTicker:
channel = channels[y] + "_" + pair.String()
case wsMarketSummary:
channel = channels[y] + "_" + pair.String()
default:
channel = channels[y]
}
subscriptions = append(subscriptions,
stream.ChannelSubscription{
Channel: channel,
Currency: pair,
Asset: asset.Spot,
})
}
}
return subscriptions, nil
}
// Subscribe sends a websocket message to receive data from the channel
func (b *Bittrex) Subscribe(channelsToSubscribe []stream.ChannelSubscription) error {
var x int
var errs common.Errors
for x = 0; x+wsMessageRateLimit < len(channelsToSubscribe); x += wsMessageRateLimit {
err := b.subscribeSlice(channelsToSubscribe[x : x+wsMessageRateLimit])
if err != nil {
errs = append(errs, err)
}
}
err := b.subscribeSlice(channelsToSubscribe[x:])
if err != nil {
errs = append(errs, err)
}
if errs != nil {
return errs
}
return nil
}
func (b *Bittrex) subscribeSlice(channelsToSubscribe []stream.ChannelSubscription) error {
req := WsEventRequest{
Hub: "c3",
Method: subscribe,
InvocationID: b.Websocket.Conn.GenerateMessageID(false),
}
var channels []string
for i := range channelsToSubscribe {
channels = append(channels, channelsToSubscribe[i].Channel)
}
arguments := make([][]string, 0)
arguments = append(arguments, channels)
req.Arguments = arguments
requestString, err := json.Marshal(req)
if err != nil {
return err
}
if b.Verbose {
log.Debugf(log.WebsocketMgr, "%s - Sending JSON message - %s\n", b.Name, requestString)
}
respRaw, err := b.Websocket.Conn.SendMessageReturnResponse(req.InvocationID, req)
if err != nil {
return err
}
var response WsSubscriptionResponse
err = json.Unmarshal(respRaw, &response)
if err != nil {
return err
}
var errs common.Errors
for i := range response.Response {
if !response.Response[i].Success {
errs = append(errs, errors.New("unable to subscribe to "+channels[i]+" - error code "+response.Response[i].ErrorCode))
continue
}
b.Websocket.AddSuccessfulSubscriptions(channelsToSubscribe[i])
}
if errs != nil {
return errs
}
return nil
}
// Unsubscribe sends a websocket message to receive data from the channel
func (b *Bittrex) Unsubscribe(channelsToUnsubscribe []stream.ChannelSubscription) error {
var x int
var errs common.Errors
for x = 0; x+wsMessageRateLimit < len(channelsToUnsubscribe); x += wsMessageRateLimit {
err := b.unsubscribeSlice(channelsToUnsubscribe[x : x+wsMessageRateLimit])
if err != nil {
errs = append(errs, err)
}
}
err := b.unsubscribeSlice(channelsToUnsubscribe[x:])
if err != nil {
errs = append(errs, err)
}
if errs != nil {
return errs
}
return nil
}
func (b *Bittrex) unsubscribeSlice(channelsToUnsubscribe []stream.ChannelSubscription) error {
req := WsEventRequest{
Hub: "c3",
Method: unsubscribe,
InvocationID: b.Websocket.Conn.GenerateMessageID(false),
}
var channels []string
for i := range channelsToUnsubscribe {
channels = append(channels, channelsToUnsubscribe[i].Channel)
}
arguments := make([][]string, 0)
arguments = append(arguments, channels)
req.Arguments = arguments
requestString, err := json.Marshal(req)
if err != nil {
return err
}
if b.Verbose {
log.Debugf(log.WebsocketMgr, "%s - Sending JSON message - %s\n", b.Name, requestString)
}
respRaw, err := b.Websocket.Conn.SendMessageReturnResponse(req.InvocationID, req)
if err != nil {
return err
}
var response WsSubscriptionResponse
err = json.Unmarshal(respRaw, &response)
if err != nil {
return err
}
var errs common.Errors
for i := range response.Response {
if !response.Response[i].Success {
errs = append(errs, errors.New("unable to unsubscribe from "+channels[i]+" - error code "+response.Response[i].ErrorCode))
continue
}
b.Websocket.RemoveSuccessfulUnsubscriptions(channelsToUnsubscribe[i])
}
if errs != nil {
return errs
}
return nil
}
// wsReadData gets and passes on websocket messages for processing
func (b *Bittrex) wsReadData() {
b.Websocket.Wg.Add(1)
defer b.Websocket.Wg.Done()
for {
select {
case <-b.Websocket.ShutdownC:
return
default:
resp := b.Websocket.Conn.ReadMessage()
if resp.Raw == nil {
log.Warnf(log.WebsocketMgr, "%s Received empty message\n", b.Name)
return
}
err := b.wsHandleData(resp.Raw)
if err != nil {
b.Websocket.DataHandler <- err
}
}
}
}
func (b *Bittrex) wsDecodeMessage(encodedMessage string, v interface{}) error {
raw, err := crypto.Base64Decode(encodedMessage)
if err != nil {
return err
}
reader := flate.NewReader(bytes.NewBuffer(raw))
message, err := ioutil.ReadAll(reader)
if err != nil {
return err
}
return json.Unmarshal(message, v)
}
func (b *Bittrex) wsHandleData(respRaw []byte) error {
var response WsEventResponse
err := json.Unmarshal(respRaw, &response)
if err != nil {
log.Warnf(log.WebsocketMgr, "%s Cannot unmarshal into eventResponse (%s)\n", b.Name, string(respRaw))
return err
}
if response.Response != nil && response.InvocationID > 0 {
if b.Websocket.Match.IncomingWithData(response.InvocationID, respRaw) {
return nil
}
return errors.New("received response to unknown request")
}
if response.Response == nil && len(response.Message) == 0 && response.C == "" {
if b.Verbose {
log.Warnf(log.WebsocketMgr, "%s Received keep-alive (%s)\n", b.Name, string(respRaw))
}
return nil
}
for i := range response.Message {
switch response.Message[i].Method {
case "orderBook":
for j := range response.Message[i].Arguments {
var orderbookUpdate OrderbookUpdateMessage
err = b.wsDecodeMessage(response.Message[i].Arguments[j], &orderbookUpdate)
if err != nil {
return err
}
var init bool
init, err = b.UpdateLocalOBBuffer(&orderbookUpdate)
if err != nil {
if init {
return nil
}
return fmt.Errorf("%v - UpdateLocalCache error: %s",
b.Name,
err)
}
}
case "ticker":
for j := range response.Message[i].Arguments {
var tickerUpdate TickerData
err = b.wsDecodeMessage(response.Message[i].Arguments[j], &tickerUpdate)
if err != nil {
return err
}
err = b.WsProcessUpdateTicker(tickerUpdate)
if err != nil {
return err
}
}
case "marketSummary":
for j := range response.Message[i].Arguments {
var marketSummaryUpdate MarketSummaryData
err = b.wsDecodeMessage(response.Message[i].Arguments[j], &marketSummaryUpdate)
if err != nil {
return err
}
err = b.WsProcessUpdateMarketSummary(&marketSummaryUpdate)
if err != nil {
return err
}
}
case "heartbeat":
if b.Verbose {
log.Warnf(log.WebsocketMgr, "%s Received heartbeat\n", b.Name)
}
case "authenticationExpiring":
if b.Verbose {
log.Debugf(log.WebsocketMgr, "%s - Re-authenticating.\n", b.Name)
}
err = b.WsAuth()
if err != nil {
b.Websocket.DataHandler <- err
b.Websocket.SetCanUseAuthenticatedEndpoints(false)
}
case "order":
for j := range response.Message[i].Arguments {
var orderUpdate OrderUpdateMessage
err = b.wsDecodeMessage(response.Message[i].Arguments[j], &orderUpdate)
if err != nil {
return err
}
err = b.WsProcessUpdateOrder(&orderUpdate)
if err != nil {
return err
}
}
}
}
return nil
}
// WsProcessUpdateTicker processes an update on the ticker
func (b *Bittrex) WsProcessUpdateTicker(tickerData TickerData) error {
pair, err := currency.NewPairFromString(tickerData.Symbol)
if err != nil {
return err
}
tickerPrice, err := ticker.GetTicker(b.Name, pair, asset.Spot)
if err != nil {
b.tickerCache.Lock()
defer b.tickerCache.Unlock()
if b.tickerCache.MarketSummaries[tickerData.Symbol] != nil {
marketSummaryData := b.tickerCache.MarketSummaries[tickerData.Symbol]
tickerPrice = b.constructTicker(tickerData, marketSummaryData, pair, asset.Spot)
b.Websocket.DataHandler <- tickerPrice
return nil
}
b.tickerCache.Tickers[tickerData.Symbol] = &tickerData
return nil
}
tickerPrice.Last = tickerData.LastTradeRate
tickerPrice.Bid = tickerData.BidRate
tickerPrice.Ask = tickerData.AskRate
b.Websocket.DataHandler <- tickerPrice
return nil
}
// WsProcessUpdateMarketSummary processes an update on the ticker
func (b *Bittrex) WsProcessUpdateMarketSummary(marketSummaryData *MarketSummaryData) error {
pair, err := currency.NewPairFromString(marketSummaryData.Symbol)
if err != nil {
return err
}
tickerPrice, err := ticker.GetTicker(b.Name, pair, asset.Spot)
if err != nil {
b.tickerCache.Lock()
defer b.tickerCache.Unlock()
if b.tickerCache.Tickers[marketSummaryData.Symbol] != nil {
tickerData := b.tickerCache.Tickers[marketSummaryData.Symbol]
tickerPrice = b.constructTicker(*tickerData, marketSummaryData, pair, asset.Spot)
b.Websocket.DataHandler <- tickerPrice
return nil
}
b.tickerCache.MarketSummaries[marketSummaryData.Symbol] = marketSummaryData
return nil
}
tickerPrice.High = marketSummaryData.High
tickerPrice.Low = marketSummaryData.Low
tickerPrice.Volume = marketSummaryData.Volume
tickerPrice.QuoteVolume = marketSummaryData.QuoteVolume
tickerPrice.LastUpdated = marketSummaryData.UpdatedAt
b.Websocket.DataHandler <- tickerPrice
return nil
}
// WsProcessUpdateOrder processes an update on the open orders
func (b *Bittrex) WsProcessUpdateOrder(data *OrderUpdateMessage) error {
orderType, err := order.StringToOrderType(data.Delta.Type)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: data.Delta.ID,
Err: err,
}
}
orderSide, err := order.StringToOrderSide(data.Delta.Direction)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: data.Delta.ID,
Err: err,
}
}
orderStatus, err := order.StringToOrderStatus(data.Delta.Status)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: data.Delta.ID,
Err: err,
}
}
pair, err := currency.NewPairFromString(data.Delta.MarketSymbol)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: data.Delta.ID,
Err: err,
}
}
b.Websocket.DataHandler <- &order.Modify{
ImmediateOrCancel: data.Delta.TimeInForce == string(ImmediateOrCancel),
FillOrKill: data.Delta.TimeInForce == string(GoodTilCancelled),
PostOnly: data.Delta.TimeInForce == string(PostOnlyGoodTilCancelled),
Price: data.Delta.Limit,
Amount: data.Delta.Quantity,
RemainingAmount: data.Delta.Quantity - data.Delta.FillQuantity,
ExecutedAmount: data.Delta.FillQuantity,
Exchange: b.Name,
ID: data.Delta.ID,
Type: orderType,
Side: orderSide,
Status: orderStatus,
AssetType: asset.Spot,
Date: data.Delta.CreatedAt,
Pair: pair,
}
return nil
}