subscriptions: Encapsulate, replace Pair with Pairs and refactor; improve exchange support

* Websocket: Use ErrSubscribedAlready

instead of errChannelAlreadySubscribed

* Subscriptions: Replace Pair with Pairs

Given that some subscriptions have multiple pairs, support that as the
standard.

* Docs: Update subscriptions in add new exch

* RPC: Update Subscription Pairs

* Linter: Disable testifylint.Len

We deliberately use Equal over Len to avoid spamming the contents of large Slices

* Websocket: Add suffix to state consts

* Binance: Subscription Pairs support

* Bitfinex: Subscription Pairs support

* Bithumb: Subscription Pairs support

* Bitmex: Subscription Pairs support

* Bitstamp: Subscription Pairs support

* BTCMarkets: Subscription Pairs support

* BTSE: Subscription Pairs support

* Coinbase: Subscription Pairs support

* Coinut: Subscription Pairs support

* GateIO: Subscription Pairs support

* Gemini: Subscription Pairs support and improvement

* Hitbtc: Subscription Pairs support

* Huboi: Subscription Pairs support

* Kucoin: Subscription Pairs support

* Okcoin: Subscription Pairs support

* Poloniex: Subscription Pairs support

* Kraken: Add subscription Pairs support

Note: This is a naieve implementation because we want to rebase the
kraken websocket rewrite on top of this

* Bybit: Subscription Pairs support

* Okx: Subscription Pairs support

* Bitmex: Subsription configuration

* Fixes unauthenticated websocket left as CanUseAuth
* Fixes auth subs happening privately

* CoinbasePro: Subscription Configuration

* Consolidate ProductIDs when all subscriptions are for the same list

* Websocket: Log actual sent message when Verbose

* Subscriptions: Improve clarity of which key is which in Match

* Subscriptions: Lint fix for HugeParam

* Subscriptions: Add AddPairs and move keys from test

* Subscriptions: Simplify subscription keys and add key types

* Subscriptions: Add List.GroupPairs Rename sub.AddPairs

* Subscription: Fix ExactKey not matching 0 pairs

* Subscriptions: Remove unused IdentityKey and HasPairKey

* Subscriptions: Fix GetKey test

* Subscriptions: Test coverage improvements

* Websocket: Change State on Add/Remove

* Subscriptions: Improve error context

* Subscriptions: Fix Enable: false subs not ignored

* Bitfinex: Fix WsAuth test failing on DataHandler

DataHandler is eaten by dataMonitor now, so we need to use ToRoutine

* Deribit: Subscription Pairs support

* Websocket: Accept nil lists for checkSubscriptions

If the user passes in a nil (implicitly empty) list, we would not panic.
Therefore the burden of correctness about that data lies with them.
The list of subscriptions is empty, and that's okay, and possibly
convenient

* Websocket: Add context to NilPointer errors

* Subscriptions: Add context to nil errors

* Exchange: Fix error expectations in UnsubToWSChans
This commit is contained in:
Gareth Kirwan
2024-06-07 08:54:08 +07:00
committed by GitHub
parent afb6f75d88
commit 1199f38546
75 changed files with 4551 additions and 3891 deletions

View File

@@ -0,0 +1,88 @@
package subscription
import (
"fmt"
"github.com/thrasher-corp/gocryptotrader/currency"
)
// MatchableKey interface should be implemented by Key types which want a more complex matching than a simple key equality check
// The Subscription method allows keys to compare against keys of other types
type MatchableKey interface {
Match(MatchableKey) bool
GetSubscription() *Subscription
String() string
}
// ExactKey is key type for subscriptions where all the pairs in a Subscription must match exactly
type ExactKey struct {
*Subscription
}
var _ MatchableKey = ExactKey{} // Enforce ExactKey must implement MatchableKey
// GetSubscription returns the underlying subscription
func (k ExactKey) GetSubscription() *Subscription {
return k.Subscription
}
// String implements Stringer; returns the Asset, Channel and Pairs
// Does not provide concurrency protection on the subscription it points to
func (k ExactKey) String() string {
s := k.Subscription
if s == nil {
return "Uninitialised ExactKey"
}
p := s.Pairs.Format(currency.PairFormat{Uppercase: true, Delimiter: "/"})
return fmt.Sprintf("%s %s %s", s.Channel, s.Asset, p.Join())
}
// Match implements MatchableKey
// Returns true if the key fields exactly matches the subscription, including all Pairs
func (k ExactKey) Match(eachKey MatchableKey) bool {
if eachKey == nil {
return false
}
eachSub := eachKey.GetSubscription()
return eachSub != nil &&
eachSub.Channel == k.Channel &&
eachSub.Asset == k.Asset &&
eachSub.Pairs.Equal(k.Pairs) &&
eachSub.Levels == k.Levels &&
eachSub.Interval == k.Interval
}
// IgnoringPairsKey is a key type for finding subscriptions to group together for requests
type IgnoringPairsKey struct {
*Subscription
}
var _ MatchableKey = IgnoringPairsKey{} // Enforce IgnoringPairsKey must implement MatchableKey
// GetSubscription returns the underlying subscription
func (k IgnoringPairsKey) GetSubscription() *Subscription {
return k.Subscription
}
// String implements Stringer; returns the asset and Channel name but no pairs
func (k IgnoringPairsKey) String() string {
s := k.Subscription
if s == nil {
return "Uninitialised IgnoringPairsKey"
}
return fmt.Sprintf("%s %s", s.Channel, s.Asset)
}
// Match implements MatchableKey
func (k IgnoringPairsKey) Match(eachKey MatchableKey) bool {
if eachKey == nil {
return false
}
eachSub := eachKey.GetSubscription()
return eachSub != nil &&
eachSub.Channel == k.Channel &&
eachSub.Asset == k.Asset &&
eachSub.Levels == k.Levels &&
eachSub.Interval == k.Interval
}

