diff --git a/exchanges/huobi/huobi_test.go b/exchanges/huobi/huobi_test.go index b8144d4ce15..ee6912f2840 100644 --- a/exchanges/huobi/huobi_test.go +++ b/exchanges/huobi/huobi_test.go @@ -2,6 +2,8 @@ package huobi import ( "context" + "errors" + "fmt" "log" "os" "strconv" @@ -9,6 +11,7 @@ import ( "testing" "time" + "github.com/buger/jsonparser" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -23,12 +26,14 @@ import ( "github.com/thrasher-corp/gocryptotrader/exchanges/futures" "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/sharedtestvalues" "github.com/thrasher-corp/gocryptotrader/exchanges/stream" "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" "github.com/thrasher-corp/gocryptotrader/exchanges/ticker" testexch "github.com/thrasher-corp/gocryptotrader/internal/testing/exchange" testsubs "github.com/thrasher-corp/gocryptotrader/internal/testing/subscriptions" + mockws "github.com/thrasher-corp/gocryptotrader/internal/testing/websocket" "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" ) @@ -69,33 +74,9 @@ func TestMain(m *testing.M) { if err != nil { log.Fatal("Huobi setup error", err) } - os.Exit(m.Run()) } -func setupWsTests(t *testing.T) { - t.Helper() - if wsSetupRan { - return - } - if !h.Websocket.IsEnabled() && !h.API.AuthenticatedWebsocketSupport || !sharedtestvalues.AreAPICredentialsSet(h) { - t.Skip(stream.ErrWebsocketNotEnabled.Error()) - } - comms = make(chan WsMessage, sharedtestvalues.WebsocketChannelOverrideCapacity) - go h.wsReadData() - var dialer websocket.Dialer - err := h.wsAuthenticatedDial(&dialer) - if err != nil { - t.Fatal(err) - } - err = h.wsLogin(context.Background()) - if err != nil { - t.Fatal(err) - } - - wsSetupRan = true -} - func TestGetCurrenciesIncludingChains(t *testing.T) { t.Parallel() r, err := h.GetCurrenciesIncludingChains(context.Background(), currency.EMPTYCODE) @@ -1315,284 +1296,226 @@ func TestQueryWithdrawQuota(t *testing.T) { } } -// TestWsGetAccountsList connects to WS, logs in, gets account list -func TestWsGetAccountsList(t *testing.T) { - setupWsTests(t) - if _, err := h.wsGetAccountsList(context.Background()); err != nil { - t.Error(err) - } -} - -// TestWsGetOrderList connects to WS, logs in, gets order list -func TestWsGetOrderList(t *testing.T) { - setupWsTests(t) - p, err := currency.NewPairFromString("ethbtc") - if err != nil { - t.Error(err) - } - _, err = h.wsGetOrdersList(context.Background(), 1, p) - if err != nil { - t.Error(err) - } -} - -// TestWsGetOrderDetails connects to WS, logs in, gets order details -func TestWsGetOrderDetails(t *testing.T) { - setupWsTests(t) - orderID := "123" - _, err := h.wsGetOrderDetails(context.Background(), orderID) - if err != nil { - t.Error(err) - } -} - -func TestWsSubResponse(t *testing.T) { - pressXToJSON := []byte(`{ - "op": "sub", - "cid": "123", - "err-code": 0, - "ts": 1489474081631, - "topic": "accounts" -}`) - err := h.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) +func TestWsCandles(t *testing.T) { + h := new(HUOBI) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(h), "Setup Instance must not error") + err := h.Websocket.AddSubscriptions(h.Websocket.Conn, &subscription.Subscription{Key: "market.btcusdt.kline.1min", Asset: asset.Spot, Pairs: currency.Pairs{btcusdtPair}, Channel: subscription.CandlesChannel}) + require.NoError(t, err, "AddSubscriptions must not error") + testexch.FixtureToDataHandler(t, "testdata/wsCandles.json", h.wsHandleData) + close(h.Websocket.DataHandler) + require.Len(t, h.Websocket.DataHandler, 1, "Must see correct number of records") + cAny := <-h.Websocket.DataHandler + c, ok := cAny.(stream.KlineData) + require.True(t, ok, "Must get the correct type from DataHandler") + exp := stream.KlineData{ + Timestamp: time.UnixMilli(1489474082831), + Pair: btcusdtPair, + AssetType: asset.Spot, + Exchange: h.Name, + OpenPrice: 7962.62, + ClosePrice: 8014.56, + HighPrice: 14962.77, + LowPrice: 5110.14, + Volume: 4.4, + Interval: "0s", } + assert.Equal(t, exp, c) } -func TestWsKline(t *testing.T) { - err := h.Websocket.AddSubscriptions(h.Websocket.Conn, &subscription.Subscription{Key: "market.btcusdt.kline.1min", Asset: asset.Spot, Pairs: currency.Pairs{btcusdtPair}, Channel: subscription.CandlesChannel}) - require.NoError(t, err, "AddSubscriptions must not error") - pressXToJSON := []byte(`{ - "ch": "market.btcusdt.kline.1min", - "ts": 1489474082831, - "tick": { - "id": 1489464480, - "amount": 0.0, - "count": 0, - "open": 7962.62, - "close": 7962.62, - "low": 7962.62, - "high": 7962.62, - "vol": 0.0 - } -}`) - err = h.wsHandleData(pressXToJSON) - require.NoError(t, err) -} - -func TestWsKlineArray(t *testing.T) { - pressXToJSON := []byte(`{ - "status": "ok", - "rep": "market.btcusdt.kline.1min", - "data": [ - { - "amount": 1.6206, - "count": 3, - "id": 1494465840, - "open": 9887.00, - "close": 9885.00, - "low": 9885.00, - "high": 9887.00, - "vol": 16021.632026 - }, - { - "amount": 2.2124, - "count": 6, - "id": 1494465900, - "open": 9885.00, - "close": 9880.00, - "low": 9880.00, - "high": 9885.00, - "vol": 21859.023500 - } - ] -}`) - err := h.wsHandleData(pressXToJSON) - require.NoError(t, err) -} - -func TestWsMarketDepth(t *testing.T) { +func TestWsOrderbook(t *testing.T) { + h := new(HUOBI) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(h), "Setup Instance must not error") err := h.Websocket.AddSubscriptions(h.Websocket.Conn, &subscription.Subscription{Key: "market.btcusdt.depth.step0", Asset: asset.Spot, Pairs: currency.Pairs{btcusdtPair}, Channel: subscription.OrderbookChannel}) require.NoError(t, err, "AddSubscriptions must not error") - pressXToJSON := []byte(`{ - "ch": "market.btcusdt.depth.step0", - "ts": 1572362902027, - "tick": { - "bids": [ - [3.7721, 344.86], - [3.7709, 46.66] - ], - "asks": [ - [3.7745, 15.44], - [3.7746, 70.52] - ], - "version": 100434317651, - "ts": 1572362902012 - } -}`) - err = h.wsHandleData(pressXToJSON) - require.NoError(t, err) -} - + testexch.FixtureToDataHandler(t, "testdata/wsOrderbook.json", h.wsHandleData) + close(h.Websocket.DataHandler) + require.Len(t, h.Websocket.DataHandler, 1, "Must see correct number of records") + dAny := <-h.Websocket.DataHandler + d, ok := dAny.(*orderbook.Depth) + require.True(t, ok, "Must get the correct type from DataHandler") + require.NotNil(t, d) + l, err := d.GetAskLength() + require.NoError(t, err, "GetAskLength must not error") + assert.Equal(t, 2, l, "Ask length should be correct") + liq, _, err := d.TotalAskAmounts() + require.NoError(t, err, "TotalAskAmount must not error") + assert.Equal(t, 0.502591, liq, "Ask Liquidity should be correct") + l, err = d.GetBidLength() + require.NoError(t, err, "GetBidLength must not error") + assert.Equal(t, 2, l, "Bid length should be correct") + liq, _, err = d.TotalBidAmounts() + require.NoError(t, err, "TotalBidAmount must not error") + assert.Equal(t, 0.56281, liq, "Bid Liquidity should be correct") +} + +// TestWsTradeDetail checks we can send a trade detail through +// We can't currently easily see the result with the current DB instance, so we just check it doesn't error func TestWsTradeDetail(t *testing.T) { + h := new(HUOBI) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(h), "Setup Instance must not error") err := h.Websocket.AddSubscriptions(h.Websocket.Conn, &subscription.Subscription{Key: "market.btcusdt.trade.detail", Asset: asset.Spot, Pairs: currency.Pairs{btcusdtPair}, Channel: subscription.AllTradesChannel}) require.NoError(t, err, "AddSubscriptions must not error") - pressXToJSON := []byte(`{ - "ch": "market.btcusdt.trade.detail", - "ts": 1489474082831, - "tick": { - "id": 14650745135, - "ts": 1533265950234, - "data": [ - { - "amount": 0.0099, - "ts": 1533265950234, - "id": 146507451359183894799, - "tradeId": 102043495674, - "price": 401.74, - "direction": "buy" - } - ] - } - }`) - err = h.wsHandleData(pressXToJSON) - require.NoError(t, err) + h.SetSaveTradeDataStatus(true) + testexch.FixtureToDataHandler(t, "testdata/wsAllTrades.json", h.wsHandleData) + close(h.Websocket.DataHandler) + require.Empty(t, h.Websocket.DataHandler, "Must not see any errors going to datahandler") } func TestWsTicker(t *testing.T) { - pressXToJSON := []byte(`{ - "rep": "market.btcusdt.detail", - "id": "id11", - "data":{ - "amount": 12224.2922, - "open": 9790.52, - "close": 10195.00, - "high": 10300.00, - "ts": 1494496390000, - "id": 1494496390, - "count": 15195, - "low": 9657.00, - "vol": 121906001.754751 - } -}`) - err := h.wsHandleData(pressXToJSON) - require.NoError(t, err) + h := new(HUOBI) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(h), "Setup Instance must not error") + err := h.Websocket.AddSubscriptions(h.Websocket.Conn, &subscription.Subscription{Key: "market.btcusdt.detail", Asset: asset.Spot, Pairs: currency.Pairs{btcusdtPair}, Channel: subscription.TickerChannel}) + require.NoError(t, err, "AddSubscriptions must not error") + testexch.FixtureToDataHandler(t, "testdata/wsTicker.json", h.wsHandleData) + close(h.Websocket.DataHandler) + require.Len(t, h.Websocket.DataHandler, 1, "Must see correct number of records") + tickAny := <-h.Websocket.DataHandler + tick, ok := tickAny.(*ticker.Price) + require.True(t, ok, "Must get the correct type from DataHandler") + require.NotNil(t, tick) + exp := &ticker.Price{ + High: 52924.14, + Low: 51000, + Bid: 0, + Volume: 13991.028076056185, + QuoteVolume: 7.27676440200527e+08, + Open: 51823.62, + Close: 52379.99, + Pair: btcusdtPair, + ExchangeName: h.Name, + AssetType: asset.Spot, + LastUpdated: time.UnixMilli(1630998026649), + } + assert.Equal(t, exp, tick) } func TestWsAccountUpdate(t *testing.T) { - pressXToJSON := []byte(`{ - "op": "notify", - "ts": 1522856623232, - "topic": "accounts", - "data": { - "event": "order.place", - "list": [ - { - "account-id": 419013, - "currency": "usdt", - "type": "trade", - "balance": "500009195917.4362872650" - } - ] - } - }`) - err := h.wsHandleData(pressXToJSON) - require.NoError(t, err) + h := new(HUOBI) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(h), "Setup Instance must not error") + err := h.Websocket.AddSubscriptions(h.Websocket.Conn, &subscription.Subscription{Key: "accounts.update#2", Asset: asset.Spot, Pairs: currency.Pairs{btcusdtPair}, Channel: subscription.MyAccountChannel}) + require.NoError(t, err, "AddSubscriptions must not error") + h.SetSaveTradeDataStatus(true) + testexch.FixtureToDataHandler(t, "testdata/wsMyAccount.json", h.wsHandleData) + close(h.Websocket.DataHandler) + require.Len(t, h.Websocket.DataHandler, 3, "Must see correct number of records") + exp := []WsAccountUpdate{ + {Currency: "btc", AccountID: 123456, Balance: 23.111, ChangeType: "transfer", AccountType: "trade", ChangeTime: 1568601800000, SeqNum: 1}, + {Currency: "btc", AccountID: 33385, Available: 2028.69, ChangeType: "order.match", AccountType: "trade", ChangeTime: 1574393385167, SeqNum: 2}, + {Currency: "usdt", AccountID: 14884859, Available: 20.29388158, Balance: 20.29388158, AccountType: "trade", SeqNum: 3}, + } + for _, e := range exp { + uAny := <-h.Websocket.DataHandler + u, ok := uAny.(WsAccountUpdate) + require.True(t, ok, "Must get the correct type from DataHandler") + require.NotNil(t, u) + assert.Equal(t, e, u) + } } func TestWsOrderUpdate(t *testing.T) { - pressXToJSON := []byte(`{ - "op": "notify", - "topic": "orders.htusdt", - "ts": 1522856623232, - "data": { - "seq-id": 94984, - "order-id": 2039498445, - "symbol": "btcusdt", - "account-id": 100077, - "order-amount": "5000.000000000000000000", - "order-price": "1.662100000000000000", - "created-at": 1522858623622, - "order-type": "buy-limit", - "order-source": "api", - "order-state": "filled", - "role": "taker", - "price": "1.662100000000000000", - "filled-amount": "5000.000000000000000000", - "unfilled-amount": "0.000000000000000000", - "filled-cash-amount": "8301.357280000000000000", - "filled-fees": "8.000000000000000000" - } -}`) - err := h.wsHandleData(pressXToJSON) - require.NoError(t, err) -} - -func TestWsMarketByPrice(t *testing.T) { - err := h.Websocket.AddSubscriptions(h.Websocket.Conn, &subscription.Subscription{Key: "market.btcusdt.mbp.150", Asset: asset.Spot, Pairs: currency.Pairs{btcusdtPair}, Channel: subscription.OrderbookChannel}) + h := new(HUOBI) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(h), "Setup Instance must not error") + err := h.Websocket.AddSubscriptions(h.Websocket.Conn, &subscription.Subscription{Key: "orders#*", Asset: asset.Spot, Pairs: currency.Pairs{btcusdtPair}, Channel: subscription.MyOrdersChannel}) require.NoError(t, err, "AddSubscriptions must not error") - pressXToJSON := []byte(`{ - "ch": "market.btcusdt.mbp.150", - "ts": 1573199608679, - "tick": { - "seqNum": 100020146795, - "prevSeqNum": 100020146794, - "bids": [], - "asks": [ - [645.140000000000000000, 26.755973959140651643] - ] - } - }`) - err = h.wsHandleData(pressXToJSON) - require.NoError(t, err) - pressXToJSON = []byte(`{ - "id": "id2", - "rep": "market.btcusdt.mbp.150", - "status": "ok", - "data": { - "seqNum": 100020142010, - "bids": [ - [618.37, 71.594], - [423.33, 77.726], - [223.18, 47.997], - [219.34, 24.82], - [210.34, 94.463] - ], - "asks": [ - [650.59, 14.909733438479636], - [650.63, 97.996], - [650.77, 97.465], - [651.23, 83.973], - [651.42, 34.465] - ] - } - }`) - err = h.wsHandleData(pressXToJSON) - require.NoError(t, err) -} - -func TestWsOrdersUpdate(t *testing.T) { - pressXToJSON := []byte(`{ - "op": "notify", - "ts": 1522856623232, - "topic": "orders.btcusdt.update", - "data": { - "unfilled-amount": "0.000000000000000000", - "filled-amount": "5000.000000000000000000", - "price": "1.662100000000000000", - "order-id": 2039498445, - "symbol": "btcusdt", - "match-id": 94984, - "filled-cash-amount": "8301.357280000000000000", - "role": "taker|maker", - "order-state": "filled", - "client-order-id": "a0001", - "order-type": "buy-limit" + h.SetSaveTradeDataStatus(true) + errs := testexch.FixtureToDataHandlerWithErrors(t, "testdata/wsMyOrders.json", h.wsHandleData) + close(h.Websocket.DataHandler) + require.Equal(t, 1, len(errs), "Must receive the correct number of errors back") + require.ErrorContains(t, errs[0].Err, "error with order `test1`: invalid.client.order.id (NT) (2002)") + require.Len(t, h.Websocket.DataHandler, 4, "Must see correct number of records") + exp := []*order.Detail{ + { + Exchange: h.Name, + Pair: btcusdtPair, + Side: order.Buy, + Status: order.Rejected, + ClientOrderID: "test1", + AssetType: asset.Spot, + LastUpdated: time.Unix(1583853365586000, 0), + }, + { + Exchange: h.Name, + Pair: btcusdtPair, + Side: order.Buy, + Status: order.Cancelled, + ClientOrderID: "test2", + AssetType: asset.Spot, + LastUpdated: time.Unix(1583853365586000, 0), + }, + { + Exchange: h.Name, + Pair: btcusdtPair, + Side: order.Sell, + Status: order.New, + ClientOrderID: "test3", + AssetType: asset.Spot, + Price: 77, + Amount: 2, + Type: order.Limit, + OrderID: "27163533", + LastUpdated: time.Unix(1583853365586000, 0), + }, + { + Exchange: h.Name, + Pair: btcusdtPair, + Side: order.Buy, + Status: order.New, + AssetType: asset.Spot, + Price: 70000, + Amount: 0.000157, + Type: order.Limit, + OrderID: "1199329381585359", + LastUpdated: time.Unix(1731039387696000, 0), + }, } - }`) - err := h.wsHandleData(pressXToJSON) - require.NoError(t, err) + for _, e := range exp { + m := <-h.Websocket.DataHandler + require.IsType(t, &order.Detail{}, m, "Must get the correct type from DataHandler") + d, _ := m.(*order.Detail) + require.NotNil(t, d) + assert.Equal(t, e, d, "Order Detail should match") + } +} + +func TestWsMyTrades(t *testing.T) { + h := new(HUOBI) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(h), "Setup Instance must not error") + err := h.Websocket.AddSubscriptions(h.Websocket.Conn, &subscription.Subscription{Key: "trade.clearing#btcusdt#1", Asset: asset.Spot, Pairs: currency.Pairs{btcusdtPair}, Channel: subscription.MyTradesChannel}) + require.NoError(t, err, "AddSubscriptions must not error") + h.SetSaveTradeDataStatus(true) + testexch.FixtureToDataHandler(t, "testdata/wsMyTrades.json", h.wsHandleData) + close(h.Websocket.DataHandler) + require.Len(t, h.Websocket.DataHandler, 1, "Must see correct number of records") + m := <-h.Websocket.DataHandler + exp := &order.Detail{ + Exchange: h.Name, + Pair: btcusdtPair, + Side: order.Buy, + Status: order.PartiallyFilled, + ClientOrderID: "a001", + OrderID: "99998888", + AssetType: asset.Spot, + Date: time.Unix(1583853365586000, 0), + LastUpdated: time.Unix(1583853365996000, 0), + Price: 10000, + Amount: 1, + Trades: []order.TradeHistory{ + { + Price: 9999.99, + Amount: 0.96, + Fee: 19.88, + Exchange: h.Name, + TID: "919219323232", + Side: order.Buy, + IsMaker: false, + Timestamp: time.Unix(1583853365996000, 0), + }, + }, + } + require.IsType(t, &order.Detail{}, m, "Must get the correct type from DataHandler") + d, _ := m.(*order.Detail) + require.NotNil(t, d) + assert.Equal(t, exp, d, "Order Detail should match") } func TestStringToOrderStatus(t *testing.T) { @@ -1702,12 +1625,12 @@ func TestFormatFuturesPair(t *testing.T) { r, err = h.formatFuturesPair(btcFutureDatedPair.Lower(), false) require.NoError(t, err) assert.Len(t, r, 9, "Should be an 9 character string") - assert.Equal(t, "BTC2", r[0:4], "Should start with btc and a date this millenium") + assert.Equal(t, "BTC2", r[0:4], "Should start with btc and a date this millennium") r, err = h.formatFuturesPair(btccwPair, true) require.NoError(t, err) assert.Len(t, r, 9, "Should be an 9 character string") - assert.Equal(t, "BTC2", r[0:4], "Should start with btc and a date this millenium") + assert.Equal(t, "BTC2", r[0:4], "Should start with btc and a date this millennium") r, err = h.formatFuturesPair(currency.NewPair(currency.BTC, currency.USDT), false) require.NoError(t, err) @@ -1756,9 +1679,9 @@ func TestGetFuturesContractDetails(t *testing.T) { require.ErrorIs(t, err, asset.ErrNotSupported) _, err = h.GetFuturesContractDetails(context.Background(), asset.CoinMarginedFutures) - require.ErrorIs(t, err, nil) + require.NoError(t, err) _, err = h.GetFuturesContractDetails(context.Background(), asset.Futures) - require.ErrorIs(t, err, nil) + require.NoError(t, err) } func TestGetLatestFundingRates(t *testing.T) { @@ -1954,13 +1877,18 @@ func TestGetCurrencyTradeURL(t *testing.T) { func TestGenerateSubscriptions(t *testing.T) { t.Parallel() - h := new(HUOBI) + h := new(HUOBI) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes require.NoError(t, testexch.Setup(h), "Test instance Setup must not error") + + h.Websocket.SetCanUseAuthenticatedEndpoints(true) subs, err := h.generateSubscriptions() require.NoError(t, err, "generateSubscriptions must not error") exp := subscription.List{} for _, s := range h.Features.Subscriptions { - if s.Authenticated && !h.Websocket.CanUseAuthenticatedEndpoints() { + if s.Asset == asset.Empty { + s := s.Clone() //nolint:govet // Intentional lexical scope shadow + s.QualifiedChannel = channelName(s) + exp = append(exp, s) continue } for _, a := range h.GetAssetTypes(true) { @@ -1972,6 +1900,12 @@ func TestGenerateSubscriptions(t *testing.T) { pairs = common.SortStrings(pairs).Format(currency.PairFormat{Uppercase: false, Delimiter: ""}) s := s.Clone() //nolint:govet // Intentional lexical scope shadow s.Asset = a + if isWildcardChannel(s) { + s.Pairs = pairs + s.QualifiedChannel = channelName(s) + exp = append(exp, s) + continue + } for i, p := range pairs { s := s.Clone() //nolint:govet // Intentional lexical scope shadow s.QualifiedChannel = channelName(s, p) @@ -1989,10 +1923,28 @@ func TestGenerateSubscriptions(t *testing.T) { testsubs.EqualLists(t, exp, subs) } +func wsFixture(tb testing.TB, msg []byte, w *websocket.Conn) error { + tb.Helper() + action, _ := jsonparser.GetString(msg, "action") + ch, _ := jsonparser.GetString(msg, "ch") + if action == "req" && ch == "auth" { + return w.WriteMessage(websocket.TextMessage, []byte(`{"action":"req","code":200,"ch":"auth","data":{}}`)) + } + if action == "sub" { + return w.WriteMessage(websocket.TextMessage, []byte(`{"action":"sub","code":200,"ch":"`+ch+`"}`)) + } + id, _ := jsonparser.GetString(msg, "id") + sub, _ := jsonparser.GetString(msg, "sub") + if id != "" && sub != "" { + return w.WriteMessage(websocket.TextMessage, []byte(`{"id":"`+id+`","status":"ok","subbed":"`+sub+`"}`)) + } + return fmt.Errorf("%w: %s", errors.New("Unhandled mock websocket message"), msg) +} + // TestSubscribe exercises live public subscriptions func TestSubscribe(t *testing.T) { t.Parallel() - h := new(HUOBI) + h := new(HUOBI) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes require.NoError(t, testexch.Setup(h), "Test instance Setup must not error") subs, err := h.Features.Subscriptions.ExpandTemplates(h) require.NoError(t, err, "ExpandTemplates must not error") @@ -2000,17 +1952,52 @@ func TestSubscribe(t *testing.T) { err = h.Subscribe(subs) require.NoError(t, err, "Subscribe must not error") got := h.Websocket.GetSubscriptions() - require.Equal(t, 4, len(got), "Must get correct number of subscriptions") + require.Equal(t, 8, len(got), "Must get correct number of subscriptions") + for _, s := range got { + assert.Equal(t, subscription.SubscribedState, s.State()) + } +} + +// TestAuthSubscribe exercises mock subscriptions including private +func TestAuthSubscribe(t *testing.T) { + t.Parallel() + subCfg := h.Features.Subscriptions + h := testexch.MockWsInstance[HUOBI](t, mockws.CurryWsMockUpgrader(t, wsFixture)) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + h.Websocket.SetCanUseAuthenticatedEndpoints(true) + subs, err := subCfg.ExpandTemplates(h) + require.NoError(t, err, "ExpandTemplates must not error") + err = h.Subscribe(subs) + require.NoError(t, err, "Subscribe must not error") + got := h.Websocket.GetSubscriptions() + require.Equal(t, 11, len(got), "Must get correct number of subscriptions") for _, s := range got { assert.Equal(t, subscription.SubscribedState, s.State()) } } func TestChannelName(t *testing.T) { - p := currency.NewPair(currency.BTC, currency.USD) - assert.Equal(t, "market.BTCUSD.kline", channelName(&subscription.Subscription{Channel: subscription.CandlesChannel}, p)) - assert.Panics(t, func() { channelName(&subscription.Subscription{Channel: wsOrderbookChannel}, p) }) - assert.Panics(t, func() { channelName(&subscription.Subscription{Channel: subscription.MyAccountChannel}, p) }, "Should panic on V2 endpoints until implemented") + assert.Equal(t, "market.BTC-USD.kline", channelName(&subscription.Subscription{Channel: subscription.CandlesChannel}, btcusdPair)) + assert.Equal(t, "trade.clearing#*#1", channelName(&subscription.Subscription{Channel: subscription.MyTradesChannel}, btcusdPair)) + assert.Panics(t, func() { channelName(&subscription.Subscription{Channel: wsOrderbookChannel}, btcusdPair) }) +} + +func TestIsWildcardChannel(t *testing.T) { + assert.False(t, isWildcardChannel(&subscription.Subscription{Channel: subscription.CandlesChannel})) + assert.True(t, isWildcardChannel(&subscription.Subscription{Channel: subscription.MyOrdersChannel})) + assert.Panics(t, func() { channelName(&subscription.Subscription{Channel: wsOrderbookChannel}) }) +} + +func TestGetErrResp(t *testing.T) { + err := getErrResp([]byte(`{"status":"error","err-code":"bad-request","err-msg":"invalid topic promiscuous.drop🐻s.nearby"}`)) + assert.ErrorContains(t, err, "invalid topic promiscuous.drop🐻s.nearby (bad-request)", "V1 errors should return correctly") + err = getErrResp([]byte(`{"status":"ok","subbed":"market.btcusdt.trade.detail"}`)) + assert.NoError(t, err, "V1 success should not error") + + err = getErrResp([]byte(`{"action":"sub","code":2001,"ch":"naughty.drop🐻s.locally","message":"invalid.ch"}`)) + assert.ErrorContains(t, err, "invalid.ch (2001)", "V2 errors should return correctly") + + err = getErrResp([]byte(`{"action":"sub","code":200,"ch":"orders#btcusdt","data":{}}`)) + assert.NoError(t, err, "V2 success should not error") } func TestBootstrap(t *testing.T) { diff --git a/exchanges/huobi/huobi_types.go b/exchanges/huobi/huobi_types.go index 948e23d3e24..99fd712168d 100644 --- a/exchanges/huobi/huobi_types.go +++ b/exchanges/huobi/huobi_types.go @@ -807,30 +807,11 @@ type KlinesRequestParams struct { Size int // Size; [1-2000] } -// WsRequest defines a request data structure -type WsRequest struct { - Topic string `json:"req,omitempty"` - Subscribe string `json:"sub,omitempty"` - Unsubscribe string `json:"unsub,omitempty"` - ClientID int64 `json:"cid,string,omitempty"` -} - -// WsResponse defines a response from the websocket connection when there -// is an error -type WsResponse struct { - Op string `json:"op"` - TS int64 `json:"ts"` - Status string `json:"status"` - // ErrorCode returns either an integer or a string - ErrorCode interface{} `json:"err-code"` - ErrorMessage string `json:"err-msg"` - Ping int64 `json:"ping"` - Channel string `json:"ch"` - Rep string `json:"rep"` - Topic string `json:"topic"` - Subscribed string `json:"subbed"` - UnSubscribed string `json:"unsubbed"` - ClientID int64 `json:"cid,string"` +// wsSubReq is a request to subscribe to or unubscribe from a topic for public channels (private channels use generic wsReq) +type wsSubReq struct { + ID string `json:"id,omitempty"` + Sub string `json:"sub,omitempty"` + Unsub string `json:"unsub,omitempty"` } // WsHeartBeat defines a heartbeat request @@ -901,189 +882,100 @@ type WsTrade struct { } } -// 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"` - ClientID int64 `json:"cid,string,omitempty"` -} - -// 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"` - ClientID int64 `json:"cid,string,omitempty"` -} - -// 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 string `json:"symbol"` - ClientID int64 `json:"cid,string,omitempty"` -} - -// 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"` - ClientID int64 `json:"cid,string,omitempty"` -} - -// 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 string `json:"symbol"` - ClientID int64 `json:"cid,string,omitempty"` -} - -// WsAuthenticatedAccountsResponse response from Accounts authenticated subscription -type WsAuthenticatedAccountsResponse struct { - WsResponse - 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 { - WsResponse - Data WsAuthenticatedOrdersUpdateResponseData `json:"data"` -} - -// WsAuthenticatedOrdersUpdateResponseData order update data -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 string `json:"symbol"` - MatchID int64 `json:"match-id"` - FilledCashAmount float64 `json:"filled-cash-amount,string"` - Role string `json:"role"` - OrderState string `json:"order-state"` - OrderType string `json:"order-type"` -} - -// WsAuthenticatedOrdersResponse response from Orders authenticated subscription -type WsAuthenticatedOrdersResponse struct { - WsResponse - Data []WsAuthenticatedOrdersResponseData `json:"data"` -} - -// WsOldOrderUpdate response from Orders authenticated subscription -type WsOldOrderUpdate struct { - WsResponse - Data WsAuthenticatedOrdersResponseData `json:"data"` -} - -// WsAuthenticatedOrdersResponseData order data -type WsAuthenticatedOrdersResponseData struct { - SeqID int64 `json:"seq-id"` - OrderID int64 `json:"order-id"` - Symbol string `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 { - WsResponse - 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 { - WsResponse - Data []OrderInfo `json:"data"` -} - -// WsAuthenticatedOrderDetailResponse response from OrderDetail authenticated endpoint -type WsAuthenticatedOrderDetailResponse struct { - WsResponse - Data OrderInfo `json:"data"` -} - -// WsPong sent for pong messages -type WsPong struct { - Pong int64 `json:"pong"` -} - -type authenticationPing struct { - OP string `json:"op"` - TS int64 `json:"ts"` +// wsReq contains authentication login fields +type wsReq struct { + Action string `json:"action"` + Channel string `json:"ch"` + Params any `json:"params"` +} + +// wsAuthReq contains authentication login fields +type wsAuthReq struct { + AuthType string `json:"authType"` + AccessKey string `json:"accessKey"` + SignatureMethod string `json:"signatureMethod"` + SignatureVersion string `json:"signatureVersion"` + Timestamp string `json:"timestamp"` + Signature string `json:"signature"` +} + +type wsAccountUpdateMsg struct { + Data WsAccountUpdate `json:"data"` +} + +// WsAccountUpdate contains account updates to balances +type WsAccountUpdate struct { + Currency string `json:"currency"` + AccountID int64 `json:"accountId"` + Balance float64 `json:"balance,string"` + Available float64 `json:"available,string"` + ChangeType string `json:"changeType"` + AccountType string `json:"accountType"` + ChangeTime int64 `json:"changeTime"` + SeqNum int64 `json:"seqNum"` +} + +type wsOrderUpdateMsg struct { + Data WsOrderUpdate `json:"data"` +} + +// WsOrderUpdate contains updates to orders +type WsOrderUpdate struct { + EventType string `json:"eventType"` + Symbol string `json:"symbol"` + AccountID int64 `json:"accountId"` + OrderID int64 `json:"orderId"` + TradeID int64 `json:"tradeId"` + ClientOrderID string `json:"clientOrderId"` + Source string `json:"orderSource"` + Price float64 `json:"orderPrice,string"` + Size float64 `json:"orderSize,string"` + Value float64 `json:"orderValue,string"` + OrderType string `json:"type"` + TradePrice float64 `json:"tradePrice,string"` + TradeVolume float64 `json:"tradeVolume,string"` + RemainingAmount float64 `json:"remainAmt,string"` + ExecutedAmount float64 `json:"execAmt,string"` + IsTaker bool `json:"aggressor"` + Side order.Side `json:"orderSide"` + OrderStatus string `json:"orderStatus"` + LastActTime int64 `json:"lastActTime"` + CreateTime int64 `json:"orderCreateTime"` + TradeTime int64 `json:"tradeTime"` + ErrCode int64 `json:"errCode"` + ErrMessage string `json:"errMessage"` +} + +type wsTradeUpdateMsg struct { + Data WsTradeUpdate `json:"data"` +} + +// WsTradeUpdate contains trade updates to orders +type WsTradeUpdate struct { + EventType string `json:"eventType"` + Symbol string `json:"symbol"` + OrderID int64 `json:"orderId"` + TradePrice float64 `json:"tradePrice,string"` + TradeVolume float64 `json:"tradeVolume,string"` + Side order.Side `json:"orderSide"` + OrderType string `json:"orderType"` + IsTaker bool `json:"aggressor"` + TradeID int64 `json:"tradeId"` + TradeTime int64 `json:"tradeTime"` + TransactFee float64 `json:"transactFee,string"` + FeeCurrency string `json:"feeCurrency"` + FeeDeduct string `json:"feeDeduct"` + FeeDeductType string `json:"feeDeductType"` + AccountID int64 `json:"accountId"` + Source string `json:"orderSource"` + OrderPrice float64 `json:"orderPrice,string"` + OrderSize float64 `json:"orderSize,string"` + Value float64 `json:"orderValue,string"` + ClientOrderID string `json:"clientOrderId"` + StopPrice string `json:"stopPrice"` + Operator string `json:"operator"` + OrderCreateTime int64 `json:"orderCreateTime"` + OrderStatus string `json:"orderStatus"` } // OrderVars stores side, status and type for any order/trade diff --git a/exchanges/huobi/huobi_websocket.go b/exchanges/huobi/huobi_websocket.go index 0ff45913cce..269d72e46f5 100644 --- a/exchanges/huobi/huobi_websocket.go +++ b/exchanges/huobi/huobi_websocket.go @@ -12,6 +12,7 @@ import ( "text/template" "time" + "github.com/buger/jsonparser" "github.com/gorilla/websocket" "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/common/crypto" @@ -30,34 +31,31 @@ import ( ) const ( - baseWSURL = "wss://api.huobi.pro" - futuresWSURL = "wss://api.hbdm.com/" + wsSpotHost = "api.huobi.pro" + wsSpotURL = "wss://" + wsSpotHost + wsPublicPath = "/ws" + wsPrivatePath = "/ws/v2" - wsMarketURL = baseWSURL + "/ws" wsCandlesChannel = "market.%s.kline" wsOrderbookChannel = "market.%s.depth" wsTradesChannel = "market.%s.trade.detail" wsMarketDetailChannel = "market.%s.detail" - wsMyOrdersChannel = "orders.%s" - wsMyTradesChannel = "orders.%s.update" - - wsAccountsOrdersEndPoint = "/ws/v1" - wsAccountsList = "accounts.list" - wsOrdersList = "orders.list" - wsOrdersDetail = "orders.detail" - wsAccountsOrdersURL = baseWSURL + wsAccountsOrdersEndPoint - wsAccountListEndpoint = wsAccountsOrdersEndPoint + "/" + wsAccountsList - wsOrdersListEndpoint = wsAccountsOrdersEndPoint + "/" + wsOrdersList - wsOrdersDetailEndpoint = wsAccountsOrdersEndPoint + "/" + wsOrdersDetail + 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" +) - signatureMethod = "HmacSHA256" - signatureVersion = "2" - requestOp = "req" - authOp = "auth" - - loginDelay = 50 * time.Millisecond +var ( + errInvalidChannel = errors.New("invalid channel format") + errParsingMsg = errors.New("error parsing message") ) var defaultSubscriptions = subscription.List{ @@ -75,427 +73,240 @@ var subscriptionNames = map[string]string{ subscription.CandlesChannel: wsCandlesChannel, subscription.OrderbookChannel: wsOrderbookChannel, subscription.AllTradesChannel: wsTradesChannel, - /* TODO: Pending upcoming V2 support, these are dropped from the translation table so that the sub conf will be correct and not need upgrading, but will error on usage - subscription.MyTradesChannel: wsMyOrdersChannel, - subscription.MyOrdersChannel: wsMyTradesChannel, + subscription.MyTradesChannel: wsMyTradesChannel, + subscription.MyOrdersChannel: wsMyOrdersChannel, subscription.MyAccountChannel: wsMyAccountChannel, - */ } -// Instantiates a communications channel between websocket connections -var comms = make(chan WsMessage) - // WsConnect initiates a new websocket connection func (h *HUOBI) WsConnect() error { if !h.Websocket.IsEnabled() || !h.IsEnabled() { return stream.ErrWebsocketNotEnabled } - var dialer websocket.Dialer - err := h.wsDial(&dialer) - if err != nil { + if err := h.Websocket.Conn.Dial(&websocket.Dialer{}, http.Header{}); err != nil { return err } + ctx := context.Background() - if h.Websocket.CanUseAuthenticatedEndpoints() { - err = h.wsAuthenticatedDial(&dialer) - if err != nil { - log.Errorf(log.ExchangeSys, - "%v - authenticated dial failed: %v\n", - h.Name, - err) - } - err = h.wsLogin(context.TODO()) - if err != nil { - log.Errorf(log.ExchangeSys, - "%v - authentication failed: %v\n", - h.Name, - err) - h.Websocket.SetCanUseAuthenticatedEndpoints(false) - } - } - - h.Websocket.Wg.Add(1) - go h.wsReadData() - return nil -} - -func (h *HUOBI) wsDial(dialer *websocket.Dialer) error { - err := h.Websocket.Conn.Dial(dialer, http.Header{}) - if err != nil { - return err - } - h.Websocket.Wg.Add(1) - go h.wsFunnelConnectionData(h.Websocket.Conn, wsMarketURL) - return nil -} + ch := make(chan []byte) + h.Websocket.Wg.Add(2) + go h.wsReadMsgs(ch) + go h.wsFunnelMsgs(h.Websocket.Conn, ch) -func (h *HUOBI) wsAuthenticatedDial(dialer *websocket.Dialer) error { - if !h.IsWebsocketAuthenticationSupported() { - return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", - h.Name) - } - err := h.Websocket.AuthConn.Dial(dialer, http.Header{}) - if err != nil { - return err + h.Websocket.SetCanUseAuthenticatedEndpoints(false) + if h.IsWebsocketAuthenticationSupported() { + if err := h.wsAuthConnect(ctx); err != nil { + return fmt.Errorf("error authenticating websocket: %w", err) + } + h.Websocket.Wg.Add(1) + go h.wsFunnelMsgs(h.Websocket.AuthConn, ch) } - h.Websocket.Wg.Add(1) - go h.wsFunnelConnectionData(h.Websocket.AuthConn, wsAccountsOrdersURL) return nil } -// wsFunnelConnectionData manages data from multiple endpoints and passes it to -// a channel -func (h *HUOBI) wsFunnelConnectionData(ws stream.Connection, url string) { +// wsFunnelMsgs relays messages from a websocket to a channel for central processing +func (h *HUOBI) wsFunnelMsgs(s stream.Connection, ch chan []byte) { defer h.Websocket.Wg.Done() for { - resp := ws.ReadMessage() - if resp.Raw == nil { + msg := s.ReadMessage() + if msg.Raw == nil { return } - comms <- WsMessage{Raw: resp.Raw, URL: url} + ch <- msg.Raw } } -// wsReadData receives and passes on websocket messages for processing -func (h *HUOBI) wsReadData() { +// wsReadMsgs receives messages from a message funnel and processes them +func (h *HUOBI) wsReadMsgs(ch chan []byte) { defer h.Websocket.Wg.Done() for { select { case <-h.Websocket.ShutdownC: - select { - case resp := <-comms: - err := h.wsHandleData(resp.Raw) - if err != nil { - select { - case h.Websocket.DataHandler <- err: - default: - log.Errorf(log.WebsocketMgr, - "%s websocket handle data error: %v", - h.Name, - err) - } - } - default: - } return - case resp := <-comms: - err := h.wsHandleData(resp.Raw) - if err != nil { + case msg := <-ch: + if err := h.wsHandleData(msg); err != nil { h.Websocket.DataHandler <- err } } } } - -func stringToOrderStatus(status string) (order.Status, error) { - switch status { - case "submitted": - return order.New, nil - case "canceled": - return order.Cancelled, nil - case "partial-filled": - return order.PartiallyFilled, nil - case "partial-canceled": - return order.PartiallyCancelled, 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") -} - func (h *HUOBI) wsHandleData(respRaw []byte) error { - var init WsResponse - err := json.Unmarshal(respRaw, &init) - if err != nil { - return err - } - if init.Subscribed != "" || - init.UnSubscribed != "" || - init.Op == "sub" || - init.Op == "unsub" { - // TODO handle subs - return nil - } - if init.Ping != 0 { - h.sendPingResponse(init.Ping) - return nil + for _, op := range []string{wsSubOp, wsUnsubOp} { + key := op + "bed" // subbed, unsubbed + if ch, err := jsonparser.GetString(respRaw, key); err == nil { + if !h.Websocket.Match.IncomingWithData(op+":"+ch, respRaw) { + return fmt.Errorf("%w: %s:%s", stream.ErrNoMessageListener, op, ch) + } + } } - if init.Op == "ping" { - authPing := authenticationPing{ - OP: "pong", - TS: init.TS, - } - err := h.Websocket.AuthConn.SendJSONMessage(context.TODO(), request.Unset, authPing) - if err != nil { - log.Errorln(log.ExchangeSys, err) + if id, err := jsonparser.GetString(respRaw, "id"); err == nil { + if h.Websocket.Match.IncomingWithData(id, respRaw) { + return nil } - return nil } - if init.ErrorMessage != "" { - if init.ErrorMessage == "api-signature-not-valid" { - h.Websocket.SetCanUseAuthenticatedEndpoints(false) - return errors.New(h.Name + - " - invalid credentials. Authenticated requests disabled") + if ping, err := jsonparser.GetInt(respRaw, "ping"); err == nil { + if err := h.Websocket.Conn.SendJSONMessage(context.Background(), request.Unset, json.RawMessage(`{"pong":`+strconv.Itoa(int(ping))+`}`)); err != nil { + return fmt.Errorf("error sending pong response: %w", err) } - - codes, _ := init.ErrorCode.(string) - return errors.New(h.Name + " Code:" + codes + " Message:" + init.ErrorMessage) + return nil } - if init.ClientID > 0 { - if h.Websocket.Match.IncomingWithData(init.ClientID, respRaw) { - return nil + if action, err := jsonparser.GetString(respRaw, "action"); err == nil { + switch action { + case "ping": + return h.wsHandleV2ping(action, respRaw) + case wsSubOp, wsUnsubOp: + return h.wsHandleV2subResp(action, respRaw) } } - switch { - case strings.EqualFold(init.Op, authOp): - h.Websocket.SetCanUseAuthenticatedEndpoints(true) - // Auth captured - return nil - case strings.EqualFold(init.Topic, "accounts"): - var response WsAuthenticatedAccountsResponse - err := json.Unmarshal(respRaw, &response) - if err != nil { - return err - } - h.Websocket.DataHandler <- response + if err := getErrResp(respRaw); err != nil { + return err + } - case strings.Contains(init.Topic, "orders") && - strings.Contains(init.Topic, "update"): - var response WsAuthenticatedOrdersUpdateResponse - err := json.Unmarshal(respRaw, &response) - if err != nil { - return err - } - data := strings.Split(response.Topic, ".") - if len(data) < 2 { - return errors.New(h.Name + - " - currency could not be extracted from response") - } - orderID := strconv.FormatInt(response.Data.OrderID, 10) - var oSide order.Side - oSide, err = stringToOrderSide(response.Data.OrderType) - if err != nil { - h.Websocket.DataHandler <- order.ClassificationError{ - Exchange: h.Name, - OrderID: orderID, - Err: err, - } - } - var oType order.Type - oType, err = stringToOrderType(response.Data.OrderType) - if err != nil { - h.Websocket.DataHandler <- order.ClassificationError{ - Exchange: h.Name, - OrderID: orderID, - Err: err, - } - } - var oStatus order.Status - oStatus, err = stringToOrderStatus(response.Data.OrderState) - if err != nil { - h.Websocket.DataHandler <- order.ClassificationError{ - Exchange: h.Name, - OrderID: orderID, - Err: err, - } - } - var p currency.Pair - var a asset.Item - p, a, err = h.GetRequestFormattedPairAndAssetType(data[1]) - if err != nil { - return err - } - h.Websocket.DataHandler <- &order.Detail{ - Price: response.Data.Price, - Amount: response.Data.UnfilledAmount + response.Data.FilledAmount, - ExecutedAmount: response.Data.FilledAmount, - RemainingAmount: response.Data.UnfilledAmount, - Exchange: h.Name, - OrderID: orderID, - Type: oType, - Side: oSide, - Status: oStatus, - AssetType: a, - LastUpdated: time.Unix(response.TS*1000, 0), - Pair: p, + if ch, err := jsonparser.GetString(respRaw, "ch"); err == nil { + s := h.Websocket.GetSubscription(ch) + if s == nil { + return subscription.ErrNotFound } + return h.wsHandleChannelMsgs(s, respRaw) + } - case strings.Contains(init.Topic, "orders"): - var response WsOldOrderUpdate - err := json.Unmarshal(respRaw, &response) - if err != nil { - return err - } - h.Websocket.DataHandler <- response - case strings.Contains(init.Channel, "depth"): - var depth WsDepth - err := json.Unmarshal(respRaw, &depth) - if err != nil { - return err - } + h.Websocket.DataHandler <- stream.UnhandledMessageWarning{ + Message: h.Name + stream.UnhandledMessage + string(respRaw), + } - data := strings.Split(depth.Channel, ".") - err = h.WsProcessOrderbook(&depth, data[1]) - if err != nil { - return err - } - case strings.Contains(init.Channel, "kline"): - var kline WsKline - err := json.Unmarshal(respRaw, &kline) - if err != nil { - return err - } - data := strings.Split(kline.Channel, ".") - var p currency.Pair - var a asset.Item - p, a, err = h.GetRequestFormattedPairAndAssetType(data[1]) - if err != nil { - return err - } - h.Websocket.DataHandler <- stream.KlineData{ - Timestamp: time.UnixMilli(kline.Timestamp), - Exchange: h.Name, - AssetType: a, - Pair: p, - OpenPrice: kline.Tick.Open, - ClosePrice: kline.Tick.Close, - HighPrice: kline.Tick.High, - LowPrice: kline.Tick.Low, - Volume: kline.Tick.Volume, - Interval: data[3], - } - case strings.Contains(init.Channel, "trade.detail"): - if !h.IsSaveTradeDataEnabled() { - return nil - } - var t WsTrade - err := json.Unmarshal(respRaw, &t) - if err != nil { - return err - } - data := strings.Split(t.Channel, ".") - var p currency.Pair - var a asset.Item - p, a, err = h.GetRequestFormattedPairAndAssetType(data[1]) - if err != nil { - return err - } - var trades []trade.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: a, - CurrencyPair: p, - Timestamp: time.UnixMilli(t.Tick.Data[i].Timestamp), - 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), - }) - } - return trade.AddTradesToBuffer(h.Name, trades...) - case strings.Contains(init.Channel, "detail"), - strings.Contains(init.Rep, "detail"): - var wsTicker WsTick - err := json.Unmarshal(respRaw, &wsTicker) - if err != nil { - return err - } - var data []string - if wsTicker.Channel != "" { - data = strings.Split(wsTicker.Channel, ".") - } - if wsTicker.Rep != "" { - data = strings.Split(wsTicker.Rep, ".") - } + return nil +} - var p currency.Pair - var a asset.Item - p, a, err = h.GetRequestFormattedPairAndAssetType(data[1]) - if err != nil { - return err - } +func (h *HUOBI) wsHandleV2ping(_ string, 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 +} - 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: time.UnixMilli(wsTicker.Timestamp), - AssetType: a, - Pair: p, +func (h *HUOBI) wsHandleV2subResp(action string, respRaw []byte) error { + if ch, err := jsonparser.GetString(respRaw, "ch"); err == nil { + if !h.Websocket.Match.IncomingWithData(action+":"+ch, respRaw) { + return fmt.Errorf("%w: %s:%s", stream.ErrNoMessageListener, action, ch) } - default: - h.Websocket.DataHandler <- stream.UnhandledMessageWarning{ - Message: h.Name + stream.UnhandledMessage + string(respRaw), - } - return nil } return nil } -func (h *HUOBI) sendPingResponse(pong int64) { - err := h.Websocket.Conn.SendJSONMessage(context.TODO(), request.Unset, WsPong{Pong: pong}) - if err != nil { - log.Errorln(log.ExchangeSys, err) - } +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) } -// WsProcessOrderbook processes new orderbook data -func (h *HUOBI) WsProcessOrderbook(update *WsDepth, symbol string) error { - pairs, err := h.GetEnabledPairs(asset.Spot) - if err != nil { +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 <- stream.KlineData{ + Timestamp: time.UnixMilli(c.Timestamp), + 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 +} - format, err := h.GetPairFormat(asset.Spot, true) - if err != nil { +func (h *HUOBI) wsHandleAllTradesMsg(s *subscription.Subscription, respRaw []byte) error { + if !h.IsSaveTradeDataEnabled() { + 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: time.UnixMilli(t.Tick.Data[i].Timestamp), + 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), + }) + } + return trade.AddTradesToBuffer(h.Name, trades...) +} - p, err := currency.NewPairFromFormattedPairs(symbol, - pairs, - format) - if err != 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: time.UnixMilli(wsTicker.Timestamp), + 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) @@ -531,7 +342,7 @@ func (h *HUOBI) WsProcessOrderbook(update *WsDepth, symbol string) error { var newOrderBook orderbook.Base newOrderBook.Asks = asks newOrderBook.Bids = bids - newOrderBook.Pair = p + newOrderBook.Pair = s.Pairs[0] newOrderBook.Asset = asset.Spot newOrderBook.Exchange = h.Name newOrderBook.VerifyOrderbook = h.CanVerifyOrderbook @@ -540,6 +351,147 @@ func (h *HUOBI) WsProcessOrderbook(update *WsDepth, symbol string) error { 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 = time.Unix(o.LastActTime*1000, 0) + case "creation": + d.LastUpdated = time.Unix(o.CreateTime*1000, 0) + case "trade": + d.LastUpdated = time.Unix(o.TradeTime*1000, 0) + } + 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: time.Unix(t.OrderCreateTime*1000, 0), + LastUpdated: time.Unix(t.TradeTime*1000, 0), + 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.Itoa(int(t.TradeID)), + Type: d.Type, + Side: d.Side, + IsMaker: !t.IsTaker, + Timestamp: time.Unix(t.TradeTime*1000, 0), + }, + } + 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) @@ -548,284 +500,232 @@ func (h *HUOBI) generateSubscriptions() (subscription.List, error) { // 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, - "interval": h.FormatExchangeKlineInterval, + "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 { - ctx := context.Background() - var errs error - var creds *account.Credentials - if len(subs.Private()) > 0 { - if creds, errs = h.GetCredentials(ctx); errs != nil { - return errs - } - } - for _, s := range subs { - var err error - if s.Authenticated { - if err = h.wsAuthenticatedSubscribe(creds, "sub", wsAccountsOrdersEndPoint+"/"+s.QualifiedChannel, s.QualifiedChannel); err == nil { - err = h.Websocket.AddSuccessfulSubscriptions(h.Websocket.Conn, s) - } - } else { - if err = h.Websocket.Conn.SendJSONMessage(ctx, request.Unset, WsRequest{Subscribe: s.QualifiedChannel}); err == nil { - err = h.Websocket.AddSuccessfulSubscriptions(h.Websocket.AuthConn, s) - } - } - errs = common.AppendError(errs, err) - } - return nil + 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 { - ctx := context.Background() - var errs error - var creds *account.Credentials - if len(subs.Private()) > 0 { - if creds, errs = h.GetCredentials(ctx); errs != nil { - return errs - } + 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 } - for _, s := range subs { - var err error - if s.Authenticated { - err = h.wsAuthenticatedSubscribe(creds, "unsub", wsAccountsOrdersEndPoint+"/"+s.QualifiedChannel, s.QualifiedChannel) + s := subs[0] + var c stream.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 { - err = h.Websocket.Conn.SendJSONMessage(ctx, request.Unset, WsRequest{Unsubscribe: s.QualifiedChannel}) + 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", stream.ErrSubscriptionFailure, s, err) } - if err == nil { - err = h.Websocket.RemoveSubscriptions(h.Websocket.Conn, s) + } + 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) } - errs = common.AppendError(errs, err) + return fmt.Errorf("%s: %w", s, err) } - return errs + 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, endpoint string) ([]byte, error) { +func (h *HUOBI) wsGenerateSignature(creds *account.Credentials, timestamp string) ([]byte, error) { values := url.Values{} - values.Set("AccessKeyId", creds.Key) - 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", - http.MethodGet, host, endpoint, values.Encode()) + values.Set("accessKey", creds.Key) + values.Set("signatureMethod", signatureMethod) + values.Set("signatureVersion", signatureVersion) + values.Set("timestamp", timestamp) + payload := fmt.Sprintf("%s\n%s\n%s\n%s", http.MethodGet, wsSpotHost, wsPrivatePath, values.Encode()) return crypto.GetHMAC(crypto.HashSHA256, []byte(payload), []byte(creds.Secret)) } -func (h *HUOBI) wsLogin(ctx context.Context) error { - if !h.IsWebsocketAuthenticationSupported() { - return fmt.Errorf("%v AuthenticatedWebsocketAPISupport not enabled", h.Name) +func (h *HUOBI) wsAuthConnect(ctx context.Context) error { + if err := h.Websocket.AuthConn.Dial(&websocket.Dialer{}, http.Header{}); err != nil { + return fmt.Errorf("authenticated dial failed: %w", err) } - creds, err := h.GetCredentials(ctx) - if err != nil { - return err + if err := h.wsLogin(ctx); err != nil { + return fmt.Errorf("authentication failed: %w", err) } - h.Websocket.SetCanUseAuthenticatedEndpoints(true) - timestamp := time.Now().UTC().Format(wsDateTimeFormatting) - req := WsAuthenticationRequest{ - Op: authOp, - AccessKeyID: creds.Key, - SignatureMethod: signatureMethod, - SignatureVersion: signatureVersion, - Timestamp: timestamp, - } - hmac, err := h.wsGenerateSignature(creds, timestamp, wsAccountsOrdersEndPoint) - if err != nil { - return err - } - req.Signature = crypto.Base64Encode(hmac) - err = h.Websocket.AuthConn.SendJSONMessage(context.TODO(), request.Unset, req) - if err != nil { - h.Websocket.SetCanUseAuthenticatedEndpoints(false) - return err - } - - time.Sleep(loginDelay) return nil } -func (h *HUOBI) wsAuthenticatedSubscribe(creds *account.Credentials, operation, endpoint, topic string) error { - timestamp := time.Now().UTC().Format(wsDateTimeFormatting) - req := WsAuthenticatedSubscriptionRequest{ - Op: operation, - AccessKeyID: creds.Key, - SignatureMethod: signatureMethod, - SignatureVersion: signatureVersion, - Timestamp: timestamp, - Topic: topic, - } - hmac, err := h.wsGenerateSignature(creds, timestamp, endpoint) - if err != nil { - return err - } - req.Signature = crypto.Base64Encode(hmac) - return h.Websocket.AuthConn.SendJSONMessage(context.TODO(), request.Unset, req) -} - -func (h *HUOBI) wsGetAccountsList(ctx context.Context) (*WsAuthenticatedAccountsListResponse, error) { - if !h.Websocket.CanUseAuthenticatedEndpoints() { - return nil, fmt.Errorf("%v not authenticated cannot get accounts list", h.Name) - } +func (h *HUOBI) wsLogin(ctx context.Context) error { creds, err := h.GetCredentials(ctx) if err != nil { - return nil, err + return err } - timestamp := time.Now().UTC().Format(wsDateTimeFormatting) - req := WsAuthenticatedAccountsListRequest{ - Op: requestOp, - AccessKeyID: creds.Key, - SignatureMethod: signatureMethod, - SignatureVersion: signatureVersion, - Timestamp: timestamp, - Topic: wsAccountsList, - } - hmac, err := h.wsGenerateSignature(creds, timestamp, wsAccountListEndpoint) + c := h.Websocket.AuthConn + ts := time.Now().UTC().Format(wsDateTimeFormatting) + hmac, err := h.wsGenerateSignature(creds, ts) if err != nil { - return nil, err + return err } - req.Signature = crypto.Base64Encode(hmac) - req.ClientID = h.Websocket.AuthConn.GenerateMessageID(true) - resp, err := h.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, req.ClientID, req) + 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 nil, err + return err } - var response WsAuthenticatedAccountsListResponse - err = json.Unmarshal(resp, &response) - if err != nil { - return nil, err + resp := c.ReadMessage() + if resp.Raw == nil { + return &websocket.CloseError{Code: websocket.CloseAbnormalClosure} } - code, _ := response.ErrorCode.(int) - if code != 0 { - return nil, errors.New(response.ErrorMessage) - } - return &response, nil + return getErrResp(resp.Raw) } -func (h *HUOBI) wsGetOrdersList(ctx context.Context, accountID int64, pair currency.Pair) (*WsAuthenticatedOrdersResponse, error) { - if !h.Websocket.CanUseAuthenticatedEndpoints() { - return nil, fmt.Errorf("%v not authenticated cannot get orders list", h.Name) - } - - creds, err := h.GetCredentials(ctx) - if err != nil { - return nil, err - } - - fPair, err := h.FormatExchangeCurrency(pair, asset.Spot) - if err != nil { - return nil, err - } - - timestamp := time.Now().UTC().Format(wsDateTimeFormatting) - req := WsAuthenticatedOrdersListRequest{ - Op: requestOp, - AccessKeyID: creds.Key, - SignatureMethod: signatureMethod, - SignatureVersion: signatureVersion, - Timestamp: timestamp, - Topic: wsOrdersList, - AccountID: accountID, - Symbol: fPair.String(), - States: "submitted,partial-filled", +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") } +} - hmac, err := h.wsGenerateSignature(creds, timestamp, wsOrdersListEndpoint) - if err != nil { - return nil, err +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 } - req.Signature = crypto.Base64Encode(hmac) - req.ClientID = h.Websocket.AuthConn.GenerateMessageID(true) - resp, err := h.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, req.ClientID, req) - if err != nil { - return nil, err - } + return order.UnknownSide, errors.New(side + " not recognised as order side") +} - var response WsAuthenticatedOrdersResponse - err = json.Unmarshal(resp, &response) - if err != nil { - return nil, err +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 } - code, _ := response.ErrorCode.(int) - if code != 0 { - return nil, errors.New(response.ErrorMessage) - } - return &response, nil + return order.UnknownType, + errors.New(oType + " not recognised as order type") } -func (h *HUOBI) wsGetOrderDetails(ctx context.Context, orderID string) (*WsAuthenticatedOrderDetailResponse, error) { - if !h.Websocket.CanUseAuthenticatedEndpoints() { - return nil, fmt.Errorf("%v not authenticated cannot get order details", h.Name) - } - creds, err := h.GetCredentials(ctx) - if err != nil { - return nil, err - } - timestamp := time.Now().UTC().Format(wsDateTimeFormatting) - req := WsAuthenticatedOrderDetailsRequest{ - Op: requestOp, - AccessKeyID: creds.Key, - SignatureMethod: signatureMethod, - SignatureVersion: signatureVersion, - Timestamp: timestamp, - Topic: wsOrdersDetail, - OrderID: orderID, - } - hmac, err := h.wsGenerateSignature(creds, timestamp, wsOrdersDetailEndpoint) - if err != nil { - return nil, err - } - req.Signature = crypto.Base64Encode(hmac) - req.ClientID = h.Websocket.AuthConn.GenerateMessageID(true) - resp, err := h.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, req.ClientID, req) - if err != nil { - return nil, err - } - var response WsAuthenticatedOrderDetailResponse - err = json.Unmarshal(resp, &response) - if err != nil { - return nil, err +/* +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: %w", errParsingMsg, err) + } + errCode = strconv.Itoa(int(errCodeInt)) + errMsg, _ = jsonparser.GetString(msg, "message") } - - code, _ := response.ErrorCode.(int) - if code != 0 { - return nil, errors.New(response.ErrorMessage) + if errCode != "" { + return fmt.Errorf("%s (%v)", errMsg, errCode) } - return &response, nil + 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 { +func channelName(s *subscription.Subscription, p ...currency.Pair) string { if n, ok := subscriptionNames[s.Channel]; ok { - return fmt.Sprintf(n, p) - } - if s.Authenticated { - panic(fmt.Errorf("%w: Private endpoints not currently supported", common.ErrNotYetImplemented)) + 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 }} - {{- 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 }} + {{- 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 nil }} + {{ channelName $.S }} {{- end }} ` diff --git a/exchanges/huobi/huobi_wrapper.go b/exchanges/huobi/huobi_wrapper.go index 76c461cb196..4de8f9ae110 100644 --- a/exchanges/huobi/huobi_wrapper.go +++ b/exchanges/huobi/huobi_wrapper.go @@ -176,7 +176,7 @@ func (h *HUOBI) SetDefaults() { exchange.RestSpot: huobiAPIURL, exchange.RestFutures: huobiFuturesURL, exchange.RestCoinMargined: huobiFuturesURL, - exchange.WebsocketSpot: wsMarketURL, + exchange.WebsocketSpot: wsSpotURL + wsPublicPath, }) if err != nil { log.Errorln(log.ExchangeSys, err) @@ -220,7 +220,7 @@ func (h *HUOBI) Setup(exch *config.Exchange) error { err = h.Websocket.Setup(&stream.WebsocketSetup{ ExchangeConfig: exch, - DefaultURL: wsMarketURL, + DefaultURL: wsSpotURL + wsPublicPath, RunningURL: wsRunningURL, Connector: h.WsConnect, Subscriber: h.Subscribe, @@ -245,7 +245,7 @@ func (h *HUOBI) Setup(exch *config.Exchange) error { RateLimit: request.NewWeightedRateLimitByDuration(20 * time.Millisecond), ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - URL: wsAccountsOrdersURL, + URL: wsSpotURL + wsPrivatePath, Authenticated: true, }) } @@ -694,72 +694,50 @@ func (h *HUOBI) UpdateAccountInfo(ctx context.Context, assetType asset.Item) (ac info.Exchange = h.Name switch assetType { case asset.Spot: - if h.Websocket.CanUseAuthenticatedWebsocketForWrapper() { - resp, err := h.wsGetAccountsList(ctx) - if err != nil { - return info, err - } - var currencyDetails []account.Balance - for i := range resp.Data { - if len(resp.Data[i].List) == 0 { - continue - } - currData := account.Balance{ - Currency: currency.NewCode(resp.Data[i].List[0].Currency), - Total: resp.Data[i].List[0].Balance, - } - if len(resp.Data[i].List) > 1 && resp.Data[i].List[1].Type == "frozen" { - currData.Hold = resp.Data[i].List[1].Balance - } - currencyDetails = append(currencyDetails, currData) + accounts, err := h.GetAccountID(ctx) + if err != nil { + return info, err + } + for i := range accounts { + if accounts[i].Type != "spot" { + continue } - acc.Currencies = currencyDetails - } else { - accounts, err := h.GetAccountID(ctx) + acc.ID = strconv.FormatInt(accounts[i].ID, 10) + balances, err := h.GetAccountBalance(ctx, acc.ID) if err != nil { return info, err } - for i := range accounts { - if accounts[i].Type != "spot" { - continue - } - acc.ID = strconv.FormatInt(accounts[i].ID, 10) - balances, err := h.GetAccountBalance(ctx, acc.ID) - if err != nil { - return info, err - } - var currencyDetails []account.Balance - balance: - for j := range balances { - frozen := balances[j].Type == "frozen" - for i := range currencyDetails { - if currencyDetails[i].Currency.String() == balances[j].Currency { - if frozen { - currencyDetails[i].Hold = balances[j].Balance - } else { - currencyDetails[i].Total = balances[j].Balance - } - continue balance + var currencyDetails []account.Balance + balance: + for j := range balances { + frozen := balances[j].Type == "frozen" + for i := range currencyDetails { + if currencyDetails[i].Currency.String() == balances[j].Currency { + if frozen { + currencyDetails[i].Hold = balances[j].Balance + } else { + currencyDetails[i].Total = balances[j].Balance } + continue balance } + } - if frozen { - currencyDetails = append(currencyDetails, - account.Balance{ - Currency: currency.NewCode(balances[j].Currency), - Hold: balances[j].Balance, - }) - } else { - currencyDetails = append(currencyDetails, - account.Balance{ - Currency: currency.NewCode(balances[j].Currency), - Total: balances[j].Balance, - }) - } + if frozen { + currencyDetails = append(currencyDetails, + account.Balance{ + Currency: currency.NewCode(balances[j].Currency), + Hold: balances[j].Balance, + }) + } else { + currencyDetails = append(currencyDetails, + account.Balance{ + Currency: currency.NewCode(balances[j].Currency), + Total: balances[j].Balance, + }) } - acc.Currencies = currencyDetails } + acc.Currencies = currencyDetails } case asset.CoinMarginedFutures: @@ -1316,24 +1294,15 @@ func (h *HUOBI) GetOrderInfo(ctx context.Context, orderID string, pair currency. var orderDetail order.Detail switch assetType { case asset.Spot: - var respData *OrderInfo - if h.Websocket.CanUseAuthenticatedWebsocketForWrapper() { - resp, err := h.wsGetOrderDetails(ctx, orderID) - if err != nil { - return nil, err - } - respData = &resp.Data - } else { - oID, err := strconv.ParseInt(orderID, 10, 64) - if err != nil { - return nil, err - } - resp, err := h.GetOrder(ctx, oID) - if err != nil { - return nil, err - } - respData = &resp + oID, err := strconv.ParseInt(orderID, 10, 64) + if err != nil { + return nil, err + } + resp, err := h.GetOrder(ctx, oID) + if err != nil { + return nil, err } + respData := &resp if respData.ID == 0 { return nil, fmt.Errorf("%s - order not found for orderid %s", h.Name, orderID) } @@ -1543,87 +1512,34 @@ func (h *HUOBI) GetActiveOrders(ctx context.Context, req *order.MultiOrderReques if req.Side == order.Sell { side = req.Side.Lower() } - if h.Websocket.CanUseAuthenticatedWebsocketForWrapper() { - for i := range req.Pairs { - resp, err := h.wsGetOrdersList(ctx, -1, req.Pairs[i]) - if err != nil { - return orders, err - } - for j := range resp.Data { - sideData := strings.Split(resp.Data[j].OrderState, "-") - side = sideData[0] - var orderID = strconv.FormatInt(resp.Data[j].OrderID, 10) - orderSide, err := order.StringToOrderSide(side) - if err != nil { - h.Websocket.DataHandler <- order.ClassificationError{ - Exchange: h.Name, - OrderID: orderID, - Err: err, - } - } - orderType, err := order.StringToOrderType(sideData[1]) - if err != nil { - h.Websocket.DataHandler <- order.ClassificationError{ - Exchange: h.Name, - OrderID: orderID, - Err: err, - } - } - orderStatus, err := order.StringToOrderStatus(resp.Data[j].OrderState) - if err != nil { - h.Websocket.DataHandler <- order.ClassificationError{ - Exchange: h.Name, - OrderID: orderID, - Err: err, - } - } - orders = append(orders, order.Detail{ - Exchange: h.Name, - AccountID: strconv.FormatInt(resp.Data[j].AccountID, 10), - OrderID: orderID, - Pair: req.Pairs[i], - Type: orderType, - Side: orderSide, - Date: time.UnixMilli(resp.Data[j].CreatedAt), - Status: orderStatus, - Price: resp.Data[j].Price, - Amount: resp.Data[j].OrderAmount, - ExecutedAmount: resp.Data[j].FilledAmount, - RemainingAmount: resp.Data[j].UnfilledAmount, - Fee: resp.Data[j].FilledFees, - }) - } - } - } else { - creds, err := h.GetCredentials(ctx) + creds, err := h.GetCredentials(ctx) + if err != nil { + return nil, err + } + for i := range req.Pairs { + resp, err := h.GetOpenOrders(ctx, + req.Pairs[i], + creds.ClientID, + side, + 500) if err != nil { return nil, err } - for i := range req.Pairs { - resp, err := h.GetOpenOrders(ctx, - req.Pairs[i], - creds.ClientID, - side, - 500) - if err != nil { - return nil, err - } - for x := range resp { - orderDetail := order.Detail{ - OrderID: strconv.FormatInt(resp[x].ID, 10), - Price: resp[x].Price, - Amount: resp[x].Amount, - ExecutedAmount: resp[x].FilledAmount, - RemainingAmount: resp[x].Amount - resp[x].FilledAmount, - Pair: req.Pairs[i], - Exchange: h.Name, - Date: time.UnixMilli(resp[x].CreatedAt), - AccountID: strconv.FormatInt(resp[x].AccountID, 10), - Fee: resp[x].FilledFees, - } - setOrderSideStatusAndType(resp[x].State, resp[x].Type, &orderDetail) - orders = append(orders, orderDetail) + for x := range resp { + orderDetail := order.Detail{ + OrderID: strconv.FormatInt(resp[x].ID, 10), + Price: resp[x].Price, + Amount: resp[x].Amount, + ExecutedAmount: resp[x].FilledAmount, + RemainingAmount: resp[x].Amount - resp[x].FilledAmount, + Pair: req.Pairs[i], + Exchange: h.Name, + Date: time.UnixMilli(resp[x].CreatedAt), + AccountID: strconv.FormatInt(resp[x].AccountID, 10), + Fee: resp[x].FilledFees, } + setOrderSideStatusAndType(resp[x].State, resp[x].Type, &orderDetail) + orders = append(orders, orderDetail) } } case asset.CoinMarginedFutures: diff --git a/exchanges/huobi/testdata/wsAllTrades.json b/exchanges/huobi/testdata/wsAllTrades.json new file mode 100644 index 00000000000..916c41ce0a9 --- /dev/null +++ b/exchanges/huobi/testdata/wsAllTrades.json @@ -0,0 +1 @@ +{"ch":"market.btcusdt.trade.detail","ts":1630994963175,"tick":{"id":137005445109,"ts":1630994963173,"data":[{"id":137005445109359290000000000,"ts":1630994963173,"tradeId":102523573486,"amount":0.006754,"price":52648.62,"direction":"buy"}]}} diff --git a/exchanges/huobi/testdata/wsCandles.json b/exchanges/huobi/testdata/wsCandles.json new file mode 100644 index 00000000000..a51783aa675 --- /dev/null +++ b/exchanges/huobi/testdata/wsCandles.json @@ -0,0 +1 @@ +{"ch":"market.btcusdt.kline.1min","ts":1489474082831,"tick":{"id":1489464480,"amount":1821.49,"count":4,"open":7962.62,"close":8014.56,"low":5110.14,"high":14962.77,"vol":4.4}} diff --git a/exchanges/huobi/testdata/wsMyAccount.json b/exchanges/huobi/testdata/wsMyAccount.json new file mode 100644 index 00000000000..bc85fa36fb4 --- /dev/null +++ b/exchanges/huobi/testdata/wsMyAccount.json @@ -0,0 +1,3 @@ +{"action":"push","ch":"accounts.update#2","data":{"currency":"btc","accountId":123456,"balance":"23.111","changeType":"transfer","accountType":"trade","seqNum":1,"changeTime":1568601800000}} +{"action":"push","ch":"accounts.update#2","data":{"currency":"btc","accountId":33385,"available":"2028.69","changeType":"order.match","accountType":"trade","seqNum":2,"changeTime":1574393385167}} +{"action":"push","ch":"accounts.update#2","data":{"currency":"usdt","accountId":14884859,"balance":"20.29388158","available":"20.29388158","changeType":null,"accountType":"trade","changeTime":null,"seqNum":3}} diff --git a/exchanges/huobi/testdata/wsMyOrders.json b/exchanges/huobi/testdata/wsMyOrders.json new file mode 100644 index 00000000000..689d7bf44f6 --- /dev/null +++ b/exchanges/huobi/testdata/wsMyOrders.json @@ -0,0 +1,4 @@ +{"action":"push","ch":"orders#*","data":{"orderSide":"buy","lastActTime":1583853365586,"clientOrderId":"test1","orderStatus":"rejected","symbol":"btcusdt","eventType":"trigger","errCode":2002,"errMessage":"invalid.client.order.id (NT)"}} +{"action":"push","ch":"orders#*","data":{"orderSide":"buy","lastActTime":1583853365586,"clientOrderId":"test2","orderStatus":"canceled","symbol":"btcusdt","eventType":"deletion"}} +{"action":"push","ch":"orders#*","data":{"orderSize":"2.000000000000000000","orderCreateTime":1583853365586,"accountld":992701,"orderPrice":"77.000000000000000000","type":"sell-limit","orderId":27163533,"clientOrderId":"test3","orderSource":"spot-api","orderStatus":"submitted","symbol":"btcusdt","eventType":"creation"}} +{"action":"push","ch":"orders#*","data":{"orderSource":"spot-web","accountId":16820007,"orderPrice":"70000","orderSize":"0.000157","orderCreateTime":1731039387696,"symbol":"btcusdt","eventType":"creation","type":"buy-limit","orderId":1199329381585359,"clientOrderId":"","orderStatus":"submitted"}} diff --git a/exchanges/huobi/testdata/wsMyTrades.json b/exchanges/huobi/testdata/wsMyTrades.json new file mode 100644 index 00000000000..4bb712679ba --- /dev/null +++ b/exchanges/huobi/testdata/wsMyTrades.json @@ -0,0 +1 @@ +{"ch":"trade.clearing#btcusdt#1","data":{"eventType":"trade","symbol":"btcusdt","orderId":99998888,"tradePrice":"9999.99","tradeVolume":"0.96","orderSide":"buy","aggressor":true,"tradeId":919219323232,"tradeTime":1583853365996,"transactFee":"19.88","feeDeduct ":"0","feeDeductType":"","feeCurrency":"btc","accountId":9912791,"source":"spot-api","orderPrice":"10000","orderSize":"1","clientOrderId":"a001","orderCreateTime":1583853365586,"orderStatus":"partial-filled"}} diff --git a/exchanges/huobi/testdata/wsOrderbook.json b/exchanges/huobi/testdata/wsOrderbook.json new file mode 100644 index 00000000000..1385e890b0e --- /dev/null +++ b/exchanges/huobi/testdata/wsOrderbook.json @@ -0,0 +1 @@ +{"ch":"market.btcusdt.depth.step0","ts":1630983549503,"tick":{"bids":[[52690.69,0.36281],[52690.68,0.2]],"asks":[[52690.7,0.372591],[52691.26,0.13]],"version":136998124622,"ts":1630983549500}} diff --git a/exchanges/huobi/testdata/wsTicker.json b/exchanges/huobi/testdata/wsTicker.json new file mode 100644 index 00000000000..01f566010b6 --- /dev/null +++ b/exchanges/huobi/testdata/wsTicker.json @@ -0,0 +1 @@ +{"ch":"market.btcusdt.detail","ts":1630998026649,"tick":{"id":273956868110,"low":51000,"high":52924.14,"open":51823.62,"close":52379.99,"vol":727676440.200527,"amount":13991.028076056185,"version":273956868110,"count":471348}} diff --git a/exchanges/order/order_types.go b/exchanges/order/order_types.go index 75193c1ccab..5f5d28fe7af 100644 --- a/exchanges/order/order_types.go +++ b/exchanges/order/order_types.go @@ -184,8 +184,7 @@ type ModifyResponse struct { } // Detail contains all properties of an order -// Each exchange has their own requirements, so not all fields -// are required to be populated +// Each exchange has their own requirements, so not all fields are required to be populated type Detail struct { ImmediateOrCancel bool HiddenOrder bool diff --git a/exchanges/order/orders.go b/exchanges/order/orders.go index ad09ba5dfc8..08c82a6001c 100644 --- a/exchanges/order/orders.go +++ b/exchanges/order/orders.go @@ -1167,15 +1167,15 @@ func StringToOrderStatus(status string) (Status, error) { switch status { case AnyStatus.String(): return AnyStatus, nil - case New.String(), "PLACED", "ACCEPTED": + case New.String(), "PLACED", "ACCEPTED", "SUBMITTED": return New, nil case Active.String(), "STATUS_ACTIVE", "LIVE": return Active, nil - case PartiallyFilled.String(), "PARTIALLY MATCHED", "PARTIALLY FILLED": + case PartiallyFilled.String(), "PARTIAL-FILLED", "PARTIALLY MATCHED", "PARTIALLY FILLED": return PartiallyFilled, nil case Filled.String(), "FULLY MATCHED", "FULLY FILLED", "ORDER_FULLY_TRANSACTED", "EFFECTIVE": return Filled, nil - case PartiallyCancelled.String(), "PARTIALLY CANCELLED", "ORDER_PARTIALLY_TRANSACTED": + case PartiallyCancelled.String(), "PARTIAL-CANCELED", "PARTIALLY CANCELLED", "ORDER_PARTIALLY_TRANSACTED": return PartiallyCancelled, nil case PartiallyFilledCancelled.String(), "PARTIALLYFILLEDCANCELED": return PartiallyFilledCancelled, nil diff --git a/testdata/configtest.json b/testdata/configtest.json index 51de85a84c9..caa19e825ef 100644 --- a/testdata/configtest.json +++ b/testdata/configtest.json @@ -1844,7 +1844,7 @@ }, "spot": { "assetEnabled": true, - "enabled": "BTC-USDT", + "enabled": "BTC-USDT,ETH-BTC", "available": "PROPY-ETH,IOTA-BTC,UGAS-ETH,PAI-USDT,BSV-HUSD,MTX-ETH,BCH-BTC,LTC-HT,SOC-USDT,WXT-BTC,SALT-BTC,RCN-ETH,PNT-ETH,TT-USDT,AIDOC-ETH,BIX-BTC,OCN-USDT,QTUM-ETH,KCASH-ETH,SNT-USDT,LUN-BTC,QASH-BTC,ITC-BTC,NAS-BTC,XMR-BTC,TNT-ETH,UC-ETH,FAIR-BTC,PC-ETH,YEE-BTC,PAY-ETH,XMX-BTC,CRE-USDT,BAT-ETH,BHT-USDT,CKB-HT,LAMB-HT,AE-USDT,QUN-ETH,LYM-BTC,BCH-HT,BHT-BTC,RUFF-ETH,CNN-BTC,FOR-USDT,GTC-ETH,TRX-ETH,ELA-USDT,ACT-ETH,SMT-ETH,BUT-ETH,BCH-USDT,ICX-BTC,MEET-BTC,NCC-BTC,APPC-BTC,GVE-ETH,TNB-BTC,STEEM-ETH,18C-ETH,LBA-BTC,EKO-BTC,REQ-BTC,SOC-BTC,BOX-ETH,ELF-BTC,ZRX-ETH,LET-USDT,HT-BTC,TUSD-HUSD,EGCC-BTC,WTC-BTC,ATP-USDT,DOCK-USDT,PAI-BTC,ONT-ETH,IRIS-BTC,BTT-ETH,SC-BTC,XZC-BTC,LBA-USDT,HT-USDT,VET-ETH,KMD-ETH,SHE-ETH,PORTAL-BTC,ONE-BTC,BIX-USDT,RCCC-BTC,SKM-USDT,XTZ-ETH,SWFTC-BTC,RSR-BTC,LINK-ETH,DATX-BTC,HPT-HT,GET-ETH,BLZ-ETH,CTXC-USDT,CNNS-USDT,PVT-HT,ITC-USDT,LTC-BTC,NCASH-BTC,HOT-ETH,ADA-USDT,ADX-BTC,NODE-USDT,TRIO-BTC,GXC-ETH,SNT-BTC,FOR-BTC,DBC-BTC,UUU-USDT,CVCOIN-ETH,RSR-USDT,CRO-USDT,OCN-BTC,NEW-USDT,EGT-USDT,MANA-BTC,CMT-USDT,WXT-HT,XRP-BTC,MT-ETH,PAX-HUSD,LSK-ETH,IOTA-USDT,SRN-ETH,ZIL-ETH,ELF-USDT,LXT-ETH,LAMB-BTC,CRE-HT,CKB-BTC,XVG-BTC,BSV-BTC,BFT-BTC,WPR-ETH,HT-HUSD,POWR-BTC,MANA-ETH,ENG-ETH,ZJLT-ETH,SNC-ETH,ATOM-ETH,WICC-USDT,KAN-ETH,DGD-BTC,VSYS-HT,BCD-BTC,BTM-ETH,DOGE-USDT,MEX-BTC,BTG-BTC,DAC-ETH,DAT-BTC,GRS-ETH,ADX-ETH,EM-HT,GXC-USDT,CVC-BTC,OMG-ETH,SSP-ETH,OGO-HT,CMT-ETH,POLY-ETH,XZC-USDT,THETA-USDT,XEM-USDT,LOL-USDT,BCH-HUSD,GSC-BTC,DOGE-ETH,MDS-BTC,BTS-ETH,CTXC-BTC,MCO-BTC,BCX-BTC,ZLA-ETH,EKT-USDT,MAN-BTC,BLZ-BTC,ATOM-USDT,LOL-BTC,HPT-USDT,EM-BTC,EOS-USDT,WAN-BTC,GNT-BTC,CRO-BTC,MANA-USDT,SEELE-USDT,FSN-BTC,VIDY-HT,USDC-HUSD,LTC-HUSD,XRP-USDT,VSYS-BTC,STORJ-BTC,LOOM-ETH,SKM-BTC,LINK-USDT,TT-HT,QSP-ETH,ETN-BTC,FSN-HT,NODE-BTC,HC-USDT,PHX-BTC,XLM-BTC,RCCC-ETH,LTC-USDT,UUU-BTC,SEELE-ETH,PVT-BTC,HC-ETH,REN-ETH,KAN-USDT,EOS-ETH,BSV-USDT,BTS-USDT,KMD-BTC,OGO-USDT,THETA-ETH,MUSK-BTC,CNNS-HT,ETC-BTC,COVA-BTC,BTT-TRX,XMR-USDT,MTN-ETH,QUN-BTC,NAS-USDT,ELA-ETH,HIT-ETH,BTT-USDT,EKT-ETH,TOS-BTC,GAS-ETH,DCR-USDT,ONT-BTC,NEW-HT,NEXO-BTC,ETH-USDT,WXT-USDT,FOR-HT,ADA-BTC,EVX-ETH,VET-BTC,ZEC-USDT,NANO-ETH,IOST-HT,BCV-ETH,REN-USDT,NULS-ETH,ACT-USDT,LET-ETH,BTM-USDT,MEET-ETH,AKRO-HT,ARDR-BTC,DCR-ETH,NANO-USDT,BTC-HUSD,ALGO-BTC,IIC-ETH,BHD-BTC,KNC-ETH,ATP-BTC,ZRX-BTC,ABT-BTC,18C-BTC,XMR-ETH,WAXP-BTC,CVNT-BTC,MX-USDT,OST-ETH,NKN-BTC,TOPC-BTC,GNX-BTC,FTT-USDT,ONE-HT,DGB-ETH,NULS-USDT,DASH-BTC,UIP-BTC,KCASH-HT,WICC-ETH,EKO-ETH,EGT-HT,IRIS-USDT,STK-ETH,MXC-BTC,NAS-ETH,OMG-USDT,SMT-BTC,BUT-BTC,HIT-USDT,BAT-BTC,IRIS-ETH,NKN-HT,PC-BTC,TOP-USDT,GTC-BTC,LSK-BTC,ITC-ETH,DTA-BTC,HOT-BTC,BTT-BTC,FAIR-ETH,DOCK-ETH,QTUM-BTC,ZEN-BTC,ZIL-BTC,RCN-BTC,FTI-BTC,BHD-USDT,VIDY-USDT,LUN-ETH,DBC-ETH,TOPC-ETH,IIC-BTC,STEEM-USDT,IOTA-ETH,KCASH-BTC,RUFF-BTC,APPC-ETH,MT-BTC,SOC-ETH,GT-HT,PROPY-BTC,AIDOC-BTC,ACT-BTC,LYM-ETH,CHAT-BTC,SWFTC-ETH,ETH-BTC,UIP-USDT,UGAS-BTC,XRP-HUSD,ALGO-USDT,TNT-BTC,ONT-USDT,YEE-ETH,AKRO-BTC,TRX-USDT,OCN-ETH,SRN-BTC,DASH-USDT,XMX-ETH,NANO-BTC,QASH-ETH,EOS-HT,GT-BTC,XTZ-USDT,ARPA-USDT,SALT-ETH,BKBT-ETH,MTX-BTC,SMT-USDT,GXC-BTC,VIDY-BTC,FTT-HT,LAMB-ETH,TRX-BTC,TRIO-ETH,BFT-ETH,LINK-BTC,AE-ETH,NULS-BTC,BHD-HT,AST-ETH,NEO-USDT,EDU-BTC,CVCOIN-BTC,GVE-BTC,GET-BTC,ZRX-USDT,ELF-ETH,DATX-ETH,ADA-ETH,TOP-HT,NCASH-ETH,QTUM-USDT,ETC-HT,ZIL-USDT,TNB-ETH,BIX-ETH,SHE-BTC,PNT-BTC,BTC-USDT,PORTAL-ETH,WAVES-USDT,XZC-ETH,HT-ETH,POLY-BTC,MCO-ETH,MUSK-ETH,PAI-ETH,LXT-USDT,UTK-BTC,RTE-BTC,NCC-ETH,HB10-USDT,BOX-BTC,RDN-ETH,ARPA-BTC,LBA-ETH,CNN-ETH,AAC-ETH,XTZ-BTC,IDT-BTC,AKRO-USDT,IOST-BTC,GT-USDT,WAN-ETH,ETN-ETH,PVT-USDT,NEO-BTC,WAVES-ETH,ONE-USDT,ZEC-BTC,SKM-HT,IOST-ETH,NPXS-ETH,CVC-ETH,CMT-BTC,COVA-ETH,ARDR-ETH,RDN-BTC,DCR-BTC,REN-BTC,YCC-ETH,MX-HT,NEXO-ETH,XLM-ETH,YCC-BTC,ENG-BTC,CNNS-BTC,ZLA-BTC,QSP-BTC,MAN-ETH,UUU-ETH,ETH-HUSD,RTE-ETH,ATP-HT,BTM-BTC,DAC-BTC,TOS-ETH,LAMB-USDT,DASH-HT,NPXS-BTC,NEW-BTC,FTT-BTC,EOS-HUSD,GRS-BTC,POWR-ETH,VET-USDT,AAC-BTC,MX-BTC,MTN-BTC,XVG-ETH,GNX-ETH,SSP-BTC,WAVES-BTC,EGT-BTC,CTXC-ETH,IDT-ETH,STK-BTC,WICC-BTC,UTK-ETH,CRO-HT,LXT-BTC,GSC-ETH,OMG-BTC,XRP-HT,DGB-BTC,IOST-USDT,CVNT-ETH,GAS-BTC,HIT-BTC,CKB-USDT,ARPA-HT,RUFF-USDT,HC-BTC,WTC-ETH,MDS-USDT,ABT-ETH,ALGO-ETH,BIFI-BTC,KNC-BTC,TT-BTC,LET-BTC,NKN-USDT,PAY-BTC,DTA-USDT,AE-BTC,UC-BTC,VSYS-USDT,USDT-HUSD,EOS-BTC,STEEM-BTC,DOGE-BTC,NODE-HT,MDS-ETH,CRE-BTC,GNT-USDT,UIP-ETH,AST-BTC,XEM-BTC,ZEN-ETH,EDU-ETH,MEX-ETH,EKT-BTC,CVC-USDT,WAXP-ETH,REQ-ETH,OST-BTC,STORJ-USDT,SBTC-BTC,DGD-ETH,SC-ETH,WTC-USDT,THETA-BTC,DTA-ETH,BCV-BTC,SNC-BTC,RSR-HT,KAN-BTC,ELA-BTC,ATOM-BTC,BKBT-BTC,FSN-USDT,EM-USDT,WPR-BTC,TOP-BTC,BTS-BTC,EGCC-ETH,MTL-BTC,GNT-ETH,SEELE-BTC,EVX-BTC,FTI-ETH,BAT-USDT,MT-HT,LOL-HT,ICX-ETH,LOOM-BTC,ZJLT-BTC,XLM-USDT,OGO-BTC,DOCK-BTC,CHAT-ETH,DAT-ETH,ETC-USDT,HPT-BTC,BHT-HT" } }