twap: add v2 fixed quantity executor

This commit is contained in:
c9s 2024-08-17 18:00:49 +08:00
parent 0a83c26fd5
commit 51c1b995c2
No known key found for this signature in database
GPG Key ID: 7385E7E464CB0A54
6 changed files with 314 additions and 11 deletions

View File

@ -84,6 +84,7 @@ func NewGeneralOrderExecutor(
executor := &GeneralOrderExecutor{
BaseOrderExecutor: BaseOrderExecutor{
session: session,
exchange: session.Exchange,
activeMakerOrders: NewActiveOrderBook(symbol),
orderStore: orderStore,
},

View File

@ -22,6 +22,7 @@ func NewSimpleOrderExecutor(session *ExchangeSession) *SimpleOrderExecutor {
return &SimpleOrderExecutor{
BaseOrderExecutor: BaseOrderExecutor{
session: session,
exchange: session.Exchange,
activeMakerOrders: NewActiveOrderBook(""),
orderStore: core.NewOrderStore(""),
},

View File

@ -6,9 +6,10 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/c9s/bbgo/pkg/bbgo"
"github.com/c9s/bbgo/pkg/types"
"github.com/stretchr/testify/assert"
)
func generateTestOrder(side types.SideType, status types.OrderStatus, createdAt time.Time) types.Order {
@ -29,7 +30,7 @@ func Test_RecoverState(t *testing.T) {
t.Run("new strategy", func(t *testing.T) {
currentRound := Round{}
position := types.NewPositionFromMarket(strategy.Market)
orderExecutor := bbgo.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position)
orderExecutor := bbgo.NewGeneralOrderExecutor(&bbgo.ExchangeSession{}, strategy.Symbol, ID, "", position)
state, err := recoverState(context.Background(), 5, currentRound, orderExecutor)
assert.NoError(t, err)
assert.Equal(t, WaitToOpenPosition, state)
@ -47,7 +48,7 @@ func Test_RecoverState(t *testing.T) {
},
}
position := types.NewPositionFromMarket(strategy.Market)
orderExecutor := bbgo.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position)
orderExecutor := bbgo.NewGeneralOrderExecutor(&bbgo.ExchangeSession{}, strategy.Symbol, ID, "", position)
state, err := recoverState(context.Background(), 5, currentRound, orderExecutor)
assert.NoError(t, err)
assert.Equal(t, OpenPositionReady, state)
@ -65,7 +66,7 @@ func Test_RecoverState(t *testing.T) {
},
}
position := types.NewPositionFromMarket(strategy.Market)
orderExecutor := bbgo.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position)
orderExecutor := bbgo.NewGeneralOrderExecutor(&bbgo.ExchangeSession{}, strategy.Symbol, ID, "", position)
state, err := recoverState(context.Background(), 5, currentRound, orderExecutor)
assert.NoError(t, err)
assert.Equal(t, OpenPositionOrderFilled, state)
@ -83,7 +84,7 @@ func Test_RecoverState(t *testing.T) {
},
}
position := types.NewPositionFromMarket(strategy.Market)
orderExecutor := bbgo.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position)
orderExecutor := bbgo.NewGeneralOrderExecutor(&bbgo.ExchangeSession{}, strategy.Symbol, ID, "", position)
state, err := recoverState(context.Background(), 5, currentRound, orderExecutor)
assert.NoError(t, err)
assert.Equal(t, OpenPositionOrdersCancelling, state)
@ -101,7 +102,7 @@ func Test_RecoverState(t *testing.T) {
},
}
position := types.NewPositionFromMarket(strategy.Market)
orderExecutor := bbgo.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position)
orderExecutor := bbgo.NewGeneralOrderExecutor(&bbgo.ExchangeSession{}, strategy.Symbol, ID, "", position)
state, err := recoverState(context.Background(), 5, currentRound, orderExecutor)
assert.NoError(t, err)
assert.Equal(t, OpenPositionOrdersCancelling, state)
@ -122,7 +123,7 @@ func Test_RecoverState(t *testing.T) {
},
}
position := types.NewPositionFromMarket(strategy.Market)
orderExecutor := bbgo.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position)
orderExecutor := bbgo.NewGeneralOrderExecutor(&bbgo.ExchangeSession{}, strategy.Symbol, ID, "", position)
state, err := recoverState(context.Background(), 5, currentRound, orderExecutor)
assert.NoError(t, err)
assert.Equal(t, TakeProfitReady, state)
@ -143,7 +144,7 @@ func Test_RecoverState(t *testing.T) {
},
}
position := types.NewPositionFromMarket(strategy.Market)
orderExecutor := bbgo.NewGeneralOrderExecutor(nil, strategy.Symbol, ID, "", position)
orderExecutor := bbgo.NewGeneralOrderExecutor(&bbgo.ExchangeSession{}, strategy.Symbol, ID, "", position)
state, err := recoverState(context.Background(), 5, currentRound, orderExecutor)
assert.NoError(t, err)
assert.Equal(t, WaitToOpenPosition, state)

