mirror of
https://github.com/d0zingcat/gocryptotrader.git
synced 2026-05-13 23:16:45 +00:00
* Websockets: Add keys to websocket subscriptions * This switches all RO uses of the mutex to use a RLock method. * The mutex used for discrete field access has had scope drift from name 'connectionMutex' so rename to more appropriate fieldsMutex * The mutex used for Set/CanUseAuthEndpoints moves from the subscriptions endpoint to the fieldsMutex * Add GetSubscription by key * Expose stream.Matcher type * Bitfinex: Subscribe and Unsubscribe atomicly * Fix Auth failures ignored * This change makes it so that Subscribe and Unsubscribe wait for success ** Tells the DataHandler about errors ** Errors are returned to consumers * Subscribes concurrently to the channels * It also simplifies the chanId to stream mapping * Removes unable to locate chanID: %d errors which are just noise * Paves the way for unified channelSubscription id handling * Adds support for subId for Book subscriptions, which is more robust * Vastly simplifies what we need to test TestWsSubscribedResponse This test was working to ensure that the various fancy key parsing mechanisms all worked. Now that we use subId, we just need a thorough test of that * Expose Match.Set in order to capture websocket incoming data Can't see another way of doing this. Doesn't seem too bad * Allow tests to run with auth or WS These flags made it difficult to run the tests whilst working on websockets * Enable API auth and WS in testconfig This change minimises the changes requires for a full test run against live endpoints, so that new contributors have a clearer testing path. I cannot see any reason to turn WS off and Auth endpoints off when we're not going to run API tests without Creds being set, and we're not going to do live fire tests without canManipulateRealOrders * TestWsSubscribe and various fixes ** Enables the websocket for live non-authed integration tests by default ** Adds an integration test for subscriptions ** Changes the Ws tests to respect canManipulateRealOrders ** Uses WsConnect instead of setupWs; fixes seqNo config not sent for WS tests ** Allows api creds to live in config/testdata.json which might be less likely to accidentally commit, and less obtrusive * Bitfinex: Support period and timeframe for Candles * Fixes manual Subscribe() symbol or key formatting * Unifies handling of params for DefaultSubscriptions and manual subsrciptions * Bitfinex: Handle conf and info WS channel events * Bitfinex: Better tests for subscriptions * fixup! Websockets: Add keys to websocket subscriptions * fixup! Bitfinex: Subscribe and Unsubscribe atomicly * fixup! Websockets: Add keys to websocket subscriptions * Websockets: Add Pending subscription status Add a status tracker so that Sub/Unsub can prevent duplicates, and also fixes when first message comes before we have added the sub to the tracker * Websockets: Add State instead of pending This change allows more clarity about the current state and checks for specifically already Unsubing * Bitfinex: Fix first sub message maybe lost The only link we have between a sub req and the sub resp is the subID. And the only link we have between a sub message and the sub is the chanID. We can't derive a link using Pair or anything else. This meant that by sending the resp and its chanID down the IncomingData channel, we allowed the channel reader to maybe process the next message, the first message on the channel, before the runtime executed the switch back to subscribeToChan waiting on the chan. To fix this, we key initially on subId.(string), and then replace it with chanId.(int64) when we have it *inside* the wsHandleData so we know we've procedurally handled it before the next message. subscribeToChan is then free to remove the subId keyed Sub regardless of error or not If there's an error, we don't need to inline handling because there won't be any second update. Expands test coverage to make sure those subId keyed subscriptions are removed. * Websocket: Validate state in SetChanState * fixup! Bitfinex: Fix first sub message maybe lost * Websockets: Rename RemoveUnsuccessfulSubs Implementation doesn't imply Unsuccessful or need to. This change supports the registering of Pending subs * Bitfinex: Fix race in Tests
1062 lines
28 KiB
Go
1062 lines
28 KiB
Go
package binance
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"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/ticker"
|
|
"github.com/thrasher-corp/gocryptotrader/exchanges/trade"
|
|
"github.com/thrasher-corp/gocryptotrader/log"
|
|
)
|
|
|
|
const (
|
|
binanceDefaultWebsocketURL = "wss://stream.binance.com:9443/stream"
|
|
pingDelay = time.Minute * 9
|
|
)
|
|
|
|
var listenKey string
|
|
|
|
var (
|
|
// maxWSUpdateBuffer defines max websocket updates to apply when an
|
|
// orderbook is initially fetched
|
|
maxWSUpdateBuffer = 150
|
|
// maxWSOrderbookJobs defines max websocket orderbook jobs in queue to fetch
|
|
// an orderbook snapshot via REST
|
|
maxWSOrderbookJobs = 2000
|
|
// maxWSOrderbookWorkers defines a max amount of workers allowed to execute
|
|
// jobs from the job channel
|
|
maxWSOrderbookWorkers = 10
|
|
)
|
|
|
|
// WsConnect initiates a websocket connection
|
|
func (b *Binance) WsConnect() error {
|
|
if !b.Websocket.IsEnabled() || !b.IsEnabled() {
|
|
return errors.New(stream.WebsocketNotEnabled)
|
|
}
|
|
|
|
var dialer websocket.Dialer
|
|
dialer.HandshakeTimeout = b.Config.HTTPTimeout
|
|
dialer.Proxy = http.ProxyFromEnvironment
|
|
var err error
|
|
if b.Websocket.CanUseAuthenticatedEndpoints() {
|
|
listenKey, err = b.GetWsAuthStreamKey(context.TODO())
|
|
if err != nil {
|
|
b.Websocket.SetCanUseAuthenticatedEndpoints(false)
|
|
log.Errorf(log.ExchangeSys,
|
|
"%v unable to connect to authenticated Websocket. Error: %s",
|
|
b.Name,
|
|
err)
|
|
} else {
|
|
// cleans on failed connection
|
|
clean := strings.Split(b.Websocket.GetWebsocketURL(), "?streams=")
|
|
authPayload := clean[0] + "?streams=" + listenKey
|
|
err = b.Websocket.SetWebsocketURL(authPayload, false, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
err = b.Websocket.Conn.Dial(&dialer, http.Header{})
|
|
if err != nil {
|
|
return fmt.Errorf("%v - Unable to connect to Websocket. Error: %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
|
|
if b.Websocket.CanUseAuthenticatedEndpoints() {
|
|
go b.KeepAuthKeyAlive()
|
|
}
|
|
|
|
b.Websocket.Conn.SetupPingHandler(stream.PingHandler{
|
|
UseGorillaHandler: true,
|
|
MessageType: websocket.PongMessage,
|
|
Delay: pingDelay,
|
|
})
|
|
|
|
b.Websocket.Wg.Add(1)
|
|
go b.wsReadData()
|
|
|
|
b.setupOrderbookManager()
|
|
return nil
|
|
}
|
|
|
|
func (b *Binance) setupOrderbookManager() {
|
|
if b.obm == nil {
|
|
b.obm = &orderbookManager{
|
|
state: make(map[currency.Code]map[currency.Code]map[asset.Item]*update),
|
|
jobs: make(chan job, maxWSOrderbookJobs),
|
|
}
|
|
} else {
|
|
// Change state on reconnect for initial sync.
|
|
for _, m1 := range b.obm.state {
|
|
for _, m2 := range m1 {
|
|
for _, update := range m2 {
|
|
update.initialSync = true
|
|
update.needsFetchingBook = true
|
|
update.lastUpdateID = 0
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for i := 0; i < maxWSOrderbookWorkers; i++ {
|
|
// 10 workers for synchronising book
|
|
b.SynchroniseWebsocketOrderbook()
|
|
}
|
|
}
|
|
|
|
// KeepAuthKeyAlive will continuously send messages to
|
|
// keep the WS auth key active
|
|
func (b *Binance) KeepAuthKeyAlive() {
|
|
b.Websocket.Wg.Add(1)
|
|
defer b.Websocket.Wg.Done()
|
|
ticks := time.NewTicker(time.Minute * 30)
|
|
for {
|
|
select {
|
|
case <-b.Websocket.ShutdownC:
|
|
ticks.Stop()
|
|
return
|
|
case <-ticks.C:
|
|
err := b.MaintainWsAuthStreamKey(context.TODO())
|
|
if err != nil {
|
|
b.Websocket.DataHandler <- err
|
|
log.Warnf(log.ExchangeSys,
|
|
b.Name+" - Unable to renew auth websocket token, may experience shutdown")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// wsReadData receives and passes on websocket messages for processing
|
|
func (b *Binance) 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 *Binance) wsHandleData(respRaw []byte) error {
|
|
var multiStreamData map[string]interface{}
|
|
err := json.Unmarshal(respRaw, &multiStreamData)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if r, ok := multiStreamData["result"]; ok {
|
|
if r == nil {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if method, ok := multiStreamData["method"].(string); ok {
|
|
// TODO handle subscription handling
|
|
if strings.EqualFold(method, "subscribe") {
|
|
return nil
|
|
}
|
|
if strings.EqualFold(method, "unsubscribe") {
|
|
return nil
|
|
}
|
|
}
|
|
if newdata, ok := multiStreamData["data"].(map[string]interface{}); ok {
|
|
if e, ok := newdata["e"].(string); ok {
|
|
switch e {
|
|
case "outboundAccountInfo":
|
|
var data wsAccountInfo
|
|
err := json.Unmarshal(respRaw, &data)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - Could not convert to outboundAccountInfo structure %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
b.Websocket.DataHandler <- data
|
|
return nil
|
|
case "outboundAccountPosition":
|
|
var data wsAccountPosition
|
|
err := json.Unmarshal(respRaw, &data)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - Could not convert to outboundAccountPosition structure %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
b.Websocket.DataHandler <- data
|
|
return nil
|
|
case "balanceUpdate":
|
|
var data wsBalanceUpdate
|
|
err := json.Unmarshal(respRaw, &data)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - Could not convert to balanceUpdate structure %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
b.Websocket.DataHandler <- data
|
|
return nil
|
|
case "executionReport":
|
|
var data wsOrderUpdate
|
|
err := json.Unmarshal(respRaw, &data)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - Could not convert to executionReport structure %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
averagePrice := 0.0
|
|
if data.Data.CumulativeFilledQuantity != 0 {
|
|
averagePrice = data.Data.CumulativeQuoteTransactedQuantity / data.Data.CumulativeFilledQuantity
|
|
}
|
|
remainingAmount := data.Data.Quantity - data.Data.CumulativeFilledQuantity
|
|
pair, assetType, err := b.GetRequestFormattedPairAndAssetType(data.Data.Symbol)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var feeAsset currency.Code
|
|
if data.Data.CommissionAsset != "" {
|
|
feeAsset = currency.NewCode(data.Data.CommissionAsset)
|
|
}
|
|
orderID := strconv.FormatInt(data.Data.OrderID, 10)
|
|
orderStatus, err := stringToOrderStatus(data.Data.OrderStatus)
|
|
if err != nil {
|
|
b.Websocket.DataHandler <- order.ClassificationError{
|
|
Exchange: b.Name,
|
|
OrderID: orderID,
|
|
Err: err,
|
|
}
|
|
}
|
|
clientOrderID := data.Data.ClientOrderID
|
|
if orderStatus == order.Cancelled {
|
|
clientOrderID = data.Data.CancelledClientOrderID
|
|
}
|
|
orderType, err := order.StringToOrderType(data.Data.OrderType)
|
|
if err != nil {
|
|
b.Websocket.DataHandler <- order.ClassificationError{
|
|
Exchange: b.Name,
|
|
OrderID: orderID,
|
|
Err: err,
|
|
}
|
|
}
|
|
orderSide, err := order.StringToOrderSide(data.Data.Side)
|
|
if err != nil {
|
|
b.Websocket.DataHandler <- order.ClassificationError{
|
|
Exchange: b.Name,
|
|
OrderID: orderID,
|
|
Err: err,
|
|
}
|
|
}
|
|
b.Websocket.DataHandler <- &order.Detail{
|
|
Price: data.Data.Price,
|
|
Amount: data.Data.Quantity,
|
|
AverageExecutedPrice: averagePrice,
|
|
ExecutedAmount: data.Data.CumulativeFilledQuantity,
|
|
RemainingAmount: remainingAmount,
|
|
Cost: data.Data.CumulativeQuoteTransactedQuantity,
|
|
CostAsset: pair.Quote,
|
|
Fee: data.Data.Commission,
|
|
FeeAsset: feeAsset,
|
|
Exchange: b.Name,
|
|
OrderID: orderID,
|
|
ClientOrderID: clientOrderID,
|
|
Type: orderType,
|
|
Side: orderSide,
|
|
Status: orderStatus,
|
|
AssetType: assetType,
|
|
Date: data.Data.OrderCreationTime,
|
|
LastUpdated: data.Data.TransactionTime,
|
|
Pair: pair,
|
|
}
|
|
return nil
|
|
case "listStatus":
|
|
var data wsListStatus
|
|
err := json.Unmarshal(respRaw, &data)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - Could not convert to listStatus structure %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
b.Websocket.DataHandler <- data
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
if wsStream, ok := multiStreamData["stream"].(string); ok {
|
|
streamType := strings.Split(wsStream, "@")
|
|
if len(streamType) > 1 {
|
|
if data, ok := multiStreamData["data"]; ok {
|
|
rawData, err := json.Marshal(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
pairs, err := b.GetEnabledPairs(asset.Spot)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
format, err := b.GetPairFormat(asset.Spot, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch streamType[1] {
|
|
case "trade":
|
|
saveTradeData := b.IsSaveTradeDataEnabled()
|
|
|
|
if !saveTradeData &&
|
|
!b.IsTradeFeedEnabled() {
|
|
return nil
|
|
}
|
|
|
|
var t TradeStream
|
|
err := json.Unmarshal(rawData, &t)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - Could not unmarshal trade data: %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
|
|
price, err := strconv.ParseFloat(t.Price, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - price conversion error: %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
|
|
amount, err := strconv.ParseFloat(t.Quantity, 64)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - amount conversion error: %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
|
|
pair, err := currency.NewPairFromFormattedPairs(t.Symbol, pairs, format)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return b.Websocket.Trade.Update(saveTradeData,
|
|
trade.Data{
|
|
CurrencyPair: pair,
|
|
Timestamp: t.TimeStamp,
|
|
Price: price,
|
|
Amount: amount,
|
|
Exchange: b.Name,
|
|
AssetType: asset.Spot,
|
|
TID: strconv.FormatInt(t.TradeID, 10),
|
|
})
|
|
case "ticker":
|
|
var t TickerStream
|
|
err := json.Unmarshal(rawData, &t)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - Could not convert to a TickerStream structure %s",
|
|
b.Name,
|
|
err.Error())
|
|
}
|
|
|
|
pair, err := currency.NewPairFromFormattedPairs(t.Symbol, pairs, format)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
b.Websocket.DataHandler <- &ticker.Price{
|
|
ExchangeName: b.Name,
|
|
Open: t.OpenPrice,
|
|
Close: t.ClosePrice,
|
|
Volume: t.TotalTradedVolume,
|
|
QuoteVolume: t.TotalTradedQuoteVolume,
|
|
High: t.HighPrice,
|
|
Low: t.LowPrice,
|
|
Bid: t.BestBidPrice,
|
|
Ask: t.BestAskPrice,
|
|
Last: t.LastPrice,
|
|
LastUpdated: t.EventTime,
|
|
AssetType: asset.Spot,
|
|
Pair: pair,
|
|
}
|
|
return nil
|
|
case "kline_1m", "kline_3m", "kline_5m", "kline_15m", "kline_30m", "kline_1h", "kline_2h", "kline_4h",
|
|
"kline_6h", "kline_8h", "kline_12h", "kline_1d", "kline_3d", "kline_1w", "kline_1M":
|
|
var kline KlineStream
|
|
err := json.Unmarshal(rawData, &kline)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - Could not convert to a KlineStream structure %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
|
|
pair, err := currency.NewPairFromFormattedPairs(kline.Symbol, pairs, format)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
b.Websocket.DataHandler <- stream.KlineData{
|
|
Timestamp: kline.EventTime,
|
|
Pair: pair,
|
|
AssetType: asset.Spot,
|
|
Exchange: b.Name,
|
|
StartTime: kline.Kline.StartTime,
|
|
CloseTime: kline.Kline.CloseTime,
|
|
Interval: kline.Kline.Interval,
|
|
OpenPrice: kline.Kline.OpenPrice,
|
|
ClosePrice: kline.Kline.ClosePrice,
|
|
HighPrice: kline.Kline.HighPrice,
|
|
LowPrice: kline.Kline.LowPrice,
|
|
Volume: kline.Kline.Volume,
|
|
}
|
|
return nil
|
|
case "depth":
|
|
var depth WebsocketDepthStream
|
|
err := json.Unmarshal(rawData, &depth)
|
|
if err != nil {
|
|
return fmt.Errorf("%v - Could not convert to depthStream structure %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
init, err := b.UpdateLocalBuffer(&depth)
|
|
if err != nil {
|
|
if init {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%v - UpdateLocalCache error: %s",
|
|
b.Name,
|
|
err)
|
|
}
|
|
return nil
|
|
default:
|
|
b.Websocket.DataHandler <- stream.UnhandledMessageWarning{
|
|
Message: b.Name + stream.UnhandledMessage + string(respRaw),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return fmt.Errorf("unhandled stream data %s", string(respRaw))
|
|
}
|
|
|
|
func stringToOrderStatus(status string) (order.Status, error) {
|
|
switch status {
|
|
case "NEW":
|
|
return order.New, nil
|
|
case "PARTIALLY_FILLED":
|
|
return order.PartiallyFilled, nil
|
|
case "FILLED":
|
|
return order.Filled, nil
|
|
case "CANCELED":
|
|
return order.Cancelled, nil
|
|
case "PENDING_CANCEL":
|
|
return order.PendingCancel, nil
|
|
case "REJECTED":
|
|
return order.Rejected, nil
|
|
case "EXPIRED":
|
|
return order.Expired, nil
|
|
default:
|
|
return order.UnknownStatus, errors.New(status + " not recognised as order status")
|
|
}
|
|
}
|
|
|
|
// SeedLocalCache seeds depth data
|
|
func (b *Binance) SeedLocalCache(ctx context.Context, p currency.Pair) error {
|
|
ob, err := b.GetOrderBook(ctx,
|
|
OrderBookDataRequestParams{
|
|
Symbol: p,
|
|
Limit: 1000,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return b.SeedLocalCacheWithBook(p, ob)
|
|
}
|
|
|
|
// SeedLocalCacheWithBook seeds the local orderbook cache
|
|
func (b *Binance) SeedLocalCacheWithBook(p currency.Pair, orderbookNew *OrderBook) error {
|
|
newOrderBook := orderbook.Base{
|
|
Pair: p,
|
|
Asset: asset.Spot,
|
|
Exchange: b.Name,
|
|
LastUpdateID: orderbookNew.LastUpdateID,
|
|
VerifyOrderbook: b.CanVerifyOrderbook,
|
|
Bids: make(orderbook.Items, len(orderbookNew.Bids)),
|
|
Asks: make(orderbook.Items, len(orderbookNew.Asks)),
|
|
LastUpdated: time.Now(), // Time not provided in REST book.
|
|
}
|
|
for i := range orderbookNew.Bids {
|
|
newOrderBook.Bids[i] = orderbook.Item{
|
|
Amount: orderbookNew.Bids[i].Quantity,
|
|
Price: orderbookNew.Bids[i].Price,
|
|
}
|
|
}
|
|
for i := range orderbookNew.Asks {
|
|
newOrderBook.Asks[i] = orderbook.Item{
|
|
Amount: orderbookNew.Asks[i].Quantity,
|
|
Price: orderbookNew.Asks[i].Price,
|
|
}
|
|
}
|
|
return b.Websocket.Orderbook.LoadSnapshot(&newOrderBook)
|
|
}
|
|
|
|
// UpdateLocalBuffer updates and returns the most recent iteration of the orderbook
|
|
func (b *Binance) UpdateLocalBuffer(wsdp *WebsocketDepthStream) (bool, error) {
|
|
enabledPairs, err := b.GetEnabledPairs(asset.Spot)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
format, err := b.GetPairFormat(asset.Spot, true)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
currencyPair, err := currency.NewPairFromFormattedPairs(wsdp.Pair,
|
|
enabledPairs,
|
|
format)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
err = b.obm.stageWsUpdate(wsdp, currencyPair, asset.Spot)
|
|
if err != nil {
|
|
init, err2 := b.obm.checkIsInitialSync(currencyPair)
|
|
if err2 != nil {
|
|
return false, err2
|
|
}
|
|
return init, err
|
|
}
|
|
|
|
err = b.applyBufferUpdate(currencyPair)
|
|
if err != nil {
|
|
b.flushAndCleanup(currencyPair)
|
|
}
|
|
|
|
return false, err
|
|
}
|
|
|
|
// GenerateSubscriptions generates the default subscription set
|
|
func (b *Binance) GenerateSubscriptions() ([]stream.ChannelSubscription, error) {
|
|
var channels = []string{"@ticker", "@trade", "@kline_1m", "@depth@100ms"}
|
|
var subscriptions []stream.ChannelSubscription
|
|
assets := b.GetAssetTypes(true)
|
|
for x := range assets {
|
|
if assets[x] == asset.Spot {
|
|
pairs, err := b.GetEnabledPairs(assets[x])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for y := range pairs {
|
|
for z := range channels {
|
|
lp := pairs[y].Lower()
|
|
lp.Delimiter = ""
|
|
subscriptions = append(subscriptions, stream.ChannelSubscription{
|
|
Channel: lp.String() + channels[z],
|
|
Currency: pairs[y],
|
|
Asset: assets[x],
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return subscriptions, nil
|
|
}
|
|
|
|
// Subscribe subscribes to a set of channels
|
|
func (b *Binance) Subscribe(channelsToSubscribe []stream.ChannelSubscription) error {
|
|
payload := WsPayload{
|
|
Method: "SUBSCRIBE",
|
|
}
|
|
for i := range channelsToSubscribe {
|
|
payload.Params = append(payload.Params, channelsToSubscribe[i].Channel)
|
|
if i%50 == 0 && i != 0 {
|
|
err := b.Websocket.Conn.SendJSONMessage(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload.Params = []string{}
|
|
}
|
|
}
|
|
if len(payload.Params) > 0 {
|
|
err := b.Websocket.Conn.SendJSONMessage(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
b.Websocket.AddSuccessfulSubscriptions(channelsToSubscribe...)
|
|
return nil
|
|
}
|
|
|
|
// Unsubscribe unsubscribes from a set of channels
|
|
func (b *Binance) Unsubscribe(channelsToUnsubscribe []stream.ChannelSubscription) error {
|
|
payload := WsPayload{
|
|
Method: "UNSUBSCRIBE",
|
|
}
|
|
for i := range channelsToUnsubscribe {
|
|
payload.Params = append(payload.Params, channelsToUnsubscribe[i].Channel)
|
|
if i%50 == 0 && i != 0 {
|
|
err := b.Websocket.Conn.SendJSONMessage(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
payload.Params = []string{}
|
|
}
|
|
}
|
|
if len(payload.Params) > 0 {
|
|
err := b.Websocket.Conn.SendJSONMessage(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
b.Websocket.RemoveSubscriptions(channelsToUnsubscribe...)
|
|
return nil
|
|
}
|
|
|
|
// ProcessUpdate processes the websocket orderbook update
|
|
func (b *Binance) ProcessUpdate(cp currency.Pair, a asset.Item, ws *WebsocketDepthStream) error {
|
|
updateBid := make([]orderbook.Item, len(ws.UpdateBids))
|
|
for i := range ws.UpdateBids {
|
|
price, ok := ws.UpdateBids[i][0].(string)
|
|
if !ok {
|
|
return errors.New("type assertion failed for bid price")
|
|
}
|
|
p, err := strconv.ParseFloat(price, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
amount, ok := ws.UpdateBids[i][1].(string)
|
|
if !ok {
|
|
return errors.New("type assertion failed for bid amount")
|
|
}
|
|
a, err := strconv.ParseFloat(amount, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updateBid[i] = orderbook.Item{Price: p, Amount: a}
|
|
}
|
|
|
|
updateAsk := make([]orderbook.Item, len(ws.UpdateAsks))
|
|
for i := range ws.UpdateAsks {
|
|
price, ok := ws.UpdateAsks[i][0].(string)
|
|
if !ok {
|
|
return errors.New("type assertion failed for ask price")
|
|
}
|
|
p, err := strconv.ParseFloat(price, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
amount, ok := ws.UpdateAsks[i][1].(string)
|
|
if !ok {
|
|
return errors.New("type assertion failed for ask amount")
|
|
}
|
|
a, err := strconv.ParseFloat(amount, 64)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updateAsk[i] = orderbook.Item{Price: p, Amount: a}
|
|
}
|
|
|
|
return b.Websocket.Orderbook.Update(&orderbook.Update{
|
|
Bids: updateBid,
|
|
Asks: updateAsk,
|
|
Pair: cp,
|
|
UpdateID: ws.LastUpdateID,
|
|
UpdateTime: ws.Timestamp,
|
|
Asset: a,
|
|
})
|
|
}
|
|
|
|
// applyBufferUpdate applies the buffer to the orderbook or initiates a new
|
|
// orderbook sync by the REST protocol which is off handed to go routine.
|
|
func (b *Binance) applyBufferUpdate(pair currency.Pair) error {
|
|
fetching, needsFetching, err := b.obm.handleFetchingBook(pair)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if fetching {
|
|
return nil
|
|
}
|
|
if needsFetching {
|
|
if b.Verbose {
|
|
log.Debugf(log.WebsocketMgr, "%s Orderbook: Fetching via REST\n", b.Name)
|
|
}
|
|
return b.obm.fetchBookViaREST(pair)
|
|
}
|
|
|
|
recent, err := b.Websocket.Orderbook.GetOrderbook(pair, asset.Spot)
|
|
if err != nil {
|
|
log.Errorf(
|
|
log.WebsocketMgr,
|
|
"%s error fetching recent orderbook when applying updates: %s\n",
|
|
b.Name,
|
|
err)
|
|
}
|
|
|
|
if recent != nil {
|
|
err = b.obm.checkAndProcessUpdate(b.ProcessUpdate, pair, recent)
|
|
if err != nil {
|
|
log.Errorf(
|
|
log.WebsocketMgr,
|
|
"%s error processing update - initiating new orderbook sync via REST: %s\n",
|
|
b.Name,
|
|
err)
|
|
err = b.obm.setNeedsFetchingBook(pair)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setNeedsFetchingBook completes the book fetching initiation.
|
|
func (o *orderbookManager) setNeedsFetchingBook(pair currency.Pair) error {
|
|
o.Lock()
|
|
defer o.Unlock()
|
|
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
|
if !ok {
|
|
return fmt.Errorf("could not match pair %s and asset type %s in hash table",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
state.needsFetchingBook = true
|
|
return nil
|
|
}
|
|
|
|
// SynchroniseWebsocketOrderbook synchronises full orderbook for currency pair
|
|
// asset
|
|
func (b *Binance) SynchroniseWebsocketOrderbook() {
|
|
b.Websocket.Wg.Add(1)
|
|
go func() {
|
|
defer b.Websocket.Wg.Done()
|
|
for {
|
|
select {
|
|
case <-b.Websocket.ShutdownC:
|
|
for {
|
|
select {
|
|
case <-b.obm.jobs:
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
case j := <-b.obm.jobs:
|
|
err := b.processJob(j.Pair)
|
|
if err != nil {
|
|
log.Errorf(log.WebsocketMgr,
|
|
"%s processing websocket orderbook error %v",
|
|
b.Name, err)
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// processJob fetches and processes orderbook updates
|
|
func (b *Binance) processJob(p currency.Pair) error {
|
|
err := b.SeedLocalCache(context.TODO(), p)
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s seeding local cache for orderbook error: %v",
|
|
p, asset.Spot, err)
|
|
}
|
|
|
|
err = b.obm.stopFetchingBook(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Immediately apply the buffer updates so we don't wait for a
|
|
// new update to initiate this.
|
|
err = b.applyBufferUpdate(p)
|
|
if err != nil {
|
|
b.flushAndCleanup(p)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// flushAndCleanup flushes orderbook and clean local cache
|
|
func (b *Binance) flushAndCleanup(p currency.Pair) {
|
|
errClean := b.Websocket.Orderbook.FlushOrderbook(p, asset.Spot)
|
|
if errClean != nil {
|
|
log.Errorf(log.WebsocketMgr,
|
|
"%s flushing websocket error: %v",
|
|
b.Name,
|
|
errClean)
|
|
}
|
|
errClean = b.obm.cleanup(p)
|
|
if errClean != nil {
|
|
log.Errorf(log.WebsocketMgr, "%s cleanup websocket error: %v",
|
|
b.Name,
|
|
errClean)
|
|
}
|
|
}
|
|
|
|
// stageWsUpdate stages websocket update to roll through updates that need to
|
|
// be applied to a fetched orderbook via REST.
|
|
func (o *orderbookManager) stageWsUpdate(u *WebsocketDepthStream, pair currency.Pair, a asset.Item) error {
|
|
o.Lock()
|
|
defer o.Unlock()
|
|
m1, ok := o.state[pair.Base]
|
|
if !ok {
|
|
m1 = make(map[currency.Code]map[asset.Item]*update)
|
|
o.state[pair.Base] = m1
|
|
}
|
|
|
|
m2, ok := m1[pair.Quote]
|
|
if !ok {
|
|
m2 = make(map[asset.Item]*update)
|
|
m1[pair.Quote] = m2
|
|
}
|
|
|
|
state, ok := m2[a]
|
|
if !ok {
|
|
state = &update{
|
|
// 100ms update assuming we might have up to a 10 second delay.
|
|
// There could be a potential 100 updates for the currency.
|
|
buffer: make(chan *WebsocketDepthStream, maxWSUpdateBuffer),
|
|
fetchingBook: false,
|
|
initialSync: true,
|
|
needsFetchingBook: true,
|
|
}
|
|
m2[a] = state
|
|
}
|
|
|
|
if state.lastUpdateID != 0 && u.FirstUpdateID != state.lastUpdateID+1 {
|
|
// While listening to the stream, each new event's U should be
|
|
// equal to the previous event's u+1.
|
|
return fmt.Errorf("websocket orderbook synchronisation failure for pair %s and asset %s", pair, a)
|
|
}
|
|
state.lastUpdateID = u.LastUpdateID
|
|
|
|
select {
|
|
// Put update in the channel buffer to be processed
|
|
case state.buffer <- u:
|
|
return nil
|
|
default:
|
|
<-state.buffer // pop one element
|
|
state.buffer <- u // to shift buffer on fail
|
|
return fmt.Errorf("channel blockage for %s, asset %s and connection",
|
|
pair, a)
|
|
}
|
|
}
|
|
|
|
// handleFetchingBook checks if a full book is being fetched or needs to be
|
|
// fetched
|
|
func (o *orderbookManager) handleFetchingBook(pair currency.Pair) (fetching, needsFetching bool, err error) {
|
|
o.Lock()
|
|
defer o.Unlock()
|
|
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
|
if !ok {
|
|
return false,
|
|
false,
|
|
fmt.Errorf("check is fetching book cannot match currency pair %s asset type %s",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
|
|
if state.fetchingBook {
|
|
return true, false, nil
|
|
}
|
|
|
|
if state.needsFetchingBook {
|
|
state.needsFetchingBook = false
|
|
state.fetchingBook = true
|
|
return false, true, nil
|
|
}
|
|
return false, false, nil
|
|
}
|
|
|
|
// stopFetchingBook completes the book fetching.
|
|
func (o *orderbookManager) stopFetchingBook(pair currency.Pair) error {
|
|
o.Lock()
|
|
defer o.Unlock()
|
|
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
|
if !ok {
|
|
return fmt.Errorf("could not match pair %s and asset type %s in hash table",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
if !state.fetchingBook {
|
|
return fmt.Errorf("fetching book already set to false for %s %s",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
state.fetchingBook = false
|
|
return nil
|
|
}
|
|
|
|
// completeInitialSync sets if an asset type has completed its initial sync
|
|
func (o *orderbookManager) completeInitialSync(pair currency.Pair) error {
|
|
o.Lock()
|
|
defer o.Unlock()
|
|
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
|
if !ok {
|
|
return fmt.Errorf("complete initial sync cannot match currency pair %s asset type %s",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
if !state.initialSync {
|
|
return fmt.Errorf("initial sync already set to false for %s %s",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
state.initialSync = false
|
|
return nil
|
|
}
|
|
|
|
// checkIsInitialSync checks status if the book is Initial Sync being via the REST
|
|
// protocol.
|
|
func (o *orderbookManager) checkIsInitialSync(pair currency.Pair) (bool, error) {
|
|
o.Lock()
|
|
defer o.Unlock()
|
|
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
|
if !ok {
|
|
return false,
|
|
fmt.Errorf("checkIsInitialSync of orderbook cannot match currency pair %s asset type %s",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
return state.initialSync, nil
|
|
}
|
|
|
|
// fetchBookViaREST pushes a job of fetching the orderbook via the REST protocol
|
|
// to get an initial full book that we can apply our buffered updates too.
|
|
func (o *orderbookManager) fetchBookViaREST(pair currency.Pair) error {
|
|
o.Lock()
|
|
defer o.Unlock()
|
|
|
|
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
|
if !ok {
|
|
return fmt.Errorf("fetch book via rest cannot match currency pair %s asset type %s",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
|
|
state.initialSync = true
|
|
state.fetchingBook = true
|
|
|
|
select {
|
|
case o.jobs <- job{pair}:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("%s %s book synchronisation channel blocked up",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
}
|
|
|
|
func (o *orderbookManager) checkAndProcessUpdate(processor func(currency.Pair, asset.Item, *WebsocketDepthStream) error, pair currency.Pair, recent *orderbook.Base) error {
|
|
o.Lock()
|
|
defer o.Unlock()
|
|
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
|
if !ok {
|
|
return fmt.Errorf("could not match pair [%s] asset type [%s] in hash table to process websocket orderbook update",
|
|
pair, asset.Spot)
|
|
}
|
|
|
|
// This will continuously remove updates from the buffered channel and
|
|
// apply them to the current orderbook.
|
|
buffer:
|
|
for {
|
|
select {
|
|
case d := <-state.buffer:
|
|
process, err := state.validate(d, recent)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if process {
|
|
err := processor(pair, asset.Spot, d)
|
|
if err != nil {
|
|
return fmt.Errorf("%s %s processing update error: %w",
|
|
pair, asset.Spot, err)
|
|
}
|
|
}
|
|
default:
|
|
break buffer
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validate checks for correct update alignment
|
|
func (u *update) validate(updt *WebsocketDepthStream, recent *orderbook.Base) (bool, error) {
|
|
if updt.LastUpdateID <= recent.LastUpdateID {
|
|
// Drop any event where u is <= lastUpdateId in the snapshot.
|
|
return false, nil
|
|
}
|
|
|
|
id := recent.LastUpdateID + 1
|
|
if u.initialSync {
|
|
// The first processed event should have U <= lastUpdateId+1 AND
|
|
// u >= lastUpdateId+1.
|
|
if updt.FirstUpdateID > id || updt.LastUpdateID < id {
|
|
return false, fmt.Errorf("initial websocket orderbook sync failure for pair %s and asset %s",
|
|
recent.Pair,
|
|
asset.Spot)
|
|
}
|
|
u.initialSync = false
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// cleanup cleans up buffer and reset fetch and init
|
|
func (o *orderbookManager) cleanup(pair currency.Pair) error {
|
|
o.Lock()
|
|
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
|
if !ok {
|
|
o.Unlock()
|
|
return fmt.Errorf("cleanup cannot match %s %s to hash table",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
|
|
bufferEmpty:
|
|
for {
|
|
select {
|
|
case <-state.buffer:
|
|
// bleed and discard buffer
|
|
default:
|
|
break bufferEmpty
|
|
}
|
|
}
|
|
o.Unlock()
|
|
// disable rest orderbook synchronisation
|
|
_ = o.stopFetchingBook(pair)
|
|
_ = o.completeInitialSync(pair)
|
|
_ = o.stopNeedsFetchingBook(pair)
|
|
return nil
|
|
}
|
|
|
|
// stopNeedsFetchingBook completes the book fetching initiation.
|
|
func (o *orderbookManager) stopNeedsFetchingBook(pair currency.Pair) error {
|
|
o.Lock()
|
|
defer o.Unlock()
|
|
state, ok := o.state[pair.Base][pair.Quote][asset.Spot]
|
|
if !ok {
|
|
return fmt.Errorf("could not match pair %s and asset type %s in hash table",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
if !state.needsFetchingBook {
|
|
return fmt.Errorf("needs fetching book already set to false for %s %s",
|
|
pair,
|
|
asset.Spot)
|
|
}
|
|
state.needsFetchingBook = false
|
|
return nil
|
|
}
|