mirror of
https://github.com/c9s/bbgo.git
synced 2024-11-22 14:55:16 +00:00
Merge pull request #1474 from c9s/kbearXD/dca2/callbacks-and-close
FEATURE: [dca2] add callbacks and shutdown function
This commit is contained in:
commit
4a0c9ca032
|
@ -22,10 +22,10 @@ exchangeStrategies:
|
||||||
- on: max
|
- on: max
|
||||||
dca2:
|
dca2:
|
||||||
symbol: ETHUSDT
|
symbol: ETHUSDT
|
||||||
short: false
|
quoteInvestment: "200"
|
||||||
budget: 200
|
maxOrderCount: 5
|
||||||
maxOrderNum: 5
|
priceDeviation: "0.01"
|
||||||
priceDeviation: 1%
|
takeProfitRatio: "0.002"
|
||||||
takeProfitRatio: 0.2%
|
coolDownInterval: 180
|
||||||
coolDownInterval: 3m
|
recoverWhenStart: true
|
||||||
circuitBreakLossThreshold: -0.9
|
keepOrdersWhenShutdown: true
|
||||||
|
|
38
pkg/strategy/common/callbacks.go
Normal file
38
pkg/strategy/common/callbacks.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -20,7 +20,7 @@ func (s *Strategy) placeOpenPositionOrders(ctx context.Context) error {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -44,12 +44,12 @@ func getBestPriceUntilSuccess(ctx context.Context, ex types.Exchange, symbol str
|
||||||
return ticker.Sell, nil
|
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)
|
factor := fixedpoint.One.Sub(priceDeviation)
|
||||||
|
|
||||||
// calculate all valid prices
|
// calculate all valid prices
|
||||||
var prices []fixedpoint.Value
|
var prices []fixedpoint.Value
|
||||||
for i := 0; i < int(maxOrderNum); i++ {
|
for i := 0; i < int(maxOrderCount); i++ {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
price = price.Mul(factor)
|
price = price.Mul(factor)
|
||||||
}
|
}
|
||||||
|
@ -61,9 +61,9 @@ func generateOpenPositionOrders(market types.Market, budget, price, priceDeviati
|
||||||
prices = append(prices, price)
|
prices = append(prices, price)
|
||||||
}
|
}
|
||||||
|
|
||||||
notional, orderNum := calculateNotionalAndNum(market, budget, prices)
|
notional, orderNum := calculateNotionalAndNumOrders(market, quoteInvestment, prices)
|
||||||
if orderNum == 0 {
|
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
|
side := types.SideTypeBuy
|
||||||
|
@ -87,11 +87,11 @@ func generateOpenPositionOrders(market types.Market, budget, price, priceDeviati
|
||||||
return submitOrders, nil
|
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
|
// 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-- {
|
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 {
|
if notional.Compare(market.MinNotional) < 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -107,24 +107,3 @@ func calculateNotionalAndNum(market types.Market, budget fixedpoint.Value, price
|
||||||
|
|
||||||
return fixedpoint.Zero, 0
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -47,10 +47,10 @@ func TestGenerateOpenPositionOrders(t *testing.T) {
|
||||||
strategy := newTestStrategy()
|
strategy := newTestStrategy()
|
||||||
|
|
||||||
t.Run("case 1: all config is valid and we can place enough orders", func(t *testing.T) {
|
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")
|
askPrice := Number("30000")
|
||||||
margin := Number("0.05")
|
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) {
|
if !assert.NoError(err) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
93
pkg/strategy/dca2/profit_stats.go
Normal file
93
pkg/strategy/dca2/profit_stats.go
Normal file
|
@ -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()
|
||||||
|
}
|
|
@ -34,7 +34,7 @@ func (s *Strategy) recover(ctx context.Context) error {
|
||||||
return err
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,7 @@ func (s *Strategy) recover(ctx context.Context) error {
|
||||||
debugRoundOrders(s.logger, "current", currentRound)
|
debugRoundOrders(s.logger, "current", currentRound)
|
||||||
|
|
||||||
// recover state
|
// 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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -56,23 +56,20 @@ func (s *Strategy) recover(ctx context.Context) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// recover budget
|
// recover profit stats
|
||||||
budget := recoverBudget(currentRound)
|
recoverProfitStats(ctx, s)
|
||||||
|
|
||||||
// recover startTimeOfNextRound
|
// recover startTimeOfNextRound
|
||||||
startTimeOfNextRound := recoverStartTimeOfNextRound(ctx, currentRound, s.CoolDownInterval)
|
startTimeOfNextRound := recoverStartTimeOfNextRound(ctx, currentRound, s.CoolDownInterval)
|
||||||
|
|
||||||
s.state = state
|
s.state = state
|
||||||
if !budget.IsZero() {
|
|
||||||
s.Budget = budget
|
|
||||||
}
|
|
||||||
s.startTimeOfNextRound = startTimeOfNextRound
|
s.startTimeOfNextRound = startTimeOfNextRound
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// recover state
|
// 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 {
|
if len(currentRound.OpenPositionOrders) == 0 {
|
||||||
// new strategy
|
// new strategy
|
||||||
return WaitToOpenPosition, nil
|
return WaitToOpenPosition, nil
|
||||||
|
@ -101,10 +98,10 @@ func recoverState(ctx context.Context, symbol string, maxOrderNum int, openOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
numOpenPositionOrders := len(currentRound.OpenPositionOrders)
|
numOpenPositionOrders := len(currentRound.OpenPositionOrders)
|
||||||
if numOpenPositionOrders > maxOrderNum {
|
if numOpenPositionOrders > maxOrderCount {
|
||||||
return None, fmt.Errorf("the number of open-position orders is > max order number")
|
return None, fmt.Errorf("the number of open-position orders is > max order number")
|
||||||
} else if numOpenPositionOrders < maxOrderNum {
|
} else if numOpenPositionOrders < maxOrderCount {
|
||||||
// The number of open-position orders should be the same as maxOrderNum
|
// The number of open-position orders should be the same as maxOrderCount
|
||||||
// If not, it may be the following possible cause
|
// 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
|
// 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.....
|
// 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 {
|
func recoverPosition(ctx context.Context, position *types.Position, queryService RecoverApiQueryService, currentRound Round) error {
|
||||||
if position == nil {
|
if position == nil {
|
||||||
return nil
|
return fmt.Errorf("position is nil, please check it")
|
||||||
}
|
}
|
||||||
|
|
||||||
var positionOrders []types.Order
|
var positionOrders []types.Order
|
||||||
|
@ -192,7 +189,17 @@ func recoverPosition(ctx context.Context, position *types.Position, queryService
|
||||||
return nil
|
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 {
|
if len(currentRound.OpenPositionOrders) == 0 {
|
||||||
return fixedpoint.Zero
|
return fixedpoint.Zero
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,8 @@ package dca2
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/c9s/bbgo/pkg/bbgo"
|
||||||
)
|
)
|
||||||
|
|
||||||
type State int64
|
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")
|
s.logger.Info("[State] WaitToOpenPosition - check startTimeOfNextRound")
|
||||||
if time.Now().Before(s.startTimeOfNextRound) {
|
if time.Now().Before(s.startTimeOfNextRound) {
|
||||||
return
|
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.state = PositionOpening
|
||||||
s.logger.Info("[State] WaitToOpenPosition -> 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) {
|
func (s *Strategy) runOpenPositionOrdersCancelling(ctx context.Context, next State) {
|
||||||
s.logger.Info("[State] OpenPositionOrdersCancelling - start cancelling open-position orders")
|
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")
|
s.logger.WithError(err).Error("failed to cancel maker orders")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -177,15 +186,17 @@ func (s *Strategy) runOpenPositionOrdersCancelled(ctx context.Context, next Stat
|
||||||
s.logger.Info("[State] OpenPositionOrdersCancelled -> TakeProfitReady")
|
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
|
// wait 3 seconds to avoid position not update
|
||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
|
|
||||||
s.logger.Info("[State] TakeProfitReady - start reseting position and calculate budget for next round")
|
s.logger.Info("[State] TakeProfitReady - start reseting position and calculate quote investment for next round")
|
||||||
s.Budget = s.Budget.Add(s.Position.Quote)
|
|
||||||
|
|
||||||
// reset position
|
// calculate profit stats
|
||||||
s.Position.Reset()
|
s.CalculateProfitOfCurrentRound(ctx)
|
||||||
|
bbgo.Sync(ctx, s)
|
||||||
|
|
||||||
|
s.EmitProfit(s.ProfitStats)
|
||||||
|
|
||||||
// set the start time of the next round
|
// set the start time of the next round
|
||||||
s.startTimeOfNextRound = time.Now().Add(s.CoolDownInterval.Duration())
|
s.startTimeOfNextRound = time.Now().Add(s.CoolDownInterval.Duration())
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ import (
|
||||||
"github.com/c9s/bbgo/pkg/strategy/common"
|
"github.com/c9s/bbgo/pkg/strategy/common"
|
||||||
"github.com/c9s/bbgo/pkg/types"
|
"github.com/c9s/bbgo/pkg/types"
|
||||||
"github.com/c9s/bbgo/pkg/util"
|
"github.com/c9s/bbgo/pkg/util"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -25,17 +27,21 @@ func init() {
|
||||||
bbgo.RegisterStrategy(ID, &Strategy{})
|
bbgo.RegisterStrategy(ID, &Strategy{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//go:generate callbackgen -type Strateg
|
||||||
type Strategy struct {
|
type Strategy struct {
|
||||||
*common.Strategy
|
Position *types.Position `json:"position,omitempty" persistence:"position"`
|
||||||
|
ProfitStats *ProfitStats `json:"profitStats,omitempty" persistence:"profit_stats"`
|
||||||
|
|
||||||
Environment *bbgo.Environment
|
Environment *bbgo.Environment
|
||||||
|
Session *bbgo.ExchangeSession
|
||||||
|
OrderExecutor *bbgo.GeneralOrderExecutor
|
||||||
Market types.Market
|
Market types.Market
|
||||||
|
|
||||||
Symbol string `json:"symbol"`
|
Symbol string `json:"symbol"`
|
||||||
|
|
||||||
// setting
|
// setting
|
||||||
Budget fixedpoint.Value `json:"budget"`
|
QuoteInvestment fixedpoint.Value `json:"quoteInvestment"`
|
||||||
MaxOrderNum int64 `json:"maxOrderNum"`
|
MaxOrderCount int64 `json:"maxOrderCount"`
|
||||||
PriceDeviation fixedpoint.Value `json:"priceDeviation"`
|
PriceDeviation fixedpoint.Value `json:"priceDeviation"`
|
||||||
TakeProfitRatio fixedpoint.Value `json:"takeProfitRatio"`
|
TakeProfitRatio fixedpoint.Value `json:"takeProfitRatio"`
|
||||||
CoolDownInterval types.Duration `json:"coolDownInterval"`
|
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 is the group ID used for the strategy instance for canceling orders
|
||||||
OrderGroupID uint32 `json:"orderGroupID"`
|
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
|
// log
|
||||||
logger *logrus.Entry
|
logger *logrus.Entry
|
||||||
LogFields logrus.Fields `json:"logFields"`
|
LogFields logrus.Fields `json:"logFields"`
|
||||||
|
|
||||||
|
// PrometheusLabels will be used as the base prometheus labels
|
||||||
|
PrometheusLabels prometheus.Labels `json:"prometheusLabels"`
|
||||||
|
|
||||||
// private field
|
// private field
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
takeProfitPrice fixedpoint.Value
|
takeProfitPrice fixedpoint.Value
|
||||||
startTimeOfNextRound time.Time
|
startTimeOfNextRound time.Time
|
||||||
nextStateC chan State
|
nextStateC chan State
|
||||||
state State
|
state State
|
||||||
|
|
||||||
|
// callbacks
|
||||||
|
common.StatusCallbacks
|
||||||
|
positionCallbacks []func(*types.Position)
|
||||||
|
profitCallbacks []func(*ProfitStats)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Strategy) ID() string {
|
func (s *Strategy) ID() string {
|
||||||
|
@ -60,8 +80,8 @@ func (s *Strategy) ID() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Strategy) Validate() error {
|
func (s *Strategy) Validate() error {
|
||||||
if s.MaxOrderNum < 1 {
|
if s.MaxOrderCount < 1 {
|
||||||
return fmt.Errorf("maxOrderNum can not be < 1")
|
return fmt.Errorf("maxOrderCount can not be < 1")
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.TakeProfitRatio.Sign() <= 0 {
|
if s.TakeProfitRatio.Sign() <= 0 {
|
||||||
|
@ -88,7 +108,6 @@ func (s *Strategy) Defaults() error {
|
||||||
|
|
||||||
func (s *Strategy) Initialize() error {
|
func (s *Strategy) Initialize() error {
|
||||||
s.logger = log.WithFields(s.LogFields)
|
s.logger = log.WithFields(s.LogFields)
|
||||||
s.Strategy = &common.Strategy{}
|
|
||||||
return nil
|
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 {
|
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()
|
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 {
|
if s.OrderGroupID == 0 {
|
||||||
s.OrderGroupID = util.FNV32(instanceID) % math.MaxInt32
|
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")
|
s.logger.Info("[DCA] user data stream authenticated")
|
||||||
time.AfterFunc(3*time.Second, func() {
|
time.AfterFunc(3*time.Second, func() {
|
||||||
if isInitialize := s.initializeNextStateC(); !isInitialize {
|
if isInitialize := s.initializeNextStateC(); !isInitialize {
|
||||||
|
if s.RecoverWhenStart {
|
||||||
// recover
|
// recover
|
||||||
if err := s.recover(ctx); err != nil {
|
if err := s.recover(ctx); err != nil {
|
||||||
s.logger.WithError(err).Error("[DCA] something wrong when state recovering")
|
s.logger.WithError(err).Error("[DCA] something wrong when state recovering")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Infof("[DCA] recovered state: %d", s.state)
|
s.logger.Infof("[DCA] state: %d", s.state)
|
||||||
s.logger.Infof("[DCA] recovered position %s", s.Position.String())
|
s.logger.Infof("[DCA] position %s", s.Position.String())
|
||||||
s.logger.Infof("[DCA] recovered budget %s", s.Budget)
|
s.logger.Infof("[DCA] profit stats %s", s.ProfitStats.String())
|
||||||
s.logger.Infof("[DCA] recovered startTimeOfNextRound %s", s.startTimeOfNextRound)
|
s.logger.Infof("[DCA] startTimeOfNextRound %s", s.startTimeOfNextRound)
|
||||||
|
} else {
|
||||||
|
s.state = WaitToOpenPosition
|
||||||
|
}
|
||||||
|
|
||||||
s.updateTakeProfitPrice()
|
s.updateTakeProfitPrice()
|
||||||
|
|
||||||
// store persistence
|
// store persistence
|
||||||
bbgo.Sync(ctx, s)
|
bbgo.Sync(ctx, s)
|
||||||
|
|
||||||
|
// ready
|
||||||
|
s.EmitReady()
|
||||||
|
|
||||||
// start running state machine
|
// start running state machine
|
||||||
s.runState(ctx)
|
s.runState(ctx)
|
||||||
}
|
}
|
||||||
|
@ -179,10 +226,23 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.
|
||||||
}
|
}
|
||||||
|
|
||||||
balance := balances[s.Market.QuoteCurrency]
|
balance := balances[s.Market.QuoteCurrency]
|
||||||
if balance.Available.Compare(s.Budget) < 0 {
|
if balance.Available.Compare(s.ProfitStats.QuoteInvestment) < 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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -191,3 +251,86 @@ func (s *Strategy) updateTakeProfitPrice() {
|
||||||
s.takeProfitPrice = s.Market.TruncatePrice(s.Position.AverageCost.Mul(fixedpoint.One.Add(takeProfitRatio)))
|
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)
|
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
|
||||||
|
}
|
||||||
|
|
27
pkg/strategy/dca2/strategy_callbacks.go
Normal file
27
pkg/strategy/dca2/strategy_callbacks.go
Normal file
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
18
pkg/types/persistence_ttl.go
Normal file
18
pkg/types/persistence_ttl.go
Normal file
|
@ -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
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user