BTSE: Various fixes (#1550)

* Common: DriveBy sanitisation of vars

* BTSE: Unify to exchange OrderLimits

* BTSE: Remove SeedAssets and test GetOrderExcutionLimit

* BTSE: Fix handling for K_* pairs

In addition to the M_ pairs we previously handled, BTSE has now
introduced K_* pairs (K_SATS-USD*).

This change moves the handling over to look for an exponent, and moves
the filtering to happen on all market data.
The original MarketSummary is still availiable, but I can't see any of
our current use-cases wanting to get the unfiltered list

* BTSE: Fix marketSummary futures field

BTSE returns no futures field for futures api marketInfo, and the documentation for the futures api shows returning false.
When we know we asked for futures, it makes the data flow much saner if
we can trust this field and not have to track what asset we asked for

* BTSE: Abstract marketPair.Pair()

* BTSE: Fix UpdateTicker symbol format
This commit is contained in:
Gareth Kirwan
2024-06-14 10:09:19 +07:00
committed by GitHub
parent 4c4b6935be
commit 98f025e38f
7 changed files with 223 additions and 269 deletions

View File

@@ -68,8 +68,9 @@ func (b *BTSE) FetchFundingHistory(ctx context.Context, symbol string) (map[stri
return resp, b.SendHTTPRequest(ctx, exchange.RestFutures, http.MethodGet, btseFuturesFunding+params.Encode(), &resp, false, queryFunc)
}
// GetMarketSummary stores market summary data
func (b *BTSE) GetMarketSummary(ctx context.Context, symbol string, spot bool) (MarketSummary, error) {
// GetRawMarketSummary returns an unfiltered list of market pairs
// Consider using the wrapper function GetMarketSummary instead
func (b *BTSE) GetRawMarketSummary(ctx context.Context, symbol string, spot bool) (MarketSummary, error) {
var m MarketSummary
path := btseMarketOverview
if symbol != "" {
@@ -619,23 +620,7 @@ func parseOrderTime(timeStr string) (time.Time, error) {
return time.Parse(time.DateTime, timeStr)
}
// MillionPairs returns a map of symbol names which have a IsMillion equivalent
func (m *MarketSummary) MillionPairs() map[string]bool {
pairs := map[string]bool{}
for _, s := range *m {
if s.Active && s.HasLiquidity() && s.IsMillions() {
pairs[strings.TrimPrefix(s.Symbol, "M_")] = true
}
}
return pairs
}
// HasLiquidity returns if a market pair has a bid or ask != 0
func (m *MarketPair) HasLiquidity() bool {
return m.LowestAsk != 0 || m.HighestBid != 0
}
// IsMillions returns if a market pair represents a million of Base / Quote
func (m *MarketPair) IsMillions() bool {
return strings.HasPrefix(m.Symbol, "M_")
}

View File

@@ -144,6 +144,10 @@ func TestFormatExchangeKlineInterval(t *testing.T) {
func TestGetHistoricCandles(t *testing.T) {
t.Parallel()
r := b.Requester
b := new(BTSE) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes
require.NoError(t, testexch.Setup(b), "Test exchange Setup must not error")
b.Requester = r
start := time.Now().AddDate(0, 0, -3)
_, err := b.GetHistoricCandles(context.Background(), spotPair, asset.Spot, kline.OneHour, start, time.Now())
assert.NoError(t, err, "GetHistoricCandles should not error")
@@ -154,6 +158,10 @@ func TestGetHistoricCandles(t *testing.T) {
func TestGetHistoricCandlesExtended(t *testing.T) {
t.Parallel()
r := b.Requester
b := new(BTSE) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes
require.NoError(t, testexch.Setup(b), "Test exchange Setup must not error")
b.Requester = r
err := b.CurrencyPairs.StorePairs(asset.Futures, currency.Pairs{futuresPair}, true)
assert.NoError(t, err, "StorePairs should not error")
@@ -542,80 +550,28 @@ func TestMatchType(t *testing.T) {
assert.True(t, ret, "matchType should match")
}
func TestSeedOrderSizeLimits(t *testing.T) {
t.Parallel()
err := b.seedOrderSizeLimits(context.Background())
assert.NoError(t, err, "seedOrderSizeLimits should not error")
}
func TestOrderSizeLimits(t *testing.T) {
t.Parallel()
seedOrderSizeLimitMap()
_, ok := OrderSizeLimits(spotPair.String())
assert.True(t, ok, "OrderSizeLimits should find BTC-USD")
_, ok = OrderSizeLimits("XRP-GARBAGE")
assert.False(t, ok, "OrderSizeLimits should not find XRP-GARBAGE until the next bull market")
}
func seedOrderSizeLimitMap() {
testOrderSizeLimits := []struct {
name string
o OrderSizeLimit
}{
{
name: "XRP-USD",
o: OrderSizeLimit{
MinSizeIncrement: 1,
MinOrderSize: 1,
MaxOrderSize: 1000000,
},
},
{
name: "LTC-USD",
o: OrderSizeLimit{
MinSizeIncrement: 0.01,
MinOrderSize: 0.01,
MaxOrderSize: 5000,
},
},
{
name: "BTC-USD",
o: OrderSizeLimit{
MinSizeIncrement: 0.0001,
MinOrderSize: 1,
MaxOrderSize: 1000000,
},
},
}
orderSizeLimitMap.Range(func(key interface{}, _ interface{}) bool {
orderSizeLimitMap.Delete(key)
return true
})
for x := range testOrderSizeLimits {
orderSizeLimitMap.Store(testOrderSizeLimits[x].name, testOrderSizeLimits[x].o)
}
}
func TestWithinLimits(t *testing.T) {
// TestUpdateOrderExecutionLimits exercises UpdateOrderExecutionLimits
func TestUpdateOrderExecutionLimits(t *testing.T) {
t.Parallel()
testexch.UpdatePairsOnce(t, b)
seedOrderSizeLimitMap()
p, _ := currency.NewPairDelimiter("XRP-USD", "-")
assert.NoError(t, b.withinLimits(p, 1.0), "withinLimits should not error")
assert.NoError(t, b.withinLimits(p, 5.0000001), "withinLimits should not error")
assert.NoError(t, b.withinLimits(p, 100), "withinLimits should not error")
assert.NoError(t, b.withinLimits(p, 10.1), "withinLimits should not error")
for _, a := range b.GetAssetTypes(false) {
err := b.UpdateOrderExecutionLimits(context.Background(), a)
require.NoErrorf(t, err, "UpdateOrderExecutionLimits must not error for %s", a)
p.Base = currency.LTC
assert.NoError(t, b.withinLimits(p, 10), "withinLimits should not error")
assert.ErrorIs(t, b.withinLimits(p, 0.009), order.ErrAmountBelowMin, "withinLimits should error correctly")
pairs, err := b.GetAvailablePairs(a)
require.NoErrorf(t, err, "GetAvailablePairs must not error for %s", a)
require.NotEmpty(t, pairs, "GetAvailablePairs must return some pairs")
p.Base = currency.BTC
assert.NoError(t, b.withinLimits(p, 10), "withinLimits should not error")
assert.ErrorIs(t, b.withinLimits(p, 0.001), order.ErrAmountBelowMin, "withinLimits should error correctly")
for _, p := range pairs {
limits, err := b.GetOrderExecutionLimits(a, p)
require.NoErrorf(t, err, "GetOrderExecutionLimits must not error for %s %s", a, p)
assert.Positivef(t, limits.MinimumBaseAmount, "MinimumBaseAmount must be positive for %s %s", a, p)
assert.Positivef(t, limits.MaximumBaseAmount, "MaximumBaseAmount must be positive for %s %s", a, p)
assert.Positivef(t, limits.AmountStepIncrementSize, "AmountStepIncrementSize must be positive for %s %s", a, p)
assert.Positivef(t, limits.MinPrice, "MinPrice must be positive for %s %s", a, p)
assert.Positivef(t, limits.PriceStepIncrementSize, "PriceStepIncrementSize must be positive for %s %s", a, p)
}
}
}
func TestGetRecentTrades(t *testing.T) {
@@ -718,7 +674,11 @@ func TestIsPerpetualFutureCurrency(t *testing.T) {
func TestGetOpenInterest(t *testing.T) {
t.Parallel()
r := b.Requester
b := new(BTSE) //nolint:govet // Intentional shadow to avoid future copy/paste mistakes
require.NoError(t, testexch.Setup(b), "Test exchange Setup must not error")
testexch.UpdatePairsOnce(t, b)
b.Requester = r
cp1 := currency.NewPair(currency.BTC, currency.PFC)
cp2 := currency.NewPair(currency.ETH, currency.PFC)
sharedtestvalues.SetupCurrencyPairsForExchangeAsset(t, b, asset.Futures, futuresPair, cp1, cp2)
@@ -769,3 +729,23 @@ func TestGetCurrencyTradeURL(t *testing.T) {
assert.NotEmpty(t, resp)
}
}
// TestStripExponent exercises StripExponent
func TestStripExponent(t *testing.T) {
t.Parallel()
s, err := (&MarketPair{Symbol: "BTC-ETH"}).StripExponent()
assert.NoError(t, err, "Should not error on a symbol without exponent")
assert.Empty(t, s, "Should return an empty symbol without exponent")
for _, p := range []string{"B", "M", "K"} {
s, err = (&MarketPair{Symbol: p + "_BTC-ETH"}).StripExponent()
assert.NoError(t, err, "Should not error on a symbol with exponent")
assert.Equal(t, "BTC-ETH", s, "Should return the symbol without the exponent")
}
_, err = (&MarketPair{Symbol: "Z_BTC-ETH"}).StripExponent()
assert.ErrorIs(t, err, errInvalidPairSymbol, "Should error on a symbol with unknown exponent")
_, err = (&MarketPair{Symbol: "M_BTC_ETH"}).StripExponent()
assert.ErrorIs(t, err, errInvalidPairSymbol, "Should error on a symbol with too many underscores")
}

View File

@@ -1,7 +1,6 @@
package btse
import (
"sync"
"time"
)
@@ -354,16 +353,6 @@ type ErrorResponse struct {
Status int `json:"status"`
}
// OrderSizeLimit holds accepted minimum, maximum, and size increment when submitting new orders
type OrderSizeLimit struct {
MinOrderSize float64
MaxOrderSize float64
MinSizeIncrement float64
}
// orderSizeLimitMap map of OrderSizeLimit per currency
var orderSizeLimitMap sync.Map
// WsSubscriptionAcknowledgement contains successful subscription messages
type WsSubscriptionAcknowledgement struct {
Channel []string `json:"channel"`

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"math"
"sort"
"strconv"
"strings"
@@ -33,6 +32,11 @@ import (
"github.com/thrasher-corp/gocryptotrader/portfolio/withdraw"
)
// Private Errors
var (
errInvalidPairSymbol = errors.New("invalid currency pair symbol")
)
// SetDefaults sets the basic defaults for BTSE
func (b *BTSE) SetDefaults() {
b.Name = "BTSE"
@@ -193,11 +197,6 @@ func (b *BTSE) Setup(exch *config.Exchange) error {
return err
}
err = b.seedOrderSizeLimits(context.TODO())
if err != nil {
return err
}
return b.Websocket.SetupNewConnection(stream.ConnectionSetup{
ResponseCheckTimeout: exch.WebsocketResponseCheckTimeout,
ResponseMaxLimit: exch.WebsocketResponseMaxLimit,
@@ -210,41 +209,16 @@ func (b *BTSE) FetchTradablePairs(ctx context.Context, a asset.Item) (currency.P
if err != nil {
return nil, err
}
var errs error
pairs := make(currency.Pairs, 0, len(m))
mPairs := m.MillionPairs()
for _, l := range m {
if !l.Active || !l.HasLiquidity() ||
(a == asset.Spot && !l.IsMarketOpenToSpot) { // Skip OTC assets only tradable on web UI
continue
}
if mPairs[l.Symbol] {
// BTSE lists M_ symbols for very small pairs, in millions. For those listings, we want to take the M_ listing in preference
// to the native listing, since they're often going to appear as locked markets due to size (bid == ask, e.g. 0.0000000003)
continue
}
baseCurr := l.Base
var quoteCurr string
if a == asset.Futures {
s := strings.Split(l.Symbol, l.Base) // e.g. RUNEPFC for RUNE-USD futures pair
if len(s) <= 1 {
continue
}
quoteCurr = s[1]
for _, marketInfo := range m {
if pair, err := marketInfo.Pair(); err != nil {
errs = common.AppendError(errs, fmt.Errorf("%s: %w", marketInfo.Symbol, err))
} else {
s := strings.Split(l.Symbol, currency.DashDelimiter)
if len(s) != 2 {
continue
}
baseCurr = s[0]
quoteCurr = s[1]
pairs = append(pairs, pair)
}
pair, err := currency.NewPairFromStrings(baseCurr, quoteCurr)
if err != nil {
return nil, err
}
pairs = append(pairs, pair)
}
return pairs, nil
return pairs, errs
}
// UpdateTradablePairs updates the exchanges available pairs and stores
@@ -305,7 +279,11 @@ func (b *BTSE) UpdateTicker(ctx context.Context, p currency.Pair, a asset.Item)
if !b.SupportsAsset(a) {
return nil, fmt.Errorf("%w %v", asset.ErrNotSupported, a)
}
ticks, err := b.GetMarketSummary(ctx, p.String(), a == asset.Spot)
symbol, err := b.FormatSymbol(p, a)
if err != nil {
return nil, err
}
ticks, err := b.GetMarketSummary(ctx, symbol, a == asset.Spot)
if err != nil {
return nil, err
}
@@ -458,23 +436,6 @@ func (b *BTSE) GetAccountFundingHistory(_ context.Context) ([]exchange.FundingHi
return nil, common.ErrFunctionNotSupported
}
func (b *BTSE) withinLimits(pair currency.Pair, amount float64) error {
val, found := OrderSizeLimits(pair.String())
if !found {
return fmt.Errorf("%w for pair %v", order.ErrExchangeLimitNotLoaded, pair)
}
if math.Mod(amount, val.MinSizeIncrement) < 0 {
return fmt.Errorf("%w %v %v %v", order.ErrAmountBelowMin, pair, amount, val.MinSizeIncrement)
}
if amount < val.MinOrderSize {
return fmt.Errorf("%w %v %v %v", order.ErrAmountBelowMin, pair, amount, val.MinOrderSize)
}
if amount > val.MaxOrderSize {
return fmt.Errorf("%w %v %v %v", order.ErrAmountExceedsMax, pair, amount, val.MinSizeIncrement)
}
return nil
}
// GetWithdrawalsHistory returns previous withdrawals data
func (b *BTSE) GetWithdrawalsHistory(_ context.Context, _ currency.Code, _ asset.Item) ([]exchange.WithdrawalHistory, error) {
return nil, common.ErrFunctionNotSupported
@@ -543,10 +504,6 @@ func (b *BTSE) SubmitOrder(ctx context.Context, s *order.Submit) (*order.SubmitR
if err != nil {
return nil, err
}
err = b.withinLimits(fPair, s.Amount)
if err != nil {
return nil, err
}
r, err := b.CreateOrder(ctx,
s.ClientID, 0.0,
@@ -1077,45 +1034,6 @@ func (b *BTSE) GetHistoricCandlesExtended(ctx context.Context, pair currency.Pai
return req.ProcessResponse(timeSeries)
}
func (b *BTSE) seedOrderSizeLimits(ctx context.Context) error {
pairs, err := b.GetMarketSummary(ctx, "", true)
if err != nil {
return err
}
for x := range pairs {
tempValues := OrderSizeLimit{
MinOrderSize: pairs[x].MinOrderSize,
MaxOrderSize: pairs[x].MaxOrderSize,
MinSizeIncrement: pairs[x].MinSizeIncrement,
}
orderSizeLimitMap.Store(pairs[x].Symbol, tempValues)
}
pairs, err = b.GetMarketSummary(ctx, "", false)
if err != nil {
return err
}
for x := range pairs {
tempValues := OrderSizeLimit{
MinOrderSize: pairs[x].MinOrderSize,
MaxOrderSize: pairs[x].MaxOrderSize,
MinSizeIncrement: pairs[x].MinSizeIncrement,
}
orderSizeLimitMap.Store(pairs[x].Symbol, tempValues)
}
return nil
}
// OrderSizeLimits looks up currency pair in orderSizeLimitMap and returns OrderSizeLimit
func OrderSizeLimits(pair string) (limits OrderSizeLimit, found bool) {
resp, ok := orderSizeLimitMap.Load(pair)
if !ok {
return
}
val, ok := resp.(OrderSizeLimit)
return val, ok
}
// GetServerTime returns the current exchange server time.
func (b *BTSE) GetServerTime(ctx context.Context, _ asset.Item) (time.Time, error) {
st, err := b.GetCurrentServerTime(ctx)
@@ -1125,6 +1043,95 @@ func (b *BTSE) GetServerTime(ctx context.Context, _ asset.Item) (time.Time, erro
return st.ISO, nil
}
// ExponentPairs returns a map of symbol names which have a Exponent equivalent
// e.g. PIT-USD will be returned if M_PIT-USD exists, and SATS-USD if K_SATS-USD exists
func (m *MarketSummary) ExponentPairs() (map[string]bool, error) {
pairs := map[string]bool{}
var errs error
for _, s := range *m {
if s.Active && s.HasLiquidity() {
if symbol, err := s.StripExponent(); err != nil {
errs = common.AppendError(errs, err)
} else if symbol != "" {
pairs[symbol] = true
}
}
}
return pairs, errs
}
// StripExponent returns the symbol without a exponent prefix; e.g. B_, M_, K_
// Returns an empty string if no exponent prefix is found
// Errors if there's too many underscores, or if the exponent is not recognised
func (m *MarketPair) StripExponent() (string, error) {
parts := strings.Split(m.Symbol, "_")
switch len(parts) {
case 1:
return "", nil
case 2:
switch parts[0] {
case "B", "M", "K":
return parts[1], nil
}
}
return "", errInvalidPairSymbol
}
// Pair returns the currency Pair for a MarketPair
func (m *MarketPair) Pair() (currency.Pair, error) {
baseCurr := m.Base
var quoteCurr string
if m.Futures {
s := strings.Split(m.Symbol, m.Base) // e.g. RUNEPFC for RUNE-USD futures pair
if len(s) <= 1 {
return currency.EMPTYPAIR, errInvalidPairSymbol
}
quoteCurr = s[1]
} else {
s := strings.Split(m.Symbol, currency.DashDelimiter)
if len(s) != 2 {
return currency.EMPTYPAIR, errInvalidPairSymbol
}
baseCurr = s[0]
quoteCurr = s[1]
}
return currency.NewPairFromStrings(baseCurr, quoteCurr)
}
// GetMarketSummary returns filtered market pair details; Specifically:
// - Pairs which aren't active are removed
// - Pairs which don't have liquidity are removed
// - OTC pairs only traded on web UI are removed
// - Pairs with an exponent counterpart pair are removed
// BTSE lists M_ symbols for very small pairs, in millions. For those listings, we want to take the M_ listing in preference
// to the native listing, since they're often going to appear as locked markets due to size (bid == ask, e.g. 0.0000000003)
func (b *BTSE) GetMarketSummary(ctx context.Context, symbol string, spot bool) (MarketSummary, error) {
m, err := b.GetRawMarketSummary(ctx, symbol, spot)
if err != nil {
return m, err
}
ePairs, err := m.ExponentPairs()
if err != nil {
return m, err
}
filtered := make(MarketSummary, 0, len(m))
for _, l := range m {
if !l.Active || !l.HasLiquidity() || (spot && !l.IsMarketOpenToSpot) { // Skip OTC assets only tradable on web UI
continue
}
if ePairs[l.Symbol] { // Skip pair with an exponent sibling
continue
}
if !spot {
// BTSE API for futures does not return futures field at all, and the docs show it coming back as false
// Much easier for our data flow if we can trust this field
l.Futures = true
}
filtered = append(filtered, l)
}
return filtered, nil
}
// GetFuturesContractDetails returns details about futures contracts
func (b *BTSE) GetFuturesContractDetails(ctx context.Context, item asset.Item) ([]futures.Contract, error) {
if !item.IsFutures() {
@@ -1265,8 +1272,33 @@ func (b *BTSE) IsPerpetualFutureCurrency(a asset.Item, p currency.Pair) (bool, e
}
// UpdateOrderExecutionLimits updates order execution limits
func (b *BTSE) UpdateOrderExecutionLimits(_ context.Context, _ asset.Item) error {
return common.ErrNotYetImplemented
func (b *BTSE) UpdateOrderExecutionLimits(ctx context.Context, a asset.Item) error {
summary, err := b.GetMarketSummary(ctx, "", a == asset.Spot)
if err != nil {
return err
}
var errs error
limits := make([]order.MinMaxLevel, 0, len(summary))
for _, marketInfo := range summary {
p, err := marketInfo.Pair() //nolint:govet // Deliberately shadow err
if err != nil {
errs = common.AppendError(err, fmt.Errorf("%s: %w", p, err))
continue
}
limits = append(limits, order.MinMaxLevel{
Pair: p,
Asset: a,
MinimumBaseAmount: marketInfo.MinOrderSize,
MaximumBaseAmount: marketInfo.MaxOrderSize,
AmountStepIncrementSize: marketInfo.MinSizeIncrement,
MinPrice: marketInfo.MinValidPrice,
PriceStepIncrementSize: marketInfo.MinPriceIncrement,
})
}
if err = b.LoadLimits(limits); err != nil {
errs = common.AppendError(errs, err)
}
return errs
}
// GetOpenInterest returns the open interest rate for a given asset pair