View File

@ -48,8 +48,6 @@ type StreamExecutor struct {
stoppedC chan struct{}
state int
mu sync.Mutex
}
@ -349,6 +347,7 @@ func (e *StreamExecutor) cancelContextIfTargetQuantityFilled() bool {
e.cancelExecution()
return true
}
return false
}
@ -399,10 +398,10 @@ func (e *StreamExecutor) Run(parentCtx context.Context) error {
e.orderBook = types.NewStreamBook(e.Symbol)
e.orderBook.BindStream(e.marketDataStream)
go e.connectMarketData(e.executionCtx)
e.userDataStream = e.Session.Exchange.NewStream()
e.userDataStream.OnTradeUpdate(e.handleTradeUpdate)
e.position = &types.Position{
Symbol: e.Symbol,
BaseCurrency: e.market.BaseCurrency,
@ -415,6 +414,7 @@ func (e *StreamExecutor) Run(parentCtx context.Context) error {
e.activeMakerOrders.OnFilled(e.handleFilledOrder)
e.activeMakerOrders.BindStream(e.userDataStream)
go e.connectMarketData(e.executionCtx)
go e.connectUserData(e.userDataStreamCtx)
go e.orderUpdater(e.executionCtx)
return nil

View File

@ -0,0 +1,291 @@
package twap
import (
"context"
"errors"
"sync"
"time"
"github.com/sirupsen/logrus"
"golang.org/x/time/rate"
"github.com/c9s/bbgo/pkg/bbgo"
"github.com/c9s/bbgo/pkg/core"
"github.com/c9s/bbgo/pkg/fixedpoint"
"github.com/c9s/bbgo/pkg/types"
)
var defaultUpdateInterval = time.Minute
type DoneSignal struct {
doneC chan struct{}
mu sync.Mutex
}
func NewDoneSignal() *DoneSignal {
return &DoneSignal{
doneC: make(chan struct{}),
}
}
func (e *DoneSignal) Emit() {
e.mu.Lock()
if e.doneC == nil {
e.doneC = make(chan struct{})
}
close(e.doneC)
e.mu.Unlock()
}
// Chan returns a channel that emits a signal when the execution is done.
func (e *DoneSignal) Chan() (c <-chan struct{}) {
// if the channel is not allocated, it means it's not started yet, we need to return a closed channel
e.mu.Lock()
if e.doneC == nil {
e.doneC = make(chan struct{})
c = e.doneC
} else {
c = e.doneC
}
e.mu.Unlock()
return c
}
// FixedQuantityExecutor is a TWAP executor that places orders on the exchange using the exchange's stream API.
// It uses a fixed target quantity to place orders.
type FixedQuantityExecutor struct {
exchange types.Exchange
// configuration fields
symbol string
side types.SideType
targetQuantity fixedpoint.Value
// updateInterval is a fixed update interval for placing new order
updateInterval time.Duration
// delayInterval is the delay interval between each order placement
delayInterval time.Duration
// priceLimit is the price limit for the order
// for buy-orders, the price limit is the maximum price
// for sell-orders, the price limit is the minimum price
priceLimit fixedpoint.Value
// deadlineTime is the deadline time for the order execution
deadlineTime *time.Time
executionCtx context.Context
cancelExecution context.CancelFunc
userDataStreamCtx context.Context
cancelUserDataStream context.CancelFunc
market types.Market
marketDataStream types.Stream
orderBook *types.StreamOrderBook
userDataStream types.Stream
activeMakerOrders *bbgo.ActiveOrderBook
orderStore *core.OrderStore
position *types.Position
logger logrus.FieldLogger
mu sync.Mutex
done *DoneSignal
}
func NewStreamExecutor(
exchange types.Exchange,
symbol string,
market types.Market,
side types.SideType,
targetQuantity fixedpoint.Value,
) *FixedQuantityExecutor {
return &FixedQuantityExecutor{
exchange: exchange,
symbol: symbol,
side: side,
market: market,
position: types.NewPositionFromMarket(market),
targetQuantity: targetQuantity,
updateInterval: defaultUpdateInterval,
logger: logrus.WithFields(logrus.Fields{
"executor": "twapStream",
"symbol": symbol,
}),
done: NewDoneSignal(),
}
}
func (e *FixedQuantityExecutor) SetDeadlineTime(t time.Time) {
e.deadlineTime = &t
}
func (e *FixedQuantityExecutor) SetDelayInterval(delayInterval time.Duration) {
e.delayInterval = delayInterval
}
func (e *FixedQuantityExecutor) SetUpdateInterval(updateInterval time.Duration) {
e.updateInterval = updateInterval
}
func (e *FixedQuantityExecutor) connectMarketData(ctx context.Context) {
e.logger.Infof("connecting market data stream...")
if err := e.marketDataStream.Connect(ctx); err != nil {
e.logger.WithError(err).Errorf("market data stream connect error")
}
}
func (e *FixedQuantityExecutor) connectUserData(ctx context.Context) {
e.logger.Infof("connecting user data stream...")
if err := e.userDataStream.Connect(ctx); err != nil {
e.logger.WithError(err).Errorf("user data stream connect error")
}
}
func (e *FixedQuantityExecutor) handleFilledOrder(order types.Order) {
e.logger.Info(order.String())
// filled event triggers the order removal from the active order store
// we need to ensure we received every order update event before the execution is done.
e.cancelContextIfTargetQuantityFilled()
}
func (e *FixedQuantityExecutor) cancelContextIfTargetQuantityFilled() bool {
base := e.position.GetBase()
if base.Abs().Compare(e.targetQuantity) >= 0 {
e.logger.Infof("filled target quantity, canceling the order execution context")
e.cancelExecution()
return true
}
return false
}
func (e *FixedQuantityExecutor) cancelActiveOrders(ctx context.Context) error {
gracefulCtx, gracefulCancel := context.WithTimeout(ctx, 30*time.Second)
defer gracefulCancel()
return e.activeMakerOrders.GracefulCancel(gracefulCtx, e.exchange)
}
func (e *FixedQuantityExecutor) orderUpdater(ctx context.Context) {
updateLimiter := rate.NewLimiter(rate.Every(3*time.Second), 1)
_ = updateLimiter
defer func() {
if err := e.cancelActiveOrders(ctx); err != nil {
e.logger.WithError(err).Error("cancel active orders error")
}
e.cancelUserDataStream()
e.done.Emit()
}()
ticker := time.NewTimer(e.updateInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-e.orderBook.C:
if !updateLimiter.Allow() {
break
}
if e.cancelContextIfTargetQuantityFilled() {
return
}
e.logger.Infof("%s order book changed, checking order...", e.symbol)
/*
if err := e.updateOrder(ctx); err != nil {
e.logger.WithError(err).Errorf("order update failed")
}
*/
case <-ticker.C:
if !updateLimiter.Allow() {
break
}
if e.cancelContextIfTargetQuantityFilled() {
return
}
/*
if err := e.updateOrder(ctx); err != nil {
e.logger.WithError(err).Errorf("order update failed")
}
*/
}
}
}
func (e *FixedQuantityExecutor) Start(ctx context.Context) error {
if e.marketDataStream != nil {
return errors.New("market data stream is not nil, you can't start the executor twice")
}
e.executionCtx, e.cancelExecution = context.WithCancel(ctx)
e.userDataStreamCtx, e.cancelUserDataStream = context.WithCancel(ctx)
e.marketDataStream = e.exchange.NewStream()
e.marketDataStream.SetPublicOnly()
e.marketDataStream.Subscribe(types.BookChannel, e.symbol, types.SubscribeOptions{
Depth: types.DepthLevelMedium,
})
e.orderBook = types.NewStreamBook(e.symbol)
e.orderBook.BindStream(e.marketDataStream)
e.orderStore = core.NewOrderStore(e.symbol)
e.orderStore.BindStream(e.userDataStream)
e.activeMakerOrders = bbgo.NewActiveOrderBook(e.symbol)
e.activeMakerOrders.OnFilled(e.handleFilledOrder)
e.activeMakerOrders.BindStream(e.userDataStream)
go e.connectMarketData(e.executionCtx)
go e.connectUserData(e.userDataStreamCtx)
go e.orderUpdater(e.executionCtx)
return nil
}
// Done returns a channel that emits a signal when the execution is done.
func (e *FixedQuantityExecutor) Done() <-chan struct{} {
return e.done.Chan()
}
// Shutdown stops the execution
// If we call this method, it means the execution is still running,
// We need it to:
// 1. Stop the order updater (by using the execution context)
// 2. The order updater cancels all open orders and closes the user data stream
func (e *FixedQuantityExecutor) Shutdown(shutdownCtx context.Context) {
e.mu.Lock()
if e.cancelExecution != nil {
e.cancelExecution()
}
e.mu.Unlock()
for {
select {
case <-shutdownCtx.Done():
return
case <-e.done.Chan():
return
}
}
}

View File

@ -0,0 +1,9 @@
package twap
import (
"testing"
)
func TestNewStreamExecutorV2(t *testing.T) {
}