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

     1  package scrapers
     2  
     3  import (
     4  	"encoding/json"
     5  	"errors"
     6  	"fmt"
     7  	"strconv"
     8  	"strings"
     9  	"sync"
    10  	"sync/atomic"
    11  	"time"
    12  
    13  	ws "github.com/gorilla/websocket"
    14  	"github.com/zekroTJA/timedmap"
    15  	"go.uber.org/ratelimit"
    16  
    17  	"github.com/diadata-org/diadata/pkg/dia"
    18  	models "github.com/diadata-org/diadata/pkg/model"
    19  	"github.com/diadata-org/diadata/pkg/utils"
    20  )
    21  
    22  const (
    23  	cryptoDotComAPIEndpoint    = "https://api.crypto.com/v2"
    24  	cryptoDotComWSEndpoint     = "wss://stream.crypto.com/v2/market"
    25  	cryptoDotComSpotTradingBuy = "BUY"
    26  
    27  	// cryptoDotComWSRateLimitPerSec is a max request per second for sending websocket requests.
    28  	cryptoDotComWSRateLimitPerSec = 10
    29  
    30  	// cryptoDotComTaskMaxRetry is a max retry value used when retrying subscribe/unsubscribe trades.
    31  	cryptoDotComTaskMaxRetry = 20
    32  
    33  	// cryptoDotComConnMaxRetry is a max retry value used when retrying to create a new connection.
    34  	cryptoDotComConnMaxRetry = 50
    35  
    36  	// cryptoDotComRateLimitError is a rate limit error code.
    37  	cryptoDotComRateLimitError = 10006
    38  
    39  	// cryptoDotComBackoffSeconds is the number of seconds it waits for the next ws reconnect.
    40  	cryptoDotComBackoffSeconds = 5
    41  )
    42  
    43  // cryptoDotComWSTask is a websocket task tracking subscription/unsubscription
    44  type cryptoDotComWSTask struct {
    45  	Method     string
    46  	Params     cryptoDotComWSRequestParams
    47  	RetryCount int
    48  }
    49  
    50  func (c *cryptoDotComWSTask) toString() string {
    51  	return fmt.Sprintf("method=%s, param=%s, retry=%d", c.Method, c.Params.toString(), c.RetryCount)
    52  }
    53  
    54  // cryptoDotComWSRequest is a websocket request
    55  type cryptoDotComWSRequest struct {
    56  	ID     int                         `json:"id"`
    57  	Method string                      `json:"method"`
    58  	Params cryptoDotComWSRequestParams `json:"params,omitempty"`
    59  	Nonce  int64                       `json:"nonce,omitempty"`
    60  }
    61  
    62  // cryptoDotComWSRequestParams is a websocket request param
    63  type cryptoDotComWSRequestParams struct {
    64  	Channels []string `json:"channels"`
    65  }
    66  
    67  func (c *cryptoDotComWSRequestParams) toString() string {
    68  	length := len(c.Channels)
    69  	if length == 1 {
    70  		return c.Channels[0]
    71  	}
    72  	if length > 1 {
    73  		return fmt.Sprintf("%s +%d more", c.Channels[0], length-1)
    74  	}
    75  
    76  	return ""
    77  }
    78  
    79  // cryptoDotComWSResponse is a websocket response
    80  type cryptoDotComWSResponse struct {
    81  	ID     int             `json:"id"`
    82  	Code   int             `json:"code"`
    83  	Method string          `json:"method"`
    84  	Result json.RawMessage `json:"result"`
    85  }
    86  
    87  // cryptoDotComWSSubscriptionResult is a trade result coming from websocket
    88  type cryptoDotComWSSubscriptionResult struct {
    89  	InstrumentName string            `json:"instrument_name"`
    90  	Subscription   string            `json:"subscription"`
    91  	Channel        string            `json:"channel"`
    92  	Data           []json.RawMessage `json:"data"`
    93  }
    94  
    95  // cryptoDotComWSInstrument represents a trade
    96  type cryptoDotComWSInstrument struct {
    97  	Price     string `json:"p"`
    98  	Quantity  string `json:"q"`
    99  	Side      string `json:"s"`
   100  	TradeID   string `json:"d"`
   101  	TradeTime int64  `json:"t"`
   102  }
   103  
   104  // cryptoDotComInstrument represents a trading pair
   105  type cryptoDotComInstrument struct {
   106  	InstrumentName          string `json:"instrument_name"`
   107  	QuoteCurrency           string `json:"quote_currency"`
   108  	BaseCurrency            string `json:"base_currency"`
   109  	PriceDecimals           int    `json:"price_decimals"`
   110  	QuantityDecimals        int    `json:"quantity_decimals"`
   111  	MarginTradingEnabled    bool   `json:"margin_trading_enabled"`
   112  	MarginTradingEnabled5x  bool   `json:"margin_trading_enabled_5x"`
   113  	MarginTradingEnabled10x bool   `json:"margin_trading_enabled_10x"`
   114  	MaxQuantity             string `json:"max_quantity"`
   115  	MinQuantity             string `json:"min_quantity"`
   116  }
   117  
   118  // cryptoDotComInstrumentResponse is an API response for retrieving instruments
   119  type cryptoDotComInstrumentResponse struct {
   120  	Code   int `json:"code"`
   121  	Result struct {
   122  		Instruments []cryptoDotComInstrument `json:"instruments"`
   123  	} `json:"result"`
   124  }
   125  
   126  // CryptoDotComScraper is a scraper for Crypto.com
   127  type CryptoDotComScraper struct {
   128  	ws *ws.Conn
   129  	rl ratelimit.Limiter
   130  
   131  	// signaling channels for session initialization and finishing
   132  	shutdown           chan nothing
   133  	shutdownDone       chan nothing
   134  	signalShutdown     sync.Once
   135  	signalShutdownDone sync.Once
   136  
   137  	// error handling; err should be read from error(), closed should be read from isClosed()
   138  	// those two methods implement RW lock
   139  	errMutex    sync.RWMutex
   140  	err         error
   141  	closedMutex sync.RWMutex
   142  	closed      bool
   143  	//consecutiveErrCount int
   144  
   145  	// used to keep track of trading pairs that we subscribed to
   146  	pairScrapers sync.Map
   147  	exchangeName string
   148  	chanTrades   chan *dia.Trade
   149  	db           *models.RelDB
   150  	taskCount    int32
   151  	tasks        sync.Map
   152  
   153  	// used to handle connection retry
   154  	connMutex      sync.RWMutex
   155  	connRetryCount int
   156  }
   157  
   158  // NewCryptoDotComScraper returns a new Crypto.com scraper
   159  func NewCryptoDotComScraper(exchange dia.Exchange, scrape bool, relDB *models.RelDB) *CryptoDotComScraper {
   160  	s := &CryptoDotComScraper{
   161  		shutdown:     make(chan nothing),
   162  		shutdownDone: make(chan nothing),
   163  		exchangeName: exchange.Name,
   164  		err:          nil,
   165  		chanTrades:   make(chan *dia.Trade),
   166  		db:           relDB,
   167  	}
   168  
   169  	if err := s.newConn(); err != nil {
   170  		log.Error(err)
   171  
   172  		return nil
   173  	}
   174  
   175  	s.rl = ratelimit.New(cryptoDotComWSRateLimitPerSec)
   176  
   177  	if scrape {
   178  		go s.mainLoop()
   179  	}
   180  
   181  	return s
   182  }
   183  
   184  // Close unsubscribes data and closes any existing WebSocket connections, as well as channels of CryptoDotComScraper
   185  func (s *CryptoDotComScraper) Close() error {
   186  	if s.isClosed() {
   187  		return errors.New("CryptoDotComScraper: Already closed")
   188  	}
   189  
   190  	s.signalShutdown.Do(func() {
   191  		close(s.shutdown)
   192  	})
   193  
   194  	<-s.shutdownDone
   195  
   196  	return s.error()
   197  }
   198  
   199  // Channel returns a channel that can be used to receive trades
   200  func (s *CryptoDotComScraper) Channel() chan *dia.Trade {
   201  	return s.chanTrades
   202  }
   203  
   204  // FetchAvailablePairs returns all traded pairs on Crypto.com
   205  func (s *CryptoDotComScraper) FetchAvailablePairs() (pairs []dia.ExchangePair, err error) {
   206  	data, _, err := utils.GetRequest(cryptoDotComAPIEndpoint + "/public/get-instruments")
   207  	if err != nil {
   208  		return nil, err
   209  	}
   210  
   211  	var res cryptoDotComInstrumentResponse
   212  	if err := json.Unmarshal(data, &res); err != nil {
   213  		return nil, err
   214  	}
   215  
   216  	if res.Code != 0 {
   217  		return nil, fmt.Errorf("CryptoDotComScraper: Getting available pairs error with code %d", res.Code)
   218  	}
   219  
   220  	for _, i := range res.Result.Instruments {
   221  		pairs = append(pairs, dia.ExchangePair{
   222  			Symbol:      i.BaseCurrency,
   223  			ForeignName: i.InstrumentName,
   224  			Exchange:    s.exchangeName,
   225  		})
   226  	}
   227  
   228  	return pairs, nil
   229  }
   230  
   231  // FillSymbolData adds the name to the asset underlying @symbol on Crypto.com
   232  func (s *CryptoDotComScraper) FillSymbolData(symbol string) (dia.Asset, error) {
   233  	return dia.Asset{Symbol: symbol}, nil
   234  }
   235  
   236  func (s *CryptoDotComScraper) NormalizePair(pair dia.ExchangePair) (dia.ExchangePair, error) {
   237  	return pair, nil
   238  }
   239  
   240  // ScrapePair returns a PairScraper that can be used to get trades for a single pair from the Crypto.com scraper
   241  func (s *CryptoDotComScraper) ScrapePair(pair dia.ExchangePair) (PairScraper, error) {
   242  	if err := s.error(); err != nil {
   243  		return nil, err
   244  	}
   245  	if s.isClosed() {
   246  		return nil, errors.New("CryptoDotComScraper: Call ScrapePair on closed scraper")
   247  	}
   248  
   249  	ps := &CryptoDotComPairScraper{
   250  		parent: s,
   251  		pair:   pair,
   252  	}
   253  	if err := s.subscribe([]dia.ExchangePair{pair}); err != nil {
   254  		return nil, err
   255  	}
   256  
   257  	return ps, nil
   258  }
   259  
   260  func (s *CryptoDotComScraper) mainLoop() {
   261  	defer s.cleanup()
   262  
   263  	tmFalseDuplicateTrades := timedmap.New(duplicateTradesScanFrequency)
   264  	tmDuplicateTrades := timedmap.New(duplicateTradesScanFrequency)
   265  
   266  	for {
   267  		select {
   268  		case <-s.shutdown:
   269  			log.Println("CryptoDotComScraper: Shutting down main loop")
   270  		default:
   271  		}
   272  
   273  		var res cryptoDotComWSResponse
   274  		if err := s.wsConn().ReadJSON(&res); err != nil {
   275  			log.Warnf("CryptoDotComScraper: Creating a new connection caused by err=%s", err.Error())
   276  
   277  			if retryErr := s.retryConnection(); retryErr != nil {
   278  				s.setError(retryErr)
   279  				log.Errorf("CryptoDotComScraper: Shutting down main loop after retrying to create a new connection, err=%s", retryErr.Error())
   280  			}
   281  
   282  			log.Info("CryptoDotComScraper: Successfully created a new connection")
   283  		}
   284  		if res.Code == cryptoDotComRateLimitError {
   285  			time.Sleep(time.Duration(cryptoDotComBackoffSeconds) * time.Second)
   286  			if err := s.retryTask(res.ID); err != nil {
   287  				s.setError(err)
   288  				log.Errorf("CryptoDotComScraper: Shutting down main loop due to failing to retry a task, err=%s", err.Error())
   289  			}
   290  		}
   291  		if res.Code != 0 {
   292  			log.Errorf("CryptoDotComScraper: Shutting down main loop due to non-retryable response code %d", res.Code)
   293  		}
   294  
   295  		switch res.Method {
   296  		case "public/heartbeat":
   297  			if err := s.ping(res.ID); err != nil {
   298  				s.setError(err)
   299  				log.Errorf("CryptoDotComScraper: Shutting down main loop due to heartbeat failure, err=%s", err.Error())
   300  			}
   301  		case "subscribe":
   302  			if len(res.Result) == 0 {
   303  				continue
   304  			}
   305  
   306  			var subscription cryptoDotComWSSubscriptionResult
   307  			if err := json.Unmarshal(res.Result, &subscription); err != nil {
   308  				s.setError(err)
   309  				log.Errorf("CryptoDotComScraper: Shutting down main loop due to response unmarshaling failure, err=%s", err.Error())
   310  			}
   311  			if subscription.Channel != "trade" {
   312  				continue
   313  			}
   314  
   315  			baseCurrency := strings.Split(subscription.InstrumentName, `_`)[0]
   316  			pair, err := s.db.GetExchangePairCache(s.exchangeName, subscription.InstrumentName)
   317  			if err != nil {
   318  				log.Error("get exchange pair from cache: ", err)
   319  			}
   320  
   321  			for _, data := range subscription.Data {
   322  				var i cryptoDotComWSInstrument
   323  				if err := json.Unmarshal(data, &i); err != nil {
   324  					s.setError(err)
   325  					log.Errorf("CryptoDotComScraper: Shutting down main loop due to instrument unmarshaling failure, err=%s", err.Error())
   326  				}
   327  
   328  				volume, err := strconv.ParseFloat(i.Quantity, 64)
   329  				if err != nil {
   330  					log.Error("parse volume: ", err)
   331  					continue
   332  				}
   333  				if i.Side != cryptoDotComSpotTradingBuy {
   334  					volume = -volume
   335  				}
   336  
   337  				price, err := strconv.ParseFloat(i.Price, 64)
   338  				if err != nil {
   339  					log.Error("parse price: ", err)
   340  					continue
   341  				}
   342  
   343  				trade := &dia.Trade{
   344  					Symbol:         baseCurrency,
   345  					Pair:           subscription.InstrumentName,
   346  					Price:          price,
   347  					Time:           time.Unix(0, i.TradeTime*int64(time.Millisecond)),
   348  					Volume:         volume,
   349  					Source:         s.exchangeName,
   350  					ForeignTradeID: i.TradeID,
   351  					VerifiedPair:   pair.Verified,
   352  					BaseToken:      pair.UnderlyingPair.BaseToken,
   353  					QuoteToken:     pair.UnderlyingPair.QuoteToken,
   354  				}
   355  				if pair.Verified {
   356  					log.Infoln("Got verified trade", trade)
   357  				}
   358  				// Handle duplicate trades.
   359  				discardTrade := trade.IdentifyDuplicateFull(tmFalseDuplicateTrades, duplicateTradesMemory)
   360  				if !discardTrade {
   361  					trade.IdentifyDuplicateTagset(tmDuplicateTrades, duplicateTradesMemory)
   362  					select {
   363  					case <-s.shutdown:
   364  					case s.chanTrades <- trade:
   365  					}
   366  				}
   367  
   368  			}
   369  		}
   370  	}
   371  }
   372  
   373  func (s *CryptoDotComScraper) newConn() error {
   374  	conn, _, err := ws.DefaultDialer.Dial(cryptoDotComWSEndpoint, nil)
   375  	if err != nil {
   376  		return err
   377  	}
   378  
   379  	// Crypto.com recommends adding a 1-second sleep after establishing the websocket connection, and before requests are sent
   380  	// to avoid occurrences of rate-limit (`TOO_MANY_REQUESTS`) errors.
   381  	// https://exchange-docs.crypto.com/spot/index.html?javascript#websocket-subscriptions
   382  	time.Sleep(time.Duration(cryptoDotComBackoffSeconds) * time.Second)
   383  
   384  	defer s.connMutex.Unlock()
   385  	s.connMutex.Lock()
   386  	s.ws = conn
   387  
   388  	return nil
   389  }
   390  
   391  func (s *CryptoDotComScraper) wsConn() *ws.Conn {
   392  	defer s.connMutex.RUnlock()
   393  	s.connMutex.RLock()
   394  
   395  	return s.ws
   396  }
   397  
   398  func (s *CryptoDotComScraper) ping(id int) error {
   399  	s.rl.Take()
   400  
   401  	return s.wsConn().WriteJSON(&cryptoDotComWSRequest{
   402  		ID:     id,
   403  		Method: "public/respond-heartbeat",
   404  	})
   405  }
   406  
   407  func (s *CryptoDotComScraper) cleanup() {
   408  	if err := s.wsConn().Close(); err != nil {
   409  		s.setError(err)
   410  	}
   411  
   412  	close(s.chanTrades)
   413  	s.close()
   414  	s.signalShutdownDone.Do(func() {
   415  		close(s.shutdownDone)
   416  	})
   417  }
   418  
   419  func (s *CryptoDotComScraper) error() error {
   420  	s.errMutex.RLock()
   421  	defer s.errMutex.RUnlock()
   422  
   423  	return s.err
   424  }
   425  
   426  func (s *CryptoDotComScraper) setError(err error) {
   427  	s.errMutex.Lock()
   428  	defer s.errMutex.Unlock()
   429  
   430  	s.err = err
   431  }
   432  
   433  func (s *CryptoDotComScraper) isClosed() bool {
   434  	s.closedMutex.RLock()
   435  	defer s.closedMutex.RUnlock()
   436  
   437  	return s.closed
   438  }
   439  
   440  func (s *CryptoDotComScraper) close() {
   441  	s.closedMutex.Lock()
   442  	defer s.closedMutex.Unlock()
   443  
   444  	s.closed = true
   445  }
   446  
   447  func (s *CryptoDotComScraper) subscribe(pairs []dia.ExchangePair) error {
   448  	channels := make([]string, len(pairs))
   449  	for idx, pair := range pairs {
   450  		channels[idx] = "trade." + pair.ForeignName
   451  		s.pairScrapers.Store(pair.ForeignName, pair)
   452  	}
   453  
   454  	taskID := int(atomic.AddInt32(&s.taskCount, 1))
   455  	task := cryptoDotComWSTask{
   456  		Method: "subscribe",
   457  		Params: cryptoDotComWSRequestParams{
   458  			Channels: channels,
   459  		},
   460  		RetryCount: 0,
   461  	}
   462  	s.tasks.Store(taskID, task)
   463  
   464  	return s.send(taskID, task)
   465  }
   466  
   467  func (s *CryptoDotComScraper) unsubscribe(pairs []dia.ExchangePair) error {
   468  	channels := make([]string, len(pairs))
   469  	for idx, pair := range pairs {
   470  		channels[idx] = "trade." + pair.ForeignName
   471  		s.pairScrapers.Delete(pair.ForeignName)
   472  	}
   473  
   474  	taskID := int(atomic.AddInt32(&s.taskCount, 1))
   475  	task := cryptoDotComWSTask{
   476  		Method: "unsubscribe",
   477  		Params: cryptoDotComWSRequestParams{
   478  			Channels: channels,
   479  		},
   480  		RetryCount: 0,
   481  	}
   482  	s.tasks.Store(taskID, task)
   483  
   484  	return s.send(taskID, task)
   485  }
   486  
   487  func (s *CryptoDotComScraper) retryConnection() error {
   488  	s.connRetryCount += 1
   489  	if s.connRetryCount > cryptoDotComConnMaxRetry {
   490  		return errors.New("CryptoDotComPairScraper: Reached max retry connection")
   491  	}
   492  	if err := s.wsConn().Close(); err != nil {
   493  		return err
   494  	}
   495  	if err := s.newConn(); err != nil {
   496  		return err
   497  	}
   498  
   499  	var pairs []dia.ExchangePair
   500  	s.pairScrapers.Range(func(key, value interface{}) bool {
   501  		pair := value.(dia.ExchangePair)
   502  		pairs = append(pairs, pair)
   503  		return true
   504  	})
   505  	if err := s.subscribe(pairs); err != nil {
   506  		return err
   507  	}
   508  
   509  	return nil
   510  }
   511  
   512  func (s *CryptoDotComScraper) retryTask(taskID int) error {
   513  	val, ok := s.tasks.Load(taskID)
   514  	if !ok {
   515  		return fmt.Errorf("CryptoDotComScraper: Facing unknown task id, taskId=%d", taskID)
   516  	}
   517  
   518  	task := val.(cryptoDotComWSTask)
   519  	task.RetryCount += 1
   520  	if task.RetryCount > cryptoDotComTaskMaxRetry {
   521  		return fmt.Errorf("CryptoDotComScraper: Exeeding max retry, taskId=%d, %s", taskID, task.toString())
   522  	}
   523  
   524  	log.Warnf("CryptoDotComScraper: Retrying a task, taskId=%d, %s", taskID, task.toString())
   525  	s.tasks.Store(taskID, task)
   526  
   527  	return s.send(taskID, task)
   528  }
   529  
   530  func (s *CryptoDotComScraper) send(taskID int, task cryptoDotComWSTask) error {
   531  	s.rl.Take()
   532  
   533  	return s.wsConn().WriteJSON(&cryptoDotComWSRequest{
   534  		ID:     taskID,
   535  		Method: task.Method,
   536  		Params: task.Params,
   537  		Nonce:  time.Now().UnixNano() / 1000,
   538  	})
   539  }
   540  
   541  // CryptoDotComPairScraper implements PairScraper for Crypto.com
   542  type CryptoDotComPairScraper struct {
   543  	parent *CryptoDotComScraper
   544  	pair   dia.ExchangePair
   545  	closed bool
   546  }
   547  
   548  // Error returns an error when the channel Channel() is closed
   549  // and nil otherwise
   550  func (p *CryptoDotComPairScraper) Error() error {
   551  	return p.parent.error()
   552  }
   553  
   554  // Pair returns the pair this scraper is subscribed to
   555  func (p *CryptoDotComPairScraper) Pair() dia.ExchangePair {
   556  	return p.pair
   557  }
   558  
   559  // Close stops listening for trades of the pair associated with the Crypto.com scraper
   560  func (p *CryptoDotComPairScraper) Close() error {
   561  	if err := p.parent.error(); err != nil {
   562  		return err
   563  	}
   564  	if p.closed {
   565  		return errors.New("CryptoDotComPairScraper: Already closed")
   566  	}
   567  	if err := p.parent.unsubscribe([]dia.ExchangePair{p.pair}); err != nil {
   568  		return err
   569  	}
   570  
   571  	p.closed = true
   572  
   573  	return nil
   574  }