mirror of
https://github.com/c9s/bbgo.git
synced 2024-11-10 09:11:55 +00:00
FEATURE: run state machine
FEATURE: support recover FEATURE: add order into orderStore and recover position recover position/budget FEATURE: support recover budget
This commit is contained in:
parent
a4f996c963
commit
bfd9c8ac64
|
@ -23,8 +23,9 @@ exchangeStrategies:
|
||||||
dca2:
|
dca2:
|
||||||
symbol: ETHUSDT
|
symbol: ETHUSDT
|
||||||
short: false
|
short: false
|
||||||
budget: 5000
|
budget: 200
|
||||||
maxOrderNum: 10
|
maxOrderNum: 5
|
||||||
priceDeviation: 1%
|
priceDeviation: 1%
|
||||||
takeProfitRatio: 1%
|
takeProfitRatio: 0.2%
|
||||||
coolDownInterval: 5m
|
coolDownInterval: 3m
|
||||||
|
circuitBreakLossThreshold: -0.9
|
||||||
|
|
|
@ -72,7 +72,6 @@ func (g *GetWalletOpenOrdersRequest) GetParameters() (map[string]interface{}, er
|
||||||
// assign parameter of timestamp
|
// assign parameter of timestamp
|
||||||
// convert time.Time to milliseconds time stamp
|
// convert time.Time to milliseconds time stamp
|
||||||
params["timestamp"] = strconv.FormatInt(timestamp.UnixNano()/int64(time.Millisecond), 10)
|
params["timestamp"] = strconv.FormatInt(timestamp.UnixNano()/int64(time.Millisecond), 10)
|
||||||
fmt.Println(params["timestamp"], timestamp)
|
|
||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
// check orderBy field -> json key order_by
|
// check orderBy field -> json key order_by
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/c9s/bbgo/pkg/types"
|
"github.com/c9s/bbgo/pkg/types"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Strategy) debugOrders(submitOrders []types.Order) {
|
func (s *Strategy) debugOrders(submitOrders []types.Order) {
|
||||||
|
@ -17,3 +18,15 @@ func (s *Strategy) debugOrders(submitOrders []types.Order) {
|
||||||
|
|
||||||
s.logger.Info(sb.String())
|
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())
|
||||||
|
}
|
||||||
|
|
|
@ -33,13 +33,11 @@ func newTestStrategy(va ...string) *Strategy {
|
||||||
|
|
||||||
market := newTestMarket()
|
market := newTestMarket()
|
||||||
s := &Strategy{
|
s := &Strategy{
|
||||||
logger: logrus.NewEntry(logrus.New()),
|
logger: logrus.NewEntry(logrus.New()),
|
||||||
Symbol: symbol,
|
Symbol: symbol,
|
||||||
Market: market,
|
Market: market,
|
||||||
Short: false,
|
Short: false,
|
||||||
TakeProfitRatio: Number("10%"),
|
TakeProfitRatio: Number("10%"),
|
||||||
openPositionSide: types.SideTypeBuy,
|
|
||||||
takeProfitSide: types.SideTypeSell,
|
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
268
pkg/strategy/dca2/recover.go
Normal file
268
pkg/strategy/dca2/recover.go
Normal file
|
@ -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
|
||||||
|
}
|
236
pkg/strategy/dca2/recover_test.go
Normal file
236
pkg/strategy/dca2/recover_test.go
Normal file
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
163
pkg/strategy/dca2/state.go
Normal file
163
pkg/strategy/dca2/state.go
Normal file
|
@ -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")
|
||||||
|
}
|
|
@ -50,10 +50,10 @@ type Strategy struct {
|
||||||
|
|
||||||
// private field
|
// private field
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
openPositionSide types.SideType
|
|
||||||
takeProfitSide types.SideType
|
|
||||||
takeProfitPrice fixedpoint.Value
|
takeProfitPrice fixedpoint.Value
|
||||||
startTimeOfNextRound time.Time
|
startTimeOfNextRound time.Time
|
||||||
|
nextStateC chan State
|
||||||
|
state State
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Strategy) ID() string {
|
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())
|
s.Strategy.Initialize(ctx, s.Environment, session, s.Market, ID, s.InstanceID())
|
||||||
instanceID := 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 {
|
if s.OrderGroupID == 0 {
|
||||||
s.OrderGroupID = util.FNV32(instanceID) % math.MaxInt32
|
s.OrderGroupID = util.FNV32(instanceID) % math.MaxInt32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.updateTakeProfitPrice()
|
||||||
|
|
||||||
// order executor
|
// order executor
|
||||||
s.OrderExecutor.TradeCollector().OnPositionUpdate(func(position *types.Position) {
|
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)
|
bbgo.Sync(ctx, s)
|
||||||
|
|
||||||
// update take profit price here
|
// 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) {
|
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
|
// 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() {
|
session.UserDataStream.OnAuth(func() {
|
||||||
s.logger.Info("user data stream authenticated, start the process")
|
s.logger.Info("[DCA] user data stream authenticated")
|
||||||
// decide state here
|
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)
|
balances, err := session.Exchange.QueryAccountBalances(ctx)
|
||||||
|
@ -146,3 +200,12 @@ func (s *Strategy) Run(ctx context.Context, _ bbgo.OrderExecutor, session *bbgo.
|
||||||
|
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue
Block a user