Files
gocryptotrader/exchanges/huobi/huobi_websocket.go
Ryan O'Hara-Reid cad7586e98 exchange/websocket, gateio: Rename/export package again, add websocket request functions for futures trading (#1603)
* gateio: Add multi asset websocket support WIP.

* meow

* Add tests and shenanigans

* integrate flushing and for enabling/disabling pairs from rpc shenanigans

* some changes

* linter: fixes strikes again.

* Change name ConnectionAssociation -> ConnectionCandidate for better clarity on purpose. Change connections map to point to candidate to track subscriptions for future dynamic connections holder and drop struct ConnectionDetails.

* Add subscription tests (state functional)

* glorious:nits + proxy handling

* Spelling

* linter: fixerino

* instead of nil, dont do nil.

* clean up nils

* cya nils

* don't need to set URL or check if its running

* stream match update

* update tests

* linter: fix

* glorious: nits + handle context cancellations

* stop ping handler routine leak

* * Fix bug where reader routine on error that is not a disconnection error but websocket frame error or anything really makes the reader routine return and then connection never cycles and the buffer gets filled.
* Handle reconnection via an errors.Is check which is simpler and in that scope allow for quick disconnect reconnect without waiting for connection cycle.
* Dial now uses code from DialContext but just calls context.Background()
* Don't allow reader to return on parse binary response error. Just output error and return a non nil response

* Allow rollback on connect on any error across all connections

* fix shadow jutsu

* glorious/gk: nitters - adds in ws mock server

* linter: fix

* fix deadlock on connection as the previous channel had no reader and would hang connection reader for eternity.

* glorious: whooops

* gk: nits

* Leak issue and edge case

* Websocket: Add SendMessageReturnResponses

* whooooooopsie

* gk: nitssssss

* Update exchanges/stream/stream_match.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/stream/stream_match_test.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* linter: appease the linter gods

* gk: nits

* gk: drain brain

* started

* more changes before merge match pr

* gateio: still building out

* gateio: finish spot

* fix up tests in gateio

* Add tests for stream package

* rm unused field

* glorious: nits

* rn files, specifically set function names to asset and offload routing to websocket type.

* linter: fix

* Add futures websocket request support

* gateio: integrate with IBOTExchange (cherry pick my nose)

* linter: fix

* glorious: nits

* add counter and update gateio

* fix collision issue

* Update exchanges/stream/websocket.go

Co-authored-by: Scott <gloriousCode@users.noreply.github.com>

* glorious: nits

* add tests

* linter: fix

* After merge

* Add error connection info

* upgrade to upstream merge

* Fix edge case where it does not reconnect made by an already closed connection

* stream coverage

* glorious: nits

* glorious: nits removed asset error handling in stream package

* linter: fix

* rm block

* Add basic readme

* fix asset enabled flush cycle for multi connection

* spella: fix

* linter: fix

* Add glorious suggestions, fix some race thing

* reinstate name before any routine gets spawned

* stop on error in mock tests

* glorious: nits

* Set correct price

* glorious: nits found in CI build

* Add test for drain, bumped wait times as there seems to be something happening on macos CI builds, used context.WithTimeout because its instant.

* mutex across shutdown and connect for protection

* lint: fix

* test time withoffset, reinstate stop

* fix whoops

* const trafficCheckInterval; rm testmain

* y

* fix lint

* bump time check window

* stream: fix intermittant test failures while testing routines and remove code that is not needed.

* spells

* cant do what I did

* protect race due to routine.

* update testURL

* use mock websocket connection instead of test URL's

* linter: fix

* remove url because its throwing errors on CI builds

* connections drop all the time, don't need to worry about not being able to echo back ws data as it can be easily reviewed _test file side.

* remove another superfluous url thats not really set up for this

* spawn overwatch routine when there is no errors, inline checker instead of waiting for a time period, add sleep inline with echo handler as this is really quick and wanted to ensure that latency is handing correctly

* linter: fixerino uperino

* fix ID bug, why I do this, I don't know.

* glorious: panix

* linter: things

* whoops

* dont need to make consecutive Unix() calls

* websocket: fix potential panic on error and no responses and adding waitForResponses

* rm json parser and handle in json package instead

* in favour of json package unmarshalling

* linter: fix

* linter: fix again

* * change field name OutboundRequestSignature to WrapperDefinedConnectionSignature for agnostic inbound and outbound connections.
* change method name GetOutboundConnection to GetConnection for agnostic inbound and outbound connections.
* drop outbound field map for improved performance just using a range and field check (less complex as well)
* change field name connections to connectionToWrapper for better clarity

