github.com/diadata-org/diadata@v1.4.593/pkg/dia/scraper/exchange-scrapers/KuCoinScraper.go (about) 1 package scrapers 2 3 import ( 4 "errors" 5 "strconv" 6 "strings" 7 "sync" 8 "time" 9 10 "github.com/Kucoin/kucoin-go-sdk" 11 "github.com/diadata-org/diadata/pkg/dia" 12 models "github.com/diadata-org/diadata/pkg/model" 13 "github.com/zekroTJA/timedmap" 14 ) 15 16 type KuExchangePairs []KuExchangePair 17 18 type KucoinMarketMatch struct { 19 Symbol string `json:"symbol"` 20 Sequence string `json:"sequence"` 21 Side string `json:"side"` 22 Size string `json:"size"` 23 Price string `json:"price"` 24 TakerOrderID string `json:"takerOrderId"` 25 Time string `json:"time"` 26 Type string `json:"type"` 27 MakerOrderID string `json:"makerOrderId"` 28 TradeID string `json:"tradeId"` 29 } 30 31 type KucoinCurrency struct { 32 Symbol string `json:"currency"` 33 Name string `json:"fullName"` 34 Address string `json:"contractAddress"` 35 } 36 37 type KuExchangePair struct { 38 Symbol string `json:"symbol"` 39 Name string `json:"name"` 40 BaseCurrency string `json:"baseCurrency"` 41 QuoteCurrency string `json:"quoteCurrency"` 42 FeeCurrency string `json:"feeCurrency"` 43 Market string `json:"market"` 44 BaseMinSize string `json:"baseMinSize"` 45 QuoteMinSize string `json:"quoteMinSize"` 46 BaseMaxSize string `json:"baseMaxSize"` 47 QuoteMaxSize string `json:"quoteMaxSize"` 48 BaseIncrement string `json:"baseIncrement"` 49 QuoteIncrement string `json:"quoteIncrement"` 50 PriceIncrement string `json:"priceIncrement"` 51 PriceLimitRate string `json:"priceLimitRate"` 52 IsMarginEnabled bool `json:"isMarginEnabled"` 53 EnableTrading bool `json:"enableTrading"` 54 } 55 56 type KuCoinScraper struct { 57 // signaling channels for session initialization and finishing 58 initDone chan nothing 59 shutdown chan nothing 60 shutdownDone chan nothing 61 // error handling; to read error or closed, first acquire read lock 62 // only cleanup method should hold write lock 63 errorLock sync.RWMutex 64 error error 65 closed bool 66 // used to keep track of trading pairs that we subscribed to 67 // use sync.Maps to concurrently handle multiple pairs 68 pairScrapers map[string]*KuCoinPairScraper // dia.ExchangePair -> KuCoinPairScraper 69 // pairSubscriptions sync.Map // dia.ExchangePair -> string (subscription ID) 70 // pairLocks sync.Map // dia.ExchangePair -> sync.Mutex 71 exchangeName string 72 chanTrades chan *dia.Trade 73 apiService *kucoin.ApiService 74 db *models.RelDB 75 } 76 77 func NewKuCoinScraper(apiKey string, secretKey string, exchange dia.Exchange, scrape bool, relDB *models.RelDB) *KuCoinScraper { 78 apiService := kucoin.NewApiService() 79 80 s := &KuCoinScraper{ 81 initDone: make(chan nothing), 82 shutdown: make(chan nothing), 83 shutdownDone: make(chan nothing), 84 exchangeName: exchange.Name, 85 pairScrapers: make(map[string]*KuCoinPairScraper), 86 error: nil, 87 chanTrades: make(chan *dia.Trade), 88 apiService: apiService, 89 db: relDB, 90 } 91 92 // establish connection in the background 93 if scrape { 94 go s.mainLoop() 95 } 96 return s 97 } 98 99 // runs in a goroutine until s is closed 100 func (s *KuCoinScraper) mainLoop() { 101 var channelsForClient1, channelsForClient2, channelsForClient3 []*kucoin.WebSocketSubscribeMessage 102 103 close(s.initDone) 104 105 lastTradeMap := make(map[dia.Pair]time.Time) 106 countMap := make(map[dia.Pair]int) 107 108 rsp, err := s.apiService.WebSocketPublicToken() 109 if err != nil { 110 // Handle error 111 log.Error("Error WebSocketPublicToken", err) 112 } 113 114 tk := &kucoin.WebSocketTokenModel{} 115 if err = rsp.ReadData(tk); err != nil { 116 log.Error("Error Reading data", err) 117 } 118 119 client1 := s.apiService.NewWebSocketClient(tk) 120 client2 := s.apiService.NewWebSocketClient(tk) 121 client3 := s.apiService.NewWebSocketClient(tk) 122 123 client1DownStream, _, err := client1.Connect() 124 if err != nil { 125 log.Error("Error Reading data", err) 126 } 127 client2DownStream, _, err := client2.Connect() 128 if err != nil { 129 log.Error("Error Reading data", err) 130 } 131 client3DownStream, _, err := client3.Connect() 132 if err != nil { 133 log.Error("Error Reading data", err) 134 } 135 136 count := 0 137 for pair := range s.pairScrapers { 138 ch := kucoin.NewSubscribeMessage("/market/match:"+pair, false) 139 if count >= 598 { 140 channelsForClient3 = append(channelsForClient3, ch) 141 count++ 142 continue 143 } 144 if count >= 299 { 145 channelsForClient2 = append(channelsForClient2, ch) 146 count++ 147 continue 148 } else { 149 channelsForClient1 = append(channelsForClient1, ch) 150 count++ 151 } 152 } 153 154 log.Info("number of pairs: ", count) 155 156 if err := client1.Subscribe(channelsForClient1...); err != nil { 157 log.Fatal("Error while subscribing client1 ", err) 158 } 159 if err := client2.Subscribe(channelsForClient2...); err != nil { 160 log.Fatal("Error while subscribing client2 ", err) 161 } 162 if err := client3.Subscribe(channelsForClient3...); err != nil { 163 log.Fatal("Error while subscribing client3 ", err) 164 } 165 166 tmFalseDuplicateTrades := timedmap.New(duplicateTradesScanFrequency) 167 tmDuplicateTrades := timedmap.New(duplicateTradesScanFrequency) 168 go func() { 169 var msg *kucoin.WebSocketDownstreamMessage 170 for { 171 select { 172 case msg = <-client1DownStream: 173 if msg == nil { 174 continue 175 } 176 t := &KucoinMarketMatch{} 177 if err := msg.ReadData(t); err != nil { 178 log.Printf("Failure to read: %s", err.Error()) 179 return 180 } 181 asset := strings.Split(t.Symbol, "-") 182 f64Price, _ := strconv.ParseFloat(t.Price, 64) 183 f64Volume, _ := strconv.ParseFloat(t.Size, 64) 184 timeOrder, err := strconv.ParseInt(t.Time, 10, 64) 185 if err != nil { 186 log.Error("parse trade time: ", err) 187 } 188 // WS returns different lengths of Unix timestamps. Adjust to nanoseconds if returns milliseconds. 189 if len(t.Time) == 13 { 190 timeOrder *= 1e6 191 } 192 193 if t.Side == "sell" { 194 f64Volume = -f64Volume 195 } 196 197 exchangepair, err := s.db.GetExchangePairCache(s.exchangeName, t.Symbol) 198 if err != nil { 199 log.Error(err) 200 } 201 202 // Make trade times unique 203 tradeTime := time.Unix(0, timeOrder) 204 pair := dia.Pair{QuoteToken: exchangepair.UnderlyingPair.QuoteToken, BaseToken: exchangepair.UnderlyingPair.BaseToken} 205 if _, ok := lastTradeMap[pair]; ok { 206 if lastTradeMap[pair] != tradeTime { 207 lastTradeMap[pair] = tradeTime 208 countMap[pair] = 0 209 } else { 210 tradeTime = tradeTime.Add(time.Duration((countMap[pair] + 1)) * time.Nanosecond) 211 countMap[pair] += 1 212 } 213 } else { 214 lastTradeMap[pair] = tradeTime 215 } 216 217 trade := &dia.Trade{ 218 Symbol: asset[0], 219 Pair: t.Symbol, 220 Price: f64Price, 221 Time: tradeTime, 222 Volume: f64Volume, 223 Source: s.exchangeName, 224 VerifiedPair: exchangepair.Verified, 225 BaseToken: exchangepair.UnderlyingPair.BaseToken, 226 QuoteToken: exchangepair.UnderlyingPair.QuoteToken, 227 ForeignTradeID: t.TradeID, 228 } 229 if exchangepair.Verified { 230 log.Info("Got verified trade from stream 1: ", trade) 231 } 232 233 // Handle duplicate trades. 234 discardTrade := trade.IdentifyDuplicateFull(tmFalseDuplicateTrades, duplicateTradesMemory) 235 if !discardTrade { 236 trade.IdentifyDuplicateTagset(tmDuplicateTrades, duplicateTradesMemory) 237 s.chanTrades <- trade 238 } 239 240 case msg = <-client2DownStream: 241 if msg == nil { 242 continue 243 } 244 t := &KucoinMarketMatch{} 245 if err := msg.ReadData(t); err != nil { 246 log.Errorf("Failure to read: %v", err) 247 return 248 } 249 asset := strings.Split(t.Symbol, "-") 250 f64Price, _ := strconv.ParseFloat(t.Price, 64) 251 f64Volume, _ := strconv.ParseFloat(t.Size, 64) 252 timeOrder, err := strconv.ParseInt(t.Time, 10, 64) 253 if err != nil { 254 log.Error("parse trade time: ", err) 255 } 256 // WS returns different lengths of Unix timestamps. Adjust to nanoseconds if returns milliseconds. 257 if len(t.Time) == 13 { 258 timeOrder *= 1e6 259 } 260 261 if t.Side == "sell" { 262 f64Volume = -f64Volume 263 } 264 265 exchangepair, err := s.db.GetExchangePairCache(s.exchangeName, t.Symbol) 266 if err != nil { 267 log.Error(err) 268 } 269 270 // Make trade times unique 271 tradeTime := time.Unix(0, timeOrder) 272 pair := dia.Pair{QuoteToken: exchangepair.UnderlyingPair.QuoteToken, BaseToken: exchangepair.UnderlyingPair.BaseToken} 273 if _, ok := lastTradeMap[pair]; ok { 274 if lastTradeMap[pair] != tradeTime { 275 lastTradeMap[pair] = tradeTime 276 countMap[pair] = 0 277 } else { 278 //nolint 279 tradeTime.Add(time.Duration(countMap[pair]+1) * time.Nanosecond) 280 281 countMap[pair] += 1 282 } 283 } else { 284 lastTradeMap[pair] = tradeTime 285 } 286 287 trade := &dia.Trade{ 288 Symbol: asset[0], 289 Pair: t.Symbol, 290 Price: f64Price, 291 Time: time.Unix(0, timeOrder), 292 Volume: f64Volume, 293 Source: s.exchangeName, 294 VerifiedPair: exchangepair.Verified, 295 BaseToken: exchangepair.UnderlyingPair.BaseToken, 296 QuoteToken: exchangepair.UnderlyingPair.QuoteToken, 297 ForeignTradeID: t.TradeID, 298 } 299 if exchangepair.Verified { 300 log.Info("Got verified trade from stream 2: ", trade) 301 } 302 s.chanTrades <- trade 303 304 case msg = <-client3DownStream: 305 if msg == nil { 306 continue 307 } 308 t := &KucoinMarketMatch{} 309 if err := msg.ReadData(t); err != nil { 310 log.Errorf("Failure to read: %v", err) 311 return 312 } 313 asset := strings.Split(t.Symbol, "-") 314 f64Price, _ := strconv.ParseFloat(t.Price, 64) 315 f64Volume, _ := strconv.ParseFloat(t.Size, 64) 316 timeOrder, err := strconv.ParseInt(t.Time, 10, 64) 317 if err != nil { 318 log.Error("parse trade time: ", err) 319 } 320 // WS returns different lengths of Unix timestamps. Adjust to nanoseconds if returns milliseconds. 321 if len(t.Time) == 13 { 322 timeOrder *= 1e6 323 } 324 325 if t.Side == "sell" { 326 f64Volume = -f64Volume 327 } 328 329 exchangepair, err := s.db.GetExchangePairCache(s.exchangeName, t.Symbol) 330 if err != nil { 331 log.Error(err) 332 } 333 334 // Make trade times unique 335 tradeTime := time.Unix(0, timeOrder) 336 pair := dia.Pair{QuoteToken: exchangepair.UnderlyingPair.QuoteToken, BaseToken: exchangepair.UnderlyingPair.BaseToken} 337 if _, ok := lastTradeMap[pair]; ok { 338 if lastTradeMap[pair] != tradeTime { 339 lastTradeMap[pair] = tradeTime 340 countMap[pair] = 0 341 } else { 342 //nolint 343 tradeTime.Add(time.Duration(countMap[pair]+1) * time.Nanosecond) 344 countMap[pair] += 1 345 } 346 } else { 347 lastTradeMap[pair] = tradeTime 348 } 349 350 trade := &dia.Trade{ 351 Symbol: asset[0], 352 Pair: t.Symbol, 353 Price: f64Price, 354 Time: time.Unix(0, timeOrder), 355 Volume: f64Volume, 356 Source: s.exchangeName, 357 VerifiedPair: exchangepair.Verified, 358 BaseToken: exchangepair.UnderlyingPair.BaseToken, 359 QuoteToken: exchangepair.UnderlyingPair.QuoteToken, 360 ForeignTradeID: t.TradeID, 361 } 362 if exchangepair.Verified { 363 log.Info("Got verified trade from stream 3: ", trade) 364 } 365 s.chanTrades <- trade 366 367 case <-s.shutdown: // user requested shutdown 368 log.Println("KuCoin shutting down") 369 s.cleanup(nil) 370 return 371 } 372 } 373 }() 374 } 375 376 func (s *KuCoinScraper) NormalizePair(pair dia.ExchangePair) (dia.ExchangePair, error) { 377 return dia.ExchangePair{}, nil 378 } 379 380 // closes all connected PairScrapers 381 // must only be called from mainLoop 382 func (s *KuCoinScraper) cleanup(err error) { 383 s.errorLock.Lock() 384 defer s.errorLock.Unlock() 385 386 if err != nil { 387 s.error = err 388 } 389 s.closed = true 390 391 close(s.shutdownDone) 392 } 393 394 // Close closes any existing API connections, as well as channels of 395 // PairScrapers from calls to ScrapePair 396 func (s *KuCoinScraper) Close() error { 397 if s.closed { 398 return errors.New("KuCoinScraper: Already closed") 399 } 400 close(s.shutdown) 401 <-s.shutdownDone 402 s.errorLock.RLock() 403 defer s.errorLock.RUnlock() 404 return s.error 405 } 406 407 // ScrapePair returns a PairScraper that can be used to get trades for a single pair from 408 // this APIScraper 409 func (s *KuCoinScraper) ScrapePair(pair dia.ExchangePair) (PairScraper, error) { 410 s.errorLock.RLock() 411 defer s.errorLock.RUnlock() 412 if s.error != nil { 413 return nil, s.error 414 } 415 if s.closed { 416 return nil, errors.New("KucoinScraper: Call ScrapePair on closed scraper") 417 } 418 419 ps := &KuCoinPairScraper{ 420 parent: s, 421 pair: pair, 422 } 423 s.pairScrapers[pair.ForeignName] = ps 424 return ps, nil 425 } 426 427 // FetchAvailablePairs returns all traded pairs on kucoin. 428 func (s *KuCoinScraper) FetchAvailablePairs() (pairs []dia.ExchangePair, err error) { 429 response, err := s.apiService.Symbols("") 430 if err != nil { 431 log.Println("Error Getting Symbols for KuCoin Exchange", err) 432 } 433 434 var kep KuExchangePairs 435 err = response.ReadData(&kep) 436 if err != nil { 437 log.Println("Error Reading Symbols for KuCoin Exchange", err) 438 } 439 for _, p := range kep { 440 pairs = append(pairs, dia.ExchangePair{ 441 Symbol: p.BaseCurrency, 442 ForeignName: p.Symbol, 443 Exchange: s.exchangeName, 444 }) 445 } 446 return 447 } 448 449 // FillSymbolData adds the name to the asset underlying @symbol on kucoin. 450 func (s *KuCoinScraper) FillSymbolData(symbol string) (asset dia.Asset, err error) { 451 // Comment Philipp: 452 // Kucoin's notations for symbols differ too often from the ones used in the underlying contracts. 453 454 // resp, err := s.apiService.Currency(symbol, "") 455 // if err != nil { 456 // log.Errorf("error fetching %s from kucoin api: %v", symbol, err) 457 // } 458 // var kc KucoinCurrency 459 // err = resp.ReadData(&kc) 460 // if err != nil { 461 // log.Errorf("error reading data for %s: %v", symbol, err) 462 // } 463 asset.Symbol = symbol 464 // asset.Name = kc.Name 465 // asset.Address = kc.Address 466 return 467 } 468 469 // KuCoinPairScraper implements PairScraper for kuCoin 470 type KuCoinPairScraper struct { 471 parent *KuCoinScraper 472 pair dia.ExchangePair 473 closed bool 474 } 475 476 // Close stops listening for trades of the pair associated with s 477 func (ps *KuCoinPairScraper) Close() error { 478 var err error 479 s := ps.parent 480 // if parent already errored, return early 481 s.errorLock.RLock() 482 defer s.errorLock.RUnlock() 483 if s.error != nil { 484 return s.error 485 } 486 if ps.closed { 487 return errors.New("KuCoinPairScraper: Already closed") 488 } 489 490 // TODO stop collection for the pair 491 492 ps.closed = true 493 return err 494 } 495 496 // Channel returns a channel that can be used to receive trades 497 func (ps *KuCoinScraper) Channel() chan *dia.Trade { 498 return ps.chanTrades 499 } 500 501 // Error returns an error when the channel Channel() is closed 502 // and nil otherwise 503 func (ps *KuCoinPairScraper) Error() error { 504 s := ps.parent 505 s.errorLock.RLock() 506 defer s.errorLock.RUnlock() 507 return s.error 508 } 509 510 // Pair returns the pair this scraper is subscribed to 511 func (ps *KuCoinPairScraper) Pair() dia.ExchangePair { 512 return ps.pair 513 }