mirror of
https://github.com/d0zingcat/gocryptotrader.git
synced 2026-05-18 15:10:03 +00:00
* Exchanges: Remove example BespokeGenerateMessageID * Okx: Replace conn.RequestIDGenerator with MesssageID Continued overall direction to remove the closed-loop of e => conn => e roundtrip for message ids * Exchanges: Add MessageSequence This method removes the either/or nature of message id generation. We don't tie the message ids to connections, or to anything. Consumers just call whichever they want, or even combine them as they want. Anything more complicated will need a separate installation anyway * GateIO: Split usage of MessageID and MessageSequence * Binance: Switch to UUID message IDs * Kraken: Switch to e.MessageSequence * Kucoin: Switch to MessageID * HitBTC: Switch to UUIDv7 for ws message ID * Bybit: Switch to UUIDv7 for ws message ID * Bitfinex: Switch to UUIDv7 and MessageSequence Tested CID - It accepts 53 bits only for an int, so MessageSequence makes sense. Can't use MessageID * Websocket: Remove now unused MessageID function Moved all MessageID usage into funcs and onto base methods, to remove the closed loop of message IDs * Docs: Update guidance for message signatures
888 lines
28 KiB
Go
888 lines
28 KiB
Go
package bybit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/buger/jsonparser"
|
|
gws "github.com/gorilla/websocket"
|
|
"github.com/thrasher-corp/gocryptotrader/common"
|
|
"github.com/thrasher-corp/gocryptotrader/common/crypto"
|
|
"github.com/thrasher-corp/gocryptotrader/currency"
|
|
"github.com/thrasher-corp/gocryptotrader/encoding/json"
|
|
"github.com/thrasher-corp/gocryptotrader/exchange/websocket"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/account"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/fill"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/kline"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/order"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/request"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/subscription"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/ticker"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/trade"
|
|
"github.com/thrasher-corp/gocryptotrader/log"
|
|
)
|
|
|
|
const (
|
|
bybitWebsocketTimer = 20 * time.Second
|
|
|
|
// Public v5 channels
|
|
chanOrderbook = "orderbook"
|
|
chanPublicTrade = "publicTrade"
|
|
chanPublicTicker = "tickers"
|
|
chanKline = "kline"
|
|
chanLiquidation = "liquidation"
|
|
chanLeverageTokenKline = "kline_lt"
|
|
chanLeverageTokenTicker = "tickers_lt"
|
|
chanLeverageTokenNav = "lt"
|
|
|
|
// Private v5 channels
|
|
chanPositions = "position"
|
|
chanExecution = "execution"
|
|
chanOrder = "order"
|
|
chanWallet = "wallet"
|
|
chanGreeks = "greeks"
|
|
// TODO: Implement DCP (Disconnection Protect) subscription
|
|
|
|
spotPublic = "wss://stream.bybit.com/v5/public/spot"
|
|
linearPublic = "wss://stream.bybit.com/v5/public/linear" // USDT, USDC perpetual & USDC Futures
|
|
inversePublic = "wss://stream.bybit.com/v5/public/inverse" // Inverse contract
|
|
optionPublic = "wss://stream.bybit.com/v5/public/option" // USDC Option
|
|
|
|
// Main-net private
|
|
websocketPrivate = "wss://stream.bybit.com/v5/private"
|
|
websocketTrade = "wss://stream.bybit.com/v5/trade"
|
|
)
|
|
|
|
var defaultSubscriptions = subscription.List{
|
|
{Enabled: true, Asset: asset.Spot, Channel: subscription.TickerChannel},
|
|
{Enabled: true, Asset: asset.Spot, Channel: subscription.OrderbookChannel, Levels: 50},
|
|
{Enabled: true, Asset: asset.Spot, Channel: subscription.AllTradesChannel},
|
|
{Enabled: true, Asset: asset.Spot, Channel: subscription.CandlesChannel, Interval: kline.OneHour},
|
|
// Authenticated channels are currently being managed by the `generateAuthSubscriptions` method for the private connection
|
|
// TODO: expand subscription template generation to handle authenticated subscriptions across all assets
|
|
}
|
|
|
|
var subscriptionNames = map[string]string{
|
|
subscription.TickerChannel: chanPublicTicker,
|
|
subscription.OrderbookChannel: chanOrderbook,
|
|
subscription.AllTradesChannel: chanPublicTrade,
|
|
subscription.MyOrdersChannel: chanOrder,
|
|
subscription.MyWalletChannel: chanWallet,
|
|
subscription.MyTradesChannel: chanExecution,
|
|
subscription.CandlesChannel: chanKline,
|
|
}
|
|
|
|
var (
|
|
errUnhandledStreamData = errors.New("unhandled stream data")
|
|
errUnsupportedCategory = errors.New("unsupported category")
|
|
)
|
|
|
|
// WsConnect connects to a websocket feed
|
|
func (e *Exchange) WsConnect(ctx context.Context, conn websocket.Connection) error {
|
|
if err := conn.Dial(ctx, &gws.Dialer{}, http.Header{}); err != nil {
|
|
return err
|
|
}
|
|
conn.SetupPingHandler(request.Unset, websocket.PingHandler{
|
|
MessageType: gws.TextMessage,
|
|
Message: []byte(`{"op": "ping"}`),
|
|
Delay: bybitWebsocketTimer,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// WebsocketAuthenticatePrivateConnection sends an authentication message to the private websocket for inbound account
|
|
// data
|
|
func (e *Exchange) WebsocketAuthenticatePrivateConnection(ctx context.Context, conn websocket.Connection) error {
|
|
req, err := e.GetAuthenticationPayload(ctx, e.MessageID())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := conn.SendMessageReturnResponse(ctx, wsSubscriptionEPL, req.RequestID, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var response SubscriptionResponse
|
|
if err := json.Unmarshal(resp, &response); err != nil {
|
|
return err
|
|
}
|
|
if !response.Success {
|
|
return fmt.Errorf("%s with request ID %s msg: %s", response.Operation, response.RequestID, response.ReturnMessage)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WebsocketAuthenticateTradeConnection sends an authentication message to the private trade websocket for outbound
|
|
// account data
|
|
func (e *Exchange) WebsocketAuthenticateTradeConnection(ctx context.Context, conn websocket.Connection) error {
|
|
// request ID is not returned with the response, a workaround in the trade connection handler monitors the response
|
|
// for the operation type "auth", which is then set in the response match key.
|
|
req, err := e.GetAuthenticationPayload(ctx, "auth")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := conn.SendMessageReturnResponse(ctx, wsSubscriptionEPL, req.RequestID, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var response struct {
|
|
ReturnCode int64 `json:"retCode"`
|
|
ReturnMessage string `json:"retMsg"`
|
|
Operation string `json:"op"`
|
|
ConnectionID string `json:"connId"`
|
|
}
|
|
if err := json.Unmarshal(resp, &response); err != nil {
|
|
return err
|
|
}
|
|
if response.ReturnCode != 0 {
|
|
c, ok := retCode[response.ReturnCode]
|
|
if !ok {
|
|
c = "unknown return error code"
|
|
}
|
|
return fmt.Errorf("%s failed - code:%d [%v] msg:%s", response.Operation, response.ReturnCode, c, response.ReturnMessage)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetAuthenticationPayload returns the authentication payload for the websocket connection to upgrade the connection.
|
|
func (e *Exchange) GetAuthenticationPayload(ctx context.Context, requestID string) (*Authenticate, error) {
|
|
creds, err := e.GetCredentials(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
expires := time.Now().Add(time.Hour * 6).UnixMilli()
|
|
hmac, err := crypto.GetHMAC(crypto.HashSHA256, []byte("GET/realtime"+strconv.FormatInt(expires, 10)), []byte(creds.Secret))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Authenticate{
|
|
RequestID: requestID,
|
|
Operation: "auth",
|
|
Args: []any{creds.Key, expires, hex.EncodeToString(hmac)},
|
|
}, nil
|
|
}
|
|
|
|
func (e *Exchange) handleSubscriptions(_ websocket.Connection, operation string, subs subscription.List) (args []SubscriptionArgument, err error) {
|
|
subs, err = subs.ExpandTemplates(e)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
for _, list := range []subscription.List{subs.Public(), subs.Private()} {
|
|
for _, b := range common.Batch(list, 10) {
|
|
args = append(args, SubscriptionArgument{
|
|
auth: b[0].Authenticated,
|
|
Operation: operation,
|
|
RequestID: e.MessageID(),
|
|
Arguments: b.QualifiedChannels(),
|
|
associatedSubs: b,
|
|
})
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// generateSubscriptions generates default subscription
|
|
func (e *Exchange) generateSubscriptions() (subscription.List, error) {
|
|
return e.Features.Subscriptions.ExpandTemplates(e)
|
|
}
|
|
|
|
// GetSubscriptionTemplate returns a subscription channel template
|
|
func (e *Exchange) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) {
|
|
return template.New("master.tmpl").Funcs(template.FuncMap{
|
|
"channelName": channelName,
|
|
"isSymbolChannel": isSymbolChannel,
|
|
"intervalToString": intervalToString,
|
|
"getCategoryName": getCategoryName,
|
|
}).Parse(subTplText)
|
|
}
|
|
|
|
func (e *Exchange) wsHandleTradeData(conn websocket.Connection, respRaw []byte) error {
|
|
var response struct {
|
|
RequestID string `json:"reqId"`
|
|
Operation string `json:"op"`
|
|
}
|
|
if err := json.Unmarshal(respRaw, &response); err != nil {
|
|
return err
|
|
}
|
|
|
|
if response.RequestID != "" {
|
|
return conn.RequireMatchWithData(response.RequestID, respRaw)
|
|
}
|
|
|
|
switch response.Operation {
|
|
case "auth": // When authenticating the connection there is no request ID, so a static value is used.
|
|
return conn.RequireMatchWithData(response.Operation, respRaw)
|
|
case "pong":
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("%w for trade: %v", errUnhandledStreamData, string(respRaw))
|
|
}
|
|
}
|
|
|
|
func (e *Exchange) wsHandleData(conn websocket.Connection, assetType asset.Item, respRaw []byte) error {
|
|
var result WebsocketResponse
|
|
if err := json.Unmarshal(respRaw, &result); err != nil {
|
|
return err
|
|
}
|
|
if result.Topic == "" {
|
|
return e.handleNoTopicWebsocketResponse(conn, &result, respRaw)
|
|
}
|
|
topicSplit := strings.Split(result.Topic, ".")
|
|
switch topicSplit[0] {
|
|
case chanOrderbook:
|
|
return e.wsProcessOrderbook(assetType, &result)
|
|
case chanPublicTrade:
|
|
return e.wsProcessPublicTrade(assetType, &result)
|
|
case chanPublicTicker:
|
|
return e.wsProcessPublicTicker(assetType, &result)
|
|
case chanKline:
|
|
return e.wsProcessKline(assetType, &result, topicSplit)
|
|
case chanLiquidation:
|
|
return e.wsProcessLiquidation(&result)
|
|
case chanLeverageTokenKline:
|
|
return e.wsProcessLeverageTokenKline(assetType, &result, topicSplit)
|
|
case chanLeverageTokenTicker:
|
|
return e.wsProcessLeverageTokenTicker(assetType, &result)
|
|
case chanLeverageTokenNav:
|
|
return e.wsLeverageTokenNav(&result)
|
|
}
|
|
return fmt.Errorf("%w %s", errUnhandledStreamData, string(respRaw))
|
|
}
|
|
|
|
func (e *Exchange) wsHandleAuthenticatedData(ctx context.Context, conn websocket.Connection, respRaw []byte) error {
|
|
var result WebsocketResponse
|
|
if err := json.Unmarshal(respRaw, &result); err != nil {
|
|
return err
|
|
}
|
|
if result.Topic == "" {
|
|
return e.handleNoTopicWebsocketResponse(conn, &result, respRaw)
|
|
}
|
|
topicSplit := strings.Split(result.Topic, ".")
|
|
switch topicSplit[0] {
|
|
case chanPositions:
|
|
return e.wsProcessPosition(&result)
|
|
case chanExecution:
|
|
return e.wsProcessExecution(&result)
|
|
case chanOrder:
|
|
// Use first order's orderLinkId to match with an entire batch of order change requests
|
|
if id, err := jsonparser.GetString(respRaw, "data", "[0]", "orderLinkId"); err == nil {
|
|
if conn.IncomingWithData(id, respRaw) {
|
|
return nil // If the data has been routed, return
|
|
}
|
|
}
|
|
return e.wsProcessOrder(&result)
|
|
case chanWallet:
|
|
return e.wsProcessWalletPushData(ctx, respRaw)
|
|
case chanGreeks:
|
|
return e.wsProcessGreeks(respRaw)
|
|
}
|
|
return fmt.Errorf("%w %s", errUnhandledStreamData, string(respRaw))
|
|
}
|
|
|
|
func (e *Exchange) handleNoTopicWebsocketResponse(conn websocket.Connection, result *WebsocketResponse, respRaw []byte) error {
|
|
switch result.Operation {
|
|
case "subscribe", "unsubscribe", "auth":
|
|
if result.RequestID != "" {
|
|
return conn.RequireMatchWithData(result.RequestID, respRaw)
|
|
}
|
|
case "ping", "pong":
|
|
default:
|
|
e.Websocket.DataHandler <- websocket.UnhandledMessageWarning{Message: string(respRaw)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) wsProcessGreeks(resp []byte) error {
|
|
var result GreeksResponse
|
|
if err := json.Unmarshal(resp, &result); err != nil {
|
|
return err
|
|
}
|
|
e.Websocket.DataHandler <- &result
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) wsProcessWalletPushData(ctx context.Context, resp []byte) error {
|
|
var result WebsocketWallet
|
|
if err := json.Unmarshal(resp, &result); err != nil {
|
|
return err
|
|
}
|
|
creds, err := e.GetCredentials(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var changes []account.Change
|
|
for x := range result.Data {
|
|
for y := range result.Data[x].Coin {
|
|
changes = append(changes, account.Change{
|
|
AssetType: asset.Spot,
|
|
Balance: &account.Balance{
|
|
Currency: result.Data[x].Coin[y].Coin,
|
|
Total: result.Data[x].Coin[y].WalletBalance.Float64(),
|
|
Free: result.Data[x].Coin[y].WalletBalance.Float64(),
|
|
UpdatedAt: result.CreationTime.Time(),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- changes
|
|
return account.ProcessChange(e.Name, changes, creds)
|
|
}
|
|
|
|
// wsProcessOrder the order stream to see changes to your orders in real-time.
|
|
func (e *Exchange) wsProcessOrder(resp *WebsocketResponse) error {
|
|
var result []WebsocketOrderDetails
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
return err
|
|
}
|
|
execution := make([]order.Detail, len(result))
|
|
for x := range result {
|
|
cp, a, err := e.matchPairAssetFromResponse(result[x].Category, result[x].Symbol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
orderType, err := order.StringToOrderType(result[x].OrderType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tif, err := order.StringToTimeInForce(result[x].TimeInForce)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
execution[x] = order.Detail{
|
|
TimeInForce: tif,
|
|
Amount: result[x].Quantity.Float64(),
|
|
Exchange: e.Name,
|
|
OrderID: result[x].OrderID,
|
|
ClientOrderID: result[x].OrderLinkID,
|
|
Side: result[x].Side,
|
|
Type: orderType,
|
|
Pair: cp,
|
|
Cost: result[x].CumulativeExecutedQuantity.Float64() * result[x].AveragePrice.Float64(),
|
|
Fee: result[x].CumulativeExecutedFee.Float64(),
|
|
AssetType: a,
|
|
Status: StringToOrderStatus(result[x].OrderStatus),
|
|
Price: result[x].Price.Float64(),
|
|
ExecutedAmount: result[x].CumulativeExecutedQuantity.Float64(),
|
|
AverageExecutedPrice: result[x].AveragePrice.Float64(),
|
|
Date: result[x].CreatedTime.Time(),
|
|
LastUpdated: result[x].UpdatedTime.Time(),
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- execution
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) wsProcessExecution(resp *WebsocketResponse) error {
|
|
var result WsExecutions
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
return err
|
|
}
|
|
executions := make([]fill.Data, len(result))
|
|
for x := range result {
|
|
cp, a, err := e.matchPairAssetFromResponse(result[x].Category, result[x].Symbol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
side, err := order.StringToOrderSide(result[x].Side)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
executions[x] = fill.Data{
|
|
ID: result[x].ExecID,
|
|
Timestamp: result[x].ExecTime.Time(),
|
|
Exchange: e.Name,
|
|
AssetType: a,
|
|
CurrencyPair: cp,
|
|
Side: side,
|
|
OrderID: result[x].OrderID,
|
|
ClientOrderID: result[x].OrderLinkID,
|
|
Price: result[x].ExecPrice.Float64(),
|
|
Amount: result[x].ExecQty.Float64(),
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- executions
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) wsProcessPosition(resp *WebsocketResponse) error {
|
|
var result WsPositions
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
return err
|
|
}
|
|
e.Websocket.DataHandler <- result
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) wsLeverageTokenNav(resp *WebsocketResponse) error {
|
|
var result LTNav
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
return err
|
|
}
|
|
e.Websocket.DataHandler <- result
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) wsProcessLeverageTokenTicker(assetType asset.Item, resp *WebsocketResponse) error {
|
|
var result TickerWebsocket
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
return err
|
|
}
|
|
cp, err := e.MatchSymbolWithAvailablePairs(result.Symbol, assetType, hasPotentialDelimiter(assetType))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
e.Websocket.DataHandler <- &ticker.Price{
|
|
Last: result.LastPrice.Float64(),
|
|
High: result.HighPrice24H.Float64(),
|
|
Low: result.LowPrice24H.Float64(),
|
|
Pair: cp,
|
|
ExchangeName: e.Name,
|
|
AssetType: assetType,
|
|
LastUpdated: resp.PushTimestamp.Time(),
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) wsProcessLeverageTokenKline(assetType asset.Item, resp *WebsocketResponse, topicSplit []string) error {
|
|
var result LTKlines
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
return err
|
|
}
|
|
cp, err := e.MatchSymbolWithAvailablePairs(topicSplit[2], assetType, hasPotentialDelimiter(assetType))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ltKline := make([]websocket.KlineData, len(result))
|
|
for x := range result {
|
|
interval, err := stringToInterval(result[x].Interval)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ltKline[x] = websocket.KlineData{
|
|
Timestamp: result[x].Timestamp.Time(),
|
|
Pair: cp,
|
|
AssetType: assetType,
|
|
Exchange: e.Name,
|
|
StartTime: result[x].Start.Time(),
|
|
CloseTime: result[x].End.Time(),
|
|
Interval: interval.String(),
|
|
OpenPrice: result[x].Open.Float64(),
|
|
ClosePrice: result[x].Close.Float64(),
|
|
HighPrice: result[x].High.Float64(),
|
|
LowPrice: result[x].Low.Float64(),
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- result
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) wsProcessLiquidation(resp *WebsocketResponse) error {
|
|
var result WebsocketLiquidation
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
return err
|
|
}
|
|
e.Websocket.DataHandler <- result
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) wsProcessKline(assetType asset.Item, resp *WebsocketResponse, topicSplit []string) error {
|
|
var result WsKlines
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
return err
|
|
}
|
|
cp, err := e.MatchSymbolWithAvailablePairs(topicSplit[2], assetType, hasPotentialDelimiter(assetType))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
spotCandlesticks := make([]websocket.KlineData, len(result))
|
|
for x := range result {
|
|
interval, err := stringToInterval(result[x].Interval)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
spotCandlesticks[x] = websocket.KlineData{
|
|
Timestamp: result[x].Timestamp.Time(),
|
|
Pair: cp,
|
|
AssetType: assetType,
|
|
Exchange: e.Name,
|
|
StartTime: result[x].Start.Time(),
|
|
CloseTime: result[x].End.Time(),
|
|
Interval: interval.String(),
|
|
OpenPrice: result[x].Open.Float64(),
|
|
ClosePrice: result[x].Close.Float64(),
|
|
HighPrice: result[x].High.Float64(),
|
|
LowPrice: result[x].Low.Float64(),
|
|
Volume: result[x].Volume.Float64(),
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- spotCandlesticks
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) wsProcessPublicTicker(assetType asset.Item, resp *WebsocketResponse) error {
|
|
var tickResp TickerWebsocket
|
|
if err := json.Unmarshal(resp.Data, &tickResp); err != nil {
|
|
return err
|
|
}
|
|
|
|
p, err := e.MatchSymbolWithAvailablePairs(tickResp.Symbol, assetType, hasPotentialDelimiter(assetType))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tick := &ticker.Price{Pair: p, ExchangeName: e.Name, AssetType: assetType}
|
|
if resp.Type != "snapshot" {
|
|
// ticker updates may be partial, so we need to update the current ticker
|
|
tick, err = e.GetCachedTicker(p, assetType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
updateTicker(tick, &tickResp)
|
|
tick.LastUpdated = resp.PushTimestamp.Time()
|
|
if err := ticker.ProcessTicker(tick); err != nil {
|
|
return err
|
|
}
|
|
e.Websocket.DataHandler <- tick
|
|
return nil
|
|
}
|
|
|
|
func updateTicker(tick *ticker.Price, resp *TickerWebsocket) {
|
|
if resp.LastPrice.Float64() != 0 {
|
|
tick.Last = resp.LastPrice.Float64()
|
|
}
|
|
if resp.HighPrice24H.Float64() != 0 {
|
|
tick.High = resp.HighPrice24H.Float64()
|
|
}
|
|
if resp.LowPrice24H.Float64() != 0 {
|
|
tick.Low = resp.LowPrice24H.Float64()
|
|
}
|
|
if resp.Volume24H.Float64() != 0 {
|
|
tick.Volume = resp.Volume24H.Float64()
|
|
}
|
|
|
|
if tick.AssetType == asset.Spot {
|
|
return
|
|
}
|
|
|
|
if resp.MarkPrice.Float64() != 0 {
|
|
tick.MarkPrice = resp.MarkPrice.Float64()
|
|
}
|
|
if resp.IndexPrice.Float64() != 0 {
|
|
tick.IndexPrice = resp.IndexPrice.Float64()
|
|
}
|
|
if resp.OpenInterest.Float64() != 0 {
|
|
tick.OpenInterest = resp.OpenInterest.Float64()
|
|
}
|
|
|
|
switch tick.AssetType {
|
|
case asset.Options:
|
|
if resp.BidPrice.Float64() != 0 {
|
|
tick.Bid = resp.BidPrice.Float64()
|
|
}
|
|
if resp.BidSize.Float64() != 0 {
|
|
tick.BidSize = resp.BidSize.Float64()
|
|
}
|
|
if resp.AskPrice.Float64() != 0 {
|
|
tick.Ask = resp.AskPrice.Float64()
|
|
}
|
|
if resp.AskSize.Float64() != 0 {
|
|
tick.AskSize = resp.AskSize.Float64()
|
|
}
|
|
case asset.USDCMarginedFutures, asset.USDTMarginedFutures, asset.CoinMarginedFutures:
|
|
if resp.Bid1Price.Float64() != 0 {
|
|
tick.Bid = resp.Bid1Price.Float64()
|
|
}
|
|
if resp.Bid1Size.Float64() != 0 {
|
|
tick.BidSize = resp.Bid1Size.Float64()
|
|
}
|
|
if resp.Ask1Price.Float64() != 0 {
|
|
tick.Ask = resp.Ask1Price.Float64()
|
|
}
|
|
if resp.Ask1Size.Float64() != 0 {
|
|
tick.AskSize = resp.Ask1Size.Float64()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (e *Exchange) wsProcessPublicTrade(assetType asset.Item, resp *WebsocketResponse) error {
|
|
var result WebsocketPublicTrades
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
return err
|
|
}
|
|
tradeDatas := make([]trade.Data, len(result))
|
|
for x := range result {
|
|
cp, err := e.MatchSymbolWithAvailablePairs(result[x].Symbol, assetType, hasPotentialDelimiter(assetType))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
side, err := order.StringToOrderSide(result[x].Side)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tradeDatas[x] = trade.Data{
|
|
Timestamp: result[x].OrderFillTimestamp.Time(),
|
|
CurrencyPair: cp,
|
|
AssetType: assetType,
|
|
Exchange: e.Name,
|
|
Price: result[x].Price.Float64(),
|
|
Amount: result[x].Size.Float64(),
|
|
Side: side,
|
|
TID: result[x].TradeID,
|
|
}
|
|
}
|
|
return trade.AddTradesToBuffer(tradeDatas...)
|
|
}
|
|
|
|
func (e *Exchange) wsProcessOrderbook(assetType asset.Item, resp *WebsocketResponse) error {
|
|
var result WsOrderbookDetail
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
return err
|
|
}
|
|
|
|
cp, err := e.MatchSymbolWithAvailablePairs(result.Symbol, assetType, hasPotentialDelimiter(assetType))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if resp.Type == "snapshot" {
|
|
return e.Websocket.Orderbook.LoadSnapshot(&orderbook.Book{
|
|
Pair: cp,
|
|
Exchange: e.Name,
|
|
Asset: assetType,
|
|
LastUpdated: resp.OrderbookLastUpdated.Time(),
|
|
LastUpdateID: result.UpdateID,
|
|
LastPushed: resp.PushTimestamp.Time(),
|
|
Asks: result.Asks.Levels(),
|
|
Bids: result.Bids.Levels(),
|
|
})
|
|
}
|
|
return e.Websocket.Orderbook.Update(&orderbook.Update{
|
|
Pair: cp,
|
|
Asks: result.Asks.Levels(),
|
|
Bids: result.Bids.Levels(),
|
|
Asset: assetType,
|
|
UpdateID: result.UpdateID,
|
|
UpdateTime: resp.OrderbookLastUpdated.Time(),
|
|
LastPushed: resp.PushTimestamp.Time(),
|
|
AllowEmpty: true,
|
|
})
|
|
}
|
|
|
|
// channelName converts global channel names to exchange specific names
|
|
func channelName(s *subscription.Subscription) string {
|
|
if name, ok := subscriptionNames[s.Channel]; ok {
|
|
return name
|
|
}
|
|
return s.Channel
|
|
}
|
|
|
|
// isSymbolChannel returns whether the channel accepts a symbol parameter
|
|
func isSymbolChannel(name string) bool {
|
|
switch name {
|
|
case chanPositions, chanExecution, chanOrder, chanWallet:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
const subTplText = `
|
|
{{ with $name := channelName $.S }}
|
|
{{- range $asset, $pairs := $.AssetPairs }}
|
|
{{- if isSymbolChannel $name }}
|
|
{{- range $p := $pairs }}
|
|
{{- $name -}} .
|
|
{{- if eq $name "orderbook" -}} {{- $.S.Levels -}} . {{- end }}
|
|
{{- if eq $name "kline" -}} {{- intervalToString $.S.Interval -}} . {{- end }}
|
|
{{- $p }}
|
|
{{- $.PairSeparator }}
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- $.AssetSeparator }}
|
|
{{- end }}
|
|
`
|
|
|
|
// hasPotentialDelimiter returns if the asset has a potential delimiter on the pairs being returned.
|
|
func hasPotentialDelimiter(a asset.Item) bool {
|
|
return a == asset.Options || a == asset.USDCMarginedFutures
|
|
}
|
|
|
|
// TODO: Remove this function when template expansion is across all assets
|
|
func (e *Exchange) submitDirectSubscription(ctx context.Context, conn websocket.Connection, a asset.Item, operation string, channelsToSubscribe subscription.List) error {
|
|
payloads, err := e.directSubscriptionPayload(a, operation, channelsToSubscribe)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
op := e.Websocket.AddSubscriptions
|
|
if operation == "unsubscribe" {
|
|
op = e.Websocket.RemoveSubscriptions
|
|
}
|
|
|
|
for _, payload := range payloads {
|
|
if a == asset.Options {
|
|
// The options connection does not send the subscription request id back with the subscription notification payload
|
|
// therefore the code doesn't wait for the response to check whether the subscription is successful or not.
|
|
if err := conn.SendJSONMessage(ctx, wsSubscriptionEPL, payload); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
response, err := conn.SendMessageReturnResponse(ctx, wsSubscriptionEPL, payload.RequestID, payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var resp SubscriptionResponse
|
|
if err := json.Unmarshal(response, &resp); err != nil {
|
|
return err
|
|
}
|
|
if !resp.Success {
|
|
return fmt.Errorf("%s with request ID %s msg: %s", resp.Operation, resp.RequestID, resp.ReturnMessage)
|
|
}
|
|
}
|
|
if err := op(conn, payload.associatedSubs...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TODO: Remove this function when template expansion is across all assets
|
|
func (e *Exchange) directSubscriptionPayload(assetType asset.Item, operation string, channelsToSubscribe subscription.List) ([]SubscriptionArgument, error) {
|
|
var args []SubscriptionArgument
|
|
arg := SubscriptionArgument{
|
|
Operation: operation,
|
|
RequestID: e.MessageID(),
|
|
Arguments: []string{},
|
|
}
|
|
authArg := SubscriptionArgument{
|
|
auth: true,
|
|
Operation: operation,
|
|
RequestID: e.MessageID(),
|
|
Arguments: []string{},
|
|
}
|
|
|
|
chanMap := map[string]bool{}
|
|
pairFmt, err := e.GetPairFormat(assetType, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, s := range channelsToSubscribe {
|
|
var pair currency.Pair
|
|
if len(s.Pairs) > 1 {
|
|
return nil, subscription.ErrNotSinglePair
|
|
}
|
|
if len(s.Pairs) == 1 {
|
|
pair = s.Pairs[0]
|
|
}
|
|
switch s.Channel {
|
|
case chanOrderbook:
|
|
arg.Arguments = append(arg.Arguments, fmt.Sprintf("%s.%d.%s", s.Channel, 50, pairFmt.Format(pair)))
|
|
arg.associatedSubs = append(arg.associatedSubs, s)
|
|
case chanPublicTrade, chanPublicTicker, chanLiquidation, chanLeverageTokenTicker, chanLeverageTokenNav:
|
|
arg.Arguments = append(arg.Arguments, s.Channel+"."+pairFmt.Format(pair))
|
|
arg.associatedSubs = append(arg.associatedSubs, s)
|
|
case chanKline, chanLeverageTokenKline:
|
|
interval, err := intervalToString(kline.FiveMin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
arg.Arguments = append(arg.Arguments, s.Channel+"."+interval+"."+pairFmt.Format(pair))
|
|
arg.associatedSubs = append(arg.associatedSubs, s)
|
|
case chanPositions, chanExecution, chanOrder, chanWallet, chanGreeks:
|
|
if chanMap[s.Channel] {
|
|
continue
|
|
}
|
|
authArg.Arguments = append(authArg.Arguments, s.Channel)
|
|
// add channel name to map so we only subscribe to channel once
|
|
chanMap[s.Channel] = true
|
|
authArg.associatedSubs = append(authArg.associatedSubs, s)
|
|
}
|
|
|
|
if len(arg.Arguments) >= 10 {
|
|
args = append(args, arg)
|
|
arg = SubscriptionArgument{
|
|
Operation: operation,
|
|
RequestID: e.MessageID(),
|
|
Arguments: []string{},
|
|
}
|
|
}
|
|
}
|
|
if len(arg.Arguments) != 0 {
|
|
args = append(args, arg)
|
|
}
|
|
if len(authArg.Arguments) != 0 {
|
|
args = append(args, authArg)
|
|
}
|
|
return args, nil
|
|
}
|
|
|
|
// generateAuthSubscriptions generates default subscription for the dedicated auth websocket connection. These are
|
|
// agnostic to the asset type and pair as all account level data will be routed through this connection.
|
|
// TODO: Remove this function when template expansion is across all assets
|
|
func (e *Exchange) generateAuthSubscriptions() (subscription.List, error) {
|
|
if !e.Websocket.CanUseAuthenticatedEndpoints() {
|
|
return nil, nil
|
|
}
|
|
|
|
for _, configSub := range e.Config.Features.Subscriptions.Enabled() {
|
|
if configSub.Authenticated {
|
|
log.Warnf(log.WebsocketMgr, "%s has an authenticated subscription %q in config which is not supported. Please remove.", e.Name, configSub.Channel)
|
|
configSub.Enabled = false
|
|
}
|
|
}
|
|
|
|
var subscriptions subscription.List
|
|
// TODO: Implement DCP (Disconnection Protect) subscription
|
|
for _, channel := range []string{chanPositions, chanExecution, chanOrder, chanWallet} {
|
|
subscriptions = append(subscriptions, &subscription.Subscription{Channel: channel, Asset: asset.All})
|
|
}
|
|
return subscriptions, nil
|
|
}
|
|
|
|
func (e *Exchange) authSubscribe(ctx context.Context, conn websocket.Connection, channelSubscriptions subscription.List) error {
|
|
return e.submitDirectSubscription(ctx, conn, asset.Spot, "subscribe", channelSubscriptions)
|
|
}
|
|
|
|
func (e *Exchange) authUnsubscribe(ctx context.Context, conn websocket.Connection, channelSubscriptions subscription.List) error {
|
|
return e.submitDirectSubscription(ctx, conn, asset.Spot, "unsubscribe", channelSubscriptions)
|
|
}
|
|
|
|
// matchPairAssetFromResponse returns the currency pair and asset type based on the category and symbol. Used with a dedicated
|
|
// auth connection where multiple asset type changes are piped through a single connection.
|
|
func (e *Exchange) matchPairAssetFromResponse(category, symbol string) (currency.Pair, asset.Item, error) {
|
|
assets := make([]asset.Item, 0, 2)
|
|
switch category {
|
|
case cSpot:
|
|
assets = append(assets, asset.Spot)
|
|
case cInverse:
|
|
assets = append(assets, asset.CoinMarginedFutures)
|
|
case cLinear:
|
|
assets = append(assets, asset.USDTMarginedFutures, asset.USDCMarginedFutures)
|
|
case cOption:
|
|
assets = append(assets, asset.Options)
|
|
default:
|
|
return currency.EMPTYPAIR, 0, fmt.Errorf("incoming symbol %q %w: %q", symbol, errUnsupportedCategory, category)
|
|
}
|
|
for _, a := range assets {
|
|
cp, err := e.MatchSymbolWithAvailablePairs(symbol, a, hasPotentialDelimiter(a))
|
|
if err != nil {
|
|
if !errors.Is(err, currency.ErrPairNotFound) {
|
|
return currency.EMPTYPAIR, 0, fmt.Errorf("%w for symbol %q: %q", err, category, symbol)
|
|
}
|
|
continue
|
|
}
|
|
return cp, a, nil
|
|
}
|
|
return currency.EMPTYPAIR, 0, currency.ErrPairNotFound
|
|
}
|