View File

@@ -0,0 +1,110 @@
package subscription
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
)
// DummyKey is a test key type that ensures that cross compatible keys can be used
// It will panic if Match() is called
type DummyKey struct {
*Subscription
detonator testing.TB
}
var _ MatchableKey = DummyKey{} // Enforce DummyKey must implement MatchableKey
// GetSubscription returns the underlying subscription
func (k DummyKey) GetSubscription() *Subscription {
return k.Subscription
}
// Match implements MatchableKey
func (k DummyKey) Match(_ MatchableKey) bool {
k.detonator.Fatal("DummyKey Match should never be called")
return false
}
// TestExactKeyMatch exercises ExactKey.Match
func TestExactKeyMatch(t *testing.T) {
t.Parallel()
key := &ExactKey{&Subscription{Channel: TickerChannel}}
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.Asset = asset.Spot
require.False(t, key.Match(try), "Gate 2: Match must reject a bad Asset")
try.Asset = asset.Spot
require.True(t, key.Match(try), "Gate 2: Match must accept a good Asset")
key.Pairs = currency.Pairs{btcusdtPair}
require.False(t, key.Match(try), "Gate 3: Match must reject B empty Pairs when key has Pairs")
try.Pairs = currency.Pairs{btcusdtPair}
key.Pairs = nil
require.False(t, key.Match(try), "Gate 3: Match must reject B has Pairs when key has empty Pairs")
key.Pairs = currency.Pairs{btcusdtPair}
require.True(t, key.Match(try), "Gate 3: Match must accept matching pairs")
key.Pairs = currency.Pairs{ethusdcPair}
require.False(t, key.Match(try), "Gate 3: Match must reject when key.Pairs not matching")
try.Pairs = currency.Pairs{btcusdtPair, ethusdcPair}
require.False(t, key.Match(try), "Gate 3: Match must reject when key.Pairs is only a subset")
key.Pairs = currency.Pairs{ethusdcPair, btcusdtPair}
require.True(t, key.Match(try), "Gate 3: Match accept when Pairs match in different order")
key.Levels = 4
require.False(t, key.Match(try), "Gate 4: Match must reject a bad Level")
try.Levels = 4
require.True(t, key.Match(try), "Gate 4: Match must accept a good Level")
key.Interval = kline.FiveMin
require.False(t, key.Match(try), "Gate 5: Match must reject a bad Interval")
try.Interval = kline.FiveMin
require.True(t, key.Match(try), "Gate 5: Match must accept a good Interval")
}
// TestExactKeyString exercises ExactKey.String
func TestExactKeyString(t *testing.T) {
t.Parallel()
key := &ExactKey{}
assert.Equal(t, "Uninitialised ExactKey", key.String())
key = &ExactKey{&Subscription{Asset: asset.Spot, Channel: TickerChannel, Pairs: currency.Pairs{ethusdcPair, btcusdtPair}}}
assert.Equal(t, "ticker spot ETH/USDC,BTC/USDT", key.String())
}
// TestIgnoringPairsKeyMatch exercises IgnoringPairsKey.Match
func TestIgnoringPairsKeyMatch(t *testing.T) {
t.Parallel()
key := &IgnoringPairsKey{&Subscription{Channel: TickerChannel, Pairs: currency.Pairs{btcusdtPair}}}
try := &DummyKey{&Subscription{Channel: OrderbookChannel, Pairs: currency.Pairs{ethusdcPair}}, 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.Asset = asset.Spot
require.False(t, key.Match(try), "Gate 2: Match must reject a bad Asset")
try.Asset = asset.Spot
require.True(t, key.Match(try), "Gate 2: Match must accept a good Asset")
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")
}
// TestIgnoringPairsKeyString exercises IgnoringPairsKey.String
func TestIgnoringPairsKeyString(t *testing.T) {
t.Parallel()
key := &IgnoringPairsKey{&Subscription{Asset: asset.Spot, Channel: TickerChannel, Pairs: currency.Pairs{ethusdcPair, btcusdtPair}}}
assert.Equal(t, "ticker spot", key.String())
}

View File

@@ -0,0 +1,32 @@
package subscription
import (
"slices"
)
// List is a container of subscription pointers
type List []*Subscription
// Strings returns a sorted slice of subscriptions
func (l List) Strings() []string {
s := make([]string, len(l))
for i := range l {
s[i] = l[i].String()
}
slices.Sort(s)
return s
}
// GroupPairs groups subscriptions which are identical apart from the Pairs
// The returned List contains cloned Subscriptions, and the original Subscriptions are left alone
func (l List) GroupPairs() (n List) {
s := NewStore()
for _, sub := range l {
if found := s.match(&IgnoringPairsKey{sub}); found == nil {
s.unsafeAdd(sub.Clone())
} else {
found.AddPairs(sub.Pairs...)
}
}
return s.List()
}

View File

@@ -0,0 +1,47 @@
package subscription
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
)
// TestListStrings exercises List.Strings()
func TestListStrings(t *testing.T) {
l := List{
&Subscription{
Channel: TickerChannel,
Asset: asset.Spot,
Pairs: currency.Pairs{ethusdcPair, btcusdtPair},
},
&Subscription{
Channel: OrderbookChannel,
Pairs: currency.Pairs{ethusdcPair},
},
}
exp := []string{"orderbook ETH/USDC", "ticker spot ETH/USDC,BTC/USDT"}
assert.ElementsMatch(t, exp, l.Strings(), "String must return correct sorted list")
}
// TestListGroupPairs exercises List.GroupPairs()
func TestListGroupPairs(t *testing.T) {
l := List{
{Asset: asset.Spot, Channel: TickerChannel, Pairs: currency.Pairs{ethusdcPair, btcusdtPair}},
}
for _, c := range []string{TickerChannel, OrderbookChannel} {
for _, p := range []currency.Pair{ethusdcPair, btcusdtPair} {
l = append(l, &Subscription{
Channel: c,
Asset: asset.Spot,
Pairs: currency.Pairs{p},
})
}
}
n := l.GroupPairs()
assert.Len(t, l, 5, "Orig list should not be changed")
assert.Len(t, n, 2, "New list should be grouped")
exp := []string{"ticker spot ETH/USDC,BTC/USDT", "orderbook spot ETH/USDC,BTC/USDT"}
assert.ElementsMatch(t, exp, n.Strings(), "String must return correct sorted list")
}

