package grid2 import ( "context" "fmt" "strconv" "sync" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/c9s/bbgo/pkg/bbgo" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/types" "github.com/c9s/bbgo/pkg/util" ) const ID = "grid2" var log = logrus.WithField("strategy", ID) var maxNumberOfOrderTradesQueryTries = 10 func init() { // Register the pointer of the strategy struct, // so that bbgo knows what struct to be used to unmarshal the configs (YAML or JSON) // Note: built-in strategies need to imported manually in the bbgo cmd package. bbgo.RegisterStrategy(ID, &Strategy{}) } type Strategy struct { Environment *bbgo.Environment // Market stores the configuration of the market, for example, VolumePrecision, PricePrecision, MinLotSize... etc // This field will be injected automatically since we defined the Symbol field. types.Market `json:"-"` // These fields will be filled from the config file (it translates YAML to JSON) Symbol string `json:"symbol"` // ProfitSpread is the fixed profit spread you want to submit the sell order // When ProfitSpread is enabled, the grid will shift up, e.g., // If you opened a grid with the price range 10_000 to 20_000 // With profit spread set to 3_000 // The sell orders will be placed in the range 13_000 to 23_000 // And the buy orders will be placed in the original price range 10_000 to 20_000 ProfitSpread fixedpoint.Value `json:"profitSpread"` // GridNum is the grid number, how many orders you want to post on the orderbook. GridNum int64 `json:"gridNumber"` UpperPrice fixedpoint.Value `json:"upperPrice"` LowerPrice fixedpoint.Value `json:"lowerPrice"` // Compound option is used for buying more inventory when // the profit is made by the filled sell order. Compound bool `json:"compound"` // EarnBase option is used for earning profit in base currency. // e.g. earn BTC in BTCUSDT and earn ETH in ETHUSDT // instead of earn USDT in BTCUSD EarnBase bool `json:"earnBase"` // QuantityOrAmount embeds the Quantity field and the Amount field // If you set up the Quantity field or the Amount field, you don't need to set the QuoteInvestment and BaseInvestment bbgo.QuantityOrAmount // If Quantity and Amount is not set, we can use the quote investment to calculate our quantity. QuoteInvestment fixedpoint.Value `json:"quoteInvestment"` // BaseInvestment is the total base quantity you want to place as the sell order. BaseInvestment fixedpoint.Value `json:"baseInvestment"` TriggerPrice fixedpoint.Value `json:"triggerPrice"` StopLossPrice fixedpoint.Value `json:"stopLossPrice"` TakeProfitPrice fixedpoint.Value `json:"takeProfitPrice"` // CloseWhenCancelOrder option is used to close the grid if any of the order is canceled. // This option let you simply remote control the grid from the crypto exchange mobile app. CloseWhenCancelOrder bool `json:"closeWhenCancelOrder"` // KeepOrdersWhenShutdown option is used for keeping the grid orders when shutting down bbgo KeepOrdersWhenShutdown bool `json:"keepOrdersWhenShutdown"` // ClearOpenOrdersWhenStart // If this is set, when bbgo started, it will clear the open orders in the same market (by symbol) ClearOpenOrdersWhenStart bool `json:"clearOpenOrdersWhenStart"` ResetPositionWhenStart bool `json:"resetPositionWhenStart"` // FeeRate is used for calculating the minimal profit spread. // it makes sure that your grid configuration is profitable. FeeRate fixedpoint.Value `json:"feeRate"` GridProfitStats *GridProfitStats `persistence:"grid_profit_stats"` ProfitStats *types.ProfitStats `persistence:"profit_stats"` Position *types.Position `persistence:"position"` grid *Grid session *bbgo.ExchangeSession orderQueryService types.ExchangeOrderQueryService orderExecutor *bbgo.GeneralOrderExecutor historicalTrades *bbgo.TradeStore // groupID is the group ID used for the strategy instance for canceling orders groupID uint32 logger *logrus.Entry } func (s *Strategy) ID() string { return ID } func (s *Strategy) Validate() error { if s.UpperPrice.IsZero() { return errors.New("upperPrice can not be zero, you forgot to set?") } if s.LowerPrice.IsZero() { return errors.New("lowerPrice can not be zero, you forgot to set?") } if s.UpperPrice.Compare(s.LowerPrice) <= 0 { return fmt.Errorf("upperPrice (%s) should not be less than or equal to lowerPrice (%s)", s.UpperPrice.String(), s.LowerPrice.String()) } if s.GridNum == 0 { return fmt.Errorf("gridNum can not be zero") } if s.FeeRate.IsZero() { s.FeeRate = fixedpoint.NewFromFloat(0.1 * 0.01) // 0.1%, 0.075% with BNB } if !s.ProfitSpread.IsZero() { // the min fee rate from 2 maker/taker orders (with 0.1 rate for profit) gridFeeRate := s.FeeRate.Mul(fixedpoint.NewFromFloat(2.01)) if s.ProfitSpread.Div(s.LowerPrice).Compare(gridFeeRate) < 0 { return fmt.Errorf("profitSpread %f %s is too small for lower price, less than the fee rate: %s", s.ProfitSpread.Float64(), s.ProfitSpread.Div(s.LowerPrice).Percentage(), s.FeeRate.Percentage()) } if s.ProfitSpread.Div(s.UpperPrice).Compare(gridFeeRate) < 0 { return fmt.Errorf("profitSpread %f %s is too small for upper price, less than the fee rate: %s", s.ProfitSpread.Float64(), s.ProfitSpread.Div(s.UpperPrice).Percentage(), s.FeeRate.Percentage()) } } if err := s.QuantityOrAmount.Validate(); err != nil { if s.QuoteInvestment.IsZero() && s.BaseInvestment.IsZero() { return err } } if !s.QuantityOrAmount.IsSet() && s.QuoteInvestment.IsZero() && s.BaseInvestment.IsZero() { return fmt.Errorf("one of quantity, amount, quoteInvestment must be set") } return nil } func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval1m}) } // InstanceID returns the instance identifier from the current grid configuration parameters func (s *Strategy) InstanceID() string { return fmt.Sprintf("%s-%s-%d-%d-%d", ID, s.Symbol, s.GridNum, s.UpperPrice.Int(), s.LowerPrice.Int()) } func (s *Strategy) handleOrderCanceled(o types.Order) { s.logger.Infof("GRID ORDER CANCELED: %s", o.String()) ctx := context.Background() if s.CloseWhenCancelOrder { s.logger.Infof("one of the grid orders is canceled, now closing grid...") if err := s.closeGrid(ctx); err != nil { s.logger.WithError(err).Errorf("graceful order cancel error") } } } // TODO: consider order fee func (s *Strategy) calculateProfit(o types.Order, buyPrice, buyQuantity fixedpoint.Value) *GridProfit { if s.EarnBase { // sell quantity - buy quantity profitQuantity := o.Quantity.Sub(buyQuantity) profit := &GridProfit{ Currency: s.Market.BaseCurrency, Profit: profitQuantity, Time: o.UpdateTime.Time(), Order: o, } return profit } // earn quote // (sell_price - buy_price) * quantity profitQuantity := o.Price.Sub(buyPrice).Mul(o.Quantity) profit := &GridProfit{ Currency: s.Market.QuoteCurrency, Profit: profitQuantity, Time: o.UpdateTime.Time(), Order: o, } return profit } // collectTradeFee collects the fee from the given trade slice func collectTradeFee(trades []types.Trade) map[string]fixedpoint.Value { fees := make(map[string]fixedpoint.Value) for _, t := range trades { if fee, ok := fees[t.FeeCurrency]; ok { fees[t.FeeCurrency] = fee.Add(t.Fee) } else { fees[t.FeeCurrency] = t.Fee } } return fees } func aggregateTradesQuantity(trades []types.Trade) fixedpoint.Value { tq := fixedpoint.Zero for _, t := range trades { tq = tq.Add(t.Quantity) } return tq } func (s *Strategy) verifyOrderTrades(o types.Order, trades []types.Trade) bool { tq := aggregateTradesQuantity(trades) if tq.Compare(o.Quantity) != 0 { s.logger.Warnf("order trades missing. expected: %f actual: %f", o.Quantity.Float64(), tq.Float64()) return false } return true } // aggregateOrderBaseFee collects the base fee quantity from the given order // it falls back to query the trades via the RESTful API when the websocket trades are not all received. func (s *Strategy) aggregateOrderBaseFee(o types.Order) fixedpoint.Value { // try to get the received trades (websocket trades) orderTrades := s.historicalTrades.GetOrderTrades(o) if len(orderTrades) > 0 { s.logger.Infof("found filled order trades: %+v", orderTrades) } for maxTries := maxNumberOfOrderTradesQueryTries; maxTries > 0; maxTries-- { // if one of the trades is missing, we need to query the trades from the RESTful API if s.verifyOrderTrades(o, orderTrades) { // if trades are verified fees := collectTradeFee(orderTrades) if fee, ok := fees[s.Market.BaseCurrency]; ok { return fee } return fixedpoint.Zero } // if we don't support orderQueryService, then we should just skip if s.orderQueryService == nil { return fixedpoint.Zero } s.logger.Warnf("missing order trades or missing trade fee, pulling order trades from API") // if orderQueryService is supported, use it to query the trades of the filled order apiOrderTrades, err := s.orderQueryService.QueryOrderTrades(context.Background(), types.OrderQuery{ Symbol: o.Symbol, OrderID: strconv.FormatUint(o.OrderID, 10), }) if err != nil { s.logger.WithError(err).Errorf("query order trades error") } else { s.logger.Infof("fetched api trades: %+v", apiOrderTrades) orderTrades = apiOrderTrades } } return fixedpoint.Zero } // handleOrderFilled is called when an order status is FILLED func (s *Strategy) handleOrderFilled(o types.Order) { if s.grid == nil { return } s.logger.Infof("GRID ORDER FILLED: %s", o.String()) // check order fee newSide := types.SideTypeSell newPrice := o.Price newQuantity := o.Quantity orderQuoteQuantity := o.Quantity.Mul(o.Price) // collect trades baseSellQuantityReduction := fixedpoint.Zero // baseSellQuantityReduction calculation should be only for BUY order // because when 1.0 BTC buy order is filled without FEE token, then we will actually get 1.0 * (1 - feeRate) BTC // if we don't reduce the sell quantity, than we might fail to place the sell order if o.Side == types.SideTypeBuy { baseSellQuantityReduction = s.aggregateOrderBaseFee(o) s.logger.Infof("base fee: %f %s", baseSellQuantityReduction.Float64(), s.Market.BaseCurrency) newQuantity = newQuantity.Sub(baseSellQuantityReduction) } switch o.Side { case types.SideTypeSell: newSide = types.SideTypeBuy if !s.ProfitSpread.IsZero() { newPrice = newPrice.Sub(s.ProfitSpread) } else { if pin, ok := s.grid.NextLowerPin(newPrice); ok { newPrice = fixedpoint.Value(pin) } } // use the profit to buy more inventory in the grid if s.Compound || s.EarnBase { newQuantity = fixedpoint.Max(orderQuoteQuantity.Div(newPrice), s.Market.MinQuantity) } // calculate profit // TODO: send profit notification profit := s.calculateProfit(o, newPrice, newQuantity) s.logger.Infof("GENERATED GRID PROFIT: %+v", profit) s.GridProfitStats.AddProfit(profit) case types.SideTypeBuy: newSide = types.SideTypeSell if !s.ProfitSpread.IsZero() { newPrice = newPrice.Add(s.ProfitSpread) } else { if pin, ok := s.grid.NextHigherPin(newPrice); ok { newPrice = fixedpoint.Value(pin) } } if s.EarnBase { newQuantity = fixedpoint.Max(orderQuoteQuantity.Div(newPrice).Sub(baseSellQuantityReduction), s.Market.MinQuantity) } } orderForm := types.SubmitOrder{ Symbol: s.Symbol, Market: s.Market, Type: types.OrderTypeLimit, Price: newPrice, Side: newSide, TimeInForce: types.TimeInForceGTC, Quantity: newQuantity, Tag: "grid", } s.logger.Infof("SUBMIT GRID REVERSE ORDER: %s", orderForm.String()) if createdOrders, err := s.orderExecutor.SubmitOrders(context.Background(), orderForm); err != nil { s.logger.WithError(err).Errorf("can not submit arbitrage order") } else { s.logger.Infof("order created: %+v", createdOrders) } } func (s *Strategy) checkRequiredInvestmentByQuantity(baseBalance, quoteBalance, quantity, lastPrice fixedpoint.Value, pins []Pin) (requiredBase, requiredQuote fixedpoint.Value, err error) { // check more investment budget details requiredBase = fixedpoint.Zero requiredQuote = fixedpoint.Zero // when we need to place a buy-to-sell conversion order, we need to mark the price si := -1 for i := len(pins) - 1; i >= 0; i-- { pin := pins[i] price := fixedpoint.Value(pin) // TODO: add fee if we don't have the platform token. BNB, OKB or MAX... if price.Compare(lastPrice) >= 0 { si = i // for orders that sell // if we still have the base balance if requiredBase.Add(quantity).Compare(baseBalance) <= 0 { requiredBase = requiredBase.Add(quantity) } else if i > 0 { // we do not want to sell at i == 0 // convert sell to buy quote and add to requiredQuote nextLowerPin := pins[i-1] nextLowerPrice := fixedpoint.Value(nextLowerPin) requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice)) } } else { // for orders that buy if i+1 == si { continue } requiredQuote = requiredQuote.Add(quantity.Mul(price)) } } if requiredBase.Compare(baseBalance) > 0 && requiredQuote.Compare(quoteBalance) > 0 { return requiredBase, requiredQuote, fmt.Errorf("both base balance (%f %s) or quote balance (%f %s) is not enough, required = base %f + quote %f", baseBalance.Float64(), s.Market.BaseCurrency, quoteBalance.Float64(), s.Market.QuoteCurrency, requiredBase.Float64(), requiredQuote.Float64()) } if requiredBase.Compare(baseBalance) > 0 { return requiredBase, requiredQuote, fmt.Errorf("base balance (%f %s), required = base %f", baseBalance.Float64(), s.Market.BaseCurrency, requiredBase.Float64(), ) } if requiredQuote.Compare(quoteBalance) > 0 { return requiredBase, requiredQuote, fmt.Errorf("quote balance (%f %s) is not enough, required = quote %f", quoteBalance.Float64(), s.Market.QuoteCurrency, requiredQuote.Float64(), ) } return requiredBase, requiredQuote, nil } func (s *Strategy) checkRequiredInvestmentByAmount(baseBalance, quoteBalance, amount, lastPrice fixedpoint.Value, pins []Pin) (requiredBase, requiredQuote fixedpoint.Value, err error) { // check more investment budget details requiredBase = fixedpoint.Zero requiredQuote = fixedpoint.Zero // when we need to place a buy-to-sell conversion order, we need to mark the price si := -1 for i := len(pins) - 1; i >= 0; i-- { pin := pins[i] price := fixedpoint.Value(pin) // TODO: add fee if we don't have the platform token. BNB, OKB or MAX... if price.Compare(lastPrice) >= 0 { si = i // for orders that sell // if we still have the base balance quantity := amount.Div(lastPrice) if requiredBase.Add(quantity).Compare(baseBalance) <= 0 { requiredBase = requiredBase.Add(quantity) } else if i > 0 { // we do not want to sell at i == 0 // convert sell to buy quote and add to requiredQuote nextLowerPin := pins[i-1] nextLowerPrice := fixedpoint.Value(nextLowerPin) requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice)) } } else { // for orders that buy if s.ProfitSpread.IsZero() && i+1 == si { continue } requiredQuote = requiredQuote.Add(amount) } } if requiredBase.Compare(baseBalance) > 0 && requiredQuote.Compare(quoteBalance) > 0 { return requiredBase, requiredQuote, fmt.Errorf("both base balance (%f %s) or quote balance (%f %s) is not enough, required = base %f + quote %f", baseBalance.Float64(), s.Market.BaseCurrency, quoteBalance.Float64(), s.Market.QuoteCurrency, requiredBase.Float64(), requiredQuote.Float64()) } if requiredBase.Compare(baseBalance) > 0 { return requiredBase, requiredQuote, fmt.Errorf("base balance (%f %s), required = base %f", baseBalance.Float64(), s.Market.BaseCurrency, requiredBase.Float64(), ) } if requiredQuote.Compare(quoteBalance) > 0 { return requiredBase, requiredQuote, fmt.Errorf("quote balance (%f %s) is not enough, required = quote %f", quoteBalance.Float64(), s.Market.QuoteCurrency, requiredQuote.Float64(), ) } return requiredBase, requiredQuote, nil } func (s *Strategy) calculateQuoteInvestmentQuantity(quoteInvestment, lastPrice fixedpoint.Value, pins []Pin) (fixedpoint.Value, error) { // quoteInvestment = (p1 * q) + (p2 * q) + (p3 * q) + .... // => // quoteInvestment = (p1 + p2 + p3) * q // q = quoteInvestment / (p1 + p2 + p3) totalQuotePrice := fixedpoint.Zero si := -1 for i := len(pins) - 1; i >= 0; i-- { pin := pins[i] price := fixedpoint.Value(pin) if price.Compare(lastPrice) >= 0 { si = i // for orders that sell // if we still have the base balance // quantity := amount.Div(lastPrice) if s.ProfitSpread.Sign() > 0 { totalQuotePrice = totalQuotePrice.Add(price) } else if i > 0 { // we do not want to sell at i == 0 // convert sell to buy quote and add to requiredQuote nextLowerPin := pins[i-1] nextLowerPrice := fixedpoint.Value(nextLowerPin) totalQuotePrice = totalQuotePrice.Add(nextLowerPrice) } } else { // for orders that buy if s.ProfitSpread.IsZero() && i+1 == si { continue } totalQuotePrice = totalQuotePrice.Add(price) } } return quoteInvestment.Div(totalQuotePrice), nil } func (s *Strategy) calculateQuoteBaseInvestmentQuantity(quoteInvestment, baseInvestment, lastPrice fixedpoint.Value, pins []Pin) (fixedpoint.Value, error) { s.logger.Infof("calculating quantity by quote/base investment: %f / %f", baseInvestment.Float64(), quoteInvestment.Float64()) // q_p1 = q_p2 = q_p3 = q_p4 // baseInvestment = q_p1 + q_p2 + q_p3 + q_p4 + .... // baseInvestment = numberOfSellOrders * q // maxBaseQuantity = baseInvestment / numberOfSellOrders // if maxBaseQuantity < minQuantity or maxBaseQuantity * priceLowest < minNotional // then reduce the numberOfSellOrders numberOfSellOrders := 0 for i := len(pins) - 1; i >= 0; i-- { pin := pins[i] price := fixedpoint.Value(pin) sellPrice := price if s.ProfitSpread.Sign() > 0 { sellPrice = sellPrice.Add(s.ProfitSpread) } if sellPrice.Compare(lastPrice) < 0 { break } numberOfSellOrders++ } // if the maxBaseQuantity is less than minQuantity, then we need to reduce the number of the sell orders // so that the quantity can be increased. maxNumberOfSellOrders := numberOfSellOrders + 1 minBaseQuantity := fixedpoint.Max(s.Market.MinNotional.Div(lastPrice), s.Market.MinQuantity) maxBaseQuantity := fixedpoint.Zero for maxBaseQuantity.Compare(s.Market.MinQuantity) <= 0 || maxBaseQuantity.Compare(minBaseQuantity) <= 0 { maxNumberOfSellOrders-- maxBaseQuantity = baseInvestment.Div(fixedpoint.NewFromInt(int64(maxNumberOfSellOrders))) } s.logger.Infof("grid base investment sell orders: %d", maxNumberOfSellOrders) if maxNumberOfSellOrders > 0 { s.logger.Infof("grid base investment quantity range: %f <=> %f", minBaseQuantity.Float64(), maxBaseQuantity.Float64()) } // calculate quantity with quote investment totalQuotePrice := fixedpoint.Zero // quoteInvestment = (p1 * q) + (p2 * q) + (p3 * q) + .... // => // quoteInvestment = (p1 + p2 + p3) * q // maxBuyQuantity = quoteInvestment / (p1 + p2 + p3) si := -1 for i := len(pins) - 1 - maxNumberOfSellOrders; i >= 0; i-- { pin := pins[i] price := fixedpoint.Value(pin) // buy price greater than the last price will trigger taker order. if price.Compare(lastPrice) >= 0 { si = i // when profit spread is set, we count all the grid prices as buy prices if s.ProfitSpread.Sign() > 0 { totalQuotePrice = totalQuotePrice.Add(price) } else if i > 0 { // when profit spread is not set // we do not want to place sell order at i == 0 // here we submit an order to convert a buy order into a sell order nextLowerPin := pins[i-1] nextLowerPrice := fixedpoint.Value(nextLowerPin) // requiredQuote = requiredQuote.Add(quantity.Mul(nextLowerPrice)) totalQuotePrice = totalQuotePrice.Add(nextLowerPrice) } } else { // for orders that buy if s.ProfitSpread.IsZero() && i+1 == si { continue } totalQuotePrice = totalQuotePrice.Add(price) } } quoteSideQuantity := quoteInvestment.Div(totalQuotePrice) if maxNumberOfSellOrders > 0 { return fixedpoint.Max(quoteSideQuantity, maxBaseQuantity), nil } return quoteSideQuantity, nil } func (s *Strategy) newTriggerPriceHandler(ctx context.Context, session *bbgo.ExchangeSession) types.KLineCallback { return types.KLineWith(s.Symbol, types.Interval1m, func(k types.KLine) { if s.TriggerPrice.Compare(k.High) > 0 || s.TriggerPrice.Compare(k.Low) < 0 { return } if s.grid != nil { return } s.logger.Infof("the last price %f hits triggerPrice %f, opening grid", k.Close.Float64(), s.TriggerPrice.Float64()) if err := s.openGrid(ctx, session); err != nil { s.logger.WithError(err).Errorf("failed to setup grid orders") return } }) } func (s *Strategy) newStopLossPriceHandler(ctx context.Context, session *bbgo.ExchangeSession) types.KLineCallback { return types.KLineWith(s.Symbol, types.Interval1m, func(k types.KLine) { if s.StopLossPrice.Compare(k.Low) < 0 { return } s.logger.Infof("last low price %f hits stopLossPrice %f, closing grid", k.Low.Float64(), s.StopLossPrice.Float64()) if err := s.closeGrid(ctx); err != nil { s.logger.WithError(err).Errorf("can not close grid") return } base := s.Position.GetBase() if base.Sign() < 0 { return } s.logger.Infof("position base %f > 0, closing position...", base.Float64()) if err := s.orderExecutor.ClosePosition(ctx, fixedpoint.One, "grid2:stopLoss"); err != nil { s.logger.WithError(err).Errorf("can not close position") return } }) } func (s *Strategy) newTakeProfitHandler(ctx context.Context, session *bbgo.ExchangeSession) types.KLineCallback { return types.KLineWith(s.Symbol, types.Interval1m, func(k types.KLine) { if s.TakeProfitPrice.Compare(k.High) > 0 { return } s.logger.Infof("last high price %f hits takeProfitPrice %f, closing grid", k.High.Float64(), s.TakeProfitPrice.Float64()) if err := s.closeGrid(ctx); err != nil { s.logger.WithError(err).Errorf("can not close grid") return } base := s.Position.GetBase() if base.Sign() < 0 { return } s.logger.Infof("position base %f > 0, closing position...", base.Float64()) if err := s.orderExecutor.ClosePosition(ctx, fixedpoint.One, "grid2:takeProfit"); err != nil { s.logger.WithError(err).Errorf("can not close position") return } }) } // closeGrid closes the grid orders func (s *Strategy) closeGrid(ctx context.Context) error { bbgo.Sync(ctx, s) // now we can cancel the open orders s.logger.Infof("canceling grid orders...") if err := s.orderExecutor.GracefulCancel(ctx); err != nil { return err } // free the grid object s.grid = nil return nil } // openGrid // 1) if quantity or amount is set, we should use quantity/amount directly instead of using investment amount to calculate. // 2) if baseInvestment, quoteInvestment is set, then we should calculate the quantity from the given base investment and quote investment. // TODO: fix sell order placement for profitSpread func (s *Strategy) openGrid(ctx context.Context, session *bbgo.ExchangeSession) error { // grid object guard if s.grid != nil { return nil } s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize) s.grid.CalculateArithmeticPins() s.logger.Info("OPENING GRID: ", s.grid.String()) lastPrice, err := s.getLastTradePrice(ctx, session) if err != nil { return errors.Wrap(err, "failed to get the last trade price") } // check if base and quote are enough baseBalance, ok := session.Account.Balance(s.Market.BaseCurrency) if !ok { return fmt.Errorf("base %s balance not found", s.Market.BaseCurrency) } quoteBalance, ok := session.Account.Balance(s.Market.QuoteCurrency) if !ok { return fmt.Errorf("quote %s balance not found", s.Market.QuoteCurrency) } totalBase := baseBalance.Available totalQuote := quoteBalance.Available // shift 1 grid because we will start from the buy order // if the buy order is filled, then we will submit another sell order at the higher grid. if s.QuantityOrAmount.IsSet() { if quantity := s.QuantityOrAmount.Quantity; !quantity.IsZero() { if _, _, err2 := s.checkRequiredInvestmentByQuantity(totalBase, totalQuote, lastPrice, s.QuantityOrAmount.Quantity, s.grid.Pins); err != nil { return err2 } } if amount := s.QuantityOrAmount.Amount; !amount.IsZero() { if _, _, err2 := s.checkRequiredInvestmentByAmount(totalBase, totalQuote, lastPrice, amount, s.grid.Pins); err != nil { return err2 } } } else { // calculate the quantity from the investment configuration if !s.QuoteInvestment.IsZero() && !s.BaseInvestment.IsZero() { quantity, err2 := s.calculateQuoteBaseInvestmentQuantity(s.QuoteInvestment, s.BaseInvestment, lastPrice, s.grid.Pins) if err2 != nil { return err2 } s.QuantityOrAmount.Quantity = quantity } else if !s.QuoteInvestment.IsZero() { quantity, err2 := s.calculateQuoteInvestmentQuantity(s.QuoteInvestment, lastPrice, s.grid.Pins) if err2 != nil { return err2 } s.QuantityOrAmount.Quantity = quantity } } // if base investment and quote investment is set, when we should check if the // investment configuration is valid with the current balances if !s.BaseInvestment.IsZero() && !s.QuoteInvestment.IsZero() { if s.BaseInvestment.Compare(totalBase) > 0 { return fmt.Errorf("baseInvestment setup %f is greater than the total base balance %f", s.BaseInvestment.Float64(), totalBase.Float64()) } if s.QuoteInvestment.Compare(totalQuote) > 0 { return fmt.Errorf("quoteInvestment setup %f is greater than the total quote balance %f", s.QuoteInvestment.Float64(), totalQuote.Float64()) } if !s.QuantityOrAmount.IsSet() { // TODO: calculate and override the quantity here } } submitOrders, err := s.generateGridOrders(totalQuote, totalBase, lastPrice) if err != nil { return err } createdOrders, err2 := s.orderExecutor.SubmitOrders(ctx, submitOrders...) if err2 != nil { return err } // debug info s.logger.Infof("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()) } s.logger.Info(" - ", order.String()) } s.logger.Infof("] END OF GRID ORDERS") for _, order := range createdOrders { s.logger.Info(order.String()) } return nil } func (s *Strategy) generateGridOrders(totalQuote, totalBase, lastPrice fixedpoint.Value) ([]types.SubmitOrder, error) { var pins = s.grid.Pins var usedBase = fixedpoint.Zero var usedQuote = fixedpoint.Zero var submitOrders []types.SubmitOrder // si is for sell order price index var si = len(pins) - 1 for i := len(pins) - 1; i >= 0; i-- { pin := pins[i] price := fixedpoint.Value(pin) sellPrice := price // when profitSpread is set, the sell price is shift upper with the given spread if s.ProfitSpread.Sign() > 0 { sellPrice = sellPrice.Add(s.ProfitSpread) } quantity := s.QuantityOrAmount.Quantity if quantity.IsZero() { quantity = s.QuantityOrAmount.Amount.Div(price) } // TODO: add fee if we don't have the platform token. BNB, OKB or MAX... if price.Compare(lastPrice) >= 0 { si = i if usedBase.Add(quantity).Compare(totalBase) < 0 { submitOrders = append(submitOrders, types.SubmitOrder{ Symbol: s.Symbol, Type: types.OrderTypeLimit, Side: types.SideTypeSell, Price: sellPrice, Quantity: quantity, Market: s.Market, TimeInForce: types.TimeInForceGTC, Tag: "grid2", }) usedBase = usedBase.Add(quantity) } else if i > 0 { // next price nextPin := pins[i-1] nextPrice := fixedpoint.Value(nextPin) submitOrders = append(submitOrders, types.SubmitOrder{ Symbol: s.Symbol, Type: types.OrderTypeLimit, Side: types.SideTypeBuy, Price: nextPrice, Quantity: quantity, Market: s.Market, TimeInForce: types.TimeInForceGTC, Tag: "grid2", }) quoteQuantity := quantity.Mul(price) usedQuote = usedQuote.Add(quoteQuantity) } else if i == 0 { // skip i == 0 } } else { if s.ProfitSpread.IsZero() && i+1 == si { continue } submitOrders = append(submitOrders, types.SubmitOrder{ Symbol: s.Symbol, Type: types.OrderTypeLimit, Side: types.SideTypeBuy, Price: price, Quantity: quantity, Market: s.Market, TimeInForce: types.TimeInForceGTC, Tag: "grid", }) quoteQuantity := quantity.Mul(price) usedQuote = usedQuote.Add(quoteQuantity) } } return submitOrders, nil } func (s *Strategy) clearOpenOrders(ctx context.Context, session *bbgo.ExchangeSession) error { // clear open orders when start openOrders, err := session.Exchange.QueryOpenOrders(ctx, s.Symbol) if err != nil { return err } err = session.Exchange.CancelOrders(ctx, openOrders...) if err != nil { return err } return nil } func (s *Strategy) getLastTradePrice(ctx context.Context, session *bbgo.ExchangeSession) (fixedpoint.Value, error) { if bbgo.IsBackTesting { price, ok := session.LastPrice(s.Symbol) if !ok { return fixedpoint.Zero, fmt.Errorf("last price of %s not found", s.Symbol) } return price, nil } tickers, err := session.Exchange.QueryTickers(ctx, s.Symbol) if err != nil { return fixedpoint.Zero, err } if ticker, ok := tickers[s.Symbol]; ok { if !ticker.Last.IsZero() { return ticker.Last, nil } // fallback to buy price return ticker.Buy, nil } return fixedpoint.Zero, fmt.Errorf("%s ticker price not found", s.Symbol) } func calculateMinimalQuoteInvestment(market types.Market, lowerPrice, upperPrice fixedpoint.Value, gridNum int64) fixedpoint.Value { num := fixedpoint.NewFromInt(gridNum) minimalAmountLowerPrice := fixedpoint.Max(lowerPrice.Mul(market.MinQuantity), market.MinNotional) minimalAmountUpperPrice := fixedpoint.Max(upperPrice.Mul(market.MinQuantity), market.MinNotional) return fixedpoint.Max(minimalAmountLowerPrice, minimalAmountUpperPrice).Mul(num) } func (s *Strategy) checkMinimalQuoteInvestment() error { minimalQuoteInvestment := calculateMinimalQuoteInvestment(s.Market, s.LowerPrice, s.UpperPrice, s.GridNum) if s.QuoteInvestment.Compare(minimalQuoteInvestment) <= 0 { return fmt.Errorf("need at least %f %s for quote investment, %f %s given", minimalQuoteInvestment.Float64(), s.Market.QuoteCurrency, s.QuoteInvestment.Float64(), s.Market.QuoteCurrency) } return nil } func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { instanceID := s.InstanceID() s.session = session if service, ok := session.Exchange.(types.ExchangeOrderQueryService); ok { s.orderQueryService = service } s.logger = log.WithFields(logrus.Fields{ "symbol": s.Symbol, }) s.groupID = util.FNV32(instanceID) s.logger.Infof("using group id %d from fnv(%s)", s.groupID, instanceID) if s.ProfitSpread.Sign() > 0 { s.ProfitSpread = s.Market.TruncatePrice(s.ProfitSpread) } if s.GridProfitStats == nil { s.GridProfitStats = newGridProfitStats(s.Market) } if s.ProfitStats == nil { s.ProfitStats = types.NewProfitStats(s.Market) } if s.Position == nil { s.Position = types.NewPositionFromMarket(s.Market) } if s.ResetPositionWhenStart { s.Position.Reset() } // we need to check the minimal quote investment here, because we need the market info if s.QuoteInvestment.Sign() > 0 { if err := s.checkMinimalQuoteInvestment(); err != nil { return err } } s.historicalTrades = bbgo.NewTradeStore() s.historicalTrades.EnablePrune = true s.historicalTrades.BindStream(session.UserDataStream) s.orderExecutor = bbgo.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) s.orderExecutor.BindEnvironment(s.Environment) s.orderExecutor.BindProfitStats(s.ProfitStats) s.orderExecutor.Bind() s.orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, _, _ fixedpoint.Value) { s.GridProfitStats.AddTrade(trade) }) s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { bbgo.Sync(ctx, s) }) s.orderExecutor.ActiveMakerOrders().OnFilled(s.handleOrderFilled) // TODO: detect if there are previous grid orders on the order book if s.ClearOpenOrdersWhenStart { if err := s.clearOpenOrders(ctx, session); err != nil { return err } } bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() if s.KeepOrdersWhenShutdown { s.logger.Infof("KeepOrdersWhenShutdown is set, will keep the orders on the exchange") return } if err := s.closeGrid(ctx); err != nil { s.logger.WithError(err).Errorf("grid graceful order cancel error") } }) if !s.TriggerPrice.IsZero() { session.MarketDataStream.OnKLineClosed(s.newTriggerPriceHandler(ctx, session)) } if !s.StopLossPrice.IsZero() { session.MarketDataStream.OnKLineClosed(s.newStopLossPriceHandler(ctx, session)) } if !s.TakeProfitPrice.IsZero() { session.MarketDataStream.OnKLineClosed(s.newTakeProfitHandler(ctx, session)) } session.UserDataStream.OnStart(func() { if s.TriggerPrice.IsZero() { if err := s.openGrid(ctx, session); err != nil { s.logger.WithError(err).Errorf("failed to setup grid orders") } return } }) return nil }