diff --git a/.github/workflows/npmpublish.yml b/.github/workflows/npmpublish.yml index f2af8e7..b0b36fc 100644 --- a/.github/workflows/npmpublish.yml +++ b/.github/workflows/npmpublish.yml @@ -42,6 +42,8 @@ jobs: - run: npm ci if: steps.version-updated.outputs.has-updated + - run: npm run clean + if: steps.version-updated.outputs.has-updated - run: npm run build if: steps.version-updated.outputs.has-updated - run: npm publish diff --git a/README.md b/README.md index 2d75d55..5a4321d 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,12 @@ A production-ready Node.js connector for the Bybit APIs and WebSockets, with Typ ## Issues & Discussion - Issues? Check the [issues tab](https://github.com/tiagosiebler/bybit-api/issues). - Discuss & collaborate with other node devs? Join our [Node.js Algo Traders](https://t.me/nodetraders) engineering community on telegram. +- `'bybit-api' has no exported member 'RestClient'`: use `InverseClient` instead of `RestClient` ## Documentation Most methods accept JS objects. These can be populated using parameters specified by Bybit's API documentation. - [Bybit API Inverse Documentation](https://bybit-exchange.github.io/docs/inverse/#t-introduction). -- [Bybit API Linear Documentation (not supported yet)](https://bybit-exchange.github.io/docs/linear/#t-introduction) +- [Bybit API Linear Documentation](https://bybit-exchange.github.io/docs/linear/#t-introduction) ## Structure This project uses typescript. Resources are stored in 3 key structures: @@ -39,13 +40,10 @@ Build a bundle using webpack: The bundle can be found in `dist/`. Altough usage should be largely consistent, smaller differences will exist. Documentation is still TODO. ### Inverse Contracts -#### Rest client -```javascript -const { RestClient } = require('bybit-api'); +Since inverse and linear (USDT) contracts don't use the exact same APIs, the REST abstractions are split into two modules. To use the inverse REST APIs, import the `InverseClient`: -const API_KEY = 'xxx'; -const PRIVATE_KEY = 'yyy'; -const useLivenet = false; +```javascript +const { InverseClient } = require('bybit-api'); const restInverseOptions = { // override the max size of the request window (in ms) @@ -68,7 +66,11 @@ const restInverseOptions = { parse_exceptions?: boolean; }; -const client = new RestClient( +const API_KEY = 'xxx'; +const PRIVATE_KEY = 'yyy'; +const useLivenet = false; + +const client = new InverseClient( API_KEY, PRIVATE_KEY, @@ -88,9 +90,67 @@ client.changeUserLeverage({leverage: 4, symbol: 'ETHUSD'}) }); ``` -See inverse [rest-client.ts](./src/rest-client.ts) for further information. +See inverse [inverse-client.ts](./src/inverse-client.ts) for further information. + +### Linear Contracts +To use the Linear (USDT) REST APIs, import the `LinearClient`: + +```javascript +const { LinearClient } = require('bybit-api'); + +const restInverseOptions = { + // override the max size of the request window (in ms) + recv_window?: number; + + // how often to sync time drift with bybit servers + sync_interval_ms?: number | string; + + // Default: false. Disable above sync mechanism if true. + disable_time_sync?: boolean; + + // Default: false. If true, we'll throw errors if any params are undefined + strict_param_validation?: boolean; + + // Optionally override API protocol + domain + // e.g 'https://api.bytick.com' + baseUrl?: string; + + // Default: true. whether to try and post-process request exceptions. + parse_exceptions?: boolean; +}; + +const API_KEY = 'xxx'; +const PRIVATE_KEY = 'yyy'; +const useLivenet = false; + +const client = new LinearClient( + API_KEY, + PRIVATE_KEY, + + // optional, uses testnet by default. Set to 'true' to use livenet. + useLivenet, + + // restInverseOptions, + // requestLibraryOptions +); + +client.changeUserLeverage({leverage: 4, symbol: 'ETHUSDT'}) + .then(result => { + console.log(result); + }) + .catch(err => { + console.error(err); + }); +``` + +### WebSockets + +Inverse & linear WebSockets can be used via a shared `WebsocketClient`. + +Note: to use the linear websockets, pass "linear: true" in the constructor options when instancing the `WebsocketClient`. + +To connect to both linear and inverse websockets, make two instances of the WebsocketClient: -#### Websocket client ```javascript const { WebsocketClient } = require('bybit-api'); @@ -101,67 +161,84 @@ const wsConfig = { key: API_KEY, secret: PRIVATE_KEY, - // The following parameters are optional: + /* + The following parameters are optional: + */ - // defaults to false == testnet. set to true for livenet. + // defaults to false == testnet. Set to true for livenet. // livenet: true - // override which URL to use for websocket connections - // wsUrl: 'wss://stream.bytick.com/realtime' - - // how often to check (in ms) that WS connection is still alive - // pingInterval: 10000, + // defaults to fase == inverse. Set to true for linear (USDT) trading. + // linear: true // how long to wait (in ms) before deciding the connection should be terminated & reconnected // pongTimeout: 1000, + // how often to check (in ms) that WS connection is still alive + // pingInterval: 10000, + // how long to wait before attempting to reconnect (in ms) after connection is closed // reconnectTimeout: 500, // config options sent to RestClient (used for time sync). See RestClient docs. // restOptions: { }, - // config for axios to pass to RestClient. E.g for proxy support + // config for axios used for HTTP requests. E.g for proxy support // requestOptions: { } + + // override which URL to use for websocket connections + // wsUrl: 'wss://stream.bytick.com/realtime' }; const ws = new WebsocketClient(wsConfig); +// subscribe to multiple topics at once ws.subscribe(['position', 'execution', 'trade']); + +// and/or subscribe to individual topics on demand ws.subscribe('kline.BTCUSD.1m'); -ws.on('open', () => { - console.log('connection open'); +// Listen to events coming from websockets. This is the primary data source +ws.on('update', data => { + console.log('update', data); }); -ws.on('update', message => { - console.log('update', message); +// Optional: Listen to websocket connection open event (automatic after subscribing to one or more topics) +ws.on('open', ({ wsKey, event }) => { + console.log('connection open for websocket with ID: ' + wsKey); }); +// Optional: Listen to responses to websocket queries (e.g. the response after subscribing to a topic) ws.on('response', response => { console.log('response', response); }); +// Optional: Listen to connection close event. Unexpected connection closes are automatically reconnected. ws.on('close', () => { console.log('connection closed'); }); +// Optional: Listen to raw error events. +// Note: responses to invalid topics are currently only sent in the "response" event. ws.on('error', err => { console.error('ERR', err); }); ``` -See inverse [websocket-client.ts](./src/websocket-client.ts) for further information. +See [websocket-client.ts](./src/websocket-client.ts) for further information. ### Customise Logging Pass a custom logger which supports the log methods `silly`, `debug`, `notice`, `info`, `warning` and `error`, or override methods from the default logger as desired: ```js -const { RestClient, WebsocketClient, DefaultLogger } = require('bybit-api'); +const { WebsocketClient, DefaultLogger } = require('bybit-api'); // Disable all logging on the silly level DefaultLogger.silly = () => {}; -const ws = new WebsocketClient({key: 'xxx', secret: 'yyy'}, DefaultLogger); +const ws = new WebsocketClient( + { key: 'xxx', secret: 'yyy' }, + DefaultLogger +); ``` ## Contributions & Thanks diff --git a/package-lock.json b/package-lock.json index 1c4e97c..922abff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 47ac72c..98ff433 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,11 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "clean": "rm -rf lib dist", - "prebuild": "npm run clean", "build": "tsc", + "build:clean": "npm run clean && npm run build", + "build:watch": "npm run clean && tsc --watch", "pack": "webpack --config webpack/webpack.config.js", - "prepublish": "npm run build", + "prepublish": "npm run build:clean", "betapublish": "npm publish --tag beta" }, "author": "Tiago Siebler (https://github.com/tiagosiebler)", diff --git a/src/index.ts b/src/index.ts index ab8ec69..bc82e2e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ -export * from './rest-client'; +export * from './inverse-client'; +export * from './linear-client'; export * from './websocket-client'; export * from './logger'; diff --git a/src/rest-client.ts b/src/inverse-client.ts similarity index 54% rename from src/rest-client.ts rename to src/inverse-client.ts index 79b0855..405c4b1 100644 --- a/src/rest-client.ts +++ b/src/inverse-client.ts @@ -1,9 +1,10 @@ import { AxiosRequestConfig } from 'axios'; -import { GenericAPIResponse, getBaseRESTInverseUrl, RestClientInverseOptions } from './util/requestUtils'; +import { GenericAPIResponse, getRestBaseUrl, RestClientOptions } from './util/requestUtils'; import RequestWrapper from './util/requestWrapper'; +import SharedEndpoints from './shared-endpoints'; -export class RestClient { - private requestWrapper: RequestWrapper; +export class InverseClient extends SharedEndpoints { + protected requestWrapper: RequestWrapper; /** * @public Creates an instance of the inverse REST API client. @@ -11,22 +12,23 @@ export class RestClient { * @param {string} key - your API key * @param {string} secret - your API secret * @param {boolean} [useLivenet=false] - * @param {RestClientInverseOptions} [restInverseOptions={}] options to configure REST API connectivity + * @param {RestClientOptions} [restInverseOptions={}] options to configure REST API connectivity * @param {AxiosRequestConfig} [requestOptions={}] HTTP networking options for axios */ constructor( key?: string | undefined, secret?: string | undefined, useLivenet?: boolean, - restInverseOptions: RestClientInverseOptions = {}, - httpOptions: AxiosRequestConfig = {} + restInverseOptions: RestClientOptions = {}, + requestOptions: AxiosRequestConfig = {} ) { + super() this.requestWrapper = new RequestWrapper( key, secret, - getBaseRESTInverseUrl(useLivenet), + getRestBaseUrl(useLivenet), restInverseOptions, - httpOptions + requestOptions ); return this; } @@ -37,12 +39,6 @@ export class RestClient { * */ - getOrderBook(params: { - symbol: string; - }): GenericAPIResponse { - return this.requestWrapper.get('v2/public/orderBook/L2', params); - } - getKline(params: { symbol: string; interval: string; @@ -57,15 +53,9 @@ export class RestClient { */ getLatestInformation(params?: { symbol?: string; - }): GenericAPIResponse { + }): GenericAPIResponse { return this.getTickers(params); - } - - getTickers(params?: { - symbol?: string; - }): GenericAPIResponse { - return this.requestWrapper.get('v2/public/tickers', params); - } + } /** * @deprecated use getTrades() instead @@ -86,10 +76,6 @@ export class RestClient { return this.requestWrapper.get('v2/public/trading-records', params); } - getSymbols(): GenericAPIResponse { - return this.requestWrapper.get('v2/public/symbols'); - } - /** * @deprecated use getLiquidations() instead */ @@ -103,16 +89,6 @@ export class RestClient { return this.getLiquidations(params); } - getLiquidations(params: { - symbol: string; - from?: number; - limit?: number; - start_time?: number; - end_time?: number; - }): GenericAPIResponse { - return this.requestWrapper.get('v2/public/liq-records', params); - } - getMarkPriceKline(params: { symbol: string; interval: string; @@ -122,33 +98,32 @@ export class RestClient { return this.requestWrapper.get('v2/public/mark-price-kline', params); } - getOpenInterest(params: { + getIndexPriceKline(params: { symbol: string; - period: string; + interval: string; + from: number; limit?: number; }): GenericAPIResponse { - return this.requestWrapper.get('v2/public/open-interest', params); + return this.requestWrapper.get('v2/public/index-price-kline', params); } - getLatestBigDeal(params: { + getPremiumIndexKline(params: { symbol: string; + interval: string; + from: number; limit?: number; }): GenericAPIResponse { - return this.requestWrapper.get('v2/public/big-deal', params); - } - - getLongShortRatio(params: { - symbol: string; - period: string; - limit?: number; - }): GenericAPIResponse { - return this.requestWrapper.get('v2/public/account-ratio', params); + return this.requestWrapper.get('v2/public/premium-index-kline', params); } /** * * Account Data Endpoints * + */ + + /** + * Active orders */ placeActiveOrder(orderRequest: { @@ -164,9 +139,6 @@ export class RestClient { close_on_trigger?: boolean; order_link_id?: string; }): GenericAPIResponse { - // if (orderRequest.order_type === 'Limit' && !orderRequest.price) { - // throw new Error('Price required for limit orders'); - // } return this.requestWrapper.post('v2/private/order/create', orderRequest); } @@ -180,29 +152,11 @@ export class RestClient { return this.requestWrapper.get('v2/private/order/list', params); } - /** - * @deprecated use getActiveOrderList() instead - */ - getActiveOrder(params: { - order_id?: string; - order_link_id?: string; - symbol?: string; - order?: string; - page?: number; - limit?: number; - order_status?: string; - }): GenericAPIResponse { - return this.requestWrapper.get('open-api/order/list', params); - } - cancelActiveOrder(params: { symbol: string; order_id?: string; order_link_id?: string; }): GenericAPIResponse { - // if (!params.order_id && !params.order_link_id) { - // throw new Error('Parameter order_id OR order_link_id is required'); - // } return this.requestWrapper.post('v2/private/order/cancel', params); } @@ -219,33 +173,21 @@ export class RestClient { p_r_qty?: string; p_r_price?: string; }): GenericAPIResponse { - // if (!params.order_id && !params.order_link_id) { - // throw new Error('Parameter order_id OR order_link_id is required'); - // } return this.requestWrapper.post('v2/private/order/replace', params); } - /** - * @deprecated use replaceActiveOrder() - */ - replaceActiveOrderOld(params: any): GenericAPIResponse { - // if (!params.order_id && !params.order_link_id) { - // throw new Error('Parameter order_id OR order_link_id is required'); - // } - return this.requestWrapper.post('open-api/order/replace', params); - } - queryActiveOrder(params: { order_id?: string; order_link_id?: string; symbol: string; }): GenericAPIResponse { - // if (!params.order_id && !params.order_link_id) { - // throw new Error('Parameter order_id OR order_link_id is required'); - // } return this.requestWrapper.get('v2/private/order', params); } + /** + * Conditional orders + */ + placeConditionalOrder(params: { side: string; symbol: string; @@ -259,22 +201,9 @@ export class RestClient { close_on_trigger?: boolean; order_link_id?: string; }): GenericAPIResponse { - // if (params.order_type === 'Limit' && !params.price) { - // throw new Error('Parameter price is required for limit orders'); - // } return this.requestWrapper.post('v2/private/stop-order/create', params); } - /** - * @deprecated use placeConditionalOrder - */ - placeConditionalOrderOld(params: any): GenericAPIResponse { - // if (params.order_type === 'Limit' && !params.price) { - // throw new Error('Parameter price is required for limit orders'); - // } - return this.requestWrapper.post('open-api/stop-order/create', params); - } - getConditionalOrder(params: { symbol: string; stop_order_status?: string; @@ -285,34 +214,14 @@ export class RestClient { return this.requestWrapper.get('v2/private/stop-order/list', params); } - /** - * @deprecated use placeConditionalOrder - */ - getConditionalOrderOld(params: any): GenericAPIResponse { - return this.requestWrapper.get('open-api/stop-order/list', params); - } - cancelConditionalOrder(params: { symbol: string; stop_order_id?: string; order_link_id?: string; }): GenericAPIResponse { - // if (!params.stop_order_id && !params.order_link_id) { - // throw new Error('Parameter stop_order_id OR order_link_id is required'); - // } return this.requestWrapper.post('v2/private/stop-order/cancel', params); } - /** - * @deprecated use cancelConditionalOrder - */ - cancelConditionalOrderOld(params: any): GenericAPIResponse { - // if (!params.stop_order_id && !params.order_link_id) { - // throw new Error('Parameter stop_order_id OR order_link_id is required'); - // } - return this.requestWrapper.post('open-api/stop-order/cancel', params); - } - cancelAllConditionalOrders(params: { symbol: string; }): GenericAPIResponse { @@ -327,30 +236,21 @@ export class RestClient { p_r_price?: string; p_r_trigger_price?: string; }): GenericAPIResponse { - // if (!params.stop_order_id && !params.order_link_id) { - // throw new Error('Parameter stop_order_id OR order_link_id is required'); - // } return this.requestWrapper.post('v2/private/stop-order/replace', params); } - /** - * @deprecated use replaceConditionalOrder - */ - replaceConditionalOrderOld(params: any): GenericAPIResponse { - return this.requestWrapper.post('open-api/stop-order/replace', params); - } - queryConditionalOrder(params: { symbol: string; stop_order_id?: string; order_link_id?: string; }): GenericAPIResponse { - // if (!params.stop_order_id && !params.order_link_id) { - // throw new Error('Parameter stop_order_id OR order_link_id is required'); - // } return this.requestWrapper.get('v2/private/stop-order', params); } + /** + * Position + */ + /** * @deprecated use getPosition() instead */ @@ -386,14 +286,14 @@ export class RestClient { sl_trigger_by?: string; new_trailing_active?: number; }): GenericAPIResponse { - return this.requestWrapper.post('open-api/position/trading-stop', params); + return this.requestWrapper.post('v2/private/position/trading-stop', params); } setUserLeverage(params: { symbol: string; leverage: number; }): GenericAPIResponse { - return this.requestWrapper.post('user/leverage/save', params); + return this.requestWrapper.post('v2/private/position/leverage/save', params); } /** @@ -425,6 +325,10 @@ export class RestClient { return this.requestWrapper.get('v2/private/trade/closed-pnl/list', params); } + /** + * Risk Limit + */ + getRiskLimitList(): GenericAPIResponse { return this.requestWrapper.get('open-api/wallet/risk-limit/list'); } @@ -436,96 +340,35 @@ export class RestClient { return this.requestWrapper.post('open-api/wallet/risk-limit', params); } + /** + * Funding + */ + getLastFundingRate(params: { symbol: string; }): GenericAPIResponse { - return this.requestWrapper.get('open-api/funding/prev-funding-rate', params); + return this.requestWrapper.get('v2/public/funding/prev-funding-rate', params); } getMyLastFundingFee(params: { symbol: string; }): GenericAPIResponse { - return this.requestWrapper.get('open-api/funding/prev-funding', params); + return this.requestWrapper.get('v2/private/funding/prev-funding', params); } getPredictedFunding(params: { symbol: string; }): GenericAPIResponse { - return this.requestWrapper.get('open-api/funding/predicted-funding', params); + return this.requestWrapper.get('v2/private/funding/predicted-funding', params); } - getApiKeyInfo(): GenericAPIResponse { - return this.requestWrapper.get('open-api/api-key'); - } + /** + * LCP Info + */ getLcpInfo(params: { symbol: string; }): GenericAPIResponse { return this.requestWrapper.get('v2/private/account/lcp', params); } - - /** - * - * Wallet Data Endpoints - * - */ - - getWalletBalance(params?: { - coin?: string; - }): GenericAPIResponse { - return this.requestWrapper.get('v2/private/wallet/balance', params); - } - - getWalletFundRecords(params?: { - start_date?: string; - end_date?: string; - currency?: string; - coin?: string; - wallet_fund_type?: string; - page?: number; - limit?: number; - }): GenericAPIResponse { - return this.requestWrapper.get('open-api/wallet/fund/records', params); - } - - getWithdrawRecords(params: { - start_date?: string; - end_date?: string; - coin?: string; - status?: string; - page?: number; - limit?: number; - }): GenericAPIResponse { - return this.requestWrapper.get('open-api/wallet/withdraw/list', params); - } - - getAssetExchangeRecords(params?: { - limit?: number; - from?: number; - direction?: string; - }): GenericAPIResponse { - return this.requestWrapper.get('v2/private/exchange-order/list', params); - } - - /** - * - * API Data Endpoints - * - */ - - getServerTime(): GenericAPIResponse { - return this.requestWrapper.get('v2/public/time'); - } - - getApiAnnouncements(): GenericAPIResponse { - return this.requestWrapper.get('v2/public/announcement'); - } - - async getTimeOffset(): Promise { - const start = Date.now(); - return this.getServerTime().then(result => { - const end = Date.now(); - return Math.ceil((result.time_now * 1000) - end + ((end - start) / 2)); - }); - } }; diff --git a/src/linear-client.ts b/src/linear-client.ts new file mode 100644 index 0000000..d44d6ad --- /dev/null +++ b/src/linear-client.ts @@ -0,0 +1,356 @@ +import { AxiosRequestConfig } from 'axios'; +import { GenericAPIResponse, getRestBaseUrl, RestClientOptions } from './util/requestUtils'; +import RequestWrapper from './util/requestWrapper'; +import SharedEndpoints from './shared-endpoints'; + +export class LinearClient extends SharedEndpoints { + protected requestWrapper: RequestWrapper; + + /** + * @public Creates an instance of the inverse REST API client. + * + * @param {string} key - your API key + * @param {string} secret - your API secret + * @param {boolean} [useLivenet=false] + * @param {RestClientOptions} [restInverseOptions={}] options to configure REST API connectivity + * @param {AxiosRequestConfig} [requestOptions={}] HTTP networking options for axios + */ + constructor( + key?: string | undefined, + secret?: string | undefined, + useLivenet?: boolean, + restInverseOptions: RestClientOptions = {}, + requestOptions: AxiosRequestConfig = {} + ) { + super() + this.requestWrapper = new RequestWrapper( + key, + secret, + getRestBaseUrl(useLivenet), + restInverseOptions, + requestOptions + ); + return this; + } + + /** + * + * Market Data Endpoints + * + */ + + getKline(params: { + symbol: string; + interval: string; + from: number; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('public/linear/kline', params); + } + + getTrades(params: { + symbol: string; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('public/linear/recent-trading-records', params); + } + + getLastFundingRate(params: { + symbol: string; + }): GenericAPIResponse { + return this.requestWrapper.get('public/linear/funding/prev-funding-rate', params); + } + + getMarkPriceKline(params: { + symbol: string; + interval: string; + from: number; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('public/linear/mark-price-kline', params); + } + + getIndexPriceKline(params: { + symbol: string; + interval: string; + from: number; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('public/linear/index-price-kline', params); + } + + getPremiumIndexKline(params: { + symbol: string; + interval: string; + from: number; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('public/linear/premium-index-kline', params); + } + + /** + * + * Account Data Endpoints + * + */ + + placeActiveOrder(params: { + side: string; + symbol: string; + order_type: string; + qty: number; + price?: number; + time_in_force: string; + take_profit?: number; + stop_loss?: number; + tp_trigger_by?: string; + sl_trigger_by?: string; + reduce_only?: boolean; + close_on_trigger?: boolean; + order_link_id?: string; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/order/create', params); + } + + getActiveOrderList(params: { + order_id?: string; + order_link_id?: string; + symbol: string; + order?: string; + page?: number; + limit?: number; + order_status?: string; + }): GenericAPIResponse { + return this.requestWrapper.get('private/linear/order/list', params); + } + + cancelActiveOrder(params: { + symbol: string; + order_id?: string; + order_link_id?: string; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/order/cancel', params); + } + + cancelAllActiveOrders(params: { + symbol: string; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/order/cancel-all', params); + } + + replaceActiveOrder(params: { + order_id?: string; + order_link_id?: string; + symbol: string; + p_r_qty?: number; + p_r_price?: number; + take_profit?: number; + stop_loss?: number; + tp_trigger_by?: string; + sl_trigger_by?: string; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/order/replace', params); + } + + queryActiveOrder(params: { + order_id?: string; + order_link_id?: string; + symbol: string; + }): GenericAPIResponse { + return this.requestWrapper.get('private/linear/order/search', params); + } + + /** + * Conditional orders + */ + + placeConditionalOrder(params: { + side: string; + symbol: string; + order_type: string; + qty: number; + price?: number; + base_price: number; + stop_px: number; + time_in_force: string; + trigger_by?: string; + close_on_trigger?: boolean; + order_link_id?: string; + reduce_only: boolean; + take_profit?: number; + stop_loss?: number; + tp_trigger_by?: string; + sl_trigger_by?: string; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/stop-order/create', params); + } + + getConditionalOrder(params: { + stop_order_id?: string; + order_link_id?: string; + symbol: string; + stop_order_status?: string; + order?: string; + page?: number; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('private/linear/stop-order/list', params); + } + + cancelConditionalOrder(params: { + symbol: string; + stop_order_id?: string; + order_link_id?: string; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/stop-order/cancel', params); + } + + cancelAllConditionalOrders(params: { + symbol: string; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/stop-order/cancel-all', params); + } + + replaceConditionalOrder(params: { + stop_order_id?: string; + order_link_id?: string; + symbol: string; + p_r_qty?: number; + p_r_price?: number; + p_r_trigger_price?: number; + take_profit?: number; + stop_loss?: number; + tp_trigger_by?: string; + sl_trigger_by?: string; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/stop-order/replace', params); + } + + queryConditionalOrder(params: { + symbol: string; + stop_order_id?: string; + order_link_id?: string; + }): GenericAPIResponse { + return this.requestWrapper.get('private/linear/stop-order/search', params); + } + + /** + * Position + */ + + getPosition(params?: { + symbol?: string; + }): GenericAPIResponse { + return this.requestWrapper.get('private/linear/position/list', params); + } + + setAutoAddMargin(params?: { + symbol: string; + side: string; + auto_add_margin: boolean; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/position/set-auto-add-margin', params); + } + + setMarginSwitch(params?: { + symbol: string; + is_isolated: boolean; + buy_leverage: number; + sell_leverage: number; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/position/switch-isolated', params); + } + + setSwitchMode(params?: { + symbol: string; + tp_sl_mode: string; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/tpsl/switch-mode', params); + } + + setAddReduceMargin(params?: { + symbol: string; + side: string; + margin: number; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/position/add-margin', params); + } + + setUserLeverage(params: { + symbol: string; + buy_leverage: number; + sell_leverage: number; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/position/set-leverage', params); + } + + setTradingStop(params: { + symbol: string; + side: string; + take_profit?: number; + stop_loss?: number; + trailing_stop?: number; + tp_trigger_by?: string; + sl_trigger_by?: string; + sl_size?: number; + tp_size?: number; + }): GenericAPIResponse { + return this.requestWrapper.post('private/linear/position/trading-stop', params); + } + + getTradeRecords(params: { + symbol: string; + start_time?: number; + end_time?: number; + exec_type?: string; + page?: number; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('private/linear/trade/execution/list', params); + } + + getClosedPnl(params: { + symbol: string; + start_time?: number; + end_time?: number; + exec_type?: string; + page?: number; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('private/linear/tpsl/switch-mode', params); + } + + /** + * Risk Limit + */ + + getRiskLimitList(params: { + symbol: string; + }): GenericAPIResponse { + return this.requestWrapper.get('public/linear/risk-limit', params); + } + + setRiskLimit(params: { + symbol: string; + side: string; + risk_id: string; + }): GenericAPIResponse { + return this.requestWrapper.get('private/linear/position/set-risk', params); + } + + /** + * Funding + */ + + getPredictedFundingFee(params: { + symbol: string; + }): GenericAPIResponse { + return this.requestWrapper.get('private/linear/funding/predicted-funding', params); + } + + getLastFundingFee(params: { + symbol: string; + }): GenericAPIResponse { + return this.requestWrapper.get('private/linear/funding/prev-funding', params); + } +} diff --git a/src/shared-endpoints.ts b/src/shared-endpoints.ts new file mode 100644 index 0000000..1f3a9b2 --- /dev/null +++ b/src/shared-endpoints.ts @@ -0,0 +1,143 @@ +import { GenericAPIResponse } from './util/requestUtils'; +import RequestWrapper from './util/requestWrapper'; + +export default class SharedEndpoints { + // TODO: Is there a way to say that Base has to provide this? + protected requestWrapper: RequestWrapper; + + /** + * + * Market Data Endpoints + * + */ + + getOrderBook(params: { + symbol: string; + }): GenericAPIResponse { + return this.requestWrapper.get('v2/public/orderBook/L2', params); + } + + getTickers(params?: { + symbol?: string; + }): GenericAPIResponse { + return this.requestWrapper.get('v2/public/tickers', params); + } + + getSymbols(): GenericAPIResponse { + return this.requestWrapper.get('v2/public/symbols'); + } + + getLiquidations(params: { + symbol: string; + from?: number; + limit?: number; + start_time?: number; + end_time?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('v2/public/liq-records', params); + } + + /** + * + * Market Data : Advanced + * + */ + + getOpenInterest(params: { + symbol: string; + period: string; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('v2/public/open-interest', params); + } + + getLatestBigDeal(params: { + symbol: string; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('v2/public/big-deal', params); + } + + getLongShortRatio(params: { + symbol: string; + period: string; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('v2/public/account-ratio', params); + } + + /** + * + * Account Data Endpoints + * + */ + + getApiKeyInfo(): GenericAPIResponse { + return this.requestWrapper.get('v2/private/account/api-key'); + } + + /** + * + * Wallet Data Endpoints + * + */ + + getWalletBalance(params: { + coin?: string; + }): GenericAPIResponse { + return this.requestWrapper.get('v2/private/wallet/balance', params) + } + + getWalletFundRecords(params?: { + start_date?: string; + end_date?: string; + currency?: string; + coin?: string; + wallet_fund_type?: string; + page?: number; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('v2/private/wallet/fund/records', params); + } + + getWithdrawRecords(params: { + start_date?: string; + end_date?: string; + coin?: string; + status?: string; + page?: number; + limit?: number; + }): GenericAPIResponse { + return this.requestWrapper.get('v2/private/wallet/withdraw/list', params); + } + + getAssetExchangeRecords(params?: { + limit?: number; + from?: number; + direction?: string; + }): GenericAPIResponse { + return this.requestWrapper.get('v2/private/exchange-order/list', params); + } + + /** + * + * API Data Endpoints + * + */ + + getServerTime(): GenericAPIResponse { + return this.requestWrapper.get('v2/public/time'); + } + + getApiAnnouncements(): GenericAPIResponse { + return this.requestWrapper.get('v2/public/announcement'); + } + + async getTimeOffset(): Promise { + const start = Date.now(); + return this.getServerTime().then(result => { + const end = Date.now(); + return Math.ceil((result.time_now * 1000) - end + ((end - start) / 2)); + }); + } +} diff --git a/src/util/WsStore.ts b/src/util/WsStore.ts new file mode 100644 index 0000000..37e2f96 --- /dev/null +++ b/src/util/WsStore.ts @@ -0,0 +1,124 @@ +import { WsConnectionState } from '../websocket-client'; +import { DefaultLogger } from '../logger'; + +import WebSocket from 'isomorphic-ws'; + +type WsTopicList = Set; +type KeyedWsTopicLists = { + [key: string]: WsTopicList; +}; + +interface WsStoredState { + ws?: WebSocket; + connectionState?: WsConnectionState; + activePingTimer?: NodeJS.Timeout | undefined; + activePongTimer?: NodeJS.Timeout | undefined; + subscribedTopics: WsTopicList; +}; + +export default class WsStore { + private wsState: { + [key: string]: WsStoredState; + } + private logger: typeof DefaultLogger; + + constructor(logger: typeof DefaultLogger) { + this.logger = logger || DefaultLogger; + this.wsState = {}; + } + + get(key: string, createIfMissing?: boolean): WsStoredState | undefined { + if (this.wsState[key]) { + return this.wsState[key]; + } + + if (createIfMissing) { + return this.create(key); + } + + return undefined; + } + + getKeys(): string[] { + return Object.keys(this.wsState); + } + + create(key: string): WsStoredState | undefined { + if (this.hasExistingActiveConnection(key)) { + this.logger.warning('WsStore setConnection() overwriting existing open connection: ', this.getWs(key)); + } + this.wsState[key] = { + subscribedTopics: new Set(), + connectionState: WsConnectionState.READY_STATE_INITIAL + }; + return this.get(key); + } + + delete(key: string) { + if (this.hasExistingActiveConnection(key)) { + const ws = this.getWs(key); + this.logger.warning('WsStore deleting state for connection still open: ', ws); + ws?.close(); + } + delete this.wsState[key]; + } + + /* connection websocket */ + + hasExistingActiveConnection(key) { + return this.get(key) && this.isWsOpen(key); + } + + getWs(key: string): WebSocket | undefined { + return this.get(key)?.ws; + } + + setWs(key: string, wsConnection: WebSocket): WebSocket { + if (this.isWsOpen(key)) { + this.logger.warning('WsStore setConnection() overwriting existing open connection: ', this.getWs(key)); + } + this.get(key, true)!.ws = wsConnection; + return wsConnection; + } + + /* connection state */ + + isWsOpen(key: string): boolean { + const existingConnection = this.getWs(key); + return !!existingConnection && existingConnection.readyState === existingConnection.OPEN; + } + + getConnectionState(key: string): WsConnectionState { + return this.get(key, true)!.connectionState!; + } + + setConnectionState(key: string, state: WsConnectionState) { + this.get(key, true)!.connectionState = state; + } + + isConnectionState(key: string, state: WsConnectionState): boolean { + return this.getConnectionState(key) === state; + } + + /* subscribed topics */ + + getTopics(key: string): WsTopicList { + return this.get(key, true)!.subscribedTopics; + } + + getTopicsByKey(): KeyedWsTopicLists { + const result = {}; + for (const refKey in this.wsState) { + result[refKey] = this.getTopics(refKey); + } + return result; + } + + addTopic(key: string, topic: string) { + return this.getTopics(key).add(topic); + } + + deleteTopic(key: string, topic: string) { + return this.getTopics(key).delete(topic); + } +} \ No newline at end of file diff --git a/src/util/requestUtils.ts b/src/util/requestUtils.ts index b5f5761..903a7a2 100644 --- a/src/util/requestUtils.ts +++ b/src/util/requestUtils.ts @@ -1,6 +1,6 @@ import { createHmac } from 'crypto'; -export interface RestClientInverseOptions { +export interface RestClientOptions { // override the max size of the request window (in ms) recv_window?: number; @@ -42,7 +42,7 @@ export function serializeParams(params: object = {}, strict_validation = false): .join('&'); }; -export function getBaseRESTInverseUrl(useLivenet?: boolean, restInverseOptions?: RestClientInverseOptions) { +export function getRestBaseUrl(useLivenet?: boolean, restInverseOptions?: RestClientOptions) { const baseUrlsInverse = { livenet: 'https://api.bybit.com', testnet: 'https://api-testnet.bybit.com' @@ -56,4 +56,23 @@ export function getBaseRESTInverseUrl(useLivenet?: boolean, restInverseOptions?: return baseUrlsInverse.livenet; } return baseUrlsInverse.testnet; +} + +export function isPublicEndpoint (endpoint: string): boolean { + if (endpoint.startsWith('v2/public')) { + return true; + } + if (endpoint.startsWith('public/linear')) { + return true; + } + return false; +} + +export function isWsPong(response: any) { + return ( + response.request && + response.request.op === 'ping' && + response.ret_msg === 'pong' && + response.success === true + ); } \ No newline at end of file diff --git a/src/util/requestWrapper.ts b/src/util/requestWrapper.ts index cf5c71c..e19f8f3 100644 --- a/src/util/requestWrapper.ts +++ b/src/util/requestWrapper.ts @@ -1,11 +1,11 @@ import axios, { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; -import { signMessage, serializeParams, RestClientInverseOptions, GenericAPIResponse } from './requestUtils'; +import { signMessage, serializeParams, RestClientOptions, GenericAPIResponse, isPublicEndpoint } from './requestUtils'; export default class RequestUtil { private timeOffset: number | null; private syncTimePromise: null | Promise; - private options: RestClientInverseOptions; + private options: RestClientOptions; private baseUrl: string; private globalRequestOptions: AxiosRequestConfig; private key: string | undefined; @@ -15,7 +15,7 @@ export default class RequestUtil { key: string | undefined, secret: string | undefined, baseUrl: string, - options: RestClientInverseOptions = {}, + options: RestClientOptions = {}, requestOptions: AxiosRequestConfig = {} ) { this.timeOffset = null; @@ -68,9 +68,7 @@ export default class RequestUtil { * @private Make a HTTP request to a specific endpoint. Private endpoints are automatically signed. */ async _call(method: Method, endpoint: string, params?: any): GenericAPIResponse { - const publicEndpoint = endpoint.startsWith('v2/public'); - - if (!publicEndpoint) { + if (!isPublicEndpoint(endpoint)) { if (!this.key || !this.secret) { throw new Error('Private endpoints require api and private keys set'); } diff --git a/src/websocket-client.ts b/src/websocket-client.ts index 8b80c80..7e23e42 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -1,27 +1,51 @@ import { EventEmitter } from 'events'; - -import { RestClient } from './rest-client'; +import { InverseClient } from './inverse-client'; +import { LinearClient } from './linear-client'; import { DefaultLogger } from './logger'; -import { signMessage, serializeParams } from './util/requestUtils'; -// import WebSocket from 'ws'; -import WebSocket from 'isomorphic-ws'; +import { signMessage, serializeParams, isWsPong } from './util/requestUtils'; -const wsUrls = { +import WebSocket from 'isomorphic-ws'; +import WsStore from './util/WsStore'; + +const inverseEndpoints = { livenet: 'wss://stream.bybit.com/realtime', testnet: 'wss://stream-testnet.bybit.com/realtime' }; +const linearEndpoints = { + private: { + livenet: 'wss://stream.bybit.com/realtime_private', + livenet2: 'wss://stream.bytick.com/realtime_public', + testnet: 'wss://stream-testnet.bybit.com/realtime_private' + }, + public: { + livenet: 'wss://stream.bybit.com/realtime_public', + livenet2: 'wss://stream.bytick.com/realtime_private', + testnet: 'wss://stream-testnet.bybit.com/realtime_public' + } +}; + +const loggerCategory = { category: 'bybit-ws' }; + const READY_STATE_INITIAL = 0; const READY_STATE_CONNECTING = 1; const READY_STATE_CONNECTED = 2; const READY_STATE_CLOSING = 3; const READY_STATE_RECONNECTING = 4; -export interface WebsocketClientOptions { +export enum WsConnectionState { + READY_STATE_INITIAL, + READY_STATE_CONNECTING, + READY_STATE_CONNECTED, + READY_STATE_CLOSING, + READY_STATE_RECONNECTING +}; + +export interface WSClientConfigurableOptions { key?: string; secret?: string; livenet?: boolean; - + linear?: boolean; pongTimeout?: number; pingInterval?: number; reconnectTimeout?: number; @@ -30,231 +54,400 @@ export interface WebsocketClientOptions { wsUrl?: string; }; -type Logger = typeof DefaultLogger; +export interface WebsocketClientOptions extends WSClientConfigurableOptions { + livenet: boolean; + linear: boolean; + pongTimeout: number; + pingInterval: number; + reconnectTimeout: number; +}; + +export const wsKeyInverse = 'inverse'; +export const wsKeyLinearPrivate = 'linearPrivate'; +export const wsKeyLinearPublic = 'linearPublic'; + +const getLinearWsKeyForTopic = (topic: string) => { + const privateLinearTopics = ['position', 'execution', 'order', 'stop_order', 'wallet']; + if (privateLinearTopics.includes(topic)) { + return wsKeyLinearPrivate; + } + + return wsKeyLinearPublic; +} + +export declare interface WebsocketClient { + on(event: 'open' | 'reconnected', listener: ({ wsKey: string, event: any }) => void): this; + on(event: 'response' | 'update' | 'error', listener: (response: any) => void): this; + on(event: 'reconnect' | 'close', listener: () => void): this; +} export class WebsocketClient extends EventEmitter { - private logger: Logger; - private readyState: number; - private pingInterval?: number | undefined; - private pongTimeout?: number | undefined; - private client: RestClient; - private _subscriptions: Set; - private ws: WebSocket; + private logger: typeof DefaultLogger; + private restClient: InverseClient | LinearClient; private options: WebsocketClientOptions; + private wsStore: WsStore; - constructor(options: WebsocketClientOptions, logger?: Logger) { + constructor(options: WSClientConfigurableOptions, logger?: typeof DefaultLogger) { super(); this.logger = logger || DefaultLogger; - - this.readyState = READY_STATE_INITIAL; - this.pingInterval = undefined; - this.pongTimeout = undefined; + this.wsStore = new WsStore(this.logger); this.options = { livenet: false, + linear: false, pongTimeout: 1000, pingInterval: 10000, reconnectTimeout: 500, ...options }; - this.client = new RestClient(undefined, undefined, this.options.livenet, this.options.restOptions, this.options.requestOptions); - this._subscriptions = new Set(); - - this._connect(); - } - - subscribe(topics) { - if (!Array.isArray(topics)) topics = [topics]; - topics.forEach(topic => this._subscriptions.add(topic)); - - // subscribe not necessary if not yet connected (will subscribe onOpen) - if (this.readyState === READY_STATE_CONNECTED) this._subscribe(topics); - } - - unsubscribe(topics) { - if (!Array.isArray(topics)) topics = [topics]; - - topics.forEach(topic => this._subscriptions.delete(topic)); - - // unsubscribe not necessary if not yet connected - if (this.readyState === READY_STATE_CONNECTED) this._unsubscribe(topics); - } - - close() { - this.logger.info('Closing connection', {category: 'bybit-ws'}); - this.readyState = READY_STATE_CLOSING; - this._teardown(); - this.ws && this.ws.close(); - } - - _getWsUrl() { - if (this.options.wsUrl) { - return this.options.wsUrl; + if (this.isLinear()) { + this.restClient = new LinearClient(undefined, undefined, this.isLivenet(), this.options.restOptions, this.options.requestOptions); + } else { + this.restClient = new InverseClient(undefined, undefined, this.isLivenet(), this.options.restOptions, this.options.requestOptions); } - return wsUrls[this.options.livenet ? 'livenet' : 'testnet']; } - async _connect() { + public isLivenet(): boolean { + return this.options.livenet === true; + } + + public isLinear(): boolean { + return this.options.linear === true; + } + + public isInverse(): boolean { + return !this.isLinear(); + } + + /** + * Add topic/topics to WS subscription list + */ + public subscribe(wsTopics: string[] | string) { + const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; + topics.forEach(topic => this.wsStore.addTopic( + this.getWsKeyForTopic(topic), + topic + )); + + // attempt to send subscription topic per websocket + this.wsStore.getKeys().forEach(wsKey => { + // if connected, send subscription request + if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTED)) { + return this.requestSubscribeTopics(wsKey, [...this.wsStore.getTopics(wsKey)]); + } + + // start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect + if ( + !this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTING) && + !this.wsStore.isConnectionState(wsKey, READY_STATE_RECONNECTING) + ) { + return this.connect(wsKey); + } + }); + } + + /** + * Remove topic/topics from WS subscription list + */ + public unsubscribe(wsTopics: string[] | string) { + const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; + topics.forEach(topic => this.wsStore.deleteTopic( + this.getWsKeyForTopic(topic), + topic + )); + + this.wsStore.getKeys().forEach(wsKey => { + // unsubscribe request only necessary if active connection exists + if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTED)) { + this.requestUnsubscribeTopics(wsKey, [...this.wsStore.getTopics(wsKey)]) + } + }); + } + + public close(wsKey: string) { + this.logger.info('Closing connection', { ...loggerCategory, wsKey }); + this.setWsState(wsKey, READY_STATE_CLOSING); + this.clearTimers(wsKey); + + this.getWs(wsKey)?.close(); + } + + /** + * Request connection of all dependent websockets, instead of waiting for automatic connection by library + */ + public connectAll(): Promise[] | undefined { + if (this.isInverse()) { + return [this.connect(wsKeyInverse)]; + } + + if (this.isLinear()) { + return [this.connect(wsKeyLinearPublic), this.connect(wsKeyLinearPrivate)]; + } + } + + private async connect(wsKey: string): Promise { try { - if (this.readyState === READY_STATE_INITIAL) this.readyState = READY_STATE_CONNECTING; + if (this.wsStore.isWsOpen(wsKey)) { + this.logger.error('Refused to connect to ws with existing active connection', { ...loggerCategory, wsKey }) + return this.wsStore.getWs(wsKey); + } - const authParams = await this._authenticate(); - const url = this._getWsUrl() + authParams; + if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTING)) { + this.logger.error('Refused to connect to ws, connection attempt already active', { ...loggerCategory, wsKey }) + return; + } - const ws = new WebSocket(url); + if ( + !this.wsStore.getConnectionState(wsKey) || + this.wsStore.isConnectionState(wsKey, READY_STATE_INITIAL) + ) { + this.setWsState(wsKey, READY_STATE_CONNECTING); + } - ws.onopen = this._wsOpenHandler.bind(this); - ws.onmessage = this._wsMessageHandler.bind(this); - ws.onerror = this._wsOnErrorHandler.bind(this); - ws.onclose = this._wsCloseHandler.bind(this); - - this.ws = ws; + const authParams = await this.getAuthParams(wsKey); + const url = this.getWsUrl(wsKey) + authParams; + const ws = this.connectToWsUrl(url, wsKey); + return this.wsStore.setWs(wsKey, ws); } catch (err) { - this.logger.error('Connection failed: ', err); - this._reconnect(this.options.reconnectTimeout); + this.parseWsError('Connection failed', err, wsKey); + this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!); } } - async _authenticate() { - if (this.options.key && this.options.secret) { - this.logger.debug('Starting authenticated websocket client.', {category: 'bybit-ws'}); + private parseWsError(context: string, error, wsKey: string) { + if (!error.message) { + this.logger.error(`${context} due to unexpected error: `, error); + return; + } - const timeOffset = await this.client.getTimeOffset(); + switch (error.message) { + case 'Unexpected server response: 401': + this.logger.error(`${context} due to 401 authorization failure.`, { ...loggerCategory, wsKey }); + break; + + default: + this.logger.error(`{context} due to unexpected response error: ${error.msg}`, { ...loggerCategory, wsKey }); + break; + } + } + + /** + * Return params required to make authorized request + */ + private async getAuthParams(wsKey: string): Promise { + const { key, secret } = this.options; + + if (key && secret && wsKey !== wsKeyLinearPublic) { + this.logger.debug('Getting auth\'d request params', { ...loggerCategory, wsKey }); + + const timeOffset = await this.restClient.getTimeOffset(); const params: any = { api_key: this.options.key, expires: (Date.now() + timeOffset + 5000) }; - params.signature = signMessage('GET/realtime' + params.expires, this.options.secret); + params.signature = signMessage('GET/realtime' + params.expires, secret); return '?' + serializeParams(params); - } else if (this.options.key || this.options.secret) { - this.logger.warning('Could not authenticate websocket, either api key or private key missing.', { category: 'bybit-ws' }); + } else if (!key || !secret) { + this.logger.warning('Connot authenticate websocket, either api or private keys missing.', { ...loggerCategory, wsKey }); } else { - this.logger.debug('Starting public only websocket client.', { category: 'bybit-ws' }); + this.logger.debug('Starting public only websocket client.', { ...loggerCategory, wsKey }); } - return ''; + return ''; } - _reconnect(timeout) { - this._teardown(); - if (this.readyState !== READY_STATE_CONNECTING) { - this.readyState = READY_STATE_RECONNECTING; + private reconnectWithDelay(wsKey: string, connectionDelayMs: number) { + this.clearTimers(wsKey); + if (this.wsStore.getConnectionState(wsKey) !== READY_STATE_CONNECTING) { + this.setWsState(wsKey, READY_STATE_RECONNECTING); } setTimeout(() => { - this.logger.info('Reconnecting to server', { category: 'bybit-ws' }); - - this._connect(); - }, timeout); + this.logger.info('Reconnecting to websocket', { ...loggerCategory, wsKey }); + this.connect(wsKey); + }, connectionDelayMs); } - _ping() { - clearTimeout(this.pongTimeout!); - delete this.pongTimeout; + private ping(wsKey: string) { + this.clearPongTimer(wsKey); - this.logger.silly('Sending ping', { category: 'bybit-ws' }); - this.ws.send(JSON.stringify({op: 'ping'})); + this.logger.silly('Sending ping', { ...loggerCategory, wsKey }); + this.tryWsSend(wsKey, JSON.stringify({ op: 'ping' })); - this.pongTimeout = setTimeout(() => { - this.logger.info('Pong timeout', { category: 'bybit-ws' }); - this._teardown(); - // this.ws.terminate(); - // TODO: does this work? - this.ws.close(); + this.wsStore.get(wsKey, true)!.activePongTimer = setTimeout(() => { + this.logger.info('Pong timeout - closing socket to reconnect', { ...loggerCategory, wsKey }); + this.getWs(wsKey)?.close(); }, this.options.pongTimeout); } - _teardown() { - if (this.pingInterval) clearInterval(this.pingInterval); - if (this.pongTimeout) clearTimeout(this.pongTimeout); - - this.pongTimeout = undefined; - this.pingInterval = undefined; + private clearTimers(wsKey: string) { + this.clearPingTimer(wsKey); + this.clearPongTimer(wsKey); } - _wsOpenHandler() { - if (this.readyState === READY_STATE_CONNECTING) { - this.logger.info('Websocket connected', { category: 'bybit-ws', livenet: this.options.livenet }); - this.emit('open'); - } else if (this.readyState === READY_STATE_RECONNECTING) { - this.logger.info('Websocket reconnected', { category: 'bybit-ws', livenet: this.options.livenet }); - this.emit('reconnected'); + // Send a ping at intervals + private clearPingTimer(wsKey: string) { + const wsState = this.wsStore.get(wsKey); + if (wsState?.activePingTimer) { + clearInterval(wsState.activePingTimer); + wsState.activePingTimer = undefined; + } + } + + // Expect a pong within a time limit + private clearPongTimer(wsKey: string) { + const wsState = this.wsStore.get(wsKey); + if (wsState?.activePongTimer) { + clearTimeout(wsState.activePongTimer); + wsState.activePongTimer = undefined; + } + } + + /** + * Send WS message to subscribe to topics. + */ + private requestSubscribeTopics(wsKey: string, topics: string[]) { + const wsMessage = JSON.stringify({ + op: 'subscribe', + args: topics + }); + + this.tryWsSend(wsKey, wsMessage); + } + + /** + * Send WS message to unsubscribe from topics. + */ + private requestUnsubscribeTopics(wsKey: string, topics: string[]) { + const wsMessage = JSON.stringify({ + op: 'unsubscribe', + args: topics + }); + + this.tryWsSend(wsKey, wsMessage); + } + + private tryWsSend(wsKey: string, wsMessage: string) { + try { + this.logger.silly(`Sending upstream ws message: `, { ...loggerCategory, wsMessage, wsKey }); + if (!wsKey) { + throw new Error('Cannot send message due to no known websocket for this wsKey'); + } + this.getWs(wsKey)?.send(wsMessage); + } catch (e) { + this.logger.error(`Failed to send WS message`, { ...loggerCategory, wsMessage, wsKey, exception: e }); + } + } + + private connectToWsUrl(url: string, wsKey: string): WebSocket { + this.logger.silly(`Opening WS connection to URL: ${url}`, { ...loggerCategory, wsKey }) + + const ws = new WebSocket(url); + ws.onopen = event => this.onWsOpen(event, wsKey); + ws.onmessage = event => this.onWsMessage(event, wsKey); + ws.onerror = event => this.onWsError(event, wsKey); + ws.onclose = event => this.onWsClose(event, wsKey); + + return ws; + } + + private onWsOpen(event, wsKey: string) { + if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTING)) { + this.logger.info('Websocket connected', { ...loggerCategory, wsKey, livenet: this.isLivenet(), linear: this.isLinear() }); + this.emit('open', { wsKey, event }); + } else if (this.wsStore.isConnectionState(wsKey, READY_STATE_RECONNECTING)) { + this.logger.info('Websocket reconnected', { ...loggerCategory, wsKey }); + this.emit('reconnected', { wsKey, event }); } - this.readyState = READY_STATE_CONNECTED; + this.setWsState(wsKey, READY_STATE_CONNECTED); - this._subscribe([...this._subscriptions]); - this.pingInterval = setInterval(this._ping.bind(this), this.options.pingInterval); + this.requestSubscribeTopics(wsKey, [...this.wsStore.getTopics(wsKey)]); + + this.wsStore.get(wsKey, true)!.activePingTimer = setInterval( + () => this.ping(wsKey), + this.options.pingInterval + ); } - _wsMessageHandler(message) { - const msg = JSON.parse(message && message.data || message); + private onWsMessage(event, wsKey: string) { + const msg = JSON.parse(event && event.data || event); if ('success' in msg) { - this._handleResponse(msg); + this.onWsMessageResponse(msg, wsKey); } else if (msg.topic) { - this._handleUpdate(msg); + this.onWsMessageUpdate(msg); } else { - this.logger.warning('Got unhandled ws message', msg); + this.logger.warning('Got unhandled ws message', { ...loggerCategory, message: msg, event, wsKey}); } } - _wsOnErrorHandler(err) { - this.logger.error('Websocket error', {category: 'bybit-ws', err}); - if (this.readyState === READY_STATE_CONNECTED) this.emit('error', err); + private onWsError(err, wsKey: string) { + this.parseWsError('Websocket error', err, wsKey); + if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTED)) { + this.emit('error', err); + } } - _wsCloseHandler() { - this.logger.info('Websocket connection closed', {category: 'bybit-ws'}); + private onWsClose(event, wsKey: string) { + this.logger.info('Websocket connection closed', { ...loggerCategory, wsKey}); - if (this.readyState !== READY_STATE_CLOSING) { - this._reconnect(this.options.reconnectTimeout); + if (this.wsStore.getConnectionState(wsKey) !== READY_STATE_CLOSING) { + this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!); this.emit('reconnect'); } else { - this.readyState = READY_STATE_INITIAL; + this.setWsState(wsKey, READY_STATE_INITIAL); this.emit('close'); } } - _handleResponse(response) { - if ( - response.request && - response.request.op === 'ping' && - response.ret_msg === 'pong' && - response.success === true - ) { - this.logger.silly('pong recieved', {category: 'bybit-ws'}); - clearTimeout(this.pongTimeout); + private onWsMessageResponse(response: any, wsKey: string) { + if (isWsPong(response)) { + this.logger.silly('Received pong', { ...loggerCategory, wsKey }); + this.clearPongTimer(wsKey); } else { this.emit('response', response); } } - _handleUpdate(message) { + private onWsMessageUpdate(message: any) { this.emit('update', message); } - _subscribe(topics) { - const msgStr = JSON.stringify({ - op: 'subscribe', - 'args': topics - }); - - this.ws.send(msgStr); + private getWs(wsKey: string) { + return this.wsStore.getWs(wsKey); } - _unsubscribe(topics) { - const msgStr = JSON.stringify({ - op: 'unsubscribe', - 'args': topics - }); + private setWsState(wsKey: string, state: WsConnectionState) { + this.wsStore.setConnectionState(wsKey, state); + } - this.ws.send(msgStr); + private getWsUrl(wsKey: string): string { + if (this.options.wsUrl) { + return this.options.wsUrl; + } + + const networkKey = this.options.livenet ? 'livenet' : 'testnet'; + if (this.isLinear() || wsKey.startsWith('linear')){ + if (wsKey === wsKeyLinearPublic) { + return linearEndpoints.public[networkKey]; + } + if (wsKey === wsKeyLinearPrivate) { + return linearEndpoints.private[networkKey]; + } + this.logger.error('Unhandled linear wsKey: ', { ...loggerCategory, wsKey }); + return linearEndpoints[networkKey]; + } + return inverseEndpoints[networkKey]; + } + + private getWsKeyForTopic(topic: string) { + return this.isInverse() ? wsKeyInverse : getLinearWsKeyForTopic(topic); } };