Files
gocryptotrader/exchanges/okcoin/okcoin_websocket.go
Scott d739c66a8a exchanges: Remove OKGroup (#1103)
* removes okgroup

* fixes more things

* pointers, string concat, comments + test fixes

* lint

* addresses nits without testing yet
2022-12-23 10:27:47 +11:00

766 lines
22 KiB
Go

package okcoin
import (
"context"
"encoding/json"
"errors"
"fmt"
"hash/crc32"
"net/http"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"github.com/thrasher-corp/gocryptotrader/common/crypto"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
"github.com/thrasher-corp/gocryptotrader/exchanges/stream"
"github.com/thrasher-corp/gocryptotrader/exchanges/ticker"
"github.com/thrasher-corp/gocryptotrader/exchanges/trade"
"github.com/thrasher-corp/gocryptotrader/log"
)
// WsConnect initiates a websocket connection
func (o *OKCoin) WsConnect() error {
if !o.Websocket.IsEnabled() || !o.IsEnabled() {
return errors.New(stream.WebsocketNotEnabled)
}
var dialer websocket.Dialer
dialer.ReadBufferSize = 8192
dialer.WriteBufferSize = 8192
err := o.Websocket.Conn.Dial(&dialer, http.Header{})
if err != nil {
return err
}
if o.Verbose {
log.Debugf(log.ExchangeSys, "Successful connection to %v\n",
o.Websocket.GetWebsocketURL())
}
o.Websocket.Wg.Add(1)
go o.WsReadData()
if o.IsWebsocketAuthenticationSupported() {
err = o.WsLogin(context.TODO())
if err != nil {
log.Errorf(log.ExchangeSys,
"%v - authentication failed: %v\n",
o.Name,
err)
}
}
return nil
}
// WsLogin sends a login request to websocket to enable access to authenticated endpoints
func (o *OKCoin) WsLogin(ctx context.Context) error {
creds, err := o.GetCredentials(ctx)
if err != nil {
return err
}
o.Websocket.SetCanUseAuthenticatedEndpoints(true)
unixTime := time.Now().UTC().Unix()
signPath := "/users/self/verify"
hmac, err := crypto.GetHMAC(crypto.HashSHA256,
[]byte(strconv.FormatInt(unixTime, 10)+http.MethodGet+signPath),
[]byte(creds.Secret),
)
if err != nil {
return err
}
base64 := crypto.Base64Encode(hmac)
request := WebsocketEventRequest{
Operation: "login",
Arguments: []string{
creds.Key,
creds.ClientID,
strconv.FormatInt(unixTime, 10),
base64,
},
}
_, err = o.Websocket.Conn.SendMessageReturnResponse("login", request)
if err != nil {
o.Websocket.SetCanUseAuthenticatedEndpoints(false)
return err
}
return nil
}
// WsReadData receives and passes on websocket messages for processing
func (o *OKCoin) WsReadData() {
defer o.Websocket.Wg.Done()
for {
resp := o.Websocket.Conn.ReadMessage()
if resp.Raw == nil {
return
}
err := o.WsHandleData(resp.Raw)
if err != nil {
o.Websocket.DataHandler <- err
}
}
}
// WsHandleData will read websocket raw data and pass to appropriate handler
func (o *OKCoin) WsHandleData(respRaw []byte) error {
var dataResponse WebsocketDataResponse
err := json.Unmarshal(respRaw, &dataResponse)
if err != nil {
return err
}
if len(dataResponse.Data) > 0 {
switch o.GetWsChannelWithoutOrderType(dataResponse.Table) {
case okcoinWsCandle60s, okcoinWsCandle180s, okcoinWsCandle300s,
okcoinWsCandle900s, okcoinWsCandle1800s, okcoinWsCandle3600s,
okcoinWsCandle7200s, okcoinWsCandle14400s, okcoinWsCandle21600s,
okcoinWsCandle43200s, okcoinWsCandle86400s, okcoinWsCandle604900s:
return o.wsProcessCandles(respRaw)
case okcoinWsDepth, okcoinWsDepth5:
return o.WsProcessOrderBook(respRaw)
case okcoinWsTicker:
return o.wsProcessTickers(respRaw)
case okcoinWsTrade:
return o.wsProcessTrades(respRaw)
case okcoinWsOrder:
return o.wsProcessOrder(respRaw)
}
o.Websocket.DataHandler <- stream.UnhandledMessageWarning{
Message: o.Name + stream.UnhandledMessage + string(respRaw),
}
return nil
}
var errorResponse WebsocketErrorResponse
err = json.Unmarshal(respRaw, &errorResponse)
if err == nil && errorResponse.ErrorCode > 0 {
return fmt.Errorf("%v error - %v message: %s ",
o.Name,
errorResponse.ErrorCode,
errorResponse.Message)
}
var eventResponse WebsocketEventResponse
err = json.Unmarshal(respRaw, &eventResponse)
if err == nil && eventResponse.Event != "" {
if eventResponse.Event == "login" {
if o.Websocket.Match.Incoming("login") {
o.Websocket.SetCanUseAuthenticatedEndpoints(eventResponse.Success)
}
}
if o.Verbose {
log.Debug(log.ExchangeSys,
o.Name+" - "+eventResponse.Event+" on channel: "+eventResponse.Channel)
}
}
return nil
}
// StringToOrderStatus converts order status IDs to internal types
func StringToOrderStatus(num int64) (order.Status, error) {
switch num {
case -2:
return order.Rejected, nil
case -1:
return order.Cancelled, nil
case 0:
return order.Active, nil
case 1:
return order.PartiallyFilled, nil
case 2:
return order.Filled, nil
case 3:
return order.New, nil
case 4:
return order.PendingCancel, nil
default:
return order.UnknownStatus, fmt.Errorf("%v not recognised as order status", num)
}
}
func (o *OKCoin) wsProcessOrder(respRaw []byte) error {
var resp WebsocketSpotOrderResponse
err := json.Unmarshal(respRaw, &resp)
if err != nil {
return err
}
for i := range resp.Data {
var oType order.Type
var oSide order.Side
var oStatus order.Status
oType, err = order.StringToOrderType(resp.Data[i].Type)
if err != nil {
o.Websocket.DataHandler <- order.ClassificationError{
Exchange: o.Name,
OrderID: resp.Data[i].OrderID,
Err: err,
}
}
oSide, err = order.StringToOrderSide(resp.Data[i].Side)
if err != nil {
o.Websocket.DataHandler <- order.ClassificationError{
Exchange: o.Name,
OrderID: resp.Data[i].OrderID,
Err: err,
}
}
oStatus, err = StringToOrderStatus(resp.Data[i].State)
if err != nil {
o.Websocket.DataHandler <- order.ClassificationError{
Exchange: o.Name,
OrderID: resp.Data[i].OrderID,
Err: err,
}
}
pair, err := currency.NewPairFromString(resp.Data[i].InstrumentID)
if err != nil {
o.Websocket.DataHandler <- order.ClassificationError{
Exchange: o.Name,
OrderID: resp.Data[i].OrderID,
Err: err,
}
}
o.Websocket.DataHandler <- &order.Detail{
ImmediateOrCancel: resp.Data[i].OrderType == 3,
FillOrKill: resp.Data[i].OrderType == 2,
PostOnly: resp.Data[i].OrderType == 1,
Price: resp.Data[i].Price,
Amount: resp.Data[i].Size,
ExecutedAmount: resp.Data[i].LastFillQty,
RemainingAmount: resp.Data[i].Size - resp.Data[i].LastFillQty,
Exchange: o.Name,
OrderID: resp.Data[i].OrderID,
Type: oType,
Side: oSide,
Status: oStatus,
AssetType: o.GetAssetTypeFromTableName(resp.Table),
Date: resp.Data[i].CreatedAt,
Pair: pair,
}
}
return nil
}
// wsProcessTickers converts ticker data and sends it to the datahandler
func (o *OKCoin) wsProcessTickers(respRaw []byte) error {
var response WebsocketTickerData
err := json.Unmarshal(respRaw, &response)
if err != nil {
return err
}
a := o.GetAssetTypeFromTableName(response.Table)
for i := range response.Data {
f := strings.Split(response.Data[i].InstrumentID, delimiterDash)
c := currency.NewPairWithDelimiter(f[0], f[1], delimiterDash)
baseVolume := response.Data[i].BaseVolume24h
if response.Data[i].ContractVolume24h != 0 {
baseVolume = response.Data[i].ContractVolume24h
}
quoteVolume := response.Data[i].QuoteVolume24h
if response.Data[i].TokenVolume24h != 0 {
quoteVolume = response.Data[i].TokenVolume24h
}
o.Websocket.DataHandler <- &ticker.Price{
ExchangeName: o.Name,
Open: response.Data[i].Open24h,
Close: response.Data[i].Last,
Volume: baseVolume,
QuoteVolume: quoteVolume,
High: response.Data[i].High24h,
Low: response.Data[i].Low24h,
Bid: response.Data[i].BestBid,
Ask: response.Data[i].BestAsk,
Last: response.Data[i].Last,
AssetType: a,
Pair: c,
LastUpdated: response.Data[i].Timestamp,
}
}
return nil
}
// wsProcessTrades converts trade data and sends it to the datahandler
func (o *OKCoin) wsProcessTrades(respRaw []byte) error {
if !o.IsSaveTradeDataEnabled() {
return nil
}
var response WebsocketTradeResponse
err := json.Unmarshal(respRaw, &response)
if err != nil {
return err
}
a := o.GetAssetTypeFromTableName(response.Table)
trades := make([]trade.Data, len(response.Data))
for i := range response.Data {
f := strings.Split(response.Data[i].InstrumentID, delimiterDash)
c := currency.NewPairWithDelimiter(f[0], f[1], delimiterDash)
tSide, err := order.StringToOrderSide(response.Data[i].Side)
if err != nil {
o.Websocket.DataHandler <- order.ClassificationError{
Exchange: o.Name,
Err: err,
}
}
amount := response.Data[i].Size
if response.Data[i].Quantity != 0 {
amount = response.Data[i].Quantity
}
trades[i] = trade.Data{
Amount: amount,
AssetType: a,
CurrencyPair: c,
Exchange: o.Name,
Price: response.Data[i].Price,
Side: tSide,
Timestamp: response.Data[i].Timestamp,
TID: response.Data[i].TradeID,
}
}
return trade.AddTradesToBuffer(o.Name, trades...)
}
// wsProcessCandles converts candle data and sends it to the data handler
func (o *OKCoin) wsProcessCandles(respRaw []byte) error {
var response WebsocketCandleResponse
err := json.Unmarshal(respRaw, &response)
if err != nil {
return err
}
a := o.GetAssetTypeFromTableName(response.Table)
for i := range response.Data {
f := strings.Split(response.Data[i].InstrumentID, delimiterDash)
c := currency.NewPairWithDelimiter(f[0], f[1], delimiterDash)
timeData, err := time.Parse(time.RFC3339Nano,
response.Data[i].Candle[0])
if err != nil {
return fmt.Errorf("%v Time data could not be parsed: %v",
o.Name,
response.Data[i].Candle[0])
}
candleIndex := strings.LastIndex(response.Table, okcoinWsCandle)
candleInterval := response.Table[candleIndex+len(okcoinWsCandle):]
klineData := stream.KlineData{
AssetType: a,
Pair: c,
Exchange: o.Name,
Timestamp: timeData,
Interval: candleInterval,
}
klineData.OpenPrice, err = strconv.ParseFloat(response.Data[i].Candle[1], 64)
if err != nil {
return err
}
klineData.HighPrice, err = strconv.ParseFloat(response.Data[i].Candle[2], 64)
if err != nil {
return err
}
klineData.LowPrice, err = strconv.ParseFloat(response.Data[i].Candle[3], 64)
if err != nil {
return err
}
klineData.ClosePrice, err = strconv.ParseFloat(response.Data[i].Candle[4], 64)
if err != nil {
return err
}
klineData.Volume, err = strconv.ParseFloat(response.Data[i].Candle[5], 64)
if err != nil {
return err
}
o.Websocket.DataHandler <- klineData
}
return nil
}
// WsProcessOrderBook Validates the checksum and updates internal orderbook values
func (o *OKCoin) WsProcessOrderBook(respRaw []byte) error {
var response WebsocketOrderBooksData
err := json.Unmarshal(respRaw, &response)
if err != nil {
return err
}
orderbookMutex.Lock()
defer orderbookMutex.Unlock()
a := o.GetAssetTypeFromTableName(response.Table)
for i := range response.Data {
f := strings.Split(response.Data[i].InstrumentID, delimiterDash)
c := currency.NewPairWithDelimiter(f[0], f[1], delimiterDash)
if response.Action == okcoinWsOrderbookPartial {
err := o.WsProcessPartialOrderBook(&response.Data[i], c, a)
if err != nil {
err2 := o.wsResubscribeToOrderbook(&response)
if err2 != nil {
o.Websocket.DataHandler <- err2
}
return err
}
} else if response.Action == okcoinWsOrderbookUpdate {
if len(response.Data[i].Asks) == 0 && len(response.Data[i].Bids) == 0 {
return nil
}
err := o.WsProcessUpdateOrderbook(&response.Data[i], c, a)
if err != nil {
err2 := o.wsResubscribeToOrderbook(&response)
if err2 != nil {
o.Websocket.DataHandler <- err2
}
return err
}
}
}
return nil
}
func (o *OKCoin) wsResubscribeToOrderbook(response *WebsocketOrderBooksData) error {
a := o.GetAssetTypeFromTableName(response.Table)
for i := range response.Data {
f := strings.Split(response.Data[i].InstrumentID, delimiterDash)
c := currency.NewPairWithDelimiter(f[0], f[1], delimiterDash)
channelToResubscribe := &stream.ChannelSubscription{
Channel: response.Table,
Currency: c,
Asset: a,
}
err := o.Websocket.ResubscribeToChannel(channelToResubscribe)
if err != nil {
return fmt.Errorf("%s resubscribe to orderbook error %s", o.Name, err)
}
}
return nil
}
// AppendWsOrderbookItems adds websocket orderbook data bid/asks into an
// orderbook item array
func (o *OKCoin) AppendWsOrderbookItems(entries [][]interface{}) ([]orderbook.Item, error) {
items := make([]orderbook.Item, len(entries))
for j := range entries {
amount, err := strconv.ParseFloat(entries[j][1].(string), 64)
if err != nil {
return nil, err
}
price, err := strconv.ParseFloat(entries[j][0].(string), 64)
if err != nil {
return nil, err
}
items[j] = orderbook.Item{Amount: amount, Price: price}
}
return items, nil
}
// WsProcessPartialOrderBook takes websocket orderbook data and creates an
// orderbook Calculates checksum to ensure it is valid
func (o *OKCoin) WsProcessPartialOrderBook(wsEventData *WebsocketOrderBook, instrument currency.Pair, a asset.Item) error {
signedChecksum, err := o.CalculatePartialOrderbookChecksum(wsEventData)
if err != nil {
return fmt.Errorf("%s channel: %s. Orderbook unable to calculate partial orderbook checksum: %s",
o.Name,
a,
err)
}
if signedChecksum != wsEventData.Checksum {
return fmt.Errorf("%s channel: %s. Orderbook partial for %v checksum invalid",
o.Name,
a,
instrument)
}
if o.Verbose {
log.Debugf(log.ExchangeSys,
"%s passed checksum for instrument %s",
o.Name,
instrument)
}
asks, err := o.AppendWsOrderbookItems(wsEventData.Asks)
if err != nil {
return err
}
bids, err := o.AppendWsOrderbookItems(wsEventData.Bids)
if err != nil {
return err
}
newOrderBook := orderbook.Base{
Asks: asks,
Bids: bids,
Asset: a,
LastUpdated: wsEventData.Timestamp,
Pair: instrument,
Exchange: o.Name,
VerifyOrderbook: o.CanVerifyOrderbook,
}
return o.Websocket.Orderbook.LoadSnapshot(&newOrderBook)
}
// WsProcessUpdateOrderbook updates an existing orderbook using websocket data
// After merging WS data, it will sort, validate and finally update the existing
// orderbook
func (o *OKCoin) WsProcessUpdateOrderbook(wsEventData *WebsocketOrderBook, instrument currency.Pair, a asset.Item) error {
update := orderbook.Update{
Asset: a,
Pair: instrument,
UpdateTime: wsEventData.Timestamp,
}
var err error
update.Asks, err = o.AppendWsOrderbookItems(wsEventData.Asks)
if err != nil {
return err
}
update.Bids, err = o.AppendWsOrderbookItems(wsEventData.Bids)
if err != nil {
return err
}
err = o.Websocket.Orderbook.Update(&update)
if err != nil {
return err
}
updatedOb, err := o.Websocket.Orderbook.GetOrderbook(instrument, a)
if err != nil {
return err
}
checksum := o.CalculateUpdateOrderbookChecksum(updatedOb)
if checksum != wsEventData.Checksum {
// re-sub
log.Warnf(log.ExchangeSys, "%s checksum failure for item %s",
o.Name,
wsEventData.InstrumentID)
return errors.New("checksum failed")
}
return nil
}
// CalculatePartialOrderbookChecksum alternates over the first 25 bid and ask
// entries from websocket data. The checksum is made up of the price and the
// quantity with a semicolon (:) deliminating them. This will also work when
// there are less than 25 entries (for whatever reason)
// eg Bid:Ask:Bid:Ask:Ask:Ask
func (o *OKCoin) CalculatePartialOrderbookChecksum(orderbookData *WebsocketOrderBook) (int32, error) {
var checksum strings.Builder
for i := 0; i < allowableIterations; i++ {
if len(orderbookData.Bids)-1 >= i {
bidPrice, ok := orderbookData.Bids[i][0].(string)
if !ok {
return 0, fmt.Errorf("unable to type assert bidPrice")
}
bidAmount, ok := orderbookData.Bids[i][1].(string)
if !ok {
return 0, fmt.Errorf("unable to type assert bidAmount")
}
checksum.WriteString(bidPrice +
delimiterColon +
bidAmount +
delimiterColon)
}
if len(orderbookData.Asks)-1 >= i {
askPrice, ok := orderbookData.Asks[i][0].(string)
if !ok {
return 0, fmt.Errorf("unable to type assert askPrice")
}
askAmount, ok := orderbookData.Asks[i][1].(string)
if !ok {
return 0, fmt.Errorf("unable to type assert askAmount")
}
checksum.WriteString(askPrice +
delimiterColon +
askAmount +
delimiterColon)
}
}
checksumStr := strings.TrimSuffix(checksum.String(), delimiterColon)
return int32(crc32.ChecksumIEEE([]byte(checksumStr))), nil
}
// CalculateUpdateOrderbookChecksum alternates over the first 25 bid and ask
// entries of a merged orderbook. The checksum is made up of the price and the
// quantity with a semicolon (:) deliminating them. This will also work when
// there are less than 25 entries (for whatever reason)
// eg Bid:Ask:Bid:Ask:Ask:Ask
func (o *OKCoin) CalculateUpdateOrderbookChecksum(orderbookData *orderbook.Base) int32 {
var checksum strings.Builder
for i := 0; i < allowableIterations; i++ {
if len(orderbookData.Bids)-1 >= i {
price := strconv.FormatFloat(orderbookData.Bids[i].Price, 'f', -1, 64)
amount := strconv.FormatFloat(orderbookData.Bids[i].Amount, 'f', -1, 64)
checksum.WriteString(price + delimiterColon + amount + delimiterColon)
}
if len(orderbookData.Asks)-1 >= i {
price := strconv.FormatFloat(orderbookData.Asks[i].Price, 'f', -1, 64)
amount := strconv.FormatFloat(orderbookData.Asks[i].Amount, 'f', -1, 64)
checksum.WriteString(price + delimiterColon + amount + delimiterColon)
}
}
checksumStr := strings.TrimSuffix(checksum.String(), delimiterColon)
return int32(crc32.ChecksumIEEE([]byte(checksumStr)))
}
// GenerateDefaultSubscriptions Adds default subscriptions to websocket to be
// handled by ManageSubscriptions()
func (o *OKCoin) GenerateDefaultSubscriptions() ([]stream.ChannelSubscription, error) {
var subscriptions []stream.ChannelSubscription
assets := o.GetAssetTypes(true)
for x := range assets {
pairs, err := o.GetEnabledPairs(assets[x])
if err != nil {
return nil, err
}
if assets[x] != asset.Spot {
o.Websocket.DataHandler <- fmt.Errorf("%w %v", asset.ErrNotSupported, assets[x])
return nil, fmt.Errorf("%w %v", asset.ErrNotSupported, assets[x])
}
channels := defaultSpotSubscribedChannels
if o.IsWebsocketAuthenticationSupported() {
channels = append(channels,
okcoinWsSpotMarginAccount,
okcoinWsSpotAccount,
okcoinWsSpotOrder)
}
for i := range pairs {
p, err := o.FormatExchangeCurrency(pairs[i], asset.Spot)
if err != nil {
return nil, err
}
for y := range channels {
subscriptions = append(subscriptions,
stream.ChannelSubscription{
Channel: channels[y],
Currency: p,
Asset: asset.Spot,
})
}
}
}
return subscriptions, nil
}
// Subscribe sends a websocket message to receive data from the channel
func (o *OKCoin) Subscribe(channelsToSubscribe []stream.ChannelSubscription) error {
return o.handleSubscriptions("subscribe", channelsToSubscribe)
}
// Unsubscribe sends a websocket message to stop receiving data from the channel
func (o *OKCoin) Unsubscribe(channelsToUnsubscribe []stream.ChannelSubscription) error {
return o.handleSubscriptions("unsubscribe", channelsToUnsubscribe)
}
func (o *OKCoin) handleSubscriptions(operation string, subs []stream.ChannelSubscription) error {
request := WebsocketEventRequest{
Operation: operation,
}
var channels []stream.ChannelSubscription
for i := 0; i < len(subs); i++ {
// Temp type to evaluate max byte len after a marshal on batched unsubs
temp := WebsocketEventRequest{
Operation: operation,
}
temp.Arguments = make([]string, len(request.Arguments))
copy(temp.Arguments, request.Arguments)
arg := subs[i].Channel + delimiterColon
if strings.EqualFold(subs[i].Channel, okcoinWsSpotAccount) {
arg += subs[i].Currency.Base.String()
} else {
arg += subs[i].Currency.String()
}
temp.Arguments = append(temp.Arguments, arg)
chunk, err := json.Marshal(request)
if err != nil {
return err
}
if len(chunk) > maxConnByteLen {
// If temp chunk exceeds max byte length determined by the exchange,
// commit last payload.
i-- // reverse position in range to reuse channel unsubscription on
// next iteration
err = o.Websocket.Conn.SendJSONMessage(request)
if err != nil {
return err
}
if operation == "unsubscribe" {
o.Websocket.RemoveSuccessfulUnsubscriptions(channels...)
} else {
o.Websocket.AddSuccessfulSubscriptions(channels...)
}
// Drop prior unsubs and chunked payload args on successful unsubscription
channels = nil
request.Arguments = nil
continue
}
// Add pending chained items
channels = append(channels, subs[i])
request.Arguments = temp.Arguments
}
// Commit left overs to payload
err := o.Websocket.Conn.SendJSONMessage(request)
if err != nil {
return err
}
if operation == "unsubscribe" {
o.Websocket.RemoveSuccessfulUnsubscriptions(channels...)
} else {
o.Websocket.AddSuccessfulSubscriptions(channels...)
}
return nil
}
// GetWsChannelWithoutOrderType takes WebsocketDataResponse.Table and returns
// The base channel name eg receive "spot/depth5:BTC-USDT" return "depth5"
func (o *OKCoin) GetWsChannelWithoutOrderType(table string) string {
index := strings.Index(table, "/")
if index == -1 {
return table
}
channel := table[index+1:]
index = strings.Index(channel, ":")
// Some events do not contain a currency
if index == -1 {
return channel
}
return channel[:index]
}
// GetAssetTypeFromTableName gets the asset type from the table name
// eg "spot/ticker:BTCUSD" results in "SPOT"
func (o *OKCoin) GetAssetTypeFromTableName(table string) asset.Item {
assetIndex := strings.Index(table, "/")
switch table[:assetIndex] {
case asset.Spot.String():
return asset.Spot
default:
log.Warnf(log.ExchangeSys, "%s unhandled asset type %s",
o.Name,
table[:assetIndex])
return asset.Empty
}
}