UNPKG

ccxws

Version:

Websocket client for 37 cryptocurrency exchanges

499 lines (494 loc) 17.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.BitmexClient = void 0; /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ const moment_1 = __importDefault(require("moment")); const BasicClient_1 = require("../BasicClient"); const Candle_1 = require("../Candle"); const CandlePeriod_1 = require("../CandlePeriod"); const Level2Point_1 = require("../Level2Point"); const Level2Snapshots_1 = require("../Level2Snapshots"); const Level2Update_1 = require("../Level2Update"); const NotImplementedFn_1 = require("../NotImplementedFn"); const Ticker_1 = require("../Ticker"); const Trade_1 = require("../Trade"); class BitmexClient extends BasicClient_1.BasicClient { /** Documentation: https://www.bitmex.com/app/wsAPI */ constructor({ wssPath = "wss://www.bitmex.com/realtime", watcherMs } = {}) { super(wssPath, "BitMEX", undefined, watcherMs); this._sendSubLevel2Snapshots = NotImplementedFn_1.NotImplementedFn; this._sendUnsubLevel2Snapshots = NotImplementedFn_1.NotImplementedFn; this._sendSubLevel3Snapshots = NotImplementedFn_1.NotImplementedFn; this._sendUnsubLevel3Snapshots = NotImplementedFn_1.NotImplementedFn; this._sendSubLevel3Updates = NotImplementedFn_1.NotImplementedFn; this._sendUnsubLevel3Updates = NotImplementedFn_1.NotImplementedFn; this.hasTickers = true; this.hasTrades = true; this.hasCandles = true; this.hasLevel2Updates = true; this.candlePeriod = CandlePeriod_1.CandlePeriod._1m; this.constructL2Price = true; this.l2PriceMap = new Map(); /** * Keyed from remote_id, market.id * */ this.tickerMap = new Map(); } _sendSubTicker(remote_id) { this._sendSubQuote(remote_id); this._sendSubTrades(remote_id); } _sendUnsubTicker(remote_id) { this._sendUnsubQuote(remote_id); // if we're still subscribed to trades for this symbol, don't unsub if (!this._tradeSubs.has(remote_id)) { this._sendUnsubTrades(remote_id); } this._deleteTicker(remote_id); } _sendSubQuote(remote_id) { this._wss.send(JSON.stringify({ op: "subscribe", args: [`quote:${remote_id}`], })); } _sendUnsubQuote(remote_id) { this._wss.send(JSON.stringify({ op: "unsubscribe", args: [`quote:${remote_id}`], })); } _sendSubTrades(remote_id) { this._wss.send(JSON.stringify({ op: "subscribe", args: [`trade:${remote_id}`], })); } _sendUnsubTrades(remote_id) { this._wss.send(JSON.stringify({ op: "unsubscribe", args: [`trade:${remote_id}`], })); } _sendSubCandles(remote_id) { this._wss.send(JSON.stringify({ op: "subscribe", args: [`tradeBin${candlePeriod(this.candlePeriod)}:${remote_id}`], })); } _sendUnsubCandles(remote_id) { this._wss.send(JSON.stringify({ op: "unsubscribe", args: [`tradeBin${candlePeriod(this.candlePeriod)}:${remote_id}`], })); } _sendSubLevel2Updates(remote_id) { this._wss.send(JSON.stringify({ op: "subscribe", args: [`orderBookL2:${remote_id}`], })); } _sendUnsubLevel2Updates(remote_id) { this._wss.send(JSON.stringify({ op: "unsubscribe", args: [`orderBookL2:${remote_id}`], })); } _onMessage(msgs) { const message = JSON.parse(msgs); const { table, action } = message; if (table === "quote") { this._onQuoteMessage(message); return; } if (table === "trade") { if (action !== "insert") return; for (const datum of message.data) { const remote_id = datum.symbol; // trade let market = this._tradeSubs.get(remote_id); if (market) { const trade = this._constructTrades(datum, market); this.emit("trade", trade, market); } // ticker market = this._tickerSubs.get(remote_id); if (market) { const ticker = this._constructTickerForTrade(datum, market); if (this._isTickerReady(ticker)) { this.emit("ticker", ticker, market); } } } return; } // candles if (table && table.startsWith("tradeBin")) { for (const datum of message.data) { const remote_id = datum.symbol; const market = this._candleSubs.get(remote_id); if (!market) continue; const candle = this._constructCandle(datum); this.emit("candle", candle, market); } return; } if (table === "orderBookL2") { /** From testing, we've never encountered non-uniform markets in a single message broadcast and will assume uniformity (though we will validate in the construction methods). */ const remote_id = message.data[0].symbol; const market = this._level2UpdateSubs.get(remote_id); if (!market) return; /** The partial action is sent when there is a new subscription. It contains the snapshot of data. Updates may arrive prior to the snapshot but can be discarded. Otherwise it will be an insert, update, or delete action. All three of those will be handles in l2update messages. */ if (action === "partial") { const snapshot = this._constructLevel2Snapshot(message.data, market); this.emit("l2snapshot", snapshot, market); } else { const update = this._constructLevel2Update(message, market); this.emit("l2update", update, market); } return; } } _constructTrades(datum, market) { const { size, side, timestamp, price, trdMatchID } = datum; const unix = (0, moment_1.default)(timestamp).valueOf(); return new Trade_1.Trade({ exchange: "BitMEX", base: market.base, quote: market.quote, id: market.id, tradeId: trdMatchID.replace(/-/g, ""), unix, side: side.toLowerCase(), price: price.toFixed(8), amount: size.toFixed(8), raw: datum, // attach the raw data incase it is needed in raw format }); } /** { table: 'tradeBin1m', action: 'insert', data: [ { timestamp: '2020-08-12T20:33:00.000Z', symbol: 'XBTUSD', open: 11563, high: 11563, low: 11560, close: 11560.5, trades: 158, volume: 157334, vwap: 11562.0303, lastSize: 4000, turnover: 1360824337, homeNotional: 13.60824337, foreignNotional: 157334 } ] } */ _constructCandle(datum) { const ts = (0, moment_1.default)(datum.timestamp).valueOf(); return new Candle_1.Candle(ts, datum.open.toFixed(8), datum.high.toFixed(8), datum.low.toFixed(8), datum.close.toFixed(8), datum.volume.toFixed(8)); } /** Snapshot message are sent when an l2orderbook is subscribed to. This part is necessary to maintain a proper orderbook because BitMEX sends updates with a unique price key and does not include a price value. This code will maintain the price map so that update messages can be constructed with a price. */ _constructLevel2Snapshot(data, market) { let asks = []; const bids = []; for (const datum of data) { // Construct the price lookup map for all values supplied here. // Per the documentation, the id is a unique value for the // market and the price. if (this.constructL2Price) { this.l2PriceMap.set(datum.id, datum.price.toFixed(8)); } // build the data point const point = new Level2Point_1.Level2Point(datum.price.toFixed(8), datum.size.toFixed(8), undefined, { id: datum.id, }); // add the datapoint to the asks or bids depending if its sell or bid side if (datum.side === "Sell") asks.push(point); else bids.push(point); } // asks arrive in descending order (best ask last) // ccxws standardizes so that best bid/ask are array index 0 asks = asks.reverse(); return new Level2Snapshots_1.Level2Snapshot({ exchange: "BitMEX", base: market.base, quote: market.quote, id: market.id, asks, bids, }); } /** Update messages will arrive as either insert, update, or delete messages. The data payload appears to be uniform for a market. This code will do the heavy lifting on remapping the pricing structure. BitMEX sends hte updates without a price and instead include a unique identifer for the asset and the price. Insert: { table: 'orderbookL2' action: 'insert' data: [{ symbol: 'XBTUSD', id: 8799198150, side: 'Sell', size: 1, price: 8018.5 }] } Update: { table: 'orderBookL2', action: 'update', data: [ { symbol: 'XBTUSD', id: 8799595600, side: 'Sell', size: 258136 } ] } Delete: { table: 'orderBookL2', action: 'delete', data: [ { symbol: 'XBTUSD', id: 8799198650, side: 'Sell' } ] } We will standardize these to the CCXWS format: - Insert and update will have price and size - Delete will have a size of 0. */ _constructLevel2Update(msg, market) { // get the data from the message const data = msg.data; const action = msg.action; let asks = []; const bids = []; for (const datum of data) { let price; let size; /** In our testing, we've always seen message uniformity in the symbols. For performance reasons we're going to batch these into a single response. But if we have a piece of data that doesn't match the symbol we want to throw an error instead of polluting the orderbook with bad data. */ if (datum.symbol !== market.id) { throw new Error(`l2update symbol mismatch, expected ${market.id}, got ${datum.symbol}`); } // Find the price based on the price identifier if (this.constructL2Price) { switch (action) { // inserts will contain the price, we need to set these in the map // we can also directly use the price value case "insert": price = datum.price.toFixed(8); this.l2PriceMap.set(datum.id, price); break; // update will require us to look up the price from the map case "update": price = this.l2PriceMap.get(datum.id); break; // price will require us to look up the price from the map // we also will want to delete the map value since it's // no longer needed case "delete": price = this.l2PriceMap.get(datum.id); this.l2PriceMap.delete(datum.id); break; } } // Find the size switch (action) { case "insert": case "update": size = datum.size.toFixed(8); break; case "delete": size = (0).toFixed(8); break; } if (!price) { // eslint-disable-next-line no-console console.warn("unknown price", datum); } // Construct the data point const point = new Level2Point_1.Level2Point(price, size, undefined, { type: action, id: datum.id }); // Insert into ask or bid if (datum.side === "Sell") asks.push(point); else bids.push(point); } // asks arrive in descending order (best ask last) // ccxws standardizes so that best bid/ask are array index 0 asks = asks.reverse(); return new Level2Update_1.Level2Update({ exchange: "BitMEX", base: market.base, quote: market.quote, id: market.id, asks, bids, }); } /** * Updates a ticker for a quote update. From * testing, quote broadcasts are sorted from oldest to newest and are * for a single market. The parent message looks like below and * the last object in the array is provided to this method. * { table: 'quote', action: 'insert', data: [ { timestamp: '2020-04-17T16:05:57.560Z', symbol: 'XBTUSD', bidSize: 689279, bidPrice: 7055, askPrice: 7055.5, askSize: 927374 }, { timestamp: '2020-04-17T16:05:58.016Z', symbol: 'XBTUSD', bidSize: 684279, bidPrice: 7055, askPrice: 7055.5, askSize: 927374 } ] } */ _onQuoteMessage(msg) { const data = msg.data; const lastQuote = data[data.length - 1]; const remote_id = lastQuote.symbol; const market = this._tickerSubs.get(remote_id); if (market) { const ticker = this._constructTickerForQuote(lastQuote, market); if (this._isTickerReady(ticker)) { this.emit("ticker", ticker, market); } } } /** * Constructs a ticker from a single quote data { timestamp: '2020-04-17T16:05:58.016Z', symbol: 'XBTUSD', bidSize: 684279, bidPrice: 7055, askPrice: 7055.5, askSize: 927374 } */ _constructTickerForQuote(datum, market) { const ticker = this._getTicker(market); ticker.ask = datum.askPrice.toFixed(); ticker.askVolume = datum.askSize.toFixed(); ticker.bid = datum.bidPrice.toFixed(); ticker.bidVolume = datum.bidSize.toFixed(); ticker.timestamp = new Date(datum.timestamp).valueOf(); return ticker; } /** * Updates a ticker for the market based on the trade informatio { timestamp: '2020-04-17T16:39:53.324Z', symbol: 'XBTUSD', side: 'Buy', size: 20, price: 7062, tickDirection: 'ZeroPlusTick', trdMatchID: 'e6101cc7-844e-25d2-e4a5-7e71d04439e3', grossValue: 283200, homeNotional: 0.002832, foreignNotional: 20 } */ _constructTickerForTrade(data, market) { const ticker = this._getTicker(market); ticker.last = data.price.toFixed(); ticker.timestamp = new Date(data.timestamp).valueOf(); return ticker; } /** * Creates a blank ticker for the specified market. The Ticker class is optimized * to maintain a consistent shape to prevent shape transitions and reduce garbage. * @param {*} market */ _createTicker(market) { return new Ticker_1.Ticker({ exchange: "BitMEX", base: market.base, quote: market.quote, }); } /** * Retrieves a ticker for the market or constructs one if it doesn't exist * @param {string} market */ _getTicker(market) { const remote_id = market.id; let ticker = this.tickerMap.get(remote_id); if (!ticker) { ticker = this._createTicker(market); this.tickerMap.set(remote_id, ticker); } return ticker; } /** * Deletes cached ticker data after unsubbing from ticker. */ _deleteTicker(remote_id) { this.tickerMap.delete(remote_id); } /** * Returns true when all required information is available * in the ticker. Because the ticker is built from multiple stream * testing will break if a ticker is prematurely emitted that does * not contain all of the required data. */ _isTickerReady(ticker) { return !!(ticker.last && ticker.bid && ticker.ask); } } exports.BitmexClient = BitmexClient; function candlePeriod(period) { switch (period) { case CandlePeriod_1.CandlePeriod._1m: return "1m"; case CandlePeriod_1.CandlePeriod._5m: return "5m"; case CandlePeriod_1.CandlePeriod._1h: return "1h"; case CandlePeriod_1.CandlePeriod._1d: return "1d"; } } //# sourceMappingURL=BitmexClient.js.map