Merge pull request #1022 from c9s/feature/grid2

strategy: grid2: more refactoring, fix bugs and add more tests
This commit is contained in:
Yo-An Lin 2022-12-07 16:29:50 +08:00 committed by GitHub
commit 37f4435aba
4 changed files with 476 additions and 41 deletions

View File

@ -36,7 +36,7 @@ exchangeStrategies:
symbol: BTCUSDT
upperPrice: 18_000.0
lowerPrice: 16_000.0
gridNumber: 200
gridNumber: 100
## compound is used for buying more inventory when the profit is made by the filled SELL order.
## when compound is disabled, fixed quantity is used for each grid order.
@ -70,12 +70,12 @@ exchangeStrategies:
# amount: 10.0
## 2) fixed quantity: it will use your balance to place orders with the fixed quantity. e.g. 0.001 BTC
# quantity: 0.001
quantity: 0.001
## 3) quoteInvestment and baseInvestment: when using quoteInvestment, the strategy will automatically calculate your best quantity for the whole grid.
## quoteInvestment is required, and baseInvestment is optional (could be zero)
## if you have existing BTC position and want to reuse it you can set the baseInvestment.
quoteInvestment: 10_000
# quoteInvestment: 10_000
# baseInvestment: 1.0
feeRate: 0.075%

View File

