diff --git a/README.md b/README.md index 694adbb..95cd303 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ This project uses typescript. Resources are stored in 3 key structures: - [src](./src) - the whole connector written in typescript - [lib](./lib) - the javascript version of the project (compiled from typescript). This should not be edited directly, as it will be overwritten with each release. - [dist](./dist) - the packed bundle of the project for use in browser environments. +- [examples](./examples) - some implementation examples & demonstrations. Contributions are welcome! --- @@ -42,7 +43,7 @@ There are three REST API modules as there are some differences in each contract 3. `LinearClient` for linear perpetual ### REST Inverse -
To use the inverse REST APIs, import the `InverseClient`. Click here to expand and see full sample: +To use the inverse REST APIs, import the `InverseClient`: ```javascript const { InverseClient } = require('bybit-api'); @@ -100,12 +101,11 @@ client.getOrderBook({ symbol: 'BTCUSD' }) }); ``` -
See [inverse-client.ts](./src/inverse-client.ts) for further information. ### REST Inverse Futures -
To use the inverse futures REST APIs, import the `InverseFuturesClient`. Click here to expand and see full sample: +To use the inverse futures REST APIs, import the `InverseFuturesClient`: ```javascript const { InverseFuturesClient } = require('bybit-api'); @@ -142,12 +142,10 @@ client.getOrderBook({ symbol: 'BTCUSDH21' }) }); ``` -
- See [inverse-futures-client.ts](./src/inverse-futures-client.ts) for further information. ### REST Linear -
To use the Linear (USDT) REST APIs, import the `LinearClient`. Click here to expand and see full sample: +To use the Linear (USDT) REST APIs, import the `LinearClient`: ```javascript const { LinearClient } = require('bybit-api'); @@ -184,10 +182,50 @@ client.getOrderBook({ symbol: 'BTCUSDT' }) }); ``` -
+See [linear-client.ts](./src/linear-client.ts) for further information. + +### REST Spot +To use the Spot REST APIs, import the `SpotClient`: + +```javascript +const { SpotClient } = require('bybit-api'); + +const API_KEY = 'xxx'; +const PRIVATE_KEY = 'yyy'; +const useLivenet = false; + +const client = new javascript( + API_KEY, + PRIVATE_KEY, + + // optional, uses testnet by default. Set to 'true' to use livenet. + useLivenet, + + // restClientOptions, + // requestLibraryOptions +); + +client.getSymbols() + .then(result => { + console.log(result); + }) + .catch(err => { + console.error(err); + }); + +client.getBalances() + .then(result => { + console.log("getBalances result: ", result); + }) + .catch(err => { + console.error("getBalances error: ", err); + }); +``` + +See [spot-client.ts](./src/spot-client.ts) for further information. ## WebSockets -
Inverse & linear WebSockets can be used via a shared `WebsocketClient`. Click here to expand and see full sample: +Inverse, linear & spot WebSockets can be used via a shared `WebsocketClient`. However, make sure to make one instance of WebsocketClient per market type (spot vs inverse vs linear vs linearfutures): ```javascript const { WebsocketClient } = require('bybit-api'); @@ -206,8 +244,12 @@ const wsConfig = { // defaults to false == testnet. Set to true for livenet. // livenet: true - // defaults to false == inverse. Set to true for linear (USDT) trading. - // linear: true + // NOTE: to listen to multiple markets (spot vs inverse vs linear vs linearfutures) at once, make one WebsocketClient instance per market + + // defaults to inverse: + // market: 'inverse' + // market: 'linear' + // market: 'spot' // how long to wait (in ms) before deciding the connection should be terminated & reconnected // pongTimeout: 1000, @@ -263,7 +305,6 @@ ws.on('error', err => { }); ``` -
See [websocket-client.ts](./src/websocket-client.ts) for further information. @@ -274,8 +315,6 @@ Note: for linear websockets, pass `linear: true` in the constructor options when ## 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. -
Click here to expand and see full sample: - ```javascript const { WebsocketClient, DefaultLogger } = require('bybit-api'); @@ -288,8 +327,6 @@ const ws = new WebsocketClient( ); ``` -
- ## Browser Usage Build a bundle using webpack: - `npm install` diff --git a/examples/rest-spot-public.ts b/examples/rest-spot-public.ts new file mode 100644 index 0000000..151d71d --- /dev/null +++ b/examples/rest-spot-public.ts @@ -0,0 +1,18 @@ +import { SpotClient } from '../src/index'; + +// or +// import { SpotClient } from 'bybit-api'; + +const client = new SpotClient(); + +const symbol = 'BTCUSDT'; + +(async () => { + try { + // console.log('getSymbols: ', await client.getSymbols()); + // console.log('getOrderBook: ', await client.getOrderBook(symbol)); + console.log('getOrderBook: ', await client.getOrderBook(symbol)); + } catch (e) { + console.error('request failed: ', e); + } +})(); diff --git a/examples/ws-public.ts b/examples/ws-public.ts new file mode 100644 index 0000000..130c87c --- /dev/null +++ b/examples/ws-public.ts @@ -0,0 +1,55 @@ +import { DefaultLogger } from '../src'; +import { WebsocketClient, wsKeySpotPublic } from '../src/websocket-client'; + +// or +// import { DefaultLogger, WebsocketClient } from 'bybit-api'; + +(async () => { + const logger = { + ...DefaultLogger, + // silly: () => {}, + }; + + const wsClient = new WebsocketClient({ + // key: key, + // secret: secret, + // market: 'inverse', + // market: 'linear', + market: 'spot', + }, logger); + + wsClient.on('update', (data) => { + console.log('raw message received ', JSON.stringify(data, null, 2)); + }); + + wsClient.on('open', (data) => { + console.log('connection opened open:', data.wsKey); + + if (data.wsKey === wsKeySpotPublic) { + // Spot public. + // wsClient.subscribePublicSpotTrades('BTCUSDT'); + // wsClient.subscribePublicSpotTradingPair('BTCUSDT'); + // wsClient.subscribePublicSpotV1Kline('BTCUSDT', '1m'); + // wsClient.subscribePublicSpotOrderbook('BTCUSDT', 'full'); + } + }); + wsClient.on('response', (data) => { + console.log('log response: ', JSON.stringify(data, null, 2)); + }); + wsClient.on('reconnect', ({ wsKey }) => { + console.log('ws automatically reconnecting.... ', wsKey); + }); + wsClient.on('reconnected', (data) => { + console.log('ws has reconnected ', data?.wsKey ); + }); + + // Inverse + // wsClient.subscribe('trade'); + + // Linear + // wsClient.subscribe('trade.BTCUSDT'); + + // For spot, request public connection first then send required topics on 'open' + // wsClient.connectPublic(); + +})(); diff --git a/package.json b/package.json index e989ea3..d9955ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "2.0.7", + "version": "2.1.0", "description": "Node.js connector for Bybit's REST APIs and WebSockets, with TypeScript & integration tests.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/index.ts b/src/index.ts index bdd9850..7f3aa47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from './inverse-client'; export * from './inverse-futures-client'; export * from './linear-client'; +export * from './spot-client'; export * from './websocket-client'; export * from './logger'; diff --git a/src/spot-client.ts b/src/spot-client.ts new file mode 100644 index 0000000..2c67f0a --- /dev/null +++ b/src/spot-client.ts @@ -0,0 +1,157 @@ +import { AxiosRequestConfig } from 'axios'; +import { KlineInterval } from './types/shared'; +import { NewSpotOrder, OrderSide, OrderTypeSpot, SpotOrderQueryById } from './types/spot'; +import BaseRestClient from './util/BaseRestClient'; +import { GenericAPIResponse, getRestBaseUrl, RestClientOptions } from './util/requestUtils'; +import RequestWrapper from './util/requestWrapper'; + +export class SpotClient extends BaseRestClient { + protected requestWrapper: RequestWrapper; + + /** + * @public Creates an instance of the Spot REST API client. + * + * @param {string} key - your API key + * @param {string} secret - your API secret + * @param {boolean} [useLivenet=false] + * @param {RestClientOptions} [restClientOptions={}] options to configure REST API connectivity + * @param {AxiosRequestConfig} [requestOptions={}] HTTP networking options for axios + */ + constructor( + key?: string | undefined, + secret?: string | undefined, + useLivenet: boolean = false, + restClientOptions: RestClientOptions = {}, + requestOptions: AxiosRequestConfig = {} + ) { + super(key, secret, getRestBaseUrl(useLivenet, restClientOptions), restClientOptions, requestOptions); + + // this.requestWrapper = new RequestWrapper( + // key, + // secret, + // getRestBaseUrl(useLivenet, restClientOptions), + // restClientOptions, + // requestOptions + // ); + return this; + } + + async getServerTime(urlKeyOverride?: string): Promise { + const result = await this.get('/spot/v1/time'); + return result.serverTime; + } + + /** + * + * Market Data Endpoints + * + **/ + + getSymbols() { + return this.get('/spot/v1/symbols'); + } + + getOrderBook(symbol: string, limit?: number) { + return this.get('/spot/quote/v1/depth', { + symbol, limit + }); + } + + getMergedOrderBook(symbol: string, scale?: number, limit?: number) { + return this.get('/spot/quote/v1/depth/merged', { + symbol, + scale, + limit, + }); + } + + getTrades(symbol: string, limit?: number) { + return this.get('/spot/v1/trades', { + symbol, + limit, + }); + } + + getCandles(symbol: string, interval: KlineInterval, limit?: number, startTime?: number, endTime?: number) { + return this.get('/spot/v1/trades', { + symbol, + interval, + limit, + startTime, + endTime, + }); + } + + get24hrTicker(symbol?: string) { + return this.get('/spot/quote/v1/ticker/24hr', { symbol }); + } + + getLastTradedPrice(symbol?: string) { + return this.get('/spot/quote/v1/ticker/price', { symbol }); + } + + getBestBidAskPrice(symbol?: string) { + return this.get('/spot/quote/v1/ticker/book_ticker', { symbol }); + } + + /** + * Account Data Endpoints + */ + + submitOrder(params: NewSpotOrder) { + return this.postPrivate('/spot/v1/order', params); + } + + getOrder(params: SpotOrderQueryById) { + return this.getPrivate('/spot/v1/order', params); + } + + cancelOrder(params: SpotOrderQueryById) { + return this.deletePrivate('/spot/v1/order', params); + } + + cancelOrderBatch(params: { + symbol: string; + side?: OrderSide; + orderTypes: OrderTypeSpot[] + }) { + const orderTypes = params.orderTypes ? params.orderTypes.join(',') : undefined; + return this.deletePrivate('/spot/order/batch-cancel', { + ...params, + orderTypes, + }); + } + + getOpenOrders(symbol?: string, orderId?: string, limit?: number) { + return this.getPrivate('/spot/v1/open-orders', { + symbol, + orderId, + limit, + }); + } + + getPastOrders(symbol?: string, orderId?: string, limit?: number) { + return this.getPrivate('/spot/v1/history-orders', { + symbol, + orderId, + limit, + }); + } + + getMyTrades(symbol?: string, limit?: number, fromId?: number, toId?: number) { + return this.getPrivate('/spot/v1/myTrades', { + symbol, + limit, + fromId, + toId, + }); + } + + /** + * Wallet Data Endpoints + */ + + getBalances() { + return this.getPrivate('/spot/v1/account'); + } +} diff --git a/src/types/shared.ts b/src/types/shared.ts new file mode 100644 index 0000000..73ea211 --- /dev/null +++ b/src/types/shared.ts @@ -0,0 +1,13 @@ +export type KlineInterval = '1m' + | '3m' + | '5m' + | '15m' + | '30m' + | '1h' + | '2h' + | '4h' + | '6h' + | '12h' + | '1d' + | '1w' + | '1M'; diff --git a/src/types/spot.ts b/src/types/spot.ts new file mode 100644 index 0000000..9e92081 --- /dev/null +++ b/src/types/spot.ts @@ -0,0 +1,18 @@ +export type OrderSide = 'Buy' | 'Sell'; +export type OrderTypeSpot = 'LIMIT' | 'MARKET' | 'LIMIT_MAKER'; +export type OrderTimeInForce = 'GTC' | 'FOK' | 'IOC'; + +export interface NewSpotOrder { + symbol: string; + qty: number; + side: OrderSide; + type: OrderTypeSpot; + timeInForce?: OrderTimeInForce; + price?: number; + orderLinkId?: string; +} + +export interface SpotOrderQueryById { + orderId?: string; + orderLinkId?: string; +} diff --git a/src/util/BaseRestClient.ts b/src/util/BaseRestClient.ts new file mode 100644 index 0000000..62db52e --- /dev/null +++ b/src/util/BaseRestClient.ts @@ -0,0 +1,208 @@ +import axios, { AxiosError, AxiosRequestConfig, AxiosResponse, Method } from 'axios'; + +import { signMessage } from './node-support'; +import { RestClientOptions, GenericAPIResponse, getRestBaseUrl, serializeParams, isPublicEndpoint } from './requestUtils'; + +export default abstract class BaseRestClient { + private timeOffset: number | null; + private syncTimePromise: null | Promise; + private options: RestClientOptions; + private baseUrl: string; + private globalRequestOptions: AxiosRequestConfig; + private key: string | undefined; + private secret: string | undefined; + + constructor( + key: string | undefined, + secret: string | undefined, + baseUrl: string, + options: RestClientOptions = {}, + requestOptions: AxiosRequestConfig = {} + ) { + this.timeOffset = null; + this.syncTimePromise = null; + + this.options = { + recv_window: 5000, + // how often to sync time drift with bybit servers + sync_interval_ms: 3600000, + // if true, we'll throw errors if any params are undefined + strict_param_validation: false, + ...options + }; + + this.globalRequestOptions = { + // in ms == 5 minutes by default + timeout: 1000 * 60 * 5, + // custom request options based on axios specs - see: https://github.com/axios/axios#request-config + ...requestOptions, + headers: { + 'x-referer': 'bybitapinode' + }, + }; + + this.baseUrl = baseUrl; + + if (key && !secret) { + throw new Error('API Key & Secret are both required for private enpoints') + } + + if (this.options.disable_time_sync !== true) { + this.syncTime(); + setInterval(this.syncTime.bind(this), +this.options.sync_interval_ms!); + } + + this.key = key; + this.secret = secret; + } + + get(endpoint: string, params?: any): GenericAPIResponse { + return this._call('GET', endpoint, params, true); + } + + post(endpoint: string, params?: any): GenericAPIResponse { + return this._call('POST', endpoint, params, true); + } + + getPrivate(endpoint: string, params?: any): GenericAPIResponse { + return this._call('GET', endpoint, params, false); + } + + postPrivate(endpoint: string, params?: any): GenericAPIResponse { + return this._call('POST', endpoint, params, false); + } + + deletePrivate(endpoint: string, params?: any): GenericAPIResponse { + return this._call('DELETE', endpoint, params, false); + } + + /** + * @private Make a HTTP request to a specific endpoint. Private endpoints are automatically signed. + */ + private async _call(method: Method, endpoint: string, params?: any, isPublicApi?: boolean): GenericAPIResponse { + if (!isPublicApi) { + if (!this.key || !this.secret) { + throw new Error('Private endpoints require api and private keys set'); + } + + if (this.timeOffset === null) { + await this.syncTime(); + } + + params = await this.signRequest(params); + } + + const options = { + ...this.globalRequestOptions, + url: [this.baseUrl, endpoint].join(endpoint.startsWith('/') ? '' : '/'), + method: method, + json: true + }; + + if (method === 'GET') { + options.params = params; + } else { + options.data = params; + } + + return axios(options).then(response => { + if (response.status == 200) { + return response.data; + } + + throw response; + }).catch(e => this.parseException(e)); + } + + /** + * @private generic handler to parse request exceptions + */ + parseException(e: any): unknown { + if (this.options.parse_exceptions === false) { + throw e; + } + + // Something happened in setting up the request that triggered an Error + if (!e.response) { + if (!e.request) { + throw e.message; + } + + // request made but no response received + throw e; + } + + // The request was made and the server responded with a status code + // that falls out of the range of 2xx + const response: AxiosResponse = e.response; + throw { + code: response.status, + message: response.statusText, + body: response.data, + headers: response.headers, + requestOptions: this.options + }; + } + + /** + * @private sign request and set recv window + */ + async signRequest(data: any): Promise { + const params = { + ...data, + api_key: this.key, + timestamp: Date.now() + (this.timeOffset || 0) + }; + + // Optional, set to 5000 by default. Increase if timestamp/recv_window errors are seen. + if (this.options.recv_window && !params.recv_window) { + params.recv_window = this.options.recv_window; + } + + if (this.key && this.secret) { + const serializedParams = serializeParams(params, this.options.strict_param_validation); + params.sign = await signMessage(serializedParams, this.secret); + } + + return params; + } + + /** + * Trigger time sync and store promise + */ + private syncTime(): GenericAPIResponse { + if (this.options.disable_time_sync === true) { + return Promise.resolve(false); + } + + if (this.syncTimePromise !== null) { + return this.syncTimePromise; + } + + this.syncTimePromise = this.fetchTimeOffset().then(offset => { + this.timeOffset = offset; + this.syncTimePromise = null; + }); + + return this.syncTimePromise; + } + + abstract getServerTime(baseUrlKeyOverride?: string): Promise; + + /** + * Estimate drift based on client<->server latency + */ + async fetchTimeOffset(): Promise { + try { + const start = Date.now(); + const serverTime = await this.getServerTime(); + const end = Date.now(); + + const avgDrift = ((end - start) / 2); + return Math.ceil(serverTime - end + avgDrift); + } catch (e) { + console.error('Failed to fetch get time offset: ', e); + return 0; + } + } +}; diff --git a/src/util/requestUtils.ts b/src/util/requestUtils.ts index 00dbe51..d75a0a5 100644 --- a/src/util/requestUtils.ts +++ b/src/util/requestUtils.ts @@ -61,6 +61,9 @@ export function isPublicEndpoint (endpoint: string): boolean { } export function isWsPong(response: any) { + if (response.pong) { + return true; + } return ( response.request && response.request.op === 'ping' && diff --git a/src/websocket-client.ts b/src/websocket-client.ts index fa9576f..e3829be 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -4,6 +4,7 @@ import WebSocket from 'isomorphic-ws'; import { InverseClient } from './inverse-client'; import { LinearClient } from './linear-client'; import { DefaultLogger } from './logger'; +import { KlineInterval } from './types/shared'; import { signMessage } from './util/node-support'; import { serializeParams, isWsPong } from './util/requestUtils'; @@ -17,16 +18,29 @@ const inverseEndpoints = { const linearEndpoints = { private: { livenet: 'wss://stream.bybit.com/realtime_private', - livenet2: 'wss://stream.bytick.com/realtime_public', + livenet2: 'wss://stream.bytick.com/realtime_private', testnet: 'wss://stream-testnet.bybit.com/realtime_private' }, public: { livenet: 'wss://stream.bybit.com/realtime_public', - livenet2: 'wss://stream.bytick.com/realtime_private', + livenet2: 'wss://stream.bytick.com/realtime_public', testnet: 'wss://stream-testnet.bybit.com/realtime_public' } }; +const spotEndpoints = { + private: { + livenet: 'wss://stream.bybit.com/spot/ws', + testnet: 'wss://stream-testnet.bybit.com/spot/ws', + }, + public: { + livenet: 'wss://stream.bybit.com/spot/quote/ws/v1', + livenet2: 'wss://stream.bybit.com/spot/quote/ws/v2', + testnet: 'wss://stream-testnet.bybit.com/spot/quote/ws/v1', + testnet2: 'wss://stream-testnet.bybit.com/spot/quote/ws/v2', + } +} + const loggerCategory = { category: 'bybit-ws' }; const READY_STATE_INITIAL = 0; @@ -43,11 +57,78 @@ export enum WsConnectionState { READY_STATE_RECONNECTING }; +export type APIMarket = 'inverse' | 'linear' | 'spot'; + +// Same as inverse futures +export type WsPublicInverseTopic = 'orderBookL2_25' + | 'orderBookL2_200' + | 'trade' + | 'insurance' + | 'instrument_info' + | 'klineV2'; + +export type WsPublicUSDTPerpTopic = 'orderBookL2_25' + | 'orderBookL2_200' + | 'trade' + | 'insurance' + | 'instrument_info' + | 'kline'; + +export type WsPublicSpotV1Topic = 'trade' + | 'realtimes' + | 'kline' + | 'depth' + | 'mergedDepth' + | 'diffDepth'; + +export type WsPublicSpotV2Topic = 'depth' + | 'kline' + | 'trade' + | 'bookTicker' + | 'realtimes'; + +export type WsPublicTopics = WsPublicInverseTopic + | WsPublicUSDTPerpTopic + | WsPublicSpotV1Topic + | WsPublicSpotV2Topic + | string; + +// Same as inverse futures +export type WsPrivateInverseTopic = 'position' + | 'execution' + | 'order' + | 'stop_order'; + +export type WsPrivateUSDTPerpTopic = 'position' + | 'execution' + | 'order' + | 'stop_order' + | 'wallet'; + +export type WsPrivateSpotTopic = 'outboundAccountInfo' + | 'executionReport' + | 'ticketInfo'; + +export type WsPrivateTopic = WsPrivateInverseTopic + | WsPrivateUSDTPerpTopic + | WsPrivateSpotTopic + | string; + +export type WsTopic = WsPublicTopics | WsPrivateTopic; + export interface WSClientConfigurableOptions { key?: string; secret?: string; livenet?: boolean; + + // defaults to inverse. + /** + * @deprecated Use the property { market: 'linear' } instead + */ linear?: boolean; + + market?: APIMarket; + pongTimeout?: number; pingInterval?: number; reconnectTimeout?: number; @@ -58,7 +139,11 @@ export interface WSClientConfigurableOptions { export interface WebsocketClientOptions extends WSClientConfigurableOptions { livenet: boolean; - linear: boolean; + /** + * @deprecated Use the property { market: 'linear' } instead + */ + linear?: boolean; + market?: APIMarket; pongTimeout: number; pingInterval: number; reconnectTimeout: number; @@ -68,9 +153,11 @@ export interface WebsocketClientOptions extends WSClientConfigurableOptions { export const wsKeyInverse = 'inverse'; export const wsKeyLinearPrivate = 'linearPrivate'; export const wsKeyLinearPublic = 'linearPublic'; +export const wsKeySpotPrivate = 'spotPrivate'; +export const wsKeySpotPublic = 'spotPublic'; // This is used to differentiate between each of the available websocket streams (as bybit has multiple websockets) -export type WsKey = 'inverse' | 'linearPrivate' | 'linearPublic'; +export type WsKey = 'inverse' | 'linearPrivate' | 'linearPublic' | 'spotPrivate' | 'spotPublic'; const getLinearWsKeyForTopic = (topic: string): WsKey => { const privateLinearTopics = ['position', 'execution', 'order', 'stop_order', 'wallet']; @@ -80,11 +167,27 @@ const getLinearWsKeyForTopic = (topic: string): WsKey => { return wsKeyLinearPublic; } +const getSpotWsKeyForTopic = (topic: string): WsKey => { + const privateLinearTopics = ['position', 'execution', 'order', 'stop_order', 'outboundAccountInfo', 'executionReport', 'ticketInfo']; + + if (privateLinearTopics.includes(topic)) { + return wsKeySpotPrivate; + } + + return wsKeySpotPublic; +} export declare interface WebsocketClient { on(event: 'open' | 'reconnected', listener: ({ wsKey: WsKey, event: any }) => void): this; on(event: 'response' | 'update' | 'error', listener: (response: any) => void): this; - on(event: 'reconnect' | 'close', listener: () => void): this; + on(event: 'reconnect' | 'close', listener: ({ wsKey: WsKey }) => void): this; +} + +function resolveMarket(options: WSClientConfigurableOptions): APIMarket { + if (options.linear) { + return 'linear'; + } + return 'inverse'; } export class WebsocketClient extends EventEmitter { @@ -101,15 +204,22 @@ export class WebsocketClient extends EventEmitter { this.options = { livenet: false, - linear: false, pongTimeout: 1000, pingInterval: 10000, reconnectTimeout: 500, ...options }; + if (!this.options.market) { + this.options.market = resolveMarket(this.options); + } + if (this.isLinear()) { this.restClient = new LinearClient(undefined, undefined, this.isLivenet(), this.options.restOptions, this.options.requestOptions); + } else if (this.isSpot()) { + // TODO: spot client + this.restClient = new LinearClient(undefined, undefined, this.isLivenet(), this.options.restOptions, this.options.requestOptions); + this.connectPublic(); } else { this.restClient = new InverseClient(undefined, undefined, this.isLivenet(), this.options.restOptions, this.options.requestOptions); } @@ -120,17 +230,21 @@ export class WebsocketClient extends EventEmitter { } public isLinear(): boolean { - return this.options.linear === true; + return this.options.market === 'linear'; + } + + public isSpot(): boolean { + return this.options.market === 'spot'; } public isInverse(): boolean { - return !this.isLinear(); + return !this.isLinear() && !this.isSpot(); } /** * Add topic/topics to WS subscription list */ - public subscribe(wsTopics: string[] | string) { + public subscribe(wsTopics: WsTopic[] | WsTopic) { const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; topics.forEach(topic => this.wsStore.addTopic( this.getWsKeyForTopic(topic), @@ -157,7 +271,7 @@ export class WebsocketClient extends EventEmitter { /** * Remove topic/topics from WS subscription list */ - public unsubscribe(wsTopics: string[] | string) { + public unsubscribe(wsTopics: WsTopic[] | WsTopic) { const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; topics.forEach(topic => this.wsStore.deleteTopic( this.getWsKeyForTopic(topic), @@ -191,6 +305,38 @@ export class WebsocketClient extends EventEmitter { if (this.isLinear()) { return [this.connect(wsKeyLinearPublic), this.connect(wsKeyLinearPrivate)]; } + + if (this.isSpot()) { + return [this.connect(wsKeySpotPublic), this.connect(wsKeySpotPrivate)]; + } + } + + public connectPublic(): Promise | undefined { + if (this.isInverse()) { + return this.connect(wsKeyInverse); + } + + if (this.isLinear()) { + return this.connect(wsKeyLinearPublic); + } + + if (this.isSpot()) { + return this.connect(wsKeySpotPublic); + } + } + + public connectPrivate(): Promise | undefined { + if (this.isInverse()) { + return this.connect(wsKeyInverse); + } + + if (this.isLinear()) { + return this.connect(wsKeyLinearPrivate); + } + + if (this.isSpot()) { + return this.connect(wsKeySpotPrivate); + } } private async connect(wsKey: WsKey): Promise { @@ -246,7 +392,7 @@ export class WebsocketClient extends EventEmitter { private async getAuthParams(wsKey: WsKey): Promise { const { key, secret } = this.options; - if (key && secret && wsKey !== wsKeyLinearPublic) { + if (key && secret && wsKey !== wsKeyLinearPublic && wsKey !== wsKeySpotPublic) { this.logger.debug('Getting auth\'d request params', { ...loggerCategory, wsKey }); const timeOffset = await this.restClient.getTimeOffset(); @@ -319,6 +465,9 @@ export class WebsocketClient extends EventEmitter { * Send WS message to subscribe to topics. */ private requestSubscribeTopics(wsKey: WsKey, topics: string[]) { + if (!topics.length) { + return; + } const wsMessage = JSON.stringify({ op: 'subscribe', args: topics @@ -331,6 +480,9 @@ export class WebsocketClient extends EventEmitter { * Send WS message to unsubscribe from topics. */ private requestUnsubscribeTopics(wsKey: WsKey, topics: string[]) { + if (!topics.length) { + return; + } const wsMessage = JSON.stringify({ op: 'unsubscribe', args: topics @@ -345,7 +497,11 @@ export class WebsocketClient extends EventEmitter { if (!wsKey) { throw new Error('Cannot send message due to no known websocket for this wsKey'); } - this.getWs(wsKey)?.send(wsMessage); + const ws = this.getWs(wsKey); + if (!ws) { + throw new Error(`${wsKey} socket not connected yet, call "connect(${wsKey}) first then try again when the "open" event arrives`); + } + ws.send(wsMessage); } catch (e) { this.logger.error(`Failed to send WS message`, { ...loggerCategory, wsMessage, wsKey, exception: e }); } @@ -365,7 +521,7 @@ export class WebsocketClient extends EventEmitter { private onWsOpen(event, wsKey: WsKey) { if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTING)) { - this.logger.info('Websocket connected', { ...loggerCategory, wsKey, livenet: this.isLivenet(), linear: this.isLinear() }); + this.logger.info('Websocket connected', { ...loggerCategory, wsKey, livenet: this.isLivenet(), linear: this.isLinear(), spot: this.isSpot() }); this.emit('open', { wsKey, event }); } else if (this.wsStore.isConnectionState(wsKey, READY_STATE_RECONNECTING)) { this.logger.info('Websocket reconnected', { ...loggerCategory, wsKey }); @@ -374,7 +530,10 @@ export class WebsocketClient extends EventEmitter { this.setWsState(wsKey, READY_STATE_CONNECTED); - this.requestSubscribeTopics(wsKey, [...this.wsStore.getTopics(wsKey)]); + // TODO: persistence not working yet for spot topics + if (wsKey !== 'spotPublic' && wsKey !== 'spotPrivate') { + this.requestSubscribeTopics(wsKey, [...this.wsStore.getTopics(wsKey)]); + } this.wsStore.get(wsKey, true)!.activePingTimer = setInterval( () => this.ping(wsKey), @@ -383,14 +542,17 @@ export class WebsocketClient extends EventEmitter { } private onWsMessage(event, wsKey: WsKey) { - const msg = JSON.parse(event && event.data || event); - - if ('success' in msg) { - this.onWsMessageResponse(msg, wsKey); - } else if (msg.topic) { - this.onWsMessageUpdate(msg); - } else { - this.logger.warning('Got unhandled ws message', { ...loggerCategory, message: msg, event, wsKey}); + try { + const msg = JSON.parse(event && event.data || event); + if ('success' in msg || msg?.pong) { + this.onWsMessageResponse(msg, wsKey); + } else if (msg.topic) { + this.onWsMessageUpdate(msg); + } else { + this.logger.warning('Got unhandled ws message', { ...loggerCategory, message: msg, event, wsKey}); + } + } catch (e) { + this.logger.error('Failed to parse ws event message', { ...loggerCategory, error: e, event, wsKey}) } } @@ -406,10 +568,10 @@ export class WebsocketClient extends EventEmitter { if (this.wsStore.getConnectionState(wsKey) !== READY_STATE_CLOSING) { this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!); - this.emit('reconnect'); + this.emit('reconnect', { wsKey }); } else { this.setWsState(wsKey, READY_STATE_INITIAL); - this.emit('close'); + this.emit('close', { wsKey }); } } @@ -439,7 +601,8 @@ export class WebsocketClient extends EventEmitter { return this.options.wsUrl; } - const networkKey = this.options.livenet ? 'livenet' : 'testnet'; + const networkKey = this.isLivenet() ? 'livenet' : 'testnet'; + // TODO: reptitive if (this.isLinear() || wsKey.startsWith('linear')){ if (wsKey === wsKeyLinearPublic) { return linearEndpoints.public[networkKey]; @@ -452,10 +615,123 @@ export class WebsocketClient extends EventEmitter { this.logger.error('Unhandled linear wsKey: ', { ...loggerCategory, wsKey }); return linearEndpoints[networkKey]; } + + if (this.isSpot() || wsKey.startsWith('spot')){ + if (wsKey === wsKeySpotPublic) { + return spotEndpoints.public[networkKey]; + } + + if (wsKey === wsKeySpotPrivate) { + return spotEndpoints.private[networkKey]; + } + + this.logger.error('Unhandled spot wsKey: ', { ...loggerCategory, wsKey }); + return spotEndpoints[networkKey]; + } + + // fallback to inverse return inverseEndpoints[networkKey]; } private getWsKeyForTopic(topic: string) { - return this.isInverse() ? wsKeyInverse : getLinearWsKeyForTopic(topic); + if (this.isInverse()) { + return wsKeyInverse; + } + if (this.isLinear()) { + return getLinearWsKeyForTopic(topic) + } + return getSpotWsKeyForTopic(topic); } + + private wrongMarketError(market: APIMarket) { + return new Error(`This WS client was instanced for the ${this.options.market} market. Make another WebsocketClient instance with "market: '${market}' to listen to spot topics`); + } + + // TODO: persistance for subbed topics. Look at ftx-api implementation. + public subscribePublicSpotTrades(symbol: string, binary?: boolean) { + if (!this.isSpot()) { + throw this.wrongMarketError('spot'); + } + + return this.tryWsSend(wsKeySpotPublic, JSON.stringify({ + topic: 'trade', + event: 'sub', + symbol, + params: { + binary: !!binary, + } + })); + } + + public subscribePublicSpotTradingPair(symbol: string, binary?: boolean) { + if (!this.isSpot()) { + throw this.wrongMarketError('spot'); + } + + return this.tryWsSend(wsKeySpotPublic, JSON.stringify({ + symbol, + topic: 'realtimes', + event: 'sub', + params: { + binary: !!binary, + }, + })); + } + + public subscribePublicSpotV1Kline(symbol: string, candleSize: KlineInterval, binary?: boolean) { + if (!this.isSpot()) { + throw this.wrongMarketError('spot'); + } + + return this.tryWsSend(wsKeySpotPublic, JSON.stringify({ + symbol, + topic: 'kline_' + candleSize, + event: 'sub', + params: { + binary: !!binary, + }, + })); + } + + //ws.send('{"symbol":"BTCUSDT","topic":"depth","event":"sub","params":{"binary":false}}'); + //ws.send('{"symbol":"BTCUSDT","topic":"mergedDepth","event":"sub","params":{"binary":false,"dumpScale":1}}'); + //ws.send('{"symbol":"BTCUSDT","topic":"diffDepth","event":"sub","params":{"binary":false}}'); + public subscribePublicSpotOrderbook(symbol: string, depth: 'full' | 'merge' | 'delta', dumpScale?: number, binary?: boolean) { + if (!this.isSpot()) { + throw this.wrongMarketError('spot'); + } + + let topic: string; + switch (depth) { + case 'full': { + topic = 'depth'; + break; + }; + case 'merge': { + topic = 'mergedDepth'; + if (!dumpScale) { + throw new Error(`Dumpscale must be provided for merged orderbooks`); + } + break; + } + case 'delta': { + topic = 'diffDepth'; + break; + } + } + + const msg: any = { + symbol, + topic, + event: 'sub', + params: { + binary: !!binary, + }, + }; + if (dumpScale) { + msg.params.dumpScale = dumpScale; + } + return this.tryWsSend(wsKeySpotPublic, JSON.stringify(msg)); + } + };