├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── config.yml ├── package-lock.json ├── package.json ├── src ├── basic-client.js ├── basic-client.spec.js ├── exchanges │ ├── binance │ │ ├── binance-client.js │ │ └── binance-client.spec.js │ ├── bitfinex │ │ ├── bitfinex-client.js │ │ └── bitfinex-client.spec.js │ ├── bitflyer │ │ ├── bitflyer-client.js │ │ └── bitflyer-client.spec.js │ ├── bitmex │ │ ├── bitmex-client.js │ │ └── bitmex-client.spec.js │ ├── bitstamp │ │ ├── bitstamp-client.js │ │ └── bitstamp-client.spec.js │ ├── bittrex │ │ ├── bittrex-client.js │ │ └── bittrex-client.spec.js │ ├── gdax │ │ ├── gdax-client.js │ │ └── gdax-client.spec.js │ ├── gemini │ │ ├── gemini-client.js │ │ └── gemini-client.spec.js │ ├── hitbtc │ │ ├── hitbtc-client.js │ │ └── hitbtc-client.spec.js │ ├── huobi │ │ ├── huobi-client.js │ │ └── huobi-client.spec.js │ ├── okex │ │ ├── okex-client.js │ │ └── okex-client.spec.js │ └── poloniex │ │ ├── poloniex-client.js │ │ └── poloniex-client.spec.js ├── index.js ├── smart-wss.js ├── types │ ├── auction.js │ ├── block-trade.js │ ├── candle-stick.js │ ├── level2-point.js │ ├── level2-snapshot.js │ ├── level2-update.js │ ├── level3-point.js │ ├── level3-snapshot.js │ ├── level3-update.js │ ├── order-book.js │ ├── ticker.js │ ├── trade.js │ └── trade.spec.js ├── watcher.js └── watcher.spec.js └── test └── SimpleTest.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 8, 4 | "sourceType": "module" 5 | }, 6 | "env": { 7 | "node": true, 8 | "es6": true, 9 | "jest": true 10 | }, 11 | "extends": ["eslint:recommended"], 12 | "rules": { 13 | "no-console": 0, 14 | "semi": [2, "always"], 15 | "comma-dangle": 0 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | .idea/ 61 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth": 100 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Altangent Labs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CryptoCurrency eXchange WebSockets 2 | 3 | [![CircleCI](https://circleci.com/gh/altangent/ccew/tree/master.svg?style=shield)](https://circleci.com/gh/altangent/ccew/tree/master) 4 | [![Coverage Status](https://coveralls.io/repos/github/altangent/ccew/badge.svg?branch=master)](https://coveralls.io/github/altangent/ccew?branch=master) 5 | 6 | A JavaScript library for connecting to realtime public APIs on all cryptocurrency exchanges. 7 | 8 | ccew can be used by those wishing to use a standardized eventing interface for connection to these public APIs. Currently ccew support trade and orderbook events. Support for tickers and candle events will be added in the future. 9 | 10 | The ccew socket client performs automatic reconnection when there are disconnections. It also has silent reconnection logic to assist when no data has been seen by the client, but the socket remains open. 11 | 12 | ccew uses similar market structures to those generated by the CCXT library. This allows interoperability between the RESTful interfaces provided by CCXT and the realtime interfaces provided by ccew. 13 | 14 | ## Getting Started 15 | 16 | Install ccew 17 | 18 | ```bash 19 | npm install ccew 20 | ``` 21 | 22 | Create a new client for an exchange. Subscribe to the events that you want to listen to by supplying a market. 23 | 24 | ```javascript 25 | const ccew = require("ccew"); 26 | const binance = new ccew.Binance(); 27 | 28 | // market could be from CCXT or genearted by the user 29 | const market = { 30 | id: "ADABTC", // remote_id used by the exchange 31 | base: "ADA", // standardized base symbol for Cardano 32 | quote: "BTC", // standardized quote symbol for Bitcoin 33 | }; 34 | 35 | // handle trade events 36 | binance.on("trade", trade => console.log(trade)); 37 | 38 | // handle level2 orderbook snapshots 39 | binance.on("l2snapshot", snapshot => console.log(snapshot)); 40 | 41 | // subscribe to trades 42 | binance.subscribeTrades(market); 43 | 44 | // subscribe to level2 orderbook snapshots 45 | binance.subscribeLevel2Snapshots(market); 46 | ``` 47 | 48 | ## Exchanges 49 | 50 | | Exchange | Class | Ticker | Trades | OB-L2 Snapshot | OB-L2 Updates | OB-L3 Snapshot | OB-L3 Updates | 51 | | -------- | -------- | ------- | ------- | -------------- | ------------- | -------------- | ------------- | 52 | | Binance | binance | Support | Support | Support | Support | - | - | 53 | | Bitfinex | bitfinex | Support | Support | - | Support\* | - | Support\* | 54 | | bitFlyer | bitflyer | Support | Support | - | Support | - | - | 55 | | BitMEX | bitmex | | Support | - | Support\* | - | - | 56 | | Bitstamp | bitstamp | - | Support | Support | Support | - | Support | 57 | | Bittrex | bittrex | Support | Support | - | Support\* | - | - | 58 | | GDAX | gdax | Support | Support | - | Support\* | - | Support | 59 | | Gemini | gemini | - | Support | - | Support\* | - | - | 60 | | HitBTC | hitbtc | Support | Support | - | Support\* | - | - | 61 | | Huobi | huobi | Support | Support | Support | - | - | - | 62 | | OKEx | okex | Support | Support | Support | Support | - | - | 63 | | Poloniex | poloniex | Support | Support | - | Support\* | - | - | 64 | 65 | Notes: 66 | 67 | * Support\*: Broadcasts a snapshot event at startup 68 | 69 | ## Definitions 70 | 71 | Trades - A maker/taker match has been made. Broadcast as an aggregated event. 72 | 73 | Orderbook level 2 - has aggregated price points for bids/asks that include the price and total volume at that point. Some exchange may include the number of orders making up the volume at that price point. 74 | 75 | Orderbook level 3 - this is the most granual order book information. It has raw order information for bids/asks that can be used to build aggregated volume information for the price points. 76 | 77 | ## API 78 | 79 | ### `Market` 80 | 81 | Markets are used as input to many of the client functions. Markets can be generated and stored by you the developer or loaded from the CCXT library. 82 | 83 | The following properties are used by ccew. 84 | 85 | * `id: string` - the identifier used by the remote exchange 86 | * `base: string` - the normalized base symbol for the market 87 | * `quote: string` - the normalized quote symbol for the market 88 | 89 | ### `Client` 90 | 91 | A websocket client that connects to a specific exchange. There is an implementation of this class for each exchange that governs the specific rules for managing the realtime connections to the exchange. You must instantiate the specific exchanges client to conncet to the exchange. 92 | 93 | ```javascript 94 | const binance = new ccew.Binance(); 95 | const gdax = new ccew.GDAX(); 96 | ``` 97 | 98 | #### Properties 99 | 100 | ##### `reconnectIntervalMs: int` - default 90000 101 | 102 | Property that controls silent socket drop checking. This will enable a check at the reconnection interval that looks for ANY broadcast message from the server. If there has not been a message since the last check a reconnection (close, connect) operation is performed. This property must be set before the first subscription (and subsequent connection). 103 | 104 | #### Events 105 | 106 | Subscribe to events by addding an event handler to the client `.on()` method of the client. Multiple event handlers can be added for the same event. 107 | 108 | Once an event handler is attached you can start the stream using the `subscribe` methods. 109 | 110 | ```javascript 111 | binance.on("trades", trade => console.log(trade)); 112 | binance.on("l2snapshot", snapshot => console.log(snapshot)); 113 | ``` 114 | 115 | ##### `ticker: Ticker` 116 | 117 | Fired when a ticker update is received. Returns an instance of `Ticker`. 118 | 119 | ##### `trade: Trade` 120 | 121 | Fired when a trade is received. Returns an instance of `Trade`. 122 | 123 | ##### `l2snapshot: Level2Snapshot` 124 | 125 | Fired when a orderbook level 2 snapshot is received. Returns an instance of `Level2Snapshot`. 126 | 127 | The level of detail will depend on the specific exchange and may include 5/10/20/50/100/1000 bids and asks. 128 | 129 | This event is also fired when subscribing to the `l2update` event on many exchanges. 130 | 131 | ##### `l2update: Level2Update` 132 | 133 | Fired when a orderbook level 2 update is recieved. Returns an instance of `Level2Update`. 134 | 135 | Subscribing to this event may trigger an initial `l2snapshot` event for many exchanges. 136 | 137 | ##### `l3snapshot: Level3Snapshot` 138 | 139 | Fired when a orderbook level 3 snapshot is received. Returns an instance of `Level3Snapshot`. 140 | 141 | ##### `l3update: Level3Update` - orderbook level 3 Update 142 | 143 | Fired when a level 3 update is recieved. Returns an instance of `Level3Update`. 144 | 145 | #### Methods 146 | 147 | ##### `subscribeTicker(market): void` 148 | 149 | Subscribes to a ticker feed for a market. This method will cause the client to emit `ticker` events that have a payload of the `Ticker` object. 150 | 151 | ##### `unsubscribeTicker(market): void` 152 | 153 | Unsubscribes from a ticker feed for a market. 154 | 155 | ##### `subscribeTrades(market): void` 156 | 157 | Subscribes to a trade feed for a market. This method will cause the client to emit `trade` events that have a payload of the `Trade` object. 158 | 159 | ##### `unsubscribeTrades(market): void` 160 | 161 | Unsubscribes from a trade feed for a market. 162 | 163 | \*For some exchanges, calling unsubscribe may cause a temporary disruption in all feeds. 164 | 165 | ##### `subscribeLevel2Snapshots(market): void` 166 | 167 | Subscribes to the orderbook level 2 snapshot feed for a market. This method will cause the client to emit `l2snapshot` events that have a payload of the `Level2Snaphot` object. 168 | 169 | This method is a no-op for exchanges that do not support level 2 snapshot subscriptions. 170 | 171 | ##### `unsubscribeLevel2Snapshots(market): void` 172 | 173 | Unbusbscribes from the orderbook level 2 snapshot for a market. 174 | 175 | \*For some exchanges, calling unsubscribe may cause a temporary disruption in all feeds. 176 | 177 | ##### `subscribeLevel2Updates(market): void` 178 | 179 | Subscribes to the orderbook level 2 update feed for a market. This method will cause the client to emit `l2update` events that have a payload of the `Level2Update` object. 180 | 181 | This method is a no-op for exchanges that do not support level 2 snapshot subscriptions. 182 | 183 | ##### `unsubscribeLevel2Updates(market): void` 184 | 185 | Unbusbscribes from the orderbook level 2 updates for a market. 186 | 187 | \*For some exchanges, calling unsubscribe may cause a temporary disruption in all feeds. 188 | 189 | ##### `subscribeLevel3Snapshots(market): void` 190 | 191 | Subscribes to the orderbook level 3 snapshot feed for a market. This method will cause the client to emit `l3snapshot` events that have a payload of the `Level3Snaphot` object. 192 | 193 | This method is a no-op for exchanges that do not support level 2 snapshot subscriptions. 194 | 195 | ##### `unsubscribeLevel3Snapshots(market): void` 196 | 197 | Unbusbscribes from the orderbook level 3 snapshot for a market. 198 | 199 | \*For some exchanges, calling unsubscribe may cause a temporary disruption in all feeds. 200 | 201 | ##### `subscribeLevel3Updates(market): void` 202 | 203 | Subscribes to the orderbook level 3 update feed for a market. This method will cause the client to emit `l3update` events that have a payload of the `Level3Update` object. 204 | 205 | This method is a no-op for exchanges that do not support level 3 snapshot subscriptions. 206 | 207 | ##### `unsubscribeLevel3Updates(market): void` 208 | 209 | Unbusbscribes from the orderbook level 3 updates for a market. 210 | 211 | \*For some exchanges, calling unsubscribe may cause a temporary disruption in all feeds. 212 | 213 | ### `Ticker` 214 | 215 | The ticker class is the result of a `ticker` event. 216 | 217 | #### Properties 218 | 219 | * `fullId: string` - the normalized market id prefixed with the exchange, ie: `Binance:LTC/BTC` 220 | * `exchange: string` - the name of the exchange 221 | * `base: string` - the normalized base symbol for the market 222 | * `quote: string` - the normalized quote symbol for the market 223 | * `timestamp: int` - the unix timestamp in milliseconds 224 | * `last: string` - the last price of a match that caused a tick 225 | * `open: string` - the price 24 hours ago 226 | * `low: string` - the highest price in the last 24 hours 227 | * `high: string` - the lowest price in the last 24 hours 228 | * `volume: string` - the base volume traded in the last 24 hours 229 | * `quoteVolume: string` - the quote volume traded in the last 24 hours 230 | * `change: string` - the price change (last - open) 231 | * `changePercent: string` - the price change in percent (last - open) / open \* 100 232 | * `bid: string` - the best bid price 233 | * `bidVolume: string` - the volume at the best bid price 234 | * `ask: string` - the best ask price 235 | * `askVolume: string` - the volume at the best ask price 236 | 237 | ### `Trade` 238 | 239 | The trade class is the result of a `trade` event emitted from a client. 240 | 241 | #### Properties 242 | 243 | * `fullId: string` - the normalized market id prefixed with the exchange, ie: `Binance:LTC/BTC` 244 | * `exchange: string` - the name of the exchange 245 | * `base: string` - the normalized base symbol for the market 246 | * `quote: string` - the normalized quote symbol for the market 247 | * `tradeId: int` - the unique trade identifer from the exchanges feed 248 | * `unix: int` - the unix timestamp in milliseconds for when the trade executed 249 | * `side: string` - whether the buyer `buy` or seller `sell` was the maker for the match 250 | * `price: string` - the price at which the match executed 251 | * `amount: string` - the amount executed in the match 252 | 253 | ### `Level2Point` 254 | 255 | Represents a price point in a level 2 orderbook 256 | 257 | #### Properties 258 | 259 | * `price: string` - price 260 | * `size: string` - aggregated volume for all orders at this price point 261 | * `count: int` - optional number of orders aggregated into the price point 262 | 263 | ### `Level2Snapshot` 264 | 265 | The level 2 snapshot class is the result of a `l2snapshot` or `l2update` event emitted from the client. 266 | 267 | #### Properties 268 | 269 | * `fullId: string` - the normalized market id prefixed with the exchange, ie: `Binance:LTC/BTC` 270 | * `exchange: string` - the name of the exchange 271 | * `base: string` - the normalized base symbol for the market 272 | * `quote: string` - the normalized quote symbol for the market 273 | * `timestampMs: int` - optional timestamp in milliseconds for the snapshot 274 | * `sequenceId: int` - optional sequence identifier for the snapshot 275 | * `asks: [Level2Point]` - the ask (seller side) price points 276 | * `bids: [Level2Point]` - the bid (buyer side) price points 277 | 278 | ### `Level2Update` 279 | 280 | The level 2 update class is a result of a `l2update` event emitted from the client. It consists of a collection of bids/asks even exchanges broadcast single events at a time. 281 | 282 | #### Properties 283 | 284 | * `fullId: string` - the normalized market id prefixed with the exchange, ie: `Binance:LTC/BTC` 285 | * `exchange: string` - the name of the exchange 286 | * `base: string` - the normalized base symbol for the market 287 | * `quote: string` - the normalized quote symbol for the market 288 | * `timestampMs: int` - optional timestamp in milliseconds for the snapshot 289 | * `sequenceId: int` - optional sequence identifier for the snapshot 290 | * `asks: [Level2Point]` - the ask (seller side) price points 291 | * `bids: [Level2Point]` - the bid (buyer side) price points 292 | 293 | ### `Level3Point` 294 | 295 | Represents a price point in a level 3 orderbook 296 | 297 | #### Properties 298 | 299 | * `orderId: string` - identifier for the order 300 | * `price: string` - price 301 | * `size: string` - volume of the order 302 | * `meta: object` - optional exchange specific metadata with additional information about the update. 303 | 304 | ### `Level3Snapshot` 305 | 306 | The level 3 snapshot class is the result of a `l3snapshot` or `l3update` event emitted from the client. 307 | 308 | #### Properties 309 | 310 | * `fullId: string` - the normalized market id prefixed with the exchange, ie: `Binance:LTC/BTC` 311 | * `exchange: string` - the name of the exchange 312 | * `base: string` - the normalized base symbol for the market 313 | * `quote: string` - the normalized quote symbol for the market 314 | * `timestampMs: int` - optional timestamp in milliseconds for the snapshot 315 | * `sequenceId: int` - optional sequence identifier for the snapshot 316 | * `asks: [Level3Point]` - the ask (seller side) price points 317 | * `bids: [Level3Point]` - the bid (buyer side) price points 318 | 319 | ### `Level3Update` 320 | 321 | The level 3 update class is a result of a `l3update` event emitted from the client. It consists of a collection of bids/asks even exchanges broadcast single events at a time. 322 | 323 | Additional metadata is often provided in the `meta` property that has more detailed information that is often required to propertly manage a level 3 orderbook. 324 | 325 | #### Properties 326 | 327 | * `fullId: string` - the normalized market id prefixed with the exchange, ie: `Binance:LTC/BTC` 328 | * `exchange: string` - the name of the exchange 329 | * `base: string` - the normalized base symbol for the market 330 | * `quote: string` - the normalized quote symbol for the market 331 | * `timestampMs: int` - optional timestamp in milliseconds for the snapshot 332 | * `sequenceId: int` - optional sequence identifier for the snapshot 333 | * `asks: [Level3Point]` - the ask (seller side) price points 334 | * `bids: [Level3Point]` - the bid (buyer side) price points 335 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:8.11 6 | 7 | working_directory: ~/repo 8 | 9 | steps: 10 | - checkout 11 | 12 | # Download and cache dependencies 13 | - restore_cache: 14 | keys: 15 | - v1-dependencies-{{ checksum "package.json" }} 16 | # fallback to using the latest cache if no exact match is found 17 | - v1-dependencies- 18 | 19 | - run: npm install 20 | 21 | - save_cache: 22 | paths: 23 | - node_modules 24 | key: v1-dependencies-{{ checksum "package.json" }} 25 | 26 | # run tests! 27 | - run: npm run test:ci 28 | 29 | 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ccew", 3 | "version": "0.11.0", 4 | "description": "Cryptocurrency exchange websocket feeds", 5 | "keywords": [ 6 | "cryptocurrency", 7 | "exchange", 8 | "websockets", 9 | "realtime", 10 | "feeds", 11 | "bitcoin", 12 | "ethereum", 13 | "litecoin" 14 | ], 15 | "author": "Brian Mancini ", 16 | "license": "MIT", 17 | "main": "src/index.js", 18 | "scripts": { 19 | "test": "jest --coverage", 20 | "test:ci": "jest --coverage && cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 21 | }, 22 | "repository": "saytoken/ccew", 23 | "dependencies": { 24 | "cloudscraper": "^1.5.0", 25 | "jsonic": "^0.3.0", 26 | "moment": "^2.22.1", 27 | "pusher-js": "^4.2.2", 28 | "semaphore": "^1.1.0", 29 | "signalr-client": "0.0.17", 30 | "winston": "^2.4.2", 31 | "ws": "^5.1.1" 32 | }, 33 | "devDependencies": { 34 | "coveralls": "^3.0.1", 35 | "eslint": "^4.19.1", 36 | "jest": "^22.4.3", 37 | "prettier": "^1.12.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/basic-client.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require("events"); 2 | const winston = require("winston"); 3 | const SmartWss = require("./smart-wss"); 4 | const Watcher = require("./watcher"); 5 | 6 | /** 7 | * Single websocket connection client with 8 | * subscribe and unsubscribe methods. It is also an EventEmitter 9 | * and broadcasts 'trade' events. 10 | * 11 | * Anytime the WSS client connects (such as a reconnect) 12 | * it run the _onConnected method and will resubscribe. 13 | */ 14 | class BasicTradeClient extends EventEmitter { 15 | constructor(wssPath, name) { 16 | super(); 17 | this._wssPath = wssPath; 18 | this._name = name; 19 | this._tickerSubs = new Map(); 20 | this._tradeSubs = new Map(); 21 | this._level2SnapshotSubs = new Map(); 22 | this._level2UpdateSubs = new Map(); 23 | this._level3UpdateSubs = new Map(); 24 | this._wss = undefined; 25 | this._watcher = new Watcher(this); 26 | 27 | this.hasTickers = false; 28 | this.hasTrades = true; 29 | this.hasLevel2Snapshots = false; 30 | this.hasLevel2Updates = false; 31 | this.hasLevel3Updates = false; 32 | } 33 | 34 | ////////////////////////////////////////////// 35 | 36 | close(emitClosed = true) { 37 | this._watcher.stop(); 38 | if (this._wss) { 39 | this._wss.close(); 40 | this._wss = undefined; 41 | } 42 | if (emitClosed) this.emit("closed"); 43 | } 44 | 45 | reconnect() { 46 | this.close(false); 47 | this._connect(); 48 | this.emit("reconnected"); 49 | } 50 | 51 | subscribeTicker(market) { 52 | if (!this.hasTickers) return; 53 | this._subscribe( 54 | market, 55 | this._tickerSubs, 56 | "subscribing to ticker", 57 | this._sendSubTicker.bind(this) 58 | ); 59 | } 60 | 61 | unsubscribeTicker(market) { 62 | if (!this.hasTickers) return; 63 | this._unsubscribe( 64 | market, 65 | this._tickerSubs, 66 | "unsubscribing from ticker", 67 | this._sendUnsubTicker.bind(this) 68 | ); 69 | } 70 | 71 | subscribeTrades(market) { 72 | if (!this.hasTrades) return; 73 | this._subscribe( 74 | market, 75 | this._tradeSubs, 76 | "subscribing to trades", 77 | this._sendSubTrades.bind(this) 78 | ); 79 | } 80 | 81 | unsubscribeTrades(market) { 82 | if (!this.hasTrades) return; 83 | this._unsubscribe( 84 | market, 85 | this._tradeSubs, 86 | "unsubscribing from trades", 87 | this._sendUnsubTrades.bind(this) 88 | ); 89 | } 90 | 91 | subscribeLevel2Snapshots(market) { 92 | if (!this.hasLevel2Snapshots) return; 93 | this._subscribe( 94 | market, 95 | this._level2SnapshotSubs, 96 | "subscribing to level 2 snapshots", 97 | this._sendSubLevel2Snapshots.bind(this) 98 | ); 99 | } 100 | 101 | unsubscribeLevel2Snapshots(market) { 102 | if (!this.hasLevel2Snapshots) return; 103 | this._unsubscribe( 104 | market, 105 | this._level2SnapshotSubs, 106 | "unsubscribing from level 2 snapshots", 107 | this._sendUnsubLevel2Snapshots.bind(this) 108 | ); 109 | } 110 | 111 | subscribeLevel2Updates(market) { 112 | if (!this.hasLevel2Updates) return; 113 | this._subscribe( 114 | market, 115 | this._level2UpdateSubs, 116 | "subscribing to level 2 updates", 117 | this._sendSubLevel2Updates.bind(this) 118 | ); 119 | } 120 | 121 | unsubscribeLevel2Updates(market) { 122 | if (!this.hasLevel2Updates) return; 123 | this._unsubscribe( 124 | market, 125 | this._level2UpdateSubs, 126 | "unsubscribing to level 2 updates", 127 | this._sendUnsubLevel2Updates.bind(this) 128 | ); 129 | } 130 | 131 | subscribeLevel3Updates(market) { 132 | if (!this.hasLevel3Updates) return; 133 | this._subscribe( 134 | market, 135 | this._level3UpdateSubs, 136 | "subscribing to level 3 updates", 137 | this._sendSubLevel3Updates.bind(this) 138 | ); 139 | } 140 | 141 | unsubscribeLevel3Updates(market) { 142 | if (!this.hasLevel3Updates) return; 143 | this._unsubscribe( 144 | market, 145 | this._level3UpdateSubs, 146 | "unsubscribing from level 3 updates", 147 | this._sendUnsubLevel3Updates.bind(this) 148 | ); 149 | } 150 | 151 | //////////////////////////////////////////// 152 | // PROTECTED 153 | 154 | /** 155 | * Helper function for performing a subscription operation 156 | * where a subscription map is maintained and the message 157 | * send operation is performed 158 | * @param {Market} market 159 | * @param {Map}} map 160 | * @param {String} msg 161 | * @param {Function} sendFn 162 | */ 163 | _subscribe(market, map, msg, sendFn) { 164 | this._connect(); 165 | let remote_id = market.id; 166 | if (!map.has(remote_id)) { 167 | winston.info(msg, this._name, remote_id); 168 | map.set(remote_id, market); 169 | 170 | // perform the subscription if we're connected 171 | // and if not, then we'll reply on the _onConnected event 172 | // to send the signal to our server! 173 | if (this._wss.isConnected) { 174 | sendFn(remote_id); 175 | } 176 | } 177 | } 178 | 179 | /** 180 | * Helper function for performing an unsubscription operation 181 | * where a subscription map is maintained and the message 182 | * send operation is performed 183 | * @param {Market} market 184 | * @param {Map}} map 185 | * @param {String} msg 186 | * @param {Function} sendFn 187 | */ 188 | _unsubscribe(market, map, msg, sendFn) { 189 | let remote_id = market.id; 190 | if (map.has(remote_id)) { 191 | winston.info("unsubscribing from", this._name, remote_id); 192 | map.delete(remote_id); 193 | 194 | if (this._wss.isConnected) { 195 | sendFn(remote_id); 196 | } 197 | } 198 | } 199 | 200 | /** 201 | * Idempotent method for creating and initializing 202 | * a long standing web socket client. This method 203 | * is only called in the subscribe method. Multiple calls 204 | * have no effect. 205 | */ 206 | _connect() { 207 | if (!this._wss) { 208 | this._wss = new SmartWss(this._wssPath); 209 | this._wss.on("open", this._onConnected.bind(this)); 210 | this._wss.on("message", this._onMessage.bind(this)); 211 | this._wss.on("disconnected", this._onDisconnected.bind(this)); 212 | this._wss.connect(); 213 | } 214 | } 215 | 216 | /** 217 | * This method is fired anytime the socket is opened, whether 218 | * the first time, or any subsequent reconnects. This allows 219 | * the socket to immediate trigger resubscription to relevent 220 | * feeds 221 | */ 222 | _onConnected() { 223 | this.emit("connected"); 224 | for (let marketSymbol of this._tickerSubs.keys()) { 225 | this._sendSubTicker(marketSymbol); 226 | } 227 | for (let marketSymbol of this._tradeSubs.keys()) { 228 | this._sendSubTrades(marketSymbol); 229 | } 230 | for (let marketSymbol of this._level2SnapshotSubs.keys()) { 231 | this._sendSubLevel2Snapshots(marketSymbol); 232 | } 233 | for (let marketSymbol of this._level2UpdateSubs.keys()) { 234 | this._sendSubLevel2Updates(marketSymbol); 235 | } 236 | for (let marketSymbol of this._level3UpdateSubs.keys()) { 237 | this._sendSubLevel3Updates(marketSymbol); 238 | } 239 | this._watcher.start(); 240 | } 241 | 242 | /** 243 | * Handles a disconnection event 244 | */ 245 | _onDisconnected() { 246 | this._watcher.stop(); 247 | this.emit("disconnected"); 248 | } 249 | 250 | //////////////////////////////////////////// 251 | // ABSTRACT 252 | 253 | /* istanbul ignore next */ 254 | _onMessage() { 255 | throw new Error("not implemented"); 256 | } 257 | 258 | /* istanbul ignore next */ 259 | _sendSubTicker() { 260 | throw new Error("not implemented"); 261 | } 262 | 263 | /* istanbul ignore next */ 264 | _sendUnsubTicker() { 265 | throw new Error("not implemented"); 266 | } 267 | 268 | /* istanbul ignore next */ 269 | _sendSubTrades() { 270 | throw new Error("not implemented"); 271 | } 272 | 273 | /* istanbul ignore next */ 274 | _sendUnsubTrades() { 275 | throw new Error("not implemented"); 276 | } 277 | 278 | /* istanbul ignore next */ 279 | _sendSubLevel2Snapshots() { 280 | throw new Error("not implemented"); 281 | } 282 | 283 | /* istanbul ignore next */ 284 | _sendSubLevel2Updates() { 285 | throw new Error("not implemented"); 286 | } 287 | 288 | /* istanbul ignore next */ 289 | _sendSubLevel3Updates() { 290 | throw new Error("not implemented"); 291 | } 292 | } 293 | 294 | module.exports = BasicTradeClient; 295 | -------------------------------------------------------------------------------- /src/basic-client.spec.js: -------------------------------------------------------------------------------- 1 | let BasicClient = require("./basic-client"); 2 | jest.mock("winston", () => ({ info: jest.fn() })); 3 | jest.mock("./smart-wss", () => { 4 | return function mockSmartWss() { 5 | return { 6 | _events: {}, 7 | connect: jest.fn(), 8 | close: jest.fn(), 9 | on: function(event, handler) { 10 | this._events[event] = handler; 11 | }, 12 | mockEmit: function(event, payload) { 13 | if (event === "open") this.isConnected = true; 14 | this._events[event](payload); 15 | }, 16 | isConnected: false, 17 | }; 18 | }; 19 | }); 20 | 21 | function buildInstance() { 22 | let instance = new BasicClient("wss://localhost/test", "test"); 23 | instance._watcher.intervalMs = 100; 24 | instance.hasTickers = true; 25 | instance.hasLevel2Snapshots = true; 26 | instance.hasLevel2Updates = true; 27 | instance.hasLevel3Updates = true; 28 | instance._onMessage = jest.fn(); 29 | instance._sendSubTicker = jest.fn(); 30 | instance._sendUnsubTicker = jest.fn(); 31 | instance._sendSubTrades = jest.fn(); 32 | instance._sendUnsubTrades = jest.fn(); 33 | instance._sendSubLevel2Snapshots = jest.fn(); 34 | instance._sendUnsubLevel2Snapshots = jest.fn(); 35 | instance._sendSubLevel2Updates = jest.fn(); 36 | instance._sendUnsubLevel2Updates = jest.fn(); 37 | instance._sendSubLevel3Updates = jest.fn(); 38 | instance._sendUnsubLevel3Updates = jest.fn(); 39 | 40 | jest.spyOn(instance._watcher, "start"); 41 | jest.spyOn(instance._watcher, "stop"); 42 | return instance; 43 | } 44 | 45 | let instance; 46 | 47 | function wait(ms) { 48 | return new Promise(resolve => setTimeout(resolve, ms)); 49 | } 50 | 51 | beforeAll(() => { 52 | instance = buildInstance(); 53 | instance._connect(); 54 | }); 55 | 56 | describe("on first subscribe", () => { 57 | test("it should open a connection", () => { 58 | instance.subscribeTrades({ id: "BTCUSD" }); 59 | expect(instance._wss).toBeDefined(); 60 | expect(instance._wss.connect.mock.calls.length).toBe(1); 61 | }); 62 | test("it should send subscribe to the socket", () => { 63 | instance._wss.mockEmit("open"); 64 | expect(instance._sendSubTrades.mock.calls.length).toBe(1); 65 | expect(instance._sendSubTrades.mock.calls[0][0]).toBe("BTCUSD"); 66 | }); 67 | test("it should start the watcher", () => { 68 | expect(instance._watcher.start).toHaveBeenCalledTimes(1); 69 | }); 70 | }); 71 | 72 | describe("on subsequent subscribes", () => { 73 | test("it should not connect again", () => { 74 | instance.subscribeTrades({ id: "LTCBTC" }); 75 | expect(instance._wss.connect.mock.calls.length).toBe(1); 76 | }); 77 | test("it should send subscribe to the socket", () => { 78 | expect(instance._sendSubTrades.mock.calls.length).toBe(2); 79 | expect(instance._sendSubTrades.mock.calls[1][0]).toBe("LTCBTC"); 80 | }); 81 | }); 82 | 83 | describe("on duplicate subscribe", () => { 84 | test("it should not send subscribe to the socket", () => { 85 | instance.subscribeTrades({ id: "LTCBTC" }); 86 | expect(instance._sendSubTrades.mock.calls.length).toBe(2); 87 | }); 88 | }); 89 | 90 | describe("on message", () => { 91 | beforeAll(() => { 92 | instance._wss.mockEmit("message", "test"); 93 | }); 94 | test("it should call on message", () => { 95 | expect(instance._onMessage.mock.calls[0][0]).toBe("test"); 96 | }); 97 | }); 98 | 99 | describe("on reconnect", () => { 100 | test("it should resubscribe to markets", () => { 101 | instance._wss.mockEmit("open"); 102 | expect(instance._sendSubTrades.mock.calls.length).toBe(4); 103 | expect(instance._sendSubTrades.mock.calls[2][0]).toBe("BTCUSD"); 104 | expect(instance._sendSubTrades.mock.calls[3][0]).toBe("LTCBTC"); 105 | }); 106 | }); 107 | 108 | describe("on unsubscribe", () => { 109 | test("it should send unsubscribe to socket", () => { 110 | instance.unsubscribeTrades({ id: "LTCBTC" }); 111 | expect(instance._sendUnsubTrades.mock.calls.length).toBe(1); 112 | expect(instance._sendUnsubTrades.mock.calls[0][0]).toBe("LTCBTC"); 113 | }); 114 | }); 115 | 116 | describe("on duplicate unsubscribe", () => { 117 | test("it should not send unsubscribe to the socket", () => { 118 | instance.unsubscribeTrades({ id: "LTCBTC" }); 119 | expect(instance._sendUnsubTrades.mock.calls.length).toBe(1); 120 | }); 121 | }); 122 | 123 | describe("when no messages received", () => { 124 | let originalWss; 125 | let closedEvent = jest.fn(); 126 | let reconnectedEvent = jest.fn(); 127 | beforeAll(async () => { 128 | originalWss = instance._wss; 129 | instance.on("closed", closedEvent); 130 | instance.on("reconnected", reconnectedEvent); 131 | instance.emit("trade"); // triggers the connection watcher 132 | await wait(300); 133 | }); 134 | test("it should close the connection", () => { 135 | expect(originalWss.close.mock.calls.length).toBe(1); 136 | }); 137 | test("it should not emit a closed event", () => { 138 | expect(closedEvent.mock.calls.length).toBe(0); 139 | }); 140 | test("it should reopen the connection", () => { 141 | expect(instance._wss).not.toEqual(originalWss); 142 | expect(instance._wss.connect.mock.calls.length).toBe(1); 143 | }); 144 | test("should emit a reconnected event", () => { 145 | expect(reconnectedEvent.mock.calls.length).toBe(1); 146 | }); 147 | }); 148 | 149 | describe("when connected, on disconnect", () => { 150 | test("disconnect event should fire if the underlying socket closes", done => { 151 | instance._watcher.stop.mockClear(); 152 | instance.on("disconnected", done); 153 | instance._wss.mockEmit("disconnected"); 154 | }); 155 | 156 | test("close should stop the reconnection checker", () => { 157 | expect(instance._watcher.stop).toHaveBeenCalledTimes(1); 158 | }); 159 | }); 160 | 161 | describe("when connected, stop", () => { 162 | test("close should emit closed event", done => { 163 | instance._watcher.stop.mockClear(); 164 | instance.on("closed", done); 165 | instance.close(); 166 | }); 167 | 168 | test("close should stop the reconnection checker", () => { 169 | expect(instance._watcher.stop).toHaveBeenCalledTimes(1); 170 | }); 171 | }); 172 | 173 | describe("when already closed", () => { 174 | test("it should still emit closed event", done => { 175 | instance.on("closed", done); 176 | instance.close(); 177 | }); 178 | }); 179 | 180 | describe("level2 snapshots", () => { 181 | let instance; 182 | 183 | beforeAll(() => { 184 | instance = buildInstance(); 185 | instance._connect(); 186 | }); 187 | 188 | describe("on first subscribe", () => { 189 | test("it should open a connection", () => { 190 | instance.subscribeLevel2Snapshots({ id: "BTCUSD" }); 191 | expect(instance._wss).toBeDefined(); 192 | expect(instance._wss.connect.mock.calls.length).toBe(1); 193 | }); 194 | test("it should send subscribe to the socket", () => { 195 | instance._wss.mockEmit("open"); 196 | expect(instance._sendSubLevel2Snapshots.mock.calls.length).toBe(1); 197 | expect(instance._sendSubLevel2Snapshots.mock.calls[0][0]).toBe("BTCUSD"); 198 | }); 199 | test("it should start the reconnectChecker", () => { 200 | expect(instance._watcher.start).toHaveBeenCalledTimes(1); 201 | }); 202 | }); 203 | 204 | describe("on subsequent subscribes", () => { 205 | test("it should not connect again", () => { 206 | instance.subscribeLevel2Snapshots({ id: "LTCBTC" }); 207 | expect(instance._wss.connect.mock.calls.length).toBe(1); 208 | }); 209 | test("it should send subscribe to the socket", () => { 210 | expect(instance._sendSubLevel2Snapshots.mock.calls.length).toBe(2); 211 | expect(instance._sendSubLevel2Snapshots.mock.calls[1][0]).toBe("LTCBTC"); 212 | }); 213 | }); 214 | 215 | describe("on unsubscribe", () => { 216 | test("it should send unsubscribe to socket", () => { 217 | instance.unsubscribeLevel2Snapshots({ id: "LTCBTC" }); 218 | expect(instance._sendUnsubLevel2Snapshots.mock.calls.length).toBe(1); 219 | expect(instance._sendUnsubLevel2Snapshots.mock.calls[0][0]).toBe("LTCBTC"); 220 | }); 221 | }); 222 | }); 223 | 224 | describe("level2 updates", () => { 225 | let instance; 226 | 227 | beforeAll(() => { 228 | instance = buildInstance(); 229 | instance._connect(); 230 | }); 231 | 232 | describe("on first subscribe", () => { 233 | test("it should open a connection", () => { 234 | instance.subscribeLevel2Updates({ id: "BTCUSD" }); 235 | expect(instance._wss).toBeDefined(); 236 | expect(instance._wss.connect.mock.calls.length).toBe(1); 237 | }); 238 | test("it should send subscribe to the socket", () => { 239 | instance._wss.mockEmit("open"); 240 | expect(instance._sendSubLevel2Updates.mock.calls.length).toBe(1); 241 | expect(instance._sendSubLevel2Updates.mock.calls[0][0]).toBe("BTCUSD"); 242 | }); 243 | test("it should start the reconnectChecker", () => { 244 | expect(instance._watcher.start).toHaveBeenCalledTimes(1); 245 | }); 246 | }); 247 | 248 | describe("on subsequent subscribes", () => { 249 | test("it should not connect again", () => { 250 | instance.subscribeLevel2Updates({ id: "LTCBTC" }); 251 | expect(instance._wss.connect.mock.calls.length).toBe(1); 252 | }); 253 | test("it should send subscribe to the socket", () => { 254 | expect(instance._sendSubLevel2Updates.mock.calls.length).toBe(2); 255 | expect(instance._sendSubLevel2Updates.mock.calls[1][0]).toBe("LTCBTC"); 256 | }); 257 | }); 258 | 259 | describe("on unsubscribe", () => { 260 | test("it should send unsubscribe to socket", () => { 261 | instance.unsubscribeLevel2Updates({ id: "LTCBTC" }); 262 | expect(instance._sendUnsubLevel2Updates.mock.calls.length).toBe(1); 263 | expect(instance._sendUnsubLevel2Updates.mock.calls[0][0]).toBe("LTCBTC"); 264 | }); 265 | }); 266 | }); 267 | 268 | describe("level3 updates", () => { 269 | let instance; 270 | 271 | beforeAll(() => { 272 | instance = buildInstance(); 273 | instance._connect(); 274 | }); 275 | 276 | describe("on first subscribe", () => { 277 | test("it should open a connection", () => { 278 | instance.subscribeLevel3Updates({ id: "BTCUSD" }); 279 | expect(instance._wss).toBeDefined(); 280 | expect(instance._wss.connect.mock.calls.length).toBe(1); 281 | }); 282 | test("it should send subscribe to the socket", () => { 283 | instance._wss.mockEmit("open"); 284 | expect(instance._sendSubLevel3Updates.mock.calls.length).toBe(1); 285 | expect(instance._sendSubLevel3Updates.mock.calls[0][0]).toBe("BTCUSD"); 286 | }); 287 | test("it should start the reconnectChecker", () => { 288 | expect(instance._watcher.start).toHaveBeenCalledTimes(1); 289 | }); 290 | }); 291 | 292 | describe("on subsequent subscribes", () => { 293 | test("it should not connect again", () => { 294 | instance.subscribeLevel3Updates({ id: "LTCBTC" }); 295 | expect(instance._wss.connect.mock.calls.length).toBe(1); 296 | }); 297 | test("it should send subscribe to the socket", () => { 298 | expect(instance._sendSubLevel3Updates.mock.calls.length).toBe(2); 299 | expect(instance._sendSubLevel3Updates.mock.calls[1][0]).toBe("LTCBTC"); 300 | }); 301 | }); 302 | 303 | describe("on unsubscribe", () => { 304 | test("it should send unsubscribe to socket", () => { 305 | instance.unsubscribeLevel3Updates({ id: "LTCBTC" }); 306 | expect(instance._sendUnsubLevel3Updates.mock.calls.length).toBe(1); 307 | expect(instance._sendUnsubLevel3Updates.mock.calls[0][0]).toBe("LTCBTC"); 308 | }); 309 | }); 310 | }); 311 | 312 | describe("ticker", () => { 313 | let instance; 314 | 315 | beforeAll(() => { 316 | instance = buildInstance(); 317 | instance._connect(); 318 | }); 319 | 320 | describe("on first subscribe", () => { 321 | test("it should open a connection", () => { 322 | instance.subscribeTicker({ id: "BTCUSD" }); 323 | expect(instance._wss).toBeDefined(); 324 | expect(instance._wss.connect.mock.calls.length).toBe(1); 325 | }); 326 | test("it should send subscribe to the socket", () => { 327 | instance._wss.mockEmit("open"); 328 | expect(instance._sendSubTicker.mock.calls.length).toBe(1); 329 | expect(instance._sendSubTicker.mock.calls[0][0]).toBe("BTCUSD"); 330 | }); 331 | test("it should start the reconnectChecker", () => { 332 | expect(instance._watcher.start).toHaveBeenCalledTimes(1); 333 | }); 334 | }); 335 | 336 | describe("on subsequent subscribes", () => { 337 | test("it should not connect again", () => { 338 | instance.subscribeTicker({ id: "LTCBTC" }); 339 | expect(instance._wss.connect.mock.calls.length).toBe(1); 340 | }); 341 | test("it should send subscribe to the socket", () => { 342 | expect(instance._sendSubTicker.mock.calls.length).toBe(2); 343 | expect(instance._sendSubTicker.mock.calls[1][0]).toBe("LTCBTC"); 344 | }); 345 | }); 346 | 347 | // describe("on unsubscribe", () => { 348 | // test("it should send unsubscribe to socket", () => { 349 | // instance.unsubscribeTicker({ id: "LTCBTC" }); 350 | // expect(instance._sendUnsubTicker.mock.calls.length).toBe(1); 351 | // expect(instance._sendUnsubTicker.mock.calls[0][0]).toBe("LTCBTC"); 352 | // }); 353 | // }); 354 | }); 355 | 356 | describe("neutered should no-op", () => { 357 | let instance; 358 | let market = { id: "BTCUSD" }; 359 | 360 | beforeAll(() => { 361 | instance = buildInstance(); 362 | instance.hasTickers = false; 363 | instance.hasTrades = false; 364 | instance.hasLevel2Snapshots = false; 365 | instance.hasLevel2Updates = false; 366 | instance.hasLevel3Updates = false; 367 | instance._connect(); 368 | instance._wss.mockEmit("open"); 369 | }); 370 | 371 | test("it should not send ticker sub", () => { 372 | instance.subscribeTicker(market); 373 | expect(instance._sendSubTicker.mock.calls.length).toBe(0); 374 | }); 375 | 376 | test("it should not send trade sub", () => { 377 | instance.subscribeTrades(market); 378 | expect(instance._sendSubTrades.mock.calls.length).toBe(0); 379 | }); 380 | test("it should not send trade unsub", () => { 381 | instance.unsubscribeTrades(market); 382 | expect(instance._sendUnsubTrades.mock.calls.length).toBe(0); 383 | }); 384 | test("it should not send level2 snapshot sub", () => { 385 | instance.subscribeLevel2Snapshots(market); 386 | expect(instance._sendSubLevel2Snapshots.mock.calls.length).toBe(0); 387 | }); 388 | test("it should not send level2 snapshot unsub", () => { 389 | instance.unsubscribeLevel2Snapshots(market); 390 | expect(instance._sendUnsubLevel2Snapshots.mock.calls.length).toBe(0); 391 | }); 392 | test("it should not send level2 update sub", () => { 393 | instance.subscribeLevel2Updates(market); 394 | expect(instance._sendSubLevel2Updates.mock.calls.length).toBe(0); 395 | }); 396 | test("it should not send level2 update unsub", () => { 397 | instance.unsubscribeLevel2Updates(market); 398 | expect(instance._sendUnsubLevel2Updates.mock.calls.length).toBe(0); 399 | }); 400 | test("it should not send level3 update sub", () => { 401 | instance.subscribeLevel3Updates(market); 402 | expect(instance._sendSubLevel3Updates.mock.calls.length).toBe(0); 403 | }); 404 | test("it should not send level3 update unsub", () => { 405 | instance.unsubscribeLevel3Updates(market); 406 | expect(instance._sendUnsubLevel3Updates.mock.calls.length).toBe(0); 407 | }); 408 | }); 409 | -------------------------------------------------------------------------------- /src/exchanges/binance/binance-client.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require("events"); 2 | const winston = require("winston"); 3 | const Ticker = require("../../type/ticker"); 4 | const Trade = require("../../type/trade"); 5 | const Level2Point = require("../../type/level2-point"); 6 | const Level2Update = require("../../type/level2-update"); 7 | const Level2Snapshot = require("../../type/level2-snapshot"); 8 | const SmartWss = require("../../smart-wss"); 9 | const Watcher = require("../../watcher"); 10 | 11 | class BinanceClient extends EventEmitter { 12 | constructor() { 13 | super(); 14 | this._name = "Binance"; 15 | this._tickerSubs = new Map(); 16 | this._tradeSubs = new Map(); 17 | this._level2SnapshotSubs = new Map(); 18 | this._level2UpdateSubs = new Map(); 19 | this._wss = undefined; 20 | this._reconnectDebounce = undefined; 21 | 22 | this.hasTickers = true; 23 | this.hasTrades = true; 24 | this.hasLevel2Snapshots = true; 25 | this.hasLevel2Updates = true; 26 | this.hasLevel3Snapshots = false; 27 | this.hasLevel3Updates = false; 28 | 29 | this._watcher = new Watcher(this, 30000); 30 | } 31 | 32 | ////////////////////////////////////////////// 33 | 34 | subscribeTicker(market) { 35 | this._subscribe(market, "subscribing to ticker", this._tickerSubs); 36 | } 37 | 38 | unsubscribeTicker(market) { 39 | this._unsubscribe(market, "unsubscribing from ticker", this._tickerSubs); 40 | } 41 | 42 | subscribeTrades(market) { 43 | this._subscribe(market, "subscribing to trades", this._tradeSubs); 44 | } 45 | 46 | unsubscribeTrades(market) { 47 | this._unsubscribe(market, "unsubscribing to trades", this._tradeSubs); 48 | } 49 | 50 | subscribeLevel2Snapshots(market) { 51 | this._subscribe(market, "subscribing to l2 snapshots", this._level2SnapshotSubs); 52 | } 53 | 54 | unsubscribeLevel2Snapshots(market) { 55 | this._unsubscribe(market, "unsubscribing from l2 snapshots", this._level2SnapshotSubs); 56 | } 57 | 58 | subscribeLevel2Updates(market) { 59 | this._subscribe(market, "subscribing to l2 upates", this._level2UpdateSubs); 60 | } 61 | 62 | unsubscribeLevel2Updates(market) { 63 | this._unsubscribe(market, "unsubscribing from l2 updates", this._level2UpdateSubs); 64 | } 65 | 66 | reconnect() { 67 | winston.info("reconnecting"); 68 | this._reconnect(); 69 | this.emit("reconnected"); 70 | } 71 | 72 | close() { 73 | this._close(); 74 | } 75 | 76 | //////////////////////////////////////////// 77 | // PROTECTED 78 | 79 | _subscribe(market, msg, map) { 80 | let remote_id = market.id.toLowerCase(); 81 | if (!map.has(remote_id)) { 82 | winston.info(msg, this._name, remote_id); 83 | map.set(remote_id, market); 84 | this._reconnect(); 85 | } 86 | } 87 | 88 | _unsubscribe(market, msg, map) { 89 | let remote_id = market.id.toLowerCase(); 90 | if (map.has(remote_id)) { 91 | winston.info(msg, this._name, remote_id); 92 | map.delete(market); 93 | this._reconnect(); 94 | } 95 | } 96 | 97 | /** 98 | * Reconnects the socket after a debounce period 99 | * so that multiple calls don't cause connect/reconnect churn 100 | */ 101 | _reconnect() { 102 | clearTimeout(this._reconnectDebounce); 103 | this._reconnectDebounce = setTimeout(() => { 104 | this._close(); 105 | this._connect(); 106 | }, 100); 107 | } 108 | 109 | /** 110 | * Close the underlying connction, which provides a way to reset the things 111 | */ 112 | _close() { 113 | if (this._wss) { 114 | this._wss.close(); 115 | this._wss = undefined; 116 | this.emit("closed"); 117 | } 118 | } 119 | 120 | /** Connect to the websocket stream by constructing a path from 121 | * the subscribed markets. 122 | */ 123 | _connect() { 124 | if (!this._wss) { 125 | let streams = [].concat( 126 | Array.from(this._tradeSubs.keys()).map(p => p + "@aggTrade"), 127 | Array.from(this._level2SnapshotSubs.keys()).map(p => p + "@depth20"), 128 | Array.from(this._level2UpdateSubs.keys()).map(p => p + "@depth") 129 | ); 130 | if (this._tickerSubs.size > 0) { 131 | streams.push("!ticker@arr"); 132 | } 133 | 134 | let wssPath = "wss://stream.binance.com:9443/stream?streams=" + streams.join("/"); 135 | 136 | this._wss = new SmartWss(wssPath); 137 | this._wss.on("message", this._onMessage.bind(this)); 138 | this._wss.on("open", this._onConnected.bind(this)); 139 | this._wss.on("disconnected", this._onDisconnected.bind(this)); 140 | this._wss.connect(); 141 | } 142 | } 143 | 144 | //////////////////////////////////////////// 145 | // ABSTRACT 146 | 147 | _onConnected() { 148 | this._watcher.start(); 149 | this.emit("connected"); 150 | } 151 | 152 | _onDisconnected() { 153 | this._watcher.stop(); 154 | this.emit("disconnected"); 155 | } 156 | 157 | _onMessage(raw) { 158 | let msg = JSON.parse(raw); 159 | 160 | // ticker 161 | if (msg.stream === "!ticker@arr") { 162 | for (let raw of msg.data) { 163 | if (this._tickerSubs.has(raw.s.toLowerCase())) { 164 | let ticker = this._constructTicker(raw); 165 | this.emit("ticker", ticker); 166 | } 167 | } 168 | } 169 | 170 | // trades 171 | if (msg.stream.endsWith("aggTrade")) { 172 | let trade = this._constructTradeFromMessage(msg); 173 | this.emit("trade", trade); 174 | } 175 | 176 | // l2snapshot 177 | if (msg.stream.endsWith("depth20")) { 178 | let snapshot = this._constructLevel2Snapshot(msg); 179 | this.emit("l2snapshot", snapshot); 180 | } 181 | 182 | // l2update 183 | if (msg.stream.endsWith("depth")) { 184 | let update = this._constructLevel2Update(msg); 185 | this.emit("l2update", update); 186 | } 187 | } 188 | 189 | _constructTicker(msg) { 190 | let { 191 | E: timestamp, 192 | s: symbol, 193 | c: last, 194 | v: volume, 195 | q: quoteVolume, 196 | h: high, 197 | l: low, 198 | p: change, 199 | P: changePercent, 200 | a: ask, 201 | A: askVolume, 202 | b: bid, 203 | B: bidVolume, 204 | } = msg; 205 | let market = this._tickerSubs.get(symbol.toLowerCase()); 206 | let open = parseFloat(last) + parseFloat(change); 207 | return new Ticker({ 208 | exchange: "Binance", 209 | base: market.base, 210 | quote: market.quote, 211 | timestamp: timestamp * 1000, 212 | last, 213 | open: open.toFixed(8), 214 | high, 215 | low, 216 | volume, 217 | quoteVolume, 218 | change, 219 | changePercent, 220 | bid, 221 | bidVolume, 222 | ask, 223 | askVolume, 224 | }); 225 | } 226 | 227 | _constructTradeFromMessage({ data }) { 228 | let { s: symbol, a: trade_id, p: price, q: size, T: time, m: buyer } = data; 229 | 230 | let market = this._tradeSubs.get(symbol.toLowerCase()); 231 | 232 | let unix = time; 233 | let amount = size; 234 | let side = buyer ? "buy" : "sell"; 235 | 236 | return new Trade({ 237 | exchange: "Binance", 238 | base: market.base, 239 | quote: market.quote, 240 | tradeId: trade_id, 241 | unix, 242 | side, 243 | price, 244 | amount, 245 | }); 246 | } 247 | 248 | _constructLevel2Snapshot(msg) { 249 | let remote_id = msg.stream.split("@")[0]; 250 | let market = this._level2SnapshotSubs.get(remote_id); 251 | let sequenceId = msg.data.lastUpdateId; 252 | let asks = msg.data.asks.map(p => new Level2Point(p[0], p[1])); 253 | let bids = msg.data.bids.map(p => new Level2Point(p[0], p[1])); 254 | return new Level2Update({ 255 | exchange: "Binance", 256 | base: market.base, 257 | quote: market.quote, 258 | sequenceId, 259 | asks, 260 | bids, 261 | }); 262 | } 263 | 264 | _constructLevel2Update(msg) { 265 | let remote_id = msg.data.s.toLowerCase(); 266 | let market = this._level2UpdateSubs.get(remote_id); 267 | let sequenceId = msg.data.U; 268 | let lastSequenceId = msg.data.u; 269 | let asks = msg.data.a.map(p => new Level2Point(p[0], p[1])); 270 | let bids = msg.data.b.map(p => new Level2Point(p[0], p[1])); 271 | return new Level2Snapshot({ 272 | exchange: "Binance", 273 | base: market.base, 274 | quote: market.quote, 275 | sequenceId, 276 | lastSequenceId, 277 | asks, 278 | bids, 279 | }); 280 | } 281 | } 282 | 283 | module.exports = BinanceClient; 284 | -------------------------------------------------------------------------------- /src/exchanges/binance/binance-client.spec.js: -------------------------------------------------------------------------------- 1 | const Binance = require("./binance-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "ETHBTC", 7 | base: "ETH", 8 | quote: "BTC", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new Binance(); 13 | }); 14 | 15 | test("it should support tickers", () => { 16 | expect(client.hasTickers).toBeTruthy(); 17 | }); 18 | 19 | test("it should support trades", () => { 20 | expect(client.hasTrades).toBeTruthy(); 21 | }); 22 | 23 | test("it should support level2 snapshots", () => { 24 | expect(client.hasLevel2Snapshots).toBeTruthy(); 25 | }); 26 | 27 | test("it should support level2 updates", () => { 28 | expect(client.hasLevel2Updates).toBeTruthy(); 29 | }); 30 | 31 | test("it should not support level3 snapshots", () => { 32 | expect(client.hasLevel3Snapshots).toBeFalsy(); 33 | }); 34 | 35 | test("it should not support level3 updates", () => { 36 | expect(client.hasLevel3Updates).toBeFalsy(); 37 | }); 38 | 39 | test("should subscribe and emit ticker events", done => { 40 | client.subscribeTicker(market); 41 | client.on("ticker", ticker => { 42 | expect(ticker.fullId).toMatch("Binance:ETH/BTC"); 43 | expect(ticker.timestamp).toBeGreaterThan(1531677480465); 44 | expect(typeof ticker.last).toBe("string"); 45 | expect(typeof ticker.open).toBe("string"); 46 | expect(typeof ticker.high).toBe("string"); 47 | expect(typeof ticker.low).toBe("string"); 48 | expect(typeof ticker.volume).toBe("string"); 49 | expect(typeof ticker.change).toBe("string"); 50 | expect(typeof ticker.changePercent).toBe("string"); 51 | expect(typeof ticker.bid).toBe("string"); 52 | expect(typeof ticker.bidVolume).toBe("string"); 53 | expect(typeof ticker.ask).toBe("string"); 54 | expect(typeof ticker.askVolume).toBe("string"); 55 | expect(parseFloat(ticker.last)).toBeGreaterThan(0); 56 | expect(parseFloat(ticker.open)).toBeGreaterThan(0); 57 | expect(parseFloat(ticker.high)).toBeGreaterThan(0); 58 | expect(parseFloat(ticker.low)).toBeGreaterThan(0); 59 | expect(parseFloat(ticker.volume)).toBeGreaterThan(0); 60 | expect(parseFloat(ticker.quoteVolume)).toBeGreaterThan(0); 61 | expect(Math.abs(parseFloat(ticker.change))).toBeGreaterThan(0); 62 | expect(Math.abs(parseFloat(ticker.changePercent))).toBeGreaterThan(0); 63 | expect(parseFloat(ticker.bid)).toBeGreaterThan(0); 64 | expect(parseFloat(ticker.bidVolume)).toBeGreaterThan(0); 65 | expect(parseFloat(ticker.ask)).toBeGreaterThan(0); 66 | expect(parseFloat(ticker.askVolume)).toBeGreaterThan(0); 67 | done(); 68 | }); 69 | }); 70 | 71 | test( 72 | "should subscribe and emit trade events", 73 | done => { 74 | client.subscribeTrades(market); 75 | client.on("trade", trade => { 76 | expect(trade.fullId).toMatch("Binance:ETH/BTC"); 77 | expect(trade.exchange).toMatch("Binance"); 78 | expect(trade.base).toMatch("ETH"); 79 | expect(trade.quote).toMatch("BTC"); 80 | expect(trade.tradeId).toBeGreaterThan(0); 81 | expect(trade.unix).toBeGreaterThan(1522540800000); 82 | expect(trade.side).toMatch(/buy|sell/); 83 | expect(typeof trade.price).toBe("string"); 84 | expect(typeof trade.amount).toBe("string"); 85 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 86 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 87 | done(); 88 | }); 89 | }, 90 | 30000 91 | ); 92 | 93 | test("should subscribe and emit level2 snapshots", done => { 94 | client.subscribeLevel2Snapshots(market); 95 | client.on("l2snapshot", snapshot => { 96 | expect(snapshot.fullId).toMatch("Binance:ETH/BTC"); 97 | expect(snapshot.exchange).toMatch("Binance"); 98 | expect(snapshot.base).toMatch("ETH"); 99 | expect(snapshot.quote).toMatch("BTC"); 100 | expect(snapshot.sequenceId).toBeGreaterThan(0); 101 | expect(snapshot.timestampMs).toBeUndefined(); 102 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 103 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 104 | expect(snapshot.asks[0].count).toBeUndefined(); 105 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 106 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 107 | expect(snapshot.bids[0].count).toBeUndefined(); 108 | done(); 109 | }); 110 | }); 111 | 112 | test("should subscribe and emit level2 updates", done => { 113 | client.subscribeLevel2Updates(market); 114 | client.on("l2update", update => { 115 | expect(update.fullId).toMatch("Binance:ETH/BTC"); 116 | expect(update.exchange).toMatch("Binance"); 117 | expect(update.base).toMatch("ETH"); 118 | expect(update.quote).toMatch("BTC"); 119 | expect(update.sequenceId).toBeGreaterThan(0); 120 | expect(update.lastSequenceId).toBeGreaterThanOrEqual(update.sequenceId); 121 | if (update.asks.length) { 122 | expect(parseFloat(update.asks[0].price)).toBeGreaterThanOrEqual(0); 123 | expect(parseFloat(update.asks[0].size)).toBeGreaterThanOrEqual(0); 124 | } 125 | if (update.bids.length) { 126 | expect(parseFloat(update.bids[0].price)).toBeGreaterThanOrEqual(0); 127 | expect(parseFloat(update.bids[0].size)).toBeGreaterThanOrEqual(0); 128 | } 129 | done(); 130 | }); 131 | }); 132 | 133 | test("should unsubscribe from tickers", () => { 134 | client.unsubscribeTicker(market); 135 | }); 136 | 137 | test("should unsubscribe from trades", () => { 138 | client.unsubscribeTrades(market); 139 | }); 140 | 141 | test("should unsubscribe from level2 snapshots", () => { 142 | client.unsubscribeLevel2Snapshots(market); 143 | }); 144 | 145 | test("should unsubscribe from level2 updates", () => { 146 | client.unsubscribeLevel2Updates(market); 147 | }); 148 | 149 | test("should close connections", done => { 150 | client.on("closed", done); 151 | client.close(); 152 | }); 153 | -------------------------------------------------------------------------------- /src/exchanges/bitfinex/bitfinex-client.js: -------------------------------------------------------------------------------- 1 | const BasicClient = require("../../basic-client"); 2 | const Ticker = require("../../type/ticker"); 3 | const Trade = require("../../type/trade"); 4 | const Level2Point = require("../../type/level2-point"); 5 | const Level2Snapshot = require("../../type/level2-snapshot"); 6 | const Level2Update = require("../../type/level2-update"); 7 | const Level3Point = require("../../type/level3-point"); 8 | const Level3Snapshot = require("../../type/level3-snapshot"); 9 | const Level3Update = require("../../type/level3-update"); 10 | 11 | class BitfinexClient extends BasicClient { 12 | constructor() { 13 | super("wss://api.bitfinex.com/ws", "Bitfinex"); 14 | this._channels = {}; 15 | 16 | this.hasTickers = true; 17 | this.hasTrades = true; 18 | this.hasLevel2Updates = true; 19 | this.hasLevel3Updates = true; 20 | } 21 | 22 | _sendSubTicker(remote_id) { 23 | this._wss.send( 24 | JSON.stringify({ 25 | event: "subscribe", 26 | channel: "ticker", 27 | pair: remote_id, 28 | }) 29 | ); 30 | } 31 | 32 | _sendUnsubTicker(remote_id) { 33 | this._wss.send( 34 | JSON.stringify({ 35 | event: "unsubscribe", 36 | channel: "ticker", 37 | pair: remote_id, 38 | }) 39 | ); 40 | } 41 | 42 | _sendSubTrades(remote_id) { 43 | this._wss.send( 44 | JSON.stringify({ 45 | event: "subscribe", 46 | channel: "trades", 47 | pair: remote_id, 48 | }) 49 | ); 50 | } 51 | 52 | _sendUnsubTrades(remote_id) { 53 | let chanId = this._findChannel("trades", remote_id); 54 | this._sendUnsubscribe(chanId); 55 | } 56 | 57 | _sendSubLevel2Updates(remote_id) { 58 | this._wss.send( 59 | JSON.stringify({ 60 | event: "subscribe", 61 | channel: "book", 62 | pair: remote_id, 63 | length: "100", 64 | }) 65 | ); 66 | } 67 | 68 | _sendUnsubLevel2Updates(remote_id) { 69 | let chanId = this._findChannel("level2updates", remote_id); 70 | this._sendUnsubscribe(chanId); 71 | } 72 | 73 | _sendSubLevel3Updates(remote_id) { 74 | this._wss.send( 75 | JSON.stringify({ 76 | event: "subscribe", 77 | channel: "book", 78 | pair: remote_id, 79 | prec: "R0", 80 | length: "100", 81 | }) 82 | ); 83 | } 84 | 85 | _sendUnsubLevel3Updates(remote_id) { 86 | let chanId = this._findChannel("level3updates", remote_id); 87 | this._sendUnsubscribe(chanId); 88 | } 89 | 90 | _sendUnsubscribe(chanId) { 91 | if (chanId) { 92 | this._wss.send( 93 | JSON.stringify({ 94 | event: "unsubscribe", 95 | chanId: chanId, 96 | }) 97 | ); 98 | } 99 | } 100 | 101 | _findChannel(type, remote_id) { 102 | for (let chan of Object.values(this._channels)) { 103 | if (chan.pair === remote_id) { 104 | if (type === "trades" && chan.channel === "trades") return chan.chanId; 105 | if (type === "level2updates" && chan.channel === "book" && chan.prec !== "R0") 106 | return chan.chanId; 107 | if (type === "level3updates" && chan.channel === "book" && chan.prec === "R0") 108 | return chan.chanId; 109 | } 110 | } 111 | } 112 | 113 | _onMessage(raw) { 114 | let msg = JSON.parse(raw); 115 | 116 | // capture channel metadata 117 | if (msg.event === "subscribed") { 118 | this._channels[msg.chanId] = msg; 119 | return; 120 | } 121 | 122 | // lookup channel 123 | let channel = this._channels[msg[0]]; 124 | if (!channel) return; 125 | 126 | // ignore heartbeats 127 | if (msg[1] === "hb") return; 128 | 129 | if (channel.channel === "ticker") { 130 | this._onTicker(msg, channel); 131 | return; 132 | } 133 | 134 | // trades 135 | if (channel.channel === "trades" && msg[1] === "tu") { 136 | this._onTradeMessage(msg, channel); 137 | return; 138 | } 139 | 140 | // level3 141 | if (channel.channel === "book" && channel.prec === "R0") { 142 | if (Array.isArray(msg[1])) this._onLevel3Snapshot(msg, channel); 143 | else this._onLevel3Update(msg, channel); 144 | return; 145 | } 146 | 147 | // level2 148 | if (channel.channel === "book") { 149 | if (Array.isArray(msg[1])) this._onLevel2Snapshot(msg, channel); 150 | else this._onLevel2Update(msg, channel); 151 | return; 152 | } 153 | } 154 | 155 | _onTicker(msg) { 156 | let [chanId, bid, bidSize, ask, askSize, change, changePercent, last, volume, high, low] = msg; 157 | let remote_id = this._channels[chanId].pair; 158 | let market = this._tickerSubs.get(remote_id); 159 | let open = last + change; 160 | let ticker = new Ticker({ 161 | exchange: "Bitfinex", 162 | base: market.base, 163 | quote: market.quote, 164 | timestamp: Date.now(), 165 | last: last.toFixed(8), 166 | open: open.toFixed(8), 167 | high: high.toFixed(8), 168 | low: low.toFixed(8), 169 | volume: volume.toFixed(8), 170 | change: change.toFixed(8), 171 | changePercent: changePercent.toFixed(2), 172 | bid: bid.toFixed(8), 173 | bidVolume: bidSize.toFixed(8), 174 | ask: ask.toFixed(8), 175 | askVolume: askSize.toFixed(8), 176 | }); 177 | this.emit("ticker", ticker); 178 | } 179 | 180 | _onTradeMessage(msg) { 181 | let [chanId, , , id, unix, price, amount] = msg; 182 | let remote_id = this._channels[chanId].pair; 183 | let market = this._tradeSubs.get(remote_id); 184 | let side = amount > 0 ? "buy" : "sell"; 185 | price = price.toFixed(8); 186 | amount = Math.abs(amount).toFixed(8); 187 | let trade = new Trade({ 188 | exchange: "Bitfinex", 189 | base: market.base, 190 | quote: market.quote, 191 | tradeId: id, 192 | unix: unix * 1000, 193 | side, 194 | price, 195 | amount, 196 | }); 197 | this.emit("trade", trade); 198 | } 199 | 200 | _onLevel2Snapshot(msg) { 201 | let remote_id = this._channels[msg[0]].pair; 202 | let market = this._level2UpdateSubs.get(remote_id); // this message will be coming from an l2update 203 | let bids = []; 204 | let asks = []; 205 | for (let val of msg[1]) { 206 | let result = new Level2Point( 207 | val[0].toFixed(8), 208 | Math.abs(val[2]).toFixed(8), 209 | val[1].toFixed(0) 210 | ); 211 | if (val[2] > 0) bids.push(result); 212 | else asks.push(result); 213 | } 214 | let result = new Level2Snapshot({ 215 | exchange: "Bitfinex", 216 | base: market.base, 217 | quote: market.quote, 218 | bids, 219 | asks, 220 | }); 221 | this.emit("l2snapshot", result); 222 | } 223 | 224 | _onLevel2Update(msg) { 225 | let remote_id = this._channels[msg[0]].pair; 226 | let market = this._level2UpdateSubs.get(remote_id); 227 | if (!msg[1].toFixed) console.log(msg); 228 | let point = new Level2Point(msg[1].toFixed(8), Math.abs(msg[3]).toFixed(8), msg[2].toFixed(0)); 229 | let asks = []; 230 | let bids = []; 231 | if (msg[3] > 0) bids.push(point); 232 | else asks.push(point); 233 | let update = new Level2Update({ 234 | exchange: "Bitfinex", 235 | base: market.base, 236 | quote: market.quote, 237 | asks, 238 | bids, 239 | }); 240 | this.emit("l2update", update); 241 | } 242 | 243 | _onLevel3Snapshot(msg, channel) { 244 | let remote_id = channel.pair; 245 | let market = this._level3UpdateSubs.get(remote_id); // this message will be coming from an l2update 246 | let bids = []; 247 | let asks = []; 248 | msg[1].forEach(p => { 249 | let point = new Level3Point(p[0], p[1].toFixed(8), Math.abs(p[2]).toFixed(8)); 250 | if (p[2] > 0) bids.push(point); 251 | else asks.push(point); 252 | }); 253 | let result = new Level3Snapshot({ 254 | exchange: "Bitfinex", 255 | base: market.base, 256 | quote: market.quote, 257 | asks, 258 | bids, 259 | }); 260 | this.emit("l3snapshot", result); 261 | } 262 | 263 | _onLevel3Update(msg, channel) { 264 | let remote_id = channel.pair; 265 | let market = this._level3UpdateSubs.get(remote_id); 266 | let bids = []; 267 | let asks = []; 268 | 269 | let point = new Level3Point(msg[1], msg[2].toFixed(8), Math.abs(msg[3]).toFixed(8)); 270 | if (msg[3] > 0) bids.push(point); 271 | else asks.push(point); 272 | 273 | let result = new Level3Update({ 274 | exchange: "Bitfinex", 275 | base: market.base, 276 | quote: market.quote, 277 | asks, 278 | bids, 279 | }); 280 | this.emit("l3update", result); 281 | } 282 | } 283 | 284 | module.exports = BitfinexClient; 285 | -------------------------------------------------------------------------------- /src/exchanges/bitfinex/bitfinex-client.spec.js: -------------------------------------------------------------------------------- 1 | const Bitfinex = require("./bitfinex-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "BTCUSD", 7 | base: "BTC", 8 | quote: "USD", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new Bitfinex(); 13 | }); 14 | 15 | test("it should support tickers", () => { 16 | expect(client.hasTickers).toBeTruthy(); 17 | }); 18 | 19 | test("it should support trades", () => { 20 | expect(client.hasTrades).toBeTruthy(); 21 | }); 22 | 23 | test("it should not support level2 snapshots", () => { 24 | expect(client.hasLevel2Snapshots).toBeFalsy(); 25 | }); 26 | 27 | test("it should support level2 updates", () => { 28 | expect(client.hasLevel2Updates).toBeTruthy(); 29 | }); 30 | 31 | test("it should not support level3 snapshots", () => { 32 | expect(client.hasLevel3Snapshots).toBeFalsy(); 33 | }); 34 | 35 | test("it should support level3 updates", () => { 36 | expect(client.hasLevel3Updates).toBeTruthy(); 37 | }); 38 | 39 | test("should subscribe and emit ticker events", done => { 40 | client.subscribeTicker(market); 41 | client.on("ticker", ticker => { 42 | expect(ticker.fullId).toMatch("Bitfinex:BTC/USD"); 43 | expect(ticker.timestamp).toBeGreaterThan(1531677480465); 44 | expect(typeof ticker.last).toBe("string"); 45 | expect(typeof ticker.open).toBe("string"); 46 | expect(typeof ticker.high).toBe("string"); 47 | expect(typeof ticker.low).toBe("string"); 48 | expect(typeof ticker.volume).toBe("string"); 49 | expect(typeof ticker.change).toBe("string"); 50 | expect(typeof ticker.changePercent).toBe("string"); 51 | expect(typeof ticker.bid).toBe("string"); 52 | expect(typeof ticker.bidVolume).toBe("string"); 53 | expect(typeof ticker.ask).toBe("string"); 54 | expect(typeof ticker.askVolume).toBe("string"); 55 | expect(parseFloat(ticker.last)).toBeGreaterThan(0); 56 | expect(parseFloat(ticker.open)).toBeGreaterThan(0); 57 | expect(parseFloat(ticker.high)).toBeGreaterThan(0); 58 | expect(parseFloat(ticker.low)).toBeGreaterThan(0); 59 | expect(parseFloat(ticker.volume)).toBeGreaterThan(0); 60 | expect(ticker.quoteVolume).toBeUndefined(); 61 | expect(Math.abs(parseFloat(ticker.change))).toBeGreaterThan(0); 62 | expect(Math.abs(parseFloat(ticker.changePercent))).toBeGreaterThan(0); 63 | expect(parseFloat(ticker.bid)).toBeGreaterThan(0); 64 | expect(parseFloat(ticker.bidVolume)).toBeGreaterThan(0); 65 | expect(parseFloat(ticker.ask)).toBeGreaterThan(0); 66 | expect(parseFloat(ticker.askVolume)).toBeGreaterThan(0); 67 | done(); 68 | }); 69 | }); 70 | 71 | test( 72 | "should subscribe and emit trade events", 73 | done => { 74 | client.subscribeTrades(market); 75 | client.on("trade", trade => { 76 | expect(trade.fullId).toMatch("Bitfinex:BTC/USD"); 77 | expect(trade.exchange).toMatch("Bitfinex"); 78 | expect(trade.base).toMatch("BTC"); 79 | expect(trade.quote).toMatch("USD"); 80 | expect(trade.tradeId).toBeGreaterThan(0); 81 | expect(trade.unix).toBeGreaterThan(1522540800000); 82 | expect(trade.side).toMatch(/buy|sell/); 83 | expect(typeof trade.price).toBe("string"); 84 | expect(typeof trade.amount).toBe("string"); 85 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 86 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 87 | done(); 88 | }); 89 | }, 90 | 30000 91 | ); 92 | 93 | test("should subscribe and emit level2 snapshot and updates", done => { 94 | let hasSnapshot = false; 95 | client.subscribeLevel2Updates(market); 96 | client.on("l2snapshot", snapshot => { 97 | hasSnapshot = true; 98 | expect(snapshot.fullId).toMatch("Bitfinex:BTC/USD"); 99 | expect(snapshot.exchange).toMatch("Bitfinex"); 100 | expect(snapshot.base).toMatch("BTC"); 101 | expect(snapshot.quote).toMatch("USD"); 102 | expect(snapshot.sequenceId).toBeUndefined(); 103 | expect(snapshot.timestampMs).toBeUndefined(); 104 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 105 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 106 | expect(parseFloat(snapshot.asks[0].count)).toBeGreaterThanOrEqual(0); 107 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 108 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 109 | expect(parseFloat(snapshot.bids[0].count)).toBeGreaterThanOrEqual(0); 110 | }); 111 | client.on("l2update", update => { 112 | expect(hasSnapshot).toBeTruthy(); 113 | expect(update.fullId).toMatch("Bitfinex:BTC/USD"); 114 | expect(update.exchange).toMatch("Bitfinex"); 115 | expect(update.base).toMatch("BTC"); 116 | expect(update.quote).toMatch("USD"); 117 | expect(update.sequenceId).toBeUndefined(); 118 | expect(update.timestampMs).toBeUndefined(); 119 | let point = update.asks[0] || update.bids[0]; 120 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 121 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 122 | expect(parseFloat(point.count)).toBeGreaterThanOrEqual(0); 123 | done(); 124 | }); 125 | }); 126 | 127 | test("should subscribe and emit level3 snapshot and updates", done => { 128 | let hasSnapshot = false; 129 | client.subscribeLevel3Updates(market); 130 | client.on("l3snapshot", snapshot => { 131 | hasSnapshot = true; 132 | expect(snapshot.fullId).toMatch("Bitfinex:BTC/USD"); 133 | expect(snapshot.exchange).toMatch("Bitfinex"); 134 | expect(snapshot.base).toMatch("BTC"); 135 | expect(snapshot.quote).toMatch("USD"); 136 | expect(snapshot.sequenceId).toBeUndefined(); 137 | expect(snapshot.timestampMs).toBeUndefined(); 138 | expect(snapshot.asks[0].orderId).toBeGreaterThan(0); 139 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 140 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 141 | expect(snapshot.bids[0].orderId).toBeGreaterThan(0); 142 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 143 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 144 | }); 145 | client.on("l3update", update => { 146 | expect(hasSnapshot).toBeTruthy(); 147 | expect(update.fullId).toMatch("Bitfinex:BTC/USD"); 148 | expect(update.exchange).toMatch("Bitfinex"); 149 | expect(update.base).toMatch("BTC"); 150 | expect(update.quote).toMatch("USD"); 151 | expect(update.sequenceId).toBeUndefined(); 152 | expect(update.timestampMs).toBeUndefined(); 153 | let point = update.asks[0] || update.bids[0]; 154 | expect(point.orderId).toBeGreaterThanOrEqual(0); 155 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 156 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 157 | done(); 158 | }); 159 | }); 160 | 161 | test("should unsubscribe from tickers", () => { 162 | client.unsubscribeTicker(market); 163 | }); 164 | 165 | test("should unsubscribe from trades", () => { 166 | client.unsubscribeTrades(market); 167 | }); 168 | 169 | test("should unsubscribe from level2 updates", () => { 170 | client.unsubscribeLevel2Updates(market); 171 | }); 172 | 173 | test("should unsubscribe from level3 updates", () => { 174 | client.unsubscribeLevel3Updates(market); 175 | }); 176 | 177 | test("should close connections", done => { 178 | client.on("closed", done); 179 | client.close(); 180 | }); 181 | -------------------------------------------------------------------------------- /src/exchanges/bitflyer/bitflyer-client.js: -------------------------------------------------------------------------------- 1 | const BasicClient = require("../../basic-client"); 2 | const Ticker = require("../../type/ticker"); 3 | const Trade = require("../../type/trade"); 4 | const Level2Point = require("../../type/level2-point"); 5 | const Level2Update = require("../../type/level2-update"); 6 | const moment = require("moment"); 7 | 8 | class BitFlyerClient extends BasicClient { 9 | constructor() { 10 | super("wss://ws.lightstream.bitflyer.com/json-rpc", "BitFlyer"); 11 | this.hasTickers = true; 12 | this.hasTrades = true; 13 | this.hasLevel2Updates = true; 14 | } 15 | 16 | _sendSubTicker(remote_id) { 17 | this._wss.send( 18 | JSON.stringify({ 19 | method: "subscribe", 20 | params: { 21 | channel: `lightning_ticker_${remote_id}`, 22 | }, 23 | }) 24 | ); 25 | } 26 | 27 | _sendUnsubTicker(remote_id) { 28 | this._wss.send( 29 | JSON.stringify({ 30 | method: "unsubscribe", 31 | params: { 32 | channel: `lightning_ticker_${remote_id}`, 33 | }, 34 | }) 35 | ); 36 | } 37 | 38 | _sendSubTrades(remote_id) { 39 | this._wss.send( 40 | JSON.stringify({ 41 | method: "subscribe", 42 | params: { 43 | channel: `lightning_executions_${remote_id}`, 44 | }, 45 | }) 46 | ); 47 | } 48 | 49 | _sendSubLevel2Updates(remote_id) { 50 | this._wss.send( 51 | JSON.stringify({ 52 | method: "subscribe", 53 | params: { 54 | channel: `lightning_board_${remote_id}`, 55 | }, 56 | }) 57 | ); 58 | } 59 | 60 | _sendUnsubTrades(remote_id) { 61 | this._wss.send( 62 | JSON.stringify({ 63 | method: "unsubscribe", 64 | params: { 65 | channel: `lightning_executions_${remote_id}`, 66 | }, 67 | }) 68 | ); 69 | } 70 | 71 | _sendUnsubLevel2Updates(remote_id) { 72 | this._wss.send( 73 | JSON.stringify({ 74 | method: "unsubscribe", 75 | params: { 76 | channel: `lightning_board_${remote_id}`, 77 | }, 78 | }) 79 | ); 80 | } 81 | 82 | _onMessage(data) { 83 | let parsed = JSON.parse(data); 84 | if (!parsed.params || !parsed.params.channel || !parsed.params.message) return; 85 | let { channel, message } = parsed.params; 86 | 87 | if (channel.startsWith("lightning_ticker_")) { 88 | let remote_id = channel.substr("lightning_ticker_".length); 89 | let ticker = this._createTicker(remote_id, message); 90 | this.emit("ticker", ticker); 91 | return; 92 | } 93 | 94 | // trades 95 | if (channel.startsWith("lightning_executions_")) { 96 | let remote_id = channel.substr("lightning_executions_".length); 97 | for (let datum of message) { 98 | let trade = this._createTrades(remote_id, datum); 99 | this.emit("trade", trade); 100 | } 101 | } 102 | 103 | // orderbook 104 | if (channel.startsWith("lightning_board_")) { 105 | let remote_id = channel.substr("lightning_board_".length); 106 | let update = this._createLevel2Update(remote_id, message); 107 | this.emit("l2update", update); 108 | } 109 | } 110 | 111 | _createTicker(remoteId, data) { 112 | let { 113 | timestamp, 114 | best_bid, 115 | best_ask, 116 | best_bid_size, 117 | best_ask_size, 118 | ltp, 119 | volume, 120 | volume_by_product, 121 | } = data; 122 | let market = this._tickerSubs.get(remoteId); 123 | return new Ticker({ 124 | exchange: "bitFlyer", 125 | base: market.base, 126 | quote: market.quote, 127 | timestamp: moment.utc(timestamp).valueOf(), 128 | last: ltp.toFixed(8), 129 | volume: volume.toFixed(8), 130 | quoteVolume: volume_by_product.toFixed(8), 131 | bid: best_bid.toFixed(8), 132 | bidVolume: best_bid_size.toFixed(8), 133 | ask: best_ask.toFixed(8), 134 | askVolume: best_ask_size.toFixed(8), 135 | }); 136 | } 137 | 138 | _createTrades(remoteId, datum) { 139 | let { size, side, exec_date, price, id } = datum; 140 | let market = this._tradeSubs.get(remoteId); 141 | 142 | side = side.toLowerCase(); 143 | let unix = moment(exec_date).valueOf(); 144 | 145 | return new Trade({ 146 | exchange: "bitFlyer", 147 | base: market.base, 148 | quote: market.quote, 149 | tradeId: id, 150 | unix, 151 | side: side.toLowerCase(), 152 | price: price.toFixed(8), 153 | amount: size.toFixed(8), 154 | }); 155 | } 156 | 157 | _createLevel2Update(remote_id, msg) { 158 | let market = this._level2UpdateSubs.get(remote_id); 159 | let asks = msg.asks.map(p => new Level2Point(p.price.toFixed(8), p.size.toFixed(8))); 160 | let bids = msg.bids.map(p => new Level2Point(p.price.toFixed(8), p.size.toFixed(8))); 161 | 162 | return new Level2Update({ 163 | exchange: "bitFlyer", 164 | base: market.base, 165 | quote: market.quote, 166 | asks, 167 | bids, 168 | }); 169 | } 170 | } 171 | 172 | module.exports = BitFlyerClient; 173 | -------------------------------------------------------------------------------- /src/exchanges/bitflyer/bitflyer-client.spec.js: -------------------------------------------------------------------------------- 1 | const BitFlyerClient = require("./bitflyer-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "FX_BTC_JPY", 7 | base: "BTC", 8 | quote: "JPY", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new BitFlyerClient(); 13 | }); 14 | 15 | test("it should support trades", () => { 16 | expect(client.hasTrades).toBeTruthy(); 17 | }); 18 | 19 | test("it should not support level2 snapshots", () => { 20 | expect(client.hasLevel2Snapshots).toBeFalsy(); 21 | }); 22 | 23 | test("it should support level2 updates", () => { 24 | expect(client.hasLevel2Updates).toBeTruthy(); 25 | }); 26 | 27 | test("it should not support level3 snapshots", () => { 28 | expect(client.hasLevel3Snapshots).toBeFalsy(); 29 | }); 30 | 31 | test("it should not support level3 updates", () => { 32 | expect(client.hasLevel3Updates).toBeFalsy(); 33 | }); 34 | 35 | test("should subscribe and emit ticker events", done => { 36 | client.subscribeTicker(market); 37 | client.on("ticker", ticker => { 38 | expect(ticker.fullId).toMatch("bitFlyer:BTC/JPY"); 39 | expect(ticker.timestamp).toBeGreaterThan(1531677480465); 40 | expect(typeof ticker.last).toBe("string"); 41 | expect(typeof ticker.volume).toBe("string"); 42 | expect(typeof ticker.bid).toBe("string"); 43 | expect(typeof ticker.bidVolume).toBe("string"); 44 | expect(typeof ticker.ask).toBe("string"); 45 | expect(typeof ticker.askVolume).toBe("string"); 46 | expect(parseFloat(ticker.last)).toBeGreaterThan(0); 47 | expect(ticker.open).toBeUndefined(); 48 | expect(ticker.high).toBeUndefined(); 49 | expect(ticker.low).toBeUndefined(); 50 | expect(parseFloat(ticker.volume)).toBeGreaterThan(0); 51 | expect(parseFloat(ticker.quoteVolume)).toBeGreaterThan(0); 52 | expect(ticker.change).toBeUndefined(); 53 | expect(ticker.changePercent).toBeUndefined(); 54 | expect(parseFloat(ticker.bid)).toBeGreaterThan(0); 55 | expect(parseFloat(ticker.bidVolume)).toBeGreaterThan(0); 56 | expect(parseFloat(ticker.ask)).toBeGreaterThan(0); 57 | expect(parseFloat(ticker.askVolume)).toBeGreaterThan(0); 58 | done(); 59 | }); 60 | }); 61 | 62 | test( 63 | "should subscribe and emit trade events", 64 | done => { 65 | client.subscribeTrades(market); 66 | client.on("trade", trade => { 67 | expect(trade.fullId).toMatch("bitFlyer:BTC/JPY"); 68 | expect(trade.exchange).toMatch("bitFlyer"); 69 | expect(trade.base).toMatch("BTC"); 70 | expect(trade.quote).toMatch("JPY"); 71 | expect(trade.tradeId).toBeGreaterThan(0); 72 | expect(trade.unix).toBeGreaterThan(1522540800000); 73 | expect(trade.side).toMatch(/buy|sell/); 74 | expect(typeof trade.price).toBe("string"); 75 | expect(typeof trade.amount).toBe("string"); 76 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 77 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 78 | done(); 79 | }); 80 | }, 81 | 90000 82 | ); 83 | 84 | test("should subscribe and emit level2 updates", done => { 85 | client.subscribeLevel2Updates(market); 86 | client.on("l2update", update => { 87 | expect(update.fullId).toMatch("bitFlyer:BTC/JPY"); 88 | expect(update.exchange).toMatch("bitFlyer"); 89 | expect(update.base).toMatch("BTC"); 90 | expect(update.quote).toMatch("JPY"); 91 | expect(update.sequenceId).toBeUndefined(); 92 | expect(update.timestampMs).toBeUndefined(); 93 | let point = update.asks[0] || update.bids[0]; 94 | expect(typeof point.price).toBe("string"); 95 | expect(typeof point.size).toBe("string"); 96 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 97 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 98 | expect(point.count).toBeUndefined(); 99 | done(); 100 | }); 101 | }); 102 | 103 | test("should unsubscribe from tickers", () => { 104 | client.unsubscribeTicker(market); 105 | }); 106 | 107 | test("should unsubscribe trades", () => { 108 | client.unsubscribeTrades(market); 109 | }); 110 | 111 | test("should unsubscribe from level2orders", () => { 112 | client.unsubscribeLevel2Updates(market); 113 | }); 114 | 115 | test("should close connections", done => { 116 | client.on("closed", done); 117 | client.close(); 118 | }); 119 | -------------------------------------------------------------------------------- /src/exchanges/bitmex/bitmex-client.js: -------------------------------------------------------------------------------- 1 | const BasicClient = require("../../basic-client"); 2 | const Trade = require("../../type/trade"); 3 | const Level2Point = require("../../type/level2-point"); 4 | const Level2Snapshot = require("../../type/level2-snapshot"); 5 | const Level2Update = require("../../type/level2-update"); 6 | const moment = require("moment"); 7 | 8 | class BitmexClient extends BasicClient { 9 | constructor() { 10 | super("wss://www.bitmex.com/realtime", "BitMEX"); 11 | this.hasTrades = true; 12 | this.hasLevel2Updates = true; 13 | } 14 | 15 | _sendSubTrades(remote_id) { 16 | this._wss.send( 17 | JSON.stringify({ 18 | op: "subscribe", 19 | args: [`trade:${remote_id}`], 20 | }) 21 | ); 22 | } 23 | 24 | _sendSubLevel2Updates(remote_id) { 25 | this._wss.send( 26 | JSON.stringify({ 27 | op: "subscribe", 28 | args: [`orderBookL2:${remote_id}`], 29 | }) 30 | ); 31 | } 32 | 33 | _sendUnsubTrades(remote_id) { 34 | this._wss.send( 35 | JSON.stringify({ 36 | op: "unsubscribe", 37 | args: [`trade:${remote_id}`], 38 | }) 39 | ); 40 | } 41 | 42 | _sendUnsubLevel2Updates(remote_id) { 43 | this._wss.send( 44 | JSON.stringify({ 45 | op: "unsubscribe", 46 | args: [`orderBookL2:${remote_id}`], 47 | }) 48 | ); 49 | } 50 | 51 | _onMessage(msgs) { 52 | let message = JSON.parse(msgs); 53 | let { table, action } = message; 54 | 55 | if (table === "trade") { 56 | if (action !== "insert") return; 57 | for (let datum of message.data) { 58 | let trade = this._constructTrades(datum); 59 | this.emit("trade", trade); 60 | } 61 | return; 62 | } 63 | 64 | if (table === "orderBookL2") { 65 | if (action === "partial") { 66 | let snapshot = this._constructLevel2Snapshot(message.data); 67 | this.emit("l2snapshot", snapshot); 68 | } else { 69 | let update = this._constructLevel2Update(message.data, action); 70 | this.emit("l2update", update); 71 | } 72 | return; 73 | } 74 | } 75 | 76 | _constructTrades(datum) { 77 | let { size, side, timestamp, price, trdMatchID } = datum; 78 | let market = this._tradeSubs.get(datum.symbol); 79 | let unix = moment(timestamp).valueOf(); 80 | return new Trade({ 81 | exchange: "BitMEX", 82 | base: market.base, 83 | quote: market.quote, 84 | tradeId: trdMatchID.replace(/-/g, ""), 85 | unix, 86 | side: side.toLowerCase(), 87 | price: price.toFixed(8), 88 | amount: size.toFixed(8), 89 | }); 90 | } 91 | 92 | // prettier-ignore 93 | _constructLevel2Snapshot(data) { 94 | let market = this._level2UpdateSubs.get(data[0].symbol); 95 | let asks = []; 96 | let bids = []; 97 | for (let datum of data) { 98 | let point = new Level2Point(datum.price.toFixed(8), datum.size.toFixed(8), undefined, { id: datum.id }); 99 | if(datum.side === 'Sell') asks.push(point); 100 | else bids.push(point); 101 | } 102 | return new Level2Snapshot({ 103 | exchange: 'BitMEX', 104 | base: market.base, 105 | quote: market.quote, 106 | asks, 107 | bids, 108 | }); 109 | } 110 | 111 | // prettier-ignore 112 | _constructLevel2Update(data, type) { 113 | let market = this._level2UpdateSubs.get(data[0].symbol); 114 | let asks = []; 115 | let bids = []; 116 | for (let datum of data) { 117 | let price = datum.price && datum.price.toFixed(8); 118 | let size = datum.size && datum.size.toFixed(8); 119 | let point = new Level2Point(price, size, undefined, { type, id: datum.id }); 120 | if(datum.side === 'Sell') asks.push(point); 121 | else bids.push(point); 122 | } 123 | return new Level2Update({ 124 | exchange: 'BitMEX', 125 | base: market.base, 126 | quote: market.quote, 127 | asks, 128 | bids, 129 | }); 130 | } 131 | } 132 | 133 | module.exports = BitmexClient; 134 | -------------------------------------------------------------------------------- /src/exchanges/bitmex/bitmex-client.spec.js: -------------------------------------------------------------------------------- 1 | const BitmexClient = require("./bitmex-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "XBTUSD", 7 | base: "XBT", 8 | quote: "USD", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new BitmexClient(); 13 | }); 14 | 15 | test("it should support trades", () => { 16 | expect(client.hasTrades).toBeTruthy(); 17 | }); 18 | 19 | test("it should not support level2 snapshots", () => { 20 | expect(client.hasLevel2Snapshots).toBeFalsy(); 21 | }); 22 | 23 | test("it should support level2 updates", () => { 24 | expect(client.hasLevel2Updates).toBeTruthy(); 25 | }); 26 | 27 | test("it should not support level3 snapshots", () => { 28 | expect(client.hasLevel3Snapshots).toBeFalsy(); 29 | }); 30 | 31 | test("it should not support level3 updates", () => { 32 | expect(client.hasLevel3Updates).toBeFalsy(); 33 | }); 34 | 35 | test( 36 | "should subscribe and emit trade events", 37 | done => { 38 | client.subscribeTrades(market); 39 | client.on("trade", trade => { 40 | expect(trade.fullId).toMatch("BitMEX:XBT/USD"); 41 | expect(trade.exchange).toMatch("BitMEX"); 42 | expect(trade.base).toMatch("XBT"); 43 | expect(trade.quote).toMatch("USD"); 44 | expect(trade.tradeId).toMatch(/^[a-f0-9]{32,32}$/); 45 | expect(trade.unix).toBeGreaterThan(1522540800000); 46 | expect(trade.side).toMatch(/buy|sell/); 47 | expect(typeof trade.price).toBe("string"); 48 | expect(typeof trade.amount).toBe("string"); 49 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 50 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 51 | done(); 52 | }); 53 | }, 54 | 30000 55 | ); 56 | 57 | // run first so we can capture snapshot 58 | test( 59 | "should subscribe and emit level2 snapshot and updates", 60 | done => { 61 | let hasSnapshot = false; 62 | let hasUpdate = false; 63 | let hasInsert = false; 64 | let hasDelete = false; 65 | client.subscribeLevel2Updates(market); 66 | client.on("l2snapshot", snapshot => { 67 | hasSnapshot = true; 68 | expect(snapshot.fullId).toMatch("BitMEX:XBT/USD"); 69 | expect(snapshot.exchange).toMatch("BitMEX"); 70 | expect(snapshot.base).toMatch("XBT"); 71 | expect(snapshot.quote).toMatch("USD"); 72 | expect(snapshot.sequenceId).toBeUndefined(); 73 | expect(snapshot.timestampMs).toBeUndefined(); 74 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 75 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 76 | expect(snapshot.asks[0].count).toBeUndefined(); 77 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 78 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 79 | expect(snapshot.bids[0].count).toBeUndefined(); 80 | }); 81 | client.on("l2update", update => { 82 | expect(update.fullId).toMatch("BitMEX:XBT/USD"); 83 | expect(update.exchange).toMatch("BitMEX"); 84 | expect(update.base).toMatch("XBT"); 85 | expect(update.quote).toMatch("USD"); 86 | expect(update.sequenceId).toBeUndefined(); 87 | expect(update.timestampMs).toBeUndefined(); 88 | let point = update.asks[0] || update.bids[0]; 89 | expect(point.meta.type).toMatch(/(update|delete|insert)/); 90 | expect(point.meta.id).toBeGreaterThan(0); 91 | if (point.meta.type === "insert") { 92 | expect(typeof point.price).toBe("string"); 93 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 94 | } 95 | if (point.meta.type === "insert" || point.meta.type === "update") { 96 | expect(typeof point.size).toBe("string"); 97 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 98 | } 99 | expect(point.count).toBeUndefined(); 100 | 101 | if (point.meta.type === "update") hasUpdate = true; 102 | if (point.meta.type === "insert") hasInsert = true; 103 | if (point.meta.type === "delete") hasDelete = true; 104 | 105 | if (hasUpdate && hasInsert && hasDelete) { 106 | expect(hasSnapshot).toBeTruthy(); 107 | done(); 108 | } 109 | }); 110 | }, 111 | 30000 112 | ); 113 | 114 | test("should unsubscribe", () => { 115 | client.unsubscribeTrades(market); 116 | }); 117 | 118 | test("should close connections", done => { 119 | client.on("closed", done); 120 | client.close(); 121 | }); 122 | -------------------------------------------------------------------------------- /src/exchanges/bitstamp/bitstamp-client.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require("events"); 2 | const winston = require("winston"); 3 | const Pusher = require("pusher-js"); 4 | const Trade = require("../../type/trade"); 5 | const Level2Point = require("../../type/level2-point"); 6 | const Level2Snapshot = require("../../type/level2-snapshot"); 7 | const Level2Update = require("../../type/level2-update"); 8 | const Level3Point = require("../../type/level3-point"); 9 | const Level3Update = require("../../type/level3-update"); 10 | 11 | class BitstampClient extends EventEmitter { 12 | constructor() { 13 | super(); 14 | this._name = "Bitstamp"; 15 | this._tradeSubs = new Map(); 16 | this._level2SnapSubs = new Map(); 17 | this._level2UpdateSubs = new Map(); 18 | this._level3UpdateSubs = new Map(); 19 | 20 | this.hasTrades = true; 21 | this.hasLevel2Snapshots = true; 22 | this.hasLevel2Updates = true; 23 | this.hasLevel3Snapshots = false; 24 | this.hasLevel3Updates = true; 25 | } 26 | 27 | subscribeTrades(market) { 28 | this._subscribe( 29 | market, 30 | this._tradeSubs, 31 | "subscribing to trades", 32 | this._sendSubTrades.bind(this) 33 | ); 34 | } 35 | 36 | unsubscribeTrades(market) { 37 | this._unsubscribe( 38 | market, 39 | this._tradeSubs, 40 | "unsubscribing from trades", 41 | this._sendUnsubTrades.bind(this) 42 | ); 43 | } 44 | 45 | subscribeLevel2Snapshots(market) { 46 | this._subscribe( 47 | market, 48 | this._level2SnapSubs, 49 | "subscribing to level2 spot", 50 | this._sendSubLevel2Snapshot.bind(this) 51 | ); 52 | } 53 | 54 | unsubscribeLevel2Snapshots(market) { 55 | this._unsubscribe( 56 | market, 57 | this._level2SnapSubs, 58 | "unsubscribing from level2 spot", 59 | this._sendUnsubLevel2Snapshot.bind(this) 60 | ); 61 | } 62 | 63 | subscribeLevel2Updates(market) { 64 | this._subscribe( 65 | market, 66 | this._level2UpdateSubs, 67 | "subscribing to level2 updates", 68 | this._sendSubLevel2Updates.bind(this) 69 | ); 70 | } 71 | 72 | unsubscribeLevel2Updates(market) { 73 | this._unsubscribe( 74 | market, 75 | this._level2UpdateSubs, 76 | "unsubscribing from level2 updates", 77 | this._sendUnsubLevel2Updates.bind(this) 78 | ); 79 | } 80 | 81 | subscribeLevel3Updates(market) { 82 | this._subscribe( 83 | market, 84 | this._level3UpdateSubs, 85 | "subscribing to level3 updates", 86 | this._sendSubLevel3Updates.bind(this) 87 | ); 88 | } 89 | 90 | unsubscribeLevel3Updates(market) { 91 | this._subscribe( 92 | market, 93 | this._level3UpdateSubs, 94 | "unsubscribing from level3 updates", 95 | this._sendUnsubLevel3Updates.bind(this) 96 | ); 97 | } 98 | 99 | close() { 100 | if (this._pusher) { 101 | this._pusher.disconnect(); 102 | this._pusher = undefined; 103 | } 104 | this.emit("closed"); 105 | } 106 | 107 | ////////////////////////////// 108 | 109 | _connect() { 110 | if (!this._pusher) { 111 | this._pusher = new Pusher("de504dc5763aeef9ff52"); 112 | } 113 | } 114 | 115 | _subscribe(market, map, msg, subFn) { 116 | this._connect(); 117 | let remote_id = market.id; 118 | if (!map.has(remote_id)) { 119 | winston.info(msg, this._name, remote_id); 120 | map.set(remote_id, market); 121 | subFn(remote_id); 122 | } 123 | } 124 | 125 | _unsubscribe(market, map, msg, subFn) { 126 | let remote_id = market.id; 127 | if (map.has(remote_id)) { 128 | winston.info(msg, this._name, remote_id); 129 | map.delete(remote_id); 130 | subFn(remote_id); 131 | } 132 | } 133 | 134 | _sendSubTrades(remote_id) { 135 | let channelName = remote_id === "btcusd" ? "live_trades" : `live_trades_${remote_id}`; 136 | let channel = this._pusher.subscribe(channelName); 137 | channel.bind("trade", this._onTrade.bind(this, remote_id)); 138 | } 139 | 140 | _sendUnsubTrades(remote_id) { 141 | let channelName = remote_id === "btcusd" ? "live_trades" : `live_trades_${remote_id}`; 142 | this._pusher.unsubscribe(channelName); 143 | } 144 | 145 | _onTrade(remote_id, msg) { 146 | let market = this._tradeSubs.get(remote_id); 147 | 148 | /* 149 | { amount: 0.363, 150 | buy_order_id: 1347930302, 151 | sell_order_id: 1347930276, 152 | amount_str: '0.36300000', 153 | price_str: '8094.97', 154 | timestamp: '1524058372', 155 | price: 8094.97, 156 | type: 0, 157 | id: 62696598 } 158 | */ 159 | 160 | let trade = new Trade({ 161 | exchange: "Bitstamp", 162 | base: market.base, 163 | quote: market.quote, 164 | tradeId: msg.id, 165 | unix: msg.timestamp * 1000, 166 | side: msg.type === 1 ? "sell" : "buy", 167 | price: msg.price_str, 168 | amount: msg.amount_str, 169 | buyOrderId: msg.buy_order_id, 170 | sellOrderId: msg.sell_order_id, 171 | }); 172 | this.emit("trade", trade); 173 | } 174 | 175 | _sendSubLevel2Snapshot(remote_id) { 176 | let channelName = remote_id === "btcusd" ? "order_book" : `order_book_${remote_id}`; 177 | let channel = this._pusher.subscribe(channelName); 178 | channel.bind("data", this._onLevel2Snapshot.bind(this, remote_id)); 179 | } 180 | 181 | _sendUnsubLevel2Snapshot(remote_id) { 182 | let channelName = remote_id === "btcusd" ? "order_book" : `order_book_${remote_id}`; 183 | this._pusher.unsubscribe(channelName); 184 | } 185 | 186 | _onLevel2Snapshot(remote_id, msg) { 187 | let market = this._level2SnapSubs.get(remote_id); 188 | let { bids, asks, timestamp } = msg; 189 | /* 190 | { 191 | "timestamp": "1528754789", 192 | "bids": [ 193 | ["6778.10", "0.30000000"], 194 | ["6778.01", "0.02490239"], 195 | ["6778.00", "0.78593442"], 196 | ["6777.35", "20.23652315"], 197 | ["6776.20", "5.00000000"] 198 | ], 199 | "asks": [ 200 | ["6780.00", "28.96553416"], 201 | ["6784.22", "0.00116447"], 202 | ["6788.21", "0.01000000"], 203 | ["6788.73", "1.00000000"], 204 | ["6790.00", "1.00000000"] 205 | ] 206 | } 207 | */ 208 | 209 | bids = bids.map(([price, size]) => new Level2Point(price, size)); 210 | asks = asks.map(([price, size]) => new Level2Point(price, size)); 211 | 212 | let spot = new Level2Snapshot({ 213 | exchange: "Bitstamp", 214 | base: market.base, 215 | quote: market.quote, 216 | timestampMs: timestamp * 1000, 217 | bids, 218 | asks, 219 | }); 220 | 221 | this.emit("l2snapshot", spot); 222 | } 223 | 224 | _sendSubLevel2Updates(remote_id) { 225 | let channelName = remote_id === "btcusd" ? "diff_order_book" : `diff_order_book_${remote_id}`; 226 | let channel = this._pusher.subscribe(channelName); 227 | channel.bind("data", this._onLevel2Update.bind(this, remote_id)); 228 | } 229 | 230 | _sendUnsubLevel2Updates(remote_id) { 231 | let channelName = remote_id === "btcusd" ? "diff_order_book" : `diff_order_book_${remote_id}`; 232 | this._pusher.unsubscribe(channelName); 233 | } 234 | 235 | _onLevel2Update(remote_id, msg) { 236 | let market = this._level2UpdateSubs.get(remote_id); 237 | let { bids, asks, timestamp } = msg; 238 | /* 239 | { 240 | "timestamp": "1528755218", 241 | "bids": [ 242 | ["6762.72", "0.00000000"], 243 | ["6762.70", "0.00000000"], 244 | ["6759.82", "0.07750000"], 245 | ["6759.70", "1.47580000"], 246 | ["6745.50", "1.95000000"], 247 | ["6734.61", "0.46150000"], 248 | ["6733.82", "0.00000000"], 249 | ["6732.99", "0.00000000"] 250 | ], 251 | "asks": [ 252 | ["6778.78", "0.28855655"], 253 | ["6778.79", "6.67991600"], 254 | ["6778.80", "1.47460000"], 255 | ["6778.88", "0.00000000"], 256 | ["6778.90", "0.00000000"], 257 | ["6779.99", "0.00000000"] 258 | ] 259 | } 260 | */ 261 | 262 | bids = bids.map(([price, size]) => new Level2Point(price, size)); 263 | asks = asks.map(([price, size]) => new Level2Point(price, size)); 264 | 265 | let update = new Level2Update({ 266 | exchange: "Bitstamp", 267 | base: market.base, 268 | quote: market.quote, 269 | timestampMs: timestamp * 1000, 270 | bids, 271 | asks, 272 | }); 273 | 274 | this.emit("l2update", update); 275 | } 276 | 277 | _sendSubLevel3Updates(remote_id) { 278 | let channelName = remote_id === "btcusd" ? "live_orders" : `live_orders_${remote_id}`; 279 | let channel = this._pusher.subscribe(channelName); 280 | channel.bind("order_created", this._onLevel3Update.bind(this, remote_id, "created")); 281 | channel.bind("order_changed", this._onLevel3Update.bind(this, remote_id, "changed")); 282 | channel.bind("order_deleted", this._onLevel3Update.bind(this, remote_id, "deleted")); 283 | } 284 | 285 | _sendUnsubLevel3Updates(remote_id) { 286 | let channelName = remote_id === "btcusd" ? "live_orders" : `live_orders${remote_id}`; 287 | this._pusher.unsubscribe(channelName); 288 | } 289 | 290 | _onLevel3Update(remote_id, type, msg) { 291 | let market = this._level3UpdateSubs.get(remote_id); 292 | 293 | /* 294 | { 295 | order_type: 1, 296 | price: 6844.1, 297 | datetime: '1528757709', 298 | amount: 2.66106012, 299 | id: 1667122035, 300 | microtimestamp: '1528757717001990' 301 | } 302 | */ 303 | 304 | let asks = []; 305 | let bids = []; 306 | 307 | let timestampMs = Math.trunc(msg.microtimestamp / 1000); // comes in in microseconds 308 | let point = new Level3Point(msg.id, msg.price.toFixed(8), msg.amount.toFixed(8), { type }); 309 | 310 | if (msg.order_type === 0) bids.push(point); 311 | else asks.push(point); 312 | 313 | let update = new Level3Update({ 314 | exchange: "Bitstamp", 315 | base: market.base, 316 | quote: market.quote, 317 | timestampMs, 318 | asks, 319 | bids, 320 | }); 321 | 322 | this.emit("l3update", update); 323 | } 324 | } 325 | 326 | module.exports = BitstampClient; 327 | -------------------------------------------------------------------------------- /src/exchanges/bitstamp/bitstamp-client.spec.js: -------------------------------------------------------------------------------- 1 | const Bitstamp = require("./bitstamp-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "btcusd", 7 | base: "BTC", 8 | quote: "USD", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new Bitstamp(); 13 | }); 14 | 15 | test("it should support trades", () => { 16 | expect(client.hasTrades).toBeTruthy(); 17 | }); 18 | 19 | test("it should support level2 snapshots", () => { 20 | expect(client.hasLevel2Snapshots).toBeTruthy(); 21 | }); 22 | 23 | test("it should support level2 updates", () => { 24 | expect(client.hasLevel2Updates).toBeTruthy(); 25 | }); 26 | 27 | test("it should not support level3 snapshots", () => { 28 | expect(client.hasLevel3Snapshots).toBeFalsy(); 29 | }); 30 | 31 | test("it should support level3 updates", () => { 32 | expect(client.hasLevel3Updates).toBeTruthy(); 33 | }); 34 | 35 | test( 36 | "should subscribe and emit trade events", 37 | done => { 38 | client.subscribeTrades(market); 39 | client.on("trade", trade => { 40 | expect(trade.fullId).toMatch("Bitstamp:BTC/USD"); 41 | expect(trade.exchange).toMatch("Bitstamp"); 42 | expect(trade.base).toMatch("BTC"); 43 | expect(trade.quote).toMatch("USD"); 44 | expect(trade.tradeId).toBeGreaterThan(0); 45 | expect(trade.unix).toBeGreaterThan(1522540800000); 46 | expect(trade.side).toMatch(/buy|sell/); 47 | expect(typeof trade.price).toBe("string"); 48 | expect(typeof trade.amount).toBe("string"); 49 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 50 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 51 | done(); 52 | }); 53 | }, 54 | 30000 55 | ); 56 | 57 | test("should subscribe and emit level2 snapshots", done => { 58 | client.subscribeLevel2Snapshots(market); 59 | client.on("l2snapshot", snapshot => { 60 | expect(snapshot.fullId).toMatch("Bitstamp:BTC/USD"); 61 | expect(snapshot.exchange).toMatch("Bitstamp"); 62 | expect(snapshot.base).toMatch("BTC"); 63 | expect(snapshot.quote).toMatch("USD"); 64 | expect(snapshot.sequenceId).toBeUndefined(); 65 | expect(snapshot.timestampMs).toBeGreaterThan(0); 66 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 67 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 68 | expect(snapshot.asks[0].count).toBeUndefined(); 69 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 70 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 71 | expect(snapshot.bids[0].count).toBeUndefined(); 72 | done(); 73 | }); 74 | }); 75 | 76 | test("should subscribe and emit level2 updates", done => { 77 | client.subscribeLevel2Updates(market); 78 | client.on("l2update", update => { 79 | expect(update.fullId).toMatch("Bitstamp:BTC/USD"); 80 | expect(update.exchange).toMatch("Bitstamp"); 81 | expect(update.base).toMatch("BTC"); 82 | expect(update.quote).toMatch("USD"); 83 | expect(update.sequenceId).toBeUndefined(); 84 | expect(update.timestampMs).toBeGreaterThan(0); 85 | let point = update.asks[0] || update.bids[0]; 86 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 87 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 88 | expect(point.count).toBeUndefined(); 89 | done(); 90 | }); 91 | }); 92 | 93 | test("should subscribe and emit level3 updates", done => { 94 | client.subscribeLevel3Updates(market); 95 | client.on("l3update", update => { 96 | expect(update.fullId).toMatch("Bitstamp:BTC/USD"); 97 | expect(update.exchange).toMatch("Bitstamp"); 98 | expect(update.base).toMatch("BTC"); 99 | expect(update.quote).toMatch("USD"); 100 | expect(update.sequenceId).toBeUndefined(); 101 | expect(update.timestampMs).toBeGreaterThan(0); 102 | let point = update.asks[0] || update.bids[0]; 103 | expect(point.orderId).toBeGreaterThan(0); 104 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 105 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 106 | expect(point.meta.type).toMatch(/created|updated|deleted/); 107 | done(); 108 | }); 109 | }); 110 | 111 | test("should unsubscribe from trade events", () => { 112 | client.unsubscribeTrades(market); 113 | }); 114 | 115 | test("should unsubscribe from level2 snapshot", () => { 116 | client.unsubscribeLevel2Snapshots(market); 117 | }); 118 | 119 | test("should unsubscribe from level2 updates", () => { 120 | client.unsubscribeLevel2Updates(market); 121 | }); 122 | 123 | test("should unsubscribe from level3 updates", () => { 124 | client.unsubscribeLevel3Updates(market); 125 | }); 126 | 127 | test("should close connections", done => { 128 | client.on("closed", done); 129 | client.close(); 130 | }); 131 | -------------------------------------------------------------------------------- /src/exchanges/bittrex/bittrex-client.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require("events"); 2 | const crypto = require("crypto"); 3 | const winston = require("winston"); 4 | const moment = require("moment"); 5 | const cloudscraper = require("cloudscraper"); 6 | const signalr = require("signalr-client"); 7 | const Watcher = require("../../watcher"); 8 | const Ticker = require("../../type/ticker"); 9 | const Trade = require("../../type/trade"); 10 | const Level2Snapshot = require("../../type/level2-snapshot"); 11 | const Level2Update = require("../../type/level2-update"); 12 | const Level2Point = require("../../type/level2-point"); 13 | 14 | class BittrexClient extends EventEmitter { 15 | constructor() { 16 | super(); 17 | this._retryTimeoutMs = 15000; 18 | this._cloudflare; // placeholder for information from cloudflare 19 | this._tickerSubs = new Map(); 20 | this._tradeSubs = new Map(); 21 | this._level2UpdateSubs = new Map(); 22 | this._watcher = new Watcher(this); 23 | this._tickerConnected; 24 | 25 | this.hasTickers = true; 26 | this.hasTrades = true; 27 | this.hasLevel2Snapshots = false; 28 | this.hasLevel2Updates = true; 29 | this.hasLevel3Snapshots = false; 30 | this.hasLevel3Updates = false; 31 | } 32 | 33 | close(emitEvent = true) { 34 | this._watcher.stop(); 35 | if (this._wss) { 36 | try { 37 | this._wss.end(); 38 | } catch (e) { 39 | // ignore 40 | } 41 | this._wss = undefined; 42 | } 43 | if (emitEvent) this.emit("closed"); 44 | } 45 | 46 | reconnect(emitEvent = true) { 47 | this.close(false); 48 | this._connect(); 49 | if (emitEvent) this.emit("reconnected"); 50 | } 51 | 52 | subscribeTicker(market) { 53 | let remote_id = market.id; 54 | if (this._tickerSubs.has(remote_id)) return; 55 | 56 | this._connect(); 57 | winston.info("subscribing to ticker", "Bittrex", remote_id); 58 | this._tickerSubs.set(remote_id, market); 59 | if (this._wss) { 60 | this._sendSubTickers(remote_id); 61 | } 62 | } 63 | 64 | unsubscribeTicker(market) { 65 | let remote_id = market.id; 66 | if (!this._tickerSubs.has(remote_id)) return; 67 | winston.info("subscribing to ticker", "Bittrex", remote_id); 68 | this._tickerSubs.delete(remote_id); 69 | if (this._wss) { 70 | this._sendUnsubTicker(remote_id); 71 | } 72 | } 73 | 74 | subscribeTrades(market) { 75 | this._subscribe(market, this._tradeSubs, "subscribing to trades"); 76 | } 77 | 78 | subscribeLevel2Updates(market) { 79 | this._subscribe(market, this._level2UpdateSubs, "subscribing to level2 updates"); 80 | } 81 | 82 | unsubscribeTrades(market) { 83 | this._unsubscribe(market, this._tradeSubs, "unsubscribing from trades"); 84 | } 85 | 86 | unsubscribeLevel2Updates(market) { 87 | this._unsubscribe(market, this._level2UpdateSubs, "unsubscribing from level2 updates"); 88 | } 89 | 90 | //////////////////////////////////// 91 | // PROTECTED 92 | 93 | _resetSubCount() { 94 | this._subCount = {}; 95 | } 96 | 97 | _subscribe(market, map, msg) { 98 | this._connect(); 99 | let remote_id = market.id; 100 | 101 | if (!map.has(remote_id)) { 102 | winston.info(msg, "Bittrex", remote_id); 103 | map.set(remote_id, market); 104 | 105 | if (this._wss) { 106 | this._sendSub(remote_id); 107 | } 108 | } 109 | } 110 | 111 | _unsubscribe(market, map, msg) { 112 | let remote_id = market.id; 113 | if (map.has(remote_id)) { 114 | winston.info(msg, "Bittrex", remote_id); 115 | map.delete(remote_id); 116 | 117 | if (this._wss) { 118 | this._sendUnsub(remote_id); 119 | } 120 | } 121 | } 122 | 123 | _sendSub(remote_id) { 124 | // increment market counter 125 | this._subCount[remote_id] = (this._subCount[remote_id] || 0) + 1; 126 | 127 | // if we have more than one sub, ignore the request as we're already subbed 128 | if (this._subCount[remote_id] > 1) return; 129 | 130 | // initiate snapshot request 131 | if (this._level2UpdateSubs.has(remote_id)) { 132 | this._wss.call("CoreHub", "QueryExchangeState", remote_id).done((err, result) => { 133 | if (err) return winston.error("snapshot failed", remote_id, err); 134 | if (!result) return winston.warn("snapshot empty", remote_id); 135 | result.MarketName = remote_id; 136 | let snapshot = this._constructLevel2Snapshot(result); 137 | this.emit("l2snapshot", snapshot); 138 | }); 139 | } 140 | 141 | // initiate the subscription 142 | this._wss.call("CoreHub", "SubscribeToExchangeDeltas", remote_id).done(err => { 143 | if (err) return winston.error("subscribe failed", remote_id, err); 144 | }); 145 | } 146 | 147 | _sendUnsub(remote_id) { 148 | // decrement market count 149 | this._subCount[remote_id] -= 1; 150 | 151 | // if we still have subs, then leave channel open 152 | if (this._subCount[remote_id]) return; 153 | 154 | // otherwise initiate the unsubscription 155 | this._wss.call("CoreHub", "UnsubscribeToExchangeDeltas", remote_id).done(err => { 156 | if (err) winston.error("ussubscribe failed", remote_id); 157 | }); 158 | } 159 | 160 | _sendSubTickers() { 161 | if (this._tickerConnected) return; 162 | this._wss.call("CoreHub", "SubscribeToSummaryDeltas").done(err => { 163 | if (err) winston.error("ticker subscribe failed"); 164 | else this._tickerConnected = true; 165 | }); 166 | } 167 | 168 | _sendUnsubTicker(remote_id) { 169 | this._wss.call("CoreHub", "UnsubscribeToSummaryDeltas", remote_id).done(err => { 170 | if (err) winston.error("ticker unsubscribe failed", remote_id); 171 | }); 172 | } 173 | 174 | async _connectCloudflare() { 175 | return new Promise((resolve, reject) => { 176 | winston.info("cloudflare connection to https://bittrex.com/"); 177 | cloudscraper.get("https://bittrex.com/", (err, res) => { 178 | if (err) return reject(err); 179 | else 180 | resolve({ 181 | cookie: res.request.headers["cookie"] || "", 182 | user_agent: res.request.headers["User-Agent"] || "", 183 | }); 184 | }); 185 | }); 186 | } 187 | 188 | async _connect() { 189 | // ignore wss creation is we already are connected 190 | if (this._wss) return; 191 | 192 | // connect to cloudflare once and cache the promise 193 | if (!this._cloudflare) this._cloudflare = this._connectCloudflare(); 194 | 195 | // wait for single connection to cloudflare 196 | let metadata = await this._cloudflare; 197 | 198 | // doublecheck if wss was already created 199 | if (this._wss) return; 200 | 201 | let wss = (this._wss = new signalr.client( 202 | "wss://socket.bittrex.com/signalr", // service url 203 | ["CoreHub"], // hubs 204 | undefined, // disable reconnection 205 | true // wait till .start() called 206 | )); 207 | 208 | wss.headers["User-Agent"] = metadata.user_agent; 209 | wss.headers["cookie"] = metadata.cookie; 210 | 211 | wss.start(); 212 | wss.serviceHandlers = { 213 | connected: this._onConnected.bind(this), 214 | disconnected: this._onDisconnected.bind(this), 215 | messageReceived: this._onMessage.bind(this), 216 | onerror: err => winston.error("error", err).error, 217 | connectionlost: err => winston.error("connectionlost", err), 218 | connectfailed: err => winston.error("connectfailed", err), 219 | reconnecting: () => true, // disables reconnection 220 | }; 221 | } 222 | 223 | _onConnected() { 224 | winston.info("connected to wss://socket.bittrex.com/signalr"); 225 | clearTimeout(this._reconnectHandle); 226 | this.emit("connected"); 227 | this._subCount = {}; 228 | this._tickerConnected = false; 229 | this._watcher.start(); 230 | for (let marketSymbol of this._tickerSubs.keys()) { 231 | this._sendSubTickers(marketSymbol); 232 | } 233 | for (let marketSymbol of this._tradeSubs.keys()) { 234 | this._sendSub(marketSymbol); 235 | } 236 | for (let marketSymbol of this._level2UpdateSubs.keys()) { 237 | this._sendSub(marketSymbol); 238 | } 239 | } 240 | 241 | _onDisconnected() { 242 | clearTimeout(this._reconnectHandle); 243 | this._watcher.stop(); 244 | this.emit("disconnected"); 245 | this._reconnectHandle = setTimeout(() => this.reconnect(false), this._retryTimeoutMs); 246 | } 247 | 248 | _onMessage(raw) { 249 | // message format 250 | // { type: 'utf8', utf8Data: '{"C":"d-5ED873F4-C,0|Ejin,0|Ejio,2|I:,67FC","M":[{"H":"CoreHub","M":"updateExchangeState","A":[{"MarketName":"BTC-ETH","Nounce":26620,"Buys":[{"Type":0,"Rate":0.07117610,"Quantity":7.22300000},{"Type":1,"Rate":0.07117608,"Quantity":0.0},{"Type":0,"Rate":0.07114400,"Quantity":0.08000000},{"Type":0,"Rate":0.07095001,"Quantity":0.46981436},{"Type":1,"Rate":0.05470000,"Quantity":0.0},{"Type":1,"Rate":0.05458200,"Quantity":0.0}],"Sells":[{"Type":2,"Rate":0.07164500,"Quantity":21.55180000},{"Type":1,"Rate":0.07179460,"Quantity":0.0},{"Type":0,"Rate":0.07180300,"Quantity":6.96349769},{"Type":0,"Rate":0.07190173,"Quantity":0.27815742},{"Type":1,"Rate":0.07221246,"Quantity":0.0},{"Type":0,"Rate":0.07223299,"Quantity":58.39672846},{"Type":1,"Rate":0.07676211,"Quantity":0.0}],"Fills":[]}]}]}' } 251 | 252 | if (!raw.utf8Data) return; 253 | raw = JSON.parse(raw.utf8Data); 254 | 255 | if (!raw.M) return; 256 | 257 | for (let msg of raw.M) { 258 | if (msg.M === "updateExchangeState") { 259 | msg.A.forEach(data => { 260 | if (this._tradeSubs.has(data.MarketName)) { 261 | data.Fills.forEach(fill => { 262 | let trade = this._constructTradeFromMessage(fill, data.MarketName); 263 | this.emit("trade", trade); 264 | }); 265 | } 266 | if (this._level2UpdateSubs.has(data.MarketName)) { 267 | let l2update = this._constructLevel2Update(data); 268 | this.emit("l2update", l2update); 269 | } 270 | }); 271 | } 272 | if (msg.M === "updateSummaryState") { 273 | for (let raw of msg.A[0].Deltas) { 274 | if (this._tickerSubs.has(raw.MarketName)) { 275 | let ticker = this._constructTicker(raw); 276 | this.emit("ticker", ticker); 277 | } 278 | } 279 | } 280 | } 281 | } 282 | 283 | _constructTicker(msg) { 284 | let market = this._tickerSubs.get(msg.MarketName); 285 | let { High, Low, Last, PrevDay, BaseVolume, Volume, TimeStamp, Bid, Ask } = msg; 286 | let change = Last - PrevDay; 287 | let percentChange = (Last - PrevDay) / PrevDay * 100; 288 | return new Ticker({ 289 | exchange: "Bittrex", 290 | base: market.base, 291 | quote: market.quote, 292 | timestamp: moment.utc(TimeStamp).valueOf(), 293 | last: Last.toFixed(8), 294 | open: PrevDay.toFixed(8), 295 | high: High.toFixed(8), 296 | low: Low.toFixed(8), 297 | volume: BaseVolume.toFixed(8), 298 | quoteVolume: Volume.toFixed(8), 299 | change: change.toFixed(8), 300 | changePercent: percentChange.toFixed(8), 301 | bid: Bid.toFixed(8), 302 | ask: Ask.toFixed(8), 303 | }); 304 | } 305 | 306 | _constructTradeFromMessage(msg, marketName) { 307 | let market = this._tradeSubs.get(marketName); 308 | let tradeId = this._getTradeId(msg); 309 | let unix = moment.utc(msg.TimeStamp).valueOf(); 310 | let price = msg.Rate.toFixed(8); 311 | let amount = msg.Quantity.toFixed(8); 312 | let side = msg.OrderType === "BUY" ? "buy" : "sell"; 313 | return new Trade({ 314 | exchange: "Bittrex", 315 | base: market.base, 316 | quote: market.quote, 317 | tradeId, 318 | unix, 319 | side, 320 | price, 321 | amount, 322 | }); 323 | } 324 | 325 | // prettier-ignore 326 | _constructLevel2Snapshot(msg) { 327 | let market = this._level2UpdateSubs.get(msg.MarketName); 328 | let sequenceId = msg.Nounce; 329 | let bids = msg.Buys.map(p => new Level2Point(p.Rate.toFixed(8), p.Quantity.toFixed(8), undefined, { type: p.Type })); 330 | let asks = msg.Sells.map(p => new Level2Point(p.Rate.toFixed(8), p.Quantity.toFixed(8), undefined, { type: p.Type })); 331 | return new Level2Snapshot({ 332 | exchange: "Bittrex", 333 | base: market.base, 334 | quote: market.quote, 335 | sequenceId, 336 | asks, 337 | bids, 338 | }); 339 | } 340 | 341 | // prettier-ignore 342 | _constructLevel2Update(msg) { 343 | let market = this._level2UpdateSubs.get(msg.MarketName); 344 | let sequenceId = msg.Nounce; 345 | let bids = msg.Buys.map(p => new Level2Point(p.Rate.toFixed(8), p.Quantity.toFixed(8), undefined, { type: p.Type })); 346 | let asks = msg.Sells.map(p => new Level2Point(p.Rate.toFixed(8), p.Quantity.toFixed(8), undefined, { type: p.Type })); 347 | return new Level2Update({ 348 | exchange: "Bittrex", 349 | base: market.base, 350 | quote: market.quote, 351 | sequenceId, 352 | asks, 353 | bids, 354 | }); 355 | } 356 | 357 | _getTradeId(msg) { 358 | let ms = moment.utc(msg.TimeStamp).valueOf(); 359 | let buysell = msg.OrderType === "BUY" ? 1 : 0; 360 | let price = msg.Rate.toFixed(8); 361 | let amount = msg.Quantity.toFixed(8); 362 | let preimage = `${ms}:${buysell}:${price}:${amount}`; 363 | let hasher = crypto.createHash("md5"); 364 | hasher.update(preimage); 365 | let tradeId = hasher.digest().toString("hex"); 366 | return tradeId; 367 | } 368 | } 369 | 370 | module.exports = BittrexClient; 371 | -------------------------------------------------------------------------------- /src/exchanges/bittrex/bittrex-client.spec.js: -------------------------------------------------------------------------------- 1 | const Bittrex = require("./bittrex-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "USDT-BTC", 7 | base: "BTC", 8 | quote: "USDT", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new Bittrex(); 13 | }); 14 | 15 | test("it should support tickers", () => { 16 | expect(client.hasTickers).toBeTruthy(); 17 | }); 18 | 19 | test("it should support trades", () => { 20 | expect(client.hasTrades).toBeTruthy(); 21 | }); 22 | 23 | test("it should not support level2 snapshots", () => { 24 | expect(client.hasLevel2Snapshots).toBeFalsy(); 25 | }); 26 | 27 | test("it should support level2 updates", () => { 28 | expect(client.hasLevel2Updates).toBeTruthy(); 29 | }); 30 | 31 | test("it should not support level3 snapshots", () => { 32 | expect(client.hasLevel3Snapshots).toBeFalsy(); 33 | }); 34 | 35 | test("it should not support level3 updates", () => { 36 | expect(client.hasLevel3Updates).toBeFalsy(); 37 | }); 38 | 39 | test( 40 | "should subscribe and emit ticker events", 41 | done => { 42 | client.subscribeTicker(market); 43 | client.on("ticker", ticker => { 44 | expect(ticker.fullId).toMatch("Bittrex:BTC/USDT"); 45 | expect(ticker.timestamp).toBeGreaterThan(1531677480465); 46 | expect(typeof ticker.last).toBe("string"); 47 | expect(typeof ticker.open).toBe("string"); 48 | expect(typeof ticker.high).toBe("string"); 49 | expect(typeof ticker.low).toBe("string"); 50 | expect(typeof ticker.volume).toBe("string"); 51 | expect(typeof ticker.quoteVolume).toBe("string"); 52 | expect(typeof ticker.change).toBe("string"); 53 | expect(typeof ticker.changePercent).toBe("string"); 54 | expect(typeof ticker.bid).toBe("string"); 55 | expect(typeof ticker.ask).toBe("string"); 56 | expect(parseFloat(ticker.last)).toBeGreaterThan(0); 57 | expect(parseFloat(ticker.open)).toBeGreaterThan(0); 58 | expect(parseFloat(ticker.high)).toBeGreaterThan(0); 59 | expect(parseFloat(ticker.low)).toBeGreaterThan(0); 60 | expect(parseFloat(ticker.volume)).toBeGreaterThan(0); 61 | expect(parseFloat(ticker.quoteVolume)).toBeGreaterThan(0); 62 | expect(Math.abs(parseFloat(ticker.change))).toBeGreaterThan(0); 63 | expect(Math.abs(parseFloat(ticker.changePercent))).toBeGreaterThan(0); 64 | expect(parseFloat(ticker.bid)).toBeGreaterThan(0); 65 | expect(ticker.bidVolume).toBeUndefined(); 66 | expect(parseFloat(ticker.ask)).toBeGreaterThan(0); 67 | expect(ticker.askVolume).toBeUndefined(); 68 | done(); 69 | }); 70 | }, 71 | 90000 72 | ); 73 | 74 | test( 75 | "should subscribe and emit level2 snapshot and updates", 76 | done => { 77 | let hasSnapshot = false; 78 | client.subscribeLevel2Updates(market); 79 | client.on("l2snapshot", snapshot => { 80 | hasSnapshot = true; 81 | expect(snapshot.fullId).toMatch("Bittrex:BTC/USDT"); 82 | expect(snapshot.exchange).toMatch("Bittrex"); 83 | expect(snapshot.base).toMatch("BTC"); 84 | expect(snapshot.quote).toMatch("USDT"); 85 | expect(snapshot.sequenceId).toBeGreaterThan(0); 86 | expect(snapshot.timestampMs).toBeUndefined(); 87 | expect(typeof snapshot.asks[0].price).toBe("string"); 88 | expect(typeof snapshot.asks[0].size).toBe("string"); 89 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 90 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 91 | expect(snapshot.asks[0].count).toBeUndefined(); 92 | expect(typeof snapshot.bids[0].price).toBe("string"); 93 | expect(typeof snapshot.bids[0].size).toBe("string"); 94 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 95 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 96 | expect(snapshot.bids[0].count).toBeUndefined(); 97 | }); 98 | client.on("l2update", update => { 99 | expect(update.fullId).toMatch("Bittrex:BTC/USDT"); 100 | expect(update.exchange).toMatch("Bittrex"); 101 | expect(update.base).toMatch("BTC"); 102 | expect(update.quote).toMatch("USDT"); 103 | expect(update.sequenceId).toBeGreaterThan(0); 104 | expect(update.timestampMs).toBeUndefined(); 105 | let point = update.asks[0] || update.bids[0]; 106 | expect(typeof point.price).toBe("string"); 107 | expect(typeof point.size).toBe("string"); 108 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 109 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 110 | expect(point.count).toBeUndefined(); 111 | expect(point.meta.type).toBeGreaterThanOrEqual(0); 112 | if (hasSnapshot) done(); 113 | }); 114 | }, 115 | 90000 116 | ); 117 | 118 | test( 119 | "should subscribe and emit trade events", 120 | done => { 121 | client.subscribeTrades(market); 122 | client.on("trade", trade => { 123 | expect(trade.fullId).toMatch("Bittrex:BTC/USDT"); 124 | expect(trade.exchange).toMatch("Bittrex"); 125 | expect(trade.base).toMatch("BTC"); 126 | expect(trade.quote).toMatch("USDT"); 127 | expect(trade.tradeId).toMatch(/^[0-9a-f]{32,32}$/); 128 | expect(trade.unix).toBeGreaterThan(1522540800000); 129 | expect(trade.side).toMatch(/buy|sell/); 130 | expect(typeof trade.price).toBe("string"); 131 | expect(typeof trade.amount).toBe("string"); 132 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 133 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 134 | done(); 135 | }); 136 | }, 137 | 90000 138 | ); 139 | 140 | test("should unsubscribe from tickers", () => { 141 | client.unsubscribeTicker(market); 142 | }); 143 | 144 | test("should unsubscribe from trade events", () => { 145 | client.unsubscribeTrades(market); 146 | }); 147 | 148 | test("should unsubscribe from level2 updates", () => { 149 | client.unsubscribeLevel2Updates(market); 150 | }); 151 | 152 | test("should close connections", done => { 153 | client.on("closed", done); 154 | client.close(); 155 | }); 156 | -------------------------------------------------------------------------------- /src/exchanges/gdax/gdax-client.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment"); 2 | const BasicClient = require("../../basic-client"); 3 | const Ticker = require("../../type/ticker"); 4 | const Trade = require("../../type/trade"); 5 | const Level2Point = require("../../type/level2-point"); 6 | const Level2Snapshot = require("../../type/level2-snapshot"); 7 | const Level2Update = require("../../type/level2-update"); 8 | const Level3Point = require("../../type/level3-point"); 9 | const Level3Update = require("../../type/level3-update"); 10 | 11 | class GdaxClient extends BasicClient { 12 | constructor() { 13 | super("wss://ws-feed.gdax.com", "GDAX"); 14 | this.hasTickers = true; 15 | this.hasTrades = true; 16 | this.hasLevel2Spotshots = false; 17 | this.hasLevel2Updates = true; 18 | this.hasLevel3Updates = true; 19 | } 20 | 21 | _sendSubTicker(remote_id) { 22 | this._wss.send( 23 | JSON.stringify({ 24 | type: "subscribe", 25 | product_ids: [remote_id], 26 | channels: ["ticker"], 27 | }) 28 | ); 29 | } 30 | 31 | _sendSubTrades(remote_id) { 32 | this._wss.send( 33 | JSON.stringify({ 34 | type: "subscribe", 35 | product_ids: [remote_id], 36 | channels: ["matches"], 37 | }) 38 | ); 39 | } 40 | 41 | _sendUnsubTrades(remote_id) { 42 | this._wss.send( 43 | JSON.stringify({ 44 | type: "unsubscribe", 45 | product_ids: [remote_id], 46 | channels: ["matches"], 47 | }) 48 | ); 49 | } 50 | 51 | _sendSubLevel2Updates(remote_id) { 52 | this._wss.send( 53 | JSON.stringify({ 54 | type: "subscribe", 55 | product_ids: [remote_id], 56 | channels: ["level2"], 57 | }) 58 | ); 59 | } 60 | 61 | _sendUnsubLevel2Updates(remote_id) { 62 | this._wss.send( 63 | JSON.stringify({ 64 | type: "unsubscribe", 65 | product_ids: [remote_id], 66 | channels: ["level2"], 67 | }) 68 | ); 69 | } 70 | 71 | _sendSubLevel3Updates(remote_id) { 72 | this._wss.send( 73 | JSON.stringify({ 74 | type: "subscribe", 75 | product_ids: [remote_id], 76 | channels: ["full"], 77 | }) 78 | ); 79 | } 80 | 81 | _sendUnsubLevel3Updates(remote_id) { 82 | this._wss.send( 83 | JSON.stringify({ 84 | type: "unsubscribe", 85 | product_ids: [remote_id], 86 | channels: ["full"], 87 | }) 88 | ); 89 | } 90 | 91 | _onMessage(raw) { 92 | let msg = JSON.parse(raw); 93 | 94 | let { type, product_id } = msg; 95 | 96 | if (type === "ticker" && this._tickerSubs.has(product_id)) { 97 | let ticker = this._constructTicker(msg); 98 | this.emit("ticker", ticker); 99 | } 100 | 101 | if (type === "match" && this._tradeSubs.has(product_id)) { 102 | let trade = this._constructTrade(msg); 103 | this.emit("trade", trade); 104 | } 105 | 106 | if (type === "snapshot" && this._level2UpdateSubs.has(product_id)) { 107 | let snapshot = this._constructLevel2Snapshot(msg); 108 | this.emit("l2snapshot", snapshot); 109 | } 110 | 111 | if (type === "l2update" && this._level2UpdateSubs.has(product_id)) { 112 | let update = this._constructLevel2Update(msg); 113 | this.emit("l2update", update); 114 | } 115 | 116 | if ( 117 | ["received", "open", "done", "match", "change"].includes(type) && 118 | this._level3UpdateSubs.has(product_id) 119 | ) { 120 | let update = this._constructLevel3Update(msg); 121 | this.emit("l3update", update); 122 | return; 123 | } 124 | } 125 | 126 | _constructTicker(msg) { 127 | let { 128 | product_id, 129 | price, 130 | volume_24h, 131 | open_24h, 132 | low_24h, 133 | high_24h, 134 | best_bid, 135 | best_ask, 136 | time, 137 | } = msg; 138 | let market = this._tickerSubs.get(product_id); 139 | let change = parseFloat(price) - parseFloat(open_24h); 140 | let changePercent = (parseFloat(price) - parseFloat(open_24h)) / parseFloat(open_24h) * 100; 141 | return new Ticker({ 142 | exchange: "GDAX", 143 | base: market.base, 144 | quote: market.quote, 145 | timestamp: moment.utc(time).valueOf(), 146 | last: price, 147 | open: open_24h, 148 | high: high_24h, 149 | low: low_24h, 150 | volume: volume_24h, 151 | change: change.toFixed(8), 152 | changePercent: changePercent.toFixed(8), 153 | bid: best_bid, 154 | ask: best_ask, 155 | }); 156 | } 157 | 158 | _constructTrade(msg) { 159 | let { trade_id, time, product_id, size, price, side, maker_order_id, taker_order_id } = msg; 160 | 161 | let market = this._tradeSubs.get(product_id); 162 | 163 | let unix = moment.utc(time).valueOf(); 164 | 165 | maker_order_id = maker_order_id.replace(/-/g, ""); 166 | taker_order_id = taker_order_id.replace(/-/g, ""); 167 | 168 | let buyOrderId = side === "buy" ? maker_order_id : taker_order_id; 169 | let sellOrderId = side === "sell" ? maker_order_id : taker_order_id; 170 | 171 | return new Trade({ 172 | exchange: "GDAX", 173 | base: market.base, 174 | quote: market.quote, 175 | tradeId: trade_id, 176 | unix, 177 | side, 178 | price, 179 | amount: size, 180 | buyOrderId, 181 | sellOrderId, 182 | }); 183 | } 184 | 185 | _constructLevel2Snapshot(msg) { 186 | let { product_id, bids, asks } = msg; 187 | 188 | let market = this._level2UpdateSubs.get(product_id); 189 | 190 | bids = bids.map(([price, size]) => new Level2Point(price, size)); 191 | asks = asks.map(([price, size]) => new Level2Point(price, size)); 192 | 193 | return new Level2Snapshot({ 194 | exchange: "GDAX", 195 | base: market.base, 196 | quote: market.quote, 197 | bids, 198 | asks, 199 | }); 200 | } 201 | 202 | _constructLevel2Update(msg) { 203 | let { product_id, changes } = msg; 204 | 205 | let market = this._level2UpdateSubs.get(product_id); 206 | 207 | let asks = []; 208 | let bids = []; 209 | changes.forEach(([side, price, size]) => { 210 | let point = new Level2Point(price, size); 211 | if (side === "buy") bids.push(point); 212 | else asks.push(point); 213 | }); 214 | 215 | return new Level2Update({ 216 | exchange: "GDAX", 217 | base: market.base, 218 | quote: market.quote, 219 | asks, 220 | bids, 221 | }); 222 | } 223 | 224 | _constructLevel3Update(msg) { 225 | let market = this._level3UpdateSubs.get(msg.product_id); 226 | let timestampMs = moment(msg.time).valueOf(); 227 | let sequenceId = msg.sequence; 228 | 229 | let asks = []; 230 | let bids = []; 231 | let point; 232 | 233 | switch (msg.type) { 234 | case "received": 235 | point = new Level3Point(msg.order_id.replace(/-/g, ""), msg.price, msg.size, { 236 | type: msg.type, 237 | side: msg.side, 238 | order_type: msg.order_type, 239 | funds: msg.funds, 240 | }); 241 | break; 242 | case "open": 243 | point = new Level3Point(msg.order_id.replace(/-/g, ""), msg.price, msg.remaining_size, { 244 | type: msg.type, 245 | remaining_size: msg.remaining_size, 246 | }); 247 | break; 248 | case "done": 249 | point = new Level3Point(msg.order_id.replace(/-/g, ""), msg.price, msg.remaining_size, { 250 | type: msg.type, 251 | reason: msg.reason, 252 | remaining_size: msg.remaining_size, 253 | }); 254 | break; 255 | case "match": 256 | point = new Level3Point(msg.maker_order_id.replace(/-/g, ""), msg.price, msg.size, { 257 | type: msg.type, 258 | trade_id: msg.trade_id, 259 | maker_order_id: msg.maker_order_id.replace(/-/g, ""), 260 | taker_order_id: msg.taker_order_id.replace(/-/g, ""), 261 | }); 262 | break; 263 | case "change": 264 | point = new Level3Point(msg.order_id.replace(/-/g, ""), msg.price, msg.new_size, { 265 | type: msg.type, 266 | new_size: msg.new_size, 267 | old_size: msg.old_size, 268 | new_funds: msg.new_funds, 269 | old_funds: msg.old_funds, 270 | }); 271 | break; 272 | } 273 | 274 | if (msg.side === "sell") asks.push(point); 275 | else bids.push(point); 276 | 277 | return new Level3Update({ 278 | exchange: "GDAX", 279 | base: market.base, 280 | quote: market.quote, 281 | sequenceId, 282 | timestampMs, 283 | asks, 284 | bids, 285 | }); 286 | } 287 | } 288 | 289 | module.exports = GdaxClient; 290 | -------------------------------------------------------------------------------- /src/exchanges/gdax/gdax-client.spec.js: -------------------------------------------------------------------------------- 1 | const GDAX = require("./gdax-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "BTC-USD", 7 | base: "BTC", 8 | quote: "USD", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new GDAX(); 13 | }); 14 | 15 | test("it should support tickers", () => { 16 | expect(client.hasTickers).toBeTruthy(); 17 | }); 18 | 19 | test("it should support trades", () => { 20 | expect(client.hasTrades).toBeTruthy(); 21 | }); 22 | 23 | test("it should not support level2 snapshots", () => { 24 | expect(client.hasLevel2Snapshots).toBeFalsy(); 25 | }); 26 | 27 | test("it should support level2 updates", () => { 28 | expect(client.hasLevel2Updates).toBeTruthy(); 29 | }); 30 | 31 | test("it should not support level3 snapshots", () => { 32 | expect(client.hasLevel3Snapshots).toBeFalsy(); 33 | }); 34 | 35 | test("it should support level3 updates", () => { 36 | expect(client.hasLevel3Updates).toBeTruthy(); 37 | }); 38 | 39 | test( 40 | "should subscribe and emit ticker events", 41 | done => { 42 | client.subscribeTicker(market); 43 | client.on("ticker", ticker => { 44 | expect(ticker.fullId).toMatch("GDAX:BTC/USD"); 45 | expect(ticker.timestamp).toBeGreaterThan(1531677480465); 46 | expect(typeof ticker.last).toBe("string"); 47 | expect(typeof ticker.open).toBe("string"); 48 | expect(typeof ticker.high).toBe("string"); 49 | expect(typeof ticker.low).toBe("string"); 50 | expect(typeof ticker.volume).toBe("string"); 51 | expect(typeof ticker.change).toBe("string"); 52 | expect(typeof ticker.changePercent).toBe("string"); 53 | expect(typeof ticker.bid).toBe("string"); 54 | expect(typeof ticker.ask).toBe("string"); 55 | expect(parseFloat(ticker.last)).toBeGreaterThan(0); 56 | expect(parseFloat(ticker.open)).toBeGreaterThan(0); 57 | expect(parseFloat(ticker.high)).toBeGreaterThan(0); 58 | expect(parseFloat(ticker.low)).toBeGreaterThan(0); 59 | expect(parseFloat(ticker.volume)).toBeGreaterThan(0); 60 | expect(Math.abs(parseFloat(ticker.change))).toBeGreaterThan(0); 61 | expect(Math.abs(parseFloat(ticker.changePercent))).toBeGreaterThan(0); 62 | expect(parseFloat(ticker.bid)).toBeGreaterThan(0); 63 | expect(ticker.bidVolume).toBeUndefined(); 64 | expect(parseFloat(ticker.ask)).toBeGreaterThan(0); 65 | expect(ticker.askVolume).toBeUndefined(); 66 | done(); 67 | }); 68 | }, 69 | 10000 70 | ); 71 | 72 | test( 73 | "should subscribe and emit trade events", 74 | done => { 75 | client.subscribeTrades(market); 76 | client.on("trade", trade => { 77 | expect(trade.fullId).toMatch("GDAX:BTC/USD"); 78 | expect(trade.exchange).toMatch("GDAX"); 79 | expect(trade.base).toMatch("BTC"); 80 | expect(trade.quote).toMatch("USD"); 81 | expect(trade.tradeId).toBeGreaterThan(0); 82 | expect(trade.unix).toBeGreaterThan(1522540800000); 83 | expect(trade.side).toMatch(/buy|sell/); 84 | expect(typeof trade.price).toBe("string"); 85 | expect(typeof trade.amount).toBe("string"); 86 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 87 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 88 | expect(trade.buyOrderId).toMatch(/^[0-9a-f]{32,32}$/); 89 | expect(trade.sellOrderId).toMatch(/^[0-9a-f]{32,32}$/); 90 | expect(trade.buyOrderId).not.toEqual(trade.sellOrderId); 91 | done(); 92 | }); 93 | }, 94 | 30000 95 | ); 96 | 97 | test("should subscribe and emit level2 snapshot and updates", done => { 98 | let hasSnapshot = false; 99 | client.subscribeLevel2Updates(market); 100 | client.on("l2snapshot", snapshot => { 101 | hasSnapshot = true; 102 | expect(snapshot.fullId).toMatch("GDAX:BTC/USD"); 103 | expect(snapshot.exchange).toMatch("GDAX"); 104 | expect(snapshot.base).toMatch("BTC"); 105 | expect(snapshot.quote).toMatch("USD"); 106 | expect(snapshot.sequenceId).toBeUndefined(); 107 | expect(snapshot.timestampMs).toBeUndefined(); 108 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 109 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 110 | expect(snapshot.asks[0].count).toBeUndefined(); 111 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 112 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 113 | expect(snapshot.bids[0].count).toBeUndefined(); 114 | }); 115 | client.on("l2update", update => { 116 | expect(hasSnapshot).toBeTruthy(); 117 | expect(update.fullId).toMatch("GDAX:BTC/USD"); 118 | expect(update.exchange).toMatch("GDAX"); 119 | expect(update.base).toMatch("BTC"); 120 | expect(update.quote).toMatch("USD"); 121 | expect(update.sequenceId).toBeUndefined(); 122 | expect(update.timestampMs).toBeUndefined(); 123 | let point = update.asks[0] || update.bids[0]; 124 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 125 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 126 | expect(point.count).toBeUndefined(); 127 | done(); 128 | }); 129 | }); 130 | 131 | test( 132 | "should subscribe and emit level3 updates", 133 | done => { 134 | let hasReceived, hasOpen, hasDone, hasMatch; 135 | let point; 136 | client.subscribeLevel3Updates(market); 137 | client.on("l3update", update => { 138 | try { 139 | expect(update.fullId).toMatch("GDAX:BTC/USD"); 140 | expect(update.exchange).toMatch("GDAX"); 141 | expect(update.base).toMatch("BTC"); 142 | expect(update.quote).toMatch("USD"); 143 | expect(update.sequenceId).toBeGreaterThan(0); 144 | expect(update.timestampMs).toBeGreaterThan(0); 145 | point = update.asks[0] || update.bids[0]; 146 | expect(point.orderId).toMatch(/^[a-f0-9]{32,32}$/); 147 | 148 | switch (point.meta.type) { 149 | case "received": 150 | hasReceived = true; 151 | // if (point.meta.order_type === "market") { 152 | // expect(parseFloat(point.meta.funds)).toBeGreaterThan(0); 153 | // } else 154 | if (point.meta.order_type === "limit") { 155 | expect(parseFloat(point.price)).toBeGreaterThan(0); 156 | expect(parseFloat(point.size)).toBeGreaterThan(0); 157 | } 158 | // else throw new Error("unknown type " + point.meta.order_type); 159 | break; 160 | case "open": 161 | hasOpen = true; 162 | expect(parseFloat(point.price)).toBeGreaterThan(0); 163 | expect(parseFloat(point.size)).toBeGreaterThan(0); 164 | expect(parseFloat(point.meta.remaining_size)).toBeGreaterThanOrEqual(0); 165 | break; 166 | case "done": 167 | hasDone = true; 168 | // removed because we may sometimes have data 169 | // expect(parseFloat(point.price)).toBeGreaterThan(0); 170 | // expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 171 | // expect(parseFloat(point.meta.remaining_size)).toBeGreaterThanOrEqual(0); 172 | expect(point.meta.reason).toMatch(/filled|canceled/); 173 | break; 174 | case "match": 175 | hasMatch = true; 176 | expect(parseFloat(point.price)).toBeGreaterThan(0); 177 | expect(parseFloat(point.size)).toBeGreaterThan(0); 178 | expect(point.meta.trade_id).toBeGreaterThan(0); 179 | expect(point.meta.maker_order_id).toMatch(/^[a-f0-9]{32,32}$/); 180 | expect(point.meta.taker_order_id).toMatch(/^[a-f0-9]{32,32}$/); 181 | break; 182 | } 183 | 184 | if (hasReceived && hasOpen && hasDone && hasMatch) done(); 185 | } catch (ex) { 186 | console.log(point); 187 | throw ex; 188 | } 189 | }); 190 | }, 191 | 30000 192 | ); 193 | 194 | test("unsubscribe from trades", () => { 195 | client.unsubscribeTrades(market); 196 | }); 197 | 198 | test("unsubscribe from level2 updates", () => { 199 | client.unsubscribeLevel2Updates(market); 200 | }); 201 | 202 | test("unsubscribe from level3 updates", () => { 203 | client.unsubscribeLevel3Updates(market); 204 | }); 205 | 206 | test("should close connections", done => { 207 | client.on("closed", done); 208 | client.close(); 209 | }); 210 | -------------------------------------------------------------------------------- /src/exchanges/gemini/gemini-client.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require("events"); 2 | const Trade = require("../../type/trade"); 3 | const Level2Point = require("../../type/level2-point"); 4 | const Level2Snapshot = require("../../type/level2-snapshot"); 5 | const Level2Update = require("../../type/level2-update"); 6 | const SmartWss = require("../../smart-wss"); 7 | const winston = require("winston"); 8 | 9 | class GeminiClient extends EventEmitter { 10 | constructor() { 11 | super(); 12 | this._name = "Gemini"; 13 | this._subscriptions = new Map(); 14 | this.reconnectIntervalMs = 90000; 15 | 16 | this.hasTrades = true; 17 | this.hasLevel2Snapshots = false; 18 | this.hasLevel2Updates = true; 19 | this.hasLevel3Snapshots = false; 20 | this.hasLevel3Updates = false; 21 | } 22 | 23 | subscribeTrades(market) { 24 | this._subscribe(market, "trades"); 25 | } 26 | 27 | unsubscribeTrades(market) { 28 | this._unsubscribe(market, "trades"); 29 | } 30 | 31 | subscribeLevel2Updates(market) { 32 | this._subscribe(market, "level2updates"); 33 | } 34 | 35 | unsubscribeLevel2Updates(market) { 36 | this._unsubscribe(market, "level2updates"); 37 | } 38 | 39 | close() { 40 | this._close(); 41 | } 42 | 43 | //////////////////////////////////////////// 44 | // PROTECTED 45 | 46 | _subscribe(market, mode) { 47 | let remote_id = market.id.toLowerCase(); 48 | let subscription = this._subscriptions.get(remote_id); 49 | 50 | if (subscription && subscription[mode]) return; 51 | 52 | winston.info("subscribing to " + mode, this._name, remote_id); 53 | 54 | if (!subscription) { 55 | subscription = { 56 | market, 57 | wss: this._connect(remote_id), 58 | lastMessage: undefined, 59 | reconnectIntervalHandle: undefined, 60 | remoteId: remote_id, 61 | trades: false, 62 | level2Updates: false, 63 | }; 64 | 65 | this._startReconnectWatcher(subscription); 66 | this._subscriptions.set(remote_id, subscription); 67 | } 68 | 69 | subscription[mode] = true; 70 | } 71 | 72 | _unsubscribe(market, mode) { 73 | let remote_id = market.id.toLowerCase(); 74 | let subscription = this._subscriptions.get(remote_id); 75 | 76 | if (!subscription) return; 77 | 78 | winston.info("unsubscribing from " + mode, this._name, remote_id); 79 | 80 | subscription[mode] = false; 81 | if (!subscription.trades && !subscription.level2updates) { 82 | this._close(this._subscriptions.get(remote_id)); 83 | this._subscriptions.delete(remote_id); 84 | } 85 | } 86 | 87 | /** Connect to the websocket stream by constructing a path from 88 | * the subscribed markets. 89 | */ 90 | _connect(remote_id) { 91 | let wssPath = "wss://api.gemini.com/v1/marketdata/" + remote_id; 92 | let wss = new SmartWss(wssPath); 93 | wss.on("open", () => this._onConnected(remote_id)); 94 | wss.on("message", raw => this._onMessage(remote_id, raw)); 95 | wss.on("disconnected", () => this._onDisconnected(remote_id)); 96 | wss.connect(); 97 | return wss; 98 | } 99 | 100 | /** 101 | * Fires when connected 102 | */ 103 | _onConnected(remote_id) { 104 | this._startReconnectWatcher(this._subscriptions.get(remote_id)); 105 | } 106 | 107 | /** 108 | * Fires when there is a disconnection event 109 | */ 110 | _onDisconnected(remote_id) { 111 | this._stopReconnectWatcher(this._subscriptions.get(remote_id)); 112 | this.emit("disconnected", remote_id); 113 | } 114 | 115 | /** 116 | * Close the underlying connction, which provides a way to reset the things 117 | */ 118 | _close(subscription) { 119 | if (subscription && subscription.wss) { 120 | subscription.wss.close(); 121 | subscription.wss = undefined; 122 | this._stopReconnectWatcher(subscription); 123 | } else { 124 | this._subscriptions.forEach(sub => { 125 | this._stopReconnectWatcher(sub); 126 | sub.wss.close(); 127 | }); 128 | this.emit("closed"); 129 | this._subscriptions = new Map(); 130 | } 131 | } 132 | 133 | /** 134 | * Reconnects the socket 135 | */ 136 | _reconnect(subscription) { 137 | this._close(subscription.wss); 138 | subscription.wss = this._connect(subscription.remoteId); 139 | this.emit("reconnected", subscription.remoteId); 140 | } 141 | 142 | /** 143 | * Starts an interval to check if a reconnction is required 144 | */ 145 | _startReconnectWatcher(subscription) { 146 | this._stopReconnectWatcher(subscription); // always clear the prior interval 147 | subscription.reconnectIntervalHandle = setInterval( 148 | () => this._onReconnectCheck(subscription), 149 | this.reconnectIntervalMs 150 | ); 151 | } 152 | 153 | /** 154 | * Stops an interval to check if a reconnection is required 155 | */ 156 | _stopReconnectWatcher(subscription) { 157 | clearInterval(subscription.reconnectIntervalHandle); 158 | subscription.reconnectIntervalHandle = undefined; 159 | } 160 | 161 | /** 162 | * Checks if a reconnecton is required by comparing the current 163 | * date to the last receieved message date 164 | */ 165 | _onReconnectCheck(subscription) { 166 | if (subscription.lastMessage < Date.now() - this.reconnectIntervalMs) { 167 | this._reconnect(subscription); 168 | } 169 | } 170 | 171 | //////////////////////////////////////////// 172 | // ABSTRACT 173 | 174 | _onMessage(remote_id, raw) { 175 | let msg = JSON.parse(raw); 176 | let subscription = this._subscriptions.get(remote_id); 177 | let market = subscription.market; 178 | subscription.lastMessage = Date.now(); 179 | 180 | if (!market) return; 181 | 182 | if (msg.type === "update") { 183 | let { timestampms, eventId, socket_sequence } = msg; 184 | 185 | // process trades 186 | if (subscription.trades) { 187 | let events = msg.events.filter(p => p.type === "trade" && /ask|bid/.test(p.makerSide)); 188 | for (let event of events) { 189 | let trade = this._constructTrade(event, market, timestampms); 190 | this.emit("trade", trade); 191 | } 192 | } 193 | 194 | // process l2 updates 195 | if (subscription.level2updates) { 196 | let updates = msg.events.filter(p => p.type === "change"); 197 | if (socket_sequence === 0) { 198 | let snapshot = this._constructL2Snapshot(updates, market, eventId); 199 | this.emit("l2snapshot", snapshot); 200 | } else { 201 | let update = this._constructL2Update(updates, market, eventId, timestampms); 202 | this.emit("l2update", update); 203 | } 204 | } 205 | } 206 | } 207 | 208 | _constructTrade(event, market, timestamp) { 209 | let side = event.makerSide === "ask" ? "sell" : "buy"; 210 | let price = event.price; 211 | let amount = event.amount; 212 | 213 | return new Trade({ 214 | exchange: "Gemini", 215 | base: market.base, 216 | quote: market.quote, 217 | tradeId: event.tid, 218 | side, 219 | unix: timestamp, 220 | price, 221 | amount, 222 | }); 223 | } 224 | 225 | _constructL2Snapshot(events, market, sequenceId) { 226 | let asks = []; 227 | let bids = []; 228 | 229 | for (let { side, price, remaining, reason, delta } of events) { 230 | let update = new Level2Point(price, remaining, undefined, { reason, delta }); 231 | if (side === "ask") asks.push(update); 232 | else bids.push(update); 233 | } 234 | 235 | return new Level2Snapshot({ 236 | exchange: "Gemini", 237 | base: market.base, 238 | quote: market.quote, 239 | sequenceId, 240 | asks, 241 | bids, 242 | }); 243 | } 244 | 245 | _constructL2Update(events, market, sequenceId, timestampMs) { 246 | let asks = []; 247 | let bids = []; 248 | 249 | for (let { side, price, remaining, reason, delta } of events) { 250 | let update = new Level2Point(price, remaining, undefined, { reason, delta }); 251 | if (side === "ask") asks.push(update); 252 | else bids.push(update); 253 | } 254 | 255 | return new Level2Update({ 256 | exchange: "Gemini", 257 | base: market.base, 258 | quote: market.quote, 259 | sequenceId, 260 | timestampMs, 261 | asks, 262 | bids, 263 | }); 264 | } 265 | } 266 | 267 | module.exports = GeminiClient; 268 | -------------------------------------------------------------------------------- /src/exchanges/gemini/gemini-client.spec.js: -------------------------------------------------------------------------------- 1 | const Gemini = require("./gemini-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market1 = { 6 | id: "btcusd", 7 | base: "BTC", 8 | quote: "USD", 9 | }; 10 | let market2 = { 11 | id: "ethusd", 12 | base: "ETH", 13 | quote: "USD", 14 | }; 15 | 16 | beforeAll(() => { 17 | client = new Gemini(); 18 | }); 19 | 20 | test("it should support trades", () => { 21 | expect(client.hasTrades).toBeTruthy(); 22 | }); 23 | 24 | test("it should not support level2 snapshots", () => { 25 | expect(client.hasLevel2Snapshots).toBeFalsy(); 26 | }); 27 | 28 | test("it should support level2 updates", () => { 29 | expect(client.hasLevel2Updates).toBeTruthy(); 30 | }); 31 | 32 | test("it should not support level3 snapshots", () => { 33 | expect(client.hasLevel3Snapshots).toBeFalsy(); 34 | }); 35 | 36 | test("it should not support level3 updates", () => { 37 | expect(client.hasLevel3Updates).toBeFalsy(); 38 | }); 39 | 40 | // run first so we can capture snapshot 41 | test("should subscribe and emit level2 snapshot and updates", done => { 42 | let hasSnapshot = false; 43 | client.subscribeLevel2Updates(market1); 44 | client.on("l2snapshot", snapshot => { 45 | hasSnapshot = true; 46 | expect(snapshot.fullId).toMatch("Gemini:BTC/USD"); 47 | expect(snapshot.exchange).toMatch("Gemini"); 48 | expect(snapshot.base).toMatch("BTC"); 49 | expect(snapshot.quote).toMatch("USD"); 50 | expect(snapshot.sequenceId).toBeGreaterThan(0); 51 | expect(snapshot.timestampMs).toBeUndefined(); 52 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 53 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 54 | expect(snapshot.asks[0].count).toBeUndefined(); 55 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 56 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 57 | expect(snapshot.bids[0].count).toBeUndefined(); 58 | }); 59 | client.on("l2update", update => { 60 | expect(hasSnapshot).toBeTruthy(); 61 | expect(update.fullId).toMatch("Gemini:BTC/USD"); 62 | expect(update.exchange).toMatch("Gemini"); 63 | expect(update.base).toMatch("BTC"); 64 | expect(update.quote).toMatch("USD"); 65 | expect(update.sequenceId).toBeGreaterThan(0); 66 | expect(update.timestampMs).toBeGreaterThan(1522540800000); 67 | let point = update.asks[0] || update.bids[0]; 68 | expect(typeof point.price).toBe("string"); 69 | expect(typeof point.size).toBe("string"); 70 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 71 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 72 | expect(point.count).toBeUndefined(); 73 | expect(point.meta.reason).toMatch(/(place|trade|cancel)/); 74 | expect(parseFloat(point.meta.delta)).toBeDefined(); 75 | done(); 76 | }); 77 | }); 78 | 79 | test( 80 | "should subscribe and emit trade events", 81 | done => { 82 | client.subscribeTrades(market1); 83 | client.subscribeTrades(market2); 84 | client.on("trade", trade => { 85 | expect(trade.fullId).toMatch(/Gemini:(BTC|ETH)\/USD/); 86 | expect(trade.exchange).toMatch("Gemini"); 87 | expect(trade.base).toMatch(/ETH|BTC/); 88 | expect(trade.quote).toMatch("USD"); 89 | expect(trade.tradeId).toBeGreaterThan(0); 90 | expect(trade.unix).toBeGreaterThan(1522540800000); 91 | expect(trade.side).toMatch(/buy|sell/); 92 | expect(typeof trade.price).toBe("string"); 93 | expect(typeof trade.amount).toBe("string"); 94 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 95 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 96 | done(); 97 | }); 98 | }, 99 | 60000 100 | ); 101 | 102 | test("should unsubscribe from trades", () => { 103 | client.unsubscribeTrades(market1); 104 | client.unsubscribeTrades(market2); 105 | }); 106 | 107 | test("should unsubscribe from level2orders", () => { 108 | client.unsubscribeLevel2Updates(market1); 109 | }); 110 | 111 | test("should close connections", done => { 112 | client.on("closed", done); 113 | client.close(); 114 | }); 115 | -------------------------------------------------------------------------------- /src/exchanges/hitbtc/hitbtc-client.js: -------------------------------------------------------------------------------- 1 | const moment = require("moment"); 2 | const semaphore = require("semaphore"); 3 | const BasicClient = require("../../basic-client"); 4 | const Ticker = require("../../type/ticker"); 5 | const Trade = require("../../type/trade"); 6 | const Level2Point = require("../../type/level2-point"); 7 | const Level2Snapshot = require("../../type/level2-snapshot"); 8 | const Level2Update = require("../../type/level2-update"); 9 | 10 | class HitBTCClient extends BasicClient { 11 | constructor() { 12 | super("wss://api.hitbtc.com/api/2/ws", "HitBTC"); 13 | this._id = 0; 14 | 15 | this.hasTickers = true; 16 | this.hasTrades = true; 17 | this.hasLevel2Updates = true; 18 | 19 | this.on("connected", this._resetSemaphore.bind(this)); 20 | } 21 | 22 | _resetSemaphore() { 23 | this._sem = semaphore(10); 24 | } 25 | 26 | _sendSubTicker(remote_id) { 27 | this._sem.take(() => { 28 | this._wss.send( 29 | JSON.stringify({ 30 | method: "subscribeTicker", 31 | params: { 32 | symbol: remote_id, 33 | }, 34 | id: ++this._id, 35 | }) 36 | ); 37 | }); 38 | } 39 | 40 | _sendUnsubTicker(remote_id) { 41 | this._wss.send( 42 | JSON.stringify({ 43 | method: "unsubscribeTicker", 44 | params: { 45 | symbol: remote_id, 46 | }, 47 | id: ++this._id, 48 | }) 49 | ); 50 | } 51 | 52 | _sendSubTrades(remote_id) { 53 | this._sem.take(() => { 54 | this._wss.send( 55 | JSON.stringify({ 56 | method: "subscribeTrades", 57 | params: { 58 | symbol: remote_id, 59 | }, 60 | id: ++this._id, 61 | }) 62 | ); 63 | }); 64 | } 65 | 66 | _sendUnsubTrades(remote_id) { 67 | this._wss.send( 68 | JSON.stringify({ 69 | method: "unsubscribeTrades", 70 | params: { 71 | symbol: remote_id, 72 | }, 73 | id: ++this._id, 74 | }) 75 | ); 76 | } 77 | 78 | _sendSubLevel2Updates(remote_id) { 79 | this._sem.take(() => { 80 | this._wss.send( 81 | JSON.stringify({ 82 | method: "subscribeOrderbook", 83 | params: { 84 | symbol: remote_id, 85 | }, 86 | id: ++this._id, 87 | }) 88 | ); 89 | }); 90 | } 91 | 92 | _sendUnsubLevel2Updates(remote_id) { 93 | this._wss.send( 94 | JSON.stringify({ 95 | method: "unsubscribeOrderbook", 96 | params: { 97 | symbol: remote_id, 98 | }, 99 | }) 100 | ); 101 | } 102 | 103 | _onMessage(raw) { 104 | let msg = JSON.parse(raw); 105 | 106 | if (msg.result) { 107 | this._sem.leave(); 108 | return; 109 | } 110 | 111 | if (msg.method === "ticker") { 112 | let ticker = this._constructTicker(msg.params); 113 | this.emit("ticker", ticker); 114 | } 115 | 116 | if (msg.method === "updateTrades") { 117 | for (let datum of msg.params.data) { 118 | datum.symbol = msg.params.symbol; 119 | let trade = this._constructTradesFromMessage(datum); 120 | this.emit("trade", trade); 121 | } 122 | return; 123 | } 124 | 125 | if (msg.method === "snapshotOrderbook") { 126 | let result = this._constructLevel2Snapshot(msg.params); 127 | this.emit("l2snapshot", result); 128 | return; 129 | } 130 | 131 | if (msg.method === "updateOrderbook") { 132 | let result = this._constructLevel2Update(msg.params); 133 | this.emit("l2update", result); 134 | return; 135 | } 136 | } 137 | 138 | _constructTicker(param) { 139 | let { ask, bid, last, open, low, high, volume, volumeQuote, timestamp, symbol } = param; 140 | let market = this._tickerSubs.get(symbol); 141 | let change = (parseFloat(last) - parseFloat(open)).toFixed(8); 142 | let changePercent = ((parseFloat(last) - parseFloat(open)) / parseFloat(open) * 100).toFixed(8); 143 | return new Ticker({ 144 | exchange: "HitBTC", 145 | base: market.base, 146 | quote: market.quote, 147 | timestamp: moment.utc(timestamp).valueOf(), 148 | last, 149 | open, 150 | high, 151 | low, 152 | volume, 153 | quoteVolume: volumeQuote, 154 | ask, 155 | bid, 156 | change, 157 | changePercent, 158 | }); 159 | } 160 | 161 | _constructTradesFromMessage(datum) { 162 | let { symbol, id, price, quantity, side, timestamp } = datum; 163 | 164 | let market = this._tradeSubs.get(symbol); 165 | 166 | let unix = moment(timestamp).valueOf(); 167 | 168 | return new Trade({ 169 | exchange: "HitBTC", 170 | base: market.base, 171 | quote: market.quote, 172 | tradeId: id, 173 | side, 174 | unix, 175 | price, 176 | amount: quantity, 177 | }); 178 | } 179 | 180 | _constructLevel2Snapshot(data) { 181 | let { ask, bid, symbol, sequence } = data; 182 | let market = this._level2UpdateSubs.get(symbol); // coming from l2update sub 183 | let asks = ask.map(p => new Level2Point(p.price, p.size)); 184 | let bids = bid.map(p => new Level2Point(p.price, p.size)); 185 | return new Level2Snapshot({ 186 | exchange: "HitBTC", 187 | base: market.base, 188 | quote: market.quote, 189 | sequenceId: sequence, 190 | asks, 191 | bids, 192 | }); 193 | } 194 | 195 | _constructLevel2Update(data) { 196 | let { ask, bid, symbol, sequence } = data; 197 | let market = this._level2UpdateSubs.get(symbol); 198 | let asks = ask.map(p => new Level2Point(p.price, p.size, p.count)); 199 | let bids = bid.map(p => new Level2Point(p.price, p.size, p.count)); 200 | return new Level2Update({ 201 | exchange: "HitBTC", 202 | base: market.base, 203 | quote: market.quote, 204 | sequenceId: sequence, 205 | asks, 206 | bids, 207 | }); 208 | } 209 | } 210 | 211 | module.exports = HitBTCClient; 212 | -------------------------------------------------------------------------------- /src/exchanges/hitbtc/hitbtc-client.spec.js: -------------------------------------------------------------------------------- 1 | const HitBTC = require("./hitbtc-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "ETHBTC", 7 | base: "ETH", 8 | quote: "BTC", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new HitBTC(); 13 | }); 14 | 15 | test("it should support tickers", () => { 16 | expect(client.hasTickers).toBeTruthy(); 17 | }); 18 | 19 | test("it should support trades", () => { 20 | expect(client.hasTrades).toBeTruthy(); 21 | }); 22 | 23 | test("it should not support level2 snapshots", () => { 24 | expect(client.hasLevel2Snapshots).toBeFalsy(); 25 | }); 26 | 27 | test("it should support level2 updates", () => { 28 | expect(client.hasLevel2Updates).toBeTruthy(); 29 | }); 30 | 31 | test("it should not support level3 snapshots", () => { 32 | expect(client.hasLevel3Snapshots).toBeFalsy(); 33 | }); 34 | 35 | test("it should not support level3 updates", () => { 36 | expect(client.hasLevel3Updates).toBeFalsy(); 37 | }); 38 | 39 | test( 40 | "should subscribe and emit ticker events", 41 | done => { 42 | client.subscribeTicker(market); 43 | client.on("ticker", ticker => { 44 | expect(ticker.fullId).toMatch("HitBTC:ETH/BTC"); 45 | expect(ticker.timestamp).toBeGreaterThan(1531677480465); 46 | expect(typeof ticker.last).toBe("string"); 47 | expect(typeof ticker.open).toBe("string"); 48 | expect(typeof ticker.high).toBe("string"); 49 | expect(typeof ticker.low).toBe("string"); 50 | expect(typeof ticker.volume).toBe("string"); 51 | expect(typeof ticker.quoteVolume).toBe("string"); 52 | expect(typeof ticker.change).toBe("string"); 53 | expect(typeof ticker.changePercent).toBe("string"); 54 | expect(typeof ticker.bid).toBe("string"); 55 | expect(typeof ticker.ask).toBe("string"); 56 | expect(parseFloat(ticker.last)).toBeGreaterThan(0); 57 | expect(parseFloat(ticker.open)).toBeGreaterThan(0); 58 | expect(parseFloat(ticker.high)).toBeGreaterThan(0); 59 | expect(parseFloat(ticker.low)).toBeGreaterThan(0); 60 | expect(parseFloat(ticker.volume)).toBeGreaterThan(0); 61 | expect(parseFloat(ticker.quoteVolume)).toBeGreaterThan(0); 62 | expect(Math.abs(parseFloat(ticker.change))).toBeGreaterThan(0); 63 | expect(Math.abs(parseFloat(ticker.changePercent))).toBeGreaterThan(0); 64 | expect(parseFloat(ticker.bid)).toBeGreaterThan(0); 65 | expect(ticker.bidVolume).toBeUndefined(); 66 | expect(parseFloat(ticker.ask)).toBeGreaterThan(0); 67 | expect(ticker.askVolume).toBeUndefined(); 68 | done(); 69 | }); 70 | }, 71 | 10000 72 | ); 73 | 74 | test( 75 | "should subscribe and emit trade events", 76 | done => { 77 | client.subscribeTrades(market); 78 | client.on("trade", trade => { 79 | expect(trade.fullId).toMatch("HitBTC:ETH/BTC"); 80 | expect(trade.exchange).toMatch("HitBTC"); 81 | expect(trade.base).toMatch("ETH"); 82 | expect(trade.quote).toMatch("BTC"); 83 | expect(trade.tradeId).toBeGreaterThan(0); 84 | expect(trade.unix).toBeGreaterThan(1522540800000); 85 | expect(trade.side).toMatch(/buy|sell/); 86 | expect(typeof trade.price).toBe("string"); 87 | expect(typeof trade.amount).toBe("string"); 88 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 89 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 90 | done(); 91 | }); 92 | }, 93 | 30000 94 | ); 95 | 96 | test("should subscribe and emit level2 snapshot and updates", done => { 97 | let hasSnapshot = false; 98 | client.subscribeLevel2Updates(market); 99 | client.on("l2snapshot", snapshot => { 100 | hasSnapshot = true; 101 | expect(snapshot.fullId).toMatch("HitBTC:ETH/BTC"); 102 | expect(snapshot.exchange).toMatch("HitBTC"); 103 | expect(snapshot.base).toMatch("ETH"); 104 | expect(snapshot.quote).toMatch("BTC"); 105 | expect(snapshot.sequenceId).toBeGreaterThan(0); 106 | expect(snapshot.timestampMs).toBeUndefined(); 107 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 108 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 109 | expect(snapshot.asks[0].count).toBeUndefined(); 110 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 111 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 112 | expect(snapshot.bids[0].count).toBeUndefined(); 113 | }); 114 | client.on("l2update", update => { 115 | expect(hasSnapshot).toBeTruthy(); 116 | expect(update.fullId).toMatch("HitBTC:ETH/BTC"); 117 | expect(update.exchange).toMatch("HitBTC"); 118 | expect(update.base).toMatch("ETH"); 119 | expect(update.quote).toMatch("BTC"); 120 | expect(update.sequenceId).toBeGreaterThan(0); 121 | expect(update.timestampMs).toBeUndefined(); 122 | let point = update.asks[0] || update.bids[0]; 123 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 124 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 125 | expect(point.count).toBeUndefined(); 126 | done(); 127 | }); 128 | }); 129 | 130 | test("should unsubscribe from tickers", () => { 131 | client.unsubscribeTicker(market); 132 | }); 133 | 134 | test("should unsubscribe from trades", () => { 135 | client.unsubscribeTrades(market); 136 | }); 137 | 138 | test("should unusbscribe from level2 updates", () => { 139 | client.unsubscribeLevel2Updates(market); 140 | }); 141 | 142 | test("should close connections", done => { 143 | client.on("closed", done); 144 | client.close(); 145 | }); 146 | -------------------------------------------------------------------------------- /src/exchanges/huobi/huobi-client.js: -------------------------------------------------------------------------------- 1 | const BasicClient = require("../../basic-client"); 2 | const zlib = require("zlib"); 3 | const winston = require("winston"); 4 | const Ticker = require("../../type/ticker"); 5 | const Trade = require("../../type/trade"); 6 | const Level2Point = require("../../type/level2-point"); 7 | const Level2Snapshot = require("../../type/level2-snapshot"); 8 | 9 | class HuobiClient extends BasicClient { 10 | constructor() { 11 | super("wss://api.huobi.pro/ws", "Huobi"); 12 | this.hasTickers = true; 13 | this.hasTrades = true; 14 | this.hasLevel2Snapshots = true; 15 | } 16 | 17 | _sendPong(ts) { 18 | if (this._wss) { 19 | this._wss.send(JSON.stringify({ pong: ts })); 20 | } 21 | } 22 | 23 | _sendSubTicker(remote_id) { 24 | this._wss.send( 25 | JSON.stringify({ 26 | sub: `market.${remote_id}.detail`, 27 | id: remote_id, 28 | }) 29 | ); 30 | } 31 | 32 | _sendUnsubTicker(remote_id) { 33 | this._wss.send( 34 | JSON.stringify({ 35 | unsub: `market.${remote_id}.detail`, 36 | id: remote_id, 37 | }) 38 | ); 39 | } 40 | 41 | _sendSubTrades(remote_id) { 42 | this._wss.send( 43 | JSON.stringify({ 44 | sub: `market.${remote_id}.trade.detail`, 45 | id: remote_id, 46 | }) 47 | ); 48 | } 49 | 50 | _sendUnsubTrades(remote_id) { 51 | this._wss.send( 52 | JSON.stringify({ 53 | unsub: `market.${remote_id}.trade.detail`, 54 | id: remote_id, 55 | }) 56 | ); 57 | } 58 | 59 | _sendSubLevel2Snapshots(remote_id) { 60 | this._wss.send( 61 | JSON.stringify({ 62 | sub: `market.${remote_id}.depth.step0`, 63 | id: "depth_" + remote_id, 64 | }) 65 | ); 66 | } 67 | 68 | _sendUnsubLevel2Snapshots(remote_id) { 69 | this._wss.send( 70 | JSON.stringify({ 71 | unsub: `market.${remote_id}.depth.step0`, 72 | }) 73 | ); 74 | } 75 | 76 | _onMessage(raw) { 77 | zlib.unzip(raw, (err, resp) => { 78 | if (err) { 79 | winston.error(err); 80 | return; 81 | } 82 | 83 | let msgs = JSON.parse(resp); 84 | 85 | // handle pongs 86 | if (msgs.ping) { 87 | this._sendPong(msgs.ping); 88 | return; 89 | } 90 | 91 | if (!msgs.ch) return; 92 | 93 | // trades 94 | if (msgs.ch.endsWith("trade.detail")) { 95 | msgs = JSON.parse(resp.toString().replace(/:([0-9]{1,}\.{0,1}[0-9]{0,}),/g, ':"$1",')); 96 | 97 | let remoteId = msgs.ch.split(".")[1]; //market.ethbtc.trade.detail 98 | for (let datum of msgs.tick.data) { 99 | let trade = this._constructTradesFromMessage(remoteId, datum); 100 | this.emit("trade", trade); 101 | } 102 | return; 103 | } 104 | 105 | // tickers 106 | if (msgs.ch.endsWith(".detail")) { 107 | let remoteId = msgs.ch.split(".")[1]; 108 | let ticker = this._constructTicker(remoteId, msgs.tick); 109 | this.emit("ticker", ticker); 110 | return; 111 | } 112 | 113 | // level2updates 114 | if (msgs.ch.endsWith("depth.step0")) { 115 | let remoteId = msgs.ch.split(".")[1]; 116 | let update = this._constructLevel2Snapshot(remoteId, msgs); 117 | this.emit("l2snapshot", update); 118 | return; 119 | } 120 | }); 121 | } 122 | 123 | _constructTicker(remoteId, data) { 124 | let { open, close, high, low, vol, amount } = data; 125 | let market = this._tickerSubs.get(remoteId); 126 | let dayChange = close - open; 127 | let dayChangePercent = (close - open) / open * 100; 128 | return new Ticker({ 129 | exchange: "Huobi", 130 | base: market.base, 131 | quote: market.quote, 132 | timestamp: Date.now(), 133 | last: close.toFixed(8), 134 | open: open.toFixed(8), 135 | high: high.toFixed(8), 136 | low: low.toFixed(8), 137 | volume: amount.toFixed(8), 138 | quoteVolume: vol.toFixed(8), 139 | change: dayChange.toFixed(8), 140 | changePercent: dayChangePercent.toFixed(8), 141 | }); 142 | } 143 | 144 | _constructTradesFromMessage(remoteId, datum) { 145 | let { amount, direction, ts, price, id } = datum; 146 | let market = this._tradeSubs.get(remoteId); 147 | let unix = Math.trunc(parseInt(ts)); 148 | 149 | return new Trade({ 150 | exchange: "Huobi", 151 | base: market.base, 152 | quote: market.quote, 153 | tradeId: id, 154 | side: direction, 155 | unix, 156 | price, 157 | amount, 158 | }); 159 | } 160 | 161 | _constructLevel2Snapshot(remoteId, msg) { 162 | let { ts, tick } = msg; 163 | let market = this._level2SnapshotSubs.get(remoteId); 164 | let bids = tick.bids.map(p => new Level2Point(p[0].toFixed(8), p[1].toFixed(8))); 165 | let asks = tick.asks.map(p => new Level2Point(p[0].toFixed(8), p[1].toFixed(8))); 166 | return new Level2Snapshot({ 167 | exchange: "Huobi", 168 | base: market.base, 169 | quote: market.quote, 170 | timestampMs: ts, 171 | asks, 172 | bids, 173 | }); 174 | } 175 | } 176 | 177 | module.exports = HuobiClient; 178 | -------------------------------------------------------------------------------- /src/exchanges/huobi/huobi-client.spec.js: -------------------------------------------------------------------------------- 1 | const HuobiClient = require("./huobi-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "btcusdt", 7 | base: "BTC", 8 | quote: "USDT", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new HuobiClient(); 13 | }); 14 | 15 | test("it should support tickers", () => { 16 | expect(client.hasTickers).toBeTruthy(); 17 | }); 18 | 19 | test("it should support trades", () => { 20 | expect(client.hasTrades).toBeTruthy(); 21 | }); 22 | 23 | test("it should support level2 snapshots", () => { 24 | expect(client.hasLevel2Snapshots).toBeTruthy(); 25 | }); 26 | 27 | test("it should not support level2 updates", () => { 28 | expect(client.hasLevel2Updates).toBeFalsy(); 29 | }); 30 | 31 | test("it should not support level3 snapshots", () => { 32 | expect(client.hasLevel3Snapshots).toBeFalsy(); 33 | }); 34 | 35 | test("it should not support level3 updates", () => { 36 | expect(client.hasLevel3Updates).toBeFalsy(); 37 | }); 38 | 39 | test( 40 | "should subscribe and emit ticker events", 41 | done => { 42 | client.subscribeTicker(market); 43 | client.on("ticker", ticker => { 44 | expect(ticker.fullId).toMatch("Huobi:BTC/USDT"); 45 | expect(ticker.timestamp).toBeGreaterThan(1531677480465); 46 | expect(typeof ticker.last).toBe("string"); 47 | expect(typeof ticker.open).toBe("string"); 48 | expect(typeof ticker.high).toBe("string"); 49 | expect(typeof ticker.low).toBe("string"); 50 | expect(typeof ticker.volume).toBe("string"); 51 | expect(typeof ticker.quoteVolume).toBe("string"); 52 | expect(typeof ticker.change).toBe("string"); 53 | expect(typeof ticker.changePercent).toBe("string"); 54 | expect(parseFloat(ticker.last)).toBeGreaterThan(0); 55 | expect(parseFloat(ticker.open)).toBeGreaterThan(0); 56 | expect(parseFloat(ticker.high)).toBeGreaterThan(0); 57 | expect(parseFloat(ticker.low)).toBeGreaterThan(0); 58 | expect(parseFloat(ticker.volume)).toBeGreaterThan(0); 59 | expect(parseFloat(ticker.quoteVolume)).toBeGreaterThan(0); 60 | expect(Math.abs(parseFloat(ticker.change))).toBeGreaterThan(0); 61 | expect(Math.abs(parseFloat(ticker.changePercent))).toBeGreaterThan(0); 62 | expect(ticker.bid).toBeUndefined(); 63 | expect(ticker.bidVolume).toBeUndefined(); 64 | expect(ticker.ask).toBeUndefined(); 65 | expect(ticker.askVolume).toBeUndefined(); 66 | done(); 67 | }); 68 | }, 69 | 10000 70 | ); 71 | 72 | test( 73 | "should subscribe and emit trade events", 74 | done => { 75 | client.subscribeTrades(market); 76 | client.on("trade", trade => { 77 | expect(trade.fullId).toMatch("Huobi:BTC/USDT"); 78 | expect(trade.exchange).toMatch("Huobi"); 79 | expect(trade.base).toMatch("BTC"); 80 | expect(trade.quote).toMatch("USDT"); 81 | expect(trade.tradeId).toMatch(/[0-9]{1,}/); 82 | expect(trade.unix).toBeGreaterThan(1522540800000); 83 | expect(trade.side).toMatch(/buy|sell/); 84 | expect(typeof trade.price).toBe("string"); 85 | expect(typeof trade.amount).toBe("string"); 86 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 87 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 88 | done(); 89 | }); 90 | }, 91 | 30000 92 | ); 93 | 94 | test("should subscribe and emit level2 snapshots", done => { 95 | client.subscribeLevel2Snapshots(market); 96 | client.on("l2snapshot", snapshot => { 97 | expect(snapshot.fullId).toMatch("Huobi:BTC/USDT"); 98 | expect(snapshot.exchange).toMatch("Huobi"); 99 | expect(snapshot.base).toMatch("BTC"); 100 | expect(snapshot.quote).toMatch("USDT"); 101 | expect(snapshot.sequenceId).toBeUndefined(); 102 | expect(snapshot.timestampMs).toBeGreaterThan(0); 103 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 104 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 105 | expect(snapshot.asks[0].count).toBeUndefined(); 106 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 107 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 108 | expect(snapshot.bids[0].count).toBeUndefined(); 109 | done(); 110 | }); 111 | }); 112 | 113 | test("should unsubscribe trades", () => { 114 | client.unsubscribeTrades(market); 115 | }); 116 | 117 | test("should unsubscribe level2 snapshots", () => { 118 | client.unsubscribeLevel2Snapshots(market); 119 | }); 120 | 121 | test("should close connections", done => { 122 | client.on("closed", done); 123 | client.close(); 124 | }); 125 | -------------------------------------------------------------------------------- /src/exchanges/okex/okex-client.js: -------------------------------------------------------------------------------- 1 | const semaphore = require("semaphore"); 2 | const BasicClient = require("../../basic-client"); 3 | const Ticker = require("../../type/ticker"); 4 | const Trade = require("../../type/trade"); 5 | const Level2Point = require("../../type/level2-point"); 6 | const Level2Snapshot = require("../../type/level2-snapshot"); 7 | const Level2Update = require("../../type/level2-update"); 8 | 9 | class OKExClient extends BasicClient { 10 | constructor() { 11 | super("wss://real.okex.com:10441/websocket", "OKEx"); 12 | this._pingInterval = setInterval(this._sendPing.bind(this), 30000); 13 | this.on("connected", this._resetSemaphore.bind(this)); 14 | 15 | this.hasTickers = true; 16 | this.hasTrades = true; 17 | this.hasLevel2Snapshots = true; 18 | this.hasLevel2Updates = true; 19 | } 20 | 21 | _resetSemaphore() { 22 | this._sem = semaphore(10); 23 | } 24 | 25 | _sendPing() { 26 | if (this._wss) { 27 | this._wss.send(JSON.stringify({ event: "ping" })); 28 | } 29 | } 30 | 31 | _sendSubTicker(remote_id) { 32 | this._sem.take(() => { 33 | this._wss.send( 34 | JSON.stringify({ 35 | event: "addChannel", 36 | channel: `ok_sub_spot_${remote_id}_ticker`, 37 | }) 38 | ); 39 | }); 40 | } 41 | 42 | _sendUnsubTicker(remote_id) { 43 | this._sem.take(() => { 44 | this._wss.send( 45 | JSON.stringify({ 46 | event: "removeChannel", 47 | channel: `ok_sub_spot_${remote_id}_ticker`, 48 | }) 49 | ); 50 | }); 51 | } 52 | 53 | _sendSubTrades(remote_id) { 54 | this._sem.take(() => { 55 | let [base, quote] = remote_id.split("_"); 56 | this._wss.send( 57 | JSON.stringify({ 58 | event: "addChannel", 59 | parameters: { base, binary: "0", product: "spot", quote, type: "deal" }, 60 | }) 61 | ); 62 | }); 63 | } 64 | 65 | _sendUnsubTrades(remote_id) { 66 | let [base, quote] = remote_id.split("_"); 67 | this._wss.send( 68 | JSON.stringify({ 69 | event: "removeChannel", 70 | parameters: { base, binary: "0", product: "spot", quote, type: "deal" }, 71 | }) 72 | ); 73 | } 74 | 75 | _sendSubLevel2Snapshots(remote_id, { depth = 20 } = {}) { 76 | this._sem.take(() => { 77 | this._wss.send( 78 | JSON.stringify({ 79 | event: "addChannel", 80 | channel: `ok_sub_spot_${remote_id}_depth_${depth}`, 81 | }) 82 | ); 83 | }); 84 | } 85 | 86 | _sendUnsubLevel2Snapshots(remote_id, { depth = 20 } = {}) { 87 | this._wss.send( 88 | JSON.stringify({ 89 | event: "removeChannel", 90 | channel: `ok_sub_spot_${remote_id}_depth_${depth}`, 91 | }) 92 | ); 93 | } 94 | 95 | _sendSubLevel2Updates(remote_id) { 96 | this._sem.take(() => { 97 | this._wss.send( 98 | JSON.stringify({ 99 | event: "addChannel", 100 | channel: `ok_sub_spot_${remote_id}_depth`, 101 | }) 102 | ); 103 | }); 104 | } 105 | 106 | _sendUnsubLevel2Updates(remote_id) { 107 | this._wss.send( 108 | JSON.stringify({ 109 | event: "removeChannel", 110 | channel: `ok_sub_spot_${remote_id}_depth`, 111 | }) 112 | ); 113 | } 114 | 115 | _onMessage(raw) { 116 | let msgs = JSON.parse(raw); 117 | if (!Array.isArray(msgs)) return; 118 | 119 | for (let msg of msgs) { 120 | // clear semaphore 121 | if (msg.data.result) { 122 | this._sem.leave(); 123 | continue; 124 | } 125 | 126 | // trades 127 | if (msg.product === "spot" && msg.type === "deal") { 128 | let { base, quote } = msg; 129 | let remote_id = `${base}_${quote}`; 130 | for (let datum of msg.data) { 131 | let trade = this._constructTradesFromMessage(remote_id, datum); 132 | this.emit("trade", trade); 133 | } 134 | return; 135 | } 136 | 137 | if (!msg.channel) return; 138 | 139 | // tickers 140 | if (msg.channel.endsWith("_ticker")) { 141 | let ticker = this._constructTicker(msg); 142 | this.emit("ticker", ticker); 143 | return; 144 | } 145 | 146 | // l2 snapshots 147 | if ( 148 | msg.channel.endsWith("_5") || 149 | msg.channel.endsWith("_10") || 150 | msg.channel.endsWith("_20") 151 | ) { 152 | let snapshot = this._constructLevel2Snapshot(msg); 153 | this.emit("l2snapshot", snapshot); 154 | return; 155 | } 156 | 157 | // l2 updates 158 | if (msg.channel.endsWith("depth")) { 159 | let update = this._constructoL2Update(msg); 160 | this.emit("l2update", update); 161 | return; 162 | } 163 | } 164 | } 165 | 166 | _constructTicker(msg) { 167 | /* 168 | { binary: 0, 169 | channel: 'ok_sub_spot_eth_btc_ticker', 170 | data: 171 | { high: '0.07121405', 172 | vol: '53824.717918', 173 | last: '0.07071044', 174 | low: '0.06909468', 175 | buy: '0.07065946', 176 | change: '0.00141498', 177 | sell: '0.07071625', 178 | dayLow: '0.06909468', 179 | close: '0.07071044', 180 | dayHigh: '0.07121405', 181 | open: '0.06929546', 182 | timestamp: 1531692991115 } } 183 | */ 184 | let remoteId = msg.channel.substr("ok_sub_spot_".length).replace("_ticker", ""); 185 | let market = this._tickerSubs.get(remoteId); 186 | let { open, vol, last, buy, change, sell, dayLow, dayHigh, timestamp } = msg.data; 187 | let dayChangePercent = parseFloat(change) / parseFloat(open) * 100; 188 | return new Ticker({ 189 | exchange: "OKEx", 190 | base: market.base, 191 | quote: market.quote, 192 | timestamp, 193 | last, 194 | open, 195 | high: dayHigh, 196 | low: dayLow, 197 | volume: vol, 198 | change: change, 199 | changePercent: dayChangePercent.toFixed(2), 200 | bid: buy, 201 | ask: sell, 202 | }); 203 | } 204 | 205 | _constructTradesFromMessage(remoteId, datum) { 206 | /* 207 | [{ base: '1st', 208 | binary: 0, 209 | channel: 'addChannel', 210 | data: { result: true }, 211 | product: 'spot', 212 | quote: 'btc', 213 | type: 'deal' }, 214 | { base: '1st', 215 | binary: 0, 216 | data: 217 | [ { amount: '818.619', 218 | side: 1, 219 | createdDate: 1527013680457, 220 | price: '0.00003803', 221 | id: 4979071 }, 222 | ], 223 | product: 'spot', 224 | quote: 'btc', 225 | type: 'deal' }] 226 | */ 227 | let { amount, side, createdDate, price, id } = datum; 228 | let market = this._tradeSubs.get(remoteId); 229 | side = side === 1 ? "buy" : "sell"; 230 | 231 | return new Trade({ 232 | exchange: "OKEx", 233 | base: market.base, 234 | quote: market.quote, 235 | tradeId: id, 236 | side, 237 | unix: createdDate, 238 | price, 239 | amount, 240 | }); 241 | } 242 | 243 | _constructLevel2Snapshot(msg) { 244 | /* 245 | [{ 246 | "binary": 0, 247 | "channel": "ok_sub_spot_bch_btc_depth", 248 | "data": { 249 | "asks": [], 250 | "bids": [ 251 | [ 252 | "115", 253 | "1" 254 | ], 255 | [ 256 | "114", 257 | "1" 258 | ], 259 | [ 260 | "1E-8", 261 | "0.0008792" 262 | ] 263 | ], 264 | "timestamp": 1504529236946 265 | } 266 | }] 267 | */ 268 | let remote_id = msg.channel.replace("ok_sub_spot_", "").replace(/_depth_\d+/, ""); 269 | let market = this._level2SnapshotSubs.get(remote_id); 270 | let asks = msg.data.asks.map(p => new Level2Point(p[0], p[1])); 271 | let bids = msg.data.bids.map(p => new Level2Point(p[0], p[1])); 272 | return new Level2Snapshot({ 273 | exchange: "OKEx", 274 | base: market.base, 275 | quote: market.quote, 276 | timestampMs: msg.data.timestamp, 277 | asks, 278 | bids, 279 | }); 280 | } 281 | 282 | _constructoL2Update(msg) { 283 | /* 284 | [{ 285 | "binary": 0, 286 | "channel": "ok_sub_spot_bch_btc_depth", 287 | "data": { 288 | "asks": [], 289 | "bids": [ 290 | [ 291 | "115", 292 | "1" 293 | ], 294 | [ 295 | "114", 296 | "1" 297 | ], 298 | [ 299 | "1E-8", 300 | "0.0008792" 301 | ] 302 | ], 303 | "timestamp": 1504529236946 304 | } 305 | }] 306 | */ 307 | let remote_id = msg.channel.replace("ok_sub_spot_", "").replace("_depth", ""); 308 | let market = this._level2UpdateSubs.get(remote_id); 309 | let asks = msg.data.asks.map(p => new Level2Point(p[0], p[1])); 310 | let bids = msg.data.bids.map(p => new Level2Point(p[0], p[1])); 311 | return new Level2Update({ 312 | exchange: "OKEx", 313 | base: market.base, 314 | quote: market.quote, 315 | timestampMs: msg.data.timestamp, 316 | asks, 317 | bids, 318 | }); 319 | } 320 | } 321 | 322 | module.exports = OKExClient; 323 | -------------------------------------------------------------------------------- /src/exchanges/okex/okex-client.spec.js: -------------------------------------------------------------------------------- 1 | const OKEx = require("./okex-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "btc_usdt", 7 | base: "BTC", 8 | quote: "USDT", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new OKEx(); 13 | }); 14 | 15 | test("it should support tickers", () => { 16 | expect(client.hasTickers).toBeTruthy(); 17 | }); 18 | 19 | test("it should support trades", () => { 20 | expect(client.hasTrades).toBeTruthy(); 21 | }); 22 | 23 | test("it should support level2 snapshots", () => { 24 | expect(client.hasLevel2Snapshots).toBeTruthy(); 25 | }); 26 | 27 | test("it should support level2 updates", () => { 28 | expect(client.hasLevel2Updates).toBeTruthy(); 29 | }); 30 | 31 | test("it should not support level3 snapshots", () => { 32 | expect(client.hasLevel3Snapshots).toBeFalsy(); 33 | }); 34 | 35 | test("it should not support level3 updates", () => { 36 | expect(client.hasLevel3Updates).toBeFalsy(); 37 | }); 38 | 39 | test( 40 | "should subscribe and emit ticker events", 41 | done => { 42 | client.subscribeTicker(market); 43 | client.on("ticker", ticker => { 44 | expect(ticker.fullId).toMatch("OKEx:BTC/USDT"); 45 | expect(ticker.timestamp).toBeGreaterThan(1531677480465); 46 | expect(typeof ticker.last).toBe("string"); 47 | expect(typeof ticker.open).toBe("string"); 48 | expect(typeof ticker.high).toBe("string"); 49 | expect(typeof ticker.low).toBe("string"); 50 | expect(typeof ticker.volume).toBe("string"); 51 | expect(typeof ticker.change).toBe("string"); 52 | expect(typeof ticker.changePercent).toBe("string"); 53 | expect(typeof ticker.bid).toBe("string"); 54 | expect(typeof ticker.ask).toBe("string"); 55 | expect(parseFloat(ticker.last)).toBeGreaterThan(0); 56 | expect(parseFloat(ticker.open)).toBeGreaterThan(0); 57 | expect(parseFloat(ticker.high)).toBeGreaterThan(0); 58 | expect(parseFloat(ticker.low)).toBeGreaterThan(0); 59 | expect(parseFloat(ticker.volume)).toBeGreaterThan(0); 60 | expect(Math.abs(parseFloat(ticker.change))).toBeGreaterThan(0); 61 | expect(Math.abs(parseFloat(ticker.changePercent))).toBeGreaterThan(0); 62 | expect(parseFloat(ticker.bid)).toBeGreaterThan(0); 63 | expect(ticker.bidVolume).toBeUndefined(); 64 | expect(parseFloat(ticker.ask)).toBeGreaterThan(0); 65 | expect(ticker.askVolume).toBeUndefined(); 66 | done(); 67 | }); 68 | }, 69 | 10000 70 | ); 71 | 72 | test( 73 | "should subscribe and emit trade events", 74 | done => { 75 | client.subscribeTrades(market); 76 | client.on("trade", trade => { 77 | expect(trade.fullId).toMatch("OKEx:BTC/USDT"); 78 | expect(trade.exchange).toMatch("OKEx"); 79 | expect(trade.base).toMatch("BTC"); 80 | expect(trade.quote).toMatch("USDT"); 81 | expect(trade.tradeId).toBeGreaterThan(0); 82 | expect(trade.unix).toBeGreaterThan(1522540800000); 83 | expect(trade.side).toMatch(/buy|sell/); 84 | expect(typeof trade.price).toBe("string"); 85 | expect(typeof trade.amount).toBe("string"); 86 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 87 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 88 | done(); 89 | }); 90 | }, 91 | 30000 92 | ); 93 | 94 | test("should subscribe and emit level2 snapshots", done => { 95 | client.subscribeLevel2Snapshots(market); 96 | client.on("l2snapshot", snapshot => { 97 | expect(snapshot.fullId).toMatch("OKEx:BTC/USDT"); 98 | expect(snapshot.exchange).toMatch("OKEx"); 99 | expect(snapshot.base).toMatch("BTC"); 100 | expect(snapshot.quote).toMatch("USDT"); 101 | expect(snapshot.sequenceId).toBeUndefined(); 102 | expect(snapshot.timestampMs).toBeGreaterThan(0); 103 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 104 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 105 | expect(snapshot.asks[0].count).toBeUndefined(); 106 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 107 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 108 | expect(snapshot.bids[0].count).toBeUndefined(); 109 | done(); 110 | }); 111 | }); 112 | 113 | test("should subscribe and emit level2 updates", done => { 114 | client.subscribeLevel2Updates(market); 115 | client.on("l2update", update => { 116 | expect(update.fullId).toMatch("OKEx:BTC/USD"); 117 | expect(update.exchange).toMatch("OKEx"); 118 | expect(update.base).toMatch("BTC"); 119 | expect(update.quote).toMatch("USD"); 120 | expect(update.sequenceId).toBeUndefined(); 121 | expect(update.timestampMs).toBeGreaterThan(0); 122 | let point = update.asks[0] || update.bids[0]; 123 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 124 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 125 | expect(point.count).toBeUndefined(); 126 | done(); 127 | }); 128 | }); 129 | 130 | test("should unsubscribe from tickers", () => { 131 | client.unsubscribeTicker(market); 132 | }); 133 | 134 | test("should unsubscribe from trades", () => { 135 | client.unsubscribeTrades(market); 136 | }); 137 | 138 | test("should unsubscribe from level2 snapshots", () => { 139 | client.unsubscribeLevel2Snapshots(market); 140 | }); 141 | 142 | test("should unsubscribe from level2 updates", () => { 143 | client.unsubscribeLevel2Updates(market); 144 | }); 145 | 146 | test("should close connections", done => { 147 | client.on("closed", done); 148 | client.close(); 149 | }); 150 | -------------------------------------------------------------------------------- /src/exchanges/poloniex/poloniex-client.js: -------------------------------------------------------------------------------- 1 | const BasicClient = require("../../basic-client"); 2 | const Ticker = require("../../type/ticker"); 3 | const Trade = require("../../type/trade"); 4 | const Level2Point = require("../../type/level2-point"); 5 | const Level2Update = require("../../type/level2-update"); 6 | const Level2Snapshot = require("../../type/level2-snapshot"); 7 | 8 | const TICKERS_ID = 1002; 9 | const MARKET_IDS = { 10 | 7: "BTC_BCN", 11 | 8: "BTC_BELA", 12 | 10: "BTC_BLK", 13 | 12: "BTC_BTCD", 14 | 13: "BTC_BTM", 15 | 14: "BTC_BTS", 16 | 15: "BTC_BURST", 17 | 20: "BTC_CLAM", 18 | 24: "BTC_DASH", 19 | 25: "BTC_DGB", 20 | 27: "BTC_DOGE", 21 | 28: "BTC_EMC2", 22 | 31: "BTC_FLDC", 23 | 32: "BTC_FLO", 24 | 38: "BTC_GAME", 25 | 40: "BTC_GRC", 26 | 43: "BTC_HUC", 27 | 50: "BTC_LTC", 28 | 51: "BTC_MAID", 29 | 58: "BTC_OMNI", 30 | 61: "BTC_NAV", 31 | 63: "BTC_NEOS", 32 | 64: "BTC_NMC", 33 | 69: "BTC_NXT", 34 | 73: "BTC_PINK", 35 | 74: "BTC_POT", 36 | 75: "BTC_PPC", 37 | 83: "BTC_RIC", 38 | 89: "BTC_STR", 39 | 92: "BTC_SYS", 40 | 97: "BTC_VIA", 41 | 98: "BTC_XVC", 42 | 99: "BTC_VRC", 43 | 100: "BTC_VTC", 44 | 104: "BTC_XBC", 45 | 108: "BTC_XCP", 46 | 112: "BTC_XEM", 47 | 114: "BTC_XMR", 48 | 116: "BTC_XPM", 49 | 117: "BTC_XRP", 50 | 121: "USDT_BTC", 51 | 122: "USDT_DASH", 52 | 123: "USDT_LTC", 53 | 124: "USDT_NXT", 54 | 125: "USDT_STR", 55 | 126: "USDT_XMR", 56 | 127: "USDT_XRP", 57 | 129: "XMR_BCN", 58 | 130: "XMR_BLK", 59 | 131: "XMR_BTCD", 60 | 132: "XMR_DASH", 61 | 137: "XMR_LTC", 62 | 138: "XMR_MAID", 63 | 140: "XMR_NXT", 64 | 148: "BTC_ETH", 65 | 149: "USDT_ETH", 66 | 150: "BTC_SC", 67 | 151: "BTC_BCY", 68 | 153: "BTC_EXP", 69 | 155: "BTC_FCT", 70 | 158: "BTC_RADS", 71 | 160: "BTC_AMP", 72 | 162: "BTC_DCR", 73 | 163: "BTC_LSK", 74 | 166: "ETH_LSK", 75 | 167: "BTC_LBC", 76 | 168: "BTC_STEEM", 77 | 169: "ETH_STEEM", 78 | 170: "BTC_SBD", 79 | 171: "BTC_ETC", 80 | 172: "ETH_ETC", 81 | 173: "USDT_ETC", 82 | 174: "BTC_REP", 83 | 175: "USDT_REP", 84 | 176: "ETH_REP", 85 | 177: "BTC_ARDR", 86 | 178: "BTC_ZEC", 87 | 179: "ETH_ZEC", 88 | 180: "USDT_ZEC", 89 | 181: "XMR_ZEC", 90 | 182: "BTC_STRAT", 91 | 183: "BTC_NXC", 92 | 184: "BTC_PASC", 93 | 185: "BTC_GNT", 94 | 186: "ETH_GNT", 95 | 187: "BTC_GNO", 96 | 188: "ETH_GNO", 97 | 189: "BTC_BCH", 98 | 190: "ETH_BCH", 99 | 191: "USDT_BCH", 100 | 192: "BTC_ZRX", 101 | 193: "ETH_ZRX", 102 | 194: "BTC_CVC", 103 | 195: "ETH_CVC", 104 | 196: "BTC_OMG", 105 | 197: "ETH_OMG", 106 | 198: "BTC_GAS", 107 | 199: "ETH_GAS", 108 | 200: "BTC_STORJ", 109 | }; 110 | 111 | class PoloniexClient extends BasicClient { 112 | constructor() { 113 | super("wss://api2.poloniex.com/", "Poloniex"); 114 | this._idMap = new Map(); 115 | this.hasTickers = true; 116 | this.hasTrades = true; 117 | this.hasLevel2Updates = true; 118 | this.on("connected", this._resetSubCount.bind(this)); 119 | } 120 | 121 | _resetSubCount() { 122 | this._subCount = {}; 123 | } 124 | 125 | _sendSubTicker() { 126 | if (this._tickerSubs.size > 1) return; // send for first request 127 | this._wss.send( 128 | JSON.stringify({ 129 | command: "subscribe", 130 | channel: TICKERS_ID, 131 | }) 132 | ); 133 | } 134 | 135 | _sendUnsubTicker() { 136 | if (this._tickerSubs.size) return; // send when no more 137 | this._wss.send( 138 | JSON.stringify({ 139 | command: "unsubscribe", 140 | channel: TICKERS_ID, 141 | }) 142 | ); 143 | } 144 | 145 | _sendSubTrades(remote_id) { 146 | this._sendSubscribe(remote_id); 147 | } 148 | 149 | _sendUnsubTrades(remote_id) { 150 | this._sendUnsubscribe(remote_id); 151 | } 152 | 153 | _sendSubLevel2Updates(remote_id) { 154 | this._sendSubscribe(remote_id); 155 | } 156 | 157 | _sendUnsubLevel2Updates(remote_id) { 158 | this._sendUnsubscribe(remote_id); 159 | } 160 | 161 | _sendSubscribe(remote_id) { 162 | this._subCount[remote_id] = (this._subCount[remote_id] || 0) + 1; // increment market counter 163 | // if we have more than one sub, ignore the request as we're already subbed 164 | if (this._subCount[remote_id] > 1) return; 165 | 166 | this._wss.send( 167 | JSON.stringify({ 168 | command: "subscribe", 169 | channel: remote_id, 170 | }) 171 | ); 172 | } 173 | 174 | _sendUnsubscribe(remote_id) { 175 | this._subCount[remote_id] -= 1; // decrement market count 176 | 177 | // if we still have subs, then leave channel open 178 | if (this._subCount[remote_id]) return; 179 | 180 | this._wss.send( 181 | JSON.stringify({ 182 | command: "unsubscribe", 183 | channel: remote_id, 184 | }) 185 | ); 186 | } 187 | 188 | _onMessage(raw) { 189 | // different because messages are broadcast as joined updates 190 | // [148,540672082,[["o",1,"0.07313000","7.21110596"],["t","43781170",0,"0.07313000","0.00199702",1528900825]]] 191 | // we need to pick apart these messages and broadcast them accordingly 192 | 193 | let msg = JSON.parse(raw); 194 | let id = msg[0]; 195 | let seq = msg[1]; 196 | let updates = msg[2]; 197 | 198 | // tickers 199 | if (id === 1002 && updates) { 200 | let remoteId = MARKET_IDS[updates[0]]; 201 | if (this._tickerSubs.has(remoteId)) { 202 | let ticker = this._createTicker(remoteId, updates); 203 | this.emit("ticker", ticker); 204 | } 205 | return; 206 | } 207 | 208 | if (!updates) return; 209 | 210 | let bids = []; 211 | let asks = []; 212 | 213 | for (let update of updates) { 214 | switch (update[0]) { 215 | // when connection is first established it will send an 'info' packet 216 | // that can be used to map the "id" to the market_symbol 217 | case "i": { 218 | let remote_id = update[1].currencyPair; 219 | this._idMap.set(id, remote_id); 220 | 221 | if (this._level2UpdateSubs.has(remote_id)) { 222 | let snapshot = this._constructoLevel2Snapshot(seq, update[1]); 223 | this.emit("l2snapshot", snapshot); 224 | } 225 | 226 | break; 227 | } 228 | // trade events will stream-in after we are subscribed to the channel 229 | // and hopefully after the info packet has been sent 230 | case "t": { 231 | if (this._tradeSubs.has(this._idMap.get(id))) { 232 | let trade = this._constructTradeFromMessage(id, update); 233 | this.emit("trade", trade); 234 | } 235 | break; 236 | } 237 | 238 | case "o": { 239 | if (this._level2UpdateSubs.has(this._idMap.get(id))) { 240 | //[171, 280657226, [["o", 0, "0.00225182", "0.00000000"], ["o", 0, "0.00225179", "860.66363984"]]] 241 | //[171, 280657227, [["o", 1, "0.00220001", "0.00000000"], ["o", 1, "0.00222288", "208.47334089"]]] 242 | let point = new Level2Point(update[2], update[3]); 243 | if (update[1] === 0) asks.push(point); 244 | if (update[1] === 1) bids.push(point); 245 | } 246 | break; 247 | } 248 | } 249 | } 250 | 251 | // check if we have bids/asks and construct order update message 252 | if (bids.length || asks.length) { 253 | let market = this._level2UpdateSubs.get(this._idMap.get(id)); 254 | let l2update = new Level2Update({ 255 | exchange: "Poloniex", 256 | base: market.base, 257 | quote: market.quote, 258 | sequenceId: seq, 259 | asks, 260 | bids, 261 | }); 262 | this.emit("l2update", l2update); 263 | } 264 | } 265 | 266 | _createTicker(remoteId, update) { 267 | let [, last, ask, bid, percent, quoteVol, baseVol, , high, low] = update; 268 | let market = this._tickerSubs.get(remoteId); 269 | let open = parseFloat(last) / (1 + parseFloat(percent)); 270 | let dayChange = parseFloat(last) - open; 271 | return new Ticker({ 272 | exchange: "Poloniex", 273 | base: market.base, 274 | quote: market.quote, 275 | timestamp: Date.now(), 276 | last, 277 | open: open.toFixed(8), 278 | high, 279 | low, 280 | volume: baseVol, 281 | quoteVolume: quoteVol, 282 | change: dayChange.toFixed(8), 283 | changePercent: percent, 284 | ask, 285 | bid, 286 | }); 287 | } 288 | 289 | _constructTradeFromMessage(id, update) { 290 | let [, trade_id, side, price, size, unix] = update; 291 | 292 | // figure out the market symbol 293 | let remote_id = this._idMap.get(id); 294 | if (!remote_id) return; 295 | 296 | let market = this._tradeSubs.get(remote_id); 297 | 298 | side = side === 1 ? "buy" : "sell"; 299 | unix = unix * 1000; 300 | trade_id = parseInt(trade_id); 301 | 302 | return new Trade({ 303 | exchange: "Poloniex", 304 | base: market.base, 305 | quote: market.quote, 306 | tradeId: trade_id, 307 | side, 308 | unix, 309 | price, 310 | amount: size, 311 | }); 312 | } 313 | 314 | _constructoLevel2Snapshot(seq, update) { 315 | let market = this._level2UpdateSubs.get(update.currencyPair); 316 | let [asksObj, bidsObj] = update.orderBook; 317 | let asks = []; 318 | let bids = []; 319 | for (let price in asksObj) { 320 | asks.push(new Level2Point(price, asksObj[price])); 321 | } 322 | for (let price in bidsObj) { 323 | bids.push(new Level2Point(price, bidsObj[price])); 324 | } 325 | return new Level2Snapshot({ 326 | exchange: "Poloniex", 327 | base: market.base, 328 | quote: market.quote, 329 | sequenceId: seq, 330 | asks, 331 | bids, 332 | }); 333 | } 334 | } 335 | 336 | module.exports = PoloniexClient; 337 | -------------------------------------------------------------------------------- /src/exchanges/poloniex/poloniex-client.spec.js: -------------------------------------------------------------------------------- 1 | const Poloniex = require("./poloniex-client"); 2 | jest.mock("winston", () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() })); 3 | 4 | let client; 5 | let market = { 6 | id: "USDT_BTC", 7 | base: "BTC", 8 | quote: "USDT", 9 | }; 10 | 11 | beforeAll(() => { 12 | client = new Poloniex(); 13 | }); 14 | 15 | test("it should support tickers", () => { 16 | expect(client.hasTickers).toBeTruthy(); 17 | }); 18 | 19 | test("it should support trades", () => { 20 | expect(client.hasTrades).toBeTruthy(); 21 | }); 22 | 23 | test("it should not support level2 snapshots", () => { 24 | expect(client.hasLevel2Snapshots).toBeFalsy(); 25 | }); 26 | 27 | test("it should support level2 updates", () => { 28 | expect(client.hasLevel2Updates).toBeTruthy(); 29 | }); 30 | 31 | test("it should not support level3 snapshots", () => { 32 | expect(client.hasLevel3Snapshots).toBeFalsy(); 33 | }); 34 | 35 | test("it should not support level3 updates", () => { 36 | expect(client.hasLevel3Updates).toBeFalsy(); 37 | }); 38 | 39 | test( 40 | "should subscribe and emit ticker events", 41 | done => { 42 | client.subscribeTicker(market); 43 | client.on("ticker", ticker => { 44 | expect(ticker.fullId).toMatch("Poloniex:BTC/USDT"); 45 | expect(ticker.timestamp).toBeGreaterThan(1531677480465); 46 | expect(typeof ticker.last).toBe("string"); 47 | expect(typeof ticker.open).toBe("string"); 48 | expect(typeof ticker.high).toBe("string"); 49 | expect(typeof ticker.low).toBe("string"); 50 | expect(typeof ticker.volume).toBe("string"); 51 | expect(typeof ticker.quoteVolume).toBe("string"); 52 | expect(typeof ticker.change).toBe("string"); 53 | expect(typeof ticker.changePercent).toBe("string"); 54 | expect(typeof ticker.bid).toBe("string"); 55 | expect(typeof ticker.ask).toBe("string"); 56 | expect(parseFloat(ticker.last)).toBeGreaterThan(0); 57 | expect(parseFloat(ticker.open)).toBeGreaterThan(0); 58 | expect(parseFloat(ticker.high)).toBeGreaterThan(0); 59 | expect(parseFloat(ticker.low)).toBeGreaterThan(0); 60 | expect(parseFloat(ticker.volume)).toBeGreaterThan(0); 61 | expect(parseFloat(ticker.quoteVolume)).toBeGreaterThan(0); 62 | expect(Math.abs(parseFloat(ticker.change))).toBeGreaterThan(0); 63 | expect(Math.abs(parseFloat(ticker.changePercent))).toBeGreaterThan(0); 64 | expect(parseFloat(ticker.bid)).toBeGreaterThan(0); 65 | expect(ticker.bidVolume).toBeUndefined(); 66 | expect(parseFloat(ticker.ask)).toBeGreaterThan(0); 67 | expect(ticker.askVolume).toBeUndefined(); 68 | done(); 69 | }); 70 | }, 71 | 90000 72 | ); 73 | 74 | // run first so we can capture snapshot 75 | test("should subscribe and emit level2 snapshot and updates", done => { 76 | let hasSnapshot = false; 77 | client.subscribeLevel2Updates(market); 78 | client.on("l2snapshot", snapshot => { 79 | hasSnapshot = true; 80 | expect(snapshot.fullId).toMatch("Poloniex:BTC/USDT"); 81 | expect(snapshot.exchange).toMatch("Poloniex"); 82 | expect(snapshot.base).toMatch("BTC"); 83 | expect(snapshot.quote).toMatch("USDT"); 84 | expect(snapshot.sequenceId).toBeGreaterThan(0); 85 | expect(snapshot.timestampMs).toBeUndefined(); 86 | expect(parseFloat(snapshot.asks[0].price)).toBeGreaterThanOrEqual(0); 87 | expect(parseFloat(snapshot.asks[0].size)).toBeGreaterThanOrEqual(0); 88 | expect(snapshot.asks[0].count).toBeUndefined(); 89 | expect(parseFloat(snapshot.bids[0].price)).toBeGreaterThanOrEqual(0); 90 | expect(parseFloat(snapshot.bids[0].size)).toBeGreaterThanOrEqual(0); 91 | expect(snapshot.bids[0].count).toBeUndefined(); 92 | }); 93 | client.on("l2update", update => { 94 | expect(hasSnapshot).toBeTruthy(); 95 | expect(update.fullId).toMatch("Poloniex:BTC/USDT"); 96 | expect(update.exchange).toMatch("Poloniex"); 97 | expect(update.base).toMatch("BTC"); 98 | expect(update.quote).toMatch("USDT"); 99 | expect(update.sequenceId).toBeGreaterThan(0); 100 | expect(update.timestampMs).toBeUndefined(); 101 | let point = update.asks[0] || update.bids[0]; 102 | expect(parseFloat(point.price)).toBeGreaterThanOrEqual(0); 103 | expect(parseFloat(point.size)).toBeGreaterThanOrEqual(0); 104 | expect(point.count).toBeUndefined(); 105 | done(); 106 | }); 107 | }); 108 | 109 | test( 110 | "should subscribe and emit trade events", 111 | done => { 112 | client.subscribeTrades(market); 113 | client.on("trade", trade => { 114 | expect(trade.fullId).toMatch("Poloniex:BTC/USDT"); 115 | expect(trade.exchange).toMatch("Poloniex"); 116 | expect(trade.base).toMatch("BTC"); 117 | expect(trade.quote).toMatch("USDT"); 118 | expect(trade.tradeId).toBeGreaterThan(0); 119 | expect(trade.unix).toBeGreaterThan(1522540800000); 120 | expect(trade.side).toMatch(/buy|sell/); 121 | expect(typeof trade.price).toBe("string"); 122 | expect(typeof trade.amount).toBe("string"); 123 | expect(parseFloat(trade.price)).toBeGreaterThan(0); 124 | expect(parseFloat(trade.amount)).toBeGreaterThan(0); 125 | done(); 126 | }); 127 | }, 128 | 90000 129 | ); 130 | 131 | test("should unsubscribe from tickers", () => { 132 | client.unsubscribeTicker(market); 133 | }); 134 | 135 | test("should unsubscribe from trades", () => { 136 | client.unsubscribeTrades(market); 137 | }); 138 | 139 | test("should unsubscribe from level2 updates", () => { 140 | client.unsubscribeLevel2Updates(market); 141 | }); 142 | 143 | test("should close connections", done => { 144 | client.on("closed", done); 145 | client.close(); 146 | }); 147 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const binance = require("./exchanges/binance/binance-client"); 2 | const bitfinex = require("./exchanges/bitfinex/bitfinex-client"); 3 | const bitflyer = require("./exchanges/bitflyer/bitflyer-client"); 4 | const bitmex = require("./exchanges/bitmex/bitmex-client"); 5 | const bitstamp = require("./exchanges/bitstamp/bitstamp-client"); 6 | const bittrex = require("./exchanges/bittrex/bittrex-client"); 7 | const gdax = require("./exchanges/gdax/gdax-client"); 8 | const gemini = require("./exchanges/gemini/gemini-client"); 9 | const hitbtc = require("./exchanges/hitbtc/hitbtc-client"); 10 | const huobi = require("./exchanges/huobi/huobi-client"); 11 | const okex = require("./exchanges/okex/okex-client"); 12 | const poloniex = require("./exchanges/poloniex/poloniex-client"); 13 | 14 | module.exports = { 15 | Binance: binance, 16 | Bitfinex: bitfinex, 17 | Bitflyer: bitflyer, 18 | BitMEX: bitmex, 19 | Bitstamp: bitstamp, 20 | Bittrex: bittrex, 21 | GDAX: gdax, 22 | Gemini: gemini, 23 | HitBTC: hitbtc, 24 | Huobi: huobi, 25 | OKEx: okex, 26 | Poloniex: poloniex, 27 | 28 | binance, 29 | bitfinex, 30 | bitflyer, 31 | bitmex, 32 | bitstamp, 33 | bittrex, 34 | gdax, 35 | gemini, 36 | hitbtc, 37 | huobi, 38 | okex, 39 | poloniex, 40 | }; 41 | -------------------------------------------------------------------------------- /src/smart-wss.js: -------------------------------------------------------------------------------- 1 | const { EventEmitter } = require("events"); 2 | const WebSocket = require("ws"); 3 | const winston = require("winston"); 4 | 5 | class SmartWss extends EventEmitter { 6 | constructor(wssPath) { 7 | super(); 8 | this._wssPath = wssPath; 9 | this._retryTimeoutMs = 15000; 10 | this._connected = false; 11 | } 12 | 13 | /** 14 | * Gets if the socket is currently connected 15 | */ 16 | get isConnected() { 17 | return this._connected; 18 | } 19 | 20 | /** 21 | * Attempts to connect 22 | */ 23 | async connect() { 24 | await this._attemptConnect(); 25 | } 26 | 27 | /** 28 | * Closes the connection 29 | */ 30 | async close() { 31 | winston.info("closing connection to", this._wssPath); 32 | this._wss.removeAllListeners(); 33 | this._wss.close(); 34 | } 35 | 36 | /** 37 | * Sends the data if the socket is currently connected. 38 | * Otherwise the consumer needs to retry to send the information 39 | * when the socket is connected. 40 | */ 41 | send(data) { 42 | if (this._connected) { 43 | try { 44 | this._wss.send(data); 45 | } catch (e) { 46 | winston.error(e.message); 47 | } 48 | } 49 | } 50 | 51 | ///////////////////////// 52 | 53 | /** 54 | * Attempts a connection and will either fail or timeout otherwise. 55 | */ 56 | _attemptConnect() { 57 | winston.info("attempting connection"); 58 | return new Promise(resolve => { 59 | let wssPath = this._wssPath; 60 | winston.info("connecting to", wssPath); 61 | this._wss = new WebSocket(wssPath, { perMessageDeflate: false }); 62 | this._wss.on("open", () => { 63 | winston.info("connected to", wssPath); 64 | this._connected = true; 65 | this.emit("open"); 66 | resolve(); 67 | }); 68 | this._wss.on("close", () => this._closeCallback()); 69 | this._wss.on("error", err => winston.error(this._wssPath, err)); 70 | this._wss.on("message", msg => this.emit("message", msg)); 71 | }); 72 | } 73 | 74 | /** 75 | * Handles the closing event by reconnecting 76 | */ 77 | _closeCallback() { 78 | winston.warn("disconnected from", this._wssPath); 79 | this._connected = false; 80 | this._wss = null; 81 | this.emit("disconnected"); 82 | this._retryConnect(); 83 | } 84 | 85 | /** 86 | * Perform reconnection after the timeout period 87 | * and will loop on hard failures 88 | */ 89 | async _retryConnect() { 90 | // eslint-disable-next-line 91 | while (true) { 92 | try { 93 | await wait(this._retryTimeoutMs); 94 | await this._attemptConnect(); 95 | return; 96 | } catch (ex) { 97 | winston.error(ex); 98 | } 99 | } 100 | } 101 | } 102 | 103 | async function wait(timeout) { 104 | return new Promise(resolve => setTimeout(resolve, timeout)); 105 | } 106 | 107 | module.exports = SmartWss; 108 | -------------------------------------------------------------------------------- /src/types/auction.js: -------------------------------------------------------------------------------- 1 | class Auction { 2 | constructor({ exchange, base, quote, tradeId, unix, price, amount, high, low }) { 3 | this.exchange = exchange; 4 | this.quote = quote; 5 | this.base = base; 6 | this.tradeId = tradeId; 7 | this.unix = unix; 8 | this.price = price; 9 | this.high = high; 10 | this.low = low; 11 | this.amount = amount; 12 | } 13 | 14 | get marketId() { 15 | return `${this.base}/${this.quote}`; 16 | } 17 | 18 | get fullId() { 19 | return `${this.exchange}:${this.base}/${this.quote}`; 20 | } 21 | } 22 | 23 | module.exports = Auction; 24 | -------------------------------------------------------------------------------- /src/types/block-trade.js: -------------------------------------------------------------------------------- 1 | class BlockTrade { 2 | constructor({ exchange, base, quote, tradeId, unix, price, amount }) { 3 | this.exchange = exchange; 4 | this.quote = quote; 5 | this.base = base; 6 | this.tradeId = tradeId; 7 | this.unix = unix; 8 | this.price = price; 9 | this.amount = amount; 10 | } 11 | 12 | get marketId() { 13 | return `${this.base}/${this.quote}`; 14 | } 15 | 16 | get fullId() { 17 | return `${this.exchange}:${this.base}/${this.quote}`; 18 | } 19 | } 20 | 21 | module.exports = BlockTrade; 22 | -------------------------------------------------------------------------------- /src/types/candle-stick.js: -------------------------------------------------------------------------------- 1 | /** 2 | * K线数据 3 | */ 4 | class CandleStick { 5 | constructor({ exchange, base, quote, tradeId, unix, price, amount, buyOrderId, sellOrderId }) { 6 | this.exchange = exchange; 7 | this.quote = quote; 8 | this.base = base; 9 | this.tradeId = tradeId; 10 | this.unix = unix; 11 | this.price = price; 12 | this.amount = amount; 13 | this.buyOrderId = buyOrderId; 14 | this.sellOrderId = sellOrderId; 15 | } 16 | 17 | get marketId() { 18 | return `${this.base}/${this.quote}`; 19 | } 20 | 21 | get fullId() { 22 | return `${this.exchange}:${this.base}/${this.quote}`; 23 | } 24 | } 25 | 26 | module.exports = CandleStick; 27 | -------------------------------------------------------------------------------- /src/types/level2-point.js: -------------------------------------------------------------------------------- 1 | class Level2Point { 2 | constructor(price, size, count, meta) { 3 | this.price = price; 4 | this.size = size; 5 | this.count = count; 6 | this.meta = meta; 7 | } 8 | } 9 | 10 | module.exports = Level2Point; 11 | -------------------------------------------------------------------------------- /src/types/level2-snapshot.js: -------------------------------------------------------------------------------- 1 | class Level2Snapshot { 2 | constructor(props) { 3 | for (let key in props) { 4 | this[key] = props[key]; 5 | } 6 | } 7 | 8 | get marketId() { 9 | return `${this.base}/${this.quote}`; 10 | } 11 | 12 | get fullId() { 13 | return `${this.exchange}:${this.base}/${this.quote}`; 14 | } 15 | } 16 | 17 | module.exports = Level2Snapshot; 18 | -------------------------------------------------------------------------------- /src/types/level2-update.js: -------------------------------------------------------------------------------- 1 | class Level2Update { 2 | constructor(props) { 3 | for (let key in props) { 4 | this[key] = props[key]; 5 | } 6 | } 7 | 8 | get marketId() { 9 | return `${this.base}/${this.quote}`; 10 | } 11 | 12 | get fullId() { 13 | return `${this.exchange}:${this.base}/${this.quote}`; 14 | } 15 | } 16 | 17 | module.exports = Level2Update; 18 | -------------------------------------------------------------------------------- /src/types/level3-point.js: -------------------------------------------------------------------------------- 1 | class Level3Point { 2 | constructor(orderId, price, size, meta) { 3 | this.orderId = orderId; 4 | this.price = price; 5 | this.size = size; 6 | this.meta = meta; 7 | } 8 | } 9 | 10 | module.exports = Level3Point; 11 | -------------------------------------------------------------------------------- /src/types/level3-snapshot.js: -------------------------------------------------------------------------------- 1 | class Level3Snapshot { 2 | constructor(props) { 3 | for (let key in props) { 4 | this[key] = props[key]; 5 | } 6 | } 7 | 8 | get fullId() { 9 | return `${this.exchange}:${this.base}/${this.quote}`; 10 | } 11 | } 12 | 13 | module.exports = Level3Snapshot; 14 | -------------------------------------------------------------------------------- /src/types/level3-update.js: -------------------------------------------------------------------------------- 1 | class Level3Update { 2 | constructor(props) { 3 | for (let key in props) { 4 | this[key] = props[key]; 5 | } 6 | } 7 | 8 | get fullId() { 9 | return `${this.exchange}:${this.base}/${this.quote}`; 10 | } 11 | } 12 | 13 | module.exports = Level3Update; 14 | -------------------------------------------------------------------------------- /src/types/order-book.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 订单数据 3 | */ 4 | class OrderBook { 5 | constructor({ exchange, base, quote, tradeId, unix, price, amount, buyOrderId, sellOrderId }) { 6 | this.exchange = exchange; 7 | this.quote = quote; 8 | this.base = base; 9 | this.tradeId = tradeId; 10 | this.unix = unix; 11 | this.price = price; 12 | this.amount = amount; 13 | this.buyOrderId = buyOrderId; 14 | this.sellOrderId = sellOrderId; 15 | } 16 | 17 | get marketId() { 18 | return `${this.base}/${this.quote}`; 19 | } 20 | 21 | get fullId() { 22 | return `${this.exchange}:${this.base}/${this.quote}`; 23 | } 24 | } 25 | 26 | module.exports = OrderBook; 27 | -------------------------------------------------------------------------------- /src/types/ticker.js: -------------------------------------------------------------------------------- 1 | class Ticker { 2 | constructor({ 3 | exchange, 4 | base, 5 | quote, 6 | timestamp, 7 | last, 8 | open, 9 | high, 10 | low, 11 | volume, 12 | quoteVolume, 13 | change, 14 | changePercent, 15 | bid, 16 | bidVolume, 17 | ask, 18 | askVolume, 19 | }) { 20 | this.exchange = exchange; 21 | this.base = base; 22 | this.quote = quote; 23 | this.timestamp = timestamp; 24 | this.last = last; 25 | this.open = open; 26 | this.high = high; 27 | this.low = low; 28 | this.volume = volume; 29 | this.quoteVolume = quoteVolume; 30 | this.change = change; 31 | this.changePercent = changePercent; 32 | this.bid = bid; 33 | this.bidVolume = bidVolume; 34 | this.ask = ask; 35 | this.askVolume = askVolume; 36 | } 37 | 38 | get fullId() { 39 | return `${this.exchange}:${this.base}/${this.quote}`; 40 | } 41 | } 42 | 43 | module.exports = Ticker; 44 | -------------------------------------------------------------------------------- /src/types/trade.js: -------------------------------------------------------------------------------- 1 | class Trade { 2 | constructor({ 3 | exchange, 4 | base, 5 | quote, 6 | tradeId, 7 | unix, 8 | side, 9 | price, 10 | amount, 11 | buyOrderId, 12 | sellOrderId, 13 | }) { 14 | this.exchange = exchange; 15 | this.quote = quote; 16 | this.base = base; 17 | this.tradeId = tradeId; 18 | this.unix = unix; 19 | this.side = side; 20 | this.price = price; 21 | this.amount = amount; 22 | this.buyOrderId = buyOrderId; 23 | this.sellOrderId = sellOrderId; 24 | } 25 | 26 | get marketId() { 27 | return `${this.base}/${this.quote}`; 28 | } 29 | 30 | get fullId() { 31 | return `${this.exchange}:${this.base}/${this.quote}`; 32 | } 33 | } 34 | 35 | module.exports = Trade; 36 | -------------------------------------------------------------------------------- /src/types/trade.spec.js: -------------------------------------------------------------------------------- 1 | const Trade = require("./trade"); 2 | 3 | test("marketId should be base + quote", () => { 4 | let t = new Trade({ base: "BTC", quote: "USD" }); 5 | expect(t.marketId).toBe("BTC/USD"); 6 | }); 7 | 8 | test("fullId should be exchange + base + quote", () => { 9 | let t = new Trade({ exchange: "GDAX", base: "BTC", quote: "USD" }); 10 | expect(t.fullId).toBe("GDAX:BTC/USD"); 11 | }); 12 | -------------------------------------------------------------------------------- /src/watcher.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Watcher subscribes to a client's messages and 3 | * will trigger a restart of the client if no 4 | * information has been transmitted in the checking interval 5 | */ 6 | class Watcher { 7 | constructor(client, intervalMs = 90000) { 8 | this.intervalMs = intervalMs; 9 | this.client = client; 10 | 11 | this._intervalHandle = undefined; 12 | this._lastMessage = undefined; 13 | 14 | this._markAlive = this._markAlive.bind(this); 15 | client.on("trade", this._markAlive); 16 | client.on("l2snapshot", this._markAlive); 17 | client.on("l2update", this._markAlive); 18 | client.on("l3snapshot", this._markAlive); 19 | client.on("l3update", this._markAlive); 20 | } 21 | 22 | /** 23 | * Starts an interval to check if a reconnction is required 24 | */ 25 | start() { 26 | this.stop(); // always clear the prior interval 27 | this._intervalHandle = setInterval(this._onCheck.bind(this), this.intervalMs); 28 | } 29 | 30 | /** 31 | * Stops an interval to check if a reconnection is required 32 | */ 33 | stop() { 34 | clearInterval(this._intervalHandle); 35 | this._intervalHandle = undefined; 36 | } 37 | 38 | /** 39 | * Marks that a message was received 40 | */ 41 | _markAlive() { 42 | this._lastMessage = Date.now(); 43 | } 44 | 45 | /** 46 | * Checks if a reconnecton is required by comparing the current 47 | * date to the last receieved message date 48 | */ 49 | _onCheck() { 50 | if (!this._lastMessage || this._lastMessage < Date.now() - this.intervalMs) { 51 | this.client.reconnect(); 52 | this.stop(); 53 | } 54 | } 55 | } 56 | 57 | module.exports = Watcher; 58 | -------------------------------------------------------------------------------- /src/watcher.spec.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require("events").EventEmitter; 2 | const Watcher = require("./watcher"); 3 | 4 | class MockClient extends EventEmitter { 5 | constructor() { 6 | super(); 7 | this.reconnect = jest.fn(); 8 | } 9 | } 10 | 11 | function wait(ms) { 12 | return new Promise(resolve => setTimeout(resolve, ms)); 13 | } 14 | 15 | let sut; 16 | let client; 17 | 18 | beforeAll(() => { 19 | client = new MockClient(); 20 | sut = new Watcher(client, 100); 21 | jest.spyOn(sut, "stop"); 22 | }); 23 | 24 | describe("start", () => { 25 | beforeAll(() => { 26 | sut.start(); 27 | }); 28 | test("should trigger a stop", () => { 29 | expect(sut.stop).toHaveBeenCalledTimes(1); 30 | }); 31 | test("should start the interval", () => { 32 | expect(sut._intervalHandle).toBeDefined(); 33 | }); 34 | }); 35 | 36 | describe("stop", () => { 37 | beforeAll(() => { 38 | sut.stop(); 39 | }); 40 | test("should clear the interval", () => { 41 | expect(sut._intervalHandle).toBeUndefined(); 42 | }); 43 | }); 44 | 45 | describe("on messages", () => { 46 | beforeEach(() => { 47 | sut._lastMessage = undefined; 48 | }); 49 | test("other should not mark", () => { 50 | client.emit("other"); 51 | expect(sut._lastMessage).toBeUndefined(); 52 | }); 53 | test("trade should mark", () => { 54 | client.emit("trade"); 55 | expect(sut._lastMessage).toBeDefined(); 56 | }); 57 | test("l2snapshot should mark", () => { 58 | client.emit("l2snapshot"); 59 | expect(sut._lastMessage).toBeDefined(); 60 | }); 61 | test("l2update should mark", () => { 62 | client.emit("l2update"); 63 | expect(sut._lastMessage).toBeDefined(); 64 | }); 65 | test("l3snapshot should mark", () => { 66 | client.emit("l3snapshot"); 67 | expect(sut._lastMessage).toBeDefined(); 68 | }); 69 | test("l3update should mark", () => { 70 | client.emit("l3update"); 71 | expect(sut._lastMessage).toBeDefined(); 72 | }); 73 | }); 74 | 75 | describe("on expire", () => { 76 | beforeAll(() => { 77 | sut.start(); 78 | }); 79 | test("it should call reconnect on the client", async () => { 80 | await wait(150); 81 | expect(client.reconnect).toHaveBeenCalledTimes(1); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /test/SimpleTest.js: -------------------------------------------------------------------------------- 1 | const ccew = require("../src/index"); 2 | const hitbtc = new ccew.HitBTC(); 3 | 4 | hitbtc.on("ticker", ticker => console.log(ticker)); 5 | hitbtc.subscribeTickers({ 6 | id: "ETHBTC", 7 | base: "ETH", 8 | quote: "BTC", 9 | }); 10 | --------------------------------------------------------------------------------