Merge pull request #75 from peepopoggers/master

Support for Linear (USDT) on the updated typescript version.
This commit is contained in:
Tiago
2021-02-16 08:34:25 +00:00
committed by GitHub
12 changed files with 1147 additions and 390 deletions

View File

@@ -42,6 +42,8 @@ jobs:
- run: npm ci - run: npm ci
if: steps.version-updated.outputs.has-updated if: steps.version-updated.outputs.has-updated
- run: npm run clean
if: steps.version-updated.outputs.has-updated
- run: npm run build - run: npm run build
if: steps.version-updated.outputs.has-updated if: steps.version-updated.outputs.has-updated
- run: npm publish - run: npm publish

127
README.md
View File

@@ -13,11 +13,12 @@ A production-ready Node.js connector for the Bybit APIs and WebSockets, with Typ
## Issues & Discussion ## Issues & Discussion
- Issues? Check the [issues tab](https://github.com/tiagosiebler/bybit-api/issues). - 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. - 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 ## Documentation
Most methods accept JS objects. These can be populated using parameters specified by Bybit's API 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 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 ## Structure
This project uses typescript. Resources are stored in 3 key structures: 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. The bundle can be found in `dist/`. Altough usage should be largely consistent, smaller differences will exist. Documentation is still TODO.
### Inverse Contracts ### Inverse Contracts
#### Rest client 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`:
```javascript
const { RestClient } = require('bybit-api');
const API_KEY = 'xxx'; ```javascript
const PRIVATE_KEY = 'yyy'; const { InverseClient } = require('bybit-api');
const useLivenet = false;
const restInverseOptions = { const restInverseOptions = {
// override the max size of the request window (in ms) // override the max size of the request window (in ms)
@@ -68,7 +66,11 @@ const restInverseOptions = {
parse_exceptions?: boolean; parse_exceptions?: boolean;
}; };
const client = new RestClient( const API_KEY = 'xxx';
const PRIVATE_KEY = 'yyy';
const useLivenet = false;
const client = new InverseClient(
API_KEY, API_KEY,
PRIVATE_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 ```javascript
const { WebsocketClient } = require('bybit-api'); const { WebsocketClient } = require('bybit-api');
@@ -101,67 +161,84 @@ const wsConfig = {
key: API_KEY, key: API_KEY,
secret: PRIVATE_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 // livenet: true
// override which URL to use for websocket connections // defaults to fase == inverse. Set to true for linear (USDT) trading.
// wsUrl: 'wss://stream.bytick.com/realtime' // linear: true
// how often to check (in ms) that WS connection is still alive
// pingInterval: 10000,
// how long to wait (in ms) before deciding the connection should be terminated & reconnected // how long to wait (in ms) before deciding the connection should be terminated & reconnected
// pongTimeout: 1000, // 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 // how long to wait before attempting to reconnect (in ms) after connection is closed
// reconnectTimeout: 500, // reconnectTimeout: 500,
// config options sent to RestClient (used for time sync). See RestClient docs. // config options sent to RestClient (used for time sync). See RestClient docs.
// restOptions: { }, // 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: { } // requestOptions: { }
// override which URL to use for websocket connections
// wsUrl: 'wss://stream.bytick.com/realtime'
}; };
const ws = new WebsocketClient(wsConfig); const ws = new WebsocketClient(wsConfig);
// subscribe to multiple topics at once
ws.subscribe(['position', 'execution', 'trade']); ws.subscribe(['position', 'execution', 'trade']);
// and/or subscribe to individual topics on demand
ws.subscribe('kline.BTCUSD.1m'); ws.subscribe('kline.BTCUSD.1m');
ws.on('open', () => { // Listen to events coming from websockets. This is the primary data source
console.log('connection open'); ws.on('update', data => {
console.log('update', data);
}); });
ws.on('update', message => { // Optional: Listen to websocket connection open event (automatic after subscribing to one or more topics)
console.log('update', message); 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 => { ws.on('response', response => {
console.log('response', response); console.log('response', response);
}); });
// Optional: Listen to connection close event. Unexpected connection closes are automatically reconnected.
ws.on('close', () => { ws.on('close', () => {
console.log('connection closed'); 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 => { ws.on('error', err => {
console.error('ERR', 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 ### 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: 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 ```js
const { RestClient, WebsocketClient, DefaultLogger } = require('bybit-api'); const { WebsocketClient, DefaultLogger } = require('bybit-api');
// Disable all logging on the silly level // Disable all logging on the silly level
DefaultLogger.silly = () => {}; DefaultLogger.silly = () => {};
const ws = new WebsocketClient({key: 'xxx', secret: 'yyy'}, DefaultLogger); const ws = new WebsocketClient(
{ key: 'xxx', secret: 'yyy' },
DefaultLogger
);
``` ```
## Contributions & Thanks ## Contributions & Thanks

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{ {
"name": "bybit-api", "name": "bybit-api",
"version": "1.3.1", "version": "1.3.2",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View File

@@ -11,10 +11,11 @@
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
"clean": "rm -rf lib dist", "clean": "rm -rf lib dist",
"prebuild": "npm run clean",
"build": "tsc", "build": "tsc",
"build:clean": "npm run clean && npm run build",
"build:watch": "npm run clean && tsc --watch",
"pack": "webpack --config webpack/webpack.config.js", "pack": "webpack --config webpack/webpack.config.js",
"prepublish": "npm run build", "prepublish": "npm run build:clean",
"betapublish": "npm publish --tag beta" "betapublish": "npm publish --tag beta"
}, },
"author": "Tiago Siebler (https://github.com/tiagosiebler)", "author": "Tiago Siebler (https://github.com/tiagosiebler)",

View File

@@ -1,3 +1,4 @@
export * from './rest-client'; export * from './inverse-client';
export * from './linear-client';
export * from './websocket-client'; export * from './websocket-client';
export * from './logger'; export * from './logger';

View File

@@ -1,9 +1,10 @@
import { AxiosRequestConfig } from 'axios'; import { AxiosRequestConfig } from 'axios';
import { GenericAPIResponse, getBaseRESTInverseUrl, RestClientInverseOptions } from './util/requestUtils'; import { GenericAPIResponse, getRestBaseUrl, RestClientOptions } from './util/requestUtils';
import RequestWrapper from './util/requestWrapper'; import RequestWrapper from './util/requestWrapper';
import SharedEndpoints from './shared-endpoints';
export class RestClient { export class InverseClient extends SharedEndpoints {
private requestWrapper: RequestWrapper; protected requestWrapper: RequestWrapper;
/** /**
* @public Creates an instance of the inverse REST API client. * @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} key - your API key
* @param {string} secret - your API secret * @param {string} secret - your API secret
* @param {boolean} [useLivenet=false] * @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 * @param {AxiosRequestConfig} [requestOptions={}] HTTP networking options for axios
*/ */
constructor( constructor(
key?: string | undefined, key?: string | undefined,
secret?: string | undefined, secret?: string | undefined,
useLivenet?: boolean, useLivenet?: boolean,
restInverseOptions: RestClientInverseOptions = {}, restInverseOptions: RestClientOptions = {},
httpOptions: AxiosRequestConfig = {} requestOptions: AxiosRequestConfig = {}
) { ) {
super()
this.requestWrapper = new RequestWrapper( this.requestWrapper = new RequestWrapper(
key, key,
secret, secret,
getBaseRESTInverseUrl(useLivenet), getRestBaseUrl(useLivenet),
restInverseOptions, restInverseOptions,
httpOptions requestOptions
); );
return this; 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: { getKline(params: {
symbol: string; symbol: string;
interval: string; interval: string;
@@ -57,15 +53,9 @@ export class RestClient {
*/ */
getLatestInformation(params?: { getLatestInformation(params?: {
symbol?: string; symbol?: string;
}): GenericAPIResponse { }): GenericAPIResponse {
return this.getTickers(params); return this.getTickers(params);
} }
getTickers(params?: {
symbol?: string;
}): GenericAPIResponse {
return this.requestWrapper.get('v2/public/tickers', params);
}
/** /**
* @deprecated use getTrades() instead * @deprecated use getTrades() instead
@@ -86,10 +76,6 @@ export class RestClient {
return this.requestWrapper.get('v2/public/trading-records', params); return this.requestWrapper.get('v2/public/trading-records', params);
} }
getSymbols(): GenericAPIResponse {
return this.requestWrapper.get('v2/public/symbols');
}
/** /**
* @deprecated use getLiquidations() instead * @deprecated use getLiquidations() instead
*/ */
@@ -103,16 +89,6 @@ export class RestClient {
return this.getLiquidations(params); 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: { getMarkPriceKline(params: {
symbol: string; symbol: string;
interval: string; interval: string;
@@ -122,33 +98,32 @@ export class RestClient {
return this.requestWrapper.get('v2/public/mark-price-kline', params); return this.requestWrapper.get('v2/public/mark-price-kline', params);
} }
getOpenInterest(params: { getIndexPriceKline(params: {
symbol: string; symbol: string;
period: string; interval: string;
from: number;
limit?: number; limit?: number;
}): GenericAPIResponse { }): 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; symbol: string;
interval: string;
from: number;
limit?: number; limit?: number;
}): GenericAPIResponse { }): GenericAPIResponse {
return this.requestWrapper.get('v2/public/big-deal', params); return this.requestWrapper.get('v2/public/premium-index-kline', params);
}
getLongShortRatio(params: {
symbol: string;
period: string;
limit?: number;
}): GenericAPIResponse {
return this.requestWrapper.get('v2/public/account-ratio', params);
} }
/** /**
* *
* Account Data Endpoints * Account Data Endpoints
* *
*/
/**
* Active orders
*/ */
placeActiveOrder(orderRequest: { placeActiveOrder(orderRequest: {
@@ -164,9 +139,6 @@ export class RestClient {
close_on_trigger?: boolean; close_on_trigger?: boolean;
order_link_id?: string; order_link_id?: string;
}): GenericAPIResponse { }): 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); 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); 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: { cancelActiveOrder(params: {
symbol: string; symbol: string;
order_id?: string; order_id?: string;
order_link_id?: string; order_link_id?: string;
}): GenericAPIResponse { }): 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); return this.requestWrapper.post('v2/private/order/cancel', params);
} }
@@ -219,33 +173,21 @@ export class RestClient {
p_r_qty?: string; p_r_qty?: string;
p_r_price?: string; p_r_price?: string;
}): GenericAPIResponse { }): 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); 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: { queryActiveOrder(params: {
order_id?: string; order_id?: string;
order_link_id?: string; order_link_id?: string;
symbol: string; symbol: string;
}): GenericAPIResponse { }): 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); return this.requestWrapper.get('v2/private/order', params);
} }
/**
* Conditional orders
*/
placeConditionalOrder(params: { placeConditionalOrder(params: {
side: string; side: string;
symbol: string; symbol: string;
@@ -259,22 +201,9 @@ export class RestClient {
close_on_trigger?: boolean; close_on_trigger?: boolean;
order_link_id?: string; order_link_id?: string;
}): GenericAPIResponse { }): 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); 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: { getConditionalOrder(params: {
symbol: string; symbol: string;
stop_order_status?: string; stop_order_status?: string;
@@ -285,34 +214,14 @@ export class RestClient {
return this.requestWrapper.get('v2/private/stop-order/list', params); 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: { cancelConditionalOrder(params: {
symbol: string; symbol: string;
stop_order_id?: string; stop_order_id?: string;
order_link_id?: string; order_link_id?: string;
}): GenericAPIResponse { }): 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); 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: { cancelAllConditionalOrders(params: {
symbol: string; symbol: string;
}): GenericAPIResponse { }): GenericAPIResponse {
@@ -327,30 +236,21 @@ export class RestClient {
p_r_price?: string; p_r_price?: string;
p_r_trigger_price?: string; p_r_trigger_price?: string;
}): GenericAPIResponse { }): 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); 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: { queryConditionalOrder(params: {
symbol: string; symbol: string;
stop_order_id?: string; stop_order_id?: string;
order_link_id?: string; order_link_id?: string;
}): GenericAPIResponse { }): 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); return this.requestWrapper.get('v2/private/stop-order', params);
} }
/**
* Position
*/
/** /**
* @deprecated use getPosition() instead * @deprecated use getPosition() instead
*/ */
@@ -386,14 +286,14 @@ export class RestClient {
sl_trigger_by?: string; sl_trigger_by?: string;
new_trailing_active?: number; new_trailing_active?: number;
}): GenericAPIResponse { }): GenericAPIResponse {
return this.requestWrapper.post('open-api/position/trading-stop', params); return this.requestWrapper.post('v2/private/position/trading-stop', params);
} }
setUserLeverage(params: { setUserLeverage(params: {
symbol: string; symbol: string;
leverage: number; leverage: number;
}): GenericAPIResponse { }): 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); return this.requestWrapper.get('v2/private/trade/closed-pnl/list', params);
} }
/**
* Risk Limit
*/
getRiskLimitList(): GenericAPIResponse { getRiskLimitList(): GenericAPIResponse {
return this.requestWrapper.get('open-api/wallet/risk-limit/list'); 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); return this.requestWrapper.post('open-api/wallet/risk-limit', params);
} }
/**
* Funding
*/
getLastFundingRate(params: { getLastFundingRate(params: {
symbol: string; symbol: string;
}): GenericAPIResponse { }): 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: { getMyLastFundingFee(params: {
symbol: string; symbol: string;
}): GenericAPIResponse { }): GenericAPIResponse {
return this.requestWrapper.get('open-api/funding/prev-funding', params); return this.requestWrapper.get('v2/private/funding/prev-funding', params);
} }
getPredictedFunding(params: { getPredictedFunding(params: {
symbol: string; symbol: string;
}): GenericAPIResponse { }): 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: { getLcpInfo(params: {
symbol: string; symbol: string;
}): GenericAPIResponse { }): GenericAPIResponse {
return this.requestWrapper.get('v2/private/account/lcp', params); 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<number> {
const start = Date.now();
return this.getServerTime().then(result => {
const end = Date.now();
return Math.ceil((result.time_now * 1000) - end + ((end - start) / 2));
});
}
}; };

