ccxws
Version:
Websocket client for 37 cryptocurrency exchanges
632 lines (618 loc) • 22.6 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.KrakenClient = void 0;
/* eslint-disable @typescript-eslint/member-ordering */
/* eslint-disable @typescript-eslint/restrict-plus-operands */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
const decimal_js_1 = __importDefault(require("decimal.js"));
const BasicClient_1 = require("../BasicClient");
const Candle_1 = require("../Candle");
const CandlePeriod_1 = require("../CandlePeriod");
const https = __importStar(require("../Https"));
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");
/**
Kraken's API documentation is availble at:
https://www.kraken.com/features/websocket-api
Once the socket is open you can subscribe to a channel by sending
a subscribe request message.
Ping is initiated by the client, not the server. This means
we do not need to listen for pings events or respond appropriately.
Requests take an array of pairs to subscribe to an event. This means
when we subscribe or unsubscribe we need to send the COMPLETE list
of active markets. BasicClient maintains the list of active markets
in the various maps: _tickerSubs, _tradeSubs, _level2UpdateSubs.
This client will retrieve the market keys from those maps to
determine the remoteIds to send to the server on all sub/unsub requests.
*/
class KrakenClient extends BasicClient_1.BasicClient {
constructor({ wssPath = "wss://ws.kraken.com", autoloadSymbolMaps = true, watcherMs, } = {}) {
super(wssPath, "Kraken", 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.hasLevel2Snapshots = false;
this.candlePeriod = CandlePeriod_1.CandlePeriod._1m;
this.bookDepth = 500;
this.subscriptionLog = new Map();
this.debouceTimeoutHandles = new Map();
this.debounceWait = 200;
this.fromRestMap = new Map();
this.fromWsMap = new Map();
if (autoloadSymbolMaps) {
this.loadSymbolMaps().catch(err => this.emit("error", err));
}
}
/**
Kraken made the websocket symbols different
than the REST symbols. Because CCXT uses the REST symbols,
we're going to default to receiving REST symbols and mapping them
to the corresponding WS symbol.
In order to do this, we'll need to retrieve the list of symbols from
the REST API. The constructor executes this.
*/
async loadSymbolMaps() {
const uri = "https://api.kraken.com/0/public/AssetPairs";
const { result } = await https.get(uri);
for (const symbol in result) {
const restName = symbol;
const wsName = result[symbol].wsname;
if (wsName) {
this.fromRestMap.set(restName, wsName);
this.fromWsMap.set(wsName, restName);
}
}
}
/**
Helper that retrieves the list of ws symbols from the supplied
subscription map. The BasicClient manages the subscription maps
when subscribe<Trade|Ticker|etc> is called and adds the records.
This helper will take the values in a subscription map and
convert them into the websocket symbols, ensuring that markets
that are not mapped do not get included in the list.
@param map subscription map such as _tickerSubs or _tradeSubs
*/
_wsSymbolsFromSubMap(map) {
const restSymbols = Array.from(map.keys());
return restSymbols.map(p => this.fromRestMap.get(p)).filter(p => p);
}
/**
Debounce is used to throttle a function that is repeatedly called. This
is applicable when many calls to subscribe or unsubscribe are executed
in quick succession by the calling application.
*/
_debounce(type, fn) {
clearTimeout(this.debouceTimeoutHandles.get(type));
this.debouceTimeoutHandles.set(type, setTimeout(fn, this.debounceWait));
}
/**
This method is called by each of the _send* methods. It uses
a debounce function on a given key so we can batch send the request
with the active symbols. We also need to convert the rest symbols
provided by the caller into websocket symbols used by the Kraken
ws server.
@param debounceKey unique key for the caller so each call
is debounced with related calls
@param subMap subscription map storing the current subs
for the type, such as _tickerSubs, _tradeSubs, etc.
@param subscribe true for subscribe, false for unsubscribe
@param subscription the subscription name passed to the
JSON-RPC call
*/
_debounceSend(debounceKey, subMap, subscribe, subscription) {
this._debounce(debounceKey, () => {
const wsSymbols = this._wsSymbolsFromSubMap(subMap);
if (!this._wss)
return;
this._wss.send(JSON.stringify({
event: subscribe ? "subscribe" : "unsubscribe",
pair: wsSymbols,
subscription,
}));
});
}
/**
Constructs a request that looks like:
{
"event": "subscribe",
"pair": ["XBT/USD","BCH/USD"]
"subscription": {
"name": "ticker"
}
}
*/
_sendSubTicker() {
this._debounceSend("sub-ticker", this._tickerSubs, true, { name: "ticker" });
}
/**
Constructs a request that looks like:
{
"event": "unsubscribe",
"pair": ["XBT/USD","BCH/USD"]
"subscription": {
"name": "ticker"
}
}
*/
_sendUnsubTicker() {
this._debounceSend("unsub-ticker", this._tickerSubs, false, { name: "ticker" });
}
/**
Constructs a request that looks like:
{
"event": "subscribe",
"pair": ["XBT/USD","BCH/USD"]
"subscription": {
"name": "trade"
}
}
*/
_sendSubTrades() {
this._debounceSend("sub-trades", this._tradeSubs, true, { name: "trade" });
}
/**
Constructs a request that looks like:
{
"event": "unsubscribe",
"pair": ["XBT/USD","BCH/USD"]
"subscription": {
"name": "trade"
}
}
*/
_sendUnsubTrades() {
this._debounceSend("unsub-trades", this._tradeSubs, false, { name: "trade" });
}
/**
* Constructs a request that looks like:
{
"event": "unsubscribe",
"pair": ["XBT/USD","BCH/USD"]
"subscription": {
"name": "ohlc"
"interval": 1
}
}
*/
_sendSubCandles() {
const interval = getCandlePeriod(this.candlePeriod);
this._debounceSend("sub-candles", this._candleSubs, true, { name: "ohlc", interval });
}
/**
* Constructs a request that looks like:
{
"event": "unsubscribe",
"pair": ["XBT/USD","BCH/USD"]
"subscription": {
"name": "ohlc"
"interval": 1
}
}
*/
_sendUnsubCandles() {
const interval = getCandlePeriod(this.candlePeriod);
this._debounceSend("unsub-candles", this._candleSubs, false, { name: "ohlc", interval });
}
/**
Constructs a request that looks like:
{
"event": "subscribe",
"pair": ["XBT/USD","BCH/USD"]
"subscription": {
"name": "book"
}
}
*/
_sendSubLevel2Updates() {
this._debounceSend("sub-l2updates", this._level2UpdateSubs, true, {
name: "book",
depth: this.bookDepth,
});
}
/**
Constructs a request that looks like:
{
"event": "unsubscribe",
"pair": ["XBT/USD","BCH/USD"]
"subscription": {
"name": "trade"
}
}
*/
_sendUnsubLevel2Updates() {
this._debounceSend("unsub-l2updates", this._level2UpdateSubs, false, { name: "book" });
}
/**
Handle for incoming messages
@param raw
*/
_onMessage(raw) {
const msgs = JSON.parse(raw);
this._processsMessage(msgs);
}
/**
When a subscription is initiated, a subscriptionStatus event is sent.
This message will be cached in the subscriptionLog for look up later.
When messages arrive, they only contain the subscription id. The
id is used to look up the subscription details in the subscriptionLog
to determine what the message means.
*/
_processsMessage(msg) {
if (msg.event === "heartbeat") {
return;
}
if (msg.event === "systemStatus") {
return;
}
// Capture the subscription metadata for use later.
if (msg.event === "subscriptionStatus") {
/*
{
channelID: '15',
event: 'subscriptionStatus',
pair: 'XBT/EUR',
status: 'subscribed',
subscription: { name: 'ticker' }
}
*/
this.subscriptionLog.set(parseInt(msg.channelID), msg);
return;
}
// All messages from this point forward should arrive as an array
if (!Array.isArray(msg)) {
return;
}
const [subscriptionId, details] = msg;
const sl = this.subscriptionLog.get(subscriptionId);
// If we don't have a subscription log entry for this event then
// we need to abort since we don't know what to do with it!
// From the subscriptionLog entry's pair, we can convert
// the ws symbol into a rest symbol
const remote_id = this.fromWsMap.get(sl.pair);
// tickers
if (sl.subscription.name === "ticker") {
const market = this._tickerSubs.get(remote_id);
if (!market)
return;
const ticker = this._constructTicker(details, market);
if (ticker) {
this.emit("ticker", ticker, market);
}
return;
}
// trades
if (sl.subscription.name === "trade") {
if (Array.isArray(msg[1])) {
const market = this._tradeSubs.get(remote_id);
if (!market)
return;
for (const t of msg[1]) {
const trade = this._constructTrade(t, market);
if (trade) {
this.emit("trade", trade, market);
}
}
}
return;
}
// candles
if (sl.subscription.name === "ohlc") {
const market = this._candleSubs.get(remote_id);
if (!market)
return;
const candle = this._constructCandle(msg);
this.emit("candle", candle, market);
return;
}
//l2 updates
if (sl.subscription.name === "book") {
const market = this._level2UpdateSubs.get(remote_id);
if (!market)
return;
// snapshot use as/bs
// updates us a/b
const isSnapshot = !!msg[1].as;
if (isSnapshot) {
const l2snapshot = this._constructLevel2Snapshot(msg[1], market);
if (l2snapshot) {
this.emit("l2snapshot", l2snapshot, market, msg);
}
}
else {
const l2update = this._constructLevel2Update(msg, market);
if (l2update) {
this.emit("l2update", l2update, market, msg);
}
}
}
return;
}
/**
Refer to https://www.kraken.com/en-us/features/websocket-api#message-ticker
*/
_constructTicker(msg, market) {
/*
{ a: [ '3343.70000', 1, '1.03031692' ],
b: [ '3342.20000', 1, '1.00000000' ],
c: [ '3343.70000', '0.01000000' ],
v: [ '4514.26000539', '7033.48119179' ],
p: [ '3357.13865', '3336.28299' ],
t: [ 14731, 22693 ],
l: [ '3308.40000', '3223.90000' ],
h: [ '3420.00000', '3420.00000' ],
o: [ '3339.40000', '3349.00000' ] }
*/
// calculate change and change percent based from the open/close
// prices
const open = parseFloat(msg.o[1]);
const last = parseFloat(msg.c[0]);
const change = open - last;
const changePercent = ((last - open) / open) * 100;
// calculate the quoteVolume by multiplying the volume
// over the last 24h by the 24h vwap
const quoteVolume = parseFloat(msg.v[1]) * parseFloat(msg.p[1]);
return new Ticker_1.Ticker({
exchange: this.name,
base: market.base,
quote: market.quote,
timestamp: Date.now(),
last: msg.c[0],
open: msg.o[1],
high: msg.h[0],
low: msg.l[0],
volume: msg.v[1],
quoteVolume: quoteVolume.toFixed(8),
change: change.toFixed(8),
changePercent: changePercent.toFixed(2),
bid: msg.b[0],
bidVolume: msg.b[2],
ask: msg.a[0],
askVolume: msg.a[2],
});
}
/**
Refer to https://www.kraken.com/en-us/features/websocket-api#message-trade
Since Kraken doesn't send a trade Id we create a surrogate from
the time stamp. This can result in duplicate trade Ids being generated.
Additionaly mechanism will need to be put into place by the consumer to
dedupe them.
*/
_constructTrade(datum, market) {
/*
[ '3363.20000', '0.05168143', '1551432237.079148', 'b', 'l', '' ]
*/
const side = datum[3] === "b" ? "buy" : "sell";
// see above
const tradeId = this._createTradeId(datum[2]);
// convert to ms timestamp as an int
const unix = parseInt((parseFloat(datum[2]) * 1000));
return new Trade_1.Trade({
exchange: this.name,
base: market.base,
quote: market.quote,
tradeId,
side: side,
unix,
price: datum[0],
amount: datum[1],
rawUnix: datum[2],
});
}
/**
Refer to https://www.kraken.com/en-us/features/websocket-api#message-ohlc
*/
_constructCandle(msg) {
/**
[
6,
[ '1571080988.157759',
'1571081040.000000',
'8352.00000',
'8352.00000',
'8352.00000',
'8352.00000',
'8352.00000',
'0.01322211',
1
],
'ohlc-1',
'XBT/USD'
]
*/
const datum = msg[1];
const ms = parseInt(datum[1]) * 1000;
return new Candle_1.Candle(ms, datum[2], datum[3], datum[4], datum[5], datum[7]);
}
/**
* Refer to https://www.kraken.com/en-us/features/websocket-api#message-book
* Values will look like:
* [
* 270,
* {"b":[["11260.50000","0.00000000","1596221402.104952"],["11228.70000","2.60111463","1596221103.546084","r"]],"c":"1281654047"},
* "book-100",
* "XBT/USD"
* ]
*
* [
* 270,
* {"a":[["11277.30000","1.01949833","1596221402.163693"]]},
* {"b":[["11275.30000","0.17300000","1596221402.163680"]],"c":"1036980588"},
* "book-100",
* "XBT/USD"
* ]
*/
_constructLevel2Update(msg, market) {
const asks = [];
const bids = [];
let checksum;
// Because some messages will send more than a single result object
// we need to iterate the results blocks starting at position 1 and
// look for ask, bid, and checksum data.
for (let i = 1; i < msg.length; i++) {
// Process ask updates
if (msg[i].a) {
for (const [price, size, timestamp] of msg[i].a) {
asks.push(new Level2Point_1.Level2Point(price, size, undefined, undefined, timestamp));
}
}
// Process bid updates
if (msg[i].b) {
for (const [price, size, timestamp] of msg[i].b) {
bids.push(new Level2Point_1.Level2Point(price, size, undefined, undefined, timestamp));
}
}
// Process checksum
if (msg[i].c) {
checksum = msg[i].c;
}
}
// Calculates the newest timestamp value to maintain backwards
// compatibility with the update timestamp
const timestamp = Math.max(...asks.concat(bids).map(p => parseFloat(p.timestamp)));
return new Level2Update_1.Level2Update({
exchange: this.name,
base: market.base,
quote: market.quote,
timestampMs: parseInt((timestamp * 1000)),
asks,
bids,
checksum,
});
}
/**
* Refer to https://www.kraken.com/en-us/features/websocket-api#message-book
*
* {
* as: [
* [ '3361.30000', '25.57512297', '1551438550.367822' ],
* [ '3363.80000', '15.81228000', '1551438539.149525' ]
* ],
* bs: [
* [ '3361.20000', '0.07234101', '1551438547.041624' ],
* [ '3357.60000', '1.75000000', '1551438516.825218' ]
* ]
* }
*/
_constructLevel2Snapshot(datum, market) {
// Process asks
const as = datum.as || [];
const asks = [];
for (const [price, size, timestamp] of as) {
asks.push(new Level2Point_1.Level2Point(price, size, undefined, undefined, timestamp));
}
// Process bids
const bs = datum.bs || [];
const bids = [];
for (const [price, size, timestamp] of bs) {
bids.push(new Level2Point_1.Level2Point(price, size, undefined, undefined, timestamp));
}
// Calculates the newest timestamp value to maintain backwards
// compatibility with the update timestamp
const timestamp = Math.max(...asks.concat(bids).map(p => parseFloat(p.timestamp)));
return new Level2Snapshots_1.Level2Snapshot({
exchange: this.name,
base: market.base,
quote: market.quote,
timestampMs: parseInt((timestamp * 1000)),
asks,
bids,
});
}
/**
Since Kraken doesn't send a trade id, we need to come up with a way
to generate one on our own. The REST API include the last trade id
which gives us the clue that it is the second timestamp + 9 sub-second
digits.
The WS will provide timestamps with up to 6 decimals of precision.
The REST API only has timestamps with 4 decimal of precision.
To maintain consistency, we're going to use the following formula:
<integer part of unix timestamp> +
<first 4 digits of fractional part of unix timestamp> +
00000
We're using the ROUND_HALF_UP method. From testing, this resulted
in the best rounding results. Ids are in picoseconds, the websocket
is broadcast in microsecond, and the REST results are truncated to
4 decimals.
This mean it is impossible to determine the rounding algorithm or
the proper rounding to go from 6 to 4 decimals as the 6 decimals
are being rounded from 9 which causes issues as the half
point for 4 digit rounding
.222950 rounds up to .2230 if the pico_ms value is > .222295000
.222950 rounds down to .2229 if the pico_ms value is < .222295000
Consumer code will need to account for collisions and id mismatch.
*/
_createTradeId(unix) {
const roundMode = decimal_js_1.default.ROUND_HALF_UP;
const [integer, frac] = unix.split(".");
const fracResult = new decimal_js_1.default("0." + frac)
.toDecimalPlaces(4, roundMode)
.toFixed(4)
.split(".")[1];
return integer + fracResult + "00000";
}
}
exports.KrakenClient = KrakenClient;
/**
* Maps the candle period from CCXWS to those required by the subscription mechanism
* as defined in https://www.kraken.com/en-us/features/websocket-api#message-subscribe
* @paramp
*/
function getCandlePeriod(p) {
switch (p) {
case CandlePeriod_1.CandlePeriod._1m:
return 1;
case CandlePeriod_1.CandlePeriod._5m:
return 5;
case CandlePeriod_1.CandlePeriod._15m:
return 15;
case CandlePeriod_1.CandlePeriod._30m:
return 30;
case CandlePeriod_1.CandlePeriod._1h:
return 60;
case CandlePeriod_1.CandlePeriod._4h:
return 240;
case CandlePeriod_1.CandlePeriod._1d:
return 1440;
case CandlePeriod_1.CandlePeriod._1w:
return 10080;
case CandlePeriod_1.CandlePeriod._2w:
return 21600;
}
}
//# sourceMappingURL=KrakenClient.js.map