├── package.json ├── examples ├── subscribe_to_orders.js ├── subscribe_to_tickers.js └── subscribe_to_markets.js ├── README.md ├── CHANGELOG.md ├── doc └── api.md └── lib ├── connection.js └── client.js /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bittrex-signalr-client", 3 | "version": "1.1.10", 4 | "description": "Node.js implementation of SignalR protocol tailored for Bittrex exchange", 5 | "main": "lib/client.js", 6 | "scripts": {}, 7 | "author": "Aloysius Pendergast ", 8 | "license": "ISC", 9 | "keywords": [ 10 | "trading", 11 | "cryptocurrency", 12 | "bittrex", 13 | "api", 14 | "websocket", 15 | "signalr-client" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/aloysius-pgast/bittrex-signalr-client" 20 | }, 21 | "dependencies": { 22 | "big.js": "^5.0.3", 23 | "cloudscraper": "^1.4.1", 24 | "debug": "^3.1.0", 25 | "lodash": "^4.17.4", 26 | "request": "^2.83.0", 27 | "retry": "^0.10.1", 28 | "ws": "^3.2.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/subscribe_to_orders.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const util = require('util'); 3 | const SignalRClient = require('../lib/client.js'); 4 | 5 | let client = new SignalRClient({ 6 | // websocket will be automatically reconnected if server does not respond to ping after 10s 7 | pingTimeout:10000, 8 | // NB: you need to provide correct API key & secret above to be able to subscribe to orders 9 | auth:{ 10 | key:"abcdef", 11 | secret: "123456" 12 | }, 13 | watchdog:{ 14 | // automatically re-subscribe for orders every 30min (this is enabled by default) 15 | orders:{ 16 | enabled:true, 17 | period:1800 18 | } 19 | }, 20 | // use cloud scraper to bypass Cloud Fare (default) 21 | useCloudScraper:true 22 | }); 23 | 24 | //-- event handlers 25 | client.on('order', function(data){ 26 | console.log(util.format("Got 'order' event for order '%s' (%s)", data.orderNumber, data.pair)); 27 | console.log(JSON.stringify(data)); 28 | }); 29 | 30 | //-- start subscription 31 | console.log("=== Subscribing to orders"); 32 | client.subscribeToOrders(); 33 | 34 | // disconnect client after 10min 35 | setTimeout(function(){ 36 | console.log('=== Disconnecting...'); 37 | client.disconnect(); 38 | process.exit(0); 39 | }, 600000); 40 | -------------------------------------------------------------------------------- /examples/subscribe_to_tickers.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const util = require('util'); 3 | const SignalRClient = require('../lib/client.js'); 4 | let client = new SignalRClient({ 5 | // websocket will be automatically reconnected if server does not respond to ping after 10s 6 | pingTimeout:10000, 7 | // use cloud scraper to bypass Cloud Fare (default) 8 | useCloudScraper:true 9 | }); 10 | 11 | //-- event handlers 12 | client.on('ticker', function(data){ 13 | console.log(util.format("Got ticker update for pair '%s'", data.pair)); 14 | }); 15 | 16 | //-- start subscription 17 | console.log("=== Subscribing to 'USDT-BTC' pair"); 18 | client.subscribeToTickers(['USDT-BTC']); 19 | 20 | // add subscription for 'USDT-ETH' & 'BTC-USDT' after 15s 21 | setTimeout(function(){ 22 | console.log("=== Adding subscription for USDT-ETH & BTC-ETH pairs"); 23 | client.subscribeToTickers(['USDT-ETH','BTC-ETH']); 24 | }, 30000); 25 | 26 | // add subscription for 'BTC-NEO', unsubscribe from previous pairs after 30s 27 | setTimeout(function(){ 28 | console.log("=== Setting BTC-NEO as the only pair we want to subscribe to"); 29 | client.subscribeToTickers(['BTC-NEO'], true); 30 | }, 60000); 31 | 32 | // disconnect client after 60s 33 | setTimeout(function(){ 34 | console.log('=== Disconnecting...'); 35 | client.disconnect(); 36 | process.exit(0); 37 | }, 120000); 38 | -------------------------------------------------------------------------------- /examples/subscribe_to_markets.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const util = require('util'); 3 | const SignalRClient = require('../lib/client.js'); 4 | let client = new SignalRClient({ 5 | // websocket will be automatically reconnected if server does not respond to ping after 10s 6 | pingTimeout:10000, 7 | watchdog:{ 8 | // automatically reconnect if we don't receive markets data for 30min (this is the default) 9 | markets:{ 10 | timeout:1800000, 11 | reconnect:true 12 | } 13 | }, 14 | // use cloud scraper to bypass Cloud Fare (default) 15 | useCloudScraper:true 16 | }); 17 | 18 | //-- event handlers 19 | client.on('orderBook', function(data){ 20 | console.log(util.format("Got full order book for pair '%s' : cseq = %d", data.pair, data.cseq)); 21 | }); 22 | client.on('orderBookUpdate', function(data){ 23 | console.log(util.format("Got order book update for pair '%s' : cseq = %d", data.pair, data.cseq)); 24 | }); 25 | client.on('trades', function(data){ 26 | console.log(util.format("Got trades for pair '%s'", data.pair)); 27 | }); 28 | 29 | //-- start subscription 30 | console.log("=== Subscribing to 'USDT-BTC' pair"); 31 | client.subscribeToMarkets(['USDT-BTC']); 32 | 33 | // add subscription for 'USDT-ETH' & 'BTC-USDT' after 15s 34 | setTimeout(function(){ 35 | console.log("=== Adding subscription for USDT-ETH & BTC-ETH pairs"); 36 | client.subscribeToMarkets(['USDT-ETH','BTC-ETH']); 37 | }, 30000); 38 | 39 | // add subscription for 'BTC-NEO', unsubscribe from previous pairs after 30s 40 | setTimeout(function(){ 41 | console.log("=== Setting BTC-NEO as the only pair we want to subscribe to"); 42 | client.subscribeToMarkets(['BTC-NEO'], true); 43 | }, 60000); 44 | 45 | // disconnect client after 60s 46 | setTimeout(function(){ 47 | console.log('=== Disconnecting...'); 48 | client.disconnect(); 49 | process.exit(0); 50 | }, 120000); 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bittrex-signalr-client 2 | 3 | Node.js implementation of SignalR protocol tailored for Bittrex exchange 4 | 5 | ## Disclaimer 6 | 7 | This is a work in progress mostly meant to be integrated in [Crypto Exchanges Gateway](https://github.com/aloysius-pgast/crypto-exchanges-gateway). But it can also be used as a standalone module to connect to Bittrex WS 8 | 9 | NB : doing REST API calls is outside of the scope of the module. If you need support for Bittrex REST API, use [node.bittrex.api](https://github.com/dparlevliet/node.bittrex.api) instead 10 | 11 | ## What it does 12 | 13 | * Implement methods for subscribing to tickers & markets (order books & trades) 14 | 15 | * Automatically detect missed order book events and resync automatically by retrieving full order book 16 | 17 | * Handle automatic reconnection in (I think !) every possible scenario 18 | 19 | * Implements a watchdog to detect when _Bittrex_ stops sending data (default to 30min, automatic reconnection) 20 | 21 | * Handle CloudFare's anti-ddos page using [cloudscaper](https://www.npmjs.com/package/cloudscraper/) 22 | 23 | * Support for new Bittrex API (tickers, order books, trades & user orders) 24 | 25 | ## Installation 26 | 27 | ``` 28 | npm install bittrex-signalr-client 29 | ``` 30 | 31 | ## How to use it 32 | 33 | See [documentation in _doc_ directory](https://github.com/aloysius-pgast/bittrex-signalr-client/tree/master/doc/) for a description of supported API 34 | 35 | See [examples in _examples_ directory](https://github.com/aloysius-pgast/bittrex-signalr-client/tree/master/examples/) for an overview of what this library can do 36 | 37 | ## Other similar projects 38 | 39 | * [signalr-client](https://www.npmjs.com/package/signalr-client) 40 | 41 | My work is inspired by _signalr-client_. Unfortunately, developer of _signalr-client_ is not working actively on it anymore. 42 | Also, the way disconnection was managed in _signalr-client_ didn't suit my needs 43 | 44 | * [node.bittrex.api](https://github.com/dparlevliet/node.bittrex.api) 45 | 46 | _node.bittrex.api_ is a really nice wrapper around Bittrex API. Unfortunately it uses _signalr-client_ internally. 47 | 48 | I need to add that without the work of [dparlevliet](https://github.com/dparlevliet) who did some reverse engineering on Bittrex usage of SignalR, this library would not exist 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v1.1.10] 4 | * New constructor option _resyncOrderBooksAutomatically_ can be set to _false_ to disable automatic order books resync (default = true). This might be useful when subscribing to many pairs 5 | * New constructor option _exitOnQueryExchangeStateError_ can be set to _true_ to force library to exit in case an error occurs while trying to resync an order book (mostly for troubleshooting purpose) (default = false) 6 | 7 | ## [v1.1.9] 8 | * New constructor option _markNewOrderBookEntriesAsUpdates_ can be set to _false_ to have _action_ = _add_ instead of _update_ when a new order book entries are added (default = _true_) 9 | 10 | ## [v1.1.8] 11 | * An error was triggered when calling QueryExchangeState for an unsupported pair 12 | 13 | ## [v1.1.7] 14 | * Fix previous version in case socket is reconnected 15 | 16 | ## [v1.1.6] 17 | * Only return trades which were executed after subscription (this means that first 'trades' event will be emitted only when the first trade will be executed after subscription) 18 | 19 | ## [v1.1.5] 20 | * Fix _ticker_ timestamp 21 | 22 | ## [v1.1.4] 23 | * Add _trade id_ when emitting _trades_ 24 | 25 | ## [v1.1.3] 26 | * Only emit _watchdog_ event for _orders_ if authentication succeeded 27 | 28 | ## [v1.1.2] 29 | * Ensures connection to exchange is always closed when we don't have any subscription remaining 30 | * New constructor option _watchdog.orders_ to automatically force re-subscriptions for orders periodically 31 | * Fix method _unsubscribeFromOrders_ (subscription wasn't cancelled) 32 | 33 | ## [v1.1.1] 34 | * Allows to force re-subscription to orders (Bittrex Beta API) 35 | * New method to enable logging keepalive messages (node _DEBUG_ must be enabled) 36 | * Bittrex Beta is over, removed _legacy_ methods 37 | 38 | ## [v1.1.0] 39 | * Support for Bittrex Beta API (tickers, order books, trades & user orders) 40 | * Possibility to disable Cloud Scraper 41 | 42 | ## [v1.0.9] 43 | * Change default User-Agent & add extra headers to bypass CloudFare protection 44 | 45 | ## [v1.0.8] 46 | * Watchdog was added to reconnect upon detecting timeout (ie: when _Bittrex_ stopped sending data) 47 | 48 | ## [v1.0.7] 49 | * Method _subscribeToAllTickers_ was added to subscribe to all tickers at once 50 | 51 | ## [v1.0.6] 52 | * Take UTC offset into account when parsing DateTime strings returned by Bittrex 53 | 54 | ## [v1.0.5] 55 | * Use _Big.js_ for floating point arithmetic 56 | 57 | ## [v1.0.4] 58 | * Change _transport_ from _serverSentEvents_ to _webSockets_ for _abort_ step 59 | 60 | ## [v1.0.3] 61 | * Change _transport_ from _serverSentEvents_ to _webSockets_ for _start_ step 62 | * Implement call to _SignalR_ method _SubscribeToSummaryDeltas_ for tickers subscription (update are not sent automatically anymore by Bittrex) 63 | 64 | ## [v1.0.2] 65 | * Ensure _disconnected_ and _connected_ events are properly emitted 66 | * New method to retrieve SignalR connectionId 67 | * Changes to logged messages 68 | -------------------------------------------------------------------------------- /doc/api.md: -------------------------------------------------------------------------------- 1 | # Constructor 2 | 3 | Constructor takes an object as argument with following available properties (all optional) 4 | 5 | * _useCloudScraper_ : _boolean_, if _false_ Cloud Scraper will not be used (default = _true_) 6 | 7 | * _auth.key_ : _string_, Bittrex API key (only needed to subscribe to user orders) 8 | 9 | * _auth.secret_ : _string_, Bittrex API secret (only needed to subscribe to user orders) 10 | 11 | * _retryDelay_ : _integer_, delay in milliseconds before reconnecting upon disconnection or connection failure (default = _10000_) 12 | 13 | * _retryCount.negotiate_ : _integer_, number of retries in case _negotiate_ step fails (default = _11_) (can be set to string _always_ to retry indefinitely) 14 | 15 | * _retryCount.connect_ : _integer_, number of retries in case _connect_ step fails (default = _1_) (can be set to string _always_ to retry indefinitely) 16 | 17 | * _retryCount.start_ : _integer_, number of retries in case _start_ step fails (default = _1_) (can be set to string _always_ to retry indefinitely) 18 | 19 | * _reconnectAfterUnsubscribingFromMarkets.reconnect_ : _boolean_. If _true_, current _SignalR_ connection will be reconnected when unsubscribing from markets (since _Bittrex_ does not allow any other way to unsubscribe). If _false_ library will keep receiving data for unsubscribed markets (which could consume unnecessary bandwidth in the long run) (default = _true_) 20 | 21 | * _reconnectAfterUnsubscribingFromMarkets.after_ : integer, indicates after how many un-subscriptions _SignalR_ connection should be reconnected (default = _1_) 22 | 23 | * _watchdog.tickers.timeout_ : _integer_, delay in milliseconds after which we should consider timeout if no _tickers_ data was received. (default = _1800000_, 30min) (set to _0_ to disable) 24 | 25 | * _watchdog.tickers.reconnect_ : _boolean_, if true a reconnection will occur upon detecting timeout (default = _true_) 26 | 27 | * _watchdog.markets.timeout_ : _integer_, delay in milliseconds after which we should consider timeout if no _markets_ data was received. (default = _1800000_, 30min) (set to _0_ to disable) 28 | 29 | * _watchdog.markets.reconnect_ : _boolean_, if true a reconnection will occur upon detecting timeout (default = _true_) 30 | 31 | * _watchdog.orders.enabled_ : _boolean_, if true library will re-subscribe for orders periodically (default = _true_) 32 | 33 | * _watchdog.orders.period_ : _integer_, delay (in _seconds_) after which re-subscription should be performed (default = _1800_, 30min) 34 | 35 | * _markNewOrderBookEntriesAsUpdates_ : _boolean_. If set to _false_ new order book entries will have _action_ = _add_ instead of _update_. By default both new entries an updated entries will have _action_ = _update_ in _orderBookUpdate_ event (default = _true_) 36 | 37 | * _exitOnQueryExchangeStateError_ : _boolean_. If set to _true_ library will exit if an error is triggered after calling *QueryExchangeState* (this is mostly for troubleshooting purpose) (default = _false_) 38 | 39 | * _resyncOrderBooksAutomatically_ : _boolean_. If set to _true_ order books will be resynced automatically in case a missing *Nounce* is detected (default = _true_) 40 | 41 | # Watchdog 42 | 43 | ## Tickers & Markets 44 | 45 | When watchdog is enabled, it will only be activated if subscriptions exist. If client unsubscribe from all _markets_ or all _tickers_, it will be automatically disabled and re-enabled once new subscriptions exist 46 | 47 | If a watchdog is enabled and _timeout_ is detected, _timeout_ event will be triggered. If _reconnect_ is _false_ for this watchdog, client will need to reconnect manually 48 | 49 | Watchdog will check for timeout every _timeout / 10_. This means that will be detected between _timeout_ ms and _timeout ms + 10%_. 50 | 51 | Do not set timeout value *too low* : setting value to _5 min_ will ensure timeout will be detected between _5 min_ and _5 min 30 sec_, while checking for timeout _every 30 seconds_ 52 | 53 | ## Orders 54 | 55 | When _watchdog.orders.enabled_ is _true_, a new subscription for orders will be sent to Bittrex, N seconds (_watchdog.orders.period_) after the last subscription 56 | 57 | NB: re-subscription only be triggered if a subscription for orders exist (ie: if _subscribeToOrders_ was called by client) 58 | 59 | # Reconnection 60 | 61 | Method _reconnect(immediate)_ should be called upon receiving _terminated_ event 62 | 63 | * _immediate_ : boolean, if _true_ connection will be reconnected immediately, otherwise delay will be as specified in constructor (_retryDelay_) (default = _false_) 64 | 65 | # Subscriptions methods 66 | 67 | ## Subscribe to all tickers 68 | 69 | Used to subscribe to all existing tickers at once 70 | 71 | Method _subscribeToAllTickers()_ 72 | 73 | ## Subscribe to tickers 74 | 75 | Used to subscribe to tickers for a list of pairs (will be ignored if a subscription to _all_ tickers exists) 76 | 77 | Method _subscribeToTickers(pairs, reset)_ 78 | 79 | * _pairs_ : array of pairs to subscribed to (ex: _['USDT-BTC']_) 80 | 81 | * _reset_ : if _true_, previous subscriptions will be discarded (optional, default = _false_) 82 | 83 | ## Unsubscribe from tickers 84 | 85 | Used to unsubscribe from tickers for a list of pairs (will be ignored if a subscription to _all_ tickers exists) 86 | 87 | Method _unsubscribeFromTickers(pairs)_ 88 | 89 | * _pairs_ : array of pairs to unsubscribe from (ex: _['USDT-BTC']_) 90 | 91 | ## Unsubscribe from all tickers 92 | 93 | Used to unsubscribe from tickers for all currently subscribed pairs 94 | 95 | Method _unsubscribeFromAllTickers()_ 96 | 97 | ## Subscribe to markets 98 | 99 | Used to subscribe to markets data (order books & trades) for a list of pairs 100 | 101 | Method _subscribeToMarkets(pairs, reset)_ 102 | 103 | * _pairs_ : array of pairs to subscribed to (ex: _['USDT-BTC']_) 104 | 105 | * _reset_ : if _true_, previous subscriptions will be discarded (optional, default = _false_) 106 | 107 | ## Unsubscribe from markets 108 | 109 | Used to unsubscribe from markets (order books & trades) for a list of pairs 110 | 111 | Method _unsubscribeFromMarkets(pairs)_ 112 | 113 | * _pairs_ : array of pairs to unsubscribe from (ex: _['USDT-BTC']_) 114 | 115 | ## Unsubscribe from all markets 116 | 117 | Used to unsubscribe from markets for all currently subscribed pairs 118 | 119 | Method _unsubscribeFromAllMarkets()_ 120 | 121 | ## Resync order book 122 | 123 | Used to request full order book for a list of pairs. This shouldn't be necessary as this is automatically done by library upon subscribing to a market or when library detect that order book updates were missed (based on cseq) 124 | 125 | Method _resyncOrderBooks(pairs)_ 126 | 127 | * _pairs_ : array of pairs to ask full order books for (ex: _['USDT-BTC']_) 128 | 129 | ## Subscribe to orders 130 | 131 | Used to subscribe to orders data (ie: receive events when your orders are being opened, filled, cancelled) 132 | 133 | Method _subscribeToOrders(resubscribe)_ 134 | 135 | * _resubscribe_ : if _true_ client will re-subscribe to exchange, even if a subscription already exists (default = _false_) 136 | 137 | ## Unsubscribe from orders 138 | 139 | Used to unsubscribe from orders feed 140 | 141 | Method _unsubscribeFromOrders()_ 142 | 143 | # Emitted events 144 | 145 | ## Connection related events 146 | 147 | ### connected 148 | 149 | When _SignalR_ connection connection is connected/reconnected. No action should be taken by client. 150 | 151 | ``` 152 | { 153 | "connectionId":string 154 | } 155 | ``` 156 | 157 | * _connectionId_ : id of _SignalR_ connection 158 | 159 | ### disconnected 160 | 161 | When _SignalR_ connection has been closed by exchange. Reconnection will be automatic, no action should be taken by client. 162 | 163 | ``` 164 | { 165 | "connectionId":string, 166 | "code":integer, 167 | "reason":string 168 | } 169 | ``` 170 | 171 | * _connectionId_ : id of _SignalR_ connection 172 | 173 | * _code_ : disconnection code 174 | 175 | * _reason_ : disconnection reason 176 | 177 | ### connectionError 178 | 179 | When a connection/reconnection error has occurred. Library will automatically retry to connect. No action should be taken by client. 180 | 181 | ``` 182 | { 183 | "step":string, 184 | "attempts":integer, 185 | "error":object 186 | } 187 | ``` 188 | 189 | * _step_ : connection step (negotiate|start|connect) 190 | 191 | * _attempts_ : number of attempts to connect 192 | 193 | * _error_ : the connection error which occurred 194 | 195 | ### terminated 196 | 197 | When connection failed after last connection retry. This is a final event, library will not try to reconnect automatically anymore. This event will never be emitted if library was setup with infinite retry (see _constructor_). Client should call method _reconnect()_ upon receiving this event. 198 | 199 | ``` 200 | { 201 | "step":string, 202 | "attempts":integer, 203 | "error":object 204 | } 205 | ``` 206 | 207 | ### timeout 208 | 209 | When watchdog detected that Bittrex stopped sending data. If _watchdog_ was configured to reconnect automatically upon detecting timeout, no action is required on client side 210 | 211 | ``` 212 | { 213 | "connectionId":string, 214 | "dataType":string, 215 | "lastTimestamp":integer 216 | } 217 | ``` 218 | 219 | * _connectionId_ : id of _SignalR_ connection 220 | 221 | * _dataType_ : one of (_tickers_,_markets_) 222 | 223 | * _lastTimestamp_ : unix timestamp (in ms) of last received data 224 | 225 | NB: will only be sent for _tickers_ & _markets_ 226 | 227 | ### watchdog 228 | 229 | ### Tickers & markets 230 | 231 | For _tickers_ and _markets_ watchdogs, event will be emitted everytime watchdog checks if a timeout occurred. 232 | 233 | ``` 234 | { 235 | "connectionId":string, 236 | "dataType":string, 237 | "lastTimestamp":integer 238 | } 239 | ``` 240 | 241 | * _connectionId_ : id of _SignalR_ connection 242 | 243 | * _dataType_ : one of (_tickers_,_markets_) 244 | 245 | * _lastTimestamp_ : unix timestamp (in ms) of last received data 246 | 247 | #### Orders 248 | 249 | For _orders_ watchdog, event will be emitted everytime, an _automatic_ re-subscription was successful 250 | 251 | ``` 252 | { 253 | "connectionId":string, 254 | "dataType":string, 255 | "lastTimestamp":integer 256 | } 257 | ``` 258 | 259 | * _connectionId_ : id of _SignalR_ connection 260 | 261 | * _dataType_ : _orders_ 262 | 263 | * _lastTimestamp_ : unix timestamp (in ms) of last automatic re-subscription 264 | 265 | ## Tickers & markets events 266 | 267 | ### ticker 268 | 269 | _Example_ 270 | 271 | ``` 272 | { 273 | "pair":"USDT-BTC", 274 | "data":{ 275 | "pair":"USDT-BTC", 276 | "last":7155, 277 | "priceChangePercent":-5.206677139913463, 278 | "sell":7155, 279 | "buy":7150, 280 | "high":7576, 281 | "low":7100.01, 282 | "volume":5357.92210528, 283 | "timestamp":1509986841.91 284 | } 285 | } 286 | ``` 287 | 288 | ### orderBook 289 | 290 | This event contains a **full** order book and is emitted in following cases : 291 | 292 | * on first subscription for a given pair 293 | * when _resyncOrderBooks_ is called or when a missed _Nounce_ has been detected (if _resyncOrderBooksAutomatically_ was set to _true_ in constructor) 294 | 295 | Order book events received from *Bittrex* contain a sequence number (_Nounce_) which is incremented by 1 on each event. If library detects that an event has been missed (`{last seq number} - {previous seq number} > 1`), it will resync (retrieve full orderbook) and emit an _orderBook_ event 296 | 297 | ``` 298 | { 299 | "pair":"USDT-BTC", 300 | "cseq":54694, 301 | "data":{ 302 | "buy":[ 303 | { 304 | "rate":7158, 305 | "quantity":0.18125832 306 | }, 307 | { 308 | "rate":7147.84000102, 309 | "quantity":0.33576833 310 | }, 311 | { 312 | "rate":7147.84000003, 313 | "quantity":0.00037697 314 | } 315 | ], 316 | "sell":[ 317 | { 318 | "rate":7159.61768333, 319 | "quantity":0.75758168 320 | }, 321 | { 322 | "rate":7159.62768333, 323 | "quantity":0.00350054 324 | }, 325 | { 326 | "rate":7162.99999999, 327 | "quantity":0.1648124 328 | }, 329 | { 330 | "rate":7167.99999999, 331 | "quantity":0.59600039 332 | }, 333 | { 334 | "rate":7169.99999999, 335 | "quantity":0.5333059 336 | } 337 | ] 338 | } 339 | } 340 | ``` 341 | 342 | ### orderBookUpdate 343 | 344 | This event only contains the changes which should be applied to order book 345 | 346 | ``` 347 | { 348 | "pair":"USDT-BTC", 349 | "cseq":85719, 350 | "data":{ 351 | "buy":[ 352 | { 353 | "action":"update", 354 | "rate":7131, 355 | "quantity":0.72188827 356 | } 357 | ], 358 | "sell":[ 359 | { 360 | "action":"remove", 361 | "rate":7221.71517258, 362 | "quantity":0 363 | }, 364 | { 365 | "action":"update", 366 | "rate":7226.99999999, 367 | "quantity":0.61909178 368 | }, 369 | { 370 | "action":"update", 371 | "rate":7265.72525, 372 | "quantity":0.00709438 373 | } 374 | ] 375 | } 376 | } 377 | ``` 378 | 379 | NB : by default _action_ will be set to _update_ for both new entries and updated entries. If you need to distinguish new entries from updated entries, set _markNewOrderBookEntriesAsUpdates_ option to _false_ in constructor 380 | 381 | ### queryExchangeStateError 382 | 383 | Event will be triggered in case an error is trigger after calling *QueryExchangeState* (called when resyncing order books). This usually indicate that a pair is invalid but some users reported getting this error with existing / working pairs 384 | 385 | Since subscription will be automatically deleted when an error is triggered after calling *QueryExchangeState*, this event can be used to re-subscribe to the order book 386 | 387 | Payload depends on the type of error 388 | 389 | * if *Bittrex* returned *nil* data 390 | ``` 391 | { 392 | "pair":"USDT-BTC", 393 | "type":"nil_data", 394 | } 395 | ``` 396 | 397 | * if *Bittrex* returned an error 398 | ``` 399 | { 400 | "pair":"USDT-BTC", 401 | "type":"error", 402 | "error":"There was an error invoking Hub method 'c2.queryexchangestate" 403 | } 404 | ``` 405 | 406 | ### trades 407 | 408 | ``` 409 | { 410 | "pair":"USDT-BTC", 411 | "data":[ 412 | { 413 | "id":23090089, 414 | "quantity":0.0288771, 415 | "rate":7149.99999999, 416 | "price":206.47126499, 417 | "orderType":"buy", 418 | "timestamp":1509986924.897 419 | }, 420 | { 421 | "id":23090087, 422 | "quantity":0.00460101, 423 | "rate":7149.99999999, 424 | "price":32.89722149, 425 | "orderType":"buy", 426 | "timestamp":1509986924.553 427 | } 428 | ] 429 | } 430 | ``` 431 | 432 | ### order 433 | 434 | _orderState_ can be one of the following : 435 | 436 | * _OPEN_ : order has been created 437 | * _PARTIAL_ : order has been partially filled and is still open 438 | * _CANCEL_ : order has been cancelled before being filled (ie: _quantity_ = _remainingQuantity_) 439 | * _FILL_ : order is closed (it can be filled partially or completely) 440 | 441 | #### event when an order has been created 442 | 443 | ``` 444 | { 445 | "pair":"BTC-PTC", 446 | "orderNumber":"77c8f585-6d0c-4d5f-a5b0-a3c5abee504e", 447 | "data":{ 448 | "pair":"BTC-PTC", 449 | "orderNumber":"77c8f585-6d0c-4d5f-a5b0-a3c5abee504e", 450 | "orderState":"OPEN", 451 | "orderType":"LIMIT_SELL", 452 | "quantity":1000, 453 | "remainingQuantity":1000, 454 | "openTimestamp":1522765015.517, 455 | "targetRate":0.00000406, 456 | "targetPrice":0.00406 457 | } 458 | } 459 | ``` 460 | 461 | #### event when an order has been partially filled 462 | 463 | An order will be in state _PARTIAL_ until it is closed in following cases : 464 | 465 | * order is completely filled 466 | * order is cancelled 467 | 468 | ``` 469 | { 470 | "pair":"BTC-PTC", 471 | "orderNumber":"77c8f585-6d0c-4d5f-a5b0-a3c5abee504e", 472 | "data":{ 473 | "pair":"BTC-PTC", 474 | "orderNumber":"77c8f585-6d0c-4d5f-a5b0-a3c5abee504e", 475 | "orderState":"PARTIAL", 476 | "orderType":"LIMIT_SELL", 477 | "quantity":1000, 478 | "remainingQuantity":29.71930944, 479 | "openTimestamp":1522765015.517, 480 | "targetRate":0.00000406, 481 | "targetPrice":0.00406 482 | } 483 | } 484 | ``` 485 | 486 | #### event when an order has been cancelled 487 | 488 | An order will only be in _CANCEL_ state if it was *not filled at all*. In such case : 489 | 490 | * _quantity_ = _remainingQuantity_ 491 | * _actualPrice_ = 0 492 | * _fees_ = 0 493 | * _actualRate_ = _null_ 494 | 495 | ``` 496 | { 497 | "pair":"BTC-PTC", 498 | "orderNumber":"0c047f50-de8d-4c9d-841e-88c67bfbc28d", 499 | "data":{ 500 | "pair":"BTC-PTC", 501 | "orderNumber":"0c047f50-de8d-4c9d-841e-88c67bfbc28d", 502 | "orderState":"CANCEL", 503 | "orderType":"LIMIT_SELL", 504 | "quantity":1000, 505 | "remainingQuantity":1000, 506 | "openTimestamp":1522762678.567, 507 | "targetRate":0.000008, 508 | "targetPrice":0.008, 509 | "closedTimestamp":1522762698.473, 510 | "actualPrice":0, 511 | "fees":0, 512 | "actualRate":null 513 | } 514 | } 515 | ``` 516 | 517 | #### event when an order has been closed after being filled 518 | 519 | If an order has been filled (partially or completely), it will be in _FILL_ state. If order has been cancelled while being partially completed _remainingQuantity_ will be _!= 0_ 520 | 521 | ``` 522 | { 523 | "pair":"BTC-PTC", 524 | "orderNumber":"77c8f585-6d0c-4d5f-a5b0-a3c5abee504e", 525 | "data":{ 526 | "pair":"BTC-PTC", 527 | "orderNumber":"77c8f585-6d0c-4d5f-a5b0-a3c5abee504e", 528 | "orderState":"FILL", 529 | "orderType":"LIMIT_SELL", 530 | "quantity":1000, 531 | "remainingQuantity":29.71930944, 532 | "openTimestamp":1522765015.517, 533 | "targetRate":0.00000406, 534 | "targetPrice":0.00406, 535 | "closedTimestamp":1522765092.253, 536 | "actualPrice":0.00393933, 537 | "fees":0.00000984, 538 | "actualRate":0.00000405 539 | } 540 | }``` 541 | -------------------------------------------------------------------------------- /lib/connection.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const WebSocket = require('ws'); 3 | const request = require('request'); 4 | const util = require('util'); 5 | const retry = require('retry'); 6 | const querystring = require('querystring'); 7 | const debug = require('debug')('BittrexSignalRClient:Connection'); 8 | const cloudScraper = require('cloudscraper'); 9 | const _ = require('lodash'); 10 | const EventEmitter = require('events'); 11 | 12 | //-- Bittrex endpoints 13 | // base url 14 | const BASE_PATH = 'bittrex.com/signalr'; 15 | const CLOUD_SCRAPER_URL = 'https://bittrex.com/'; 16 | 17 | // connection configuration 18 | const DEFAULT_SOCKETTIMEOUT = 60 * 1000; 19 | // in case connection fails, how long should we wait before retrying ? 20 | const RETRY_DELAY = 10 * 1000; 21 | const RETRY_COUNT = { 22 | // retry 11 times (this means that WS will try to connect for a maximum of 120s) 23 | negotiate:11, 24 | // only retry once for connect 25 | connect:1, 26 | // only retry once for start 27 | start:1 28 | } 29 | 30 | // SignalR connection states 31 | const STATE_NEW = 0; 32 | const STATE_CONNECTING = 1; 33 | const STATE_CONNECTED = 2; 34 | const STATE_DISCONNECTING = 3; 35 | const STATE_DISCONNECTED = 4; 36 | 37 | // whether or not we want to perform 'start' step (does not seem to be mandatory with Bittrex) 38 | const IGNORE_START_STEP = false; 39 | 40 | //-- SignalR config 41 | // hub 42 | const HUB = 'c2'; 43 | const CLIENT_PROTOCOL_VERSION = '1.5'; 44 | 45 | // Extra headers used to bypass CloudFare 46 | const DEFAULT_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1'; 47 | const CLOUD_SCRAPER_HTTP_HEADERS = { 48 | 'Referer': 'https://google.com', 49 | 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8', 50 | 'Accept-Encoding': 'gzip, deflate, br', 51 | 'Accept-Language': 'en-US,en;q=0.9', 52 | 'Cache-Control': 'no-cache', 53 | 'Pragma': 'no-cache', 54 | 'Upgrade-Insecure-Requests': '1' 55 | } 56 | 57 | /* 58 | 59 | ConnectionTimeout : this setting represents the amount of time to leave a transport connection open and waiting for a response before closing it and 60 | opening a new connection. The default value is 110 seconds. This setting applies only when keepalive functionality is disabled, which normally applies 61 | only to the long polling transport 62 | 63 | DisconnectTimeout : the amount of time in seconds within which the client should try to reconnect if the connection goes away. 64 | 65 | KeepAliveTimeout : the amount of time in seconds the client should wait before attempting to reconnect if it has not received a keep alive message. 66 | If the server is configured to not send keep alive messages this value is null. 67 | 68 | TransportConnectTimeout : the maximum amount of time the client should try to connect to the server using a given transport 69 | 70 | */ 71 | 72 | /* 73 | Following events are emitted : 74 | 75 | 1) message, when a message is received 76 | 77 | Data will contain the message received 78 | 79 | 2) connectionError, when a connection error occurs 80 | 81 | Data will be an object {step:string,attempts:integer,retry:boolean,error:err} 82 | 83 | - step : connection step (negotiate|start|connect) 84 | - attempts : number of attempts to connect 85 | - retry : whether or not there are retry left 86 | - error : the connection error which occurred 87 | 88 | 3) disconnected, when websocket connection has been disconnected 89 | 90 | This is a final event. Websocket won't be reconnected. A new SignalRConnection object should be used 91 | Event will not be emitted in case connection is disconnected by client or on connection failure 92 | Event will not be emitted for connection/reconnection error (event connectionError will be emitted instead) 93 | 94 | Data will be an object {connectionId:string, code:integer,reason:string} 95 | 96 | 4) connected, when connection is ready to receive message 97 | 98 | Event will only be emitted once in the lifetime of the object 99 | 100 | Data will be an object {connectionId:string} 101 | 102 | */ 103 | class SignalRConnection extends EventEmitter 104 | { 105 | 106 | constructor(options) 107 | { 108 | super(); 109 | // by default use Cloud Scraper 110 | this._useCloudScraper = true; 111 | this._retryCount = { 112 | negotiate:RETRY_COUNT.negotiate, 113 | connect:RETRY_COUNT.connect, 114 | start:RETRY_COUNT.start 115 | } 116 | this._retryDelay = RETRY_DELAY; 117 | this._ignoreStartStep = IGNORE_START_STEP; 118 | // how long should we wait for a ping response ? (set to 0 to disable) 119 | this._pingTimeout = 30000; 120 | this._userAgent = DEFAULT_USER_AGENT; 121 | if (undefined !== options) 122 | { 123 | // Cloud Scraper 124 | if (false === options.useCloudScraper) 125 | { 126 | this._useCloudScraper = false; 127 | } 128 | // retry count 129 | if (undefined !== options.retryCount) 130 | { 131 | if (undefined !== options.retryCount.negotiate) 132 | { 133 | this._retryCount.negotiate = options.retryCount.negotiate; 134 | } 135 | if (undefined !== options.retryCount.connect) 136 | { 137 | this._retryCount.connect = options.retryCount.connect; 138 | } 139 | if (undefined !== options.retryCount.start) 140 | { 141 | this._retryCount.start = options.retryCount.start; 142 | } 143 | } 144 | if (undefined !== options.retryDelay) 145 | { 146 | this._retryDelay = options.retryDelay; 147 | } 148 | if (undefined !== options.ignoreStartStep) 149 | { 150 | this._ignoreStartStep = options.ignoreStartStep; 151 | } 152 | if (undefined != options.userAgent && '' != options.userAgent) 153 | { 154 | this._userAgent = options.userAgent; 155 | } 156 | if (undefined !== options.pingTimeout) 157 | { 158 | this._pingTimeout = options.pingTimeout; 159 | } 160 | } 161 | 162 | // cloud scraper parameters 163 | this._cloudScraper = { 164 | cookie:'', 165 | } 166 | 167 | this._timestamps = { 168 | negotiate:null, 169 | connect:null, 170 | start:null 171 | } 172 | this._ignoreCloseEvent = true; 173 | this._connectionState = STATE_NEW; 174 | this._connection = null, 175 | this._ws = null; 176 | this._lastMessageId = 1; 177 | this._callbacks = {}; 178 | 179 | // for debugging purpose 180 | this._logAllWsMessages = false; 181 | this._logKeepaliveMessages = false; 182 | } 183 | 184 | /** 185 | * Enable / disable logging of all received WS messages 186 | * 187 | * @param {boolean} true to enable, false to disable 188 | */ 189 | logAllWsMessages(flag) 190 | { 191 | this._logAllWsMessages = flag; 192 | } 193 | 194 | /** 195 | * Enable / disable logging of keepalive messages (received 'ping' & received 'pong') 196 | * 197 | * @param {boolean} true to enable, false to disable 198 | */ 199 | logKeepaliveMessages(flag) 200 | { 201 | this._logKeepaliveMessages = flag; 202 | } 203 | 204 | /** 205 | * Indicates whether or not we're ready to process messages from server 206 | */ 207 | isConnected() 208 | { 209 | return STATE_CONNECTED == this._connectionState; 210 | } 211 | 212 | /** 213 | * Indicates whether or not we're waiting for connection to be established 214 | */ 215 | isConnecting() 216 | { 217 | return STATE_CONNECTING == this._connectionState; 218 | } 219 | 220 | /** 221 | * Indicates whether or not connection is new (ie: can be connected) 222 | */ 223 | isNew() 224 | { 225 | return STATE_NEW == this._connectionState; 226 | } 227 | 228 | /** 229 | * Indicates whether or no connection is disconnected (ie: cannot be used anymore) 230 | */ 231 | isDisconnected() 232 | { 233 | return STATE_DISCONNECTED == this._connectionState; 234 | } 235 | 236 | /** 237 | * Returns an https url 238 | * 239 | * @param {path} url path 240 | * @return {string} 241 | */ 242 | _getHttpsUrl(path) 243 | { 244 | return `https://${BASE_PATH}/${path}`; 245 | } 246 | 247 | /** 248 | * Returns a wss url 249 | * 250 | * @param {path} url path 251 | * @return {string} 252 | */ 253 | _getWssUrl(path) 254 | { 255 | return `wss://${BASE_PATH}/${path}`; 256 | } 257 | 258 | /** 259 | * Returns SignalR hubs 260 | * 261 | * @return {array} 262 | */ 263 | _getHubs() 264 | { 265 | return [{name:HUB}]; 266 | } 267 | 268 | /** 269 | * Return hub name 270 | * 271 | * @return {string} 272 | */ 273 | _getHubName() 274 | { 275 | return HUB; 276 | } 277 | 278 | /** 279 | * SignalR 'negotiate' step 280 | */ 281 | _negotiate(retryCount) 282 | { 283 | let attempt = 1; 284 | try 285 | { 286 | let retryOptions = { 287 | minTimeout:this._retryDelay, 288 | factor:1, 289 | randomize:false 290 | }; 291 | if (-1 === retryCount) 292 | { 293 | retryOptions.forever = true; 294 | } 295 | else 296 | { 297 | retryOptions.retries = retryCount; 298 | } 299 | let operation = retry.operation(retryOptions); 300 | let headers = { 301 | 'User-Agent': this._userAgent 302 | } 303 | // use data retrieved by cloud scraper if available 304 | if ('' != this._cloudScraper.cookie) 305 | { 306 | headers['cookie'] = this._cloudScraper.cookie; 307 | } 308 | let requestOptions = { 309 | timeout:DEFAULT_SOCKETTIMEOUT, 310 | method:'GET', 311 | headers: headers, 312 | json:true, 313 | url:this._getHttpsUrl('negotiate'), 314 | qs:{ 315 | clientProtocol:CLIENT_PROTOCOL_VERSION, 316 | transport:'serverSentEvents', 317 | connectionData:JSON.stringify(this._getHubs()) 318 | } 319 | } 320 | let self = this; 321 | return new Promise((resolve, reject) => { 322 | operation.attempt(function(currentAttempt){ 323 | if (STATE_CONNECTING != self._connectionState) 324 | { 325 | resolve({ignore:true}); 326 | return; 327 | } 328 | attempt = currentAttempt; 329 | request(requestOptions, function(error, response, body){ 330 | if (STATE_CONNECTING != self._connectionState) 331 | { 332 | resolve({ignore:true}); 333 | return; 334 | } 335 | let err = null; 336 | let doRetry = true; 337 | if (null !== error) 338 | { 339 | err = {origin:'client', error:error.message}; 340 | } 341 | else if (200 != response.statusCode) 342 | { 343 | err = {origin:'remote', error:{code:response.statusCode,message:response.statusMessage}} 344 | } 345 | if (null !== err) 346 | { 347 | if (debug.enabled) 348 | { 349 | debug("'negotiate' step error (%d/%s) : %s", attempt, -1 === retryCount ? 'unlimited' : (1 + retryCount), JSON.stringify(err)); 350 | } 351 | if (doRetry && operation.retry(err)) 352 | { 353 | self.emit('connectionError', {step:'negotiate',attempts:attempt,retry:true,error:err}); 354 | return; 355 | } 356 | reject({attempts:attempt, error:err}); 357 | return; 358 | } 359 | self._connection = body; 360 | self._timestamps.negotiate = new Date().getTime(); 361 | resolve({connectionId:body.ConnectionId,attempts:currentAttempt}); 362 | }); 363 | }); 364 | }); 365 | } 366 | catch (e) 367 | { 368 | if (debug.enabled) 369 | { 370 | debug("'negotiate' step exception : %s", e.stack); 371 | } 372 | return new Promise((resolve, reject) => { 373 | let err = {origin:'client', error:e.message, stack:e.stack}; 374 | reject({attempts:attempt, error:err}); 375 | }); 376 | } 377 | } 378 | 379 | _getConnectQueryString(connection) 380 | { 381 | let qs = { 382 | clientProtocol: connection.ProtocolVersion, 383 | transport: "webSockets", 384 | connectionToken: connection.ConnectionToken, 385 | connectionData: JSON.stringify(this._getHubs()), 386 | tid: parseInt(new Date().getTime()) 387 | }; 388 | return `${this._getWssUrl('connect')}?${querystring.stringify(qs)}`; 389 | } 390 | 391 | /** 392 | * SignalR 'connect' step 393 | */ 394 | _connect(connection, retryCount) 395 | { 396 | let attempt = 1; 397 | try 398 | { 399 | let self = this; 400 | let retryOptions = { 401 | minTimeout:this._retryDelay, 402 | factor:1, 403 | randomize:false 404 | }; 405 | if (-1 === retryCount) 406 | { 407 | retryOptions.forever = true; 408 | } 409 | else 410 | { 411 | retryOptions.retries = retryCount; 412 | } 413 | let headers = { 414 | 'User-Agent': this._userAgent 415 | } 416 | // use data retrieved by cloud scraper if available 417 | if ('' != this._cloudScraper.cookie) 418 | { 419 | headers['cookie'] = this._cloudScraper.cookie; 420 | } 421 | let wsOptions = { 422 | perMessageDeflate: false, 423 | handshakeTimeout:connection.TransportConnectTimeout * 2000, 424 | headers: headers 425 | } 426 | let operation = retry.operation(retryOptions); 427 | let queryString = this._getConnectQueryString(connection); 428 | return new Promise((resolve, reject) => { 429 | operation.attempt(function(currentAttempt){ 430 | if (STATE_CONNECTING != self._connectionState) 431 | { 432 | resolve({ignore:true}); 433 | return; 434 | } 435 | attempt = currentAttempt; 436 | let doRetry = true; 437 | let ws = new WebSocket(queryString, wsOptions); 438 | let ignoreErrorEvent = false; 439 | let skipCloseEvent = false; 440 | ws.on('open', function open() { 441 | // connection has already been disconnected 442 | if (STATE_CONNECTING != self._connectionState) 443 | { 444 | resolve({ignore:true}); 445 | return; 446 | } 447 | if (debug.enabled) 448 | { 449 | debug("WS connected for connection '%s'", connection.ConnectionId); 450 | } 451 | self._timestamps.connect = new Date().getTime(); 452 | self._ignoreCloseEvent = false; 453 | skipCloseEvent = false; 454 | self._ws = this; 455 | // start ping/pong 456 | if (0 != self._pingTimeout) 457 | { 458 | let _ws = this; 459 | _ws.isAlive = false; 460 | // initial ping 461 | _ws.ping('', true, true); 462 | let interval = setInterval(function() { 463 | if (WebSocket.OPEN != _ws.readyState) 464 | { 465 | clearTimeout(interval); 466 | return; 467 | } 468 | if (!_ws.isAlive) 469 | { 470 | if (debug.enabled) 471 | { 472 | debug("WS timeout for connection '%s' : timeout = '%d'", connection.ConnectionId, self._pingTimeout); 473 | } 474 | _ws.terminate(); 475 | clearTimeout(interval); 476 | return; 477 | } 478 | _ws.isAlive = false; 479 | _ws.ping('', true, true); 480 | }, self._pingTimeout); 481 | } 482 | resolve({attempts:attempt}); 483 | }); 484 | ws.on('message', function (message) { 485 | // this is for debugging purpose 486 | if (self._logAllWsMessages && debug.enabled) 487 | { 488 | debug(message); 489 | } 490 | // discard messages if we're not in ready state 491 | if (STATE_CONNECTED != self._connectionState) 492 | { 493 | return; 494 | } 495 | // ignore empty data 496 | if ('{}' === message) 497 | { 498 | return; 499 | } 500 | let data; 501 | try 502 | { 503 | data = JSON.parse(message); 504 | } 505 | catch (e) 506 | { 507 | if (debug.enabled) 508 | { 509 | debug("Received invalid JSON message : %s", message); 510 | } 511 | return; 512 | } 513 | // process responses 514 | if (undefined !== data.I) 515 | { 516 | let messageId = parseInt(data.I); 517 | // ignore progress 518 | if (undefined !== data.D) 519 | { 520 | return; 521 | } 522 | // process result 523 | if (undefined !== data.R) 524 | { 525 | // do we have a callback for this messageId 526 | if (undefined !== self._callbacks[messageId]) 527 | { 528 | self._callbacks[messageId](data.R, null); 529 | delete self._callbacks[messageId]; 530 | } 531 | } 532 | // probably an error 533 | else 534 | { 535 | let err = ''; 536 | // process error 537 | if (undefined !== data.E) 538 | { 539 | err = data.E; 540 | } 541 | if (debug.enabled) 542 | { 543 | debug("Got an error for message %d : err = '%s'", messageId, err); 544 | } 545 | // do we have a callback for this messageId 546 | if (undefined !== self._callbacks[messageId]) 547 | { 548 | self._callbacks[messageId](null, err); 549 | delete self._callbacks[messageId]; 550 | } 551 | } 552 | return; 553 | } 554 | if (undefined !== data.M) 555 | { 556 | _.forEach(data.M, (entry) => { 557 | self.emit('data', entry); 558 | }); 559 | } 560 | }); 561 | ws.on('error', function(e) { 562 | if (ignoreErrorEvent) 563 | { 564 | return; 565 | } 566 | // connection has already been disconnected 567 | if (STATE_CONNECTING != self._connectionState) 568 | { 569 | resolve({ignore:true}); 570 | return; 571 | } 572 | let err = {origin:'client', error:{code:e.code,message:e.message}} 573 | if (debug.enabled) 574 | { 575 | debug("'connect' step error for connection '%s' (%d/%s) : %s", connection.ConnectionId, attempt, -1 === retryCount ? 'unlimited' : (1 + retryCount), JSON.stringify(err)); 576 | } 577 | skipCloseEvent = true; 578 | self._ws = null; 579 | this.terminate(); 580 | // ws is not open yet, likely to be a connection error 581 | if (null === self._timestamps.connect) 582 | { 583 | if (doRetry && operation.retry(err)) 584 | { 585 | self.emit('connectionError', {step:'connect',attempts:attempt,retry:true,error:err}); 586 | return; 587 | } 588 | } 589 | reject({attempts:attempt, error:err}); 590 | }); 591 | // likely to be an auth error 592 | ws.on('unexpected-response', function(request, response){ 593 | // connection has already been disconnected 594 | if (STATE_CONNECTING != self._connectionState) 595 | { 596 | resolve({ignore:true}); 597 | return; 598 | } 599 | let err = {origin:'remote', error:{code:response.statusCode,message:response.statusMessage}} 600 | if (debug.enabled) 601 | { 602 | debug("'connect' step unexpected-response for connection '%s' (%d/%s) : %s", connection.ConnectionId, attempt, -1 === retryCount ? 'unlimited' : (1 + retryCount), JSON.stringify(err)); 603 | } 604 | ignoreErrorEvent = true; 605 | skipCloseEvent = true; 606 | self._ws = null; 607 | if (doRetry && operation.retry(err)) 608 | { 609 | self.emit('connectionError', {step:'connect',attempts:attempt,retry:true,error:err}); 610 | return; 611 | } 612 | reject({attempts:attempt, error:err}); 613 | }); 614 | ws.on('close', function(code, reason){ 615 | if (self._ignoreCloseEvent) 616 | { 617 | return; 618 | } 619 | // connection has already been disconnected 620 | if (STATE_CONNECTING != self._connectionState && STATE_CONNECTED != self._connectionState) 621 | { 622 | return; 623 | } 624 | if (debug.enabled) 625 | { 626 | debug("WS closed for connection '%s' : code = '%d', reason = '%s'", connection.ConnectionId, code, reason); 627 | } 628 | self._ws = null; 629 | self._finalize(true, STATE_DISCONNECTED); 630 | if (!skipCloseEvent) 631 | { 632 | self.emit('disconnected', {connectionId:connection.ConnectionId, code:code, reason:reason}); 633 | } 634 | }); 635 | // reply to ping 636 | ws.on('ping', function(data){ 637 | // this is for debugging purpose 638 | if (self._logKeepaliveMessages && debug.enabled) 639 | { 640 | debug(`Got 'ping' message from Bittrex at ${parseInt(Date.now() / 1000.0)}`); 641 | } 642 | this.pong('', true, true); 643 | }); 644 | ws.on('pong', function(data){ 645 | // this is for debugging purpose 646 | if (self._logKeepaliveMessages && debug.enabled) 647 | { 648 | debug(`Got 'pong' message from Bittrex at ${parseInt(Date.now() / 1000.0)}`); 649 | } 650 | this.isAlive = true; 651 | }); 652 | }); 653 | }); 654 | } 655 | catch (e) 656 | { 657 | if (debug.enabled) 658 | { 659 | debug("'connect' step exception : %s", e.stack); 660 | } 661 | return new Promise((resolve, reject) => { 662 | let err = {origin:'client', error:e.message, stack:e.stack}; 663 | reject({attempts:attempt, error:err}); 664 | }); 665 | } 666 | } 667 | 668 | /** 669 | * SignalR 'start' step 670 | */ 671 | _start(connection, retryCount) 672 | { 673 | let attempt = 1; 674 | try 675 | { 676 | // connection has already been disconnected 677 | if (STATE_CONNECTING != this._connectionState) 678 | { 679 | return new Promise((resolve, reject) => { 680 | resolve({ignore:true}); 681 | }); 682 | } 683 | // don't perform start step 684 | if (this._ignoreStartStep) 685 | { 686 | return new Promise((resolve, reject) => { 687 | resolve({}); 688 | }); 689 | } 690 | let retryOptions = { 691 | minTimeout:this._retryDelay, 692 | factor:1, 693 | randomize:false 694 | }; 695 | if (-1 === retryCount) 696 | { 697 | retryOptions.forever = true; 698 | } 699 | else 700 | { 701 | retryOptions.retries = retryCount; 702 | } 703 | let operation = retry.operation(retryOptions); 704 | let headers = { 705 | 'User-Agent': this._userAgent 706 | } 707 | // use data retrieved by cloud scraper if available 708 | if ('' != this._cloudScraper.cookie) 709 | { 710 | headers['cookie'] = this._cloudScraper.cookie; 711 | } 712 | let requestOptions = { 713 | timeout:DEFAULT_SOCKETTIMEOUT, 714 | method:'GET', 715 | headers: headers, 716 | json:true, 717 | url:this._getHttpsUrl('start'), 718 | qs:{ 719 | clientProtocol:CLIENT_PROTOCOL_VERSION, 720 | //transport:'serverSentEvents', 721 | // On 19/11/2017 Bittrex did some changes to transport 722 | transport:'webSockets', 723 | connectionToken:connection.ConnectionToken, 724 | connectionData:JSON.stringify(this._getHubs()) 725 | } 726 | } 727 | let self = this; 728 | return new Promise((resolve, reject) => { 729 | operation.attempt(function(currentAttempt){ 730 | // connection has already been disconnected 731 | if (STATE_CONNECTING != self._connectionState) 732 | { 733 | resolve({ignore:true}); 734 | return; 735 | } 736 | attempt = currentAttempt; 737 | request(requestOptions, function(error, response, body){ 738 | // connection has already been disconnected 739 | if (STATE_CONNECTING != self._connectionState) 740 | { 741 | resolve({ignore:true}); 742 | return; 743 | } 744 | let err = null; 745 | let doRetry = true; 746 | if (null !== error) 747 | { 748 | err = {origin:'client', error:error.message}; 749 | } 750 | else if (200 != response.statusCode) 751 | { 752 | err = {origin:'remote', error:{code:response.statusCode,message:response.statusMessage}} 753 | } 754 | if (null !== err) 755 | { 756 | if (debug.enabled) 757 | { 758 | debug("'start' step error for connection '%s' (%d/%s) : %s", connection.ConnectionId, attempt, -1 === retryCount ? 'unlimited' : (1 + retryCount), JSON.stringify(err)); 759 | } 760 | if (doRetry && operation.retry(err)) 761 | { 762 | self.emit('connectionError', {step:'start',attempts:attempt,retry:true,error:err}); 763 | return; 764 | } 765 | reject({attempts:attempt, error:err}); 766 | return; 767 | } 768 | self._timestamps.start = new Date().getTime(); 769 | resolve({attempts:attempt}); 770 | }); 771 | }); 772 | }); 773 | } 774 | catch (e) 775 | { 776 | if (debug.enabled) 777 | { 778 | debug("'start' step exception : %s", e.stack); 779 | } 780 | return new Promise((resolve, reject) => { 781 | let err = {origin:'client', error:e.message, stack:e.stack}; 782 | reject({attempts:attempt, error:err}); 783 | }); 784 | } 785 | } 786 | 787 | /** 788 | * SignalR 'abort' step 789 | */ 790 | _abort(connection) 791 | { 792 | try 793 | { 794 | // do nothing if start was not sent 795 | if (null === this._timestamps.start) 796 | { 797 | return; 798 | } 799 | let headers = { 800 | 'User-Agent': this._userAgent 801 | } 802 | // use data retrieved by cloud scraper if available 803 | if ('' != this._cloudScraper.cookie) 804 | { 805 | headers['cookie'] = this._cloudScraper.cookie; 806 | } 807 | let requestOptions = { 808 | timeout:DEFAULT_SOCKETTIMEOUT, 809 | method:'GET', 810 | headers: headers, 811 | json:true, 812 | url:this._getHttpsUrl('abort'), 813 | qs:{ 814 | clientProtocol:CLIENT_PROTOCOL_VERSION, 815 | transport:'webSockets', 816 | connectionToken:connection.ConnectionToken, 817 | connectionData:JSON.stringify(this._getHubs()) 818 | } 819 | } 820 | request(requestOptions, function(error, response, body){ 821 | let err = null; 822 | if (null !== error) 823 | { 824 | err = {origin:'client', error:error.message}; 825 | } 826 | else if (200 != response.statusCode) 827 | { 828 | err = {origin:'remote', error:{code:response.statusCode,message:response.statusMessage}} 829 | } 830 | if (null !== err) 831 | { 832 | if (debug.enabled) 833 | { 834 | debug("'abort' step error : %s", JSON.stringify(err)); 835 | } 836 | } 837 | else 838 | { 839 | if (debug.enabled) 840 | { 841 | debug("'abort' step successfully performed for connection '%s'", connection.ConnectionId); 842 | } 843 | } 844 | }); 845 | } 846 | catch (e) 847 | { 848 | if (debug.enabled) 849 | { 850 | debug("'abort' step exception : %s", e.stack); 851 | } 852 | } 853 | } 854 | 855 | /** 856 | * Used to do a bit of cleaning (close ws, abort ...) 857 | * 858 | * @param {boolean} indicates whether or not WS should be terminated vs closed (default = false) 859 | */ 860 | _finalize(terminate, newState) 861 | { 862 | // abort connection 863 | if (null !== this._connection) 864 | { 865 | let connection = this._connection; 866 | this._connection = null; 867 | this._abort(connection); 868 | } 869 | // close ws 870 | if (null !== this._ws) 871 | { 872 | let ws = this._ws; 873 | this._ws = null; 874 | this._ignoreCloseEvent = true; 875 | try 876 | { 877 | if (terminate) 878 | { 879 | ws.terminate(); 880 | } 881 | else 882 | { 883 | ws.close(); 884 | } 885 | } 886 | catch (e) 887 | { 888 | // do nothing 889 | } 890 | } 891 | this._connectionState = newState; 892 | } 893 | 894 | disconnect() 895 | { 896 | if (STATE_DISCONNECTED == this._connectionState || STATE_DISCONNECTING == this._connectionState) 897 | { 898 | return; 899 | } 900 | this._connectionState = STATE_DISCONNECTING; 901 | this._finalize(false, STATE_DISCONNECTED); 902 | return; 903 | } 904 | 905 | callMethod(method, args, cb) 906 | { 907 | if (STATE_CONNECTED != this._connectionState || WebSocket.OPEN != this._ws.readyState) 908 | { 909 | return false; 910 | } 911 | if (undefined !== cb) 912 | { 913 | this._callbacks[this._lastMessageId] = cb; 914 | } 915 | try 916 | { 917 | let methodName = method.toLowerCase(); 918 | let data = { 919 | H:this._getHubName(), 920 | M:methodName, 921 | A:undefined === args ? [] : args, 922 | I:this._lastMessageId++ 923 | } 924 | let payload = JSON.stringify(data); 925 | this._ws.send(payload); 926 | } 927 | catch (e) 928 | { 929 | if (debug.enabled) 930 | { 931 | debug("Exception when trying to call method %s : %s", method, e.stack); 932 | } 933 | return false; 934 | } 935 | return true; 936 | } 937 | 938 | connect() 939 | { 940 | if (STATE_NEW != this._connectionState) 941 | { 942 | return false; 943 | } 944 | this._connectionState = STATE_CONNECTING; 945 | let self = this; 946 | this._cloudScraperRequest(function(err){ 947 | if (null !== err) 948 | { 949 | // emit connectionError 950 | self.emit('connectionError', {step:'negotiate',attempts:0,retry:false,error:err}); 951 | return; 952 | } 953 | // 'negotiate' step 954 | self._negotiate(self._retryCount.negotiate).then((result) => { 955 | if (result.ignore) 956 | { 957 | return; 958 | } 959 | if (debug.enabled) 960 | { 961 | debug("'negotiate' step successful after %d attempts : %s", result.attempts, JSON.stringify(self._connection)); 962 | } 963 | // 'connect' step 964 | self._connect(self._connection, self._retryCount.connect).then((result) => { 965 | if (result.ignore) 966 | { 967 | return; 968 | } 969 | if (debug.enabled) 970 | { 971 | debug("'connect' step successful after %d attempts", result.attempts); 972 | } 973 | // 'start' step 974 | self._start(self._connection, self._retryCount.start).then((result) => { 975 | if (result.ignore) 976 | { 977 | return; 978 | } 979 | self._connectionState = STATE_CONNECTED; 980 | if (self._ignoreStartStep) 981 | { 982 | self.emit('connected', {connectionId:self._connection.ConnectionId}); 983 | return; 984 | } 985 | // now we're ready to have fun 986 | if (debug.enabled) 987 | { 988 | debug("'start' step successful after %d attempts", result.attempts); 989 | } 990 | self.emit('connected', {connectionId:self._connection.ConnectionId}); 991 | }).catch ((e) => { 992 | if (debug.enabled) 993 | { 994 | debug("'start' step stopped after %d attempts : %s", e.attempts, JSON.stringify(e.error)); 995 | } 996 | self.emit('connectionError', {step:'start',attempts:e.attempts,retry:false,error:e.error}); 997 | }); 998 | }).catch ((e) => { 999 | if (debug.enabled) 1000 | { 1001 | debug("'connect' step stopped after %d attempts : %s", e.attempts, JSON.stringify(e.error)); 1002 | } 1003 | self.emit('connectionError', {step:'connect',attempts:e.attempts,retry:false,error:e.error}); 1004 | }); 1005 | }).catch ((e) => { 1006 | if (debug.enabled) 1007 | { 1008 | debug("'negotiate' step stopped after %d attempts : %s", e.attempts, JSON.stringify(e.error)); 1009 | } 1010 | self.emit('connectionError', {step:'negotiate',attempts:e.attempts,retry:false,error:e.error}); 1011 | }); 1012 | }); 1013 | return true; 1014 | } 1015 | 1016 | /** 1017 | * Cloud Scraper request 1018 | * 1019 | * @param {callback} cb callback to call after cloud scraper request (cb parameter will be null if cloudscraper request was successful) 1020 | */ 1021 | _cloudScraperRequest(cb) 1022 | { 1023 | // we're not supposed to use Cloud Scraper 1024 | if (!this._useCloudScraper) 1025 | { 1026 | cb(null); 1027 | return; 1028 | } 1029 | let self = this; 1030 | // cloud scraper magic 1031 | let headers = _.clone(CLOUD_SCRAPER_HTTP_HEADERS); 1032 | headers['User-Agent'] = this._userAgent; 1033 | cloudScraper.get(CLOUD_SCRAPER_URL, function(error, response, body) { 1034 | // we're fucked 1035 | if (error) 1036 | { 1037 | if (debug.enabled) 1038 | { 1039 | debug("'Cloud Scraper' error : %s", JSON.stringify(error)); 1040 | } 1041 | let err; 1042 | if (0 === error.errorType) 1043 | { 1044 | err = {origin:'client', error:error.error.message} 1045 | } 1046 | else 1047 | { 1048 | err = {origin:'remote', error:error} 1049 | } 1050 | cb(err); 1051 | return; 1052 | } 1053 | // save cloud scraper data 1054 | self._cloudScraper = { 1055 | cookie:response.request.headers['cookie'] || '', 1056 | } 1057 | cb(null); 1058 | }, headers); 1059 | } 1060 | 1061 | } 1062 | 1063 | module.exports = SignalRConnection; 1064 | -------------------------------------------------------------------------------- /lib/client.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const WebSocket = require('ws'); 3 | const _ = require('lodash'); 4 | const util = require('util'); 5 | const debug = require('debug')('BittrexSignalRClient:Client'); 6 | const Big = require('big.js'); 7 | const EventEmitter = require('events'); 8 | const zlib = require('zlib'); 9 | const crypto = require('crypto'); 10 | const SignalRConnection = require('./connection'); 11 | 12 | // how long should we wait before trying to reconnect upon disconnection 13 | const RETRY_DELAY = 10 * 1000; 14 | 15 | class SignalRClient extends EventEmitter 16 | { 17 | 18 | /* 19 | Following events related to connection can be emitted 20 | 21 | 1) connectionError, when a connection/reconnection error occurs 22 | 23 | Data will be an object {step:string,attempts:integer,error:err} 24 | 25 | - step : connection step (negotiate|start|connect) 26 | - attempts : number of attempts to connect 27 | - error : the connection error which occurred 28 | 29 | Reconnection will be automatic 30 | 31 | 2) disconnected, when WS has been disconnected by exchange 32 | 33 | Data will be an object {connectionId:string,code:integer,reason:string} 34 | 35 | - connectionId : id of SignelR connection 36 | - code: disconnection code 37 | - reason : disconnection reason 38 | 39 | Reconnection will be automatic 40 | 41 | 3) terminated, when connection failed after last connection retry 42 | 43 | This is a final event 44 | 45 | Data will be an object {step:string,attempts:integer,error:err} 46 | 47 | - step : connection step (negotiate|start|connect) 48 | - attempts : number of attempts to connect 49 | - error : the connection error which occurred 50 | 51 | 4) connected, when SignalRConnection connection is connected/reconnected 52 | 53 | Data will be an object {connectionId:string} 54 | 55 | - connectionId : id of SignalRConnection 56 | 57 | 5) timeout, when watchdog detected that Bittrex stopped sending data 58 | 59 | Data will be an object {connectionId:string,dataType:string,lastTimestamp:integer} 60 | 61 | - connectionId : id of SignalRConnection 62 | - dataType: tickers|markets 63 | - lastTimestamp: unix timestamp (in ms) when last data was received 64 | 65 | If watchdog was configured to reconnect automatically, no action should be taken by client 66 | 67 | 6) watchdog, will be emitted everytime watchdog checks for timeout 68 | 69 | Data will be an object {connectionId:string,dataType:string,lastTimestamp:integer} 70 | 71 | - connectionId : id of SignalRConnection 72 | - dataType: tickers|markets 73 | - lastTimestamp: unix timestamp (in ms) when last data was received 74 | 75 | Following exchange events can be emitted 76 | 77 | 1) ticker 78 | 79 | 2) orderBook 80 | 81 | 3) orderBookUpdate 82 | 83 | 4) trades 84 | */ 85 | 86 | constructor(options) 87 | { 88 | super(); 89 | 90 | // by default a new entry in the order book will be considered as an update 91 | this._markNewOrderBookEntriesAsUpdates = true; 92 | 93 | // whether or not library should exit in case an error occurred while calling QueryExchangeState 94 | this._exitOnQueryExchangeStateError = false; 95 | 96 | // Whether or not order books should be resynced automatically in case a missing Nounce is detected 97 | this._resyncOrderBooksAutomatically = true; 98 | 99 | this._auth = { 100 | key:null, 101 | secret:null 102 | } 103 | // subscriptions 104 | this._subscriptions = { 105 | tickers:{ 106 | // wether or not we subscribed to tickers globally 107 | global:false, 108 | timestamp:null, 109 | pairs:{} 110 | }, 111 | markets:{ 112 | timestamp:null, 113 | pairs:{} 114 | }, 115 | orders:{ 116 | timestamp:null, 117 | subscribed:false 118 | } 119 | }; 120 | 121 | this._unsubscribedMarkets = { 122 | pairs:{}, 123 | count:0 124 | }; 125 | 126 | // use to keep track of last trades id 127 | this._lastTrades = {}; 128 | 129 | this._reconnectAfterUnsubscribingFromMarkets = { 130 | // if true, SignalR connection will be reconnected upon unsubscribing from markets (to ensure we don't receive unwanted data) 131 | reconnect:true, 132 | // after how many unsubscriptions we want to reconnect 133 | after:1 134 | }; 135 | 136 | this._logger = null; 137 | 138 | // used to detect and Bittrex stops sending data over websocket 139 | this._watchdog = { 140 | // used to detect timeout on markets data 141 | markets:{ 142 | // defaults to 30 min 143 | timeout:1800000, 144 | // whether or not we should reconnect if data timeout occurs 145 | reconnect:true, 146 | lastTimestamp:0, 147 | timer:null 148 | }, 149 | // used to detect timeout on tickers data 150 | tickers:{ 151 | // defaults to 30 min 152 | timeout:1800000, 153 | // whether or not we should reconnect if data timeout occurs 154 | reconnect:true, 155 | lastTimestamp:0, 156 | timer:null 157 | }, 158 | // used to re-subscribe periodically 159 | orders:{ 160 | // defaults to 30 min 161 | period:1800000, 162 | // whether or not we should re-subscribe automatically 163 | enabled:true, 164 | lastTimestamp:0, 165 | timer:null 166 | } 167 | }; 168 | 169 | this._retryDelay = RETRY_DELAY; 170 | this._connectionOptions = {useCloudScraper:true}; 171 | if (undefined !== options) 172 | { 173 | // whether or not library should exit when an error occurs after calling QueryExchangeState 174 | if (true === options.exitOnQueryExchangeStateError) 175 | { 176 | this._exitOnQueryExchangeStateError = true; 177 | } 178 | if (false === options.resyncOrderBooksAutomatically) 179 | { 180 | this._resyncOrderBooksAutomatically = false; 181 | } 182 | // auth 183 | if (undefined !== options.auth) 184 | { 185 | if (undefined !== options.auth.key && '' != options.auth.key && 186 | undefined !== options.auth.secret && '' !== options.auth.secret) 187 | { 188 | this._auth.key = options.auth.key; 189 | this._auth.secret = options.auth.secret; 190 | } 191 | } 192 | if (false === options.useCloudScraper) 193 | { 194 | this._connectionOptions.useCloudScraper = false; 195 | } 196 | // data timeout 197 | if (undefined !== options.watchdog) 198 | { 199 | if (undefined !== options.watchdog.tickers) 200 | { 201 | if (undefined !== options.watchdog.tickers.timeout) 202 | { 203 | this._watchdog.tickers.timeout = options.watchdog.tickers.timeout; 204 | } 205 | if (undefined !== options.watchdog.tickers.reconnect && false === options.watchdog.tickers.reconnect) 206 | { 207 | this._watchdog.tickers.reconnect = false; 208 | } 209 | } 210 | if (undefined !== options.watchdog.markets) 211 | { 212 | if (undefined !== options.watchdog.markets.timeout) 213 | { 214 | this._watchdog.markets.timeout = options.watchdog.markets.timeout; 215 | } 216 | if (undefined !== options.watchdog.markets.reconnect && false === options.watchdog.markets.reconnect) 217 | { 218 | this._watchdog.markets.reconnect = false; 219 | } 220 | } 221 | if (undefined !== options.watchdog.orders) 222 | { 223 | if (undefined !== options.watchdog.orders.period) 224 | { 225 | this._watchdog.orders.period = options.watchdog.orders.period * 1000; 226 | } 227 | if (undefined !== options.watchdog.orders.enabled && false === options.watchdog.orders.enabled) 228 | { 229 | this._watchdog.orders.enabled = false; 230 | } 231 | } 232 | } 233 | // retry count 234 | if (undefined !== options.retryCount) 235 | { 236 | let retryCount = {}; 237 | _.forEach(['negotiate','connect','start'], (step) => { 238 | if (undefined !== options.retryCount[step]) 239 | { 240 | if ('always' == options.retryCount[step]) 241 | { 242 | retryCount[step] = -1; 243 | } 244 | else 245 | { 246 | let value = parseInt(options.retryCount[step]); 247 | if (isNaN(value) || value < 0) 248 | { 249 | throw new Error(`Argument 'options.retryCount.${step}' should be 'always' or an integer >= 0`); 250 | } 251 | else 252 | { 253 | retryCount[step] = value; 254 | } 255 | } 256 | } 257 | }); 258 | this._connectionOptions.retryCount = retryCount; 259 | } 260 | if (undefined !== options.retryDelay) 261 | { 262 | let value = parseInt(options.retryDelay); 263 | if (isNaN(value) || value <= 0) 264 | { 265 | throw new Error("Argument 'options.retryDelay' should be an integer > 0"); 266 | } 267 | this._retryDelay = value; 268 | this._connectionOptions.retryDelay = value; 269 | } 270 | if (undefined !== options.ignoreStartStep) 271 | { 272 | if (false !== options.ignoreStartStep && true !== options.ignoreStartStep) 273 | { 274 | throw new Error("Argument 'options.ignoreStartStep' should be a boolean"); 275 | } 276 | this._connectionOptions.ignoreStartStep = true === options.ignoreStartStep; 277 | } 278 | if (undefined != options.userAgent && '' != options.userAgent) 279 | { 280 | this._connectionOptions.userAgent = options.userAgent; 281 | } 282 | if (undefined !== options.pingTimeout) 283 | { 284 | let value = parseInt(options.pingTimeout); 285 | if (isNaN(value) || value < 0) 286 | { 287 | throw new Error("Argument 'options.pingTimeout' should be an integer >= 0"); 288 | } 289 | this._connectionOptions.pingTimeout = value; 290 | } 291 | if (undefined !== options.reconnectAfterUnsubscribingFromMarkets) 292 | { 293 | if (undefined !== options.reconnectAfterUnsubscribingFromMarkets) 294 | { 295 | if (false !== options.reconnectAfterUnsubscribingFromMarkets.reconnect && true !== options.reconnectAfterUnsubscribingFromMarkets.reconnect) 296 | { 297 | throw new Error("Argument 'options.reconnectAfterUnsubscribingFromMarkets.reconnect' should be a boolean"); 298 | } 299 | this._reconnectAfterUnsubscribingFromMarkets.reconnect = true === options.reconnectAfterUnsubscribingFromMarkets.reconnect; 300 | } 301 | if (this._reconnectAfterUnsubscribingFromMarkets.reconnect) 302 | { 303 | if (undefined !== options.reconnectAfterUnsubscribingFromMarkets.after) 304 | { 305 | let value = parseInt(options.reconnectAfterUnsubscribingFromMarkets.after); 306 | if (isNaN(value) || value < 1) 307 | { 308 | throw new Error("Argument 'options.reconnectAfterUnsubscribingFromMarkets.after' should be an integer >= 1"); 309 | } 310 | this._reconnectAfterUnsubscribingFromMarkets.after = value; 311 | } 312 | } 313 | } 314 | if (undefined !== options.logger) 315 | { 316 | this._logger = options.logger; 317 | } 318 | if (false === options.markNewOrderBookEntriesAsUpdates) 319 | { 320 | this._markNewOrderBookEntriesAsUpdates = false; 321 | } 322 | } 323 | 324 | // ensure we correctly parse datetime string 325 | this._utcOffset = 0; 326 | 327 | // keep track of how many connections we started 328 | this._connectionCounter = 0; 329 | this._connection = null; 330 | // SignalR connection id 331 | this._connectionId = null; 332 | // timestamp of last connected event 333 | this._connectedTimestamp = null; 334 | 335 | // for debugging purpose 336 | this._logAllWsMessages = false; 337 | this._logKeepaliveMessages = false; 338 | } 339 | 340 | /** 341 | * Enable / disable logging of all received WS messages 342 | * 343 | * @param {boolean} true to enable, false to disable 344 | */ 345 | logAllWsMessages(flag) 346 | { 347 | this._logAllWsMessages = flag; 348 | if (null !== this._connection) 349 | { 350 | this._connection.logAllWsMessages(flag); 351 | } 352 | } 353 | 354 | /** 355 | * Enable / disable logging of keepalive messages (received 'ping' & received 'pong') 356 | * 357 | * @param {boolean} true to enable, false to disable 358 | */ 359 | logKeepaliveMessages(flag) 360 | { 361 | this._logKeepaliveMessages = flag; 362 | if (null !== this._connection) 363 | { 364 | this._connection.logKeepaliveMessages(flag); 365 | } 366 | } 367 | 368 | // used to compute utc offset in seconds (negative offset means we're ahead of utc time) 369 | _computeUtcOffset() 370 | { 371 | this._utcOffset = new Date().getTimezoneOffset() * 60; 372 | } 373 | 374 | /** 375 | * Parses a datetime in UTC format (YYYY-mm-dd HH:MM:SS) 376 | * @param {date} 377 | * @return {integer} unix timestamp based on local timezone 378 | */ 379 | _parseUtcDateTime(dateTime) 380 | { 381 | return parseInt(Date.parse(dateTime) / 1000.0) - this._utcOffset; 382 | } 383 | 384 | _initializeLastTrade(pair) 385 | { 386 | if (undefined === this._lastTrades[pair]) 387 | { 388 | this._lastTrades[pair] = {id:0}; 389 | } 390 | } 391 | 392 | _resetLastTrade(pair) 393 | { 394 | delete this._lastTrades[pair]; 395 | } 396 | 397 | /* 398 | * The result of being lazy 399 | */ 400 | _debugChanges(changes) 401 | { 402 | try 403 | { 404 | let stack = new Error().stack; 405 | let line = stack.split('\n')[2]; 406 | let method = line.replace(/^.* at [a-zA-Z0-9_.][a-zA-Z0-9_]*\.([a-zA-Z0-9_]+).*$/, '$1'); 407 | debug(`Method '${method}' will trigger following changes : ${JSON.stringify(changes)}`); 408 | } 409 | catch (e) 410 | { 411 | return; 412 | } 413 | } 414 | 415 | /** 416 | * Initialize markets subscriptions for a given pair 417 | * 418 | * @param {float} timestamp timestamp of the first subscription 419 | */ 420 | _initializeMarketsPair(timestamp) 421 | { 422 | let obj = { 423 | // last time subscription for current pair has changed 424 | timestamp:timestamp, 425 | lastCseq:0, 426 | lastUpdateCseq:0 427 | } 428 | return obj; 429 | } 430 | 431 | /** 432 | * Subscribe to order books & trades for a list of pairs 433 | * 434 | * @param {array} pairs array of pairs 435 | * @param {boolean} reset if true, previous subscriptions will be ignored (default = false) 436 | * @param {boolean} connect whether or not connection with exchange should be established if necessary (optional, default = true) 437 | */ 438 | subscribeToMarkets(pairs, reset, connect) 439 | { 440 | if (undefined === connect) 441 | { 442 | connect = true; 443 | } 444 | let timestamp = Date.now() / 1000.0; 445 | let changes = { 446 | subscribe:[], 447 | unsubscribe:[], 448 | resync:[] 449 | }; 450 | let updated = false; 451 | 452 | // just add new subscriptions 453 | if (undefined === reset || false === reset) 454 | { 455 | // process subscribe 456 | _.forEach(pairs, (p) => { 457 | // no subscriptions for this pair yet 458 | if (undefined === this._subscriptions.markets.pairs[p]) 459 | { 460 | this._subscriptions.markets.pairs[p] = this._initializeMarketsPair(timestamp); 461 | this._initializeLastTrade(p); 462 | changes.subscribe.push({entity:'market',pair:p}); 463 | // request full order book for new pair 464 | changes.resync.push({entity:'orderBook',pair:p}) 465 | updated = true; 466 | } 467 | }); 468 | } 469 | // add new subscriptions & discard previous 470 | else 471 | { 472 | let newPairs = {}; 473 | // check new subscriptions 474 | _.forEach(pairs, (p) => { 475 | if (undefined !== newPairs[p]) 476 | { 477 | return; 478 | } 479 | this._initializeLastTrade(p); 480 | // pair has been added 481 | if (undefined === this._subscriptions.markets.pairs[p]) 482 | { 483 | newPairs[p] = this._initializeMarketsPair(timestamp); 484 | changes.subscribe.push({entity:'market',pair:p}); 485 | // request full order book for new pair 486 | changes.resync.push({entity:'orderBook',pair:p}) 487 | updated = true; 488 | } 489 | else 490 | { 491 | newPairs[p] = this._subscriptions.markets.pairs[p]; 492 | } 493 | }); 494 | // check if we need to unsubscribe 495 | _.forEach(this._subscriptions.markets.pairs, (obj, p) => { 496 | // pair has been removed 497 | if (undefined === newPairs[p]) 498 | { 499 | this._resetLastTrade(p); 500 | changes.unsubscribe.push({entity:'market',pair:p}); 501 | if (this._reconnectAfterUnsubscribingFromMarkets.reconnect) 502 | { 503 | if (undefined === this._unsubscribedMarkets.pairs[p]) 504 | { 505 | this._unsubscribedMarkets.pairs[p] = timestamp; 506 | ++this._unsubscribedMarkets.count; 507 | } 508 | } 509 | updated = true; 510 | } 511 | }); 512 | this._subscriptions.markets.pairs = newPairs; 513 | } 514 | if (updated) 515 | { 516 | if (debug.enabled) 517 | { 518 | this._debugChanges(changes); 519 | } 520 | this._subscriptions.markets.timestamp = timestamp; 521 | this._processChanges(changes, connect); 522 | } 523 | } 524 | 525 | /** 526 | * Unsubscribe from order books & trades for a list of pairs 527 | * 528 | * @param {array} pairs array of pairs 529 | */ 530 | unsubscribeFromMarkets(pairs) 531 | { 532 | let timestamp = Date.now() / 1000.0; 533 | let changes = { 534 | unsubscribe:[] 535 | }; 536 | let updated = false; 537 | _.forEach(pairs, (p) => { 538 | if (undefined !== this._subscriptions.markets.pairs[p]) 539 | { 540 | this._resetLastTrade(p); 541 | changes.unsubscribe.push({entity:'market',pair:p}) 542 | if (this._reconnectAfterUnsubscribingFromMarkets.reconnect) 543 | { 544 | if (undefined === this._unsubscribedMarkets.pairs[p]) 545 | { 546 | this._unsubscribedMarkets.pairs[p] = timestamp; 547 | ++this._unsubscribedMarkets.count; 548 | } 549 | } 550 | delete this._subscriptions.markets.pairs[p]; 551 | updated = true; 552 | } 553 | }); 554 | if (updated) 555 | { 556 | if (debug.enabled) 557 | { 558 | this._debugChanges(changes); 559 | } 560 | this._subscriptions.markets.timestamp = timestamp; 561 | this._processChanges(changes, false); 562 | } 563 | } 564 | 565 | /** 566 | * Unsubscribe from order books & trades for all currently subscribed pairs 567 | * 568 | * @param {array} pairs array of pairs 569 | */ 570 | unsubscribeFromAllMarkets() 571 | { 572 | // we don't have any subscribed markets 573 | if (_.isEmpty(this._subscriptions.markets.pairs)) 574 | { 575 | return; 576 | } 577 | let timestamp = Date.now() / 1000.0; 578 | let changes = { 579 | unsubscribe:[] 580 | }; 581 | _.forEach(this._subscriptions.markets.pairs, (obj, p) => { 582 | changes.unsubscribe.push({entity:'market',pair:p}); 583 | if (this._reconnectAfterUnsubscribingFromMarkets.reconnect) 584 | { 585 | if (undefined === this._unsubscribedMarkets.pairs[p]) 586 | { 587 | this._unsubscribedMarkets.pairs[p] = timestamp; 588 | ++this._unsubscribedMarkets.count; 589 | } 590 | } 591 | }); 592 | this._subscriptions.markets.timestamp = timestamp; 593 | this._subscriptions.markets.pairs = {}; 594 | if (debug.enabled) 595 | { 596 | this._debugChanges(changes); 597 | } 598 | this._processChanges(changes, false); 599 | } 600 | 601 | /** 602 | * Resync order books (ie: ask for full order book) for a list of pairs 603 | * 604 | * @param {array} pairs array of pairs 605 | */ 606 | resyncOrderBooks(pairs) 607 | { 608 | let changes = { 609 | resync:[] 610 | }; 611 | let updated = false; 612 | _.forEach(pairs, (p) => { 613 | // no subscription for this pair 614 | if (undefined === this._subscriptions.markets.pairs[p]) 615 | { 616 | return; 617 | } 618 | changes.resync.push({entity:'orderBook', pair:p}); 619 | updated = true; 620 | }); 621 | if (updated) 622 | { 623 | if (debug.enabled) 624 | { 625 | this._debugChanges(changes); 626 | } 627 | this._processChanges(changes, true); 628 | } 629 | } 630 | 631 | /** 632 | * Subscribe to all tickers 633 | * 634 | * @param {boolean} connect whether or not connection with exchange should be established if necessary (optional, default = true) 635 | */ 636 | subscribeToAllTickers(connect) 637 | { 638 | // we already subscribed to all tickers 639 | if (this._subscriptions.tickers.global) 640 | { 641 | return; 642 | } 643 | if (undefined === connect) 644 | { 645 | connect = true; 646 | } 647 | let timestamp = Date.now() / 1000.0; 648 | let changes = { 649 | subscribe:[{entity:'ticker',global:true}], 650 | unsubscribe:[] 651 | }; 652 | this._subscriptions.tickers.pairs = {}; 653 | if (debug.enabled) 654 | { 655 | this._debugChanges(changes); 656 | } 657 | this._subscriptions.tickers.global = true; 658 | this._subscriptions.tickers.timestamp = timestamp; 659 | this._processChanges(changes, connect); 660 | } 661 | 662 | /** 663 | * Subscribe to tickers for a list of pairs 664 | * 665 | * @param {array} pairs array of pairs 666 | * @param {boolean} reset, previous subscriptions will be ignored (default = false) 667 | * @param {boolean} connect whether or not connection with exchange should be established if necessary (optional, default = true) 668 | */ 669 | subscribeToTickers(pairs, reset, connect) 670 | { 671 | // ignore if we subscribed to tickers globally 672 | if (this._subscriptions.tickers.global) 673 | { 674 | return; 675 | } 676 | if (undefined === connect) 677 | { 678 | connect = true; 679 | } 680 | let timestamp = Date.now() / 1000.0; 681 | let changes = { 682 | subscribe:[], 683 | unsubscribe:[] 684 | }; 685 | let updated = false; 686 | 687 | // just add new subscriptions 688 | if (undefined === reset || false === reset) 689 | { 690 | // process subscribe 691 | _.forEach(pairs, (p) => { 692 | // no subscriptions for this pair yet 693 | if (undefined === this._subscriptions.tickers.pairs[p]) 694 | { 695 | this._subscriptions.tickers.pairs[p] = timestamp; 696 | changes.subscribe.push({entity:'ticker',pair:p}); 697 | updated = true; 698 | } 699 | }); 700 | } 701 | // add new subscriptions & discard previous 702 | else 703 | { 704 | let newPairs = {}; 705 | // check new subscriptions 706 | _.forEach(pairs, (p) => { 707 | if (undefined !== newPairs[p]) 708 | { 709 | return; 710 | } 711 | // pair has been added 712 | if (undefined === this._subscriptions.tickers.pairs[p]) 713 | { 714 | newPairs[p] = timestamp; 715 | changes.subscribe.push({entity:'ticker',pair:p}); 716 | updated = true; 717 | } 718 | else 719 | { 720 | newPairs[p] = this._subscriptions.tickers.pairs[p]; 721 | } 722 | }); 723 | // check if we need to unsubscribe 724 | _.forEach(this._subscriptions.tickers.pairs, (ts, p) => { 725 | // pair has been removed 726 | if (undefined === newPairs[p]) 727 | { 728 | changes.unsubscribe.push({entity:'ticker',pair:p}); 729 | updated = true; 730 | } 731 | }); 732 | this._subscriptions.tickers.pairs = newPairs; 733 | } 734 | if (updated) 735 | { 736 | if (debug.enabled) 737 | { 738 | this._debugChanges(changes); 739 | } 740 | this._subscriptions.tickers.timestamp = timestamp; 741 | this._processChanges(changes, connect); 742 | } 743 | } 744 | 745 | /** 746 | * Unsubscribe from tickers for a list of pairs 747 | * 748 | * @param {array} pairs array of pairs 749 | */ 750 | unsubscribeFromTickers(pairs) 751 | { 752 | // ignore if we subscribed to tickers globally 753 | if (this._subscriptions.tickers.global) 754 | { 755 | return; 756 | } 757 | let timestamp = Date.now() / 1000.0; 758 | let changes = { 759 | unsubscribe:[] 760 | }; 761 | let updated = false; 762 | _.forEach(pairs, (p) => { 763 | if (undefined !== this._subscriptions.tickers.pairs[p]) 764 | { 765 | changes.unsubscribe.push({entity:'ticker',pair:p}) 766 | delete this._subscriptions.tickers.pairs[p]; 767 | updated = true; 768 | } 769 | }); 770 | if (updated) 771 | { 772 | if (debug.enabled) 773 | { 774 | this._debugChanges(changes); 775 | } 776 | this._subscriptions.tickers.timestamp = timestamp; 777 | this._processChanges(changes, false); 778 | } 779 | } 780 | 781 | /** 782 | * Unsubscribe from all tickers 783 | */ 784 | unsubscribeFromAllTickers() 785 | { 786 | // we don't have any subscribed tickers 787 | if (!this._subscriptions.tickers.global && _.isEmpty(this._subscriptions.tickers.pairs)) 788 | { 789 | return; 790 | } 791 | let timestamp = Date.now() / 1000.0; 792 | let changes = { 793 | unsubscribe:[] 794 | }; 795 | this._subscriptions.tickers.timestamp = timestamp; 796 | if (this._subscriptions.tickers.global) 797 | { 798 | changes.unsubscribe.push({entity:'ticker',global:true}) 799 | } 800 | else 801 | { 802 | _.forEach(this._subscriptions.tickers.pairs, (p) => { 803 | changes.unsubscribe.push({entity:'ticker',pair:p}) 804 | }); 805 | this._subscriptions.tickers.pairs = {}; 806 | } 807 | if (debug.enabled) 808 | { 809 | this._debugChanges(changes); 810 | } 811 | this._subscriptions.tickers.global = false; 812 | this._processChanges(changes, false); 813 | } 814 | 815 | /** 816 | * Subscribe to orders (requires valid api key & api secret) 817 | * 818 | * @param {boolean} resubscribe if true, will resubscribe even if a subscription already exists (default = false) 819 | * @param {boolean} connect whether or not connection with exchange should be established if necessary (optional, default = true) 820 | */ 821 | subscribeToOrders(resubscribe, connect) 822 | { 823 | // no support 824 | if (null === this._auth.key) 825 | { 826 | return false; 827 | } 828 | if (undefined === resubscribe) 829 | { 830 | resubscribe = false; 831 | } 832 | if (undefined === connect) 833 | { 834 | connect = true; 835 | } 836 | // already subscribed, do nothing 837 | if (this._subscriptions.orders.subscribed && !resubscribe) 838 | { 839 | return; 840 | } 841 | // cancel watchdog since a new one will be automatically started 842 | if (resubscribe) 843 | { 844 | this._clearWatchdogTimer('orders'); 845 | } 846 | let timestamp = Date.now() / 1000.0; 847 | let changes = { 848 | subscribe:[{entity:'orders'}] 849 | }; 850 | if (debug.enabled) 851 | { 852 | this._debugChanges(changes); 853 | } 854 | this._subscriptions.orders.timestamp = timestamp; 855 | this._subscriptions.orders.subscribed = true; 856 | this._processChanges(changes, connect); 857 | } 858 | 859 | /** 860 | * Unsubscribe from orders to orders (requires valid api key & api secret) 861 | */ 862 | unsubscribeFromOrders() 863 | { 864 | // no support 865 | if (null === this._auth.key) 866 | { 867 | return false; 868 | } 869 | // ignore if we didn't subscribe previously 870 | if (!this._subscriptions.orders.subscribed) 871 | { 872 | return; 873 | } 874 | let timestamp = Date.now() / 1000.0; 875 | let changes = { 876 | unsubscribe:[{entity:'orders'}] 877 | }; 878 | if (debug.enabled) 879 | { 880 | this._debugChanges(changes); 881 | } 882 | this._subscriptions.orders.timestamp = timestamp; 883 | this._subscriptions.orders.subscribed = false; 884 | this._processChanges(changes, false); 885 | } 886 | 887 | /** 888 | * Process subscription changes 889 | * 890 | * @param {object} changes list of changes to process 891 | * @param {boolean} connect whether or not connection with exchange should be established if necessary 892 | * 893 | * Each property (subscribe,unsubscribe,resync) is optional 894 | * Entity can be (ticker,market) for subscribe/unsubscribe or (orderBook) for resync 895 | * 896 | * { 897 | * "subscribe":[{"entity":"","pair":""},...], 898 | * "unsubscribe":[{"entity":"","pair":""},...], 899 | * "resync":[{"entity":"","pair":""},...] 900 | * } 901 | */ 902 | _processChanges(changes, connect) 903 | { 904 | if (null === this._connection) 905 | { 906 | if (connect) 907 | { 908 | this._createConnection(); 909 | } 910 | return; 911 | } 912 | if (!this._connection.isConnected()) 913 | { 914 | return; 915 | } 916 | // check if we need to reconnect 917 | if (this._reconnectAfterUnsubscribingFromMarkets.reconnect && this._unsubscribedMarkets.count >= this._reconnectAfterUnsubscribingFromMarkets.after) 918 | { 919 | this.reconnect(true); 920 | return; 921 | } 922 | // check if we need to resync order books 923 | if (undefined !== changes.resync) 924 | { 925 | _.forEach(changes.resync, (entry) => { 926 | this._queryExchangeState(entry.pair); 927 | }); 928 | } 929 | // check if we need to subscribe to markets/tickers 930 | if (undefined !== changes.subscribe) 931 | { 932 | _.forEach(changes.subscribe, (entry) => { 933 | switch (entry.entity) 934 | { 935 | case 'market': 936 | this._connection.callMethod('SubscribeToExchangeDeltas', [entry.pair]); 937 | break; 938 | // Since 20/11/2017 tickers update are not sent automatically and require method SubscribeToSummaryDeltas 939 | case 'ticker': 940 | this._connection.callMethod('SubscribeToSummaryDeltas'); 941 | break; 942 | case 'orders': 943 | this._subscribeToOrders(); 944 | break; 945 | } 946 | }); 947 | } 948 | this._initializeWatchdog(); 949 | } 950 | 951 | /** 952 | * Performs SignalR request 953 | * 954 | * @param {function} cb callback to call upon completion (optional) 955 | */ 956 | _subscribeToOrders(cb) 957 | { 958 | let self = this; 959 | this._connection.callMethod('GetAuthContext', [this._auth.key], function(challenge, err){ 960 | // we got an error 961 | if (null !== err) 962 | { 963 | if (debug.enabled) 964 | { 965 | debug("Could not call 'GetAuthContext' : err = '%s'", err); 966 | } 967 | if (null !== self._logger) 968 | { 969 | self._logger.warn("Could not call 'GetAuthContext' : err = '%s'", err); 970 | } 971 | // unsubscribe from orders 972 | let timestamp = Date.now() / 1000.0; 973 | self._subscriptions.orders.timestamp = timestamp; 974 | self._subscriptions.orders.subscribed = false; 975 | self._initializeWatchdog.call(self); 976 | 977 | if (undefined !== cb) 978 | { 979 | try 980 | { 981 | cb(false, err); 982 | } 983 | catch (e) 984 | { 985 | // just ignore 986 | } 987 | } 988 | return; 989 | } 990 | if (null === self._connection || !self._connection.isConnected()) 991 | { 992 | if (undefined !== cb) 993 | { 994 | try 995 | { 996 | cb(false); 997 | } 998 | catch (e) 999 | { 1000 | // just ignore 1001 | } 1002 | } 1003 | return; 1004 | } 1005 | 1006 | // create response 1007 | const hmac = crypto.createHmac('sha512', self._auth.secret); 1008 | hmac.update(challenge); 1009 | const response = hmac.digest('hex'); 1010 | 1011 | // call Authenticate 1012 | self._connection.callMethod('Authenticate', [self._auth.key, response], function(success, err){ 1013 | // we got an error 1014 | if (null !== err) 1015 | { 1016 | if (debug.enabled) 1017 | { 1018 | debug("Could not call 'Authenticate' : err = '%s'", err); 1019 | } 1020 | if (null !== self._logger) 1021 | { 1022 | self._logger.warn("Could not call 'Authenticate' : err = '%s'", err); 1023 | } 1024 | // unsubscribe from orders 1025 | let timestamp = Date.now() / 1000.0; 1026 | self._subscriptions.orders.timestamp = timestamp; 1027 | self._subscriptions.orders.subscribed = false; 1028 | self._initializeWatchdog.call(self); 1029 | if (undefined !== cb) 1030 | { 1031 | try 1032 | { 1033 | cb(false, err); 1034 | } 1035 | catch (e) 1036 | { 1037 | // just ignore 1038 | } 1039 | } 1040 | return; 1041 | } 1042 | if (undefined !== cb) 1043 | { 1044 | try 1045 | { 1046 | cb(true); 1047 | } 1048 | catch (e) 1049 | { 1050 | // just ignore 1051 | } 1052 | } 1053 | }); 1054 | }); 1055 | } 1056 | 1057 | /** 1058 | * Clears all watchdog timers 1059 | */ 1060 | _clearWatchdogTimers() 1061 | { 1062 | this._clearWatchdogTimer('tickers'); 1063 | this._clearWatchdogTimer('markets'); 1064 | this._clearWatchdogTimer('orders'); 1065 | } 1066 | 1067 | /** 1068 | * Clears a specific timer 1069 | * 1070 | * @param {string} type tickers|markets 1071 | */ 1072 | _clearWatchdogTimer(type) 1073 | { 1074 | if (undefined === this._watchdog[type]) 1075 | { 1076 | return; 1077 | } 1078 | if (null !== this._watchdog[type].timer) 1079 | { 1080 | clearTimeout(this._watchdog[type].timer); 1081 | this._watchdog[type].timer = null; 1082 | } 1083 | } 1084 | 1085 | /** 1086 | * Checks if watchdog timers should be started / stopped 1087 | */ 1088 | _initializeWatchdog() 1089 | { 1090 | // tickers watchdog is enabled 1091 | if (0 != this._watchdog.tickers.timeout) 1092 | { 1093 | // no subscriptions => disable watchdog 1094 | if (!this._subscriptions.tickers.global && _.isEmpty(this._subscriptions.tickers.pairs)) 1095 | { 1096 | if (debug.enabled) 1097 | { 1098 | debug("Watchdog for 'tickers' will be disabled since we don't have any remaining subscription"); 1099 | } 1100 | this._clearWatchdogTimer('tickers'); 1101 | } 1102 | else 1103 | { 1104 | this._startWatchdogTimer('tickers'); 1105 | } 1106 | } 1107 | // markets watchdog is enabled 1108 | if (0 != this._watchdog.markets.timeout) 1109 | { 1110 | // no subscriptions => disable watchdog 1111 | if (_.isEmpty(this._subscriptions.markets.pairs)) 1112 | { 1113 | if (debug.enabled) 1114 | { 1115 | debug("Watchdog for 'markets' will be disabled since we don't have any remaining subscription"); 1116 | } 1117 | this._clearWatchdogTimer('markets'); 1118 | } 1119 | else 1120 | { 1121 | this._startWatchdogTimer('markets'); 1122 | } 1123 | } 1124 | // orders watchdog is enabled 1125 | if (this._watchdog.orders.enabled) 1126 | { 1127 | // no subscriptions => disable watchdog 1128 | if (!this._subscriptions.orders.subscribed) 1129 | { 1130 | if (debug.enabled) 1131 | { 1132 | debug("Watchdog for 'orders' will be disabled since we don't have a subscription"); 1133 | } 1134 | this._clearWatchdogTimer('orders'); 1135 | } 1136 | else 1137 | { 1138 | this._startWatchdogTimerForOrders(); 1139 | } 1140 | } 1141 | } 1142 | 1143 | /** 1144 | * Starts a timer for orders subscription 1145 | * 1146 | */ 1147 | _startWatchdogTimerForOrders() 1148 | { 1149 | // timer already exists 1150 | if (null !== this._watchdog.orders.timer) 1151 | { 1152 | return; 1153 | } 1154 | let self = this; 1155 | if (debug.enabled) 1156 | { 1157 | debug("Watchdog for 'orders' will be started since we have a subscription"); 1158 | } 1159 | this._watchdog.orders.timer = setInterval(function(){ 1160 | // if socket is not connected, do nothing 1161 | if (!self.isConnected.call(self)) 1162 | { 1163 | return; 1164 | } 1165 | self._watchdog.orders.lastTimestamp = new Date().getTime(); 1166 | if (debug.enabled) 1167 | { 1168 | debug("About to re-subscribe for 'orders'..."); 1169 | } 1170 | // resubscribe & emit event 1171 | self._subscribeToOrders.call(self, function(subscribed, err){ 1172 | // only emit event if re-subscribe was successful 1173 | if (subscribed) 1174 | { 1175 | let evt = {connectionId:self._connectionId,dataType:'orders',lastTimestamp:self._watchdog.orders.lastTimestamp} 1176 | self.emit('watchdog', evt); 1177 | } 1178 | }); 1179 | }, this._watchdog.orders.period); 1180 | } 1181 | 1182 | /** 1183 | * Starts a specific timer 1184 | * 1185 | * @param {string} type tickers|markets 1186 | */ 1187 | _startWatchdogTimer(type) 1188 | { 1189 | if (undefined === this._watchdog[type]) 1190 | { 1191 | return; 1192 | } 1193 | // timer already exists 1194 | if (null !== this._watchdog[type].timer) 1195 | { 1196 | return; 1197 | } 1198 | let self = this; 1199 | if (debug.enabled) 1200 | { 1201 | debug("Watchdog for '%s' will be started since we have at least one subscription", type); 1202 | } 1203 | // use timeout / 10 to ensure we properly detect timeout soon enough (this means timeout will be detected at most in ${timeout * 1.10} ms) 1204 | let interval = parseInt(this._watchdog[type].timeout / 10.0); 1205 | this._watchdog[type].timer = setInterval(function(){ 1206 | // if socket is not connected, do nothing 1207 | if (!self.isConnected.call(self)) 1208 | { 1209 | return; 1210 | } 1211 | let timestamp = new Date().getTime(); 1212 | // timeout triggered 1213 | let delta = timestamp - self._watchdog[type].lastTimestamp; 1214 | if (debug.enabled) 1215 | { 1216 | debug("Last '%s' data was received %dms ago", type, delta); 1217 | } 1218 | let evt = {connectionId:self._connectionId,dataType:type,lastTimestamp:self._watchdog[type].lastTimestamp} 1219 | self.emit('watchdog', evt); 1220 | if (delta > self._watchdog[type].timeout) 1221 | { 1222 | if (debug.enabled) 1223 | { 1224 | debug("Data timeout occured for '%s' : last data received at = %d", type, self._watchdog[type].lastTimestamp); 1225 | } 1226 | self.emit('timeout', evt); 1227 | // reconnect if necessary 1228 | if (self._watchdog[type].reconnect) 1229 | { 1230 | if (null !== self._logger) 1231 | { 1232 | self._logger.warn("Data timeout occured (bittrex|%d|%s), will try to reconnect immediately", self._connectionCounter, self._connectionId); 1233 | } 1234 | self.reconnect.call(self, true); 1235 | } 1236 | } 1237 | }, interval); 1238 | } 1239 | 1240 | /** 1241 | * Creates a new connection 1242 | * 1243 | * @param {integer} delay delay in ms before connecting (optional, default = no delay) 1244 | */ 1245 | _createConnection(delay) 1246 | { 1247 | this._connectionCounter += 1; 1248 | this._connectionId = null; 1249 | let connection = new SignalRConnection(this._connectionOptions); 1250 | 1251 | connection.logAllWsMessages(this._logAllWsMessages); 1252 | connection.logKeepaliveMessages(this._logKeepaliveMessages); 1253 | 1254 | // recompute utc offset on each reconnect 1255 | this._computeUtcOffset(); 1256 | 1257 | let self = this; 1258 | 1259 | connection.on('disconnected', function(data){ 1260 | // clear timers for data timeout 1261 | self._clearWatchdogTimers.call(self); 1262 | if (debug.enabled) 1263 | { 1264 | debug("Connection (bittrex|%d|%s) disconnected, will try to reconnect in %dms", self._connectionCounter, data.connectionId, self._retryDelay); 1265 | } 1266 | if (null !== self._logger) 1267 | { 1268 | self._logger.warn("Connection (bittrex|%d|%s) disconnected, will try to reconnect in %dms", self._connectionCounter, data.connectionId, self._retryDelay); 1269 | } 1270 | self.emit('disconnected', {connectionId:data.connectionId, code:data.code, reason:data.reason}); 1271 | self._createConnection.call(self, self._retryDelay); 1272 | }); 1273 | 1274 | connection.on('connectionError', function(err){ 1275 | // retry is possible 1276 | if (err.retry) 1277 | { 1278 | if (debug.enabled) 1279 | { 1280 | debug("Connection (bittrex|%d|%s) failed (will try to reconnect in %dms) : attempts = %d, error = '%s'", self._connectionCounter, err.step, self._retryDelay, err.attempts, JSON.stringify(err.error)); 1281 | } 1282 | if (null !== self._logger) 1283 | { 1284 | self._logger.warn("Connection (bittrex|%d|%s) failed (will try to reconnect in %dms) : attempts = %d, error = '%s'", self._connectionCounter, err.step, self._retryDelay, err.attempts, JSON.stringify(err.error)); 1285 | } 1286 | self.emit('connectionError', {step:err.step,attempts:err.attempts,error:err.error}); 1287 | return; 1288 | } 1289 | // no more retry 1290 | if (debug.enabled) 1291 | { 1292 | debug("Connection (bittrex|%s|%s) failed (no more retry left) : attempts = %d, error = '%s'", self._connectionCounter, err.step, err.attempts, JSON.stringify(err.error)); 1293 | } 1294 | if (null !== self._logger) 1295 | { 1296 | self._logger.warn("Connection (bittrex|%d|%s) failed (no more retry left) : attempts = %d, error = '%s'", self._connectionCounter, err.step, err.attempts, JSON.stringify(err.error)); 1297 | } 1298 | self.emit('terminated', {step:err.step,attempts:err.attempts,error:err.error}); 1299 | }); 1300 | 1301 | connection.on('connected', function(data){ 1302 | // clear timers for data timeout 1303 | self._clearWatchdogTimers.call(self); 1304 | self._connectionId = data.connectionId; 1305 | if (debug.enabled) 1306 | { 1307 | debug("Connection (bittrex|%d|%s) connected", self._connectionCounter, data.connectionId); 1308 | } 1309 | if (null !== self._logger) 1310 | { 1311 | self._logger.info("Connection (bittrex|%d|%s) connected", self._connectionCounter, data.connectionId); 1312 | } 1313 | self.emit('connected', {connectionId:data.connectionId}); 1314 | self._connectedTimestamp = Date.now() / 1000.0; 1315 | self._processSubscriptions.call(self); 1316 | }); 1317 | 1318 | connection.on('data', function(data){ 1319 | self._processData.call(self, data); 1320 | }); 1321 | 1322 | this._connection = connection; 1323 | 1324 | try 1325 | { 1326 | // connect immediately 1327 | if (undefined === delay) 1328 | { 1329 | connection.connect(); 1330 | } 1331 | else 1332 | { 1333 | setTimeout(function(){ 1334 | // disconnection probably requested by client 1335 | if (null === self._connection) 1336 | { 1337 | return; 1338 | } 1339 | connection.connect(); 1340 | }, delay); 1341 | } 1342 | } 1343 | catch (e) 1344 | { 1345 | throw e; 1346 | } 1347 | } 1348 | 1349 | /** 1350 | * This method will be called upon reconnection and will call _processChanges 1351 | */ 1352 | _processSubscriptions() 1353 | { 1354 | let changes = { 1355 | subscribe:[], 1356 | resync:[] 1357 | }; 1358 | // we just reconnected, reset unsubscribed markets 1359 | this._unsubscribedMarkets = { 1360 | pairs:{}, 1361 | count:0 1362 | }; 1363 | if (this._subscriptions.tickers.global) 1364 | { 1365 | changes.subscribe.push({entity:'ticker',global:true}); 1366 | } 1367 | else 1368 | { 1369 | _.forEach(Object.keys(this._subscriptions.tickers.pairs), (p) => { 1370 | changes.subscribe.push({entity:'ticker',pair:p}); 1371 | }); 1372 | } 1373 | _.forEach(Object.keys(this._subscriptions.markets.pairs), (p) => { 1374 | changes.subscribe.push({entity:'market',pair:p}); 1375 | // request full order book upon reconnection 1376 | changes.resync.push({entity:'orderBook',pair:p}); 1377 | }); 1378 | if (this._subscriptions.orders.subscribed) 1379 | { 1380 | changes.subscribe.push({entity:'orders'}); 1381 | } 1382 | this._processChanges(changes); 1383 | } 1384 | 1385 | /* 1386 | Example data 1387 | { 1388 | "M":null, 1389 | "N":1870, 1390 | "Z":[ 1391 | { 1392 | "Q":0.64276295, 1393 | "R":7375.00000000 1394 | }, 1395 | { 1396 | "Q":0.00134273, 1397 | "R":7373.02479393 1398 | }, 1399 | { 1400 | "Q":0.02982468, 1401 | "R":7358.00000000 1402 | } 1403 | ], 1404 | "S":[ 1405 | { 1406 | "Q":0.23198481, 1407 | "R":7389.89999990 1408 | }, 1409 | { 1410 | "Q":6.78724854, 1411 | "R":7390.00000000 1412 | }, 1413 | { 1414 | "Q":0.01109804, 1415 | "R":7396.44820350 1416 | } 1417 | ], 1418 | "f":[ 1419 | { 1420 | "I":46589978, 1421 | "T":1522748465633, 1422 | "Q":0.16349764, 1423 | "P":7376.00000000, 1424 | "t":1205.95859264, 1425 | "F":"FILL", 1426 | "OT":"SELL" 1427 | }, 1428 | { 1429 | "I":46589971, 1430 | "T":1522748446290, 1431 | "Q":0.00696611, 1432 | "P":7375.00000000, 1433 | "t":51.37506125, 1434 | "F":"PARTIAL_FILL", 1435 | "OT":"SELL" 1436 | } 1437 | ] 1438 | } 1439 | */ 1440 | _queryExchangeState(pair) 1441 | { 1442 | // reset nounce 1443 | this._subscriptions.markets.pairs[pair].cseq = 0; 1444 | let self = this; 1445 | this._connection.callMethod('QueryExchangeState', [pair], function(d, err){ 1446 | // we got an error 1447 | if (null !== err) 1448 | { 1449 | if (debug.enabled) 1450 | { 1451 | debug("Could not query exchange for pair '%s' (might be an invalid pair) : err = '%s'", pair, err); 1452 | } 1453 | if (null !== self._logger) 1454 | { 1455 | self._logger.warn("Could not query exchange for pair '%s' (might be an invalid pair) : err = '%s'", pair, err); 1456 | } 1457 | delete self._subscriptions.markets.pairs[pair]; 1458 | self.emit('queryExchangeStateError', {pair:pair,type:'error',error:err}); 1459 | if (self._exitOnQueryExchangeStateError) 1460 | { 1461 | process.exit(1); 1462 | } 1463 | return; 1464 | } 1465 | self._decodeData.call(self, d, function(data){ 1466 | // ignore if we're not subscribed to this pair anymore 1467 | if (undefined === self._subscriptions.markets.pairs[pair]) 1468 | { 1469 | return; 1470 | } 1471 | // probably an invalid trading pair 1472 | if (null === data) 1473 | { 1474 | if (debug.enabled) 1475 | { 1476 | debug("QueryExchangeState returned null for pair '%s' (probably an invalid pair)", pair); 1477 | } 1478 | if (null !== self._logger) 1479 | { 1480 | self._logger.warn("QueryExchangeState returned null for pair '%s' (probably an invalid pair)", pair); 1481 | } 1482 | // remove subscription 1483 | delete self._subscriptions.markets.pairs[pair]; 1484 | self.emit('queryExchangeStateError', {pair:pair,type:'nil_data'}); 1485 | if (self._exitOnQueryExchangeStateError) 1486 | { 1487 | process.exit(1); 1488 | } 1489 | return; 1490 | } 1491 | 1492 | self._subscriptions.markets.pairs[pair].lastCseq = data.N; 1493 | 1494 | // build events 1495 | let orderBookEvt = { 1496 | pair:pair, 1497 | cseq:data.N, 1498 | data:{ 1499 | buy:_.map(data.Z, entry => { 1500 | return { 1501 | rate:parseFloat(entry.R), 1502 | quantity:parseFloat(entry.Q) 1503 | } 1504 | }), 1505 | sell:_.map(data.S, entry => { 1506 | return { 1507 | rate:parseFloat(entry.R), 1508 | quantity:parseFloat(entry.Q) 1509 | } 1510 | }) 1511 | } 1512 | } 1513 | let tradesEvt = { 1514 | pair:pair, 1515 | data:_.map(data.f, entry => { 1516 | let price = undefined !== entry.t ? entry.t : parseFloat(new Big(entry.Q).times(entry.P)); 1517 | let orderType = 'sell'; 1518 | if ('BUY' == entry.OT) 1519 | { 1520 | orderType = 'buy'; 1521 | } 1522 | return { 1523 | id:entry.I, 1524 | quantity:entry.Q, 1525 | rate:entry.P, 1526 | price:price, 1527 | orderType:orderType, 1528 | timestamp:parseFloat(entry.T / 1000.0) 1529 | } 1530 | }) 1531 | } 1532 | 1533 | // emit events 1534 | if (0 != orderBookEvt.data.buy.length || 0 != orderBookEvt.data.sell.length) 1535 | { 1536 | self.emit('orderBook', orderBookEvt); 1537 | } 1538 | if (0 != tradesEvt.data.length) 1539 | { 1540 | // only if we didn't already emitted a 'trades' event for same cseq 1541 | if (self._subscriptions.markets.pairs[pair].lastUpdateCseq != data.N) 1542 | { 1543 | // only emit trades which were executed after last(trade.id) or subscription 1544 | let minTimestamp = 0; 1545 | let minTradeId = self._lastTrades[pair].id; 1546 | // if we don't have previous trade id, use subscription timestamp 1547 | if (0 == minTradeId) 1548 | { 1549 | minTimestamp = self._subscriptions.markets.pairs[pair].timestamp; 1550 | } 1551 | // if oldest entry is <= last(trade).id or is < timestamp(subscription), we need to do some filtering 1552 | if (tradesEvt.data[tradesEvt.data.length - 1].id <= minTradeId || tradesEvt.data[tradesEvt.data.length - 1].timestamp < minTimestamp) 1553 | { 1554 | let data = []; 1555 | _.forEach(tradesEvt.data, (e) => { 1556 | if (e.id <= minTradeId || e.timestamp < minTimestamp) 1557 | { 1558 | return false; 1559 | } 1560 | data.push(e); 1561 | }); 1562 | tradesEvt.data = data; 1563 | } 1564 | if (0 != tradesEvt.data.length) 1565 | { 1566 | // update last trade id 1567 | self._lastTrades[pair].id = tradesEvt.data[0].id; 1568 | self.emit('trades', tradesEvt); 1569 | } 1570 | } 1571 | } 1572 | }); 1573 | }); 1574 | } 1575 | 1576 | /* 1577 | Example output 1578 | {"C":"d-D0576E0C-B,0|v0,0|v1,3|g,467C|v2,0","M":[{"H":"C2","M":"uE","A":["ddA9DsIwDAXgu3gO0XNi528EVpCgZQDUlUug3p1ShYq2qsfo07Pz3nSiQrfm2O727YEMnalwEmRDDyrPN7V3KjB0pZJShMUwCrChywBtFP2+oDdbEqOEZZ8zUvAbchipmepYWaP+JK+kr5nYJrwkrpLAc8KWRQRe00pOYdMf1MFFF5dSvQ3pT7J1Ps96qQcKJ4sUlXnKRN8ZaqaqK8wiVtxs+aLjHENY3gfPDhFj5GuI7PoP"]}]} 1579 | */ 1580 | _processData(data) 1581 | { 1582 | try 1583 | { 1584 | let methodName = data.M.toLowerCase(); 1585 | switch (methodName) 1586 | { 1587 | case 'ue': 1588 | _.forEach(data.A, (entry) => { 1589 | this._processUpdateExchangeState(entry); 1590 | }) 1591 | break; 1592 | case 'us': 1593 | _.forEach(data.A, (entry) => { 1594 | this._processUpdateSummaryState(entry); 1595 | }) 1596 | break; 1597 | case 'uo': 1598 | _.forEach(data.A, (entry) => { 1599 | this._processOrdersDelta(entry); 1600 | }) 1601 | break; 1602 | }; 1603 | } 1604 | catch (e) 1605 | { 1606 | if (debug.enabled) 1607 | { 1608 | debug("Exception when trying to process data : %s", e.stack); 1609 | } 1610 | } 1611 | } 1612 | 1613 | /** 1614 | * Process Market Delta 1615 | */ 1616 | 1617 | /* 1618 | Example data : 1619 | 1620 | { 1621 | "M":"USDT-BTC", 1622 | "N":3188, 1623 | "Z":[ 1624 | { 1625 | "TY":1, 1626 | "R":6992.378, 1627 | "Q":0 1628 | }, 1629 | { 1630 | "TY":0, 1631 | "R":6687, 1632 | "Q":0.1 1633 | } 1634 | ], 1635 | "S":[ 1636 | { 1637 | "TY":2, 1638 | "R":7374.38554949, 1639 | "Q":0.10561806 1640 | }, 1641 | { 1642 | "TY":1, 1643 | "R":7449.51146141, 1644 | "Q":0 1645 | }, 1646 | { 1647 | "TY":0, 1648 | "R":7960.22045353, 1649 | "Q":0.09085729 1650 | } 1651 | ], 1652 | "f":[ 1653 | { 1654 | "OT":"BUY", 1655 | "R":7374.38554949, 1656 | "Q":0.00105, 1657 | "T":1522750645830 1658 | } 1659 | ] 1660 | } 1661 | */ 1662 | _processUpdateExchangeState(d) 1663 | { 1664 | this._decodeData(d, function(data){ 1665 | // an error occurred 1666 | if (undefined === data) 1667 | { 1668 | return; 1669 | } 1670 | // keep track of last timestamp when data was received 1671 | this._watchdog.markets.lastTimestamp = new Date().getTime(); 1672 | // we're not subscribed to this pair => ignore 1673 | if (undefined === this._subscriptions.markets.pairs[data.M]) 1674 | { 1675 | return; 1676 | } 1677 | let lastCseq = this._subscriptions.markets.pairs[data.M].lastCseq; 1678 | // ignore, we didn't receive full order book yet 1679 | if (0 == lastCseq) 1680 | { 1681 | return; 1682 | } 1683 | // did we miss some nounce ? 1684 | let missedCount = data.N - lastCseq; 1685 | // ignore update since it's the same nounce as it's the same one as the full order book we last received 1686 | if (0 === missedCount) 1687 | { 1688 | return; 1689 | } 1690 | // ignore update since last nounce is greater 1691 | if (missedCount < 0) 1692 | { 1693 | return; 1694 | } 1695 | if (missedCount > 1) 1696 | { 1697 | if (this._resyncOrderBooksAutomatically) 1698 | { 1699 | if (debug.enabled) 1700 | { 1701 | debug("We missed %d update for market '%s' (full data will be retrieved) : last cseq = %d, current cseq", missedCount, data.M, lastCseq, data.N); 1702 | } 1703 | this._queryExchangeState(data.M); 1704 | return; 1705 | } 1706 | if (debug.enabled) 1707 | { 1708 | debug("We missed %d update for market '%s' : last cseq = %d, current cseq", missedCount, data.M, lastCseq, data.N); 1709 | } 1710 | } 1711 | this._subscriptions.markets.pairs[data.M].lastCseq = data.N; 1712 | this._subscriptions.markets.pairs[data.M].lastUpdateCseq = data.N; 1713 | 1714 | // build events 1715 | /* 1716 | entry.Type : 1717 | - Type 0 – you need to add this entry into your orderbook. There were no orders at matching price before. 1718 | - Type 1 – you need to delete this entry from your orderbook. This entry no longer exists (no orders at matching price) 1719 | - Type 2 – you need to edit this entry. There are different number of orders at this price. 1720 | */ 1721 | let orderBookUpdateEvt = { 1722 | pair:data.M, 1723 | cseq:data.N, 1724 | data:{ 1725 | buy:_.map(data.Z, entry => { 1726 | let action = 'update'; 1727 | if (1 == entry.TY) 1728 | { 1729 | action = 'remove'; 1730 | } 1731 | // by default a new entry in the order book will be considered as an update 1732 | else if (0 == entry.TY) 1733 | { 1734 | if (!this._markNewOrderBookEntriesAsUpdates) 1735 | { 1736 | action = 'add'; 1737 | } 1738 | } 1739 | return { 1740 | action:action, 1741 | rate:parseFloat(entry.R), 1742 | quantity:parseFloat(entry.Q) 1743 | } 1744 | }), 1745 | sell:_.map(data.S, entry => { 1746 | let action = 'update'; 1747 | if (1 == entry.TY) 1748 | { 1749 | action = 'remove'; 1750 | } 1751 | // by default a new entry in the order book will be considered as an update 1752 | else if (0 == entry.TY) 1753 | { 1754 | if (!this._markNewOrderBookEntriesAsUpdates) 1755 | { 1756 | action = 'add'; 1757 | } 1758 | } 1759 | return { 1760 | action:action, 1761 | rate:parseFloat(entry.R), 1762 | quantity:parseFloat(entry.Q) 1763 | } 1764 | }) 1765 | } 1766 | } 1767 | let tradesEvt = { 1768 | pair:data.M, 1769 | data:_.map(data.f, entry => { 1770 | let price = parseFloat(new Big(entry.Q).times(entry.R)); 1771 | let orderType = 'sell'; 1772 | if ('BUY' == entry.OT) 1773 | { 1774 | orderType = 'buy'; 1775 | } 1776 | let obj = { 1777 | // 2018-05-07 : seems that trade id is now available 1778 | id:entry.FI, 1779 | quantity:entry.Q, 1780 | rate:entry.R, 1781 | price:price, 1782 | orderType:orderType, 1783 | timestamp:parseFloat(entry.T / 1000.0) 1784 | } 1785 | return obj; 1786 | }) 1787 | } 1788 | 1789 | // emit events 1790 | if (0 != orderBookUpdateEvt.data.buy.length || 0 != orderBookUpdateEvt.data.sell.length) 1791 | { 1792 | this.emit('orderBookUpdate', orderBookUpdateEvt); 1793 | } 1794 | if (0 != tradesEvt.data.length) 1795 | { 1796 | // only emit trades which were executed after last(trade.id) or subscription 1797 | let minTimestamp = 0; 1798 | let minTradeId = this._lastTrades[tradesEvt.pair].id; 1799 | // if we don't have previous trade id, use subscription timestamp 1800 | if (0 == minTradeId) 1801 | { 1802 | minTimestamp = this._subscriptions.markets.pairs[tradesEvt.pair].timestamp; 1803 | } 1804 | // if oldest entry is <= last(trade).id or is < timestamp(subscription), we need to do some filtering 1805 | if (tradesEvt.data[tradesEvt.data.length - 1].id <= minTradeId || tradesEvt.data[tradesEvt.data.length - 1].timestamp < minTimestamp) 1806 | { 1807 | let data = []; 1808 | _.forEach(tradesEvt.data, (e) => { 1809 | if (e.id <= minTradeId || e.timestamp < minTimestamp) 1810 | { 1811 | return false; 1812 | } 1813 | data.push(e); 1814 | }); 1815 | tradesEvt.data = data; 1816 | } 1817 | if (0 != tradesEvt.data.length) 1818 | { 1819 | // update last trade id 1820 | this._lastTrades[tradesEvt.pair].id = tradesEvt.data[0].id; 1821 | this.emit('trades', tradesEvt); 1822 | } 1823 | } 1824 | }); 1825 | } 1826 | 1827 | /** 1828 | * Process Summary Delta 1829 | */ 1830 | 1831 | /* 1832 | Example data : 1833 | 1834 | { 1835 | "N":208, 1836 | "D":[ 1837 | { 1838 | "M":"BTC-AMP", 1839 | "H":0.000033, 1840 | "L":0.00002861, 1841 | "V":5045735.76501928, 1842 | "l":0.00002889, 1843 | "m":152.43805072, 1844 | "T":1522746674360, 1845 | "B":0.00002888, 1846 | "A":0.00002889, 1847 | "G":296, 1848 | "g":3807, 1849 | "PD":0.00003045, 1850 | "x":1446578035180 1851 | }, 1852 | { 1853 | "M":"BTC-ARDR", 1854 | "H":0.00004035, 1855 | "L":0.00003099, 1856 | "V":7108198.19040366, 1857 | "l":0.00003532, 1858 | "m":251.78803265, 1859 | "T":1522746673860, 1860 | "B":0.00003514, 1861 | "A":0.00003556, 1862 | "G":911, 1863 | "g":2462, 1864 | "PD":0.00003116, 1865 | "x":1476385177407 1866 | } 1867 | ] 1868 | } 1869 | 1870 | */ 1871 | _processUpdateSummaryState(d) 1872 | { 1873 | this._decodeData(d, function(data){ 1874 | // an error occurred 1875 | if (undefined === data) 1876 | { 1877 | return; 1878 | } 1879 | // no entry 1880 | if (undefined === data.D) 1881 | { 1882 | return; 1883 | } 1884 | // keep track of last timestamp when data was received 1885 | this._watchdog.tickers.lastTimestamp = new Date().getTime(); 1886 | _.forEach(data.D, (entry) => { 1887 | // we're not subscribed to this pair => ignore 1888 | if (!this._subscriptions.tickers.global && undefined === this._subscriptions.tickers.pairs[entry.M]) 1889 | { 1890 | return; 1891 | } 1892 | let last = parseFloat(entry.l); 1893 | let previousDay = parseFloat(entry.PD); 1894 | let percentChange = 0; 1895 | if (previousDay > 0) 1896 | { 1897 | percentChange = ((last/previousDay) - 1) * 100; 1898 | } 1899 | let evt = { 1900 | pair:entry.M, 1901 | data:{ 1902 | pair:entry.M, 1903 | last: last, 1904 | priceChangePercent:percentChange, 1905 | sell: parseFloat(entry.A), 1906 | buy: parseFloat(entry.B), 1907 | high: parseFloat(entry.H), 1908 | low: parseFloat(entry.L), 1909 | volume: parseFloat(entry.V), 1910 | timestamp: entry.T / 1000.0 1911 | } 1912 | } 1913 | this.emit('ticker', evt); 1914 | }); 1915 | }); 1916 | } 1917 | 1918 | /** 1919 | * Process Orders Delta 1920 | */ 1921 | 1922 | /* 1923 | Example data : 1924 | 1925 | 1) TY = 0 (OPEN) 1926 | 1927 | { 1928 | "w":"45a0efa4-02c7-4f39-81dd-ecb6125671da", 1929 | "N":1, 1930 | "TY":0, 1931 | "o":{ 1932 | "U":"45a0efa4-02c7-4f39-81dd-ecb6125671da", 1933 | "I":6459944809, 1934 | "OU":"88d2a22e-be40-4a48-a857-fd6ad4293438", 1935 | "E":"BTC-PTC", 1936 | "OT":"LIMIT_SELL", 1937 | "Q":1000.00000000, 1938 | "q":1000.00000000, 1939 | "X":0.00000800, 1940 | "n":0.00000000, 1941 | "P":0.00000000, 1942 | "PU":null, 1943 | "Y":1522759978173, 1944 | "C":null, 1945 | "i":null, 1946 | "CI":false, 1947 | "K":false, 1948 | "k":false, 1949 | "J":"NONE", 1950 | "j":null, 1951 | "u":null 1952 | } 1953 | } 1954 | 1955 | 2) TY = 3 (PARTIAL) 1956 | 1957 | { 1958 | "w":"42a0efa3-02c7-4f39-81dd-ecb6125671db", 1959 | "N":17, 1960 | "TY":1, 1961 | "o":{ 1962 | "U":"42a0efa3-02c7-4f39-81dd-ecb6125671db", 1963 | "I":6460428042, 1964 | "OU":"76664025-ee77-4d16-976a-a408d033dc61", 1965 | "E":"BTC-PTC", 1966 | "OT":"LIMIT_SELL", 1967 | "Q":1000, 1968 | "q":359.21926724, 1969 | "X":0.00000406, 1970 | "n":0.0000065, 1971 | "P":0.00260156, 1972 | "PU":0.00000405, 1973 | "Y":1522762803353, 1974 | "C":null, 1975 | "i":null, 1976 | "CI":false, 1977 | "K":false, 1978 | "k":false, 1979 | "J":"NONE", 1980 | "j":null, 1981 | "u":null 1982 | } 1983 | } 1984 | 1985 | 3) TY = 2 (FILL) 1986 | 1987 | Example where order was filled 100% 1988 | 1989 | { 1990 | "w":"45a0efa4-02c7-4f39-81dd-ecb6125671da", 1991 | "N":8, 1992 | "TY":2, 1993 | "o":{ 1994 | "U":"45a0efa4-02c7-4f39-81dd-ecb6125671da", 1995 | "I":6460531390, 1996 | "OU":"a333660a-1919-434b-9863-bdef13a849e6", 1997 | "E":"BTC-PTC", 1998 | "OT":"LIMIT_SELL", 1999 | "Q":123.7654321, 2000 | "q":0, 2001 | "X":0.00000405, 2002 | "n":0.00000125, 2003 | "P":0.00050125, 2004 | "PU":0.00000404, 2005 | "Y":1522763432983, 2006 | "C":1522763433140, 2007 | "i":null, 2008 | "CI":false, 2009 | "K":false, 2010 | "k":false, 2011 | "J":"NONE", 2012 | "j":null, 2013 | "u":null 2014 | } 2015 | } 2016 | 2017 | Example where order was cancelled after being partially filled 2018 | 2019 | { 2020 | "w":"42a0efa3-02c7-4f39-81dd-ecb6125671db", 2021 | "N":24, 2022 | "TY":2, 2023 | "o":{ 2024 | "U":"42a0efa3-02c7-4f39-81dd-ecb6125671db", 2025 | "I":6460803705, 2026 | "OU":"77c8f585-6d0c-4d2f-a5b0-a3c5abee504e", 2027 | "E":"BTC-PTC", 2028 | "OT":"LIMIT_SELL", 2029 | "Q":1000, 2030 | "q":29.71930944, 2031 | "X":0.00000406, 2032 | "n":0.00000984, 2033 | "P":0.00393933, 2034 | "PU":0.00000405, 2035 | "Y":1522765015517, 2036 | "C":1522765092253, 2037 | "i":null, 2038 | "CI":true, 2039 | "K":false, 2040 | "k":false, 2041 | "J":"NONE", 2042 | "j":null, 2043 | "u":null 2044 | } 2045 | } 2046 | 2047 | 4) TY = 3 (CANCEL) 2048 | 2049 | This state will be only set for an order which was cancelled before being filled (partially or completely) 2050 | 2051 | { 2052 | "w":"45a0efa4-02c7-4f39-81dd-ecb6125671da", 2053 | "N":2, 2054 | "TY":3, 2055 | "o":{ 2056 | "U":"45a0efa4-02c7-4f39-81dd-ecb6125671da", 2057 | "I":6459944809, 2058 | "OU":"88d2a22e-be40-4a48-a857-fd6ad4293438", 2059 | "E":"BTC-PTC", 2060 | "OT":"LIMIT_SELL", 2061 | "Q":1000.00000000, 2062 | "q":1000.00000000, 2063 | "X":0.00000800, 2064 | "n":0.00000000, 2065 | "P":0.00000000, 2066 | "PU":null, 2067 | "Y":1522759978173, 2068 | "C":1522761657550, 2069 | "i":null, 2070 | "CI":true, 2071 | "K":false, 2072 | "k":false, 2073 | "J":"NONE", 2074 | "j":null, 2075 | "u":null 2076 | } 2077 | } 2078 | 2079 | */ 2080 | _processOrdersDelta(d) 2081 | { 2082 | this._decodeData(d, function(data){ 2083 | // an error occurred 2084 | if (undefined === data) 2085 | { 2086 | return; 2087 | } 2088 | // no entry 2089 | if (undefined === data.o) 2090 | { 2091 | return; 2092 | } 2093 | let orderState = 'OPEN'; 2094 | switch (data.TY) 2095 | { 2096 | case 1: 2097 | orderState = 'PARTIAL'; 2098 | break; 2099 | case 2: 2100 | orderState = 'FILL'; 2101 | break; 2102 | case 3: 2103 | orderState = 'CANCEL'; 2104 | break; 2105 | } 2106 | let evt = { 2107 | pair:data.o.E, 2108 | orderNumber:data.o.OU, 2109 | data:{ 2110 | pair:data.o.E, 2111 | orderNumber:data.o.OU, 2112 | orderState:orderState, 2113 | orderType:data.o.OT, 2114 | quantity:parseFloat(data.o.Q), 2115 | remainingQuantity:parseFloat(data.o.q), 2116 | openTimestamp:parseFloat(data.o.Y / 1000.0), 2117 | targetRate:parseFloat(data.o.X) 2118 | } 2119 | } 2120 | evt.data.targetPrice = parseFloat(new Big(evt.data.targetRate).times(evt.data.quantity)); 2121 | 2122 | // order is closed 2123 | if (null !== data.o.C) 2124 | { 2125 | evt.data.closedTimestamp = parseFloat(data.o.C / 1000.0); 2126 | evt.data.actualPrice = parseFloat(data.o.P); 2127 | evt.data.fees = parseFloat(data.o.n); 2128 | evt.data.actualRate = null; 2129 | if (null !== data.o.PU) 2130 | { 2131 | evt.data.actualRate = parseFloat(data.o.PU); 2132 | } 2133 | } 2134 | 2135 | this.emit('order', evt); 2136 | }); 2137 | } 2138 | 2139 | /** 2140 | * Decode data received from endpoint : 2141 | * 1) base64 decode 2142 | * 2) gzip inflate 2143 | */ 2144 | _decodeData(d, cb) 2145 | { 2146 | let self = this; 2147 | let gzipData = Buffer.from(d, 'base64'); 2148 | // we need to use inflateRaw to avoid zlib error 'incorrect header check' (Z_DATA_ERROR) 2149 | zlib.inflateRaw(gzipData, function(err, str){ 2150 | if (null !== err) 2151 | { 2152 | self._logger.warn("Could not decompress Bittrex gzip data : %s", err); 2153 | cb.call(self, undefined); 2154 | return; 2155 | } 2156 | let data; 2157 | try 2158 | { 2159 | data = JSON.parse(str); 2160 | } 2161 | catch (e) 2162 | { 2163 | self._logger.warn("Decompressed Bittrex data does not contain valid JSON", err); 2164 | cb.call(self, undefined); 2165 | return; 2166 | } 2167 | cb.call(self, data); 2168 | }); 2169 | } 2170 | 2171 | /* 2172 | * Connect SignalR connection 2173 | * 2174 | * Should not be necessary since connection will happen automatically 2175 | */ 2176 | connect() 2177 | { 2178 | // create if needed 2179 | if (null !== this._connection) 2180 | { 2181 | return; 2182 | } 2183 | this._createConnection(); 2184 | } 2185 | 2186 | getConnectionId() 2187 | { 2188 | return this._connectionId; 2189 | } 2190 | 2191 | isConnected() 2192 | { 2193 | if (null === this._connection) 2194 | { 2195 | return false; 2196 | } 2197 | return this._connection.isConnected() 2198 | } 2199 | 2200 | isConnecting() 2201 | { 2202 | if (null === this._connection) 2203 | { 2204 | return false; 2205 | } 2206 | return this._connection.isConnecting() 2207 | } 2208 | 2209 | disconnect() 2210 | { 2211 | if (null === this._connection) 2212 | { 2213 | return; 2214 | } 2215 | // clear timers for data timeout 2216 | this._clearWatchdogTimers(); 2217 | if (debug.enabled) 2218 | { 2219 | debug("Connection (bittrex|%d) will be disconnected", this._connectionCounter); 2220 | } 2221 | if (null !== this._logger) 2222 | { 2223 | this._logger.info("Connection (bittrex|%d) will be disconnected", this._connectionCounter); 2224 | } 2225 | this._connectionId = null; 2226 | this._connection.disconnect(); 2227 | this._connection = null; 2228 | } 2229 | 2230 | /** 2231 | * Reconnect 2232 | * 2233 | * @param {boolean} immediate whether or not we want to reconnect immediately (otherwise, we will wait for options.retryDelay as provided in constructor) (optional, default = false) 2234 | */ 2235 | reconnect(immediate) 2236 | { 2237 | if (null === this._connection) 2238 | { 2239 | return; 2240 | } 2241 | // clear timers for data timeout 2242 | this._clearWatchdogTimers(); 2243 | let connection = this._connection; 2244 | connection.disconnect(); 2245 | // reconnect immediately 2246 | if (true === immediate) 2247 | { 2248 | this._createConnection(); 2249 | } 2250 | else 2251 | { 2252 | if (debug.enabled) 2253 | { 2254 | debug("Client (bittrex) will reconnect in %dms", this._retryDelay); 2255 | } 2256 | if (null !== this._logger) 2257 | { 2258 | this._logger.info("Client (bittrex) will reconnect in %dms", this._retryDelay); 2259 | } 2260 | this._createConnection(this._retryDelay); 2261 | } 2262 | } 2263 | 2264 | } 2265 | 2266 | module.exports = SignalRClient; 2267 | --------------------------------------------------------------------------------