View File

@@ -0,0 +1,208 @@
package subscription
import (
"fmt"
"maps"
"sync"
"github.com/thrasher-corp/gocryptotrader/common"
)
// Store is a container of subscription pointers
type Store struct {
m map[any]*Subscription
mu sync.RWMutex
}
// NewStore creates a ready to use store and should always be used
func NewStore() *Store {
return &Store{
m: map[any]*Subscription{},
}
}
// NewStoreFromList creates a Store from a List
func NewStoreFromList(l List) (*Store, error) {
s := NewStore()
for _, sub := range l {
if sub == nil {
return nil, fmt.Errorf("%w: List parameter contains an nil element", common.ErrNilPointer)
}
if err := s.add(sub); err != nil {
return nil, err
}
}
return s, nil
}
// Add adds a subscription to the store
// Key can be already set; if omitted EnsureKeyed will be used
// Errors if it already exists
func (s *Store) Add(sub *Subscription) error {
if s == nil {
return fmt.Errorf("%w: Add called on nil Store", common.ErrNilPointer)
}
if s.m == nil {
return fmt.Errorf("%w: Add called on an Uninitialised Store", common.ErrNilPointer)
}
if sub == nil {
return fmt.Errorf("%w: Subscription param", common.ErrNilPointer)
}
s.mu.Lock()
defer s.mu.Unlock()
return s.add(sub)
}
// add adds a subscription to the store
// Key can be already set; if omitted EnsureKeyed will be used
// This method provides no locking protection
func (s *Store) add(sub *Subscription) error {
key := sub.EnsureKeyed()
if found := s.get(key); found != nil {
return fmt.Errorf("%w: %s", ErrDuplicate, sub)
}
s.m[key] = sub
return nil
}
// unsafeAdd adds a subscription to the store without checking if it is a duplicate
// Key can be already set; if omitted EnsureKeyed will be used
// This method provides no locking protection
func (s *Store) unsafeAdd(sub *Subscription) {
key := sub.EnsureKeyed()
s.m[key] = sub
}
// Get returns a pointer to a subscription or nil if not found
// If the key passed in is a Subscription then its Key will be used; which may be a pointer to itself.
// If key implements MatchableKey then key.Match will be used; Note that *Subscription implements MatchableKey
func (s *Store) Get(key any) *Subscription {
if s == nil || s.m == nil || key == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.get(key)
}
// get returns a pointer to subscription or nil if not found
// If the key passed in is a Subscription then its Key will be used; which may be a pointer to itself.
// If key implements MatchableKey then key.Match will be used; Note that *Subscription implements MatchableKey
// This method provides no locking protection
func (s *Store) get(key any) *Subscription {
switch v := key.(type) {
case Subscription:
key = v.EnsureKeyed()
case *Subscription:
key = v.EnsureKeyed()
}
switch v := key.(type) {
case MatchableKey:
return s.match(v)
default:
return s.m[v]
}
}
// Remove removes a subscription from the store
// If the key passed in is a Subscription then its Key will be used; which may be a pointer to itself.
// If key implements MatchableKey then key.Match will be used; Note that *Subscription implements MatchableKey
func (s *Store) Remove(key any) error {
if s == nil {
return fmt.Errorf("%w: Remove called on nil Store", common.ErrNilPointer)
}
if s.m == nil {
return fmt.Errorf("%w: Remove called on an Uninitialised Store", common.ErrNilPointer)
}
if key == nil {
return fmt.Errorf("%w: key param", common.ErrNilPointer)
}
s.mu.Lock()
defer s.mu.Unlock()
if found := s.get(key); found != nil {
delete(s.m, found.Key)
return nil
}
return ErrNotFound
}
// List returns a slice of Subscriptions pointers
func (s *Store) List() List {
if s == nil || s.m == nil {
return List{}
}
s.mu.RLock()
defer s.mu.RUnlock()
subs := make(List, 0, len(s.m))
for _, sub := range s.m {
subs = append(subs, sub)
}
return subs
}
// Clear empties the subscription store
func (s *Store) Clear() {
if s == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if s.m == nil {
s.m = map[any]*Subscription{}
}
clear(s.m)
}
// match returns the first subscription which matches the Key's Asset, Channel and Pairs
// If the key provided has:
// 1) Empty pairs then only Subscriptions without pairs will be considered
// 2) >=1 pairs then Subscriptions which contain all the pairs will be considered
// This method provides no locking protection
func (s *Store) match(key MatchableKey) *Subscription {
for eachKey, sub := range s.m {
if m, ok := eachKey.(MatchableKey); ok {
if key.Match(m) {
return sub
}
}
}
return nil
}
// Diff returns a list of the added and missing subs from a new list
// The store Diff is invoked upon is read-lock protected
// The new store is assumed to be a new instance and enjoys no locking protection
func (s *Store) Diff(compare List) (added, removed List) {
if s == nil || s.m == nil {
return
}
s.mu.RLock()
defer s.mu.RUnlock()
removedMap := maps.Clone(s.m)
for _, sub := range compare {
if found := s.get(sub); found != nil {
delete(removedMap, found.Key)
} else {
added = append(added, sub)
}
}
for _, c := range removedMap {
removed = append(removed, c)
}
return
}
// Len returns the number of subscriptions
func (s *Store) Len() int {
if s == nil {
return 0
}
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.m)
}

