From d2ba5d3e0174e8a3829fe97bd0e6e2d542d3afc5 Mon Sep 17 00:00:00 2001 From: tiagosiebler Date: Fri, 16 Sep 2022 13:13:49 +0100 Subject: [PATCH] usdc options public ws test --- README.md | 2 +- examples/ws-public.ts | 16 ++- src/types/shared.ts | 4 +- src/types/websockets.ts | 2 +- src/util/requestUtils.ts | 20 ++-- src/util/websocket-util.ts | 136 +++++++++++++++++++------- src/websocket-client.ts | 145 +++++++++++++++------------- test/usdc/options/ws.public.test.ts | 79 +++++++++++++++ test/ws.util.ts | 30 ++++++ 9 files changed, 319 insertions(+), 115 deletions(-) create mode 100644 test/usdc/options/ws.public.test.ts diff --git a/README.md b/README.md index 07e213b..8641a9b 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ The WebsocketClient can be configured to a specific API group using the market p | Futures v2 - Inverse Futures | `market: 'inverse'` | The [inverse futures v2](https://bybit-exchange.github.io/docs/futuresV2/inverse_futures/#t-websocket) category uses the same market as inverse perps. | | Spot v3 | `market: 'spotv3'` | The [spot v3](https://bybit-exchange.github.io/docs/spot/v3/#t-websocket) category. | | Spot v1 | `market: 'spot'` | The older [spot v1](https://bybit-exchange.github.io/docs/spot/v1/#t-websocket) category. Use the `spotv3` market if possible, as the v1 category does not have automatic re-subscribe if reconnected. | -| Copy Trading | `market: 'linear'` | The [copy trading](https://bybit-exchange.github.io/docs/copy_trading/#t-websocket) category. Use the linear market to listen to private topics. | +| Copy Trading | `market: 'linear'` | The [copy trading](https://bybit-exchange.github.io/docs/copy_trading/#t-websocket) category. Use the linear market to listen to all copy trading topics. | | USDC Perps | TBC | The [USDC perps](https://bybit-exchange.github.io/docs/usdc/perpetual/#t-websocket) category. | | USDC Options | TBC | The [USDC options](https://bybit-exchange.github.io/docs/usdc/option/#t-websocket) category. | diff --git a/examples/ws-public.ts b/examples/ws-public.ts index 2aa1cd7..1313cd9 100644 --- a/examples/ws-public.ts +++ b/examples/ws-public.ts @@ -13,9 +13,10 @@ import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from '../src'; { // key: key, // secret: secret, - market: 'linear', + // market: 'linear', // market: 'inverse', // market: 'spot', + market: 'usdcOption', }, logger ); @@ -51,10 +52,15 @@ import { DefaultLogger, WS_KEY_MAP, WebsocketClient } from '../src'; // Linear wsClient.subscribe('trade.BTCUSDT'); - setTimeout(() => { - console.log('unsubscribing'); - wsClient.unsubscribe('trade.BTCUSDT'); - }, 5 * 1000); + // usdc options + wsClient.subscribe(`recenttrades.BTC`); + wsClient.subscribe(`recenttrades.ETH`); + wsClient.subscribe(`recenttrades.SOL`); + + // setTimeout(() => { + // console.log('unsubscribing'); + // wsClient.unsubscribe('trade.BTCUSDT'); + // }, 5 * 1000); // For spot, request public connection first then send required topics on 'open' // wsClient.connectPublic(); diff --git a/src/types/shared.ts b/src/types/shared.ts index 2f8dd65..77bd21f 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -2,12 +2,14 @@ import { InverseClient } from '../inverse-client'; import { LinearClient } from '../linear-client'; import { SpotClient } from '../spot-client'; import { SpotClientV3 } from '../spot-client-v3'; +import { USDCOptionClient } from '../usdc-option-client'; export type RESTClient = | InverseClient | LinearClient | SpotClient - | SpotClientV3; + | SpotClientV3 + | USDCOptionClient; export type numberInString = string; diff --git a/src/types/websockets.ts b/src/types/websockets.ts index b85ca02..67b86d4 100644 --- a/src/types/websockets.ts +++ b/src/types/websockets.ts @@ -1,7 +1,7 @@ import { RestClientOptions, WS_KEY_MAP } from '../util'; /** For spot markets, spotV3 is recommended */ -export type APIMarket = 'inverse' | 'linear' | 'spot' | 'spotv3'; //| 'v3'; +export type APIMarket = 'inverse' | 'linear' | 'spot' | 'spotv3' | 'usdcOption'; // Same as inverse futures export type WsPublicInverseTopic = diff --git a/src/util/requestUtils.ts b/src/util/requestUtils.ts index d3d6eb7..a17f752 100644 --- a/src/util/requestUtils.ts +++ b/src/util/requestUtils.ts @@ -61,15 +61,23 @@ export function getRestBaseUrl( return exchangeBaseUrls.testnet; } -export function isWsPong(response: any) { - if (response.pong || response.ping) { +export function isWsPong(msg: any): boolean { + if (!msg) { + return false; + } + if (msg.pong || msg.ping) { return true; } + + if (msg['op'] === 'pong') { + return true; + } + return ( - response.request && - response.request.op === 'ping' && - response.ret_msg === 'pong' && - response.success === true + msg.request && + msg.request.op === 'ping' && + msg.ret_msg === 'pong' && + msg.success === true ); } diff --git a/src/util/websocket-util.ts b/src/util/websocket-util.ts index f88d4c2..7efc262 100644 --- a/src/util/websocket-util.ts +++ b/src/util/websocket-util.ts @@ -57,6 +57,18 @@ export const WS_BASE_URL_MAP: Record< testnet: 'wss://stream-testnet.bybit.com/spot/private/v3', }, }, + usdcOption: { + public: { + livenet: 'wss://stream.bybit.com/trade/option/usdc/public/v1', + livenet2: 'wss://stream.bytick.com/trade/option/usdc/public/v1', + testnet: 'wss://stream-testnet.bybit.com/trade/option/usdc/public/v1', + }, + private: { + livenet: 'wss://stream.bybit.com/trade/option/usdc/private/v1', + livenet2: 'wss://stream.bytick.com/trade/option/usdc/private/v1', + testnet: 'wss://stream-testnet.bybit.com/trade/option/usdc/private/v1', + }, + }, }; export const WS_KEY_MAP = { @@ -67,6 +79,10 @@ export const WS_KEY_MAP = { spotPublic: 'spotPublic', spotV3Private: 'spotV3Private', spotV3Public: 'spotV3Public', + usdcOptionPrivate: 'usdcOptionPrivate', + usdcOptionPublic: 'usdcOptionPublic', + // usdcPerpPrivate: 'usdcPerpPrivate', + // usdcPerpPublic: 'usdcPerpPublic', } as const; export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [WS_KEY_MAP.spotV3Private]; @@ -74,51 +90,103 @@ 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, + WS_KEY_MAP.spotV3Public, + WS_KEY_MAP.usdcOptionPublic, ] as string[]; -export function getLinearWsKeyForTopic(topic: string): WsKey { - const privateTopics = [ - 'position', - 'execution', - 'order', - 'stop_order', - 'wallet', - ]; - if (privateTopics.includes(topic)) { - return WS_KEY_MAP.linearPrivate; - } +/** 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', + // copy trading apis + 'copyTradePosition', + 'copyTradeOrder', + 'copyTradeExecution', + 'copyTradeWallet', + // usdc options + 'user.openapi.option.position', + 'user.openapi.option.trade', + 'user.order', + 'user.openapi.option.order', + 'user.service', + 'user.openapi.greeks', + 'user.mmp.event', + // usdc perps + 'user.openapi.perp.position', + 'user.openapi.perp.trade', + 'user.openapi.perp.order', + 'user.service', + // unified margin + 'user.position.unifiedAccount', + 'user.execution.unifiedAccount', + 'user.order.unifiedAccount', + 'user.wallet.unifiedAccount', + 'user.greeks.unifiedAccount', +]; - return WS_KEY_MAP.linearPublic; +export function getWsKeyForTopic( + market: APIMarket, + topic: string, + isPrivate?: boolean +): WsKey { + const isPrivateTopic = isPrivate === true || PRIVATE_TOPICS.includes(topic); + switch (market) { + case 'inverse': { + return WS_KEY_MAP.inverse; + } + case 'linear': { + return isPrivateTopic + ? WS_KEY_MAP.linearPrivate + : WS_KEY_MAP.linearPublic; + } + case 'spot': { + return isPrivateTopic ? WS_KEY_MAP.spotPrivate : WS_KEY_MAP.spotPublic; + } + case 'spotv3': { + return isPrivateTopic + ? WS_KEY_MAP.spotV3Private + : WS_KEY_MAP.spotV3Public; + } + case 'usdcOption': { + return isPrivateTopic + ? WS_KEY_MAP.usdcOptionPrivate + : WS_KEY_MAP.usdcOptionPublic; + } + default: { + throw neverGuard(market, `getWsKeyForTopic(): Unhandled market`); + } + } } -export function getSpotWsKeyForTopic( +export function getUsdcWsKeyForTopic( topic: string, - apiVersion: 'v1' | 'v3' + subGroup: 'option' | 'perp' ): WsKey { - const privateTopics = [ - 'position', - 'execution', - 'order', - 'stop_order', - 'outboundAccountInfo', - 'executionReport', - 'ticketInfo', - ]; - - if (apiVersion === 'v3') { - if (privateTopics.includes(topic)) { - return WS_KEY_MAP.spotV3Private; - } - return WS_KEY_MAP.spotV3Public; + const isPrivateTopic = PRIVATE_TOPICS.includes(topic); + if (subGroup === 'option') { + return isPrivateTopic + ? WS_KEY_MAP.usdcOptionPrivate + : WS_KEY_MAP.usdcOptionPublic; } - - if (privateTopics.includes(topic)) { - return WS_KEY_MAP.spotPrivate; - } - return WS_KEY_MAP.spotPublic; + return isPrivateTopic + ? WS_KEY_MAP.usdcOptionPrivate + : WS_KEY_MAP.usdcOptionPublic; + // return isPrivateTopic + // ? WS_KEY_MAP.usdcPerpPrivate + // : WS_KEY_MAP.usdcPerpPublic; } export const WS_ERROR_ENUM = { NOT_AUTHENTICATED_SPOT_V3: '-1004', BAD_API_KEY_SPOT_V3: '10003', }; + +export function neverGuard(x: never, msg: string): Error { + return new Error(`Unhandled value exception "x", ${msg}`); +} diff --git a/src/websocket-client.ts b/src/websocket-client.ts index ce4ed1c..99610cf 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -22,19 +22,16 @@ import { import { serializeParams, isWsPong, - getLinearWsKeyForTopic, - getSpotWsKeyForTopic, WsConnectionStateEnum, PUBLIC_WS_KEYS, WS_AUTH_ON_CONNECT_KEYS, WS_KEY_MAP, DefaultLogger, WS_BASE_URL_MAP, + getWsKeyForTopic, + neverGuard, } from './util'; - -function neverGuard(x: never, msg: string): Error { - return new Error(`Unhandled value exception "x", ${msg}`); -} +import { USDCOptionClient } from './usdc-option-client'; const loggerCategory = { category: 'bybit-ws' }; @@ -94,9 +91,7 @@ export class WebsocketClient extends EventEmitter { ...options, }; - if (this.options.fetchTimeOffsetBeforeAuth) { - this.prepareRESTClient(); - } + this.prepareRESTClient(); } /** @@ -148,15 +143,28 @@ export class WebsocketClient extends EventEmitter { this.connectPublic(); break; } - // if (this.isV3()) { - // this.restClient = new SpotClientV3( - // undefined, - // undefined, - // this.isLivenet(), - // this.options.restOptions, - // this.options.requestOptions - // ); - // } + case 'spotv3': { + this.restClient = new SpotClientV3( + undefined, + undefined, + !this.isTestnet(), + this.options.restOptions, + this.options.requestOptions + ); + this.connectPublic(); + break; + } + case 'usdcOption': { + this.restClient = new USDCOptionClient( + undefined, + undefined, + !this.isTestnet(), + this.options.restOptions, + this.options.requestOptions + ); + this.connectPublic(); + break; + } default: { throw neverGuard( this.options.market, @@ -208,25 +216,15 @@ export class WebsocketClient extends EventEmitter { public connectAll(): Promise[] { switch (this.options.market) { case 'inverse': { - return [this.connect(WS_KEY_MAP.inverse)]; + // only one for inverse + return [this.connectPublic()]; } - case 'linear': { - return [ - this.connect(WS_KEY_MAP.linearPublic), - this.connect(WS_KEY_MAP.linearPrivate), - ]; - } - case 'spot': { - return [ - this.connect(WS_KEY_MAP.spotPublic), - this.connect(WS_KEY_MAP.spotPrivate), - ]; - } - case 'spotv3': { - return [ - this.connect(WS_KEY_MAP.spotV3Public), - this.connect(WS_KEY_MAP.spotV3Private), - ]; + // these all have separate public & private ws endpoints + case 'linear': + case 'spot': + case 'spotv3': + case 'usdcOption': { + return [this.connectPublic(), this.connectPrivate()]; } default: { throw neverGuard(this.options.market, `connectAll(): Unhandled market`); @@ -248,6 +246,9 @@ export class WebsocketClient extends EventEmitter { case 'spotv3': { return this.connect(WS_KEY_MAP.spotV3Public); } + case 'usdcOption': { + return this.connect(WS_KEY_MAP.usdcOptionPublic); + } default: { throw neverGuard( this.options.market, @@ -257,7 +258,7 @@ export class WebsocketClient extends EventEmitter { } } - public connectPrivate(): Promise | undefined { + public connectPrivate(): Promise { switch (this.options.market) { case 'inverse': { return this.connect(WS_KEY_MAP.inverse); @@ -271,6 +272,9 @@ export class WebsocketClient extends EventEmitter { case 'spotv3': { return this.connect(WS_KEY_MAP.spotV3Private); } + case 'usdcOption': { + return this.connect(WS_KEY_MAP.usdcOptionPrivate); + } default: { throw neverGuard( this.options.market, @@ -596,10 +600,15 @@ 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) { + this.logger.silly('Received event', { + ...this.logger, + wsKey, + msg: JSON.stringify(msg, null, 2), + }); + + // TODO: cleanme + if (msg['success'] || msg?.pong || isWsPong(msg)) { if (isWsPong(msg)) { this.logger.silly('Received pong', { ...loggerCategory, wsKey }); } else { @@ -608,6 +617,9 @@ export class WebsocketClient extends EventEmitter { return; } + if (msg['finalFragment']) { + return this.emit('response', msg); + } if (msg?.topic) { return this.emit('update', msg); } @@ -701,6 +713,18 @@ export class WebsocketClient extends EventEmitter { // 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.usdcOption.public[networkKey]; + // } + // case WS_KEY_MAP.usdcPerpPrivate: { + // return WS_BASE_URL_MAP.usdcOption.private[networkKey]; + // } default: { this.logger.error('getWsUrl(): Unhandled wsKey: ', { ...loggerCategory, @@ -711,29 +735,6 @@ export class WebsocketClient extends EventEmitter { } } - private getWsKeyForTopic(topic: string): WsKey { - switch (this.options.market) { - case 'inverse': { - return WS_KEY_MAP.inverse; - } - case 'linear': { - return getLinearWsKeyForTopic(topic); - } - case 'spot': { - return getSpotWsKeyForTopic(topic, 'v1'); - } - case 'spotv3': { - return getSpotWsKeyForTopic(topic, 'v3'); - } - default: { - throw neverGuard( - this.options.market, - `connectPublic(): Unhandled market` - ); - } - } - } - 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` @@ -742,11 +743,16 @@ export class WebsocketClient extends EventEmitter { /** * Add topic/topics to WS subscription list + * @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) { + public subscribe(wsTopics: WsTopic[] | WsTopic, isPrivateTopic?: boolean) { const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; topics.forEach((topic) => - this.wsStore.addTopic(this.getWsKeyForTopic(topic), topic) + this.wsStore.addTopic( + getWsKeyForTopic(this.options.market, topic, isPrivateTopic), + topic + ) ); // attempt to send subscription topic per websocket @@ -776,11 +782,16 @@ export class WebsocketClient extends EventEmitter { /** * Remove topic/topics from WS subscription list + * @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) { + public unsubscribe(wsTopics: WsTopic[] | WsTopic, isPrivateTopic?: boolean) { const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; topics.forEach((topic) => - this.wsStore.deleteTopic(this.getWsKeyForTopic(topic), topic) + this.wsStore.deleteTopic( + getWsKeyForTopic(this.options.market, topic, isPrivateTopic), + topic + ) ); this.wsStore.getKeys().forEach((wsKey: WsKey) => { diff --git a/test/usdc/options/ws.public.test.ts b/test/usdc/options/ws.public.test.ts new file mode 100644 index 0000000..8aeb862 --- /dev/null +++ b/test/usdc/options/ws.public.test.ts @@ -0,0 +1,79 @@ +import { + WebsocketClient, + WSClientConfigurableOptions, + WS_KEY_MAP, +} from '../../../src'; +import { + logAllEvents, + getSilentLogger, + waitForSocketEvent, + WS_OPEN_EVENT_PARTIAL, +} from '../../ws.util'; + +describe('Public USDC Option Websocket Client', () => { + let wsClient: WebsocketClient; + + const wsClientOptions: WSClientConfigurableOptions = { + market: 'usdcOption', + }; + + beforeAll(() => { + wsClient = new WebsocketClient( + wsClientOptions, + getSilentLogger('expectSuccessNoAuth') + ); + // logAllEvents(wsClient); + }); + + beforeEach(() => { + wsClient.removeAllListeners(); + }); + + afterAll(() => { + wsClient.closeAll(); + }); + + it('should open a public ws connection', async () => { + const wsOpenPromise = waitForSocketEvent(wsClient, 'open'); + + expect(wsOpenPromise).resolves.toMatchObject({ + event: WS_OPEN_EVENT_PARTIAL, + wsKey: WS_KEY_MAP.usdcOptionPublic, + }); + + await Promise.all([wsOpenPromise]); + }); + + it('should subscribe to public trade events', async () => { + const wsResponsePromise = waitForSocketEvent(wsClient, 'response'); + // const wsUpdatePromise = waitForSocketEvent(wsClient, 'update'); + + wsClient.subscribe([ + 'recenttrades.BTC', + 'recenttrades.ETH', + 'recenttrades.SOL', + ]); + + try { + expect(await wsResponsePromise).toMatchObject({ + success: true, + data: { + failTopics: [], + successTopics: expect.any(Array), + }, + type: 'COMMAND_RESP', + }); + } catch (e) { + // sub failed + expect(e).toBeFalsy(); + } + + // Takes a while to get an event from USDC options - testing this manually for now + // try { + // expect(await wsUpdatePromise).toStrictEqual('asdfasdf'); + // } catch (e) { + // // no data + // expect(e).toBeFalsy(); + // } + }); +}); diff --git a/test/ws.util.ts b/test/ws.util.ts index 952e9fc..855dee5 100644 --- a/test/ws.util.ts +++ b/test/ws.util.ts @@ -73,6 +73,36 @@ export function waitForSocketEvent( }); } +export function listenToSocketEvents(wsClient: WebsocketClient) { + const retVal: Record< + 'update' | 'open' | 'response' | 'close' | 'error', + typeof jest.fn + > = { + open: jest.fn(), + response: jest.fn(), + update: jest.fn(), + close: jest.fn(), + error: jest.fn(), + }; + + wsClient.on('open', retVal.open); + wsClient.on('response', retVal.response); + wsClient.on('update', retVal.update); + wsClient.on('close', retVal.close); + wsClient.on('error', retVal.error); + + return { + ...retVal, + cleanup: () => { + wsClient.removeListener('open', retVal.open); + wsClient.removeListener('response', retVal.response); + wsClient.removeListener('update', retVal.update); + wsClient.removeListener('close', retVal.close); + wsClient.removeListener('error', retVal.error); + }, + }; +} + export function logAllEvents(wsClient: WebsocketClient) { wsClient.on('update', (data) => { console.log('wsUpdate: ', JSON.stringify(data, null, 2));