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",