Skip to content

Commit

Permalink
FEATURE: [dca2] make the take-profit order of round from order to orders
Browse files Browse the repository at this point in the history
  • Loading branch information
kbearXD committed May 29, 2024
1 parent 7bde48a commit 64280e8
Show file tree
Hide file tree
Showing 7 changed files with 187 additions and 73 deletions.
31 changes: 19 additions & 12 deletions pkg/strategy/dca2/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package dca2

import (
"context"
"fmt"
"strconv"
"time"

Expand All @@ -11,9 +10,15 @@ import (
"github.com/sirupsen/logrus"
)

// Round contains the open-position orders and the take-profit orders
// 1. len(OpenPositionOrders) == 0 -> not open position
// 2. len(TakeProfitOrders) == 0 -> not in the take-profit stage
// 3. There are take-profit orders only when open-position orders are cancelled
// 4. We need to make sure the order: open-position (BUY) -> take-profit (SELL) -> open-position (BUY) -> take-profit (SELL) -> ...
// 5. When there is one filled take-profit order, this round must be finished. We need to verify all take-profit orders are not active
type Round struct {
OpenPositionOrders []types.Order
TakeProfitOrder types.Order
TakeProfitOrders []types.Order
}

type Collector struct {
Expand Down Expand Up @@ -106,10 +111,7 @@ func (rc Collector) CollectCurrentRound(ctx context.Context) (Round, error) {
case openPositionSide:
currentRound.OpenPositionOrders = append(currentRound.OpenPositionOrders, order)
case takeProfitSide:
if currentRound.TakeProfitOrder.OrderID != 0 {
return currentRound, fmt.Errorf("there are two take-profit orders in one round, please check it")
}
currentRound.TakeProfitOrder = order
currentRound.TakeProfitOrders = append(currentRound.TakeProfitOrders, order)
default:
}

Expand Down Expand Up @@ -141,12 +143,20 @@ func (rc *Collector) CollectFinishRounds(ctx context.Context, fromOrderID uint64
case types.SideTypeBuy:
round.OpenPositionOrders = append(round.OpenPositionOrders, order)
case types.SideTypeSell:
round.TakeProfitOrders = append(round.TakeProfitOrders, order)

if order.Status != types.OrderStatusFilled {
rc.logger.Infof("take-profit order is %s not filled, so this round is not finished. Skip it", order.Status)
rc.logger.Infof("take-profit order is %s not filled, so this round is not finished. Keep collecting", order.Status)
continue
}

round.TakeProfitOrder = order
for _, o := range round.TakeProfitOrders {
if types.IsActiveOrder(o) {
// Should not happen ! but we only log it
rc.logger.Errorf("there is at least one take-profit order (%d) is still active, please check it", o.OrderID)
}
}

rounds = append(rounds, round)
round = Round{}
default:
Expand All @@ -164,10 +174,7 @@ func (rc *Collector) CollectRoundTrades(ctx context.Context, round Round) ([]typ
var roundTrades []types.Trade
var roundOrders []types.Order = round.OpenPositionOrders

// if the take-profit order's OrderID == 0 -> no take-profit order.
if round.TakeProfitOrder.OrderID != 0 {
roundOrders = append(roundOrders, round.TakeProfitOrder)
}
roundOrders = append(roundOrders, round.TakeProfitOrders...)

for _, order := range roundOrders {
if order.ExecutedQuantity.IsZero() {
Expand Down
72 changes: 72 additions & 0 deletions pkg/strategy/dca2/collector_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package dca2

import (
"testing"

"github.com/c9s/bbgo/pkg/types"
"github.com/c9s/bbgo/pkg/types/mocks"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
)

func Test_NewCollector(t *testing.T) {
symbol := "ETHUSDT"
logger := log.WithField("strategy", ID)

t.Run("return nil if the exchange doesn't support ExchangeTradeHistoryService", func(t *testing.T) {
mockCtrl := gomock.NewController(t)
mockEx := mocks.NewMockExchange(mockCtrl)
mockEx.EXPECT().Name().Return(types.ExchangeMax)

collector := NewCollector(logger, symbol, 0, mockEx)

assert.Nil(t, collector)
})

t.Run("return nil if the exchange doesn't support ExchangeOrderQueryService", func(t *testing.T) {
mockCtrl := gomock.NewController(t)
mockEx := mocks.NewMockExchange(mockCtrl)
mockEx.EXPECT().Name().Return(types.ExchangeMax)

mockTradeHistoryService := mocks.NewMockExchangeTradeHistoryService(mockCtrl)

type TestEx struct {
types.Exchange
types.ExchangeTradeHistoryService
}

ex := TestEx{
Exchange: mockEx,
ExchangeTradeHistoryService: mockTradeHistoryService,
}

collector := NewCollector(logger, symbol, 0, ex)

assert.Nil(t, collector)
})

t.Run("return nil if the exchange doesn't support descendingClosedOrderQueryService", func(t *testing.T) {
mockCtrl := gomock.NewController(t)
mockEx := mocks.NewMockExchange(mockCtrl)
mockEx.EXPECT().Name().Return(types.ExchangeMax)

mockTradeHistoryService := mocks.NewMockExchangeTradeHistoryService(mockCtrl)
mockOrderQueryService := mocks.NewMockExchangeOrderQueryService(mockCtrl)

type TestEx struct {
types.Exchange
types.ExchangeTradeHistoryService
types.ExchangeOrderQueryService
}

ex := TestEx{
Exchange: mockEx,
ExchangeTradeHistoryService: mockTradeHistoryService,
ExchangeOrderQueryService: mockOrderQueryService,
}

collector := NewCollector(logger, symbol, 0, ex)

assert.Nil(t, collector)
})
}
4 changes: 3 additions & 1 deletion pkg/strategy/dca2/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ func (s *Strategy) debugOrders(submitOrders []types.Order) {
func debugRoundOrders(logger *logrus.Entry, roundName string, round Round) {
var sb strings.Builder
sb.WriteString("ROUND " + roundName + " [\n")
sb.WriteString(round.TakeProfitOrder.String() + "\n")
for i, order := range round.TakeProfitOrders {
sb.WriteString(fmt.Sprintf("%3d) ", i+1) + order.String() + "\n")
}
sb.WriteString("------------------------------------------------\n")
for i, order := range round.OpenPositionOrders {
sb.WriteString(fmt.Sprintf("%3d) ", i+1) + order.String() + "\n")
Expand Down
101 changes: 59 additions & 42 deletions pkg/strategy/dca2/recover.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,63 +64,55 @@ func recoverState(ctx context.Context, maxOrderCount int, currentRound Round, or
orderStore := orderExecutor.OrderStore()

// dca stop at take-profit order stage
if currentRound.TakeProfitOrder.OrderID != 0 {
// the number of open-positions orders may not be equal to maxOrderCount, because the notional may not enough to open maxOrderCount orders
if len(currentRound.OpenPositionOrders) > maxOrderCount {
return None, fmt.Errorf("there is take-profit order but the number of open-position orders (%d) is greater than maxOrderCount(%d). Please check it", len(currentRound.OpenPositionOrders), maxOrderCount)
if len(currentRound.TakeProfitOrders) > 0 {
openedOrders, cancelledOrders, filledOrders, unexpectedOrders := classifyOrders(currentRound.TakeProfitOrders)

if len(unexpectedOrders) > 0 {
return None, fmt.Errorf("there is unexpected status in orders %+v", unexpectedOrders)
}

takeProfitOrder := currentRound.TakeProfitOrder
if takeProfitOrder.Status == types.OrderStatusFilled {
if len(filledOrders) > 0 && len(openedOrders) == 0 {
return WaitToOpenPosition, nil
} else if types.IsActiveOrder(takeProfitOrder) {
activeOrderBook.Add(takeProfitOrder)
orderStore.Add(takeProfitOrder)
}

if len(filledOrders) == 0 && len(openedOrders) > 0 {
// add opened order into order store
for _, order := range openedOrders {
activeOrderBook.Add(order)
orderStore.Add(order)
}
return TakeProfitReady, nil
} else {
return None, fmt.Errorf("the status of take-profit order is %s. Please check it", takeProfitOrder.Status)
}

return None, fmt.Errorf("the classify orders count is not expected (opened: %d, cancelled: %d, filled: %d)", len(openedOrders), len(cancelledOrders), len(filledOrders))
}

// dca stop at no take-profit order stage
openPositionOrders := currentRound.OpenPositionOrders
numOpenPositionOrders := len(openPositionOrders)

// new strategy
if len(openPositionOrders) == 0 {
return WaitToOpenPosition, nil
}

// should not happen
if numOpenPositionOrders > maxOrderCount {
return None, fmt.Errorf("the number of open-position orders (%d) is > max order number", numOpenPositionOrders)
}

// collect open-position orders' status
var openedCnt, filledCnt, cancelledCnt int64
for _, order := range currentRound.OpenPositionOrders {
switch order.Status {
case types.OrderStatusNew, types.OrderStatusPartiallyFilled:
activeOrderBook.Add(order)
orderStore.Add(order)
openedCnt++
case types.OrderStatusFilled:
filledCnt++
case types.OrderStatusCanceled:
cancelledCnt++
default:
return None, fmt.Errorf("there is unexpected status %s of order %s", order.Status, order)
}
openedOrders, cancelledOrders, filledOrders, unexpectedOrders := classifyOrders(currentRound.OpenPositionOrders)
if len(unexpectedOrders) > 0 {
return None, fmt.Errorf("there is unexpected status of orders %+v", unexpectedOrders)
}
for _, order := range openedOrders {
activeOrderBook.Add(order)
orderStore.Add(order)
}

// no order is filled -> OpenPositionReady
if filledCnt == 0 {
if len(filledOrders) == 0 {
return OpenPositionReady, nil
}

// there are at least one open-position orders filled
if cancelledCnt == 0 {
if openedCnt > 0 {
if len(cancelledOrders) == 0 {
if len(openedOrders) > 0 {
return OpenPositionOrderFilled, nil
} else {
// all open-position orders filled, change to cancelling and place the take-profit order
Expand All @@ -141,13 +133,17 @@ func recoverPosition(ctx context.Context, position *types.Position, currentRound
position.Reset()

var positionOrders []types.Order
if currentRound.TakeProfitOrder.OrderID != 0 {
// if the take-profit order is already filled, the position is 0
if !types.IsActiveOrder(currentRound.TakeProfitOrder) {
return nil

var filledCnt int64
for _, order := range currentRound.TakeProfitOrders {
if !types.IsActiveOrder(order) {
filledCnt++
}
positionOrders = append(positionOrders, order)
}

positionOrders = append(positionOrders, currentRound.TakeProfitOrder)
if filledCnt == int64(len(currentRound.TakeProfitOrders)) {
return nil
}

for _, order := range currentRound.OpenPositionOrders {
Expand Down Expand Up @@ -184,9 +180,30 @@ func recoverProfitStats(ctx context.Context, strategy *Strategy) error {
}

func recoverStartTimeOfNextRound(ctx context.Context, currentRound Round, coolDownInterval types.Duration) time.Time {
if currentRound.TakeProfitOrder.OrderID != 0 && currentRound.TakeProfitOrder.Status == types.OrderStatusFilled {
return currentRound.TakeProfitOrder.UpdateTime.Time().Add(coolDownInterval.Duration())
var startTimeOfNextRound time.Time

for _, order := range currentRound.TakeProfitOrders {
if t := order.UpdateTime.Time().Add(coolDownInterval.Duration()); t.After(startTimeOfNextRound) {
startTimeOfNextRound = t
}
}

return startTimeOfNextRound
}

func classifyOrders(orders []types.Order) (opened, cancelled, filled, unexpected []types.Order) {
for _, order := range orders {
switch order.Status {
case types.OrderStatusNew, types.OrderStatusPartiallyFilled:
opened = append(opened, order)
case types.OrderStatusFilled:
filled = append(filled, order)
case types.OrderStatusCanceled:
cancelled = append(cancelled, order)
default:
unexpected = append(unexpected, order)
}
}

return time.Time{}
return opened, cancelled, filled, unexpected
}
42 changes: 27 additions & 15 deletions pkg/strategy/dca2/recover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,6 @@ func generateTestOrder(side types.SideType, status types.OrderStatus, createdAt

}

type MockQueryOrders struct {
OpenOrders []types.Order
ClosedOrders []types.Order
}

func (m *MockQueryOrders) QueryOpenOrders(ctx context.Context, symbol string) ([]types.Order, error) {
return m.OpenOrders, nil
}

func (m *MockQueryOrders) QueryClosedOrdersDesc(ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64) ([]types.Order, error) {
return m.ClosedOrders, nil
}

func Test_RecoverState(t *testing.T) {
strategy := newTestStrategy()

Expand Down Expand Up @@ -123,7 +110,9 @@ func Test_RecoverState(t *testing.T) {
t.Run("at take profit stage, and not filled yet", func(t *testing.T) {
now := time.Now()
currentRound := Round{
TakeProfitOrder: generateTestOrder(types.SideTypeSell, types.OrderStatusNew, now),
TakeProfitOrders: []types.Order{
generateTestOrder(types.SideTypeSell, types.OrderStatusNew, now),
},
OpenPositionOrders: []types.Order{
generateTestOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)),
generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-2*time.Second)),
Expand All @@ -142,7 +131,9 @@ func Test_RecoverState(t *testing.T) {
t.Run("at take profit stage, take-profit order filled", func(t *testing.T) {
now := time.Now()
currentRound := Round{
TakeProfitOrder: generateTestOrder(types.SideTypeSell, types.OrderStatusFilled, now),
TakeProfitOrders: []types.Order{
generateTestOrder(types.SideTypeSell, types.OrderStatusFilled, now),
},
OpenPositionOrders: []types.Order{
generateTestOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)),
generateTestOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-2*time.Second)),
Expand All @@ -158,3 +149,24 @@ func Test_RecoverState(t *testing.T) {
assert.Equal(t, WaitToOpenPosition, state)
})
}

func Test_classifyOrders(t *testing.T) {
orders := []types.Order{
types.Order{Status: types.OrderStatusCanceled},
types.Order{Status: types.OrderStatusFilled},
types.Order{Status: types.OrderStatusCanceled},
types.Order{Status: types.OrderStatusFilled},
types.Order{Status: types.OrderStatusPartiallyFilled},
types.Order{Status: types.OrderStatusCanceled},
types.Order{Status: types.OrderStatusPartiallyFilled},
types.Order{Status: types.OrderStatusNew},
types.Order{Status: types.OrderStatusRejected},
types.Order{Status: types.OrderStatusCanceled},
}

opened, cancelled, filled, unexpected := classifyOrders(orders)
assert.Equal(t, 3, len(opened))
assert.Equal(t, 4, len(cancelled))
assert.Equal(t, 2, len(filled))
assert.Equal(t, 1, len(unexpected))
}
6 changes: 5 additions & 1 deletion pkg/strategy/dca2/strategy.go
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,11 @@ func (s *Strategy) UpdateProfitStats(ctx context.Context) (bool, error) {
}

// update profit stats FromOrderID to make sure we will not collect duplicated rounds
s.ProfitStats.FromOrderID = round.TakeProfitOrder.OrderID + 1
for _, order := range round.TakeProfitOrders {
if order.OrderID >= s.ProfitStats.FromOrderID {
s.ProfitStats.FromOrderID = order.OrderID + 1
}
}

// update quote investment
s.ProfitStats.QuoteInvestment = s.ProfitStats.QuoteInvestment.Add(s.ProfitStats.CurrentRoundProfit)
Expand Down
4 changes: 2 additions & 2 deletions pkg/strategy/dca2/take_profit.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ func (s *Strategy) placeTakeProfitOrders(ctx context.Context) error {
return errors.Wrap(err, "failed to place the take-profit order when collecting current round")
}

if currentRound.TakeProfitOrder.OrderID != 0 {
return fmt.Errorf("there is a take-profit order before placing the take-profit order, please check it")
if len(currentRound.TakeProfitOrders) > 0 {
return fmt.Errorf("there is a take-profit order before placing the take-profit order, please check it and manually fix it")
}

trades, err := s.collector.CollectRoundTrades(ctx, currentRound)
Expand Down

0 comments on commit 64280e8

Please sign in to comment.