diff --git a/pkg/bbgo/exit_trailing_stop.go b/pkg/bbgo/exit_trailing_stop.go index 5c6360af9..481a1a0de 100644 --- a/pkg/bbgo/exit_trailing_stop.go +++ b/pkg/bbgo/exit_trailing_stop.go @@ -16,6 +16,9 @@ type TrailingStop2 struct { // CallbackRate is the callback rate from the previous high price CallbackRate fixedpoint.Value `json:"callbackRate,omitempty"` + // CallbackProfitRate is the callback rate from the previous high profit + CallbackProfitRate fixedpoint.Value `json:"callbackProfitRate,omitempty"` + ActivationRatio fixedpoint.Value `json:"activationRatio,omitempty"` // ClosePosition is a percentage of the position to be closed @@ -31,7 +34,8 @@ type TrailingStop2 struct { Side types.SideType `json:"side,omitempty"` - latestHigh fixedpoint.Value + latestHigh fixedpoint.Value + latestHighProfit fixedpoint.Value // activated: when the price reaches the min profit price, we set the activated to true to enable trailing stop activated bool @@ -140,32 +144,48 @@ func (s *TrailingStop2) checkStopPrice(price fixedpoint.Value, position *types.P return nil } - switch s.Side { - case types.SideTypeBuy: - change := price.Sub(s.latestHigh).Div(s.latestHigh) - if change.Compare(s.CallbackRate) >= 0 { - // submit order - return s.triggerStop(price) - } - case types.SideTypeSell: - change := s.latestHigh.Sub(price).Div(s.latestHigh) - if change.Compare(s.CallbackRate) >= 0 { - // submit order - return s.triggerStop(price) - } - default: - if position.IsLong() { - change := s.latestHigh.Sub(price).Div(s.latestHigh) + if !s.CallbackRate.IsZero() { + switch s.Side { + case types.SideTypeBuy: + change := price.Sub(s.latestHigh).Div(s.latestHigh) if change.Compare(s.CallbackRate) >= 0 { // submit order return s.triggerStop(price) } - } else if position.IsShort() { - change := price.Sub(s.latestHigh).Div(s.latestHigh) + case types.SideTypeSell: + change := s.latestHigh.Sub(price).Div(s.latestHigh) if change.Compare(s.CallbackRate) >= 0 { // submit order return s.triggerStop(price) } + default: + if position.IsLong() { + change := s.latestHigh.Sub(price).Div(s.latestHigh) + if change.Compare(s.CallbackRate) >= 0 { + // submit order + return s.triggerStop(price) + } + } else if position.IsShort() { + change := price.Sub(s.latestHigh).Div(s.latestHigh) + if change.Compare(s.CallbackRate) >= 0 { + // submit order + return s.triggerStop(price) + } + } + } + } + + if !s.CallbackProfitRate.IsZero() { + if s.latestHighProfit.IsZero() { + s.latestHighProfit = position.UnrealizedProfit(price) + } else { + profit := position.UnrealizedProfit(price) + change := s.latestHighProfit.Sub(profit).Div(s.latestHighProfit) + if change.Compare(s.CallbackProfitRate) >= 0 { + // submit order + return s.triggerStop(price) + } + s.latestHighProfit = fixedpoint.Max(profit, s.latestHighProfit) } } @@ -177,16 +197,32 @@ func (s *TrailingStop2) triggerStop(price fixedpoint.Value) error { defer func() { s.activated = false s.latestHigh = fixedpoint.Zero + s.latestHighProfit = fixedpoint.Zero }() - Notify("[TrailingStop] %s %s tailingStop is triggered. price: %f callbackRate: %s", s.Symbol, s.ActivationRatio.Percentage(), price.Float64(), s.CallbackRate.Percentage()) + message := fmt.Sprintf("[TrailingStop] %s %s tailingStop is triggered. price: %f ", + s.Symbol, s.ActivationRatio.Percentage(), price.Float64()) + if !s.CallbackRate.IsZero() { + message += fmt.Sprintf(" callbackRate: %s", s.CallbackRate.Percentage()) + } + if !s.CallbackProfitRate.IsZero() { + message += fmt.Sprintf(" callbackProfitRate: %s", s.CallbackProfitRate.Percentage()) + } + Notify("%s", message) + ctx := context.Background() p := fixedpoint.One if !s.ClosePosition.IsZero() { p = s.ClosePosition } - tagName := fmt.Sprintf("trailingStop:activation=%s,callback=%s", s.ActivationRatio.Percentage(), s.CallbackRate.Percentage()) + tagName := fmt.Sprintf("trailingStop:activation=%s", s.ActivationRatio.Percentage()) + if !s.CallbackRate.IsZero() { + tagName += fmt.Sprintf(",callback=%s", s.CallbackRate.Percentage()) + } + if !s.CallbackProfitRate.IsZero() { + tagName += fmt.Sprintf(",callbackProfit=%s", s.CallbackProfitRate.Percentage()) + } return s.orderExecutor.ClosePosition(ctx, p, tagName) } diff --git a/pkg/bbgo/exit_trailing_stop_test.go b/pkg/bbgo/exit_trailing_stop_test.go index d43c6d70a..fa9bfe017 100644 --- a/pkg/bbgo/exit_trailing_stop_test.go +++ b/pkg/bbgo/exit_trailing_stop_test.go @@ -182,3 +182,80 @@ func TestTrailingStop_LongPosition(t *testing.T) { assert.False(t, stop.activated) } } + +func TestTrailingStop_CallbackProfitRate(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().SubmitOrder(gomock.Any(), types.SubmitOrder{ + Symbol: "BTCUSDT", + Side: types.SideTypeSell, + Type: types.OrderTypeMarket, + Market: market, + Quantity: fixedpoint.NewFromFloat(1.0), + Tag: "trailingStop:activation=1%,callbackProfit=10%", + MarginSideEffect: types.SideEffectTypeAutoRepay, + }) + + 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) + callbackProfitRatio := fixedpoint.NewFromFloat(0.1) + stop := &TrailingStop2{ + Symbol: "BTCUSDT", + Interval: types.Interval1m, + Side: types.SideTypeSell, + CallbackProfitRate: callbackProfitRatio, + 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% = 20200 + currentPrice = currentPrice.Mul(one.Add(activationRatio)) + assert.Equal(t, fixedpoint.NewFromFloat(20200.0), currentPrice) + + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.True(t, stop.activated) + assert.Equal(t, fixedpoint.NewFromFloat(200), stop.latestHighProfit) + } + + // 20200 + 1% = 20402 + currentPrice = currentPrice.Mul(one.Add(activationRatio)) + assert.Equal(t, fixedpoint.NewFromFloat(20402.0), currentPrice) + + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.Equal(t, fixedpoint.NewFromFloat(402), stop.latestHighProfit) + assert.True(t, stop.activated) + } + + // 20402 - 402*(1-10%) + currentPrice = currentPrice.Sub(stop.latestHighProfit.Mul(one.Sub(callbackProfitRatio))) + assert.Equal(t, fixedpoint.NewFromFloat(20040.2), currentPrice) + err = stop.checkStopPrice(currentPrice, position) + if assert.NoError(t, err) { + assert.False(t, stop.activated) + assert.Equal(t, fixedpoint.Zero, stop.latestHighProfit) + } +}