package huobi import ( "context" "encoding/base64" "errors" "fmt" "net/http" "net/url" "strconv" "strings" "text/template" "time" "github.com/buger/jsonparser" gws "github.com/gorilla/websocket" "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/crypto" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/encoding/json" "github.com/thrasher-corp/gocryptotrader/exchange/accounts" "github.com/thrasher-corp/gocryptotrader/exchange/websocket" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" "github.com/thrasher-corp/gocryptotrader/exchanges/kline" "github.com/thrasher-corp/gocryptotrader/exchanges/order" "github.com/thrasher-corp/gocryptotrader/exchanges/orderbook" "github.com/thrasher-corp/gocryptotrader/exchanges/request" "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" "github.com/thrasher-corp/gocryptotrader/exchanges/ticker" "github.com/thrasher-corp/gocryptotrader/exchanges/trade" "github.com/thrasher-corp/gocryptotrader/log" ) const ( wsSpotHost = "api.huobi.pro" wsSpotURL = "wss://" + wsSpotHost wsPublicPath = "/ws" wsPrivatePath = "/ws/v2" wsCandlesChannel = "market.%s.kline" wsOrderbookChannel = "market.%s.depth" wsTradesChannel = "market.%s.trade.detail" wsMarketDetailChannel = "market.%s.detail" wsMyOrdersChannel = "orders#*" wsMyTradesChannel = "trade.clearing#*#1" // 0=Only trade events, 1=Trade and Cancellation events wsMyAccountChannel = "accounts.update#2" // 0=Only balance, 1=Balance or Available, 2=Balance and Available when either change wsAuthChannel = "auth" wsDateTimeFormatting = "2006-01-02T15:04:05" signatureMethod = "HmacSHA256" signatureVersion = "2.1" wsRequestOp = "req" wsSubOp = "sub" wsUnsubOp = "unsub" ) var defaultSubscriptions = subscription.List{ {Enabled: true, Asset: asset.Spot, Channel: subscription.TickerChannel}, {Enabled: true, Asset: asset.Spot, Channel: subscription.CandlesChannel, Interval: kline.OneMin}, {Enabled: true, Asset: asset.Spot, Channel: subscription.OrderbookChannel, Levels: 0}, // Aggregation Levels; 0 is no depth aggregation {Enabled: true, Asset: asset.Spot, Channel: subscription.AllTradesChannel}, {Enabled: true, Asset: asset.Spot, Channel: subscription.MyOrdersChannel, Authenticated: true}, {Enabled: true, Asset: asset.Spot, Channel: subscription.MyTradesChannel, Authenticated: true}, {Enabled: true, Channel: subscription.MyAccountChannel, Authenticated: true}, } var subscriptionNames = map[string]string{ subscription.TickerChannel: wsMarketDetailChannel, subscription.CandlesChannel: wsCandlesChannel, subscription.OrderbookChannel: wsOrderbookChannel, subscription.AllTradesChannel: wsTradesChannel, subscription.MyTradesChannel: wsMyTradesChannel, subscription.MyOrdersChannel: wsMyOrdersChannel, subscription.MyAccountChannel: wsMyAccountChannel, } // WsConnect initiates a new websocket connection func (e *Exchange) WsConnect() error { ctx := context.TODO() if !e.Websocket.IsEnabled() || !e.IsEnabled() { return websocket.ErrWebsocketNotEnabled } if err := e.Websocket.Conn.Dial(ctx, &gws.Dialer{}, http.Header{}); err != nil { return err } e.Websocket.Wg.Add(1) go e.wsReadMsgs(ctx, e.Websocket.Conn) if e.IsWebsocketAuthenticationSupported() { if err := e.wsAuthConnect(ctx); err != nil { e.Websocket.SetCanUseAuthenticatedEndpoints(false) return fmt.Errorf("error authenticating websocket: %w", err) } e.Websocket.SetCanUseAuthenticatedEndpoints(true) e.Websocket.Wg.Add(1) go e.wsReadMsgs(ctx, e.Websocket.AuthConn) } return nil } // wsReadMsgs reads and processes messages from a websocket connection func (e *Exchange) wsReadMsgs(ctx context.Context, s websocket.Connection) { defer e.Websocket.Wg.Done() for { msg := s.ReadMessage() if msg.Raw == nil { return } if err := e.wsHandleData(ctx, msg.Raw); err != nil { e.Websocket.DataHandler <- err } } } func (e *Exchange) wsHandleData(ctx context.Context, respRaw []byte) error { if id, err := jsonparser.GetString(respRaw, "id"); err == nil { if e.Websocket.Match.IncomingWithData(id, respRaw) { return nil } } if pingValue, err := jsonparser.GetInt(respRaw, "ping"); err == nil { return e.wsHandleV1ping(ctx, int(pingValue)) } if action, err := jsonparser.GetString(respRaw, "action"); err == nil { switch action { case "ping": return e.wsHandleV2ping(ctx, respRaw) case wsSubOp, wsUnsubOp: return e.wsHandleV2subResp(action, respRaw) } } if err := getErrResp(respRaw); err != nil { return err } if ch, err := jsonparser.GetString(respRaw, "ch"); err == nil { s := e.Websocket.GetSubscription(ch) if s == nil { return fmt.Errorf("%w: %q", subscription.ErrNotFound, ch) } return e.wsHandleChannelMsgs(s, respRaw) } e.Websocket.DataHandler <- websocket.UnhandledMessageWarning{ Message: e.Name + websocket.UnhandledMessage + string(respRaw), } return nil } // wsHandleV1ping handles v1 style pings, currently only used with public connections func (e *Exchange) wsHandleV1ping(ctx context.Context, pingValue int) error { if err := e.Websocket.Conn.SendJSONMessage(ctx, request.Unset, json.RawMessage(`{"pong":`+strconv.Itoa(pingValue)+`}`)); err != nil { return fmt.Errorf("error sending pong response: %w", err) } return nil } // wsHandleV2ping handles v2 style pings, currently only used with private connections func (e *Exchange) wsHandleV2ping(ctx context.Context, respRaw []byte) error { ts, err := jsonparser.GetInt(respRaw, "data", "ts") if err != nil { return fmt.Errorf("error getting ts from auth ping: %w", err) } if err := e.Websocket.AuthConn.SendJSONMessage(ctx, request.Unset, json.RawMessage(`{"action":"pong","data":{"ts":`+strconv.FormatInt(ts, 10)+`}}`)); err != nil { return fmt.Errorf("error sending auth pong response: %w", err) } return nil } func (e *Exchange) wsHandleV2subResp(action string, respRaw []byte) error { if ch, err := jsonparser.GetString(respRaw, "ch"); err == nil { return e.Websocket.Match.RequireMatchWithData(action+":"+ch, respRaw) } return nil } func (e *Exchange) wsHandleChannelMsgs(s *subscription.Subscription, respRaw []byte) error { switch s.Channel { case subscription.TickerChannel: return e.wsHandleTickerMsg(s, respRaw) case subscription.OrderbookChannel: return e.wsHandleOrderbookMsg(s, respRaw) case subscription.CandlesChannel: return e.wsHandleCandleMsg(s, respRaw) case subscription.AllTradesChannel: return e.wsHandleAllTradesMsg(s, respRaw) case subscription.MyAccountChannel: return e.wsHandleMyAccountMsg(respRaw) case subscription.MyOrdersChannel: return e.wsHandleMyOrdersMsg(s, respRaw) case subscription.MyTradesChannel: return e.wsHandleMyTradesMsg(s, respRaw) } return fmt.Errorf("%w: %s", common.ErrNotYetImplemented, s.Channel) } func (e *Exchange) wsHandleCandleMsg(s *subscription.Subscription, respRaw []byte) error { if len(s.Pairs) != 1 { return subscription.ErrNotSinglePair } var c WsKline if err := json.Unmarshal(respRaw, &c); err != nil { return err } e.Websocket.DataHandler <- websocket.KlineData{ Timestamp: c.Timestamp.Time(), Exchange: e.Name, AssetType: s.Asset, Pair: s.Pairs[0], OpenPrice: c.Tick.Open, ClosePrice: c.Tick.Close, HighPrice: c.Tick.High, LowPrice: c.Tick.Low, Volume: c.Tick.Volume, Interval: s.Interval.String(), } return nil } func (e *Exchange) wsHandleAllTradesMsg(s *subscription.Subscription, respRaw []byte) error { saveTradeData := e.IsSaveTradeDataEnabled() tradeFeed := e.IsTradeFeedEnabled() if !saveTradeData && !tradeFeed { return nil } if len(s.Pairs) != 1 { return subscription.ErrNotSinglePair } var t WsTrade if err := json.Unmarshal(respRaw, &t); err != nil { return err } trades := make([]trade.Data, 0, len(t.Tick.Data)) for i := range t.Tick.Data { side := order.Buy if t.Tick.Data[i].Direction != "buy" { side = order.Sell } trades = append(trades, trade.Data{ Exchange: e.Name, AssetType: s.Asset, CurrencyPair: s.Pairs[0], Timestamp: t.Tick.Data[i].Timestamp.Time().UTC(), Amount: t.Tick.Data[i].Amount, Price: t.Tick.Data[i].Price, Side: side, TID: strconv.FormatFloat(t.Tick.Data[i].TradeID, 'f', -1, 64), }) } if tradeFeed { for i := range trades { e.Websocket.DataHandler <- trades[i] } } if saveTradeData { return trade.AddTradesToBuffer(trades...) } return nil } func (e *Exchange) wsHandleTickerMsg(s *subscription.Subscription, respRaw []byte) error { if len(s.Pairs) != 1 { return subscription.ErrNotSinglePair } var wsTicker WsTick if err := json.Unmarshal(respRaw, &wsTicker); err != nil { return err } e.Websocket.DataHandler <- &ticker.Price{ ExchangeName: e.Name, Open: wsTicker.Tick.Open, Close: wsTicker.Tick.Close, Volume: wsTicker.Tick.Amount, QuoteVolume: wsTicker.Tick.Volume, High: wsTicker.Tick.High, Low: wsTicker.Tick.Low, LastUpdated: wsTicker.Timestamp.Time(), AssetType: s.Asset, Pair: s.Pairs[0], } return nil } func (e *Exchange) wsHandleOrderbookMsg(s *subscription.Subscription, respRaw []byte) error { if len(s.Pairs) != 1 { return subscription.ErrNotSinglePair } var update WsDepth if err := json.Unmarshal(respRaw, &update); err != nil { return err } bids := make(orderbook.Levels, len(update.Tick.Bids)) for i := range update.Tick.Bids { price, ok := update.Tick.Bids[i][0].(float64) if !ok { return errors.New("unable to type assert bid price") } amount, ok := update.Tick.Bids[i][1].(float64) if !ok { return errors.New("unable to type assert bid amount") } bids[i] = orderbook.Level{ Price: price, Amount: amount, } } asks := make(orderbook.Levels, len(update.Tick.Asks)) for i := range update.Tick.Asks { price, ok := update.Tick.Asks[i][0].(float64) if !ok { return errors.New("unable to type assert ask price") } amount, ok := update.Tick.Asks[i][1].(float64) if !ok { return errors.New("unable to type assert ask amount") } asks[i] = orderbook.Level{ Price: price, Amount: amount, } } var newOrderBook orderbook.Book newOrderBook.Asks = asks newOrderBook.Bids = bids newOrderBook.Pair = s.Pairs[0] newOrderBook.Asset = asset.Spot newOrderBook.Exchange = e.Name newOrderBook.ValidateOrderbook = e.ValidateOrderbook newOrderBook.LastUpdated = update.Timestamp.Time() return e.Websocket.Orderbook.LoadSnapshot(&newOrderBook) } func (e *Exchange) wsHandleMyOrdersMsg(s *subscription.Subscription, respRaw []byte) error { var msg wsOrderUpdateMsg if err := json.Unmarshal(respRaw, &msg); err != nil { return err } o := msg.Data p, err := e.CurrencyPairs.Match(o.Symbol, s.Asset) if err != nil { return err } d := &order.Detail{ ClientOrderID: o.ClientOrderID, Price: o.Price, Amount: o.Size, ExecutedAmount: o.ExecutedAmount, RemainingAmount: o.RemainingAmount, Exchange: e.Name, Side: o.Side, AssetType: s.Asset, Pair: p, } if o.OrderID != 0 { d.OrderID = strconv.FormatInt(o.OrderID, 10) } switch o.EventType { case "trigger", "deletion", "cancellation": d.LastUpdated = o.LastActTime.Time() case "creation": d.LastUpdated = o.CreateTime.Time() case "trade": d.LastUpdated = o.TradeTime.Time() } if d.Status, err = order.StringToOrderStatus(o.OrderStatus); err != nil { return &order.ClassificationError{ Exchange: e.Name, OrderID: d.OrderID, Err: err, } } if o.Side == order.UnknownSide { d.Side, err = stringToOrderSide(o.OrderType) if err != nil { return &order.ClassificationError{ Exchange: e.Name, OrderID: d.OrderID, Err: err, } } } if o.OrderType != "" { d.Type, err = stringToOrderType(o.OrderType) if err != nil { return &order.ClassificationError{ Exchange: e.Name, OrderID: d.OrderID, Err: err, } } } e.Websocket.DataHandler <- d if o.ErrCode != 0 { return fmt.Errorf("error with order %q: %s (%v)", o.ClientOrderID, o.ErrMessage, o.ErrCode) } return nil } func (e *Exchange) wsHandleMyTradesMsg(s *subscription.Subscription, respRaw []byte) error { var msg wsTradeUpdateMsg if err := json.Unmarshal(respRaw, &msg); err != nil { return err } t := msg.Data p, err := e.CurrencyPairs.Match(t.Symbol, s.Asset) if err != nil { return err } d := &order.Detail{ ClientOrderID: t.ClientOrderID, Price: t.OrderPrice, Amount: t.OrderSize, Exchange: e.Name, Side: t.Side, AssetType: s.Asset, Pair: p, Date: t.OrderCreateTime.Time(), LastUpdated: t.TradeTime.Time(), OrderID: strconv.FormatInt(t.OrderID, 10), } if d.Status, err = order.StringToOrderStatus(t.OrderStatus); err != nil { return &order.ClassificationError{ Exchange: e.Name, OrderID: d.OrderID, Err: err, } } if t.Side == order.UnknownSide { d.Side, err = stringToOrderSide(t.OrderType) if err != nil { return &order.ClassificationError{ Exchange: e.Name, OrderID: d.OrderID, Err: err, } } } if t.OrderType != "" { d.Type, err = stringToOrderType(t.OrderType) if err != nil { return &order.ClassificationError{ Exchange: e.Name, OrderID: d.OrderID, Err: err, } } } d.Trades = []order.TradeHistory{ { Price: t.TradePrice, Amount: t.TradeVolume, Fee: t.TransactFee, Exchange: e.Name, TID: strconv.FormatInt(t.TradeID, 10), Type: d.Type, Side: d.Side, IsMaker: !t.IsTaker, Timestamp: t.TradeTime.Time(), }, } e.Websocket.DataHandler <- d return nil } func (e *Exchange) wsHandleMyAccountMsg(respRaw []byte) error { u := &wsAccountUpdateMsg{} if err := json.Unmarshal(respRaw, u); err != nil { return err } e.Websocket.DataHandler <- u.Data return nil } // generateSubscriptions returns a list of subscriptions from the configured subscriptions feature func (e *Exchange) generateSubscriptions() (subscription.List, error) { return e.Features.Subscriptions.ExpandTemplates(e) } // GetSubscriptionTemplate returns a subscription channel template func (e *Exchange) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) { return template.New("master.tmpl").Funcs(template.FuncMap{ "channelName": channelName, "isWildcardChannel": isWildcardChannel, "interval": e.FormatExchangeKlineInterval, }).Parse(subTplText) } // Subscribe sends a websocket message to receive data from the channel func (e *Exchange) Subscribe(subs subscription.List) error { ctx := context.TODO() subs, errs := subs.ExpandTemplates(e) return common.AppendError(errs, e.ParallelChanOp(ctx, subs, func(ctx context.Context, l subscription.List) error { return e.manageSubs(ctx, wsSubOp, l) }, 1)) } // Unsubscribe sends a websocket message to stop receiving data from the channel func (e *Exchange) Unsubscribe(subs subscription.List) error { ctx := context.TODO() subs, errs := subs.ExpandTemplates(e) return common.AppendError(errs, e.ParallelChanOp(ctx, subs, func(ctx context.Context, l subscription.List) error { return e.manageSubs(ctx, wsUnsubOp, l) }, 1)) } func (e *Exchange) manageSubs(ctx context.Context, op string, subs subscription.List) error { if len(subs) != 1 { return subscription.ErrBatchingNotSupported } s := subs[0] var c websocket.Connection var req any if s.Authenticated { c = e.Websocket.AuthConn req = wsReq{Action: op, Channel: s.QualifiedChannel} } else { c = e.Websocket.Conn if op == wsSubOp { // Set the id to the channel so that V1 errors can make it back to us req = wsSubReq{ID: wsSubOp + ":" + s.QualifiedChannel, Sub: s.QualifiedChannel} } else { req = wsSubReq{Unsub: s.QualifiedChannel} } } if op == wsSubOp { s.SetKey(s.QualifiedChannel) if err := e.Websocket.AddSubscriptions(c, s); err != nil { return fmt.Errorf("%w: %s; error: %w", websocket.ErrSubscriptionFailure, s, err) } } respRaw, err := c.SendMessageReturnResponse(ctx, request.Unset, wsSubOp+":"+s.QualifiedChannel, req) if err == nil { err = getErrResp(respRaw) } if err != nil { if op == wsSubOp { _ = e.Websocket.RemoveSubscriptions(c, s) } return fmt.Errorf("%s: %w", s, err) } if op == wsSubOp { err = s.SetState(subscription.SubscribedState) if e.Verbose { log.Debugf(log.ExchangeSys, "%s Subscribed to %s", e.Name, s) } } else { err = e.Websocket.RemoveSubscriptions(c, s) } return err } func (e *Exchange) wsGenerateSignature(creds *accounts.Credentials, timestamp string) ([]byte, error) { values := url.Values{} values.Set("accessKey", creds.Key) values.Set("signatureMethod", signatureMethod) values.Set("signatureVersion", signatureVersion) values.Set("timestamp", timestamp) payload := http.MethodGet + "\n" + wsSpotHost + "\n" + wsPrivatePath + "\n" + values.Encode() return crypto.GetHMAC(crypto.HashSHA256, []byte(payload), []byte(creds.Secret)) } func (e *Exchange) wsAuthConnect(ctx context.Context) error { if err := e.Websocket.AuthConn.Dial(ctx, &gws.Dialer{}, http.Header{}); err != nil { return fmt.Errorf("authenticated dial failed: %w", err) } if err := e.wsLogin(ctx); err != nil { return fmt.Errorf("authentication failed: %w", err) } return nil } func (e *Exchange) wsLogin(ctx context.Context) error { creds, err := e.GetCredentials(ctx) if err != nil { return err } ts := time.Now().UTC().Format(wsDateTimeFormatting) hmac, err := e.wsGenerateSignature(creds, ts) if err != nil { return err } req := wsReq{ Action: wsRequestOp, Channel: wsAuthChannel, Params: wsAuthReq{ AuthType: "api", AccessKey: creds.Key, SignatureMethod: signatureMethod, SignatureVersion: signatureVersion, Signature: base64.StdEncoding.EncodeToString(hmac), Timestamp: ts, }, } c := e.Websocket.AuthConn if err := c.SendJSONMessage(ctx, request.Unset, req); err != nil { return err } resp := c.ReadMessage() if resp.Raw == nil { return &gws.CloseError{Code: gws.CloseAbnormalClosure} } return getErrResp(resp.Raw) } func stringToOrderStatus(status string) (order.Status, error) { switch status { case "rejected": return order.Rejected, nil case "submitted": return order.New, nil case "partial-filled": return order.PartiallyFilled, nil case "filled": return order.Filled, nil case "partial-canceled": return order.PartiallyCancelled, nil case "canceled": return order.Cancelled, nil default: return order.UnknownStatus, errors.New(status + " not recognised as order status") } } func stringToOrderSide(side string) (order.Side, error) { switch { case strings.Contains(side, "buy"): return order.Buy, nil case strings.Contains(side, "sell"): return order.Sell, nil } return order.UnknownSide, errors.New(side + " not recognised as order side") } func stringToOrderType(oType string) (order.Type, error) { switch { case strings.Contains(oType, "limit"): return order.Limit, nil case strings.Contains(oType, "market"): return order.Market, nil } return order.UnknownType, errors.New(oType + " not recognised as order type") } /* getErrResp looks for any of the following to determine an error: - An err-code (V1) - A code field that isn't 200 (V2) Error message is retreieved from the field err-message or message. Errors are returned in the format of () */ func getErrResp(msg []byte) error { var errCode string errMsg, _ := jsonparser.GetString(msg, "err-msg") errCode, err := jsonparser.GetString(msg, "err-code") switch err { case nil: // Nothing to do case jsonparser.KeyPathNotFoundError: // Look for a V2 error errCodeInt, err := jsonparser.GetInt(msg, "code") if errCodeInt == 200 || errors.Is(err, jsonparser.KeyPathNotFoundError) { return nil } if err != nil { return fmt.Errorf("%w 'code': %w from message: %s", common.ErrParsingWSField, err, msg) } errCode = strconv.Itoa(int(errCodeInt)) errMsg, _ = jsonparser.GetString(msg, "message") } if errCode != "" { return fmt.Errorf("%s (%v)", errMsg, errCode) } return nil } // channelName converts global channel Names used in config of channel input into exchange channel names // returns the name unchanged if no match is found func channelName(s *subscription.Subscription, p ...currency.Pair) string { if n, ok := subscriptionNames[s.Channel]; ok { if strings.Contains(n, "%s") { return fmt.Sprintf(n, p[0]) } return n } panic(subscription.ErrUseConstChannelName) } func isWildcardChannel(s *subscription.Subscription) bool { return s.Channel == subscription.MyTradesChannel || s.Channel == subscription.MyOrdersChannel } const subTplText = ` {{- if $.S.Asset }} {{ range $asset, $pairs := $.AssetPairs }} {{- if isWildcardChannel $.S }} {{- channelName $.S -}} {{- else }} {{- range $p := $pairs }} {{- channelName $.S $p -}} {{- if eq $.S.Channel "candles" -}} . {{- interval $.S.Interval }}{{ end }} {{- if eq $.S.Channel "orderbook" -}} .step {{- $.S.Levels }}{{ end }} {{ $.PairSeparator }} {{- end }} {{- end }} {{ $.AssetSeparator }} {{- end }} {{- else -}} {{ channelName $.S }} {{- end }} `