From ce8063654dcfd016b3d94210867ab92a06c29247 Mon Sep 17 00:00:00 2001 From: c9s Date: Sat, 5 Aug 2023 16:38:46 +0800 Subject: [PATCH 1/3] tradingutil: add test on CollectTradeFee --- pkg/util/tradingutil/trades_test.go | 41 +++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 pkg/util/tradingutil/trades_test.go diff --git a/pkg/util/tradingutil/trades_test.go b/pkg/util/tradingutil/trades_test.go new file mode 100644 index 000000000..381eed483 --- /dev/null +++ b/pkg/util/tradingutil/trades_test.go @@ -0,0 +1,41 @@ +package tradingutil + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +var number = fixedpoint.MustNewFromString + +func Test_CollectTradeFee(t *testing.T) { + trades := []types.Trade{ + { + ID: 1, + Price: number("21000"), + Quantity: number("0.001"), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Fee: number("0.00001"), + FeeCurrency: "BTC", + FeeDiscounted: false, + }, + { + ID: 2, + Price: number("21200"), + Quantity: number("0.001"), + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Fee: number("0.00002"), + FeeCurrency: "BTC", + FeeDiscounted: false, + }, + } + + fees := CollectTradeFee(trades) + assert.NotNil(t, fees) + assert.Equal(t, number("0.00003"), fees["BTC"]) +} From 8b6a8aeb7b773fd7a8f094837c9153fae0cee200 Mon Sep 17 00:00:00 2001 From: c9s Date: Sat, 5 Aug 2023 16:39:03 +0800 Subject: [PATCH 2/3] convert: move moq check/adjustment to types.Market --- pkg/strategy/convert/strategy.go | 53 +++++++------------------------- pkg/types/market.go | 46 +++++++++++++++++++++++++++ pkg/types/market_test.go | 25 +++++++++++++++ 3 files changed, 82 insertions(+), 42 deletions(-) diff --git a/pkg/strategy/convert/strategy.go b/pkg/strategy/convert/strategy.go index 5f8618864..419ea658d 100644 --- a/pkg/strategy/convert/strategy.go +++ b/pkg/strategy/convert/strategy.go @@ -162,7 +162,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { s.collectPendingQuantity(ctx) - + _ = s.orderExecutor.GracefulCancel(ctx) }) @@ -327,27 +327,16 @@ func (s *Strategy) convertBalance(ctx context.Context, fromAsset string, availab switch fromAsset { case market.BaseCurrency: - log.Infof("converting %s %s to %s...", available, fromAsset, market.QuoteCurrency) - - available = market.TruncateQuantity(available) - - // from = Base -> action = sell - if available.Compare(market.MinQuantity) < 0 { - log.Debugf("asset %s %s is less than minQuantity %s, skip convert", available, fromAsset, market.MinQuantity) - return nil - } - price := ticker.Sell if s.UseTakerOrder { price = ticker.Buy } - quoteAmount := price.Mul(available) - if quoteAmount.Compare(market.MinNotional) < 0 { - log.Debugf("asset %s %s (%s %s) is less than minNotional %s, skip convert", - available, fromAsset, - quoteAmount, market.QuoteCurrency, - market.MinNotional) + log.Infof("converting %s %s to %s...", available, fromAsset, market.QuoteCurrency) + + quantity, ok := market.GreaterThanMinimalOrderQuantity(types.SideTypeSell, price, available) + if !ok { + log.Debugf("asset %s %s is less than MoQ, skip convert", available, fromAsset) return nil } @@ -355,7 +344,7 @@ func (s *Strategy) convertBalance(ctx context.Context, fromAsset string, availab Symbol: market.Symbol, Side: types.SideTypeSell, Type: types.OrderTypeLimit, - Quantity: available, + Quantity: quantity, Price: price, Market: market, TimeInForce: types.TimeInForceGTC, @@ -365,36 +354,16 @@ func (s *Strategy) convertBalance(ctx context.Context, fromAsset string, availab } case market.QuoteCurrency: - log.Infof("converting %s %s to %s...", available, fromAsset, market.BaseCurrency) - - available = market.TruncateQuoteQuantity(available) - - // from = Quote -> action = buy - if available.Compare(market.MinNotional) < 0 { - log.Debugf("asset %s %s is less than minNotional %s, skip convert", available, fromAsset, market.MinNotional) - return nil - } - price := ticker.Buy if s.UseTakerOrder { price = ticker.Sell } - quantity := available.Div(price) - quantity = market.TruncateQuantity(quantity) - if quantity.Compare(market.MinQuantity) < 0 { - log.Debugf("asset %s %s is less than minQuantity %s, skip convert", - quantity, fromAsset, - market.MinQuantity) - return nil - } + log.Infof("converting %s %s to %s...", available, fromAsset, market.BaseCurrency) - notional := quantity.Mul(price) - if notional.Compare(market.MinNotional) < 0 { - log.Debugf("asset %s %s (%s %s) is less than minNotional %s, skip convert", - quantity, fromAsset, - notional, market.QuoteCurrency, - market.MinNotional) + quantity, ok := market.GreaterThanMinimalOrderQuantity(types.SideTypeBuy, price, available) + if !ok { + log.Debugf("asset %s %s is less than MoQ, skip convert", available, fromAsset) return nil } diff --git a/pkg/types/market.go b/pkg/types/market.go index 0c598799f..07f2cc0da 100644 --- a/pkg/types/market.go +++ b/pkg/types/market.go @@ -72,6 +72,7 @@ func (m Market) TruncateQuantity(quantity fixedpoint.Value) fixedpoint.Value { } // TruncateQuoteQuantity uses the tick size to truncate floating number, in order to avoid the rounding issue +// this is usually used for calculating the order size from the quote quantity. func (m Market) TruncateQuoteQuantity(quantity fixedpoint.Value) fixedpoint.Value { var ts = m.TickSize.Float64() var prec = int(math.Round(math.Log10(ts) * -1.0)) @@ -84,6 +85,51 @@ func (m Market) TruncateQuoteQuantity(quantity fixedpoint.Value) fixedpoint.Valu return fixedpoint.MustNewFromString(qs) } +// GreaterThanMinimalOrderQuantity ensures that your given balance could fit the minimal order quantity +// when side = sell, then available = base balance +// when side = buy, then available = quote balance +// The balance will be truncated first in order to calculate the minimal notional and minimal quantity +// The adjusted (truncated) order quantity will be returned +func (m Market) GreaterThanMinimalOrderQuantity(side SideType, price, available fixedpoint.Value) (fixedpoint.Value, bool) { + switch side { + case SideTypeSell: + available = m.TruncateQuantity(available) + + if available.Compare(m.MinQuantity) < 0 { + return fixedpoint.Zero, false + } + + quoteAmount := price.Mul(available) + if quoteAmount.Compare(m.MinNotional) < 0 { + return fixedpoint.Zero, false + } + + return available, true + + case SideTypeBuy: + available = m.TruncateQuoteQuantity(available) + + if available.Compare(m.MinNotional) < 0 { + return fixedpoint.Zero, false + } + + quantity := available.Div(price) + quantity = m.TruncateQuantity(quantity) + if quantity.Compare(m.MinQuantity) < 0 { + return fixedpoint.Zero, false + } + + notional := quantity.Mul(price) + if notional.Compare(m.MinNotional) < 0 { + return fixedpoint.Zero, false + } + + return quantity, true + } + + return available, true +} + // RoundDownQuantityByPrecision uses the volume precision to round down the quantity // This is different from the TruncateQuantity, which uses StepSize (it uses fewer fractions to truncate) func (m Market) RoundDownQuantityByPrecision(quantity fixedpoint.Value) fixedpoint.Value { diff --git a/pkg/types/market_test.go b/pkg/types/market_test.go index 7966a6d5a..a20121750 100644 --- a/pkg/types/market_test.go +++ b/pkg/types/market_test.go @@ -13,6 +13,31 @@ import ( var s func(string) fixedpoint.Value = fixedpoint.MustNewFromString +func TestMarket_GreaterThanMinimalOrderQuantity(t *testing.T) { + market := Market{ + Symbol: "BTCUSDT", + LocalSymbol: "BTCUSDT", + PricePrecision: 8, + VolumePrecision: 8, + QuoteCurrency: "USDT", + BaseCurrency: "BTC", + MinNotional: number(10.0), + MinAmount: number(10.0), + MinQuantity: number(0.0001), + StepSize: number(0.00001), + TickSize: number(0.01), + } + + _, ok := market.GreaterThanMinimalOrderQuantity(SideTypeSell, number(20000.0), number(0.00051)) + assert.True(t, ok) + + _, ok = market.GreaterThanMinimalOrderQuantity(SideTypeBuy, number(20000.0), number(10.0)) + assert.True(t, ok) + + _, ok = market.GreaterThanMinimalOrderQuantity(SideTypeBuy, number(20000.0), number(0.99999)) + assert.False(t, ok) +} + func TestFormatQuantity(t *testing.T) { quantity := formatQuantity( s("0.12511"), From c3cce05bdd541dde42e7b54d9684359222a0b2fc Mon Sep 17 00:00:00 2001 From: c9s Date: Sat, 5 Aug 2023 16:49:25 +0800 Subject: [PATCH 3/3] xalign: apply market.GreaterThanMinimalOrderQuantity on xalign --- pkg/strategy/xalign/strategy.go | 79 +++++++++++++++------------------ 1 file changed, 36 insertions(+), 43 deletions(-) diff --git a/pkg/strategy/xalign/strategy.go b/pkg/strategy/xalign/strategy.go index 012bf56cb..140c1fb50 100644 --- a/pkg/strategy/xalign/strategy.go +++ b/pkg/strategy/xalign/strategy.go @@ -145,8 +145,9 @@ func (s *Strategy) selectSessionForCurrency(ctx context.Context, sessions map[st // changeQuantity < 0 = sell q := changeQuantity.Abs() + // a fast filtering if q.Compare(market.MinQuantity) < 0 { - log.Infof("skip dust quantity: %f", q.Float64()) + log.Debugf("skip dust quantity: %f", q.Float64()) continue } @@ -155,11 +156,6 @@ func (s *Strategy) selectSessionForCurrency(ctx context.Context, sessions map[st switch side { case types.SideTypeBuy: - quoteBalance, ok := session.Account.Balance(quoteCurrency) - if !ok { - continue - } - price := ticker.Sell if taker { price = ticker.Sell @@ -169,6 +165,11 @@ func (s *Strategy) selectSessionForCurrency(ctx context.Context, sessions map[st price = ticker.Buy } + quoteBalance, ok := session.Account.Balance(quoteCurrency) + if !ok { + continue + } + requiredQuoteAmount := q.Mul(price) requiredQuoteAmount = requiredQuoteAmount.Round(market.PricePrecision, fixedpoint.Up) if requiredQuoteAmount.Compare(quoteBalance.Available) > 0 { @@ -176,24 +177,28 @@ func (s *Strategy) selectSessionForCurrency(ctx context.Context, sessions map[st continue } - if market.IsDustQuantity(q, price) { - log.Infof("%s ignore dust quantity: %f", currency, q.Float64()) - return nil, nil - } - - q = market.AdjustQuantityByMinNotional(q, price) - - return session, &types.SubmitOrder{ - Symbol: symbol, - Side: side, - Type: types.OrderTypeLimit, - Quantity: q, - Price: price, - Market: market, - TimeInForce: "GTC", + if quantity, ok := market.GreaterThanMinimalOrderQuantity(side, price, requiredQuoteAmount); ok { + return session, &types.SubmitOrder{ + Symbol: symbol, + Side: side, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + Market: market, + TimeInForce: types.TimeInForceGTC, + } } case types.SideTypeSell: + price := ticker.Buy + if taker { + price = ticker.Buy + } else if spread.Compare(market.TickSize) > 0 { + price = ticker.Buy.Add(market.TickSize) + } else { + price = ticker.Sell + } + baseBalance, ok := session.Account.Balance(currency) if !ok { continue @@ -204,28 +209,16 @@ func (s *Strategy) selectSessionForCurrency(ctx context.Context, sessions map[st continue } - price := ticker.Buy - if taker { - price = ticker.Buy - } else if spread.Compare(market.TickSize) > 0 { - price = ticker.Buy.Add(market.TickSize) - } else { - price = ticker.Sell - } - - if market.IsDustQuantity(q, price) { - log.Infof("%s ignore dust quantity: %f", currency, q.Float64()) - return nil, nil - } - - return session, &types.SubmitOrder{ - Symbol: symbol, - Side: side, - Type: types.OrderTypeLimit, - Quantity: q, - Price: price, - Market: market, - TimeInForce: "GTC", + if quantity, ok := market.GreaterThanMinimalOrderQuantity(side, price, q); ok { + return session, &types.SubmitOrder{ + Symbol: symbol, + Side: side, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + Market: market, + TimeInForce: types.TimeInForceGTC, + } } }