mirror of
https://github.com/d0zingcat/gocryptotrader.git
synced 2026-05-17 15:09:59 +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
1000 lines
31 KiB
Go
1000 lines
31 KiB
Go
package gateio
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha512"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"text/template"
|
|
"time"
|
|
|
|
"github.com/Masterminds/sprig/v3"
|
|
"github.com/buger/jsonparser"
|
|
gws "github.com/gorilla/websocket"
|
|
"github.com/thrasher-corp/gocryptotrader/common"
|
|
"github.com/thrasher-corp/gocryptotrader/common/key"
|
|
"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/types"
|
|
)
|
|
|
|
const (
|
|
gateioWebsocketEndpoint = "wss://api.gateio.ws/ws/v4/"
|
|
|
|
spotPingChannel = "spot.ping"
|
|
spotPongChannel = "spot.pong"
|
|
spotTickerChannel = "spot.tickers"
|
|
spotTradesChannel = "spot.trades"
|
|
spotCandlesticksChannel = "spot.candlesticks"
|
|
spotOrderbookTickerChannel = "spot.book_ticker" // Best bid or ask price
|
|
spotOrderbookUpdateChannel = "spot.order_book_update" // Changed order book levels
|
|
spotOrderbookChannel = "spot.order_book" // Limited-Level Full Order Book Snapshot
|
|
spotOrdersChannel = "spot.orders"
|
|
spotUserTradesChannel = "spot.usertrades"
|
|
spotBalancesChannel = "spot.balances"
|
|
marginBalancesChannel = "spot.margin_balances"
|
|
spotFundingBalanceChannel = "spot.funding_balances"
|
|
crossMarginBalanceChannel = "spot.cross_balances"
|
|
crossMarginLoanChannel = "spot.cross_loan"
|
|
|
|
subscribeEvent = "subscribe"
|
|
unsubscribeEvent = "unsubscribe"
|
|
)
|
|
|
|
var defaultSubscriptions = subscription.List{
|
|
{Enabled: true, Channel: subscription.TickerChannel, Asset: asset.Spot},
|
|
{Enabled: true, Channel: subscription.CandlesChannel, Asset: asset.Spot, Interval: kline.FiveMin},
|
|
{Enabled: true, Channel: subscription.OrderbookChannel, Asset: asset.Spot, Interval: kline.HundredMilliseconds},
|
|
{Enabled: false, Channel: spotOrderbookTickerChannel, Asset: asset.Spot, Interval: kline.TenMilliseconds, Levels: 1},
|
|
{Enabled: false, Channel: spotOrderbookChannel, Asset: asset.Spot, Interval: kline.HundredMilliseconds, Levels: 100},
|
|
{Enabled: true, Channel: spotBalancesChannel, Asset: asset.Spot, Authenticated: true},
|
|
{Enabled: true, Channel: crossMarginBalanceChannel, Asset: asset.CrossMargin, Authenticated: true},
|
|
{Enabled: true, Channel: marginBalancesChannel, Asset: asset.Margin, Authenticated: true},
|
|
{Enabled: false, Channel: subscription.AllTradesChannel, Asset: asset.Spot},
|
|
}
|
|
|
|
var subscriptionNames = map[string]string{
|
|
subscription.TickerChannel: spotTickerChannel,
|
|
subscription.OrderbookChannel: spotOrderbookUpdateChannel,
|
|
subscription.CandlesChannel: spotCandlesticksChannel,
|
|
subscription.AllTradesChannel: spotTradesChannel,
|
|
}
|
|
|
|
var (
|
|
standardMarginAssetTypes = []asset.Item{asset.Spot, asset.Margin, asset.CrossMargin}
|
|
validPingChannels = []string{optionsPingChannel, futuresPingChannel, spotPingChannel}
|
|
)
|
|
|
|
var errInvalidPingChannel = errors.New("invalid ping channel")
|
|
|
|
// WsConnectSpot initiates a websocket connection
|
|
func (e *Exchange) WsConnectSpot(ctx context.Context, conn websocket.Connection) error {
|
|
if err := e.CurrencyPairs.IsAssetEnabled(asset.Spot); err != nil {
|
|
return err
|
|
}
|
|
if err := conn.Dial(ctx, &gws.Dialer{}, http.Header{}); err != nil {
|
|
return err
|
|
}
|
|
pingHandler, err := getWSPingHandler(spotPingChannel)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
conn.SetupPingHandler(websocketRateLimitNotNeededEPL, pingHandler)
|
|
return nil
|
|
}
|
|
|
|
// websocketLogin authenticates the websocket connection
|
|
func (e *Exchange) websocketLogin(ctx context.Context, conn websocket.Connection, channel string) error {
|
|
if conn == nil {
|
|
return fmt.Errorf("%w: %T", common.ErrNilPointer, conn)
|
|
}
|
|
|
|
if channel == "" {
|
|
return errChannelEmpty
|
|
}
|
|
|
|
creds, err := e.GetCredentials(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tn := time.Now().Unix()
|
|
msg := "api\n" + channel + "\n" + "\n" + strconv.FormatInt(tn, 10)
|
|
mac := hmac.New(sha512.New, []byte(creds.Secret))
|
|
if _, err = mac.Write([]byte(msg)); err != nil {
|
|
return err
|
|
}
|
|
signature := hex.EncodeToString(mac.Sum(nil))
|
|
|
|
payload := WebsocketPayload{
|
|
RequestID: e.MessageID(),
|
|
APIKey: creds.Key,
|
|
Signature: signature,
|
|
Timestamp: strconv.FormatInt(tn, 10),
|
|
}
|
|
|
|
req := WebsocketRequest{Time: tn, Channel: channel, Event: "api", Payload: payload}
|
|
|
|
resp, err := conn.SendMessageReturnResponse(ctx, websocketRateLimitNotNeededEPL, payload.RequestID, req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var inbound WebsocketAPIResponse
|
|
if err := json.Unmarshal(resp, &inbound); err != nil {
|
|
return err
|
|
}
|
|
|
|
if inbound.Header.Status == http.StatusOK {
|
|
return nil
|
|
}
|
|
|
|
var wsErr WebsocketErrors
|
|
if err := json.Unmarshal(inbound.Data, &wsErr.Errors); err != nil {
|
|
return err
|
|
}
|
|
|
|
return fmt.Errorf("%s: %s", wsErr.Errors.Label, wsErr.Errors.Message)
|
|
}
|
|
|
|
func (e *Exchange) generateWsSignature(secret, event, channel string, t int64) (string, error) {
|
|
msg := "channel=" + channel + "&event=" + event + "&time=" + strconv.FormatInt(t, 10)
|
|
mac := hmac.New(sha512.New, []byte(secret))
|
|
if _, err := mac.Write([]byte(msg)); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(mac.Sum(nil)), nil
|
|
}
|
|
|
|
// WsHandleSpotData handles spot data
|
|
func (e *Exchange) WsHandleSpotData(ctx context.Context, conn websocket.Connection, respRaw []byte) error {
|
|
push, err := parseWSHeader(respRaw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if push.RequestID != "" {
|
|
return conn.RequireMatchWithData(push.RequestID, respRaw)
|
|
}
|
|
|
|
if push.Event == subscribeEvent || push.Event == unsubscribeEvent {
|
|
return conn.RequireMatchWithData(push.ID, respRaw)
|
|
}
|
|
|
|
switch push.Channel { // TODO: Convert function params below to only use push.Result
|
|
case spotTickerChannel:
|
|
return e.processTicker(push.Result, push.Time)
|
|
case spotTradesChannel:
|
|
return e.processTrades(push.Result)
|
|
case spotCandlesticksChannel:
|
|
return e.processCandlestick(push.Result)
|
|
case spotOrderbookTickerChannel:
|
|
return e.processOrderbookTicker(push.Result, push.Time)
|
|
case spotOrderbookUpdateChannel:
|
|
return e.processOrderbookUpdate(ctx, push.Result, push.Time)
|
|
case spotOrderbookChannel:
|
|
return e.processOrderbookSnapshot(push.Result, push.Time)
|
|
case spotOrdersChannel:
|
|
return e.processSpotOrders(respRaw)
|
|
case spotUserTradesChannel:
|
|
return e.processUserPersonalTrades(respRaw)
|
|
case spotBalancesChannel:
|
|
return e.processSpotBalances(ctx, push.Result)
|
|
case marginBalancesChannel:
|
|
return e.processMarginBalances(ctx, respRaw)
|
|
case spotFundingBalanceChannel:
|
|
return e.processFundingBalances(respRaw)
|
|
case crossMarginBalanceChannel:
|
|
return e.processCrossMarginBalance(ctx, respRaw)
|
|
case crossMarginLoanChannel:
|
|
return e.processCrossMarginLoans(respRaw)
|
|
case spotPongChannel:
|
|
default:
|
|
e.Websocket.DataHandler <- websocket.UnhandledMessageWarning{
|
|
Message: e.Name + websocket.UnhandledMessage + string(respRaw),
|
|
}
|
|
return errors.New(websocket.UnhandledMessage)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseWSHeader(msg []byte) (r *WSResponse, errs error) {
|
|
r = &WSResponse{}
|
|
paths := [][]string{{"time_ms"}, {"time"}, {"channel"}, {"event"}, {"request_id"}, {"id"}, {"result"}}
|
|
jsonparser.EachKey(msg, func(idx int, v []byte, _ jsonparser.ValueType, _ error) {
|
|
switch idx {
|
|
case 0: // time_ms
|
|
if ts, err := strconv.ParseInt(string(v), 10, 64); err != nil {
|
|
errs = common.AppendError(errs, fmt.Errorf("%w parsing `time_ms`", err))
|
|
} else {
|
|
r.Time = time.UnixMilli(ts)
|
|
}
|
|
case 1: // time
|
|
if r.Time.IsZero() {
|
|
if ts, err := strconv.ParseInt(string(v), 10, 64); err != nil {
|
|
errs = common.AppendError(errs, fmt.Errorf("%w parsing `time`", err))
|
|
} else {
|
|
r.Time = time.Unix(ts, 0)
|
|
}
|
|
}
|
|
case 2:
|
|
r.Channel = string(v)
|
|
case 3:
|
|
r.Event = string(v)
|
|
case 4:
|
|
r.RequestID = string(v)
|
|
case 5:
|
|
if id, err := strconv.ParseInt(string(v), 10, 64); err != nil {
|
|
errs = common.AppendError(errs, fmt.Errorf("%w parsing `id`", err))
|
|
} else {
|
|
r.ID = id
|
|
}
|
|
case 6:
|
|
r.Result = json.RawMessage(v)
|
|
}
|
|
}, paths...)
|
|
|
|
return r, errs
|
|
}
|
|
|
|
func (e *Exchange) processTicker(incoming []byte, pushTime time.Time) error {
|
|
var data WsTicker
|
|
if err := json.Unmarshal(incoming, &data); err != nil {
|
|
return err
|
|
}
|
|
out := make([]ticker.Price, 0, len(standardMarginAssetTypes))
|
|
for _, a := range standardMarginAssetTypes {
|
|
if enabled, _ := e.CurrencyPairs.IsPairEnabled(data.CurrencyPair, a); enabled {
|
|
out = append(out, ticker.Price{
|
|
ExchangeName: e.Name,
|
|
Volume: data.BaseVolume.Float64(),
|
|
QuoteVolume: data.QuoteVolume.Float64(),
|
|
High: data.High24H.Float64(),
|
|
Low: data.Low24H.Float64(),
|
|
Last: data.Last.Float64(),
|
|
Bid: data.HighestBid.Float64(),
|
|
Ask: data.LowestAsk.Float64(),
|
|
AssetType: a,
|
|
Pair: data.CurrencyPair,
|
|
LastUpdated: pushTime,
|
|
})
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- out
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) processTrades(incoming []byte) error {
|
|
saveTradeData := e.IsSaveTradeDataEnabled()
|
|
if !saveTradeData && !e.IsTradeFeedEnabled() {
|
|
return nil
|
|
}
|
|
|
|
var data WsTrade
|
|
if err := json.Unmarshal(incoming, &data); err != nil {
|
|
return err
|
|
}
|
|
|
|
side, err := order.StringToOrderSide(data.Side)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, a := range standardMarginAssetTypes {
|
|
if enabled, _ := e.CurrencyPairs.IsPairEnabled(data.CurrencyPair, a); enabled {
|
|
if err := e.Websocket.Trade.Update(saveTradeData, trade.Data{
|
|
Timestamp: data.CreateTime.Time(),
|
|
CurrencyPair: data.CurrencyPair,
|
|
AssetType: a,
|
|
Exchange: e.Name,
|
|
Price: data.Price.Float64(),
|
|
Amount: data.Amount.Float64(),
|
|
Side: side,
|
|
TID: strconv.FormatInt(data.ID, 10),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) processCandlestick(incoming []byte) error {
|
|
var data WsCandlesticks
|
|
if err := json.Unmarshal(incoming, &data); err != nil {
|
|
return err
|
|
}
|
|
icp := strings.Split(data.NameOfSubscription, currency.UnderscoreDelimiter)
|
|
if len(icp) < 3 {
|
|
return errors.New("malformed candlestick websocket push data")
|
|
}
|
|
currencyPair, err := currency.NewPairFromString(strings.Join(icp[1:], currency.UnderscoreDelimiter))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
out := make([]websocket.KlineData, 0, len(standardMarginAssetTypes))
|
|
for _, a := range standardMarginAssetTypes {
|
|
if enabled, _ := e.CurrencyPairs.IsPairEnabled(currencyPair, a); enabled {
|
|
out = append(out, websocket.KlineData{
|
|
Pair: currencyPair,
|
|
AssetType: a,
|
|
Exchange: e.Name,
|
|
StartTime: data.Timestamp.Time(),
|
|
Interval: icp[0],
|
|
OpenPrice: data.OpenPrice.Float64(),
|
|
ClosePrice: data.ClosePrice.Float64(),
|
|
HighPrice: data.HighestPrice.Float64(),
|
|
LowPrice: data.LowestPrice.Float64(),
|
|
Volume: data.TotalVolume.Float64(),
|
|
})
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- out
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) processOrderbookTicker(incoming []byte, lastPushed time.Time) error {
|
|
var data WsOrderbookTickerData
|
|
if err := json.Unmarshal(incoming, &data); err != nil {
|
|
return err
|
|
}
|
|
return e.Websocket.Orderbook.LoadSnapshot(&orderbook.Book{
|
|
Exchange: e.Name,
|
|
Pair: data.Pair,
|
|
Asset: asset.Spot,
|
|
LastUpdated: data.UpdateTime.Time(),
|
|
LastPushed: lastPushed,
|
|
Bids: []orderbook.Level{{Price: data.BestBidPrice.Float64(), Amount: data.BestBidAmount.Float64()}},
|
|
Asks: []orderbook.Level{{Price: data.BestAskPrice.Float64(), Amount: data.BestAskAmount.Float64()}},
|
|
})
|
|
}
|
|
|
|
func (e *Exchange) processOrderbookUpdate(ctx context.Context, incoming []byte, lastPushed time.Time) error {
|
|
var data WsOrderbookUpdate
|
|
if err := json.Unmarshal(incoming, &data); err != nil {
|
|
return err
|
|
}
|
|
return e.wsOBUpdateMgr.ProcessOrderbookUpdate(ctx, e, data.FirstUpdateID, &orderbook.Update{
|
|
UpdateID: data.LastUpdateID,
|
|
UpdateTime: data.UpdateTime.Time(),
|
|
LastPushed: lastPushed,
|
|
Pair: data.Pair,
|
|
Asset: asset.Spot,
|
|
Asks: data.Asks.Levels(),
|
|
Bids: data.Bids.Levels(),
|
|
AllowEmpty: true,
|
|
})
|
|
}
|
|
|
|
func (e *Exchange) processOrderbookSnapshot(incoming []byte, lastPushed time.Time) error {
|
|
var data WsOrderbookSnapshot
|
|
if err := json.Unmarshal(incoming, &data); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, a := range standardMarginAssetTypes {
|
|
if enabled, _ := e.CurrencyPairs.IsPairEnabled(data.CurrencyPair, a); enabled {
|
|
if err := e.Websocket.Orderbook.LoadSnapshot(&orderbook.Book{
|
|
Exchange: e.Name,
|
|
Pair: data.CurrencyPair,
|
|
Asset: a,
|
|
LastUpdated: data.UpdateTime.Time(),
|
|
LastPushed: lastPushed,
|
|
Bids: data.Bids.Levels(),
|
|
Asks: data.Asks.Levels(),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) processSpotOrders(data []byte) error {
|
|
resp := struct {
|
|
Time types.Time `json:"time"`
|
|
Channel string `json:"channel"`
|
|
Event string `json:"event"`
|
|
Result []WsSpotOrder `json:"result"`
|
|
}{}
|
|
err := json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
details := make([]order.Detail, len(resp.Result))
|
|
for x := range resp.Result {
|
|
side, err := order.StringToOrderSide(resp.Result[x].Side)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
orderType, err := order.StringToOrderType(resp.Result[x].Type)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a, err := asset.New(resp.Result[x].Account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
details[x] = order.Detail{
|
|
Amount: resp.Result[x].Amount.Float64(),
|
|
Exchange: e.Name,
|
|
OrderID: resp.Result[x].ID,
|
|
Side: side,
|
|
Type: orderType,
|
|
Pair: resp.Result[x].CurrencyPair,
|
|
Cost: resp.Result[x].Fee.Float64(),
|
|
AssetType: a,
|
|
Price: resp.Result[x].Price.Float64(),
|
|
ExecutedAmount: resp.Result[x].Amount.Float64() - resp.Result[x].Left.Float64(),
|
|
Date: resp.Result[x].CreateTime.Time(),
|
|
LastUpdated: resp.Result[x].UpdateTime.Time(),
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- details
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) processUserPersonalTrades(data []byte) error {
|
|
if !e.IsFillsFeedEnabled() {
|
|
return nil
|
|
}
|
|
|
|
resp := struct {
|
|
Time types.Time `json:"time"`
|
|
Channel string `json:"channel"`
|
|
Event string `json:"event"`
|
|
Result []WsUserPersonalTrade `json:"result"`
|
|
}{}
|
|
err := json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fills := make([]fill.Data, len(resp.Result))
|
|
for x := range fills {
|
|
side, err := order.StringToOrderSide(resp.Result[x].Side)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fills[x] = fill.Data{
|
|
Timestamp: resp.Result[x].CreateTime.Time(),
|
|
Exchange: e.Name,
|
|
CurrencyPair: resp.Result[x].CurrencyPair,
|
|
Side: side,
|
|
OrderID: resp.Result[x].OrderID,
|
|
TradeID: strconv.FormatInt(resp.Result[x].ID, 10),
|
|
Price: resp.Result[x].Price.Float64(),
|
|
Amount: resp.Result[x].Amount.Float64(),
|
|
}
|
|
}
|
|
return e.Websocket.Fills.Update(fills...)
|
|
}
|
|
|
|
func (e *Exchange) processSpotBalances(ctx context.Context, data []byte) error {
|
|
var resp []WsSpotBalance
|
|
if err := json.Unmarshal(data, &resp); err != nil {
|
|
return err
|
|
}
|
|
|
|
creds, err := e.GetCredentials(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
changes := make([]account.Change, len(resp))
|
|
for i := range resp {
|
|
changes[i] = account.Change{
|
|
Account: resp[i].User,
|
|
AssetType: asset.Spot,
|
|
Balance: &account.Balance{
|
|
Currency: resp[i].Currency,
|
|
Total: resp[i].Total.Float64(),
|
|
Free: resp[i].Available.Float64(),
|
|
Hold: resp[i].Freeze.Float64(),
|
|
AvailableWithoutBorrow: resp[i].Available.Float64(),
|
|
UpdatedAt: resp[i].Timestamp.Time(),
|
|
},
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- changes
|
|
return account.ProcessChange(e.Name, changes, creds)
|
|
}
|
|
|
|
func (e *Exchange) processMarginBalances(ctx context.Context, data []byte) error {
|
|
resp := struct {
|
|
Time types.Time `json:"time"`
|
|
Channel string `json:"channel"`
|
|
Event string `json:"event"`
|
|
Result []WsMarginBalance `json:"result"`
|
|
}{}
|
|
err := json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
creds, err := e.GetCredentials(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changes := make([]account.Change, len(resp.Result))
|
|
for x := range resp.Result {
|
|
changes[x] = account.Change{
|
|
AssetType: asset.Margin,
|
|
Balance: &account.Balance{
|
|
Currency: currency.NewCode(resp.Result[x].Currency),
|
|
Total: resp.Result[x].Available.Float64() + resp.Result[x].Freeze.Float64(),
|
|
Free: resp.Result[x].Available.Float64(),
|
|
Hold: resp.Result[x].Freeze.Float64(),
|
|
UpdatedAt: resp.Result[x].Timestamp.Time(),
|
|
},
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- changes
|
|
return account.ProcessChange(e.Name, changes, creds)
|
|
}
|
|
|
|
func (e *Exchange) processFundingBalances(data []byte) error {
|
|
resp := struct {
|
|
Time types.Time `json:"time"`
|
|
Channel string `json:"channel"`
|
|
Event string `json:"event"`
|
|
Result []WsFundingBalance `json:"result"`
|
|
}{}
|
|
err := json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
e.Websocket.DataHandler <- resp
|
|
return nil
|
|
}
|
|
|
|
func (e *Exchange) processCrossMarginBalance(ctx context.Context, data []byte) error {
|
|
resp := struct {
|
|
Time types.Time `json:"time"`
|
|
Channel string `json:"channel"`
|
|
Event string `json:"event"`
|
|
Result []WsCrossMarginBalance `json:"result"`
|
|
}{}
|
|
err := json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
creds, err := e.GetCredentials(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changes := make([]account.Change, len(resp.Result))
|
|
for x := range resp.Result {
|
|
changes[x] = account.Change{
|
|
Account: resp.Result[x].User,
|
|
AssetType: asset.Margin,
|
|
Balance: &account.Balance{
|
|
Currency: currency.NewCode(resp.Result[x].Currency),
|
|
Total: resp.Result[x].Total.Float64(),
|
|
Free: resp.Result[x].Available.Float64(),
|
|
UpdatedAt: resp.Result[x].Timestamp.Time(),
|
|
},
|
|
}
|
|
}
|
|
e.Websocket.DataHandler <- changes
|
|
return account.ProcessChange(e.Name, changes, creds)
|
|
}
|
|
|
|
func (e *Exchange) processCrossMarginLoans(data []byte) error {
|
|
resp := struct {
|
|
Time types.Time `json:"time"`
|
|
Channel string `json:"channel"`
|
|
Event string `json:"event"`
|
|
Result WsCrossMarginLoan `json:"result"`
|
|
}{}
|
|
err := json.Unmarshal(data, &resp)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
e.Websocket.DataHandler <- resp
|
|
return nil
|
|
}
|
|
|
|
// generateSubscriptionsSpot returns configured subscriptions
|
|
func (e *Exchange) generateSubscriptionsSpot() (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(sprig.FuncMap()).
|
|
Funcs(template.FuncMap{
|
|
"channelName": channelName,
|
|
"singleSymbolChannel": singleSymbolChannel,
|
|
"orderbookInterval": orderbookChannelInterval,
|
|
"candlesInterval": candlesChannelInterval,
|
|
"levels": channelLevels,
|
|
}).Parse(subTplText)
|
|
}
|
|
|
|
// manageSubs sends a websocket message to subscribe or unsubscribe from a list of channel
|
|
func (e *Exchange) manageSubs(ctx context.Context, event string, conn websocket.Connection, subs subscription.List) error {
|
|
var errs error
|
|
subs, errs = subs.ExpandTemplates(e)
|
|
if errs != nil {
|
|
return errs
|
|
}
|
|
|
|
for _, s := range subs {
|
|
if err := func() error {
|
|
msg, err := e.manageSubReq(ctx, event, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result, err := conn.SendMessageReturnResponse(ctx, websocketRateLimitNotNeededEPL, msg.ID, msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var resp WsEventResponse
|
|
if err := json.Unmarshal(result, &resp); err != nil {
|
|
return err
|
|
}
|
|
if resp.Error != nil && resp.Error.Code != 0 {
|
|
return fmt.Errorf("(%d) %s", resp.Error.Code, resp.Error.Message)
|
|
}
|
|
if event == "unsubscribe" {
|
|
return e.Websocket.RemoveSubscriptions(conn, s)
|
|
}
|
|
return e.Websocket.AddSuccessfulSubscriptions(conn, s)
|
|
}(); err != nil {
|
|
errs = common.AppendError(errs, fmt.Errorf("%s %s %s: %w", s.Channel, s.Asset, s.Pairs, err))
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
// manageSubReq constructs the subscription management message for a subscription
|
|
func (e *Exchange) manageSubReq(ctx context.Context, event string, s *subscription.Subscription) (*WsInput, error) {
|
|
req := &WsInput{
|
|
ID: e.MessageSequence(),
|
|
Event: event,
|
|
Channel: channelName(s),
|
|
Time: time.Now().Unix(),
|
|
Payload: strings.Split(s.QualifiedChannel, ","),
|
|
}
|
|
if s.Authenticated {
|
|
creds, err := e.GetCredentials(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sig, err := e.generateWsSignature(creds.Secret, event, req.Channel, req.Time)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Auth = &WsAuthInput{
|
|
Method: "api_key",
|
|
Key: creds.Key,
|
|
Sign: sig,
|
|
}
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// Subscribe sends a websocket message to stop receiving data from the channel
|
|
func (e *Exchange) Subscribe(ctx context.Context, conn websocket.Connection, subs subscription.List) error {
|
|
return e.manageSubs(ctx, subscribeEvent, conn, subs)
|
|
}
|
|
|
|
// Unsubscribe sends a websocket message to stop receiving data from the channel
|
|
func (e *Exchange) Unsubscribe(ctx context.Context, conn websocket.Connection, subs subscription.List) error {
|
|
return e.manageSubs(ctx, unsubscribeEvent, conn, subs)
|
|
}
|
|
|
|
// channelName converts global channel names to gateio specific channel names
|
|
func channelName(s *subscription.Subscription) string {
|
|
if name, ok := subscriptionNames[s.Channel]; ok {
|
|
return name
|
|
}
|
|
return s.Channel
|
|
}
|
|
|
|
// singleSymbolChannel returns if the channel should be fanned out into single symbol requests
|
|
func singleSymbolChannel(name string) bool {
|
|
switch name {
|
|
case spotCandlesticksChannel, spotOrderbookUpdateChannel, spotOrderbookChannel:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidateSubscriptions implements the subscription.ListValidator interface.
|
|
// It ensures that, for each orderbook pair asset, only one type of subscription (e.g., best bid/ask, orderbook update, or orderbook snapshot)
|
|
// is active at a time. Multiple concurrent subscriptions for the same asset are disallowed to prevent orderbook data corruption.
|
|
func (e *Exchange) ValidateSubscriptions(l subscription.List) error {
|
|
orderbookGuard := map[key.PairAsset]string{}
|
|
for _, s := range l {
|
|
n := channelName(s)
|
|
if !isSingleOrderbookChannel(n) {
|
|
continue
|
|
}
|
|
for _, p := range s.Pairs {
|
|
k := key.PairAsset{Base: p.Base.Item, Quote: p.Quote.Item, Asset: s.Asset}
|
|
existingChanName, ok := orderbookGuard[k]
|
|
if !ok {
|
|
orderbookGuard[k] = n
|
|
continue
|
|
}
|
|
if existingChanName != n {
|
|
return fmt.Errorf("%w for %q %q between %q and %q, please enable only one type", subscription.ErrExclusiveSubscription, k.Pair(), k.Asset, existingChanName, n)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isSingleOrderbookChannel checks if the specified channel represents a single orderbook subscription.
|
|
// It returns true for channels like orderbook updates, snapshots, or tickers, as multiple subscriptions
|
|
// for the same pair asset could corrupt the stored orderbook data.
|
|
func isSingleOrderbookChannel(name string) bool {
|
|
switch name {
|
|
case spotOrderbookUpdateChannel,
|
|
spotOrderbookChannel,
|
|
spotOrderbookTickerChannel,
|
|
futuresOrderbookChannel,
|
|
futuresOrderbookTickerChannel,
|
|
futuresOrderbookUpdateChannel,
|
|
optionsOrderbookChannel,
|
|
optionsOrderbookTickerChannel,
|
|
optionsOrderbookUpdateChannel:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
var channelIntervalsMap = map[asset.Item]map[string][]kline.Interval{
|
|
asset.Spot: {
|
|
spotOrderbookTickerChannel: {},
|
|
spotOrderbookChannel: {kline.HundredMilliseconds, kline.ThousandMilliseconds},
|
|
spotOrderbookUpdateChannel: {kline.TwentyMilliseconds, kline.HundredMilliseconds},
|
|
},
|
|
asset.Futures: {
|
|
futuresOrderbookTickerChannel: {},
|
|
futuresOrderbookChannel: {0},
|
|
futuresOrderbookUpdateChannel: {kline.TwentyMilliseconds, kline.HundredMilliseconds},
|
|
},
|
|
asset.DeliveryFutures: {
|
|
futuresOrderbookTickerChannel: {},
|
|
futuresOrderbookChannel: {0},
|
|
futuresOrderbookUpdateChannel: {kline.HundredMilliseconds, kline.ThousandMilliseconds},
|
|
},
|
|
asset.Options: {
|
|
optionsOrderbookTickerChannel: {},
|
|
optionsOrderbookChannel: {0},
|
|
optionsOrderbookUpdateChannel: {kline.HundredMilliseconds, kline.ThousandMilliseconds},
|
|
},
|
|
}
|
|
|
|
func candlesChannelInterval(s *subscription.Subscription) (string, error) {
|
|
if s.Channel == subscription.CandlesChannel {
|
|
return getIntervalString(s.Interval)
|
|
}
|
|
return "", nil
|
|
}
|
|
|
|
func orderbookChannelInterval(s *subscription.Subscription, a asset.Item) (string, error) {
|
|
cName := channelName(s)
|
|
|
|
assetChannels, ok := channelIntervalsMap[a]
|
|
if !ok {
|
|
return "", nil
|
|
}
|
|
|
|
switch intervals, ok := assetChannels[cName]; {
|
|
case !ok:
|
|
return "", nil
|
|
case len(intervals) == 0:
|
|
if s.Interval != 0 {
|
|
return "", fmt.Errorf("%w for %s: %q; interval not supported for channel", subscription.ErrInvalidInterval, cName, s.Interval)
|
|
}
|
|
return "", nil
|
|
case !slices.Contains(intervals, s.Interval):
|
|
return "", fmt.Errorf("%w for %s: %q; supported: %q", subscription.ErrInvalidInterval, cName, s.Interval, intervals)
|
|
case cName == futuresOrderbookUpdateChannel && s.Interval == kline.TwentyMilliseconds && s.Levels != 20:
|
|
return "", fmt.Errorf("%w for %q: 20ms only valid with Levels 20", subscription.ErrInvalidInterval, cName)
|
|
case s.Interval == 0:
|
|
return "0", nil // Do not move this into getIntervalString, it's only valid for ws subs
|
|
}
|
|
|
|
return getIntervalString(s.Interval)
|
|
}
|
|
|
|
var channelLevelsMap = map[asset.Item]map[string][]int{
|
|
asset.Spot: {
|
|
spotOrderbookTickerChannel: {},
|
|
spotOrderbookUpdateChannel: {},
|
|
spotOrderbookChannel: {1, 5, 10, 20, 50, 100},
|
|
},
|
|
asset.Futures: {
|
|
futuresOrderbookChannel: {1, 5, 10, 20, 50, 100},
|
|
futuresOrderbookTickerChannel: {},
|
|
futuresOrderbookUpdateChannel: {20, 50, 100},
|
|
},
|
|
asset.DeliveryFutures: {
|
|
futuresOrderbookChannel: {1, 5, 10, 20, 50, 100},
|
|
futuresOrderbookTickerChannel: {},
|
|
futuresOrderbookUpdateChannel: {5, 10, 20, 50, 100},
|
|
},
|
|
asset.Options: {
|
|
optionsOrderbookTickerChannel: {},
|
|
optionsOrderbookUpdateChannel: {5, 10, 20, 50},
|
|
optionsOrderbookChannel: {5, 10, 20, 50},
|
|
},
|
|
}
|
|
|
|
func channelLevels(s *subscription.Subscription, a asset.Item) (string, error) {
|
|
cName := channelName(s)
|
|
assetChannels, ok := channelLevelsMap[a]
|
|
if !ok {
|
|
return "", nil
|
|
}
|
|
|
|
switch levels, ok := assetChannels[cName]; {
|
|
case !ok:
|
|
return "", nil
|
|
case len(levels) == 0:
|
|
if s.Levels != 0 {
|
|
return "", fmt.Errorf("%w for %s: `%d`; levels not supported for channel", subscription.ErrInvalidLevel, cName, s.Levels)
|
|
}
|
|
return "", nil
|
|
case !slices.Contains(levels, s.Levels):
|
|
return "", fmt.Errorf("%w for %s: %d; supported: %v", subscription.ErrInvalidLevel, cName, s.Levels, levels)
|
|
}
|
|
|
|
return strconv.Itoa(s.Levels), nil
|
|
}
|
|
|
|
const subTplText = `
|
|
{{- with $name := channelName $.S }}
|
|
{{- range $asset, $pairs := $.AssetPairs }}
|
|
{{- if singleSymbolChannel $name }}
|
|
{{- range $i, $p := $pairs -}}
|
|
{{- with $i := candlesInterval $.S }}{{ $i -}} , {{- end }}
|
|
{{- $p }}
|
|
{{- with $l := levels $.S $asset -}} , {{- $l }}{{ end }}
|
|
{{- with $i := orderbookInterval $.S $asset -}} , {{- $i }}{{- end }}
|
|
{{- $.PairSeparator }}
|
|
{{- end }}
|
|
{{- $.AssetSeparator }}
|
|
{{- else }}
|
|
{{- $pairs.Join }}
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- end }}
|
|
`
|
|
|
|
// GeneratePayload returns the payload for a websocket message
|
|
type GeneratePayload func(ctx context.Context, event string, channelsToSubscribe subscription.List) ([]WsInput, error)
|
|
|
|
// handleSubscription sends a websocket message to receive data from the channel
|
|
func (e *Exchange) handleSubscription(ctx context.Context, conn websocket.Connection, event string, channelsToSubscribe subscription.List, generatePayload GeneratePayload) error {
|
|
payloads, err := generatePayload(ctx, event, channelsToSubscribe)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var errs error
|
|
for k := range payloads {
|
|
result, err := conn.SendMessageReturnResponse(ctx, websocketRateLimitNotNeededEPL, payloads[k].ID, payloads[k])
|
|
if err != nil {
|
|
errs = common.AppendError(errs, err)
|
|
continue
|
|
}
|
|
var resp WsEventResponse
|
|
if err = json.Unmarshal(result, &resp); err != nil {
|
|
errs = common.AppendError(errs, err)
|
|
} else {
|
|
if resp.Error != nil && resp.Error.Code != 0 {
|
|
errs = common.AppendError(errs, fmt.Errorf("error while %s to channel %s error code: %d message: %s", payloads[k].Event, payloads[k].Channel, resp.Error.Code, resp.Error.Message))
|
|
continue
|
|
}
|
|
if event == subscribeEvent {
|
|
errs = common.AppendError(errs, e.Websocket.AddSuccessfulSubscriptions(conn, channelsToSubscribe[k]))
|
|
} else {
|
|
errs = common.AppendError(errs, e.Websocket.RemoveSubscriptions(conn, channelsToSubscribe[k]))
|
|
}
|
|
}
|
|
}
|
|
return errs
|
|
}
|
|
|
|
type resultHolder struct {
|
|
Result any `json:"result"`
|
|
}
|
|
|
|
// SendWebsocketRequest sends a websocket request to the exchange
|
|
func (e *Exchange) SendWebsocketRequest(ctx context.Context, epl request.EndpointLimit, channel string, connSignature, params, result any, expectedResponses int) error {
|
|
paramPayload, err := json.Marshal(params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
conn, err := e.Websocket.GetConnection(connSignature)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tn := time.Now().Unix()
|
|
req := &WebsocketRequest{
|
|
Time: tn,
|
|
Channel: channel,
|
|
Event: "api",
|
|
Payload: WebsocketPayload{
|
|
RequestID: e.MessageID(),
|
|
RequestParam: paramPayload,
|
|
Timestamp: strconv.FormatInt(tn, 10),
|
|
},
|
|
}
|
|
|
|
responses, err := conn.SendMessageReturnResponsesWithInspector(ctx, epl, req.Payload.RequestID, req, expectedResponses, wsRespAckInspector{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(responses) == 0 {
|
|
return common.ErrNoResponse
|
|
}
|
|
|
|
// responses may include an ack resp, which we skip
|
|
endResponse := responses[len(responses)-1]
|
|
|
|
var inbound WebsocketAPIResponse
|
|
if err := json.Unmarshal(endResponse, &inbound); err != nil {
|
|
return err
|
|
}
|
|
|
|
if inbound.Header.Status != http.StatusOK {
|
|
var wsErr WebsocketErrors
|
|
if err := json.Unmarshal(inbound.Data, &wsErr); err != nil {
|
|
return err
|
|
}
|
|
return fmt.Errorf("%s: %s", wsErr.Errors.Label, wsErr.Errors.Message)
|
|
}
|
|
|
|
return json.Unmarshal(inbound.Data, &resultHolder{Result: result})
|
|
}
|
|
|
|
type wsRespAckInspector struct{}
|
|
|
|
// IsFinal checks the payload for an ack, it returns true if the payload does not contain an ack.
|
|
// This will force the cancellation of further waiting for responses.
|
|
func (wsRespAckInspector) IsFinal(data []byte) bool {
|
|
return !strings.Contains(string(data), "ack")
|
|
}
|
|
|
|
func getWSPingHandler(channel string) (websocket.PingHandler, error) {
|
|
if !slices.Contains(validPingChannels, channel) {
|
|
return websocket.PingHandler{}, fmt.Errorf("%w: %q", errInvalidPingChannel, channel)
|
|
}
|
|
pingMessage, err := json.Marshal(WsInput{Channel: channel})
|
|
if err != nil {
|
|
return websocket.PingHandler{}, err
|
|
}
|
|
return websocket.PingHandler{
|
|
Delay: time.Second * 10, // Arbitrary reasonable delay
|
|
Message: pingMessage,
|
|
MessageType: gws.TextMessage,
|
|
}, nil
|
|
}
|