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 }