github.com/diadata-org/diadata@v1.4.593/pkg/dia/scraper/exchange-scrapers/BitMartScraper.go (about) 1 package scrapers 2 3 import ( 4 "bytes" 5 "compress/flate" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "io/ioutil" 10 "strconv" 11 "strings" 12 "sync" 13 "time" 14 15 ws "github.com/gorilla/websocket" 16 "github.com/zekroTJA/timedmap" 17 18 "github.com/diadata-org/diadata/pkg/dia" 19 models "github.com/diadata-org/diadata/pkg/model" 20 "github.com/diadata-org/diadata/pkg/utils" 21 ) 22 23 const ( 24 bitMartAPIEndpoint = "https://api-cloud.bitmart.com/spot/v1" 25 bitMartWSEndpoint = "wss://ws-manager-compress.bitmart.com/api?protocol=1.1" 26 bitMartSpotTradingSell = "sell" 27 bitMartSymbolsStatusActive = "trading" 28 bitMartPingMessage = "ping" 29 bitMartPongMessage = "pong" 30 bitMartWSSpotTradingTopic = "spot/trade" 31 bitMartWSOpSubscribe = "subscribe" 32 bitMartWSOpUnsubscribe = "unsubscribe" 33 bitMartRetryAttempts = 15 // Max consecutive retry attempts until connection fail. 34 bitMartPingInterval = 15 // Number of seconds between ping messages. 35 bitMartMaxConnections = 10 // Numbers of connections per IP. 36 bitMartMaxSubsPerConnection = 100 // Subscription limit for each connection. 37 ) 38 39 type BitmartWsRequest struct { 40 Op string `json:"op"` 41 Args []string `json:"args"` 42 } 43 44 type BitmartHttpSymbolsDetailsResponse struct { 45 Message string `json:"message"` 46 Code int `json:"code"` 47 Trace string `json:"trace"` 48 Data struct { 49 Symbols []struct { 50 Symbol string `json:"symbol"` 51 SymbolId int `json:"symbol_id"` 52 BaseCurrency string `json:"base_currency"` 53 QuoteCurrency string `json:"quote_currency"` 54 QuoteIncrement string `json:"quote_increment"` 55 BaseMinSize string `json:"base_min_size"` 56 PriceMinPrecision int `json:"price_min_precision"` 57 PriceMaxPrecision int `json:"price_max_precision"` 58 Expiration string `json:"expiration"` 59 MinBuyAmount string `json:"min_buy_amount"` 60 MinSellAmount string `json:"min_sell_amount"` 61 TradeStatus string `json:"trade_status"` 62 } `json:"symbols"` 63 } `json:"data"` 64 } 65 type BitmartWsTradeResponse struct { 66 Table string `json:"table"` 67 Data []struct { 68 Symbol string `json:"symbol"` 69 Price string `json:"price"` 70 Side string `json:"side"` 71 Size string `json:"size"` 72 TimestampSec int `json:"s_t"` 73 } `json:"data"` 74 ErrorMessage string `json:"errorMessage"` 75 ErrorCode string `json:"errorCode"` 76 Event string `json:"event"` 77 } 78 79 type bitMartPairScraperSet map[*BitMartPairScraper]nothing 80 81 // BitMartScraper is a scraper for BitMart 82 type BitMartScraper struct { 83 // the websocket connection to the BitMart API 84 wsClient []*ws.Conn 85 errCount []int 86 countTopic []int 87 lastUsedConnection int 88 listener chan *BitmartWsTradeResponse 89 // signaling channels for session initialization and finishing 90 shutdown chan nothing 91 shutdownDone chan nothing 92 // error handling; err should be read from error(), closed should be read from isClosed() 93 // those two methods implement RW lock 94 // only cleanup method should hold write lock 95 closedMutex sync.RWMutex // don't need to lock on read 96 closed bool 97 errMutex sync.RWMutex 98 err error 99 // used to keep track of trading pairs that we subscribed to 100 // use sync.Maps to concurrently handle multiple pairs 101 pairScrapers sync.Map // dia.ExchangePair -> bitMartPairScraperSet 102 pairSubscriptions sync.Map // dia.ExchangePair -> int (connection ID) 103 exchangeName string 104 chanTrades chan *dia.Trade 105 db *models.RelDB 106 } 107 108 // NewBitMartScraper returns a new BitMart scraper 109 func NewBitMartScraper(exchange dia.Exchange, scrape bool, relDB *models.RelDB) *BitMartScraper { 110 s := &BitMartScraper{ 111 wsClient: make([]*ws.Conn, bitMartMaxConnections), 112 errCount: make([]int, bitMartMaxConnections), 113 countTopic: make([]int, bitMartMaxConnections), 114 listener: make(chan *BitmartWsTradeResponse), 115 shutdown: make(chan nothing), 116 shutdownDone: make(chan nothing), 117 closed: false, 118 err: nil, 119 exchangeName: exchange.Name, 120 chanTrades: make(chan *dia.Trade), 121 db: relDB, 122 } 123 for i := 0; i < bitMartMaxConnections; i++ { 124 var wsDialer ws.Dialer 125 wsConn, _, err := wsDialer.Dial(bitMartWSEndpoint, nil) 126 if err != nil { 127 println(err.Error()) 128 } 129 s.wsClient[i] = wsConn 130 } 131 if scrape { 132 go s.mainLoop() 133 } 134 return s 135 } 136 137 // Close closes any existing API connections, as well as channels of 138 // PairScrapers from calls to ScrapePair 139 func (s *BitMartScraper) Close() error { 140 if s.isClosed() { 141 return errors.New("scraper already closed") 142 } 143 s.close() 144 close(s.shutdown) 145 for i := 0; i < bitMartMaxConnections; i++ { 146 err := s.wsClient[i].Close() 147 if err != nil { 148 return fmt.Errorf("Close Error: %s", err.Error()) 149 } 150 } 151 <-s.shutdownDone 152 return s.error() 153 } 154 155 // ScrapePair returns a PairScraper that can be used to get trades for a single pair from the BitMart scraper 156 func (s *BitMartScraper) ScrapePair(pair dia.ExchangePair) (PairScraper, error) { 157 if err := s.error(); err != nil { 158 return nil, err 159 } 160 if s.isClosed() { 161 return nil, errors.New("scraper is closed") 162 } 163 ps := &BitMartPairScraper{ 164 parent: s, 165 pair: pair, 166 } 167 pairScrapers, _ := s.pairScrapers.LoadOrStore(pair.ForeignName, bitMartPairScraperSet{}) 168 pairScrapers.(bitMartPairScraperSet)[ps] = nothing{} 169 if _, ok := s.pairSubscriptions.Load(pair.ForeignName); !ok { 170 wsIdx := s.lastUsedConnection 171 if cIdx := s.lastUsedConnection; s.countTopic[cIdx] < bitMartMaxSubsPerConnection { 172 wsIdx = cIdx 173 } else { 174 for i := 0; i < bitMartMaxConnections; i++ { 175 if s.countTopic[i] < bitMartMaxSubsPerConnection { 176 wsIdx = i 177 } 178 } 179 } 180 if err := s.subscribe(pair.ForeignName, wsIdx); err != nil { 181 delete(pairScrapers.(bitMartPairScraperSet), ps) 182 return nil, err 183 } 184 s.pairSubscriptions.Store(pair.ForeignName, wsIdx) 185 } else { 186 return nil, fmt.Errorf("pair %s already subscribed", pair.ForeignName) 187 } 188 return ps, nil 189 } 190 191 // FetchAvailablePairs returns a list with all available trade pairs 192 func (s *BitMartScraper) FetchAvailablePairs() (pairs []dia.ExchangePair, err error) { 193 data, _, err := utils.GetRequest(bitMartAPIEndpoint + "/symbols/details") 194 if err != nil { 195 return 196 } 197 var res BitmartHttpSymbolsDetailsResponse 198 err = json.Unmarshal(data, &res) 199 if err == nil { 200 for _, p := range res.Data.Symbols { 201 if p.TradeStatus != bitMartSymbolsStatusActive { 202 continue 203 } 204 pair, err := s.NormalizePair(dia.ExchangePair{ 205 Symbol: p.BaseCurrency, 206 ForeignName: p.Symbol, 207 Exchange: s.exchangeName, 208 }) 209 if err != nil { 210 return nil, err 211 } 212 pairs = append(pairs, pair) 213 } 214 } 215 return pairs, nil 216 } 217 218 // Channel returns a channel that can be used to receive trades 219 func (s *BitMartScraper) Channel() chan *dia.Trade { 220 return s.chanTrades 221 } 222 223 // TODO: FillSymbolData adds the name to the asset underlying @symbol on BitMart 224 func (s *BitMartScraper) FillSymbolData(symbol string) (dia.Asset, error) { 225 return dia.Asset{Symbol: symbol}, nil 226 } 227 228 // TODO: NormalizePair accounts for the par 229 func (s *BitMartScraper) NormalizePair(pair dia.ExchangePair) (dia.ExchangePair, error) { 230 return pair, nil 231 } 232 233 // BitMartPairScraper implements PairScraper for BitMart 234 type BitMartPairScraper struct { 235 parent *BitMartScraper 236 pair dia.ExchangePair 237 closed bool 238 } 239 240 // Close stops listening for trades of the pair associated with the BitMart scraper 241 func (ps *BitMartPairScraper) Close() error { 242 if ps.closed { 243 return fmt.Errorf("pair %s already unsubscribed", ps.pair.ForeignName) 244 } 245 log.Infof("Closing %s pair scraper...", ps.pair.ForeignName) 246 if err := ps.parent.error(); err != nil { 247 return err 248 } 249 if err := ps.parent.unsubscribe(ps.pair.ForeignName); err != nil { 250 return err 251 } 252 ps.parent.pairSubscriptions.Delete(ps.pair.ForeignName) 253 ps.closed = true 254 return nil 255 } 256 257 // Error returns an error when the channel Channel() is closed and nil otherwise 258 func (ps *BitMartPairScraper) Error() error { 259 return ps.parent.error() 260 } 261 262 // Pair returns the pair this scraper is subscribed to 263 func (ps *BitMartPairScraper) Pair() dia.ExchangePair { 264 return ps.pair 265 } 266 267 // runs in a goroutine until s is closed 268 func (s *BitMartScraper) mainLoop() { 269 defer s.cleanup(nil) 270 defer func() { 271 log.Printf("Shutting down main loop...\n") 272 }() 273 tmFalseDuplicateTrades := timedmap.New(duplicateTradesScanFrequency) 274 tmDuplicateTrades := timedmap.New(duplicateTradesScanFrequency) 275 276 for i := range bitMartMaxConnections { 277 go func(idx int) { 278 defer func() { 279 if a := recover(); a != nil { 280 log.Errorf("Work routine end. Recover msg: %+v", a) 281 } 282 }() 283 // ticker := time.NewTicker(bitMartPingInterval * time.Second) 284 // defer ticker.Stop() 285 // for { 286 // <-ticker.C 287 // if s.isClosed() { 288 // return 289 // } 290 291 // err := s.wsClient[idx].WriteMessage(ws.TextMessage, []byte(bitMartPingMessage)) 292 // if err != nil { 293 // log.Errorf("Error sending ping: %s", err) 294 // return 295 // } else { 296 // log.Warn("sent ping") 297 // } 298 // } 299 }(i) 300 go func(idx int) { 301 defer func() { 302 if a := recover(); a != nil { 303 log.Errorf("Receive routine end. Recover msg: %+v", a) 304 } 305 }() 306 for { 307 if s.wsClient[idx] != nil { 308 msgType, msg, err := s.wsClient[idx].ReadMessage() 309 if err != nil { 310 if s.isClosed() || s.errCount[idx] > bitMartRetryAttempts { 311 return 312 } 313 if err := s.retryConnection(idx); err != nil || ws.IsCloseError(err, ws.CloseAbnormalClosure) { 314 return 315 } 316 } 317 318 var output []byte 319 switch msgType { 320 case ws.BinaryMessage: 321 reader := bytes.NewReader(msg) 322 gzreader := flate.NewReader(reader) 323 if err != nil { 324 log.Error("flate reader: ", err) 325 return 326 } 327 output, err = ioutil.ReadAll(gzreader) 328 if err != nil { 329 log.Error("read all: ", err) 330 return 331 } 332 case ws.TextMessage: 333 if string(msg) == bitMartPongMessage { 334 s.errCount[idx] = 0 335 return 336 } 337 output = msg 338 } 339 340 // Unmarshal output and forward to listener. 341 var subResults BitmartWsTradeResponse 342 if err := json.Unmarshal(output, &subResults); err != nil { 343 log.Errorf("Response error at connection #%d, err=%s\n", idx, err.Error()) 344 s.setError(err) 345 s.errCount[idx]++ 346 if err := s.retryConnection(idx); err != nil { 347 return 348 } 349 } 350 if subResults.ErrorCode != "" { 351 log.Errorf("Error code %s at %s event: %s", subResults.ErrorCode, subResults.Event, subResults.ErrorMessage) 352 s.errCount[idx]++ 353 continue 354 } 355 if subResults.Table == bitMartWSSpotTradingTopic { 356 s.errCount[idx] = 0 357 s.listener <- &subResults 358 } 359 } 360 } 361 }(i) 362 } 363 for { 364 select { 365 case response := <-s.listener: 366 367 for _, data := range response.Data { 368 var exchangepair dia.ExchangePair 369 volume, _ := strconv.ParseFloat(data.Size, 64) 370 if data.Side == bitMartSpotTradingSell { 371 volume = -volume 372 } 373 price, _ := strconv.ParseFloat(data.Price, 64) 374 timestamp := time.Unix(int64(data.TimestampSec), 0) 375 symbol := strings.Split(data.Symbol, `_`) 376 exchangepair, err := s.db.GetExchangePairCache(s.exchangeName, data.Symbol) 377 if err != nil { 378 log.Error(err) 379 } 380 t := &dia.Trade{ 381 Symbol: symbol[0], 382 Pair: data.Symbol, 383 Price: price, 384 Time: timestamp, 385 Volume: volume, 386 Source: s.exchangeName, 387 ForeignTradeID: fmt.Sprintf("%s_%d", data.Symbol, data.TimestampSec), 388 VerifiedPair: exchangepair.Verified, 389 BaseToken: exchangepair.UnderlyingPair.BaseToken, 390 QuoteToken: exchangepair.UnderlyingPair.QuoteToken, 391 } 392 393 discardTrade := t.IdentifyDuplicateFull(tmFalseDuplicateTrades, duplicateTradesMemory) 394 if discardTrade { 395 log.Warn("Identical trade already scraped: ", t) 396 continue 397 } else { 398 t.IdentifyDuplicateTagset(tmDuplicateTrades, duplicateTradesMemory) 399 log.Infof("got trade at %v : %s -- %v -- %v", t.Time, t.Pair, t.Price, t.Volume) 400 s.chanTrades <- t 401 } 402 } 403 case <-s.shutdown: 404 return 405 } 406 } 407 } 408 409 func (s *BitMartScraper) retryConnection(idx int) error { 410 s.countTopic[idx] = 0 411 log.Errorf("Reconnecting connection #%d...\n", idx) 412 wsConn, _, err := ws.DefaultDialer.Dial(bitMartWSEndpoint, nil) 413 if err != nil { 414 s.errCount[idx]++ 415 return err 416 } 417 s.wsClient[idx] = wsConn 418 subs := make([]string, 0) 419 s.pairSubscriptions.Range(func(k, v interface{}) bool { 420 if v.(int) == idx { 421 if foreignName := k.(string); foreignName != "" { 422 subs = append(subs, foreignName) 423 } 424 } 425 return true 426 }) 427 for _, foreignName := range subs { 428 if err = s.subscribe(foreignName, idx); err != nil { 429 break 430 } 431 } 432 if err != nil { 433 log.Errorf("Recovering %d error., err=%s\n", idx, err.Error()) 434 s.setError(err) 435 s.errCount[idx]++ 436 return err 437 } 438 log.Infof("Successfully reconnected connection %d., errCount=%d", idx, s.errCount[idx]) 439 return nil 440 } 441 442 // closes all connected PairScrapers 443 // must only be called from mainLoop 444 func (s *BitMartScraper) cleanup(err error) { 445 s.pairScrapers.Range(func(k, v interface{}) bool { 446 for ps := range v.(bitMartPairScraperSet) { 447 ps.closed = true 448 } 449 s.pairScrapers.Delete(k) 450 return true 451 }) 452 if err != nil { 453 s.setError(err) 454 } 455 s.close() 456 close(s.shutdownDone) 457 } 458 459 func (s *BitMartScraper) isClosed() bool { 460 s.closedMutex.RLock() 461 defer s.closedMutex.RUnlock() 462 return s.closed 463 } 464 465 func (s *BitMartScraper) close() { 466 s.closedMutex.Lock() 467 defer s.closedMutex.Unlock() 468 s.closed = true 469 } 470 471 func (s *BitMartScraper) error() error { 472 s.errMutex.RLock() 473 defer s.errMutex.RUnlock() 474 return s.err 475 } 476 477 func (s *BitMartScraper) setError(err error) { 478 s.errMutex.Lock() 479 defer s.errMutex.Unlock() 480 s.err = err 481 } 482 483 func (s *BitMartScraper) subscribe(foreignName string, id int) error { 484 topic := fmt.Sprintf("%s:%s", bitMartWSSpotTradingTopic, foreignName) 485 if err := s.wsClient[id].WriteJSON(BitmartWsRequest{ 486 Op: bitMartWSOpSubscribe, 487 Args: []string{topic}, 488 }); err != nil { 489 return err 490 } 491 s.countTopic[id]++ 492 if s.lastUsedConnection == bitMartMaxConnections-1 { 493 s.lastUsedConnection = 0 494 } else { 495 s.lastUsedConnection++ 496 } 497 return nil 498 } 499 500 func (s *BitMartScraper) unsubscribe(foreignName string) error { 501 if id, ok := s.pairSubscriptions.Load(foreignName); ok { 502 if err := s.wsClient[id.(int)].WriteJSON(BitmartWsRequest{ 503 Op: bitMartWSOpUnsubscribe, 504 Args: []string{fmt.Sprintf("%s:%s", bitMartWSSpotTradingTopic, foreignName)}, 505 }); err != nil { 506 return err 507 } 508 s.pairSubscriptions.Delete(foreignName) 509 s.countTopic[id.(int)]-- 510 return nil 511 } else { 512 return errors.New("subscription id not found") 513 } 514 }