From bfd9c8ac64692c1a4e3418b41813e1c89b85e9ae Mon Sep 17 00:00:00 2001 From: "chiahung.lin" Date: Mon, 27 Nov 2023 15:55:02 +0800 Subject: [PATCH] FEATURE: run state machine FEATURE: support recover FEATURE: add order into orderStore and recover position recover position/budget FEATURE: support recover budget --- config/dca2.yaml | 9 +- ...t_wallet_open_orders_request_requestgen.go | 1 - pkg/strategy/dca2/debug.go | 13 + pkg/strategy/dca2/open_position_test.go | 12 +- pkg/strategy/dca2/recover.go | 268 ++++++++++++++++++ pkg/strategy/dca2/recover_test.go | 236 +++++++++++++++ pkg/strategy/dca2/state.go | 163 +++++++++++ pkg/strategy/dca2/strategy.go | 89 +++++- 8 files changed, 766 insertions(+), 25 deletions(-) create mode 100644 pkg/strategy/dca2/recover.go create mode 100644 pkg/strategy/dca2/recover_test.go create mode 100644 pkg/strategy/dca2/state.go diff --git a/config/dca2.yaml b/config/dca2.yaml index 8c10453ca..6cb8b6ca9 100644 --- a/config/dca2.yaml +++ b/config/dca2.yaml @@ -23,8 +23,9 @@ exchangeStrategies: dca2: symbol: ETHUSDT short: false - budget: 5000 - maxOrderNum: 10 + budget: 200 + maxOrderNum: 5 priceDeviation: 1% - takeProfitRatio: 1% - coolDownInterval: 5m + takeProfitRatio: 0.2% + coolDownInterval: 3m + circuitBreakLossThreshold: -0.9 diff --git a/pkg/exchange/max/maxapi/v3/get_wallet_open_orders_request_requestgen.go b/pkg/exchange/max/maxapi/v3/get_wallet_open_orders_request_requestgen.go index 51416ccda..741f375a2 100644 --- a/pkg/exchange/max/maxapi/v3/get_wallet_open_orders_request_requestgen.go +++ b/pkg/exchange/max/maxapi/v3/get_wallet_open_orders_request_requestgen.go @@ -72,7 +72,6 @@ func (g *GetWalletOpenOrdersRequest) GetParameters() (map[string]interface{}, er // assign parameter of timestamp // convert time.Time to milliseconds time stamp params["timestamp"] = strconv.FormatInt(timestamp.UnixNano()/int64(time.Millisecond), 10) - fmt.Println(params["timestamp"], timestamp) } else { } // check orderBy field -> json key order_by diff --git a/pkg/strategy/dca2/debug.go b/pkg/strategy/dca2/debug.go index 8e44bf916..cf474080c 100644 --- a/pkg/strategy/dca2/debug.go +++ b/pkg/strategy/dca2/debug.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/c9s/bbgo/pkg/types" + "github.com/sirupsen/logrus" ) func (s *Strategy) debugOrders(submitOrders []types.Order) { @@ -17,3 +18,15 @@ func (s *Strategy) debugOrders(submitOrders []types.Order) { s.logger.Info(sb.String()) } + +func debugRoundOrders(logger *logrus.Entry, roundName string, round Round) { + var sb strings.Builder + sb.WriteString("ROUND " + roundName + " [\n") + sb.WriteString(round.TakeProfitOrder.String() + "\n") + sb.WriteString("------------------------------------------------\n") + for i, order := range round.OpenPositionOrders { + sb.WriteString(fmt.Sprintf("%3d) ", i+1) + order.String() + "\n") + } + sb.WriteString("] END OF ROUND") + logger.Info(sb.String()) +} diff --git a/pkg/strategy/dca2/open_position_test.go b/pkg/strategy/dca2/open_position_test.go index 3d51bf6af..a9fd33cf0 100644 --- a/pkg/strategy/dca2/open_position_test.go +++ b/pkg/strategy/dca2/open_position_test.go @@ -33,13 +33,11 @@ func newTestStrategy(va ...string) *Strategy { market := newTestMarket() s := &Strategy{ - logger: logrus.NewEntry(logrus.New()), - Symbol: symbol, - Market: market, - Short: false, - TakeProfitRatio: Number("10%"), - openPositionSide: types.SideTypeBuy, - takeProfitSide: types.SideTypeSell, + logger: logrus.NewEntry(logrus.New()), + Symbol: symbol, + Market: market, + Short: false, + TakeProfitRatio: Number("10%"), } return s } diff --git a/pkg/strategy/dca2/recover.go b/pkg/strategy/dca2/recover.go new file mode 100644 index 000000000..40c66ee9f --- /dev/null +++ b/pkg/strategy/dca2/recover.go @@ -0,0 +1,268 @@ +package dca2 + +import ( + "context" + "fmt" + "sort" + "strconv" + "time" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/core" + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +type queryAPI interface { + QueryOpenOrders(ctx context.Context, symbol string) ([]types.Order, error) + QueryClosedOrdersDesc(ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64) ([]types.Order, error) + QueryOrderTrades(ctx context.Context, q types.OrderQuery) ([]types.Trade, error) +} + +func (s *Strategy) recover(ctx context.Context) error { + s.logger.Info("[DCA] recover") + queryService, ok := s.Session.Exchange.(queryAPI) + if !ok { + return fmt.Errorf("[DCA] exchange %s doesn't support queryAPI interface", s.Session.ExchangeName) + } + + openOrders, err := queryService.QueryOpenOrders(ctx, s.Symbol) + if err != nil { + return err + } + + closedOrders, err := queryService.QueryClosedOrdersDesc(ctx, s.Symbol, time.Time{}, time.Now(), 0) + if err != nil { + return err + } + + currentRound, err := getCurrentRoundOrders(s.Short, openOrders, closedOrders, s.OrderGroupID) + if err != nil { + return err + } + debugRoundOrders(s.logger, "current", currentRound) + + // recover state + state, err := recoverState(ctx, s.Symbol, s.Short, int(s.MaxOrderNum), openOrders, currentRound, s.OrderExecutor.ActiveMakerOrders(), s.OrderExecutor.OrderStore(), s.OrderGroupID) + if err != nil { + return err + } + + // recover position + if err := recoverPosition(ctx, s.Position, queryService, currentRound); err != nil { + return err + } + + // recover budget + budget := recoverBudget(currentRound) + + // recover startTimeOfNextRound + startTimeOfNextRound := recoverStartTimeOfNextRound(ctx, currentRound, s.CoolDownInterval) + + s.state = state + if !budget.IsZero() { + s.Budget = budget + } + s.startTimeOfNextRound = startTimeOfNextRound + + return nil +} + +// recover state +func recoverState(ctx context.Context, symbol string, short bool, maxOrderNum int, openOrders []types.Order, currentRound Round, activeOrderBook *bbgo.ActiveOrderBook, orderStore *core.OrderStore, groupID uint32) (State, error) { + numOpenOrders := len(openOrders) + // dca stop at take profit order stage + if currentRound.TakeProfitOrder.OrderID != 0 { + // check the open orders is take profit order or not + if numOpenOrders == 1 { + if openOrders[0].OrderID == currentRound.TakeProfitOrder.OrderID { + activeOrderBook.Add(openOrders[0]) + // current round's take-profit order still opened, wait to fill + return TakeProfitReady, nil + } else { + return None, fmt.Errorf("stop at taking profit stage, but the open order's OrderID is not the take-profit order's OrderID") + } + } + + if numOpenOrders == 0 { + // current round's take-profit order filled, wait to open next round + return WaitToOpenPosition, nil + } + + return None, fmt.Errorf("stop at taking profit stage, but the number of open orders is > 1") + } + + if len(currentRound.OpenPositionOrders) == 0 { + // new strategy + return WaitToOpenPosition, nil + } + + numOpenPositionOrders := len(currentRound.OpenPositionOrders) + if numOpenPositionOrders > maxOrderNum { + return None, fmt.Errorf("the number of open-position orders is > max order number") + } else if numOpenPositionOrders < maxOrderNum { + // failed to place some orders at open position stage + return None, fmt.Errorf("the number of open-position orders is < max order number") + } + + if numOpenOrders > numOpenPositionOrders { + return None, fmt.Errorf("the number of open orders is > the number of open-position orders") + } + + if numOpenOrders == numOpenPositionOrders { + activeOrderBook.Add(openOrders...) + orderStore.Add(openOrders...) + return OpenPositionReady, nil + } + + var openedCnt, filledCnt, cancelledCnt int64 + for _, order := range currentRound.OpenPositionOrders { + switch order.Status { + case types.OrderStatusNew, types.OrderStatusPartiallyFilled: + 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) + } + } + + if filledCnt > 0 && cancelledCnt == 0 { + activeOrderBook.Add(openOrders...) + orderStore.Add(openOrders...) + return OpenPositionOrderFilled, nil + } + + if openedCnt > 0 && filledCnt > 0 && cancelledCnt > 0 { + return OpenPositionOrdersCancelling, nil + } + + if openedCnt == 0 && filledCnt > 0 && cancelledCnt > 0 { + return OpenPositionOrdersCancelled, nil + } + + return None, fmt.Errorf("unexpected order status combination") +} + +func recoverPosition(ctx context.Context, position *types.Position, queryService queryAPI, currentRound Round) error { + if position == nil { + return nil + } + + var positionOrders []types.Order + position.Reset() + if currentRound.TakeProfitOrder.OrderID != 0 { + if !types.IsActiveOrder(currentRound.TakeProfitOrder) { + return nil + } + + positionOrders = append(positionOrders, currentRound.TakeProfitOrder) + } + + for _, order := range currentRound.OpenPositionOrders { + // no executed quantity order, no need to get trades + if order.ExecutedQuantity.IsZero() { + continue + } + + positionOrders = append(positionOrders, order) + } + + for _, positionOrder := range positionOrders { + trades, err := queryService.QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: position.Symbol, + OrderID: strconv.FormatUint(positionOrder.OrderID, 10), + }) + + if err != nil { + return fmt.Errorf("failed to get trades of order (%d)", positionOrder.OrderID) + } + + position.AddTrades(trades) + } + + return nil +} + +func recoverBudget(currentRound Round) fixedpoint.Value { + if len(currentRound.OpenPositionOrders) == 0 { + return fixedpoint.Zero + } + + total := fixedpoint.Zero + for _, order := range currentRound.OpenPositionOrders { + total = total.Add(order.Quantity.Mul(order.Price)) + } + + if currentRound.TakeProfitOrder.OrderID != 0 && currentRound.TakeProfitOrder.Status == types.OrderStatusFilled { + total = total.Add(currentRound.TakeProfitOrder.Quantity.Mul(currentRound.TakeProfitOrder.Price)) + for _, order := range currentRound.OpenPositionOrders { + total = total.Sub(order.ExecutedQuantity.Mul(order.Price)) + } + } + + return total +} + +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()) + } + + return time.Time{} +} + +type Round struct { + OpenPositionOrders []types.Order + TakeProfitOrder types.Order +} + +func getCurrentRoundOrders(short bool, openOrders, closedOrders []types.Order, groupID uint32) (Round, error) { + openPositionSide := types.SideTypeBuy + takeProfitSide := types.SideTypeSell + + if short { + openPositionSide = types.SideTypeSell + takeProfitSide = types.SideTypeBuy + } + + var allOrders []types.Order + allOrders = append(allOrders, openOrders...) + allOrders = append(allOrders, closedOrders...) + + sort.Slice(allOrders, func(i, j int) bool { + return allOrders[i].CreationTime.After(allOrders[j].CreationTime.Time()) + }) + + var currentRound Round + lastSide := takeProfitSide + for _, order := range allOrders { + // group id filter is used for debug when local running + /* + if order.GroupID != groupID { + continue + } + */ + + if order.Side == takeProfitSide && lastSide == openPositionSide { + break + } + + switch order.Side { + 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 + default: + } + + lastSide = order.Side + } + + return currentRound, nil +} diff --git a/pkg/strategy/dca2/recover_test.go b/pkg/strategy/dca2/recover_test.go new file mode 100644 index 000000000..98550083a --- /dev/null +++ b/pkg/strategy/dca2/recover_test.go @@ -0,0 +1,236 @@ +package dca2 + +import ( + "context" + "math/rand" + "testing" + "time" + + "github.com/c9s/bbgo/pkg/bbgo" + "github.com/c9s/bbgo/pkg/core" + "github.com/c9s/bbgo/pkg/types" + "github.com/stretchr/testify/assert" +) + +func generateOrder(side types.SideType, status types.OrderStatus, createdAt time.Time) types.Order { + return types.Order{ + OrderID: rand.Uint64(), + SubmitOrder: types.SubmitOrder{ + Side: side, + }, + Status: status, + CreationTime: types.Time(createdAt), + } + +} + +func Test_GetCurrenctAndLastRoundOrders(t *testing.T) { + assert := assert.New(t) + + t.Run("case 1", func(t *testing.T) { + now := time.Now() + openOrders := []types.Order{ + generateOrder(types.SideTypeSell, types.OrderStatusNew, now), + } + + closedOrders := []types.Order{ + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-2*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-3*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-4*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-5*time.Second)), + } + + currentRound, err := getCurrentRoundOrders(false, openOrders, closedOrders, 0) + + assert.NoError(err) + assert.NotEqual(0, currentRound.TakeProfitOrder.OrderID) + assert.Equal(5, len(currentRound.OpenPositionOrders)) + }) + + t.Run("case 2", func(t *testing.T) { + now := time.Now() + openOrders := []types.Order{ + generateOrder(types.SideTypeSell, types.OrderStatusNew, now), + } + + closedOrders := []types.Order{ + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-2*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-3*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-4*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-5*time.Second)), + generateOrder(types.SideTypeSell, types.OrderStatusFilled, now.Add(-6*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-7*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-8*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-9*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-10*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-11*time.Second)), + generateOrder(types.SideTypeSell, types.OrderStatusFilled, now.Add(-12*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-13*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-14*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-15*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-16*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-17*time.Second)), + } + + currentRound, err := getCurrentRoundOrders(false, openOrders, closedOrders, 0) + + assert.NoError(err) + assert.NotEqual(0, currentRound.TakeProfitOrder.OrderID) + assert.Equal(5, len(currentRound.OpenPositionOrders)) + }) +} + +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) { + assert := assert.New(t) + symbol := "BTCUSDT" + + t.Run("new strategy", func(t *testing.T) { + openOrders := []types.Order{} + currentRound := Round{} + activeOrderBook := bbgo.NewActiveOrderBook(symbol) + orderStore := core.NewOrderStore(symbol) + state, err := recoverState(context.Background(), symbol, false, 5, openOrders, currentRound, activeOrderBook, orderStore, 0) + assert.NoError(err) + assert.Equal(WaitToOpenPosition, state) + }) + + t.Run("at open position stage and no filled order", func(t *testing.T) { + now := time.Now() + openOrders := []types.Order{ + generateOrder(types.SideTypeBuy, types.OrderStatusPartiallyFilled, now.Add(-1*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-2*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-3*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-4*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-5*time.Second)), + } + currentRound := Round{ + OpenPositionOrders: openOrders, + } + orderStore := core.NewOrderStore(symbol) + activeOrderBook := bbgo.NewActiveOrderBook(symbol) + state, err := recoverState(context.Background(), symbol, false, 5, openOrders, currentRound, activeOrderBook, orderStore, 0) + assert.NoError(err) + assert.Equal(OpenPositionReady, state) + }) + + t.Run("at open position stage and there at least one filled order", func(t *testing.T) { + now := time.Now() + openOrders := []types.Order{ + generateOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-2*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-3*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-4*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-5*time.Second)), + } + currentRound := Round{ + OpenPositionOrders: []types.Order{ + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + openOrders[0], + openOrders[1], + openOrders[2], + openOrders[3], + }, + } + orderStore := core.NewOrderStore(symbol) + activeOrderBook := bbgo.NewActiveOrderBook(symbol) + state, err := recoverState(context.Background(), symbol, false, 5, openOrders, currentRound, activeOrderBook, orderStore, 0) + assert.NoError(err) + assert.Equal(OpenPositionOrderFilled, state) + }) + + t.Run("open position stage finish, but stop at cancelling", func(t *testing.T) { + now := time.Now() + openOrders := []types.Order{ + generateOrder(types.SideTypeBuy, types.OrderStatusNew, now.Add(-5*time.Second)), + } + currentRound := Round{ + OpenPositionOrders: []types.Order{ + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-2*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-3*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-4*time.Second)), + openOrders[0], + }, + } + orderStore := core.NewOrderStore(symbol) + activeOrderBook := bbgo.NewActiveOrderBook(symbol) + state, err := recoverState(context.Background(), symbol, false, 5, openOrders, currentRound, activeOrderBook, orderStore, 0) + assert.NoError(err) + assert.Equal(OpenPositionOrdersCancelling, state) + }) + + t.Run("open-position orders are cancelled", func(t *testing.T) { + now := time.Now() + openOrders := []types.Order{} + currentRound := Round{ + OpenPositionOrders: []types.Order{ + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-2*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-3*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-4*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-5*time.Second)), + }, + } + orderStore := core.NewOrderStore(symbol) + activeOrderBook := bbgo.NewActiveOrderBook(symbol) + state, err := recoverState(context.Background(), symbol, false, 5, openOrders, currentRound, activeOrderBook, orderStore, 0) + assert.NoError(err) + assert.Equal(OpenPositionOrdersCancelled, state) + }) + + t.Run("at take profit stage, and not filled yet", func(t *testing.T) { + now := time.Now() + openOrders := []types.Order{ + generateOrder(types.SideTypeSell, types.OrderStatusNew, now), + } + currentRound := Round{ + TakeProfitOrder: openOrders[0], + OpenPositionOrders: []types.Order{ + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-2*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-3*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-4*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-5*time.Second)), + }, + } + orderStore := core.NewOrderStore(symbol) + activeOrderBook := bbgo.NewActiveOrderBook(symbol) + state, err := recoverState(context.Background(), symbol, false, 5, openOrders, currentRound, activeOrderBook, orderStore, 0) + assert.NoError(err) + assert.Equal(TakeProfitReady, state) + }) + + t.Run("at take profit stage, take-profit order filled", func(t *testing.T) { + now := time.Now() + openOrders := []types.Order{} + currentRound := Round{ + TakeProfitOrder: generateOrder(types.SideTypeSell, types.OrderStatusFilled, now), + OpenPositionOrders: []types.Order{ + generateOrder(types.SideTypeBuy, types.OrderStatusFilled, now.Add(-1*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-2*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-3*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-4*time.Second)), + generateOrder(types.SideTypeBuy, types.OrderStatusCanceled, now.Add(-5*time.Second)), + }, + } + orderStore := core.NewOrderStore(symbol) + activeOrderBook := bbgo.NewActiveOrderBook(symbol) + state, err := recoverState(context.Background(), symbol, false, 5, openOrders, currentRound, activeOrderBook, orderStore, 0) + assert.NoError(err) + assert.Equal(WaitToOpenPosition, state) + }) +} diff --git a/pkg/strategy/dca2/state.go b/pkg/strategy/dca2/state.go new file mode 100644 index 000000000..7a787fcda --- /dev/null +++ b/pkg/strategy/dca2/state.go @@ -0,0 +1,163 @@ +package dca2 + +import ( + "context" + "time" +) + +type State int64 + +const ( + None State = iota + WaitToOpenPosition + PositionOpening + OpenPositionReady + OpenPositionOrderFilled + OpenPositionOrdersCancelling + OpenPositionOrdersCancelled + TakeProfitReady +) + +func (s *Strategy) initializeNextStateC() bool { + s.mu.Lock() + defer s.mu.Unlock() + + isInitialize := false + if s.nextStateC == nil { + s.logger.Info("[DCA] initializing next state channel") + s.nextStateC = make(chan State, 1) + } else { + s.logger.Info("[DCA] nextStateC is already initialized") + isInitialize = true + } + + return isInitialize +} + +// runState +// WaitToOpenPosition -> after startTimeOfNextRound, place dca orders -> +// PositionOpening +// OpenPositionReady -> any dca maker order filled -> +// OpenPositionOrderFilled -> price hit the take profit ration, start cancelling -> +// OpenPositionOrdersCancelled -> place the takeProfit order -> +// TakeProfitReady -> the takeProfit order filled -> +func (s *Strategy) runState(ctx context.Context) { + s.logger.Info("[DCA] runState") + for { + select { + case <-ctx.Done(): + s.logger.Info("[DCA] runState DONE") + return + case nextState := <-s.nextStateC: + s.logger.Infof("[DCA] currenct state: %d, next state: %d", s.state, nextState) + switch s.state { + case WaitToOpenPosition: + s.runWaitToOpenPositionState(ctx, nextState) + case PositionOpening: + s.runPositionOpening(ctx, nextState) + case OpenPositionReady: + s.runOpenPositionReady(ctx, nextState) + case OpenPositionOrderFilled: + s.runOpenPositionOrderFilled(ctx, nextState) + case OpenPositionOrdersCancelling: + s.runOpenPositionOrdersCancelling(ctx, nextState) + case OpenPositionOrdersCancelled: + s.runOpenPositionOrdersCancelled(ctx, nextState) + case TakeProfitReady: + s.runTakeProfitReady(ctx, nextState) + } + } + } +} + +func (s *Strategy) runWaitToOpenPositionState(_ context.Context, next State) { + if next != None { + return + } + + s.logger.Info("[WaitToOpenPosition] check startTimeOfNextRound") + if time.Now().Before(s.startTimeOfNextRound) { + return + } + + s.state = PositionOpening + s.logger.Info("[WaitToOpenPosition] move to PositionOpening") +} + +func (s *Strategy) runPositionOpening(ctx context.Context, next State) { + if next != None { + return + } + + s.logger.Info("[PositionOpening] start placing open-position orders") + if err := s.placeOpenPositionOrders(ctx); err != nil { + s.logger.WithError(err).Error("failed to place dca orders, please check it.") + return + } + s.state = OpenPositionReady + s.logger.Info("[PositionOpening] move to OpenPositionReady") +} + +func (s *Strategy) runOpenPositionReady(_ context.Context, next State) { + if next != OpenPositionOrderFilled { + return + } + s.state = OpenPositionOrderFilled + s.logger.Info("[OpenPositionReady] move to OpenPositionOrderFilled") +} + +func (s *Strategy) runOpenPositionOrderFilled(_ context.Context, next State) { + if next != OpenPositionOrdersCancelling { + return + } + s.state = OpenPositionOrdersCancelling + s.logger.Info("[OpenPositionOrderFilled] move to OpenPositionOrdersCancelling") +} + +func (s *Strategy) runOpenPositionOrdersCancelling(ctx context.Context, next State) { + if next != None { + return + } + + s.logger.Info("[OpenPositionOrdersCancelling] start cancelling open-position orders") + if err := s.cancelOpenPositionOrders(ctx); err != nil { + s.logger.WithError(err).Error("failed to cancel maker orders") + return + } + s.state = OpenPositionOrdersCancelled + s.logger.Info("[OpenPositionOrdersCancelling] move to OpenPositionOrdersCancelled") +} + +func (s *Strategy) runOpenPositionOrdersCancelled(ctx context.Context, next State) { + if next != None { + return + } + s.logger.Info("[OpenPositionOrdersCancelled] start placing take-profit orders") + if err := s.placeTakeProfitOrders(ctx); err != nil { + s.logger.WithError(err).Error("failed to open take profit orders") + return + } + s.state = TakeProfitReady + s.logger.Info("[OpenPositionOrdersCancelled] move to TakeProfitReady") +} + +func (s *Strategy) runTakeProfitReady(_ context.Context, next State) { + if next != WaitToOpenPosition { + return + } + + s.logger.Info("[TakeProfitReady] start reseting position and calculate budget for next round") + if s.Short { + s.Budget = s.Budget.Add(s.Position.Base) + } else { + s.Budget = s.Budget.Add(s.Position.Quote) + } + + // reset position + s.Position.Reset() + + // set the start time of the next round + s.startTimeOfNextRound = time.Now().Add(s.CoolDownInterval.Duration()) + s.state = WaitToOpenPosition + s.logger.Info("[TakeProfitReady] move to WaitToOpenPosition") +} diff --git a/pkg/strategy/dca2/strategy.go b/pkg/strategy/dca2/strategy.go index 0cf17fbf4..2ed8e8c1c 100644 --- a/pkg/strategy/dca2/strategy.go +++ b/pkg/strategy/dca2/strategy.go @@ -50,10 +50,10 @@ type Strategy struct { // private field mu sync.Mutex - openPositionSide types.SideType - takeProfitSide types.SideType takeProfitPrice fixedpoint.Value startTimeOfNextRound time.Time + nextStateC chan State + state State } func (s *Strategy) ID() string { @@ -105,33 +105,87 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo. s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) instanceID := s.InstanceID() - if s.Short { - s.openPositionSide = types.SideTypeSell - s.takeProfitSide = types.SideTypeBuy - } else { - s.openPositionSide = types.SideTypeBuy - s.takeProfitSide = types.SideTypeSell - } - if s.OrderGroupID == 0 { s.OrderGroupID = util.FNV32(instanceID) % math.MaxInt32 } + s.updateTakeProfitPrice() + // order executor s.OrderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) { - s.logger.Infof("position: %s", s.Position.String()) + s.logger.Infof("[DCA] POSITION UPDATE: %s", s.Position.String()) bbgo.Sync(ctx, s) // update take profit price here + s.updateTakeProfitPrice() + }) + + s.OrderExecutor.ActiveMakerOrders().OnFilled(func(o types.Order) { + s.logger.Infof("[DCA] FILLED ORDER: %s", o.String()) + openPositionSide := types.SideTypeBuy + takeProfitSide := types.SideTypeSell + if s.Short { + openPositionSide = types.SideTypeSell + takeProfitSide = types.SideTypeBuy + } + + switch o.Side { + case openPositionSide: + s.nextStateC <- OpenPositionOrderFilled + case takeProfitSide: + s.nextStateC <- WaitToOpenPosition + default: + s.logger.Infof("[DCA] unsupported side (%s) of order: %s", o.Side, o) + } }) session.MarketDataStream.OnKLine(func(kline types.KLine) { + s.logger.Infof("[DCA] %s", s.Strategy.Position.String()) + s.logger.Infof("[DCA] tkae-profit price: %s", s.takeProfitPrice) // check price here + // because we subscribe 1m kline, it will close every 1 min + // we use it as ticker to maker WaitToOpenPosition -> OpenPositionReady + select { + case s.nextStateC <- None: + default: + s.logger.Info("[DCA] nextStateC is full or not initialized") + } + + if s.state != OpenPositionOrderFilled { + return + } + + compRes := kline.Close.Compare(s.takeProfitPrice) + // price doesn't hit the take profit price + if (s.Short && compRes > 0) || (!s.Short && compRes < 0) { + return + } + + s.nextStateC <- OpenPositionOrdersCancelling }) session.UserDataStream.OnAuth(func() { - s.logger.Info("user data stream authenticated, start the process") - // decide state here + s.logger.Info("[DCA] user data stream authenticated") + time.AfterFunc(3*time.Second, func() { + if isInitialize := s.initializeNextStateC(); !isInitialize { + // recover + if err := s.recover(ctx); err != nil { + s.logger.WithError(err).Error("[DCA] something wrong when state recovering") + return + } + + s.logger.Infof("[DCA] recovered state: %d", s.state) + s.logger.Infof("[DCA] recovered position %s", s.Position.String()) + s.logger.Infof("[DCA] recovered budget %s", s.Budget) + s.logger.Infof("[DCA] recovered startTimeOfNextRound %s", s.startTimeOfNextRound) + + // store persistence + bbgo.Sync(ctx, s) + + // start running state machine + s.runState(ctx) + } + }) }) balances, err := session.Exchange.QueryAccountBalances(ctx) @@ -146,3 +200,12 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo. return nil } + +func (s *Strategy) updateTakeProfitPrice() { + takeProfitRatio := s.TakeProfitRatio + if s.Short { + takeProfitRatio = takeProfitRatio.Neg() + } + s.takeProfitPrice = s.Market.TruncatePrice(s.Position.AverageCost.Mul(fixedpoint.One.Add(takeProfitRatio))) + s.logger.Infof("[DCA] cost: %s, ratio: %s, price: %s", s.Position.AverageCost, takeProfitRatio, s.takeProfitPrice) +}