diff --git a/pkg/backtest/matching.go b/pkg/backtest/matching.go index 9ea4c64df..a032299ee 100644 --- a/pkg/backtest/matching.go +++ b/pkg/backtest/matching.go @@ -56,7 +56,7 @@ type SimplePriceMatching struct { mu sync.Mutex bidOrders []types.Order askOrders []types.Order - closedOrders []types.Order + closedOrders map[uint64]types.Order LastPrice fixedpoint.Value LastKLine types.KLine @@ -375,8 +375,9 @@ func (m *SimplePriceMatching) BuyToPrice(price fixedpoint.Value) (closedOrders [ trades = append(trades, trade) m.EmitOrderUpdate(o) + + m.closedOrders[o.OrderID] = o } - m.closedOrders = append(m.closedOrders, closedOrders...) return closedOrders, trades } @@ -445,12 +446,33 @@ func (m *SimplePriceMatching) SellToPrice(price fixedpoint.Value) (closedOrders trades = append(trades, trade) m.EmitOrderUpdate(o) + + m.closedOrders[o.OrderID] = o } - m.closedOrders = append(m.closedOrders, closedOrders...) return closedOrders, trades } +func (m *SimplePriceMatching) getOrder(orderID uint64) (types.Order, bool) { + if o, ok := m.closedOrders[orderID]; ok { + return o, true + } + + for _, o := range m.bidOrders { + if o.OrderID == orderID { + return o, true + } + } + + for _, o := range m.askOrders { + if o.OrderID == orderID { + return o, true + } + } + + return types.Order{}, false +} + func (m *SimplePriceMatching) processKLine(kline types.KLine) { m.CurrentTime = kline.EndTime.Time() m.LastKLine = kline diff --git a/pkg/bbgo/order_execution.go b/pkg/bbgo/order_execution.go index 942329341..a4e93baf7 100644 --- a/pkg/bbgo/order_execution.go +++ b/pkg/bbgo/order_execution.go @@ -14,11 +14,6 @@ import ( type OrderExecutor interface { SubmitOrders(ctx context.Context, orders ...types.SubmitOrder) (createdOrders types.OrderSlice, err error) CancelOrders(ctx context.Context, orders ...types.Order) error - - OnTradeUpdate(cb func(trade types.Trade)) - OnOrderUpdate(cb func(order types.Order)) - EmitTradeUpdate(trade types.Trade) - EmitOrderUpdate(order types.Order) } type OrderExecutionRouter interface { diff --git a/pkg/bbgo/order_executor_general.go b/pkg/bbgo/order_executor_general.go index 751cddacb..054ae4b20 100644 --- a/pkg/bbgo/order_executor_general.go +++ b/pkg/bbgo/order_executor_general.go @@ -85,6 +85,11 @@ func (e *GeneralOrderExecutor) Bind() { e.tradeCollector.BindStream(e.session.UserDataStream) } +func (e *GeneralOrderExecutor) CancelOrders(ctx context.Context, orders ...types.Order) error { + err := e.session.Exchange.CancelOrders(ctx, orders...) + return err +} + func (e *GeneralOrderExecutor) SubmitOrders(ctx context.Context, submitOrders ...types.SubmitOrder) (types.OrderSlice, error) { formattedOrders, err := e.session.FormatOrders(submitOrders) if err != nil { @@ -125,3 +130,11 @@ func (e *GeneralOrderExecutor) ClosePosition(ctx context.Context, percentage fix func (e *GeneralOrderExecutor) TradeCollector() *TradeCollector { return e.tradeCollector } + +func (e *GeneralOrderExecutor) Session() *ExchangeSession { + return e.session +} + +func (e *GeneralOrderExecutor) Position() *types.Position { + return e.position +} diff --git a/pkg/strategy/pivotshort/protection_stop.go b/pkg/strategy/pivotshort/protection_stop.go new file mode 100644 index 000000000..3831ac2c0 --- /dev/null +++ b/pkg/strategy/pivotshort/protection_stop.go @@ -0,0 +1,155 @@ +package pivotshort + +import ( + "context" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +type ProtectionStopLoss struct { + // ActivationRatio is the trigger condition of this ROI protection stop loss + // When the price goes lower (for short position) with the ratio, the protection stop will be activated. + // This number should be positive to protect the profit + ActivationRatio fixedpoint.Value `json:"activationRatio"` + + // StopLossRatio is the ratio for stop loss. This number should be positive to protect the profit. + // negative ratio will cause loss. + StopLossRatio fixedpoint.Value `json:"stopLossRatio"` + + // PlaceStopOrder places the stop order on exchange and lock the balance + PlaceStopOrder bool `json:"placeStopOrder"` + + session *bbgo.ExchangeSession + orderExecutor *bbgo.GeneralOrderExecutor + stopLossPrice fixedpoint.Value + stopLossOrder *types.Order +} + +func (s *ProtectionStopLoss) shouldActivate(position *types.Position, closePrice fixedpoint.Value) bool { + if position.IsLong() { + r := one.Add(s.ActivationRatio) + activationPrice := position.AverageCost.Mul(r) + return closePrice.Compare(activationPrice) > 0 + } else if position.IsShort() { + r := one.Sub(s.ActivationRatio) + activationPrice := position.AverageCost.Mul(r) + // for short position, if the close price is less than the activation price then this is a profit position. + return closePrice.Compare(activationPrice) < 0 + } + + return false +} + +func (s *ProtectionStopLoss) placeStopOrder(ctx context.Context, position *types.Position, orderExecutor bbgo.OrderExecutor) error { + if s.stopLossOrder != nil { + if err := orderExecutor.CancelOrders(ctx, *s.stopLossOrder); err != nil { + log.WithError(err).Errorf("failed to cancel stop limit order: %+v", s.stopLossOrder) + } + s.stopLossOrder = nil + } + + createdOrders, err := orderExecutor.SubmitOrders(ctx, types.SubmitOrder{ + Symbol: position.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeStopLimit, + Quantity: position.GetQuantity(), + Price: s.stopLossPrice.Mul(one.Add(fixedpoint.NewFromFloat(0.005))), // +0.5% from the trigger price, slippage protection + StopPrice: s.stopLossPrice, + Market: position.Market, + }) + + if len(createdOrders) > 0 { + s.stopLossOrder = &createdOrders[0] + } + return err +} + +func (s *ProtectionStopLoss) shouldStop(closePrice fixedpoint.Value) bool { + if s.stopLossPrice.IsZero() { + return false + } + + return closePrice.Compare(s.stopLossPrice) >= 0 +} + +func (s *ProtectionStopLoss) Bind(session *bbgo.ExchangeSession, orderExecutor *bbgo.GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + orderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { + if position.IsClosed() { + s.stopLossOrder = nil + s.stopLossPrice = zero + } + }) + + session.UserDataStream.OnOrderUpdate(func(order types.Order) { + if s.stopLossOrder == nil { + return + } + + if order.OrderID == s.stopLossOrder.OrderID { + switch order.Status { + case types.OrderStatusFilled, types.OrderStatusCanceled: + s.stopLossOrder = nil + s.stopLossPrice = zero + } + } + }) + + position := orderExecutor.Position() + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + if kline.Symbol != position.Symbol || kline.Interval != types.Interval1m { + return + } + + isPositionOpened := !position.IsClosed() && !position.IsDust(kline.Close) + if isPositionOpened && position.IsShort() { + s.handleChange(context.Background(), position, kline.Close, s.orderExecutor) + } + }) +} + +func (s *ProtectionStopLoss) handleChange(ctx context.Context, position *types.Position, closePrice fixedpoint.Value, orderExecutor *bbgo.GeneralOrderExecutor) { + if s.stopLossOrder != nil { + // use RESTful to query the order status + // orderQuery := orderExecutor.Session().Exchange.(types.ExchangeOrderQueryService) + // order, err := orderQuery.QueryOrder(ctx, types.OrderQuery{ + // Symbol: s.stopLossOrder.Symbol, + // OrderID: strconv.FormatUint(s.stopLossOrder.OrderID, 10), + // }) + // if err != nil { + // log.WithError(err).Errorf("query order failed") + // } + } + + if s.stopLossPrice.IsZero() { + if s.shouldActivate(position, closePrice) { + // calculate stop loss price + if position.IsShort() { + s.stopLossPrice = position.AverageCost.Mul(one.Sub(s.StopLossRatio)) + } else if position.IsLong() { + s.stopLossPrice = position.AverageCost.Mul(one.Add(s.StopLossRatio)) + } + + log.Infof("[ProtectionStopLoss] %s protection stop loss activated, current price = %f, average cost = %f, stop loss price = %f", + position.Symbol, closePrice.Float64(), position.AverageCost.Float64(), s.stopLossPrice.Float64()) + } else { + // not activated, skip setup stop order + return + } + } + + if s.PlaceStopOrder { + if err := s.placeStopOrder(ctx, position, orderExecutor); err != nil { + log.WithError(err).Errorf("failed to place stop limit order") + } + } else if s.shouldStop(closePrice) { + log.Infof("[ProtectionStopLoss] protection stop order is triggered at price %f, position = %+v", closePrice.Float64(), position) + if err := orderExecutor.ClosePosition(ctx, one); err != nil { + log.WithError(err).Errorf("failed to close position") + } + } +} diff --git a/pkg/strategy/pivotshort/roi_stop.go b/pkg/strategy/pivotshort/roi_stop.go new file mode 100644 index 000000000..ab9f3766a --- /dev/null +++ b/pkg/strategy/pivotshort/roi_stop.go @@ -0,0 +1,41 @@ +package pivotshort + +import ( + "context" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +type RoiStopLoss struct { + Percentage fixedpoint.Value `json:"percentage"` + + session *bbgo.ExchangeSession + orderExecutor *bbgo.GeneralOrderExecutor +} + +func (s *RoiStopLoss) Bind(session *bbgo.ExchangeSession, orderExecutor *bbgo.GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + position := orderExecutor.Position() + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + if kline.Symbol != position.Symbol || kline.Interval != types.Interval1m { + return + } + + closePrice := kline.Close + if position.IsClosed() || position.IsDust(closePrice) { + return + } + + roi := position.ROI(closePrice) + if roi.Compare(s.Percentage.Neg()) < 0 { + // stop loss + bbgo.Notify("[RoiStopLoss] %s stop loss triggered by ROI %s/%s, price: %f", position.Symbol, roi.Percentage(), s.Percentage.Neg().Percentage(), kline.Close.Float64()) + _ = orderExecutor.ClosePosition(context.Background(), fixedpoint.One) + return + } + }) +} diff --git a/pkg/strategy/pivotshort/roi_take_profit.go b/pkg/strategy/pivotshort/roi_take_profit.go new file mode 100644 index 000000000..6c1efce8e --- /dev/null +++ b/pkg/strategy/pivotshort/roi_take_profit.go @@ -0,0 +1,41 @@ +package pivotshort + +import ( + "context" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +type RoiTakeProfit struct { + Percentage fixedpoint.Value `json:"percentage"` + + session *bbgo.ExchangeSession + orderExecutor *bbgo.GeneralOrderExecutor +} + +func (s *RoiTakeProfit) Bind(session *bbgo.ExchangeSession, orderExecutor *bbgo.GeneralOrderExecutor) { + s.session = session + s.orderExecutor = orderExecutor + + position := orderExecutor.Position() + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + if kline.Symbol != position.Symbol || kline.Interval != types.Interval1m { + return + } + + closePrice := kline.Close + if position.IsClosed() || position.IsDust(closePrice) { + return + } + + roi := position.ROI(closePrice) + if roi.Compare(s.Percentage) > 0 { + // stop loss + bbgo.Notify("[RoiTakeProfit] %s take profit is triggered by ROI %s/%s, price: %f", position.Symbol, roi.Percentage(), s.Percentage.Percentage(), kline.Close.Float64()) + _ = orderExecutor.ClosePosition(context.Background(), fixedpoint.One) + return + } + }) +} diff --git a/pkg/strategy/pivotshort/strategy.go b/pkg/strategy/pivotshort/strategy.go index b2aab4616..76df9de2c 100644 --- a/pkg/strategy/pivotshort/strategy.go +++ b/pkg/strategy/pivotshort/strategy.go @@ -17,6 +17,9 @@ import ( const ID = "pivotshort" +var one = fixedpoint.One +var zero = fixedpoint.Zero + var log = logrus.WithField("strategy", ID) func init() { @@ -72,10 +75,12 @@ type CumulatedVolume struct { } type Exit struct { - RoiStopLossPercentage fixedpoint.Value `json:"roiStopLossPercentage"` - RoiTakeProfitPercentage fixedpoint.Value `json:"roiTakeProfitPercentage"` RoiMinTakeProfitPercentage fixedpoint.Value `json:"roiMinTakeProfitPercentage"` + RoiTakeProfit *RoiTakeProfit `json:"roiTakeProfit"` + RoiStopLoss *RoiStopLoss `json:"roiStopLoss"` + ProtectionStopLoss *ProtectionStopLoss `json:"protectionStopLoss"` + LowerShadowRatio fixedpoint.Value `json:"lowerShadowRatio"` CumulatedVolume *CumulatedVolume `json:"cumulatedVolume"` @@ -108,6 +113,7 @@ type Strategy struct { session *bbgo.ExchangeSession orderExecutor *bbgo.GeneralOrderExecutor + stopLossPrice fixedpoint.Value lastLow fixedpoint.Value pivot *indicator.Pivot resistancePivot *indicator.Pivot @@ -178,6 +184,7 @@ func (s *Strategy) CurrentPosition() *types.Position { } func (s *Strategy) ClosePosition(ctx context.Context, percentage fixedpoint.Value) error { + bbgo.Notify("Closing position", s.Position) return s.orderExecutor.ClosePosition(ctx, percentage) } @@ -271,9 +278,20 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } }) + if s.Exit.ProtectionStopLoss != nil { + s.Exit.ProtectionStopLoss.Bind(session, s.orderExecutor) + } + + if s.Exit.RoiStopLoss != nil { + s.Exit.RoiStopLoss.Bind(session, s.orderExecutor) + } + + if s.Exit.RoiTakeProfit != nil { + s.Exit.RoiTakeProfit.Bind(session, s.orderExecutor) + } + // Always check whether you can open a short position or not session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { - // StrategyController if s.Status != types.StrategyStatusRunning { return } @@ -285,21 +303,9 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se isPositionOpened := !s.Position.IsClosed() && !s.Position.IsDust(kline.Close) if isPositionOpened && s.Position.IsShort() { - // calculate return rate - // TODO: apply quantity to this formula - roi := s.Position.AverageCost.Sub(kline.Close).Div(s.Position.AverageCost) - if roi.Compare(s.Exit.RoiStopLossPercentage.Neg()) < 0 { - // stop loss - bbgo.Notify("%s ROI StopLoss triggered at price %f: Loss %s", s.Symbol, kline.Close.Float64(), roi.Percentage()) - _ = s.ClosePosition(ctx, fixedpoint.One) - return - } else { - // take profit - if roi.Compare(s.Exit.RoiTakeProfitPercentage) > 0 { // force take profit - bbgo.Notify("%s TakeProfit triggered at price %f: by ROI percentage %s", s.Symbol, kline.Close.Float64(), roi.Percentage(), kline) - _ = s.ClosePosition(ctx, fixedpoint.One) - return - } else if !s.Exit.RoiMinTakeProfitPercentage.IsZero() && roi.Compare(s.Exit.RoiMinTakeProfitPercentage) > 0 { + roi := s.Position.ROI(kline.Close) + if !s.Exit.RoiMinTakeProfitPercentage.IsZero() { + if roi.Compare(s.Exit.RoiMinTakeProfitPercentage) > 0 { if !s.Exit.LowerShadowRatio.IsZero() && kline.GetLowerShadowHeight().Div(kline.Close).Compare(s.Exit.LowerShadowRatio) > 0 { bbgo.Notify("%s TakeProfit triggered at price %f: by shadow ratio %f", s.Symbol, @@ -334,6 +340,7 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } if len(s.pivotLowPrices) == 0 { + log.Infof("currently there is no pivot low prices, skip placing orders...") return } diff --git a/pkg/types/position.go b/pkg/types/position.go index 62c41873a..185106572 100644 --- a/pkg/types/position.go +++ b/pkg/types/position.go @@ -174,6 +174,11 @@ func (p *Position) GetBase() (base fixedpoint.Value) { return base } +func (p *Position) GetQuantity() fixedpoint.Value { + base := p.GetBase() + return base.Abs() +} + func (p *Position) UnrealizedProfit(price fixedpoint.Value) fixedpoint.Value { quantity := p.GetBase().Abs()