diff --git a/pkg/strategy/grid2/strategy.go b/pkg/strategy/grid2/strategy.go index 91cc53316..45ee95372 100644 --- a/pkg/strategy/grid2/strategy.go +++ b/pkg/strategy/grid2/strategy.go @@ -6,6 +6,7 @@ import ( "math" "sort" "strconv" + "strings" "sync" "time" @@ -134,6 +135,8 @@ type Strategy struct { ClearOpenOrdersIfMismatch bool `json:"clearOpenOrdersIfMismatch"` + ClearDuplicatedPriceOpenOrders bool `json:"clearDuplicatedPriceOpenOrders"` + // UseCancelAllOrdersApiWhenClose uses a different API to cancel all the orders on the market when closing a grid UseCancelAllOrdersApiWhenClose bool `json:"useCancelAllOrdersApiWhenClose"` @@ -1158,15 +1161,35 @@ func sortOrdersByPriceAscending(orders []types.Order) []types.Order { } func (s *Strategy) debugGridOrders(submitOrders []types.SubmitOrder, lastPrice fixedpoint.Value) { - s.logger.Infof("GRID ORDERS: [") + var sb strings.Builder + + sb.WriteString("GRID ORDERS [") for i, order := range submitOrders { if i > 0 && lastPrice.Compare(order.Price) >= 0 && lastPrice.Compare(submitOrders[i-1].Price) <= 0 { - s.logger.Infof(" - LAST PRICE: %f", lastPrice.Float64()) + sb.WriteString(fmt.Sprintf(" - LAST PRICE: %f", lastPrice.Float64())) } - s.logger.Info(" - ", order.String()) + sb.WriteString(" - " + order.String()) } - s.logger.Infof("] END OF GRID ORDERS") + sb.WriteString("] END OF GRID ORDERS") + + s.logger.Infof(sb.String()) +} + +func (s *Strategy) debugOrders(desc string, orders []types.Order) { + var sb strings.Builder + + if desc == "" { + desc = "ORDERS" + } + + sb.WriteString(desc + " [") + for i, order := range orders { + sb.WriteString(fmt.Sprintf(" - %d) %s", i, order.String())) + } + sb.WriteString("]") + + s.logger.Infof(sb.String()) } func (s *Strategy) generateGridOrders(totalQuote, totalBase, lastPrice fixedpoint.Value) ([]types.SubmitOrder, error) { @@ -1884,6 +1907,13 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo. } } } + + if s.ClearDuplicatedPriceOpenOrders { + s.logger.Infof("clearDuplicatedPriceOpenOrders is set, finding duplicated open orders...") + if err := s.cancelDuplicatedPriceOpenOrders(ctx, session); err != nil { + s.logger.WithError(err).Errorf("cancelDuplicatedPriceOpenOrders error") + } + } }) // if TriggerPrice is zero, that means we need to open the grid when start up @@ -2020,11 +2050,55 @@ func (s *Strategy) openOrdersMismatches(ctx context.Context, session *bbgo.Excha return false, nil } -func roundUpMarketQuantity(market types.Market, v fixedpoint.Value, c string) (fixedpoint.Value, int) { - prec := market.VolumePrecision - if c == market.QuoteCurrency { - prec = market.PricePrecision +func (s *Strategy) cancelDuplicatedPriceOpenOrders(ctx context.Context, session *bbgo.ExchangeSession) error { + openOrders, err := session.Exchange.QueryOpenOrders(ctx, s.Symbol) + if err != nil { + return err } - return v.Round(prec, fixedpoint.Up), prec + if len(openOrders) == 0 { + return nil + } + + dupOrders := s.findDuplicatedPriceOpenOrders(openOrders) + + if len(dupOrders) > 0 { + s.debugOrders("DUPLICATED ORDERS", dupOrders) + return session.Exchange.CancelOrders(ctx, dupOrders...) + } + + s.logger.Infof("no duplicated order found") + return nil +} + +func (s *Strategy) findDuplicatedPriceOpenOrders(openOrders []types.Order) (dupOrders []types.Order) { + orderBook := bbgo.NewActiveOrderBook(s.Symbol) + for _, openOrder := range openOrders { + existingOrder := orderBook.Lookup(func(o types.Order) bool { + return o.Price.Compare(openOrder.Price) == 0 + }) + + if existingOrder != nil { + // found duplicated order + // compare creation time and remove the latest created order + // if the creation time equals, then we can just cancel one of them + s.debugOrders( + fmt.Sprintf("found duplicated order at price %s, comparing orders", openOrder.Price.String()), + []types.Order{*existingOrder, openOrder}) + + dupOrder := *existingOrder + if openOrder.CreationTime.After(existingOrder.CreationTime.Time()) { + dupOrder = openOrder + } else if openOrder.CreationTime.Before(existingOrder.CreationTime.Time()) { + // override the existing order and take the existing order as a duplicated one + orderBook.Add(openOrder) + } + + dupOrders = append(dupOrders, dupOrder) + } else { + orderBook.Add(openOrder) + } + } + + return dupOrders } diff --git a/pkg/strategy/grid2/strategy_test.go b/pkg/strategy/grid2/strategy_test.go index c70a9eaec..8d42273e6 100644 --- a/pkg/strategy/grid2/strategy_test.go +++ b/pkg/strategy/grid2/strategy_test.go @@ -355,11 +355,10 @@ func TestStrategy_calculateQuoteInvestmentQuantity(t *testing.T) { assert.NoError(t, err) assert.InDelta(t, 0.099992, quantity.Float64(), 0.0001) }) - } -func newTestStrategy() *Strategy { - market := types.Market{ +func newTestMarket() types.Market { + return types.Market{ BaseCurrency: "BTC", QuoteCurrency: "USDT", TickSize: number(0.01), @@ -368,6 +367,36 @@ func newTestStrategy() *Strategy { MinNotional: number(10.0), MinQuantity: number(0.001), } +} + +var testOrderID = uint64(0) + +func newTestOrder(price, quantity fixedpoint.Value, side types.SideType) types.Order { + market := newTestMarket() + testOrderID++ + return types.Order{ + SubmitOrder: types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: side, + Type: types.OrderTypeLimit, + Quantity: quantity, + Price: price, + AveragePrice: fixedpoint.Zero, + StopPrice: fixedpoint.Zero, + Market: market, + TimeInForce: types.TimeInForceGTC, + }, + Exchange: "binance", + GID: testOrderID, + OrderID: testOrderID, + Status: types.OrderStatusNew, + ExecutedQuantity: fixedpoint.Zero, + IsWorking: true, + } +} + +func newTestStrategy() *Strategy { + market := newTestMarket() s := &Strategy{ logger: logrus.NewEntry(logrus.New()), @@ -500,6 +529,33 @@ func TestStrategy_aggregateOrderBaseFee(t *testing.T) { assert.Equal(t, "0.01", baseFee.String()) } +func TestStrategy_findDuplicatedPriceOpenOrders(t *testing.T) { + t.Run("no duplicated open orders", func(t *testing.T) { + s := newTestStrategy() + s.grid = s.newGrid() + + dupOrders := s.findDuplicatedPriceOpenOrders([]types.Order{ + newTestOrder(number(1900.0), number(0.1), types.SideTypeSell), + newTestOrder(number(1800.0), number(0.1), types.SideTypeSell), + newTestOrder(number(1700.0), number(0.1), types.SideTypeSell), + }) + assert.Empty(t, dupOrders) + assert.Len(t, dupOrders, 0) + }) + + t.Run("1 duplicated open order SELL", func(t *testing.T) { + s := newTestStrategy() + s.grid = s.newGrid() + + dupOrders := s.findDuplicatedPriceOpenOrders([]types.Order{ + newTestOrder(number(1900.0), number(0.1), types.SideTypeSell), + newTestOrder(number(1900.0), number(0.1), types.SideTypeSell), + newTestOrder(number(1800.0), number(0.1), types.SideTypeSell), + }) + assert.Len(t, dupOrders, 1) + }) +} + func TestStrategy_handleOrderFilled(t *testing.T) { ctx := context.Background() @@ -971,17 +1027,6 @@ func TestStrategy_checkMinimalQuoteInvestment(t *testing.T) { }) } -func Test_roundUpMarketQuantity(t *testing.T) { - q := number("0.00000003") - assert.Equal(t, "0.00000003", q.String()) - - q3, prec := roundUpMarketQuantity(types.Market{ - VolumePrecision: 8, - }, q, "BTC") - assert.Equal(t, "0.00000003", q3.String(), "rounding prec 8") - assert.Equal(t, 8, prec) -} - func Test_buildPinOrderMap(t *testing.T) { assert := assert.New(t) s := newTestStrategy()