qbtrade/pkg/exchange/bitget/stream.go

478 lines
12 KiB
Go
Raw Normal View History

2024-06-27 14:42:38 +00:00
package bitget
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/gorilla/websocket"
"golang.org/x/time/rate"
"git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi"
v2 "git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/bitget/bitgetapi/v2"
"git.qtrade.icu/lychiyu/qbtrade/pkg/types"
)
var (
pingBytes = []byte("ping")
pongBytes = []byte("pong")
marketTradeLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1)
tradeLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1)
orderLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1)
kLineLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1)
)
//go:generate callbackgen -type Stream
type Stream struct {
types.StandardStream
privateChannelSymbols []string
key, secret, passphrase string
bookEventCallbacks []func(o BookEvent)
marketTradeEventCallbacks []func(o MarketTradeEvent)
KLineEventCallbacks []func(o KLineEvent)
accountEventCallbacks []func(e AccountEvent)
orderTradeEventCallbacks []func(e OrderTradeEvent)
lastCandle map[string]types.KLine
}
func NewStream(key, secret, passphrase string) *Stream {
stream := &Stream{
StandardStream: types.NewStandardStream(),
lastCandle: map[string]types.KLine{},
key: key,
secret: secret,
passphrase: passphrase,
}
stream.SetEndpointCreator(stream.createEndpoint)
stream.SetParser(parseWebSocketEvent)
stream.SetDispatcher(stream.dispatchEvent)
stream.SetHeartBeat(stream.ping)
stream.OnConnect(stream.handlerConnect)
stream.OnBookEvent(stream.handleBookEvent)
stream.OnMarketTradeEvent(stream.handleMaretTradeEvent)
stream.OnKLineEvent(stream.handleKLineEvent)
stream.OnAuth(stream.handleAuth)
stream.OnAccountEvent(stream.handleAccountEvent)
stream.OnOrderTradeEvent(stream.handleOrderTradeEvent)
return stream
}
func (s *Stream) syncSubscriptions(opType WsEventType) error {
if opType != WsEventUnsubscribe && opType != WsEventSubscribe {
return fmt.Errorf("unexpected subscription type: %v", opType)
}
logger := log.WithField("opType", opType)
var args []WsArg
for _, subscription := range s.Subscriptions {
arg, err := convertSubscription(subscription)
if err != nil {
logger.WithError(err).Errorf("convert error, subscription: %+v", subscription)
return err
}
args = append(args, arg)
}
logger.Infof("%s channels: %+v", opType, args)
batchSize := 10
lenArgs := len(args)
for begin := 0; begin < lenArgs; begin += batchSize {
end := begin + batchSize
if end > lenArgs {
end = lenArgs
}
if err := s.Conn.WriteJSON(WsOp{
Op: opType,
Args: args[begin:end],
}); err != nil {
logger.WithError(err).Error("failed to send request")
return err
}
}
return nil
}
func (s *Stream) Unsubscribe() {
// errors are handled in the syncSubscriptions, so they are skipped here.
_ = s.syncSubscriptions(WsEventUnsubscribe)
s.Resubscribe(func(old []types.Subscription) (new []types.Subscription, err error) {
// clear the subscriptions
return []types.Subscription{}, nil
})
}
func (s *Stream) createEndpoint(_ context.Context) (string, error) {
var url string
if s.PublicOnly {
url = bitgetapi.PublicWebSocketURL
} else {
url = v2.PrivateWebSocketURL
}
return url, nil
}
func (s *Stream) dispatchEvent(event interface{}) {
switch e := event.(type) {
case *WsEvent:
if err := e.IsValid(); err != nil {
log.Errorf("invalid event: %v", err)
return
}
if e.IsAuthenticated() {
s.EmitAuth()
}
case *BookEvent:
s.EmitBookEvent(*e)
case *MarketTradeEvent:
s.EmitMarketTradeEvent(*e)
case *KLineEvent:
s.EmitKLineEvent(*e)
case *AccountEvent:
s.EmitAccountEvent(*e)
case *OrderTradeEvent:
s.EmitOrderTradeEvent(*e)
case []byte:
// We only handle the 'pong' case. Others are unexpected.
if !bytes.Equal(e, pongBytes) {
log.Errorf("invalid event: %q", e)
}
}
}
// handleAuth subscribe private stream channels. Because Bitget doesn't allow authentication and subscription to be used
// consecutively, we subscribe after authentication confirmation.
func (s *Stream) handleAuth() {
op := WsOp{
Op: WsEventSubscribe,
Args: []WsArg{
{
InstType: instSpV2,
Channel: ChannelAccount,
Coin: "default", // all coins
},
},
}
if len(s.privateChannelSymbols) > 0 {
for _, symbol := range s.privateChannelSymbols {
op.Args = append(op.Args, WsArg{
InstType: instSpV2,
Channel: ChannelOrders,
InstId: symbol,
})
}
} else {
log.Warnf("you have not subscribed to any order channels")
}
if err := s.Conn.WriteJSON(op); err != nil {
log.WithError(err).Error("failed to send subscription request")
return
}
}
func (s *Stream) SetPrivateChannelSymbols(symbols []string) {
s.privateChannelSymbols = symbols
}
func (s *Stream) handlerConnect() {
if s.PublicOnly {
// errors are handled in the syncSubscriptions, so they are skipped here.
_ = s.syncSubscriptions(WsEventSubscribe)
} else {
timestamp := strconv.FormatInt(time.Now().Unix(), 10)
if err := s.Conn.WriteJSON(WsOp{
Op: WsEventLogin,
Args: []WsArg{
{
ApiKey: s.key,
Passphrase: s.passphrase,
Timestamp: timestamp,
Sign: bitgetapi.Sign(fmt.Sprintf("%sGET/user/verify", timestamp), s.secret),
},
},
}); err != nil {
log.WithError(err).Error("failed to auth request")
return
}
}
}
func (s *Stream) handleBookEvent(o BookEvent) {
for _, book := range o.ToGlobalOrderBooks() {
switch o.actionType {
case ActionTypeSnapshot:
s.EmitBookSnapshot(book)
case ActionTypeUpdate:
s.EmitBookUpdate(book)
}
}
}
// ping implements the bitget text message of WebSocket PingPong.
func (s *Stream) ping(conn *websocket.Conn) error {
err := conn.WriteMessage(websocket.TextMessage, pingBytes)
if err != nil {
log.WithError(err).Error("ping error", err)
return nil
}
return nil
}
func convertSubscription(sub types.Subscription) (WsArg, error) {
arg := WsArg{
// support spot only
InstType: instSp,
Channel: "",
InstId: sub.Symbol,
}
switch sub.Channel {
case types.BookChannel:
arg.Channel = ChannelOrderBook5
switch sub.Options.Depth {
case types.DepthLevel5:
arg.Channel = ChannelOrderBook5
case types.DepthLevel15, types.DepthLevelMedium:
arg.Channel = ChannelOrderBook15
case types.DepthLevel200, types.DepthLevelFull:
log.Warn("*** The subscription events for the order book may return fewer than 200 bids/asks at a depth of 200. ***")
arg.Channel = ChannelOrderBook
}
return arg, nil
case types.MarketTradeChannel:
arg.Channel = ChannelTrade
return arg, nil
case types.KLineChannel:
interval, found := toLocalInterval[sub.Options.Interval]
if !found {
return WsArg{}, fmt.Errorf("interval %s not supported on KLine subscription", sub.Options.Interval)
}
arg.Channel = ChannelType(interval)
return arg, nil
}
return arg, fmt.Errorf("unsupported stream channel: %s", sub.Channel)
}
func parseWebSocketEvent(in []byte) (interface{}, error) {
switch {
case bytes.Equal(in, pongBytes):
// return global pong event to avoid emit raw message
return types.WebsocketPongEvent{}, nil
default:
return parseEvent(in)
}
}
func parseEvent(in []byte) (interface{}, error) {
var event WsEvent
err := json.Unmarshal(in, &event)
if err != nil {
return nil, err
}
if event.IsOp() {
return &event, nil
}
ch := event.Arg.Channel
switch ch {
case ChannelAccount:
var acct AccountEvent
err = json.Unmarshal(event.Data, &acct.Balances)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data into AccountEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err)
}
acct.actionType = event.Action
acct.instId = event.Arg.InstId
return &acct, nil
case ChannelOrderBook, ChannelOrderBook5, ChannelOrderBook15:
var book BookEvent
err = json.Unmarshal(event.Data, &book.Events)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data into BookEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err)
}
book.actionType = event.Action
book.instId = event.Arg.InstId
return &book, nil
case ChannelOrders:
var order OrderTradeEvent
err = json.Unmarshal(event.Data, &order.Orders)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data into OrderTradeEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err)
}
order.actionType = event.Action
order.instId = event.Arg.InstId
return &order, nil
case ChannelTrade:
var trade MarketTradeEvent
err = json.Unmarshal(event.Data, &trade.Events)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data into MarketTradeEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err)
}
trade.actionType = event.Action
trade.instId = event.Arg.InstId
return &trade, nil
default:
// handle the `KLine` case here to avoid complicating the code structure.
if strings.HasPrefix(string(ch), "candle") {
var kline KLineEvent
err = json.Unmarshal(event.Data, &kline.Events)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal data into KLineEvent, Arg: %+v Data: %s, err: %w", event.Arg, string(event.Data), err)
}
kline.actionType = event.Action
kline.channel = ch
kline.instId = event.Arg.InstId
return &kline, nil
}
// return an error for any other case
return nil, fmt.Errorf("unhandled websocket event: %+v", string(in))
}
}
func (s *Stream) handleMaretTradeEvent(m MarketTradeEvent) {
if m.actionType == ActionTypeSnapshot {
// we don't support snapshot event
return
}
for _, trade := range m.Events {
globalTrade, err := trade.ToGlobal(m.instId)
if err != nil {
if marketTradeLogLimiter.Allow() {
log.WithError(err).Error("failed to convert to market trade")
}
return
}
s.EmitMarketTrade(globalTrade)
}
}
func (s *Stream) handleKLineEvent(k KLineEvent) {
if k.actionType == ActionTypeSnapshot {
// we don't support snapshot event
return
}
interval, found := toGlobalInterval[string(k.channel)]
if !found {
if kLineLogLimiter.Allow() {
log.Errorf("unexpected interval %s on KLine subscription", k.channel)
}
return
}
for _, kline := range k.Events {
last, ok := s.lastCandle[k.CacheKey()]
if ok && kline.StartTime.Time().After(last.StartTime.Time()) {
last.Closed = true
s.EmitKLineClosed(last)
}
kLine := kline.ToGlobal(interval, k.instId)
s.EmitKLine(kLine)
s.lastCandle[k.CacheKey()] = kLine
}
}
func (s *Stream) handleAccountEvent(m AccountEvent) {
balanceMap := toGlobalBalanceMap(m.Balances)
if len(balanceMap) == 0 {
return
}
if m.actionType == ActionTypeUpdate {
s.StandardStream.EmitBalanceUpdate(balanceMap)
return
}
s.StandardStream.EmitBalanceSnapshot(balanceMap)
}
func (s *Stream) handleOrderTradeEvent(m OrderTradeEvent) {
if len(m.Orders) == 0 {
return
}
debugf("received OrderTradeEvent: %+v", m)
for _, order := range m.Orders {
debugf("received Order: %+v", order)
globalOrder, err := order.toGlobalOrder()
if err != nil {
if orderLogLimiter.Allow() {
log.Errorf("failed to convert order to global: %s", err)
}
continue
}
// The bitget support only snapshot on orders channel, so we use snapshot as update to emit data.
if m.actionType != ActionTypeSnapshot {
continue
}
s.StandardStream.EmitOrderUpdate(globalOrder)
if order.TradeId == 0 {
continue
}
debugf("received Trade: %+v", order.Trade)
switch globalOrder.Status {
case types.OrderStatusPartiallyFilled, types.OrderStatusFilled:
trade, err := order.toGlobalTrade()
if err != nil {
if tradeLogLimiter.Allow() {
log.Errorf("failed to convert trade to global: %s", err)
}
continue
}
s.StandardStream.EmitTradeUpdate(trade)
}
}
}