diff --git a/pkg/bbgo/activeorderbook.go b/pkg/bbgo/activeorderbook.go index e77bcfc84d..ad73c64c94 100644 --- a/pkg/bbgo/activeorderbook.go +++ b/pkg/bbgo/activeorderbook.go @@ -373,7 +373,7 @@ func (b *ActiveOrderBook) Update(order types.Order) { } switch order.Status { - case types.OrderStatusFilled: + case types.OrderStatusFilled, types.OrderStatusFinished: // make sure we have the order and we remove it removed := b.orders.Remove(order.OrderID) b.mu.Unlock() @@ -405,6 +405,10 @@ func (b *ActiveOrderBook) Update(order types.Order) { } b.C.Emit() + case types.OrderStatusTriggering, types.OrderStatusTriggered: + b.orders.Update(order) + b.mu.Unlock() + default: b.mu.Unlock() b.logger.Warnf("[ActiveOrderBook] unhandled order status: %s", order.Status) diff --git a/pkg/exchange/binance/parse.go b/pkg/exchange/binance/parse.go index ba540d4df3..d75ab079b3 100644 --- a/pkg/exchange/binance/parse.go +++ b/pkg/exchange/binance/parse.go @@ -494,6 +494,11 @@ func parseWebSocketEvent(message []byte) (interface{}, error) { err = json.Unmarshal([]byte(message), &event) return &event, err + case "ALGO_UPDATE": + var event AlgoOrderUpdateEvent + err = json.Unmarshal([]byte(message), &event) + return &event, err + default: id := val.GetInt("id") if id > 0 { @@ -1266,3 +1271,99 @@ type OrderTradeLiteUpdateEvent struct { TradeID int64 `json:"t"` OrderID int64 `json:"i"` } + +/* + { + "e":"ALGO_UPDATE", // Event Type + "T":1750515742297, // Transaction Time + "E":1750515742303, // Event Time + "o":{ + "caid":"Q5xaq5EGKgXXa0fD7fs0Ip", // Client Algo Id + "aid":2148719, // Algo Id + "at":"CONDITIONAL", // Algo Type + "o":"TAKE_PROFIT", //Order Type + "s":"BNBUSDT", //Symbol + "S":"SELL", //Side + "ps":"BOTH", //Position Side + "f":"GTC", //Time in force + "q":"0.01", //quantity + "X":"CANCELED", //Algo status + "ai":"", // order id + "ap": "0.00000", // avg fill price in matching engine, only display when order is triggered and placed in matching engine + "aq": "0.00000", // execuated quantity in matching engine, only display when order is triggered and placed in matching engine + "act": "0", // actual order type in matching engine, only display when order is triggered and placed in matching engine + "tp":"750", //Trigger price + "p":"750", //Order Price + "V":"EXPIRE_MAKER", //STP mode + "wt":"CONTRACT_PRICE", //Working type + "pm":"NONE", // Price match mode + "cp":false, //If Close-All + "pP":false, //If price protection is turned on + "R":false, // Is this reduce only + "tt":0, //Trigger time + "gtd":0, // good till time for GTD time in force + "rm": "Reduce Only reject" // algo order failed reason + } + } +*/ +type AlgoOrderUpdateEvent struct { + EventBase + + TransactionTime types.MillisecondTimestamp `json:"T"` + AlgoOrder AlgoOrder `json:"o"` +} + +type AlgoOrder struct { + ClientAlgoId string `json:"caid"` + AlgoId int `json:"aid"` + AlgoType string `json:"at"` + OrderType string `json:"o"` + Symbol string `json:"s"` + Side string `json:"S"` + PositionSide string `json:"ps"` + TimeInForce string `json:"f"` + Quantity fixedpoint.Value `json:"q"` + Price fixedpoint.Value `json:"p"` + TriggerPrice fixedpoint.Value `json:"tp"` + StopPrice fixedpoint.Value `json:"sp"` + Status string `json:"X"` + OrderId string `json:"ai"` + AvgFillPrice fixedpoint.Value `json:"ap"` + ExecutedQuantity fixedpoint.Value `json:"aq"` + ActualOrderType string `json:"act"` + STPMode string `json:"V"` + WorkingType string `json:"wt"` + PriceMatchMode string `json:"pm"` + CloseAll bool `json:"cp"` + PriceProtection bool `json:"pP"` + TriggerTime int64 `json:"tt"` + GoodTillTime int64 `json:"gtd"` + ReduceOnly bool `json:"R"` + FailedReason string `json:"rm"` +} + +func (e *AlgoOrderUpdateEvent) OrderFutures() (*types.Order, error) { + switch e.AlgoOrder.Status { + case "NEW", "CANCELED", "EXPIRED", "REJECTED", "TRIGGERING", "TRIGGERED", "FINISHED": + default: + return nil, errors.New("algo update event type is not for futures order") + } + + return &types.Order{ + Exchange: types.ExchangeBinance, + SubmitOrder: types.SubmitOrder{ + Symbol: e.AlgoOrder.Symbol, + ClientOrderID: e.AlgoOrder.ClientAlgoId, + Side: toGlobalFuturesSideType(futures.SideType(e.AlgoOrder.Side)), + Type: toGlobalFuturesOrderType(futures.AlgoOrderType(e.AlgoOrder.OrderType)), + Quantity: e.AlgoOrder.Quantity, + Price: e.AlgoOrder.Price, + StopPrice: e.AlgoOrder.TriggerPrice, + TimeInForce: types.TimeInForce(e.AlgoOrder.TimeInForce), + }, + OrderID: uint64(e.AlgoOrder.AlgoId), + Status: toGlobalFuturesOrderStatus(futures.OrderStatusType(e.AlgoOrder.Status)), + ExecutedQuantity: e.AlgoOrder.ExecutedQuantity, + UpdateTime: types.Time(e.TransactionTime.Time()), + }, nil +} diff --git a/pkg/exchange/binance/parse_test.go b/pkg/exchange/binance/parse_test.go index fab1631ebe..73b7d9eadd 100644 --- a/pkg/exchange/binance/parse_test.go +++ b/pkg/exchange/binance/parse_test.go @@ -429,3 +429,331 @@ func TestParseOrderFuturesUpdate(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, orderUpdate) } + +func TestAlgoOrderUpdateEvent_OrderFutures(t *testing.T) { + transactionTime := types.MillisecondTimestamp(time.UnixMilli(1750515742297)) + + t.Run("valid status NEW", func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "Q5xaq5EGKgXXa0fD7fs0Ip", + AlgoId: 2148719, + AlgoType: "CONDITIONAL", + OrderType: "TAKE_PROFIT", + Symbol: "BNBUSDT", + Side: "SELL", + PositionSide: "BOTH", + TimeInForce: "GTC", + Quantity: fixedpoint.MustNewFromString("0.01"), + Price: fixedpoint.MustNewFromString("750"), + TriggerPrice: fixedpoint.MustNewFromString("750"), + Status: "NEW", + OrderId: "", + AvgFillPrice: fixedpoint.MustNewFromString("0.00000"), + ExecutedQuantity: fixedpoint.MustNewFromString("0.00000"), + ActualOrderType: "0", + STPMode: "EXPIRE_MAKER", + WorkingType: "CONTRACT_PRICE", + PriceMatchMode: "NONE", + CloseAll: false, + PriceProtection: false, + TriggerTime: 0, + GoodTillTime: 0, + ReduceOnly: false, + FailedReason: "", + }, + } + + order, err := event.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, order) + assert.Equal(t, types.ExchangeBinance, order.Exchange) + assert.Equal(t, "BNBUSDT", order.Symbol) + assert.Equal(t, "Q5xaq5EGKgXXa0fD7fs0Ip", order.ClientOrderID) + assert.Equal(t, types.SideTypeSell, order.Side) + assert.Equal(t, types.OrderTypeTakeProfit, order.Type) + assert.Equal(t, fixedpoint.MustNewFromString("0.01"), order.Quantity) + assert.Equal(t, fixedpoint.MustNewFromString("750"), order.Price) + assert.Equal(t, fixedpoint.MustNewFromString("750"), order.StopPrice) + assert.Equal(t, types.TimeInForce("GTC"), order.TimeInForce) + assert.Equal(t, uint64(2148719), order.OrderID) + assert.Equal(t, types.OrderStatusNew, order.Status) + assert.Equal(t, fixedpoint.MustNewFromString("0.00000"), order.ExecutedQuantity) + assert.Equal(t, transactionTime.Time(), order.UpdateTime.Time()) + }) + + t.Run("valid status CANCELED", func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "test-client-id", + AlgoId: 123456, + OrderType: "STOP", + Symbol: "BTCUSDT", + Side: "BUY", + TimeInForce: "GTC", + Quantity: fixedpoint.MustNewFromString("1.0"), + Price: fixedpoint.MustNewFromString("50000"), + TriggerPrice: fixedpoint.MustNewFromString("49000"), + Status: "CANCELED", + ExecutedQuantity: fixedpoint.MustNewFromString("0"), + }, + } + + order, err := event.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, order) + assert.Equal(t, types.OrderStatusCanceled, order.Status) + }) + + t.Run("valid status EXPIRED", func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "test-expired", + AlgoId: 789012, + OrderType: "TAKE_PROFIT_MARKET", + Symbol: "ETHUSDT", + Side: "SELL", + TimeInForce: "GTC", + Quantity: fixedpoint.MustNewFromString("5.0"), + Price: fixedpoint.MustNewFromString("3000"), + TriggerPrice: fixedpoint.MustNewFromString("3200"), + Status: "EXPIRED", + ExecutedQuantity: fixedpoint.MustNewFromString("0"), + }, + } + + order, err := event.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, order) + assert.Equal(t, types.OrderStatusExpired, order.Status) + }) + + t.Run("valid status REJECTED", func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "test-rejected", + AlgoId: 345678, + OrderType: "STOP_MARKET", + Symbol: "SOLUSDT", + Side: "BUY", + TimeInForce: "GTC", + Quantity: fixedpoint.MustNewFromString("10.0"), + Price: fixedpoint.MustNewFromString("100"), + TriggerPrice: fixedpoint.MustNewFromString("95"), + Status: "REJECTED", + FailedReason: "Insufficient balance", + ExecutedQuantity: fixedpoint.MustNewFromString("0"), + }, + } + + order, err := event.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, order) + assert.Equal(t, types.OrderStatusRejected, order.Status) + }) + + t.Run("valid status TRIGGERING", func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "test-triggering", + AlgoId: 456789, + OrderType: "STOP", + Symbol: "ADAUSDT", + Side: "SELL", + TimeInForce: "GTC", + Quantity: fixedpoint.MustNewFromString("1000.0"), + Price: fixedpoint.MustNewFromString("0.5"), + TriggerPrice: fixedpoint.MustNewFromString("0.48"), + Status: "TRIGGERING", + ExecutedQuantity: fixedpoint.MustNewFromString("0"), + }, + } + + order, err := event.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, order) + assert.Equal(t, uint64(456789), order.OrderID) + }) + + t.Run("valid status TRIGGERED", func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "test-triggered", + AlgoId: 567890, + OrderType: "TAKE_PROFIT_MARKET", + Symbol: "DOGEUSDT", + Side: "BUY", + TimeInForce: "GTC", + Quantity: fixedpoint.MustNewFromString("10000.0"), + Price: fixedpoint.MustNewFromString("0.1"), + TriggerPrice: fixedpoint.MustNewFromString("0.12"), + Status: "TRIGGERED", + OrderId: "12345", + AvgFillPrice: fixedpoint.MustNewFromString("0.11"), + ExecutedQuantity: fixedpoint.MustNewFromString("5000.0"), + ActualOrderType: "MARKET", + }, + } + + order, err := event.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, order) + assert.Equal(t, fixedpoint.MustNewFromString("5000.0"), order.ExecutedQuantity) + }) + + t.Run("valid status FINISHED", func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "test-finished", + AlgoId: 678901, + OrderType: "TRAILING_STOP_MARKET", + Symbol: "XRPUSDT", + Side: "SELL", + TimeInForce: "GTC", + Quantity: fixedpoint.MustNewFromString("500.0"), + Price: fixedpoint.MustNewFromString("0.6"), + TriggerPrice: fixedpoint.MustNewFromString("0.58"), + Status: "FINISHED", + OrderId: "67890", + AvgFillPrice: fixedpoint.MustNewFromString("0.59"), + ExecutedQuantity: fixedpoint.MustNewFromString("500.0"), + ActualOrderType: "MARKET", + }, + } + + order, err := event.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, order) + assert.Equal(t, fixedpoint.MustNewFromString("500.0"), order.ExecutedQuantity) + }) + + t.Run("invalid status returns error", func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "test-invalid", + AlgoId: 999999, + OrderType: "STOP", + Symbol: "BTCUSDT", + Side: "BUY", + Status: "INVALID_STATUS", + }, + } + + order, err := event.OrderFutures() + assert.Error(t, err) + assert.Nil(t, order) + assert.Contains(t, err.Error(), "algo update event type is not for futures order") + }) + + t.Run("with executed quantity and avg fill price", func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "test-executed", + AlgoId: 111222, + OrderType: "TAKE_PROFIT", + Symbol: "LTCUSDT", + Side: "BUY", + TimeInForce: "GTC", + Quantity: fixedpoint.MustNewFromString("2.0"), + Price: fixedpoint.MustNewFromString("100"), + TriggerPrice: fixedpoint.MustNewFromString("95"), + Status: "TRIGGERED", + OrderId: "99999", + AvgFillPrice: fixedpoint.MustNewFromString("97.5"), + ExecutedQuantity: fixedpoint.MustNewFromString("1.5"), + ActualOrderType: "LIMIT", + }, + } + + order, err := event.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, order) + assert.Equal(t, fixedpoint.MustNewFromString("1.5"), order.ExecutedQuantity) + }) + + t.Run("different order types", func(t *testing.T) { + testCases := []struct { + name string + orderType string + expected types.OrderType + }{ + {"STOP", "STOP", types.OrderTypeStopLimit}, + {"STOP_MARKET", "STOP_MARKET", types.OrderTypeStopMarket}, + {"TAKE_PROFIT", "TAKE_PROFIT", types.OrderTypeTakeProfit}, + {"TAKE_PROFIT_MARKET", "TAKE_PROFIT_MARKET", types.OrderTypeTakeProfitMarket}, + {"TRAILING_STOP_MARKET", "TRAILING_STOP_MARKET", types.OrderTypeStopMarket}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "test-" + tc.name, + AlgoId: 111111, + OrderType: tc.orderType, + Symbol: "BTCUSDT", + Side: "BUY", + TimeInForce: "GTC", + Quantity: fixedpoint.MustNewFromString("1.0"), + Price: fixedpoint.MustNewFromString("50000"), + TriggerPrice: fixedpoint.MustNewFromString("49000"), + Status: "NEW", + ExecutedQuantity: fixedpoint.MustNewFromString("0"), + }, + } + + order, err := event.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, order) + assert.Equal(t, tc.expected, order.Type) + }) + } + }) + + t.Run("different sides", func(t *testing.T) { + testCases := []struct { + name string + side string + expected types.SideType + }{ + {"BUY", "BUY", types.SideTypeBuy}, + {"SELL", "SELL", types.SideTypeSell}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + event := &AlgoOrderUpdateEvent{ + TransactionTime: transactionTime, + AlgoOrder: AlgoOrder{ + ClientAlgoId: "test-side-" + tc.name, + AlgoId: 222222, + OrderType: "STOP", + Symbol: "BTCUSDT", + Side: tc.side, + TimeInForce: "GTC", + Quantity: fixedpoint.MustNewFromString("1.0"), + Price: fixedpoint.MustNewFromString("50000"), + TriggerPrice: fixedpoint.MustNewFromString("49000"), + Status: "NEW", + ExecutedQuantity: fixedpoint.MustNewFromString("0"), + }, + } + + order, err := event.OrderFutures() + assert.NoError(t, err) + assert.NotNil(t, order) + assert.Equal(t, tc.expected, order.Side) + }) + } + }) +} diff --git a/pkg/exchange/binance/stream.go b/pkg/exchange/binance/stream.go index 3e97bd6828..ae0b9f2499 100644 --- a/pkg/exchange/binance/stream.go +++ b/pkg/exchange/binance/stream.go @@ -105,6 +105,7 @@ type Stream struct { accountConfigUpdateEventCallbacks []func(e *AccountConfigUpdateEvent) marginCallEventCallbacks []func(e *MarginCallEvent) listenKeyExpiredCallbacks []func(e *ListenKeyExpired) + algoOrderUpdateEventCallbacks []func(e *AlgoOrderUpdateEvent) errorCallbacks []func(e *ErrorEvent) @@ -190,6 +191,8 @@ func NewStream(ex *Exchange, client *binance.Client, futuresClient *futures.Clie // =================================== // Event type ACCOUNT_UPDATE from user data stream updates Balance and FuturesPosition. stream.OnOrderTradeUpdateEvent(stream.handleOrderTradeUpdateEvent) + // Event type ALGO_UPDATE from user data stream updates AlgoOrder. + stream.OnAlgoOrderUpdateEvent(stream.handleAlgoOrderUpdateEvent) // =================================== if debugMode { @@ -653,6 +656,8 @@ func (s *Stream) dispatchEvent(e interface{}) { case *ForceOrderEvent: s.EmitForceOrderEvent(e) + case *AlgoOrderUpdateEvent: + s.EmitAlgoOrderUpdateEvent(e) case *MarginCallEvent: } @@ -864,3 +869,13 @@ func (s *Stream) listenKeyKeepAlive(ctx context.Context, listenKey string) { } } } + +func (s *Stream) handleAlgoOrderUpdateEvent(e *AlgoOrderUpdateEvent) { + order, err := e.OrderFutures() + if err != nil { + log.WithError(err).Error("algo order convert error") + return + } + + s.EmitOrderUpdate(*order) +} diff --git a/pkg/exchange/binance/stream_callbacks.go b/pkg/exchange/binance/stream_callbacks.go index 7ecfbb4b80..9b33d17c92 100644 --- a/pkg/exchange/binance/stream_callbacks.go +++ b/pkg/exchange/binance/stream_callbacks.go @@ -2,8 +2,6 @@ package binance -import () - func (s *Stream) OnDepthEvent(cb func(e *DepthEvent)) { s.depthEventCallbacks = append(s.depthEventCallbacks, cb) } @@ -204,6 +202,16 @@ func (s *Stream) EmitError(e *ErrorEvent) { } } +func (s *Stream) OnAlgoOrderUpdateEvent(cb func(e *AlgoOrderUpdateEvent)) { + s.algoOrderUpdateEventCallbacks = append(s.algoOrderUpdateEventCallbacks, cb) +} + +func (s *Stream) EmitAlgoOrderUpdateEvent(e *AlgoOrderUpdateEvent) { + for _, cb := range s.algoOrderUpdateEventCallbacks { + cb(e) + } +} + type StreamEventHub interface { OnDepthEvent(cb func(e *DepthEvent)) diff --git a/pkg/types/order.go b/pkg/types/order.go index b29ea325e5..bb07c25684 100644 --- a/pkg/types/order.go +++ b/pkg/types/order.go @@ -133,13 +133,23 @@ const ( // OrderStatusExpired means the order is expired, it's an end state. OrderStatusExpired OrderStatus = "EXPIRED" + + // OrderStatusTriggering means the algo order is triggering. + OrderStatusTriggering OrderStatus = "TRIGGERING" + + // OrderStatusTriggered means the algo order is triggered. + OrderStatusTriggered OrderStatus = "TRIGGERED" + + // OrderStatusFinished means the algo order is finished, it's an end state. + OrderStatusFinished OrderStatus = "FINISHED" ) func (o OrderStatus) Closed() bool { return o == OrderStatusFilled || o == OrderStatusCanceled || o == OrderStatusRejected || - o == OrderStatusExpired + o == OrderStatusExpired || + o == OrderStatusFinished } type SubmitOrder struct {