Files
gocryptotrader/exchanges/bitmex/bitmex_websocket.go
Ryan O'Hara-Reid c892f492a9 buffer/orderbook: shift orderbook update logic from buffer package to orderbook package (#1908)
* buffer/orderbook: shift orderbook update logic from buffer package to orderbook package

* Update exchanges/orderbook/depth.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* linter: fixes

* spelling: fix

* samboss: add in some todos

* sammy nit: add unlock on error

* sammy nits: rm ptr to slice field buffer in orderbookHolder

* sammy nits: Add more coverage bro

* sammy nits: even more coverage

* gk: nits on commentary

* gk: nits change sort.Slice to slices.SortFunc

* gk: fix commentary on buffer clearing

* gk: nits fin

* linter: fix

* Update exchange/websocket/buffer/buffer.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchange/websocket/buffer/buffer.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/orderbook/tranches.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/orderbook/orderbook.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchange/websocket/buffer/buffer_test.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchange/websocket/buffer/buffer_test.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/orderbook/incremental_updates.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* gk: refresh action types and names

* gk nits: consolidate error vars and naming

* gk nits: more name changes

* gk nits; buffer tests update

* gk nits: error var names change

* linter: FIX

* it gets inlined but there is an alloc

* rn field in TODO

* Update exchanges/binance/binance_websocket.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* Update exchanges/binance/binance_websocket.go

Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>

* orderbook: shift verify/validate funcs to validate.go and rn Verify() -> Validate()

* orderbook: validate even in presence of checksum and allow cowboy mode

* buffer; fix test

* kraken: fix futures orderbook by reversing incoming bids

* okx: change default spread pair

* Update exchanges/orderbook/validate.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/orderbook/validate.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/orderbook/validate.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/orderbook/validate.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* Update exchanges/orderbook/validate.go

Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>

* gk: initial nits

* rn fields V(v)erifyorderbook to V(v)alidateOrderbook

* buffer/orderbook: nilguard in validate and change method receiver w -> o

---------

Co-authored-by: Ryan O'Hara-Reid <ryan.oharareid@thrasher.io>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Gareth Kirwan <gbjkirwan@gmail.com>
Co-authored-by: Adrian Gallagher <adrian.gallagher@thrasher.io>
2025-06-18 16:19:58 +10:00

653 lines
19 KiB
Go