View File

@@ -0,0 +1,182 @@
package subscription
import (
"maps"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thrasher-corp/gocryptotrader/common"
"github.com/thrasher-corp/gocryptotrader/currency"
)
// TestNewStore exercises NewStore
func TestNewStore(t *testing.T) {
s := NewStore()
require.IsType(t, &Store{}, s, "Must return a store ref")
require.NotNil(t, s.m, "storage map must be initialised")
}
// TestNewStoreFromList exercises NewStoreFromList
func TestNewStoreFromList(t *testing.T) {
s, err := NewStoreFromList(List{})
assert.NoError(t, err, "Should not error on empty list")
require.IsType(t, &Store{}, s, "Must return a store ref")
l := List{
{Channel: OrderbookChannel},
{Channel: TickerChannel},
}
s, err = NewStoreFromList(l)
assert.NoError(t, err, "Should not error on empty list")
assert.Len(t, s.m, 2, "Map should have 2 values")
assert.NotNil(t, s.get(l[0]), "Should be able to get a list element")
l = append(l, &Subscription{Channel: OrderbookChannel})
_, err = NewStoreFromList(l)
assert.ErrorIs(t, err, ErrDuplicate, "Should error correctly on duplicates")
l = List{nil, &Subscription{Channel: OrderbookChannel}}
_, err = NewStoreFromList(l)
assert.ErrorIs(t, err, common.ErrNilPointer, "Should error correctly on nils")
}
// TestAdd exercises Add and add methods
func TestAdd(t *testing.T) {
assert.ErrorIs(t, (*Store)(nil).Add(&Subscription{}), common.ErrNilPointer, "Should error nil pointer correctly")
assert.ErrorIs(t, (&Store{}).Add(nil), common.ErrNilPointer, "Should error nil pointer correctly")
assert.ErrorIs(t, (&Store{}).Add(&Subscription{}), common.ErrNilPointer, "Should error nil pointer correctly")
s := NewStore()
sub := &Subscription{Channel: TickerChannel}
require.NoError(t, s.Add(sub), "Should not error on a standard add")
assert.NotNil(t, s.get(sub), "Should have stored the sub")
assert.ErrorIs(t, s.Add(sub), ErrDuplicate, "Should error on duplicates")
assert.NotNil(t, sub.Key, sub, "Add should call EnsureKeyed")
}
// TestGet exercises Get and get methods
// Ensures that key's Match is used, but does not exercise subscription.Match; See TestMatch for that coverage
func TestGet(t *testing.T) {
assert.Nil(t, (*Store)(nil).Get(&Subscription{}), "Should return nil when called on nil")
assert.Nil(t, (&Store{}).Get(&Subscription{}), "Should return nil when called with no subscription map")
s := NewStore()
exp := List{
{Channel: AllOrdersChannel},
{Channel: TickerChannel, Pairs: currency.Pairs{btcusdtPair}},
{Key: 42, Channel: OrderbookChannel},
{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}},
}
for _, sub := range exp {
require.NoError(t, s.Add(sub), "Adding subscription must not error)")
}
// Tests for a MatchableKey, ensuring that ExactKey works
assert.Nil(t, s.Get(Subscription{Channel: CandlesChannel}), "Should return nil without pairs")
assert.Nil(t, s.Get(Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{ltcusdcPair}}), "Should return nil with wrong pair")
assert.Nil(t, s.Get(Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair}}), "Should return nil with only one right pair")
assert.Same(t, exp[3], s.Get(Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}), "Should return pointer when all pairs match")
assert.Nil(t, s.Get(Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair, ltcusdcPair}}), "Should return nil when key is superset of pairs")
}
// TestRemove exercises the Remove method
func TestRemove(t *testing.T) {
assert.ErrorIs(t, (*Store)(nil).Remove(&Subscription{}), common.ErrNilPointer, "Should error correctly when called on nil")
assert.ErrorIs(t, (&Store{}).Remove(nil), common.ErrNilPointer, "Should error correctly when called passing nil")
assert.ErrorIs(t, (&Store{}).Remove(&Subscription{}), common.ErrNilPointer, "Should error correctly when called with no subscription map")
s := NewStore()
require.NoError(t, s.Add(&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}), "Adding subscription must not error")
assert.NotNil(t, s.Get(&ExactKey{&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}}), "Should have added the sub")
assert.ErrorIs(t, s.Remove(&ExactKey{&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair}}}), ErrNotFound, "Should error correctly when called with a non-matching key")
assert.NoError(t, s.Remove(&ExactKey{&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}}), "Should not error when called with a matching key")
assert.Nil(t, s.Get(&ExactKey{&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}}), "Should have removed the sub")
assert.ErrorIs(t, s.Remove(&ExactKey{&Subscription{Channel: CandlesChannel, Pairs: currency.Pairs{btcusdtPair, ethusdcPair}}}), ErrNotFound, "Should error correctly when called twice ")
}
// TestList exercises the List and Len methods
func TestList(t *testing.T) {
assert.Empty(t, (*Store)(nil).List(), "Should return an empty List when called on nil")
assert.Empty(t, (&Store{}).List(), "Should return an empty List when called on Store without map")
s := NewStore()
exp := List{
{Channel: OrderbookChannel},
{Channel: TickerChannel},
{Key: 42, Channel: CandlesChannel},
}
for _, sub := range exp {
require.NoError(t, s.Add(sub), "Adding subscription must not error)")
}
l := s.List()
require.Len(t, l, 3, "Must have 3 elements in the list")
assert.ElementsMatch(t, exp, l, "List Should have the same subscriptions")
require.Equal(t, 3, s.Len(), "Len must return 3")
require.Equal(t, 0, (*Store)(nil).Len(), "Len must return 0 on a nil store")
require.Equal(t, 0, (&Store{}).Len(), "Len must return 0 on an uninitialized store")
}
// TestStoreClear exercises the Clear method
func TestStoreClear(t *testing.T) {
assert.NotPanics(t, func() { (*Store)(nil).Clear() }, "Should not panic when called on nil")
s := &Store{}
assert.NotPanics(t, func() { s.Clear() }, "Should not panic when called with no subscription map")
assert.NotNil(t, s.m, "Should create a map when called on an empty Store")
require.NoError(t, s.Add(&Subscription{Channel: CandlesChannel}), "Adding subscription must not error")
require.Len(t, s.m, 1, "Must have a subscription")
s.Clear()
require.Empty(t, s.m, "Map must be empty after clearing")
assert.NotPanics(t, func() { s.Clear() }, "Should not panic when called on an empty map")
}
// TestStoreDiff exercises the Diff method
func TestStoreDiff(t *testing.T) {
s := NewStore()
assert.NotPanics(t, func() { (*Store)(nil).Diff(List{}) }, "Should not panic when called on nil")
assert.NotPanics(t, func() { (&Store{}).Diff(List{}) }, "Should not panic when called with no subscription map")
subs, unsubs := s.Diff(List{{Channel: TickerChannel}, {Channel: CandlesChannel}, {Channel: OrderbookChannel}})
assert.Equal(t, 3, len(subs), "Should get the correct number of subs")
assert.Empty(t, unsubs, "Should get no unsubs")
for _, sub := range subs {
require.NoError(t, s.add(sub), "add must not error")
}
assert.NotPanics(t, func() { s.Diff(nil) }, "Should not panic when called with nil list")
subs, unsubs = s.Diff(List{{Channel: CandlesChannel}})
assert.Empty(t, subs, "Should get no subs")
assert.Equal(t, 2, len(unsubs), "Should get the correct number of unsubs")
subs, unsubs = s.Diff(List{{Channel: TickerChannel}, {Channel: MyTradesChannel}})
require.Equal(t, 1, len(subs), "Should get the correct number of subs")
assert.Equal(t, MyTradesChannel, subs[0].Channel, "Should get correct channels in sub")
require.Equal(t, 2, len(unsubs), "Should get the correct number of unsubs")
EqualLists(t, unsubs, List{{Channel: OrderbookChannel}, {Channel: CandlesChannel}})
}
func EqualLists(tb testing.TB, a, b List) {
tb.Helper()
// Must not use store.Diff directly
s, err := NewStoreFromList(a)
require.NoError(tb, err, "NewStoreFromList must not error")
missingMap := maps.Clone(s.m)
var added, missing List
for _, sub := range b {
if found := s.get(sub); found != nil {
delete(missingMap, found.Key)
} else {
added = append(added, sub)
}
}
for _, c := range missingMap {
missing = append(missing, c)
}
if len(added) > 0 || len(missing) > 0 {
fail := "Differences:"
if len(added) > 0 {
fail = fail + "\n + " + strings.Join(added.Strings(), "\n + ")
}
if len(missing) > 0 {
fail = fail + "\n - " + strings.Join(missing.Strings(), "\n - ")
}
assert.Fail(tb, fail, "Subscriptions should be equal")
}
}

