v3.5.1: feat() add support for V5 public & private websockets

This commit is contained in:
tiagosiebler
2023-02-24 15:59:05 +00:00
parent fad12f460f
commit 0a1cc4ed2b
6 changed files with 358 additions and 113 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "bybit-api",
"version": "3.5.0-beta.0",
"version": "3.5.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "bybit-api",
"version": "3.5.0-beta.0",
"version": "3.5.1",
"license": "MIT",
"dependencies": {
"axios": "^0.21.0",

View File

@@ -1,6 +1,6 @@
{
"name": "bybit-api",
"version": "3.5.0",
"version": "3.5.1",
"description": "Complete & robust Node.js SDK for Bybit's REST APIs and WebSockets, with TypeScript & strong end to end tests.",
"main": "lib/index.js",
"types": "lib/index.d.ts",

View File

@@ -1,6 +1,7 @@
import { ContractClient } from '../contract-client';
import { InverseClient } from '../inverse-client';
import { LinearClient } from '../linear-client';
import { RestClientV5 } from '../rest-client-v5';
import { SpotClient } from '../spot-client';
import { SpotClientV3 } from '../spot-client-v3';
import { UnifiedMarginClient } from '../unified-margin-client';
@@ -15,7 +16,8 @@ export type RESTClient =
| USDCOptionClient
| USDCPerpetualClient
| UnifiedMarginClient
| ContractClient;
| ContractClient
| RestClientV5;
export type numberInString = string;

View File

@@ -11,7 +11,8 @@ export type APIMarket =
| 'unifiedPerp'
| 'unifiedOption'
| 'contractUSDT'
| 'contractInverse';
| 'contractInverse'
| 'v5';
// Same as inverse futures
export type WsPublicInverseTopic =

View File

@@ -1,4 +1,4 @@
import { APIMarket, WsKey } from '../types';
import { APIMarket, CategoryV5, WsKey } from '../types';
interface NetworkMapV3 {
livenet: string;
@@ -9,10 +9,28 @@ interface NetworkMapV3 {
type PublicPrivateNetwork = 'public' | 'private';
/**
* The following WS keys are logical.
*
* They're not directly used as a market. They usually have one private endpoint but many public ones,
* so they need a bit of extra handling for seamless messaging between endpoints.
*
* For the unified keys, the "split" happens using the symbol. Symbols suffixed with USDT are obviously USDT topics.
* For the v5 endpoints, the subscribe/unsubscribe call must specify the category the subscription should route to.
*/
type PublicOnlyWsKeys =
| 'unifiedPerpUSDT'
| 'unifiedPerpUSDC'
| 'v5SpotPublic'
| 'v5LinearPublic'
| 'v5InversePublic'
| 'v5OptionPublic';
export const WS_BASE_URL_MAP: Record<
APIMarket | 'unifiedPerpUSDT' | 'unifiedPerpUSDC',
APIMarket,
Record<PublicPrivateNetwork, NetworkMapV3>
> = {
> &
Record<PublicOnlyWsKeys, Record<'public', NetworkMapV3>> = {
inverse: {
public: {
livenet: 'wss://stream.bybit.com/realtime',
@@ -106,20 +124,12 @@ export const WS_BASE_URL_MAP: Record<
livenet: 'wss://stream.bybit.com/contract/usdt/public/v3',
testnet: 'wss://stream-testnet.bybit.com/contract/usdt/public/v3',
},
private: {
livenet: 'useUnifiedEndpoint',
testnet: 'useUnifiedEndpoint',
},
},
unifiedPerpUSDC: {
public: {
livenet: 'wss://stream.bybit.com/contract/usdc/public/v3',
testnet: 'wss://stream-testnet.bybit.com/contract/usdc/public/v3',
},
private: {
livenet: 'useUnifiedEndpoint',
testnet: 'useUnifiedEndpoint',
},
},
contractUSDT: {
public: {
@@ -141,6 +151,40 @@ export const WS_BASE_URL_MAP: Record<
testnet: 'wss://stream-testnet.bybit.com/contract/private/v3',
},
},
v5: {
public: {
livenet: 'public topics are routed internally via the public wskeys',
testnet: 'public topics are routed internally via the public wskeys',
},
private: {
livenet: 'wss://stream.bybit.com/v5/private',
testnet: 'wss://stream-testnet.bybit.com/v5/private',
},
},
v5SpotPublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/spot',
testnet: 'wss://stream-testnet.bybit.com/v5/public/spot',
},
},
v5LinearPublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/linear',
testnet: 'wss://stream-testnet.bybit.com/v5/public/linear',
},
},
v5InversePublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/inverse',
testnet: 'wss://stream-testnet.bybit.com/v5/public/inverse',
},
},
v5OptionPublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/option',
testnet: 'wss://stream-testnet.bybit.com/v5/public/option',
},
},
};
export const WS_KEY_MAP = {
@@ -163,6 +207,11 @@ export const WS_KEY_MAP = {
contractUSDTPrivate: 'contractUSDTPrivate',
contractInversePublic: 'contractInversePublic',
contractInversePrivate: 'contractInversePrivate',
v5SpotPublic: 'v5SpotPublic',
v5LinearPublic: 'v5LinearPublic',
v5InversePublic: 'v5InversePublic',
v5OptionPublic: 'v5OptionPublic',
v5Private: 'v5Private',
} as const;
export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [
@@ -172,6 +221,7 @@ export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [
WS_KEY_MAP.unifiedPrivate,
WS_KEY_MAP.contractUSDTPrivate,
WS_KEY_MAP.contractInversePrivate,
WS_KEY_MAP.v5Private,
];
export const PUBLIC_WS_KEYS = [
@@ -185,15 +235,15 @@ export const PUBLIC_WS_KEYS = [
WS_KEY_MAP.unifiedPerpUSDCPublic,
WS_KEY_MAP.contractUSDTPublic,
WS_KEY_MAP.contractInversePublic,
WS_KEY_MAP.v5SpotPublic,
WS_KEY_MAP.v5LinearPublic,
WS_KEY_MAP.v5InversePublic,
WS_KEY_MAP.v5OptionPublic,
] as string[];
/** Used to automatically determine if a sub request should be to the public or private ws (when there's two) */
const PRIVATE_TOPICS = [
'position',
'execution',
'order',
'stop_order',
'wallet',
'outboundAccountInfo',
'executionReport',
'ticketInfo',
@@ -226,12 +276,23 @@ const PRIVATE_TOPICS = [
'user.execution.contractAccount',
'user.order.contractAccount',
'user.wallet.contractAccount',
// v5
'position',
'execution',
'order',
'wallet',
'greeks',
];
export function isPrivateWsTopic(topic: string): boolean {
return PRIVATE_TOPICS.includes(topic);
}
export function getWsKeyForTopic(
market: APIMarket,
topic: string,
isPrivate?: boolean
isPrivate?: boolean,
category?: CategoryV5
): WsKey {
const isPrivateTopic = isPrivate === true || PRIVATE_TOPICS.includes(topic);
switch (market) {
@@ -297,12 +358,138 @@ export function getWsKeyForTopic(
? WS_KEY_MAP.contractUSDTPrivate
: WS_KEY_MAP.contractUSDTPublic;
}
case 'v5': {
if (isPrivateTopic) {
return WS_KEY_MAP.v5Private;
}
switch (category) {
case 'spot': {
return WS_KEY_MAP.v5SpotPublic;
}
case 'linear': {
return WS_KEY_MAP.v5LinearPublic;
}
case 'inverse': {
return WS_KEY_MAP.v5InversePublic;
}
case 'option': {
return WS_KEY_MAP.v5OptionPublic;
}
case undefined: {
throw new Error('Category cannot be undefined');
}
default: {
throw neverGuard(
category,
'getWsKeyForTopic(v5): Unhandled v5 category'
);
}
}
// TODO: simple way to manage many public api groups in one api market?
return isPrivateTopic ? WS_KEY_MAP.v5Private : WS_KEY_MAP.v5Private;
}
default: {
throw neverGuard(market, 'getWsKeyForTopic(): Unhandled market');
}
}
}
export function getWsUrl(
wsKey: WsKey,
wsUrl: string | undefined,
isTestnet: boolean
): string {
if (wsUrl) {
return wsUrl;
}
const networkKey = isTestnet ? 'testnet' : 'livenet';
switch (wsKey) {
case WS_KEY_MAP.linearPublic: {
return WS_BASE_URL_MAP.linear.public[networkKey];
}
case WS_KEY_MAP.linearPrivate: {
return WS_BASE_URL_MAP.linear.private[networkKey];
}
case WS_KEY_MAP.spotPublic: {
return WS_BASE_URL_MAP.spot.public[networkKey];
}
case WS_KEY_MAP.spotPrivate: {
return WS_BASE_URL_MAP.spot.private[networkKey];
}
case WS_KEY_MAP.spotV3Public: {
return WS_BASE_URL_MAP.spotv3.public[networkKey];
}
case WS_KEY_MAP.spotV3Private: {
return WS_BASE_URL_MAP.spotv3.private[networkKey];
}
case WS_KEY_MAP.inverse: {
// private and public are on the same WS connection
return WS_BASE_URL_MAP.inverse.public[networkKey];
}
case WS_KEY_MAP.usdcOptionPublic: {
return WS_BASE_URL_MAP.usdcOption.public[networkKey];
}
case WS_KEY_MAP.usdcOptionPrivate: {
return WS_BASE_URL_MAP.usdcOption.private[networkKey];
}
case WS_KEY_MAP.usdcPerpPublic: {
return WS_BASE_URL_MAP.usdcPerp.public[networkKey];
}
case WS_KEY_MAP.usdcPerpPrivate: {
return WS_BASE_URL_MAP.usdcPerp.private[networkKey];
}
case WS_KEY_MAP.unifiedOptionPublic: {
return WS_BASE_URL_MAP.unifiedOption.public[networkKey];
}
case WS_KEY_MAP.unifiedPerpUSDTPublic: {
return WS_BASE_URL_MAP.unifiedPerpUSDT.public[networkKey];
}
case WS_KEY_MAP.unifiedPerpUSDCPublic: {
return WS_BASE_URL_MAP.unifiedPerpUSDC.public[networkKey];
}
case WS_KEY_MAP.unifiedPrivate: {
return WS_BASE_URL_MAP.unifiedPerp.private[networkKey];
}
case WS_KEY_MAP.contractInversePrivate: {
return WS_BASE_URL_MAP.contractInverse.private[networkKey];
}
case WS_KEY_MAP.contractInversePublic: {
return WS_BASE_URL_MAP.contractInverse.public[networkKey];
}
case WS_KEY_MAP.contractUSDTPrivate: {
return WS_BASE_URL_MAP.contractUSDT.private[networkKey];
}
case WS_KEY_MAP.contractUSDTPublic: {
return WS_BASE_URL_MAP.contractUSDT.public[networkKey];
}
case WS_KEY_MAP.v5Private: {
return WS_BASE_URL_MAP.v5.private[networkKey];
}
case WS_KEY_MAP.v5SpotPublic: {
return WS_BASE_URL_MAP.v5SpotPublic.public[networkKey];
}
case WS_KEY_MAP.v5LinearPublic: {
return WS_BASE_URL_MAP.v5LinearPublic.public[networkKey];
}
case WS_KEY_MAP.v5InversePublic: {
return WS_BASE_URL_MAP.v5InversePublic.public[networkKey];
}
case WS_KEY_MAP.v5OptionPublic: {
return WS_BASE_URL_MAP.v5OptionPublic.public[networkKey];
}
default: {
this.logger.error('getWsUrl(): Unhandled wsKey: ', {
category: 'bybit-ws',
wsKey,
});
throw neverGuard(wsKey, 'getWsUrl(): Unhandled wsKey');
}
}
}
export function getMaxTopicsPerSubscribeEvent(
market: APIMarket
): number | null {
@@ -315,7 +502,8 @@ export function getMaxTopicsPerSubscribeEvent(
case 'unifiedPerp':
case 'spot':
case 'contractInverse':
case 'contractUSDT': {
case 'contractUSDT':
case 'v5': {
return null;
}
case 'spotv3': {

View File

@@ -17,6 +17,7 @@ import WsStore from './util/WsStore';
import {
APIMarket,
CategoryV5,
KlineInterval,
RESTClient,
WSClientConfigurableOptions,
@@ -34,10 +35,13 @@ import {
WsConnectionStateEnum,
getMaxTopicsPerSubscribeEvent,
getWsKeyForTopic,
getWsUrl,
isPrivateWsTopic,
isWsPong,
neverGuard,
serializeParams,
} from './util';
import { RestClientV5 } from './rest-client-v5';
const loggerCategory = { category: 'bybit-ws' };
@@ -119,13 +123,82 @@ export class WebsocketClient extends EventEmitter {
this.on('error', () => {});
}
/** Get the WsStore that tracks websockets & topics */
public getWsStore(): WsStore {
return this.wsStore;
}
public isTestnet(): boolean {
return this.options.testnet === true;
}
/**
* Subscribe to topics & track/persist them. They will be automatically resubscribed to if the connection drops/reconnects.
* @param wsTopics topic or list of topics
* Subscribe to V5 topics & track/persist them.
* @param wsTopics - topic or list of topics
* @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics.
* @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet)
*/
public subscribeV5(
wsTopics: WsTopic[] | WsTopic[],
category: CategoryV5,
isPrivateTopic?: boolean
) {
const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics];
topics.forEach((topic) => {
const wsKey = getWsKeyForTopic(
this.options.market,
topic,
isPrivateTopic,
category
);
// Persist topic for reconnects
this.wsStore.addTopic(wsKey, topic);
// if connected, send subscription request
if (
this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED)
) {
return this.requestSubscribeTopics(wsKey, [topic]);
}
// start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect
if (
!this.wsStore.isConnectionState(
wsKey,
WsConnectionStateEnum.CONNECTING
) &&
!this.wsStore.isConnectionState(
wsKey,
WsConnectionStateEnum.RECONNECTING
)
) {
return this.connect(wsKey);
}
});
}
/**
* Subscribe to V1-V3 topics & track/persist them.
*
* Note: for public V5 topics use the `subscribeV5()` method.
*
* Topics will be automatically resubscribed to if the connection resets/drops/reconnects.
* @param wsTopics - topic or list of topics
* @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet)
*/
public subscribe(wsTopics: WsTopic[] | WsTopic, isPrivateTopic?: boolean) {
const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics];
if (this.options.market === 'v5') {
topics.forEach((topic) => {
if (!isPrivateWsTopic(topic)) {
throw new Error(
'For public "v5" websocket topics, use the subscribeV5() method & provide the category parameter'
);
}
});
}
topics.forEach((topic) => {
const wsKey = getWsKeyForTopic(
@@ -161,12 +234,57 @@ export class WebsocketClient extends EventEmitter {
}
/**
* Unsubscribe from topics & remove them from memory. They won't be re-subscribed to if the connection reconnects.
* Unsubscribe from V5 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects.
* @param wsTopics - topic or list of topics
* @param category - the API category this topic is for (e.g. "linear"). The value is only important when connecting to public topics and will be ignored for private topics.
* @param isPrivateTopic - optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet)
*/
public unsubscribeV5(
wsTopics: WsTopic[] | WsTopic[],
category: CategoryV5,
isPrivateTopic?: boolean
) {
const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics];
topics.forEach((topic) => {
const wsKey = getWsKeyForTopic(
this.options.market,
topic,
isPrivateTopic,
category
);
// Remove topic from persistence for reconnects
this.wsStore.deleteTopic(wsKey, topic);
// unsubscribe request only necessary if active connection exists
if (
this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED)
) {
this.requestUnsubscribeTopics(wsKey, [topic]);
}
});
}
/**
* Unsubscribe from V1-V3 topics & remove them from memory. They won't be re-subscribed to if the connection reconnects.
*
* Note: For public V5 topics, use `unsubscribeV5()` instead!
*
* @param wsTopics topic or list of topics
* @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet)
*/
public unsubscribe(wsTopics: WsTopic[] | WsTopic, isPrivateTopic?: boolean) {
const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics];
if (this.options.market === 'v5') {
topics.forEach((topic) => {
if (!isPrivateWsTopic(topic)) {
throw new Error(
'For public "v5" websocket topics, use the unsubscribeV5() method & provide the category parameter'
);
}
});
}
topics.forEach((topic) => {
const wsKey = getWsKeyForTopic(
this.options.market,
@@ -251,6 +369,13 @@ export class WebsocketClient extends EventEmitter {
);
break;
}
case 'v5': {
this.restClient = new RestClientV5(
this.options.restOptions,
this.options.requestOptions
);
break;
}
default: {
throw neverGuard(
this.options.market,
@@ -260,15 +385,6 @@ export class WebsocketClient extends EventEmitter {
}
}
/** Get the WsStore that tracks websockets & topics */
public getWsStore(): WsStore {
return this.wsStore;
}
public isTestnet(): boolean {
return this.options.testnet === true;
}
public close(wsKey: WsKey, force?: boolean) {
this.logger.info('Closing connection', { ...loggerCategory, wsKey });
this.setWsState(wsKey, WsConnectionStateEnum.CLOSING);
@@ -310,6 +426,9 @@ export class WebsocketClient extends EventEmitter {
case 'contractInverse': {
return [...this.connectPublic(), this.connectPrivate()];
}
case 'v5': {
return [this.connectPrivate()];
}
default: {
throw neverGuard(this.options.market, 'connectAll(): Unhandled market');
}
@@ -349,6 +468,14 @@ export class WebsocketClient extends EventEmitter {
return [this.connect(WS_KEY_MAP.contractUSDTPublic)];
case 'contractInverse':
return [this.connect(WS_KEY_MAP.contractInversePublic)];
case 'v5': {
return [
this.connect(WS_KEY_MAP.v5SpotPublic),
this.connect(WS_KEY_MAP.v5LinearPublic),
this.connect(WS_KEY_MAP.v5InversePublic),
this.connect(WS_KEY_MAP.v5OptionPublic),
];
}
default: {
throw neverGuard(
this.options.market,
@@ -386,6 +513,9 @@ export class WebsocketClient extends EventEmitter {
return this.connect(WS_KEY_MAP.contractUSDTPrivate);
case 'contractInverse':
return this.connect(WS_KEY_MAP.contractInversePrivate);
case 'v5': {
return this.connect(WS_KEY_MAP.v5Private);
}
default: {
throw neverGuard(
this.options.market,
@@ -423,8 +553,8 @@ export class WebsocketClient extends EventEmitter {
}
const authParams = await this.getAuthParams(wsKey);
const url = this.getWsUrl(wsKey) + authParams;
const ws = this.connectToWsUrl(url, wsKey);
const url = getWsUrl(wsKey, this.options.wsUrl, this.isTestnet());
const ws = this.connectToWsUrl(url + authParams, wsKey);
return this.wsStore.setWs(wsKey, ws);
} catch (err) {
@@ -891,85 +1021,9 @@ export class WebsocketClient extends EventEmitter {
this.wsStore.setConnectionState(wsKey, state);
}
private getWsUrl(wsKey: WsKey): string {
if (this.options.wsUrl) {
return this.options.wsUrl;
}
const networkKey = this.isTestnet() ? 'testnet' : 'livenet';
switch (wsKey) {
case WS_KEY_MAP.linearPublic: {
return WS_BASE_URL_MAP.linear.public[networkKey];
}
case WS_KEY_MAP.linearPrivate: {
return WS_BASE_URL_MAP.linear.private[networkKey];
}
case WS_KEY_MAP.spotPublic: {
return WS_BASE_URL_MAP.spot.public[networkKey];
}
case WS_KEY_MAP.spotPrivate: {
return WS_BASE_URL_MAP.spot.private[networkKey];
}
case WS_KEY_MAP.spotV3Public: {
return WS_BASE_URL_MAP.spotv3.public[networkKey];
}
case WS_KEY_MAP.spotV3Private: {
return WS_BASE_URL_MAP.spotv3.private[networkKey];
}
case WS_KEY_MAP.inverse: {
// private and public are on the same WS connection
return WS_BASE_URL_MAP.inverse.public[networkKey];
}
case WS_KEY_MAP.usdcOptionPublic: {
return WS_BASE_URL_MAP.usdcOption.public[networkKey];
}
case WS_KEY_MAP.usdcOptionPrivate: {
return WS_BASE_URL_MAP.usdcOption.private[networkKey];
}
case WS_KEY_MAP.usdcPerpPublic: {
return WS_BASE_URL_MAP.usdcPerp.public[networkKey];
}
case WS_KEY_MAP.usdcPerpPrivate: {
return WS_BASE_URL_MAP.usdcPerp.private[networkKey];
}
case WS_KEY_MAP.unifiedOptionPublic: {
return WS_BASE_URL_MAP.unifiedOption.public[networkKey];
}
case WS_KEY_MAP.unifiedPerpUSDTPublic: {
return WS_BASE_URL_MAP.unifiedPerpUSDT.public[networkKey];
}
case WS_KEY_MAP.unifiedPerpUSDCPublic: {
return WS_BASE_URL_MAP.unifiedPerpUSDC.public[networkKey];
}
case WS_KEY_MAP.unifiedPrivate: {
return WS_BASE_URL_MAP.unifiedPerp.private[networkKey];
}
case WS_KEY_MAP.contractInversePrivate: {
return WS_BASE_URL_MAP.contractInverse.private[networkKey];
}
case WS_KEY_MAP.contractInversePublic: {
return WS_BASE_URL_MAP.contractInverse.public[networkKey];
}
case WS_KEY_MAP.contractUSDTPrivate: {
return WS_BASE_URL_MAP.contractUSDT.private[networkKey];
}
case WS_KEY_MAP.contractUSDTPublic: {
return WS_BASE_URL_MAP.contractUSDT.public[networkKey];
}
default: {
this.logger.error('getWsUrl(): Unhandled wsKey: ', {
...loggerCategory,
wsKey,
});
throw neverGuard(wsKey, 'getWsUrl(): Unhandled wsKey');
}
}
}
private wrongMarketError(market: APIMarket) {
return new Error(
`This WS client was instanced for the ${this.options.market} market. Make another WebsocketClient instance with "market: '${market}' to listen to spot topics`
`This WS client was instanced for the ${this.options.market} market. Make another WebsocketClient instance with "market: '${market}'" to listen to ${market} topics`
);
}