package bitmex
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/asset"
"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/trade"
"github.com/thrasher-corp/gocryptotrader/log"
)
const (
bitmexWSURL = "wss://ws.bitmex.com/realtime"
// Public Subscription Channels
bitmexWSAnnouncement = "announcement"
bitmexWSChat = "chat"
bitmexWSConnected = "connected"
bitmexWSFunding = "funding"
bitmexWSInstrument = "instrument"
bitmexWSInsurance = "insurance"
bitmexWSLiquidation = "liquidation"
bitmexWSOrderbookL2 = "orderBookL2"
bitmexWSOrderbookL225 = "orderBookL2_25"
bitmexWSOrderbookL10 = "orderBook10"
bitmexWSPublicNotifications = "publicNotifications"
bitmexWSQuote = "quote"
bitmexWSQuote1m = "quoteBin1m"
bitmexWSQuote5m = "quoteBin5m"
bitmexWSQuote1h = "quoteBin1h"
bitmexWSQuote1d = "quoteBin1d"
bitmexWSSettlement = "settlement"
bitmexWSTrade = "trade"
bitmexWSTrade1m = "tradeBin1m"
bitmexWSTrade5m = "tradeBin5m"
bitmexWSTrade1h = "tradeBin1h"
bitmexWSTrade1d = "tradeBin1d"
// Authenticated Subscription Channels
bitmexWSAffiliate = "affiliate"
bitmexWSExecution = "execution"
bitmexWSOrder = "order"
bitmexWSMargin = "margin"
bitmexWSPosition = "position"
bitmexWSPrivateNotifications = "privateNotifications"
bitmexWSTransact = "transact"
bitmexWSWallet = "wallet"
bitmexActionInitialData = "partial"
bitmexActionInsertData = "insert"
bitmexActionDeleteData = "delete"
bitmexActionUpdateData = "update"
)
var defaultSubscriptions = subscription.List{
{Enabled: true, Channel: bitmexWSOrderbookL2, Asset: asset.All},
{Enabled: true, Channel: bitmexWSTrade, Asset: asset.All},
{Enabled: true, Channel: bitmexWSAffiliate, Authenticated: true},
{Enabled: true, Channel: bitmexWSOrder, Authenticated: true},
{Enabled: true, Channel: bitmexWSMargin, Authenticated: true},
{Enabled: true, Channel: bitmexWSTransact, Authenticated: true},
{Enabled: true, Channel: bitmexWSWallet, Authenticated: true},
{Enabled: true, Channel: bitmexWSExecution, Authenticated: true, Asset: asset.PerpetualContract},
{Enabled: true, Channel: bitmexWSPosition, Authenticated: true, Asset: asset.PerpetualContract},
}
// WsConnect initiates a new websocket connection
func (b *Bitmex) WsConnect() error {
if !b.Websocket.IsEnabled() || !b.IsEnabled() {
return websocket.ErrWebsocketNotEnabled
}
ctx := context.TODO()
var dialer gws.Dialer
if err := b.Websocket.Conn.Dial(ctx, &dialer, http.Header{}); err != nil {
return err
}
b.Websocket.Wg.Add(1)
go b.wsReadData()
if b.Websocket.CanUseAuthenticatedEndpoints() {
if err := b.websocketSendAuth(ctx); err != nil {
b.Websocket.SetCanUseAuthenticatedEndpoints(false)
log.Errorf(log.ExchangeSys, "%v - authentication failed: %v\n", b.Name, err)
}
}
return nil
}
const (
wsSubscribeOp = "subscribe"
wsUnsubscribeOp = "unsubscribe"
)
// wsReadData receives and passes on websocket messages for processing
func (b *Bitmex) wsReadData() {
defer b.Websocket.Wg.Done()
for {
resp := b.Websocket.Conn.ReadMessage()
if resp.Raw == nil {
return
}
err := b.wsHandleData(resp.Raw)
if err != nil {
b.Websocket.DataHandler <- err
}
}
}
func (b *Bitmex) wsHandleData(respRaw []byte) error {
// We don't need to know about errors, since we're looking optimistically into the json
op, _ := jsonparser.GetString(respRaw, "request", "op")
errMsg, _ := jsonparser.GetString(respRaw, "error")
success, _ := jsonparser.GetBoolean(respRaw, "success")
version, _ := jsonparser.GetString(respRaw, "version")
switch {
case version != "":
var welcomeResp WebsocketWelcome
if err := json.Unmarshal(respRaw, &welcomeResp); err != nil {
return err
}
if b.Verbose {
log.Debugf(log.ExchangeSys, "%s successfully connected to websocket API at time: %s Limit: %d", b.Name, welcomeResp.Timestamp, welcomeResp.Limit.Remaining)
}
return nil
case errMsg != "", success:
var req any
if op == "authKeyExpires" {
req = op
} else {
reqBytes, _, _, err := jsonparser.Get(respRaw, "request")
if err != nil {
return err
}
req = string(reqBytes)
}
if err := b.Websocket.Match.RequireMatchWithData(req, respRaw); err != nil {
return fmt.Errorf("%w: %s", err, op)
}
return nil
}
tableName, err := jsonparser.GetString(respRaw, "table")
if err != nil {
// Anything that's not a table isn't expected
return fmt.Errorf("unknown message format: %s", respRaw)
}
switch tableName {
case bitmexWSOrderbookL2, bitmexWSOrderbookL225, bitmexWSOrderbookL10:
var orderbooks OrderBookData
if err := json.Unmarshal(respRaw, &orderbooks); err != nil {
return err
}
if len(orderbooks.Data) == 0 {
return fmt.Errorf("empty orderbook data received: %s", respRaw)
}
pair, a, err := b.GetPairAndAssetTypeRequestFormatted(orderbooks.Data[0].Symbol)
if err != nil {
return err
}
err = b.processOrderbook(orderbooks.Data, orderbooks.Action, pair, a)
if err != nil {
return err
}
case bitmexWSTrade:
return b.handleWsTrades(respRaw)
case bitmexWSAnnouncement:
var announcement AnnouncementData
if err := json.Unmarshal(respRaw, &announcement); err != nil {
return err
}
if announcement.Action == bitmexActionInitialData {
return nil
}
b.Websocket.DataHandler <- announcement.Data
case bitmexWSAffiliate:
var response WsAffiliateResponse
if err := json.Unmarshal(respRaw, &response); err != nil {
return err
}
b.Websocket.DataHandler <- response
case bitmexWSInstrument:
// ticker
case bitmexWSExecution:
// trades of an order
var response WsExecutionResponse
if err := json.Unmarshal(respRaw, &response); err != nil {
return err
}
for i := range response.Data {
p, a, err := b.GetPairAndAssetTypeRequestFormatted(response.Data[i].Symbol)
if err != nil {
return err
}
oStatus, err := order.StringToOrderStatus(response.Data[i].OrdStatus)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: response.Data[i].OrderID,
Err: err,
}
}
oSide, err := order.StringToOrderSide(response.Data[i].Side)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: response.Data[i].OrderID,
Err: err,
}
}
b.Websocket.DataHandler <- &order.Detail{
Exchange: b.Name,
OrderID: response.Data[i].OrderID,
AccountID: strconv.FormatInt(response.Data[i].Account, 10),
AssetType: a,
Pair: p,
Status: oStatus,
Trades: []order.TradeHistory{
{
Price: response.Data[i].Price,
Amount: response.Data[i].OrderQuantity,
Exchange: b.Name,
TID: response.Data[i].ExecID,
Side: oSide,
Timestamp: response.Data[i].Timestamp,
IsMaker: false,
},
},
}
}
case bitmexWSOrder:
var response WsOrderResponse
if err := json.Unmarshal(respRaw, &response); err != nil {
return err
}
switch response.Action {
case "update", "insert":
for x := range response.Data {
p, a, err := b.GetRequestFormattedPairAndAssetType(response.Data[x].Symbol)
if err != nil {
return err
}
oSide, err := order.StringToOrderSide(response.Data[x].Side)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: response.Data[x].OrderID,
Err: err,
}
}
oType, err := order.StringToOrderType(response.Data[x].OrderType)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: response.Data[x].OrderID,
Err: err,
}
}
oStatus, err := order.StringToOrderStatus(response.Data[x].OrderStatus)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: response.Data[x].OrderID,
Err: err,
}
}
b.Websocket.DataHandler <- &order.Detail{
Price: response.Data[x].Price,
Amount: response.Data[x].OrderQuantity,
Exchange: b.Name,
OrderID: response.Data[x].OrderID,
AccountID: strconv.FormatInt(response.Data[x].Account, 10),
Type: oType,
Side: oSide,
Status: oStatus,
AssetType: a,
Date: response.Data[x].TransactTime,
Pair: p,
}
}
case "delete":
for x := range response.Data {
p, a, err := b.GetRequestFormattedPairAndAssetType(response.Data[x].Symbol)
if err != nil {
return err
}
var oSide order.Side
oSide, err = order.StringToOrderSide(response.Data[x].Side)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: response.Data[x].OrderID,
Err: err,
}
}
var oType order.Type
oType, err = order.StringToOrderType(response.Data[x].OrderType)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: response.Data[x].OrderID,
Err: err,
}
}
var oStatus order.Status
oStatus, err = order.StringToOrderStatus(response.Data[x].OrderStatus)
if err != nil {
b.Websocket.DataHandler <- order.ClassificationError{
Exchange: b.Name,
OrderID: response.Data[x].OrderID,
Err: err,
}
}
b.Websocket.DataHandler <- &order.Detail{
Price: response.Data[x].Price,
Amount: response.Data[x].OrderQuantity,
Exchange: b.Name,
OrderID: response.Data[x].OrderID,
AccountID: strconv.FormatInt(response.Data[x].Account, 10),
Type: oType,
Side: oSide,
Status: oStatus,
AssetType: a,
Date: response.Data[x].TransactTime,
Pair: p,
}
}
default:
b.Websocket.DataHandler <- fmt.Errorf("%s - Unsupported order update %+v", b.Name, response)
}
case bitmexWSMargin:
var response WsMarginResponse
if err := json.Unmarshal(respRaw, &response); err != nil {
return err
}
b.Websocket.DataHandler <- response
case bitmexWSPosition:
var response WsPositionResponse
if err := json.Unmarshal(respRaw, &response); err != nil {
return err
}
case bitmexWSPrivateNotifications:
var response WsPrivateNotificationsResponse
if err := json.Unmarshal(respRaw, &response); err != nil {
return err
}
b.Websocket.DataHandler <- response
case bitmexWSTransact:
var response WsTransactResponse
if err := json.Unmarshal(respRaw, &response); err != nil {
return err
}
b.Websocket.DataHandler <- response
case bitmexWSWallet:
var response WsWalletResponse
if err := json.Unmarshal(respRaw, &response); err != nil {
return err
}
b.Websocket.DataHandler <- response
default:
b.Websocket.DataHandler <- websocket.UnhandledMessageWarning{Message: b.Name + websocket.UnhandledMessage + string(respRaw)}
}
return nil
}
// ProcessOrderbook processes orderbook updates
func (b *Bitmex) processOrderbook(data []OrderBookL2, action string, p currency.Pair, a asset.Item) error {
if len(data) < 1 {
return errors.New("no orderbook data")
}
switch action {
case bitmexActionInitialData:
book := orderbook.Book{
Asks: make(orderbook.Levels, 0, len(data)),
Bids: make(orderbook.Levels, 0, len(data)),
}
for i := range data {
item := orderbook.Level{
Price: data[i].Price,
Amount: float64(data[i].Size),
ID: data[i].ID,
}
switch {
case strings.EqualFold(data[i].Side, order.Sell.String()):
book.Asks = append(book.Asks, item)
case strings.EqualFold(data[i].Side, order.Buy.String()):
book.Bids = append(book.Bids, item)
default:
return fmt.Errorf("could not process websocket orderbook update, order side could not be matched for %s",
data[i].Side)
}
}
book.Asks.Reverse() // Reverse asks for correct alignment
book.Asset = a
book.Pair = p
book.Exchange = b.Name
book.ValidateOrderbook = b.ValidateOrderbook
book.LastUpdated = data[0].Timestamp
err := b.Websocket.Orderbook.LoadSnapshot(&book)
if err != nil {
return fmt.Errorf("process orderbook error - %s",
err)
}
default:
updateAction, err := b.GetActionFromString(action)
if err != nil {
return err
}
asks := make([]orderbook.Level, 0, len(data))
bids := make([]orderbook.Level, 0, len(data))
for i := range data {
nItem := orderbook.Level{
Price: data[i].Price,
Amount: float64(data[i].Size),
ID: data[i].ID,
}
if strings.EqualFold(data[i].Side, "Sell") {
asks = append(asks, nItem)
continue
}
bids = append(bids, nItem)
}
err = b.Websocket.Orderbook.Update(&orderbook.Update{
Bids: bids,
Asks: asks,
Pair: p,
Asset: a,
Action: updateAction,
UpdateTime: data[0].Timestamp,
})
if err != nil {
return err
}
}
return nil
}
func (b *Bitmex) handleWsTrades(msg []byte) error {
if !b.IsSaveTradeDataEnabled() {
return nil
}
var tradeHolder TradeData
if err := json.Unmarshal(msg, &tradeHolder); err != nil {
return err
}
trades := make([]trade.Data, 0, len(tradeHolder.Data))
for _, t := range tradeHolder.Data {
if t.Size == 0 {
// Indices (symbols starting with .) post trades at intervals to the trade feed
// These have a size of 0 and are used only to indicate a changing price
continue
}
p, a, err := b.GetPairAndAssetTypeRequestFormatted(t.Symbol)
if err != nil {
return err
}
oSide, err := order.StringToOrderSide(t.Side)
if err != nil {
return err
}
trades = append(trades, trade.Data{
TID: t.TrdMatchID,
Exchange: b.Name,
CurrencyPair: p,
AssetType: a,
Side: oSide,
Price: t.Price,
Amount: float64(t.Size),
Timestamp: t.Timestamp,
})
}
return b.AddTradesToBuffer(trades...)
}
// generateSubscriptions returns a list of subscriptions from the configured subscriptions feature
func (b *Bitmex) generateSubscriptions() (subscription.List, error) {
return b.Features.Subscriptions.ExpandTemplates(b)
}
// GetSubscriptionTemplate returns a subscription channel template
func (b *Bitmex) GetSubscriptionTemplate(_ *subscription.Subscription) (*template.Template, error) {
return template.New("master.tmpl").Funcs(template.FuncMap{
"channelName": channelName,
}).Parse(subTplText)
}
// Subscribe subscribes to a websocket channel
func (b *Bitmex) Subscribe(subs subscription.List) error {
ctx := context.TODO()
return common.AppendError(
b.ParallelChanOp(ctx, subs.Public(), func(ctx context.Context, l subscription.List) error { return b.manageSubs(ctx, wsSubscribeOp, l) }, len(subs)),
b.ParallelChanOp(ctx, subs.Private(), func(ctx context.Context, l subscription.List) error { return b.manageSubs(ctx, wsSubscribeOp, l) }, len(subs)),
)
}
// Unsubscribe sends a websocket message to stop receiving data from the channel
func (b *Bitmex) Unsubscribe(subs subscription.List) error {
ctx := context.TODO()
return common.AppendError(
b.ParallelChanOp(ctx, subs.Public(), func(ctx context.Context, l subscription.List) error { return b.manageSubs(ctx, wsUnsubscribeOp, l) }, len(subs)),
b.ParallelChanOp(ctx, subs.Private(), func(ctx context.Context, l subscription.List) error { return b.manageSubs(ctx, wsUnsubscribeOp, l) }, len(subs)),
)
}
func (b *Bitmex) manageSubs(ctx context.Context, op string, subs subscription.List) error {
req := WebsocketRequest{
Command: op,
}
exp := map[string]*subscription.Subscription{}
for _, s := range subs {
req.Arguments = append(req.Arguments, s.QualifiedChannel)
exp[s.QualifiedChannel] = s
}
reqJSON, err := json.Marshal(req)
if err != nil {
return err
}
resps, errs := b.Websocket.Conn.SendMessageReturnResponses(ctx, request.Unset, string(reqJSON), req, len(subs))
for _, resp := range resps {
if errMsg, _ := jsonparser.GetString(resp, "error"); errMsg != "" {
errs = common.AppendError(errs, errors.New(errMsg))
} else {
chanName, err := jsonparser.GetString(resp, op)
if err != nil {
errs = common.AppendError(errs, err)
}
s, ok := exp[chanName]
if !ok {
errs = common.AppendError(errs, fmt.Errorf("%w: %s", subscription.ErrNotFound, chanName))
} else {
if op == wsSubscribeOp {
errs = common.AppendError(errs, b.Websocket.AddSuccessfulSubscriptions(b.Websocket.Conn, s))
} else {
errs = common.AppendError(errs, b.Websocket.RemoveSubscriptions(b.Websocket.Conn, s))
}
}
}
}
return errs
}
// WebsocketSendAuth sends an authenticated subscription
func (b *Bitmex) websocketSendAuth(ctx context.Context) error {
creds, err := b.GetCredentials(ctx)
if err != nil {
return err
}
timestamp := time.Now().Add(time.Hour * 1).Unix()
timestampStr := strconv.FormatInt(timestamp, 10)
hmac, err := crypto.GetHMAC(crypto.HashSHA256, []byte("GET/realtime"+timestampStr), []byte(creds.Secret))
if err != nil {
return err
}
req := WebsocketRequest{
Command: "authKeyExpires",
Arguments: []any{creds.Key, timestamp, hex.EncodeToString(hmac)},
}
resp, err := b.Websocket.Conn.SendMessageReturnResponse(ctx, request.Unset, req.Command, req)
if err != nil {
return err
}
if errMsg, _ := jsonparser.GetString(resp, "error"); errMsg != "" {
return errors.New(errMsg)
}
if b.Verbose {
log.Debugf(log.ExchangeSys, "%s websocket: Successfully authenticated websocket connection", b.Name)
}
return nil
}
// GetActionFromString matches a string action to an internal action.
func (b *Bitmex) GetActionFromString(s string) (orderbook.ActionType, error) {
switch s {
case "update":
return orderbook.UpdateAction, nil
case "delete":
return orderbook.DeleteAction, nil
case "insert":
return orderbook.InsertAction, nil
case "update/insert":
return orderbook.UpdateOrInsertAction, nil
}
return 0, fmt.Errorf("%s %w", s, orderbook.ErrInvalidAction)
}
// channelName returns the correct channel name for the asset
func channelName(s *subscription.Subscription, a asset.Item) string {
switch s.Channel {
case subscription.OrderbookChannel:
if a == asset.Index {
return "" // There are no L2 orderbook for index assets
}
return bitmexWSOrderbookL2
case subscription.AllTradesChannel:
return bitmexWSTrade
}
return s.Channel
}
const subTplText = `
{{- if $.S.Asset }}
{{- range $asset, $pairs := $.AssetPairs }}
{{- with $name := channelName $.S $asset }}
{{- range $i, $p := $pairs }}
{{- $name -}} : {{- $p }}
{{- $.PairSeparator }}
{{- end }}
{{- end }}
{{- $.AssetSeparator }}
{{- end }}
{{- else }}
{{- channelName $.S $.S.Asset }}
{{- end }}
`