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

     1  package scrapers
     2  
     3  import (
     4  	"bytes"
     5  	"compress/gzip"
     6  	"encoding/json"
     7  	"errors"
     8  	"math"
     9  	"strconv"
    10  	"strings"
    11  	"sync"
    12  	"time"
    13  
    14  	"github.com/diadata-org/diadata/pkg/dia"
    15  	models "github.com/diadata-org/diadata/pkg/model"
    16  	"github.com/diadata-org/diadata/pkg/utils"
    17  	ws "github.com/gorilla/websocket"
    18  	"github.com/zekroTJA/timedmap"
    19  )
    20  
    21  var (
    22  	CoinExWSBaseString = "wss://socket.coinex.com/v2/spot"
    23  )
    24  
    25  type coinExPairScraperSet map[*BinancePairScraper]nothing
    26  
    27  type CoinExScraper struct {
    28  	// signaling channels for session finishing
    29  	shutdown     chan nothing
    30  	shutdownDone chan nothing
    31  	// error handling; to read error or closed, first acquire read lock
    32  	// only cleanup method should hold write lock
    33  	errorLock sync.RWMutex
    34  	error     error
    35  	closed    bool
    36  	// used to keep track of trading pairs that we subscribed to
    37  	// use sync.Maps to concurrently handle multiple pairs
    38  	pairScrapers    sync.Map // dia.ExchangePair -> coinexPairScraperSet
    39  	newPairScrapers map[string]*CoinExPairScraper
    40  	exchangeName    string
    41  	chanTrades      chan *dia.Trade
    42  	db              *models.RelDB
    43  	wsClient        *ws.Conn
    44  }
    45  
    46  type CoinExPairScraper struct {
    47  	parent *CoinExScraper
    48  	pair   dia.ExchangePair
    49  	closed bool
    50  }
    51  
    52  type SubscribeRequest struct {
    53  	Method string     `json:"method"`
    54  	Params MarketList `json:"params"`
    55  	ID     int        `json:"id"`
    56  }
    57  
    58  type MarketList struct {
    59  	MarketList []string `json:"market_list"`
    60  }
    61  
    62  type coinexWSResponse struct {
    63  	Data struct {
    64  		ForeignName string `json:"market"`
    65  		DealList    []Deal `json:"deal_list"`
    66  	} `json:"data"`
    67  }
    68  
    69  type Deal struct {
    70  	DealID    int64  `json:"deal_id"`
    71  	CreatedAt int64  `json:"created_at"`
    72  	Side      string `json:"side"`
    73  	Price     string `json:"price"`
    74  	Amount    string `json:"amount"`
    75  }
    76  
    77  func NewCoinExScraper(exchange dia.Exchange, scrape bool, relDB *models.RelDB) *CoinExScraper {
    78  
    79  	s := &CoinExScraper{
    80  		shutdown:        make(chan nothing),
    81  		shutdownDone:    make(chan nothing),
    82  		exchangeName:    exchange.Name,
    83  		error:           nil,
    84  		chanTrades:      make(chan *dia.Trade),
    85  		db:              relDB,
    86  		newPairScrapers: make(map[string]*CoinExPairScraper),
    87  	}
    88  
    89  	err := s.connectToAPI()
    90  	if err != nil {
    91  		log.Error("getting an error while connecting to api: ", err)
    92  	} else {
    93  		log.Println("Successfully connect to websocket server.")
    94  	}
    95  
    96  	//establish connection in the background
    97  	if scrape {
    98  		go s.mainLoop()
    99  	}
   100  
   101  	return s
   102  }
   103  
   104  func (s *CoinExScraper) mainLoop() {
   105  
   106  	defer func() {
   107  		log.Println("CoinExScraper main loop exiting")
   108  		s.cleanup()
   109  	}()
   110  
   111  	tmFalseDuplicateTrades := timedmap.New(duplicateTradesScanFrequency)
   112  	tmDuplicateTrades := timedmap.New(duplicateTradesScanFrequency)
   113  
   114  	for {
   115  		_, message, err := s.wsClient.ReadMessage()
   116  		if err != nil {
   117  			log.Error("Receive error:", err)
   118  			continue
   119  		}
   120  
   121  		// Decompress GZIP data
   122  		reader, err := gzip.NewReader(bytes.NewReader(message))
   123  		if err != nil {
   124  			log.Error("Decompression failed:", err)
   125  			continue
   126  		}
   127  		defer reader.Close()
   128  
   129  		// Read decompressed content
   130  		var buf bytes.Buffer
   131  		if _, err := buf.ReadFrom(reader); err != nil {
   132  			log.Error("Read failed:", err)
   133  			continue
   134  		}
   135  
   136  		var response coinexWSResponse
   137  		if err := json.Unmarshal(buf.Bytes(), &response); err != nil {
   138  			log.Errorf("JSON parsing failed:%v | raw data:%s", err, buf.String())
   139  			continue
   140  		}
   141  
   142  		if len(response.Data.DealList) == 0 {
   143  			log.Warnf("Empty trade list | raw data:%s", buf.String())
   144  			continue
   145  		} else {
   146  			s.parseWSResponse(response, tmFalseDuplicateTrades, tmDuplicateTrades)
   147  		}
   148  	}
   149  }
   150  
   151  func (s *CoinExScraper) parseWSResponse(
   152  	message coinexWSResponse,
   153  	tmFalseDuplicateTrades *timedmap.TimedMap,
   154  	tmDuplicateTrades *timedmap.TimedMap,
   155  ) {
   156  	if len(message.Data.DealList) == 0 {
   157  		log.Warn("Empty Trade Message:", message)
   158  		return
   159  	}
   160  
   161  	var exchangepair dia.ExchangePair
   162  	var err error
   163  
   164  	ps := s.newPairScrapers[message.Data.ForeignName]
   165  	pair := ps.pair
   166  
   167  	exchangepair, err = s.db.GetExchangePairCache(s.exchangeName, message.Data.ForeignName)
   168  	if err != nil {
   169  		log.Error(err)
   170  	}
   171  
   172  	for _, deal := range message.Data.DealList {
   173  		tradeTime := time.Unix(deal.CreatedAt/1000, (deal.CreatedAt%1000)*1e6)
   174  
   175  		tradePrice, err := strconv.ParseFloat(deal.Price, 64)
   176  		if err != nil {
   177  			log.Errorf("Price parsing failed:%v | raw value:%s", err, deal.Price)
   178  		}
   179  
   180  		tradeVolume, err := strconv.ParseFloat(deal.Amount, 64)
   181  		if err != nil {
   182  			log.Errorf("Volume parsing failed:%v | raw value:%s", err, deal.Amount)
   183  		}
   184  
   185  		if strings.ToLower(deal.Side) == "sell" {
   186  			tradeVolume = -math.Abs(tradeVolume)
   187  		} else {
   188  			tradeVolume = math.Abs(tradeVolume)
   189  		}
   190  
   191  		tradeForeignTradeID := strconv.FormatInt(deal.DealID, 10)
   192  
   193  		t := &dia.Trade{
   194  			Symbol:         pair.Symbol,
   195  			Pair:           message.Data.ForeignName,
   196  			Price:          tradePrice,
   197  			Volume:         tradeVolume,
   198  			Time:           tradeTime,
   199  			ForeignTradeID: tradeForeignTradeID,
   200  			Source:         s.exchangeName,
   201  			VerifiedPair:   exchangepair.Verified,
   202  			BaseToken:      exchangepair.UnderlyingPair.BaseToken,
   203  			QuoteToken:     exchangepair.UnderlyingPair.QuoteToken,
   204  		}
   205  
   206  		if utils.Contains(reverseBasetokens, t.BaseToken.Identifier()) {
   207  			// If we need quotation of a base token, reverse pair
   208  			tSwapped, errSwap := dia.SwapTrade(*t)
   209  			if errSwap == nil {
   210  				t = &tSwapped
   211  			}
   212  		}
   213  
   214  		if exchangepair.Verified {
   215  			log.Infoln("Got verified trade", t)
   216  		}
   217  
   218  		// Handle duplicate trades.
   219  		discardTrade := t.IdentifyDuplicateFull(tmFalseDuplicateTrades, duplicateTradesMemory)
   220  		if !discardTrade {
   221  			t.IdentifyDuplicateTagset(tmDuplicateTrades, duplicateTradesMemory)
   222  			ps.parent.chanTrades <- t
   223  		}
   224  	}
   225  }
   226  
   227  func (s *CoinExScraper) cleanup() {
   228  	s.errorLock.Lock()
   229  	defer s.errorLock.Unlock()
   230  	// close all channels of PairScraper children
   231  	s.pairScrapers.Range(func(k, v interface{}) bool {
   232  		for ps := range v.(coinExPairScraperSet) {
   233  			ps.closed = true
   234  		}
   235  		s.pairScrapers.Delete(k)
   236  		return true
   237  	})
   238  
   239  	s.closed = true
   240  	close(s.shutdownDone) // signal that shutdown is complete
   241  }
   242  
   243  func (scraper *CoinExScraper) connectToAPI() error {
   244  	log.Info("Starting connect to API")
   245  
   246  	dialer := ws.Dialer{
   247  		EnableCompression: true, // Enable compression support
   248  	}
   249  	// Connect to CoinEx API.
   250  	conn, _, err := dialer.Dial(CoinExWSBaseString, nil)
   251  	if err != nil {
   252  		log.Errorf("CoinEx - Connect to API: %s.", err.Error())
   253  		return err
   254  	}
   255  	scraper.wsClient = conn
   256  
   257  	return nil
   258  }
   259  
   260  func (scraper *CoinExScraper) subscribe(pair dia.ExchangePair, subscribe bool) error {
   261  	if scraper.closed {
   262  		return errors.New("CoinEx Scraper: Call ScrapePair on closed scraper")
   263  	}
   264  
   265  	// Validate WebSocket connection exists
   266  	if scraper.wsClient == nil {
   267  		return errors.New("WebSocket connection not initialized")
   268  	}
   269  
   270  	// Determine subscription type (SUBSCRIBE/UNSUBSCRIBE)
   271  	subscribeType := "deals.unsubscribe"
   272  	if subscribe {
   273  		subscribeType = "deals.subscribe"
   274  	}
   275  	// Convert symbol+currency to uppercase (e.g., "btcusdt@trade")
   276  	pairTicker := strings.ToUpper(pair.ForeignName)
   277  
   278  	subscribeMessage := SubscribeRequest{
   279  		Method: subscribeType,
   280  		Params: MarketList{
   281  			MarketList: []string{pairTicker},
   282  		},
   283  		ID: int(time.Now().UnixNano()),
   284  	}
   285  	log.Info("Subscribe Message: ", subscribeMessage)
   286  
   287  	if err := scraper.wsClient.WriteJSON(subscribeMessage); err != nil {
   288  		log.Errorf("Failed to send subscription request: %v", err)
   289  		return err
   290  	}
   291  	return nil
   292  }
   293  
   294  func (s *CoinExScraper) ScrapePair(pair dia.ExchangePair) (PairScraper, error) {
   295  	ps := &CoinExPairScraper{
   296  		parent: s,
   297  		pair:   pair,
   298  	}
   299  
   300  	s.newPairScrapers[pair.ForeignName] = ps
   301  
   302  	s.subscribe(pair, true)
   303  
   304  	return ps, nil
   305  }
   306  
   307  func (s *CoinExScraper) normalizeSymbol(p dia.ExchangePair, foreignName string, params ...string) (pair dia.ExchangePair, err error) {
   308  	return pair, nil
   309  }
   310  
   311  func (s *CoinExScraper) FetchAvailablePairs() (pairs []dia.ExchangePair, err error) {
   312  	return pairs, err
   313  }
   314  
   315  func (ps *CoinExPairScraper) Close() error {
   316  	var err error
   317  	s := ps.parent
   318  	// if parent already errored, return early
   319  	s.errorLock.RLock()
   320  	defer s.errorLock.RUnlock()
   321  	if s.error != nil {
   322  		return s.error
   323  	}
   324  	if ps.closed {
   325  		return errors.New("CoinExPairScraper: Already closed")
   326  	}
   327  
   328  	ps.closed = true
   329  	return err
   330  }
   331  
   332  func (ps *CoinExScraper) Channel() chan *dia.Trade {
   333  	return ps.chanTrades
   334  }
   335  
   336  func (ps *CoinExPairScraper) Error() error {
   337  	s := ps.parent
   338  	s.errorLock.RLock()
   339  	defer s.errorLock.RUnlock()
   340  	return s.error
   341  }
   342  
   343  func (ps *CoinExPairScraper) Pair() dia.ExchangePair {
   344  	return ps.pair
   345  }
   346  
   347  func (s *CoinExScraper) Close() error {
   348  	if s.closed {
   349  		return errors.New("CoinExScraper: Already closed")
   350  	}
   351  	close(s.shutdown)
   352  	<-s.shutdownDone
   353  	s.errorLock.RLock()
   354  	defer s.errorLock.RUnlock()
   355  	return s.error
   356  }
   357  
   358  func (s *CoinExScraper) FillSymbolData(symbol string) (dia.Asset, error) {
   359  	return dia.Asset{Symbol: symbol}, nil
   360  }
   361  
   362  func (up *CoinExScraper) NormalizePair(pair dia.ExchangePair) (dia.ExchangePair, error) {
   363  	return pair, nil
   364  }