From 33e82c170f5f36ae8941d33f5a357860bb8c9d2b Mon Sep 17 00:00:00 2001 From: Gareth Kirwan Date: Tue, 8 Oct 2024 00:34:10 +0100 Subject: [PATCH] Kraken: Subscription improvements (#1587) * Convert: Fix TimeFromUnixTimestampDecimal using local All parsed times should be in UTC * Subscriptions: Add IgnoringAssetsKey * Tests: Pass tb to curried WS handlers * Websocket: Make ErrNoMessageListener a public error * Kraken: Fix URLMap ignored for websocket URLs * Kraken: Move SeedAssets from Setup to Bootstrap Having SeedAssets in Setup is cruel and unusual because it calls the API. Most other interactive data seeding happens in Bootstrap. This made it so that fixing and creating unit tests for Kraken was painfully slow, particularly on flaky internet. * Kraken: Remove convert test Duplicate of convert_test.go TestTimeFromUnixTimestampDecimal * Kraken: Test config upgrades * Kraken: Sub Channel improvements * Use Websocket subscriptionChannels instead of local slice * Remove ChannelID - Deprecated in docs * Simplify ping handlers and hardcodes message * Add Depth as configurable orderbook channel param * Simplify auth/non-auth channel updates * Add configurable Book depth * Add configurable Candle timeframes Kraken: Simplify all WS handlers with reqId * Kraken: Subscription templating * Generate N+ subs for pairs If we generate one sub for all pairs, but then fan it out in the responses, we end up with a mis-match between the sub store and GenerateSubs, and when we do FlushChannels it will try to resub everything again. * Kraken: Rename channelName var throughout Avoid shadowing func of same name * Kraken: Add TestEnforceStandardChannelNames * Websocket: Fix Resubscribe erroring Duplicate --- common/convert/convert.go | 5 +- common/convert/convert_test.go | 24 +- currency/pair.go | 3 +- exchanges/binance/binance_test.go | 12 +- exchanges/bitfinex/bitfinex_websocket.go | 8 +- exchanges/kraken/kraken.go | 2 - exchanges/kraken/kraken_test.go | 1570 ++++++------------- exchanges/kraken/kraken_types.go | 70 +- exchanges/kraken/kraken_websocket.go | 1201 +++++++------- exchanges/kraken/kraken_wrapper.go | 49 +- exchanges/kraken/mock_ws_test.go | 76 + exchanges/kraken/testdata/wsHandleData.json | 10 + exchanges/kraken/testdata/wsOpenTrades.json | 2 +- exchanges/kucoin/kucoin_websocket.go | 2 +- exchanges/stream/websocket.go | 3 + exchanges/subscription/keys.go | 35 + exchanges/subscription/keys_test.go | 33 + exchanges/subscription/subscription.go | 12 +- internal/testing/exchange/exchange.go | 4 +- internal/testing/exchange/exchange_test.go | 2 +- testdata/configtest.json | 12 +- 21 files changed, 1359 insertions(+), 1776 deletions(-) create mode 100644 exchanges/kraken/mock_ws_test.go create mode 100644 exchanges/kraken/testdata/wsHandleData.json diff --git a/common/convert/convert.go b/common/convert/convert.go index c9dc390b..c0537770 100644 --- a/common/convert/convert.go +++ b/common/convert/convert.go @@ -58,11 +58,10 @@ func TimeFromUnixTimestampFloat(raw interface{}) (time.Time, error) { return time.UnixMilli(int64(ts)), nil } -// TimeFromUnixTimestampDecimal converts a unix timestamp in decimal form to -// a time.Time +// TimeFromUnixTimestampDecimal converts a unix timestamp in decimal form to a time.Time in UTC func TimeFromUnixTimestampDecimal(input float64) time.Time { i, f := math.Modf(input) - return time.Unix(int64(i), int64(f*(1e9))) + return time.Unix(int64(i), int64(f*(1e9))).UTC() } // UnixTimestampToTime returns time.time diff --git a/common/convert/convert_test.go b/common/convert/convert_test.go index 1fdd6f1c..2b7004cc 100644 --- a/common/convert/convert_test.go +++ b/common/convert/convert_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" ) func TestFloatFromString(t *testing.T) { @@ -97,18 +98,17 @@ func TestTimeFromUnixTimestampFloat(t *testing.T) { } func TestTimeFromUnixTimestampDecimal(t *testing.T) { - r := TimeFromUnixTimestampDecimal(1590633982.5714) - if r.Year() != 2020 || - r.Month().String() != "May" || - r.Day() != 28 { - t.Error("unexpected result") - } - - r = TimeFromUnixTimestampDecimal(1560516023.070651) - if r.Year() != 2019 || - r.Month().String() != "June" || - r.Day() != 14 { - t.Error("unexpected result") + for in, exp := range map[float64]time.Time{ + 1590633982.5714: time.Date(2020, 5, 28, 2, 46, 22, 571400000, time.UTC), + 1560516023.070651: time.Date(2019, 6, 14, 12, 40, 23, 70651000, time.UTC), + // Examples from Kraken + 1373750306.9819: time.Date(2013, 7, 13, 21, 18, 26, 981900000, time.UTC), + 1534614098.345543: time.Date(2018, 8, 18, 17, 41, 38, 345543000, time.UTC), + } { + got := TimeFromUnixTimestampDecimal(in) + z, _ := got.Zone() + assert.Equal(t, "UTC", z, "TimeFromUnixTimestampDecimal should return a UTC time") + assert.WithinRangef(t, got, exp.Add(-time.Microsecond), exp.Add(time.Microsecond), "TimeFromUnixTimestampDecimal(%f) should parse a unix timestamp correctly", in) } } diff --git a/currency/pair.go b/currency/pair.go index 1f0a4f8c..6645a6bc 100644 --- a/currency/pair.go +++ b/currency/pair.go @@ -19,8 +19,7 @@ func NewBTCUSD() Pair { return NewPair(BTC, USD) } -// NewPairDelimiter splits the desired currency string at delimiter, the returns -// a Pair struct +// NewPairDelimiter splits the desired currency string at delimiter, then returns a Pair struct func NewPairDelimiter(currencyPair, delimiter string) (Pair, error) { if !strings.Contains(currencyPair, delimiter) { return EMPTYPAIR, diff --git a/exchanges/binance/binance_test.go b/exchanges/binance/binance_test.go index 83110dc2..f7dbbc17 100644 --- a/exchanges/binance/binance_test.go +++ b/exchanges/binance/binance_test.go @@ -1982,10 +1982,11 @@ func TestSubscribe(t *testing.T) { require.NoError(t, err, "generateSubscriptions must not error") if mockTests { exp := []string{"btcusdt@depth@100ms", "btcusdt@kline_1m", "btcusdt@ticker", "btcusdt@trade", "dogeusdt@depth@100ms", "dogeusdt@kline_1m", "dogeusdt@ticker", "dogeusdt@trade"} - mock := func(msg []byte, w *websocket.Conn) error { + mock := func(tb testing.TB, msg []byte, w *websocket.Conn) error { + tb.Helper() var req WsPayload - require.NoError(t, json.Unmarshal(msg, &req), "Unmarshal should not error") - require.ElementsMatch(t, req.Params, exp, "Params should have correct channels") + require.NoError(tb, json.Unmarshal(msg, &req), "Unmarshal should not error") + require.ElementsMatch(tb, req.Params, exp, "Params should have correct channels") return w.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"result":null,"id":%d}`, req.ID))) } b = testexch.MockWsInstance[Binance](t, testexch.CurryWsMockUpgrader(t, mock)) @@ -2003,10 +2004,11 @@ func TestSubscribeBadResp(t *testing.T) { channels := subscription.List{ {Channel: "moons@ticker"}, } - mock := func(msg []byte, w *websocket.Conn) error { + mock := func(tb testing.TB, msg []byte, w *websocket.Conn) error { + tb.Helper() var req WsPayload err := json.Unmarshal(msg, &req) - require.NoError(t, err, "Unmarshal should not error") + require.NoError(tb, err, "Unmarshal should not error") return w.WriteMessage(websocket.TextMessage, []byte(fmt.Sprintf(`{"result":{"error":"carrots"},"id":%d}`, req.ID))) } b := testexch.MockWsInstance[Binance](t, testexch.CurryWsMockUpgrader(t, mock)) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes diff --git a/exchanges/bitfinex/bitfinex_websocket.go b/exchanges/bitfinex/bitfinex_websocket.go index 7cfa71b6..f60bd896 100644 --- a/exchanges/bitfinex/bitfinex_websocket.go +++ b/exchanges/bitfinex/bitfinex_websocket.go @@ -438,16 +438,16 @@ func (b *Bitfinex) handleWSEvent(respRaw []byte) error { return fmt.Errorf("%w 'chanId': %w from message: %s", errParsingWSField, err, respRaw) } if !b.Websocket.Match.IncomingWithData("unsubscribe:"+chanID, respRaw) { - return fmt.Errorf("%v channel unsubscribe listener not found", chanID) + return fmt.Errorf("%w: unsubscribe:%v", stream.ErrNoMessageListener, chanID) } case wsEventError: if subID, err := jsonparser.GetUnsafeString(respRaw, "subId"); err == nil { if !b.Websocket.Match.IncomingWithData("subscribe:"+subID, respRaw) { - return fmt.Errorf("%v channel subscribe listener not found", subID) + return fmt.Errorf("%w: subscribe:%v", stream.ErrNoMessageListener, subID) } } else if chanID, err := jsonparser.GetUnsafeString(respRaw, "chanId"); err == nil { if !b.Websocket.Match.IncomingWithData("unsubscribe:"+chanID, respRaw) { - return fmt.Errorf("%v channel unsubscribe listener not found", chanID) + return fmt.Errorf("%w: unsubscribe:%v", stream.ErrNoMessageListener, chanID) } } else { return fmt.Errorf("unknown channel error; Message: %s", respRaw) @@ -520,7 +520,7 @@ func (b *Bitfinex) handleWSSubscribed(respRaw []byte) error { log.Debugf(log.ExchangeSys, "%s Subscribed to Channel: %s Pair: %s ChannelID: %d\n", b.Name, c.Channel, c.Pairs, chanID) } if !b.Websocket.Match.IncomingWithData("subscribe:"+subID, respRaw) { - return fmt.Errorf("%v channel subscribe listener not found", subID) + return fmt.Errorf("%w: subscribe:%v", stream.ErrNoMessageListener, subID) } return nil } diff --git a/exchanges/kraken/kraken.go b/exchanges/kraken/kraken.go index 28b1f405..f85f8431 100644 --- a/exchanges/kraken/kraken.go +++ b/exchanges/kraken/kraken.go @@ -9,7 +9,6 @@ import ( "net/url" "strconv" "strings" - "sync" "time" "github.com/thrasher-corp/gocryptotrader/common" @@ -38,7 +37,6 @@ const ( // Kraken is the overarching type across the kraken package type Kraken struct { exchange.Base - wsRequestMtx sync.Mutex } // GetCurrentServerTime returns current server time diff --git a/exchanges/kraken/kraken_test.go b/exchanges/kraken/kraken_test.go index 99deb65b..b41af30e 100644 --- a/exchanges/kraken/kraken_test.go +++ b/exchanges/kraken/kraken_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "log" "net/http" "os" @@ -12,11 +11,9 @@ import ( "testing" "time" - "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/thrasher-corp/gocryptotrader/common" - "github.com/thrasher-corp/gocryptotrader/common/convert" "github.com/thrasher-corp/gocryptotrader/common/key" "github.com/thrasher-corp/gocryptotrader/core" "github.com/thrasher-corp/gocryptotrader/currency" @@ -28,18 +25,17 @@ import ( "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" "github.com/thrasher-corp/gocryptotrader/portfolio/withdraw" ) var ( k *Kraken - wsSetupRan bool - futuresTestPair = currency.NewPairWithDelimiter("PF", "XBTUSD", "_") spotTestPair = currency.NewPair(currency.XBT, currency.USD) + futuresTestPair = currency.NewPairWithDelimiter("PF", "XBTUSD", "_") ) // Please add your own APIkeys to do correct due diligence testing. @@ -69,21 +65,14 @@ func TestUpdateTradablePairs(t *testing.T) { func TestGetCurrentServerTime(t *testing.T) { t.Parallel() _, err := k.GetCurrentServerTime(context.Background()) - if err != nil { - t.Error("GetCurrentServerTime() error", err) - } + assert.NoError(t, err, "GetCurrentServerTime should not error") } func TestWrapperGetServerTime(t *testing.T) { t.Parallel() st, err := k.GetServerTime(context.Background(), asset.Spot) - if !errors.Is(err, nil) { - t.Fatalf("received: '%v' but expected: '%v'", err, nil) - } - - if st.IsZero() { - t.Error("expected a time") - } + require.NoError(t, err, "GetServerTime should not error") + assert.WithinRange(t, st, time.Now().Add(-24*time.Hour), time.Now().Add(24*time.Hour), "ServerTime should be within a day of now") } // TestUpdateOrderExecutionLimits exercises UpdateOrderExecutionLimits and GetOrderExecutionLimits @@ -106,43 +95,51 @@ func TestUpdateOrderExecutionLimits(t *testing.T) { func TestFetchTradablePairs(t *testing.T) { t.Parallel() _, err := k.FetchTradablePairs(context.Background(), asset.Futures) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FetchTradablePairs should not error") } func TestUpdateTicker(t *testing.T) { t.Parallel() + testexch.UpdatePairsOnce(t, k) _, err := k.UpdateTicker(context.Background(), spotTestPair, asset.Spot) assert.NoError(t, err, "UpdateTicker spot asset should not error") + _, err = k.UpdateTicker(context.Background(), futuresTestPair, asset.Futures) assert.NoError(t, err, "UpdateTicker futures asset should not error") } func TestUpdateTickers(t *testing.T) { t.Parallel() + k := new(Kraken) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes require.NoError(t, testexch.Setup(k), "Test instance Setup must not error") + testexch.UpdatePairsOnce(t, k) + err := k.UpdateTickers(context.Background(), asset.Spot) require.NoError(t, err, "UpdateTickers must not error") + ap, err := k.GetAvailablePairs(asset.Spot) require.NoError(t, err, "GetAvailablePairs must not error") + for i := range ap { _, err = ticker.GetTicker(k.Name, ap[i], asset.Spot) - require.NoError(t, err, "GetTicker must not error") + assert.NoErrorf(t, err, "GetTicker should not error for %s", ap[i]) } + ap, err = k.GetAvailablePairs(asset.Futures) + require.NoError(t, err, "GetAvailablePairs must not error") err = k.UpdateTickers(context.Background(), asset.Futures) require.NoError(t, err, "UpdateTickers must not error") + for i := range ap { _, err = ticker.GetTicker(k.Name, ap[i], asset.Futures) - require.NoError(t, err, "GetTicker must not error") + assert.NoErrorf(t, err, "GetTicker should not error for %s", ap[i]) } err = k.UpdateTickers(context.Background(), asset.Index) - assert.ErrorIs(t, err, asset.ErrNotSupported, "UpdateTickers should error correctly on Index asset") + assert.ErrorIs(t, err, asset.ErrNotSupported, "UpdateTickers should error correctly for asset.Index") } func TestUpdateOrderbook(t *testing.T) { @@ -153,27 +150,6 @@ func TestUpdateOrderbook(t *testing.T) { assert.NoError(t, err, "UpdateOrderbook futures asset should not error") } -func TestUpdateAccountInfo(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCredentialsUnset(t, k) - - _, err := k.UpdateAccountInfo(context.Background(), asset.Spot) - if err != nil { - t.Error(err) - } -} - -func TestWrapperGetOrderInfo(t *testing.T) { - t.Parallel() - sharedtestvalues.SkipTestIfCredentialsUnset(t, k) - - _, err := k.GetOrderInfo(context.Background(), - "123", currency.EMPTYPAIR, asset.Futures) - if err != nil { - t.Error(err) - } -} - func TestFuturesBatchOrder(t *testing.T) { t.Parallel() var data []PlaceBatchOrderData @@ -183,17 +159,13 @@ func TestFuturesBatchOrder(t *testing.T) { tempData.Symbol = futuresTestPair.Lower().String() data = append(data, tempData) _, err := k.FuturesBatchOrder(context.Background(), data) - if !errors.Is(err, errInvalidBatchOrderType) { - t.Fatalf("received: '%v' but expected: '%v'", err, errInvalidBatchOrderType) - } + assert.ErrorIs(t, err, errInvalidBatchOrderType, "FuturesBatchOrder should error correctly") sharedtestvalues.SkipTestIfCredentialsUnset(t, k, canManipulateRealOrders) data[0].PlaceOrderType = "cancel" _, err = k.FuturesBatchOrder(context.Background(), data) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FuturesBatchOrder should not error") } func TestFuturesEditOrder(t *testing.T) { @@ -201,9 +173,7 @@ func TestFuturesEditOrder(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, k, canManipulateRealOrders) _, err := k.FuturesEditOrder(context.Background(), "test123", "", 5.2, 1, 0) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FuturesEditOrder should not error") } func TestFuturesSendOrder(t *testing.T) { @@ -219,9 +189,7 @@ func TestFuturesCancelOrder(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, k, canManipulateRealOrders) _, err := k.FuturesCancelOrder(context.Background(), "test123", "") - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FuturesCancelOrder should not error") } func TestFuturesGetFills(t *testing.T) { @@ -229,9 +197,7 @@ func TestFuturesGetFills(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, k) _, err := k.FuturesGetFills(context.Background(), time.Now().Add(-time.Hour*24)) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FuturesGetFills should not error") } func TestFuturesTransfer(t *testing.T) { @@ -239,9 +205,7 @@ func TestFuturesTransfer(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, k) _, err := k.FuturesTransfer(context.Background(), "cash", "futures", "btc", 2) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FuturesTransfer should not error") } func TestFuturesGetOpenPositions(t *testing.T) { @@ -249,9 +213,7 @@ func TestFuturesGetOpenPositions(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, k) _, err := k.FuturesGetOpenPositions(context.Background()) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FuturesGetOpenPositions should not error") } func TestFuturesNotifications(t *testing.T) { @@ -259,9 +221,7 @@ func TestFuturesNotifications(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, k) _, err := k.FuturesNotifications(context.Background()) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FuturesNotifications should not error") } func TestFuturesCancelAllOrders(t *testing.T) { @@ -277,9 +237,7 @@ func TestGetFuturesAccountData(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, k) _, err := k.GetFuturesAccountData(context.Background()) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "GetFuturesAccountData should not error") } func TestFuturesCancelAllOrdersAfter(t *testing.T) { @@ -287,9 +245,7 @@ func TestFuturesCancelAllOrdersAfter(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, k, canManipulateRealOrders) _, err := k.FuturesCancelAllOrdersAfter(context.Background(), 50) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FuturesCancelAllOrdersAfter should not error") } func TestFuturesOpenOrders(t *testing.T) { @@ -297,9 +253,7 @@ func TestFuturesOpenOrders(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, k) _, err := k.FuturesOpenOrders(context.Background()) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FuturesOpenOrders should not error") } func TestFuturesRecentOrders(t *testing.T) { @@ -315,20 +269,15 @@ func TestFuturesWithdrawToSpotWallet(t *testing.T) { sharedtestvalues.SkipTestIfCredentialsUnset(t, k, canManipulateRealOrders) _, err := k.FuturesWithdrawToSpotWallet(context.Background(), "xbt", 5) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "FuturesWithdrawToSpotWallet should not error") } func TestFuturesGetTransfers(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, k, canManipulateRealOrders) - _, err := k.FuturesGetTransfers(context.Background(), - time.Now().Add(-time.Hour*24)) - if err != nil { - t.Error(err) - } + _, err := k.FuturesGetTransfers(context.Background(), time.Now().Add(-time.Hour*24)) + assert.NoError(t, err, "FuturesGetTransfers should not error") } func TestGetFuturesOrderbook(t *testing.T) { @@ -340,17 +289,13 @@ func TestGetFuturesOrderbook(t *testing.T) { func TestGetFuturesMarkets(t *testing.T) { t.Parallel() _, err := k.GetInstruments(context.Background()) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "GetInstruments should not error") } func TestGetFuturesTickers(t *testing.T) { t.Parallel() _, err := k.GetFuturesTickers(context.Background()) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "GetFuturesTickers should not error") } func TestGetFuturesTradeHistory(t *testing.T) { @@ -363,123 +308,68 @@ func TestGetFuturesTradeHistory(t *testing.T) { func TestGetAssets(t *testing.T) { t.Parallel() _, err := k.GetAssets(context.Background()) - if err != nil { - t.Error("GetAssets() error", err) - } + assert.NoError(t, err, "GetAssets should not error") } func TestSeedAssetTranslator(t *testing.T) { t.Parallel() - // Test currency pair - if r := assetTranslator.LookupAltName("XXBTZUSD"); r != "XBTUSD" { - t.Error("unexpected result") - } - if r := assetTranslator.LookupCurrency("XBTUSD"); r != "XXBTZUSD" { - t.Error("unexpected result") - } - // Test fiat currency - if r := assetTranslator.LookupAltName("ZUSD"); r != "USD" { - t.Error("unexpected result") - } - if r := assetTranslator.LookupCurrency("USD"); r != "ZUSD" { - t.Error("unexpected result") - } + err := k.SeedAssets(context.TODO()) + require.NoError(t, err, "SeedAssets must not error") - // Test cryptocurrency - if r := assetTranslator.LookupAltName("XXBT"); r != "XBT" { - t.Error("unexpected result") - } - if r := assetTranslator.LookupCurrency("XBT"); r != "XXBT" { - t.Error("unexpected result") + for from, to := range map[string]string{"XBTUSD": "XXBTZUSD", "USD": "ZUSD", "XBT": "XXBT"} { + assert.Equal(t, from, assetTranslator.LookupAltName(to), "LookupAltName should return the correct value") + assert.Equal(t, to, assetTranslator.LookupCurrency(from), "LookupCurrency should return the correct value") } } func TestSeedAssets(t *testing.T) { t.Parallel() var a assetTranslatorStore - if r := a.LookupAltName("ZUSD"); r != "" { - t.Error("unexpected result") - } + assert.Empty(t, a.LookupAltName("ZUSD"), "LookupAltName on unseeded store should return empty") a.Seed("ZUSD", "USD") - if r := a.LookupAltName("ZUSD"); r != "USD" { - t.Error("unexpected result") - } + assert.Equal(t, "USD", a.LookupAltName("ZUSD"), "LookupAltName should return the correct value") a.Seed("ZUSD", "BLA") - if r := a.LookupAltName("ZUSD"); r != "USD" { - t.Error("unexpected result") - } + assert.Equal(t, "USD", a.LookupAltName("ZUSD"), "Store should ignore second reseed of existing currency") } func TestLookupCurrency(t *testing.T) { t.Parallel() var a assetTranslatorStore - if r := a.LookupCurrency("USD"); r != "" { - t.Error("unexpected result") - } + assert.Empty(t, a.LookupCurrency("USD"), "LookupCurrency on unseeded store should return empty") a.Seed("ZUSD", "USD") - if r := a.LookupCurrency("USD"); r != "ZUSD" { - t.Error("unexpected result") - } - if r := a.LookupCurrency("EUR"); r != "" { - t.Error("unexpected result") - } + assert.Equal(t, "ZUSD", a.LookupCurrency("USD"), "LookupCurrency should return the correct value") + assert.Empty(t, a.LookupCurrency("EUR"), "LookupCurrency should still not return an unseeded key") } // TestGetAssetPairs API endpoint test func TestGetAssetPairs(t *testing.T) { t.Parallel() - _, err := k.GetAssetPairs(context.Background(), []string{}, "fees") - if err != nil { - t.Error("GetAssetPairs() error", err) - } - _, err = k.GetAssetPairs(context.Background(), []string{}, "leverage") - if err != nil { - t.Error("GetAssetPairs() error", err) - } - _, err = k.GetAssetPairs(context.Background(), []string{}, "margin") - if err != nil { - t.Error("GetAssetPairs() error", err) - } - _, err = k.GetAssetPairs(context.Background(), []string{}, "") - if err != nil { - t.Error("GetAssetPairs() error", err) + for _, v := range []string{"fees", "leverage", "margin", ""} { + _, err := k.GetAssetPairs(context.Background(), []string{}, v) + require.NoErrorf(t, err, "GetAssetPairs %s must not error", v) } } // TestGetTicker API endpoint test func TestGetTicker(t *testing.T) { t.Parallel() - cp, err := currency.NewPairFromString("BCHEUR") - if err != nil { - t.Error(err) - } - _, err = k.GetTicker(context.Background(), cp) - if err != nil { - t.Error("GetTicker() error", err) - } + _, err := k.GetTicker(context.Background(), spotTestPair) + assert.NoError(t, err, "GetTicker should not error") } // TestGetTickers API endpoint test func TestGetTickers(t *testing.T) { t.Parallel() _, err := k.GetTickers(context.Background(), "LTCUSD,ETCUSD") - if err != nil { - t.Error("GetTickers() error", err) - } + assert.NoError(t, err, "GetTickers should not error") } // TestGetOHLC API endpoint test func TestGetOHLC(t *testing.T) { t.Parallel() - cp, err := currency.NewPairFromString("XXBTZUSD") - if err != nil { - t.Error(err) - } - _, err = k.GetOHLC(context.Background(), cp, "1440") - if err != nil { - t.Error("GetOHLC() error", err) - } + _, err := k.GetOHLC(context.Background(), currency.NewPairWithDelimiter("XXBT", "ZUSD", ""), "1440") + assert.NoError(t, err, "GetOHLC should not error") } // TestGetDepth API endpoint test @@ -492,183 +382,154 @@ func TestGetDepth(t *testing.T) { // TestGetTrades API endpoint test func TestGetTrades(t *testing.T) { t.Parallel() - cp, err := currency.NewPairFromString("BCHEUR") - if err != nil { - t.Error(err) - } - _, err = k.GetTrades(context.Background(), cp) - if err != nil { - t.Error("GetTrades() error", err) - } + testexch.UpdatePairsOnce(t, k) + _, err := k.GetTrades(context.Background(), spotTestPair) + assert.NoError(t, err, "GetTrades should not error") - cp, err = currency.NewPairFromString("XXXXX") - if err != nil { - t.Error(err) - } - _, err = k.GetTrades(context.Background(), cp) - if err == nil { - t.Error("GetTrades() error: expecting error") - } + _, err = k.GetTrades(context.Background(), currency.NewPairWithDelimiter("XXX", "XXX", "")) + assert.ErrorContains(t, err, "Unknown asset pair", "GetDepth should error correctly") } // TestGetSpread API endpoint test func TestGetSpread(t *testing.T) { t.Parallel() - cp, err := currency.NewPairFromString("BCHEUR") - if err != nil { - t.Error(err) - } - _, err = k.GetSpread(context.Background(), cp) - if err != nil { - t.Error("GetSpread() error", err) - } + _, err := k.GetSpread(context.Background(), currency.NewPair(currency.BCH, currency.EUR)) // XBTUSD not in spread data + assert.NoError(t, err, "GetSpread should not error") } // TestGetBalance API endpoint test func TestGetBalance(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) _, err := k.GetBalance(context.Background()) - if err == nil { - t.Error("GetBalance() Expected error") - } + assert.NoError(t, err, "GetBalance should not error") } // TestGetTradeBalance API endpoint test func TestGetDepositMethods(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCredentialsUnset(t, k) - _, err := k.GetDepositMethods(context.Background(), "USDT") - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "GetDepositMethods should not error") } // TestGetTradeBalance API endpoint test func TestGetTradeBalance(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) args := TradeBalanceOptions{Asset: "ZEUR"} _, err := k.GetTradeBalance(context.Background(), args) - if err == nil { - t.Error("GetTradeBalance() Expected error") - } + assert.NoError(t, err) } // TestGetOpenOrders API endpoint test func TestGetOpenOrders(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) args := OrderInfoOptions{Trades: true} _, err := k.GetOpenOrders(context.Background(), args) - if err == nil { - t.Error("GetOpenOrders() Expected error") - } + assert.NoError(t, err) } // TestGetClosedOrders API endpoint test func TestGetClosedOrders(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) args := GetClosedOrdersOptions{Trades: true, Start: "OE4KV4-4FVQ5-V7XGPU"} _, err := k.GetClosedOrders(context.Background(), args) - if err == nil { - t.Error("GetClosedOrders() Expected error") - } + assert.NoError(t, err) } // TestQueryOrdersInfo API endpoint test func TestQueryOrdersInfo(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) args := OrderInfoOptions{Trades: true} - _, err := k.QueryOrdersInfo(context.Background(), - args, "OR6ZFV-AA6TT-CKFFIW", "OAMUAJ-HLVKG-D3QJ5F") - if err == nil { - t.Error("QueryOrdersInfo() Expected error") - } + _, err := k.QueryOrdersInfo(context.Background(), args, "OR6ZFV-AA6TT-CKFFIW", "OAMUAJ-HLVKG-D3QJ5F") + assert.NoError(t, err) } // TestGetTradesHistory API endpoint test func TestGetTradesHistory(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) args := GetTradesHistoryOptions{Trades: true, Start: "TMZEDR-VBJN2-NGY6DX", End: "TVRXG2-R62VE-RWP3UW"} _, err := k.GetTradesHistory(context.Background(), args) - if err == nil { - t.Error("GetTradesHistory() Expected error") - } + assert.NoError(t, err) } // TestQueryTrades API endpoint test func TestQueryTrades(t *testing.T) { t.Parallel() - _, err := k.QueryTrades(context.Background(), - true, "TMZEDR-VBJN2-NGY6DX", "TFLWIB-KTT7L-4TWR3L", "TDVRAH-2H6OS-SLSXRX") - if err == nil { - t.Error("QueryTrades() Expected error") - } + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) + _, err := k.QueryTrades(context.Background(), true, "TMZEDR-VBJN2-NGY6DX", "TFLWIB-KTT7L-4TWR3L", "TDVRAH-2H6OS-SLSXRX") + assert.NoError(t, err) } // TestOpenPositions API endpoint test func TestOpenPositions(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) _, err := k.OpenPositions(context.Background(), false) - if err == nil { - t.Error("OpenPositions() Expected error") - } + assert.NoError(t, err) } // TestGetLedgers API endpoint test +// TODO: Needs a positive test func TestGetLedgers(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) + args := GetLedgersOptions{Start: "LRUHXI-IWECY-K4JYGO", End: "L5NIY7-JZQJD-3J4M2V", Ofs: 15} _, err := k.GetLedgers(context.Background(), args) - if err == nil { - t.Error("GetLedgers() Expected error") - } + assert.ErrorContains(t, err, "EQuery:Unknown asset pair", "GetLedger should error on imaginary ledgers") } // TestQueryLedgers API endpoint test func TestQueryLedgers(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) _, err := k.QueryLedgers(context.Background(), "LVTSFS-NHZVM-EXNZ5M") - if err == nil { - t.Error("QueryLedgers() Expected error") - } + assert.NoError(t, err) } // TestGetTradeVolume API endpoint test func TestGetTradeVolume(t *testing.T) { t.Parallel() - cp, err := currency.NewPairFromString("OAVY7T-MV5VK-KHDF5X") - if err != nil { - t.Error(err) - } - _, err = k.GetTradeVolume(context.Background(), true, cp) - if err == nil { - t.Error("GetTradeVolume() Expected error") - } + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) + _, err := k.GetTradeVolume(context.Background(), true, spotTestPair) + assert.NoError(t, err, "GetTradeVolume should not error") } -// TestAddOrder API endpoint test -func TestAddOrder(t *testing.T) { +// TestOrders Tests AddOrder and CancelExistingOrder +func TestOrders(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k, canManipulateRealOrders) + args := AddOrderOptions{OrderFlags: "fcib"} cp, err := currency.NewPairFromString("XXBTZUSD") - if err != nil { - t.Error(err) - } - _, err = k.AddOrder(context.Background(), + assert.NoError(t, err, "NewPairFromString should not error") + resp, err := k.AddOrder(context.Background(), cp, - order.Sell.Lower(), order.Limit.Lower(), - 0.00000001, 0, 0, 0, &args) - if err == nil { - t.Error("AddOrder() Expected error") + order.Buy.Lower(), order.Limit.Lower(), + 0.0001, 9000, 9000, 0, &args) + + if assert.NoError(t, err, "AddOrder should not error") { + if assert.Len(t, resp.TransactionIDs, 1, "One TransactionId should be returned") { + id := resp.TransactionIDs[0] + _, err = k.CancelExistingOrder(context.Background(), id) + assert.NoErrorf(t, err, "CancelExistingOrder should not error, Please ensure order %s is cancelled manually", id) + } } } // TestCancelExistingOrder API endpoint test func TestCancelExistingOrder(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k, canManipulateRealOrders) _, err := k.CancelExistingOrder(context.Background(), "OAVY7T-MV5VK-KHDF5X") - if err == nil { - t.Error("CancelExistingOrder() Expected error") + if assert.Error(t, err, "Cancel with imaginary order-id should error") { + assert.ErrorContains(t, err, "EOrder:Unknown order", "Cancel with imaginary order-id should error Unknown Order") } } @@ -683,107 +544,81 @@ func setFeeBuilder() *exchange.FeeBuilder { } } -// TestGetFee logic test - // TestGetFeeByTypeOfflineTradeFee logic test func TestGetFeeByTypeOfflineTradeFee(t *testing.T) { t.Parallel() var feeBuilder = setFeeBuilder() - _, err := k.GetFeeByType(context.Background(), feeBuilder) - if err != nil { - t.Error(err) - } + f, err := k.GetFeeByType(context.Background(), feeBuilder) + require.NoError(t, err, "GetFeeByType must not error") + assert.Positive(t, f, "GetFeeByType should return a positive value") if !sharedtestvalues.AreAPICredentialsSet(k) { - if feeBuilder.FeeType != exchange.OfflineTradeFee { - t.Errorf("Expected %v, received %v", exchange.OfflineTradeFee, feeBuilder.FeeType) - } + assert.Equal(t, exchange.OfflineTradeFee, feeBuilder.FeeType, "GetFeeByType should set FeeType correctly") } else { - if feeBuilder.FeeType != exchange.CryptocurrencyTradeFee { - t.Errorf("Expected %v, received %v", exchange.CryptocurrencyTradeFee, feeBuilder.FeeType) - } + assert.Equal(t, exchange.CryptocurrencyTradeFee, feeBuilder.FeeType, "GetFeeByType should set FeeType correctly") } } +// TestGetFee exercises GetFee func TestGetFee(t *testing.T) { t.Parallel() var feeBuilder = setFeeBuilder() if sharedtestvalues.AreAPICredentialsSet(k) { - // CryptocurrencyTradeFee Basic - if _, err := k.GetFee(context.Background(), feeBuilder); err != nil { - t.Error(err) - } + _, err := k.GetFee(context.Background(), feeBuilder) + assert.NoError(t, err, "CryptocurrencyTradeFee Basic GetFee should not error") - // CryptocurrencyTradeFee High quantity feeBuilder = setFeeBuilder() feeBuilder.Amount = 1000 feeBuilder.PurchasePrice = 1000 - if _, err := k.GetFee(context.Background(), feeBuilder); err != nil { - t.Error(err) - } + _, err = k.GetFee(context.Background(), feeBuilder) + assert.NoError(t, err, "CryptocurrencyTradeFee High quantity GetFee should not error") - // CryptocurrencyTradeFee IsMaker feeBuilder = setFeeBuilder() feeBuilder.IsMaker = true - if _, err := k.GetFee(context.Background(), feeBuilder); err != nil { - t.Error(err) - } + _, err = k.GetFee(context.Background(), feeBuilder) + assert.NoError(t, err, "CryptocurrencyTradeFee IsMaker GetFee should not error") - // CryptocurrencyTradeFee Negative purchase price feeBuilder = setFeeBuilder() feeBuilder.PurchasePrice = -1000 - if _, err := k.GetFee(context.Background(), feeBuilder); err != nil { - t.Error(err) - } + _, err = k.GetFee(context.Background(), feeBuilder) + assert.NoError(t, err, "CryptocurrencyTradeFee Negative purchase price GetFee should not error") - // InternationalBankDepositFee Basic feeBuilder = setFeeBuilder() feeBuilder.FeeType = exchange.InternationalBankDepositFee - if _, err := k.GetFee(context.Background(), feeBuilder); err != nil { - t.Error(err) - } + _, err = k.GetFee(context.Background(), feeBuilder) + assert.NoError(t, err, "InternationalBankDepositFee Basic GetFee should not error") } - // CryptocurrencyDepositFee Basic feeBuilder = setFeeBuilder() feeBuilder.FeeType = exchange.CryptocurrencyDepositFee feeBuilder.Pair.Base = currency.XXBT - if _, err := k.GetFee(context.Background(), feeBuilder); err != nil { - t.Error(err) - } + _, err := k.GetFee(context.Background(), feeBuilder) + assert.NoError(t, err, "CryptocurrencyDepositFee Basic GetFee should not error") - // CryptocurrencyWithdrawalFee Basic feeBuilder = setFeeBuilder() feeBuilder.FeeType = exchange.CryptocurrencyWithdrawalFee - if _, err := k.GetFee(context.Background(), feeBuilder); err != nil { - t.Error(err) - } + _, err = k.GetFee(context.Background(), feeBuilder) + assert.NoError(t, err, "CryptocurrencyWithdrawalFee Basic GetFee should not error") - // CryptocurrencyWithdrawalFee Invalid currency feeBuilder = setFeeBuilder() feeBuilder.Pair.Base = currency.NewCode("hello") feeBuilder.FeeType = exchange.CryptocurrencyWithdrawalFee - if _, err := k.GetFee(context.Background(), feeBuilder); err != nil { - t.Error(err) - } + _, err = k.GetFee(context.Background(), feeBuilder) + assert.NoError(t, err, "CryptocurrencyWithdrawalFee Invalid currency GetFee should not error") - // InternationalBankWithdrawalFee Basic feeBuilder = setFeeBuilder() feeBuilder.FeeType = exchange.InternationalBankWithdrawalFee feeBuilder.FiatCurrency = currency.USD - if _, err := k.GetFee(context.Background(), feeBuilder); err != nil { - t.Error(err) - } + _, err = k.GetFee(context.Background(), feeBuilder) + assert.NoError(t, err, "InternationalBankWithdrawalFee Basic GetFee should not error") } // TestFormatWithdrawPermissions logic test func TestFormatWithdrawPermissions(t *testing.T) { t.Parallel() - expectedResult := exchange.AutoWithdrawCryptoWithSetupText + " & " + exchange.WithdrawCryptoWith2FAText + " & " + exchange.AutoWithdrawFiatWithSetupText + " & " + exchange.WithdrawFiatWith2FAText + exp := exchange.AutoWithdrawCryptoWithSetupText + " & " + exchange.WithdrawCryptoWith2FAText + " & " + exchange.AutoWithdrawFiatWithSetupText + " & " + exchange.WithdrawFiatWith2FAText withdrawPermissions := k.FormatWithdrawPermissions() - if withdrawPermissions != expectedResult { - t.Errorf("Expected: %s, Received: %s", expectedResult, withdrawPermissions) - } + assert.Equal(t, exp, withdrawPermissions, "FormatWithdrawPermissions should return correct value") } // TestGetActiveOrders wrapper test @@ -799,14 +634,14 @@ func TestGetActiveOrders(t *testing.T) { } _, err := k.GetActiveOrders(context.Background(), &getOrdersRequest) - if err != nil { - t.Error(err) - } + assert.NoError(t, err, "GetActiveOrders should not error") } // TestGetOrderHistory wrapper test func TestGetOrderHistory(t *testing.T) { t.Parallel() + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) + var getOrdersRequest = order.MultiOrderRequest{ Type: order.AnyType, AssetType: asset.Spot, @@ -814,30 +649,15 @@ func TestGetOrderHistory(t *testing.T) { } _, err := k.GetOrderHistory(context.Background(), &getOrdersRequest) - if sharedtestvalues.AreAPICredentialsSet(k) && err != nil { - t.Errorf("Could not get order history: %s", err) - } else if !sharedtestvalues.AreAPICredentialsSet(k) && err == nil { - t.Error("Expecting an error when no keys are set") - } + assert.NoError(t, err) } -// TestGetOrderHistory wrapper test +// TestGetOrderInfo exercises GetOrderInfo func TestGetOrderInfo(t *testing.T) { t.Parallel() - sharedtestvalues.SkipTestIfCannotManipulateOrders(t, k, canManipulateRealOrders) - - _, err := k.GetOrderInfo(context.Background(), - "OZPTPJ-HVYHF-EDIGXS", currency.EMPTYPAIR, asset.Spot) - if !sharedtestvalues.AreAPICredentialsSet(k) && err == nil { - t.Error("Expecting error") - } - if sharedtestvalues.AreAPICredentialsSet(k) && err != nil { - if !strings.Contains(err.Error(), "- Order ID not found:") { - t.Error("Expected Order ID not found error") - } else { - t.Error(err) - } - } + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) + _, err := k.GetOrderInfo(context.Background(), "OZPTPJ-HVYHF-EDIGXS", currency.EMPTYPAIR, asset.Spot) + assert.ErrorContains(t, err, "order OZPTPJ-HVYHF-EDIGXS not found in response", "Should error that order was not found in response") } // Any tests below this line have the ability to impact your orders on the exchange. Enable canManipulateRealOrders to run them @@ -849,11 +669,8 @@ func TestSubmitOrder(t *testing.T) { sharedtestvalues.SkipTestIfCannotManipulateOrders(t, k, canManipulateRealOrders) var orderSubmission = &order.Submit{ - Exchange: k.Name, - Pair: currency.Pair{ - Base: currency.XBT, - Quote: currency.USD, - }, + Exchange: k.Name, + Pair: spotTestPair, Side: order.Buy, Type: order.Limit, Price: 1, @@ -862,10 +679,11 @@ func TestSubmitOrder(t *testing.T) { AssetType: asset.Spot, } response, err := k.SubmitOrder(context.Background(), orderSubmission) - if sharedtestvalues.AreAPICredentialsSet(k) && (err != nil || response.Status != order.New) { - t.Errorf("Order failed to be placed: %v", err) - } else if !sharedtestvalues.AreAPICredentialsSet(k) && err == nil { - t.Error("Expecting an error when no keys are set") + if sharedtestvalues.AreAPICredentialsSet(k) { + assert.NoError(t, err, "SubmitOrder should not error") + assert.Equal(t, order.New, response.Status, "SubmitOrder should return a New order status") + } else { + assert.ErrorIs(t, err, exchange.ErrAuthenticationSupportNotEnabled, "SubmitOrder should error correctly") } } @@ -873,12 +691,11 @@ func TestSubmitOrder(t *testing.T) { func TestCancelExchangeOrder(t *testing.T) { t.Parallel() - if err := k.CancelOrder(context.Background(), &order.Cancel{ + err := k.CancelOrder(context.Background(), &order.Cancel{ AssetType: asset.Options, OrderID: "1337", - }); !errors.Is(err, asset.ErrNotSupported) { - t.Errorf("expected: %v, received: %v", asset.ErrNotSupported, err) - } + }) + assert.ErrorIs(t, err, asset.ErrNotSupported, "CancelOrder should error on Options asset") sharedtestvalues.SkipTestIfCannotManipulateOrders(t, k, canManipulateRealOrders) @@ -887,12 +704,11 @@ func TestCancelExchangeOrder(t *testing.T) { AssetType: asset.Spot, } - err := k.CancelOrder(context.Background(), orderCancellation) - if !sharedtestvalues.AreAPICredentialsSet(k) && err == nil { - t.Error("Expecting an error when no keys are set") - } - if sharedtestvalues.AreAPICredentialsSet(k) && err != nil { - t.Errorf("Could not cancel orders: %v", err) + err = k.CancelOrder(context.Background(), orderCancellation) + if sharedtestvalues.AreAPICredentialsSet(k) { + assert.NoError(t, err, "CancelOrder should not error") + } else { + assert.ErrorIs(t, err, exchange.ErrAuthenticationSupportNotEnabled, "CancelOrder should error correctly") } } @@ -909,11 +725,10 @@ func TestCancelBatchExchangeOrder(t *testing.T) { }) _, err := k.CancelBatchOrders(context.Background(), ordersCancellation) - if !sharedtestvalues.AreAPICredentialsSet(k) && err == nil { - t.Error("Expecting an error when no keys are set") - } - if sharedtestvalues.AreAPICredentialsSet(k) && err != nil { - t.Errorf("Could not cancel orders: %v", err) + if sharedtestvalues.AreAPICredentialsSet(k) { + assert.NoError(t, err, "CancelBatchOrder should not error") + } else { + assert.ErrorIs(t, err, common.ErrFunctionNotSupported, "CancelBatchOrders should error correctly") } } @@ -922,58 +737,38 @@ func TestCancelAllExchangeOrders(t *testing.T) { t.Parallel() sharedtestvalues.SkipTestIfCannotManipulateOrders(t, k, canManipulateRealOrders) - resp, err := k.CancelAllOrders(context.Background(), - &order.Cancel{AssetType: asset.Spot}) - if !sharedtestvalues.AreAPICredentialsSet(k) && err == nil { - t.Error("Expecting an error when no keys are set") - } - if sharedtestvalues.AreAPICredentialsSet(k) && err != nil { - t.Errorf("Could not cancel orders: %v", err) - } + resp, err := k.CancelAllOrders(context.Background(), &order.Cancel{AssetType: asset.Spot}) - if len(resp.Status) > 0 { - t.Errorf("%v orders failed to cancel", len(resp.Status)) - } -} - -// TestGetAccountInfo wrapper test -func TestGetAccountInfo(t *testing.T) { - t.Parallel() if sharedtestvalues.AreAPICredentialsSet(k) { - _, err := k.UpdateAccountInfo(context.Background(), asset.Spot) - if err != nil { - // Spot and Futures have separate api keys. Please ensure that the correct one is provided - t.Error("GetAccountInfo() error", err) - } + assert.NoError(t, err, "CancelAllOrders should not error") } else { - _, err := k.UpdateAccountInfo(context.Background(), asset.Spot) - if err == nil { - t.Error("GetAccountInfo() Expected error") - } + assert.ErrorIs(t, err, exchange.ErrAuthenticationSupportNotEnabled, "CancelBatchOrders should error correctly") } + + assert.Empty(t, resp.Status, "CancelAllOrders Status should not contain any failed order errors") } -func TestUpdateFuturesAccountInfo(t *testing.T) { +// TestUpdateAccountInfo exercises UpdateAccountInfo +func TestUpdateAccountInfo(t *testing.T) { t.Parallel() - sharedtestvalues.SkipTestIfCredentialsUnset(t, k) - _, err := k.UpdateAccountInfo(context.Background(), asset.Futures) - if err != nil { - // Spot and Futures have separate api keys. Please ensure that the correct one is provided - t.Error(err) + for _, a := range []asset.Item{asset.Spot, asset.Futures} { + _, err := k.UpdateAccountInfo(context.Background(), a) + + if sharedtestvalues.AreAPICredentialsSet(k) { + assert.NoErrorf(t, err, "UpdateAccountInfo should not error for asset %s", a) // Note Well: Spot and Futures have separate api keys + } else { + assert.ErrorIsf(t, err, exchange.ErrAuthenticationSupportNotEnabled, "UpdateAccountInfo should error correctly for asset %s", a) + } } } // TestModifyOrder wrapper test func TestModifyOrder(t *testing.T) { t.Parallel() - sharedtestvalues.SkipTestIfCannotManipulateOrders(t, k, canManipulateRealOrders) - _, err := k.ModifyOrder(context.Background(), - &order.Modify{AssetType: asset.Spot}) - if err == nil { - t.Error("ModifyOrder() Expected error") - } + _, err := k.ModifyOrder(context.Background(), &order.Modify{AssetType: asset.Spot}) + assert.ErrorIs(t, err, common.ErrFunctionNotSupported, "ModifyOrder should error correctly") } // TestWithdraw wrapper test @@ -1107,111 +902,289 @@ func TestWithdrawCancel(t *testing.T) { // ---------------------------- Websocket tests ----------------------------------------- -func setupWsTests(t *testing.T) { - t.Helper() - if wsSetupRan { - return - } - if !k.Websocket.IsEnabled() && !k.API.AuthenticatedWebsocketSupport || !sharedtestvalues.AreAPICredentialsSet(k) { - t.Skip(stream.ErrWebsocketNotEnabled.Error()) - } - var dialer websocket.Dialer - err := k.Websocket.Conn.Dial(&dialer, http.Header{}) - if err != nil { - t.Fatal(err) - } - err = k.Websocket.AuthConn.Dial(&dialer, http.Header{}) - if err != nil { - t.Fatal(err) +// TestWsSubscribe tests unauthenticated websocket subscriptions +// Specifically looking to ensure multiple errors are collected and returned and ws.Subscriptions Added/Removed in cases of: +// single pass, single fail, mixed fail, multiple pass, all fail +// No objection to this becoming a fixture test, so long as it integrates through Un/Subscribe roundtrip +func TestWsSubscribe(t *testing.T) { + k := new(Kraken) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(k), "Setup Instance must not error") + testexch.SetupWs(t, k) + + for _, enabled := range []bool{false, true} { + require.NoError(t, k.SetPairs(currency.Pairs{ + spotTestPair, + currency.NewPairWithDelimiter("ETH", "USD", "/"), + currency.NewPairWithDelimiter("LTC", "ETH", "/"), + currency.NewPairWithDelimiter("ETH", "XBT", "/"), + // Enable pairs that won't error locally, so we get upstream errors to test error combinations + currency.NewPairWithDelimiter("DWARF", "HOBBIT", "/"), + currency.NewPairWithDelimiter("DWARF", "GOBLIN", "/"), + currency.NewPairWithDelimiter("DWARF", "ELF", "/"), + }, asset.Spot, enabled), "SetPairs must not error") } - token, err := k.GetWebsocketToken(context.Background()) - if err != nil { - t.Error(err) + err := k.Subscribe(subscription.List{{Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{spotTestPair}}}) + require.NoError(t, err, "Simple subscription must not error") + subs := k.Websocket.GetSubscriptions() + require.Len(t, subs, 1, "Should add 1 Subscription") + assert.Equal(t, subscription.SubscribedState, subs[0].State(), "Subscription should be subscribed state") + + err = k.Subscribe(subscription.List{{Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{spotTestPair}}}) + assert.ErrorIs(t, err, subscription.ErrDuplicate, "Resubscribing to the same channel should error with SubscribedAlready") + subs = k.Websocket.GetSubscriptions() + require.Len(t, subs, 1, "Should not add a subscription on error") + assert.Equal(t, subscription.SubscribedState, subs[0].State(), "Existing subscription state should not change") + + err = k.Subscribe(subscription.List{{Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{currency.NewPairWithDelimiter("DWARF", "HOBBIT", "/")}}}) + assert.ErrorContains(t, err, "Currency pair not supported; Channel: ticker Pairs: DWARF/HOBBIT", "Subscribing to an invalid pair should error correctly") + require.Len(t, k.Websocket.GetSubscriptions(), 1, "Should not add a subscription on error") + + // Mix success and failure + err = k.Subscribe(subscription.List{ + {Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{currency.NewPairWithDelimiter("ETH", "USD", "/")}}, + {Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{currency.NewPairWithDelimiter("DWARF", "HOBBIT", "/")}}, + {Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{currency.NewPairWithDelimiter("DWARF", "ELF", "/")}}, + }) + assert.ErrorContains(t, err, "Currency pair not supported; Channel: ticker Pairs:", "Subscribing to an invalid pair should error correctly") + assert.ErrorContains(t, err, "DWARF/HOBBIT", "Subscribing to an invalid pair should error correctly") + assert.ErrorContains(t, err, "DWARF/ELF", "Subscribing to an invalid pair should error correctly") + require.Len(t, k.Websocket.GetSubscriptions(), 2, "Should have 2 subscriptions after mixed success/failures") + + // Just failures + err = k.Subscribe(subscription.List{ + {Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{currency.NewPairWithDelimiter("DWARF", "HOBBIT", "/")}}, + {Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{currency.NewPairWithDelimiter("DWARF", "GOBLIN", "/")}}, + }) + assert.ErrorContains(t, err, "Currency pair not supported; Channel: ticker Pairs:", "Subscribing to an invalid pair should error correctly") + assert.ErrorContains(t, err, "DWARF/HOBBIT", "Subscribing to an invalid pair should error correctly") + assert.ErrorContains(t, err, "DWARF/GOBLIN", "Subscribing to an invalid pair should error correctly") + require.Len(t, k.Websocket.GetSubscriptions(), 2, "Should have 2 subscriptions after mixed success/failures") + + // Just success + err = k.Subscribe(subscription.List{ + {Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{currency.NewPairWithDelimiter("ETH", "XBT", "/")}}, + {Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{currency.NewPairWithDelimiter("LTC", "ETH", "/")}}, + }) + assert.NoError(t, err, "Multiple successful subscriptions should not error") + + subs = k.Websocket.GetSubscriptions() + assert.Len(t, subs, 4, "Should have correct number of subscriptions") + + err = k.Unsubscribe(subs[:1]) + assert.NoError(t, err, "Simple Unsubscribe should succeed") + assert.Len(t, k.Websocket.GetSubscriptions(), 3, "Should have removed 1 channel") + + err = k.Unsubscribe(subscription.List{{Channel: subscription.TickerChannel, Pairs: currency.Pairs{currency.NewPairWithDelimiter("DWARF", "WIZARD", "/")}, Key: 1337}}) + assert.ErrorIs(t, err, subscription.ErrNotFound, "Simple failing Unsubscribe should error NotFound") + assert.ErrorContains(t, err, "DWARF/WIZARD", "Unsubscribing from an invalid pair should error correctly") + assert.Len(t, k.Websocket.GetSubscriptions(), 3, "Should not have removed any channels") + + err = k.Unsubscribe(subscription.List{ + subs[1], + {Asset: asset.Spot, Channel: subscription.TickerChannel, Pairs: currency.Pairs{currency.NewPairWithDelimiter("DWARF", "EAGLE", "/")}, Key: 1338}, + }) + assert.ErrorIs(t, err, subscription.ErrNotFound, "Mixed failing Unsubscribe should error NotFound") + assert.ErrorContains(t, err, "Channel: ticker Pairs: DWARF/EAGLE", "Unsubscribing from an invalid pair should error correctly") + + subs = k.Websocket.GetSubscriptions() + assert.Len(t, subs, 2, "Should have removed only 1 more channel") + + err = k.Unsubscribe(subs) + assert.NoError(t, err, "Unsubscribe multiple passing subscriptions should not error") + assert.Empty(t, k.Websocket.GetSubscriptions(), "Should have successfully removed all channels") + + for _, c := range []string{"ohlc", "ohlc-5"} { + err = k.Subscribe(subscription.List{{ + Asset: asset.Spot, + Channel: c, + Pairs: currency.Pairs{spotTestPair}, + }}) + assert.ErrorIs(t, err, subscription.ErrPrivateChannelName, "Must error when trying to use a private channel name") + assert.ErrorContains(t, err, c+" => subscription.CandlesChannel", "Must error when trying to use a private channel name") } - authToken = token - comms := make(chan stream.Response) - go k.wsFunnelConnectionData(k.Websocket.Conn, comms) - go k.wsFunnelConnectionData(k.Websocket.AuthConn, comms) - go k.wsReadData(comms) - go func() { - err := k.wsPingHandler() - if err != nil { - fmt.Println("error:", err) - } - }() - wsSetupRan = true } -// TestWebsocketSubscribe tests returning a message with an id -func TestWebsocketSubscribe(t *testing.T) { - setupWsTests(t) - err := k.Subscribe(subscription.List{ - { - Channel: defaultSubscribedChannels[0], - Pairs: currency.Pairs{currency.NewPairWithDelimiter("XBT", "USD", "/")}, - }, - }) - if err != nil { - t.Error(err) +// TestWsResubscribe tests websocket resubscription +func TestWsResubscribe(t *testing.T) { + k := new(Kraken) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(k), "TestInstance must not error") + testexch.SetupWs(t, k) + + err := k.Subscribe(subscription.List{{Asset: asset.Spot, Channel: subscription.OrderbookChannel, Levels: 1000}}) + require.NoError(t, err, "Subscribe must not error") + subs := k.Websocket.GetSubscriptions() + require.Len(t, subs, 1, "Should add 1 Subscription") + require.Equal(t, subscription.SubscribedState, subs[0].State(), "Subscription should be subscribed state") + + require.Eventually(t, func() bool { + b, e2 := k.Websocket.Orderbook.GetOrderbook(spotTestPair, asset.Spot) + if e2 == nil { + return !b.LastUpdated.IsZero() + } + return false + }, time.Second*4, time.Millisecond*10, "orderbook must start streaming") + + // Set the state to Unsub so we definitely know Resub worked + err = subs[0].SetState(subscription.UnsubscribingState) + require.NoError(t, err) + + err = k.Websocket.ResubscribeToChannel(subs[0]) + require.NoError(t, err, "Resubscribe must not error") + require.Equal(t, subscription.SubscribedState, subs[0].State(), "subscription must be subscribed again") +} + +// TestWsOrderbookSub tests orderbook subscriptions for MaxDepth params +func TestWsOrderbookSub(t *testing.T) { + t.Parallel() + + k := new(Kraken) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(k), "Setup Instance must not error") + testexch.SetupWs(t, k) + + err := k.Subscribe(subscription.List{{ + Asset: asset.Spot, + Channel: subscription.OrderbookChannel, + Pairs: currency.Pairs{spotTestPair}, + Levels: 25, + }}) + require.NoError(t, err, "Simple subscription should not error") + + subs := k.Websocket.GetSubscriptions() + require.Equal(t, 1, len(subs), "Should have 1 subscription channel") + + err = k.Unsubscribe(subs) + assert.NoError(t, err, "Unsubscribe should not error") + assert.Empty(t, k.Websocket.GetSubscriptions(), "Should have successfully removed all channels") + + err = k.Subscribe(subscription.List{{ + Asset: asset.Spot, + Channel: subscription.OrderbookChannel, + Pairs: currency.Pairs{spotTestPair}, + Levels: 42, + }}) + assert.ErrorContains(t, err, "Subscription depth not supported", "Bad subscription should error about depth") +} + +// TestWsCandlesSub tests candles subscription for Timeframe params +func TestWsCandlesSub(t *testing.T) { + t.Parallel() + + k := new(Kraken) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(k), "Setup Instance must not error") + testexch.SetupWs(t, k) + + err := k.Subscribe(subscription.List{{ + Asset: asset.Spot, + Channel: subscription.CandlesChannel, + Pairs: currency.Pairs{spotTestPair}, + Interval: kline.OneHour, + }}) + require.NoError(t, err, "Simple subscription should not error") + + subs := k.Websocket.GetSubscriptions() + require.Equal(t, 1, len(subs), "Should add 1 Subscription") + + err = k.Unsubscribe(subs) + assert.NoError(t, err, "Unsubscribe should not error") + assert.Empty(t, k.Websocket.GetSubscriptions(), "Should have successfully removed all channels") + + err = k.Subscribe(subscription.List{{ + Asset: asset.Spot, + Channel: subscription.CandlesChannel, + Pairs: currency.Pairs{spotTestPair}, + Interval: kline.Interval(time.Minute * time.Duration(127)), + }}) + assert.ErrorContains(t, err, "Subscription ohlc interval not supported", "Bad subscription should error about interval") +} + +// TestWsOwnTradesSub tests the authenticated WS subscription channel for trades +func TestWsOwnTradesSub(t *testing.T) { + t.Parallel() + + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) + + k := new(Kraken) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(k), "Setup Instance must not error") + testexch.SetupWs(t, k) + + err := k.Subscribe(subscription.List{{Channel: subscription.MyTradesChannel, Authenticated: true}}) + assert.NoError(t, err, "Subsrcibing to ownTrades should not error") + + subs := k.Websocket.GetSubscriptions() + assert.Len(t, subs, 1, "Should add 1 Subscription") + + err = k.Unsubscribe(subs) + assert.NoError(t, err, "Unsubscribing an auth channel should not error") + assert.Empty(t, k.Websocket.GetSubscriptions(), "Should have successfully removed channel") +} + +// TestGenerateSubscriptions tests the subscriptions generated from configuration +func TestGenerateSubscriptions(t *testing.T) { + t.Parallel() + + pairs, err := k.GetEnabledPairs(asset.Spot) + require.NoError(t, err, "GetEnabledPairs must not error") + require.False(t, k.Websocket.CanUseAuthenticatedEndpoints(), "Websocket must not be authenticated by default") + exp := subscription.List{ + {Channel: subscription.TickerChannel}, + {Channel: subscription.AllTradesChannel}, + {Channel: subscription.CandlesChannel, Interval: kline.OneMin}, + {Channel: subscription.OrderbookChannel, Levels: 1000}, } + for _, s := range exp { + s.QualifiedChannel = channelName(s) + s.Asset = asset.Spot + s.Pairs = pairs + } + subs, err := k.generateSubscriptions() + require.NoError(t, err, "generateSubscriptions should not error") + testsubs.EqualLists(t, exp, subs) + + k.Websocket.SetCanUseAuthenticatedEndpoints(true) + exp = append(exp, subscription.List{ + {Channel: subscription.MyOrdersChannel, QualifiedChannel: krakenWsOpenOrders}, + {Channel: subscription.MyTradesChannel, QualifiedChannel: krakenWsOwnTrades}, + }...) + subs, err = k.generateSubscriptions() + require.NoError(t, err, "generateSubscriptions should not error") + testsubs.EqualLists(t, exp, subs) } func TestGetWSToken(t *testing.T) { t.Parallel() - if !sharedtestvalues.AreAPICredentialsSet(k) { - t.Skip("API keys required, skipping") - } + sharedtestvalues.SkipTestIfCredentialsUnset(t, k) + + k := new(Kraken) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(k), "Setup Instance must not error") + testexch.SetupWs(t, k) + resp, err := k.GetWebsocketToken(context.Background()) - if err != nil { - t.Error(err) - } - if resp == "" { - t.Error("Token not returned") - } + require.NoError(t, err, "GetWebsocketToken must not error") + assert.NotEmpty(t, resp, "Token should not be empty") } +// TestWsAddOrder exercises roundtrip of wsAddOrder; See also: mockWsAddOrder func TestWsAddOrder(t *testing.T) { t.Parallel() - sharedtestvalues.SkipTestIfCredentialsUnset(t, k, canManipulateRealOrders) - testexch.SetupWs(t, k) - _, err := k.wsAddOrder(&WsAddOrderRequest{ + + k := testexch.MockWsInstance[Kraken](t, curryWsMockUpgrader(t, mockWsServer)) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.True(t, k.IsWebsocketAuthenticationSupported(), "WS must be authenticated") + id, err := k.wsAddOrder(&WsAddOrderRequest{ OrderType: order.Limit.Lower(), OrderSide: order.Buy.Lower(), Pair: "XBT/USD", - Price: -100, + Price: 80000, }) - if err != nil { - t.Error(err) - } -} - -func mockWsCancelOrders(msg []byte, w *websocket.Conn) error { - var req WsCancelOrderRequest - if err := json.Unmarshal(msg, &req); err != nil { - return err - } - resp := WsCancelOrderResponse{ - Event: krakenWsCancelOrderStatus, - Status: "ok", - RequestID: req.RequestID, - Count: int64(len(req.TransactionIDs)), - } - if len(req.TransactionIDs) == 0 || strings.Contains(req.TransactionIDs[0], "FISH") { // Reject anything that smells suspicious - resp.Status = "error" - resp.ErrorMessage = "[EOrder:Unknown order]" - } - respJSON, err := json.Marshal(resp) - if err != nil { - return err - } - return w.WriteMessage(websocket.TextMessage, respJSON) + require.NoError(t, err, "wsAddOrder must not error") + assert.Equal(t, "ONPNXH-KMKMU-F4MR5V", id, "wsAddOrder should return correct order ID") } +// TestWsCancelOrders exercises roundtrip of wsCancelOrders; See also: mockWsCancelOrders func TestWsCancelOrders(t *testing.T) { t.Parallel() - k := testexch.MockWsInstance[Kraken](t, curryWsMockUpgrader(t, mockWsCancelOrders)) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + k := testexch.MockWsInstance[Kraken](t, curryWsMockUpgrader(t, mockWsServer)) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes require.True(t, k.IsWebsocketAuthenticationSupported(), "WS must be authenticated") err := k.wsCancelOrders([]string{"RABBIT", "BATFISH", "SQUIRREL", "CATFISH", "MOUSE"}) @@ -1225,531 +1198,26 @@ func TestWsCancelOrders(t *testing.T) { } func TestWsCancelAllOrders(t *testing.T) { - setupWsTests(t) - if _, err := k.wsCancelAllOrders(); err != nil { - t.Error(err) - } + sharedtestvalues.SkipTestIfCredentialsUnset(t, k, canManipulateRealOrders) + testexch.SetupWs(t, k) + _, err := k.wsCancelAllOrders() + require.NoError(t, err, "wsCancelAllOrders must not error") } -func TestWsPong(t *testing.T) { +func TestWsHandleData(t *testing.T) { t.Parallel() - pressXToJSON := []byte(`{ - "event": "pong", - "reqid": 42 - }`) - err := k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsSystemStatus(t *testing.T) { - t.Parallel() - pressXToJSON := []byte(`{ - "connectionID": 8628615390848610000, - "event": "systemStatus", - "status": "online", - "version": "1.0.0" - }`) - err := k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsSubscriptionStatus(t *testing.T) { - t.Parallel() - pressXToJSON := []byte(`{ - "channelID": 10001, - "channelName": "ticker", - "event": "subscriptionStatus", - "pair": "XBT/EUR", - "status": "subscribed", - "subscription": { - "name": "ticker" - } - }`) - err := k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } - - pressXToJSON = []byte(`{ - "channelID": 10001, - "channelName": "ohlc-5", - "event": "subscriptionStatus", - "pair": "XBT/EUR", - "reqid": 42, - "status": "unsubscribed", - "subscription": { - "interval": 5, - "name": "ohlc" - } - }`) - err = k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } - - pressXToJSON = []byte(`{ - "channelName": "ownTrades", - "event": "subscriptionStatus", - "status": "subscribed", - "subscription": { - "name": "ownTrades" - } - }`) - err = k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } - pressXToJSON = []byte(`{ - "errorMessage": "Subscription depth not supported", - "event": "subscriptionStatus", - "pair": "XBT/USD", - "status": "error", - "subscription": { - "depth": 42, - "name": "book" - } - }`) - err = k.wsHandleData(pressXToJSON) - if err == nil { - t.Error("Expected error") - } -} - -func TestWsTicker(t *testing.T) { - t.Parallel() - pressXToJSON := []byte(`{ - "channelID": 1337, - "channelName": "ticker", - "event": "subscriptionStatus", - "pair": "XBT/EUR", - "status": "subscribed", - "subscription": { - "name": "ticker" - } - }`) - err := k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } - pressXToJSON = []byte(`[ - 1337, - { - "a": [ - "5525.40000", - 1, - "1.000" - ], - "b": [ - "5525.10000", - 1, - "1.000" - ], - "c": [ - "5525.10000", - "0.00398963" - ], - "h": [ - "5783.00000", - "5783.00000" - ], - "l": [ - "5505.00000", - "5505.00000" - ], - "o": [ - "5760.70000", - "5763.40000" - ], - "p": [ - "5631.44067", - "5653.78939" - ], - "t": [ - 11493, - 16267 - ], - "v": [ - "2634.11501494", - "3591.17907851" - ] - }, - "ticker", - "XBT/USD" - ]`) - err = k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsOHLC(t *testing.T) { - t.Parallel() - pressXToJSON := []byte(`{ - "channelID": 13337, - "channelName": "ohlc", - "event": "subscriptionStatus", - "pair": "XBT/EUR", - "status": "subscribed", - "subscription": { - "name": "ohlc" - } - }`) - err := k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } - pressXToJSON = []byte(`[ - 13337, - [ - "1542057314.748456", - "1542057360.435743", - "3586.70000", - "3586.70000", - "3586.60000", - "3586.60000", - "3586.68894", - "0.03373000", - 2 - ], - "ohlc-5", - "XBT/USD" - ]`) - err = k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsTrade(t *testing.T) { - t.Parallel() - pressXToJSON := []byte(`{ - "channelID": 133337, - "channelName": "trade", - "event": "subscriptionStatus", - "pair": "XBT/EUR", - "status": "subscribed", - "subscription": { - "name": "trade" - } - }`) - err := k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } - pressXToJSON = []byte(`[ - 133337, - [ - [ - "5541.20000", - "0.15850568", - "1534614057.321597", - "s", - "l", - "" - ], - [ - "6060.00000", - "0.02455000", - "1534614057.324998", - "b", - "l", - "" - ] - ], - "trade", - "XBT/USD" - ]`) - err = k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsSpread(t *testing.T) { - t.Parallel() - pressXToJSON := []byte(`{ - "channelID": 1333337, - "channelName": "spread", - "event": "subscriptionStatus", - "pair": "XBT/EUR", - "status": "subscribed", - "subscription": { - "name": "spread" - } - }`) - err := k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } - pressXToJSON = []byte(`[ - 1333337, - [ - "5698.40000", - "5700.00000", - "1542057299.545897", - "1.01234567", - "0.98765432" - ], - "spread", - "XBT/USD" - ]`) - err = k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsOrdrbook(t *testing.T) { - t.Parallel() - pressXToJSON := []byte(`{ - "channelID": 13333337, - "channelName": "book", - "event": "subscriptionStatus", - "pair": "XBT/USD", - "status": "subscribed", - "subscription": { - "name": "book" - } - }`) - err := k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } - pressXToJSON = []byte(`[ - 13333337, - { - "as": [ - [ - "5541.30000", - "2.50700000", - "1534614248.123678" - ], - [ - "5541.80000", - "0.33000000", - "1534614098.345543" - ], - [ - "5542.70000", - "0.64700000", - "1534614244.654432" - ], - [ - "5544.30000", - "2.50700000", - "1534614248.123678" - ], - [ - "5545.80000", - "0.33000000", - "1534614098.345543" - ], - [ - "5546.70000", - "0.64700000", - "1534614244.654432" - ], - [ - "5547.70000", - "0.64700000", - "1534614244.654432" - ], - [ - "5548.30000", - "2.50700000", - "1534614248.123678" - ], - [ - "5549.80000", - "0.33000000", - "1534614098.345543" - ], - [ - "5550.70000", - "0.64700000", - "1534614244.654432" - ] - ], - "bs": [ - [ - "5541.20000", - "1.52900000", - "1534614248.765567" - ], - [ - "5539.90000", - "0.30000000", - "1534614241.769870" - ], - [ - "5539.50000", - "5.00000000", - "1534613831.243486" - ], - [ - "5538.20000", - "1.52900000", - "1534614248.765567" - ], - [ - "5537.90000", - "0.30000000", - "1534614241.769870" - ], - [ - "5536.50000", - "5.00000000", - "1534613831.243486" - ], - [ - "5535.20000", - "1.52900000", - "1534614248.765567" - ], - [ - "5534.90000", - "0.30000000", - "1534614241.769870" - ], - [ - "5533.50000", - "5.00000000", - "1534613831.243486" - ], - [ - "5532.50000", - "5.00000000", - "1534613831.243486" - ] - ] - }, - "book-100", - "XBT/USD" - ]`) - err = k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } - pressXToJSON = []byte(`[ - 13333337, - { - "a": [ - [ - "5541.30000", - "2.50700000", - "1534614248.456738" - ], - [ - "5542.50000", - "0.40100000", - "1534614248.456738" - ] - ], - "c": "4187525586" - }, - "book-10", - "XBT/USD" - ]`) - err = k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } - pressXToJSON = []byte(`[ - 13333337, - { - "b": [ - [ - "5541.30000", - "0.00000000", - "1534614335.345903" - ] - ], - "c": "4187525586" - }, - "book-10", - "XBT/USD" - ]`) - err = k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestWsOwnTrades(t *testing.T) { - t.Parallel() - pressXToJSON := []byte(`[ - [ - { - "TDLH43-DVQXD-2KHVYY": { - "cost": "1000000.00000", - "fee": "1600.00000", - "margin": "0.00000", - "ordertxid": "TDLH43-DVQXD-2KHVYY", - "ordertype": "limit", - "pair": "XBT/USD", - "postxid": "OGTT3Y-C6I3P-XRI6HX", - "price": "100000.00000", - "time": "1560516023.070651", - "type": "sell", - "vol": "1000000000.00000000" - } - }, - { - "TDLH43-DVQXD-2KHVYY": { - "cost": "1000000.00000", - "fee": "600.00000", - "margin": "0.00000", - "ordertxid": "TDLH43-DVQXD-2KHVYY", - "ordertype": "limit", - "pair": "XBT/USD", - "postxid": "OGTT3Y-C6I3P-XRI6HX", - "price": "100000.00000", - "time": "1560516023.070658", - "type": "buy", - "vol": "1000000000.00000000" - } - }, - { - "TDLH43-DVQXD-2KHVYY": { - "cost": "1000000.00000", - "fee": "1600.00000", - "margin": "0.00000", - "ordertxid": "TDLH43-DVQXD-2KHVYY", - "ordertype": "limit", - "pair": "XBT/USD", - "postxid": "OGTT3Y-C6I3P-XRI6HX", - "price": "100000.00000", - "time": "1560520332.914657", - "type": "sell", - "vol": "1000000000.00000000" - } - }, - { - "TDLH43-DVQXD-2KHVYY": { - "cost": "1000000.00000", - "fee": "600.00000", - "margin": "0.00000", - "ordertxid": "TDLH43-DVQXD-2KHVYY", - "ordertype": "limit", - "pair": "XBT/USD", - "postxid": "OGTT3Y-C6I3P-XRI6HX", - "price": "100000.00000", - "time": "1560520332.914664", - "type": "buy", - "vol": "1000000000.00000000" - } - } - ], - "ownTrades" - ]`) - err := k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) + k := new(Kraken) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(k), "Setup Instance must not error") + for _, l := range []int{10, 100} { + err := k.Websocket.AddSuccessfulSubscriptions(&subscription.Subscription{ + Channel: subscription.OrderbookChannel, + Pairs: currency.Pairs{spotTestPair}, + Asset: asset.Spot, + Levels: l, + }) + require.NoError(t, err, "AddSuccessfulSubscriptions must not error") } + testexch.FixtureToDataHandler(t, "testdata/wsHandleData.json", k.wsHandleData) } func TestWsOpenOrders(t *testing.T) { @@ -1778,7 +1246,7 @@ func TestWsOpenOrders(t *testing.T) { assert.Equal(t, order.Pending, v.Status, "order status") assert.Equal(t, 0.0, v.Price, "price") assert.Equal(t, 0.0001, v.Amount, "amount") - assert.Equal(t, time.UnixMicro(1692851641361371), v.Date, "Date") + assert.Equal(t, time.UnixMicro(1692851641361371).UTC(), v.Date, "Date") case 4: assert.Equal(t, "OKB55A-UEMMN-YUXM2A", v.OrderID, "OrderID") assert.Equal(t, order.Open, v.Status, "order status") @@ -1795,7 +1263,7 @@ func TestWsOpenOrders(t *testing.T) { assert.Equal(t, 0.0001, v.ExecutedAmount, "ExecutedAmount") assert.Equal(t, 26425.2, v.AverageExecutedPrice, "AverageExecutedPrice") assert.Equal(t, 0.00687, v.Fee, "Fee") - assert.Equal(t, time.UnixMicro(1692851641361447), v.LastUpdated, "LastUpdated") + assert.Equal(t, time.UnixMicro(1692851641361447).UTC(), v.LastUpdated, "LastUpdated") case 1: assert.Equal(t, "OGTT3Y-C6I3P-XRI6HR", v.OrderID, "OrderID") assert.Equal(t, order.UnknownStatus, v.Status, "order status") @@ -1805,7 +1273,7 @@ func TestWsOpenOrders(t *testing.T) { case 0: assert.Equal(t, "OGTT3Y-C6I3P-XRI6HR", v.OrderID, "OrderID") assert.Equal(t, order.Closed, v.Status, "order status") - assert.Equal(t, time.UnixMicro(1692675961789052), v.LastUpdated, "LastUpdated") + assert.Equal(t, time.UnixMicro(1692675961789052).UTC(), v.LastUpdated, "LastUpdated") assert.Equal(t, 10.00345345, v.ExecutedAmount, "ExecutedAmount") assert.Equal(t, 0.001, v.Fee, "Fee") assert.Equal(t, 34.5, v.AverageExecutedPrice, "AverageExecutedPrice") @@ -1818,96 +1286,43 @@ func TestWsOpenOrders(t *testing.T) { } } -func TestWsAddOrderJSON(t *testing.T) { - t.Parallel() - pressXToJSON := []byte(`{ - "descr": "buy 0.01770000 XBTUSD @ limit 4000", - "event": "addOrderStatus", - "status": "ok", - "txid": "ONPNXH-KMKMU-F4MR5V" -}`) - err := k.wsHandleData(pressXToJSON) - if err != nil { - t.Error(err) - } -} - -func TestParseTime(t *testing.T) { - t.Parallel() - // Test REST example - r := convert.TimeFromUnixTimestampDecimal(1373750306.9819).UTC() - if r.Year() != 2013 || - r.Month().String() != "July" || - r.Day() != 13 { - t.Error("unexpected result") - } - - // Test Websocket time example - r = convert.TimeFromUnixTimestampDecimal(1534614098.345543).UTC() - if r.Year() != 2018 || - r.Month().String() != "August" || - r.Day() != 18 { - t.Error("unexpected result") - } -} - func TestGetHistoricCandles(t *testing.T) { t.Parallel() + testexch.UpdatePairsOnce(t, k) + _, err := k.GetHistoricCandles(context.Background(), spotTestPair, asset.Spot, kline.OneHour, time.Now().Add(-time.Hour*12), time.Now()) assert.NoError(t, err, "GetHistoricCandles should not error") - pairs, err := k.CurrencyPairs.GetPairs(asset.Futures, false) - require.NoError(t, err, "GetPairs must not error") - err = k.CurrencyPairs.EnablePair(asset.Futures, pairs[0]) - assert.True(t, err == nil || errors.Is(err, currency.ErrPairAlreadyEnabled), "EnablePair should not error") - _, err = k.GetHistoricCandles(context.Background(), pairs[0], asset.Futures, kline.OneHour, time.Now().Add(-time.Hour*12), time.Now()) + _, err = k.GetHistoricCandles(context.Background(), futuresTestPair, asset.Futures, kline.OneHour, time.Now().Add(-time.Hour*12), time.Now()) assert.ErrorIs(t, err, asset.ErrNotSupported, "GetHistoricCandles should error with asset.ErrNotSupported") } func TestGetHistoricCandlesExtended(t *testing.T) { t.Parallel() - _, err := k.GetHistoricCandlesExtended(context.Background(), spotTestPair, asset.Spot, kline.OneMin, time.Now().Add(-time.Minute*3), time.Now()) - - assert.ErrorIs(t, err, common.ErrFunctionNotSupported) + _, err := k.GetHistoricCandlesExtended(context.Background(), futuresTestPair, asset.Spot, kline.OneMin, time.Now().Add(-time.Minute*3), time.Now()) + assert.ErrorIs(t, err, common.ErrFunctionNotSupported, "GetHistoricCandlesExtended should error correctly") } func Test_FormatExchangeKlineInterval(t *testing.T) { t.Parallel() - testCases := []struct { - name string + for _, tt := range []struct { interval kline.Interval - output string + exp string }{ - { - "OneMin", - kline.OneMin, - "1", - }, - { - "OneDay", - kline.OneDay, - "1440", - }, - } - - for x := range testCases { - test := testCases[x] - - t.Run(test.name, func(t *testing.T) { - t.Parallel() - ret := k.FormatExchangeKlineInterval(test.interval) - - if ret != test.output { - t.Errorf("unexpected result return expected: %v received: %v", test.output, ret) - } - }) + {kline.OneMin, "1"}, + {kline.OneDay, "1440"}, + } { + assert.Equalf(t, tt.exp, k.FormatExchangeKlineInterval(tt.interval), "FormatExchangeKlineInterval should return correct output for %s", tt.interval.Short()) } } func TestGetRecentTrades(t *testing.T) { t.Parallel() + testexch.UpdatePairsOnce(t, k) + _, err := k.GetRecentTrades(context.Background(), spotTestPair, asset.Spot) assert.NoError(t, err, "GetRecentTrades should not error") + _, err = k.GetRecentTrades(context.Background(), futuresTestPair, asset.Futures) assert.NoError(t, err, "GetRecentTrades should not error") } @@ -1987,7 +1402,6 @@ func TestGetFuturesTrades(t *testing.T) { } var websocketXDGUSDOrderbookUpdates = []string{ - `{"channelID":2304,"channelName":"book-10","event":"subscriptionStatus","pair":"XDG/USD","reqid":163845014,"status":"subscribed","subscription":{"depth":10,"name":"book"}}`, `[2304,{"as":[["0.074602700","278.39626342","1690246067.832139"],["0.074611000","555.65134028","1690246086.243668"],["0.074613300","524.87121572","1690245901.574881"],["0.074624600","77.57180740","1690246060.668500"],["0.074632500","620.64648404","1690246010.904883"],["0.074698400","409.57419037","1690246041.269821"],["0.074700000","61067.71115772","1690246089.485595"],["0.074723200","4394.01869240","1690246087.557913"],["0.074725200","4229.57885125","1690246082.911452"],["0.074738400","212.25501214","1690246089.421559"]],"bs":[["0.074597400","53591.43163675","1690246089.451762"],["0.074596700","33594.18269213","1690246089.514152"],["0.074596600","53598.60351469","1690246089.340781"],["0.074594800","5358.57247081","1690246089.347962"],["0.074594200","30168.21074680","1690246089.345112"],["0.074590900","7089.69894583","1690246088.212880"],["0.074586700","46925.20182082","1690246089.074618"],["0.074577200","5500.00000000","1690246087.568856"],["0.074569600","8132.49888631","1690246086.841219"],["0.074562900","8413.11098009","1690246087.024863"]]},"book-10","XDG/USD"]`, `[2304,{"a":[["0.074700000","0.00000000","1690246089.516119"],["0.074738500","125000.00000000","1690246063.352141","r"]],"c":"2219685759"},"book-10","XDG/USD"]`, `[2304,{"a":[["0.074678800","33476.70673703","1690246089.570183"]],"c":"1897176819"},"book-10","XDG/USD"]`, @@ -2006,24 +1420,37 @@ var websocketXDGUSDOrderbookUpdates = []string{ } var websocketLUNAEUROrderbookUpdates = []string{ - `{"channelID":9536,"channelName":"book-10","event":"subscriptionStatus","pair":"LUNA/EUR","reqid":106845459,"status":"subscribed","subscription":{"depth":10,"name":"book"}}`, `[9536,{"as":[["0.000074650000","147354.32016076","1690249755.076929"],["0.000074710000","5084881.40000000","1690250711.359411"],["0.000074760000","9700502.70476704","1690250743.279490"],["0.000074990000","2933380.23886300","1690249596.627969"],["0.000075000000","433333.33333333","1690245575.626780"],["0.000075020000","152914.84493416","1690243661.232520"],["0.000075070000","146529.90542161","1690249048.358424"],["0.000075250000","737072.85720004","1690211553.549248"],["0.000075400000","670061.64567140","1690250769.261196"],["0.000075460000","980226.63603417","1690250769.627523"]],"bs":[["0.000074590000","71029.87806720","1690250763.012724"],["0.000074580000","15935576.86404000","1690250763.012710"],["0.000074520000","33758611.79634000","1690250718.290955"],["0.000074350000","3156650.58590277","1690250766.499648"],["0.000074340000","301727260.79999999","1690250766.490238"],["0.000074320000","64611496.53837000","1690250742.680258"],["0.000074310000","104228596.60000000","1690250744.679121"],["0.000074300000","40366046.10582000","1690250762.685914"],["0.000074200000","3690216.57320475","1690250645.311465"],["0.000074060000","1337170.52532521","1690250742.012527"]]},"book-10","LUNA/EUR"]`, `[9536,{"b":[["0.000074060000","0.00000000","1690250770.616604"],["0.000074050000","16742421.17790510","1690250710.867730","r"]],"c":"418307145"},"book-10","LUNA/EUR"]`, } var websocketGSTEUROrderbookUpdates = []string{ - `{"channelID":8912,"channelName":"book-10","event":"subscriptionStatus","pair":"GST/EUR","reqid":157734759,"status":"subscribed","subscription":{"depth":10,"name":"book"}}`, `[8912,{"as":[["0.01300","850.00000000","1690230914.230506"],["0.01400","323483.99590510","1690256356.615823"],["0.01500","100287.34442717","1690219133.193345"],["0.01600","67995.78441017","1690118389.451216"],["0.01700","41776.38397740","1689676303.381189"],["0.01800","11785.76177777","1688631951.812452"],["0.01900","23700.00000000","1686935422.319042"],["0.02000","3941.17000000","1689415829.176481"],["0.02100","16598.69173066","1689420942.541943"],["0.02200","17572.51572836","1689851425.907427"]],"bs":[["0.01200","14220.66466572","1690256540.842831"],["0.01100","160223.61546438","1690256401.072463"],["0.01000","63083.48958963","1690256604.037673"],["0.00900","6750.00000000","1690252470.633938"],["0.00800","213059.49706376","1690256360.386301"],["0.00700","1000.00000000","1689869458.464975"],["0.00600","4000.00000000","1690221333.528698"],["0.00100","245000.00000000","1690051368.753455"]]},"book-10","GST/EUR"]`, `[8912,{"b":[["0.01000","60583.48958963","1690256620.206768"],["0.01000","63083.48958963","1690256620.206783"]],"c":"69619317"},"book-10","GST/EUR"]`, } func TestWsOrderbookMax10Depth(t *testing.T) { t.Parallel() + k := new(Kraken) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes + require.NoError(t, testexch.Setup(k), "Setup Instance must not error") + pairs := currency.Pairs{ + currency.NewPairWithDelimiter("XDG", "USD", "/"), + currency.NewPairWithDelimiter("LUNA", "EUR", "/"), + currency.NewPairWithDelimiter("GST", "EUR", "/"), + } + for _, p := range pairs { + err := k.Websocket.AddSuccessfulSubscriptions(&subscription.Subscription{ + Channel: subscription.OrderbookChannel, + Pairs: currency.Pairs{p}, + Asset: asset.Spot, + Levels: 10, + }) + require.NoError(t, err, "AddSuccessfulSubscriptions must not error") + } + for x := range websocketXDGUSDOrderbookUpdates { err := k.wsHandleData([]byte(websocketXDGUSDOrderbookUpdates[x])) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err, "wsHandleData should not error") } for x := range websocketLUNAEUROrderbookUpdates { @@ -2032,17 +1459,15 @@ func TestWsOrderbookMax10Depth(t *testing.T) { // storage and checksum calc. Might need to store raw strings as fields // in the orderbook.Tranche struct. // Required checksum: 7465000014735432016076747100005084881400000007476000097005027047670474990000293338023886300750000004333333333333375020000152914844934167507000014652990542161752500007370728572000475400000670061645671407546000098022663603417745900007102987806720745800001593557686404000745200003375861179634000743500003156650585902777434000030172726079999999743200006461149653837000743100001042285966000000074300000403660461058200074200000369021657320475740500001674242117790510 - if err != nil && x != len(websocketLUNAEUROrderbookUpdates)-1 { - t.Fatal(err) + if x != len(websocketLUNAEUROrderbookUpdates)-1 { + require.NoError(t, err, "wsHandleData should not error") } } // This has less than 10 bids and still needs a checksum calc. for x := range websocketGSTEUROrderbookUpdates { err := k.wsHandleData([]byte(websocketGSTEUROrderbookUpdates[x])) - if err != nil { - t.Fatal(err) - } + require.NoError(t, err, "wsHandleData should not error") } } @@ -2267,3 +1692,16 @@ func TestGetFuturesErr(t *testing.T) { assert.ErrorContains(t, err, "3 goat", "JSON with both error and errors should error correctly") assert.ErrorContains(t, err, "too many goat", "JSON both error and with errors should error correctly") } + +func TestEnforceStandardChannelNames(t *testing.T) { + for _, n := range []string{ + krakenWsSpread, krakenWsTicker, subscription.TickerChannel, subscription.OrderbookChannel, subscription.CandlesChannel, + subscription.AllTradesChannel, subscription.MyTradesChannel, subscription.MyOrdersChannel, + } { + assert.NoError(t, enforceStandardChannelNames(&subscription.Subscription{Channel: n}), "Standard channel names and bespoke names should not error") + } + for _, n := range []string{krakenWsOrderbook, krakenWsOHLC, krakenWsTrade, krakenWsOwnTrades, krakenWsOpenOrders, krakenWsOrderbook + "-5"} { + err := enforceStandardChannelNames(&subscription.Subscription{Channel: n}) + assert.ErrorIsf(t, err, subscription.ErrPrivateChannelName, "Private channel names should not be allowed for %s", n) + } +} diff --git a/exchanges/kraken/kraken_types.go b/exchanges/kraken/kraken_types.go index 595f71dd..68256c30 100644 --- a/exchanges/kraken/kraken_types.go +++ b/exchanges/kraken/kraken_types.go @@ -10,7 +10,6 @@ import ( "github.com/thrasher-corp/gocryptotrader/common" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/order" - "github.com/thrasher-corp/gocryptotrader/exchanges/subscription" "github.com/thrasher-corp/gocryptotrader/types" ) @@ -77,10 +76,18 @@ const ( statusOpen = "open" krakenFormat = "2006-01-02T15:04:05.000Z" + + // ChannelOrderbookDepthKey configures the orderbook depth in stream.ChannelSubscription.Params + ChannelOrderbookDepthKey = "_depth" + // ChannelCandlesTimeframeKey configures the candle bar timeframe in stream.ChannelSubscription.Params + ChannelCandlesTimeframeKey = "_timeframe" ) var ( assetTranslator assetTranslatorStore + + errNoWebsocketOrderbookData = errors.New("no websocket orderbook data") + errBadChannelSuffix = errors.New("bad websocket channel suffix") ) // GenericResponse stores general response data for functions that only return success @@ -492,43 +499,29 @@ type WithdrawStatusResponse struct { Status string `json:"status"` } -// WebsocketSubscriptionEventRequest handles WS subscription events -type WebsocketSubscriptionEventRequest struct { - Event string `json:"event"` // subscribe - RequestID int64 `json:"reqid,omitempty"` // Optional, client originated ID reflected in response message. - Pairs []string `json:"pair,omitempty"` // Array of currency pairs (pair1,pair2,pair3). +// WebsocketSubRequest contains request data for Subscribe/Unsubscribe to channels +type WebsocketSubRequest struct { + Event string `json:"event"` + RequestID int64 `json:"reqid,omitempty"` + Pairs []string `json:"pair,omitempty"` Subscription WebsocketSubscriptionData `json:"subscription,omitempty"` - Channels subscription.List `json:"-"` // Keeps track of associated subscriptions in batched outgoings -} - -// WebsocketBaseEventRequest Just has an "event" property -type WebsocketBaseEventRequest struct { - Event string `json:"event"` // eg "unsubscribe" -} - -// WebsocketUnsubscribeByChannelIDEventRequest handles WS unsubscribe events -type WebsocketUnsubscribeByChannelIDEventRequest struct { - WebsocketBaseEventRequest - RequestID int64 `json:"reqid,omitempty"` // Optional, client originated ID reflected in response message. - Pairs []string `json:"pair,omitempty"` // Array of currency pairs (pair1,pair2,pair3). - ChannelID int64 `json:"channelID,omitempty"` } // WebsocketSubscriptionData contains details on WS channel type WebsocketSubscriptionData struct { Name string `json:"name,omitempty"` // ticker|ohlc|trade|book|spread|*, * for all (ohlc interval value is 1 if all channels subscribed) - Interval int64 `json:"interval,omitempty"` // Optional - Time interval associated with ohlc subscription in minutes. Default 1. Valid Interval values: 1|5|15|30|60|240|1440|10080|21600 - Depth int64 `json:"depth,omitempty"` // Optional - depth associated with book subscription in number of levels each side, default 10. Valid Options are: 10, 25, 100, 500, 1000 - Token string `json:"token,omitempty"` // Optional used for authenticated requests + Interval int `json:"interval,omitempty"` // Optional - Timeframe for candles subscription in minutes; default 1. Valid: 1|5|15|30|60|240|1440|10080|21600 + Depth int `json:"depth,omitempty"` // Optional - Depth associated with orderbook; default 10. Valid: 10|25|100|500|1000 + Token string `json:"token,omitempty"` // Optional - Token for authenticated channels } // WebsocketEventResponse holds all data response types type WebsocketEventResponse struct { - WebsocketBaseEventRequest + Event string `json:"event"` Status string `json:"status"` Pair currency.Pair `json:"pair,omitempty"` - RequestID int64 `json:"reqid,omitempty"` // Optional, client originated ID reflected in response message. + RequestID int64 `json:"reqid,omitempty"` Subscription WebsocketSubscriptionResponseData `json:"subscription,omitempty"` ChannelName string `json:"channelName,omitempty"` WebsocketSubscriptionEventResponse @@ -545,23 +538,11 @@ type WebsocketSubscriptionResponseData struct { Name string `json:"name"` } -// WebsocketDataResponse defines a websocket data type -type WebsocketDataResponse []interface{} - // WebsocketErrorResponse defines a websocket error response type WebsocketErrorResponse struct { ErrorMessage string `json:"errorMessage"` } -// WebsocketChannelData Holds relevant data for channels to identify what we're -// doing -type WebsocketChannelData struct { - Subscription string - Pair currency.Pair - ChannelID *int64 - MaxDepth int -} - // WsTokenResponse holds the WS auth token type WsTokenResponse struct { Expires int64 `json:"expires"` @@ -575,21 +556,6 @@ type wsSystemStatus struct { Version string `json:"version"` } -type wsSubscription struct { - ChannelID *int64 `json:"channelID"` - ChannelName string `json:"channelName"` - ErrorMessage string `json:"errorMessage"` - Event string `json:"event"` - Pair string `json:"pair"` - RequestID int64 `json:"reqid"` - Status string `json:"status"` - Subscription struct { - Depth int `json:"depth"` - Interval int `json:"interval"` - Name string `json:"name"` - } `json:"subscription"` -} - // WsOpenOrder contains all open order data from ws feed type WsOpenOrder struct { UserReferenceID int64 `json:"userref"` diff --git a/exchanges/kraken/kraken_websocket.go b/exchanges/kraken/kraken_websocket.go index 6ae5cbf9..a964bd28 100644 --- a/exchanges/kraken/kraken_websocket.go +++ b/exchanges/kraken/kraken_websocket.go @@ -1,6 +1,7 @@ package kraken import ( + "bytes" "context" "encoding/json" "errors" @@ -9,7 +10,7 @@ import ( "net/http" "strconv" "strings" - "sync" + "text/template" "time" "github.com/buger/jsonparser" @@ -18,6 +19,7 @@ import ( "github.com/thrasher-corp/gocryptotrader/common/convert" "github.com/thrasher-corp/gocryptotrader/currency" "github.com/thrasher-corp/gocryptotrader/exchanges/asset" + "github.com/thrasher-corp/gocryptotrader/exchanges/kline" "github.com/thrasher-corp/gocryptotrader/exchanges/order" "github.com/thrasher-corp/gocryptotrader/exchanges/orderbook" "github.com/thrasher-corp/gocryptotrader/exchanges/request" @@ -34,12 +36,15 @@ const ( krakenAuthWSURL = "wss://ws-auth.kraken.com" krakenWSSandboxURL = "wss://sandbox.kraken.com" krakenWSSupportedVersion = "1.4.0" - // WS endpoints + + // Websocket Channels krakenWsHeartbeat = "heartbeat" krakenWsSystemStatus = "systemStatus" krakenWsSubscribe = "subscribe" - krakenWsSubscriptionStatus = "subscriptionStatus" krakenWsUnsubscribe = "unsubscribe" + krakenWsSubscribed = "subscribed" + krakenWsUnsubscribed = "unsubscribed" + krakenWsSubscriptionStatus = "subscriptionStatus" krakenWsTicker = "ticker" krakenWsOHLC = "ohlc" krakenWsTrade = "trade" @@ -54,30 +59,40 @@ const ( krakenWsCancelOrderStatus = "cancelOrderStatus" krakenWsCancelAllOrderStatus = "cancelAllStatus" krakenWsPingDelay = time.Second * 27 - krakenWsOrderbookDepth = 1000 ) -// orderbookMutex Ensures if two entries arrive at once, only one can be -// processed at a time +var channelNames = map[string]string{ + subscription.TickerChannel: krakenWsTicker, + subscription.OrderbookChannel: krakenWsOrderbook, + subscription.CandlesChannel: krakenWsOHLC, + subscription.AllTradesChannel: krakenWsTrade, + subscription.MyTradesChannel: krakenWsOwnTrades, + subscription.MyOrdersChannel: krakenWsOpenOrders, +} +var reverseChannelNames = map[string]string{} + +func init() { + for k, v := range channelNames { + reverseChannelNames[v] = k + } +} + var ( - subscriptionChannelPair []WebsocketChannelData - authToken string - pingRequest = WebsocketBaseEventRequest{Event: stream.Ping} - m sync.Mutex - errNoWebsocketOrderbookData = errors.New("no websocket orderbook data") - errParsingWSField = errors.New("error parsing WS field") - errCancellingOrder = errors.New("error cancelling order") + authToken string + errParsingWSField = errors.New("error parsing WS field") + errCancellingOrder = errors.New("error cancelling order") + errSubPairMissing = errors.New("pair missing from subscription response") + errInvalidChecksum = errors.New("invalid checksum") ) -// Channels require a topic and a currency -// Format [[ticker,but-t4u],[orderbook,nce-btt]] -var defaultSubscribedChannels = []string{ - krakenWsTicker, - krakenWsTrade, - krakenWsOrderbook, - krakenWsOHLC, - krakenWsSpread} -var authenticatedChannels = []string{krakenWsOwnTrades, krakenWsOpenOrders} +var defaultSubscriptions = subscription.List{ + {Enabled: true, Asset: asset.Spot, Channel: subscription.TickerChannel}, + {Enabled: true, Asset: asset.Spot, Channel: subscription.AllTradesChannel}, + {Enabled: true, Asset: asset.Spot, Channel: subscription.CandlesChannel, Interval: kline.OneMin}, + {Enabled: true, Asset: asset.Spot, Channel: subscription.OrderbookChannel, Levels: 1000}, + {Enabled: true, Channel: subscription.MyOrdersChannel, Authenticated: true}, + {Enabled: true, Channel: subscription.MyTradesChannel, Authenticated: true}, +} // WsConnect initiates a websocket connection func (k *Kraken) WsConnect() error { @@ -116,24 +131,13 @@ func (k *Kraken) WsConnect() error { k.Websocket.SetCanUseAuthenticatedEndpoints(true) k.Websocket.Wg.Add(1) go k.wsFunnelConnectionData(k.Websocket.AuthConn, comms) - err = k.wsAuthPingHandler() - if err != nil { - log.Errorf(log.ExchangeSys, - "%v - failed setup ping handler for auth connection. Websocket may disconnect unexpectedly. %v\n", - k.Name, - err) - } + k.startWsPingHandler(k.Websocket.AuthConn) } } } - err = k.wsPingHandler() - if err != nil { - log.Errorf(log.ExchangeSys, - "%v - failed setup ping handler. Websocket may disconnect unexpectedly. %v\n", - k.Name, - err) - } + k.startWsPingHandler(k.Websocket.Conn) + return nil } @@ -163,10 +167,7 @@ func (k *Kraken) wsReadData(comms chan stream.Response) { select { case k.Websocket.DataHandler <- err: default: - log.Errorf(log.WebsocketMgr, - "%s websocket handle data error: %v", - k.Name, - err) + log.Errorf(log.WebsocketMgr, "%s websocket handle data error: %v", k.Name, err) } } default: @@ -175,9 +176,7 @@ func (k *Kraken) wsReadData(comms chan stream.Response) { case resp := <-comms: err := k.wsHandleData(resp.Raw) if err != nil { - k.Websocket.DataHandler <- fmt.Errorf("%s - unhandled websocket data: %v", - k.Name, - err) + k.Websocket.DataHandler <- err } } } @@ -185,254 +184,111 @@ func (k *Kraken) wsReadData(comms chan stream.Response) { func (k *Kraken) wsHandleData(respRaw []byte) error { if strings.HasPrefix(string(respRaw), "[") { - var dataResponse WebsocketDataResponse - err := json.Unmarshal(respRaw, &dataResponse) - if err != nil { + var msg []any + if err := json.Unmarshal(respRaw, &msg); err != nil { return err } - if _, ok := dataResponse[0].(float64); ok { - err = k.wsReadDataResponse(dataResponse) - if err != nil { + if len(msg) < 3 { + return fmt.Errorf("data array too short: %s", respRaw) + } + + // For all types of channel second to last field is the channel Name + c, ok := msg[len(msg)-2].(string) + if !ok { + return common.GetTypeAssertError("string", msg[len(msg)-2], "channelName") + } + + pair := currency.EMPTYPAIR + if maybePair, ok2 := msg[len(msg)-1].(string); ok2 { + var err error + if pair, err = currency.NewPairFromString(maybePair); err != nil { return err } } - if _, ok := dataResponse[1].(string); ok { - err = k.wsHandleAuthDataResponse(dataResponse) - if err != nil { - return err - } - } - } else { - var eventResponse map[string]interface{} - err := json.Unmarshal(respRaw, &eventResponse) - if err != nil { - return fmt.Errorf("%s - err %s could not parse websocket data: %s", k.Name, err, respRaw) - } - if event, ok := eventResponse["event"]; ok { - switch event { - case stream.Pong, krakenWsHeartbeat: - return nil - case krakenWsCancelOrderStatus: - id, err := jsonparser.GetInt(respRaw, "reqid") - if err != nil { - return fmt.Errorf("%w 'reqid': %w from message: %s", errParsingWSField, err, respRaw) - } - if !k.Websocket.Match.IncomingWithData(id, respRaw) { - return fmt.Errorf("%v cancel order listener not found", id) - } - case krakenWsCancelAllOrderStatus: - var status WsCancelOrderResponse - err := json.Unmarshal(respRaw, &status) - if err != nil { - return fmt.Errorf("%s - err %s unable to parse WsCancelOrderResponse: %s", - k.Name, - err, - respRaw) - } + return k.wsReadDataResponse(c, pair, msg) + } - var isChannelExist bool - if status.RequestID > 0 { - isChannelExist = k.Websocket.Match.IncomingWithData(status.RequestID, respRaw) - } + event, err := jsonparser.GetString(respRaw, "event") + if err != nil { + return fmt.Errorf("%w parsing: %s", err, respRaw) + } - if status.Status == "error" { - return fmt.Errorf("%v Websocket status for RequestID %d: '%v'", - k.Name, - status.RequestID, - status.ErrorMessage) - } + if event == krakenWsSubscriptionStatus { // Must happen before IncomingWithData to avoid race + k.wsProcessSubStatus(respRaw) + } - if !isChannelExist && status.RequestID > 0 { - return fmt.Errorf("can't send ws incoming data to Matched channel with RequestID: %d", - status.RequestID) - } - case krakenWsSystemStatus: - var systemStatus wsSystemStatus - err := json.Unmarshal(respRaw, &systemStatus) - if err != nil { - return fmt.Errorf("%s - err %s unable to parse system status response: %s", - k.Name, - err, - respRaw) - } - if systemStatus.Status != "online" { - k.Websocket.DataHandler <- fmt.Errorf("%v Websocket status '%v'", - k.Name, - systemStatus.Status) - } - if systemStatus.Version > krakenWSSupportedVersion { - log.Warnf(log.ExchangeSys, - "%v New version of Websocket API released. Was %v Now %v", - k.Name, - krakenWSSupportedVersion, - systemStatus.Version) - } - case krakenWsAddOrderStatus: - var status WsAddOrderResponse - err := json.Unmarshal(respRaw, &status) - if err != nil { - return fmt.Errorf("%s - err %s unable to parse add order response: %s", - k.Name, - err, - respRaw) - } + reqID, err := jsonparser.GetInt(respRaw, "reqid") + if err == nil && reqID != 0 && k.Websocket.Match.IncomingWithData(reqID, respRaw) { + return nil + } - var isChannelExist bool - if status.RequestID > 0 { - isChannelExist = k.Websocket.Match.IncomingWithData(status.RequestID, respRaw) - } + if event == "" { + return nil + } - if status.Status == "error" { - return fmt.Errorf("%v Websocket status for RequestID %d: '%v'", - k.Name, - status.RequestID, - status.ErrorMessage) - } - - k.Websocket.DataHandler <- &order.Detail{ - Exchange: k.Name, - OrderID: status.TransactionID, - Status: order.New, - } - - if !isChannelExist && status.RequestID > 0 { - return fmt.Errorf("can't send ws incoming data to Matched channel with RequestID: %d", - status.RequestID) - } - case krakenWsSubscriptionStatus: - var sub wsSubscription - err := json.Unmarshal(respRaw, &sub) - if err != nil { - return fmt.Errorf("%s - err %s unable to parse subscription response: %s", - k.Name, - err, - respRaw) - } - if sub.Status != "subscribed" && sub.Status != "unsubscribed" { - return fmt.Errorf("%v %v %v", - k.Name, - sub.RequestID, - sub.ErrorMessage) - } - k.addNewSubscriptionChannelData(&sub) - if sub.RequestID > 0 { - k.Websocket.Match.IncomingWithData(sub.RequestID, respRaw) - } - default: - k.Websocket.DataHandler <- stream.UnhandledMessageWarning{ - Message: k.Name + stream.UnhandledMessage + string(respRaw), - } - } - return nil + switch event { + case stream.Pong, krakenWsHeartbeat: + return nil + case krakenWsCancelOrderStatus, krakenWsCancelAllOrderStatus, krakenWsAddOrderStatus, krakenWsSubscriptionStatus: + // All of these should have found a listener already + return fmt.Errorf("%w: %s %v", stream.ErrNoMessageListener, event, reqID) + case krakenWsSystemStatus: + return k.wsProcessSystemStatus(respRaw) + default: + k.Websocket.DataHandler <- stream.UnhandledMessageWarning{ + Message: fmt.Sprintf("%s: %s", stream.UnhandledMessage, respRaw), } } + return nil } -// wsPingHandler sends a message "ping" every 27 to maintain the connection to the websocket -func (k *Kraken) wsPingHandler() error { - message, err := json.Marshal(pingRequest) - if err != nil { - return err - } - k.Websocket.Conn.SetupPingHandler(request.Unset, stream.PingHandler{ - Message: message, +// startWsPingHandler sets up a websocket ping handler to maintain a connection +func (k *Kraken) startWsPingHandler(conn stream.Connection) { + conn.SetupPingHandler(request.Unset, stream.PingHandler{ + Message: []byte(`{"event":"ping"}`), Delay: krakenWsPingDelay, MessageType: websocket.TextMessage, }) - return nil -} - -// wsAuthPingHandler sends a message "ping" every 27 to maintain the connection to the websocket -func (k *Kraken) wsAuthPingHandler() error { - message, err := json.Marshal(pingRequest) - if err != nil { - return err - } - k.Websocket.AuthConn.SetupPingHandler(request.Unset, stream.PingHandler{ - Message: message, - Delay: krakenWsPingDelay, - MessageType: websocket.TextMessage, - }) - return nil } // wsReadDataResponse classifies the WS response and sends to appropriate handler -func (k *Kraken) wsReadDataResponse(response WebsocketDataResponse) error { - if cID, ok := response[0].(float64); ok { - channelID := int64(cID) - channelData, err := getSubscriptionChannelData(channelID) - if err != nil { - return err - } - switch channelData.Subscription { - case krakenWsTicker: - t, ok := response[1].(map[string]interface{}) - if !ok { - return errors.New("received invalid ticker data") - } - return k.wsProcessTickers(&channelData, t) - case krakenWsOHLC: - o, ok := response[1].([]interface{}) - if !ok { - return errors.New("received invalid OHLCV data") - } - return k.wsProcessCandles(&channelData, o) - case krakenWsOrderbook: - ob, ok := response[1].(map[string]interface{}) - if !ok { - return errors.New("received invalid orderbook data") - } - - if len(response) == 5 { - ob2, okob2 := response[2].(map[string]interface{}) - if !okob2 { - return errors.New("received invalid orderbook data") - } - - // Squish both maps together to process - for k, v := range ob2 { - if _, ok := ob[k]; ok { - return errors.New("cannot merge maps, conflict is present") - } - ob[k] = v - } - } - return k.wsProcessOrderBook(&channelData, ob) - case krakenWsSpread: - s, ok := response[1].([]interface{}) - if !ok { - return errors.New("received invalid spread data") - } - k.wsProcessSpread(&channelData, s) - case krakenWsTrade: - t, ok := response[1].([]interface{}) - if !ok { - return errors.New("received invalid trade data") - } - return k.wsProcessTrades(&channelData, t) - default: - return fmt.Errorf("%s received unidentified data for subscription %s: %+v", - k.Name, - channelData.Subscription, - response) - } +func (k *Kraken) wsReadDataResponse(c string, pair currency.Pair, response []any) error { + switch c { + case krakenWsTicker: + return k.wsProcessTickers(response, pair) + case krakenWsSpread: + return k.wsProcessSpread(response, pair) + case krakenWsTrade: + return k.wsProcessTrades(response, pair) + case krakenWsOwnTrades: + return k.wsProcessOwnTrades(response[0]) + case krakenWsOpenOrders: + return k.wsProcessOpenOrders(response[0]) } - return nil + channelType := strings.TrimRight(c, "-0123456789") + switch channelType { + case krakenWsOHLC: + return k.wsProcessCandle(c, response, pair) + case krakenWsOrderbook: + return k.wsProcessOrderBook(c, response, pair) + default: + return fmt.Errorf("received unidentified data for subscription %s: %+v", c, response) + } } -func (k *Kraken) wsHandleAuthDataResponse(response WebsocketDataResponse) error { - if chName, ok := response[1].(string); ok { - switch chName { - case krakenWsOwnTrades: - return k.wsProcessOwnTrades(response[0]) - case krakenWsOpenOrders: - return k.wsProcessOpenOrders(response[0]) - default: - return fmt.Errorf("%v Unidentified websocket data received: %+v", - k.Name, response) - } +func (k *Kraken) wsProcessSystemStatus(respRaw []byte) error { + var systemStatus wsSystemStatus + err := json.Unmarshal(respRaw, &systemStatus) + if err != nil { + return fmt.Errorf("%s parsing system status: %s", err, respRaw) + } + if systemStatus.Status != "online" { + k.Websocket.DataHandler <- fmt.Errorf("system status not online: %v", systemStatus.Status) + } + if systemStatus.Version > krakenWSSupportedVersion { + log.Warnf(log.ExchangeSys, "%v New version of Websocket API released. Was %v Now %v", k.Name, krakenWSSupportedVersion, systemStatus.Version) } return nil } @@ -582,156 +438,99 @@ func (k *Kraken) wsProcessOpenOrders(ownOrders interface{}) error { } return nil } - return errors.New(k.Name + " - Invalid own trades data") -} - -// addNewSubscriptionChannelData stores channel ids, pairs and subscription types to an array -// allowing correlation between subscriptions and returned data -func (k *Kraken) addNewSubscriptionChannelData(response *wsSubscription) { - // We change the / to - to maintain compatibility with REST/config - var pair, fPair currency.Pair - var err error - if response.Pair != "" { - pair, err = currency.NewPairFromString(response.Pair) - if err != nil { - log.Errorf(log.ExchangeSys, "%s exchange error: %s", k.Name, err) - return - } - fPair, err = k.FormatExchangeCurrency(pair, asset.Spot) - if err != nil { - log.Errorf(log.ExchangeSys, "%s exchange error: %s", k.Name, err) - return - } - } - - maxDepth := 0 - if splits := strings.Split(response.ChannelName, "-"); len(splits) > 1 { - maxDepth, err = strconv.Atoi(splits[1]) - if err != nil { - log.Errorf(log.ExchangeSys, "%s exchange error: %s", k.Name, err) - } - } - m.Lock() - defer m.Unlock() - subscriptionChannelPair = append(subscriptionChannelPair, WebsocketChannelData{ - Subscription: response.Subscription.Name, - Pair: fPair, - ChannelID: response.ChannelID, - MaxDepth: maxDepth, - }) -} - -// getSubscriptionChannelData retrieves WebsocketChannelData based on response ID -func getSubscriptionChannelData(id int64) (WebsocketChannelData, error) { - m.Lock() - defer m.Unlock() - for i := range subscriptionChannelPair { - if subscriptionChannelPair[i].ChannelID == nil { - continue - } - if id == *subscriptionChannelPair[i].ChannelID { - return subscriptionChannelPair[i], nil - } - } - return WebsocketChannelData{}, - fmt.Errorf("could not get subscription data for id %d", id) + return errors.New("invalid own trades data") } // wsProcessTickers converts ticker data and sends it to the datahandler -func (k *Kraken) wsProcessTickers(channelData *WebsocketChannelData, data map[string]interface{}) error { - closePrice, err := strconv.ParseFloat(data["c"].([]interface{})[0].(string), 64) - if err != nil { - return err +func (k *Kraken) wsProcessTickers(response []any, pair currency.Pair) error { + t, ok := response[1].(map[string]any) + if !ok { + return errors.New("received invalid ticker data") } - openPrice, err := strconv.ParseFloat(data["o"].([]interface{})[0].(string), 64) - if err != nil { - return err - } - highPrice, err := strconv.ParseFloat(data["h"].([]interface{})[0].(string), 64) - if err != nil { - return err - } - lowPrice, err := strconv.ParseFloat(data["l"].([]interface{})[0].(string), 64) - if err != nil { - return err - } - quantity, err := strconv.ParseFloat(data["v"].([]interface{})[0].(string), 64) - if err != nil { - return err - } - ask, err := strconv.ParseFloat(data["a"].([]interface{})[0].(string), 64) - if err != nil { - return err - } - bid, err := strconv.ParseFloat(data["b"].([]interface{})[0].(string), 64) - if err != nil { - return err + data := map[string]float64{} + for _, b := range []byte("abcvlho") { // p and t skipped + key := string(b) + a, ok := t[key].([]any) + if !ok { + return fmt.Errorf("received invalid ticker data: %w", common.GetTypeAssertError("[]any", t[key], "ticker."+key)) + } + var s string + if s, ok = a[0].(string); !ok { + return fmt.Errorf("received invalid ticker data: %w", common.GetTypeAssertError("string", a[0], "ticker."+key+"[0]")) + } + + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return fmt.Errorf("received invalid ticker data: %w", err) + } + data[key] = f } k.Websocket.DataHandler <- &ticker.Price{ ExchangeName: k.Name, - Open: openPrice, - Close: closePrice, - Volume: quantity, - High: highPrice, - Low: lowPrice, - Bid: bid, - Ask: ask, + Ask: data["a"], + Bid: data["b"], + Close: data["c"], + Volume: data["v"], + Low: data["l"], + High: data["h"], + Open: data["o"], AssetType: asset.Spot, - Pair: channelData.Pair, + Pair: pair, } return nil } // wsProcessSpread converts spread/orderbook data and sends it to the datahandler -func (k *Kraken) wsProcessSpread(channelData *WebsocketChannelData, data []interface{}) { +func (k *Kraken) wsProcessSpread(response []any, pair currency.Pair) error { + data, ok := response[1].([]any) + if !ok { + return errors.New("received invalid spread data") + } if len(data) < 5 { - k.Websocket.DataHandler <- fmt.Errorf("%s unexpected wsProcessSpread data length", k.Name) - return + return errors.New("unexpected wsProcessSpread data length") } bestBid, ok := data[0].(string) if !ok { - k.Websocket.DataHandler <- fmt.Errorf("%s wsProcessSpread: unable to type assert bestBid", k.Name) - return + return errors.New("wsProcessSpread: unable to type assert bestBid") } bestAsk, ok := data[1].(string) if !ok { - k.Websocket.DataHandler <- fmt.Errorf("%s wsProcessSpread: unable to type assert bestAsk", k.Name) - return + return errors.New("wsProcessSpread: unable to type assert bestAsk") } timeData, err := strconv.ParseFloat(data[2].(string), 64) if err != nil { - k.Websocket.DataHandler <- fmt.Errorf("%s wsProcessSpread: unable to parse timeData. Error: %s", - k.Name, - err) - return + return fmt.Errorf("wsProcessSpread: unable to parse timeData: %w", err) } bidVolume, ok := data[3].(string) if !ok { - k.Websocket.DataHandler <- fmt.Errorf("%s wsProcessSpread: unable to type assert bidVolume", k.Name) - return + return errors.New("wsProcessSpread: unable to type assert bidVolume") } askVolume, ok := data[4].(string) if !ok { - k.Websocket.DataHandler <- fmt.Errorf("%s wsProcessSpread: unable to type assert askVolume", k.Name) - return + return errors.New("wsProcessSpread: unable to type assert askVolume") } if k.Verbose { log.Debugf(log.ExchangeSys, "%v Spread data for '%v' received. Best bid: '%v' Best ask: '%v' Time: '%v', Bid volume '%v', Ask volume '%v'", k.Name, - channelData.Pair, + pair, bestBid, bestAsk, convert.TimeFromUnixTimestampDecimal(timeData), bidVolume, askVolume) } + return nil } // wsProcessTrades converts trade data and sends it to the datahandler -func (k *Kraken) wsProcessTrades(channelData *WebsocketChannelData, data []interface{}) error { +func (k *Kraken) wsProcessTrades(response []any, pair currency.Pair) error { + data, ok := response[1].([]any) + if !ok { + return errors.New("received invalid trade data") + } if !k.IsSaveTradeDataEnabled() { return nil } @@ -766,7 +565,7 @@ func (k *Kraken) wsProcessTrades(channelData *WebsocketChannelData, data []inter trades[i] = trade.Data{ AssetType: asset.Spot, - CurrencyPair: channelData.Pair, + CurrencyPair: pair, Exchange: k.Name, Price: price, Amount: amount, @@ -777,63 +576,84 @@ func (k *Kraken) wsProcessTrades(channelData *WebsocketChannelData, data []inter return trade.AddTradesToBuffer(k.Name, trades...) } -// wsProcessOrderBook determines if the orderbook data is partial or update -// Then sends to appropriate fun -func (k *Kraken) wsProcessOrderBook(channelData *WebsocketChannelData, data map[string]interface{}) error { +// wsProcessOrderBook handles both partial and full orderbook updates +func (k *Kraken) wsProcessOrderBook(c string, response []any, pair currency.Pair) error { + key := &subscription.Subscription{ + Channel: c, + Asset: asset.Spot, + Pairs: currency.Pairs{pair}, + } + if err := fqChannelNameSub(key); err != nil { + return err + } + s := k.Websocket.GetSubscription(key) + if s == nil { + return fmt.Errorf("%w: %s %s %s", subscription.ErrNotFound, asset.Spot, c, pair) + } + if s.State() == subscription.UnsubscribingState { + // We only care if it's currently unsubscribing + return nil + } + + ob, ok := response[1].(map[string]any) + if !ok { + return errors.New("received invalid orderbook data") + } + + if len(response) == 5 { + ob2, ok2 := response[2].(map[string]any) + if !ok2 { + return errors.New("received invalid orderbook data") + } + + // Squish both maps together to process + for k, v := range ob2 { + if _, ok := ob[k]; ok { + return errors.New("cannot merge maps, conflict is present") + } + ob[k] = v + } + } // NOTE: Updates are a priority so check if it's an update first as we don't // need multiple map lookups to check for snapshot. - askData, asksExist := data["a"].([]interface{}) - bidData, bidsExist := data["b"].([]interface{}) + askData, asksExist := ob["a"].([]interface{}) + bidData, bidsExist := ob["b"].([]interface{}) if asksExist || bidsExist { - checksum, ok := data["c"].(string) + checksum, ok := ob["c"].(string) if !ok { return errors.New("could not process orderbook update checksum not found") } - k.wsRequestMtx.Lock() - defer k.wsRequestMtx.Unlock() - err := k.wsProcessOrderBookUpdate(channelData, askData, bidData, checksum) - if err != nil { - outbound := channelData.Pair // Format required "XBT/USD" - outbound.Delimiter = "/" - go func(resub *subscription.Subscription) { - // This was locking the main websocket reader routine and a - // backlog occurred. So put this into it's own go routine. - errResub := k.Websocket.ResubscribeToChannel(resub) - if errResub != nil { - log.Errorf(log.WebsocketMgr, - "resubscription failure for %v: %v", - resub, - errResub) + err := k.wsProcessOrderBookUpdate(pair, askData, bidData, checksum) + if errors.Is(err, errInvalidChecksum) { + log.Debugf(log.Global, "%s Resubscribing to invalid %s orderbook", k.Name, pair) + go func() { + if e2 := k.Websocket.ResubscribeToChannel(s); e2 != nil && !errors.Is(e2, subscription.ErrInStateAlready) { + log.Errorf(log.ExchangeSys, "%s resubscription failure for %v: %v", k.Name, pair, e2) } - }(&subscription.Subscription{ - Channel: krakenWsOrderbook, - Pairs: currency.Pairs{outbound}, - Asset: asset.Spot, - }) - return err + }() } - return nil + return err } - askSnapshot, askSnapshotExists := data["as"].([]interface{}) - bidSnapshot, bidSnapshotExists := data["bs"].([]interface{}) + askSnapshot, askSnapshotExists := ob["as"].([]interface{}) + bidSnapshot, bidSnapshotExists := ob["bs"].([]interface{}) if !askSnapshotExists && !bidSnapshotExists { - return fmt.Errorf("%w for %v %v", errNoWebsocketOrderbookData, channelData.Pair, asset.Spot) + return fmt.Errorf("%w for %v %v", errNoWebsocketOrderbookData, pair, asset.Spot) } - return k.wsProcessOrderBookPartial(channelData, askSnapshot, bidSnapshot) + return k.wsProcessOrderBookPartial(pair, askSnapshot, bidSnapshot, key.Levels) } // wsProcessOrderBookPartial creates a new orderbook entry for a given currency pair -func (k *Kraken) wsProcessOrderBookPartial(channelData *WebsocketChannelData, askData, bidData []interface{}) error { +func (k *Kraken) wsProcessOrderBookPartial(pair currency.Pair, askData, bidData []any, levels int) error { base := orderbook.Base{ - Pair: channelData.Pair, + Pair: pair, Asset: asset.Spot, VerifyOrderbook: k.CanVerifyOrderbook, Bids: make(orderbook.Tranches, len(bidData)), Asks: make(orderbook.Tranches, len(askData)), - MaxDepth: channelData.MaxDepth, + MaxDepth: levels, ChecksumStringRequired: true, } // Kraken ob data is timestamped per price, GCT orderbook data is @@ -935,10 +755,10 @@ func (k *Kraken) wsProcessOrderBookPartial(channelData *WebsocketChannelData, as } // wsProcessOrderBookUpdate updates an orderbook entry for a given currency pair -func (k *Kraken) wsProcessOrderBookUpdate(channelData *WebsocketChannelData, askData, bidData []interface{}, checksum string) error { +func (k *Kraken) wsProcessOrderBookUpdate(pair currency.Pair, askData, bidData []any, checksum string) error { update := orderbook.Update{ Asset: asset.Spot, - Pair: channelData.Pair, + Pair: pair, Bids: make([]orderbook.Tranche, len(bidData)), Asks: make([]orderbook.Tranche, len(askData)), } @@ -1054,12 +874,9 @@ func (k *Kraken) wsProcessOrderBookUpdate(channelData *WebsocketChannelData, ask return err } - book, err := k.Websocket.Orderbook.GetOrderbook(channelData.Pair, asset.Spot) + book, err := k.Websocket.Orderbook.GetOrderbook(pair, asset.Spot) if err != nil { - return fmt.Errorf("cannot calculate websocket checksum: book not found for %s %s %w", - channelData.Pair, - asset.Spot, - err) + return fmt.Errorf("cannot calculate websocket checksum: book not found for %s %s %w", pair, asset.Spot, err) } token, err := strconv.ParseInt(checksum, 10, 64) @@ -1071,6 +888,9 @@ func (k *Kraken) wsProcessOrderBookUpdate(channelData *WebsocketChannelData, ask } func validateCRC32(b *orderbook.Base, token uint32) error { + if b == nil { + return common.ErrNilPointer + } var checkStr strings.Builder for i := 0; i < 10 && i < len(b.Asks); i++ { _, err := checkStr.WriteString(trim(b.Asks[i].StrPrice + trim(b.Asks[i].StrAmount))) @@ -1087,11 +907,7 @@ func validateCRC32(b *orderbook.Base, token uint32) error { } if check := crc32.ChecksumIEEE([]byte(checkStr.String())); check != token { - return fmt.Errorf("%s %s invalid checksum %d, expected %d", - b.Pair, - b.Asset, - check, - token) + return fmt.Errorf("%s %s %w %d, expected %d", b.Pair, b.Asset, errInvalidChecksum, check, token) } return nil } @@ -1103,212 +919,368 @@ func trim(s string) string { return s } -// wsProcessCandles converts candle data and sends it to the data handler -func (k *Kraken) wsProcessCandles(channelData *WebsocketChannelData, data []interface{}) error { - startTime, err := strconv.ParseFloat(data[0].(string), 64) - if err != nil { - return err +// wsProcessCandle converts candle data and sends it to the data handler +func (k *Kraken) wsProcessCandle(c string, resp []any, pair currency.Pair) error { + // 8 string quoted floats followed by 1 integer for trade count + dataRaw, ok := resp[1].([]any) + if !ok || len(dataRaw) != 9 { + return errors.New("received invalid candle data") + } + data := make([]float64, 8) + for i := range 8 { + s, ok := dataRaw[i].(string) + if !ok { + return fmt.Errorf("received invalid candle data: %w", common.GetTypeAssertError("string", dataRaw[i], "candle-data")) + } + + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return fmt.Errorf("received invalid candle data: %w", err) + } + data[i] = f } - endTime, err := strconv.ParseFloat(data[1].(string), 64) - if err != nil { - return err - } - - openPrice, err := strconv.ParseFloat(data[2].(string), 64) - if err != nil { - return err - } - - highPrice, err := strconv.ParseFloat(data[3].(string), 64) - if err != nil { - return err - } - - lowPrice, err := strconv.ParseFloat(data[4].(string), 64) - if err != nil { - return err - } - - closePrice, err := strconv.ParseFloat(data[5].(string), 64) - if err != nil { - return err - } - - volume, err := strconv.ParseFloat(data[7].(string), 64) - if err != nil { - return err + // Faster than getting it through the subscription + parts := strings.Split(c, "-") + if len(parts) != 2 { + return errBadChannelSuffix } + interval := parts[1] k.Websocket.DataHandler <- stream.KlineData{ - AssetType: asset.Spot, - Pair: channelData.Pair, - Timestamp: time.Now(), - Exchange: k.Name, - StartTime: convert.TimeFromUnixTimestampDecimal(startTime), - CloseTime: convert.TimeFromUnixTimestampDecimal(endTime), - // Candles are sent every 60 seconds - Interval: "60", - HighPrice: highPrice, - LowPrice: lowPrice, - OpenPrice: openPrice, - ClosePrice: closePrice, - Volume: volume, + AssetType: asset.Spot, + Pair: pair, + Timestamp: time.Now(), + Exchange: k.Name, + StartTime: convert.TimeFromUnixTimestampDecimal(data[0]), + CloseTime: convert.TimeFromUnixTimestampDecimal(data[1]), + OpenPrice: data[2], + HighPrice: data[3], + LowPrice: data[4], + ClosePrice: data[5], + Volume: data[7], + Interval: interval, } return nil } -// GenerateDefaultSubscriptions Adds default subscriptions to websocket to be handled by ManageSubscriptions() -func (k *Kraken) GenerateDefaultSubscriptions() (subscription.List, error) { - enabledPairs, err := k.GetEnabledPairs(asset.Spot) +// GetSubscriptionTemplate returns a subscription channel template +func (k *Kraken) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) { + return template.New("master.tmpl").Funcs(template.FuncMap{"channelName": channelName}).Parse(subTplText) +} + +func (k *Kraken) generateSubscriptions() (subscription.List, error) { + return k.Features.Subscriptions.ExpandTemplates(k) +} + +// Subscribe adds a channel subscription to the websocket +func (k *Kraken) Subscribe(in subscription.List) error { + in, errs := in.ExpandTemplates(k) + + // Collect valid new subs and add to websocket in Subscribing state + subs := subscription.List{} + for _, s := range in { + if s.State() != subscription.ResubscribingState { + if err := k.Websocket.AddSubscriptions(s); err != nil { + errs = common.AppendError(errs, fmt.Errorf("%w; Channel: %s Pairs: %s", err, s.Channel, s.Pairs.Join())) + continue + } + } + subs = append(subs, s) + } + + // Merge subs by grouping pairs for request; We make a single request to subscribe to N+ pairs, but get N+ responses back + groupedSubs := subs.GroupPairs() + + errs = common.AppendError(errs, + k.ParallelChanOp(groupedSubs, func(s subscription.List) error { return k.manageSubs(krakenWsSubscribe, s) }, 1), + ) + + for _, s := range subs { + if s.State() != subscription.SubscribedState { + _ = s.SetState(subscription.InactiveState) + if err := k.Websocket.RemoveSubscriptions(s); err != nil { + errs = common.AppendError(errs, fmt.Errorf("error removing failed subscription: %w; Channel: %s Pairs: %s", err, s.Channel, s.Pairs.Join())) + } + } + } + + return errs +} + +// Unsubscribe removes a channel subscriptions from the websocket +func (k *Kraken) Unsubscribe(keys subscription.List) error { + var errs error + // Make sure we have the concrete subscriptions, since we will change the state + subs := make(subscription.List, 0, len(keys)) + for _, key := range keys { + if s := k.Websocket.GetSubscription(key); s == nil { + errs = common.AppendError(errs, fmt.Errorf("%w; Channel: %s Pairs: %s", subscription.ErrNotFound, key.Channel, key.Pairs.Join())) + } else { + if s.State() != subscription.ResubscribingState { + if err := s.SetState(subscription.UnsubscribingState); err != nil { + errs = common.AppendError(errs, fmt.Errorf("%w; Channel: %s Pairs: %s", err, s.Channel, s.Pairs.Join())) + continue + } + } + subs = append(subs, s) + } + } + + subs = subs.GroupPairs() + + return common.AppendError(errs, + k.ParallelChanOp(subs, func(s subscription.List) error { return k.manageSubs(krakenWsUnsubscribe, s) }, 1), + ) +} + +// manageSubs handles both websocket channel subscribe and unsubscribe +func (k *Kraken) manageSubs(op string, subs subscription.List) error { + if len(subs) != 1 { + return subscription.ErrBatchingNotSupported + } + + s := subs[0] + + if err := enforceStandardChannelNames(s); err != nil { + return err + } + + reqFmt := currency.PairFormat{Uppercase: true, Delimiter: "/"} + r := &WebsocketSubRequest{ + Event: op, + RequestID: k.Websocket.Conn.GenerateMessageID(false), + Subscription: WebsocketSubscriptionData{ + Name: s.QualifiedChannel, + Depth: s.Levels, + }, + Pairs: s.Pairs.Format(reqFmt).Strings(), + } + + if s.Interval != 0 { + // TODO: Can Interval type be a kraken specific type with a MarshalText so we don't have to duplicate this + r.Subscription.Interval = int(time.Duration(s.Interval).Minutes()) + } + + conn := k.Websocket.Conn + if s.Authenticated { + r.Subscription.Token = authToken + conn = k.Websocket.AuthConn + } + + resps, err := conn.SendMessageReturnResponses(context.TODO(), request.Unset, r.RequestID, r, len(s.Pairs)) + + // Ignore an overall timeout, because we'll track individual subscriptions in handleSubResps + err = common.ExcludeError(err, stream.ErrSignatureTimeout) + if err != nil { - return nil, err + return fmt.Errorf("%w; Channel: %s Pair: %s", err, s.Channel, s.Pairs) } - var subscriptions subscription.List - for i := range defaultSubscribedChannels { - for j := range enabledPairs { - enabledPairs[j].Delimiter = "/" - subscriptions = append(subscriptions, &subscription.Subscription{ - Channel: defaultSubscribedChannels[i], - Pairs: currency.Pairs{enabledPairs[j]}, - Asset: asset.Spot, - }) - } - } - if k.Websocket.CanUseAuthenticatedEndpoints() { - for i := range authenticatedChannels { - subscriptions = append(subscriptions, &subscription.Subscription{ - Channel: authenticatedChannels[i], - }) - } - } - return subscriptions, nil + + return k.handleSubResps(s, resps, op) } -// Subscribe sends a websocket message to receive data from the channel -func (k *Kraken) Subscribe(channelsToSubscribe subscription.List) error { - var subscriptions = make(map[string]*[]WebsocketSubscriptionEventRequest) -channels: - for i := range channelsToSubscribe { - s, ok := subscriptions[channelsToSubscribe[i].Channel] - if !ok { - s = &[]WebsocketSubscriptionEventRequest{} - subscriptions[channelsToSubscribe[i].Channel] = s - } +// handleSubResps takes a collection of subscription responses from Kraken +// We submit a subscription for N+ pairs, and we get N+ individual responses +// Returns an error collection of unique errors and its pairs +func (k *Kraken) handleSubResps(s *subscription.Subscription, resps [][]byte, op string) error { + reqFmt := currency.PairFormat{Uppercase: true, Delimiter: "/"} - for j := range *s { - (*s)[j].Pairs = append((*s)[j].Pairs, channelsToSubscribe[i].Pairs.Strings()...) - (*s)[j].Channels = append((*s)[j].Channels, channelsToSubscribe[i]) - continue channels - } - - id := k.Websocket.Conn.GenerateMessageID(false) - outbound := WebsocketSubscriptionEventRequest{ - Event: krakenWsSubscribe, - RequestID: id, - Subscription: WebsocketSubscriptionData{ - Name: channelsToSubscribe[i].Channel, - }, - } - if channelsToSubscribe[i].Channel == "book" { - outbound.Subscription.Depth = krakenWsOrderbookDepth - } - for _, p := range channelsToSubscribe[i].Pairs { - outbound.Pairs = append(outbound.Pairs, p.String()) - } - if common.StringSliceContains(authenticatedChannels, channelsToSubscribe[i].Channel) { - outbound.Subscription.Token = authToken - } - - outbound.Channels = append(outbound.Channels, channelsToSubscribe[i]) - *s = append(*s, outbound) + errMap := map[string]error{} + pairErrs := map[currency.Pair]error{} + for _, p := range s.Pairs { + pairErrs[p.Format(reqFmt)] = errSubPairMissing } - var errs error - for _, subs := range subscriptions { - for i := range *subs { - var err error - if common.StringSliceContains(authenticatedChannels, (*subs)[i].Subscription.Name) { - _, err = k.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, (*subs)[i].RequestID, (*subs)[i]) - } else { - _, err = k.Websocket.Conn.SendMessageReturnResponse(context.TODO(), request.Unset, (*subs)[i].RequestID, (*subs)[i]) - } - if err == nil { - err = k.Websocket.AddSuccessfulSubscriptions((*subs)[i].Channels...) - } - if err != nil { - errs = common.AppendError(errs, err) - } - } - } - return errs -} - -// Unsubscribe sends a websocket message to stop receiving data from the channel -func (k *Kraken) Unsubscribe(channelsToUnsubscribe subscription.List) error { - var unsubs []WebsocketSubscriptionEventRequest -channels: - for x := range channelsToUnsubscribe { - for y := range unsubs { - if unsubs[y].Subscription.Name == channelsToUnsubscribe[x].Channel { - unsubs[y].Pairs = append(unsubs[y].Pairs, channelsToUnsubscribe[x].Pairs.Strings()...) - unsubs[y].Channels = append(unsubs[y].Channels, channelsToUnsubscribe[x]) - continue channels - } - } - var depth int64 - if channelsToUnsubscribe[x].Channel == "book" { - depth = krakenWsOrderbookDepth - } - - var id int64 - if common.StringSliceContains(authenticatedChannels, channelsToUnsubscribe[x].Channel) { - id = k.Websocket.AuthConn.GenerateMessageID(false) - } else { - id = k.Websocket.Conn.GenerateMessageID(false) - } - - unsub := WebsocketSubscriptionEventRequest{ - Event: krakenWsUnsubscribe, - Pairs: []string{channelsToUnsubscribe[x].Pairs[0].String()}, - Subscription: WebsocketSubscriptionData{ - Name: channelsToUnsubscribe[x].Channel, - Depth: depth, - }, - RequestID: id, - } - if common.StringSliceContains(authenticatedChannels, channelsToUnsubscribe[x].Channel) { - unsub.Subscription.Token = authToken - } - unsub.Channels = append(unsub.Channels, channelsToUnsubscribe[x]) - unsubs = append(unsubs, unsub) - } - - var errs error - for i := range unsubs { - var err error - if common.StringSliceContains(authenticatedChannels, unsubs[i].Subscription.Name) { - _, err = k.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, unsubs[i].RequestID, unsubs[i]) - } else { - _, err = k.Websocket.Conn.SendMessageReturnResponse(context.TODO(), request.Unset, unsubs[i].RequestID, unsubs[i]) - } - if err == nil { - err = k.Websocket.RemoveSubscriptions(unsubs[i].Channels...) - } + subPairs := currency.Pairs{} + for _, resp := range resps { + pName, err := jsonparser.GetUnsafeString(resp, "pair") if err != nil { - errs = common.AppendError(errs, err) + return fmt.Errorf("%w parsing WS pair from message: %s", err, resp) + } + pair, err := currency.NewPairDelimiter(pName, "/") + if err != nil { + return fmt.Errorf("%w parsing WS pair; Channel: %s Pair: %s", err, s.Channel, pName) + } + if err := k.getSubRespErr(resp, op); err != nil { + // Remove the pair name from the error so we can group errors + errStr := strings.TrimSpace(strings.TrimSuffix(err.Error(), pName)) + if _, ok := errMap[errStr]; !ok { + errMap[errStr] = errors.New(errStr) + } + pairErrs[pair] = errMap[errStr] + } else { + delete(pairErrs, pair) + if k.Verbose && op == krakenWsSubscribe { + subPairs = subPairs.Add(pair) + } } } + + // 2) Reverse the collection and report a list of pairs with each unique error, and re-add the missing and error pairs for unsubscribe + errPairs := map[error]currency.Pairs{} + for pair, err := range pairErrs { + errPairs[err] = errPairs[err].Add(pair) + } + + var errs error + for err, pairs := range errPairs { + errs = common.AppendError(errs, fmt.Errorf("%w; Channel: %s Pairs: %s", err, s.Channel, pairs.Join())) + } + + if k.Verbose && len(subPairs) > 0 { + log.Debugf(log.ExchangeSys, "%s Subscribed to Channel: %s Pairs: %s", k.Name, s.Channel, subPairs.Join()) + } + return errs } +// getSubErrResp calls getRespErr and if there's no error from that ensures the status matches the sub operation +func (k *Kraken) getSubRespErr(resp []byte, op string) error { + if err := k.getRespErr(resp); err != nil { + return err + } + exp := op + "d" // subscribed or unsubscribed + if status, err := jsonparser.GetUnsafeString(resp, "status"); err != nil { + return fmt.Errorf("error parsing WS status: %w from message: %s", err, resp) + } else if status != exp { + return fmt.Errorf("wrong WS status: %s; expected: %s from message %s", exp, op, resp) + } + + return nil +} + +// getRespErr takes a json response string and looks for an error event type +// If found it returns the errorMessage +// It might log parsing errors about the nature of the error +// If the error message is not defined it will return a wrapped errUnknownError +func (k *Kraken) getRespErr(resp []byte) error { + event, err := jsonparser.GetUnsafeString(resp, "event") + switch { + case err != nil: + return fmt.Errorf("error parsing WS event: %w from message: %s", err, resp) + case event != "error": + status, _ := jsonparser.GetUnsafeString(resp, "status") // Error is really irrelevant here + if status != "error" { + return nil + } + } + + var msg string + if msg, err = jsonparser.GetString(resp, "errorMessage"); err != nil { + log.Errorf(log.ExchangeSys, "%s error parsing WS errorMessage: %s from message: %s", k.Name, err, resp) + return fmt.Errorf("%w: error message did not contain errorMessage: %s", common.ErrUnknownError, resp) + } + return errors.New(msg) +} + +// wsProcessSubStatus handles creating or removing Subscriptions as soon as we receive a message +// It's job is to ensure that subscription state is kept correct sequentially between WS messages +// If this responsibility was moved to Subscribe then we would have a race due to the channel connecting IncomingWithData +func (k *Kraken) wsProcessSubStatus(resp []byte) { + pName, err := jsonparser.GetUnsafeString(resp, "pair") + if err != nil { + return + } + pair, err := currency.NewPairFromString(pName) + if err != nil { + return + } + c, err := jsonparser.GetUnsafeString(resp, "channelName") + if err != nil { + return + } + if err = k.getRespErr(resp); err != nil { + return + } + status, err := jsonparser.GetUnsafeString(resp, "status") + if err != nil { + return + } + key := &subscription.Subscription{ + // We don't use asset because it's either Empty or Spot, but not both + Channel: c, + Pairs: currency.Pairs{pair}, + } + + if err = fqChannelNameSub(key); err != nil { + return + } + s := k.Websocket.GetSubscription(&subscription.IgnoringAssetKey{Subscription: key}) + if s == nil { + log.Errorf(log.ExchangeSys, "%s %s Channel: %s Pairs: %s", k.Name, subscription.ErrNotFound, key.Channel, key.Pairs.Join()) + return + } + + if status == krakenWsSubscribed { + err = s.SetState(subscription.SubscribedState) + } else if s.State() != subscription.ResubscribingState { // Do not remove a resubscribing sub which just unsubbed + err = k.Websocket.RemoveSubscriptions(s) + if e2 := s.SetState(subscription.UnsubscribedState); e2 != nil { + err = common.AppendError(err, e2) + } + } + + if err != nil { + log.Errorf(log.ExchangeSys, "%s %s Channel: %s Pairs: %s", k.Name, err, s.Channel, s.Pairs.Join()) + } +} + +// channelName converts a global channel name to kraken bespoke names +func channelName(s *subscription.Subscription) string { + if n, ok := channelNames[s.Channel]; ok { + return n + } + return s.Channel +} + +func enforceStandardChannelNames(s *subscription.Subscription) error { + name := strings.Split(s.Channel, "-") // Protect against attempted usage of book-N as a channel name + if n, ok := reverseChannelNames[name[0]]; ok && n != s.Channel { + return fmt.Errorf("%w: %s => subscription.%s%sChannel", subscription.ErrPrivateChannelName, s.Channel, bytes.ToUpper([]byte{n[0]}), n[1:]) + } + return nil +} + +// fqChannelNameSub converts an fully qualified channel name into standard name and subscription params +// e.g. book-5 => subscription.OrderbookChannel with Levels: 5 +func fqChannelNameSub(s *subscription.Subscription) error { + parts := strings.Split(s.Channel, "-") + name := parts[0] + if stdName, ok := reverseChannelNames[name]; ok { + name = stdName + } + + if name == subscription.OrderbookChannel || name == subscription.CandlesChannel { + if len(parts) != 2 { + return errBadChannelSuffix + } + i, err := strconv.Atoi(parts[1]) + if err != nil { + return errBadChannelSuffix + } + switch name { + case subscription.OrderbookChannel: + s.Levels = i + case subscription.CandlesChannel: + s.Interval = kline.Interval(time.Minute * time.Duration(i)) + } + } + + s.Channel = name + + return nil +} + // wsAddOrder creates an order, returned order ID if success func (k *Kraken) wsAddOrder(req *WsAddOrderRequest) (string, error) { - id := k.Websocket.AuthConn.GenerateMessageID(false) - req.RequestID = id + if req == nil { + return "", common.ErrNilPointer + } + req.RequestID = k.Websocket.AuthConn.GenerateMessageID(false) req.Event = krakenWsAddOrder req.Token = authToken - jsonResp, err := k.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, id, req) + jsonResp, err := k.Websocket.AuthConn.SendMessageReturnResponse(context.TODO(), request.Unset, req.RequestID, req) if err != nil { return "", err } @@ -1317,8 +1289,13 @@ func (k *Kraken) wsAddOrder(req *WsAddOrderRequest) (string, error) { if err != nil { return "", err } - if resp.ErrorMessage != "" { - return "", errors.New(k.Name + " - " + resp.ErrorMessage) + if resp.Status == "error" { + return "", errors.New("AddOrder error: " + resp.ErrorMessage) + } + k.Websocket.DataHandler <- &order.Detail{ + Exchange: k.Name, + OrderID: resp.TransactionID, + Status: order.New, } return resp.TransactionID, nil } @@ -1387,7 +1364,27 @@ func (k *Kraken) wsCancelAllOrders() (*WsCancelOrderResponse, error) { return &WsCancelOrderResponse{}, err } if resp.ErrorMessage != "" { - return &WsCancelOrderResponse{}, errors.New(k.Name + " - " + resp.ErrorMessage) + return &WsCancelOrderResponse{}, errors.New(resp.ErrorMessage) } return &resp, nil } + +/* +One sub per-pair. We don't use one sub with many pairs because: + - Kraken will fan out in responses anyay + - resubscribe is messy when our subs don't match their respsonses + - FlushChannels and GetChannelDiff would incorrectly resub existing subs if we don't generate the same as we've stored +*/ +const subTplText = ` +{{- if $.S.Asset -}} + {{ range $asset, $pairs := $.AssetPairs }} + {{- range $p := $pairs -}} + {{- channelName $.S }} + {{- $.PairSeparator }} + {{- end -}} + {{ $.AssetSeparator }} + {{- end -}} +{{- else -}} + {{- channelName $.S }} +{{- end }} +` diff --git a/exchanges/kraken/kraken_wrapper.go b/exchanges/kraken/kraken_wrapper.go index 4f0861e4..82447a60 100644 --- a/exchanges/kraken/kraken_wrapper.go +++ b/exchanges/kraken/kraken_wrapper.go @@ -168,6 +168,7 @@ func (k *Kraken) SetDefaults() { GlobalResultLimit: 720, }, }, + Subscriptions: defaultSubscriptions.Clone(), } k.Requester, err = request.New(k.Name, @@ -178,10 +179,11 @@ func (k *Kraken) SetDefaults() { } k.API.Endpoints = k.NewEndpoints() err = k.API.Endpoints.SetDefaultEndpoints(map[exchange.URL]string{ - exchange.RestSpot: krakenAPIURL, - exchange.RestFutures: krakenFuturesURL, - exchange.WebsocketSpot: krakenWSURL, - exchange.RestFuturesSupplementary: krakenFuturesSupplementaryURL, + exchange.RestSpot: krakenAPIURL, + exchange.RestFutures: krakenFuturesURL, + exchange.WebsocketSpot: krakenWSURL, + exchange.WebsocketSpotSupplementary: krakenAuthWSURL, + exchange.RestFuturesSupplementary: krakenFuturesSupplementaryURL, }) if err != nil { log.Errorln(log.ExchangeSys, err) @@ -207,11 +209,6 @@ func (k *Kraken) Setup(exch *config.Exchange) error { return err } - err = k.SeedAssets(context.TODO()) - if err != nil { - return err - } - wsRunningURL, err := k.API.Endpoints.GetURL(exchange.WebsocketSpot) if err != nil { return err @@ -223,7 +220,7 @@ func (k *Kraken) Setup(exch *config.Exchange) error { Connector: k.WsConnect, Subscriber: k.Subscribe, Unsubscriber: k.Unsubscribe, - GenerateSubscriptions: k.GenerateDefaultSubscriptions, + GenerateSubscriptions: k.generateSubscriptions, Features: &k.Features.Supports.WebsocketCapabilities, OrderbookBufferConfig: buffer.Config{SortBuffer: true}, }) @@ -235,27 +232,47 @@ func (k *Kraken) Setup(exch *config.Exchange) error { RateLimit: request.NewWeightedRateLimitByDuration(50 * time.Millisecond), ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - URL: krakenWSURL, }) if err != nil { return err } + wsRunningAuthURL, err := k.API.Endpoints.GetURL(exchange.WebsocketSpotSupplementary) + if err != nil { + return err + } return k.Websocket.SetupNewConnection(stream.ConnectionSetup{ RateLimit: request.NewWeightedRateLimitByDuration(50 * time.Millisecond), ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout, ResponseMaxLimit: exch.WebsocketResponseMaxLimit, - URL: krakenAuthWSURL, Authenticated: true, + URL: wsRunningAuthURL, }) } +// Bootstrap provides initialisation for an exchange +func (k *Kraken) Bootstrap(_ context.Context) (continueBootstrap bool, err error) { + continueBootstrap = true + + if err = k.SeedAssets(context.TODO()); err != nil { + err = fmt.Errorf("failed to Seed Assets: %w", err) + } + + return +} + // UpdateOrderExecutionLimits sets exchange execution order limits for an asset type func (k *Kraken) UpdateOrderExecutionLimits(ctx context.Context, a asset.Item) error { if a != asset.Spot { return common.ErrNotYetImplemented } + if !assetTranslator.Seeded() { + if err := k.SeedAssets(ctx); err != nil { + return err + } + } + pairInfo, err := k.fetchSpotPairInfo(ctx) if err != nil { return fmt.Errorf("%s failed to load %s pair execution limits. Err: %s", k.Name, a, err) @@ -547,6 +564,11 @@ func (k *Kraken) UpdateAccountInfo(ctx context.Context, assetType asset.Item) (a var info account.Holdings var balances []account.Balance info.Exchange = k.Name + if !assetTranslator.Seeded() { + if err := k.SeedAssets(ctx); err != nil { + return info, err + } + } switch assetType { case asset.Spot: bal, err := k.GetBalance(ctx) @@ -798,8 +820,7 @@ func (k *Kraken) SubmitOrder(ctx context.Context, s *order.Submit) (*order.Submi return resp, nil } -// ModifyOrder will allow of changing orderbook placement and limit to -// market conversion +// ModifyOrder will allow of changing orderbook placement and limit to market conversion func (k *Kraken) ModifyOrder(_ context.Context, _ *order.Modify) (*order.ModifyResponse, error) { return nil, common.ErrFunctionNotSupported } diff --git a/exchanges/kraken/mock_ws_test.go b/exchanges/kraken/mock_ws_test.go new file mode 100644 index 00000000..09df7b74 --- /dev/null +++ b/exchanges/kraken/mock_ws_test.go @@ -0,0 +1,76 @@ +package kraken + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/buger/jsonparser" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" +) + +func mockWsServer(tb testing.TB, msg []byte, w *websocket.Conn) error { + tb.Helper() + event, err := jsonparser.GetUnsafeString(msg, "event") + if err != nil { + return err + } + switch event { + case krakenWsCancelOrder: + return mockWsCancelOrders(tb, msg, w) + case krakenWsAddOrder: + return mockWsAddOrder(tb, msg, w) + } + return nil +} + +func mockWsCancelOrders(tb testing.TB, msg []byte, w *websocket.Conn) error { + tb.Helper() + var req WsCancelOrderRequest + if err := json.Unmarshal(msg, &req); err != nil { + return err + } + resp := WsCancelOrderResponse{ + Event: krakenWsCancelOrderStatus, + Status: "ok", + RequestID: req.RequestID, + Count: int64(len(req.TransactionIDs)), + } + if len(req.TransactionIDs) == 0 || strings.Contains(req.TransactionIDs[0], "FISH") { // Reject anything that smells suspicious + resp.Status = "error" + resp.ErrorMessage = "[EOrder:Unknown order]" + } + msg, err := json.Marshal(resp) + if err != nil { + return err + } + return w.WriteMessage(websocket.TextMessage, msg) +} + +func mockWsAddOrder(tb testing.TB, msg []byte, w *websocket.Conn) error { + tb.Helper() + var req WsAddOrderRequest + if err := json.Unmarshal(msg, &req); err != nil { + return err + } + + assert.Equal(tb, "buy", req.OrderSide, "OrderSide should be correct") + assert.Equal(tb, "limit", req.OrderType, "OrderType should be correct") + assert.Equal(tb, "XBT/USD", req.Pair, "Pair should be correct") + assert.Equal(tb, 80000.0, req.Price, "Pair should be correct") + + resp := WsAddOrderResponse{ + Event: krakenWsAddOrderStatus, + Status: "ok", + RequestID: req.RequestID, + TransactionID: "ONPNXH-KMKMU-F4MR5V", + Description: fmt.Sprintf("%s %.f %s @ %s %.f", req.OrderSide, req.Volume, req.Pair, req.OrderSide, req.Price), + } + msg, err := json.Marshal(resp) + if err != nil { + return err + } + return w.WriteMessage(websocket.TextMessage, msg) +} diff --git a/exchanges/kraken/testdata/wsHandleData.json b/exchanges/kraken/testdata/wsHandleData.json new file mode 100644 index 00000000..2ae601f8 --- /dev/null +++ b/exchanges/kraken/testdata/wsHandleData.json @@ -0,0 +1,10 @@ +{"event":"pong"} +{"connectionID":8628615390848610000,"event":"systemStatus","status":"online","version":"1.0.0"} +[1337,{"a":["5525.40000",1,"1.000"],"b":["5525.10000",1,"1.000"],"c":["5525.10000","0.00398963"],"h":["5783.00000","5783.00000"],"l":["5505.00000","5505.00000"],"o":["5760.70000","5763.40000"],"p":["5631.44067","5653.78939"],"t":[11493,16267],"v":["2634.11501494","3591.17907851"]},"ticker","XBT/USD"] +[13337,["1542057314.748456","1542057360.435743","3586.70000","3586.70000","3586.60000","3586.60000","3586.68894","0.03373000",2],"ohlc-5","XBT/USD"] +[133337,[["5541.20000","0.15850568","1534614057.321597","s","l",""],["6060.00000","0.02455000","1534614057.324998","b","l",""]],"trade","XBT/USD"] +[1333337,["5698.40000","5700.00000","1542057299.545897","1.01234567","0.98765432"],"spread","XBT/USD"] +[13333337,{"as":[["5541.30000","2.50700000","1534614248.123678"],["5541.80000","0.33000000","1534614098.345543"],["5542.70000","0.64700000","1534614244.654432"],["5544.30000","2.50700000","1534614248.123678"],["5545.80000","0.33000000","1534614098.345543"],["5546.70000","0.64700000","1534614244.654432"],["5547.70000","0.64700000","1534614244.654432"],["5548.30000","2.50700000","1534614248.123678"],["5549.80000","0.33000000","1534614098.345543"],["5550.70000","0.64700000","1534614244.654432"]],"bs":[["5541.20000","1.52900000","1534614248.765567"],["5539.90000","0.30000000","1534614241.769870"],["5539.50000","5.00000000","1534613831.243486"],["5538.20000","1.52900000","1534614248.765567"],["5537.90000","0.30000000","1534614241.769870"],["5536.50000","5.00000000","1534613831.243486"],["5535.20000","1.52900000","1534614248.765567"],["5534.90000","0.30000000","1534614241.769870"],["5533.50000","5.00000000","1534613831.243486"],["5532.50000","5.00000000","1534613831.243486"]]},"book-100","XBT/USD"] +[13333337,{"a":[["5541.30000","2.50700000","1534614248.456738"],["5542.50000","0.40100000","1534614248.456738"]],"c":"4187525586"},"book-10","XBT/USD"] +[13333337,{"b":[["5541.30000","0.00000000","1534614335.345903"]],"c":"4187525586"},"book-10","XBT/USD"] +[[{"TDLH43-DVQXD-2KHVYY":{"cost":"1000000.00000","fee":"1600.00000","margin":"0.00000","ordertxid":"TDLH43-DVQXD-2KHVYY","ordertype":"limit","pair":"XBT/USD","postxid":"OGTT3Y-C6I3P-XRI6HX","price":"100000.00000","time":"1560516023.070651","type":"sell","vol":"1000000000.00000000"}},{"TDLH43-DVQXD-2KHVYY":{"cost":"1000000.00000","fee":"600.00000","margin":"0.00000","ordertxid":"TDLH43-DVQXD-2KHVYY","ordertype":"limit","pair":"XBT/USD","postxid":"OGTT3Y-C6I3P-XRI6HX","price":"100000.00000","time":"1560516023.070658","type":"buy","vol":"1000000000.00000000"}},{"TDLH43-DVQXD-2KHVYY":{"cost":"1000000.00000","fee":"1600.00000","margin":"0.00000","ordertxid":"TDLH43-DVQXD-2KHVYY","ordertype":"limit","pair":"XBT/USD","postxid":"OGTT3Y-C6I3P-XRI6HX","price":"100000.00000","time":"1560520332.914657","type":"sell","vol":"1000000000.00000000"}},{"TDLH43-DVQXD-2KHVYY":{"cost":"1000000.00000","fee":"600.00000","margin":"0.00000","ordertxid":"TDLH43-DVQXD-2KHVYY","ordertype":"limit","pair":"XBT/USD","postxid":"OGTT3Y-C6I3P-XRI6HX","price":"100000.00000","time":"1560520332.914664","type":"buy","vol":"1000000000.00000000"}}],"ownTrades",{"sequence":1}] diff --git a/exchanges/kraken/testdata/wsOpenTrades.json b/exchanges/kraken/testdata/wsOpenTrades.json index 40a516eb..57235086 100644 --- a/exchanges/kraken/testdata/wsOpenTrades.json +++ b/exchanges/kraken/testdata/wsOpenTrades.json @@ -1,4 +1,4 @@ -[[{"OGTT3Y-C6I3P-XRI6HR":{"cost":"0.00000","descr":{"close":"","leverage":"0.1","order":"sell 10.00345345 XBT/USD @ limit 34.50000 with 0:1 leverage","ordertype":"limit","pair":"XBT/USD","price":"34.50000","price2":"0.00000","type":"sell"},"expiretm":"0.000000","fee":"0.00000","limitprice":"34.50000","misc":"","oflags":"fcib","opentm":"0.000000","avg_price":"0.00000","refid":"LIMIT-OPEN","starttm":"0.000000","status":"open","stopprice":"0.000000","userref":0,"vol":"10.00345345","vol_exec":"0.00000000"}},{"OKB55A-UEMMN-YUXM2A":{"avg_price":"0.00000","cost":"0.00000","descr":{"close":null,"leverage":null,"order":"buy 0.00010000 XBT/USDT @ market 0.00000","ordertype":"market","pair":"XBT/USDT","price":"0.00000","price2":"0.00000","type":"buy"},"expiretm":null,"fee":"0.00000","limitprice":"0.00000","misc":"","oflags":"fciq","opentm":"1692851641.361371","refid":null,"starttm":null,"status":"pending","stopprice":"0.00000","timeinforce":"GTC","userref":0,"vol":"0.00010000","vol_exec":"0.00000000"}}],"openOrders",{"sequence":1}] +[[{"OGTT3Y-C6I3P-XRI6HR":{"cost":"0.00000","descr":{"close":"","leverage":"0.1","order":"sell 10.00345345 XBT/USD @ limit 34.50000 with 0:1 leverage","ordertype":"limit","pair":"XBT/USD","price":"34.50000","price2":"0.00000","type":"sell"},"expiretm":"0.000000","fee":"0.00000","limitprice":"34.50000","misc":"","oflags":"fcib","opentm":"0.000000","avg_price":"0.00000","refid":"LIMIT-OPEN","starttm":"0.000000","status":"open","stopprice":"0.000000","userref":0,"vol":"10.00345345","vol_exec":"0.00000000"}},{"OKB55A-UEMMN-YUXM2A":{"avg_price":"0.00000","cost":"0.00000","descr":{"close":null,"leverage":null,"order":"buy 0.00010000 XBT/USD @ market 0.00000","ordertype":"market","pair":"XBT/USD","price":"0.00000","price2":"0.00000","type":"buy"},"expiretm":null,"fee":"0.00000","limitprice":"0.00000","misc":"","oflags":"fciq","opentm":"1692851641.361371","refid":null,"starttm":null,"status":"pending","stopprice":"0.00000","timeinforce":"GTC","userref":0,"vol":"0.00010000","vol_exec":"0.00000000"}}],"openOrders",{"sequence":1}] [[{"OKB55A-UEMMN-YUXM2A":{"status":"open","userref":0}}],"openOrders",{"sequence":2}] [[{"OKB55A-UEMMN-YUXM2A":{"vol_exec":"0.00010000","cost":"2.64252","fee":"0.00687","avg_price":"26425.20000","userref":0}}],"openOrders",{"sequence":3}] [[{"OKB55A-UEMMN-YUXM2A":{"lastupdated":"1692851641.361447","status":"closed","vol_exec":"0.00010000","cost":"2.64252","fee":"0.00687","avg_price":"26425.20000","userref":0}}],"openOrders",{"sequence":4}] diff --git a/exchanges/kucoin/kucoin_websocket.go b/exchanges/kucoin/kucoin_websocket.go index 9e37b964..5c79c8d6 100644 --- a/exchanges/kucoin/kucoin_websocket.go +++ b/exchanges/kucoin/kucoin_websocket.go @@ -211,7 +211,7 @@ func (ku *Kucoin) wsHandleData(respData []byte) error { } if resp.ID != "" { if !ku.Websocket.Match.IncomingWithData("msgID:"+resp.ID, respData) { - return fmt.Errorf("message listener not found: %s", resp.ID) + return fmt.Errorf("%w: %s", stream.ErrNoMessageListener, resp.ID) } return nil } diff --git a/exchanges/stream/websocket.go b/exchanges/stream/websocket.go index b4a6ba63..69693db7 100644 --- a/exchanges/stream/websocket.go +++ b/exchanges/stream/websocket.go @@ -966,6 +966,9 @@ func (w *Websocket) checkSubscriptions(subs subscription.List) error { } for _, s := range subs { + if s.State() == subscription.ResubscribingState { + continue + } if found := w.subscriptions.Get(s); found != nil { return fmt.Errorf("%w: %s", subscription.ErrDuplicate, s) } diff --git a/exchanges/subscription/keys.go b/exchanges/subscription/keys.go index 9cc31f96..ea29431b 100644 --- a/exchanges/subscription/keys.go +++ b/exchanges/subscription/keys.go @@ -87,3 +87,38 @@ func (k IgnoringPairsKey) Match(eachKey MatchableKey) bool { eachSub.Levels == k.Levels && eachSub.Interval == k.Interval } + +// IgnoringAssetKey is a key type for finding subscriptions to group together for requests +type IgnoringAssetKey struct { + *Subscription +} + +var _ MatchableKey = IgnoringAssetKey{} // Enforce IgnoringAssetKey must implement MatchableKey + +// GetSubscription returns the underlying subscription +func (k IgnoringAssetKey) GetSubscription() *Subscription { + return k.Subscription +} + +// String implements Stringer; returns the asset and Channel name but no pairs +func (k IgnoringAssetKey) String() string { + s := k.Subscription + if s == nil { + return "Uninitialised IgnoringAssetKey" + } + return fmt.Sprintf("%s %s", s.Channel, s.Pairs) +} + +// Match implements MatchableKey +func (k IgnoringAssetKey) Match(eachKey MatchableKey) bool { + if eachKey == nil { + return false + } + eachSub := eachKey.GetSubscription() + + return eachSub != nil && + eachSub.Channel == k.Channel && + eachSub.Pairs.Equal(k.Pairs) && + eachSub.Levels == k.Levels && + eachSub.Interval == k.Interval +} diff --git a/exchanges/subscription/keys_test.go b/exchanges/subscription/keys_test.go index 4a4054b2..bd940ccb 100644 --- a/exchanges/subscription/keys_test.go +++ b/exchanges/subscription/keys_test.go @@ -110,6 +110,39 @@ func TestIgnoringPairsKeyString(t *testing.T) { assert.Equal(t, "ticker spot", key.String()) } +// TestIgnoringAssetKeyMatch exercises IgnoringAssetKey.Match +func TestIgnoringAssetKeyMatch(t *testing.T) { + t.Parallel() + + key := &IgnoringAssetKey{&Subscription{Channel: TickerChannel, Asset: asset.Spot}} + try := &DummyKey{&Subscription{Channel: OrderbookChannel}, t} + + require.False(t, key.Match(nil), "Match on a nil must return false") + require.False(t, key.Match(try), "Gate 1: Match must reject a bad Channel") + try.Channel = TickerChannel + require.True(t, key.Match(try), "Gate 1: Match must accept a good Channel") + key.Pairs = currency.Pairs{btcusdtPair} + require.False(t, key.Match(try), "Gate 2: Match must reject bad Pairs") + try.Pairs = currency.Pairs{btcusdtPair} + require.True(t, key.Match(try), "Gate 2: Match must accept a good Pairs") + key.Levels = 4 + require.False(t, key.Match(try), "Gate 3: Match must reject a bad Level") + try.Levels = 4 + require.True(t, key.Match(try), "Gate 3: Match must accept a good Level") + key.Interval = kline.FiveMin + require.False(t, key.Match(try), "Gate 4: Match must reject a bad Interval") + try.Interval = kline.FiveMin + require.True(t, key.Match(try), "Gate 4: Match must accept a good Interval") +} + +// TestIgnoringAssetKeyString exercises IgnoringAssetKey.String +func TestIgnoringAssetKeyString(t *testing.T) { + t.Parallel() + assert.Equal(t, "Uninitialised IgnoringAssetKey", IgnoringAssetKey{}.String()) + key := &IgnoringAssetKey{&Subscription{Asset: asset.Spot, Channel: TickerChannel, Pairs: currency.Pairs{ethusdcPair, btcusdtPair}}} + assert.Equal(t, "ticker [ETHUSDC BTCUSDT]", key.String()) +} + // TestGetSubscription exercises GetSubscription func TestGetSubscription(t *testing.T) { t.Parallel() diff --git a/exchanges/subscription/subscription.go b/exchanges/subscription/subscription.go index 51646636..31e0493d 100644 --- a/exchanges/subscription/subscription.go +++ b/exchanges/subscription/subscription.go @@ -35,11 +35,13 @@ const ( // Public errors var ( - ErrNotFound = errors.New("subscription not found") - ErrNotSinglePair = errors.New("only single pair subscriptions expected") - ErrInStateAlready = errors.New("subscription already in state") - ErrInvalidState = errors.New("invalid subscription state") - ErrDuplicate = errors.New("duplicate subscription") + ErrNotFound = errors.New("subscription not found") + ErrNotSinglePair = errors.New("only single pair subscriptions expected") + ErrBatchingNotSupported = errors.New("subscription batching not supported") + ErrInStateAlready = errors.New("subscription already in state") + ErrInvalidState = errors.New("invalid subscription state") + ErrDuplicate = errors.New("duplicate subscription") + ErrPrivateChannelName = errors.New("must use standard channel name constants") ) // State tracks the status of a subscription channel diff --git a/internal/testing/exchange/exchange.go b/internal/testing/exchange/exchange.go index 85de1fea..048585d2 100644 --- a/internal/testing/exchange/exchange.go +++ b/internal/testing/exchange/exchange.go @@ -89,7 +89,7 @@ func MockHTTPInstance(e exchange.IBotExchange) error { var upgrader = websocket.Upgrader{} // WsMockFunc is a websocket handler to be called with each websocket message -type WsMockFunc func([]byte, *websocket.Conn) error +type WsMockFunc func(testing.TB, []byte, *websocket.Conn) error // MockWsInstance creates a new Exchange instance with a mock websocket instance and HTTP server // It accepts an exchange package type argument and a http.HandlerFunc @@ -150,7 +150,7 @@ func WsMockUpgrader(tb testing.TB, w http.ResponseWriter, r *http.Request, wsHan } require.NoError(tb, err, "ReadMessage should not error") - err = wsHandler(p, c) + err = wsHandler(tb, p, c) assert.NoError(tb, err, "WS Mock Function should not error") } } diff --git a/internal/testing/exchange/exchange_test.go b/internal/testing/exchange/exchange_test.go index d6796c9e..0976d47b 100644 --- a/internal/testing/exchange/exchange_test.go +++ b/internal/testing/exchange/exchange_test.go @@ -30,6 +30,6 @@ func TestMockHTTPInstance(t *testing.T) { // TestMockWsInstance exercises MockWsInstance func TestMockWsInstance(t *testing.T) { - b := MockWsInstance[binance.Binance](t, CurryWsMockUpgrader(t, func(_ []byte, _ *websocket.Conn) error { return nil })) + b := MockWsInstance[binance.Binance](t, CurryWsMockUpgrader(t, func(_ testing.TB, _ []byte, _ *websocket.Conn) error { return nil })) require.NotNil(t, b, "MockWsInstance must not be nil") } diff --git a/testdata/configtest.json b/testdata/configtest.json index 168bb13d..46a7096d 100644 --- a/testdata/configtest.json +++ b/testdata/configtest.json @@ -1894,9 +1894,6 @@ "separator": "," }, "useGlobalFormat": true, - "assetTypes": [ - "spot" - ], "pairs": { "futures": { "assetEnabled": true, @@ -1912,6 +1909,7 @@ } }, "spot": { + "assetEnabled": true, "enabled": "XBT-USD", "available": "ETH-GBP,XRP-USD,DAI-EUR,LSK-USD,BAT-EUR,BCH-EUR,EOS-ETH,GNO-EUR,ETH-CAD,XRP-JPY,ADA-ETH,DAI-USD,DASH-EUR,GNO-USD,LSK-XBT,ETH-EUR,ZEC-EUR,DASH-XBT,EOS-EUR,ETH-CHF,SC-ETH,SC-USD,WAVES-EUR,XBT-USD,ADA-EUR,LINK-USD,NANO-EUR,PAXG-USD,SC-EUR,WAVES-ETH,REP-USD,EOS-XBT,ETC-ETH,XMR-USD,LTC-USD,MLN-XBT,XTZ-CAD,XBT-GBP,ADA-CAD,XTZ-EUR,ETH-JPY,XTZ-USD,XDG-XBT,XLM-EUR,ATOM-USD,ATOM-XBT,OMG-EUR,ZEC-JPY,ADA-XBT,GNO-ETH,LINK-XBT,ETC-EUR,BCH-XBT,QTUM-ETH,XBT-CHF,LTC-EUR,ETH-DAI,LSK-EUR,NANO-USD,QTUM-XBT,XRP-XBT,ZEC-USD,BAT-ETH,LINK-ETH,XBT-CAD,BAT-USD,GNO-XBT,ICX-XBT,PAXG-ETH,DAI-USDT,NANO-ETH,OMG-ETH,WAVES-XBT,ZEC-XBT,BAT-XBT,NANO-XBT,XBT-JPY,DASH-USD,ICX-ETH,LSK-ETH,QTUM-CAD,REP-XBT,XMR-XBT,XRP-EUR,ATOM-CAD,OMG-USD,LTC-XBT,MLN-ETH,XTZ-ETH,EOS-USD,ICX-EUR,SC-XBT,ETC-USD,BCH-USD,ICX-USD,QTUM-USD,ETH-XBT,ETH-USD,OMG-XBT,PAXG-EUR,REP-EUR,ADA-USD,USDT-USD,XMR-EUR,XRP-CAD,ATOM-EUR,ETC-XBT,XBT-EUR,XLM-USD,ATOM-ETH,LINK-EUR,PAXG-XBT,WAVES-USD,REP-ETH,XLM-XBT,QTUM-EUR,XTZ-XBT" } @@ -1964,7 +1962,13 @@ "iban": "", "supportedCurrencies": "" } - ] + ], + "orderbook": { + "verificationBypass": false, + "websocketBufferLimit": 5, + "websocketBufferEnabled": false, + "publishPeriod": 10000000000 + } }, { "name": "Kucoin",