diff --git a/config/rebalance.yaml b/config/rebalance.yaml index 14a784c93..bdcd5f6f5 100644 --- a/config/rebalance.yaml +++ b/config/rebalance.yaml @@ -12,9 +12,9 @@ backtest: startTime: "2022-01-01" endTime: "2022-10-01" symbols: - - BTCUSDT - - ETHUSDT - - MAXUSDT + - BTCUSDT + - ETHUSDT + - MAXUSDT account: max: makerFeeRate: 0.075% @@ -28,7 +28,7 @@ backtest: exchangeStrategies: - on: max rebalance: - interval: 1d + cronExpression: "@every 1s" quoteCurrency: USDT targetWeights: BTC: 50% @@ -37,5 +37,5 @@ exchangeStrategies: threshold: 1% maxAmount: 1_000 # max amount to buy or sell per order orderType: LIMIT_MAKER # LIMIT, LIMIT_MAKER or MARKET - dryRun: false + dryRun: true onStart: true diff --git a/pkg/strategy/rebalance/multi_market_strategy.go b/pkg/strategy/rebalance/multi_market_strategy.go new file mode 100644 index 000000000..e7d17dd0b --- /dev/null +++ b/pkg/strategy/rebalance/multi_market_strategy.go @@ -0,0 +1,44 @@ +package rebalance + +import ( + "context" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/types" +) + +type MultiMarketStrategy struct { + Environ *bbgo.Environment + Session *bbgo.ExchangeSession + + PositionMap PositionMap `persistence:"positionMap"` + ProfitStatsMap ProfitStatsMap `persistence:"profitStatsMap"` + OrderExecutorMap GeneralOrderExecutorMap + + parent, ctx context.Context + cancel context.CancelFunc +} + +func (s *MultiMarketStrategy) Initialize(ctx context.Context, environ *bbgo.Environment, session *bbgo.ExchangeSession, markets map[string]types.Market, strategyID string) { + s.parent = ctx + s.ctx, s.cancel = context.WithCancel(ctx) + + s.Environ = environ + s.Session = session + + if s.PositionMap == nil { + s.PositionMap = make(PositionMap) + } + s.PositionMap.CreatePositions(markets) + + if s.ProfitStatsMap == nil { + s.ProfitStatsMap = make(ProfitStatsMap) + } + s.ProfitStatsMap.CreateProfitStats(markets) + + s.OrderExecutorMap = NewGeneralOrderExecutorMap(session, s.PositionMap) + s.OrderExecutorMap.BindEnvironment(environ) + s.OrderExecutorMap.BindProfitStats(s.ProfitStatsMap) + s.OrderExecutorMap.Sync(ctx, s) + s.OrderExecutorMap.Bind() +} diff --git a/pkg/strategy/rebalance/position_map.go b/pkg/strategy/rebalance/position_map.go index 772d1726c..73bdda499 100644 --- a/pkg/strategy/rebalance/position_map.go +++ b/pkg/strategy/rebalance/position_map.go @@ -6,17 +6,17 @@ import ( type PositionMap map[string]*types.Position -func (m PositionMap) CreatePositions(markets []types.Market) PositionMap { - for _, market := range markets { - if _, ok := m[market.Symbol]; ok { +func (m PositionMap) CreatePositions(markets map[string]types.Market) PositionMap { + for symbol, market := range markets { + if _, ok := m[symbol]; ok { continue } - log.Infof("creating position for symbol %s", market.Symbol) + log.Infof("creating position for symbol %s", symbol) position := types.NewPositionFromMarket(market) position.Strategy = ID - position.StrategyInstanceID = instanceID(market.Symbol) - m[market.Symbol] = position + position.StrategyInstanceID = instanceID(symbol) + m[symbol] = position } return m } diff --git a/pkg/strategy/rebalance/profit_stats_map.go b/pkg/strategy/rebalance/profit_stats_map.go index a84bf5cc9..29e427a6e 100644 --- a/pkg/strategy/rebalance/profit_stats_map.go +++ b/pkg/strategy/rebalance/profit_stats_map.go @@ -1,17 +1,19 @@ package rebalance -import "github.com/c9s/bbgo/pkg/types" +import ( + "github.com/c9s/bbgo/pkg/types" +) type ProfitStatsMap map[string]*types.ProfitStats -func (m ProfitStatsMap) CreateProfitStats(markets []types.Market) ProfitStatsMap { - for _, market := range markets { - if _, ok := m[market.Symbol]; ok { +func (m ProfitStatsMap) CreateProfitStats(markets map[string]types.Market) ProfitStatsMap { + for symbol, market := range markets { + if _, ok := m[symbol]; ok { continue } - log.Infof("creating profit stats for symbol %s", market.Symbol) - m[market.Symbol] = types.NewProfitStats(market) + log.Infof("creating profit stats for symbol %s", symbol) + m[symbol] = types.NewProfitStats(market) } return m } diff --git a/pkg/strategy/rebalance/strategy.go b/pkg/strategy/rebalance/strategy.go index e15e2507f..22896d24b 100644 --- a/pkg/strategy/rebalance/strategy.go +++ b/pkg/strategy/rebalance/strategy.go @@ -5,6 +5,7 @@ import ( "fmt" "sync" + "github.com/robfig/cron/v3" "github.com/sirupsen/logrus" "github.com/c9s/bbgo/pkg/bbgo" @@ -15,6 +16,7 @@ import ( const ID = "rebalance" var log = logrus.WithField("strategy", ID) +var two = fixedpoint.NewFromFloat(2.0) func init() { bbgo.RegisterStrategy(ID, &Strategy{}) @@ -25,23 +27,24 @@ func instanceID(symbol string) string { } type Strategy struct { + *MultiMarketStrategy + Environment *bbgo.Environment - Interval types.Interval `json:"interval"` - QuoteCurrency string `json:"quoteCurrency"` - TargetWeights types.ValueMap `json:"targetWeights"` - Threshold fixedpoint.Value `json:"threshold"` - MaxAmount fixedpoint.Value `json:"maxAmount"` // max amount to buy or sell per order - OrderType types.OrderType `json:"orderType"` - DryRun bool `json:"dryRun"` - OnStart bool `json:"onStart"` // rebalance on start + CronExpression string `json:"cronExpression"` + QuoteCurrency string `json:"quoteCurrency"` + TargetWeights types.ValueMap `json:"targetWeights"` + Threshold fixedpoint.Value `json:"threshold"` + MaxAmount fixedpoint.Value `json:"maxAmount"` // max amount to buy or sell per order + OrderType types.OrderType `json:"orderType"` + DryRun bool `json:"dryRun"` + OnStart bool `json:"onStart"` // rebalance on start - PositionMap PositionMap `persistence:"positionMap"` - ProfitStatsMap ProfitStatsMap `persistence:"profitStatsMap"` - - session *bbgo.ExchangeSession - orderExecutorMap GeneralOrderExecutorMap - activeOrderBook *bbgo.ActiveOrderBook + session *bbgo.ExchangeSession + symbols []string + markets map[string]types.Market + activeOrderBook *bbgo.ActiveOrderBook + cron *cron.Cron } func (s *Strategy) Defaults() error { @@ -52,6 +55,13 @@ func (s *Strategy) Defaults() error { } func (s *Strategy) Initialize() error { + for currency := range s.TargetWeights { + if currency == s.QuoteCurrency { + continue + } + + s.symbols = append(s.symbols, currency+s.QuoteCurrency) + } return nil } @@ -84,35 +94,22 @@ func (s *Strategy) Validate() error { return nil } -func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { - for _, symbol := range s.symbols() { - session.Subscribe(types.KLineChannel, symbol, types.SubscribeOptions{Interval: s.Interval}) - } -} +func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) {} func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { s.session = session - markets, err := s.markets() - if err != nil { - return err + s.markets = make(map[string]types.Market) + for _, symbol := range s.symbols { + market, ok := s.session.Market(symbol) + if !ok { + return fmt.Errorf("market %s not found", symbol) + } + s.markets[symbol] = market } - if s.PositionMap == nil { - s.PositionMap = make(PositionMap) - } - s.PositionMap.CreatePositions(markets) - - if s.ProfitStatsMap == nil { - s.ProfitStatsMap = make(ProfitStatsMap) - } - s.ProfitStatsMap.CreateProfitStats(markets) - - s.orderExecutorMap = NewGeneralOrderExecutorMap(session, s.PositionMap) - s.orderExecutorMap.BindEnvironment(s.Environment) - s.orderExecutorMap.BindProfitStats(s.ProfitStatsMap) - s.orderExecutorMap.Bind() - s.orderExecutorMap.Sync(ctx, s) + s.MultiMarketStrategy = &MultiMarketStrategy{} + s.MultiMarketStrategy.Initialize(ctx, s.Environment, session, s.markets, ID) s.activeOrderBook = bbgo.NewActiveOrderBook("") s.activeOrderBook.BindStream(s.session.UserDataStream) @@ -123,16 +120,18 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo. } }) - s.session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { - s.rebalance(ctx) - }) - // the shutdown handler, you can cancel all orders bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() - _ = s.orderExecutorMap.GracefulCancel(ctx) + _ = s.OrderExecutorMap.GracefulCancel(ctx) }) + s.cron = cron.New() + s.cron.AddFunc(s.CronExpression, func() { + s.rebalance(ctx) + }) + s.cron.Start() + return nil } @@ -142,21 +141,24 @@ func (s *Strategy) rebalance(ctx context.Context) { log.WithError(err).Errorf("failed to cancel orders") } - submitOrders, err := s.generateSubmitOrders(ctx) + order, err := s.generateOrder(ctx) if err != nil { - log.WithError(err).Error("failed to generate submit orders") + log.WithError(err).Error("failed to generate order") return } - for _, order := range submitOrders { - log.Infof("generated submit order: %s", order.String()) + + if order == nil { + log.Info("no order generated") + return } + log.Infof("generated order: %s", order.String()) if s.DryRun { log.Infof("dry run, not submitting orders") return } - createdOrders, err := s.orderExecutorMap.SubmitOrders(ctx, submitOrders...) + createdOrders, err := s.OrderExecutorMap.SubmitOrders(ctx, *order) if err != nil { log.WithError(err).Error("failed to submit orders") return @@ -164,7 +166,7 @@ func (s *Strategy) rebalance(ctx context.Context) { s.activeOrderBook.Add(createdOrders...) } -func (s *Strategy) prices(ctx context.Context) (types.ValueMap, error) { +func (s *Strategy) queryMidPrices(ctx context.Context) (types.ValueMap, error) { m := make(types.ValueMap) for currency := range s.TargetWeights { if currency == s.QuoteCurrency { @@ -177,12 +179,12 @@ func (s *Strategy) prices(ctx context.Context) (types.ValueMap, error) { return nil, err } - m[currency] = ticker.Buy.Add(ticker.Sell).Div(fixedpoint.NewFromFloat(2.0)) + m[currency] = ticker.Buy.Add(ticker.Sell).Div(two) } return m, nil } -func (s *Strategy) balances() (types.BalanceMap, error) { +func (s *Strategy) selectBalances() (types.BalanceMap, error) { m := make(types.BalanceMap) balances := s.session.GetAccount().Balances() for currency := range s.TargetWeights { @@ -195,47 +197,37 @@ func (s *Strategy) balances() (types.BalanceMap, error) { return m, nil } -func (s *Strategy) generateSubmitOrders(ctx context.Context) (submitOrders []types.SubmitOrder, err error) { - prices, err := s.prices(ctx) +func (s *Strategy) generateOrder(ctx context.Context) (*types.SubmitOrder, error) { + prices, err := s.queryMidPrices(ctx) if err != nil { return nil, err } - balances, err := s.balances() + + balances, err := s.selectBalances() if err != nil { return nil, err } - marketValues := prices.Mul(balanceToTotal(balances)) - currentWeights := marketValues.Normalize() - for currency, targetWeight := range s.TargetWeights { - if currency == s.QuoteCurrency { - continue - } + values := prices.Mul(toValueMap(balances)) + weights := values.Normalize() - symbol := currency + s.QuoteCurrency - currentWeight := currentWeights[currency] - currentPrice := prices[currency] + for symbol, market := range s.markets { + target := s.TargetWeights[market.BaseCurrency] + weight := weights[market.BaseCurrency] + midPrice := prices[market.BaseCurrency] - log.Infof("%s price: %v, current weight: %v, target weight: %v", - symbol, - currentPrice, - currentWeight, - targetWeight) + log.Infof("%s mid price: %s", symbol, midPrice.String()) + log.Infof("%s weight: %.2f%%, target: %.2f%%", market.BaseCurrency, weight.Float64()*100, target.Float64()*100) // calculate the difference between current weight and target weight // if the difference is less than threshold, then we will not create the order - weightDifference := targetWeight.Sub(currentWeight) - if weightDifference.Abs().Compare(s.Threshold) < 0 { - log.Infof("%s weight distance |%v - %v| = |%v| less than the threshold: %v", - symbol, - currentWeight, - targetWeight, - weightDifference, - s.Threshold) + diff := target.Sub(weight) + if diff.Abs().Compare(s.Threshold) < 0 { + log.Infof("%s weight is close to target, skip", market.BaseCurrency) continue } - quantity := weightDifference.Mul(marketValues.Sum()).Div(currentPrice) + quantity := diff.Mul(values.Sum()).Div(midPrice) side := types.SideTypeBuy if quantity.Sign() < 0 { @@ -243,94 +235,47 @@ func (s *Strategy) generateSubmitOrders(ctx context.Context) (submitOrders []typ quantity = quantity.Abs() } - maxAmount := s.adjustMaxAmountByBalance(side, currency, currentPrice, balances) - if maxAmount.Sign() > 0 { - quantity = bbgo.AdjustQuantityByMaxAmount(quantity, currentPrice, maxAmount) - log.Infof("adjust the quantity %v (%s %s @ %v) by max amount %v", - quantity, + if s.MaxAmount.Float64() > 0 { + quantity = bbgo.AdjustQuantityByMaxAmount(quantity, midPrice, s.MaxAmount) + log.Infof("adjust quantity %s (%s %s @ %s) by max amount %s", + quantity.String(), symbol, side.String(), - currentPrice, - s.MaxAmount) + midPrice.String(), + s.MaxAmount.String()) } - log.Debugf("symbol: %v, quantity: %v", symbol, quantity) + if side == types.SideTypeBuy { + quantity = fixedpoint.Min(quantity, balances[s.QuoteCurrency].Available.Div(midPrice)) + } else if side == types.SideTypeSell { + quantity = fixedpoint.Min(quantity, balances[market.BaseCurrency].Available) + } - order := types.SubmitOrder{ + if market.IsDustQuantity(quantity, midPrice) { + log.Infof("quantity %s (%s %s @ %s) is dust quantity, skip", + quantity.String(), + symbol, + side.String(), + midPrice.String()) + continue + } + + return &types.SubmitOrder{ Symbol: symbol, Side: side, Type: s.OrderType, Quantity: quantity, - Price: currentPrice, - } - - if ok := s.checkMinimalOrderQuantity(order); ok { - submitOrders = append(submitOrders, order) - } + Price: midPrice, + }, nil } - - return submitOrders, err + return nil, nil } -func (s *Strategy) symbols() (symbols []string) { - for currency := range s.TargetWeights { - if currency == s.QuoteCurrency { - continue - } - symbols = append(symbols, currency+s.QuoteCurrency) - } - return symbols -} - -func (s *Strategy) markets() ([]types.Market, error) { - markets := []types.Market{} - for _, symbol := range s.symbols() { - market, ok := s.session.Market(symbol) - if !ok { - return nil, fmt.Errorf("market %s not found", symbol) - } - markets = append(markets, market) - } - return markets, nil -} - -func (s *Strategy) adjustMaxAmountByBalance(side types.SideType, currency string, currentPrice fixedpoint.Value, balances types.BalanceMap) fixedpoint.Value { - var maxAmount fixedpoint.Value - - switch side { - case types.SideTypeBuy: - maxAmount = balances[s.QuoteCurrency].Available - case types.SideTypeSell: - maxAmount = balances[currency].Available.Mul(currentPrice) - default: - log.Errorf("unknown side type: %s", side) - return fixedpoint.Zero - } - - if s.MaxAmount.Sign() > 0 { - maxAmount = fixedpoint.Min(s.MaxAmount, maxAmount) - } - - return maxAmount -} - -func (s *Strategy) checkMinimalOrderQuantity(order types.SubmitOrder) bool { - if order.Quantity.Compare(order.Market.MinQuantity) < 0 { - log.Infof("order quantity is too small: %f < %f", order.Quantity.Float64(), order.Market.MinQuantity.Float64()) - return false - } - - if order.Quantity.Mul(order.Price).Compare(order.Market.MinNotional) < 0 { - log.Infof("order min notional is too small: %f < %f", order.Quantity.Mul(order.Price).Float64(), order.Market.MinNotional.Float64()) - return false - } - return true -} - -func balanceToTotal(balances types.BalanceMap) types.ValueMap { +func toValueMap(balances types.BalanceMap) types.ValueMap { m := make(types.ValueMap) for _, b := range balances { - m[b.Currency] = b.Total() + // m[b.Currency] = b.Net() + m[b.Currency] = b.Available } return m }