From a40488b0a31e252aebe980a10a563ffeebc0c118 Mon Sep 17 00:00:00 2001 From: narumi Date: Thu, 5 Oct 2023 14:36:08 +0800 Subject: [PATCH] add xfixedmaker strategy --- config/xfixedmaker.yaml | 24 ++ pkg/cmd/strategy/builtin.go | 1 + pkg/strategy/fixedmaker/strategy.go | 45 ++-- pkg/strategy/xfixedmaker/order_price_risk.go | 34 +++ .../xfixedmaker/order_price_risk_test.go | 63 +++++ pkg/strategy/xfixedmaker/strategy.go | 250 ++++++++++++++++++ 6 files changed, 402 insertions(+), 15 deletions(-) create mode 100644 config/xfixedmaker.yaml create mode 100644 pkg/strategy/xfixedmaker/order_price_risk.go create mode 100644 pkg/strategy/xfixedmaker/order_price_risk_test.go create mode 100644 pkg/strategy/xfixedmaker/strategy.go diff --git a/config/xfixedmaker.yaml b/config/xfixedmaker.yaml new file mode 100644 index 000000000..c6c656343 --- /dev/null +++ b/config/xfixedmaker.yaml @@ -0,0 +1,24 @@ +--- +sessions: + max: + exchange: max + envVarPrefix: max + binance: + exchange: binance + envVarPrefix: binance + +crossExchangeStrategies: + - xfixedmaker: + tradingExchange: max + symbol: BTCUSDT + interval: 5m + halfSpread: 0.05% + quantity: 0.005 + orderType: LIMIT_MAKER + dryRun: true + + referenceExchange: binance + referencePriceEMA: + interval: 1m + window: 14 + orderPriceLossThreshold: -10 diff --git a/pkg/cmd/strategy/builtin.go b/pkg/cmd/strategy/builtin.go index 05b652a25..571eb53d4 100644 --- a/pkg/cmd/strategy/builtin.go +++ b/pkg/cmd/strategy/builtin.go @@ -43,6 +43,7 @@ import ( _ "github.com/c9s/bbgo/pkg/strategy/wall" _ "github.com/c9s/bbgo/pkg/strategy/xalign" _ "github.com/c9s/bbgo/pkg/strategy/xbalance" + _ "github.com/c9s/bbgo/pkg/strategy/xfixedmaker" _ "github.com/c9s/bbgo/pkg/strategy/xfunding" _ "github.com/c9s/bbgo/pkg/strategy/xgap" _ "github.com/c9s/bbgo/pkg/strategy/xmaker" diff --git a/pkg/strategy/fixedmaker/strategy.go b/pkg/strategy/fixedmaker/strategy.go index 28de0a917..ccee7c090 100644 --- a/pkg/strategy/fixedmaker/strategy.go +++ b/pkg/strategy/fixedmaker/strategy.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "sync" - "time" "github.com/sirupsen/logrus" @@ -71,6 +70,10 @@ func (s *Strategy) Validate() error { func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + + if !s.CircuitBreakLossThreshold.IsZero() { + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.CircuitBreakEMA.Interval}) + } } func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { @@ -81,15 +84,29 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo. s.activeOrderBook.BindStream(session.UserDataStream) s.activeOrderBook.OnFilled(func(order types.Order) { + if s.IsHalted(order.UpdateTime.Time()) { + log.Infof("circuit break halted") + return + } + if s.activeOrderBook.NumOfOrders() == 0 { - log.Infof("no active orders, replenish") - s.replenish(ctx, order.UpdateTime.Time()) + log.Infof("no active orders, placing orders...") + s.placeOrders(ctx) } }) session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { log.Infof("%s", kline.String()) - s.replenish(ctx, kline.EndTime.Time()) + + if s.IsHalted(kline.EndTime.Time()) { + log.Infof("circuit break halted") + return + } + + if kline.Interval == s.Interval { + s.cancelOrders(ctx) + s.placeOrders(ctx) + } }) // the shutdown handler, you can cancel all orders @@ -97,32 +114,30 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo. defer wg.Done() _ = s.OrderExecutor.GracefulCancel(ctx) }) + return nil } -func (s *Strategy) replenish(ctx context.Context, t time.Time) { +func (s *Strategy) cancelOrders(ctx context.Context) { if err := s.Session.Exchange.CancelOrders(ctx, s.activeOrderBook.Orders()...); err != nil { log.WithError(err).Errorf("failed to cancel orders") } +} - if s.IsHalted(t) { - log.Infof("circuit break halted, not replenishing") - return - } - - submitOrders, err := s.generateSubmitOrders(ctx) +func (s *Strategy) placeOrders(ctx context.Context) { + orders, err := s.generateOrders(ctx) if err != nil { - log.WithError(err).Error("failed to generate submit orders") + log.WithError(err).Error("failed to generate orders") return } - log.Infof("submit orders: %+v", submitOrders) + log.Infof("orders: %+v", orders) if s.DryRun { log.Infof("dry run, not submitting orders") return } - createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, submitOrders...) + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, orders...) if err != nil { log.WithError(err).Error("failed to submit orders") return @@ -132,7 +147,7 @@ func (s *Strategy) replenish(ctx context.Context, t time.Time) { s.activeOrderBook.Add(createdOrders...) } -func (s *Strategy) generateSubmitOrders(ctx context.Context) ([]types.SubmitOrder, error) { +func (s *Strategy) generateOrders(ctx context.Context) ([]types.SubmitOrder, error) { orders := []types.SubmitOrder{} baseBalance, ok := s.Session.GetAccount().Balance(s.Market.BaseCurrency) diff --git a/pkg/strategy/xfixedmaker/order_price_risk.go b/pkg/strategy/xfixedmaker/order_price_risk.go new file mode 100644 index 000000000..e7ec81b1c --- /dev/null +++ b/pkg/strategy/xfixedmaker/order_price_risk.go @@ -0,0 +1,34 @@ +package xfixedmaker + +import ( + "github.com/c9s/bbgo/pkg/fixedpoint" + indicatorv2 "github.com/c9s/bbgo/pkg/indicator/v2" + "github.com/c9s/bbgo/pkg/types" +) + +type OrderPriceRiskControl struct { + referencePrice *indicatorv2.EWMAStream + lossThreshold fixedpoint.Value +} + +func NewOrderPriceRiskControl(referencePrice *indicatorv2.EWMAStream, threshold fixedpoint.Value) *OrderPriceRiskControl { + return &OrderPriceRiskControl{ + referencePrice: referencePrice, + lossThreshold: threshold, + } +} + +func (r *OrderPriceRiskControl) IsSafe(side types.SideType, price fixedpoint.Value, quantity fixedpoint.Value) bool { + refPrice := fixedpoint.NewFromFloat(r.referencePrice.Last(0)) + // calculate profit + var profit fixedpoint.Value + if side == types.SideTypeBuy { + profit = refPrice.Sub(price).Mul(quantity) + } else if side == types.SideTypeSell { + profit = price.Sub(refPrice).Mul(quantity) + } else { + log.Warnf("OrderPriceRiskControl: unsupported side type: %s", side) + return false + } + return profit.Compare(r.lossThreshold) > 0 +} diff --git a/pkg/strategy/xfixedmaker/order_price_risk_test.go b/pkg/strategy/xfixedmaker/order_price_risk_test.go new file mode 100644 index 000000000..da023c733 --- /dev/null +++ b/pkg/strategy/xfixedmaker/order_price_risk_test.go @@ -0,0 +1,63 @@ +package xfixedmaker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + indicatorv2 "github.com/c9s/bbgo/pkg/indicator/v2" + "github.com/c9s/bbgo/pkg/types" +) + +func Test_OrderPriceRiskControl_IsSafe(t *testing.T) { + refPrice := 30000.00 + lossThreshold := fixedpoint.NewFromFloat(-100) + + window := types.IntervalWindow{Window: 30, Interval: types.Interval1m} + refPriceEWMA := indicatorv2.EWMA2(nil, window.Window) + refPriceEWMA.PushAndEmit(refPrice) + + cases := []struct { + name string + side types.SideType + price fixedpoint.Value + quantity fixedpoint.Value + isSafe bool + }{ + { + name: "BuyingHighSafe", + side: types.SideTypeBuy, + price: fixedpoint.NewFromFloat(30040.0), + quantity: fixedpoint.NewFromFloat(1.0), + isSafe: true, + }, + { + name: "SellingLowSafe", + side: types.SideTypeSell, + price: fixedpoint.NewFromFloat(29960.0), + quantity: fixedpoint.NewFromFloat(1.0), + isSafe: true, + }, + { + name: "BuyingHighLoss", + side: types.SideTypeBuy, + price: fixedpoint.NewFromFloat(30040.0), + quantity: fixedpoint.NewFromFloat(10.0), + isSafe: false, + }, + { + name: "SellingLowLoss", + side: types.SideTypeSell, + price: fixedpoint.NewFromFloat(29960.0), + quantity: fixedpoint.NewFromFloat(10.0), + isSafe: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var riskControl = NewOrderPriceRiskControl(refPriceEWMA, lossThreshold) + assert.Equal(t, tc.isSafe, riskControl.IsSafe(tc.side, tc.price, tc.quantity)) + }) + } +} diff --git a/pkg/strategy/xfixedmaker/strategy.go b/pkg/strategy/xfixedmaker/strategy.go new file mode 100644 index 000000000..2e6f927f1 --- /dev/null +++ b/pkg/strategy/xfixedmaker/strategy.go @@ -0,0 +1,250 @@ +package xfixedmaker + +import ( + "context" + "fmt" + "sync" + + "github.com/sirupsen/logrus" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/strategy/common" + "github.com/c9s/bbgo/pkg/types" +) + +const ID = "xfixedmaker" + +var log = logrus.WithField("strategy", ID) + +func init() { + bbgo.RegisterStrategy(ID, &Strategy{}) +} + +// Fixed spread market making strategy +type Strategy struct { + *common.Strategy + + Environment *bbgo.Environment + + TradingExchange string `json:"tradingExchange"` + Symbol string `json:"symbol"` + Interval types.Interval `json:"interval"` + Quantity fixedpoint.Value `json:"quantity"` + HalfSpread fixedpoint.Value `json:"halfSpread"` + OrderType types.OrderType `json:"orderType"` + DryRun bool `json:"dryRun"` + + ReferenceExchange string `json:"referenceExchange"` + ReferencePriceEMA types.IntervalWindow `json:"referencePriceEMA"` + OrderPriceLossThreshold fixedpoint.Value `json:"orderPriceLossThreshold"` + + market types.Market + activeOrderBook *bbgo.ActiveOrderBook + orderPriceRiskControl *OrderPriceRiskControl +} + +func (s *Strategy) Defaults() error { + if s.OrderType == "" { + log.Infof("order type is not set, using limit maker order type") + s.OrderType = types.OrderTypeLimitMaker + } + return nil +} +func (s *Strategy) Initialize() error { + return nil +} + +func (s *Strategy) ID() string { + return ID +} + +func (s *Strategy) InstanceID() string { + return fmt.Sprintf("%s:%s", ID, s.Symbol) +} + +func (s *Strategy) Validate() error { + if s.Quantity.Float64() <= 0 { + return fmt.Errorf("quantity should be positive") + } + + if s.HalfSpread.Float64() <= 0 { + return fmt.Errorf("halfSpread should be positive") + } + return nil +} + +func (s *Strategy) CrossSubscribe(sessions map[string]*bbgo.ExchangeSession) { + tradingSession, ok := sessions[s.TradingExchange] + if !ok { + log.Errorf("trading session %s is not defined", s.TradingExchange) + return + } + tradingSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) + if !s.CircuitBreakLossThreshold.IsZero() { + tradingSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.CircuitBreakEMA.Interval}) + } + + referenceSession, ok := sessions[s.ReferenceExchange] + if !ok { + log.Errorf("reference session %s is not defined", s.ReferenceExchange) + } + referenceSession.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.ReferencePriceEMA.Interval}) +} + +func (s *Strategy) CrossRun(ctx context.Context, _ bbgo.OrderExecutionRouter, sessions map[string]*bbgo.ExchangeSession) error { + tradingSession, ok := sessions[s.TradingExchange] + if !ok { + return fmt.Errorf("trading session %s is not defined", s.TradingExchange) + } + + referenceSession, ok := sessions[s.ReferenceExchange] + if !ok { + return fmt.Errorf("reference session %s is not defined", s.ReferenceExchange) + } + + market, ok := tradingSession.Market(s.Symbol) + if !ok { + return fmt.Errorf("market %s not found", s.Symbol) + } + s.market = market + + s.Strategy = &common.Strategy{} + s.Strategy.Initialize(ctx, s.Environment, tradingSession, s.market, ID, s.InstanceID()) + + s.orderPriceRiskControl = NewOrderPriceRiskControl( + referenceSession.Indicators(s.Symbol).EMA(s.ReferencePriceEMA), + s.OrderPriceLossThreshold, + ) + + s.activeOrderBook = bbgo.NewActiveOrderBook(s.Symbol) + s.activeOrderBook.BindStream(tradingSession.UserDataStream) + s.activeOrderBook.OnFilled(func(order types.Order) { + if s.IsHalted(order.UpdateTime.Time()) { + log.Infof("circuit break halted") + return + } + + if s.activeOrderBook.NumOfOrders() == 0 { + log.Infof("no active orders, placing orders...") + s.placeOrders(ctx) + } + }) + + tradingSession.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + log.Infof("kline: %s", kline.String()) + + if s.IsHalted(kline.EndTime.Time()) { + log.Infof("circuit break halted") + return + } + + if kline.Interval == s.Interval { + s.cancelOrders(ctx) + s.placeOrders(ctx) + } + }) + + // the shutdown handler, you can cancel all orders + bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + _ = s.OrderExecutor.GracefulCancel(ctx) + }) + return nil +} + +func (s *Strategy) cancelOrders(ctx context.Context) { + if err := s.Session.Exchange.CancelOrders(ctx, s.activeOrderBook.Orders()...); err != nil { + log.WithError(err).Errorf("failed to cancel orders") + } +} + +func (s *Strategy) placeOrders(ctx context.Context) { + submitOrders, err := s.generateOrders(ctx) + if err != nil { + log.WithError(err).Error("failed to generate orders") + return + } + log.Infof("submit orders: %+v", submitOrders) + + if s.DryRun { + log.Infof("dry run, not submitting orders") + return + } + + createdOrders, err := s.OrderExecutor.SubmitOrders(ctx, submitOrders...) + if err != nil { + log.WithError(err).Error("failed to submit orders") + return + } + log.Infof("created orders: %+v", createdOrders) + + s.activeOrderBook.Add(createdOrders...) +} + +func (s *Strategy) generateOrders(ctx context.Context) ([]types.SubmitOrder, error) { + orders := []types.SubmitOrder{} + + baseBalance, ok := s.Session.GetAccount().Balance(s.market.BaseCurrency) + if !ok { + return nil, fmt.Errorf("base currency %s balance not found", s.market.BaseCurrency) + } + log.Infof("base balance: %s", baseBalance.String()) + + quoteBalance, ok := s.Session.GetAccount().Balance(s.market.QuoteCurrency) + if !ok { + return nil, fmt.Errorf("quote currency %s balance not found", s.market.QuoteCurrency) + } + log.Infof("quote balance: %s", quoteBalance.String()) + + ticker, err := s.Session.Exchange.QueryTicker(ctx, s.Symbol) + if err != nil { + return nil, err + } + midPrice := ticker.Buy.Add(ticker.Sell).Div(fixedpoint.NewFromFloat(2.0)) + log.Infof("mid price: %s", midPrice.String()) + + // calculate bid and ask price + // sell price = mid price * (1 + r)) + // buy price = mid price * (1 - r)) + sellPrice := midPrice.Mul(fixedpoint.One.Add(s.HalfSpread)).Round(s.market.PricePrecision, fixedpoint.Up) + buyPrice := midPrice.Mul(fixedpoint.One.Sub(s.HalfSpread)).Round(s.market.PricePrecision, fixedpoint.Down) + log.Infof("sell price: %s, buy price: %s", sellPrice.String(), buyPrice.String()) + + // check balance and generate orders + amount := s.Quantity.Mul(buyPrice) + if quoteBalance.Available.Compare(amount) > 0 { + if s.orderPriceRiskControl.IsSafe(types.SideTypeBuy, buyPrice, s.Quantity) { + orders = append(orders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeBuy, + Type: s.OrderType, + Price: buyPrice, + Quantity: s.Quantity, + }) + + } else { + log.Infof("ref price risk control triggered, not placing buy order") + } + } else { + log.Infof("not enough quote balance to buy, available: %s, amount: %s", quoteBalance.Available, amount) + } + + if baseBalance.Available.Compare(s.Quantity) > 0 { + if s.orderPriceRiskControl.IsSafe(types.SideTypeSell, sellPrice, s.Quantity) { + orders = append(orders, types.SubmitOrder{ + Symbol: s.Symbol, + Side: types.SideTypeSell, + Type: s.OrderType, + Price: sellPrice, + Quantity: s.Quantity, + }) + } else { + log.Infof("ref price risk control triggered, not placing sell order") + } + } else { + log.Infof("not enough base balance to sell, available: %s, quantity: %s", baseBalance.Available, s.Quantity) + } + + return orders, nil +}