* spells and magic and wands

* glorious: nits

* comparable check for signature

* mv err var

* glorious: nits and stuff

* attempt to fix race

* glorious: nits

* gk: nits; engine log cleanup

* gk: nits; OCD

* gk: nits; move function change file names

* gk: nits; 🚀

* gk: nits; convert variadic function and message inspection to interface and include a specific function for that handling so as to not need nil on every call

* gk: nits; continued

* gk: engine nits; rm loaded exchange

* gk: nits; drop WebsocketLoginResponse

* stream: Add match method EnsureMatchWithData

* gk: nits; rn Inspect to IsFinal

* gk: nits; rn to MessageFilter

* linter: fix

* gateio: update rate limit definitions (cherry-pick)

* Add test and missing

* Shared REST rate limit definitions with Websocket service, set lookup item to nil for systems that do not require rate limiting; add glorious nit

* integrate rate limits for websocket trading spot

* conform to match upstream changes

* standardise names to upstream style

* fix wrapper standards test when sending a auth request through a websocket connection

* whoops

* Update exchanges/gateio/gateio_types.go

Co-authored-by: Scott <gloriousCode@users.noreply.github.com>

* glorious: nits

* linter: fix

* linter: overload

* whoops

* spelling fixes on recent merge

* glorious: nits

* linter: fix?

* glorious: nits

* gk: assert errors touched

* gk: unexport derive functions

* gk: nitssssssss

* fix test

* gk: nitters v1

* gk: http status

* gk/nits: Add getAssetFromFuturesPair

* gk: nits single response when submitting

* gk: new pair with delimiter in tests

* gk: param update slice to slice of pointers

* gk: add asset type in params, includes t.Context() for tests

* linter: fix

* linter: fix

* fix merge whoopsie

* glorious: nits

* gk: nit

* shift over to websocket package error

* internal/exchange/websocket -> exchange/websocket

* PEAK OCD!

* appease the OCD gods

* thrasher: nits

---------

Co-authored-by: shazbert <ryan.oharareid@thrasher.io>
Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>
Co-authored-by: Scott <gloriousCode@users.noreply.github.com>
2025-04-11 16:47:33 +10:00

719 lines
21 KiB
Go

