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 }