@ -0,0 +1,95 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/c9s/bbgo/pkg/strategy/grid2 (interfaces: OrderExecutor)
// Package mocks is a generated GoMock package.
package mocks
import (
context "context"
reflect "reflect"
fixedpoint "github.com/c9s/bbgo/pkg/fixedpoint"
types "github.com/c9s/bbgo/pkg/types"
gomock "github.com/golang/mock/gomock"
)
// MockOrderExecutor is a mock of OrderExecutor interface.
type MockOrderExecutor struct {
ctrl *gomock.Controller
recorder *MockOrderExecutorMockRecorder
}
// MockOrderExecutorMockRecorder is the mock recorder for MockOrderExecutor.
type MockOrderExecutorMockRecorder struct {
mock *MockOrderExecutor
}
// NewMockOrderExecutor creates a new mock instance.
func NewMockOrderExecutor(ctrl *gomock.Controller) *MockOrderExecutor {
mock := &MockOrderExecutor{ctrl: ctrl}
mock.recorder = &MockOrderExecutorMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockOrderExecutor) EXPECT() *MockOrderExecutorMockRecorder {
return m.recorder
}
// ClosePosition mocks base method.
func (m *MockOrderExecutor) ClosePosition(arg0 context.Context, arg1 fixedpoint.Value, arg2 ...string) error {
m.ctrl.T.Helper()
varargs := []interface{}{arg0, arg1}
for _, a := range arg2 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "ClosePosition", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// ClosePosition indicates an expected call of ClosePosition.
func (mr *MockOrderExecutorMockRecorder) ClosePosition(arg0, arg1 interface{}, arg2 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0, arg1}, arg2...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClosePosition", reflect.TypeOf((*MockOrderExecutor)(nil).ClosePosition), varargs...)
}
// GracefulCancel mocks base method.
func (m *MockOrderExecutor) GracefulCancel(arg0 context.Context, arg1 ...types.Order) error {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "GracefulCancel", varargs...)
ret0, _ := ret[0].(error)
return ret0
}
// GracefulCancel indicates an expected call of GracefulCancel.
func (mr *MockOrderExecutorMockRecorder) GracefulCancel(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GracefulCancel", reflect.TypeOf((*MockOrderExecutor)(nil).GracefulCancel), varargs...)
}
// SubmitOrders mocks base method.
func (m *MockOrderExecutor) SubmitOrders(arg0 context.Context, arg1 ...types.SubmitOrder) (types.OrderSlice, error) {
m.ctrl.T.Helper()
varargs := []interface{}{arg0}
for _, a := range arg1 {
varargs = append(varargs, a)
}
ret := m.ctrl.Call(m, "SubmitOrders", varargs...)
ret0, _ := ret[0].(types.OrderSlice)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// SubmitOrders indicates an expected call of SubmitOrders.
func (mr *MockOrderExecutorMockRecorder) SubmitOrders(arg0 interface{}, arg1 ...interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
varargs := append([]interface{}{arg0}, arg1...)
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SubmitOrders", reflect.TypeOf((*MockOrderExecutor)(nil).SubmitOrders), varargs...)
}

View File

@ -28,6 +28,13 @@ func init() {
bbgo.RegisterStrategy(ID, &Strategy{})
}
//go:generate mockgen -destination=mocks/order_executor.go -package=mocks . OrderExecutor
type OrderExecutor interface {
SubmitOrders(ctx context.Context, submitOrders ...types.SubmitOrder) (types.OrderSlice, error)
ClosePosition(ctx context.Context, percentage fixedpoint.Value, tags ...string) error
GracefulCancel(ctx context.Context, orders ...types.Order) error
}
type Strategy struct {
Environment *bbgo.Environment
@ -102,7 +109,7 @@ type Strategy struct {
session *bbgo.ExchangeSession
orderQueryService types.ExchangeOrderQueryService
orderExecutor *bbgo.GeneralOrderExecutor
orderExecutor OrderExecutor
historicalTrades *bbgo.TradeStore
// groupID is the group ID used for the strategy instance for canceling orders
@ -132,31 +139,12 @@ func (s *Strategy) Validate() error {
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 err := s.checkSpread(); err != nil {
return errors.Wrapf(err, "spread is too small, please try to reduce your gridNum or increase the price range (upperPrice and lowerPrice)")
}
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")
if !s.QuantityOrAmount.IsSet() && s.QuoteInvestment.IsZero() {
return fmt.Errorf("either quantity, amount or quoteInvestment must be set")
}
return nil
@ -171,6 +159,32 @@ 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) checkSpread() error {
gridNum := fixedpoint.NewFromInt(s.GridNum)
spread := s.ProfitSpread
if spread.IsZero() {
spread = s.UpperPrice.Sub(s.LowerPrice).Div(gridNum)
}
feeRate := s.FeeRate
if feeRate.IsZero() {
feeRate = fixedpoint.NewFromFloat(0.075 * 0.01)
}
// the min fee rate from 2 maker/taker orders (with 0.1 rate for profit)
gridFeeRate := feeRate.Mul(fixedpoint.NewFromFloat(2.01))
if spread.Div(s.LowerPrice).Compare(gridFeeRate) < 0 {
return fmt.Errorf("profitSpread %f %s is too small for lower price, less than the grid fee rate: %s", spread.Float64(), spread.Div(s.LowerPrice).Percentage(), gridFeeRate.Percentage())
}
if spread.Div(s.UpperPrice).Compare(gridFeeRate) < 0 {
return fmt.Errorf("profitSpread %f %s is too small for upper price, less than the grid fee rate: %s", spread.Float64(), spread.Div(s.UpperPrice).Percentage(), gridFeeRate.Percentage())
}
return nil
}
func (s *Strategy) handleOrderCanceled(o types.Order) {
s.logger.Infof("GRID ORDER CANCELED: %s", o.String())
@ -606,7 +620,7 @@ func (s *Strategy) calculateQuoteBaseInvestmentQuantity(quoteInvestment, baseInv
quoteSideQuantity := quoteInvestment.Div(totalQuotePrice)
if maxNumberOfSellOrders > 0 {
return fixedpoint.Max(quoteSideQuantity, maxBaseQuantity), nil
return fixedpoint.Min(quoteSideQuantity, maxBaseQuantity), nil
}
return quoteSideQuantity, nil
@ -698,6 +712,12 @@ func (s *Strategy) closeGrid(ctx context.Context) error {
return nil
}
func (s *Strategy) newGrid() *Grid {
grid := NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize)
grid.CalculateArithmeticPins()
return grid
}
// 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.
@ -708,9 +728,7 @@ func (s *Strategy) openGrid(ctx context.Context, session *bbgo.ExchangeSession)
return nil
}
s.grid = NewGrid(s.LowerPrice, s.UpperPrice, fixedpoint.NewFromInt(s.GridNum), s.Market.TickSize)
s.grid.CalculateArithmeticPins()
s.grid = s.newGrid()
s.logger.Info("OPENING GRID: ", s.grid.String())
lastPrice, err := s.getLastTradePrice(ctx, session)
@ -950,7 +968,7 @@ func (s *Strategy) checkMinimalQuoteInvestment() error {
return nil
}
func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, session *bbgo.ExchangeSession) error {
func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error {
instanceID := s.InstanceID()
s.session = session
@ -997,19 +1015,18 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se
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) {
orderExecutor := bbgo.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position)
orderExecutor.BindEnvironment(s.Environment)
orderExecutor.BindProfitStats(s.ProfitStats)
orderExecutor.Bind()
orderExecutor.TradeCollector().OnTrade(func(trade types.Trade, _, _ fixedpoint.Value) {
s.GridProfitStats.AddTrade(trade)
})
s.orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) {
orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) {
bbgo.Sync(ctx, s)
})
s.orderExecutor.ActiveMakerOrders().OnFilled(s.handleOrderFilled)
orderExecutor.ActiveMakerOrders().OnFilled(s.handleOrderFilled)
s.orderExecutor = orderExecutor
// TODO: detect if there are previous grid orders on the order book
if s.ClearOpenOrdersWhenStart {

View File

@ -15,6 +15,8 @@ import (
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
"github.com/c9s/bbgo/pkg/types/mocks"
gridmocks "github.com/c9s/bbgo/pkg/strategy/grid2/mocks"
)
func TestStrategy_checkRequiredInvestmentByQuantity(t *testing.T) {
@ -269,6 +271,7 @@ func newTestStrategy() *Strategy {
s := &Strategy{
logger: logrus.NewEntry(logrus.New()),
Symbol: "BTCUSDT",
Market: market,
GridProfitStats: newGridProfitStats(market),
UpperPrice: number(20_000),
@ -397,6 +400,326 @@ func TestStrategy_aggregateOrderBaseFee(t *testing.T) {
assert.Equal(t, "0.01", baseFee.String())
}
func TestStrategy_handleOrderFilled(t *testing.T) {
ctx := context.Background()
t.Run("no fee token", func(t *testing.T) {
gridQuantity := number(0.1)
orderID := uint64(1)
s := newTestStrategy()
s.Quantity = gridQuantity
s.grid = s.newGrid()
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
mockService := mocks.NewMockExchangeOrderQueryService(mockCtrl)
mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{
Symbol: "BTCUSDT",
OrderID: "1",
}).Return([]types.Trade{
{
ID: 1,
OrderID: orderID,
Exchange: "binance",
Price: number(11000.0),
Quantity: gridQuantity,
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
IsBuyer: true,
FeeCurrency: "BTC",
Fee: number(gridQuantity.Float64() * 0.1 * 0.01),
},
}, nil)
s.orderQueryService = mockService
expectedSubmitOrder := types.SubmitOrder{
Symbol: "BTCUSDT",
Type: types.OrderTypeLimit,
Price: number(12_000.0),
Quantity: number(0.0999),
Side: types.SideTypeSell,
TimeInForce: types.TimeInForceGTC,
Market: s.Market,
Tag: "grid",
}
orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl)
orderExecutor.EXPECT().SubmitOrders(ctx, expectedSubmitOrder).Return([]types.Order{
{SubmitOrder: expectedSubmitOrder},
}, nil)
s.orderExecutor = orderExecutor
s.handleOrderFilled(types.Order{
SubmitOrder: types.SubmitOrder{
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
Type: types.OrderTypeLimit,
Quantity: gridQuantity,
Price: number(11000.0),
TimeInForce: types.TimeInForceGTC,
},
Exchange: "binance",
OrderID: orderID,
Status: types.OrderStatusFilled,
ExecutedQuantity: gridQuantity,
})
})
t.Run("with fee token", func(t *testing.T) {
gridQuantity := number(0.1)
orderID := uint64(1)
s := newTestStrategy()
s.Quantity = gridQuantity
s.grid = s.newGrid()
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
mockService := mocks.NewMockExchangeOrderQueryService(mockCtrl)
mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{
Symbol: "BTCUSDT",
OrderID: "1",
}).Return([]types.Trade{
{
ID: 1,
OrderID: orderID,
Exchange: "binance",
Price: number(11000.0),
Quantity: gridQuantity,
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
IsBuyer: true,
FeeCurrency: "BTC",
Fee: fixedpoint.Zero,
},
}, nil)
s.orderQueryService = mockService
expectedSubmitOrder := types.SubmitOrder{
Symbol: "BTCUSDT",
Type: types.OrderTypeLimit,
Price: number(12_000.0),
Quantity: gridQuantity,
Side: types.SideTypeSell,
TimeInForce: types.TimeInForceGTC,
Market: s.Market,
Tag: "grid",
}
orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl)
orderExecutor.EXPECT().SubmitOrders(ctx, expectedSubmitOrder).Return([]types.Order{
{SubmitOrder: expectedSubmitOrder},
}, nil)
s.orderExecutor = orderExecutor
s.handleOrderFilled(types.Order{
SubmitOrder: types.SubmitOrder{
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
Type: types.OrderTypeLimit,
Quantity: gridQuantity,
Price: number(11000.0),
TimeInForce: types.TimeInForceGTC,
},
Exchange: "binance",
OrderID: orderID,
Status: types.OrderStatusFilled,
ExecutedQuantity: gridQuantity,
})
})
t.Run("with fee token and EarnBase", func(t *testing.T) {
gridQuantity := number(0.1)
orderID := uint64(1)
s := newTestStrategy()
s.Quantity = gridQuantity
s.EarnBase = true
s.grid = s.newGrid()
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
mockService := mocks.NewMockExchangeOrderQueryService(mockCtrl)
mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{
Symbol: "BTCUSDT",
OrderID: "1",
}).Return([]types.Trade{
{
ID: 1,
OrderID: orderID,
Exchange: "binance",
Price: number(11000.0),
Quantity: gridQuantity,
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
IsBuyer: true,
FeeCurrency: "BTC",
Fee: fixedpoint.Zero,
},
}, nil)
s.orderQueryService = mockService
orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl)
expectedSubmitOrder := types.SubmitOrder{
Symbol: "BTCUSDT",
Type: types.OrderTypeLimit,
Side: types.SideTypeSell,
Price: number(12_000.0),
Quantity: number(0.09166666),
TimeInForce: types.TimeInForceGTC,
Market: s.Market,
Tag: "grid",
}
orderExecutor.EXPECT().SubmitOrders(ctx, expectedSubmitOrder).Return([]types.Order{
{SubmitOrder: expectedSubmitOrder},
}, nil)
expectedSubmitOrder2 := types.SubmitOrder{
Symbol: "BTCUSDT",
Type: types.OrderTypeLimit,
Side: types.SideTypeBuy,
Price: number(11_000.0),
Quantity: number(0.09999999),
TimeInForce: types.TimeInForceGTC,
Market: s.Market,
Tag: "grid",
}
orderExecutor.EXPECT().SubmitOrders(ctx, expectedSubmitOrder2).Return([]types.Order{
{SubmitOrder: expectedSubmitOrder2},
}, nil)
s.orderExecutor = orderExecutor
s.handleOrderFilled(types.Order{
SubmitOrder: types.SubmitOrder{
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
Type: types.OrderTypeLimit,
Quantity: gridQuantity,
Price: number(11000.0),
TimeInForce: types.TimeInForceGTC,
},
Exchange: "binance",
OrderID: 1,
Status: types.OrderStatusFilled,
ExecutedQuantity: gridQuantity,
})
s.handleOrderFilled(types.Order{
SubmitOrder: expectedSubmitOrder,
Exchange: "binance",
OrderID: 2,
Status: types.OrderStatusFilled,
ExecutedQuantity: expectedSubmitOrder.Quantity,
})
})
t.Run("with fee token and compound", func(t *testing.T) {
gridQuantity := number(0.1)
orderID := uint64(1)
s := newTestStrategy()
s.Quantity = gridQuantity
s.Compound = true
s.grid = s.newGrid()
mockCtrl := gomock.NewController(t)
defer mockCtrl.Finish()
mockService := mocks.NewMockExchangeOrderQueryService(mockCtrl)
mockService.EXPECT().QueryOrderTrades(ctx, types.OrderQuery{
Symbol: "BTCUSDT",
OrderID: "1",
}).Return([]types.Trade{
{
ID: 1,
OrderID: orderID,
Exchange: "binance",
Price: number(11000.0),
Quantity: gridQuantity,
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
IsBuyer: true,
FeeCurrency: "BTC",
Fee: fixedpoint.Zero,
},
}, nil)
s.orderQueryService = mockService
expectedSubmitOrder := types.SubmitOrder{
Symbol: "BTCUSDT",
Type: types.OrderTypeLimit,
Price: number(12_000.0),
Quantity: gridQuantity,
Side: types.SideTypeSell,
TimeInForce: types.TimeInForceGTC,
Market: s.Market,
Tag: "grid",
}
orderExecutor := gridmocks.NewMockOrderExecutor(mockCtrl)
orderExecutor.EXPECT().SubmitOrders(ctx, expectedSubmitOrder).Return([]types.Order{
{SubmitOrder: expectedSubmitOrder},
}, nil)
expectedSubmitOrder2 := types.SubmitOrder{
Symbol: "BTCUSDT",
Type: types.OrderTypeLimit,
Price: number(11_000.0),
Quantity: number(0.1090909),
Side: types.SideTypeBuy,
TimeInForce: types.TimeInForceGTC,
Market: s.Market,
Tag: "grid",
}
orderExecutor.EXPECT().SubmitOrders(ctx, expectedSubmitOrder2).Return([]types.Order{
{SubmitOrder: expectedSubmitOrder2},
}, nil)
s.orderExecutor = orderExecutor
s.handleOrderFilled(types.Order{
SubmitOrder: types.SubmitOrder{
Symbol: "BTCUSDT",
Side: types.SideTypeBuy,
Type: types.OrderTypeLimit,
Quantity: gridQuantity,
Price: number(11000.0),
TimeInForce: types.TimeInForceGTC,
},
Exchange: "binance",
OrderID: 1,
Status: types.OrderStatusFilled,
ExecutedQuantity: gridQuantity,
})
s.handleOrderFilled(types.Order{
SubmitOrder: types.SubmitOrder{
Symbol: "BTCUSDT",
Side: types.SideTypeSell,
Type: types.OrderTypeLimit,
Quantity: gridQuantity,
Price: number(12000.0),
TimeInForce: types.TimeInForceGTC,
},
Exchange: "binance",
OrderID: 2,
Status: types.OrderStatusFilled,
ExecutedQuantity: gridQuantity,
})
})
}
func TestStrategy_aggregateOrderBaseFeeRetry(t *testing.T) {
s := newTestStrategy()