356
src/linear-client.ts Normal file
View File

@@ -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);
}
}

143
src/shared-endpoints.ts Normal file
View File

@@ -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<number> {
const start = Date.now();
return this.getServerTime().then(result => {
const end = Date.now();
return Math.ceil((result.time_now * 1000) - end + ((end - start) / 2));
});
}
}

124
src/util/WsStore.ts Normal file
View File

@@ -0,0 +1,124 @@
import { WsConnectionState } from '../websocket-client';
import { DefaultLogger } from '../logger';
import WebSocket from 'isomorphic-ws';
type WsTopicList = Set<string>;
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);
}
}

View File

@@ -1,6 +1,6 @@
import { createHmac } from 'crypto'; import { createHmac } from 'crypto';
export interface RestClientInverseOptions { export interface RestClientOptions {
// override the max size of the request window (in ms) // override the max size of the request window (in ms)
recv_window?: number; recv_window?: number;
@@ -42,7 +42,7 @@ export function serializeParams(params: object = {}, strict_validation = false):
.join('&'); .join('&');
}; };
export function getBaseRESTInverseUrl(useLivenet?: boolean, restInverseOptions?: RestClientInverseOptions) { export function getRestBaseUrl(useLivenet?: boolean, restInverseOptions?: RestClientOptions) {
const baseUrlsInverse = { const baseUrlsInverse = {
livenet: 'https://api.bybit.com', livenet: 'https://api.bybit.com',
testnet: 'https://api-testnet.bybit.com' testnet: 'https://api-testnet.bybit.com'
@@ -56,4 +56,23 @@ export function getBaseRESTInverseUrl(useLivenet?: boolean, restInverseOptions?:
return baseUrlsInverse.livenet; return baseUrlsInverse.livenet;
} }
return baseUrlsInverse.testnet; 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
);
} }

