325 lines
8.4 KiB
Go
325 lines
8.4 KiB
Go
package okex
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"golang.org/x/time/rate"
|
|
|
|
"git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/okex/okexapi"
|
|
"git.qtrade.icu/lychiyu/qbtrade/pkg/exchange/retry"
|
|
"git.qtrade.icu/lychiyu/qbtrade/pkg/types"
|
|
)
|
|
|
|
var (
|
|
marketTradeLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1)
|
|
tradeLogLimiter = rate.NewLimiter(rate.Every(time.Minute), 1)
|
|
// pingInterval the connection will break automatically if the subscription is not established or data has not been
|
|
// pushed for more than 30 seconds. Therefore, we set it to 20 seconds.
|
|
pingInterval = 20 * time.Second
|
|
)
|
|
|
|
type WebsocketOp struct {
|
|
Op WsEventType `json:"op"`
|
|
Args interface{} `json:"args"`
|
|
}
|
|
|
|
type WebsocketLogin struct {
|
|
Key string `json:"apiKey"`
|
|
Passphrase string `json:"passphrase"`
|
|
Timestamp string `json:"timestamp"`
|
|
Sign string `json:"sign"`
|
|
}
|
|
|
|
//go:generate callbackgen -type Stream -interface
|
|
type Stream struct {
|
|
types.StandardStream
|
|
kLineStream *KLineStream
|
|
|
|
client *okexapi.RestClient
|
|
balanceProvider types.ExchangeAccountService
|
|
|
|
// public callbacks
|
|
kLineEventCallbacks []func(candle KLineEvent)
|
|
bookEventCallbacks []func(book BookEvent)
|
|
accountEventCallbacks []func(account okexapi.Account)
|
|
orderTradesEventCallbacks []func(orderTrades []OrderTradeEvent)
|
|
marketTradeEventCallbacks []func(tradeDetail []MarketTradeEvent)
|
|
}
|
|
|
|
func NewStream(client *okexapi.RestClient, balanceProvider types.ExchangeAccountService) *Stream {
|
|
stream := &Stream{
|
|
client: client,
|
|
balanceProvider: balanceProvider,
|
|
StandardStream: types.NewStandardStream(),
|
|
kLineStream: NewKLineStream(),
|
|
}
|
|
|
|
stream.SetParser(parseWebSocketEvent)
|
|
stream.SetDispatcher(stream.dispatchEvent)
|
|
stream.SetEndpointCreator(stream.createEndpoint)
|
|
stream.SetPingInterval(pingInterval)
|
|
|
|
stream.OnBookEvent(stream.handleBookEvent)
|
|
stream.OnAccountEvent(stream.handleAccountEvent)
|
|
stream.OnMarketTradeEvent(stream.handleMarketTradeEvent)
|
|
stream.OnOrderTradesEvent(stream.handleOrderDetailsEvent)
|
|
stream.OnConnect(stream.handleConnect)
|
|
stream.OnAuth(stream.subscribePrivateChannels(stream.emitBalanceSnapshot))
|
|
stream.kLineStream.OnKLineClosed(stream.EmitKLineClosed)
|
|
stream.kLineStream.OnKLine(stream.EmitKLine)
|
|
|
|
return stream
|
|
}
|
|
|
|
func syncSubscriptions(conn *websocket.Conn, subscriptions []types.Subscription, opType WsEventType) error {
|
|
if opType != WsEventTypeUnsubscribe && opType != WsEventTypeSubscribe {
|
|
return fmt.Errorf("unexpected subscription type: %v", opType)
|
|
}
|
|
|
|
logger := log.WithField("opType", opType)
|
|
var topics []WebsocketSubscription
|
|
for _, subscription := range subscriptions {
|
|
topic, err := convertSubscription(subscription)
|
|
if err != nil {
|
|
logger.WithError(err).Errorf("convert error, subscription: %+v", subscription)
|
|
return err
|
|
}
|
|
|
|
topics = append(topics, topic)
|
|
}
|
|
|
|
logger.Infof("%s channels: %+v", opType, topics)
|
|
if err := conn.WriteJSON(WebsocketOp{
|
|
Op: opType,
|
|
Args: topics,
|
|
}); 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.
|
|
_ = syncSubscriptions(s.StandardStream.Conn, s.StandardStream.Subscriptions, WsEventTypeUnsubscribe)
|
|
s.Resubscribe(func(old []types.Subscription) (new []types.Subscription, err error) {
|
|
// clear the subscriptions
|
|
return []types.Subscription{}, nil
|
|
})
|
|
|
|
s.kLineStream.Unsubscribe()
|
|
}
|
|
|
|
func (s *Stream) Connect(ctx context.Context) error {
|
|
if err := s.StandardStream.Connect(ctx); err != nil {
|
|
return err
|
|
}
|
|
if err := s.kLineStream.Connect(ctx); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Stream) Subscribe(channel types.Channel, symbol string, options types.SubscribeOptions) {
|
|
if channel == types.KLineChannel {
|
|
s.kLineStream.Subscribe(channel, symbol, options)
|
|
} else {
|
|
s.StandardStream.Subscribe(channel, symbol, options)
|
|
}
|
|
}
|
|
|
|
func subscribe(conn *websocket.Conn, subs []WebsocketSubscription) {
|
|
if len(subs) == 0 {
|
|
return
|
|
}
|
|
|
|
log.Infof("subscribing channels: %+v", subs)
|
|
err := conn.WriteJSON(WebsocketOp{
|
|
Op: "subscribe",
|
|
Args: subs,
|
|
})
|
|
|
|
if err != nil {
|
|
log.WithError(err).Error("subscribe error")
|
|
}
|
|
}
|
|
|
|
func (s *Stream) handleConnect() {
|
|
if s.PublicOnly {
|
|
var subs []WebsocketSubscription
|
|
for _, subscription := range s.Subscriptions {
|
|
sub, err := convertSubscription(subscription)
|
|
if err != nil {
|
|
log.WithError(err).Errorf("subscription convert error")
|
|
continue
|
|
}
|
|
|
|
subs = append(subs, sub)
|
|
}
|
|
subscribe(s.StandardStream.Conn, subs)
|
|
} else {
|
|
// login as private channel
|
|
// sign example:
|
|
// sign=CryptoJS.enc.Base64.Stringify(CryptoJS.HmacSHA256(timestamp +'GET'+'/users/self/verify', secretKey))
|
|
msTimestamp := strconv.FormatFloat(float64(time.Now().UnixNano())/float64(time.Second), 'f', -1, 64)
|
|
payload := msTimestamp + "GET" + "/users/self/verify"
|
|
sign := okexapi.Sign(payload, s.client.Secret)
|
|
op := WebsocketOp{
|
|
Op: "login",
|
|
Args: []WebsocketLogin{
|
|
{
|
|
Key: s.client.Key,
|
|
Passphrase: s.client.Passphrase,
|
|
Timestamp: msTimestamp,
|
|
Sign: sign,
|
|
},
|
|
},
|
|
}
|
|
|
|
log.Infof("sending okex login request")
|
|
err := s.Conn.WriteJSON(op)
|
|
if err != nil {
|
|
log.WithError(err).Errorf("can not send login message")
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Stream) subscribePrivateChannels(next func()) func() {
|
|
return func() {
|
|
var subs = []WebsocketSubscription{
|
|
{Channel: ChannelAccount},
|
|
{Channel: "orders", InstrumentType: string(okexapi.InstrumentTypeSpot)},
|
|
}
|
|
|
|
// https://www.okx.com/docs-v5/zh/#overview-websocket-connect
|
|
// **NOTICE** 2024/06/03 Since the number of channels we are currently subscribed to is far less
|
|
// than the rate limit of 20, rate limiting is not supported for now.
|
|
log.Infof("subscribing private channels: %+v", subs)
|
|
err := s.Conn.WriteJSON(WebsocketOp{
|
|
Op: "subscribe",
|
|
Args: subs,
|
|
})
|
|
if err != nil {
|
|
log.WithError(err).Error("private channel subscribe error")
|
|
return
|
|
}
|
|
next()
|
|
}
|
|
}
|
|
|
|
func (s *Stream) emitBalanceSnapshot() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
var balancesMap types.BalanceMap
|
|
var err error
|
|
err = retry.GeneralBackoff(ctx, func() error {
|
|
balancesMap, err = s.balanceProvider.QueryAccountBalances(ctx)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
log.WithError(err).Error("no more attempts to retrieve balances")
|
|
return
|
|
}
|
|
|
|
s.EmitBalanceSnapshot(balancesMap)
|
|
}
|
|
|
|
func (s *Stream) handleOrderDetailsEvent(orderTrades []OrderTradeEvent) {
|
|
for _, evt := range orderTrades {
|
|
if evt.TradeId != "" {
|
|
trade, err := evt.toGlobalTrade()
|
|
if err != nil {
|
|
if tradeLogLimiter.Allow() {
|
|
log.WithError(err).Errorf("failed to convert global trade")
|
|
}
|
|
} else {
|
|
s.EmitTradeUpdate(trade)
|
|
}
|
|
}
|
|
|
|
order, err := orderDetailToGlobal(&evt.OrderDetail)
|
|
if err != nil {
|
|
if tradeLogLimiter.Allow() {
|
|
log.WithError(err).Errorf("failed to convert global order")
|
|
}
|
|
} else {
|
|
s.EmitOrderUpdate(*order)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Stream) handleAccountEvent(account okexapi.Account) {
|
|
balances := toGlobalBalance(&account)
|
|
s.EmitBalanceUpdate(balances)
|
|
}
|
|
|
|
func (s *Stream) handleBookEvent(data BookEvent) {
|
|
book := data.Book()
|
|
switch data.Action {
|
|
case ActionTypeSnapshot:
|
|
s.EmitBookSnapshot(book)
|
|
case ActionTypeUpdate:
|
|
s.EmitBookUpdate(book)
|
|
}
|
|
}
|
|
|
|
func (s *Stream) handleMarketTradeEvent(data []MarketTradeEvent) {
|
|
for _, event := range data {
|
|
trade, err := event.toGlobalTrade()
|
|
if err != nil {
|
|
if marketTradeLogLimiter.Allow() {
|
|
log.WithError(err).Error("failed to convert to market trade")
|
|
}
|
|
continue
|
|
}
|
|
|
|
s.EmitMarketTrade(trade)
|
|
}
|
|
}
|
|
|
|
func (s *Stream) createEndpoint(ctx context.Context) (string, error) {
|
|
var url string
|
|
if s.PublicOnly {
|
|
url = okexapi.PublicWebSocketURL
|
|
} else {
|
|
url = okexapi.PrivateWebSocketURL
|
|
}
|
|
return url, nil
|
|
}
|
|
|
|
func (s *Stream) dispatchEvent(e interface{}) {
|
|
switch et := e.(type) {
|
|
case *WebSocketEvent:
|
|
if err := et.IsValid(); err != nil {
|
|
log.Errorf("invalid event: %v", err)
|
|
return
|
|
}
|
|
if et.IsAuthenticated() {
|
|
s.EmitAuth()
|
|
}
|
|
|
|
case *BookEvent:
|
|
// there's "books" for 400 depth and books5 for 5 depth
|
|
if et.channel != ChannelBooks5 {
|
|
s.EmitBookEvent(*et)
|
|
}
|
|
s.EmitBookTickerUpdate(et.BookTicker())
|
|
|
|
case *okexapi.Account:
|
|
s.EmitAccountEvent(*et)
|
|
|
|
case []OrderTradeEvent:
|
|
s.EmitOrderTradesEvent(et)
|
|
|
|
case []MarketTradeEvent:
|
|
s.EmitMarketTradeEvent(et)
|
|
|
|
}
|
|
}
|