Files
gocryptotrader/exchanges/coinbasepro/coinbasepro_websocket.go
Gareth Kirwan 1199f38546 subscriptions: Encapsulate, replace Pair with Pairs and refactor; improve exchange support
* Websocket: Use ErrSubscribedAlready

instead of errChannelAlreadySubscribed

* Subscriptions: Replace Pair with Pairs

Given that some subscriptions have multiple pairs, support that as the
standard.

* Docs: Update subscriptions in add new exch

* RPC: Update Subscription Pairs

* Linter: Disable testifylint.Len

We deliberately use Equal over Len to avoid spamming the contents of large Slices

* Websocket: Add suffix to state consts

* Binance: Subscription Pairs support

* Bitfinex: Subscription Pairs support

* Bithumb: Subscription Pairs support

* Bitmex: Subscription Pairs support

* Bitstamp: Subscription Pairs support

* BTCMarkets: Subscription Pairs support

* BTSE: Subscription Pairs support

* Coinbase: Subscription Pairs support

* Coinut: Subscription Pairs support

* GateIO: Subscription Pairs support

* Gemini: Subscription Pairs support and improvement

* Hitbtc: Subscription Pairs support

* Huboi: Subscription Pairs support

* Kucoin: Subscription Pairs support

* Okcoin: Subscription Pairs support

* Poloniex: Subscription Pairs support

* Kraken: Add subscription Pairs support

Note: This is a naieve implementation because we want to rebase the
kraken websocket rewrite on top of this

* Bybit: Subscription Pairs support

* Okx: Subscription Pairs support

* Bitmex: Subsription configuration

* Fixes unauthenticated websocket left as CanUseAuth
* Fixes auth subs happening privately

* CoinbasePro: Subscription Configuration

* Consolidate ProductIDs when all subscriptions are for the same list

* Websocket: Log actual sent message when Verbose

* Subscriptions: Improve clarity of which key is which in Match

* Subscriptions: Lint fix for HugeParam

* Subscriptions: Add AddPairs and move keys from test

* Subscriptions: Simplify subscription keys and add key types

* Subscriptions: Add List.GroupPairs Rename sub.AddPairs

* Subscription: Fix ExactKey not matching 0 pairs

* Subscriptions: Remove unused IdentityKey and HasPairKey

* Subscriptions: Fix GetKey test

* Subscriptions: Test coverage improvements

* Websocket: Change State on Add/Remove

* Subscriptions: Improve error context

* Subscriptions: Fix Enable: false subs not ignored

* Bitfinex: Fix WsAuth test failing on DataHandler

DataHandler is eaten by dataMonitor now, so we need to use ToRoutine

* Deribit: Subscription Pairs support

* Websocket: Accept nil lists for checkSubscriptions

If the user passes in a nil (implicitly empty) list, we would not panic.
Therefore the burden of correctness about that data lies with them.
The list of subscriptions is empty, and that's okay, and possibly
convenient

* Websocket: Add context to NilPointer errors

* Subscriptions: Add context to nil errors

* Exchange: Fix error expectations in UnsubToWSChans
2024-06-07 11:54:08 +10:00

468 lines
12 KiB
Go