View File

@@ -1,11 +1,11 @@
import axios, { AxiosRequestConfig, AxiosResponse, Method } from 'axios'; 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 { export default class RequestUtil {
private timeOffset: number | null; private timeOffset: number | null;
private syncTimePromise: null | Promise<any>; private syncTimePromise: null | Promise<any>;
private options: RestClientInverseOptions; private options: RestClientOptions;
private baseUrl: string; private baseUrl: string;
private globalRequestOptions: AxiosRequestConfig; private globalRequestOptions: AxiosRequestConfig;
private key: string | undefined; private key: string | undefined;
@@ -15,7 +15,7 @@ export default class RequestUtil {
key: string | undefined, key: string | undefined,
secret: string | undefined, secret: string | undefined,
baseUrl: string, baseUrl: string,
options: RestClientInverseOptions = {}, options: RestClientOptions = {},
requestOptions: AxiosRequestConfig = {} requestOptions: AxiosRequestConfig = {}
) { ) {
this.timeOffset = null; 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. * @private Make a HTTP request to a specific endpoint. Private endpoints are automatically signed.
*/ */
async _call(method: Method, endpoint: string, params?: any): GenericAPIResponse { async _call(method: Method, endpoint: string, params?: any): GenericAPIResponse {
const publicEndpoint = endpoint.startsWith('v2/public'); if (!isPublicEndpoint(endpoint)) {
if (!publicEndpoint) {
if (!this.key || !this.secret) { if (!this.key || !this.secret) {
throw new Error('Private endpoints require api and private keys set'); throw new Error('Private endpoints require api and private keys set');
} }

View File

@@ -1,27 +1,51 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { InverseClient } from './inverse-client';
import { RestClient } from './rest-client'; import { LinearClient } from './linear-client';
import { DefaultLogger } from './logger'; import { DefaultLogger } from './logger';
import { signMessage, serializeParams } from './util/requestUtils'; import { signMessage, serializeParams, isWsPong } from './util/requestUtils';
// import WebSocket from 'ws';
import WebSocket from 'isomorphic-ws';
const wsUrls = { import WebSocket from 'isomorphic-ws';
import WsStore from './util/WsStore';
const inverseEndpoints = {
livenet: 'wss://stream.bybit.com/realtime', livenet: 'wss://stream.bybit.com/realtime',
testnet: 'wss://stream-testnet.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_INITIAL = 0;
const READY_STATE_CONNECTING = 1; const READY_STATE_CONNECTING = 1;
const READY_STATE_CONNECTED = 2; const READY_STATE_CONNECTED = 2;
const READY_STATE_CLOSING = 3; const READY_STATE_CLOSING = 3;
const READY_STATE_RECONNECTING = 4; 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; key?: string;
secret?: string; secret?: string;
livenet?: boolean; livenet?: boolean;
linear?: boolean;
pongTimeout?: number; pongTimeout?: number;
pingInterval?: number; pingInterval?: number;
reconnectTimeout?: number; reconnectTimeout?: number;
@@ -30,231 +54,400 @@ export interface WebsocketClientOptions {
wsUrl?: string; 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 { export class WebsocketClient extends EventEmitter {
private logger: Logger; private logger: typeof DefaultLogger;
private readyState: number; private restClient: InverseClient | LinearClient;
private pingInterval?: number | undefined;
private pongTimeout?: number | undefined;
private client: RestClient;
private _subscriptions: Set<unknown>;
private ws: WebSocket;
private options: WebsocketClientOptions; private options: WebsocketClientOptions;
private wsStore: WsStore;
constructor(options: WebsocketClientOptions, logger?: Logger) { constructor(options: WSClientConfigurableOptions, logger?: typeof DefaultLogger) {
super(); super();
this.logger = logger || DefaultLogger; this.logger = logger || DefaultLogger;
this.wsStore = new WsStore(this.logger);
this.readyState = READY_STATE_INITIAL;
this.pingInterval = undefined;
this.pongTimeout = undefined;
this.options = { this.options = {
livenet: false, livenet: false,
linear: false,
pongTimeout: 1000, pongTimeout: 1000,
pingInterval: 10000, pingInterval: 10000,
reconnectTimeout: 500, reconnectTimeout: 500,
...options ...options
}; };
this.client = new RestClient(undefined, undefined, this.options.livenet, this.options.restOptions, this.options.requestOptions); if (this.isLinear()) {
this._subscriptions = new Set(); this.restClient = new LinearClient(undefined, undefined, this.isLivenet(), this.options.restOptions, this.options.requestOptions);
} else {
this._connect(); this.restClient = new InverseClient(undefined, undefined, this.isLivenet(), this.options.restOptions, this.options.requestOptions);
}
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;
} }
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<WebSocket | undefined>[] | undefined {
if (this.isInverse()) {
return [this.connect(wsKeyInverse)];
}
if (this.isLinear()) {
return [this.connect(wsKeyLinearPublic), this.connect(wsKeyLinearPrivate)];
}
}
private async connect(wsKey: string): Promise<WebSocket | undefined> {
try { 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(); if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTING)) {
const url = this._getWsUrl() + authParams; 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); const authParams = await this.getAuthParams(wsKey);
ws.onmessage = this._wsMessageHandler.bind(this); const url = this.getWsUrl(wsKey) + authParams;
ws.onerror = this._wsOnErrorHandler.bind(this); const ws = this.connectToWsUrl(url, wsKey);
ws.onclose = this._wsCloseHandler.bind(this);
this.ws = ws;
return this.wsStore.setWs(wsKey, ws);
} catch (err) { } catch (err) {
this.logger.error('Connection failed: ', err); this.parseWsError('Connection failed', err, wsKey);
this._reconnect(this.options.reconnectTimeout); this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!);
} }
} }
async _authenticate() { private parseWsError(context: string, error, wsKey: string) {
if (this.options.key && this.options.secret) { if (!error.message) {
this.logger.debug('Starting authenticated websocket client.', {category: 'bybit-ws'}); 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<string> {
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 = { const params: any = {
api_key: this.options.key, api_key: this.options.key,
expires: (Date.now() + timeOffset + 5000) 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); return '?' + serializeParams(params);
} else if (this.options.key || this.options.secret) { } else if (!key || !secret) {
this.logger.warning('Could not authenticate websocket, either api key or private key missing.', { category: 'bybit-ws' }); this.logger.warning('Connot authenticate websocket, either api or private keys missing.', { ...loggerCategory, wsKey });
} else { } 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) { private reconnectWithDelay(wsKey: string, connectionDelayMs: number) {
this._teardown(); this.clearTimers(wsKey);
if (this.readyState !== READY_STATE_CONNECTING) { if (this.wsStore.getConnectionState(wsKey) !== READY_STATE_CONNECTING) {
this.readyState = READY_STATE_RECONNECTING; this.setWsState(wsKey, READY_STATE_RECONNECTING);
} }
setTimeout(() => { setTimeout(() => {
this.logger.info('Reconnecting to server', { category: 'bybit-ws' }); this.logger.info('Reconnecting to websocket', { ...loggerCategory, wsKey });
this.connect(wsKey);
this._connect(); }, connectionDelayMs);
}, timeout);
} }
_ping() { private ping(wsKey: string) {
clearTimeout(this.pongTimeout!); this.clearPongTimer(wsKey);
delete this.pongTimeout;
this.logger.silly('Sending ping', { category: 'bybit-ws' }); this.logger.silly('Sending ping', { ...loggerCategory, wsKey });
this.ws.send(JSON.stringify({op: 'ping'})); this.tryWsSend(wsKey, JSON.stringify({ op: 'ping' }));
this.pongTimeout = setTimeout(() => { this.wsStore.get(wsKey, true)!.activePongTimer = setTimeout(() => {
this.logger.info('Pong timeout', { category: 'bybit-ws' }); this.logger.info('Pong timeout - closing socket to reconnect', { ...loggerCategory, wsKey });
this._teardown(); this.getWs(wsKey)?.close();
// this.ws.terminate();
// TODO: does this work?
this.ws.close();
}, this.options.pongTimeout); }, this.options.pongTimeout);
} }
_teardown() { private clearTimers(wsKey: string) {
if (this.pingInterval) clearInterval(this.pingInterval); this.clearPingTimer(wsKey);
if (this.pongTimeout) clearTimeout(this.pongTimeout); this.clearPongTimer(wsKey);
this.pongTimeout = undefined;
this.pingInterval = undefined;
} }
_wsOpenHandler() { // Send a ping at intervals
if (this.readyState === READY_STATE_CONNECTING) { private clearPingTimer(wsKey: string) {
this.logger.info('Websocket connected', { category: 'bybit-ws', livenet: this.options.livenet }); const wsState = this.wsStore.get(wsKey);
this.emit('open'); if (wsState?.activePingTimer) {
} else if (this.readyState === READY_STATE_RECONNECTING) { clearInterval(wsState.activePingTimer);
this.logger.info('Websocket reconnected', { category: 'bybit-ws', livenet: this.options.livenet }); wsState.activePingTimer = undefined;
this.emit('reconnected'); }
}
// 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.requestSubscribeTopics(wsKey, [...this.wsStore.getTopics(wsKey)]);
this.pingInterval = setInterval(this._ping.bind(this), this.options.pingInterval);
this.wsStore.get(wsKey, true)!.activePingTimer = setInterval(
() => this.ping(wsKey),
this.options.pingInterval
);
} }
_wsMessageHandler(message) { private onWsMessage(event, wsKey: string) {
const msg = JSON.parse(message && message.data || message); const msg = JSON.parse(event && event.data || event);
if ('success' in msg) { if ('success' in msg) {
this._handleResponse(msg); this.onWsMessageResponse(msg, wsKey);
} else if (msg.topic) { } else if (msg.topic) {
this._handleUpdate(msg); this.onWsMessageUpdate(msg);
} else { } else {
this.logger.warning('Got unhandled ws message', msg); this.logger.warning('Got unhandled ws message', { ...loggerCategory, message: msg, event, wsKey});
} }
} }
_wsOnErrorHandler(err) { private onWsError(err, wsKey: string) {
this.logger.error('Websocket error', {category: 'bybit-ws', err}); this.parseWsError('Websocket error', err, wsKey);
if (this.readyState === READY_STATE_CONNECTED) this.emit('error', err); if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTED)) {
this.emit('error', err);
}
} }
_wsCloseHandler() { private onWsClose(event, wsKey: string) {
this.logger.info('Websocket connection closed', {category: 'bybit-ws'}); this.logger.info('Websocket connection closed', { ...loggerCategory, wsKey});
if (this.readyState !== READY_STATE_CLOSING) { if (this.wsStore.getConnectionState(wsKey) !== READY_STATE_CLOSING) {
this._reconnect(this.options.reconnectTimeout); this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!);
this.emit('reconnect'); this.emit('reconnect');
} else { } else {
this.readyState = READY_STATE_INITIAL; this.setWsState(wsKey, READY_STATE_INITIAL);
this.emit('close'); this.emit('close');
} }
} }
_handleResponse(response) { private onWsMessageResponse(response: any, wsKey: string) {
if ( if (isWsPong(response)) {
response.request && this.logger.silly('Received pong', { ...loggerCategory, wsKey });
response.request.op === 'ping' && this.clearPongTimer(wsKey);
response.ret_msg === 'pong' &&
response.success === true
) {
this.logger.silly('pong recieved', {category: 'bybit-ws'});
clearTimeout(this.pongTimeout);
} else { } else {
this.emit('response', response); this.emit('response', response);
} }
} }
_handleUpdate(message) { private onWsMessageUpdate(message: any) {
this.emit('update', message); this.emit('update', message);
} }
_subscribe(topics) { private getWs(wsKey: string) {
const msgStr = JSON.stringify({ return this.wsStore.getWs(wsKey);
op: 'subscribe',
'args': topics
});
this.ws.send(msgStr);
} }
_unsubscribe(topics) { private setWsState(wsKey: string, state: WsConnectionState) {
const msgStr = JSON.stringify({ this.wsStore.setConnectionState(wsKey, state);
op: 'unsubscribe', }
'args': topics
});
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);
} }
}; };