spot v3 ws support with tests

This commit is contained in:
tiagosiebler
2022-09-16 00:16:44 +01:00
parent f61e79934d
commit 7902430a17
7 changed files with 258 additions and 38 deletions

View File

@@ -1,6 +1,7 @@
import { RestClientOptions, WS_KEY_MAP } from '../util';
export type APIMarket = 'inverse' | 'linear' | 'spot' | 'spotV3'; //| 'v3';
/** For spot markets, spotV3 is recommended */
export type APIMarket = 'inverse' | 'linear' | 'spot' | 'spotv3'; //| 'v3';
// Same as inverse futures
export type WsPublicInverseTopic =

View File

@@ -47,7 +47,7 @@ export const WS_BASE_URL_MAP: Record<
testnet: 'wss://stream-testnet.bybit.com/spot/ws',
},
},
spotV3: {
spotv3: {
public: {
livenet: 'wss://stream.bybit.com/spot/public/v3',
testnet: 'wss://stream-testnet.bybit.com/spot/public/v3',
@@ -69,6 +69,8 @@ export const WS_KEY_MAP = {
spotV3Public: 'spotV3Public',
} as const;
export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [WS_KEY_MAP.spotV3Private];
export const PUBLIC_WS_KEYS = [
WS_KEY_MAP.linearPublic,
WS_KEY_MAP.spotPublic,
@@ -115,3 +117,8 @@ export function getSpotWsKeyForTopic(
}
return WS_KEY_MAP.spotPublic;
}
export const WS_ERROR_ENUM = {
NOT_AUTHENTICATED_SPOT_V3: '-1004',
BAD_API_KEY_SPOT_V3: '10003',
};

View File

@@ -26,6 +26,7 @@ import {
getSpotWsKeyForTopic,
WsConnectionStateEnum,
PUBLIC_WS_KEYS,
WS_AUTH_ON_CONNECT_KEYS,
WS_KEY_MAP,
DefaultLogger,
WS_BASE_URL_MAP,
@@ -136,7 +137,7 @@ export class WebsocketClient extends EventEmitter {
this.connectPublic();
break;
}
case 'spotV3': {
case 'spotv3': {
this.restClient = new SpotClientV3(
undefined,
undefined,
@@ -221,7 +222,7 @@ export class WebsocketClient extends EventEmitter {
this.connect(WS_KEY_MAP.spotPrivate),
];
}
case 'spotV3': {
case 'spotv3': {
return [
this.connect(WS_KEY_MAP.spotV3Public),
this.connect(WS_KEY_MAP.spotV3Private),
@@ -244,7 +245,7 @@ export class WebsocketClient extends EventEmitter {
case 'spot': {
return this.connect(WS_KEY_MAP.spotPublic);
}
case 'spotV3': {
case 'spotv3': {
return this.connect(WS_KEY_MAP.spotV3Public);
}
default: {
@@ -267,7 +268,7 @@ export class WebsocketClient extends EventEmitter {
case 'spot': {
return this.connect(WS_KEY_MAP.spotPrivate);
}
case 'spotV3': {
case 'spotv3': {
return this.connect(WS_KEY_MAP.spotV3Private);
}
default: {
@@ -354,12 +355,49 @@ export class WebsocketClient extends EventEmitter {
return '';
}
try {
const { signature, expiresAt } = await this.getWsAuthSignature(wsKey);
const authParams = {
api_key: this.options.key,
expires: expiresAt,
signature,
};
return '?' + serializeParams(authParams);
} catch (e) {
this.logger.error(e, { ...loggerCategory, wsKey });
return '';
}
}
private async sendAuthRequest(wsKey: WsKey): Promise<void> {
try {
const { signature, expiresAt } = await this.getWsAuthSignature(wsKey);
const request = {
op: 'auth',
args: [this.options.key, expiresAt, signature],
req_id: `${wsKey}-auth`,
};
return this.tryWsSend(wsKey, JSON.stringify(request));
} catch (e) {
this.logger.error(e, { ...loggerCategory, wsKey });
}
}
private async getWsAuthSignature(
wsKey: WsKey
): Promise<{ expiresAt: number; signature: string }> {
const { key, secret } = this.options;
if (!key || !secret) {
this.logger.warning(
'Cannot authenticate websocket, either api or private keys missing.',
{ ...loggerCategory, wsKey }
);
return '';
throw new Error(`Cannot auth - missing api or secret in config`);
}
this.logger.debug("Getting auth'd request params", {
@@ -378,13 +416,10 @@ export class WebsocketClient extends EventEmitter {
secret
);
const authParams = {
api_key: this.options.key,
expires: signatureExpiresAt,
return {
expiresAt: signatureExpiresAt,
signature,
};
return '?' + serializeParams(authParams);
}
private reconnectWithDelay(wsKey: WsKey, connectionDelayMs: number) {
@@ -451,6 +486,7 @@ export class WebsocketClient extends EventEmitter {
return;
}
const wsMessage = JSON.stringify({
req_id: topics.join(','),
op: 'subscribe',
args: topics,
});
@@ -518,7 +554,7 @@ export class WebsocketClient extends EventEmitter {
return ws;
}
private onWsOpen(event, wsKey: WsKey) {
private async onWsOpen(event, wsKey: WsKey) {
if (
this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING)
) {
@@ -538,8 +574,14 @@ export class WebsocketClient extends EventEmitter {
this.setWsState(wsKey, WsConnectionStateEnum.CONNECTED);
// TODO: persistence not working yet for spot topics
if (wsKey !== 'spotPublic' && wsKey !== 'spotPrivate') {
// Some websockets require an auth packet to be sent after opening the connection
if (WS_AUTH_ON_CONNECT_KEYS.includes(wsKey)) {
this.logger.info(`Sending auth request...`);
await this.sendAuthRequest(wsKey);
}
// TODO: persistence not working yet for spot v1 topics
if (wsKey !== WS_KEY_MAP.spotPublic && wsKey !== WS_KEY_MAP.spotPrivate) {
this.requestSubscribeTopics(wsKey, [...this.wsStore.getTopics(wsKey)]);
}
@@ -554,6 +596,8 @@ export class WebsocketClient extends EventEmitter {
// any message can clear the pong timer - wouldn't get a message if the ws dropped
this.clearPongTimer(wsKey);
// this.logger.silly('Received event', { ...this.logger, wsKey, event });
const msg = JSON.parse((event && event.data) || event);
if (msg['success'] || msg?.pong) {
if (isWsPong(msg)) {
@@ -564,11 +608,20 @@ export class WebsocketClient extends EventEmitter {
return;
}
if (msg.topic) {
if (msg?.topic) {
return this.emit('update', msg);
}
this.logger.warning('Got unhandled ws message', {
if (
// spot v1
msg?.code ||
// spot v3
msg?.type === 'error'
) {
return this.emit('error', msg);
}
this.logger.warning('Unhandled/unrecognised ws event message', {
...loggerCategory,
message: msg,
event,
@@ -639,10 +692,10 @@ export class WebsocketClient extends EventEmitter {
return WS_BASE_URL_MAP.spot.private[networkKey];
}
case WS_KEY_MAP.spotV3Public: {
return WS_BASE_URL_MAP.spot.public[networkKey];
return WS_BASE_URL_MAP.spotv3.public[networkKey];
}
case WS_KEY_MAP.spotV3Private: {
return WS_BASE_URL_MAP.spot.private[networkKey];
return WS_BASE_URL_MAP.spotv3.private[networkKey];
}
case WS_KEY_MAP.inverse: {
// private and public are on the same WS connection
@@ -669,7 +722,7 @@ export class WebsocketClient extends EventEmitter {
case 'spot': {
return getSpotWsKeyForTopic(topic, 'v1');
}
case 'spotV3': {
case 'spotv3': {
return getSpotWsKeyForTopic(topic, 'v3');
}
default: {
@@ -740,7 +793,7 @@ export class WebsocketClient extends EventEmitter {
});
}
// TODO: persistance for subbed topics. Look at ftx-api implementation.
/** @deprecated use "market: 'spotv3" client */
public subscribePublicSpotTrades(symbol: string, binary?: boolean) {
if (!this.isSpot()) {
throw this.wrongMarketError('spot');
@@ -759,6 +812,7 @@ export class WebsocketClient extends EventEmitter {
);
}
/** @deprecated use "market: 'spotv3" client */
public subscribePublicSpotTradingPair(symbol: string, binary?: boolean) {
if (!this.isSpot()) {
throw this.wrongMarketError('spot');
@@ -777,6 +831,7 @@ export class WebsocketClient extends EventEmitter {
);
}
/** @deprecated use "market: 'spotv3" client */
public subscribePublicSpotV1Kline(
symbol: string,
candleSize: KlineInterval,
@@ -803,6 +858,7 @@ export class WebsocketClient extends EventEmitter {
//ws.send('{"symbol":"BTCUSDT","topic":"mergedDepth","event":"sub","params":{"binary":false,"dumpScale":1}}');
//ws.send('{"symbol":"BTCUSDT","topic":"diffDepth","event":"sub","params":{"binary":false}}');
/** @deprecated use "market: 'spotv3" client */
public subscribePublicSpotOrderbook(
symbol: string,
depth: 'full' | 'merge' | 'delta',

View File

@@ -0,0 +1,124 @@
import {
WebsocketClient,
WSClientConfigurableOptions,
WS_ERROR_ENUM,
WS_KEY_MAP,
} from '../../src';
import {
silentLogger,
waitForSocketEvent,
WS_OPEN_EVENT_PARTIAL,
} from '../ws.util';
describe('Private Spot V3 Websocket Client', () => {
const API_KEY = process.env.API_KEY_COM;
const API_SECRET = process.env.API_SECRET_COM;
const wsClientOptions: WSClientConfigurableOptions = {
market: 'spotv3',
key: API_KEY,
secret: API_SECRET,
};
const wsTopic = `outboundAccountInfo`;
describe('with invalid credentials', () => {
it('should reject private subscribe if keys/signature are incorrect', async () => {
const badClient = new WebsocketClient(
{
...wsClientOptions,
key: 'bad',
secret: 'bad',
},
silentLogger
);
// const wsOpenPromise = waitForSocketEvent(badClient, 'open');
const wsResponsePromise = waitForSocketEvent(badClient, 'response');
// const wsUpdatePromise = waitForSocketEvent(wsClient, 'update');
badClient.connectPrivate();
badClient.subscribe(wsTopic);
expect(wsResponsePromise).rejects.toMatchObject({
ret_code: WS_ERROR_ENUM.BAD_API_KEY_SPOT_V3,
ret_msg: expect.any(String),
type: 'error',
});
try {
await Promise.all([wsResponsePromise]);
} catch (e) {
// console.error()
}
badClient.closeAll();
});
});
describe('with valid API credentails', () => {
let wsClient: WebsocketClient;
it('should have api credentials to test with', () => {
expect(API_KEY).toStrictEqual(expect.any(String));
expect(API_SECRET).toStrictEqual(expect.any(String));
});
beforeAll(() => {
wsClient = new WebsocketClient(wsClientOptions, silentLogger);
wsClient.connectPrivate();
// logAllEvents(wsClient);
});
afterAll(() => {
wsClient.closeAll();
});
it('should open a private ws connection', async () => {
const wsOpenPromise = waitForSocketEvent(wsClient, 'open');
const wsResponsePromise = waitForSocketEvent(wsClient, 'response');
expect(wsOpenPromise).resolves.toMatchObject({
event: WS_OPEN_EVENT_PARTIAL,
wsKey: WS_KEY_MAP.spotV3Private,
});
try {
await Promise.all([wsOpenPromise]);
} catch (e) {
expect(e).toBeFalsy();
}
try {
expect(await wsResponsePromise).toMatchObject({
op: 'auth',
success: true,
req_id: `${WS_KEY_MAP.spotV3Private}-auth`,
});
} catch (e) {
console.error(`Wait for "${wsTopic}" event exception: `, e);
expect(e).toBeFalsy();
}
});
it('should subscribe to private outboundAccountInfo events', async () => {
const wsResponsePromise = waitForSocketEvent(wsClient, 'response');
// expect(wsUpdatePromise).resolves.toStrictEqual('');
wsClient.subscribe(wsTopic);
try {
expect(await wsResponsePromise).toMatchObject({
op: 'subscribe',
success: true,
ret_msg: '',
req_id: wsTopic,
});
} catch (e) {
console.error(
`Wait for "${wsTopic}" subscription response exception: `,
e
);
expect(e).toBeFalsy();
}
});
});
});

View File

@@ -6,6 +6,7 @@ import {
import {
logAllEvents,
silentLogger,
fullLogger,
waitForSocketEvent,
WS_OPEN_EVENT_PARTIAL,
} from '../ws.util';
@@ -20,7 +21,7 @@ describe('Public Spot V1 Websocket Client', () => {
beforeAll(() => {
wsClient = new WebsocketClient(wsClientOptions, silentLogger);
wsClient.connectPublic();
// logAllEvents(wsClient);
logAllEvents(wsClient);
});
afterAll(() => {

View File

@@ -6,6 +6,7 @@ import {
import {
logAllEvents,
silentLogger,
fullLogger,
waitForSocketEvent,
WS_OPEN_EVENT_PARTIAL,
} from '../ws.util';
@@ -14,7 +15,7 @@ describe('Public Spot V3 Websocket Client', () => {
let wsClient: WebsocketClient;
const wsClientOptions: WSClientConfigurableOptions = {
market: 'spotV3',
market: 'spotv3',
};
beforeAll(() => {
@@ -42,15 +43,27 @@ describe('Public Spot V3 Websocket Client', () => {
const wsResponsePromise = waitForSocketEvent(wsClient, 'response');
const wsUpdatePromise = waitForSocketEvent(wsClient, 'update');
const wsTopic = 'orderbook.40.BTCUSDT';
const symbol = 'BTCUSDT';
const wsTopic = `orderbook.40.${symbol}`;
expect(wsResponsePromise).resolves.toMatchObject({
request: {
args: [wsTopic],
op: 'subscribe',
},
op: 'subscribe',
success: true,
ret_msg: 'subscribe',
req_id: wsTopic,
});
expect(wsUpdatePromise).resolves.toMatchObject({
data: {
a: expect.any(Array),
b: expect.any(Array),
s: symbol,
t: expect.any(Number),
},
topic: wsTopic,
ts: expect.any(Number),
type: 'delta',
});
expect(wsUpdatePromise).resolves.toStrictEqual('');
wsClient.subscribe(wsTopic);

View File

@@ -5,8 +5,17 @@ export const silentLogger = {
debug: () => {},
notice: () => {},
info: () => {},
warning: () => {},
error: () => {},
warning: (...params) => console.warn('warning', ...params),
error: (...params) => console.error('error', ...params),
};
export const fullLogger = {
silly: (...params) => console.log('silly', ...params),
debug: (...params) => console.log('debug', ...params),
notice: (...params) => console.log('notice', ...params),
info: (...params) => console.info('info', ...params),
warning: (...params) => console.warn('warning', ...params),
error: (...params) => console.error('error', ...params),
};
export const WS_OPEN_EVENT_PARTIAL = {
@@ -26,20 +35,29 @@ export function waitForSocketEvent(
);
}, timeoutMs);
function cleanup() {
clearTimeout(timeout);
resolvedOnce = true;
wsClient.removeListener(event, (e) => resolver(e));
wsClient.removeListener('error', (e) => rejector(e));
}
let resolvedOnce = false;
wsClient.on(event, (event) => {
clearTimeout(timeout);
function resolver(event) {
resolve(event);
resolvedOnce = true;
});
cleanup();
}
wsClient.on('error', (event) => {
clearTimeout(timeout);
function rejector(event) {
if (!resolvedOnce) {
reject(event);
}
});
cleanup();
}
wsClient.on(event, (e) => resolver(e));
wsClient.on('error', (e) => rejector(e));
// if (event !== 'close') {
// wsClient.on('close', (event) => {