diff --git a/config/dca2.yaml b/config/dca2.yaml index 6cb8b6ca9..e14c2c963 100644 --- a/config/dca2.yaml +++ b/config/dca2.yaml @@ -22,10 +22,10 @@ exchangeStrategies: - on: max dca2: symbol: ETHUSDT - short: false - budget: 200 - maxOrderNum: 5 - priceDeviation: 1% - takeProfitRatio: 0.2% - coolDownInterval: 3m - circuitBreakLossThreshold: -0.9 + quoteInvestment: "200" + maxOrderCount: 5 + priceDeviation: "0.01" + takeProfitRatio: "0.002" + coolDownInterval: 180 + recoverWhenStart: true + keepOrdersWhenShutdown: true diff --git a/pkg/strategy/common/callbacks.go b/pkg/strategy/common/callbacks.go new file mode 100644 index 000000000..3df6c875f --- /dev/null +++ b/pkg/strategy/common/callbacks.go @@ -0,0 +1,38 @@ +package common + +//go:generate callbackgen -type StatusCallbacks +type StatusCallbacks struct { + readyCallbacks []func() + closedCallbacks []func() + errorCallbacks []func(error) +} + +func (c *StatusCallbacks) OnReady(cb func()) { + c.readyCallbacks = append(c.readyCallbacks, cb) +} + +func (c *StatusCallbacks) EmitReady() { + for _, cb := range c.readyCallbacks { + cb() + } +} + +func (c *StatusCallbacks) OnClosed(cb func()) { + c.closedCallbacks = append(c.closedCallbacks, cb) +} + +func (c *StatusCallbacks) EmitClosed() { + for _, cb := range c.closedCallbacks { + cb() + } +} + +func (c *StatusCallbacks) OnError(cb func(err error)) { + c.errorCallbacks = append(c.errorCallbacks, cb) +} + +func (c *StatusCallbacks) EmitError(err error) { + for _, cb := range c.errorCallbacks { + cb(err) + } +} diff --git a/pkg/strategy/dca2/open_position.go b/pkg/strategy/dca2/open_position.go index 04e886e3d..617a06c44 100644 --- a/pkg/strategy/dca2/open_position.go +++ b/pkg/strategy/dca2/open_position.go @@ -20,7 +20,7 @@ func (s *Strategy) placeOpenPositionOrders(ctx context.Context) error { return err } - orders, err := generateOpenPositionOrders(s.Market, s.Budget, price, s.PriceDeviation, s.MaxOrderNum, s.OrderGroupID) + orders, err := generateOpenPositionOrders(s.Market, s.ProfitStats.QuoteInvestment, price, s.PriceDeviation, s.MaxOrderCount, s.OrderGroupID) if err != nil { return err } @@ -44,12 +44,12 @@ func getBestPriceUntilSuccess(ctx context.Context, ex types.Exchange, symbol str return ticker.Sell, nil } -func generateOpenPositionOrders(market types.Market, budget, price, priceDeviation fixedpoint.Value, maxOrderNum int64, orderGroupID uint32) ([]types.SubmitOrder, error) { +func generateOpenPositionOrders(market types.Market, quoteInvestment, price, priceDeviation fixedpoint.Value, maxOrderCount int64, orderGroupID uint32) ([]types.SubmitOrder, error) { factor := fixedpoint.One.Sub(priceDeviation) // calculate all valid prices var prices []fixedpoint.Value - for i := 0; i < int(maxOrderNum); i++ { + for i := 0; i < int(maxOrderCount); i++ { if i > 0 { price = price.Mul(factor) } @@ -61,9 +61,9 @@ func generateOpenPositionOrders(market types.Market, budget, price, priceDeviati prices = append(prices, price) } - notional, orderNum := calculateNotionalAndNum(market, budget, prices) + notional, orderNum := calculateNotionalAndNumOrders(market, quoteInvestment, prices) if orderNum == 0 { - return nil, fmt.Errorf("failed to calculate notional and num of open position orders, price: %s, budget: %s", price, budget) + return nil, fmt.Errorf("failed to calculate notional and num of open position orders, price: %s, quote investment: %s", price, quoteInvestment) } side := types.SideTypeBuy @@ -87,11 +87,11 @@ func generateOpenPositionOrders(market types.Market, budget, price, priceDeviati return submitOrders, nil } -// calculateNotionalAndNum calculates the notional and num of open position orders +// calculateNotionalAndNumOrders calculates the notional and num of open position orders // DCA2 is notional-based, every order has the same notional -func calculateNotionalAndNum(market types.Market, budget fixedpoint.Value, prices []fixedpoint.Value) (fixedpoint.Value, int) { +func calculateNotionalAndNumOrders(market types.Market, quoteInvestment fixedpoint.Value, prices []fixedpoint.Value) (fixedpoint.Value, int) { for num := len(prices); num > 0; num-- { - notional := budget.Div(fixedpoint.NewFromInt(int64(num))) + notional := quoteInvestment.Div(fixedpoint.NewFromInt(int64(num))) if notional.Compare(market.MinNotional) < 0 { continue } @@ -107,24 +107,3 @@ func calculateNotionalAndNum(market types.Market, budget fixedpoint.Value, price return fixedpoint.Zero, 0 } - -func (s *Strategy) cancelOpenPositionOrders(ctx context.Context) error { - s.logger.Info("[DCA] cancel open position orders") - e, ok := s.Session.Exchange.(cancelOrdersByGroupIDApi) - if ok { - cancelledOrders, err := e.CancelOrdersByGroupID(ctx, int64(s.OrderGroupID)) - if err != nil { - return err - } - - for _, cancelledOrder := range cancelledOrders { - s.logger.Info("CANCEL ", cancelledOrder.String()) - } - } else { - if err := s.OrderExecutor.ActiveMakerOrders().GracefulCancel(ctx, s.Session.Exchange); err != nil { - return err - } - } - - return nil -} diff --git a/pkg/strategy/dca2/open_position_test.go b/pkg/strategy/dca2/open_position_test.go index 4d68df510..28b00c0ea 100644 --- a/pkg/strategy/dca2/open_position_test.go +++ b/pkg/strategy/dca2/open_position_test.go @@ -47,10 +47,10 @@ func TestGenerateOpenPositionOrders(t *testing.T) { strategy := newTestStrategy() t.Run("case 1: all config is valid and we can place enough orders", func(t *testing.T) { - budget := Number("10500") + quoteInvestment := Number("10500") askPrice := Number("30000") margin := Number("0.05") - submitOrders, err := generateOpenPositionOrders(strategy.Market, budget, askPrice, margin, 4, strategy.OrderGroupID) + submitOrders, err := generateOpenPositionOrders(strategy.Market, quoteInvestment, askPrice, margin, 4, strategy.OrderGroupID) if !assert.NoError(err) { return } diff --git a/pkg/strategy/dca2/profit_stats.go b/pkg/strategy/dca2/profit_stats.go new file mode 100644 index 000000000..2bde24197 --- /dev/null +++ b/pkg/strategy/dca2/profit_stats.go @@ -0,0 +1,93 @@ +package dca2 + +import ( + "fmt" + "strings" + + "github.com/c9s/bbgo/pkg/fixedpoint" + "github.com/c9s/bbgo/pkg/types" +) + +type ProfitStats struct { + Symbol string `json:"symbol"` + Market types.Market `json:"market,omitempty"` + + FromOrderID uint64 `json:"fromOrderID,omitempty"` + Round int64 `json:"round,omitempty"` + QuoteInvestment fixedpoint.Value `json:"quoteInvestment,omitempty"` + + CurrentRoundProfit fixedpoint.Value `json:"currentRoundProfit,omitempty"` + CurrentRoundFee map[string]fixedpoint.Value `json:"currentRoundFee,omitempty"` + TotalProfit fixedpoint.Value `json:"totalProfit,omitempty"` + TotalFee map[string]fixedpoint.Value `json:"totalFee,omitempty"` + + types.PersistenceTTL +} + +func newProfitStats(market types.Market, quoteInvestment fixedpoint.Value) *ProfitStats { + return &ProfitStats{ + Symbol: market.Symbol, + Market: market, + Round: 0, + QuoteInvestment: quoteInvestment, + CurrentRoundFee: make(map[string]fixedpoint.Value), + TotalFee: make(map[string]fixedpoint.Value), + } +} + +func (s *ProfitStats) AddTrade(trade types.Trade) { + if s.CurrentRoundFee == nil { + s.CurrentRoundFee = make(map[string]fixedpoint.Value) + } + + if fee, ok := s.CurrentRoundFee[trade.FeeCurrency]; ok { + s.CurrentRoundFee[trade.FeeCurrency] = fee.Add(trade.Fee) + } else { + s.CurrentRoundFee[trade.FeeCurrency] = trade.Fee + } + + if s.TotalFee == nil { + s.TotalFee = make(map[string]fixedpoint.Value) + } + + if fee, ok := s.TotalFee[trade.FeeCurrency]; ok { + s.TotalFee[trade.FeeCurrency] = fee.Add(trade.Fee) + } else { + s.TotalFee[trade.FeeCurrency] = trade.Fee + } + + quoteQuantity := trade.QuoteQuantity + if trade.Side == types.SideTypeBuy { + quoteQuantity = quoteQuantity.Neg() + } + + s.CurrentRoundProfit = s.CurrentRoundProfit.Add(quoteQuantity) + s.TotalProfit = s.TotalProfit.Add(quoteQuantity) + + if s.Market.QuoteCurrency == trade.FeeCurrency { + s.CurrentRoundProfit.Sub(trade.Fee) + s.TotalProfit.Sub(trade.Fee) + } +} + +func (s *ProfitStats) NewRound() { + s.Round++ + s.CurrentRoundProfit = fixedpoint.Zero + s.CurrentRoundFee = make(map[string]fixedpoint.Value) +} + +func (s *ProfitStats) String() string { + var sb strings.Builder + sb.WriteString("[------------------ Profit Stats ------------------]\n") + sb.WriteString(fmt.Sprintf("Round: %d\n", s.Round)) + sb.WriteString(fmt.Sprintf("From Order ID: %d\n", s.FromOrderID)) + sb.WriteString(fmt.Sprintf("Quote Investment: %s\n", s.QuoteInvestment)) + sb.WriteString(fmt.Sprintf("Current Round Profit: %s\n", s.CurrentRoundProfit)) + sb.WriteString(fmt.Sprintf("Total Profit: %s\n", s.TotalProfit)) + for currency, fee := range s.CurrentRoundFee { + sb.WriteString(fmt.Sprintf("FEE (%s): %s\n", currency, fee)) + } + sb.WriteString("[------------------ Profit Stats ------------------]\n") + + return sb.String() +} diff --git a/pkg/strategy/dca2/recover.go b/pkg/strategy/dca2/recover.go index 6a09dd6c9..b96474b07 100644 --- a/pkg/strategy/dca2/recover.go +++ b/pkg/strategy/dca2/recover.go @@ -34,7 +34,7 @@ func (s *Strategy) recover(ctx context.Context) error { return err } - closedOrders, err := queryService.QueryClosedOrdersDesc(ctx, s.Symbol, time.Time{}, time.Now(), 0) + closedOrders, err := queryService.QueryClosedOrdersDesc(ctx, s.Symbol, time.Date(2024, time.January, 1, 0, 0, 0, 0, time.Local), time.Now(), 0) if err != nil { return err } @@ -46,7 +46,7 @@ func (s *Strategy) recover(ctx context.Context) error { debugRoundOrders(s.logger, "current", currentRound) // recover state - state, err := recoverState(ctx, s.Symbol, int(s.MaxOrderNum), openOrders, currentRound, s.OrderExecutor.ActiveMakerOrders(), s.OrderExecutor.OrderStore(), s.OrderGroupID) + state, err := recoverState(ctx, s.Symbol, int(s.MaxOrderCount), openOrders, currentRound, s.OrderExecutor.ActiveMakerOrders(), s.OrderExecutor.OrderStore(), s.OrderGroupID) if err != nil { return err } @@ -56,23 +56,20 @@ func (s *Strategy) recover(ctx context.Context) error { return err } - // recover budget - budget := recoverBudget(currentRound) + // recover profit stats + recoverProfitStats(ctx, s) // 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, maxOrderNum int, openOrders []types.Order, currentRound Round, activeOrderBook *bbgo.ActiveOrderBook, orderStore *core.OrderStore, groupID uint32) (State, error) { +func recoverState(ctx context.Context, symbol string, maxOrderCount int, openOrders []types.Order, currentRound Round, activeOrderBook *bbgo.ActiveOrderBook, orderStore *core.OrderStore, groupID uint32) (State, error) { if len(currentRound.OpenPositionOrders) == 0 { // new strategy return WaitToOpenPosition, nil @@ -101,10 +98,10 @@ func recoverState(ctx context.Context, symbol string, maxOrderNum int, openOrder } numOpenPositionOrders := len(currentRound.OpenPositionOrders) - if numOpenPositionOrders > maxOrderNum { + if numOpenPositionOrders > maxOrderCount { return None, fmt.Errorf("the number of open-position orders is > max order number") - } else if numOpenPositionOrders < maxOrderNum { - // The number of open-position orders should be the same as maxOrderNum + } else if numOpenPositionOrders < maxOrderCount { + // The number of open-position orders should be the same as maxOrderCount // 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..... @@ -154,7 +151,7 @@ func recoverState(ctx context.Context, symbol string, maxOrderNum int, openOrder func recoverPosition(ctx context.Context, position *types.Position, queryService RecoverApiQueryService, currentRound Round) error { if position == nil { - return nil + return fmt.Errorf("position is nil, please check it") } var positionOrders []types.Order @@ -192,7 +189,17 @@ func recoverPosition(ctx context.Context, position *types.Position, queryService return nil } -func recoverBudget(currentRound Round) fixedpoint.Value { +func recoverProfitStats(ctx context.Context, strategy *Strategy) error { + if strategy.ProfitStats == nil { + return fmt.Errorf("profit stats is nil, please check it") + } + + strategy.CalculateProfitOfCurrentRound(ctx) + + return nil +} + +func recoverQuoteInvestment(currentRound Round) fixedpoint.Value { if len(currentRound.OpenPositionOrders) == 0 { return fixedpoint.Zero } diff --git a/pkg/strategy/dca2/state.go b/pkg/strategy/dca2/state.go index 693607995..38190d2d2 100644 --- a/pkg/strategy/dca2/state.go +++ b/pkg/strategy/dca2/state.go @@ -3,6 +3,8 @@ package dca2 import ( "context" "time" + + "github.com/c9s/bbgo/pkg/bbgo" ) type State int64 @@ -121,12 +123,19 @@ func (s *Strategy) triggerNextState() { } } -func (s *Strategy) runWaitToOpenPositionState(_ context.Context, next State) { +func (s *Strategy) runWaitToOpenPositionState(ctx context.Context, next State) { s.logger.Info("[State] WaitToOpenPosition - check startTimeOfNextRound") if time.Now().Before(s.startTimeOfNextRound) { return } + // reset position and open new round for profit stats before position opening + s.Position.Reset() + s.ProfitStats.NewRound() + + // store into redis + bbgo.Sync(ctx, s) + s.state = PositionOpening s.logger.Info("[State] WaitToOpenPosition -> PositionOpening") } @@ -156,7 +165,7 @@ func (s *Strategy) runOpenPositionOrderFilled(_ context.Context, next State) { func (s *Strategy) runOpenPositionOrdersCancelling(ctx context.Context, next State) { s.logger.Info("[State] OpenPositionOrdersCancelling - start cancelling open-position orders") - if err := s.cancelOpenPositionOrders(ctx); err != nil { + if err := s.OrderExecutor.GracefulCancel(ctx); err != nil { s.logger.WithError(err).Error("failed to cancel maker orders") return } @@ -177,15 +186,17 @@ func (s *Strategy) runOpenPositionOrdersCancelled(ctx context.Context, next Stat s.logger.Info("[State] OpenPositionOrdersCancelled -> TakeProfitReady") } -func (s *Strategy) runTakeProfitReady(_ context.Context, next State) { +func (s *Strategy) runTakeProfitReady(ctx context.Context, next State) { // wait 3 seconds to avoid position not update time.Sleep(3 * time.Second) - s.logger.Info("[State] TakeProfitReady - start reseting position and calculate budget for next round") - s.Budget = s.Budget.Add(s.Position.Quote) + s.logger.Info("[State] TakeProfitReady - start reseting position and calculate quote investment for next round") - // reset position - s.Position.Reset() + // calculate profit stats + s.CalculateProfitOfCurrentRound(ctx) + bbgo.Sync(ctx, s) + + s.EmitProfit(s.ProfitStats) // set the start time of the next round s.startTimeOfNextRound = time.Now().Add(s.CoolDownInterval.Duration()) diff --git a/pkg/strategy/dca2/strategy.go b/pkg/strategy/dca2/strategy.go index d1eab7ffd..1717a1e5b 100644 --- a/pkg/strategy/dca2/strategy.go +++ b/pkg/strategy/dca2/strategy.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "math" + "strconv" "sync" "time" @@ -12,6 +13,7 @@ import ( "github.com/c9s/bbgo/pkg/strategy/common" "github.com/c9s/bbgo/pkg/types" "github.com/c9s/bbgo/pkg/util" + "github.com/prometheus/client_golang/prometheus" "github.com/sirupsen/logrus" ) @@ -25,17 +27,21 @@ func init() { bbgo.RegisterStrategy(ID, &Strategy{}) } +//go:generate callbackgen -type Strateg type Strategy struct { - *common.Strategy + Position *types.Position `json:"position,omitempty" persistence:"position"` + ProfitStats *ProfitStats `json:"profitStats,omitempty" persistence:"profit_stats"` - Environment *bbgo.Environment - Market types.Market + Environment *bbgo.Environment + Session *bbgo.ExchangeSession + OrderExecutor *bbgo.GeneralOrderExecutor + Market types.Market Symbol string `json:"symbol"` // setting - Budget fixedpoint.Value `json:"budget"` - MaxOrderNum int64 `json:"maxOrderNum"` + QuoteInvestment fixedpoint.Value `json:"quoteInvestment"` + MaxOrderCount int64 `json:"maxOrderCount"` PriceDeviation fixedpoint.Value `json:"priceDeviation"` TakeProfitRatio fixedpoint.Value `json:"takeProfitRatio"` CoolDownInterval types.Duration `json:"coolDownInterval"` @@ -43,16 +49,30 @@ type Strategy struct { // OrderGroupID is the group ID used for the strategy instance for canceling orders OrderGroupID uint32 `json:"orderGroupID"` + // RecoverWhenStart option is used for recovering dca states + RecoverWhenStart bool `json:"recoverWhenStart"` + + // KeepOrdersWhenShutdown option is used for keeping the grid orders when shutting down bbgo + KeepOrdersWhenShutdown bool `json:"keepOrdersWhenShutdown"` + // log logger *logrus.Entry LogFields logrus.Fields `json:"logFields"` + // PrometheusLabels will be used as the base prometheus labels + PrometheusLabels prometheus.Labels `json:"prometheusLabels"` + // private field mu sync.Mutex takeProfitPrice fixedpoint.Value startTimeOfNextRound time.Time nextStateC chan State state State + + // callbacks + common.StatusCallbacks + positionCallbacks []func(*types.Position) + profitCallbacks []func(*ProfitStats) } func (s *Strategy) ID() string { @@ -60,8 +80,8 @@ func (s *Strategy) ID() string { } func (s *Strategy) Validate() error { - if s.MaxOrderNum < 1 { - return fmt.Errorf("maxOrderNum can not be < 1") + if s.MaxOrderCount < 1 { + return fmt.Errorf("maxOrderCount can not be < 1") } if s.TakeProfitRatio.Sign() <= 0 { @@ -88,7 +108,6 @@ func (s *Strategy) Defaults() error { func (s *Strategy) Initialize() error { s.logger = log.WithFields(s.LogFields) - s.Strategy = &common.Strategy{} return nil } @@ -101,8 +120,29 @@ func (s *Strategy) Subscribe(session *bbgo.ExchangeSession) { } func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.ExchangeSession) error { - s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID()) instanceID := s.InstanceID() + s.Session = session + if s.ProfitStats == nil { + s.ProfitStats = newProfitStats(s.Market, s.QuoteInvestment) + } + + if s.Position == nil { + s.Position = types.NewPositionFromMarket(s.Market) + } + + s.Position.Strategy = ID + s.Position.StrategyInstanceID = instanceID + + if session.MakerFeeRate.Sign() > 0 || session.TakerFeeRate.Sign() > 0 { + s.Position.SetExchangeFeeRate(session.ExchangeName, types.ExchangeFee{ + MakerFeeRate: session.MakerFeeRate, + TakerFeeRate: session.TakerFeeRate, + }) + } + + s.OrderExecutor = bbgo.NewGeneralOrderExecutor(session, s.Symbol, ID, instanceID, s.Position) + s.OrderExecutor.BindEnvironment(s.Environment) + s.OrderExecutor.Bind() if s.OrderGroupID == 0 { s.OrderGroupID = util.FNV32(instanceID) % math.MaxInt32 @@ -151,22 +191,29 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo. 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 - } + if s.RecoverWhenStart { + // 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) + s.logger.Infof("[DCA] state: %d", s.state) + s.logger.Infof("[DCA] position %s", s.Position.String()) + s.logger.Infof("[DCA] profit stats %s", s.ProfitStats.String()) + s.logger.Infof("[DCA] startTimeOfNextRound %s", s.startTimeOfNextRound) + } else { + s.state = WaitToOpenPosition + } s.updateTakeProfitPrice() // store persistence bbgo.Sync(ctx, s) + // ready + s.EmitReady() + // start running state machine s.runState(ctx) } @@ -179,10 +226,23 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo. } balance := balances[s.Market.QuoteCurrency] - if balance.Available.Compare(s.Budget) < 0 { - return fmt.Errorf("the available balance of %s is %s which is less than budget setting %s, please check it", s.Market.QuoteCurrency, balance.Available, s.Budget) + if balance.Available.Compare(s.ProfitStats.QuoteInvestment) < 0 { + return fmt.Errorf("the available balance of %s is %s which is less than quote investment setting %s, please check it", s.Market.QuoteCurrency, balance.Available, s.ProfitStats.QuoteInvestment) } + bbgo.OnShutdown(ctx, func(ctx context.Context, wg *sync.WaitGroup) { + defer wg.Done() + + if s.KeepOrdersWhenShutdown { + s.logger.Infof("keepOrdersWhenShutdown is set, will keep the orders on the exchange") + return + } + + if err := s.Close(ctx); err != nil { + s.logger.WithError(err).Errorf("dca2 graceful order cancel error") + } + }) + return nil } @@ -191,3 +251,86 @@ func (s *Strategy) updateTakeProfitPrice() { 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) } + +func (s *Strategy) Close(ctx context.Context) error { + s.logger.Infof("[DCA] closing %s dca2", s.Symbol) + + defer s.EmitClosed() + + err := s.OrderExecutor.GracefulCancel(ctx) + if err != nil { + s.logger.WithError(err).Errorf("[DCA] there are errors when cancelling orders at close") + } + + bbgo.Sync(ctx, s) + return err +} + +func (s *Strategy) CleanUp(ctx context.Context) error { + _ = s.Initialize() + defer s.EmitClosed() + + err := s.OrderExecutor.GracefulCancel(ctx) + if err != nil { + s.logger.WithError(err).Errorf("[DCA] there are errors when cancelling orders at clean up") + } + + bbgo.Sync(ctx, s) + return err +} + +func (s *Strategy) CalculateProfitOfCurrentRound(ctx context.Context) error { + historyService, ok := s.Session.Exchange.(types.ExchangeTradeHistoryService) + if !ok { + return fmt.Errorf("exchange %s doesn't support ExchangeTradeHistoryService", s.Session.Exchange.Name()) + } + + queryService, ok := s.Session.Exchange.(types.ExchangeOrderQueryService) + if !ok { + return fmt.Errorf("exchange %s doesn't support ExchangeOrderQueryService", s.Session.Exchange.Name()) + } + + // query the orders of this round + orders, err := historyService.QueryClosedOrders(ctx, s.Symbol, time.Time{}, time.Time{}, s.ProfitStats.FromOrderID) + if err != nil { + return err + } + + // query the trades of this round + for _, order := range orders { + if order.OrderID > s.ProfitStats.FromOrderID { + s.ProfitStats.FromOrderID = order.OrderID + } + + // skip not this strategy order + if order.GroupID != s.OrderGroupID { + continue + } + + if order.ExecutedQuantity.Sign() == 0 { + // skip no trade orders + continue + } + + s.logger.Infof("[DCA] calculate profit stats from order: %s", order.String()) + + trades, err := queryService.QueryOrderTrades(ctx, types.OrderQuery{ + Symbol: order.Symbol, + OrderID: strconv.FormatUint(order.OrderID, 10), + }) + + if err != nil { + return err + } + + for _, trade := range trades { + s.logger.Infof("[DCA] calculate profit stats from trade: %s", trade.String()) + s.ProfitStats.AddTrade(trade) + } + } + + s.ProfitStats.FromOrderID = s.ProfitStats.FromOrderID + 1 + s.ProfitStats.QuoteInvestment = s.ProfitStats.QuoteInvestment.Add(s.ProfitStats.CurrentRoundProfit) + + return nil +} diff --git a/pkg/strategy/dca2/strategy_callbacks.go b/pkg/strategy/dca2/strategy_callbacks.go new file mode 100644 index 000000000..febebd52e --- /dev/null +++ b/pkg/strategy/dca2/strategy_callbacks.go @@ -0,0 +1,27 @@ +// Code generated by "callbackgen -type Strategy"; DO NOT EDIT. + +package dca2 + +import ( + "github.com/c9s/bbgo/pkg/types" +) + +func (s *Strategy) OnPosition(cb func(*types.Position)) { + s.positionCallbacks = append(s.positionCallbacks, cb) +} + +func (s *Strategy) EmitPosition(position *types.Position) { + for _, cb := range s.positionCallbacks { + cb(position) + } +} + +func (s *Strategy) OnProfit(cb func(*ProfitStats)) { + s.profitCallbacks = append(s.profitCallbacks, cb) +} + +func (s *Strategy) EmitProfit(profitStats *ProfitStats) { + for _, cb := range s.profitCallbacks { + cb(profitStats) + } +} diff --git a/pkg/types/persistence_ttl.go b/pkg/types/persistence_ttl.go new file mode 100644 index 000000000..1b056ca71 --- /dev/null +++ b/pkg/types/persistence_ttl.go @@ -0,0 +1,18 @@ +package types + +import "time" + +type PersistenceTTL struct { + ttl time.Duration +} + +func (p *PersistenceTTL) SetTTL(ttl time.Duration) { + if ttl.Nanoseconds() <= 0 { + return + } + p.ttl = ttl +} + +func (p *PersistenceTTL) Expiration() time.Duration { + return p.ttl +}