diff --git a/config/buyandhold.yaml b/config/buyandhold.yaml index 1af30d558..98c0fad05 100644 --- a/config/buyandhold.yaml +++ b/config/buyandhold.yaml @@ -25,7 +25,7 @@ riskControls: # "max" is the session name that you want to configure the risk control max: # orderExecutors is one of the risk control - orderExecutors: + orderExecutor: # symbol-routed order executor bySymbol: BTCUSDT: diff --git a/config/grid-max.yaml b/config/grid-max.yaml index a451441ca..1283ae787 100644 --- a/config/grid-max.yaml +++ b/config/grid-max.yaml @@ -31,8 +31,8 @@ riskControls: sessionBased: # "max" is the session name that you want to configure the risk control max: - # orderExecutors is one of the risk control - orderExecutors: + # orderExecutor is one of the risk control + orderExecutor: # symbol-routed order executor bySymbol: BTCUSDT: diff --git a/config/grid.yaml b/config/grid.yaml index 7df96e605..5bd7ba9b5 100644 --- a/config/grid.yaml +++ b/config/grid.yaml @@ -31,8 +31,8 @@ riskControls: sessionBased: # "max" is the session name that you want to configure the risk control max: - # orderExecutors is one of the risk control - orderExecutors: + # orderExecutor is one of the risk control + orderExecutor: # symbol-routed order executor bySymbol: BNBUSDT: diff --git a/config/swing.yaml b/config/swing.yaml index 92b0eb0cd..764fa5a12 100644 --- a/config/swing.yaml +++ b/config/swing.yaml @@ -27,8 +27,8 @@ riskControls: sessionBased: # "max" is the session name that you want to configure the risk control binance: - # orderExecutors is one of the risk control - orderExecutors: + # orderExecutor is one of the risk control + orderExecutor: # symbol-routed order executor bySymbol: BNBUSDT: diff --git a/go.mod b/go.mod index aa15fb814..9139ff4d9 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ go 1.13 require ( github.com/DATA-DOG/go-sqlmock v1.5.0 github.com/adshao/go-binance v0.0.0-20201015231210-37cee298310e + github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 github.com/c9s/goose v0.0.0-20200415105707-8da682162a5b github.com/fastly/go-utils v0.0.0-20180712184237-d95a45783239 // indirect github.com/fsnotify/fsnotify v1.4.7 diff --git a/pkg/bbgo/order_execution.go b/pkg/bbgo/order_execution.go index d8cbc04fa..19eb88db6 100644 --- a/pkg/bbgo/order_execution.go +++ b/pkg/bbgo/order_execution.go @@ -2,10 +2,10 @@ package bbgo import ( "context" - "fmt" "math" "github.com/pkg/errors" + "github.com/sirupsen/logrus" "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/types" @@ -71,97 +71,161 @@ func (e *ExchangeOrderExecutor) SubmitOrders(ctx context.Context, orders ...type return e.session.Exchange.SubmitOrders(ctx, formattedOrders...) } -type BasicRiskControlOrderExecutor struct { - *ExchangeOrderExecutor +type BasicRiskController struct { + Logger *logrus.Logger - MinQuoteBalance fixedpoint.Value `json:"minQuoteBalance,omitempty"` - MaxAssetBalance fixedpoint.Value `json:"maxBaseAssetBalance,omitempty"` - MinAssetBalance fixedpoint.Value `json:"minBaseAssetBalance,omitempty"` - MaxOrderAmount fixedpoint.Value `json:"maxOrderAmount,omitempty"` + MaxOrderAmount fixedpoint.Value `json:"maxOrderAmount,omitempty"` + MinQuoteBalance fixedpoint.Value `json:"minQuoteBalance,omitempty"` + MaxBaseAssetBalance fixedpoint.Value `json:"maxBaseAssetBalance,omitempty"` + MinBaseAssetBalance fixedpoint.Value `json:"minBaseAssetBalance,omitempty"` } -func (e *BasicRiskControlOrderExecutor) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (types.OrderSlice, error) { - var formattedOrders []types.SubmitOrder +// ProcessOrders filters and modifies the submit order objects by: +// 1. Increase the quantity by the minimal requirement +// 2. Decrease the quantity by risk controls +// 3. If the quantity does not meet minimal requirement, we should ignore the submit order. +func (c *BasicRiskController) ProcessOrders(session *ExchangeSession, orders ...types.SubmitOrder) (outOrders []types.SubmitOrder, err error) { + balances := session.Account.Balances() + + accumulativeQuoteAmount := 0.0 + accumulativeBaseSellQuantity := 0.0 for _, order := range orders { - currentPrice, ok := e.session.LastPrice(order.Symbol) + lastPrice, ok := session.LastPrice(order.Symbol) if !ok { - return nil, errors.Errorf("the last price of symbol %q is not found", order.Symbol) + c.Logger.Errorf("the last price of symbol %q is not found", order.Symbol) + continue } - market := order.Market + market, ok := session.Market(order.Symbol) + if !ok { + c.Logger.Errorf("the market config of symbol %q is not found", order.Symbol) + continue + } + + price := order.Price quantity := order.Quantity - balances := e.session.Account.Balances() + switch order.Type { + case types.OrderTypeMarket: + price = lastPrice + } switch order.Side { case types.SideTypeBuy: + // Critical conditions for placing buy orders + quoteBalance, ok := balances[market.QuoteCurrency] + if !ok { + c.Logger.Errorf("can not place buy order, quote balance %s not found", market.QuoteCurrency) + continue + } - if balance, ok := balances[market.QuoteCurrency]; ok { - if balance.Available < e.MinQuoteBalance.Float64() { - return nil, errors.Wrapf(ErrQuoteBalanceLevelTooLow, "quote balance level is too low: %s < %s", - types.USD.FormatMoneyFloat64(balance.Available), - types.USD.FormatMoneyFloat64(e.MinQuoteBalance.Float64())) + if quoteBalance.Available < c.MinQuoteBalance.Float64() { + c.Logger.WithError(ErrQuoteBalanceLevelTooLow).Errorf("can not place buy order, quote balance level is too low: %s < %s", + types.USD.FormatMoneyFloat64(quoteBalance.Available), + types.USD.FormatMoneyFloat64(c.MinQuoteBalance.Float64())) + continue + } + + // Increase the quantity if the amount is not enough, + // this is the only increase op, later we will decrease the quantity if it meets the criteria + quantity = adjustQuantityByMinAmount(quantity, price, market.MinAmount*1.01) + + if c.MaxOrderAmount > 0 { + quantity = adjustQuantityByMaxAmount(quantity, price, c.MaxOrderAmount.Float64()) + } + + quoteAssetQuota := math.Max(0.0, quoteBalance.Available-c.MinQuoteBalance.Float64()) + if quoteAssetQuota < market.MinAmount { + c.Logger.WithError(ErrInsufficientQuoteBalance).Errorf("can not place buy order, insufficient quote balance: quota %f < min amount %f", quoteAssetQuota, market.MinAmount) + continue + } + + quantity = adjustQuantityByMaxAmount(quantity, price, quoteAssetQuota) + + // if MaxBaseAssetBalance is enabled, we should check the current base asset balance + if baseBalance, hasBaseAsset := balances[market.BaseCurrency]; hasBaseAsset && c.MaxBaseAssetBalance > 0 { + if baseBalance.Available > c.MaxBaseAssetBalance.Float64() { + c.Logger.WithError(ErrAssetBalanceLevelTooHigh).Errorf("should not place buy order, asset balance level is too high: %f > %f", baseBalance.Available, c.MaxBaseAssetBalance.Float64()) + continue } - if baseBalance, ok := balances[market.BaseCurrency]; ok { - if e.MaxAssetBalance > 0 && baseBalance.Available > e.MaxAssetBalance.Float64() { - return nil, errors.Wrapf(ErrAssetBalanceLevelTooHigh, "asset balance level is too high: %f > %f", baseBalance.Available, e.MaxAssetBalance.Float64()) - } - } - - available := math.Max(0.0, balance.Available-e.MinQuoteBalance.Float64()) - if available < market.MinAmount { - return nil, errors.Wrapf(ErrInsufficientQuoteBalance, "insufficient quote balance: %f < min amount %f", available, market.MinAmount) - } - - quantity = adjustQuantityByMinAmount(quantity, currentPrice, market.MinAmount*1.01) - quantity = adjustQuantityByMaxAmount(quantity, currentPrice, available) - amount := quantity * currentPrice - if amount < market.MinAmount { - return nil, fmt.Errorf("amount too small: %f < min amount %f", amount, market.MinAmount) + baseAssetQuota := math.Max(0, c.MaxBaseAssetBalance.Float64()-baseBalance.Available) + if quantity > baseAssetQuota { + quantity = baseAssetQuota } } + // if the amount is still too small, we should skip it. + notional := quantity * lastPrice + if notional < market.MinAmount { + c.Logger.Errorf("can not place buy order, quote amount too small: notional %f < min amount %f", notional, market.MinAmount) + continue + } + + accumulativeQuoteAmount += notional + case types.SideTypeSell: + // Critical conditions for placing SELL orders + baseAssetBalance, ok := balances[market.BaseCurrency] + if !ok { + c.Logger.Errorf("can not place sell order, no base asset balance %s", market.BaseCurrency) + continue + } - if balance, ok := balances[market.BaseCurrency]; ok { - if e.MinAssetBalance > 0 && balance.Available < e.MinAssetBalance.Float64() { - return nil, errors.Wrapf(ErrAssetBalanceLevelTooLow, "asset balance level is too low: %f > %f", balance.Available, e.MinAssetBalance.Float64()) + // if the amount is too small, we should increase it. + quantity = adjustQuantityByMinAmount(quantity, price, market.MinNotional*1.01) + + // we should not SELL too much + quantity = math.Min(quantity, baseAssetBalance.Available) + + if c.MinBaseAssetBalance > 0 { + if baseAssetBalance.Available < c.MinBaseAssetBalance.Float64() { + c.Logger.WithError(ErrAssetBalanceLevelTooLow).Errorf("asset balance level is too low: %f > %f", baseAssetBalance.Available, c.MinBaseAssetBalance.Float64()) + continue } - quantity = adjustQuantityByMinAmount(quantity, currentPrice, market.MinNotional*1.01) - - available := balance.Available - quantity = math.Min(quantity, available) + quantity = math.Min(quantity, baseAssetBalance.Available-c.MinBaseAssetBalance.Float64()) if quantity < market.MinQuantity { - return nil, errors.Wrapf(ErrInsufficientAssetBalance, "insufficient asset balance: %f > minimal quantity %f", available, market.MinQuantity) - } - - notional := quantity * currentPrice - if notional < market.MinNotional { - return nil, fmt.Errorf("notional %f < min notional: %f", notional, market.MinNotional) - } - - if quantity < market.MinLot { - return nil, fmt.Errorf("quantity %f less than min lot %f", quantity, market.MinLot) - } - - notional = quantity * currentPrice - if notional < market.MinNotional { - return nil, fmt.Errorf("notional %f < min notional: %f", notional, market.MinNotional) + c.Logger.WithError(ErrInsufficientAssetBalance).Errorf("insufficient asset balance: %f > minimal quantity %f", baseAssetBalance.Available, market.MinQuantity) + continue } } + + if c.MaxOrderAmount > 0 { + quantity = adjustQuantityByMaxAmount(quantity, price, c.MaxOrderAmount.Float64()) + } + + notional := quantity * lastPrice + if notional < market.MinNotional { + c.Logger.Errorf("can not place sell order, notional %f < min notional: %f", notional, market.MinNotional) + continue + } + + if quantity < market.MinLot { + c.Logger.Errorf("can not place sell order, quantity %f is less than the minimal lot %f", quantity, market.MinLot) + continue + } + + accumulativeBaseSellQuantity += quantity } // update quantity and format the order order.Quantity = quantity - o, err := formatOrder(order, e.session) - if err != nil { - return nil, err - } - - formattedOrders = append(formattedOrders, o) + outOrders = append(outOrders, order) } + return outOrders, nil +} + +type BasicRiskControlOrderExecutor struct { + *ExchangeOrderExecutor + + BasicRiskController +} + +func (e *BasicRiskControlOrderExecutor) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (types.OrderSlice, error) { + orders, _ = e.BasicRiskController.ProcessOrders(e.session, orders...) + formattedOrders, _ := formatOrders(orders, e.session) + e.notifySubmitOrders(formattedOrders...) return e.session.Exchange.SubmitOrders(ctx, formattedOrders...) diff --git a/pkg/bbgo/order_processor.go b/pkg/bbgo/order_processor.go index 00907cb02..60b9448dc 100644 --- a/pkg/bbgo/order_processor.go +++ b/pkg/bbgo/order_processor.go @@ -33,8 +33,8 @@ var ( } if baseBalance, ok := tradingCtx.Balances[market.BaseCurrency]; ok { - if util.NotZero(p.MaxAssetBalance) && baseBalance.Available > p.MaxAssetBalance { - return errors.Wrapf(ErrAssetBalanceLevelTooHigh, "asset balance level is too high: %f > %f", baseBalance.Available, p.MaxAssetBalance) + if util.NotZero(p.MaxBaseAssetBalance) && baseBalance.Available > p.MaxBaseAssetBalance { + return errors.Wrapf(ErrAssetBalanceLevelTooHigh, "asset balance level is too high: %f > %f", baseBalance.Available, p.MaxBaseAssetBalance) } } @@ -55,8 +55,8 @@ var ( case types.SideTypeSell: if balance, ok := tradingCtx.Balances[market.BaseCurrency]; ok { - if util.NotZero(p.MinAssetBalance) && balance.Available < p.MinAssetBalance { - return errors.Wrapf(ErrAssetBalanceLevelTooLow, "asset balance level is too low: %f > %f", balance.Available, p.MinAssetBalance) + if util.NotZero(p.MinBaseAssetBalance) && balance.Available < p.MinBaseAssetBalance { + return errors.Wrapf(ErrAssetBalanceLevelTooLow, "asset balance level is too low: %f > %f", balance.Available, p.MinBaseAssetBalance) } quantity = adjustQuantityByMinAmount(quantity, currentPrice, market.MinNotional*1.01) @@ -101,7 +101,8 @@ var ( order.QuantityString = market.FormatVolume(quantity) */ -func adjustQuantityByMinAmount(quantity float64, currentPrice float64, minAmount float64) float64 { +// adjustQuantityByMinAmount adjusts the quantity to make the amount greater than the given minAmount +func adjustQuantityByMinAmount(quantity, currentPrice, minAmount float64) float64 { // modify quantity for the min amount amount := currentPrice * quantity if amount < minAmount { @@ -112,8 +113,8 @@ func adjustQuantityByMinAmount(quantity float64, currentPrice float64, minAmount return quantity } -func adjustQuantityByMaxAmount(quantity float64, currentPrice float64, maxAmount float64) float64 { - amount := currentPrice * quantity +func adjustQuantityByMaxAmount(quantity float64, price float64, maxAmount float64) float64 { + amount := price * quantity if amount > maxAmount { ratio := maxAmount / amount quantity *= ratio diff --git a/pkg/bbgo/order_processor_test.go b/pkg/bbgo/order_processor_test.go new file mode 100644 index 000000000..beb19d994 --- /dev/null +++ b/pkg/bbgo/order_processor_test.go @@ -0,0 +1,43 @@ +package bbgo + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAdjustQuantityByMinAmount(t *testing.T) { + type args struct { + quantity, price, minAmount float64 + } + type testcase struct { + name string + args args + wanted float64 + } + + tests := []testcase{ + { + name: "amount too small", + args: args{0.1, 10.0, 10.0}, + wanted: 1.0, + }, + { + name: "amount equals to min amount", + args: args{1.0, 10.0, 10.0}, + wanted: 1.0, + }, + { + name: "amount is greater than min amount", + args: args{2.0, 10.0, 10.0}, + wanted: 2.0, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + q := adjustQuantityByMinAmount(test.args.quantity, test.args.price, test.args.minAmount) + assert.Equal(t, test.wanted, q) + }) + } +} diff --git a/pkg/bbgo/risk_controls.go b/pkg/bbgo/risk_controls.go index 74a7fb8cd..fb4ebca1b 100644 --- a/pkg/bbgo/risk_controls.go +++ b/pkg/bbgo/risk_controls.go @@ -6,49 +6,40 @@ import ( "github.com/c9s/bbgo/pkg/types" ) -type SymbolBasedOrderExecutor struct { - BasicRiskControlOrderExecutor *BasicRiskControlOrderExecutor `json:"basic,omitempty" yaml:"basic,omitempty"` +type SymbolBasedRiskController struct { + BasicRiskController *BasicRiskController `json:"basic,omitempty" yaml:"basic,omitempty"` } -type RiskControlOrderExecutors struct { +type RiskControlOrderExecutor struct { *ExchangeOrderExecutor // Symbol => Executor config - BySymbol map[string]*SymbolBasedOrderExecutor `json:"bySymbol,omitempty" yaml:"bySymbol,omitempty"` + BySymbol map[string]*SymbolBasedRiskController `json:"bySymbol,omitempty" yaml:"bySymbol,omitempty"` } -func (e *RiskControlOrderExecutors) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (types.OrderSlice, error) { - var symbolOrders = make(map[string][]types.SubmitOrder, len(orders)) - for _, order := range orders { - symbolOrders[order.Symbol] = append(symbolOrders[order.Symbol], order) - } - - var retOrders []types.Order - +func (e *RiskControlOrderExecutor) SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (retOrders types.OrderSlice, err error) { + var symbolOrders = groupSubmitOrdersBySymbol(orders) for symbol, orders := range symbolOrders { - var err error - var retOrders2 []types.Order - if exec, ok := e.BySymbol[symbol]; ok && exec.BasicRiskControlOrderExecutor != nil { - retOrders2, err = exec.BasicRiskControlOrderExecutor.SubmitOrders(ctx, orders...) + if controller, ok := e.BySymbol[symbol]; ok && controller != nil { + orders, err = controller.BasicRiskController.ProcessOrders(e.session, orders...) if err != nil { - return retOrders, err + return } - - } else { - retOrders2, err = e.ExchangeOrderExecutor.SubmitOrders(ctx, orders...) - if err != nil { - return retOrders, err - } - } + + retOrders2, err := e.ExchangeOrderExecutor.SubmitOrders(ctx, orders...) + if err != nil { + return retOrders, err + } + retOrders = append(retOrders, retOrders2...) } - return retOrders, nil + return } type SessionBasedRiskControl struct { - OrderExecutor *RiskControlOrderExecutors `json:"orderExecutors,omitempty" yaml:"orderExecutors"` + OrderExecutor *RiskControlOrderExecutor `json:"orderExecutor,omitempty" yaml:"orderExecutor"` } func (control *SessionBasedRiskControl) SetBaseOrderExecutor(executor *ExchangeOrderExecutor) { @@ -57,16 +48,15 @@ func (control *SessionBasedRiskControl) SetBaseOrderExecutor(executor *ExchangeO } control.OrderExecutor.ExchangeOrderExecutor = executor +} - if control.OrderExecutor.BySymbol == nil { - return +func groupSubmitOrdersBySymbol(orders []types.SubmitOrder) map[string][]types.SubmitOrder { + var symbolOrders = make(map[string][]types.SubmitOrder, len(orders)) + for _, order := range orders { + symbolOrders[order.Symbol] = append(symbolOrders[order.Symbol], order) } - for _, exec := range control.OrderExecutor.BySymbol { - if exec.BasicRiskControlOrderExecutor != nil { - exec.BasicRiskControlOrderExecutor.ExchangeOrderExecutor = executor - } - } + return symbolOrders } type RiskControls struct { diff --git a/pkg/bbgo/testdata/order_executor.yaml b/pkg/bbgo/testdata/order_executor.yaml index ed7027a33..7d289e486 100644 --- a/pkg/bbgo/testdata/order_executor.yaml +++ b/pkg/bbgo/testdata/order_executor.yaml @@ -16,7 +16,7 @@ riskControls: sessionBased: # max is the session name that you want to configure the risk control max: - orderExecutors: + orderExecutor: bySymbol: BTCUSDT: basic: