diff --git a/pkg/backtest/matching.go b/pkg/backtest/matching.go index a032299ee..47695d52d 100644 --- a/pkg/backtest/matching.go +++ b/pkg/backtest/matching.go @@ -124,7 +124,8 @@ func (m *SimplePriceMatching) CancelOrder(o types.Order) (types.Order, error) { return o, nil } -func (m *SimplePriceMatching) PlaceOrder(o types.SubmitOrder) (closedOrders *types.Order, trades *types.Trade, err error) { +// PlaceOrder returns the created order object, executed trade (if any) and error +func (m *SimplePriceMatching) PlaceOrder(o types.SubmitOrder) (*types.Order, *types.Trade, error) { // price for checking account balance, default price price := o.Price @@ -135,7 +136,7 @@ func (m *SimplePriceMatching) PlaceOrder(o types.SubmitOrder) (closedOrders *typ } price = m.LastPrice - case types.OrderTypeLimit, types.OrderTypeLimitMaker: + case types.OrderTypeLimit, types.OrderTypeStopLimit, types.OrderTypeLimitMaker: price = o.Price } @@ -301,11 +302,42 @@ func (m *SimplePriceMatching) newTradeFromOrder(order *types.Order, isMaker bool } } +// BuyToPrice means price go up and the limit sell should be triggered func (m *SimplePriceMatching) BuyToPrice(price fixedpoint.Value) (closedOrders []types.Order, trades []types.Trade) { klineMatchingLogger.Debugf("kline buy to price %s", price.String()) - var askOrders []types.Order + var bidOrders []types.Order + for _, o := range m.bidOrders { + switch o.Type { + case types.OrderTypeStopLimit: + // should we trigger the order? + if price.Compare(o.StopPrice) <= 0 { + bidOrders = append(bidOrders, o) + break + } + // convert this order to limit order + // we use value object here, so it's a copy + o.Type = types.OrderTypeLimit + + // is it a taker order? + // higher than the current price, then it's a taker order + if o.Price.Compare(price) >= 0 { + // taker order, move it to the closed order + o.ExecutedQuantity = o.Quantity + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + } else { + // keep it as a maker order + bidOrders = append(bidOrders, o) + } + default: + bidOrders = append(bidOrders, o) + } + } + m.bidOrders = bidOrders + + var askOrders []types.Order for _, o := range m.askOrders { switch o.Type { @@ -333,10 +365,13 @@ func (m *SimplePriceMatching) BuyToPrice(price fixedpoint.Value) (closedOrders [ o.Type = types.OrderTypeLimit // is it a taker order? - if price.Compare(o.Price) >= 0 { + // higher than the current price, then it's a taker order + if o.Price.Compare(price) >= 0 { + // price protection, added by @zenix if o.Price.Compare(m.LastKLine.Low) < 0 { o.Price = m.LastKLine.Low } + o.ExecutedQuantity = o.Quantity o.Status = types.OrderStatusFilled closedOrders = append(closedOrders, o) @@ -386,6 +421,37 @@ func (m *SimplePriceMatching) SellToPrice(price fixedpoint.Value) (closedOrders klineMatchingLogger.Debugf("kline sell to price %s", price.String()) var sellPrice = price + + var askOrders []types.Order + for _, o := range m.askOrders { + switch o.Type { + + case types.OrderTypeStopLimit: + // if the price is lower than the stop price + // we should trigger the stop sell order + if sellPrice.Compare(o.StopPrice) > 0 { + askOrders = append(askOrders, o) + break + } + + o.Type = types.OrderTypeLimit + + // if the order price is lower than the current price + // it's a taker order + if o.Price.Compare(sellPrice) <= 0 { + o.ExecutedQuantity = o.Quantity + o.Status = types.OrderStatusFilled + closedOrders = append(closedOrders, o) + } else { + askOrders = append(askOrders, o) + } + + default: + askOrders = append(askOrders, o) + } + } + m.askOrders = askOrders + var bidOrders []types.Order for _, o := range m.bidOrders { switch o.Type { @@ -402,11 +468,12 @@ func (m *SimplePriceMatching) SellToPrice(price fixedpoint.Value) (closedOrders } case types.OrderTypeStopLimit: - // should we trigger the order + // if the price is lower than the stop price + // we should trigger the stop order if sellPrice.Compare(o.StopPrice) <= 0 { o.Type = types.OrderTypeLimit - if sellPrice.Compare(o.Price) <= 0 { + if o.Price.Compare(sellPrice) <= 0 { if o.Price.Compare(m.LastKLine.High) > 0 { o.Price = m.LastKLine.High } diff --git a/pkg/backtest/matching_test.go b/pkg/backtest/matching_test.go index 3b478bbce..d0e20d65c 100644 --- a/pkg/backtest/matching_test.go +++ b/pkg/backtest/matching_test.go @@ -151,17 +151,7 @@ func newKLine(symbol string, interval types.Interval, startTime time.Time, o, h, } } -func TestSimplePriceMatching_PlaceLimitOrder(t *testing.T) { - account := &types.Account{ - MakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01), - TakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01), - } - - account.UpdateBalances(types.BalanceMap{ - "USDT": {Currency: "USDT", Available: fixedpoint.NewFromFloat(1000000.0)}, - "BTC": {Currency: "BTC", Available: fixedpoint.NewFromFloat(100.0)}, - }) - +func getTestMarket() types.Market { market := types.Market{ Symbol: "BTCUSDT", PricePrecision: 8, @@ -172,7 +162,98 @@ func TestSimplePriceMatching_PlaceLimitOrder(t *testing.T) { MinAmount: fixedpoint.MustNewFromString("10.0"), MinQuantity: fixedpoint.MustNewFromString("0.001"), } + return market +} +func getTestAccount() *types.Account { + account := &types.Account{ + MakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01), + TakerFeeRate: fixedpoint.NewFromFloat(0.075 * 0.01), + } + account.UpdateBalances(types.BalanceMap{ + "USDT": {Currency: "USDT", Available: fixedpoint.NewFromFloat(1000000.0)}, + "BTC": {Currency: "BTC", Available: fixedpoint.NewFromFloat(100.0)}, + }) + return account +} + +func TestSimplePriceMatching_StopLimitOrderBuy(t *testing.T) { + account := getTestAccount() + market := getTestMarket() + engine := &SimplePriceMatching{ + Account: account, + Market: market, + closedOrders: make(map[uint64]types.Order), + LastPrice: fixedpoint.NewFromFloat(19000.0), + } + + stopOrder := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeBuy, + Type: types.OrderTypeStopLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(22000.0), + StopPrice: fixedpoint.NewFromFloat(21000.0), + TimeInForce: types.TimeInForceGTC, + } + createdOrder, trade, err := engine.PlaceOrder(stopOrder) + assert.NoError(t, err) + assert.Nil(t, trade, "place stop order should not trigger the stop buy") + assert.NotNil(t, createdOrder, "place stop order should not trigger the stop buy") + + closedOrders, trades := engine.BuyToPrice(fixedpoint.NewFromFloat(20000.0)) + assert.Len(t, closedOrders, 0, "price change far from the price should not trigger the stop buy") + assert.Len(t, trades, 0, "price change far from the price should not trigger the stop buy") + + closedOrders, trades = engine.BuyToPrice(fixedpoint.NewFromFloat(21001.0)) + assert.Len(t, closedOrders, 1, "should trigger the stop buy order") + assert.Len(t, trades, 1, "should have stop order trade executed") + + assert.Equal(t, types.OrderStatusFilled, closedOrders[0].Status) + assert.Equal(t, types.OrderTypeLimit, closedOrders[0].Type) + assert.Equal(t, stopOrder.Price, trades[0].Price) +} + +func TestSimplePriceMatching_StopLimitOrderSell(t *testing.T) { + account := getTestAccount() + market := getTestMarket() + engine := &SimplePriceMatching{ + Account: account, + Market: market, + closedOrders: make(map[uint64]types.Order), + LastPrice: fixedpoint.NewFromFloat(22000.0), + } + + stopOrder := types.SubmitOrder{ + Symbol: market.Symbol, + Side: types.SideTypeSell, + Type: types.OrderTypeStopLimit, + Quantity: fixedpoint.NewFromFloat(0.1), + Price: fixedpoint.NewFromFloat(20000.0), + StopPrice: fixedpoint.NewFromFloat(21000.0), + TimeInForce: types.TimeInForceGTC, + } + createdOrder, trade, err := engine.PlaceOrder(stopOrder) + assert.NoError(t, err) + assert.Nil(t, trade, "place stop order should not trigger the stop sell") + assert.NotNil(t, createdOrder, "place stop order should not trigger the stop sell") + + closedOrders, trades := engine.SellToPrice(fixedpoint.NewFromFloat(21500.0)) + assert.Len(t, closedOrders, 0, "price change far from the price should not trigger the stop buy") + assert.Len(t, trades, 0, "price change far from the price should not trigger the stop buy") + + closedOrders, trades = engine.SellToPrice(fixedpoint.NewFromFloat(20990.0)) + assert.Len(t, closedOrders, 1, "should trigger the stop sell order") + assert.Len(t, trades, 1, "should have stop order trade executed") + + assert.Equal(t, types.OrderStatusFilled, closedOrders[0].Status) + assert.Equal(t, types.OrderTypeLimit, closedOrders[0].Type) + assert.Equal(t, stopOrder.Price, trades[0].Price) +} + +func TestSimplePriceMatching_PlaceLimitOrder(t *testing.T) { + account := getTestAccount() + market := getTestMarket() engine := &SimplePriceMatching{ Account: account, Market: market, diff --git a/pkg/strategy/pivotshort/strategy.go b/pkg/strategy/pivotshort/strategy.go index 1edb13968..3daa3a456 100644 --- a/pkg/strategy/pivotshort/strategy.go +++ b/pkg/strategy/pivotshort/strategy.go @@ -220,16 +220,38 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.orderExecutor.Bind() store, _ := session.MarketDataStore(s.Symbol) + standardIndicator, _ := session.StandardIndicatorSet(s.Symbol) s.pivot = &indicator.Pivot{IntervalWindow: s.IntervalWindow} s.pivot.Bind(store) + if kLinesP, ok := store.KLinesOfInterval(s.IntervalWindow.Interval); ok { + s.pivot.Update(*kLinesP) + } + + // update pivot low data + session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { + if kline.Symbol != s.Symbol || kline.Interval != s.Interval { + return + } + + lastLow := fixedpoint.NewFromFloat(s.pivot.LastLow()) + if lastLow.IsZero() { + return + } + + if lastLow.Compare(s.lastLow) != 0 { + log.Infof("new pivot low detected: %f %s", s.pivot.LastLow(), kline.EndTime.Time()) + } + + s.lastLow = lastLow + s.pivotLowPrices = append(s.pivotLowPrices, s.lastLow) + }) if s.BounceShort != nil && s.BounceShort.Enabled { s.resistancePivot = &indicator.Pivot{IntervalWindow: s.BounceShort.IntervalWindow} s.resistancePivot.Bind(store) } - standardIndicator, _ := session.StandardIndicatorSet(s.Symbol) if s.BreakLow.StopEMA != nil { s.stopEWMA = standardIndicator.EWMA(*s.BreakLow.StopEMA) } @@ -298,6 +320,17 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se s.pivotLowPrices = s.pivotLowPrices[len(s.pivotLowPrices)-10:] } + ratio := fixedpoint.One.Add(s.BreakLow.Ratio) + breakPrice := previousLow.Mul(ratio) + + closePrice := kline.Close + // if previous low is not break, skip + if closePrice.Compare(breakPrice) >= 0 { + return + } + + log.Infof("%s breakLow signal detected, closed price %f < breakPrice %f", kline.Symbol, closePrice.Float64(), breakPrice.Float64()) + // stop EMA protection if s.stopEWMA != nil && !s.BreakLow.StopEMARange.IsZero() { ema := fixedpoint.NewFromFloat(s.stopEWMA.Last()) @@ -306,19 +339,12 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se } emaStopShortPrice := ema.Mul(fixedpoint.One.Sub(s.BreakLow.StopEMARange)) - if kline.Close.Compare(emaStopShortPrice) < 0 { + if closePrice.Compare(emaStopShortPrice) < 0 { + log.Infof("stopEMA protection: close price %f < EMA(%v) = %f", closePrice.Float64(), s.BreakLow.StopEMA, ema.Float64()) return } } - ratio := fixedpoint.One.Add(s.BreakLow.Ratio) - breakPrice := previousLow.Mul(ratio) - - // if previous low is not break, skip - if kline.Close.Compare(breakPrice) >= 0 { - return - } - _ = s.orderExecutor.GracefulCancel(ctx) quantity := s.useQuantityOrBaseBalance(s.BreakLow.Quantity) @@ -374,27 +400,6 @@ func (s *Strategy) Run(ctx context.Context, orderExecutor bbgo.OrderExecutor, se }) } - session.MarketDataStream.OnKLineClosed(func(kline types.KLine) { - // StrategyController - if s.Status != types.StrategyStatusRunning { - return - } - - if kline.Symbol != s.Symbol || kline.Interval != s.Interval { - return - } - - if s.pivot.LastLow() > 0.0 { - lastLow := fixedpoint.NewFromFloat(s.pivot.LastLow()) - if lastLow.Compare(s.lastLow) != 0 { - log.Infof("new pivot low detected: %f %s", s.pivot.LastLow(), kline.EndTime.Time()) - } - - s.lastLow = lastLow - s.pivotLowPrices = append(s.pivotLowPrices, s.lastLow) - } - }) - s.Graceful.OnShutdown(func(ctx context.Context, wg *sync.WaitGroup) { _, _ = fmt.Fprintln(os.Stderr, s.TradeStats.String()) wg.Done() diff --git a/pkg/types/order.go b/pkg/types/order.go index a16de24db..993cd9a5a 100644 --- a/pkg/types/order.go +++ b/pkg/types/order.go @@ -271,7 +271,7 @@ func (o Order) String() string { orderID = strconv.FormatUint(o.OrderID, 10) } - return fmt.Sprintf("ORDER %s | %s | %s | %s | %s %-4s | %s/%s @ %s | %s", + desc := fmt.Sprintf("ORDER %s | %s | %s | %s | %s %-4s | %s/%s @ %s", o.Exchange.String(), o.CreationTime.Time().Local().Format(time.RFC1123), orderID, @@ -280,8 +280,13 @@ func (o Order) String() string { o.Side, o.ExecutedQuantity.String(), o.Quantity.String(), - o.Price.String(), - o.Status) + o.Price.String()) + + if o.Type == OrderTypeStopLimit { + desc += " Stop @ " + o.StopPrice.String() + } + + return desc + " | " + string(o.Status) } // PlainText is used for telegram-styled messages