From 2bc12c0522e27ead0b213342b9c47de3c6c6ccb5 Mon Sep 17 00:00:00 2001 From: c9s Date: Wed, 6 Jul 2022 03:04:01 +0800 Subject: [PATCH] add trailing stop and it's test cases with gomock Signed-off-by: c9s --- pkg/bbgo/exit_trailing_stop.go | 104 ++++++++++++++++++++++++---- pkg/bbgo/exit_trailing_stop_test.go | 102 +++++++++++++++++++++++++++ pkg/bbgo/session.go | 21 +----- pkg/strategy/pivotshort/strategy.go | 3 - 4 files changed, 194 insertions(+), 36 deletions(-) create mode 100644 pkg/bbgo/exit_trailing_stop_test.go diff --git a/pkg/bbgo/exit_trailing_stop.go b/pkg/bbgo/exit_trailing_stop.go index 769d7cda8..fc953037b 100644 --- a/pkg/bbgo/exit_trailing_stop.go +++ b/pkg/bbgo/exit_trailing_stop.go @@ -1,6 +1,9 @@ package bbgo import ( + "context" + "fmt" + "github.com/c9s/bbgo/pkg/fixedpoint" "github.com/c9s/bbgo/pkg/types" ) @@ -11,6 +14,8 @@ type TrailingStop2 struct { // CallbackRate is the callback rate from the previous high price CallbackRate fixedpoint.Value `json:"callbackRate,omitempty"` + ActivationRatio fixedpoint.Value `json:"activationRatio,omitempty"` + // ClosePosition is a percentage of the position to be closed ClosePosition fixedpoint.Value `json:"closePosition,omitempty"` @@ -22,6 +27,13 @@ type TrailingStop2 struct { // KLine per Interval will be used for updating the stop order Interval types.Interval `json:"interval,omitempty"` + Side types.SideType `json:"side,omitempty"` + + latestHigh fixedpoint.Value + + // activated: when the price reaches the min profit price, we set the activated to true to enable trailing stop + activated bool + // private fields session *ExchangeSession orderExecutor *GeneralOrderExecutor @@ -29,7 +41,7 @@ type TrailingStop2 struct { func (s *TrailingStop2) Subscribe(session *ExchangeSession) { // use 1m kline to handle roi stop - session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: types.Interval1m}) + session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) } func (s *TrailingStop2) Bind(session *ExchangeSession, orderExecutor *GeneralOrderExecutor) { @@ -37,7 +49,7 @@ func (s *TrailingStop2) Bind(session *ExchangeSession, orderExecutor *GeneralOrd s.orderExecutor = orderExecutor position := orderExecutor.Position() - session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, types.Interval1m, func(kline types.KLine) { + session.MarketDataStream.OnKLineClosed(types.KLineWith(s.Symbol, s.Interval, func(kline types.KLine) { s.checkStopPrice(kline.Close, position) })) @@ -52,18 +64,82 @@ func (s *TrailingStop2) Bind(session *ExchangeSession, orderExecutor *GeneralOrd } } -func (s *TrailingStop2) checkStopPrice(closePrice fixedpoint.Value, position *types.Position) { - if position.IsClosed() || position.IsDust(closePrice) { - return +func (s *TrailingStop2) getRatio(price fixedpoint.Value, position *types.Position) (fixedpoint.Value, error) { + switch s.Side { + case types.SideTypeBuy: + // for short position + return position.AverageCost.Sub(price).Div(price), nil + case types.SideTypeSell: + return price.Sub(position.AverageCost).Div(position.AverageCost), nil } - /* - roi := position.ROI(closePrice) - if roi.Compare(s.CallbackRate.Neg()) < 0 { - // stop loss - Notify("[TrailingStop2] %s stop loss triggered by ROI %s/%s, price: %f", position.Symbol, roi.Percentage(), s.Percentage.Neg().Percentage(), closePrice.Float64()) - _ = s.orderExecutor.ClosePosition(context.Background(), fixedpoint.One, "TrailingStop2") - return - } - */ + return fixedpoint.Zero, fmt.Errorf("unexpected side type: %v", s.Side) +} + +func (s *TrailingStop2) checkStopPrice(price fixedpoint.Value, position *types.Position) error { + if position.IsClosed() || position.IsDust(price) { + return nil + } + + if !s.MinProfit.IsZero() { + // check if we have the minimal profit + roi := position.ROI(price) + if roi.Compare(s.MinProfit) >= 0 { + Notify("[trailingStop] activated: ROI %f > minimal profit ratio %f", roi.Float64(), s.MinProfit.Float64()) + s.activated = true + } + } else if !s.ActivationRatio.IsZero() { + ratio, err := s.getRatio(price, position) + if err != nil { + return err + } + + if ratio.Compare(s.ActivationRatio) >= 0 { + s.activated = true + } + } + + // update the latest high for the sell order, or the latest low for the buy order + if s.latestHigh.IsZero() { + s.latestHigh = price + } else { + switch s.Side { + case types.SideTypeBuy: + s.latestHigh = fixedpoint.Min(price, s.latestHigh) + case types.SideTypeSell: + s.latestHigh = fixedpoint.Max(price, s.latestHigh) + } + } + + if !s.activated { + return nil + } + + switch s.Side { + case types.SideTypeBuy: + s.latestHigh = fixedpoint.Min(price, s.latestHigh) + + change := price.Sub(s.latestHigh).Div(s.latestHigh) + if change.Compare(s.CallbackRate) >= 0 { + // submit order + return s.triggerStop(price) + } + + case types.SideTypeSell: + s.latestHigh = fixedpoint.Max(price, s.latestHigh) + + change := price.Sub(s.latestHigh).Div(s.latestHigh) + if change.Compare(s.CallbackRate) >= 0 { + // submit order + return s.triggerStop(price) + } + } + + return nil +} + +func (s *TrailingStop2) triggerStop(price fixedpoint.Value) error { + Notify("[TrailingStop] %s stop loss triggered. price: %f callback rate: %f", s.Symbol, price.Float64(), s.CallbackRate.Float64()) + ctx := context.Background() + return s.orderExecutor.ClosePosition(ctx, fixedpoint.One, "trailingStop") } diff --git a/pkg/bbgo/exit_trailing_stop_test.go b/pkg/bbgo/exit_trailing_stop_test.go new file mode 100644 index 000000000..d1285a1a3 --- /dev/null +++ b/pkg/bbgo/exit_trailing_stop_test.go @@ -0,0 +1,102 @@ +package bbgo + +import ( + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" + "github.com/c9s/bbgo/pkg/types/mocks" +) + +// getTestMarket returns the BTCUSDT market information +// for tests, we always use BTCUSDT +func getTestMarket() types.Market { + market := types.Market{ + Symbol: "BTCUSDT", + PricePrecision: 8, + VolumePrecision: 8, + QuoteCurrency: "USDT", + BaseCurrency: "BTC", + MinNotional: fixedpoint.MustNewFromString("0.001"), + MinAmount: fixedpoint.MustNewFromString("10.0"), + MinQuantity: fixedpoint.MustNewFromString("0.001"), + } + return market +} + +func TestTrailingStop(t *testing.T) { + market := getTestMarket() + + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockEx := mocks.NewMockExchange(mockCtrl) + mockEx.EXPECT().NewStream().Return(&types.StandardStream{}).Times(2) + mockEx.EXPECT().SubmitOrders(gomock.Any(), types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeBuy, + Type: types.OrderTypeMarket, + Market: market, + Quantity: fixedpoint.NewFromFloat(1.0), + Tag: "trailingStop", + }) + + session := NewExchangeSession("test", mockEx) + assert.NotNil(t, session) + + session.markets[market.Symbol] = market + + position := types.NewPositionFromMarket(market) + position.AverageCost = fixedpoint.NewFromFloat(20000.0) + position.Base = fixedpoint.NewFromFloat(-1.0) + + orderExecutor := NewGeneralOrderExecutor(session, "BTCUSDT", "test", "test-01", position) + + activationRatio := fixedpoint.NewFromFloat(0.01) + callbackRatio := fixedpoint.NewFromFloat(0.01) + stop := &TrailingStop2{ + Symbol: "BTCUSDT", + Interval: types.Interval1m, + Side: types.SideTypeBuy, + CallbackRate: callbackRatio, + ActivationRatio: activationRatio, + } + stop.Bind(session, orderExecutor) + + // the same price + currentPrice := fixedpoint.NewFromFloat(20000.0) + err := stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.False(t, stop.activated) + } + + // 20000 - 1% = 19800 + currentPrice = currentPrice.Mul(one.Sub(activationRatio)) + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.True(t, stop.activated) + assert.Equal(t, fixedpoint.NewFromFloat(19800.0), currentPrice) + assert.Equal(t, fixedpoint.NewFromFloat(19800.0), stop.latestHigh) + } + + // 19800 - 1% = 19602 + currentPrice = currentPrice.Mul(one.Sub(callbackRatio)) + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.Equal(t, fixedpoint.NewFromFloat(19602.0), currentPrice) + assert.Equal(t, fixedpoint.NewFromFloat(19602.0), stop.latestHigh) + assert.True(t, stop.activated) + } + + // 19602 + 1% = 19798.02 + currentPrice = currentPrice.Mul(one.Add(callbackRatio)) + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.Equal(t, fixedpoint.NewFromFloat(19798.02), currentPrice) + assert.Equal(t, fixedpoint.NewFromFloat(19602.0), stop.latestHigh) + assert.True(t, stop.activated) + } +} diff --git a/pkg/bbgo/session.go b/pkg/bbgo/session.go index c6796c8f1..87bea4fe1 100644 --- a/pkg/bbgo/session.go +++ b/pkg/bbgo/session.go @@ -160,10 +160,6 @@ func (set *StandardIndicatorSet) VOLATILITY(iw types.IntervalWindow) *indicator. // ExchangeSession presents the exchange connection Session // It also maintains and collects the data returned from the stream. type ExchangeSession struct { - // exchange Session based notification system - // we make it as a value field so that we can configure it separately - Notifiability `json:"-" yaml:"-"` - // --------------------------- // Session config fields // --------------------------- @@ -253,12 +249,6 @@ func NewExchangeSession(name string, exchange types.Exchange) *ExchangeSession { marketDataStream.SetPublicOnly() session := &ExchangeSession{ - Notifiability: Notifiability{ - SymbolChannelRouter: NewPatternChannelRouter(nil), - SessionChannelRouter: NewPatternChannelRouter(nil), - ObjectChannelRouter: NewObjectChannelRouter(), - }, - Name: name, Exchange: exchange, UserDataStream: userDataStream, @@ -282,8 +272,7 @@ func NewExchangeSession(name string, exchange types.Exchange) *ExchangeSession { session.OrderExecutor = &ExchangeOrderExecutor{ // copy the notification system so that we can route - Notifiability: session.Notifiability, - Session: session, + Session: session, } return session @@ -805,11 +794,6 @@ func (session *ExchangeSession) InitExchange(name string, ex types.Exchange) err } session.Name = name - session.Notifiability = Notifiability{ - SymbolChannelRouter: NewPatternChannelRouter(nil), - SessionChannelRouter: NewPatternChannelRouter(nil), - ObjectChannelRouter: NewObjectChannelRouter(), - } session.Exchange = ex session.UserDataStream = ex.NewStream() session.MarketDataStream = ex.NewStream() @@ -830,8 +814,7 @@ func (session *ExchangeSession) InitExchange(name string, ex types.Exchange) err session.orderStores = make(map[string]*OrderStore) session.OrderExecutor = &ExchangeOrderExecutor{ // copy the notification system so that we can route - Notifiability: session.Notifiability, - Session: session, + Session: session, } session.usedSymbols = make(map[string]struct{}) diff --git a/pkg/strategy/pivotshort/strategy.go b/pkg/strategy/pivotshort/strategy.go index 3e011a75a..1d9acf8b6 100644 --- a/pkg/strategy/pivotshort/strategy.go +++ b/pkg/strategy/pivotshort/strategy.go @@ -46,13 +46,10 @@ type SupportTakeProfit struct { } func (s *SupportTakeProfit) Subscribe(session *bbgo.ExchangeSession) { - log.Infof("[supportTakeProfit] Subscribe(%s, %s)", s.Symbol, s.Interval) session.Subscribe(types.KLineChannel, s.Symbol, types.SubscribeOptions{Interval: s.Interval}) } func (s *SupportTakeProfit) Bind(session *bbgo.ExchangeSession, orderExecutor *bbgo.GeneralOrderExecutor) { - log.Infof("[supportTakeProfit] Bind(%s, %s)", s.Symbol, s.Interval) - s.session = session s.orderExecutor = orderExecutor s.activeOrders = bbgo.NewActiveOrderBook(s.Symbol)