2023-11-27 07:55:02 +00:00
|
|
|
package dca2
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"fmt"
|
|
|
|
"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"
|
|
|
|
)
|
|
|
|
|
2023-12-27 03:41:29 +00:00
|
|
|
type descendingClosedOrderQueryService interface {
|
2023-11-27 07:55:02 +00:00
|
|
|
QueryClosedOrdersDesc(ctx context.Context, symbol string, since, until time.Time, lastOrderID uint64) ([]types.Order, error)
|
2023-12-27 03:41:29 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
type RecoverApiQueryService interface {
|
|
|
|
types.ExchangeOrderQueryService
|
|
|
|
types.ExchangeTradeService
|
|
|
|
descendingClosedOrderQueryService
|
2023-11-27 07:55:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func (s *Strategy) recover(ctx context.Context) error {
|
|
|
|
s.logger.Info("[DCA] recover")
|
2023-12-27 03:41:29 +00:00
|
|
|
queryService, ok := s.Session.Exchange.(RecoverApiQueryService)
|
2023-11-27 07:55:02 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-01-02 09:16:47 +00:00
|
|
|
closedOrders, err := queryService.QueryClosedOrdersDesc(ctx, s.Symbol, time.Date(2024, time.January, 1, 0, 0, 0, 0, time.Local), time.Now(), 0)
|
2023-11-27 07:55:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2023-12-22 07:50:48 +00:00
|
|
|
currentRound, err := getCurrentRoundOrders(openOrders, closedOrders, s.OrderGroupID)
|
2023-11-27 07:55:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
debugRoundOrders(s.logger, "current", currentRound)
|
|
|
|
|
|
|
|
// recover state
|
2024-01-02 09:16:47 +00:00
|
|
|
state, err := recoverState(ctx, s.Symbol, int(s.MaxOrderCount), openOrders, currentRound, s.OrderExecutor.ActiveMakerOrders(), s.OrderExecutor.OrderStore(), s.OrderGroupID)
|
2023-11-27 07:55:02 +00:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// recover position
|
|
|
|
if err := recoverPosition(ctx, s.Position, queryService, currentRound); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
2024-01-08 10:24:11 +00:00
|
|
|
// recover profit stats
|
2024-01-09 08:01:10 +00:00
|
|
|
recoverProfitStats(ctx, s)
|
2023-11-27 07:55:02 +00:00
|
|
|
|
|
|
|
// recover startTimeOfNextRound
|
|
|
|
startTimeOfNextRound := recoverStartTimeOfNextRound(ctx, currentRound, s.CoolDownInterval)
|
|
|
|
|
|
|
|
s.state = state
|
|
|
|
s.startTimeOfNextRound = startTimeOfNextRound
|
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// recover state
|
2024-01-02 09:16:47 +00:00
|
|
|
func recoverState(ctx context.Context, symbol string, maxOrderCount int, openOrders []types.Order, currentRound Round, activeOrderBook *bbgo.ActiveOrderBook, orderStore *core.OrderStore, groupID uint32) (State, error) {
|
2023-12-27 03:41:29 +00:00
|
|
|
if len(currentRound.OpenPositionOrders) == 0 {
|
|
|
|
// new strategy
|
|
|
|
return WaitToOpenPosition, nil
|
|
|
|
}
|
|
|
|
|
2023-11-27 07:55:02 +00:00
|
|
|
numOpenOrders := len(openOrders)
|
|
|
|
// dca stop at take profit order stage
|
|
|
|
if currentRound.TakeProfitOrder.OrderID != 0 {
|
2023-12-27 03:41:29 +00:00
|
|
|
if numOpenOrders == 0 {
|
|
|
|
// current round's take-profit order filled, wait to open next round
|
|
|
|
return WaitToOpenPosition, nil
|
|
|
|
}
|
|
|
|
|
2023-11-27 07:55:02 +00:00
|
|
|
// 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")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return None, fmt.Errorf("stop at taking profit stage, but the number of open orders is > 1")
|
|
|
|
}
|
|
|
|
|
|
|
|
numOpenPositionOrders := len(currentRound.OpenPositionOrders)
|
2024-01-02 09:16:47 +00:00
|
|
|
if numOpenPositionOrders > maxOrderCount {
|
2023-11-27 07:55:02 +00:00
|
|
|
return None, fmt.Errorf("the number of open-position orders is > max order number")
|
2024-01-02 09:16:47 +00:00
|
|
|
} else if numOpenPositionOrders < maxOrderCount {
|
|
|
|
// The number of open-position orders should be the same as maxOrderCount
|
2023-12-27 03:41:29 +00:00
|
|
|
// If not, it may be the following possible cause
|
|
|
|
// 1. This strategy at position opening, so it may not place all orders we want successfully
|
|
|
|
// 2. There are some errors when placing open-position orders. e.g. cannot lock fund.....
|
2023-11-27 07:55:02 +00:00
|
|
|
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")
|
|
|
|
}
|
|
|
|
|
2023-12-27 03:41:29 +00:00
|
|
|
func recoverPosition(ctx context.Context, position *types.Position, queryService RecoverApiQueryService, currentRound Round) error {
|
2023-11-27 07:55:02 +00:00
|
|
|
if position == nil {
|
2024-01-08 10:24:11 +00:00
|
|
|
return fmt.Errorf("position is nil, please check it")
|
2023-11-27 07:55:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2024-01-09 08:01:10 +00:00
|
|
|
func recoverProfitStats(ctx context.Context, strategy *Strategy) error {
|
|
|
|
if strategy.ProfitStats == nil {
|
2024-01-08 10:24:11 +00:00
|
|
|
return fmt.Errorf("profit stats is nil, please check it")
|
|
|
|
}
|
|
|
|
|
2024-01-09 08:01:10 +00:00
|
|
|
strategy.CalculateProfitOfCurrentRound(ctx)
|
2024-01-08 10:24:11 +00:00
|
|
|
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2024-01-02 09:16:47 +00:00
|
|
|
func recoverQuoteInvestment(currentRound Round) fixedpoint.Value {
|
2023-11-27 07:55:02 +00:00
|
|
|
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
|
|
|
|
}
|
|
|
|
|
2023-12-22 07:50:48 +00:00
|
|
|
func getCurrentRoundOrders(openOrders, closedOrders []types.Order, groupID uint32) (Round, error) {
|
2023-11-27 07:55:02 +00:00
|
|
|
openPositionSide := types.SideTypeBuy
|
|
|
|
takeProfitSide := types.SideTypeSell
|
|
|
|
|
|
|
|
var allOrders []types.Order
|
|
|
|
allOrders = append(allOrders, openOrders...)
|
|
|
|
allOrders = append(allOrders, closedOrders...)
|
|
|
|
|
2023-12-27 03:41:29 +00:00
|
|
|
types.SortOrdersDescending(allOrders)
|
2023-11-27 07:55:02 +00:00
|
|
|
|
|
|
|
var currentRound Round
|
|
|
|
lastSide := takeProfitSide
|
|
|
|
for _, order := range allOrders {
|
|
|
|
// group id filter is used for debug when local running
|
2023-12-22 07:27:31 +00:00
|
|
|
if order.GroupID != groupID {
|
|
|
|
continue
|
|
|
|
}
|
2023-11-27 07:55:02 +00:00
|
|
|
|
|
|
|
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
|
|
|
|
}
|