View File

@@ -1,92 +1,163 @@
package subscription
import (
"encoding/json"
"errors"
"fmt"
"maps"
"slices"
"sync"
"github.com/thrasher-corp/gocryptotrader/currency"
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
)
// DefaultKey is the fallback key for AddSuccessfulSubscriptions
type DefaultKey struct {
Channel string
Pair currency.Pair
Asset asset.Item
}
// State constants
const (
InactiveState State = iota
SubscribingState
SubscribedState
ResubscribingState
UnsubscribingState
UnsubscribedState
)
// Channel constants
const (
TickerChannel = "ticker"
OrderbookChannel = "orderbook"
CandlesChannel = "candles"
AllOrdersChannel = "allOrders"
AllTradesChannel = "allTrades"
MyTradesChannel = "myTrades"
MyOrdersChannel = "myOrders"
)
// 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")
)
// State tracks the status of a subscription channel
type State uint8
const (
UnknownState State = iota // UnknownState subscription state is not registered, but doesn't imply Inactive
SubscribingState // SubscribingState means channel is in the process of subscribing
SubscribedState // SubscribedState means the channel has finished a successful and acknowledged subscription
UnsubscribingState // UnsubscribingState means the channel has started to unsubscribe, but not yet confirmed
TickerChannel = "ticker" // TickerChannel Subscription Type
OrderbookChannel = "orderbook" // OrderbookChannel Subscription Type
CandlesChannel = "candles" // CandlesChannel Subscription Type
AllOrdersChannel = "allOrders" // AllOrdersChannel Subscription Type
AllTradesChannel = "allTrades" // AllTradesChannel Subscription Type
MyTradesChannel = "myTrades" // MyTradesChannel Subscription Type
MyOrdersChannel = "myOrders" // MyOrdersChannel Subscription Type
)
// Subscription container for streaming subscriptions
type Subscription struct {
Enabled bool `json:"enabled"`
Key any `json:"-"`
Channel string `json:"channel,omitempty"`
Pair currency.Pair `json:"pair,omitempty"`
Asset asset.Item `json:"asset,omitempty"`
Params map[string]interface{} `json:"params,omitempty"`
State State `json:"-"`
Interval kline.Interval `json:"interval,omitempty"`
Levels int `json:"levels,omitempty"`
Authenticated bool `json:"authenticated,omitempty"`
Enabled bool `json:"enabled"`
Key any `json:"-"`
Channel string `json:"channel,omitempty"`
Pairs currency.Pairs `json:"pairs,omitempty"`
Asset asset.Item `json:"asset,omitempty"`
Params map[string]any `json:"params,omitempty"`
Interval kline.Interval `json:"interval,omitempty"`
Levels int `json:"levels,omitempty"`
Authenticated bool `json:"authenticated,omitempty"`
state State
m sync.RWMutex
}
// MarshalJSON generates a JSON representation of a Subscription, specifically for config writing
// The only reason it exists is to avoid having to make Pair a pointer, since that would be generally painful
// If Pair becomes a pointer, this method is redundant and should be removed
func (s *Subscription) MarshalJSON() ([]byte, error) {
// None of the usual type embedding tricks seem to work for not emitting an nil Pair
// The embedded type's Pair always fills the empty value
type MaybePair struct {
Enabled bool `json:"enabled"`
Channel string `json:"channel,omitempty"`
Asset asset.Item `json:"asset,omitempty"`
Params map[string]interface{} `json:"params,omitempty"`
Interval kline.Interval `json:"interval,omitempty"`
Levels int `json:"levels,omitempty"`
Authenticated bool `json:"authenticated,omitempty"`
Pair *currency.Pair `json:"pair,omitempty"`
}
k := MaybePair{s.Enabled, s.Channel, s.Asset, s.Params, s.Interval, s.Levels, s.Authenticated, nil}
if s.Pair != currency.EMPTYPAIR {
k.Pair = &s.Pair
}
return json.Marshal(k)
}
// String implements the Stringer interface for Subscription, giving a human representation of the subscription
// String implements Stringer, and aims to informatively and uniquely identify a subscription for errors and information
// returns a string of the subscription key by delegating to MatchableKey.String() when possible
// If the key is not a MatchableKey then both the key and an ExactKey.String() will be returned; e.g. 1137: spot MyTrades
func (s *Subscription) String() string {
return fmt.Sprintf("%s %s %s", s.Channel, s.Asset, s.Pair)
key := s.EnsureKeyed()
s.m.RLock()
defer s.m.RUnlock()
if k, ok := key.(MatchableKey); ok {
return k.String()
}
return fmt.Sprintf("%v: %s", key, ExactKey{s}.String())
}
// EnsureKeyed sets the default key on a channel if it doesn't have one
// Returns key for convenience
// State returns the subscription state
func (s *Subscription) State() State {
s.m.RLock()
defer s.m.RUnlock()
return s.state
}
// SetState sets the subscription state
// Errors if already in that state or the new state is not valid
func (s *Subscription) SetState(state State) error {
s.m.Lock()
defer s.m.Unlock()
if state == s.state {
return ErrInStateAlready
}
if state > UnsubscribedState {
return ErrInvalidState
}
s.state = state
return nil
}
// SetKey does what it says on the tin safely for concurrency
func (s *Subscription) SetKey(key any) {
s.m.Lock()
defer s.m.Unlock()
s.Key = key
}
// EnsureKeyed returns the subscription key
// If no key exists then ExactKey will be used
func (s *Subscription) EnsureKeyed() any {
if s.Key == nil {
s.Key = DefaultKey{
Channel: s.Channel,
Asset: s.Asset,
Pair: s.Pair,
}
// Juggle RLock/WLock to minimize concurrent bottleneck for hottest path
s.m.RLock()
if s.Key != nil {
defer s.m.RUnlock()
return s.Key
}
s.m.RUnlock()
s.m.Lock()
defer s.m.Unlock()
if s.Key == nil { // Ensure race hasn't updated Key whilst we swapped locks
s.Key = &ExactKey{s}
}
return s.Key
}
// Clone returns a copy of a subscription
// Key is set to nil, because most Key types contain a pointer to the subscription, and because the clone isn't added to the store yet
// Users should allow a default key to be assigned on AddSubscription or can SetKey as necessary
func (s *Subscription) Clone() *Subscription {
s.m.RLock()
c := &Subscription{
Key: nil,
Enabled: s.Enabled,
Channel: s.Channel,
Asset: s.Asset,
Params: s.Params,
Interval: s.Interval,
Levels: s.Levels,
Authenticated: s.Authenticated,
state: s.state,
Pairs: s.Pairs,
}
s.Pairs = slices.Clone(s.Pairs)
s.Params = maps.Clone(s.Params)
s.m.RUnlock()
return c
}
// SetPairs does what it says on the tin safely for concurrency
func (s *Subscription) SetPairs(pairs currency.Pairs) {
s.m.Lock()
s.Pairs = pairs
s.m.Unlock()
}
// AddPairs does what it says on the tin safely for concurrency
func (s *Subscription) AddPairs(pairs ...currency.Pair) {
if len(pairs) == 0 {
return
}
s.m.Lock()
for _, p := range pairs {
s.Pairs = s.Pairs.Add(p)
}
s.m.Unlock()
}

View File

@@ -10,39 +10,80 @@ import (
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
)
// TestEnsureKeyed logic test
func TestEnsureKeyed(t *testing.T) {
t.Parallel()
c := Subscription{
var (
btcusdtPair = currency.NewPair(currency.BTC, currency.USDT)
ethusdcPair = currency.NewPair(currency.ETH, currency.USDC)
ltcusdcPair = currency.NewPair(currency.LTC, currency.USDC)
)
// TestSubscriptionString exercises the String method
func TestSubscriptionString(t *testing.T) {
s := &Subscription{
Channel: "candles",
Asset: asset.Spot,
Pair: currency.NewPair(currency.BTC, currency.USDT),
}
k1, ok := c.EnsureKeyed().(DefaultKey)
if assert.True(t, ok, "EnsureKeyed should return a DefaultKey") {
assert.Exactly(t, k1, c.Key, "EnsureKeyed should set the same key")
assert.Equal(t, k1.Channel, c.Channel, "DefaultKey channel should be correct")
assert.Equal(t, k1.Asset, c.Asset, "DefaultKey asset should be correct")
assert.Equal(t, k1.Pair, c.Pair, "DefaultKey currency should be correct")
}
type platypus string
c = Subscription{
Key: platypus("Gerald"),
Channel: "orderbook",
Asset: asset.Margin,
Pair: currency.NewPair(currency.ETH, currency.USDC),
}
k2, ok := c.EnsureKeyed().(platypus)
if assert.True(t, ok, "EnsureKeyed should return a platypus") {
assert.Exactly(t, k2, c.Key, "EnsureKeyed should set the same key")
assert.EqualValues(t, "Gerald", k2, "key should have the correct value")
Pairs: currency.Pairs{btcusdtPair, ethusdcPair.Format(currency.PairFormat{Delimiter: "/"})},
}
assert.Equal(t, "candles spot BTC/USDT,ETH/USDC", s.String(), "Subscription String should return correct value")
}
// TestMarshalling logic test
func TestMarshaling(t *testing.T) {
// TestState exercises the state getter
func TestState(t *testing.T) {
t.Parallel()
j, err := json.Marshal(&Subscription{Channel: CandlesChannel})
s := &Subscription{}
assert.Equal(t, InactiveState, s.State(), "State should return initial state")
s.state = SubscribedState
assert.Equal(t, SubscribedState, s.State(), "State should return correct state")
}
// TestSetState exercises the state setter
func TestSetState(t *testing.T) {
t.Parallel()
s := &Subscription{state: UnsubscribedState}
for i := InactiveState; i <= UnsubscribedState; i++ {
assert.NoErrorf(t, s.SetState(i), "State should not error setting state %s", i)
}
assert.ErrorIs(t, s.SetState(UnsubscribedState), ErrInStateAlready, "SetState should error on same state")
assert.ErrorIs(t, s.SetState(UnsubscribedState+1), ErrInvalidState, "Setting an invalid state should error")
}
// TestString exercises the Stringer implementation
func TestString(t *testing.T) {
s := &Subscription{
Channel: "candles",
Asset: asset.Spot,
Pairs: currency.Pairs{btcusdtPair},
}
_ = s.EnsureKeyed()
assert.Equal(t, "candles spot BTC/USDT", s.String(), "String with a MatchableKey")
s.Key = 42
assert.Equal(t, "42: candles spot BTC/USDT", s.String(), "String with a MatchableKey")
}
// TestEnsureKeyed exercises the key getter and ensures it sets a self-pointer key for non
func TestEnsureKeyed(t *testing.T) {
t.Parallel()
s := &Subscription{}
k1, ok := s.EnsureKeyed().(MatchableKey)
if assert.True(t, ok, "EnsureKeyed should return a MatchableKey") {
assert.Same(t, s, k1.GetSubscription(), "Key should point to the same struct")
}
type platypus string
s = &Subscription{
Key: platypus("Gerald"),
Channel: "orderbook",
}
k2 := s.EnsureKeyed()
assert.IsType(t, platypus(""), k2, "EnsureKeyed should return a platypus")
assert.Equal(t, s.Key, k2, "Key should be the key provided")
}
// TestSubscriptionMarshalling ensures json Marshalling is clean and concise
// Since there is no UnmarshalJSON, this just exercises the json field tags of Subscription, and regressions in conciseness
func TestSubscriptionMarshaling(t *testing.T) {
t.Parallel()
j, err := json.Marshal(&Subscription{Key: 42, Channel: CandlesChannel})
assert.NoError(t, err, "Marshalling should not error")
assert.Equal(t, `{"enabled":false,"channel":"candles"}`, string(j), "Marshalling should be clean and concise")
@@ -50,11 +91,46 @@ func TestMarshaling(t *testing.T) {
assert.NoError(t, err, "Marshalling should not error")
assert.Equal(t, `{"enabled":true,"channel":"orderbook","interval":"5m","levels":4}`, string(j), "Marshalling should be clean and concise")
j, err = json.Marshal(&Subscription{Enabled: true, Channel: OrderbookChannel, Interval: kline.FiveMin, Levels: 4, Pair: currency.NewPair(currency.BTC, currency.USDT)})
j, err = json.Marshal(&Subscription{Enabled: true, Channel: OrderbookChannel, Interval: kline.FiveMin, Levels: 4, Pairs: currency.Pairs{currency.NewPair(currency.BTC, currency.USDT)}})
assert.NoError(t, err, "Marshalling should not error")
assert.Equal(t, `{"enabled":true,"channel":"orderbook","interval":"5m","levels":4,"pair":"BTCUSDT"}`, string(j), "Marshalling should be clean and concise")
assert.Equal(t, `{"enabled":true,"channel":"orderbook","pairs":"BTCUSDT","interval":"5m","levels":4}`, string(j), "Marshalling should be clean and concise")
j, err = json.Marshal(&Subscription{Enabled: true, Channel: MyTradesChannel, Authenticated: true})
assert.NoError(t, err, "Marshalling should not error")
assert.Equal(t, `{"enabled":true,"channel":"myTrades","authenticated":true}`, string(j), "Marshalling should be clean and concise")
}
// TestClone exercises Clone
func TestClone(t *testing.T) {
a := &Subscription{
Channel: TickerChannel,
Interval: kline.OneHour,
Pairs: currency.Pairs{btcusdtPair},
Params: map[string]any{"a": 42},
}
a.EnsureKeyed()
b := a.Clone()
assert.IsType(t, new(Subscription), b, "Clone must return a Subscription pointer")
assert.NotSame(t, a, b, "Clone should return a new Subscription")
assert.Nil(t, b.Key, "Clone should have a nil key")
b.Pairs[0] = ethusdcPair
assert.Equal(t, btcusdtPair, a.Pairs[0], "Pairs should be (relatively) deep copied")
b.Params["a"] = 12
assert.Equal(t, 42, a.Params["a"], "Params should be (relatively) deep copied")
a.m.Lock()
assert.True(t, b.m.TryLock(), "Clone must use a different Mutex")
}
// TestSetKey exercises SetKey
func TestSetKey(t *testing.T) {
s := &Subscription{}
s.SetKey(14)
assert.Equal(t, 14, s.Key, "SetKey should set a key correctly")
}
// TestSetPairs exercises SetPairs
func TestSetPairs(t *testing.T) {
s := &Subscription{}
s.SetPairs(currency.Pairs{btcusdtPair})
assert.Equal(t, "BTCUSDT", s.Pairs.Join(), "SetPairs should set a key correctly")
}