github.com/diadata-org/diadata@v1.4.593/pkg/dia/scraper/exchange-scrapers/BitMartScraper.go (about)

     1  package scrapers
     2  
     3  import (
     4  	"bytes"
     5  	"compress/flate"
     6  	"encoding/json"
     7  	"errors"
     8  	"fmt"
     9  	"io/ioutil"
    10  	"strconv"
    11  	"strings"
    12  	"sync"
    13  	"time"
    14  
    15  	ws "github.com/gorilla/websocket"
    16  	"github.com/zekroTJA/timedmap"
    17  
    18  	"github.com/diadata-org/diadata/pkg/dia"
    19  	models "github.com/diadata-org/diadata/pkg/model"
    20  	"github.com/diadata-org/diadata/pkg/utils"
    21  )
    22  
    23  const (
    24  	bitMartAPIEndpoint          = "https://api-cloud.bitmart.com/spot/v1"
    25  	bitMartWSEndpoint           = "wss://ws-manager-compress.bitmart.com/api?protocol=1.1"
    26  	bitMartSpotTradingSell      = "sell"
    27  	bitMartSymbolsStatusActive  = "trading"
    28  	bitMartPingMessage          = "ping"
    29  	bitMartPongMessage          = "pong"
    30  	bitMartWSSpotTradingTopic   = "spot/trade"
    31  	bitMartWSOpSubscribe        = "subscribe"
    32  	bitMartWSOpUnsubscribe      = "unsubscribe"
    33  	bitMartRetryAttempts        = 15  // Max consecutive retry attempts until connection fail.
    34  	bitMartPingInterval         = 15  // Number of seconds between ping messages.
    35  	bitMartMaxConnections       = 10  // Numbers of connections per IP.
    36  	bitMartMaxSubsPerConnection = 100 // Subscription limit for each connection.
    37  )
    38  
    39  type BitmartWsRequest struct {
    40  	Op   string   `json:"op"`
    41  	Args []string `json:"args"`
    42  }
    43  
    44  type BitmartHttpSymbolsDetailsResponse struct {
    45  	Message string `json:"message"`
    46  	Code    int    `json:"code"`
    47  	Trace   string `json:"trace"`
    48  	Data    struct {
    49  		Symbols []struct {
    50  			Symbol            string `json:"symbol"`
    51  			SymbolId          int    `json:"symbol_id"`
    52  			BaseCurrency      string `json:"base_currency"`
    53  			QuoteCurrency     string `json:"quote_currency"`
    54  			QuoteIncrement    string `json:"quote_increment"`
    55  			BaseMinSize       string `json:"base_min_size"`
    56  			PriceMinPrecision int    `json:"price_min_precision"`
    57  			PriceMaxPrecision int    `json:"price_max_precision"`
    58  			Expiration        string `json:"expiration"`
    59  			MinBuyAmount      string `json:"min_buy_amount"`
    60  			MinSellAmount     string `json:"min_sell_amount"`
    61  			TradeStatus       string `json:"trade_status"`
    62  		} `json:"symbols"`
    63  	} `json:"data"`
    64  }
    65  type BitmartWsTradeResponse struct {
    66  	Table string `json:"table"`
    67  	Data  []struct {
    68  		Symbol       string `json:"symbol"`
    69  		Price        string `json:"price"`
    70  		Side         string `json:"side"`
    71  		Size         string `json:"size"`
    72  		TimestampSec int    `json:"s_t"`
    73  	} `json:"data"`
    74  	ErrorMessage string `json:"errorMessage"`
    75  	ErrorCode    string `json:"errorCode"`
    76  	Event        string `json:"event"`
    77  }
    78  
    79  type bitMartPairScraperSet map[*BitMartPairScraper]nothing
    80  
    81  // BitMartScraper is a scraper for BitMart
    82  type BitMartScraper struct {
    83  	// the websocket connection to the BitMart API
    84  	wsClient           []*ws.Conn
    85  	errCount           []int
    86  	countTopic         []int
    87  	lastUsedConnection int
    88  	listener           chan *BitmartWsTradeResponse
    89  	// signaling channels for session initialization and finishing
    90  	shutdown     chan nothing
    91  	shutdownDone chan nothing
    92  	// error handling; err should be read from error(), closed should be read from isClosed()
    93  	// those two methods implement RW lock
    94  	// only cleanup method should hold write lock
    95  	closedMutex sync.RWMutex // don't need to lock on read
    96  	closed      bool
    97  	errMutex    sync.RWMutex
    98  	err         error
    99  	// used to keep track of trading pairs that we subscribed to
   100  	// use sync.Maps to concurrently handle multiple pairs
   101  	pairScrapers      sync.Map // dia.ExchangePair -> bitMartPairScraperSet
   102  	pairSubscriptions sync.Map // dia.ExchangePair -> int (connection ID)
   103  	exchangeName      string
   104  	chanTrades        chan *dia.Trade
   105  	db                *models.RelDB
   106  }
   107  
   108  // NewBitMartScraper returns a new BitMart scraper
   109  func NewBitMartScraper(exchange dia.Exchange, scrape bool, relDB *models.RelDB) *BitMartScraper {
   110  	s := &BitMartScraper{
   111  		wsClient:     make([]*ws.Conn, bitMartMaxConnections),
   112  		errCount:     make([]int, bitMartMaxConnections),
   113  		countTopic:   make([]int, bitMartMaxConnections),
   114  		listener:     make(chan *BitmartWsTradeResponse),
   115  		shutdown:     make(chan nothing),
   116  		shutdownDone: make(chan nothing),
   117  		closed:       false,
   118  		err:          nil,
   119  		exchangeName: exchange.Name,
   120  		chanTrades:   make(chan *dia.Trade),
   121  		db:           relDB,
   122  	}
   123  	for i := 0; i < bitMartMaxConnections; i++ {
   124  		var wsDialer ws.Dialer
   125  		wsConn, _, err := wsDialer.Dial(bitMartWSEndpoint, nil)
   126  		if err != nil {
   127  			println(err.Error())
   128  		}
   129  		s.wsClient[i] = wsConn
   130  	}
   131  	if scrape {
   132  		go s.mainLoop()
   133  	}
   134  	return s
   135  }
   136  
   137  // Close closes any existing API connections, as well as channels of
   138  // PairScrapers from calls to ScrapePair
   139  func (s *BitMartScraper) Close() error {
   140  	if s.isClosed() {
   141  		return errors.New("scraper already closed")
   142  	}
   143  	s.close()
   144  	close(s.shutdown)
   145  	for i := 0; i < bitMartMaxConnections; i++ {
   146  		err := s.wsClient[i].Close()
   147  		if err != nil {
   148  			return fmt.Errorf("Close Error: %s", err.Error())
   149  		}
   150  	}
   151  	<-s.shutdownDone
   152  	return s.error()
   153  }
   154  
   155  // ScrapePair returns a PairScraper that can be used to get trades for a single pair from the BitMart scraper
   156  func (s *BitMartScraper) ScrapePair(pair dia.ExchangePair) (PairScraper, error) {
   157  	if err := s.error(); err != nil {
   158  		return nil, err
   159  	}
   160  	if s.isClosed() {
   161  		return nil, errors.New("scraper is closed")
   162  	}
   163  	ps := &BitMartPairScraper{
   164  		parent: s,
   165  		pair:   pair,
   166  	}
   167  	pairScrapers, _ := s.pairScrapers.LoadOrStore(pair.ForeignName, bitMartPairScraperSet{})
   168  	pairScrapers.(bitMartPairScraperSet)[ps] = nothing{}
   169  	if _, ok := s.pairSubscriptions.Load(pair.ForeignName); !ok {
   170  		wsIdx := s.lastUsedConnection
   171  		if cIdx := s.lastUsedConnection; s.countTopic[cIdx] < bitMartMaxSubsPerConnection {
   172  			wsIdx = cIdx
   173  		} else {
   174  			for i := 0; i < bitMartMaxConnections; i++ {
   175  				if s.countTopic[i] < bitMartMaxSubsPerConnection {
   176  					wsIdx = i
   177  				}
   178  			}
   179  		}
   180  		if err := s.subscribe(pair.ForeignName, wsIdx); err != nil {
   181  			delete(pairScrapers.(bitMartPairScraperSet), ps)
   182  			return nil, err
   183  		}
   184  		s.pairSubscriptions.Store(pair.ForeignName, wsIdx)
   185  	} else {
   186  		return nil, fmt.Errorf("pair %s already subscribed", pair.ForeignName)
   187  	}
   188  	return ps, nil
   189  }
   190  
   191  // FetchAvailablePairs returns a list with all available trade pairs
   192  func (s *BitMartScraper) FetchAvailablePairs() (pairs []dia.ExchangePair, err error) {
   193  	data, _, err := utils.GetRequest(bitMartAPIEndpoint + "/symbols/details")
   194  	if err != nil {
   195  		return
   196  	}
   197  	var res BitmartHttpSymbolsDetailsResponse
   198  	err = json.Unmarshal(data, &res)
   199  	if err == nil {
   200  		for _, p := range res.Data.Symbols {
   201  			if p.TradeStatus != bitMartSymbolsStatusActive {
   202  				continue
   203  			}
   204  			pair, err := s.NormalizePair(dia.ExchangePair{
   205  				Symbol:      p.BaseCurrency,
   206  				ForeignName: p.Symbol,
   207  				Exchange:    s.exchangeName,
   208  			})
   209  			if err != nil {
   210  				return nil, err
   211  			}
   212  			pairs = append(pairs, pair)
   213  		}
   214  	}
   215  	return pairs, nil
   216  }
   217  
   218  // Channel returns a channel that can be used to receive trades
   219  func (s *BitMartScraper) Channel() chan *dia.Trade {
   220  	return s.chanTrades
   221  }
   222  
   223  // TODO: FillSymbolData adds the name to the asset underlying @symbol on BitMart
   224  func (s *BitMartScraper) FillSymbolData(symbol string) (dia.Asset, error) {
   225  	return dia.Asset{Symbol: symbol}, nil
   226  }
   227  
   228  // TODO: NormalizePair accounts for the par
   229  func (s *BitMartScraper) NormalizePair(pair dia.ExchangePair) (dia.ExchangePair, error) {
   230  	return pair, nil
   231  }
   232  
   233  // BitMartPairScraper implements PairScraper for BitMart
   234  type BitMartPairScraper struct {
   235  	parent *BitMartScraper
   236  	pair   dia.ExchangePair
   237  	closed bool
   238  }
   239  
   240  // Close stops listening for trades of the pair associated with the BitMart scraper
   241  func (ps *BitMartPairScraper) Close() error {
   242  	if ps.closed {
   243  		return fmt.Errorf("pair %s already unsubscribed", ps.pair.ForeignName)
   244  	}
   245  	log.Infof("Closing %s pair scraper...", ps.pair.ForeignName)
   246  	if err := ps.parent.error(); err != nil {
   247  		return err
   248  	}
   249  	if err := ps.parent.unsubscribe(ps.pair.ForeignName); err != nil {
   250  		return err
   251  	}
   252  	ps.parent.pairSubscriptions.Delete(ps.pair.ForeignName)
   253  	ps.closed = true
   254  	return nil
   255  }
   256  
   257  // Error returns an error when the channel Channel() is closed and nil otherwise
   258  func (ps *BitMartPairScraper) Error() error {
   259  	return ps.parent.error()
   260  }
   261  
   262  // Pair returns the pair this scraper is subscribed to
   263  func (ps *BitMartPairScraper) Pair() dia.ExchangePair {
   264  	return ps.pair
   265  }
   266  
   267  // runs in a goroutine until s is closed
   268  func (s *BitMartScraper) mainLoop() {
   269  	defer s.cleanup(nil)
   270  	defer func() {
   271  		log.Printf("Shutting down main loop...\n")
   272  	}()
   273  	tmFalseDuplicateTrades := timedmap.New(duplicateTradesScanFrequency)
   274  	tmDuplicateTrades := timedmap.New(duplicateTradesScanFrequency)
   275  
   276  	for i := range bitMartMaxConnections {
   277  		go func(idx int) {
   278  			defer func() {
   279  				if a := recover(); a != nil {
   280  					log.Errorf("Work routine end. Recover msg: %+v", a)
   281  				}
   282  			}()
   283  			// ticker := time.NewTicker(bitMartPingInterval * time.Second)
   284  			// defer ticker.Stop()
   285  			// for {
   286  			// 	<-ticker.C
   287  			// 	if s.isClosed() {
   288  			// 		return
   289  			// 	}
   290  
   291  			// 	err := s.wsClient[idx].WriteMessage(ws.TextMessage, []byte(bitMartPingMessage))
   292  			// 	if err != nil {
   293  			// 		log.Errorf("Error sending ping: %s", err)
   294  			// 		return
   295  			// 	} else {
   296  			// 		log.Warn("sent ping")
   297  			// 	}
   298  			// }
   299  		}(i)
   300  		go func(idx int) {
   301  			defer func() {
   302  				if a := recover(); a != nil {
   303  					log.Errorf("Receive routine end. Recover msg: %+v", a)
   304  				}
   305  			}()
   306  			for {
   307  				if s.wsClient[idx] != nil {
   308  					msgType, msg, err := s.wsClient[idx].ReadMessage()
   309  					if err != nil {
   310  						if s.isClosed() || s.errCount[idx] > bitMartRetryAttempts {
   311  							return
   312  						}
   313  						if err := s.retryConnection(idx); err != nil || ws.IsCloseError(err, ws.CloseAbnormalClosure) {
   314  							return
   315  						}
   316  					}
   317  
   318  					var output []byte
   319  					switch msgType {
   320  					case ws.BinaryMessage:
   321  						reader := bytes.NewReader(msg)
   322  						gzreader := flate.NewReader(reader)
   323  						if err != nil {
   324  							log.Error("flate reader: ", err)
   325  							return
   326  						}
   327  						output, err = ioutil.ReadAll(gzreader)
   328  						if err != nil {
   329  							log.Error("read all: ", err)
   330  							return
   331  						}
   332  					case ws.TextMessage:
   333  						if string(msg) == bitMartPongMessage {
   334  							s.errCount[idx] = 0
   335  							return
   336  						}
   337  						output = msg
   338  					}
   339  
   340  					// Unmarshal output and forward to listener.
   341  					var subResults BitmartWsTradeResponse
   342  					if err := json.Unmarshal(output, &subResults); err != nil {
   343  						log.Errorf("Response error at connection #%d, err=%s\n", idx, err.Error())
   344  						s.setError(err)
   345  						s.errCount[idx]++
   346  						if err := s.retryConnection(idx); err != nil {
   347  							return
   348  						}
   349  					}
   350  					if subResults.ErrorCode != "" {
   351  						log.Errorf("Error code %s at %s event: %s", subResults.ErrorCode, subResults.Event, subResults.ErrorMessage)
   352  						s.errCount[idx]++
   353  						continue
   354  					}
   355  					if subResults.Table == bitMartWSSpotTradingTopic {
   356  						s.errCount[idx] = 0
   357  						s.listener <- &subResults
   358  					}
   359  				}
   360  			}
   361  		}(i)
   362  	}
   363  	for {
   364  		select {
   365  		case response := <-s.listener:
   366  
   367  			for _, data := range response.Data {
   368  				var exchangepair dia.ExchangePair
   369  				volume, _ := strconv.ParseFloat(data.Size, 64)
   370  				if data.Side == bitMartSpotTradingSell {
   371  					volume = -volume
   372  				}
   373  				price, _ := strconv.ParseFloat(data.Price, 64)
   374  				timestamp := time.Unix(int64(data.TimestampSec), 0)
   375  				symbol := strings.Split(data.Symbol, `_`)
   376  				exchangepair, err := s.db.GetExchangePairCache(s.exchangeName, data.Symbol)
   377  				if err != nil {
   378  					log.Error(err)
   379  				}
   380  				t := &dia.Trade{
   381  					Symbol:         symbol[0],
   382  					Pair:           data.Symbol,
   383  					Price:          price,
   384  					Time:           timestamp,
   385  					Volume:         volume,
   386  					Source:         s.exchangeName,
   387  					ForeignTradeID: fmt.Sprintf("%s_%d", data.Symbol, data.TimestampSec),
   388  					VerifiedPair:   exchangepair.Verified,
   389  					BaseToken:      exchangepair.UnderlyingPair.BaseToken,
   390  					QuoteToken:     exchangepair.UnderlyingPair.QuoteToken,
   391  				}
   392  
   393  				discardTrade := t.IdentifyDuplicateFull(tmFalseDuplicateTrades, duplicateTradesMemory)
   394  				if discardTrade {
   395  					log.Warn("Identical trade already scraped: ", t)
   396  					continue
   397  				} else {
   398  					t.IdentifyDuplicateTagset(tmDuplicateTrades, duplicateTradesMemory)
   399  					log.Infof("got trade at %v : %s -- %v -- %v", t.Time, t.Pair, t.Price, t.Volume)
   400  					s.chanTrades <- t
   401  				}
   402  			}
   403  		case <-s.shutdown:
   404  			return
   405  		}
   406  	}
   407  }
   408  
   409  func (s *BitMartScraper) retryConnection(idx int) error {
   410  	s.countTopic[idx] = 0
   411  	log.Errorf("Reconnecting connection #%d...\n", idx)
   412  	wsConn, _, err := ws.DefaultDialer.Dial(bitMartWSEndpoint, nil)
   413  	if err != nil {
   414  		s.errCount[idx]++
   415  		return err
   416  	}
   417  	s.wsClient[idx] = wsConn
   418  	subs := make([]string, 0)
   419  	s.pairSubscriptions.Range(func(k, v interface{}) bool {
   420  		if v.(int) == idx {
   421  			if foreignName := k.(string); foreignName != "" {
   422  				subs = append(subs, foreignName)
   423  			}
   424  		}
   425  		return true
   426  	})
   427  	for _, foreignName := range subs {
   428  		if err = s.subscribe(foreignName, idx); err != nil {
   429  			break
   430  		}
   431  	}
   432  	if err != nil {
   433  		log.Errorf("Recovering %d error., err=%s\n", idx, err.Error())
   434  		s.setError(err)
   435  		s.errCount[idx]++
   436  		return err
   437  	}
   438  	log.Infof("Successfully reconnected connection %d., errCount=%d", idx, s.errCount[idx])
   439  	return nil
   440  }
   441  
   442  // closes all connected PairScrapers
   443  // must only be called from mainLoop
   444  func (s *BitMartScraper) cleanup(err error) {
   445  	s.pairScrapers.Range(func(k, v interface{}) bool {
   446  		for ps := range v.(bitMartPairScraperSet) {
   447  			ps.closed = true
   448  		}
   449  		s.pairScrapers.Delete(k)
   450  		return true
   451  	})
   452  	if err != nil {
   453  		s.setError(err)
   454  	}
   455  	s.close()
   456  	close(s.shutdownDone)
   457  }
   458  
   459  func (s *BitMartScraper) isClosed() bool {
   460  	s.closedMutex.RLock()
   461  	defer s.closedMutex.RUnlock()
   462  	return s.closed
   463  }
   464  
   465  func (s *BitMartScraper) close() {
   466  	s.closedMutex.Lock()
   467  	defer s.closedMutex.Unlock()
   468  	s.closed = true
   469  }
   470  
   471  func (s *BitMartScraper) error() error {
   472  	s.errMutex.RLock()
   473  	defer s.errMutex.RUnlock()
   474  	return s.err
   475  }
   476  
   477  func (s *BitMartScraper) setError(err error) {
   478  	s.errMutex.Lock()
   479  	defer s.errMutex.Unlock()
   480  	s.err = err
   481  }
   482  
   483  func (s *BitMartScraper) subscribe(foreignName string, id int) error {
   484  	topic := fmt.Sprintf("%s:%s", bitMartWSSpotTradingTopic, foreignName)
   485  	if err := s.wsClient[id].WriteJSON(BitmartWsRequest{
   486  		Op:   bitMartWSOpSubscribe,
   487  		Args: []string{topic},
   488  	}); err != nil {
   489  		return err
   490  	}
   491  	s.countTopic[id]++
   492  	if s.lastUsedConnection == bitMartMaxConnections-1 {
   493  		s.lastUsedConnection = 0
   494  	} else {
   495  		s.lastUsedConnection++
   496  	}
   497  	return nil
   498  }
   499  
   500  func (s *BitMartScraper) unsubscribe(foreignName string) error {
   501  	if id, ok := s.pairSubscriptions.Load(foreignName); ok {
   502  		if err := s.wsClient[id.(int)].WriteJSON(BitmartWsRequest{
   503  			Op:   bitMartWSOpUnsubscribe,
   504  			Args: []string{fmt.Sprintf("%s:%s", bitMartWSSpotTradingTopic, foreignName)},
   505  		}); err != nil {
   506  			return err
   507  		}
   508  		s.pairSubscriptions.Delete(foreignName)
   509  		s.countTopic[id.(int)]--
   510  		return nil
   511  	} else {
   512  		return errors.New("subscription id not found")
   513  	}
   514  }