package coinbasepro
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gorilla/websocket"
"github.com/thrasher-corp/gocryptotrader/common/convert"
"github.com/thrasher-corp/gocryptotrader/common/crypto"
"github.com/thrasher-corp/gocryptotrader/currency"
"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/stream"
"github.com/thrasher-corp/gocryptotrader/exchanges/subscription"
"github.com/thrasher-corp/gocryptotrader/exchanges/ticker"
"github.com/thrasher-corp/gocryptotrader/exchanges/trade"
)
const (
coinbaseproWebsocketURL = "wss://ws-feed.pro.coinbase.com"
)
// WsConnect initiates a websocket connection
func (c *CoinbasePro) WsConnect() error {
if !c.Websocket.IsEnabled() || !c.IsEnabled() {
return stream.ErrWebsocketNotEnabled
}
var dialer websocket.Dialer
err := c.Websocket.Conn.Dial(&dialer, http.Header{})
if err != nil {
return err
}
c.Websocket.Wg.Add(1)
go c.wsReadData()
return nil
}
// wsReadData receives and passes on websocket messages for processing
func (c *CoinbasePro) wsReadData() {
defer c.Websocket.Wg.Done()
for {
resp := c.Websocket.Conn.ReadMessage()
if resp.Raw == nil {
return
}
err := c.wsHandleData(resp.Raw)
if err != nil {
c.Websocket.DataHandler <- err
}
}
}
func (c *CoinbasePro) wsHandleData(respRaw []byte) error {
msgType := wsMsgType{}
err := json.Unmarshal(respRaw, &msgType)
if err != nil {
return err
}
if msgType.Type == "subscriptions" || msgType.Type == "heartbeat" {
return nil
}
switch msgType.Type {
case "status":
var status wsStatus
err = json.Unmarshal(respRaw, &status)
if err != nil {
return err
}
c.Websocket.DataHandler <- status
case "error":
c.Websocket.DataHandler <- errors.New(string(respRaw))
case "ticker":
wsTicker := WebsocketTicker{}
err := json.Unmarshal(respRaw, &wsTicker)
if err != nil {
return err
}
c.Websocket.DataHandler <- &ticker.Price{
LastUpdated: wsTicker.Time,
Pair: wsTicker.ProductID,
AssetType: asset.Spot,
ExchangeName: c.Name,
Open: wsTicker.Open24H,
High: wsTicker.High24H,
Low: wsTicker.Low24H,
Last: wsTicker.Price,
Volume: wsTicker.Volume24H,
Bid: wsTicker.BestBid,
Ask: wsTicker.BestAsk,
}
case "snapshot":
var snapshot WebsocketOrderbookSnapshot
err := json.Unmarshal(respRaw, &snapshot)
if err != nil {
return err
}
err = c.ProcessSnapshot(&snapshot)
if err != nil {
return err
}
case "l2update":
var update WebsocketL2Update
err := json.Unmarshal(respRaw, &update)
if err != nil {
return err
}
err = c.ProcessUpdate(&update)
if err != nil {
return err
}
case "received", "open", "done", "change", "activate":
var wsOrder wsOrderReceived
err := json.Unmarshal(respRaw, &wsOrder)
if err != nil {
return err
}
var oType order.Type
var oSide order.Side
var oStatus order.Status
oType, err = order.StringToOrderType(wsOrder.OrderType)
if err != nil {
c.Websocket.DataHandler <- order.ClassificationError{
Exchange: c.Name,
OrderID: wsOrder.OrderID,
Err: err,
}
}
oSide, err = order.StringToOrderSide(wsOrder.Side)
if err != nil {
c.Websocket.DataHandler <- order.ClassificationError{
Exchange: c.Name,
OrderID: wsOrder.OrderID,
Err: err,
}
}
oStatus, err = statusToStandardStatus(wsOrder.Type)
if err != nil {
c.Websocket.DataHandler <- order.ClassificationError{
Exchange: c.Name,
OrderID: wsOrder.OrderID,
Err: err,
}
}
if wsOrder.Reason == "canceled" {
oStatus = order.Cancelled
}
ts := wsOrder.Time
if wsOrder.Type == "activate" {
ts = convert.TimeFromUnixTimestampDecimal(wsOrder.Timestamp)
}
creds, err := c.GetCredentials(context.TODO())
if err != nil {
c.Websocket.DataHandler <- order.ClassificationError{
Exchange: c.Name,
OrderID: wsOrder.OrderID,
Err: err,
}
}
if wsOrder.UserID != "" {
var p currency.Pair
var a asset.Item
p, a, err = c.GetRequestFormattedPairAndAssetType(wsOrder.ProductID)
if err != nil {
return err
}
c.Websocket.DataHandler <- &order.Detail{
HiddenOrder: wsOrder.Private,
Price: wsOrder.Price,
Amount: wsOrder.Size,
TriggerPrice: wsOrder.StopPrice,
ExecutedAmount: wsOrder.Size - wsOrder.RemainingSize,
RemainingAmount: wsOrder.RemainingSize,
Fee: wsOrder.TakerFeeRate,
Exchange: c.Name,
OrderID: wsOrder.OrderID,
AccountID: wsOrder.ProfileID,
ClientID: creds.ClientID,
Type: oType,
Side: oSide,
Status: oStatus,
AssetType: a,
Date: ts,
Pair: p,
}
}
case "match", "last_match":
var wsOrder wsOrderReceived
err := json.Unmarshal(respRaw, &wsOrder)
if err != nil {
return err
}
oSide, err := order.StringToOrderSide(wsOrder.Side)
if err != nil {
c.Websocket.DataHandler <- order.ClassificationError{
Exchange: c.Name,
Err: err,
}
}
var p currency.Pair
var a asset.Item
p, a, err = c.GetRequestFormattedPairAndAssetType(wsOrder.ProductID)
if err != nil {
return err
}
if wsOrder.UserID != "" {
c.Websocket.DataHandler <- &order.Detail{
OrderID: wsOrder.OrderID,
Pair: p,
AssetType: a,
Trades: []order.TradeHistory{
{
Price: wsOrder.Price,
Amount: wsOrder.Size,
Exchange: c.Name,
TID: strconv.FormatInt(wsOrder.TradeID, 10),
Side: oSide,
Timestamp: wsOrder.Time,
IsMaker: wsOrder.TakerUserID == "",
},
},
}
} else {
if !c.IsSaveTradeDataEnabled() {
return nil
}
return trade.AddTradesToBuffer(c.Name, trade.Data{
Timestamp: wsOrder.Time,
Exchange: c.Name,
CurrencyPair: p,
AssetType: a,
Price: wsOrder.Price,
Amount: wsOrder.Size,
Side: oSide,
TID: strconv.FormatInt(wsOrder.TradeID, 10),
})
}
default:
c.Websocket.DataHandler <- stream.UnhandledMessageWarning{Message: c.Name + stream.UnhandledMessage + string(respRaw)}
return nil
}
return nil
}
func statusToStandardStatus(stat string) (order.Status, error) {
switch stat {
case "received":
return order.New, nil
case "open":
return order.Active, nil
case "done":
return order.Filled, nil
case "match":
return order.PartiallyFilled, nil
case "change", "activate":
return order.Active, nil
default:
return order.UnknownStatus, fmt.Errorf("%s not recognised as status type", stat)
}
}
// ProcessSnapshot processes the initial orderbook snap shot
func (c *CoinbasePro) ProcessSnapshot(snapshot *WebsocketOrderbookSnapshot) error {
pair, err := currency.NewPairFromString(snapshot.ProductID)
if err != nil {
return err
}
base := orderbook.Base{
Pair: pair,
Bids: make(orderbook.Tranches, len(snapshot.Bids)),
Asks: make(orderbook.Tranches, len(snapshot.Asks)),
}
for i := range snapshot.Bids {
var price float64
price, err = strconv.ParseFloat(snapshot.Bids[i][0], 64)
if err != nil {
return err
}
var amount float64
amount, err = strconv.ParseFloat(snapshot.Bids[i][1], 64)
if err != nil {
return err
}
base.Bids[i] = orderbook.Tranche{Price: price, Amount: amount}
}
for i := range snapshot.Asks {
var price float64
price, err = strconv.ParseFloat(snapshot.Asks[i][0], 64)
if err != nil {
return err
}
var amount float64
amount, err = strconv.ParseFloat(snapshot.Asks[i][1], 64)
if err != nil {
return err
}
base.Asks[i] = orderbook.Tranche{Price: price, Amount: amount}
}
base.Asset = asset.Spot
base.Pair = pair
base.Exchange = c.Name
base.VerifyOrderbook = c.CanVerifyOrderbook
base.LastUpdated = snapshot.Time
return c.Websocket.Orderbook.LoadSnapshot(&base)
}
// ProcessUpdate updates the orderbook local cache
func (c *CoinbasePro) ProcessUpdate(update *WebsocketL2Update) error {
if len(update.Changes) == 0 {
return errors.New("no data in websocket update")
}
p, err := currency.NewPairFromString(update.ProductID)
if err != nil {
return err
}
asks := make(orderbook.Tranches, 0, len(update.Changes))
bids := make(orderbook.Tranches, 0, len(update.Changes))
for i := range update.Changes {
price, err := strconv.ParseFloat(update.Changes[i][1], 64)
if err != nil {
return err
}
volume, err := strconv.ParseFloat(update.Changes[i][2], 64)
if err != nil {
return err
}
if update.Changes[i][0] == order.Buy.Lower() {
bids = append(bids, orderbook.Tranche{Price: price, Amount: volume})
} else {
asks = append(asks, orderbook.Tranche{Price: price, Amount: volume})
}
}
return c.Websocket.Orderbook.Update(&orderbook.Update{
Bids: bids,
Asks: asks,
Pair: p,
UpdateTime: update.Time,
Asset: asset.Spot,
})
}
// generateSubscriptions returns a list of subscriptions from the configured subscriptions feature
func (c *CoinbasePro) generateSubscriptions() (subscription.List, error) {
pairs, err := c.GetEnabledPairs(asset.Spot)
if err != nil {
return nil, err
}
pairFmt, err := c.GetPairFormat(asset.Spot, true)
if err != nil {
return nil, err
}
pairs = pairs.Format(pairFmt)
authed := c.IsWebsocketAuthenticationSupported()
subs := make(subscription.List, 0, len(c.Features.Subscriptions))
for _, baseSub := range c.Features.Subscriptions {
if !authed && baseSub.Authenticated {
continue
}
s := baseSub.Clone()
s.Asset = asset.Spot
s.Pairs = pairs
subs = append(subs, s)
}
return subs, nil
}
// Subscribe sends a websocket message to receive data from the channel
func (c *CoinbasePro) Subscribe(subs subscription.List) error {
r := &WebsocketSubscribe{
Type: "subscribe",
Channels: make([]any, 0, len(subs)),
}
// See if we have a consistent Pair list for all the subs that we can use globally
// If all the subs have the same pairs then we can use the top level ProductIDs field
// Otherwise each and every sub needs to have it's own list
for i, s := range subs {
if i == 0 {
r.ProductIDs = s.Pairs.Strings()
} else if !subs[0].Pairs.Equal(s.Pairs) {
r.ProductIDs = nil
break
}
}
for _, s := range subs {
if s.Authenticated && r.Key == "" && c.IsWebsocketAuthenticationSupported() {
if err := c.authWsSubscibeReq(r); err != nil {
return err
}
}
if len(r.ProductIDs) == 0 {
r.Channels = append(r.Channels, WsChannel{
Name: s.Channel,
ProductIDs: s.Pairs.Strings(),
})
} else {
// Coinbase does not support using [WsChannel{Name:"x"}] unless each ProductIDs field is populated
// Therefore we have to use Channels as an array of strings
r.Channels = append(r.Channels, s.Channel)
}
}
err := c.Websocket.Conn.SendJSONMessage(r)
if err == nil {
err = c.Websocket.AddSuccessfulSubscriptions(subs...)
}
return err
}
func (c *CoinbasePro) authWsSubscibeReq(r *WebsocketSubscribe) error {
creds, err := c.GetCredentials(context.TODO())
if err != nil {
return err
}
r.Timestamp = strconv.FormatInt(time.Now().Unix(), 10)
message := r.Timestamp + http.MethodGet + "/users/self/verify"
hmac, err := crypto.GetHMAC(crypto.HashSHA256, []byte(message), []byte(creds.Secret))
if err != nil {
return err
}
r.Signature = crypto.Base64Encode(hmac)
r.Key = creds.Key
r.Passphrase = creds.ClientID
return nil
}
// Unsubscribe sends a websocket message to stop receiving data from the channel
func (c *CoinbasePro) Unsubscribe(subs subscription.List) error {
r := &WebsocketSubscribe{
Type: "unsubscribe",
Channels: make([]any, 0, len(subs)),
}
for _, s := range subs {
r.Channels = append(r.Channels, WsChannel{
Name: s.Channel,
ProductIDs: s.Pairs.Strings(),
})
}
err := c.Websocket.Conn.SendJSONMessage(r)
if err == nil {
err = c.Websocket.RemoveSubscriptions(subs...)
}
return err
}