package huobi
import (
"context"
"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/websocket"
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
"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 (h *HUOBI) WsConnect() error {
if !h.Websocket.IsEnabled() || !h.IsEnabled() {
return websocket.ErrWebsocketNotEnabled
}
if err := h.Websocket.Conn.Dial(&gws.Dialer{}, http.Header{}); err != nil {
return err
}
h.Websocket.Wg.Add(1)
go h.wsReadMsgs(h.Websocket.Conn)
if h.IsWebsocketAuthenticationSupported() {
ctx := context.Background()
if err := h.wsAuthConnect(ctx); err != nil {
h.Websocket.SetCanUseAuthenticatedEndpoints(false)
return fmt.Errorf("error authenticating websocket: %w", err)
}
h.Websocket.SetCanUseAuthenticatedEndpoints(true)
h.Websocket.Wg.Add(1)
go h.wsReadMsgs(h.Websocket.AuthConn)
}
return nil
}
// wsReadMsgs reads and processes messages from a websocket connection
func (h *HUOBI) wsReadMsgs(s websocket.Connection) {
defer h.Websocket.Wg.Done()
for {
msg := s.ReadMessage()
if msg.Raw == nil {
return
}
if err := h.wsHandleData(msg.Raw); err != nil {
h.Websocket.DataHandler <- err
}
}
}
func (h *HUOBI) wsHandleData(respRaw []byte) error {
if id, err := jsonparser.GetString(respRaw, "id"); err == nil {
if h.Websocket.Match.IncomingWithData(id, respRaw) {
return nil
}
}
if pingValue, err := jsonparser.GetInt(respRaw, "ping"); err == nil {
return h.wsHandleV1ping(int(pingValue))
}
if action, err := jsonparser.GetString(respRaw, "action"); err == nil {
switch action {
case "ping":
return h.wsHandleV2ping(respRaw)
case wsSubOp, wsUnsubOp:
return h.wsHandleV2subResp(action, respRaw)
}
}
if err := getErrResp(respRaw); err != nil {
return err
}
if ch, err := jsonparser.GetString(respRaw, "ch"); err == nil {
s := h.Websocket.GetSubscription(ch)
if s == nil {
return fmt.Errorf("%w: `%s`", subscription.ErrNotFound, ch)
}
return h.wsHandleChannelMsgs(s, respRaw)
}
h.Websocket.DataHandler <- websocket.UnhandledMessageWarning{
Message: h.Name + websocket.UnhandledMessage + string(respRaw),
}
return nil
}
// wsHandleV1ping handles v1 style pings, currently only used with public connections
func (h *HUOBI) wsHandleV1ping(pingValue int) error {
if err := h.Websocket.Conn.SendJSONMessage(context.Background(), 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 (h *HUOBI) wsHandleV2ping(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 := h.Websocket.AuthConn.SendJSONMessage(context.Background(), request.Unset, json.RawMessage(`{"action":"pong","data":{"ts":`+strconv.Itoa(int(ts))+`}}`)); err != nil {
return fmt.Errorf("error sending auth pong response: %w", err)
}
return nil
}
func (h *HUOBI) wsHandleV2subResp(action string, respRaw []byte) error {
if ch, err := jsonparser.GetString(respRaw, "ch"); err == nil {
return h.Websocket.Match.RequireMatchWithData(action+":"+ch, respRaw)
}
return nil
}
func (h *HUOBI) wsHandleChannelMsgs(s *subscription.Subscription, respRaw []byte) error {
switch s.Channel {
case subscription.TickerChannel:
return h.wsHandleTickerMsg(s, respRaw)
case subscription.OrderbookChannel:
return h.wsHandleOrderbookMsg(s, respRaw)
case subscription.CandlesChannel:
return h.wsHandleCandleMsg(s, respRaw)
case subscription.AllTradesChannel:
return h.wsHandleAllTradesMsg(s, respRaw)
case subscription.MyAccountChannel:
return h.wsHandleMyAccountMsg(respRaw)
case subscription.MyOrdersChannel:
return h.wsHandleMyOrdersMsg(s, respRaw)
case subscription.MyTradesChannel:
return h.wsHandleMyTradesMsg(s, respRaw)
}
return fmt.Errorf("%w: %s", common.ErrNotYetImplemented, s.Channel)
}
func (h *HUOBI) 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
}
h.Websocket.DataHandler <- websocket.KlineData{
Timestamp: c.Timestamp.Time(),
Exchange: h.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 (h *HUOBI) wsHandleAllTradesMsg(s *subscription.Subscription, respRaw []byte) error {
saveTradeData := h.IsSaveTradeDataEnabled()
tradeFeed := h.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: h.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 {
h.Websocket.DataHandler <- trades[i]
}
}
if saveTradeData {
return trade.AddTradesToBuffer(trades...)
}
return nil
}
func (h *HUOBI) 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
}
h.Websocket.DataHandler <- &ticker.Price{
ExchangeName: h.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 (h *HUOBI) 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.Tranches, 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.Tranche{
Price: price,
Amount: amount,
}
}
asks := make(orderbook.Tranches, 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.Tranche{
Price: price,
Amount: amount,
}
}
var newOrderBook orderbook.Base
newOrderBook.Asks = asks
newOrderBook.Bids = bids
newOrderBook.Pair = s.Pairs[0]
newOrderBook.Asset = asset.Spot
newOrderBook.Exchange = h.Name
newOrderBook.VerifyOrderbook = h.CanVerifyOrderbook
newOrderBook.LastUpdated = update.Timestamp.Time()
return h.Websocket.Orderbook.LoadSnapshot(&newOrderBook)
}
func (h *HUOBI) 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 := h.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: h.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: h.Name,
OrderID: d.OrderID,
Err: err,
}
}
if o.Side == order.UnknownSide {
d.Side, err = stringToOrderSide(o.OrderType)
if err != nil {
return &order.ClassificationError{
Exchange: h.Name,
OrderID: d.OrderID,
Err: err,
}
}
}
if o.OrderType != "" {
d.Type, err = stringToOrderType(o.OrderType)
if err != nil {
return &order.ClassificationError{
Exchange: h.Name,
OrderID: d.OrderID,
Err: err,
}
}
}
h.Websocket.DataHandler <- d
if o.ErrCode != 0 {
return fmt.Errorf("error with order `%s`: %s (%v)", o.ClientOrderID, o.ErrMessage, o.ErrCode)
}
return nil
}
func (h *HUOBI) 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 := h.CurrencyPairs.Match(t.Symbol, s.Asset)
if err != nil {
return err
}
d := &order.Detail{
ClientOrderID: t.ClientOrderID,
Price: t.OrderPrice,
Amount: t.OrderSize,
Exchange: h.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: h.Name,
OrderID: d.OrderID,
Err: err,
}
}
if t.Side == order.UnknownSide {
d.Side, err = stringToOrderSide(t.OrderType)
if err != nil {
return &order.ClassificationError{
Exchange: h.Name,
OrderID: d.OrderID,
Err: err,
}
}
}
if t.OrderType != "" {
d.Type, err = stringToOrderType(t.OrderType)
if err != nil {
return &order.ClassificationError{
Exchange: h.Name,
OrderID: d.OrderID,
Err: err,
}
}
}
d.Trades = []order.TradeHistory{
{
Price: t.TradePrice,
Amount: t.TradeVolume,
Fee: t.TransactFee,
Exchange: h.Name,
TID: strconv.FormatInt(t.TradeID, 10),
Type: d.Type,
Side: d.Side,
IsMaker: !t.IsTaker,
Timestamp: t.TradeTime.Time(),
},
}
h.Websocket.DataHandler <- d
return nil
}
func (h *HUOBI) wsHandleMyAccountMsg(respRaw []byte) error {
u := &wsAccountUpdateMsg{}
if err := json.Unmarshal(respRaw, u); err != nil {
return err
}
h.Websocket.DataHandler <- u.Data
return nil
}
// generateSubscriptions returns a list of subscriptions from the configured subscriptions feature
func (h *HUOBI) generateSubscriptions() (subscription.List, error) {
return h.Features.Subscriptions.ExpandTemplates(h)
}
// GetSubscriptionTemplate returns a subscription channel template
func (h *HUOBI) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) {
return template.New("master.tmpl").Funcs(template.FuncMap{
"channelName": channelName,
"isWildcardChannel": isWildcardChannel,
"interval": h.FormatExchangeKlineInterval,
}).Parse(subTplText)
}
// Subscribe sends a websocket message to receive data from the channel
func (h *HUOBI) Subscribe(subs subscription.List) error {
subs, errs := subs.ExpandTemplates(h)
return common.AppendError(errs, h.ParallelChanOp(subs, func(l subscription.List) error { return h.manageSubs(wsSubOp, l) }, 1))
}
// Unsubscribe sends a websocket message to stop receiving data from the channel
func (h *HUOBI) Unsubscribe(subs subscription.List) error {
subs, errs := subs.ExpandTemplates(h)
return common.AppendError(errs, h.ParallelChanOp(subs, func(l subscription.List) error { return h.manageSubs(wsUnsubOp, l) }, 1))
}
func (h *HUOBI) manageSubs(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 = h.Websocket.AuthConn
req = wsReq{Action: op, Channel: s.QualifiedChannel}
} else {
c = h.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 := h.Websocket.AddSubscriptions(c, s); err != nil {
return fmt.Errorf("%w: %s; error: %w", websocket.ErrSubscriptionFailure, s, err)
}
}
ctx := context.Background()
respRaw, err := c.SendMessageReturnResponse(ctx, request.Unset, wsSubOp+":"+s.QualifiedChannel, req)
if err == nil {
err = getErrResp(respRaw)
}
if err != nil {
if op == wsSubOp {
_ = h.Websocket.RemoveSubscriptions(c, s)
}
return fmt.Errorf("%s: %w", s, err)
}
if op == wsSubOp {
err = s.SetState(subscription.SubscribedState)
if h.Verbose {
log.Debugf(log.ExchangeSys, "%s Subscribed to %s", h.Name, s)
}
} else {
err = h.Websocket.RemoveSubscriptions(c, s)
}
return err
}
func (h *HUOBI) wsGenerateSignature(creds *account.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 (h *HUOBI) wsAuthConnect(ctx context.Context) error {
if err := h.Websocket.AuthConn.Dial(&gws.Dialer{}, http.Header{}); err != nil {
return fmt.Errorf("authenticated dial failed: %w", err)
}
if err := h.wsLogin(ctx); err != nil {
return fmt.Errorf("authentication failed: %w", err)
}
return nil
}
func (h *HUOBI) wsLogin(ctx context.Context) error {
creds, err := h.GetCredentials(ctx)
if err != nil {
return err
}
c := h.Websocket.AuthConn
ts := time.Now().UTC().Format(wsDateTimeFormatting)
hmac, err := h.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: crypto.Base64Encode(hmac),
Timestamp: ts,
},
}
err = c.SendJSONMessage(context.Background(), request.Unset, req)
if 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 <message> (<code>)
*/
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 }}
`