ccxws
Version:
Websocket client for 37 cryptocurrency exchanges
499 lines (494 loc) • 17.7 kB
JavaScript
"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